diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-03-10 02:36:59 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-03-10 02:36:59 -0500 |
| commit | 7821fadddc8af8b1a712b0da02e35534996da422 (patch) | |
| tree | e820e8e891a40d441adc0b6a33771eff7291000d /src | |
| parent | 6a310f34ccf2dde98ca82136b992afd7bebfd3d5 (diff) | |
Track files moved from Overwrite to mods for in-place write-back
When users move generated files (logs, INIs) from Overwrite into a
dedicated mod folder, future VFS writes to those files now go directly
to the mod instead of creating duplicates in Overwrite. Tracking
persists across sessions via a JSON file and detects manual moves
by comparing overwrite snapshots between sessions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/src/fuseconnector.cpp | 63 | ||||
| -rw-r--r-- | src/src/fuseconnector.h | 5 | ||||
| -rw-r--r-- | src/src/mainwindow.cpp | 15 | ||||
| -rw-r--r-- | src/src/modlistviewactions.cpp | 9 | ||||
| -rw-r--r-- | src/src/organizercore.cpp | 58 | ||||
| -rw-r--r-- | src/src/organizercore.h | 1 | ||||
| -rw-r--r-- | src/src/vfs/mo2filesystem.cpp | 192 | ||||
| -rw-r--r-- | src/src/vfs/mo2filesystem.h | 4 | ||||
| -rw-r--r-- | src/src/vfs/trackedwrites.cpp | 370 | ||||
| -rw-r--r-- | src/src/vfs/trackedwrites.h | 73 |
10 files changed, 769 insertions, 21 deletions
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp index 499f77d..c749d69 100644 --- a/src/src/fuseconnector.cpp +++ b/src/src/fuseconnector.cpp @@ -272,13 +272,33 @@ bool FuseConnector::mount( tree->file_count, tree->dir_count, static_cast<long long>(ms)); } - m_context = std::make_shared<Mo2FsContext>(); - m_context->tree = tree; - m_context->inodes = std::make_unique<InodeTable>(); - m_context->overwrite = std::make_unique<OverwriteManager>(m_stagingDir, m_overwriteDir); - m_context->backing_dir_fd = m_backingFd; - m_context->uid = ::getuid(); - m_context->gid = ::getgid(); + // Load tracked writes (files user moved from Overwrite to a mod) + m_trackedWrites = std::make_shared<TrackedWrites>(); + std::fprintf(stderr, "[VFS] tracking file path: '%s' (overwrite: '%s', %zu mods)\n", + m_trackingFilePath.c_str(), m_overwriteDir.c_str(), mods.size()); + if (!m_trackingFilePath.empty()) { + const bool existed = fs::exists(m_trackingFilePath); + std::fprintf(stderr, "[VFS] tracking file %s\n", existed ? "exists" : "does NOT exist (first run)"); + m_trackedWrites->load(m_trackingFilePath); + if (existed) { + m_trackedWrites->detectManualMoves(m_overwriteDir, mods); + } else { + // First run: scan overwrite for files that also exist in a mod + m_trackedWrites->initialScan(m_overwriteDir, mods); + } + m_trackedWrites->save(m_trackingFilePath); + } else { + std::fprintf(stderr, "[VFS] WARNING: tracking file path is empty!\n"); + } + + m_context = std::make_shared<Mo2FsContext>(); + m_context->tree = tree; + m_context->inodes = std::make_unique<InodeTable>(); + m_context->overwrite = std::make_unique<OverwriteManager>(m_stagingDir, m_overwriteDir); + m_context->tracked_writes = m_trackedWrites; + m_context->backing_dir_fd = m_backingFd; + m_context->uid = ::getuid(); + m_context->gid = ::getgid(); // NOTE: Do NOT include mount_point here — low-level API passes it // separately to fuse_session_mount(). Including it here causes @@ -368,6 +388,15 @@ void FuseConnector::unmount() static_cast<long long>(ms)); } + // After flush: scan overwrite for files that also exist in mods (bootstraps + // tracking on first run), snapshot overwrite contents for next session's + // manual-move detection, then save tracking data. + if (m_trackedWrites && !m_trackingFilePath.empty()) { + m_trackedWrites->initialScan(m_overwriteDir, m_lastMods); + m_trackedWrites->snapshotOverwrite(m_overwriteDir); + m_trackedWrites->save(m_trackingFilePath); + } + if (m_backingFd >= 0) { close(m_backingFd); m_backingFd = -1; @@ -403,6 +432,17 @@ void FuseConnector::setPluginLoadOrder(const std::vector<std::string>& load_orde m_pluginLoadOrder = load_order; } +void FuseConnector::setTrackingFilePath(const std::string& path) +{ + m_trackingFilePath = path; + std::fprintf(stderr, "[VFS] setTrackingFilePath: '%s'\n", path.c_str()); +} + +std::shared_ptr<TrackedWrites> FuseConnector::trackedWrites() const +{ + return m_trackedWrites; +} + void FuseConnector::rebuild( const std::vector<std::pair<std::string, std::string>>& mods, const QString& overwrite_dir, const QString& data_dir_name) @@ -454,6 +494,15 @@ void FuseConnector::updateMapping(const MappingType& mapping) const QString dataDirName = game->dataDirectory().dirName(); const QString overwriteDir = Settings::instance().paths().overwrite(); + // Auto-derive tracking file path if not explicitly set + if (m_trackingFilePath.empty() && !overwriteDir.isEmpty()) { + QDir owDir(overwriteDir); + QString trackPath = QDir::cleanPath(owDir.absoluteFilePath("../tracked_writes.json")); + m_trackingFilePath = trackPath.toStdString(); + std::fprintf(stderr, "[VFS] auto-derived tracking path: '%s'\n", + m_trackingFilePath.c_str()); + } + auto mods = buildModsFromMapping(mapping, dataDirPath, overwriteDir); // Check if any data-dir mapping has createTarget set — that directory diff --git a/src/src/fuseconnector.h b/src/src/fuseconnector.h index 96ea56a..a13f014 100644 --- a/src/src/fuseconnector.h +++ b/src/src/fuseconnector.h @@ -3,6 +3,7 @@ #include "envdump.h" #include "vfs/mo2filesystem.h" +#include "vfs/trackedwrites.h" #include <QObject> #include <QString> @@ -40,6 +41,8 @@ public: const std::vector<std::pair<std::string, std::string>>& mods); void setPluginLoadOrder(const std::vector<std::string>& load_order); + void setTrackingFilePath(const std::string& path); + std::shared_ptr<TrackedWrites> trackedWrites() const; void unmount(); void discardStagingOnUnmount(); @@ -86,6 +89,8 @@ private: std::vector<std::pair<std::string, std::string>> m_extraVfsFiles; std::shared_ptr<Mo2FsContext> m_context; + std::shared_ptr<TrackedWrites> m_trackedWrites; + std::string m_trackingFilePath; struct fuse_session* m_session = nullptr; std::thread m_fuseThread; diff --git a/src/src/mainwindow.cpp b/src/src/mainwindow.cpp index a01b1c4..c6d119b 100644 --- a/src/src/mainwindow.cpp +++ b/src/src/mainwindow.cpp @@ -2628,6 +2628,21 @@ void MainWindow::fileMoved(const QString& filePath, const QString& oldOriginName .arg(newOriginName)
.arg(e.what()));
}
+
+#ifndef _WIN32
+ // Track files moved from Overwrite to a mod for in-place write-back
+ ModInfo::Ptr owInfo = ModInfo::getOverwrite();
+ if (owInfo && oldOriginName == owInfo->name()) {
+ if (m_OrganizerCore.directoryStructure()->originExists(
+ ToWString(newOriginName))) {
+ FilesOrigin& newOrigin =
+ m_OrganizerCore.directoryStructure()->getOriginByName(
+ ToWString(newOriginName));
+ m_OrganizerCore.trackOverwriteMove(
+ filePath, ToQString(newOrigin.getPath()));
+ }
+ }
+#endif
} else {
// this is probably not an error, the specified path is likely a directory
}
diff --git a/src/src/modlistviewactions.cpp b/src/src/modlistviewactions.cpp index ace0045..051635f 100644 --- a/src/src/modlistviewactions.cpp +++ b/src/src/modlistviewactions.cpp @@ -1379,6 +1379,15 @@ void ModListViewActions::moveOverwriteContentsTo(const QString& absolutePath) co }
if (successful) {
+ // Track all files now in the target mod so future VFS writes go
+ // back to this mod instead of creating new copies in Overwrite.
+ QDirIterator trackIter(absolutePath, QDir::Files | QDir::NoDotAndDotDot,
+ QDirIterator::Subdirectories);
+ while (trackIter.hasNext()) {
+ trackIter.next();
+ QString relPath = QDir(absolutePath).relativeFilePath(trackIter.filePath());
+ m_core.trackOverwriteMove(relPath, absolutePath);
+ }
MessageDialog::showMessage(tr("Move successful."), m_parent);
} else {
const auto e = GetLastError();
diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp index 7db8a93..c0f1bf9 100644 --- a/src/src/organizercore.cpp +++ b/src/src/organizercore.cpp @@ -650,6 +650,17 @@ void OrganizerCore::prepareVFS() }
m_USVFS.setPluginLoadOrder(loadOrder);
}
+
+ // Set up tracked writes file (per-profile, next to the overwrite folder)
+ {
+ QString owPath = settings().paths().overwrite();
+ QDir owDir(owPath);
+ QString trackPath = owDir.absoluteFilePath("../tracked_writes.json");
+ trackPath = QDir::cleanPath(trackPath);
+ std::fprintf(stderr, "[VFS] prepareVFS: owPath='%s' trackPath='%s'\n",
+ owPath.toStdString().c_str(), trackPath.toStdString().c_str());
+ m_USVFS.setTrackingFilePath(trackPath.toStdString());
+ }
#endif
m_USVFS.updateMapping(fileMapping(m_CurrentProfile->name(), QString()));
}
@@ -659,6 +670,17 @@ void OrganizerCore::unmountVFS() m_USVFS.unmount();
}
+void OrganizerCore::trackOverwriteMove(const QString& relativePath,
+ const QString& modFolderPath)
+{
+#ifndef _WIN32
+ auto tw = m_USVFS.trackedWrites();
+ if (tw) {
+ tw->track(relativePath.toStdString(), modFolderPath.toStdString());
+ }
+#endif
+}
+
void OrganizerCore::discardVFSStagingOnUnmount()
{
#ifndef _WIN32
@@ -2056,10 +2078,46 @@ void OrganizerCore::loginFailedUpdate(const QString& message) void OrganizerCore::syncOverwrite()
{
ModInfo::Ptr modInfo = ModInfo::getOverwrite();
+
+#ifndef _WIN32
+ // Snapshot overwrite before sync so we can detect what was moved
+ QStringList beforeFiles;
+ {
+ QDirIterator it(modInfo->absolutePath(), QDir::Files | QDir::NoDotAndDotDot,
+ QDirIterator::Subdirectories);
+ while (it.hasNext()) {
+ it.next();
+ beforeFiles << QDir(modInfo->absolutePath()).relativeFilePath(it.filePath());
+ }
+ }
+#endif
+
SyncOverwriteDialog syncDialog(modInfo->absolutePath(), m_DirectoryStructure,
qApp->activeWindow());
if (syncDialog.exec() == QDialog::Accepted) {
syncDialog.apply(QDir::fromNativeSeparators(m_Settings.paths().mods()));
+
+#ifndef _WIN32
+ // Track files that were moved out of overwrite to mods.
+ // Files that existed before but are gone now were synced to a mod.
+ const QString modsDir = QDir::fromNativeSeparators(m_Settings.paths().mods());
+ for (const auto& relPath : beforeFiles) {
+ const QString owFile = modInfo->absolutePath() + "/" + relPath;
+ if (!QFile::exists(owFile)) {
+ // Find which mod folder it ended up in
+ QDirIterator modDirIter(modsDir, QDir::Dirs | QDir::NoDotAndDotDot);
+ while (modDirIter.hasNext()) {
+ modDirIter.next();
+ const QString candidate = modDirIter.filePath() + "/" + relPath;
+ if (QFile::exists(candidate)) {
+ trackOverwriteMove(relPath, modDirIter.filePath());
+ break;
+ }
+ }
+ }
+ }
+#endif
+
modInfo->diskContentModified();
refreshDirectoryStructure();
}
diff --git a/src/src/organizercore.h b/src/src/organizercore.h index ccad14a..a3ee7f6 100644 --- a/src/src/organizercore.h +++ b/src/src/organizercore.h @@ -352,6 +352,7 @@ public: void prepareVFS();
void unmountVFS();
void discardVFSStagingOnUnmount();
+ void trackOverwriteMove(const QString& relativePath, const QString& modFolderPath);
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 bfbc923..962a458 100644 --- a/src/src/vfs/mo2filesystem.cpp +++ b/src/src/vfs/mo2filesystem.cpp @@ -889,16 +889,58 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) std::string realPath = snap.real_path; const bool writable = isWritableOpen(fi->flags); bool isBacking = snap.is_backing; + bool cowPending = false; + bool isTracked = false; - // Mod/staging/overwrite files opened writable → open R/W in-place so - // writes go back to the original mod folder (no copying to Overwrite). + // Write strategy: + // 1. Files already in staging/overwrite → open R/W directly. + // 2. Tracked files (user moved from Overwrite to a mod) → open R/W + // in-place so writes go back to the user's dedicated mod folder. + // 3. Everything else (untracked mod files, base-game files) → open R/O + // now, lazy COW to Overwrite on first actual write(). This avoids + // COW from Wine metadata ops (O_RDWR without writing). int fd = -1; if (writable) { - if (isBacking && ctx->backing_dir_fd >= 0) { - fd = openat(ctx->backing_dir_fd, realPath.c_str(), O_RDWR); - } else { + const std::string stagedPath = ctx->overwrite->stagingPath(path); + const std::string owPath = ctx->overwrite->overwritePath(path); + bool alreadyStaged = (realPath == stagedPath || realPath == owPath); + + // Check if this file is tracked to a mod folder — even if the VFS + // resolves it to overwrite (overwrite wins in priority), the write + // should go to the mod folder so the user's dedicated mod stays updated. + std::string trackedMod; + if (ctx->tracked_writes) { + trackedMod = ctx->tracked_writes->modFolderFor(path); + } + + if (!trackedMod.empty()) { + // Tracked file — open R/W in-place in the mod folder. + const std::string modFilePath = trackedMod + "/" + path; + fd = open(modFilePath.c_str(), O_RDWR); + if (fd >= 0) { + realPath = modFilePath; + isTracked = true; + } else { + // Mod file disappeared — fall through to normal handling + trackedMod.clear(); + } + } + + if (fd < 0 && alreadyStaged) { + // Already in staging/overwrite — open R/W directly. fd = open(realPath.c_str(), O_RDWR); + } else if (fd < 0 && !trackedMod.empty()) { + // Should not reach here, but safety fallback + fd = open(realPath.c_str(), O_RDWR); + } else if (fd < 0) { + // Untracked file — open R/O, defer COW to first write(). + if (isBacking && ctx->backing_dir_fd >= 0) { + fd = openat(ctx->backing_dir_fd, realPath.c_str(), O_RDONLY); + } else { + fd = open(realPath.c_str(), O_RDONLY); + } + cowPending = true; } if (fd < 0) { fuse_reply_err(req, EIO); @@ -914,6 +956,8 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) of.real_path = realPath; of.writable = writable; of.is_backing = isBacking; + of.cow_pending = cowPending; + of.is_tracked = isTracked; of.relative_path = path; ctx->open_files[fh] = std::move(of); } @@ -990,7 +1034,9 @@ void mo2_write(fuse_req_t req, fuse_ino_t /*ino*/, const char* buf, size_t size, int fd = -1; std::string relativePath; std::string realPath; - bool writable = false; + bool writable = false; + bool cowPending = false; + bool isBacking = false; { std::shared_lock lock(ctx->open_files_mutex); auto it = ctx->open_files.find(fi->fh); @@ -1000,6 +1046,8 @@ void mo2_write(fuse_req_t req, fuse_ino_t /*ino*/, const char* buf, size_t size, } fd = it->second.fd; writable = it->second.writable; + cowPending = it->second.cow_pending; + isBacking = it->second.is_backing; relativePath = it->second.relative_path; realPath = it->second.real_path; } @@ -1009,6 +1057,44 @@ void mo2_write(fuse_req_t req, fuse_ino_t /*ino*/, const char* buf, size_t size, return; } + // Lazy COW: first actual write on an untracked file — copy to Overwrite + // staging and reopen the fd read-write on the new copy. + if (cowPending) { + try { + std::string newPath; + if (isBacking && ctx->backing_dir_fd >= 0) { + newPath = ctx->overwrite->copyOnWriteFromFd(ctx->backing_dir_fd, relativePath); + } else { + newPath = ctx->overwrite->copyOnWrite(realPath, relativePath); + } + + int newFd = open(newPath.c_str(), O_RDWR); + if (newFd < 0) { + fuse_reply_err(req, EIO); + return; + } + + if (fd >= 0) close(fd); + fd = newFd; + + { + std::scoped_lock lock(ctx->open_files_mutex); + auto it = ctx->open_files.find(fi->fh); + if (it != ctx->open_files.end()) { + it->second.fd = newFd; + it->second.real_path = newPath; + it->second.is_backing = false; + it->second.cow_pending = false; + } + } + realPath = newPath; + updateFileNode(ctx, relativePath, newPath, "Staging"); + } catch (...) { + fuse_reply_err(req, EIO); + return; + } + } + if (fd < 0) { fuse_reply_err(req, EBADF); return; @@ -1020,7 +1106,10 @@ void mo2_write(fuse_req_t req, fuse_ino_t /*ino*/, const char* buf, size_t size, return; } - updateFileNode(ctx, relativePath, realPath, "Mod"); + if (!cowPending) { + // Update VFS tree size/mtime for in-place writes (tracked files) + updateFileNode(ctx, relativePath, realPath, "Mod"); + } fuse_reply_write(req, static_cast<size_t>(written)); } @@ -1043,14 +1132,38 @@ void mo2_create(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t /*mo const std::string relative = joinPath(parentPath, name); std::string realPath; - try { - realPath = ctx->overwrite->writeFile(relative, {}); - } catch (...) { - fuse_reply_err(req, EIO); - return; + + // If this file is tracked to a mod folder, create it there instead of staging + std::string trackedMod; + if (ctx->tracked_writes) { + trackedMod = ctx->tracked_writes->modFolderFor(relative); + } + + if (!trackedMod.empty()) { + realPath = trackedMod + "/" + relative; + // Ensure parent directories exist in the mod folder + std::error_code ec; + fs::create_directories(fs::path(realPath).parent_path(), ec); + // Create the file + int tmpFd = open(realPath.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (tmpFd < 0) { + // Fall back to staging + trackedMod.clear(); + } else { + close(tmpFd); + } } - updateFileNode(ctx, relative, realPath, "Staging"); + if (trackedMod.empty()) { + try { + realPath = ctx->overwrite->writeFile(relative, {}); + } catch (...) { + fuse_reply_err(req, EIO); + return; + } + } + + updateFileNode(ctx, relative, realPath, trackedMod.empty() ? "Staging" : "TrackedMod"); invalidateDirCache(ctx, parentPath); { std::unique_lock lock(ctx->tree_mutex); @@ -1185,6 +1298,7 @@ void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set, if ((to_set & FUSE_SET_ATTR_SIZE) != 0 && attr != nullptr) { std::string target; bool targetIsBacking = false; + bool targetIsTracked = false; uint64_t fh = 0; if (fi != nullptr) { @@ -1194,6 +1308,7 @@ void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set, if (it != ctx->open_files.end()) { target = it->second.real_path; targetIsBacking = it->second.is_backing; + targetIsTracked = it->second.is_tracked; } } @@ -1207,6 +1322,55 @@ void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set, targetIsBacking = snap.is_backing; } + // If file is tracked to a mod, redirect target to mod folder + if (ctx->tracked_writes) { + const std::string trackedMod = ctx->tracked_writes->modFolderFor(path); + if (!trackedMod.empty()) { + const std::string modFilePath = trackedMod + "/" + path; + if (fs::exists(modFilePath)) { + target = modFilePath; + targetIsTracked = true; + targetIsBacking = false; + } + } + } + + // COW for untracked files: copy to staging before truncating. + // Tracked files and files already in staging are truncated in-place. + const std::string stagedPath = ctx->overwrite->stagingPath(path); + if (!targetIsTracked && + fs::path(target).lexically_normal().string() != + fs::path(stagedPath).lexically_normal().string()) { + try { + if (targetIsBacking && ctx->backing_dir_fd >= 0) { + target = ctx->overwrite->copyOnWriteFromFd(ctx->backing_dir_fd, path); + } else { + target = ctx->overwrite->copyOnWrite(target, path); + } + } catch (...) { + fuse_reply_err(req, EIO); + return; + } + + if (fi != nullptr) { + std::scoped_lock lock(ctx->open_files_mutex); + auto it = ctx->open_files.find(fh); + if (it != ctx->open_files.end()) { + const int newFd = open(target.c_str(), O_RDWR); + if (newFd < 0) { + fuse_reply_err(req, EIO); + return; + } + if (it->second.fd >= 0) close(it->second.fd); + it->second.fd = newFd; + it->second.real_path = target; + it->second.writable = true; + it->second.is_backing = false; + it->second.cow_pending = false; + } + } + } + bool resized = false; if (fi != nullptr) { std::scoped_lock lock(ctx->open_files_mutex); @@ -1229,7 +1393,7 @@ void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set, } } - updateFileNode(ctx, path, target, "Mod"); + updateFileNode(ctx, path, target, targetIsTracked ? "Mod" : "Staging"); } // Handle explicit timestamp changes (utimensat / Wine SetFileTime) diff --git a/src/src/vfs/mo2filesystem.h b/src/src/vfs/mo2filesystem.h index 7ab0206..2c4b257 100644 --- a/src/src/vfs/mo2filesystem.h +++ b/src/src/vfs/mo2filesystem.h @@ -5,6 +5,7 @@ #include "inodetable.h" #include "overwritemanager.h" +#include "trackedwrites.h" #include "vfstree.h" #include <atomic> @@ -24,6 +25,7 @@ struct Mo2FsContext mutable std::shared_mutex inode_mutex; std::unique_ptr<OverwriteManager> overwrite; + std::shared_ptr<TrackedWrites> tracked_writes; int backing_dir_fd = -1; @@ -33,6 +35,8 @@ struct Mo2FsContext std::string real_path; bool writable = false; bool is_backing = false; + bool cow_pending = false; // true = opened R/O, will COW on first write() + bool is_tracked = false; // true = user moved this from Overwrite to a mod std::string relative_path; }; diff --git a/src/src/vfs/trackedwrites.cpp b/src/src/vfs/trackedwrites.cpp new file mode 100644 index 0000000..66e83e5 --- /dev/null +++ b/src/src/vfs/trackedwrites.cpp @@ -0,0 +1,370 @@ +#include "trackedwrites.h" + +#include <algorithm> +#include <cctype> +#include <cstdio> +#include <filesystem> +#include <fstream> +#include <sstream> + +namespace fs = std::filesystem; + +namespace +{ + +std::string toLower(const std::string& s) +{ + std::string out = s; + std::transform(out.begin(), out.end(), out.begin(), + [](unsigned char c) { return std::tolower(c); }); + return out; +} + +// Minimal JSON helpers — avoids adding a dependency for a simple +// { "key": "value" } map + string array. + +std::string jsonEscape(const std::string& s) +{ + std::string out; + out.reserve(s.size() + 4); + for (char c : s) { + if (c == '"') + out += "\\\""; + else if (c == '\\') + out += "\\\\"; + else + out += c; + } + return out; +} + +// Parse a JSON string value starting after the opening '"'. +std::string parseJsonString(const std::string& data, size_t& pos) +{ + std::string out; + while (pos < data.size()) { + char c = data[pos++]; + if (c == '"') + return out; + if (c == '\\' && pos < data.size()) { + char next = data[pos++]; + if (next == '"') + out += '"'; + else if (next == '\\') + out += '\\'; + else if (next == 'n') + out += '\n'; + else { + out += '\\'; + out += next; + } + } else { + out += c; + } + } + return out; +} + +void skipWhitespace(const std::string& data, size_t& pos) +{ + while (pos < data.size() && std::isspace(static_cast<unsigned char>(data[pos]))) + ++pos; +} + +} // namespace + +void TrackedWrites::load(const std::string& path) +{ + std::ifstream f(path); + if (!f.is_open()) + return; + + std::ostringstream ss; + ss << f.rdbuf(); + const std::string data = ss.str(); + f.close(); + + std::lock_guard lock(m_mutex); + m_tracked.clear(); + m_overwriteSnapshot.clear(); + + size_t pos = 0; + skipWhitespace(data, pos); + if (pos >= data.size() || data[pos] != '{') + return; + ++pos; + + // Parse top-level keys + while (pos < data.size()) { + skipWhitespace(data, pos); + if (pos >= data.size() || data[pos] == '}') + break; + if (data[pos] != '"') + break; + ++pos; + std::string key = parseJsonString(data, pos); + + skipWhitespace(data, pos); + if (pos >= data.size() || data[pos] != ':') + break; + ++pos; + skipWhitespace(data, pos); + + if (key == "tracked") { + // Parse object { "relpath": "modpath", ... } + if (pos < data.size() && data[pos] == '{') { + ++pos; + while (pos < data.size()) { + skipWhitespace(data, pos); + if (pos >= data.size() || data[pos] == '}') { + ++pos; + break; + } + if (data[pos] != '"') + break; + ++pos; + std::string relPath = parseJsonString(data, pos); + + skipWhitespace(data, pos); + if (pos >= data.size() || data[pos] != ':') + break; + ++pos; + skipWhitespace(data, pos); + + if (pos >= data.size() || data[pos] != '"') + break; + ++pos; + std::string modPath = parseJsonString(data, pos); + + m_tracked[toLower(relPath)] = modPath; + + skipWhitespace(data, pos); + if (pos < data.size() && data[pos] == ',') + ++pos; + } + } + } else if (key == "overwrite_snapshot") { + // Parse array [ "relpath", ... ] + if (pos < data.size() && data[pos] == '[') { + ++pos; + while (pos < data.size()) { + skipWhitespace(data, pos); + if (pos >= data.size() || data[pos] == ']') { + ++pos; + break; + } + if (data[pos] != '"') + break; + ++pos; + m_overwriteSnapshot.push_back(parseJsonString(data, pos)); + + skipWhitespace(data, pos); + if (pos < data.size() && data[pos] == ',') + ++pos; + } + } + } + + skipWhitespace(data, pos); + if (pos < data.size() && data[pos] == ',') + ++pos; + } + + std::fprintf(stderr, "[VFS] loaded %zu tracked write mappings\n", m_tracked.size()); +} + +void TrackedWrites::save(const std::string& path) const +{ + std::lock_guard lock(m_mutex); + + // Ensure parent directory exists + std::error_code ec; + fs::create_directories(fs::path(path).parent_path(), ec); + + std::ofstream f(path); + if (!f.is_open()) { + std::fprintf(stderr, "[VFS] failed to save tracked writes to %s\n", path.c_str()); + return; + } + + f << "{\n \"tracked\": {"; + bool first = true; + for (const auto& [relPath, modPath] : m_tracked) { + if (!first) + f << ","; + f << "\n \"" << jsonEscape(relPath) << "\": \"" << jsonEscape(modPath) << "\""; + first = false; + } + f << "\n },\n \"overwrite_snapshot\": ["; + + first = true; + for (const auto& relPath : m_overwriteSnapshot) { + if (!first) + f << ","; + f << "\n \"" << jsonEscape(relPath) << "\""; + first = false; + } + f << "\n ]\n}\n"; + + std::fprintf(stderr, "[VFS] saved %zu tracked write mappings\n", m_tracked.size()); +} + +void TrackedWrites::track(const std::string& relative_path, + const std::string& mod_folder_path) +{ + std::lock_guard lock(m_mutex); + m_tracked[toLower(relative_path)] = mod_folder_path; +} + +void TrackedWrites::untrack(const std::string& relative_path) +{ + std::lock_guard lock(m_mutex); + m_tracked.erase(toLower(relative_path)); +} + +std::string TrackedWrites::modFolderFor(const std::string& relative_path) const +{ + std::lock_guard lock(m_mutex); + auto it = m_tracked.find(toLower(relative_path)); + if (it != m_tracked.end()) + return it->second; + return {}; +} + +void TrackedWrites::snapshotOverwrite(const std::string& overwrite_dir) +{ + std::lock_guard lock(m_mutex); + m_overwriteSnapshot.clear(); + + std::error_code ec; + if (!fs::exists(overwrite_dir, ec)) + return; + + for (auto it = fs::recursive_directory_iterator( + overwrite_dir, fs::directory_options::skip_permission_denied, ec); + it != fs::recursive_directory_iterator(); ++it) { + if (it->is_regular_file(ec)) { + auto rel = fs::relative(it->path(), overwrite_dir, ec); + if (!ec) + m_overwriteSnapshot.push_back(rel.string()); + } + } +} + +void TrackedWrites::detectManualMoves( + const std::string& overwrite_dir, + const std::vector<std::pair<std::string, std::string>>& mods) +{ + std::lock_guard lock(m_mutex); + + if (m_overwriteSnapshot.empty()) + return; + + // Find files that were in overwrite last session but are gone now + std::vector<std::string> missing; + for (const auto& relPath : m_overwriteSnapshot) { + std::error_code ec; + if (!fs::exists(fs::path(overwrite_dir) / relPath, ec)) { + missing.push_back(relPath); + } + } + + if (missing.empty()) + return; + + // For each missing file, check if it now exists in any mod folder + int detected = 0; + for (const auto& relPath : missing) { + const std::string key = toLower(relPath); + // Skip if already tracked + if (m_tracked.count(key)) + continue; + + for (const auto& [modName, modPath] : mods) { + std::error_code ec; + if (fs::exists(fs::path(modPath) / relPath, ec)) { + m_tracked[key] = modPath; + ++detected; + std::fprintf(stderr, "[VFS] detected manual move: %s -> %s\n", + relPath.c_str(), modName.c_str()); + break; + } + } + } + + if (detected > 0) + std::fprintf(stderr, "[VFS] detected %d manually moved files from overwrite\n", + detected); + + m_overwriteSnapshot.clear(); +} + +void TrackedWrites::initialScan( + const std::string& overwrite_dir, + const std::vector<std::pair<std::string, std::string>>& mods) +{ + // On first run (or any mount), scan overwrite for files that also exist + // in a mod. This means the user already moved files to a mod but a stale + // copy remains in overwrite. Track the mod version so future writes go + // there instead of creating another duplicate. + // + // Also scan all mods for duplicate files — if the same relative path + // exists in multiple mods, the highest-priority one (last in the mods + // vector) wins in the VFS. If a lower-priority mod also has it, that + // lower mod likely holds user-moved overwrite output. But this is too + // heuristic-heavy, so we only do the overwrite-vs-mod check. + + std::lock_guard lock(m_mutex); + + std::error_code ec; + if (!fs::exists(overwrite_dir, ec)) + return; + + int detected = 0; + for (auto it = fs::recursive_directory_iterator( + overwrite_dir, fs::directory_options::skip_permission_denied, ec); + it != fs::recursive_directory_iterator(); ++it) { + if (!it->is_regular_file(ec)) + continue; + + auto rel = fs::relative(it->path(), overwrite_dir, ec); + if (ec) + continue; + + const std::string relStr = rel.string(); + const std::string key = toLower(relStr); + + if (m_tracked.count(key)) + continue; + + // Check if any mod also has this file — use the LAST match (highest + // priority in the load order, since mods are ordered low→high). + std::string matchedMod; + std::string matchedPath; + for (const auto& [modName, modPath] : mods) { + if (fs::exists(fs::path(modPath) / relStr, ec)) { + matchedMod = modName; + matchedPath = modPath; + } + } + + if (!matchedPath.empty()) { + m_tracked[key] = matchedPath; + ++detected; + std::fprintf(stderr, "[VFS] initial scan: %s exists in overwrite AND %s — tracking mod\n", + relStr.c_str(), matchedMod.c_str()); + } + } + + if (detected > 0) + std::fprintf(stderr, "[VFS] initial scan tracked %d files\n", detected); + else + std::fprintf(stderr, "[VFS] initial scan: overwrite has %s\n", + fs::is_empty(overwrite_dir, ec) ? "no files" : "files but no mod matches"); +} + +std::unordered_map<std::string, std::string> TrackedWrites::allMappings() const +{ + std::lock_guard lock(m_mutex); + return m_tracked; +} diff --git a/src/src/vfs/trackedwrites.h b/src/src/vfs/trackedwrites.h new file mode 100644 index 0000000..c9218ae --- /dev/null +++ b/src/src/vfs/trackedwrites.h @@ -0,0 +1,73 @@ +#ifndef VFS_TRACKEDWRITES_H +#define VFS_TRACKEDWRITES_H + +#include <mutex> +#include <string> +#include <unordered_map> +#include <vector> + +// Tracks files that the user has moved from Overwrite into a mod folder. +// When the VFS sees a write to a tracked file, the write goes in-place to +// the mod folder instead of creating a new copy in Overwrite (COW). +// +// Tracking is updated when: +// - Files are moved from Overwrite via the MO2 UI (sync, create mod, +// move to existing mod, drag-and-drop). +// - Manual moves are detected at VFS mount time by comparing the current +// Overwrite contents against a saved snapshot from the previous session. +// +// The tracking data is persisted as a JSON file in the instance directory. + +class TrackedWrites +{ +public: + TrackedWrites() = default; + + // Load tracking data from a JSON file. + void load(const std::string& path); + + // Save tracking data to a JSON file. + void save(const std::string& path) const; + + // Record that a file at relative_path now lives in mod_folder_path. + // mod_folder_path is the absolute path to the mod's root directory. + void track(const std::string& relative_path, const std::string& mod_folder_path); + + // Remove tracking for a file (e.g. if the user deletes it or moves it back). + void untrack(const std::string& relative_path); + + // Check if a file is tracked. If so, returns the absolute path to the + // mod folder it should be written to. Returns empty string if not tracked. + std::string modFolderFor(const std::string& relative_path) const; + + // Snapshot the contents of the overwrite directory (relative paths). + // Call this at unmount time so we can detect manual moves on next mount. + void snapshotOverwrite(const std::string& overwrite_dir); + + // Detect files that disappeared from overwrite since the last snapshot + // and now exist in a mod folder. Adds them to tracking automatically. + // mods is the same (mod_name, mod_path) vector used for VFS building. + void detectManualMoves( + const std::string& overwrite_dir, + const std::vector<std::pair<std::string, std::string>>& mods); + + // Initial scan: for each file in overwrite, check if the same relative + // path exists in any mod. If so, track it — the user already moved it. + // Called on first run (no previous snapshot/tracking data exists). + void initialScan( + const std::string& overwrite_dir, + const std::vector<std::pair<std::string, std::string>>& mods); + + // Get all tracked mappings (for debugging / inspection). + std::unordered_map<std::string, std::string> allMappings() const; + +private: + // relative_path (lowercase) -> absolute mod folder path + mutable std::mutex m_mutex; + std::unordered_map<std::string, std::string> m_tracked; + + // Snapshot of overwrite directory contents from previous session + std::vector<std::string> m_overwriteSnapshot; +}; + +#endif |
