Add support for Starfield

Starfield adds a new type of plugin that I'm calling an override plugin,
which does not use up a load order slot when active, and a plugin cannot
be light and override at the same time. It also increases the BA2
version number, though the differences are not relevant to libloot's
usage, and loads BA2s from plugins differently.

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