diff --git a/src/ArchiveManagement/NexusMods.FileExtractor/FileSignatures/Signatures.cs b/src/ArchiveManagement/NexusMods.FileExtractor/FileSignatures/Signatures.cs
index a2b3549a22..3377a7c32f 100644
--- a/src/ArchiveManagement/NexusMods.FileExtractor/FileSignatures/Signatures.cs
+++ b/src/ArchiveManagement/NexusMods.FileExtractor/FileSignatures/Signatures.cs
@@ -1,211 +1,245 @@
using NexusMods.Paths;
+
#pragma warning disable CS1591 // missing XML documentation
// ReSharper disable All
-namespace NexusMods.FileExtractor.FileSignatures {
-
- public enum FileType
- { ///
- /// Windows Batch File
- ///
- BAT,
- ///
- /// Bethesda Tar
- ///
- BTAR,
- ///
- /// Windows Command File
- ///
- CMD,
- ///
- /// OSX Command File
- ///
- COMMAND,
- ///
- /// Creation Engine Plugin
- ///
- CreationEnginePlugin,
- ///
- /// Cyberpunk Appearance Preset
- ///
- Cyberpunk2077AppearancePreset,
- ///
- /// Ini Configuration File
- ///
- INI,
- ///
- /// JSON File
- ///
- JSON,
- ///
- /// Test files
- ///
- JustTest,
- ///
- /// Morrowind BSA
- ///
- MorrowindBSA,
- ///
- /// NetImmerse File Format
- ///
- NIF,
- ///
- /// Unix Shell Script
- ///
- SH,
- ///
- /// TES4 Plugin
- ///
- TES4,
- ///
- /// Text File
- ///
- TXT,
- ///
- /// XML File
- ///
- XML,
- ///
- /// 7-Zip compressed file
- ///
- _7Z,
- ///
- /// FO4 BSA
- ///
- BA2,
- ///
- /// TES 4-5 and FO 3 BSA
- ///
- BSA,
- ///
- /// DDS
- ///
- DDS,
- ///
- /// PDF file
- ///
- FDF,
- ///
- /// GZIP archive file
- ///
- GZ,
- ///
- /// PDF file
- ///
- PDF,
- ///
- /// Relaxed RAR format
- ///
- RAR,
- ///
- /// RAR5 or newer
- ///
- RAR_NEW,
- ///
- /// RAR4 or older
- ///
- RAR_OLD,
- ///
- /// Zip
- ///
- ZIP,
- }
-
- public static class Definitions {
-
-
- public static (FileType, byte[])[] Signatures = {
+namespace NexusMods.FileExtractor.FileSignatures
+{
+ public enum FileType
+ {
+ ///
+ /// Windows Batch File
+ ///
+ BAT,
+
+ ///
+ /// Bethesda Tar
+ ///
+ BTAR,
+
+ ///
+ /// Windows Command File
+ ///
+ CMD,
+
+ ///
+ /// OSX Command File
+ ///
+ COMMAND,
+
+ ///
+ /// Creation Engine Plugin
+ ///
+ CreationEnginePlugin,
+
+ ///
+ /// Cyberpunk Appearance Preset
+ ///
+ Cyberpunk2077AppearancePreset,
+
+ ///
+ /// Ini Configuration File
+ ///
+ INI,
+
+ ///
+ /// JSON File
+ ///
+ JSON,
+
+ ///
+ /// Test files
+ ///
+ JustTest,
+
+ ///
+ /// Morrowind BSA
+ ///
+ MorrowindBSA,
+
+ ///
+ /// NetImmerse File Format
+ ///
+ NIF,
+
+ ///
+ /// Unix Shell Script
+ ///
+ SH,
+
+ ///
+ /// TES4 Plugin
+ ///
+ TES4,
+
+ ///
+ /// Text File
+ ///
+ TXT,
+
+ ///
+ /// XML File
+ ///
+ XML,
+
+ ///
+ /// 7-Zip compressed file
+ ///
+ _7Z,
+
+ ///
+ /// FO4 BSA
+ ///
+ BA2,
+
+ ///
+ /// TES 4-5 and FO 3 BSA
+ ///
+ BSA,
+
+ ///
+ /// DDS
+ ///
+ DDS,
+
+ ///
+ /// PDF file
+ ///
+ FDF,
+
+ ///
+ /// GZIP archive file
+ ///
+ GZ,
+
+ ///
+ /// PDF file
+ ///
+ PDF,
+
+ ///
+ /// Relaxed RAR format
+ ///
+ RAR,
+
+ ///
+ /// RAR5 or newer
+ ///
+ RAR_NEW,
+
+ ///
+ /// RAR4 or older
+ ///
+ RAR_OLD,
+
+ ///
+ /// Zip
+ ///
+ ZIP,
+ }
+
+ public static class Definitions
+ {
+ public static (FileType, byte[])[] Signatures =
+ {
// 7-Zip compressed file
- (FileType._7Z, new byte[] {0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C}),
-
- // Zip
- (FileType.ZIP, new byte[] {0x50, 0x4B, 0x03, 0x04}),
-
- // Zip
- (FileType.ZIP, new byte[] {0x50, 0x4B, 0x05, 0x06}),
-
- // Zip
- (FileType.ZIP, new byte[] {0x50, 0x4B, 0x07, 0x08}),
+ (FileType._7Z, new byte[] { 0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C }),
- // Relaxed RAR format
- (FileType.RAR, new byte[] {0x52, 0x61, 0x72, 0x21}),
+ // Zip
+ (FileType.ZIP, new byte[] { 0x50, 0x4B, 0x03, 0x04 }),
- // RAR5 or newer
- (FileType.RAR_NEW, new byte[] {0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x01, 0x00}),
+ // Zip
+ (FileType.ZIP, new byte[] { 0x50, 0x4B, 0x05, 0x06 }),
- // RAR4 or older
- (FileType.RAR_OLD, new byte[] {0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00}),
+ // Zip
+ (FileType.ZIP, new byte[] { 0x50, 0x4B, 0x07, 0x08 }),
- // Morrowind BSA
- (FileType. MorrowindBSA, new byte[] {0x00, 0x01, 0x00, 0x00}),
+ // Relaxed RAR format
+ (FileType.RAR, new byte[] { 0x52, 0x61, 0x72, 0x21 }),
- // TES 4-5 and FO 3 BSA
- (FileType.BSA, new byte[] {0x42, 0x53, 0x41, 0x00}),
+ // RAR5 or newer
+ (FileType.RAR_NEW, new byte[] { 0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x01, 0x00 }),
- // FO4 BSA
- (FileType.BA2, new byte[] {0x42, 0x54, 0x44, 0x58}),
+ // RAR4 or older
+ (FileType.RAR_OLD, new byte[] { 0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00 }),
- // DDS
- (FileType.DDS, new byte[] {0x44, 0x44, 0x53, 0x20}),
+ // Morrowind BSA
+ (FileType.MorrowindBSA, new byte[] { 0x00, 0x01, 0x00, 0x00 }),
- // Bethesda Tar
- (FileType. BTAR, new byte[] {0x42, 0x54, 0x41, 0x52}),
+ // TES 4-5 and FO 3 BSA
+ (FileType.BSA, new byte[] { 0x42, 0x53, 0x41, 0x00 }),
- // GZIP archive file
- (FileType.GZ, new byte[] {0x1F, 0x8B, 0x08}),
+ // FO4 BSA
+ (FileType.BA2, new byte[] { 0x42, 0x54, 0x44, 0x58 }),
- // NetImmerse File Format
- (FileType. NIF, new byte[] {0x4e, 0x65, 0x74, 0x49, 0x6d, 0x6d, 0x65, 0x72, 0x73, 0x65, 0x20, 0x46, 0x69, 0x6c, 0x65, 0x20, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74}),
+ // DDS
+ (FileType.DDS, new byte[] { 0x44, 0x44, 0x53, 0x20 }),
- // Gamebryo File Format
- (FileType. NIF, new byte[] {0x47, 0x61, 0x6d, 0x65, 0x62, 0x72, 0x79, 0x6f, 0x20, 0x46, 0x69, 0x6c, 0x65, 0x20, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74}),
+ // Bethesda Tar
+ (FileType.BTAR, new byte[] { 0x42, 0x54, 0x41, 0x52 }),
- // Creation Engine Plugin
- (FileType. CreationEnginePlugin, new byte[] {0x54, 0x45, 0x53, 0x34}),
+ // GZIP archive file
+ (FileType.GZ, new byte[] { 0x1F, 0x8B, 0x08 }),
- // TES4 Plugin
- (FileType. TES4, new byte[] {0x54, 0x45, 0x53, 0x34}),
+ // NetImmerse File Format
+ (FileType.NIF,
+ new byte[]
+ {
+ 0x4e, 0x65, 0x74, 0x49, 0x6d, 0x6d, 0x65, 0x72, 0x73, 0x65, 0x20, 0x46, 0x69, 0x6c, 0x65, 0x20,
+ 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74
+ }),
- // PDF file
- (FileType.PDF, new byte[] {0x25, 0x50, 0x44, 0x46}),
+ // Gamebryo File Format
+ (FileType.NIF,
+ new byte[]
+ {
+ 0x47, 0x61, 0x6d, 0x65, 0x62, 0x72, 0x79, 0x6f, 0x20, 0x46, 0x69, 0x6c, 0x65, 0x20, 0x46, 0x6f,
+ 0x72, 0x6d, 0x61, 0x74
+ }),
- // PDF file
- (FileType.FDF, new byte[] {0x25, 0x50, 0x44, 0x46}),
+ // Creation Engine Plugin
+ (FileType.CreationEnginePlugin, new byte[] { 0x54, 0x45, 0x53, 0x34 }),
- // Cyberpunk Appearance Preset
- (FileType. Cyberpunk2077AppearancePreset, new byte[] {0x4c, 0x6f, 0x63, 0x4b, 0x65, 0x79, 0x23}),
+ // TES4 Plugin
+ (FileType.TES4, new byte[] { 0x54, 0x45, 0x53, 0x34 }),
- // Test files
- (FileType. JustTest, new byte[] {0x4A, 0x55, 0x53, 0x54, 0x54, 0x45, 0x53, 0x54}),
+ // PDF file
+ (FileType.PDF, new byte[] { 0x25, 0x50, 0x44, 0x46 }),
-
- };
+ // PDF file
+ (FileType.FDF, new byte[] { 0x25, 0x50, 0x44, 0x46 }),
- public static (FileType, Extension)[] Extensions = {
- // Ini Configuration File
- (FileType.INI, new Extension(".ini")),
+ // Cyberpunk Appearance Preset
+ (FileType.Cyberpunk2077AppearancePreset, new byte[] { 0x4c, 0x6f, 0x63, 0x4b, 0x65, 0x79, 0x23 }),
- // Text File
- (FileType.TXT, new Extension(".txt")),
+ // Test files
+ (FileType.JustTest, new byte[] { 0x4A, 0x55, 0x53, 0x54, 0x54, 0x45, 0x53, 0x54 }),
+ };
- // JSON File
- (FileType.JSON, new Extension(".json")),
+ public static (FileType, Extension)[] Extensions =
+ {
+ // Ini Configuration File
+ (FileType.INI, new Extension(".ini")),
- // XML File
- (FileType.XML, new Extension(".xml")),
+ // Text File
+ (FileType.TXT, new Extension(".txt")),
- // Unix Shell Script
- (FileType.SH, new Extension(".sh")),
+ // JSON File
+ (FileType.JSON, new Extension(".json")),
- // Windows Batch File
- (FileType.BAT, new Extension(".bat")),
+ // XML File
+ (FileType.XML, new Extension(".xml")),
- // Windows Command File
- (FileType.CMD, new Extension(".cmd")),
+ // Unix Shell Script
+ (FileType.SH, new Extension(".sh")),
- // OSX Command File
- (FileType.COMMAND, new Extension(".command")),
+ // Windows Batch File
+ (FileType.BAT, new Extension(".bat")),
-
- };
+ // Windows Command File
+ (FileType.CMD, new Extension(".cmd")),
-}}
\ No newline at end of file
+ // OSX Command File
+ (FileType.COMMAND, new Extension(".command")),
+ };
+ }
+}
diff --git a/src/Games/NexusMods.Games.BethesdaGameStudios/Capabilities/BethesdaFolderMatchInstallerCapability.cs b/src/Games/NexusMods.Games.BethesdaGameStudios/Capabilities/BethesdaFolderMatchInstallerCapability.cs
new file mode 100644
index 0000000000..0c582a65a5
--- /dev/null
+++ b/src/Games/NexusMods.Games.BethesdaGameStudios/Capabilities/BethesdaFolderMatchInstallerCapability.cs
@@ -0,0 +1,103 @@
+using NexusMods.DataModel.Games.GameCapabilities.AFolderMatchInstallerCapability;
+using NexusMods.Paths;
+
+namespace NexusMods.Games.BethesdaGameStudios.Capabilities;
+
+///
+/// Capability to support installing simple Data and GameRoot level mods for Bethesda games.
+///
+public class BethesdaFolderMatchInstallerCapability : AFolderMatchInstallerCapability
+{
+ protected static readonly RelativePath DataFolder = new RelativePath("data");
+
+ // TODO: make this only contain values common for all bethesda games, let games add their own values
+ // find good way to do that
+ protected static readonly InstallFolderTarget DataInstallFolderTarget = new()
+ {
+ DestinationGamePath = new GamePath(GameFolderType.Game, DataFolder),
+
+ KnownSourceFolderNames = new[] { "data" },
+
+ KnownValidSubfolders = new[]
+ {
+ "fonts",
+ "interface",
+ "menus",
+ "meshes",
+ "music",
+ "scripts",
+ "shaders",
+ "sound",
+ "strings",
+ "textures",
+ "trees",
+ "video",
+ "facegen",
+ "materials",
+ "skse",
+ "obse",
+ "mwse",
+ "nvse",
+ "fose",
+ "f4se",
+ "distantlod",
+ "asi",
+ "SkyProc Patchers",
+ "Tools",
+ "MCM",
+ "icons",
+ "bookart",
+ "distantland",
+ "mits",
+ "splash",
+ "dllplugins",
+ "CalienteTools",
+ "NetScriptFramework",
+ "shadersfx"
+ },
+
+ KnownValidFileExtensions = new[]
+ {
+ new Extension(".esp"),
+ new Extension(".esm"),
+ new Extension(".esl"),
+ new Extension(".bsa"),
+ new Extension(".ba2"),
+ new Extension(".modgroups"),
+ }
+ };
+
+ protected static readonly InstallFolderTarget GameRootInstallFolderTarget = new()
+ {
+ DestinationGamePath = new GamePath(GameFolderType.Game, RelativePath.Empty),
+
+ KnownSourceFolderNames = new[]
+ {
+ "Mopy",
+ "xLODGen",
+ "DynDOLOD",
+ "BethINI Standalone",
+ "WrapperVersion"
+ },
+
+ KnownValidSubfolders = new[]
+ {
+ "data"
+ },
+
+ SubPathsToDiscard = new[]
+ {
+ new RelativePath("src")
+ },
+
+ SubTargets = new[]
+ {
+ DataInstallFolderTarget
+ }
+ };
+
+ protected override IEnumerable InstallFolderTargets { get; } = new[]
+ {
+ GameRootInstallFolderTarget
+ };
+}
diff --git a/src/Games/NexusMods.Games.BethesdaGameStudios/Installers/SkyrimInstaller.cs b/src/Games/NexusMods.Games.BethesdaGameStudios/Installers/SkyrimInstaller.cs
deleted file mode 100644
index 8738f6ab17..0000000000
--- a/src/Games/NexusMods.Games.BethesdaGameStudios/Installers/SkyrimInstaller.cs
+++ /dev/null
@@ -1,166 +0,0 @@
-using System.Diagnostics;
-using NexusMods.Common;
-using NexusMods.DataModel.Abstractions;
-using NexusMods.DataModel.ArchiveContents;
-using NexusMods.DataModel.Extensions;
-using NexusMods.DataModel.Games;
-using NexusMods.DataModel.ModInstallers;
-using NexusMods.DataModel.Loadouts;
-using NexusMods.DataModel.Loadouts.ModFiles;
-using NexusMods.Hashing.xxHash64;
-using NexusMods.Paths;
-
-namespace NexusMods.Games.BethesdaGameStudios.Installers;
-
-///
-/// Installs Skyrim files; both plugins and loose files.
-///
-///
-/// This installer allows mods which use the following formats (where {Root} is archive root):
-/// - {Root}/{AnyFolderName}
-/// - {Root}/Data
-/// - {Root}
-///
-/// Inside these folders, you can either have .esp, .esm, .esl or 'meshes' and 'textures' folders.
-/// {AnyFolderName} allows only 1 level of nesting.
-///
-public class SkyrimInstaller : IModInstaller
-{
- private static readonly RelativePath DataFolder = "Data";
- private static readonly RelativePath MeshesFolder = "meshes";
- private static readonly RelativePath TexturesFolder = "textures";
-
- private static readonly Extension MeshExtension = new(".nif");
- private static readonly Extension TextureExtension = new(".dds");
- private static readonly Extension EspExtension = new(".esp");
- private static readonly Extension EslExtension = new(".esl");
- private static readonly Extension EsmExtension = new(".esm");
- private static readonly Extension BsaExtension = new(".bsa");
-
- public Priority GetPriority(GameInstallation installation, EntityDictionary archiveFiles)
- {
- if (installation.Game is not SkyrimSpecialEdition &&
- installation.Game is not SkyrimLegendaryEdition)
- return Priority.None;
-
- // Determine one of the following:
- // - There is a 'meshes' folder with NIF file.
- // - There is a 'textures' folder with DDS file.
- // - There is a folder (max 1 level deep) with either of the following cases.
- foreach (var kv in archiveFiles)
- {
- var (path, _) = kv;
-
- // Check in subfolder first
- if (path.Depth != 0)
- {
- var child = path.DropFirst(numDirectories: 1);
- if (AssertPathForPriority(child)) return Priority.Normal;
- }
-
- if (AssertPathForPriority(path)) return Priority.Normal;
- }
-
- return Priority.None;
- }
-
- public ValueTask> GetModsAsync(
- GameInstallation gameInstallation,
- ModId baseModId,
- Hash srcArchiveHash,
- EntityDictionary archiveFiles,
- CancellationToken cancellationToken = default)
- {
- return ValueTask.FromResult(GetMods(baseModId, srcArchiveHash, archiveFiles));
- }
-
- private IEnumerable GetMods(
- ModId baseModId,
- Hash srcArchiveHash,
- EntityDictionary archiveFiles)
- {
- var prefix = FindFolderPrefixForExtract(archiveFiles);
- var modFiles = archiveFiles
- .Where(kv => prefix == RelativePath.Empty || kv.Key.InFolder(prefix))
- .Select(kv =>
- {
- var (path, file) = kv;
- var relative = path.RelativeTo(prefix);
-
- return file.ToFromArchive(
- new GamePath(GameFolderType.Game, DataFolder.Join(relative))
- );
- });
-
- yield return new ModInstallerResult
- {
- Id = baseModId,
- Files = modFiles
- };
- }
-
- private static RelativePath FindFolderPrefixForExtract(EntityDictionary files)
- {
- // Note: We already determined in priority testing that this is something
- // we want to extract, so just find the folder with meshes/textures subfolders
- // and roll with that.
-
- // Normally we could cache this stuff between priority and extract but there's
- // no guarantee functions will be ran in that order and another file wouldn't be
- // checked somewhere in the middle for example.
- foreach (var kv in files)
- {
- var (path, _) = kv;
-
- // foo/bar/baz
- // 1) child: foo/bar/baz -> bar/baz
- // 2) top-parent: foo/bar/baz -> foo
-
- // Check in subfolder first
- if (path.Depth != 0)
- {
- var child = path.DropFirst(numDirectories: 1);
- if (AssertFolderForExtract(child)) return path.TopParent;
- }
-
- if (AssertFolderForExtract(path)) return RelativePath.Empty;
- }
-
- throw new UnreachableException();
- }
-
- private static bool AssertPathForPriority(RelativePath relativePath)
- {
- if (relativePath.InFolder(MeshesFolder) && relativePath.Extension == MeshExtension)
- return true;
-
- if (relativePath.InFolder(TexturesFolder) && relativePath.Extension == TextureExtension)
- return true;
-
- // Has plugins in current directory.
- if (relativePath.Depth != 0) return false;
-
- if (relativePath.Extension == EsmExtension) return true;
- if (relativePath.Extension == EspExtension) return true;
- if (relativePath.Extension == EslExtension) return true;
- if (relativePath.Extension == BsaExtension) return true;
-
- return false;
- }
-
- private static bool AssertFolderForExtract(RelativePath relativePath)
- {
- if (relativePath.InFolder(MeshesFolder)) return true;
- if (relativePath.InFolder(TexturesFolder)) return true;
-
- // Check for plugins, subdirectory and extension check here should be sufficient.
- if (relativePath.Depth != 0) return false;
-
- if (relativePath.Extension == EsmExtension) return true;
- if (relativePath.Extension == EspExtension) return true;
- if (relativePath.Extension == EslExtension) return true;
- if (relativePath.Extension == BsaExtension) return true;
-
- return false;
- }
-}
diff --git a/src/Games/NexusMods.Games.BethesdaGameStudios/Services.cs b/src/Games/NexusMods.Games.BethesdaGameStudios/Services.cs
index 2c182caabb..2c826d01e8 100644
--- a/src/Games/NexusMods.Games.BethesdaGameStudios/Services.cs
+++ b/src/Games/NexusMods.Games.BethesdaGameStudios/Services.cs
@@ -3,8 +3,6 @@
using NexusMods.DataModel.Abstractions;
using NexusMods.DataModel.Games;
using NexusMods.DataModel.JsonConverters.ExpressionGenerator;
-using NexusMods.DataModel.ModInstallers;
-using NexusMods.Games.BethesdaGameStudios.Installers;
namespace NexusMods.Games.BethesdaGameStudios;
@@ -12,7 +10,6 @@ public static class Services
{
public static IServiceCollection AddBethesdaGameStudios(this IServiceCollection services)
{
- services.AddAllSingleton();
services.AddAllSingleton();
services.AddAllSingleton();
services.AddSingleton>();
diff --git a/src/Games/NexusMods.Games.BethesdaGameStudios/SkyrimLegendaryEdition/SkyrimLegendaryEdition.cs b/src/Games/NexusMods.Games.BethesdaGameStudios/SkyrimLegendaryEdition/SkyrimLegendaryEdition.cs
index 3bc2be9455..a8bb5491be 100644
--- a/src/Games/NexusMods.Games.BethesdaGameStudios/SkyrimLegendaryEdition/SkyrimLegendaryEdition.cs
+++ b/src/Games/NexusMods.Games.BethesdaGameStudios/SkyrimLegendaryEdition/SkyrimLegendaryEdition.cs
@@ -1,8 +1,10 @@
using NexusMods.Common;
using NexusMods.DataModel.Games;
-using NexusMods.Paths;
+using NexusMods.DataModel.Games.GameCapabilities;
+using NexusMods.DataModel.Games.GameCapabilities.AFolderMatchInstallerCapability;
using NexusMods.FileExtractor.StreamFactories;
-
+using NexusMods.Games.BethesdaGameStudios.Capabilities;
+using NexusMods.Paths;
namespace NexusMods.Games.BethesdaGameStudios;
@@ -27,6 +29,14 @@ protected override IEnumerable> GetLo
.GetKnownPath(KnownPath.LocalApplicationDataDirectory)
.Combine("Skyrim"));
}
+
+ public override Dictionary SupportedCapabilities => new()
+ {
+ {
+ // Support for installing simple Data and GameRoot level mods.
+ AFolderMatchInstallerCapability.CapabilityId, new BethesdaFolderMatchInstallerCapability()
+ }
+ };
public IEnumerable SteamIds => new[] { 72850u };
diff --git a/src/Games/NexusMods.Games.BethesdaGameStudios/SkyrimSpecialEdition/SkyrimSpecialEdition.cs b/src/Games/NexusMods.Games.BethesdaGameStudios/SkyrimSpecialEdition/SkyrimSpecialEdition.cs
index 826ce5f5f6..55a9efd120 100644
--- a/src/Games/NexusMods.Games.BethesdaGameStudios/SkyrimSpecialEdition/SkyrimSpecialEdition.cs
+++ b/src/Games/NexusMods.Games.BethesdaGameStudios/SkyrimSpecialEdition/SkyrimSpecialEdition.cs
@@ -1,8 +1,11 @@
using NexusMods.Common;
using NexusMods.DataModel.Abstractions;
using NexusMods.DataModel.Games;
+using NexusMods.DataModel.Games.GameCapabilities;
+using NexusMods.DataModel.Games.GameCapabilities.AFolderMatchInstallerCapability;
using NexusMods.DataModel.Loadouts;
using NexusMods.FileExtractor.StreamFactories;
+using NexusMods.Games.BethesdaGameStudios.Capabilities;
using NexusMods.Paths;
namespace NexusMods.Games.BethesdaGameStudios;
@@ -50,6 +53,15 @@ public override IEnumerable GetGameFiles(GameInstallation installation
};
}
+ public override Dictionary SupportedCapabilities => new()
+ {
+ {
+ // Support for installing simple Data and GameRoot level mods.
+ AFolderMatchInstallerCapability.CapabilityId, new BethesdaFolderMatchInstallerCapability()
+ }
+ };
+
+
public IEnumerable SteamIds => new[] { 489830u };
public IEnumerable GogIds => new long[]
@@ -62,8 +74,10 @@ public override IEnumerable GetGameFiles(GameInstallation installation
public IEnumerable XboxIds => new[] { "BethesdaSoftworks.SkyrimSE-PC" };
public override IStreamFactory Icon =>
- new EmbededResourceStreamFactory("NexusMods.Games.BethesdaGameStudios.Resources.SkyrimSpecialEdition.icon.jpg");
+ new EmbededResourceStreamFactory(
+ "NexusMods.Games.BethesdaGameStudios.Resources.SkyrimSpecialEdition.icon.jpg");
public override IStreamFactory GameImage =>
- new EmbededResourceStreamFactory("NexusMods.Games.BethesdaGameStudios.Resources.SkyrimSpecialEdition.game_image.png");
+ new EmbededResourceStreamFactory(
+ "NexusMods.Games.BethesdaGameStudios.Resources.SkyrimSpecialEdition.game_image.png");
}
diff --git a/src/Games/NexusMods.Games.Generic/Installers/GenericFolderMatchInstaller.cs b/src/Games/NexusMods.Games.Generic/Installers/GenericFolderMatchInstaller.cs
new file mode 100644
index 0000000000..5a21f3158a
--- /dev/null
+++ b/src/Games/NexusMods.Games.Generic/Installers/GenericFolderMatchInstaller.cs
@@ -0,0 +1,384 @@
+using Microsoft.Extensions.Logging;
+using NexusMods.Common;
+using NexusMods.DataModel.Abstractions;
+using NexusMods.DataModel.ArchiveContents;
+using NexusMods.DataModel.Extensions;
+using NexusMods.DataModel.Games;
+using NexusMods.DataModel.Games.GameCapabilities;
+using NexusMods.DataModel.Games.GameCapabilities.AFolderMatchInstallerCapability;
+using NexusMods.DataModel.Loadouts;
+using NexusMods.DataModel.Loadouts.ModFiles;
+using NexusMods.DataModel.ModInstallers;
+using NexusMods.Hashing.xxHash64;
+using NexusMods.Paths;
+
+namespace NexusMods.Games.Generic.Installers;
+
+///
+/// Generic mod installer for simple mods that only need to have their contents placed to a specific game location
+/// ().
+/// Requires the game to support .
+/// Tries to match the mod archive folder structure to offered by the capability.
+///
+/// Example: myMod/Textures/myTexture.dds -> Skyrim/Data/Textures/myTexture.dds
+///
+public class GenericFolderMatchInstaller : IModInstaller
+{
+ private readonly ILogger _logger;
+
+
+ public GameCapabilityId RequiredGameCapability => AFolderMatchInstallerCapability.CapabilityId;
+
+ public GenericFolderMatchInstaller(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ #region IModInstaller
+
+ public Priority GetPriority(GameInstallation installation,
+ EntityDictionary archiveFiles)
+ {
+ if (!installation.Game.SupportedCapabilities.TryGetValue(RequiredGameCapability, out var capability))
+ return Priority.None;
+ var folderMatchInstallerCapability = (AFolderMatchInstallerCapability)capability;
+ var installFolderTargets = folderMatchInstallerCapability.GetInstallFolderTargets();
+
+ var filePaths = archiveFiles.Keys;
+
+ if (filePaths.Any(filePath => PathMatchesAnyTarget(filePath, installFolderTargets)))
+ {
+ return Priority.Normal;
+ }
+
+ return Priority.None;
+ }
+
+ public ValueTask> GetModsAsync(GameInstallation gameInstallation, ModId baseModId,
+ Hash srcArchiveHash,
+ EntityDictionary archiveFiles, CancellationToken cancellationToken = default)
+ {
+ if (!gameInstallation.Game.SupportedCapabilities.TryGetValue(RequiredGameCapability, out var capability))
+ {
+ throw new NotSupportedException(
+ $"Game {gameInstallation.Game.Name} does not support GenericFolderMatchInstaller capability.");
+ }
+
+ var folderMatchInstallerCapability = (AFolderMatchInstallerCapability)capability;
+ var installFolderTargets = folderMatchInstallerCapability.GetInstallFolderTargets();
+
+ List missedFiles = new();
+
+ List modFiles = new();
+
+ foreach (var target in installFolderTargets)
+ {
+ modFiles.AddRange(GetModFilesForTarget(archiveFiles, target, missedFiles));
+ if (modFiles.Any())
+ {
+ // If any file matched target, ignore other targets.
+ // no support for multiple targets from the same archive.
+ break;
+ }
+ }
+
+ if (missedFiles.Any())
+ {
+ // Even though installation was successful, some files were not matched to the target.
+ var missedFilesString = string.Join(",\n", missedFiles);
+ _logger.LogWarning("Installer could not install some files:\n {MissedFiles}", missedFilesString);
+ }
+
+ return ValueTask.FromResult>(new[]
+ {
+ new ModInstallerResult
+ {
+ Id = baseModId,
+ Files = modFiles
+ }
+ });
+ }
+
+ #endregion
+
+ #region Helpers
+
+ ///
+ /// Gets all the mod files for a target or its sub-targets
+ ///
+ ///
+ ///
+ ///
+ ///
+ private IEnumerable GetModFilesForTarget(EntityDictionary archiveFiles,
+ InstallFolderTarget target, List missedFiles)
+ {
+ List modFiles = new();
+
+ // TODO: Currently just assumes that the prefix of the first file that matches the target structure is the correct one.
+ // Consider checking that each file matches the target at the found location before adding it.
+
+ if (TryFindPrefixToDrop(target, archiveFiles.Keys, out var prefixToDrop))
+ {
+ foreach (var (filePath, fileData) in archiveFiles)
+ {
+ var trimmedPath = filePath;
+
+ if (prefixToDrop != RelativePath.Empty)
+ {
+ if (filePath.InFolder(prefixToDrop))
+ {
+ trimmedPath = filePath.RelativeTo(prefixToDrop);
+ }
+ else
+ {
+ // File didn't have the same prefix as the first file that matched the target structure.
+ // Keep track of these for debugging.
+ missedFiles.Add(filePath);
+ }
+ }
+
+ if (PathIsExcluded(trimmedPath, target))
+ continue;
+
+ var modPath = new GamePath(target.DestinationGamePath.Type,
+ target.DestinationGamePath.Path.Join(trimmedPath));
+
+ modFiles.Add(fileData.ToFromArchive(modPath));
+ }
+
+ return modFiles;
+ }
+
+ // No files matched, try sub targets
+ foreach (var subTarget in target.SubTargets)
+ {
+ modFiles.AddRange(GetModFilesForTarget(archiveFiles, subTarget, missedFiles));
+ }
+
+ return modFiles;
+ }
+
+ ///
+ /// If any of the files match the install target, returns true with set to the prefix to drop.
+ ///
+ ///
+ ///
+ ///
+ ///
+ private bool TryFindPrefixToDrop(InstallFolderTarget target, IEnumerable filePaths,
+ out RelativePath prefix)
+ {
+ foreach (var filePath in filePaths)
+ {
+ if (PathMatchesTarget(filePath, target))
+ {
+ prefix = GetPrefixOfLength(filePath, GetNumParentsToDrop(filePath, target));
+ return true;
+ }
+ }
+
+ prefix = RelativePath.Empty;
+ return false;
+ }
+
+ ///
+ /// Returns the first numFolders of the path as a relative path.
+ ///
+ ///
+ ///
+ ///
+ private RelativePath GetPrefixOfLength(RelativePath path, int numFolders)
+ {
+ // NOTE: Assumes that the path is longer than numFolders
+ if (numFolders == 0)
+ return RelativePath.Empty;
+
+ var foldersToDrop = numFolders;
+ var suffix = path;
+ var prefix = new RelativePath("");
+ while (foldersToDrop > 0)
+ {
+ prefix = prefix.Join(suffix.TopParent);
+ suffix = suffix.DropFirst();
+ foldersToDrop--;
+ }
+
+ return prefix;
+ }
+
+ ///
+ /// Returns true if any file matches any target or sub-target.
+ ///
+ ///
+ ///
+ ///
+ private bool PathMatchesAnyTarget(RelativePath filePath,
+ IEnumerable installFolderTargets)
+ {
+ foreach (var target in installFolderTargets)
+ {
+ if (PathMatchesTarget(filePath, target))
+ return true;
+
+ if (PathMatchesAnyTarget(filePath, target.SubTargets))
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Returns true if the path matches the target
+ /// NOTE: Does not check sub-targets
+ ///
+ ///
+ ///
+ ///
+ private bool PathMatchesTarget(RelativePath filePath, InstallFolderTarget installFolderTarget)
+ {
+ if (PathContainsAny(filePath, installFolderTarget.KnownSourceFolderNames) > -1)
+ return true;
+
+ if (PathContainsAny(filePath, installFolderTarget.KnownValidSubfolders) > -1)
+ return true;
+
+ if (PathMatchesAnyExtensions(filePath, installFolderTarget.KnownValidFileExtensions))
+ return true;
+
+ return false;
+ }
+
+ ///
+ /// Returns the number of folders that need to be dropped from the path to install the file to the target.
+ /// NOTE: Assumes that the path matches the target.
+ /// NOTE: number of folders to drop != depth, as depth starts from 0 for the first folder,
+ /// while number of folders to drop starts from 1.
+ ///
+ ///
+ ///
+ ///
+ ///
+ private int GetNumParentsToDrop(RelativePath path, InstallFolderTarget target)
+ {
+ var depth = 0;
+
+ if ((depth = PathContainsAny(path, target.KnownSourceFolderNames)) > -1)
+ {
+ // the match indicates an alias for the target, so we need the alias as well
+ return depth + 1;
+ }
+
+ if ((depth = PathContainsAny(path, target.KnownValidSubfolders)) > -1)
+ {
+ // the match indicates a subfolder of the target, so we need to drop up to the parent
+ return depth;
+ }
+
+ if (PathMatchesAnyExtensions(path, target.KnownValidFileExtensions))
+ {
+ // the file is a child of the target, so we need to drop everything up to the filename
+ return path.Depth;
+ }
+
+ throw new InvalidOperationException($"Path {path} does not match target {target}");
+ }
+
+ ///
+ /// Returns true if the path matches any of the provided extensions.
+ ///
+ ///
+ ///
+ ///
+ private bool PathMatchesAnyExtensions(RelativePath filePath, IEnumerable extensions)
+ {
+ foreach (var extension in extensions)
+ {
+ if (filePath.Extension == extension)
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Returns true if the install target indicates that the path should be excluded.
+ ///
+ ///
+ ///
+ ///
+ private bool PathIsExcluded(RelativePath path, InstallFolderTarget target)
+ {
+ if (target.SubPathsToDiscard.Any(subPath => PathContainsSubPath(path, subPath)))
+ return true;
+ if (PathMatchesAnyExtensions(path, target.FileExtensionsToDiscard))
+ return true;
+
+ return false;
+ }
+
+ ///
+ /// Returns whether the path contains the subPath.
+ ///
+ /// Example: path: "skse_1_07_03/src/skse/skse.sln" subPath: "src/skse"
+ ///
+ ///
+ ///
+ ///
+ private bool PathContainsSubPath(RelativePath path, RelativePath subPath)
+ {
+ if (path.InFolder(subPath))
+ return true;
+
+ // Remove parents of path until we reach the depth of subPath
+ while (path.Depth > subPath.Depth)
+ {
+ if (path.InFolder(subPath))
+ return true;
+
+ path = path.DropFirst();
+ }
+
+ return false;
+ }
+
+ ///
+ /// If any of the folderNames are in the path, returns the depth of the first match, otherwise -1.
+ ///
+ ///
+ ///
+ ///
+ private int PathContainsAny(RelativePath path, IEnumerable folderNames)
+ {
+ var depth = -1;
+ if (folderNames.Any(folderName => (depth = GetFolderDepth(path, folderName)) > -1))
+ {
+ return depth;
+ }
+
+ return -1;
+ }
+
+ ///
+ /// Returns the depth of the folderName in the filePath if present, otherwise -1.
+ ///
+ ///
+ ///
+ ///
+ private int GetFolderDepth(RelativePath path, string folderName)
+ {
+ var depth = 0;
+ while (path != RelativePath.Empty && path.Depth > 0)
+ {
+ if (path.TopParent == folderName)
+ return depth;
+
+ depth++;
+ path = path.DropFirst();
+ }
+
+ return -1;
+ }
+
+ #endregion
+}
diff --git a/src/Games/NexusMods.Games.Generic/Services.cs b/src/Games/NexusMods.Games.Generic/Services.cs
index 8ebda36ae5..c577fc189d 100644
--- a/src/Games/NexusMods.Games.Generic/Services.cs
+++ b/src/Games/NexusMods.Games.Generic/Services.cs
@@ -1,8 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
using NexusMods.DataModel.Abstractions;
using NexusMods.DataModel.JsonConverters.ExpressionGenerator;
+using NexusMods.DataModel.ModInstallers;
using NexusMods.Games.Generic.Entities;
using NexusMods.Games.Generic.FileAnalyzers;
+using NexusMods.Games.Generic.Installers;
namespace NexusMods.Games.Generic;
@@ -11,6 +13,7 @@ public static class Services
public static IServiceCollection AddGenericGameSupport(this IServiceCollection services)
{
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
return services;
diff --git a/src/NexusMods.DataModel/Games/AGame.cs b/src/NexusMods.DataModel/Games/AGame.cs
index faba89a200..63d6f4a80c 100644
--- a/src/NexusMods.DataModel/Games/AGame.cs
+++ b/src/NexusMods.DataModel/Games/AGame.cs
@@ -1,5 +1,6 @@
using NexusMods.Common;
using NexusMods.DataModel.Abstractions;
+using NexusMods.DataModel.Games.GameCapabilities;
using NexusMods.DataModel.Loadouts;
using NexusMods.Paths;
@@ -47,7 +48,11 @@ public virtual IEnumerable GetGameFiles(GameInstallation installation,
public virtual IStreamFactory Icon => throw new NotImplementedException("No icon provided for this game.");
///
- public virtual IStreamFactory GameImage => throw new NotImplementedException("No game image provided for this game.");
+ public virtual IStreamFactory GameImage =>
+ throw new NotImplementedException("No game image provided for this game.");
+
+ ///
+ public virtual Dictionary SupportedCapabilities { get; } = new();
private Version GetVersion(GameLocatorResult installation)
{
@@ -63,7 +68,7 @@ private Version GetVersion(GameLocatorResult installation)
return new Version(0, 0, 0, 0);
}
}
-
+
///
/// Clears the internal cache of game installations, so that the next access will re-query the system.
///
@@ -79,7 +84,8 @@ from installation in locator.Find(this)
select new GameInstallation
{
Game = this,
- Locations = new Dictionary(GetLocations(installation.Path.FileSystem, locator, installation)),
+ Locations = new Dictionary(GetLocations(installation.Path.FileSystem,
+ locator, installation)),
Version = installation.Version ?? GetVersion(installation),
Store = installation.Store
})
diff --git a/src/NexusMods.DataModel/Games/GameCapabilities/AFolderMatchInstallerCapability/AFolderMatchInstallerCapability.cs b/src/NexusMods.DataModel/Games/GameCapabilities/AFolderMatchInstallerCapability/AFolderMatchInstallerCapability.cs
new file mode 100644
index 0000000000..10c2d92915
--- /dev/null
+++ b/src/NexusMods.DataModel/Games/GameCapabilities/AFolderMatchInstallerCapability/AFolderMatchInstallerCapability.cs
@@ -0,0 +1,29 @@
+
+namespace NexusMods.DataModel.Games.GameCapabilities.AFolderMatchInstallerCapability;
+
+///
+/// GameCapability for games that support installing mod archives by matching archive folder to a known folder structure.
+///
+public abstract class AFolderMatchInstallerCapability : IGameCapability
+{
+ ///
+ public static GameCapabilityId CapabilityId =>
+ GameCapabilityId.From(new Guid("71F525D2-B4FB-4350-A6AA-0D5F4091E9BB"));
+
+
+ ///
+ /// Collection of InstallFolderTargets to provide to the installer.
+ /// Reimplement this property to contain the InstallFolderTargets for a specific game or collection of games.
+ ///
+ protected abstract IEnumerable InstallFolderTargets { get; }
+
+
+ ///
+ /// Collection of
+ ///
+ ///
+ public IEnumerable GetInstallFolderTargets()
+ {
+ return InstallFolderTargets;
+ }
+}
diff --git a/src/NexusMods.DataModel/Games/GameCapabilities/AFolderMatchInstallerCapability/InstallFolderTarget.cs b/src/NexusMods.DataModel/Games/GameCapabilities/AFolderMatchInstallerCapability/InstallFolderTarget.cs
new file mode 100644
index 0000000000..548a8e4662
--- /dev/null
+++ b/src/NexusMods.DataModel/Games/GameCapabilities/AFolderMatchInstallerCapability/InstallFolderTarget.cs
@@ -0,0 +1,50 @@
+using NexusMods.Paths;
+
+namespace NexusMods.DataModel.Games.GameCapabilities.AFolderMatchInstallerCapability;
+
+///
+/// Represents a target path for installing simple mods archives
+/// that only need to have their contents placed to a specific game location.
+///
+/// Each represents a single game location and
+/// contains information useful for recognizing and installing mod file paths to that location.
+///
+public class InstallFolderTarget
+{
+ ///
+ /// GamePath to which the relative mod file paths should appended to.
+ ///
+ public GamePath DestinationGamePath { get; init; }
+
+ ///
+ /// List of known recognizable aliases that can be directly mapped to the .
+ ///
+ public IEnumerable KnownSourceFolderNames { get; init; } = Enumerable.Empty();
+
+ ///
+ /// 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();
+
+ ///
+ /// 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();
+
+ ///
+ /// List of subPaths of the target that should be discarded.
+ ///
+ public IEnumerable SubPathsToDiscard { get; init; } = Enumerable.Empty();
+
+ ///
+ /// List of file extensions to discard when installing to this target.
+ ///
+ public IEnumerable FileExtensionsToDiscard { get; init; } = Enumerable.Empty();
+
+ ///
+ /// Collection of Targets that are nested paths relative to .
+ ///
+ public IEnumerable SubTargets { get; init; } = Enumerable.Empty();
+}
diff --git a/src/NexusMods.DataModel/Games/GameCapabilities/GameCapabilityId.cs b/src/NexusMods.DataModel/Games/GameCapabilities/GameCapabilityId.cs
new file mode 100644
index 0000000000..48e3b0a62a
--- /dev/null
+++ b/src/NexusMods.DataModel/Games/GameCapabilities/GameCapabilityId.cs
@@ -0,0 +1,11 @@
+using JetBrains.Annotations;
+using Vogen;
+
+namespace NexusMods.DataModel.Games.GameCapabilities;
+
+///
+/// Represents a unique identifier for a game capability type.
+///
+[PublicAPI]
+[ValueObject(conversions: Conversions.None)]
+public readonly partial struct GameCapabilityId { }
diff --git a/src/NexusMods.DataModel/Games/GameCapabilities/IGameCapability.cs b/src/NexusMods.DataModel/Games/GameCapabilities/IGameCapability.cs
new file mode 100644
index 0000000000..5fdd695e46
--- /dev/null
+++ b/src/NexusMods.DataModel/Games/GameCapabilities/IGameCapability.cs
@@ -0,0 +1,26 @@
+namespace NexusMods.DataModel.Games.GameCapabilities;
+
+///
+/// The base interface for all game capabilities abstract classes.
+///
+/// GameCapabilities represent optional functionalities that a game can support,
+/// such as a particular mod installer or the use of a plugin system.
+///
+/// Games can have game-specific implementations of these capabilities,
+/// allowing for custom logic to be used for each game.
+///
+/// This interface should not be implemented directly,
+/// but rather through one of the abstract classes
+/// defining a specific capability type.
+///
+public interface IGameCapability
+{
+ ///
+ /// The unique identifier for this capability type.
+ ///
+ ///
+ /// This should only be implemented by the abstract classes inheriting directly from
+ /// and then be sealed.
+ ///
+ public static GameCapabilityId CapabilityId { get; }
+}
diff --git a/src/NexusMods.DataModel/Games/IGame.cs b/src/NexusMods.DataModel/Games/IGame.cs
index c0cb1196de..8ed6e12cf1 100644
--- a/src/NexusMods.DataModel/Games/IGame.cs
+++ b/src/NexusMods.DataModel/Games/IGame.cs
@@ -1,5 +1,6 @@
using NexusMods.Common;
using NexusMods.DataModel.Abstractions;
+using NexusMods.DataModel.Games.GameCapabilities;
using NexusMods.DataModel.Loadouts;
namespace NexusMods.DataModel.Games;
@@ -52,4 +53,10 @@ public interface IGame
/// Stream factory for the game's image, should be close to 16:9 aspect ratio.
///
public IStreamFactory GameImage { get; }
+
+
+ ///
+ /// Collection of that this game supports.
+ ///
+ public Dictionary SupportedCapabilities { get; }
}
diff --git a/src/NexusMods.DataModel/Games/Unknown/UnknownGame.cs b/src/NexusMods.DataModel/Games/Unknown/UnknownGame.cs
index af52eaf03b..6aaa6dc307 100644
--- a/src/NexusMods.DataModel/Games/Unknown/UnknownGame.cs
+++ b/src/NexusMods.DataModel/Games/Unknown/UnknownGame.cs
@@ -1,5 +1,6 @@
using NexusMods.Common;
using NexusMods.DataModel.Abstractions;
+using NexusMods.DataModel.Games.GameCapabilities;
using NexusMods.DataModel.Loadouts;
using NexusMods.Paths;
@@ -50,6 +51,10 @@ public IEnumerable GetGameFiles(GameInstallation installation, IDataSt
{
return Array.Empty();
}
+
+ ///
+ public virtual Dictionary SupportedCapabilities { get; } = new();
+
///
public IStreamFactory Icon => throw new NotImplementedException("No icon provided for this game.");
diff --git a/src/NexusMods.DataModel/NexusMods.DataModel.csproj b/src/NexusMods.DataModel/NexusMods.DataModel.csproj
index 1f8a98c718..bf78977266 100644
--- a/src/NexusMods.DataModel/NexusMods.DataModel.csproj
+++ b/src/NexusMods.DataModel/NexusMods.DataModel.csproj
@@ -10,7 +10,7 @@
-
+
diff --git a/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/Assets/DownloadableMods/HasScriptExtender/manifest.json b/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/Assets/DownloadableMods/HasScriptExtender/manifest.json
new file mode 100644
index 0000000000..de0d157f43
--- /dev/null
+++ b/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/Assets/DownloadableMods/HasScriptExtender/manifest.json
@@ -0,0 +1,5 @@
+{
+ "FilePath": "skse_1_07_03.zip",
+ "Source": "RealFileSystem",
+ "Name": "skse"
+}
diff --git a/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/Assets/DownloadableMods/HasScriptExtender/skse_1_07_03.zip b/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/Assets/DownloadableMods/HasScriptExtender/skse_1_07_03.zip
new file mode 100644
index 0000000000..c51d8d0319
Binary files /dev/null and b/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/Assets/DownloadableMods/HasScriptExtender/skse_1_07_03.zip differ
diff --git a/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/Installers/SkyrimInstallerTests.cs b/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/Installers/GenericFolderMatchInstallerTests.cs
similarity index 69%
rename from tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/Installers/SkyrimInstallerTests.cs
rename to tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/Installers/GenericFolderMatchInstallerTests.cs
index d722308ca8..2e720bf5ba 100644
--- a/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/Installers/SkyrimInstallerTests.cs
+++ b/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/Installers/GenericFolderMatchInstallerTests.cs
@@ -9,12 +9,13 @@ namespace NexusMods.Games.BethesdaGameStudios.Tests.Installers;
///
/// Tests for the Skyrim installer.
///
-public abstract class SkyrimInstallerTests : AGameTest where TGame : AGame
+public abstract class GenericFolderMatchInstallerTests : AGameTest where TGame : AGame
{
private readonly TestModDownloader _downloader;
private readonly IFileSystem _realFs;
- protected SkyrimInstallerTests(IServiceProvider serviceProvider, TestModDownloader downloader, IFileSystem realFs) : base(serviceProvider)
+ protected GenericFolderMatchInstallerTests(IServiceProvider serviceProvider, TestModDownloader downloader,
+ IFileSystem realFs) : base(serviceProvider)
{
_downloader = downloader;
_realFs = realFs;
@@ -55,13 +56,35 @@ protected SkyrimInstallerTests(IServiceProvider serviceProvider, TestModDownload
[Fact]
public async Task InstallMod_WithEspInSubfolder() => await TestLooseFileCommon("HasEsp_InSubfolder");
+ [Fact]
+ public async Task InstallMod_WithScriptExtender()
+ {
+ var loadout = await CreateLoadout(indexGameFiles: false);
+ var path = BethesdaTestHelpers.GetDownloadableModFolder(_realFs, "HasScriptExtender");
+ var downloaded = await _downloader.DownloadFromManifestAsync(path, _realFs);
+
+ var mod = await InstallModFromArchiveIntoLoadout(
+ loadout,
+ downloaded.Path,
+ downloaded.Manifest.Name);
+
+ var files = mod.Files;
+ files.Count.Should().BeGreaterThan(0);
+ files.Values.Where(f => f is FromArchive fromArchive && fromArchive.To.Path.Equals("skse_loader.exe")).Should()
+ .HaveCount(1);
+ files.Values.Where(f => f is FromArchive fromArchive && fromArchive.To.Path.StartsWith("Data/scripts")).Should()
+ .HaveCount(120);
+ files.Values.Where(f => f is FromArchive fromArchive && fromArchive.To.Path.StartsWith("src")).Should()
+ .HaveCount(0);
+ }
+
protected async Task TestLooseFileCommon(string folderName)
{
// TODO: Technically these tests don't cover where the file is sourced from, some code could be added here to do this.
var loadout = await CreateLoadout(indexGameFiles: false);
var path = BethesdaTestHelpers.GetDownloadableModFolder(_realFs, folderName);
var downloaded = await _downloader.DownloadFromManifestAsync(path, _realFs);
-
+
var mod = await InstallModFromArchiveIntoLoadout(
loadout,
downloaded.Path,
@@ -69,7 +92,7 @@ protected async Task TestLooseFileCommon(string folderName)
var files = mod.Files;
files.Count.Should().BeGreaterThan(0);
-
+
foreach (var file in files)
{
if (file.Value is FromArchive fromArchive)
diff --git a/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/NexusMods.Games.BethesdaGameStudios.Tests.csproj b/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/NexusMods.Games.BethesdaGameStudios.Tests.csproj
index fc1be0ea84..ab83e52916 100644
--- a/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/NexusMods.Games.BethesdaGameStudios.Tests.csproj
+++ b/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/NexusMods.Games.BethesdaGameStudios.Tests.csproj
@@ -5,6 +5,7 @@
+
@@ -14,6 +15,9 @@
PreserveNewest
+
+ PreserveNewest
+
PreserveNewest
diff --git a/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/SkyrimLegendaryEditionTests/Installers/SkyrimSpecialEditionInstallerTests.cs b/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/SkyrimLegendaryEditionTests/Installers/SkyrimLegendaryEditionMatchInstallerTests.cs
similarity index 69%
rename from tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/SkyrimLegendaryEditionTests/Installers/SkyrimSpecialEditionInstallerTests.cs
rename to tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/SkyrimLegendaryEditionTests/Installers/SkyrimLegendaryEditionMatchInstallerTests.cs
index a75c8574fa..e8c5812773 100644
--- a/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/SkyrimLegendaryEditionTests/Installers/SkyrimSpecialEditionInstallerTests.cs
+++ b/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/SkyrimLegendaryEditionTests/Installers/SkyrimLegendaryEditionMatchInstallerTests.cs
@@ -4,9 +4,9 @@
namespace NexusMods.Games.BethesdaGameStudios.Tests.SkyrimLegendaryEditionTests.Installers;
-public class SkyrimLegendaryEditionInstallerTests : SkyrimInstallerTests
+public class SkyrimLegendaryEditionMatchInstallerTests : GenericFolderMatchInstallerTests
{
- public SkyrimLegendaryEditionInstallerTests(
+ public SkyrimLegendaryEditionMatchInstallerTests(
IServiceProvider serviceProvider,
TestModDownloader downloader,
IFileSystem realFs) :
diff --git a/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/SkyrimSpecialEditionTests/Installers/SkyrimSpecialEditionInstallerTests.cs b/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/SkyrimSpecialEditionTests/Installers/SkyrimSpecialEditionMatchInstallerTests.cs
similarity index 70%
rename from tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/SkyrimSpecialEditionTests/Installers/SkyrimSpecialEditionInstallerTests.cs
rename to tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/SkyrimSpecialEditionTests/Installers/SkyrimSpecialEditionMatchInstallerTests.cs
index 587be273d7..5dc8cfc2bb 100644
--- a/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/SkyrimSpecialEditionTests/Installers/SkyrimSpecialEditionInstallerTests.cs
+++ b/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/SkyrimSpecialEditionTests/Installers/SkyrimSpecialEditionMatchInstallerTests.cs
@@ -4,9 +4,9 @@
namespace NexusMods.Games.BethesdaGameStudios.Tests.SkyrimSpecialEditionTests.Installers;
-public class SkyrimSpecialEditionInstallerTests : SkyrimInstallerTests
+public class SkyrimSpecialEditionMatchInstallerTests : GenericFolderMatchInstallerTests
{
- public SkyrimSpecialEditionInstallerTests(
+ public SkyrimSpecialEditionMatchInstallerTests(
IServiceProvider serviceProvider,
TestModDownloader downloader,
IFileSystem realFs) :
diff --git a/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/Startup.cs b/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/Startup.cs
index 01d7a052df..8b93480d9f 100644
--- a/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/Startup.cs
+++ b/tests/Games/NexusMods.Games.BethesdaGameStudios.Tests/Startup.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NexusMods.Common;
+using NexusMods.Games.Generic;
using NexusMods.Games.TestFramework;
using NexusMods.StandardGameLocators.TestHelpers;
using Xunit.DependencyInjection;
@@ -17,6 +18,7 @@ public void ConfigureServices(IServiceCollection services)
.AddUniversalGameLocator(new Version("1.6.659.0"))
.AddUniversalGameLocator(new Version("1.9.32.0"))
.AddBethesdaGameStudios()
+ .AddGenericGameSupport()
.Validate();
}
diff --git a/tests/Networking/NexusMods.Networking.Downloaders.Tests/Startup.cs b/tests/Networking/NexusMods.Networking.Downloaders.Tests/Startup.cs
index 38f260a00e..9cc0569587 100644
--- a/tests/Networking/NexusMods.Networking.Downloaders.Tests/Startup.cs
+++ b/tests/Networking/NexusMods.Networking.Downloaders.Tests/Startup.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using NexusMods.Common;
using NexusMods.Games.BethesdaGameStudios;
+using NexusMods.Games.Generic;
using NexusMods.Games.RedEngine;
using NexusMods.Games.TestFramework;
using NexusMods.Networking.HttpDownloader.Tests;
@@ -18,6 +19,7 @@ public void ConfigureServices(IServiceCollection services)
.AddUniversalGameLocator(new Version("1.6.659.0"))
.AddStubbedGameLocators()
.AddBethesdaGameStudios()
+ .AddGenericGameSupport()
.AddRedEngineGames()
.AddSingleton()
.AddSingleton()