From 4918ff8c9f27682d99b5b650d58f35c4a6bbe3eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tibor=20Ili=C4=87?= Date: Mon, 27 Jul 2026 22:09:31 +0200 Subject: [PATCH 1/2] fix for ipc regressions --- AssetEditor/Properties/launchSettings.json | 1 + .../IpcEditor/DependencyInjectionContainer.cs | 1 + Editors/Ipc/IpcEditor/ExternalPackLoader.cs | 70 ++++++------- Editors/Ipc/IpcEditor/IUiDispatcher.cs | 24 +++++ .../Ipc/Test.Ipc/ExternalPackLoaderTests.cs | 97 +++++++++++++++++++ Editors/Ipc/Test.Ipc/Test.Ipc.csproj | 3 +- .../Shared.Core/PackFiles/IPackFileService.cs | 1 + .../Shared.Core/PackFiles/PackFileService.cs | 31 ++++++ .../PackFiles/PackFileServiceTest.cs | 30 ++++++ 9 files changed, 217 insertions(+), 41 deletions(-) create mode 100644 Editors/Ipc/IpcEditor/IUiDispatcher.cs create mode 100644 Editors/Ipc/Test.Ipc/ExternalPackLoaderTests.cs diff --git a/AssetEditor/Properties/launchSettings.json b/AssetEditor/Properties/launchSettings.json index 5c29a8335..e8d5b2138 100644 --- a/AssetEditor/Properties/launchSettings.json +++ b/AssetEditor/Properties/launchSettings.json @@ -2,6 +2,7 @@ "profiles": { "AssetEditor": { "commandName": "Project", + "commandLineArgs": "Start_IPC", "nativeDebugging": false }, "AE-KitbashKarl": { diff --git a/Editors/Ipc/IpcEditor/DependencyInjectionContainer.cs b/Editors/Ipc/IpcEditor/DependencyInjectionContainer.cs index 8d5e98394..630dd11b4 100644 --- a/Editors/Ipc/IpcEditor/DependencyInjectionContainer.cs +++ b/Editors/Ipc/IpcEditor/DependencyInjectionContainer.cs @@ -13,6 +13,7 @@ public override void Register(IServiceCollection serviceCollection) serviceCollection.AddTransient(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); } diff --git a/Editors/Ipc/IpcEditor/ExternalPackLoader.cs b/Editors/Ipc/IpcEditor/ExternalPackLoader.cs index 12b887bfd..0348fd596 100644 --- a/Editors/Ipc/IpcEditor/ExternalPackLoader.cs +++ b/Editors/Ipc/IpcEditor/ExternalPackLoader.cs @@ -1,5 +1,4 @@ -using System.Windows; -using Shared.Core.PackFiles; +using Shared.Core.PackFiles; using Shared.Core.PackFiles.Models; using Shared.Core.PackFiles.Utility; @@ -10,59 +9,60 @@ public class ExternalPackLoader : IExternalPackLoader private readonly ILogger _logger = Logging.Create(); private readonly IPackFileService _packFileService; private readonly IPackFileContainerLoader _packFileContainerLoader; + private readonly IUiDispatcher _uiDispatcher; - public ExternalPackLoader(IPackFileService packFileService, IPackFileContainerLoader packFileContainerLoader) + public ExternalPackLoader(IPackFileService packFileService, IPackFileContainerLoader packFileContainerLoader, IUiDispatcher uiDispatcher) { _packFileService = packFileService; _packFileContainerLoader = packFileContainerLoader; + _uiDispatcher = uiDispatcher; } - public Task EnsureLoadedAsync(string packPathOnDisk, CancellationToken cancellationToken) + public async Task EnsureLoadedAsync(string packPathOnDisk, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (string.IsNullOrWhiteSpace(packPathOnDisk)) - return Task.FromResult(PackLoadResult.Ok()); + return 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 => x.GetAllFiles().ContainsKey(normalizedDiskPath)); - - - if (alreadyLoaded) - return Task.FromResult(PackLoadResult.Ok()); + return PackLoadResult.Fail("Pack path is empty"); try { - var container = _packFileContainerLoader.CreateFromPackFile(PackFileContainerType.Normal, normalizedDiskPath, true); - 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()); + // The pack loader owns WPF wait-cursor handling, so both loading and + // publishing the new container must run on the application dispatcher. + return await _uiDispatcher.InvokeAsync( + () => EnsureLoadedOnUiThread(normalizedDiskPath), + cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } catch (Exception ex) { _logger.Here().Error(ex, $"Failed loading external pack file {normalizedDiskPath}"); - return Task.FromResult(PackLoadResult.Fail("Pack file load failed")); + return PackLoadResult.Fail("Pack file load failed"); } } - private IPackFileContainer AddContainerOnUiThread(IPackFileContainer container) + private PackLoadResult EnsureLoadedOnUiThread(string normalizedDiskPath) { - var app = Application.Current; - if (app?.Dispatcher == null || app.Dispatcher.CheckAccess()) - return _packFileService.AddContainer(container, false); + if (_packFileService.IsPackFileLoaded(normalizedDiskPath)) + return PackLoadResult.Ok(); - return app.Dispatcher.Invoke(() => _packFileService.AddContainer(container, false)); + var container = _packFileContainerLoader.CreateFromPackFile(PackFileContainerType.Normal, normalizedDiskPath, true); + if (container == null) + return PackLoadResult.Fail("Pack file could not be loaded"); + + var added = _packFileService.AddContainer(container, false); + if (added == null) + return PackLoadResult.Fail("Pack file could not be added"); + + _logger.Here().Information($"Externally loaded pack file {normalizedDiskPath}"); + return PackLoadResult.Ok(); } private static string NormalizeDiskPath(string input) @@ -89,15 +89,5 @@ private static string NormalizeDiskPath(string input) 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/Editors/Ipc/IpcEditor/IUiDispatcher.cs b/Editors/Ipc/IpcEditor/IUiDispatcher.cs new file mode 100644 index 000000000..3fcdbe92d --- /dev/null +++ b/Editors/Ipc/IpcEditor/IUiDispatcher.cs @@ -0,0 +1,24 @@ +using System.Windows; +using System.Windows.Threading; + +namespace Editors.Ipc +{ + public interface IUiDispatcher + { + Task InvokeAsync(Func action, CancellationToken cancellationToken); + } + + internal sealed class WpfUiDispatcher : IUiDispatcher + { + public async Task InvokeAsync(Func action, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var dispatcher = Application.Current?.Dispatcher; + if (dispatcher == null || dispatcher.CheckAccess()) + return action(); + + return await dispatcher.InvokeAsync(action, DispatcherPriority.Normal, cancellationToken); + } + } +} diff --git a/Editors/Ipc/Test.Ipc/ExternalPackLoaderTests.cs b/Editors/Ipc/Test.Ipc/ExternalPackLoaderTests.cs new file mode 100644 index 000000000..2a09f4358 --- /dev/null +++ b/Editors/Ipc/Test.Ipc/ExternalPackLoaderTests.cs @@ -0,0 +1,97 @@ +using Editors.Ipc; +using Moq; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Utility; + +namespace Test.Ipc +{ + public class ExternalPackLoaderTests + { + [Test] + public async Task EnsureLoadedAsync_LoadsAndAddsPackInsideUiDispatcher() + { + var packPath = Path.GetFullPath("external.pack"); + var container = new Mock().Object; + var packFileService = new Mock(); + var containerLoader = new Mock(); + var dispatcher = new RecordingUiDispatcher(); + + packFileService + .Setup(x => x.IsPackFileLoaded(packPath)) + .Returns(false); + containerLoader + .Setup(x => x.CreateFromPackFile(PackFileContainerType.Normal, packPath, true)) + .Returns(() => + { + Assert.That(dispatcher.IsExecuting, Is.True); + return container; + }); + packFileService + .Setup(x => x.AddContainer(container, false)) + .Returns(() => + { + Assert.That(dispatcher.IsExecuting, Is.True); + return container; + }); + + var sut = new ExternalPackLoader(packFileService.Object, containerLoader.Object, dispatcher); + + var result = await sut.EnsureLoadedAsync(packPath, CancellationToken.None); + + Assert.That(result.Success, Is.True); + Assert.That(dispatcher.InvocationCount, Is.EqualTo(1)); + containerLoader.Verify( + x => x.CreateFromPackFile(PackFileContainerType.Normal, packPath, true), + Times.Once); + packFileService.Verify(x => x.AddContainer(container, false), Times.Once); + } + + [Test] + public async Task EnsureLoadedAsync_DoesNotReloadPack_WhenSourcePackIsAlreadyLoaded() + { + var packPath = Path.GetFullPath("already-loaded.pack"); + var packFileService = new Mock(); + var containerLoader = new Mock(); + var dispatcher = new RecordingUiDispatcher(); + + packFileService + .Setup(x => x.IsPackFileLoaded(packPath)) + .Returns(true); + + var sut = new ExternalPackLoader(packFileService.Object, containerLoader.Object, dispatcher); + + var result = await sut.EnsureLoadedAsync(packPath, CancellationToken.None); + + Assert.That(result.Success, Is.True); + Assert.That(dispatcher.InvocationCount, Is.EqualTo(1)); + containerLoader.Verify( + x => x.CreateFromPackFile(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + packFileService.Verify( + x => x.AddContainer(It.IsAny(), It.IsAny()), + Times.Never); + } + + private sealed class RecordingUiDispatcher : IUiDispatcher + { + public int InvocationCount { get; private set; } + public bool IsExecuting { get; private set; } + + public Task InvokeAsync(Func action, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + InvocationCount++; + IsExecuting = true; + try + { + return Task.FromResult(action()); + } + finally + { + IsExecuting = false; + } + } + } + } +} diff --git a/Editors/Ipc/Test.Ipc/Test.Ipc.csproj b/Editors/Ipc/Test.Ipc/Test.Ipc.csproj index 68a842903..c73056fcc 100644 --- a/Editors/Ipc/Test.Ipc/Test.Ipc.csproj +++ b/Editors/Ipc/Test.Ipc/Test.Ipc.csproj @@ -9,6 +9,7 @@ + @@ -22,4 +23,4 @@ - \ No newline at end of file + diff --git a/Shared/SharedCore/Shared.Core/PackFiles/IPackFileService.cs b/Shared/SharedCore/Shared.Core/PackFiles/IPackFileService.cs index ff9ee1644..5def9916a 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/IPackFileService.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/IPackFileService.cs @@ -16,6 +16,7 @@ public interface IPackFileService void DeleteFolder(IPackFileContainer pf, string folder); PackFile? FindFile(string path, IPackFileContainer? container = null); List GetAllPackfileContainers(); + bool IsPackFileLoaded(string packFilePath); IPackFileContainer? GetEditablePack(); string GetFullPath(PackFile file, IPackFileContainer? container = null); IPackFileContainer? GetPackFileContainer(PackFile file); diff --git a/Shared/SharedCore/Shared.Core/PackFiles/PackFileService.cs b/Shared/SharedCore/Shared.Core/PackFiles/PackFileService.cs index a6c1bc86a..dcc9911f3 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/PackFileService.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/PackFileService.cs @@ -32,6 +32,31 @@ public PackFileService(IGlobalEventHub? globalEventHub) public List GetAllPackfileContainers() => _packFileContainers.Cast().ToList(); + public bool IsPackFileLoaded(string packFilePath) + { + if (string.IsNullOrWhiteSpace(packFilePath)) + return false; + + var normalizedPath = NormalizeSystemPath(packFilePath); + foreach (var container in _packFileContainers) + { + if (PathsEqual(container.SystemFilePath, normalizedPath)) + return true; + + var sourcePackFilePaths = container switch + { + PackFileContainer packFileContainer => packFileContainer.SourcePackFilePaths, + CachedPackFileContainer cachedPackFileContainer => cachedPackFileContainer.SourcePackFilePaths, + _ => [] + }; + + if (sourcePackFilePaths.Any(path => PathsEqual(path, normalizedPath))) + return true; + } + + return false; + } + public IPackFileContainer? AddContainer(IPackFileContainer container, bool setToMainPackIfFirst = false) { var pf = CastContainer(container); @@ -388,6 +413,12 @@ private static string NormalizeSystemPath(string path) } } + private static bool PathsEqual(string? path, string normalizedPath) + { + return string.IsNullOrWhiteSpace(path) == false + && NormalizeSystemPath(path) == normalizedPath; + } + private static string DescribeFile(IPackFileContainer container, PackFile file) { var concreteContainer = CastContainer(container); diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/PackFileServiceTest.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/PackFileServiceTest.cs index 0e8bc0eb9..1c931a1e4 100644 --- a/Shared/SharedCore/Shared.CoreTest/PackFiles/PackFileServiceTest.cs +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/PackFileServiceTest.cs @@ -97,6 +97,36 @@ public void AddContainers_Duplicate() dialogProvider.Verify(m => m.ShowDialogBox(It.IsAny(), It.IsAny()), Times.Once); } + [Test] + public void IsPackFileLoaded_ReturnsTrue_ForContainerSystemPath() + { + var pfs = CreateService(); + var packPath = Path.GetFullPath("direct.pack"); + pfs.AddContainer(PackFileContainer.CreateCaPackFile("Direct", packPath)); + + var result = pfs.IsPackFileLoaded(packPath.ToUpperInvariant()); + + Assert.That(result, Is.True); + } + + [Test] + public void IsPackFileLoaded_ReturnsTrue_ForSourcePackInCachedAggregate() + { + var pfs = CreateService(); + var sourcePackPath = Path.GetFullPath("variants.pack"); + using var cached = CachedPackFileContainer.CreateFromFileList( + "All Game Packs", + [], + useInMemoryDb: true, + systemFilePath: Path.GetDirectoryName(sourcePackPath), + sourcePackFilePath: sourcePackPath); + pfs.AddContainer(cached); + + var result = pfs.IsPackFileLoaded(sourcePackPath); + + Assert.That(result, Is.True); + } + [Test] public void CreateNewPackFileContainer() { From f11996f8ccdfbf0d158298ed4d83715558cbb77b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tibor=20Ili=C4=87?= Date: Mon, 27 Jul 2026 22:19:26 +0200 Subject: [PATCH 2/2] don't include launch setting --- AssetEditor/Properties/launchSettings.json | 1 - 1 file changed, 1 deletion(-) diff --git a/AssetEditor/Properties/launchSettings.json b/AssetEditor/Properties/launchSettings.json index e8d5b2138..5c29a8335 100644 --- a/AssetEditor/Properties/launchSettings.json +++ b/AssetEditor/Properties/launchSettings.json @@ -2,7 +2,6 @@ "profiles": { "AssetEditor": { "commandName": "Project", - "commandLineArgs": "Start_IPC", "nativeDebugging": false }, "AE-KitbashKarl": {