diff --git a/.vscode/settings.json b/.vscode/settings.json index 9a85d1da1..5b47b3ffb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,7 @@ { "chat.tools.terminal.autoApprove": { - "ForEach-Object": true + "ForEach-Object": true, + "dotnet restore": true, + "Set-Content": true } } \ No newline at end of file diff --git a/AssetEditor/AssetEditor.csproj b/AssetEditor/AssetEditor.csproj index 5771acb38..8aafb3bba 100644 --- a/AssetEditor/AssetEditor.csproj +++ b/AssetEditor/AssetEditor.csproj @@ -48,7 +48,7 @@ https://github.com/donkeyProgramming/TheAssetEditor https://github.com/donkeyProgramming/TheAssetEditor AssetEditor - 0.71.0 + 0.72.0 6.0 diff --git a/AssetEditor/Language_Cn.json b/AssetEditor/Language_Cn.json index 7596b6afd..bd28f99bc 100644 --- a/AssetEditor/Language_Cn.json +++ b/AssetEditor/Language_Cn.json @@ -11,6 +11,7 @@ "SettingsWindow.RenderBackground": "渲染引擎背景颜色", "SettingsWindow.OnlyLoadLod0": "仅为参考 Mesh 加载 Lod0", "SettingsWindow.GridSize": "网格大小", + "SettingsWindow.VertexSelectionColour": "顶点选择颜色", "SettingsWindow.AudioModding": "音频 Mod", "SettingsWindow.SaveButton": "保存", @@ -97,6 +98,7 @@ "SettingsWindow.Title": "设置", "SettingsWindow.OnlyLoadLod0Tooltip": "在 Kitbash 工具中导入参考 Mesh 时,仅导入 Lod0 以节省内存。", "SettingsWindow.GridSizeTooltip": "可视化编辑器中网格的大小", + "SettingsWindow.VertexSelectionColourTooltip": "选中顶点的十六进制颜色(例如 #FF7800)", "SettingsWindow.GameFiles": "游戏文件", "SettingsWindow.GameDataPathHint": "浏览游戏数据位置,例如 \"C:\\SteamLibrary\\steamapps\\common\\Total War WARHAMMER III\\data\"", "SettingsWindow.WwiseCLI": "Wwise CLI", @@ -881,5 +883,13 @@ "AnimationReTarget.BoneSettings.Title": "骨骼设置:", "FbxSettingsDialog.Import": "导入", - "FbxSettingsDialog.Cancel": "取消" + "FbxSettingsDialog.Cancel": "取消", + + "PackFileCache.InvalidReason.Title": "Cache Rebuild Required", + "PackFileCache.InvalidReason.NotFound": "No cache found for {0}. Building cache for the first time - this may take a moment.", + "PackFileCache.InvalidReason.DataChanged": "Game files for {0} have changed since the cache was built. Rebuilding cache.", + "PackFileCache.InvalidReason.Corrupted": "Cache for {0} is corrupted and will be rebuilt.", + "PackFileCache.BuildingCache": "Building pack file cache for {0}. This may take some time, please wait...", + "PackFileCache.BuildingCache.Title": "Building Cache", + "PackFileCache.Description": "The cache allows faster startup times and reduces memory usage by storing pre-processed game file data on disk." } \ No newline at end of file diff --git a/AssetEditor/Language_En.json b/AssetEditor/Language_En.json index a0a16d1a7..368db562b 100644 --- a/AssetEditor/Language_En.json +++ b/AssetEditor/Language_En.json @@ -11,6 +11,7 @@ "SettingsWindow.RenderBackground": "Render engine background colour", "SettingsWindow.OnlyLoadLod0": "Only load Lod0 for reference meshes", "SettingsWindow.GridSize": "Grid Size", + "SettingsWindow.VertexSelectionColour": "Vertex Selection Colour", "SettingsWindow.AudioModding": "Audio Modding", "SettingsWindow.SaveButton": "Save", @@ -97,6 +98,7 @@ "SettingsWindow.Title": "Settings", "SettingsWindow.OnlyLoadLod0Tooltip": "When importing a reference mesh in the Kitbash tool, only import Lod0 in order to save memory.", "SettingsWindow.GridSizeTooltip": "Size of the grid in visual editors", + "SettingsWindow.VertexSelectionColourTooltip": "Hex colour for selected vertices (e.g. #FF7800)", "SettingsWindow.GameFiles": "Game Files", "SettingsWindow.GameDataPathHint": "Browse for game data location e.g. \"C:\\SteamLibrary\\steamapps\\common\\Total War WARHAMMER III\\data\"", "SettingsWindow.WwiseCLI": "Wwise CLI", @@ -881,6 +883,14 @@ "AnimationReTarget.BoneSettings.Title": "Bone settings:", "FbxSettingsDialog.Import": "Import", - "FbxSettingsDialog.Cancel": "Cancel" + "FbxSettingsDialog.Cancel": "Cancel", + + "PackFileCache.InvalidReason.Title": "Cache Rebuild Required", + "PackFileCache.InvalidReason.NotFound": "No cache found for {0}. Building cache for the first time - this may take a moment.", + "PackFileCache.InvalidReason.DataChanged": "Game files for {0} have changed since the cache was built. Rebuilding cache.", + "PackFileCache.InvalidReason.Corrupted": "Cache for {0} is corrupted and will be rebuilt.", + "PackFileCache.BuildingCache": "Building pack file cache for {0}. This may take some time, please wait...", + "PackFileCache.BuildingCache.Title": "Building Cache", + "PackFileCache.Description": "The cache allows faster startup times and reduces memory usage by storing pre-processed game file data on disk." } diff --git a/AssetEditor/Language_Fr.json b/AssetEditor/Language_Fr.json index 2b8f92667..23ccc7b60 100644 --- a/AssetEditor/Language_Fr.json +++ b/AssetEditor/Language_Fr.json @@ -10,6 +10,7 @@ "SettingsWindow.RenderBackground": "Couleur d’arrière-plan du moteur de rendu", "SettingsWindow.OnlyLoadLod0": "Charger uniquement le Lod0 pour les maillages de référence", "SettingsWindow.GridSize": "Taille de la grille", + "SettingsWindow.VertexSelectionColour": "Couleur de sélection des sommets", "SettingsWindow.AudioModding": "Modding audio", "SettingsWindow.SaveButton": "Enregistrer", @@ -27,5 +28,13 @@ "KitbashTool.Debug.Geometry.CreatePlane.Tooltip": "Creer un plan subdivise", "KitbashTool.Debug.Geometry.CreateSphere.Tooltip": "Creer une sphere subdivisee", - "KitbashTool.SaveDialog.DisplayDialogOnNextSave": "Afficher la boîte de dialogue à la prochaine sauvegarde" + "KitbashTool.SaveDialog.DisplayDialogOnNextSave": "Afficher la boîte de dialogue à la prochaine sauvegarde", + + "PackFileCache.InvalidReason.Title": "Cache Rebuild Required", + "PackFileCache.InvalidReason.NotFound": "No cache found for {0}. Building cache for the first time - this may take a moment.", + "PackFileCache.InvalidReason.DataChanged": "Game files for {0} have changed since the cache was built. Rebuilding cache.", + "PackFileCache.InvalidReason.Corrupted": "Cache for {0} is corrupted and will be rebuilt.", + "PackFileCache.BuildingCache": "Building pack file cache for {0}. This may take some time, please wait...", + "PackFileCache.BuildingCache.Title": "Building Cache", + "PackFileCache.Description": "The cache allows faster startup times and reduces memory usage by storing pre-processed game file data on disk." } \ No newline at end of file diff --git a/AssetEditor/ViewModels/SettingsViewModel.cs b/AssetEditor/ViewModels/SettingsViewModel.cs index 07876244f..c3b7a2120 100644 --- a/AssetEditor/ViewModels/SettingsViewModel.cs +++ b/AssetEditor/ViewModels/SettingsViewModel.cs @@ -6,9 +6,11 @@ using System.Windows.Input; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.Xna.Framework; using Shared.Core.Misc; using Shared.Core.Services; using Shared.Core.Settings; +using Shared.Ui.BaseDialogs.ColourPickerButton; namespace AssetEditor.ViewModels { @@ -28,6 +30,7 @@ partial class SettingsViewModel : ObservableObject [ObservableProperty] private ThemeType _currentTheme; [ObservableProperty] private BackgroundColour _currentRenderEngineBackgroundColour; [ObservableProperty] private int _visualEditorsGridSize; + [ObservableProperty] private ColourPickerViewModel _vertexSelectionColour; [ObservableProperty] private bool _startMaximised; [ObservableProperty] private GameTypeEnum _currentGame; [ObservableProperty] private bool _loadCaPacksByDefault; @@ -49,6 +52,7 @@ public SettingsViewModel(ApplicationSettingsService settingsService, Localizatio RenderEngineBackgroundColours = new ObservableCollection((BackgroundColour[])Enum.GetValues(typeof(BackgroundColour))); CurrentRenderEngineBackgroundColour = _settingsService.CurrentSettings.RenderEngineBackgroundColour; VisualEditorsGridSize = _settingsService.CurrentSettings.VisualEditorsGridSize; + VertexSelectionColour = new ColourPickerViewModel(_settingsService.CurrentSettings.VertexSelectionColour); CameraModes = new ObservableCollection((CameraControlMode[])Enum.GetValues(typeof(CameraControlMode))); StartMaximised = _settingsService.CurrentSettings.StartMaximised; @@ -79,6 +83,7 @@ private void Save() _settingsService.CurrentSettings.Theme = CurrentTheme; _settingsService.CurrentSettings.RenderEngineBackgroundColour = CurrentRenderEngineBackgroundColour; _settingsService.CurrentSettings.VisualEditorsGridSize = VisualEditorsGridSize; + _settingsService.CurrentSettings.VertexSelectionColour = VertexSelectionColour.SelectedColour; _settingsService.CurrentSettings.StartMaximised = StartMaximised; _settingsService.CurrentSettings.CurrentGame = CurrentGame; _settingsService.CurrentSettings.LoadCaPacksByDefault = LoadCaPacksByDefault; diff --git a/AssetEditor/Views/Settings/SettingsView.xaml b/AssetEditor/Views/Settings/SettingsView.xaml index b3ce1ef27..4d74aa71f 100644 --- a/AssetEditor/Views/Settings/SettingsView.xaml +++ b/AssetEditor/Views/Settings/SettingsView.xaml @@ -7,7 +7,8 @@ xmlns:gameinfo="clr-namespace:Shared.Core.Services;assembly=Shared.Core" xmlns:behaviors="clr-namespace:Shared.Ui.Common.Behaviors;assembly=Shared.Ui" xmlns:loc="clr-namespace:Shared.Ui.Common;assembly=Shared.Ui" - xmlns:local="clr-namespace:AssetEditor.Views.Settings" xmlns:viewmodels="clr-namespace:AssetEditor.ViewModels" + xmlns:local="clr-namespace:AssetEditor.Views.Settings" xmlns:viewmodels="clr-namespace:AssetEditor.ViewModels" + xmlns:colourpickerbutton="clr-namespace:Shared.Ui.BaseDialogs.ColourPickerButton;assembly=Shared.Ui" d:DataContext="{d:DesignInstance Type=viewmodels:SettingsViewModel}" mc:Ignorable="d" > @@ -98,6 +99,7 @@ + @@ -113,6 +115,9 @@ + + + diff --git a/Editors/Kitbashing/KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs b/Editors/Kitbashing/KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs index 0d19fdbc8..0999442cd 100644 --- a/Editors/Kitbashing/KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs +++ b/Editors/Kitbashing/KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs @@ -1,5 +1,4 @@ -using System.Collections.ObjectModel; -using System.Diagnostics; +using System.Collections.ObjectModel; using System.Windows.Input; using Editors.KitbasherEditor.ChildEditors.MeshFitter; using Editors.KitbasherEditor.ChildEditors.PhotoStudio; @@ -30,16 +29,14 @@ public class MenuBarViewModel : IKeyboardHandler private readonly CommandExecutor _commandExecutor; private readonly MenuItemVisibilityRuleEngine _menuItemVisibilityRuleEngine; private readonly ActionHotkeyHandler _hotKeyHandler = new ActionHotkeyHandler(); - private readonly IWindowsKeyboard _keyboard; private readonly LocalizationManager _localizationManager; private readonly Dictionary _uiCommands = new(); - public MenuBarViewModel(CommandExecutor commandExecutor, IEventHub eventHub, MenuItemVisibilityRuleEngine menuItemVisibilityRuleEngine, TransformToolViewModel transformToolViewModel, IUiCommandFactory uiCommandFactory, IWindowsKeyboard windowKeyboard, LocalizationManager localizationManager) + public MenuBarViewModel(CommandExecutor commandExecutor, IEventHub eventHub, MenuItemVisibilityRuleEngine menuItemVisibilityRuleEngine, TransformToolViewModel transformToolViewModel, IUiCommandFactory uiCommandFactory, LocalizationManager localizationManager) { _commandExecutor = commandExecutor; _menuItemVisibilityRuleEngine = menuItemVisibilityRuleEngine; _uiCommandFactory = uiCommandFactory; - _keyboard = windowKeyboard; _localizationManager = localizationManager; TransformTool = transformToolViewModel; diff --git a/Editors/Kitbashing/Test.KitbashEditor/PinTool/SkinWrapRiggingCommandTests.cs b/Editors/Kitbashing/Test.KitbashEditor/PinTool/SkinWrapRiggingCommandTests.cs index f695e13e4..9843abe32 100644 --- a/Editors/Kitbashing/Test.KitbashEditor/PinTool/SkinWrapRiggingCommandTests.cs +++ b/Editors/Kitbashing/Test.KitbashEditor/PinTool/SkinWrapRiggingCommandTests.cs @@ -41,7 +41,7 @@ public void Setup() // SelectionManager has complex constructor and non-virtual methods; // use reflection to set _currentState so GetStateCopy() works. _selectionManagerMock = new Mock(MockBehavior.Loose, - null!, null!, null!, null!, null!); + null!, null!, null!, null!, null!, null!); var selectionState = Mock.Of(s => s.Clone() == Mock.Of()); var stateField = typeof(SelectionManager).GetField("_currentState", BindingFlags.NonPublic | BindingFlags.Instance)!; diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Components/Selection/SelectionManager.cs b/GameWorld/GameWorldCore/GameWorld.Core/Components/Selection/SelectionManager.cs index a157d2c04..699ac73bb 100644 --- a/GameWorld/GameWorldCore/GameWorld.Core/Components/Selection/SelectionManager.cs +++ b/GameWorld/GameWorldCore/GameWorld.Core/Components/Selection/SelectionManager.cs @@ -7,6 +7,7 @@ using GameWorld.Core.Utility; using Microsoft.Xna.Framework; using Shared.Core.Events; +using Shared.Core.Settings; namespace GameWorld.Core.Components.Selection { @@ -31,6 +32,7 @@ public class SelectionManager : BaseComponent, IDisposable private readonly IScopedResourceLibrary _resourceLib; private readonly IDeviceResolver _deviceResolverComponent; private readonly IGraphicsResourceCreator _graphicsResourceCreator; + private readonly ApplicationSettingsService _settingsService; private (int v0, int v1)[] _cachedEdgeIndices; private Rmv2MeshNode _cachedEdgeMesh; @@ -43,13 +45,14 @@ public class SelectionManager : BaseComponent, IDisposable const int MaxRenderEdges = 50000; private readonly EdgeData[] _edgeDataCache = new EdgeData[MaxRenderEdges]; - public SelectionManager(IEventHub eventHub, RenderEngineComponent renderEngine, IScopedResourceLibrary resourceLib, IDeviceResolver deviceResolverComponent, IGraphicsResourceCreator graphicsResourceCreator) + public SelectionManager(IEventHub eventHub, RenderEngineComponent renderEngine, IScopedResourceLibrary resourceLib, IDeviceResolver deviceResolverComponent, IGraphicsResourceCreator graphicsResourceCreator, ApplicationSettingsService settingsService) { _eventHub = eventHub; _renderEngine = renderEngine; _resourceLib = resourceLib; _deviceResolverComponent = deviceResolverComponent; _graphicsResourceCreator = graphicsResourceCreator; + _settingsService = settingsService; } public override void Initialize() @@ -72,7 +75,6 @@ public override void Initialize() base.Initialize(); } - public ISelectionState CreateSelectionSate(GeometrySelectionMode mode, ISelectable selectedObj, bool sendEvent = true) { if (_currentState != null) @@ -120,6 +122,9 @@ private void SelectionManager_SelectionChanged(ISelectionState state, bool sendE public override void Draw(GameTime gameTime) { + if (_vertexRenderer != null) + _vertexRenderer.SelectionColour = _settingsService.CurrentSettings.VertexSelectionColour; + var selectionState = GetState(); if (selectionState is ObjectSelectionState objectSelectionState) @@ -147,6 +152,8 @@ public override void Draw(GameTime gameTime) _cachedEdgeMesh = vertexObject; _cachedEdgeIndices = BuildEdgeIndexCache(geo); _edgeDataDirty = true; + _sampleIdx0 = 0; + _sampleIdx1 = geo.VertexCount() > 1 ? 1 : 0; } if (selectionVertexState.SelectedVertices.Count >= 2) @@ -160,7 +167,14 @@ public override void Draw(GameTime gameTime) _sampleIdx1 = _sampleIdx0 < geo.VertexCount() - 1 ? _sampleIdx0 + 1 : 0; } - if (!_edgeDataDirty && geo.VertexCount() >= 2) + var vertexCount = geo.VertexCount(); + if (vertexCount > 0) + { + _sampleIdx0 = Math.Clamp(_sampleIdx0, 0, vertexCount - 1); + _sampleIdx1 = Math.Clamp(_sampleIdx1, 0, vertexCount - 1); + } + + if (!_edgeDataDirty && vertexCount >= 2) { var p0 = geo.GetVertexById(_sampleIdx0); var p1 = geo.GetVertexById(_sampleIdx1); @@ -173,7 +187,7 @@ public override void Draw(GameTime gameTime) UpdateEdgeQuadData(vertexObject, selectionVertexState); _edgeDataDirty = false; - if (geo.VertexCount() >= 2) + if (vertexCount >= 2) { _samplePos0 = geo.GetVertexById(_sampleIdx0); _samplePos1 = geo.GetVertexById(_sampleIdx1); diff --git a/GameWorld/GameWorldCore/GameWorld.Core/Rendering/VertexInstanceMesh.cs b/GameWorld/GameWorldCore/GameWorld.Core/Rendering/VertexInstanceMesh.cs index 79b30ed73..0a7b7123b 100644 --- a/GameWorld/GameWorldCore/GameWorld.Core/Rendering/VertexInstanceMesh.cs +++ b/GameWorld/GameWorldCore/GameWorld.Core/Rendering/VertexInstanceMesh.cs @@ -49,9 +49,9 @@ public class VertexInstanceMesh : IDisposable readonly int _maxInstanceCount = 50000; int _currentInstanceCount; - Vector3 _selectedColour = new(1.0f, 0.47f, 0.0f); Vector3 _deselectedColour = new(0.0f, 0.0f, 0.0f); + public Vector3 SelectionColour { get; set; } = new Vector3(1.0f, 0.47f, 0.0f); public float VertexPixelSize { get; set; } = 5.5f; public float SelectedSizeBoost { get; set; } = 2.0f; public float SelectionThresholdMultiplier { get; set; } = 2.0f; @@ -108,7 +108,7 @@ public void Update(MeshObject geo, Matrix modelMatrix, Vector3 cameraPos, var distance = (cameraPos - vertPos).Length(); var weight = selectedVertexes.VertexWeights[i]; - var color = Vector3.Lerp(_deselectedColour, _selectedColour, weight); + var color = Vector3.Lerp(_deselectedColour, SelectionColour, weight); var effectivePixelSize = VertexPixelSize + weight * SelectedSizeBoost; var worldScale = effectivePixelSize * distance * fovScale; diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Selection/SelectionManagerTests.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Selection/SelectionManagerTests.cs new file mode 100644 index 000000000..a7ebed419 --- /dev/null +++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Selection/SelectionManagerTests.cs @@ -0,0 +1,141 @@ +using GameWorld.Core.Components.Rendering; +using GameWorld.Core.Components.Selection; +using GameWorld.Core.Rendering; +using GameWorld.Core.Rendering.Geometry; +using GameWorld.Core.Rendering.Materials.Shaders; +using GameWorld.Core.Rendering.RenderItems; +using GameWorld.Core.SceneNodes; +using GameWorld.Core.Test.TestUtility; +using Microsoft.Xna.Framework; +using Moq; +using Shared.Core.Events; +using Shared.Core.Settings; +using Shared.GameFormats.RigidModel.MaterialHeaders; +using System.Reflection; +using Test.GameWorld.Core.WsMaterialTemplate; + +namespace GameWorld.Core.Test.Selection +{ + // This test is terrible! Keeping it so that I know what to change later + + [TestFixture] + public class SelectionManagerTests + { + private Mock _eventHubMock; +#pragma warning disable NUnit1032 // RenderEngine constructed with null deps cannot be Disposed safely + private RenderEngineComponent _renderEngine; +#pragma warning restore NUnit1032 + private SelectionManager _selectionManager; + + [TearDown] + public void TearDown() + { + _selectionManager?.Dispose(); + } + + [SetUp] + public void SetUp() + { + _eventHubMock = new Mock(); + + _renderEngine = new RenderEngineComponent(null, null, null, null, null, null, _eventHubMock.Object, null); + + _selectionManager = new SelectionManager( + _eventHubMock.Object, + _renderEngine, + null, + null, + null, + new ApplicationSettingsService()); + + // Set render items that are normally created in Initialize() (requires GPU) + SetPrivateField(_selectionManager, "_edgeQuadRenderItem", new EdgeQuadRenderItem()); + SetPrivateField(_selectionManager, "_vertexRenderItem", new VertexRenderItem()); + } + + [Test] + public void Draw_SwitchFromLargeMeshToSmallMesh_NoIndexOutOfRange() + { + // Arrange: mesh A with 20 vertices, select vertex 15 + var nodeA = CreateMeshNode(vertexCount: 20); + var stateA = new VertexSelectionState(nodeA, 0); + stateA.ModifySelection(new[] { 15 }, onlyRemove: false); + _selectionManager.SetState(stateA); + _selectionManager.Draw(new GameTime()); + + // Switch to mesh B with only 3 vertices, no selection + var nodeB = CreateMeshNode(vertexCount: 3); + var stateB = new VertexSelectionState(nodeB, 0); + _selectionManager.SetState(stateB); + + // Act & Assert: stale _sampleIdx0=15 must not cause IndexOutOfRangeException + Assert.DoesNotThrow(() => _selectionManager.Draw(new GameTime())); + } + + [Test] + public void Draw_ZeroSelectedVerticesAfterHighIndexSelection_NoIndexOutOfRange() + { + // Arrange: mesh with 20 vertices, select high-index vertices + var node = CreateMeshNode(vertexCount: 20); + var state = new VertexSelectionState(node, 0); + state.ModifySelection(new[] { 15, 16 }, onlyRemove: false); + _selectionManager.SetState(state); + _selectionManager.Draw(new GameTime()); + + // Deselect everything — stale indices persist with same mesh + state.ModifySelection(new[] { 15, 16 }, onlyRemove: true); + + // Act & Assert: stale indices are still valid for same mesh + Assert.DoesNotThrow(() => _selectionManager.Draw(new GameTime())); + } + + [Test] + public void Draw_SwitchFromLargeMeshToSmallMesh_IndicesAreClamped() + { + // Arrange: mesh A with 20 vertices, select vertex 18 + var nodeA = CreateMeshNode(vertexCount: 20); + var stateA = new VertexSelectionState(nodeA, 0); + stateA.ModifySelection(new[] { 18, 19 }, onlyRemove: false); + _selectionManager.SetState(stateA); + _selectionManager.Draw(new GameTime()); + + // Switch to mesh B with only 3 vertices, select vertex 2 + var nodeB = CreateMeshNode(vertexCount: 3); + var stateB = new VertexSelectionState(nodeB, 0); + stateB.ModifySelection(new[] { 2 }, onlyRemove: false); + _selectionManager.SetState(stateB); + + // Act & Assert: should handle the index clamping safely + Assert.DoesNotThrow(() => _selectionManager.Draw(new GameTime())); + } + + private static Rmv2MeshNode CreateMeshNode(int vertexCount) + { + var contextFactory = new TestGeometryGraphicsContextFactory(); + var mesh = new MeshObject(contextFactory.Create(), "test_skeleton"); + mesh.VertexArray = new VertexPositionNormalTextureCustom[vertexCount]; + for (var i = 0; i < vertexCount; i++) + mesh.VertexArray[i] = new VertexPositionNormalTextureCustom { Position = new Vector4(i, 0, 0, 1) }; + + // Build a valid triangle index array + var indices = new List(); + for (var i = 0; i < vertexCount - 2; i++) + indices.AddRange(new ushort[] { 0, (ushort)(i + 1), (ushort)(i + 2) }); + mesh.IndexArray = indices.ToArray(); + + var materialMock = new Mock(); + materialMock.Setup(m => m.ModelName).Returns("test_mesh"); + materialMock.Setup(m => m.PivotPoint).Returns(Vector3.Zero); + + var shader = new CapabilityMaterialMock(CapabilityMaterialsEnum.SpecGlossPbr_Default); + + return new Rmv2MeshNode(mesh, materialMock.Object, shader, null); + } + + private static void SetPrivateField(object obj, string fieldName, object value) + { + var field = obj.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance); + field!.SetValue(obj, value); + } + } +} diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Services/SkeletonAnimationLookUpHelperKarlPackTests.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Services/SkeletonAnimationLookUpHelperKarlPackTests.cs index e9170bd6d..73a43df35 100644 --- a/GameWorld/GameWorldCore/GameWorld.CoreTest/Services/SkeletonAnimationLookUpHelperKarlPackTests.cs +++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Services/SkeletonAnimationLookUpHelperKarlPackTests.cs @@ -7,6 +7,7 @@ using Shared.Core.PackFiles; using Shared.Core.PackFiles.Models; using Shared.Core.PackFiles.Utility; +using Shared.Core.Services; using Shared.Core.Settings; using Test.TestingUtility.TestUtility; @@ -31,7 +32,7 @@ public void LoadAndUnload_KarlPack_UpdatesSkeletonAndAnimationLookup() .Returns(() => containers.ToList()); var settingsService = new ApplicationSettingsService(GameTypeEnum.Warhammer3); - var loader = new PackFileContainerLoader(settingsService); + var loader = new PackFileContainerLoader(settingsService, new Mock().Object, new LocalizationManager()); var karlPackPath = PathHelper.GetDataFile("Karl_and_celestialgeneral.pack"); var karlContainer = loader.Load(karlPackPath); diff --git a/README.md b/README.md index 262d28658..75fed6542 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,70 @@ -Modding tool for total war games -Features: -- Super easy to use 3d editor which supports saving and loading to all game formats (rmv2) -- Animation editor that easily allows for moving animation between skeletons or making mount animations where there is none. -- Lots of other cool stuff, check it out! - -![image](https://user-images.githubusercontent.com/54080240/143955132-badd843e-b823-4a4d-8326-e64be4e2c877.png) - - -Planned major features: -- Mesh verifications (Show normals, ensure weights) -- Animation transfer tool -- Flip faces tool/Invert normals -- Rendering improvements -- Improve re-rigging -- Auto rig mesh to mesh -- Load anim 4 save rmv2 -- UV selection/display -- Realtime meta editor/full preview -- Animated prop creator -- Split face tool -- Campagin animation bin editor -- Improve animPack editing - -Known bugs: -- Pivot/Normal bug -- Some older vmds not loading -- Some anims for 3k not loading +![AssetEditor](https://user-images.githubusercontent.com/54080240/143955132-badd843e-b823-4a4d-8326-e64be4e2c877.png) + +# The Asset Editor + +[![Build & Test](https://github.com/donkeyProgramming/TheAssetEditor/actions/workflows/pr-test.yml/badge.svg)](https://github.com/donkeyProgramming/TheAssetEditor/actions/workflows/pr-test.yml) +[![Latest release](https://img.shields.io/github/v/release/donkeyProgramming/TheAssetEditor)](https://github.com/donkeyProgramming/TheAssetEditor/releases/latest) +[![Downloads](https://img.shields.io/github/downloads/donkeyProgramming/TheAssetEditor/total)](https://github.com/donkeyProgramming/TheAssetEditor/releases) + +A powerful modding tool for Total War games. Provides a full suite of editors for 3D models, animations, audio, textures, and more — all in one integrated desktop application. + +**[Documentation](https://donkeyprogramming.github.io/TheAssetEditor/)** · **[Video Tutorial](https://www.youtube.com/watch?v=ZlmG2AVL5-g)** · **[Latest Release](https://github.com/donkeyProgramming/TheAssetEditor/releases/latest)** + +--- + +## Editors + +### Kitbasher (3D Editor) +Full-featured 3D mesh editor for creating composite models by combining parts from multiple meshes. Supports material editing, mesh operations (merge, split, duplicate), vertex/face/object selection modes, and real-time 3D preview with a MonoGame-based viewport. + +### Visual Metadata Editor +Editing interface for animation metadata. Supports manipulation of animation tags, attributes, and timing properties that control how animations blend and trigger in-game. + +### Audio Editor +Comprehensive audio project editor with support for Wwise-based audio exploration, project creation, waveform visualization, format conversion, and dialogue event management. + +### Animation Re-Targeting +Retargets animations between different skeletons, allowing you to transfer animations from one character model to another. Provides bone mapping and re-rigging capabilities for cross-skeleton animation reuse. + +### Skeleton Editor +Visual skeleton editor for creating, editing, and rigging skeletal structures. Supports bone manipulation, weight visualization, and reference mesh display for precise rigging workflows. + +### Texture Editor +Texture preview and editing tool supporting all game texture formats. Provides metadata inspection and visual inspection of textures used by game models. + +### Animation Fragment Editor +Manages animation packs (.animpack files) and campaign animation bin files. Includes a batch animation exporter for bulk format conversions across your mod. + +### Import/Export Editor +Handles bidirectional import/export of game asset formats, supporting GLTF/FBX to RMV2 conversions for bringing external 3D assets into the game. + +### Reports +Generates analysis and diagnostic reports including material reports, mesh info dumps, metadata JSON exports, deep file searches, and dialogue event information. + +--- + +## Getting Started + +1. Download the [latest release](https://github.com/donkeyProgramming/TheAssetEditor/releases/latest) +2. Extract and run `AssetEditor.exe` +3. Load your game's pack files and start editing + +For detailed guides, see the **[Documentation](https://donkeyprogramming.github.io/TheAssetEditor/)**. + +## Building from Source + +Prerequisites: .NET 10 SDK, Windows + +```bash +git clone https://github.com/donkeyProgramming/TheAssetEditor.git +cd TheAssetEditor +dotnet build AssetEditor.sln +``` + +## Contributing + +Pull requests are welcome. The project uses a modular architecture with dependency injection — each editor is a self-contained project under the `Editors/` folder. + +## License + +See the repository for license details. diff --git a/Shared/SharedCore/Shared.Core/DependencyInjectionContainer.cs b/Shared/SharedCore/Shared.Core/DependencyInjectionContainer.cs index 39bd802a2..34170e869 100644 --- a/Shared/SharedCore/Shared.Core/DependencyInjectionContainer.cs +++ b/Shared/SharedCore/Shared.Core/DependencyInjectionContainer.cs @@ -37,7 +37,7 @@ public override void Register(IServiceCollection services) services.AddSingleton(); - services.AddSingleton(); + services.AddTransient(); } } diff --git a/Shared/SharedCore/Shared.Core/Events/Global/PackFileSavedEvent.cs b/Shared/SharedCore/Shared.Core/Events/Global/PackFileSavedEvent.cs index 760413d6b..57bc733f8 100644 --- a/Shared/SharedCore/Shared.Core/Events/Global/PackFileSavedEvent.cs +++ b/Shared/SharedCore/Shared.Core/Events/Global/PackFileSavedEvent.cs @@ -14,7 +14,7 @@ public record PackFileContainerFilesUpdatedEvent(IPackFileContainer Container, L public record PackFileContainerFilesAddedEvent(IPackFileContainer Container, List AddedFiles) : PackFileContainerManipulationEvent; public record PackFileContainerFilesRemovedEvent(IPackFileContainer Container, List RemovedFiles) : PackFileContainerManipulationEvent; public record PackFileContainerFolderRemovedEvent(IPackFileContainer Container, string Folder) : PackFileContainerManipulationEvent; - public record PackFileContainerFolderRenamedEvent(IPackFileContainer Container, string NewNodePath) : PackFileContainerManipulationEvent; + public record PackFileContainerFolderRenamedEvent(IPackFileContainer Container, string OldNodePath, string NewNodePath) : PackFileContainerManipulationEvent; public class BeforePackFileContainerRemovedEvent(IPackFileContainer removed) { diff --git a/Shared/SharedCore/Shared.Core/Misc/DirectoryHelper.cs b/Shared/SharedCore/Shared.Core/Misc/DirectoryHelper.cs index 68a206fed..8c71f84e9 100644 --- a/Shared/SharedCore/Shared.Core/Misc/DirectoryHelper.cs +++ b/Shared/SharedCore/Shared.Core/Misc/DirectoryHelper.cs @@ -36,6 +36,7 @@ public class DirectoryHelper public static string Applications { get { return ApplicationDirectory + "\\Applications"; } } public static string Temp { get { return ApplicationDirectory + "\\Temp"; } } public static string UpdateDirectory { get { return Temp + "\\Update"; } } + public static string CacheDirectory { get => ApplicationDirectory + "\\Cache"; } public static string AnimationIndexMappingDirectory { get { return ApplicationDirectory + "\\Animation\\BoneIndexMapping"; } } public static void EnsureCreated() @@ -47,6 +48,7 @@ public static void EnsureCreated() EnsureCreated(Applications); EnsureCreated(Temp); EnsureCreated(AnimationIndexMappingDirectory); + EnsureCreated(CacheDirectory); } public static void EnsureCreated(string? path) diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/CachedPackFileContainer.cs b/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/CachedPackFileContainer.cs index a9b84215d..ee3bafa20 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/CachedPackFileContainer.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/CachedPackFileContainer.cs @@ -1,55 +1,162 @@ -using Shared.Core.Settings; +using Microsoft.EntityFrameworkCore; +using Shared.Core.PackFiles.Models.FileSources; +using Shared.Core.PackFiles.Serialization.CacheDatabase; +using Shared.Core.PackFiles.Utility; +using Shared.Core.Settings; namespace Shared.Core.PackFiles.Models.Containers { internal class CachedPackFileContainer : IPackFileContainerInternal { + private readonly DbContextOptions _dbOptions; + public string Name { get; set; } public bool IsCaPackFile { get => true; set { } } public string SystemFilePath { get; set; } - public Dictionary FileList { get; set; } = []; public HashSet SourcePackFilePaths { get; set; } = []; - public CachedPackFileContainer(string name) + public CachedPackFileContainer(string name, DbContextOptions dbOptions) { Name = name; SystemFilePath = string.Empty; + _dbOptions = dbOptions; } - public int GetFileCount() => FileList.Count; - public Dictionary GetAllFiles() => FileList; - - public List<(string FileName, PackFile Pack)> FindAllWithExtention(string extention) + public int GetFileCount() { - extention = extention.ToLower(); - var output = new List<(string, PackFile)>(); - foreach (var file in FileList) - { - if (Path.GetExtension(file.Key) == extention) - output.Add((file.Key, file.Value)); - } - return output; + using var db = new CacheDbContext(_dbOptions); + return db.Files.Count(); } public PackFile? FindFile(string path) { - var lowerPath = path.Replace('/', '\\').ToLower().Trim(); - return FileList.TryGetValue(lowerPath, out var value) ? value : null; + var lowerPath = PathNormalization.NormalizeFileName(path); + using var db = new CacheDbContext(_dbOptions); + var entry = db.Files.AsNoTracking().FirstOrDefault(f => f.RelativePath == lowerPath); + return entry != null ? ToPackFile(entry) : null; } public bool ContainsFile(string path) { - var lowerPath = path.Replace('/', '\\').ToLower().Trim(); - return FileList.ContainsKey(lowerPath); + var lowerPath = PathNormalization.NormalizeFileName(path); + using var db = new CacheDbContext(_dbOptions); + return db.Files.Any(f => f.RelativePath == lowerPath); } public string? GetFullPath(PackFile file) { - var res = FileList.FirstOrDefault(x => ReferenceEquals(x.Value, file)).Key; - return string.IsNullOrWhiteSpace(res) ? null : res; + using var db = new CacheDbContext(_dbOptions); + var entry = db.Files.AsNoTracking().FirstOrDefault(f => f.FileName == file.Name); + return entry?.RelativePath; + } + + public List<(string FileName, PackFile Pack)> FindAllWithExtention(string extention) + { + extention = extention.ToLower(); + using var db = new CacheDbContext(_dbOptions); + var entries = db.Files.AsNoTracking() + .Where(f => f.Extension == extention) + .ToList(); + + return entries.Select(e => (e.RelativePath, ToPackFile(e))).ToList(); + } + + public Dictionary GetAllFiles() + { + using var db = new CacheDbContext(_dbOptions); + var entries = db.Files.AsNoTracking().ToList(); + var parentCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + var result = new Dictionary(entries.Count); + + foreach (var entry in entries) + { + if (!parentCache.TryGetValue(entry.SourcePackFilePath, out var parent)) + { + parent = new PackedFileSourceParent { FilePath = entry.SourcePackFilePath }; + parentCache[entry.SourcePackFilePath] = parent; + } + + var source = new PackedFileSource( + parent, entry.Offset, entry.Size, + entry.IsEncrypted, entry.IsCompressed, + (Utility.CompressionFormat)entry.CompressionFormat, entry.UncompressedSize); + + result[entry.RelativePath] = new PackFile(entry.FileName, source); + } + + return result; } - public void AddOrUpdateFile(string path, PackFile file) => throw new InvalidOperationException("Cannot modify a cached CA pack file container."); + public void AddOrUpdateFile(string path, PackFile file) => + throw new InvalidOperationException("Cannot modify a cached CA pack file container."); + + public DirectoryContent GetDirectoryContent(string directoryPath) + { + using var db = new CacheDbContext(_dbOptions); + + // Get files directly in this folder + var filesInDir = db.Files.AsNoTracking() + .Where(f => f.FolderPath == directoryPath) + .Select(f => new { f.FileName, f.SourcePackFilePath, f.Offset, f.Size, f.IsEncrypted, f.IsCompressed, f.CompressionFormat, f.UncompressedSize }) + .ToList(); + + var files = filesInDir + .Select(f => + { + var parent = new PackedFileSourceParent { FilePath = f.SourcePackFilePath }; + var source = new PackedFileSource(parent, f.Offset, f.Size, f.IsEncrypted, f.IsCompressed, (Utility.CompressionFormat)f.CompressionFormat, f.UncompressedSize); + return (f.FileName, File: new PackFile(f.FileName, source)); + }) + .OrderBy(x => x.FileName, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + + // Get immediate subfolders + var prefix = string.IsNullOrEmpty(directoryPath) ? "" : directoryPath + "\\"; + var prefixLen = prefix.Length; + + List subFolders; + if (string.IsNullOrEmpty(directoryPath)) + { + // Root level: get distinct first path segment from all FolderPaths + subFolders = db.Files.AsNoTracking() + .Where(f => f.FolderPath != "") + .Select(f => f.FolderPath) + .Distinct() + .AsEnumerable() + .Select(fp => + { + var sepIdx = fp.IndexOf('\\'); + return sepIdx == -1 ? fp : fp.Substring(0, sepIdx); + }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + } + else + { + // Non-root: get distinct next segment from FolderPaths that start with prefix + subFolders = db.Files.AsNoTracking() + .Where(f => f.FolderPath.StartsWith(prefix)) + .Select(f => f.FolderPath) + .Distinct() + .AsEnumerable() + .Select(fp => + { + var remainder = fp.Substring(prefixLen); + var sepIdx = remainder.IndexOf('\\'); + return sepIdx == -1 ? remainder : remainder.Substring(0, sepIdx); + }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + } + + return new DirectoryContent + { + SubFolders = subFolders, + Files = files + }; + } public List AddFiles(List newFiles) => throw new InvalidOperationException("Cannot modify a cached CA pack file container."); @@ -74,5 +181,15 @@ public void SaveFileData(PackFile file, byte[] data) => public void SaveToDisk(string path, bool createBackup, GameInformation gameInformation) => throw new InvalidOperationException("Cannot modify a cached CA pack file container."); + + private static PackFile ToPackFile(CachedFileEntity entry) + { + var parent = new PackedFileSourceParent { FilePath = entry.SourcePackFilePath }; + var source = new PackedFileSource( + parent, entry.Offset, entry.Size, + entry.IsEncrypted, entry.IsCompressed, + (Utility.CompressionFormat)entry.CompressionFormat, entry.UncompressedSize); + return new PackFile(entry.FileName, source); + } } } diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/PackFileContainer.cs b/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/PackFileContainer.cs index 425d23638..093fa3087 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/PackFileContainer.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/PackFileContainer.cs @@ -1,6 +1,7 @@ using Shared.Core.Misc; using Shared.Core.PackFiles.Models.FileSources; using Shared.Core.PackFiles.Serialization; +using Shared.Core.PackFiles.Utility; using Shared.Core.Settings; namespace Shared.Core.PackFiles.Models.Containers @@ -20,12 +21,47 @@ internal class PackFileContainer : IPackFileContainerInternal public void AddOrUpdateFile(string path, PackFile file) { - var lowerPath = path.Replace('/', '\\').ToLower().Trim(); + var lowerPath = PathNormalization.NormalizeFileName(path); FileList[lowerPath] = file; } public Dictionary GetAllFiles() => FileList; + public DirectoryContent GetDirectoryContent(string directoryPath) + { + var prefix = string.IsNullOrEmpty(directoryPath) ? "" : directoryPath + "\\"; + var prefixLength = prefix.Length; + var subFolders = new HashSet(StringComparer.OrdinalIgnoreCase); + var files = new List<(string FileName, PackFile File)>(); + + foreach (var (path, packFile) in FileList) + { + if (prefixLength > 0 && !path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + continue; + if (prefixLength == 0 && path.Length == 0) + continue; + + var remainder = path.AsSpan(prefixLength); + var separatorIndex = remainder.IndexOf(Path.DirectorySeparatorChar); + + if (separatorIndex == -1) + { + files.Add((packFile.Name, packFile)); + } + else + { + var folderName = remainder.Slice(0, separatorIndex).ToString(); + subFolders.Add(folderName); + } + } + + return new DirectoryContent + { + SubFolders = subFolders.OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase).ToList(), + Files = files.OrderBy(x => x.FileName, StringComparer.CurrentCultureIgnoreCase).ToList() + }; + } + public List<(string FileName, PackFile Pack)> FindAllWithExtention(string extention) { extention = extention.ToLower(); @@ -101,13 +137,13 @@ public virtual void DeleteFolder(string folder) public virtual PackFile? FindFile(string path) { - var lowerPath = path.Replace('/', '\\').ToLower().Trim(); + var lowerPath = PathNormalization.NormalizeFileName(path); return FileList.TryGetValue(lowerPath, out var value) ? value : null; } public virtual bool ContainsFile(string path) { - var lowerPath = path.Replace('/', '\\').ToLower().Trim(); + var lowerPath = PathNormalization.NormalizeFileName(path); return FileList.ContainsKey(lowerPath); } diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Models/IPackFileContainer.cs b/Shared/SharedCore/Shared.Core/PackFiles/Models/IPackFileContainer.cs index 07c0a7cf5..e20975ffc 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/Models/IPackFileContainer.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/Models/IPackFileContainer.cs @@ -1,5 +1,19 @@ -namespace Shared.Core.PackFiles.Models +using System.Collections.Generic; + +namespace Shared.Core.PackFiles.Models { + public readonly struct DirectoryContent + { + public IReadOnlyList SubFolders { get; init; } + public IReadOnlyList<(string FileName, PackFile File)> Files { get; init; } + + public static DirectoryContent Empty => new() + { + SubFolders = [], + Files = [] + }; + } + public interface IPackFileContainer { string Name { get; } @@ -14,5 +28,6 @@ public interface IPackFileContainer void AddOrUpdateFile(string path, PackFile file); List<(string FileName, PackFile Pack)> FindAllWithExtention(string extention); Dictionary GetAllFiles(); + DirectoryContent GetDirectoryContent(string directoryPath); } } diff --git a/Shared/SharedCore/Shared.Core/PackFiles/PackFileService.cs b/Shared/SharedCore/Shared.Core/PackFiles/PackFileService.cs index a7b65b8da..6b6ac60e9 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/PackFileService.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/PackFileService.cs @@ -6,6 +6,7 @@ using Shared.Core.PackFiles.Models.Containers; using Shared.Core.PackFiles.Models.FileSources; using Shared.Core.PackFiles.Serialization; +using Shared.Core.PackFiles.Utility; using Shared.Core.Settings; namespace Shared.Core.PackFiles @@ -103,7 +104,7 @@ public void CopyFileFromOtherPackFile(IPackFileContainer source, string path, IP var sourceContainer = CastContainer(source); var targetContainer = CastContainer(target); - var lowerPath = path.Replace('/', '\\').ToLower().Trim(); + var lowerPath = PathNormalization.NormalizeFileName(path); var file = sourceContainer.FindFile(lowerPath); if (file != null) { @@ -196,7 +197,7 @@ public void RenameDirectory(IPackFileContainer pf, string currentNodeName, strin throw new Exception("Name can not be empty"); var newNodePath = container.RenameDirectory(currentNodeName, newName); - _globalEventHub?.PublishGlobalEvent(new PackFileContainerFolderRenamedEvent(container, newNodePath)); + _globalEventHub?.PublishGlobalEvent(new PackFileContainerFolderRenamedEvent(container, currentNodeName, newNodePath)); } public void RenameFile(IPackFileContainer pf, PackFile file, string newName) diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Serialization/CacheDatabase/CacheDbContext.cs b/Shared/SharedCore/Shared.Core/PackFiles/Serialization/CacheDatabase/CacheDbContext.cs new file mode 100644 index 000000000..03ef10bba --- /dev/null +++ b/Shared/SharedCore/Shared.Core/PackFiles/Serialization/CacheDatabase/CacheDbContext.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore; + +namespace Shared.Core.PackFiles.Serialization.CacheDatabase +{ + internal class CacheDbContext : DbContext + { + public DbSet CacheInfo { get; set; } + public DbSet Files { get; set; } + + public CacheDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("CacheInfo"); + entity.HasKey(e => e.Id); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("FileList"); + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.RelativePath).IsUnique(); + entity.HasIndex(e => e.Extension); + entity.HasIndex(e => e.FolderPath); + }); + } + } + + internal class CacheInfoEntity + { + public int Id { get; set; } + public int SchemaVersion { get; set; } + public string Fingerprint { get; set; } = ""; + public string ContainerName { get; set; } = ""; + public string SystemFilePath { get; set; } = ""; + public string SourcePackFilePaths { get; set; } = ""; + } + + internal class CachedFileEntity + { + public int Id { get; set; } + public string RelativePath { get; set; } = ""; + public string FileName { get; set; } = ""; + public string Extension { get; set; } = ""; + public string FolderPath { get; set; } = ""; + public string SourcePackFilePath { get; set; } = ""; + public long Offset { get; set; } + public long Size { get; set; } + public bool IsEncrypted { get; set; } + public bool IsCompressed { get; set; } + public int CompressionFormat { get; set; } + public uint UncompressedSize { get; set; } + } +} diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Serialization/CacheDatabase/PackFileContainerCacheHelper.cs b/Shared/SharedCore/Shared.Core/PackFiles/Serialization/CacheDatabase/PackFileContainerCacheHelper.cs new file mode 100644 index 000000000..950656807 --- /dev/null +++ b/Shared/SharedCore/Shared.Core/PackFiles/Serialization/CacheDatabase/PackFileContainerCacheHelper.cs @@ -0,0 +1,236 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Shared.Core.ErrorHandling; +using Shared.Core.Misc; +using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.PackFiles.Models.FileSources; + +namespace Shared.Core.PackFiles.Serialization.CacheDatabase +{ + internal static class PackFileContainerCacheHelper + { + private static readonly ILogger _logger = Logging.CreateStatic(typeof(PackFileContainerCacheHelper)); + public static string GetCacheFilePath(string gameDataFolder, string gameName, string cacheId) + { + var safeGameName = string.Join("_", gameName.Split(Path.GetInvalidFileNameChars())); + return Path.Combine(DirectoryHelper.CacheDirectory, $"CachedGameFiles_{safeGameName}_{cacheId}.db"); + } + + public static string ComputeFingerprint(string gameDataFolder, List packFileNames) + { + using var sha = SHA256.Create(); + var sb = new StringBuilder(); + + foreach (var packFileName in packFileNames.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) + { + var fullPath = Path.Combine(gameDataFolder, packFileName); + if (File.Exists(fullPath)) + { + var info = new FileInfo(fullPath); + sb.Append(packFileName); + sb.Append('|'); + sb.Append(info.Length); + sb.Append('|'); + sb.Append(info.LastWriteTimeUtc.Ticks); + sb.Append(';'); + } + } + + var manifestPath = Path.Combine(gameDataFolder, "manifest.txt"); + if (File.Exists(manifestPath)) + { + var info = new FileInfo(manifestPath); + sb.Append("manifest.txt|"); + sb.Append(info.Length); + sb.Append('|'); + sb.Append(info.LastWriteTimeUtc.Ticks); + } + + var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString())); + return Convert.ToHexString(hash); + } + + public static DbContextOptions CreateDbOptions(string dbFilePath) + { + return new DbContextOptionsBuilder() + .UseSqlite($"Data Source={dbFilePath};Pooling=False") + .Options; + } + + private const int CurrentSchemaVersion = 3; + + public static void SaveCache(string fingerprint, PackFileContainer container, DbContextOptions dbOptions) + { + _logger.Here().Information($"Saving cache for '{container.Name}' with {container.GetFileCount()} files"); + + // Use EF only to create the schema, then dispose to release memory + using (var db = new CacheDbContext(dbOptions)) + { + db.Database.EnsureDeleted(); + db.Database.EnsureCreated(); + } + + // Use raw SQLite for bulk insert - avoids EF change tracker memory and perf overhead + var connectionString = $"Data Source={GetDbFilePath(dbOptions)};Pooling=False"; + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + using var transaction = connection.BeginTransaction(); + + // Insert CacheInfo + using (var cmd = connection.CreateCommand()) + { + cmd.Transaction = transaction; + cmd.CommandText = @"INSERT INTO CacheInfo (SchemaVersion, Fingerprint, ContainerName, SystemFilePath, SourcePackFilePaths) + VALUES ($schemaVersion, $fingerprint, $containerName, $systemFilePath, $sourcePackFilePaths)"; + cmd.Parameters.AddWithValue("$schemaVersion", CurrentSchemaVersion); + cmd.Parameters.AddWithValue("$fingerprint", fingerprint); + cmd.Parameters.AddWithValue("$containerName", container.Name); + cmd.Parameters.AddWithValue("$systemFilePath", container.SystemFilePath); + cmd.Parameters.AddWithValue("$sourcePackFilePaths", string.Join("|", container.SourcePackFilePaths)); + cmd.ExecuteNonQuery(); + } + + // Bulk insert files using a prepared statement + using (var cmd = connection.CreateCommand()) + { + cmd.Transaction = transaction; + cmd.CommandText = @"INSERT INTO FileList (RelativePath, FileName, Extension, FolderPath, SourcePackFilePath, Offset, Size, IsEncrypted, IsCompressed, CompressionFormat, UncompressedSize) + VALUES ($relativePath, $fileName, $extension, $folderPath, $sourcePackFilePath, $offset, $size, $isEncrypted, $isCompressed, $compressionFormat, $uncompressedSize)"; + + var pRelativePath = cmd.Parameters.Add("$relativePath", SqliteType.Text); + var pFileName = cmd.Parameters.Add("$fileName", SqliteType.Text); + var pExtension = cmd.Parameters.Add("$extension", SqliteType.Text); + var pFolderPath = cmd.Parameters.Add("$folderPath", SqliteType.Text); + var pSourcePackFilePath = cmd.Parameters.Add("$sourcePackFilePath", SqliteType.Text); + var pOffset = cmd.Parameters.Add("$offset", SqliteType.Integer); + var pSize = cmd.Parameters.Add("$size", SqliteType.Integer); + var pIsEncrypted = cmd.Parameters.Add("$isEncrypted", SqliteType.Integer); + var pIsCompressed = cmd.Parameters.Add("$isCompressed", SqliteType.Integer); + var pCompressionFormat = cmd.Parameters.Add("$compressionFormat", SqliteType.Integer); + var pUncompressedSize = cmd.Parameters.Add("$uncompressedSize", SqliteType.Integer); + + cmd.Prepare(); + + foreach (var (relativePath, packFile) in container.GetAllFiles()) + { + if (packFile.DataSource is not PackedFileSource source) + continue; + + var lastSep = relativePath.LastIndexOf(Path.DirectorySeparatorChar); + var folderPath = lastSep == -1 ? "" : relativePath.Substring(0, lastSep); + + pRelativePath.Value = relativePath; + pFileName.Value = packFile.Name; + pExtension.Value = Path.GetExtension(relativePath).ToLower(); + pFolderPath.Value = folderPath; + pSourcePackFilePath.Value = source.Parent.FilePath; + pOffset.Value = source.Offset; + pSize.Value = source.Size; + pIsEncrypted.Value = source.IsEncrypted ? 1 : 0; + pIsCompressed.Value = source.IsCompressed ? 1 : 0; + pCompressionFormat.Value = (int)source.CompressionFormat; + pUncompressedSize.Value = (long)source.UncompressedSize; + + cmd.ExecuteNonQuery(); + } + } + + transaction.Commit(); + _logger.Here().Information($"Cache saved successfully for '{container.Name}'"); + } + + private static string GetDbFilePath(DbContextOptions dbOptions) + { + var relationalOptions = dbOptions.Extensions + .OfType() + .FirstOrDefault(); + var connStr = relationalOptions?.ConnectionString ?? ""; + var builder = new SqliteConnectionStringBuilder(connStr); + return builder.DataSource; + } + + public static CachedPackFileContainer? LoadContainerFromCache(string dbFilePath, string expectedFingerprint) + { + if (!File.Exists(dbFilePath)) + { + _logger.Here().Information($"No cache file found at '{dbFilePath}'"); + return null; + } + + var dbOptions = CreateDbOptions(dbFilePath); + return LoadContainerFromCache(dbOptions, expectedFingerprint); + } + + public static CachedPackFileContainer? LoadContainerFromCache(DbContextOptions dbOptions, string expectedFingerprint) + { + using var db = new CacheDbContext(dbOptions); + + try + { + db.Database.EnsureCreated(); + } + catch (Exception ex) + { + _logger.Here().Warning($"Failed to open cache database: {ex.Message}"); + return null; + } + + CacheInfoEntity? cacheInfo; + try + { + cacheInfo = db.CacheInfo.FirstOrDefault(); + } + catch (Exception ex) + { + _logger.Here().Warning($"Failed to read cache info: {ex.Message}"); + return null; + } + + if (cacheInfo == null || cacheInfo.SchemaVersion != CurrentSchemaVersion || cacheInfo.Fingerprint != expectedFingerprint) + { + _logger.Here().Information($"Cache invalid - schema:{cacheInfo?.SchemaVersion} (expected {CurrentSchemaVersion}), fingerprint match:{cacheInfo?.Fingerprint == expectedFingerprint}"); + return null; + } + + var container = new CachedPackFileContainer(cacheInfo.ContainerName, dbOptions) + { + SystemFilePath = cacheInfo.SystemFilePath, + }; + + if (!string.IsNullOrEmpty(cacheInfo.SourcePackFilePaths)) + { + foreach (var path in cacheInfo.SourcePackFilePaths.Split('|')) + container.SourcePackFilePaths.Add(path); + } + + _logger.Here().Information($"Loaded container '{container.Name}' from cache"); + return container; + } + + public static CachedPackFileContainer? TryLoadFromCache(string cacheFilePath, string fingerprint) + { + if (!File.Exists(cacheFilePath)) + { + _logger.Here().Information($"Cache file does not exist: {cacheFilePath}"); + return null; + } + + try + { + _logger.Here().Information($"Attempting to load cache from: {cacheFilePath} with fingerprint: {fingerprint}"); + var result = LoadContainerFromCache(cacheFilePath, fingerprint); + if (result == null) + _logger.Here().Information($"Cache load returned null (fingerprint/schema mismatch) for: {cacheFilePath}"); + return result; + } + catch (Exception ex) + { + _logger.Here().Warning($"Failed to load from cache '{cacheFilePath}': {ex.Message}"); + return null; + } + } + } +} diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Serialization/PackFileContainerCacheHelper.cs b/Shared/SharedCore/Shared.Core/PackFiles/Serialization/PackFileContainerCacheHelper.cs deleted file mode 100644 index 6ef0ae65f..000000000 --- a/Shared/SharedCore/Shared.Core/PackFiles/Serialization/PackFileContainerCacheHelper.cs +++ /dev/null @@ -1,254 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using Shared.Core.PackFiles.Models; -using Shared.Core.PackFiles.Models.Containers; -using Shared.Core.PackFiles.Models.FileSources; -using Shared.Core.PackFiles.Utility; - -namespace Shared.Core.PackFiles.Serialization -{ - internal record CachedFileEntry( - string RelativePath, - string FileName, - string SourcePackFilePath, - long Offset, - long Size, - bool IsEncrypted, - bool IsCompressed, - CompressionFormat CompressionFormat, - uint UncompressedSize); - - internal class CachedContainerData - { - public string Fingerprint { get; set; } = ""; - public string ContainerName { get; set; } = ""; - public string SystemFilePath { get; set; } = ""; - public List SourcePackFilePaths { get; set; } = []; - public List Files { get; set; } = []; - } - - internal static class PackFileContainerCacheHelper - { - private static readonly byte[] s_magic = "AEPC"u8.ToArray(); - private const int CacheVersion = 1; - - public static string GetCacheFilePath(string gameDataFolder, string gameName) - { - var cacheDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "AssetEditor", - "Cache"); - - Directory.CreateDirectory(cacheDir); - - var safeGameName = string.Join("_", gameName.Split(Path.GetInvalidFileNameChars())); - return Path.Combine(cacheDir, $"ca_pack_cache_{safeGameName}.bin"); - } - - public static string ComputeFingerprint(string gameDataFolder, List packFileNames) - { - using var sha = SHA256.Create(); - var sb = new StringBuilder(); - - foreach (var packFileName in packFileNames.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) - { - var fullPath = Path.Combine(gameDataFolder, packFileName); - if (File.Exists(fullPath)) - { - var info = new FileInfo(fullPath); - sb.Append(packFileName); - sb.Append('|'); - sb.Append(info.Length); - sb.Append('|'); - sb.Append(info.LastWriteTimeUtc.Ticks); - sb.Append(';'); - } - } - - var manifestPath = Path.Combine(gameDataFolder, "manifest.txt"); - if (File.Exists(manifestPath)) - { - var info = new FileInfo(manifestPath); - sb.Append("manifest.txt|"); - sb.Append(info.Length); - sb.Append('|'); - sb.Append(info.LastWriteTimeUtc.Ticks); - } - - var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString())); - return Convert.ToHexString(hash); - } - - public static void SaveCache(CachedContainerData data, string cacheFilePath) - { - using var stream = File.Create(cacheFilePath); - using var writer = new BinaryWriter(stream, Encoding.UTF8); - - writer.Write(s_magic); - writer.Write(CacheVersion); - - writer.Write(data.Fingerprint); - writer.Write(data.ContainerName); - writer.Write(data.SystemFilePath); - - writer.Write(data.SourcePackFilePaths.Count); - foreach (var path in data.SourcePackFilePaths) - writer.Write(path); - - writer.Write(data.Files.Count); - foreach (var entry in data.Files) - { - writer.Write(entry.RelativePath); - writer.Write(entry.FileName); - writer.Write(entry.SourcePackFilePath); - writer.Write(entry.Offset); - writer.Write(entry.Size); - writer.Write(entry.IsEncrypted); - writer.Write(entry.IsCompressed); - writer.Write((int)entry.CompressionFormat); - writer.Write(entry.UncompressedSize); - } - } - - /// - /// Single-pass optimized load: reads binary cache directly into a CachedPackFileContainer, - /// skipping intermediate CachedContainerData/CachedFileEntry allocations. - /// Returns null if the file is missing, corrupt, or the fingerprint doesn't match. - /// - public static CachedPackFileContainer? LoadContainerFromCache(string cacheFilePath, string expectedFingerprint) - { - if (!File.Exists(cacheFilePath)) - return null; - - using var fileStream = File.OpenRead(cacheFilePath); - using var buffered = new BufferedStream(fileStream, 1024 * 64); - using var reader = new BinaryReader(buffered, Encoding.UTF8); - - var magic = reader.ReadBytes(s_magic.Length); - if (!magic.AsSpan().SequenceEqual(s_magic)) - return null; - - var version = reader.ReadInt32(); - if (version != CacheVersion) - return null; - - var fingerprint = reader.ReadString(); - if (fingerprint != expectedFingerprint) - return null; - - var containerName = reader.ReadString(); - var systemFilePath = reader.ReadString(); - - var container = new CachedPackFileContainer(containerName) - { - SystemFilePath = systemFilePath, - }; - - var sourcePathCount = reader.ReadInt32(); - for (var i = 0; i < sourcePathCount; i++) - container.SourcePackFilePaths.Add(reader.ReadString()); - - var fileCount = reader.ReadInt32(); - var fileList = new Dictionary(fileCount); - var parentCache = new Dictionary(sourcePathCount, StringComparer.OrdinalIgnoreCase); - var stringPool = new Dictionary(sourcePathCount, StringComparer.Ordinal); - - for (var i = 0; i < fileCount; i++) - { - var relativePath = reader.ReadString(); - var fileName = reader.ReadString(); - var sourcePackFilePath = reader.ReadString(); - var offset = reader.ReadInt64(); - var size = reader.ReadInt64(); - var isEncrypted = reader.ReadBoolean(); - var isCompressed = reader.ReadBoolean(); - var compressionFormat = (CompressionFormat)reader.ReadInt32(); - var uncompressedSize = reader.ReadUInt32(); - - // Intern the source pack path string — ~20 unique values across 600K entries - if (!stringPool.TryGetValue(sourcePackFilePath, out var internedPath)) - { - internedPath = sourcePackFilePath; - stringPool[internedPath] = internedPath; - } - - if (!parentCache.TryGetValue(internedPath, out var parent)) - { - parent = new PackedFileSourceParent { FilePath = internedPath }; - parentCache[internedPath] = parent; - } - - var source = new PackedFileSource(parent, offset, size, isEncrypted, isCompressed, compressionFormat, uncompressedSize); - fileList[relativePath] = new PackFile(fileName, source); - } - - container.FileList = fileList; - return container; - } - - public static CachedContainerData BuildCacheData(string fingerprint, PackFileContainer container) - { - var data = new CachedContainerData - { - Fingerprint = fingerprint, - ContainerName = container.Name, - SystemFilePath = container.SystemFilePath, - SourcePackFilePaths = container.SourcePackFilePaths.ToList(), - }; - - foreach (var (relativePath, packFile) in container.GetAllFiles()) - { - if (packFile.DataSource is PackedFileSource source) - { - data.Files.Add(new CachedFileEntry( - relativePath, - packFile.Name, - source.Parent.FilePath, - source.Offset, - source.Size, - source.IsEncrypted, - source.IsCompressed, - source.CompressionFormat, - source.UncompressedSize)); - } - } - - return data; - } - - public static CachedPackFileContainer RestoreFromCache(CachedContainerData data) - { - var container = new CachedPackFileContainer(data.ContainerName) - { - SystemFilePath = data.SystemFilePath, - }; - - foreach (var sourcePath in data.SourcePackFilePaths) - container.SourcePackFilePaths.Add(sourcePath); - - var parentCache = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var entry in data.Files) - { - if (!parentCache.TryGetValue(entry.SourcePackFilePath, out var parent)) - { - parent = new PackedFileSourceParent { FilePath = entry.SourcePackFilePath }; - parentCache[entry.SourcePackFilePath] = parent; - } - - var source = new PackedFileSource( - parent, - entry.Offset, - entry.Size, - entry.IsEncrypted, - entry.IsCompressed, - entry.CompressionFormat, - entry.UncompressedSize); - - container.FileList.Add(entry.RelativePath, new PackFile(entry.FileName, source)); - } - - return container; - } - } -} diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Utility/PackFileContainerLoader.cs b/Shared/SharedCore/Shared.Core/PackFiles/Utility/PackFileContainerLoader.cs index 22903ed07..8352c43f6 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/Utility/PackFileContainerLoader.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/Utility/PackFileContainerLoader.cs @@ -5,6 +5,8 @@ using Shared.Core.PackFiles.Models; using Shared.Core.PackFiles.Models.Containers; using Shared.Core.PackFiles.Serialization; +using Shared.Core.PackFiles.Serialization.CacheDatabase; +using Shared.Core.Services; using Shared.Core.Settings; namespace Shared.Core.PackFiles.Utility @@ -21,10 +23,14 @@ public class PackFileContainerLoader : IPackFileContainerLoader { static private readonly ILogger _logger = Logging.CreateStatic(typeof(PackFileContainerLoader)); private readonly ApplicationSettingsService _settingsService; + private readonly IStandardDialogs _standardDialogs; + private readonly LocalizationManager _localizationManager; - public PackFileContainerLoader(ApplicationSettingsService settingsService) + public PackFileContainerLoader(ApplicationSettingsService settingsService, IStandardDialogs standardDialogs, LocalizationManager localizationManager) { _settingsService = settingsService; + _standardDialogs = standardDialogs; + _localizationManager = localizationManager; } public IPackFileContainer LoadSystemFolderAsPackFileContainer(string packFileSystemPath) @@ -103,23 +109,44 @@ private static void AddFolderContentToPackFile(PackFileContainer container, stri var allCaPackFiles = ManifestHelper.GetPackFilesFromManifest(gameDataFolder, out var manifestFileFound); var fingerprint = PackFileContainerCacheHelper.ComputeFingerprint(gameDataFolder, allCaPackFiles); - var cacheFilePath = PackFileContainerCacheHelper.GetCacheFilePath(gameDataFolder, gameName); + _logger.Here().Information($"Computed fingerprint for {gameName}: {fingerprint}"); - var cached = TryLoadFromCache(cacheFilePath, fingerprint, gameName); + var cacheFilePath = PackFileContainerCacheHelper.GetCacheFilePath(gameDataFolder, gameName, fingerprint); + _logger.Here().Information($"Cache file path for {gameName}: {cacheFilePath}"); + + var cached = PackFileContainerCacheHelper.TryLoadFromCache(cacheFilePath, fingerprint); if (cached != null) + { + _logger.Here().Information($"Cache hit for {gameName} - loaded {cached.GetFileCount()} files from: {cacheFilePath}"); return cached; + } - var container = LoadAllCaFilesFromDisk(gameDataFolder, gameName, allCaPackFiles, manifestFileFound); + _logger.Here().Information($"Cache miss for {gameName} at: {cacheFilePath}"); - try - { - var cacheData = PackFileContainerCacheHelper.BuildCacheData(fingerprint, container); - PackFileContainerCacheHelper.SaveCache(cacheData, cacheFilePath); - _logger.Here().Information($"Saved CA pack cache for {gameName} to {cacheFilePath}"); - } - catch (Exception cacheEx) + var cacheInvalidReason = GetCacheInvalidReason(cacheFilePath, fingerprint); + _logger.Here().Information($"Cache invalid reason for {gameName}: {cacheInvalidReason}"); + var reasonMessage = string.Format(_localizationManager.Get("PackFileCache.InvalidReason." + cacheInvalidReason), gameName); + var buildingMessage = string.Format(_localizationManager.Get("PackFileCache.BuildingCache"), gameName); + var cacheDescription = _localizationManager.Get("PackFileCache.Description"); + _standardDialogs.ShowDialogBox( + reasonMessage + "\n\n" + buildingMessage + "\n\n" + cacheDescription, + _localizationManager.Get("PackFileCache.InvalidReason.Title")); + + PackFileContainer container; + using (_standardDialogs.ShowWaitCursor()) { - _logger.Here().Warning($"Failed to save CA pack cache for {gameName}: {cacheEx.Message}"); + container = LoadAllCaFilesFromDisk(gameDataFolder, gameName, allCaPackFiles, manifestFileFound); + + try + { + var dbOptions = PackFileContainerCacheHelper.CreateDbOptions(cacheFilePath); + PackFileContainerCacheHelper.SaveCache(fingerprint, container, dbOptions); + _logger.Here().Information($"Saved CA pack cache for {gameName} to {cacheFilePath}"); + } + catch (Exception cacheEx) + { + _logger.Here().Warning($"Failed to save CA pack cache for {gameName}: {cacheEx.Message}"); + } } return container; @@ -131,22 +158,24 @@ private static void AddFolderContentToPackFile(PackFileContainer container, stri } } - private CachedPackFileContainer? TryLoadFromCache(string cacheFilePath, string fingerprint, string gameName) + private static string GetCacheInvalidReason(string cacheFilePath, string fingerprint) { + if (!File.Exists(cacheFilePath)) + return "NotFound"; + try { - var container = PackFileContainerCacheHelper.LoadContainerFromCache(cacheFilePath, fingerprint); - if (container != null) - { - _logger.Here().Information($"Loading CA packs for {gameName} from cache: {cacheFilePath}"); - return container; - } + var dbOptions = PackFileContainerCacheHelper.CreateDbOptions(cacheFilePath); + var result = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, fingerprint); + if (result == null) + return "DataChanged"; } - catch (Exception ex) + catch { - _logger.Here().Warning($"Failed to read CA pack cache for {gameName}, will rebuild: {ex.Message}"); + return "Corrupted"; } - return null; + + return "DataChanged"; } private PackFileContainer LoadAllCaFilesFromDisk(string gameDataFolder, string gameName, List allCaPackFiles, bool manifestFileFound) diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Utility/PathNormalization.cs b/Shared/SharedCore/Shared.Core/PackFiles/Utility/PathNormalization.cs new file mode 100644 index 000000000..eb23215e7 --- /dev/null +++ b/Shared/SharedCore/Shared.Core/PackFiles/Utility/PathNormalization.cs @@ -0,0 +1,10 @@ +namespace Shared.Core.PackFiles.Utility +{ + public static class PathNormalization + { + public static string NormalizeFileName(string path) + { + return path.Replace('/', '\\').ToLower().Trim(); + } + } +} diff --git a/Shared/SharedCore/Shared.Core/Settings/ApplicationSettingsService.cs b/Shared/SharedCore/Shared.Core/Settings/ApplicationSettingsService.cs index 623a97fcb..25fb5125d 100644 --- a/Shared/SharedCore/Shared.Core/Settings/ApplicationSettingsService.cs +++ b/Shared/SharedCore/Shared.Core/Settings/ApplicationSettingsService.cs @@ -1,5 +1,6 @@ using System.Collections.ObjectModel; using System.Text.Json; +using Microsoft.Xna.Framework; using Shared.Core.ErrorHandling; using Shared.Core.Misc; @@ -32,6 +33,7 @@ public class GamePathPair public string WwisePath { get; set; } public bool OnlyLoadLod0ForReferenceMeshes { get; set; } = true; public int VisualEditorsGridSize { get; set; } = 10; + public Vector3 VertexSelectionColour { get; set; } = new Vector3(1.0f, 0.47f, 0.0f); public string SelectedLangauge { get; set; } = "en"; public CameraControlMode CameraControlMode { get; set; } = CameraControlMode.AssetEditorStyle; diff --git a/Shared/SharedCore/Shared.Core/Shared.Core.csproj b/Shared/SharedCore/Shared.Core/Shared.Core.csproj index 667ee2128..58e3a872a 100644 --- a/Shared/SharedCore/Shared.Core/Shared.Core.csproj +++ b/Shared/SharedCore/Shared.Core/Shared.Core.csproj @@ -9,13 +9,14 @@ + - + @@ -26,8 +27,8 @@ - - + + diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/CachedPackFileContainerTests.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/CachedPackFileContainerTests.cs index 7987dd1a4..12dc99df7 100644 --- a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/CachedPackFileContainerTests.cs +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/CachedPackFileContainerTests.cs @@ -1,22 +1,55 @@ -using Shared.Core.PackFiles; +using Microsoft.EntityFrameworkCore; +using Shared.Core.PackFiles; using Shared.Core.PackFiles.Models; using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.PackFiles.Models.FileSources; +using Shared.Core.PackFiles.Serialization.CacheDatabase; +using Shared.Core.PackFiles.Utility; using Shared.Core.Settings; namespace Shared.CoreTest.PackFiles.Models.Containers { internal class CachedPackFileContainer_ReadOnly { + private string _tempDir; + private string _dbFilePath; private CachedPackFileContainer _container; [SetUp] public void Setup() { - _container = new CachedPackFileContainer("TestCache") + _tempDir = Path.Combine(Path.GetTempPath(), "CachedContainerTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + _dbFilePath = Path.Combine(_tempDir, "test.db"); + + // Build a container with test data and save to DB + var sourceContainer = new PackFileContainer("TestCache") { + IsCaPackFile = true, SystemFilePath = @"c:\game\data" }; - _container.FileList.Add("folder\\file.txt", new PackFile("file.txt", null)); + sourceContainer.SourcePackFilePaths.Add(@"c:\game\data\pack1.pack"); + + var parent = new PackedFileSourceParent { FilePath = @"c:\game\data\pack1.pack" }; + sourceContainer.AddOrUpdateFile("folder\\file.txt", new PackFile("file.txt", + new PackedFileSource(parent, 100, 200, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("other\\data.bin", new PackFile("data.bin", + new PackedFileSource(parent, 300, 400, false, true, CompressionFormat.Lz4, 800))); + sourceContainer.AddOrUpdateFile("audio\\sound.wem", new PackFile("sound.wem", + new PackedFileSource(parent, 700, 500, false, false, CompressionFormat.None, 0))); + + var dbOptions = PackFileContainerCacheHelper.CreateDbOptions(_dbFilePath); + PackFileContainerCacheHelper.SaveCache("test_fp", sourceContainer, dbOptions); + + // Load the cached container (lazy, no files in memory) + _container = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "test_fp")!; + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); } [Test] @@ -32,6 +65,12 @@ public void IsCaPackFile_SetterDoesNotChangeValue() Assert.That(_container.IsCaPackFile, Is.True); } + [Test] + public void GetFileCount_ReturnsCorrectCount() + { + Assert.That(_container.GetFileCount(), Is.EqualTo(3)); + } + [Test] public void FindFile_ReturnsFile() { @@ -54,10 +93,32 @@ public void FindFile_NormalizesPath() Assert.That(result, Is.Not.Null); } + [Test] + public void FindFile_ReturnsCorrectDataSource() + { + var result = _container.FindFile("folder\\file.txt"); + var source = result!.DataSource as PackedFileSource; + Assert.That(source, Is.Not.Null); + Assert.That(source.Offset, Is.EqualTo(100)); + Assert.That(source.Size, Is.EqualTo(200)); + } + + [Test] + public void ContainsFile_ReturnsTrueForExisting() + { + Assert.That(_container.ContainsFile("folder\\file.txt"), Is.True); + } + + [Test] + public void ContainsFile_ReturnsFalseForMissing() + { + Assert.That(_container.ContainsFile("missing.txt"), Is.False); + } + [Test] public void GetFullPath_ReturnsPath() { - var file = _container.FindFile("folder\\file.txt"); + var file = _container.FindFile("folder\\file.txt")!; var path = _container.GetFullPath(file); Assert.That(path, Is.EqualTo("folder\\file.txt")); } @@ -71,9 +132,28 @@ public void GetFullPath_ReturnsNullForMissing() } [Test] - public void AddFiles_AddOrUpdateFile() + public void FindAllWithExtention_ReturnsMatching() + { + var results = _container.FindAllWithExtention(".wem"); + Assert.That(results.Count, Is.EqualTo(1)); + Assert.That(results[0].FileName, Does.EndWith("sound.wem")); + } + + [Test] + public void FindAllWithExtention_ReturnsEmptyForNoMatch() { - Assert.Throws(() => _container.AddOrUpdateFile("path", new PackFile("new.txt", null))); + var results = _container.FindAllWithExtention(".xyz"); + Assert.That(results, Is.Empty); + } + + [Test] + public void GetAllFiles_ReturnsAllFiles() + { + var all = _container.GetAllFiles(); + Assert.That(all.Count, Is.EqualTo(3)); + Assert.That(all.ContainsKey("folder\\file.txt"), Is.True); + Assert.That(all.ContainsKey("other\\data.bin"), Is.True); + Assert.That(all.ContainsKey("audio\\sound.wem"), Is.True); } [Test] @@ -89,7 +169,7 @@ public void AddFiles_Throws() [Test] public void DeleteFile_Throws() { - var file = _container.FindFile("folder\\file.txt"); + var file = _container.FindFile("folder\\file.txt")!; Assert.Throws(() => _container.DeleteFile(file)); } @@ -102,7 +182,7 @@ public void DeleteFolder_Throws() [Test] public void MoveFile_Throws() { - var file = _container.FindFile("folder\\file.txt"); + var file = _container.FindFile("folder\\file.txt")!; Assert.Throws(() => _container.MoveFile(file, "other")); } @@ -115,14 +195,14 @@ public void RenameDirectory_Throws() [Test] public void RenameFile_Throws() { - var file = _container.FindFile("folder\\file.txt"); + var file = _container.FindFile("folder\\file.txt")!; Assert.Throws(() => _container.RenameFile(file, "renamed.txt")); } [Test] public void SaveFileData_Throws() { - var file = _container.FindFile("folder\\file.txt"); + var file = _container.FindFile("folder\\file.txt")!; Assert.Throws(() => _container.SaveFileData(file, [1, 2, 3])); } @@ -132,5 +212,279 @@ public void SaveToDisk_Throws() var gameInfo = GameInformationDatabase.GetGameById(GameTypeEnum.Warhammer3); Assert.Throws(() => _container.SaveToDisk("path", false, gameInfo)); } + + [Test] + public void AddOrUpdateFile_Throws() + { + Assert.Throws(() => + _container.AddOrUpdateFile("test\\new.txt", new PackFile("new.txt", null))); + } + } + + internal class CachedPackFileContainer_GetDirectoryContent + { + private string _tempDir; + private string _dbFilePath; + private CachedPackFileContainer _container; + + [SetUp] + public void Setup() + { + _tempDir = Path.Combine(Path.GetTempPath(), "CachedDirContentTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + _dbFilePath = Path.Combine(_tempDir, "test.db"); + + var sourceContainer = new PackFileContainer("DirTest") + { + IsCaPackFile = true, + SystemFilePath = @"c:\game\data" + }; + sourceContainer.SourcePackFilePaths.Add(@"c:\game\data\pack1.pack"); + + var parent = new PackedFileSourceParent { FilePath = @"c:\game\data\pack1.pack" }; + + // Root-level files + sourceContainer.AddOrUpdateFile("root_file.txt", new PackFile("root_file.txt", + new PackedFileSource(parent, 0, 10, false, false, CompressionFormat.None, 0))); + + // Single-level folders + sourceContainer.AddOrUpdateFile("models\\unit.model", new PackFile("unit.model", + new PackedFileSource(parent, 10, 20, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("models\\vehicle.model", new PackFile("vehicle.model", + new PackedFileSource(parent, 30, 40, false, false, CompressionFormat.None, 0))); + + // Nested folders + sourceContainer.AddOrUpdateFile("models\\textures\\diffuse.dds", new PackFile("diffuse.dds", + new PackedFileSource(parent, 70, 50, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("models\\textures\\normal.dds", new PackFile("normal.dds", + new PackedFileSource(parent, 120, 60, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("models\\textures\\specular\\gloss.dds", new PackFile("gloss.dds", + new PackedFileSource(parent, 180, 30, false, false, CompressionFormat.None, 0))); + + // Another top-level folder + sourceContainer.AddOrUpdateFile("audio\\music.wem", new PackFile("music.wem", + new PackedFileSource(parent, 210, 100, false, false, CompressionFormat.None, 0))); + + var dbOptions = PackFileContainerCacheHelper.CreateDbOptions(_dbFilePath); + PackFileContainerCacheHelper.SaveCache("fp", sourceContainer, dbOptions); + _container = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp")!; + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + + [Test] + public void Root_ReturnsRootFilesAndTopFolders() + { + var content = _container.GetDirectoryContent(""); + + Assert.That(content.Files.Any(f => f.FileName == "root_file.txt"), Is.True); + Assert.That(content.SubFolders, Does.Contain("models")); + Assert.That(content.SubFolders, Does.Contain("audio")); + // Should NOT contain nested folders at root level + Assert.That(content.SubFolders, Does.Not.Contain("textures")); + } + + [Test] + public void Root_DoesNotIncludeFilesFromSubfolders() + { + var content = _container.GetDirectoryContent(""); + + Assert.That(content.Files.Count, Is.EqualTo(1)); + Assert.That(content.Files[0].FileName, Is.EqualTo("root_file.txt")); + } + + [Test] + public void Subfolder_ReturnsDirectFilesAndImmediateSubfolders() + { + var content = _container.GetDirectoryContent("models"); + + Assert.That(content.Files.Any(f => f.FileName == "unit.model"), Is.True); + Assert.That(content.Files.Any(f => f.FileName == "vehicle.model"), Is.True); + Assert.That(content.SubFolders, Does.Contain("textures")); + // Should NOT include files from deeper levels + Assert.That(content.Files.Any(f => f.FileName == "diffuse.dds"), Is.False); + } + + [Test] + public void NestedSubfolder_ReturnsCorrectContent() + { + var content = _container.GetDirectoryContent("models\\textures"); + + Assert.That(content.Files.Any(f => f.FileName == "diffuse.dds"), Is.True); + Assert.That(content.Files.Any(f => f.FileName == "normal.dds"), Is.True); + Assert.That(content.SubFolders, Does.Contain("specular")); + Assert.That(content.Files.Count, Is.EqualTo(2)); + } + + [Test] + public void DeepNestedFolder_ReturnsOnlyItsFiles() + { + var content = _container.GetDirectoryContent("models\\textures\\specular"); + + Assert.That(content.Files.Count, Is.EqualTo(1)); + Assert.That(content.Files[0].FileName, Is.EqualTo("gloss.dds")); + Assert.That(content.SubFolders, Is.Empty); + } + + [Test] + public void NonexistentFolder_ReturnsEmpty() + { + var content = _container.GetDirectoryContent("nonexistent\\path"); + + Assert.That(content.Files, Is.Empty); + Assert.That(content.SubFolders, Is.Empty); + } + + [Test] + public void SubfoldersAreSorted() + { + var content = _container.GetDirectoryContent(""); + + var sorted = content.SubFolders.OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase).ToList(); + Assert.That(content.SubFolders, Is.EqualTo(sorted)); + } + + [Test] + public void FilesAreSorted() + { + var content = _container.GetDirectoryContent("models"); + + var sorted = content.Files.OrderBy(x => x.FileName, StringComparer.CurrentCultureIgnoreCase).ToList(); + Assert.That(content.Files.Select(f => f.FileName), Is.EqualTo(sorted.Select(f => f.FileName))); + } + + [Test] + public void Files_HaveCorrectDataSource() + { + var content = _container.GetDirectoryContent("models"); + var unitFile = content.Files.First(f => f.FileName == "unit.model"); + var source = unitFile.File.DataSource as PackedFileSource; + + Assert.That(source, Is.Not.Null); + Assert.That(source.Offset, Is.EqualTo(10)); + Assert.That(source.Size, Is.EqualTo(20)); + Assert.That(source.Parent.FilePath, Is.EqualTo(@"c:\game\data\pack1.pack")); + } + } + + internal class CachedPackFileContainer_EdgeCases + { + private string _tempDir; + private string _dbFilePath; + + [SetUp] + public void Setup() + { + _tempDir = Path.Combine(Path.GetTempPath(), "CachedEdgeCaseTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + _dbFilePath = Path.Combine(_tempDir, "test.db"); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + + [Test] + public void FindAllWithExtention_IsCaseInsensitive() + { + var sourceContainer = new PackFileContainer("Test") { SystemFilePath = @"c:\game" }; + var parent = new PackedFileSourceParent { FilePath = @"c:\game\pack.pack" }; + sourceContainer.AddOrUpdateFile("sound.wem", new PackFile("sound.wem", + new PackedFileSource(parent, 0, 10, false, false, CompressionFormat.None, 0))); + + var dbOptions = PackFileContainerCacheHelper.CreateDbOptions(_dbFilePath); + PackFileContainerCacheHelper.SaveCache("fp", sourceContainer, dbOptions); + var container = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp")!; + + var upper = container.FindAllWithExtention(".WEM"); + var lower = container.FindAllWithExtention(".wem"); + Assert.That(upper.Count, Is.EqualTo(1)); + Assert.That(lower.Count, Is.EqualTo(1)); + } + + [Test] + public void ContainsFile_NormalizesForwardSlashes() + { + var sourceContainer = new PackFileContainer("Test") { SystemFilePath = @"c:\game" }; + var parent = new PackedFileSourceParent { FilePath = @"c:\game\pack.pack" }; + sourceContainer.AddOrUpdateFile("folder\\file.txt", new PackFile("file.txt", + new PackedFileSource(parent, 0, 10, false, false, CompressionFormat.None, 0))); + + var dbOptions = PackFileContainerCacheHelper.CreateDbOptions(_dbFilePath); + PackFileContainerCacheHelper.SaveCache("fp", sourceContainer, dbOptions); + var container = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp")!; + + Assert.That(container.ContainsFile("folder/file.txt"), Is.True); + Assert.That(container.ContainsFile("FOLDER/FILE.TXT"), Is.True); + Assert.That(container.ContainsFile(" folder\\file.txt "), Is.True); + } + + [Test] + public void GetAllFiles_PreservesCompressionMetadata() + { + var sourceContainer = new PackFileContainer("Test") { SystemFilePath = @"c:\game" }; + var parent = new PackedFileSourceParent { FilePath = @"c:\game\pack.pack" }; + sourceContainer.AddOrUpdateFile("data.bin", new PackFile("data.bin", + new PackedFileSource(parent, 100, 500, true, true, CompressionFormat.Lz4, 2000))); + + var dbOptions = PackFileContainerCacheHelper.CreateDbOptions(_dbFilePath); + PackFileContainerCacheHelper.SaveCache("fp", sourceContainer, dbOptions); + var container = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp")!; + + var all = container.GetAllFiles(); + var source = (PackedFileSource)all["data.bin"].DataSource; + + Assert.That(source.IsEncrypted, Is.True); + Assert.That(source.IsCompressed, Is.True); + Assert.That(source.CompressionFormat, Is.EqualTo(CompressionFormat.Lz4)); + Assert.That(source.UncompressedSize, Is.EqualTo(2000)); + Assert.That(source.Offset, Is.EqualTo(100)); + Assert.That(source.Size, Is.EqualTo(500)); + } + + [Test] + public void GetFullPath_WithDuplicateFileNames_ReturnsFirstMatch() + { + var sourceContainer = new PackFileContainer("Test") { SystemFilePath = @"c:\game" }; + var parent = new PackedFileSourceParent { FilePath = @"c:\game\pack.pack" }; + sourceContainer.AddOrUpdateFile("folder_a\\shared.txt", new PackFile("shared.txt", + new PackedFileSource(parent, 0, 10, false, false, CompressionFormat.None, 0))); + sourceContainer.AddOrUpdateFile("folder_b\\shared.txt", new PackFile("shared.txt", + new PackedFileSource(parent, 10, 20, false, false, CompressionFormat.None, 0))); + + var dbOptions = PackFileContainerCacheHelper.CreateDbOptions(_dbFilePath); + PackFileContainerCacheHelper.SaveCache("fp", sourceContainer, dbOptions); + var container = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp")!; + + // GetFullPath matches by FileName - with duplicates it returns the first DB row + var file = container.FindFile("folder_a\\shared.txt")!; + var path = container.GetFullPath(file); + + // Should return a valid path (either folder_a or folder_b) + Assert.That(path, Does.Contain("shared.txt")); + Assert.That(path, Does.Contain("\\")); + } + + [Test] + public void GetDirectoryContent_EmptyContainer_ReturnsEmpty() + { + var sourceContainer = new PackFileContainer("Empty") { SystemFilePath = @"c:\game" }; + + var dbOptions = PackFileContainerCacheHelper.CreateDbOptions(_dbFilePath); + PackFileContainerCacheHelper.SaveCache("fp", sourceContainer, dbOptions); + var container = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp")!; + + var content = container.GetDirectoryContent(""); + Assert.That(content.Files, Is.Empty); + Assert.That(content.SubFolders, Is.Empty); + } } } diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/PackFileServiceTest.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/PackFileServiceTest.cs index 23f6674eb..5c822cf5e 100644 --- a/Shared/SharedCore/Shared.CoreTest/PackFiles/PackFileServiceTest.cs +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/PackFileServiceTest.cs @@ -11,22 +11,34 @@ namespace Shared.CoreTest.PackFiles { internal class PackFileServiceTest { + private static PackFileService CreateService(Mock? eventHub = null) + { + eventHub ??= new Mock(); + var pfs = new PackFileService(eventHub.Object); + pfs.MessageBoxProvider = new Mock().Object; + return pfs; + } + + private static PackFileService CreateServiceWithCaPack(Mock? eventHub = null) + { + var pfs = CreateService(eventHub); + var ca = new PackFileContainer("CaPack") { IsCaPackFile = true, SystemFilePath = "ca_path" }; + pfs.AddContainer(ca); + return pfs; + } + [Test] public void AddContainer_CaPackFile() { - // Arrenge var eventHub = new Mock(); var pfs = new PackFileService(eventHub.Object); var container = new PackFileContainer("MyTest"); container.SystemFilePath = "SystemPath"; container.IsCaPackFile = true; - // Act pfs.AddContainer(container); - // Assert var containers = pfs.GetAllPackfileContainers(); - Assert.That(containers.First(), Is.EqualTo(container)); Assert.That(pfs.GetEditablePack(), Is.Null); eventHub.Verify(m => m.PublishGlobalEvent(new PackFileContainerAddedEvent(container)), Times.Once); @@ -35,7 +47,6 @@ public void AddContainer_CaPackFile() [Test] public void AddContainer_CaPackFileNotSet() { - // Arrenge var eventHub = new Mock(); var dialogProvider = new Mock(); var pfs = new PackFileService(eventHub.Object); @@ -44,12 +55,9 @@ public void AddContainer_CaPackFileNotSet() container.SystemFilePath = "SystemPath"; container.IsCaPackFile = false; - // Act pfs.AddContainer(container); - // Assert var containers = pfs.GetAllPackfileContainers(); - Assert.That(containers.Count, Is.EqualTo(0)); Assert.That(pfs.GetEditablePack(), Is.Null); eventHub.Verify(m => m.PublishGlobalEvent(new PackFileContainerAddedEvent(container)), Times.Never); @@ -59,9 +67,7 @@ public void AddContainer_CaPackFileNotSet() [Test] public void AddContainer_CaPackFileSet() { - // Arrenge var eventHub = new Mock(); - var pfs = new PackFileService(eventHub.Object); var caContainer = new PackFileContainer("MyTest"); caContainer.SystemFilePath = "SystemPath"; @@ -71,13 +77,10 @@ public void AddContainer_CaPackFileSet() customContainer.SystemFilePath = "SystemPath2"; customContainer.IsCaPackFile = false; - // Act pfs.AddContainer(caContainer); pfs.AddContainer(customContainer, true); - // Assert var containers = pfs.GetAllPackfileContainers(); - Assert.That(containers.Count, Is.EqualTo(2)); Assert.That(pfs.GetEditablePack(), Is.EqualTo(customContainer)); eventHub.Verify(m => m.PublishGlobalEvent(new PackFileContainerAddedEvent(caContainer)), Times.Once); @@ -88,12 +91,11 @@ public void AddContainer_CaPackFileSet() [Test] public void AddContainers_Duplicate() { - // Arrenge var eventHub = new Mock(); var dialogProvider = new Mock(); - var pfs = new PackFileService( eventHub.Object); - var caContainer = new PackFileContainer("MyTest"); + var pfs = new PackFileService(eventHub.Object); pfs.MessageBoxProvider = dialogProvider.Object; + var caContainer = new PackFileContainer("MyTest"); caContainer.SystemFilePath = "SystemPath"; caContainer.IsCaPackFile = true; @@ -101,14 +103,11 @@ public void AddContainers_Duplicate() customContainer.SystemFilePath = "SystemPath2"; customContainer.IsCaPackFile = false; - // Act pfs.AddContainer(caContainer); pfs.AddContainer(customContainer, true); pfs.AddContainer(customContainer); - // Assert var containers = pfs.GetAllPackfileContainers(); - Assert.That(containers.Count, Is.EqualTo(2)); Assert.That(pfs.GetEditablePack(), Is.EqualTo(customContainer)); dialogProvider.Verify(m => m.ShowDialogBox(It.IsAny(), It.IsAny()), Times.Once); @@ -117,20 +116,16 @@ public void AddContainers_Duplicate() [Test] public void CreateNewPackFileContainer() { - // Arrenge var eventHub = new Mock(); var pfs = new PackFileService(eventHub.Object); var container = new PackFileContainer("MyTest"); container.SystemFilePath = "SystemPath"; container.IsCaPackFile = true; - // Act pfs.AddContainer(container); var emptyPackFileContainer = pfs.CreateNewPackFileContainer("Custom", PackFileVersion.PFH5, PackFileCAType.MOD, true); - // Assert var containers = pfs.GetAllPackfileContainers(); - Assert.That(containers.Count(), Is.EqualTo(2)); Assert.That(pfs.GetEditablePack(), Is.EqualTo(emptyPackFileContainer)); eventHub.Verify(m => m.PublishGlobalEvent(new PackFileContainerAddedEvent(container)), Times.Once); @@ -138,32 +133,13 @@ public void CreateNewPackFileContainer() } [Test] - public void CreateNewPackFileContainer_Wh3() + public void CreateNewPackFileContainer_EmptyName_Throws() { - var eventHub = new Mock(); - var pfs = new PackFileService(eventHub.Object); - // pfs.CreateNewPackFileContainer("Custom", PackFileCAType.WH3, true); - - + var pfs = CreateServiceWithCaPack(); + Assert.Throws(() => pfs.CreateNewPackFileContainer("", PackFileVersion.PFH5, PackFileCAType.MOD)); + Assert.Throws(() => pfs.CreateNewPackFileContainer(" ", PackFileVersion.PFH5, PackFileCAType.MOD)); } - [Test] - public void CreateNewPackFileContainer_Rome() - { } - - - - - //AddFilesToPack - - - // AddFilesToPack - Ensure valid path! - // CopyFileFromOtherPackFile - // SetEditablePack - Ca file, null, valid - // UnloadPackContainer - // SaveFile - // SavePackContainer - [Test] public void SaveFile_NoEditablePack_ThrowsDescriptiveException() { @@ -193,5 +169,449 @@ public void AddContainer_TwoContainersWithNullSystemFilePath_BothAdded() Assert.That(result, Is.Not.Null); Assert.That(pfs.GetAllPackfileContainers().Count, Is.EqualTo(3)); } + + [Test] + public void AddContainer_EnforceGameFilesDisabled_AllowsNonCaFirst() + { + var pfs = CreateService(); + pfs.EnforceGameFilesMustBeLoaded = false; + + var container = new PackFileContainer("Custom") { IsCaPackFile = false, SystemFilePath = "path" }; + var result = pfs.AddContainer(container); + + Assert.That(result, Is.Not.Null); + Assert.That(pfs.GetAllPackfileContainers().Count, Is.EqualTo(1)); + } + + [Test] + public void SetEditablePack_CaPack_Throws() + { + var pfs = CreateServiceWithCaPack(); + var caPack = pfs.GetAllPackfileContainers().First(); + + Assert.Throws(() => pfs.SetEditablePack(caPack)); + } + + [Test] + public void SetEditablePack_Null_ClearsSelection() + { + var eventHub = new Mock(); + var pfs = CreateServiceWithCaPack(eventHub); + var custom = new PackFileContainer("Custom") { SystemFilePath = "custom_path" }; + pfs.AddContainer(custom, true); + + Assert.That(pfs.GetEditablePack(), Is.EqualTo(custom)); + pfs.SetEditablePack(null); + Assert.That(pfs.GetEditablePack(), Is.Null); + } + + [Test] + public void UnloadPackContainer_RemovesContainer() + { + var eventHub = new Mock(); + var pfs = CreateServiceWithCaPack(eventHub); + var custom = new PackFileContainer("Custom") { SystemFilePath = "custom_path" }; + pfs.AddContainer(custom, true); + + pfs.UnloadPackContainer(custom); + + Assert.That(pfs.GetAllPackfileContainers().Count, Is.EqualTo(1)); + Assert.That(pfs.GetEditablePack(), Is.Null); + eventHub.Verify(m => m.PublishGlobalEvent(It.IsAny()), Times.Once); + } + + [Test] + public void UnloadPackContainer_NonEditablePack_DoesNotClearEditable() + { + var pfs = CreateServiceWithCaPack(); + var pack1 = new PackFileContainer("Pack1") { SystemFilePath = "path1" }; + var pack2 = new PackFileContainer("Pack2") { SystemFilePath = "path2" }; + pfs.AddContainer(pack1, true); + pfs.AddContainer(pack2); + + pfs.UnloadPackContainer(pack2); + + Assert.That(pfs.GetEditablePack(), Is.EqualTo(pack1)); + Assert.That(pfs.GetAllPackfileContainers().Count, Is.EqualTo(2)); + } + + [Test] + public void AddFilesToPack_AddsFiles() + { + var eventHub = new Mock(); + var pfs = CreateServiceWithCaPack(eventHub); + var container = new PackFileContainer("Custom") { SystemFilePath = "path" }; + pfs.AddContainer(container, true); + + var newFiles = new List + { + new("folder", new PackFile("test.txt", new MemorySource([1, 2, 3]))) + }; + + pfs.AddFilesToPack(container, newFiles); + + Assert.That(container.FindFile("folder\\test.txt"), Is.Not.Null); + eventHub.Verify(m => m.PublishGlobalEvent(It.IsAny()), Times.Once); + } + + [Test] + public void AddFilesToPack_CaPack_Throws() + { + var pfs = CreateServiceWithCaPack(); + var caPack = pfs.GetAllPackfileContainers().First(); + var newFiles = new List + { + new("folder", new PackFile("test.txt", new MemorySource([1]))) + }; + + Assert.Throws(() => pfs.AddFilesToPack(caPack, newFiles)); + } + + [Test] + public void CopyFileFromOtherPackFile_CopiesData() + { + var eventHub = new Mock(); + var pfs = CreateServiceWithCaPack(eventHub); + + var source = new PackFileContainer("Source") { SystemFilePath = "source_path" }; + source.AddOrUpdateFile("data\\file.bin", new PackFile("file.bin", new MemorySource([10, 20, 30]))); + pfs.AddContainer(source); + + var target = new PackFileContainer("Target") { SystemFilePath = "target_path" }; + pfs.AddContainer(target, true); + + pfs.CopyFileFromOtherPackFile(source, "data\\file.bin", target); + + var copied = target.FindFile("data\\file.bin"); + Assert.That(copied, Is.Not.Null); + Assert.That(copied.Name, Is.EqualTo("file.bin")); + Assert.That(copied.DataSource.ReadData(), Is.EqualTo(new byte[] { 10, 20, 30 })); + } + + [Test] + public void CopyFileFromOtherPackFile_MissingFile_DoesNothing() + { + var pfs = CreateServiceWithCaPack(); + var source = new PackFileContainer("Source") { SystemFilePath = "source_path" }; + pfs.AddContainer(source); + var target = new PackFileContainer("Target") { SystemFilePath = "target_path" }; + pfs.AddContainer(target, true); + + pfs.CopyFileFromOtherPackFile(source, "nonexistent\\file.bin", target); + + Assert.That(target.GetFileCount(), Is.EqualTo(0)); + } + + [Test] + public void CopyFileFromOtherPackFile_TargetIsCaPack_Throws() + { + var pfs = CreateServiceWithCaPack(); + var caPack = pfs.GetAllPackfileContainers().First(); + var source = new PackFileContainer("Source") { SystemFilePath = "source_path" }; + source.AddOrUpdateFile("file.txt", new PackFile("file.txt", new MemorySource([1]))); + pfs.AddContainer(source); + + Assert.Throws(() => pfs.CopyFileFromOtherPackFile(source, "file.txt", caPack)); + } + + [Test] + public void DeleteFile_RemovesFile() + { + var eventHub = new Mock(); + var pfs = CreateServiceWithCaPack(eventHub); + var container = new PackFileContainer("Custom") { SystemFilePath = "path" }; + container.AddOrUpdateFile("folder\\test.txt", new PackFile("test.txt", new MemorySource([1]))); + pfs.AddContainer(container, true); + + var file = container.FindFile("folder\\test.txt")!; + pfs.DeleteFile(container, file); + + Assert.That(container.FindFile("folder\\test.txt"), Is.Null); + eventHub.Verify(m => m.PublishGlobalEvent(It.IsAny()), Times.Once); + } + + [Test] + public void DeleteFile_CaPack_Throws() + { + var pfs = CreateServiceWithCaPack(); + var caPack = pfs.GetAllPackfileContainers().First(); + var file = new PackFile("test.txt", new MemorySource([1])); + + Assert.Throws(() => pfs.DeleteFile(caPack, file)); + } + + [Test] + public void DeleteFolder_RemovesFiles() + { + var eventHub = new Mock(); + var pfs = CreateServiceWithCaPack(eventHub); + var container = new PackFileContainer("Custom") { SystemFilePath = "path" }; + container.AddOrUpdateFile("folder\\a.txt", new PackFile("a.txt", new MemorySource([1]))); + container.AddOrUpdateFile("folder\\b.txt", new PackFile("b.txt", new MemorySource([2]))); + container.AddOrUpdateFile("other\\c.txt", new PackFile("c.txt", new MemorySource([3]))); + pfs.AddContainer(container, true); + + pfs.DeleteFolder(container, "folder"); + + Assert.That(container.FindFile("folder\\a.txt"), Is.Null); + Assert.That(container.FindFile("folder\\b.txt"), Is.Null); + Assert.That(container.FindFile("other\\c.txt"), Is.Not.Null); + } + + [Test] + public void DeleteFolder_CaPack_Throws() + { + var pfs = CreateServiceWithCaPack(); + var caPack = pfs.GetAllPackfileContainers().First(); + + Assert.Throws(() => pfs.DeleteFolder(caPack, "folder")); + } + + [Test] + public void MoveFile_ChangesPath() + { + var eventHub = new Mock(); + var pfs = CreateServiceWithCaPack(eventHub); + var container = new PackFileContainer("Custom") { SystemFilePath = "path" }; + container.AddOrUpdateFile("old\\test.txt", new PackFile("test.txt", new MemorySource([1, 2]))); + pfs.AddContainer(container, true); + + var file = container.FindFile("old\\test.txt")!; + pfs.MoveFile(container, file, "new"); + + Assert.That(container.FindFile("old\\test.txt"), Is.Null); + Assert.That(container.FindFile("new\\test.txt"), Is.Not.Null); + } + + [Test] + public void MoveFile_CaPack_Throws() + { + var pfs = CreateServiceWithCaPack(); + var caPack = pfs.GetAllPackfileContainers().First(); + var file = new PackFile("test.txt", new MemorySource([1])); + + Assert.Throws(() => pfs.MoveFile(caPack, file, "new")); + } + + [Test] + public void RenameFile_ChangesName() + { + var eventHub = new Mock(); + var pfs = CreateServiceWithCaPack(eventHub); + var container = new PackFileContainer("Custom") { SystemFilePath = "path" }; + container.AddOrUpdateFile("folder\\old.txt", new PackFile("old.txt", new MemorySource([1]))); + pfs.AddContainer(container, true); + + var file = container.FindFile("folder\\old.txt")!; + pfs.RenameFile(container, file, "new.txt"); + + Assert.That(file.Name, Is.EqualTo("new.txt")); + eventHub.Verify(m => m.PublishGlobalEvent(It.IsAny()), Times.Once); + } + + [Test] + public void RenameFile_EmptyName_Throws() + { + var pfs = CreateServiceWithCaPack(); + var container = new PackFileContainer("Custom") { SystemFilePath = "path" }; + container.AddOrUpdateFile("folder\\file.txt", new PackFile("file.txt", new MemorySource([1]))); + pfs.AddContainer(container, true); + + var file = container.FindFile("folder\\file.txt")!; + Assert.Throws(() => pfs.RenameFile(container, file, "")); + Assert.Throws(() => pfs.RenameFile(container, file, " ")); + } + + [Test] + public void RenameFile_CaPack_Throws() + { + var pfs = CreateServiceWithCaPack(); + var caPack = pfs.GetAllPackfileContainers().First(); + var file = new PackFile("test.txt", new MemorySource([1])); + + Assert.Throws(() => pfs.RenameFile(caPack, file, "new.txt")); + } + + [Test] + public void RenameDirectory_ChangesPath() + { + var eventHub = new Mock(); + var pfs = CreateServiceWithCaPack(eventHub); + var container = new PackFileContainer("Custom") { SystemFilePath = "path" }; + container.AddOrUpdateFile("oldname\\file.txt", new PackFile("file.txt", new MemorySource([1]))); + pfs.AddContainer(container, true); + + pfs.RenameDirectory(container, "oldname", "newname"); + + Assert.That(container.FindFile("oldname\\file.txt"), Is.Null); + Assert.That(container.FindFile("newname\\file.txt"), Is.Not.Null); + eventHub.Verify(m => m.PublishGlobalEvent(It.IsAny()), Times.Once); + } + + [Test] + public void RenameDirectory_EmptyName_Throws() + { + var pfs = CreateServiceWithCaPack(); + var container = new PackFileContainer("Custom") { SystemFilePath = "path" }; + pfs.AddContainer(container, true); + + Assert.Throws(() => pfs.RenameDirectory(container, "folder", "")); + } + + [Test] + public void RenameDirectory_CaPack_Throws() + { + var pfs = CreateServiceWithCaPack(); + var caPack = pfs.GetAllPackfileContainers().First(); + + Assert.Throws(() => pfs.RenameDirectory(caPack, "folder", "new")); + } + + [Test] + public void FindFile_FindsAcrossContainers() + { + var pfs = CreateServiceWithCaPack(); + var container = new PackFileContainer("Custom") { SystemFilePath = "path" }; + container.AddOrUpdateFile("data\\file.txt", new PackFile("file.txt", new MemorySource([1, 2]))); + pfs.AddContainer(container, true); + + var result = pfs.FindFile("data\\file.txt"); + Assert.That(result, Is.Not.Null); + Assert.That(result.Name, Is.EqualTo("file.txt")); + } + + [Test] + public void FindFile_ReturnsNullForMissing() + { + var pfs = CreateServiceWithCaPack(); + var result = pfs.FindFile("nonexistent\\file.txt"); + Assert.That(result, Is.Null); + } + + [Test] + public void FindFile_SpecificContainer_OnlySearchesThat() + { + var pfs = CreateServiceWithCaPack(); + var container1 = new PackFileContainer("Pack1") { SystemFilePath = "path1" }; + container1.AddOrUpdateFile("file.txt", new PackFile("file.txt", new MemorySource([1]))); + pfs.AddContainer(container1); + + var container2 = new PackFileContainer("Pack2") { SystemFilePath = "path2" }; + pfs.AddContainer(container2); + + var found = pfs.FindFile("file.txt", container2); + Assert.That(found, Is.Null); + + var foundInCorrect = pfs.FindFile("file.txt", container1); + Assert.That(foundInCorrect, Is.Not.Null); + } + + [Test] + public void FindFile_LaterContainerTakesPriority() + { + var pfs = CreateServiceWithCaPack(); + var container1 = new PackFileContainer("Pack1") { SystemFilePath = "path1" }; + container1.AddOrUpdateFile("file.txt", new PackFile("file.txt", new MemorySource([1]))); + pfs.AddContainer(container1); + + var container2 = new PackFileContainer("Pack2") { SystemFilePath = "path2" }; + container2.AddOrUpdateFile("file.txt", new PackFile("file.txt", new MemorySource([2]))); + pfs.AddContainer(container2); + + var result = pfs.FindFile("file.txt"); + Assert.That(result!.DataSource.ReadData(), Is.EqualTo(new byte[] { 2 })); + } + + [Test] + public void FindAllWithExtention_SearchesAllContainers() + { + var pfs = CreateServiceWithCaPack(); + var container = new PackFileContainer("Custom") { SystemFilePath = "path" }; + container.AddOrUpdateFile("a.txt", new PackFile("a.txt", new MemorySource([1]))); + container.AddOrUpdateFile("b.txt", new PackFile("b.txt", new MemorySource([2]))); + container.AddOrUpdateFile("c.bin", new PackFile("c.bin", new MemorySource([3]))); + pfs.AddContainer(container, true); + + var results = pfs.FindAllWithExtention(".txt"); + Assert.That(results.Count, Is.EqualTo(2)); + } + + [Test] + public void FindAllWithExtention_SpecificContainer() + { + var pfs = CreateServiceWithCaPack(); + var container1 = new PackFileContainer("Pack1") { SystemFilePath = "path1" }; + container1.AddOrUpdateFile("a.txt", new PackFile("a.txt", new MemorySource([1]))); + pfs.AddContainer(container1); + + var container2 = new PackFileContainer("Pack2") { SystemFilePath = "path2" }; + container2.AddOrUpdateFile("b.txt", new PackFile("b.txt", new MemorySource([2]))); + pfs.AddContainer(container2); + + var results = pfs.FindAllWithExtention(".txt", container1); + Assert.That(results.Count, Is.EqualTo(1)); + } + + [Test] + public void GetFullPath_ReturnsPath() + { + var pfs = CreateServiceWithCaPack(); + var container = new PackFileContainer("Custom") { SystemFilePath = "path" }; + container.AddOrUpdateFile("folder\\file.txt", new PackFile("file.txt", new MemorySource([1]))); + pfs.AddContainer(container, true); + + var file = container.FindFile("folder\\file.txt")!; + var path = pfs.GetFullPath(file); + Assert.That(path, Is.EqualTo("folder\\file.txt")); + } + + [Test] + public void GetFullPath_UnknownFile_Throws() + { + var pfs = CreateServiceWithCaPack(); + var unknownFile = new PackFile("unknown.txt", new MemorySource([1])); + + Assert.Throws(() => pfs.GetFullPath(unknownFile)); + } + + [Test] + public void GetPackFileContainer_ReturnsCorrectContainer() + { + var pfs = CreateServiceWithCaPack(); + var container = new PackFileContainer("Custom") { SystemFilePath = "path" }; + container.AddOrUpdateFile("file.txt", new PackFile("file.txt", new MemorySource([1]))); + pfs.AddContainer(container, true); + + var file = container.FindFile("file.txt")!; + var result = pfs.GetPackFileContainer(file); + Assert.That(result, Is.EqualTo(container)); + } + + [Test] + public void GetPackFileContainer_UnknownFile_ReturnsNull() + { + var pfs = CreateServiceWithCaPack(); + var unknownFile = new PackFile("unknown.txt", new MemorySource([1])); + + var result = pfs.GetPackFileContainer(unknownFile); + Assert.That(result, Is.Null); + } + + [Test] + public void SaveFile_UpdatesFileData() + { + var eventHub = new Mock(); + var pfs = CreateServiceWithCaPack(eventHub); + var container = new PackFileContainer("Custom") { SystemFilePath = "path" }; + container.AddOrUpdateFile("file.txt", new PackFile("file.txt", new MemorySource([1, 2, 3]))); + pfs.AddContainer(container, true); + + var file = container.FindFile("file.txt")!; + pfs.SaveFile(file, [4, 5, 6]); + + Assert.That(file.DataSource.ReadData(), Is.EqualTo(new byte[] { 4, 5, 6 })); + eventHub.Verify(m => m.PublishGlobalEvent(It.IsAny()), Times.Once); + eventHub.Verify(m => m.PublishGlobalEvent(It.IsAny()), Times.Once); + } } } diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Serialization/PackFileContainerCacheHelperTests.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Serialization/PackFileContainerCacheHelperTests.cs index 6acfdfc71..0b1023af0 100644 --- a/Shared/SharedCore/Shared.CoreTest/PackFiles/Serialization/PackFileContainerCacheHelperTests.cs +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Serialization/PackFileContainerCacheHelperTests.cs @@ -1,7 +1,8 @@ -using Shared.Core.PackFiles.Models; +using Microsoft.EntityFrameworkCore; +using Shared.Core.PackFiles.Models; using Shared.Core.PackFiles.Models.Containers; using Shared.Core.PackFiles.Models.FileSources; -using Shared.Core.PackFiles.Serialization; +using Shared.Core.PackFiles.Serialization.CacheDatabase; using Shared.Core.PackFiles.Utility; namespace Shared.CoreTest.PackFiles.Serialization @@ -9,14 +10,14 @@ namespace Shared.CoreTest.PackFiles.Serialization internal class PackFileContainerCacheHelperTests { private string _tempDir; - private string _cacheFilePath; + private string _dbFilePath; [SetUp] public void Setup() { _tempDir = Path.Combine(Path.GetTempPath(), "AssetEditorCacheTests_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDir); - _cacheFilePath = Path.Combine(_tempDir, "test_cache.bin"); + _dbFilePath = Path.Combine(_tempDir, "test_cache.db"); } [TearDown] @@ -26,6 +27,11 @@ public void TearDown() Directory.Delete(_tempDir, true); } + private DbContextOptions CreateTestDbOptions() + { + return PackFileContainerCacheHelper.CreateDbOptions(_dbFilePath); + } + [Test] public void RoundTrip_PreservesMetadata() { @@ -49,9 +55,9 @@ public void RoundTrip_PreservesMetadata() container.AddOrUpdateFile("folder\\file2.bin", new PackFile("file2.bin", source2)); // Act - var cacheData = PackFileContainerCacheHelper.BuildCacheData("fingerprint123", container); - PackFileContainerCacheHelper.SaveCache(cacheData, _cacheFilePath); - var loaded = PackFileContainerCacheHelper.LoadContainerFromCache(_cacheFilePath, "fingerprint123"); + var dbOptions = CreateTestDbOptions(); + PackFileContainerCacheHelper.SaveCache("fingerprint123", container, dbOptions); + var loaded = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fingerprint123"); // Assert Assert.That(loaded, Is.Not.Null); @@ -62,52 +68,35 @@ public void RoundTrip_PreservesMetadata() } [Test] - public void RestoreFromCache_CreatesCorrectContainer() + public void LoadCache_ReturnsCorrectFileData() { // Arrange - var cacheData = new CachedContainerData + var container = new PackFileContainer("Test Container") { - Fingerprint = "fp", - ContainerName = "Test Container", - SystemFilePath = @"c:\game\data", - SourcePackFilePaths = [@"c:\game\data\pack1.pack"], - Files = - [ - new CachedFileEntry( - "folder\\file.txt", - "file.txt", - @"c:\game\data\pack1.pack", - Offset: 512, - Size: 1024, - IsEncrypted: false, - IsCompressed: false, - CompressionFormat.None, - UncompressedSize: 0), - new CachedFileEntry( - "other\\data.bin", - "data.bin", - @"c:\game\data\pack1.pack", - Offset: 2048, - Size: 4096, - IsEncrypted: false, - IsCompressed: true, - CompressionFormat.Lz4, - UncompressedSize: 8192) - ] + IsCaPackFile = true, + SystemFilePath = @"c:\game\data" }; + container.SourcePackFilePaths.Add(@"c:\game\data\pack1.pack"); + + var parent = new PackedFileSourceParent { FilePath = @"c:\game\data\pack1.pack" }; + container.AddOrUpdateFile("folder\\file.txt", new PackFile("file.txt", + new PackedFileSource(parent, 512, 1024, false, false, CompressionFormat.None, 0))); + container.AddOrUpdateFile("other\\data.bin", new PackFile("data.bin", + new PackedFileSource(parent, 2048, 4096, false, true, CompressionFormat.Lz4, 8192))); + + var dbOptions = CreateTestDbOptions(); + PackFileContainerCacheHelper.SaveCache("fp", container, dbOptions); + // Act - var container = PackFileContainerCacheHelper.RestoreFromCache(cacheData); + var loaded = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp"); // Assert - Assert.That(container, Is.InstanceOf()); - Assert.That(container.Name, Is.EqualTo("Test Container")); - Assert.That(container.IsCaPackFile, Is.True); - Assert.That(container.SystemFilePath, Is.EqualTo(@"c:\game\data")); - Assert.That(container.SourcePackFilePaths.Count, Is.EqualTo(1)); - Assert.That(container.GetFileCount(), Is.EqualTo(2)); - - var file1 = container.FindFile("folder\\file.txt"); + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded.GetFileCount(), Is.EqualTo(2)); + + var file1 = loaded.FindFile("folder\\file.txt"); + Assert.That(file1, Is.Not.Null); Assert.That(file1.Name, Is.EqualTo("file.txt")); var file1Source = file1.DataSource as PackedFileSource; Assert.That(file1Source, Is.Not.Null); @@ -116,7 +105,7 @@ public void RestoreFromCache_CreatesCorrectContainer() Assert.That(file1Source.IsCompressed, Is.False); Assert.That(file1Source.Parent.FilePath, Is.EqualTo(@"c:\game\data\pack1.pack")); - var file2 = container.FindFile("other\\data.bin"); + var file2 = loaded.FindFile("other\\data.bin"); var file2Source = file2.DataSource as PackedFileSource; Assert.That(file2Source, Is.Not.Null); Assert.That(file2Source.Offset, Is.EqualTo(2048)); @@ -127,39 +116,55 @@ public void RestoreFromCache_CreatesCorrectContainer() } [Test] - public void RestoreFromCache_SharesPackedFileSourceParents() + public void LoadCache_PreservesSourcePackFilePath() { - var cacheData = new CachedContainerData + var container = new PackFileContainer("Test") { - Fingerprint = "fp", - ContainerName = "Test", - SystemFilePath = @"c:\game", - Files = - [ - new CachedFileEntry("a.txt", "a.txt", @"c:\pack.pack", 0, 10, false, false, CompressionFormat.None, 0), - new CachedFileEntry("b.txt", "b.txt", @"c:\pack.pack", 10, 20, false, false, CompressionFormat.None, 0), - ] + SystemFilePath = @"c:\game" }; - var container = PackFileContainerCacheHelper.RestoreFromCache(cacheData); + var parent = new PackedFileSourceParent { FilePath = @"c:\pack.pack" }; + container.AddOrUpdateFile("a.txt", new PackFile("a.txt", + new PackedFileSource(parent, 0, 10, false, false, CompressionFormat.None, 0))); + container.AddOrUpdateFile("b.txt", new PackFile("b.txt", + new PackedFileSource(parent, 10, 20, false, false, CompressionFormat.None, 0))); + + var dbOptions = CreateTestDbOptions(); + PackFileContainerCacheHelper.SaveCache("fp", container, dbOptions); + var loaded = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp"); - var sourceA = (PackedFileSource)container.FindFile("a.txt")!.DataSource; - var sourceB = (PackedFileSource)container.FindFile("b.txt")!.DataSource; - Assert.That(ReferenceEquals(sourceA.Parent, sourceB.Parent), Is.True, - "Files from the same pack should share PackedFileSourceParent instances"); + var sourceA = (PackedFileSource)loaded!.FindFile("a.txt")!.DataSource; + var sourceB = (PackedFileSource)loaded!.FindFile("b.txt")!.DataSource; + Assert.That(sourceA.Parent.FilePath, Is.EqualTo(@"c:\pack.pack")); + Assert.That(sourceB.Parent.FilePath, Is.EqualTo(@"c:\pack.pack")); } [Test] public void LoadCache_ReturnsNullForMissingFile() { - var result = PackFileContainerCacheHelper.LoadContainerFromCache(Path.Combine(_tempDir, "nonexistent.bin"), "fp"); + var result = PackFileContainerCacheHelper.LoadContainerFromCache( + Path.Combine(_tempDir, "nonexistent.db"), "fp"); + Assert.That(result, Is.Null); + } + + [Test] + public void LoadCache_ReturnsNullForWrongFingerprint() + { + var container = new PackFileContainer("Test") + { + SystemFilePath = @"c:\game" + }; + + var dbOptions = CreateTestDbOptions(); + PackFileContainerCacheHelper.SaveCache("correct_fp", container, dbOptions); + + var result = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "wrong_fp"); Assert.That(result, Is.Null); } [Test] public void ComputeFingerprint_DeterministicForSameInputs() { - // Create fake pack files var packDir = Path.Combine(_tempDir, "gamedata"); Directory.CreateDirectory(packDir); File.WriteAllText(Path.Combine(packDir, "a.pack"), "data_a"); @@ -182,7 +187,6 @@ public void ComputeFingerprint_ChangesWhenFileChanges() var fp1 = PackFileContainerCacheHelper.ComputeFingerprint(packDir, packFiles); - // Modify the file (change size) File.WriteAllText(Path.Combine(packDir, "a.pack"), "data_a_modified_longer"); var fp2 = PackFileContainerCacheHelper.ComputeFingerprint(packDir, packFiles); @@ -191,7 +195,7 @@ public void ComputeFingerprint_ChangesWhenFileChanges() } [Test] - public void RoundTrip_FullCycle_BuildSaveLoadRestore() + public void RoundTrip_FullCycle() { // Arrange var container = new PackFileContainer("Full Cycle Test") @@ -208,12 +212,13 @@ public void RoundTrip_FullCycle_BuildSaveLoadRestore() container.AddOrUpdateFile("text\\localisation.loc", new PackFile("localisation.loc", new PackedFileSource(parent, 256, 512, false, true, CompressionFormat.Lz4, 1024))); - // Act: build → save → load - var cacheData = PackFileContainerCacheHelper.BuildCacheData("test_fp", container); - PackFileContainerCacheHelper.SaveCache(cacheData, _cacheFilePath); - var restored = PackFileContainerCacheHelper.LoadContainerFromCache(_cacheFilePath, "test_fp"); + // Act: save ? load + var dbOptions = CreateTestDbOptions(); + PackFileContainerCacheHelper.SaveCache("test_fp", container, dbOptions); + var restored = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "test_fp"); - // Assert: restored container matches original + // Assert + Assert.That(restored, Is.Not.Null); Assert.That(restored.Name, Is.EqualTo("Full Cycle Test")); Assert.That(restored.IsCaPackFile, Is.True); Assert.That(restored.SystemFilePath, Is.EqualTo(@"c:\game\data")); @@ -231,67 +236,206 @@ public void RoundTrip_FullCycle_BuildSaveLoadRestore() } [Test] - public void LoadCache_ReturnsNullForBadMagic() + public void TryLoadFromCache_ReturnsContainerWhenValid() { - File.WriteAllBytes(_cacheFilePath, [0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]); - var result = PackFileContainerCacheHelper.LoadContainerFromCache(_cacheFilePath, "fp"); + var container = new PackFileContainer("TryLoad Test") + { + SystemFilePath = @"c:\game\data" + }; + + var parent = new PackedFileSourceParent { FilePath = @"c:\game\data\pack.pack" }; + container.AddOrUpdateFile("test\\file.txt", new PackFile("file.txt", + new PackedFileSource(parent, 0, 100, false, false, CompressionFormat.None, 0))); + + var dbOptions = CreateTestDbOptions(); + PackFileContainerCacheHelper.SaveCache("fp_try", container, dbOptions); + + var result = PackFileContainerCacheHelper.TryLoadFromCache(_dbFilePath, "fp_try"); + Assert.That(result, Is.Not.Null); + Assert.That(result.Name, Is.EqualTo("TryLoad Test")); + } + + [Test] + public void TryLoadFromCache_ReturnsNullForMissingFile() + { + var result = PackFileContainerCacheHelper.TryLoadFromCache( + Path.Combine(_tempDir, "does_not_exist.db"), "fp"); Assert.That(result, Is.Null); } [Test] - public void LoadCache_ReturnsNullForWrongVersion() + public void TryLoadFromCache_ReturnsNullForCorruptFile() { - using (var stream = File.Create(_cacheFilePath)) - using (var writer = new BinaryWriter(stream)) + File.WriteAllBytes(_dbFilePath, [0xFF, 0xFE, 0x00, 0x01]); + var result = PackFileContainerCacheHelper.TryLoadFromCache(_dbFilePath, "fp"); + Assert.That(result, Is.Null); + } + + [Test] + public void SaveCache_PreservesEncryptedFlag() + { + var container = new PackFileContainer("Encrypted Test") { - writer.Write("AEPC"u8); - writer.Write(999); // wrong version - } + SystemFilePath = @"c:\game" + }; - var result = PackFileContainerCacheHelper.LoadContainerFromCache(_cacheFilePath, "fp"); - Assert.That(result, Is.Null); + var parent = new PackedFileSourceParent { FilePath = @"c:\game\encrypted.pack" }; + container.AddOrUpdateFile("secret\\data.bin", new PackFile("data.bin", + new PackedFileSource(parent, 0, 500, isEncrypted: true, isCompressed: false, CompressionFormat.None, 0))); + + var dbOptions = CreateTestDbOptions(); + PackFileContainerCacheHelper.SaveCache("fp", container, dbOptions); + var loaded = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp"); + + var source = (PackedFileSource)loaded!.FindFile("secret\\data.bin")!.DataSource; + Assert.That(source.IsEncrypted, Is.True); } [Test] - public void LoadContainerFromCache_ReturnsNullForFingerprintMismatch() + public void SaveCache_EmptyContainer_RoundTrips() { - var container = new PackFileContainer("Test") + var container = new PackFileContainer("Empty Pack") { - IsCaPackFile = true, - SystemFilePath = @"c:\game\data" + SystemFilePath = @"c:\game\empty" }; - var parent = new PackedFileSourceParent { FilePath = @"c:\game\data\pack.pack" }; - container.AddOrUpdateFile("a.txt", new PackFile("a.txt", - new PackedFileSource(parent, 0, 10, false, false, CompressionFormat.None, 0))); - var cacheData = PackFileContainerCacheHelper.BuildCacheData("original_fp", container); - PackFileContainerCacheHelper.SaveCache(cacheData, _cacheFilePath); + var dbOptions = CreateTestDbOptions(); + PackFileContainerCacheHelper.SaveCache("fp_empty", container, dbOptions); + var loaded = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp_empty"); - var result = PackFileContainerCacheHelper.LoadContainerFromCache(_cacheFilePath, "different_fp"); - Assert.That(result, Is.Null); + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded.Name, Is.EqualTo("Empty Pack")); + Assert.That(loaded.GetFileCount(), Is.EqualTo(0)); } [Test] - public void LoadContainerFromCache_SharesParentInstances() + public void SaveCache_OverwritesExistingCache() { - var container = new PackFileContainer("Test") + var parent = new PackedFileSourceParent { FilePath = @"c:\game\pack.pack" }; + var dbOptions = CreateTestDbOptions(); + + // Save first version + var container1 = new PackFileContainer("Version1") { SystemFilePath = @"c:\game" }; + container1.AddOrUpdateFile("old.txt", new PackFile("old.txt", + new PackedFileSource(parent, 0, 10, false, false, CompressionFormat.None, 0))); + PackFileContainerCacheHelper.SaveCache("fp1", container1, dbOptions); + + // Save second version (same db path) + var container2 = new PackFileContainer("Version2") { SystemFilePath = @"c:\game" }; + container2.AddOrUpdateFile("new.txt", new PackFile("new.txt", + new PackedFileSource(parent, 0, 20, false, false, CompressionFormat.None, 0))); + PackFileContainerCacheHelper.SaveCache("fp2", container2, dbOptions); + + // Old fingerprint should fail + var oldResult = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp1"); + Assert.That(oldResult, Is.Null); + + // New fingerprint should work + var newResult = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp2"); + Assert.That(newResult, Is.Not.Null); + Assert.That(newResult.Name, Is.EqualTo("Version2")); + Assert.That(newResult.GetFileCount(), Is.EqualTo(1)); + Assert.That(newResult.FindFile("new.txt"), Is.Not.Null); + } + + [Test] + public void SaveCache_StoresFolderPathCorrectly() + { + var container = new PackFileContainer("FolderPath Test") { - IsCaPackFile = true, SystemFilePath = @"c:\game" }; - var parent = new PackedFileSourceParent { FilePath = @"c:\pack.pack" }; - container.AddOrUpdateFile("a.txt", new PackFile("a.txt", + + var parent = new PackedFileSourceParent { FilePath = @"c:\game\pack.pack" }; + container.AddOrUpdateFile("a\\b\\c\\file.txt", new PackFile("file.txt", new PackedFileSource(parent, 0, 10, false, false, CompressionFormat.None, 0))); - container.AddOrUpdateFile("b.txt", new PackFile("b.txt", + container.AddOrUpdateFile("root_file.txt", new PackFile("root_file.txt", new PackedFileSource(parent, 10, 20, false, false, CompressionFormat.None, 0))); - var cacheData = PackFileContainerCacheHelper.BuildCacheData("fp", container); - PackFileContainerCacheHelper.SaveCache(cacheData, _cacheFilePath); + var dbOptions = CreateTestDbOptions(); + PackFileContainerCacheHelper.SaveCache("fp", container, dbOptions); + + // Verify via the CachedPackFileContainer's GetDirectoryContent + var loaded = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp"); + Assert.That(loaded, Is.Not.Null); + + var rootContent = loaded.GetDirectoryContent(""); + Assert.That(rootContent.Files.Any(f => f.FileName == "root_file.txt"), Is.True); + Assert.That(rootContent.SubFolders, Does.Contain("a")); + + var deepContent = loaded.GetDirectoryContent("a\\b\\c"); + Assert.That(deepContent.Files.Any(f => f.FileName == "file.txt"), Is.True); + } + + [Test] + public void SaveCache_SkipsNonPackedFileSources() + { + var container = new PackFileContainer("Mixed Sources") + { + SystemFilePath = @"c:\game" + }; + + var parent = new PackedFileSourceParent { FilePath = @"c:\game\pack.pack" }; + container.AddOrUpdateFile("packed.txt", new PackFile("packed.txt", + new PackedFileSource(parent, 0, 10, false, false, CompressionFormat.None, 0))); + container.AddOrUpdateFile("memory.txt", new PackFile("memory.txt", + new MemorySource([1, 2, 3]))); + + var dbOptions = CreateTestDbOptions(); + PackFileContainerCacheHelper.SaveCache("fp", container, dbOptions); + var loaded = PackFileContainerCacheHelper.LoadContainerFromCache(dbOptions, "fp"); + + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded.GetFileCount(), Is.EqualTo(1)); + Assert.That(loaded.FindFile("packed.txt"), Is.Not.Null); + Assert.That(loaded.FindFile("memory.txt"), Is.Null); + } + + [Test] + public void ComputeFingerprint_IgnoresMissingPackFiles() + { + var packDir = Path.Combine(_tempDir, "partial"); + Directory.CreateDirectory(packDir); + File.WriteAllText(Path.Combine(packDir, "exists.pack"), "data"); + + var packFiles = new List { "exists.pack", "missing.pack" }; + var fp = PackFileContainerCacheHelper.ComputeFingerprint(packDir, packFiles); + + Assert.That(fp, Is.Not.Null.And.Not.Empty); + + // Same result regardless of missing file in list + var fp2 = PackFileContainerCacheHelper.ComputeFingerprint(packDir, new List { "exists.pack", "missing.pack" }); + Assert.That(fp, Is.EqualTo(fp2)); + } + + [Test] + public void ComputeFingerprint_OrderIndependent() + { + var packDir = Path.Combine(_tempDir, "order_test"); + Directory.CreateDirectory(packDir); + File.WriteAllText(Path.Combine(packDir, "alpha.pack"), "aaa"); + File.WriteAllText(Path.Combine(packDir, "beta.pack"), "bbb"); + File.WriteAllText(Path.Combine(packDir, "gamma.pack"), "ccc"); + + var fp1 = PackFileContainerCacheHelper.ComputeFingerprint(packDir, + new List { "gamma.pack", "alpha.pack", "beta.pack" }); + var fp2 = PackFileContainerCacheHelper.ComputeFingerprint(packDir, + new List { "alpha.pack", "beta.pack", "gamma.pack" }); + var fp3 = PackFileContainerCacheHelper.ComputeFingerprint(packDir, + new List { "beta.pack", "gamma.pack", "alpha.pack" }); + + Assert.That(fp1, Is.EqualTo(fp2)); + Assert.That(fp2, Is.EqualTo(fp3)); + } + + [Test] + public void GetCacheFilePath_SanitizesInvalidChars() + { + var path = PackFileContainerCacheHelper.GetCacheFilePath(@"c:\game", "Game:Name/WithChars", "abc123"); + var fileName = Path.GetFileName(path); - var restored = PackFileContainerCacheHelper.LoadContainerFromCache(_cacheFilePath, "fp"); - var sourceA = (PackedFileSource)restored!.FindFile("a.txt")!.DataSource; - var sourceB = (PackedFileSource)restored.FindFile("b.txt")!.DataSource; - Assert.That(ReferenceEquals(sourceA.Parent, sourceB.Parent), Is.True); + Assert.That(fileName.IndexOfAny(Path.GetInvalidFileNameChars()), Is.EqualTo(-1)); + Assert.That(path.EndsWith(".db"), Is.True); } } } diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Utility/PackFileContainerLoaderTests.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Utility/PackFileContainerLoaderTests.cs new file mode 100644 index 000000000..1f0c835db --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Utility/PackFileContainerLoaderTests.cs @@ -0,0 +1,180 @@ +using Moq; +using Shared.Core.Misc; +using Shared.Core.PackFiles.Utility; +using Shared.Core.Services; +using Shared.Core.Settings; +using Test.TestingUtility.TestUtility; + +namespace Shared.CoreTest.PackFiles.Utility +{ + internal class PackFileContainerLoaderTests + { + private string _tempGameDir; + private Mock _dialogs; + private Mock _waitCursor; + private LocalizationManager _localizationManager; + private ApplicationSettingsService _settingsService; + + [SetUp] + public void Setup() + { + DirectoryHelper.EnsureCreated(); + + _tempGameDir = Path.Combine(Path.GetTempPath(), "AE_LoaderTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempGameDir); + + // Copy the Karl pack file into the temp game dir and touch it to get a unique fingerprint + var karlPackSource = PathHelper.GetDataFile("Karl_and_celestialgeneral.pack"); + var destPath = Path.Combine(_tempGameDir, "Karl_and_celestialgeneral.pack"); + File.Copy(karlPackSource, destPath); + File.SetLastWriteTimeUtc(destPath, DateTime.UtcNow); + + _dialogs = new Mock(); + _waitCursor = new Mock(); + _dialogs.Setup(d => d.ShowWaitCursor()).Returns(_waitCursor.Object); + + _localizationManager = new LocalizationManager(); + + _settingsService = new ApplicationSettingsService(GameTypeEnum.Warhammer3); + _settingsService.CurrentSettings.GameDirectories.Add( + new ApplicationSettings.GamePathPair { Game = GameTypeEnum.Warhammer3, Path = _tempGameDir }); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempGameDir)) + Directory.Delete(_tempGameDir, true); + } + + private PackFileContainerLoader CreateLoader() + { + return new PackFileContainerLoader(_settingsService, _dialogs.Object, _localizationManager); + } + + [Test] + public void LoadAllCaFiles_NoCacheExists_ShowsNotFoundDialogAndWaitCursor() + { + var loader = CreateLoader(); + + var result = loader.LoadAllCaFiles(GameTypeEnum.Warhammer3); + + Assert.That(result, Is.Not.Null); + + // Single combined dialog: reason + building message + _dialogs.Verify(d => d.ShowDialogBox(It.IsAny(), It.IsAny()), Times.Once); + + // Should show wait cursor + _dialogs.Verify(d => d.ShowWaitCursor(), Times.Once); + _waitCursor.Verify(w => w.Dispose(), Times.Once); + } + + [Test] + public void LoadAllCaFiles_CacheExists_NoDialogShown() + { + var loader = CreateLoader(); + + // First call builds the cache + var firstResult = loader.LoadAllCaFiles(GameTypeEnum.Warhammer3); + Assert.That(firstResult, Is.Not.Null); + + _dialogs.Invocations.Clear(); + + // Second call should use cache - no dialogs + var secondResult = loader.LoadAllCaFiles(GameTypeEnum.Warhammer3); + Assert.That(secondResult, Is.Not.Null); + + _dialogs.Verify(d => d.ShowDialogBox(It.IsAny(), It.IsAny()), Times.Never); + _dialogs.Verify(d => d.ShowWaitCursor(), Times.Never); + } + + [Test] + public void LoadAllCaFiles_CacheCorrupted_ShowsCorruptedDialog() + { + var loader = CreateLoader(); + + // First call builds the cache + var firstResult = loader.LoadAllCaFiles(GameTypeEnum.Warhammer3); + Assert.That(firstResult, Is.Not.Null); + + // Find and corrupt the cache file + var cacheDir = Shared.Core.Misc.DirectoryHelper.CacheDirectory; + var cacheFiles = Directory.GetFiles(cacheDir, "*.db", SearchOption.AllDirectories); + Assert.That(cacheFiles.Length, Is.GreaterThan(0), "Cache file should have been created"); + + // Corrupt all matching cache files + foreach (var cacheFile in cacheFiles) + { + if (cacheFile.Contains("Warhammer")) + File.WriteAllText(cacheFile, "CORRUPTED DATA"); + } + + _dialogs.Invocations.Clear(); + + // Load again — should detect corruption + var secondResult = loader.LoadAllCaFiles(GameTypeEnum.Warhammer3); + Assert.That(secondResult, Is.Not.Null); + + // Single combined dialog: reason + building message + _dialogs.Verify(d => d.ShowDialogBox(It.IsAny(), It.IsAny()), Times.Once); + _dialogs.Verify(d => d.ShowWaitCursor(), Times.Once); + } + + [Test] + public void LoadAllCaFiles_DataChanged_ShowsDialogsAndRebuildsCache() + { + var loader = CreateLoader(); + + // First call builds the cache + var firstResult = loader.LoadAllCaFiles(GameTypeEnum.Warhammer3); + Assert.That(firstResult, Is.Not.Null); + + // Add a new pack file to the game dir to change the fingerprint + var newPackPath = Path.Combine(_tempGameDir, "extra.pack"); + File.Copy(Path.Combine(_tempGameDir, "Karl_and_celestialgeneral.pack"), newPackPath); + + _dialogs.Invocations.Clear(); + + // Load again — fingerprint changed, new fingerprint has no cache file + var secondResult = loader.LoadAllCaFiles(GameTypeEnum.Warhammer3); + Assert.That(secondResult, Is.Not.Null); + + // Single combined dialog: reason + building message + _dialogs.Verify(d => d.ShowDialogBox(It.IsAny(), It.IsAny()), Times.Once); + _dialogs.Verify(d => d.ShowWaitCursor(), Times.Once); + } + + [Test] + public void LoadAllCaFiles_WaitCursorActive_DuringBuild() + { + var waitCursorCreated = false; + var waitCursorDisposed = false; + var dialogShownBeforeWaitCursor = false; + + _dialogs.Setup(d => d.ShowDialogBox(It.IsAny(), It.IsAny())) + .Callback(() => + { + // Dialog is shown BEFORE wait cursor is created + if (!waitCursorCreated) + dialogShownBeforeWaitCursor = true; + }); + + _dialogs.Setup(d => d.ShowWaitCursor()) + .Returns(() => + { + waitCursorCreated = true; + return _waitCursor.Object; + }); + + _waitCursor.Setup(w => w.Dispose()) + .Callback(() => waitCursorDisposed = true); + + var loader = CreateLoader(); + loader.LoadAllCaFiles(GameTypeEnum.Warhammer3); + + Assert.That(dialogShownBeforeWaitCursor, Is.True, "Dialog should be shown before wait cursor starts"); + Assert.That(waitCursorCreated, Is.True, "Wait cursor should have been created"); + Assert.That(waitCursorDisposed, Is.True, "Wait cursor should have been disposed"); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/Shared.CoreTest.csproj b/Shared/SharedCore/Shared.CoreTest/Shared.CoreTest.csproj index 32063cc91..27966a8f4 100644 --- a/Shared/SharedCore/Shared.CoreTest/Shared.CoreTest.csproj +++ b/Shared/SharedCore/Shared.CoreTest/Shared.CoreTest.csproj @@ -13,6 +13,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserViewModel.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserViewModel.cs index fd18c9197..7c1f9475c 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserViewModel.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserViewModel.cs @@ -69,7 +69,7 @@ public PackFileBrowserViewModel(ApplicationSettingsService applicationSettingsSe _eventHub?.Register(this, x => AddFiles(x.Container, x.AddedFiles)); _eventHub?.Register(this, x => Database_PackFilesRemoved(x.Container, x.RemovedFiles)); _eventHub?.Register(this, x => Database_PackFileFolderRemoved(x.Container, x.Folder)); - _eventHub?.Register(this, x => Database_PackFileFolderRenamed(x.Container, x.NewNodePath)); + _eventHub?.Register(this, x => Database_PackFileFolderRenamed(x.Container, x.OldNodePath, x.NewNodePath)); _eventHub?.Register(this, ContainerSaved); Filter = new SearchFilter(Files, () => _treeStates.Values.Select(x => x.RootSource)); @@ -117,13 +117,19 @@ private void Database_PackFileFolderRemoved(IPackFileContainer container, string Filter.Reapply(); } - private void Database_PackFileFolderRenamed(IPackFileContainer container, string folder) + private void Database_PackFileFolderRenamed(IPackFileContainer container, string oldPath, string newPath) { var state = GetPackFileTreeState(container); - var node = GetNodeFromPath(state.RootSource, folder, false); + var node = GetNodeFromPath(state.RootSource, oldPath, false); if (node == null) return; + var newLeafName = newPath; + var lastSep = newPath.LastIndexOf(Path.DirectorySeparatorChar); + if (lastSep != -1) + newLeafName = newPath.Substring(lastSep + 1); + + node.Name = newLeafName; node.UnsavedChanged = true; Filter.Reapply(); } @@ -133,14 +139,23 @@ private void ContainerSaved(PackFileContainerSavedEvent e) var state = GetPackFileTreeState(e.Container); var root = state.RootNode; - foreach (var node in state.RootSource.EnumerateAllNodesDepthFirst()) - node.UnsavedChanged = false; + ClearUnsavedOnLoadedSourceNodes(state.RootSource); root.UnsavedChanged = false; root.ForeachNode((node) => node.UnsavedChanged = false); Filter.Reapply(); } + private static void ClearUnsavedOnLoadedSourceNodes(TreeNodeSource node) + { + node.UnsavedChanged = false; + if (!node.ChildrenLoaded) + return; + + foreach (var child in node.Children) + ClearUnsavedOnLoadedSourceNodes(child); + } + private void Database_PackFilesRemoved(IPackFileContainer container, List files) { var state = GetPackFileTreeState(container); @@ -255,7 +270,6 @@ private void AddFiles(IPackFileContainer container, List files) var numSeperators = fullPath.Count(x => x == Path.DirectorySeparatorChar); var directoryEnd = fullPath.LastIndexOf(Path.DirectorySeparatorChar); - var fileName = fullPath.Substring(directoryEnd + 1); // Check if alreayd added - this happens moving files. @@ -263,14 +277,14 @@ private void AddFiles(IPackFileContainer container, List files) if (numSeperators == 0) { RemoveExistingFileNode(state.RootSource, item.Name, item); - newNode = new TreeNodeSource(fileName, NodeType.File, container, state.RootSource, item); + newNode = new TreeNodeSource(item.Name, NodeType.File, container, state.RootSource, item); InsertChildSorted(state.RootSource, newNode); } else { var directory = fullPath.Substring(0, directoryEnd); var folder = GetNodeFromPath(state.RootSource, directory)!; - newNode = new TreeNodeSource(fileName, NodeType.File, container, folder, item); + newNode = new TreeNodeSource(item.Name, NodeType.File, container, folder, item); // remove any existing files with same name var existingFile = folder.Children.FirstOrDefault(node => node.Name == item.Name && node.NodeType == NodeType.File); @@ -351,6 +365,8 @@ private void AddFiles(IPackFileContainer container, List files) if (path.Length == 0) return parent; + parent.EnsureChildrenPopulated(); + var separatorIndex = path.IndexOf(Path.DirectorySeparatorChar); if (separatorIndex == -1) return parent.Children.FirstOrDefault(x => x.Name == path); @@ -368,6 +384,8 @@ private void AddFiles(IPackFileContainer container, List files) if (path.Length == 0) return parent; + parent.EnsureChildrenPopulated(); + var nodeName = path; var remainingStr = ""; @@ -387,6 +405,7 @@ private void AddFiles(IPackFileContainer container, List files) if (createIfMissing) { var newNode = new TreeNodeSource(nodeName, NodeType.Directory, parent.FileOwner, parent); + newNode.MarkChildrenLoaded(); InsertChildSorted(parent, newNode); return GetNodeFromPath(newNode, remainingStr, createIfMissing); } @@ -400,6 +419,8 @@ private void AddFiles(IPackFileContainer container, List files) var fullPath = _packFileService.GetFullPath(pf, container); var numSeperators = fullPath.Count(x => x == Path.DirectorySeparatorChar); + root.EnsureChildrenPopulated(); + if (numSeperators == 0) { return root.Children.FirstOrDefault(x => x.Item == pf); @@ -422,79 +443,41 @@ private void ReloadTree(IPackFileContainer container) _treeStates.Remove(container); } - var rootSource = new TreeNodeSource(container.Name, NodeType.Root, container, null); - var directoryMap_new = new Dictionary(container.GetFileCount()) - { - [string.Empty] = rootSource - }; var skipWemFiles = container.IsCaPackFile && _applicationSettingsService.CurrentSettings.ShowCAWemFiles == false; - List<(string FolderName, string FullFolderPath)> stackFileNames = new(10); - foreach (var item in container.GetAllFiles()) - { - ReadOnlySpan pathSpan = item.Key; - var lastTreeNode = rootSource; - - if (skipWemFiles) - { - var isWemFile = pathSpan.EndsWith(".wem", StringComparison.InvariantCultureIgnoreCase); - if (isWemFile) - continue; - } - - stackFileNames.Clear(); - var end = pathSpan.Length - 1; - while (end >= 0) - { - var index = pathSpan.Slice(0, end + 1).LastIndexOf(Path.DirectorySeparatorChar); - if (index == -1) - break; - - var subDirStringSpan = pathSpan.Slice(0, index); - var subDirString = subDirStringSpan.ToString(); + var rootSource = new TreeNodeSource(container.Name, NodeType.Root, container, null); + rootSource.SetChildLoader(node => LoadChildrenFromContainer(node, container, skipWemFiles)); - if (directoryMap_new.TryGetValue(subDirString, out var lookUpNode)) - { - lastTreeNode = lookUpNode; - break; - } - else - { - var subFolderIndex = subDirString.LastIndexOf(Path.DirectorySeparatorChar); - var fullPath = subDirString; - if (subFolderIndex != -1) - subDirString = subDirString.Substring(subFolderIndex + 1, subDirString.Length - 1 - subFolderIndex); - stackFileNames.Add((subDirString, fullPath)); - } + var root = CreateTreeNode(rootSource, null); + root.IsMainEditabelPack = _packFileService.GetEditablePack() == container; - // Move end position backward to continue search - end = index - 1; - } + _treeStates[container] = new PackFileTreeState(rootSource, root); + Files.Add(root); + Filter.Reapply(); + } - // Pop the stack and build the folder structure - for (int i = stackFileNames.Count - 1; i >= 0; i--) - { - var currentInstance = stackFileNames[i]; - var currentNode = new TreeNodeSource(currentInstance.FolderName, NodeType.Directory, container, lastTreeNode); + private bool LoadChildrenFromContainer(TreeNodeSource node, IPackFileContainer container, bool skipWemFiles) + { + var directoryPath = node.GetFullPath(); + var content = container.GetDirectoryContent(directoryPath); - lastTreeNode.AddChild(currentNode); - lastTreeNode = currentNode; + foreach (var folderName in content.SubFolders) + { + var childNode = new TreeNodeSource(folderName, NodeType.Directory, container, node); + childNode.SetChildLoader(n => LoadChildrenFromContainer(n, container, skipWemFiles)); + node.AddChild(childNode); + } - directoryMap_new.Add(currentInstance.FullFolderPath, currentNode); - } + foreach (var (fileName, file) in content.Files) + { + if (skipWemFiles && fileName.EndsWith(".wem", StringComparison.OrdinalIgnoreCase)) + continue; - // Add - var treeNode = new TreeNodeSource(item.Value.Name, NodeType.File, container, lastTreeNode, item.Value); - lastTreeNode.AddChild(treeNode); + var fileNode = new TreeNodeSource(fileName, NodeType.File, container, node, file); + node.AddChild(fileNode); } - SortSourceTree(rootSource); - var root = CreateTreeNode(rootSource, null); - root.IsMainEditabelPack = _packFileService.GetEditablePack() == container; - - _treeStates[container] = new PackFileTreeState(rootSource, root); - Files.Add(root); - Filter.Reapply(); + return true; } private void PackFileContainerRemoved(PackFileContainerRemovedEvent e) @@ -559,21 +542,16 @@ private TreeNode CreateTreeNode(TreeNodeSource source, TreeNode? parent) return StringComparer.CurrentCultureIgnoreCase.Compare(left.Name, right.Name); }; - private static void SortSourceTree(TreeNodeSource node) - { - node.Children.Sort(TreeNodeSourceComparison); - foreach (var child in node.Children) - SortSourceTree(child); - } - private static void InsertChildSorted(TreeNodeSource parent, TreeNodeSource child) { + parent.EnsureChildrenPopulated(); parent.AddChild(child); parent.Children.Sort(TreeNodeSourceComparison); } private static void RemoveExistingFileNode(TreeNodeSource parent, string fileName, PackFile packFile) { + parent.EnsureChildrenPopulated(); var existingFile = parent.Children.FirstOrDefault(node => node.NodeType == NodeType.File && (node.Item == packFile || node.Name == fileName)); if (existingFile == null) return; diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/SearchFilter.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/SearchFilter.cs index 9cbd1c619..31948142b 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/SearchFilter.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/SearchFilter.cs @@ -62,6 +62,14 @@ string Filter(string text) } var rootSources = _sourceRootsFactory().ToList(); + + // Force-populate all source children when an active filter requires full traversal + if (HasActiveFilter) + { + foreach (var item in rootSources) + item.EnsureFullyPopulated(); + } + foreach (var item in rootSources) HasChildWithFilterMatch(item, expression); diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNode.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNode.cs index d6442c014..bec939cc1 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNode.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNode.cs @@ -190,7 +190,9 @@ public TreeNode AddDirectoryChild(string name) return newNode; } + _source.EnsureChildrenPopulated(); var sourceNode = new TreeNodeSource(name, NodeType.Directory, FileOwner, _source); + sourceNode.MarkChildrenLoaded(); _source.AddChild(sourceNode); if (HasMaterializedChildren || IsNodeExpanded || (_isFilterActive?.Invoke() ?? false)) @@ -231,7 +233,12 @@ internal void RefreshLoadedBranch() internal void EnsureChildrenLoaded() { - if (_source == null || !_source.HasChildren || _nodeFactory == null) + if (_source == null || _nodeFactory == null) + return; + + _source.EnsureChildrenPopulated(); + + if (!_source.HasChildren) return; var existingChildren = Children diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNodeSource.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNodeSource.cs index e3ab15fc1..118ed45a4 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNodeSource.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNodeSource.cs @@ -8,9 +8,13 @@ namespace Shared.Ui.BaseDialogs.PackFileTree /// Represents the lightweight backing data model for the pack-file tree. /// It stores the canonical hierarchy and metadata independent of UI state, and may optionally /// reference a materialized when that branch has been created for display. + /// Children are loaded lazily from the container on first access of a directory/root node. /// internal sealed class TreeNodeSource { + private Func? _childLoader; + private bool _childrenLoaded; + public string Name { get; set; } public NodeType NodeType { get; } public IPackFileContainer FileOwner { get; } @@ -21,7 +25,15 @@ internal sealed class TreeNodeSource public bool UnsavedChanged { get; set; } public TreeNode? MaterializedNode { get; set; } - public bool HasChildren => Children.Count > 0; + /// + /// Returns true if this node has children (loaded or potentially unloaded). + /// + public bool HasChildren => Children.Count > 0 || (!_childrenLoaded && _childLoader != null && NodeType != NodeType.File); + + /// + /// Returns true if children have been loaded from the container. + /// + public bool ChildrenLoaded => _childrenLoaded; public TreeNodeSource(string name, NodeType nodeType, IPackFileContainer fileOwner, TreeNodeSource? parent, PackFile? item = null) { @@ -35,6 +47,44 @@ public TreeNodeSource(string name, NodeType nodeType, IPackFileContainer fileOwn throw new Exception($"Packfile name or folder is empty '{GetFullPath()}', this is not allowed! Please report as a bug if it happens outside of packfile loading! If it happens while loading clean up the packfile in RPFM"); } + /// + /// Sets the child loader delegate used for lazy population of children. + /// The delegate receives this node and returns true if children were loaded. + /// + public void SetChildLoader(Func childLoader) + { + _childLoader = childLoader; + } + + /// + /// Ensures children are populated. If not yet loaded and a child loader is set, loads them now. + /// + public void EnsureChildrenPopulated() + { + if (_childrenLoaded || _childLoader == null || NodeType == NodeType.File) + return; + + _childrenLoaded = _childLoader(this); + } + + /// + /// Recursively ensures all descendants are populated. + /// + public void EnsureFullyPopulated() + { + EnsureChildrenPopulated(); + foreach (var child in Children) + child.EnsureFullyPopulated(); + } + + /// + /// Marks children as already loaded (used when children are added externally). + /// + public void MarkChildrenLoaded() + { + _childrenLoaded = true; + } + public string GetFullPath() { if (NodeType == NodeType.Root) @@ -74,6 +124,7 @@ public IEnumerable EnumerateAllNodesDepthFirst() while (stack.Count > 0) { var current = stack.Pop(); + current.EnsureChildrenPopulated(); yield return current; for (var i = current.Children.Count - 1; i >= 0; i--) diff --git a/Shared/SharedUI/Shared.UiTest/PackFileBrowserViewModelTests.cs b/Shared/SharedUI/Shared.UiTest/PackFileBrowserViewModelTests.cs index a9b219224..6eab570f9 100644 --- a/Shared/SharedUI/Shared.UiTest/PackFileBrowserViewModelTests.cs +++ b/Shared/SharedUI/Shared.UiTest/PackFileBrowserViewModelTests.cs @@ -1,6 +1,31 @@ -using System.Windows.Input; +// PackFileBrowserViewModel Lazy Loading Architecture: +// +// The tree is built lazily. When a container is added (via PackFileContainerAddedEvent), only +// the root TreeNodeSource is created with a child-loader delegate. Children are NOT loaded from +// the container's GetDirectoryContent() until the node is expanded or explicitly queried. +// +// TreeNodeSource is the canonical backing model (lightweight, no UI state). +// TreeNode is the materialized WPF-bound node, created on demand when a branch is expanded. +// A placeholder child is used so the WPF TreeView shows the expand arrow for unloaded directories. +// +// Event-driven updates from PackFileService: +// - PackFileContainerAddedEvent → ReloadTree: creates root source + lazy child loader +// - PackFileContainerRemovedEvent → removes container's tree state and TreeNode from Files +// - PackFileContainerFilesAddedEvent → AddFiles: inserts nodes into loaded source branches, creates dirs as needed +// - PackFileContainerFilesRemovedEvent → removes source + materialized nodes for deleted files +// - PackFileContainerFilesUpdatedEvent → updates Name/UnsavedChanged on source nodes (for rename/save) +// - PackFileContainerFolderRemovedEvent → finds and removes folder source node and materialized subtree +// - PackFileContainerFolderRenamedEvent → finds folder by OLD path, renames leaf, marks unsaved +// - PackFileContainerSetAsMainEditableEvent → toggles IsMainEditabelPack on root TreeNodes +// - PackFileContainerSavedEvent → clears UnsavedChanged on loaded nodes only (avoids forcing full population) +// +// SearchFilter: when active, calls EnsureFullyPopulated() to load entire subtree for regex matching. +// When cleared, filter-expanded nodes are absorbed as user expansions and collapsed nodes are unloaded. + +using System.Windows.Input; using AssetEditor.ViewModels; using Microsoft.Extensions.DependencyInjection; +using Shared.Core.Events; using Shared.Core.PackFiles; using Shared.Core.PackFiles.Models; using Shared.Core.Settings; @@ -320,6 +345,293 @@ private void CreatePackfiles(params (string Path, string FileName)[] files) _packageFileService.AddContainer(container); } + + [Test] + public void RenameFolderUsingPfs_EnsureTreeViewUpdated() + { + CreatePackfiles(("foldera\\file.txt", "file.txt")); + var root = _viewModel.Files[0]; + root.IsNodeExpanded = true; + + var folderA = _viewModel.GetFromPath(root, "foldera"); + Assert.That(folderA, Is.Not.Null); + + var container = _packageFileService.GetAllPackfileContainers().Last(x => x.Name == "test.pack"); + _packageFileService.RenameDirectory(container, "foldera", "folderb"); + + // Old path gone, new path available + var oldNode = _viewModel.GetFromPath(root, "foldera"); + var newNode = _viewModel.GetFromPath(root, "folderb"); + + Assert.That(oldNode, Is.Null, "Old folder path should not exist after rename"); + Assert.That(newNode, Is.Not.Null, "Renamed folder should be accessible via the new name"); + Assert.That(newNode.UnsavedChanged, Is.True, "Renamed folder should be marked unsaved"); + } + + [Test] + public void RenameFolderUsingPfs_NestedFolder_EnsureTreeViewUpdated() + { + CreatePackfiles(("parent\\child\\file.txt", "file.txt")); + var root = _viewModel.Files[0]; + root.IsNodeExpanded = true; + + var parent = _viewModel.GetFromPath(root, "parent"); + Assert.That(parent, Is.Not.Null); + parent.IsNodeExpanded = true; + + var container = _packageFileService.GetAllPackfileContainers().Last(x => x.Name == "test.pack"); + _packageFileService.RenameDirectory(container, "parent\\child", "renamed"); + + var newNode = _viewModel.GetFromPath(root, "parent\\renamed"); + Assert.That(newNode, Is.Not.Null, "Nested renamed folder should be accessible"); + Assert.That(newNode.UnsavedChanged, Is.True); + } + + [Test] + public void DeleteFolderUsingPfs_EnsureTreeViewUpdated() + { + CreatePackfiles(("foldera\\file.txt", "file.txt"), ("folderb\\other.txt", "other.txt")); + var root = _viewModel.Files[0]; + root.IsNodeExpanded = true; + + var folderA = _viewModel.GetFromPath(root, "foldera"); + Assert.That(folderA, Is.Not.Null); + + var container = _packageFileService.GetAllPackfileContainers().Last(x => x.Name == "test.pack"); + _packageFileService.DeleteFolder(container, "foldera"); + + var deletedNode = _viewModel.GetFromPath(root, "foldera"); + Assert.That(deletedNode, Is.Null, "Deleted folder should not exist in tree"); + + var survivingNode = _viewModel.GetFromPath(root, "folderb"); + Assert.That(survivingNode, Is.Not.Null, "Non-deleted folder should still exist"); + } + + [Test] + public void UnloadPackContainer_RemovesFromTree() + { + CreatePackfiles(("foldera\\file.txt", "file.txt")); + Assert.That(_viewModel.Files.Count, Is.EqualTo(1)); + + var container = _packageFileService.GetAllPackfileContainers().Last(x => x.Name == "test.pack"); + _packageFileService.UnloadPackContainer(container); + + Assert.That(_viewModel.Files.Count, Is.EqualTo(0)); + } + + [Test] + public void AddContainer_AppearsInTree() + { + Assert.That(_viewModel.Files.Count, Is.EqualTo(0)); + + var container = _packageFileService.CreateNewPackFileContainer("new.pack", PackFileVersion.PFH5, PackFileCAType.MOD); + _packageFileService.AddContainer(container); + + Assert.That(_viewModel.Files.Count, Is.EqualTo(1)); + Assert.That(_viewModel.Files[0].Name, Is.EqualTo("new.pack")); + Assert.That(_viewModel.Files[0].NodeType, Is.EqualTo(NodeType.Root)); + } + + [Test] + public void MainEditablePackChanged_UpdatesRootNode() + { + var container1 = _packageFileService.CreateNewPackFileContainer("pack1.pack", PackFileVersion.PFH5, PackFileCAType.MOD, true); + _packageFileService.AddContainer(container1); + var container2 = _packageFileService.CreateNewPackFileContainer("pack2.pack", PackFileVersion.PFH5, PackFileCAType.MOD); + _packageFileService.AddContainer(container2); + + var root1 = _viewModel.Files.First(x => x.Name == "pack1.pack"); + var root2 = _viewModel.Files.First(x => x.Name == "pack2.pack"); + + Assert.That(root1.IsMainEditabelPack, Is.True); + Assert.That(root2.IsMainEditabelPack, Is.False); + + _packageFileService.SetEditablePack(container2); + + Assert.That(root1.IsMainEditabelPack, Is.False); + Assert.That(root2.IsMainEditabelPack, Is.True); + } + + [Test] + public void ContainerSaved_ClearsUnsavedFlags() + { + CreatePackfiles(("foldera\\file.txt", "file.txt")); + var root = _viewModel.Files[0]; + var container = _packageFileService.GetAllPackfileContainers().Last(x => x.Name == "test.pack"); + + // Add a file to mark things as unsaved + var newFile = PackFile.CreateFromASCII("new.txt", "data"); + _packageFileService.AddFilesToPack(container, [new NewPackFileEntry("foldera", newFile)]); + + root.IsNodeExpanded = true; + var folderA = _viewModel.GetFromPath(root, "foldera"); + Assert.That(folderA, Is.Not.Null); + Assert.That(root.UnsavedChanged, Is.True, "Root should be unsaved after adding file"); + + // Simulate save via PFS — we can't call SavePackContainer without disk I/O, + // so trigger the event directly through the event hub + var eventHub = _runner.ServiceProvider.GetRequiredService(); + eventHub.PublishGlobalEvent(new Shared.Core.Events.Global.PackFileContainerSavedEvent(container)); + + Assert.That(root.UnsavedChanged, Is.False, "Root should be cleared after save"); + } + + [Test] + public void ContainerSaved_DoesNotForcePopulationOfUnloadedBranches() + { + CreatePackfiles(("foldera\\sub\\deep\\file.txt", "file.txt")); + var root = _viewModel.Files[0]; + var container = _packageFileService.GetAllPackfileContainers().Last(x => x.Name == "test.pack"); + + // Root is collapsed — only root exists as materialized node + Assert.That(root.IsNodeExpanded, Is.False); + + var eventHub = _runner.ServiceProvider.GetRequiredService(); + eventHub.PublishGlobalEvent(new Shared.Core.Events.Global.PackFileContainerSavedEvent(container)); + + // After save, the tree should NOT have expanded/materialized children + Assert.That(root.Children.All(c => c.Name == "" || c.NodeType == NodeType.Root), + Is.True, "Save should not force population of collapsed branches"); + } + + [Test] + public void AddFileToRootLevel_EnsureTreeViewUpdated() + { + var container = _packageFileService.CreateNewPackFileContainer("test.pack", PackFileVersion.PFH5, PackFileCAType.MOD, true); + _packageFileService.AddContainer(container); + + var newFile = PackFile.CreateFromASCII("rootfile.txt", "content"); + _packageFileService.AddFilesToPack(container, [new NewPackFileEntry("", newFile)]); + + var root = _viewModel.Files.First(x => x.Name == "test.pack"); + root.IsNodeExpanded = true; + + var fileNode = _viewModel.GetFromPath(root, "rootfile.txt"); + Assert.That(fileNode, Is.Not.Null, "File added at root level should appear in tree"); + Assert.That(fileNode.NodeType, Is.EqualTo(NodeType.File)); + } + + [Test] + public void DoubleClick_File_InvokesFileOpenEvent() + { + CreatePackfiles(("foldera\\file.txt", "file.txt")); + var root = _viewModel.Files[0]; + root.IsNodeExpanded = true; + + var folderA = _viewModel.GetFromPath(root, "foldera"); + folderA.IsNodeExpanded = true; + + var fileNode = _viewModel.GetFromPath(root, "foldera\\file.txt"); + Assert.That(fileNode, Is.Not.Null); + + PackFile? openedFile = null; + _viewModel.FileOpen += f => openedFile = f; + + _viewModel.DoubleClickCommand.Execute(fileNode); + + Assert.That(openedFile, Is.Not.Null, "FileOpen should be invoked on double-click"); + Assert.That(openedFile.Name, Is.EqualTo("file.txt")); + } + + [Test] + public void DoubleClick_Directory_TogglesExpansion() + { + CreatePackfiles(("foldera\\file.txt", "file.txt")); + var root = _viewModel.Files[0]; + root.IsNodeExpanded = true; + + var folderA = _viewModel.GetFromPath(root, "foldera"); + Assert.That(folderA, Is.Not.Null); + Assert.That(folderA.IsNodeExpanded, Is.False); + + _viewModel.DoubleClickCommand.Execute(folderA); + Assert.That(folderA.IsNodeExpanded, Is.True); + + _viewModel.DoubleClickCommand.Execute(folderA); + Assert.That(folderA.IsNodeExpanded, Is.False); + } + + [Test] + public void MoveFileUsingPfs_EnsureTreeViewUpdated() + { + CreatePackfiles(("source\\file.txt", "file.txt"), ("target\\other.txt", "other.txt")); + var root = _viewModel.Files[0]; + root.IsNodeExpanded = true; + var container = _packageFileService.GetAllPackfileContainers().Last(x => x.Name == "test.pack"); + + var sourceFolder = _viewModel.GetFromPath(root, "source"); + Assert.That(sourceFolder, Is.Not.Null); + sourceFolder.IsNodeExpanded = true; + + var fileNode = _viewModel.GetFromPath(root, "source\\file.txt"); + Assert.That(fileNode, Is.Not.Null); + + var file = _packageFileService.FindFile("source\\file.txt", container); + _packageFileService.MoveFile(container, file, "target"); + + // Old location gone + var oldNode = _viewModel.GetFromPath(root, "source\\file.txt"); + Assert.That(oldNode, Is.Null, "File should not be in old folder after move"); + + // New location has the file + var targetFolder = _viewModel.GetFromPath(root, "target"); + targetFolder.IsNodeExpanded = true; + var newNode = _viewModel.GetFromPath(root, "target\\file.txt"); + Assert.That(newNode, Is.Not.Null, "File should appear in target folder after move"); + } + + [Test] + public void DragDrop_FileOntoFile_NotAllowed() + { + CreatePackfiles(("foldera\\file1.txt", "file1.txt"), ("foldera\\file2.txt", "file2.txt")); + var root = _viewModel.Files[0]; + root.IsNodeExpanded = true; + + var folderA = _viewModel.GetFromPath(root, "foldera"); + folderA.IsNodeExpanded = true; + + var file1 = _viewModel.GetFromPath(root, "foldera\\file1.txt"); + var file2 = _viewModel.GetFromPath(root, "foldera\\file2.txt"); + + Assert.That(_viewModel.AllowDrop(file1, file2), Is.False, "Should not allow dropping file onto file"); + } + + [Test] + public void DragDrop_FolderDrag_NotAllowed() + { + CreatePackfiles(("foldera\\file.txt", "file.txt"), ("folderb\\file2.txt", "file2.txt")); + var root = _viewModel.Files[0]; + root.IsNodeExpanded = true; + + var folderA = _viewModel.GetFromPath(root, "foldera"); + var folderB = _viewModel.GetFromPath(root, "folderb"); + + Assert.That(_viewModel.AllowDrop(folderA, folderB), Is.False, "Should not allow dragging folders"); + } + + [Test] + public void ShowFoldersOnly_HidesFiles() + { + CreatePackfiles(("foldera\\file.txt", "file.txt")); + var root = _viewModel.Files[0]; + + _viewModel.Filter.ShowFoldersOnly = true; + + root.IsNodeExpanded = true; + var folderA = _viewModel.GetFromPath(root, "foldera"); + Assert.That(folderA, Is.Not.Null, "Folder should be visible with ShowFoldersOnly"); + Assert.That(folderA.IsVisible, Is.True); + + folderA.IsNodeExpanded = true; + + // File nodes are not materialized when ShowFoldersOnly is active + var fileNode = _viewModel.GetFromPath(root, "foldera\\file.txt"); + Assert.That(fileNode, Is.Null, "File should not be materialized with ShowFoldersOnly active"); + + // Only folder children should be present + Assert.That(folderA.Children.All(c => c.NodeType != NodeType.File || !c.IsVisible), Is.True, + "No visible file nodes should exist under folder with ShowFoldersOnly"); + } } } diff --git a/Testing/AssetEditorTests/AssetEditorTests.csproj b/Testing/AssetEditorTests/AssetEditorTests.csproj index eb54ae594..6ea9e6c44 100644 --- a/Testing/AssetEditorTests/AssetEditorTests.csproj +++ b/Testing/AssetEditorTests/AssetEditorTests.csproj @@ -13,7 +13,7 @@ - + diff --git a/Testing/Shared/TestUtility/PackFileSerivceTestHelper.cs b/Testing/Shared/TestUtility/PackFileSerivceTestHelper.cs index 9c87192c7..cf77e6172 100644 --- a/Testing/Shared/TestUtility/PackFileSerivceTestHelper.cs +++ b/Testing/Shared/TestUtility/PackFileSerivceTestHelper.cs @@ -1,5 +1,7 @@ -using Shared.Core.PackFiles; +using Moq; +using Shared.Core.PackFiles; using Shared.Core.PackFiles.Utility; +using Shared.Core.Services; using Shared.Core.Settings; using Test.TestingUtility.TestUtility; @@ -10,7 +12,7 @@ public static class PackFileSerivceTestHelper public static IPackFileService Create(string path, GameTypeEnum gameTypeEnum = GameTypeEnum.Warhammer3) { var pfs = new PackFileService(null); - var loader = new PackFileContainerLoader(new ApplicationSettingsService(gameTypeEnum)); + var loader = new PackFileContainerLoader(new ApplicationSettingsService(gameTypeEnum), new Mock().Object, new LocalizationManager()); var container = loader.LoadSystemFolderAsPackFileContainer(path); container.IsCaPackFile = true; pfs.AddContainer(container); @@ -21,7 +23,7 @@ public static IPackFileService Create(string path, GameTypeEnum gameTypeEnum = G public static IPackFileService CreateFromFolder(GameTypeEnum selectedGame, string path ) { var pfs = new PackFileService(null); - var loader = new PackFileContainerLoader(new ApplicationSettingsService(selectedGame)); + var loader = new PackFileContainerLoader(new ApplicationSettingsService(selectedGame), new Mock().Object, new LocalizationManager()); var container = loader.LoadSystemFolderAsPackFileContainer(PathHelper.GetDataFolder(path)); container.IsCaPackFile = true;