From d7f5a518e1c7ab3ca73b9a3b9fde5dd9c6a198ff Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Thu, 20 Apr 2023 18:27:26 +0100 Subject: [PATCH] Add support for MS Store's Fallout 4 DLC The Microsoft Store installs Fallout 4 DLC to separate directories outside of the game's install path. The relative paths are fixed: users cannot customise the install paths (aside from the folder within which all games are installed on a drive), and renaming the DLC's path or its Content directory causes an error when trying to launch the game. The game scans these additional data paths similarly to how it scans the main data path: it doesn't only load the DLC plugins from their data paths, it will find non-DLC plugins in them, and DLC plugins can be moved between them and still be detected. The same is true for BA2 files and other resources. To support these separate directories: - GameInterface::IsValidPlugin, GameInterface::LoadPlugins and GameInterface::SortPlugins() now take plugin paths instead of plugin filenames. Relative paths are interpreted as relative to the data path, so the change is backwards-compatible, and absolute paths are used as given. - LoadPlugins now checks that all filenames in the given paths are unique, which was previously required but not enforced. - When scanning for archives, LOOT now scans the DLC data paths before scanning the game data path. - libloadorder and loot-condition-interpreter have been updated to support the MS Store Fallout 4 DLC paths. libloadorder has built-in support for the Fallout 4 DLCs specifically, so does not need to be initialised, while loot-condition-interpreter's support is not specific and so it's now passed a list of data paths that includes the Fallout 4 DLC paths when appropriate. When listing the DLC paths together with the game's main data path, the order they're listed in matches the order in which the game scans them for plugins, with the game stopping at the first directory in which it finds a file it's looking for. This introduces detection of whether or not a game install comes from the Microsoft Store: like LOOT and libloadorder, libloot detects this by looking for an appxmanifest.xml file in the install path. The new Game::SetAdditionalDataPaths() method may be added to GameInterface in the future: that hasn't been done yet because adding it will break the ABI. --- CMakeLists.txt | 8 +- include/loot/game_interface.h | 24 ++- src/api/game/game.cpp | 170 ++++++++++++++---- src/api/game/game.h | 11 +- src/api/game/load_order_handler.cpp | 27 +++ src/api/game/load_order_handler.h | 3 + src/api/metadata/condition_evaluator.cpp | 17 +- src/api/metadata/condition_evaluator.h | 3 + src/tests/api/internals/game/game_test.h | 148 +++++++++++++++ .../internals/game/load_order_handler_test.h | 12 ++ .../metadata/condition_evaluator_test.h | 22 +++ src/tests/common_game_test_fixture.h | 2 +- 12 files changed, 397 insertions(+), 50 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 84b95ee6..dfb02ea5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -87,8 +87,8 @@ endif() ExternalProject_Add(libloadorder PREFIX "external" - URL "https://github.com/Ortham/libloadorder/archive/14.0.0.tar.gz" - URL_HASH "SHA256=ee235eeb6bbef6f73050a35190c6c10f7a1fbdb3fd32a7ed7ff7b7727844b30f" + URL "https://github.com/Ortham/libloadorder/archive/14.1.0.tar.gz" + URL_HASH "SHA256=9ff9c73612bc9e375759e122bfd56a4a45d8a4ca36837f610c05ab52fef9d67b" CONFIGURE_COMMAND "" BUILD_IN_SOURCE 1 BUILD_COMMAND cargo build --release --manifest-path ffi/Cargo.toml --target ${RUST_TARGET} && @@ -105,8 +105,8 @@ endif() ExternalProject_Add(loot-condition-interpreter PREFIX "external" - URL "https://github.com/loot/loot-condition-interpreter/archive/2.3.1.tar.gz" - URL_HASH "SHA256=e0f5533bf113c2ed48e2249b241e01c1521d9bbf615d01581870db9948b21278" + URL "https://github.com/loot/loot-condition-interpreter/archive/2.4.0.tar.gz" + URL_HASH "SHA256=7c1d42636d8b10ae2b4dc3fced4a0b25112caf5e489a8f8ef0c915b9dd1189e1" CONFIGURE_COMMAND "" BUILD_IN_SOURCE 1 BUILD_COMMAND cargo build --release --manifest-path ffi/Cargo.toml --target ${RUST_TARGET} && diff --git a/include/loot/game_interface.h b/include/loot/game_interface.h index aa6cbab6..09f6b525 100644 --- a/include/loot/game_interface.h +++ b/include/loot/game_interface.h @@ -57,25 +57,29 @@ public: * @details The validity check is not exhaustive: it checks that the file * extension is ``.esm`` or ``.esp`` (after trimming any ``.ghost`` * extension), and that the ``TES4`` header can be parsed. - * @param plugin - * The filename of the file to check. + * @param pluginPath + * The path to the file to check. Relative paths are resolved relative + * to the game's plugins directory, while absolute paths are used + * as given. * @returns True if the file is a valid plugin, false otherwise. */ - virtual bool IsValidPlugin(const std::string& plugin) const = 0; + virtual bool IsValidPlugin(const std::string& pluginPath) const = 0; /** * @brief Parses plugins and loads their data. * @details Any previously-loaded plugin data is discarded when this function * is called. - * @param plugins - * The filenames of the plugins to load. + * @param pluginPaths + * The plugin paths to load. Relative paths are resolved relative to + * the game's plugins directory, while absolute paths are used as + * given. Each plugin filename must be unique within the vector. * @param loadHeadersOnly * If true, only the plugins' ``TES4`` headers are loaded. If false, * all records in the plugins are parsed, apart from the main master * file if it has been identified by a previous call to * ``IdentifyMainMasterFile()``. */ - virtual void LoadPlugins(const std::vector& plugins, + virtual void LoadPlugins(const std::vector& pluginPaths, bool loadHeadersOnly) = 0; /** @@ -118,13 +122,15 @@ public: * applied to the load order used by the game. This function does * not load or evaluate the masterlist or userlist. * @param plugins - * A vector of filenames of the plugins to sort, in their current - * load order. + * The plugin paths to sort, in their current load order. Relative + * paths are resolved relative to the game's plugins directory, while + * absolute paths are used as given. Each plugin filename must be + * unique within the vector. * @returns A vector of the given plugin filenames in their sorted load * order. */ virtual std::vector SortPlugins( - const std::vector& plugins) = 0; + const std::vector& pluginPaths) = 0; /** * @} diff --git a/src/api/game/game.cpp b/src/api/game/game.cpp index 367480b7..1dbb18f1 100644 --- a/src/api/game/game.cpp +++ b/src/api/game/game.cpp @@ -50,6 +50,85 @@ using std::filesystem::u8path; +namespace { +using loot::GameType; + +// The Microsoft Store installs Fallout 4 DLCs to directories outside of the +// game's install path. These directories have fixed paths relative to the +// game install path (renaming them causes the game launch to fail, or not +// find the DLC files). +constexpr const char* MS_FO4_AUTOMATRON_DATA_PATH = + "../../Fallout 4- Automatron (PC)/Content/Data"; +constexpr const char* MS_FO4_CONTRAPTIONS_DATA_PATH = + "../../Fallout 4- Contraptions Workshop (PC)/Content/Data"; +constexpr const char* MS_FO4_FAR_HARBOR_DATA_PATH = + "../../Fallout 4- Far Harbor (PC)/Content/Data"; +constexpr const char* MS_FO4_TEXTURE_PACK_DATA_PATH = + "../../Fallout 4- High Resolution Texture Pack/Content/Data"; +constexpr const char* MS_FO4_NUKA_WORLD_DATA_PATH = + "../../Fallout 4- Nuka-World (PC)/Content/Data"; +constexpr const char* MS_FO4_VAULT_TEC_DATA_PATH = + "../../Fallout 4- Vault-Tec Workshop (PC)/Content/Data"; +constexpr const char* MS_FO4_WASTELAND_DATA_PATH = + "../../Fallout 4- Wasteland Workshop (PC)/Content/Data"; + +bool IsMicrosoftStoreGame(const std::filesystem::path& gamePath) { + return std::filesystem::exists(gamePath / "appxmanifest.xml"); +} + +std::vector GetAdditionalDataPaths( + const GameType gameType, + const std::filesystem::path& dataPath) { + const auto gamePath = dataPath.parent_path(); + + if (gameType == GameType::fo4 && IsMicrosoftStoreGame(gamePath)) { + // All DLC directories are listed before the main data path because DLC + // plugins in those directories override any in the main data path. + return {gamePath / MS_FO4_AUTOMATRON_DATA_PATH, + gamePath / MS_FO4_NUKA_WORLD_DATA_PATH, + gamePath / MS_FO4_WASTELAND_DATA_PATH, + gamePath / MS_FO4_TEXTURE_PACK_DATA_PATH, + gamePath / MS_FO4_VAULT_TEC_DATA_PATH, + gamePath / MS_FO4_FAR_HARBOR_DATA_PATH, + gamePath / MS_FO4_CONTRAPTIONS_DATA_PATH, + dataPath}; + } + + return {}; +} + +std::filesystem::path ResolvePluginPath( + const std::filesystem::path& dataPath, + const std::filesystem::path& pluginPath) { + return pluginPath.is_absolute() ? pluginPath : dataPath / pluginPath; +} + +std::vector FindArchives( + const std::filesystem::path& parentPath, + const std::string& archiveFileExtension) { + if (!std::filesystem::is_directory(parentPath)) { + return {}; + } + + std::vector archivePaths; + + for (std::filesystem::directory_iterator it(parentPath); + it != std::filesystem::directory_iterator(); + ++it) { + // This is only correct for ASCII strings, but that's all that + // GetArchiveFileExtension() can return. It's a lot faster than the more + // generally-correct approach of testing file path equivalence when + // there are a lot of entries in DataPath(). + if (it->is_regular_file() && + boost::iends_with(it->path().u8string(), archiveFileExtension)) { + archivePaths.push_back(it->path()); + } + } + + return archivePaths; +} +} + namespace loot { Game::Game(const GameType gameType, const std::filesystem::path& gamePath, @@ -59,7 +138,10 @@ Game::Game(const GameType gameType, loadOrderHandler_(type_, gamePath_, localDataPath), conditionEvaluator_( std::make_shared(Type(), DataPath())), - database_(ApiDatabase(conditionEvaluator_)) {} + database_(ApiDatabase(conditionEvaluator_)), + additionalDataPaths_(GetAdditionalDataPaths(Type(), DataPath())) { + conditionEvaluator_->SetAdditionalDataPaths(additionalDataPaths_); +} GameType Game::Type() const { return type_; } @@ -85,29 +167,51 @@ const DatabaseInterface& Game::GetDatabase() const { return database_; } DatabaseInterface& Game::GetDatabase() { return database_; } -bool Game::IsValidPlugin(const std::string& plugin) const { - return Plugin::IsValid(Type(), DataPath() / u8path(plugin)); +void Game::SetAdditionalDataPaths( + const std::vector& additionalDataPaths) { + additionalDataPaths_ = additionalDataPaths; + + conditionEvaluator_->SetAdditionalDataPaths(additionalDataPaths_); + conditionEvaluator_->ClearConditionCache(); + loadOrderHandler_.SetAdditionalDataPaths(additionalDataPaths_); } -void Game::LoadPlugins(const std::vector& plugins, +bool Game::IsValidPlugin(const std::string& pluginPath) const { + return Plugin::IsValid(Type(), + ResolvePluginPath(DataPath(), u8path(pluginPath))); +} + +void Game::LoadPlugins(const std::vector& pluginPaths, bool loadHeadersOnly) { const auto logger = getLogger(); - // First validate the plugins (the validity check is done in parallel because + // Check that all plugin filenames are unique. + std::unordered_set filenames; + for (const auto& pluginPath : pluginPaths) { + const auto filename = + NormalizeFilename(u8path(pluginPath).filename().u8string()); + const auto inserted = filenames.insert(filename).second; + if (!inserted) { + throw std::invalid_argument("The filename \"" + filename + + "\" is not unique."); + } + } + + // Validate the plugins (the validity check is done in parallel because // it's relatively slow). const auto invalidPluginIt = std::find_if(std::execution::par_unseq, - plugins.cbegin(), - plugins.cend(), - [this](const std::string& pluginName) { + pluginPaths.cbegin(), + pluginPaths.cend(), + [this](const std::string& pluginPath) { try { - return !IsValidPlugin(pluginName); + return !IsValidPlugin(pluginPath); } catch (...) { return true; } }); - if (invalidPluginIt != plugins.end()) { + if (invalidPluginIt != pluginPaths.end()) { throw std::invalid_argument("\"" + *invalidPluginIt + "\" is not a valid plugin"); } @@ -126,16 +230,17 @@ void Game::LoadPlugins(const std::vector& plugins, const auto masterPath = DataPath() / u8path(masterFilename_); std::for_each( std::execution::par_unseq, - plugins.begin(), - plugins.end(), - [&](const std::string& pluginName) { + pluginPaths.begin(), + pluginPaths.end(), + [&](const std::string& pluginPathString) { try { const auto endIt = - boost::iends_with(pluginName, GHOST_FILE_EXTENSION) - ? pluginName.end() - GHOST_FILE_EXTENSION_LENGTH - : pluginName.end(); + boost::iends_with(pluginPathString, GHOST_FILE_EXTENSION) + ? pluginPathString.end() - GHOST_FILE_EXTENSION_LENGTH + : pluginPathString.end(); - auto pluginPath = DataPath() / u8path(pluginName.begin(), endIt); + const auto pluginPath = ResolvePluginPath( + DataPath(), u8path(pluginPathString.begin(), endIt)); const bool loadHeader = loadHeadersOnly || loot::equivalent(pluginPath, masterPath); @@ -144,7 +249,7 @@ void Game::LoadPlugins(const std::vector& plugins, if (logger) { logger->error( "Caught exception while trying to add {} to the cache: {}", - pluginName, + pluginPathString, e.what()); } } @@ -171,11 +276,17 @@ void Game::IdentifyMainMasterFile(const std::string& masterFile) { } std::vector Game::SortPlugins( - const std::vector& plugins) { - LoadPlugins(plugins, false); + const std::vector& pluginPaths) { + LoadPlugins(pluginPaths, false); + + std::vector loadOrder; + for (const auto& pluginPath : pluginPaths) { + const auto filename = u8path(pluginPath).filename().u8string(); + loadOrder.push_back(filename); + } // Sort plugins into their load order. - return loot::SortPlugins(*this, plugins); + return loot::SortPlugins(*this, loadOrder); } void Game::LoadCurrentLoadOrderState() { @@ -208,19 +319,14 @@ void Game::CacheArchives() { const auto archiveFileExtension = GetArchiveFileExtension(Type()); std::set archivePaths; - for (std::filesystem::directory_iterator it(DataPath()); - it != std::filesystem::directory_iterator(); - ++it) { - // This is only correct for ASCII strings, but that's all that - // GetArchiveFileExtension() can return. It's a lot faster than the more - // generally-correct approach of testing file path equivalence when - // there are a lot of entries in DataPath(). - if (it->is_regular_file() && - boost::iends_with(it->path().u8string(), archiveFileExtension)) { - archivePaths.insert(it->path()); - } + for (const auto& parentPath : additionalDataPaths_) { + const auto archives = FindArchives(parentPath, archiveFileExtension); + archivePaths.insert(archives.begin(), archives.end()); } + const auto archives = FindArchives(DataPath(), archiveFileExtension); + archivePaths.insert(archives.begin(), archives.end()); + cache_.CacheArchivePaths(std::move(archivePaths)); } } diff --git a/src/api/game/game.h b/src/api/game/game.h index 5ec022cb..e8730ce8 100644 --- a/src/api/game/game.h +++ b/src/api/game/game.h @@ -55,14 +55,17 @@ public: const DatabaseInterface& GetDatabase() const; + void SetAdditionalDataPaths( + const std::vector& additionalDataPaths); + // Game Interface Methods // //////////////////////////// DatabaseInterface& GetDatabase() override; - bool IsValidPlugin(const std::string& plugin) const override; + bool IsValidPlugin(const std::string& pluginPath) const override; - void LoadPlugins(const std::vector& plugins, + void LoadPlugins(const std::vector& pluginPaths, bool loadHeadersOnly) override; const PluginInterface* GetPlugin( @@ -73,7 +76,7 @@ public: void IdentifyMainMasterFile(const std::string& masterFile) override; std::vector SortPlugins( - const std::vector& plugins) override; + const std::vector& pluginPaths) override; void LoadCurrentLoadOrderState() override; @@ -99,6 +102,8 @@ private: ApiDatabase database_; std::string masterFilename_; + + std::vector additionalDataPaths_; }; } #endif diff --git a/src/api/game/load_order_handler.cpp b/src/api/game/load_order_handler.cpp index 676cb734..953fee1b 100644 --- a/src/api/game/load_order_handler.cpp +++ b/src/api/game/load_order_handler.cpp @@ -232,6 +232,33 @@ void LoadOrderHandler::SetLoadOrder( } } +void LoadOrderHandler::SetAdditionalDataPaths( + const std::vector& dataPaths) const { + auto logger = getLogger(); + if (logger) { + logger->debug("Setting additional data paths:"); + for (const auto& dataPath : dataPaths) { + logger->debug("\t{}", dataPath.u8string()); + } + } + + std::vector dataPathStrings; + std::vector dataPathCStrings; + for (const auto& dataPath : dataPaths) { + dataPathStrings.push_back(dataPath.u8string()); + dataPathCStrings.push_back(dataPathStrings.back().c_str()); + } + + const unsigned int ret = lo_set_additional_plugins_directories( + gh_.get(), dataPathCStrings.data(), dataPathCStrings.size()); + + HandleError("set additional data paths", ret); + + if (logger) { + logger->debug("Additional data paths set successfully."); + } +} + void LoadOrderHandler::HandleError(const std::string& operation, unsigned int returnCode) const { if (returnCode == LIBLO_OK || returnCode == LIBLO_WARN_LO_MISMATCH) { diff --git a/src/api/game/load_order_handler.h b/src/api/game/load_order_handler.h index 32a08d85..225b1c77 100644 --- a/src/api/game/load_order_handler.h +++ b/src/api/game/load_order_handler.h @@ -55,6 +55,9 @@ public: void SetLoadOrder(const std::vector& loadOrder) const; + void SetAdditionalDataPaths( + const std::vector& dataPaths) const; + private: void HandleError(const std::string& operation, unsigned int returnCode) const; diff --git a/src/api/metadata/condition_evaluator.cpp b/src/api/metadata/condition_evaluator.cpp index 03233537..6feeb360 100644 --- a/src/api/metadata/condition_evaluator.cpp +++ b/src/api/metadata/condition_evaluator.cpp @@ -90,7 +90,8 @@ ConditionEvaluator::ConditionEvaluator(const GameType gameType, // This probably isn't correct for API users other than LOOT. // But that probably doesn't matter, as the only things conditional // on LOOT's version are LOOT-specific messages. - auto lootPath = std::filesystem::absolute("LOOT.exe"); + const auto lootPath = std::filesystem::absolute("LOOT.exe"); + int result = lci_state_create(&state, mapGameType(gameType), dataPath.u8string().c_str(), @@ -242,6 +243,20 @@ void ConditionEvaluator::RefreshLoadedPluginsState( HandleError("fill CRC cache for condition evaluation", result); } +void ConditionEvaluator::SetAdditionalDataPaths( + const std::vector& dataPaths) { + std::vector dataPathStrings; + std::vector dataPathCStrings; + for (const auto& dataPath : dataPaths) { + dataPathStrings.push_back(dataPath.u8string()); + dataPathCStrings.push_back(dataPathStrings.back().c_str()); + } + + int result = lci_state_set_additional_data_paths( + lciState_.get(), dataPathCStrings.data(), dataPathCStrings.size()); + HandleError("create state object for condition evaluation", result); +} + bool ConditionEvaluator::Evaluate(const PluginCleaningData& cleaningData, const std::string& pluginName) { if (pluginName.empty()) diff --git a/src/api/metadata/condition_evaluator.h b/src/api/metadata/condition_evaluator.h index 70ecbf2b..ad1c33cb 100644 --- a/src/api/metadata/condition_evaluator.h +++ b/src/api/metadata/condition_evaluator.h @@ -51,6 +51,9 @@ public: void RefreshLoadedPluginsState( const std::vector& plugins); + void SetAdditionalDataPaths( + const std::vector& dataPaths); + private: bool Evaluate(const PluginCleaningData& cleaningData, const std::string& pluginName); diff --git a/src/tests/api/internals/game/game_test.h b/src/tests/api/internals/game/game_test.h index 7697887e..b6f976df 100644 --- a/src/tests/api/internals/game/game_test.h +++ b/src/tests/api/internals/game/game_test.h @@ -95,6 +95,72 @@ TEST_P(GameTest, constructingShouldNotThrowIfGameAndLocalPathsAreNotEmpty) { EXPECT_NO_THROW(Game(GetParam(), dataPath.parent_path(), localPath)); } +TEST_P( + GameTest, + constructingForAMicrosoftStoreFallout4InstallShouldSetExternalPathsForTheDlcs) { + if (GetParam() != GameType::fo4) { + return; + } + + const auto touch = [](const std::filesystem::path& path) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream out(path); + out.close(); + }; + + // Create the file that indicates it's a Microsoft Store install. + touch(dataPath.parent_path() / "appxmanifest.xml"); + + // Create a few external files. + const auto pluginPath = + dataPath.parent_path() / + "../../Fallout 4- Automatron (PC)/Content/Data/DLCRobot.esm"; + const auto ba2Path1 = + dataPath.parent_path() / + "../../Fallout 4- Far Harbor (PC)/Content/Data/DLCCoast - Main.ba2"; + const auto ba2Path2 = + dataPath.parent_path() / + "../../Fallout 4- Nuka-World (PC)/Content/Data/DLCNukaWorld " + "- Voices_it.ba2"; + touch(pluginPath); + touch(ba2Path1); + touch(ba2Path2); + + Game game = Game(GetParam(), dataPath.parent_path(), localPath); + + EXPECT_NO_THROW(loadInstalledPlugins(game, true)); + + const auto archivePaths = game.GetCache().GetArchivePaths(); + + EXPECT_EQ(std::set( + {ba2Path1, ba2Path2, dataPath / blankArchive}), + archivePaths); + + PluginMetadata metadata(blankEsm); + metadata.SetLoadAfterFiles( + {File("DLCRobot.esm", "", "file(\"DLCRobot.esm\")")}); + game.GetDatabase().SetPluginUserMetadata(metadata); + + const auto evaluatedMetadata = + game.GetDatabase().GetPluginUserMetadata(blankEsm, true).value(); + EXPECT_FALSE(evaluatedMetadata.GetLoadAfterFiles().empty()); +} + +TEST_P(GameTest, isValidPluginShouldResolveRelativePathsRelativeToDataPath) { + const Game game(GetParam(), dataPath.parent_path(), localPath); + + game.IsValidPlugin("../" + dataPath.filename().u8string() + "/" + blankEsm); +} + +TEST_P(GameTest, isValidPluginShouldUseAbsolutePathsAsGiven) { + const Game game(GetParam(), dataPath.parent_path(), localPath); + + ASSERT_TRUE(dataPath.is_absolute()); + + const auto path = dataPath / std::filesystem::u8path(blankEsm); + game.IsValidPlugin(path.u8string()); +} + TEST_P( GameTest, loadPluginsWithHeadersOnlyTrueShouldLoadTheHeadersOfAllInstalledPlugins) { @@ -164,6 +230,43 @@ TEST_P( EXPECT_EQ(expected, game.GetCache().GetArchivePaths()); } +TEST_P(GameTest, loadPluginsShouldFindArchivesInExternalDataPaths) { + const auto touch = [](const std::filesystem::path& path) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream out(path); + out.close(); + }; + + // Create a couple of external archive files. + const std::string archiveFileExtension = + GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr ? ".ba2" + : ".bsa"; + + const auto ba2Path1 = + dataPath.parent_path() / + ("../../Fallout 4- Far Harbor (PC)/Content/Data/DLCCoast - Main" + + archiveFileExtension); + const auto ba2Path2 = + dataPath.parent_path() / + ("../../Fallout 4- Nuka-World (PC)/Content/Data/DLCNukaWorld " + "- Voices_it" + + archiveFileExtension); + touch(ba2Path1); + touch(ba2Path2); + + Game game = Game(GetParam(), dataPath.parent_path(), localPath); + + game.SetAdditionalDataPaths({ba2Path1.parent_path(), ba2Path2.parent_path()}); + + EXPECT_NO_THROW(loadInstalledPlugins(game, true)); + + const auto archivePaths = game.GetCache().GetArchivePaths(); + + EXPECT_EQ(std::set( + {ba2Path1, ba2Path2, dataPath / blankArchive}), + archivePaths); +} + TEST_P(GameTest, loadPluginsShouldClearTheArchivesCacheBeforeFindingArchives) { Game game = Game(GetParam(), dataPath.parent_path(), localPath); @@ -186,6 +289,51 @@ TEST_P( EXPECT_NO_THROW(loadInstalledPlugins(game, false)); } +TEST_P(GameTest, + loadPluginsShouldThrowIfGivenVectorElementsWithTheSameFilename) { + Game game = Game(GetParam(), dataPath.parent_path(), localPath); + + const auto dataPluginPath = dataPath / std::filesystem::u8path(blankEsm); + const auto sourcePluginPath = + getSourcePluginsPath() / std::filesystem::u8path(blankEsm); + + EXPECT_THROW( + game.LoadPlugins({dataPluginPath.u8string(), sourcePluginPath.u8string()}, + true), + std::invalid_argument); +} + +TEST_P(GameTest, loadPluginsShouldResolveRelativePathsRelativeToDataPath) { + Game game = Game(GetParam(), dataPath.parent_path(), localPath); + + const auto relativePath = + "../" + dataPath.filename().u8string() + "/" + blankEsm; + + game.LoadPlugins({relativePath}, true); + + EXPECT_NE(nullptr, game.GetPlugin(blankEsm)); +} + +TEST_P(GameTest, loadPluginsShouldUseAbsolutePathsAsGiven) { + Game game = Game(GetParam(), dataPath.parent_path(), localPath); + + const auto absolutePath = dataPath / std::filesystem::u8path(blankEsm); + + game.LoadPlugins({absolutePath.u8string()}, true); + + EXPECT_NE(nullptr, game.GetPlugin(blankEsm)); +} + +TEST_P(GameTest, sortPluginsShouldHandlePluginPathsThatAreNotJustFilenames) { + Game game = Game(GetParam(), dataPath.parent_path(), localPath); + + const auto absolutePath = dataPath / std::filesystem::u8path(blankEsm); + + const auto newLoadOrder = game.SortPlugins({absolutePath.u8string()}); + + EXPECT_EQ(std::vector{blankEsm}, newLoadOrder); +} + TEST_P(GameTest, shouldShowBlankEsmAsActiveIfItHasNotBeenLoaded) { Game game = Game(GetParam(), dataPath.parent_path(), localPath); game.LoadCurrentLoadOrderState(); 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 706df1bb..baca7bdb 100644 --- a/src/tests/api/internals/game/load_order_handler_test.h +++ b/src/tests/api/internals/game/load_order_handler_test.h @@ -214,6 +214,18 @@ TEST_P(LoadOrderHandlerTest, setLoadOrderShouldSetTheLoadOrder) { EXPECT_EQ(loadOrderToSet_, getLoadOrder()); } + +TEST_P(LoadOrderHandlerTest, setExternalPluginPathsShouldAcceptAnEmptyVector) { + auto loadOrderHandler = createHandler(); + EXPECT_NO_THROW(loadOrderHandler.SetAdditionalDataPaths({})); +} + +TEST_P(LoadOrderHandlerTest, + setExternalPluginPathsShouldAcceptANonEmptyVector) { + auto loadOrderHandler = createHandler(); + EXPECT_NO_THROW(loadOrderHandler.SetAdditionalDataPaths( + {std::filesystem::u8path("a"), std::filesystem::u8path("b")})); +} } } diff --git a/src/tests/api/internals/metadata/condition_evaluator_test.h b/src/tests/api/internals/metadata/condition_evaluator_test.h index cdeaf18b..8179f8d4 100644 --- a/src/tests/api/internals/metadata/condition_evaluator_test.h +++ b/src/tests/api/internals/metadata/condition_evaluator_test.h @@ -122,6 +122,17 @@ TEST_P(ConditionEvaluatorTest, EXPECT_TRUE(evaluator_.Evaluate("file(\"" + blankEsm + "\")")); } +TEST_P(ConditionEvaluatorTest, evaluateShouldUseAllGivenDataPaths) { + ASSERT_FALSE( + evaluator_.Evaluate("file(\"" + localPath.filename().u8string() + "\")")); + + evaluator_.ClearConditionCache(); + evaluator_.SetAdditionalDataPaths({localPath.parent_path()}); + + EXPECT_TRUE( + evaluator_.Evaluate("file(\"" + localPath.filename().u8string() + "\")")); +} + TEST_P(ConditionEvaluatorTest, evaluateFileConditionShouldReturnTrueForANonAsciiFileThatExists) { EXPECT_TRUE(evaluator_.Evaluate("file(\"" + nonAsciiEsm + "\")")); @@ -256,6 +267,17 @@ TEST_P( EXPECT_FALSE(evaluator_.Evaluate(condition)); } + +TEST_P(ConditionEvaluatorTest, + setAdditionalDataPathsShouldAcceptAnEmptyVector) { + EXPECT_NO_THROW(evaluator_.SetAdditionalDataPaths({})); +} + +TEST_P(ConditionEvaluatorTest, + setAdditionalDataPathsShouldAcceptANonEmptyVector) { + EXPECT_NO_THROW(evaluator_.SetAdditionalDataPaths( + {std::filesystem::u8path("a"), std::filesystem::u8path("b")})); +} } } diff --git a/src/tests/common_game_test_fixture.h b/src/tests/common_game_test_fixture.h index a94ff1d7..22c793c1 100644 --- a/src/tests/common_game_test_fixture.h +++ b/src/tests/common_game_test_fixture.h @@ -57,7 +57,7 @@ protected: french("fr"), german("de"), missingPath(rootTestPath / "missing"), - dataPath(rootTestPath / "game" / getPluginsFolder()), + dataPath(rootTestPath / "games" / "game" / getPluginsFolder()), localPath(rootTestPath / "local" / "game"), metadataFilesPath(rootTestPath / "metadata"), masterFile(getMasterFile()),