diff --git a/CHANGELOG.md b/CHANGELOG.md
index 193624598a..5609d42466 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,64 @@
# Changelog
-## [v0.6.0](https://github.com/Nexus-Mods/NexusMods.App/releases/tag/v0.6.0) - 2024-08-29
+## [v0.6.1](https://github.com/Nexus-Mods/NexusMods.App/releases/tag/v0.6.1) - 2024-09-24
# Caution: To update the app, you must completely uninstall the old version, including all mods. [Learn More.](https://nexus-mods.github.io/NexusMods.App/users/faq/#why-do-i-have-to-uninstall-everything-to-update-the-app)
+This release adds a very basic implementation of downloading Collections, updates the UI to the new tree view and includes some enhancements when interacting with Windows applications via Linux.
+
+### New UI for My Mods and Library
+The My Mods and Library pages have been completely reworked to use the new tree view. Mods are now grouped by the mod page on Nexus Mods, meaning if download several files from the same page they will be grouped together. A "Switch View" option has been added to the toolbar to toggle these groupings on or off. We are continuing to work towards to designs shown in the [previous changelog](./docs/changelog-assets/1b28e2fad5b5a6431a72c286d1bcd3fd.webp).
+
+![An image showing mods in the Library nested by mod page (left) or ungrouped (right)](./docs/changelog-assets/823627a8ccb068dc1559d62cd3326ebe.webp)
+
+### EXPERIMENTAL - Collections
+**WARNING: The feature is unfinished and not considered stable. It will not accurately install complex collections and is currently only functional for Premium users.**
+
+We've included a very early implementation of the Collections feature in this release. It's incomplete and will not install collections as the user has set them up in Vortex. Currently, only mods from Nexus Mods can be installed - anything from external websites or bundled with the collection will not install as expected.
+
+![A collection for Cyberpunk 2077 installed into a loadout.](./docs/changelog-assets/8a591449d6a8cddc5d2bad7d1fd5c849.webp)
+
+Collections will appear as a separate list of mods in the left menu. Users can view all mods in the loadout from the new "Installed Mods" option at the top of the left menu.
+
+To start out, this will only be available to Premium users, but we are working on the free user journey separately which requires considerably more UI elements to be created. This will be available in a future release.
+
+
+### Cyberpunk 2077 Enhancements
+As a further enhancement to support for Cyberpunk 2077, we will now detect if the REDmod DLC is missing and prompt the user to install it if required.
+
+![The diagnostic message for REDmod shown in the Health Check.](./docs/changelog-assets/531dc13e8116620f8ded4a8a98b281da.webp)
+
+We've also fixed the issue which prevented REDmod from deploying automatically on Linux. This work also sets up a framework for running Windows apps and tools on a Linux system using [Protontricks](https://github.com/Matoking/protontricks) ([#1989](https://github.com/Nexus-Mods/NexusMods.App/pull/1989)).
+
+### Known Issues
+- When batch selecting mods in My Mods and using the remove button the app will occasionally fail to remove mods that are not currently visible in the UI due to scrolling.
+- Trying to install a collection with an unsupported type of mod (e.g. Bundled or External) will fail with no error message. This is not supported in the current build.
+- Trying to install a collection as a non-Premium user will fail with no error message. This is not supported in the current build.
+- Once a collection is added to the app, it cannot be removed from the left menu.
+- Collections allow users to modify the included mods but do not allow you to reset them to a the original state.
+- The first row of the My Mods or Library tables will sometimes be misaligned with the headers. Scrolling or adjusting any column width will correct this.
+- The "Switch View" option does not persist.
+
+### Other Features
+- The name of the active loadout will now appear in the top bar ([#1953](https://github.com/Nexus-Mods/NexusMods.App/pull/1953)).
+- The app now has a minimum window size of `360x360` to prevent it being resized to unusable dimensions ([#1947](https://github.com/Nexus-Mods/NexusMods.App/pull/1947)).
+
+### Bugfixes
+- Stardew Valley: Fixed enabled mods showing up as disabled in the diagnostics ([#1923](https://github.com/Nexus-Mods/NexusMods.App/pull/1953)).
+- Linux: Fixed the game not launching when running through Steam ([#1917](https://github.com/Nexus-Mods/NexusMods.App/pull/1917)).
+
+### Technical Changes
+- Added a system for storing and displaying images in the app.
+
+### External Contributors
+- [@Patriot99](https://github.com/Patriot99): [#1896](https://github.com/Nexus-Mods/NexusMods.App/pull/1896)
+- [@LoulouNoLegend](https://github.com/LoulouNoLegend): [#1997](https://github.com/Nexus-Mods/NexusMods.App/pull/1997), [#1998](https://github.com/Nexus-Mods/NexusMods.App/pull/1998), [#1999](https://github.com/Nexus-Mods/NexusMods.App/pull/1999)
+
+
+## [v0.6.0](https://github.com/Nexus-Mods/NexusMods.App/releases/tag/v0.6.0) - 2024-09-03
+
+**Caution: To update the app, you must completely uninstall the old version, including all mods. [Learn More.](https://nexus-mods.github.io/NexusMods.App/users/faq/#why-do-i-have-to-uninstall-everything-to-update-the-app)**
+
This release enhances support for Cyberpunk 2077, adds multiple Loadouts per game and implements the back-end changes to support our new "Loadout items" data model.
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 3ceb5f0728..f76858446f 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -6,6 +6,7 @@
+
@@ -120,7 +121,7 @@
-
+
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/docs/changelog-assets/531dc13e8116620f8ded4a8a98b281da.webp b/docs/changelog-assets/531dc13e8116620f8ded4a8a98b281da.webp
new file mode 100644
index 0000000000..e1186c17ab
Binary files /dev/null and b/docs/changelog-assets/531dc13e8116620f8ded4a8a98b281da.webp differ
diff --git a/docs/changelog-assets/823627a8ccb068dc1559d62cd3326ebe.webp b/docs/changelog-assets/823627a8ccb068dc1559d62cd3326ebe.webp
new file mode 100644
index 0000000000..030666bac8
Binary files /dev/null and b/docs/changelog-assets/823627a8ccb068dc1559d62cd3326ebe.webp differ
diff --git a/docs/changelog-assets/8a591449d6a8cddc5d2bad7d1fd5c849.webp b/docs/changelog-assets/8a591449d6a8cddc5d2bad7d1fd5c849.webp
new file mode 100644
index 0000000000..70a92a284c
Binary files /dev/null and b/docs/changelog-assets/8a591449d6a8cddc5d2bad7d1fd5c849.webp differ
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.GameLocators/Stores/GOG/GOGLocatorResultMetadata.cs b/src/Abstractions/NexusMods.Abstractions.GameLocators/Stores/GOG/GOGLocatorResultMetadata.cs
index d5cba47d39..1c471ca5cb 100644
--- a/src/Abstractions/NexusMods.Abstractions.GameLocators/Stores/GOG/GOGLocatorResultMetadata.cs
+++ b/src/Abstractions/NexusMods.Abstractions.GameLocators/Stores/GOG/GOGLocatorResultMetadata.cs
@@ -14,3 +14,6 @@ public record GOGLocatorResultMetadata : IGameLocatorResultMetadata
///
public required long Id { get; init; }
}
+
+[PublicAPI]
+public record HeroicGOGLocatorResultMetadata : GOGLocatorResultMetadata;
diff --git a/src/Abstractions/NexusMods.Abstractions.Games/RunGameTool.cs b/src/Abstractions/NexusMods.Abstractions.Games/RunGameTool.cs
index 39c9851350..1748a1835a 100644
--- a/src/Abstractions/NexusMods.Abstractions.Games/RunGameTool.cs
+++ b/src/Abstractions/NexusMods.Abstractions.Games/RunGameTool.cs
@@ -5,6 +5,7 @@
using Microsoft.Extensions.Logging;
using NexusMods.Abstractions.GameLocators;
using NexusMods.Abstractions.Games.DTO;
+using NexusMods.Abstractions.Games.Stores.GOG;
using NexusMods.Abstractions.Games.Stores.Steam;
using NexusMods.Abstractions.Loadouts;
using NexusMods.CrossPlatform.Process;
@@ -60,10 +61,18 @@ public async Task Execute(Loadout.ReadOnly loadout, CancellationToken cancellati
var program = await GetGamePath(loadout);
var primaryFile = _game.GetPrimaryFile(loadout.InstallationInstance.Store).CombineChecked(loadout.InstallationInstance);
- if (OSInformation.Shared.IsLinux && program.Equals(primaryFile) && loadout.InstallationInstance.LocatorResultMetadata is SteamLocatorResultMetadata steamLocatorResultMetadata)
+ if (OSInformation.Shared.IsLinux && program.Equals(primaryFile))
{
- await RunThroughSteam(steamLocatorResultMetadata.AppId, cancellationToken);
- return;
+ var locator = loadout.InstallationInstance.LocatorResultMetadata;
+ switch (locator)
+ {
+ case SteamLocatorResultMetadata steamLocatorResultMetadata:
+ await RunThroughSteam(steamLocatorResultMetadata.AppId, cancellationToken);
+ return;
+ case HeroicGOGLocatorResultMetadata heroicGOGLocatorResultMetadata:
+ await RunThroughHeroic("gog", heroicGOGLocatorResultMetadata.Id, cancellationToken);
+ return;
+ }
}
var names = new HashSet
@@ -177,6 +186,14 @@ private async Task RunThroughSteam(uint appId, CancellationToken cancellationTok
await reaper.WaitForExitAsync(cancellationToken);
}
+ private async Task RunThroughHeroic(string type, long appId, CancellationToken cancellationToken)
+ {
+ Debug.Assert(OSInformation.Shared.IsLinux);
+
+ // TODO: track process
+ await _osInterop.OpenUrl(new Uri($"heroic://launch/{type}/{appId.ToString(CultureInfo.InvariantCulture)}"), fireAndForget: true, cancellationToken: cancellationToken);
+ }
+
private async ValueTask WaitForProcessToStart(
string processName,
TimeSpan timeout,
diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/ALoadoutSynchronizer.cs b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/ALoadoutSynchronizer.cs
index 2ee1f7789d..5b3165d2d2 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/Abstractions/NexusMods.Abstractions.NexusModsLibrary/GraphQLResolver.cs b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/GraphQLResolver.cs
index 47153853cd..e278def657 100644
--- a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/GraphQLResolver.cs
+++ b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/GraphQLResolver.cs
@@ -1,5 +1,6 @@
using NexusMods.MnemonicDB.Abstractions;
using NexusMods.MnemonicDB.Abstractions.Attributes;
+using NexusMods.MnemonicDB.Abstractions.IndexSegments;
using NexusMods.MnemonicDB.Abstractions.Models;
using Splat.ModeDetection;
@@ -19,13 +20,34 @@ public readonly struct GraphQLResolver(ITransaction Tx, ReadOnlyModel Model)
///
/// Create a new resolver using the given primary key attribute and value.
///
- public static GraphQLResolver Create(IDb referenceDb, ITransaction tx, ScalarAttribute primaryKeyAttribute, THighLevel primaryKeyValue) where THighLevel : notnull
+ public static GraphQLResolver Create(IDb db, ITransaction tx, ScalarAttribute primaryKeyAttribute, THighLevel primaryKeyValue) where THighLevel : notnull
{
- var existing = referenceDb.Datoms(primaryKeyAttribute, primaryKeyValue);
+ var existing = db.Datoms(primaryKeyAttribute, primaryKeyValue);
var exists = existing.Count > 0;
var id = existing.Count == 0 ? tx.TempId() : existing[0].E;
if (!exists)
tx.Add(id, primaryKeyAttribute, primaryKeyValue);
+ return new GraphQLResolver(tx, new ReadOnlyModel(db, id));
+ }
+
+ ///
+ /// Create a resolver that depends on two primary key attributes and values.
+ ///
+ public static GraphQLResolver Create(IDb referenceDb, ITransaction tx,
+ (ScalarAttribute A, THighLevel1 V) pair1,
+ (ScalarAttribute A, THighLevel2 V) pair2)
+ where THighLevel1 : notnull
+ where THighLevel2 : notnull
+ {
+ var existing = referenceDb.Datoms(pair1, pair2);
+ var exists = existing.Count > 0;
+ var id = existing.Count == 0 ? tx.TempId() : existing[0];
+ if (!exists)
+ {
+ tx.Add(id, pair1.A, pair1.V);
+ tx.Add(id, pair2.A, pair2.V);
+ }
+
return new GraphQLResolver(tx, new ReadOnlyModel(referenceDb, id));
}
@@ -81,7 +103,7 @@ public void Add(ReferenceAttribute attribute, EntityId id)
return;
}
- if (attribute.Get(Model).Equals(id))
+ if (attribute.TryGet(Model, out var foundId) && foundId.Equals(id))
return;
// Else add the value
diff --git a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Models/CollectionMetadata.cs b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Models/CollectionMetadata.cs
index 630e0bba19..4b2b05198c 100644
--- a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Models/CollectionMetadata.cs
+++ b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Models/CollectionMetadata.cs
@@ -51,4 +51,9 @@ public partial class CollectionMetadata : IModelDefinition
/// The collections' image.
///
public static readonly MemoryAttribute TileImage = new(Namespace, nameof(TileImage));
+
+ ///
+ /// The collections' image.
+ ///
+ public static readonly MemoryAttribute BackgroundImage = new(Namespace, nameof(BackgroundImage));
}
diff --git a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Models/CollectionRevisionMetadata.cs b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Models/CollectionRevisionMetadata.cs
index 82bc789c40..9fe2c1745d 100644
--- a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Models/CollectionRevisionMetadata.cs
+++ b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Models/CollectionRevisionMetadata.cs
@@ -21,13 +21,18 @@ public partial class CollectionRevisionMetadata : IModelDefinition
///
/// The locally unique revision number (aka "version") of a collection. Only unique within one collection.
///
- public static readonly RevisionNumberAttribute RevisionNumber = new(Namespace, nameof(RevisionNumber));
+ public static readonly RevisionNumberAttribute RevisionNumber = new(Namespace, nameof(RevisionNumber)) { IsIndexed = true };
///
/// The collection this revision belongs to.
///
public static readonly ReferenceAttribute Collection = new(Namespace, nameof(Collection));
+ ///
+ /// All the mod files in this revision.
+ ///
+ public static readonly BackReferenceAttribute Files = new(CollectionRevisionModFile.CollectionRevision);
+
///
/// The number of downloads this revision has.
///
@@ -47,9 +52,4 @@ public partial class CollectionRevisionMetadata : IModelDefinition
/// The total number of ratings this revision has.
///
public static readonly ULongAttribute TotalRatings = new(Namespace, nameof(TotalRatings));
-
- ///
- /// The total number of mods in this revision.
- ///
- public static readonly ULongAttribute ModCount = new(Namespace, nameof(ModCount));
}
diff --git a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Models/CollectionRevisionModFile.cs b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Models/CollectionRevisionModFile.cs
new file mode 100644
index 0000000000..0331888959
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Models/CollectionRevisionModFile.cs
@@ -0,0 +1,30 @@
+using NexusMods.Abstractions.MnemonicDB.Attributes;
+using NexusMods.MnemonicDB.Abstractions.Attributes;
+using NexusMods.MnemonicDB.Abstractions.Models;
+
+namespace NexusMods.Abstractions.NexusModsLibrary.Models;
+
+public partial class CollectionRevisionModFile : IModelDefinition
+{
+ private const string Namespace = "NexusMods.Library.CollectionRevisionModFile";
+
+ ///
+ /// The Nexus, globally unique id identifying a specific file of a collection revision.
+ ///
+ public static readonly ULongAttribute FileId = new(Namespace, nameof(FileId)) { IsIndexed = true };
+
+ ///
+ /// If the file is optional or not
+ ///
+ public static readonly BooleanAttribute IsOptional = new(Namespace, nameof(IsOptional));
+
+ ///
+ /// The associated NexusModsFileMetadata that contains the other metadata of the file.
+ ///
+ public static readonly ReferenceAttribute NexusModFile = new(Namespace, nameof(NexusModFile));
+
+ ///
+ /// The associated CollectionRevision
+ ///
+ public static readonly ReferenceAttribute CollectionRevision = new(Namespace, nameof(CollectionRevision));
+}
diff --git a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusModsFileMetadata.cs b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusModsFileMetadata.cs
index ed78ee9333..404c0cb288 100644
--- a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusModsFileMetadata.cs
+++ b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusModsFileMetadata.cs
@@ -1,4 +1,5 @@
using JetBrains.Annotations;
+using NexusMods.Abstractions.MnemonicDB.Attributes;
using NexusMods.Abstractions.NexusWebApi.Types;
using NexusMods.MnemonicDB.Abstractions.Attributes;
using NexusMods.MnemonicDB.Abstractions.Models;
@@ -27,6 +28,11 @@ public partial class NexusModsFileMetadata : IModelDefinition
/// The version of the file.
///
public static readonly StringAttribute Version = new(Namespace, nameof(Version));
+
+ ///
+ /// The size in bytes of the file.
+ ///
+ public static readonly SizeAttribute Size = new(Namespace, nameof(Size)) { IsOptional = true };
///
/// Reference to the mod page of the file.
diff --git a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Services.cs b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Services.cs
index 5164a16d5c..84be9a0a39 100644
--- a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Services.cs
+++ b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Services.cs
@@ -21,6 +21,7 @@ public static IServiceCollection AddNexusModsLibraryModels(this IServiceCollecti
.AddNexusModsLibraryFileModel()
.AddCollectionMetadataModel()
.AddCollectionRevisionMetadataModel()
+ .AddCollectionRevisionModFileModel()
.AddCollectionTagModel()
.AddUserModel()
.AddNexusModsCollectionLibraryFileModel();
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/src/Games/NexusMods.Games.RedEngine/Cyberpunk2077/Cyberpunk2077Game.cs b/src/Games/NexusMods.Games.RedEngine/Cyberpunk2077/Cyberpunk2077Game.cs
index 91bcf3055a..4146f23ddb 100644
--- a/src/Games/NexusMods.Games.RedEngine/Cyberpunk2077/Cyberpunk2077Game.cs
+++ b/src/Games/NexusMods.Games.RedEngine/Cyberpunk2077/Cyberpunk2077Game.cs
@@ -41,10 +41,12 @@ protected override IReadOnlyDictionary GetLocations(IF
var result = new Dictionary()
{
{ LocationId.Game, installation.Path },
- {
- LocationId.Saves,
- fileSystem.GetKnownPath(KnownPath.HomeDirectory).Combine("Saved Games/CD Projekt Red/Cyberpunk 2077")
- },
+ // Skip managing saves for now, to prevent accidental deletion of saves
+ // e.g. when removing loadouts, un-managing the game, or uninstalling the app
+ // {
+ // LocationId.Saves,
+ // fileSystem.GetKnownPath(KnownPath.HomeDirectory).Combine("Saved Games/CD Projekt Red/Cyberpunk 2077")
+ // },
{
LocationId.AppData,
fileSystem.GetKnownPath(KnownPath.LocalApplicationDataDirectory)
diff --git a/src/Games/NexusMods.Games.RedEngine/Cyberpunk2077/Cyberpunk2077Synchronizer.cs b/src/Games/NexusMods.Games.RedEngine/Cyberpunk2077/Cyberpunk2077Synchronizer.cs
index ccfeca98f5..30145ef8e5 100644
--- a/src/Games/NexusMods.Games.RedEngine/Cyberpunk2077/Cyberpunk2077Synchronizer.cs
+++ b/src/Games/NexusMods.Games.RedEngine/Cyberpunk2077/Cyberpunk2077Synchronizer.cs
@@ -27,6 +27,10 @@ public class Cyberpunk2077Synchronizer : ALoadoutSynchronizer
///
private static GamePath RedModTweaksFolder => new(LocationId.Game, "tools/redmod/tweaks");
+ private static GamePath ArchivePcContentFolder => new(LocationId.Game, "archive/pc/content");
+
+ private static GamePath ArchivePcEp1Folder => new(LocationId.Game, "archive/pc/ep1");
+
private readonly RedModDeployTool _redModTool;
@@ -41,16 +45,18 @@ protected internal Cyberpunk2077Synchronizer(IServiceProvider provider) : base(p
private static readonly GamePath[] IgnoredBackupFolders =
[
- new GamePath(LocationId.Game, "archive/pc/content"),
- new GamePath(LocationId.Game, "archive/pc/ep1"),
+ ArchivePcContentFolder,
+ ArchivePcEp1Folder,
];
public override bool IsIgnoredPath(GamePath path)
{
// Ignore the mod cache folder, as it's regenerated by redmod every time we deploy using the tool.
+
return path.InFolder(RedModCacheFolder)
- || path.InFolder(RedModScriptsFolder)
- || path.InFolder(RedModTweaksFolder);
+ || path.InFolder(RedModScriptsFolder)
+ || path.InFolder(RedModTweaksFolder)
+ || !_settings.DoFullGameBackup && (path.InFolder(ArchivePcContentFolder) || path.InFolder(ArchivePcEp1Folder));
}
public override async Task Synchronize(Loadout.ReadOnly loadout)
diff --git a/src/Games/NexusMods.Games.RedEngine/RedModDeployTool.cs b/src/Games/NexusMods.Games.RedEngine/RedModDeployTool.cs
index c3516d13b7..edd35b2182 100644
--- a/src/Games/NexusMods.Games.RedEngine/RedModDeployTool.cs
+++ b/src/Games/NexusMods.Games.RedEngine/RedModDeployTool.cs
@@ -1,7 +1,9 @@
using System.Reflection;
using CliWrap;
+using Microsoft.Extensions.Logging;
using NexusMods.Abstractions.GameLocators;
using NexusMods.Abstractions.Games.DTO;
+using NexusMods.Abstractions.Games.Stores.Steam;
using NexusMods.Abstractions.Loadouts;
using NexusMods.Games.Generic;
using NexusMods.Paths;
@@ -13,9 +15,11 @@ public class RedModDeployTool : ITool
{
private readonly GameToolRunner _toolRunner;
private readonly TemporaryFileManager _temporaryFileManager;
+ private readonly ILogger _logger;
- public RedModDeployTool(GameToolRunner toolRunner, TemporaryFileManager temporaryFileManager)
+ public RedModDeployTool(GameToolRunner toolRunner, TemporaryFileManager temporaryFileManager, ILogger logger)
{
+ _logger = logger;
_toolRunner = toolRunner;
_temporaryFileManager = temporaryFileManager;
}
@@ -42,8 +46,15 @@ public async Task Execute(Loadout.ReadOnly loadout, CancellationToken cancellati
}
else
{
- await using var batchPath = await ExtractTemporaryDeployScript();
- await _toolRunner.ExecuteAsync(loadout, Cli.Wrap(batchPath.ToString()), true, cancellationToken);
+ if (loadout.InstallationInstance.LocatorResultMetadata is SteamLocatorResultMetadata)
+ {
+ await using var batchPath = await ExtractTemporaryDeployScript();
+ await _toolRunner.ExecuteAsync(loadout, Cli.Wrap(batchPath.ToString()), true, cancellationToken);
+ }
+ else
+ {
+ _logger.LogWarning("Skip running redmod, it's only supported for Steam on Linux at the moment");
+ }
}
}
diff --git a/src/Games/NexusMods.Games.StardewValley/Diagnostics.cs b/src/Games/NexusMods.Games.StardewValley/Diagnostics.cs
index b72a6b35d6..4ee86102a2 100644
--- a/src/Games/NexusMods.Games.StardewValley/Diagnostics.cs
+++ b/src/Games/NexusMods.Games.StardewValley/Diagnostics.cs
@@ -278,7 +278,7 @@ of the mod from {NexusModsLink}.
.WithSeverity(DiagnosticSeverity.Suggestion)
.WithSummary("Mod {GroupName} overwrites game files")
.WithDetails("""
-Mod {Mod} overwrites game files. This can cause compatibility issues and have other
+Mod {GroupName} overwrites game files. This can cause compatibility issues and have other
unintended side-effects. See the {SMAPIWikiLink} for details.
You can resolve this diagnostic by replacing {Group} with a SMAPI mod that doesn't
diff --git a/src/Games/NexusMods.Games.StardewValley/Emitters/ModOverwritesGameFilesEmitter.cs b/src/Games/NexusMods.Games.StardewValley/Emitters/ModOverwritesGameFilesEmitter.cs
index 5a8f0554f6..dca760fb52 100644
--- a/src/Games/NexusMods.Games.StardewValley/Emitters/ModOverwritesGameFilesEmitter.cs
+++ b/src/Games/NexusMods.Games.StardewValley/Emitters/ModOverwritesGameFilesEmitter.cs
@@ -32,7 +32,7 @@ public async IAsyncEnumerable Diagnose(
})
.Where(file => ((GamePath)file.AsLoadoutItemWithTargetPath().TargetPath).StartsWith(ContentDirectoryPath))
.Select(file => file.AsLoadoutItemWithTargetPath().AsLoadoutItem().Parent)
- .ToArray();
+ .DistinctBy(item => item.Id);
foreach (var group in groups)
{
diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/Extensions/FragmentExtensions.cs b/src/Networking/NexusMods.Networking.NexusWebApi/Extensions/FragmentExtensions.cs
new file mode 100644
index 0000000000..4151a111e1
--- /dev/null
+++ b/src/Networking/NexusMods.Networking.NexusWebApi/Extensions/FragmentExtensions.cs
@@ -0,0 +1,70 @@
+using NexusMods.Abstractions.Games.DTO;
+using NexusMods.Abstractions.NexusModsLibrary;
+using NexusMods.Abstractions.NexusModsLibrary.Models;
+using NexusMods.Abstractions.NexusWebApi.Types;
+using NexusMods.MnemonicDB.Abstractions;
+using NexusMods.Paths;
+
+namespace NexusMods.Networking.NexusWebApi.Extensions;
+
+///
+/// Extensions to GraphQL fragments.
+///
+public static class FragmentExtensions
+{
+ ///
+ /// Resolves the IUserFragment to an entity in the database, inserting or updating as necessary.
+ ///
+ public static async Task Resolve(this IUserFragment userFragment, IDb db, ITransaction tx, HttpClient client, CancellationToken token)
+ {
+ var userResolver = GraphQLResolver.Create(db, tx, User.NexusId, (ulong)userFragment.MemberId);
+ userResolver.Add(User.Name, userFragment.Name);
+ userResolver.Add(User.Avatar, new Uri(userFragment.Avatar));
+
+ var avatarImage = await DownloadImage(client, userFragment.Avatar, token);
+ userResolver.Add(User.AvatarImage,avatarImage);
+ return userResolver.Id;
+ }
+
+ ///
+ /// Resolves the IModFragment to an entity in the database, inserting or updating as necessary.
+ ///
+ public static EntityId Resolve(this IModFileFragment modFileFragment, IDb db, ITransaction tx, EntityId modEId)
+ {
+ var nexusFileResolver = GraphQLResolver.Create(db, tx, (NexusModsFileMetadata.FileId, FileId.From((ulong)modFileFragment.FileId)), (NexusModsFileMetadata.ModPageId, modEId));
+ nexusFileResolver.Add(NexusModsFileMetadata.ModPageId, modEId);
+ nexusFileResolver.Add(NexusModsFileMetadata.Name, modFileFragment.Name);
+ nexusFileResolver.Add(NexusModsFileMetadata.Version, modFileFragment.Version);
+ if (ulong.TryParse(modFileFragment.SizeInBytes, out var size))
+ nexusFileResolver.Add(NexusModsFileMetadata.Size, Size.From(size));
+ return nexusFileResolver.Id;
+ }
+
+ ///
+ /// Resolves the IModFragment to an entity in the database, inserting or updating as necessary.
+ ///
+ public static EntityId Resolve(this IModFragment modFragment, IDb db, ITransaction tx)
+ {
+ var nexusModResolver = GraphQLResolver.Create(db, tx, NexusModsModPageMetadata.ModId,
+ ModId.From((ulong)modFragment.ModId));
+
+ nexusModResolver.Add(NexusModsModPageMetadata.Name, modFragment.Name);
+ nexusModResolver.Add(NexusModsModPageMetadata.GameDomain, GameDomain.From(modFragment.Game.DomainName));
+
+ if (Uri.TryCreate(modFragment.PictureUrl, UriKind.Absolute, out var fullSizedPictureUri))
+ nexusModResolver.Add(NexusModsModPageMetadata.FullSizedPictureUri, fullSizedPictureUri);
+
+ if (Uri.TryCreate(modFragment.ThumbnailUrl, UriKind.Absolute, out var thumbnailUri))
+ nexusModResolver.Add(NexusModsModPageMetadata.ThumbnailUri, thumbnailUri);
+ return nexusModResolver.Id;
+ }
+
+ private static async Task DownloadImage(HttpClient client, string? uri, CancellationToken token)
+ {
+ if (uri is null) return [];
+ if (!Uri.TryCreate(uri, UriKind.Absolute, out var imageUri)) return [];
+
+ return await client.GetByteArrayAsync(imageUri, token);
+ }
+
+}
diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/GraphQL/CollectionInfo.graphql b/src/Networking/NexusMods.Networking.NexusWebApi/GraphQL/CollectionInfo.graphql
deleted file mode 100644
index 5ad1721da9..0000000000
--- a/src/Networking/NexusMods.Networking.NexusWebApi/GraphQL/CollectionInfo.graphql
+++ /dev/null
@@ -1,35 +0,0 @@
-
-# Gets information about a collection.
-query CollectionInfo($slug: String!, $viewAdultContent: Boolean!)
-{
- collection(slug: $slug, viewAdultContent: $viewAdultContent)
- {
- name
- summary
- endorsements
- tileImage {
- thumbnailUrl(size: small)
- }
- tags {
- name
- id
- }
- revisions
- {
- id
- totalDownloads
- assetsSizeBytes
- revisionNumber
- adultContent
- totalSize
- overallRating
- overallRatingCount
- modCount
- }
- user {
- name
- avatar
- memberId
- }
- }
-}
diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/GraphQL/CollectionRevisionInfo.graphql b/src/Networking/NexusMods.Networking.NexusWebApi/GraphQL/CollectionRevisionInfo.graphql
new file mode 100644
index 0000000000..efea25fd58
--- /dev/null
+++ b/src/Networking/NexusMods.Networking.NexusWebApi/GraphQL/CollectionRevisionInfo.graphql
@@ -0,0 +1,52 @@
+#import { UserFragment, ModFragment, ModFileFragment } from './CommonFragments.graphql';
+
+# Pulls all the information we need about a collection revision.
+query CollectionRevisionInfo($slug: String!, $revisionNumber: Int!, $viewAdultContent: Boolean!)
+{
+ collectionRevision(slug: $slug, revision: $revisionNumber, viewAdultContent: $viewAdultContent)
+ {
+ id
+ totalDownloads
+ assetsSizeBytes
+ revisionNumber
+ adultContent
+ totalSize
+ overallRating
+ overallRatingCount
+ modCount
+
+ modFiles {
+ id,
+ optional,
+ gameId,
+ fileId,
+ file {
+ ...ModFileFragment
+ mod {
+ ...ModFragment
+ }
+ }
+ updatePolicy,
+ version,
+ }
+
+ collection {
+ name
+ summary
+ endorsements
+ tileImage {
+ thumbnailUrl(size: small)
+ }
+ headerImage {
+ url
+ }
+ tags {
+ name
+ id
+ }
+ user {
+ ...UserFragment
+ }
+ }
+ }
+}
diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/GraphQL/CommonFragments.graphql b/src/Networking/NexusMods.Networking.NexusWebApi/GraphQL/CommonFragments.graphql
new file mode 100644
index 0000000000..f27b5fada4
--- /dev/null
+++ b/src/Networking/NexusMods.Networking.NexusWebApi/GraphQL/CommonFragments.graphql
@@ -0,0 +1,24 @@
+
+fragment UserFragment on User {
+ name
+ avatar
+ memberId
+}
+
+fragment ModFileFragment on ModFile {
+ name,
+ modId,
+ fileId,
+ version,
+ sizeInBytes
+}
+
+fragment ModFragment on Mod {
+ modId
+ name
+ game {
+ domainName
+ }
+ thumbnailUrl
+ pictureUrl
+}
diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/GraphQL/ModInfo.graphql b/src/Networking/NexusMods.Networking.NexusWebApi/GraphQL/ModInfo.graphql
new file mode 100644
index 0000000000..894a14b68c
--- /dev/null
+++ b/src/Networking/NexusMods.Networking.NexusWebApi/GraphQL/ModInfo.graphql
@@ -0,0 +1,12 @@
+#include { ModFragment } from './CommonFragments.graphql'
+
+query ModInfo($gameDomain: String!, $modId: Int!)
+{
+ legacyModsByDomain(ids: [{gameDomain: $gameDomain, modId: $modId}])
+ {
+ nodes
+ {
+ ...ModFragment
+ }
+ }
+}
diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/NexusModsLibrary.cs b/src/Networking/NexusMods.Networking.NexusWebApi/NexusModsLibrary.cs
index 5732b10b05..e378abe252 100644
--- a/src/Networking/NexusMods.Networking.NexusWebApi/NexusModsLibrary.cs
+++ b/src/Networking/NexusMods.Networking.NexusWebApi/NexusModsLibrary.cs
@@ -12,6 +12,7 @@
using NexusMods.Extensions.BCL;
using NexusMods.MnemonicDB.Abstractions;
using NexusMods.Networking.HttpDownloader;
+using NexusMods.Networking.NexusWebApi.Extensions;
using NexusMods.Paths;
using User = NexusMods.Abstractions.NexusModsLibrary.Models.User;
@@ -48,101 +49,80 @@ public NexusModsLibrary(IServiceProvider serviceProvider)
using var tx = _connection.BeginTransaction();
- var modInfo = await _apiClient.ModInfoAsync(gameDomain.ToString(), modId, cancellationToken);
-
- var newModPage = new NexusModsModPageMetadata.New(tx)
- {
- Name = modInfo.Data.Name,
- ModId = modId,
- GameDomain = gameDomain,
- };
-
- if (Uri.TryCreate(modInfo.Data.PictureUrl, UriKind.Absolute, out var fullSizedPictureUri))
+ var modInfo = await _gqlClient.ModInfo.ExecuteAsync(gameDomain.ToString(), (int)modId.Value, cancellationToken);
+ EntityId first = default;
+ foreach (var node in modInfo.Data!.LegacyModsByDomain.Nodes)
{
- newModPage.FullSizedPictureUri = fullSizedPictureUri;
-
- var thumbnailUrl = modInfo.Data.PictureUrl.Replace("/images/", "/images/thumbnails/", StringComparison.OrdinalIgnoreCase);
- if (Uri.TryCreate(thumbnailUrl, UriKind.Absolute, out var thumbnailUri))
- {
- newModPage.ThumbnailUri = thumbnailUri;
- }
+ first = node.Resolve(_connection.Db, tx);
}
-
+
var txResults = await tx.Commit();
- return txResults.Remap(newModPage);
+ return NexusModsModPageMetadata.Load(txResults.Db, txResults[first]);
}
-
+
///
/// Get or add a collection metadata
///
- public async Task GetOrAddCollectionMetadata(CollectionSlug slug, bool referesh = false, CancellationToken token = default)
+ public async Task GetOrAddCollectionRevision(CollectionSlug slug, RevisionNumber revisionNumber, CancellationToken token)
{
- if (!referesh)
- {
- var collections = CollectionMetadata.FindBySlug(_connection.Db, slug);
- if (collections.TryGetFirst(x => x.Slug == slug, out var collection))
- return collection;
- }
-
- var info = await _gqlClient.CollectionInfo.ExecuteAsync(slug.Value, true, token);
-
- var collectionInfo = info.Data!.Collection;
- var collectionTileImage = _httpClient.GetByteArrayAsync(new Uri(collectionInfo.TileImage!.ThumbnailUrl), token);
- var avatarImage = _httpClient.GetByteArrayAsync(new Uri(collectionInfo.User.Avatar), token);
+ var revisions = CollectionRevisionMetadata.FindByRevisionNumber(_connection.Db, revisionNumber)
+ .Where(r => r.Collection.Slug == slug);
+ if (revisions.TryGetFirst(r => r.RevisionNumber == revisionNumber, out var revision))
+ return revision;
+ var info = await _gqlClient.CollectionRevisionInfo.ExecuteAsync(slug.Value, (int)revisionNumber.Value, true, token);
using var tx = _connection.BeginTransaction();
+
var db = _connection.Db;
+ var collectionInfo = info.Data!.CollectionRevision.Collection;
+ var collectionTileImage = DownloadImage(collectionInfo.TileImage?.ThumbnailUrl, token);
+ var collectionBackgroundImage = DownloadImage(collectionInfo.HeaderImage?.Url, token);
+
+ // Remap the collection info
var collectionResolver = GraphQLResolver.Create(db, tx, CollectionMetadata.Slug, slug);
collectionResolver.Add(CollectionMetadata.Name, collectionInfo.Name);
collectionResolver.Add(CollectionMetadata.Summary, collectionInfo.Summary);
collectionResolver.Add(CollectionMetadata.Endorsements, (ulong)collectionInfo.Endorsements);
collectionResolver.Add(CollectionMetadata.TileImage, await collectionTileImage);
-
- // Remap the user info
- var userResolver = GraphQLResolver.Create(db, tx, User.NexusId, (ulong)collectionInfo.User.MemberId);
- userResolver.Add(User.Name, collectionInfo.User.Name);
- userResolver.Add(User.Avatar, new Uri(collectionInfo.User.Avatar));
- userResolver.Add(User.AvatarImage, await avatarImage);
+ collectionResolver.Add(CollectionMetadata.BackgroundImage, await collectionBackgroundImage);
- collectionResolver.Add(CollectionMetadata.Author, userResolver.Id);
+ var user = await collectionInfo.User.Resolve(db, tx, _httpClient, token);
+ collectionResolver.Add(CollectionMetadata.Author, user);
- // Remap the revisions
- foreach (var revision in collectionInfo.Revisions)
- {
- var revisionResolver = GraphQLResolver.Create(db, tx, CollectionRevisionMetadata.RevisionId, RevisionId.From((ulong)revision.Id));
- revisionResolver.Add(CollectionRevisionMetadata.RevisionId, RevisionId.From((ulong)revision.Id));
- revisionResolver.Add(CollectionRevisionMetadata.RevisionNumber, RevisionNumber.From((ulong)revision.RevisionNumber));
- revisionResolver.Add(CollectionRevisionMetadata.CollectionId, collectionResolver.Id);
- revisionResolver.Add(CollectionRevisionMetadata.Downloads, (ulong)revision.TotalDownloads);
- revisionResolver.Add(CollectionRevisionMetadata.TotalSize, Size.From(ulong.Parse(revision.TotalSize)));
- revisionResolver.Add(CollectionRevisionMetadata.OverallRating, float.Parse(revision.OverallRating ?? "0.0"));
- revisionResolver.Add(CollectionRevisionMetadata.TotalRatings, (ulong)(revision.OverallRatingCount ?? 0));
- revisionResolver.Add(CollectionRevisionMetadata.ModCount, (ulong)revision.ModCount);
- }
-
- foreach (var tag in collectionInfo.Tags)
+ // Remap the revision info
+ var revisionInfo = info.Data!.CollectionRevision;
+ var revisionResolver = GraphQLResolver.Create(db, tx, CollectionRevisionMetadata.RevisionId, RevisionId.From((ulong)revisionInfo.Id));
+ revisionResolver.Add(CollectionRevisionMetadata.RevisionId, RevisionId.From((ulong)revisionInfo.Id));
+ revisionResolver.Add(CollectionRevisionMetadata.RevisionNumber, RevisionNumber.From((ulong)revisionInfo.RevisionNumber));
+ revisionResolver.Add(CollectionRevisionMetadata.CollectionId, collectionResolver.Id);
+ revisionResolver.Add(CollectionRevisionMetadata.Downloads, (ulong)revisionInfo.TotalDownloads);
+ revisionResolver.Add(CollectionRevisionMetadata.TotalSize, Size.From(ulong.Parse(revisionInfo.TotalSize)));
+ revisionResolver.Add(CollectionRevisionMetadata.OverallRating, float.Parse(revisionInfo.OverallRating ?? "0.0") / 100);
+ revisionResolver.Add(CollectionRevisionMetadata.TotalRatings, (ulong)(revisionInfo.OverallRatingCount ?? 0));
+
+ foreach (var file in revisionInfo.ModFiles)
{
- var categoryResolver = GraphQLResolver.Create(db, tx, CollectionTag.NexusId, ulong.Parse(tag.Id));
- categoryResolver.Add(CollectionTag.Name, tag.Name);
- collectionResolver.Add(CollectionMetadata.Tags, categoryResolver.Id);
+ var fileInfo = file.File!;
+
+ var modEId = fileInfo.Mod.Resolve(db, tx);
+ var modfile = fileInfo.Resolve(db, tx, modEId);
+
+ var revisionFileResolver = GraphQLResolver.Create(db, tx, CollectionRevisionModFile.FileId, ulong.Parse(file.Id));
+ revisionFileResolver.Add(CollectionRevisionModFile.CollectionRevision, revisionResolver.Id);
+ revisionFileResolver.Add(CollectionRevisionModFile.NexusModFile, modfile);
+ revisionFileResolver.Add(CollectionRevisionModFile.IsOptional, file.Optional);
}
var txResults = await tx.Commit();
-
- return CollectionMetadata.Load(txResults.Db, txResults[collectionResolver.Id]);
+ return CollectionRevisionMetadata.Load(txResults.Db, txResults[revisionResolver.Id]);
}
- ///
- /// Get or add a collection metadata
- ///
- public async Task GetOrAddCollectionRevision(CollectionSlug slug, RevisionNumber revisionNumber, CancellationToken token)
+ private async Task DownloadImage(string? uri, CancellationToken token)
{
- var collection = await GetOrAddCollectionMetadata(slug, false, token);
- if (collection.Revisions.TryGetFirst(r => r.RevisionNumber == revisionNumber, out var revision))
- return revision;
+ if (uri is null) return [];
+ if (!Uri.TryCreate(uri, UriKind.Absolute, out var imageUri)) return [];
- collection = await GetOrAddCollectionMetadata(slug, true, token);
- return collection.Revisions.First(r => r.RevisionNumber == revisionNumber);
+ return await _httpClient.GetByteArrayAsync(imageUri, token);
}
public async Task GetOrAddFile(
@@ -161,7 +141,7 @@ public NexusModsLibrary(IServiceProvider serviceProvider)
if (!files.TryGetFirst(x => x.FileId == fileId, out var fileInfo))
throw new NotImplementedException();
-
+
var newFile = new NexusModsFileMetadata.New(tx)
{
Name = fileInfo.Name,
@@ -169,6 +149,9 @@ public NexusModsLibrary(IServiceProvider serviceProvider)
FileId = fileId,
ModPageId = modPage,
};
+
+ if (fileInfo.SizeInBytes.HasValue)
+ newFile.Size = Size.FromLong(fileInfo.SizeInBytes!.Value);
var txResults = await tx.Commit();
return txResults.Remap(newFile);
diff --git a/src/NexusMods.App.UI/Assets/DesignTime/header-background.webp b/src/NexusMods.App.UI/Assets/DesignTime/header-background.webp
new file mode 100644
index 0000000000..e389a0d11d
Binary files /dev/null and b/src/NexusMods.App.UI/Assets/DesignTime/header-background.webp differ
diff --git a/src/NexusMods.App.UI/NexusMods.App.UI.csproj b/src/NexusMods.App.UI/NexusMods.App.UI.csproj
index 39b7301511..b036424fb4 100644
--- a/src/NexusMods.App.UI/NexusMods.App.UI.csproj
+++ b/src/NexusMods.App.UI/NexusMods.App.UI.csproj
@@ -641,6 +641,12 @@
ICollectionsViewModel.cs
+
+ ICollectionDownloadViewModel.cs
+
+
+ ICollectionCardViewModel.cs
+
diff --git a/src/NexusMods.App.UI/Pages/CollectionDownload/CollectionDownloadDesignViewModel.cs b/src/NexusMods.App.UI/Pages/CollectionDownload/CollectionDownloadDesignViewModel.cs
new file mode 100644
index 0000000000..a1bcba65ee
--- /dev/null
+++ b/src/NexusMods.App.UI/Pages/CollectionDownload/CollectionDownloadDesignViewModel.cs
@@ -0,0 +1,31 @@
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using NexusMods.Abstractions.Jobs;
+using NexusMods.Abstractions.NexusWebApi.Types;
+using NexusMods.Paths;
+
+namespace NexusMods.App.UI.Pages.CollectionDownload;
+
+public class CollectionDownloadDesignViewModel : AViewModel, ICollectionDownloadViewModel
+{
+ public string Name => "Vanilla+ [Quality of Life]";
+ public CollectionSlug Slug { get; } = CollectionSlug.From("tckf0m");
+ public RevisionNumber RevisionNumber { get; } = RevisionNumber.From(6);
+ public string AuthorName => "Lowtonotolerance";
+
+ public string Summary =>
+ "Aims to improves vanilla gameplay while adding minimal additional content. Aims to improves vanilla gameplay while adding minimal additional content. Aims to improves vanilla gameplay while adding minimal additional content. Aims to improves vanilla gameplay while adding minimal additional content.";
+
+ public int ModCount => 9;
+ public int RequiredModCount => 7;
+ public int OptionalModCount => 2;
+ public int EndorsementCount => 248;
+ public int DownloadCount => 35_123;
+ public Size TotalSize { get; } = Size.From(76_123_456);
+ public Percent OverallRating { get; } = Percent.CreateClamped(0.82);
+ public Bitmap TileImage { get; } = new(AssetLoader.Open(new Uri("avares://NexusMods.App.UI/Assets/DesignTime/collection_tile_image.png")));
+
+ public Bitmap BackgroundImage { get; } = new(AssetLoader.Open(new Uri("avares://NexusMods.App.UI/Assets/DesignTime/header-background.webp")));
+
+ public string CollectionStatusText { get; } = "0 of 9 mods downloaded";
+}
diff --git a/src/NexusMods.App.UI/Pages/CollectionDownload/CollectionDownloadView.axaml b/src/NexusMods.App.UI/Pages/CollectionDownload/CollectionDownloadView.axaml
new file mode 100644
index 0000000000..0fd2a97f08
--- /dev/null
+++ b/src/NexusMods.App.UI/Pages/CollectionDownload/CollectionDownloadView.axaml
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ COLLECTION DOWNLOAD
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Required
+
+
+
+ TODO: Grid of mods will appear here
+
+
+
+
+ Optional
+
+
+
+ TODO: Grid of mods will appear here
+
+
+
+
diff --git a/src/NexusMods.App.UI/Pages/CollectionDownload/CollectionDownloadView.axaml.cs b/src/NexusMods.App.UI/Pages/CollectionDownload/CollectionDownloadView.axaml.cs
new file mode 100644
index 0000000000..9cf1bcf580
--- /dev/null
+++ b/src/NexusMods.App.UI/Pages/CollectionDownload/CollectionDownloadView.axaml.cs
@@ -0,0 +1,67 @@
+using System.Reactive.Disposables;
+using Avalonia.Media;
+using Avalonia.ReactiveUI;
+using ReactiveUI;
+
+namespace NexusMods.App.UI.Pages.CollectionDownload;
+
+public partial class CollectionDownloadView : ReactiveUserControl
+{
+ public CollectionDownloadView()
+ {
+ InitializeComponent();
+
+ this.WhenActivated(d =>
+ {
+
+ // Uncomment this to enable the background image
+ this.WhenAnyValue(view => view.ViewModel!.BackgroundImage)
+ .WhereNotNull()
+ .SubscribeWithErrorLogging(image => Body.Background = new ImageBrush { Source = image, Stretch = Stretch.UniformToFill})
+ .DisposeWith(d);
+
+ this.WhenAnyValue(view => view.ViewModel!.TileImage)
+ .WhereNotNull()
+ .SubscribeWithErrorLogging(image => CollectionImage.Source = image)
+ .DisposeWith(d);
+
+ this.OneWayBind(ViewModel, vm => vm.Name, view => view.Heading.Text)
+ .DisposeWith(d);
+
+ this.OneWayBind(ViewModel, vm => vm.AuthorName, view => view.AuthorName.Text)
+ .DisposeWith(d);
+
+ this.OneWayBind(ViewModel, vm => vm.Summary, view => view.Summary.Text)
+ .DisposeWith(d);
+
+ this.OneWayBind(ViewModel, vm => vm.ModCount, view => view.ModCount.Text)
+ .DisposeWith(d);
+
+ this.OneWayBind(ViewModel, vm => vm.EndorsementCount, view => view.Endorsements.Text)
+ .DisposeWith(d);
+
+ this.OneWayBind(ViewModel, vm => vm.DownloadCount, view => view.Downloads.Text)
+ .DisposeWith(d);
+
+ this.OneWayBind(ViewModel, vm => vm.TotalSize, view => view.TotalSize.Text)
+ .DisposeWith(d);
+
+ this.OneWayBind(ViewModel, vm => vm.OverallRating, view => view.OverallRating.Text)
+ .DisposeWith(d);
+
+ this.OneWayBind(ViewModel, vm => vm.RequiredModCount, view => view.RequiredModsCount.Text)
+ .DisposeWith(d);
+
+ this.OneWayBind(ViewModel, vm => vm.OptionalModCount, view => view.OptionalModsCount.Text)
+ .DisposeWith(d);
+
+ this.OneWayBind(ViewModel, vm => vm.CollectionStatusText, view => view.CollectionStatusText.Text)
+ .DisposeWith(d);
+
+ }
+ );
+ }
+
+
+}
+
diff --git a/src/NexusMods.App.UI/Pages/CollectionDownload/ICollectionDownloadViewModel.cs b/src/NexusMods.App.UI/Pages/CollectionDownload/ICollectionDownloadViewModel.cs
new file mode 100644
index 0000000000..29782068a1
--- /dev/null
+++ b/src/NexusMods.App.UI/Pages/CollectionDownload/ICollectionDownloadViewModel.cs
@@ -0,0 +1,84 @@
+using Avalonia.Media.Imaging;
+using NexusMods.Abstractions.Jobs;
+using NexusMods.Abstractions.NexusWebApi.Types;
+using NexusMods.Paths;
+
+namespace NexusMods.App.UI.Pages.CollectionDownload;
+
+public interface ICollectionDownloadViewModel : IViewModelInterface
+{
+ ///
+ /// Name of the collection
+ ///
+ public string Name { get; }
+
+ ///
+ /// The collection's slug
+ ///
+ public CollectionSlug Slug { get; }
+
+ ///
+ /// The collection's revision number
+ ///
+ public RevisionNumber RevisionNumber { get; }
+
+ ///
+ /// Name of the author of the collection
+ ///
+ public string AuthorName { get; }
+
+ ///
+ /// The summary (short description) of the collection
+ ///
+ public string Summary { get; }
+
+ ///
+ /// Total number of mods in the collection
+ ///
+ public int ModCount { get; }
+
+ ///
+ /// The number of required mods in the collection
+ ///
+ public int RequiredModCount { get; }
+
+ ///
+ /// The number of optional mods in the collection
+ ///
+ public int OptionalModCount { get; }
+
+ ///
+ /// The number of endorsements the collection has
+ ///
+ public int EndorsementCount { get; }
+
+ ///
+ /// The number of downloads the collection has
+ ///
+ public int DownloadCount { get; }
+
+ ///
+ /// The size of the collection including all downloads and the collection file iteself
+ ///
+ public Size TotalSize { get; }
+
+ ///
+ /// The overall approval rating of the collection
+ ///
+ public Percent OverallRating { get; }
+
+ ///
+ /// The small tileable image of the collection
+ ///
+ public Bitmap TileImage { get; }
+
+ ///
+ /// The background banner image of the collection
+ ///
+ public Bitmap BackgroundImage { get; }
+
+ ///
+ /// A text representation of the collection's status, such as "Downloading", "Installing", "Ready to Play", etc.
+ ///
+ public string CollectionStatusText { get; }
+}
diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionCardView.axaml b/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionCardView.axaml
index c27f98567d..8a0d157560 100644
--- a/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionCardView.axaml
+++ b/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionCardView.axaml
@@ -28,9 +28,9 @@
-
+
-
+
diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionCardViewModel.cs b/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionCardViewModel.cs
index 9d977668d1..4a29b31756 100644
--- a/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionCardViewModel.cs
+++ b/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionCardViewModel.cs
@@ -24,7 +24,7 @@ public CollectionCardViewModel(IConnection connection, RevisionId revision)
public Bitmap Image => new(new MemoryStream(_collection.TileImage.ToArray()));
public string Summary => _collection.Summary;
public string Category => string.Join(" \u2022 ", _collection.Tags.Select(t => t.Name));
- public int ModCount => (int)_revision.ModCount;
+ public int ModCount => _revision.Files.Count;
public ulong EndorsementCount => _collection.Endorsements;
public ulong DownloadCount => _revision.Downloads;
public Size TotalSize => _revision.TotalSize;
diff --git a/src/NexusMods.App.UI/Pages/LoadoutPage/FakeParentLoadoutItemModel.cs b/src/NexusMods.App.UI/Pages/LoadoutPage/FakeParentLoadoutItemModel.cs
index 41cad3e01f..c3f01e5d16 100644
--- a/src/NexusMods.App.UI/Pages/LoadoutPage/FakeParentLoadoutItemModel.cs
+++ b/src/NexusMods.App.UI/Pages/LoadoutPage/FakeParentLoadoutItemModel.cs
@@ -11,24 +11,22 @@ public class FakeParentLoadoutItemModel : LoadoutItemModel
{
public required IObservable InstalledAtObservable { get; init; }
- public required IObservable> LoadoutItemIdsObservable { get; init; }
+ public IObservable> LoadoutItemIdsObservable { get; }
public ObservableHashSet LoadoutItemIds { get; private set; } = [];
public override IReadOnlyCollection GetLoadoutItemIds() => LoadoutItemIds;
private readonly IDisposable _modelActivationDisposable;
- private readonly SerialDisposable _loadoutItemIdsDisposable = new();
+ private readonly IDisposable _loadoutItemIdsDisposable;
- public FakeParentLoadoutItemModel() : base(default(LoadoutItemId))
+ public FakeParentLoadoutItemModel(IObservable> loadoutItemIdsObservable) : base(default(LoadoutItemId))
{
+ LoadoutItemIdsObservable = loadoutItemIdsObservable;
+ _loadoutItemIdsDisposable = LoadoutItemIdsObservable.OnUI().SubscribeWithErrorLogging(changeSet => LoadoutItemIds.ApplyChanges(changeSet));
+
_modelActivationDisposable = WhenModelActivated(this, static (model, disposables) =>
{
model.InstalledAtObservable.OnUI().Subscribe(date => model.InstalledAt.Value = date).AddTo(disposables);
-
- if (model._loadoutItemIdsDisposable.Disposable is null)
- {
- model._loadoutItemIdsDisposable.Disposable = model.LoadoutItemIdsObservable.OnUI().SubscribeWithErrorLogging(changeSet => model.LoadoutItemIds.ApplyChanges(changeSet));
- }
});
}
diff --git a/src/NexusMods.App.UI/Pages/LocalFileDataProvider.cs b/src/NexusMods.App.UI/Pages/LocalFileDataProvider.cs
index 3684c9f578..8d42d8ed6f 100644
--- a/src/NexusMods.App.UI/Pages/LocalFileDataProvider.cs
+++ b/src/NexusMods.App.UI/Pages/LocalFileDataProvider.cs
@@ -158,11 +158,10 @@ public IObservable> ObserveNestedLoadoutI
return isEnabled.HasValue ? isEnabled.Value : null;
}).DistinctUntilChanged(x => x is null ? -1 : x.Value ? 1 : 0);
- LoadoutItemModel model = new FakeParentLoadoutItemModel
+ LoadoutItemModel model = new FakeParentLoadoutItemModel(loadoutItemIdsObservable)
{
NameObservable = Observable.Return(libraryFile.AsLibraryItem().Name),
InstalledAtObservable = installedAtObservable,
- LoadoutItemIdsObservable = loadoutItemIdsObservable,
IsEnabledObservable = isEnabledObservable,
HasChildrenObservable = Observable.Return(true),
diff --git a/src/NexusMods.App.UI/Pages/NexusModsDataProvider.cs b/src/NexusMods.App.UI/Pages/NexusModsDataProvider.cs
index e459e9c9bd..90c396a39a 100644
--- a/src/NexusMods.App.UI/Pages/NexusModsDataProvider.cs
+++ b/src/NexusMods.App.UI/Pages/NexusModsDataProvider.cs
@@ -180,11 +180,10 @@ public IObservable> ObserveNestedLoadoutI
return isEnabled.HasValue ? isEnabled.Value : null;
}).DistinctUntilChanged(x => x is null ? -1 : x.Value ? 1 : 0);
- LoadoutItemModel model = new FakeParentLoadoutItemModel
+ LoadoutItemModel model = new FakeParentLoadoutItemModel(loadoutItemIdsObservable)
{
NameObservable = Observable.Return(modPage.Name),
InstalledAtObservable = installedAtObservable,
- LoadoutItemIdsObservable = loadoutItemIdsObservable,
IsEnabledObservable = isEnabledObservable,
HasChildrenObservable = hasChildrenObservable,
diff --git a/src/NexusMods.StandardGameLocators/HeroicGogLocator.cs b/src/NexusMods.StandardGameLocators/HeroicGogLocator.cs
new file mode 100644
index 0000000000..e7e80b1947
--- /dev/null
+++ b/src/NexusMods.StandardGameLocators/HeroicGogLocator.cs
@@ -0,0 +1,53 @@
+using GameFinder.Launcher.Heroic;
+using GameFinder.StoreHandlers.GOG;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using NexusMods.Abstractions.GameLocators;
+using NexusMods.Abstractions.GameLocators.Stores.GOG;
+using NexusMods.Abstractions.Games.Stores.GOG;
+
+namespace NexusMods.StandardGameLocators;
+
+///
+/// Find GOG games installed with the Heroic launcher.
+///
+public class HeroicGogLocator : IGameLocator
+{
+ private readonly ILogger _logger;
+
+ private readonly HeroicGOGHandler _handler;
+ private IReadOnlyDictionary? _cachedGames;
+
+ ///
+ /// Constructor.
+ ///
+ public HeroicGogLocator(IServiceProvider provider)
+ {
+ _logger = provider.GetRequiredService>();
+ _handler = provider.GetRequiredService();
+ }
+
+ public IEnumerable Find(ILocatableGame game)
+ {
+ if (game is not IGogGame tg) yield break;
+
+ if (_cachedGames is null)
+ {
+ _cachedGames = _handler.FindAllGamesById(out var errors);
+ if (errors.Any())
+ {
+ foreach (var error in errors)
+ _logger.LogError("While looking for games: {Error}", error);
+ }
+ }
+
+ foreach (var id in tg.GogIds)
+ {
+ if (!_cachedGames.TryGetValue(GOGGameId.From(id), out var found)) continue;
+ yield return new GameLocatorResult(found.Path, GameStore.GOG, new HeroicGOGLocatorResultMetadata
+ {
+ Id = id,
+ });
+ }
+ }
+}
diff --git a/src/NexusMods.StandardGameLocators/NexusMods.StandardGameLocators.csproj b/src/NexusMods.StandardGameLocators/NexusMods.StandardGameLocators.csproj
index 27a773f608..853d0bf61a 100644
--- a/src/NexusMods.StandardGameLocators/NexusMods.StandardGameLocators.csproj
+++ b/src/NexusMods.StandardGameLocators/NexusMods.StandardGameLocators.csproj
@@ -4,6 +4,7 @@
+
diff --git a/src/NexusMods.StandardGameLocators/Services.cs b/src/NexusMods.StandardGameLocators/Services.cs
index 9c50d35589..b93cd89377 100644
--- a/src/NexusMods.StandardGameLocators/Services.cs
+++ b/src/NexusMods.StandardGameLocators/Services.cs
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using GameFinder.Common;
+using GameFinder.Launcher.Heroic;
using GameFinder.RegistryUtils;
using GameFinder.StoreHandlers.EADesktop;
using GameFinder.StoreHandlers.EADesktop.Crypto;
@@ -61,6 +62,7 @@ public static IServiceCollection AddStandardGameLocators(
onLinux: () =>
{
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
@@ -92,6 +94,7 @@ public static IServiceCollection AddStandardGameLocators(
onLinux: () =>
{
services.AddSingleton>(provider => new SteamHandler(provider.GetRequiredService(), registry: null));
+ services.AddSingleton(provider => new HeroicGOGHandler(provider.GetRequiredService()));
services.AddSingleton>(provider => new DefaultWinePrefixManager(provider.GetRequiredService()));
services.AddSingleton>(provider => new BottlesWinePrefixManager(provider.GetRequiredService()));
diff --git a/src/Themes/NexusMods.Themes.NexusFluentDark/Styles/StylesIndex.axaml b/src/Themes/NexusMods.Themes.NexusFluentDark/Styles/StylesIndex.axaml
index 71f6405bc5..9d4bc87292 100644
--- a/src/Themes/NexusMods.Themes.NexusFluentDark/Styles/StylesIndex.axaml
+++ b/src/Themes/NexusMods.Themes.NexusFluentDark/Styles/StylesIndex.axaml
@@ -31,6 +31,7 @@
+
diff --git a/src/Themes/NexusMods.Themes.NexusFluentDark/Styles/UserControls/CollectionCards/CollectionCardStyles.axaml b/src/Themes/NexusMods.Themes.NexusFluentDark/Styles/UserControls/CollectionCards/CollectionCardStyles.axaml
index f81bb93feb..2d32f2af4b 100644
--- a/src/Themes/NexusMods.Themes.NexusFluentDark/Styles/UserControls/CollectionCards/CollectionCardStyles.axaml
+++ b/src/Themes/NexusMods.Themes.NexusFluentDark/Styles/UserControls/CollectionCards/CollectionCardStyles.axaml
@@ -42,14 +42,14 @@
-
-
+
+
diff --git a/src/Themes/NexusMods.Themes.NexusFluentDark/Styles/UserControls/CollectionDownloadPage/CollectionDownloadPageStyles.axaml b/src/Themes/NexusMods.Themes.NexusFluentDark/Styles/UserControls/CollectionDownloadPage/CollectionDownloadPageStyles.axaml
new file mode 100644
index 0000000000..73a21923f5
--- /dev/null
+++ b/src/Themes/NexusMods.Themes.NexusFluentDark/Styles/UserControls/CollectionDownloadPage/CollectionDownloadPageStyles.axaml
@@ -0,0 +1,166 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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