diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-03-07 08:12:48 -0600 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-03-07 08:12:48 -0600 |
| commit | 7a612650d6582c18097c3f73018101e5a69172bf (patch) | |
| tree | 44fbac9adfed9757c4495d5a850ab592b3f19d23 /src | |
| parent | 7b3a56ca04e818bd5c1d1998dabde977d960ffe0 (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.cpp | 15 | ||||
| -rw-r--r-- | src/src/fuseconnector.h | 4 | ||||
| -rw-r--r-- | src/src/loot.cpp | 125 | ||||
| -rw-r--r-- | src/src/organizercore.cpp | 7 | ||||
| -rw-r--r-- | src/src/organizercore.h | 1 | ||||
| -rw-r--r-- | src/src/vfs/mo2filesystem.cpp | 34 | ||||
| -rw-r--r-- | src/src/vfs/vfstree.cpp | 175 |
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) |
