From 88d9a3a5bdcab64b18976b8ee16b66552cb8d17a Mon Sep 17 00:00:00 2001 From: Ron Friedman <9833218+Cryotechnic@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:47:35 -0500 Subject: [PATCH 01/47] [skip ci] Update README --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 74c422aff..1651c3be6 100644 --- a/README.md +++ b/README.md @@ -64,11 +64,11 @@ Not only that, this launcher also has some advanced features for **Genshin Impac > ### You can find the list of features on our [new website](https://collapselauncher.com/features.html)! # Download Ready-To-Use Builds -[](https://github.com/CollapseLauncher/Collapse/releases/download/CL-v1.82.36/CollapseLauncher-stable-Setup.exe) -> **Note**: The version for this build is `1.82.36` (Released on: October 26th, 2025). +[](https://github.com/CollapseLauncher/Collapse/releases/download/CL-v1.83.13/CollapseLauncher-stable-Setup.exe) +> **Note**: The version for this build is `1.82.36` (Released on: December 19th, 2025). -[](https://github.com/CollapseLauncher/Collapse/releases/download/CL-v1.83.12-pre/CollapseLauncher-preview-Setup.exe) -> **Note**: The version for this build is `1.83.12` (Released on: October 26th, 2025). +[](https://github.com/CollapseLauncher/Collapse/releases/download/CL-v1.83.13-pre/CollapseLauncher-preview-Setup.exe) +> **Note**: The version for this build is `1.83.12` (Released on: December 19th, 2025). To view all releases, [**click here**](https://github.com/neon-nyan/CollapseLauncher/releases). From ed60afc4fb08880145532731f04af87f9c23f948 Mon Sep 17 00:00:00 2001 From: Gabriel Lima <44784408+gablm@users.noreply.github.com> Date: Sat, 20 Dec 2025 17:00:23 +0000 Subject: [PATCH 02/47] Fix misc settings not loading --- .../GameSettings/Universal/RegistryClass/CollapseMiscSetting.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/Universal/RegistryClass/CollapseMiscSetting.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Universal/RegistryClass/CollapseMiscSetting.cs index 943dc16b6..39343c98f 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/Universal/RegistryClass/CollapseMiscSetting.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/Universal/RegistryClass/CollapseMiscSetting.cs @@ -154,6 +154,8 @@ public static CollapseMiscSetting Load(IGameSettings gameSettings) #endif CollapseMiscSetting result = byteStr.Deserialize(UniversalSettingsJsonContext.Default.CollapseMiscSetting) ?? new CollapseMiscSetting(); result.ParentGameSettings = gameSettings; + + return result; } } catch ( Exception ex ) From 6f31f55e069293add7dd1bb4a42e19c5d334b201 Mon Sep 17 00:00:00 2001 From: Gabriel Lima <44784408+gablm@users.noreply.github.com> Date: Sat, 20 Dec 2025 17:02:52 +0000 Subject: [PATCH 03/47] [skip ci] Update README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1651c3be6..741f66e33 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,10 @@ Not only that, this launcher also has some advanced features for **Genshin Impac # Download Ready-To-Use Builds [](https://github.com/CollapseLauncher/Collapse/releases/download/CL-v1.83.13/CollapseLauncher-stable-Setup.exe) -> **Note**: The version for this build is `1.82.36` (Released on: December 19th, 2025). +> **Note**: The version for this build is `1.83.13` (Released on: December 19th, 2025). [](https://github.com/CollapseLauncher/Collapse/releases/download/CL-v1.83.13-pre/CollapseLauncher-preview-Setup.exe) -> **Note**: The version for this build is `1.83.12` (Released on: December 19th, 2025). +> **Note**: The version for this build is `1.83.13` (Released on: December 19th, 2025). To view all releases, [**click here**](https://github.com/neon-nyan/CollapseLauncher/releases). From bc92ac966e3f93d12959e66db41912684f3befc6 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sun, 21 Dec 2025 18:21:54 +0700 Subject: [PATCH 04/47] Update Hi3Helper.SharpDiscordRPC --- Hi3Helper.SharpDiscordRPC | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Hi3Helper.SharpDiscordRPC b/Hi3Helper.SharpDiscordRPC index a6ba8bac8..73fc6cbfa 160000 --- a/Hi3Helper.SharpDiscordRPC +++ b/Hi3Helper.SharpDiscordRPC @@ -1 +1 @@ -Subproject commit a6ba8bac8d7795fb3059100a2a8b4905a3738636 +Subproject commit 73fc6cbfa862ca00f2f48356cfa0dbc03efe76ae From 5c945280244e66d3dfba4cd91c70f2535979058e Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Thu, 25 Dec 2025 01:26:40 +0700 Subject: [PATCH 05/47] [PR Fix] Fix wait event --- Hi3Helper.SharpDiscordRPC | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Hi3Helper.SharpDiscordRPC b/Hi3Helper.SharpDiscordRPC index 73fc6cbfa..035995e78 160000 --- a/Hi3Helper.SharpDiscordRPC +++ b/Hi3Helper.SharpDiscordRPC @@ -1 +1 @@ -Subproject commit 73fc6cbfa862ca00f2f48356cfa0dbc03efe76ae +Subproject commit 035995e786f7f7a90d1dd002a74d9a434b030465 From 1e087ff5b6c42eb9fc73f57d756972c0916da928 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Thu, 25 Dec 2025 01:33:09 +0700 Subject: [PATCH 06/47] Update Hi3Helper.SharpDiscordRPC --- Hi3Helper.SharpDiscordRPC | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Hi3Helper.SharpDiscordRPC b/Hi3Helper.SharpDiscordRPC index 035995e78..fb14695a0 160000 --- a/Hi3Helper.SharpDiscordRPC +++ b/Hi3Helper.SharpDiscordRPC @@ -1 +1 @@ -Subproject commit 035995e786f7f7a90d1dd002a74d9a434b030465 +Subproject commit fb14695a08aa686b0b6d09831923c30097d9daef From 1452e4efb2d3636f01a836d659273db5a523e6c6 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Fri, 26 Dec 2025 07:48:05 +0000 Subject: [PATCH 07/47] [skip ci] Sync translation Translate en_US.json in zh_CN 100% reviewed source file: 'en_US.json' on 'zh_CN'. --- Hi3Helper.Core/Lang/zh_CN.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Hi3Helper.Core/Lang/zh_CN.json b/Hi3Helper.Core/Lang/zh_CN.json index 543083447..136d33df3 100644 --- a/Hi3Helper.Core/Lang/zh_CN.json +++ b/Hi3Helper.Core/Lang/zh_CN.json @@ -612,9 +612,9 @@ "NetworkSettings_Proxy_PasswordHelp2": "[空]", "NetworkSettings_Proxy_PasswordHelp3": "如果您的代理不需要身份验证,请将此字段留空。", - "NetworkSettings_ProxyTest_Button": "测试代理可联通性", - "NetworkSettings_ProxyTest_ButtonChecking": "检查可联通性中……", - "NetworkSettings_ProxyTest_ButtonSuccess": "代理可联通性测试成功!", + "NetworkSettings_ProxyTest_Button": "测试代理可连通性", + "NetworkSettings_ProxyTest_ButtonChecking": "检查可连通性中……", + "NetworkSettings_ProxyTest_ButtonSuccess": "代理可连通性测试成功!", "NetworkSettings_ProxyTest_ButtonFailed": "测试失败!代理无法访问", "NetworkSettings_Dns_Title": "自定义 DNS 设置", From 78728d044c5cb92644b276144947c5a2d3087219 Mon Sep 17 00:00:00 2001 From: Gabriel Lima <44784408+gablm@users.noreply.github.com> Date: Fri, 26 Dec 2025 12:50:04 +0000 Subject: [PATCH 08/47] Fix plugin news not updating without restarting the launcher --- .../HoYoPlay/HypLauncherContentApi.cs | 10 ++++++++++ .../Classes/Plugins/PluginLauncherApiWrapper.News.cs | 1 + 2 files changed, 11 insertions(+) diff --git a/CollapseLauncher/Classes/Helper/LauncherApiLoader/HoYoPlay/HypLauncherContentApi.cs b/CollapseLauncher/Classes/Helper/LauncherApiLoader/HoYoPlay/HypLauncherContentApi.cs index 0e3d06438..159893945 100644 --- a/CollapseLauncher/Classes/Helper/LauncherApiLoader/HoYoPlay/HypLauncherContentApi.cs +++ b/CollapseLauncher/Classes/Helper/LauncherApiLoader/HoYoPlay/HypLauncherContentApi.cs @@ -43,6 +43,7 @@ internal List NewsEventKind field ??= News .Where(x => x.ContentType == LauncherGameNewsPostType.POST_TYPE_ACTIVITY) .ToList(); + private set; } [JsonIgnore] @@ -53,6 +54,7 @@ internal List NewsAnnouncementKind field ??= News .Where(x => x.ContentType == LauncherGameNewsPostType.POST_TYPE_ANNOUNCE) .ToList(); + private set; } [JsonIgnore] @@ -63,6 +65,14 @@ internal List NewsInformationKind field ??= News .Where(x => x.ContentType == LauncherGameNewsPostType.POST_TYPE_INFO) .ToList(); + private set; + } + + public void ResetCachedNews() + { + NewsEventKind = null; + NewsAnnouncementKind = null; + NewsInformationKind = null; } } diff --git a/CollapseLauncher/Classes/Plugins/PluginLauncherApiWrapper.News.cs b/CollapseLauncher/Classes/Plugins/PluginLauncherApiWrapper.News.cs index e17b28e93..6ac378f60 100644 --- a/CollapseLauncher/Classes/Plugins/PluginLauncherApiWrapper.News.cs +++ b/CollapseLauncher/Classes/Plugins/PluginLauncherApiWrapper.News.cs @@ -26,6 +26,7 @@ private async Task ConvertNewsAndCarouselEntries(HypLauncherContentApi contentAp var carouselList = contentApi.Data.Content.Carousel; newsList.Clear(); + contentApi.Data.Content.ResetCachedNews(); carouselList.Clear(); using PluginDisposableMemory newsEntry = PluginDisposableMemoryExtension.ToManagedSpan(_pluginNewsApi.GetNewsEntries); From ce17af2cfa6a6748c63976020c0296ba1ea30583 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Fri, 26 Dec 2025 22:17:47 +0700 Subject: [PATCH 09/47] Fix HSR Install/Update still downloads blacklisted files --- .../StarRail/StarRailInstall.SophonPatch.cs | 36 +++++++++++++++++-- Hi3Helper.Sophon | 2 +- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.SophonPatch.cs b/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.SophonPatch.cs index 785573400..881509da4 100644 --- a/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.SophonPatch.cs +++ b/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.SophonPatch.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +// ReSharper disable InvertIf #pragma warning disable IDE0130 namespace CollapseLauncher.InstallManager.StarRail @@ -35,7 +36,8 @@ protected override async Task FilterSophonPatchAssetList(List continue; } - blackListAlt.Add(span); + // Normalize path + AddBothPersistentOrStreamingAssets(blackListAlt, span); } if (blackList.Count == 0) @@ -55,8 +57,8 @@ protected override async Task FilterSophonPatchAssetList(List continue; } - string assetPath = Path.Combine(GamePath, patchAsset.TargetFilePath); - if (assetPath.AsSpan().ContainsAny(searchValues)) + int indexOfAny = patchAsset.TargetFilePath.IndexOfAny(searchValues); + if (indexOfAny >= 0) { continue; } @@ -91,5 +93,33 @@ static ReadOnlySpan GetFilePathFromJson(ReadOnlySpan line) return line[..endIndexOf]; } } + + private static void AddBothPersistentOrStreamingAssets( + HashSet.AlternateLookup> hashList, + ReadOnlySpan filePath) + { + const string streamingAssetsSegment = "StarRail_Data/StreamingAssets/"; + const string persistentSegment = "StarRail_Data/Persistent/"; + + bool isContainStreamingAssets = filePath.Contains(streamingAssetsSegment, StringComparison.OrdinalIgnoreCase); + bool isContainPersistent = filePath.Contains(persistentSegment, StringComparison.OrdinalIgnoreCase); + + // Add original path + hashList.Add(filePath); + + if (isContainStreamingAssets) + { + string persistentPath = persistentSegment + + filePath[streamingAssetsSegment.Length..].ToString(); + hashList.Add(persistentPath); + } + + if (isContainPersistent) + { + string streamingAssetsPath = streamingAssetsSegment + + filePath[persistentSegment.Length..].ToString(); + hashList.Add(streamingAssetsPath); + } + } } } diff --git a/Hi3Helper.Sophon b/Hi3Helper.Sophon index a672778c9..84c5119f5 160000 --- a/Hi3Helper.Sophon +++ b/Hi3Helper.Sophon @@ -1 +1 @@ -Subproject commit a672778c9ed2e4b748b27a43c1bd616946cf6e51 +Subproject commit 84c5119f51af3224ef41e5e08067f8c6eded3ef1 From 523a652c540c328d71a83b0c044eb076a588bb18 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Fri, 26 Dec 2025 22:49:22 +0700 Subject: [PATCH 10/47] Always normalize blacklist path --- .../StarRail/StarRailInstall.SophonPatch.cs | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.SophonPatch.cs b/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.SophonPatch.cs index 881509da4..156db6930 100644 --- a/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.SophonPatch.cs +++ b/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.SophonPatch.cs @@ -1,4 +1,4 @@ -using Hi3Helper.Sophon; +using Hi3Helper.Data; using System; using System.Buffers; using System.Collections.Generic; @@ -9,11 +9,15 @@ // ReSharper disable InvertIf #pragma warning disable IDE0130 +#nullable enable namespace CollapseLauncher.InstallManager.StarRail { internal sealed partial class StarRailInstall { - protected override async Task FilterSophonPatchAssetList(List itemList, CancellationToken token) + public override async Task FilterAssetList( + List itemList, + Func itemPathSelector, + CancellationToken token) { string blackListFilePath = Path.Combine(GamePath, @"StarRail_Data\Persistent\DownloadBlacklist.json"); FileInfo fileInfo = new(blackListFilePath); @@ -37,6 +41,7 @@ protected override async Task FilterSophonPatchAssetList(List } // Normalize path + ConverterTool.NormalizePathInplaceNoTrim(span); AddBothPersistentOrStreamingAssets(blackListAlt, span); } @@ -48,16 +53,16 @@ protected override async Task FilterSophonPatchAssetList(List SearchValues searchValues = SearchValues.Create(blackList.ToArray(), StringComparison.OrdinalIgnoreCase); - List listFiltered = []; - foreach (SophonPatchAsset patchAsset in itemList) + List listFiltered = []; + foreach (T patchAsset in itemList) { - if (patchAsset.TargetFilePath == null) + if (itemPathSelector(patchAsset) is not {} filePath) { listFiltered.Add(patchAsset); continue; } - int indexOfAny = patchAsset.TargetFilePath.IndexOfAny(searchValues); + int indexOfAny = filePath.IndexOfAny(searchValues); if (indexOfAny >= 0) { continue; @@ -98,8 +103,8 @@ private static void AddBothPersistentOrStreamingAssets( HashSet.AlternateLookup> hashList, ReadOnlySpan filePath) { - const string streamingAssetsSegment = "StarRail_Data/StreamingAssets/"; - const string persistentSegment = "StarRail_Data/Persistent/"; + const string streamingAssetsSegment = @"StarRail_Data\StreamingAssets\"; + const string persistentSegment = @"StarRail_Data\Persistent\"; bool isContainStreamingAssets = filePath.Contains(streamingAssetsSegment, StringComparison.OrdinalIgnoreCase); bool isContainPersistent = filePath.Contains(persistentSegment, StringComparison.OrdinalIgnoreCase); From 8db75575a3d2303e3e6e9b7f93a70e45cea5efd2 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Fri, 26 Dec 2025 22:50:02 +0700 Subject: [PATCH 11/47] Adjust FilterSophonPatchAssetList -> FilterAssetList for ZZZ --- .../Base/InstallManagerBase.SophonPatch.cs | 7 ++- .../Zenless/ZenlessInstall.Sophon.cs | 23 ------- .../Zenless/ZenlessInstall.SophonPatch.cs | 60 +++++++++++++------ 3 files changed, 46 insertions(+), 44 deletions(-) delete mode 100644 CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.Sophon.cs diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.SophonPatch.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.SophonPatch.cs index 6b5b68600..3d9dfa397 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.SophonPatch.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.SophonPatch.cs @@ -125,7 +125,7 @@ await GetAlterSophonPatchAssets(httpClient, Token.Token); // Filter asset list - await FilterSophonPatchAssetList(patchAssets.AssetList, Token.Token); + await FilterAssetList(patchAssets.AssetList, x => x.TargetFilePath, Token.Token); // Start the patch pipeline await StartAlterSophonPatch(httpClient, @@ -140,7 +140,10 @@ await StartAlterSophonPatch(httpClient, return true; } - protected virtual Task FilterSophonPatchAssetList(List itemList, CancellationToken token) + public virtual Task FilterAssetList( + List itemList, + Func itemPathSelector, + CancellationToken token) { // NOP return Task.CompletedTask; diff --git a/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.Sophon.cs b/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.Sophon.cs deleted file mode 100644 index 8c4ac349b..000000000 --- a/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.Sophon.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Hi3Helper.Sophon; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -// ReSharper disable CheckNamespace - -#nullable enable -namespace CollapseLauncher.InstallManager.Zenless -{ - internal partial class ZenlessInstall - { - protected override async Task FilterSophonPatchAssetList(List itemList, CancellationToken token) - { - HashSet exceptMatchFieldHashSet = await GetExceptMatchFieldHashSet(token); - if (exceptMatchFieldHashSet.Count == 0) - { - return; - } - - FilterSophonAsset(itemList, x => x, exceptMatchFieldHashSet, token); - } - } -} diff --git a/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.SophonPatch.cs b/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.SophonPatch.cs index 26415d9b0..17d0ad7c3 100644 --- a/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.SophonPatch.cs +++ b/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.SophonPatch.cs @@ -24,7 +24,10 @@ internal partial class ZenlessInstall [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "k__BackingField")] private static extern ref SophonChunksInfo GetChunkAssetChunksInfoAlt(SophonAsset element); - protected override async Task FilterSophonPatchAssetList(List itemList, CancellationToken token) + public override async Task FilterAssetList( + List itemList, + Func itemPathSelector, + CancellationToken token) { HashSet exceptMatchFieldHashSet = await GetExceptMatchFieldHashSet(token); if (exceptMatchFieldHashSet.Count == 0) @@ -32,13 +35,44 @@ protected override async Task FilterSophonPatchAssetList(List return; } - FilterSophonAsset(itemList, x => x.MainAssetInfo, exceptMatchFieldHashSet, token); + FilterSophonAsset(itemList, Selector, exceptMatchFieldHashSet); + return; + + string? Selector(T item) + { + // For Game Update + if (item is SophonPatchAsset asset) + { + if (asset.MainAssetInfo is not { } assetSelected) + { + return null; + } + + token.ThrowIfCancellationRequested(); + ref SophonChunksInfo chunkInfo = ref Unsafe.IsNullRef(ref assetSelected) + ? ref Unsafe.NullRef() + : ref GetChunkAssetChunksInfo(assetSelected); + + if (Unsafe.IsNullRef(ref chunkInfo)) + { + chunkInfo = ref GetChunkAssetChunksInfoAlt(assetSelected); + } + + return Unsafe.IsNullRef(ref chunkInfo) + ? asset.MainAssetInfo.AssetName + : chunkInfo.ChunksBaseUrl; + } + + // TODO: For Game Repair handle + + return null; + } } private async Task> GetExceptMatchFieldHashSet(CancellationToken token) { string gameExecDataName = - Path.GetFileNameWithoutExtension(GameVersionManager?.GamePreset.GameExecutableName) ?? "ZenlessZoneZero"; + Path.GetFileNameWithoutExtension(GameVersionManager.GamePreset.GameExecutableName) ?? "ZenlessZoneZero"; string gameExecDataPath = $"{gameExecDataName}_Data"; string gamePersistentDataPath = Path.Combine(GamePath, gameExecDataPath, "Persistent"); string gameExceptMatchFieldFile = Path.Combine(gamePersistentDataPath, "KDelResource"); @@ -55,7 +89,7 @@ private async Task> GetExceptMatchFieldHashSet(CancellationToken to } // ReSharper disable once IdentifierTypo - private static void FilterSophonAsset(List itemList, Func assetSelector, HashSet exceptMatchFieldHashSet, CancellationToken token) + private static void FilterSophonAsset(List itemList, Func assetSelector, HashSet exceptMatchFieldHashSet) { const string separators = "/\\"; scoped Span urlPathRanges = stackalloc Range[32]; @@ -63,27 +97,15 @@ private static void FilterSophonAsset(List itemList, Func List filteredList = []; foreach (T asset in itemList) { - SophonAsset? assetSelected = assetSelector(asset); - - token.ThrowIfCancellationRequested(); - ref SophonChunksInfo chunkInfo = ref assetSelected == null - ? ref Unsafe.NullRef() - : ref GetChunkAssetChunksInfo(assetSelected); - - if (assetSelected != null && Unsafe.IsNullRef(ref chunkInfo)) - { - chunkInfo = ref GetChunkAssetChunksInfoAlt(assetSelected); - } - - if (Unsafe.IsNullRef(ref chunkInfo)) + string? assetPath = assetSelector(asset); + if (assetPath == null) { filteredList.Add(asset); continue; } - ReadOnlySpan manifestUrl = chunkInfo.ChunksBaseUrl; + ReadOnlySpan manifestUrl = assetPath; int rangeLen = manifestUrl.SplitAny(urlPathRanges, separators, SplitOptions); - if (rangeLen <= 0) { continue; From 9c1ba768b169b57b9dfe0054b0f3b44037b96d44 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Fri, 26 Dec 2025 22:56:46 +0700 Subject: [PATCH 12/47] Fix HSR Game Repair still downloads blacklisted files --- CollapseLauncher/Classes/GamePresetProperty.cs | 4 ++-- .../StarRail/StarRailInstall.cs | 3 ++- .../RepairManagement/StarRail/StarRailRepair.cs | 17 ++++++++++++----- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CollapseLauncher/Classes/GamePresetProperty.cs b/CollapseLauncher/Classes/GamePresetProperty.cs index 37e396bcc..66cdb4ed8 100644 --- a/CollapseLauncher/Classes/GamePresetProperty.cs +++ b/CollapseLauncher/Classes/GamePresetProperty.cs @@ -60,9 +60,9 @@ internal static GamePresetProperty Create(UIElement uiElementParent, ILauncherAp case GameNameType.StarRail: property.GameVersion = new GameTypeStarRailVersion(launcherApis, gamePreset); property.GameSettings = new StarRailSettings(property.GameVersion); - property.GameCache = new StarRailCache(uiElementParent, property.GameVersion, property.GameSettings); - property.GameRepair = new StarRailRepair(uiElementParent, property.GameVersion, property.GameSettings); 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); break; case GameNameType.Genshin: property.GameVersion = new GameTypeGenshinVersion(launcherApis, gamePreset); diff --git a/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.cs b/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.cs index f6c85f102..af182cf6d 100644 --- a/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.cs +++ b/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.cs @@ -93,6 +93,7 @@ public override async ValueTask StartPackageVerification(List new StarRailRepair(ParentUI, GameVersionManager, + this, GameSettings, true, versionString); @@ -115,7 +116,7 @@ protected override async Task StartPackageInstallationInner(List OriginAssetIndex { get; set; } private string ExecName { get; } @@ -59,6 +61,7 @@ private string GameAudioLangListPath public StarRailRepair( UIElement parentUI, IGameVersion gameVersionManager, + IGameInstallManager gameInstallManager, IGameSettings gameSettings, bool onlyRecoverMainAsset = false, string versionOverride = null) @@ -71,6 +74,7 @@ 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); } @@ -105,15 +109,18 @@ private async Task CheckRoutine() ResetStatusAndProgress(); // Step 1: Fetch asset indexes - await Fetch(AssetIndex, Token.Token); + await Fetch(AssetIndex, Token!.Token); - // Step 2: Calculate the total size and count of the files + // Step 2: Remove blacklisted files from asset index (borrow function from StarRailInstall) + await InnerGameInstaller.FilterAssetList(AssetIndex, x => x.N, Token.Token); + + // Step 3: Calculate the total size and count of the files CountAssetIndex(AssetIndex); - // Step 3: Check for the asset indexes integrity + // Step 4: Check for the asset indexes integrity await Check(AssetIndex, Token.Token); - // Step 4: Summarize and returns true if the assetIndex count != 0 indicates broken file was found. + // Step 5: Summarize and returns true if the assetIndex count != 0 indicates broken file was found. // either way, returns false. return SummarizeStatusAndProgress( AssetIndex, @@ -124,7 +131,7 @@ private async Task CheckRoutine() private async Task RepairRoutine() { // Assign repair task - Task repairTask = Repair(AssetIndex, Token.Token); + Task repairTask = Repair(AssetIndex, Token!.Token); // Run repair process bool repairTaskSuccess = await TryRunExamineThrow(repairTask); From 91971e917482bc16554897ff8e3c0efbf944785a Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Mon, 29 Dec 2025 04:29:17 +0700 Subject: [PATCH 13/47] 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 0da50fc82253eb3a0233be8620bc605fe2c967d7 Mon Sep 17 00:00:00 2001 From: Bagus Nur Listiyono Date: Tue, 30 Dec 2025 01:25:09 +0700 Subject: [PATCH 14/47] [DiscordRPC] Fix toggle not actually disabling RPC Signed-off-by: Bagus Nur Listiyono --- .../DiscordPresence/DiscordPresenceManager.cs | 34 ++++++++++++++----- .../RegionManagement/RegionManagement.cs | 2 +- .../XAMLs/MainApp/MainPage.xaml.cs | 4 +-- .../XAMLs/MainApp/Pages/SettingsPage.xaml.cs | 13 ++++--- 4 files changed, 35 insertions(+), 18 deletions(-) diff --git a/CollapseLauncher/Classes/DiscordPresence/DiscordPresenceManager.cs b/CollapseLauncher/Classes/DiscordPresence/DiscordPresenceManager.cs index cbdea80e9..660f0811d 100644 --- a/CollapseLauncher/Classes/DiscordPresence/DiscordPresenceManager.cs +++ b/CollapseLauncher/Classes/DiscordPresence/DiscordPresenceManager.cs @@ -37,6 +37,24 @@ public enum ActivityType public sealed partial class DiscordPresenceManager : IDisposable { #region Properties + + [field: NonSerialized] + private bool? _isRpcEnabled; + public bool IsRpcEnabled + { + get => _isRpcEnabled ??= GetAppConfigValue("EnableDiscordRPC").ToBoolNullable() ?? false; + set + { + if (_isRpcEnabled == value) return; + + _isRpcEnabled = value; + + SetAndSaveConfigValue("EnableDiscordRPC", value); + if (value) SetupPresence(); + else DisablePresence(); + } + } + private const string CollapseLogoExt = "https://collapselauncher.com/img/logo@2x.webp"; private DiscordRpcClient? _client; @@ -46,8 +64,8 @@ public sealed partial class DiscordPresenceManager : IDisposable private DateTime? _lastPlayTime; private bool _firstTimeConnect = true; private readonly ActionBlock _presenceUpdateQueue; - - private bool _cachedIsIdleEnabled = true; + + private bool _cachedIsIdleEnabled = true; public bool IdleEnabled { @@ -103,6 +121,7 @@ public void Dispose() private void EnablePresence(ulong applicationId) { + if (!IsRpcEnabled) return; _firstTimeConnect = true; // Flush and dispose the session @@ -168,8 +187,10 @@ public void DisablePresence() public void SetupPresence() { - string? gameCategory = GetAppConfigValue("GameCategory").ToString(); - bool isGameStatusEnabled = GetAppConfigValue("EnableDiscordGameStatus").ToBool(); + if (!IsRpcEnabled) return; + + var gameCategory = GetAppConfigValue("GameCategory").ToString(); + var isGameStatusEnabled = GetAppConfigValue("EnableDiscordGameStatus").ToBool(); if (isGameStatusEnabled) { @@ -220,10 +241,7 @@ private bool TryEnablePresenceIfPlugin() public void SetActivity(ActivityType activity, DateTime? activityOffset = null) { - if (!GetAppConfigValue("EnableDiscordRPC").ToBool()) - { - return; - } + if (!IsRpcEnabled) return; //_lastAttemptedActivityType = activity; _activityType = activity; diff --git a/CollapseLauncher/Classes/RegionManagement/RegionManagement.cs b/CollapseLauncher/Classes/RegionManagement/RegionManagement.cs index 0f3ffbb11..a408dce14 100644 --- a/CollapseLauncher/Classes/RegionManagement/RegionManagement.cs +++ b/CollapseLauncher/Classes/RegionManagement/RegionManagement.cs @@ -489,7 +489,7 @@ private async Task LoadRegionRootButton() LogWriteLine($"Region changed to {Preset.ZoneFullname}", LogType.Scheme, true); #if !DISABLEDISCORD - if (GetAppConfigValue("EnableDiscordRPC").ToBool()) + if (AppDiscordPresence.IsRpcEnabled) AppDiscordPresence.SetupPresence(); #endif return true; diff --git a/CollapseLauncher/XAMLs/MainApp/MainPage.xaml.cs b/CollapseLauncher/XAMLs/MainApp/MainPage.xaml.cs index 1f85b2483..7f1a15ab7 100644 --- a/CollapseLauncher/XAMLs/MainApp/MainPage.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/MainPage.xaml.cs @@ -881,8 +881,8 @@ private async void ChangeToActivatedRegion() if (await LoadRegionFromCurrentConfigV2(preset, gameName, gameRegion)) { #if !DISABLEDISCORD - if (GetAppConfigValue("EnableDiscordRPC").ToBool() && !sameRegion) - AppDiscordPresence?.SetupPresence(); + if ((AppDiscordPresence?.IsRpcEnabled ?? false) && !sameRegion) + AppDiscordPresence.SetupPresence(); #endif InvokeLoadingRegionPopup(false); LauncherFrame.BackStack.Clear(); diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/SettingsPage.xaml.cs b/CollapseLauncher/XAMLs/MainApp/Pages/SettingsPage.xaml.cs index 098c87d9b..afab85887 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/SettingsPage.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/SettingsPage.xaml.cs @@ -769,9 +769,9 @@ private bool IsDiscordRpcEnabled { get { - bool isEnabled = GetAppConfigValue("EnableDiscordRPC"); - ToggleDiscordGameStatus.IsEnabled = IsEnabled; - if (isEnabled) + var e = AppDiscordPresence.IsRpcEnabled; + ToggleDiscordGameStatus.IsEnabled = e; + if (e) { ToggleDiscordGameStatus.Visibility = Visibility.Visible; ToggleDiscordIdleStatus.Visibility = Visibility.Visible; @@ -781,23 +781,22 @@ private bool IsDiscordRpcEnabled ToggleDiscordGameStatus.Visibility = Visibility.Collapsed; ToggleDiscordIdleStatus.Visibility = Visibility.Collapsed; } - return isEnabled; + return e; } set { if (value) { - AppDiscordPresence.SetupPresence(); ToggleDiscordGameStatus.Visibility = Visibility.Visible; ToggleDiscordIdleStatus.Visibility = Visibility.Visible; } else { - AppDiscordPresence.DisablePresence(); ToggleDiscordGameStatus.Visibility = Visibility.Collapsed; ToggleDiscordIdleStatus.Visibility = Visibility.Collapsed; } - SetAndSaveConfigValue("EnableDiscordRPC", value); + + AppDiscordPresence.IsRpcEnabled = value; ToggleDiscordGameStatus.IsEnabled = value; } } From 47086d063c6d886f83a668188b3241a59192eaa2 Mon Sep 17 00:00:00 2001 From: Bagus Nur Listiyono Date: Tue, 30 Dec 2025 01:26:10 +0700 Subject: [PATCH 15/47] [DiscordRPC] Disable regional QS toggle if global toggle is off Signed-off-by: Bagus Nur Listiyono --- CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml | 1 + CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml.cs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml index e31018779..0c71d5fe9 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml @@ -2219,6 +2219,7 @@ Style="{ThemeResource BodyStrongTextBlockStyle}" Text="{x:Bind helper:Locale.Lang._HomePage.GameSettings_Panel3RegionRpc}" /> diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml.cs b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml.cs index 8fbba9fdb..8b1adeca0 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml.cs @@ -79,6 +79,8 @@ public sealed partial class HomePage private int barWidth; private int consoleWidth; + private readonly bool IsRpcEnabled_QS = AppDiscordPresence?.IsRpcEnabled ?? false; + public static int RefreshRateDefault => 500; public static int RefreshRateSlow => 1000; From 6ece0a3094c0545de9d266406d8044105f3b3c34 Mon Sep 17 00:00:00 2001 From: Bagus Nur Listiyono <28079733+bagusnl@users.noreply.github.com> Date: Tue, 30 Dec 2025 22:48:37 +0700 Subject: [PATCH 16/47] [skip ci][CI] Fix sentry release version format Should use this format `CollapseLauncher@+` --- .github/workflows/release-signed.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-signed.yml b/.github/workflows/release-signed.yml index b841201ff..e13622120 100644 --- a/.github/workflows/release-signed.yml +++ b/.github/workflows/release-signed.yml @@ -210,10 +210,9 @@ jobs: run: | $exePath = "${{ runner.temp }}\SignedArtifact\BuildArtifact-${{ env.VERSION }}\CollapseLauncher.exe" if (Test-Path $exePath) { - $version = ((Get-Item $exePath).VersionInfo.FileVersion) - if ($version.EndsWith(".0")) { - $version = $version.Substring(0, $version.Length - 2) - } + $prefix = "CollapseLauncher@" + $version = ((Get-Item $exePath).VersionInfo.ProductVersion) + $version = $prefix + $version if ($version) { sentry-cli releases new --org collapse --project collapse-launcher $version From 6f45dbe3265fe3152796a1cb5973e3c72180cdfe Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Wed, 31 Dec 2025 02:28:04 +0700 Subject: [PATCH 17/47] 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 ed273a583524529259f4b118bfc8382956fca647 Mon Sep 17 00:00:00 2001 From: Gabriel Lima <44784408+gablm@users.noreply.github.com> Date: Thu, 1 Jan 2026 18:14:34 +0000 Subject: [PATCH 18/47] Fix playtime counter not stopping after early process disposal --- CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameLauncher.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameLauncher.cs b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameLauncher.cs index 7b6fde33b..59c3d37e0 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameLauncher.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameLauncher.cs @@ -814,6 +814,7 @@ private async Task CheckRunningGameInstance(PresetConfig presetConfig, Cancellat if (!usePluginGameLaunchApi) currentGameProcess = Process.GetProcessById(processId); + Task playtimeTask = Task.CompletedTask; try { // HACK: For some reason, the text still unchanged. @@ -844,7 +845,7 @@ Task ProcessAwaiter(CancellationToken x) => ? Task.CompletedTask : ((PluginPresetConfigWrapper)presetConfig).RunGameContext.WaitRunningGameAsync(x)); - _ = CurrentGameProperty!.GamePlaytime!.StartSessionFromAwaiter(ProcessAwaiter); + playtimeTask = CurrentGameProperty!.GamePlaytime!.StartSessionFromAwaiter(ProcessAwaiter); await ProcessAwaiter(token); @@ -852,6 +853,7 @@ Task ProcessAwaiter(CancellationToken x) => } finally { + await playtimeTask; currentGameProcess?.Dispose(); } } From a36c9d778e852772d01a9864973b167fa50f3538 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Fri, 2 Jan 2026 03:52:21 +0700 Subject: [PATCH 19/47] 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 20/47] 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 21/47] 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 4fef04e450568459034d6dc9694345647f08b03c Mon Sep 17 00:00:00 2001 From: Gabriel Lima <44784408+gablm@users.noreply.github.com> Date: Fri, 2 Jan 2026 04:12:43 +0000 Subject: [PATCH 22/47] Fix conversion to icon for large images Also should fix support for urls in base64 --- .../Helper/Image/ImageConverterHelper.cs | 51 ++++++++++++++----- .../ShortcutCreator/ShortcutCreator.cs | 2 +- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/CollapseLauncher/Classes/Helper/Image/ImageConverterHelper.cs b/CollapseLauncher/Classes/Helper/Image/ImageConverterHelper.cs index 68fea5002..14550b366 100644 --- a/CollapseLauncher/Classes/Helper/Image/ImageConverterHelper.cs +++ b/CollapseLauncher/Classes/Helper/Image/ImageConverterHelper.cs @@ -1,4 +1,6 @@ -using System.Drawing; +using System; +using System.Drawing; +using System.Drawing.Drawing2D; using System.Drawing.Imaging; using System.IO; using DImage = System.Drawing.Image; @@ -7,18 +9,17 @@ namespace CollapseLauncher.Classes.Helper.Image { internal static class ImageConverterHelper { + const int MaxIconSize = 256; + public static Icon ConvertToIcon(DImage image) { - using var bmp32 = new Bitmap(image.Width, image.Height, PixelFormat.Format32bppArgb); - - using (var g = Graphics.FromImage(bmp32)) - { - g.DrawImage(image, new Rectangle(0, 0, bmp32.Width, bmp32.Height)); - } + using var bitmap = ResizeToIconSize(image); + if (bitmap == null || bitmap.Width > MaxIconSize || bitmap.Height > MaxIconSize) + throw new Exception("Failed to resize image for icon conversion."); using (var stream = new MemoryStream()) { - bmp32.Save(stream, ImageFormat.Png); + bitmap.Save(stream, ImageFormat.Png); var bytes = stream.ToArray(); using var ms = new MemoryStream(); @@ -31,12 +32,9 @@ public static Icon ConvertToIcon(DImage image) bw.Write((ushort)1); // Number of images - int width = image.Width >= 256 ? 0 : image.Width; - int height = image.Height >= 256 ? 0 : image.Height; - // ICONDIRENTRY (16 bytes) - bw.Write((byte)width); // width - bw.Write((byte)height); // height + bw.Write((byte)bitmap.Width); // width + bw.Write((byte)bitmap.Height); // height bw.Write((byte)0); // Color palette (0 = no palette) bw.Write((byte)0); // Reserved bw.Write((ushort)0); // Color planes @@ -56,5 +54,32 @@ public static Icon ConvertToIcon(DImage image) return new Icon(ms); } } + + private static Bitmap ResizeToIconSize(DImage sourceImage) + { + if (sourceImage.Width == MaxIconSize && sourceImage.Height == MaxIconSize) + return new Bitmap(sourceImage); + + float scaleFactor = Math.Min( + (float)MaxIconSize / sourceImage.Width, + (float)MaxIconSize / sourceImage.Height + ); + + int scaledWidth = (int)Math.Round(sourceImage.Width * scaleFactor); + int scaledHeight = (int)Math.Round(sourceImage.Height * scaleFactor); + + var bitmap = new Bitmap(scaledWidth, scaledHeight, PixelFormat.Format32bppArgb); + + using (var g = Graphics.FromImage(bitmap)) + { + g.Clear(Color.Transparent); + g.InterpolationMode = InterpolationMode.HighQualityBicubic; + g.PixelOffsetMode = PixelOffsetMode.HighQuality; + g.SmoothingMode = SmoothingMode.HighQuality; + g.DrawImage(sourceImage, 0, 0, scaledWidth, scaledHeight); + } + + return bitmap; + } } } diff --git a/CollapseLauncher/Classes/ShortcutCreator/ShortcutCreator.cs b/CollapseLauncher/Classes/ShortcutCreator/ShortcutCreator.cs index 275c5b90e..0c557058c 100644 --- a/CollapseLauncher/Classes/ShortcutCreator/ShortcutCreator.cs +++ b/CollapseLauncher/Classes/ShortcutCreator/ShortcutCreator.cs @@ -45,7 +45,7 @@ public static string GetIconPath(PresetConfig preset) } pluginPresetConfig.Plugin.GetPluginAppIconUrl(out string? iconUrl); - string? appIconUrl = ImageLoaderHelper.CopyToLocalIfBase64(iconUrl, iconPath); + string? appIconUrl = PluginLauncherApiWrapper.CopyOverEmbeddedData(iconPath, iconUrl); if (appIconUrl == null) return icon; From 707d97e8c493dc092be7d072bfdf6e1ab988595a Mon Sep 17 00:00:00 2001 From: Bagus Nur Listiyono Date: Fri, 2 Jan 2026 23:13:28 +0700 Subject: [PATCH 23/47] [ZZZ GSP] Placeholder for ZZZ Mobile mode Touch function broken in game-level where movement joystick is non-functionable unless you tap outside of the window while moving the stick. Option is still disabled forcefully in the UI. Signed-off-by: Bagus Nur Listiyono --- .../GameSettings/Zenless/Enums.cs | 6 +++++ .../Zenless/FileClass/GeneralData.cs | 8 +++--- .../GameSettings/Zenless/Settings.cs | 9 +++++++ .../ZenlessGameSettingsPage.Ext.cs | 8 +++++- .../ZenlessGameSettingsPage.xaml | 25 +++++++++++-------- 5 files changed, 41 insertions(+), 15 deletions(-) diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Enums.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Enums.cs index bce6ed4bb..ff4a62649 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Enums.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Enums.cs @@ -186,6 +186,12 @@ public enum AnisotropicSamplingOption x16 } +public enum LocalUiLayoutPlatform +{ + Mobile = 1, + PC = 3 +} + public static class ServerName { public const string Europe = "prod_gf_eu"; diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/FileClass/GeneralData.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/FileClass/GeneralData.cs index 4559dd83f..a7604272e 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/FileClass/GeneralData.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/FileClass/GeneralData.cs @@ -205,11 +205,11 @@ public LanguageVoice DeviceLanguageVoiceType set => SettingsJsonNode.SetNodeValueEnum("DeviceLanguageVoiceType", value); } - [JsonPropertyName("LocalUILayoutPlatform ")] - public int LocalUILayoutPlatform + [JsonPropertyName("LocalUILayoutPlatform")] + public LocalUiLayoutPlatform LocalUILayoutPlatform { - get => SettingsJsonNode.GetNodeValue("LocalUILayoutPlatform", 3); - set => SettingsJsonNode.SetNodeValue("LocalUILayoutPlatform", value); + get => SettingsJsonNode.GetNodeValueEnum("LocalUILayoutPlatform", LocalUiLayoutPlatform.PC); + set => SettingsJsonNode.SetNodeValueEnum("LocalUILayoutPlatform", value); } [JsonPropertyName("UILayoutManualSetRecordState")] diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Settings.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Settings.cs index 418774940..48755554f 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Settings.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Settings.cs @@ -1,5 +1,6 @@ using CollapseLauncher.GameSettings.Base; using CollapseLauncher.GameSettings.Zenless.Context; +using CollapseLauncher.GameSettings.Zenless.Enums; using CollapseLauncher.GameVersioning; using CollapseLauncher.Interfaces; using System; @@ -83,6 +84,14 @@ public override string GetLaunchArguments(GamePresetProperty property) Size screenSize = SettingsScreen.sizeRes; parameter.Append($"-screen-width {screenSize.Width} -screen-height {screenSize.Height} "); } + + //Enable MobileMode + if (SettingsCollapseMisc.LaunchMobileMode) + { + // Force save on every launch + GeneralData.LocalUILayoutPlatform = LocalUiLayoutPlatform.Mobile; + GeneralData.Save(); + } if (SettingsCollapseScreen.GameGraphicsAPI == 4) { diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.Ext.cs b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.Ext.cs index 761151ae3..c60f44b0b 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.Ext.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.Ext.cs @@ -346,7 +346,13 @@ public bool IsGameBoost public bool IsMobileMode { get => Settings?.SettingsCollapseMisc?.LaunchMobileMode ?? false; - set => Settings.SettingsCollapseMisc.LaunchMobileMode = value; + set + { + Settings.SettingsCollapseMisc.LaunchMobileMode = value; + Settings.GeneralData.LocalUILayoutPlatform = + value ? LocalUiLayoutPlatform.Mobile : LocalUiLayoutPlatform.PC; + } + } #endregion diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml index 0bba51ac0..ab559b4bc 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml @@ -164,7 +164,7 @@ HorizontalAlignment="Left" VerticalAlignment="Center" IsChecked="{x:Bind IsMobileMode, Mode=TwoWay}" - IsEnabled="False" + IsEnabled="True" ToolTipService.ToolTip="{x:Bind helper:Locale.Lang._Misc.Generic_GameFeatureDeprecation}" Visibility="Collapsed"> + OnContent="{x:Bind helper:Locale.Lang._Misc.Enabled}" + Visibility="Collapsed"> @@ -576,10 +576,11 @@ - + + Spacing="16" + Visibility="Collapsed"> @@ -628,7 +629,8 @@ - + - + - + - + Date: Fri, 2 Jan 2026 23:14:47 +0700 Subject: [PATCH 24/47] [INTERIM] fix(ui): Fix carousel still running after using StartOnTray (#846) This is interim commit before @neon-nyan take precedence over at fecc199d91dbac8a1340399ec3683ec92895ad0a 1. Move CTSW creator outside while loop to prevent self recreation 2. Always pause carousel when window is not in foreground 3. Properly break the for loop when CTS is called 4. Delay stop scroller to wait for scroller to initialize 5. Pause/resume carousel on tray activity --- .../XAMLs/MainApp/Pages/HomePage.xaml.cs | 17 +++++++++++++++-- CollapseLauncher/XAMLs/MainApp/TrayIcon.xaml.cs | 2 ++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml.cs b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml.cs index 8fbba9fdb..a15f4fd13 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml.cs @@ -436,6 +436,7 @@ await ImageLoaderHelper.GetConvertedImageAsPng(outStream, #endregion #region Carousel + private bool _isCarouselInitialized = false; private async Task StartCarouselAutoScroll(int delaySeconds = 5) { @@ -444,11 +445,11 @@ private async Task StartCarouselAutoScroll(int delaySeconds = 5) try { + CarouselToken ??= new CancellationTokenSourceWrapper(); while (true) { - CarouselToken ??= new CancellationTokenSourceWrapper(); - await Task.Delay(TimeSpan.FromSeconds(delaySeconds), CarouselToken.Token); + _isCarouselInitialized = true; if (!IsCarouselPanelAvailable) return; if (ImageCarousel.SelectedIndex != GameCarouselData?.Count - 1 && ImageCarousel.SelectedIndex < ImageCarousel.Items.Count - 1) @@ -456,6 +457,10 @@ private async Task StartCarouselAutoScroll(int delaySeconds = 5) else for (int i = GameCarouselData?.Count ?? 0; i > 0; i--) { + while (!WindowUtility.IsCurrentWindowInFocus()) + { + await Task.Delay(RefreshRate, CarouselToken.Token); + } if (i - 1 >= 0 && i - 1 < ImageCarousel.Items.Count) { ImageCarousel.SelectedIndex = i - 1; @@ -464,7 +469,9 @@ private async Task StartCarouselAutoScroll(int delaySeconds = 5) { await Task.Delay(100, CarouselToken.Token); } + else break; } + break; } } catch (TaskCanceledException) @@ -496,6 +503,12 @@ public async Task CarouselRestartScroll(int delaySeconds = 5) public async ValueTask CarouselStopScroll() { + // Wait until Carousel is fully initialized to invoke the cts cancellation + while (!_isCarouselInitialized) + { + await Task.Delay(500); + } + if (CarouselToken is { IsCancellationRequested: false, IsDisposed: false, IsCancelled: false }) { await CarouselToken.CancelAsync(); diff --git a/CollapseLauncher/XAMLs/MainApp/TrayIcon.xaml.cs b/CollapseLauncher/XAMLs/MainApp/TrayIcon.xaml.cs index cb3cf13a1..1dce200a8 100644 --- a/CollapseLauncher/XAMLs/MainApp/TrayIcon.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/TrayIcon.xaml.cs @@ -292,6 +292,7 @@ public void ToggleMainVisibility(bool forceShow = false) MainTaskbarToggle.Text = _showApp; // Increase refresh rate to 1000ms when main window is hidden RefreshRate = RefreshRateSlow; + m_homePage?.CarouselStopScroll(); LogWriteLine("Main window is hidden!"); // Spawn the hidden to tray toast notification @@ -307,6 +308,7 @@ public void ToggleMainVisibility(bool forceShow = false) EfficiencyModeWrapper(false); PInvoke.SetForegroundWindow(mainWindowHandle); MainTaskbarToggle.Text = _hideApp; + m_homePage?.CarouselRestartScroll(); // Revert refresh rate to its default RefreshRate = RefreshRateDefault; LogWriteLine("Main window is shown!"); From 18910b1acf74dd32db74d275c46f43d68874cd87 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 3 Jan 2026 03:56:33 +0700 Subject: [PATCH 25/47] 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 26/47] 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 27/47] 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 28/47] 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 29/47] 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 fa91e2fde3b9b3131b10f5fbd9c1a234d83bb3f9 Mon Sep 17 00:00:00 2001 From: Bagus Nur Listiyono Date: Sat, 3 Jan 2026 10:16:22 +0700 Subject: [PATCH 30/47] [ZZZ GSP] Bring back DX12, RT, and Upscaling settings The settings is back on 2.5, the option were made by neon before but hidden because it was removed at 2.4. Co-authored-by: neon-nyan Signed-off-by: neon-nyan Signed-off-by: Bagus Nur Listiyono --- .../GameSettings/Zenless/Enums.cs | 2 +- .../Zenless/FileClass/GeneralData.cs | 2 +- .../ZenlessGameSettingsPage.xaml | 22 +++++++------------ 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Enums.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Enums.cs index ff4a62649..7b60a36f0 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Enums.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Enums.cs @@ -189,7 +189,7 @@ public enum AnisotropicSamplingOption public enum LocalUiLayoutPlatform { Mobile = 1, - PC = 3 + PC = 2 } public static class ServerName diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/FileClass/GeneralData.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/FileClass/GeneralData.cs index a7604272e..3a0332f03 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/FileClass/GeneralData.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/FileClass/GeneralData.cs @@ -532,7 +532,7 @@ public QualityOption3 GlobalIllumination set => _envGlobalIllumination?.SetDataEnum(value); } - // Key 8 VSync + // Key 106 Motion Blur private SystemSettingLocalData? _vMotionBlur; /// diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml index ab559b4bc..0b4cf8c32 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml @@ -1,4 +1,4 @@ - + @@ -221,7 +221,7 @@ IsOn="{x:Bind AdvancedGraphics_UseDirectX12Api, Mode=TwoWay}" OffContent="{x:Bind helper:Locale.Lang._Misc.Disabled}" OnContent="{x:Bind helper:Locale.Lang._Misc.Enabled}" - Visibility="Collapsed"> + Visibility="Visible"> @@ -576,11 +576,9 @@ - + + Spacing="16"> @@ -629,8 +627,7 @@ - + - + - + - + Date: Sat, 3 Jan 2026 16:52:49 +0700 Subject: [PATCH 31/47] [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 32/47] [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 33/47] 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 34/47] 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 From e6c23307dc09bf6491a4b8342d4e8e2f532d124d Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 3 Jan 2026 20:36:03 +0700 Subject: [PATCH 35/47] [Misc] Add alt. zh-cn string on GetLanguageLocaleCodeByLanguageStringStatic --- .../Classes/InstallManagement/Base/InstallManagerBase.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs index fb8fc1513..b1e5ebc56 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs @@ -1926,6 +1926,7 @@ protected virtual string GetLanguageStringByID(int id) ) => langString switch { "Chinese" => "zh-cn", + "Chinese(PRC)" => "zh-cn", "English" => "en-us", "English(US)" => "en-us", "Korean" => "ko-kr", From bc9612789bcdf3e63b98059f69cf43116c0fb5a3 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 3 Jan 2026 20:36:17 +0700 Subject: [PATCH 36/47] Small code cleanup --- .../Base/InstallManagerBase.Sophon.cs | 2 +- .../Base/InstallManagerBase.SophonPatch.cs | 22 +++++++++---------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs index 575bd9e79..75f47ab0e 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs @@ -577,7 +577,7 @@ private async Task ConfirmAdditionalInstallDataPackageFiles( .Where(x => matchingFieldsList.Contains(x.MatchingField, StringComparer.OrdinalIgnoreCase)) .Sum(x => { - var firstTag = x.ChunkInfo; + SophonManifestChunkInfo? firstTag = x.ChunkInfo; return firstTag?.CompressedSize ?? 0; }); diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.SophonPatch.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.SophonPatch.cs index 3d9dfa397..1e84bb06d 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.SophonPatch.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.SophonPatch.cs @@ -11,7 +11,6 @@ using CollapseLauncher.Extension; using CollapseLauncher.Helper; using CollapseLauncher.Helper.Metadata; -using CollapseLauncher.Interfaces; using Hi3Helper; using Hi3Helper.Data; using Hi3Helper.Plugin.Core.Management; @@ -51,15 +50,14 @@ protected virtual async Task AlterStartPatchUpdateSophon(HttpClient httpCl nameof(GameVersionManager.GamePreset.LauncherBizName)); // Get GameVersionManager and GamePreset - IGameVersion gameVersion = GameVersionManager; - PresetConfig gamePreset = gameVersion.GamePreset; + PresetConfig gamePreset = GameVersionManager.GamePreset; // Gt current and future version - GameVersion? requestedVersionFrom = gameVersion.GetGameExistingVersion() ?? + GameVersion? requestedVersionFrom = GameVersionManager.GetGameExistingVersion() ?? throw new NullReferenceException("Cannot get previous/current version of the game"); GameVersion? requestedVersionTo = (isPreloadMode ? - gameVersion.GetGameVersionApiPreload() : - gameVersion.GetGameVersionApi()) ?? + GameVersionManager.GetGameVersionApiPreload() : + GameVersionManager.GetGameVersionApi()) ?? throw new NullReferenceException("Cannot get next/future version of the game"); // Assign branch properties @@ -168,13 +166,13 @@ protected virtual async Task ConfirmAdditionalPatchDataPackageFiles(SophonChunkM .Where(x => matchingFieldsList.Contains(x.MatchingField, StringComparer.OrdinalIgnoreCase)) .Sum(x => { - var firstTag = x.DiffTaggedInfo.FirstOrDefault(y => y.Key == currentVersion).Value; + SophonManifestChunkInfo? firstTag = x.DiffTaggedInfo.FirstOrDefault(y => y.Key == currentVersion).Value; return firstTag?.CompressedSize ?? 0; }); long sizeAdditionalToDownload = otherManifestIdentity .Sum(x => { - var firstTag = x.DiffTaggedInfo.FirstOrDefault(y => y.Key == currentVersion).Value; + SophonManifestChunkInfo? firstTag = x.DiffTaggedInfo.FirstOrDefault(y => y.Key == currentVersion).Value; return firstTag?.CompressedSize ?? 0; }); @@ -205,12 +203,12 @@ string GetFileDetails() long chunkCount = 0; // ReSharper disable once ConvertToUsingDeclaration - using (FileStream fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write)) + using (FileStream fileStream = new(filePath, FileMode.Create, FileAccess.Write)) { - using StreamWriter writer = new StreamWriter(fileStream); - foreach (var field in otherManifestIdentity) + using StreamWriter writer = new(fileStream); + foreach (SophonManifestPatchIdentity field in otherManifestIdentity) { - var fieldInfo = field.DiffTaggedInfo.FirstOrDefault(x => x.Key == currentVersion).Value; + SophonManifestChunkInfo? fieldInfo = field.DiffTaggedInfo.FirstOrDefault(x => x.Key == currentVersion).Value; if (fieldInfo == null) { continue; From 5be0272539f585c2c30dfc7e116d2613f3c0ed20 Mon Sep 17 00:00:00 2001 From: Gabriel Lima <44784408+gablm@users.noreply.github.com> Date: Sat, 3 Jan 2026 13:52:48 +0000 Subject: [PATCH 37/47] Fix updates not working for games without audio packages --- .../Classes/InstallManagement/Base/InstallManagerBase.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs index fb8fc1513..dde24029e 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs @@ -2575,11 +2575,8 @@ private async Task GetLatestPackageList(List packageList, Ga if (gameState != GameInstallStateEnum.InstalledHavePlugin) { // Iterate the package resource version and add it into packageList - if (packageDetail.AudioPackage.Count != 0) - { - RearrangeDataListLocaleOrder(packageDetail.AudioPackage, x => x.Language); - await TryAddResourceVersionList(packageDetail, packageList); - } + RearrangeDataListLocaleOrder(packageDetail.AudioPackage, x => x.Language); + await TryAddResourceVersionList(packageDetail, packageList); } // Check if the existing installation has the plugin installed or not From 65ca84b258788feee68df328b04bf3859a92f274 Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sat, 3 Jan 2026 21:21:50 +0700 Subject: [PATCH 38/47] Fix ignored assets gets redownloaded --- .../Zenless/ZenlessInstall.SophonPatch.cs | 76 +++---------------- .../Zenless/ZenlessRepair.Fetch.cs | 4 +- Hi3Helper.Sophon | 2 +- 3 files changed, 15 insertions(+), 67 deletions(-) diff --git a/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.SophonPatch.cs b/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.SophonPatch.cs index 17d0ad7c3..c867ee2fb 100644 --- a/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.SophonPatch.cs +++ b/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.SophonPatch.cs @@ -1,5 +1,6 @@ using Hi3Helper.Sophon; using Hi3Helper.Sophon.Infos; +using Hi3Helper.Sophon.Structs; using System; using System.Buffers; using System.Collections.Generic; @@ -29,47 +30,16 @@ public override async Task FilterAssetList( Func itemPathSelector, CancellationToken token) { - HashSet exceptMatchFieldHashSet = await GetExceptMatchFieldHashSet(token); + HashSet exceptMatchFieldHashSet = await GetExceptMatchFieldHashSet(token); if (exceptMatchFieldHashSet.Count == 0) { return; } - FilterSophonAsset(itemList, Selector, exceptMatchFieldHashSet); - return; - - string? Selector(T item) - { - // For Game Update - if (item is SophonPatchAsset asset) - { - if (asset.MainAssetInfo is not { } assetSelected) - { - return null; - } - - token.ThrowIfCancellationRequested(); - ref SophonChunksInfo chunkInfo = ref Unsafe.IsNullRef(ref assetSelected) - ? ref Unsafe.NullRef() - : ref GetChunkAssetChunksInfo(assetSelected); - - if (Unsafe.IsNullRef(ref chunkInfo)) - { - chunkInfo = ref GetChunkAssetChunksInfoAlt(assetSelected); - } - - return Unsafe.IsNullRef(ref chunkInfo) - ? asset.MainAssetInfo.AssetName - : chunkInfo.ChunksBaseUrl; - } - - // TODO: For Game Repair handle - - return null; - } + FilterSophonAsset(itemList, exceptMatchFieldHashSet); } - private async Task> GetExceptMatchFieldHashSet(CancellationToken token) + private async Task> GetExceptMatchFieldHashSet(CancellationToken token) { string gameExecDataName = Path.GetFileNameWithoutExtension(GameVersionManager.GamePreset.GameExecutableName) ?? "ZenlessZoneZero"; @@ -82,38 +52,20 @@ private async Task> GetExceptMatchFieldHashSet(CancellationToken to return []; } - string exceptMatchFieldContent = await File.ReadAllTextAsync(gameExceptMatchFieldFile, token); - HashSet exceptMatchFieldHashSet = CreateExceptMatchFieldHashSet(exceptMatchFieldContent); + string exceptMatchFieldContent = await File.ReadAllTextAsync(gameExceptMatchFieldFile, token); + HashSet exceptMatchFieldHashSet = CreateExceptMatchFieldHashSet(exceptMatchFieldContent); return exceptMatchFieldHashSet; } // ReSharper disable once IdentifierTypo - private static void FilterSophonAsset(List itemList, Func assetSelector, HashSet exceptMatchFieldHashSet) + private static void FilterSophonAsset(List itemList, HashSet exceptMatchFieldHashSet) { - const string separators = "/\\"; - scoped Span urlPathRanges = stackalloc Range[32]; - List filteredList = []; foreach (T asset in itemList) { - string? assetPath = assetSelector(asset); - if (assetPath == null) - { - filteredList.Add(asset); - continue; - } - - ReadOnlySpan manifestUrl = assetPath; - int rangeLen = manifestUrl.SplitAny(urlPathRanges, separators, SplitOptions); - if (rangeLen <= 0) - { - continue; - } - - ReadOnlySpan manifestStr = manifestUrl[urlPathRanges[rangeLen - 1]]; - if (int.TryParse(manifestStr, null, out int lookupNumber) && - exceptMatchFieldHashSet.Contains(lookupNumber)) + if (asset is SophonIdentifiableProperty { MatchingField: { } assetMatchingField } && + exceptMatchFieldHashSet.Contains(assetMatchingField)) { continue; } @@ -130,11 +82,10 @@ private static void FilterSophonAsset(List itemList, Func asse itemList.AddRange(filteredList); } - internal static HashSet CreateExceptMatchFieldHashSet(string exceptMatchFieldContent) - where T : ISpanParsable + internal static HashSet CreateExceptMatchFieldHashSet(string exceptMatchFieldContent) { const string lineFeedSeparators = "\r\n"; - HashSet hashSetReturn = []; + HashSet hashSetReturn = new(StringComparer.OrdinalIgnoreCase); scoped Span contentLineRange = stackalloc Range[2]; ReadOnlySpan contentSpan = exceptMatchFieldContent.AsSpan(); @@ -157,10 +108,7 @@ internal static HashSet CreateExceptMatchFieldHashSet(string exceptMatchFi } ReadOnlySpan contentMatch = contentSpan[contentMatchRange].Trim(separatorsChars); - if (T.TryParse(contentMatch, null, out T? result)) - { - hashSetReturn.Add(result); - } + hashSetReturn.Add(contentMatch.ToString()); } return hashSetReturn; diff --git a/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Fetch.cs b/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Fetch.cs index 225aed9dc..ea0c0d3f9 100644 --- a/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Fetch.cs +++ b/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Fetch.cs @@ -107,7 +107,7 @@ private void FilterExcludedAssets(List assetList) } string exceptMatchFieldContent = File.ReadAllText(gameExceptMatchFieldFile); - HashSet exceptMatchFieldHashSet = ZenlessInstall.CreateExceptMatchFieldHashSet(exceptMatchFieldContent); + HashSet exceptMatchFieldHashSet = ZenlessInstall.CreateExceptMatchFieldHashSet(exceptMatchFieldContent); List filteredList = []; foreach (FilePropertiesRemote asset in assetList) @@ -120,7 +120,7 @@ private void FilterExcludedAssets(List assetList) continue; } - bool isExceptionFound = zenlessResAsset.PackageMatchingIds.Any(exceptMatchFieldHashSet.Contains); + bool isExceptionFound = zenlessResAsset.PackageMatchingIds.Any(x => exceptMatchFieldHashSet.Contains($"{x}")); if (isExceptionFound) { continue; diff --git a/Hi3Helper.Sophon b/Hi3Helper.Sophon index 84c5119f5..612fb1fee 160000 --- a/Hi3Helper.Sophon +++ b/Hi3Helper.Sophon @@ -1 +1 @@ -Subproject commit 84c5119f51af3224ef41e5e08067f8c6eded3ef1 +Subproject commit 612fb1fee1fc67ca1ab429d85df016b54d0e5a15 From 785634602b103feaec52719434e17213c36a70e9 Mon Sep 17 00:00:00 2001 From: shatyuka Date: Sat, 3 Jan 2026 22:38:49 +0800 Subject: [PATCH 39/47] [ZZZ GSP] Fix encoder --- .../Classes/GameManagement/GameSettings/Zenless/Sleepy.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Sleepy.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Sleepy.cs index a27d1b9ec..87c430935 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Sleepy.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Sleepy.cs @@ -245,7 +245,7 @@ private static unsafe int InternalWrite(ReadOnlySpan magic, int contentLen if (*(evil + n)) { byte eepy = 0; - if (*(bp + j) > 0x40) + if (*(bp + j) >= 0x40) { ch -= 0x40; eepy = 1; From cbba343041086a714fea5d95584f7800676671e6 Mon Sep 17 00:00:00 2001 From: Gabriel Lima <44784408+gablm@users.noreply.github.com> Date: Sat, 3 Jan 2026 15:10:11 +0000 Subject: [PATCH 40/47] Add advanced setting to disable running the game under explorer --- .../RegistryClass/CollapseMiscSetting.cs | 5 +++++ .../MainApp/Pages/Dialogs/SimpleDialogs.cs | 7 ++++++- .../GenshinGameSettingsPage.Ext.cs | 6 ++++++ .../GenshinGameSettingsPage.xaml | 11 +++++++++++ .../HonkaiGameSettingsPage.Ext.cs | 6 ++++++ .../HonkaiGameSettingsPage.xaml | 11 +++++++++++ .../StarRailGameSettingsPage.Ext.cs | 6 ++++++ .../StarRailGameSettingsPage.xaml | 11 +++++++++++ .../ZenlessGameSettingsPage.Ext.cs | 6 ++++++ .../ZenlessGameSettingsPage.xaml | 11 +++++++++++ .../MainApp/Pages/HomePage.GameLauncher.cs | 17 ++++++++++++----- Hi3Helper.Core/Lang/Locale/LangDialogs.cs | 1 + .../Lang/Locale/LangGameSettingsPage.cs | 4 ++++ Hi3Helper.Core/Lang/en_US.json | 7 ++++++- 14 files changed, 102 insertions(+), 7 deletions(-) diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/Universal/RegistryClass/CollapseMiscSetting.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Universal/RegistryClass/CollapseMiscSetting.cs index 39343c98f..ce39ed13a 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/Universal/RegistryClass/CollapseMiscSetting.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/Universal/RegistryClass/CollapseMiscSetting.cs @@ -133,6 +133,11 @@ public bool UseCustomRegionBG /// public bool IsPlayingRpc { get; set; } = true; + /// + /// Forces the game process to launch under Explorer to prevent logins from getting blocked
+ /// Must be disabled when using Steam Input and Overlay + ///
+ public bool RunWithExplorerAsParent { get; set; } = true; #endregion #region Methods diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/SimpleDialogs.cs b/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/SimpleDialogs.cs index 87a60ea94..19aab9413 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/SimpleDialogs.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/SimpleDialogs.cs @@ -1436,7 +1436,12 @@ public static Task Dialog_SteamShortcutCreationSuccess(bool { Text = Lang._Dialogs.SteamShortcutCreationSuccessSubtitle6, TextWrapping = TextWrapping.WrapWholeWords - }.WithMargin(0d, 2d, 0d, 4d)); + }.WithMargin(0d, 2d, 0d, 1d), + new TextBlock + { + Text = Lang._Dialogs.SteamShortcutCreationSuccessSubtitle8, + TextWrapping = TextWrapping.WrapWholeWords + }.WithMargin(0d, 1d, 0d, 4d)); return SpawnDialog(Lang._Dialogs.SteamShortcutCreationSuccessTitle, panel, diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.Ext.cs b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.Ext.cs index ec939afe5..736a210bb 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.Ext.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.Ext.cs @@ -611,6 +611,12 @@ private void GameLaunchDelay_OnValueChanged(NumberBox sender, NumberBoxValueChan if ((int)sender.Value < 0) sender.Value = 0; } + + public bool RunWithExplorerAsParent + { + get => Settings.SettingsCollapseMisc.RunWithExplorerAsParent; + set => Settings.SettingsCollapseMisc.RunWithExplorerAsParent = value; + } #endregion } } diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml index ae9075a11..0f8e0a0ae 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml @@ -1083,6 +1083,17 @@ + + + diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/HonkaiGameSettingsPage.Ext.cs b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/HonkaiGameSettingsPage.Ext.cs index 9b1657b77..ff4c8af1b 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/HonkaiGameSettingsPage.Ext.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/HonkaiGameSettingsPage.Ext.cs @@ -662,6 +662,12 @@ private void GameLaunchDelay_OnValueChanged(NumberBox sender, NumberBoxValueChan if ((int)sender.Value < 0) sender.Value = 0; } + + public bool RunWithExplorerAsParent + { + get => Settings.SettingsCollapseMisc.RunWithExplorerAsParent; + set => Settings.SettingsCollapseMisc.RunWithExplorerAsParent = value; + } #endregion } } diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/HonkaiGameSettingsPage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/HonkaiGameSettingsPage.xaml index d3922061d..23301bdd4 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/HonkaiGameSettingsPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/HonkaiGameSettingsPage.xaml @@ -906,6 +906,17 @@ + + + diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.Ext.cs b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.Ext.cs index b98367761..15feee52a 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.Ext.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.Ext.cs @@ -476,6 +476,12 @@ private void GameLaunchDelay_OnValueChanged(NumberBox sender, NumberBoxValueChan if ((int)sender.Value < 0) sender.Value = 0; } + + public bool RunWithExplorerAsParent + { + get => Settings.SettingsCollapseMisc.RunWithExplorerAsParent; + set => Settings.SettingsCollapseMisc.RunWithExplorerAsParent = value; + } #endregion } } diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.xaml index 8400dec67..a29bc638e 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.xaml @@ -801,6 +801,17 @@ + + + diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.Ext.cs b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.Ext.cs index c60f44b0b..13e9ac868 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.Ext.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.Ext.cs @@ -455,6 +455,12 @@ private void GameLaunchDelay_OnValueChanged(NumberBox sender, NumberBoxValueChan if ((int)sender.Value < 0) sender.Value = 0; } + + public bool RunWithExplorerAsParent + { + get => Settings.SettingsCollapseMisc.RunWithExplorerAsParent; + set => Settings.SettingsCollapseMisc.RunWithExplorerAsParent = value; + } #endregion #region Language Settings - GENERAL_DATA diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml index ab559b4bc..9891e09b5 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml @@ -1011,6 +1011,17 @@ + + + diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameLauncher.cs b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameLauncher.cs index 59c3d37e0..e73461522 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameLauncher.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameLauncher.cs @@ -108,14 +108,21 @@ private async void StartGame(object? sender, RoutedEventArgs? e) workingDir = NormalizePath(GameDirPath); } - var pid = CreateProcessWithParent("explorer", exePath, additionalArguments, workingDir); - if (pid > 0) + if (CurrentGameProperty!.GameSettings?.SettingsCollapseMisc.RunWithExplorerAsParent ?? true) { - proc = Process.GetProcessById(pid); + var pid = CreateProcessWithParent("explorer", exePath, additionalArguments, workingDir); + if (pid > 0) + { + proc = Process.GetProcessById(pid); + } + else + { + LogWriteLine("[HomePage::StartGame()] Failed to start process with parent, falling back to normal process start.", LogType.Warning, true); + } } - else + + if (proc == null) { - LogWriteLine("[HomePage::StartGame()] Failed to start process with parent, falling back to normal process start.", LogType.Warning, true); proc = new Process(); proc.StartInfo.FileName = exePath; proc.StartInfo.Arguments = additionalArguments; diff --git a/Hi3Helper.Core/Lang/Locale/LangDialogs.cs b/Hi3Helper.Core/Lang/Locale/LangDialogs.cs index 1d35604f5..02f531a8f 100644 --- a/Hi3Helper.Core/Lang/Locale/LangDialogs.cs +++ b/Hi3Helper.Core/Lang/Locale/LangDialogs.cs @@ -166,6 +166,7 @@ public sealed partial class LangDialogs public string SteamShortcutCreationSuccessSubtitle5 { get; set; } = LangFallback?._Dialogs.SteamShortcutCreationSuccessSubtitle5; public string SteamShortcutCreationSuccessSubtitle6 { get; set; } = LangFallback?._Dialogs.SteamShortcutCreationSuccessSubtitle6; public string SteamShortcutCreationSuccessSubtitle7 { get; set; } = LangFallback?._Dialogs.SteamShortcutCreationSuccessSubtitle7; + public string SteamShortcutCreationSuccessSubtitle8 { get; set; } = LangFallback?._Dialogs.SteamShortcutCreationSuccessSubtitle8; public string SteamShortcutCreationFailureTitle { get; set; } = LangFallback?._Dialogs.SteamShortcutCreationFailureTitle; public string SteamShortcutCreationFailureSubtitle { get; set; } = LangFallback?._Dialogs.SteamShortcutCreationFailureSubtitle; public string SteamShortcutTitle { get; set; } = LangFallback?._Dialogs.SteamShortcutTitle; diff --git a/Hi3Helper.Core/Lang/Locale/LangGameSettingsPage.cs b/Hi3Helper.Core/Lang/Locale/LangGameSettingsPage.cs index 1b5c3802c..293fbae96 100644 --- a/Hi3Helper.Core/Lang/Locale/LangGameSettingsPage.cs +++ b/Hi3Helper.Core/Lang/Locale/LangGameSettingsPage.cs @@ -123,6 +123,10 @@ public sealed partial class LangGameSettingsPage public string Advanced_GLC_PreLaunch_Delay { get; set; } = LangFallback?._GameSettingsPage.Advanced_GLC_PreLaunch_Delay; public string Advanced_GLC_PostExit_Title { get; set; } = LangFallback?._GameSettingsPage.Advanced_GLC_PostExit_Title; public string Advanced_GLC_PostExit_Subtitle { get; set; } = LangFallback?._GameSettingsPage.Advanced_GLC_PostExit_Subtitle; + + public string Advanced_RunWithExplorerAsParent_Title { get; set; } = LangFallback?._GameSettingsPage.Advanced_RunWithExplorerAsParent_Title; + public string Advanced_RunWithExplorerAsParent_Subtitle { get; set; } = LangFallback?._GameSettingsPage.Advanced_RunWithExplorerAsParent_Subtitle; + public string Advanced_RunWithExplorerAsParent_Warning { get; set; } = LangFallback?._GameSettingsPage.Advanced_RunWithExplorerAsParent_Warning; } } #endregion diff --git a/Hi3Helper.Core/Lang/en_US.json b/Hi3Helper.Core/Lang/en_US.json index df7f6faf1..2d9d9362e 100644 --- a/Hi3Helper.Core/Lang/en_US.json +++ b/Hi3Helper.Core/Lang/en_US.json @@ -425,7 +425,11 @@ "Advanced_GLC_PreLaunch_Exit": "Force exit launched process when Game is closed/stopped", "Advanced_GLC_PreLaunch_Delay": "Delay Game Launch (ms)", "Advanced_GLC_PostExit_Title": "Post-Exit Commands", - "Advanced_GLC_PostExit_Subtitle": "Commands to be executed after the game is closed" + "Advanced_GLC_PostExit_Subtitle": "Commands to be executed after the game is closed", + + "Advanced_RunWithExplorerAsParent_Title": "Launch Game with Explorer as Parent", + "Advanced_RunWithExplorerAsParent_Subtitle": "Disable for compatibility with Steam Input and Overlay", + "Advanced_RunWithExplorerAsParent_Warning": "WARNING: Game logins might be blocked when disabled!" }, "_SettingsPage": { @@ -1128,6 +1132,7 @@ "SteamShortcutCreationSuccessSubtitle5": " • New shortcuts will only be shown after Steam is reloaded.", "SteamShortcutCreationSuccessSubtitle6": " • In order to use the Steam overlay, Steam needs to be run as administrator and Collapse must either be fully closed or have the \"Multiple Instances\" option enabled in the settings.", "SteamShortcutCreationSuccessSubtitle7": " • If the game is not installed/updated, Collapse will try to install/update it. Please note that dialogs related to these processes will still be shown.", + "SteamShortcutCreationSuccessSubtitle8": " Additionally, the \"Launch Game With Explorer As Parent\" option in the region's Advanced Settings must be disabled.", "SteamShortcutCreationFailureTitle": "Invalid Steam data folder", "SteamShortcutCreationFailureSubtitle": "It was not possible to find a valid userdata folder.\n\nPlease be sure to login at least once to the Steam client before trying to use this functionality.", "SteamShortcutTitle": "Steam Shortcut", From 42fbd5145c7cb6f0eb29716f6a13bdb30b65a8ac Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sun, 4 Jan 2026 01:29:30 +0700 Subject: [PATCH 41/47] Make Sophon patch-update resumable Previously, the launcher will try to redownload the entire patch files if say, the user tried to cancel the patch-update process, even though some target files have already been patched/updated. Now, the launcher will try to check for target file hash/existence before downloading the patch file. If the hash (of the target file) matches, then skip the patch process. --- .../Base/InstallManagerBase.SophonPatch.cs | 83 ++++++++++++++----- 1 file changed, 62 insertions(+), 21 deletions(-) diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.SophonPatch.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.SophonPatch.cs index 1e84bb06d..b4a88c650 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.SophonPatch.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.SophonPatch.cs @@ -11,6 +11,7 @@ using CollapseLauncher.Extension; using CollapseLauncher.Helper; using CollapseLauncher.Helper.Metadata; +using CollapseLauncher.Helper.StreamUtility; using Hi3Helper; using Hi3Helper.Data; using Hi3Helper.Plugin.Core.Management; @@ -24,8 +25,10 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.IO.Hashing; using System.Linq; using System.Net.Http; +using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; @@ -189,7 +192,7 @@ protected virtual async Task ConfirmAdditionalPatchDataPackageFiles(SophonChunkM } } - matchingFieldsList.AddRange(otherManifestIdentity.Select(identity => identity.MatchingField)); + matchingFieldsList.AddRange(otherManifestIdentity.Select(identity => identity.MatchingField ?? "")); return; string GetFileDetails() @@ -558,29 +561,67 @@ async ValueTask ImplDownload(Tuple> ct SophonPatchAsset patchAsset = ctx.Item1; Dictionary downloadedDict = ctx.Item2; - using (dictionaryLock.EnterScope()) + try { - _ = downloadedDict.TryAdd(patchAsset.PatchNameSource, 0); - downloadedDict[patchAsset.PatchNameSource]++; + UpdateCurrentDownloadStatus(); + // Check if target file has already been patched so the launcher won't redownload everything. + if (!isPreloadMode && patchAsset.PatchMethod != SophonPatchMethod.Remove) + { + FileInfo fileInfo = new FileInfo(Path.Combine(GamePath, patchAsset.TargetFilePath)) + .EnsureNoReadOnly() + .StripAlternateDataStream(); + + if (fileInfo.Exists && + fileInfo.Length == patchAsset.TargetFileSize) + { + byte[] remoteHashBytes = HexTool.HexToBytesUnsafe(patchAsset.TargetFileHash); + byte[] localHashBytes = remoteHashBytes.Length > 8 + ? await GetCryptoHashAsync(fileInfo, null, false, false, innerToken) + : await GetHashAsync(fileInfo, false, false, innerToken); + + // Try to reverse hash bytes in case the returned bytes are going to be Big-endian. + if (!localHashBytes.SequenceEqual(remoteHashBytes)) + { + Array.Reverse(localHashBytes); + } + + // Now compare. If the hash is already equal (means the target file is already downloaded), + // then skip from downloading the patch. + if (localHashBytes.SequenceEqual(remoteHashBytes)) + { + long patchSize = patchAsset.PatchSize; + UpdateSophonFileTotalProgress(patchSize); + UpdateSophonFileDownloadProgress(patchSize, patchSize); + return; + } + } + } + + await patchAsset.DownloadPatchAsync(httpClient, + GamePath, + patchOutputDir, + true, + read => + { + UpdateSophonFileTotalProgress(read); + UpdateSophonFileDownloadProgress(read, read); + }, + downloadLimiter, + innerToken); } + finally + { + using (dictionaryLock.EnterScope()) + { + _ = downloadedDict.TryAdd(patchAsset.PatchNameSource, 0); + downloadedDict[patchAsset.PatchNameSource]++; + } - UpdateCurrentDownloadStatus(); - await patchAsset.DownloadPatchAsync(httpClient, - GamePath, - patchOutputDir, - true, - read => - { - UpdateSophonFileTotalProgress(read); - UpdateSophonFileDownloadProgress(read, read); - }, - downloadLimiter, - innerToken); - - Logger.LogWriteLine($"Downloaded patch file for: {patchAsset.TargetFilePath}", - LogType.Debug, - true); - Interlocked.Increment(ref ProgressAllCountCurrent); + Logger.LogWriteLine($"Downloaded patch file for: {patchAsset.TargetFilePath}", + LogType.Debug, + true); + Interlocked.Increment(ref ProgressAllCountCurrent); + } } async ValueTask ImplPatchUpdate(Tuple> ctx, CancellationToken innerToken) From 228bc63cc6fbae13539a8dd394e918f0e4b6009a Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sun, 4 Jan 2026 01:38:02 +0700 Subject: [PATCH 42/47] Simplify IsRpcEnabled Use direct assignment to field and use implicit cast instead of ToBoolNullable() as the boolean will return false as default value afterwards. --- .../Classes/DiscordPresence/DiscordPresenceManager.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/CollapseLauncher/Classes/DiscordPresence/DiscordPresenceManager.cs b/CollapseLauncher/Classes/DiscordPresence/DiscordPresenceManager.cs index 660f0811d..0f843e4e7 100644 --- a/CollapseLauncher/Classes/DiscordPresence/DiscordPresenceManager.cs +++ b/CollapseLauncher/Classes/DiscordPresence/DiscordPresenceManager.cs @@ -38,16 +38,13 @@ public sealed partial class DiscordPresenceManager : IDisposable { #region Properties - [field: NonSerialized] - private bool? _isRpcEnabled; public bool IsRpcEnabled { - get => _isRpcEnabled ??= GetAppConfigValue("EnableDiscordRPC").ToBoolNullable() ?? false; + get => field = GetAppConfigValue("EnableDiscordRPC"); set { - if (_isRpcEnabled == value) return; - - _isRpcEnabled = value; + if (field == value) return; + field = value; SetAndSaveConfigValue("EnableDiscordRPC", value); if (value) SetupPresence(); From 2956ebf34e2323c717a4fccdb41978df40bc36fa Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sun, 4 Jan 2026 16:39:33 +0700 Subject: [PATCH 43/47] [Misc] #847 Ensure Creation of Directory on Sophon Patch Update --- .../Base/InstallManagerBase.Sophon.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs index 29fef7202..a0ca1d281 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs @@ -968,7 +968,9 @@ private ValueTask RunSophonAssetDownloadThread(HttpClient client, } // Get the target and temp file info - FileInfo existingFileInfo = new FileInfo(filePath).EnsureNoReadOnly(); + FileInfo existingFileInfo = new FileInfo(filePath) + .EnsureCreationOfDirectory() + .EnsureNoReadOnly(); return asset.WriteToStreamAsync(client, assetSize => existingFileInfo.Open(new FileStreamOptions @@ -1001,9 +1003,13 @@ private ValueTask RunSophonAssetUpdateThread(HttpClient client, // Get the target and temp file info FileInfo existingFileInfo = - new FileInfo(filePath).EnsureNoReadOnly(out bool isExistingFileInfoExist); + new FileInfo(filePath) + .EnsureCreationOfDirectory() + .EnsureNoReadOnly(out bool isExistingFileInfoExist); FileInfo sophonFileInfo = - new FileInfo(filePath + "_tempSophon").EnsureNoReadOnly(out bool isSophonFileInfoExist); + new FileInfo(filePath + "_tempSophon") + .EnsureCreationOfDirectory() + .EnsureNoReadOnly(out bool isSophonFileInfoExist); // Use "_tempSophon" if file is new or if "_tempSophon" file exist. Otherwise use original file if exist if (!isExistingFileInfoExist || isSophonFileInfoExist From 1fd5c87b4e9c978817503fe94b24660baefb0dfa Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sun, 4 Jan 2026 16:41:42 +0700 Subject: [PATCH 44/47] Simplify EnsureCreationOfDirectory() DirectoryInfo.Create() already contains a check for directory existence on PInvoke level. --- .../Classes/Helper/StreamUtility/StreamExtension.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs b/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs index 17fc990e8..0e7cae51b 100644 --- a/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs +++ b/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs @@ -142,9 +142,7 @@ internal static FileInfo EnsureCreationOfDirectory(this FileInfo filePath) try { - if (directoryInfo is { Exists: false }) - directoryInfo.Create(); - + directoryInfo?.Create(); return filePath; } finally From 1fb5e62e1ad988e1d3b949a0b7a9b8352091606d Mon Sep 17 00:00:00 2001 From: Kemal Setya Adhi Date: Sun, 4 Jan 2026 20:00:35 +0700 Subject: [PATCH 45/47] Fix wrong method on StarRailPersistentRefResult.FinalizeFetch --- .../StarRailV2/StarRailPersistentRefResult.FinalizeFetch.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.FinalizeFetch.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.FinalizeFetch.cs index 12183cb7e..3591ace70 100644 --- a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.FinalizeFetch.cs +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.FinalizeFetch.cs @@ -1,8 +1,8 @@ using CollapseLauncher.Helper.StreamUtility; using CollapseLauncher.RepairManagement.StarRail.Struct.Assets; using Hi3Helper; +using Hi3Helper.Data; using Hi3Helper.EncTool; -using Hi3Helper.Plugin.Core.Utility; using Hi3Helper.Shared.ClassStruct; using Hi3Helper.Sophon; using System; @@ -83,7 +83,7 @@ public async Task FinalizeCacheFetchAsync(StarRailRepairV2 instance, // -- Get game server's dictionary asset StarRailAssetSignaturelessMetadata.Metadata? gameServStockLuaPath = Metadata.CacheLua?.DataList.FirstOrDefault(); - string gameServLuaDictUrl = BaseUrls.CacheLua.CombineUrlFromString(gameServStockLuaPath?.Filename); + 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", From ab1ee93fa59adfdf09458e72ed3ad6601c6ce6c9 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 14:03:57 +0000 Subject: [PATCH 46/47] [skip ci] Sync translation Translate en_US.json in ja_JP 100% reviewed source file: 'en_US.json' on 'ja_JP'. --- Hi3Helper.Core/Lang/ja_JP.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Hi3Helper.Core/Lang/ja_JP.json b/Hi3Helper.Core/Lang/ja_JP.json index 281da3f62..442f847cb 100644 --- a/Hi3Helper.Core/Lang/ja_JP.json +++ b/Hi3Helper.Core/Lang/ja_JP.json @@ -425,7 +425,11 @@ "Advanced_GLC_PreLaunch_Exit": "ゲームが終了/停止した場合に実行中のプロセスを強制終了させる", "Advanced_GLC_PreLaunch_Delay": "ゲームの起動を遅らせる(ミリ秒)", "Advanced_GLC_PostExit_Title": "終了後コマンド", - "Advanced_GLC_PostExit_Subtitle": "ゲームの終了後にコマンドを実行する" + "Advanced_GLC_PostExit_Subtitle": "ゲームの終了後にコマンドを実行する", + + "Advanced_RunWithExplorerAsParent_Title": "エクスプローラーを親プロセスとしてゲームを起動", + "Advanced_RunWithExplorerAsParent_Subtitle": "Steam入力・Steamオーバーレイのために無効にする", + "Advanced_RunWithExplorerAsParent_Warning": "警告:無効にした場合、ゲームへのログインがブロックされる可能性があります!" }, "_SettingsPage": { @@ -1128,6 +1132,7 @@ "SteamShortcutCreationSuccessSubtitle5": " • 作成したショートカットはSteamの再起動後に表示されます。", "SteamShortcutCreationSuccessSubtitle6": " • Steamオーバーレイを使用するには、Steamを管理者として実行した上でCollapseを完全に閉じるか、ランチャー設定の「多重起動を許可する」を有効にする必要があります。", "SteamShortcutCreationSuccessSubtitle7": " • ゲームがインストール/更新されていない場合、Collapseはゲームのインストール/更新を試みます。これらのプロセスに関するダイアログが引き続き表示されることにご留意ください。", + "SteamShortcutCreationSuccessSubtitle8": "ゲームごとの設定から、「エクスプローラーを親プロセスとしてゲームを起動」を無効にする必要があります。", "SteamShortcutCreationFailureTitle": "Steamデータフォルダーが無効です", "SteamShortcutCreationFailureSubtitle": "有効なuserdataフォルダーが見つかりません。\n\nこの機能を使用する前に、少なくとも1回はSteamクライアントにログインしてください。", "SteamShortcutTitle": "Steamショートカット", From 5ff9ef9340d6c8ab87798db0607d7662091fdd0c Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 14:08:28 +0000 Subject: [PATCH 47/47] [skip ci] Sync translation Translate en_US.json in zh_CN 100% reviewed source file: 'en_US.json' on 'zh_CN'. --- Hi3Helper.Core/Lang/zh_CN.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Hi3Helper.Core/Lang/zh_CN.json b/Hi3Helper.Core/Lang/zh_CN.json index 136d33df3..f38482920 100644 --- a/Hi3Helper.Core/Lang/zh_CN.json +++ b/Hi3Helper.Core/Lang/zh_CN.json @@ -425,7 +425,11 @@ "Advanced_GLC_PreLaunch_Exit": "游戏关闭或停止时强制退出启动的进程", "Advanced_GLC_PreLaunch_Delay": "延迟游戏启动(毫秒)", "Advanced_GLC_PostExit_Title": "退出后命令", - "Advanced_GLC_PostExit_Subtitle": "在游戏关闭后执行的命令" + "Advanced_GLC_PostExit_Subtitle": "在游戏关闭后执行的命令", + + "Advanced_RunWithExplorerAsParent_Title": "以资源管理器作为父进程启动游戏", + "Advanced_RunWithExplorerAsParent_Subtitle": "禁用以兼容 Steam 输入和叠加层", + "Advanced_RunWithExplorerAsParent_Warning": "警告:禁用后可能导致游戏登录被封禁!" }, "_SettingsPage": { @@ -1128,6 +1132,7 @@ "SteamShortcutCreationSuccessSubtitle5": " • 新的快捷方式仅在 Steam 重新加载后才会显示。", "SteamShortcutCreationSuccessSubtitle6": " • 要使用 Steam 叠加层,Steam 需要以管理员身份运行,而且 Collapse 必须完全关闭或在设置中启用“允许开启多个 Collapse”选项。", "SteamShortcutCreationSuccessSubtitle7": " • 如果游戏未安装/更新,Collapse 会尝试安装/更新。请注意,与这些过程相关的对话框仍会显示。", + "SteamShortcutCreationSuccessSubtitle8": " 此外,该区服的“高级设置”中的“以资源管理器作为父进程启动游戏”选项必须禁用。", "SteamShortcutCreationFailureTitle": "无效的 Steam 数据文件夹", "SteamShortcutCreationFailureSubtitle": "无法找到有效的 userdata 文件夹。\n\n在尝试使用此功能之前,请确保至少登录一次 Steam 客户端。", "SteamShortcutTitle": "Steam 快捷方式",