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!
-
-
-
-
-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
+
+
+# The Asset Editor
+
+[](https://github.com/donkeyProgramming/TheAssetEditor/actions/workflows/pr-test.yml)
+[](https://github.com/donkeyProgramming/TheAssetEditor/releases/latest)
+[](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;