aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSulfurNitride <lukew19@proton.me>2026-06-19 18:01:56 -0500
committerSulfurNitride <lukew19@proton.me>2026-06-19 18:01:56 -0500
commitea794b1eba77e9b5dd8bea0fe4d3280b67d77a5a (patch)
treec24a032e914bcf9e8572a47e096571e8770cf8f3
parent6c511ba040b07279bf5495088bfe98e2b6d6d318 (diff)
Fix VFS writes and stylesheet URLs
-rwxr-xr-xbuild.sh10
-rw-r--r--libs/installer_fomod/src/fomodinstallerdialog.cpp48
-rw-r--r--libs/installer_fomod/src/installerfomod.cpp2
-rw-r--r--src/src/moapplication.cpp3
-rw-r--r--src/src/vfs/mo2filesystem.cpp98
5 files changed, 120 insertions, 41 deletions
diff --git a/build.sh b/build.sh
index bd23ccf..d58ae2f 100755
--- a/build.sh
+++ b/build.sh
@@ -17,8 +17,10 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Auto-detect container runtime
if command -v podman >/dev/null 2>&1; then
DOCKER=podman
+ VOLUME_SUFFIX=",Z"
elif command -v docker >/dev/null 2>&1; then
DOCKER=docker
+ VOLUME_SUFFIX=""
else
echo "ERROR: Neither podman nor docker found in PATH"
exit 1
@@ -53,8 +55,8 @@ mkdir -p "${CCACHE_DIR}"
if [ "${BUILD_MODE}" = "shell" ]; then
echo "=== Dropping into build container shell ==="
exec ${DOCKER} run --rm -it \
- -v "${SCRIPT_DIR}:/src:rw" \
- -v "${CCACHE_DIR}:/ccache:rw" \
+ -v "${SCRIPT_DIR}:/src:rw${VOLUME_SUFFIX}" \
+ -v "${CCACHE_DIR}:/ccache:rw${VOLUME_SUFFIX}" \
-e CCACHE_DIR=/ccache \
-w /src \
--device /dev/fuse \
@@ -68,8 +70,8 @@ echo "=== Starting build (mode: ${BUILD_MODE}) ==="
# Defaults to all available cores.
BUILD_JOBS="${BUILD_JOBS:-$(nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1)}"
${DOCKER} run --rm \
- -v "${SCRIPT_DIR}:/src:rw" \
- -v "${CCACHE_DIR}:/ccache:rw" \
+ -v "${SCRIPT_DIR}:/src:rw${VOLUME_SUFFIX}" \
+ -v "${CCACHE_DIR}:/ccache:rw${VOLUME_SUFFIX}" \
-e CCACHE_DIR=/ccache \
-e BUILD_MODE="${BUILD_MODE}" \
-e BUILD_JOBS="${BUILD_JOBS}" \
diff --git a/libs/installer_fomod/src/fomodinstallerdialog.cpp b/libs/installer_fomod/src/fomodinstallerdialog.cpp
index f675e3d..7c27726 100644
--- a/libs/installer_fomod/src/fomodinstallerdialog.cpp
+++ b/libs/installer_fomod/src/fomodinstallerdialog.cpp
@@ -146,6 +146,26 @@ struct XmlParseError : std::runtime_error
XmlParseError(const QString& message) : std::runtime_error(qUtf8Printable(message)) {}
};
+static QString normalizeArchivePath(QString path)
+{
+ path.replace("\\", "/");
+ path = QDir::cleanPath(path);
+ return path == "." ? QString() : path;
+}
+
+static QString joinArchivePath(const QString& base, const QString& child)
+{
+ if (base.isEmpty()) {
+ return normalizeArchivePath(child);
+ }
+ return normalizeArchivePath(QDir(base).filePath(child));
+}
+
+static QString tempArchivePath(const QString& archivePath)
+{
+ return QDir(QDir::tempPath()).filePath(normalizeArchivePath(archivePath));
+}
+
QByteArray skipXmlHeader(QIODevice& file)
{
static const unsigned char UTF16LE_BOM[] = {0xFF, 0xFE};
@@ -245,7 +265,8 @@ void FomodInstallerDialog::readXml(QFile& file,
void FomodInstallerDialog::readInfoXml()
{
- QFile file(QDir::tempPath() + "/" + m_FomodPath + "/" + m_FomodDirName + "/info.xml");
+ QFile file(tempArchivePath(joinArchivePath(
+ joinArchivePath(m_FomodPath, m_FomodDirName), "info.xml")));
// We don't need a info.xml file, so we just return if we cannot open it:
if (!file.open(QIODevice::ReadOnly)) {
@@ -256,7 +277,8 @@ void FomodInstallerDialog::readInfoXml()
void FomodInstallerDialog::readModuleConfigXml()
{
- QFile file(QDir::tempPath() + "/" + m_FomodPath + "/" + m_FomodDirName + "/ModuleConfig.xml");
+ QFile file(tempArchivePath(joinArchivePath(
+ joinArchivePath(m_FomodPath, m_FomodDirName), "ModuleConfig.xml")));
if (!file.open(QIODevice::ReadOnly)) {
throw Exception(tr("%1 missing.").arg(file.fileName()));
}
@@ -270,8 +292,8 @@ void FomodInstallerDialog::initData(IOrganizer* moInfo)
// parse provided package information
readInfoXml();
- QString screenshotPath =
- QDir::tempPath() + "/" + m_FomodPath + "/" + m_FomodDirName + "/screenshot.png";
+ QString screenshotPath = tempArchivePath(joinArchivePath(
+ joinArchivePath(m_FomodPath, m_FomodDirName), "screenshot.png"));
if (!QImage(screenshotPath).isNull()) {
ui->screenshotLabel->setScalableResource(screenshotPath);
ui->screenshotExpand->setVisible(false);
@@ -645,8 +667,8 @@ void FomodInstallerDialog::highlightControl(QAbstractButton* button)
if (screenshotName.isValid()) {
QString screenshotFileName = screenshotName.toString();
if (!screenshotFileName.isEmpty()) {
- QString temp = QDir::tempPath() + "/" + m_FomodPath + "/" +
- QDir::fromNativeSeparators(screenshotFileName);
+ QString temp =
+ tempArchivePath(joinArchivePath(m_FomodPath, screenshotFileName));
ui->screenshotLabel->setScalableResource(temp);
ui->screenshotExpand->setVisible(true);
} else {
@@ -775,10 +797,12 @@ void FomodInstallerDialog::readFileList(XmlReader& reader, FileDescriptorList& f
log::debug("Ignoring {} entry with empty source.", reader.name().toString());
} else {
FileDescriptor* file = new FileDescriptor(this);
- file->m_Source = attributes.value("source").toString();
- file->m_Destination = attributes.hasAttribute("destination")
- ? attributes.value("destination").toString()
- : file->m_Source;
+ file->m_Source =
+ normalizeArchivePath(attributes.value("source").toString());
+ file->m_Destination =
+ attributes.hasAttribute("destination")
+ ? normalizeArchivePath(attributes.value("destination").toString())
+ : file->m_Source;
file->m_Priority = attributes.hasAttribute("priority")
? attributes.value("priority").toString().toInt()
: 0;
@@ -1691,8 +1715,8 @@ void FomodInstallerDialog::on_screenshotExpand_clicked()
continue;
}
- QString temp = QDir::tempPath() + "/" + m_FomodPath + "/" +
- QDir::fromNativeSeparators(screenshotFileName);
+ QString temp =
+ tempArchivePath(joinArchivePath(m_FomodPath, screenshotFileName));
carouselImages.push_back(std::pair<QString, QString>(choice->text(), temp));
// Focus the screenshot carousel on the user's selected choice (or the first if
diff --git a/libs/installer_fomod/src/installerfomod.cpp b/libs/installer_fomod/src/installerfomod.cpp
index 239ec9a..e526610 100644
--- a/libs/installer_fomod/src/installerfomod.cpp
+++ b/libs/installer_fomod/src/installerfomod.cpp
@@ -216,7 +216,7 @@ InstallerFomod::install(GuessedValue<QString>& modName,
try {
std::shared_ptr<const IFileTree> fomodTree = findFomodDirectory(tree);
- QString fomodPath = fomodTree->parent()->path();
+ QString fomodPath = fomodTree->parent()->path("/");
QString fomodDirName = fomodTree->name();
FomodInstallerDialog dialog(
this, modName, fomodPath, fomodDirName,
diff --git a/src/src/moapplication.cpp b/src/src/moapplication.cpp
index 4b1ee86..eb6e46d 100644
--- a/src/src/moapplication.cpp
+++ b/src/src/moapplication.cpp
@@ -852,8 +852,9 @@ QString styleSheetFontSizeOverride(int fontSize)
QString resolveStyleSheetUrl(const QString& url, const QString& baseDir)
{
const QString trimmed = url.trimmed();
+ const QUrl parsed(trimmed);
if (trimmed.isEmpty() || trimmed.startsWith(':') || trimmed.startsWith('/') ||
- trimmed.contains("://")) {
+ !parsed.scheme().isEmpty()) {
return trimmed;
}
diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp
index 9a65017..5176614 100644
--- a/src/src/vfs/mo2filesystem.cpp
+++ b/src/src/vfs/mo2filesystem.cpp
@@ -1796,17 +1796,21 @@ 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);
+ const bool truncateOnOpen = writable && ((fi->flags & O_TRUNC) != 0);
bool isBacking = snap.is_backing;
bool cowPending = false;
bool isTracked = false;
+ uint64_t openSize = snap.size;
+ auto openMtime = snap.mtime;
// 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. Existing mod/data-dir files → open R/W in place.
- // 4. Base-game backing files → open R/O and COW to staging only on the
- // first actual write().
+ // 3. Existing mod/data-dir/base-game files → open R/O and COW to staging
+ // on the first actual write().
+ // 4. O_TRUNC on a COW source must materialize + truncate immediately so
+ // the caller sees normal POSIX open(..., O_TRUNC) semantics.
//
// This matches upstream USVFS behavior more closely than the previous
// conservative "copy everything writable into overwrite" approach.
@@ -1832,10 +1836,20 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi)
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);
+ int openFlags = O_RDWR | O_CLOEXEC;
+ if (truncateOnOpen) {
+ openFlags |= O_TRUNC;
+ }
+ fd = open(modFilePath.c_str(), openFlags);
if (fd >= 0) {
realPath = modFilePath;
isTracked = true;
+ if (truncateOnOpen) {
+ openSize = 0;
+ openMtime = std::chrono::system_clock::now();
+ updateFileNodeKnown(ctx, path, realPath, originForPath(ctx, realPath),
+ openSize, openMtime);
+ }
} else {
// Mod file disappeared — fall through to normal handling
trackedMod.clear();
@@ -1844,18 +1858,49 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi)
if (fd < 0 && alreadyStaged) {
// Already in staging/overwrite — open R/W directly.
- fd = open(realPath.c_str(), O_RDWR);
- } else if (fd < 0 && isBacking) {
- // Backing file — open R/O, defer COW to first write().
+ int openFlags = O_RDWR | O_CLOEXEC;
+ if (truncateOnOpen) {
+ openFlags |= O_TRUNC;
+ }
+ fd = open(realPath.c_str(), openFlags);
+ if (fd >= 0 && truncateOnOpen) {
+ openSize = 0;
+ openMtime = std::chrono::system_clock::now();
+ updateFileNodeKnown(ctx, path, realPath, originForPath(ctx, realPath),
+ openSize, openMtime);
+ }
+ } else if (fd < 0 && truncateOnOpen) {
+ try {
+ std::string newPath;
+ if (isBacking && ctx->backing_dir_fd >= 0) {
+ newPath = ctx->overwrite->copyOnWriteFromFd(ctx->backing_dir_fd, path);
+ } else {
+ newPath = ctx->overwrite->copyOnWrite(realPath, path);
+ }
+
+ fd = open(newPath.c_str(), O_RDWR | O_CLOEXEC | O_TRUNC);
+ if (fd >= 0) {
+ realPath = newPath;
+ isBacking = false;
+ cowPending = false;
+ openSize = 0;
+ openMtime = std::chrono::system_clock::now();
+ updateFileNodeKnown(ctx, path, newPath, originForPath(ctx, newPath),
+ openSize, openMtime);
+ }
+ } catch (...) {
+ fuse_reply_err(req, EIO);
+ return;
+ }
+ } else if (fd < 0) {
+ // Existing mod/base-game file — open R/O, defer COW to first write().
if (ctx->backing_dir_fd >= 0) {
- fd = openat(ctx->backing_dir_fd, realPath.c_str(), O_RDONLY);
+ fd = isBacking ? openat(ctx->backing_dir_fd, realPath.c_str(), O_RDONLY | O_CLOEXEC)
+ : open(realPath.c_str(), O_RDONLY | O_CLOEXEC);
} else {
- fd = open(realPath.c_str(), O_RDONLY);
+ fd = open(realPath.c_str(), O_RDONLY | O_CLOEXEC);
}
cowPending = true;
- } else if (fd < 0) {
- // Existing mod/data-dir file — write in place.
- fd = open(realPath.c_str(), O_RDWR);
}
if (fd < 0) {
fuse_reply_err(req, errno != 0 ? errno : EIO);
@@ -1879,8 +1924,8 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi)
of.cow_pending = cowPending;
of.is_tracked = isTracked;
of.relative_path = path;
- of.virtual_size = snap.size;
- of.virtual_mtime = snap.mtime;
+ of.virtual_size = openSize;
+ of.virtual_mtime = openMtime;
ctx->open_files[fh] = std::move(of);
}
@@ -2010,9 +2055,9 @@ void mo2_write(fuse_req_t req, fuse_ino_t /*ino*/, const char* buf, size_t size,
return;
}
- // Lazy COW applies only to backing-game files. Existing mod files should
- // stay in place so config writes update the source mod rather than creating
- // a shadow copy in overwrite.
+ // Lazy COW applies to any existing VFS source that is not already staging,
+ // overwrite, or an explicitly tracked custom-output file. This preserves
+ // mod files and lets generated output land in staging/overwrite.
if (cowPending) {
ctx->cow_write_count.fetch_add(1, std::memory_order_relaxed);
try {
@@ -2375,13 +2420,20 @@ void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set,
}
}
- // COW only for backing files: copy to staging before truncating.
- // Tracked files, staging files, and existing mod files are truncated
- // in place.
+ // COW any non-staging source before truncating. Tracked files and already
+ // staged/overwrite files are truncated in place.
const std::string stagedPath = ctx->overwrite->stagingPath(path);
- if (targetIsBacking &&
- fs::path(target).lexically_normal().string() !=
- fs::path(stagedPath).lexically_normal().string()) {
+ const std::string overwritePath = ctx->overwrite->overwritePath(path);
+ const bool alreadyWritableTarget =
+ fs::path(target).lexically_normal().string() ==
+ fs::path(stagedPath).lexically_normal().string() ||
+ fs::path(target).lexically_normal().string() ==
+ fs::path(overwritePath).lexically_normal().string();
+ const bool trackedTarget =
+ ctx->tracked_writes != nullptr &&
+ !ctx->tracked_writes->modFolderFor(path).empty() &&
+ !targetIsBacking;
+ if (!alreadyWritableTarget && !trackedTarget) {
try {
if (targetIsBacking && ctx->backing_dir_fd >= 0) {
target = ctx->overwrite->copyOnWriteFromFd(ctx->backing_dir_fd, path);