Skip to content

Commit

Permalink
Game Support: Bannerlord (#799)
Browse files Browse the repository at this point in the history
* Initial implementation for Bannerlord

* Added game version resolution

Using RandomAccess for performance gains
Added Tools for launching
Added NoWarn for CS1591

* Renamed MountAndBladeBannerlord to MountAndBlade2Bannerlord

* Leftover renaming fixes, game name reusage

* Added proper version resolution

* Added semi Xbox support

Switched to static gamedomain

* dotnet format

* Added some theoretical UI usage code

Process start additional checks

* Added icon and game image

* Adapted to latest changes

* Merge fix

* Fixed Bannerlord, added base for tests

* Minor adjustments

* Build fix

* Update

* Update

* Added future store support

Added metadata enrichment

* Added initial doc

* Fix

* Added GamePathProvier for easier path calculation

Namespace change
Other minor fixes

* Adapted Store code

Removed old test code

* Post merge fixes

* Added installer tests, fixed LauncherManager IO

* Lil refactor

* Added BLSE test case just to be safe

* Adapted for multimod support

* Formatting adjustments

* Added Xbox support

Fixed test compilation

* Use the parameteter IFileSystem instead of the class field, shouldn't really matter but whatever

* Fix

* Added ModuleInfoSort for sorting, still needs integration

* Fix

* Added sorting

* Some improvements

* Adapted to latest master

Added Emitter adapter of our diagnotics system

* Latest changes

* Fix

* Finalized adaptation

* Test fix

* Adapted latest changes

* Fixed tests

* Fix

* Fixed tests

Added custom sorting

* Better implementation

* Added MountAndBlade2BannerlordLoadoutSynchronizerTests

Exposed ModSortRules
Added SortRules propagation

* File metadata rework

Added ViewModelCreator delegate for tests

* Fix

* Fix

* Fix Builds

---------

Co-authored-by: Vitalii Mikhailov <[email protected]>
  • Loading branch information
halgari and Aragas authored Nov 30, 2023
1 parent 79171ab commit 510937e
Show file tree
Hide file tree
Showing 42 changed files with 1,731 additions and 1 deletion.
3 changes: 3 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
<PackageVersion Include="NexusMods.Telemetry.OpenTelemetry" Version="1.0.0" />
<PackageVersion Include="FomodInstaller.Interface" Version="1.2.0" />
<PackageVersion Include="FomodInstaller.Scripting.XmlScript" Version="1.0.0" />
<!-- Bannerlord -->
<PackageVersion Include="Bannerlord.LauncherManager" Version="1.0.76" />
<PackageVersion Include="FetchBannerlordVersion" Version="1.0.6.39" />
</ItemGroup>
<ItemGroup>
<PackageVersion Include="JetBrains.Annotations" Version="2023.3.0" PrivateAssets="all" />
Expand Down
14 changes: 14 additions & 0 deletions NexusMods.App.sln
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,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}"
Expand Down Expand Up @@ -273,6 +277,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
Expand Down Expand Up @@ -358,6 +370,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}
Expand Down
33 changes: 33 additions & 0 deletions docs/games/000X-Bannerlord.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[*.cs]
max_line_length = 180

space_within_single_line_array_initializer_braces = true
Original file line number Diff line number Diff line change
@@ -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<Diagnostic> 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)
}
};
}
}
Original file line number Diff line number Diff line change
@@ -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<IEnumerable<Mod>> SortMods(Loadout loadout)
{
var loadoutSynchronizer = (loadout.Installation.Game.Synchronizer as MountAndBlade2BannerlordLoadoutSynchronizer)!;

var sorted = await loadoutSynchronizer.SortMods(loadout);
return sorted;
}

public static IEnumerable<LoadoutModuleViewModel> GetViewModels(this Loadout loadout, IEnumerable<Mod> 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<StoredFile>().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<LoadoutModuleViewModel>();
}

public static async Task<IEnumerable<LoadoutModuleViewModel>> GetSortedViewModelsAsync(this Loadout loadout, ViewModelCreator? viewModelCreator = null)
{
var sortedMods = await SortMods(loadout);
return GetViewModels(loadout, sortedMods, viewModelCreator);
}

public static IEnumerable<LoadoutModuleViewModel> 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)));
}
Original file line number Diff line number Diff line change
@@ -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<SubModuleFileMetadata>().FirstOrDefault();
public static ModuleInfoExtended? GetModuleInfo(this Mod mod) => GetSubModuleFileMetadata(mod)?.ModuleInfo;

public static IEnumerable<ModuleFileMetadata> GetModuleFileMetadatas(this Mod mod) => mod.Files.Values.Select(GetModuleFileMetadata).OfType<ModuleFileMetadata>();
public static ModuleFileMetadata? GetModuleFileMetadata(this AModFile modFile) => modFile.Metadata.OfType<ModuleFileMetadata>().FirstOrDefault();
public static string? GetOriginalRelativePath(this AModFile mod) => GetModuleFileMetadata(mod)?.OriginalRelativePath;
}
Loading

0 comments on commit 510937e

Please sign in to comment.