diff --git a/CUE4Parse b/CUE4Parse index 7ed9bd5a..6d7157a2 160000 --- a/CUE4Parse +++ b/CUE4Parse @@ -1 +1 @@ -Subproject commit 7ed9bd5adf3daada4bd7d884f3a42d163a64247a +Subproject commit 6d7157a29b08d583aef9887a56d70f27a2ff36d5 diff --git a/FModel/Constants.cs b/FModel/Constants.cs index af4c4a51..45a79836 100644 --- a/FModel/Constants.cs +++ b/FModel/Constants.cs @@ -40,6 +40,12 @@ public static class Constants public const string _NO_PRESET_TRIGGER = "Hand Made"; + // Common issues + public const string MAPPING_ISSUE_LINK = "https://github.com/4sval/FModel/discussions/418"; + public const string AUDIO_ISSUE_LINK = "https://github.com/4sval/FModel/discussions/658"; + public const string RADA_ISSUE_LINK = "https://github.com/4sval/FModel/discussions/422"; + public const string VERSION_ISSUE_LINK = "https://github.com/4sval/FModel/discussions/425"; + public static int PALETTE_LENGTH => COLOR_PALETTE.Length; public static readonly Vector3[] COLOR_PALETTE = { diff --git a/FModel/Settings/UserSettings.cs b/FModel/Settings/UserSettings.cs index ebf9e534..f6a77b19 100644 --- a/FModel/Settings/UserSettings.cs +++ b/FModel/Settings/UserSettings.cs @@ -252,13 +252,6 @@ namespace FModel.Settings set => SetProperty(ref _imageMergerMargin, value); } - private bool _canExportRawData; - public bool CanExportRawData - { - get => _canExportRawData; - set => SetProperty(ref _canExportRawData, value); - } - private bool _readScriptData; public bool ReadScriptData { diff --git a/FModel/ViewModels/ApplicationViewModel.cs b/FModel/ViewModels/ApplicationViewModel.cs index 7822aa70..b49b2dd9 100644 --- a/FModel/ViewModels/ApplicationViewModel.cs +++ b/FModel/ViewModels/ApplicationViewModel.cs @@ -246,7 +246,7 @@ public class ApplicationViewModel : ViewModel } else { - FLogger.Append(ELog.Error, () => FLogger.Text("Could not download VgmStream", Constants.WHITE, true)); + FLogger.Append(ELog.Error, () => FLogger.Text("Could not download vgmstream", Constants.WHITE, true)); } } } diff --git a/FModel/ViewModels/AudioPlayerViewModel.cs b/FModel/ViewModels/AudioPlayerViewModel.cs index d12b2e7b..eff4cbb7 100644 --- a/FModel/ViewModels/AudioPlayerViewModel.cs +++ b/FModel/ViewModels/AudioPlayerViewModel.cs @@ -298,13 +298,23 @@ public class AudioPlayerViewModel : ViewModel, ISource, IDisposable Save(a, true); } - FLogger.Append(ELog.Information, () => - { - FLogger.Text("Successfully saved audio from ", Constants.WHITE); - FLogger.Link(_audioFiles.First().FileName, _audioFiles.First().FilePath, true); - }); if (_audioFiles.Count > 1) - FLogger.Append(ELog.Information, () => FLogger.Text($"Successfully saved {_audioFiles.Count} audio files", Constants.WHITE, true)); + { + var dir = new DirectoryInfo(Path.GetDirectoryName(_audioFiles.First().FilePath)); + FLogger.Append(ELog.Information, () => + { + FLogger.Text($"Successfully saved {_audioFiles.Count} audio files to ", Constants.WHITE); + FLogger.Link(dir.Name, dir.FullName, true); + }); + } + else + { + FLogger.Append(ELog.Information, () => + { + FLogger.Text("Successfully saved ", Constants.WHITE); + FLogger.Link(_audioFiles.First().FileName, _audioFiles.First().FilePath, true); + }); + } }); } @@ -654,15 +664,24 @@ public class AudioPlayerViewModel : ViewModel, ISource, IDisposable } } - private bool TryConvert(out string wavFilePath) => TryConvert(SelectedAudioFile.FilePath, SelectedAudioFile.Data, out wavFilePath); - public static bool TryConvert(string inputFilePath, byte[] inputFileData, out string wavFilePath) + private bool TryConvert(out string wavFilePath) => TryConvert(SelectedAudioFile.FilePath, SelectedAudioFile.Data, out wavFilePath, true); + public static bool TryConvert(string inputFilePath, byte[] inputFileData, out string wavFilePath, bool updateUi = false) { wavFilePath = string.Empty; var vgmFilePath = Path.Combine(UserSettings.Default.OutputDirectory, ".data", "test.exe"); if (!File.Exists(vgmFilePath)) { vgmFilePath = Path.Combine(UserSettings.Default.OutputDirectory, ".data", "vgmstream-cli.exe"); - if (!File.Exists(vgmFilePath)) return false; + if (!File.Exists(vgmFilePath)) + { + Log.Error("Failed to convert {InputFilePath}, vgmstream is missing", inputFilePath); + FLogger.Append(ELog.Error, () => + { + FLogger.Text("Failed to convert audio because vgmstream is missing. See: ", Constants.WHITE); + FLogger.Link("→ link ←", Constants.AUDIO_ISSUE_LINK, true); + }); + return false; + } } Directory.CreateDirectory(inputFilePath.SubstringBeforeLast("/")); @@ -679,7 +698,22 @@ public class AudioPlayerViewModel : ViewModel, ISource, IDisposable vgmProcess?.WaitForExit(5000); File.Delete(inputFilePath); - return vgmProcess?.ExitCode == 0 && File.Exists(wavFilePath); + + var success = vgmProcess?.ExitCode == 0 && File.Exists(wavFilePath); + if (!success) + { + Log.Error("Failed to convert {InputFilePath} to .wav format", inputFilePath); + if (updateUi) + { + FLogger.Append(ELog.Error, () => + { + FLogger.Text("Failed to convert audio to .wav format. See: ", Constants.WHITE); + FLogger.Link("→ link ←", Constants.AUDIO_ISSUE_LINK, true); + }); + } + } + + return success; } private bool TryDecode(string extension, out string rawFilePath) @@ -688,6 +722,12 @@ public class AudioPlayerViewModel : ViewModel, ISource, IDisposable var decoderPath = Path.Combine(UserSettings.Default.OutputDirectory, ".data", $"{extension}dec.exe"); if (!File.Exists(decoderPath)) { + Log.Error("Failed to convert {FilePath}, rada decoder is missing", SelectedAudioFile.FilePath); + FLogger.Append(ELog.Error, () => + { + FLogger.Text("Failed to convert audio because rada decoder is missing. See: ", Constants.WHITE); + FLogger.Link("→ link ←", Constants.RADA_ISSUE_LINK, true); + }); return false; } diff --git a/FModel/ViewModels/CUE4ParseViewModel.cs b/FModel/ViewModels/CUE4ParseViewModel.cs index df7cbbfa..6b13ecc4 100644 --- a/FModel/ViewModels/CUE4ParseViewModel.cs +++ b/FModel/ViewModels/CUE4ParseViewModel.cs @@ -1179,7 +1179,7 @@ public class CUE4ParseViewModel : ViewModel case USoundWave when isNone || saveAudio: { // If UAkMediaAsset exists in the same package it should be used to handle the audio instead (because it contains actual audio name) - if (pointer.Object.Value is UAkMediaAssetData dataObj && dataObj.Outer is UAkMediaAsset) + if (pointer.Object.Value is UAkMediaAssetData dataObj && dataObj.Outer.Object.Value is UAkMediaAsset) return false; var shouldDecompress = UserSettings.Default.CompressedAudioMode == ECompressedAudio.PlayDecompressed; @@ -1196,7 +1196,7 @@ public class CUE4ParseViewModel : ViewModel } case UAkMediaAsset when (isNone || saveAudio) && pointer.Object.Value is UAkMediaAsset akMediaAsset: { - var audioName = akMediaAsset.MediaName; + var audioName = akMediaAsset.MediaName ?? akMediaAsset.Name; if (akMediaAsset.CurrentMediaAssetData?.TryLoad(out var akMediaAssetData) is true) { var shouldDecompress = UserSettings.Default.CompressedAudioMode is ECompressedAudio.PlayDecompressed; @@ -1416,25 +1416,15 @@ public class CUE4ParseViewModel : ViewModel writer.Flush(); } + bool conversionSuccess = true; if (UserSettings.Default.ConvertAudioOnBulkExport) { - AudioPlayerViewModel.TryConvert(savedAudioPath, data, out string wavFilePath); - if (!string.IsNullOrEmpty(wavFilePath)) - { - savedAudioPath = wavFilePath; - } - else if (updateUi) - { - FLogger.Append(ELog.Error, () => - { - FLogger.Text("Failed to convert audio to WAV format, aborting extraction.", Constants.WHITE, true); - }); - return; - } + conversionSuccess = AudioPlayerViewModel.TryConvert(savedAudioPath, data, out string wavFilePath); + if (conversionSuccess) savedAudioPath = wavFilePath; } Log.Information("Successfully saved {FilePath}", savedAudioPath); - if (updateUi) + if (updateUi && conversionSuccess) { FLogger.Append(ELog.Information, () => { diff --git a/FModel/ViewModels/GameFileViewModel.cs b/FModel/ViewModels/GameFileViewModel.cs index 1e837b92..689e315d 100644 --- a/FModel/ViewModels/GameFileViewModel.cs +++ b/FModel/ViewModels/GameFileViewModel.cs @@ -356,6 +356,7 @@ public class GameFileViewModel(GameFile asset) : ViewModel case "csv": AssetCategory = EAssetCategory.Data; break; + case "stinfo": case "ushaderbytecode": AssetCategory = EAssetCategory.ByteCode; break; diff --git a/FModel/ViewModels/GameSelectorViewModel.cs b/FModel/ViewModels/GameSelectorViewModel.cs index 9cd43d67..369945b2 100644 --- a/FModel/ViewModels/GameSelectorViewModel.cs +++ b/FModel/ViewModels/GameSelectorViewModel.cs @@ -4,6 +4,8 @@ using Serilog; using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -67,12 +69,103 @@ public class GameSelectorViewModel : ViewModel public void AddUndetectedDir(string gameDirectory) => AddUndetectedDir(gameDirectory.SubstringAfterLast('\\'), gameDirectory); public void AddUndetectedDir(string gameName, string gameDirectory) { - var setting = DirectorySettings.Default(gameName, gameDirectory, true); + if (TryDetectUeVersion(gameDirectory, out var ueVersion, out var newGameDirectory)) + { + // gameDirectory = newGameDirectory; // directory was changed to point to the correct paks folder + } + + var setting = DirectorySettings.Default(gameName, gameDirectory, true, ueVersion); UserSettings.Default.PerDirectory[gameDirectory] = setting; _detectedDirectories.Add(setting); SelectedDirectory = DetectedDirectories.Last(); } + private bool TryDetectUeVersion(string gameDirectory, out EGame ueVersion, [MaybeNullWhen(false)] out string newGameDirectory) + { + var targetGameDir = gameDirectory; + if (!targetGameDir.EndsWith("Paks", StringComparison.OrdinalIgnoreCase)) + { + var dirs = Directory.GetDirectories(targetGameDir, "Paks", SearchOption.AllDirectories); + var paksDir = dirs.Length == 1 ? dirs[0] : dirs.FirstOrDefault(x => !x.EndsWith("Engine\\Programs\\CrashReportClient\\Content\\Paks")); + if (!string.IsNullOrEmpty(paksDir)) + { + Log.Warning("Selected directory \"{GameDirectory}\" does not end with \"Paks\". Looking in \"{PaksDir}\" instead.", targetGameDir, paksDir); + targetGameDir = paksDir; + } + + if (Directory.GetFiles(gameDirectory, "*.exe") is { Length: 1 } exe && TryGetUeVersionFromExe(exe[0], out ueVersion)) + { + // we checked the exe in the original directory, the BootstrapPackagedGame one + // but we still want c4p to use the paks folder as the game directory (if any), not the original one + newGameDirectory = targetGameDir; + Log.Information("Detected UE version {UeVersion} from \"{Exe}\"", ueVersion, exe[0]); + return true; + } + } + + // past this point, we assume targetGameDir is the correct Paks folder + newGameDirectory = targetGameDir; + var projectDir = Path.Combine(targetGameDir, "..", ".."); + + var projectBinariesDir = Path.Combine(projectDir, "Binaries", "Win64"); + if (Directory.Exists(projectBinariesDir)) + { + if (Directory.GetFiles(projectBinariesDir, "*-Win64-Shipping.exe") is { Length: > 0 } shipping) + { + foreach (var exe in shipping) + { + if (TryGetUeVersionFromExe(exe, out ueVersion)) + { + Log.Information("Detected UE version {UeVersion} from \"{Exe}\"", ueVersion, exe); + return true; + } + } + } + else if (Directory.GetFiles(projectBinariesDir, "*.exe") is { Length: < 3 } exes) + { + foreach (var exe in exes) + { + if (TryGetUeVersionFromExe(exe, out ueVersion)) + { + Log.Information("Detected UE version {UeVersion} from \"{Exe}\"", ueVersion, exe); + return true; + } + } + } + } + + var crashReportClientExe = Path.Combine(projectDir, "..", "Engine", "Binaries", "Win64", "CrashReportClient.exe"); + if (File.Exists(crashReportClientExe) && TryGetUeVersionFromExe(crashReportClientExe, out ueVersion)) + { + Log.Information("Detected UE version {UeVersion} from \"{Exe}\"", ueVersion, crashReportClientExe); + return true; + } + + ueVersion = EGame.GAME_UE4_LATEST; + Log.Warning("Failed to detect UE version for \"{GameDirectory}\".", gameDirectory); + return false; + } + + private bool TryGetUeVersionFromExe(string exePath, out EGame ueVersion) + { + ueVersion = EGame.GAME_UE4_LATEST; + try + { + var info = FileVersionInfo.GetVersionInfo(exePath); + ueVersion = info.FileMajorPart switch + { + 4 => (EGame) Math.Min((uint)(GameUtils.GameUe4Base + (info.FileMinorPart << 16)), (uint) EGame.GAME_UE4_LATEST), + 5 => (EGame) Math.Min((uint)(GameUtils.GameUe5Base + (info.FileMinorPart << 16)), (uint) EGame.GAME_UE5_LATEST), + _ => throw new InvalidOperationException($"Unsupported UE major version {info.FileMajorPart} detected from {exePath}") + }; + return true; + } + catch + { + return false; + } + } + public void DeleteSelectedGame() { UserSettings.Default.PerDirectory.Remove(SelectedDirectory.GameDirectory); // should not be a problem diff --git a/FModel/ViewModels/ThreadWorkerViewModel.cs b/FModel/ViewModels/ThreadWorkerViewModel.cs index c1fc7b44..59c49f19 100644 --- a/FModel/ViewModels/ThreadWorkerViewModel.cs +++ b/FModel/ViewModels/ThreadWorkerViewModel.cs @@ -1,6 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using CUE4Parse.UE4.Exceptions; using FModel.Framework; using FModel.Services; using FModel.Views.Resources.Controls; @@ -100,7 +101,26 @@ public class ThreadWorkerViewModel : ViewModel CurrentCancellationTokenSource = null; // kill token Log.Error("{Exception}", e); - FLogger.Append(e); + switch (e) + { + case MappingException: + FLogger.Append(ELog.Error, () => + { + FLogger.Text("Package has unversioned properties but mapping file (.usmap) is missing, can't serialize. See: ", Constants.WHITE); + FLogger.Link("→ link ←", Constants.MAPPING_ISSUE_LINK, true); + }); + break; + case VersionException v: // Error might be unrelated to version, but it's usually the case + FLogger.Append(ELog.Error, () => + { + FLogger.Text(v.Message[..^1] + ", can't serialize. Make sure the correct UE version is configured. See: ", Constants.WHITE); + FLogger.Link("→ link ←", Constants.VERSION_ISSUE_LINK, true); + }); + break; + default: + FLogger.Append(e); + break; + } return; } } diff --git a/FModel/Views/Resources/Controls/ContextMenus/FileContextMenu.xaml b/FModel/Views/Resources/Controls/ContextMenus/FileContextMenu.xaml index b72206f3..109b71fb 100644 --- a/FModel/Views/Resources/Controls/ContextMenus/FileContextMenu.xaml +++ b/FModel/Views/Resources/Controls/ContextMenus/FileContextMenu.xaml @@ -110,8 +110,7 @@ - + + Command="{Binding RightClickMenuCommand}"> diff --git a/FModel/Views/Resources/Controls/Rtb/CustomRichTextBox.cs b/FModel/Views/Resources/Controls/Rtb/CustomRichTextBox.cs index 8b995c01..56478eb9 100644 --- a/FModel/Views/Resources/Controls/Rtb/CustomRichTextBox.cs +++ b/FModel/Views/Resources/Controls/Rtb/CustomRichTextBox.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.Windows; using System.Windows.Controls; @@ -126,13 +126,40 @@ public class FLogger : ITextFormatter { NavigateUri = new Uri(url), OverridesDefaultStyle = true, - Style = new Style(typeof(Hyperlink)) { Setters = + Style = new Style(typeof(Hyperlink)) { - new Setter(FrameworkContentElement.CursorProperty, Cursors.Hand), - new Setter(TextBlock.TextDecorationsProperty, TextDecorations.Underline), - new Setter(TextElement.ForegroundProperty, Brushes.Cornsilk) - }} - }.Click += (sender, _) => Process.Start("explorer.exe", $"/select, \"{((Hyperlink)sender).NavigateUri.AbsoluteUri}\""); + Setters = + { + new Setter(FrameworkContentElement.CursorProperty, Cursors.Hand), + new Setter(TextElement.ForegroundProperty, Brushes.Goldenrod), + new Setter(TextElement.FontWeightProperty, FontWeights.Bold) + }, + Triggers = + { + new Trigger + { + Property = UIElement.IsMouseOverProperty, + Value = true, + Setters = + { + new Setter(TextElement.ForegroundProperty, Brushes.Gold), + new Setter(TextBlock.TextDecorationsProperty, TextDecorations.Underline) + } + } + } + } + }.Click += (sender, _) => + { + var uri = ((Hyperlink) sender).NavigateUri; + if (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) + { + Process.Start(new ProcessStartInfo(uri.AbsoluteUri) { UseShellExecute = true }); + } + else + { + Process.Start("explorer.exe", $"/select, \"{uri.AbsoluteUri}\""); + } + }; } finally { diff --git a/FModel/Views/Resources/Resources.xaml b/FModel/Views/Resources/Resources.xaml index d9f04f99..c99c9de0 100644 --- a/FModel/Views/Resources/Resources.xaml +++ b/FModel/Views/Resources/Resources.xaml @@ -926,8 +926,7 @@ - + diff --git a/FModel/Views/SearchView.xaml b/FModel/Views/SearchView.xaml index 3b945aea..1b37d87a 100644 --- a/FModel/Views/SearchView.xaml +++ b/FModel/Views/SearchView.xaml @@ -1,7 +1,6 @@ - + @@ -556,8 +554,7 @@ - + diff --git a/FModel/Views/SettingsView.xaml b/FModel/Views/SettingsView.xaml index 179a60c6..4f60506d 100644 --- a/FModel/Views/SettingsView.xaml +++ b/FModel/Views/SettingsView.xaml @@ -44,7 +44,6 @@ - @@ -223,51 +222,46 @@