Skip to content

Commit

Permalink
Merge pull request #2175 from Nexus-Mods/feat/bg3-health-checks
Browse files Browse the repository at this point in the history
BG3 Health Checks and improvements
  • Loading branch information
Al12rs authored Oct 22, 2024
2 parents 5e6b022 + 3fe4c84 commit a661a10
Show file tree
Hide file tree
Showing 18 changed files with 377 additions and 69 deletions.
45 changes: 40 additions & 5 deletions docs/developers/games/0003-BaldursGate3.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,37 @@ BG3 was released in Early Access in 2020, and thanks to the similarities with DO
BG3 has native Windows and MacOS support, but Linux users can play it using Wine.



## Game Files and Locations
### Windows/Wine:
Two executables: `bg3.exe` and `bg3_dx11.exe` in `Baldurs Gate 3/Bin`. One for Vulkan, one for DirectX 11.
Game launcher in `Baldurs Gate 3\Launcher\LariLauncher.exe`
Two actual game executables: `bg3.exe` and `bg3_dx11.exe` in `Baldurs Gate 3/Bin`. One for Vulkan, one for DirectX 11.
Game settings and load order are stored in `%localappdata%\Larian Studios\Baldur's Gate 3`.
Majority of mods are stored in `%localappdata%\Larian Studios\Baldur's Gate 3\Mods`.
Load order is stored in `%localappdata%\Larian Studios\Baldur's Gate 3\PlayerProfiles\Public\modsettings.lsx`.

### MacOS:
TBD
Only one executable `Baldur's Gate 3.app/Contents/MacOS/Baldur's Gate 3`
For more info on game folder: https://steamdb.info/depot/1419660/
Equivalent for Appdata and Mods folder TBD.

### Vulkan and DirectX11:
On windows and Wine, the game Launcher allows users to choose between running the Vulkan or DirectX11 version of the game.
Using one or the other can affect game performance depending on the system. Additionally, some mods such as Texture replacers, may require one or the other.

It is important to allow users know and choose which version of the game they are running, as mods may not work correctly if the wrong version is used.
Running the Launcher allows users to choose the version of the game to run at the cost of a longer startup time.

The launcher supports the following command line arguments:
- `--skip-launcher` Skips the launcher and launches the game with whatever was last selected.
- `--vulkan` Launches the game with Vulkan.
- `--dx11` Launches the game with DirectX11.

The Launcher stores the last selected version in `AppData\Local\Larian Studios\Launcher\Settings\preferences.json`.
If `"DefaultRenderingBackend": 0` exists, Vulkan is loaded, if the property is set to anything other than 0, or is missing, DirectX11 is loaded and the property is removed.

On Steam running any of the executables will prompt the game to be launched from Steam instead, which will then in turn run the Launcher.

On GOG, running the executables will launch the game directly, bypassing the Launcher, because there is no Steam-like DRM looping back to the Launcher.


## Mod formats:
### BG3 Script Extender (BG3SE)
Expand Down Expand Up @@ -267,7 +288,21 @@ Since there is no evident way to distinguish between vanilla and mod pak files,
## Essential Mods & Tools
- BG3SE
Requirement for a lot of mods, but not allowed for Modio mods.
New scripting capabilities (osiris scripting) added in patch 7 may reduce the need for BG3SE in the future.
Requires additional steps to work on Linux(Wine): https://wiki.bg3.community/en/Tutorials/Mod-Use/How-to-install-Script-Extender#h-3-install-script-extender-on-linuxsteam-deck

Pak mods that use the Script Extender will contain a ScriptExtender folder with a `config.json` config file with a format similar to this:
```json
{
"RequiredVersion": 19,
"ModTable": "WaypointInsideEmeraldGrove",
"FeatureFlags": [
"Lua"
]
}
```
`RequiredVersion` indicates the minimum version of the Script Extender required for the mod to work.
Details on schema: https://github.com/Norbyte/bg3se/blob/updater-20240329/Docs/API.md


For mod authors:
- BG3 Toolkit: https://store.steampowered.com/app/2956320/Baldurs_Gate_3_Toolkit_Data/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ public override GamePath GetPrimaryFile(GameStore store)
{
if (_osInformation.IsOSX)
return new GamePath(LocationId.Game, "Contents/MacOS/Baldur's Gate 3");
return new GamePath(LocationId.Game, "bin/bg3.exe");

// Use launcher to allow choosing between DirectX11 and Vulkan on GOG, Steam already always starts the launcher
return new GamePath(LocationId.Game, "Launcher/LariLauncher.exe");
}

protected override IReadOnlyDictionary<LocationId, AbsolutePath> GetLocations(IFileSystem fileSystem, GameLocatorResult installation)
Expand Down
63 changes: 58 additions & 5 deletions src/Games/NexusMods.Games.Larian/BaldursGate3/Diagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,71 @@ internal static partial class Diagnostics
.WithId(new DiagnosticId(Source, number: 1))
.WithTitle("Missing required dependency")
.WithSeverity(DiagnosticSeverity.Warning)
.WithSummary("Mod {PakMod} is missing required dependency '{MissingDependencyName}'.")
.WithSummary("The mod {ModName} is missing the required dependency '{MissingDepName}' v{MissingDepVersion}+.")
.WithDetails("""
'{MissingDependencyName}' is required by '{PakModuleName}' but is not present in the loadout.
'{MissingDepName}' v{MissingDepVersion}+ is not installed or enabled in your Loadout. This pak module is required by '{PakModuleName}' v{PakModuleVersion} to run correctly.

You can try to search the missing mod on {NexusModsLink} or using the in-game mod manager.

## Recommended Actions
#### Search for and install the missing mod
You can search for '{MissingDepName}' on {NexusModsLink} or search the in-game mod manager.
#### 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>("PakMod")
.AddValue<string>("MissingDependencyName")
.AddDataReference<LoadoutItemGroupReference>("ModName")
.AddValue<string>("MissingDepName")
.AddValue<string>("MissingDepVersion")
.AddValue<string>("PakModuleName")
.AddValue<string>("PakModuleVersion")
.AddValue<NamedLink>("NexusModsLink")
)
.Finish();

[DiagnosticTemplate]
[UsedImplicitly]
internal static IDiagnosticTemplate OutdatedDependencyTemplate = DiagnosticTemplateBuilder
.Start()
.WithId(new DiagnosticId(Source, number: 1))
.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")
.AddValue<string>("PakModuleVersion")
.AddDataReference<LoadoutItemGroupReference>("DepModName")
.AddValue<string>("DepName")
.AddValue<string>("MinDepVersion")
.AddValue<string>("CurrentDepVersion")
)
.Finish();


[DiagnosticTemplate]
[UsedImplicitly]
internal static IDiagnosticTemplate InvalidPakFileTemplate = DiagnosticTemplateBuilder
.Start()
.WithId(new DiagnosticId(Source, number: 1))
.WithTitle("Invalid pak file")
.WithSeverity(DiagnosticSeverity.Warning)
.WithSummary("Invalid .pak File Detected in {ModName}")
.WithDetails("""
The mod contains a .pak file, typically used to store mod data for Baldur's Gate 3. However,
this one appears to be invalid or incompatible: '{PakFileName}'.


## 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();

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@
using NexusMods.Abstractions.Loadouts;
using NexusMods.Abstractions.Resources;
using NexusMods.Abstractions.Telemetry;
using NexusMods.Games.Larian.BaldursGate3.Utils.PakParsing;
using NexusMods.Games.Larian.BaldursGate3.Utils.LsxXmlParsing;
using NexusMods.Hashing.xxHash64;
using Polly;

namespace NexusMods.Games.Larian.BaldursGate3.Emitters;

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

public DependencyDiagnosticEmitter(IServiceProvider serviceProvider, ILogger<DependencyDiagnosticEmitter> logger)
{
Expand All @@ -25,7 +26,7 @@ public DependencyDiagnosticEmitter(IServiceProvider serviceProvider, ILogger<Dep

public async IAsyncEnumerable<Diagnostic> Diagnose(Loadout.ReadOnly loadout, [EnumeratorCancellation] CancellationToken cancellationToken)
{
var diagnostics = await DiagnoseDependenciesAsync(loadout, cancellationToken);
var diagnostics = await DiagnosePakModulesAsync(loadout, cancellationToken);
foreach (var diagnostic in diagnostics)
{
yield return diagnostic;
Expand All @@ -34,10 +35,10 @@ public async IAsyncEnumerable<Diagnostic> Diagnose(Loadout.ReadOnly loadout, [En

#region Diagnosers

private async Task<IEnumerable<Diagnostic>> DiagnoseDependenciesAsync(Loadout.ReadOnly loadout, CancellationToken cancellationToken)
private async Task<IEnumerable<Diagnostic>> DiagnosePakModulesAsync(Loadout.ReadOnly loadout, CancellationToken cancellationToken)
{
var pakLoadoutFiles = GetAllPakLoadoutFiles(loadout, onlyEnabledMods: true);
var allFileMetadata = await GetAllPakMetadata(pakLoadoutFiles,
var metaFileTuples = await GetAllPakMetadata(pakLoadoutFiles,
_metadataPipeline,
_logger,
cancellationToken
Expand All @@ -46,28 +47,77 @@ private async Task<IEnumerable<Diagnostic>> DiagnoseDependenciesAsync(Loadout.Re

var diagnostics = new List<Diagnostic>();

foreach (var metaFileData in allFileMetadata)
foreach (var metaFileTuple in metaFileTuples)
{
var dependencies = metaFileData.Item2.Dependencies;
var loadoutItemGroup = metaFileData.Item1.AsLoadoutItemWithTargetPath().AsLoadoutItem().Parent;
var (mod, metadataOrError) = metaFileTuple;
var loadoutItemGroup = mod.AsLoadoutItemWithTargetPath().AsLoadoutItem().Parent;

// error case
if (metadataOrError.Exception is not null)
{
diagnostics.Add(Diagnostics.CreateInvalidPakFile(
ModName: loadoutItemGroup.ToReference(loadout),
PakFileName: mod.AsLoadoutItemWithTargetPath().TargetPath.Item3.FileName
)
);
continue;
}

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

foreach (var dependency in dependencies)
{
var dependencyUuid = dependency.Uuid;
if (dependencyUuid == string.Empty)
continue;

var matchingDeps = metaFileTuples.Where(
x =>
x.Item2.Exception is null &&
x.Item2.Result.ModuleShortDesc.Uuid == dependencyUuid
)
.ToArray();

if (dependencyUuid == string.Empty || allFileMetadata.Any(x => x.Item2.ModuleShortDesc.Uuid == dependencyUuid))
if (matchingDeps.Length == 0)
{
// Missing dependency
diagnostics.Add(Diagnostics.CreateMissingDependency(
ModName: loadoutItemGroup.ToReference(loadout),
MissingDepName: dependency.Name,
MissingDepVersion: dependency.SemanticVersion.ToString(),
PakModuleName: metadata.ModuleShortDesc.Name,
PakModuleVersion: metadata.ModuleShortDesc.SemanticVersion.ToString(),
NexusModsLink: NexusModsLink
)
);
continue;
}

// add diagnostic
diagnostics.Add(Diagnostics.CreateMissingDependency(
PakMod: loadoutItemGroup.ToReference(loadout),
MissingDependencyName: dependency.Name,
PakModuleName: metaFileData.Item2.ModuleShortDesc.Name,
NexusModsLink: NexusModsLink
)
if (dependency.SemanticVersion == default(LsxXmlFormat.ModuleVersion))
continue;

var highestInstalledMatch = matchingDeps.MaxBy(
x => x.Item2.Result.ModuleShortDesc.SemanticVersion
);
var installedMatchModule = highestInstalledMatch.Item2.Result.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(),
DepModName: matchLoadoutItemGroup.ToReference(loadout),
DepName: installedMatchModule.Name,
MinDepVersion: dependency.SemanticVersion.ToString(),
CurrentDepVersion: installedMatchModule.SemanticVersion.ToString()
)
);
}
}
}

Expand All @@ -78,15 +128,15 @@ private async Task<IEnumerable<Diagnostic>> DiagnoseDependenciesAsync(Loadout.Re

#region Helpers

private static async IAsyncEnumerable<ValueTuple<LoadoutFile.ReadOnly, LsxXmlFormat.MetaFileData>> GetAllPakMetadata(
private static async IAsyncEnumerable<ValueTuple<LoadoutFile.ReadOnly, Outcome<LsxXmlFormat.MetaFileData>>> GetAllPakMetadata(
LoadoutFile.ReadOnly[] pakLoadoutFiles,
IResourceLoader<Hash, LsxXmlFormat.MetaFileData> metadataPipeline,
IResourceLoader<Hash, Outcome<LsxXmlFormat.MetaFileData>> metadataPipeline,
ILogger logger,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (var pakLoadoutFile in pakLoadoutFiles)
{
Resource<LsxXmlFormat.MetaFileData> resource;
Resource<Outcome<LsxXmlFormat.MetaFileData>> resource;
try
{
resource = await metadataPipeline.LoadResourceAsync(pakLoadoutFile.Hash, cancellationToken);
Expand All @@ -97,6 +147,12 @@ private async Task<IEnumerable<Diagnostic>> DiagnoseDependenciesAsync(Loadout.Re
continue;
}

// Log the InvalidDataException case, but still return the resource
if (resource.Data.Exception is not null)
{
logger.LogWarning(resource.Data.Exception, "Detected invalid BG3 Pak file: `{Name}`", pakLoadoutFile.AsLoadoutItemWithTargetPath().TargetPath.Item3);
}

yield return (pakLoadoutFile, resource.Data);
}
}
Expand All @@ -118,7 +174,7 @@ private static LoadoutFile.ReadOnly[] GetAllPakLoadoutFiles(
.ToArray();
}

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

#endregion Helpers
}
30 changes: 20 additions & 10 deletions src/Games/NexusMods.Games.Larian/BaldursGate3/Pipelines.cs
Original file line number Diff line number Diff line change
@@ -1,51 +1,61 @@
using System.Reactive;
using System.Text;
using BitFaster.Caching.Lru;
using Microsoft.Extensions.DependencyInjection;
using NexusMods.Abstractions.IO;
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.xxHash64;
using Polly;

namespace NexusMods.Games.Larian.BaldursGate3;

public static class Pipelines
{
public const string MetadataPipelineKey = nameof(MetadataPipelineKey);

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

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

private static IResourceLoader<Hash, LsxXmlFormat.MetaFileData> CreateMetadataPipeline(IFileStore fileStore)
private static IResourceLoader<Hash, Outcome<LsxXmlFormat.MetaFileData>> CreateMetadataPipeline(IFileStore fileStore)
{
// TODO: change pipeline to return C# 9 type unions instead of OneOf
var pipeline = new FileStoreStreamLoader(fileStore)
.ThenDo(Unit.Default,
static (_, _, resource, _) =>
{
var metaFileData = PakFileParser.ParsePakMeta(resource.Data);
return ValueTask.FromResult(resource.WithData(metaFileData));
try
{
var metaFileData = PakFileParser.ParsePakMeta(resource.Data);
return ValueTask.FromResult(resource.WithData(Outcome.FromResult(metaFileData)));
}
catch (InvalidDataException e)
{
return ValueTask.FromResult(resource.WithData(Outcome.FromException<LsxXmlFormat.MetaFileData>(e)));
}
}
)
.UseCache(
keyGenerator: static hash => hash,
keyComparer: EqualityComparer<Hash>.Default,
capacityPartition: new FavorWarmPartition(totalCapacity: 300)
);

return pipeline;
}
}
Loading

0 comments on commit a661a10

Please sign in to comment.