diff --git a/CUE4Parse b/CUE4Parse index dc0f8183..267d479e 160000 --- a/CUE4Parse +++ b/CUE4Parse @@ -1 +1 @@ -Subproject commit dc0f8183f6551f9b5bccc8aecee65807362ef297 +Subproject commit 267d479e721b424e2e913ec195c596b660aa1d09 diff --git a/FModel/Enums.cs b/FModel/Enums.cs index bf85c984..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 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 66acbeb8..bca1427d 100644 --- a/FModel/Settings/UserSettings.cs +++ b/FModel/Settings/UserSettings.cs @@ -461,13 +461,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 4080cc53..42b24d90 100644 --- a/FModel/ViewModels/ApplicationViewModel.cs +++ b/FModel/ViewModels/ApplicationViewModel.cs @@ -140,8 +140,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; @@ -154,6 +156,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); diff --git a/FModel/ViewModels/AudioPlayerViewModel.cs b/FModel/ViewModels/AudioPlayerViewModel.cs index eff4cbb7..802e87ab 100644 --- a/FModel/ViewModels/AudioPlayerViewModel.cs +++ b/FModel/ViewModels/AudioPlayerViewModel.cs @@ -340,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)) { @@ -684,25 +680,11 @@ public class AudioPlayerViewModel : ViewModel, ISource, IDisposable } } - 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 - { - FileName = vgmFilePath, - Arguments = $"-o \"{wavFilePath}\" \"{inputFilePath}\"", - UseShellExecute = false, - CreateNoWindow = true - }); - vgmProcess?.WaitForExit(5000); - - File.Delete(inputFilePath); - - var success = vgmProcess?.ExitCode == 0 && File.Exists(wavFilePath); if (!success) { - Log.Error("Failed to convert {InputFilePath} to .wav format", inputFilePath); + Log.Error("Failed to convert {InputFilePath} to .wav format", Path.GetFileName(inputFilePath)); if (updateUi) { FLogger.Append(ELog.Error, () => @@ -731,20 +713,37 @@ public class AudioPlayerViewModel : ViewModel, ISource, IDisposable 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 b482f692..3896d582 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; @@ -334,7 +337,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}"); @@ -610,6 +613,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)); @@ -825,13 +831,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; @@ -1138,7 +1144,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: @@ -1146,7 +1153,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; } @@ -1155,7 +1162,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; } @@ -1220,12 +1227,13 @@ public class CUE4ParseViewModel : ViewModel case UAkMediaAsset when (isNone || saveAudio) && pointer.Object.Value is UAkMediaAsset akMediaAsset: { var audioName = akMediaAsset.MediaName ?? akMediaAsset.Name; - if (akMediaAsset.CurrentMediaAssetData?.TryLoad(out var akMediaAssetData) is true) + 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; } @@ -1234,14 +1242,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); } } } @@ -1253,7 +1262,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; } @@ -1263,12 +1272,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); } } @@ -1277,12 +1287,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); } } @@ -1426,30 +1437,36 @@ public class CUE4ParseViewModel : ViewModel return cpp.Length > 0; } - 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) { - conversionSuccess = AudioPlayerViewModel.TryConvert(savedAudioPath, data, out string wavFilePath); - if (conversionSuccess) savedAudioPath = wavFilePath; + if (AudioPlayerViewModel.TryConvert(savedAudioPath, data, out string wavFilePath)) + savedAudioPath = wavFilePath; + else + { + 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 && conversionSuccess) { @@ -1463,6 +1480,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" @@ -1480,6 +1500,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) { @@ -1492,6 +1513,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)); } @@ -1513,6 +1535,7 @@ public class CUE4ParseViewModel : ViewModel } }); + Interlocked.Increment(ref ExportedCount); Log.Information("{FileName} successfully exported", entry.Name); if (updateUi) { @@ -1525,6 +1548,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 2f2f4f17..b490957c 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,203 +43,151 @@ 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), + "Save_Code" => (EAction.Export, EShowAssetType.None, EBulkType.Code), + + _ => 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"), + EBulkType.Code => (UserSettings.Default.CodeDirectory, "code 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; - case "Folders_Save_Code": - foreach (var folder in folders) - { - Thread.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.CodeFolder(cancellationToken, folder); + foreach (var folder in folders) + { + cancellationToken.ThrowIfCancellationRequested(); + folderAction(folder); - FLogger.Append(ELog.Information, () => - { - FLogger.Text("Successfully saved decompiled blueprints from ", Constants.WHITE); - FLogger.Link(folder.PathAtThisPoint, UserSettings.Default.CodeDirectory, true); - }); - } - break; - #endregion + 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/TabControlViewModel.cs b/FModel/ViewModels/TabControlViewModel.cs index 31e579e7..dbeb4423 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); } @@ -422,6 +424,7 @@ public class TabItem : ViewModel { if (File.Exists(path)) { + Interlocked.Increment(ref ApplicationService.ApplicationView.CUE4Parse.ExportedCount); Log.Information("{FileName} successfully saved", fileName); if (updateUi) { @@ -434,6 +437,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/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">