From 91971e917482bc16554897ff8e3c0efbf944785a Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Mon, 29 Dec 2025 04:29:17 +0700 Subject: [PATCH 01/14] Attempt for parsing NativeData response --- .../Versioning/StarRail/VersionCheck.cs | 3 +- .../Classes/GamePresetProperty.cs | 2 +- ...cUnixStampToDateTimeOffsetJsonConverter.cs | 67 +++++++ .../StarRail/StarRailInstall.cs | 1 - .../Interfaces/Class/GamePropertyBase.cs | 4 +- .../HonkaiV2/HonkaiRepairV2.Fetch.cs | 62 +------ .../SophonRepairFetchUtility.cs | 96 ++++++++++ .../RepairManagement/StarRail/Fetch.cs | 104 +++++++---- .../StarRail/StarRailPersistentRefResult.cs | 117 ++++++++++++ .../StarRail/StarRailRepair.cs | 11 +- .../Struct/Assets/StarRailBinaryDataNative.cs | 170 ++++++++++++++++++ .../StarRail/Struct/StarRailBinaryData.cs | 55 ++++++ .../Struct/StarRailBinaryDataExtension.cs | 105 +++++++++++ Hi3Helper.EncTool | 2 +- 14 files changed, 697 insertions(+), 102 deletions(-) create mode 100644 CollapseLauncher/Classes/Helper/JsonConverter/UtcUnixStampToDateTimeOffsetJsonConverter.cs create mode 100644 CollapseLauncher/Classes/RepairManagement/SophonRepairFetchUtility.cs create mode 100644 CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.cs create mode 100644 CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailBinaryDataNative.cs create mode 100644 CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryData.cs create mode 100644 CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataExtension.cs 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..159dbc8a9 100644 --- a/CollapseLauncher/Classes/GamePresetProperty.cs +++ b/CollapseLauncher/Classes/GamePresetProperty.cs @@ -62,7 +62,7 @@ internal static GamePresetProperty Create(UIElement uiElementParent, ILauncherAp 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.GameRepair = new StarRailRepair(uiElementParent, property.GameVersion, property.GameSettings); break; case GameNameType.Genshin: property.GameVersion = new GameTypeGenshinVersion(launcherApis, 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/InstallManagement/StarRail/StarRailInstall.cs b/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.cs index af182cf6d..c03458352 100644 --- a/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.cs +++ b/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.cs @@ -93,7 +93,6 @@ public override async ValueTask StartPackageVerification(List new StarRailRepair(ParentUI, GameVersionManager, - this, GameSettings, true, versionString); 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/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/SophonRepairFetchUtility.cs b/CollapseLauncher/Classes/RepairManagement/SophonRepairFetchUtility.cs new file mode 100644 index 000000000..ffc75af88 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/SophonRepairFetchUtility.cs @@ -0,0 +1,96 @@ +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/Fetch.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs index 0546e066f..ac32206fb 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs @@ -1,8 +1,10 @@ using CollapseLauncher.Helper; using CollapseLauncher.Helper.Metadata; +using CollapseLauncher.RepairManagement; using Hi3Helper; using Hi3Helper.Data; using Hi3Helper.EncTool.Parser.AssetIndex; +using Hi3Helper.EncTool.Parser.AssetMetadata; using Hi3Helper.EncTool.Parser.AssetMetadata.SRMetadataAsset; using Hi3Helper.Http; using Hi3Helper.Shared.ClassStruct; @@ -25,6 +27,7 @@ // ReSharper disable StringLiteralTypo // ReSharper disable SwitchStatementHandlesSomeKnownEnumValuesWithDefault +#nullable enable namespace CollapseLauncher { internal static partial class StarRailRepairExtension @@ -101,13 +104,32 @@ private async Task Fetch(List assetIndex, CancellationToke } } + string regionId = GetExistingGameRegionID(); + + // 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); + + Task persistentRef = StarRailPersistentRefResult + .GetReferenceAsync(this, dispatcherInfo, client, GameDataPersistentPath, token); + } + // 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"))) + if (!IsVersionOverride && !IsOnlyRecoverMain && await InnerGameVersionManager.StarRailMetadataTool.Initialize(token, downloadClient, _httpClient_FetchAssetProgress, regionId, Path.Combine(GamePath, $"{Path.GetFileNameWithoutExtension(InnerGameVersionManager.GamePreset.GameExecutableName)}_Data\\Persistent"))) { await Task.WhenAll( // Read Block metadata @@ -147,43 +169,37 @@ await Task.WhenAll( #region PrimaryManifest private async Task GetPrimaryManifest(List assetIndex, CancellationToken token) { - // Initialize pkgVersion list - List pkgVersion = []; + // 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. - // Initialize repo metadata - try - { - // Get the metadata - Dictionary repoMetadata = await FetchMetadata(token); + HttpClient client = FallbackCDNUtil.GetGlobalHttpClient(true); - // 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; + string[] excludedMatchingField = ["en-us", "zh-cn", "ja-jp", "ko-kr"]; + if (File.Exists(GameAudioLangListPathStatic)) + { + string[] installedAudioLang = (await File.ReadAllLinesAsync(GameAudioLangListPathStatic, token)) + .Select(x => x switch + { + "English" => "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(); } - // 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(); + await this.FetchAssetsFromSophonAsync(client, + assetIndex, + DetermineFileTypeFromExtension, + GameVersion, + excludedMatchingField, + token); } private async Task> FetchMetadata(CancellationToken token) @@ -215,6 +231,26 @@ private void ConvertPkgVersionToAssetIndex(List pkgVersion #endregion #region Utilities + private 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 FilePropertiesRemote GetNormalizedFilePropertyTypeBased(string remoteParentURL, string remoteRelativePath, long fileSize, diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.cs new file mode 100644 index 000000000..801431c03 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.cs @@ -0,0 +1,117 @@ +using CollapseLauncher.Helper.JsonConverter; +using CollapseLauncher.Helper.StreamUtility; +using CollapseLauncher.RepairManagement.StarRail.Struct.Assets; +using Hi3Helper; +using Hi3Helper.Data; +using Hi3Helper.EncTool; +using Hi3Helper.EncTool.Parser.AssetMetadata; +using Hi3Helper.EncTool.Proto.StarRail; +using Microsoft.UI.Xaml.Shapes; +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Path = System.IO.Path; + +#nullable enable +#pragma warning disable IDE0130 +namespace CollapseLauncher; + +internal class StarRailPersistentRefResult +{ + public static async Task GetReferenceAsync( + StarRailRepair instance, + SRDispatcherInfo dispatcherInfo, + HttpClient client, + string persistentDir, + CancellationToken token) + { + StarRailGatewayStatic gateway = dispatcherInfo.RegionGateway; + Dictionary gatewayKvp = gateway.ValuePairs; + + string mainUrlAsb = gatewayKvp["AssetBundleVersionUpdateUrl"]; + string mainUrlDesignData = gatewayKvp["DesignDataBundleVersionUpdateUrl"]; + + string mInfoDesignArchiveUrl = mainUrlDesignData.CombineURLFromString("client/Windows/M_Design_ArchiveV.bytes"); + Dictionary mInfoDesignArchive = await StarRailRefMainInfo + .ParseListFromUrlAsync(instance, + client, + mInfoDesignArchiveUrl, + token); + + string mInfoArchiveUrl = mainUrlAsb.CombineURLFromString("client/Windows/Archive/M_ArchiveV.bytes"); + Dictionary mInfoArchive = await StarRailRefMainInfo + .ParseListFromUrlAsync(instance, + client, + mInfoArchiveUrl, + token); + + await using FileStream stream = + File.OpenRead(@"C:\Users\neon-nyan\AppData\LocalLow\CollapseLauncher\GameFolder\SRGlb\Games\StarRail_Data\StreamingAssets\NativeData\Windows\NativeDataV_2ebcf9e27323a0561ef4825a14819ed5.bytes"); + + StarRailBinaryDataNative binaryRefNativeData = new(); + await binaryRefNativeData.ParseAsync(stream, token); + + return default; + } +} + +[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; + + public static async Task> ParseListFromUrlAsync( + StarRailRepair instance, + HttpClient client, + string url, + CancellationToken token) + { + // 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; + + Dictionary returnList = []; + using StreamReader reader = new(networkStream); + while (await reader.ReadLineAsync(token) is { } line) + { + StarRailRefMainInfo refInfo = line.Deserialize(StarRailRepairJsonContext.Default.StarRailRefMainInfo) + ?? throw new NullReferenceException(); + + returnList.Add(refInfo.UnaliasedFileName, refInfo); + } + + return returnList; + } +} \ No newline at end of file diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs index b7f993867..72beddad7 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs @@ -1,6 +1,7 @@ using CollapseLauncher.GameVersioning; using CollapseLauncher.InstallManager.StarRail; using CollapseLauncher.Interfaces; +using CollapseLauncher.Statics; using Hi3Helper.Data; using Hi3Helper.Shared.ClassStruct; using Microsoft.UI.Xaml; @@ -23,8 +24,12 @@ public override string GamePath set => GameVersionManager.GameDirPath = value; } - private GameTypeStarRailVersion InnerGameVersionManager { get; } - private StarRailInstall InnerGameInstaller { get; } + private GameTypeStarRailVersion InnerGameVersionManager { get; } + private StarRailInstall InnerGameInstaller + { + get => field ??= GamePropertyVault.GetCurrentGameProperty().GameInstall as StarRailInstall; + } + private bool IsOnlyRecoverMain { get; } private List OriginAssetIndex { get; set; } private string ExecName { get; } @@ -61,7 +66,6 @@ private string GameAudioLangListPath public StarRailRepair( UIElement parentUI, IGameVersion gameVersionManager, - IGameInstallManager gameInstallManager, IGameSettings gameSettings, bool onlyRecoverMainAsset = false, string versionOverride = null) @@ -74,7 +78,6 @@ public StarRailRepair( // Get flag to only recover main assets IsOnlyRecoverMain = onlyRecoverMainAsset; InnerGameVersionManager = gameVersionManager as GameTypeStarRailVersion; - InnerGameInstaller = gameInstallManager as StarRailInstall; ExecName = Path.GetFileNameWithoutExtension(InnerGameVersionManager!.GamePreset.GameExecutableName); } diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailBinaryDataNative.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailBinaryDataNative.cs new file mode 100644 index 000000000..6f96d2803 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailBinaryDataNative.cs @@ -0,0 +1,170 @@ +using Hi3Helper.EncTool; +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; +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +internal class StarRailBinaryDataNative : StarRailBinaryData +{ + protected override ReadOnlySpan MagicSignature => "SRBM"u8; + + protected override async ValueTask ReadDataCoreAsync(long currentOffset, Stream dataStream, CancellationToken token) + { + (HeaderStruct header, int readHeader) = await dataStream + .ReadDataAssertAndSeekAsync(x => x.AssetInfoStructSize, token) + .ConfigureAwait(false); + currentOffset += readHeader; + + Debug.Assert(header.FilenameBufferStartOffset == currentOffset); // ASSERT: Make sure the current data stream offset is at the exact filename buffer offset. + Debug.Assert(header.AssetCount is < ushort.MaxValue and > 0); // ASSERT: Make sure the data isn't more than ushort.MaxValue and not empty. + + // Read file info + int fileInfoBufferLen = header.AssetInfoStructSize * header.AssetCount; + byte[] filenameBuffer = ArrayPool.Shared.Rent(header.FilenameBufferSize); + byte[] fileInfoBuffer = ArrayPool.Shared.Rent(fileInfoBufferLen); + try + { + + // Read filename buffer + int read = await dataStream.ReadAtLeastAsync(filenameBuffer.AsMemory(0, header.FilenameBufferSize), + header.FilenameBufferSize, + cancellationToken: token) + .ConfigureAwait(false); + Debug.Assert(header.FilenameBufferSize == read); // ASSERT: Make sure the filename buffer size is equal as what we read. + + currentOffset += read; + int paddingOffsetToInfoBuffer = header.AssetInfoAbsoluteStartOffset - (int)currentOffset; + await dataStream.SeekForwardAsync(paddingOffsetToInfoBuffer, token); // ASSERT: Seek forward to the asset info buffer. + + // Read file info buffer + read = await dataStream.ReadAtLeastAsync(fileInfoBuffer.AsMemory(0, fileInfoBufferLen), + fileInfoBufferLen, + cancellationToken: token); + Debug.Assert(fileInfoBufferLen == read); // ASSERT: Make sure the asset info struct size is equal as what we read. + currentOffset += read; + + // Create spans + Span filenameBufferSpan = filenameBuffer.AsSpan(0, header.FilenameBufferSize); + Span fileInfoBufferSpan = fileInfoBuffer.AsSpan(0, fileInfoBufferLen); + + // Allocate list + DataList = new List(header.AssetCount); + + for (int i = 0; i < header.AssetCount && !filenameBufferSpan.IsEmpty; i++) + { + ref StarRailAssetNative.StarRailAssetNativeInfo fileInfo = + ref MemoryMarshal.AsRef(fileInfoBufferSpan); + filenameBufferSpan = StarRailAssetNative.Parse(filenameBufferSpan, + ref fileInfo, + out StarRailAssetNative? asset); + fileInfoBufferSpan = fileInfoBufferSpan[header.AssetInfoStructSize..]; + + 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 HeaderStruct + { + public int Reserved; + /// + /// The absolute offset of struct array in the data stream. + /// + public int AssetInfoAbsoluteStartOffset; + /// + /// The count of the struct array. + /// + public int AssetCount; + /// + /// The size of the struct. + /// + public int AssetInfoStructSize; + public int Unknown1; + /// + /// The absolute offset of filename buffer in the data stream. + /// + public int FilenameBufferStartOffset; + public int Unknown2; + /// + /// The size of the filename buffer in bytes. + /// + public int FilenameBufferSize; + } +} + +internal class StarRailAssetNative +{ + public required string Filename { get; init; } + public required long Size { get; init; } + public required byte[] MD5Checksum { get; init; } + + public static Span Parse(Span filenameBuffer, + ref StarRailAssetNativeInfo assetInfo, + out StarRailAssetNative? 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.Size; + byte[] md5Checksum = new byte[16]; + + assetInfo.Shifted4BytesMD5Checksum.CopyTo(md5Checksum); + StarRailBinaryDataExtension.ReverseReorderBy4X4HashData(md5Checksum); // Reorder 4x4 hash using SIMD + + result = new StarRailAssetNative + { + Filename = filename, + Size = fileSize, + MD5Checksum = md5Checksum + }; + + return filenameBuffer[filenameLength..]; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public unsafe struct StarRailAssetNativeInfo + { + private fixed byte _shifted4BytesMD5Checksum[16]; + public long Size; + public int FilenameLength; + public int FilenameStartAt; + + public ReadOnlySpan Shifted4BytesMD5Checksum + { + get + { + fixed (byte* magicP = _shifted4BytesMD5Checksum) + { + return new ReadOnlySpan(magicP, 16); + } + } + } + } +} \ No newline at end of file diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryData.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryData.cs new file mode 100644 index 000000000..57b368429 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryData.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +#pragma warning disable IDE0130 + +namespace CollapseLauncher.RepairManagement.StarRail.Struct; + +internal abstract class StarRailBinaryData +{ + protected abstract ReadOnlySpan MagicSignature { get; } + public StarRailBinaryHeader Header { get; private set; } + public List DataList { get; protected set; } + + public virtual async Task> ParseAsync(Stream dataStream, CancellationToken token) + { + (Header, int offset) = await dataStream.ReadDataAssertAndSeekAsync(x => x.SubStructStartOffset, 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); + return this; + } + + protected abstract ValueTask ReadDataCoreAsync(long currentOffset, Stream dataStream, CancellationToken token); +} + +[StructLayout(LayoutKind.Sequential, Pack = 2)] +public unsafe struct StarRailBinaryHeader +{ + private fixed byte _magicSignature[4]; + public short ParentTypeFlag; + public short TypeVersionFlag; + public int HeaderLength; + public short SubStructCount; + public short SubStructStartOffset; + + 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} | SubStructStartOffset: {SubStructStartOffset} | {HeaderLength} bytes"; +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataExtension.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataExtension.cs new file mode 100644 index 000000000..bd01e3d62 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataExtension.cs @@ -0,0 +1,105 @@ +using Hi3Helper.EncTool; +using System; +using System.Buffers.Binary; +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 IDE0130 + +namespace CollapseLauncher.RepairManagement.StarRail.Struct; + +internal static class StarRailBinaryDataExtension +{ + internal static async ValueTask<(T Data, int Read)> ReadDataAssertAndSeekAsync( + this Stream stream, + 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 {nameof(T)} 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 static unsafe void ReverseReorderBy4X4HashData(Span data) + { + if (data.Length != 16) + throw new ArgumentException("Data length must be multiple of 4x4.", 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]); + } +} + diff --git a/Hi3Helper.EncTool b/Hi3Helper.EncTool index 15f443056..d1f7c1ed9 160000 --- a/Hi3Helper.EncTool +++ b/Hi3Helper.EncTool @@ -1 +1 @@ -Subproject commit 15f443056e3019118ed37d653c6c06c13b368158 +Subproject commit d1f7c1ed921b58158f5ad25f232a81b0ad8fb8d9 From 6f45dbe3265fe3152796a1cb5973e3c72180cdfe Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Wed, 31 Dec 2025 02:28:04 +0700 Subject: [PATCH 02/14] Implements parsers for SR metadata --- .../SophonRepairFetchUtility.cs | 1 - .../RepairManagement/StarRail/Fetch.cs | 2 +- .../StarRail/StarRailPersistentRefResult.cs | 252 ++++++++++++++++-- .../Assets/StarRailAssetBlockMetadata.cs | 154 +++++++++++ .../Assets/StarRailAssetBundleMetadata.cs | 113 ++++++++ .../Assets/StarRailAssetGenericFileInfo.cs | 33 +++ .../Assets/StarRailAssetJsonMetadata.cs | 77 ++++++ .../Assets/StarRailAssetNativeDataMetadata.cs | 185 +++++++++++++ .../StarRailAssetSignaturelessMetadata.cs | 148 ++++++++++ .../Struct/Assets/StarRailBinaryDataNative.cs | 170 ------------ .../Struct/StarRailAssetBinaryMetadata.cs | 33 +++ .../Struct/StarRailAssetMetadataIndex.cs | 173 ++++++++++++ .../StarRail/Struct/StarRailBinaryData.cs | 72 ++++- .../Struct/StarRailBinaryDataExtension.cs | 126 +++++++-- .../Struct/StarRailBinaryDataWritable.cs | 54 ++++ Hi3Helper.EncTool | 2 +- Hi3Helper.Sophon | 2 +- 17 files changed, 1375 insertions(+), 222 deletions(-) create mode 100644 CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetBlockMetadata.cs create mode 100644 CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetBundleMetadata.cs create mode 100644 CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetGenericFileInfo.cs create mode 100644 CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetJsonMetadata.cs create mode 100644 CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetNativeDataMetadata.cs create mode 100644 CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetSignaturelessMetadata.cs delete mode 100644 CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailBinaryDataNative.cs create mode 100644 CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailAssetBinaryMetadata.cs create mode 100644 CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailAssetMetadataIndex.cs create mode 100644 CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataWritable.cs diff --git a/CollapseLauncher/Classes/RepairManagement/SophonRepairFetchUtility.cs b/CollapseLauncher/Classes/RepairManagement/SophonRepairFetchUtility.cs index ffc75af88..0c241e92f 100644 --- a/CollapseLauncher/Classes/RepairManagement/SophonRepairFetchUtility.cs +++ b/CollapseLauncher/Classes/RepairManagement/SophonRepairFetchUtility.cs @@ -68,7 +68,6 @@ public static async Task FetchAssetsFromSophonAsync( .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})"); diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs index ac32206fb..8fc558b9d 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs @@ -119,7 +119,7 @@ private async Task Fetch(List assetIndex, CancellationToke GameVersionManager.GetGameVersionApi().ToString()); await dispatcherInfo.Initialize(client, regionId, token); - Task persistentRef = StarRailPersistentRefResult + StarRailPersistentRefResult persistentRef = await StarRailPersistentRefResult .GetReferenceAsync(this, dispatcherInfo, client, GameDataPersistentPath, token); } diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.cs index 801431c03..91e903dac 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.cs @@ -1,22 +1,21 @@ 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.Parser.AssetMetadata; using Hi3Helper.EncTool.Proto.StarRail; -using Microsoft.UI.Xaml.Shapes; using System; using System.Collections.Generic; using System.IO; using System.Net.Http; -using System.Text; -using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using Path = System.IO.Path; + +// ReSharper disable CommentTypo #nullable enable #pragma warning disable IDE0130 @@ -34,31 +33,201 @@ public static async Task GetReferenceAsync( StarRailGatewayStatic gateway = dispatcherInfo.RegionGateway; Dictionary gatewayKvp = gateway.ValuePairs; - string mainUrlAsb = gatewayKvp["AssetBundleVersionUpdateUrl"]; - string mainUrlDesignData = gatewayKvp["DesignDataBundleVersionUpdateUrl"]; + string mainUrlAsb = gatewayKvp["AssetBundleVersionUpdateUrl"].CombineURLFromString("client/Windows"); + string mainUrlDesignData = gatewayKvp["DesignDataBundleVersionUpdateUrl"].CombineURLFromString("client/Windows"); + string mainUrlArchive = mainUrlAsb.CombineURLFromString("Archive"); + string mainUrlAudio = mainUrlAsb.CombineURLFromString("AudioBlock"); + string mainUrlAsbBlock = mainUrlAsb.CombineURLFromString("Block"); + string mainUrlNativeData = mainUrlDesignData.CombineURLFromString("NativeData"); + string mainUrlVideo = mainUrlAsb.CombineURLFromString("Video"); + + 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 refDesignArchiveUrl = mainUrlDesignData.CombineURLFromString("M_Design_ArchiveV.bytes"); + string refArchiveUrl = mainUrlArchive.CombineURLFromString("M_ArchiveV.bytes"); - string mInfoDesignArchiveUrl = mainUrlDesignData.CombineURLFromString("client/Windows/M_Design_ArchiveV.bytes"); - Dictionary mInfoDesignArchive = await StarRailRefMainInfo + // -- Fetch and parse the index references + Dictionary handleDesignArchive = await StarRailRefMainInfo .ParseListFromUrlAsync(instance, client, - mInfoDesignArchiveUrl, + refDesignArchiveUrl, + null, token); - string mInfoArchiveUrl = mainUrlAsb.CombineURLFromString("client/Windows/Archive/M_ArchiveV.bytes"); - Dictionary mInfoArchive = await StarRailRefMainInfo + Dictionary handleArchive = await StarRailRefMainInfo .ParseListFromUrlAsync(instance, client, - mInfoArchiveUrl, + refArchiveUrl, + lDirArchive, token); - await using FileStream stream = - File.OpenRead(@"C:\Users\neon-nyan\AppData\LocalLow\CollapseLauncher\GameFolder\SRGlb\Games\StarRail_Data\StreamingAssets\NativeData\Windows\NativeDataV_2ebcf9e27323a0561ef4825a14819ed5.bytes"); + // -- 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, lDirDesignData, "DesignV", token); + await SaveLocalIndexFiles(instance, handleArchive, lDirAsbBlock, "AsbV", token); + await SaveLocalIndexFiles(instance, handleArchive, lDirAsbBlock, "BlockV", token); + await SaveLocalIndexFiles(instance, handleArchive, lDirAsbBlock, "Start_AsbV", token); + await SaveLocalIndexFiles(instance, handleArchive, lDirAsbBlock, "Start_BlockV", token); + await SaveLocalIndexFiles(instance, handleArchive, lDirAudio, "AudioV", token); + await SaveLocalIndexFiles(instance, handleArchive, lDirVideo, "VideoV", token); + + // -- Load metadata files + // -- DesignV + StarRailAssetSignaturelessMetadata? metadataDesignV = + await LoadMetadataFile(instance, + handleDesignArchive, + client, + mainUrlDesignData, + "DesignV", + lDirDesignData, + token); + + // -- NativeDataV + StarRailAssetNativeDataMetadata? metadataNativeDataV = + await LoadMetadataFile(instance, + handleDesignArchive, + client, + mainUrlNativeData, + "NativeDataV", + lDirNativeData, + token); + + // -- Start_AsbV + StarRailAssetBundleMetadata? metadataStartAsbV = + await LoadMetadataFile(instance, + handleArchive, + client, + mainUrlAsbBlock, + "Start_AsbV", + lDirAsbBlock, + token); - StarRailBinaryDataNative binaryRefNativeData = new(); - await binaryRefNativeData.ParseAsync(stream, token); + // -- Start_BlockV + StarRailAssetBlockMetadata? metadataStartBlockV = + await LoadMetadataFile(instance, + handleArchive, + client, + mainUrlAsbBlock, + "Start_BlockV", + lDirAsbBlock, + token); + + // -- AsbV + StarRailAssetBundleMetadata? metadataAsbV = + await LoadMetadataFile(instance, + handleArchive, + client, + mainUrlAsbBlock, + "AsbV", + null, + token); + + // -- BlockV + StarRailAssetBlockMetadata? metadataBlockV = + await LoadMetadataFile(instance, + handleArchive, + client, + mainUrlAsbBlock, + "BlockV", + null, + token); + + // -- AudioV + StarRailAssetJsonMetadata? metadataAudioV = + await LoadMetadataFile(instance, + handleArchive, + client, + mainUrlAudio, + "AudioV", + lDirAudio, + token); + + // -- VideoV + StarRailAssetJsonMetadata? metadataVideoV = + await LoadMetadataFile(instance, + handleArchive, + client, + mainUrlVideo, + "VideoV", + lDirVideo, + token); return default; } + + private static async ValueTask SaveLocalIndexFiles( + StarRailRepair 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 async ValueTask LoadMetadataFile( + StarRailRepair instance, + Dictionary handleArchiveSource, + HttpClient client, + string baseUrl, + string indexKey, + string? saveToLocalDir = null, + CancellationToken token = default) + where T : StarRailBinaryData, new() + { + T parser = StarRailBinaryData.CreateDefault(); + + 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(); + + string filename = index.RemoteFileName; + 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); + } + } } [JsonSerializable(typeof(StarRailRefMainInfo))] @@ -88,11 +257,46 @@ internal class StarRailRefMainInfo 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 + }; + + 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( StarRailRepair instance, HttpClient client, string url, - CancellationToken token) + 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)}"); @@ -101,9 +305,12 @@ public static async Task> ParseListFromU 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(networkStream); + using StreamReader reader = new(sourceStream); while (await reader.ReadLineAsync(token) is { } line) { StarRailRefMainInfo refInfo = line.Deserialize(StarRailRepairJsonContext.Default.StarRailRefMainInfo) @@ -113,5 +320,14 @@ public static async Task> ParseListFromU } return returnList; + + static Stream 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/StarRail/Struct/Assets/StarRailAssetBlockMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetBlockMetadata.cs new file mode 100644 index 000000000..5e79b4725 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetBlockMetadata.cs @@ -0,0 +1,154 @@ +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; +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +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 : StarRailAssetGenericFileInfo + { + /// + /// Defined flags of the asset bundle block file. + /// + public required uint Flags { get; init; } + + 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..]; + } + + public override string ToString() => + $"{Filename} | Flags: {ConverterTool.ToBinaryString(Flags)} | Hash: {HexTool.BytesToHexUnsafe(MD5Checksum)} | Size: {FileSize}"; + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetBundleMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetBundleMetadata.cs new file mode 100644 index 000000000..8bcc0856f --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetBundleMetadata.cs @@ -0,0 +1,113 @@ +using Hi3Helper.Data; +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 +#pragma warning disable IDE0130 + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +public 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/StarRail/Struct/Assets/StarRailAssetGenericFileInfo.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetGenericFileInfo.cs new file mode 100644 index 000000000..f65978e97 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetGenericFileInfo.cs @@ -0,0 +1,33 @@ +using Hi3Helper.Data; +using Hi3Helper.Plugin.Core.Utility.Json.Converters; +using System.Text.Json.Serialization; + +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +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/StarRail/Struct/Assets/StarRailAssetJsonMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetJsonMetadata.cs new file mode 100644 index 000000000..222a02e88 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetJsonMetadata.cs @@ -0,0 +1,77 @@ +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 +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +internal 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 : StarRailAssetGenericFileInfo + { + [JsonPropertyName("Patch")] + public bool IsPatch { get; init; } + + [JsonPropertyName("SubPackId")] + public int SubPackId { get; init; } + + [JsonPropertyName("TaskIds")] + public int[]? TaskIdList { get; init; } + + public override string ToString() => $"{base.ToString()} | Patch: {IsPatch} | SubPackId: {SubPackId}" + + (TaskIdList?.Length == 0 ? "" : $" | TaskIds: [{string.Join(", ", TaskIdList ?? [])}]"); + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetNativeDataMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetNativeDataMetadata.cs new file mode 100644 index 000000000..3aab78b0a --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetNativeDataMetadata.cs @@ -0,0 +1,185 @@ +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; +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +internal 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/StarRail/Struct/Assets/StarRailAssetSignaturelessMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetSignaturelessMetadata.cs new file mode 100644 index 000000000..25ffad544 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetSignaturelessMetadata.cs @@ -0,0 +1,148 @@ +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 +#pragma warning disable IDE0130 + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +internal class StarRailAssetSignaturelessMetadata : StarRailAssetBinaryMetadata +{ + public StarRailAssetSignaturelessMetadata() + : base(0, + 256, + 0, // Leave the rest of it to 0 as this metadata has non-consistent header struct + 0, + 0) { } + + 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, + 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 : StarRailAssetGenericFileInfo + { + /// + /// Defined flags of the asset bundle block file. + /// + public required uint Flags { get; init; } + + public static void Parse(ReadOnlySpan buffer, + 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)}.block", + FileSize = fileSize, + Flags = assetType + }; + } + + public override string ToString() => + $"{Filename} | Flags: {ConverterTool.ToBinaryString(Flags)} | Hash: {HexTool.BytesToHexUnsafe(MD5Checksum)} | Size: {FileSize}"; + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailBinaryDataNative.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailBinaryDataNative.cs deleted file mode 100644 index 6f96d2803..000000000 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailBinaryDataNative.cs +++ /dev/null @@ -1,170 +0,0 @@ -using Hi3Helper.EncTool; -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; -#pragma warning disable IDE0130 -#nullable enable - -namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; - -internal class StarRailBinaryDataNative : StarRailBinaryData -{ - protected override ReadOnlySpan MagicSignature => "SRBM"u8; - - protected override async ValueTask ReadDataCoreAsync(long currentOffset, Stream dataStream, CancellationToken token) - { - (HeaderStruct header, int readHeader) = await dataStream - .ReadDataAssertAndSeekAsync(x => x.AssetInfoStructSize, token) - .ConfigureAwait(false); - currentOffset += readHeader; - - Debug.Assert(header.FilenameBufferStartOffset == currentOffset); // ASSERT: Make sure the current data stream offset is at the exact filename buffer offset. - Debug.Assert(header.AssetCount is < ushort.MaxValue and > 0); // ASSERT: Make sure the data isn't more than ushort.MaxValue and not empty. - - // Read file info - int fileInfoBufferLen = header.AssetInfoStructSize * header.AssetCount; - byte[] filenameBuffer = ArrayPool.Shared.Rent(header.FilenameBufferSize); - byte[] fileInfoBuffer = ArrayPool.Shared.Rent(fileInfoBufferLen); - try - { - - // Read filename buffer - int read = await dataStream.ReadAtLeastAsync(filenameBuffer.AsMemory(0, header.FilenameBufferSize), - header.FilenameBufferSize, - cancellationToken: token) - .ConfigureAwait(false); - Debug.Assert(header.FilenameBufferSize == read); // ASSERT: Make sure the filename buffer size is equal as what we read. - - currentOffset += read; - int paddingOffsetToInfoBuffer = header.AssetInfoAbsoluteStartOffset - (int)currentOffset; - await dataStream.SeekForwardAsync(paddingOffsetToInfoBuffer, token); // ASSERT: Seek forward to the asset info buffer. - - // Read file info buffer - read = await dataStream.ReadAtLeastAsync(fileInfoBuffer.AsMemory(0, fileInfoBufferLen), - fileInfoBufferLen, - cancellationToken: token); - Debug.Assert(fileInfoBufferLen == read); // ASSERT: Make sure the asset info struct size is equal as what we read. - currentOffset += read; - - // Create spans - Span filenameBufferSpan = filenameBuffer.AsSpan(0, header.FilenameBufferSize); - Span fileInfoBufferSpan = fileInfoBuffer.AsSpan(0, fileInfoBufferLen); - - // Allocate list - DataList = new List(header.AssetCount); - - for (int i = 0; i < header.AssetCount && !filenameBufferSpan.IsEmpty; i++) - { - ref StarRailAssetNative.StarRailAssetNativeInfo fileInfo = - ref MemoryMarshal.AsRef(fileInfoBufferSpan); - filenameBufferSpan = StarRailAssetNative.Parse(filenameBufferSpan, - ref fileInfo, - out StarRailAssetNative? asset); - fileInfoBufferSpan = fileInfoBufferSpan[header.AssetInfoStructSize..]; - - 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 HeaderStruct - { - public int Reserved; - /// - /// The absolute offset of struct array in the data stream. - /// - public int AssetInfoAbsoluteStartOffset; - /// - /// The count of the struct array. - /// - public int AssetCount; - /// - /// The size of the struct. - /// - public int AssetInfoStructSize; - public int Unknown1; - /// - /// The absolute offset of filename buffer in the data stream. - /// - public int FilenameBufferStartOffset; - public int Unknown2; - /// - /// The size of the filename buffer in bytes. - /// - public int FilenameBufferSize; - } -} - -internal class StarRailAssetNative -{ - public required string Filename { get; init; } - public required long Size { get; init; } - public required byte[] MD5Checksum { get; init; } - - public static Span Parse(Span filenameBuffer, - ref StarRailAssetNativeInfo assetInfo, - out StarRailAssetNative? 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.Size; - byte[] md5Checksum = new byte[16]; - - assetInfo.Shifted4BytesMD5Checksum.CopyTo(md5Checksum); - StarRailBinaryDataExtension.ReverseReorderBy4X4HashData(md5Checksum); // Reorder 4x4 hash using SIMD - - result = new StarRailAssetNative - { - Filename = filename, - Size = fileSize, - MD5Checksum = md5Checksum - }; - - return filenameBuffer[filenameLength..]; - } - - [StructLayout(LayoutKind.Sequential, Pack = 4)] - public unsafe struct StarRailAssetNativeInfo - { - private fixed byte _shifted4BytesMD5Checksum[16]; - public long Size; - public int FilenameLength; - public int FilenameStartAt; - - public ReadOnlySpan Shifted4BytesMD5Checksum - { - get - { - fixed (byte* magicP = _shifted4BytesMD5Checksum) - { - return new ReadOnlySpan(magicP, 16); - } - } - } - } -} \ No newline at end of file diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailAssetBinaryMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailAssetBinaryMetadata.cs new file mode 100644 index 000000000..d0adc2c96 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailAssetBinaryMetadata.cs @@ -0,0 +1,33 @@ +using System; +using CollapseLauncher.RepairManagement.StarRail.Struct.Assets; +// ReSharper disable CommentTypo +#pragma warning disable IDE0290 +#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:
+/// +/// +///
+public abstract class StarRailAssetBinaryMetadata : StarRailBinaryData +{ + 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/StarRail/Struct/StarRailAssetMetadataIndex.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailAssetMetadataIndex.cs new file mode 100644 index 000000000..74259c6a1 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailAssetMetadataIndex.cs @@ -0,0 +1,173 @@ +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 +#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() + : 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) + { } + + private static ReadOnlySpan MagicSignatureStatic => "SRMI"u8; + protected override ReadOnlySpan MagicSignature => MagicSignatureStatic; + + protected override async ValueTask ReadDataCoreAsync( + long currentOffset, + Stream dataStream, + CancellationToken token = default) + { + (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]; + 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); + } + } + } + } + + 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 byte[] Reserved { get; init; } = new byte[10]; + + 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 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); + } + } +} \ No newline at end of file diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryData.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryData.cs index 57b368429..47398153b 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryData.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryData.cs @@ -6,39 +6,87 @@ using System.Threading; using System.Threading.Tasks; #pragma warning disable IDE0130 +#nullable enable namespace CollapseLauncher.RepairManagement.StarRail.Struct; -internal abstract class StarRailBinaryData +public abstract class StarRailBinaryData { - protected abstract ReadOnlySpan MagicSignature { get; } - public StarRailBinaryHeader Header { get; private set; } - public List DataList { get; protected set; } + protected abstract ReadOnlySpan MagicSignature { get; } + public StarRailBinaryDataHeaderStruct Header { get; protected set; } - public virtual async Task> ParseAsync(Stream dataStream, CancellationToken token) + 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 dataStream.ReadDataAssertAndSeekAsync(x => x.SubStructStartOffset, token); + (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); - return this; + if (seekToEnd) // Read all remained data to null stream, even though not all data is being read. + { + await dataStream.CopyToAsync(Stream.Null, token); + } } - protected abstract ValueTask ReadDataCoreAsync(long currentOffset, Stream dataStream, CancellationToken token); + protected virtual async ValueTask<(StarRailBinaryDataHeaderStruct Header, int Offset)> ReadHeaderCoreAsync( + Stream dataStream, + CancellationToken token = default) + { + return await dataStream + .ReadDataAssertAndSeekAsync(x => x.HeaderOrDataLength, + token); + } + + protected abstract ValueTask ReadDataCoreAsync(long currentOffset, Stream dataStream, CancellationToken token = default); +} + +public abstract class StarRailBinaryData : StarRailBinaryData +{ + 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; + } } [StructLayout(LayoutKind.Sequential, Pack = 2)] -public unsafe struct StarRailBinaryHeader +public unsafe struct StarRailBinaryDataHeaderStruct { private fixed byte _magicSignature[4]; public short ParentTypeFlag; public short TypeVersionFlag; - public int HeaderLength; + public int HeaderOrDataLength; public short SubStructCount; - public short SubStructStartOffset; + public short SubStructSize; public Span MagicSignature { @@ -51,5 +99,5 @@ public Span MagicSignature } } - public override string ToString() => $"{Encoding.UTF8.GetString(MagicSignature)} | ParentTypeFlag: {ParentTypeFlag} | TypeVersionFlag: {TypeVersionFlag} | SubStructCount: {SubStructCount} | SubStructStartOffset: {SubStructStartOffset} | {HeaderLength} bytes"; + 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/StarRail/Struct/StarRailBinaryDataExtension.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataExtension.cs index bd01e3d62..aeaefbf1f 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataExtension.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataExtension.cs @@ -1,6 +1,8 @@ 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; @@ -16,35 +18,113 @@ namespace CollapseLauncher.RepairManagement.StarRail.Struct; internal static class StarRailBinaryDataExtension { - internal static async ValueTask<(T Data, int Read)> ReadDataAssertAndSeekAsync( - this Stream stream, - Func minimalSizeAssertGet, - CancellationToken token) - where T : unmanaged + extension(Stream stream) { - T result = await stream.ReadAsync(token).ConfigureAwait(false); - int sizeOfImplemented = Unsafe.SizeOf(); - int minimalSizeToAssert = minimalSizeAssertGet(result); + 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); + } - // 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) + internal async ValueTask ReadBufferAssertAsync(long currentPos, + Memory buffer, + CancellationToken token) { - throw new InvalidOperationException($"Game data use {minimalSizeToAssert} bytes of struct for {nameof(T)} 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"); + 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; } - // 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); + 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); + } + } + } - return (result, read); + 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 multiple of 4x4.", nameof(data)); + throw new ArgumentException("Data length must be 16 bytes.", nameof(data)); void* dataP = Unsafe.AsPointer(ref MemoryMarshal.GetReference(data)); if (Sse3.IsSupported) @@ -101,5 +181,15 @@ private static unsafe void ReverseByInt32X4Scalar(void* int32X4P) 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/StarRail/Struct/StarRailBinaryDataWritable.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataWritable.cs new file mode 100644 index 000000000..f62d6514e --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataWritable.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +#pragma warning disable IDE0290 +#pragma warning disable IDE0130 + +namespace CollapseLauncher.RepairManagement.StarRail.Struct; + +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) { } + + public virtual ValueTask WriteAsync(string filePath, CancellationToken token = default) + => WriteAsync(new FileInfo(filePath), token); + + 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); + } + + 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); + } + + protected abstract ValueTask WriteHeaderCoreAsync(Stream dataStream, CancellationToken token = default); + + protected abstract ValueTask WriteDataCoreAsync(Stream dataStream, CancellationToken token = default); +} diff --git a/Hi3Helper.EncTool b/Hi3Helper.EncTool index d1f7c1ed9..0d54d993f 160000 --- a/Hi3Helper.EncTool +++ b/Hi3Helper.EncTool @@ -1 +1 @@ -Subproject commit d1f7c1ed921b58158f5ad25f232a81b0ad8fb8d9 +Subproject commit 0d54d993fd4feb6eea643be21a5bd740a60ff671 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 From a36c9d778e852772d01a9864973b167fa50f3538 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Fri, 2 Jan 2026 03:52:21 +0700 Subject: [PATCH 03/14] Make fetching work --- .../RepairManagement/StarRail/Fetch.cs | 266 ++------------- ...lPersistentRefResult.GetPersistentFiles.cs | 180 ++++++++++ .../StarRail/StarRailPersistentRefResult.cs | 308 +++++++++++++++--- .../StarRail/StarRailRepair.cs | 3 +- .../Assets/StarRailAssetBlockMetadata.cs | 10 +- .../Assets/StarRailAssetGenericFileInfo.cs | 16 + .../Assets/StarRailAssetJsonMetadata.cs | 4 +- .../StarRailAssetSignaturelessMetadata.cs | 10 +- .../Struct/StarRailAssetBinaryMetadata.cs | 1 + .../StarRail/Struct/StarRailBinaryData.cs | 7 +- .../Struct/StarRailBinaryDataWritable.cs | 3 +- Hi3Helper.EncTool | 2 +- 12 files changed, 514 insertions(+), 296 deletions(-) create mode 100644 CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.GetPersistentFiles.cs diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs index 8fc558b9d..2f9e7402d 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs @@ -2,25 +2,17 @@ using CollapseLauncher.Helper.Metadata; using CollapseLauncher.RepairManagement; using Hi3Helper; -using Hi3Helper.Data; -using Hi3Helper.EncTool.Parser.AssetIndex; using Hi3Helper.EncTool.Parser.AssetMetadata; -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 @@ -84,12 +76,13 @@ private async Task Fetch(List assetIndex, CancellationToke .Create(); // Initialize the new DownloadClient - DownloadClient downloadClient = DownloadClient.CreateInstance(client); + string regionId = GetExistingGameRegionID(); + string[] installedVoiceLang = await GetInstalledVoiceLanguageOrDefault(token); try { // Get the primary manifest - await GetPrimaryManifest(assetIndex, token); + await GetPrimaryManifest(assetIndex, installedVoiceLang, token); // If the this._isOnlyRecoverMain && base._isVersionOverride is true, copy the asset index into the _originAssetIndex if (IsOnlyRecoverMain && IsVersionOverride) @@ -104,8 +97,6 @@ private async Task Fetch(List assetIndex, CancellationToke } } - string regionId = GetExistingGameRegionID(); - // Fetch assets from game server if (!IsVersionOverride && !IsOnlyRecoverMain) @@ -119,32 +110,15 @@ private async Task Fetch(List assetIndex, CancellationToke GameVersionManager.GetGameVersionApi().ToString()); await dispatcherInfo.Initialize(client, regionId, token); - StarRailPersistentRefResult persistentRef = await StarRailPersistentRefResult - .GetReferenceAsync(this, dispatcherInfo, client, GameDataPersistentPath, token); - } - - // Subscribe the fetching progress and subscribe StarRailMetadataTool progress to adapter - // _innerGameVersionManager.StarRailMetadataTool.HttpEvent += _httpClient_FetchAssetProgress; + StarRailPersistentRefResult persistentRefResult = await StarRailPersistentRefResult + .GetReferenceAsync(this, + dispatcherInfo, + client, + GamePath, + GameDataPersistentPathRelative, + token); - // 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, regionId, 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); + persistentRefResult.GetPersistentFiles(assetIndex, GamePath, installedVoiceLang, token); } // Force-Fetch the Bilibili SDK (if exist :pepehands:) @@ -156,6 +130,9 @@ await Task.WhenAll( { EliminatePluginAssetIndex(assetIndex, x => x.N, x => x.RN); } + + // Remove blacklisted assets + await InnerGameInstaller.FilterAssetList(assetIndex, x => x.N, token); } finally { @@ -167,7 +144,21 @@ await Task.WhenAll( } #region PrimaryManifest - private async Task GetPrimaryManifest(List assetIndex, CancellationToken token) + + 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 @@ -178,10 +169,11 @@ private async Task GetPrimaryManifest(List assetIndex, Can string[] excludedMatchingField = ["en-us", "zh-cn", "ja-jp", "ko-kr"]; if (File.Exists(GameAudioLangListPathStatic)) { - string[] installedAudioLang = (await File.ReadAllLinesAsync(GameAudioLangListPathStatic, token)) + string[] installedAudioLang = voiceLang .Select(x => x switch { "English" => "en-us", + "English(US)" => "en-us", "Japanese" => "ja-jp", "Chinese(PRC)" => "zh-cn", "Korean" => "ko-kr", @@ -202,36 +194,11 @@ await this.FetchAssetsFromSophonAsync(client, token); } - 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 static FileType DetermineFileTypeFromExtension(string fileName) + + internal static FileType DetermineFileTypeFromExtension(string fileName) { if (fileName.EndsWith(".block", StringComparison.OrdinalIgnoreCase)) { @@ -251,41 +218,6 @@ private static FileType DetermineFileTypeFromExtension(string fileName) return FileType.Generic; } - 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 @@ -320,129 +252,6 @@ private unsafe string GetExistingGameRegionID() } } - 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 @@ -452,15 +261,6 @@ private void CountAssetIndex(List assetIndex) 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, diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.GetPersistentFiles.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.GetPersistentFiles.cs new file mode 100644 index 000000000..ea8da824a --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.GetPersistentFiles.cs @@ -0,0 +1,180 @@ +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 IDE0130 +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, + CancellationToken token) + { + 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); + } + + AddAdditionalAssets(gameDirPath, + BaseDirs.StreamingAsbBlock, + BaseDirs.PersistentAsbBlock, + BaseUrls.AsbBlock, + BaseUrls.AsbBlockPersistent, + fileList, + unusedAssets, + oldDic, + Metadata.StartBlockV!.DataList); + + AddAdditionalAssets(gameDirPath, + BaseDirs.StreamingAsbBlock, + BaseDirs.PersistentAsbBlock, + BaseUrls.AsbBlock, + BaseUrls.AsbBlockPersistent, + fileList, + unusedAssets, + oldDic, + Metadata.BlockV!.DataList); + + AddAdditionalAssets(gameDirPath, + BaseDirs.StreamingVideo, + BaseDirs.PersistentVideo, + BaseUrls.Video, + BaseUrls.Video, + fileList, + unusedAssets, + oldDic, + Metadata.VideoV!.DataList); + + AddAdditionalAssets(gameDirPath, + BaseDirs.StreamingAudio, + BaseDirs.PersistentAudio, + BaseUrls.Audio, + BaseUrls.Audio, + fileList, + unusedAssets, + oldDic, + Metadata.AudioV!.DataList + .WhereNotStartWith(excludedAudioLangPrefix)); + + return unusedAssets.Values.ToList(); + } + + private static void AddAdditionalAssets( + string gameDirPath, + string assetDirPathStreaming, + string assetDirPathPersistent, + string urlBase, + string urlBasePersistent, + 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)) + { + 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 = StarRailRepair.DetermineFileTypeFromExtension(asset.Filename ?? "") + }; + fileDic.TryAdd(asset.Filename, file); + fileList.Add(file); + } + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.cs index 91e903dac..68058b98b 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.cs @@ -5,28 +5,34 @@ 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 - -#nullable enable #pragma warning disable IDE0130 +#nullable enable + namespace CollapseLauncher; -internal class StarRailPersistentRefResult +internal partial class StarRailPersistentRefResult { + public required AssetBaseUrls BaseUrls { get; set; } + public required AssetBaseDirs BaseDirs { get; set; } + public required AssetMetadata Metadata { get; set; } + public static async Task GetReferenceAsync( StarRailRepair instance, SRDispatcherInfo dispatcherInfo, HttpClient client, + string gameBaseDir, string persistentDir, CancellationToken token) { @@ -34,23 +40,79 @@ public static async Task GetReferenceAsync( 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 mainUrlAudio = mainUrlAsb.CombineURLFromString("AudioBlock"); - string mainUrlAsbBlock = mainUrlAsb.CombineURLFromString("Block"); - string mainUrlNativeData = mainUrlDesignData.CombineURLFromString("NativeData"); - string mainUrlVideo = mainUrlAsb.CombineURLFromString("Video"); - - 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 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::GetReferenceAsync] 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::GetReferenceAsync] 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"); + + AssetBaseUrls baseUrl = new() + { + GatewayKvp = gatewayKvp, + DesignData = mainUrlDesignData, + Archive = mainUrlArchive, + Audio = mainUrlAudio, + AsbBlock = mainUrlAsbBlock, + AsbBlockPersistent = mainUrlAsbBlockAlt, + NativeData = mainUrlNativeData, + Video = mainUrlVideo + }; + + // -- 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 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); + AssetBaseDirs baseDirs = new(lDirArchive, lDirAsbBlock, lDirAudio, lDirDesignData, lDirNativeData, lDirVideo); + // -- Fetch and parse the index references Dictionary handleDesignArchive = await StarRailRefMainInfo .ParseListFromUrlAsync(instance, @@ -63,19 +125,41 @@ public static async Task GetReferenceAsync( .ParseListFromUrlAsync(instance, client, refArchiveUrl, - lDirArchive, + 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::GetReferenceAsync] Given persistent asset bundle URL is invalid! (previously: {baseUrl.AsbBlockPersistent}). Try swapping...", + LogType.Warning, + true); + isSecondAsbEndpointRetry = true; + baseUrl.SwapAsbPersistentUrl(); + goto TestAsbPersistentEndpoint; + } + Logger.LogWriteLine($"[StarRailPersistentRefResult::GetReferenceAsync] 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, lDirDesignData, "DesignV", token); - await SaveLocalIndexFiles(instance, handleArchive, lDirAsbBlock, "AsbV", token); - await SaveLocalIndexFiles(instance, handleArchive, lDirAsbBlock, "BlockV", token); - await SaveLocalIndexFiles(instance, handleArchive, lDirAsbBlock, "Start_AsbV", token); - await SaveLocalIndexFiles(instance, handleArchive, lDirAsbBlock, "Start_BlockV", token); - await SaveLocalIndexFiles(instance, handleArchive, lDirAudio, "AudioV", token); - await SaveLocalIndexFiles(instance, handleArchive, lDirVideo, "VideoV", token); + 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); // -- Load metadata files // -- DesignV @@ -83,9 +167,9 @@ public static async Task GetReferenceAsync( await LoadMetadataFile(instance, handleDesignArchive, client, - mainUrlDesignData, + baseUrl.DesignData, "DesignV", - lDirDesignData, + aDirDesignData, token); // -- NativeDataV @@ -93,9 +177,9 @@ await LoadMetadataFile(instance, await LoadMetadataFile(instance, handleDesignArchive, client, - mainUrlNativeData, + baseUrl.NativeData, "NativeDataV", - lDirNativeData, + aDirNativeData, token); // -- Start_AsbV @@ -103,9 +187,9 @@ await LoadMetadataFile(instance, await LoadMetadataFile(instance, handleArchive, client, - mainUrlAsbBlock, + baseUrl.AsbBlockPersistent, "Start_AsbV", - lDirAsbBlock, + aDirAsbBlock, token); // -- Start_BlockV @@ -113,9 +197,9 @@ await LoadMetadataFile(instance, await LoadMetadataFile(instance, handleArchive, client, - mainUrlAsbBlock, + baseUrl.AsbBlockPersistent, "Start_BlockV", - lDirAsbBlock, + aDirAsbBlock, token); // -- AsbV @@ -123,7 +207,7 @@ await LoadMetadataFile(instance, await LoadMetadataFile(instance, handleArchive, client, - mainUrlAsbBlock, + baseUrl.AsbBlockPersistent, "AsbV", null, token); @@ -133,7 +217,7 @@ await LoadMetadataFile(instance, await LoadMetadataFile(instance, handleArchive, client, - mainUrlAsbBlock, + baseUrl.AsbBlockPersistent, "BlockV", null, token); @@ -143,9 +227,9 @@ await LoadMetadataFile(instance, await LoadMetadataFile(instance, handleArchive, client, - mainUrlAudio, + baseUrl.Audio, "AudioV", - lDirAudio, + aDirAudio, token); // -- VideoV @@ -153,12 +237,27 @@ await LoadMetadataFile(instance, await LoadMetadataFile(instance, handleArchive, client, - mainUrlVideo, + baseUrl.Video, "VideoV", - lDirVideo, + aDirVideo, token); - return default; + 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 + } + }; } private static async ValueTask SaveLocalIndexFiles( @@ -209,14 +308,58 @@ private static async ValueTask SaveLocalIndexFiles( instance.Status.IsIncludePerFileIndicator = false; instance.UpdateStatus(); - string filename = index.RemoteFileName; - string fileUrl = baseUrl.CombineURLFromString(filename); + 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) @@ -228,6 +371,91 @@ static Stream CreateLocalStream(Stream thisSourceStream, string filePath) 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) + { + 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 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); + + 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 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; } + } } [JsonSerializable(typeof(StarRailRefMainInfo))] diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs index 72beddad7..a6539d170 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs @@ -33,7 +33,8 @@ private StarRailInstall InnerGameInstaller 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 GameDataPersistentPathRelative { get => Path.Combine($"{ExecName}_Data", "Persistent"); } + private string GameDataPersistentPath { get => Path.Combine(GamePath, GameDataPersistentPathRelative); } private string GameAudioLangListPath { get diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetBlockMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetBlockMetadata.cs index 5e79b4725..386911b47 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetBlockMetadata.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetBlockMetadata.cs @@ -120,13 +120,8 @@ public ReadOnlySpan Shifted4BytesMD5Checksum } } - public class Metadata : StarRailAssetGenericFileInfo + public class Metadata : StarRailAssetFlaggable { - /// - /// Defined flags of the asset bundle block file. - /// - public required uint Flags { get; init; } - public static ReadOnlySpan Parse(ReadOnlySpan buffer, int sizeOfStruct, out Metadata result) @@ -147,8 +142,5 @@ public static ReadOnlySpan Parse(ReadOnlySpan buffer, return buffer[sizeOfStruct..]; } - - public override string ToString() => - $"{Filename} | Flags: {ConverterTool.ToBinaryString(Flags)} | Hash: {HexTool.BytesToHexUnsafe(MD5Checksum)} | Size: {FileSize}"; } } diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetGenericFileInfo.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetGenericFileInfo.cs index f65978e97..076ed35b5 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetGenericFileInfo.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetGenericFileInfo.cs @@ -7,6 +7,22 @@ namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; +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}"; +} + public class StarRailAssetGenericFileInfo { /// diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetJsonMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetJsonMetadata.cs index 222a02e88..4e7c79c3b 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetJsonMetadata.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetJsonMetadata.cs @@ -60,7 +60,7 @@ protected override async ValueTask ReadDataCoreAsync( [JsonSerializable(typeof(Metadata))] public partial class MetadataJsonContext : JsonSerializerContext; - public class Metadata : StarRailAssetGenericFileInfo + public class Metadata : StarRailAssetFlaggable { [JsonPropertyName("Patch")] public bool IsPatch { get; init; } @@ -71,6 +71,8 @@ public class Metadata : StarRailAssetGenericFileInfo [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/StarRail/Struct/Assets/StarRailAssetSignaturelessMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetSignaturelessMetadata.cs index 25ffad544..f79f49016 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetSignaturelessMetadata.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetSignaturelessMetadata.cs @@ -108,13 +108,8 @@ protected override async ValueTask ReadDataCoreAsync( return currentOffset; } - public class Metadata : StarRailAssetGenericFileInfo + public class Metadata : StarRailAssetFlaggable { - /// - /// Defined flags of the asset bundle block file. - /// - public required uint Flags { get; init; } - public static void Parse(ReadOnlySpan buffer, int subDataSize, long lastDataStreamPos, @@ -141,8 +136,5 @@ public static void Parse(ReadOnlySpan buffer, Flags = assetType }; } - - public override string ToString() => - $"{Filename} | Flags: {ConverterTool.ToBinaryString(Flags)} | Hash: {HexTool.BytesToHexUnsafe(MD5Checksum)} | Size: {FileSize}"; } } diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailAssetBinaryMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailAssetBinaryMetadata.cs index d0adc2c96..571fdafc8 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailAssetBinaryMetadata.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailAssetBinaryMetadata.cs @@ -14,6 +14,7 @@ namespace CollapseLauncher.RepairManagement.StarRail.Struct; /// /// public abstract class StarRailAssetBinaryMetadata : StarRailBinaryData + where TAsset : StarRailAssetGenericFileInfo { protected StarRailAssetBinaryMetadata( short parentTypeFlag, diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryData.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryData.cs index 47398153b..68e1582a1 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryData.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryData.cs @@ -1,6 +1,11 @@ -using System; +using CollapseLauncher.RepairManagement.StarRail.Struct.Assets; +using Hi3Helper.Data; +using Hi3Helper.EncTool; +using System; using System.Collections.Generic; using System.IO; +using System.Net.Http; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; using System.Threading; diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataWritable.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataWritable.cs index f62d6514e..979678ae4 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataWritable.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataWritable.cs @@ -1,4 +1,5 @@ -using System; +using CollapseLauncher.RepairManagement.StarRail.Struct.Assets; +using System; using System.IO; using System.Threading; using System.Threading.Tasks; diff --git a/Hi3Helper.EncTool b/Hi3Helper.EncTool index 0d54d993f..1cf849bd3 160000 --- a/Hi3Helper.EncTool +++ b/Hi3Helper.EncTool @@ -1 +1 @@ -Subproject commit 0d54d993fd4feb6eea643be21a5bd740a60ff671 +Subproject commit 1cf849bd30bafa8a8a3087411b712cc11ffa05ed From 19bad4ab318065988113aaa8b946e85d7da793b8 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Fri, 2 Jan 2026 04:44:01 +0700 Subject: [PATCH 04/14] Add asset fetch finalizers This will generate InstallVersion.bin, DownloadedFullAssets.txt and AppIdentity.txt file inside persistent folder --- .../RepairManagement/StarRail/Fetch.cs | 6 +- ...arRailPersistentRefResult.FinalizeFetch.cs | 77 +++++++++++++++++++ ...lPersistentRefResult.GetPersistentFiles.cs | 3 +- 3 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.FinalizeFetch.cs diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs index 2f9e7402d..10e5c3a75 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs @@ -75,6 +75,8 @@ private async Task Fetch(List assetIndex, CancellationToke .SetAllowedDecompression(DecompressionMethods.None) .Create(); + HttpClient sharedClient = FallbackCDNUtil.GetGlobalHttpClient(true); + // Initialize the new DownloadClient string regionId = GetExistingGameRegionID(); string[] installedVoiceLang = await GetInstalledVoiceLanguageOrDefault(token); @@ -119,6 +121,7 @@ private async Task Fetch(List assetIndex, CancellationToke token); persistentRefResult.GetPersistentFiles(assetIndex, GamePath, installedVoiceLang, token); + await StarRailPersistentRefResult.FinalizeFetchAsync(this, sharedClient, assetIndex, GameDataPersistentPath, token); } // Force-Fetch the Bilibili SDK (if exist :pepehands:) @@ -130,9 +133,6 @@ private async Task Fetch(List assetIndex, CancellationToke { EliminatePluginAssetIndex(assetIndex, x => x.N, x => x.RN); } - - // Remove blacklisted assets - await InnerGameInstaller.FilterAssetList(assetIndex, x => x.N, token); } finally { diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.FinalizeFetch.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.FinalizeFetch.cs new file mode 100644 index 000000000..769199e32 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.FinalizeFetch.cs @@ -0,0 +1,77 @@ +using CollapseLauncher.Helper.StreamUtility; +using Hi3Helper; +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.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0130 +#nullable enable +namespace CollapseLauncher; + +internal partial class StarRailPersistentRefResult +{ + public static async Task FinalizeFetchAsync(StarRailRepair instance, + HttpClient client, + List assetIndex, + string persistentDir, + CancellationToken token) + { + // 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::FinalizeFetchAsync] 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..]); + } + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.GetPersistentFiles.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.GetPersistentFiles.cs index ea8da824a..2ee489efb 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.GetPersistentFiles.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.GetPersistentFiles.cs @@ -12,6 +12,7 @@ using System.Threading; #pragma warning disable IDE0130 +#nullable enable namespace CollapseLauncher; file static class StarRailPersistentExtension @@ -173,7 +174,7 @@ private static void AddAdditionalAssets( CRCArray = asset.MD5Checksum, FT = StarRailRepair.DetermineFileTypeFromExtension(asset.Filename ?? "") }; - fileDic.TryAdd(asset.Filename, file); + fileDic.TryAdd(relPathInPersistent, file); fileList.Add(file); } } From 1e14a0929ceb6d6e36927a5b99158a27767373a2 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Fri, 2 Jan 2026 06:21:39 +0700 Subject: [PATCH 05/14] Make Repair Works + Also fix Hi3 Repair won't pop repair table entry --- .../HonkaiV2/HonkaiRepairV2.AsbExt.Generic.cs | 81 ++-- .../HonkaiV2/HonkaiRepairV2.Repair.Generic.cs | 48 ++- .../HonkaiV2/HonkaiRepairV2.Repair.cs | 6 +- .../RepairManagement/StarRail/Check.cs | 343 ++++------------- .../RepairManagement/StarRail/Fetch.cs | 157 +++----- .../RepairManagement/StarRail/Repair.cs | 351 +++++++++++++----- ...arRailPersistentRefResult.FinalizeFetch.cs | 4 +- ...lPersistentRefResult.GetPersistentFiles.cs | 16 +- .../StarRail/StarRailRepair.cs | 59 +-- .../Zenless/ZenlessRepair.Check.cs | 2 +- 10 files changed, 464 insertions(+), 603 deletions(-) diff --git a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Generic.cs b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Generic.cs index f4fe3d0f9..f6f116ddf 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) + { + // Increment total count current + progressBase.ProgressAllCountCurrent++; + progressBase.Status.ActivityStatus = string.Format(Locale.Lang._GameRepairPage.Status8, asset.N); + progressBase.UpdateStatus(); + } } private static RepairAssetType GetRepairAssetType(this FilePropertiesRemote asset) => 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/StarRail/Check.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Check.cs index ac795da81..bff20de78 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Check.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Check.cs @@ -1,9 +1,7 @@ -using CollapseLauncher.Helper.StreamUtility; -using Hi3Helper; +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; @@ -19,40 +17,6 @@ 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) @@ -87,22 +51,12 @@ private async Task Check(List assetIndex, CancellationToke 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; - } + await CheckGenericAssetType(asset, brokenAssetIndex, threadToken); }); } catch (AggregateException ex) { - var innerExceptionsFirst = ex.Flatten().InnerExceptions.First(); + Exception innerExceptionsFirst = ex.Flatten().InnerExceptions.First(); await SentryHelper.ExceptionHandlerAsync(innerExceptionsFirst, SentryHelper.ExceptionType.UnhandledOther); throw innerExceptionsFirst; } @@ -113,11 +67,11 @@ private async Task Check(List assetIndex, CancellationToke } #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)); + Status.ActivityStatus = string.Format(Lang._GameRepairPage.Status6, asset.N); // Increment current total count ProgressAllCountCurrent++; @@ -126,13 +80,24 @@ private async Task CheckGenericAssetType(FilePropertiesRemote asset, List Remote: {asset.S}", LogType.Warning, true); @@ -165,245 +130,87 @@ private async Task CheckGenericAssetType(FilePropertiesRemote asset, List 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); - + AddIndex(asset, targetAssetIndex); 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) + private void AddIndex(FilePropertiesRemote asset, List targetAssetIndex) { - // 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; + // Update the total progress and found counter + ProgressAllSizeFound += asset.S; + ProgressAllCountFound++; - // 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; + // Set the per size progress + ProgressPerFileSizeCurrent = 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); + // Increment the total current progress + ProgressAllSizeCurrent += asset.S; - LogWriteLine($"File [T: {asset.FT}]: {unusedAsset.N} is redundant (exist both on persistent and streaming)", LogType.Warning, true); - } + var prop = new AssetProperty(Path.GetFileName(asset.N)!, + ConvertRepairAssetTypeEnum(asset.FT), + Path.GetDirectoryName(asset.N), + asset.S, + null, + null); - // If the file has Hash Mark or is persistent, then create the hash mark file - if (isHasMark) CreateHashMarkFile(asset.N, asset.CRC); + Dispatch(() => AssetEntry.Add(prop)); + asset.AssociatedAssetProperty = prop; + targetAssetIndex.Add(asset); + } - // Check if both location has the file exist or has the size right - if ((usePersistent && !isPersistentExist && !isStreamingExist) - || (usePersistent && !isPersistentExist)) + private void AddUnusedHashMarkFile(string filePath, + string gamePath, + FilePropertiesRemote asset, + List brokenFileList) + { + if (asset.CRCArray.Length == 0 || + (!asset.IsHasHashMark && asset.FT != FileType.Unused)) { - // 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; - } + string dir = Path.GetDirectoryName(filePath)!; + string fileNameNoExt = Path.GetFileNameWithoutExtension(filePath); - // Open and read fileInfo as FileStream - string fileNameToOpen = usePersistent ? fileInfoPersistent.FullName : fileInfoStreaming.FullName; - try - { - await CheckFile(fileNameToOpen, asset, targetAssetIndex, token); - } - catch (FileNotFoundException ex) + if (!Directory.Exists(dir)) { - 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); + return; } - } - - 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)) + foreach (string markFile in Directory.EnumerateFiles(dir, $"{fileNameNoExt}_*", + SearchOption.TopDirectoryOnly)) { - 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); + ReadOnlySpan markFilename = Path.GetFileName(markFile); + ReadOnlySpan hashSpan = ConverterTool.GetSplit(markFilename, ^2, "_."); + if (!HexTool.IsHexString(hashSpan)) + { + continue; + } - // Re-create the hash file - string toName = Path.Combine(basePath ?? "", $"{baseName}_{hash}.hash"); - if (File.Exists(toName)) return; - File.Create(toName).Dispose(); - } + if (!asset.CRC?.Equals(hashSpan, StringComparison.OrdinalIgnoreCase) ?? false) + { + AddAssetInner(markFile); + } - 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); + // Add equal hash mark if the file is marked as unused. + if (asset.FT != FileType.Unused) + { + continue; + } - // Get directory base info. If it doesn't exist, return - if (string.IsNullOrEmpty(basePath)) - { - return; + AddAssetInner(markFile); } - DirectoryInfo basePathDirInfo = new DirectoryInfo(basePath); - if (!basePathDirInfo.Exists) - { - return; - } + return; - // Enumerate any possible existing hash path and delete it - foreach (FileInfo existingPath in basePathDirInfo.EnumerateFiles($"{baseName}_*.hash") - .EnumerateNoReadOnly()) + void AddAssetInner(string thisFilePath) { - existingPath.Delete(); + string relPath = thisFilePath[gamePath.Length..].Trim('\\'); + AddIndex(new FilePropertiesRemote + { + FT = FileType.Unused, + N = relPath + }, brokenFileList); } } #endregion diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs index 10e5c3a75..363a0d3d6 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs @@ -1,7 +1,6 @@ using CollapseLauncher.Helper; using CollapseLauncher.Helper.Metadata; using CollapseLauncher.RepairManagement; -using Hi3Helper; using Hi3Helper.EncTool.Parser.AssetMetadata; using Hi3Helper.Shared.ClassStruct; using System; @@ -14,7 +13,6 @@ using System.Threading; using System.Threading.Tasks; using static Hi3Helper.Locale; -using static Hi3Helper.Logger; // ReSharper disable CommentTypo // ReSharper disable StringLiteralTypo // ReSharper disable SwitchStatementHandlesSomeKnownEnumValuesWithDefault @@ -22,41 +20,6 @@ #nullable enable 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) @@ -66,14 +29,13 @@ private async Task Fetch(List assetIndex, CancellationToke Status.IsProgressAllIndetermined = true; UpdateStatus(); - StarRailRepairExtension.ClearHashtable(); // Initialize new proxy-aware HttpClient using HttpClient client = new HttpClientBuilder() - .UseLauncherConfig(DownloadThreadWithReservedCount) - .SetUserAgent(UserAgent) - .SetAllowedDecompression(DecompressionMethods.None) - .Create(); + .UseLauncherConfig(DownloadThreadWithReservedCount) + .SetUserAgent(UserAgent) + .SetAllowedDecompression(DecompressionMethods.None) + .Create(); HttpClient sharedClient = FallbackCDNUtil.GetGlobalHttpClient(true); @@ -81,65 +43,57 @@ private async Task Fetch(List assetIndex, CancellationToke string regionId = GetExistingGameRegionID(); string[] installedVoiceLang = await GetInstalledVoiceLanguageOrDefault(token); - try - { - // Get the primary manifest - await GetPrimaryManifest(assetIndex, installedVoiceLang, token); + // 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) + // If the this._isOnlyRecoverMain && base._isVersionOverride is true, copy the asset index into the _originAssetIndex + if (IsOnlyRecoverMain && IsVersionOverride) + { + OriginAssetIndex = []; + foreach (FilePropertiesRemote asset in assetIndex) { - 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); - } + 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 - .GetReferenceAsync(this, - dispatcherInfo, - client, - GamePath, - GameDataPersistentPathRelative, - token); - - persistentRefResult.GetPersistentFiles(assetIndex, GamePath, installedVoiceLang, token); - await StarRailPersistentRefResult.FinalizeFetchAsync(this, sharedClient, assetIndex, GameDataPersistentPath, token); - } + // 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 + .GetReferenceAsync(this, + dispatcherInfo, + client, + GamePath, + GameDataPersistentPathRelative, + token); + + assetIndex.AddRange(persistentRefResult.GetPersistentFiles(assetIndex, GamePath, installedVoiceLang, + token)); + await StarRailPersistentRefResult.FinalizeFetchAsync(this, sharedClient, assetIndex, + GameDataPersistentPath, token); + } - // Force-Fetch the Bilibili SDK (if exist :pepehands:) - await FetchBilibiliSdk(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); - } - } - finally + // Remove plugin from assetIndex + // Skip the removal for Delta-Patch + if (!IsOnlyRecoverMain) { - // Clear the hashtable - StarRailRepairExtension.ClearHashtable(); - // Unsubscribe the fetching progress and dispose it and unsubscribe cacheUtil progress to adapter - // _innerGameVersionManager.StarRailMetadataTool.HttpEvent -= _httpClient_FetchAssetProgress; + EliminatePluginAssetIndex(assetIndex, x => x.N, x => x.RN); } } @@ -223,13 +177,11 @@ 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; @@ -262,12 +214,13 @@ private void CountAssetIndex(List assetIndex) } private static RepairAssetType ConvertRepairAssetTypeEnum(FileType assetType) => assetType switch - { - FileType.Block => RepairAssetType.Block, - FileType.Audio => RepairAssetType.Audio, - FileType.Video => RepairAssetType.Video, - _ => RepairAssetType.Generic - }; + { + 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/StarRail/Repair.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Repair.cs index 371a1b77d..5c391da6d 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Repair.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/Repair.cs @@ -1,143 +1,288 @@ -using CollapseLauncher.Helper; +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 System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.ObjectModel; +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; -using static Hi3Helper.Locale; -using static Hi3Helper.Logger; +#pragma warning disable IDE0130 +#nullable enable namespace CollapseLauncher { internal partial class StarRailRepair { - private async Task Repair(List repairAssetIndex, CancellationToken token) + private static ReadOnlySpan HashMarkFileContent => [0x20]; + + public async Task StartRepairRoutine( + bool showInteractivePrompt = false, + Action? actionIfInteractiveCancel = null) { - // Set total activity string as "Waiting for repair process to start..." - Status.ActivityStatus = Lang._GameRepairPage.Status11; - Status.IsProgressAllIndetermined = true; - Status.IsProgressPerFileIndetermined = true; + await TryRunExamineThrow(StartRepairRoutineCoreAsync(showInteractivePrompt, actionIfInteractiveCancel)); - // Update status - UpdateStatus(); + // Reset status and progress + ResetStatusAndProgress(); - // 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) + // Set as completed + Status.ActivityStatus = 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 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 _); - }); + await SpawnRepairDialog(AssetIndex, actionIfInteractiveCancel); } - else + + // Initialize new proxy-aware HttpClient + using HttpClient client = new HttpClientBuilder() + .UseLauncherConfig(DownloadThreadWithReservedCount) + .SetUserAgent(UserAgent) + .SetAllowedDecompression(DecompressionMethods.None) + .Create(); + + int threadNum = IsBurstDownloadEnabled + ? 1 + : ThreadForIONormalized; + + await Parallel.ForEachAsync(AssetIndex, + new ParallelOptions + { + CancellationToken = Token!.Token, + MaxDegreeOfParallelism = threadNum + }, + Impl); + + return; + + async ValueTask Impl(FilePropertiesRemote asset, CancellationToken token) { - foreach ((FilePropertiesRemote AssetIndex, IAssetProperty AssetProperty) asset in - PairEnumeratePropertyAndAssetIndexPackage( -#if ENABLEHTTPREPAIR - EnforceHttpSchemeToAssetIndex(repairAssetIndex) -#else - repairAssetIndex -#endif - , assetProperty)) + await (asset switch { - 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); + { AssociatedObject: SophonAsset } => RepairAssetGenericSophonType(asset, token), + // ReSharper disable once AccessToDisposedClosure + _ => RepairAssetGenericType(client, asset, token) + }); - // Await the task - await assetTask; - runningTask.Remove(asset, out _); + if (!asset.IsHasHashMark) + { + return; } - } - return true; + 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); + } } - #region GenericRepair - private async Task RepairAssetTypeGeneric((FilePropertiesRemote AssetIndex, IAssetProperty AssetProperty) asset, DownloadClient downloadClient, DownloadProgressDelegate downloadProgress, CancellationToken token) + private async ValueTask RepairAssetGenericSophonType( + FilePropertiesRemote asset, + 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) + // Update repair status to the UI + this.UpdateCurrentRepairStatus(asset); + + try { - if (fileInfo.Exists) + 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()); + + if (asset.AssociatedObject is not SophonAsset sophonAsset) { - fileInfo.Delete(); - LogWriteLine($"File [T: {asset.AssetIndex.FT}] {(asset.AssetIndex.FT == FileType.Block ? asset.AssetIndex.CRC : asset.AssetIndex.N)} deleted!", LogType.Default, true); + throw new + InvalidOperationException("Invalid operation! This asset shouldn't have been here! It's not a sophon-based asset!"); } - RemoveHashMarkFile(asset.AssetIndex.N, out _, out _); + + // Download as Sophon asset + await sophonAsset + .WriteToStreamAsync(FallbackCDNUtil.GetGlobalHttpClient(true), + assetFileStream, + readBytes => UpdateProgressCounter(readBytes, readBytes), + token: token); } - else + finally { - 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) + this.PopBrokenAssetFromList(asset); + } + } + + private async ValueTask RepairAssetGenericType( + HttpClient downloadHttpClient, + 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() + .EnsureNoReadOnly(); + + try + { + if (asset.FT == FileType.Unused) { - 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); + if (assetFileInfo.TryDeleteFile()) + { + Logger.LogWriteLine($"[StarRailRepair::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($"[StarRailRepair::RepairAssetGenericType] Asset {asset.N} has been downloaded!", + LogType.Default, + true); + } + finally + { + this.PopBrokenAssetFromList(asset); + } + } + + // 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; - // Pop repair asset display entry - PopRepairAssetEntry(asset.AssetProperty); + ProgressAllSizeCurrent = 0; + ProgressPerFileSizeCurrent = 0; } - #endregion } } diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.FinalizeFetch.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.FinalizeFetch.cs index 769199e32..16311ed91 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.FinalizeFetch.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.FinalizeFetch.cs @@ -1,5 +1,4 @@ -using CollapseLauncher.Helper.StreamUtility; -using Hi3Helper; +using Hi3Helper; using Hi3Helper.Shared.ClassStruct; using Hi3Helper.Sophon; using System; @@ -8,7 +7,6 @@ using System.IO; using System.Linq; using System.Net.Http; -using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.GetPersistentFiles.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.GetPersistentFiles.cs index 2ee489efb..8c836d090 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.GetPersistentFiles.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.GetPersistentFiles.cs @@ -67,6 +67,7 @@ public List GetPersistentFiles( BaseDirs.PersistentAsbBlock, BaseUrls.AsbBlock, BaseUrls.AsbBlockPersistent, + false, fileList, unusedAssets, oldDic, @@ -77,6 +78,7 @@ public List GetPersistentFiles( BaseDirs.PersistentAsbBlock, BaseUrls.AsbBlock, BaseUrls.AsbBlockPersistent, + false, fileList, unusedAssets, oldDic, @@ -87,6 +89,7 @@ public List GetPersistentFiles( BaseDirs.PersistentVideo, BaseUrls.Video, BaseUrls.Video, + true, fileList, unusedAssets, oldDic, @@ -97,6 +100,7 @@ public List GetPersistentFiles( BaseDirs.PersistentAudio, BaseUrls.Audio, BaseUrls.Audio, + true, fileList, unusedAssets, oldDic, @@ -112,6 +116,7 @@ private static void AddAdditionalAssets( string assetDirPathPersistent, string urlBase, string urlBasePersistent, + bool isHashMarked, List fileList, Dictionary unusedFileList, Dictionary fileDic, @@ -168,11 +173,12 @@ private static void AddAdditionalAssets( FilePropertiesRemote file = new() { - RN = url, - N = relPathInPersistent, - S = asset.FileSize, - CRCArray = asset.MD5Checksum, - FT = StarRailRepair.DetermineFileTypeFromExtension(asset.Filename ?? "") + RN = url, + N = relPathInPersistent, + S = asset.FileSize, + CRCArray = asset.MD5Checksum, + FT = StarRailRepair.DetermineFileTypeFromExtension(asset.Filename ?? ""), + IsHasHashMark = isHashMarked }; fileDic.TryAdd(relPathInPersistent, file); fileList.Add(file); diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs index a6539d170..6feb86d3f 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs @@ -35,32 +35,9 @@ private StarRailInstall InnerGameInstaller private string ExecName { get; } private string GameDataPersistentPathRelative { get => Path.Combine($"{ExecName}_Data", "Persistent"); } private string GameDataPersistentPath { get => Path.Combine(GamePath, GameDataPersistentPathRelative); } - 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"; + private string GameAudioLangListPathStatic { get => Path.Combine(GameDataPersistentPath, "AudioLaucherRecord.txt"); } - 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 @@ -92,18 +69,6 @@ public async Task StartCheckRoutine(bool 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 @@ -132,28 +97,6 @@ private async Task CheckRoutine() 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 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++; From 18910b1acf74dd32db74d275c46f43d68874cd87 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 3 Jan 2026 03:56:33 +0700 Subject: [PATCH 06/14] Rename class and implement cache update fetch --- .../CachesManagement/StarRail/Check.cs | 149 ----------- .../CachesManagement/StarRail/Fetch.cs | 139 ---------- .../StarRail/StarRailCache.cs | 110 -------- .../StarRail/StarRailCacheV2.cs | 20 ++ .../CachesManagement/StarRail/Update.cs | 117 --------- .../Classes/GamePresetProperty.cs | 23 +- .../Helper/StreamUtility/StreamExtension.cs | 2 +- .../StarRail/StarRailInstall.cs | 6 +- ...arRailPersistentRefResult.FinalizeFetch.cs | 75 ------ ...arRailPersistentRefResult.FinalizeFetch.cs | 241 +++++++++++++++++ ...lPersistentRefResult.GetPersistentFiles.cs | 69 ++++- .../StarRailPersistentRefResult.cs | 247 ++++++++++++++++-- .../StarRailRepairV2.Check.cs} | 15 +- .../StarRailRepairV2.Fetch.cs} | 25 +- ...tarRailRepairV2.FetchForCacheUpdateMode.cs | 57 ++++ .../StarRailRepairV2.Repair.cs} | 22 +- .../StarRailRepairV2.cs} | 30 ++- .../Assets/StarRailAssetBlockMetadata.cs | 5 + .../Assets/StarRailAssetBundleMetadata.cs | 11 +- .../Struct/Assets/StarRailAssetCsvMetadata.cs | 102 ++++++++ .../Assets/StarRailAssetGenericFileInfo.cs | 7 + .../Assets/StarRailAssetJsonMetadata.cs | 8 +- .../Assets/StarRailAssetNativeDataMetadata.cs | 9 +- .../StarRailAssetSignaturelessMetadata.cs | 26 +- .../Struct/StarRailAssetBinaryMetadata.cs | 9 +- .../Struct/StarRailAssetMetadataIndex.cs | 146 ++++++++++- .../Struct/StarRailBinaryData.cs | 56 +++- .../Struct/StarRailBinaryDataExtension.cs | 2 + .../Struct/StarRailBinaryDataWritable.cs | 37 ++- Hi3Helper.EncTool | 2 +- 30 files changed, 1068 insertions(+), 699 deletions(-) delete mode 100644 CollapseLauncher/Classes/CachesManagement/StarRail/Check.cs delete mode 100644 CollapseLauncher/Classes/CachesManagement/StarRail/Fetch.cs delete mode 100644 CollapseLauncher/Classes/CachesManagement/StarRail/StarRailCache.cs create mode 100644 CollapseLauncher/Classes/CachesManagement/StarRail/StarRailCacheV2.cs delete mode 100644 CollapseLauncher/Classes/CachesManagement/StarRail/Update.cs delete mode 100644 CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.FinalizeFetch.cs create mode 100644 CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.FinalizeFetch.cs rename CollapseLauncher/Classes/RepairManagement/{StarRail => StarRailV2}/StarRailPersistentRefResult.GetPersistentFiles.cs (74%) rename CollapseLauncher/Classes/RepairManagement/{StarRail => StarRailV2}/StarRailPersistentRefResult.cs (71%) rename CollapseLauncher/Classes/RepairManagement/{StarRail/Check.cs => StarRailV2/StarRailRepairV2.Check.cs} (95%) rename CollapseLauncher/Classes/RepairManagement/{StarRail/Fetch.cs => StarRailV2/StarRailRepairV2.Fetch.cs} (91%) create mode 100644 CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.FetchForCacheUpdateMode.cs rename CollapseLauncher/Classes/RepairManagement/{StarRail/Repair.cs => StarRailV2/StarRailRepairV2.Repair.cs} (93%) rename CollapseLauncher/Classes/RepairManagement/{StarRail/StarRailRepair.cs => StarRailV2/StarRailRepairV2.cs} (77%) rename CollapseLauncher/Classes/RepairManagement/{StarRail => StarRailV2}/Struct/Assets/StarRailAssetBlockMetadata.cs (96%) rename CollapseLauncher/Classes/RepairManagement/{StarRail => StarRailV2}/Struct/Assets/StarRailAssetBundleMetadata.cs (90%) create mode 100644 CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetCsvMetadata.cs rename CollapseLauncher/Classes/RepairManagement/{StarRail => StarRailV2}/Struct/Assets/StarRailAssetGenericFileInfo.cs (88%) rename CollapseLauncher/Classes/RepairManagement/{StarRail => StarRailV2}/Struct/Assets/StarRailAssetJsonMetadata.cs (88%) rename CollapseLauncher/Classes/RepairManagement/{StarRail => StarRailV2}/Struct/Assets/StarRailAssetNativeDataMetadata.cs (94%) rename CollapseLauncher/Classes/RepairManagement/{StarRail => StarRailV2}/Struct/Assets/StarRailAssetSignaturelessMetadata.cs (87%) rename CollapseLauncher/Classes/RepairManagement/{StarRail => StarRailV2}/Struct/StarRailAssetBinaryMetadata.cs (76%) rename CollapseLauncher/Classes/RepairManagement/{StarRail => StarRailV2}/Struct/StarRailAssetMetadataIndex.cs (53%) rename CollapseLauncher/Classes/RepairManagement/{StarRail => StarRailV2}/Struct/StarRailBinaryData.cs (64%) rename CollapseLauncher/Classes/RepairManagement/{StarRail => StarRailV2}/Struct/StarRailBinaryDataExtension.cs (99%) rename CollapseLauncher/Classes/RepairManagement/{StarRail => StarRailV2}/Struct/StarRailBinaryDataWritable.cs (54%) 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/GamePresetProperty.cs b/CollapseLauncher/Classes/GamePresetProperty.cs index 159dbc8a9..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.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/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/StarRail/StarRailInstall.cs b/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.cs index c03458352..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,8 +90,8 @@ public override async ValueTask StartPackageVerification(List - new StarRailRepair(ParentUI, + protected override StarRailRepairV2 GetGameRepairInstance(string? versionString) => + new StarRailRepairV2(ParentUI, GameVersionManager, GameSettings, true, diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.FinalizeFetch.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.FinalizeFetch.cs deleted file mode 100644 index 16311ed91..000000000 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.FinalizeFetch.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Hi3Helper; -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.Threading; -using System.Threading.Tasks; - -#pragma warning disable IDE0130 -#nullable enable -namespace CollapseLauncher; - -internal partial class StarRailPersistentRefResult -{ - public static async Task FinalizeFetchAsync(StarRailRepair instance, - HttpClient client, - List assetIndex, - string persistentDir, - CancellationToken token) - { - // 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::FinalizeFetchAsync] 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..]); - } - } -} 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/StarRail/StarRailPersistentRefResult.GetPersistentFiles.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.GetPersistentFiles.cs similarity index 74% rename from CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.GetPersistentFiles.cs rename to CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.GetPersistentFiles.cs index 8c836d090..2a3ec8c42 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.GetPersistentFiles.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.GetPersistentFiles.cs @@ -11,8 +11,10 @@ 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 @@ -107,9 +109,74 @@ public List GetPersistentFiles( Metadata.AudioV!.DataList .WhereNotStartWith(excludedAudioLangPrefix)); + AddUnusedAudioAssets(gameDirPath, + BaseDirs.StreamingAudio, + BaseDirs.PersistentAudio, + Metadata.AudioV!.DataList, + fileList, + excludedAudioLangPrefix); + 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, @@ -177,7 +244,7 @@ private static void AddAdditionalAssets( N = relPathInPersistent, S = asset.FileSize, CRCArray = asset.MD5Checksum, - FT = StarRailRepair.DetermineFileTypeFromExtension(asset.Filename ?? ""), + FT = StarRailRepairV2.DetermineFileTypeFromExtension(asset.Filename ?? ""), IsHasHashMark = isHashMarked }; fileDic.TryAdd(relPathInPersistent, file); diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.cs similarity index 71% rename from CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.cs rename to CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.cs index 68058b98b..45ad5cc1a 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailPersistentRefResult.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.cs @@ -17,6 +17,9 @@ 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 @@ -24,12 +27,125 @@ 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 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 GetReferenceAsync( - StarRailRepair instance, + 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 = "", + 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, @"Asb\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, @@ -58,7 +174,7 @@ public static async Task GetReferenceAsync( throw new HttpRequestException("Seems like the URL for ArchiveV is missing. Please report this issue to our devs!"); } - Logger.LogWriteLine($"[StarRailPersistentRefResult::GetReferenceAsync] Given ArchiveV Url is invalid! (previously: {refArchiveUrl}). Try swapping...", + Logger.LogWriteLine($"[StarRailPersistentRefResult::GetRepairReferenceAsync] Given ArchiveV Url is invalid! (previously: {refArchiveUrl}). Try swapping...", LogType.Warning, true); @@ -70,7 +186,7 @@ public static async Task GetReferenceAsync( refArchiveUrl = mainUrlArchive.CombineURLFromString("M_ArchiveV.bytes"); goto TestArchiveVEndpoint; } - Logger.LogWriteLine($"[StarRailPersistentRefResult::GetReferenceAsync] ArchiveV Url is found! at: {refArchiveUrl}", + Logger.LogWriteLine($"[StarRailPersistentRefResult::GetRepairReferenceAsync] ArchiveV Url is found! at: {refArchiveUrl}", LogType.Debug, true); @@ -139,14 +255,14 @@ public static async Task GetReferenceAsync( throw new HttpRequestException("Seems like the URL for persistent asset bundle is missing. Please report this issue to our devs!"); } - Logger.LogWriteLine($"[StarRailPersistentRefResult::GetReferenceAsync] Given persistent asset bundle URL is invalid! (previously: {baseUrl.AsbBlockPersistent}). Try swapping...", + 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::GetReferenceAsync] Persistent asset bundle URL is found! at: {baseUrl.AsbBlockPersistent}", + Logger.LogWriteLine($"[StarRailPersistentRefResult::GetRepairReferenceAsync] Persistent asset bundle URL is found! at: {baseUrl.AsbBlockPersistent}", LogType.Debug, true); @@ -261,7 +377,7 @@ await LoadMetadataFile(instance, } private static async ValueTask SaveLocalIndexFiles( - StarRailRepair instance, + StarRailRepairV2 instance, Dictionary handleArchiveSource, string outputDir, string indexKey, @@ -284,8 +400,8 @@ private static async ValueTask SaveLocalIndexFiles( await indexMetadata.WriteAsync(filePath, token); } - private static async ValueTask LoadMetadataFile( - StarRailRepair instance, + private static ValueTask LoadMetadataFile( + StarRailRepairV2 instance, Dictionary handleArchiveSource, HttpClient client, string baseUrl, @@ -295,7 +411,27 @@ private static async ValueTask SaveLocalIndexFiles( 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); @@ -315,7 +451,7 @@ private static async ValueTask SaveLocalIndexFiles( foreach (FileInfo oldFilePath in dirInfo.EnumerateFiles($"{index.UnaliasedFileName}_*.bytes", SearchOption.TopDirectoryOnly)) { ReadOnlySpan fileNameOnly = oldFilePath.Name; - ReadOnlySpan fileHash = ConverterTool.GetSplit(fileNameOnly, ^2, "_."); + ReadOnlySpan fileHash = ConverterTool.GetSplit(fileNameOnly, ^2, "_."); if (HexTool.IsHexString(fileHash) && !fileHash.Equals(index.ContentHash, StringComparison.OrdinalIgnoreCase)) { @@ -331,7 +467,7 @@ private static async ValueTask SaveLocalIndexFiles( // Check if the stream has been downloaded if (!string.IsNullOrEmpty(saveToLocalDir) && - Path.Combine(saveToLocalDir, filename) is {} localFilePath && + Path.Combine(saveToLocalDir, filename) is { } localFilePath && File.Exists(localFilePath)) { await using FileStream existingFileStream = File.OpenRead(localFilePath); @@ -415,6 +551,11 @@ public class AssetBaseDirs( string nNativeData, string nVideo) { + public AssetBaseDirs() : this("", "", "", "", "", "") + { + + } + public string PersistentArchive { get; set; } = nArchive; public string PersistentAsbBlock { get; set; } = nAsbBlock; public string PersistentAudio { get; set; } = nAudio; @@ -428,6 +569,9 @@ public class AssetBaseDirs( public string StreamingNativeData { get; set; } = GetStreamingAssetsDir(nNativeData); public string StreamingVideo { get; set; } = GetStreamingAssetsDir(nVideo); + public string? CacheIFix { get; set; } + public string? CacheLua { get; set; } + private static string GetStreamingAssetsDir(string dir) => dir.Replace("Persistent", "StreamingAssets"); } @@ -442,6 +586,9 @@ public class AssetBaseUrls public required string NativeData { get; set; } public required string Video { get; set; } + public string? CacheLua { get; set; } + public string? CacheIFix { get; set; } + public void SwapAsbPersistentUrl() => (AsbBlock, AsbBlockPersistent) = (AsbBlockPersistent, AsbBlock); } @@ -455,6 +602,9 @@ public class AssetMetadata public StarRailAssetBlockMetadata? BlockV { get; set; } public StarRailAssetJsonMetadata? AudioV { get; set; } public StarRailAssetJsonMetadata? VideoV { get; set; } + + public StarRailAssetSignaturelessMetadata? CacheLua { get; set; } + public StarRailAssetCsvMetadata? CacheIFix { get; set; } } } @@ -504,7 +654,8 @@ public StarRailAssetMetadataIndex ToMetadataIndex() MD5Checksum = HexTool.HexToBytesUnsafe(ContentHash), MetadataIndexFileSize = (int)FileSize, PrevPatch = 0, // Leave PrevPatch to be 0 - Timestamp = TimeStamp + Timestamp = TimeStamp, + Reserved = new byte[10] }; metadataIndex.DataList.Add(indexData); @@ -520,7 +671,7 @@ public StarRailAssetMetadataIndex ToMetadataIndex() public static implicit operator StarRailAssetMetadataIndex(StarRailRefMainInfo instance) => instance.ToMetadataIndex(); public static async Task> ParseListFromUrlAsync( - StarRailRepair instance, + StarRailRepairV2 instance, HttpClient client, string url, string? saveToLocalDir = null, @@ -548,14 +699,64 @@ public static async Task> ParseListFromU } return returnList; + } - static Stream CreateLocalStream(Stream thisSourceStream, string filePath) + 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() { - FileInfo fileInfo = new FileInfo(filePath) - .EnsureCreationOfDirectory() - .EnsureNoReadOnly() - .StripAlternateDataStream(); - return new CopyToStream(thisSourceStream, fileInfo.Create(), null, true); - } + 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/StarRail/Check.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Check.cs similarity index 95% rename from CollapseLauncher/Classes/RepairManagement/StarRail/Check.cs rename to CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Check.cs index bff20de78..a43d36cd3 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Check.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Check.cs @@ -15,9 +15,13 @@ // 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 StarRailRepair + internal partial class StarRailRepairV2 { private async Task Check(List assetIndex, CancellationToken token) { @@ -48,9 +52,8 @@ private async Task Check(List assetIndex, CancellationToke 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 Parallel.ForEachAsync(assetIndex, new ParallelOptions { MaxDegreeOfParallelism = 1, CancellationToken = token }, async (asset, threadToken) => { - // Assign a task depends on the asset type await CheckGenericAssetType(asset, brokenAssetIndex, threadToken); }); } @@ -77,7 +80,7 @@ private async Task CheckGenericAssetType(FilePropertiesRemote asset, List tar // Increment the total current progress ProgressAllSizeCurrent += asset.S; - var prop = new AssetProperty(Path.GetFileName(asset.N)!, + var prop = new AssetProperty(Path.GetFileName(asset.N), ConvertRepairAssetTypeEnum(asset.FT), Path.GetDirectoryName(asset.N), asset.S, @@ -163,7 +166,7 @@ private void AddUnusedHashMarkFile(string filePath, FilePropertiesRemote asset, List brokenFileList) { - if (asset.CRCArray.Length == 0 || + if (asset.CRCArray?.Length == 0 || (!asset.IsHasHashMark && asset.FT != FileType.Unused)) { return; diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Fetch.cs similarity index 91% rename from CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs rename to CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Fetch.cs index 363a0d3d6..1fb63c14e 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Fetch.cs @@ -17,10 +17,13 @@ // 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 StarRailRepair + internal partial class StarRailRepairV2 { private async Task Fetch(List assetIndex, CancellationToken token) { @@ -37,11 +40,17 @@ private async Task Fetch(List assetIndex, CancellationToke .SetAllowedDecompression(DecompressionMethods.None) .Create(); - HttpClient sharedClient = FallbackCDNUtil.GetGlobalHttpClient(true); + // Get shared HttpClient + HttpClient sharedClient = FallbackCDNUtil.GetGlobalHttpClient(true); + string regionId = GetExistingGameRegionID(); + string[] installedVoiceLang = await GetInstalledVoiceLanguageOrDefault(token); - // Initialize the new DownloadClient - 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); @@ -73,7 +82,7 @@ private async Task Fetch(List assetIndex, CancellationToke await dispatcherInfo.Initialize(client, regionId, token); StarRailPersistentRefResult persistentRefResult = await StarRailPersistentRefResult - .GetReferenceAsync(this, + .GetRepairReferenceAsync(this, dispatcherInfo, client, GamePath, @@ -82,8 +91,8 @@ private async Task Fetch(List assetIndex, CancellationToke assetIndex.AddRange(persistentRefResult.GetPersistentFiles(assetIndex, GamePath, installedVoiceLang, token)); - await StarRailPersistentRefResult.FinalizeFetchAsync(this, sharedClient, assetIndex, - GameDataPersistentPath, token); + await persistentRefResult.FinalizeRepairFetchAsync(this, sharedClient, assetIndex, + GameDataPersistentPath, token); } // Force-Fetch the Bilibili SDK (if exist :pepehands:) diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.FetchForCacheUpdateMode.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.FetchForCacheUpdateMode.cs new file mode 100644 index 000000000..1e8b7a012 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.FetchForCacheUpdateMode.cs @@ -0,0 +1,57 @@ +using CollapseLauncher.Helper.Metadata; +using Hi3Helper.EncTool.Parser.AssetMetadata; +using Hi3Helper.Shared.ClassStruct; +using System.Collections.Generic; +using System.Drawing; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Windows.Media.Protection.PlayReady; + +#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); + } + + #endregion +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Repair.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Repair.cs similarity index 93% rename from CollapseLauncher/Classes/RepairManagement/StarRail/Repair.cs rename to CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Repair.cs index 5c391da6d..c1c8d12fd 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Repair.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Repair.cs @@ -14,12 +14,14 @@ 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 StarRailRepair + internal partial class StarRailRepairV2 { private static ReadOnlySpan HashMarkFileContent => [0x20]; @@ -107,14 +109,14 @@ 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, @@ -137,6 +139,7 @@ await sophonAsset finally { this.PopBrokenAssetFromList(asset); + assetFileInfo.Directory?.DeleteEmptyDirectory(true); } } @@ -159,7 +162,7 @@ private async ValueTask RepairAssetGenericType( { if (assetFileInfo.TryDeleteFile()) { - Logger.LogWriteLine($"[StarRailRepair::RepairAssetGenericType] Unused asset {asset} has been deleted!", + Logger.LogWriteLine($"[StarRailRepairV2::RepairAssetGenericType] Unused asset {asset} has been deleted!", LogType.Default, true); } @@ -179,13 +182,14 @@ await RunDownloadTask(asset.S, ProgressRepairAssetGenericType, token); - Logger.LogWriteLine($"[StarRailRepair::RepairAssetGenericType] Asset {asset.N} has been downloaded!", + Logger.LogWriteLine($"[StarRailRepairV2::RepairAssetGenericType] Asset {asset.N} has been downloaded!", LogType.Default, true); } finally { this.PopBrokenAssetFromList(asset); + assetFileInfo.Directory?.DeleteEmptyDirectory(true); } } diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.cs similarity index 77% rename from CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs rename to CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.cs index 6feb86d3f..a0805b070 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.cs @@ -12,9 +12,13 @@ using static Hi3Helper.Locale; // ReSharper disable StringLiteralTypo +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + namespace CollapseLauncher { - internal partial class StarRailRepair : ProgressBase, IRepair, IRepairAssetIndex + internal partial class StarRailRepairV2 : ProgressBase, IRepair, IRepairAssetIndex { #region Properties @@ -25,14 +29,16 @@ public override string GamePath } private GameTypeStarRailVersion InnerGameVersionManager { get; } - private StarRailInstall InnerGameInstaller + private StarRailInstall? InnerGameInstaller { get => field ??= GamePropertyVault.GetCurrentGameProperty().GameInstall as StarRailInstall; } - private bool IsOnlyRecoverMain { get; } - private List OriginAssetIndex { get; set; } - private string ExecName { get; } + 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); } @@ -41,12 +47,13 @@ private StarRailInstall InnerGameInstaller protected override string UserAgent => "UnityPlayer/2019.4.34f1 (UnityWebRequest/1.0, libcurl/7.75.0-DEV)"; #endregion - public StarRailRepair( + public StarRailRepairV2( UIElement parentUI, IGameVersion gameVersionManager, IGameSettings gameSettings, bool onlyRecoverMainAsset = false, - string versionOverride = null) + string? versionOverride = null, + bool isCacheUpdateMode = false) : base(parentUI, gameVersionManager, gameSettings, @@ -55,11 +62,12 @@ public StarRailRepair( { // Get flag to only recover main assets IsOnlyRecoverMain = onlyRecoverMainAsset; - InnerGameVersionManager = gameVersionManager as GameTypeStarRailVersion; - ExecName = Path.GetFileNameWithoutExtension(InnerGameVersionManager!.GamePreset.GameExecutableName); + InnerGameVersionManager = (gameVersionManager as GameTypeStarRailVersion)!; + ExecName = Path.GetFileNameWithoutExtension(InnerGameVersionManager.GamePreset.GameExecutableName) ?? ""; + IsCacheUpdateMode = isCacheUpdateMode; } - ~StarRailRepair() => Dispose(); + ~StarRailRepairV2() => Dispose(); public List GetAssetIndex() => OriginAssetIndex; @@ -81,7 +89,7 @@ private async Task CheckRoutine() 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); + await InnerGameInstaller!.FilterAssetList(AssetIndex, x => x.N, Token.Token); // Step 3: Calculate the total size and count of the files CountAssetIndex(AssetIndex); diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetBlockMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetBlockMetadata.cs similarity index 96% rename from CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetBlockMetadata.cs rename to CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetBlockMetadata.cs index 386911b47..5367d05e8 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetBlockMetadata.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetBlockMetadata.cs @@ -7,12 +7,17 @@ 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() diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetBundleMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetBundleMetadata.cs similarity index 90% rename from CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetBundleMetadata.cs rename to CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetBundleMetadata.cs index 8bcc0856f..840eded8c 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetBundleMetadata.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetBundleMetadata.cs @@ -1,5 +1,4 @@ -using Hi3Helper.Data; -using Hi3Helper.EncTool; +using Hi3Helper.EncTool; using System; using System.Buffers; using System.Collections.Generic; @@ -9,12 +8,16 @@ using System.Threading; using System.Threading.Tasks; -#pragma warning disable IDE0290 +#pragma warning disable IDE0290 // Shut the fuck up #pragma warning disable IDE0130 +#nullable enable namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; -public class StarRailAssetBundleMetadata : StarRailAssetBinaryMetadata +/// +/// 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, 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/StarRail/Struct/Assets/StarRailAssetGenericFileInfo.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetGenericFileInfo.cs similarity index 88% rename from CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetGenericFileInfo.cs rename to CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetGenericFileInfo.cs index 076ed35b5..a98b5d233 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetGenericFileInfo.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetGenericFileInfo.cs @@ -2,11 +2,15 @@ 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 { /// @@ -23,6 +27,9 @@ 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 { /// diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetJsonMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetJsonMetadata.cs similarity index 88% rename from CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetJsonMetadata.cs rename to CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetJsonMetadata.cs index 4e7c79c3b..12a9ab838 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetJsonMetadata.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetJsonMetadata.cs @@ -6,13 +6,17 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -#pragma warning disable IDE0290 + +#pragma warning disable IDE0290 // Shut the fuck up #pragma warning disable IDE0130 #nullable enable namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; -internal partial class StarRailAssetJsonMetadata : StarRailAssetBinaryMetadata +/// +/// 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 diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetNativeDataMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetNativeDataMetadata.cs similarity index 94% rename from CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetNativeDataMetadata.cs rename to CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetNativeDataMetadata.cs index 3aab78b0a..180472160 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetNativeDataMetadata.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetNativeDataMetadata.cs @@ -8,13 +8,18 @@ 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; -internal sealed class StarRailAssetNativeDataMetadata : StarRailAssetBinaryMetadata +/// +/// 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, @@ -154,7 +159,7 @@ public ReadOnlySpan Shifted4BytesMD5Checksum public class Metadata : StarRailAssetGenericFileInfo { - public static Span Parse(Span filenameBuffer, + public static Span Parse(Span filenameBuffer, ref FileInfoStruct assetInfo, out Metadata? result) { diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetSignaturelessMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetSignaturelessMetadata.cs similarity index 87% rename from CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetSignaturelessMetadata.cs rename to CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetSignaturelessMetadata.cs index f79f49016..bb829a3c2 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/Assets/StarRailAssetSignaturelessMetadata.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetSignaturelessMetadata.cs @@ -8,19 +8,33 @@ using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; -#pragma warning disable IDE0290 + +#pragma warning disable IDE0290 // Shut the fuck up #pragma warning disable IDE0130 +#nullable enable namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; -internal class StarRailAssetSignaturelessMetadata : StarRailAssetBinaryMetadata +/// +/// 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() + 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) { } + 0) + { + AssetExtension = customAssetExtension ?? ".block"; + } + + private string AssetExtension { get; } protected override ReadOnlySpan MagicSignature => [0x00, 0x00, 0x00, 0xFF]; @@ -89,6 +103,7 @@ protected override async ValueTask ReadDataCoreAsync( cancellationToken: token) .ConfigureAwait(false); Metadata.Parse(parentDataBuffer, + AssetExtension, childrenDataBufferLen, lastPos, out int bytesToSkip, @@ -111,6 +126,7 @@ protected override async ValueTask ReadDataCoreAsync( public class Metadata : StarRailAssetFlaggable { public static void Parse(ReadOnlySpan buffer, + string assetExtension, int subDataSize, long lastDataStreamPos, out int bytesToSkip, @@ -131,7 +147,7 @@ public static void Parse(ReadOnlySpan buffer, result = new Metadata { MD5Checksum = md5Hash, - Filename = $"{HexTool.BytesToHexUnsafe(md5Hash)}.block", + Filename = $"{HexTool.BytesToHexUnsafe(md5Hash)}{assetExtension}", FileSize = fileSize, Flags = assetType }; diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailAssetBinaryMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailAssetBinaryMetadata.cs similarity index 76% rename from CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailAssetBinaryMetadata.cs rename to CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailAssetBinaryMetadata.cs index 571fdafc8..44cf6bca6 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailAssetBinaryMetadata.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailAssetBinaryMetadata.cs @@ -1,7 +1,8 @@ using System; using CollapseLauncher.RepairManagement.StarRail.Struct.Assets; // ReSharper disable CommentTypo -#pragma warning disable IDE0290 + +#pragma warning disable IDE0290 // Shut the fuck up #pragma warning disable IDE0130 #nullable enable @@ -10,8 +11,10 @@ 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 diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailAssetMetadataIndex.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailAssetMetadataIndex.cs similarity index 53% rename from CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailAssetMetadataIndex.cs rename to CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailAssetMetadataIndex.cs index 74259c6a1..d4d962bf9 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailAssetMetadataIndex.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailAssetMetadataIndex.cs @@ -5,7 +5,8 @@ using System.Threading; using System.Threading.Tasks; // ReSharper disable CommentTypo -#pragma warning disable IDE0290 + +#pragma warning disable IDE0290 // Shut the fuck up #pragma warning disable IDE0130 #nullable enable @@ -17,25 +18,68 @@ namespace CollapseLauncher.RepairManagement.StarRail.Struct; ///
internal class StarRailAssetMetadataIndex : StarRailBinaryDataWritable { - public StarRailAssetMetadataIndex() + 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 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) @@ -73,8 +117,15 @@ protected override async ValueTask WriteDataCoreAsync(Stream dataStream, Cancell } ref readonly MetadataIndex dataRef = ref CollectionsMarshal.AsSpan(DataList)[0]; - dataRef.ToStruct(out MetadataIndexStruct indexStruct); + 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); } @@ -113,6 +164,41 @@ public Span Reserved } } + [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; } @@ -122,7 +208,7 @@ public class MetadataIndex public required int MetadataIndexFileSize { get; init; } public required int PrevPatch { get; init; } public required DateTimeOffset Timestamp { get; init; } - public byte[] Reserved { get; init; } = new byte[10]; + public required byte[] Reserved { get; init; } public static void Parse(in MetadataIndexStruct indexStruct, out MetadataIndex result) @@ -149,6 +235,31 @@ public static void Parse(in MetadataIndexStruct indexStruct, }; } + 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 @@ -169,5 +280,26 @@ public void ToStruct(out MetadataIndexStruct indexStruct) 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/StarRail/Struct/StarRailBinaryData.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryData.cs similarity index 64% rename from CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryData.cs rename to CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryData.cs index 68e1582a1..a7c8ba7ba 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryData.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryData.cs @@ -1,25 +1,38 @@ -using CollapseLauncher.RepairManagement.StarRail.Struct.Assets; -using Hi3Helper.Data; -using Hi3Helper.EncTool; -using System; +using System; using System.Collections.Generic; using System.IO; -using System.Net.Http; -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; +/// +/// Generic/Abstract Star Rail Binary Data. Do not use this class directly. +/// public abstract class StarRailBinaryData { - protected abstract ReadOnlySpan MagicSignature { get; } - public StarRailBinaryDataHeaderStruct Header { get; protected set; } + /// + /// 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(); /// @@ -47,6 +60,14 @@ public virtual async Task ParseAsync(Stream dataStream, bool seekToEnd = false, } } + /// + /// 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) @@ -56,11 +77,26 @@ public virtual async Task ParseAsync(Stream dataStream, bool seekToEnd = false, 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, @@ -83,6 +119,10 @@ protected StarRailBinaryData(ReadOnlySpan magicSignature, } } +/// +/// 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 { diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataExtension.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryDataExtension.cs similarity index 99% rename from CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataExtension.cs rename to CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryDataExtension.cs index aeaefbf1f..bee925dd8 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataExtension.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryDataExtension.cs @@ -12,7 +12,9 @@ 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; diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataWritable.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryDataWritable.cs similarity index 54% rename from CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataWritable.cs rename to CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryDataWritable.cs index 979678ae4..418298385 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Struct/StarRailBinaryDataWritable.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryDataWritable.cs @@ -1,13 +1,19 @@ -using CollapseLauncher.RepairManagement.StarRail.Struct.Assets; -using System; +using System; using System.IO; using System.Threading; using System.Threading.Tasks; -#pragma warning disable IDE0290 + +#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, @@ -23,9 +29,19 @@ protected StarRailBinaryDataWritable(ReadOnlySpan magicSignature, 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(); @@ -38,6 +54,11 @@ public virtual async ValueTask WriteAsync(FileInfo fileInfo, CancellationToken t 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)) @@ -49,7 +70,17 @@ public virtual async ValueTask WriteAsync(Stream dataStream, CancellationToken t 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/Hi3Helper.EncTool b/Hi3Helper.EncTool index 1cf849bd3..088db0f7d 160000 --- a/Hi3Helper.EncTool +++ b/Hi3Helper.EncTool @@ -1 +1 @@ -Subproject commit 1cf849bd30bafa8a8a3087411b712cc11ffa05ed +Subproject commit 088db0f7dbd6bc45c2e7bf86afaf1138ce8a2fd9 From 6b585efa0875e791d5babc2b7abfb2148485badb Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 3 Jan 2026 03:57:57 +0700 Subject: [PATCH 07/14] Fix oopsies --- .../RepairManagement/StarRailV2/StarRailRepairV2.Check.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Check.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Check.cs index a43d36cd3..dacd0f510 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Check.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Check.cs @@ -52,7 +52,7 @@ private async Task Check(List assetIndex, CancellationToke try { // Iterate assetIndex and check it using different method for each type and run it in parallel - await Parallel.ForEachAsync(assetIndex, new ParallelOptions { MaxDegreeOfParallelism = 1, CancellationToken = token }, async (asset, threadToken) => + await Parallel.ForEachAsync(assetIndex, new ParallelOptions { MaxDegreeOfParallelism = ThreadCount, CancellationToken = token }, async (asset, threadToken) => { await CheckGenericAssetType(asset, brokenAssetIndex, threadToken); }); From 4ed03da3c055e10359f8f3319439ec7fb7eb66b1 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 3 Jan 2026 04:32:25 +0700 Subject: [PATCH 08/14] Make Cache Update work --- .../HonkaiV2/HonkaiRepairV2.AsbExt.Generic.cs | 4 +- ...lPersistentRefResult.GetPersistentFiles.cs | 148 +++++++++++------- .../StarRailV2/StarRailPersistentRefResult.cs | 2 +- .../StarRailV2/StarRailRepairV2.Fetch.cs | 3 +- ...tarRailRepairV2.FetchForCacheUpdateMode.cs | 7 +- .../StarRailV2/StarRailRepairV2.Repair.cs | 4 +- .../StarRailV2/StarRailRepairV2.cs | 10 +- .../Zenless/ZenlessRepair.Repair.cs | 2 +- 8 files changed, 112 insertions(+), 68 deletions(-) diff --git a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Generic.cs b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Generic.cs index f6f116ddf..02630c1d4 100644 --- a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Generic.cs +++ b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Generic.cs @@ -56,11 +56,11 @@ internal void PopBrokenAssetFromList(FilePropertiesRemote asset) } } - internal void UpdateCurrentRepairStatus(FilePropertiesRemote asset) + internal void UpdateCurrentRepairStatus(FilePropertiesRemote asset, bool isCacheUpdateMode = false) { // Increment total count current progressBase.ProgressAllCountCurrent++; - progressBase.Status.ActivityStatus = string.Format(Locale.Lang._GameRepairPage.Status8, asset.N); + progressBase.Status.ActivityStatus = string.Format(isCacheUpdateMode ? Locale.Lang!._Misc!.Downloading + ": {0}" : Locale.Lang._GameRepairPage.Status8, asset.N); progressBase.UpdateStatus(); } } diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.GetPersistentFiles.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.GetPersistentFiles.cs index 2a3ec8c42..032b7b31c 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.GetPersistentFiles.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.GetPersistentFiles.cs @@ -47,8 +47,7 @@ internal partial class StarRailPersistentRefResult public List GetPersistentFiles( List fileList, string gameDirPath, - string[] installedVoiceLang, - CancellationToken token) + string[] installedVoiceLang) { Dictionary oldDic = fileList.ToDictionary(x => x.N); Dictionary unusedAssets = new(StringComparer.OrdinalIgnoreCase); @@ -64,57 +63,97 @@ public List GetPersistentFiles( oldDic.TryAdd(asset.N, asset); } - AddAdditionalAssets(gameDirPath, - BaseDirs.StreamingAsbBlock, - BaseDirs.PersistentAsbBlock, - BaseUrls.AsbBlock, - BaseUrls.AsbBlockPersistent, - false, - fileList, - unusedAssets, - oldDic, - Metadata.StartBlockV!.DataList); - - AddAdditionalAssets(gameDirPath, - BaseDirs.StreamingAsbBlock, - BaseDirs.PersistentAsbBlock, - BaseUrls.AsbBlock, - BaseUrls.AsbBlockPersistent, - false, - fileList, - unusedAssets, - oldDic, - Metadata.BlockV!.DataList); - - AddAdditionalAssets(gameDirPath, - BaseDirs.StreamingVideo, - BaseDirs.PersistentVideo, - BaseUrls.Video, - BaseUrls.Video, - true, - fileList, - unusedAssets, - oldDic, - Metadata.VideoV!.DataList); - - 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.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.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(); } @@ -204,7 +243,8 @@ private static void AddAdditionalAssets( // remove the persistent one. if (!asset.IsPersistent && File.Exists(pathInPersistent) && - File.Exists(pathInStreaming)) + File.Exists(pathInStreaming) && + pathInStreaming != pathInPersistent) { unusedFileList.TryAdd(relPathInPersistent, new FilePropertiesRemote { diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.cs index 45ad5cc1a..d71d80791 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.cs @@ -65,7 +65,7 @@ public static async Task GetCacheReferenceAsync( // -- Initialize persistent dirs string lDirLua = Path.Combine(persistentDir, @"Lua\Windows"); - string lDirIFix = Path.Combine(persistentDir, @"Asb\Windows"); + string lDirIFix = Path.Combine(persistentDir, @"IFix\Windows"); string aDirLua = Path.Combine(gameBaseDir, lDirLua); string aDirIFix = Path.Combine(gameBaseDir, lDirIFix); AssetBaseDirs baseDirs = new() diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Fetch.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Fetch.cs index 1fb63c14e..c4d082ace 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Fetch.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Fetch.cs @@ -89,8 +89,7 @@ private async Task Fetch(List assetIndex, CancellationToke GameDataPersistentPathRelative, token); - assetIndex.AddRange(persistentRefResult.GetPersistentFiles(assetIndex, GamePath, installedVoiceLang, - token)); + assetIndex.AddRange(persistentRefResult.GetPersistentFiles(assetIndex, GamePath, installedVoiceLang)); await persistentRefResult.FinalizeRepairFetchAsync(this, sharedClient, assetIndex, GameDataPersistentPath, token); } diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.FetchForCacheUpdateMode.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.FetchForCacheUpdateMode.cs index 1e8b7a012..ca745f342 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.FetchForCacheUpdateMode.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.FetchForCacheUpdateMode.cs @@ -2,11 +2,9 @@ using Hi3Helper.EncTool.Parser.AssetMetadata; using Hi3Helper.Shared.ClassStruct; using System.Collections.Generic; -using System.Drawing; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Windows.Media.Protection.PlayReady; #pragma warning disable IDE0290 // Shut the fuck up #pragma warning disable IDE0130 @@ -51,6 +49,11 @@ await persistentRefResult.FinalizeCacheFetchAsync(this, 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 index c1c8d12fd..e3af1bddf 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Repair.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Repair.cs @@ -35,7 +35,7 @@ public async Task StartRepairRoutine( ResetStatusAndProgress(); // Set as completed - Status.ActivityStatus = Locale.Lang._GameRepairPage.Status7; + Status.ActivityStatus = IsCacheUpdateMode ? Locale.Lang._CachesPage.CachesStatusUpToDate : Locale.Lang._GameRepairPage.Status7; // Update status and progress UpdateAll(); @@ -149,7 +149,7 @@ private async ValueTask RepairAssetGenericType( CancellationToken token) { // Update repair status to the UI - this.UpdateCurrentRepairStatus(asset); + this.UpdateCurrentRepairStatus(asset, IsCacheUpdateMode); string assetPath = Path.Combine(GamePath, asset.N); FileInfo assetFileInfo = new FileInfo(assetPath) diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.cs index a0805b070..dbd26035b 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.cs @@ -2,6 +2,7 @@ using CollapseLauncher.InstallManager.StarRail; using CollapseLauncher.Interfaces; using CollapseLauncher.Statics; +using Hi3Helper; using Hi3Helper.Data; using Hi3Helper.Shared.ClassStruct; using Microsoft.UI.Xaml; @@ -99,10 +100,11 @@ private async Task CheckRoutine() // 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); + 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() 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); From cfb36998d4fb71c76293ad08ba97a1f9572e8518 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 3 Jan 2026 04:41:56 +0700 Subject: [PATCH 09/14] Add RawResV assets on Game Repair --- ...lPersistentRefResult.GetPersistentFiles.cs | 14 ++++++++ .../StarRailV2/StarRailPersistentRefResult.cs | 32 ++++++++++++++++--- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.GetPersistentFiles.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.GetPersistentFiles.cs index 032b7b31c..7697661b3 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.GetPersistentFiles.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.GetPersistentFiles.cs @@ -127,6 +127,20 @@ public List GetPersistentFiles( 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, diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.cs index d71d80791..8d4188199 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.cs @@ -56,6 +56,7 @@ public static async Task GetCacheReferenceAsync( DesignData = "", NativeData = "", Video = "", + RawRes = "", CacheLua = mainUrlLua, CacheIFix = mainUrlIFix }; @@ -201,6 +202,7 @@ public static async Task GetRepairReferenceAsync( string mainUrlAsbBlockAlt = mainUrlAsbAlt.CombineURLFromString("Block"); string mainUrlNativeData = mainUrlDesignData.CombineURLFromString("NativeData"); string mainUrlVideo = mainUrlAsb.CombineURLFromString("Video"); + string mainUrlRawRes = mainUrlAsb.CombineURLFromString("RawRes"); AssetBaseUrls baseUrl = new() { @@ -211,7 +213,8 @@ public static async Task GetRepairReferenceAsync( AsbBlock = mainUrlAsbBlock, AsbBlockPersistent = mainUrlAsbBlockAlt, NativeData = mainUrlNativeData, - Video = mainUrlVideo + Video = mainUrlVideo, + RawRes = mainUrlRawRes }; // -- Initialize persistent dirs @@ -221,13 +224,15 @@ public static async Task GetRepairReferenceAsync( 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); - AssetBaseDirs baseDirs = new(lDirArchive, lDirAsbBlock, lDirAudio, lDirDesignData, lDirNativeData, 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 @@ -276,6 +281,7 @@ public static async Task GetRepairReferenceAsync( 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 @@ -358,6 +364,16 @@ await LoadMetadataFile(instance, aDirVideo, token); + // -- RawResV + StarRailAssetJsonMetadata? metadataRawResV = + await LoadMetadataFile(instance, + handleArchive, + client, + baseUrl.RawRes, + "RawResV", + aDirRawRes, + token); + return new StarRailPersistentRefResult { BaseDirs = baseDirs, @@ -371,7 +387,8 @@ await LoadMetadataFile(instance, AsbV = metadataAsbV, BlockV = metadataBlockV, AudioV = metadataAudioV, - VideoV = metadataVideoV + VideoV = metadataVideoV, + RawResV = metadataRawResV } }; } @@ -549,9 +566,10 @@ public class AssetBaseDirs( string nAudio, string nDesignData, string nNativeData, - string nVideo) + string nVideo, + string nRawRes) { - public AssetBaseDirs() : this("", "", "", "", "", "") + public AssetBaseDirs() : this("", "", "", "", "", "", "") { } @@ -562,12 +580,14 @@ public AssetBaseDirs() : this("", "", "", "", "", "") 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; } @@ -585,6 +605,7 @@ public class AssetBaseUrls 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; } @@ -602,6 +623,7 @@ public class AssetMetadata 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; } From 50ff6117c00c4884f1e82807c7eac468f8ea3834 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 3 Jan 2026 04:55:41 +0700 Subject: [PATCH 10/14] Fix TryRunExamineThrow state not resetting SetTaskBarState --- .../Classes/Interfaces/Class/ProgressBase.cs | 55 +++++++++++++++---- .../StarRailV2/StarRailRepairV2.cs | 1 - 2 files changed, 44 insertions(+), 12 deletions(-) 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/StarRailV2/StarRailRepairV2.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.cs index dbd26035b..324f8b365 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.cs @@ -10,7 +10,6 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; -using static Hi3Helper.Locale; // ReSharper disable StringLiteralTypo #pragma warning disable IDE0290 // Shut the fuck up From b0bae37d9ecf6ac82898bde257bc2292d2a8b290 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 3 Jan 2026 16:52:49 +0700 Subject: [PATCH 11/14] [Misc] Use locale ID instead of index for Audio Language Selection --- .../Classes/Helper/Metadata/PresetConfig.cs | 20 +- .../Base/InstallManagerBase.Sophon.cs | 130 ++++---- .../Base/InstallManagerBase.cs | 13 +- .../Zenless/ZenlessInstall.cs | 24 +- .../StarRailV2/StarRailRepairV2.Repair.cs | 4 +- .../MainApp/Pages/Dialogs/SimpleDialogs.cs | 281 +++++++++--------- 6 files changed, 217 insertions(+), 255 deletions(-) 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/InstallManagement/Base/InstallManagerBase.Sophon.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs index 575bd9e79..39787f32c 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) { @@ -1299,12 +1277,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 +1295,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 +1321,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 +1331,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/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/RepairManagement/StarRailV2/StarRailRepairV2.Repair.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Repair.cs index e3af1bddf..6f92283ea 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Repair.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Repair.cs @@ -67,8 +67,8 @@ private async Task StartRepairRoutineCoreAsync(bool showInteractivePrompt = fals .Create(); int threadNum = IsBurstDownloadEnabled - ? 1 - : ThreadForIONormalized; + ? ThreadForIONormalized + : 1; await Parallel.ForEachAsync(AssetIndex, new ParallelOptions 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( From c32319d6c98f57155f1a36484e043bd06eee64a8 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 3 Jan 2026 17:02:02 +0700 Subject: [PATCH 12/14] [Misc] Fix premature download when user cancel initial install --- .../InstallManagement/Base/InstallManagerBase.Sophon.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs index 39787f32c..871b419df 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs @@ -959,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.GetFileNameWithoutExtension(assetName); + if (filename.Equals(GameVersionManager.GamePreset.GameExecutableName, StringComparison.OrdinalIgnoreCase)) + { + filePath += "_tempSophon"; + } + // Get the target and temp file info FileInfo existingFileInfo = new FileInfo(filePath).EnsureNoReadOnly(); From 5e06001e59800ed49006ff8976b33fd77a0cdf6c Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 3 Jan 2026 17:03:19 +0700 Subject: [PATCH 13/14] Fix previous commit, use StartsWith instead of Equals --- .../InstallManagement/Base/InstallManagerBase.Sophon.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs index 871b419df..9f65f7d6f 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs @@ -961,8 +961,8 @@ private ValueTask RunSophonAssetDownloadThread(HttpClient client, // HACK: To avoid user unable to continue the download due to executable being downloaded completely, // append "_tempSophon" on it. - string filename = Path.GetFileNameWithoutExtension(assetName); - if (filename.Equals(GameVersionManager.GamePreset.GameExecutableName, StringComparison.OrdinalIgnoreCase)) + string filename = Path.GetFileName(assetName); + if (filename.StartsWith(GameVersionManager.GamePreset.GameExecutableName ?? "", StringComparison.OrdinalIgnoreCase)) { filePath += "_tempSophon"; } From 34f71c4c1b98ab01e9182193d64ef577034f7f37 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 3 Jan 2026 17:14:48 +0700 Subject: [PATCH 14/14] Make QA happy --- .../RepairManagement/StarRailV2/StarRailRepairV2.Fetch.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Fetch.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Fetch.cs index c4d082ace..d68ba8a90 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Fetch.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Fetch.cs @@ -58,14 +58,17 @@ private async Task Fetch(List assetIndex, CancellationToke // If the this._isOnlyRecoverMain && base._isVersionOverride is true, copy the asset index into the _originAssetIndex if (IsOnlyRecoverMain && IsVersionOverride) { - OriginAssetIndex = []; + 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