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; } }; }