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

BG3 Script Extender Health Checks #2206

Merged
merged 6 commits into from
Nov 8, 2024
Merged
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
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
Loading