Skip to content

Commit

Permalink
BG3 Script Extender Health Checks (#2206)
Browse files Browse the repository at this point in the history
* Parse bg3 script extender requirements from pak files

* WIP: Detect BG3SE

* WIP: find BG3SE mod

* Add diagnostic in case of missing Script Extender and mods requiring it

* Add diagnostic for BG3 Script Extender WINEOVERRIDE instructions
  • Loading branch information
Al12rs authored Nov 8, 2024
1 parent d08a52f commit 39b884f
Show file tree
Hide file tree
Showing 9 changed files with 383 additions and 172 deletions.
2 changes: 2 additions & 0 deletions src/Games/NexusMods.Games.Larian/BaldursGate3/BG3Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ public static class Bg3Constants
public static readonly Extension PakFileExtension = new(".pak");

public static readonly LocationId ModsLocationId = LocationId.From("Mods");

public static readonly GamePath BG3SEGamePath = new(LocationId.Game, "bin/DWrite.dll");
}
74 changes: 56 additions & 18 deletions src/Games/NexusMods.Games.Larian/BaldursGate3/Diagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,8 @@ namespace NexusMods.Games.Larian.BaldursGate3;
internal static partial class Diagnostics
{
private const string Source = "NexusMods.Games.Larian.BaldursGate3";

[DiagnosticTemplate]
[UsedImplicitly]
internal static IDiagnosticTemplate MissingDependencyTemplate = DiagnosticTemplateBuilder

[DiagnosticTemplate] [UsedImplicitly] internal static IDiagnosticTemplate MissingDependencyTemplate = DiagnosticTemplateBuilder
.Start()
.WithId(new DiagnosticId(Source, number: 1))
.WithTitle("Missing required dependency")
Expand All @@ -29,7 +27,8 @@ internal static partial class Diagnostics
#### Or
#### Check the required mods section on {ModName} Nexus Mods page
Mod pages can contain useful installation instructions in the 'Description' tab, this tab will also include requirements the mod needs to work correctly.
""")
"""
)
.WithMessageData(messageBuilder => messageBuilder
.AddDataReference<LoadoutItemGroupReference>("ModName")
.AddValue<string>("MissingDepName")
Expand All @@ -39,18 +38,17 @@ internal static partial class Diagnostics
.AddValue<NamedLink>("NexusModsLink")
)
.Finish();

[DiagnosticTemplate]
[UsedImplicitly]
internal static IDiagnosticTemplate OutdatedDependencyTemplate = DiagnosticTemplateBuilder

[DiagnosticTemplate] [UsedImplicitly] internal static IDiagnosticTemplate OutdatedDependencyTemplate = DiagnosticTemplateBuilder
.Start()
.WithId(new DiagnosticId(Source, number: 1))
.WithId(new DiagnosticId(Source, number: 2))
.WithTitle("Required dependency is outdated")
.WithSeverity(DiagnosticSeverity.Warning)
.WithSummary("Mod {ModName} requires at least version {MinDepVersion}+ of '{DepName}' but only v{CurrentDepVersion} is installed.")
.WithDetails("""
'{PakModuleName}' v{PakModuleVersion} requires at least version {MinDepVersion}+ of '{DepName}' to run correctly. However, you only have version v{CurrentDepVersion} installed in mod {ModName}.
""")
"""
)
.WithMessageData(messageBuilder => messageBuilder
.AddDataReference<LoadoutItemGroupReference>("ModName")
.AddValue<string>("PakModuleName")
Expand All @@ -61,13 +59,11 @@ internal static partial class Diagnostics
.AddValue<string>("CurrentDepVersion")
)
.Finish();


[DiagnosticTemplate]
[UsedImplicitly]
internal static IDiagnosticTemplate InvalidPakFileTemplate = DiagnosticTemplateBuilder


[DiagnosticTemplate] [UsedImplicitly] internal static IDiagnosticTemplate InvalidPakFileTemplate = DiagnosticTemplateBuilder
.Start()
.WithId(new DiagnosticId(Source, number: 1))
.WithId(new DiagnosticId(Source, number: 3))
.WithTitle("Invalid pak file")
.WithSeverity(DiagnosticSeverity.Warning)
.WithSummary("Invalid .pak File Detected in {ModName}")
Expand All @@ -78,11 +74,53 @@ internal static partial class Diagnostics

## Recommended Actions
Verify that the file is installed in the intended location and that it wasn't altered or corrupted. You may need to remove or reinstall the mod, consulting the mod's instructions for proper installation.
""")
"""
)
.WithMessageData(messageBuilder => messageBuilder
.AddDataReference<LoadoutItemGroupReference>("ModName")
.AddValue<string>("PakFileName")
)
.Finish();

[DiagnosticTemplate] [UsedImplicitly] internal static IDiagnosticTemplate MissingRequiredScriptExtenderTemplate = DiagnosticTemplateBuilder
.Start()
.WithId(new DiagnosticId(Source, number: 4))
.WithTitle("Missing Script Extender")
.WithSeverity(DiagnosticSeverity.Warning)
.WithSummary("Missing BG3 Script Extender, required by {ModName}")
.WithDetails("""
The .pak file {PakName} lists the Baldur's Gate 3 Script Extender (BG3SE) as a dependency, but it isn't installed.

## Recommended Actions
Install the BG3 Script Extender from {BG3SENexusLink} or from the official source.
"""
)
.WithMessageData(messageBuilder => messageBuilder
.AddDataReference<LoadoutItemGroupReference>("ModName")
.AddValue<string>("PakName")
.AddValue<NamedLink>("BG3SENexusLink")
)
.Finish();

[DiagnosticTemplate] [UsedImplicitly] internal static IDiagnosticTemplate Bg3SeWineDllOverrideSteamTemplate = DiagnosticTemplateBuilder
.Start()
.WithId(new DiagnosticId(Source, number: 5))
.WithTitle("BG3SE Wine DLL Override required for Steam")
.WithSeverity(DiagnosticSeverity.Suggestion)
.WithSummary("BG3SE Requires WINEDLLOVERRIDE Environment variable to be set")
.WithDetails("""
In Linux Wine environments, the BG3 Script Extender (BG3SE) requires WINEDLLOVERRIDE environment to contain `"DWrite=n,b"` to work correctly.
Please ensure you have this set correctly in your BG3 Steam properties, under Launch Options:
`WINEDLLOVERRIDES="DWrite=n,b" %command%`


## Details:
BG3SE adds `DWrite.dll` file to the game folder, which replaces a Windows system dll normally located in windows system folders.
On windows the game will automatically load the dll file from the game folder if present, preferring that over the system one.
On Wine, to achieve the same effect, you need to set the WINEDLLOVERRIDE environment variable to tell Wine to load the game's DWrite.dll instead of the system one.
"""

)
.WithMessageData(messageBuilder => messageBuilder.AddValue<string>("Template"))
.Finish();
}
Original file line number Diff line number Diff line change
@@ -1,32 +1,48 @@
using System.Runtime.CompilerServices;
using DynamicData.Kernel;
using Microsoft.Extensions.Logging;
using NexusMods.Abstractions.Diagnostics;
using NexusMods.Abstractions.Diagnostics.Emitters;
using NexusMods.Abstractions.Diagnostics.References;
using NexusMods.Abstractions.Diagnostics.Values;
using NexusMods.Abstractions.Games.Stores.Steam;
using NexusMods.Abstractions.Loadouts;
using NexusMods.Abstractions.Loadouts.Extensions;
using NexusMods.Abstractions.Resources;
using NexusMods.Abstractions.Telemetry;
using NexusMods.Games.Larian.BaldursGate3.Utils.LsxXmlParsing;
using NexusMods.Hashing.xxHash3;
using NexusMods.Games.Larian.BaldursGate3.Utils.PakParsing;
using NexusMods.Paths;
using Polly;

namespace NexusMods.Games.Larian.BaldursGate3.Emitters;

public class DependencyDiagnosticEmitter : ILoadoutDiagnosticEmitter
{
private readonly ILogger _logger;
private readonly IResourceLoader<Hash, Outcome<LsxXmlFormat.MetaFileData>> _metadataPipeline;
private readonly IResourceLoader<Hash, Outcome<LspkPackageFormat.PakMetaData>> _metadataPipeline;
private readonly IOSInformation _os;

public DependencyDiagnosticEmitter(IServiceProvider serviceProvider, ILogger<DependencyDiagnosticEmitter> logger)
public DependencyDiagnosticEmitter(IServiceProvider serviceProvider, ILogger<DependencyDiagnosticEmitter> logger, IOSInformation os)
{
_logger = logger;
_metadataPipeline = Pipelines.GetMetadataPipeline(serviceProvider);
_os = os;
}

public async IAsyncEnumerable<Diagnostic> Diagnose(Loadout.ReadOnly loadout, [EnumeratorCancellation] CancellationToken cancellationToken)
{
var diagnostics = await DiagnosePakModulesAsync(loadout, cancellationToken);
var bg3LoadoutFile = GetScriptExtenderLoadoutFile(loadout);

// BG3SE WINEDLLOVERRIDE diagnostic
if (_os.IsLinux && bg3LoadoutFile.HasValue && loadout.InstallationInstance.LocatorResultMetadata is SteamLocatorResultMetadata)
{
// yield return Diagnostics.
yield return Diagnostics.CreateBg3SeWineDllOverrideSteam(Template: "text");
}

var diagnostics = await DiagnosePakModulesAsync(loadout, bg3LoadoutFile, cancellationToken);
foreach (var diagnostic in diagnostics)
{
yield return diagnostic;
Expand All @@ -35,7 +51,7 @@ public async IAsyncEnumerable<Diagnostic> Diagnose(Loadout.ReadOnly loadout, [En

#region Diagnosers

private async Task<IEnumerable<Diagnostic>> DiagnosePakModulesAsync(Loadout.ReadOnly loadout, CancellationToken cancellationToken)
private async Task<IEnumerable<Diagnostic>> DiagnosePakModulesAsync(Loadout.ReadOnly loadout, Optional<LoadoutFile.ReadOnly> bg3LoadoutFile, CancellationToken cancellationToken)
{
var pakLoadoutFiles = GetAllPakLoadoutFiles(loadout, onlyEnabledMods: true);
var metaFileTuples = await GetAllPakMetadata(pakLoadoutFiles,
Expand Down Expand Up @@ -64,9 +80,21 @@ private async Task<IEnumerable<Diagnostic>> DiagnosePakModulesAsync(Loadout.Read
}

// non error case
var metadata = metadataOrError.Result;
var dependencies = metadata.Dependencies;
var pakMetaData = metadataOrError.Result;

// check if the mod requires the script extender and if SE is missing
if (pakMetaData.ScriptExtenderConfigMetadata is { HasValue: true, Value.RequiresScriptExtender: true } &&
!bg3LoadoutFile.HasValue)
{
diagnostics.Add(Diagnostics.CreateMissingRequiredScriptExtender(
ModName: loadoutItemGroup.ToReference(loadout),
PakName:pakMetaData.MetaFileData.ModuleShortDesc.Name,
BG3SENexusLink:BG3SENexusModsLink));
}


// check dependencies
var dependencies = pakMetaData.MetaFileData.Dependencies;
foreach (var dependency in dependencies)
{
var dependencyUuid = dependency.Uuid;
Expand All @@ -76,7 +104,7 @@ private async Task<IEnumerable<Diagnostic>> DiagnosePakModulesAsync(Loadout.Read
var matchingDeps = metaFileTuples.Where(
x =>
x.Item2.Exception is null &&
x.Item2.Result.ModuleShortDesc.Uuid == dependencyUuid
x.Item2.Result.MetaFileData.ModuleShortDesc.Uuid == dependencyUuid
)
.ToArray();

Expand All @@ -87,8 +115,8 @@ x.Item2.Exception is null &&
ModName: loadoutItemGroup.ToReference(loadout),
MissingDepName: dependency.Name,
MissingDepVersion: dependency.SemanticVersion.ToString(),
PakModuleName: metadata.ModuleShortDesc.Name,
PakModuleVersion: metadata.ModuleShortDesc.SemanticVersion.ToString(),
PakModuleName: pakMetaData.MetaFileData.ModuleShortDesc.Name,
PakModuleVersion: pakMetaData.MetaFileData.ModuleShortDesc.SemanticVersion.ToString(),
NexusModsLink: NexusModsLink
)
);
Expand All @@ -99,18 +127,18 @@ x.Item2.Exception is null &&
continue;

var highestInstalledMatch = matchingDeps.MaxBy(
x => x.Item2.Result.ModuleShortDesc.SemanticVersion
x => x.Item2.Result.MetaFileData.ModuleShortDesc.SemanticVersion
);
var installedMatchModule = highestInstalledMatch.Item2.Result.ModuleShortDesc;
var installedMatchModule = highestInstalledMatch.Item2.Result.MetaFileData.ModuleShortDesc;
var matchLoadoutItemGroup = highestInstalledMatch.Item1.AsLoadoutItemWithTargetPath().AsLoadoutItem().Parent;

// Check if found dependency is outdated
if (installedMatchModule.SemanticVersion < dependency.SemanticVersion)
{
diagnostics.Add(Diagnostics.CreateOutdatedDependency(
ModName: loadoutItemGroup.ToReference(loadout),
PakModuleName: metadata.ModuleShortDesc.Name,
PakModuleVersion: metadata.ModuleShortDesc.SemanticVersion.ToString(),
PakModuleName: pakMetaData.MetaFileData.ModuleShortDesc.Name,
PakModuleVersion: pakMetaData.MetaFileData.ModuleShortDesc.SemanticVersion.ToString(),
DepModName: matchLoadoutItemGroup.ToReference(loadout),
DepName: installedMatchModule.Name,
MinDepVersion: dependency.SemanticVersion.ToString(),
Expand All @@ -128,15 +156,28 @@ x.Item2.Exception is null &&

#region Helpers

private static async IAsyncEnumerable<ValueTuple<LoadoutFile.ReadOnly, Outcome<LsxXmlFormat.MetaFileData>>> GetAllPakMetadata(
private static Optional<LoadoutFile.ReadOnly> GetScriptExtenderLoadoutFile(Loadout.ReadOnly loadout)
{
return loadout.Items.OfTypeLoadoutItemGroup()
.Where(g => g.AsLoadoutItem().IsEnabled())
.SelectMany(group => group.Children.OfTypeLoadoutItemWithTargetPath()
.OfTypeLoadoutFile()
.Where(file => file.AsLoadoutItemWithTargetPath().TargetPath.Item2 == Bg3Constants.BG3SEGamePath.LocationId &&
file.AsLoadoutItemWithTargetPath().TargetPath.Item3 == Bg3Constants.BG3SEGamePath.Path
)
).FirstOrOptional(_ => true);
}


private static async IAsyncEnumerable<ValueTuple<LoadoutFile.ReadOnly, Outcome<LspkPackageFormat.PakMetaData>>> GetAllPakMetadata(
LoadoutFile.ReadOnly[] pakLoadoutFiles,
IResourceLoader<Hash, Outcome<LsxXmlFormat.MetaFileData>> metadataPipeline,
IResourceLoader<Hash, Outcome<LspkPackageFormat.PakMetaData>> metadataPipeline,
ILogger logger,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (var pakLoadoutFile in pakLoadoutFiles)
{
Resource<Outcome<LsxXmlFormat.MetaFileData>> resource;
Resource<Outcome<LspkPackageFormat.PakMetaData>> resource;
try
{
resource = await metadataPipeline.LoadResourceAsync(pakLoadoutFile.Hash, cancellationToken);
Expand Down Expand Up @@ -175,6 +216,7 @@ private static LoadoutFile.ReadOnly[] GetAllPakLoadoutFiles(
}

private static readonly NamedLink NexusModsLink = new("Nexus Mods - Baldur's Gate 3", NexusModsUrlBuilder.CreateGenericUri("https://nexusmods.com/baldursgate3"));
private static readonly NamedLink BG3SENexusModsLink = new("Nexus Mods", NexusModsUrlBuilder.CreateGenericUri("https://www.nexusmods.com/baldursgate3/mods/2172"));

#endregion Helpers
}
11 changes: 5 additions & 6 deletions src/Games/NexusMods.Games.Larian/BaldursGate3/Pipelines.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
using NexusMods.Abstractions.Resources;
using NexusMods.Abstractions.Resources.Caching;
using NexusMods.Abstractions.Resources.IO;
using NexusMods.Games.Larian.BaldursGate3.Utils.LsxXmlParsing;
using NexusMods.Games.Larian.BaldursGate3.Utils.PakParsing;
using NexusMods.Hashing.xxHash3;
using Polly;
Expand All @@ -18,21 +17,21 @@ public static class Pipelines

public static IServiceCollection AddPipelines(this IServiceCollection serviceCollection)
{
return serviceCollection.AddKeyedSingleton<IResourceLoader<Hash, Outcome<LsxXmlFormat.MetaFileData>>>(
return serviceCollection.AddKeyedSingleton<IResourceLoader<Hash, Outcome<LspkPackageFormat.PakMetaData>>>(
serviceKey: MetadataPipelineKey,
implementationFactory: static (serviceProvider, _) => CreateMetadataPipeline(
fileStore: serviceProvider.GetRequiredService<IFileStore>()
)
);
}

public static IResourceLoader<Hash, Outcome<LsxXmlFormat.MetaFileData>> GetMetadataPipeline(IServiceProvider serviceProvider)
public static IResourceLoader<Hash, Outcome<LspkPackageFormat.PakMetaData>> GetMetadataPipeline(IServiceProvider serviceProvider)
{
return serviceProvider.GetRequiredKeyedService<IResourceLoader<Hash,
Outcome<LsxXmlFormat.MetaFileData>>>(serviceKey: MetadataPipelineKey);
Outcome<LspkPackageFormat.PakMetaData>>>(serviceKey: MetadataPipelineKey);
}

private static IResourceLoader<Hash, Outcome<LsxXmlFormat.MetaFileData>> CreateMetadataPipeline(IFileStore fileStore)
private static IResourceLoader<Hash, Outcome<LspkPackageFormat.PakMetaData>> CreateMetadataPipeline(IFileStore fileStore)
{
// TODO: change pipeline to return C# 9 type unions instead of OneOf
var pipeline = new FileStoreStreamLoader(fileStore)
Expand All @@ -46,7 +45,7 @@ public static IServiceCollection AddPipelines(this IServiceCollection serviceCol
}
catch (InvalidDataException e)
{
return ValueTask.FromResult(resource.WithData(Outcome.FromException<LsxXmlFormat.MetaFileData>(e)));
return ValueTask.FromResult(resource.WithData(Outcome.FromException<LspkPackageFormat.PakMetaData>(e)));
}
}
)
Expand Down
Loading

0 comments on commit 39b884f

Please sign in to comment.