Skip to content

Commit

Permalink
Merge pull request #2067 from Nexus-Mods/feat/bg3-support-installers
Browse files Browse the repository at this point in the history
Add support for installing BG3 mods
  • Loading branch information
Al12rs authored Sep 24, 2024
2 parents 7f0dd54 + 5160426 commit 7a1fbfc
Show file tree
Hide file tree
Showing 25 changed files with 571 additions and 31 deletions.
7 changes: 7 additions & 0 deletions NexusMods.App.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using NexusMods.Abstractions.Games.GameCapabilities;
using NexusMods.Paths;

namespace NexusMods.Abstractions.GameLocators.GameCapabilities;
Expand Down Expand Up @@ -56,11 +55,6 @@ public static void AddCommonLocations(IReadOnlyDictionary<LocationId, AbsolutePa
{
// Locations has
DestinationGamePath = new GamePath(location.Key, ""),
KnownSourceFolderNames = Array.Empty<string>(),
KnownValidSubfolders = Array.Empty<string>(),
KnownValidFileExtensions = Array.Empty<Extension>(),
FileExtensionsToDiscard = Array.Empty<Extension>(),
SubPathsToDiscard = Array.Empty<RelativePath>()
});
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Represents a target path for installing simple mods archives
Expand All @@ -21,32 +19,32 @@ public class InstallFolderTarget : IModInstallDestination
/// <summary>
/// List of known recognizable aliases that can be directly mapped to the <see cref="DestinationGamePath"/>.
/// </summary>
public IEnumerable<string> KnownSourceFolderNames { get; init; } = Enumerable.Empty<string>();
public IEnumerable<RelativePath> KnownSourceFolderNames { get; init; } = [];

/// <summary>
/// List of known recognizable first level subfolders of the target <see cref="DestinationGamePath"/>.
/// NOTE: Only include folders that are only likely to appear at this level of the folder hierarchy.
/// </summary>
public IEnumerable<string> KnownValidSubfolders { get; init; } = Enumerable.Empty<string>();
public IEnumerable<RelativePath> Names { get; init; } = [];

/// <summary>
/// List of known recognizable file extensions for direct children of the target <see cref="DestinationGamePath"/>.
/// NOTE: Only include file extensions that are only likely to appear at this level of the folder hierarchy.
/// </summary>
public IEnumerable<Extension> KnownValidFileExtensions { get; init; } = Enumerable.Empty<Extension>();
public IEnumerable<Extension> KnownValidFileExtensions { get; init; } = [];

/// <summary>
/// List of subPaths of the target <see cref="DestinationGamePath"/> that should be discarded.
/// </summary>
public IEnumerable<RelativePath> SubPathsToDiscard { get; init; } = Enumerable.Empty<RelativePath>();
public IEnumerable<RelativePath> SubPathsToDiscard { get; init; } = [];

/// <summary>
/// List of file extensions to discard when installing to this target.
/// </summary>
public IEnumerable<Extension> FileExtensionsToDiscard { get; init; } = Enumerable.Empty<Extension>();
public IEnumerable<Extension> FileExtensionsToDiscard { get; init; } = [];

/// <summary>
/// Collection of Targets that are nested paths relative to <see cref="DestinationGamePath"/>.
/// </summary>
public IEnumerable<InstallFolderTarget> SubTargets { get; init; } = Enumerable.Empty<InstallFolderTarget>();
public IEnumerable<InstallFolderTarget> SubTargets { get; init; } = [];
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ public override async ValueTask<InstallerResult> ExecuteAsync(
CancellationToken cancellationToken)
{
if (Headless) return new NotSupported();

var tree = LibraryArchiveTree.Create(libraryArchive);
var (shouldInstall, deploymentData) = await GetDeploymentDataAsync(loadoutGroup.GetLoadoutItem(transaction).Name, tree, loadout);

Expand Down
Original file line number Diff line number Diff line change
@@ -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<KeyedBox<TKey, TSelf>> EnumerateFilesBfsWhereBranch<TSelf, TKey>(
this KeyedBox<TKey, TSelf> item,
Func<KeyedBox<TKey, TSelf>, bool> predicate)
where TKey : notnull
where TSelf : struct, IHaveAFileOrDirectory, IHaveBoxedChildrenWithKey<TKey, TSelf>, IHaveKey<TKey>
{
var queue = new Queue<KeyedBox<TKey, TSelf>>();
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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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);


/// <summary>
/// Generic mod installer for mods that only need to have their contents placed to a specific game location
/// (<see cref="InstallFolderTarget"/>).
/// Tries to match the mod archive folder structure to <see cref="InstallFolderTarget"/> requirements.
///
/// Example: myMod/Textures/myTexture.dds -> Skyrim/Data/Textures/myTexture.dds
/// </summary>
public class GenericPatternMatchInstaller : ALibraryArchiveInstaller
{
public GenericPatternMatchInstaller(IServiceProvider serviceProvider) :
base(serviceProvider, serviceProvider.GetRequiredService<ILogger<GenericPatternMatchInstaller>>())
{
}

public InstallFolderTarget[] InstallFolderTargets { get; init; } = [];

public override ValueTask<InstallerResult> 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<InstallerResult>(new NotSupported());

var tree = libraryArchive.GetTree();

return InstallFolderTargets.Any(target => TryInstallForTarget(target, tree, installDataTuple))
? ValueTask.FromResult<InstallerResult>(new Success())
: ValueTask.FromResult<InstallerResult>(new NotSupported());
}

private bool TryInstallForTarget(InstallFolderTarget target, KeyedBox<RelativePath, LibraryArchiveTree> 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<RelativePath, LibraryArchiveTree> node, InstallFolderTarget target, [NotNullWhen(true)] out KeyedBox<RelativePath, LibraryArchiveTree>? 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<RelativePath, LibraryArchiveTree> 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<RelativePath, LibraryArchiveTree> 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,
};
}
}
Loading

0 comments on commit 7a1fbfc

Please sign in to comment.