From 61a3d2b4ee52cedff472b0d63674fcc4332bf1ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tibor=20Ili=C4=87?= Date: Tue, 24 Feb 2026 22:02:32 +0100 Subject: [PATCH 1/3] implement ipc protocol with an open file action --- AssetEditor/App.xaml.cs | 16 ++ AssetEditor/DependencyInjectionContainer.cs | 7 + .../Services/Ipc/AssetEditorIpcServer.cs | 212 ++++++++++++++++ .../Services/Ipc/ExternalFileOpenExecutor.cs | 131 ++++++++++ .../Services/Ipc/ExternalPackFileLookup.cs | 17 ++ .../Services/Ipc/ExternalPackLoader.cs | 111 ++++++++ .../Services/Ipc/IExternalFileOpenExecutor.cs | 11 + .../Services/Ipc/IIpcRequestHandler.cs | 35 +++ AssetEditor/Services/Ipc/IpcRequest.cs | 11 + AssetEditor/Services/Ipc/IpcRequestHandler.cs | 71 ++++++ AssetEditor/Services/Ipc/IpcResponse.cs | 18 ++ AssetEditor/Services/Ipc/IpcUserNotifier.cs | 39 +++ AssetEditor/Services/Ipc/PackPathResolver.cs | 46 ++++ .../Core/KitbasherViewModel.cs | 7 +- .../DependencyInjectionContainer.cs | 2 +- .../Services/KitbashSceneCreator.cs | 15 +- .../PackFiles/Models/PackFileContainer.cs | 1 + .../Utility/PackFileContainerLoader.cs | 4 + .../AssetEditorTests/AssetEditorTests.csproj | 2 +- .../Ipc/ExternalFileOpenExecutorTests.cs | 59 +++++ .../Ipc/IpcRequestHandlerTests.cs | 237 ++++++++++++++++++ .../Ipc/PackPathResolverTests.cs | 48 ++++ docs/asseteditor-ipc.md | 71 ++++++ 23 files changed, 1166 insertions(+), 5 deletions(-) create mode 100644 AssetEditor/Services/Ipc/AssetEditorIpcServer.cs create mode 100644 AssetEditor/Services/Ipc/ExternalFileOpenExecutor.cs create mode 100644 AssetEditor/Services/Ipc/ExternalPackFileLookup.cs create mode 100644 AssetEditor/Services/Ipc/ExternalPackLoader.cs create mode 100644 AssetEditor/Services/Ipc/IExternalFileOpenExecutor.cs create mode 100644 AssetEditor/Services/Ipc/IIpcRequestHandler.cs create mode 100644 AssetEditor/Services/Ipc/IpcRequest.cs create mode 100644 AssetEditor/Services/Ipc/IpcRequestHandler.cs create mode 100644 AssetEditor/Services/Ipc/IpcResponse.cs create mode 100644 AssetEditor/Services/Ipc/IpcUserNotifier.cs create mode 100644 AssetEditor/Services/Ipc/PackPathResolver.cs create mode 100644 Testing/AssetEditorTests/Ipc/ExternalFileOpenExecutorTests.cs create mode 100644 Testing/AssetEditorTests/Ipc/IpcRequestHandlerTests.cs create mode 100644 Testing/AssetEditorTests/Ipc/PackPathResolverTests.cs create mode 100644 docs/asseteditor-ipc.md diff --git a/AssetEditor/App.xaml.cs b/AssetEditor/App.xaml.cs index 457fc048a..243410f93 100644 --- a/AssetEditor/App.xaml.cs +++ b/AssetEditor/App.xaml.cs @@ -3,6 +3,7 @@ using System.Windows; using System.Windows.Threading; using AssetEditor.Services; +using AssetEditor.Services.Ipc; using AssetEditor.ViewModels; using AssetEditor.Views; using AssetEditor.Views.Settings; @@ -20,6 +21,7 @@ namespace AssetEditor public partial class App : Application { IServiceProvider _serviceProvider; + AssetEditorIpcServer _ipcServer; protected override void OnStartup(StartupEventArgs e) { @@ -77,6 +79,9 @@ protected override void OnStartup(StartupEventArgs e) devConfigManager.OpenFileOnLoad(); ShowMainWindow(); + + _ipcServer = _serviceProvider.GetRequiredService(); + _ipcServer.Start(); } void ShowMainWindow() @@ -85,6 +90,7 @@ void ShowMainWindow() ThemesController.SetTheme(applicationSettingsService.CurrentSettings.Theme); var mainWindow = _serviceProvider.GetRequiredService(); + MainWindow = mainWindow; mainWindow.DataContext = _serviceProvider.GetRequiredService(); mainWindow.Closed += OnMainWindowClosed; mainWindow.Show(); @@ -99,11 +105,21 @@ void ShowMainWindow() private void OnMainWindowClosed(object sender, EventArgs e) { + _ipcServer?.Dispose(); + _ipcServer = null; + foreach (Window window in Current.Windows) window.Close(); Shutdown(); } + protected override void OnExit(ExitEventArgs e) + { + _ipcServer?.Dispose(); + _ipcServer = null; + base.OnExit(e); + } + void DispatcherUnhandledExceptionHandler(object sender, DispatcherUnhandledExceptionEventArgs args) { Logging.Create().Here().Fatal(args.Exception.ToString()); diff --git a/AssetEditor/DependencyInjectionContainer.cs b/AssetEditor/DependencyInjectionContainer.cs index 991fd6e9b..4c7ad2815 100644 --- a/AssetEditor/DependencyInjectionContainer.cs +++ b/AssetEditor/DependencyInjectionContainer.cs @@ -1,4 +1,5 @@ using AssetEditor.Services; +using AssetEditor.Services.Ipc; using AssetEditor.UiCommands; using AssetEditor.ViewModels; using AssetEditor.Views; @@ -28,6 +29,12 @@ public override void Register(IServiceCollection serviceCollection) serviceCollection.AddTransient(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddSingleton(); serviceCollection.AddTransient(); serviceCollection.AddScoped(); diff --git a/AssetEditor/Services/Ipc/AssetEditorIpcServer.cs b/AssetEditor/Services/Ipc/AssetEditorIpcServer.cs new file mode 100644 index 000000000..1326f65ed --- /dev/null +++ b/AssetEditor/Services/Ipc/AssetEditorIpcServer.cs @@ -0,0 +1,212 @@ +using System; +using System.IO; +using System.IO.Pipes; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using Shared.Core.ErrorHandling; + +namespace AssetEditor.Services.Ipc +{ + public class AssetEditorIpcServer : IDisposable + { + public const string PipeName = "TheAssetEditor.Ipc"; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly ILogger _logger = Logging.Create(); + private readonly IServiceScopeFactory _scopeFactory; + private readonly object _syncLock = new(); + + private CancellationTokenSource _cancellationTokenSource; + private Task _serverTask; + private NamedPipeServerStream _activePipe; + private bool _disposed; + + public AssetEditorIpcServer(IServiceScopeFactory scopeFactory) + { + _scopeFactory = scopeFactory; + } + + public void Start() + { + lock (_syncLock) + { + if (_disposed) + throw new ObjectDisposedException(nameof(AssetEditorIpcServer)); + + if (_serverTask != null) + return; + + _cancellationTokenSource = new CancellationTokenSource(); + _serverTask = Task.Run(() => RunServerLoopAsync(_cancellationTokenSource.Token)); + } + } + + private async Task RunServerLoopAsync(CancellationToken cancellationToken) + { + _logger.Here().Information($"Starting IPC named pipe server on {PipeName}"); + + while (cancellationToken.IsCancellationRequested == false) + { + NamedPipeServerStream pipe = null; + try + { + pipe = new NamedPipeServerStream(PipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); + SetActivePipe(pipe); + + await pipe.WaitForConnectionAsync(cancellationToken); + + var response = await ProcessRequestAsync(pipe, cancellationToken); + await WriteResponseAsync(pipe, response); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.Here().Error(ex, "Unhandled exception in IPC server loop"); + } + finally + { + ClearActivePipe(pipe); + pipe?.Dispose(); + } + } + + _logger.Here().Information("IPC named pipe server stopped"); + } + + private async Task ProcessRequestAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken) + { + using var reader = new StreamReader(pipe, new UTF8Encoding(false), detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true); + var line = await reader.ReadLineAsync(); + + if (string.IsNullOrWhiteSpace(line)) + return IpcResponse.Failure("Empty request"); + + IpcRequest request; + try + { + request = JsonSerializer.Deserialize(line, SerializerOptions); + } + catch (JsonException) + { + return IpcResponse.Failure("Invalid JSON"); + } + + if (request == null) + return IpcResponse.Failure("Invalid JSON"); + + using var scope = _scopeFactory.CreateScope(); + var handler = scope.ServiceProvider.GetRequiredService(); + + try + { + return await handler.HandleAsync(request, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return IpcResponse.Failure("Canceled"); + } + catch (Exception ex) + { + _logger.Here().Error(ex, "IPC request handling failed"); + return IpcResponse.Failure("Internal server error"); + } + } + + private static async Task WriteResponseAsync(NamedPipeServerStream pipe, IpcResponse response) + { + using var writer = new StreamWriter(pipe, new UTF8Encoding(false), bufferSize: 1024, leaveOpen: true) + { + AutoFlush = true + }; + + var json = JsonSerializer.Serialize(response, SerializerOptions); + await writer.WriteLineAsync(json); + } + + private void SetActivePipe(NamedPipeServerStream pipe) + { + lock (_syncLock) + { + _activePipe = pipe; + } + } + + private void ClearActivePipe(NamedPipeServerStream pipe) + { + lock (_syncLock) + { + if (ReferenceEquals(_activePipe, pipe)) + _activePipe = null; + } + } + + public void Dispose() + { + CancellationTokenSource cancellationTokenSource; + Task serverTask; + NamedPipeServerStream activePipe; + + lock (_syncLock) + { + if (_disposed) + return; + + _disposed = true; + cancellationTokenSource = _cancellationTokenSource; + serverTask = _serverTask; + activePipe = _activePipe; + + _cancellationTokenSource = null; + _serverTask = null; + _activePipe = null; + } + + try + { + cancellationTokenSource?.Cancel(); + } + catch + { + } + + try + { + activePipe?.Dispose(); + } + catch + { + } + + if (serverTask != null) + { + try + { + _ = serverTask.Wait(TimeSpan.FromSeconds(2)); + } + catch + { + } + } + + cancellationTokenSource?.Dispose(); + } + } +} diff --git a/AssetEditor/Services/Ipc/ExternalFileOpenExecutor.cs b/AssetEditor/Services/Ipc/ExternalFileOpenExecutor.cs new file mode 100644 index 000000000..1b01a5039 --- /dev/null +++ b/AssetEditor/Services/Ipc/ExternalFileOpenExecutor.cs @@ -0,0 +1,131 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Linq; +using System.Windows; +using Editors.KitbasherEditor.UiCommands; +using Editors.KitbasherEditor.ViewModels; +using AssetEditor.Services; +using Shared.Core.DependencyInjection; +using Shared.Core.Events; +using Shared.Core.PackFiles.Models; +using Shared.Core.ToolCreation; +using Shared.Ui.Events.UiCommands; + +namespace AssetEditor.Services.Ipc +{ + public class ExternalFileOpenExecutor : IExternalFileOpenExecutor + { + private readonly IUiCommandFactory _uiCommandFactory; + private readonly IScopeRepository _scopeRepository; + private readonly IEditorManager _editorManager; + + public ExternalFileOpenExecutor(IUiCommandFactory uiCommandFactory, IScopeRepository scopeRepository, IEditorManager editorManager) + { + _uiCommandFactory = uiCommandFactory; + _scopeRepository = scopeRepository; + _editorManager = editorManager; + } + + public async Task OpenAsync(PackFile file, bool bringToFront, bool openInExistingKitbashTab, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var app = Application.Current; + if (app?.Dispatcher == null) + { + OpenOnUiThread(file, bringToFront, openInExistingKitbashTab); + return; + } + + if (app.Dispatcher.CheckAccess()) + { + OpenOnUiThread(file, bringToFront, openInExistingKitbashTab); + return; + } + + await app.Dispatcher.InvokeAsync(() => OpenOnUiThread(file, bringToFront, openInExistingKitbashTab)); + } + + private void OpenOnUiThread(PackFile file, bool bringToFront, bool openInExistingKitbashTab) + { + if (openInExistingKitbashTab && CanImportIntoKitbash(file) && TryImportIntoExistingKitbash(file)) + { + if (bringToFront) + BringMainWindowToFront(); + return; + } + + var command = _uiCommandFactory.Create(); + var forceKitbash = ShouldForceKitbash(file); + + if (forceKitbash) + command.Execute(file, EditorEnums.Kitbash_Editor); + else + command.Execute(file); + + if (bringToFront == false) + return; + + BringMainWindowToFront(); + } + + private void BringMainWindowToFront() + { + var app = Application.Current; + var window = app?.MainWindow ?? app?.Windows.OfType().FirstOrDefault(); + if (window == null) + return; + + if (window.WindowState == WindowState.Minimized) + window.WindowState = WindowState.Normal; + + window.Activate(); + _ = window.Focus(); + + if (window.Topmost == false) + { + window.Topmost = true; + window.Topmost = false; + } + + window.Activate(); + } + + private bool TryImportIntoExistingKitbash(PackFile file) + { + var existingKitbash = _editorManager.GetAllEditors().OfType().LastOrDefault(); + if (existingKitbash == null) + return false; + + var localCommandFactory = _scopeRepository.GetRequiredService(existingKitbash); + localCommandFactory.Create().Execute(file); + + SelectEditor(existingKitbash); + return true; + } + + private void SelectEditor(IEditorInterface editor) + { + if (_editorManager is not EditorManager concreteEditorManager) + return; + + var index = concreteEditorManager.CurrentEditorsList.IndexOf(editor); + if (index >= 0) + concreteEditorManager.SelectedEditorIndex = index; + } + + public static bool ShouldForceKitbash(PackFile file) + { + return string.Equals(file.Extension, ".wsmodel", StringComparison.InvariantCultureIgnoreCase) + || string.Equals(file.Extension, ".variantmeshdefinition", StringComparison.InvariantCultureIgnoreCase); + } + + public static bool CanImportIntoKitbash(PackFile file) + { + return string.Equals(file.Extension, ".rigid_model_v2", StringComparison.InvariantCultureIgnoreCase) + || string.Equals(file.Extension, ".wsmodel", StringComparison.InvariantCultureIgnoreCase) + || string.Equals(file.Extension, ".variantmeshdefinition", StringComparison.InvariantCultureIgnoreCase); + } + } +} diff --git a/AssetEditor/Services/Ipc/ExternalPackFileLookup.cs b/AssetEditor/Services/Ipc/ExternalPackFileLookup.cs new file mode 100644 index 000000000..50272e5d4 --- /dev/null +++ b/AssetEditor/Services/Ipc/ExternalPackFileLookup.cs @@ -0,0 +1,17 @@ +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; + +namespace AssetEditor.Services.Ipc +{ + public class ExternalPackFileLookup : IExternalPackFileLookup + { + private readonly IPackFileService _packFileService; + + public ExternalPackFileLookup(IPackFileService packFileService) + { + _packFileService = packFileService; + } + + public PackFile FindByPath(string path) => _packFileService.FindFile(path); + } +} diff --git a/AssetEditor/Services/Ipc/ExternalPackLoader.cs b/AssetEditor/Services/Ipc/ExternalPackLoader.cs new file mode 100644 index 000000000..8ce3018f1 --- /dev/null +++ b/AssetEditor/Services/Ipc/ExternalPackLoader.cs @@ -0,0 +1,111 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using Serilog; +using Shared.Core.ErrorHandling; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Utility; + +namespace AssetEditor.Services.Ipc +{ + public class ExternalPackLoader : IExternalPackLoader + { + private readonly ILogger _logger = Logging.Create(); + private readonly IPackFileService _packFileService; + private readonly IPackFileContainerLoader _packFileContainerLoader; + + public ExternalPackLoader(IPackFileService packFileService, IPackFileContainerLoader packFileContainerLoader) + { + _packFileService = packFileService; + _packFileContainerLoader = packFileContainerLoader; + } + + public Task EnsureLoadedAsync(string packPathOnDisk, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (string.IsNullOrWhiteSpace(packPathOnDisk)) + return Task.FromResult(PackLoadResult.Ok()); + + var normalizedDiskPath = NormalizeDiskPath(packPathOnDisk); + if (string.IsNullOrWhiteSpace(normalizedDiskPath)) + return Task.FromResult(PackLoadResult.Fail("Pack path is empty")); + + var alreadyLoaded = _packFileService + .GetAllPackfileContainers() + .Any(x => + PathsEqual(x.SystemFilePath, normalizedDiskPath) + || x.SourcePackFilePaths.Any(sourcePath => PathsEqual(sourcePath, normalizedDiskPath))); + + if (alreadyLoaded) + return Task.FromResult(PackLoadResult.Ok()); + + try + { + var container = _packFileContainerLoader.Load(normalizedDiskPath); + if (container == null) + return Task.FromResult(PackLoadResult.Fail("Pack file could not be loaded")); + + var added = AddContainerOnUiThread(container); + if (added == null) + return Task.FromResult(PackLoadResult.Fail("Pack file could not be added")); + + _logger.Here().Information($"Externally loaded pack file {normalizedDiskPath}"); + return Task.FromResult(PackLoadResult.Ok()); + } + catch (Exception ex) + { + _logger.Here().Error(ex, $"Failed loading external pack file {normalizedDiskPath}"); + return Task.FromResult(PackLoadResult.Fail("Pack file load failed")); + } + } + + private PackFileContainer AddContainerOnUiThread(PackFileContainer container) + { + var app = Application.Current; + if (app?.Dispatcher == null || app.Dispatcher.CheckAccess()) + return _packFileService.AddContainer(container, false); + + return app.Dispatcher.Invoke(() => _packFileService.AddContainer(container, false)); + } + + private static string NormalizeDiskPath(string input) + { + var path = input.Trim(); + + if (path.Length >= 2) + { + var first = path[0]; + var last = path[path.Length - 1]; + var hasMatchingQuotes = (first == '"' && last == '"') || (first == '\'' && last == '\''); + if (hasMatchingQuotes) + path = path.Substring(1, path.Length - 2); + } + + path = path.Replace('/', '\\'); + + try + { + return Path.GetFullPath(path); + } + catch + { + return path; + } + } + + private static bool PathsEqual(string left, string right) + { + if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right)) + return false; + + var normalizedLeft = left.Replace('/', '\\').Trim(); + var normalizedRight = right.Replace('/', '\\').Trim(); + return string.Equals(normalizedLeft, normalizedRight, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/AssetEditor/Services/Ipc/IExternalFileOpenExecutor.cs b/AssetEditor/Services/Ipc/IExternalFileOpenExecutor.cs new file mode 100644 index 000000000..455deb1de --- /dev/null +++ b/AssetEditor/Services/Ipc/IExternalFileOpenExecutor.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; +using Shared.Core.PackFiles.Models; + +namespace AssetEditor.Services.Ipc +{ + public interface IExternalFileOpenExecutor + { + Task OpenAsync(PackFile file, bool bringToFront, bool openInExistingKitbashTab, CancellationToken cancellationToken); + } +} diff --git a/AssetEditor/Services/Ipc/IIpcRequestHandler.cs b/AssetEditor/Services/Ipc/IIpcRequestHandler.cs new file mode 100644 index 000000000..41da5d6bf --- /dev/null +++ b/AssetEditor/Services/Ipc/IIpcRequestHandler.cs @@ -0,0 +1,35 @@ +using System.Threading; +using System.Threading.Tasks; +using Shared.Core.PackFiles.Models; + +namespace AssetEditor.Services.Ipc +{ + public interface IIpcRequestHandler + { + Task HandleAsync(IpcRequest request, CancellationToken cancellationToken); + } + + public interface IExternalPackFileLookup + { + PackFile FindByPath(string path); + } + + public interface IIpcUserNotifier + { + Task ShowExternalOpenFailedAsync(string normalizedPath, CancellationToken cancellationToken); + } + + public interface IExternalPackLoader + { + Task EnsureLoadedAsync(string packPathOnDisk, CancellationToken cancellationToken); + } + + public class PackLoadResult + { + public bool Success { get; set; } + public string Error { get; set; } + + public static PackLoadResult Ok() => new() { Success = true }; + public static PackLoadResult Fail(string error) => new() { Success = false, Error = error }; + } +} diff --git a/AssetEditor/Services/Ipc/IpcRequest.cs b/AssetEditor/Services/Ipc/IpcRequest.cs new file mode 100644 index 000000000..c9632ade4 --- /dev/null +++ b/AssetEditor/Services/Ipc/IpcRequest.cs @@ -0,0 +1,11 @@ +namespace AssetEditor.Services.Ipc +{ + public class IpcRequest + { + public string Action { get; set; } + public string Path { get; set; } + public bool? BringToFront { get; set; } + public bool? OpenInExistingKitbashTab { get; set; } + public string PackPathOnDisk { get; set; } + } +} diff --git a/AssetEditor/Services/Ipc/IpcRequestHandler.cs b/AssetEditor/Services/Ipc/IpcRequestHandler.cs new file mode 100644 index 000000000..fdcf96080 --- /dev/null +++ b/AssetEditor/Services/Ipc/IpcRequestHandler.cs @@ -0,0 +1,71 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Serilog; +using Shared.Core.ErrorHandling; + +namespace AssetEditor.Services.Ipc +{ + public class IpcRequestHandler : IIpcRequestHandler + { + private readonly ILogger _logger = Logging.Create(); + private readonly IExternalPackLoader _packLoader; + private readonly IExternalPackFileLookup _packFileLookup; + private readonly IExternalFileOpenExecutor _fileOpenExecutor; + private readonly IIpcUserNotifier _userNotifier; + + public IpcRequestHandler(IExternalPackLoader packLoader, IExternalPackFileLookup packFileLookup, IExternalFileOpenExecutor fileOpenExecutor, IIpcUserNotifier userNotifier) + { + _packLoader = packLoader; + _packFileLookup = packFileLookup; + _fileOpenExecutor = fileOpenExecutor; + _userNotifier = userNotifier; + } + + public async Task HandleAsync(IpcRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var action = request.Action?.Trim(); + if (string.IsNullOrWhiteSpace(action)) + return IpcResponse.Failure("Unsupported action"); + + if (string.Equals(action, "open", StringComparison.OrdinalIgnoreCase) == false) + return IpcResponse.Failure("Unsupported action"); + + var packPathOnDisk = GetPackPathOnDisk(request); + if (string.IsNullOrWhiteSpace(packPathOnDisk) == false) + { + var loadResult = await _packLoader.EnsureLoadedAsync(packPathOnDisk, cancellationToken); + if (loadResult.Success == false) + return IpcResponse.Failure(loadResult.Error ?? "Pack file load failed"); + } + + var normalizedPath = PackPathResolver.ResolvePackPath(request.Path); + if (string.IsNullOrWhiteSpace(normalizedPath)) + return IpcResponse.Failure("Path is empty"); + + var packFile = _packFileLookup.FindByPath(normalizedPath); + if (packFile == null) + { + _logger.Here().Information($"External open failed. File not found: {normalizedPath}"); + await _userNotifier.ShowExternalOpenFailedAsync(normalizedPath, cancellationToken); + return IpcResponse.Failure("File not found", normalizedPath); + } + + var bringToFront = request.BringToFront != false; + var openInExistingKitbashTab = request.OpenInExistingKitbashTab == true; + await _fileOpenExecutor.OpenAsync(packFile, bringToFront, openInExistingKitbashTab, cancellationToken); + + return IpcResponse.Success(); + } + + private static string GetPackPathOnDisk(IpcRequest request) + { + if (string.IsNullOrWhiteSpace(request.PackPathOnDisk) == false) + return request.PackPathOnDisk; + + return string.Empty; + } + } +} diff --git a/AssetEditor/Services/Ipc/IpcResponse.cs b/AssetEditor/Services/Ipc/IpcResponse.cs new file mode 100644 index 000000000..6f9510758 --- /dev/null +++ b/AssetEditor/Services/Ipc/IpcResponse.cs @@ -0,0 +1,18 @@ +namespace AssetEditor.Services.Ipc +{ + public class IpcResponse + { + public bool Ok { get; set; } + public string Error { get; set; } + public string NormalizedPath { get; set; } + + public static IpcResponse Success() => new() { Ok = true }; + + public static IpcResponse Failure(string error, string normalizedPath = null) => new() + { + Ok = false, + Error = error, + NormalizedPath = normalizedPath + }; + } +} diff --git a/AssetEditor/Services/Ipc/IpcUserNotifier.cs b/AssetEditor/Services/Ipc/IpcUserNotifier.cs new file mode 100644 index 000000000..e6ea5801f --- /dev/null +++ b/AssetEditor/Services/Ipc/IpcUserNotifier.cs @@ -0,0 +1,39 @@ +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using Shared.Core.Services; + +namespace AssetEditor.Services.Ipc +{ + public class IpcUserNotifier : IIpcUserNotifier + { + private readonly IStandardDialogs _standardDialogs; + + public IpcUserNotifier(IStandardDialogs standardDialogs) + { + _standardDialogs = standardDialogs; + } + + public async Task ShowExternalOpenFailedAsync(string normalizedPath, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + return; + + var message = $"External open failed: {normalizedPath}"; + var app = Application.Current; + if (app?.Dispatcher == null) + { + _standardDialogs.ShowDialogBox(message, "AssetEditor IPC"); + return; + } + + if (app.Dispatcher.CheckAccess()) + { + _standardDialogs.ShowDialogBox(message, "AssetEditor IPC"); + return; + } + + await app.Dispatcher.InvokeAsync(() => _standardDialogs.ShowDialogBox(message, "AssetEditor IPC")); + } + } +} diff --git a/AssetEditor/Services/Ipc/PackPathResolver.cs b/AssetEditor/Services/Ipc/PackPathResolver.cs new file mode 100644 index 000000000..c0e5ef7e3 --- /dev/null +++ b/AssetEditor/Services/Ipc/PackPathResolver.cs @@ -0,0 +1,46 @@ +using System; + +namespace AssetEditor.Services.Ipc +{ + public static class PackPathResolver + { + private static readonly string[] KnownRoots = + [ + "variantmeshes\\", + "ui\\", + "animations\\", + "audio\\" + ]; + + public static string ResolvePackPath(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return string.Empty; + + var path = input.Trim(); + + if (path.Length >= 2) + { + var first = path[0]; + var last = path[^1]; + var hasMatchingQuotes = (first == '"' && last == '"') || (first == '\'' && last == '\''); + if (hasMatchingQuotes) + path = path[1..^1]; + } + + path = path.Replace('/', '\\'); + while (path.Contains("\\\\", StringComparison.Ordinal)) + path = path.Replace("\\\\", "\\", StringComparison.Ordinal); + var lowerPath = path.ToLowerInvariant(); + + foreach (var knownRoot in KnownRoots) + { + var index = lowerPath.IndexOf(knownRoot, StringComparison.Ordinal); + if (index >= 0) + return path[index..]; + } + + return path; + } + } +} diff --git a/Editors/Kitbashing/KitbasherEditor/Core/KitbasherViewModel.cs b/Editors/Kitbashing/KitbasherEditor/Core/KitbasherViewModel.cs index 87058e3ad..d78c022a5 100644 --- a/Editors/Kitbashing/KitbasherEditor/Core/KitbasherViewModel.cs +++ b/Editors/Kitbashing/KitbasherEditor/Core/KitbasherViewModel.cs @@ -1,4 +1,5 @@ -using System.IO; +using System; +using System.IO; using CommunityToolkit.Mvvm.ComponentModel; using Editors.KitbasherEditor.EventHandlers; using Editors.KitbasherEditor.Services; @@ -81,7 +82,9 @@ public void LoadFile(PackFile fileToLoad) { _inputFileReference = fileToLoad; _kitbashSceneCreator.CreateFromPackFile(fileToLoad); - _focusSelectableObjectComponent.FocusScene(); + var shouldFocusScene = string.Equals(Path.GetExtension(fileToLoad.Name), ".variantmeshdefinition", StringComparison.InvariantCultureIgnoreCase) == false; + if (shouldFocusScene) + _focusSelectableObjectComponent.FocusScene(); DisplayName = fileToLoad.Name; } catch (Exception e) diff --git a/Editors/Kitbashing/KitbasherEditor/DependencyInjectionContainer.cs b/Editors/Kitbashing/KitbasherEditor/DependencyInjectionContainer.cs index 3ce7608c0..c05f4f7c2 100644 --- a/Editors/Kitbashing/KitbasherEditor/DependencyInjectionContainer.cs +++ b/Editors/Kitbashing/KitbasherEditor/DependencyInjectionContainer.cs @@ -112,7 +112,7 @@ public override void RegisterTools(IEditorDatabase factory) EditorInfoBuilder .Create(EditorEnums.Kitbash_Editor) .AddExtention(".rigid_model_v2", EditorPriorites.High) - //.AddExtention(".variantmeshdefinition", 0) + .AddExtention(".variantmeshdefinition", EditorPriorites.Default) .AddExtention(".wsmodel", EditorPriorites.High) .Build(factory); } diff --git a/Editors/Kitbashing/KitbasherEditor/Services/KitbashSceneCreator.cs b/Editors/Kitbashing/KitbasherEditor/Services/KitbashSceneCreator.cs index 39947f64f..463e687cc 100644 --- a/Editors/Kitbashing/KitbasherEditor/Services/KitbashSceneCreator.cs +++ b/Editors/Kitbashing/KitbasherEditor/Services/KitbashSceneCreator.cs @@ -54,10 +54,23 @@ public void CreateFromPackFile(PackFile file) // Load the opened model var modelFullPath = _packFileService.GetFullPath(file); + var extension = Path.GetExtension(modelFullPath).ToLower(); + + if (extension == ".variantmeshdefinition") + { + LoadReference(file); + + var openedFilePath = _packFileService.GetFullPath(file); + var openedFileDirectory = Path.GetDirectoryName(openedFilePath); + if (string.IsNullOrEmpty(openedFileDirectory) == false) + _saveSettings.OutputName = openedFileDirectory + "\\"; + _saveSettings.OutputName += Path.GetFileNameWithoutExtension(openedFilePath) + ".rigid_model_v2"; + return; + } WsModelFile? wsModel = null; RmvFile rmv; - if (Path.GetExtension(modelFullPath).ToLower() == ".wsmodel") + if (extension == ".wsmodel") { wsModel = new WsModelFile(file); var rmvPackFile = _packFileService.FindFile(wsModel.GeometryPath); diff --git a/Shared/SharedCore/PackFiles/Models/PackFileContainer.cs b/Shared/SharedCore/PackFiles/Models/PackFileContainer.cs index a19ef182b..79c76ad11 100644 --- a/Shared/SharedCore/PackFiles/Models/PackFileContainer.cs +++ b/Shared/SharedCore/PackFiles/Models/PackFileContainer.cs @@ -7,6 +7,7 @@ public class PackFileContainer public bool IsCaPackFile { get; set; } = false; public string SystemFilePath { get; set; } public long OriginalLoadByteSize { get; set; } = -1; + public HashSet SourcePackFilePaths { get; set; } = []; public Dictionary FileList { get; set; } = []; diff --git a/Shared/SharedCore/PackFiles/Utility/PackFileContainerLoader.cs b/Shared/SharedCore/PackFiles/Utility/PackFileContainerLoader.cs index bbe642f30..ead316b42 100644 --- a/Shared/SharedCore/PackFiles/Utility/PackFileContainerLoader.cs +++ b/Shared/SharedCore/PackFiles/Utility/PackFileContainerLoader.cs @@ -152,7 +152,11 @@ private static void AddFolderContentToPackFile(PackFileContainer container, stri { var packFilesOrderedByName = group.OrderBy(x => x.Name); foreach (var packfile in packFilesOrderedByName) + { + if (string.IsNullOrWhiteSpace(packfile.SystemFilePath) == false) + caPackFileContainer.SourcePackFilePaths.Add(packfile.SystemFilePath); caPackFileContainer.MergePackFileContainer(packfile); + } } return caPackFileContainer; diff --git a/Testing/AssetEditorTests/AssetEditorTests.csproj b/Testing/AssetEditorTests/AssetEditorTests.csproj index 307d12e25..dac7bc11d 100644 --- a/Testing/AssetEditorTests/AssetEditorTests.csproj +++ b/Testing/AssetEditorTests/AssetEditorTests.csproj @@ -2,7 +2,7 @@ - net6.0-windows + net10.0-windows enable enable diff --git a/Testing/AssetEditorTests/Ipc/ExternalFileOpenExecutorTests.cs b/Testing/AssetEditorTests/Ipc/ExternalFileOpenExecutorTests.cs new file mode 100644 index 000000000..2c5489cc8 --- /dev/null +++ b/Testing/AssetEditorTests/Ipc/ExternalFileOpenExecutorTests.cs @@ -0,0 +1,59 @@ +using AssetEditor.Services.Ipc; +using Shared.Core.PackFiles.Models; + +namespace AssetEditorTests.Ipc +{ + [TestClass] + public class ExternalFileOpenExecutorTests + { + [TestMethod] + public void ShouldForceKitbash_ReturnsTrue_ForWsmodel() + { + var file = PackFile.CreateFromBytes("arb_base_elephant_1.wsmodel", []); + + var result = ExternalFileOpenExecutor.ShouldForceKitbash(file); + + Assert.IsTrue(result); + } + + [TestMethod] + public void ShouldForceKitbash_ReturnsTrue_ForVariantMeshDefinition() + { + var file = PackFile.CreateFromBytes("arb_base_elephant.variantmeshdefinition", []); + + var result = ExternalFileOpenExecutor.ShouldForceKitbash(file); + + Assert.IsTrue(result); + } + + [TestMethod] + public void ShouldForceKitbash_ReturnsFalse_ForRigidModel() + { + var file = PackFile.CreateFromBytes("arb_base_elephant.rigid_model_v2", []); + + var result = ExternalFileOpenExecutor.ShouldForceKitbash(file); + + Assert.IsFalse(result); + } + + [TestMethod] + public void CanImportIntoKitbash_ReturnsTrue_ForRigidModel() + { + var file = PackFile.CreateFromBytes("arb_base_elephant.rigid_model_v2", []); + + var result = ExternalFileOpenExecutor.CanImportIntoKitbash(file); + + Assert.IsTrue(result); + } + + [TestMethod] + public void CanImportIntoKitbash_ReturnsFalse_ForUnsupportedFile() + { + var file = PackFile.CreateFromBytes("something.anim", []); + + var result = ExternalFileOpenExecutor.CanImportIntoKitbash(file); + + Assert.IsFalse(result); + } + } +} diff --git a/Testing/AssetEditorTests/Ipc/IpcRequestHandlerTests.cs b/Testing/AssetEditorTests/Ipc/IpcRequestHandlerTests.cs new file mode 100644 index 000000000..389038b67 --- /dev/null +++ b/Testing/AssetEditorTests/Ipc/IpcRequestHandlerTests.cs @@ -0,0 +1,237 @@ +using AssetEditor.Services.Ipc; +using Shared.Core.PackFiles.Models; + +namespace AssetEditorTests.Ipc +{ + [TestClass] + public class IpcRequestHandlerTests + { + [TestMethod] + public async Task HandleAsync_ReturnsUnsupportedAction_ForUnknownAction() + { + var packLoader = new FakePackLoader(); + var lookup = new FakeLookup(); + var opener = new FakeOpenExecutor(); + var notifier = new FakeNotifier(); + var sut = new IpcRequestHandler(packLoader, lookup, opener, notifier); + + var result = await sut.HandleAsync(new IpcRequest { Action = "ping", Path = "x" }, CancellationToken.None); + + Assert.IsFalse(result.Ok); + Assert.AreEqual("Unsupported action", result.Error); + Assert.AreEqual(0, opener.OpenCallCount); + Assert.AreEqual(0, notifier.CallCount); + Assert.AreEqual(0, packLoader.CallCount); + } + + [TestMethod] + public async Task HandleAsync_ReturnsError_ForEmptyPath() + { + var sut = new IpcRequestHandler(new FakePackLoader(), new FakeLookup(), new FakeOpenExecutor(), new FakeNotifier()); + + var result = await sut.HandleAsync(new IpcRequest { Action = "open", Path = " " }, CancellationToken.None); + + Assert.IsFalse(result.Ok); + Assert.AreEqual("Path is empty", result.Error); + } + + [TestMethod] + public async Task HandleAsync_ReturnsNotFound_AndShowsDialog_WhenLookupFails() + { + var packLoader = new FakePackLoader(); + var lookup = new FakeLookup(); + var opener = new FakeOpenExecutor(); + var notifier = new FakeNotifier(); + var sut = new IpcRequestHandler(packLoader, lookup, opener, notifier); + + var result = await sut.HandleAsync(new IpcRequest + { + Action = "open", + Path = @"C:\tmp\variantmeshes\foo\bar.rigid_model_v2" + }, CancellationToken.None); + + Assert.IsFalse(result.Ok); + Assert.AreEqual("File not found", result.Error); + Assert.AreEqual(@"variantmeshes\foo\bar.rigid_model_v2", result.NormalizedPath); + Assert.AreEqual(@"variantmeshes\foo\bar.rigid_model_v2", lookup.LastRequestedPath); + Assert.AreEqual(1, notifier.CallCount); + Assert.AreEqual(@"variantmeshes\foo\bar.rigid_model_v2", notifier.LastPath); + Assert.AreEqual(0, opener.OpenCallCount); + Assert.AreEqual(0, packLoader.CallCount); + } + + [TestMethod] + public async Task HandleAsync_OpensFile_AndReturnsOk_WhenLookupSucceeds() + { + var packFile = PackFile.CreateFromBytes("bird.rigid_model_v2", []); + var packLoader = new FakePackLoader(); + var lookup = new FakeLookup { Result = packFile }; + var opener = new FakeOpenExecutor(); + var notifier = new FakeNotifier(); + var sut = new IpcRequestHandler(packLoader, lookup, opener, notifier); + + var result = await sut.HandleAsync(new IpcRequest + { + Action = "open", + Path = @"variantmeshes\foo\bird.rigid_model_v2" + }, CancellationToken.None); + + Assert.IsTrue(result.Ok); + Assert.AreEqual(1, opener.OpenCallCount); + Assert.AreSame(packFile, opener.LastFile); + Assert.IsTrue(opener.LastBringToFront); + Assert.IsFalse(opener.LastOpenInExistingKitbashTab); + Assert.AreEqual(0, notifier.CallCount); + Assert.AreEqual(0, packLoader.CallCount); + } + + [TestMethod] + public async Task HandleAsync_DoesNotBringToFront_WhenBringToFrontFalse() + { + var packFile = PackFile.CreateFromBytes("bird.rigid_model_v2", []); + var packLoader = new FakePackLoader(); + var lookup = new FakeLookup { Result = packFile }; + var opener = new FakeOpenExecutor(); + var sut = new IpcRequestHandler(packLoader, lookup, opener, new FakeNotifier()); + + var result = await sut.HandleAsync(new IpcRequest + { + Action = "open", + Path = @"variantmeshes\foo\bird.rigid_model_v2", + BringToFront = false + }, CancellationToken.None); + + Assert.IsTrue(result.Ok); + Assert.AreEqual(1, opener.OpenCallCount); + Assert.IsFalse(opener.LastBringToFront); + Assert.IsFalse(opener.LastOpenInExistingKitbashTab); + Assert.AreEqual(0, packLoader.CallCount); + } + + [TestMethod] + public async Task HandleAsync_LoadsPackFromDisk_WhenPackPathProvided() + { + var packFile = PackFile.CreateFromBytes("arb_base_elephant.rigid_model_v2", []); + var packLoader = new FakePackLoader(); + var lookup = new FakeLookup { Result = packFile }; + var opener = new FakeOpenExecutor(); + var sut = new IpcRequestHandler(packLoader, lookup, opener, new FakeNotifier()); + + var result = await sut.HandleAsync(new IpcRequest + { + Action = "open", + Path = "variantmeshes/wh_variantmodels/el1/arb/arb_new_elephants/arb_base_elephant/arb_base_elephant.rigid_model_v2", + PackPathOnDisk = "k:/SteamLibrary/steamapps/common/Total War WARHAMMER III/data/ovn_araby.pack" + }, CancellationToken.None); + + Assert.IsTrue(result.Ok); + Assert.AreEqual(1, packLoader.CallCount); + Assert.AreEqual("k:/SteamLibrary/steamapps/common/Total War WARHAMMER III/data/ovn_araby.pack", packLoader.LastPackPath); + Assert.AreEqual(1, opener.OpenCallCount); + Assert.IsFalse(opener.LastOpenInExistingKitbashTab); + } + + [TestMethod] + public async Task HandleAsync_ReturnsFailure_WhenPackLoadFails() + { + var packLoader = new FakePackLoader + { + Result = PackLoadResult.Fail("Pack file load failed") + }; + var lookup = new FakeLookup(); + var opener = new FakeOpenExecutor(); + var notifier = new FakeNotifier(); + var sut = new IpcRequestHandler(packLoader, lookup, opener, notifier); + + var result = await sut.HandleAsync(new IpcRequest + { + Action = "open", + Path = @"variantmeshes\foo\bird.rigid_model_v2", + PackPathOnDisk = @"k:\mods\ovn_araby.pack" + }, CancellationToken.None); + + Assert.IsFalse(result.Ok); + Assert.AreEqual("Pack file load failed", result.Error); + Assert.AreEqual(1, packLoader.CallCount); + Assert.AreEqual(0, opener.OpenCallCount); + Assert.AreEqual(0, notifier.CallCount); + } + + [TestMethod] + public async Task HandleAsync_PassesOpenInExistingKitbashTabFlag_ToExecutor() + { + var packFile = PackFile.CreateFromBytes("arb_base_elephant_1.wsmodel", []); + var packLoader = new FakePackLoader(); + var lookup = new FakeLookup { Result = packFile }; + var opener = new FakeOpenExecutor(); + var sut = new IpcRequestHandler(packLoader, lookup, opener, new FakeNotifier()); + + var result = await sut.HandleAsync(new IpcRequest + { + Action = "open", + Path = @"variantmeshes\foo\arb_base_elephant_1.wsmodel", + OpenInExistingKitbashTab = true + }, CancellationToken.None); + + Assert.IsTrue(result.Ok); + Assert.AreEqual(1, opener.OpenCallCount); + Assert.IsTrue(opener.LastOpenInExistingKitbashTab); + } + + private class FakeLookup : IExternalPackFileLookup + { + public PackFile? Result { get; set; } + public string? LastRequestedPath { get; private set; } + + public PackFile? FindByPath(string path) + { + LastRequestedPath = path; + return Result; + } + } + + private class FakePackLoader : IExternalPackLoader + { + public int CallCount { get; private set; } + public string? LastPackPath { get; private set; } + public PackLoadResult Result { get; set; } = PackLoadResult.Ok(); + + public Task EnsureLoadedAsync(string packPathOnDisk, CancellationToken cancellationToken) + { + CallCount++; + LastPackPath = packPathOnDisk; + return Task.FromResult(Result); + } + } + + private class FakeOpenExecutor : IExternalFileOpenExecutor + { + public int OpenCallCount { get; private set; } + public PackFile? LastFile { get; private set; } + public bool LastBringToFront { get; private set; } + public bool LastOpenInExistingKitbashTab { get; private set; } + + public Task OpenAsync(PackFile file, bool bringToFront, bool openInExistingKitbashTab, CancellationToken cancellationToken) + { + OpenCallCount++; + LastFile = file; + LastBringToFront = bringToFront; + LastOpenInExistingKitbashTab = openInExistingKitbashTab; + return Task.CompletedTask; + } + } + + private class FakeNotifier : IIpcUserNotifier + { + public int CallCount { get; private set; } + public string? LastPath { get; private set; } + + public Task ShowExternalOpenFailedAsync(string normalizedPath, CancellationToken cancellationToken) + { + CallCount++; + LastPath = normalizedPath; + return Task.CompletedTask; + } + } + } +} diff --git a/Testing/AssetEditorTests/Ipc/PackPathResolverTests.cs b/Testing/AssetEditorTests/Ipc/PackPathResolverTests.cs new file mode 100644 index 000000000..c0f0d1c60 --- /dev/null +++ b/Testing/AssetEditorTests/Ipc/PackPathResolverTests.cs @@ -0,0 +1,48 @@ +using AssetEditor.Services.Ipc; + +namespace AssetEditorTests.Ipc +{ + [TestClass] + public class PackPathResolverTests + { + [TestMethod] + public void ResolvePackPath_ExtractsVariantMeshesSuffix_FromAbsolutePath() + { + var input = @"C:\games\wh3\data\variantmeshes\wh_variantmodels\bi1\cth\bird.rigid_model_v2"; + + var result = PackPathResolver.ResolvePackPath(input); + + Assert.AreEqual(@"variantmeshes\wh_variantmodels\bi1\cth\bird.rigid_model_v2", result); + } + + [TestMethod] + public void ResolvePackPath_NormalizesForwardSlashes_AndQuotes() + { + var input = "\"variantmeshes/wh_variantmodels/bi1/cth/bird.rigid_model_v2\""; + + var result = PackPathResolver.ResolvePackPath(input); + + Assert.AreEqual(@"variantmeshes\wh_variantmodels\bi1\cth\bird.rigid_model_v2", result); + } + + [TestMethod] + public void ResolvePackPath_CollapsesRepeatedBackslashes() + { + var input = @"variantmeshes\\wh_variantmodels\\bi1\\cth\\bird.rigid_model_v2"; + + var result = PackPathResolver.ResolvePackPath(input); + + Assert.AreEqual(@"variantmeshes\wh_variantmodels\bi1\cth\bird.rigid_model_v2", result); + } + + [TestMethod] + public void ResolvePackPath_ReturnsInput_WhenNoKnownRootFound() + { + var input = @"custom_folder\mesh.rigid_model_v2"; + + var result = PackPathResolver.ResolvePackPath(input); + + Assert.AreEqual(input, result); + } + } +} diff --git a/docs/asseteditor-ipc.md b/docs/asseteditor-ipc.md new file mode 100644 index 000000000..7bb2fd593 --- /dev/null +++ b/docs/asseteditor-ipc.md @@ -0,0 +1,71 @@ +# AssetEditor IPC (Current Support) + +This document describes the current IPC endpoint implemented by `AssetEditor`. + +## Status +- Transport: Windows named pipe +- Pipe name: `TheAssetEditor.Ipc` +- Protocol: JSON line per request (`UTF-8`, newline-terminated) +- Current supported actions: `open` only + +## Pipe Path (Windows) +- `\\.\pipe\TheAssetEditor.Ipc` + +## Request Format +Send one JSON object followed by a newline. + +### Supported action: `open` +```json +{"action":"open","path":"variantmeshes/wh_variantmodels/.../file.rigid_model_v2"} +``` + +### Request fields +- `action` (required): currently only `"open"` +- `path` (required): pack-internal file path to open +- `bringToFront` (optional, default `true`): bring AssetEditor window to front +- `packPathOnDisk` (optional): disk path to a `.pack` to load first if needed +- `openInExistingKitbashTab` (optional, default `false`): if `true`, and a Kitbash tab exists, import supported files into that tab instead of opening a new tab + +## Open Behavior by File Type +- `.rigid_model_v2`: normal open flow (or import into existing Kitbash tab if `openInExistingKitbashTab=true`) +- `.wsmodel`: forced to open in Kitbash Editor +- `.variantmeshdefinition`: forced to open in Kitbash Editor and imported as a reference on open + +## Path Handling +- Forward slashes and backslashes are accepted +- Repeated backslashes are collapsed +- Absolute paths are accepted if they contain a known pack root such as `variantmeshes\`; AssetEditor extracts the pack-relative suffix + +## Pack Loading Behavior +- If `packPathOnDisk` is supplied, AssetEditor attempts to ensure that pack is available before opening `path` +- If the pack is already loaded as a standalone pack, it is not loaded again +- If the pack is already represented inside the merged `All Game Packs` container, it is not loaded again + +## Response Format +AssetEditor returns one JSON response line and closes the connection. + +### Success +```json +{"ok":true} +``` + +### Failure +```json +{"ok":false,"error":"File not found","normalizedPath":"variantmeshes\\..."} +``` + +## Examples +Open from already-loaded packs: +```json +{"action":"open","path":"variantmeshes/wh_variantmodels/bi1/cth/cth_great_moon_bird/cth_great_moon_bird_body_01.rigid_model_v2"} +``` + +Open from a mod pack on disk (auto-load if needed): +```json +{"action":"open","path":"variantmeshes/wh_variantmodels/el1/arb/arb_new_elephants/arb_base_elephant/arb_base_elephant.rigid_model_v2","packPathOnDisk":"k:/SteamLibrary/steamapps/common/Total War WARHAMMER III/data/ovn_araby.pack"} +``` + +Reuse an existing Kitbash tab: +```json +{"action":"open","path":"variantmeshes/wh_variantmodels/el1/arb/ane/abe/arb_base_elephant_1.wsmodel","packPathOnDisk":"k:/SteamLibrary/steamapps/common/Total War WARHAMMER III/data/ovn_araby.pack","openInExistingKitbashTab":true} +``` From 5f188ae580fcf2b484a9bd2433cc54bdbc6a9b52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tibor=20Ili=C4=87?= Date: Tue, 24 Feb 2026 22:35:31 +0100 Subject: [PATCH 2/3] fix for crash when making editable after opening a variantmeshdefinition --- GameWorld/View3D/SceneNodes/SceneNodeHelper.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/GameWorld/View3D/SceneNodes/SceneNodeHelper.cs b/GameWorld/View3D/SceneNodes/SceneNodeHelper.cs index 47d9d518d..e1d5ae57a 100644 --- a/GameWorld/View3D/SceneNodes/SceneNodeHelper.cs +++ b/GameWorld/View3D/SceneNodes/SceneNodeHelper.cs @@ -49,10 +49,12 @@ public static T CloneNodeAndChildren(T target) where T : ISceneNode public static void MakeNodeEditable(Rmv2ModelNode mainNode, ISceneNode node) { + var editableLod0 = GetOrCreateEditableLod0(mainNode); + if (node is Rmv2MeshNode meshNode) { node.Parent.RemoveObject(node); - mainNode.GetLodNodes()[0].AddObject(node); + editableLod0.AddObject(node); meshNode.IsSelectable = true; node.IsEditable = true; return; @@ -64,7 +66,7 @@ public static void MakeNodeEditable(Rmv2ModelNode mainNode, ISceneNode node) foreach (var lodModel in lodNode.Children) { (lodModel as Rmv2MeshNode).IsSelectable = true; - mainNode.GetLodNodes()[0].AddObject(lodModel); + editableLod0.AddObject(lodModel); } } @@ -90,6 +92,7 @@ public static void MakeNodeEditable(Rmv2ModelNode mainNode, ISceneNode node) static void MakeModelNodeEditable(Rmv2ModelNode mainNode, Rmv2ModelNode modelNode) { + var editableLod0 = GetOrCreateEditableLod0(mainNode); foreach (var lodChild in modelNode.Children) { if (lodChild is Rmv2LodNode lodNode0) @@ -100,7 +103,7 @@ static void MakeModelNodeEditable(Rmv2ModelNode mainNode, Rmv2ModelNode modelNod if (index > 3) continue; (lodModel as Rmv2MeshNode).IsSelectable = true; - mainNode.GetLodNodes()[0].AddObject(lodModel); + editableLod0.AddObject(lodModel); } break; } @@ -108,6 +111,15 @@ static void MakeModelNodeEditable(Rmv2ModelNode mainNode, Rmv2ModelNode modelNod } + static Rmv2LodNode GetOrCreateEditableLod0(Rmv2ModelNode mainNode) + { + var lodNodes = mainNode.GetLodNodes(); + if (lodNodes.Count != 0) + return lodNodes[0]; + + return mainNode.AddObject(new Rmv2LodNode("Lod 0", 0)); + } + public static string GetSkeletonName(ISceneNode result) { var output = new List(); From 72db0e71c23f48eeba6f6614963fd7b2d8598e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tibor=20Ili=C4=87?= Date: Tue, 24 Feb 2026 22:43:43 +0100 Subject: [PATCH 3/3] fix for a case where FindParent returns null --- GameWorld/View3D/WpfWindow/Input/WpfMouse.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/GameWorld/View3D/WpfWindow/Input/WpfMouse.cs b/GameWorld/View3D/WpfWindow/Input/WpfMouse.cs index 9cd6ed871..7ff7f6b34 100644 --- a/GameWorld/View3D/WpfWindow/Input/WpfMouse.cs +++ b/GameWorld/View3D/WpfWindow/Input/WpfMouse.cs @@ -104,10 +104,13 @@ private void HandleMouse(object sender, MouseEventArgs e) if (e.LeftButton == MouseButtonState.Pressed) { var res = LogicalTreeHelperEx.FindParent(_focusElement); - var result = VisualTreeHelper.HitTest(res, pos); - if (result?.VisualHit == _focusElement) + if (res != null) { - _focusElement.Focus(); + var result = VisualTreeHelper.HitTest(res, pos); + if (result?.VisualHit == _focusElement) + { + _focusElement.Focus(); + } } } } @@ -118,9 +121,12 @@ private void HandleMouse(object sender, MouseEventArgs e) var hit = false; var res = LogicalTreeHelperEx.FindParent(_focusElement); //if (res == null) return; <-- please see: https://github.com/donkeyProgramming/TheAssetEditor/pull/90#:~:text=Monogame.WpfInterop/Input/WpfMouse.cs - var result = VisualTreeHelper.HitTest(res, pos); - if (result?.VisualHit == _focusElement) - hit = true; + if (res != null) + { + var result = VisualTreeHelper.HitTest(res, pos); + if (result?.VisualHit == _focusElement) + hit = true; + } if (!hit) {