Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added: Support for BLSE (Bannerlord Software Extender) #2321

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public sealed class Bannerlord : AGame, ISteamGame, IGogGame, IEpicGame, IXboxGa

public override ILibraryItemInstaller[] LibraryItemInstallers =>
[
_serviceProvider.GetRequiredService<BLSEInstaller>(),
_serviceProvider.GetRequiredService<BannerlordModInstaller>(),
];
public override IDiagnosticEmitter[] DiagnosticEmitters =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
using NexusMods.Abstractions.GameLocators;
using NexusMods.Abstractions.Games;
using NexusMods.Abstractions.Loadouts;
using NexusMods.CrossPlatform.Process;
using NexusMods.Games.Generic;
using NexusMods.Paths;
using static Bannerlord.LauncherManager.Constants;
namespace NexusMods.Games.MountAndBlade2Bannerlord;

Expand All @@ -19,12 +21,14 @@ public class BannerlordRunGameTool : RunGameTool<Bannerlord>
private readonly ILogger<BannerlordRunGameTool> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly GameToolRunner _runner;
private readonly IOSInformation _os;

public BannerlordRunGameTool(IServiceProvider serviceProvider, Bannerlord game, GameToolRunner runner)
public BannerlordRunGameTool(IServiceProvider serviceProvider, Bannerlord game, GameToolRunner runner, IOSInformation os)
: base(serviceProvider, game)
{
_serviceProvider = serviceProvider;
_runner = runner;
_os = os;
_logger = serviceProvider.GetRequiredService<ILogger<BannerlordRunGameTool>>();
}

Expand All @@ -39,8 +43,25 @@ public override async Task Execute(Loadout.ReadOnly loadout, CancellationToken c
var args = await GetBannerlordExeCommandlineArgs(loadout, commandLineArgs, cancellationToken);
var install = loadout.InstallationInstance;
var exe = install.LocationsRegister[LocationId.Game];
if (install.Store != GameStore.XboxGamePass) { exe = exe/BinFolder/Win64Configuration/BannerlordExecutable; }
else { exe = exe/BinFolder/XboxConfiguration/BannerlordExecutable; }

if (loadout.LocateBLSE(out var blseRelativePath))
{
exe = exe/blseRelativePath;

// Note(sewer): If the user is on Linux, there is no guarantee they
// have .NET Framework installed; instead, Framework stuff may be run on
// Mono. Because BLSE interacts with low level components of the runtime,
// we will instead use a 'hidden' BLSE feature to use the .NET Runtime
// stored in the game folder. In this case, that's .NET 6 at the time of
// writing.
if (_os.IsLinux)
args = [..args, "/forcenetcore"];
}
else
{
if (install.Store != GameStore.XboxGamePass) { exe = exe/BinFolder/Win64Configuration/BannerlordExecutable; }
else { exe = exe/BinFolder/XboxConfiguration/BannerlordExecutable; }
}

var command = Cli.Wrap(exe.ToString())
.WithArguments(args)
Expand All @@ -61,23 +82,11 @@ private async Task<string[]> GetBannerlordExeCommandlineArgs(Loadout.ReadOnly lo
var manifestPipeline = Pipelines.GetManifestPipeline(_serviceProvider);
var modules = (await Helpers.GetAllManifestsAsync(_logger, loadout, manifestPipeline, cancellationToken).ToArrayAsync(cancellationToken))
.Select(x => x.Item2);
var sortedModules = AutoSort(Hack.GetDummyBaseGameModules()
var sortedModules = SortHelper.AutoSort(Hack.GetDummyBaseGameModules()
.Concat(modules)).Select(x => x.Id).ToArray();
var loadOrderCli = sortedModules.Length > 0 ? $"_MODULES_*{string.Join("*", sortedModules)}*_MODULES_" : string.Empty;

// Add the new arguments
return commandLineArgs.Concat(["/singleplayer", loadOrderCli]).ToArray();
}

// Copied from Bannerlord.LauncherManager
// needs upstream changes, will do those changes tomorrow (21st Nov 2024)
private static IEnumerable<ModuleInfoExtended> AutoSort(IEnumerable<ModuleInfoExtended> source)
{
var orderedModules = source
.OrderByDescending(x => x.IsOfficial)
.ThenBy(x => x.Id, new AlphanumComparatorFast())
.ToArray();

return ModuleSorter.TopologySort(orderedModules, module => ModuleUtilities.GetDependencies(orderedModules, module));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.Extensions.Logging;
using NexusMods.Abstractions.Diagnostics;
using NexusMods.Abstractions.Diagnostics.Emitters;
using NexusMods.Abstractions.Diagnostics.Values;
using NexusMods.Abstractions.Loadouts;
using NexusMods.Abstractions.Loadouts.Extensions;
using NexusMods.Abstractions.Resources;
Expand All @@ -18,6 +19,8 @@ namespace NexusMods.Games.MountAndBlade2Bannerlord.Diagnostics;
/// </summary>
internal partial class BannerlordDiagnosticEmitter : ILoadoutDiagnosticEmitter
{
private static NamedLink _blseLink = new("Bannerlord Software Extender", new Uri("https://www.nexusmods.com/mountandblade2bannerlord/mods/1"));
private static NamedLink _harmonyLink = new("Harmony", new Uri("https://www.nexusmods.com/mountandblade2bannerlord/mods/2006"));
private readonly IResourceLoader<BannerlordModuleLoadoutItem.ReadOnly, ModuleInfoExtended> _manifestPipeline;
private readonly ILogger _logger;

Expand Down Expand Up @@ -51,8 +54,12 @@ public async IAsyncEnumerable<Diagnostic> Diagnose(Loadout.ReadOnly loadout, Can
isEnabledDict[module] = true;
modulesOnly = modulesOnly.Concat(Hack.GetDummyBaseGameModules()).ToArray();
// TODO: HACK. Pretend base game modules are installed before we can properly ingest them.

// Emit diagnostics
var isBlseInstalled = loadout.IsBLSEInstalled();
if (isBlseInstalled && !IsHarmonyAvailable(modulesOnly, isEnabledDict))
yield return Diagnostics.CreateMissingHarmony(_harmonyLink);

foreach (var moduleAndMod in isEnabledDict)
{
var moduleInfo = moduleAndMod.Key;
Expand All @@ -61,17 +68,15 @@ public async IAsyncEnumerable<Diagnostic> Diagnose(Loadout.ReadOnly loadout, Can

// Note(sewer): All modules are valid by definition
// All modules are selected by definition.
foreach (var diagnostic in ModuleUtilities.ValidateModuleEx(modulesOnly, moduleInfo, module => isEnabledDict.ContainsKey(module), _ => true, false).Select(x => CreateDiagnostic(x)))
foreach (var diagnostic in ModuleUtilities.ValidateModuleEx(modulesOnly, moduleInfo, module => isEnabledDict.ContainsKey(module), _ => true, false).Select(x => CreateDiagnostic(x, isBlseInstalled)))
{
if (diagnostic != null)
yield return diagnostic;
}
}
}



private Diagnostic? CreateDiagnostic(ModuleIssueV2 issue)
private Diagnostic? CreateDiagnostic(ModuleIssueV2 issue, bool isBlseInstalled)
{
return issue switch
{
Expand All @@ -86,6 +91,14 @@ public async IAsyncEnumerable<Diagnostic> Diagnose(Loadout.ReadOnly loadout, Can
// Note(sewer): We emit this from the dependency mod itself.
ModuleDependencyValidationIssue dependencyValidation => null,

// Missing BLSE Dependency
ModuleMissingBLSEDependencyIssue missingUnversioned when !isBlseInstalled => Diagnostics.CreateMissingBLSE(
ModId: missingUnversioned.Module.Id,
ModName: missingUnversioned.Module.Name,
DependencyId: missingUnversioned.Dependency.Id,
BLSELink: _blseLink
),

// Missing Unversioned Dependency
ModuleMissingUnversionedDependencyIssue missingUnversioned => Diagnostics.CreateMissingDependency(
ModId: missingUnversioned.Module.Id,
Expand Down Expand Up @@ -221,4 +234,10 @@ public async IAsyncEnumerable<Diagnostic> Diagnose(Loadout.ReadOnly loadout, Can
"Issue text is below: {Issue}", issue);
return null;
}

private bool IsHarmonyAvailable(ModuleInfoExtended[] modulesOnly, Dictionary<ModuleInfoExtended, bool> isEnabledDict)
{
var harmonyModule = modulesOnly.FirstOrDefault(x => x.Id == "Bannerlord.Harmony");
return harmonyModule != default(ModuleInfoExtended?) && isEnabledDict[harmonyModule];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -830,4 +830,63 @@ Protontricks is required to run the game but is not present.
.AddValue<NamedLink>("ProtontricksUri")
)
.Finish();

[DiagnosticTemplate, UsedImplicitly]
internal static IDiagnosticTemplate MissingBLSETemplate = DiagnosticTemplateBuilder
.Start()
.WithId(new DiagnosticId(Source, 16))
.WithTitle("'{ModName}' Requires Bannerlord Software Extender (BLSE)")
.WithSeverity(DiagnosticSeverity.Critical)
.WithSummary("'{ModName}' Requires Bannerlord Software Extender (BLSE) which is not installed")
.WithDetails("""
'{ModName}' Requires Bannerlord Software Extender (BLSE) which is not installed.

### How to Resolve
1. Download and install {BLSELink}.
2. Enable BLSE.

### Technical Details
Looking at `{ModName}`'s `SubModule.xml`:

```xml
<Module>
<!-- 👇 Current mod is `{ModName}` with id `{ModId}` -->
<Id value="{ModId}"/>
<Name value="{ModName}"/>
<DependedModules>
<!-- 💡 This mod requires `{DependencyId}` which is provided by BLSE -->
<DependedModule Id="{DependencyId}" />
<!-- ❌ BLSE is not installed -->
</DependedModules>
</Module>
```
"""
)
.WithMessageData(messageBuilder => messageBuilder
.AddValue<string>("ModId")
.AddValue<string>("ModName")
.AddValue<string>("DependencyId")
.AddValue<NamedLink>("BLSELink")
)
.Finish();

[DiagnosticTemplate, UsedImplicitly]
internal static IDiagnosticTemplate MissingHarmonyTemplate = DiagnosticTemplateBuilder
.Start()
.WithId(new DiagnosticId(Source, 17))
.WithTitle("BLSE Requires Harmony which is not Installed")
.WithSeverity(DiagnosticSeverity.Critical)
.WithSummary("'Bannerlord Software Extender' (BLSE) requires 'Harmony' which is not installed")
.WithDetails("""
'Bannerlord Software Extender' requires 'Harmony' to function, however 'Harmony' is not installed.

### How to Resolve
1. Download and install {HarmonyLink}
2. Enable Harmony
"""
)
.WithMessageData(messageBuilder => messageBuilder
.AddValue<NamedLink>("HarmonyLink")
)
.Finish();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NexusMods.Abstractions.GameLocators;
using NexusMods.Abstractions.Library.Installers;
using NexusMods.Abstractions.Library.Models;
using NexusMods.Abstractions.Loadouts;
using NexusMods.MnemonicDB.Abstractions;
using NexusMods.Paths;

namespace NexusMods.Games.MountAndBlade2Bannerlord.Installers;

/// <summary>
/// An installer for the 'Bannerlord Software Extender' (BLSE):<br/>
/// - https://www.nexusmods.com/mountandblade2bannerlord/mods/1?tab=description
/// <br/><br/>
/// BLSE ships in approximately this folder structure:
///
/// <code>
/// 📁 bin
/// 📁 Gaming.Desktop.x64_Shipping_Client
/// 📄 Bannerlord.BLSE.Launcher.exe (139.3 kB)
/// 📄 Bannerlord.BLSE.LauncherEx.exe (375.8 kB)
/// 📄 Bannerlord.BLSE.Shared.dll (2.7 MB)
/// 📄 Bannerlord.BLSE.Standalone.exe (139.3 kB)
/// 📁 Win64_Shipping_Client
/// 📄 Bannerlord.BLSE.Launcher.exe (139.3 kB)
/// 📄 Bannerlord.BLSE.Launcher.exe.config (156 B)
/// 📄 Bannerlord.BLSE.LauncherEx.exe (375.8 kB)
/// 📄 Bannerlord.BLSE.LauncherEx.exe.config (156 B)
/// 📄 Bannerlord.BLSE.Shared.dll (2.7 MB)
/// 📄 Bannerlord.BLSE.Standalone.exe (139.3 kB)
/// 📄 Bannerlord.BLSE.Standalone.exe.config (156 B)
/// </code>
///
/// This installer will extract the files in the `bin` folder from either `Win64_Shipping_Client` or `Gaming.Desktop.x64_Shipping_Client`
/// based on the user's game store.
/// </summary>
// ReSharper disable once InconsistentNaming
public class BLSEInstaller : ALibraryArchiveInstaller
{
/// <summary/>
public BLSEInstaller(IServiceProvider serviceProvider) : base(serviceProvider, serviceProvider.GetRequiredService<ILogger<BLSEInstaller>>()) { }

/// <inheritdoc/>
public override ValueTask<InstallerResult> ExecuteAsync(
LibraryArchive.ReadOnly libraryArchive, LoadoutItemGroup.New loadoutGroup, ITransaction transaction, Loadout.ReadOnly loadout, CancellationToken cancellationToken)
{
var store = loadout.InstallationInstance.Store;
var installDir = store == GameStore.XboxGamePass ? (RelativePath)"bin/Gaming.Desktop.x64_Shipping_Client" : (RelativePath)"bin/Win64_Shipping_Client";

// Check if we are BLSE, we'll do a simple file check to determine this.
var hasBlseLauncher = libraryArchive.Children.Any(x => x.Path.StartsWith(installDir/"Bannerlord.BLSE.Launcher.exe"));
if (!hasBlseLauncher) return ValueTask.FromResult((InstallerResult)(new NotSupported()));

// This is the group which contains the files for BLSE.
var modGroup = new LoadoutItemGroup.New(transaction, out var modGroupEntityId)
{
IsGroup = true,
LoadoutItem = new LoadoutItem.New(transaction, modGroupEntityId)
{
Name = "Bannerlord Software Extender (BLSE)",
LoadoutId = loadout,
ParentId = loadoutGroup,
},
};

// Get the files for the given game store.
var folderEntries = libraryArchive.Children.Where(x => x.Path.StartsWith(installDir));

// Assign each subfolder
foreach (var fileEntry in folderEntries)
{
_ = new LoadoutFile.New(transaction, out var entityId)
{
Hash = fileEntry.AsLibraryFile().Hash,
Size = fileEntry.AsLibraryFile().Size,
LoadoutItemWithTargetPath = new LoadoutItemWithTargetPath.New(transaction, entityId)
{
// Note(sewer): Path of file inside archive matches that on FileSystem.
// So we don't need to compute it, as we've filtered it in
// the 'where' clause above.
TargetPath = (loadout.Id, LocationId.Game, fileEntry.Path),
LoadoutItem = new LoadoutItem.New(transaction, entityId)
{
Name = fileEntry.AsLibraryFile().FileName,
LoadoutId = loadout,
ParentId = modGroup,
},
},
};
}

return ValueTask.FromResult((InstallerResult)(new Success()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -142,21 +142,11 @@ public override async ValueTask<InstallerResult> ExecuteAsync(LibraryArchive.Rea
return new Success();
}

private static bool StoreEquals(GameStore bannerlordStore, Abstractions.GameLocators.GameStore nexusModsAppStore)
{
var isXbox = bannerlordStore == GameStore.Xbox && nexusModsAppStore == Abstractions.GameLocators.GameStore.XboxGamePass;
var isGog = bannerlordStore == GameStore.GOG && nexusModsAppStore == Abstractions.GameLocators.GameStore.GOG;
var isEpic = bannerlordStore == GameStore.Epic && nexusModsAppStore == Abstractions.GameLocators.GameStore.EGS;
var isSteam = bannerlordStore == GameStore.Steam && nexusModsAppStore == Abstractions.GameLocators.GameStore.Steam;
return isXbox || isGog || isEpic || isSteam;
}

private static Optional<EntityId> AddFileFromFilesToCopy(
LibraryArchive.ReadOnly libraryArchive, ITransaction transaction, Loadout.ReadOnly loadout, RelativePath source, RelativePath destination, LoadoutItemGroup.New modGroup, LibraryArchiveFileEntry.ReadOnly moduleInfoFile, Optional<EntityId> moduleInfoLoadoutItemId)
{
var fileEntry = libraryArchive.Children.First(x => x.Path.Equals(source));
var to = new GamePath(LocationId.Game, destination);

var loadoutFile = new LoadoutFile.New(transaction, out var entityId)
{
Hash = fileEntry.AsLibraryFile().Hash,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using NexusMods.Abstractions.Loadouts;
using NexusMods.Abstractions.Loadouts.Extensions;
using NexusMods.Paths;
using static Bannerlord.LauncherManager.Constants;
namespace NexusMods.Games.MountAndBlade2Bannerlord;

/// <summary>
/// Bannerlord specific extensions for loadouts.
/// </summary>
public static class LoadoutExtensions
{
public static readonly RelativePath BlseExecutable = (RelativePath)"Bannerlord.BLSE.Standalone.exe";

/// <summary>
/// Determines if BLSE is installed by matching a file name within a given loadout.
/// </summary>
public static bool LocateBLSE(this Loadout.ReadOnly loadout, out RelativePath path)
{
var blseXboxPath = (RelativePath)BinFolder / XboxConfiguration / BlseExecutable;
var blseStandalonePath = (RelativePath)BinFolder / Win64Configuration / BlseExecutable;
var blseLauncher = loadout.Items.OfTypeLoadoutItemWithTargetPath()
.Where(x => x.AsLoadoutItem().IsEnabled())
.FirstOrDefault(x =>
{
var relativePath = x.TargetPath.Item3;
return relativePath.Equals(blseXboxPath) || relativePath.Equals(blseStandalonePath);
}
);

if (!blseLauncher.IsValid())
{
path = default(RelativePath);
return false;
}

path = blseLauncher.TargetPath.Item3;
return true;
}

/// <summary>
/// Determines if BLSE is installed in a given loadout by matching its launcher name.
/// </summary>
public static bool IsBLSEInstalled(this Loadout.ReadOnly loadout) => LocateBLSE(loadout, out _);
}
Loading
Loading