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()