diff --git a/App.xaml b/App.xaml index f4fc817..0b82fe9 100644 --- a/App.xaml +++ b/App.xaml @@ -5,6 +5,7 @@ xmlns:c="clr-namespace:HashCalculator.GUI.Converters" StartupUri="MainWindow.xaml"> + @@ -38,10 +39,6 @@ Value="#202020" /> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App.xaml.cs b/App.xaml.cs index bc1c6e7..c229593 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -18,6 +18,7 @@ namespace HashCalculator.GUI { var core = new TechnologyAssemblerCoreModule(); core.Initialize(); + DispatcherUnhandledException += (s, e) => Program.ErrorBox($"SAGE FastHash 计算器遇上了未处理的错误:{e.Exception}"); } } } diff --git a/AssetEntry.cs b/AssetEntry.cs index 3a8bc0c..fad7918 100644 --- a/AssetEntry.cs +++ b/AssetEntry.cs @@ -2,20 +2,37 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using System.Xml.Linq; using TechnologyAssembler.Core.Assets; namespace HashCalculator.GUI { - public class AssetEntry : IEquatable + public class AssetEntry : IEquatable, IComparable { public string Type { get; } public string Name { get; } + public string NameString { get; } public IEnumerable DisplayLabels { get; } public uint InstanceId => SageHash.CalculateLowercaseHash(Name); - public string NameString => $"{Type}:{Name}"; - public string InstanceIdString => $"{InstanceId:X8} ({InstanceId})"; + public string InstanceIdString => $"{InstanceId:x8} ({InstanceId,10})"; + + public string LocalizedNames + { + get + { + if (!Translator.HasProvider) + { + return string.Empty; + } + if (!DisplayLabels.Any()) + { + return "N/A"; + } + return DisplayLabels.Select(Translator.Translate).Aggregate((x, y) => $"{x} {y}"); + } + } public AssetEntry(XElement element) { @@ -32,6 +49,7 @@ namespace HashCalculator.GUI Type = element.Name.LocalName; var id = element.Attribute("id")?.Value; Name = id ?? throw new NotSupportedException(); + NameString = $"{Type}:{Name}"; var labels = from name in element.Elements(ModXml.EalaAsset + "DisplayName") select name.Value; @@ -42,14 +60,15 @@ namespace HashCalculator.GUI public AssetEntry(Asset asset) { - if(asset is null) + if (asset is null) { throw new ArgumentNullException($"{nameof(asset)} is null"); } Type = asset.TypeName; Name = asset.InstanceName; - DisplayLabels = Enumerable.Empty(); - if(InstanceId != asset.InstanceId || SageHash.CalculateBinaryHash(Type) != asset.TypeId) + NameString = $"{Type}:{Name}"; + DisplayLabels = Array.Empty(); + if (InstanceId != asset.InstanceId || SageHash.CalculateBinaryHash(Type) != asset.TypeId) { throw new InvalidDataException(); } @@ -103,32 +122,34 @@ namespace HashCalculator.GUI { return HashCode.Combine(Type, InstanceId); } - } - internal class DisplayAssetEntry : IComparable - { - public string Name { get; } - public string InstanceId { get; } - - public DisplayAssetEntry(AssetEntry entry) + public int CompareTo(AssetEntry other) { - Name = entry.NameString; - InstanceId = entry.InstanceIdString; + if (other is null) + { + throw new ArgumentNullException($"{nameof(other)} is null"); + } + return string.CompareOrdinal(NameString, other.NameString); } - public int CompareTo(DisplayAssetEntry other) + public static bool operator <(AssetEntry left, AssetEntry right) { - return string.CompareOrdinal(Name, other.Name); + return left is null ? right is object : left.CompareTo(right) < 0; } - } - internal class LocalizedDisplayAssetEntry : DisplayAssetEntry - { - public string LocalizedNames { get; } - - public LocalizedDisplayAssetEntry(AssetEntry entry) : base(entry) + public static bool operator <=(AssetEntry left, AssetEntry right) { - LocalizedNames = entry.DisplayLabels.Aggregate((x, y) => $"{x} {y}"); + return left is null || left.CompareTo(right) <= 0; + } + + public static bool operator >(AssetEntry left, AssetEntry right) + { + return left is object && left.CompareTo(right) > 0; + } + + public static bool operator >=(AssetEntry left, AssetEntry right) + { + return left is null ? right is null : left.CompareTo(right) >= 0; } } } diff --git a/Command.cs b/Command.cs index 209ce4e..de446aa 100644 --- a/Command.cs +++ b/Command.cs @@ -98,12 +98,12 @@ namespace HashCalculator.GUI }); } - public bool CanExecute(object parameter) + public bool CanExecute(object? parameter) { return _canExecute; } - public void Execute(object parameter) + public void Execute(object? parameter) { if (!_canExecute) { diff --git a/Controller.cs b/Controller.cs index 8f36351..0f6264a 100644 --- a/Controller.cs +++ b/Controller.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; @@ -8,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using TechnologyAssembler.Core.IO; using TechnologyAssembler.Core.Assets; +using TechnologyAssembler.Core.Language.Providers; namespace HashCalculator.GUI { @@ -19,28 +21,36 @@ namespace HashCalculator.GUI private ViewModel ViewModel { get; } private BigFileSystemProvider? _currentBig = null; + private readonly HashSet _loadedAssets = new HashSet(); + private Tuple? _lastXmlTask = null; public Controller(ViewModel viewModel) { ViewModel = viewModel; } + public void StartTrace(Action action) + { + TracerListener.StartListening(s => action(() => ViewModel.TraceText += s)); + } + [SuppressMessage("Design", "CA1031:不捕获常规异常类型", Justification = "<挂起>")] public async Task OnMainInputDecided(InputEntry selected) { - _currentBig?.Dispose(); - _currentBig = null; - ViewModel.Entries.Clear(); - ViewModel.TraceText = string.Empty; - ViewModel.BigEntryInput.AllManifests = null; - try { + _currentBig?.Dispose(); + _currentBig = null; + ClearEntries(); + ViewModel.TraceText = string.Empty; + ViewModel.BigEntryInput.AllManifests = null; + ViewModel.IsXml = false; + switch (selected.Type) { case InputEntryType.BinaryFile: ViewModel.StatusText = "请留意一下弹出的窗口("; - CopyableBox.ShowDialog(token => CalculateBinaryHash(selected.Value, token)); + CopyableBox.ShowDialog("SAGE FastHash 计算器", token => CalculateBinaryHash(selected.Value, token)); ViewModel.StatusText = ViewModel.SuggestionString(string.Empty); return; case InputEntryType.BigFile: @@ -50,7 +60,9 @@ namespace HashCalculator.GUI await LoadManifestFromFile(selected.Value).ConfigureAwait(true); return; case InputEntryType.XmlFile: - throw new NotImplementedException(); + ViewModel.IsXml = true; + await LoadXml(selected.Value).ConfigureAwait(true); + return; default: throw new NotSupportedException(); } @@ -58,7 +70,7 @@ namespace HashCalculator.GUI catch (Exception error) { ViewModel.StatusText = "失败…"; - CopyableBox.ShowDialog(_ => + CopyableBox.ShowDialog("SAGE FastHash 计算器 - 失败!", _ => { return Task.FromResult($"在尝试加载 {selected.Type} `{selected.Value}` 时发生错误:\r\n{error}"); }); @@ -90,10 +102,10 @@ namespace HashCalculator.GUI SageHash.CalculateLauncherBinaryHash }.AsParallel().Select(fn => fn(array)).ToArray(); - return $"使用 SAGE FastHash 计算出的哈希值:{hashes[0]:X8} (十进制 {hashes[0]})\r\n" + return $"使用 SAGE FastHash 计算出的哈希值:{hashes[0]:x8} (十进制 {hashes[0]})\r\n" + "注意这是以大小写敏感模式计算出的哈希值,与素材ID的哈希值(转换成小写后计算的哈希)一般是不一样的\r\n\r\n" + "使用 SAGE Launcher FastHash(比如说 RA3.exe 用来校验更新补丁文件的哈希)计算出的哈希值:" - + $"{hashes[1]:X8} (十进制 {hashes[1]})"; + + $"{hashes[1]:x8} (十进制 {hashes[1]})"; } catch (Exception exception) { @@ -134,73 +146,252 @@ namespace HashCalculator.GUI private async Task LoadManifestFromFile(string path) { - using var provider = new FileSystemProvider(SelectedFileSystemRootPath, null); - await ProcessManifest(path).ConfigureAwait(true); + await ExceptionWrapepr(async () => + { + try + { + using var provider = new FileSystemProvider(SelectedFileSystemRootPath, null); + path = VirtualFileSystem.Combine(SelectedFileSystemRootPath, path); + await ProcessManifest(path).ConfigureAwait(true); + } + catch + { + ViewModel.StatusText = "失败…"; + throw; + } + + }, "SAGE FastHash 计算器 - Manifest 加载失败…", "Manifest 文件加载失败,也许,你选择的 manifest 文件并不是红警3使用的那种……\r\n").ConfigureAwait(true); } public async Task ProcessManifest(string path) { - ViewModel.Entries.Clear(); - ViewModel.TraceText = string.Empty; - ViewModel.StatusText = "正在加载 manifest 文件…"; + ClearEntries(); + ViewModel.StatusText = "正在读取 manifest 文件…"; var entries = await Task.Run(() => { if (path.EndsWith(ManifestExtension, StringComparison.OrdinalIgnoreCase)) { path = path.Remove(path.Length - ManifestExtension.Length); } - using var manifest = Manifest.Load(path); + using var manifest = Manifest.Load(path, true); var assets = from asset in manifest.Assets.Values - select new DisplayAssetEntry(new AssetEntry(asset)); + select new AssetEntry(asset); return assets.ToArray(); }).ConfigureAwait(true); - await AddEntries(entries).ConfigureAwait(true); - ViewModel.StatusText = $"总共加载了{ViewModel.Entries.Count}个素材"; + AddEntries(entries); + ViewModel.StatusText = $"Manifest文件读取完毕,加载了{_loadedAssets.Count}个素材"; } - public async Task AddEntries(IEnumerable entries) + public async Task CancelLoadingXml() { - var target = ViewModel.Entries; - - int BinarySearch(DisplayAssetEntry entry, int? hint = null) + if (_lastXmlTask is null) { - var begin = hint.GetValueOrDefault(0); - var end = target.Count; - while (begin < end) + return; + } + + var (tokenSource, task) = _lastXmlTask; + tokenSource.Cancel(); + try + { + await task.ConfigureAwait(true); + } + catch (OperationCanceledException) + { + ViewModel.StatusText = "已经取消了加载"; + } + } + + [SuppressMessage("Reliability", "CA2000:丢失范围之前释放对象", Justification = "<挂起>")] + private async Task LoadXml(string path) + { + await CancelLoadingXml().ConfigureAwait(true); + using var tokenSource = new CancellationTokenSource(); + var task = LoadXmlInternal(path, tokenSource.Token); + _lastXmlTask = (tokenSource, task).ToTuple(); + + await ExceptionWrapepr(async () => + { + try { - var middle = begin + (end - begin) / 2; - var result = entry.CompareTo(target[middle]); - if (result == 0) - { - return middle; - } - else if (result < 0) - { - end = middle; - } - else if (result > 0) - { - begin = middle + 1; - } + ViewModel.IsLoadingXml = true; + await task.ConfigureAwait(true); } - return end; + catch (OperationCanceledException) + { + ViewModel.StatusText = "已经取消了加载"; + } + catch + { + ViewModel.StatusText = "失败…"; + throw; + } + finally + { + ViewModel.IsLoadingXml = false; + _lastXmlTask = null; + } + + }, "SAGE FastHash 计算器 - XML 加载失败…", "XML 文件加载失败,也许,你选择的 XML 文件并不是红警3使用的那种……\r\n").ConfigureAwait(true); + } + + [SuppressMessage("Design", "CA1031:不捕获常规异常类型", Justification = "<挂起>")] + private async Task LoadXmlInternal(string path, CancellationToken token) + { + var modXml = new ModXml(path, token); + + try + { + await FindCsf(modXml.BaseDirectory).ConfigureAwait(true); + } + catch (Exception exception) + { + TracerListener.WriteLine($"[ModXml] Failed to automatically find and load CSF: {exception}"); + } + token.ThrowIfCancellationRequested(); + + var start = DateTimeOffset.UtcNow; + var lastMoment = start; + var previousCount = 0; + var preivousSpeed = 0.0; + await foreach (var entry in modXml.ProcessDocument()) + { + var now = DateTimeOffset.Now; + var total = modXml.TotalFilesProcessed; + var time = now - start; + var speed = total / time.TotalSeconds; + + var dt = (now - lastMoment).TotalSeconds; + double instantSpeed; + if (dt <= 0.1) + { + instantSpeed = preivousSpeed; + } + else + { + var dx = total - previousCount; + instantSpeed = dx / dt; + lastMoment = now; + previousCount = total; + preivousSpeed = instantSpeed; + } + ViewModel.StatusText = $"已读取{total}个文件,平均读取速度{speed,8:N2}文件/秒,瞬时速度{instantSpeed,8:N2}文件/秒"; + AddEntry(entry); + } + UpdateEntries(); + var totalTime = DateTimeOffset.UtcNow - start; + var fileSpeed = modXml.TotalFilesProcessed / totalTime.TotalSeconds; + var assetSpeed = modXml.TotalAssets / totalTime.TotalSeconds; + var statistics = $"耗时{totalTime:mm\\:ss},共读取{modXml.TotalFilesProcessed}个文件({fileSpeed,1:N2}文件/秒;{assetSpeed,1:N2}素材/秒)"; + if (token.IsCancellationRequested) + { + ViewModel.StatusText = $"ModXml 的读取已被取消,共{statistics}"; + return; } - var ordered = await Task.Run(() => + ViewModel.StatusText = $"ModXml 读取完毕~ {statistics}"; + if (_loadedAssets.Count != modXml.TotalAssets) { - var array = entries.ToArray(); - Array.Sort(array); - return array; - }).ConfigureAwait(true); - ViewModel.StatusText = $"正在处理刚刚读取出来的 {ordered.Length} 个素材"; - var hint = 0; - foreach (var entry in ordered) - { - var index = hint = BinarySearch(entry, hint); - target.Insert(index, entry); + var message = $"_loadedAssets.Count ({_loadedAssets.Count}) != modXml.TotalAssets ({modXml.TotalAssets})"; + throw new InvalidDataException(message); } } + public static async Task LoadCsf(string filePath) + { + await ExceptionWrapepr(async () => + { + switch (Path.GetExtension(filePath).ToUpperInvariant()) + { + case ".BIG": + await LoadCsfFromBig(filePath).ConfigureAwait(true); + return; + case ".CSF": + await Task.Run(() => + { + using var stream = File.OpenRead(filePath); + Translator.Provider = new CsfTranslationProvider(stream); + }).ConfigureAwait(true); + return; + } + }, "SAGE FastHash 计算器 - CSF 加载失败…", "CSF 加载失败:").ConfigureAwait(true); + } + + private static Task LoadCsfFromBig(string bigPath) + { + return Task.Run(() => + { + using var big = new BigFile(bigPath); + var files = big.GetFiles(string.Empty, "*.csf", VirtualSearchOptionType.AllDirectories); + var file = + files.FirstOrDefault(x => x.Equals("gamestrings.csf", StringComparison.OrdinalIgnoreCase)) + ?? files.FirstOrDefault(); + if (file == null) + { + throw new FileNotFoundException(); + } + using var stream = big.OpenStream(file); + Translator.Provider = new CsfTranslationProvider(stream); + }); + } + + private static Task FindCsf(DirectoryInfo baseDirectory) + { + return Task.Run(() => + { + if (!baseDirectory.Exists) + { + throw new NotSupportedException(); + } + + baseDirectory.GetFiles("*.csf"); + var parent = baseDirectory.Parent; + var searchDirectories = + parent.GetDirectories($"{baseDirectory.Name[0]}*") + .Prepend(parent.Parent) + .Prepend(baseDirectory); + searchDirectories = searchDirectories + .Concat(searchDirectories.SelectMany(x => x.GetDirectories("Additional"))); + var csfs = from directories in searchDirectories + from data in directories.GetDirectories("Data") + from csf in data.GetFiles("*.csf") + select csf; + var result = + csfs.FirstOrDefault(x => x.Name.Equals("gamestrings.csf", StringComparison.OrdinalIgnoreCase)) + ?? csfs.FirstOrDefault(); + if (result == null) + { + throw new FileNotFoundException(); + } + + using var fileStream = result.OpenRead(); + Translator.Provider = new CsfTranslationProvider(fileStream); + TracerListener.WriteLine($"[ModXml]: Automatically loaded csf from `{result.FullName}`"); + }); + } + + public void UpdateEntries() + { + ViewModel.FilterCollection(_loadedAssets); + } + + private void ClearEntries() + { + _loadedAssets.Clear(); + UpdateEntries(); + } + + private void AddEntry(AssetEntry entry) + { + _loadedAssets.Add(entry); + ViewModel.AddNewItems(Enumerable.Repeat(entry, 1)); + } + + private void AddEntries(IEnumerable entries) + { + _loadedAssets.UnionWith(entries); + UpdateEntries(); + } + public static IEnumerable FindManifests() { var manifests = VirtualFileSystem.ListFiles(SelectedFileSystemRootPath, "*.manifest", VirtualSearchOptionType.AllDirectories); @@ -223,5 +414,21 @@ namespace HashCalculator.GUI { return VirtualFileSystem.GetFileName(path).StartsWith(what, StringComparison.OrdinalIgnoreCase); } + + [SuppressMessage("Design", "CA1031:不捕获常规异常类型", Justification = "<挂起>")] + private static async Task ExceptionWrapepr(Func action, string errorTitle, string preErrorMessage) + { + try + { + await action().ConfigureAwait(true); + } + catch (Exception exception) + { + CopyableBox.ShowDialog(errorTitle, _ => + { + return Task.FromResult(preErrorMessage + exception); + }); + } + } } } diff --git a/Converters/ValueConverterAggregate.cs b/Converters/ValueConverterAggregate.cs index 3729ea9..d738740 100644 --- a/Converters/ValueConverterAggregate.cs +++ b/Converters/ValueConverterAggregate.cs @@ -10,7 +10,7 @@ namespace HashCalculator.GUI.Converters [SuppressMessage("Microsoft.Performance", "CA1812")] internal class ValueConverterAggregate : List, IValueConverter { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + public object? Convert(object? value, Type targetType, object parameter, CultureInfo culture) { return this.Aggregate(value, (current, converter) => converter.Convert(current, targetType, parameter, culture)); } diff --git a/CopyableBox.xaml b/CopyableBox.xaml index c2044cc..433c91d 100644 --- a/CopyableBox.xaml +++ b/CopyableBox.xaml @@ -8,8 +8,9 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:local="clr-namespace:HashCalculator.GUI" mc:Ignorable="d" - Title="CopyableBox" Height="350" Width="600" + Title="{Binding Title}" Height="350" Width="600" Closing="ClosingHandler" + Style="{StaticResource CommonStyle}" > diff --git a/CopyableBox.xaml.cs b/CopyableBox.xaml.cs index 1bc9997..f5437e2 100644 --- a/CopyableBox.xaml.cs +++ b/CopyableBox.xaml.cs @@ -27,10 +27,11 @@ namespace HashCalculator.GUI { private CopyableBoxViewModel ViewModel => (CopyableBoxViewModel)DataContext; - public static void ShowDialog(Func> action) + public static void ShowDialog(string title, Func> action) { using var box = new CopyableBox(); - box.ViewModel.Initialize(action, box.Dispatcher); + box.ViewModel.Initialize(title, action, box.Dispatcher); + box.Owner = App.Current.MainWindow; box.ShowDialog(); } @@ -67,7 +68,14 @@ namespace HashCalculator.GUI private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); private Task? _task; - public const string InitialMessage = "正在加载…… 关闭窗口就可以取消加载("; + private string _title = "我居然没有名字qwq"; + public string Title + { + get => _title; + private set => SetField(ref _title, value); + } + + private const string InitialMessage = "正在加载…… 关闭窗口就可以取消加载("; private string _text = InitialMessage; public string Text @@ -121,8 +129,9 @@ namespace HashCalculator.GUI _cancellationTokenSource.Dispose(); } - public void Initialize(Func> action, Dispatcher dispatcher) + public void Initialize(string title, Func> action, Dispatcher dispatcher) { + Title = title; _task = InitializationTask(action, dispatcher); } diff --git a/HashCalculator.GUI.csproj b/HashCalculator.GUI.csproj index 23e51da..a60b453 100644 --- a/HashCalculator.GUI.csproj +++ b/HashCalculator.GUI.csproj @@ -9,22 +9,32 @@ true AnyCPU;x86 bf77c300-44f6-46ea-be94-f50d6993b55b + HashCalculator.GUI.Program + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + TechnologyAssembler.Core.dll + + + + + + %(ReferenceCopyLocalPaths.DestinationSubDirectory)%(ReferenceCopyLocalPaths.Filename)%(ReferenceCopyLocalPaths.Extension) + + + \ No newline at end of file diff --git a/InputBar.xaml b/InputBar.xaml index b9697de..ee07d4c 100644 --- a/InputBar.xaml +++ b/InputBar.xaml @@ -12,11 +12,6 @@ d:DesignHeight="100" d:DesignWidth="800" > - - - - -