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 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/DiscordPresence/DiscordPresenceManager.cs b/CollapseLauncher/Classes/DiscordPresence/DiscordPresenceManager.cs index cbdea80e9..0f843e4e7 100644 --- a/CollapseLauncher/Classes/DiscordPresence/DiscordPresenceManager.cs +++ b/CollapseLauncher/Classes/DiscordPresence/DiscordPresenceManager.cs @@ -37,6 +37,21 @@ public enum ActivityType public sealed partial class DiscordPresenceManager : IDisposable { #region Properties + + public bool IsRpcEnabled + { + get => field = GetAppConfigValue("EnableDiscordRPC"); + set + { + if (field == value) return; + field = 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 +61,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 +118,7 @@ public void Dispose() private void EnablePresence(ulong applicationId) { + if (!IsRpcEnabled) return; _firstTimeConnect = true; // Flush and dispose the session @@ -168,8 +184,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 +238,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/GameManagement/GameSettings/Universal/RegistryClass/CollapseMiscSetting.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Universal/RegistryClass/CollapseMiscSetting.cs index 943dc16b6..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 @@ -154,6 +159,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 ) diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Enums.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Enums.cs index bce6ed4bb..7b60a36f0 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 = 2 +} + 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..3a0332f03 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")] @@ -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/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/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; 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 37e396bcc..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,30 +53,29 @@ 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; 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 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/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/Helper/JsonConverter/UtcUnixStampToDateTimeOffsetJsonConverter.cs b/CollapseLauncher/Classes/Helper/JsonConverter/UtcUnixStampToDateTimeOffsetJsonConverter.cs new file mode 100644 index 000000000..b634f31fd --- /dev/null +++ b/CollapseLauncher/Classes/Helper/JsonConverter/UtcUnixStampToDateTimeOffsetJsonConverter.cs @@ -0,0 +1,67 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +#pragma warning disable IDE0130 + +namespace CollapseLauncher.Helper.JsonConverter; + +internal class UtcUnixStampToDateTimeOffsetJsonConverter : JsonConverter +{ + public override DateTimeOffset Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + if (reader.ValueSpan.IsEmpty) + { + return default; + } + + if (reader.TokenType == JsonTokenType.String) + { + return double.TryParse(reader.ValueSpan, out double timestampStr) + ? Parse(timestampStr) + : throw new InvalidOperationException("Cannot parse string value to number for DateTimeOffset."); + } + + if (reader.TryGetDouble(out double unixTimestampDouble)) + { + return Parse(unixTimestampDouble); + } + + if (reader.TryGetInt64(out long unixTimestampLong)) + { + return Parse(unixTimestampLong); + } + + if (reader.TryGetUInt64(out ulong unixTimestampUlong)) + { + return Parse(unixTimestampUlong); + } + + throw new InvalidOperationException("Cannot parse number to DateTimeOffset"); + } + + public override void Write( + Utf8JsonWriter writer, + DateTimeOffset value, + JsonSerializerOptions options) + { + long number = value.Millisecond != 0 + ? value.ToUnixTimeMilliseconds() + : value.ToUnixTimeSeconds(); + + if (options.NumberHandling.HasFlag(JsonNumberHandling.WriteAsString)) + { + writer.WriteStringValue($"{number}"); + return; + } + + writer.WriteNumberValue(number); + } + + private static DateTimeOffset Parse(double timestamp) => timestamp >= 1000000000000 + ? DateTimeOffset.FromUnixTimeMilliseconds((long)Math.Abs(timestamp)) + : DateTimeOffset.FromUnixTimeSeconds((long)Math.Abs(timestamp)); +} diff --git a/CollapseLauncher/Classes/Helper/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/Helper/Metadata/PresetConfig.cs b/CollapseLauncher/Classes/Helper/Metadata/PresetConfig.cs index 429cfdf10..f1b1e3283 100644 --- a/CollapseLauncher/Classes/Helper/Metadata/PresetConfig.cs +++ b/CollapseLauncher/Classes/Helper/Metadata/PresetConfig.cs @@ -626,8 +626,8 @@ private int GetVoiceLanguageID_Genshin(string regPath) { try { - RegistryKey? keys = Registry.CurrentUser.OpenSubKey(ConfigRegistryLocation); - byte[]? value = (byte[]?)keys?.GetValue("GENERAL_DATA_h2389025596"); + RegistryKey? keys = Registry.CurrentUser.OpenSubKey(ConfigRegistryLocation); + byte[]? value = (byte[]?)keys?.GetValue("GENERAL_DATA_h2389025596"); if (keys is null || value is null || value.Length is 0) { @@ -658,15 +658,25 @@ private int GetVoiceLanguageID_Genshin(string regPath) // WARNING!!! // This feature is only available for Genshin and Star Rail. - public void SetVoiceLanguageID(int langID) + public void SetVoiceLanguageID(string localeId) { switch (GameType) { case GameNameType.Genshin: - SetVoiceLanguageID_Genshin(langID); + int genshinId = localeId switch + { + "zh-cn" => 0, + "en-us" => 1, + "ja-jp" => 2, + "ko-kr" => 3, + _ => throw new + InvalidOperationException($"[SetVoiceLanguageID] Locale ID is unknown! {localeId}") + }; + SetVoiceLanguageID_Genshin(genshinId); break; case GameNameType.StarRail: - SetVoiceLanguageID_StarRail(langID); + int srId = GetStarRailVoiceLanguageByName(localeId.GetSplit(1, "-").ToString()); + SetVoiceLanguageID_StarRail(srId); break; } } diff --git a/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs b/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs index 1171105ad..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 @@ -325,7 +323,7 @@ public static void DeleteEmptyDirectory(this DirectoryInfo dir, bool recursive = { foreach (DirectoryInfo childDir in dir.EnumerateDirectories("*", SearchOption.TopDirectoryOnly)) { - DeleteEmptyDirectory(childDir); + childDir.DeleteEmptyDirectory(); } } diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs index 575bd9e79..a0ca1d281 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) { @@ -577,7 +555,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; }); @@ -981,8 +959,18 @@ private ValueTask RunSophonAssetDownloadThread(HttpClient client, return RunSophonAssetUpdateThread(client, asset, parallelOptions); } + // HACK: To avoid user unable to continue the download due to executable being downloaded completely, + // append "_tempSophon" on it. + string filename = Path.GetFileName(assetName); + if (filename.StartsWith(GameVersionManager.GamePreset.GameExecutableName ?? "", StringComparison.OrdinalIgnoreCase)) + { + filePath += "_tempSophon"; + } + // Get the target and temp file info - FileInfo existingFileInfo = new FileInfo(filePath).EnsureNoReadOnly(); + FileInfo existingFileInfo = new FileInfo(filePath) + .EnsureCreationOfDirectory() + .EnsureNoReadOnly(); return asset.WriteToStreamAsync(client, assetSize => existingFileInfo.Open(new FileStreamOptions @@ -1015,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 @@ -1299,12 +1291,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 +1309,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 +1335,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 +1345,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.SophonPatch.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.SophonPatch.cs index 6b5b68600..b4a88c650 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.SophonPatch.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.SophonPatch.cs @@ -11,7 +11,7 @@ using CollapseLauncher.Extension; using CollapseLauncher.Helper; using CollapseLauncher.Helper.Metadata; -using CollapseLauncher.Interfaces; +using CollapseLauncher.Helper.StreamUtility; using Hi3Helper; using Hi3Helper.Data; using Hi3Helper.Plugin.Core.Management; @@ -25,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; @@ -51,15 +53,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 @@ -125,7 +126,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 +141,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; @@ -165,13 +169,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; }); @@ -188,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() @@ -202,12 +206,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; @@ -557,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) diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs index fb8fc1513..e8a960e7c 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", @@ -2575,11 +2576,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 @@ -2747,16 +2745,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 +2760,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 +2772,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 +2781,7 @@ protected virtual async ValueTask AddVoiceOverResourceVersionList( } // Set the voice language ID to value given - GameVersionManager.GamePreset.SetVoiceLanguageID(setAsDefaultVO); + GameVersionManager.GamePreset.SetVoiceLanguageID(setAsDefaultVOLocalecode); } } } diff --git a/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.SophonPatch.cs b/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.SophonPatch.cs index 785573400..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; @@ -6,13 +6,18 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +// 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); @@ -35,7 +40,9 @@ protected override async Task FilterSophonPatchAssetList(List continue; } - blackListAlt.Add(span); + // Normalize path + ConverterTool.NormalizePathInplaceNoTrim(span); + AddBothPersistentOrStreamingAssets(blackListAlt, span); } if (blackList.Count == 0) @@ -46,17 +53,17 @@ 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; } - string assetPath = Path.Combine(GamePath, patchAsset.TargetFilePath); - if (assetPath.AsSpan().ContainsAny(searchValues)) + int indexOfAny = filePath.IndexOfAny(searchValues); + if (indexOfAny >= 0) { continue; } @@ -91,5 +98,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/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.cs b/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.cs index f6c85f102..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, @@ -115,7 +115,7 @@ protected override async Task StartPackageInstallationInner(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..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; @@ -24,21 +25,24 @@ 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); + HashSet exceptMatchFieldHashSet = await GetExceptMatchFieldHashSet(token); if (exceptMatchFieldHashSet.Count == 0) { return; } - FilterSophonAsset(itemList, x => x.MainAssetInfo, exceptMatchFieldHashSet, token); + FilterSophonAsset(itemList, exceptMatchFieldHashSet); } - private async Task> GetExceptMatchFieldHashSet(CancellationToken token) + 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"); @@ -48,50 +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, CancellationToken token) + private static void FilterSophonAsset(List itemList, HashSet exceptMatchFieldHashSet) { - const string separators = "/\\"; - scoped Span urlPathRanges = stackalloc Range[32]; - 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)) - { - filteredList.Add(asset); - continue; - } - - ReadOnlySpan manifestUrl = chunkInfo.ChunksBaseUrl; - 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; } @@ -108,11 +82,10 @@ private static void FilterSophonAsset(List itemList, Func 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(); @@ -135,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/InstallManagement/Zenless/ZenlessInstall.cs b/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.cs index c5e4713ca..43be19689 100644 --- a/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.cs +++ b/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.cs @@ -159,34 +159,30 @@ protected override void WriteAudioLangList(List gamePackage) } } - protected override void WriteAudioLangListSophon(List sophonVOList) + protected override void WriteAudioLangListSophon(ICollection sophonVOList) { // Run the writing method from the base first base.WriteAudioLangListSophon(sophonVOList); // Then create the one from the alternate one // Read all the existing list - List langList = File.Exists(_gameAudioLangListPathAlternateStatic) - ? File.ReadAllLines(_gameAudioLangListPathAlternateStatic).ToList() + HashSet langList = File.Exists(_gameAudioLangListPathAlternateStatic) + ? File.ReadAllLines(_gameAudioLangListPathAlternateStatic).ToHashSet(StringComparer.OrdinalIgnoreCase) : []; // Try lookup if there is a new language list, then add it to the list - for (int index = sophonVOList.Count - 1; index >= 0; index--) + foreach (string vo in sophonVOList) { - var packageLocaleCodeString = sophonVOList[index]; - string langString = GetLanguageStringByLocaleCodeAlternate(packageLocaleCodeString); - if (!langList.Contains(langString, StringComparer.OrdinalIgnoreCase)) - { - langList.Add(langString); - } + string langString = GetLanguageStringByLocaleCodeAlternate(vo); + langList.Add(langString); } // Create the audio lang list file - using var sw = new StreamWriter(_gameAudioLangListPathAlternateStatic, - new FileStreamOptions - { Mode = FileMode.Create, Access = FileAccess.Write }); + using StreamWriter sw = new StreamWriter(_gameAudioLangListPathAlternateStatic, + new FileStreamOptions + { Mode = FileMode.Create, Access = FileAccess.Write }); // Iterate the package list - foreach (var voIds in langList) + foreach (string voIds in langList) // Write the language string as per ID { sw.WriteLine(voIds); diff --git a/CollapseLauncher/Classes/Interfaces/Class/GamePropertyBase.cs b/CollapseLauncher/Classes/Interfaces/Class/GamePropertyBase.cs index eb1975f49..834fa33ae 100644 --- a/CollapseLauncher/Classes/Interfaces/Class/GamePropertyBase.cs +++ b/CollapseLauncher/Classes/Interfaces/Class/GamePropertyBase.cs @@ -75,7 +75,7 @@ protected static int ThreadCount get => (byte)LauncherConfig.AppCurrentThread; } - protected GameVersion GameVersion + internal GameVersion GameVersion { get { @@ -87,7 +87,7 @@ protected GameVersion GameVersion } } - protected IGameVersion GameVersionManager + internal IGameVersion GameVersionManager { get; } diff --git a/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs b/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs index 02021ec93..732db006c 100644 --- a/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs +++ b/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs @@ -1108,21 +1108,54 @@ void Impl(HypPluginPackageInfo plugin) protected virtual async Task TryRunExamineThrow(Task action) { - await TryRunExamineThrow((Task)action); + // Define if the status is still running + Status.IsRunning = true; + Status.IsCompleted = false; + Status.IsCanceled = false; - if (action.IsCompletedSuccessfully) + try { - return action.Result; - } + // Run the task + T result = await action; - if ((action.IsFaulted || - action.IsCanceled) && - action.Exception != null) + Status.IsCompleted = true; + return result; + } + catch (TaskCanceledException) { - throw action.Exception; + // If a cancellation was thrown, then set IsCanceled as true + Status.IsCompleted = false; + Status.IsCanceled = true; + throw; } + catch (OperationCanceledException) + { + // If a cancellation was thrown, then set IsCanceled as true + Status.IsCompleted = false; + Status.IsCanceled = true; + throw; + } + catch (Exception) + { + // Except, if the other exception was thrown, then set both IsCompleted + // and IsCanceled as false. + Status.IsCompleted = false; + Status.IsCanceled = false; + throw; + } + finally + { + if (Status is { IsCompleted: false, IsCanceled: false }) + { + WindowUtility.SetTaskBarState(TaskbarState.Error); + } + else + { + WindowUtility.SetTaskBarState(TaskbarState.NoProgress); + } - throw new InvalidOperationException(); + Status.IsRunning = false; + } } protected virtual async Task TryRunExamineThrow(Task task) @@ -1163,7 +1196,7 @@ protected virtual async Task TryRunExamineThrow(Task task) } finally { - if (Status is { IsCompleted: false }) + if (Status is { IsCompleted: false, IsCanceled: false }) { WindowUtility.SetTaskBarState(TaskbarState.Error); } @@ -1215,7 +1248,7 @@ protected virtual async ValueTask TryRunExamineThrow(ValueTask task) } finally { - if (Status is { IsCompleted: false }) + if (Status is { IsCompleted: false, IsCanceled: false }) { WindowUtility.SetTaskBarState(TaskbarState.Error); } diff --git a/CollapseLauncher/Classes/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); 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/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Generic.cs b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Generic.cs index f4fe3d0f9..02630c1d4 100644 --- a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Generic.cs +++ b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Generic.cs @@ -14,58 +14,55 @@ namespace CollapseLauncher.RepairManagement; internal static partial class AssetBundleExtension { - internal static void AddBrokenAssetToList( - this ProgressBase progressBase, - FilePropertiesRemote asset, - byte[]? finalHash = null, - long? useFoundSize = null) + extension(ProgressBase progressBase) { - AssetProperty property = - new AssetProperty(Path.GetFileName(asset.N), - asset.GetRepairAssetType(), - Path.GetDirectoryName(asset.N) ?? "\\", - useFoundSize ?? asset.S, - finalHash, - asset.CRCArray); - - asset.AssociatedAssetProperty = property; - progressBase.Dispatch(AddToUITable); - lock (progressBase.AssetIndex) + internal void AddBrokenAssetToList(FilePropertiesRemote asset, + byte[]? finalHash = null, + long? useFoundSize = null) { - progressBase.AssetIndex.Add(asset); - } + AssetProperty property = + new AssetProperty(Path.GetFileName(asset.N), + asset.GetRepairAssetType(), + Path.GetDirectoryName(asset.N) ?? "\\", + useFoundSize ?? asset.S, + finalHash, + asset.CRCArray); - progressBase.Status.IsAssetEntryPanelShow = progressBase.AssetIndex.Count > 0; - progressBase.UpdateStatus(); - Interlocked.Add(ref progressBase.ProgressAllSizeFound, useFoundSize ?? asset.S); - Interlocked.Increment(ref progressBase.ProgressAllCountFound); + asset.AssociatedAssetProperty = property; + progressBase.Dispatch(AddToUITable); + lock (progressBase.AssetIndex) + { + progressBase.AssetIndex.Add(asset); + } - return; + progressBase.Status.IsAssetEntryPanelShow = progressBase.AssetIndex.Count > 0; + progressBase.UpdateStatus(); + Interlocked.Add(ref progressBase.ProgressAllSizeFound, useFoundSize ?? asset.S); + Interlocked.Increment(ref progressBase.ProgressAllCountFound); - void AddToUITable() - { - progressBase.AssetEntry.Add(property); + return; + + void AddToUITable() + { + progressBase.AssetEntry.Add(property); + } } - } - internal static void PopBrokenAssetFromList( - this ProgressBase progressBase, - FilePropertiesRemote asset) - { - if (asset.AssociatedAssetProperty is IAssetProperty assetProperty) + internal void PopBrokenAssetFromList(FilePropertiesRemote asset) { - progressBase.PopRepairAssetEntry(assetProperty); + if (asset.AssociatedAssetProperty is IAssetProperty assetProperty) + { + progressBase.PopRepairAssetEntry(assetProperty); + } } - } - internal static void UpdateCurrentRepairStatus( - this ProgressBase progressBase, - FilePropertiesRemote asset) - { - // Increment total count current - progressBase.ProgressAllCountCurrent++; - progressBase.Status.ActivityStatus = string.Format(Locale.Lang._GameRepairPage.Status8, asset.N); - progressBase.UpdateStatus(); + internal void UpdateCurrentRepairStatus(FilePropertiesRemote asset, bool isCacheUpdateMode = false) + { + // Increment total count current + progressBase.ProgressAllCountCurrent++; + progressBase.Status.ActivityStatus = string.Format(isCacheUpdateMode ? Locale.Lang!._Misc!.Downloading + ": {0}" : Locale.Lang._GameRepairPage.Status8, asset.N); + progressBase.UpdateStatus(); + } } private static RepairAssetType GetRepairAssetType(this FilePropertiesRemote asset) => diff --git a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Fetch.cs b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Fetch.cs index 4acbc4ae6..56c47fab4 100644 --- a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Fetch.cs +++ b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Fetch.cs @@ -44,64 +44,12 @@ internal class SenadinaFileResult #region Fetch by Sophon private async Task FetchAssetFromSophon(List assetIndex, CancellationToken token) { - // Set total activity string as "Fetching Caches Type: " - Status.ActivityStatus = string.Format(Locale.Lang._CachesPage.CachesStatusFetchingType, "Sophon"); - Status.IsProgressAllIndetermined = true; - Status.IsIncludePerFileIndicator = false; - UpdateStatus(); - - PresetConfig gamePreset = GameVersionManager!.GamePreset; - string? sophonApiUrl = gamePreset.LauncherResourceChunksURL?.MainUrl; - string? matchingField = gamePreset.LauncherResourceChunksURL?.MainBranchMatchingField; - - if (sophonApiUrl == null) - { - throw new NullReferenceException("Sophon API URL inside of the metadata is null. Try to clear your metadata by going to \"App Settings\" > \"Clear Metadata and Restart\""); - } - - string versionApiToUse = GameVersion.ToString(); - sophonApiUrl += $"&tag={versionApiToUse}"; - - SophonChunkManifestInfoPair infoPair = await SophonManifest - .CreateSophonChunkManifestInfoPair(HttpClientGeneric, - sophonApiUrl, - matchingField, - false, + await this.FetchAssetsFromSophonAsync(HttpClientGeneric, + assetIndex, + DetermineFileTypeFromExtension, + GameVersion, + ["en-us", "zh-cn", "ja-jp", "ko-kr"], token); - - if (!infoPair.IsFound) - { - throw new InvalidOperationException($"Sophon cannot find matching field: {matchingField} from API URL: {sophonApiUrl}"); - } - - SearchValues excludedMatchingFields = - SearchValues.Create(["en-us", "zh-cn", "ja-jp", "ko-kr"], StringComparison.OrdinalIgnoreCase); - List infoPairs = [infoPair]; - infoPairs.AddRange(infoPair - .OtherSophonBuildData? - .ManifestIdentityList - .Where(x => !x.MatchingField - .ContainsAny(excludedMatchingFields) && !x.MatchingField.Equals(matchingField)) - .Select(x => infoPair.GetOtherManifestInfoPair(x.MatchingField)) ?? []); - - - foreach (SophonChunkManifestInfoPair pair in infoPairs) - { - await foreach (SophonAsset asset in SophonManifest - .EnumerateAsync(HttpClientGeneric, - pair, - token: token)) - { - assetIndex.Add(new FilePropertiesRemote - { - AssociatedObject = asset, - S = asset.AssetSize, - N = asset.AssetName.NormalizePath(), - CRC = asset.AssetHash, - FT = DetermineFileTypeFromExtension(asset.AssetName) - }); - } - } } #endregion diff --git a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Repair.Generic.cs b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Repair.Generic.cs index 882f1e354..905b650f3 100644 --- a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Repair.Generic.cs +++ b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Repair.Generic.cs @@ -24,30 +24,37 @@ private async ValueTask RepairAssetGenericSophonType( // Update repair status to the UI this.UpdateCurrentRepairStatus(asset); - string assetPath = Path.Combine(GamePath, asset.N); - FileInfo assetFileInfo = new FileInfo(assetPath) - .StripAlternateDataStream() - .EnsureCreationOfDirectory() - .EnsureNoReadOnly(); + try + { + string assetPath = Path.Combine(GamePath, asset.N); + FileInfo assetFileInfo = new FileInfo(assetPath) + .StripAlternateDataStream() + .EnsureCreationOfDirectory() + .EnsureNoReadOnly(); - await using FileStream assetFileStream = assetFileInfo - .Open(FileMode.Create, - FileAccess.Write, - FileShare.Write, - asset.S.GetFileStreamBufferSize()); + await using FileStream assetFileStream = assetFileInfo + .Open(FileMode.Create, + FileAccess.Write, + FileShare.Write, + asset.S.GetFileStreamBufferSize()); - if (asset.AssociatedObject is not SophonAsset sophonAsset) + if (asset.AssociatedObject is not SophonAsset sophonAsset) + { + throw new + InvalidOperationException("Invalid operation! This asset shouldn't have been here! It's not a sophon-based asset!"); + } + + // Download as Sophon asset + await sophonAsset + .WriteToStreamAsync(HttpClientGeneric, + assetFileStream, + readBytes => UpdateProgressCounter(readBytes, readBytes), + token: token); + } + finally { - throw new - InvalidOperationException("Invalid operation! This asset shouldn't have been here! It's not a sophon-based asset!"); + this.PopBrokenAssetFromList(asset); } - - // Download as Sophon asset - await sophonAsset - .WriteToStreamAsync(HttpClientGeneric, - assetFileStream, - readBytes => UpdateProgressCounter(readBytes, readBytes), - token: token); } private async ValueTask RepairAssetGenericType( @@ -61,6 +68,7 @@ private async ValueTask RepairAssetGenericType( string assetPath = Path.Combine(GamePath, asset.N); FileInfo assetFileInfo = new FileInfo(assetPath) .StripAlternateDataStream() + .EnsureCreationOfDirectory() .EnsureNoReadOnly(); try diff --git a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Repair.cs b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Repair.cs index 9a1139c93..faa5ff534 100644 --- a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Repair.cs +++ b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Repair.cs @@ -50,11 +50,15 @@ private async Task StartRepairRoutineCoreAsync(bool showInteractivePrompt await SpawnRepairDialog(AssetIndex, actionIfInteractiveCancel); } + int threadNum = IsBurstDownloadEnabled + ? 1 + : ThreadForIONormalized; + await Parallel.ForEachAsync(AssetIndex, new ParallelOptions { CancellationToken = Token!.Token, - MaxDegreeOfParallelism = ThreadForIONormalized + MaxDegreeOfParallelism = threadNum }, Impl); diff --git a/CollapseLauncher/Classes/RepairManagement/SophonRepairFetchUtility.cs b/CollapseLauncher/Classes/RepairManagement/SophonRepairFetchUtility.cs new file mode 100644 index 000000000..0c241e92f --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/SophonRepairFetchUtility.cs @@ -0,0 +1,95 @@ +using CollapseLauncher.Helper.Metadata; +using CollapseLauncher.Helper.StreamUtility; +using CollapseLauncher.Interfaces; +using Hi3Helper; +using Hi3Helper.Plugin.Core.Management; +using Hi3Helper.Shared.ClassStruct; +using Hi3Helper.Sophon; +using Hi3Helper.Sophon.Structs; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +#nullable enable +#pragma warning disable IDE0130 +namespace CollapseLauncher.RepairManagement; + +internal static class RepairSharedUtility +{ + public static async Task FetchAssetsFromSophonAsync( + this ProgressBase instance, + HttpClient client, + List assetIndex, + Func assetTypeDeterminer, + GameVersion gameVersion, + string[] excludeMatchingFieldList, + CancellationToken token = default) + { + // Set total activity string as "Fetching Caches Type: " + instance.Status.ActivityStatus = string.Format(Locale.Lang._CachesPage.CachesStatusFetchingType, "Sophon"); + instance.Status.IsProgressAllIndetermined = true; + instance.Status.IsIncludePerFileIndicator = false; + instance.UpdateStatus(); + + PresetConfig gamePreset = instance.GameVersionManager.GamePreset; + string? sophonApiUrl = gamePreset.LauncherResourceChunksURL?.MainUrl; + string? matchingField = gamePreset.LauncherResourceChunksURL?.MainBranchMatchingField; + + if (sophonApiUrl == null) + { + throw new NullReferenceException("Sophon API URL inside of the metadata is null. Try to clear your metadata by going to \"App Settings\" > \"Clear Metadata and Restart\""); + } + + string versionApiToUse = gameVersion.ToString(); + sophonApiUrl += $"&tag={versionApiToUse}"; + + SophonChunkManifestInfoPair infoPair = await SophonManifest + .CreateSophonChunkManifestInfoPair(client, + sophonApiUrl, + matchingField, + false, + token); + + if (!infoPair.IsFound) + { + throw new InvalidOperationException($"Sophon cannot find matching field: {matchingField} from API URL: {sophonApiUrl}"); + } + + SearchValues excludedMatchingFields = SearchValues.Create(excludeMatchingFieldList, StringComparison.OrdinalIgnoreCase); + List infoPairs = [infoPair]; + infoPairs.AddRange(infoPair + .OtherSophonBuildData? + .ManifestIdentityList + .Where(x => !x.MatchingField + .ContainsAny(excludedMatchingFields) && !x.MatchingField.Equals(matchingField)) + .Select(x => infoPair.GetOtherManifestInfoPair(x.MatchingField)) ?? []); + + foreach (SophonChunkManifestInfoPair pair in infoPairs) + { + instance.Status.ActivityStatus = string.Format(Locale.Lang._CachesPage.CachesStatusFetchingType, $"Sophon ({pair.MatchingField})"); + instance.Status.IsProgressAllIndetermined = true; + instance.Status.IsIncludePerFileIndicator = false; + instance.UpdateStatus(); + + await foreach (SophonAsset asset in SophonManifest + .EnumerateAsync(client, + pair, + token: token)) + { + assetIndex.Add(new FilePropertiesRemote + { + AssociatedObject = asset, + S = asset.AssetSize, + N = asset.AssetName.NormalizePath(), + CRC = asset.AssetHash, + FT = assetTypeDeterminer(asset.AssetName) + }); + } + } + } + +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Check.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Check.cs deleted file mode 100644 index ac795da81..000000000 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Check.cs +++ /dev/null @@ -1,411 +0,0 @@ -using CollapseLauncher.Helper.StreamUtility; -using Hi3Helper; -using Hi3Helper.Data; -using Hi3Helper.SentryHelper; -using Hi3Helper.Shared.ClassStruct; -using Hi3Helper.Win32.Native.LibraryImport; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Threading; -using System.Threading.Tasks; -using static Hi3Helper.Locale; -using static Hi3Helper.Logger; -// ReSharper disable StringLiteralTypo -// ReSharper disable CommentTypo -// ReSharper disable SwitchStatementMissingSomeEnumCasesNoDefault - -namespace CollapseLauncher -{ - internal static partial class StarRailRepairExtension - { - internal static string ReplaceStreamingToPersistentPath(string inputPath, string execName, FileType type) - { - string parentStreamingRelativePath = string.Format(type switch - { - FileType.Block => StarRailRepair.AssetGameBlocksStreamingPath, - FileType.Audio => StarRailRepair.AssetGameAudioStreamingPath, - FileType.Video => StarRailRepair.AssetGameVideoStreamingPath, - _ => string.Empty - }, execName); - string parentPersistentRelativePath = string.Format(type switch - { - FileType.Block => StarRailRepair.AssetGameBlocksPersistentPath, - FileType.Audio => StarRailRepair.AssetGameAudioPersistentPath, - FileType.Video => StarRailRepair.AssetGameVideoPersistentPath, - _ => string.Empty - }, execName); - - int indexOfStart = inputPath.IndexOf(parentStreamingRelativePath, StringComparison.Ordinal); - int indexOfEnd = indexOfStart + parentStreamingRelativePath.Length; - - if (indexOfStart == -1) return inputPath; - - ReadOnlySpan startOfPath = inputPath.AsSpan(0, indexOfStart).TrimEnd('\\'); - ReadOnlySpan endOfPath = inputPath.AsSpan(indexOfEnd, inputPath.Length - indexOfEnd).TrimStart('\\'); - - string returnPath = Path.Join(startOfPath, parentPersistentRelativePath, endOfPath); - return returnPath; - } - - internal static string GetFileRelativePath(string inputPath, string parentPath) => inputPath.AsSpan(parentPath.Length).ToString(); - } - - internal partial class StarRailRepair - { - private async Task Check(List assetIndex, CancellationToken token) - { - // Try to find "badlist.byte" files in the game folder and delete it - foreach (string badListFile in Directory.EnumerateFiles(GamePath, "*badlist*.byte*", SearchOption.AllDirectories)) - { - LogWriteLine($"Removing bad list mark at: {badListFile}", LogType.Warning, true); - TryDeleteReadOnlyFile(badListFile); - } - - // Try to find "verify.fail" files in the game folder and delete it - foreach (string verifyFail in Directory.EnumerateFiles(GamePath, "*verify*.fail*", SearchOption.AllDirectories)) - { - LogWriteLine($"Removing verify.fail mark at: {verifyFail}", LogType.Warning, true); - TryDeleteReadOnlyFile(verifyFail); - } - - List brokenAssetIndex = []; - - // Set Indetermined status as false - Status.IsProgressAllIndetermined = false; - Status.IsProgressPerFileIndetermined = false; - - // Show the asset entry panel - Status.IsAssetEntryPanelShow = true; - - // Await the task for parallel processing - try - { - // Iterate assetIndex and check it using different method for each type and run it in parallel - await Parallel.ForEachAsync(assetIndex, new ParallelOptions { MaxDegreeOfParallelism = ThreadCount, CancellationToken = token }, async (asset, threadToken) => - { - // Assign a task depends on the asset type - switch (asset.FT) - { - case FileType.Generic: - await CheckGenericAssetType(asset, brokenAssetIndex, threadToken); - break; - case FileType.Block: - case FileType.Audio: - case FileType.Video: - await CheckAssetType(asset, brokenAssetIndex, threadToken); - break; - } - }); - } - catch (AggregateException ex) - { - var innerExceptionsFirst = ex.Flatten().InnerExceptions.First(); - await SentryHelper.ExceptionHandlerAsync(innerExceptionsFirst, SentryHelper.ExceptionType.UnhandledOther); - throw innerExceptionsFirst; - } - - // Re-add the asset index with a broken asset index - assetIndex.Clear(); - assetIndex.AddRange(brokenAssetIndex); - } - - #region AssetTypeCheck - private async Task CheckGenericAssetType(FilePropertiesRemote asset, List targetAssetIndex, CancellationToken token) - { - // Update activity status - Status.ActivityStatus = string.Format(Lang._GameRepairPage.Status6, - StarRailRepairExtension.GetFileRelativePath(asset.N, GamePath)); - - // Increment current total count - ProgressAllCountCurrent++; - - // Reset per file size counter - ProgressPerFileSizeTotal = asset.S; - ProgressPerFileSizeCurrent = 0; - - // Get the file info - FileInfo fileInfo = new FileInfo(asset.N); - - // Check if the file exist or has unmatched size - if (!fileInfo.Exists) - { - AddIndex(); - LogWriteLine($"File [T: {asset.FT}]: {asset.N} is not found", LogType.Warning, true); - return; - } - - if (fileInfo.Length != asset.S) - { - if (fileInfo.Name.Contains("pkg_version")) return; - AddIndex(); - LogWriteLine($"File [T: {asset.FT}]: {asset.N} has unmatched size " + - $"(Local: {fileInfo.Length} <=> Remote: {asset.S}", - LogType.Warning, true); - return; - } - - // Skip CRC check if fast method is used - if (UseFastMethod) - { - return; - } - - // Open and read fileInfo as FileStream - await using FileStream fileStream = await NaivelyOpenFileStreamAsync(fileInfo, FileMode.Open, FileAccess.Read, FileShare.Read); - // If pass the check above, then do CRC calculation - // Additional: the total file size progress is disabled and will be incremented after this - byte[] localCrc = await GetCryptoHashAsync(fileStream, null, true, true, token); - - // If local and asset CRC doesn't match, then add the asset - if (IsArrayMatch(localCrc, asset.CRCArray)) - { - return; - } - - ProgressAllSizeFound += asset.S; - ProgressAllCountFound++; - - Dispatch(() => AssetEntry.Add( - new AssetProperty( - Path.GetFileName(asset.N), - ConvertRepairAssetTypeEnum(asset.FT), - Path.GetDirectoryName(asset.N), - asset.S, - localCrc, - asset.CRCArray - ) - )); - - // Mark the main block as "need to be repaired" - asset.IsBlockNeedRepair = true; - targetAssetIndex.Add(asset); - - LogWriteLine($"File [T: {asset.FT}]: {asset.N} is broken! Index CRC: {asset.CRC} <--> File CRC: {HexTool.BytesToHexUnsafe(localCrc)}", LogType.Warning, true); - return; - - void AddIndex() - { - // Update the total progress and found counter - ProgressAllSizeFound += asset.S; - ProgressAllCountFound++; - - // Set the per size progress - ProgressPerFileSizeCurrent = asset.S; - - // Increment the total current progress - ProgressAllSizeCurrent += asset.S; - - Dispatch(() => AssetEntry.Add( - new AssetProperty( - Path.GetFileName(asset.N), - ConvertRepairAssetTypeEnum(asset.FT), - Path.GetDirectoryName(asset.N), - asset.S, - null, - null - ) - )); - targetAssetIndex.Add(asset); - } - } - - private async Task CheckAssetType(FilePropertiesRemote asset, List targetAssetIndex, CancellationToken token) - { - // Update activity status - Status.ActivityStatus = string.Format(Lang._GameRepairPage.Status6, - StarRailRepairExtension.GetFileRelativePath(asset.N, GamePath)); - - // Increment current total count - ProgressAllCountCurrent++; - - // Reset per file size counter - ProgressPerFileSizeTotal = asset.S; - ProgressPerFileSizeCurrent = 0; - - // Get persistent and streaming paths - FileInfo fileInfoPersistent = new FileInfo(StarRailRepairExtension.ReplaceStreamingToPersistentPath(asset.N, ExecName, asset.FT)); - FileInfo fileInfoStreaming = new FileInfo(asset.N); - - bool usePersistent = asset.IsPatchApplicable || !fileInfoStreaming.Exists; - bool isHasMark = asset.IsHasHashMark || usePersistent; - bool isPersistentExist = fileInfoPersistent.Exists && fileInfoPersistent.Length == asset.S; - bool isStreamingExist = fileInfoStreaming.Exists && fileInfoStreaming.Length == asset.S; - - // Update the local path to full persistent or streaming path and add asset for missing/unmatched size file - asset.N = usePersistent ? fileInfoPersistent.FullName : fileInfoStreaming.FullName; - - // Check if the file exist on both persistent and streaming path for non-patch file, then mark the - // persistent path as redundant (unused) - bool isNonPatchHasRedundantPersistent = !asset.IsPatchApplicable && isPersistentExist && isStreamingExist && fileInfoStreaming.Length == asset.S; - - if (isNonPatchHasRedundantPersistent) - { - // Add the count and asset. Mark the type as "RepairAssetType.Unused" - ProgressAllCountFound++; - - Dispatch(() => AssetEntry.Add( - new AssetProperty( - Path.GetFileName(fileInfoPersistent.FullName), - RepairAssetType.Unused, - Path.GetDirectoryName(fileInfoPersistent.FullName), - asset.S, - null, - null - ) - )); - - // Create a new instance as unused one - FilePropertiesRemote unusedAsset = new FilePropertiesRemote - { - N = fileInfoPersistent.FullName, - FT = FileType.Unused, - RN = asset.RN, - CRC = asset.CRC, - S = asset.S - }; - targetAssetIndex.Add(unusedAsset); - - LogWriteLine($"File [T: {asset.FT}]: {unusedAsset.N} is redundant (exist both on persistent and streaming)", LogType.Warning, true); - } - - // If the file has Hash Mark or is persistent, then create the hash mark file - if (isHasMark) CreateHashMarkFile(asset.N, asset.CRC); - - // Check if both location has the file exist or has the size right - if ((usePersistent && !isPersistentExist && !isStreamingExist) - || (usePersistent && !isPersistentExist)) - { - // Update the total progress and found counter - ProgressAllSizeFound += asset.S; - ProgressAllCountFound++; - - // Set the per size progress - ProgressPerFileSizeCurrent = asset.S; - - // Increment the total current progress - ProgressAllSizeCurrent += asset.S; - - Dispatch(() => AssetEntry.Add( - new AssetProperty( - Path.GetFileName(asset.N), - ConvertRepairAssetTypeEnum(asset.FT), - Path.GetDirectoryName(asset.N), - asset.S, - null, - null - ) - )); - targetAssetIndex.Add(asset); - LogWriteLine($"File [T: {asset.FT}]: {asset.N} is not found or has unmatched size", LogType.Warning, true); - - return; - } - - // Skip CRC check if fast method is used - if (UseFastMethod) - { - return; - } - - // Open and read fileInfo as FileStream - string fileNameToOpen = usePersistent ? fileInfoPersistent.FullName : fileInfoStreaming.FullName; - try - { - await CheckFile(fileNameToOpen, asset, targetAssetIndex, token); - } - catch (FileNotFoundException ex) - { - await SentryHelper.ExceptionHandlerAsync(ex); - LogWriteLine($"File {fileNameToOpen} is not found while UsePersistent is {usePersistent}. " + - $"Creating hard link and retrying...", LogType.Warning, true); - - var targetFile = File.Exists(fileInfoPersistent.FullName) ? fileInfoPersistent.FullName : - File.Exists(fileInfoStreaming.FullName) ? fileInfoStreaming.FullName : - throw new FileNotFoundException(fileNameToOpen); - - PInvoke.CreateHardLink(fileNameToOpen, targetFile, IntPtr.Zero); - await CheckFile(fileNameToOpen, asset, targetAssetIndex, token); - } - } - - private async Task CheckFile(string fileNameToOpen, FilePropertiesRemote asset, List targetAssetIndex, CancellationToken token) - { - await using FileStream fileStream = new FileStream(fileNameToOpen, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - BufferBigLength); - // If pass the check above, then do CRC calculation - // Additional: the total file size progress is disabled and will be incremented after this - byte[] localCrc = await GetCryptoHashAsync(fileStream, null, true, true, token); - - // If local and asset CRC doesn't match, then add the asset - if (!IsArrayMatch(localCrc, asset.CRCArray)) - { - ProgressAllSizeFound += asset.S; - ProgressAllCountFound++; - - Dispatch(() => AssetEntry.Add( - new AssetProperty( - Path.GetFileName(asset.N), - ConvertRepairAssetTypeEnum(asset.FT), - Path.GetDirectoryName(asset.N), - asset.S, - localCrc, - asset.CRCArray - ) - )); - - // Mark the main block as "need to be repaired" - asset.IsBlockNeedRepair = true; - targetAssetIndex.Add(asset); - - LogWriteLine($"File [T: {asset.FT}]: {asset.N} is broken! Index CRC: {asset.CRC} <--> File CRC: {HexTool.BytesToHexUnsafe(localCrc)}", LogType.Warning, true); - } - } - - private static void CreateHashMarkFile(string filePath, string hash) - { - RemoveHashMarkFile(filePath, out var basePath, out var baseName); - - // Create base path if not exist - if (!string.IsNullOrEmpty(basePath) && !Directory.Exists(basePath)) - Directory.CreateDirectory(basePath); - - // Re-create the hash file - string toName = Path.Combine(basePath ?? "", $"{baseName}_{hash}.hash"); - if (File.Exists(toName)) return; - File.Create(toName).Dispose(); - } - - private static void RemoveHashMarkFile(string filePath, out string basePath, out string baseName) - { - // Get the base path and name - basePath = Path.GetDirectoryName(filePath); - baseName = Path.GetFileNameWithoutExtension(filePath); - - // Get directory base info. If it doesn't exist, return - if (string.IsNullOrEmpty(basePath)) - { - return; - } - - DirectoryInfo basePathDirInfo = new DirectoryInfo(basePath); - if (!basePathDirInfo.Exists) - { - return; - } - - // Enumerate any possible existing hash path and delete it - foreach (FileInfo existingPath in basePathDirInfo.EnumerateFiles($"{baseName}_*.hash") - .EnumerateNoReadOnly()) - { - existingPath.Delete(); - } - } - #endregion - } -} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs deleted file mode 100644 index 0546e066f..000000000 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs +++ /dev/null @@ -1,437 +0,0 @@ -using CollapseLauncher.Helper; -using CollapseLauncher.Helper.Metadata; -using Hi3Helper; -using Hi3Helper.Data; -using Hi3Helper.EncTool.Parser.AssetIndex; -using Hi3Helper.EncTool.Parser.AssetMetadata.SRMetadataAsset; -using Hi3Helper.Http; -using Hi3Helper.Shared.ClassStruct; -using Hi3Helper.Shared.Region; -using System; -using System.Collections.Generic; -using System.Data; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Runtime.InteropServices; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using static Hi3Helper.Data.ConverterTool; -using static Hi3Helper.Locale; -using static Hi3Helper.Logger; -// ReSharper disable CommentTypo -// ReSharper disable StringLiteralTypo -// ReSharper disable SwitchStatementHandlesSomeKnownEnumValuesWithDefault - -namespace CollapseLauncher -{ - internal static partial class StarRailRepairExtension - { - private static readonly Dictionary Hashtable = new(); - - internal static void ClearHashtable() => Hashtable.Clear(); - - internal static void AddSanitize(this List assetIndex, FilePropertiesRemote assetProperty) - { - string key = assetProperty.N + assetProperty.IsPatchApplicable; - - // Check if the asset has the key - // If yes (exist), then get the index of the asset from hashtable - if (Hashtable.TryGetValue(key, out int index)) - { - // Get the property of the asset based on index from hashtable - FilePropertiesRemote oldAssetProperty = assetIndex[index]; - // If the hash is not equal, then replace the existing property from assetIndex - if (oldAssetProperty.CRCArray - .AsSpan() - .SequenceEqual(assetProperty.CRCArray)) - { - return; - } - #if DEBUG - LogWriteLine($"[StarRailRepairExtension::AddSanitize()] Replacing duplicate of: {assetProperty.N} from: {oldAssetProperty.CRC}|{oldAssetProperty.S} to {assetProperty.CRC}|{assetProperty.S}", LogType.Debug, true); - #endif - assetIndex[index] = assetProperty; - return; - } - - Hashtable.Add(key, assetIndex.Count); - assetIndex.Add(assetProperty); - } - } - - internal partial class StarRailRepair - { - private async Task Fetch(List assetIndex, CancellationToken token) - { - // Set total activity string as "Loading Indexes..." - Status.ActivityStatus = Lang._GameRepairPage.Status2; - Status.IsProgressAllIndetermined = true; - - UpdateStatus(); - StarRailRepairExtension.ClearHashtable(); - - // Initialize new proxy-aware HttpClient - using HttpClient client = new HttpClientBuilder() - .UseLauncherConfig(DownloadThreadWithReservedCount) - .SetUserAgent(UserAgent) - .SetAllowedDecompression(DecompressionMethods.None) - .Create(); - - // Initialize the new DownloadClient - DownloadClient downloadClient = DownloadClient.CreateInstance(client); - - try - { - // Get the primary manifest - await GetPrimaryManifest(assetIndex, token); - - // If the this._isOnlyRecoverMain && base._isVersionOverride is true, copy the asset index into the _originAssetIndex - if (IsOnlyRecoverMain && IsVersionOverride) - { - OriginAssetIndex = []; - foreach (FilePropertiesRemote asset in assetIndex) - { - FilePropertiesRemote newAsset = asset.Copy(); - ReadOnlyMemory assetRelativePath = newAsset.N.AsMemory(GamePath.Length).TrimStart('\\'); - newAsset.N = assetRelativePath.ToString(); - OriginAssetIndex.Add(newAsset); - } - } - - // Subscribe the fetching progress and subscribe StarRailMetadataTool progress to adapter - // _innerGameVersionManager.StarRailMetadataTool.HttpEvent += _httpClient_FetchAssetProgress; - - // Initialize the metadata tool (including dispatcher and gateway). - // Perform this if only base._isVersionOverride is false to indicate that the repair performed is - // not for delta patch integrity check. - if (!IsVersionOverride && !IsOnlyRecoverMain && await InnerGameVersionManager.StarRailMetadataTool.Initialize(token, downloadClient, _httpClient_FetchAssetProgress, GetExistingGameRegionID(), Path.Combine(GamePath, $"{Path.GetFileNameWithoutExtension(InnerGameVersionManager.GamePreset.GameExecutableName)}_Data\\Persistent"))) - { - await Task.WhenAll( - // Read Block metadata - InnerGameVersionManager.StarRailMetadataTool.ReadAsbMetadataInformation(downloadClient, _httpClient_FetchAssetProgress, token), - InnerGameVersionManager.StarRailMetadataTool.ReadBlockMetadataInformation(downloadClient, _httpClient_FetchAssetProgress, token), - // Read Audio metadata - InnerGameVersionManager.StarRailMetadataTool.ReadAudioMetadataInformation(downloadClient, _httpClient_FetchAssetProgress, token), - // Read Video metadata - InnerGameVersionManager.StarRailMetadataTool.ReadVideoMetadataInformation(downloadClient, _httpClient_FetchAssetProgress, token) - ).ConfigureAwait(false); - - // Convert Block, Audio and Video metadata to FilePropertiesRemote - ConvertSrMetadataToAssetIndex(InnerGameVersionManager.StarRailMetadataTool.MetadataBlock, assetIndex); - ConvertSrMetadataToAssetIndex(InnerGameVersionManager.StarRailMetadataTool.MetadataAudio, assetIndex, true); - ConvertSrMetadataToAssetIndex(InnerGameVersionManager.StarRailMetadataTool.MetadataVideo, assetIndex); - } - - // Force-Fetch the Bilibili SDK (if exist :pepehands:) - await FetchBilibiliSdk(token); - - // Remove plugin from assetIndex - // Skip the removal for Delta-Patch - if (!IsOnlyRecoverMain) - { - EliminatePluginAssetIndex(assetIndex, x => x.N, x => x.RN); - } - } - finally - { - // Clear the hashtable - StarRailRepairExtension.ClearHashtable(); - // Unsubscribe the fetching progress and dispose it and unsubscribe cacheUtil progress to adapter - // _innerGameVersionManager.StarRailMetadataTool.HttpEvent -= _httpClient_FetchAssetProgress; - } - } - - #region PrimaryManifest - private async Task GetPrimaryManifest(List assetIndex, CancellationToken token) - { - // Initialize pkgVersion list - List pkgVersion = []; - - // Initialize repo metadata - try - { - // Get the metadata - Dictionary repoMetadata = await FetchMetadata(token); - - // Check for manifest. If it doesn't exist, then throw and warn the user - if (!repoMetadata.TryGetValue(GameVersion.VersionString, out var value)) - { - throw new VersionNotFoundException($"Manifest for {GameVersionManager.GamePreset.ZoneName} (version: {GameVersion.VersionString}) doesn't exist! Please contact @neon-nyan or open an issue for this!"); - } - - // Assign the URL based on the version - GameRepoURL = value; - } - // If the base._isVersionOverride is true, then throw. This sanity check is required if the delta patch is being performed. - catch when (IsVersionOverride) { throw; } - - // Fetch the asset index from CDN - // Set asset index URL - string urlIndex = string.Format(LauncherConfig.AppGameRepairIndexURLPrefix, GameVersionManager.GamePreset.ProfileName, GameVersion.VersionString) + ".binv2"; - - // Start downloading asset index using FallbackCDNUtil and return its stream - await using Stream stream = await FallbackCDNUtil.TryGetCDNFallbackStream(urlIndex, token: token); - // Deserialize asset index and set it to list - AssetIndexV2 parserTool = new AssetIndexV2(); - pkgVersion = parserTool.Deserialize(stream, out DateTime timestamp); - LogWriteLine($"Asset index timestamp: {timestamp}", LogType.Default, true); - - // Convert the pkg version list to asset index - ConvertPkgVersionToAssetIndex(pkgVersion, assetIndex); - - // Clear the pkg version list - pkgVersion.Clear(); - } - - private async Task> FetchMetadata(CancellationToken token) - { - // Set metadata URL - string urlMetadata = string.Format(LauncherConfig.AppGameRepoIndexURLPrefix, GameVersionManager.GamePreset.ProfileName); - - // Start downloading metadata using FallbackCDNUtil - await using Stream stream = await FallbackCDNUtil.TryGetCDNFallbackStream(urlMetadata, token: token); - return await stream.DeserializeAsync(CoreLibraryJsonContext.Default.DictionaryStringString, token: token); - } - - private void ConvertPkgVersionToAssetIndex(List pkgVersion, List assetIndex) - { - for (var index = pkgVersion.Count - 1; index >= 0; index--) - { - var entry = pkgVersion[index]; - // Add the pkgVersion entry to asset index - FilePropertiesRemote normalizedProperty = GetNormalizedFilePropertyTypeBased( - GameRepoURL, - entry.remoteName, - entry.fileSize, - entry.md5, - FileType.Generic, - true); - assetIndex.AddSanitize(normalizedProperty); - } - } - #endregion - - #region Utilities - private FilePropertiesRemote GetNormalizedFilePropertyTypeBased(string remoteParentURL, - string remoteRelativePath, - long fileSize, - string hash, - FileType type = FileType.Generic, - bool isPatchApplicable = false, - bool isHasHashMark = false) - { - string remoteAbsolutePath = type switch - { - FileType.Generic => CombineURLFromString(remoteParentURL, remoteRelativePath), - _ => remoteParentURL - }, - typeAssetRelativeParentPath = string.Format(type switch - { - FileType.Block => AssetGameBlocksStreamingPath, - FileType.Audio => AssetGameAudioStreamingPath, - FileType.Video => AssetGameVideoStreamingPath, - _ => string.Empty - }, ExecName); - - var localAbsolutePath = Path.Combine(GamePath, typeAssetRelativeParentPath, NormalizePath(remoteRelativePath)); - - return new FilePropertiesRemote - { - FT = type, - CRC = hash, - S = fileSize, - N = localAbsolutePath, - RN = remoteAbsolutePath, - IsPatchApplicable = isPatchApplicable, - IsHasHashMark = isHasHashMark - }; - } - - private unsafe string GetExistingGameRegionID() - { - // Delegate the default return value - string GetDefaultValue() => InnerGameVersionManager.GamePreset.GameDispatchDefaultName ?? throw new KeyNotFoundException("Default dispatcher name in metadata is not exist!"); - -#nullable enable - // Try to get the value as nullable object - object? value = GameSettings?.RegistryRoot?.GetValue("App_LastServerName_h2577443795", null); - // Check if the value is null, then return the default name - // Return the dispatch default name. If none, then throw - if (value == null) return GetDefaultValue(); -#nullable disable - - // Cast the value as byte array - byte[] valueBytes = (byte[])value; - int count = valueBytes.Length; - - // If the registry is empty, then return the default value; - if (valueBytes.Length == 0) - return GetDefaultValue(); - - // Get the pointer of the byte array - fixed (byte* valuePtr = &valueBytes[0]) - { - // Try check the byte value. If it's null, then continue the loop while - // also decreasing the count as its index - while (*(valuePtr + (count - 1)) == 0) { --count; } - - // Get the name from the span and trim the \0 character at the end - string name = Encoding.UTF8.GetString(valuePtr, count); - return name; - } - } - - private void ConvertSrMetadataToAssetIndex(SRMetadataBase metadata, List assetIndex, bool writeAudioLangReordered = false) - { - // Get the voice Lang ID - int voLangID = InnerGameVersionManager.GamePreset.GetVoiceLanguageID(); - // Get the voice Lang name by ID - string voLangName = PresetConfig.GetStarRailVoiceLanguageFullNameByID(voLangID); - - // If prompt to write Redord file - if (writeAudioLangReordered) - { - // Get game executable name, directory and file path - string execName = Path.GetFileNameWithoutExtension(InnerGameVersionManager.GamePreset.GameExecutableName); - string audioReorderedDir = Path.Combine(GamePath, @$"{execName}_Data\Persistent\Audio\AudioPackage\Windows"); - string audioReorderedPath = EnsureCreationOfDirectory(Path.Combine(audioReorderedDir, "AudioLangRedord.txt")); - - // Then write the Redord file content - File.WriteAllText(audioReorderedPath, "{\"AudioLang\":\"" + voLangName + "\"}"); - } - - // Get the audio lang list - string[] audioLangList = GetCurrentAudioLangList(voLangName); - - // Enumerate the Asset List - int lastAssetIndexCount = assetIndex.Count; - foreach (SRAsset asset in metadata.EnumerateAssets()) - { - // Get the hash by bytes - string hash = HexTool.BytesToHexUnsafe(asset.Hash); - - // Filter only current audio language file and other assets - if (!FilterCurrentAudioLangFile(asset, audioLangList, out bool isHasHashMark)) - { - continue; - } - - // Convert and add the asset as FilePropertiesRemote to assetIndex - FilePropertiesRemote assetProperty = GetNormalizedFilePropertyTypeBased( - asset.RemoteURL, - asset.LocalName, - asset.Size, - hash, - ConvertFileTypeEnum(asset.AssetType), - asset.IsPatch, - isHasHashMark - ); - assetIndex.AddSanitize(assetProperty); - } - - int addedCount = assetIndex.Count - lastAssetIndexCount; - long addedSize = 0; - ReadOnlySpan assetIndexSpan = CollectionsMarshal.AsSpan(assetIndex)[lastAssetIndexCount..]; - for (int i = assetIndexSpan.Length - 1; i >= 0; i--) addedSize += assetIndexSpan[i].S; - - LogWriteLine($"Added additional {addedCount} assets with {SummarizeSizeSimple(addedSize)}/{addedSize} bytes in size", LogType.Default, true); - } - - private string[] GetCurrentAudioLangList(string fallbackCurrentLangName) - { - // Initialize the variable. - string audioLangListPath = GameAudioLangListPath; - string audioLangListPathStatic = GameAudioLangListPathStatic; - string[] returnValue; - - // Check if the audioLangListPath is null or the file is not exist, - // then create a new one from the fallback value - if (audioLangListPath == null || !File.Exists(audioLangListPathStatic)) - { - // Try check if the folder exist. If not, create one. - string audioLangPathDir = Path.GetDirectoryName(audioLangListPathStatic); - if (Directory.Exists(audioLangPathDir)) - Directory.CreateDirectory(audioLangPathDir); - - // Assign the default value and write to the file, then return. - returnValue = [fallbackCurrentLangName]; - if (audioLangListPathStatic != null) - { - File.WriteAllLines(audioLangListPathStatic, returnValue); - } - - return returnValue; - } - - // Read all the lines. If empty, then assign the default value and rewrite it - returnValue = File.ReadAllLines(audioLangListPathStatic); - if (returnValue.Length != 0) - { - return returnValue; - } - - returnValue = [fallbackCurrentLangName]; - File.WriteAllLines(audioLangListPathStatic, returnValue); - - // Return the value - return returnValue; - } - - private static bool FilterCurrentAudioLangFile(SRAsset asset, string[] langNames, out bool isHasHashMark) - { - // Set output value as false - isHasHashMark = false; - switch (asset.AssetType) - { - // In case if the type is SRAssetType.Audio, then do filtering - case SRAssetType.Audio: - // Set isHasHashMark to true - isHasHashMark = true; - // Split the name definition from LocalName - string[] nameDef = asset.LocalName.Split('/'); - // If the name definition array length > 1, then start do filtering - if (nameDef.Length > 1) - { - // Compare if the first name definition is equal to target langName. - // Also return if the file is an audio language file if it is SFX file or not. - return langNames.Contains(nameDef[0], StringComparer.OrdinalIgnoreCase) || nameDef[0] == "SFX"; - } - // If it's not in criteria of name definition, then return true as "normal asset" - return true; - default: - // return true as "normal asset" - return true; - } - } - - private void CountAssetIndex(List assetIndex) - { - // Sum the assetIndex size and assign to _progressAllSize - ProgressAllSizeTotal = assetIndex.Sum(x => x.S); - - // Assign the assetIndex count to _progressAllCount - ProgressAllCountTotal = assetIndex.Count; - } - - private static FileType ConvertFileTypeEnum(SRAssetType assetType) => assetType switch - { - SRAssetType.Asb => FileType.Block, - SRAssetType.Block => FileType.Block, - SRAssetType.Audio => FileType.Audio, - SRAssetType.Video => FileType.Video, - _ => FileType.Generic - }; - - private static RepairAssetType ConvertRepairAssetTypeEnum(FileType assetType) => assetType switch - { - FileType.Block => RepairAssetType.Block, - FileType.Audio => RepairAssetType.Audio, - FileType.Video => RepairAssetType.Video, - _ => RepairAssetType.Generic - }; - #endregion - } -} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Repair.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Repair.cs deleted file mode 100644 index 371a1b77d..000000000 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Repair.cs +++ /dev/null @@ -1,143 +0,0 @@ -using CollapseLauncher.Helper; -using CollapseLauncher.Helper.StreamUtility; -using Hi3Helper; -using Hi3Helper.Data; -using Hi3Helper.Http; -using Hi3Helper.Shared.ClassStruct; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using static Hi3Helper.Locale; -using static Hi3Helper.Logger; - -namespace CollapseLauncher -{ - internal partial class StarRailRepair - { - private async Task Repair(List repairAssetIndex, CancellationToken token) - { - // Set total activity string as "Waiting for repair process to start..." - Status.ActivityStatus = Lang._GameRepairPage.Status11; - Status.IsProgressAllIndetermined = true; - Status.IsProgressPerFileIndetermined = true; - - // Update status - UpdateStatus(); - - // Initialize new proxy-aware HttpClient - using HttpClient client = new HttpClientBuilder() - .UseLauncherConfig(DownloadThreadWithReservedCount) - .SetUserAgent(UserAgent) - .SetAllowedDecompression(DecompressionMethods.None) - .Create(); - - // Use the new DownloadClient instance - DownloadClient downloadClient = DownloadClient.CreateInstance(client); - - // Iterate repair asset and check it using different method for each type - ObservableCollection assetProperty = [.. AssetEntry]; - ConcurrentDictionary<(FilePropertiesRemote, IAssetProperty), byte> runningTask = new(); - if (IsBurstDownloadEnabled) - { - await Parallel.ForEachAsync( - PairEnumeratePropertyAndAssetIndexPackage( -#if ENABLEHTTPREPAIR - EnforceHttpSchemeToAssetIndex(repairAssetIndex) -#else - repairAssetIndex -#endif - , assetProperty), - new ParallelOptions { CancellationToken = token, MaxDegreeOfParallelism = DownloadThreadCount }, - async (asset, innerToken) => - { - if (!runningTask.TryAdd(asset, 0)) - { - LogWriteLine($"Found duplicated task for {asset.AssetProperty.Name}! Skipping...", LogType.Warning, true); - return; - } - // Assign a task depends on the asset type - Task assetTask = RepairAssetTypeGeneric(asset, downloadClient, _httpClient_RepairAssetProgress, innerToken); - - // Await the task - await assetTask; - runningTask.Remove(asset, out _); - }); - } - else - { - foreach ((FilePropertiesRemote AssetIndex, IAssetProperty AssetProperty) asset in - PairEnumeratePropertyAndAssetIndexPackage( -#if ENABLEHTTPREPAIR - EnforceHttpSchemeToAssetIndex(repairAssetIndex) -#else - repairAssetIndex -#endif - , assetProperty)) - { - if (!runningTask.TryAdd(asset, 0)) - { - LogWriteLine($"Found duplicated task for {asset.AssetProperty.Name}! Skipping...", LogType.Warning, true); - break; - } - // Assign a task depends on the asset type - Task assetTask = RepairAssetTypeGeneric(asset, downloadClient, _httpClient_RepairAssetProgress, token); - - // Await the task - await assetTask; - runningTask.Remove(asset, out _); - } - } - - return true; - } - - #region GenericRepair - private async Task RepairAssetTypeGeneric((FilePropertiesRemote AssetIndex, IAssetProperty AssetProperty) asset, DownloadClient downloadClient, DownloadProgressDelegate downloadProgress, CancellationToken token) - { - // Increment total count current - ProgressAllCountCurrent++; - // Set repair activity status - string timeLeftString = string.Format(Lang!._Misc!.TimeRemainHMSFormat!, Progress.ProgressAllTimeLeft); - UpdateRepairStatus( - string.Format(Lang._GameRepairPage.Status8, Path.GetFileName(asset.AssetIndex.N)), - string.Format(Lang._GameRepairPage.PerProgressSubtitle2, ConverterTool.SummarizeSizeSimple(ProgressAllSizeCurrent), ConverterTool.SummarizeSizeSimple(ProgressAllSizeTotal)) + $" | {timeLeftString}", - true); - - FileInfo fileInfo = new FileInfo(asset.AssetIndex.N!).StripAlternateDataStream().EnsureNoReadOnly(); - - // If asset type is unused, then delete it - if (asset.AssetIndex.FT == FileType.Unused) - { - if (fileInfo.Exists) - { - fileInfo.Delete(); - LogWriteLine($"File [T: {asset.AssetIndex.FT}] {(asset.AssetIndex.FT == FileType.Block ? asset.AssetIndex.CRC : asset.AssetIndex.N)} deleted!", LogType.Default, true); - } - RemoveHashMarkFile(asset.AssetIndex.N, out _, out _); - } - else - { - try - { - // Start asset download task - await RunDownloadTask(asset.AssetIndex.S, fileInfo, asset.AssetIndex.RN, downloadClient, downloadProgress, token); - LogWriteLine($"File [T: {asset.AssetIndex.FT}] {(asset.AssetIndex.FT == FileType.Block ? asset.AssetIndex.CRC : asset.AssetIndex.N)} has been downloaded!", LogType.Default, true); - } - catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.NotFound) - { - LogWriteLine($"URL for asset {asset.AssetIndex.N} returned 404 Not Found. This may indicate that the asset is no longer available on the server.\r\n" + - $"\t URL: {asset.AssetIndex.GetRemoteURL()}", LogType.Warning, true); - } - } - - // Pop repair asset display entry - PopRepairAssetEntry(asset.AssetProperty); - } - #endregion - } -} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs deleted file mode 100644 index 092ae084a..000000000 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs +++ /dev/null @@ -1,160 +0,0 @@ -using CollapseLauncher.GameVersioning; -using CollapseLauncher.Interfaces; -using Hi3Helper.Data; -using Hi3Helper.Shared.ClassStruct; -using Microsoft.UI.Xaml; -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; -using static Hi3Helper.Locale; -// ReSharper disable StringLiteralTypo - -namespace CollapseLauncher -{ - internal partial class StarRailRepair : ProgressBase, IRepair, IRepairAssetIndex - { - #region Properties - - public override string GamePath - { - get => GameVersionManager.GameDirPath; - set => GameVersionManager.GameDirPath = value; - } - - private GameTypeStarRailVersion InnerGameVersionManager { get; } - private bool IsOnlyRecoverMain { get; } - private List OriginAssetIndex { get; set; } - private string ExecName { get; } - private string GameDataPersistentPath { get => Path.Combine(GamePath, $"{ExecName}_Data", "Persistent"); } - private string GameAudioLangListPath - { - get - { - // If the persistent folder is not exist, then return null - if (!Directory.Exists(GameDataPersistentPath)) return null; - - // Set the file list path - string audioRecordPath = Path.Combine(GameDataPersistentPath, "AudioLaucherRecord.txt"); - - // Check if the file exist. If not, return null - return !File.Exists(audioRecordPath) ? null : - // If it exists, then return the path - audioRecordPath; - } - } - private string GameAudioLangListPathStatic { get => Path.Combine(GameDataPersistentPath, "AudioLaucherRecord.txt"); } - - internal const string AssetGameAudioStreamingPath = @"{0}_Data\StreamingAssets\Audio\AudioPackage\Windows"; - internal const string AssetGameAudioPersistentPath = @"{0}_Data\Persistent\Audio\AudioPackage\Windows"; - - internal const string AssetGameBlocksStreamingPath = @"{0}_Data\StreamingAssets\Asb\Windows"; - internal const string AssetGameBlocksPersistentPath = @"{0}_Data\Persistent\Asb\Windows"; - - internal const string AssetGameVideoStreamingPath = @"{0}_Data\StreamingAssets\Video\Windows"; - internal const string AssetGameVideoPersistentPath = @"{0}_Data\Persistent\Video\Windows"; - protected override string UserAgent => "UnityPlayer/2019.4.34f1 (UnityWebRequest/1.0, libcurl/7.75.0-DEV)"; - #endregion - - public StarRailRepair( - UIElement parentUI, - IGameVersion gameVersionManager, - IGameSettings gameSettings, - bool onlyRecoverMainAsset = false, - string versionOverride = null) - : base(parentUI, - gameVersionManager, - gameSettings, - "", - versionOverride) - { - // Get flag to only recover main assets - IsOnlyRecoverMain = onlyRecoverMainAsset; - InnerGameVersionManager = gameVersionManager as GameTypeStarRailVersion; - ExecName = Path.GetFileNameWithoutExtension(InnerGameVersionManager!.GamePreset.GameExecutableName); - } - - ~StarRailRepair() => Dispose(); - - public List GetAssetIndex() => OriginAssetIndex; - - public async Task StartCheckRoutine(bool useFastCheck) - { - UseFastMethod = useFastCheck; - return await TryRunExamineThrow(CheckRoutine()); - } - - public async Task StartRepairRoutine(bool showInteractivePrompt = false, Action actionIfInteractiveCancel = null) - { - if (AssetIndex.Count == 0) throw new InvalidOperationException("There's no broken file being reported! You can't do the repair process!"); - - if (showInteractivePrompt) - { - await SpawnRepairDialog(AssetIndex, actionIfInteractiveCancel); - } - - _ = await TryRunExamineThrow(RepairRoutine()); - } - - private async Task CheckRoutine() - { - // Always clear the asset index list - AssetIndex.Clear(); - - // Reset status and progress - ResetStatusAndProgress(); - - // Step 1: Fetch asset indexes - await Fetch(AssetIndex, Token.Token); - - // Step 2: Calculate the total size and count of the files - CountAssetIndex(AssetIndex); - - // Step 3: 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. - // either way, returns false. - return SummarizeStatusAndProgress( - AssetIndex, - string.Format(Lang._GameRepairPage.Status3, ProgressAllCountFound, ConverterTool.SummarizeSizeSimple(ProgressAllSizeFound)), - Lang._GameRepairPage.Status4); - } - - private async Task RepairRoutine() - { - // Assign repair task - Task repairTask = Repair(AssetIndex, Token.Token); - - // Run repair process - bool repairTaskSuccess = await TryRunExamineThrow(repairTask); - - // Reset status and progress - ResetStatusAndProgress(); - - // Set as completed - Status.IsCompleted = true; - Status.IsCanceled = false; - Status.ActivityStatus = Lang._GameRepairPage.Status7; - - // Update status and progress - UpdateAll(); - - return repairTaskSuccess; - } - - public void CancelRoutine() - { - // Trigger token cancellation - Token?.Cancel(); - Token?.Dispose(); - Token = null; - } - - public void Dispose() - { - CancelRoutine(); - GC.SuppressFinalize(this); - } - } -} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.FinalizeFetch.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.FinalizeFetch.cs new file mode 100644 index 000000000..3591ace70 --- /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.Data; +using Hi3Helper.EncTool; +using Hi3Helper.Shared.ClassStruct; +using Hi3Helper.Sophon; +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0130 +#nullable enable +namespace CollapseLauncher; + +internal partial class StarRailPersistentRefResult +{ + public async Task FinalizeCacheFetchAsync(StarRailRepairV2 instance, + HttpClient client, + List assetIndex, + string gameDir, + string aLuaDir, + CancellationToken token) + { + if (!IsCacheMode) + { + throw new + InvalidOperationException("You cannot call this method for finalization as you're using Game Repair mode. Please use FinalizeRepairFetchAsync instead!"); + } + + // Set total activity string as "Fetching Caches Type: " + instance.Status.ActivityStatus = string.Format(Locale.Lang._CachesPage.CachesStatusFetchingType, "ChangeLuaPathInfo.bytes"); + instance.Status.IsProgressAllIndetermined = true; + instance.Status.IsIncludePerFileIndicator = false; + instance.UpdateStatus(); + + // -- Get stock LuaV manifest file from sophon + FilePropertiesRemote? luaStockManifestFile = + assetIndex.FirstOrDefault(x => x.N.Contains(@"StreamingAssets\Lua\Windows\LuaV_", + StringComparison.OrdinalIgnoreCase) && + x.N.EndsWith(".bytes", StringComparison.OrdinalIgnoreCase)); + + if (luaStockManifestFile is not { AssociatedObject: SophonAsset sophonAsset }) + { + Logger.LogWriteLine("[StarRailPersistentRefResult::FinalizeCacheFetchAsync] We cannot finalize fetching process as necessary file is not available. The game might behave incorrectly!", + LogType.Warning, + true); + return; + } + + // -- Load temporarily from sophon + await using MemoryStream tempStream = new(); + await sophonAsset.WriteToStreamAsync(client, tempStream, token: token); + tempStream.Position = 0; + + // -- Parse manifest and get the first asset from stock metadata + StarRailAssetSignaturelessMetadata metadataLuaV = new(".bytes"); + await metadataLuaV.ParseAsync(tempStream, true, token); + + // -- Get stock dictionary asset + StarRailAssetSignaturelessMetadata.Metadata? stockLuaDictPath = metadataLuaV.DataList.FirstOrDefault(); + FilePropertiesRemote? stockLuaDictAsset = + assetIndex.FirstOrDefault(x => x.N.EndsWith(stockLuaDictPath?.Filename ?? "", + StringComparison.OrdinalIgnoreCase)); + if (stockLuaDictAsset is not { AssociatedObject: SophonAsset stockLuaDictSophon }) + { + Logger.LogWriteLine("[StarRailPersistentRefResult::FinalizeCacheFetchAsync] Stock Lua Dictionary file is not found! Skipping", + LogType.Warning, + true); + return; + } + + using MemoryStream stockLuaDictStream = new(); + await stockLuaDictSophon.WriteToStreamAsync(client, stockLuaDictStream, token: token); + stockLuaDictStream.Position = 0; + + // -- Get game server's dictionary asset + StarRailAssetSignaturelessMetadata.Metadata? gameServStockLuaPath = Metadata.CacheLua?.DataList.FirstOrDefault(); + string gameServLuaDictUrl = BaseUrls.CacheLua.CombineURLFromString(gameServStockLuaPath?.Filename); + if (string.IsNullOrEmpty(gameServLuaDictUrl)) + { + Logger.LogWriteLine("[StarRailPersistentRefResult::FinalizeCacheFetchAsync] Game Server's Lua Dictionary file is not found! Skipping", + LogType.Warning, + true); + return; + } + + CDNCacheResult gameServLuaDictRemote = await client.TryGetCachedStreamFrom(gameServLuaDictUrl, token: token); + if (!gameServLuaDictRemote.IsSuccessStatusCode) + { + Logger.LogWriteLine("[StarRailPersistentRefResult::FinalizeCacheFetchAsync] Game Server's Lua Dictionary file returns unsuccessful code! Skipping", + LogType.Warning, + true); + return; + } + await using Stream gameServLuaDictRemoteStream = gameServLuaDictRemote.Stream; + await using MemoryStream gameServLuaDictStream = new(); + await gameServLuaDictRemoteStream.CopyToAsync(gameServLuaDictStream, token); + gameServLuaDictStream.Position = 0; + + // -- Load Lua Dictionary Stream + Dictionary stockLuaDic = await LoadStarRailLuaPathDictAsync(stockLuaDictStream, token); + Dictionary gameServLuaDic = + await LoadStarRailLuaPathDictAsync(gameServLuaDictStream, token); + + // -- Generate ChangeLuaPathInfo.bytes to persistent folder + List newLuaDic = CompareLuaDict(stockLuaDic, gameServLuaDic); + if (newLuaDic.Count == 0) + { + return; + } + + FileInfo luaPathInfo = new FileInfo(Path.Combine(gameDir, aLuaDir, "ChangeLuaPathInfo.bytes")) + .EnsureCreationOfDirectory() + .EnsureNoReadOnly() + .StripAlternateDataStream(); + await using StreamWriter luaPathInfoWriter = luaPathInfo.CreateText(); + luaPathInfoWriter.NewLine = "\n"; + foreach (string line in newLuaDic.Select(newLuaEntry => newLuaEntry.Serialize(StarRailLuaPathJsonContext.Default.StarRailLuaPath, false))) + { + await luaPathInfoWriter.WriteLineAsync(line); + } + } + + public async Task FinalizeRepairFetchAsync(StarRailRepairV2 instance, + HttpClient client, + List assetIndex, + string persistentDir, + CancellationToken token) + { + if (IsCacheMode) + { + throw new + InvalidOperationException("You cannot call this method for finalization as you're using Cache Update mode. Please use FinalizeCacheFetchAsync instead!"); + } + + // Set total activity string as "Fetching Caches Type: " + instance.Status.ActivityStatus = string.Format(Locale.Lang._CachesPage.CachesStatusFetchingType, "BinaryVersion.bytes"); + instance.Status.IsProgressAllIndetermined = true; + instance.Status.IsIncludePerFileIndicator = false; + instance.UpdateStatus(); + + FilePropertiesRemote? binaryVersionFile = + assetIndex.FirstOrDefault(x => x.N.EndsWith("StreamingAssets\\BinaryVersion.bytes", + StringComparison.OrdinalIgnoreCase)); + + if (binaryVersionFile is not { AssociatedObject: SophonAsset asSophonAsset }) + { + Logger.LogWriteLine("[StarRailPersistentRefResult::FinalizeRepairFetchAsync] We cannot finalize fetching process as necessary file is not available. The game might behave incorrectly!", + LogType.Warning, + true); + return; + } + + await using MemoryStream tempStream = new(); + await asSophonAsset.WriteToStreamAsync(client, tempStream, token: token); + tempStream.Position = 0; + + byte[] buffer = tempStream.ToArray(); + Span bufferSpan = buffer.AsSpan()[..^3]; + + string binAppIdentityPath = Path.Combine(persistentDir, "AppIdentity.txt"); + string binDownloadedFullAssetsPath = Path.Combine(persistentDir, "DownloadedFullAssets.txt"); + string binInstallVersionPath = Path.Combine(persistentDir, "InstallVersion.bin"); + + Span hashSpan = bufferSpan[^36..^4]; + string hashStr = Encoding.UTF8.GetString(hashSpan); + + GetVersionNumber(bufferSpan, out uint majorVersion, out uint minorVersion, out uint stockPatchVersion); + + await File.WriteAllTextAsync(binAppIdentityPath, hashStr, token); + await File.WriteAllTextAsync(binDownloadedFullAssetsPath, hashStr, token); + await File.WriteAllTextAsync(binInstallVersionPath, $"{hashStr},{majorVersion}.{minorVersion}.{stockPatchVersion}", token); + + return; + + static void GetVersionNumber(ReadOnlySpan span, out uint major, out uint minor, out uint patch) + { + ushort strLen = BinaryPrimitives.ReadUInt16BigEndian(span); + span = span[(2 + strLen)..]; // Skip + patch = BinaryPrimitives.ReadUInt32BigEndian(span); + major = BinaryPrimitives.ReadUInt32BigEndian(span[4..]); + minor = BinaryPrimitives.ReadUInt32BigEndian(span[8..]); + } + } + + private static List CompareLuaDict( + Dictionary stock, + Dictionary gameServ) + { + List newPaths = []; + newPaths.AddRange(from kvp in gameServ where !stock.ContainsKey(kvp.Key) select kvp.Value); + + return newPaths; + } + + private static async Task> + LoadStarRailLuaPathDictAsync( + Stream stream, + CancellationToken token) + { + Dictionary dic = new(StringComparer.OrdinalIgnoreCase); + using StreamReader reader = new(stream, leaveOpen: true); + while (await reader.ReadLineAsync(token) is { } line) + { + // Break if we are already at the end of the JSON part + if (!string.IsNullOrEmpty(line) && + line[0] != '{') + { + break; + } + + if (line.Deserialize(StarRailLuaPathJsonContext.Default.StarRailLuaPath) is { } entry) + { + dic.TryAdd(entry.Md5 + entry.Path, entry); + } + } + + return dic; + } + + [JsonSerializable(typeof(StarRailLuaPath))] + [JsonSourceGenerationOptions(NewLine = "\n")] + public partial class StarRailLuaPathJsonContext : JsonSerializerContext; + + public class StarRailLuaPath + { + [JsonPropertyOrder(0)] + public required string Path { get; set; } + + [JsonPropertyOrder(1)] + public required string Md5 { get; set; } + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.GetPersistentFiles.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.GetPersistentFiles.cs new file mode 100644 index 000000000..7697661b3 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.GetPersistentFiles.cs @@ -0,0 +1,308 @@ +using CollapseLauncher.Helper.StreamUtility; +using CollapseLauncher.RepairManagement.StarRail.Struct.Assets; +using Hi3Helper.Data; +using Hi3Helper.Shared.ClassStruct; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading; + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher; + +file static class StarRailPersistentExtension +{ + public static IEnumerable WhereNotStartWith(this IEnumerable enumerable, + params ReadOnlySpan excludeStartWith) + where T : StarRailAssetGenericFileInfo + { + SearchValues excludeStartWithS = SearchValues.Create(excludeStartWith, StringComparison.OrdinalIgnoreCase); + return enumerable.Where(Impl); + + bool Impl(T asset) + { + ReadOnlySpan filePath = asset.Filename; + return filePath.IndexOfAny(excludeStartWithS) < 0; + } + } + + public static string GetPersistentLangPrefixToLauncherAudioLang(this string str) + => str switch + { + "English" => "English(US)", + "Chinese(PRC)" => "Chinese", + _ => str + }; +} + +internal partial class StarRailPersistentRefResult +{ + public List GetPersistentFiles( + List fileList, + string gameDirPath, + string[] installedVoiceLang) + { + Dictionary oldDic = fileList.ToDictionary(x => x.N); + Dictionary unusedAssets = new(StringComparer.OrdinalIgnoreCase); + + string[] audioLangPrefix = ["Chinese(PRC)", "Japanese", "Korean", "English"]; + string[] excludedAudioLangPrefix = audioLangPrefix + .Where(x => !installedVoiceLang.Contains(x.GetPersistentLangPrefixToLauncherAudioLang(), StringComparer.OrdinalIgnoreCase)) + .ToArray(); + + + foreach (FilePropertiesRemote asset in fileList) + { + oldDic.TryAdd(asset.N, asset); + } + + if (Metadata.StartBlockV != null) + { + AddAdditionalAssets(gameDirPath, + BaseDirs.StreamingAsbBlock, + BaseDirs.PersistentAsbBlock, + BaseUrls.AsbBlock, + BaseUrls.AsbBlockPersistent, + false, + fileList, + unusedAssets, + oldDic, + Metadata.StartBlockV.DataList); + } + + if (Metadata.BlockV != null) + { + AddAdditionalAssets(gameDirPath, + BaseDirs.StreamingAsbBlock, + BaseDirs.PersistentAsbBlock, + BaseUrls.AsbBlock, + BaseUrls.AsbBlockPersistent, + false, + fileList, + unusedAssets, + oldDic, + Metadata.BlockV.DataList); + } + + if (Metadata.VideoV != null) + { + AddAdditionalAssets(gameDirPath, + BaseDirs.StreamingVideo, + BaseDirs.PersistentVideo, + BaseUrls.Video, + BaseUrls.Video, + true, + fileList, + unusedAssets, + oldDic, + Metadata.VideoV.DataList); + } + + if (Metadata.AudioV != null) + { + AddAdditionalAssets(gameDirPath, + BaseDirs.StreamingAudio, + BaseDirs.PersistentAudio, + BaseUrls.Audio, + BaseUrls.Audio, + true, + fileList, + unusedAssets, + oldDic, + Metadata.AudioV!.DataList + .WhereNotStartWith(excludedAudioLangPrefix)); + + AddUnusedAudioAssets(gameDirPath, + BaseDirs.StreamingAudio, + BaseDirs.PersistentAudio, + Metadata.AudioV!.DataList, + fileList, + excludedAudioLangPrefix); + } + + if (Metadata.RawResV != null) + { + AddAdditionalAssets(gameDirPath, + BaseDirs.StreamingRawRes, + BaseDirs.PersistentRawRes, + BaseUrls.RawRes, + BaseUrls.RawRes, + false, + fileList, + unusedAssets, + oldDic, + Metadata.RawResV.DataList); + } + + if (Metadata.CacheLua != null) + { + AddAdditionalAssets(gameDirPath, + BaseDirs.CacheLua!.Replace("Persistent", "StreamingAssets"), + BaseDirs.CacheLua ?? "", + BaseUrls.CacheLua ?? "", + BaseUrls.CacheLua ?? "", + false, + fileList, + unusedAssets, + oldDic, + Metadata.CacheLua.DataList); + } + + if (Metadata.CacheIFix != null) + { + AddAdditionalAssets(gameDirPath, + BaseDirs.CacheIFix ?? "", + BaseDirs.CacheIFix ?? "", + BaseUrls.CacheIFix ?? "", + BaseUrls.CacheIFix ?? "", + false, + fileList, + unusedAssets, + oldDic, + Metadata.CacheIFix.DataList); + } + + return unusedAssets.Values.ToList(); + } + + private static void AddUnusedAudioAssets( + string gameDir, + string streamingDir, + string persistentDir, + IEnumerable listEnumerator, + List fileList, + params ReadOnlySpan excludedAudioLang) + where T : StarRailAssetFlaggable + { + if (excludedAudioLang.Length == 4) // Assume the user doesn't have any language installed, so ignore it. + { + return; + } + + string baseStreamingDir = Path.Combine(gameDir, streamingDir); + string basePersistentDir = Path.Combine(gameDir, persistentDir); + + SearchValues searchIndexes = SearchValues.Create(excludedAudioLang, StringComparison.OrdinalIgnoreCase); + foreach (T entry in listEnumerator) + { + ReadOnlySpan filename = entry.Filename; + int indexOf = filename.IndexOfAny(searchIndexes); + if (indexOf != 0) + { + continue; + } + + string filenameStr = entry.Filename ?? ""; + + string atStreaming = Path.Combine(baseStreamingDir, filenameStr).NormalizePath(); + string atPersistent = Path.Combine(basePersistentDir, filenameStr).NormalizePath(); + + string relStreaming = Path.Combine(streamingDir, filenameStr).NormalizePath(); + string relPersistent = Path.Combine(persistentDir, filenameStr).NormalizePath(); + + if (File.Exists(atStreaming)) + { + FilePropertiesRemote entryToRemove = new() + { + FT = FileType.Unused, + N = relStreaming + }; + fileList.Add(entryToRemove); + } + + // ReSharper disable once InvertIf + if (File.Exists(atPersistent)) + { + FilePropertiesRemote entryToRemove = new() + { + FT = FileType.Unused, + N = relPersistent + }; + fileList.Add(entryToRemove); + } + } + } + + private static void AddAdditionalAssets( + string gameDirPath, + string assetDirPathStreaming, + string assetDirPathPersistent, + string urlBase, + string urlBasePersistent, + bool isHashMarked, + List fileList, + Dictionary unusedFileList, + Dictionary fileDic, + IEnumerable flaggableAssets) + where T : StarRailAssetFlaggable + { + foreach (T asset in flaggableAssets) + { + string filename = asset.Filename?.NormalizePath() ?? ""; + + // Gets relative and absolute paths. + string relPathInStreaming = Path.Combine(assetDirPathStreaming, filename); + string relPathInPersistent = Path.Combine(assetDirPathPersistent, filename); + string pathInStreaming = Path.Combine(gameDirPath, relPathInStreaming); + string pathInPersistent = Path.Combine(gameDirPath, relPathInPersistent); + + // If file is not persistent while exists on both persistent and streaming, then + // remove the persistent one. + if (!asset.IsPersistent && + File.Exists(pathInPersistent) && + File.Exists(pathInStreaming) && + pathInStreaming != pathInPersistent) + { + unusedFileList.TryAdd(relPathInPersistent, new FilePropertiesRemote + { + FT = FileType.Unused, + N = relPathInPersistent + }); + } + + // Try to check entry existence + ref FilePropertiesRemote assetFromDic = ref CollectionsMarshal + .GetValueRefOrNullRef(fileDic, + relPathInPersistent); + + if (Unsafe.IsNullRef(ref assetFromDic)) + { + assetFromDic = ref CollectionsMarshal + .GetValueRefOrNullRef(fileDic, + relPathInStreaming); + } + + // Skip if entry already exist and file is not persistent. + if (!Unsafe.IsNullRef(ref assetFromDic) && + !asset.IsPersistent) + { + continue; + } + + // Now, the game will see any files which don't exist on Sophon as persistent files, + // even though they are not marked as persistent in metadata. + string url = (asset.IsPersistent + ? urlBasePersistent + : urlBase).CombineURLFromString(asset.Filename); + + FilePropertiesRemote file = new() + { + RN = url, + N = relPathInPersistent, + S = asset.FileSize, + CRCArray = asset.MD5Checksum, + FT = StarRailRepairV2.DetermineFileTypeFromExtension(asset.Filename ?? ""), + IsHasHashMark = isHashMarked + }; + fileDic.TryAdd(relPathInPersistent, file); + fileList.Add(file); + } + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.cs new file mode 100644 index 000000000..8d4188199 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.cs @@ -0,0 +1,784 @@ +using CollapseLauncher.Helper.JsonConverter; +using CollapseLauncher.Helper.StreamUtility; +using CollapseLauncher.RepairManagement.StarRail.Struct; +using CollapseLauncher.RepairManagement.StarRail.Struct.Assets; +using Hi3Helper; +using Hi3Helper.Data; +using Hi3Helper.EncTool; +using Hi3Helper.EncTool.Hashes; +using Hi3Helper.EncTool.Parser.AssetMetadata; +using Hi3Helper.EncTool.Proto.StarRail; +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +// ReSharper disable CommentTypo +// ReSharper disable UnusedMember.Global + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher; + +internal partial class StarRailPersistentRefResult +{ + public required AssetBaseUrls BaseUrls { get; set; } + public required AssetBaseDirs BaseDirs { get; set; } + public required AssetMetadata Metadata { get; set; } + public bool IsCacheMode { get; set; } + + public static async Task GetCacheReferenceAsync( + StarRailRepairV2 instance, + SRDispatcherInfo dispatcherInfo, + HttpClient client, + string gameBaseDir, + string persistentDir, + CancellationToken token) + { + StarRailGatewayStatic gateway = dispatcherInfo.RegionGateway; + Dictionary gatewayKvp = gateway.ValuePairs; + + // -- Assign main URLs + string mainUrlLua = gatewayKvp["LuaBundleVersionUpdateUrl"].CombineURLFromString("client/Windows"); + string mainUrlIFix = gatewayKvp["IFixPatchVersionUpdateUrl"].CombineURLFromString("client/Windows"); + AssetBaseUrls baseUrls = new() + { + GatewayKvp = gatewayKvp, + Archive = "", + AsbBlock = "", + AsbBlockPersistent = "", + Audio = "", + DesignData = "", + NativeData = "", + Video = "", + RawRes = "", + CacheLua = mainUrlLua, + CacheIFix = mainUrlIFix + }; + + string refLuaUrl = mainUrlLua.CombineURLFromString("M_LuaV.bytes"); + string refIFixUrl = mainUrlIFix.CombineURLFromString("M_IFixV.bytes"); + + // -- Initialize persistent dirs + string lDirLua = Path.Combine(persistentDir, @"Lua\Windows"); + string lDirIFix = Path.Combine(persistentDir, @"IFix\Windows"); + string aDirLua = Path.Combine(gameBaseDir, lDirLua); + string aDirIFix = Path.Combine(gameBaseDir, lDirIFix); + AssetBaseDirs baseDirs = new() + { + CacheLua = lDirLua, + CacheIFix = lDirIFix + }; + + // -- Fetch and parse the index references + StarRailAssetMetadataIndex metadataLua = new(useHeaderSizeOfForAssert: true); + Dictionary handleLua = await StarRailRefMainInfo + .ParseMetadataFromUrlAsync(instance, + client, + refLuaUrl, + metadataLua, + x => x.DataList[0].MD5Checksum, + x => x.DataList[0].MetadataIndexFileSize, + x => x.DataList[0].Timestamp, + x => new Version(x.DataList[0].MajorVersion, x.DataList[0].MinorVersion, x.DataList[0].PatchVersion), + aDirLua, + token); + + StarRailAssetMetadataIndex metadataIFix = new(use6BytesPadding: true, useHeaderSizeOfForAssert: true); + Dictionary handleIFix = await StarRailRefMainInfo + .ParseMetadataFromUrlAsync(instance, + client, + refIFixUrl, + metadataIFix, + x => x.DataList[0].MD5Checksum, + x => x.DataList[0].MetadataIndexFileSize, + x => x.DataList[0].Timestamp, + x => new Version(x.DataList[0].MajorVersion, x.DataList[0].MinorVersion, x.DataList[0].PatchVersion), + aDirIFix, + token); + + // -- Save local index files + // Notes to Dev: HoYo no longer provides a proper raw bytes data anymore and the client creates it based + // on data provided by "handleArchive", so we need to emulate how the game generates these data. + await SaveLocalIndexFiles(instance, handleLua, aDirLua, "LuaV", token); + await SaveLocalIndexFiles(instance, handleIFix, aDirIFix, "IFixV", token); + + // -- Load metadata files + // -- LuaV + StarRailAssetSignaturelessMetadata? metadataLuaV = new(".bytes"); + metadataLuaV = await LoadMetadataFile(instance, + handleLua, + client, + baseUrls.CacheLua, + "LuaV", + metadataLuaV, + aDirLua, + token); + + // -- IFixV + StarRailAssetCsvMetadata? metadataIFixV = + await LoadMetadataFile(instance, + handleIFix, + client, + baseUrls.CacheIFix, + "IFixV", + aDirIFix, + token); + + // -- Generate ChangeLuaPathInfo.bytes + + return new StarRailPersistentRefResult + { + BaseDirs = baseDirs, + BaseUrls = baseUrls, + Metadata = new AssetMetadata + { + CacheLua = metadataLuaV, + CacheIFix = metadataIFixV + }, + IsCacheMode = true + }; + } + + public static async Task GetRepairReferenceAsync( + StarRailRepairV2 instance, + SRDispatcherInfo dispatcherInfo, + HttpClient client, + string gameBaseDir, + string persistentDir, + CancellationToken token) + { + StarRailGatewayStatic gateway = dispatcherInfo.RegionGateway; + Dictionary gatewayKvp = gateway.ValuePairs; + + string mainUrlAsb = gatewayKvp["AssetBundleVersionUpdateUrl"].CombineURLFromString("client/Windows"); + string mainUrlAsbAlt = gatewayKvp["AssetBundleVersionUpdateUrlAlt"].CombineURLFromString("client/Windows"); + string mainUrlDesignData = gatewayKvp["DesignDataBundleVersionUpdateUrl"].CombineURLFromString("client/Windows"); + string mainUrlArchive = mainUrlAsb.CombineURLFromString("Archive"); + + string refDesignArchiveUrl = mainUrlDesignData.CombineURLFromString("M_Design_ArchiveV.bytes"); + string refArchiveUrl = mainUrlArchive.CombineURLFromString("M_ArchiveV.bytes"); + + // -- Test ArchiveV endpoint + // Notes to Dev: This is intentional. We need to find which endpoint is actually represents the ArchiveV file URL. + bool isSecondArchiveVEndpointRetry = false; + TestArchiveVEndpoint: + if (!await IsEndpointAlive(client, refArchiveUrl, token)) + { + if (isSecondArchiveVEndpointRetry) + { + throw new HttpRequestException("Seems like the URL for ArchiveV is missing. Please report this issue to our devs!"); + } + + Logger.LogWriteLine($"[StarRailPersistentRefResult::GetRepairReferenceAsync] Given ArchiveV Url is invalid! (previously: {refArchiveUrl}). Try swapping...", + LogType.Warning, + true); + + // Also swap the Asset bundle URL so we know that the URL assigned inside the gateway is flipped. + (mainUrlAsb, mainUrlAsbAlt) = (mainUrlAsbAlt, mainUrlAsb); + + isSecondArchiveVEndpointRetry = true; + mainUrlArchive = mainUrlAsb.CombineURLFromString("Archive"); + refArchiveUrl = mainUrlArchive.CombineURLFromString("M_ArchiveV.bytes"); + goto TestArchiveVEndpoint; + } + Logger.LogWriteLine($"[StarRailPersistentRefResult::GetRepairReferenceAsync] ArchiveV Url is found! at: {refArchiveUrl}", + LogType.Debug, + true); + + // -- Assign other URLs after checks + // Notes to Dev: + // We are now only assigning URL after above check because the game sometimes being a dick for swapping these + // distinct Asset bundle URLs. We don't want to assign these other URLs below unless the Asb URL is already correct. + // We also made the second check for the actual block URLs below so HoYo wouldn't be able to fuck around with our code + // anymore. + string mainUrlAudio = mainUrlAsb.CombineURLFromString("AudioBlock"); + string mainUrlAsbBlock = mainUrlAsb.CombineURLFromString("Block"); + string mainUrlAsbBlockAlt = mainUrlAsbAlt.CombineURLFromString("Block"); + string mainUrlNativeData = mainUrlDesignData.CombineURLFromString("NativeData"); + string mainUrlVideo = mainUrlAsb.CombineURLFromString("Video"); + string mainUrlRawRes = mainUrlAsb.CombineURLFromString("RawRes"); + + AssetBaseUrls baseUrl = new() + { + GatewayKvp = gatewayKvp, + DesignData = mainUrlDesignData, + Archive = mainUrlArchive, + Audio = mainUrlAudio, + AsbBlock = mainUrlAsbBlock, + AsbBlockPersistent = mainUrlAsbBlockAlt, + NativeData = mainUrlNativeData, + Video = mainUrlVideo, + RawRes = mainUrlRawRes + }; + + // -- Initialize persistent dirs + string lDirArchive = Path.Combine(persistentDir, @"Archive\Windows"); + string lDirAsbBlock = Path.Combine(persistentDir, @"Asb\Windows"); + string lDirAudio = Path.Combine(persistentDir, @"Audio\AudioPackage\Windows"); + string lDirDesignData = Path.Combine(persistentDir, @"DesignData\Windows"); + string lDirNativeData = Path.Combine(persistentDir, @"NativeData\Windows"); + string lDirVideo = Path.Combine(persistentDir, @"Video\Windows"); + string lDirRawRes = Path.Combine(persistentDir, @"RawRes\Windows"); + string aDirArchive = Path.Combine(gameBaseDir, lDirArchive); + string aDirAsbBlock = Path.Combine(gameBaseDir, lDirAsbBlock); + string aDirAudio = Path.Combine(gameBaseDir, lDirAudio); + string aDirDesignData = Path.Combine(gameBaseDir, lDirDesignData); + string aDirNativeData = Path.Combine(gameBaseDir, lDirNativeData); + string aDirVideo = Path.Combine(gameBaseDir, lDirVideo); + string aDirRawRes = Path.Combine(gameBaseDir, lDirRawRes); + AssetBaseDirs baseDirs = new(lDirArchive, lDirAsbBlock, lDirAudio, lDirDesignData, lDirNativeData, lDirVideo, lDirRawRes); + + // -- Fetch and parse the index references + Dictionary handleDesignArchive = await StarRailRefMainInfo + .ParseListFromUrlAsync(instance, + client, + refDesignArchiveUrl, + null, + token); + + Dictionary handleArchive = await StarRailRefMainInfo + .ParseListFromUrlAsync(instance, + client, + refArchiveUrl, + aDirArchive, + token); + + // -- Test Asset bundle endpoint + // Notes to Dev: This is intentional. We need to find which endpoint is actually represents the persistent file URL. + bool isSecondAsbEndpointRetry = false; + TestAsbPersistentEndpoint: + if (!await IsEndpointAlive(handleArchive, client, baseUrl.AsbBlockPersistent, "BlockV", token)) + { + if (isSecondAsbEndpointRetry) + { + throw new HttpRequestException("Seems like the URL for persistent asset bundle is missing. Please report this issue to our devs!"); + } + + Logger.LogWriteLine($"[StarRailPersistentRefResult::GetRepairReferenceAsync] Given persistent asset bundle URL is invalid! (previously: {baseUrl.AsbBlockPersistent}). Try swapping...", + LogType.Warning, + true); + isSecondAsbEndpointRetry = true; + baseUrl.SwapAsbPersistentUrl(); + goto TestAsbPersistentEndpoint; + } + Logger.LogWriteLine($"[StarRailPersistentRefResult::GetRepairReferenceAsync] Persistent asset bundle URL is found! at: {baseUrl.AsbBlockPersistent}", + LogType.Debug, + true); + + // -- Save local index files + // Notes to Dev: HoYo no longer provides a proper raw bytes data anymore and the client creates it based + // on data provided by "handleArchive", so we need to emulate how the game generates these data. + await SaveLocalIndexFiles(instance, handleDesignArchive, aDirDesignData, "DesignV", token); + await SaveLocalIndexFiles(instance, handleArchive, aDirAsbBlock, "AsbV", token); + await SaveLocalIndexFiles(instance, handleArchive, aDirAsbBlock, "BlockV", token); + await SaveLocalIndexFiles(instance, handleArchive, aDirAsbBlock, "Start_AsbV", token); + await SaveLocalIndexFiles(instance, handleArchive, aDirAsbBlock, "Start_BlockV", token); + await SaveLocalIndexFiles(instance, handleArchive, aDirAudio, "AudioV", token); + await SaveLocalIndexFiles(instance, handleArchive, aDirVideo, "VideoV", token); + await SaveLocalIndexFiles(instance, handleArchive, aDirRawRes, "RawResV", token); + + // -- Load metadata files + // -- DesignV + StarRailAssetSignaturelessMetadata? metadataDesignV = + await LoadMetadataFile(instance, + handleDesignArchive, + client, + baseUrl.DesignData, + "DesignV", + aDirDesignData, + token); + + // -- NativeDataV + StarRailAssetNativeDataMetadata? metadataNativeDataV = + await LoadMetadataFile(instance, + handleDesignArchive, + client, + baseUrl.NativeData, + "NativeDataV", + aDirNativeData, + token); + + // -- Start_AsbV + StarRailAssetBundleMetadata? metadataStartAsbV = + await LoadMetadataFile(instance, + handleArchive, + client, + baseUrl.AsbBlockPersistent, + "Start_AsbV", + aDirAsbBlock, + token); + + // -- Start_BlockV + StarRailAssetBlockMetadata? metadataStartBlockV = + await LoadMetadataFile(instance, + handleArchive, + client, + baseUrl.AsbBlockPersistent, + "Start_BlockV", + aDirAsbBlock, + token); + + // -- AsbV + StarRailAssetBundleMetadata? metadataAsbV = + await LoadMetadataFile(instance, + handleArchive, + client, + baseUrl.AsbBlockPersistent, + "AsbV", + null, + token); + + // -- BlockV + StarRailAssetBlockMetadata? metadataBlockV = + await LoadMetadataFile(instance, + handleArchive, + client, + baseUrl.AsbBlockPersistent, + "BlockV", + null, + token); + + // -- AudioV + StarRailAssetJsonMetadata? metadataAudioV = + await LoadMetadataFile(instance, + handleArchive, + client, + baseUrl.Audio, + "AudioV", + aDirAudio, + token); + + // -- VideoV + StarRailAssetJsonMetadata? metadataVideoV = + await LoadMetadataFile(instance, + handleArchive, + client, + baseUrl.Video, + "VideoV", + aDirVideo, + token); + + // -- RawResV + StarRailAssetJsonMetadata? metadataRawResV = + await LoadMetadataFile(instance, + handleArchive, + client, + baseUrl.RawRes, + "RawResV", + aDirRawRes, + token); + + return new StarRailPersistentRefResult + { + BaseDirs = baseDirs, + BaseUrls = baseUrl, + Metadata = new AssetMetadata + { + DesignV = metadataDesignV, + NativeDataV = metadataNativeDataV, + StartAsbV = metadataStartAsbV, + StartBlockV = metadataStartBlockV, + AsbV = metadataAsbV, + BlockV = metadataBlockV, + AudioV = metadataAudioV, + VideoV = metadataVideoV, + RawResV = metadataRawResV + } + }; + } + + private static async ValueTask SaveLocalIndexFiles( + StarRailRepairV2 instance, + Dictionary handleArchiveSource, + string outputDir, + string indexKey, + CancellationToken token) + { + if (!handleArchiveSource.TryGetValue(indexKey, out StarRailRefMainInfo? index)) + { + Logger.LogWriteLine($"Game server doesn't serve index file: {indexKey}. Please contact our developer to get this fixed!", LogType.Warning, true); + return; + } + + // Set total activity string as "Fetching Caches Type: " + instance.Status.ActivityStatus = string.Format(Locale.Lang._CachesPage.CachesStatusFetchingType, $"Game Index: {index.FileName}"); + instance.Status.IsProgressAllIndetermined = true; + instance.Status.IsIncludePerFileIndicator = false; + instance.UpdateStatus(); + + StarRailAssetMetadataIndex indexMetadata = index; + string filePath = Path.Combine(outputDir, index.FileName + ".bytes"); + await indexMetadata.WriteAsync(filePath, token); + } + + private static ValueTask LoadMetadataFile( + StarRailRepairV2 instance, + Dictionary handleArchiveSource, + HttpClient client, + string baseUrl, + string indexKey, + string? saveToLocalDir = null, + CancellationToken token = default) + where T : StarRailBinaryData, new() + { + T parser = StarRailBinaryData.CreateDefault(); + return LoadMetadataFile(instance, + handleArchiveSource, + client, + baseUrl, + indexKey, + parser, + saveToLocalDir, + token); + } + + private static async ValueTask LoadMetadataFile( + StarRailRepairV2 instance, + Dictionary handleArchiveSource, + HttpClient client, + string baseUrl, + string indexKey, + T parser, + string? saveToLocalDir = null, + CancellationToken token = default) + where T : StarRailBinaryData + { + if (!handleArchiveSource.TryGetValue(indexKey, out StarRailRefMainInfo? index)) + { + Logger.LogWriteLine($"Game server doesn't serve index file: {indexKey}. Please contact our developer to get this fixed!", LogType.Warning, true); + return null; + } + + // Set total activity string as "Fetching Caches Type: " + instance.Status.ActivityStatus = string.Format(Locale.Lang._CachesPage.CachesStatusFetchingType, $"Game Metadata: {index.FileName}"); + instance.Status.IsProgressAllIndetermined = true; + instance.Status.IsIncludePerFileIndicator = false; + instance.UpdateStatus(); + + if (!string.IsNullOrEmpty(saveToLocalDir) && + Directory.Exists(saveToLocalDir)) + { + DirectoryInfo dirInfo = new(saveToLocalDir); + foreach (FileInfo oldFilePath in dirInfo.EnumerateFiles($"{index.UnaliasedFileName}_*.bytes", SearchOption.TopDirectoryOnly)) + { + ReadOnlySpan fileNameOnly = oldFilePath.Name; + ReadOnlySpan fileHash = ConverterTool.GetSplit(fileNameOnly, ^2, "_."); + if (HexTool.IsHexString(fileHash) && + !fileHash.Equals(index.ContentHash, StringComparison.OrdinalIgnoreCase)) + { + oldFilePath + .EnsureNoReadOnly() + .StripAlternateDataStream() + .TryDeleteFile(); + } + } + } + + string filename = index.RemoteFileName; + + // Check if the stream has been downloaded + if (!string.IsNullOrEmpty(saveToLocalDir) && + Path.Combine(saveToLocalDir, filename) is { } localFilePath && + File.Exists(localFilePath)) + { + await using FileStream existingFileStream = File.OpenRead(localFilePath); + byte[] hash = await CryptoHashUtility + .ThreadSafe + .GetHashFromStreamAsync(existingFileStream, token: token); + byte[] hashRemote = HexTool.HexToBytesUnsafe(index.ContentHash); + + if (!hash.SequenceEqual(hashRemote)) + { + goto GetReadFromRemote; + } + + existingFileStream.Position = 0; + await parser.ParseAsync(existingFileStream, true, token); + + return parser; + } + + GetReadFromRemote: + string fileUrl = baseUrl.CombineURLFromString(filename); + await using Stream networkStream = (await client.TryGetCachedStreamFrom(fileUrl, token: token)).Stream; + await using Stream sourceStream = !string.IsNullOrEmpty(saveToLocalDir) + ? CreateLocalStream(networkStream, Path.Combine(saveToLocalDir, filename)) + : networkStream; + + await parser.ParseAsync(sourceStream, true, token); + + return parser; + + static Stream CreateLocalStream(Stream thisSourceStream, string filePath) + { + FileInfo fileInfo = new FileInfo(filePath) + .EnsureCreationOfDirectory() + .EnsureNoReadOnly() + .StripAlternateDataStream(); + return new CopyToStream(thisSourceStream, fileInfo.Create(), null, true); + } + } + + private static async ValueTask IsEndpointAlive( + Dictionary handleArchiveSource, + HttpClient client, + string baseUrl, + string indexKey, + CancellationToken token) + { + if (!handleArchiveSource.TryGetValue(indexKey, out StarRailRefMainInfo? index)) + { + Logger.LogWriteLine($"Game server doesn't serve index file: {indexKey}. Please contact our developer to get this fixed!", LogType.Warning, true); + return false; + } + + string filename = index.RemoteFileName; + string url = baseUrl.CombineURLFromString(filename); + + return await IsEndpointAlive(client, url, token); + } + + private static async ValueTask IsEndpointAlive( + HttpClient client, + string url, + CancellationToken token) + { + UrlStatus status = await client.GetCachedUrlStatus(url, token); + if (!status.IsSuccessStatusCode) + { + Logger.LogWriteLine($"[StarRailPersistentRefResult::IsEndpointAlive] Url: {url} returns unsuccessful status code: {status.StatusCode} ({(int)status.StatusCode})", + LogType.Warning, + true); + } + + return status.IsSuccessStatusCode; + } + + public class AssetBaseDirs( + string nArchive, + string nAsbBlock, + string nAudio, + string nDesignData, + string nNativeData, + string nVideo, + string nRawRes) + { + public AssetBaseDirs() : this("", "", "", "", "", "", "") + { + + } + + public string PersistentArchive { get; set; } = nArchive; + public string PersistentAsbBlock { get; set; } = nAsbBlock; + public string PersistentAudio { get; set; } = nAudio; + public string PersistentDesignData { get; set; } = nDesignData; + public string PersistentNativeData { get; set; } = nNativeData; + public string PersistentVideo { get; set; } = nVideo; + public string PersistentRawRes { get; set; } = nRawRes; + public string StreamingArchive { get; set; } = GetStreamingAssetsDir(nArchive); + public string StreamingAsbBlock { get; set; } = GetStreamingAssetsDir(nAsbBlock); + public string StreamingAudio { get; set; } = GetStreamingAssetsDir(nAudio); + public string StreamingDesignData { get; set; } = GetStreamingAssetsDir(nDesignData); + public string StreamingNativeData { get; set; } = GetStreamingAssetsDir(nNativeData); + public string StreamingVideo { get; set; } = GetStreamingAssetsDir(nVideo); + public string StreamingRawRes { get; set; } = GetStreamingAssetsDir(nRawRes); + + public string? CacheIFix { get; set; } + public string? CacheLua { get; set; } + + private static string GetStreamingAssetsDir(string dir) => dir.Replace("Persistent", "StreamingAssets"); + } + + public class AssetBaseUrls + { + public required Dictionary GatewayKvp { get; set; } + public required string DesignData { get; set; } + public required string Archive { get; set; } + public required string Audio { get; set; } + public required string AsbBlock { get; set; } + public required string AsbBlockPersistent { get; set; } + public required string NativeData { get; set; } + public required string Video { get; set; } + public required string RawRes { get; set; } + + public string? CacheLua { get; set; } + public string? CacheIFix { get; set; } + + public void SwapAsbPersistentUrl() => (AsbBlock, AsbBlockPersistent) = (AsbBlockPersistent, AsbBlock); + } + + public class AssetMetadata + { + public StarRailAssetSignaturelessMetadata? DesignV { get; set; } + public StarRailAssetNativeDataMetadata? NativeDataV { get; set; } + public StarRailAssetBundleMetadata? StartAsbV { get; set; } + public StarRailAssetBlockMetadata? StartBlockV { get; set; } + public StarRailAssetBundleMetadata? AsbV { get; set; } + public StarRailAssetBlockMetadata? BlockV { get; set; } + public StarRailAssetJsonMetadata? AudioV { get; set; } + public StarRailAssetJsonMetadata? VideoV { get; set; } + public StarRailAssetJsonMetadata? RawResV { get; set; } + + public StarRailAssetSignaturelessMetadata? CacheLua { get; set; } + public StarRailAssetCsvMetadata? CacheIFix { get; set; } + } +} + +[JsonSerializable(typeof(StarRailRefMainInfo))] +internal partial class StarRailRepairJsonContext : JsonSerializerContext; + +internal class StarRailRefMainInfo +{ + [JsonPropertyOrder(0)] public int MajorVersion { get; init; } + [JsonPropertyOrder(1)] public int MinorVersion { get; init; } + [JsonPropertyOrder(2)] public int PatchVersion { get; init; } + [JsonPropertyOrder(3)] public int PrevPatch { get; init; } + [JsonPropertyOrder(4)] public string ContentHash { get; init; } = ""; + [JsonPropertyOrder(5)] public long FileSize { get; init; } + [JsonPropertyOrder(7)] public string FileName { get; init; } = ""; + [JsonPropertyOrder(8)] public string BaseAssetsDownloadUrl { get; init; } = ""; + + [JsonPropertyOrder(6)] + [JsonConverter(typeof(UtcUnixStampToDateTimeOffsetJsonConverter))] + public DateTimeOffset TimeStamp { get; init; } + + [JsonIgnore] + public string UnaliasedFileName => + FileName.StartsWith("M_", StringComparison.OrdinalIgnoreCase) ? FileName[2..] : FileName; + + [JsonIgnore] + public string RemoteFileName => field ??= $"{UnaliasedFileName}_{ContentHash}.bytes"; + + public override string ToString() => RemoteFileName; + + /// + /// Converts this instance to .
+ /// This is necessary as SRMI (Star Rail Metadata Index) file is now being generated on Client-side, so + /// we wanted to save the files locally to save the information about the reference file. + ///
+ /// An instance of . + public StarRailAssetMetadataIndex ToMetadataIndex() + { + StarRailAssetMetadataIndex metadataIndex = + StarRailBinaryData.CreateDefault(); + + StarRailAssetMetadataIndex.MetadataIndex indexData = new() + { + MajorVersion = MajorVersion, + MinorVersion = MinorVersion, + PatchVersion = PatchVersion, + MD5Checksum = HexTool.HexToBytesUnsafe(ContentHash), + MetadataIndexFileSize = (int)FileSize, + PrevPatch = 0, // Leave PrevPatch to be 0 + Timestamp = TimeStamp, + Reserved = new byte[10] + }; + + metadataIndex.DataList.Add(indexData); + return metadataIndex; + } + + /// + /// Converts this instance to .
+ /// This is necessary as SRMI (Star Rail Metadata Index) file is now being generated on Client-side, so + /// we wanted to save the files locally to save the information about the reference file. + ///
+ /// An instance of . + public static implicit operator StarRailAssetMetadataIndex(StarRailRefMainInfo instance) => instance.ToMetadataIndex(); + + public static async Task> ParseListFromUrlAsync( + StarRailRepairV2 instance, + HttpClient client, + string url, + string? saveToLocalDir = null, + CancellationToken token = default) + { + // Set total activity string as "Fetching Caches Type: " + instance.Status.ActivityStatus = string.Format(Locale.Lang._CachesPage.CachesStatusFetchingType, $"Game Ref: {Path.GetFileNameWithoutExtension(url)}"); + instance.Status.IsProgressAllIndetermined = true; + instance.Status.IsIncludePerFileIndicator = false; + instance.UpdateStatus(); + + await using Stream networkStream = (await client.TryGetCachedStreamFrom(url, token: token)).Stream; + await using Stream sourceStream = !string.IsNullOrEmpty(saveToLocalDir) + ? CreateLocalStream(networkStream, Path.Combine(saveToLocalDir, Path.GetFileName(url))) + : networkStream; + + Dictionary returnList = []; + using StreamReader reader = new(sourceStream); + while (await reader.ReadLineAsync(token) is { } line) + { + StarRailRefMainInfo refInfo = line.Deserialize(StarRailRepairJsonContext.Default.StarRailRefMainInfo) + ?? throw new NullReferenceException(); + + returnList.Add(refInfo.UnaliasedFileName, refInfo); + } + + return returnList; + } + + public static async Task> + ParseMetadataFromUrlAsync( + StarRailRepairV2 instance, + HttpClient client, + string url, + TParser parser, + Func md5Selector, + Func sizeSelector, + Func timestampSelector, + Func versionSelector, + string? saveToLocalDir = null, + CancellationToken token = default) + where TParser : StarRailBinaryData + { + // Set total activity string as "Fetching Caches Type: " + instance.Status.ActivityStatus = string.Format(Locale.Lang._CachesPage.CachesStatusFetchingType, $"Game Ref: {Path.GetFileNameWithoutExtension(url)}"); + instance.Status.IsProgressAllIndetermined = true; + instance.Status.IsIncludePerFileIndicator = false; + instance.UpdateStatus(); + + await using Stream networkStream = (await client.TryGetCachedStreamFrom(url, token: token)).Stream; + await using Stream sourceStream = !string.IsNullOrEmpty(saveToLocalDir) + ? CreateLocalStream(networkStream, Path.Combine(saveToLocalDir, Path.GetFileName(url))) + : networkStream; + + string filenameNoExt = Path.GetFileNameWithoutExtension(url); + + // Start parsing + await parser.ParseAsync(sourceStream, true, token); + byte[] md5Checksum = md5Selector(parser); + long fileSize = sizeSelector(parser); + DateTimeOffset timestamp = timestampSelector(parser); + Version version = versionSelector(parser); + + StarRailRefMainInfo relInfo = new() + { + ContentHash = HexTool.BytesToHexUnsafe(md5Checksum)!, + FileName = filenameNoExt, + FileSize = fileSize, + TimeStamp = timestamp, + MajorVersion = version.Major, + MinorVersion = version.Minor, + PatchVersion = version.Build + }; + + Dictionary dict = []; + dict.Add(relInfo.UnaliasedFileName, relInfo); + return dict; + } + + private static CopyToStream CreateLocalStream(Stream thisSourceStream, string filePath) + { + FileInfo fileInfo = new FileInfo(filePath) + .EnsureCreationOfDirectory() + .EnsureNoReadOnly() + .StripAlternateDataStream(); + return new CopyToStream(thisSourceStream, fileInfo.Create(), null, true); + } +} \ No newline at end of file diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Check.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Check.cs new file mode 100644 index 000000000..dacd0f510 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Check.cs @@ -0,0 +1,221 @@ +using Hi3Helper; +using Hi3Helper.Data; +using Hi3Helper.SentryHelper; +using Hi3Helper.Shared.ClassStruct; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using static Hi3Helper.Locale; +using static Hi3Helper.Logger; +// ReSharper disable StringLiteralTypo +// ReSharper disable CommentTypo +// ReSharper disable SwitchStatementMissingSomeEnumCasesNoDefault + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher +{ + internal partial class StarRailRepairV2 + { + private async Task Check(List assetIndex, CancellationToken token) + { + // Try to find "badlist.byte" files in the game folder and delete it + foreach (string badListFile in Directory.EnumerateFiles(GamePath, "*badlist*.byte*", SearchOption.AllDirectories)) + { + LogWriteLine($"Removing bad list mark at: {badListFile}", LogType.Warning, true); + TryDeleteReadOnlyFile(badListFile); + } + + // Try to find "verify.fail" files in the game folder and delete it + foreach (string verifyFail in Directory.EnumerateFiles(GamePath, "*verify*.fail*", SearchOption.AllDirectories)) + { + LogWriteLine($"Removing verify.fail mark at: {verifyFail}", LogType.Warning, true); + TryDeleteReadOnlyFile(verifyFail); + } + + List brokenAssetIndex = []; + + // Set Indetermined status as false + Status.IsProgressAllIndetermined = false; + Status.IsProgressPerFileIndetermined = false; + + // Show the asset entry panel + Status.IsAssetEntryPanelShow = true; + + // Await the task for parallel processing + try + { + // Iterate assetIndex and check it using different method for each type and run it in parallel + await Parallel.ForEachAsync(assetIndex, new ParallelOptions { MaxDegreeOfParallelism = ThreadCount, CancellationToken = token }, async (asset, threadToken) => + { + await CheckGenericAssetType(asset, brokenAssetIndex, threadToken); + }); + } + catch (AggregateException ex) + { + Exception innerExceptionsFirst = ex.Flatten().InnerExceptions.First(); + await SentryHelper.ExceptionHandlerAsync(innerExceptionsFirst, SentryHelper.ExceptionType.UnhandledOther); + throw innerExceptionsFirst; + } + + // Re-add the asset index with a broken asset index + assetIndex.Clear(); + assetIndex.AddRange(brokenAssetIndex); + } + + #region AssetTypeCheck + + private async Task CheckGenericAssetType(FilePropertiesRemote asset, List targetAssetIndex, CancellationToken token) + { + // Update activity status + Status.ActivityStatus = string.Format(Lang._GameRepairPage.Status6, asset.N); + + // Increment current total count + ProgressAllCountCurrent++; + + // Reset per file size counter + ProgressPerFileSizeTotal = asset.S; + ProgressPerFileSizeCurrent = 0; + + string gamePath = GamePath; + string filePath = Path.Combine(gamePath, asset.N); + + AddUnusedHashMarkFile(filePath, gamePath, asset, targetAssetIndex); + if (asset.FT == FileType.Unused) + { + AddIndex(asset, targetAssetIndex); + LogWriteLine($"File: {asset.N} is unused", LogType.Warning, true); + return; + } + + // Get the file info + FileInfo fileInfo = new(filePath); + + // Check if the file exist or has unmatched size + if (!fileInfo.Exists) + { + AddIndex(asset, targetAssetIndex); + LogWriteLine($"File [T: {asset.FT}]: {asset.N} is not found", LogType.Warning, true); + return; + } + + if (fileInfo.Length != asset.S) + { + if (fileInfo.Name.Contains("pkg_version")) return; + AddIndex(asset, targetAssetIndex); + LogWriteLine($"File [T: {asset.FT}]: {asset.N} has unmatched size " + + $"(Local: {fileInfo.Length} <=> Remote: {asset.S}", + LogType.Warning, true); + return; + } + + // Skip CRC check if fast method is used + if (UseFastMethod) + { + return; + } + + // Open and read fileInfo as FileStream + await using FileStream fileStream = await NaivelyOpenFileStreamAsync(fileInfo, FileMode.Open, FileAccess.Read, FileShare.Read); + // If pass the check above, then do CRC calculation + // Additional: the total file size progress is disabled and will be incremented after this + byte[] localCrc = await GetCryptoHashAsync(fileStream, null, true, true, token); + + // If local and asset CRC doesn't match, then add the asset + if (IsArrayMatch(localCrc, asset.CRCArray)) + { + return; + } + + AddIndex(asset, targetAssetIndex); + LogWriteLine($"File [T: {asset.FT}]: {asset.N} is broken! Index CRC: {asset.CRC} <--> File CRC: {HexTool.BytesToHexUnsafe(localCrc)}", LogType.Warning, true); + } + + private void AddIndex(FilePropertiesRemote asset, List targetAssetIndex) + { + // Update the total progress and found counter + ProgressAllSizeFound += asset.S; + ProgressAllCountFound++; + + // Set the per size progress + ProgressPerFileSizeCurrent = asset.S; + + // Increment the total current progress + ProgressAllSizeCurrent += asset.S; + + var prop = new AssetProperty(Path.GetFileName(asset.N), + ConvertRepairAssetTypeEnum(asset.FT), + Path.GetDirectoryName(asset.N), + asset.S, + null, + null); + + Dispatch(() => AssetEntry.Add(prop)); + asset.AssociatedAssetProperty = prop; + targetAssetIndex.Add(asset); + } + + private void AddUnusedHashMarkFile(string filePath, + string gamePath, + FilePropertiesRemote asset, + List brokenFileList) + { + if (asset.CRCArray?.Length == 0 || + (!asset.IsHasHashMark && asset.FT != FileType.Unused)) + { + return; + } + + string dir = Path.GetDirectoryName(filePath)!; + string fileNameNoExt = Path.GetFileNameWithoutExtension(filePath); + + if (!Directory.Exists(dir)) + { + return; + } + + foreach (string markFile in Directory.EnumerateFiles(dir, $"{fileNameNoExt}_*", + SearchOption.TopDirectoryOnly)) + { + ReadOnlySpan markFilename = Path.GetFileName(markFile); + ReadOnlySpan hashSpan = ConverterTool.GetSplit(markFilename, ^2, "_."); + if (!HexTool.IsHexString(hashSpan)) + { + continue; + } + + if (!asset.CRC?.Equals(hashSpan, StringComparison.OrdinalIgnoreCase) ?? false) + { + AddAssetInner(markFile); + } + + // Add equal hash mark if the file is marked as unused. + if (asset.FT != FileType.Unused) + { + continue; + } + + AddAssetInner(markFile); + } + + return; + + void AddAssetInner(string thisFilePath) + { + string relPath = thisFilePath[gamePath.Length..].Trim('\\'); + AddIndex(new FilePropertiesRemote + { + FT = FileType.Unused, + N = relPath + }, brokenFileList); + } + } + #endregion + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Fetch.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Fetch.cs new file mode 100644 index 000000000..d68ba8a90 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Fetch.cs @@ -0,0 +1,237 @@ +using CollapseLauncher.Helper; +using CollapseLauncher.Helper.Metadata; +using CollapseLauncher.RepairManagement; +using Hi3Helper.EncTool.Parser.AssetMetadata; +using Hi3Helper.Shared.ClassStruct; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using static Hi3Helper.Locale; +// ReSharper disable CommentTypo +// ReSharper disable StringLiteralTypo +// ReSharper disable SwitchStatementHandlesSomeKnownEnumValuesWithDefault + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher +{ + internal partial class StarRailRepairV2 + { + private async Task Fetch(List assetIndex, CancellationToken token) + { + // Set total activity string as "Loading Indexes..." + Status.ActivityStatus = Lang._GameRepairPage.Status2; + Status.IsProgressAllIndetermined = true; + + UpdateStatus(); + + // Initialize new proxy-aware HttpClient + using HttpClient client = new HttpClientBuilder() + .UseLauncherConfig(DownloadThreadWithReservedCount) + .SetUserAgent(UserAgent) + .SetAllowedDecompression(DecompressionMethods.None) + .Create(); + + // Get shared HttpClient + HttpClient sharedClient = FallbackCDNUtil.GetGlobalHttpClient(true); + string regionId = GetExistingGameRegionID(); + string[] installedVoiceLang = await GetInstalledVoiceLanguageOrDefault(token); + + // Redirect to fetch cache if Cache Update Mode is used. + if (IsCacheUpdateMode) + { + await FetchForCacheUpdateMode(client, regionId, assetIndex, token); + return; + } + + // Get the primary manifest + await GetPrimaryManifest(assetIndex, installedVoiceLang, token); + + // If the this._isOnlyRecoverMain && base._isVersionOverride is true, copy the asset index into the _originAssetIndex + if (IsOnlyRecoverMain && IsVersionOverride) + { + OriginAssetIndex = [..assetIndex]; + // Due to all assets have relative path instead of absolute path, this code is no longer necessary. + /* + foreach (FilePropertiesRemote asset in assetIndex) + { + FilePropertiesRemote newAsset = asset.Copy(); + ReadOnlyMemory assetRelativePath = newAsset.N.AsMemory(GamePath.Length).TrimStart('\\'); + newAsset.N = assetRelativePath.ToString(); + OriginAssetIndex.Add(newAsset); + }*/ + + } + + // Fetch assets from game server + if (!IsVersionOverride && + !IsOnlyRecoverMain) + { + PresetConfig gamePreset = GameVersionManager.GamePreset; + SRDispatcherInfo dispatcherInfo = new(gamePreset.GameDispatchArrayURL, + gamePreset.ProtoDispatchKey, + gamePreset.GameDispatchURLTemplate, + gamePreset.GameGatewayURLTemplate, + gamePreset.GameDispatchChannelName, + GameVersionManager.GetGameVersionApi().ToString()); + await dispatcherInfo.Initialize(client, regionId, token); + + StarRailPersistentRefResult persistentRefResult = await StarRailPersistentRefResult + .GetRepairReferenceAsync(this, + dispatcherInfo, + client, + GamePath, + GameDataPersistentPathRelative, + token); + + assetIndex.AddRange(persistentRefResult.GetPersistentFiles(assetIndex, GamePath, installedVoiceLang)); + await persistentRefResult.FinalizeRepairFetchAsync(this, sharedClient, assetIndex, + GameDataPersistentPath, token); + } + + // Force-Fetch the Bilibili SDK (if exist :pepehands:) + await FetchBilibiliSdk(token); + + // Remove plugin from assetIndex + // Skip the removal for Delta-Patch + if (!IsOnlyRecoverMain) + { + EliminatePluginAssetIndex(assetIndex, x => x.N, x => x.RN); + } + } + + #region PrimaryManifest + + private async Task GetInstalledVoiceLanguageOrDefault(CancellationToken token) + { + if (!File.Exists(GameAudioLangListPathStatic)) + { + return []; // Return empty. For now, let's not mind about what VOs the user actually have and let the game decide. + } + + string[] installedAudioLang = (await File.ReadAllLinesAsync(GameAudioLangListPathStatic, token)) + .Where(x => !string.IsNullOrEmpty(x)) + .ToArray(); + return installedAudioLang; + } + + private async Task GetPrimaryManifest(List assetIndex, string[] voiceLang, CancellationToken token) + { + // 2025/12/28: + // Starting from this, we use Sophon as primary manifest source instead of relying on our Game Repair Index + // as miHoYo might remove uncompressed files from their CDN and fully moving to Sophon. + + HttpClient client = FallbackCDNUtil.GetGlobalHttpClient(true); + + string[] excludedMatchingField = ["en-us", "zh-cn", "ja-jp", "ko-kr"]; + if (File.Exists(GameAudioLangListPathStatic)) + { + string[] installedAudioLang = voiceLang + .Select(x => x switch + { + "English" => "en-us", + "English(US)" => "en-us", + "Japanese" => "ja-jp", + "Chinese(PRC)" => "zh-cn", + "Korean" => "ko-kr", + _ => "" + }) + .Where(x => !string.IsNullOrEmpty(x)) + .ToArray(); + + excludedMatchingField = excludedMatchingField.Where(x => !installedAudioLang.Contains(x)) + .ToArray(); + } + + await this.FetchAssetsFromSophonAsync(client, + assetIndex, + DetermineFileTypeFromExtension, + GameVersion, + excludedMatchingField, + token); + } + + #endregion + + #region Utilities + + internal static FileType DetermineFileTypeFromExtension(string fileName) + { + if (fileName.EndsWith(".block", StringComparison.OrdinalIgnoreCase)) + { + return FileType.Block; + } + + if (fileName.EndsWith(".usm", StringComparison.OrdinalIgnoreCase)) + { + return FileType.Video; + } + + if (fileName.EndsWith(".pck", StringComparison.OrdinalIgnoreCase)) + { + return FileType.Audio; + } + + return FileType.Generic; + } + + private unsafe string GetExistingGameRegionID() + { + // Delegate the default return value + string GetDefaultValue() => InnerGameVersionManager.GamePreset.GameDispatchDefaultName ?? throw new KeyNotFoundException("Default dispatcher name in metadata is not exist!"); + + // Try to get the value as nullable object + object? value = GameSettings?.RegistryRoot?.GetValue("App_LastServerName_h2577443795", null); + // Check if the value is null, then return the default name + // Return the dispatch default name. If none, then throw + if (value == null) return GetDefaultValue(); + + // Cast the value as byte array + byte[] valueBytes = (byte[])value; + int count = valueBytes.Length; + + // If the registry is empty, then return the default value; + if (valueBytes.Length == 0) + return GetDefaultValue(); + + // Get the pointer of the byte array + fixed (byte* valuePtr = &valueBytes[0]) + { + // Try check the byte value. If it's null, then continue the loop while + // also decreasing the count as its index + while (*(valuePtr + (count - 1)) == 0) { --count; } + + // Get the name from the span and trim the \0 character at the end + string name = Encoding.UTF8.GetString(valuePtr, count); + return name; + } + } + + private void CountAssetIndex(List assetIndex) + { + // Sum the assetIndex size and assign to _progressAllSize + ProgressAllSizeTotal = assetIndex.Sum(x => x.S); + + // Assign the assetIndex count to _progressAllCount + ProgressAllCountTotal = assetIndex.Count; + } + + private static RepairAssetType ConvertRepairAssetTypeEnum(FileType assetType) => assetType switch + { + FileType.Unused => RepairAssetType.Unused, + FileType.Block => RepairAssetType.Block, + FileType.Audio => RepairAssetType.Audio, + FileType.Video => RepairAssetType.Video, + _ => RepairAssetType.Generic + }; + #endregion + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.FetchForCacheUpdateMode.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.FetchForCacheUpdateMode.cs new file mode 100644 index 000000000..ca745f342 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.FetchForCacheUpdateMode.cs @@ -0,0 +1,60 @@ +using CollapseLauncher.Helper.Metadata; +using Hi3Helper.EncTool.Parser.AssetMetadata; +using Hi3Helper.Shared.ClassStruct; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher; + +internal partial class StarRailRepairV2 +{ + + #region CacheUpdateManifest + + private async Task FetchForCacheUpdateMode(HttpClient client, + string regionId, + List assetIndex, + CancellationToken token) + { + // -- Fetch game dispatcher/gateway + PresetConfig gamePreset = GameVersionManager.GamePreset; + SRDispatcherInfo dispatcherInfo = new(gamePreset.GameDispatchArrayURL, + gamePreset.ProtoDispatchKey, + gamePreset.GameDispatchURLTemplate, + gamePreset.GameGatewayURLTemplate, + gamePreset.GameDispatchChannelName, + GameVersionManager.GetGameVersionApi().ToString()); + await dispatcherInfo.Initialize(client, regionId, token); + + StarRailPersistentRefResult persistentRefResult = await StarRailPersistentRefResult + .GetCacheReferenceAsync(this, + dispatcherInfo, + client, + GamePath, + GameDataPersistentPathRelative, + token); + + List sophonAssets = []; + await GetPrimaryManifest(sophonAssets, [], token); // Just to get the sophon asset for stock LuaV + + await persistentRefResult.FinalizeCacheFetchAsync(this, + client, + sophonAssets, + GamePath, + persistentRefResult.BaseDirs.CacheLua!, + token); + + // HACK: Duplicate List from Sophon so we know which one is being added + List sophonAssetsDup = new(sophonAssets); + assetIndex.AddRange(persistentRefResult.GetPersistentFiles(sophonAssetsDup, GamePath, [])); + assetIndex.AddRange(sophonAssetsDup[sophonAssets.Count..]); + } + + #endregion +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Repair.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Repair.cs new file mode 100644 index 000000000..6f92283ea --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Repair.cs @@ -0,0 +1,292 @@ +using CollapseLauncher.Extension; +using CollapseLauncher.Helper; +using CollapseLauncher.Helper.StreamUtility; +using CollapseLauncher.RepairManagement; +using Hi3Helper; +using Hi3Helper.Data; +using Hi3Helper.Http; +using Hi3Helper.Shared.ClassStruct; +using Hi3Helper.Sophon; +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher +{ + internal partial class StarRailRepairV2 + { + private static ReadOnlySpan HashMarkFileContent => [0x20]; + + public async Task StartRepairRoutine( + bool showInteractivePrompt = false, + Action? actionIfInteractiveCancel = null) + { + await TryRunExamineThrow(StartRepairRoutineCoreAsync(showInteractivePrompt, actionIfInteractiveCancel)); + + // Reset status and progress + ResetStatusAndProgress(); + + // Set as completed + Status.ActivityStatus = IsCacheUpdateMode ? Locale.Lang._CachesPage.CachesStatusUpToDate : Locale.Lang._GameRepairPage.Status7; + + // Update status and progress + UpdateAll(); + } + + private async Task StartRepairRoutineCoreAsync(bool showInteractivePrompt = false, + Action? actionIfInteractiveCancel = null) + { + if (AssetIndex.Count == 0) throw new InvalidOperationException("There's no broken file being reported! You can't perform repair process!"); + + // Swap current found all size to per file size + ProgressPerFileSizeTotal = ProgressAllSizeTotal; + ProgressAllSizeTotal = AssetIndex.Where(x => x.FT != FileType.Unused).Sum(x => x.S); + + // Reset progress counter + ResetProgressCounter(); + + if (showInteractivePrompt && + actionIfInteractiveCancel != null) + { + await SpawnRepairDialog(AssetIndex, actionIfInteractiveCancel); + } + + // Initialize new proxy-aware HttpClient + using HttpClient client = new HttpClientBuilder() + .UseLauncherConfig(DownloadThreadWithReservedCount) + .SetUserAgent(UserAgent) + .SetAllowedDecompression(DecompressionMethods.None) + .Create(); + + int threadNum = IsBurstDownloadEnabled + ? ThreadForIONormalized + : 1; + + await Parallel.ForEachAsync(AssetIndex, + new ParallelOptions + { + CancellationToken = Token!.Token, + MaxDegreeOfParallelism = threadNum + }, + Impl); + + return; + + async ValueTask Impl(FilePropertiesRemote asset, CancellationToken token) + { + await (asset switch + { + { AssociatedObject: SophonAsset } => RepairAssetGenericSophonType(asset, token), + // ReSharper disable once AccessToDisposedClosure + _ => RepairAssetGenericType(client, asset, token) + }); + + if (!asset.IsHasHashMark) + { + return; + } + + string fileDir = Path.Combine(GamePath, Path.GetDirectoryName(asset.N) ?? ""); + string fileNameNoExt = Path.GetFileNameWithoutExtension(asset.N); + string markPath = Path.Combine(fileDir, $"{fileNameNoExt}_{asset.CRC}.hash"); + + File.WriteAllBytes(markPath, HashMarkFileContent); + } + } + + private async ValueTask RepairAssetGenericSophonType( + FilePropertiesRemote asset, + CancellationToken token) + { + // Update repair status to the UI + this.UpdateCurrentRepairStatus(asset); + + string assetPath = Path.Combine(GamePath, asset.N); + FileInfo assetFileInfo = new FileInfo(assetPath) + .StripAlternateDataStream() + .EnsureCreationOfDirectory() + .EnsureNoReadOnly(); + + try + { + await using FileStream assetFileStream = assetFileInfo + .Open(FileMode.Create, + FileAccess.Write, + FileShare.Write, + asset.S.GetFileStreamBufferSize()); + + if (asset.AssociatedObject is not SophonAsset sophonAsset) + { + throw new + InvalidOperationException("Invalid operation! This asset shouldn't have been here! It's not a sophon-based asset!"); + } + + // Download as Sophon asset + await sophonAsset + .WriteToStreamAsync(FallbackCDNUtil.GetGlobalHttpClient(true), + assetFileStream, + readBytes => UpdateProgressCounter(readBytes, readBytes), + token: token); + } + finally + { + this.PopBrokenAssetFromList(asset); + assetFileInfo.Directory?.DeleteEmptyDirectory(true); + } + } + + private async ValueTask RepairAssetGenericType( + HttpClient downloadHttpClient, + FilePropertiesRemote asset, + CancellationToken token) + { + // Update repair status to the UI + this.UpdateCurrentRepairStatus(asset, IsCacheUpdateMode); + + string assetPath = Path.Combine(GamePath, asset.N); + FileInfo assetFileInfo = new FileInfo(assetPath) + .StripAlternateDataStream() + .EnsureNoReadOnly(); + + try + { + if (asset.FT == FileType.Unused) + { + if (assetFileInfo.TryDeleteFile()) + { + Logger.LogWriteLine($"[StarRailRepairV2::RepairAssetGenericType] Unused asset {asset} has been deleted!", + LogType.Default, + true); + } + + return; + } + + // Use Hi3Helper.Http module to download the file. + DownloadClient downloadClient = DownloadClient + .CreateInstance(downloadHttpClient); + + // Perform download + await RunDownloadTask(asset.S, + assetFileInfo, + asset.RN, + downloadClient, + ProgressRepairAssetGenericType, + token); + + Logger.LogWriteLine($"[StarRailRepairV2::RepairAssetGenericType] Asset {asset.N} has been downloaded!", + LogType.Default, + true); + } + finally + { + this.PopBrokenAssetFromList(asset); + assetFileInfo.Directory?.DeleteEmptyDirectory(true); + } + } + + // Note for future me @neon-nyan: + // This is intended that we ignore DownloadProgress for now as the download size for "per-file" progress + // is now being handled by this own class progress counter. + private void ProgressRepairAssetGenericType(int read, DownloadProgress progress) => UpdateProgressCounter(read, read); + + private double _downloadReadLastSpeed; + private long _downloadReadLastReceivedBytes; + private long _downloadReadLastTick; + + private double _dataWriteLastSpeed; + private long _dataWriteLastReceivedBytes; + private long _dataWriteLastTick; + + private void UpdateProgressCounter(long dataWrite, long downloadRead) + { + double speedAll = CalculateSpeed(dataWrite, // dataWrite used as All Progress overall speed. + ref _dataWriteLastSpeed, + ref _dataWriteLastReceivedBytes, + ref _dataWriteLastTick); + + double speedPerFile = CalculateSpeed(downloadRead, // downloadRead used as Per File Progress overall speed. + ref _downloadReadLastSpeed, + ref _downloadReadLastReceivedBytes, + ref _downloadReadLastTick); + + Interlocked.Add(ref ProgressAllSizeCurrent, dataWrite); + Interlocked.Add(ref ProgressPerFileSizeCurrent, downloadRead); + + if (!CheckIfNeedRefreshStopwatch()) + { + return; + } + + double speedClamped = speedAll.ClampLimitedSpeedNumber(); + TimeSpan timeLeftSpan = ConverterTool.ToTimeSpanRemain(ProgressAllSizeTotal, + ProgressAllSizeCurrent, + speedClamped); + + double percentPerFile = ProgressPerFileSizeCurrent != 0 + ? ConverterTool.ToPercentage(ProgressPerFileSizeTotal, ProgressPerFileSizeCurrent) + : 0; + double percentAll = ProgressAllSizeCurrent != 0 + ? ConverterTool.ToPercentage(ProgressAllSizeTotal, ProgressAllSizeCurrent) + : 0; + + lock (Progress) + { + Progress.ProgressPerFilePercentage = percentPerFile; + Progress.ProgressPerFileSizeCurrent = ProgressPerFileSizeCurrent; + Progress.ProgressPerFileSizeTotal = ProgressPerFileSizeTotal; + Progress.ProgressAllSizeCurrent = ProgressAllSizeCurrent; + Progress.ProgressAllSizeTotal = ProgressAllSizeTotal; + + // Calculate speed + Progress.ProgressAllSpeed = speedClamped; + Progress.ProgressAllTimeLeft = timeLeftSpan; + + // Update current progress percentages + Progress.ProgressAllPercentage = percentAll; + } + + lock (Status) + { + // Update current activity status + Status.IsProgressAllIndetermined = false; + Status.IsProgressPerFileIndetermined = false; + + // Set time estimation string + string timeLeftString = string.Format(Locale.Lang._Misc.TimeRemainHMSFormat, Progress.ProgressAllTimeLeft); + + Status.ActivityPerFile = string.Format(Locale.Lang._Misc.Speed, ConverterTool.SummarizeSizeSimple(speedPerFile)); + Status.ActivityAll = string.Format(Locale.Lang._GameRepairPage.PerProgressSubtitle2, + ConverterTool.SummarizeSizeSimple(ProgressAllSizeCurrent), + ConverterTool.SummarizeSizeSimple(ProgressAllSizeTotal)) + + $" | {timeLeftString}" + + $" ({string.Format(Locale.Lang._Misc.Speed, ConverterTool.SummarizeSizeSimple(speedAll))})"; + + // Trigger update + UpdateAll(); + } + } + + private void ResetProgressCounter() + { + _dataWriteLastSpeed = 0; + _dataWriteLastReceivedBytes = 0; + _dataWriteLastTick = 0; + + _downloadReadLastSpeed = 0; + _downloadReadLastReceivedBytes = 0; + _downloadReadLastTick = 0; + + ProgressAllSizeCurrent = 0; + ProgressPerFileSizeCurrent = 0; + } + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.cs new file mode 100644 index 000000000..324f8b365 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.cs @@ -0,0 +1,123 @@ +using CollapseLauncher.GameVersioning; +using CollapseLauncher.InstallManager.StarRail; +using CollapseLauncher.Interfaces; +using CollapseLauncher.Statics; +using Hi3Helper; +using Hi3Helper.Data; +using Hi3Helper.Shared.ClassStruct; +using Microsoft.UI.Xaml; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +// ReSharper disable StringLiteralTypo + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher +{ + internal partial class StarRailRepairV2 : ProgressBase, IRepair, IRepairAssetIndex + { + #region Properties + + public override string GamePath + { + get => GameVersionManager.GameDirPath; + set => GameVersionManager.GameDirPath = value; + } + + private GameTypeStarRailVersion InnerGameVersionManager { get; } + private StarRailInstall? InnerGameInstaller + { + get => field ??= GamePropertyVault.GetCurrentGameProperty().GameInstall as StarRailInstall; + } + + private bool IsCacheUpdateMode { get; } + private bool IsOnlyRecoverMain { get; } + private List OriginAssetIndex { get; set; } = []; + private string ExecName { get; } + + private string GameDataPersistentPathRelative { get => Path.Combine($"{ExecName}_Data", "Persistent"); } + private string GameDataPersistentPath { get => Path.Combine(GamePath, GameDataPersistentPathRelative); } + + private string GameAudioLangListPathStatic { get => Path.Combine(GameDataPersistentPath, "AudioLaucherRecord.txt"); } + + protected override string UserAgent => "UnityPlayer/2019.4.34f1 (UnityWebRequest/1.0, libcurl/7.75.0-DEV)"; + #endregion + + public StarRailRepairV2( + UIElement parentUI, + IGameVersion gameVersionManager, + IGameSettings gameSettings, + bool onlyRecoverMainAsset = false, + string? versionOverride = null, + bool isCacheUpdateMode = false) + : base(parentUI, + gameVersionManager, + gameSettings, + "", + versionOverride) + { + // Get flag to only recover main assets + IsOnlyRecoverMain = onlyRecoverMainAsset; + InnerGameVersionManager = (gameVersionManager as GameTypeStarRailVersion)!; + ExecName = Path.GetFileNameWithoutExtension(InnerGameVersionManager.GamePreset.GameExecutableName) ?? ""; + IsCacheUpdateMode = isCacheUpdateMode; + } + + ~StarRailRepairV2() => Dispose(); + + public List GetAssetIndex() => OriginAssetIndex; + + public async Task StartCheckRoutine(bool useFastCheck) + { + UseFastMethod = useFastCheck; + return await TryRunExamineThrow(CheckRoutine()); + } + + private async Task CheckRoutine() + { + // Always clear the asset index list + AssetIndex.Clear(); + + // Reset status and progress + ResetStatusAndProgress(); + + // Step 1: Fetch asset indexes + await Fetch(AssetIndex, Token!.Token); + + // Step 2: Remove blacklisted files from asset index (borrow function from StarRailInstall) + await InnerGameInstaller!.FilterAssetList(AssetIndex, x => x.N, Token.Token); + + // Step 3: Calculate the total size and count of the files + CountAssetIndex(AssetIndex); + + // Step 4: Check for the asset indexes integrity + await Check(AssetIndex, Token.Token); + + // Step 5: Summarize and returns true if the assetIndex count != 0 indicates broken file was found. + // either way, returns false. + string status3Msg = IsCacheUpdateMode ? Locale.Lang._CachesPage.CachesStatusNeedUpdate : Locale.Lang._GameRepairPage.Status3; + string status4Msg = IsCacheUpdateMode ? Locale.Lang._CachesPage.CachesStatusUpToDate : Locale.Lang._GameRepairPage.Status4; + return SummarizeStatusAndProgress(AssetIndex, + string.Format(status3Msg, ProgressAllCountFound, ConverterTool.SummarizeSizeSimple(ProgressAllSizeFound)), + status4Msg); + } + + public void CancelRoutine() + { + // Trigger token cancellation + Token?.Cancel(); + Token?.Dispose(); + Token = null; + } + + public void Dispose() + { + CancelRoutine(); + GC.SuppressFinalize(this); + } + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetBlockMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetBlockMetadata.cs new file mode 100644 index 000000000..5367d05e8 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetBlockMetadata.cs @@ -0,0 +1,151 @@ +using Hi3Helper.Data; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +// ReSharper disable CommentTypo + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +/// +/// Star Rail Binary Metadata (SRBM) data parser for Start_BlockV and BlockV. This parser is read-only and cannot be written back.
+///
+public sealed class StarRailAssetBlockMetadata : StarRailAssetBinaryMetadata +{ + public StarRailAssetBlockMetadata() + : base(256, + 768, + 16, + 2, + 12) + { } + + protected override async ValueTask ReadDataCoreAsync( + long currentOffset, + Stream dataStream, + CancellationToken token = default) + { + (DataSectionHeaderStruct dataHeader, int readHeader) = + await dataStream + .ReadDataAssertAndSeekAsync(_ => Header.SubStructSize, + token) + .ConfigureAwait(false); + currentOffset += readHeader; + + // ASSERT: Make sure the current data stream offset is at the exact data buffer offset. + Debug.Assert(dataHeader.DataStartOffset == currentOffset); + + int dataBufferLen = dataHeader.DataSize * dataHeader.DataCount; + byte[] dataBuffer = ArrayPool.Shared.Rent(dataBufferLen); + try + { + // -- Read data buffer + currentOffset += await dataStream.ReadBufferAssertAsync(currentOffset, + dataBuffer.AsMemory(0, dataBufferLen), + token); + + // -- Create span + ReadOnlySpan dataBufferSpan = dataBuffer.AsSpan(0, dataBufferLen); + + // -- Allocate list + DataList = new List(dataHeader.DataCount); + while (!dataBufferSpan.IsEmpty) + { + dataBufferSpan = Metadata.Parse(dataBufferSpan, + dataHeader.DataSize, + out Metadata result); + DataList.Add(result); + } + } + finally + { + ArrayPool.Shared.Return(dataBuffer); + } + + return currentOffset; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + internal struct DataSectionHeaderStruct + { + /// + /// The absolute offset of data array in the data stream. + /// + public int DataStartOffset; + + /// + /// The count of data array in the data stream. + /// + public int DataCount; + + /// + /// The size of the one data inside the array. + /// + public int DataSize; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + internal unsafe struct MetadataStruct + { + /// + /// 4x4 bytes Reordered hash (each 4 bytes reordered as Big-endian). + /// + private fixed byte _shifted4BytesMD5Checksum[16]; + + /// + /// Defined flags of the asset bundle block file. + /// + public uint Flags; + + /// + /// The size of the block file. + /// + public int FileSize; + + /// + /// 4x4 bytes Reordered hash (each 4 bytes reordered as Big-endian). + /// + public ReadOnlySpan Shifted4BytesMD5Checksum + { + get + { + fixed (byte* magicP = _shifted4BytesMD5Checksum) + { + return new ReadOnlySpan(magicP, 16); + } + } + } + } + + public class Metadata : StarRailAssetFlaggable + { + public static ReadOnlySpan Parse(ReadOnlySpan buffer, + int sizeOfStruct, + out Metadata result) + { + ref readonly MetadataStruct structRef = ref MemoryMarshal.AsRef(buffer); + byte[] md5Buffer = new byte[16]; + + structRef.Shifted4BytesMD5Checksum.CopyTo(md5Buffer); + StarRailBinaryDataExtension.ReverseReorderBy4X4HashData(md5Buffer); + + result = new Metadata + { + Filename = $"{HexTool.BytesToHexUnsafe(md5Buffer)}.block", + FileSize = structRef.FileSize, + Flags = structRef.Flags, + MD5Checksum = md5Buffer + }; + + return buffer[sizeOfStruct..]; + } + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetBundleMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetBundleMetadata.cs new file mode 100644 index 000000000..840eded8c --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetBundleMetadata.cs @@ -0,0 +1,116 @@ +using Hi3Helper.EncTool; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +/// +/// Star Rail Asset Bundle Metadata (SRAM) data parser for Start_AsbV and AsbV. This parser read-only and cannot be written back.
+///
+public sealed class StarRailAssetBundleMetadata : StarRailAssetBinaryMetadata +{ + public StarRailAssetBundleMetadata() + : base(1280, + 256, + 40, + 8, + 16) { } + + private static ReadOnlySpan MagicSignatureStatic => "SRAM"u8; + protected override ReadOnlySpan MagicSignature => MagicSignatureStatic; + + protected override async ValueTask<(StarRailBinaryDataHeaderStruct Header, int Offset)> + ReadHeaderCoreAsync( + Stream dataStream, + CancellationToken token = default) + { + return await dataStream + .ReadDataAssertWithPosAndSeekAsync // Use ReadDataAssertWithPosAndSeekAsync for SRAM + (0, + x => x.HeaderOrDataLength, + token); + } + + protected override async ValueTask ReadDataCoreAsync( + long currentOffset, + Stream dataStream, + CancellationToken token = default) + { + // -- Read the first data section header. + (DataSectionHeaderStruct headerStructs, int read) = + await dataStream.ReadDataAssertAndSeekAsync + (_ => Header.SubStructSize, + token); + currentOffset += read; + + // -- Skip other data section header. + int remainedBufferSize = Header.SubStructSize * (Header.SubStructCount - 1); + currentOffset += await dataStream.SeekForwardAsync(remainedBufferSize, token); + + // ASSERT: Make sure we are at data start offset before reading. + Debug.Assert(currentOffset == headerStructs.DataStartOffset); + + // -- Read data buffer. + int dataBufferLen = headerStructs.DataCount * headerStructs.DataSize; + byte[] dataBuffer = ArrayPool.Shared.Rent(dataBufferLen); + try + { + // -- Read data buffer + currentOffset += await dataStream.ReadBufferAssertAsync(currentOffset, + dataBuffer.AsMemory(0, dataBufferLen), + token); + + // -- Create span + ReadOnlySpan dataBufferSpan = dataBuffer.AsSpan(0, dataBufferLen); + + // -- Allocate list + DataList = new List(headerStructs.DataCount); + while (!dataBufferSpan.IsEmpty) + { + dataBufferSpan = StarRailAssetBlockMetadata + .Metadata + .Parse(dataBufferSpan, + headerStructs.DataSize, + out StarRailAssetBlockMetadata.Metadata result); + DataList.Add(result); + } + } + finally + { + ArrayPool.Shared.Return(dataBuffer); + } + + return currentOffset; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + private struct DataSectionHeaderStruct + { + public int Reserved; + + /// + /// The absolute offset of data array in the data stream. + /// + public int DataStartOffset; + + /// + /// The count of data array in the data stream. + /// + public int DataCount; + + /// + /// The size of the one data inside the array. + /// + public int DataSize; + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetCsvMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetCsvMetadata.cs new file mode 100644 index 000000000..73128c659 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetCsvMetadata.cs @@ -0,0 +1,102 @@ +using Hi3Helper.Data; +using Hi3Helper.EncTool; +using Hi3Helper.EncTool.Streams; +using System; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +/// +/// Star Rail Comma-Separated Value Metadata (CSV) data parser for IFixV. This parser is read-only and cannot be written back.
+///
+public sealed class StarRailAssetCsvMetadata : StarRailAssetBinaryMetadata +{ + public StarRailAssetCsvMetadata() + : base(0, // Leave the rest of it to 0 as this metadata is a Comma-Separated Value (CSV) format + 0, + 0, + 0, + 0) { } + + protected override ReadOnlySpan MagicSignature => "\0\0\0\0"u8; + + protected override ValueTask<(StarRailBinaryDataHeaderStruct Header, int Offset)> + ReadHeaderCoreAsync(Stream dataStream, + CancellationToken token = default) + { + return ValueTask.FromResult((default(StarRailBinaryDataHeaderStruct), 0)); + } + + protected override async ValueTask ReadDataCoreAsync( + long currentOffset, + Stream dataStream, + CancellationToken token = default) + { + // -- Allocate list + DataList = []; + + // -- Read list + await using NullPositionTrackableStream trackingNullStream = new(); + await using CopyToStream bridgeStream = new(dataStream, trackingNullStream, null, false); + using StreamReader reader = new(bridgeStream, leaveOpen: true); + while (await reader.ReadLineAsync(token) is { } line) + { + if (!Metadata.Parse(line, out Metadata result)) + { + continue; + } + DataList.Add(result); + } + + return trackingNullStream.Position; + } + + public class Metadata : StarRailAssetFlaggable + { + public static bool Parse(ReadOnlySpan line, out Metadata result) + { + Unsafe.SkipInit(out result); + if (line.IsEmpty || + line.IsWhiteSpace()) + { + return false; + } + + const string separators = ",;"; // Include ; as well, just in case. + const StringSplitOptions options = StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries; + + Span ranges = stackalloc Range[8]; + int rangesLen = line.SplitAny(ranges, separators, options); + if (rangesLen < 3) + { + throw new InvalidOperationException("Format has been changed! Please report this issue to our devs!"); + } + + ReadOnlySpan filePath = line[ranges[0]]; + ReadOnlySpan hashStr = line[ranges[1]]; + ReadOnlySpan fileSizeStr = line[ranges[2]]; + + byte[] hash = new byte[16]; + if (!HexTool.TryHexToBytesUnsafe(hashStr, hash) || + !uint.TryParse(fileSizeStr, out uint fileSize)) + { + throw new InvalidOperationException($"Cannot parse values for this current line: {line} Please report this issue to our devs!"); + } + + result = new Metadata + { + Filename = filePath.ToString(), + FileSize = fileSize, + MD5Checksum = hash + }; + return true; + } + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetGenericFileInfo.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetGenericFileInfo.cs new file mode 100644 index 000000000..a98b5d233 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetGenericFileInfo.cs @@ -0,0 +1,56 @@ +using Hi3Helper.Data; +using Hi3Helper.Plugin.Core.Utility.Json.Converters; +using System.Text.Json.Serialization; + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +/// +/// Star Rail Asset Generic and Flaggable File Info +/// +public class StarRailAssetFlaggable : StarRailAssetGenericFileInfo +{ + /// + /// Defined flags of the asset bundle block file. + /// + public uint Flags { get; init; } + + /// + /// To indicate whether this asset is persistent. + /// + public virtual bool IsPersistent => (Flags & 0b00000000_00010000_00000000_00000000u) != 0; + + public override string ToString() => + $"{Filename} | Flags: {ConverterTool.ToBinaryString(Flags)} | IsPersistent: {IsPersistent} | Hash: {HexTool.BytesToHexUnsafe(MD5Checksum)} | Size: {FileSize}"; +} + +/// +/// Star Rail Asset Generic File Info +/// +public class StarRailAssetGenericFileInfo +{ + /// + /// The filename of the asset. + /// + [JsonPropertyName("Path")] + public virtual required string? Filename { get; init; } + + /// + /// Size of the file in bytes. + /// + [JsonPropertyName("Size")] + public required long FileSize { get; init; } + + /// + /// The MD5 hash checksum of the file. + /// + [JsonPropertyName("Md5")] + [JsonConverter(typeof(HexStringToArrayJsonConverter))] + public required byte[] MD5Checksum { get; init; } + + public override string ToString() => + $"{Filename} | Hash: {HexTool.BytesToHexUnsafe(MD5Checksum)} | Size: {FileSize} bytes"; +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetJsonMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetJsonMetadata.cs new file mode 100644 index 000000000..12a9ab838 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetJsonMetadata.cs @@ -0,0 +1,83 @@ +using Hi3Helper.EncTool; +using Hi3Helper.EncTool.Streams; +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +/// +/// Star Rail JSON-based Metadata parser for AudioV and VideoV. This parser read-only and cannot be written back.
+///
+public sealed partial class StarRailAssetJsonMetadata : StarRailAssetBinaryMetadata +{ + public StarRailAssetJsonMetadata() + : base(0, // Leave the rest of it to 0 as this metadata has JSON struct + 0, + 0, + 0, + 0) + { } + + protected override ReadOnlySpan MagicSignature => "\0\0\0\0"u8; + + protected override ValueTask<(StarRailBinaryDataHeaderStruct Header, int Offset)> + ReadHeaderCoreAsync(Stream dataStream, + CancellationToken token = default) + { + return ValueTask.FromResult((default(StarRailBinaryDataHeaderStruct), 0)); + } + + protected override async ValueTask ReadDataCoreAsync( + long currentOffset, + Stream dataStream, + CancellationToken token = default) + { + // -- Allocate list + DataList = []; + + // -- Read list + await using NullPositionTrackableStream trackingNullStream = new(); + await using CopyToStream bridgeStream = new(dataStream, trackingNullStream, null, false); + using StreamReader reader = new(bridgeStream, leaveOpen: true); + while (await reader.ReadLineAsync(token) is { } line) + { + Metadata? metadata = JsonSerializer.Deserialize(line, MetadataJsonContext.Default.Metadata); + if (metadata == null) + { + continue; + } + + DataList.Add(metadata); + } + + return trackingNullStream.Position; + } + + [JsonSerializable(typeof(Metadata))] + public partial class MetadataJsonContext : JsonSerializerContext; + + public class Metadata : StarRailAssetFlaggable + { + [JsonPropertyName("Patch")] + public bool IsPatch { get; init; } + + [JsonPropertyName("SubPackId")] + public int SubPackId { get; init; } + + [JsonPropertyName("TaskIds")] + public int[]? TaskIdList { get; init; } + + public override bool IsPersistent => IsPatch; + + public override string ToString() => $"{base.ToString()} | Patch: {IsPatch} | SubPackId: {SubPackId}" + + (TaskIdList?.Length == 0 ? "" : $" | TaskIds: [{string.Join(", ", TaskIdList ?? [])}]"); + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetNativeDataMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetNativeDataMetadata.cs new file mode 100644 index 000000000..180472160 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetNativeDataMetadata.cs @@ -0,0 +1,190 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +// ReSharper disable CommentTypo + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +/// +/// Star Rail Binary Metadata (SRBM) data parser for NativeDataV. This parser is read-only and cannot be written back.
+///
+public sealed class StarRailAssetNativeDataMetadata : StarRailAssetBinaryMetadata +{ + public StarRailAssetNativeDataMetadata() + : base(256, + 768, + 16, + 2, + 16) + { } + + protected override async ValueTask ReadDataCoreAsync( + long currentOffset, + Stream dataStream, + CancellationToken token = default) + { + (DataSectionHeaderStruct fileInfoHeaderStruct, int readHeader) = + await dataStream + .ReadDataAssertAndSeekAsync(_ => Header.SubStructSize, + token) + .ConfigureAwait(false); + currentOffset += readHeader; + + (DataSectionHeaderStruct filenameHeaderStruct, readHeader) = + await dataStream + .ReadDataAssertAndSeekAsync(_ => Header.SubStructSize, + token) + .ConfigureAwait(false); + currentOffset += readHeader; + + // ASSERT: Make sure the current data stream offset is at the exact filename buffer offset. + Debug.Assert(filenameHeaderStruct.DataStartOffset == currentOffset); + + int filenameBufferLen = filenameHeaderStruct.DataSize * filenameHeaderStruct.DataCount; + int fileInfoBufferLen = fileInfoHeaderStruct.DataSize * fileInfoHeaderStruct.DataCount; + byte[] filenameBuffer = ArrayPool.Shared.Rent(filenameBufferLen); + byte[] fileInfoBuffer = ArrayPool.Shared.Rent(fileInfoBufferLen); + try + { + // -- Read filename buffer + currentOffset += await dataStream.ReadBufferAssertAsync(currentOffset, + filenameBuffer.AsMemory(0, filenameBufferLen), + token); + + // -- Read file info buffer + currentOffset += await dataStream.ReadBufferAssertAsync(currentOffset, + fileInfoBuffer.AsMemory(0, fileInfoBufferLen), + token); + + // -- Create spans + Span filenameBufferSpan = filenameBuffer.AsSpan(0, filenameBufferLen); + Span fileInfoBufferSpan = fileInfoBuffer.AsSpan(0, fileInfoBufferLen); + + // -- Allocate list + DataList = new List(fileInfoHeaderStruct.DataCount); + + for (int i = 0; i < fileInfoHeaderStruct.DataCount && !filenameBufferSpan.IsEmpty; i++) + { + ref FileInfoStruct fileInfo = ref MemoryMarshal.AsRef(fileInfoBufferSpan); + filenameBufferSpan = Metadata.Parse(filenameBufferSpan, + ref fileInfo, + out Metadata? asset); + fileInfoBufferSpan = fileInfoBufferSpan[fileInfoHeaderStruct.DataSize..]; + + if (asset == null) + { + throw new IndexOutOfRangeException("Failed to parse NativeData assets as the buffer data might be insufficient or out-of-bounds!"); + } + DataList.Add(asset); + } + } + finally + { + ArrayPool.Shared.Return(filenameBuffer); + ArrayPool.Shared.Return(fileInfoBuffer); + } + + return currentOffset; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + private struct DataSectionHeaderStruct + { + public int Reserved; + + /// + /// The absolute offset of data array in the data stream. + /// + public int DataStartOffset; + + /// + /// The count of data array in the data stream. + /// + public int DataCount; + + /// + /// The size of the one data inside the array. + /// + public int DataSize; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public unsafe struct FileInfoStruct + { + /// + /// 4x4 bytes Reordered hash (each 4 bytes reordered as Big-endian). + /// + private fixed byte _shifted4BytesMD5Checksum[16]; + + /// + /// The size of the file. + /// + public long FileSize; + + /// + /// The filename length on the filename buffer. + /// + public int FilenameLength; + + /// + /// The start offset of the filename inside the filename buffer. + /// + public int FilenameStartAt; + + /// + /// 4x4 bytes Reordered hash (each 4 bytes reordered as Big-endian). + /// + public ReadOnlySpan Shifted4BytesMD5Checksum + { + get + { + fixed (byte* magicP = _shifted4BytesMD5Checksum) + { + return new ReadOnlySpan(magicP, 16); + } + } + } + } + + public class Metadata : StarRailAssetGenericFileInfo + { + public static Span Parse(Span filenameBuffer, + ref FileInfoStruct assetInfo, + out Metadata? result) + { + Unsafe.SkipInit(out result); + int filenameLength = assetInfo.FilenameLength; + if (filenameBuffer.Length < filenameLength) + { + return filenameBuffer; + } + + string filename = Encoding.UTF8.GetString(filenameBuffer[..filenameLength]); + long fileSize = assetInfo.FileSize; + byte[] md5Checksum = new byte[16]; + + assetInfo.Shifted4BytesMD5Checksum.CopyTo(md5Checksum); + StarRailBinaryDataExtension.ReverseReorderBy4X4HashData(md5Checksum); // Reorder 4x4 hash using SIMD + + result = new Metadata + { + Filename = filename, + FileSize = fileSize, + MD5Checksum = md5Checksum + }; + + return filenameBuffer[filenameLength..]; + } + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetSignaturelessMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetSignaturelessMetadata.cs new file mode 100644 index 000000000..bb829a3c2 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetSignaturelessMetadata.cs @@ -0,0 +1,156 @@ +using Hi3Helper.Data; +using Hi3Helper.EncTool; +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +/// +/// Star Rail Signatureless Metadata parser for LuaV, DesignV. This parser is read-only and cannot be written back.
+///
+public sealed class StarRailAssetSignaturelessMetadata : StarRailAssetBinaryMetadata +{ + public StarRailAssetSignaturelessMetadata() : this(null) + { + } + + public StarRailAssetSignaturelessMetadata(string? customAssetExtension = null) + : base(0, + 256, + 0, // Leave the rest of it to 0 as this metadata has non-consistent header struct + 0, + 0) + { + AssetExtension = customAssetExtension ?? ".block"; + } + + private string AssetExtension { get; } + + protected override ReadOnlySpan MagicSignature => [0x00, 0x00, 0x00, 0xFF]; + + protected override async ValueTask<(StarRailBinaryDataHeaderStruct Header, int Offset)> + ReadHeaderCoreAsync( + Stream dataStream, + CancellationToken token = default) + { + // We manipulate the Header to only read its first 8 bytes so + // the signature assertion can be bypassed as the other data + // is needed to be read by ReadDataCoreAsync, we don't want to + // read the whole 16 bytes of it. + byte[] first8BytesBuffer = new byte[8]; + StarRailBinaryDataHeaderStruct header = default; + + _ = await dataStream.ReadAtLeastAsync(first8BytesBuffer, + first8BytesBuffer.Length, + cancellationToken: token); + + CopyHeaderUnsafe(ref header, first8BytesBuffer); + + return (header, first8BytesBuffer.Length); + + static unsafe void CopyHeaderUnsafe(scoped ref StarRailBinaryDataHeaderStruct target, + scoped Span dataBuffer) + => dataBuffer.CopyTo(new Span(Unsafe.AsPointer(in target), dataBuffer.Length)); + } + + protected override async ValueTask ReadDataCoreAsync( + long currentOffset, + Stream dataStream, + CancellationToken token = default) + { + // In this read data section, we will read the data manually since + // the entire data is Big-endian ordered. + + // -- Read the count from the header + byte[] countBuffer = new byte[8]; + currentOffset += await dataStream.ReadAtLeastAsync(countBuffer, + countBuffer.Length, + cancellationToken: token) + .ConfigureAwait(false); + + Span countBufferSpan = countBuffer; + int parentDataCount = BinaryPrimitives.ReadInt32BigEndian(countBufferSpan); + + // -- Declare constant length for each parent and children data + const int parentDataBufferLen = 32; + const int childrenDataBufferLen = 12; + + // -- Read the rest of the data buffer and parse it + byte[] parentDataBuffer = ArrayPool.Shared.Rent(parentDataBufferLen); + Memory parentDataBufferMemory = parentDataBuffer.AsMemory(0, parentDataBufferLen); + + // -- Allocate list + DataList = new List(parentDataCount); + + try + { + for (int i = 0; i < parentDataCount; i++) + { + long lastPos = currentOffset; + // -- Parse data and add to list + currentOffset += await dataStream.ReadAtLeastAsync(parentDataBufferMemory, + parentDataBufferMemory.Length, + cancellationToken: token) + .ConfigureAwait(false); + Metadata.Parse(parentDataBuffer, + AssetExtension, + childrenDataBufferLen, + lastPos, + out int bytesToSkip, + out Metadata result); + DataList.Add(result); + + // -- Skip children data + currentOffset += await dataStream.SeekForwardAsync(bytesToSkip, token) + .ConfigureAwait(false); + } + } + finally + { + ArrayPool.Shared.Return(parentDataBuffer); + } + + return currentOffset; + } + + public class Metadata : StarRailAssetFlaggable + { + public static void Parse(ReadOnlySpan buffer, + string assetExtension, + int subDataSize, + long lastDataStreamPos, + out int bytesToSkip, + out Metadata result) + { + // uint firstAssetId = BinaryPrimitives.ReadUInt32BigEndian(buffer); + uint assetType = BinaryPrimitives.ReadUInt32BigEndian(buffer[20..]); + long fileSize = BinaryPrimitives.ReadUInt32BigEndian(buffer[24..]); + int subDataCount = BinaryPrimitives.ReadInt32BigEndian(buffer[28..]); + + byte[] md5Hash = new byte[16]; + buffer[4..20].CopyTo(md5Hash); + + // subDataCount = Number of sub-data struct count + // subDataSize = Number of sub-data struct size + // 1 = Unknown offset, seek +1 + bytesToSkip = subDataCount * subDataSize + 1; + result = new Metadata + { + MD5Checksum = md5Hash, + Filename = $"{HexTool.BytesToHexUnsafe(md5Hash)}{assetExtension}", + FileSize = fileSize, + Flags = assetType + }; + } + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailAssetBinaryMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailAssetBinaryMetadata.cs new file mode 100644 index 000000000..44cf6bca6 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailAssetBinaryMetadata.cs @@ -0,0 +1,37 @@ +using System; +using CollapseLauncher.RepairManagement.StarRail.Struct.Assets; +// ReSharper disable CommentTypo + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct; + +/// +/// Star Rail Binary Metadata (SRBM) data parser. This parser is an abstract, read-only and cannot be written back.
+/// This implementation inherit these subtypes:
+/// -
+/// -
+/// -
+/// - (This type, however, doesn't actually parse raw binary data, rather parsing a JSON entry). +///
+public abstract class StarRailAssetBinaryMetadata : StarRailBinaryData + where TAsset : StarRailAssetGenericFileInfo +{ + protected StarRailAssetBinaryMetadata( + short parentTypeFlag, + short typeVersionFlag, + int headerOrDataLength, + short subStructCount, + short subStructSize) + : base(MagicSignatureStatic, + parentTypeFlag, + typeVersionFlag, + headerOrDataLength, + subStructCount, + subStructSize) { } + + private static ReadOnlySpan MagicSignatureStatic => "SRBM"u8; + protected override ReadOnlySpan MagicSignature => MagicSignatureStatic; +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailAssetMetadataIndex.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailAssetMetadataIndex.cs new file mode 100644 index 000000000..d4d962bf9 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailAssetMetadataIndex.cs @@ -0,0 +1,305 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +// ReSharper disable CommentTypo + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct; + +/// +/// Star Rail Metadata Index (SRMI) data parser. This parser also implements to write back the result to a .
+/// This implementation inherit these subtypes:
+///
+internal class StarRailAssetMetadataIndex : StarRailBinaryDataWritable +{ + public StarRailAssetMetadataIndex() : this(false, false) + { + } + + public StarRailAssetMetadataIndex(bool use6BytesPadding = false, bool useHeaderSizeOfForAssert = false) + : base(MagicSignatureStatic, + 768, + 256, + 0, // On SRMI, the header length is actually the entire size of the data inside the stream (including header). + // The size will be recalculated if something changed. + + 0, // On SRMI, the value is always be 0. + 12) // On SRMI, the subStruct header length is 12 bytes (compared to SRBM's 16 bytes) + { + Use6BytesPaddingMode = use6BytesPadding; + UseHeaderSizeOfForAssert = useHeaderSizeOfForAssert; + } + + private static ReadOnlySpan MagicSignatureStatic => "SRMI"u8; + protected override ReadOnlySpan MagicSignature => MagicSignatureStatic; + + private bool Use6BytesPaddingMode { get; } + private bool UseHeaderSizeOfForAssert { get; } + + /// + /// Reads the header and perform assertion on the header. + /// + /// The which provides the source of the data to be parsed. + /// Cancellation token for cancelling asynchronous operations. + /// + /// This returns the and the current offset/position of the data stream after reading the header. + /// + protected override async ValueTask<(StarRailBinaryDataHeaderStruct Header, int Offset)> ReadHeaderCoreAsync( + Stream dataStream, + CancellationToken token = default) + { + return await dataStream + .ReadDataAssertAndSeekAsync(x => UseHeaderSizeOfForAssert + ? Marshal.SizeOf() + : x.HeaderOrDataLength, + token); + } + + protected override async ValueTask ReadDataCoreAsync( + long currentOffset, + Stream dataStream, + CancellationToken token = default) + { + if (Use6BytesPaddingMode) + { + (MetadataIndex6BytesPadStruct indexData6BytesPad, int readHeader6BytesPad) = + await dataStream + .ReadDataAssertAndSeekAsync(_ => Unsafe.SizeOf(), token) + .ConfigureAwait(false); + currentOffset += readHeader6BytesPad; + + MetadataIndex.Parse(in indexData6BytesPad, out MetadataIndex metadataIndex6Bytes); + DataList = [metadataIndex6Bytes]; + + return currentOffset; + } + + (MetadataIndexStruct indexData, int readHeader) = + await dataStream + .ReadDataAssertAndSeekAsync(_ => Unsafe.SizeOf(), token) + .ConfigureAwait(false); + currentOffset += readHeader; + + MetadataIndex.Parse(in indexData, out MetadataIndex metadataIndex); + DataList = [metadataIndex]; + + return currentOffset; + } + + protected override async ValueTask WriteHeaderCoreAsync(Stream dataStream, CancellationToken token = default) + { + int sizeOfHeader = Marshal.SizeOf(); + int sizeOfData = DataList.Count * Marshal.SizeOf(); + + StarRailBinaryDataHeaderStruct header = Header; // Copy header + header.HeaderOrDataLength = sizeOfHeader + sizeOfData; // Set data length + + await dataStream.WriteAsync(header, token).ConfigureAwait(false); + } + + protected override async ValueTask WriteDataCoreAsync(Stream dataStream, CancellationToken token = default) + { + // ReSharper disable once ConvertIfStatementToSwitchStatement + if (DataList.Count == 0) + { + throw new InvalidOperationException("Data is not initialized!"); + } + + if (DataList.Count > 1) + { + throw new InvalidOperationException("This struct doesn't accept multiple data!"); + } + + ref readonly MetadataIndex dataRef = ref CollectionsMarshal.AsSpan(DataList)[0]; + + if (Use6BytesPaddingMode) + { + dataRef.ToStruct6BytesPad(out MetadataIndex6BytesPadStruct indexStruct6BytesPad); + await dataStream.WriteAsync(indexStruct6BytesPad, token).ConfigureAwait(false); + return; + } + + dataRef.ToStruct(out MetadataIndexStruct indexStruct); + await dataStream.WriteAsync(indexStruct, token).ConfigureAwait(false); + } + + [StructLayout(LayoutKind.Sequential, Pack = 2)] + public unsafe struct MetadataIndexStruct + { + public int MajorVersion; + public int MinorVersion; + public int PatchVersion; + private fixed byte _shifted4BytesMD5Checksum[16]; + public int MetadataIndexFileSize; + public int PrevPatch; + public int UnixTimestamp; + private fixed byte _reserved[10]; + + public Span Shifted4BytesMD5Checksum + { + get + { + fixed (byte* magicP = _shifted4BytesMD5Checksum) + { + return new Span(magicP, 16); + } + } + } + + public Span Reserved + { + get + { + fixed (byte* reservedP = _reserved) + { + return new Span(reservedP, 10); + } + } + } + } + + [StructLayout(LayoutKind.Sequential, Pack = 2)] + public unsafe struct MetadataIndex6BytesPadStruct + { + public int MajorVersion; + public int MinorVersion; + public int PatchVersion; + private fixed byte _shifted4BytesMD5Checksum[16]; + public int MetadataIndexFileSize; + public int PrevPatch; + public int UnixTimestamp; + private fixed byte _reserved[6]; + + public Span Shifted4BytesMD5Checksum + { + get + { + fixed (byte* magicP = _shifted4BytesMD5Checksum) + { + return new Span(magicP, 16); + } + } + } + + public Span Reserved + { + get + { + fixed (byte* reservedP = _reserved) + { + return new Span(reservedP, 6); + } + } + } + } + + public class MetadataIndex + { + public required int MajorVersion { get; init; } + public required int MinorVersion { get; init; } + public required int PatchVersion { get; init; } + public required byte[] MD5Checksum { get; init; } + public required int MetadataIndexFileSize { get; init; } + public required int PrevPatch { get; init; } + public required DateTimeOffset Timestamp { get; init; } + public required byte[] Reserved { get; init; } + + public static void Parse(in MetadataIndexStruct indexStruct, + out MetadataIndex result) + { + byte[] md5Buffer = new byte[16]; + byte[] reservedBuffer = new byte[10]; + + indexStruct.Shifted4BytesMD5Checksum.CopyTo(md5Buffer); + indexStruct.Reserved.CopyTo(reservedBuffer); + + StarRailBinaryDataExtension.ReverseReorderBy4X4HashData(md5Buffer); + DateTimeOffset timestamp = DateTimeOffset.FromUnixTimeSeconds(indexStruct.UnixTimestamp); + + result = new MetadataIndex + { + MajorVersion = indexStruct.MajorVersion, + MinorVersion = indexStruct.MinorVersion, + PatchVersion = indexStruct.PatchVersion, + MD5Checksum = md5Buffer, + MetadataIndexFileSize = indexStruct.MetadataIndexFileSize, + PrevPatch = indexStruct.PrevPatch, + Timestamp = timestamp, + Reserved = reservedBuffer + }; + } + + public static void Parse(in MetadataIndex6BytesPadStruct indexStruct, + out MetadataIndex result) + { + byte[] md5Buffer = new byte[16]; + byte[] reservedBuffer = new byte[6]; + + indexStruct.Shifted4BytesMD5Checksum.CopyTo(md5Buffer); + indexStruct.Reserved.CopyTo(reservedBuffer); + + StarRailBinaryDataExtension.ReverseReorderBy4X4HashData(md5Buffer); + DateTimeOffset timestamp = DateTimeOffset.FromUnixTimeSeconds(indexStruct.UnixTimestamp); + + result = new MetadataIndex + { + MajorVersion = indexStruct.MajorVersion, + MinorVersion = indexStruct.MinorVersion, + PatchVersion = indexStruct.PatchVersion, + MD5Checksum = md5Buffer, + MetadataIndexFileSize = indexStruct.MetadataIndexFileSize, + PrevPatch = indexStruct.PrevPatch, + Timestamp = timestamp, + Reserved = reservedBuffer + }; + } + + public void ToStruct(out MetadataIndexStruct indexStruct) + { + indexStruct = new MetadataIndexStruct + { + MetadataIndexFileSize = MetadataIndexFileSize, + MajorVersion = MajorVersion, + MinorVersion = MinorVersion, + PatchVersion = PatchVersion, + PrevPatch = PrevPatch, + UnixTimestamp = (int)Timestamp.ToUnixTimeSeconds() + }; + + Span reservedSpan = indexStruct.Reserved; + Span md5Span = indexStruct.Shifted4BytesMD5Checksum; + + Reserved.CopyTo(reservedSpan); + MD5Checksum.CopyTo(md5Span); + + StarRailBinaryDataExtension.ReverseReorderBy4X4HashData(md5Span); + } + + public void ToStruct6BytesPad(out MetadataIndex6BytesPadStruct indexStruct) + { + indexStruct = new MetadataIndex6BytesPadStruct + { + MetadataIndexFileSize = MetadataIndexFileSize, + MajorVersion = MajorVersion, + MinorVersion = MinorVersion, + PatchVersion = PatchVersion, + PrevPatch = PrevPatch, + UnixTimestamp = (int)Timestamp.ToUnixTimeSeconds() + }; + + Span reservedSpan = indexStruct.Reserved; + Span md5Span = indexStruct.Shifted4BytesMD5Checksum; + + Reserved.CopyTo(reservedSpan); + MD5Checksum.CopyTo(md5Span); + + StarRailBinaryDataExtension.ReverseReorderBy4X4HashData(md5Span); + } + } +} \ No newline at end of file diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryData.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryData.cs new file mode 100644 index 000000000..a7c8ba7ba --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryData.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +// ReSharper disable CommentTypo + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct; + +/// +/// Generic/Abstract Star Rail Binary Data. Do not use this class directly. +/// +public abstract class StarRailBinaryData +{ + /// + /// Magic Signature of the binary data. This property must be overriden by the derivative instances. + /// + protected abstract ReadOnlySpan MagicSignature { get; } + + /// + /// The header of the parsed binary data. + /// + public StarRailBinaryDataHeaderStruct Header { get; protected set; } + + /// + /// Create a default instance of the members. + /// + /// Member type of . + /// A parser instance which is a member of . + public static T CreateDefault() where T : StarRailBinaryData, new() => new(); + + /// + /// Parse the binary data from the provided and populate the . + /// + /// The which provides the source of the data to be parsed. + /// + /// Whether to seek the data to the end, even though not all data being read.
+ /// Keep in mind that this operation will actually read all remaining data from the and discard it. + /// + /// Cancellation token for cancelling asynchronous operations. + /// + public virtual async Task ParseAsync(Stream dataStream, bool seekToEnd = false, CancellationToken token = default) + { + (Header, int offset) = await ReadHeaderCoreAsync(dataStream, token); + if (!Header.MagicSignature.SequenceEqual(MagicSignature)) + { + throw new InvalidOperationException($"Magic Signature doesn't match! Expecting: {Encoding.UTF8.GetString(MagicSignature)} but got: {Encoding.UTF8.GetString(Header.MagicSignature)} instead."); + } + + await ReadDataCoreAsync(offset, dataStream, token); + if (seekToEnd) // Read all remained data to null stream, even though not all data is being read. + { + await dataStream.CopyToAsync(Stream.Null, token); + } + } + + /// + /// Reads the header and perform assertion on the header. + /// + /// The which provides the source of the data to be parsed. + /// Cancellation token for cancelling asynchronous operations. + /// + /// This returns the and the current offset/position of the data stream after reading the header. + /// + protected virtual async ValueTask<(StarRailBinaryDataHeaderStruct Header, int Offset)> ReadHeaderCoreAsync( + Stream dataStream, + CancellationToken token = default) + { + return await dataStream + .ReadDataAssertAndSeekAsync(x => x.HeaderOrDataLength, + token); + } + + /// + /// Reads the body of the binary data. + /// + /// The current offset of the data stream. + /// The which provides the source of the data to be parsed. + /// Cancellation token for cancelling asynchronous operations. + /// + /// This returns the current offset/position of the data stream after reading the data. + /// + protected abstract ValueTask ReadDataCoreAsync(long currentOffset, Stream dataStream, CancellationToken token = default); +} + +/// +/// Generic/Abstract Star Rail Binary Data which contain the list of assets. Do not use this class directly. +/// +public abstract class StarRailBinaryData : StarRailBinaryData +{ + /// + /// List of the assets parsed from the binary data. + /// + public List DataList { get; protected set; } = []; + + protected StarRailBinaryData(ReadOnlySpan magicSignature, + short parentTypeFlag, + short typeVersionFlag, + int headerOrDataLength, + short subStructCount, + short subStructSize) + { + StarRailBinaryDataHeaderStruct header = default; + magicSignature.CopyTo(header.MagicSignature); + + header.ParentTypeFlag = parentTypeFlag; + header.TypeVersionFlag = typeVersionFlag; + header.HeaderOrDataLength = headerOrDataLength; + header.SubStructCount = subStructCount; + header.SubStructSize = subStructSize; + + Header = header; + } +} + +/// +/// Generic Star Rail Binary Data Header Structure.
+/// This header is globally used by SRMI, SRBM, SRAM and Signatureless metadata format. +///
+[StructLayout(LayoutKind.Sequential, Pack = 2)] +public unsafe struct StarRailBinaryDataHeaderStruct +{ + private fixed byte _magicSignature[4]; + public short ParentTypeFlag; + public short TypeVersionFlag; + public int HeaderOrDataLength; + public short SubStructCount; + public short SubStructSize; + + public Span MagicSignature + { + get + { + fixed (byte* magicP = _magicSignature) + { + return new Span(magicP, 4); + } + } + } + + public override string ToString() => $"{Encoding.UTF8.GetString(MagicSignature)} | ParentTypeFlag: {ParentTypeFlag} | TypeVersionFlag: {TypeVersionFlag} | SubStructCount: {SubStructCount} | SubStructSize: {SubStructSize} | HeaderReportedLength: {HeaderOrDataLength} bytes"; +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryDataExtension.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryDataExtension.cs new file mode 100644 index 000000000..bee925dd8 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryDataExtension.cs @@ -0,0 +1,197 @@ +using Hi3Helper.EncTool; +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.X86; +using System.Threading; +using System.Threading.Tasks; +// ReSharper disable InconsistentNaming + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct; + +internal static class StarRailBinaryDataExtension +{ + extension(Stream stream) + { + internal async ValueTask<(T Data, int Read)> ReadDataAssertAndSeekAsync( + Func minimalSizeAssertGet, + CancellationToken token) + where T : unmanaged + { + T result = await stream.ReadAsync(token).ConfigureAwait(false); + int sizeOfImplemented = Unsafe.SizeOf(); + int minimalSizeToAssert = minimalSizeAssertGet(result); + + // ASSERT: Make sure the struct size is versionable and at least, bigger than what we currently implement. + // (cuz we know you might change this in the future, HoYo :/) + if (sizeOfImplemented > minimalSizeToAssert) + { + throw new InvalidOperationException($"Game data use {minimalSizeToAssert} bytes of struct for {typeof(T).Name} while current implementation only supports struct with size >= {sizeOfImplemented}. Please contact @neon-nyan or ping us on our Official Discord to report this issue :D"); + } + + // ASSERT: Make sure to advance the stream position if the struct is bigger than what we currently implement. + int read = sizeOfImplemented; + int remained = minimalSizeToAssert - read; + read += await stream.SeekForwardAsync(remained, token); + + return (result, read); + } + + internal async ValueTask<(T Data, int Read)> ReadDataAssertWithPosAndSeekAsync( + long currentPos, + Func expectedPosAfterReadGet, + CancellationToken token) + where T : unmanaged + { + T result = await stream.ReadAsync(token).ConfigureAwait(false); + int sizeOfImplemented = Unsafe.SizeOf(); + long expectedPosAfterRead = expectedPosAfterReadGet(result); + + int read = sizeOfImplemented; + currentPos += read; + + // ASSERT: Make sure the current stream position is at least smaller or equal to what the game + // expect to be positioned at. + // (Again, as the same situation for ReadDataAssertAndSeekAsync, HoYo might change this in the future + // so we got to stop this from reading out of bounds :/) + if (currentPos > expectedPosAfterRead) + { + throw new InvalidOperationException($"Game data expect stream position to: {expectedPosAfterRead} bytes after reading struct for {typeof(T).Name} while our current data stream implementation stops at position: {currentPos + read} bytes. Please contact @neon-nyan or ping us on our Official Discord to report this issue :D"); + } + + // ASSERT: Make sure to advance the stream position if the struct we implement is smaller than the game implement. + int remained = (int)(expectedPosAfterRead - currentPos); + read += await stream.SeekForwardAsync(remained, token); + + return (result, read); + } + + internal async ValueTask ReadBufferAssertAsync(long currentPos, + Memory buffer, + CancellationToken token) + { + int read = await stream.ReadAtLeastAsync(buffer, + buffer.Length, + cancellationToken: token) + .ConfigureAwait(false); + // ASSERT: Make sure the amount of data being read is equal to buffer size + Debug.Assert(buffer.Length == read); + return read; + } + + internal async ValueTask WriteAsync( + T value, + CancellationToken token) + where T : unmanaged + { + int sizeOfImplemented = Unsafe.SizeOf(); + byte[] buffer = ArrayPool.Shared.Rent(sizeOfImplemented); + + try + { + // ASSERT: Try copy the value to buffer and assert whether the size is unequal + if (!TryCopyStructToBuffer(in value, buffer, out int writtenValueSize) || + writtenValueSize != sizeOfImplemented) + { + throw new DataMisalignedException($"Buffer size is insufficient than the size of the actual struct of {typeof(T).Name}"); + } + + await stream.WriteAsync(buffer.AsMemory(0, writtenValueSize), token) + .ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } + + private static unsafe bool TryCopyStructToBuffer(in T value, Span buffer, out int written) + where T : unmanaged + { + written = Unsafe.SizeOf(); + ReadOnlySpan valueSpan = new(Unsafe.AsPointer(in value), written); + return valueSpan.TryCopyTo(buffer); + } + + internal static unsafe void ReverseReorderBy4X4HashData(Span data) + { + if (data.Length != 16) + throw new ArgumentException("Data length must be 16 bytes.", nameof(data)); + + void* dataP = Unsafe.AsPointer(ref MemoryMarshal.GetReference(data)); + if (Sse3.IsSupported) + { + ReverseByInt32X4Sse3(dataP); + return; + } + + if (Sse2.IsSupported) + { + ReverseByInt32X4Sse2(dataP); + return; + } + + ReverseByInt32X4Scalar(dataP); + } + + private static readonly Vector128 ReverseByInt32X4ByteMask = + Vector128.Create((byte)3, 2, 1, 0, 7, 6, 5, 4, 11, 10, 9, 8, 15, 14, 13, 12); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static unsafe void ReverseByInt32X4Sse3(void* int32X4VectorP) + => *(Vector128*)int32X4VectorP = Ssse3.Shuffle(*(Vector128*)int32X4VectorP, ReverseByInt32X4ByteMask); // Swap + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static unsafe void ReverseByInt32X4Sse2(void* int32X4VectorP) + { + Vector128 vector = *(Vector128*)int32X4VectorP; + + // Masks + var mask00FF = Vector128.Create(0x00ff00ffu); + var maskFF00 = Vector128.Create(0xff00ff00u); + + // Swap bytes within 16-bit halves + Vector128 t1 = Sse2.ShiftLeftLogical(vector, 8); + Vector128 t2 = Sse2.ShiftRightLogical(vector, 8); + + vector = Sse2.Or(Sse2.And(t1, maskFF00), + Sse2.And(t2, mask00FF)); + + // Swap 16-bit halves + Vector128 result = Sse2.Or(Sse2.ShiftLeftLogical(vector, 16), + Sse2.ShiftRightLogical(vector, 16)); + *(Vector128*)int32X4VectorP = result; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static unsafe void ReverseByInt32X4Scalar(void* int32X4P) + { + uint* uintP = (uint*)int32X4P; + + uintP[0] = BinaryPrimitives.ReverseEndianness(uintP[0]); + uintP[1] = BinaryPrimitives.ReverseEndianness(uintP[1]); + uintP[2] = BinaryPrimitives.ReverseEndianness(uintP[2]); + uintP[3] = BinaryPrimitives.ReverseEndianness(uintP[3]); + } + + internal static unsafe bool IsStructEqual(T left, T right) + where T : unmanaged + { + int structSize = Marshal.SizeOf(); + Span leftSpan = new(Unsafe.AsPointer(ref left), structSize); + Span rightSpan = new(Unsafe.AsPointer(ref right), structSize); + + return leftSpan.SequenceEqual(rightSpan); + } +} + diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryDataWritable.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryDataWritable.cs new file mode 100644 index 000000000..418298385 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryDataWritable.cs @@ -0,0 +1,86 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct; + +/// +/// Generic/Abstract Star Rail Writeable Binary Data. Do not use this class directly.
+/// This implementation inherit these subtypes:
+/// - +///
+internal abstract class StarRailBinaryDataWritable : StarRailBinaryData +{ + protected StarRailBinaryDataWritable(ReadOnlySpan magicSignature, + short parentTypeFlag, + short typeVersionFlag, + int headerLength, + short subStructCount, + short subStructSize) + : base(magicSignature, + parentTypeFlag, + typeVersionFlag, + headerLength, + subStructCount, + subStructSize) { } + + /// + /// Write the binary data to a specified file path. + /// + /// Target file path to be written to. + /// Cancellation token for cancelling asynchronous operations. + public virtual ValueTask WriteAsync(string filePath, CancellationToken token = default) + => WriteAsync(new FileInfo(filePath), token); + + /// + /// Write the binary data to a specified file. + /// + /// Target file to be written to. + /// Cancellation token for cancelling asynchronous operations. + public virtual async ValueTask WriteAsync(FileInfo fileInfo, CancellationToken token = default) + { + fileInfo.Directory?.Create(); + if (fileInfo.Exists) + { + fileInfo.IsReadOnly = false; + } + + await using FileStream dataStream = fileInfo.Create(); + await WriteAsync(dataStream, token); + } + + /// + /// Write the binary data to a Stream instance. + /// + /// Target Stream to be written to. + /// Cancellation token for cancelling asynchronous operations. + public virtual async ValueTask WriteAsync(Stream dataStream, CancellationToken token = default) + { + if (StarRailBinaryDataExtension.IsStructEqual(Header, default)) + { + throw new InvalidOperationException("Header is not initialized!"); + } + + await WriteHeaderCoreAsync(dataStream, token); + await WriteDataCoreAsync(dataStream, token); + } + + /// + /// Writes the header of the data to the target data stream. + /// + /// Target data stream which the header will be written to. + /// Cancellation token for cancelling asynchronous operations. + protected abstract ValueTask WriteHeaderCoreAsync(Stream dataStream, CancellationToken token = default); + + /// + /// Writes the data to the target data stream. + /// + /// Target data stream which the data will be written to. + /// Cancellation token for cancelling asynchronous operations. + protected abstract ValueTask WriteDataCoreAsync(Stream dataStream, CancellationToken token = default); +} diff --git a/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Check.cs b/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Check.cs index 5b8f3490a..ab635f99a 100644 --- a/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Check.cs +++ b/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Check.cs @@ -58,7 +58,7 @@ private async Task Check(List assetIndex, CancellationToke private async ValueTask CheckGenericAssetType(FilePropertiesRemote asset, List targetAssetIndex, CancellationToken token) { // Update activity status - Status.ActivityStatus = string.Format(Locale.Lang._GameRepairPage.Status6, StarRailRepairExtension.GetFileRelativePath(asset.N, GamePath)); + Status.ActivityStatus = string.Format(Locale.Lang._GameRepairPage.Status6, asset.N[GamePath.Length..].AsSpan().Trim("\\/").ToString()); // Increment current total count ProgressAllCountCurrent++; diff --git a/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.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/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Repair.cs b/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Repair.cs index 8b28eb08d..ead6a20a7 100644 --- a/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Repair.cs +++ b/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Repair.cs @@ -107,7 +107,7 @@ private async Task RepairAssetTypeGeneric((FilePropertiesRemote AssetIndex, IAss // Set repair activity status string timeLeftString = string.Format(Locale.Lang!._Misc!.TimeRemainHMSFormat!, Progress.ProgressAllTimeLeft); UpdateRepairStatus( - string.Format(Locale.Lang._GameRepairPage.Status8, Path.GetFileName(asset.AssetIndex.N)), + string.Format(IsCacheUpdateMode ? Locale.Lang!._Misc!.Downloading + ": {0}" : Locale.Lang._GameRepairPage.Status8, Path.GetFileName(asset.AssetIndex.N)), string.Format(Locale.Lang._GameRepairPage.PerProgressSubtitle2, ConverterTool.SummarizeSizeSimple(ProgressAllSizeCurrent), ConverterTool.SummarizeSizeSimple(ProgressAllSizeTotal)) + $" | {timeLeftString}", true); diff --git a/CollapseLauncher/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; 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/Dialogs/SimpleDialogs.cs b/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/SimpleDialogs.cs index 87a60ea94..5f17c5c1d 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( @@ -1436,7 +1425,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 761151ae3..13e9ac868 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 @@ -449,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 0bba51ac0..5ea6e8fb9 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml @@ -1,4 +1,4 @@ - + @@ -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="Visible"> @@ -576,9 +576,8 @@ - + - + - + - + - + + + + diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameLauncher.cs b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameLauncher.cs index 7b6fde33b..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; @@ -814,6 +821,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 +852,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 +860,7 @@ Task ProcessAwaiter(CancellationToken x) => } finally { + await playtimeTask; currentGameProcess?.Dispose(); } } 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..44054d716 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; @@ -436,6 +438,7 @@ await ImageLoaderHelper.GetConvertedImageAsPng(outStream, #endregion #region Carousel + private bool _isCarouselInitialized = false; private async Task StartCarouselAutoScroll(int delaySeconds = 5) { @@ -444,11 +447,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 +459,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 +471,9 @@ private async Task StartCarouselAutoScroll(int delaySeconds = 5) { await Task.Delay(100, CarouselToken.Token); } + else break; } + break; } } catch (TaskCanceledException) @@ -496,6 +505,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/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; } } 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!"); 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", 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ショートカット", diff --git a/Hi3Helper.Core/Lang/zh_CN.json b/Hi3Helper.Core/Lang/zh_CN.json index 543083447..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": { @@ -612,9 +616,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 设置", @@ -1128,6 +1132,7 @@ "SteamShortcutCreationSuccessSubtitle5": " • 新的快捷方式仅在 Steam 重新加载后才会显示。", "SteamShortcutCreationSuccessSubtitle6": " • 要使用 Steam 叠加层,Steam 需要以管理员身份运行,而且 Collapse 必须完全关闭或在设置中启用“允许开启多个 Collapse”选项。", "SteamShortcutCreationSuccessSubtitle7": " • 如果游戏未安装/更新,Collapse 会尝试安装/更新。请注意,与这些过程相关的对话框仍会显示。", + "SteamShortcutCreationSuccessSubtitle8": " 此外,该区服的“高级设置”中的“以资源管理器作为父进程启动游戏”选项必须禁用。", "SteamShortcutCreationFailureTitle": "无效的 Steam 数据文件夹", "SteamShortcutCreationFailureSubtitle": "无法找到有效的 userdata 文件夹。\n\n在尝试使用此功能之前,请确保至少登录一次 Steam 客户端。", "SteamShortcutTitle": "Steam 快捷方式", diff --git a/Hi3Helper.EncTool b/Hi3Helper.EncTool index 15f443056..088db0f7d 160000 --- a/Hi3Helper.EncTool +++ b/Hi3Helper.EncTool @@ -1 +1 @@ -Subproject commit 15f443056e3019118ed37d653c6c06c13b368158 +Subproject commit 088db0f7dbd6bc45c2e7bf86afaf1138ce8a2fd9 diff --git a/Hi3Helper.SharpDiscordRPC b/Hi3Helper.SharpDiscordRPC index a6ba8bac8..fb14695a0 160000 --- a/Hi3Helper.SharpDiscordRPC +++ b/Hi3Helper.SharpDiscordRPC @@ -1 +1 @@ -Subproject commit a6ba8bac8d7795fb3059100a2a8b4905a3738636 +Subproject commit fb14695a08aa686b0b6d09831923c30097d9daef diff --git a/Hi3Helper.Sophon b/Hi3Helper.Sophon index a672778c9..612fb1fee 160000 --- a/Hi3Helper.Sophon +++ b/Hi3Helper.Sophon @@ -1 +1 @@ -Subproject commit a672778c9ed2e4b748b27a43c1bd616946cf6e51 +Subproject commit 612fb1fee1fc67ca1ab429d85df016b54d0e5a15 diff --git a/README.md b/README.md index 74c422aff..741f66e33 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.83.13` (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.13` (Released on: December 19th, 2025). To view all releases, [**click here**](https://github.com/neon-nyan/CollapseLauncher/releases).