aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-03-10 02:36:59 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-03-10 02:36:59 -0500
commit7821fadddc8af8b1a712b0da02e35534996da422 (patch)
treee820e8e891a40d441adc0b6a33771eff7291000d
parent6a310f34ccf2dde98ca82136b992afd7bebfd3d5 (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>
-rw-r--r--src/src/fuseconnector.cpp63
-rw-r--r--src/src/fuseconnector.h5
-rw-r--r--src/src/mainwindow.cpp15
-rw-r--r--src/src/modlistviewactions.cpp9
-rw-r--r--src/src/organizercore.cpp58
-rw-r--r--src/src/organizercore.h1
-rw-r--r--src/src/vfs/mo2filesystem.cpp192
-rw-r--r--src/src/vfs/mo2filesystem.h4
-rw-r--r--src/src/vfs/trackedwrites.cpp370
-rw-r--r--src/src/vfs/trackedwrites.h73
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