aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-03-07 08:12:48 -0600
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-03-07 08:12:48 -0600
commit7a612650d6582c18097c3f73018101e5a69172bf (patch)
tree44fbac9adfed9757c4495d5a850ab592b3f19d23 /src
parent7b3a56ca04e818bd5c1d1998dabde977d960ffe0 (diff)
Fix LOOT integration: FileTime support, VFS timestamps, perf, and overwrite pollution
- Fix VFS files/dirs showing 1969 dates (separate error codes for mtime, set dir timestamps) - Add stampPluginTimestamps() for FileTime games (FalloutNV/FO3/Oblivion) so LOOT sees correct load order - Fix LOOT masterlist URL (v0.21 → v0.26) and point local_path to Fluorine prefix AppData - Capture FileTime-based load order from VFS timestamps before unmount - Handle Plugins.txt case sensitivity on Linux (LOOT writes capital P) - Skip loadorder.txt copy-back for FileTime games to preserve sorted order - Prevent LOOT COW'd plugins from polluting overwrite (discardStagingOnUnmount flag) - Update mo2_setattr to modify VFS tree mtime in-place without triggering COW - VFS perf: replace fs::relative() with fast string prefix strip, cache clock offset, parallel mod scanning Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/src/fuseconnector.cpp15
-rw-r--r--src/src/fuseconnector.h4
-rw-r--r--src/src/loot.cpp125
-rw-r--r--src/src/organizercore.cpp7
-rw-r--r--src/src/organizercore.h1
-rw-r--r--src/src/vfs/mo2filesystem.cpp34
-rw-r--r--src/src/vfs/vfstree.cpp175
7 files changed, 323 insertions, 38 deletions
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp
index f56340b..325d7ed 100644
--- a/src/src/fuseconnector.cpp
+++ b/src/src/fuseconnector.cpp
@@ -350,7 +350,15 @@ void FuseConnector::unmount()
{
const auto t0 = std::chrono::steady_clock::now();
- flushStaging();
+ if (m_discardStaging) {
+ // Discard all COW'd files instead of moving them to overwrite.
+ log::info("Discarding staging directory (discard flag set)");
+ std::error_code ec;
+ fs::remove_all(m_stagingDir, ec);
+ m_discardStaging = false;
+ } else {
+ flushStaging();
+ }
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - t0).count();
std::fprintf(stderr, "[VFS] flushed staging in %lldms\n",
@@ -382,6 +390,11 @@ bool FuseConnector::isMounted() const
return m_mounted;
}
+void FuseConnector::discardStagingOnUnmount()
+{
+ m_discardStaging = true;
+}
+
void FuseConnector::setPluginLoadOrder(const std::vector<std::string>& load_order)
{
m_pluginLoadOrder = load_order;
diff --git a/src/src/fuseconnector.h b/src/src/fuseconnector.h
index fa82a27..96ea56a 100644
--- a/src/src/fuseconnector.h
+++ b/src/src/fuseconnector.h
@@ -42,6 +42,7 @@ public:
void setPluginLoadOrder(const std::vector<std::string>& load_order);
void unmount();
+ void discardStagingOnUnmount();
bool isMounted() const;
void rebuild(const std::vector<std::pair<std::string, std::string>>& mods,
@@ -88,7 +89,8 @@ private:
struct fuse_session* m_session = nullptr;
std::thread m_fuseThread;
- bool m_mounted = false;
+ bool m_mounted = false;
+ bool m_discardStaging = false;
};
#endif
diff --git a/src/src/loot.cpp b/src/src/loot.cpp
index 5c8bc64..5e796df 100644
--- a/src/src/loot.cpp
+++ b/src/src/loot.cpp
@@ -1267,15 +1267,10 @@ static QString lootFolderName(const QString& mo2GameName)
static void ensureLootSettings(const QString& lootDataPath,
const QString& mo2GameName,
const QString& folderName,
- const QString& gamePath)
+ const QString& gamePath,
+ const QString& localPath)
{
QDir().mkpath(lootDataPath);
-
- // Create a local app data directory for this game. LOOT needs it for
- // load order interaction (plugins.txt / loadorder.txt). On Linux the
- // native local-appdata location (~/.local/share/<game>) doesn't exist
- // because games run via Proton, so we create one under our data dir.
- QString localPath = lootDataPath + "/local/" + folderName;
QDir().mkpath(localPath);
// Map MO2 game names to LOOT masterlist repository names.
@@ -1290,7 +1285,7 @@ static void ensureLootSettings(const QString& lootDataPath,
};
QString repoName = masterlistRepos.value(mo2GameName, mo2GameName.toLower());
QString masterlistUrl =
- QString("https://raw.githubusercontent.com/loot/%1/v0.21/masterlist.yaml")
+ QString("https://raw.githubusercontent.com/loot/%1/v0.26/masterlist.yaml")
.arg(repoName);
// Always rewrite the settings file so the path matches the current
@@ -1330,12 +1325,31 @@ static bool launchLootGui(QWidget* parent, OrganizerCore& core)
QString folderName = lootFolderName(mo2GameName);
QString gamePath = core.managedGame()->gameDirectory().absolutePath();
QString lootDataPath = fluorineDataDir() + "/loot";
- QString localPath = lootDataPath + "/local/" + folderName;
+
+ // Resolve the Fluorine prefix AppData/Local/<game> path — this is where
+ // LOOT auto-detects the plugins.txt / loadorder.txt from, so we must
+ // use it as local_path and deploy our load order files there.
+ QString localPath;
+ {
+ QString prefixBase = fluorineDataDir() + "/Prefix/pfx/drive_c/users/steamuser/AppData/Local";
+ // Use documentsDirectory leaf name as the AppData/Local subfolder —
+ // this matches how Bethesda games map their local data folder.
+ QString dataDirName = core.managedGame()->documentsDirectory().dirName();
+ if (dataDirName.isEmpty() || dataDirName == ".") {
+ dataDirName = core.managedGame()->gameShortName();
+ }
+ QString candidate = prefixBase + "/" + dataDirName;
+ if (!dataDirName.isEmpty() && QDir(prefixBase).exists()) {
+ localPath = candidate;
+ } else {
+ localPath = lootDataPath + "/local/" + folderName;
+ }
+ }
// Pre-seed LOOT settings so --game resolution works on first launch.
- ensureLootSettings(lootDataPath, mo2GameName, folderName, gamePath);
+ ensureLootSettings(lootDataPath, mo2GameName, folderName, gamePath, localPath);
- // Copy the profile's load order files to LOOT's local path so LOOT sees
+ // Copy the profile's load order files to the local path so LOOT sees
// the current load order.
QDir().mkpath(localPath);
QString profilePlugins = core.profilePath() + "/plugins.txt";
@@ -1424,23 +1438,92 @@ static bool launchLootGui(QWidget* parent, OrganizerCore& core)
lootProcess.waitForFinished(-1);
+ int exitCode = lootProcess.exitCode();
+ log::info("LOOT exited with code {}", exitCode);
+
+ // For FileTime-based games (FalloutNV, Fallout3, Oblivion), LOOT sets
+ // load order via file timestamps rather than modifying loadorder.txt.
+ // Read the plugin timestamps from the still-mounted VFS and write a
+ // sorted loadorder.txt before unmounting (timestamps are lost on unmount).
+ if (core.managedGame()->loadOrderMechanism() ==
+ MOBase::IPluginGame::LoadOrderMechanism::FileTime) {
+ QString dataPath = core.managedGame()->dataDirectory().absolutePath();
+ QDir dataDir(dataPath);
+ QStringList pluginFilters = {"*.esp", "*.esm", "*.esl"};
+
+ struct PluginMtime {
+ QString name;
+ qint64 mtime;
+ };
+ std::vector<PluginMtime> plugins;
+
+ for (const auto& entry : dataDir.entryInfoList(pluginFilters, QDir::Files)) {
+ plugins.push_back({entry.fileName(),
+ entry.lastModified().toSecsSinceEpoch()});
+ }
+
+ std::sort(plugins.begin(), plugins.end(),
+ [](const PluginMtime& a, const PluginMtime& b) {
+ return a.mtime < b.mtime;
+ });
+
+ if (!plugins.empty()) {
+ QFile lo(profileLoadOrder);
+ if (lo.open(QIODevice::WriteOnly | QIODevice::Text)) {
+ QTextStream out(&lo);
+ for (const auto& p : plugins) {
+ out << p.name << "\n";
+ }
+ lo.close();
+ log::info("Wrote sorted load order ({} plugins) from VFS timestamps",
+ plugins.size());
+ }
+ }
+ }
+
+ // Discard any COW'd files in staging (LOOT opens plugins with write access
+ // to set timestamps, which triggers copy-on-write — we don't want those
+ // copies ending up in overwrite).
+ core.discardVFSStagingOnUnmount();
+
// Unmount the VFS now that LOOT has finished.
log::info("Unmounting VFS after LOOT...");
core.unmountVFS();
- int exitCode = lootProcess.exitCode();
- log::info("LOOT exited with code {}", exitCode);
-
// Copy LOOT's updated load order files back to the profile.
- if (QFile::exists(lootPlugins)) {
+ // For FileTime games, we already wrote loadorder.txt from VFS timestamps
+ // above — skip the copy-back so we don't overwrite it with the old order.
+ bool isFileTime = core.managedGame()->loadOrderMechanism() ==
+ MOBase::IPluginGame::LoadOrderMechanism::FileTime;
+
+ // LOOT may write "Plugins.txt" (capital P, Windows convention) instead of
+ // "plugins.txt", so check both case variants on case-sensitive Linux.
+ QString lootPluginsActual = lootPlugins;
+ if (!QFile::exists(lootPluginsActual)) {
+ QString alt = localPath + "/Plugins.txt";
+ if (QFile::exists(alt)) {
+ lootPluginsActual = alt;
+ }
+ }
+ if (QFile::exists(lootPluginsActual)) {
QFile::remove(profilePlugins);
- QFile::copy(lootPlugins, profilePlugins);
- log::info("Copied LOOT plugins.txt back to profile");
+ QFile::copy(lootPluginsActual, profilePlugins);
+ log::info("Copied LOOT {} back to profile", lootPluginsActual);
}
- if (QFile::exists(lootLoadOrder)) {
- QFile::remove(profileLoadOrder);
- QFile::copy(lootLoadOrder, profileLoadOrder);
- log::info("Copied LOOT loadorder.txt back to profile");
+
+ if (!isFileTime) {
+ QString lootLoadOrderActual = lootLoadOrder;
+ if (!QFile::exists(lootLoadOrderActual)) {
+ QString alt = localPath + "/Loadorder.txt";
+ if (QFile::exists(alt)) {
+ lootLoadOrderActual = alt;
+ }
+ }
+ if (QFile::exists(lootLoadOrderActual)) {
+ QFile::remove(profileLoadOrder);
+ QFile::copy(lootLoadOrderActual, profileLoadOrder);
+ log::info("Copied LOOT {} back to profile", lootLoadOrderActual);
+ }
}
return true;
diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp
index 3d08395..7db8a93 100644
--- a/src/src/organizercore.cpp
+++ b/src/src/organizercore.cpp
@@ -659,6 +659,13 @@ void OrganizerCore::unmountVFS()
m_USVFS.unmount();
}
+void OrganizerCore::discardVFSStagingOnUnmount()
+{
+#ifndef _WIN32
+ m_USVFS.discardStagingOnUnmount();
+#endif
+}
+
void OrganizerCore::updateVFSParams(log::Levels logLevel,
env::CoreDumpTypes coreDumpType,
const QString& crashDumpsPath,
diff --git a/src/src/organizercore.h b/src/src/organizercore.h
index 2526ce8..ccad14a 100644
--- a/src/src/organizercore.h
+++ b/src/src/organizercore.h
@@ -351,6 +351,7 @@ public:
void prepareVFS();
void unmountVFS();
+ void discardVFSStagingOnUnmount();
void updateVFSParams(MOBase::log::Levels logLevel, env::CoreDumpTypes coreDumpType,
const QString& coreDumpsPath, std::chrono::seconds spawnDelay,
diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp
index 83c32f4..5eb1607 100644
--- a/src/src/vfs/mo2filesystem.cpp
+++ b/src/src/vfs/mo2filesystem.cpp
@@ -725,11 +725,39 @@ void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set,
times[1].tv_nsec = UTIME_OMIT;
}
- utimensat(AT_FDCWD, snap.real_path.c_str(), times, 0);
+ // Only write timestamps to disk for files we own (staging/overwrite).
+ // For mod and base game files, just update the VFS tree in-memory.
+ if (!snap.is_backing) {
+ utimensat(AT_FDCWD, snap.real_path.c_str(), times, 0);
+ }
- // Update the VFS tree node with the new mtime
+ // Update the VFS tree mtime without changing origin or triggering COW.
if (to_set & (FUSE_SET_ATTR_MTIME | FUSE_SET_ATTR_MTIME_NOW)) {
- updateFileNode(ctx, path, snap.real_path, "Staging");
+ std::chrono::system_clock::time_point newMtime;
+ if (to_set & FUSE_SET_ATTR_MTIME_NOW) {
+ newMtime = std::chrono::system_clock::now();
+ } else {
+ newMtime = std::chrono::system_clock::time_point(
+ std::chrono::seconds(attr->st_mtim.tv_sec));
+ }
+ std::unique_lock lock(ctx->tree_mutex);
+ auto components = splitPath(path);
+ VfsNode* cur = &ctx->tree->root;
+ for (const auto& part : components) {
+ if (!cur->is_directory) {
+ cur = nullptr;
+ break;
+ }
+ auto it = cur->dir_info.children.find(normalizeForLookup(part));
+ if (it == cur->dir_info.children.end()) {
+ cur = nullptr;
+ break;
+ }
+ cur = it->second.get();
+ }
+ if (cur != nullptr && !cur->is_directory) {
+ cur->file_info.mtime = newMtime;
+ }
}
}
}
diff --git a/src/src/vfs/vfstree.cpp b/src/src/vfs/vfstree.cpp
index 6e7f1d3..f3e0f42 100644
--- a/src/src/vfs/vfstree.cpp
+++ b/src/src/vfs/vfstree.cpp
@@ -2,18 +2,46 @@
#include <algorithm>
#include <cctype>
+#include <cstdio>
#include <filesystem>
+#include <future>
+#include <mutex>
+#include <thread>
namespace
{
namespace fs = std::filesystem;
+// Clock offset computed once and reused for all file time conversions.
+// Avoids two now() syscalls per file (was ~600K calls for 300K files).
+struct ClockOffset
+{
+ std::chrono::system_clock::duration offset;
+
+ ClockOffset()
+ {
+ const auto nowFs = fs::file_time_type::clock::now();
+ const auto nowSys = std::chrono::system_clock::now();
+ offset = nowSys.time_since_epoch() -
+ std::chrono::duration_cast<std::chrono::system_clock::duration>(
+ nowFs.time_since_epoch());
+ }
+
+ std::chrono::system_clock::time_point convert(const fs::file_time_type& t) const
+ {
+ return std::chrono::system_clock::time_point(
+ std::chrono::duration_cast<std::chrono::system_clock::duration>(
+ t.time_since_epoch()) +
+ offset);
+ }
+};
+
+static const ClockOffset g_clockOffset;
+
std::chrono::system_clock::time_point
fsTimeToSystemClock(const fs::file_time_type& t)
{
- const auto nowFs = fs::file_time_type::clock::now();
- const auto nowSys = std::chrono::system_clock::now();
- return nowSys + std::chrono::duration_cast<std::chrono::system_clock::duration>(t - nowFs);
+ return g_clockOffset.convert(t);
}
std::vector<std::string> splitPath(const std::string& path)
@@ -42,6 +70,95 @@ std::vector<std::string> splitPath(const std::string& path)
return out;
}
+// Fast relative path extraction by stripping a known prefix string.
+// Avoids fs::relative() which does expensive canonicalization (~300K calls).
+std::string fastRelative(const std::string& fullPath, const std::string& prefix)
+{
+ if (fullPath.size() <= prefix.size()) {
+ return {};
+ }
+ size_t start = prefix.size();
+ // Skip separators after prefix
+ while (start < fullPath.size() && (fullPath[start] == '/' || fullPath[start] == '\\')) {
+ ++start;
+ }
+ if (start >= fullPath.size()) {
+ return {};
+ }
+ return fullPath.substr(start);
+}
+
+// Per-directory intermediate results for parallel tree building.
+struct DirScanResult
+{
+ struct FileEntry
+ {
+ std::vector<std::string> components;
+ std::string real_path;
+ uint64_t size;
+ std::chrono::system_clock::time_point mtime;
+ };
+
+ std::vector<FileEntry> files;
+ std::vector<std::vector<std::string>> dirs;
+};
+
+DirScanResult scanOneModDir(const fs::path& walkDir, const std::string& prefixStr,
+ const std::vector<std::string>& prefix)
+{
+ DirScanResult result;
+
+ if (!fs::exists(walkDir)) {
+ return result;
+ }
+
+ for (auto it = fs::recursive_directory_iterator(
+ walkDir, fs::directory_options::skip_permission_denied);
+ it != fs::recursive_directory_iterator(); ++it) {
+ const auto& entry = *it;
+ std::error_code ec;
+
+ const std::string relStr = fastRelative(entry.path().string(), prefixStr);
+ if (relStr.empty()) {
+ continue;
+ }
+
+ // Skip meta.ini at top level only
+ if (relStr == "meta.ini") {
+ continue;
+ }
+
+ auto relParts = splitPath(relStr);
+ std::vector<std::string> components;
+ components.reserve(prefix.size() + relParts.size());
+ components.insert(components.end(), prefix.begin(), prefix.end());
+ components.insert(components.end(), relParts.begin(), relParts.end());
+
+ if (entry.is_directory(ec)) {
+ result.dirs.push_back(std::move(components));
+ continue;
+ }
+
+ if (!entry.is_regular_file(ec)) {
+ continue;
+ }
+
+ const auto size = entry.file_size(ec);
+ std::error_code mtimeEc;
+ const auto mtime = entry.last_write_time(mtimeEc);
+
+ DirScanResult::FileEntry fe;
+ fe.components = std::move(components);
+ fe.real_path = entry.path().string();
+ fe.size = ec ? 0ULL : size;
+ fe.mtime = mtimeEc ? std::chrono::system_clock::time_point{}
+ : fsTimeToSystemClock(mtime);
+ result.files.push_back(std::move(fe));
+ }
+
+ return result;
+}
+
void addDirectoryToTree(VfsTree& tree, const fs::path& walkDir,
const fs::path& stripPrefix, const std::string& origin,
const std::vector<std::string>& prefix,
@@ -51,18 +168,19 @@ void addDirectoryToTree(VfsTree& tree, const fs::path& walkDir,
return;
}
+ const std::string prefixStr = stripPrefix.string();
+
for (auto it = fs::recursive_directory_iterator(
walkDir, fs::directory_options::skip_permission_denied);
it != fs::recursive_directory_iterator(); ++it) {
const auto& entry = *it;
std::error_code ec;
- const fs::path rel = fs::relative(entry.path(), stripPrefix, ec);
- if (ec || rel.empty()) {
+ const std::string relStr = fastRelative(entry.path().string(), prefixStr);
+ if (relStr.empty()) {
continue;
}
- const std::string relStr = rel.generic_string();
if (relStr == "meta.ini") {
continue;
}
@@ -313,19 +431,21 @@ std::vector<CachedBaseFile> scanDataDir(const std::string& data_dir_path)
return cache;
}
+ const std::string dataDirStr = dataDir.string();
+
for (auto it = fs::recursive_directory_iterator(
dataDir, fs::directory_options::skip_permission_denied);
it != fs::recursive_directory_iterator(); ++it) {
const auto& entry = *it;
std::error_code ec;
- const fs::path rel = fs::relative(entry.path(), dataDir, ec);
- if (ec || rel.empty()) {
+ const std::string relStr = fastRelative(entry.path().string(), dataDirStr);
+ if (relStr.empty()) {
continue;
}
CachedBaseFile cf;
- cf.relative_path = rel.generic_string();
+ cf.relative_path = relStr;
cf.is_dir = entry.is_directory(ec);
if (!cf.is_dir) {
@@ -369,9 +489,40 @@ VfsTree buildDataDirVfs(const std::vector<CachedBaseFile>& cached_files,
}
}
- // Layer 2: Mods in priority order (higher priority)
- for (const auto& [modName, modPath] : mods) {
- addDirectoryToTree(tree, fs::path(modPath), fs::path(modPath), modName, {});
+ // Layer 2: Mods in priority order (higher priority).
+ // Scan mod directories in parallel — each mod is independent.
+ // Use a bounded number of threads to avoid overwhelming the IO subsystem.
+ {
+ const size_t numThreads =
+ std::min(static_cast<size_t>(std::thread::hardware_concurrency()),
+ std::max(mods.size(), size_t{1}));
+
+ // Launch parallel scans
+ std::vector<std::future<DirScanResult>> futures;
+ futures.reserve(mods.size());
+ for (const auto& [modName, modPath] : mods) {
+ futures.push_back(std::async(
+ std::launch::async,
+ [&modPath]() {
+ return scanOneModDir(fs::path(modPath), modPath, {});
+ }));
+ }
+
+ // Merge results into tree in priority order (sequential — tree isn't thread-safe)
+ for (size_t i = 0; i < mods.size(); ++i) {
+ auto result = futures[i].get();
+ const auto& origin = mods[i].first;
+
+ for (auto& dir : result.dirs) {
+ tree.root.insertDirectory(dir);
+ ++tree.dir_count;
+ }
+ for (auto& fe : result.files) {
+ tree.root.insertFile(fe.components, fe.real_path, fe.size, fe.mtime,
+ origin, false);
+ ++tree.file_count;
+ }
+ }
}
// Layer 3: Overwrite (highest priority)