diff --git a/Directory.Packages.props b/Directory.Packages.props
index ae23f6930c..f8c1e87082 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -8,6 +8,9 @@
+
+
+
diff --git a/NexusMods.App.sln b/NexusMods.App.sln
index 070efeea92..bd64cb550a 100644
--- a/NexusMods.App.sln
+++ b/NexusMods.App.sln
@@ -111,6 +111,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Common.Tests", "tests\NexusMods.Common.Tests\NexusMods.Common.Tests.csproj", "{FE0B804A-949E-44E7-9531-B16664ACEC01}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Games.MountAndBlade2Bannerlord", "src\Games\NexusMods.Games.MountAndBlade2Bannerlord\NexusMods.Games.MountAndBlade2Bannerlord.csproj", "{3E970563-DAE0-4168-AE8D-AB09A786C8A3}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Games.MountAndBlade2Bannerlord.Tests", "tests\Games\NexusMods.Games.MountAndBlade2Bannerlord.Tests\NexusMods.Games.MountAndBlade2Bannerlord.Tests.csproj", "{355C8D44-F46F-4AA2-96C0-DDB6844D8BEA}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Networking.NexusWebApi.NMA", "src\Networking\NexusMods.Networking.NexusWebApi.NMA\NexusMods.Networking.NexusWebApi.NMA.csproj", "{871E2565-BD95-43D1-9EC3-CAAC74D55507}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Networking.Downloaders", "src\Networking\NexusMods.Networking.Downloaders\NexusMods.Networking.Downloaders.csproj", "{3FBDEE15-9892-40EF-9593-6353068FAF48}"
@@ -285,6 +289,14 @@ Global
{FE0B804A-949E-44E7-9531-B16664ACEC01}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FE0B804A-949E-44E7-9531-B16664ACEC01}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FE0B804A-949E-44E7-9531-B16664ACEC01}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3E970563-DAE0-4168-AE8D-AB09A786C8A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3E970563-DAE0-4168-AE8D-AB09A786C8A3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3E970563-DAE0-4168-AE8D-AB09A786C8A3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3E970563-DAE0-4168-AE8D-AB09A786C8A3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {355C8D44-F46F-4AA2-96C0-DDB6844D8BEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {355C8D44-F46F-4AA2-96C0-DDB6844D8BEA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {355C8D44-F46F-4AA2-96C0-DDB6844D8BEA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {355C8D44-F46F-4AA2-96C0-DDB6844D8BEA}.Release|Any CPU.Build.0 = Release|Any CPU
{871E2565-BD95-43D1-9EC3-CAAC74D55507}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{871E2565-BD95-43D1-9EC3-CAAC74D55507}.Debug|Any CPU.Build.0 = Debug|Any CPU
{871E2565-BD95-43D1-9EC3-CAAC74D55507}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -372,6 +384,8 @@ Global
{83B2A024-0218-4F65-B75B-0102DAF38443} = {02A589BE-50CA-4D29-BA99-81EEA2410F8D}
{CB61A764-B3BB-42C0-8CDB-DBE57FB80DF5} = {CF7454A5-0EBB-46E7-9A10-614380DB95D9}
{FE0B804A-949E-44E7-9531-B16664ACEC01} = {52AF9D62-7D5B-4AD0-BA12-86F2AA67428B}
+ {3E970563-DAE0-4168-AE8D-AB09A786C8A3} = {70D38D24-79AE-4600-8E83-17F3C11BA81F}
+ {355C8D44-F46F-4AA2-96C0-DDB6844D8BEA} = {05B06AC1-7F2B-492F-983E-5BC63CDBF20D}
{871E2565-BD95-43D1-9EC3-CAAC74D55507} = {D7E9D8F5-8AC8-4ADA-B219-C549084AD84C}
{3FBDEE15-9892-40EF-9593-6353068FAF48} = {D7E9D8F5-8AC8-4ADA-B219-C549084AD84C}
{09B037AB-07BB-4154-95FD-6EA2E55C4568} = {897C4198-884F-448A-B0B0-C2A6D971EAE0}
diff --git a/docs/games/000X-Bannerlord.md b/docs/games/000X-Bannerlord.md
new file mode 100644
index 0000000000..24baabc46d
--- /dev/null
+++ b/docs/games/000X-Bannerlord.md
@@ -0,0 +1,33 @@
+## General Info
+
+- Name: Mount & Blade II: Bannerlord
+- Release Date: 2020
+- Engine: Custom - C++ Foundation, C# Scripting
+
+### Stores and Ids
+
+- [Steam](https://store.steampowered.com/app/261550/Mount__Blade_II_Bannerlord/): `261550`
+- [GOG](https://www.gog.com/game/mount_blade_ii_bannerlord): `1802539526`, `1564781494`
+- [Epic Game Store](https://store.epicgames.com/en-US/p/mount-and-blade-2): `Chickadee`
+- [Xbox Game Pass](https://www.xbox.com/en-US/games/store/mount-blade-ii-bannerlord/9pdhwz7x3p03): `TaleWorldsEntertainment.MountBladeIIBannerlord`
+
+### Engine and Mod Support
+
+Bannerlord uses .NET Framework 4.7.2 for Steam/GOG/Epic and .NET Core 3.1 for Xbox game Pass PC.
+Modding is supported out of the box.
+Bannerlord has a modding extension [BLSE](https://www.nexusmods.com/mountandblade2bannerlord/mods/1) that expands the modding capabilities.
+It's required to run mods on Xbox and is optional for Steam/GOG/Epic.
+
+## Overview of Mod loading process(es)
+
+## Uploaded Files Structure
+
+## Additional Considerations for Manager
+
+## Essential Mods & Tools
+
+## Deployment Strategy
+
+## Work To Do
+
+## Misc Notes
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/.editorconfig b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/.editorconfig
new file mode 100644
index 0000000000..ece3986bd3
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/.editorconfig
@@ -0,0 +1,4 @@
+[*.cs]
+max_line_length = 180
+
+space_within_single_line_array_initializer_braces = true
\ No newline at end of file
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Emitters/BuiltInEmitter.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Emitters/BuiltInEmitter.cs
new file mode 100644
index 0000000000..df0327ce42
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Emitters/BuiltInEmitter.cs
@@ -0,0 +1,103 @@
+using Bannerlord.LauncherManager;
+using Bannerlord.LauncherManager.Localization;
+using Bannerlord.ModuleManager;
+using NexusMods.DataModel.Diagnostics;
+using NexusMods.DataModel.Diagnostics.Emitters;
+using NexusMods.DataModel.Diagnostics.References;
+using NexusMods.DataModel.Loadouts;
+using NexusMods.DataModel.Loadouts.Mods;
+using NexusMods.Games.MountAndBlade2Bannerlord.Extensions;
+using NexusMods.Games.MountAndBlade2Bannerlord.Utils;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Emitters;
+
+public class BuiltInEmitter : ILoadoutDiagnosticEmitter
+{
+ internal const string Source = "NexusMods.Games.MountAndBlade2Bannerlord";
+
+ public async IAsyncEnumerable Diagnose(Loadout loadout)
+ {
+ await Task.Yield();
+
+ var viewModels = (await loadout.GetSortedViewModelsAsync()).ToList();
+ var lookup = viewModels.ToDictionary(x => x.ModuleInfoExtended.Id, x => x);
+ var modules = lookup.Values.Select(x => x.ModuleInfoExtended).Concat(FeatureIds.LauncherFeatures.Select(x => new ModuleInfoExtended { Id = x })).ToList();
+
+ var ctx = new ModuleContext(lookup);
+ foreach (var moduleViewModel in viewModels)
+ {
+ foreach (var diagnostic in ModuleUtilities.ValidateModule(modules, moduleViewModel.ModuleInfoExtended, ctx.GetIsSelected, ctx.GetIsValid).Select(x => Render(loadout, moduleViewModel.Mod, x)))
+ {
+ yield return diagnostic;
+ }
+ }
+ }
+
+ private static Diagnostic Render(Loadout loadout, Mod mod, ModuleIssue issue)
+ {
+ static string Version(ApplicationVersionRange version) => version == ApplicationVersionRange.Empty
+ ? version.ToString()
+ : version.Min == version.Max
+ ? version.Min.ToString()
+ : "";
+
+ // We reuse the translation for now
+ var (level, message) = issue.Type switch
+ {
+ ModuleIssueType.Missing => (DiagnosticSeverity.Critical, new BUTRTextObject("{=J3Uh6MV4}Missing '{ID}' {VERSION} in modules list")
+ .SetTextVariable("ID", issue.SourceId)
+ .SetTextVariable("VERSION", issue.SourceVersion.Min.ToString())),
+
+ ModuleIssueType.MissingDependencies => (DiagnosticSeverity.Critical, new BUTRTextObject("{=3eQSr6wt}Missing '{ID}' {VERSION}")
+ .SetTextVariable("ID", issue.SourceId)
+ .SetTextVariable("VERSION", Version(issue.SourceVersion))),
+ ModuleIssueType.DependencyMissingDependencies => (DiagnosticSeverity.Critical, new BUTRTextObject("{=U858vdQX}'{ID}' is missing it's dependencies!")
+ .SetTextVariable("ID", issue.SourceId)),
+
+ ModuleIssueType.DependencyValidationError => (DiagnosticSeverity.Critical, new BUTRTextObject("{=1LS8Z5DU}'{ID}' has unresolved issues!")
+ .SetTextVariable("ID", issue.SourceId)),
+
+ ModuleIssueType.VersionMismatchLessThanOrEqual => (DiagnosticSeverity.Warning, new BUTRTextObject("{=Vjz9HQ41}'{ID}' wrong version <= {VERSION}")
+ .SetTextVariable("ID", issue.SourceId)
+ .SetTextVariable("VERSION", Version(issue.SourceVersion))),
+ ModuleIssueType.VersionMismatchLessThan => (DiagnosticSeverity.Warning, new BUTRTextObject("{=ZvnlL7VE}'{ID}' wrong version < [{VERSION}]")
+ .SetTextVariable("ID", issue.SourceId)
+ .SetTextVariable("VERSION", Version(issue.SourceVersion))),
+ ModuleIssueType.VersionMismatchGreaterThan => (DiagnosticSeverity.Warning, new BUTRTextObject("{=EfNuH2bG}'{ID}' wrong version > [{VERSION}]")
+ .SetTextVariable("ID", issue.SourceId)
+ .SetTextVariable("VERSION", Version(issue.SourceVersion))),
+
+ ModuleIssueType.Incompatible => (DiagnosticSeverity.Warning, new BUTRTextObject("{=zXDidmpQ}'{ID}' is incompatible with this module")
+ .SetTextVariable("ID", issue.SourceId)),
+
+ ModuleIssueType.DependencyConflictDependentAndIncompatible => (DiagnosticSeverity.Critical, new BUTRTextObject("{=4KFwqKgG}Module '{ID}' is both depended upon and marked as incompatible")
+ .SetTextVariable("ID", issue.SourceId)),
+ ModuleIssueType.DependencyConflictDependentLoadBeforeAndAfter => (DiagnosticSeverity.Critical, new BUTRTextObject("{=9DRB6yXv}Module '{ID}' is both depended upon as LoadBefore and LoadAfter")
+ .SetTextVariable("ID", issue.SourceId)),
+ ModuleIssueType.DependencyConflictCircular => (DiagnosticSeverity.Critical, new BUTRTextObject("{=RC1V9BbP}Circular dependencies. '{TARGETID}' and '{SOURCEID}' depend on each other")
+ .SetTextVariable("TARGETID", issue.Target.Id)
+ .SetTextVariable("SOURCEID", issue.SourceId)),
+
+ ModuleIssueType.DependencyNotLoadedBeforeThis => (DiagnosticSeverity.Warning, new BUTRTextObject("{=s3xbuejE}'{SOURCEID}' should be loaded before '{TARGETID}'")
+ .SetTextVariable("TARGETID", issue.Target.Id)
+ .SetTextVariable("SOURCEID", issue.SourceId)),
+
+ ModuleIssueType.DependencyNotLoadedAfterThis => (DiagnosticSeverity.Warning, new BUTRTextObject("{=2ALJB7z2}'{SOURCEID}' should be loaded after '{TARGETID}'")
+ .SetTextVariable("ID", issue.SourceId)),
+
+ _ => throw new ArgumentOutOfRangeException(nameof(issue))
+ };
+
+ return new Diagnostic
+ {
+ Id = new DiagnosticId(Source, (ushort) issue.Type),
+ Message = DiagnosticMessage.From(message.ToString()),
+ Severity = level,
+ DataReferences = new IDataReference[]
+ {
+ loadout.ToReference(),
+ mod.ToReference(loadout)
+ }
+ };
+ }
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Extensions/LoadoutExtensions.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Extensions/LoadoutExtensions.cs
new file mode 100644
index 0000000000..f340f43de6
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Extensions/LoadoutExtensions.cs
@@ -0,0 +1,64 @@
+using Bannerlord.LauncherManager;
+using Bannerlord.LauncherManager.Models;
+using NexusMods.DataModel.Loadouts;
+using NexusMods.DataModel.Loadouts.ModFiles;
+using NexusMods.DataModel.Loadouts.Mods;
+using NexusMods.Games.MountAndBlade2Bannerlord.Models;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Extensions;
+
+internal delegate LoadoutModuleViewModel ViewModelCreator(Mod mod, ModuleInfoExtendedWithPath moduleInfo, int index);
+
+internal static class LoadoutExtensions
+{
+ private static LoadoutModuleViewModel Default(Mod mod, ModuleInfoExtendedWithPath moduleInfo, int index) => new()
+ {
+ Mod = mod,
+ ModuleInfoExtended = moduleInfo,
+ IsValid = mod.GetSubModuleFileMetadata()?.IsValid == true,
+ IsSelected = mod.Enabled,
+ IsDisabled = mod.Status == ModStatus.Failed,
+ Index = index,
+ };
+
+ private static async Task> SortMods(Loadout loadout)
+ {
+ var loadoutSynchronizer = (loadout.Installation.Game.Synchronizer as MountAndBlade2BannerlordLoadoutSynchronizer)!;
+
+ var sorted = await loadoutSynchronizer.SortMods(loadout);
+ return sorted;
+ }
+
+ public static IEnumerable GetViewModels(this Loadout loadout, IEnumerable mods, ViewModelCreator? viewModelCreator = null)
+ {
+ viewModelCreator ??= Default;
+ var i = 0;
+ return mods.Select(x =>
+ {
+ var moduleInfo = x.GetModuleInfo();
+ if (moduleInfo is null) return null;
+
+ var subModule = x.Files.Values.OfType().First(y => y.To.FileName.Path.Equals(Constants.SubModuleName, StringComparison.OrdinalIgnoreCase));
+ var subModulePath = loadout.Installation.LocationsRegister.GetResolvedPath(subModule.To).GetFullPath();
+
+ return viewModelCreator(x, new ModuleInfoExtendedWithPath(moduleInfo, subModulePath), i++);
+ }).OfType();
+ }
+
+ public static async Task> GetSortedViewModelsAsync(this Loadout loadout, ViewModelCreator? viewModelCreator = null)
+ {
+ var sortedMods = await SortMods(loadout);
+ return GetViewModels(loadout, sortedMods, viewModelCreator);
+ }
+
+ public static IEnumerable GetViewModels(this Loadout loadout, ViewModelCreator? viewModelCreator = null)
+ {
+ return GetViewModels(loadout, loadout.Mods.Values, viewModelCreator);
+ }
+
+ public static bool HasModuleInstalled(this Loadout loadout, string moduleId) => loadout.Mods.Values.Any(x =>
+ x.GetModuleInfo() is { } moduleInfo && moduleInfo.Id.Equals(moduleId, StringComparison.OrdinalIgnoreCase));
+
+ public static bool HasInstalledFile(this Loadout loadout, string filename) => loadout.Mods.Values.Any(x =>
+ x.GetModuleFileMetadatas().Any(y => y.OriginalRelativePath.EndsWith(filename, StringComparison.OrdinalIgnoreCase)));
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Extensions/ModExtensions.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Extensions/ModExtensions.cs
new file mode 100644
index 0000000000..85dba3e554
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Extensions/ModExtensions.cs
@@ -0,0 +1,16 @@
+using Bannerlord.ModuleManager;
+using NexusMods.DataModel.Loadouts;
+using NexusMods.DataModel.Loadouts.Mods;
+using NexusMods.Games.MountAndBlade2Bannerlord.Models;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Extensions;
+
+internal static class ModExtensions
+{
+ public static SubModuleFileMetadata? GetSubModuleFileMetadata(this Mod mod) => mod.Files.SelectMany(y => y.Value.Metadata).OfType().FirstOrDefault();
+ public static ModuleInfoExtended? GetModuleInfo(this Mod mod) => GetSubModuleFileMetadata(mod)?.ModuleInfo;
+
+ public static IEnumerable GetModuleFileMetadatas(this Mod mod) => mod.Files.Values.Select(GetModuleFileMetadata).OfType();
+ public static ModuleFileMetadata? GetModuleFileMetadata(this AModFile modFile) => modFile.Metadata.OfType().FirstOrDefault();
+ public static string? GetOriginalRelativePath(this AModFile mod) => GetModuleFileMetadata(mod)?.OriginalRelativePath;
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Installers/MountAndBlade2BannerlordModInstaller.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Installers/MountAndBlade2BannerlordModInstaller.cs
new file mode 100644
index 0000000000..1e53cd08c8
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Installers/MountAndBlade2BannerlordModInstaller.cs
@@ -0,0 +1,129 @@
+using System.Xml;
+using Bannerlord.LauncherManager.Models;
+using Bannerlord.ModuleManager;
+using Microsoft.Extensions.DependencyInjection;
+using NexusMods.Common;
+using NexusMods.DataModel.Abstractions;
+using NexusMods.DataModel.Games;
+using NexusMods.DataModel.Loadouts;
+using NexusMods.DataModel.ModInstallers;
+using NexusMods.Games.MountAndBlade2Bannerlord.Models;
+using NexusMods.Games.MountAndBlade2Bannerlord.Services;
+using NexusMods.Games.MountAndBlade2Bannerlord.Sorters;
+using NexusMods.Paths;
+using NexusMods.Paths.Extensions;
+using NexusMods.Paths.FileTree;
+using static NexusMods.Games.MountAndBlade2Bannerlord.MountAndBlade2BannerlordConstants;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Installers;
+
+public sealed class MountAndBlade2BannerlordModInstaller : AModInstaller
+{
+ private readonly LauncherManagerFactory _launcherManagerFactory;
+
+ private MountAndBlade2BannerlordModInstaller(IServiceProvider serviceProvider) : base(serviceProvider)
+ {
+ _launcherManagerFactory = serviceProvider.GetRequiredService();
+ }
+
+ public static MountAndBlade2BannerlordModInstaller Create(IServiceProvider serviceProvider) => new(serviceProvider);
+
+ private static IAsyncEnumerable<(FileTreeNode ModuleInfoFile, ModuleInfoExtended ModuleInfo)> GetModuleInfoFiles(
+ FileTreeNode files)
+ {
+ return files.GetAllDescendentFiles().SelectAsync(async kv =>
+ {
+ var (path, file) = kv;
+
+ if (!path.FileName.Equals(SubModuleFile))
+ return default;
+
+ await using var stream = await file!.Open();
+ try
+ {
+ var doc = new XmlDocument();
+ doc.Load(stream);
+ var data = ModuleInfoExtended.FromXml(doc);
+ return (ModuleInfoFile: kv, ModuleInfo: data);
+ }
+ catch (Exception e)
+ {
+ return default;
+ //_logger.LogError("Failed to Parse Bannerlord Module: {EMessage}\\n{EStackTrace}", e.Message, e.StackTrace);
+ }
+ }).Where(kv => kv.ModuleInfo != null!);
+ }
+
+ public override async ValueTask> GetModsAsync(GameInstallation installation, LoadoutId loadoutId, ModId baseModId,
+ FileTreeNode archiveFiles, CancellationToken ct = default)
+ {
+ var moduleInfoFiles = await GetModuleInfoFiles(archiveFiles).ToArrayAsync(ct);
+
+ // Not a valid Bannerlord Module - install in root folder the content
+ if (!moduleInfoFiles.Any())
+ {
+ //return NoResults;
+
+ var modFiles = archiveFiles.GetAllDescendentFiles().Select(kv =>
+ {
+ var (path, file) = kv;
+ var moduleRoot = path.Parent;
+
+ return file!.ToStoredFile(new GamePath(LocationId.Game, ModFolder.Join(path.DropFirst(moduleRoot.Depth - 1))));
+ });
+ return new List
+ {
+ new ModInstallerResult
+ {
+ Id = baseModId,
+ Files = modFiles
+ }
+ };
+ }
+
+ var launcherManager = _launcherManagerFactory.Get(installation);
+
+ return moduleInfoFiles.Select(node =>
+ {
+ var (moduleInfoFile, moduleInfo) = node;
+ var moduleRoot = moduleInfoFile.Parent;
+ var moduleInfoWithPath = new ModuleInfoExtendedWithPath(moduleInfo, moduleInfoFile.Path);
+
+ // InstallModuleContent will only install mods if the ModuleInfoExtendedWithPath for a mod was provided
+ var result = launcherManager.InstallModuleContent(moduleRoot.GetAllDescendentFiles().Select(x => x.Path.ToString()).ToArray(), new[] { moduleInfoWithPath });
+ var modFiles = result.Instructions.OfType().Select(instruction =>
+ {
+ static IEnumerable GetMetadata(ModuleInfoExtendedWithPath moduleInfo, RelativePath relativePath)
+ {
+ yield return new ModuleFileMetadata { OriginalRelativePath = relativePath.Path };
+ if (relativePath.Equals(SubModuleFile)) yield return new SubModuleFileMetadata
+ {
+ IsValid = true, // TODO: For now lets keep it true while we don't have the validation layer
+ ModuleInfo = moduleInfo
+ };
+ }
+
+ var relativePath = instruction.Source.ToRelativePath();
+ var (path, file) = moduleRoot.FindNode(relativePath)!;
+
+ var fromArchive = file!.ToStoredFile(new GamePath(LocationId.Game, ModFolder.Join(path.DropFirst(moduleRoot.Depth - 1))));
+ return fromArchive with
+ {
+ Metadata = fromArchive.Metadata.AddRange(GetMetadata(moduleInfoWithPath, relativePath))
+ };
+ });
+
+ return new ModInstallerResult
+ {
+ Id = ModId.NewId(),
+ Files = modFiles,
+ Name = moduleInfo.Name,
+ Version = moduleInfo.Version.ToString(),
+ SortRules = new []
+ {
+ new ModuleInfoSort()
+ },
+ };
+ });
+ }
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Models/LoadoutModuleViewModel.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Models/LoadoutModuleViewModel.cs
new file mode 100644
index 0000000000..ac625ca8ca
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Models/LoadoutModuleViewModel.cs
@@ -0,0 +1,19 @@
+using Bannerlord.LauncherManager.Models;
+using NexusMods.DataModel.Loadouts.Mods;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Models;
+
+internal class LoadoutModuleViewModel : IModuleViewModel
+{
+ public required Mod Mod { get; init; }
+
+ public required ModuleInfoExtendedWithPath ModuleInfoExtended { get; init; }
+
+ public required bool IsValid { get; init; }
+
+ public required bool IsSelected { get; set; }
+
+ public required bool IsDisabled { get; set; }
+
+ public required int Index { get; set; }
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Models/ModuleFileMetadata.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Models/ModuleFileMetadata.cs
new file mode 100644
index 0000000000..d6e651e109
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Models/ModuleFileMetadata.cs
@@ -0,0 +1,12 @@
+using JetBrains.Annotations;
+using NexusMods.DataModel.Abstractions;
+using NexusMods.DataModel.JsonConverters;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Models;
+
+[PublicAPI]
+[JsonName("NexusMods.Games.MountAndBlade2Bannerlord.ModuleFileMetadata")]
+public class ModuleFileMetadata : IMetadata
+{
+ public required string OriginalRelativePath { get; init; }
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Models/SubModuleFileMetadata.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Models/SubModuleFileMetadata.cs
new file mode 100644
index 0000000000..44344582fd
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Models/SubModuleFileMetadata.cs
@@ -0,0 +1,14 @@
+using Bannerlord.ModuleManager;
+using JetBrains.Annotations;
+using NexusMods.DataModel.Abstractions;
+using NexusMods.DataModel.JsonConverters;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Models;
+
+[PublicAPI]
+[JsonName("NexusMods.Games.MountAndBlade2Bannerlord.SubModuleFileMetadata")]
+public class SubModuleFileMetadata : IMetadata
+{
+ public bool IsValid { get; set; } // TODO: I guess this is where we will store the validation check result?
+ public required ModuleInfoExtended ModuleInfo { get; init; }
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/MountAndBlade2Bannerlord.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/MountAndBlade2Bannerlord.cs
new file mode 100644
index 0000000000..059b6d1851
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/MountAndBlade2Bannerlord.cs
@@ -0,0 +1,78 @@
+using NexusMods.Common;
+using NexusMods.DataModel.Games;
+using NexusMods.DataModel.Games.GameCapabilities.FolderMatchInstallerCapability;
+using NexusMods.DataModel.LoadoutSynchronizer;
+using NexusMods.DataModel.ModInstallers;
+using NexusMods.FileExtractor.StreamFactories;
+using NexusMods.Games.MountAndBlade2Bannerlord.Installers;
+using NexusMods.Games.MountAndBlade2Bannerlord.Services;
+using NexusMods.Games.MountAndBlade2Bannerlord.Utils;
+using NexusMods.Paths;
+using static NexusMods.Games.MountAndBlade2Bannerlord.MountAndBlade2BannerlordConstants;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord;
+
+///
+/// Maintained by the BUTR Team
+/// https://github.com/BUTR
+///
+public sealed class MountAndBlade2Bannerlord : AGame, ISteamGame, IGogGame, IEpicGame, IXboxGame
+{
+ public static readonly GameDomain StaticDomain = GameDomain.From("mountandblade2bannerlord");
+ public static string DisplayName => "Mount & Blade II: Bannerlord";
+
+ private readonly IServiceProvider _serviceProvider;
+ private readonly LauncherManagerFactory _launcherManagerFactory;
+
+ public IEnumerable SteamIds => new[] { 261550u };
+ public IEnumerable GogIds => new long[] { 1802539526, 1564781494 };
+ public IEnumerable EpicCatalogItemId => new[] { "Chickadee" };
+ public IEnumerable XboxIds => new[] { "TaleWorldsEntertainment.MountBladeIIBannerlord" };
+
+ public MountAndBlade2Bannerlord(IServiceProvider serviceProvider, LauncherManagerFactory launcherManagerFactory) : base(serviceProvider)
+ {
+ _serviceProvider = serviceProvider;
+ _launcherManagerFactory = launcherManagerFactory;
+ }
+
+ public override string Name => DisplayName;
+ public override GameDomain Domain => StaticDomain;
+
+ public override GamePath GetPrimaryFile(GameStore store) => GamePathProvier.PrimaryLauncherFile(store);
+
+ public override IStreamFactory Icon =>
+ new EmbededResourceStreamFactory("NexusMods.Games.MountAndBlade2Bannerlord.Resources.icon.jpg");
+
+ public override IStreamFactory GameImage =>
+ new EmbededResourceStreamFactory("NexusMods.Games.MountAndBlade2Bannerlord.Resources.game_image.jpg");
+
+ public override IEnumerable Installers => new IModInstaller[]
+ {
+ MountAndBlade2BannerlordModInstaller.Create(_serviceProvider),
+ };
+
+ protected override Version GetVersion(GameLocatorResult installation)
+ {
+ var launcherManagerHandler = _launcherManagerFactory.Get(installation);
+ return Version.TryParse(launcherManagerHandler.GetGameVersion(), out var val) ? val : new Version();
+ }
+
+ protected override IReadOnlyDictionary GetLocations(IFileSystem fileSystem, GameLocatorResult installation)
+ {
+ var documentsFolder = fileSystem.GetKnownPath(KnownPath.MyDocumentsDirectory);
+ return new Dictionary()
+ {
+ { LocationId.Game, installation.Store == GameStore.XboxGamePass ? installation.Path.Combine("Content") : installation.Path },
+ { LocationId.Saves, documentsFolder.Combine($"{DocumentsFolderName}/Game Saves") },
+ { LocationId.Preferences, documentsFolder.Combine($"{DocumentsFolderName}/Configs") },
+ };
+ }
+
+ protected override IStandardizedLoadoutSynchronizer MakeSynchronizer(IServiceProvider provider)
+ {
+ return new MountAndBlade2BannerlordLoadoutSynchronizer(provider);
+ }
+
+ public override List GetInstallDestinations(IReadOnlyDictionary locations)
+ => ModInstallDestinationHelpers.GetCommonLocations(locations);
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/MountAndBlade2BannerlordConstants.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/MountAndBlade2BannerlordConstants.cs
new file mode 100644
index 0000000000..58345ec1ce
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/MountAndBlade2BannerlordConstants.cs
@@ -0,0 +1,13 @@
+using Bannerlord.LauncherManager;
+using NexusMods.Paths;
+using NexusMods.Paths.Extensions;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord;
+
+public static class MountAndBlade2BannerlordConstants
+{
+ public static readonly string DocumentsFolderName = "Mount and Blade II Bannerlord";
+
+ public static readonly RelativePath ModFolder = Constants.ModulesFolder.ToRelativePath();
+ public static readonly RelativePath SubModuleFile = Constants.SubModuleName.ToRelativePath();
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/MountAndBlade2BannerlordLoadoutSynchronizer.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/MountAndBlade2BannerlordLoadoutSynchronizer.cs
new file mode 100644
index 0000000000..3aca78f914
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/MountAndBlade2BannerlordLoadoutSynchronizer.cs
@@ -0,0 +1,14 @@
+using NexusMods.DataModel.Loadouts;
+using NexusMods.DataModel.Loadouts.Mods;
+using NexusMods.DataModel.LoadoutSynchronizer;
+using NexusMods.DataModel.Sorting.Rules;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord;
+
+public sealed class MountAndBlade2BannerlordLoadoutSynchronizer : ALoadoutSynchronizer
+{
+ public MountAndBlade2BannerlordLoadoutSynchronizer(IServiceProvider provider) : base(provider) { }
+
+ public new ValueTask[]> ModSortRules(Loadout loadout, Mod mod) => base.ModSortRules(loadout, mod);
+ public new Task> SortMods(Loadout loadout) => base.SortMods(loadout);
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/NexusMods.Games.MountAndBlade2Bannerlord.csproj b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/NexusMods.Games.MountAndBlade2Bannerlord.csproj
new file mode 100644
index 0000000000..21e66a7c30
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/NexusMods.Games.MountAndBlade2Bannerlord.csproj
@@ -0,0 +1,28 @@
+
+
+
+
+ $(NoWarn);CS1591;
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Resources/game_image.jpg b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Resources/game_image.jpg
new file mode 100644
index 0000000000..e09b5899b8
Binary files /dev/null and b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Resources/game_image.jpg differ
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Resources/icon.jpg b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Resources/icon.jpg
new file mode 100644
index 0000000000..74fdc6e61a
Binary files /dev/null and b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Resources/icon.jpg differ
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/RunLauncherTool.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/RunLauncherTool.cs
new file mode 100644
index 0000000000..5b25ad3283
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/RunLauncherTool.cs
@@ -0,0 +1,55 @@
+using System.Diagnostics;
+using Microsoft.Extensions.Logging;
+using NexusMods.DataModel.Games;
+using NexusMods.DataModel.Loadouts;
+using NexusMods.DataModel.Loadouts.LoadoutSynchronizerDTOs;
+using NexusMods.Games.MountAndBlade2Bannerlord.Extensions;
+using static NexusMods.Games.MountAndBlade2Bannerlord.Utils.GamePathProvier;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord;
+
+public class RunLauncherTool : ITool
+{
+ private readonly ILogger _logger;
+
+ public string Name => $"Run Launcher for {MountAndBlade2Bannerlord.DisplayName}";
+ public IEnumerable Domains => new[] { MountAndBlade2Bannerlord.StaticDomain };
+
+ public RunLauncherTool(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public async Task Execute(Loadout loadout, CancellationToken ct)
+ {
+ if (!loadout.Installation.Is()) return;
+
+ var store = loadout.Installation.Store;
+ var isXbox = store == GameStore.XboxGamePass;
+ var useVanillaLauncher = false; // TODO: From Options
+ var hasBLSE = loadout.HasInstalledFile("Bannerlord.BLSE.Shared.dll");
+ if (isXbox && !hasBLSE) return; // Not supported.
+
+ var blseExecutable = useVanillaLauncher
+ ? BLSELauncherFile(store)
+ : BLSELauncherExFile(store);
+
+ var program = isXbox
+ ? blseExecutable
+ : hasBLSE
+ ? blseExecutable
+ : PrimaryLauncherFile(store);
+ _logger.LogInformation("Running {Program}", program);
+
+ var psi = new ProcessStartInfo(program.ToString());
+ var process = Process.Start(psi);
+ if (process is null)
+ {
+ _logger.LogError("Failed to run {Program}", program);
+ return;
+ }
+
+ await process.WaitForExitAsync(ct);
+ _logger.LogInformation("Finished running {Program}", program);
+ }
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/RunStandaloneTool.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/RunStandaloneTool.cs
new file mode 100644
index 0000000000..8644a76ee1
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/RunStandaloneTool.cs
@@ -0,0 +1,56 @@
+using System.Diagnostics;
+using Microsoft.Extensions.Logging;
+using NexusMods.DataModel.Games;
+using NexusMods.DataModel.Loadouts;
+using NexusMods.DataModel.Loadouts.LoadoutSynchronizerDTOs;
+using NexusMods.Games.MountAndBlade2Bannerlord.Extensions;
+using NexusMods.Games.MountAndBlade2Bannerlord.Services;
+
+using static NexusMods.Games.MountAndBlade2Bannerlord.Utils.GamePathProvier;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord;
+
+public class RunStandaloneTool : ITool
+{
+ private readonly ILogger _logger;
+ private readonly LauncherManagerFactory _launcherManagerFactory;
+
+ public string Name => $"Run {MountAndBlade2Bannerlord.DisplayName}";
+ public IEnumerable Domains => new[] { MountAndBlade2Bannerlord.StaticDomain };
+
+ public RunStandaloneTool(ILogger logger, LauncherManagerFactory launcherManagerFactory)
+ {
+ _logger = logger;
+ _launcherManagerFactory = launcherManagerFactory;
+ }
+
+ public async Task Execute(Loadout loadout, CancellationToken ct)
+ {
+ if (!loadout.Installation.Is()) return;
+
+ var store = loadout.Installation.Store;
+ var isXbox = store == GameStore.XboxGamePass;
+ var hasBLSE = loadout.HasInstalledFile("Bannerlord.BLSE.Shared.dll");
+ if (isXbox && !hasBLSE) return; // Not supported.
+
+ var program = hasBLSE
+ ? BLSEStandaloneFile(store)
+ : PrimaryStandaloneFile(store);
+ _logger.LogInformation("Running {Program}", program);
+
+ var launcherManager = _launcherManagerFactory.Get(loadout.Installation);
+ var psi = new ProcessStartInfo(program.ToString())
+ {
+ Arguments = launcherManager.ExecutableParameters
+ };
+ var process = Process.Start(psi);
+ if (process is null)
+ {
+ _logger.LogError("Failed to run {Program}", program);
+ return;
+ }
+
+ await process.WaitForExitAsync(ct);
+ _logger.LogInformation("Finished running {Program}", program);
+ }
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services.cs
new file mode 100644
index 0000000000..f6a7f2fa82
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services.cs
@@ -0,0 +1,25 @@
+using Microsoft.Extensions.DependencyInjection;
+using NexusMods.Common;
+using NexusMods.DataModel.Diagnostics.Emitters;
+using NexusMods.DataModel.Games;
+using NexusMods.DataModel.JsonConverters.ExpressionGenerator;
+using NexusMods.Games.MountAndBlade2Bannerlord.Emitters;
+using NexusMods.Games.MountAndBlade2Bannerlord.Services;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord;
+
+public static class ServicesExtensions
+{
+ public static IServiceCollection AddMountAndBladeBannerlord(this IServiceCollection services)
+ {
+ services.AddSingleton();
+
+ services.AddAllSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton();
+
+ return services;
+ }
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services/LauncherManagerFactory.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services/LauncherManagerFactory.cs
new file mode 100644
index 0000000000..a999494850
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services/LauncherManagerFactory.cs
@@ -0,0 +1,43 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Logging;
+using NexusMods.DataModel.Games;
+using NexusMods.Games.MountAndBlade2Bannerlord.Utils;
+using NexusMods.Paths;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Services;
+
+public sealed class LauncherManagerFactory
+{
+ private readonly ILoggerFactory _loggerFactory;
+ private readonly ConcurrentDictionary _instances = new();
+
+ public LauncherManagerFactory(ILoggerFactory loggerFactory)
+ {
+ _loggerFactory = loggerFactory;
+ }
+
+ public LauncherManagerNexusMods Get(GameInstallation installation)
+ {
+ var store = Converter.ToGameStoreTW(installation.Store);
+ return _instances.GetOrAdd(installation.LocationsRegister[LocationId.Game].ToString(),
+ static (installationPath, tuple) => ValueFactory(tuple._loggerFactory, installationPath, tuple.store), (_loggerFactory, store));
+ }
+
+ public LauncherManagerNexusMods Get(GameLocatorResult gameLocator)
+ {
+ var store = Converter.ToGameStoreTW(gameLocator.Store);
+ return _instances.GetOrAdd(gameLocator.Path.ToString(),
+ static (installationPath, tuple) => ValueFactory(tuple._loggerFactory, installationPath, tuple.store), (_loggerFactory, store));
+ }
+
+ public LauncherManagerNexusMods Get(string installationPath, Bannerlord.LauncherManager.Models.GameStore store)
+ {
+ return _instances.GetOrAdd(installationPath,
+ static (installationPath, tuple) => ValueFactory(tuple._loggerFactory, installationPath, tuple.store), (_loggerFactory, store));
+ }
+
+ private static LauncherManagerNexusMods ValueFactory(ILoggerFactory loggerFactory, string installationPath, Bannerlord.LauncherManager.Models.GameStore store)
+ {
+ return new LauncherManagerNexusMods(loggerFactory.CreateLogger(), installationPath, store);
+ }
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services/LauncherManagerNexusMods.Utils.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services/LauncherManagerNexusMods.Utils.cs
new file mode 100644
index 0000000000..a252b909a5
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services/LauncherManagerNexusMods.Utils.cs
@@ -0,0 +1,20 @@
+using Bannerlord.ModuleManager;
+using FetchBannerlordVersion;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Services;
+
+partial class LauncherManagerNexusMods
+{
+ public override string GetGameVersion()
+ {
+ var gamePath = GetInstallPath();
+ var versionStr = Fetcher.GetVersion(gamePath, "TaleWorlds.Library.dll");
+ return ApplicationVersion.TryParse(versionStr, out var av) ? $"{av.Major}.{av.Minor}.{av.Revision}.{av.ChangeSet}" : "0.0.0.0";
+ }
+
+ public override int GetChangeset()
+ {
+ var gamePath = GetInstallPath();
+ return Fetcher.GetChangeSet(gamePath, "TaleWorlds.Library.dll");
+ }
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services/LauncherManagerNexusMods.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services/LauncherManagerNexusMods.cs
new file mode 100644
index 0000000000..7f4b80316e
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services/LauncherManagerNexusMods.cs
@@ -0,0 +1,189 @@
+using System.Collections.Concurrent;
+using Avalonia.Controls;
+using Avalonia.Platform.Storage;
+using Bannerlord.LauncherManager;
+using Bannerlord.LauncherManager.Localization;
+using Bannerlord.LauncherManager.Models;
+using Microsoft.Extensions.Logging;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Services;
+
+public sealed partial class LauncherManagerNexusMods : LauncherManagerHandler
+{
+ private readonly ILogger _logger;
+ private readonly string _installationPath;
+ private readonly ConcurrentDictionary _notificationIds = new();
+
+ private Window? _window; // TODO: How to inject the window?
+
+ public string ExecutableParameters { get; private set; } = string.Empty;
+
+ public LauncherManagerNexusMods(ILogger logger, string installationPath, GameStore store)
+ {
+ _logger = logger;
+ _installationPath = installationPath;
+
+ RegisterCallbacks(
+ loadLoadOrder: null!, // TODO:
+ saveLoadOrder: null!, // TODO:
+ sendNotification: SendNotificationDelegate,
+ sendDialog: SendDialogDelegate,
+ setGameParameters: (executable, parameters) => ExecutableParameters = string.Join(" ", parameters),
+ getInstallPath: () => installationPath,
+ readFileContent: ReadFileContentDelegate,
+ writeFileContent: WriteFileContentDelegate,
+ readDirectoryFileList: s => Directory.Exists(s) ? Directory.GetFiles(s) : null,
+ readDirectoryList: s => Directory.Exists(s) ? Directory.GetDirectories(s) : null,
+ getAllModuleViewModels: null!, // TODO:
+ getModuleViewModels: null!, // TODO:
+ setModuleViewModels: null!, // TODO:
+ getOptions: null!, // TODO:
+ getState: null! // TODO:
+ );
+ SetGameStore(store);
+ }
+
+ public void SetCurrentWindow(Window window)
+ {
+ _window = window;
+ }
+
+ private void SendNotificationDelegate(string id, NotificationType type, string message, uint ms)
+ {
+ if (_window is null)
+ {
+ return;
+ }
+
+ if (string.IsNullOrEmpty(id)) id = Guid.NewGuid().ToString();
+
+ // Prevents message spam
+ if (_notificationIds.TryAdd(id, null)) return;
+ using var cts = new CancellationTokenSource();
+ _ = Task.Delay(TimeSpan.FromMilliseconds(ms), cts.Token).ContinueWith(x => _notificationIds.TryRemove(id, out _), cts.Token);
+
+ var translatedMessage = new BUTRTextObject(message).ToString();
+ switch (type)
+ {
+ case NotificationType.Hint:
+ {
+ //HintManager.ShowHint(translatedMessage);
+ cts.Cancel();
+ break;
+ }
+ case NotificationType.Info:
+ {
+ // TODO:
+ //HintManager.ShowHint(translatedMessage);
+ cts.Cancel();
+ break;
+ }
+ default:
+ //MessageBox.Show(translatedMessage);
+ cts.Cancel();
+ break;
+ }
+ }
+
+ private void SendDialogDelegate(DialogType type, string title, string message, IReadOnlyList filters, Action onResult)
+ {
+ if (_window is null)
+ {
+ onResult(string.Empty);
+ return;
+ }
+
+ switch (type)
+ {
+ case DialogType.Warning:
+ {
+ var split = message.Split(new[] { "--CONTENT-SPLIT--" }, StringSplitOptions.RemoveEmptyEntries);
+ /*
+ using var okButton = new TaskDialogButton(ButtonType.Yes);
+ using var cancelButton = new TaskDialogButton(ButtonType.No);
+ using var dialog = new TaskDialog
+ {
+ MainIcon = TaskDialogIcon.Warning,
+ WindowTitle = new BUTRTextObject(title).ToString(),
+ MainInstruction = split[0],
+ Content = split.Length > 1 ? split[1] : string.Empty,
+ Buttons = { okButton, cancelButton },
+ CenterParent = true,
+ AllowDialogCancellation = true,
+ };
+ onResult(dialog.ShowDialog() == okButton ? "true" : "false");
+ */
+ return;
+ }
+ case DialogType.FileOpen:
+ {
+ _window.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = title,
+ FileTypeFilter = filters.Select(x => new FilePickerFileType(x.Name) { Patterns = x.Extensions }).ToArray(),
+ AllowMultiple = false
+ }).ContinueWith(x => onResult(x.Result.Count < 1 ? string.Empty : x.Result[0].Path.ToString()));
+ return;
+ }
+ case DialogType.FileSave:
+ {
+ var fileName = message;
+ _window.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = title,
+ FileTypeChoices = filters.Select(x => new FilePickerFileType(x.Name) { Patterns = x.Extensions }).ToArray(),
+ SuggestedFileName = fileName,
+ ShowOverwritePrompt = true
+ }).ContinueWith(x => onResult(x.Result is null ? string.Empty : x.Result.Path.ToString()));
+ return;
+ }
+ }
+ }
+
+
+ private byte[]? ReadFileContentDelegate(string path, int offset, int length)
+ {
+ if (!File.Exists(path)) return null;
+
+ try
+ {
+ if (offset == 0 && length == -1)
+ {
+ return File.ReadAllBytes(path);
+ }
+ else if (offset >= 0 && length > 0)
+ {
+ var data = new byte[length];
+ using var handle = File.OpenHandle(path, options: FileOptions.RandomAccess);
+ RandomAccess.Read(handle, data, offset);
+ return data;
+ }
+ else
+ {
+ return null;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Bannerlord IO Read Operation failed! {Path}", path);
+ return null;
+ }
+ }
+
+ private void WriteFileContentDelegate(string path, byte[]? data)
+ {
+ if (!File.Exists(path)) return;
+
+ try
+ {
+ if (data is null)
+ File.Delete(path);
+ else
+ File.WriteAllBytes(path, data);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Bannerlord IO Write Operation failed! {Path}", path);
+ }
+ }
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Sorters/ModuleInfoSort.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Sorters/ModuleInfoSort.cs
new file mode 100644
index 0000000000..5c75d39c55
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Sorters/ModuleInfoSort.cs
@@ -0,0 +1,72 @@
+using System.Diagnostics;
+using Bannerlord.ModuleManager;
+using JetBrains.Annotations;
+using NexusMods.DataModel.JsonConverters;
+using NexusMods.DataModel.Loadouts;
+using NexusMods.DataModel.Loadouts.Mods;
+using NexusMods.DataModel.Sorting.Rules;
+using NexusMods.DataModel.TriggerFilter;
+using NexusMods.Games.MountAndBlade2Bannerlord.Extensions;
+using NexusMods.Hashing.xxHash64;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Sorters;
+
+[PublicAPI]
+[JsonName("MountAndBlade2Bannerlord.Sorters.ModuleInfoSort")]
+public class ModuleInfoSort : IGeneratedSortRule, ISortRule, ITriggerFilter
+{
+ public ITriggerFilter TriggerFilter => this;
+
+ private static async IAsyncEnumerable> GetRules(ModuleInfoExtended moduleInfo, Loadout loadout)
+ {
+ ModId? GetModIdFromModuleId(string moduleId) => loadout.Mods.Values.FirstOrDefault(x => x.GetModuleInfo() is { } mi && mi.Id == moduleId)?.Id;
+
+ await Task.Yield();
+
+ foreach (var moduleMetadata in moduleInfo.DependenciesLoadBeforeThisDistinct())
+ {
+ if (GetModIdFromModuleId(moduleMetadata.Id) is { } modId)
+ {
+ yield return new After { Other = modId };
+ }
+ }
+ foreach (var moduleMetadata in moduleInfo.DependenciesLoadAfterThisDistinct())
+ {
+ if (GetModIdFromModuleId(moduleMetadata.Id) is { } modId)
+ {
+ yield return new Before { Other = modId };
+ }
+ }
+ foreach (var moduleMetadata in moduleInfo.DependenciesIncompatiblesDistinct())
+ {
+ if (GetModIdFromModuleId(moduleMetadata.Id) is { } modId)
+ {
+ // If an incompatible module was detected, the dependency rules were not respected
+ throw new UnreachableException();
+ }
+ }
+ }
+
+ public IAsyncEnumerable> GenerateSortRules(ModId selfId, Loadout loadout)
+ {
+ var thisMod = loadout.Mods[selfId];
+ return thisMod.GetModuleInfo() is { } moduleInfo ? GetRules(moduleInfo, loadout) : AsyncEnumerable.Empty>();
+ }
+
+ // From what I guess, we will need to re-sort either when a mod was added/removed or a mod version changed
+ // We could only consider the mods that are relevant for the self mod, but not sure if this will work correct
+ // Investigate once testing is available
+ public Hash GetFingerprint(ModId self, Loadout loadout)
+ {
+ var moduleInfos = loadout.Mods.Select(x => x.Value.GetModuleInfo()).OfType().OrderBy(x => x.Id).ToArray();
+
+ using var fp = Fingerprinter.Create();
+ fp.Add(loadout.Mods[self].DataStoreId);
+ foreach (var moduleInfo in moduleInfos)
+ {
+ fp.Add(moduleInfo.Id);
+ fp.Add(moduleInfo.Version.ToString());
+ }
+ return fp.Digest();
+ }
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/TypeFinder.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/TypeFinder.cs
new file mode 100644
index 0000000000..c1bbd8be4a
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/TypeFinder.cs
@@ -0,0 +1,20 @@
+using NexusMods.DataModel.JsonConverters.ExpressionGenerator;
+using NexusMods.Games.MountAndBlade2Bannerlord.Models;
+using NexusMods.Games.MountAndBlade2Bannerlord.Sorters;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord;
+
+public class TypeFinder : ITypeFinder
+{
+ public IEnumerable DescendentsOf(Type type)
+ {
+ return AllTypes.Where(t => t.IsAssignableTo(type));
+ }
+
+ private static IEnumerable AllTypes => new[]
+ {
+ typeof(ModuleFileMetadata),
+ typeof(SubModuleFileMetadata),
+ typeof(ModuleInfoSort),
+ };
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Utils/Converter.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Utils/Converter.cs
new file mode 100644
index 0000000000..7ba29c71d8
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Utils/Converter.cs
@@ -0,0 +1,20 @@
+using GameStore = NexusMods.DataModel.Games.GameStore;
+using GameStoreTW = Bannerlord.LauncherManager.Models.GameStore;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Utils;
+
+public static class Converter
+{
+ public static GameStoreTW ToGameStoreTW(GameStore store)
+ {
+ if (store == GameStore.Steam)
+ return GameStoreTW.Steam;
+ if (store == GameStore.GOG)
+ return GameStoreTW.GOG;
+ if (store == GameStore.EGS)
+ return GameStoreTW.Epic;
+ if (store == GameStore.XboxGamePass)
+ return GameStoreTW.Xbox;
+ return GameStoreTW.Unknown;
+ }
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Utils/GamePathProvier.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Utils/GamePathProvier.cs
new file mode 100644
index 0000000000..33640046cb
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Utils/GamePathProvier.cs
@@ -0,0 +1,30 @@
+using Bannerlord.LauncherManager;
+using NexusMods.Paths;
+using NexusMods.Paths.Extensions;
+using GameStore = NexusMods.DataModel.Games.GameStore;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Utils;
+
+public static class GamePathProvier
+{
+ private static string GetConfiguration(GameStore store) =>
+ LauncherManagerHandler.GetConfigurationByPlatform(LauncherManagerHandler.FromStore(Converter.ToGameStoreTW(store)));
+
+ public static GamePath PrimaryLauncherFile(GameStore store) =>
+ new(LocationId.Game, Path.Combine("bin", GetConfiguration(store)).ToRelativePath().Join("TaleWorlds.MountAndBlade.Launcher.exe"));
+
+ public static GamePath PrimaryXboxLauncherFile(GameStore store) =>
+ new(LocationId.Game, Path.Combine("bin", GetConfiguration(store)).ToRelativePath().Join("Launcher.Native.exe"));
+
+ public static GamePath PrimaryStandaloneFile(GameStore store) =>
+ new(LocationId.Game, Path.Combine("bin", GetConfiguration(store)).ToRelativePath().Join(Constants.BannerlordExecutable));
+
+ public static GamePath BLSEStandaloneFile(GameStore store) =>
+ new(LocationId.Game, Path.Combine("bin", GetConfiguration(store)).ToRelativePath().Join("Bannerlord.BLSE.Standalone.exe"));
+
+ public static GamePath BLSELauncherFile(GameStore store) =>
+ new(LocationId.Game, Path.Combine("bin", GetConfiguration(store)).ToRelativePath().Join("Bannerlord.BLSE.Launcher.exe"));
+
+ public static GamePath BLSELauncherExFile(GameStore store) =>
+ new(LocationId.Game, Path.Combine("bin", GetConfiguration(store)).ToRelativePath().Join("Bannerlord.BLSE.LauncherEx.exe"));
+}
diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Utils/ModuleContext.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Utils/ModuleContext.cs
new file mode 100644
index 0000000000..a5e959e2c7
--- /dev/null
+++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Utils/ModuleContext.cs
@@ -0,0 +1,52 @@
+using Bannerlord.LauncherManager;
+using Bannerlord.ModuleManager;
+using NexusMods.Games.MountAndBlade2Bannerlord.Models;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Utils;
+
+internal class ModuleContext
+{
+ private readonly IDictionary _lookup;
+ public ModuleContext(IEnumerable moduleVMs)
+ {
+ _lookup = moduleVMs.ToDictionary(x => x.ModuleInfoExtended.Id, x => x);
+ }
+ public ModuleContext(IDictionary lookup)
+ {
+ _lookup = lookup;
+ }
+
+ public bool GetIsValid(ModuleInfoExtended module)
+ {
+ if (FeatureIds.LauncherFeatures.Contains(module.Id))
+ return true;
+
+ return _lookup[module.Id].IsValid;
+ }
+ public bool GetIsSelected(ModuleInfoExtended module)
+ {
+ if (FeatureIds.LauncherFeatures.Contains(module.Id))
+ return false;
+
+ return _lookup[module.Id].IsSelected;
+ }
+ public void SetIsSelected(ModuleInfoExtended module, bool value)
+ {
+ if (FeatureIds.LauncherFeatures.Contains(module.Id))
+ return;
+ _lookup[module.Id].IsSelected = value;
+ }
+ public bool GetIsDisabled(ModuleInfoExtended module)
+ {
+ if (FeatureIds.LauncherFeatures.Contains(module.Id))
+ return false;
+
+ return _lookup[module.Id].IsDisabled;
+ }
+ public void SetIsDisabled(ModuleInfoExtended module, bool value)
+ {
+ if (FeatureIds.LauncherFeatures.Contains(module.Id))
+ return;
+ _lookup[module.Id].IsDisabled = value;
+ }
+}
diff --git a/src/NexusMods.App/NexusMods.App.csproj b/src/NexusMods.App/NexusMods.App.csproj
index 568d5b6450..b3f74f3502 100644
--- a/src/NexusMods.App/NexusMods.App.csproj
+++ b/src/NexusMods.App/NexusMods.App.csproj
@@ -15,6 +15,7 @@
+
diff --git a/src/NexusMods.App/Services.cs b/src/NexusMods.App/Services.cs
index 374f6d6b2e..6d7ea9566b 100644
--- a/src/NexusMods.App/Services.cs
+++ b/src/NexusMods.App/Services.cs
@@ -15,6 +15,7 @@
using NexusMods.Games.FOMOD;
using NexusMods.Games.FOMOD.UI;
using NexusMods.Games.Generic;
+using NexusMods.Games.MountAndBlade2Bannerlord;
using NexusMods.Games.RedEngine;
using NexusMods.Games.Reshade;
using NexusMods.Games.Sifu;
@@ -69,6 +70,7 @@ public static IServiceCollection AddApp(this IServiceCollection services,
.AddDarkestDungeon()
.AddSifu()
.AddStardewValley()
+ .AddMountAndBladeBannerlord()
.AddRenderers()
.AddNexusWebApi()
.AddNexusWebApiNmaIntegration()
diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/.editorconfig b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/.editorconfig
new file mode 100644
index 0000000000..ece3986bd3
--- /dev/null
+++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/.editorconfig
@@ -0,0 +1,4 @@
+[*.cs]
+max_line_length = 180
+
+space_within_single_line_array_initializer_braces = true
\ No newline at end of file
diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Emitters/BuiltInEmitterTests.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Emitters/BuiltInEmitterTests.cs
new file mode 100644
index 0000000000..d09fc452fc
--- /dev/null
+++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Emitters/BuiltInEmitterTests.cs
@@ -0,0 +1,47 @@
+using Bannerlord.ModuleManager;
+using FluentAssertions;
+using NexusMods.DataModel.Diagnostics;
+using NexusMods.DataModel.Diagnostics.References;
+using NexusMods.Games.MountAndBlade2Bannerlord.Emitters;
+using NexusMods.Games.MountAndBlade2Bannerlord.Tests.Shared;
+using NexusMods.Games.TestFramework;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Tests.Emitters;
+
+public class BuiltInEmitterTests : ALoadoutDiagnosticEmitterTest
+{
+ public BuiltInEmitterTests(IServiceProvider serviceProvider) : base(serviceProvider) { }
+
+ [Fact]
+ public async Task Test_Emitter()
+ {
+ var loadoutMarker = await CreateLoadout();
+
+ var context = AGameTestContext.Create(CreateTestArchive, InstallModStoredFileIntoLoadout);
+
+ await loadoutMarker.AddNative(context);
+ await loadoutMarker.AddButterLib(context);
+ await loadoutMarker.AddHarmony(context);
+
+ var diagnostics = await GetAllDiagnostics(loadoutMarker.Value);
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Test_Emitter2()
+ {
+ var loadoutMarker = await CreateLoadout();
+
+ var context = AGameTestContext.Create(CreateTestArchive, InstallModStoredFileIntoLoadout);
+
+ await loadoutMarker.AddNative(context);
+ var modA = await loadoutMarker.AddButterLib(context);
+
+ var diagnostic = await GetSingleDiagnostic(loadoutMarker.Value);
+ diagnostic.Id.Should().Be(new DiagnosticId(BuiltInEmitter.Source, (ushort) ModuleIssueType.MissingDependencies));
+ diagnostic.DataReferences.Should().Equal(
+ loadoutMarker.Value.ToReference(),
+ modA.ToReference(loadoutMarker.Value)
+ );
+ }
+}
diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Installers/MountAndBlade2BannerlordModInstallerTests.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Installers/MountAndBlade2BannerlordModInstallerTests.cs
new file mode 100644
index 0000000000..2e51fb6a34
--- /dev/null
+++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Installers/MountAndBlade2BannerlordModInstallerTests.cs
@@ -0,0 +1,123 @@
+using System.Text;
+using FluentAssertions;
+using NexusMods.DataModel.Loadouts.ModFiles;
+using NexusMods.Games.MountAndBlade2Bannerlord.Installers;
+using NexusMods.Games.MountAndBlade2Bannerlord.Tests.Shared;
+using NexusMods.Games.TestFramework;
+using NexusMods.Networking.NexusWebApi.Types;
+using NexusMods.Paths;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Tests.Installers;
+
+public class MountAndBlade2BannerlordModInstallerTests : AModInstallerTest
+{
+ public MountAndBlade2BannerlordModInstallerTests(IServiceProvider serviceProvider) : base(serviceProvider) { }
+
+ [Fact]
+ [Trait("RequiresNetworking", "True")]
+ public async Task Test_WithBLSE()
+ {
+ var loadout = await CreateLoadout();
+
+ // Bannerlord Software Extender (BLSE): Bannerlord Software Extender (BLSE) Main File (Version 1.3.6)
+ var downloadId = await DownloadMod(Game.Domain, ModId.From(1), FileId.From(34698));
+
+ var mod = await InstallModStoredFileIntoLoadout(loadout, downloadId);
+ mod.Files.Should().HaveCount(11);
+ mod.Files.Values.Cast().Should().AllSatisfy(x => x.To.Path.StartsWith("bin"));
+ mod.Files.Values.Cast().Should().Contain(x => x.To.FileName == "Bannerlord.BLSE.Shared.dll");
+ mod.Files.Values.Cast().Should().Contain(x => x.To.FileName == "Bannerlord.BLSE.Standalone.exe");
+ mod.Files.Values.Cast().Should().Contain(x => x.To.FileName == "Bannerlord.BLSE.Launcher.exe");
+ mod.Files.Values.Cast().Should().Contain(x => x.To.FileName == "Bannerlord.BLSE.LauncherEx.exe");
+ }
+
+ [Fact]
+ [Trait("RequiresNetworking", "True")]
+ public async Task Test_WithStandardMod()
+ {
+ var loadout = await CreateLoadout(false);
+
+ // Harmony: Harmony Main File (Version 2.3.0)
+ var downloadId = await DownloadMod(Game.Domain, ModId.From(2006), FileId.From(34666));
+
+ var mod = await InstallModStoredFileIntoLoadout(loadout, downloadId);
+ mod.Name.Should().BeEquivalentTo("Harmony");
+ mod.Version.Should().BeEquivalentTo("v2.3.0.0");
+ mod.Files.Values.Cast().Should().HaveCount(49);
+ mod.Files.Values.Cast().Should().AllSatisfy(x => x.To.Path.StartsWith("Modules/Test"));
+ mod.Files.Values.Cast().Should().Contain(x => x.To.FileName == "Bannerlord.Harmony.dll");
+ mod.Files.Values.Cast().Should().Contain(x => x.To.FileName == "SubModule.xml");
+ }
+
+ [Fact]
+ [Trait("RequiresNetworking", "True")]
+ public async Task Test_WithStandardModMultiple()
+ {
+ var loadout = await CreateLoadout(false);
+
+ // Calradia at War (Custom Spawns): CalradiaAtWar For Bannerlord v1.1.0 - v1.1.1 - v1.1.2 Main File (Version 1.9.5)
+ var downloadId = await DownloadMod(Game.Domain, ModId.From(411), FileId.From(34610));
+
+ var mods = await InstallModsStoredFileIntoLoadout(loadout, downloadId);
+ var mod1 = mods.FirstOrDefault(x => x.Name == "Custom Spawns API");
+ var mod2 = mods.FirstOrDefault(x => x.Name == "Calradia At War");
+
+ mod1.Name.Should().BeEquivalentTo("Custom Spawns API");
+ mod1.Version.Should().BeEquivalentTo("v1.9.5.0");
+ mod1.Files.Should().HaveCount(8);
+ mod1.Files.Values.Cast().Should().AllSatisfy(x => x.To.Path.StartsWith("Modules/CustomSpawns"));
+ mod1.Files.Values.Cast().Should().Contain(x => x.To.FileName == "CustomSpawns.dll");
+ mod1.Files.Values.Cast().Should().Contain(x => x.To.FileName == "SubModule.xml");
+
+ mod2.Name.Should().BeEquivalentTo("Calradia At War");
+ mod2.Version.Should().BeEquivalentTo("v1.9.1.0");
+ mod2.Files.Values.Cast().Should().HaveCount(20);
+ mod2.Files.Values.Cast().Should().AllSatisfy(x => x.To.Path.StartsWith("Modules/CalradiaAtWar"));
+ mod2.Files.Values.Cast().Should().Contain(x => x.To.FileName == "SubModule.xml");
+ }
+
+ [Fact]
+ public async Task Test_WithFakeMod()
+ {
+ var testFiles = new Dictionary
+ {
+ ["Test/SubModule.xml"] = Encoding.UTF8.GetBytes(Data.HarmonySubModuleXml),
+ ["Test/bin/Win64_Shipping_Client/Bannerlord.Harmony.dll"] = Array.Empty(),
+ ["Test/bin/Gaming.Desktop.x64_Shipping_Client/Bannerlord.Harmony.dll"] = Array.Empty()
+ };
+
+ var file = await CreateTestArchive(testFiles);
+ await using (file)
+ {
+ var (mod, modFiles) = await GetModWithFilesFromInstaller(file.Path);
+ mod.Name.Should().BeEquivalentTo("Harmony");
+ mod.Version.Should().BeEquivalentTo("v2.2.0.0");
+ modFiles.Should().HaveCount(3);
+ modFiles.Cast().Should().AllSatisfy(x => x.To.Path.StartsWith("Modules/Test"));
+ modFiles.Cast().Should().Contain(x => x.To.FileName == "Bannerlord.Harmony.dll");
+ modFiles.Cast().Should().Contain(x => x.To.FileName == "SubModule.xml");
+ }
+ }
+ [Fact]
+ public async Task Test_WithFakeMod_WithModulesRoot()
+ {
+ var testFiles = new Dictionary
+ {
+ ["Modules/Test/SubModule.xml"] = Encoding.UTF8.GetBytes(Data.HarmonySubModuleXml),
+ ["Modules/Test/bin/Win64_Shipping_Client/Bannerlord.Harmony.dll"] = Array.Empty(),
+ ["Modules/Test/bin/Gaming.Desktop.x64_Shipping_Client/Bannerlord.Harmony.dll"] = Array.Empty()
+ };
+
+ var file = await CreateTestArchive(testFiles);
+ await using (file)
+ {
+ var (mod, modFiles) = await GetModWithFilesFromInstaller(file.Path);
+ mod.Name.Should().BeEquivalentTo("Harmony");
+ mod.Version.Should().BeEquivalentTo("v2.2.0.0");
+ modFiles.Should().HaveCount(3);
+ modFiles.Cast().Should().AllSatisfy(x => x.To.Path.StartsWith("Modules/Test"));
+ modFiles.Cast().Should().Contain(x => x.To.FileName == "Bannerlord.Harmony.dll");
+ modFiles.Cast().Should().Contain(x => x.To.FileName == "SubModule.xml");
+ }
+ }
+}
diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/NexusMods.Games.MountAndBlade2Bannerlord.Tests.csproj b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/NexusMods.Games.MountAndBlade2Bannerlord.Tests.csproj
new file mode 100644
index 0000000000..07fecb40ef
--- /dev/null
+++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/NexusMods.Games.MountAndBlade2Bannerlord.Tests.csproj
@@ -0,0 +1,16 @@
+
+
+ NexusMods.Games.MountAndBlade2Bannerlord.Tests
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/AGameTestContext.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/AGameTestContext.cs
new file mode 100644
index 0000000000..80214c9198
--- /dev/null
+++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/AGameTestContext.cs
@@ -0,0 +1,22 @@
+using NexusMods.DataModel.Loadouts.Markers;
+using NexusMods.DataModel.Loadouts.Mods;
+using NexusMods.Paths;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Tests.Shared;
+
+public class AGameTestContext
+{
+ public static AGameTestContext Create(
+ Func, Task> createTestArchive,
+ Func> installModStoredFileIntoLoadout) =>
+ new(createTestArchive, installModStoredFileIntoLoadout);
+
+ public Func, Task> CreateTestArchive { get; }
+ public Func> InstallModStoredFileIntoLoadout { get; }
+
+ private AGameTestContext(Func, Task> createTestArchive, Func> installModStoredFileIntoLoadout)
+ {
+ CreateTestArchive = createTestArchive;
+ InstallModStoredFileIntoLoadout = installModStoredFileIntoLoadout;
+ }
+}
diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/Data.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/Data.cs
new file mode 100644
index 0000000000..3dd40ac5e6
--- /dev/null
+++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/Data.cs
@@ -0,0 +1,108 @@
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Tests.Shared;
+
+public static class Data
+{
+ public static readonly string HarmonySubModuleXml = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+""";
+
+ public static readonly string ButterLibSubModuleXml = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+""";
+}
diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/LoadoutMarkerExtensions.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/LoadoutMarkerExtensions.cs
new file mode 100644
index 0000000000..11421f14fa
--- /dev/null
+++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/LoadoutMarkerExtensions.cs
@@ -0,0 +1,66 @@
+using System.Xml;
+using Bannerlord.ModuleManager;
+using NexusMods.DataModel.Loadouts.Markers;
+using NexusMods.DataModel.Loadouts.Mods;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Tests.Shared;
+
+public static class LoadoutMarkerExtensions
+{
+ public static async Task AddNative(this LoadoutMarker loadoutMarker, AGameTestContext context)
+ {
+ var moduleInfo = new ModuleInfoExtended
+ {
+ Id = "Native",
+ Name = "Native",
+ Version = ApplicationVersion.TryParse("v1.0.0.0", out var bVersion) ? bVersion : ApplicationVersion.Empty,
+ };
+
+ var modFiles = moduleInfo.CreateTestFiles();
+ await using var modPath = await context.CreateTestArchive(modFiles);
+
+ return await context.InstallModStoredFileIntoLoadout(loadoutMarker, modPath, null, CancellationToken.None);
+ }
+
+ public static async Task AddHarmony(this LoadoutMarker loadoutMarker, AGameTestContext context)
+ {
+ var doc = new XmlDocument();
+ doc.LoadXml(Data.HarmonySubModuleXml);
+ var moduleInfo = ModuleInfoExtended.FromXml(doc);
+
+ var modFiles = moduleInfo.CreateTestFiles();
+ await using var modPath = await context.CreateTestArchive(modFiles);
+
+ return await context.InstallModStoredFileIntoLoadout(loadoutMarker, modPath, null, CancellationToken.None);
+ }
+
+ public static async Task AddButterLib(this LoadoutMarker loadoutMarker, AGameTestContext context)
+ {
+ var doc = new XmlDocument();
+ doc.LoadXml(Data.ButterLibSubModuleXml);
+ var moduleInfo = ModuleInfoExtended.FromXml(doc);
+
+ var modFiles = moduleInfo.CreateTestFiles();
+ await using var modPath = await context.CreateTestArchive(modFiles);
+
+ return await context.InstallModStoredFileIntoLoadout(loadoutMarker, modPath, null, CancellationToken.None);
+ }
+
+ public static async Task AddFakeButterLib(this LoadoutMarker loadoutMarker, AGameTestContext context)
+ {
+ var moduleInfo = new ModuleInfoExtended
+ {
+ Id = "Bannerlord.ButterLib",
+ Name = "ButterLib",
+ Version = ApplicationVersion.TryParse("v1.0.0.0", out var bVersion) ? bVersion : ApplicationVersion.Empty,
+ DependentModuleMetadatas = new []
+ {
+ new DependentModuleMetadata("Bannerlord.Harmony", LoadType.LoadBeforeThis, false, false, ApplicationVersion.TryParse("v3.0.0.0", out var a2Version) ? a2Version : ApplicationVersion.Empty, ApplicationVersionRange.Empty)
+ }
+ };
+ var modFiles = moduleInfo.CreateTestFiles();
+ await using var modPath = await context.CreateTestArchive(modFiles);
+
+ return await context.InstallModStoredFileIntoLoadout(loadoutMarker, modPath, null, CancellationToken.None);
+ }
+}
diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/ModuleInfoExtendedExtensions.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/ModuleInfoExtendedExtensions.cs
new file mode 100644
index 0000000000..504a20b596
--- /dev/null
+++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/ModuleInfoExtendedExtensions.cs
@@ -0,0 +1,100 @@
+using System.Text;
+using System.Xml;
+using Bannerlord.ModuleManager;
+using NexusMods.Paths;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Tests.Shared;
+
+public static class ModuleInfoExtendedExtensions
+{
+ public static Dictionary CreateTestFiles(this ModuleInfoExtended moduleInfo)
+ {
+ var xml = GetXml(moduleInfo);
+ var bytes = Encoding.UTF8.GetBytes(xml);
+
+ return new Dictionary
+ {
+ { "SubModule.xml", bytes }
+ };
+ }
+
+ public static string GetXml(this ModuleInfoExtended moduleInfo)
+ {
+ var doc = new XmlDocument();
+
+ var xmlDeclaration = doc.CreateXmlDeclaration( "1.0", "UTF-8", null );
+ var root = doc.DocumentElement;
+ doc.InsertBefore(xmlDeclaration, root);
+
+ var module = doc.CreateElement(string.Empty, "Module", string.Empty);
+ doc.AppendChild(module);
+
+ var id = doc.CreateElement(string.Empty, "Id", string.Empty);
+ id.SetAttribute("value", moduleInfo.Id);
+ module.AppendChild(id);
+
+ var name = doc.CreateElement(string.Empty, "Name", string.Empty);
+ name.SetAttribute("value", moduleInfo.Name);
+ module.AppendChild(name);
+
+ var version = doc.CreateElement(string.Empty, "Version", string.Empty);
+ version.SetAttribute("value", moduleInfo.Version.ToString());
+ module.AppendChild(version);
+
+ var defaultModule = doc.CreateElement(string.Empty, "DefaultModule", string.Empty);
+ defaultModule.SetAttribute("value", moduleInfo.IsOfficial ? "false" : "true");
+ module.AppendChild(defaultModule);
+
+ var moduleCategory = doc.CreateElement(string.Empty, "ModuleCategory", string.Empty);
+ moduleCategory.SetAttribute("value", moduleInfo.IsSingleplayerModule ? "Singleplayer" : moduleInfo.IsMultiplayerModule ? "Multiplayer" : moduleInfo.IsServerModule ? "Server" : "None");
+ module.AppendChild(moduleCategory);
+
+ var moduleType = doc.CreateElement(string.Empty, "ModuleType", string.Empty);
+ moduleType.SetAttribute("value", moduleInfo.IsOfficial ? "Official" : "Community");
+ module.AppendChild(moduleType);
+
+ var url = doc.CreateElement(string.Empty, "Url", string.Empty);
+ url.SetAttribute("value", moduleInfo.Url);
+ module.AppendChild(url);
+
+ var dependedModuleMetadatas = doc.CreateElement(string.Empty, "DependedModuleMetadatas", string.Empty);
+ module.AppendChild(dependedModuleMetadatas);
+ foreach (var dependency in moduleInfo.DependenciesToLoadDistinct())
+ {
+ var dep = doc.CreateElement(string.Empty, "DependedModuleMetadata", string.Empty);
+ dep.SetAttribute("id", dependency.Id);
+ dep.SetAttribute("order", dependency.LoadType.ToString());
+ dep.SetAttribute("optional", dependency.IsOptional ? "true" : "false");
+ dependedModuleMetadatas.AppendChild(dep);
+ }
+
+ var subModules = doc.CreateElement(string.Empty, "SubModules", string.Empty);
+ module.AppendChild(subModules);
+ foreach (var subModule in moduleInfo.SubModules)
+ {
+ var sub = doc.CreateElement(string.Empty, "SubModule", string.Empty);
+
+ var subName = doc.CreateElement(string.Empty, "Name", string.Empty);
+ subName.SetAttribute("value", subModule.Name);
+ sub.AppendChild(subName);
+
+ var dllName = doc.CreateElement(string.Empty, "DLLName", string.Empty);
+ dllName.SetAttribute("value", subModule.DLLName);
+ sub.AppendChild(dllName);
+
+ var subModuleClassType = doc.CreateElement(string.Empty, "SubModuleClassType", string.Empty);
+ dllName.SetAttribute("value", subModule.SubModuleClassType);
+ sub.AppendChild(subModuleClassType);
+
+ var assemblies = doc.CreateElement(string.Empty, "Assemblies", string.Empty);
+ foreach (var assembly in subModule.Assemblies)
+ {
+ var ass = doc.CreateElement(string.Empty, "Assembly", string.Empty);
+ ass.SetAttribute("value", assembly);
+ assemblies.AppendChild(ass);
+ }
+ }
+
+ return doc.OuterXml;
+ }
+}
diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Sorters/MountAndBlade2BannerlordLoadoutSynchronizerTests.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Sorters/MountAndBlade2BannerlordLoadoutSynchronizerTests.cs
new file mode 100644
index 0000000000..42d33d81e4
--- /dev/null
+++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Sorters/MountAndBlade2BannerlordLoadoutSynchronizerTests.cs
@@ -0,0 +1,43 @@
+using FluentAssertions;
+using NexusMods.DataModel.Loadouts;
+using NexusMods.DataModel.Loadouts.Mods;
+using NexusMods.DataModel.Sorting.Rules;
+using NexusMods.Games.MountAndBlade2Bannerlord.Tests.Shared;
+using NexusMods.Games.TestFramework;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Tests.Sorters;
+
+public class MountAndBlade2BannerlordLoadoutSynchronizerTests : AGameTest
+{
+ public MountAndBlade2BannerlordLoadoutSynchronizerTests(IServiceProvider provider) : base(provider) { }
+
+ [Fact]
+ public async Task GeneratedSortRulesAreFetched()
+ {
+ var loadoutMarker = await CreateLoadout();
+ var loadoutSynchronizer = (loadoutMarker.Value.Installation.Game.Synchronizer as MountAndBlade2BannerlordLoadoutSynchronizer)!;
+
+ var context = AGameTestContext.Create(CreateTestArchive, InstallModStoredFileIntoLoadout);
+
+ await loadoutMarker.AddNative(context);
+ await loadoutMarker.AddButterLib(context);
+ await loadoutMarker.AddHarmony(context);
+
+ var mod = loadoutMarker.Value.Mods.Values.First(m => m.Name == "ButterLib");
+ var nameForId = loadoutMarker.Value.Mods.Values.ToDictionary(m => m.Id, m => m.Name);
+ var rules = await loadoutSynchronizer.ModSortRules(loadoutMarker.Value, mod);
+
+ var testData = rules.Select(r =>
+ {
+ if (r is After a) return ("After", nameForId[a.Other]);
+ if (r is Before b) return ("Before", nameForId[b.Other]);
+ throw new NotImplementedException();
+ });
+
+ testData.Should().BeEquivalentTo(new[]
+ {
+ ("After", "Harmony"),
+ ("Before", "Native")
+ });
+ }
+}
diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Startup.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Startup.cs
new file mode 100644
index 0000000000..9f91cde789
--- /dev/null
+++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Startup.cs
@@ -0,0 +1,21 @@
+using Microsoft.Extensions.DependencyInjection;
+using NexusMods.Common;
+using NexusMods.Games.TestFramework;
+using NexusMods.StandardGameLocators.TestHelpers;
+using Xunit.DependencyInjection.Logging;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Tests;
+
+public class Startup
+{
+ public void ConfigureServices(IServiceCollection services)
+ {
+ services
+ .AddDefaultServicesForTesting()
+ .AddUniversalGameLocator(new Version("1.0.0.0"))
+ .AddMountAndBladeBannerlord()
+ .AddLogging(builder => builder.AddXunitOutput())
+ .Validate();
+ }
+}
+
diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Usings.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Usings.cs
new file mode 100644
index 0000000000..c802f4480b
--- /dev/null
+++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Usings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Utils/LoadoutExtensionsTests.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Utils/LoadoutExtensionsTests.cs
new file mode 100644
index 0000000000..59d99ab327
--- /dev/null
+++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Utils/LoadoutExtensionsTests.cs
@@ -0,0 +1,50 @@
+using Bannerlord.LauncherManager.Models;
+using FluentAssertions;
+using NexusMods.DataModel.Loadouts;
+using NexusMods.DataModel.Loadouts.Mods;
+using NexusMods.Games.MountAndBlade2Bannerlord.Extensions;
+using NexusMods.Games.MountAndBlade2Bannerlord.Models;
+using NexusMods.Games.MountAndBlade2Bannerlord.Tests.Shared;
+using NexusMods.Games.TestFramework;
+
+namespace NexusMods.Games.MountAndBlade2Bannerlord.Tests.Utils;
+
+public class LoadoutExtensionsTests : AGameTest
+{
+ public LoadoutExtensionsTests(IServiceProvider serviceProvider) : base(serviceProvider) { }
+
+ private static LoadoutModuleViewModel ViewModelCreator(Mod mod, ModuleInfoExtendedWithPath moduleInfo, int index) => new()
+ {
+ Mod = mod,
+ ModuleInfoExtended = moduleInfo,
+ IsValid = true,
+ IsSelected = mod.Enabled,
+ IsDisabled = mod.Status == ModStatus.Failed,
+ Index = index,
+ };
+
+ [Fact]
+ public async Task Test_GetViewModels()
+ {
+ var loadoutMarker = await CreateLoadout();
+
+ var context = AGameTestContext.Create(CreateTestArchive, InstallModStoredFileIntoLoadout);
+
+ await loadoutMarker.AddButterLib(context);
+ await loadoutMarker.AddHarmony(context);
+
+ var unsorted = loadoutMarker.Value.GetViewModels(ViewModelCreator).Select(x => x.Mod.Name).ToList();
+ var sorted = (await loadoutMarker.Value.GetSortedViewModelsAsync(ViewModelCreator)).Select(x => x.Mod.Name).ToList();
+
+ unsorted.Should().BeEquivalentTo(new[]
+ {
+ "ButterLib",
+ "Harmony",
+ });
+ sorted.Should().BeEquivalentTo(new[]
+ {
+ "Harmony",
+ "ButterLib",
+ });
+ }
+}