diff --git a/src/api/game/game.cpp b/src/api/game/game.cpp index 8784fce7..d27bb537 100644 --- a/src/api/game/game.cpp +++ b/src/api/game/game.cpp @@ -95,23 +95,46 @@ bool IsMicrosoftStoreInstall(const GameType gameType, } } +std::filesystem::path GetUserDocumentsPath( + const std::filesystem::path& gameLocalPath) { +#ifdef _WIN32 + PWSTR path; + + if (SHGetKnownFolderPath(FOLDERID_Documents, 0, NULL, &path) != S_OK) + throw std::system_error(GetLastError(), + std::system_category(), + "Failed to get user Documents path."); + + std::filesystem::path documentsPath(path); + CoTaskMemFree(path); + + return documentsPath; +#else + // Get the documents path relative to the game's local path. + return gameLocalPath.parent_path().parent_path().parent_path() / "Documents"; +#endif +} + std::vector GetAdditionalDataPaths( const GameType gameType, - const std::filesystem::path& dataPath) { + const std::filesystem::path& dataPath, + const std::filesystem::path& gameLocalPath) { const auto gamePath = dataPath.parent_path(); if (gameType == GameType::fo4 && IsMicrosoftStoreInstall(gameType, 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}; + gamePath / MS_FO4_CONTRAPTIONS_DATA_PATH}; + } + + if (gameType == GameType::starfield) { + return {GetUserDocumentsPath(gameLocalPath) / "My Games" / "Starfield" / + "Data"}; } return {}; @@ -167,7 +190,8 @@ Game::Game(const GameType gameType, conditionEvaluator_( std::make_shared(GetType(), DataPath())), database_(ApiDatabase(conditionEvaluator_)), - additionalDataPaths_(::GetAdditionalDataPaths(GetType(), DataPath())) { + additionalDataPaths_( + ::GetAdditionalDataPaths(GetType(), DataPath(), localDataPath)) { conditionEvaluator_->SetAdditionalDataPaths(additionalDataPaths_); } diff --git a/src/tests/api/interface/game_interface_test.h b/src/tests/api/interface/game_interface_test.h index 93261ed1..459114bf 100644 --- a/src/tests/api/interface/game_interface_test.h +++ b/src/tests/api/interface/game_interface_test.h @@ -98,8 +98,7 @@ TEST_P(GameInterfaceTest, isValidPluginShouldReturnFalseForANonPluginFile) { TEST_P(GameInterfaceTest, isValidPluginShouldReturnFalseForAnEmptyFile) { // Write out an empty file. - std::ofstream out(dataPath / emptyFile); - out.close(); + touch(dataPath / emptyFile); ASSERT_TRUE(std::filesystem::exists(dataPath / emptyFile)); EXPECT_FALSE(handle_->IsValidPlugin(emptyFile)); diff --git a/src/tests/api/internals/game/game_test.h b/src/tests/api/internals/game/game_test.h index 462df581..ee1fa334 100644 --- a/src/tests/api/internals/game/game_test.h +++ b/src/tests/api/internals/game/game_test.h @@ -33,8 +33,7 @@ namespace test { class GameTest : public CommonGameTestFixture { protected: GameTest() : blankArchive("Blank" + GetArchiveFileExtension(GetParam())) { - std::ofstream out(dataPath / blankArchive); - out.close(); + touch(dataPath / blankArchive); } void loadInstalledPlugins(Game& game, bool headersOnly) { @@ -101,55 +100,90 @@ TEST_P(GameTest, constructingShouldNotThrowIfGameAndLocalPathsAreNotEmpty) { TEST_P( GameTest, - constructingForAMicrosoftStoreFallout4InstallShouldSetExternalPathsForTheDlcs) { - if (GetParam() != GameType::fo4) { - return; + constructingForFallout4FromMicrosoftStoreOrStarfieldShouldSetAdditionalDataPaths) { + if (GetParam() == GameType::fo4) { + // Create the file that indicates it's a Microsoft Store install. + touch(dataPath.parent_path() / "appxmanifest.xml"); } - 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)); + if (GetParam() == GameType::fo4) { + const auto basePath = dataPath.parent_path() / ".." / ".."; + 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"}), + game.GetAdditionalDataPaths()); + } else if (GetParam() == GameType::starfield) { + ASSERT_EQ(1, game.GetAdditionalDataPaths().size()); - const auto archivePaths = game.GetCache().GetArchivePaths(); + const auto expectedSuffix = std::filesystem::u8path("Documents") / + "My Games" / "Starfield" / "Data"; + EXPECT_TRUE(boost::ends_with(game.GetAdditionalDataPaths()[0].u8string(), + expectedSuffix.u8string())); + } else { + EXPECT_TRUE(game.GetAdditionalDataPaths().empty()); + } +} - EXPECT_EQ(std::set( - {ba2Path1, ba2Path2, dataPath / blankArchive}), - archivePaths); +TEST_P(GameTest, setAdditionalDataPathsShouldClearTheConditionCache) { + Game game = Game(GetParam(), dataPath.parent_path(), localPath); PluginMetadata metadata(blankEsm); - metadata.SetLoadAfterFiles( - {File("DLCRobot.esm", "", "file(\"DLCRobot.esm\")")}); + metadata.SetLoadAfterFiles({File("plugin.esp", "", "file(\"plugin.esp\")")}); game.GetDatabase().SetPluginUserMetadata(metadata); - const auto evaluatedMetadata = + auto evaluatedMetadata = + game.GetDatabase().GetPluginUserMetadata(blankEsm, true).value(); + EXPECT_TRUE(evaluatedMetadata.GetLoadAfterFiles().empty()); + + const auto dataFilePath = + dataPath.parent_path().parent_path() / "Data" / "plugin.esp"; + touch(dataFilePath); + game.SetAdditionalDataPaths({dataFilePath.parent_path()}); + + evaluatedMetadata = game.GetDatabase().GetPluginUserMetadata(blankEsm, true).value(); EXPECT_FALSE(evaluatedMetadata.GetLoadAfterFiles().empty()); } +TEST_P(GameTest, + setAdditionalDataPathsShouldUpdateWhereLoadOrderPluginsAreFound) { + Game game = Game(GetParam(), dataPath.parent_path(), localPath); + + // Set no additional data paths to avoid picking up non-test plugins on PCs + // which have Starfield or Fallout 4 installed. + game.SetAdditionalDataPaths({}); + game.LoadCurrentLoadOrderState(); + auto loadOrder = game.GetLoadOrder(); + + const auto dataFilePath = + dataPath.parent_path().parent_path() / "Data" / "plugin.esp"; + std::filesystem::create_directories(dataFilePath.parent_path()); + std::filesystem::copy_file(getSourcePluginsPath() / blankEsp, dataFilePath); + ASSERT_TRUE(std::filesystem::exists(dataFilePath)); + + std::filesystem::last_write_time( + dataFilePath, + std::filesystem::file_time_type::clock().now() + std::chrono::hours(1)); + + game.SetAdditionalDataPaths({dataFilePath.parent_path()}); + game.LoadCurrentLoadOrderState(); + + loadOrder.push_back("plugin.esp"); + + EXPECT_EQ(loadOrder, game.GetLoadOrder()); +} + TEST_P(GameTest, isValidPluginShouldResolveRelativePathsRelativeToDataPath) { const Game game(GetParam(), dataPath.parent_path(), localPath); @@ -240,13 +274,7 @@ 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(); - }; - +TEST_P(GameTest, loadPluginsShouldFindArchivesInAdditionalDataPaths) { // Create a couple of external archive files. const std::string archiveFileExtension = GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || @@ -290,11 +318,9 @@ TEST_P(GameTest, loadPluginsShouldClearTheArchivesCacheBeforeFindingArchives) { TEST_P( GameTest, loadPluginsShouldNotThrowIfAFilenameHasNonWindows1252EncodableCharacters) { - auto path = - dataPath / std::filesystem::u8path( - u8"\u2551\u00BB\u00C1\u2510\u2557\u00FE\u00C3\u00CE.txt"); - std::ofstream out(path); - out.close(); + touch(dataPath / + std::filesystem::u8path( + u8"\u2551\u00BB\u00C1\u2510\u2557\u00FE\u00C3\u00CE.txt")); Game game = Game(GetParam(), dataPath.parent_path(), localPath); diff --git a/src/tests/api/internals/metadata/condition_evaluator_test.h b/src/tests/api/internals/metadata/condition_evaluator_test.h index 3c746764..a474a6d8 100644 --- a/src/tests/api/internals/metadata/condition_evaluator_test.h +++ b/src/tests/api/internals/metadata/condition_evaluator_test.h @@ -45,11 +45,7 @@ protected: std::filesystem::copy_file(dataPath / blankEsm, dataPath / std::filesystem::u8path(nonAsciiEsm)); - auto nonAsciiPath = dataPath / std::filesystem::u8path(nonAsciiNestedFile); - std::filesystem::create_directory(nonAsciiPath.parent_path()); - - std::ofstream out(nonAsciiPath); - out.close(); + touch(dataPath / std::filesystem::u8path(nonAsciiNestedFile)); loadInstalledPlugins(); evaluator_.RefreshLoadedPluginsState(game_.GetLoadedPlugins()); diff --git a/src/tests/api/internals/plugin_test.h b/src/tests/api/internals/plugin_test.h index 4c25b583..a7fe3a59 100644 --- a/src/tests/api/internals/plugin_test.h +++ b/src/tests/api/internals/plugin_test.h @@ -49,8 +49,7 @@ protected: game_.LoadCurrentLoadOrderState(); // Write out an empty file. - std::ofstream out(dataPath / emptyFile); - out.close(); + touch(dataPath / emptyFile); ASSERT_TRUE(std::filesystem::exists(dataPath / emptyFile)); #ifndef _WIN32 @@ -93,12 +92,10 @@ protected: ASSERT_TRUE( std::filesystem::exists(dataPath / blankMasterDependentArchive)); } else if (GetParam() == GameType::tes3) { - out.open(dataPath / blankArchive); - out.close(); + touch(dataPath / blankArchive); blankMasterDependentArchive = "Blank - Master Dependent.bsa"; - out.open(dataPath / blankMasterDependentArchive); - out.close(); + touch(dataPath / blankMasterDependentArchive); } else { copyPlugin(getSourcePluginsPath(), blankArchive); @@ -111,22 +108,19 @@ protected: } // Create dummy archive files. - out.open(dataPath / blankSuffixArchive); - out.close(); + touch(dataPath / blankSuffixArchive); auto nonAsciiArchivePath = dataPath / std::filesystem::u8path(u8"non\u00E1scii" + GetArchiveFileExtension(game_.GetType())); - out.open(nonAsciiArchivePath); - out.close(); + touch(dataPath / nonAsciiArchivePath); auto nonAsciiPrefixArchivePath = dataPath / std::filesystem::u8path(u8"other non\u00E1scii2 - suffix" + GetArchiveFileExtension(game_.GetType())); - out.open(nonAsciiPrefixArchivePath); - out.close(); + touch(dataPath / nonAsciiPrefixArchivePath); game_.GetCache().CacheArchivePaths({dataPath / "Blank - Main.ba2", dataPath / "Blank - Textures.ba2", diff --git a/src/tests/common_game_test_fixture.h b/src/tests/common_game_test_fixture.h index 22e56c0b..38fa86ec 100644 --- a/src/tests/common_game_test_fixture.h +++ b/src/tests/common_game_test_fixture.h @@ -244,6 +244,12 @@ protected: return absolute("./Skyrim/Data"); } + void touch(const std::filesystem::path& path) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream out(path); + out.close(); + } + private: const std::filesystem::path rootTestPath;