diff --git a/NexusMods.App.sln b/NexusMods.App.sln index e5cf3acc10..b4605a1686 100644 --- a/NexusMods.App.sln +++ b/NexusMods.App.sln @@ -258,6 +258,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Abstractions.Medi EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Games.Larian", "src\Games\NexusMods.Games.Larian\NexusMods.Games.Larian.csproj", "{2A35EBB5-1CA6-4F5D-8CE8-352146C82C28}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Games.Larian.Tests", "tests\Games\NexusMods.Games.Larian.Tests\NexusMods.Games.Larian.Tests.csproj", "{425F7A13-99A2-4231-B0C1-C56EB819C174}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -668,6 +670,10 @@ Global {2A35EBB5-1CA6-4F5D-8CE8-352146C82C28}.Debug|Any CPU.Build.0 = Debug|Any CPU {2A35EBB5-1CA6-4F5D-8CE8-352146C82C28}.Release|Any CPU.ActiveCfg = Release|Any CPU {2A35EBB5-1CA6-4F5D-8CE8-352146C82C28}.Release|Any CPU.Build.0 = Release|Any CPU + {425F7A13-99A2-4231-B0C1-C56EB819C174}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {425F7A13-99A2-4231-B0C1-C56EB819C174}.Debug|Any CPU.Build.0 = Debug|Any CPU + {425F7A13-99A2-4231-B0C1-C56EB819C174}.Release|Any CPU.ActiveCfg = Release|Any CPU + {425F7A13-99A2-4231-B0C1-C56EB819C174}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -787,6 +793,7 @@ Global {8C817874-7A88-450E-B216-851A1B03684C} = {52AF9D62-7D5B-4AD0-BA12-86F2AA67428B} {5CB6D02C-07D0-4C0D-BF5C-4E2E958A0612} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C} {2A35EBB5-1CA6-4F5D-8CE8-352146C82C28} = {70D38D24-79AE-4600-8E83-17F3C11BA81F} + {425F7A13-99A2-4231-B0C1-C56EB819C174} = {05B06AC1-7F2B-492F-983E-5BC63CDBF20D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {9F9F8352-34DD-42C0-8564-EE9AF34A3501} diff --git a/src/Abstractions/NexusMods.Abstractions.GameLocators/GameCapabilities/IModInstallDestination.cs b/src/Abstractions/NexusMods.Abstractions.GameLocators/GameCapabilities/IModInstallDestination.cs index 0c7ff76f34..06a3b03e3d 100644 --- a/src/Abstractions/NexusMods.Abstractions.GameLocators/GameCapabilities/IModInstallDestination.cs +++ b/src/Abstractions/NexusMods.Abstractions.GameLocators/GameCapabilities/IModInstallDestination.cs @@ -1,4 +1,3 @@ -using NexusMods.Abstractions.Games.GameCapabilities; using NexusMods.Paths; namespace NexusMods.Abstractions.GameLocators.GameCapabilities; @@ -56,11 +55,6 @@ public static void AddCommonLocations(IReadOnlyDictionary(), - KnownValidSubfolders = Array.Empty(), - KnownValidFileExtensions = Array.Empty(), - FileExtensionsToDiscard = Array.Empty(), - SubPathsToDiscard = Array.Empty() }); } } diff --git a/src/Abstractions/NexusMods.Abstractions.GameLocators/GameCapabilities/InstallFolderTarget.cs b/src/Abstractions/NexusMods.Abstractions.GameLocators/GameCapabilities/InstallFolderTarget.cs index 49dd8dc8da..d6b124f8eb 100644 --- a/src/Abstractions/NexusMods.Abstractions.GameLocators/GameCapabilities/InstallFolderTarget.cs +++ b/src/Abstractions/NexusMods.Abstractions.GameLocators/GameCapabilities/InstallFolderTarget.cs @@ -1,8 +1,6 @@ -using NexusMods.Abstractions.GameLocators; -using NexusMods.Abstractions.GameLocators.GameCapabilities; -using NexusMods.Paths; +using NexusMods.Paths; -namespace NexusMods.Abstractions.Games.GameCapabilities; +namespace NexusMods.Abstractions.GameLocators.GameCapabilities; /// /// Represents a target path for installing simple mods archives @@ -21,32 +19,32 @@ public class InstallFolderTarget : IModInstallDestination /// /// List of known recognizable aliases that can be directly mapped to the . /// - public IEnumerable KnownSourceFolderNames { get; init; } = Enumerable.Empty(); + public IEnumerable KnownSourceFolderNames { get; init; } = []; /// /// List of known recognizable first level subfolders of the target . /// NOTE: Only include folders that are only likely to appear at this level of the folder hierarchy. /// - public IEnumerable KnownValidSubfolders { get; init; } = Enumerable.Empty(); + public IEnumerable Names { get; init; } = []; /// /// List of known recognizable file extensions for direct children of the target . /// NOTE: Only include file extensions that are only likely to appear at this level of the folder hierarchy. /// - public IEnumerable KnownValidFileExtensions { get; init; } = Enumerable.Empty(); + public IEnumerable KnownValidFileExtensions { get; init; } = []; /// /// List of subPaths of the target that should be discarded. /// - public IEnumerable SubPathsToDiscard { get; init; } = Enumerable.Empty(); + public IEnumerable SubPathsToDiscard { get; init; } = []; /// /// List of file extensions to discard when installing to this target. /// - public IEnumerable FileExtensionsToDiscard { get; init; } = Enumerable.Empty(); + public IEnumerable FileExtensionsToDiscard { get; init; } = []; /// /// Collection of Targets that are nested paths relative to . /// - public IEnumerable SubTargets { get; init; } = Enumerable.Empty(); + public IEnumerable SubTargets { get; init; } = []; } diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/ALoadoutSynchronizer.cs b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/ALoadoutSynchronizer.cs index fbad1e0bb7..f6a4f6ec6b 100644 --- a/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/ALoadoutSynchronizer.cs +++ b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/ALoadoutSynchronizer.cs @@ -177,7 +177,6 @@ public SyncTree BuildSyncTree(DiskState currentState, DiskState previousTree, IE return file; }) .Where(f => !f.TryGetAsDeletedFile(out _)) - .Where(f => !IsIgnoredPath(f.TargetPath)) .OfTypeLoadoutFile(); return BuildSyncTree(currentState, previousTree, grouped); diff --git a/src/Games/NexusMods.Games.AdvancedInstaller.UI/AdvancedManualInstallerUI.cs b/src/Games/NexusMods.Games.AdvancedInstaller.UI/AdvancedManualInstallerUI.cs index c76c895cd1..d76f639387 100644 --- a/src/Games/NexusMods.Games.AdvancedInstaller.UI/AdvancedManualInstallerUI.cs +++ b/src/Games/NexusMods.Games.AdvancedInstaller.UI/AdvancedManualInstallerUI.cs @@ -48,7 +48,6 @@ public override async ValueTask ExecuteAsync( CancellationToken cancellationToken) { if (Headless) return new NotSupported(); - var tree = LibraryArchiveTree.Create(libraryArchive); var (shouldInstall, deploymentData) = await GetDeploymentDataAsync(loadoutGroup.GetLoadoutItem(transaction).Name, tree, loadout); diff --git a/src/Games/NexusMods.Games.Generic/Extensions/LibraryArchiveTreeExtensions.cs b/src/Games/NexusMods.Games.Generic/Extensions/LibraryArchiveTreeExtensions.cs new file mode 100644 index 0000000000..52c3a5f028 --- /dev/null +++ b/src/Games/NexusMods.Games.Generic/Extensions/LibraryArchiveTreeExtensions.cs @@ -0,0 +1,33 @@ +using NexusMods.Paths.Trees; +using NexusMods.Paths.Trees.Traits; + +namespace NexusMods.Games.Generic.Extensions; + +public static class LibraryArchiveTreeExtensions +{ + public static IEnumerable> EnumerateFilesBfsWhereBranch( + this KeyedBox item, + Func, bool> predicate) + where TKey : notnull + where TSelf : struct, IHaveAFileOrDirectory, IHaveBoxedChildrenWithKey, IHaveKey + { + var queue = new Queue>(); + foreach (var child in item.Children()) + { + queue.Enqueue(child.Value); + } + + while (queue.TryDequeue(out var current)) + { + if (!predicate(current)) continue; + + if (current.IsFile()) + { + yield return current; + } + + foreach (var grandChild in current.Item.Children) + queue.Enqueue(grandChild.Value); + } + } +} diff --git a/src/Games/NexusMods.Games.Generic/Installers/GenericPatternMatchInstaller.cs b/src/Games/NexusMods.Games.Generic/Installers/GenericPatternMatchInstaller.cs new file mode 100644 index 0000000000..47da9c83a9 --- /dev/null +++ b/src/Games/NexusMods.Games.Generic/Installers/GenericPatternMatchInstaller.cs @@ -0,0 +1,169 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NexusMods.Abstractions.GameLocators.GameCapabilities; +using NexusMods.Abstractions.Library.Installers; +using NexusMods.Abstractions.Library.Models; +using NexusMods.Abstractions.Loadouts; +using NexusMods.Games.Generic.Extensions; +using NexusMods.MnemonicDB.Abstractions; +using NexusMods.Paths; +using NexusMods.Paths.Trees; +using NexusMods.Paths.Trees.Traits; + +namespace NexusMods.Games.Generic.Installers; + +using InstallDataTuple = (LoadoutItemGroup.New loadoutGroup, ITransaction transaction, Loadout.ReadOnly loadout); + + +/// +/// Generic mod installer for mods that only need to have their contents placed to a specific game location +/// (). +/// Tries to match the mod archive folder structure to requirements. +/// +/// Example: myMod/Textures/myTexture.dds -> Skyrim/Data/Textures/myTexture.dds +/// +public class GenericPatternMatchInstaller : ALibraryArchiveInstaller +{ + public GenericPatternMatchInstaller(IServiceProvider serviceProvider) : + base(serviceProvider, serviceProvider.GetRequiredService>()) + { + } + + public InstallFolderTarget[] InstallFolderTargets { get; init; } = []; + + public override ValueTask ExecuteAsync( + LibraryArchive.ReadOnly libraryArchive, + LoadoutItemGroup.New loadoutGroup, + ITransaction transaction, + Loadout.ReadOnly loadout, + CancellationToken cancellationToken) + { + var installDataTuple = (loadoutGroup, transaction, loadout); + if (InstallFolderTargets.Length == 0) + return ValueTask.FromResult(new NotSupported()); + + var tree = libraryArchive.GetTree(); + + return InstallFolderTargets.Any(target => TryInstallForTarget(target, tree, installDataTuple)) + ? ValueTask.FromResult(new Success()) + : ValueTask.FromResult(new NotSupported()); + } + + private bool TryInstallForTarget(InstallFolderTarget target, KeyedBox tree, InstallDataTuple installDataTuple) + { + foreach (var node in tree.EnumerateChildrenBfs()) + { + if (!TryGetMatch(node.Value, target, out var match)) continue; + DoInstall(match ?? tree, target, installDataTuple); + return true; + } + + return false; + } + + private static bool TryGetMatch(KeyedBox node, InstallFolderTarget target, [NotNullWhen(true)] out KeyedBox? match) + { + match = null; + + if (node.IsFile()) + { + // Check if file has a known child file extension + if (target.KnownValidFileExtensions.Contains(node.Key().Extension)) + { + match = node.Parent()!; + return true; + } + } + else + { + // Check if the directory name is a known source folder + if (target.KnownSourceFolderNames.Contains(node.Key().Name)) + { + match = node; + return true; + } + + // Check if the directory name is a known subfolder + if (target.Names.Contains(node.Key().Name)) + { + match = node.Parent()!; + return true; + } + } + + return false; + } + + private void DoInstall(KeyedBox tree, InstallFolderTarget target, InstallDataTuple installDataTuple) + { + var dropDepth = tree.Depth(); + var (loadoutGroup, transaction, loadout) = installDataTuple; + + // Discard files and directories based on the target configuration + var fileNodes = tree.EnumerateFilesBfsWhereBranch(node => + { + if (node.IsDirectory()) + { + var relativePath = node.Item.Path.DropFirst(dropDepth); + // prune branch if directory is in the discard list + if (target.SubPathsToDiscard.Contains(relativePath)) + { + return false; + } + } + else + { + // prune file if file extension is in the discard list + if (target.FileExtensionsToDiscard.Contains(node.Key().Extension)) + { + return false; + } + } + + return true; + } + ); + + // Add the files to the loadout + foreach (var fileNode in fileNodes) + { + // rebase the path to the target location + var relativePath = fileNode.Item.Path.DropFirst(dropDepth); + relativePath = target.DestinationGamePath.Path.Join(relativePath); + + GenerateFileItem(target, + transaction, + loadout, + relativePath, + loadoutGroup, + fileNode + ); + } + } + + protected virtual void GenerateFileItem( + InstallFolderTarget target, + ITransaction transaction, + Loadout.ReadOnly loadout, + RelativePath relativePath, + LoadoutItemGroup.New loadoutGroup, + KeyedBox fileNode) + { + var _ = new LoadoutFile.New(transaction, out var id) + { + LoadoutItemWithTargetPath = new LoadoutItemWithTargetPath.New(transaction, id) + { + TargetPath = (loadout.Id, target.DestinationGamePath.LocationId, relativePath), + LoadoutItem = new LoadoutItem.New(transaction, id) + { + Name = relativePath.Name, + LoadoutId = loadout.Id, + ParentId = loadoutGroup.Id, + }, + }, + Hash = fileNode.Item.LibraryFile.Value.Hash, + Size = fileNode.Item.LibraryFile.Value.Size, + }; + } +} diff --git a/src/Games/NexusMods.Games.Larian/BaldursGate3/BaldursGate3.cs b/src/Games/NexusMods.Games.Larian/BaldursGate3/BaldursGate3.cs index 351ca73468..91c7fe074a 100644 --- a/src/Games/NexusMods.Games.Larian/BaldursGate3/BaldursGate3.cs +++ b/src/Games/NexusMods.Games.Larian/BaldursGate3/BaldursGate3.cs @@ -7,8 +7,12 @@ using NexusMods.Abstractions.Games.DTO; using NexusMods.Abstractions.IO; using NexusMods.Abstractions.IO.StreamFactories; +using NexusMods.Abstractions.Library.Installers; using NexusMods.Abstractions.Loadouts.Synchronizers; +using NexusMods.Games.Generic.Installers; +using NexusMods.Games.Larian.BaldursGate3.Installers; using NexusMods.Paths; +using NexusMods.Paths.Utilities; namespace NexusMods.Games.Larian.BaldursGate3; @@ -17,13 +21,13 @@ public class BaldursGate3 : AGame, ISteamGame, IGogGame private readonly IServiceProvider _serviceProvider; private readonly IOSInformation _osInformation; public override string Name => "Baldur's Gate 3"; - + public IEnumerable SteamIds => [1086940u]; public IEnumerable GogIds => [1456460669]; public static GameDomain GameDomain => GameDomain.From("baldursgate3"); public override GameDomain Domain => GameDomain; - + public BaldursGate3(IServiceProvider provider) : base(provider) { _serviceProvider = provider; @@ -42,9 +46,9 @@ protected override IReadOnlyDictionary GetLocations(IF var result = new Dictionary() { { LocationId.Game, installation.Path }, - { LocationId.From("Mods"), fileSystem.GetKnownPath(KnownPath.HomeDirectory).Combine("Larian Studios/Baldur's Gate 3/Mods") }, - { LocationId.From("PlayerProfiles"), fileSystem.GetKnownPath(KnownPath.HomeDirectory).Combine("Larian Studios/Baldur's Gate 3/PlayerProfiles/Public") }, - { LocationId.From("ScriptExtenderConfig"), fileSystem.GetKnownPath(KnownPath.HomeDirectory).Combine("Larian Studios/Baldur's Gate 3/ScriptExtender") }, + { LocationId.From("Mods"), fileSystem.GetKnownPath(KnownPath.LocalApplicationDataDirectory).Combine("Larian Studios/Baldur's Gate 3/Mods") }, + { LocationId.From("PlayerProfiles"), fileSystem.GetKnownPath(KnownPath.LocalApplicationDataDirectory).Combine("Larian Studios/Baldur's Gate 3/PlayerProfiles/Public") }, + { LocationId.From("ScriptExtenderConfig"), fileSystem.GetKnownPath(KnownPath.LocalApplicationDataDirectory).Combine("Larian Studios/Baldur's Gate 3/ScriptExtender") }, }; return result; } @@ -52,15 +56,62 @@ protected override IReadOnlyDictionary GetLocations(IF /// public override List GetInstallDestinations(IReadOnlyDictionary locations) { - // TODO: fill this in for Generic installer - return []; + return + [ + ]; } - + + public override ILibraryItemInstaller[] LibraryItemInstallers => + [ + new BG3SEInstaller(_serviceProvider), + new GenericPatternMatchInstaller(_serviceProvider) + { + InstallFolderTargets = + [ + // Pak mods + // Examples: + // - ImpUI (ImprovedUI) Patch7Ready + // - NPC Visual Overhaul (WIP) - NPC VO + new InstallFolderTarget + { + DestinationGamePath = new GamePath(LocationId.From("Mods"), ""), + KnownValidFileExtensions = [new Extension(".pak")], + FileExtensionsToDiscard = + [ + KnownExtensions.Txt, KnownExtensions.Md, KnownExtensions.Pdf, KnownExtensions.Png, + KnownExtensions.Json, new Extension(".lnk"), + ], + }, + + // bin and NativeMods mods + // Examples: + // - Native Mod Loader + // - Achievement Enabler + new InstallFolderTarget + { + DestinationGamePath = new GamePath(LocationId.Game, "bin"), + KnownSourceFolderNames = ["bin"], + Names = ["NativeMods"], + }, + + // loose files Data mods + // Examples: + // - Fast XP + new InstallFolderTarget + { + DestinationGamePath = new GamePath(LocationId.Game, "Data"), + KnownSourceFolderNames = ["Data"], + Names = ["Generated", "Public"], + }, + ], + }, + ]; + protected override ILoadoutSynchronizer MakeSynchronizer(IServiceProvider provider) { return new BaldursGate3Synchronizer(provider); } - + // TODO: We are using Icon for both Spine and GameWidget and GameImage is unused. We should use GameImage for the GameWidget, but need to update all the games to have better images. public override IStreamFactory Icon => new EmbededResourceStreamFactory("NexusMods.Games.Larian.Resources.BaldursGate3.icon.png"); diff --git a/src/Games/NexusMods.Games.Larian/BaldursGate3/BaldursGate3Synchronizer.cs b/src/Games/NexusMods.Games.Larian/BaldursGate3/BaldursGate3Synchronizer.cs index 7ae1bee6ae..43a3e5cce6 100644 --- a/src/Games/NexusMods.Games.Larian/BaldursGate3/BaldursGate3Synchronizer.cs +++ b/src/Games/NexusMods.Games.Larian/BaldursGate3/BaldursGate3Synchronizer.cs @@ -2,6 +2,7 @@ using NexusMods.Abstractions.GameLocators; using NexusMods.Abstractions.Loadouts.Synchronizers; using NexusMods.Abstractions.Settings; +using NexusMods.Paths; namespace NexusMods.Games.Larian.BaldursGate3; @@ -10,10 +11,13 @@ public class BaldursGate3Synchronizer : ALoadoutSynchronizer private readonly BaldursGate3Settings _settings; private static GamePath GameFolder => new(LocationId.Game, ""); + private static GamePath DataFolder => new(LocationId.Game, "Data"); private static GamePath PublicPlayerProfiles => new(LocationId.From("PlayerProfiles"), ""); private static GamePath ModSettingsFile => new(LocationId.From("PlayerProfiles"), "modsettings.lsx"); + private static Extension PakExtension => new Extension(".pak"); + public BaldursGate3Synchronizer(IServiceProvider provider) : base(provider) { @@ -24,7 +28,12 @@ public BaldursGate3Synchronizer(IServiceProvider provider) : base(provider) public override bool IsIgnoredPath(GamePath path) { // Always ignore all PlayerProfile files except the modsettings file. - return path.InFolder(PublicPlayerProfiles) && path.Path != ModSettingsFile.Path; + if (path.InFolder(PublicPlayerProfiles)) + return path.Path != ModSettingsFile.Path; + + if (_settings.DoFullGameBackup) return false; + + return path.InFolder(DataFolder) && path.Extension == PakExtension; } public override bool IsIgnoredBackupPath(GamePath path) diff --git a/src/Games/NexusMods.Games.Larian/BaldursGate3/Installers/BG3SEInstaller.cs b/src/Games/NexusMods.Games.Larian/BaldursGate3/Installers/BG3SEInstaller.cs new file mode 100644 index 0000000000..6d13ef787d --- /dev/null +++ b/src/Games/NexusMods.Games.Larian/BaldursGate3/Installers/BG3SEInstaller.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NexusMods.Abstractions.GameLocators; +using NexusMods.Abstractions.Library.Installers; +using NexusMods.Abstractions.Library.Models; +using NexusMods.Abstractions.Loadouts; +using NexusMods.MnemonicDB.Abstractions; +using NexusMods.Paths; +using NexusMods.Paths.Trees.Traits; + +namespace NexusMods.Games.Larian.BaldursGate3.Installers; + +/// +/// Installer for the Baldur's Gate 3 Script Extender +/// BG3SE GitHub Repository +/// BG3SE Nexus Mods Page +/// +public class BG3SEInstaller : ALibraryArchiveInstaller +{ + public BG3SEInstaller(IServiceProvider serviceProvider) : + base(serviceProvider, serviceProvider.GetRequiredService>()) + { + } + + public override ValueTask ExecuteAsync( + LibraryArchive.ReadOnly libraryArchive, + LoadoutItemGroup.New loadoutGroup, + ITransaction transaction, + Loadout.ReadOnly loadout, + CancellationToken cancellationToken) + { + var tree = libraryArchive.GetTree(); + var nodes = tree.FindSubPathsByKeyUpward(["DWrite.dll"]); + if (nodes.Count == 0) + return ValueTask.FromResult(new NotSupported()); + var dllNode = nodes[0]; + var parent = dllNode.Parent() ?? tree; + + List results = []; + foreach (var fileNode in parent.EnumerateFilesBfs()) + { + var relativePath = new RelativePath("Bin").Join(fileNode.Value.Item.Path.DropFirst(parent.Depth())); + var loadoutFile = new LoadoutFile.New(transaction, out var id) + { + LoadoutItemWithTargetPath = new LoadoutItemWithTargetPath.New(transaction, id) + { + TargetPath = (loadout.Id, LocationId.Game, relativePath), + LoadoutItem = new LoadoutItem.New(transaction, id) + { + Name = relativePath.Name, + LoadoutId = loadout.Id, + ParentId = loadoutGroup.Id, + }, + }, + Hash = fileNode.Value.Item.LibraryFile.Value.Hash, + Size = fileNode.Value.Item.LibraryFile.Value.Size, + }; + results.Add(loadoutFile); + } + + return results.Count > 0 + ? ValueTask.FromResult(new Success()) + : ValueTask.FromResult(new NotSupported()); + } +} diff --git a/src/Games/NexusMods.Games.Larian/BaldursGate3/RunGameTools/BG3RunGameTool.cs b/src/Games/NexusMods.Games.Larian/BaldursGate3/RunGameTools/BG3RunGameTool.cs new file mode 100644 index 0000000000..fa2f4600dc --- /dev/null +++ b/src/Games/NexusMods.Games.Larian/BaldursGate3/RunGameTools/BG3RunGameTool.cs @@ -0,0 +1,10 @@ +using NexusMods.Abstractions.Games; + +namespace NexusMods.Games.Larian.BaldursGate3.RunGameTools; + +public class BG3RunGameTool : RunGameTool +{ + public BG3RunGameTool(IServiceProvider serviceProvider, BaldursGate3 game) : base(serviceProvider, game) + { + } +} diff --git a/src/Games/NexusMods.Games.Larian/BaldursGate3/Services.cs b/src/Games/NexusMods.Games.Larian/BaldursGate3/Services.cs index 94f8761f47..eed4afbb1f 100644 --- a/src/Games/NexusMods.Games.Larian/BaldursGate3/Services.cs +++ b/src/Games/NexusMods.Games.Larian/BaldursGate3/Services.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.DependencyInjection; using NexusMods.Abstractions.Games; +using NexusMods.Abstractions.Loadouts; using NexusMods.Abstractions.Settings; +using NexusMods.Games.Larian.BaldursGate3.RunGameTools; namespace NexusMods.Games.Larian.BaldursGate3; @@ -10,6 +12,7 @@ public static IServiceCollection AddBaldursGate3(this IServiceCollection service { services .AddGame() + .AddSingleton() .AddSettings(); return services; diff --git a/src/Games/NexusMods.Games.Larian/NexusMods.Games.Larian.csproj b/src/Games/NexusMods.Games.Larian/NexusMods.Games.Larian.csproj index a87de8aaa1..a07634b348 100644 --- a/src/Games/NexusMods.Games.Larian/NexusMods.Games.Larian.csproj +++ b/src/Games/NexusMods.Games.Larian/NexusMods.Games.Larian.csproj @@ -3,6 +3,7 @@ + diff --git a/tests/Games/NexusMods.Games.AdvancedInstaller.Tests/ModInstallDestinationTests.cs b/tests/Games/NexusMods.Games.AdvancedInstaller.Tests/ModInstallDestinationTests.cs index fd686439a2..7f9044fd0c 100644 --- a/tests/Games/NexusMods.Games.AdvancedInstaller.Tests/ModInstallDestinationTests.cs +++ b/tests/Games/NexusMods.Games.AdvancedInstaller.Tests/ModInstallDestinationTests.cs @@ -1,7 +1,6 @@ using FluentAssertions; using NexusMods.Abstractions.GameLocators; using NexusMods.Abstractions.GameLocators.GameCapabilities; -using NexusMods.Abstractions.Games.GameCapabilities; using NexusMods.Paths; namespace NexusMods.Games.AdvancedInstaller.Tests; @@ -57,7 +56,7 @@ public void FromInstallFolderTargets_DetectsNestedChildren() static readonly InstallFolderTarget GameRootInstallFolderTarget = new() { DestinationGamePath = new GamePath(LocationId.Game, RelativePath.Empty), - KnownValidSubfolders = new[] { "data" }, - SubTargets = new[] { DataInstallFolderTarget } + Names = [ "data" ], + SubTargets = [DataInstallFolderTarget] }; } diff --git a/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=BG3 Script Extender.verified.txt b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=BG3 Script Extender.verified.txt new file mode 100644 index 0000000000..5afc503be9 --- /dev/null +++ b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=BG3 Script Extender.verified.txt @@ -0,0 +1,12 @@ +[ + { + FromPath: DWrite.dll, + Hash: 0x8EF806095E9F8F9B, + ToGamePath: {Game}/Bin/DWrite.dll + }, + { + FromPath: ScriptExtenderSettings.json, + Hash: 0x273ED670FBF4332F, + ToGamePath: {Game}/Bin/ScriptExtenderSettings.json + } +] \ No newline at end of file diff --git a/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=Bin Mod.verified.txt b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=Bin Mod.verified.txt new file mode 100644 index 0000000000..6307ff86ab --- /dev/null +++ b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=Bin Mod.verified.txt @@ -0,0 +1,7 @@ +[ + { + FromPath: bin/bink2w64.dll, + Hash: 0xC6363140D5333C3E, + ToGamePath: {Game}/bin/bink2w64.dll + } +] \ No newline at end of file diff --git a/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=Data Mod.verified.txt b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=Data Mod.verified.txt new file mode 100644 index 0000000000..7dc07d5f87 --- /dev/null +++ b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=Data Mod.verified.txt @@ -0,0 +1,12 @@ +[ + { + FromPath: Recommended/Data/Generated/Public/Shared/Content/Assets/Characters/Character Editor Presets/Origin Presets/[PAK]_Shadowheart/_merged.lsf, + Hash: 0x8ED74FCCE6291C5E, + ToGamePath: {Game}/Data/Generated/Public/Shared/Content/Assets/Characters/Character Editor Presets/Origin Presets/[PAK]_Shadowheart/_merged.lsf + }, + { + FromPath: Recommended/Data/Generated/Public/Shared/Assets/Characters/_Models/Humans/_Female/_Hair/Resources/HAIR_HUM_F_Shadowheart_Spring.gr2, + Hash: 0x3103D39FC0AEBDE5, + ToGamePath: {Game}/Data/Generated/Public/Shared/Assets/Characters/_Models/Humans/_Female/_Hair/Resources/HAIR_HUM_F_Shadowheart_Spring.gr2 + } +] \ No newline at end of file diff --git a/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=Data Public Mod with nested Data folder.verified.txt b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=Data Public Mod with nested Data folder.verified.txt new file mode 100644 index 0000000000..1283abd521 --- /dev/null +++ b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=Data Public Mod with nested Data folder.verified.txt @@ -0,0 +1,12 @@ +[ + { + FromPath: Public/Shared/Stats/Generated/Data/XPData1.txt, + Hash: 0xC98EB6BC4E8661F0, + ToGamePath: {Game}/Data/Public/Shared/Stats/Generated/Data/XPData1.txt + }, + { + FromPath: Public/SharedDev/Stats/Generated/Data/XPData2.txt, + Hash: 0x9345F61E5A6C7013, + ToGamePath: {Game}/Data/Public/SharedDev/Stats/Generated/Data/XPData2.txt + } +] \ No newline at end of file diff --git a/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=Multiple Pak files Mod.verified.txt b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=Multiple Pak files Mod.verified.txt new file mode 100644 index 0000000000..f460894dd8 --- /dev/null +++ b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=Multiple Pak files Mod.verified.txt @@ -0,0 +1,12 @@ +[ + { + FromPath: myMod1.pak, + Hash: 0x12382AC3D91AF5AC, + ToGamePath: {Mods}/myMod1.pak + }, + { + FromPath: myMod2.pak, + Hash: 0x39F52D14C582063C, + ToGamePath: {Mods}/myMod2.pak + } +] \ No newline at end of file diff --git a/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=NativeMods Mod.verified.txt b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=NativeMods Mod.verified.txt new file mode 100644 index 0000000000..4bfef51d11 --- /dev/null +++ b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=NativeMods Mod.verified.txt @@ -0,0 +1,12 @@ +[ + { + FromPath: NativeMods/BG3NativeCameraTweaks.dll, + Hash: 0x9D3A484CEE5E5A51, + ToGamePath: {Game}/bin/NativeMods/BG3NativeCameraTweaks.dll + }, + { + FromPath: BG3NativeCameraTweaks.toml, + Hash: 0xF52CC272D80C600A, + ToGamePath: {Game}/bin/BG3NativeCameraTweaks.toml + } +] \ No newline at end of file diff --git a/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=Nested Pak Mod.verified.txt b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=Nested Pak Mod.verified.txt new file mode 100644 index 0000000000..c07f55776c --- /dev/null +++ b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=Nested Pak Mod.verified.txt @@ -0,0 +1,7 @@ +[ + { + FromPath: Mods/myMod.pak, + Hash: 0x33C4706972EAAE6E, + ToGamePath: {Mods}/myMod.pak + } +] \ No newline at end of file diff --git a/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=Simple Pak Mod.verified.txt b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=Simple Pak Mod.verified.txt new file mode 100644 index 0000000000..a847ae9fe2 --- /dev/null +++ b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.CanInstallBG3Mods_testCaseName=Simple Pak Mod.verified.txt @@ -0,0 +1,7 @@ +[ + { + FromPath: myMod.pak, + Hash: 0x23BC397CD47BAFDA, + ToGamePath: {Mods}/myMod.pak + } +] \ No newline at end of file diff --git a/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.cs b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.cs new file mode 100644 index 0000000000..f19794be6e --- /dev/null +++ b/tests/Games/NexusMods.Games.Larian.Tests/BaldursGate3/BG3InstallerTests.cs @@ -0,0 +1,84 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.DependencyInjection; +using NexusMods.Games.Generic.Installers; +using NexusMods.Games.Larian.BaldursGate3; +using NexusMods.Games.Larian.BaldursGate3.Installers; +using NexusMods.Games.TestFramework; +using NexusMods.Paths; +using NexusMods.StandardGameLocators.TestHelpers; +using Xunit.Abstractions; + +namespace NexusMods.Games.Larian.Tests.BaldursGate3; + +public class BG3InstallerTests(ITestOutputHelper outputHelper) : ALibraryArchiveInstallerTests(outputHelper) +{ + protected override IServiceCollection AddServices(IServiceCollection services) + { + return base.AddServices(services) + .AddBaldursGate3() + .AddUniversalGameLocator(new Version("1.6.1")); + } + + + /// + /// Test cases, key is the name, the values are the archive file paths. + /// + public static readonly List<(string TestName, Type InstallerType, string[] Paths)> TestCases = + [ + ("BG3 Script Extender", typeof(BG3SEInstaller), ["DWrite.dll", "ScriptExtenderSettings.json"]), + ("Simple Pak Mod", typeof(GenericPatternMatchInstaller), ["myMod.pak", "info.json"]), + ("Nested Pak Mod", typeof(GenericPatternMatchInstaller), ["Mods/myMod.pak", "Mods/info.json", "readme.txt"]), + ("Multiple Pak files Mod", typeof(GenericPatternMatchInstaller), ["myMod1.pak", "myMod2.pak", "info.json", "readme.txt"]), + ("Bin Mod", typeof(GenericPatternMatchInstaller), ["bin/bink2w64.dll", "bink2w64_original.dll"]), + ("NativeMods Mod", typeof(GenericPatternMatchInstaller), [ + "NativeMods/BG3NativeCameraTweaks.dll", + "BG3NativeCameraTweaks.toml", + ]), + ("Data Mod", typeof(GenericPatternMatchInstaller), [ + "Recommended/Data/Generated/Public/Shared/Assets/Characters/_Models/Humans/_Female/_Hair/Resources/HAIR_HUM_F_Shadowheart_Spring.gr2", + "Recommended/Data/Generated/Public/Shared/Content/Assets/Characters/Character Editor Presets/Origin Presets/[PAK]_Shadowheart/_merged.lsf", + ]), + ("Data Public Mod with nested Data folder", typeof(GenericPatternMatchInstaller), [ + "Public/Shared/Stats/Generated/Data/XPData1.txt", + "Public/SharedDev/Stats/Generated/Data/XPData2.txt", + ]), + ]; + + public static IEnumerable TestCaseData() + { + return TestCases.Select(row => (object[]) [row.TestName, row.InstallerType, row.Paths]); + } + + + [Theory] + [MemberData(nameof(TestCaseData))] + public async Task CanInstallBG3Mods(string testCaseName, Type installerType, string[] archivePaths) + { + var loadout = await CreateLoadout(); + var archive = await AddFromPaths(archivePaths); + var group = await Install(installerType, loadout, archive); + + await VerifyChildren(ChildrenFilesAndHashes(group), archivePaths).UseParameters(testCaseName); + } + + + private static SettingsTask VerifyChildren( + IEnumerable<(RelativePath FromPath, Hashing.xxHash64.Hash Hash, Abstractions.GameLocators.GamePath GamePath)> childrenFilesAndHashes, + string[] archivePaths, + [CallerFilePath] string sourceFile = "") + { + var asArray = childrenFilesAndHashes.ToArray(); + + return Verify(asArray.Select(row => + new + { + FromPath = row.FromPath.ToString(), + Hash = row.Hash.ToString(), + ToGamePath = row.GamePath.ToString(), + } + // ReSharper disable once ExplicitCallerInfoArgument + ), + sourceFile: sourceFile + ); + } +} diff --git a/tests/Games/NexusMods.Games.Larian.Tests/NexusMods.Games.Larian.Tests.csproj b/tests/Games/NexusMods.Games.Larian.Tests/NexusMods.Games.Larian.Tests.csproj new file mode 100644 index 0000000000..fdb03c8816 --- /dev/null +++ b/tests/Games/NexusMods.Games.Larian.Tests/NexusMods.Games.Larian.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/Games/NexusMods.Games.Larian.Tests/Startup.cs b/tests/Games/NexusMods.Games.Larian.Tests/Startup.cs new file mode 100644 index 0000000000..1876805268 --- /dev/null +++ b/tests/Games/NexusMods.Games.Larian.Tests/Startup.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NexusMods.Abstractions.FileStore; +using NexusMods.Abstractions.Games; +using NexusMods.Abstractions.GuidedInstallers; +using NexusMods.Abstractions.Loadouts; +using NexusMods.Abstractions.Serialization; +using NexusMods.App.BuildInfo; +using NexusMods.Games.Larian.BaldursGate3; +using NexusMods.Games.TestFramework; +using NexusMods.StandardGameLocators.TestHelpers; + +namespace NexusMods.Games.Larian.Tests; + +public class Startup +{ + public void ConfigureServices(IServiceCollection container) + { + container + .AddSingleton() + .AddDefaultServicesForTesting() + .AddUniversalGameLocator(new Version("1.61")) + .AddBaldursGate3() + .AddLogging(builder => builder.AddXUnit()) + .AddGames() + .AddSerializationAbstractions() + .AddLoadoutAbstractions() + .AddFileStoreAbstractions() + .Validate(); + } +}