diff --git a/src/Games/NexusMods.Games.Larian/BaldursGate3/BG3Constants.cs b/src/Games/NexusMods.Games.Larian/BaldursGate3/BG3Constants.cs index ef3811fa2..c23ad70fd 100644 --- a/src/Games/NexusMods.Games.Larian/BaldursGate3/BG3Constants.cs +++ b/src/Games/NexusMods.Games.Larian/BaldursGate3/BG3Constants.cs @@ -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"); } diff --git a/src/Games/NexusMods.Games.Larian/BaldursGate3/Diagnostics.cs b/src/Games/NexusMods.Games.Larian/BaldursGate3/Diagnostics.cs index 0ddf47ef3..0d247712a 100644 --- a/src/Games/NexusMods.Games.Larian/BaldursGate3/Diagnostics.cs +++ b/src/Games/NexusMods.Games.Larian/BaldursGate3/Diagnostics.cs @@ -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") @@ -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("ModName") .AddValue("MissingDepName") @@ -39,18 +38,17 @@ internal static partial class Diagnostics .AddValue("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("ModName") .AddValue("PakModuleName") @@ -61,13 +59,11 @@ internal static partial class Diagnostics .AddValue("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}") @@ -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("ModName") .AddValue("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("ModName") + .AddValue("PakName") + .AddValue("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("Template")) + .Finish(); } diff --git a/src/Games/NexusMods.Games.Larian/BaldursGate3/Emitters/DependencyDiagnosticEmitter.cs b/src/Games/NexusMods.Games.Larian/BaldursGate3/Emitters/DependencyDiagnosticEmitter.cs index 01c3e4af9..0a9d43ba8 100644 --- a/src/Games/NexusMods.Games.Larian/BaldursGate3/Emitters/DependencyDiagnosticEmitter.cs +++ b/src/Games/NexusMods.Games.Larian/BaldursGate3/Emitters/DependencyDiagnosticEmitter.cs @@ -1,14 +1,19 @@ 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; @@ -16,17 +21,28 @@ namespace NexusMods.Games.Larian.BaldursGate3.Emitters; public class DependencyDiagnosticEmitter : ILoadoutDiagnosticEmitter { private readonly ILogger _logger; - private readonly IResourceLoader> _metadataPipeline; + private readonly IResourceLoader> _metadataPipeline; + private readonly IOSInformation _os; - public DependencyDiagnosticEmitter(IServiceProvider serviceProvider, ILogger logger) + public DependencyDiagnosticEmitter(IServiceProvider serviceProvider, ILogger logger, IOSInformation os) { _logger = logger; _metadataPipeline = Pipelines.GetMetadataPipeline(serviceProvider); + _os = os; } public async IAsyncEnumerable 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; @@ -35,7 +51,7 @@ public async IAsyncEnumerable Diagnose(Loadout.ReadOnly loadout, [En #region Diagnosers - private async Task> DiagnosePakModulesAsync(Loadout.ReadOnly loadout, CancellationToken cancellationToken) + private async Task> DiagnosePakModulesAsync(Loadout.ReadOnly loadout, Optional bg3LoadoutFile, CancellationToken cancellationToken) { var pakLoadoutFiles = GetAllPakLoadoutFiles(loadout, onlyEnabledMods: true); var metaFileTuples = await GetAllPakMetadata(pakLoadoutFiles, @@ -64,9 +80,21 @@ private async Task> 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; @@ -76,7 +104,7 @@ private async Task> 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(); @@ -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 ) ); @@ -99,9 +127,9 @@ 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 @@ -109,8 +137,8 @@ x.Item2.Exception is null && { 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(), @@ -128,15 +156,28 @@ x.Item2.Exception is null && #region Helpers - private static async IAsyncEnumerable>> GetAllPakMetadata( + private static Optional 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>> GetAllPakMetadata( LoadoutFile.ReadOnly[] pakLoadoutFiles, - IResourceLoader> metadataPipeline, + IResourceLoader> metadataPipeline, ILogger logger, [EnumeratorCancellation] CancellationToken cancellationToken) { foreach (var pakLoadoutFile in pakLoadoutFiles) { - Resource> resource; + Resource> resource; try { resource = await metadataPipeline.LoadResourceAsync(pakLoadoutFile.Hash, cancellationToken); @@ -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 } diff --git a/src/Games/NexusMods.Games.Larian/BaldursGate3/Pipelines.cs b/src/Games/NexusMods.Games.Larian/BaldursGate3/Pipelines.cs index 48c7e3cc6..d8d417924 100644 --- a/src/Games/NexusMods.Games.Larian/BaldursGate3/Pipelines.cs +++ b/src/Games/NexusMods.Games.Larian/BaldursGate3/Pipelines.cs @@ -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; @@ -18,7 +17,7 @@ public static class Pipelines public static IServiceCollection AddPipelines(this IServiceCollection serviceCollection) { - return serviceCollection.AddKeyedSingleton>>( + return serviceCollection.AddKeyedSingleton>>( serviceKey: MetadataPipelineKey, implementationFactory: static (serviceProvider, _) => CreateMetadataPipeline( fileStore: serviceProvider.GetRequiredService() @@ -26,13 +25,13 @@ public static IServiceCollection AddPipelines(this IServiceCollection serviceCol ); } - public static IResourceLoader> GetMetadataPipeline(IServiceProvider serviceProvider) + public static IResourceLoader> GetMetadataPipeline(IServiceProvider serviceProvider) { return serviceProvider.GetRequiredKeyedService>>(serviceKey: MetadataPipelineKey); + Outcome>>(serviceKey: MetadataPipelineKey); } - private static IResourceLoader> CreateMetadataPipeline(IFileStore fileStore) + private static IResourceLoader> CreateMetadataPipeline(IFileStore fileStore) { // TODO: change pipeline to return C# 9 type unions instead of OneOf var pipeline = new FileStoreStreamLoader(fileStore) @@ -46,7 +45,7 @@ public static IServiceCollection AddPipelines(this IServiceCollection serviceCol } catch (InvalidDataException e) { - return ValueTask.FromResult(resource.WithData(Outcome.FromException(e))); + return ValueTask.FromResult(resource.WithData(Outcome.FromException(e))); } } ) diff --git a/src/Games/NexusMods.Games.Larian/BaldursGate3/Utils/LsxXmlParsing/LsxXmlFormat.cs b/src/Games/NexusMods.Games.Larian/BaldursGate3/Utils/LsxXmlParsing/LsxXmlFormat.cs index 1204dcf48..d5742aaf9 100644 --- a/src/Games/NexusMods.Games.Larian/BaldursGate3/Utils/LsxXmlParsing/LsxXmlFormat.cs +++ b/src/Games/NexusMods.Games.Larian/BaldursGate3/Utils/LsxXmlParsing/LsxXmlFormat.cs @@ -7,8 +7,6 @@ namespace NexusMods.Games.Larian.BaldursGate3.Utils.LsxXmlParsing; /// public static class LsxXmlFormat { - - /// /// Pak metadata, containing the module short description and its dependencies. /// @@ -17,8 +15,8 @@ public struct MetaFileData public ModuleShortDesc ModuleShortDesc; public ModuleShortDesc[] Dependencies; } - - + + /// /// Pak module short description. /// @@ -32,15 +30,15 @@ public struct ModuleShortDesc public string Md5; public ModuleVersion SemanticVersion; } - - + + public struct ModuleVersion : IComparable, IEquatable { public ulong Major; public ulong Minor; public ulong Patch; public ulong Build; - + public static ModuleVersion FromInt32String(string? str) { if (string.IsNullOrWhiteSpace(str)) @@ -53,19 +51,20 @@ public static ModuleVersion FromInt32String(string? str) // Apparently the string can contain 64-bit values even though the type is marked as Int32 return FromInt64String(str); } + return FromUInt32(parse32Result); } - + public static ModuleVersion FromInt64String(string? str) { if (string.IsNullOrWhiteSpace(str) || !UInt64.TryParse(str, out var parse64Result)) { return FromUInt64(0); } - + return FromUInt64(parse64Result); } - + private static ModuleVersion FromUInt64(ulong uIntVal) { if (uIntVal == 1 || uIntVal == 268435456) @@ -88,7 +87,7 @@ private static ModuleVersion FromUInt64(ulong uIntVal) }; } - + private static ModuleVersion FromUInt32(UInt32 uIntVal) { if (uIntVal == 1) @@ -101,7 +100,7 @@ private static ModuleVersion FromUInt32(UInt32 uIntVal) Build = 0, }; } - + return new ModuleVersion { Major = uIntVal >> 28, @@ -110,9 +109,9 @@ private static ModuleVersion FromUInt32(UInt32 uIntVal) Build = uIntVal & 0xFFFF, }; } - + public override string ToString() => $"{Major}.{Minor}.{Patch}"; - + public int CompareTo(ModuleVersion other) { var majorComparison = Major.CompareTo(other.Major); @@ -142,7 +141,7 @@ public override int GetHashCode() Build ); } - + public static bool operator !=(ModuleVersion left, ModuleVersion right) => !left.Equals(right); public static bool operator ==(ModuleVersion left, ModuleVersion right) => left.Equals(right); public static bool operator >(ModuleVersion left, ModuleVersion right) => left.CompareTo(right) > 0; @@ -150,8 +149,8 @@ public override int GetHashCode() public static bool operator >=(ModuleVersion left, ModuleVersion right) => left.CompareTo(right) >= 0; public static bool operator <=(ModuleVersion left, ModuleVersion right) => left.CompareTo(right) <= 0; } - - + + /// /// Serializes a to an LSX `ModuleShortDesc` xml element string. /// @@ -162,7 +161,7 @@ public static string SerializeModuleShortDesc(ModuleShortDesc moduleShortDesc) Indent = true, OmitXmlDeclaration = true, }; - + using var stringWriter = new StringWriter(); using (var xmlWriter = XmlWriter.Create(stringWriter, settings)) { @@ -177,102 +176,115 @@ public static string SerializeModuleShortDesc(ModuleShortDesc moduleShortDesc) /// Collection of modules that should be ignored when parsing dependencies. /// These are mostly vanilla pak files, so not relevant for dependencies between mods. /// - public static readonly ModuleShortDesc[] DependencyModulesToIgnore = + public static readonly ModuleShortDesc[] DependencyModulesToIgnore = + [ - new() - { - // Vanilla pak file - Name = "Gustav", - Uuid = "991c9c7a-fb80-40cb-8f0d-b92d4e80e9b1", - }, - new() - { - // Vanilla pak file - Name = "DiceSet_01", - Uuid = "e842840a-2449-588c-b0c4-22122cfce31b", - }, - new() - { - // Vanilla pak file - Name = "GustavDev", - Uuid = "28ac9ce2-2aba-8cda-b3b5-6e922f71b6b8", - }, - new() - { - // Vanilla pak file - Name = "DiceSet_02", - Uuid = "b176a0ac-d79f-ed9d-5a87-5c2c80874e10", - }, - new() - { - // Vanilla pak file - Name = "DiceSet_03", - Uuid = "e0a4d990-7b9b-8fa9-d7c6-04017c6cf5b1", - }, - new() - { - // Vanilla pak file - Name = "DiceSet_04", - Uuid = "77a2155f-4b35-4f0c-e7ff-4338f91426a4", - }, - new() - { - // Vanilla pak file - Name = "Shared", - Uuid = "ed539163-bb70-431b-96a7-f5b2eda5376b", - }, - new() - { - // Vanilla pak file - Name = "SharedDev", - Uuid = "3d0c5ff8-c95d-c907-ff3e-34b204f1c630", - }, - new() - { - // Vanilla pak file - Name = "FW3", - Uuid = "e5c9077e-1fca-4f24-b55d-464f512c98a8", - }, - new() - { - // Vanilla pak file - Name = "Engine", - Uuid = "9dff4c3b-fda7-43de-a763-ce1383039999", - }, - new() - { - // Vanilla pak file - Name = "DiceSet_06", - Uuid = "ee4989eb-aab8-968f-8674-812ea2f4bfd7", - }, - new() - { - // Vanilla pak file - Name = "Honour", - Uuid = "b77b6210-ac50-4cb1-a3d5-5702fb9c744c", - }, - new() - { - // Vanilla pak file - Name = "ModBrowser", - Uuid = "ee5a55ff-eb38-0b27-c5b0-f358dc306d34", - }, - new() - { - // Vanilla pak file - Name = "MainUI", - Uuid = "630daa32-70f8-3da5-41b9-154fe8410236", - }, + new () + { + // Vanilla pak file + Name = "Gustav", + Uuid = "991c9c7a-fb80-40cb-8f0d-b92d4e80e9b1", + }, + + new () + { + // Vanilla pak file + Name = "DiceSet_01", + Uuid = "e842840a-2449-588c-b0c4-22122cfce31b", + }, + + new () + { + // Vanilla pak file + Name = "GustavDev", + Uuid = "28ac9ce2-2aba-8cda-b3b5-6e922f71b6b8", + }, + + new () + { + // Vanilla pak file + Name = "DiceSet_02", + Uuid = "b176a0ac-d79f-ed9d-5a87-5c2c80874e10", + }, + + new () + { + // Vanilla pak file + Name = "DiceSet_03", + Uuid = "e0a4d990-7b9b-8fa9-d7c6-04017c6cf5b1", + }, + + new () + { + // Vanilla pak file + Name = "DiceSet_04", + Uuid = "77a2155f-4b35-4f0c-e7ff-4338f91426a4", + }, + + new () + { + // Vanilla pak file + Name = "Shared", + Uuid = "ed539163-bb70-431b-96a7-f5b2eda5376b", + }, + + new () + { + // Vanilla pak file + Name = "SharedDev", + Uuid = "3d0c5ff8-c95d-c907-ff3e-34b204f1c630", + }, + + new () + { + // Vanilla pak file + Name = "FW3", + Uuid = "e5c9077e-1fca-4f24-b55d-464f512c98a8", + }, + + new () + { + // Vanilla pak file + Name = "Engine", + Uuid = "9dff4c3b-fda7-43de-a763-ce1383039999", + }, + + new () + { + // Vanilla pak file + Name = "DiceSet_06", + Uuid = "ee4989eb-aab8-968f-8674-812ea2f4bfd7", + }, + + new () + { + // Vanilla pak file + Name = "Honour", + Uuid = "b77b6210-ac50-4cb1-a3d5-5702fb9c744c", + }, + + new () + { + // Vanilla pak file + Name = "ModBrowser", + Uuid = "ee5a55ff-eb38-0b27-c5b0-f358dc306d34", + }, + + new () + { + // Vanilla pak file + Name = "MainUI", + Uuid = "630daa32-70f8-3da5-41b9-154fe8410236", + }, ]; - + /// /// Dependency Name values to ignore, based on the collection. /// public static readonly string[] DependencyNamesToIgnore = DependencyModulesToIgnore.Select(m => m.Name).ToArray(); - + /// /// Dependency Uuid values to ignore, based on the collection. /// public static readonly string[] DependencyUuidsToIgnore = DependencyModulesToIgnore.Select(m => m.Uuid).ToArray(); - } diff --git a/src/Games/NexusMods.Games.Larian/BaldursGate3/Utils/PakParsing/LspkPackageFormat.cs b/src/Games/NexusMods.Games.Larian/BaldursGate3/Utils/PakParsing/LspkPackageFormat.cs index 01fae4e07..af05c367c 100644 --- a/src/Games/NexusMods.Games.Larian/BaldursGate3/Utils/PakParsing/LspkPackageFormat.cs +++ b/src/Games/NexusMods.Games.Larian/BaldursGate3/Utils/PakParsing/LspkPackageFormat.cs @@ -1,4 +1,6 @@ using System.Text; +using DynamicData.Kernel; +using NexusMods.Games.Larian.BaldursGate3.Utils.LsxXmlParsing; namespace NexusMods.Games.Larian.BaldursGate3.Utils.PakParsing; @@ -7,6 +9,18 @@ namespace NexusMods.Games.Larian.BaldursGate3.Utils.PakParsing; /// public static class LspkPackageFormat { + + public struct PakMetaData + { + public LsxXmlFormat.MetaFileData MetaFileData; + public Optional ScriptExtenderConfigMetadata; + } + public struct ScriptExtenderConfigMetadata + { + public bool RequiresScriptExtender; + public int SeRequiredVersion; + } + #region Enums public enum PackageVersion diff --git a/src/Games/NexusMods.Games.Larian/BaldursGate3/Utils/PakParsing/PakFileParser.cs b/src/Games/NexusMods.Games.Larian/BaldursGate3/Utils/PakParsing/PakFileParser.cs index 70061ecbe..191c91d03 100644 --- a/src/Games/NexusMods.Games.Larian/BaldursGate3/Utils/PakParsing/PakFileParser.cs +++ b/src/Games/NexusMods.Games.Larian/BaldursGate3/Utils/PakParsing/PakFileParser.cs @@ -1,8 +1,10 @@ using System.IO.Compression; using System.Text; +using System.Text.Json; using DynamicData.Kernel; using K4os.Compression.LZ4; using NexusMods.Games.Larian.BaldursGate3.Utils.LsxXmlParsing; +using NexusMods.Paths; using ZstdSharp; namespace NexusMods.Games.Larian.BaldursGate3.Utils.PakParsing; @@ -13,14 +15,13 @@ namespace NexusMods.Games.Larian.BaldursGate3.Utils.PakParsing; /// public static class PakFileParser { - #region Public Methods /// /// Parses a bg3 `.pak` file stream and extracts the metadata from the packed `meta.lsx` file. /// /// In case of errors during parsing - public static LsxXmlFormat.MetaFileData ParsePakMeta(Stream pakFileStream) + public static LspkPackageFormat.PakMetaData ParsePakMeta(Stream pakFileStream) { using var br = new BinaryReader(pakFileStream); var headerData = ParseHeaderInternal(br); @@ -32,21 +33,52 @@ public static LsxXmlFormat.MetaFileData ParsePakMeta(Stream pakFileStream) throw new InvalidDataException($"Unable to find `meta.lsx` file in pak archive"); } - var metaStream = ReadFileEntryData(br, fileEntryInfo.Value); - return MetaLsxParser.ParseMetaFile(metaStream); - } + var metaStream = GetFileEntryStream(br, fileEntryInfo.Value); + var metaFile = MetaLsxParser.ParseMetaFile(metaStream); -#endregion + var seConfig = GetScriptExtenderConfigMetaData(fileList, br); -#region Private Methods + return new LspkPackageFormat.PakMetaData + { + MetaFileData = metaFile, + ScriptExtenderConfigMetadata = seConfig, + }; + } - private static void Load(BinaryReader br) + private static Optional GetScriptExtenderConfigMetaData(List fileList, BinaryReader br) { - var headerData = ParseHeaderInternal(br); + var seConfig = fileList.FirstOrOptional( + f => new RelativePath(f.Name).EndsWith(new RelativePath("ScriptExtender/Config.json")) + ); - var fileList = ParseFileListInternal(br, (int)headerData.FileListOffset, headerData); + if (seConfig.HasValue) + { + var configStream = GetFileEntryBytes(br, seConfig.Value); + var jsonReader = new Utf8JsonReader(configStream); + while (jsonReader.Read()) + { + if (jsonReader.TokenType != JsonTokenType.PropertyName || jsonReader.GetString() != "RequiredVersion") + continue; + + jsonReader.Read(); + if (jsonReader.TokenType == JsonTokenType.Number && jsonReader.TryGetInt32(out var requiredVersion)) + { + return new LspkPackageFormat.ScriptExtenderConfigMetadata + { + RequiresScriptExtender = true, + SeRequiredVersion = requiredVersion, + }; + } + } + } + + return Optional.None; } +#endregion + +#region Private Methods + private static LspkPackageFormat.HeaderCommon ParseHeaderInternal(BinaryReader br) { var magic = br.ReadBytes(4); @@ -171,8 +203,74 @@ private static LspkPackageFormat.FileEntryInfoCommon ParseFileEntryInternal(Bina throw new InvalidDataException($"Unrecognized Pak version: v{version}"); } } - - private static Stream ReadFileEntryData(BinaryReader br, LspkPackageFormat.FileEntryInfoCommon fileMeta) + + + private static byte[] GetFileEntryBytes(BinaryReader br, LspkPackageFormat.FileEntryInfoCommon fileMeta) + { + br.BaseStream.Seek((long)fileMeta.OffsetInFile, SeekOrigin.Begin); + + var rawData = br.ReadBytes((int)fileMeta.SizeOnDisk); + + return fileMeta.Flags.Method() switch + { + LspkPackageFormat.CompressionMethod.None => rawData, + LspkPackageFormat.CompressionMethod.LZ4 => DecompressLz4(fileMeta, rawData), + LspkPackageFormat.CompressionMethod.Zlib => DecompressZlib(fileMeta, rawData), + LspkPackageFormat.CompressionMethod.Zstd => DecompressZstd(fileMeta, rawData), + _ => throw new InvalidDataException($"Unsupported compression method {fileMeta.Flags.Method()} for file {fileMeta.Name}") + }; + + byte[] DecompressLz4(LspkPackageFormat.FileEntryInfoCommon fileEntryInfoCommon, byte[] bytes) + { + var decompressedBytes = new byte[fileEntryInfoCommon.UncompressedSize]; + + var decodedSize = LZ4Codec.Decode(bytes, 0, bytes.Length, + decompressedBytes, 0, decompressedBytes.Length + ); + if (decodedSize != decompressedBytes.Length) + { + throw new InvalidDataException( + $"Failed to extract {fileEntryInfoCommon.Name} from Pak archive: decompressed size {decodedSize} does not match expected size {fileEntryInfoCommon.UncompressedSize}" + ); + } + + return decompressedBytes; + } + + byte[] DecompressZlib(LspkPackageFormat.FileEntryInfoCommon fileEntryInfoCommon, byte[] bytes) + { + using var ms = new MemoryStream(bytes); + using var ds = new ZLibStream(ms, CompressionMode.Decompress); + var decompressedBytes = new byte[fileEntryInfoCommon.UncompressedSize]; + var read = ds.Read(decompressedBytes, 0, decompressedBytes.Length); + if (read != decompressedBytes.Length) + { + throw new InvalidDataException( + $"Failed to extract {fileEntryInfoCommon.Name} from Pak archive: decompressed size {read} does not match expected size {fileEntryInfoCommon.UncompressedSize}" + ); + } + + return decompressedBytes; + } + + byte[] DecompressZstd(LspkPackageFormat.FileEntryInfoCommon fileEntryInfoCommon, byte[] bytes) + { + using var ms = new MemoryStream(bytes); + using var ds = new DecompressionStream(ms); + var decompressedBytes = new byte[fileEntryInfoCommon.UncompressedSize]; + var read = ds.Read(decompressedBytes, 0, decompressedBytes.Length); + if (read != decompressedBytes.Length) + { + throw new InvalidDataException( + $"Failed to extract {fileEntryInfoCommon.Name} from Pak archive: decompressed size {read} does not match expected size {fileEntryInfoCommon.UncompressedSize}" + ); + } + + return decompressedBytes; + } + } + + private static Stream GetFileEntryStream(BinaryReader br, LspkPackageFormat.FileEntryInfoCommon fileMeta) { br.BaseStream.Seek((long)fileMeta.OffsetInFile, SeekOrigin.Begin); @@ -190,16 +288,20 @@ private static Stream ReadFileEntryData(BinaryReader br, LspkPackageFormat.FileE Stream DecompressLz4(LspkPackageFormat.FileEntryInfoCommon fileEntryInfoCommon, byte[] bytes) { var decompressedBytes = new byte[fileEntryInfoCommon.UncompressedSize]; - - var decodedSize = LZ4Codec.Decode(bytes, 0, bytes.Length, decompressedBytes, 0, decompressedBytes.Length); + + var decodedSize = LZ4Codec.Decode(bytes, 0, bytes.Length, + decompressedBytes, 0, decompressedBytes.Length + ); if (decodedSize != decompressedBytes.Length) { - throw new InvalidDataException($"Failed to extract {fileEntryInfoCommon.Name} from Pak archive: decompressed size {decodedSize} does not match expected size {fileEntryInfoCommon.UncompressedSize}"); + throw new InvalidDataException( + $"Failed to extract {fileEntryInfoCommon.Name} from Pak archive: decompressed size {decodedSize} does not match expected size {fileEntryInfoCommon.UncompressedSize}" + ); } - + return new MemoryStream(decompressedBytes); } - + Stream DecompressZlib(LspkPackageFormat.FileEntryInfoCommon fileEntryInfoCommon, byte[] bytes) { using var ms = new MemoryStream(bytes); @@ -208,12 +310,14 @@ Stream DecompressZlib(LspkPackageFormat.FileEntryInfoCommon fileEntryInfoCommon, var read = ds.Read(decompressedBytes, 0, decompressedBytes.Length); if (read != decompressedBytes.Length) { - throw new InvalidDataException($"Failed to extract {fileEntryInfoCommon.Name} from Pak archive: decompressed size {read} does not match expected size {fileEntryInfoCommon.UncompressedSize}"); + throw new InvalidDataException( + $"Failed to extract {fileEntryInfoCommon.Name} from Pak archive: decompressed size {read} does not match expected size {fileEntryInfoCommon.UncompressedSize}" + ); } - + return new MemoryStream(decompressedBytes); } - + Stream DecompressZstd(LspkPackageFormat.FileEntryInfoCommon fileEntryInfoCommon, byte[] bytes) { using var ms = new MemoryStream(bytes); @@ -222,14 +326,14 @@ Stream DecompressZstd(LspkPackageFormat.FileEntryInfoCommon fileEntryInfoCommon, var read = ds.Read(decompressedBytes, 0, decompressedBytes.Length); if (read != decompressedBytes.Length) { - throw new InvalidDataException($"Failed to extract {fileEntryInfoCommon.Name} from Pak archive: decompressed size {read} does not match expected size {fileEntryInfoCommon.UncompressedSize}"); + throw new InvalidDataException( + $"Failed to extract {fileEntryInfoCommon.Name} from Pak archive: decompressed size {read} does not match expected size {fileEntryInfoCommon.UncompressedSize}" + ); } - + return new MemoryStream(decompressedBytes); } } - - #endregion } diff --git a/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3ModsettingsFileFormatTests.cs b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3ModsettingsFileFormatTests.cs index 469a5dd6d..4f9c8cf65 100644 --- a/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3ModsettingsFileFormatTests.cs +++ b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3ModsettingsFileFormatTests.cs @@ -28,7 +28,7 @@ public async Task SerializeModsettingsLoadOrder_VerifyTest() LsxXmlFormat.MetaFileData metaFileData; try { - metaFileData = PakFileParser.ParsePakMeta(pakFileStream); + metaFileData = PakFileParser.ParsePakMeta(pakFileStream).MetaFileData; } catch (InvalidDataException) { diff --git a/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3PakParsingTests.cs b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3PakParsingTests.cs index 2bf0fc3a9..254d61a48 100644 --- a/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3PakParsingTests.cs +++ b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3PakParsingTests.cs @@ -26,7 +26,7 @@ public async Task ParsePakMeta_ShouldParseCorrectly(string pakFilePath) { var fullPath = _fs.GetKnownPath(KnownPath.EntryDirectory).Combine("BaldursGate3/Resources/PakFiles/" + pakFilePath); await using var pakFileStream = File.OpenRead(fullPath.ToString()); - var metaFileData = PakFileParser.ParsePakMeta(pakFileStream); + var metaFileData = PakFileParser.ParsePakMeta(pakFileStream).MetaFileData; var sb = new StringBuilder(); sb.AppendLine("ModuleShortDesc:");