aboutsummaryrefslogtreecommitdiff
path: root/libs/installer_bsplugins/src/TESData/PluginList.cpp
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-04-12 21:04:15 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-04-12 21:04:15 -0500
commit3949dcfce95af4bd305f258ff5b170d7d50435f6 (patch)
tree4c768be256c40f31794c3311f0a2c22933a6705a /libs/installer_bsplugins/src/TESData/PluginList.cpp
parent892070f3dec1dc690983fd862880e37e711c2516 (diff)
VFS perf fixes, icon/stylesheet compat, download filename fix
VFS: - Open backing fd at mo2_open time for read-only handles so mo2_read can splice without re-opening the file on every read call. - Bump RLIMIT_NOFILE at mount time to fit the resulting fd pressure when the game keeps hundreds of BSAs open. - Dedicated node_cache_mutex so resolveByInode / mo2_lookup stop racing unordered_map writes while multiple tree_mutex readers are active. - max_read=1MB + matching conn->max_read and raised max_readahead/max_write so the kernel merges Wine's small sequential reads into bigger FUSE requests. - Per-op wall-clock counters in mo2_init() logs so we can distinguish VFS latency from game-side work. Proton launch: - Write dxvk.conf to the prefix and set DXVK_CONFIG_FILE to force dxvk.enableGraphicsPipelineLibrary=False, avoiding long GPL compile stalls on first run. UI / plugin compat: - Clamp IconDelegate's per-icon width to [8, 16] so narrow content columns don't trigger QIcon::pixmap(0,0) returning null and logging "failed to load icon" every repaint. - Pre-create lowercase symlinks in the stylesheet directory so QSS files authored on Windows resolve url(foo.svg) when on-disk files are Foo.svg. - Flip content icons empty-chessboard.png and facegen.png to 8-bit RGBA so Qt's built-in PNG reader loads them uniformly. - Add mobase.Version.canonicalString shim for legacy Python plugins that still call the old VersionInfo method on appVersion()'s return. - BSPluginList: compare normalized plugin name lists instead of byte hashes so the post-run case-fix refresh stops spuriously firing the "load order changed" dialog. - BSPluginList disabled by default. Download manager: - Parse CDN URL with QUrl + QUrlQuery and read the filename from the response-content-disposition query param (RFC 6266 quoted / unquoted / ext-value forms), not QFileInfo on the raw URL. - When the API or URL gives us a CDN object key (no archive extension), prefer the actual Content-Disposition header from the live response in metaDataChanged and downloadFinished. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'libs/installer_bsplugins/src/TESData/PluginList.cpp')
-rw-r--r--libs/installer_bsplugins/src/TESData/PluginList.cpp1429
1 files changed, 1429 insertions, 0 deletions
diff --git a/libs/installer_bsplugins/src/TESData/PluginList.cpp b/libs/installer_bsplugins/src/TESData/PluginList.cpp
new file mode 100644
index 0000000..805261c
--- /dev/null
+++ b/libs/installer_bsplugins/src/TESData/PluginList.cpp
@@ -0,0 +1,1429 @@
+#include "PluginList.h"
+#include "FileConflictParser.h"
+#include "TESFile/Reader.h"
+
+#include <bsatk/bsatk.h>
+#include <game_features/gameplugins.h>
+#include <game_features/igamefeatures.h>
+#include <log.h>
+#include <safewritefile.h>
+#include <utility.h>
+
+#include <boost/container/flat_map.hpp>
+#include <boost/container/flat_set.hpp>
+#include <boost/thread/future.hpp>
+
+#include <QDir>
+#include <QFile>
+#include <QStringTokenizer>
+#include <QTextStream>
+
+#include <algorithm>
+#include <future>
+#include <iterator>
+#include <limits>
+#include <ranges>
+#include <semaphore>
+#include <thread>
+#include <tuple>
+#include <utility>
+
+using namespace Qt::Literals::StringLiterals;
+
+namespace TESData
+{
+
+#pragma region Constructor / Destructor
+
+PluginList::PluginList(const MOBase::IOrganizer* moInfo) : m_Organizer{moInfo}
+{
+ refresh(true);
+}
+
+PluginList::~PluginList() noexcept
+{
+ m_Refreshed.disconnect_all_slots();
+ m_PluginMoved.disconnect_all_slots();
+ m_PluginStateChanged.disconnect_all_slots();
+}
+
+#pragma endregion Constructor / Destructor
+#pragma region Plugin Access
+
+int PluginList::pluginCount() const
+{
+ return static_cast<int>(m_Plugins.size());
+}
+
+int PluginList::getIndex(const QString& pluginName) const
+{
+ const auto it = m_PluginsByName.find(pluginName);
+ return it != m_PluginsByName.end() ? it->second : -1;
+}
+
+int PluginList::getIndexAtPriority(int priority) const
+{
+ return m_PluginsByPriority.at(priority);
+}
+
+QString PluginList::getOriginName(int index) const
+{
+ if (index < 0 || index >= m_Plugins.size()) {
+ return QString();
+ }
+
+ const auto& name = m_Plugins[index]->name();
+ const auto origins = m_Organizer->getFileOrigins(name);
+ return !origins.isEmpty() ? origins.first() : QString();
+}
+
+const FileInfo* PluginList::getPlugin(int index) const
+{
+ if (index < 0 || index >= m_Plugins.size()) {
+ return nullptr;
+ }
+
+ return m_Plugins[index].get();
+}
+
+const FileInfo* PluginList::getPluginByName(const QString& name) const
+{
+ const auto it = m_PluginsByName.find(name);
+ if (it == m_PluginsByName.end()) {
+ return nullptr;
+ } else {
+ return m_Plugins.at(it->second).get();
+ }
+}
+
+const FileInfo* PluginList::getPluginByPriority(int priority) const
+{
+ if (priority < 0 || priority >= m_PluginsByPriority.size()) {
+ return nullptr;
+ }
+
+ return m_Plugins.at(m_PluginsByPriority[priority]).get();
+}
+
+FileInfo* PluginList::findPlugin(const QString& name)
+{
+ const auto it = m_PluginsByName.find(name);
+ if (it == m_PluginsByName.end()) {
+ return nullptr;
+ } else {
+ return m_Plugins.at(it->second).get();
+ }
+}
+
+const FileInfo* PluginList::findPlugin(const QString& name) const
+{
+ const auto it = m_PluginsByName.find(name);
+ if (it == m_PluginsByName.end()) {
+ return nullptr;
+ } else {
+ return m_Plugins.at(it->second).get();
+ }
+}
+
+#pragma endregion Plugin Access
+#pragma region Record Access
+
+FileEntry* PluginList::findEntryByName(const std::string& pluginName) const
+{
+ std::shared_lock lk{m_FileEntryMutex};
+ const auto it = m_EntriesByName.find(pluginName);
+ return it != m_EntriesByName.end() ? it->second.get() : nullptr;
+}
+
+FileEntry* PluginList::findEntryByHandle(TESFileHandle handle) const
+{
+ std::shared_lock lk{m_FileEntryMutex};
+ const auto it = m_EntriesByHandle.find(handle);
+ return it != m_EntriesByHandle.end() ? it->second.get() : nullptr;
+}
+
+AssociatedEntry* PluginList::findArchive(const QString& name) const
+{
+ std::shared_lock lk{m_ArchiveEntryMutex};
+ const auto it = m_Archives.find(name);
+ return it != m_Archives.end() ? it->second.get() : nullptr;
+}
+
+FileEntry* PluginList::createEntry(const std::string& name)
+{
+ std::unique_lock lk{m_FileEntryMutex};
+
+ const auto it = m_EntriesByName.find(name);
+ if (it != m_EntriesByName.end()) {
+ return it->second.get();
+ }
+
+ const auto entry = std::make_shared<FileEntry>(m_NextHandle++, name);
+ m_EntriesByHandle.emplace_hint(m_EntriesByHandle.cend(), entry->handle(), entry);
+ m_EntriesByName[name] = entry;
+ return entry.get();
+}
+
+void PluginList::addRecordConflict(const std::string& pluginName,
+ const RecordPath& path, TESFile::Type type,
+ const std::string& name)
+{
+ const auto& master = path.hasFormId() ? path.files()[path.formId() >> 24] : "";
+ const auto owner = createEntry(master);
+ const auto record = owner->createRecord(path, name, type);
+ if (pluginName != master) {
+ const auto entry = createEntry(pluginName);
+ entry->addRecord(path, name, type, record);
+ }
+}
+
+void PluginList::addGroupPlaceholder(const std::string& pluginName,
+ const RecordPath& path)
+{
+ const auto group = path.groups().back();
+ const auto& master = group.hasParent() ? path.files()[group.parent() >> 24] : "";
+ if (const auto owner = findEntryByName(master)) {
+ owner->addChildGroup(path);
+ }
+ if (pluginName != master) {
+ if (const auto entry = findEntryByName(pluginName)) {
+ entry->addChildGroup(path);
+ }
+ }
+}
+
+#pragma endregion Record Access
+#pragma region List Management
+
+QString PluginList::groupsPath() const
+{
+ const auto profilePath = QDir(m_Organizer->profilePath());
+ if (profilePath.isEmpty()) {
+ return QString();
+ }
+
+ return QDir::cleanPath(profilePath.absoluteFilePath(u"plugingroups.txt"_s));
+}
+
+QString PluginList::lockedOrderPath() const
+{
+ const auto profilePath = QDir(m_Organizer->profilePath());
+ if (profilePath.isEmpty()) {
+ return QString();
+ }
+
+ return QDir::cleanPath(profilePath.absoluteFilePath(u"lockedorder.txt"_s));
+}
+
+void PluginList::refresh(bool invalidate)
+{
+ MOBase::TimeThis tt{"TESData::PluginList::refresh()"};
+
+ m_Refreshing = true;
+ scanDataFiles(invalidate);
+ readPluginLists();
+
+ if (const auto groupsFile = groupsPath(); !groupsFile.isEmpty()) {
+ readGroups(groupsFile);
+ }
+
+ computeCompileIndices();
+ refreshLoadOrder();
+ dispatchPluginStateChanges();
+ testMasters();
+
+ if (const auto lockedOrderFile = lockedOrderPath(); !lockedOrderFile.isEmpty()) {
+ writeEmptyTextFile(lockedOrderFile);
+ }
+
+ m_Refreshing = false;
+ m_Refreshed();
+}
+
+void PluginList::setEnabled(int id, bool enable)
+{
+ const auto plugin = m_Plugins.at(id);
+
+ const bool enabled = plugin->enabled();
+ const bool shouldEnable =
+ (enable && !plugin->forceDisabled()) || plugin->isAlwaysEnabled();
+
+ if (shouldEnable != enabled) {
+ plugin->setEnabled(shouldEnable);
+ computeCompileIndices();
+ refreshLoadOrder();
+ pluginStatesChanged({plugin->name()}, shouldEnable ? STATE_ACTIVE : STATE_INACTIVE);
+ testMasters();
+ }
+}
+
+void PluginList::setEnabled(const std::vector<int>& ids, bool enable)
+{
+ QStringList changed;
+ for (const int id : ids) {
+ const auto plugin = m_Plugins.at(id);
+
+ const bool enabled = plugin->enabled();
+ const bool shouldEnable =
+ (enable && !plugin->forceDisabled()) || plugin->isAlwaysEnabled();
+
+ if (shouldEnable != enabled) {
+ plugin->setEnabled(shouldEnable);
+ changed.append(plugin->name());
+ }
+ }
+
+ if (!changed.isEmpty()) {
+ computeCompileIndices();
+ refreshLoadOrder();
+ pluginStatesChanged(changed, enable ? STATE_ACTIVE : STATE_INACTIVE);
+ testMasters();
+ }
+}
+
+void PluginList::toggleState(const std::vector<int>& ids)
+{
+ QStringList active;
+ QStringList inactive;
+ for (const int id : ids) {
+ const auto plugin = m_Plugins.at(id);
+
+ const bool enabled = plugin->enabled();
+ const bool shouldEnable =
+ (!enabled && !plugin->forceDisabled()) || plugin->isAlwaysEnabled();
+
+ if (shouldEnable != enabled) {
+ plugin->setEnabled(shouldEnable);
+ if (shouldEnable) {
+ active.append(plugin->name());
+ } else {
+ inactive.append(plugin->name());
+ }
+ }
+ }
+
+ if (!active.isEmpty() || !inactive.isEmpty()) {
+ computeCompileIndices();
+ refreshLoadOrder();
+ if (!active.isEmpty()) {
+ pluginStatesChanged(active, STATE_ACTIVE);
+ }
+ if (!inactive.isEmpty()) {
+ pluginStatesChanged(inactive, STATE_INACTIVE);
+ }
+ testMasters();
+ }
+}
+
+bool PluginList::canMoveToPriority(const std::vector<int>& ids, int newPriority) const
+{
+ boost::container::flat_set<QString, MOBase::FileNameComparator> names;
+ names.reserve(ids.size());
+ for (const int id : ids) {
+ const auto plugin = m_Plugins[id];
+ names.insert(plugin->name());
+ }
+
+ for (const int id : ids) {
+ const auto pluginToMove = m_Plugins[id];
+ const int priority = pluginToMove->priority();
+
+ if (pluginToMove->forceLoaded()) {
+ const int min = std::min(priority, newPriority);
+ const int max = std::max(priority, newPriority);
+ for (int i = min; i < max; ++i) {
+ if (std::ranges::find(ids, m_PluginsByPriority.at(i)) == std::end(ids)) {
+ return false;
+ }
+ }
+ }
+
+ for (int i = newPriority; i < priority; ++i) {
+ const auto plugin = m_Plugins.at(m_PluginsByPriority.at(i));
+ if (!names.contains(plugin->name()) && pluginToMove->mustLoadAfter(*plugin)) {
+ return false;
+ }
+ }
+
+ for (int i = priority + 1; i < newPriority; ++i) {
+ const auto plugin = m_Plugins.at(m_PluginsByPriority.at(i));
+ if (!names.contains(plugin->name()) && plugin->mustLoadAfter(*pluginToMove)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+QString PluginList::destinationGroup(
+ int oldPriority, int newPriority, const QString& originalGroup, bool isESM,
+ const boost::container::flat_set<QString, MOBase::FileNameComparator>& exclusions)
+{
+ const auto findPrevious = [&, this](int priority) -> FileInfo* {
+ for (int i = priority - 1; i >= 0; --i) {
+ const auto& plugin = m_Plugins.at(m_PluginsByPriority[i]);
+ if (!exclusions.contains(plugin->name())) {
+ return plugin.get();
+ }
+ }
+ return nullptr;
+ };
+
+ const auto findNext = [&, this](int priority) -> FileInfo* {
+ for (int i = priority + 1; i < m_PluginsByPriority.size(); ++i) {
+ const auto& plugin = m_Plugins.at(m_PluginsByPriority[i]);
+ if (!exclusions.contains(plugin->name())) {
+ return plugin.get();
+ }
+ }
+ return nullptr;
+ };
+
+ bool removedFromGroup = false;
+ if (!originalGroup.isEmpty()) {
+ if (const auto previous = findPrevious(oldPriority)) {
+ if (previous->group() == originalGroup && previous->isMasterFile() == isESM) {
+ removedFromGroup = true;
+ }
+ }
+
+ if (const auto next = findNext(oldPriority)) {
+ if (next->group() == originalGroup && next->isMasterFile() == isESM) {
+ removedFromGroup = true;
+ }
+ }
+ }
+
+ QString displacedGroup;
+ if (const auto displaced = m_Plugins.at(m_PluginsByPriority[newPriority])) {
+ displacedGroup = displaced->group();
+ }
+
+ QString neighborGroup;
+ if (newPriority < oldPriority) {
+ if (const auto neighbor = findPrevious(newPriority)) {
+ neighborGroup = neighbor->group();
+ }
+ } else if (newPriority > oldPriority) {
+ if (const auto neighbor = findNext(newPriority)) {
+ neighborGroup = neighbor->group();
+ }
+ }
+
+ if (!displacedGroup.isEmpty() && neighborGroup == displacedGroup) {
+ return displacedGroup;
+ }
+
+ if (!removedFromGroup || displacedGroup == originalGroup) {
+ return originalGroup;
+ }
+
+ return QString();
+}
+
+void PluginList::moveToPriority(std::vector<int> ids, int destination, bool disjoint)
+{
+ if (ids.empty()) {
+ return;
+ }
+
+ destination = std::max(destination, 0);
+ destination = std::min(destination, pluginCount());
+
+ boost::container::flat_set<QString, MOBase::FileNameComparator> names;
+ names.reserve(ids.size());
+ boost::container::flat_map<QString, int> priorities;
+ priorities.reserve(ids.size());
+ for (const int id : ids) {
+ const auto plugin = m_Plugins.at(id);
+ names.insert(plugin->name());
+ priorities.emplace(plugin->name(), plugin->priority());
+ }
+
+ std::ranges::sort(ids, [this](int lhs, int rhs) {
+ return m_Plugins[lhs]->priority() > m_Plugins[rhs]->priority();
+ });
+
+ int nextDestination = destination;
+ for (const int id : ids) {
+ const auto& pluginToMove = m_Plugins[id];
+ const int priority = pluginToMove->priority();
+
+ if (nextDestination < priority) {
+ for (int i = priority - 1; i >= nextDestination; --i) {
+ const auto& plugin = m_Plugins.at(m_PluginsByPriority.at(i));
+ if (!names.contains(plugin->name()) && pluginToMove->mustLoadAfter(*plugin)) {
+ nextDestination = i + 1;
+ break;
+ }
+ }
+
+ const int newPriority = nextDestination;
+ pluginToMove->setGroup(destinationGroup(priority, newPriority,
+ pluginToMove->group(),
+ pluginToMove->isMasterFile(), names));
+ for (int i = priority; i > nextDestination; --i) {
+ m_PluginsByPriority[i] = m_PluginsByPriority[i - 1];
+ m_Plugins[m_PluginsByPriority[i]]->setPriority(i);
+ }
+
+ m_PluginsByPriority[newPriority] = id;
+ pluginToMove->setPriority(newPriority);
+ } else if (nextDestination > priority) {
+ for (int i = priority + 1; i < nextDestination; ++i) {
+ const auto& plugin = m_Plugins.at(m_PluginsByPriority.at(i));
+ if (!names.contains(plugin->name()) && plugin->mustLoadAfter(*pluginToMove)) {
+ nextDestination = i;
+ break;
+ }
+ }
+
+ const int newPriority = --nextDestination;
+ pluginToMove->setGroup(destinationGroup(priority, newPriority,
+ pluginToMove->group(),
+ pluginToMove->isMasterFile(), names));
+ for (int i = priority; i < newPriority; ++i) {
+ m_PluginsByPriority[i] = m_PluginsByPriority[i + 1];
+ m_Plugins[m_PluginsByPriority[i]]->setPriority(i);
+ }
+
+ m_PluginsByPriority[newPriority] = id;
+ pluginToMove->setPriority(newPriority);
+ }
+
+ if (disjoint) {
+ nextDestination = destination;
+ }
+ }
+
+ computeCompileIndices();
+ refreshLoadOrder();
+
+ boost::container::flat_map<int, std::tuple<QString, int>> movedUp;
+ boost::container::flat_map<int, std::tuple<QString, int>, std::greater<int>>
+ movedDown;
+
+ for (const int id : ids) {
+ const auto& plugin = m_Plugins[id];
+ const auto& name = plugin->name();
+ const int oldPriority = priorities[name];
+ const int newPriority = plugin->priority();
+ if (newPriority < oldPriority) {
+ movedUp[oldPriority] = std::make_tuple(name, newPriority);
+ } else if (newPriority > oldPriority) {
+ movedDown[oldPriority] = std::make_tuple(name, newPriority);
+ }
+ }
+
+ for (auto& [oldPriority, moveInfo] : movedDown) {
+ auto& [name, newPriority] = moveInfo;
+ m_PluginMoved(name, oldPriority, newPriority);
+ }
+
+ for (auto& [oldPriority, moveInfo] : movedUp) {
+ auto& [name, newPriority] = moveInfo;
+ m_PluginMoved(name, oldPriority, newPriority);
+ }
+}
+
+void PluginList::shiftPriority(const std::vector<int>& ids, int offset)
+{
+ if (offset < 0) {
+ const int minPriority =
+ std::ranges::min(std::ranges::transform_view(ids, [this](auto&& id) {
+ return m_Plugins.at(id)->priority();
+ }));
+ moveToPriority(ids, minPriority + offset);
+ } else if (offset > 0) {
+ const int maxPriority =
+ std::ranges::max(std::ranges::transform_view(ids, [this](auto&& id) {
+ return m_Plugins.at(id)->priority();
+ }));
+ moveToPriority(ids, maxPriority + 1 + offset);
+ }
+}
+
+void PluginList::setGroup(const std::vector<int>& ids, const QString& group)
+{
+ for (const int id : ids) {
+ m_Plugins.at(id)->setGroup(group);
+ }
+
+ if (const auto groupsFile = groupsPath(); !groupsFile.isEmpty()) {
+ writeGroups(groupsFile);
+ }
+}
+
+QStringList PluginList::loadOrder() const
+{
+ QStringList result;
+
+ for (const int id : m_PluginsByPriority) {
+ result.append(m_Plugins.at(id)->name());
+ }
+
+ return result;
+}
+
+void PluginList::notifyPendingState(const QString& mod,
+ MOBase::IModList::ModStates state)
+{
+ if (state & MOBase::IModList::STATE_ACTIVE) {
+ m_PendingActive.insert(mod);
+ } else {
+ m_PendingActive.erase(mod);
+ }
+}
+
+void PluginList::flushPendingStates()
+{
+ m_PendingActive.clear();
+}
+
+#pragma endregion List Management
+#pragma region IPluginList
+
+QStringList PluginList::pluginNames() const
+{
+ QStringList result;
+
+ for (const auto& info : m_Plugins) {
+ result.append(info->name());
+ }
+
+ return result;
+}
+
+MOBase::IPluginList::PluginStates PluginList::state(const QString& name) const
+{
+ const auto plugin = findPlugin(name);
+ if (!plugin) {
+ return IPluginList::STATE_MISSING;
+ } else {
+ return plugin->enabled() ? IPluginList::STATE_ACTIVE : IPluginList::STATE_INACTIVE;
+ }
+}
+
+void PluginList::setState(const QString& name, PluginStates state)
+{
+ const auto it = m_PluginsByName.find(name);
+ if (it == m_PluginsByName.end()) {
+ return;
+ }
+
+ const auto plugin = m_Plugins.at(it->second).get();
+
+ if (state == STATE_MISSING) {
+ m_Plugins.erase(m_Plugins.begin() + it->second);
+ } else {
+ const bool enabled = plugin->enabled();
+ const bool shouldEnable = (state == STATE_ACTIVE && !plugin->forceDisabled()) ||
+ plugin->isAlwaysEnabled();
+ if (shouldEnable == enabled) {
+ return;
+ }
+ plugin->setEnabled(shouldEnable);
+ }
+
+ queuePluginStateChange(plugin->name(), state);
+}
+
+int PluginList::priority(const QString& name) const
+{
+ const auto plugin = findPlugin(name);
+ return plugin ? plugin->priority() : -1;
+}
+
+bool PluginList::setPriority(const QString& name, int newPriority)
+{
+ if (newPriority < 0 || newPriority >= static_cast<int>(m_PluginsByPriority.size())) {
+ MOBase::log::warn("Requested priority for \"{}\" out of range: {}", name,
+ newPriority);
+ return false;
+ }
+
+ const int oldPriority = priority(name);
+ if (oldPriority == -1) {
+ return false;
+ }
+
+ const int rowIndex = m_PluginsByPriority.at(oldPriority);
+
+ int destination = newPriority;
+ if (newPriority > oldPriority) {
+ ++destination;
+ }
+ moveToPriority({rowIndex}, destination);
+
+ return true;
+}
+
+int PluginList::loadOrder(const QString& name) const
+{
+ const auto plugin = findPlugin(name);
+ return plugin ? plugin->loadOrder() : -1;
+}
+
+void PluginList::setLoadOrder(const QStringList& pluginList)
+{
+ for (const auto& info : m_Plugins) {
+ info->setPriority(-1);
+ }
+
+ int maxPriority = 0;
+ for (const QString& pluginName : pluginList) {
+ const auto plugin = const_cast<TESData::FileInfo*>(findPlugin(pluginName));
+ if (plugin != nullptr) {
+ plugin->setPriority(maxPriority++);
+ }
+ }
+
+ for (const auto& info : m_Plugins) {
+ if (info->priority() == -1) {
+ info->setPriority(maxPriority++);
+ }
+ }
+
+ updateCache();
+}
+
+bool PluginList::isMaster(const QString& name) const
+{
+ return isMasterFlagged(name);
+}
+
+QStringList PluginList::masters(const QString& name) const
+{
+ const auto plugin = findPlugin(name);
+ QStringList result;
+ if (plugin != nullptr) {
+ for (const QString& master : plugin->masters()) {
+ result.append(master);
+ }
+ }
+ return result;
+}
+
+QString PluginList::origin(const QString& name) const
+{
+ if (m_PluginsByName.find(name) == m_PluginsByName.end()) {
+ return QString();
+ }
+
+ const auto origins = m_Organizer->getFileOrigins(name);
+ return !origins.isEmpty() ? origins.first() : QString();
+}
+
+bool PluginList::onRefreshed(const std::function<void()>& callback)
+{
+ auto connection = m_Refreshed.connect(callback);
+ return connection.connected();
+}
+
+bool PluginList::onPluginMoved(
+ const std::function<void(const QString&, int, int)>& func)
+{
+ auto connection = m_PluginMoved.connect(func);
+ return connection.connected();
+}
+
+bool PluginList::onPluginStateChanged(
+ const std::function<void(const std::map<QString, PluginStates>&)>& func)
+{
+ auto connection = m_PluginStateChanged.connect(func);
+ return connection.connected();
+}
+
+bool PluginList::hasMasterExtension(const QString& name) const
+{
+ const auto plugin = findPlugin(name);
+ return plugin ? plugin->hasMasterExtension() : false;
+}
+
+bool PluginList::hasLightExtension(const QString& name) const
+{
+ const auto plugin = findPlugin(name);
+ return plugin ? plugin->hasLightExtension() : false;
+}
+
+bool PluginList::isMasterFlagged(const QString& name) const
+{
+ const auto plugin = findPlugin(name);
+ return plugin ? plugin->isMasterFlagged() : false;
+}
+
+bool PluginList::isMediumFlagged(const QString& name) const
+{
+ const auto plugin = findPlugin(name);
+ return plugin ? plugin->isMediumFlagged() : false;
+}
+
+bool PluginList::isLightFlagged(const QString& name) const
+{
+ const auto plugin = findPlugin(name);
+ return plugin ? plugin->isLightFlagged() : false;
+}
+
+bool PluginList::isBlueprintFlagged(const QString& name) const
+{
+ const auto plugin = findPlugin(name);
+ return plugin ? plugin->isBlueprintFlagged() : false;
+}
+
+bool PluginList::hasNoRecords(const QString& name) const
+{
+ const auto plugin = findPlugin(name);
+ return plugin ? plugin->hasNoRecords() : false;
+}
+
+int PluginList::formVersion(const QString& name) const
+{
+ const auto plugin = findPlugin(name);
+ return plugin ? plugin->formVersion() : 0;
+}
+
+float PluginList::headerVersion(const QString& name) const
+{
+ const auto plugin = findPlugin(name);
+ return plugin ? plugin->headerVersion() : 0.0;
+}
+
+QString PluginList::author(const QString& name) const
+{
+ const auto plugin = findPlugin(name);
+ return plugin ? plugin->author() : QString();
+}
+
+QString PluginList::description(const QString& name) const
+{
+ const auto plugin = findPlugin(name);
+ return plugin ? plugin->description() : QString();
+}
+
+#pragma endregion IPluginList
+#pragma region ILootCache
+
+void PluginList::clearAdditionalInformation()
+{
+ m_LootInfo.clear();
+}
+
+void PluginList::addLootReport(const QString& name, MOTools::Loot::Plugin plugin)
+{
+ const auto it = m_PluginsByName.find(name);
+ if (it != m_PluginsByName.end()) {
+ m_LootInfo[name] = std::move(plugin);
+ } else {
+ MOBase::log::warn("failed to associate loot report for \"{}\"", name);
+ }
+}
+
+const MOTools::Loot::Plugin* PluginList::getLootReport(const QString& name) const
+{
+ const auto it = m_LootInfo.find(name);
+ return it != m_LootInfo.end() ? &it->second : nullptr;
+}
+
+#pragma endregion ILootCache
+#pragma region Slots
+
+void PluginList::writePluginLists() const
+{
+ const auto gameFeatures = m_Organizer->gameFeatures();
+ const auto tesSupport =
+ gameFeatures ? gameFeatures->gameFeature<MOBase::GamePlugins>() : nullptr;
+ if (tesSupport) {
+ tesSupport->writePluginLists(this);
+ }
+
+ if (const auto groupsFile = groupsPath(); !groupsFile.isEmpty()) {
+ writeGroups(groupsFile);
+ }
+}
+
+#pragma endregion Slots
+#pragma region Helpers
+
+static bool isPluginFile(const QString& filename)
+{
+ return filename.endsWith(u".esp"_s, Qt::CaseInsensitive) ||
+ filename.endsWith(u".esm"_s, Qt::CaseInsensitive) ||
+ filename.endsWith(u".esl"_s, Qt::CaseInsensitive);
+}
+
+enum class Game
+{
+ Skyrim,
+ Fallout4,
+ Starfield,
+ Other
+};
+
+static bool isAssociatedArchive(TESData::FileInfo& info, const QString& candidate,
+ Game game)
+{
+ const QString baseName = QFileInfo(info.name()).completeBaseName();
+ if (!candidate.startsWith(baseName)) {
+ return false;
+ }
+
+ switch (game) {
+ case Game::Skyrim:
+ if (candidate.endsWith(u".bsa"_s)) {
+ if (candidate.compare(baseName + u".bsa"_s, Qt::CaseInsensitive) == 0 ||
+ candidate.compare(baseName + u" - Textures.bsa"_s, Qt::CaseInsensitive) ==
+ 0) {
+ return true;
+ }
+ }
+ return false;
+
+ case Game::Fallout4:
+ case Game::Starfield:
+ if (candidate.endsWith(u".ba2"_s)) {
+ if (candidate.compare(baseName + u" - Main.ba2"_s, Qt::CaseInsensitive) == 0 ||
+ candidate.compare(baseName + u" - Textures.ba2"_s, Qt::CaseInsensitive) ==
+ 0 ||
+ candidate.startsWith(baseName + u" - Voices_"_s, Qt::CaseInsensitive)) {
+ return true;
+ }
+ }
+ return false;
+
+ default:
+ return candidate.endsWith(u".bsa"_s);
+ }
+}
+
+void PluginList::checkBsa(TESData::FileInfo& info,
+ const std::shared_ptr<const MOBase::IFileTree>& fileTree)
+{
+ const auto managedGame = m_Organizer->managedGame();
+ const auto gameName = managedGame ? managedGame->gameName() : QString();
+ const Game game = gameName.startsWith(u"Skyrim"_s) ? Game::Skyrim
+ : gameName.startsWith(u"Enderal"_s) ? Game::Skyrim
+ : gameName.startsWith(u"Fallout 4"_s) ? Game::Fallout4
+ : gameName == u"Starfield"_s ? Game::Starfield
+ : Game::Other;
+
+ const QString baseName = QFileInfo(info.name()).completeBaseName();
+ for (const auto entry : *fileTree) {
+ if (!entry) {
+ continue;
+ }
+
+ const auto candidate = entry->name();
+ if (isAssociatedArchive(info, candidate, game)) {
+ info.addArchive(candidate);
+ associateArchive(info, candidate);
+ }
+ }
+}
+
+static void checkIni(TESData::FileInfo& info,
+ const std::shared_ptr<const MOBase::IFileTree>& fileTree)
+{
+ QString baseName = QFileInfo(info.name()).completeBaseName();
+ QString iniPath = baseName + u".ini"_s;
+ if (fileTree->find(iniPath, MOBase::FileTreeEntry::FILE) != nullptr) {
+ info.setHasIni(true);
+ }
+}
+
+static void assignConsecutivePriorities(std::vector<std::shared_ptr<FileInfo>>& plugins)
+{
+ boost::container::flat_multimap<int, int> priorityToId;
+ for (int i = 0; i < plugins.size(); ++i) {
+ int priority = plugins[i]->priority();
+ if (priority == -1) {
+ priority = std::numeric_limits<int>::max();
+ }
+ priorityToId.emplace(priority, i);
+ }
+
+ int newPriority = 0;
+ for (auto& [priority, id] : priorityToId) {
+ plugins[id]->setPriority(newPriority++);
+ }
+}
+
+void PluginList::scanDataFiles(bool invalidate)
+{
+ if (invalidate) {
+ m_Plugins.clear();
+ m_PluginsByName.clear();
+ m_PluginsByPriority.clear();
+
+ m_EntriesByName.clear();
+ m_EntriesByHandle.clear();
+ m_NextHandle = 0;
+
+ m_MasterArchiveEntry = std::make_shared<AssociatedEntry>();
+ m_Archives.clear();
+ }
+
+ const auto managedGame = m_Organizer->managedGame();
+ const auto gameFeatures = m_Organizer->gameFeatures();
+
+ const QStringList primaryPlugins =
+ managedGame ? managedGame->primaryPlugins() : QStringList();
+ const QStringList enabledPlugins =
+ managedGame ? managedGame->enabledPlugins() : QStringList();
+ const auto loadOrderMechanism = managedGame
+ ? managedGame->loadOrderMechanism()
+ : MOBase::IPluginGame::LoadOrderMechanism::None;
+
+ const auto tesSupport =
+ gameFeatures ? gameFeatures->gameFeature<MOBase::GamePlugins>() : nullptr;
+
+ const bool lightPluginsAreSupported =
+ tesSupport && tesSupport->lightPluginsAreSupported();
+ const bool mediumPluginsAreSupported =
+ tesSupport && tesSupport->mediumPluginsAreSupported();
+ const bool blueprintPluginsAreSupported =
+ tesSupport && tesSupport->blueprintPluginsAreSupported();
+
+ QStringList availablePlugins;
+
+ const auto tree = m_Organizer->virtualFileTree();
+ for (const std::shared_ptr<const MOBase::FileTreeEntry> entry : *tree) {
+ if (entry == nullptr) {
+ continue;
+ }
+
+ const QString filename = entry->name();
+
+ if (!isPluginFile(filename)) {
+ continue;
+ }
+
+ if (m_Organizer->resolvePath(filename).isEmpty()) {
+ continue;
+ }
+
+ availablePlugins.append(filename);
+ }
+
+ for (const auto& modName : m_PendingActive) {
+ const auto modInterface = m_Organizer->modList()->getMod(modName);
+ const auto fileTree = modInterface ? modInterface->fileTree() : nullptr;
+
+ if (!fileTree)
+ continue;
+
+ std::shared_ptr<const MOBase::IFileTree> searchDir;
+ if (!m_Organizer->managedGame()->modDataDirectory().isEmpty()) {
+ searchDir =
+ fileTree->findDirectory(m_Organizer->managedGame()->modDataDirectory());
+ } else {
+ searchDir = fileTree;
+ }
+ if (searchDir) {
+ for (auto&& entry : *searchDir) {
+ if (entry && isPluginFile(entry->name()) &&
+ !availablePlugins.contains(entry->name(), Qt::CaseInsensitive)) {
+ availablePlugins.append(entry->name());
+ }
+ }
+ }
+ }
+
+ // Fluorine port: use ALL cores + force true async (upstream used hw/2 +
+ // default launch policy which libstdc++ may run as deferred → sequential).
+ const uint concurrency = std::max(1U, std::thread::hardware_concurrency());
+ std::counting_semaphore smph{concurrency};
+
+ std::vector<std::shared_future<void>> futures;
+ for (const auto& filename : availablePlugins) {
+ if (!invalidate && m_PluginsByName.contains(filename)) {
+ continue;
+ }
+
+ const bool forceLoaded = primaryPlugins.contains(filename, Qt::CaseInsensitive);
+ const bool forceEnabled = enabledPlugins.contains(filename, Qt::CaseInsensitive);
+ const bool forceDisabled =
+ !forceLoaded && !forceEnabled &&
+ (loadOrderMechanism == MOBase::IPluginGame::LoadOrderMechanism::None);
+
+ const QString fullPath = m_Organizer->resolvePath(filename);
+
+ const auto& info = m_Plugins.emplace_back(
+ std::make_shared<FileInfo>(this, filename, forceLoaded, forceEnabled,
+ forceDisabled, lightPluginsAreSupported));
+
+ auto assocTask = std::async(std::launch::async, [=, &smph] {
+ smph.acquire();
+ checkBsa(*info, tree);
+ checkIni(*info, tree);
+ smph.release();
+ });
+
+ auto fileTask = std::async(
+ std::launch::async, [=, this, &smph, path = fullPath.toStdString()] {
+ smph.acquire();
+
+ try {
+ FileConflictParser handler{this, info.get(), lightPluginsAreSupported,
+ mediumPluginsAreSupported,
+ blueprintPluginsAreSupported};
+ TESFile::Reader<FileConflictParser> reader{};
+ // Fluorine port: use UTF-8 bytes (upstream used toStdWString which
+ // silently mangles paths through the C locale on Linux). Also fall
+ // back to case-insensitive directory lookup — IOrganizer::resolvePath
+ // returns paths with the user's original casing, while the file on
+ // disk may differ (Windows mods unpacked on case-sensitive Linux).
+ std::filesystem::path fspath{path};
+ if (!std::filesystem::exists(fspath)) {
+ const auto parent = fspath.parent_path();
+ const auto wantName = fspath.filename().string();
+ if (std::filesystem::exists(parent)) {
+ for (const auto& entry : std::filesystem::directory_iterator{parent}) {
+ const auto name = entry.path().filename().string();
+ if (name.size() == wantName.size() &&
+ std::equal(name.begin(), name.end(), wantName.begin(),
+ [](unsigned char a, unsigned char b) {
+ return std::tolower(a) == std::tolower(b);
+ })) {
+ fspath = entry.path();
+ break;
+ }
+ }
+ }
+ }
+ reader.parse(fspath, handler);
+ } catch (const std::exception& e) {
+ MOBase::log::error("Error parsing \"{}\": {}", path, e.what());
+ }
+
+ smph.release();
+ });
+
+ futures.push_back(assocTask.share());
+ futures.push_back(fileTask.share());
+ }
+
+ boost::wait_for_all(futures.begin(), futures.end());
+
+ if (!invalidate) {
+ std::erase_if(m_Plugins, [&](auto&& plugin) {
+ return !plugin || !availablePlugins.contains(plugin->name(), Qt::CaseInsensitive);
+ });
+ }
+
+ assignConsecutivePriorities(m_Plugins);
+ updateCache();
+}
+
+void PluginList::readPluginLists()
+{
+ const auto gameFeatures = m_Organizer->gameFeatures();
+ const auto tesSupport =
+ gameFeatures ? gameFeatures->gameFeature<MOBase::GamePlugins>() : nullptr;
+
+ if (tesSupport) {
+ tesSupport->readPluginLists(this);
+ }
+
+ enforcePluginRelationships();
+}
+
+static void populateArchiveFiles(const std::shared_ptr<AuxItem>& master,
+ const std::shared_ptr<AuxItem>& entry,
+ const BSA::Folder::Ptr& archiveFolder,
+ TESFileHandle handle)
+{
+ for (unsigned int i = 0, num = archiveFolder->getNumFiles(); i < num; ++i) {
+ const auto file = archiveFolder->getFile(i);
+
+ const auto conflictItem =
+ master->insert(file->getName())->createMember(file->getFilePath());
+ conflictItem->alternatives.insert(handle);
+ entry->insert(file->getName())->setMember(conflictItem);
+ }
+
+ for (unsigned int i = 0, num = archiveFolder->getNumSubFolders(); i < num; ++i) {
+ const auto folder = archiveFolder->getSubFolder(i);
+ populateArchiveFiles(master->insert(folder->getName()),
+ entry->insert(folder->getName()), folder, handle);
+ }
+}
+
+void PluginList::associateArchive(const TESData::FileInfo& info,
+ const QString& archiveName)
+{
+ const auto archiveEntry = [this, &archiveName] {
+ std::unique_lock lk{m_ArchiveEntryMutex};
+ auto& entry = m_Archives[archiveName];
+ if (entry == nullptr) {
+ entry = std::make_shared<AssociatedEntry>();
+ }
+ return entry;
+ }();
+
+ const QString archivePath = m_Organizer->resolvePath(archiveName);
+ if (archivePath.isEmpty()) {
+ return;
+ }
+
+ BSA::Archive archive;
+ BSA::EErrorCode result = BSA::ERROR_NONE;
+
+ try {
+ result = archive.read(qPrintable(archivePath), false);
+ } catch (const std::exception& e) {
+ MOBase::log::error("invalid bsa '{}', error {}", archivePath, e.what());
+ return;
+ }
+
+ if (result != BSA::ERROR_NONE && result != BSA::ERROR_INVALIDHASHES) {
+ MOBase::log::error("invalid bsa '{}', error {}", archivePath, result);
+ return;
+ }
+
+ const auto handle = createEntry(info.name().toStdString())->handle();
+ populateArchiveFiles(m_MasterArchiveEntry->root(), archiveEntry->root(),
+ archive.getRoot(), handle);
+}
+
+void PluginList::clearGroups()
+{
+ for (const auto& plugin : m_Plugins) {
+ plugin->setGroup(QString());
+ }
+}
+
+void PluginList::readGroups(const QString& fileName)
+{
+ clearGroups();
+
+ QFile file{fileName};
+ if (!file.exists()) {
+ return;
+ }
+
+ int lineNumber = 0;
+ if (!file.open(QFile::ReadOnly)) {
+ return;
+ }
+
+ QTextStream stream{&file};
+
+ QString line;
+ while (stream.readLineInto(&line)) {
+ ++lineNumber;
+
+ if (line.length() <= 0 || line.at(0) == u'#') {
+ continue;
+ }
+
+ const auto fields = line.split(u'|');
+ if (fields.count() != 2) {
+ MOBase::log::error("plugin groups file: invalid line #{}: {}", lineNumber, line);
+ continue;
+ }
+
+ if (const auto it = m_PluginsByName.find(fields[0]); it != m_PluginsByName.end()) {
+ const auto& plugin = m_Plugins.at(it->second);
+ plugin->setGroup(fields[1]);
+ }
+ }
+
+ file.close();
+}
+
+void PluginList::writeEmptyTextFile(const QString& fileName) const
+{
+ MOBase::SafeWriteFile file{fileName};
+
+ file->resize(0);
+ file->write("# This file was automatically generated by Mod Organizer.\r\n"_ba);
+ file->commit();
+}
+
+void PluginList::writeGroups(const QString& fileName) const
+{
+ MOBase::SafeWriteFile file{fileName};
+
+ file->resize(0);
+ file->write("# This file was automatically generated by Mod Organizer.\r\n"_ba);
+ for (const auto& [name, i] : m_PluginsByName) {
+ const auto& plugin = m_Plugins.at(i);
+ const auto& group = plugin->group();
+ if (!group.isEmpty()) {
+ file->write(u"%1|%2\r\n"_s.arg(name).arg(group).toUtf8());
+ }
+ }
+
+ file->commit();
+}
+
+void PluginList::queuePluginStateChange(const QString& pluginName, PluginStates state)
+{
+ if (m_Refreshing) {
+ m_QueuedStateChanges[pluginName] = state;
+ } else {
+ computeCompileIndices();
+ refreshLoadOrder();
+ pluginStatesChanged({pluginName}, state);
+ testMasters();
+ }
+}
+
+void PluginList::dispatchPluginStateChanges()
+{
+ if (!m_QueuedStateChanges.empty()) {
+ m_PluginStateChanged(m_QueuedStateChanges);
+ m_QueuedStateChanges.clear();
+ }
+}
+
+void PluginList::pluginStatesChanged(const QStringList& pluginNames,
+ PluginStates state) const
+{
+ if (pluginNames.isEmpty()) {
+ return;
+ }
+
+ std::map<QString, IPluginList::PluginStates> infos;
+ for (const auto& name : pluginNames) {
+ infos[name] = state;
+ }
+
+ m_PluginStateChanged(infos);
+}
+
+void PluginList::enforcePluginRelationships()
+{
+ MOBase::TimeThis tt{"TESData::PluginList::enforcePluginRelationships"};
+
+ for (int i = 0; i < m_PluginsByPriority.size(); ++i) {
+ const int& firstIndex = m_PluginsByPriority[i];
+
+ for (int j = i + 1; j < m_PluginsByPriority.size(); ++j) {
+ const int secondIndex = m_PluginsByPriority[j];
+ const auto& firstPlugin = m_Plugins.at(firstIndex);
+ const auto& secondPlugin = m_Plugins.at(secondIndex);
+
+ if (firstPlugin->mustLoadAfter(*secondPlugin)) {
+ for (int k = j; k > i + 1; --k) {
+ m_PluginsByPriority[k] = m_PluginsByPriority[k - 1];
+ m_Plugins[m_PluginsByPriority[k]]->setPriority(k);
+ }
+ m_PluginsByPriority[i + 1] = firstIndex;
+ firstPlugin->setPriority(i + 1);
+ m_PluginsByPriority[i] = secondIndex;
+ secondPlugin->setPriority(i);
+ j = i;
+ }
+ }
+ }
+
+ computeCompileIndices();
+ refreshLoadOrder();
+}
+
+void PluginList::testMasters()
+{
+ boost::container::flat_set<QString, MOBase::FileNameComparator> enabledMasters;
+ for (const auto& plugin : m_Plugins) {
+ if (plugin->enabled()) {
+ enabledMasters.insert(plugin->name());
+ }
+ }
+
+ for (auto& plugin : m_Plugins) {
+ std::vector<QString> missingMasters;
+ std::ranges::remove_copy_if(plugin->masters(), std::back_inserter(missingMasters),
+ [&](auto&& file) {
+ return enabledMasters.contains(file);
+ });
+ plugin->setMissingMasters(missingMasters);
+ }
+}
+
+void PluginList::updateCache()
+{
+ m_PluginsByName.clear();
+ m_PluginsByPriority.clear();
+ m_PluginsByPriority.resize(m_Plugins.size());
+ for (int i = 0; i < m_Plugins.size(); ++i) {
+ if (m_Plugins[i]->priority() < 0) {
+ continue;
+ }
+ if (m_Plugins[i]->priority() >= static_cast<int>(m_Plugins.size())) {
+ MOBase::log::error("invalid plugin priority: {}", m_Plugins[i]->priority());
+ continue;
+ }
+ m_PluginsByName[m_Plugins[i]->name()] = i;
+ m_PluginsByPriority[m_Plugins[i]->priority()] = i;
+ }
+
+ computeCompileIndices();
+ refreshLoadOrder();
+}
+
+void PluginList::computeCompileIndices()
+{
+ int numNormal = 0;
+ int numESLs = 0;
+ int numESHs = 0;
+ int numSkipped = 0;
+
+ const auto gameFeatures = m_Organizer->gameFeatures();
+ const auto tesSupport =
+ gameFeatures ? gameFeatures->gameFeature<MOBase::GamePlugins>() : nullptr;
+
+ const bool lightPluginsAreSupported =
+ tesSupport && tesSupport->lightPluginsAreSupported();
+ const bool mediumPluginsAreSupported =
+ tesSupport && tesSupport->mediumPluginsAreSupported();
+
+ for (int priority = 0; priority < m_PluginsByPriority.size(); ++priority) {
+ const int index = m_PluginsByPriority[priority];
+ const auto& plugin = m_Plugins.at(index);
+
+ if (!plugin->enabled()) {
+ plugin->setIndex(QString());
+ ++numSkipped;
+ continue;
+ }
+
+ if (mediumPluginsAreSupported && plugin->isMediumFile()) {
+ const int ESHpos = 0xFD + (numESHs >> 8);
+ plugin->setIndex((u"%1:%2"_s)
+ .arg(ESHpos, 2, 16, QChar(u'0'))
+ .arg(numESHs & 0xFF, 2, 16, QChar(u'0'))
+ .toUpper());
+ } else if (lightPluginsAreSupported && plugin->isSmallFile()) {
+ const int ESLpos = 0xFE + (numESLs >> 12);
+ plugin->setIndex((u"%1:%2"_s)
+ .arg(ESLpos, 2, 16, QChar(u'0'))
+ .arg(numESLs & 0xFFF, 3, 16, QChar(u'0'))
+ .toUpper());
+ ++numESLs;
+ } else {
+ plugin->setIndex((u"%1"_s).arg(numNormal++, 2, 16, QChar(u'0')).toUpper());
+ }
+ }
+}
+
+void PluginList::refreshLoadOrder()
+{
+ int loadOrder = 0;
+ for (int i = 0; i < m_PluginsByPriority.size(); ++i) {
+ int index = m_PluginsByPriority[i];
+ const auto& plugin = m_Plugins.at(index);
+
+ if (plugin->enabled()) {
+ plugin->setLoadOrder(loadOrder++);
+ } else {
+ plugin->setLoadOrder(-1);
+ }
+ }
+ emit pluginsListChanged();
+}
+
+#pragma endregion Helpers
+
+} // namespace TESData