Skip to content

Commit

Permalink
Merge pull request #2284 from Nexus-Mods/make-bannerlord-start
Browse files Browse the repository at this point in the history
Make Bannerlord 'Just Barely Work'
  • Loading branch information
Sewer56 authored Nov 21, 2024
2 parents 45d4d6f + beac0b2 commit e50f0e4
Show file tree
Hide file tree
Showing 18 changed files with 1,184 additions and 186 deletions.
29 changes: 22 additions & 7 deletions src/Abstractions/NexusMods.Abstractions.Games/RunGameTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ public RunGameTool(IServiceProvider serviceProvider, T game)
public string Name => $"Run {_game.Name}";

/// <summary/>
public async Task Execute(Loadout.ReadOnly loadout, CancellationToken cancellationToken)
public virtual async Task Execute(Loadout.ReadOnly loadout, CancellationToken cancellationToken, string[]? commandLineArgs)
{
commandLineArgs ??= [];
_logger.LogInformation("Starting {Name}", Name);

var program = await GetGamePath(loadout);
Expand All @@ -69,10 +70,10 @@ public async Task Execute(Loadout.ReadOnly loadout, CancellationToken cancellati
switch (locator)
{
case SteamLocatorResultMetadata steamLocatorResultMetadata:
await RunThroughSteam(steamLocatorResultMetadata.AppId, cancellationToken);
await RunThroughSteam(steamLocatorResultMetadata.AppId, cancellationToken, commandLineArgs);
return;
case HeroicGOGLocatorResultMetadata heroicGOGLocatorResultMetadata:
await RunThroughHeroic("gog", heroicGOGLocatorResultMetadata.Id, cancellationToken);
await RunThroughHeroic("gog", heroicGOGLocatorResultMetadata.Id, cancellationToken, commandLineArgs);
return;
}
}
Expand Down Expand Up @@ -164,7 +165,7 @@ private async Task<Process> RunWithShell(CancellationToken cancellationToken, Ab
return process;
}

private async Task RunThroughSteam(uint appId, CancellationToken cancellationToken)
private async Task RunThroughSteam(uint appId, CancellationToken cancellationToken, string[] commandLineArgs)
{
if (!OSInformation.Shared.IsLinux) throw OSInformation.Shared.CreatePlatformNotSupportedException();

Expand All @@ -174,8 +175,18 @@ private async Task RunThroughSteam(uint appId, CancellationToken cancellationTok
// the current starts, so we ignore every reaper process that already exists.
var existingReaperProcesses = Process.GetProcessesByName("reaper").Select(x => x.Id).ToHashSet();

// Build the Steam URL with optional command line arguments
// https://developer.valvesoftware.com/wiki/Steam_browser_protocol
await _osInterop.OpenUrl(new Uri($"steam://rungameid/{appId.ToString(CultureInfo.InvariantCulture)}"), fireAndForget: true, cancellationToken: cancellationToken);
var steamUrl = $"steam://run/{appId.ToString(CultureInfo.InvariantCulture)}";
if (commandLineArgs is { Length: > 0 })
{
var encodedArgs = commandLineArgs
.Select(Uri.EscapeDataString)
.Aggregate((a, b) => $"{a} {b}");
steamUrl += $"//{encodedArgs}/";
}

await _osInterop.OpenUrl(new Uri(steamUrl), fireAndForget: true, cancellationToken: cancellationToken);

var steam = await WaitForProcessToStart("steam", timeout, existingProcesses: null, cancellationToken);
if (steam is null) return;
Expand All @@ -188,11 +199,15 @@ private async Task RunThroughSteam(uint appId, CancellationToken cancellationTok
await reaper.WaitForExitAsync(cancellationToken);
}

private async Task RunThroughHeroic(string type, long appId, CancellationToken cancellationToken)
private async Task RunThroughHeroic(string type, long appId, CancellationToken cancellationToken, string[] commandLineArgs)
{
Debug.Assert(OSInformation.Shared.IsLinux);

// TODO: track process
if (commandLineArgs.Length > 0)
_logger.LogError("Heroic does not currently support command line arguments: https://github.com/Nexus-Mods/NexusMods.App/issues/2264 . " +
$"Args {string.Join(',', commandLineArgs)} were specified but will be ignored.");

await _osInterop.OpenUrl(new Uri($"heroic://launch/{type}/{appId.ToString(CultureInfo.InvariantCulture)}"), fireAndForget: true, cancellationToken: cancellationToken);
}

Expand Down Expand Up @@ -257,7 +272,7 @@ public IJobTask<ITool, Unit> StartJob(Loadout.ReadOnly loadout, IJobMonitor moni
{
return monitor.Begin<ITool, Unit>(this, async _ =>
{
await Execute(loadout, cancellationToken);
await Execute(loadout, cancellationToken, []);
return Unit.Default;
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Bannerlord.ModuleManager;
using FetchBannerlordVersion;
using Microsoft.Extensions.DependencyInjection;
using NexusMods.Abstractions.Diagnostics.Emitters;
using NexusMods.Abstractions.GameLocators;
Expand All @@ -18,15 +20,15 @@
using NexusMods.Games.MountAndBlade2Bannerlord.LauncherManager;
using NexusMods.Games.MountAndBlade2Bannerlord.Utils;
using NexusMods.Paths;
using static NexusMods.Games.MountAndBlade2Bannerlord.MountAndBlade2BannerlordConstants;
using static NexusMods.Games.MountAndBlade2Bannerlord.BannerlordConstants;

namespace NexusMods.Games.MountAndBlade2Bannerlord;

/// <summary>
/// Maintained by the BUTR Team
/// https://github.com/BUTR
/// </summary>
public sealed class MountAndBlade2Bannerlord : AGame, ISteamGame, IGogGame, IEpicGame, IXboxGame
public sealed class Bannerlord : AGame, ISteamGame, IGogGame, IEpicGame, IXboxGame
{
public static readonly GameId GameIdStatic = GameId.From(3174);
public static readonly GameDomain DomainStatic = GameDomain.From("mountandblade2bannerlord");
Expand All @@ -53,21 +55,22 @@ public sealed class MountAndBlade2Bannerlord : AGame, ISteamGame, IGogGame, IEpi
public IEnumerable<string> XboxIds => ["TaleWorldsEntertainment.MountBladeIIBannerlord"];

public override IStreamFactory Icon =>
new EmbededResourceStreamFactory<MountAndBlade2Bannerlord>("NexusMods.Games.MountAndBlade2Bannerlord.Resources.icon.jpg");
new EmbededResourceStreamFactory<Bannerlord>("NexusMods.Games.MountAndBlade2Bannerlord.Resources.icon.png");

public override IStreamFactory GameImage =>
new EmbededResourceStreamFactory<MountAndBlade2Bannerlord>("NexusMods.Games.MountAndBlade2Bannerlord.Resources.game_image.jpg");
new EmbededResourceStreamFactory<Bannerlord>("NexusMods.Games.MountAndBlade2Bannerlord.Resources.game_image.jpg");

public override ILibraryItemInstaller[] LibraryItemInstallers =>
[
_serviceProvider.GetRequiredService<MountAndBlade2BannerlordModInstaller>(),
_serviceProvider.GetRequiredService<BannerlordModInstaller>(),
];
public override IDiagnosticEmitter[] DiagnosticEmitters =>
[
new MountAndBlade2BannerlordDiagnosticEmitter(_serviceProvider),
new BannerlordDiagnosticEmitter(_serviceProvider),
new MissingProtontricksEmitter(_serviceProvider),
];

public MountAndBlade2Bannerlord(IServiceProvider serviceProvider, LauncherManagerFactory launcherManagerFactory) : base(serviceProvider)
public Bannerlord(IServiceProvider serviceProvider, LauncherManagerFactory launcherManagerFactory) : base(serviceProvider)
{
_serviceProvider = serviceProvider;
_launcherManagerFactory = launcherManagerFactory;
Expand All @@ -77,8 +80,11 @@ public MountAndBlade2Bannerlord(IServiceProvider serviceProvider, LauncherManage

protected override Version GetVersion(GameLocatorResult installation)
{
var launcherManagerHandler = _launcherManagerFactory.Get(installation);
return Version.TryParse(launcherManagerHandler.GetGameVersion(), out var val) ? val : new Version();
// Note(sewer): Bannerlord can use prefixes on versions etc. ,we want to strip them out
// so we sanitize/parse with `ApplicationVersion`.
var bannerlordVerStr = Fetcher.GetVersion(installation.Path.ToString(), "TaleWorlds.Library.dll");
var versionStr = ApplicationVersion.TryParse(bannerlordVerStr, out var av) ? $"{av.Major}.{av.Minor}.{av.Revision}.{av.ChangeSet}" : "0.0.0.0";
return Version.TryParse(versionStr, out var val) ? val : new Version();
}

protected override IReadOnlyDictionary<LocationId, AbsolutePath> GetLocations(IFileSystem fileSystem, GameLocatorResult installation)
Expand All @@ -94,7 +100,7 @@ protected override IReadOnlyDictionary<LocationId, AbsolutePath> GetLocations(IF

protected override ILoadoutSynchronizer MakeSynchronizer(IServiceProvider provider)
{
return new MountAndBlade2BannerlordLoadoutSynchronizer(provider);
return new BannerlordLoadoutSynchronizer(provider);
}

public override List<IModInstallDestination> GetInstallDestinations(IReadOnlyDictionary<LocationId, AbsolutePath> locations)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace NexusMods.Games.MountAndBlade2Bannerlord;

public static class MountAndBlade2BannerlordConstants
public static class BannerlordConstants
{
public static readonly string DocumentsFolderName = "Mount and Blade II Bannerlord";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
using NexusMods.Games.MountAndBlade2Bannerlord.Models;
using NexusMods.MnemonicDB.Abstractions;
using NexusMods.Paths;
using static NexusMods.Games.MountAndBlade2Bannerlord.MountAndBlade2BannerlordConstants;
using static NexusMods.Games.MountAndBlade2Bannerlord.BannerlordConstants;

namespace NexusMods.Games.MountAndBlade2Bannerlord;

public class MountAndBlade2BannerlordLoadoutSynchronizer : ALoadoutSynchronizer
public class BannerlordLoadoutSynchronizer : ALoadoutSynchronizer
{
// Paths to known locations
private static GamePath GameGenegratedImGuiFile => new(LocationId.Game, "bin/Win64_Shipping_Client/imgui.ini");
Expand Down Expand Up @@ -79,13 +79,13 @@ public class MountAndBlade2BannerlordLoadoutSynchronizer : ALoadoutSynchronizer
"SubModule.xml", // Metadata about mods shipped with basegame.
};

public MountAndBlade2BannerlordLoadoutSynchronizer(IServiceProvider provider) : base(provider)
public BannerlordLoadoutSynchronizer(IServiceProvider provider) : base(provider)
{
var settingsManager = provider.GetRequiredService<ISettingsManager>();
_settings = settingsManager.Get<MountAndBlade2BannerlordSettings>();
_settings = settingsManager.Get<BannerlordSettings>();
}

private readonly MountAndBlade2BannerlordSettings _settings;
private readonly BannerlordSettings _settings;

public override bool IsIgnoredBackupPath(GamePath path)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using Bannerlord.LauncherManager.Utils;
using Bannerlord.ModuleManager;
using CliWrap;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NexusMods.Abstractions.GameLocators;
using NexusMods.Abstractions.Games;
using NexusMods.Abstractions.Loadouts;
using NexusMods.Games.Generic;
using static Bannerlord.LauncherManager.Constants;
namespace NexusMods.Games.MountAndBlade2Bannerlord;

/// <summary>
/// This is to run the game or SMAPI using the shell, which allows them to start their own console,
/// allowing users to interact with it.
/// </summary>
public class BannerlordRunGameTool : RunGameTool<Bannerlord>
{
private readonly ILogger<BannerlordRunGameTool> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly GameToolRunner _runner;

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

protected override bool UseShell { get; set; } = false;

public override async Task Execute(Loadout.ReadOnly loadout, CancellationToken cancellationToken, string[]? commandLineArgs)
{
commandLineArgs ??= [];

// We need to 'inject' the current set of enabled modules in addition to any existing parameters.
// This way, external arguments specified by outside entities are preserved.
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; }

var command = Cli.Wrap(exe.ToString())
.WithArguments(args)
.WithWorkingDirectory(exe.Parent.ToString());

// Note(sewer): We use the tool runner to execute an alternative process,
// but because the UI expects `RunGameTool`, we override the `RunGameTool's` executor
// with `GameToolRunner` implementation.
await _runner.ExecuteAsync(loadout, command, true, cancellationToken);
}

// TODO: Refactor Bannerlord.LauncherManager so we don't have to copy (some) of the commandline logic.
// (Since LauncherManager has dependencies on code we don't need/want to provide).
private async Task<string[]> GetBannerlordExeCommandlineArgs(Loadout.ReadOnly loadout, string[] commandLineArgs, CancellationToken cancellationToken)
{
// Set the (automatic) load order.
// Copied from Bannerlord.LauncherManager
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()
.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 @@ -2,18 +2,18 @@

namespace NexusMods.Games.MountAndBlade2Bannerlord;

public class MountAndBlade2BannerlordSettings : ISettings
public class BannerlordSettings : ISettings
{
public bool DoFullGameBackup { get; set; } = false;
public bool BetaSorting { get; set; } = false;


public static ISettingsBuilder Configure(ISettingsBuilder settingsBuilder)
{
return settingsBuilder.AddToUI<MountAndBlade2BannerlordSettings>(builder => builder
return settingsBuilder.AddToUI<BannerlordSettings>(builder => builder
.AddPropertyToUI(x => x.DoFullGameBackup, propertyBuilder => propertyBuilder
.AddToSection(Sections.Experimental)
.WithDisplayName($"Full game backup: {MountAndBlade2Bannerlord.DisplayName}")
.WithDisplayName($"Full game backup: {Bannerlord.DisplayName}")
.WithDescription("Backup all game folders, this will greatly increase disk space usage. Should only be changed before managing the game.")
.UseBooleanContainer()
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
using NexusMods.Abstractions.Diagnostics.Emitters;
using NexusMods.Abstractions.Loadouts;
using NexusMods.Abstractions.Resources;
using NexusMods.Games.MountAndBlade2Bannerlord.LauncherManager;
using NexusMods.Games.MountAndBlade2Bannerlord.Models;
namespace NexusMods.Games.MountAndBlade2Bannerlord.Diagnostics;

Expand All @@ -16,15 +15,14 @@ namespace NexusMods.Games.MountAndBlade2Bannerlord.Diagnostics;
///
/// Only caveat, is we get the current state from loadout, rather than from real disk.
/// </summary>
internal partial class MountAndBlade2BannerlordDiagnosticEmitter : ILoadoutDiagnosticEmitter
internal partial class BannerlordDiagnosticEmitter : ILoadoutDiagnosticEmitter
{
private readonly IResourceLoader<BannerlordModuleLoadoutItem.ReadOnly, ModuleInfoExtended> _manifestPipeline;
private readonly ILogger _logger;

public MountAndBlade2BannerlordDiagnosticEmitter(IServiceProvider serviceProvider)
public BannerlordDiagnosticEmitter(IServiceProvider serviceProvider)
{
serviceProvider.GetRequiredService<LauncherManagerFactory>();
_logger = serviceProvider.GetRequiredService<ILogger<MountAndBlade2BannerlordDiagnosticEmitter>>();
_logger = serviceProvider.GetRequiredService<ILogger<BannerlordDiagnosticEmitter>>();
_manifestPipeline = Pipelines.GetManifestPipeline(serviceProvider);
}

Expand All @@ -42,9 +40,16 @@ public async IAsyncEnumerable<Diagnostic> Diagnose(Loadout.ReadOnly loadout, Can
isEnabledDict[module.Item2] = isEnabled;
}

foreach (var moduleAndMod in modulesAndMods)
// TODO: HACK. Pretend base game modules are installed before we can properly ingest them.
foreach (var module in Hack.GetDummyBaseGameModules())
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
foreach (var moduleAndMod in isEnabledDict)
{
var (_, moduleInfo) = moduleAndMod;
var moduleInfo = moduleAndMod.Key;
// 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)))
Expand All @@ -54,6 +59,8 @@ public async IAsyncEnumerable<Diagnostic> Diagnose(Loadout.ReadOnly loadout, Can
}
}
}



private Diagnostic? CreateDiagnostic(ModuleIssueV2 issue)
{
Expand Down
Loading

0 comments on commit e50f0e4

Please sign in to comment.