diff --git a/BannerlordModEditor.Common.Tests/Models/SubModule/SubModuleLoaderTests.cs b/BannerlordModEditor.Common.Tests/Models/SubModule/SubModuleLoaderTests.cs
new file mode 100644
index 0000000..de6379c
--- /dev/null
+++ b/BannerlordModEditor.Common.Tests/Models/SubModule/SubModuleLoaderTests.cs
@@ -0,0 +1,176 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Xunit;
+using BannerlordModEditor.Common.Models.SubModule;
+
+namespace BannerlordModEditor.Common.Tests.Models.SubModule
+{
+ public class SubModuleLoaderTests
+ {
+ private readonly SubModuleLoader _loader;
+
+ public SubModuleLoaderTests()
+ {
+ _loader = new SubModuleLoader();
+ }
+
+ [Fact]
+ public void Load_ReturnsNullForNonExistentFile()
+ {
+ var result = _loader.Load("/nonexistent/path/SubModule.xml");
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task LoadAsync_ReturnsNullForNonExistentFile()
+ {
+ var result = await _loader.LoadAsync("/nonexistent/path/SubModule.xml");
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Load_ReadsValidSubModuleXml()
+ {
+ var tempFile = Path.GetTempFileName();
+ var xml = @"
+
+ Test Mod
+ TestMod
+ v1.0.0
+ true
+ false
+
+
+
+
+
+ TestSubModule
+ TestMod.dll
+ TestMod.SubModule
+ false
+ true
+
+
+
+
+
+";
+
+ try
+ {
+ File.WriteAllText(tempFile, xml);
+
+ var result = _loader.Load(tempFile);
+
+ Assert.NotNull(result);
+ Assert.Equal("Test Mod", result.Name);
+ Assert.Equal("TestMod", result.Id);
+ Assert.Equal("v1.0.0", result.Version);
+ Assert.True(result.SingleplayerModule);
+ Assert.False(result.MultiplayerModule);
+ Assert.Single(result.DependedModules);
+ Assert.Equal("Native", result.DependedModules[0].Id);
+ Assert.Single(result.SubModules);
+ Assert.Equal("TestSubModule", result.SubModules[0].Name);
+ Assert.Equal("TestMod.dll", result.SubModules[0].DLLName);
+ Assert.Single(result.SubModules[0].Tags);
+ Assert.Equal("DedicatedServerType", result.SubModules[0].Tags[0].Key);
+ Assert.Equal("none", result.SubModules[0].Tags[0].Value);
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ }
+ }
+
+ [Fact]
+ public void Save_CreatesValidXml()
+ {
+ var tempFile = Path.GetTempFileName();
+ var data = new SubModuleDO
+ {
+ Name = "Save Test Mod",
+ Id = "SaveTestMod",
+ Version = "v2.0.0",
+ SingleplayerModule = true,
+ MultiplayerModule = false,
+ DependedModules = new()
+ {
+ new DependedModuleDO { Id = "Native" },
+ new DependedModuleDO { Id = "Sandbox" }
+ },
+ SubModules = new()
+ {
+ new SubModuleItemDO
+ {
+ Name = "SaveTestSubModule",
+ DLLName = "SaveTestMod.dll",
+ SubModuleClassType = "SaveTestMod.SubModule",
+ IsOptional = false,
+ IsTicked = true,
+ Tags = new()
+ {
+ new SubModuleTagDO { Key = "DedicatedServerType", Value = "none" }
+ }
+ }
+ }
+ };
+
+ try
+ {
+ _loader.Save(data, tempFile);
+
+ Assert.True(File.Exists(tempFile));
+ var loadedData = _loader.Load(tempFile);
+ Assert.NotNull(loadedData);
+ Assert.Equal("Save Test Mod", loadedData.Name);
+ Assert.Equal("SaveTestMod", loadedData.Id);
+ Assert.Equal(2, loadedData.DependedModules.Count);
+ Assert.Single(loadedData.SubModules);
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ }
+ }
+
+ [Fact]
+ public async Task SaveAsync_CreatesValidXml()
+ {
+ var tempFile = Path.GetTempFileName();
+ var data = _loader.CreateDefault("Async Test Mod", "AsyncTestMod");
+
+ try
+ {
+ await _loader.SaveAsync(data, tempFile);
+
+ Assert.True(File.Exists(tempFile));
+ var loadedData = await _loader.LoadAsync(tempFile);
+ Assert.NotNull(loadedData);
+ Assert.Equal("Async Test Mod", loadedData.Name);
+ Assert.Equal("AsyncTestMod", loadedData.Id);
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ }
+ }
+
+ [Fact]
+ public void CreateDefault_CreatesValidDefault()
+ {
+ var result = _loader.CreateDefault("Default Test", "DefaultTest");
+
+ Assert.Equal("Default Test", result.Name);
+ Assert.Equal("DefaultTest", result.Id);
+ Assert.Equal("v1.0.0", result.Version);
+ Assert.True(result.SingleplayerModule);
+ Assert.False(result.MultiplayerModule);
+ Assert.Empty(result.DependedModules);
+ Assert.Empty(result.SubModules);
+ Assert.Empty(result.Xmls);
+ Assert.Empty(result.OptionalDependedModules);
+ }
+ }
+}
diff --git a/BannerlordModEditor.Common/Models/SubModule/SubModuleDO.cs b/BannerlordModEditor.Common/Models/SubModule/SubModuleDO.cs
new file mode 100644
index 0000000..d421c35
--- /dev/null
+++ b/BannerlordModEditor.Common/Models/SubModule/SubModuleDO.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+using System.Xml.Serialization;
+
+namespace BannerlordModEditor.Common.Models.SubModule
+{
+ [XmlRoot("Module")]
+ public class SubModuleDO
+ {
+ [XmlElement("Name")]
+ public string Name { get; set; } = string.Empty;
+
+ [XmlElement("Id")]
+ public string Id { get; set; } = string.Empty;
+
+ [XmlElement("Version")]
+ public string Version { get; set; } = string.Empty;
+
+ [XmlElement("SingleplayerModule")]
+ public bool SingleplayerModule { get; set; } = true;
+
+ [XmlElement("MultiplayerModule")]
+ public bool MultiplayerModule { get; set; }
+
+ [XmlArray("DependedModules")]
+ [XmlArrayItem("Module")]
+ public List DependedModules { get; set; } = new();
+
+ [XmlArray("SubModules")]
+ [XmlArrayItem("SubModule")]
+ public List SubModules { get; set; } = new();
+
+ [XmlArray("Xmls")]
+ [XmlArrayItem("XmlNode")]
+ public List Xmls { get; set; } = new();
+
+ [XmlArray("OptionalDependedModules")]
+ [XmlArrayItem("Module")]
+ public List OptionalDependedModules { get; set; } = new();
+ }
+
+ public class DependedModuleDO
+ {
+ [XmlAttribute("Id")]
+ public string Id { get; set; } = string.Empty;
+ }
+
+ public class OptionalDependedModuleDO
+ {
+ [XmlAttribute("Id")]
+ public string Id { get; set; } = string.Empty;
+ }
+
+ public class SubModuleItemDO
+ {
+ [XmlElement("Name")]
+ public string Name { get; set; } = string.Empty;
+
+ [XmlElement("DLLName")]
+ public string DLLName { get; set; } = string.Empty;
+
+ [XmlElement("SubModuleClassType")]
+ public string SubModuleClassType { get; set; } = string.Empty;
+
+ [XmlElement("IsOptional")]
+ public bool IsOptional { get; set; }
+
+ [XmlElement("IsTicked")]
+ public bool IsTicked { get; set; } = true;
+
+ [XmlArray("Tags")]
+ [XmlArrayItem("Tag")]
+ public List Tags { get; set; } = new();
+ }
+
+ public class SubModuleTagDO
+ {
+ [XmlAttribute("Key")]
+ public string Key { get; set; } = string.Empty;
+
+ [XmlAttribute("Value")]
+ public string Value { get; set; } = string.Empty;
+ }
+
+ public class XmlNodeDO
+ {
+ [XmlAttribute("Id")]
+ public string Id { get; set; } = string.Empty;
+
+ [XmlAttribute("Type")]
+ public string Type { get; set; } = string.Empty;
+
+ [XmlText]
+ public string Path { get; set; } = string.Empty;
+ }
+}
diff --git a/BannerlordModEditor.Common/Models/SubModule/SubModuleDTO.cs b/BannerlordModEditor.Common/Models/SubModule/SubModuleDTO.cs
new file mode 100644
index 0000000..268e4b9
--- /dev/null
+++ b/BannerlordModEditor.Common/Models/SubModule/SubModuleDTO.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+using System.Xml.Serialization;
+
+namespace BannerlordModEditor.Common.Models.SubModule
+{
+ [XmlRoot("Module")]
+ public class SubModuleDTO
+ {
+ [XmlElement("Name")]
+ public string Name { get; set; } = string.Empty;
+
+ [XmlElement("Id")]
+ public string Id { get; set; } = string.Empty;
+
+ [XmlElement("Version")]
+ public string Version { get; set; } = string.Empty;
+
+ [XmlElement("SingleplayerModule")]
+ public bool SingleplayerModule { get; set; } = true;
+
+ [XmlElement("MultiplayerModule")]
+ public bool MultiplayerModule { get; set; }
+
+ [XmlArray("DependedModules")]
+ [XmlArrayItem("Module")]
+ public List DependedModules { get; set; } = new();
+
+ [XmlArray("SubModules")]
+ [XmlArrayItem("SubModule")]
+ public List SubModules { get; set; } = new();
+
+ [XmlArray("Xmls")]
+ [XmlArrayItem("XmlNode")]
+ public List Xmls { get; set; } = new();
+
+ [XmlArray("OptionalDependedModules")]
+ [XmlArrayItem("Module")]
+ public List OptionalDependedModules { get; set; } = new();
+ }
+
+ public class DependedModuleDTO
+ {
+ [XmlAttribute("Id")]
+ public string Id { get; set; } = string.Empty;
+ }
+
+ public class OptionalDependedModuleDTO
+ {
+ [XmlAttribute("Id")]
+ public string Id { get; set; } = string.Empty;
+ }
+
+ public class SubModuleItemDTO
+ {
+ [XmlElement("Name")]
+ public string Name { get; set; } = string.Empty;
+
+ [XmlElement("DLLName")]
+ public string DLLName { get; set; } = string.Empty;
+
+ [XmlElement("SubModuleClassType")]
+ public string SubModuleClassType { get; set; } = string.Empty;
+
+ [XmlElement("IsOptional")]
+ public bool IsOptional { get; set; }
+
+ [XmlElement("IsTicked")]
+ public bool IsTicked { get; set; } = true;
+
+ [XmlArray("Tags")]
+ [XmlArrayItem("Tag")]
+ public List Tags { get; set; } = new();
+ }
+
+ public class SubModuleTagDTO
+ {
+ [XmlAttribute("Key")]
+ public string Key { get; set; } = string.Empty;
+
+ [XmlAttribute("Value")]
+ public string Value { get; set; } = string.Empty;
+ }
+
+ public class XmlNodeDTO
+ {
+ [XmlAttribute("Id")]
+ public string Id { get; set; } = string.Empty;
+
+ [XmlAttribute("Type")]
+ public string Type { get; set; } = string.Empty;
+
+ [XmlText]
+ public string Path { get; set; } = string.Empty;
+ }
+}
diff --git a/BannerlordModEditor.Common/Models/SubModule/SubModuleLoader.cs b/BannerlordModEditor.Common/Models/SubModule/SubModuleLoader.cs
new file mode 100644
index 0000000..95d1af1
--- /dev/null
+++ b/BannerlordModEditor.Common/Models/SubModule/SubModuleLoader.cs
@@ -0,0 +1,107 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using System.Xml;
+using System.Xml.Serialization;
+
+namespace BannerlordModEditor.Common.Models.SubModule
+{
+ public interface ISubModuleLoader
+ {
+ Task LoadAsync(string filePath);
+ SubModuleDO? Load(string filePath);
+ Task SaveAsync(SubModuleDO data, string filePath);
+ void Save(SubModuleDO data, string filePath);
+ SubModuleDO CreateDefault(string modName, string modId);
+ }
+
+ public class SubModuleLoader : ISubModuleLoader
+ {
+ private readonly XmlSerializer _serializer;
+
+ public SubModuleLoader()
+ {
+ _serializer = new XmlSerializer(typeof(SubModuleDTO));
+ }
+
+ public async Task LoadAsync(string filePath)
+ {
+ return await Task.Run(() => Load(filePath));
+ }
+
+ public SubModuleDO? Load(string filePath)
+ {
+ if (!File.Exists(filePath))
+ return null;
+
+ try
+ {
+ using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
+ var dto = (SubModuleDTO?)_serializer.Deserialize(stream);
+ return dto != null ? SubModuleMapper.ToDO(dto) : null;
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to load SubModule.xml from {filePath}: {ex.Message}", ex);
+ }
+ }
+
+ public async Task SaveAsync(SubModuleDO data, string filePath)
+ {
+ await Task.Run(() => Save(data, filePath));
+ }
+
+ public void Save(SubModuleDO data, string filePath)
+ {
+ if (data == null)
+ throw new ArgumentNullException(nameof(data));
+
+ try
+ {
+ var dto = SubModuleMapper.ToDTO(data);
+ var directory = Path.GetDirectoryName(filePath);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ var settings = new XmlWriterSettings
+ {
+ Indent = true,
+ IndentChars = " ",
+ NewLineChars = "\n",
+ OmitXmlDeclaration = false,
+ Encoding = new System.Text.UTF8Encoding(false)
+ };
+
+ using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write);
+ using var writer = XmlWriter.Create(stream, settings);
+
+ var namespaces = new XmlSerializerNamespaces();
+ namespaces.Add("", "");
+
+ _serializer.Serialize(writer, dto, namespaces);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to save SubModule.xml to {filePath}: {ex.Message}", ex);
+ }
+ }
+
+ public SubModuleDO CreateDefault(string modName, string modId)
+ {
+ return new SubModuleDO
+ {
+ Name = modName,
+ Id = modId,
+ Version = "v1.0.0",
+ SingleplayerModule = true,
+ MultiplayerModule = false,
+ DependedModules = new(),
+ SubModules = new(),
+ Xmls = new(),
+ OptionalDependedModules = new()
+ };
+ }
+ }
+}
diff --git a/BannerlordModEditor.Common/Models/SubModule/SubModuleMapper.cs b/BannerlordModEditor.Common/Models/SubModule/SubModuleMapper.cs
new file mode 100644
index 0000000..840695e
--- /dev/null
+++ b/BannerlordModEditor.Common/Models/SubModule/SubModuleMapper.cs
@@ -0,0 +1,71 @@
+using System.Linq;
+
+namespace BannerlordModEditor.Common.Models.SubModule
+{
+ public static class SubModuleMapper
+ {
+ public static SubModuleDTO ToDTO(SubModuleDO source)
+ {
+ if (source == null)
+ return new SubModuleDTO();
+
+ return new SubModuleDTO
+ {
+ Name = source.Name,
+ Id = source.Id,
+ Version = source.Version,
+ SingleplayerModule = source.SingleplayerModule,
+ MultiplayerModule = source.MultiplayerModule,
+ DependedModules = source.DependedModules?.Select(d => new DependedModuleDTO { Id = d.Id }).ToList() ?? new(),
+ SubModules = source.SubModules?.Select(s => ToSubModuleItemDTO(s)).ToList() ?? new(),
+ Xmls = source.Xmls?.Select(x => new XmlNodeDTO { Id = x.Id, Type = x.Type, Path = x.Path }).ToList() ?? new(),
+ OptionalDependedModules = source.OptionalDependedModules?.Select(o => new OptionalDependedModuleDTO { Id = o.Id }).ToList() ?? new()
+ };
+ }
+
+ public static SubModuleDO ToDO(SubModuleDTO source)
+ {
+ if (source == null)
+ return new SubModuleDO();
+
+ return new SubModuleDO
+ {
+ Name = source.Name,
+ Id = source.Id,
+ Version = source.Version,
+ SingleplayerModule = source.SingleplayerModule,
+ MultiplayerModule = source.MultiplayerModule,
+ DependedModules = source.DependedModules?.Select(d => new DependedModuleDO { Id = d.Id }).ToList() ?? new(),
+ SubModules = source.SubModules?.Select(s => ToSubModuleItemDO(s)).ToList() ?? new(),
+ Xmls = source.Xmls?.Select(x => new XmlNodeDO { Id = x.Id, Type = x.Type, Path = x.Path }).ToList() ?? new(),
+ OptionalDependedModules = source.OptionalDependedModules?.Select(o => new OptionalDependedModuleDO { Id = o.Id }).ToList() ?? new()
+ };
+ }
+
+ private static SubModuleItemDTO ToSubModuleItemDTO(SubModuleItemDO source)
+ {
+ return new SubModuleItemDTO
+ {
+ Name = source.Name,
+ DLLName = source.DLLName,
+ SubModuleClassType = source.SubModuleClassType,
+ IsOptional = source.IsOptional,
+ IsTicked = source.IsTicked,
+ Tags = source.Tags?.Select(t => new SubModuleTagDTO { Key = t.Key, Value = t.Value }).ToList() ?? new()
+ };
+ }
+
+ private static SubModuleItemDO ToSubModuleItemDO(SubModuleItemDTO source)
+ {
+ return new SubModuleItemDO
+ {
+ Name = source.Name,
+ DLLName = source.DLLName,
+ SubModuleClassType = source.SubModuleClassType,
+ IsOptional = source.IsOptional,
+ IsTicked = source.IsTicked,
+ Tags = source.Tags?.Select(t => new SubModuleTagDO { Key = t.Key, Value = t.Value }).ToList() ?? new()
+ };
+ }
+ }
+}