Skip to content

Commit

Permalink
Add support for MS Store's Fallout 4 DLC
Browse files Browse the repository at this point in the history
The Microsoft Store installs Fallout 4 DLC to separate directories
outside of the game's install path. The relative paths are fixed: users
cannot customise the install paths (aside from the folder within which
all games are installed on a drive), and renaming the DLC's path or its
Content directory causes an error when trying to launch the game.

The game scans these additional data paths similarly to how it scans
the main data path: it doesn't only load the DLC plugins from their
data paths, it will find non-DLC plugins in them, and DLC plugins can be
moved between them and still be detected. The same is true for BA2
files and other resources.

To support these separate directories:

- GameInterface::IsValidPlugin, GameInterface::LoadPlugins
  and GameInterface::SortPlugins() now take plugin paths instead of
  plugin filenames. Relative paths are interpreted as relative to the
  data path, so the change is backwards-compatible, and absolute paths
  are used as given.
- LoadPlugins now checks that all filenames in the given paths are
  unique, which was previously required but not enforced.
- When scanning for archives, LOOT now scans the DLC data paths before
  scanning the game data path.
- libloadorder and loot-condition-interpreter have been updated to
  support the MS Store Fallout 4 DLC paths. libloadorder has built-in
  support for the Fallout 4 DLCs specifically, so does not need to be
  initialised, while loot-condition-interpreter's support is not
  specific and so it's now passed a list of data paths that includes the
  Fallout 4 DLC paths when appropriate.

When listing the DLC paths together with the game's main data path, the
order they're listed in matches the order in which the game scans them
for plugins, with the game stopping at the first directory in which it
finds a file it's looking for.

This introduces detection of whether or not a game install comes from
the Microsoft Store: like LOOT and libloadorder, libloot detects this by
looking for an appxmanifest.xml file in the install path.

The new Game::SetAdditionalDataPaths() method may be added to
GameInterface in the future: that hasn't been done yet because adding it
will break the ABI.
  • Loading branch information
Ortham committed May 6, 2023
1 parent d9a5870 commit d7f5a51
Show file tree
Hide file tree
Showing 12 changed files with 397 additions and 50 deletions.
8 changes: 4 additions & 4 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ endif()

ExternalProject_Add(libloadorder
PREFIX "external"
URL "https://github.com/Ortham/libloadorder/archive/14.0.0.tar.gz"
URL_HASH "SHA256=ee235eeb6bbef6f73050a35190c6c10f7a1fbdb3fd32a7ed7ff7b7727844b30f"
URL "https://github.com/Ortham/libloadorder/archive/14.1.0.tar.gz"
URL_HASH "SHA256=9ff9c73612bc9e375759e122bfd56a4a45d8a4ca36837f610c05ab52fef9d67b"
CONFIGURE_COMMAND ""
BUILD_IN_SOURCE 1
BUILD_COMMAND cargo build --release --manifest-path ffi/Cargo.toml --target ${RUST_TARGET} &&
Expand All @@ -105,8 +105,8 @@ endif()

ExternalProject_Add(loot-condition-interpreter
PREFIX "external"
URL "https://github.com/loot/loot-condition-interpreter/archive/2.3.1.tar.gz"
URL_HASH "SHA256=e0f5533bf113c2ed48e2249b241e01c1521d9bbf615d01581870db9948b21278"
URL "https://github.com/loot/loot-condition-interpreter/archive/2.4.0.tar.gz"
URL_HASH "SHA256=7c1d42636d8b10ae2b4dc3fced4a0b25112caf5e489a8f8ef0c915b9dd1189e1"
CONFIGURE_COMMAND ""
BUILD_IN_SOURCE 1
BUILD_COMMAND cargo build --release --manifest-path ffi/Cargo.toml --target ${RUST_TARGET} &&
Expand Down
24 changes: 15 additions & 9 deletions include/loot/game_interface.h
Original file line number Diff line number Diff line change
Expand Up @@ -57,25 +57,29 @@ class GameInterface {
* @details The validity check is not exhaustive: it checks that the file
* extension is ``.esm`` or ``.esp`` (after trimming any ``.ghost``
* extension), and that the ``TES4`` header can be parsed.
* @param plugin
* The filename of the file to check.
* @param pluginPath
* The path to the file to check. Relative paths are resolved relative
* to the game's plugins directory, while absolute paths are used
* as given.
* @returns True if the file is a valid plugin, false otherwise.
*/
virtual bool IsValidPlugin(const std::string& plugin) const = 0;
virtual bool IsValidPlugin(const std::string& pluginPath) const = 0;

/**
* @brief Parses plugins and loads their data.
* @details Any previously-loaded plugin data is discarded when this function
* is called.
* @param plugins
* The filenames of the plugins to load.
* @param pluginPaths
* The plugin paths to load. Relative paths are resolved relative to
* the game's plugins directory, while absolute paths are used as
* given. Each plugin filename must be unique within the vector.
* @param loadHeadersOnly
* If true, only the plugins' ``TES4`` headers are loaded. If false,
* all records in the plugins are parsed, apart from the main master
* file if it has been identified by a previous call to
* ``IdentifyMainMasterFile()``.
*/
virtual void LoadPlugins(const std::vector<std::string>& plugins,
virtual void LoadPlugins(const std::vector<std::string>& pluginPaths,
bool loadHeadersOnly) = 0;

/**
Expand Down Expand Up @@ -118,13 +122,15 @@ class GameInterface {
* applied to the load order used by the game. This function does
* not load or evaluate the masterlist or userlist.
* @param plugins
* A vector of filenames of the plugins to sort, in their current
* load order.
* The plugin paths to sort, in their current load order. Relative
* paths are resolved relative to the game's plugins directory, while
* absolute paths are used as given. Each plugin filename must be
* unique within the vector.
* @returns A vector of the given plugin filenames in their sorted load
* order.
*/
virtual std::vector<std::string> SortPlugins(
const std::vector<std::string>& plugins) = 0;
const std::vector<std::string>& pluginPaths) = 0;

/**
* @}
Expand Down
170 changes: 138 additions & 32 deletions src/api/game/game.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,85 @@

using std::filesystem::u8path;

namespace {
using loot::GameType;

// The Microsoft Store installs Fallout 4 DLCs to directories outside of the
// game's install path. These directories have fixed paths relative to the
// game install path (renaming them causes the game launch to fail, or not
// find the DLC files).
constexpr const char* MS_FO4_AUTOMATRON_DATA_PATH =
"../../Fallout 4- Automatron (PC)/Content/Data";
constexpr const char* MS_FO4_CONTRAPTIONS_DATA_PATH =
"../../Fallout 4- Contraptions Workshop (PC)/Content/Data";
constexpr const char* MS_FO4_FAR_HARBOR_DATA_PATH =
"../../Fallout 4- Far Harbor (PC)/Content/Data";
constexpr const char* MS_FO4_TEXTURE_PACK_DATA_PATH =
"../../Fallout 4- High Resolution Texture Pack/Content/Data";
constexpr const char* MS_FO4_NUKA_WORLD_DATA_PATH =
"../../Fallout 4- Nuka-World (PC)/Content/Data";
constexpr const char* MS_FO4_VAULT_TEC_DATA_PATH =
"../../Fallout 4- Vault-Tec Workshop (PC)/Content/Data";
constexpr const char* MS_FO4_WASTELAND_DATA_PATH =
"../../Fallout 4- Wasteland Workshop (PC)/Content/Data";

bool IsMicrosoftStoreGame(const std::filesystem::path& gamePath) {
return std::filesystem::exists(gamePath / "appxmanifest.xml");
}

std::vector<std::filesystem::path> GetAdditionalDataPaths(
const GameType gameType,
const std::filesystem::path& dataPath) {
const auto gamePath = dataPath.parent_path();

if (gameType == GameType::fo4 && IsMicrosoftStoreGame(gamePath)) {
// All DLC directories are listed before the main data path because DLC
// plugins in those directories override any in the main data path.
return {gamePath / MS_FO4_AUTOMATRON_DATA_PATH,
gamePath / MS_FO4_NUKA_WORLD_DATA_PATH,
gamePath / MS_FO4_WASTELAND_DATA_PATH,
gamePath / MS_FO4_TEXTURE_PACK_DATA_PATH,
gamePath / MS_FO4_VAULT_TEC_DATA_PATH,
gamePath / MS_FO4_FAR_HARBOR_DATA_PATH,
gamePath / MS_FO4_CONTRAPTIONS_DATA_PATH,
dataPath};
}

return {};
}

std::filesystem::path ResolvePluginPath(
const std::filesystem::path& dataPath,
const std::filesystem::path& pluginPath) {
return pluginPath.is_absolute() ? pluginPath : dataPath / pluginPath;
}

std::vector<std::filesystem::path> FindArchives(
const std::filesystem::path& parentPath,
const std::string& archiveFileExtension) {
if (!std::filesystem::is_directory(parentPath)) {
return {};
}

std::vector<std::filesystem::path> archivePaths;

for (std::filesystem::directory_iterator it(parentPath);
it != std::filesystem::directory_iterator();
++it) {
// This is only correct for ASCII strings, but that's all that
// GetArchiveFileExtension() can return. It's a lot faster than the more
// generally-correct approach of testing file path equivalence when
// there are a lot of entries in DataPath().
if (it->is_regular_file() &&
boost::iends_with(it->path().u8string(), archiveFileExtension)) {
archivePaths.push_back(it->path());
}
}

return archivePaths;
}
}

namespace loot {
Game::Game(const GameType gameType,
const std::filesystem::path& gamePath,
Expand All @@ -59,7 +138,10 @@ Game::Game(const GameType gameType,
loadOrderHandler_(type_, gamePath_, localDataPath),
conditionEvaluator_(
std::make_shared<ConditionEvaluator>(Type(), DataPath())),
database_(ApiDatabase(conditionEvaluator_)) {}
database_(ApiDatabase(conditionEvaluator_)),
additionalDataPaths_(GetAdditionalDataPaths(Type(), DataPath())) {
conditionEvaluator_->SetAdditionalDataPaths(additionalDataPaths_);
}

GameType Game::Type() const { return type_; }

Expand All @@ -85,29 +167,51 @@ const DatabaseInterface& Game::GetDatabase() const { return database_; }

DatabaseInterface& Game::GetDatabase() { return database_; }

bool Game::IsValidPlugin(const std::string& plugin) const {
return Plugin::IsValid(Type(), DataPath() / u8path(plugin));
void Game::SetAdditionalDataPaths(
const std::vector<std::filesystem::path>& additionalDataPaths) {
additionalDataPaths_ = additionalDataPaths;

conditionEvaluator_->SetAdditionalDataPaths(additionalDataPaths_);
conditionEvaluator_->ClearConditionCache();
loadOrderHandler_.SetAdditionalDataPaths(additionalDataPaths_);
}

void Game::LoadPlugins(const std::vector<std::string>& plugins,
bool Game::IsValidPlugin(const std::string& pluginPath) const {
return Plugin::IsValid(Type(),
ResolvePluginPath(DataPath(), u8path(pluginPath)));
}

void Game::LoadPlugins(const std::vector<std::string>& pluginPaths,
bool loadHeadersOnly) {
const auto logger = getLogger();

// First validate the plugins (the validity check is done in parallel because
// Check that all plugin filenames are unique.
std::unordered_set<std::string> filenames;
for (const auto& pluginPath : pluginPaths) {
const auto filename =
NormalizeFilename(u8path(pluginPath).filename().u8string());
const auto inserted = filenames.insert(filename).second;
if (!inserted) {
throw std::invalid_argument("The filename \"" + filename +
"\" is not unique.");
}
}

// Validate the plugins (the validity check is done in parallel because
// it's relatively slow).
const auto invalidPluginIt =
std::find_if(std::execution::par_unseq,
plugins.cbegin(),
plugins.cend(),
[this](const std::string& pluginName) {
pluginPaths.cbegin(),
pluginPaths.cend(),
[this](const std::string& pluginPath) {
try {
return !IsValidPlugin(pluginName);
return !IsValidPlugin(pluginPath);
} catch (...) {
return true;
}
});

if (invalidPluginIt != plugins.end()) {
if (invalidPluginIt != pluginPaths.end()) {
throw std::invalid_argument("\"" + *invalidPluginIt +
"\" is not a valid plugin");
}
Expand All @@ -126,16 +230,17 @@ void Game::LoadPlugins(const std::vector<std::string>& plugins,
const auto masterPath = DataPath() / u8path(masterFilename_);
std::for_each(
std::execution::par_unseq,
plugins.begin(),
plugins.end(),
[&](const std::string& pluginName) {
pluginPaths.begin(),
pluginPaths.end(),
[&](const std::string& pluginPathString) {
try {
const auto endIt =
boost::iends_with(pluginName, GHOST_FILE_EXTENSION)
? pluginName.end() - GHOST_FILE_EXTENSION_LENGTH
: pluginName.end();
boost::iends_with(pluginPathString, GHOST_FILE_EXTENSION)
? pluginPathString.end() - GHOST_FILE_EXTENSION_LENGTH
: pluginPathString.end();

auto pluginPath = DataPath() / u8path(pluginName.begin(), endIt);
const auto pluginPath = ResolvePluginPath(
DataPath(), u8path(pluginPathString.begin(), endIt));
const bool loadHeader =
loadHeadersOnly || loot::equivalent(pluginPath, masterPath);

Expand All @@ -144,7 +249,7 @@ void Game::LoadPlugins(const std::vector<std::string>& plugins,
if (logger) {
logger->error(
"Caught exception while trying to add {} to the cache: {}",
pluginName,
pluginPathString,
e.what());
}
}
Expand All @@ -171,11 +276,17 @@ void Game::IdentifyMainMasterFile(const std::string& masterFile) {
}

std::vector<std::string> Game::SortPlugins(
const std::vector<std::string>& plugins) {
LoadPlugins(plugins, false);
const std::vector<std::string>& pluginPaths) {
LoadPlugins(pluginPaths, false);

std::vector<std::string> loadOrder;
for (const auto& pluginPath : pluginPaths) {
const auto filename = u8path(pluginPath).filename().u8string();
loadOrder.push_back(filename);
}

// Sort plugins into their load order.
return loot::SortPlugins(*this, plugins);
return loot::SortPlugins(*this, loadOrder);
}

void Game::LoadCurrentLoadOrderState() {
Expand Down Expand Up @@ -208,19 +319,14 @@ void Game::CacheArchives() {
const auto archiveFileExtension = GetArchiveFileExtension(Type());

std::set<std::filesystem::path> archivePaths;
for (std::filesystem::directory_iterator it(DataPath());
it != std::filesystem::directory_iterator();
++it) {
// This is only correct for ASCII strings, but that's all that
// GetArchiveFileExtension() can return. It's a lot faster than the more
// generally-correct approach of testing file path equivalence when
// there are a lot of entries in DataPath().
if (it->is_regular_file() &&
boost::iends_with(it->path().u8string(), archiveFileExtension)) {
archivePaths.insert(it->path());
}
for (const auto& parentPath : additionalDataPaths_) {
const auto archives = FindArchives(parentPath, archiveFileExtension);
archivePaths.insert(archives.begin(), archives.end());
}

const auto archives = FindArchives(DataPath(), archiveFileExtension);
archivePaths.insert(archives.begin(), archives.end());

cache_.CacheArchivePaths(std::move(archivePaths));
}
}
11 changes: 8 additions & 3 deletions src/api/game/game.h
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,17 @@ class Game final : public GameInterface {

const DatabaseInterface& GetDatabase() const;

void SetAdditionalDataPaths(
const std::vector<std::filesystem::path>& additionalDataPaths);

// Game Interface Methods //
////////////////////////////

DatabaseInterface& GetDatabase() override;

bool IsValidPlugin(const std::string& plugin) const override;
bool IsValidPlugin(const std::string& pluginPath) const override;

void LoadPlugins(const std::vector<std::string>& plugins,
void LoadPlugins(const std::vector<std::string>& pluginPaths,
bool loadHeadersOnly) override;

const PluginInterface* GetPlugin(
Expand All @@ -73,7 +76,7 @@ class Game final : public GameInterface {
void IdentifyMainMasterFile(const std::string& masterFile) override;

std::vector<std::string> SortPlugins(
const std::vector<std::string>& plugins) override;
const std::vector<std::string>& pluginPaths) override;

void LoadCurrentLoadOrderState() override;

Expand All @@ -99,6 +102,8 @@ class Game final : public GameInterface {
ApiDatabase database_;

std::string masterFilename_;

std::vector<std::filesystem::path> additionalDataPaths_;
};
}
#endif
27 changes: 27 additions & 0 deletions src/api/game/load_order_handler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,33 @@ void LoadOrderHandler::SetLoadOrder(
}
}

void LoadOrderHandler::SetAdditionalDataPaths(
const std::vector<std::filesystem::path>& dataPaths) const {
auto logger = getLogger();
if (logger) {
logger->debug("Setting additional data paths:");
for (const auto& dataPath : dataPaths) {
logger->debug("\t{}", dataPath.u8string());
}
}

std::vector<std::string> dataPathStrings;
std::vector<const char*> dataPathCStrings;
for (const auto& dataPath : dataPaths) {
dataPathStrings.push_back(dataPath.u8string());
dataPathCStrings.push_back(dataPathStrings.back().c_str());
}

const unsigned int ret = lo_set_additional_plugins_directories(
gh_.get(), dataPathCStrings.data(), dataPathCStrings.size());

HandleError("set additional data paths", ret);

if (logger) {
logger->debug("Additional data paths set successfully.");
}
}

void LoadOrderHandler::HandleError(const std::string& operation,
unsigned int returnCode) const {
if (returnCode == LIBLO_OK || returnCode == LIBLO_WARN_LO_MISMATCH) {
Expand Down
3 changes: 3 additions & 0 deletions src/api/game/load_order_handler.h
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ class LoadOrderHandler {

void SetLoadOrder(const std::vector<std::string>& loadOrder) const;

void SetAdditionalDataPaths(
const std::vector<std::filesystem::path>& dataPaths) const;

private:
void HandleError(const std::string& operation, unsigned int returnCode) const;

Expand Down
Loading

0 comments on commit d7f5a51

Please sign in to comment.