From 36ad881a8dfdfba455e2ddcb5dbc68bf961513b5 Mon Sep 17 00:00:00 2001 From: LongerWarrior Date: Mon, 16 Mar 2026 21:47:08 +0200 Subject: [PATCH 1/9] Refactor right click commands --- FModel/Enums.cs | 1 + FModel/ViewModels/CUE4ParseViewModel.cs | 3 + .../Commands/RightClickMenuCommand.cs | 289 ++++++++---------- FModel/ViewModels/Commands/TabCommand.cs | 12 +- .../ContextMenus/FileContextMenu.xaml | 12 +- .../ContextMenus/FolderContextMenu.xaml | 12 +- FModel/Views/Resources/Resources.xaml | 12 +- FModel/Views/SearchView.xaml | 24 +- 8 files changed, 161 insertions(+), 204 deletions(-) diff --git a/FModel/Enums.cs b/FModel/Enums.cs index bf85c984..2bb4c25d 100644 --- a/FModel/Enums.cs +++ b/FModel/Enums.cs @@ -107,6 +107,7 @@ public enum EBulkType Animations = 1 << 4, Audio = 1 << 5, Code = 1 << 6, + Raw = 1 << 7, } public enum EAssetCategory : uint diff --git a/FModel/ViewModels/CUE4ParseViewModel.cs b/FModel/ViewModels/CUE4ParseViewModel.cs index 6b13ecc4..f2805a80 100644 --- a/FModel/ViewModels/CUE4ParseViewModel.cs +++ b/FModel/ViewModels/CUE4ParseViewModel.cs @@ -598,6 +598,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)); diff --git a/FModel/ViewModels/Commands/RightClickMenuCommand.cs b/FModel/ViewModels/Commands/RightClickMenuCommand.cs index f4456ed9..500015ed 100644 --- a/FModel/ViewModels/Commands/RightClickMenuCommand.cs +++ b/FModel/ViewModels/Commands/RightClickMenuCommand.cs @@ -1,7 +1,10 @@ +using System; using System.Collections; +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; @@ -17,6 +20,21 @@ public class RightClickMenuCommand : ViewModelCommand { } + private enum EAction + { + Show, + Export, + } + + private enum EShowAssetType + { + None, + JSON, + Metadata, + References, + Decompile, + } + public override async void Execute(ApplicationViewModel contextViewModel, object parameter) { if (parameter is not object[] parameters || parameters[0] is not string trigger) @@ -26,188 +44,123 @@ 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, + 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."), + }; + 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('\\', '/'); + FLogger.Append(ELog.Information, () => + { + FLogger.Text($"Successfully exported {filetype} from ", Constants.WHITE); + FLogger.Link(folder.PathAtThisPoint, path, true); + }); + } + + 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('\\', '/'); + FLogger.Append(ELog.Information, () => + { + FLogger.Text($"Successfully exported {list.Length} {filetype} from ", Constants.WHITE); + FLogger.Link(directory, path, true); + }); + } } }); } 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/Views/Resources/Controls/ContextMenus/FileContextMenu.xaml b/FModel/Views/Resources/Controls/ContextMenus/FileContextMenu.xaml index 109b71fb..28196a50 100644 --- a/FModel/Views/Resources/Controls/ContextMenus/FileContextMenu.xaml +++ b/FModel/Views/Resources/Controls/ContextMenus/FileContextMenu.xaml @@ -120,7 +120,7 @@ - + @@ -135,7 +135,7 @@ - + @@ -150,7 +150,7 @@ - + @@ -176,7 +176,7 @@ - + @@ -202,7 +202,7 @@ - + @@ -228,7 +228,7 @@ - + diff --git a/FModel/Views/Resources/Controls/ContextMenus/FolderContextMenu.xaml b/FModel/Views/Resources/Controls/ContextMenus/FolderContextMenu.xaml index 7ad5f5a8..ca5a5871 100644 --- a/FModel/Views/Resources/Controls/ContextMenus/FolderContextMenu.xaml +++ b/FModel/Views/Resources/Controls/ContextMenus/FolderContextMenu.xaml @@ -9,7 +9,7 @@ Command="{Binding RightClickMenuCommand}"> - + @@ -29,7 +29,7 @@ Command="{Binding RightClickMenuCommand}"> - + @@ -49,7 +49,7 @@ Command="{Binding RightClickMenuCommand}"> - + @@ -69,7 +69,7 @@ Command="{Binding RightClickMenuCommand}"> - + @@ -89,7 +89,7 @@ Command="{Binding RightClickMenuCommand}"> - + @@ -109,7 +109,7 @@ Command="{Binding RightClickMenuCommand}"> - + diff --git a/FModel/Views/Resources/Resources.xaml b/FModel/Views/Resources/Resources.xaml index c99c9de0..715a5b1f 100644 --- a/FModel/Views/Resources/Resources.xaml +++ b/FModel/Views/Resources/Resources.xaml @@ -926,7 +926,7 @@ - + @@ -938,7 +938,7 @@ - + @@ -947,7 +947,7 @@ - + @@ -956,7 +956,7 @@ - + @@ -965,7 +965,7 @@ - + @@ -974,7 +974,7 @@ - + diff --git a/FModel/Views/SearchView.xaml b/FModel/Views/SearchView.xaml index 1b37d87a..9e3a1f93 100644 --- a/FModel/Views/SearchView.xaml +++ b/FModel/Views/SearchView.xaml @@ -216,7 +216,7 @@ - + @@ -231,7 +231,7 @@ - + @@ -246,7 +246,7 @@ - + @@ -261,7 +261,7 @@ - + @@ -276,7 +276,7 @@ - + @@ -291,7 +291,7 @@ - + @@ -561,7 +561,7 @@ - + @@ -576,7 +576,7 @@ - + @@ -591,7 +591,7 @@ - + @@ -606,7 +606,7 @@ - + @@ -621,7 +621,7 @@ - + @@ -636,7 +636,7 @@ - + From a06a6a97a19cb874b050b009b5f0caf7e3ae5f93 Mon Sep 17 00:00:00 2001 From: Masusder <59669685+Masusder@users.noreply.github.com> Date: Tue, 17 Mar 2026 01:45:19 +0100 Subject: [PATCH 2/9] Handle failed extraction - Don't open audio player during json export - Don't link to directory that might not exist (would take us to desktop) - Show real amount of exported assets --- FModel/ViewModels/CUE4ParseViewModel.cs | 8 ++++ .../Commands/RightClickMenuCommand.cs | 42 ++++++++++++------- FModel/ViewModels/TabControlViewModel.cs | 21 ++++++---- 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/FModel/ViewModels/CUE4ParseViewModel.cs b/FModel/ViewModels/CUE4ParseViewModel.cs index f2805a80..6ae66c55 100644 --- a/FModel/ViewModels/CUE4ParseViewModel.cs +++ b/FModel/ViewModels/CUE4ParseViewModel.cs @@ -162,6 +162,8 @@ public class CUE4ParseViewModel : ViewModel public CriWareProvider CriWareProvider => _criWareProviderLazy?.Value; public ConcurrentBag UnknownExtensions = []; + public int ExportedCount; + public CUE4ParseViewModel() { var currentDir = UserSettings.Default.CurrentDir; @@ -1426,6 +1428,7 @@ public class CUE4ParseViewModel : ViewModel if (conversionSuccess) savedAudioPath = wavFilePath; } + Interlocked.Increment(ref ExportedCount); Log.Information("Successfully saved {FilePath}", savedAudioPath); if (updateUi && conversionSuccess) { @@ -1439,6 +1442,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" @@ -1456,6 +1462,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) { @@ -1489,6 +1496,7 @@ public class CUE4ParseViewModel : ViewModel } }); + Interlocked.Increment(ref ExportedCount); Log.Information("{FileName} successfully exported", entry.Name); if (updateUi) { diff --git a/FModel/ViewModels/Commands/RightClickMenuCommand.cs b/FModel/ViewModels/Commands/RightClickMenuCommand.cs index 500015ed..9b88f7a8 100644 --- a/FModel/ViewModels/Commands/RightClickMenuCommand.cs +++ b/FModel/ViewModels/Commands/RightClickMenuCommand.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.Data; using System.IO; using System.Linq; using System.Threading; @@ -16,9 +17,7 @@ public class RightClickMenuCommand : ViewModelCommand { private ThreadWorkerViewModel _threadWorkerView => ApplicationService.ThreadWorkerView; - public RightClickMenuCommand(ApplicationViewModel contextViewModel) : base(contextViewModel) - { - } + public RightClickMenuCommand(ApplicationViewModel contextViewModel) : base(contextViewModel) { } private enum EAction { @@ -47,7 +46,7 @@ public class RightClickMenuCommand : ViewModelCommand var assets = param .Select(static item => item switch { - GameFile gf => gf, + GameFile gf => gf, // Search view passes GameFile directly GameFileViewModel gvm => gvm.Asset, _ => null }) @@ -74,6 +73,7 @@ public class RightClickMenuCommand : ViewModelCommand _ => throw new ArgumentOutOfRangeException("Unsupported asset action."), }; + Interlocked.Exchange(ref contextViewModel.CUE4Parse.ExportedCount, 0); await _threadWorkerView.Begin(cancellationToken => { if (action is EAction.Show) @@ -126,11 +126,7 @@ public class RightClickMenuCommand : ViewModelCommand folderAction(folder); var path = Path.Combine(dirType, UserSettings.Default.KeepDirectoryStructure ? folder.PathAtThisPoint : folder.PathAtThisPoint.SubstringAfterLast('/')).Replace('\\', '/'); - FLogger.Append(ELog.Information, () => - { - FLogger.Text($"Successfully exported {filetype} from ", Constants.WHITE); - FLogger.Link(folder.PathAtThisPoint, path, true); - }); + LogExport(contextViewModel, folder.PathAtThisPoint, path, dirType, filetype); } Action fileAction = bulktype switch @@ -155,13 +151,31 @@ public class RightClickMenuCommand : ViewModelCommand if (update) { var path = Path.Combine(dirType, UserSettings.Default.KeepDirectoryStructure ? directory : directory.SubstringAfterLast('/')).Replace('\\', '/'); - FLogger.Append(ELog.Information, () => - { - FLogger.Text($"Successfully exported {list.Length} {filetype} from ", Constants.WHITE); - FLogger.Link(directory, path, true); - }); + 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 + { + // 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 export any {fileType} from {directory}", Constants.WHITE, true); + }); + } + + Interlocked.Exchange(ref contextViewModel.CUE4Parse.ExportedCount, 0); + } } diff --git a/FModel/ViewModels/TabControlViewModel.cs b/FModel/ViewModels/TabControlViewModel.cs index 9131169a..f03fcae4 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; @@ -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) { From b378ccb26a2b025065fe917acbbfcdcc35326f9f Mon Sep 17 00:00:00 2001 From: LongerWarrior Date: Sun, 22 Mar 2026 18:34:49 +0200 Subject: [PATCH 3/9] Allow drag&drop mappings file --- FModel/MainWindow.xaml | 3 +++ FModel/MainWindow.xaml.cs | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/FModel/MainWindow.xaml b/FModel/MainWindow.xaml index 8dc4c0b0..b8413665 100644 --- a/FModel/MainWindow.xaml +++ b/FModel/MainWindow.xaml @@ -10,6 +10,9 @@ xmlns:adonisUi="clr-namespace:AdonisUI;assembly=AdonisUI" xmlns:adonisControls="clr-namespace:AdonisUI.Controls;assembly=AdonisUI" xmlns:adonisExtensions="clr-namespace:AdonisUI.Extensions;assembly=AdonisUI" + AllowDrop="True" + Drop="OnDrop" + PreviewDragOver="OnPreviewDragOver" 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'}"> diff --git a/FModel/MainWindow.xaml.cs b/FModel/MainWindow.xaml.cs index 2c532723..07df9024 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; @@ -374,4 +375,50 @@ public partial class MainWindow childFolder.IsExpanded = true; childFolder.IsSelected = true; } + + private void OnPreviewDragOver(object sender, DragEventArgs e) + { + if (e.Data.GetDataPresent(DataFormats.FileDrop)) + { + var files = (string[]) e.Data.GetData(DataFormats.FileDrop); + if (_applicationView.Status.IsReady && files.Any(file => Path.GetExtension(file).Equals(".usmap", StringComparison.OrdinalIgnoreCase))) + { + e.Effects = DragDropEffects.Copy; + e.Handled = true; + return; + } + } + + e.Effects = DragDropEffects.None; + e.Handled = true; + } + + private async void OnDrop(object sender, DragEventArgs e) + { + if (!e.Data.GetDataPresent(DataFormats.FileDrop)) + return; + + var files = (string[]) e.Data.GetData(DataFormats.FileDrop); + var usmapFile = files.FirstOrDefault(file => Path.GetExtension(file).Equals(".usmap", StringComparison.OrdinalIgnoreCase)); + + if (usmapFile is null) + return; + + UserSettings.IsEndpointValid(EEndpointType.Mapping, out var oldMappingsEndpoint); + try + { + var newMappingsEndpoint = new EndpointSettings() { Overwrite = true, FilePath = usmapFile }; + UserSettings.Default.CurrentDir.Endpoints[(int) EEndpointType.Mapping] = newMappingsEndpoint; + await _applicationView.CUE4Parse.InitMappings(); + _applicationView.SettingsView.MappingEndpoint = newMappingsEndpoint; + } + catch (Exception ex) + { + UserSettings.Default.CurrentDir.Endpoints[(int) EEndpointType.Mapping] = oldMappingsEndpoint; + FLogger.Append(ELog.Error, () => + { + FLogger.Text($"Failed to load mapping file: {ex.Message}", Constants.WHITE, true); + }); + } + } } From fd4051b163a0f7b57e1a656e47030ea680f07e01 Mon Sep 17 00:00:00 2001 From: LongerWarrior Date: Sun, 22 Mar 2026 18:37:00 +0200 Subject: [PATCH 4/9] Convert audio via temp file --- FModel/ViewModels/AudioPlayerViewModel.cs | 61 +++++++++++------------ 1 file changed, 30 insertions(+), 31 deletions(-) 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; } } From 8e66371fca5f86dee425f7acd54c5f0580162d19 Mon Sep 17 00:00:00 2001 From: LongerWarrior Date: Sun, 22 Mar 2026 18:43:58 +0200 Subject: [PATCH 5/9] Update WwiseProvider for FDeferredByteData, counting failed exports Co-authored-by: Masusder <59669685+Masusder@users.noreply.github.com> --- CUE4Parse | 2 +- FModel/Settings/UserSettings.cs | 7 -- FModel/ViewModels/CUE4ParseViewModel.cs | 69 +++++++++++-------- .../Commands/RightClickMenuCommand.cs | 14 +++- FModel/ViewModels/TabControlViewModel.cs | 5 +- FModel/Views/SettingsView.xaml | 10 +-- 6 files changed, 59 insertions(+), 48 deletions(-) diff --git a/CUE4Parse b/CUE4Parse index dc0f8183..df263631 160000 --- a/CUE4Parse +++ b/CUE4Parse @@ -1 +1 @@ -Subproject commit dc0f8183f6551f9b5bccc8aecee65807362ef297 +Subproject commit df263631dbb96469f8f0316d74f4904c51f439ed diff --git a/FModel/Settings/UserSettings.cs b/FModel/Settings/UserSettings.cs index f6a77b19..44104264 100644 --- a/FModel/Settings/UserSettings.cs +++ b/FModel/Settings/UserSettings.cs @@ -454,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/CUE4ParseViewModel.cs b/FModel/ViewModels/CUE4ParseViewModel.cs index 9e75dc1a..3fa86b0f 100644 --- a/FModel/ViewModels/CUE4ParseViewModel.cs +++ b/FModel/ViewModels/CUE4ParseViewModel.cs @@ -163,6 +163,7 @@ public class CUE4ParseViewModel : ViewModel public ConcurrentBag UnknownExtensions = []; public int ExportedCount; + public int FailedExportCount; public CUE4ParseViewModel() { @@ -324,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}"); @@ -601,7 +602,7 @@ public class CUE4ParseViewModel : ViewModel } public void ExtractFolder(CancellationToken cancellationToken, TreeItem folder, EBulkType bulk) - => BulkFolder(cancellationToken, folder, asset => Extract(cancellationToken, asset, TabControl.HasNoTabs, 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)); @@ -807,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; @@ -1120,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: @@ -1128,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; } @@ -1137,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; } @@ -1202,12 +1204,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; } @@ -1216,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); } } } @@ -1235,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; } @@ -1245,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); } } @@ -1259,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); } } @@ -1404,28 +1410,33 @@ 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) { - 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); @@ -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)); } @@ -1509,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 9b88f7a8..6731918d 100644 --- a/FModel/ViewModels/Commands/RightClickMenuCommand.cs +++ b/FModel/ViewModels/Commands/RightClickMenuCommand.cs @@ -74,6 +74,7 @@ public class RightClickMenuCommand : ViewModelCommand }; Interlocked.Exchange(ref contextViewModel.CUE4Parse.ExportedCount, 0); + Interlocked.Exchange(ref contextViewModel.CUE4Parse.FailedExportCount, 0); await _threadWorkerView.Begin(cancellationToken => { if (action is EAction.Show) @@ -167,15 +168,24 @@ public class RightClickMenuCommand : ViewModelCommand FLogger.Link(directory, Path.Exists(path) ? path : basePath, true); }); } - else + 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 export any {fileType} from {directory}", Constants.WHITE, true); + 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/TabControlViewModel.cs b/FModel/ViewModels/TabControlViewModel.cs index f03fcae4..748dd1ae 100644 --- a/FModel/ViewModels/TabControlViewModel.cs +++ b/FModel/ViewModels/TabControlViewModel.cs @@ -376,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('\\', '/'); @@ -394,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); } @@ -427,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/Views/SettingsView.xaml b/FModel/Views/SettingsView.xaml index 4f60506d..658a38bb 100644 --- a/FModel/Views/SettingsView.xaml +++ b/FModel/Views/SettingsView.xaml @@ -43,7 +43,6 @@ - @@ -249,19 +248,14 @@ Margin="0 5 0 10" Style="{DynamicResource {x:Static adonisUi:Styles.ToggleSwitch}}" /> - - - - Date: Sun, 22 Mar 2026 21:16:03 +0100 Subject: [PATCH 6/9] Usmap drag & drop UI feedback --- FModel/MainWindow.xaml | 8 +- FModel/MainWindow.xaml.cs | 46 -------- .../Resources/Controls/UsmapDropOverlay.xaml | 58 ++++++++++ .../Controls/UsmapDropOverlay.xaml.cs | 106 ++++++++++++++++++ FModel/Views/Resources/Icons.xaml | 1 + FModel/Views/SettingsView.xaml | 8 +- 6 files changed, 175 insertions(+), 52 deletions(-) create mode 100644 FModel/Views/Resources/Controls/UsmapDropOverlay.xaml create mode 100644 FModel/Views/Resources/Controls/UsmapDropOverlay.xaml.cs diff --git a/FModel/MainWindow.xaml b/FModel/MainWindow.xaml index b8413665..abf9e32a 100644 --- a/FModel/MainWindow.xaml +++ b/FModel/MainWindow.xaml @@ -10,12 +10,10 @@ xmlns:adonisUi="clr-namespace:AdonisUI;assembly=AdonisUI" xmlns:adonisControls="clr-namespace:AdonisUI.Controls;assembly=AdonisUI" xmlns:adonisExtensions="clr-namespace:AdonisUI.Extensions;assembly=AdonisUI" - AllowDrop="True" - Drop="OnDrop" - PreviewDragOver="OnPreviewDragOver" 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"> @@ -712,5 +710,7 @@ + diff --git a/FModel/MainWindow.xaml.cs b/FModel/MainWindow.xaml.cs index 07df9024..b36da622 100644 --- a/FModel/MainWindow.xaml.cs +++ b/FModel/MainWindow.xaml.cs @@ -375,50 +375,4 @@ public partial class MainWindow childFolder.IsExpanded = true; childFolder.IsSelected = true; } - - private void OnPreviewDragOver(object sender, DragEventArgs e) - { - if (e.Data.GetDataPresent(DataFormats.FileDrop)) - { - var files = (string[]) e.Data.GetData(DataFormats.FileDrop); - if (_applicationView.Status.IsReady && files.Any(file => Path.GetExtension(file).Equals(".usmap", StringComparison.OrdinalIgnoreCase))) - { - e.Effects = DragDropEffects.Copy; - e.Handled = true; - return; - } - } - - e.Effects = DragDropEffects.None; - e.Handled = true; - } - - private async void OnDrop(object sender, DragEventArgs e) - { - if (!e.Data.GetDataPresent(DataFormats.FileDrop)) - return; - - var files = (string[]) e.Data.GetData(DataFormats.FileDrop); - var usmapFile = files.FirstOrDefault(file => Path.GetExtension(file).Equals(".usmap", StringComparison.OrdinalIgnoreCase)); - - if (usmapFile is null) - return; - - UserSettings.IsEndpointValid(EEndpointType.Mapping, out var oldMappingsEndpoint); - try - { - var newMappingsEndpoint = new EndpointSettings() { Overwrite = true, FilePath = usmapFile }; - UserSettings.Default.CurrentDir.Endpoints[(int) EEndpointType.Mapping] = newMappingsEndpoint; - await _applicationView.CUE4Parse.InitMappings(); - _applicationView.SettingsView.MappingEndpoint = newMappingsEndpoint; - } - catch (Exception ex) - { - UserSettings.Default.CurrentDir.Endpoints[(int) EEndpointType.Mapping] = oldMappingsEndpoint; - FLogger.Append(ELog.Error, () => - { - FLogger.Text($"Failed to load mapping file: {ex.Message}", Constants.WHITE, true); - }); - } - } } diff --git a/FModel/Views/Resources/Controls/UsmapDropOverlay.xaml b/FModel/Views/Resources/Controls/UsmapDropOverlay.xaml new file mode 100644 index 00000000..3f5f3a26 --- /dev/null +++ b/FModel/Views/Resources/Controls/UsmapDropOverlay.xaml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/FModel/Views/Resources/Controls/UsmapDropOverlay.xaml.cs b/FModel/Views/Resources/Controls/UsmapDropOverlay.xaml.cs new file mode 100644 index 00000000..d525d67c --- /dev/null +++ b/FModel/Views/Resources/Controls/UsmapDropOverlay.xaml.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using FModel.Services; +using FModel.Settings; +using FModel.ViewModels; + +namespace FModel.Views.Resources.Controls; + +public partial class UsmapDropOverlay : UserControl +{ + private ApplicationViewModel _applicationView => ApplicationService.ApplicationView; + private bool _isDraggingUsmap = false; + + public UsmapDropOverlay() + { + InitializeComponent(); + Loaded += OnLoaded; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + var window = Window.GetWindow(this); + if (window is null) + return; + + window.PreviewDragEnter += OnPreviewDragEnter; + window.PreviewDragOver += OnPreviewDragOver; + window.PreviewDragLeave += OnPreviewDragLeave; + window.Drop += OnDrop; + } + + private void OnPreviewDragEnter(object sender, DragEventArgs e) + { + _isDraggingUsmap = CheckIsUsmap(e); + if (_isDraggingUsmap) + { + Visibility = Visibility.Visible; + e.Effects = DragDropEffects.Copy; + } + else + { + e.Effects = DragDropEffects.None; + } + e.Handled = true; + } + + private void OnPreviewDragOver(object sender, DragEventArgs e) + { + if (!_isDraggingUsmap) + { + e.Effects = DragDropEffects.None; + e.Handled = true; + } + else + { + e.Effects = DragDropEffects.Copy; + e.Handled = true; + return; + } + } + + private void OnPreviewDragLeave(object sender, DragEventArgs e) => + Visibility = Visibility.Collapsed; + + private async void OnDrop(object sender, DragEventArgs e) + { + Visibility = Visibility.Collapsed; + if (!e.Data.GetDataPresent(DataFormats.FileDrop)) + return; + + var files = (string[]) e.Data.GetData(DataFormats.FileDrop); + var usmapFile = files.FirstOrDefault(file => Path.GetExtension(file).Equals(".usmap", StringComparison.OrdinalIgnoreCase)); + + if (usmapFile is null) + return; + + UserSettings.IsEndpointValid(EEndpointType.Mapping, out var oldMappingsEndpoint); + try + { + var newMappingsEndpoint = new EndpointSettings() { Overwrite = true, FilePath = usmapFile }; + UserSettings.Default.CurrentDir.Endpoints[(int) EEndpointType.Mapping] = newMappingsEndpoint; + await _applicationView.CUE4Parse.InitMappings(); + _applicationView.SettingsView.MappingEndpoint = newMappingsEndpoint; + } + catch (Exception ex) + { + UserSettings.Default.CurrentDir.Endpoints[(int) EEndpointType.Mapping] = oldMappingsEndpoint; + FLogger.Append(ELog.Error, () => + { + FLogger.Text($"Failed to load mapping file: {ex.Message}", Constants.WHITE, true); + }); + } + } + + private bool CheckIsUsmap(DragEventArgs e) + { + if (!e.Data.GetDataPresent(DataFormats.FileDrop)) + return false; + + var files = (string[]) e.Data.GetData(DataFormats.FileDrop); + return _applicationView.Status.IsReady && files.Any(f => Path.GetExtension(f).Equals(".usmap", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/FModel/Views/Resources/Icons.xaml b/FModel/Views/Resources/Icons.xaml index 45e0b5e1..39f1aa86 100644 --- a/FModel/Views/Resources/Icons.xaml +++ b/FModel/Views/Resources/Icons.xaml @@ -98,6 +98,7 @@ M5,3L4.35,6.34H17.94L17.5,8.5H3.92L3.26,11.83H16.85L16.09,15.64L10.61,17.45L5.86,15.64L6.19,14H2.85L2.06,18L9.91,21L18.96,18L20.16,11.97L20.4,10.76L21.94,3H5Z M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2M15 16L13 20H10L12 16H9V11H15V16M13 9V3.5L18.5 9H13Z M12,2A2,2 0 0,1 14,4C14,4.74 13.6,5.39 13,5.73V7H14A7,7 0 0,1 21,14H22A1,1 0 0,1 23,15V18A1,1 0 0,1 22,19H21V20A2,2 0 0,1 19,22H5A2,2 0 0,1 3,20V19H2A1,1 0 0,1 1,18V15A1,1 0 0,1 2,14H3A7,7 0 0,1 10,7H11V5.73C10.4,5.39 10,4.74 10,4A2,2 0 0,1 12,2M7.5,13A2.5,2.5 0 0,0 5,15.5A2.5,2.5 0 0,0 7.5,18A2.5,2.5 0 0,0 10,15.5A2.5,2.5 0 0,0 7.5,13M16.5,13A2.5,2.5 0 0,0 14,15.5A2.5,2.5 0 0,0 16.5,18A2.5,2.5 0 0,0 19,15.5A2.5,2.5 0 0,0 16.5,13Z + M14,12L10,8V11H2V13H10V16M20,18V6C20,4.89 19.1,4 18,4H6A2,2 0 0,0 4,6V9H6V6H18V18H6V15H4V18A2,2 0 0,0 6,20H18A2,2 0 0,0 20,18Z M13,9V3.5L18.5,9M6,2C4.89,2 4,2.89 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6 ZM12,11A3,3 0 1,0 12,17A3,3 0 0,0 12,11 ZM12,12.5L14,16H13L12,14.5L11,16H10L12,12.5Z diff --git a/FModel/Views/SettingsView.xaml b/FModel/Views/SettingsView.xaml index 658a38bb..7094e30c 100644 --- a/FModel/Views/SettingsView.xaml +++ b/FModel/Views/SettingsView.xaml @@ -10,7 +10,8 @@ xmlns:adonisExtensions="clr-namespace:AdonisUI.Extensions;assembly=AdonisUI" WindowStartupLocation="CenterScreen" ResizeMode="NoResize" IconVisibility="Collapsed" SizeToContent="Height" MinHeight="{Binding Source={x:Static SystemParameters.MaximizedPrimaryScreenHeight}, Converter={converters:RatioConverter}, ConverterParameter='0.10'}" - Width="{Binding Source={x:Static SystemParameters.MaximizedPrimaryScreenWidth}, Converter={converters:RatioConverter}, ConverterParameter='0.45'}"> + Width="{Binding Source={x:Static SystemParameters.MaximizedPrimaryScreenWidth}, Converter={converters:RatioConverter}, ConverterParameter='0.45'}" + AllowDrop="True">