add file pattern based root level detection

This commit is contained in:
Limo
2025-04-28 21:16:35 +02:00
parent 90d9d9eddc
commit 5c263a818f
35 changed files with 675 additions and 145 deletions

View File

@@ -174,6 +174,8 @@ set(CORE_SOURCES
src/core/tool.h
src/core/versionchangelog.cpp
src/core/versionchangelog.h
src/core/wildcardmatching.cpp
src/core/wildcardmatching.h
)
add_library(core OBJECT ${CORE_SOURCES})
@@ -318,6 +320,8 @@ set(PROJECT_SOURCES
src/ui/overwritebackupdialog.ui
src/ui/passwordfield.cpp
src/ui/passwordfield.h
src/ui/rootlevelcondition.cpp
src/ui/rootlevelcondition.h
src/ui/settingsdialog.cpp
src/ui/settingsdialog.h
src/ui/settingsdialog.ui

View File

@@ -78,4 +78,6 @@ struct AppInfo
* \brief For every deployer: Whether or not it is case invariant.
*/
std::vector<bool> deployer_is_case_invariant{};
/*! \brief Steam app id. Or -1 if not a Steam app. */
long steam_app_id;
};

View File

@@ -163,7 +163,7 @@ bool Bg3Deployer::initPluginFile()
{
pugi::xml_node attr = node.find_child_by_attribute("id", "UUID");
const std::string uuid = attr.attribute("value").value();
if(uuid != Bg3Plugin::BG3_VANILLA_MOD_UUID)
if(!Bg3Plugin::BG3_VANILLA_UUIDS.contains(uuid))
plugins_.emplace_back(uuid, true);
}
@@ -413,7 +413,7 @@ void Bg3Deployer::writePluginsPrivate() const
{
pugi::xml_node attr = mod.find_child_by_attribute("id", "UUID");
const std::string uuid = attr.attribute("value").value();
if(uuid != Bg3Plugin::BG3_VANILLA_MOD_UUID)
if(!Bg3Plugin::BG3_VANILLA_UUIDS.contains(uuid))
nodes_to_remove.push_back(mod);
}
for(const auto& node : nodes_to_remove)

View File

@@ -38,7 +38,7 @@ Bg3Plugin::Bg3Plugin(const std::string& xml_string) : xml_string_(xml_string)
dependency.find_child_by_attribute("id", "UUID").attribute("value").value();
const std::string name =
dependency.find_child_by_attribute("id", "Name").attribute("value").value();
if(!uuid.empty() && uuid != BG3_VANILLA_MOD_UUID)
if(!uuid.empty() && !BG3_VANILLA_UUIDS.contains(uuid))
dependencies_.emplace_back(uuid, name);
}
}
@@ -166,5 +166,5 @@ bool Bg3Plugin::isValidPlugin(const std::string& xml_string)
if(uuid_node.empty())
return false;
const std::string uuid = uuid_node.attribute("value").value();
return !uuid.empty() && uuid != BG3_VANILLA_MOD_UUID;
return !uuid.empty() && !BG3_VANILLA_UUIDS.contains(uuid);
}

View File

@@ -99,8 +99,11 @@ public:
*/
static bool isValidPlugin(const std::string& xml_string);
/*! \brief UUID of the GustavDev plugin. */
static constexpr char BG3_VANILLA_MOD_UUID[] = "28ac9ce2-2aba-8cda-b3b5-6e922f71b6b8";
/*! \brief UUIDS of the Gustav plugins for different game versions. */
static inline const std::set<std::string> BG3_VANILLA_UUIDS{
"28ac9ce2-2aba-8cda-b3b5-6e922f71b6b8",
"cb555efe-2d9e-131f-8195-a89329d218ea"
};
private:
/*! \brief Xml representation of this plugin. */

View File

@@ -36,4 +36,6 @@ struct EditApplicationInfo
std::string icon_path;
/*! \brief Version of the app. This is used for FOMOD conditions. */
std::string app_version;
/*! \brief Steam app id of the added application. */
long steam_app_id;
};

View File

@@ -209,13 +209,14 @@ void Installer::uninstall(const sfs::path& mod_path, const std::string& type)
sfs::remove_all(mod_path);
}
std::vector<sfs::path> Installer::getArchiveFileNames(const sfs::path& path)
std::vector<std::pair<sfs::path, bool>> Installer::getArchiveFileNames(const sfs::path& path)
{
std::vector<sfs::path> file_names;
std::vector<std::pair<sfs::path, bool>> file_names;
if(sfs::is_directory(path))
{
for(const auto& dir_entry : sfs::recursive_directory_iterator(path))
file_names.push_back(pu::getRelativePath(dir_entry.path(), path));
file_names.emplace_back(pu::getRelativePath(dir_entry.path(), path), sfs::is_directory(path));
return file_names;
}
struct archive* source;
@@ -226,7 +227,7 @@ std::vector<sfs::path> Installer::getArchiveFileNames(const sfs::path& path)
if(archive_read_open_filename(source, path.string().c_str(), 10240) != ARCHIVE_OK)
throw CompressionError("Could not open archive file.");
while(archive_read_next_header(source, &entry) == ARCHIVE_OK)
file_names.push_back(archive_entry_pathname(entry));
file_names.emplace_back(archive_entry_pathname(entry), archive_entry_filetype(entry) == AE_IFDIR);
if(archive_read_free(source) != ARCHIVE_OK)
throw CompressionError("Parsing of archive failed.");
return file_names;
@@ -246,11 +247,11 @@ std::tuple<int, std::string, std::string> Installer::detectInstallerSignature(
};
const auto files = getArchiveFileNames(source);
int max_length = 0;
for(const auto& file : files)
for(const auto& [file, _] : files)
max_length = std::max(max_length, pu::getPathLength(file));
for(int root_level = 0; root_level < max_length; root_level++)
{
for(const auto& file : files)
for(const auto& [file, _] : files)
{
const auto [head, tail] = pu::removePathComponents(file, root_level);
if(str_equals(path, tail))

View File

@@ -101,9 +101,11 @@ public:
/*!
* \brief Recursively reads all file and directory names from given archive.
* \param path Path to given archive.
* \return Vector of paths within the archive.
* \return Vector of paths within the archive and bools indicating whether that path points to
* a directory.
*/
static std::vector<std::filesystem::path> getArchiveFileNames(const std::filesystem::path& path);
static std::vector<std::pair<std::filesystem::path, bool>> getArchiveFileNames(
const std::filesystem::path& path);
/*!
* \brief Identifies the appropriate installer type from given source archive or
* directory.

View File

@@ -252,6 +252,7 @@ void LootDeployer::sortModsByConflicts(std::optional<ProgressNode*> progress_nod
log_(Log::LOG_WARNING, "LOOT: Requirement '" + file + "' not met for '" + plugin + "'");
}
}
log_(Log::LOG_DEBUG, std::format("LOOT: App type {}", static_cast<int>(app_type_)));
log_(Log::LOG_INFO,
std::format("LOOT: Total Plugins: {}, Master: {}, Standard: {}, Light: {}",
new_plugins.size(),

View File

@@ -36,13 +36,13 @@ Mod::Mod(const Mod& other)
Mod::Mod(const Json::Value& json)
{
long remote_mod_id = -1;
remote_mod_id = -1;
if(json.isMember("remote_mod_id"))
remote_mod_id = json["remote_mod_id"].asInt64();
long remote_file_id = -1;
remote_file_id = -1;
if(json.isMember("remote_file_id"))
remote_file_id = json["remote_file_id"].asInt64();
ImportModInfo::RemoteType remote_type = ImportModInfo::RemoteType::local;
remote_type = ImportModInfo::RemoteType::local;
if(json.isMember("remote_type"))
remote_type = static_cast<ImportModInfo::RemoteType>(json["remote_type"].asInt());
id = json["id"].asInt();
@@ -54,9 +54,6 @@ Mod::Mod(const Json::Value& json)
remote_update_time = json["remote_update_time"].asInt64();
size_on_disk = json["size_on_disk"].asInt64();
suppress_update_time = json["suppress_update_time"].asInt64();
remote_mod_id = remote_mod_id;
remote_file_id = remote_file_id;
remote_type = remote_type;
}
Json::Value Mod::toJson() const

View File

@@ -39,7 +39,7 @@ struct Mod
/*! \brief If this was retreived from a remote source: The file id on the remote. */
long remote_file_id = -1;
/*! \brief Type of remote this mod was retreived from. */
ImportModInfo::RemoteType remote_type;
ImportModInfo::RemoteType remote_type = ImportModInfo::RemoteType::local;
/*!
* \brief Constructor. Simply initializes members.

View File

@@ -469,6 +469,7 @@ AppInfo ModdedApplication::getAppInfo() const
info.command = command_;
info.num_mods = installed_mods_.size();
info.app_version = app_versions_[current_profile_];
info.steam_app_id = steam_app_id_;
for(const auto& deployer : deployers_)
{
info.deployers.push_back(deployer->getName());
@@ -1655,6 +1656,8 @@ void ModdedApplication::updateSettings(bool write)
json_settings_["auto_tags"][i] = auto_tags_[i].toJson();
}
json_settings_["steam_app_id"] = steam_app_id_;
if(write)
writeSettings();
}
@@ -1884,6 +1887,12 @@ void ModdedApplication::updateState(bool read)
updateAutoTagMap();
}
steam_app_id_ = -1;
if(json_settings_.isMember("steam_app_id"))
steam_app_id_ = json_settings_["steam_app_id"].asInt64();
else
updateSteamAppId();
updateSteamIconPath();
}
@@ -2219,3 +2228,37 @@ void ModdedApplication::updateSteamIconPath()
}
}
}
void ModdedApplication::updateSteamAppId()
{
if(steam_app_id_ != -1)
return;
std::regex old_path_regex(R"(.*?/steam/appcache/librarycache/(\d+)_icon\.jpg)");
std::smatch match;
std::string path_str = icon_path_.string();
if(std::regex_match(path_str, match, old_path_regex))
{
steam_app_id_ = std::stol(match[1]);
return;
}
std::regex new_path_regex(R"(.*?/steam/appcache/librarycache/(\d+)/.*)");
if(std::regex_match(path_str, match, new_path_regex))
{
steam_app_id_ = std::stol(match[1]);
return;
}
std::regex steam_regex(R"(/steamapps/compatdata/(\d+))");
for(const auto& depl : deployers_)
{
std::smatch match;
std::string path = depl->getDestPath();
if(std::regex_search(path, match, steam_regex))
{
steam_app_id_ = std::stol(match[1]);
return;
}
}
}

View File

@@ -706,6 +706,8 @@ private:
std::function<void(float)> progress_callback_ = [](float f) {};
/*! \brief File name used to store exported deployers and auto tags. */
std::string export_file_name = "exported_config";
/*! \brief Steam app id. Or -1 if not a Steam app. */
long steam_app_id_;
/*!
* \brief Updates json_settings_ with the current state of this object.
@@ -771,4 +773,6 @@ private:
std::string generalizeSteamPath(const std::string& path);
/*! \brief If the icon path is a steam path: Update it to the new format. */
void updateSteamIconPath();
/*! \brief If steam_app_id_ == -1: Try to determine the app id. */
void updateSteamAppId();
};

View File

@@ -1,4 +1,5 @@
#include "tagconditionnode.h"
#include "wildcardmatching.h"
#include <algorithm>
#include <format>
#include <ranges>
@@ -113,7 +114,7 @@ bool TagConditionNode::evaluateWithoutInversion(
target.end(),
target.begin(),
[](unsigned char c) { return std::tolower(c); });
result = wildcardMatch(target);
result = wildcardMatch(target, condition_);
}
if(result)
break;
@@ -273,51 +274,6 @@ void TagConditionNode::removeWhitespaces(std::string& expression) const
}
}
bool TagConditionNode::wildcardMatch(const std::string& target) const
{
if(condition_.empty())
return false;
if(condition_.find_first_not_of("*") == std::string::npos)
return true;
auto condition_strings_ = splitString(condition_);
if(condition_.front() != '*' && !target.starts_with(condition_strings_[0]) ||
condition_.back() != '*' && !target.ends_with(condition_strings_.back()))
return false;
size_t target_pos = 0;
for(const auto& search_string : condition_strings_)
{
if(target_pos >= target.size())
return false;
target_pos = target.find(search_string, target_pos);
if(target_pos == std::string::npos)
return false;
target_pos += search_string.size();
}
return true;
}
std::vector<std::string> TagConditionNode::splitString(const std::string& input) const
{
std::vector<std::string> splits;
size_t pos = 0;
size_t old_pos = 0;
while(old_pos != input.size())
{
pos = input.find('*', old_pos);
if(pos == std::string::npos)
{
splits.push_back(input.substr(old_pos));
break;
}
if(pos - old_pos > 0)
splits.push_back(input.substr(old_pos, pos - old_pos));
old_pos = pos + 1;
}
return splits;
}
bool TagConditionNode::operatorOrderIsValid(std::string expression)
{
constexpr int type_var = 0;

View File

@@ -128,19 +128,6 @@ private:
* \param expression Expression to modify.
*/
void removeWhitespaces(std::string& expression) const;
/*!
* \brief Checks if the given string matches this nodes condition_ string.
* Uses * as a wildcard.
* \param target String to compare to.
* \return True if both match.
*/
bool wildcardMatch(const std::string& target) const;
/*!
* \brief Splits the given string into substrings seperated by the * wildcard.
* \param input String to split.
* \return All substrings without the * wildcard.
*/
std::vector<std::string> splitString(const std::string& input) const;
/*!
* \brief Checks if the order of operators in the given boolean expression is valid.
* \param expression Expression to check.

View File

@@ -0,0 +1,47 @@
#include "wildcardmatching.h"
bool wildcardMatch(const std::string& target, const std::string& expression)
{
if(expression.empty())
return false;
if(expression.find_first_not_of("*") == std::string::npos)
return true;
auto expressionstrings_ = splitString(expression);
if(expression.front() != '*' && !target.starts_with(expressionstrings_[0]) ||
expression.back() != '*' && !target.ends_with(expressionstrings_.back()))
return false;
size_t target_pos = 0;
for(const auto& search_string : expressionstrings_)
{
if(target_pos >= target.size())
return false;
target_pos = target.find(search_string, target_pos);
if(target_pos == std::string::npos)
return false;
target_pos += search_string.size();
}
return true;
}
std::vector<std::string> splitString(const std::string& input)
{
std::vector<std::string> splits;
size_t pos = 0;
size_t old_pos = 0;
while(old_pos != input.size())
{
pos = input.find('*', old_pos);
if(pos == std::string::npos)
{
splits.push_back(input.substr(old_pos));
break;
}
if(pos - old_pos > 0)
splits.push_back(input.substr(old_pos, pos - old_pos));
old_pos = pos + 1;
}
return splits;
}

View File

@@ -0,0 +1,24 @@
/*!
* \file wildcardmatching.h
* \brief Contains functions for wildcard matching strings.
*/
#pragma once
#include <string>
#include <vector>
/*!
* \brief Checks if the given string matches the given expression string.
* Uses * as a wildcard.
* \param target String to compare to.
* \param expression Wildcard expression string.
* \return True if both match.
*/
bool wildcardMatch(const std::string& target, const std::string& expression);
/*!
* \brief Splits the given string into substrings seperated by the * wildcard.
* \param input String to split.
* \return All substrings without the * wildcard.
*/
std::vector<std::string> splitString(const std::string& input);

View File

@@ -120,7 +120,7 @@ void AddAppDialog::initConfigForApp()
return;
}
config_path /= (std::to_string(app_id_) + ".json");
config_path /= (std::to_string(steam_app_id_) + ".json");
if(!sfs::exists(config_path))
{
initDefaultAppConfig();
@@ -153,7 +153,7 @@ void AddAppDialog::initConfigForApp()
return;
}
Log::debug(std::format("Reading app config for id {}", app_id_));
Log::debug(std::format("Reading app config for id {}", steam_app_id_));
try
{
for(int i = 0; i < json["deployers"].size(); i++)
@@ -166,7 +166,7 @@ void AddAppDialog::initConfigForApp()
if(deployer[key].isNull())
{
Log::debug(std::format(
"App config for deployer {} for app {} does not contain key {}", i, app_id_, key));
"App config for deployer {} for app {} does not contain key {}", i, steam_app_id_, key));
continue;
}
}
@@ -175,7 +175,7 @@ void AddAppDialog::initConfigForApp()
if(str::find(DeployerFactory::DEPLOYER_TYPES, type) == DeployerFactory::DEPLOYER_TYPES.end())
{
Log::debug(std::format(
"App config for deployer {} for app {} contains unknown type {}", i, app_id_, type));
"App config for deployer {} for app {} contains unknown type {}", i, steam_app_id_, type));
continue;
}
info.type = type;
@@ -190,7 +190,7 @@ void AddAppDialog::initConfigForApp()
{
Log::debug(std::format("App config for deployer {} for app {} contains invalid target {}",
i,
app_id_,
steam_app_id_,
target_dir));
continue;
}
@@ -208,7 +208,7 @@ void AddAppDialog::initConfigForApp()
{
Log::debug(std::format("App config for deployer {} for app {} contains invalid mode {}",
i,
app_id_,
steam_app_id_,
deploy_mode.toStdString()));
continue;
}
@@ -225,7 +225,7 @@ void AddAppDialog::initConfigForApp()
{
Log::debug(std::format("App config for deployer {} for app {} contains invalid source {}",
i,
app_id_,
steam_app_id_,
source_dir));
continue;
}
@@ -248,12 +248,12 @@ void AddAppDialog::initConfigForApp()
catch(const ParseError& e)
{
Log::debug(std::format(
"Failed to read auto tag {} for app with id {}.\nError: {}", i, app_id_, e.what()));
"Failed to read auto tag {} for app with id {}.\nError: {}", i, steam_app_id_, e.what()));
continue;
}
catch(...)
{
Log::debug(std::format("Failed to read auto tag {} for app with id {}", i, app_id_));
Log::debug(std::format("Failed to read auto tag {} for app with id {}", i, steam_app_id_));
continue;
}
auto_tags_.push_back(json[JSON_AUTO_TAGS_GROUP][i]);
@@ -285,7 +285,7 @@ void AddAppDialog::initDefaultAppConfig()
deployers_.clear();
auto_tags_.clear();
Log::debug(std::format("Using default config for app {}", app_id_));
Log::debug(std::format("Using default config for app {}", steam_app_id_));
deployers_.emplace_back(DeployerFactory::CASEMATCHINGDEPLOYER,
"Install",
steam_install_path_.toStdString(),
@@ -303,7 +303,8 @@ void AddAppDialog::setEditMode(const QString& name,
const QString& path,
const QString& command,
const QString& icon_path,
int app_id)
int app_id,
long steam_app_id)
{
deployers_.clear();
auto_tags_.clear();
@@ -318,6 +319,7 @@ void AddAppDialog::setEditMode(const QString& name,
path_ = path;
command_ = command;
app_id_ = app_id;
steam_app_id_ = steam_app_id;
enableOkButton(true);
edit_mode_ = true;
ui->move_dir_box->setVisible(true);
@@ -368,6 +370,7 @@ void AddAppDialog::on_buttonBox_accepted()
info.staging_dir = ui->path_field->text().toStdString();
info.command = ui->command_field->text().toStdString();
info.icon_path = ui->icon_field->text().toStdString();
info.steam_app_id = steam_app_id_;
if(edit_mode_)
{
info.move_staging_dir = ui->move_dir_box->checkState() == Qt::Checked;
@@ -397,7 +400,7 @@ void AddAppDialog::onApplicationImported(QString name,
{
ui->name_field->setText(name);
ui->command_field->setText("xdg-open steam://rungameid/" + app_id);
app_id_ = app_id.toInt();
steam_app_id_ = app_id.toLong();
steam_install_path_ = install_dir;
steam_prefix_path_ = prefix_path;
ui->icon_field->setText(icon_path);

View File

@@ -79,6 +79,8 @@ private:
QString command_;
/*! \brief Id of the edited \ref ModdedApplication "application". */
int app_id_;
/*! \brief Steam app id of the new application, or -1 if not a steam app. */
long steam_app_id_;
/*! \brief Path to imported steam applications installation directory. */
QString steam_install_path_ = "";
/*! \brief Path to imported steam applications prefix directory. */
@@ -127,13 +129,15 @@ public:
* \ref ModdedApplication "application".
* \param command Current command to run the edited \ref ModdedApplication "application".
* \param app_id Id of the edited \ref ModdedApplication "application".
* \param steam_app_id Steam app id. Or -1 if not a Steam app.
*/
void setEditMode(const QString& name,
const QString& app_version,
const QString& path,
const QString& command,
const QString& icon_path,
int app_id);
int app_id,
long steam_app_id);
/*!
* \brief Initializes this dialog to allow creating a new
* \ref ModdedApplication "application".

View File

@@ -3,7 +3,6 @@
#include "../core/log.h"
#include "../core/pathutils.h"
#include "fomoddialog.h"
#include "qdebug.h"
#include "ui_addmoddialog.h"
#include <QGroupBox>
#include <QMessageBox>
@@ -78,7 +77,6 @@ void AddModDialog::updateOkButton()
void AddModDialog::colorTreeNodes(QTreeWidgetItem* node, int cur_depth, int root_level)
{
qDebug() << node->text(0);
if(cur_depth < root_level)
{
node->setForeground(0, COLOR_REMOVE_);
@@ -103,7 +101,6 @@ int AddModDialog::detectRootLevel(int deployer) const
{
bool is_case_invariant = case_invariant_deployers_[deployer];
sfs::path deployer_path = deployer_paths_[deployer].toStdString();
auto cur_item = ui->content_tree->invisibleRootItem();
if(cur_item->childCount() != 1)
return 0;
@@ -145,13 +142,38 @@ int AddModDialog::detectRootLevel(int deployer) const
return 0;
}
int AddModDialog::computeFomodBoxIndexFromDeployer(int index) const
{
int fomod_index = -1;
for(int i = 0; i < index; i++)
{
if(!autonomous_deployers_[i])
fomod_index++;
}
return fomod_index;
}
int AddModDialog::computeDeployerFromFomodBoxIndex(int index) const
{
int deployer = -1;
int fomod_index = -1;
for(bool is_autonomous : autonomous_deployers_)
{
deployer++;
if(!is_autonomous && ++fomod_index == index)
break;
}
return deployer;
}
bool AddModDialog::setupDialog(const QStringList& deployers,
int cur_deployer,
const QStringList& deployer_paths,
const std::vector<bool>& autonomous_deployers,
const std::vector<bool>& case_invariant_deployers,
const QString& app_version,
const ImportModInfo& info)
const ImportModInfo& info,
const std::vector<RootLevelCondition>& root_level_conditions)
{
groups_.clear();
const auto& mod_infos = mod_list_model_->getModInfo();
@@ -181,6 +203,8 @@ bool AddModDialog::setupDialog(const QStringList& deployers,
ui->group_field->setCompleter(completer_.get());
ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true);
ui->group_check->setCheckState(Qt::Unchecked);
autonomous_deployers_ = autonomous_deployers;
int mod_index = -1;
if(info.target_group_id != -1)
{
@@ -246,8 +270,7 @@ bool AddModDialog::setupDialog(const QStringList& deployers,
}
if(detected_type == Installer::FOMODINSTALLER)
{
auto [name, version] =
fomod::FomodInstaller::getMetaData(info.current_path / prefix);
auto [name, version] = fomod::FomodInstaller::getMetaData(info.current_path / prefix);
if(!name.empty() && info.name_overwrite.empty())
ui->name_text->setText(name.c_str());
if(!version.empty() && info.version_overwrite.empty())
@@ -324,11 +347,12 @@ bool AddModDialog::setupDialog(const QStringList& deployers,
try
{
auto mod_file_paths = Installer::getArchiveFileNames(info.current_path);
int max_depth = 0;
for(const auto& path : mod_file_paths)
max_depth = std::max(addTreeNode(ui->content_tree, path), max_depth);
ui->root_level_box->setMaximum(std::max(max_depth - 1, 0));
ui->root_level_box->setValue(root_level);
directory_tree_depth_ = 0;
for(const auto& [path, is_directory] : mod_file_paths)
directory_tree_depth_ =
std::max(addTreeNode(ui->content_tree, path, is_directory), directory_tree_depth_);
ui->root_level_box->setMaximum(std::max(directory_tree_depth_ - 1, 0));
ui->root_level_box->setValue(std::min(std::max(0, root_level), directory_tree_depth_ - 1));
}
catch(std::runtime_error& error)
{
@@ -337,8 +361,19 @@ bool AddModDialog::setupDialog(const QStringList& deployers,
return false;
}
ui->fomod_deployer_box->clear();
bool root_level_checked = false;
for(const auto& condition : root_level_conditions)
{
if(auto level = condition.detectRootLevel(ui->content_tree->invisibleRootItem());
level && *level >= 0)
{
root_level_checked = true;
ui->root_level_box->setValue(std::min(std::max(0, *level), directory_tree_depth_ - 1));
break;
}
}
ui->fomod_deployer_box->clear();
for(int i = 0; i < deployers.size(); i++)
{
const bool is_target = selected_deployers.contains(i) | (i == cur_deployer);
@@ -349,19 +384,19 @@ bool AddModDialog::setupDialog(const QStringList& deployers,
{
root_level_checked = true;
root_level = detectRootLevel(i);
ui->root_level_box->setValue(root_level);
ui->root_level_box->setValue(std::min(std::max(0, root_level), directory_tree_depth_ - 1));
}
}
auto item = new QListWidgetItem(deployers[i], ui->deployer_list);
item->setCheckState(is_target ? Qt::Checked : Qt::Unchecked);
item->setHidden(autonomous_deployers[i]);
}
int fomod_target_deployer = settings.value("fomod_target_deployer", -1).toInt();
if(fomod_target_deployer >= 0 && fomod_target_deployer < ui->fomod_deployer_box->count())
ui->fomod_deployer_box->setCurrentIndex(fomod_target_deployer);
else if(cur_deployer >= 0 && cur_deployer < ui->fomod_deployer_box->count())
ui->fomod_deployer_box->setCurrentIndex(cur_deployer);
else if(int depl_index = computeFomodBoxIndexFromDeployer(cur_deployer);
depl_index >= 0 && depl_index < ui->fomod_deployer_box->count())
ui->fomod_deployer_box->setCurrentIndex(depl_index);
settings.endGroup();
dialog_completed_ = false;
@@ -416,7 +451,7 @@ int AddModDialog::addTreeNode(QTreeWidgetItem* parent, const sfs::path& cur_path
return addTreeNode(child, removeRoot(cur_path)) + 1;
}
int AddModDialog::addTreeNode(QTreeWidget* tree, const sfs::path& cur_path)
int AddModDialog::addTreeNode(QTreeWidget* tree, const sfs::path& cur_path, bool is_directory)
{
if(cur_path.empty())
return 0;
@@ -430,6 +465,7 @@ int AddModDialog::addTreeNode(QTreeWidget* tree, const sfs::path& cur_path)
auto item = new QTreeWidgetItem(tree);
item->setText(0, cur_text);
item->setForeground(0, COLOR_KEEP_);
item->setData(0, Qt::UserRole, is_directory);
return addTreeNode(item, removeRoot(cur_path)) + 1;
}
@@ -486,7 +522,8 @@ void AddModDialog::on_buttonBox_accepted()
}
fomod_dialog_->setupDialog(
import_mod_info_.current_path / path_prefix_.toStdString(),
deployer_paths_[ui->fomod_deployer_box->currentIndex()].toStdString(),
deployer_paths_[computeDeployerFromFomodBoxIndex(ui->fomod_deployer_box->currentIndex())]
.toStdString(),
app_version_,
import_mod_info_,
import_mod_info_.app_id,

View File

@@ -8,6 +8,7 @@
#include "../core/importmodinfo.h"
#include "deployerlistmodel.h"
#include "modlistmodel.h"
#include "rootlevelcondition.h"
#include "ui/fomoddialog.h"
#include <QButtonGroup>
#include <QCompleter>
@@ -47,12 +48,13 @@ public:
* \brief Initializes this dialog with data needed for mod installation.
* \param deployers Contains all available \ref Deployer "deployers".
* \param cur_deployer The currently active Deployer.
* \param deployer_paths Contains target paths for all non autonomous deployers.
* \param deployer_paths Contains target paths for all deployers.
* \param autonomous_deployers Vector of bools indicating for each deployer
* if that deployer is autonomous.
* \param case_invariant_deployers Vector of bools indicating for each deployer
* if that deployer is case invariant.
* \param info Contains data relating to the current status of the mod import.
* \param root_level_conditions Contains all root level conditions for the current app.
* \return True if dialog creation was successful.
*/
bool setupDialog(const QStringList& deployers,
@@ -61,7 +63,8 @@ public:
const std::vector<bool>& autonomous_deployers,
const std::vector<bool>& case_invariant_deployers,
const QString& app_version,
const ImportModInfo& info);
const ImportModInfo& info,
const std::vector<RootLevelCondition>& root_level_conditions);
/*!
* \brief Closes the dialog and emits a signal indicating installation has been canceled.
* \param event The close even sent upon closing the dialog.
@@ -105,6 +108,10 @@ private:
std::vector<bool> case_invariant_deployers_;
/*! \brief Contains all data related to the current state of the mod installation. */
ImportModInfo import_mod_info_;
/*! \brief Depth of the current directory tree. */
int directory_tree_depth_;
/*! \brief Contains bools for every deployer indicating whether that deployer is autonomous. */
std::vector<bool> autonomous_deployers_;
/*!
* \brief Updates the enabled state of this dialog's OK button to only be enabled when
@@ -117,8 +124,9 @@ private:
* Then adds all subsequent path components as children to the new node.
* \param tree Target QTreeWidget.
* \param cur_path Source path.
* \param is_directory If True: Given path points to a directory.
*/
int addTreeNode(QTreeWidget* tree, const std::filesystem::path& cur_path);
int addTreeNode(QTreeWidget* tree, const std::filesystem::path& cur_path, bool is_directory);
/*!
* \brief Adds the root path element of given path as a root node to the given parent node.
* Then adds all subsequent path components as children to the new node.
@@ -154,6 +162,18 @@ private:
* \return The detected root level.
*/
int detectRootLevel(int deployer) const;
/*!
* \brief Computes the index in the fomod deployer combo box from the given deployer index.
* \param index Deployer index.
* \return Fomod deployer box index.
*/
int computeFomodBoxIndexFromDeployer(int index) const;
/*!
* \brief Computes the deployer index from the given index in the fomod deployer combo box.
* \param index Fomod deployer box index.
* \return Deployer index.
*/
int computeDeployerFromFomodBoxIndex(int index) const;
private slots:
/*! \brief Closes the dialog and emits a signal for completion. */

View File

@@ -12,6 +12,7 @@ IpcClient::~IpcClient()
bool IpcClient::connect()
{
socket_->connectToServer(IpcServer::server_name);
socket_->waitForConnected(50);
return socket_->state() == QLocalSocket::ConnectedState;
}

View File

@@ -38,6 +38,7 @@
namespace str = std::ranges;
namespace stv = std::views;
namespace sfs = std::filesystem;
Q_DECLARE_METATYPE(std::vector<ModInfo>);
@@ -856,6 +857,7 @@ void MainWindow::importMod()
{
if(!initNexusApiKey())
{
mod_import_queue_.pop();
setBusyStatus(false);
if(!mod_import_queue_.empty())
importMod();
@@ -1351,6 +1353,82 @@ bool MainWindow::versionIsLessOrEqual(QString current_version, QString target_ve
return true;
}
void MainWindow::initRootLevelConditions()
{
root_level_conditions_.clear();
if(app_info_.steam_app_id == -1)
return;
sfs::path config_path =
sfs::path(is_a_flatpak_ ? "/app" : APP_INSTALL_PREFIX) / "share/limo/steam_app_configs";
// Overwrite for local build
if(!is_a_flatpak_ && sfs::exists("steam_app_configs"))
config_path = "steam_app_configs";
Log::debug("Config path: " + config_path.string());
if(!sfs::exists(config_path))
{
Log::debug(
"Could not find \"steam_app_configs\" directory. " "Make sure Limo is installed correctly");
return;
}
config_path /= (std::to_string(app_info_.steam_app_id) + ".json");
if(!sfs::exists(config_path))
{
return;
}
Json::Value json;
std::ifstream file(config_path, std::fstream::binary);
if(!file.is_open())
{
Log::debug("Failed to open app settings file at: " + config_path.string());
return;
}
try
{
file >> json;
}
catch(Json::Exception& e)
{
Log::debug("Failed to read from app settings file at: " + config_path.string() +
". Error was: " + e.what());
return;
}
catch(...)
{
Log::debug("Failed to read from app settings file at: " + config_path.string());
return;
}
if(!json.isMember(JSON_ROOT_LEVEL_KEY))
return;
for(int i = 0; i < json[JSON_ROOT_LEVEL_KEY].size(); i++)
{
try
{
root_level_conditions_.emplace_back(json[JSON_ROOT_LEVEL_KEY][i]);
}
catch(std::runtime_error& e)
{
Log::debug(std::format("Failed to parse root level config for app {}. \nError: {}",
app_info_.steam_app_id,
e.what()));
}
catch(Json::Exception& e)
{
Log::debug(std::format("Failed to parse root level config for app {}. \nError: {}",
app_info_.steam_app_id,
e.what()));
}
catch(...)
{
Log::debug(std::format("Unknown error while parsing root level config for app {}.",
app_info_.steam_app_id));
}
}
}
void MainWindow::onModAdded(QList<QUrl> paths)
{
const bool was_empty = mod_import_queue_.empty();
@@ -1518,10 +1596,7 @@ void MainWindow::onGetApplicationNames(QStringList names, QStringList icon_paths
if(icon_paths[i] == "")
ui->app_selection_box->addItem(names[i]);
else
{
ui->app_selection_box->addItem(QIcon(icon_paths[i]), names[i]);
qDebug() << icon_paths[i];
}
ui->app_selection_box->setItemData(
ui->app_selection_box->count() - 1, icon_paths[i], Qt::UserRole);
}
@@ -1825,6 +1900,8 @@ void MainWindow::onGetAppInfo(AppInfo app_info)
connect(button, &QPushButton::clicked, this, &MainWindow::onAddToolClicked);
ui->info_tool_list->setCellWidget(ui->info_tool_list->rowCount() - 1, 0, button);
ignore_tool_changes_ = false;
initRootLevelConditions();
}
void MainWindow::onApplicationEdited(EditApplicationInfo info, int app_id)
@@ -2002,12 +2079,8 @@ void MainWindow::onExtractionComplete(ImportModInfo info)
ui->app_tab_widget->currentIndex() == 2 ? ui->deployer_selection_box->currentIndex() : -1;
const std::vector<bool> auto_deployers = getAutonomousDeployers();
QStringList deployer_paths;
for(int i = 0; i < ui->info_deployer_list->rowCount(); i++)
{
if(!auto_deployers[i])
deployer_paths.append(
ui->info_deployer_list->item(i, getColumnIndex(ui->info_deployer_list, "Target"))->text());
}
for(const auto& path : app_info_.target_dirs)
deployer_paths.append(path.c_str());
info.action_type = ImportModInfo::ActionType::install_dialog;
bool was_successful = add_mod_dialog_->setupDialog(deployers,
deployer,
@@ -2015,7 +2088,8 @@ void MainWindow::onExtractionComplete(ImportModInfo info)
auto_deployers,
app_info_.deployer_is_case_invariant,
ui->info_version_label->text(),
info);
info,
root_level_conditions_);
if(was_successful)
{
setBusyStatus(true, false);
@@ -2404,7 +2478,8 @@ void MainWindow::on_edit_app_button_clicked()
ui->info_sdir_label->text(),
ui->info_command_label->text(),
ui->app_selection_box->currentData(Qt::UserRole).toString(),
currentApp());
currentApp(),
app_info_.steam_app_id);
setBusyStatus(true, false);
add_app_dialog_->show();
}

View File

@@ -37,6 +37,7 @@
#include "ui/externalchangesdialog.h"
#include "ui/ipcserver.h"
#include "ui/listaction.h"
#include "ui/rootlevelcondition.h"
#include "ui/tagcheckbox.h"
#include "versionboxdelegate.h"
#include <QCloseEvent>
@@ -126,6 +127,8 @@ private:
static inline const QString deploy_mode_sym_link = "Sym Link";
/*! \brief Display string for copy deployment. */
static inline const QString deploy_mode_copy = "Copy";
/*! \brief JSON key for the root level conditions in the per steam app config file. */
static inline constexpr char JSON_ROOT_LEVEL_KEY[] = "root_level_conditions";
/*! \brief True if the button used to reorder load orders is being pressed. */
bool move_button_pressed_ = false;
/*! \brief Stores the row containing the currently held down move button for load orders. */
@@ -319,6 +322,8 @@ private:
QString previous_app_version_;
/*! \brief Contains data about the currently active application. */
AppInfo app_info_;
/*! \brief Used to detect root levels during mod installation for the current app. */
std::vector<RootLevelCondition> root_level_conditions_;
/*! \brief Creates signal/ slot connections between this and the ApplicationManager. */
void setupConnections();
@@ -472,6 +477,8 @@ private:
* is invalid, true if current_version is invalid.
*/
bool versionIsLessOrEqual(QString current_version, QString target_version);
/*! \brief Initializes the root level conditions for the current app. */
void initRootLevelConditions();
public slots:
/*!

View File

@@ -0,0 +1,101 @@
#include "rootlevelcondition.h"
#include "../core/wildcardmatching.h"
#include <queue>
#include <regex>
RootLevelCondition::RootLevelCondition(MatcherType matcher_type,
TargetType target_type,
bool case_invariant,
bool stop_on_branch,
const std::string& expression,
int level_offset) :
matcher_type_(matcher_type), target_type_(target_type), case_invariant_matching_(case_invariant),
stop_on_branch_(stop_on_branch), expression_(expression), level_offset_(level_offset)
{}
RootLevelCondition::RootLevelCondition(const Json::Value& json)
{
const std::vector<std::string> required_keys = { JSON_MATCHER_TYPE_KEY,
JSON_EXPRESSION_KEY,
JSON_TARGET_TYPE_KEY };
for(const auto& key : required_keys)
if(!json.isMember(key))
throw std::runtime_error(std::format("Missing json key: '{}'.", key));
const std::string matcher_type = json[JSON_MATCHER_TYPE_KEY].asString();
if(matcher_type == JSON_MATCHER_SIMPLE_VALUE)
matcher_type_ = simple;
else if(matcher_type == JSON_MATCHER_REGEX_VALUE)
matcher_type_ = regex;
else
throw std::runtime_error(std::format("Invalid matcher type: '{}'.", matcher_type));
const std::string target_type = json[JSON_TARGET_TYPE_KEY].asString();
if(target_type == JSON_TARGET_ANY_VALUE)
target_type_ = any;
else if(target_type == JSON_TARGET_FILE_VALUE)
target_type_ = file;
else if(target_type == JSON_TARGET_DIRECTORY_VALUE)
target_type_ = directory;
else
throw std::runtime_error(std::format("Invalid target type: '{}'.", target_type));
expression_ = json[JSON_EXPRESSION_KEY].asString();
case_invariant_matching_ = false;
if(json.isMember(JSON_CASE_INVARIANT_KEY))
case_invariant_matching_ = json[JSON_CASE_INVARIANT_KEY].asBool();
stop_on_branch_ = true;
if(json.isMember(JSON_STOP_KEY))
stop_on_branch_ = json[JSON_STOP_KEY].asBool();
level_offset_ = 0;
if(json.isMember(JSON_OFFSET_KEY))
level_offset_ = json[JSON_OFFSET_KEY].asInt();
}
std::optional<int> RootLevelCondition::detectRootLevel(QTreeWidgetItem* root_node,
int cur_level) const
{
if(root_node->childCount() == 0)
return {};
auto compare_levels = [](const auto& pair_l, const auto& pair_r)
{ return pair_l.second > pair_r.second; };
std::priority_queue<std::pair<QTreeWidgetItem*, int>,
std::vector<std::pair<QTreeWidgetItem*, int>>,
decltype(compare_levels)>
remaining_nodes{ compare_levels };
for(int i = 0; i < root_node->childCount(); i++)
remaining_nodes.push({ root_node->child(i), cur_level });
std::regex expression_regex;
if(matcher_type_ == regex)
expression_regex.assign(expression_);
while(!remaining_nodes.empty())
{
auto [node, level] = remaining_nodes.top();
remaining_nodes.pop();
const bool is_directory = node->data(0, Qt::UserRole).toBool();
if(target_type_ == any || target_type_ == file && !is_directory ||
target_type_ == directory && is_directory)
{
const std::string cur_text = case_invariant_matching_ ? node->text(0).toLower().toStdString()
: node->text(0).toStdString();
if(matcher_type_ == regex)
{
std::smatch match;
if(std::regex_match(cur_text, match, expression_regex))
return level - level_offset_;
}
else if(wildcardMatch(cur_text, expression_))
return level - level_offset_;
}
for(int i = 0; i < node->childCount(); i++)
remaining_nodes.push({ node->child(i), level + 1 });
}
return {};
}

110
src/ui/rootlevelcondition.h Normal file
View File

@@ -0,0 +1,110 @@
/*!
* \file rootlevelcondition.h
* \brief Header for the RootLevelCondition class.
*/
#pragma once
#include <QTreeWidgetItem>
#include <json/json.h>
#include <optional>
#include <string>
/*!
* \brief Used to find the root level during mod installation using a regex or wildcard expression.
*/
class RootLevelCondition
{
public:
/*! \brief Type of string matcher to use on file names. */
enum MatcherType
{
/*! \brief Wildcard matcher. */
simple,
/*! \brief Regex matcher. */
regex
};
/*! \brief Describes what file type the expression should be matched against. */
enum TargetType
{
/*! \brief Any file. */
any,
/*! \brief Only non directories. */
file,
/*! \brief Only directories. */
directory
};
/*!
* \brief Simply initializes members.
* \param matcher_type Type of file name matcher to use.
* \param target_type Only files of this type will be used for matching.
* \param case_invariant If true: Use case invariant matching.
* \param stop_on_branch If true: Stop root level detection as soon as a directory contains more
* than one entry.
* \param expression Used for matching.
* \param level_offset Offset to add to the detected root level.
*/
RootLevelCondition(MatcherType matcher_type,
TargetType target_type,
bool case_invariant,
bool stop_on_branch,
const std::string& expression,
int level_offset);
/*!
* \brief Deserializes a RootLevelCondition from the given JSON object.
* \param json Contains values for members.
*/
RootLevelCondition(const Json::Value& json);
/*!
* \brief Recursively matches the given expression to every node in the given (sub)tree.
*
* WARNING: The root node itself is ignored for matching.
*
* \param root_node Root of the (sub)tree for which to match.
* \param cur_level Current level in the tree.
* \return If possible: The level at which the expression first matches a root_node's text.
*/
std::optional<int> detectRootLevel(QTreeWidgetItem* root_node, int cur_level = 0) const;
private:
/*! \brief JSON key name for the matcher type. */
static inline constexpr char JSON_MATCHER_TYPE_KEY[] = "matcher_type";
/*! \brief JSON key name for the target type. */
static inline constexpr char JSON_TARGET_TYPE_KEY[] = "target_type";
/*! \brief JSON key name for case invariance.. */
static inline constexpr char JSON_CASE_INVARIANT_KEY[] = "case_invariant";
/*! \brief JSON key name for the expression. */
static inline constexpr char JSON_EXPRESSION_KEY[] = "expression";
/*! \brief JSON key name for stop on branch. */
static inline constexpr char JSON_STOP_KEY[] = "stop_on_branch";
/*! \brief JSON key name the level offset. */
static inline constexpr char JSON_OFFSET_KEY[] = "level_offset";
/*! \brief JSON value for the wildcard matcher. */
static inline constexpr char JSON_MATCHER_SIMPLE_VALUE[] = "simple";
/*! \brief JSON value for the regex matcher. */
static inline constexpr char JSON_MATCHER_REGEX_VALUE[] = "regex";
/*! \brief JSON value for any file type. */
static inline constexpr char JSON_TARGET_ANY_VALUE[] = "any";
/*! \brief JSON value for non directory file type. */
static inline constexpr char JSON_TARGET_FILE_VALUE[] = "file";
/*! \brief JSON value for directory file type. */
static inline constexpr char JSON_TARGET_DIRECTORY_VALUE[] = "directory";
/*! \brief Type of file name matcher to use. */
MatcherType matcher_type_;
/*! \brief Only files of this type will be used for matching. */
TargetType target_type_;
/*! \brief If true: Use case invariant matching. */
bool case_invariant_matching_;
/*! \brief If true: Stop root level detection as soon as a directory contains more
* than one entry. */
bool stop_on_branch_;
/*! \brief Used for matching. */
std::string expression_;
/*! \brief Offset to add to the detected root level. */
int level_offset_;
};

View File

@@ -20,7 +20,7 @@
"target_dir": "$STEAM_PREFIX_PATH$/users/steamuser/Local Settings/Application Data/Fallout 3",
"deploy_mode": "hard link",
"source_dir": "$STEAM_INSTALL_PATH$/Data"
},
}
],
"auto_tags":
[
@@ -50,5 +50,16 @@
}
]
}
]
],
"root_level_conditions" :
[
{
"case_invariant" : true,
"expression" : ".*\\.es[plm]",
"level_offset" : 0,
"matcher_type" : "regex",
"stop_on_branch" : false,
"target_type" : "file"
}
]
}

View File

@@ -1,5 +1,5 @@
{
"name": "The Elder Scrolls IV: Oblivion Game of the Year Edition",
"name": "Oblivion: GOTY",
"deployers":
[
{
@@ -20,7 +20,7 @@
"target_dir": "$STEAM_PREFIX_PATH$/users/steamuser/Local Settings/Application Data/Oblivion",
"deploy_mode": "hard link",
"source_dir": "$STEAM_INSTALL_PATH$/Data"
},
}
],
"auto_tags":
[
@@ -50,5 +50,16 @@
}
]
}
]
],
"root_level_conditions" :
[
{
"case_invariant" : true,
"expression" : ".*\\.es[plm]",
"level_offset" : 0,
"matcher_type" : "regex",
"stop_on_branch" : false,
"target_type" : "file"
}
]
}

View File

@@ -1,5 +1,5 @@
{
"name": "Fallout 3: Game of the Year Edition",
"name": "Fallout 3: GOTY",
"deployers":
[
{
@@ -20,7 +20,7 @@
"target_dir": "$STEAM_PREFIX_PATH$/users/steamuser/Local Settings/Application Data/Fallout 3 goty",
"deploy_mode": "hard link",
"source_dir": "$STEAM_INSTALL_PATH$/Data"
},
}
],
"auto_tags":
[
@@ -50,5 +50,16 @@
}
]
}
]
],
"root_level_conditions" :
[
{
"case_invariant" : true,
"expression" : ".*\\.es[plm]",
"level_offset" : 0,
"matcher_type" : "regex",
"stop_on_branch" : false,
"target_type" : "file"
}
]
}

View File

@@ -114,5 +114,16 @@
"type" : "Loot Deployer"
}
],
"name" : "Fallout: New Vegas"
"name" : "Fallout: New Vegas",
"root_level_conditions" :
[
{
"case_invariant" : true,
"expression" : ".*\\.es[plm]",
"level_offset" : 0,
"matcher_type" : "regex",
"stop_on_branch" : false,
"target_type" : "file"
}
]
}

View File

@@ -20,7 +20,7 @@
"target_dir": "$STEAM_PREFIX_PATH$/users/steamuser/Local Settings/Application Data/Fallout 4",
"deploy_mode": "hard link",
"source_dir": "$STEAM_INSTALL_PATH$/Data"
},
}
],
"auto_tags":
[
@@ -50,5 +50,16 @@
}
]
}
]
],
"root_level_conditions" :
[
{
"case_invariant" : true,
"expression" : ".*\\.es[plm]",
"level_offset" : 0,
"matcher_type" : "regex",
"stop_on_branch" : false,
"target_type" : "file"
}
]
}

View File

@@ -157,5 +157,16 @@
"type" : "Loot Deployer"
}
],
"name" : "Skyrim SE"
}
"name" : "Skyrim SE",
"root_level_conditions" :
[
{
"case_invariant" : true,
"expression" : ".*\\.es[plm]",
"level_offset" : 0,
"matcher_type" : "regex",
"stop_on_branch" : false,
"target_type" : "file"
}
]
}

View File

@@ -20,7 +20,7 @@
"target_dir": "$STEAM_PREFIX_PATH$/users/steamuser/Local Settings/Application Data/Skyrim Special Edition",
"deploy_mode": "hard link",
"source_dir": "$STEAM_INSTALL_PATH$/Data"
},
}
],
"auto_tags":
[
@@ -50,5 +50,16 @@
}
]
}
]
],
"root_level_conditions" :
[
{
"case_invariant" : true,
"expression" : ".*\\.es[plm]",
"level_offset" : 0,
"matcher_type" : "regex",
"stop_on_branch" : false,
"target_type" : "file"
}
]
}

View File

@@ -20,7 +20,7 @@
"target_dir": "$STEAM_PREFIX_PATH$/users/steamuser/Local Settings/Application Data/Skyrim Special Edition",
"deploy_mode": "hard link",
"source_dir": "$STEAM_INSTALL_PATH$/Data"
},
}
],
"auto_tags":
[
@@ -50,5 +50,16 @@
}
]
}
]
],
"root_level_conditions" :
[
{
"case_invariant" : true,
"expression" : ".*\\.es[plm]",
"level_offset" : 0,
"matcher_type" : "regex",
"stop_on_branch" : false,
"target_type" : "file"
}
]
}

View File

@@ -1,5 +1,5 @@
{
"name": "The Elder Scrolls IV: Oblivion Game of the Year Edition Deluxe",
"name": "Oblivion: GOTY Deluxe",
"deployers":
[
{
@@ -20,7 +20,7 @@
"target_dir": "$STEAM_PREFIX_PATH$/users/steamuser/Local Settings/Application Data/Oblivion",
"deploy_mode": "hard link",
"source_dir": "$STEAM_INSTALL_PATH$/Data"
},
}
],
"auto_tags":
[
@@ -50,5 +50,16 @@
}
]
}
]
],
"root_level_conditions" :
[
{
"case_invariant" : true,
"expression" : ".*\\.es[plm]",
"level_offset" : 0,
"matcher_type" : "regex",
"stop_on_branch" : false,
"target_type" : "file"
}
]
}