aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/src/CMakeLists.txt2
-rw-r--r--src/src/fuseconnector.cpp39
-rw-r--r--src/src/fuseconnector.h2
-rw-r--r--src/src/organizercore.cpp32
-rw-r--r--src/src/protonlauncher.cpp23
-rw-r--r--src/src/protonlauncher.h2
-rw-r--r--src/src/settingsdialog.ui44
-rw-r--r--src/src/settingsdialogproton.cpp69
-rw-r--r--src/src/settingsdialogproton.h3
-rw-r--r--src/src/spawn.cpp2
-rw-r--r--src/src/vfs/mo2filesystem.cpp125
-rw-r--r--src/src/vfs/mo2filesystem.h12
-rw-r--r--src/src/vfs/trackedwrites.cpp20
13 files changed, 283 insertions, 92 deletions
diff --git a/src/src/CMakeLists.txt b/src/src/CMakeLists.txt
index e69a814..d7a7642 100644
--- a/src/src/CMakeLists.txt
+++ b/src/src/CMakeLists.txt
@@ -115,7 +115,7 @@ target_link_libraries(organizer PRIVATE
target_compile_definitions(organizer PRIVATE
SPDLOG_USE_STD_FORMAT
- FUSE_USE_VERSION=35
+ FUSE_USE_VERSION=312
$<$<TARGET_EXISTS:Qt6::WebEngineWidgets>:MO2_WEBENGINE>)
if(NOT WIN32)
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp
index c749d69..b146359 100644
--- a/src/src/fuseconnector.cpp
+++ b/src/src/fuseconnector.cpp
@@ -160,9 +160,10 @@ void setupFuseOps(struct fuse_lowlevel_ops* ops)
ops->init = mo2_init;
ops->lookup = mo2_lookup;
ops->getattr = mo2_getattr;
- ops->opendir = mo2_opendir;
- ops->readdir = mo2_readdir;
- ops->open = mo2_open;
+ ops->opendir = mo2_opendir;
+ ops->readdir = mo2_readdir;
+ ops->readdirplus = mo2_readdirplus;
+ ops->open = mo2_open;
ops->read = mo2_read;
ops->write = mo2_write;
ops->create = mo2_create;
@@ -291,14 +292,15 @@ bool FuseConnector::mount(
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();
+ 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();
+ m_context->passthrough_requested = m_passthroughEnabled;
// NOTE: Do NOT include mount_point here — low-level API passes it
// separately to fuse_session_mount(). Including it here causes
@@ -335,7 +337,13 @@ bool FuseConnector::mount(
}
m_fuseThread = std::thread([this]() {
- fuse_session_loop_mt(m_session, nullptr);
+ // Enable clone_fd: each worker thread gets its own /dev/fuse fd,
+ // eliminating contention on a single fd lock under heavy parallel I/O.
+ struct fuse_loop_config* cfg = fuse_loop_cfg_create();
+ fuse_loop_cfg_set_clone_fd(cfg, 1);
+ fuse_loop_cfg_set_max_threads(cfg, 16);
+ fuse_session_loop_mt(m_session, cfg);
+ fuse_loop_cfg_destroy(cfg);
});
m_mounted = true;
@@ -438,6 +446,13 @@ void FuseConnector::setTrackingFilePath(const std::string& path)
std::fprintf(stderr, "[VFS] setTrackingFilePath: '%s'\n", path.c_str());
}
+void FuseConnector::setPassthroughEnabled(bool enabled)
+{
+ m_passthroughEnabled = enabled;
+ std::fprintf(stderr, "[VFS] passthrough %s by user\n",
+ enabled ? "enabled" : "disabled");
+}
+
std::shared_ptr<TrackedWrites> FuseConnector::trackedWrites() const
{
return m_trackedWrites;
diff --git a/src/src/fuseconnector.h b/src/src/fuseconnector.h
index a13f014..6d089bb 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 setTrackingFilePath(const std::string& path);
+ void setPassthroughEnabled(bool enabled);
std::shared_ptr<TrackedWrites> trackedWrites() const;
void unmount();
@@ -96,6 +97,7 @@ private:
std::thread m_fuseThread;
bool m_mounted = false;
bool m_discardStaging = false;
+ bool m_passthroughEnabled = false;
};
#endif
diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp
index c0f1bf9..3896039 100644
--- a/src/src/organizercore.cpp
+++ b/src/src/organizercore.cpp
@@ -661,6 +661,13 @@ void OrganizerCore::prepareVFS()
owPath.toStdString().c_str(), trackPath.toStdString().c_str());
m_USVFS.setTrackingFilePath(trackPath.toStdString());
}
+
+ // FUSE passthrough: read the per-instance setting and pass it to the VFS.
+ {
+ const bool passthrough =
+ QSettings().value("fluorine/fuse_passthrough", false).toBool();
+ m_USVFS.setPassthroughEnabled(passthrough);
+ }
#endif
m_USVFS.updateMapping(fileMapping(m_CurrentProfile->name(), QString()));
}
@@ -2100,20 +2107,29 @@ void OrganizerCore::syncOverwrite()
#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.
+ // Use profile priority order (highest-priority mod wins) so writes
+ // go to the correct mod when multiple mods contain the same path.
const QString modsDir = QDir::fromNativeSeparators(m_Settings.paths().mods());
+
+ // Mod names in ascending priority order (last = highest priority).
+ const QStringList modsByPriority =
+ m_ModList.allModsByProfilePriority(m_CurrentProfile.get());
+
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;
+ // Find highest-priority mod that has this file.
+ QString bestModPath;
+ for (const auto& modName : modsByPriority) {
+ const QString modPath = modsDir + "/" + modName;
+ if (QFile::exists(modPath + "/" + relPath)) {
+ bestModPath = modPath;
+ // Don't break — keep going to find the highest-priority match.
}
}
+ if (!bestModPath.isEmpty()) {
+ trackOverwriteMove(relPath, bestModPath);
+ }
}
}
#endif
diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp
index 4339920..e2895e2 100644
--- a/src/src/protonlauncher.cpp
+++ b/src/src/protonlauncher.cpp
@@ -200,19 +200,6 @@ void wrapProgram(const QStringList& wrapperCommands, const QString& program,
wrappedArguments.append(arguments);
}
-void maybeWrapWithSteamRun(bool useSteamRun, QString& program, QStringList& arguments)
-{
- if (!useSteamRun) {
- return;
- }
-
- QStringList wrappedArgs;
- wrappedArgs.append(program);
- wrappedArgs.append(arguments);
- program = QStringLiteral("steam-run");
- arguments = wrappedArgs;
-}
-
bool isValidEnvKey(const QString& key)
{
if (key.isEmpty()) {
@@ -252,7 +239,7 @@ bool parseEnvAssignment(const QString& token, QString& keyOut, QString& valueOut
} // namespace
ProtonLauncher::ProtonLauncher()
- : m_steamAppId(0), m_useSteamRun(false), m_useSteamDrm(true)
+ : m_steamAppId(0), m_useSteamDrm(true)
{}
ProtonLauncher& ProtonLauncher::setBinary(const QString& path)
@@ -313,12 +300,6 @@ ProtonLauncher& ProtonLauncher::setWrapper(const QString& wrapperCmd)
return *this;
}
-ProtonLauncher& ProtonLauncher::setUseSteamRun(bool useSteamRun)
-{
- m_useSteamRun = useSteamRun;
- return *this;
-}
-
ProtonLauncher& ProtonLauncher::setSteamDrm(bool useSteamDrm)
{
m_useSteamDrm = useSteamDrm;
@@ -375,7 +356,6 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const
QString program;
QStringList arguments;
wrapProgram(m_wrapperCommands, protonScript, protonArgs, program, arguments);
- maybeWrapWithSteamRun(m_useSteamRun, program, arguments);
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.remove("PYTHONHOME");
@@ -448,7 +428,6 @@ bool ProtonLauncher::launchDirect(qint64& pid) const
QString program;
QStringList arguments;
wrapProgram(m_wrapperCommands, m_binary, m_arguments, program, arguments);
- maybeWrapWithSteamRun(m_useSteamRun, program, arguments);
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.remove("PYTHONHOME");
diff --git a/src/src/protonlauncher.h b/src/src/protonlauncher.h
index 6b3ee42..363d92b 100644
--- a/src/src/protonlauncher.h
+++ b/src/src/protonlauncher.h
@@ -20,7 +20,6 @@ public:
ProtonLauncher& setPrefix(const QString& path);
ProtonLauncher& setSteamAppId(uint32_t id);
ProtonLauncher& setWrapper(const QString& wrapperCmd);
- ProtonLauncher& setUseSteamRun(bool useSteamRun);
ProtonLauncher& setSteamDrm(bool useSteamDrm);
ProtonLauncher& setStoreVariant(const QString& variant);
ProtonLauncher& addEnvVar(const QString& key, const QString& value);
@@ -40,7 +39,6 @@ private:
QString m_prefixPath;
uint32_t m_steamAppId;
QStringList m_wrapperCommands;
- bool m_useSteamRun;
bool m_useSteamDrm;
QString m_storeVariant; // "GOG", "Epic", or empty for Steam
QMap<QString, QString> m_envVars;
diff --git a/src/src/settingsdialog.ui b/src/src/settingsdialog.ui
index 6588a71..7704b56 100644
--- a/src/src/settingsdialog.ui
+++ b/src/src/settingsdialog.ui
@@ -1866,16 +1866,6 @@ If you disable this feature, MO will only display official DLCs this way. Please
</property>
<layout class="QVBoxLayout" name="verticalLayout_38">
<item>
- <widget class="QCheckBox" name="steamRunCheckBox">
- <property name="text">
- <string>Wrap launches with steam-run</string>
- </property>
- <property name="toolTip">
- <string>Run launch and prefix setup commands through steam-run. Useful on NixOS and other immutable setups.</string>
- </property>
- </widget>
- </item>
- <item>
<layout class="QHBoxLayout" name="horizontalLayout_14">
<item>
<widget class="QLabel" name="label_65">
@@ -1893,6 +1883,40 @@ If you disable this feature, MO will only display official DLCs this way. Please
</widget>
</item>
<item>
+ <widget class="QGroupBox" name="groupBox_vfsPerformance">
+ <property name="title">
+ <string>VFS Performance</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_vfsPerf">
+ <item>
+ <widget class="QCheckBox" name="fusePassthroughCheckBox">
+ <property name="text">
+ <string>Enable FUSE Passthrough (near-native I/O)</string>
+ </property>
+ <property name="toolTip">
+ <string>Let the kernel serve game file reads directly, bypassing userspace. Dramatically improves I/O performance for loading textures, meshes, and archives. Requires a one-time privilege grant (sudo prompt). Needs kernel 6.9+ and libfuse 3.16+.</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="passthroughStatusLabel">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ <property name="font">
+ <font>
+ <pointsize>9</pointsize>
+ </font>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
<spacer name="verticalSpacer_16">
<property name="orientation">
<enum>Qt::Vertical</enum>
diff --git a/src/src/settingsdialogproton.cpp b/src/src/settingsdialogproton.cpp
index 0199744..36838c4 100644
--- a/src/src/settingsdialogproton.cpp
+++ b/src/src/settingsdialogproton.cpp
@@ -9,7 +9,9 @@
#include <uibase/utility.h>
#include <nak_ffi.h>
#include <atomic>
+#include <QCheckBox>
#include <QComboBox>
+#include <QCoreApplication>
#include <QDateTime>
#include <QDialog>
#include <QDialogButtonBox>
@@ -40,12 +42,56 @@ ProtonSettingsTab::ProtonSettingsTab(Settings& s, SettingsDialog& d)
ui->protonProgressBar->setValue(0);
ui->protonProgressBar->setVisible(false);
- ui->steamRunCheckBox->setChecked(
- QSettings().value("fluorine/use_steam_run", false).toBool());
-
ui->launchWrapperEdit->setPlaceholderText("mangohud --dlsym");
ui->launchWrapperEdit->setText(QSettings().value("fluorine/launch_wrapper").toString());
+ // FUSE passthrough toggle — requires CAP_SYS_ADMIN on the binary.
+ ui->fusePassthroughCheckBox->setChecked(
+ QSettings().value("fluorine/fuse_passthrough", false).toBool());
+ ui->passthroughStatusLabel->setText(QString());
+
+ QObject::connect(ui->fusePassthroughCheckBox, &QCheckBox::toggled, this,
+ [this](bool checked) {
+ if (!checked) {
+ // Disabling doesn't need sudo — just save the setting.
+ QSettings().setValue("fluorine/fuse_passthrough", false);
+ ui->passthroughStatusLabel->setText(tr("Passthrough disabled. Takes effect on next game launch."));
+ return;
+ }
+
+ // Enabling requires setting CAP_SYS_ADMIN on the binary.
+ // Find the actual binary path (ModOrganizer-core).
+ const QString binary = QCoreApplication::applicationFilePath();
+ if (binary.isEmpty()) {
+ ui->fusePassthroughCheckBox->setChecked(false);
+ ui->passthroughStatusLabel->setText(tr("Could not determine binary path."));
+ return;
+ }
+
+ ui->passthroughStatusLabel->setText(
+ tr("Granting FUSE passthrough capability... (sudo prompt)"));
+
+ // Use pkexec (graphical sudo) to set the file capability.
+ // This is a one-time operation — the capability persists across runs.
+ QProcess proc;
+ proc.setProgram("pkexec");
+ proc.setArguments({"setcap", "cap_sys_admin+ep", binary});
+ proc.start();
+ proc.waitForFinished(60000); // 60s timeout for user to auth
+
+ if (proc.exitCode() == 0) {
+ QSettings().setValue("fluorine/fuse_passthrough", true);
+ ui->passthroughStatusLabel->setText(
+ tr("Passthrough enabled. Takes effect on next game launch."));
+ } else {
+ ui->fusePassthroughCheckBox->setChecked(false);
+ const QString err = QString::fromUtf8(proc.readAllStandardError()).trimmed();
+ ui->passthroughStatusLabel->setText(
+ tr("Failed to set capability: %1").arg(
+ err.isEmpty() ? tr("authorization denied or pkexec not found") : err));
+ }
+ });
+
populateProtons();
QObject::connect(ui->protonVersionCombo, &QComboBox::currentIndexChanged, this,
@@ -114,8 +160,6 @@ ProtonSettingsTab::ProtonSettingsTab(Settings& s, SettingsDialog& d)
void ProtonSettingsTab::update()
{
- QSettings().setValue("fluorine/use_steam_run",
- ui->steamRunCheckBox->isChecked());
QSettings().setValue("fluorine/launch_wrapper", ui->launchWrapperEdit->text());
}
@@ -237,8 +281,7 @@ void ProtonSettingsTab::onCreatePrefix()
ui->nakInstallLog->setVisible(true);
ui->toggleInstallLog->setChecked(true);
- startInstallTask(0, pfxPath, protonName, protonPath,
- ui->steamRunCheckBox->isChecked());
+ startInstallTask(0, pfxPath, protonName, protonPath);
}
void ProtonSettingsTab::onDeletePrefix()
@@ -287,8 +330,7 @@ void ProtonSettingsTab::onRecreatePrefix()
ui->toggleInstallLog->setChecked(true);
startInstallTask(cfg->app_id, cfg->prefix_path, cfg->proton_name,
- cfg->proton_path,
- ui->steamRunCheckBox->isChecked());
+ cfg->proton_path);
}
void ProtonSettingsTab::onOpenPrefixFolder()
@@ -579,8 +621,7 @@ void ProtonSettingsTab::showGameRegistryDialog()
void ProtonSettingsTab::startInstallTask(uint32_t appId, const QString& prefixPath,
const QString& protonName,
- const QString& protonPath,
- bool useSteamRun)
+ const QString& protonPath)
{
m_pendingAppId = appId;
m_pendingPrefixPath = prefixPath;
@@ -595,14 +636,11 @@ void ProtonSettingsTab::startInstallTask(uint32_t appId, const QString& prefixPa
appId,
prefixPath,
protonName,
- protonPath,
- useSteamRun]() -> InstallResult {
+ protonPath]() -> InstallResult {
const QByteArray prefixPathUtf8 = prefixPath.toUtf8();
const QByteArray protonNameUtf8 = protonName.toUtf8();
const QByteArray protonPathUtf8 = protonPath.toUtf8();
- qputenv("NAK_USE_STEAM_RUN", useSteamRun ? "1" : "0");
-
// Set WINEPREFIX so NAK (and its child processes like winetricks) always
// target the correct prefix during Proton init.
qputenv("WINEPREFIX", prefixPathUtf8);
@@ -629,7 +667,6 @@ void ProtonSettingsTab::startInstallTask(uint32_t appId, const QString& prefixPa
}
const auto restoreNakEnv = qScopeGuard([protonWineUtf8] {
- qunsetenv("NAK_USE_STEAM_RUN");
qunsetenv("WINEPREFIX");
if (!protonWineUtf8.isEmpty()) {
qunsetenv("WINE");
diff --git a/src/src/settingsdialogproton.h b/src/src/settingsdialogproton.h
index 58c43f2..a1221c7 100644
--- a/src/src/settingsdialogproton.h
+++ b/src/src/settingsdialogproton.h
@@ -39,8 +39,7 @@ private:
QString findProtonWine(const QString& protonPath);
void startInstallTask(uint32_t appId, const QString& prefixPath,
- const QString& protonName, const QString& protonPath,
- bool useSteamRun);
+ const QString& protonName, const QString& protonPath);
void enqueueStatus(const QString& message);
void enqueueProgress(float progress);
diff --git a/src/src/spawn.cpp b/src/src/spawn.cpp
index 28010d0..4c91fbe 100644
--- a/src/src/spawn.cpp
+++ b/src/src/spawn.cpp
@@ -656,8 +656,6 @@ int spawn(const SpawnParameters &sp, pid_t &processId) {
.setArguments(argList)
.setWorkingDir(cwd)
.setSteamAppId(parseSteamAppId(sp.steamAppID))
- .setUseSteamRun(
- QSettings().value("fluorine/use_steam_run", false).toBool())
.setSteamDrm(useSteamDrm)
.setStoreVariant(storeVariant);
diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp
index 962a458..42e90c0 100644
--- a/src/src/vfs/mo2filesystem.cpp
+++ b/src/src/vfs/mo2filesystem.cpp
@@ -18,9 +18,12 @@ namespace
{
namespace fs = std::filesystem;
-constexpr double TTL_SECONDS = 30.0;
-constexpr double NEGATIVE_TTL_SECONDS = 5.0;
-constexpr double ATTR_CACHE_SECONDS = 30.0;
+// Mod files are immutable during a game session, so cache aggressively.
+// The VFS tree is built once at mount time and only mutated by our own
+// create/rename/unlink handlers (which invalidate affected entries).
+constexpr double TTL_SECONDS = 86400.0; // 24 hours
+constexpr double NEGATIVE_TTL_SECONDS = 3600.0; // 1 hour — Wine probes many non-existent files
+constexpr double ATTR_CACHE_SECONDS = 86400.0;
void fillStatForDir(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid);
void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid,
@@ -513,11 +516,80 @@ void mo2_init(void* userdata, struct fuse_conn_info* conn)
{
auto* ctx = static_cast<Mo2FsContext*>(userdata);
- // Keep kernel page cache valid across open/close as long as mtime/size are
- // unchanged. Mod files are immutable during a game session, so this avoids
- // re-reading file data from userspace on every open().
- if (conn->capable & FUSE_CAP_AUTO_INVAL_DATA) {
- conn->want |= FUSE_CAP_AUTO_INVAL_DATA;
+ // ── Disable AUTO_INVAL_DATA (CRITICAL for performance) ──
+ // AUTO_INVAL_DATA forces a getattr() on EVERY read() to check mtime,
+ // completely bypassing attr_timeout. This alone causes ~4x throughput
+ // reduction. Our VFS tree is immutable during a session — we handle
+ // invalidation ourselves via fuse_lowlevel_notify_inval_inode() when
+ // files are created/renamed/deleted through our own handlers.
+ conn->want &= ~FUSE_CAP_AUTO_INVAL_DATA;
+
+ // Let us control page cache invalidation explicitly.
+ if (conn->capable & FUSE_CAP_EXPLICIT_INVAL_DATA) {
+ conn->want |= FUSE_CAP_EXPLICIT_INVAL_DATA;
+ }
+
+ // Force readdirplus always (unset adaptive mode). Our stat info is
+ // free (in-memory tree), so always return full entries to pre-populate
+ // the kernel dentry + attr caches on every directory listing.
+ if (conn->capable & FUSE_CAP_READDIRPLUS) {
+ conn->want |= FUSE_CAP_READDIRPLUS;
+ conn->want &= ~FUSE_CAP_READDIRPLUS_AUTO;
+ }
+
+ // NOTE: FUSE_CAP_WRITEBACK_CACHE intentionally NOT enabled.
+ // It causes extra getattr calls for cache coherency, which hurts
+ // our read-heavy VFS more than the write buffering helps.
+
+ // Cache symlink targets in the kernel page cache.
+ if (conn->capable & FUSE_CAP_CACHE_SYMLINKS) {
+ conn->want |= FUSE_CAP_CACHE_SYMLINKS;
+ }
+
+ // Allow concurrent lookup()/readdir() on the same directory.
+ if (conn->capable & FUSE_CAP_PARALLEL_DIROPS) {
+ conn->want |= FUSE_CAP_PARALLEL_DIROPS;
+ }
+
+ // Splice: reduce kernel↔userspace data copies for reads and writes.
+ if (conn->capable & FUSE_CAP_SPLICE_WRITE) {
+ conn->want |= FUSE_CAP_SPLICE_WRITE;
+ }
+ if (conn->capable & FUSE_CAP_SPLICE_MOVE) {
+ conn->want |= FUSE_CAP_SPLICE_MOVE;
+ }
+ if (conn->capable & FUSE_CAP_SPLICE_READ) {
+ conn->want |= FUSE_CAP_SPLICE_READ;
+ }
+
+ // More async readahead/writeback slots (default is 12, far too low).
+ conn->max_background = 128;
+ conn->congestion_threshold = 96;
+
+ // FUSE passthrough: if requested by the user and supported by the kernel,
+ // enable kernel-level passthrough for read-only file opens. This lets the
+ // kernel serve reads directly from the backing file without round-tripping
+ // through userspace — near-native I/O performance for game file reads.
+ // Requires: kernel 6.9+, libfuse 3.16+, CAP_SYS_ADMIN on the binary.
+ ctx->passthrough_active = false;
+ if constexpr (FUSE_CAP_PASSTHROUGH != 0) {
+ if (ctx->passthrough_requested &&
+ (conn->capable & FUSE_CAP_PASSTHROUGH)) {
+ conn->want |= FUSE_CAP_PASSTHROUGH;
+ ctx->passthrough_active = true;
+ std::fprintf(stderr, "[VFS] FUSE passthrough enabled (kernel supports it)\n");
+ } else if (ctx->passthrough_requested) {
+ std::fprintf(stderr,
+ "[VFS] FUSE passthrough requested but NOT supported by kernel "
+ "(capable=0x%x). Falling back to userspace I/O.\n",
+ conn->capable);
+ }
+ } else {
+ if (ctx->passthrough_requested) {
+ std::fprintf(stderr,
+ "[VFS] FUSE passthrough requested but NOT available at compile time "
+ "(libfuse too old). Falling back to userspace I/O.\n");
+ }
}
// Increase max read/write buffer to 1MB (kernel 4.20+ supports this).
@@ -531,9 +603,13 @@ void mo2_init(void* userdata, struct fuse_conn_info* conn)
}
std::fprintf(stderr,
- "[VFS] init: auto_inval=%d max_readahead=%u max_write=%u\n",
+ "[VFS] init: auto_inval=%d explicit_inval=%d readdirplus=%d "
+ "passthrough=%d max_bg=%u max_readahead=%u\n",
(conn->want & FUSE_CAP_AUTO_INVAL_DATA) ? 1 : 0,
- conn->max_readahead, conn->max_write);
+ (conn->want & FUSE_CAP_EXPLICIT_INVAL_DATA) ? 1 : 0,
+ (conn->want & FUSE_CAP_READDIRPLUS) ? 1 : 0,
+ ctx->passthrough_active ? 1 : 0,
+ conn->max_background, conn->max_readahead);
}
void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name)
@@ -685,7 +761,8 @@ void mo2_opendir(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi)
}
fi->fh = dh;
- fi->cache_readdir = 1;
+ fi->keep_cache = 1; // Don't invalidate cached readdir on reopen
+ fi->cache_readdir = 1; // Let kernel cache directory entries
fuse_reply_open(req, fi);
}
@@ -964,6 +1041,32 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi)
fi->fh = fh;
fi->keep_cache = 1;
+
+ // FUSE passthrough: for read-only opens, let the kernel serve reads directly
+ // from the backing file. This bypasses userspace entirely — the kernel handles
+ // read() syscalls by reading the real file, giving near-native I/O performance.
+ // Only eligible for non-writable, non-COW files (pure reads).
+ if constexpr (FUSE_CAP_PASSTHROUGH != 0) {
+ if (ctx->passthrough_active && !writable && !cowPending && fd < 0) {
+ // Open the real file so the kernel can passthrough reads from it.
+ int ptFd = -1;
+ if (isBacking && ctx->backing_dir_fd >= 0) {
+ ptFd = openat(ctx->backing_dir_fd, realPath.c_str(), O_RDONLY);
+ } else {
+ ptFd = open(realPath.c_str(), O_RDONLY);
+ }
+ if (ptFd >= 0) {
+ fi->backing_id = static_cast<uint32_t>(ptFd);
+ // Store the fd so we can close it on release
+ std::scoped_lock lock(ctx->open_files_mutex);
+ auto it = ctx->open_files.find(fh);
+ if (it != ctx->open_files.end()) {
+ it->second.fd = ptFd;
+ }
+ }
+ }
+ }
+
fuse_reply_open(req, fi);
}
diff --git a/src/src/vfs/mo2filesystem.h b/src/src/vfs/mo2filesystem.h
index 2c4b257..2883a18 100644
--- a/src/src/vfs/mo2filesystem.h
+++ b/src/src/vfs/mo2filesystem.h
@@ -16,6 +16,13 @@
#include <unordered_map>
#include <vector>
+// FUSE passthrough support (kernel 6.9+, libfuse 3.16+).
+// When available, the kernel serves reads directly from the backing file
+// without round-tripping through userspace — near-native I/O performance.
+#ifndef FUSE_CAP_PASSTHROUGH
+#define FUSE_CAP_PASSTHROUGH 0
+#endif
+
struct Mo2FsContext
{
std::shared_ptr<VfsTree> tree;
@@ -90,6 +97,11 @@ struct Mo2FsContext
uid_t uid = 0;
gid_t gid = 0;
+
+ // FUSE passthrough: when true AND kernel supports it, read-only file opens
+ // use kernel-level passthrough (zero-copy I/O bypassing userspace).
+ bool passthrough_requested = false;
+ bool passthrough_active = false; // set in mo2_init after capability check
};
void mo2_init(void* userdata, struct fuse_conn_info* conn);
diff --git a/src/src/vfs/trackedwrites.cpp b/src/src/vfs/trackedwrites.cpp
index 66e83e5..4392db3 100644
--- a/src/src/vfs/trackedwrites.cpp
+++ b/src/src/vfs/trackedwrites.cpp
@@ -272,7 +272,8 @@ void TrackedWrites::detectManualMoves(
if (missing.empty())
return;
- // For each missing file, check if it now exists in any mod folder
+ // For each missing file, check if it now exists in any mod folder.
+ // Use the LAST match (highest priority — mods are ordered low→high).
int detected = 0;
for (const auto& relPath : missing) {
const std::string key = toLower(relPath);
@@ -280,16 +281,23 @@ void TrackedWrites::detectManualMoves(
if (m_tracked.count(key))
continue;
+ std::string matchedMod;
+ std::string matchedPath;
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;
+ matchedMod = modName;
+ matchedPath = modPath;
+ // Don't break — keep going to find the highest-priority match.
}
}
+
+ if (!matchedPath.empty()) {
+ m_tracked[key] = matchedPath;
+ ++detected;
+ std::fprintf(stderr, "[VFS] detected manual move: %s -> %s\n",
+ relPath.c_str(), matchedMod.c_str());
+ }
}
if (detected > 0)