From b549b00b28b8b9cc894f944f81c5fcad9cb6dd76 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 4 Sep 2023 20:38:05 +0100 Subject: [PATCH] Add support for Starfield Starfield adds a new type of plugin that I'm calling an override plugin, which does not use up a load order slot when active, and a plugin cannot be light and override at the same time. It also increases the BA2 version number, though the differences are not relevant to libloot's usage, and loads BA2s from plugins differently. The latest libloadorder distinguishes between plugins that are implicitly active (i.e. they're active despite not being listed in plugins.txt) and those that are made to load in certain positions by the game. --- CMakeLists.txt | 12 +- include/loot/enum/game_type.h | 2 + include/loot/plugin_interface.h | 13 ++ src/api/api.cpp | 2 + src/api/bsa.cpp | 7 +- src/api/bsa.h | 3 + src/api/game/game.cpp | 7 +- src/api/game/load_order_handler.cpp | 6 +- src/api/game/load_order_handler.h | 2 +- src/api/metadata/condition_evaluator.cpp | 2 + src/api/plugin.cpp | 188 ++++++++++++------ src/api/plugin.h | 2 + src/api/sorting/plugin_sort.cpp | 30 +-- .../api/interface/create_game_handle_test.h | 6 +- src/tests/api/interface/game_interface_test.h | 25 ++- src/tests/api/internals/game/game_test.h | 29 +-- .../internals/game/load_order_handler_test.h | 17 +- src/tests/api/internals/plugin_test.h | 146 +++++++++++--- .../api/internals/sorting/plugin_graph_test.h | 4 + src/tests/common_game_test_fixture.h | 28 ++- 20 files changed, 370 insertions(+), 161 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 1d041ffa..5570df1b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,8 +80,8 @@ set(GTEST_LIBRARIES "${BINARY_DIR}/lib/${CMAKE_CFG_INTDIR}/${CMAKE_STATIC_LIBRAR ExternalProject_Add(esplugin PREFIX "external" - URL "https://github.com/Ortham/esplugin/archive/4.0.0.tar.gz" - URL_HASH "SHA256=e3aa21ffbfd8ce55e3398fbd789adb2aa0d2cceefc99bcc789f02ff95cc98b86" + URL "https://github.com/Ortham/esplugin/archive/4.1.0.tar.gz" + URL_HASH "SHA256=c0958051f99b1a3a1ea2db1d68f2521567563238b9d624dde1c5c075baddfa41" CONFIGURE_COMMAND "" BUILD_IN_SOURCE 1 BUILD_COMMAND cargo build --release --manifest-path ffi/Cargo.toml --target ${RUST_TARGET} && @@ -98,8 +98,8 @@ endif() ExternalProject_Add(libloadorder PREFIX "external" - URL "https://github.com/Ortham/libloadorder/archive/14.2.1.tar.gz" - URL_HASH "SHA256=4341844407f55ce9d96826a933ac35dfb22bbad14906e5dff742f910182eca93" + URL "https://github.com/Ortham/libloadorder/archive/15.0.0.tar.gz" + URL_HASH "SHA256=3196e497a285f069acdb0ba68c03df88361fcc9928646c88267c052111f31a90" CONFIGURE_COMMAND "" BUILD_IN_SOURCE 1 BUILD_COMMAND cargo build --release --manifest-path ffi/Cargo.toml --target ${RUST_TARGET} && @@ -116,8 +116,8 @@ endif() ExternalProject_Add(loot-condition-interpreter PREFIX "external" - URL "https://github.com/loot/loot-condition-interpreter/archive/3.0.0.tar.gz" - URL_HASH "SHA256=6492c19848f01f297537da6a561e8d0c0a79e7c15e3ddf958910dec7cdfe0abf" + URL "https://github.com/loot/loot-condition-interpreter/archive/3.1.0.tar.gz" + URL_HASH "SHA256=a2dcadff0e6f9fd4fdc12785d296bf5ee24cc556f4b20a357233e2a2e79d025c" CONFIGURE_COMMAND "" BUILD_IN_SOURCE 1 BUILD_COMMAND cargo build --release --manifest-path ffi/Cargo.toml --target ${RUST_TARGET} && diff --git a/include/loot/enum/game_type.h b/include/loot/enum/game_type.h index e0d687ef..5046c835 100644 --- a/include/loot/enum/game_type.h +++ b/include/loot/enum/game_type.h @@ -49,6 +49,8 @@ enum struct GameType : unsigned int { tes5vr, /** The Elder Scrolls III: Morrowind */ tes3, + /** Starfield */ + starfield }; } diff --git a/include/loot/plugin_interface.h b/include/loot/plugin_interface.h index e3055cd9..3290eea5 100644 --- a/include/loot/plugin_interface.h +++ b/include/loot/plugin_interface.h @@ -99,6 +99,12 @@ public: */ virtual bool IsLightPlugin() const = 0; + /** + * Check if the plugin is an override plugin. + * @return True if plugin is an override plugin, false otherwise. + */ + virtual bool IsOverridePlugin() const = 0; + /** * Check if the plugin is or would be valid as a light plugin. * @return True if the plugin is a valid light plugin or would be a valid @@ -106,6 +112,13 @@ public: */ virtual bool IsValidAsLightPlugin() const = 0; + /** + * Check if the plugin is or would be valid as an override plugin. + * @return True if the plugin is a valid override plugin or would be a valid + * override plugin, false otherwise. + */ + virtual bool IsValidAsOverridePlugin() const = 0; + /** * Check if the plugin contains any records other than its TES4 header. * @return True if the plugin only contains a TES4 header, false otherwise. diff --git a/src/api/api.cpp b/src/api/api.cpp index a34a0e06..9811d7c0 100644 --- a/src/api/api.cpp +++ b/src/api/api.cpp @@ -52,6 +52,8 @@ const char* DescribeGameType(GameType gameType) { return "The Elder Scrolls V: Skyrim VR"; case GameType::tes3: return "The Elder Scrolls III: Morrowind"; + case GameType::starfield: + return "Starfield"; default: return "Unknown"; } diff --git a/src/api/bsa.cpp b/src/api/bsa.cpp index 0edfcdf4..75aed9e6 100644 --- a/src/api/bsa.cpp +++ b/src/api/bsa.cpp @@ -142,7 +142,7 @@ void StoreHashes(std::map>& folderFileHashes, } } -// Normalise the path the same way that BA2 hashes do (it's thet same as for +// Normalise the path the same way that BA2 hashes do (it's the same as for // BSAs). void NormalisePath(std::string& filePath) { for (size_t i = 0; i < filePath.size(); ++i) { @@ -217,7 +217,8 @@ std::map> GetAssetsInBA2(std::istream& in, throw std::runtime_error("BA2 file header type ID is invalid"); } - if (header.version != 1) { + // The header version is 1 for Fallout 4 and 2 or 3 for Starfield. + if (header.version != 1 && header.version != 2 && header.version != 3) { throw std::runtime_error("BA2 file header version is invalid"); } @@ -237,7 +238,7 @@ std::map> GetAssetsInBA2(std::istream& in, bool ShouldWarnAboutHashCollisions(const std::filesystem::path& archivePath) { const auto filename = archivePath.filename().u8string(); - return !boost::iends_with(filename, ".ba2") || + return !boost::iends_with(filename, BA2_FILE_EXTENSION) || (!boost::istarts_with(filename, "Fallout4 - ") && !boost::istarts_with(filename, "DLCUltraHighResolution - ")); } diff --git a/src/api/bsa.h b/src/api/bsa.h index 73d5df46..1f3ca42e 100644 --- a/src/api/bsa.h +++ b/src/api/bsa.h @@ -31,6 +31,9 @@ #include namespace loot { +inline constexpr const char* BSA_FILE_EXTENSION = ".bsa"; +inline constexpr const char* BA2_FILE_EXTENSION = ".ba2"; + std::map> GetAssetsInBethesdaArchive( const std::filesystem::path& archivePath); diff --git a/src/api/game/game.cpp b/src/api/game/game.cpp index 1f4e90e2..8784fce7 100644 --- a/src/api/game/game.cpp +++ b/src/api/game/game.cpp @@ -84,9 +84,14 @@ bool IsMicrosoftStoreInstall(const GameType gameType, "appxmanifest.xml"); case GameType::tes5se: case GameType::fo4: + case GameType::starfield: return std::filesystem::exists(gamePath / "appxmanifest.xml"); - default: + case GameType::tes5: + case GameType::tes5vr: + case GameType::fo4vr: return false; + default: + throw std::logic_error("Unrecognised game type"); } } diff --git a/src/api/game/load_order_handler.cpp b/src/api/game/load_order_handler.cpp index 953fee1b..54401b6d 100644 --- a/src/api/game/load_order_handler.cpp +++ b/src/api/game/load_order_handler.cpp @@ -48,6 +48,8 @@ unsigned int mapGameId(GameType gameType) { return LIBLO_GAME_FO4; case GameType::fo4vr: return LIBLO_GAME_FO4VR; + case GameType::starfield: + return LIBLO_GAME_STARFIELD; default: throw std::logic_error("Unexpected game type"); } @@ -165,7 +167,7 @@ std::vector LoadOrderHandler::GetActivePlugins() const { return loadOrder; } -std::vector LoadOrderHandler::GetImplicitlyActivePlugins() const { +std::vector LoadOrderHandler::GetEarlyLoadingPlugins() const { auto logger = getLogger(); if (logger) { logger->trace("Getting implicitly active plugins."); @@ -175,7 +177,7 @@ std::vector LoadOrderHandler::GetImplicitlyActivePlugins() const { size_t pluginArrSize = 0; const unsigned int ret = - lo_get_implicitly_active_plugins(gh_.get(), &pluginArr, &pluginArrSize); + lo_get_early_loading_plugins(gh_.get(), &pluginArr, &pluginArrSize); HandleError("get implicitly active plugins", ret); diff --git a/src/api/game/load_order_handler.h b/src/api/game/load_order_handler.h index 225b1c77..786d3df2 100644 --- a/src/api/game/load_order_handler.h +++ b/src/api/game/load_order_handler.h @@ -47,7 +47,7 @@ public: std::vector GetActivePlugins() const; - std::vector GetImplicitlyActivePlugins() const; + std::vector GetEarlyLoadingPlugins() const; std::filesystem::path GetActivePluginsFilePath() const; diff --git a/src/api/metadata/condition_evaluator.cpp b/src/api/metadata/condition_evaluator.cpp index e8b25499..d9eab3b4 100644 --- a/src/api/metadata/condition_evaluator.cpp +++ b/src/api/metadata/condition_evaluator.cpp @@ -73,6 +73,8 @@ int mapGameType(GameType gameType) { return LCI_GAME_FALLOUT_4; case GameType::fo4vr: return LCI_GAME_FALLOUT_4_VR; + case GameType::starfield: + return LCI_GAME_STARFIELD; default: throw std::runtime_error( "Unrecognised game type encountered while mapping for condition " diff --git a/src/api/plugin.cpp b/src/api/plugin.cpp index e98cab9d..488b8a66 100644 --- a/src/api/plugin.cpp +++ b/src/api/plugin.cpp @@ -41,11 +41,12 @@ std::filesystem::path ReplaceExtension(std::filesystem::path path, return path.replace_extension(std::filesystem::u8path(newExtension)); } -std::filesystem::path GetTexturesArchivePath(std::filesystem::path pluginPath, +std::filesystem::path GetSuffixedArchivePath(std::filesystem::path pluginPath, + const std::string& suffix, const std::string& newExtension) { // replace_extension() with no argument just removes the existing extension. pluginPath.replace_extension(); - pluginPath += " - Textures" + newExtension; + pluginPath += suffix + newExtension; return pluginPath; } @@ -74,69 +75,110 @@ bool equivalent(const std::filesystem::path& path1, } } -std::vector FindAssociatedArchives( - const GameType gameType, - const GameCache& gameCache, +std::vector FindAssociatedArchive( const std::filesystem::path& pluginPath) { - std::vector paths; + const auto archiveFilename = ReplaceExtension(pluginPath, BSA_FILE_EXTENSION); - if (gameType == GameType::tes3) { - return paths; + if (std::filesystem::exists(archiveFilename)) { + return {archiveFilename}; } - const auto archiveExtension = GetArchiveFileExtension(gameType); + return {}; +} - if (gameType == GameType::tes5) { - // Skyrim (non-SE) plugins can only load BSAs that have exactly the same - // basename, ignoring file extensions. - const auto archiveFilename = ReplaceExtension(pluginPath, archiveExtension); +std::vector FindAssociatedArchivesWithSuffixes( + const std::filesystem::path& pluginPath, + const std::string& archiveExtension, + const std::vector& supportedSuffixes) { + std::vector paths; - if (std::filesystem::exists(archiveFilename)) { - paths.push_back(archiveFilename); - } - } else if (gameType == GameType::tes5se || gameType == GameType::tes5vr) { - // Skyrim SE can load BSAs that have exactly the same - // basename, ignoring file extensions, and also BSAs with filenames of - // the form " - Textures.bsa" (case-insensitively). - // This assumes that Skyrim VR works the same way as Skyrim SE. - const auto archiveFilename = ReplaceExtension(pluginPath, archiveExtension); - const auto texturesArchiveFilename = - GetTexturesArchivePath(pluginPath, archiveExtension); + for (const auto& suffix : supportedSuffixes) { + const auto archivePath = + GetSuffixedArchivePath(pluginPath, suffix, archiveExtension); - if (std::filesystem::exists(archiveFilename)) { - paths.push_back(archiveFilename); - } - - if (std::filesystem::exists(texturesArchiveFilename)) { - paths.push_back(texturesArchiveFilename); - } - } else if (gameType != GameType::tes4 || - boost::iends_with(pluginPath.filename().u8string(), ".esp")) { - // Oblivion .esp files and FO3, FNV, FO4 plugins can load archives which - // begin with the plugin basename. - // This assumes that FO4 VR works the same way as FO4. - - const auto basenameLength = pluginPath.stem().native().length(); - const auto pluginExtension = pluginPath.extension().native(); - - for (const auto& archivePath : gameCache.GetArchivePaths()) { - // Need to check if it starts with the given plugin's basename, - // but case insensitively. This is hard to do accurately, so - // instead check if the plugin with the same length basename and - // and the given plugin's file extension is equivalent. - const auto bsaPluginFilename = - archivePath.filename().native().substr(0, basenameLength) + - pluginExtension; - const auto bsaPluginPath = pluginPath.parent_path() / bsaPluginFilename; - if (loot::equivalent(pluginPath, bsaPluginPath)) { - paths.push_back(archivePath); - } + if (std::filesystem::exists(archivePath)) { + paths.push_back(archivePath); } } return paths; } +std::vector FindAssociatedArchivesWithArbitrarySuffixes( + const GameCache& gameCache, + const std::filesystem::path& pluginPath) { + const auto basenameLength = pluginPath.stem().native().length(); + const auto pluginExtension = pluginPath.extension().native(); + + std::vector paths; + for (const auto& archivePath : gameCache.GetArchivePaths()) { + // Need to check if it starts with the given plugin's basename, + // but case insensitively. This is hard to do accurately, so + // instead check if the plugin with the same length basename and + // and the given plugin's file extension is equivalent. + const auto bsaPluginFilename = + archivePath.filename().native().substr(0, basenameLength) + + pluginExtension; + const auto bsaPluginPath = pluginPath.parent_path() / bsaPluginFilename; + if (loot::equivalent(pluginPath, bsaPluginPath)) { + paths.push_back(archivePath); + } + } + + return paths; +} + +std::vector FindAssociatedArchives( + const GameType gameType, + const GameCache& gameCache, + const std::filesystem::path& pluginPath) { + switch (gameType) { + case GameType::tes3: + return {}; + case GameType::tes5: + // Skyrim (non-SE) plugins can only load BSAs that have exactly the same + // basename, ignoring file extensions. + return FindAssociatedArchive(pluginPath); + case GameType::tes5se: + case GameType::tes5vr: + // Skyrim SE can load BSAs that have exactly the same + // basename, ignoring file extensions, and also BSAs with filenames of + // the form " - Textures.bsa" (case-insensitively). + // This assumes that Skyrim VR works the same way as Skyrim SE. + return FindAssociatedArchivesWithSuffixes( + pluginPath, BSA_FILE_EXTENSION, {"", " - Textures"}); + case GameType::tes4: { + // Oblivion .esp files can load archives which begin with the plugin + // basename. + if (!boost::iends_with(pluginPath.filename().u8string(), ".esp")) { + return {}; + } + + return FindAssociatedArchivesWithArbitrarySuffixes(gameCache, pluginPath); + } + case GameType::fo3: + case GameType::fonv: + case GameType::fo4: + case GameType::fo4vr: { + // FO3, FNV, FO4 plugins can load archives which begin with the plugin + // basename. This assumes that FO4 VR works the same way as FO4. + return FindAssociatedArchivesWithArbitrarySuffixes(gameCache, pluginPath); + } + case GameType::starfield: + // The game will load a BA2 that's suffixed with " - Voices_" + // where is whatever language Starfield is configured to use + // (sLanguage in the ini), so this isn't exactly correct but will work + // so long as a plugin with voices has voices for English, which seems + // likely. + return FindAssociatedArchivesWithSuffixes( + pluginPath, + BA2_FILE_EXTENSION, + {" - Main", " - Textures", " - Localization", " - Voices_en"}); + default: + throw std::logic_error("Unrecognised game type"); + } +} + Plugin::Plugin(const GameType gameType, const GameCache& gameCache, std::filesystem::path pluginPath, @@ -260,6 +302,18 @@ bool Plugin::IsLightPlugin() const { return isLightPlugin; } +bool Plugin::IsOverridePlugin() const { + bool isOverridePlugin = false; + const auto ret = + esp_plugin_is_override_plugin(esPlugin.get(), &isOverridePlugin); + if (ret != ESP_OK) { + throw FileAccessError(name_ + + " : esplugin error code: " + std::to_string(ret)); + } + + return isOverridePlugin; +} + bool Plugin::IsValidAsLightPlugin() const { bool isValid = false; const auto ret = @@ -272,6 +326,18 @@ bool Plugin::IsValidAsLightPlugin() const { return isValid; } +bool Plugin::IsValidAsOverridePlugin() const { + bool isValid = false; + const auto ret = + esp_plugin_is_valid_as_override_plugin(esPlugin.get(), &isValid); + if (ret != ESP_OK) { + throw FileAccessError(name_ + + " : esplugin error code: " + std::to_string(ret)); + } + + return isValid; +} + bool Plugin::IsEmpty() const { return isEmpty_; } bool Plugin::LoadsArchive() const { return !archivePaths_.empty(); } @@ -445,10 +511,11 @@ std::string Plugin::GetDescription() const { } std::string GetArchiveFileExtension(const GameType gameType) { - if (gameType == GameType::fo4 || gameType == GameType::fo4vr) - return ".ba2"; + if (gameType == GameType::fo4 || gameType == GameType::fo4vr || + gameType == GameType::starfield) + return BA2_FILE_EXTENSION; else - return ".bsa"; + return BSA_FILE_EXTENSION; } unsigned int Plugin::GetEspluginGameId(GameType gameType) { @@ -460,13 +527,19 @@ unsigned int Plugin::GetEspluginGameId(GameType gameType) { case GameType::tes5: return ESP_GAME_SKYRIM; case GameType::tes5se: + case GameType::tes5vr: return ESP_GAME_SKYRIMSE; case GameType::fo3: return ESP_GAME_FALLOUT3; case GameType::fonv: return ESP_GAME_FALLOUTNV; - default: + case GameType::fo4: + case GameType::fo4vr: return ESP_GAME_FALLOUT4; + case GameType::starfield: + return ESP_GAME_STARFIELD; + default: + throw std::logic_error("Unrecognised game type"); } } @@ -479,7 +552,8 @@ bool hasPluginFileExtension(std::string filename, GameType gameType) { bool isEspOrEsm = boost::iends_with(filename, ".esp") || boost::iends_with(filename, ".esm"); bool isEsl = (gameType == GameType::fo4 || gameType == GameType::fo4vr || - gameType == GameType::tes5se || gameType == GameType::tes5vr) && + gameType == GameType::tes5se || gameType == GameType::tes5vr || + gameType == GameType::starfield) && boost::iends_with(filename, ".esl"); return isEspOrEsm || isEsl; diff --git a/src/api/plugin.h b/src/api/plugin.h index f35c46b8..e56cb0ca 100644 --- a/src/api/plugin.h +++ b/src/api/plugin.h @@ -70,8 +70,10 @@ public: bool IsMaster() const override; bool IsLightPlugin() const override; + bool IsOverridePlugin() const override; bool IsValidAsLightPlugin() const override; + bool IsValidAsOverridePlugin() const override; bool IsEmpty() const override; bool LoadsArchive() const override; bool DoRecordsOverlap(const PluginInterface& plugin) const override; diff --git a/src/api/sorting/plugin_sort.cpp b/src/api/sorting/plugin_sort.cpp index bc6d8d22..a89e2a2a 100644 --- a/src/api/sorting/plugin_sort.cpp +++ b/src/api/sorting/plugin_sort.cpp @@ -70,23 +70,6 @@ std::vector GetPluginsSortingData( return pluginsSortingData; } -std::vector GetPluginsWithHardcodedPositions( - const GameType gameType, - std::vector implicitlyActivePlugins) { - if (gameType == GameType::tes5) { - auto newEndIt = - std::remove_if(implicitlyActivePlugins.begin(), - implicitlyActivePlugins.end(), - [](const std::string& plugin) { - return boost::iequals(plugin, "update.esm"); - }); - - implicitlyActivePlugins.erase(newEndIt, implicitlyActivePlugins.end()); - } - - return implicitlyActivePlugins; -} - std::unordered_map GetGroupsMap( const std::vector masterlistGroups, const std::vector userGroups) { @@ -223,7 +206,7 @@ std::vector SortPlugins( const GameType gameType, const std::vector masterlistGroups, const std::vector userGroups, - const std::vector& implicitlyActivePlugins) { + const std::vector& earlyLoadingPlugins) { // If there aren't any plugins, exit early, because sorting assumes // there is at least one plugin. if (pluginsSortingData.empty()) { @@ -245,9 +228,6 @@ std::vector SortPlugins( }); // Create some shared data structures. - const auto hardcodedPlugins = - GetPluginsWithHardcodedPositions(gameType, implicitlyActivePlugins); - const auto groupsMap = GetGroupsMap(masterlistGroups, userGroups); const auto predecessorGroupsMap = GetPredecessorGroups(masterlistGroups, userGroups); @@ -270,17 +250,17 @@ std::vector SortPlugins( ValidateSpecificAndHardcodedEdges(pluginsSortingData.begin(), firstNonMasterIt, pluginsSortingData.end(), - hardcodedPlugins); + earlyLoadingPlugins); auto newLoadOrder = SortPlugins(pluginsSortingData.begin(), firstNonMasterIt, - hardcodedPlugins, + earlyLoadingPlugins, groupsMap, predecessorGroupsMap); const auto newNonMastersLoadOrder = SortPlugins(firstNonMasterIt, pluginsSortingData.end(), - hardcodedPlugins, + earlyLoadingPlugins, groupsMap, predecessorGroupsMap); @@ -310,7 +290,7 @@ std::vector SortPlugins( game.GetType(), game.GetDatabase().GetGroups(false), game.GetDatabase().GetUserGroups(), - game.GetLoadOrderHandler().GetImplicitlyActivePlugins()); + game.GetLoadOrderHandler().GetEarlyLoadingPlugins()); if (logger) { logger->debug("Calculated order:"); diff --git a/src/tests/api/interface/create_game_handle_test.h b/src/tests/api/interface/create_game_handle_test.h index 9bf216fe..b054436f 100644 --- a/src/tests/api/interface/create_game_handle_test.h +++ b/src/tests/api/interface/create_game_handle_test.h @@ -92,7 +92,11 @@ INSTANTIATE_TEST_SUITE_P(, GameType::fo3, GameType::fonv, GameType::fo4, - GameType::tes5se)); + GameType::tes5se, + GameType::fo4vr, + GameType::tes5vr, + GameType::tes3, + GameType::starfield)); TEST_P(CreateGameHandleTest, shouldSucceedIfPassedValidParametersWithRelativePaths) { diff --git a/src/tests/api/interface/game_interface_test.h b/src/tests/api/interface/game_interface_test.h index 688b249d..93261ed1 100644 --- a/src/tests/api/interface/game_interface_test.h +++ b/src/tests/api/interface/game_interface_test.h @@ -68,7 +68,11 @@ INSTANTIATE_TEST_SUITE_P(, GameType::fo3, GameType::fonv, GameType::fo4, - GameType::tes5se)); + GameType::tes5se, + GameType::fo4vr, + GameType::tes5vr, + GameType::tes3, + GameType::starfield)); TEST_P(GameInterfaceTest, setAdditionalDataPathsShouldDoThat) { const auto paths = std::vector{ @@ -211,6 +215,7 @@ TEST_P(GameInterfaceTest, sortPluginsShouldSucceedIfPassedValidArguments) { TEST_P(GameInterfaceTest, isPluginActiveShouldReturnFalseIfTheGivenPluginIsNotActive) { handle_->LoadCurrentLoadOrderState(); + EXPECT_TRUE(handle_->IsPluginActive(blankEsm)); } @@ -224,6 +229,10 @@ TEST_P(GameInterfaceTest, getLoadOrderShouldReturnTheCurrentLoadOrder) { // Remove the non-ASCII duplicate plugin. std::filesystem::remove(dataPath / std::filesystem::u8path(nonAsciiEsm)); + // Set no additional data paths to avoid picking up non-test plugins on PCs + // which have Starfield or Fallout 4 installed. + handle_->SetAdditionalDataPaths({}); + handle_->LoadCurrentLoadOrderState(); ASSERT_EQ(getLoadOrder(), handle_->GetLoadOrder()); } @@ -232,6 +241,10 @@ TEST_P(GameInterfaceTest, setLoadOrderShouldSetTheLoadOrder) { // Remove the non-ASCII duplicate plugin. std::filesystem::remove(dataPath / std::filesystem::u8path(nonAsciiEsm)); + // Set no additional data paths to avoid picking up non-test plugins on PCs + // which have Starfield or Fallout 4 installed. + handle_->SetAdditionalDataPaths({}); + handle_->LoadCurrentLoadOrderState(); std::vector loadOrder({ masterFile, @@ -247,7 +260,12 @@ TEST_P(GameInterfaceTest, setLoadOrderShouldSetTheLoadOrder) { blankPluginDependentEsp, }); - if (GetParam() == GameType::fo4 || GetParam() == GameType::tes5se) { + const auto gameSupportsEsl = + GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || + GetParam() == GameType::tes5se || GetParam() == GameType::tes5vr || + GetParam() == GameType::starfield; + + if (gameSupportsEsl) { loadOrder.insert(loadOrder.begin() + 5, blankEsl); } @@ -255,8 +273,9 @@ TEST_P(GameInterfaceTest, setLoadOrderShouldSetTheLoadOrder) { EXPECT_EQ(loadOrder, handle_->GetLoadOrder()); - if (GetParam() == GameType::fo4 || GetParam() == GameType::tes5se) + if (gameSupportsEsl) { loadOrder.erase(std::begin(loadOrder)); + } EXPECT_EQ(loadOrder, getLoadOrder()); } diff --git a/src/tests/api/internals/game/game_test.h b/src/tests/api/internals/game/game_test.h index a02ac8dd..462df581 100644 --- a/src/tests/api/internals/game/game_test.h +++ b/src/tests/api/internals/game/game_test.h @@ -67,7 +67,11 @@ INSTANTIATE_TEST_SUITE_P(, GameType::fo3, GameType::fonv, GameType::fo4, - GameType::tes5se)); + GameType::tes5se, + GameType::fo4vr, + GameType::tes5vr, + GameType::tes3, + GameType::starfield)); TEST_P(GameTest, constructingShouldStoreTheGivenValues) { Game game = Game(GetParam(), dataPath.parent_path(), localPath); @@ -77,18 +81,17 @@ TEST_P(GameTest, constructingShouldStoreTheGivenValues) { } #ifndef _WIN32 -// Testing on Windows will find real game installs in the Registry, so cannot -// test autodetection fully unless on Linux. -TEST_P(GameTest, constructingShouldThrowOnLinuxIfGamePathIsNotGiven) { - EXPECT_THROW(Game(GetParam(), "", localPath), std::invalid_argument); -} - -TEST_P(GameTest, constructingShouldThrowOnLinuxIfLocalPathIsNotGiven) { - EXPECT_THROW(Game(GetParam(), dataPath.parent_path()), std::system_error); +TEST_P(GameTest, + constructingShouldThrowOnLinuxIfLocalPathIsNotGivenExceptForMorrowind) { + if (GetParam() == GameType::tes3) { + EXPECT_NO_THROW(Game(GetParam(), dataPath.parent_path())); + } else { + EXPECT_THROW(Game(GetParam(), dataPath.parent_path()), std::system_error); + } } #else TEST_P(GameTest, constructingShouldNotThrowOnWindowsIfLocalPathIsNotGiven) { - EXPECT_NO_THROW(Game(GetParam(), dataPath.parent_path(), localPath)); + EXPECT_NO_THROW(Game(GetParam(), dataPath.parent_path())); } #endif @@ -246,8 +249,10 @@ TEST_P(GameTest, loadPluginsShouldFindArchivesInExternalDataPaths) { // Create a couple of external archive files. const std::string archiveFileExtension = - GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr ? ".ba2" - : ".bsa"; + GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || + GetParam() == GameType::starfield + ? ".ba2" + : ".bsa"; const auto ba2Path1 = dataPath.parent_path() / diff --git a/src/tests/api/internals/game/load_order_handler_test.h b/src/tests/api/internals/game/load_order_handler_test.h index 4b2ea975..43ae5531 100644 --- a/src/tests/api/internals/game/load_order_handler_test.h +++ b/src/tests/api/internals/game/load_order_handler_test.h @@ -55,10 +55,10 @@ protected: return LoadOrderHandler(GetParam(), dataPath.parent_path(), localPath); } - std::vector getImplicitlyActivePlugins() { + std::vector getEarlyLoadingPlugins() { switch (GetParam()) { case GameType::tes5: - return {"Skyrim.esm", "Update.esm"}; + return {"Skyrim.esm"}; case GameType::tes5se: return {"Skyrim.esm", "Update.esm", @@ -198,18 +198,17 @@ TEST_P(LoadOrderHandlerTest, getActivePluginsShouldReturnOnlyActivePlugins) { ASSERT_EQ(getActivePlugins(), loadOrderHandler.GetActivePlugins()); } -TEST_P( - LoadOrderHandlerTest, - getImplicitlyActivePluginsShouldReturnValidDataEvenIfStateHasNotBeenLoaded) { +TEST_P(LoadOrderHandlerTest, + getEarlyLoadingPluginsShouldReturnValidDataEvenIfStateHasNotBeenLoaded) { auto loadOrderHandler = createHandler(); - ASSERT_EQ(getImplicitlyActivePlugins(), - loadOrderHandler.GetImplicitlyActivePlugins()); + ASSERT_EQ(getEarlyLoadingPlugins(), + loadOrderHandler.GetEarlyLoadingPlugins()); loadOrderHandler.LoadCurrentState(); - ASSERT_EQ(getImplicitlyActivePlugins(), - loadOrderHandler.GetImplicitlyActivePlugins()); + ASSERT_EQ(getEarlyLoadingPlugins(), + loadOrderHandler.GetEarlyLoadingPlugins()); } TEST_P(LoadOrderHandlerTest, setLoadOrderShouldSetTheLoadOrder) { diff --git a/src/tests/api/internals/plugin_test.h b/src/tests/api/internals/plugin_test.h index 81be3b7e..4c25b583 100644 --- a/src/tests/api/internals/plugin_test.h +++ b/src/tests/api/internals/plugin_test.h @@ -65,18 +65,29 @@ protected: dataPath / blankEsp, dataPath / std::filesystem::u8path(otherNonAsciiEsp))); - if (GetParam() != GameType::fo4 && GetParam() != GameType::tes5se) { + if (GetParam() != GameType::fo4 && GetParam() != GameType::fo4vr && + GetParam() != GameType::tes5se && GetParam() != GameType::tes5vr && + GetParam() != GameType::starfield) { ASSERT_NO_THROW( std::filesystem::copy(dataPath / blankEsp, dataPath / blankEsl)); } + if (GetParam() == GameType::starfield) { + // The ESL flag is not the same as in Skyrim SE, so modify the file + // accordingly. + auto bytes = ReadFile(dataPath / blankEsl); + bytes[9] = 0x1; + WriteFile(dataPath / blankEsl, bytes); + } + // Copy across archive files. - const auto blankMasterDependentArchive = - "Blank - Master Dependent" + GetArchiveFileExtension(GetParam()); - if (GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr) { + std::filesystem::path blankMasterDependentArchive; + if (GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || + GetParam() == GameType::starfield) { copyPlugin("./Fallout 4/Data", "Blank - Main.ba2"); copyPlugin("./Fallout 4/Data", "Blank - Textures.ba2"); + blankMasterDependentArchive = "Blank - Master Dependent - Main.ba2"; std::filesystem::copy_file("./Fallout 4/Data/Blank - Main.ba2", dataPath / blankMasterDependentArchive); ASSERT_TRUE( @@ -84,10 +95,15 @@ protected: } else if (GetParam() == GameType::tes3) { out.open(dataPath / blankArchive); out.close(); + + blankMasterDependentArchive = "Blank - Master Dependent.bsa"; + out.open(dataPath / blankMasterDependentArchive); + out.close(); } else { copyPlugin(getSourcePluginsPath(), blankArchive); // Also create a copy for Blank - Master Dependent.esp to test overlap. + blankMasterDependentArchive = "Blank - Master Dependent.bsa"; std::filesystem::copy_file(getSourcePluginsPath() / blankArchive, dataPath / blankMasterDependentArchive); ASSERT_TRUE( @@ -143,6 +159,24 @@ protected: } } + std::vector ReadFile(const std::filesystem::path& path) { + std::vector bytes; + std::ifstream in(path, std::ios::binary); + + std::copy(std::istreambuf_iterator(in), + std::istreambuf_iterator(), + std::back_inserter(bytes)); + + return bytes; + } + + void WriteFile(const std::filesystem::path& path, + const std::vector& content) { + std::ofstream out(path, std::ios::binary); + + out.write(content.data(), content.size()); + } + const std::string emptyFile; const std::string lowercaseBlankEsp; const std::string nonAsciiEsp; @@ -154,7 +188,8 @@ protected: private: static std::string GetArchiveFileExtension(const GameType gameType) { - if (gameType == GameType::fo4) + if (gameType == GameType::fo4 || gameType == GameType::fo4vr || + gameType == GameType::starfield) return ".ba2"; else return ".bsa"; @@ -176,7 +211,9 @@ public: bool IsMaster() const override { return false; } bool IsLightPlugin() const override { return false; } + bool IsOverridePlugin() const override { return false; } bool IsValidAsLightPlugin() const override { return false; } + bool IsValidAsOverridePlugin() const override { return false; } bool IsEmpty() const override { return false; } bool LoadsArchive() const override { return false; } bool DoRecordsOverlap(const PluginInterface&) const override { return true; } @@ -199,13 +236,16 @@ public: // but we only have the one so no prefix is necessary. INSTANTIATE_TEST_SUITE_P(, PluginTest, - ::testing::Values(GameType::tes3, - GameType::tes4, + ::testing::Values(GameType::tes4, GameType::tes5, GameType::fo3, GameType::fonv, GameType::fo4, - GameType::tes5se)); + GameType::tes5se, + GameType::fo4vr, + GameType::tes5vr, + GameType::tes3, + GameType::starfield)); TEST_P(PluginTest, loadingShouldHandleNonAsciiFilenamesCorrectly) { Plugin plugin(game_.GetType(), @@ -305,10 +345,30 @@ TEST_P( EXPECT_FALSE(plugin1.IsLightPlugin()); EXPECT_FALSE(plugin2.IsLightPlugin()); - EXPECT_EQ(GetParam() == GameType::fo4 || GetParam() == GameType::tes5se, + EXPECT_EQ(GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || + GetParam() == GameType::tes5se || + GetParam() == GameType::tes5vr || + GetParam() == GameType::starfield, plugin3.IsLightPlugin()); } +TEST_P(PluginTest, + isOverridePluginShouldOnlyBeTrueForAStarfieldOverridePlugin) { + auto bytes = ReadFile(dataPath / blankDifferentPluginDependentEsp); + bytes[9] = 0x2; + WriteFile(dataPath / blankDifferentPluginDependentEsp, bytes); + + Plugin plugin1( + game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsp, true); + Plugin plugin2(game_.GetType(), + game_.GetCache(), + game_.DataPath() / blankDifferentPluginDependentEsp, + true); + + EXPECT_FALSE(plugin1.IsOverridePlugin()); + EXPECT_EQ(GetParam() == GameType::starfield, plugin2.IsOverridePlugin()); +} + TEST_P(PluginTest, loadingAPluginWithMastersShouldReadThemCorrectly) { Plugin plugin(game_.GetType(), game_.GetCache(), @@ -343,7 +403,7 @@ TEST_P( #ifdef _WIN32 TEST_P( PluginTest, - loadsArchiveForAnArchiveThatExactlyMatchesANonAsciiEspFileBasenameShouldReturnTrueForAllGamesExceptMorrowind) { + loadsArchiveForAnArchiveThatExactlyMatchesANonAsciiEspFileBasenameShouldReturnTrueForAllGamesExceptMorrowindAndStarfield) { bool loadsArchive = Plugin(game_.GetType(), game_.GetCache(), @@ -351,7 +411,7 @@ TEST_P( true) .LoadsArchive(); - if (GetParam() == GameType::tes3) + if (GetParam() == GameType::tes3 || GetParam() == GameType::starfield) EXPECT_FALSE(loadsArchive); else EXPECT_TRUE(loadsArchive); @@ -374,40 +434,43 @@ TEST_P( TEST_P( PluginTest, - loadsArchiveForAnArchiveWithAFilenameWhichStartsWithTheEsmFileBasenameShouldReturnTrueForAllGamesExceptMorrowindOblivionAndSkyrim) { + loadsArchiveForAnArchiveWithAFilenameWhichStartsWithTheEsmFileBasenameShouldReturnTrueForOnlyTheFalloutGames) { bool loadsArchive = Plugin(game_.GetType(), game_.GetCache(), game_.DataPath() / blankDifferentEsm, true) .LoadsArchive(); - if (GetParam() == GameType::tes3 || GetParam() == GameType::tes4 || - GetParam() == GameType::tes5 || GetParam() == GameType::tes5se) - EXPECT_FALSE(loadsArchive); - else + if (GetParam() == GameType::fo3 || GetParam() == GameType::fonv || + GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr) { EXPECT_TRUE(loadsArchive); + } else { + EXPECT_FALSE(loadsArchive); + } } TEST_P( PluginTest, - loadsArchiveForAnArchiveWithAFilenameWhichStartsWithTheEspFileBasenameShouldReturnTrueForAllGamesExceptMorrowindAndSkyrim) { + loadsArchiveForAnArchiveWithAFilenameWhichStartsWithTheEspFileBasenameShouldReturnTrueForOnlyOblivionAndTheFalloutGames) { bool loadsArchive = Plugin(game_.GetType(), game_.GetCache(), game_.DataPath() / blankDifferentEsp, true) .LoadsArchive(); - if (GetParam() == GameType::tes3 || GetParam() == GameType::tes5 || - GetParam() == GameType::tes5se) - EXPECT_FALSE(loadsArchive); - else + if (GetParam() == GameType::tes4 || GetParam() == GameType::fo3 || + GetParam() == GameType::fonv || GetParam() == GameType::fo4 || + GetParam() == GameType::fo4vr) { EXPECT_TRUE(loadsArchive); + } else { + EXPECT_FALSE(loadsArchive); + } } #ifdef _WIN32 TEST_P( PluginTest, - loadsArchiveForAnArchiveWithAFilenameWhichStartsWithTheNonAsciiEspFileBasenameShouldReturnTrueForAllGamesExceptMorrowindAndSkyrim) { + loadsArchiveForAnArchiveWithAFilenameWhichStartsWithTheNonAsciiEspFileBasenameShouldReturnTrueForOnlyOblivionAndTheFalloutGames) { bool loadsArchive = Plugin(game_.GetType(), game_.GetCache(), @@ -415,11 +478,13 @@ TEST_P( true) .LoadsArchive(); - if (GetParam() == GameType::tes3 || GetParam() == GameType::tes5 || - GetParam() == GameType::tes5se) - EXPECT_FALSE(loadsArchive); - else + if (GetParam() == GameType::tes4 || GetParam() == GameType::fo3 || + GetParam() == GameType::fonv || GetParam() == GameType::fo4 || + GetParam() == GameType::fo4vr) { EXPECT_TRUE(loadsArchive); + } else { + EXPECT_FALSE(loadsArchive); + } } #endif @@ -458,13 +523,30 @@ TEST_P( Plugin( game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsm, true) .IsValidAsLightPlugin(); - if (GetParam() == GameType::fo4 || GetParam() == GameType::tes5se) { + if (GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || + GetParam() == GameType::tes5se || GetParam() == GameType::tes5vr || + GetParam() == GameType::starfield) { EXPECT_TRUE(valid); } else { EXPECT_FALSE(valid); } } +TEST_P( + PluginTest, + IsValidAsOverridePluginShouldOnlyReturnTrueForAStarfieldPluginWithNoNewRecords) { + Plugin plugin1( + game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsp, false); + Plugin plugin2(game_.GetType(), + game_.GetCache(), + game_.DataPath() / blankDifferentPluginDependentEsp, + false); + + EXPECT_FALSE(plugin1.IsValidAsOverridePlugin()); + EXPECT_EQ(GetParam() == GameType::starfield, + plugin2.IsValidAsOverridePlugin()); +} + TEST_P(PluginTest, doRecordsOverlapShouldReturnFalseIfTheArgumentIsNotAPluginObject) { Plugin plugin1( @@ -590,7 +672,8 @@ TEST_P(PluginTest, if (GetParam() == GameType::tes3) { EXPECT_EQ(0, assetCount); - } else if (GetParam() == GameType::fo4) { + } else if (GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || + GetParam() == GameType::starfield) { EXPECT_EQ(2, assetCount); } else { EXPECT_EQ(1, assetCount); @@ -675,10 +758,13 @@ TEST_P(PluginTest, TEST_P( PluginTest, - hasPluginFileExtensionShouldBeTrueIfFileEndsInDotEslOnlyForFallout4AndSkyrimSE) { + hasPluginFileExtensionShouldBeTrueIfFileEndsInDotEslOnlyForFallout4AndLater) { bool result = hasPluginFileExtension("file.esl", GetParam()); - EXPECT_EQ(GetParam() == GameType::fo4 || GetParam() == GameType::tes5se, + EXPECT_EQ(GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || + GetParam() == GameType::tes5se || + GetParam() == GameType::tes5vr || + GetParam() == GameType::starfield, result); } diff --git a/src/tests/api/internals/sorting/plugin_graph_test.h b/src/tests/api/internals/sorting/plugin_graph_test.h index d32509ed..16d38091 100644 --- a/src/tests/api/internals/sorting/plugin_graph_test.h +++ b/src/tests/api/internals/sorting/plugin_graph_test.h @@ -60,8 +60,12 @@ public: bool IsLightPlugin() const override { return false; } + bool IsOverridePlugin() const override { return false; } + bool IsValidAsLightPlugin() const override { return false; } + bool IsValidAsOverridePlugin() const override { return false; } + bool IsEmpty() const override { return false; } bool LoadsArchive() const override { return false; } diff --git a/src/tests/common_game_test_fixture.h b/src/tests/common_game_test_fixture.h index 17379b2e..22e56c0b 100644 --- a/src/tests/common_game_test_fixture.h +++ b/src/tests/common_game_test_fixture.h @@ -108,8 +108,7 @@ protected: copyPlugin(sourcePluginsPath, blankPluginDependentEsp); copyPlugin(sourcePluginsPath, blankDifferentPluginDependentEsp); - if (GetParam() == GameType::tes5se || GetParam() == GameType::tes5vr || - GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr) { + if (supportsLightPlugins(GetParam())) { copyPlugin(sourcePluginsPath, blankEsl); } @@ -201,7 +200,7 @@ protected: actual.push_back(line); } } else { - actual = readFileLines(localPath / pluginsTxtName(GetParam())); + actual = readFileLines(localPath / "Plugins.txt"); for (auto& line : actual) { if (line[0] == '*') line = line.substr(1); @@ -226,7 +225,7 @@ protected: {blankDifferentPluginDependentEsp, false}, }); - if (GetParam() == GameType::fo4 || GetParam() == GameType::tes5se) { + if (supportsLightPlugins(GetParam())) { loadOrder.insert(loadOrder.begin() + 5, std::make_pair(blankEsl, false)); } @@ -239,7 +238,7 @@ protected: return absolute("./Morrowind/Data Files"); else if (GetParam() == GameType::tes4) return absolute("./Oblivion/Data"); - else if (GetParam() == GameType::fo4 || GetParam() == GameType::tes5se) + else if (supportsLightPlugins(GetParam())) return absolute("./SkyrimSE/Data"); else return absolute("./Skyrim/Data"); @@ -281,14 +280,19 @@ private: return "Morrowind.esm"; else if (GetParam() == GameType::tes4) return "Oblivion.esm"; - else if (GetParam() == GameType::tes5 || GetParam() == GameType::tes5se) + else if (GetParam() == GameType::tes5 || GetParam() == GameType::tes5se || + GetParam() == GameType::tes5vr) return "Skyrim.esm"; else if (GetParam() == GameType::fo3) return "Fallout3.esm"; else if (GetParam() == GameType::fonv) return "FalloutNV.esm"; - else + else if (GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr) return "Fallout4.esm"; + else if (GetParam() == GameType::starfield) + return "Starfield.esm"; + else + throw std::logic_error("Unrecognised game type"); } std::string getPluginsFolder() const { @@ -320,9 +324,9 @@ private: } } } else { - std::ofstream out(localPath / pluginsTxtName(GetParam())); + std::ofstream out(localPath / "Plugins.txt"); for (const auto& plugin : loadOrder) { - if (GetParam() == GameType::fo4 || GetParam() == GameType::tes5se) { + if (supportsLightPlugins(GetParam())) { if (plugin.second) out << '*'; } else if (!plugin.second) @@ -358,8 +362,10 @@ private: gameType == GameType::fo3 || gameType == GameType::fonv; } - static std::string pluginsTxtName(GameType gameType) { - return gameType == GameType::tes4 ? "Plugins.txt" : "plugins.txt"; + static bool supportsLightPlugins(GameType gameType) { + return gameType == GameType::tes5se || gameType == GameType::tes5vr || + gameType == GameType::fo4 || gameType == GameType::fo4vr || + gameType == GameType::starfield; } }; }