From 84c0cca53400eccfb9d7fc10eabab0d797475e2c Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Thu, 30 Jan 2025 18:45:06 +0000 Subject: [PATCH] Implement support for OpenMW Most of the complexity is handled by libloadorder, but it's worth noting that: - The game path is OpenMW's install path, not Morrowind's - OpenMW doesn't force master-flagged plugins to load before others - OpenMW doesn't provide a way to record the load order of inactive plugins - .omwgame and .omwaddon plugins are equivalent to .esm and .esp respectively, while .omwscripts plugins have a completely different format with none of the metadata that libloot uses. - OpenMW effectively relies on additional data paths to load Morrowind's (and mods') files, and the last directory listed that contains a given filename is used to load a file with that filename, with the main data path effectively being the first listed. - I've disabled support for ghosted plugins for OpenMW because it makes the multi-path stuff more confusing and may not provide any benefit. --- README.md | 2 +- docs/api/sorting.rst | 12 +- docs/metadata/conditions.rst | 2 +- include/loot/api.h | 12 +- include/loot/enum/game_type.h | 4 +- include/loot/game_interface.h | 29 ++-- include/loot/plugin_interface.h | 18 ++- src/api/api.cpp | 2 + src/api/game/game.cpp | 84 +++++----- src/api/game/load_order_handler.cpp | 24 +++ src/api/game/load_order_handler.h | 2 + src/api/metadata/condition_evaluator.cpp | 10 +- src/api/plugin.cpp | 116 ++++++++++++-- src/api/plugin.h | 9 +- .../api/interface/create_game_handle_test.h | 5 +- src/tests/api/interface/game_interface_test.h | 74 +++++++-- src/tests/api/internals/game/game_test.h | 34 ++++- .../internals/game/load_order_handler_test.h | 74 ++++++++- .../metadata/condition_evaluator_test.h | 3 +- src/tests/api/internals/plugin_test.h | 143 ++++++++++++++---- .../api/internals/sorting/plugin_sort_test.h | 121 ++++++++++++++- .../sorting/plugin_sorting_data_test.h | 36 ++--- src/tests/common_game_test_fixture.h | 53 +++++-- src/tests/test_helpers.h | 2 +- 24 files changed, 698 insertions(+), 173 deletions(-) diff --git a/README.md b/README.md index 6321963b..b3ff0518 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## Introduction -LOOT is a plugin load order optimisation tool for Starfield, TES III: Morrowind, TES IV: Oblivion, TES V: Skyrim, TES V: Skyrim Special Edition, Fallout 3, Fallout: New Vegas, Fallout 4 and Fallout 4 VR. It is designed to assist mod users in avoiding detrimental conflicts, by automatically calculating a load order that satisfies all plugin dependencies and maximises each plugin's impact on the user's game. +LOOT is a plugin load order optimisation tool for Starfield, TES III: Morrowind, TES IV: Oblivion, TES V: Skyrim, TES V: Skyrim Special Edition, TES V: Skyrim VR, Fallout 3, Fallout: New Vegas, Fallout 4, Fallout 4 VR and OpenMW. It is designed to assist mod users in avoiding detrimental conflicts, by automatically calculating a load order that satisfies all plugin dependencies and maximises each plugin's impact on the user's game. LOOT also provides some load order error checking, including checks for requirements, incompatibilities and cyclic dependencies. In addition, it provides a large number of plugin-specific usage notes, bug warnings and Bash Tag suggestions. diff --git a/docs/api/sorting.rst b/docs/api/sorting.rst index b556ca8d..8c46ee1b 100644 --- a/docs/api/sorting.rst +++ b/docs/api/sorting.rst @@ -33,9 +33,9 @@ every time, so that it gives consistent results. After that, three graphs are created, and the plugins are added to them as vertices in their sorted order: -- One graph contains plugins that are not masters (e.g. they don't have their - master flag set - for some games they must also not have the ``.esm`` file - extension). +- One graph contains plugins that are not masters (meaning master plugins in + their own right, not that they are listed as a master within another plugin's + header). - One graph contains plugins that are blueprint masters: blueprint masters are a type of plugin specific to Starfield, so for all other games this graph will be empty. @@ -49,7 +49,7 @@ than to enforce those relationships within a single graph. A consequence of using three separate graphs is that any plugin data or metadata that involves a pair of plugins that go in different graphs will be silently -ignored. For example: if plugin A is master-flagged and plugin B is not, and +ignored. For example: if plugin A is a master and plugin B is not, and plugin A has metadata saying it must load after plugin B, then that metadata will be ignored because the two plugins are sorted independently, as if the other plugin is not installed. @@ -224,8 +224,8 @@ Combine the load orders Finally, the sorted load orders are combined in this order: -1. master-flagged plugins -2. non-master-flagged plugins +1. master plugins +2. non-master plugins 3. blueprint master plugins That gives the complete sorted load order. diff --git a/docs/metadata/conditions.rst b/docs/metadata/conditions.rst index aa10809b..f8fc2e68 100644 --- a/docs/metadata/conditions.rst +++ b/docs/metadata/conditions.rst @@ -106,7 +106,7 @@ There are several conditions that can be tested for using the functions detailed .. describe:: is_master(file_path path) - Returns true if ``path`` is an installed master plugin, and false otherwise. + Returns true if ``path`` is an installed master plugin, and false otherwise. This returns false for all OpenMW plugins, as OpenMW does not force master plugins to load before others. .. describe:: checksum(file_path path, checksum expected_checksum) diff --git a/include/loot/api.h b/include/loot/api.h index 1aaf7fec..33dd52ec 100644 --- a/include/loot/api.h +++ b/include/loot/api.h @@ -98,11 +98,13 @@ LOOT_API bool IsCompatible(const unsigned int major, * The relative or absolute path to the directory containing the * game's executable. * @param game_local_path - * The relative or absolute path to the game's folder in - * `%%LOCALAPPDATA%` or an empty path. If an empty path, the API will - * attempt to look up the path that `%%LOCALAPPDATA%` corresponds to. - * This parameter is provided so that systems lacking that environmental - * variable (eg. Linux) can still use the API. + * The relative or absolute path to the game's local data folder, or an + * empty path. The local data folder is usually in `%%LOCALAPPDATA%`, but + * Morrowind has no local data folder and OpenMW's is in the user's + * My Games folder on Windows and in `$HOME/.config` on Linux. If an + * empty path is provided, the API will attempt to look up the relevant + * local data path, which may fail in some situations (e.g. when running + * libloot natively on Linux for a game other than Morrowind or OpenMW). * @returns The new game handle. */ LOOT_API std::unique_ptr CreateGameHandle( diff --git a/include/loot/enum/game_type.h b/include/loot/enum/game_type.h index 5046c835..ccec865c 100644 --- a/include/loot/enum/game_type.h +++ b/include/loot/enum/game_type.h @@ -50,7 +50,9 @@ enum struct GameType : unsigned int { /** The Elder Scrolls III: Morrowind */ tes3, /** Starfield */ - starfield + starfield, + /** OpenMW */ + openmw }; } diff --git a/include/loot/game_interface.h b/include/loot/game_interface.h index 88ebfb96..a6715dbb 100644 --- a/include/loot/game_interface.h +++ b/include/loot/game_interface.h @@ -41,9 +41,11 @@ public: /** * @brief Gets the currently-set additional data paths. - * @details Only Fallout 4 installed from the Microsoft Store is configured - * with any additional data paths by default, as its DLC directories - * are installed outside of the Fallout 4 install path. + * @details The following games are configured with additional data paths by + * default: + * - Fallout 4, when installed from the Microsoft Store + * - Starfield + * - OpenMW */ virtual std::vector GetAdditionalDataPaths() const = 0; @@ -52,8 +54,9 @@ public: * @details The additional data paths are used when interacting with the load * order, evaluating conditions and scanning for archives (BSA/BA2 * depending on the game). Additional data paths are used in the - * order they are given, and take precedence over the game's main - * data path. + * order they are given (except with OpenMW, which checks them in + * reverse order), and take precedence over the game's main data + * path. */ virtual void SetAdditionalDataPaths( const std::vector& additionalDataPaths) = 0; @@ -87,9 +90,9 @@ public: /** * @brief Check if a file is a valid plugin. - * @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. + * @details The validity check is not exhaustive: it generally checks that the + * file is a valid plugin file extension for the game and that its + * header (if applicable) can be parsed. * @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 @@ -107,10 +110,9 @@ public: * 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()``. + * If true, only the plugins' 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& pluginPaths, @@ -219,6 +221,9 @@ public: /** * @brief Set the game's load order. + * @details There is no way to persist the load order of inactive OpenMW + * plugins, so setting an OpenMW load order will have no effect if + * the relative order of active plugins is unchanged. * @param loadOrder * A vector of plugin filenames sorted in the load order to set. */ diff --git a/include/loot/plugin_interface.h b/include/loot/plugin_interface.h index 9f64ca01..4bec165d 100644 --- a/include/loot/plugin_interface.h +++ b/include/loot/plugin_interface.h @@ -44,7 +44,8 @@ public: /** * Get the plugin's filename. * @return The plugin filename. If the plugin was ghosted when it was loaded, - * this filename will be without the .ghost suffix. + * this filename will be without the .ghost suffix, unless the game is + * OpenMW, in which case ghosted plugins are not supported. */ virtual std::string GetName() const = 0; @@ -88,8 +89,19 @@ public: virtual std::optional GetCRC() const = 0; /** - * Check if the plugin's master flag is set. - * @return True if the master flag is set, false otherwise. + * Check if the plugin is a master plugin. + * + * What causes a plugin to be a master plugin varies by game, but is usually + * indicated by the plugin having its master flag set and/or by its file + * extension. However, OpenMW uses neither for determining plugins' load order + * so all OpenMW plugins are treated as non-masters. + * + * The term "master" is potentially confusing: a plugin A may not be a *master + * plugin*, but may still be a *master of* another plugin by being listed as + * such in that plugin's header record. Master plugins are sometimes referred + * to as *master files* or simply *masters*, while the other meaning is always + * referenced in relation to another plugin. + * @return True if the plugin is a master plugin, false otherwise. */ virtual bool IsMaster() const = 0; diff --git a/src/api/api.cpp b/src/api/api.cpp index 9811d7c0..c08ccedf 100644 --- a/src/api/api.cpp +++ b/src/api/api.cpp @@ -54,6 +54,8 @@ const char* DescribeGameType(GameType gameType) { return "The Elder Scrolls III: Morrowind"; case GameType::starfield: return "Starfield"; + case GameType::openmw: + return "OpenMW"; default: return "Unknown"; } diff --git a/src/api/game/game.cpp b/src/api/game/game.cpp index 77896812..18b8cb04 100644 --- a/src/api/game/game.cpp +++ b/src/api/game/game.cpp @@ -89,6 +89,7 @@ bool IsMicrosoftStoreInstall(const GameType gameType, case GameType::tes5: case GameType::tes5vr: case GameType::fo4vr: + case GameType::openmw: return false; default: throw std::logic_error("Unrecognised game type"); @@ -115,42 +116,20 @@ std::filesystem::path GetUserDocumentsPath( #endif } -std::vector GetAdditionalDataPaths( - const GameType gameType, - const std::filesystem::path& dataPath, - const std::filesystem::path& gameLocalPath) { - const auto gamePath = dataPath.parent_path(); - - if (gameType == GameType::fo4 && - IsMicrosoftStoreInstall(gameType, gamePath)) { - 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}; - } - - if (gameType == GameType::starfield) { - return {GetUserDocumentsPath(gameLocalPath) / "My Games" / "Starfield" / - "Data"}; - } - - return {}; -} - std::filesystem::path ResolvePluginPath( + GameType gameType, const std::filesystem::path& dataPath, const std::filesystem::path& pluginPath) { auto absolutePath = pluginPath.is_absolute() ? pluginPath : dataPath / pluginPath; // In case the plugin is ghosted. - if (!std::filesystem::exists(absolutePath)) { + if (gameType != GameType::openmw && !std::filesystem::exists(absolutePath)) { const auto logger = loot::getLogger(); if (logger) { - logger->debug("Could not find plugin at {}, adding {} file extension", absolutePath.u8string(), loot::GHOST_FILE_EXTENSION); + logger->debug("Could not find plugin at {}, adding {} file extension", + absolutePath.u8string(), + loot::GHOST_FILE_EXTENSION); } absolutePath += loot::GHOST_FILE_EXTENSION; } @@ -182,6 +161,35 @@ std::vector FindArchives( return archivePaths; } + +std::filesystem::path FindPlugin( + GameType gameType, + const std::filesystem::path& dataPath, + const std::vector& additionalDataPaths, + const std::string& pluginName) { + const auto relativePath = std::filesystem::u8path(pluginName); + + const auto finder = [&](const auto& path) { + const auto resolvedPath = ResolvePluginPath(gameType, path, relativePath); + return std::filesystem::exists(resolvedPath); + }; + + if (gameType == GameType::openmw) { + const auto it = std::find_if( + additionalDataPaths.rbegin(), additionalDataPaths.rend(), finder); + if (it != additionalDataPaths.rend()) { + return *it; + } + } else { + const auto it = std::find_if( + additionalDataPaths.begin(), additionalDataPaths.end(), finder); + if (it != additionalDataPaths.end()) { + return *it; + } + } + + return dataPath / relativePath; +} } namespace loot { @@ -193,9 +201,8 @@ Game::Game(const GameType gameType, loadOrderHandler_(type_, gamePath_, localDataPath), conditionEvaluator_( std::make_shared(GetType(), DataPath())), - database_(ApiDatabase(conditionEvaluator_)), - additionalDataPaths_( - ::GetAdditionalDataPaths(GetType(), DataPath(), localDataPath)) { + database_(ApiDatabase(conditionEvaluator_)) { + additionalDataPaths_ = loadOrderHandler_.GetAdditionalDataPaths(); conditionEvaluator_->SetAdditionalDataPaths(additionalDataPaths_); } @@ -204,6 +211,8 @@ GameType Game::GetType() const { return type_; } std::filesystem::path Game::DataPath() const { if (type_ == GameType::tes3) { return gamePath_ / "Data Files"; + } else if (type_ == GameType::openmw) { + return gamePath_ / "resources" / "vfs"; } else { return gamePath_ / "Data"; } @@ -237,7 +246,8 @@ void Game::SetAdditionalDataPaths( } bool Game::IsValidPlugin(const std::filesystem::path& pluginPath) const { - return Plugin::IsValid(GetType(), ResolvePluginPath(DataPath(), pluginPath)); + return Plugin::IsValid(GetType(), + ResolvePluginPath(GetType(), DataPath(), pluginPath)); } void Game::LoadPlugins(const std::vector& pluginPaths, @@ -285,7 +295,12 @@ void Game::LoadPlugins(const std::vector& pluginPaths, logger->trace("Starting plugin loading."); } - const auto masterPath = DataPath() / u8path(masterFilename_); + const auto masterPath = GetType() == GameType::openmw + ? FindPlugin(GetType(), + DataPath(), + GetAdditionalDataPaths(), + masterFilename_) + : DataPath() / u8path(masterFilename_); std::for_each( std::execution::par_unseq, pluginPaths.begin(), @@ -293,7 +308,7 @@ void Game::LoadPlugins(const std::vector& pluginPaths, [&](const std::filesystem::path& pluginPath) { try { const auto resolvedPluginPath = - ResolvePluginPath(DataPath(), pluginPath); + ResolvePluginPath(GetType(), DataPath(), pluginPath); const bool loadHeader = loadHeadersOnly || @@ -312,7 +327,8 @@ void Game::LoadPlugins(const std::vector& pluginPaths, }); if (!loadHeadersOnly && - (GetType() == GameType::tes3 || GetType() == GameType::starfield)) { + (GetType() == GameType::tes3 || GetType() == GameType::openmw || + GetType() == GameType::starfield)) { auto plugins = cache_.GetPlugins(); const auto pluginsMetadata = Plugin::GetPluginsMetadata(plugins); for (auto& plugin : plugins) { diff --git a/src/api/game/load_order_handler.cpp b/src/api/game/load_order_handler.cpp index a6c1c6a7..44293f80 100644 --- a/src/api/game/load_order_handler.cpp +++ b/src/api/game/load_order_handler.cpp @@ -50,6 +50,8 @@ unsigned int mapGameId(GameType gameType) { return LIBLO_GAME_FO4VR; case GameType::starfield: return LIBLO_GAME_STARFIELD; + case GameType::openmw: + return LIBLO_GAME_OPENMW; default: throw std::logic_error("Unexpected game type"); } @@ -208,6 +210,28 @@ std::filesystem::path LoadOrderHandler::GetActivePluginsFilePath() const { return filePath; } +std::vector LoadOrderHandler::GetAdditionalDataPaths() const { + const auto logger = getLogger(); + if (logger) { + logger->trace("Getting additional data paths."); + } + + char** pathArr = nullptr; + size_t pathArrSize = 0; + + const unsigned int ret = lo_get_additional_plugins_directories(gh_.get(), &pathArr, &pathArrSize); + + HandleError("get additional data paths", ret); + + std::vector loadOrder; + for (size_t i = 0; i < pathArrSize; i += 1) { + loadOrder.push_back(std::filesystem::u8path(std::string(pathArr[i]))); + } + lo_free_string_array(pathArr, pathArrSize); + + return loadOrder; +} + void LoadOrderHandler::SetLoadOrder( const std::vector& loadOrder) const { auto logger = getLogger(); diff --git a/src/api/game/load_order_handler.h b/src/api/game/load_order_handler.h index 786d3df2..ae5aa9b9 100644 --- a/src/api/game/load_order_handler.h +++ b/src/api/game/load_order_handler.h @@ -51,6 +51,8 @@ public: std::filesystem::path GetActivePluginsFilePath() const; + std::vector GetAdditionalDataPaths() const; + bool IsPluginActive(const std::string& pluginName) const; void SetLoadOrder(const std::vector& loadOrder) const; diff --git a/src/api/metadata/condition_evaluator.cpp b/src/api/metadata/condition_evaluator.cpp index 7f4330c6..62c42a25 100644 --- a/src/api/metadata/condition_evaluator.cpp +++ b/src/api/metadata/condition_evaluator.cpp @@ -51,7 +51,8 @@ void HandleError(const std::string operation, int returnCode) { logger->error(err); } - throw ConditionSyntaxError(returnCode, loot_condition_interpreter_category(), err); + throw ConditionSyntaxError( + returnCode, loot_condition_interpreter_category(), err); } int mapGameType(GameType gameType) { @@ -76,6 +77,8 @@ int mapGameType(GameType gameType) { return LCI_GAME_FALLOUT_4_VR; case GameType::starfield: return LCI_GAME_STARFIELD; + case GameType::openmw: + return LCI_GAME_OPENMW; default: throw std::runtime_error( "Unrecognised game type encountered while mapping for condition " @@ -90,9 +93,8 @@ ConditionEvaluator::ConditionEvaluator(const GameType gameType, lci_state_destroy)) { lci_state* state = nullptr; - int result = lci_state_create(&state, - mapGameType(gameType), - dataPath.u8string().c_str()); + int result = lci_state_create( + &state, mapGameType(gameType), dataPath.u8string().c_str()); HandleError("create state object for condition evaluation", result); lciState_ = std::unique_ptr( diff --git a/src/api/plugin.cpp b/src/api/plugin.cpp index 5795306e..6f208aa7 100644 --- a/src/api/plugin.cpp +++ b/src/api/plugin.cpp @@ -135,6 +135,7 @@ std::vector FindAssociatedArchives( const std::filesystem::path& pluginPath) { switch (gameType) { case GameType::tes3: + case GameType::openmw: return {}; case GameType::tes5: // Skyrim (non-SE) plugins can only load BSAs that have exactly the same @@ -229,18 +230,24 @@ Plugin::Plugin(const GameType gameType, const GameCache& gameCache, std::filesystem::path pluginPath, const bool headerOnly) : - name_(TrimDotGhostExtension(pluginPath.filename().u8string())), + name_(gameType == GameType::openmw + ? pluginPath.filename().u8string() + : TrimDotGhostExtension(pluginPath.filename().u8string())), esPlugin( std::unique_ptr<::Plugin, decltype(&esp_plugin_free)>(nullptr, esp_plugin_free)), + ignoreMasterFlag_(gameType == GameType::openmw), isEmpty_(true) { auto logger = getLogger(); try { - Load(pluginPath, gameType, headerOnly); + if (gameType != GameType::openmw || + pluginPath.extension() != ".omwscripts") { + Load(pluginPath, gameType, headerOnly); - auto ret = esp_plugin_is_empty(esPlugin.get(), &isEmpty_); - HandleEspluginError(ret, "check if \"{}\" is empty", name_); + auto ret = esp_plugin_is_empty(esPlugin.get(), &isEmpty_); + HandleEspluginError(ret, "check if \"{}\" is empty", name_); + } archivePaths_ = FindAssociatedArchives(gameType, gameCache, pluginPath); @@ -284,6 +291,10 @@ Plugin::Plugin(const GameType gameType, } void Plugin::ResolveRecordIds(Vec_PluginMetadata* pluginsMetadata) const { + if (esPlugin == nullptr) { + return; + } + auto ret = esp_plugin_resolve_record_ids(esPlugin.get(), pluginsMetadata); HandleEspluginError(ret, "resolve the record IDs of \"{}\"", name_); } @@ -291,6 +302,10 @@ void Plugin::ResolveRecordIds(Vec_PluginMetadata* pluginsMetadata) const { std::string Plugin::GetName() const { return name_; } std::optional Plugin::GetHeaderVersion() const { + if (esPlugin == nullptr) { + return std::nullopt; + } + float version = 0.0f; const auto ret = esp_plugin_header_version(esPlugin.get(), &version); @@ -308,6 +323,10 @@ std::optional Plugin::GetVersion() const { } std::vector Plugin::GetMasters() const { + if (esPlugin == nullptr) { + return {}; + } + char** masters = nullptr; size_t numMasters = 0; const auto ret = esp_plugin_masters(esPlugin.get(), &masters, &numMasters); @@ -325,6 +344,10 @@ std::vector Plugin::GetBashTags() const { return tags_; } std::optional Plugin::GetCRC() const { return crc_; } bool Plugin::IsMaster() const { + if (ignoreMasterFlag_ || esPlugin == nullptr) { + return false; + } + bool isMaster = false; const auto ret = esp_plugin_is_master(esPlugin.get(), &isMaster); HandleEspluginError(ret, "check if \"{}\" is a master", name_); @@ -333,6 +356,10 @@ bool Plugin::IsMaster() const { } bool Plugin::IsLightPlugin() const { + if (esPlugin == nullptr) { + return false; + } + bool isLightPlugin = false; const auto ret = esp_plugin_is_light_plugin(esPlugin.get(), &isLightPlugin); HandleEspluginError(ret, "check if \"{}\" is a light plugin", name_); @@ -341,6 +368,10 @@ bool Plugin::IsLightPlugin() const { } bool Plugin::IsMediumPlugin() const { + if (esPlugin == nullptr) { + return false; + } + bool isMediumPlugin = false; const auto ret = esp_plugin_is_medium_plugin(esPlugin.get(), &isMediumPlugin); HandleEspluginError(ret, "check if \"{}\" is a medium plugin", name_); @@ -349,6 +380,10 @@ bool Plugin::IsMediumPlugin() const { } bool Plugin::IsUpdatePlugin() const { + if (esPlugin == nullptr) { + return false; + } + bool isUpdatePlugin = false; const auto ret = esp_plugin_is_update_plugin(esPlugin.get(), &isUpdatePlugin); HandleEspluginError(ret, "check if \"{}\" is an update plugin", name_); @@ -357,6 +392,10 @@ bool Plugin::IsUpdatePlugin() const { } bool Plugin::IsBlueprintPlugin() const { + if (esPlugin == nullptr) { + return false; + } + bool isBlueprintPlugin = false; const auto ret = esp_plugin_is_blueprint_plugin(esPlugin.get(), &isBlueprintPlugin); @@ -366,6 +405,10 @@ bool Plugin::IsBlueprintPlugin() const { } bool Plugin::IsValidAsLightPlugin() const { + if (esPlugin == nullptr) { + return false; + } + bool isValid = false; const auto ret = esp_plugin_is_valid_as_light_plugin(esPlugin.get(), &isValid); @@ -375,6 +418,10 @@ bool Plugin::IsValidAsLightPlugin() const { } bool Plugin::IsValidAsMediumPlugin() const { + if (esPlugin == nullptr) { + return false; + } + bool isValid = false; const auto ret = esp_plugin_is_valid_as_medium_plugin(esPlugin.get(), &isValid); @@ -385,6 +432,10 @@ bool Plugin::IsValidAsMediumPlugin() const { } bool Plugin::IsValidAsUpdatePlugin() const { + if (esPlugin == nullptr) { + return false; + } + bool isValid = false; const auto ret = esp_plugin_is_valid_as_update_plugin(esPlugin.get(), &isValid); @@ -399,9 +450,17 @@ bool Plugin::IsEmpty() const { return isEmpty_; } bool Plugin::LoadsArchive() const { return !archivePaths_.empty(); } bool Plugin::DoRecordsOverlap(const PluginInterface& plugin) const { + if (esPlugin == nullptr) { + return false; + } + try { auto& otherPlugin = dynamic_cast(plugin); + if (otherPlugin.esPlugin == nullptr) { + return false; + } + bool doPluginsOverlap = false; const auto ret = esp_plugin_do_records_overlap( esPlugin.get(), otherPlugin.esPlugin.get(), &doPluginsOverlap); @@ -424,6 +483,10 @@ bool Plugin::DoRecordsOverlap(const PluginInterface& plugin) const { } size_t Plugin::GetOverrideRecordCount() const { + if (esPlugin == nullptr) { + return 0; + } + size_t overrideRecordCount; const auto ret = esp_plugin_count_override_records(esPlugin.get(), &overrideRecordCount); @@ -433,6 +496,10 @@ size_t Plugin::GetOverrideRecordCount() const { } uint32_t Plugin::GetRecordAndGroupCount() const { + if (esPlugin == nullptr) { + return 0; + } + uint32_t recordAndGroupCount = 0; const auto ret = esp_plugin_record_and_group_count(esPlugin.get(), &recordAndGroupCount); @@ -515,6 +582,10 @@ void Plugin::Load(const std::filesystem::path& path, } std::string Plugin::GetDescription() const { + if (esPlugin == nullptr) { + return ""; + } + char* description = nullptr; const auto ret = esp_plugin_description(esPlugin.get(), &description); HandleEspluginError(ret, "read the description of \"{}\"", name_); @@ -530,7 +601,7 @@ std::string Plugin::GetDescription() const { } std::unique_ptr -Plugin::GetPluginsMetadata(std::vector plugins) { +Plugin::GetPluginsMetadata(const std::vector& plugins) { if (plugins.empty()) { return std::unique_ptr( @@ -540,7 +611,10 @@ Plugin::GetPluginsMetadata(std::vector plugins) { std::vector esPlugins; esPlugins.reserve(plugins.size()); for (const auto& plugin : plugins) { - esPlugins.push_back(plugin->esPlugin.get()); + const auto esPlugin = plugin->esPlugin.get(); + if (esPlugin != nullptr) { + esPlugins.push_back(plugin->esPlugin.get()); + } } Vec_PluginMetadata* pluginsMetadata = nullptr; @@ -566,6 +640,7 @@ std::string GetArchiveFileExtension(const GameType gameType) { unsigned int Plugin::GetEspluginGameId(GameType gameType) { switch (gameType) { case GameType::tes3: + case GameType::openmw: return ESP_GAME_MORROWIND; case GameType::tes4: return ESP_GAME_OBLIVION; @@ -589,18 +664,31 @@ unsigned int Plugin::GetEspluginGameId(GameType gameType) { } bool hasPluginFileExtension(std::string filename, GameType gameType) { - if (boost::iends_with(filename, GHOST_FILE_EXTENSION)) { + if (gameType != GameType::openmw && + boost::iends_with(filename, GHOST_FILE_EXTENSION)) { filename = filename.substr(0, filename.length() - GHOST_FILE_EXTENSION_LENGTH); } - 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::starfield) && - boost::iends_with(filename, ".esl"); + if (boost::iends_with(filename, ".esp") || + boost::iends_with(filename, ".esm")) { + return true; + } - return isEspOrEsm || isEsl; + if (gameType == GameType::openmw && + (boost::iends_with(filename, ".omwaddon") || + boost::iends_with(filename, ".omwgame") || + boost::iends_with(filename, ".omwscripts"))) { + return true; + } + + if ((gameType == GameType::fo4 || gameType == GameType::fo4vr || + gameType == GameType::tes5se || gameType == GameType::tes5vr || + gameType == GameType::starfield) && + boost::iends_with(filename, ".esl")) { + return true; + } + + return false; } } diff --git a/src/api/plugin.h b/src/api/plugin.h index d53f31c8..3c5bc877 100644 --- a/src/api/plugin.h +++ b/src/api/plugin.h @@ -93,8 +93,7 @@ public: static std::unique_ptr - GetPluginsMetadata( - std::vector); + GetPluginsMetadata(const std::vector& plugins); private: void Load(const std::filesystem::path& path, @@ -106,8 +105,10 @@ private: std::string name_; std::unique_ptr<::Plugin, decltype(&esp_plugin_free)> esPlugin; - bool isEmpty_; // Does the plugin contain any records other than the TES4 - // header? + bool ignoreMasterFlag_{false}; + bool isEmpty_{false}; // Does the plugin contain any records other than the + // TES4 + // header? std::optional version_; // Obtained from description field. std::optional crc_; std::vector tags_; diff --git a/src/tests/api/interface/create_game_handle_test.h b/src/tests/api/interface/create_game_handle_test.h index 00ea7a33..8c21a643 100644 --- a/src/tests/api/interface/create_game_handle_test.h +++ b/src/tests/api/interface/create_game_handle_test.h @@ -95,7 +95,8 @@ INSTANTIATE_TEST_SUITE_P(, GameType::fo4vr, GameType::tes5vr, GameType::tes3, - GameType::starfield)); + GameType::starfield, + GameType::openmw)); TEST_P(CreateGameHandleTest, shouldSucceedIfPassedValidParametersWithRelativePaths) { @@ -124,7 +125,7 @@ TEST_P(CreateGameHandleTest, shouldSucceedIfPassedALocalPathThatDoesNotExist) { TEST_P(CreateGameHandleTest, shouldThrowIfPassedALocalPathThatIsNotADirectory) { EXPECT_THROW(CreateGameHandle(GetParam(), gamePath, dataPath / blankEsm), - std::invalid_argument); + std::invalid_argument); } #ifdef _WIN32 diff --git a/src/tests/api/interface/game_interface_test.h b/src/tests/api/interface/game_interface_test.h index f56be290..ce03d7a3 100644 --- a/src/tests/api/interface/game_interface_test.h +++ b/src/tests/api/interface/game_interface_test.h @@ -83,7 +83,8 @@ INSTANTIATE_TEST_SUITE_P(, GameType::fo4vr, GameType::tes5vr, GameType::tes3, - GameType::starfield)); + GameType::starfield, + GameType::openmw)); TEST_P(GameInterfaceTest, setAdditionalDataPathsShouldDoThat) { const auto paths = std::vector{ @@ -135,7 +136,16 @@ TEST_P( } TEST_P(GameInterfaceTest, loadPluginsShouldTrimDotGhostFileExtensions) { - handle_->LoadPlugins({blankMasterDependentEsm + ".ghost"}, true); + if (GetParam() == GameType::openmw) { + // Ghosting is not supported for OpenMW. + EXPECT_THROW( + handle_->LoadPlugins({blankMasterDependentEsm + ".ghost"}, true), + std::invalid_argument); + return; + } else { + handle_->LoadPlugins({blankMasterDependentEsm + ".ghost"}, true); + } + EXPECT_EQ(1, handle_->GetLoadedPlugins().size()); ASSERT_NO_THROW(handle_->GetPlugin(blankMasterDependentEsm)); @@ -245,11 +255,32 @@ TEST_P(GameInterfaceTest, getLoadOrderShouldReturnTheCurrentLoadOrder) { 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({}); + // which have Starfield or Fallout 4 installed. Don't clear the additional + // data paths for OpenMW because they come from test config. + if (GetParam() != GameType::openmw) { + handle_->SetAdditionalDataPaths({}); + } handle_->LoadCurrentLoadOrderState(); - ASSERT_EQ(getLoadOrder(), handle_->GetLoadOrder()); + + if (GetParam() == GameType::openmw) { + ASSERT_EQ(std::vector({ + blankDifferentEsm, + blankDifferentMasterDependentEsm, + blankDifferentEsp, + blankDifferentPluginDependentEsp, + blankMasterDependentEsm, + blankMasterDependentEsp, + blankEsp, + blankPluginDependentEsp, + masterFile, + blankEsm, + blankDifferentMasterDependentEsp, + }), + handle_->GetLoadOrder()); + } else { + ASSERT_EQ(getLoadOrder(), handle_->GetLoadOrder()); + } } TEST_P(GameInterfaceTest, setLoadOrderShouldSetTheLoadOrder) { @@ -257,8 +288,11 @@ TEST_P(GameInterfaceTest, setLoadOrderShouldSetTheLoadOrder) { 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({}); + // which have Starfield or Fallout 4 installed. Don't clear the additional + // data paths for OpenMW because they come from test config. + if (GetParam() != GameType::openmw) { + handle_->SetAdditionalDataPaths({}); + } handle_->LoadCurrentLoadOrderState(); @@ -278,6 +312,20 @@ TEST_P(GameInterfaceTest, setLoadOrderShouldSetTheLoadOrder) { blankEsp, blankMasterDependentEsp, }; + } else if (GetParam() == GameType::openmw) { + loadOrder = { + blankDifferentMasterDependentEsm, + blankDifferentPluginDependentEsp, + blankDifferentEsm, + blankDifferentEsp, + blankMasterDependentEsm, + blankMasterDependentEsp, + blankPluginDependentEsp, + blankEsp, + masterFile, + blankDifferentMasterDependentEsp, + blankEsm, + }; } else { loadOrder = { masterFile, @@ -302,11 +350,15 @@ TEST_P(GameInterfaceTest, setLoadOrderShouldSetTheLoadOrder) { EXPECT_EQ(loadOrder, handle_->GetLoadOrder()); - if (gameSupportsEsl) { - loadOrder.erase(std::begin(loadOrder)); - } + // It's not possible to persist the load order of inactive plugins for + // OpenMW. + if (GetParam() != GameType::openmw) { + if (gameSupportsEsl) { + loadOrder.erase(std::begin(loadOrder)); + } - EXPECT_EQ(loadOrder, getLoadOrder()); + 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 992327f2..67ad5ad9 100644 --- a/src/tests/api/internals/game/game_test.h +++ b/src/tests/api/internals/game/game_test.h @@ -58,7 +58,8 @@ INSTANTIATE_TEST_SUITE_P(, GameType::fo4vr, GameType::tes5vr, GameType::tes3, - GameType::starfield)); + GameType::starfield, + GameType::openmw)); TEST_P(GameTest, constructingShouldStoreTheGivenValues) { Game game = Game(GetParam(), gamePath, localPath); @@ -70,7 +71,7 @@ TEST_P(GameTest, constructingShouldStoreTheGivenValues) { #ifndef _WIN32 TEST_P(GameTest, constructingShouldThrowOnLinuxIfLocalPathIsNotGivenExceptForMorrowind) { - if (GetParam() == GameType::tes3) { + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { EXPECT_NO_THROW(Game(GetParam(), gamePath)); } else { EXPECT_THROW(Game(GetParam(), gamePath), std::system_error); @@ -118,6 +119,9 @@ TEST_P( "My Games" / "Starfield" / "Data"; EXPECT_TRUE(boost::ends_with(game.GetAdditionalDataPaths()[0].u8string(), expectedSuffix.u8string())); + } else if (GetParam() == GameType::openmw) { + EXPECT_EQ(std::vector{localPath / "data"}, + game.GetAdditionalDataPaths()); } else { EXPECT_TRUE(game.GetAdditionalDataPaths().empty()); } @@ -195,6 +199,23 @@ TEST_P(GameTest, isValidPluginShouldUseAbsolutePathsAsGiven) { EXPECT_TRUE(game.IsValidPlugin(path)); } +TEST_P( + GameTest, + isValidPluginShouldTryGhostedPathIfGivenPluginDoesNotExistExceptForOpenMW) { + const Game game(GetParam(), gamePath, localPath); + + if (GetParam() == GameType::openmw) { + // This wasn't done for OpenMW during common setup. + const auto pluginPath = + game.DataPath() / (blankMasterDependentEsm + ".ghost"); + std::filesystem::rename(dataPath / blankMasterDependentEsm, pluginPath); + + EXPECT_FALSE(game.IsValidPlugin(blankMasterDependentEsm)); + } else { + EXPECT_TRUE(game.IsValidPlugin(blankMasterDependentEsm)); + } +} + TEST_P( GameTest, loadPluginsWithHeadersOnlyTrueShouldLoadTheHeadersOfAllInstalledPlugins) { @@ -289,8 +310,8 @@ TEST_P(GameTest, loadPluginsShouldFindArchivesInAdditionalDataPaths) { archiveFileExtension); const auto ba2Path2 = gamePath / ("../../Fallout 4- Nuka-World (PC)/Content/Data/DLCNukaWorld " - "- Voices_it" + - archiveFileExtension); + "- Voices_it" + + archiveFileExtension); touch(ba2Path1); touch(ba2Path2); @@ -371,10 +392,11 @@ TEST_P( std::filesystem::remove(dataPath / pluginName); - if (GetParam() == GameType::tes3 || GetParam() == GameType::starfield) { + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw || + GetParam() == GameType::starfield) { try { game.LoadPlugins({blankMasterDependentEsm}, false); - FAIL(); + FAIL(); } catch (const std::system_error& e) { EXPECT_EQ(ESP_ERROR_PLUGIN_METADATA_NOT_FOUND, e.code().value()); EXPECT_EQ(esplugin_category(), e.code().category()); 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 dfedd9a2..5e7e18e3 100644 --- a/src/tests/api/internals/game/load_order_handler_test.h +++ b/src/tests/api/internals/game/load_order_handler_test.h @@ -57,6 +57,8 @@ protected: std::vector getEarlyLoadingPlugins() { switch (GetParam()) { + case GameType::openmw: + return {"builtin.omwscripts"}; case GameType::tes5: return {"Skyrim.esm"}; case GameType::tes5se: @@ -111,7 +113,8 @@ INSTANTIATE_TEST_SUITE_P(, GameType::fo3, GameType::fonv, GameType::fo4, - GameType::tes5se)); + GameType::tes5se, + GameType::openmw)); TEST_P(LoadOrderHandlerTest, constructorShouldThrowIfNoGamePathIsSet) { EXPECT_THROW(LoadOrderHandler(GetParam(), ""), std::invalid_argument); @@ -129,7 +132,7 @@ TEST_P(LoadOrderHandlerTest, constructorShouldNotThrowIfNoLocalPathIsSet) { #else TEST_P(LoadOrderHandlerTest, constructorShouldNotThrowIfNoLocalPathIsSetAndGameTypeIsMorrowind) { - if (GetParam() == GameType::tes3) { + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { EXPECT_NO_THROW(LoadOrderHandler(GetParam(), gamePath)); } else { EXPECT_THROW(LoadOrderHandler(GetParam(), gamePath), std::system_error); @@ -179,7 +182,24 @@ TEST_P(LoadOrderHandlerTest, getLoadOrderShouldReturnTheCurrentLoadOrder) { auto loadOrderHandler = createHandler(); loadOrderHandler.LoadCurrentState(); - ASSERT_EQ(getLoadOrder(), loadOrderHandler.GetLoadOrder()); + if (GetParam() == GameType::openmw) { + EXPECT_EQ(std::vector({ + blankDifferentEsm, + blankDifferentMasterDependentEsm, + blankDifferentEsp, + blankDifferentPluginDependentEsp, + blankMasterDependentEsm, + blankMasterDependentEsp, + blankEsp, + blankPluginDependentEsp, + masterFile, + blankEsm, + blankDifferentMasterDependentEsp, + }), + loadOrderHandler.GetLoadOrder()); + } else { + ASSERT_EQ(getLoadOrder(), loadOrderHandler.GetLoadOrder()); + } } TEST_P(LoadOrderHandlerTest, @@ -209,6 +229,45 @@ TEST_P(LoadOrderHandlerTest, loadOrderHandler.GetEarlyLoadingPlugins()); } +TEST_P(LoadOrderHandlerTest, getAdditionalDataPathsShouldReturnValidData) { + if (GetParam() == GameType::fo4) { + // Create the file that indicates it's a Microsoft Store install. + touch(gamePath / "appxmanifest.xml"); + } + + auto loadOrderHandler = createHandler(); + + if (GetParam() == GameType::fo4) { + const auto basePath = gamePath / ".." / ".."; + EXPECT_EQ(std::vector( + {basePath / "Fallout 4- Automatron (PC)" / "Content" / "Data", + basePath / "Fallout 4- Nuka-World (PC)" / "Content" / "Data", + basePath / "Fallout 4- Wasteland Workshop (PC)" / "Content" / + "Data", + basePath / "Fallout 4- High Resolution Texture Pack" / + "Content" / "Data", + basePath / "Fallout 4- Vault-Tec Workshop (PC)" / "Content" / + "Data", + basePath / "Fallout 4- Far Harbor (PC)" / "Content" / "Data", + basePath / "Fallout 4- Contraptions Workshop (PC)" / + "Content" / "Data"}), + loadOrderHandler.GetAdditionalDataPaths()); + } else if (GetParam() == GameType::starfield) { + ASSERT_EQ(1, loadOrderHandler.GetAdditionalDataPaths().size()); + + const auto expectedSuffix = std::filesystem::u8path("Documents") / + "My Games" / "Starfield" / "Data"; + EXPECT_TRUE(boost::ends_with( + loadOrderHandler.GetAdditionalDataPaths()[0].u8string(), + expectedSuffix.u8string())); + } else if (GetParam() == GameType::openmw) { + EXPECT_EQ(std::vector{localPath / "data"}, + loadOrderHandler.GetAdditionalDataPaths()); + } else { + EXPECT_TRUE(loadOrderHandler.GetAdditionalDataPaths().empty()); + } +} + TEST_P(LoadOrderHandlerTest, setLoadOrderShouldSetTheLoadOrder) { auto loadOrderHandler = createHandler(); loadOrderHandler.LoadCurrentState(); @@ -218,7 +277,14 @@ TEST_P(LoadOrderHandlerTest, setLoadOrderShouldSetTheLoadOrder) { if (GetParam() == GameType::fo4 || GetParam() == GameType::tes5se) loadOrderToSet_.erase(begin(loadOrderToSet_)); - EXPECT_EQ(loadOrderToSet_, getLoadOrder()); + if (GetParam() == GameType::openmw) { + // Can't set the load order positions of inactive plugins, + // this reads what libloadorder has cached in memory instead of + // what was actually saved. + EXPECT_EQ(loadOrderToSet_, loadOrderHandler.GetLoadOrder()); + } else { + EXPECT_EQ(loadOrderToSet_, getLoadOrder()); + } } TEST_P(LoadOrderHandlerTest, setExternalPluginPathsShouldAcceptAnEmptyVector) { diff --git a/src/tests/api/internals/metadata/condition_evaluator_test.h b/src/tests/api/internals/metadata/condition_evaluator_test.h index 5bf2153c..2daa1e7a 100644 --- a/src/tests/api/internals/metadata/condition_evaluator_test.h +++ b/src/tests/api/internals/metadata/condition_evaluator_test.h @@ -91,7 +91,8 @@ INSTANTIATE_TEST_SUITE_P(, GameType::fo3, GameType::fonv, GameType::fo4, - GameType::tes5se)); + GameType::tes5se, + GameType::openmw)); TEST_P(ConditionEvaluatorTest, evaluateShouldReturnTrueForAnEmptyConditionString) { diff --git a/src/tests/api/internals/plugin_test.h b/src/tests/api/internals/plugin_test.h index 995cfb3d..ac6c76a0 100644 --- a/src/tests/api/internals/plugin_test.h +++ b/src/tests/api/internals/plugin_test.h @@ -85,7 +85,7 @@ protected: dataPath / blankMasterDependentArchive); ASSERT_TRUE( std::filesystem::exists(dataPath / blankMasterDependentArchive)); - } else if (GetParam() == GameType::tes3) { + } else if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { touch(dataPath / blankArchive); blankMasterDependentArchive = "Blank - Master Dependent.bsa"; @@ -191,7 +191,26 @@ INSTANTIATE_TEST_SUITE_P(, GameType::fo4vr, GameType::tes5vr, GameType::tes3, - GameType::starfield)); + GameType::starfield, + GameType::openmw)); + +TEST_P(PluginTest, constructorShouldTrimGhostExtensionExceptForOpenMW) { + const auto pluginPath = + game_.DataPath() / (blankMasterDependentEsm + ".ghost"); + + if (GetParam() == GameType::openmw) { + // This wasn't done for OpenMW during common setup. + std::filesystem::rename(dataPath / blankMasterDependentEsm, pluginPath); + } + + Plugin plugin(game_.GetType(), game_.GetCache(), pluginPath, true); + + if (GetParam() == GameType::openmw) { + EXPECT_EQ(pluginPath.filename().u8string(), plugin.GetName()); + } else { + EXPECT_EQ(blankMasterDependentEsm, plugin.GetName()); + } +} TEST_P(PluginTest, loadingShouldHandleNonAsciiFilenamesCorrectly) { Plugin plugin(game_.GetType(), @@ -209,11 +228,15 @@ TEST_P(PluginTest, loadingHeaderOnlyShouldReadHeaderData) { EXPECT_EQ(blankEsm, plugin.GetName()); EXPECT_TRUE(plugin.GetMasters().empty()); - EXPECT_TRUE(plugin.IsMaster()); + if (GetParam() == GameType::openmw) { + EXPECT_FALSE(plugin.IsMaster()); + } else { + EXPECT_TRUE(plugin.IsMaster()); + } EXPECT_FALSE(plugin.IsEmpty()); EXPECT_EQ("5.0", plugin.GetVersion()); - if (GetParam() == GameType::tes3) { + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { EXPECT_FLOAT_EQ(1.2f, plugin.GetHeaderVersion().value()); } else if (GetParam() == GameType::tes4) { EXPECT_FLOAT_EQ(0.8f, plugin.GetHeaderVersion().value()); @@ -237,11 +260,15 @@ TEST_P(PluginTest, loadingWholePluginShouldReadHeaderData) { EXPECT_EQ(blankEsm, plugin.GetName()); EXPECT_TRUE(plugin.GetMasters().empty()); - EXPECT_TRUE(plugin.IsMaster()); + if (GetParam() == GameType::openmw) { + EXPECT_FALSE(plugin.IsMaster()); + } else { + EXPECT_TRUE(plugin.IsMaster()); + } EXPECT_FALSE(plugin.IsEmpty()); EXPECT_EQ("5.0", plugin.GetVersion()); - if (GetParam() == GameType::tes3) { + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { EXPECT_FLOAT_EQ(1.2f, plugin.GetHeaderVersion().value()); } else if (GetParam() == GameType::tes4) { EXPECT_FLOAT_EQ(0.8f, plugin.GetHeaderVersion().value()); @@ -253,12 +280,13 @@ TEST_P(PluginTest, loadingWholePluginShouldReadHeaderData) { } TEST_P(PluginTest, loadingWholePluginShouldReadFields) { - Plugin plugin(game_.GetType(), - game_.GetCache(), - game_.DataPath() / (blankMasterDependentEsm + ".ghost"), - false); + const auto pluginName = GetParam() == GameType::openmw + ? blankMasterDependentEsm + : blankMasterDependentEsm + ".ghost"; + Plugin plugin( + game_.GetType(), game_.GetCache(), game_.DataPath() / pluginName, false); - if (GetParam() == GameType::tes3) { + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { Plugin master( game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsm, false); const auto pluginsMetadata = Plugin::GetPluginsMetadata({&master}); @@ -297,6 +325,30 @@ TEST_P(PluginTest, loadingANonMasterPluginShouldReadTheMasterFlagAsFalse) { EXPECT_FALSE(plugin.IsMaster()); } +TEST_P(PluginTest, loadingWholePluginShouldSucceedForOpenMWPlugins) { + const auto omwgame = "Blank.omwgame"; + const auto omwaddon = "Blank.omwaddon"; + const auto omwscripts = "Blank.omwscripts"; + + std::filesystem::rename(dataPath / blankEsm, dataPath / omwgame); + std::filesystem::rename(dataPath / blankEsp, dataPath / omwaddon); + std::ofstream out(dataPath / omwscripts); + out.close(); + + EXPECT_NO_THROW( + Plugin(game_.GetType(), game_.GetCache(), dataPath / omwgame, false)); + EXPECT_NO_THROW( + Plugin(game_.GetType(), game_.GetCache(), dataPath / omwaddon, false)); + if (GetParam() == GameType::openmw) { + EXPECT_NO_THROW(Plugin( + game_.GetType(), game_.GetCache(), dataPath / omwscripts, false)); + } else { + EXPECT_THROW( + Plugin(game_.GetType(), game_.GetCache(), dataPath / omwscripts, false), + std::system_error); + } +} + TEST_P( PluginTest, isLightPluginShouldBeTrueForAPluginWithEslFileExtensionForFallout4AndSkyrimSeAndFalseOtherwise) { @@ -399,7 +451,8 @@ TEST_P( game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsm, true) .LoadsArchive(); - if (GetParam() == GameType::tes3 || GetParam() == GameType::tes4) + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw || + GetParam() == GameType::tes4) EXPECT_FALSE(loadsArchive); else EXPECT_TRUE(loadsArchive); @@ -416,7 +469,8 @@ TEST_P( true) .LoadsArchive(); - if (GetParam() == GameType::tes3 || GetParam() == GameType::starfield) + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw || + GetParam() == GameType::starfield) EXPECT_FALSE(loadsArchive); else EXPECT_TRUE(loadsArchive); @@ -431,7 +485,7 @@ TEST_P( game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsp, true) .LoadsArchive(); - if (GetParam() == GameType::tes3) + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) EXPECT_FALSE(loadsArchive); else EXPECT_TRUE(loadsArchive); @@ -597,10 +651,12 @@ TEST_P(PluginTest, doRecordsOverlapShouldReturnFalseForTwoPluginsWithOnlyHeadersLoaded) { Plugin plugin1( game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsm, true); - Plugin plugin2(game_.GetType(), - game_.GetCache(), - game_.DataPath() / (blankMasterDependentEsm + ".ghost"), - true); + + const auto pluginName = GetParam() == GameType::openmw + ? blankMasterDependentEsm + : blankMasterDependentEsm + ".ghost"; + Plugin plugin2( + game_.GetType(), game_.GetCache(), game_.DataPath() / pluginName, true); EXPECT_FALSE(plugin1.DoRecordsOverlap(plugin2)); EXPECT_FALSE(plugin2.DoRecordsOverlap(plugin1)); @@ -626,13 +682,14 @@ TEST_P(PluginTest, doRecordsOverlapShouldReturnTrueIfOnePluginOverridesTheOthersRecords) { const auto plugin1Name = GetParam() == GameType::starfield ? blankFullEsm : blankEsm; + const auto plugin2Name = GetParam() == GameType::openmw + ? blankMasterDependentEsm + : blankMasterDependentEsm + ".ghost"; Plugin plugin1( game_.GetType(), game_.GetCache(), game_.DataPath() / plugin1Name, false); - Plugin plugin2(game_.GetType(), - game_.GetCache(), - game_.DataPath() / (blankMasterDependentEsm + ".ghost"), - false); + Plugin plugin2( + game_.GetType(), game_.GetCache(), game_.DataPath() / plugin2Name, false); if (GetParam() == GameType::starfield) { plugin1.ResolveRecordIds(nullptr); @@ -649,7 +706,7 @@ TEST_P(PluginTest, getRecordAndGroupCountShouldReturnTheHeaderFieldValue) { Plugin plugin( game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsm, true); - if (GetParam() == GameType::tes3) { + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { EXPECT_EQ(10u, plugin.GetRecordAndGroupCount()); } else if (GetParam() == GameType::tes4) { EXPECT_EQ(14u, plugin.GetRecordAndGroupCount()); @@ -665,7 +722,7 @@ TEST_P(PluginTest, game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsp, false) .GetAssetCount(); - if (GetParam() == GameType::tes3) { + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { EXPECT_EQ(0, assetCount); } else if (GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || GetParam() == GameType::starfield) { @@ -690,7 +747,7 @@ TEST_P(PluginTest, game_.GetType(), game_.GetCache(), game_.DataPath() / blankEsp, false); OtherPluginType plugin2; - if (GetParam() == GameType::tes3) { + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { EXPECT_FALSE(plugin1.DoAssetsOverlap(plugin2)); } else { EXPECT_THROW(plugin1.DoAssetsOverlap(plugin2), std::invalid_argument); @@ -734,7 +791,7 @@ TEST_P(PluginTest, game_.DataPath() / blankMasterDependentEsp, false); - if (GetParam() == GameType::tes3) { + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { // Morrowind plugins can't load assets. EXPECT_FALSE(plugin1.DoAssetsOverlap(plugin2)); EXPECT_FALSE(plugin2.DoAssetsOverlap(plugin1)); @@ -744,16 +801,20 @@ TEST_P(PluginTest, } } -TEST_P(PluginTest, - hasPluginFileExtensionShouldBeTrueIfFileEndsInDotEspOrDotEsm) { +class HasPluginFileExtensionTest : public ::testing::TestWithParam {}; + +INSTANTIATE_TEST_SUITE_P(, + HasPluginFileExtensionTest, + ::testing::ValuesIn(ALL_GAME_TYPES)); + +TEST_P(HasPluginFileExtensionTest, shouldBeTrueIfFileEndsInDotEspOrDotEsm) { EXPECT_TRUE(hasPluginFileExtension("file.esp", GetParam())); EXPECT_TRUE(hasPluginFileExtension("file.esm", GetParam())); EXPECT_FALSE(hasPluginFileExtension("file.bsa", GetParam())); } -TEST_P( - PluginTest, - hasPluginFileExtensionShouldBeTrueIfFileEndsInDotEslOnlyForFallout4AndLater) { +TEST_P(HasPluginFileExtensionTest, + shouldBeTrueIfFileEndsInDotEslOnlyForFallout4AndLater) { bool result = hasPluginFileExtension("file.esl", GetParam()); EXPECT_EQ(GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || @@ -763,6 +824,26 @@ TEST_P( result); } +TEST_P(HasPluginFileExtensionTest, shouldTrimGhostExtensionExceptForOpenMW) { + if (GetParam() == GameType::openmw) { + EXPECT_FALSE(hasPluginFileExtension("file.esp.ghost", GetParam())); + EXPECT_FALSE(hasPluginFileExtension("file.esm.ghost", GetParam())); + } else { + EXPECT_TRUE(hasPluginFileExtension("file.esp.ghost", GetParam())); + EXPECT_TRUE(hasPluginFileExtension("file.esm.ghost", GetParam())); + } + EXPECT_FALSE(hasPluginFileExtension("file.bsa.ghost", GetParam())); +} + +TEST_P(HasPluginFileExtensionTest, shouldRecogniseOpenMWPluginExtensions) { + EXPECT_EQ(GetParam() == GameType::openmw, + hasPluginFileExtension("file.omwgame", GetParam())); + EXPECT_EQ(GetParam() == GameType::openmw, + hasPluginFileExtension("file.omwaddon", GetParam())); + EXPECT_EQ(GetParam() == GameType::openmw, + hasPluginFileExtension("file.omwscripts", GetParam())); +} + TEST(equivalent, shouldReturnTrueIfGivenEqualPathsThatExist) { auto path1 = std::filesystem::path("./testing-plugins/LICENSE"); auto path2 = std::filesystem::path("./testing-plugins/LICENSE"); diff --git a/src/tests/api/internals/sorting/plugin_sort_test.h b/src/tests/api/internals/sorting/plugin_sort_test.h index e2d712be..44e3fcf6 100644 --- a/src/tests/api/internals/sorting/plugin_sort_test.h +++ b/src/tests/api/internals/sorting/plugin_sort_test.h @@ -119,7 +119,8 @@ INSTANTIATE_TEST_SUITE_P(, ::testing::Values(GameType::tes3, GameType::tes4, GameType::fo4, - GameType::starfield)); + GameType::starfield, + GameType::openmw)); TEST_P(PluginSortTest, sortingWithNoLoadedPluginsShouldReturnAnEmptyList) { std::vector sorted = SortPlugins(game_, game_.GetLoadOrder()); @@ -131,11 +132,31 @@ TEST_P(PluginSortTest, sortingShouldNotMakeUnnecessaryChangesToAnExistingLoadOrder) { ASSERT_NO_THROW(loadInstalledPlugins(game_, false)); - std::vector expectedSortedOrder = getLoadOrder(); + std::vector expectedSortedOrder; + if (GetParam() == GameType::openmw) { + // The existing load order for OpenMW doesn't have plugins loading after + // their masters, because the game doesn't enforce that, and the test + // setup cannot enforce the positions of inactive plugins. + expectedSortedOrder = { + blankDifferentEsm, + blankDifferentMasterDependentEsm, + blankDifferentEsp, + blankDifferentPluginDependentEsp, + blankEsm, + blankMasterDependentEsm, + blankMasterDependentEsp, + blankEsp, + blankPluginDependentEsp, + masterFile, + blankDifferentMasterDependentEsp, + }; + } else { + expectedSortedOrder = getLoadOrder(); + } // Check stability by running the sort 100 times. for (int i = 0; i < 100; i++) { - std::vector sorted = SortPlugins(game_, game_.GetLoadOrder()); + auto sorted = SortPlugins(game_, game_.GetLoadOrder()); ASSERT_EQ(expectedSortedOrder, sorted) << " for sort " << i; } } @@ -218,6 +239,26 @@ TEST_P(PluginSortTest, blankDifferentEsp, blankMasterDependentEsp, }; + } else if (GetParam() == GameType::openmw) { + // OpenMW's starting order is different, so more metadata is needed to see + // a change. + plugin = PluginMetadata(blankEsp); + plugin.SetGroup("A"); + game_.GetDatabase().SetPluginUserMetadata(plugin); + + expectedSortedOrder = { + blankDifferentEsm, + blankDifferentMasterDependentEsm, + blankDifferentEsp, + blankDifferentPluginDependentEsp, + blankEsp, + blankEsm, + blankMasterDependentEsm, + blankMasterDependentEsp, + blankPluginDependentEsp, + masterFile, + blankDifferentMasterDependentEsp, + }; } else { expectedSortedOrder = { masterFile, @@ -288,6 +329,20 @@ TEST_P(PluginSortTest, blankDifferentEsp, blankMasterDependentEsp, }; + } else if (GetParam() == GameType::openmw) { + expectedSortedOrder = { + blankDifferentEsp, + blankDifferentPluginDependentEsp, + blankEsp, + blankPluginDependentEsp, + masterFile, + blankDifferentEsm, + blankDifferentMasterDependentEsm, + blankDifferentMasterDependentEsp, + blankEsm, + blankMasterDependentEsm, + blankMasterDependentEsp, + }; } else { expectedSortedOrder = { masterFile, @@ -346,6 +401,26 @@ TEST_P(PluginSortTest, blankEsp, blankMasterDependentEsp, }; + } else if (GetParam() == GameType::openmw) { + // OpenMW's starting order is different, so more metadata is needed to see + // a change. + plugin = PluginMetadata(blankEsp); + plugin.SetLoadAfterFiles({File(blankDifferentMasterDependentEsp)}); + game_.GetDatabase().SetPluginUserMetadata(plugin); + + expectedSortedOrder = { + blankDifferentEsm, + blankDifferentMasterDependentEsm, + blankDifferentEsp, + blankDifferentPluginDependentEsp, + blankEsm, + blankMasterDependentEsm, + blankMasterDependentEsp, + blankDifferentMasterDependentEsp, + blankEsp, + blankPluginDependentEsp, + masterFile, + }; } else { expectedSortedOrder = { masterFile, @@ -394,6 +469,26 @@ TEST_P(PluginSortTest, blankEsp, blankMasterDependentEsp, }; + } else if (GetParam() == GameType::openmw) { + // OpenMW's starting order is different, so more metadata is needed to see + // a change. + plugin = PluginMetadata(blankEsp); + plugin.SetRequirements({File(blankDifferentMasterDependentEsp)}); + game_.GetDatabase().SetPluginUserMetadata(plugin); + + expectedSortedOrder = { + blankDifferentEsm, + blankDifferentMasterDependentEsm, + blankDifferentEsp, + blankDifferentPluginDependentEsp, + blankEsm, + blankMasterDependentEsm, + blankMasterDependentEsp, + blankDifferentMasterDependentEsp, + blankEsp, + blankPluginDependentEsp, + masterFile, + }; } else { expectedSortedOrder = { masterFile, @@ -520,6 +615,11 @@ TEST_P(PluginSortTest, TEST_P( PluginSortTest, sortingShouldThrowIfMasterlistRequirementEdgeWouldContradictMasterFlags) { + if (GetParam() == GameType::openmw) { + // OpenMW doesn't require master-flagged plugins to load before others. + return; + } + using std::endl; ASSERT_NO_THROW(loadInstalledPlugins(game_, false)); @@ -550,6 +650,11 @@ TEST_P( TEST_P(PluginSortTest, sortingShouldThrowIfUserRequirementEdgeWouldContradictMasterFlags) { + if (GetParam() == GameType::openmw) { + // OpenMW doesn't require master-flagged plugins to load before others. + return; + } + ASSERT_NO_THROW(loadInstalledPlugins(game_, false)); PluginMetadata plugin(blankEsm); @@ -573,6 +678,11 @@ TEST_P(PluginSortTest, TEST_P(PluginSortTest, sortingShouldThrowIfMasterlistLoadAfterEdgeWouldContradictMasterFlags) { + if (GetParam() == GameType::openmw) { + // OpenMW doesn't require master-flagged plugins to load before others. + return; + } + using std::endl; ASSERT_NO_THROW(loadInstalledPlugins(game_, false)); @@ -603,6 +713,11 @@ TEST_P(PluginSortTest, TEST_P(PluginSortTest, sortingShouldThrowIfUserLoadAfterEdgeWouldContradictMasterFlags) { + if (GetParam() == GameType::openmw) { + // OpenMW doesn't require master-flagged plugins to load before others. + return; + } + ASSERT_NO_THROW(loadInstalledPlugins(game_, false)); PluginMetadata plugin(blankEsm); diff --git a/src/tests/api/internals/sorting/plugin_sorting_data_test.h b/src/tests/api/internals/sorting/plugin_sorting_data_test.h index 5e12a946..e0198b4e 100644 --- a/src/tests/api/internals/sorting/plugin_sorting_data_test.h +++ b/src/tests/api/internals/sorting/plugin_sorting_data_test.h @@ -62,15 +62,6 @@ protected: return loadedPluginInterfaces; } - std::vector getNativeLoadOrder() { - std::vector wideLoadOrder; - for (const auto &pluginName : getLoadOrder()) { - wideLoadOrder.push_back(ToComparableFilename(pluginName)); - } - - return wideLoadOrder; - } - Game game_; const std::string blankEslEsp; }; @@ -82,7 +73,8 @@ INSTANTIATE_TEST_SUITE_P(, ::testing::Values(GameType::tes3, GameType::tes4, GameType::fo4, - GameType::starfield)); + GameType::starfield, + GameType::openmw)); TEST_P(PluginSortingDataTest, lightFlaggedEspFilesShouldNotBeTreatedAsMasters) { if (GetParam() == GameType::fo4 || GetParam() == GameType::tes5se) { @@ -97,22 +89,26 @@ TEST_P(PluginSortingDataTest, lightFlaggedEspFilesShouldNotBeTreatedAsMasters) { dynamic_cast(game_.GetPlugin(blankEsp)), PluginMetadata(), PluginMetadata(), - getNativeLoadOrder()); + {}); EXPECT_FALSE(esp.IsMaster()); auto master = PluginSortingData( dynamic_cast(game_.GetPlugin(blankEsm)), PluginMetadata(), PluginMetadata(), - getNativeLoadOrder()); - EXPECT_TRUE(master.IsMaster()); + {}); + if (GetParam() == GameType::openmw) { + EXPECT_FALSE(master.IsMaster()); + } else { + EXPECT_TRUE(master.IsMaster()); + } if (GetParam() == GameType::fo4 || GetParam() == GameType::tes5se) { auto lightMaster = PluginSortingData( dynamic_cast(game_.GetPlugin(blankEsl)), PluginMetadata(), PluginMetadata(), - getNativeLoadOrder()); + {}); EXPECT_TRUE(lightMaster.IsMaster()); auto lightPlugin = @@ -120,7 +116,7 @@ TEST_P(PluginSortingDataTest, lightFlaggedEspFilesShouldNotBeTreatedAsMasters) { game_.GetPlugin(blankEslEsp)), PluginMetadata(), PluginMetadata(), - getNativeLoadOrder()); + {}); EXPECT_FALSE(lightPlugin.IsMaster()); } } @@ -133,7 +129,7 @@ TEST_P(PluginSortingDataTest, dynamic_cast(game_.GetPlugin(blankMasterDependentEsm)), PluginMetadata(), PluginMetadata(), - getNativeLoadOrder()); + {}); if (GetParam() == GameType::starfield) { EXPECT_EQ(1, plugin.GetOverrideRecordCount()); } else { @@ -152,7 +148,7 @@ TEST_P(PluginSortingDataTest, PluginSortingData(dynamic_cast(game_.GetPlugin(blankEsm)), PluginMetadata(), PluginMetadata(), - getNativeLoadOrder()); + {}); if (GetParam() == GameType::starfield) { EXPECT_TRUE(plugin.IsBlueprintMaster()); } else { @@ -163,21 +159,21 @@ TEST_P(PluginSortingDataTest, dynamic_cast(game_.GetPlugin(blankDifferentEsm)), PluginMetadata(), PluginMetadata(), - getNativeLoadOrder()); + {}); EXPECT_FALSE(plugin.IsBlueprintMaster()); plugin = PluginSortingData(dynamic_cast(game_.GetPlugin(blankEsp)), PluginMetadata(), PluginMetadata(), - getNativeLoadOrder()); + {}); EXPECT_FALSE(plugin.IsBlueprintMaster()); plugin = PluginSortingData( dynamic_cast(game_.GetPlugin(blankDifferentEsp)), PluginMetadata(), PluginMetadata(), - getNativeLoadOrder()); + {}); EXPECT_FALSE(plugin.IsBlueprintMaster()); } } diff --git a/src/tests/common_game_test_fixture.h b/src/tests/common_game_test_fixture.h index e4a41a46..4c0920be 100644 --- a/src/tests/common_game_test_fixture.h +++ b/src/tests/common_game_test_fixture.h @@ -27,17 +27,33 @@ along with LOOT. If not, see #include +#include #include #include #include #include #include +#include #include "loot/enum/game_type.h" #include "tests/test_helpers.h" namespace loot { namespace test { +static const std::array ALL_GAME_TYPES = { + GameType::tes3, + GameType::tes4, + GameType::tes5, + GameType::tes5se, + GameType::tes5vr, + GameType::fo3, + GameType::fonv, + GameType::fo4, + GameType::fo4vr, + GameType::starfield, + GameType::openmw, +}; + class CommonGameTestFixture : public ::testing::TestWithParam { protected: CommonGameTestFixture() : @@ -46,7 +62,7 @@ protected: german("de"), missingPath(rootTestPath / "missing"), gamePath(rootTestPath / "games" / "game"), - dataPath(gamePath / getPluginsFolder()), + dataPath(gamePath / getPluginsFolder()), localPath(rootTestPath / "local" / "game"), metadataFilesPath(rootTestPath / "metadata"), masterFile(getMasterFile()), @@ -147,12 +163,16 @@ protected: // Set initial load order and active plugins. setLoadOrder(getInitialLoadOrder()); - // Ghost a plugin. - ASSERT_NO_THROW(std::filesystem::rename( - dataPath / blankMasterDependentEsm, - dataPath / (blankMasterDependentEsm + ".ghost"))); - ASSERT_FALSE(exists(dataPath / blankMasterDependentEsm)); - ASSERT_TRUE(exists(dataPath / (blankMasterDependentEsm + ".ghost"))); + // Ghost a plugin, except for OpenMW. + if (GetParam() != GameType::openmw) { + ASSERT_NO_THROW(std::filesystem::rename( + dataPath / blankMasterDependentEsm, + dataPath / (blankMasterDependentEsm + ".ghost"))); + ASSERT_FALSE(exists(dataPath / blankMasterDependentEsm)); + ASSERT_TRUE(exists(dataPath / (blankMasterDependentEsm + ".ghost"))); + } else { + touch(gamePath / "openmw.cfg"); + } // Write out an non-empty, non-plugin file. std::ofstream out(dataPath / nonPluginFile); @@ -226,6 +246,10 @@ protected: if (!line.empty()) actual.push_back(line); } + } else if (GetParam() == GameType::openmw) { + throw std::runtime_error( + "OpenMW's load order derivation is too complicated to replicate " + "accurately just for a test."); } else { actual = readFileLines(localPath / "Plugins.txt"); for (auto& line : actual) { @@ -377,7 +401,7 @@ protected: private: std::string getMasterFile() const { - if (GetParam() == GameType::tes3) + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) return "Morrowind.esm"; else if (GetParam() == GameType::tes4) return "Oblivion.esm"; @@ -397,7 +421,9 @@ private: } std::string getPluginsFolder() const { - if (GetParam() == GameType::tes3) { + if (GetParam() == GameType::openmw) { + return "resources/vfs"; + } else if (GetParam() == GameType::tes3) { return "Data Files"; } else { return "Data"; @@ -407,6 +433,7 @@ private: uint32_t getBlankEsmCrc() const { switch (GetParam()) { case GameType::tes3: + case GameType::openmw: return 0x790DC6FB; case GameType::tes4: return 0x374E2A6F; @@ -426,6 +453,14 @@ private: out << "GameFile0=" << plugin.first << std::endl; } } + } else if (GetParam() == GameType::openmw) { + std::ofstream out(localPath / "openmw.cfg"); + + for (const auto& plugin : loadOrder) { + if (plugin.second) { + out << "content=" << plugin.first << std::endl; + } + } } else { std::ofstream out(localPath / "Plugins.txt"); for (const auto& plugin : loadOrder) { diff --git a/src/tests/test_helpers.h b/src/tests/test_helpers.h index ea1d8277..a40d23c0 100644 --- a/src/tests/test_helpers.h +++ b/src/tests/test_helpers.h @@ -40,7 +40,7 @@ bool supportsLightPlugins(GameType gameType) { std::filesystem::path getSourcePluginsPath(GameType gameType) { using std::filesystem::absolute; - if (gameType == GameType::tes3) { + if (gameType == GameType::tes3 || gameType == GameType::openmw) { return absolute("./testing-plugins/Morrowind/Data Files"); } else if (gameType == GameType::tes4) { return absolute("./testing-plugins/Oblivion/Data");