diff --git a/CUE4Parse b/CUE4Parse index 4fb74359..267d479e 160000 --- a/CUE4Parse +++ b/CUE4Parse @@ -1 +1 @@ -Subproject commit 4fb7435973fc57bfb78577c971d776f7577440cf +Subproject commit 267d479e721b424e2e913ec195c596b660aa1d09 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/Creator/Bases/FN/BaseIcon.cs b/FModel/Creator/Bases/FN/BaseIcon.cs index 985b100a..edf73fce 100644 --- a/FModel/Creator/Bases/FN/BaseIcon.cs +++ b/FModel/Creator/Bases/FN/BaseIcon.cs @@ -36,6 +36,7 @@ public class BaseIcon : UCreator if (Object.TryGetValue(out FInstancedStruct[] dataList, "DataList")) { + GetRarity(dataList); GetSeries(dataList); Preview = Utils.GetBitmap(dataList); } @@ -139,6 +140,12 @@ public class BaseIcon : UCreator GetSeries(export); } + private void GetRarity(FInstancedStruct[] s) + { + if (s.FirstOrDefault(d => d.NonConstStruct?.TryGetValue(out EFortRarity _, "Rarity") == true) is { } dl) + GetRarity(dl.NonConstStruct.Get("Rarity")); + } + private void GetSeries(FInstancedStruct[] s) { if (s.FirstOrDefault(d => d.NonConstStruct?.TryGetValue(out FPackageIndex _, "Series") == true) is { } dl) diff --git a/FModel/Enums.cs b/FModel/Enums.cs index 5e06621d..9f435a10 100644 --- a/FModel/Enums.cs +++ b/FModel/Enums.cs @@ -27,6 +27,7 @@ public enum SettingsOut public enum EStatusKind { Ready, // ready + Configuring, // waiting for user input Loading, // doing stuff Stopping, // trying to stop Stopped, // stopped @@ -107,6 +108,7 @@ public enum EBulkType Animations = 1 << 4, Audio = 1 << 5, Code = 1 << 6, + Raw = 1 << 7, } public enum EAssetCategory : uint @@ -158,4 +160,5 @@ public enum EAssetCategory : uint Particle = AssetCategoryExtensions.CategoryBase + (9 << 16), GameSpecific = AssetCategoryExtensions.CategoryBase + (10 << 16), Borderlands = GameSpecific + 1, + Aion2 = GameSpecific + 2, } diff --git a/FModel/Helper.cs b/FModel/Helper.cs index 58d51545..05f1530b 100644 --- a/FModel/Helper.cs +++ b/FModel/Helper.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Linq; using System.Runtime.CompilerServices; using System.Windows; @@ -62,7 +63,7 @@ public static class Helper GetOpenedWindow(windowName).Close(); } - private static bool IsWindowOpen(string name = "") where T : Window + public static bool IsWindowOpen(string name = "") where T : Window { return string.IsNullOrEmpty(name) ? Application.Current.Windows.OfType().Any() @@ -111,4 +112,24 @@ public static class Helper const float ratio = 180f / MathF.PI; return radians * ratio; } + + public static string GetGameName(string path) + { + // install_folder/ + // ├─ Engine/ + // ├─ GameName/ + // │ ├─ Binaries/ + // │ ├─ Content/ + // │ │ ├─ Paks/ + // our goal is to get the GameName folder + var dir = new DirectoryInfo(path); + if (dir.Name.Equals("Paks", StringComparison.InvariantCulture) && dir.Parent is { Parent: not null } && + dir.Parent.Name.Equals("Content", StringComparison.InvariantCulture) && + dir.Parent.Parent.GetDirectories().Any(x => x.Name == "Binaries")) + { + return dir.Parent.Parent.Name; + } + + return dir.Name; + } } diff --git a/FModel/MainWindow.xaml b/FModel/MainWindow.xaml index 8dc4c0b0..46d2ddb5 100644 --- a/FModel/MainWindow.xaml +++ b/FModel/MainWindow.xaml @@ -12,7 +12,8 @@ xmlns:adonisExtensions="clr-namespace:AdonisUI.Extensions;assembly=AdonisUI" WindowStartupLocation="CenterScreen" Closing="OnClosing" Loaded="OnLoaded" PreviewKeyDown="OnWindowKeyDown" Height="{Binding Source={x:Static SystemParameters.MaximizedPrimaryScreenHeight}, Converter={converters:RatioConverter}, ConverterParameter='0.95'}" - Width="{Binding Source={x:Static SystemParameters.MaximizedPrimaryScreenWidth}, Converter={converters:RatioConverter}, ConverterParameter='0.90'}"> + Width="{Binding Source={x:Static SystemParameters.MaximizedPrimaryScreenWidth}, Converter={converters:RatioConverter}, ConverterParameter='0.90'}" + AllowDrop="True"> @@ -628,6 +629,10 @@ + + + + @@ -709,5 +714,7 @@ + diff --git a/FModel/MainWindow.xaml.cs b/FModel/MainWindow.xaml.cs index 2c532723..b36da622 100644 --- a/FModel/MainWindow.xaml.cs +++ b/FModel/MainWindow.xaml.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel; +using System.IO; using System.Linq; using System.Threading.Tasks; using System.Windows; diff --git a/FModel/Settings/UserSettings.cs b/FModel/Settings/UserSettings.cs index ebf9e534..44104264 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 { @@ -461,13 +454,6 @@ namespace FModel.Settings set => SetProperty(ref _cameraMode, value); } - private int _wwiseMaxBnkPrefetch; - public int WwiseMaxBnkPrefetch - { - get => _wwiseMaxBnkPrefetch; - set => SetProperty(ref _wwiseMaxBnkPrefetch, value); - } - private int _previewMaxTextureSize = 1024; public int PreviewMaxTextureSize { diff --git a/FModel/ViewModels/ApplicationViewModel.cs b/FModel/ViewModels/ApplicationViewModel.cs index 7822aa70..51aa8bfe 100644 --- a/FModel/ViewModels/ApplicationViewModel.cs +++ b/FModel/ViewModels/ApplicationViewModel.cs @@ -141,8 +141,10 @@ public class ApplicationViewModel : ViewModel if (!bAlreadyLaunched && UserSettings.Default.PerDirectory.TryGetValue(gameDirectory, out var currentDir)) return currentDir; + Status.SetStatus(EStatusKind.Configuring); var gameLauncherViewModel = new GameSelectorViewModel(gameDirectory); var result = new DirectorySelector(gameLauncherViewModel).ShowDialog(); + Status.SetStatus(EStatusKind.Ready); if (!result.HasValue || !result.Value) return null; UserSettings.Default.GameDirectory = gameLauncherViewModel.SelectedDirectory.GameDirectory; @@ -155,6 +157,35 @@ public class ApplicationViewModel : ViewModel return null; } + public DirectorySettings AddGameDirectory(string directory) + { + if (Status.Kind is EStatusKind.Configuring) + { + var directorySelector = Helper.GetWindow("Directory Selector", null); + directorySelector.AddManualGame(directory); + return null; + } + else + { + Status.SetStatus(EStatusKind.Configuring); + var gameLauncherViewModel = new GameSelectorViewModel(UserSettings.Default.GameDirectory); + var directorySelector = new DirectorySelector(gameLauncherViewModel); + directorySelector.AddManualGame(directory); + var result = directorySelector.ShowDialog(); + Status.SetStatus(EStatusKind.Ready); + if (!result.HasValue || !result.Value) + return null; + + UserSettings.Default.GameDirectory = gameLauncherViewModel.SelectedDirectory.GameDirectory; + if (UserSettings.Default.CurrentDir.Equals(gameLauncherViewModel.SelectedDirectory)) + return gameLauncherViewModel.SelectedDirectory; + + UserSettings.Default.CurrentDir = gameLauncherViewModel.SelectedDirectory; + RestartWithWarning(); + return null; + } + } + public void RestartWithWarning() { MessageBox.Show("It looks like you just changed something.\nFModel will restart to apply your changes.", "Uh oh, a restart is needed", MessageBoxButton.OK, MessageBoxImage.Warning); @@ -246,7 +277,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..802e87ab 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); + }); + } }); } @@ -330,12 +340,8 @@ public class AudioPlayerViewModel : ViewModel, ISource, IDisposable Directory.CreateDirectory(path.SubstringBeforeLast('/')); } - using (var stream = new FileStream(path, FileMode.Create, FileAccess.Write)) - using (var writer = new BinaryWriter(stream)) - { - writer.Write(fileToSave.Data); - writer.Flush(); - } + using var stream = new FileStream(path, FileMode.Create, FileAccess.Write); + stream.Write(fileToSave.Data); if (File.Exists(path)) { @@ -654,32 +660,42 @@ 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("/")); - File.WriteAllBytes(inputFilePath, inputFileData); + var success = TryConvertToWAV(inputFilePath, inputFileData, vgmFilePath, true, out var tempWavFilePath); - wavFilePath = Path.ChangeExtension(inputFilePath, ".wav"); - var vgmProcess = Process.Start(new ProcessStartInfo + if (!success) { - FileName = vgmFilePath, - Arguments = $"-o \"{wavFilePath}\" \"{inputFilePath}\"", - UseShellExecute = false, - CreateNoWindow = true - }); - vgmProcess?.WaitForExit(5000); + Log.Error("Failed to convert {InputFilePath} to .wav format", Path.GetFileName(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); + }); + } + } - File.Delete(inputFilePath); - return vgmProcess?.ExitCode == 0 && File.Exists(wavFilePath); + return success; } private bool TryDecode(string extension, out string rawFilePath) @@ -688,23 +704,46 @@ 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; } - Directory.CreateDirectory(SelectedAudioFile.FilePath.SubstringBeforeLast("/")); - File.WriteAllBytes(SelectedAudioFile.FilePath, SelectedAudioFile.Data); + return TryConvertToWAV(SelectedAudioFile.FilePath, SelectedAudioFile.Data, decoderPath, false, out rawFilePath); + } - rawFilePath = Path.ChangeExtension(SelectedAudioFile.FilePath, ".wav"); - var decoderProcess = Process.Start(new ProcessStartInfo + private static bool TryConvertToWAV(string inputFilePath, byte[] inputFileData, string converterPath, bool usevgmstream, out string wavFilePath) + { + wavFilePath = Path.ChangeExtension(inputFilePath, ".wav"); + var directory = Path.GetDirectoryName(inputFilePath); + Directory.CreateDirectory(directory); + + var tempfile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + Path.GetExtension(inputFilePath)); + File.WriteAllBytes(tempfile, inputFileData); + + var tempWavFilePath = Path.ChangeExtension(tempfile, ".wav"); + + var process = Process.Start(new ProcessStartInfo { - FileName = decoderPath, - Arguments = $"-i \"{SelectedAudioFile.FilePath}\" -o \"{rawFilePath}\"", + FileName = converterPath, + Arguments = usevgmstream ? $"-o \"{tempWavFilePath}\" \"{tempfile}\"" : $"-i \"{tempfile}\" -o \"{tempWavFilePath}\"", UseShellExecute = false, CreateNoWindow = true }); - decoderProcess?.WaitForExit(5000); + process?.WaitForExit(5000); - File.Delete(SelectedAudioFile.FilePath); - return decoderProcess?.ExitCode == 0 && File.Exists(rawFilePath); + File.Delete(tempfile); + + var success = process?.ExitCode == 0 && File.Exists(tempWavFilePath); + if (success) + { + File.Move(tempWavFilePath, wavFilePath, true); + } + + return success; } } diff --git a/FModel/ViewModels/CUE4ParseViewModel.cs b/FModel/ViewModels/CUE4ParseViewModel.cs index d76aae47..3fa86b0f 100644 --- a/FModel/ViewModels/CUE4ParseViewModel.cs +++ b/FModel/ViewModels/CUE4ParseViewModel.cs @@ -162,6 +162,9 @@ public class CUE4ParseViewModel : ViewModel public CriWareProvider CriWareProvider => _criWareProviderLazy?.Value; public ConcurrentBag UnknownExtensions = []; + public int ExportedCount; + public int FailedExportCount; + public CUE4ParseViewModel() { var currentDir = UserSettings.Default.CurrentDir; @@ -322,7 +325,7 @@ public class CUE4ParseViewModel : ViewModel } Provider.Initialize(); - _wwiseProviderLazy = new Lazy(() => new WwiseProvider(Provider, UserSettings.Default.GameDirectory, UserSettings.Default.WwiseMaxBnkPrefetch)); + _wwiseProviderLazy = new Lazy(() => new WwiseProvider(Provider, UserSettings.Default.GameDirectory)); _fmodProviderLazy = new Lazy(() => new FModProvider(Provider, UserSettings.Default.GameDirectory)); _criWareProviderLazy = new Lazy(() => new CriWareProvider(Provider, UserSettings.Default.GameDirectory)); Log.Information($"{Provider.Versions.Game} ({Provider.Versions.Platform}) | Archives: x{Provider.UnloadedVfs.Count} | AES: x{Provider.RequiredKeys.Count} | Loose Files: x{Provider.Files.Count}"); @@ -491,7 +494,7 @@ public class CUE4ParseViewModel : ViewModel var ioStoreOnDemandPath = Path.Combine(UserSettings.Default.GameDirectory, "..\\..\\..\\Cloud", inst[0].Value.SubstringAfterLast("/").SubstringBefore("\"")); if (!File.Exists(ioStoreOnDemandPath)) return; - await Provider.RegisterVfsAsync(new IoChunkToc(ioStoreOnDemandPath)); + await Provider.RegisterVfsAsync(new IoChunkToc(ioStoreOnDemandPath, Provider.Versions)); var onDemandCount = await Provider.MountAsync(); FLogger.Append(ELog.Information, () => FLogger.Text($"{onDemandCount} on-demand archive{(onDemandCount > 1 ? "s" : "")} streamed via epicgames.com", Constants.WHITE, true)); @@ -598,6 +601,9 @@ public class CUE4ParseViewModel : ViewModel foreach (var f in folder.Folders) ExportFolder(cancellationToken, f); } + public void ExtractFolder(CancellationToken cancellationToken, TreeItem folder, EBulkType bulk) + => BulkFolder(cancellationToken, folder, asset => Extract(cancellationToken, asset, TabControl.HasNoTabs, bulk)); + public void ExtractFolder(CancellationToken cancellationToken, TreeItem folder) => BulkFolder(cancellationToken, folder, asset => Extract(cancellationToken, asset, TabControl.HasNoTabs)); @@ -802,13 +808,13 @@ public class CUE4ParseViewModel : ViewModel case "pck": { var archive = entry.CreateReader(); - var wwise = new WwiseReader(archive); + var wwise = new WwiseReader(archive, new WwiseGameFileSource(entry)); TabControl.SelectedTab.SetDocumentText(JsonConvert.SerializeObject(wwise, Formatting.Indented), saveProperties, updateUi); var medias = WwiseProvider.ExtractBankSounds(wwise); foreach (var media in medias) { - SaveAndPlaySound(cancellationToken, media.OutputPath, media.Extension, media.Data, saveAudio, updateUi); + SaveAndPlaySound(cancellationToken, media.OutputPath, media.Extension, media.Data?.GetData() ?? [], saveAudio, updateUi); } break; @@ -964,7 +970,7 @@ public class CUE4ParseViewModel : ViewModel } else if (entry.NameWithoutExtension.Equals("L10NString")) { - var l10nData = new FAion2L10NFile(entry); + var l10nData = new FAion2L10NFile(entry, Provider); TabControl.SelectedTab.SetDocumentText(JsonConvert.SerializeObject(l10nData, Formatting.Indented), saveProperties, updateUi); } else @@ -1115,7 +1121,8 @@ public class CUE4ParseViewModel : ViewModel case UExternalSource when (isNone || saveAudio) && pointer.Object.Value is UExternalSource externalSource: { var audioName = Path.GetFileNameWithoutExtension(externalSource.ExternalSourcePath); - SaveAndPlaySound(cancellationToken, audioName, "wem", externalSource.Data?.WemFile ?? [], saveAudio, updateUi); + var outputPath = Path.Combine(TabControl.SelectedTab.Entry.PathWithoutExtension.Replace('\\', '/').SubstringBeforeLast('/'), audioName); + SaveAndPlaySound(cancellationToken, outputPath, "wem", externalSource.Data?.WemFile?.GetData() ?? [], saveAudio, updateUi); return false; } case UAkAudioBank when (isNone || saveAudio) && pointer.Object.Value is UAkAudioBank soundBank: @@ -1123,7 +1130,7 @@ public class CUE4ParseViewModel : ViewModel var extractedSounds = WwiseProvider.ExtractBankSounds(soundBank); foreach (var sound in extractedSounds) { - SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data, saveAudio, updateUi); + SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data?.GetData() ?? [], saveAudio, updateUi); } return false; } @@ -1132,7 +1139,7 @@ public class CUE4ParseViewModel : ViewModel var extractedSounds = WwiseProvider.ExtractAudioEventSounds(audioEvent); foreach (var sound in extractedSounds) { - SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data, saveAudio, updateUi); + SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data?.GetData() ?? [], saveAudio, updateUi); } return false; } @@ -1179,7 +1186,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,13 +1203,14 @@ public class CUE4ParseViewModel : ViewModel } case UAkMediaAsset when (isNone || saveAudio) && pointer.Object.Value is UAkMediaAsset akMediaAsset: { - var audioName = akMediaAsset.MediaName; - if (akMediaAsset.CurrentMediaAssetData?.TryLoad(out var akMediaAssetData) is true) + var audioName = akMediaAsset.MediaName ?? akMediaAsset.Name; + var outputPath = Path.Combine(TabControl.SelectedTab.Entry.PathWithoutExtension.Replace('\\', '/').SubstringBeforeLast('/'), audioName); + if (akMediaAsset.CurrentMediaAssetData?.ResolvedObject?.Object?.Value is UAkMediaAssetData akMediaAssetData) { var shouldDecompress = UserSettings.Default.CompressedAudioMode is ECompressedAudio.PlayDecompressed; akMediaAssetData.Decode(shouldDecompress, out var audioFormat, out var data); - SaveAndPlaySound(cancellationToken, audioName, audioFormat, data, saveAudio, updateUi); + SaveAndPlaySound(cancellationToken, outputPath, audioFormat, data, saveAudio, updateUi); } return false; } @@ -1211,14 +1219,15 @@ public class CUE4ParseViewModel : ViewModel var shouldDecompress = UserSettings.Default.CompressedAudioMode is ECompressedAudio.PlayDecompressed; foreach (var mediaIndex in akAudioEventData.MediaList) { - if (mediaIndex.TryLoad(out var akMediaAsset)) + if (mediaIndex.ResolvedObject?.Object?.Value is UAkMediaAsset akMediaAsset) { - if (akMediaAsset.CurrentMediaAssetData?.TryLoad(out var akMediaAssetData) is true) + if (akMediaAsset.CurrentMediaAssetData?.ResolvedObject?.Object?.Value is UAkMediaAssetData akMediaAssetData) { var audioName = akMediaAsset.MediaName ?? $"{akAudioEventData.Outer.Name} ({akMediaAsset.ID})"; + var outputPath = Path.Combine(TabControl.SelectedTab.Entry.PathWithoutExtension.Replace('\\', '/').SubstringBeforeLast('/'), audioName); akMediaAssetData.Decode(shouldDecompress, out var audioFormat, out var data); - SaveAndPlaySound(cancellationToken, audioName, audioFormat, data, saveAudio, updateUi); + SaveAndPlaySound(cancellationToken, outputPath, audioFormat, data, saveAudio, updateUi); } } } @@ -1230,7 +1239,7 @@ public class CUE4ParseViewModel : ViewModel var extractedSounds = WwiseProvider.ExtractDialogBorderlands3(dialogPerformanceData); foreach (var sound in extractedSounds) { - SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data, saveAudio, updateUi); + SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data?.GetData() ?? [], saveAudio, updateUi); } return false; } @@ -1240,12 +1249,13 @@ public class CUE4ParseViewModel : ViewModel if (Provider.Versions.Game is not EGame.GAME_Borderlands4) return false; + var ownerDirectory = WwiseProvider.GetOwnerDirectory(faceFXAnimSet); foreach (var faceFXAnimData in faceFXAnimSet.FaceFXAnimDataList) { - var extractedSounds = WwiseProvider.ExtractAudioEventBorderlands4(faceFXAnimData.ID.Name, false); + var extractedSounds = WwiseProvider.ExtractAudioEventBorderlands4(ownerDirectory, faceFXAnimData.ID.Name, false); foreach (var sound in extractedSounds) { - SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data, saveAudio, updateUi); + SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data?.GetData() ?? [], saveAudio, updateUi); } } @@ -1254,12 +1264,13 @@ public class CUE4ParseViewModel : ViewModel // Borderlands 4 case UGbxGraphAsset when (isNone || saveAudio) && pointer.Object.Value is UGbxGraphAsset gbxGraphAsset: { + var ownerDirectory = WwiseProvider.GetOwnerDirectory(gbxGraphAsset); foreach (var (eventName, useSoundTag) in GbxAudioUtil.GetAndClearEvents()) { - var extractedSounds = WwiseProvider.ExtractAudioEventBorderlands4(eventName, useSoundTag); + var extractedSounds = WwiseProvider.ExtractAudioEventBorderlands4(ownerDirectory, eventName, useSoundTag); foreach (var sound in extractedSounds) { - SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data, saveAudio, updateUi); + SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data?.GetData() ?? [], saveAudio, updateUi); } } @@ -1399,42 +1410,38 @@ public class CUE4ParseViewModel : ViewModel TabControl.SelectedTab.SetDocumentText(cpp, false, false); } - private void SaveAndPlaySound(CancellationToken cancellationToken, string fullPath, string ext, byte[] data, bool isBulk, bool updateUi) + private void SaveAndPlaySound(CancellationToken cancellationToken, string fullPath, string ext, byte[] data, bool saveAudio, bool updateUi) { if (fullPath.StartsWith('/')) fullPath = fullPath[1..]; var savedAudioPath = Path.Combine(UserSettings.Default.AudioDirectory, UserSettings.Default.KeepDirectoryStructure ? fullPath : fullPath.SubstringAfterLast('/')).Replace('\\', '/') + $".{ext.ToLowerInvariant()}"; - if (isBulk) + if (saveAudio) { cancellationToken.ThrowIfCancellationRequested(); - Directory.CreateDirectory(savedAudioPath.SubstringBeforeLast('/')); - using var stream = new FileStream(savedAudioPath, FileMode.Create, FileAccess.Write); - using (var writer = new BinaryWriter(stream)) - { - writer.Write(data); - writer.Flush(); - } + var directory = Path.GetDirectoryName(savedAudioPath); + Directory.CreateDirectory(directory); + bool conversionSuccess = true; if (UserSettings.Default.ConvertAudioOnBulkExport) { - AudioPlayerViewModel.TryConvert(savedAudioPath, data, out string wavFilePath); - if (!string.IsNullOrEmpty(wavFilePath)) - { + if (AudioPlayerViewModel.TryConvert(savedAudioPath, data, out string wavFilePath)) savedAudioPath = wavFilePath; - } - else if (updateUi) + else { - FLogger.Append(ELog.Error, () => - { - FLogger.Text("Failed to convert audio to WAV format, aborting extraction.", Constants.WHITE, true); - }); + Interlocked.Increment(ref FailedExportCount); return; } } + else + { + using var stream = new FileStream(savedAudioPath, FileMode.Create, FileAccess.Write); + stream.Write(data); + } + Interlocked.Increment(ref ExportedCount); Log.Information("Successfully saved {FilePath}", savedAudioPath); - if (updateUi) + if (updateUi && conversionSuccess) { FLogger.Append(ELog.Information, () => { @@ -1446,6 +1453,9 @@ public class CUE4ParseViewModel : ViewModel return; } + if (!updateUi) + return; + // TODO // since we are currently in a thread, the audio player's lifetime (memory-wise) will keep the current thread up and running until fmodel itself closes // the solution would be to kill the current thread at this line and then open the audio player without "Application.Current.Dispatcher.Invoke" @@ -1463,6 +1473,7 @@ public class CUE4ParseViewModel : ViewModel var toSaveDirectory = new DirectoryInfo(UserSettings.Default.ModelDirectory); if (toSave.TryWriteToDir(toSaveDirectory, out var label, out var savedFilePath)) { + Interlocked.Increment(ref ExportedCount); Log.Information("Successfully saved {FilePath}", savedFilePath); if (updateUi) { @@ -1475,6 +1486,7 @@ public class CUE4ParseViewModel : ViewModel } else { + Interlocked.Increment(ref FailedExportCount); Log.Error("{FileName} could not be saved", export.Name); FLogger.Append(ELog.Error, () => FLogger.Text($"Could not save '{export.Name}'", Constants.WHITE, true)); } @@ -1496,6 +1508,7 @@ public class CUE4ParseViewModel : ViewModel } }); + Interlocked.Increment(ref ExportedCount); Log.Information("{FileName} successfully exported", entry.Name); if (updateUi) { @@ -1508,6 +1521,7 @@ public class CUE4ParseViewModel : ViewModel } else { + Interlocked.Increment(ref FailedExportCount); Log.Error("{FileName} could not be exported", entry.Name); if (updateUi) FLogger.Append(ELog.Error, () => FLogger.Text($"Could not export '{entry.Name}'", Constants.WHITE, true)); diff --git a/FModel/ViewModels/Commands/RightClickMenuCommand.cs b/FModel/ViewModels/Commands/RightClickMenuCommand.cs index f4456ed9..6731918d 100644 --- a/FModel/ViewModels/Commands/RightClickMenuCommand.cs +++ b/FModel/ViewModels/Commands/RightClickMenuCommand.cs @@ -1,7 +1,11 @@ +using System; using System.Collections; +using System.Data; +using System.IO; using System.Linq; using System.Threading; using CUE4Parse.FileProvider.Objects; +using CUE4Parse.Utils; using FModel.Framework; using FModel.Services; using FModel.Settings; @@ -13,8 +17,21 @@ public class RightClickMenuCommand : ViewModelCommand { private ThreadWorkerViewModel _threadWorkerView => ApplicationService.ThreadWorkerView; - public RightClickMenuCommand(ApplicationViewModel contextViewModel) : base(contextViewModel) + public RightClickMenuCommand(ApplicationViewModel contextViewModel) : base(contextViewModel) { } + + private enum EAction { + Show, + Export, + } + + private enum EShowAssetType + { + None, + JSON, + Metadata, + References, + Decompile, } public override async void Execute(ApplicationViewModel contextViewModel, object parameter) @@ -26,189 +43,149 @@ public class RightClickMenuCommand : ViewModelCommand if (param.Length == 0) return; var folders = param.OfType().ToArray(); - var assets = param.SelectMany(item => item switch - { - GameFile gf => new[] { gf }, // search view passes GameFile directly - GameFileViewModel gvm => new[] { gvm.Asset }, - _ => [] - }).ToArray(); + var assets = param + .Select(static item => item switch + { + GameFile gf => gf, // Search view passes GameFile directly + GameFileViewModel gvm => gvm.Asset, + _ => null + }) + .Where(static gf => gf is not null).ToArray(); if (folders.Length == 0 && assets.Length == 0) return; - var updateUi = assets.Length > 1 ? EBulkType.Auto : EBulkType.None; + var assetsGroups = assets.GroupBy(static gf => gf.Directory); + var (action, showtype, bulktype) = trigger switch + { + "Assets_Extract_New_Tab" => (EAction.Show, EShowAssetType.JSON, EBulkType.None), + "Assets_Show_Metadata" => (EAction.Show, EShowAssetType.Metadata, EBulkType.None), + "Assets_Show_References" => (EAction.Show, EShowAssetType.References, EBulkType.None), + "Assets_Decompile" => (EAction.Show, EShowAssetType.Decompile, EBulkType.Code), + + "Save_Data" => (EAction.Export, EShowAssetType.None, EBulkType.Raw), + "Save_Properties" => (EAction.Export, EShowAssetType.None, EBulkType.Properties), + "Save_Textures" => (EAction.Export, EShowAssetType.None, EBulkType.Textures), + "Save_Models" => (EAction.Export, EShowAssetType.None, EBulkType.Meshes), + "Save_Animations" => (EAction.Export, EShowAssetType.None, EBulkType.Animations), + "Save_Audio" => (EAction.Export, EShowAssetType.None, EBulkType.Audio), + + _ => throw new ArgumentOutOfRangeException("Unsupported asset action."), + }; + + Interlocked.Exchange(ref contextViewModel.CUE4Parse.ExportedCount, 0); + Interlocked.Exchange(ref contextViewModel.CUE4Parse.FailedExportCount, 0); await _threadWorkerView.Begin(cancellationToken => { - switch (trigger) + if (action is EAction.Show) { - #region Asset Commands - case "Assets_Extract_New_Tab": - foreach (var entry in assets) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.Extract(cancellationToken, entry, true); - } - break; - case "Assets_Show_Metadata": - foreach (var entry in assets) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.ShowMetadata(entry); - } - break; - case "Assets_Show_References": - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.FindReferences(assets.FirstOrDefault()); - } - break; - case "Assets_Decompile": - foreach (var entry in assets) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.Decompile(entry); - } - break; - case "Assets_Export_Data": - foreach (var entry in assets) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.ExportData(entry); - } - break; - case "Assets_Save_Properties": - foreach (var entry in assets) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.Extract(cancellationToken, entry, false, EBulkType.Properties | updateUi); - } - break; - case "Assets_Save_Textures": - foreach (var entry in assets) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.Extract(cancellationToken, entry, false, EBulkType.Textures | updateUi); - } - break; - case "Assets_Save_Models": - foreach (var entry in assets) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.Extract(cancellationToken, entry, false, EBulkType.Meshes | updateUi); - } - break; - case "Assets_Save_Animations": - foreach (var entry in assets) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.Extract(cancellationToken, entry, false, EBulkType.Animations | updateUi); - } - break; - case "Assets_Save_Audio": - foreach (var entry in assets) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.Extract(cancellationToken, entry, false, EBulkType.Audio | updateUi); - } - break; - #endregion + if (showtype is EShowAssetType.References) + assets = [assets.FirstOrDefault()]; - #region Folder Commands - case "Folders_Export_Data": - foreach (var folder in folders) - { - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.ExportFolder(cancellationToken, folder); + Action entryAction = showtype switch + { + EShowAssetType.JSON => entry => contextViewModel.CUE4Parse.Extract(cancellationToken, entry, true), + EShowAssetType.Metadata => entry => contextViewModel.CUE4Parse.ShowMetadata(entry), + EShowAssetType.Decompile => entry => contextViewModel.CUE4Parse.Decompile(entry), + EShowAssetType.References => entry => contextViewModel.CUE4Parse.FindReferences(entry), + _ => throw new ArgumentOutOfRangeException("Unsupported asset action type."), + }; - FLogger.Append(ELog.Information, () => - { - FLogger.Text("Successfully exported ", Constants.WHITE); - FLogger.Link(folder.PathAtThisPoint, UserSettings.Default.RawDataDirectory, true); - }); - } - break; - case "Folders_Save_Properties": - foreach (var folder in folders) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.SaveFolder(cancellationToken, folder); + foreach (var entry in assets) + { + Thread.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + entryAction(entry); + } - FLogger.Append(ELog.Information, () => - { - FLogger.Text("Successfully saved ", Constants.WHITE); - FLogger.Link(folder.PathAtThisPoint, UserSettings.Default.PropertiesDirectory, true); - }); - } - break; - case "Folders_Save_Textures": - foreach (var folder in folders) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.TextureFolder(cancellationToken, folder); + return; + } - FLogger.Append(ELog.Information, () => - { - FLogger.Text("Successfully saved textures from ", Constants.WHITE); - FLogger.Link(folder.PathAtThisPoint, UserSettings.Default.TextureDirectory, true); - }); - } - break; - case "Folders_Save_Models": - foreach (var folder in folders) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.ModelFolder(cancellationToken, folder); + var (dirType, filetype) = bulktype switch + { + EBulkType.Raw => (UserSettings.Default.RawDataDirectory, "files"), + EBulkType.Properties => (UserSettings.Default.PropertiesDirectory, "json files"), + EBulkType.Textures => (UserSettings.Default.TextureDirectory, "textures"), + EBulkType.Meshes => (UserSettings.Default.ModelDirectory, "models"), + EBulkType.Animations => (UserSettings.Default.ModelDirectory, "animations"), + EBulkType.Audio => (UserSettings.Default.AudioDirectory, "audio files"), + _ => (null, null), + }; - FLogger.Append(ELog.Information, () => - { - FLogger.Text("Successfully saved models from ", Constants.WHITE); - FLogger.Link(folder.PathAtThisPoint, UserSettings.Default.ModelDirectory, true); - }); - } - break; - case "Folders_Save_Animations": - foreach (var folder in folders) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.AnimationFolder(cancellationToken, folder); + if (string.IsNullOrEmpty(dirType)) + return; - FLogger.Append(ELog.Information, () => - { - FLogger.Text("Successfully saved animations from ", Constants.WHITE); - FLogger.Link(folder.PathAtThisPoint, UserSettings.Default.ModelDirectory, true); - }); - } - break; - case "Folders_Save_Audio": - foreach (var folder in folders) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.AudioFolder(cancellationToken, folder); + Action folderAction = bulktype switch + { + EBulkType.Raw => folder => contextViewModel.CUE4Parse.ExportFolder(cancellationToken, folder), + _ => folder => contextViewModel.CUE4Parse.ExtractFolder(cancellationToken, folder, bulktype | EBulkType.Auto), + }; - FLogger.Append(ELog.Information, () => - { - FLogger.Text("Successfully saved audio from ", Constants.WHITE); - FLogger.Link(folder.PathAtThisPoint, UserSettings.Default.AudioDirectory, true); - }); - } - break; - #endregion + foreach (var folder in folders) + { + cancellationToken.ThrowIfCancellationRequested(); + folderAction(folder); + + var path = Path.Combine(dirType, UserSettings.Default.KeepDirectoryStructure ? folder.PathAtThisPoint : folder.PathAtThisPoint.SubstringAfterLast('/')).Replace('\\', '/'); + LogExport(contextViewModel, folder.PathAtThisPoint, path, dirType, filetype); + } + + Action fileAction = bulktype switch + { + EBulkType.Raw => (entry, _, update) => contextViewModel.CUE4Parse.ExportData(entry, !update), + _ => (entry, bulk, update) => contextViewModel.CUE4Parse.Extract(cancellationToken, entry, false, bulk), + }; + + foreach (var group in assetsGroups) + { + var directory = group.Key; + var list = group.ToArray(); + var update = list.Length > 1; + var bulk = bulktype | (update ? EBulkType.Auto : EBulkType.None); + foreach (var entry in list) + { + Thread.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + fileAction(entry, bulk, update); + } + + if (update) + { + var path = Path.Combine(dirType, UserSettings.Default.KeepDirectoryStructure ? directory : directory.SubstringAfterLast('/')).Replace('\\', '/'); + LogExport(contextViewModel, directory, path, dirType, filetype); + } } }); } + + private void LogExport(ApplicationViewModel contextViewModel, string directory, string path, string basePath, string fileType) + { + if (contextViewModel.CUE4Parse.ExportedCount > 0) + { + FLogger.Append(ELog.Information, () => + { + FLogger.Text($"Successfully exported {contextViewModel.CUE4Parse.ExportedCount} {fileType} from ", Constants.WHITE); + FLogger.Link(directory, Path.Exists(path) ? path : basePath, true); + }); + } + else if (contextViewModel.CUE4Parse.FailedExportCount == 0) + { + // Not an error because folder simply might not contain type of asset user is trying to save + FLogger.Append(ELog.Warning, () => + { + FLogger.Text($"Failed to find any {fileType} in {directory}", Constants.WHITE, true); + }); + } + + if (contextViewModel.CUE4Parse.FailedExportCount > 0) + { + FLogger.Append(ELog.Error, () => + { + FLogger.Text($"Failed to export {contextViewModel.CUE4Parse.FailedExportCount} {fileType} from {directory}", Constants.WHITE, true); + }); + } + + Interlocked.Exchange(ref contextViewModel.CUE4Parse.ExportedCount, 0); + Interlocked.Exchange(ref contextViewModel.CUE4Parse.FailedExportCount, 0); + } } diff --git a/FModel/ViewModels/Commands/TabCommand.cs b/FModel/ViewModels/Commands/TabCommand.cs index 07d1f6c0..a622d5e4 100644 --- a/FModel/ViewModels/Commands/TabCommand.cs +++ b/FModel/ViewModels/Commands/TabCommand.cs @@ -34,34 +34,34 @@ public class TabCommand : ViewModelCommand case "Find_References": _applicationView.CUE4Parse.FindReferences(tabViewModel.Entry); break; - case "Asset_Export_Data": + case "Save_Data": await _threadWorkerView.Begin(_ => _applicationView.CUE4Parse.ExportData(tabViewModel.Entry)); break; - case "Asset_Save_Properties": + case "Save_Properties": await _threadWorkerView.Begin(cancellationToken => { _applicationView.CUE4Parse.Extract(cancellationToken, tabViewModel.Entry, false, EBulkType.Properties); }); break; - case "Asset_Save_Textures": + case "Save_Textures": await _threadWorkerView.Begin(cancellationToken => { _applicationView.CUE4Parse.Extract(cancellationToken, tabViewModel.Entry, false, EBulkType.Textures); }); break; - case "Asset_Save_Models": + case "Save_Models": await _threadWorkerView.Begin(cancellationToken => { _applicationView.CUE4Parse.Extract(cancellationToken, tabViewModel.Entry, false, EBulkType.Meshes); }); break; - case "Asset_Save_Animations": + case "Save_Animations": await _threadWorkerView.Begin(cancellationToken => { _applicationView.CUE4Parse.Extract(cancellationToken, tabViewModel.Entry, false, EBulkType.Animations); }); break; - case "Asset_Save_Audio": + case "Save_Audio": await _threadWorkerView.Begin(cancellationToken => { _applicationView.CUE4Parse.Extract(cancellationToken, tabViewModel.Entry, false, EBulkType.Audio); diff --git a/FModel/ViewModels/GameFileViewModel.cs b/FModel/ViewModels/GameFileViewModel.cs index adefb5f5..689e315d 100644 --- a/FModel/ViewModels/GameFileViewModel.cs +++ b/FModel/ViewModels/GameFileViewModel.cs @@ -67,6 +67,7 @@ public class GameFileViewModel(GameFile asset) : ViewModel private const int MaxPreviewSize = 128; private ApplicationViewModel _applicationView => ApplicationService.ApplicationView; + private EGame? GameVersion => _applicationView.CUE4Parse?.Provider.Versions.Game; public EResolveCompute Resolved { get; private set; } = EResolveCompute.None; public GameFile Asset { get; } = asset; @@ -260,7 +261,7 @@ public class GameFileViewModel(GameFile asset) : ViewModel // Game specific assets below UBorderlandsDialogObject => (EAssetCategory.Borderlands, EBulkType.None), // Borderlands 3; UGbxGraphAsset or UDialogScriptData or UDialogPerformanceData => (EAssetCategory.Borderlands, EBulkType.Audio), // Borderlands 4; Borderlands 3; - UFaceFXAnimSet when _applicationView.CUE4Parse?.Provider.Versions.Game is EGame.GAME_Borderlands4 => (EAssetCategory.Borderlands, EBulkType.Audio), // Borderlands 4; + UFaceFXAnimSet when GameVersion is EGame.GAME_Borderlands4 => (EAssetCategory.Borderlands, EBulkType.Audio), // Borderlands 4; _ => (EAssetCategory.All, EBulkType.None), }; @@ -355,6 +356,7 @@ public class GameFileViewModel(GameFile asset) : ViewModel case "csv": AssetCategory = EAssetCategory.Data; break; + case "stinfo": case "ushaderbytecode": AssetCategory = EAssetCategory.ByteCode; break; @@ -430,10 +432,13 @@ public class GameFileViewModel(GameFile asset) : ViewModel }); } // Game specific extensions below - case "ace": // Borderlands 3 - case "ncs": // Borderlands 4 + case "ace" when GameVersion is EGame.GAME_Borderlands3: + case "ncs" when GameVersion is EGame.GAME_Borderlands4: AssetCategory = EAssetCategory.Borderlands; break; + case "dat" when GameVersion is EGame.GAME_Aion2: + AssetCategory = EAssetCategory.Aion2; + break; default: AssetCategory = EAssetCategory.All; // just so it sets resolved 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/TabControlViewModel.cs b/FModel/ViewModels/TabControlViewModel.cs index 9131169a..748dd1ae 100644 --- a/FModel/ViewModels/TabControlViewModel.cs +++ b/FModel/ViewModels/TabControlViewModel.cs @@ -1,6 +1,17 @@ using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading; +using System.Windows; +using System.Windows.Media.Imaging; +using CUE4Parse.FileProvider.Objects; +using CUE4Parse.UE4.Assets.Exports.Texture; +using CUE4Parse.Utils; +using CUE4Parse_Conversion.Textures; using FModel.Extensions; using FModel.Framework; +using FModel.Services; using FModel.Settings; using FModel.ViewModels.Commands; using FModel.Views.Resources.Controls; @@ -8,15 +19,6 @@ using ICSharpCode.AvalonEdit.Document; using ICSharpCode.AvalonEdit.Highlighting; using Serilog; using SkiaSharp; -using System.Collections.ObjectModel; -using System.IO; -using System.Linq; -using System.Windows; -using System.Windows.Media.Imaging; -using CUE4Parse.UE4.Assets.Exports.Texture; -using CUE4Parse_Conversion.Textures; -using CUE4Parse.FileProvider.Objects; -using CUE4Parse.Utils; namespace FModel.ViewModels; @@ -374,8 +376,7 @@ public class TabItem : ViewModel public void SaveImage() => SaveImage(SelectedImage, true); private void SaveImage(TabImage image, bool updateUi) { - if (image == null) - return; + if (image is null) return; var path = Path.Combine(UserSettings.Default.TextureDirectory, UserSettings.Default.KeepDirectoryStructure ? Entry.Directory : "", image.ExportName).Replace('\\', '/'); @@ -392,6 +393,7 @@ public class TabItem : ViewModel private void SaveImage(TabImage image, string path) { + if (image.ImageBuffer is null) return; using var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read); fs.Write(image.ImageBuffer, 0, image.ImageBuffer.Length); } @@ -412,6 +414,7 @@ public class TabItem : ViewModel { if (File.Exists(path)) { + Interlocked.Increment(ref ApplicationService.ApplicationView.CUE4Parse.ExportedCount); Log.Information("{FileName} successfully saved", fileName); if (updateUi) { @@ -424,6 +427,7 @@ public class TabItem : ViewModel } else { + Interlocked.Increment(ref ApplicationService.ApplicationView.CUE4Parse.FailedExportCount); Log.Error("{FileName} could not be saved", fileName); if (updateUi) FLogger.Append(ELog.Error, () => FLogger.Text($"Could not save '{fileName}'", Constants.WHITE, true)); 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/DirectorySelector.xaml b/FModel/Views/DirectorySelector.xaml index 22f27a2a..3bc8b1de 100644 --- a/FModel/Views/DirectorySelector.xaml +++ b/FModel/Views/DirectorySelector.xaml @@ -1,4 +1,4 @@ - + Width="{Binding Source={x:Static SystemParameters.MaximizedPrimaryScreenWidth}, Converter={converters:RatioConverter}, ConverterParameter='0.25'}" + AllowDrop="True">