aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-04-12 21:04:15 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-04-12 21:04:15 -0500
commit3949dcfce95af4bd305f258ff5b170d7d50435f6 (patch)
tree4c768be256c40f31794c3311f0a2c22933a6705a /src
parent892070f3dec1dc690983fd862880e37e711c2516 (diff)
VFS perf fixes, icon/stylesheet compat, download filename fix
VFS: - Open backing fd at mo2_open time for read-only handles so mo2_read can splice without re-opening the file on every read call. - Bump RLIMIT_NOFILE at mount time to fit the resulting fd pressure when the game keeps hundreds of BSAs open. - Dedicated node_cache_mutex so resolveByInode / mo2_lookup stop racing unordered_map writes while multiple tree_mutex readers are active. - max_read=1MB + matching conn->max_read and raised max_readahead/max_write so the kernel merges Wine's small sequential reads into bigger FUSE requests. - Per-op wall-clock counters in mo2_init() logs so we can distinguish VFS latency from game-side work. Proton launch: - Write dxvk.conf to the prefix and set DXVK_CONFIG_FILE to force dxvk.enableGraphicsPipelineLibrary=False, avoiding long GPL compile stalls on first run. UI / plugin compat: - Clamp IconDelegate's per-icon width to [8, 16] so narrow content columns don't trigger QIcon::pixmap(0,0) returning null and logging "failed to load icon" every repaint. - Pre-create lowercase symlinks in the stylesheet directory so QSS files authored on Windows resolve url(foo.svg) when on-disk files are Foo.svg. - Flip content icons empty-chessboard.png and facegen.png to 8-bit RGBA so Qt's built-in PNG reader loads them uniformly. - Add mobase.Version.canonicalString shim for legacy Python plugins that still call the old VersionInfo method on appVersion()'s return. - BSPluginList: compare normalized plugin name lists instead of byte hashes so the post-run case-fix refresh stops spuriously firing the "load order changed" dialog. - BSPluginList disabled by default. Download manager: - Parse CDN URL with QUrl + QUrlQuery and read the filename from the response-content-disposition query param (RFC 6266 quoted / unquoted / ext-value forms), not QFileInfo on the raw URL. - When the API or URL gives us a CDN object key (no archive extension), prefer the actual Content-Disposition header from the live response in metaDataChanged and downloadFinished. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/src/downloadmanager.cpp100
-rw-r--r--src/src/fuseconnector.cpp10
-rw-r--r--src/src/icondelegate.cpp7
-rw-r--r--src/src/moapplication.cpp47
-rw-r--r--src/src/pluginlist.cpp6
-rw-r--r--src/src/protonlauncher.cpp16
-rw-r--r--src/src/resources/contents/empty-chessboard.pngbin537 -> 910 bytes
-rw-r--r--src/src/resources/contents/facegen.pngbin246 -> 638 bytes
-rw-r--r--src/src/vfs/mo2filesystem.cpp172
-rw-r--r--src/src/vfs/mo2filesystem.h13
10 files changed, 309 insertions, 62 deletions
diff --git a/src/src/downloadmanager.cpp b/src/src/downloadmanager.cpp
index 1b5c6cb..91518ff 100644
--- a/src/src/downloadmanager.cpp
+++ b/src/src/downloadmanager.cpp
@@ -42,8 +42,10 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>.
#include <QHttp2Configuration>
#include <QInputDialog>
#include <QMessageBox>
+#include <QRegularExpression>
#include <QTextDocument>
#include <QTimer>
+#include <QUrlQuery>
#include <boost/bind/bind.hpp>
#include <regex>
@@ -55,6 +57,28 @@ using namespace MOBase;
static const char UNFINISHED[] = ".unfinished";
+// Heuristic: does this filename look like a CDN object key that should be
+// replaced by the server's Content-Disposition filename if available?
+// Nexus v2 file metadata sometimes returns `file_name` as a CDN-path UUID
+// (e.g. "91/83/bb/9183bbff-...") rather than the actual archive name.
+static bool looksLikeCdnObjectKey(const QString& name)
+{
+ if (name.isEmpty()) {
+ return true;
+ }
+ // Real archive filenames always end in an archive suffix. If the name
+ // has no recognized archive extension, assume it's a CDN key.
+ static const QStringList kArchiveSuffixes = {
+ ".zip", ".rar", ".7z", ".tar", ".gz", ".bz2",
+ ".xz", ".exe", ".msi", ".bsa", ".ba2"};
+ for (const auto& suf : kArchiveSuffixes) {
+ if (name.endsWith(suf, Qt::CaseInsensitive)) {
+ return false;
+ }
+ }
+ return true;
+}
+
unsigned int DownloadManager::DownloadInfo::s_NextDownloadID = 1U;
int DownloadManager::m_DirWatcherDisabler = 0;
@@ -478,28 +502,52 @@ void DownloadManager::queryDownloadListInfo()
bool DownloadManager::addDownload(const QStringList& URLs, QString gameName, int modID,
int fileID, const ModRepositoryFileInfo* fileInfo)
{
- QString fileName = QFileInfo(URLs.first()).fileName();
- if (fileName.isEmpty()) {
- fileName = "unknown";
- } else {
- fileName = QUrl::fromPercentEncoding(fileName.toUtf8());
+ // Parse the URL properly instead of feeding it to QFileInfo — QFileInfo
+ // doesn't understand URLs, so its fileName() can pull in query-string junk
+ // on Linux and we end up saving downloads as UUIDs from the S3 object key.
+ const QUrl parsedUrl = QUrl::fromEncoded(URLs.first().toLocal8Bit());
+
+ QString fileName;
+
+ // Nexus S3-signed download URLs carry the desired filename in the
+ // `response-content-disposition` query parameter, e.g.
+ // response-content-disposition=attachment;filename="JohnnyGuitar NVSE - 5.06.7z"
+ // (percent-encoded on the wire). QUrlQuery decodes values for us. This
+ // must be checked first — the URL path itself is just the CDN object key,
+ // which looks like "9183bb...b-fdcf" and is useless as a filename.
+ const QUrlQuery query(parsedUrl);
+ const QString kDispoKey = QStringLiteral("response-content-disposition");
+ if (query.hasQueryItem(kDispoKey)) {
+ const QString disposition =
+ query.queryItemValue(kDispoKey, QUrl::FullyDecoded);
+ // RFC 6266: filename="quoted" or filename=unquoted-token or filename*=ext-value.
+ static const QRegularExpression rxQuoted(
+ QStringLiteral("filename=\"([^\"]+)\""));
+ static const QRegularExpression rxUnquoted(
+ QStringLiteral("filename=([^;\\s]+)"));
+ static const QRegularExpression rxExtValue(
+ QStringLiteral("filename\\*=[^']*'[^']*'([^;\\s]+)"));
+ QRegularExpressionMatch m = rxQuoted.match(disposition);
+ if (!m.hasMatch()) m = rxExtValue.match(disposition);
+ if (!m.hasMatch()) m = rxUnquoted.match(disposition);
+ if (m.hasMatch()) {
+ fileName = QUrl::fromPercentEncoding(m.captured(1).toUtf8());
+ }
}
- // Temporary URLs for S3-compatible storage are signed for a single method, removing
- // the ability to make HEAD requests to such URLs. We can use the
- // response-content-disposition GET parameter, setting the Content-Disposition header,
- // to predetermine intended file name without a subrequest.
- if (fileName.contains("response-content-disposition=")) {
- std::regex exp("filename=\"(.+)\"");
- std::cmatch result;
- if (std::regex_search(fileName.toStdString().c_str(), result, exp)) {
- fileName = MOBase::sanitizeFileName(QString::fromUtf8(result.str(1).c_str()));
- if (fileName.isEmpty()) {
- fileName = "unknown";
- }
+ // Fall back to the last path segment of the URL if the disposition trick
+ // didn't produce anything.
+ if (fileName.isEmpty()) {
+ fileName = parsedUrl.fileName();
+ if (!fileName.isEmpty()) {
+ fileName = QUrl::fromPercentEncoding(fileName.toUtf8());
}
}
+ if (fileName.isEmpty()) {
+ fileName = "unknown";
+ }
+
QUrl preferredUrl = QUrl::fromEncoded(URLs.first().toLocal8Bit());
log::debug("selected download url: {}", preferredUrl.toString());
QHttp2Configuration h2Conf;
@@ -2365,7 +2413,10 @@ void DownloadManager::downloadFinished(int index)
QString oldName = QFileInfo(info->m_Output).fileName();
startDisableDirWatcher();
- if (!newName.isEmpty() && (oldName.isEmpty())) {
+ // Rename to Content-Disposition if either we have no name yet, or the
+ // name we seeded from the API looks like a CDN object key.
+ if (!newName.isEmpty() &&
+ (oldName.isEmpty() || looksLikeCdnObjectKey(info->m_FileName))) {
info->setName(getDownloadFileName(newName), true);
} else {
info->setName(m_OutputDirectory + "/" + info->m_FileName,
@@ -2407,8 +2458,17 @@ void DownloadManager::metaDataChanged()
DownloadInfo* info = findDownload(this->sender(), &index);
if (info != nullptr) {
- QString newName = getFileNameFromNetworkReply(info->m_Reply);
- if (!newName.isEmpty() && (info->m_FileName.isEmpty())) {
+ const QString newName = getFileNameFromNetworkReply(info->m_Reply);
+ // Prefer Content-Disposition over whatever we seeded from the API when
+ // the seeded name looks like a CDN object key. Otherwise only take it
+ // if we have no name at all yet.
+ const bool shouldReplace =
+ !newName.isEmpty() &&
+ (info->m_FileName.isEmpty() || looksLikeCdnObjectKey(info->m_FileName));
+
+ if (shouldReplace) {
+ log::info("metaDataChanged: replacing '{}' with Content-Disposition '{}'",
+ info->m_FileName, newName);
startDisableDirWatcher();
info->setName(getDownloadFileName(newName), true);
endDisableDirWatcher();
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp
index d890613..3e327bc 100644
--- a/src/src/fuseconnector.cpp
+++ b/src/src/fuseconnector.cpp
@@ -311,8 +311,16 @@ bool FuseConnector::mount(
// NOTE: Do NOT include mount_point here — low-level API passes it
// separately to fuse_session_mount(). Including it here causes
// "fuse: unknown option(s)" error.
+ //
+ // max_read=1MB: raise per-read-request cap from default 128KB. Must
+ // match conn->max_read set in mo2_init() or libfuse rejects the mount
+ // with a max-read-mismatch error. Going higher than 1MB triggers
+ // "fuse: reading device: Invalid argument" on some kernels where
+ // libfuse's receive buffer is sized off max_write + header and the
+ // kernel reads don't fit. 1MB is the safe ceiling.
std::vector<std::string> argvStorage = {
- "mo2fuse", "-o", "fsname=mo2linux", "-o", "noatime", "-o", "default_permissions"};
+ "mo2fuse", "-o", "fsname=mo2linux", "-o", "noatime",
+ "-o", "default_permissions", "-o", "max_read=1048576"};
std::vector<char*> argv;
argv.reserve(argvStorage.size());
diff --git a/src/src/icondelegate.cpp b/src/src/icondelegate.cpp
index 5a26730..b422e39 100644
--- a/src/src/icondelegate.cpp
+++ b/src/src/icondelegate.cpp
@@ -23,6 +23,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>.
#include <QLabel>
#include <QPainter>
#include <QPixmapCache>
+#include <algorithm>
#include <log.h>
using namespace MOBase;
@@ -48,7 +49,11 @@ void IconDelegate::paintIcons(QPainter* painter, const QStyleOptionViewItem& opt
painter->save();
int iconWidth = icons.size() > 0 ? ((option.rect.width() / icons.size()) - 4) : 16;
- iconWidth = std::min(16, iconWidth);
+ // Clamp: narrow content columns can make the per-icon slot 0 or negative,
+ // which causes QIcon::pixmap() to return null and log "failed to load icon"
+ // spuriously. Keep at least 8px so the pixmap call always succeeds; excess
+ // icons may clip, which is better than paint-side failures.
+ iconWidth = std::clamp(iconWidth, 8, 16);
const int margin = (option.rect.height() - iconWidth) / 2;
diff --git a/src/src/moapplication.cpp b/src/src/moapplication.cpp
index 96d9854..08c5703 100644
--- a/src/src/moapplication.cpp
+++ b/src/src/moapplication.cpp
@@ -868,6 +868,48 @@ QString extractBaseStyleFromStyleSheet(QFile& stylesheet, const QString& default
} // namespace
+#ifndef _WIN32
+// Walk a directory and create lowercase symlinks for any file whose name
+// contains uppercase letters, if the lowercase name doesn't already exist.
+// QSS files authored on Windows often reference asset files with lowercase
+// names but the actual files on disk use mixed case — on a case-sensitive
+// filesystem Qt's url() resolver fails. Shimming lowercase symlinks lets
+// the existing paths resolve without touching the original files.
+static void createLowercaseStylesheetShims(const QString& dirPath)
+{
+ namespace fs = std::filesystem;
+ std::error_code ec;
+ if (!fs::exists(dirPath.toStdString(), ec) ||
+ !fs::is_directory(dirPath.toStdString(), ec)) {
+ return;
+ }
+ for (auto it = fs::recursive_directory_iterator(
+ dirPath.toStdString(),
+ fs::directory_options::skip_permission_denied, ec);
+ !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) {
+ const auto& entry = *it;
+ if (!entry.is_regular_file(ec)) continue;
+
+ const std::string name = entry.path().filename().string();
+ std::string lowerName = name;
+ std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(),
+ [](unsigned char c) { return std::tolower(c); });
+ if (lowerName == name) continue; // already lowercase
+
+ const auto parent = entry.path().parent_path();
+ const auto lowerPath = parent / lowerName;
+ if (fs::exists(lowerPath, ec)) continue;
+
+ std::error_code lec;
+ fs::create_symlink(name, lowerPath, lec);
+ if (lec) {
+ log::debug("stylesheet shim: failed to link '{}' -> '{}': {}",
+ lowerPath.string(), name, lec.message());
+ }
+ }
+}
+#endif
+
void MOApplication::updateStyle(const QString& fileName)
{
if (QStyleFactory::keys().contains(fileName)) {
@@ -876,6 +918,11 @@ void MOApplication::updateStyle(const QString& fileName)
} else {
QFile stylesheet(fileName);
if (stylesheet.exists()) {
+#ifndef _WIN32
+ // Pre-create lowercase shims so url(foo.svg) in the QSS resolves even
+ // when the on-disk file is Foo.svg.
+ createLowercaseStylesheetShims(QFileInfo(fileName).absolutePath());
+#endif
setStyle(new ProxyStyle(QStyleFactory::create(
extractBaseStyleFromStyleSheet(stylesheet, m_defaultStyle))));
setStyleSheet(QString("file:///%1").arg(fileName));
diff --git a/src/src/pluginlist.cpp b/src/src/pluginlist.cpp
index 3961dff..72abbc4 100644
--- a/src/src/pluginlist.cpp
+++ b/src/src/pluginlist.cpp
@@ -1972,7 +1972,11 @@ PluginList::ESPInfo::ESPInfo(const QString& name, bool forceLoaded, bool forceEn
#endif
try {
- ESP::File file(ToWString(parsePath));
+ // Linux filesystem is UTF-8. ToWString → wstring → naive wchar_t->char
+ // copy in esptk truncates non-ASCII (ö, –), producing "file not found"
+ // on paths like "Mörskom Estate" or "Official Master Files – Cleaned".
+ // Pass UTF-8 bytes directly.
+ ESP::File file(parsePath.toStdString());
auto extension = name.right(3).toLower();
hasMasterExtension = (extension == "esm");
hasLightExtension = (extension == "esl");
diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp
index 89338f4..4e9ed4d 100644
--- a/src/src/protonlauncher.cpp
+++ b/src/src/protonlauncher.cpp
@@ -525,6 +525,22 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const
env.insert("DOTNET_ROOT", "");
env.insert("DOTNET_MULTILEVEL_LOOKUP", "0");
+ // Force-disable DXVK graphics-pipeline-library. GPL causes very long shader
+ // compile stalls on first launch for heavily modded Bethesda games and the
+ // benefit is modest for us. We write a small dxvk.conf into the prefix and
+ // point DXVK_CONFIG_FILE at it so every DXVK-rendered process picks it up.
+ if (!m_prefixPath.isEmpty()) {
+ const QString dxvkConfPath = QDir(m_prefixPath).filePath("dxvk.conf");
+ QFile dxvkConfFile(dxvkConfPath);
+ if (dxvkConfFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
+ dxvkConfFile.write("dxvk.enableGraphicsPipelineLibrary = False\n");
+ dxvkConfFile.close();
+ env.insert("DXVK_CONFIG_FILE", dxvkConfPath);
+ } else {
+ MOBase::log::warn("Failed to write dxvk.conf at '{}'", dxvkConfPath);
+ }
+ }
+
for (auto it = m_wrapperEnvVars.cbegin(); it != m_wrapperEnvVars.cend(); ++it) {
env.insert(it.key(), it.value());
}
diff --git a/src/src/resources/contents/empty-chessboard.png b/src/src/resources/contents/empty-chessboard.png
index fccb3aa..8d3b776 100644
--- a/src/src/resources/contents/empty-chessboard.png
+++ b/src/src/resources/contents/empty-chessboard.png
Binary files differ
diff --git a/src/src/resources/contents/facegen.png b/src/src/resources/contents/facegen.png
index 8f5eeac..b05240d 100644
--- a/src/src/resources/contents/facegen.png
+++ b/src/src/resources/contents/facegen.png
Binary files differ
diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp
index b8b56f1..ecae325 100644
--- a/src/src/vfs/mo2filesystem.cpp
+++ b/src/src/vfs/mo2filesystem.cpp
@@ -2,11 +2,13 @@
#include <fcntl.h>
#include <linux/fs.h>
+#include <sys/resource.h>
#include <sys/time.h>
#include <unistd.h>
#include <algorithm>
#include <chrono>
+#include <climits>
#include <cstring>
#include <filesystem>
@@ -33,6 +35,25 @@ void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid,
mode_t cached_mode = 0);
void invalidateLookupCache(Mo2FsContext* ctx, const std::string& dirPath);
+// RAII helper that records per-op wall-clock nanoseconds into a counter.
+struct OpTimer
+{
+ std::atomic<uint64_t>* sink;
+ std::chrono::steady_clock::time_point start;
+ explicit OpTimer(std::atomic<uint64_t>* s)
+ : sink(s), start(std::chrono::steady_clock::now()) {}
+ ~OpTimer()
+ {
+ if (sink == nullptr) return;
+ const auto end = std::chrono::steady_clock::now();
+ const uint64_t ns = static_cast<uint64_t>(
+ std::chrono::duration_cast<std::chrono::nanoseconds>(end - start).count());
+ sink->fetch_add(ns, std::memory_order_relaxed);
+ }
+ OpTimer(const OpTimer&) = delete;
+ OpTimer& operator=(const OpTimer&) = delete;
+};
+
void maybeLogCounters(Mo2FsContext* ctx)
{
if (ctx == nullptr) {
@@ -44,25 +65,44 @@ void maybeLogCounters(Mo2FsContext* ctx)
return;
}
+ const uint64_t lc = ctx->lookup_count.load(std::memory_order_relaxed);
+ const uint64_t gc = ctx->getattr_count.load(std::memory_order_relaxed);
+ const uint64_t rc = ctx->readdir_count.load(std::memory_order_relaxed);
+ const uint64_t oc = ctx->open_count.load(std::memory_order_relaxed);
+ const uint64_t rdc = ctx->read_count.load(std::memory_order_relaxed);
+ const uint64_t ic = ctx->ioctl_count.load(std::memory_order_relaxed);
+
std::fprintf(stderr,
"[VFS] ops lookup=%llu getattr=%llu readdir=%llu open=%llu read=%llu ioctl=%llu",
- static_cast<unsigned long long>(
- ctx->lookup_count.load(std::memory_order_relaxed)),
- static_cast<unsigned long long>(
- ctx->getattr_count.load(std::memory_order_relaxed)),
- static_cast<unsigned long long>(
- ctx->readdir_count.load(std::memory_order_relaxed)),
- static_cast<unsigned long long>(
- ctx->open_count.load(std::memory_order_relaxed)),
- static_cast<unsigned long long>(
- ctx->read_count.load(std::memory_order_relaxed)),
- static_cast<unsigned long long>(
- ctx->ioctl_count.load(std::memory_order_relaxed)));
+ static_cast<unsigned long long>(lc),
+ static_cast<unsigned long long>(gc),
+ static_cast<unsigned long long>(rc),
+ static_cast<unsigned long long>(oc),
+ static_cast<unsigned long long>(rdc),
+ static_cast<unsigned long long>(ic));
{
std::scoped_lock lock(ctx->open_files_mutex);
std::fprintf(stderr, " open_handles=%zu\n", ctx->open_files.size());
}
+ // Per-op wall-clock totals and averages (microseconds).
+ auto avgUs = [](uint64_t ns, uint64_t count) -> double {
+ return count == 0 ? 0.0 : (static_cast<double>(ns) / 1000.0) / static_cast<double>(count);
+ };
+ const uint64_t lns = ctx->lookup_ns.load(std::memory_order_relaxed);
+ const uint64_t gns = ctx->getattr_ns.load(std::memory_order_relaxed);
+ const uint64_t rns = ctx->readdir_ns.load(std::memory_order_relaxed);
+ const uint64_t ons = ctx->open_ns.load(std::memory_order_relaxed);
+ const uint64_t rdns = ctx->read_ns.load(std::memory_order_relaxed);
+ std::fprintf(stderr,
+ "[VFS] time lookup=%.1fms/%.1fus-avg getattr=%.1fms/%.1fus readdir=%.1fms/%.1fus "
+ "open=%.1fms/%.1fus read=%.1fms/%.1fus\n",
+ lns / 1e6, avgUs(lns, lc),
+ gns / 1e6, avgUs(gns, gc),
+ rns / 1e6, avgUs(rns, rc),
+ ons / 1e6, avgUs(ons, oc),
+ rdns / 1e6, avgUs(rdns, rdc));
+
auto logTop = [](const char* label, const std::unordered_map<std::string, uint64_t>& m) {
if (m.empty()) {
return;
@@ -167,23 +207,22 @@ void invalidateNodeCache(Mo2FsContext* ctx, const std::string& path)
std::shared_lock lock(ctx->inode_mutex);
ino = ctx->inodes->get(path);
}
+ fuse_ino_t parentIno = 0;
+ const auto slash = path.rfind('/');
+ if (slash != std::string::npos) {
+ const std::string parentPath = path.substr(0, slash);
+ std::shared_lock lock(ctx->inode_mutex);
+ parentIno = ctx->inodes->get(parentPath);
+ }
+
+ std::scoped_lock nlock(ctx->node_cache_mutex);
if (ino != 0) {
ctx->node_cache.erase(ino);
}
-
// Also invalidate the parent directory's cache entry since its children
// map has changed (new/removed child).
- const auto slash = path.rfind('/');
- if (slash != std::string::npos) {
- const std::string parentPath = path.substr(0, slash);
- fuse_ino_t parentIno = 0;
- {
- std::shared_lock lock(ctx->inode_mutex);
- parentIno = ctx->inodes->get(parentPath);
- }
- if (parentIno != 0) {
- ctx->node_cache.erase(parentIno);
- }
+ if (parentIno != 0) {
+ ctx->node_cache.erase(parentIno);
}
}
@@ -315,9 +354,12 @@ const VfsNode* resolveByInode(Mo2FsContext* ctx, fuse_ino_t ino)
}
// Check node_cache (fast path — no splitPath, no tree walk)
- auto cacheIt = ctx->node_cache.find(ino);
- if (cacheIt != ctx->node_cache.end()) {
- return cacheIt->second;
+ {
+ std::scoped_lock nlock(ctx->node_cache_mutex);
+ auto cacheIt = ctx->node_cache.find(ino);
+ if (cacheIt != ctx->node_cache.end()) {
+ return cacheIt->second;
+ }
}
// Cache miss — resolve via inode→path→tree walk
@@ -332,9 +374,12 @@ const VfsNode* resolveByInode(Mo2FsContext* ctx, fuse_ino_t ino)
const VfsNode* node = ctx->tree->root.resolve(splitPath(path));
if (node != nullptr) {
- // Safe: we hold tree_mutex shared, and mutations hold it exclusively
- // so they can clear stale entries before any pointer becomes dangling.
- const_cast<std::unordered_map<fuse_ino_t, const VfsNode*>&>(ctx->node_cache)[ino] = node;
+ // Validity of node pointer is tied to tree_mutex shared (held by caller)
+ // — mutations acquire tree_mutex exclusive and clear the cache before
+ // any pointer becomes dangling. Serialize map write against other
+ // concurrent shared readers.
+ std::scoped_lock nlock(ctx->node_cache_mutex);
+ ctx->node_cache[ino] = node;
}
return node;
}
@@ -735,6 +780,28 @@ void mo2_init(void* userdata, struct fuse_conn_info* conn)
{
auto* ctx = static_cast<Mo2FsContext*>(userdata);
+ // Bump RLIMIT_NOFILE. We hold one real fd per open file so games that
+ // stream hundreds of BSAs concurrently would otherwise hit the default
+ // 1024 soft limit. Raise to hard limit (or a sane cap).
+ {
+ struct rlimit rl{};
+ if (getrlimit(RLIMIT_NOFILE, &rl) == 0) {
+ rlim_t wanted = rl.rlim_max;
+ if (wanted == RLIM_INFINITY || wanted > 1048576) {
+ wanted = 1048576;
+ }
+ if (rl.rlim_cur < wanted) {
+ rl.rlim_cur = wanted;
+ if (setrlimit(RLIMIT_NOFILE, &rl) != 0) {
+ std::fprintf(stderr, "[VFS] setrlimit(NOFILE) failed: errno=%d\n", errno);
+ } else {
+ std::fprintf(stderr, "[VFS] RLIMIT_NOFILE raised to %llu\n",
+ static_cast<unsigned long long>(rl.rlim_cur));
+ }
+ }
+ }
+ }
+
// ── 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
@@ -799,15 +866,23 @@ void mo2_init(void* userdata, struct fuse_conn_info* conn)
conn->max_background = 32767;
conn->congestion_threshold = 24576;
- // Request large read/write buffers. The kernel caps these at its own
- // compiled-in maximum, so overshooting is harmless.
- constexpr unsigned int EIGHT_MB = 8 * 1048576;
- if (conn->max_readahead < EIGHT_MB) {
- conn->max_readahead = EIGHT_MB;
+ // Request large read/write buffers. libfuse sizes its receive buffer at
+ // session creation time based on (max_write + header); overshooting here
+ // yields a mismatch where the kernel expects room for a big write but
+ // libfuse's buffer is smaller, and reads from /dev/fuse fail with EINVAL.
+ // Stay conservative: 1MB matches libfuse's bufsize ceiling on most kernels
+ // and is the largest value the kernel's FUSE driver typically accepts.
+ constexpr unsigned int ONE_MB = 1 * 1024 * 1024;
+ if (conn->max_readahead < ONE_MB) {
+ conn->max_readahead = ONE_MB;
}
- if (conn->max_write < EIGHT_MB) {
- conn->max_write = EIGHT_MB;
+ if (conn->max_write < ONE_MB) {
+ conn->max_write = ONE_MB;
}
+ // max_read MUST match the "-o max_read=..." mount option passed to
+ // fuse_session_new() or libfuse errors out with
+ // "init() and fuse_session_new() requested different maximum read size"
+ conn->max_read = ONE_MB;
std::fprintf(stderr,
"[VFS] init: auto_inval=%d explicit_inval=%d readdirplus=%d "
@@ -825,6 +900,7 @@ void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name)
fuse_reply_err(req, EINVAL);
return;
}
+ OpTimer _t(&ctx->lookup_ns);
ctx->lookup_count.fetch_add(1, std::memory_order_relaxed);
maybeLogCounters(ctx);
@@ -896,7 +972,8 @@ void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name)
const std::string key = normalizeForLookup(lr.canonical_name);
auto it = parentNode->dir_info.children.find(key);
if (it != parentNode->dir_info.children.end()) {
- const_cast<std::unordered_map<fuse_ino_t, const VfsNode*>&>(ctx->node_cache)[childIno] = it->second.get();
+ std::scoped_lock nlock(ctx->node_cache_mutex);
+ ctx->node_cache[childIno] = it->second.get();
}
}
}
@@ -933,6 +1010,7 @@ void mo2_getattr(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* /*fi*/)
fuse_reply_err(req, EINVAL);
return;
}
+ OpTimer _t(&ctx->getattr_ns);
ctx->getattr_count.fetch_add(1, std::memory_order_relaxed);
maybeLogCounters(ctx);
@@ -1043,6 +1121,7 @@ void mo2_readdir(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off,
fuse_reply_err(req, EINVAL);
return;
}
+ OpTimer _t(&ctx->readdir_ns);
ctx->readdir_count.fetch_add(1, std::memory_order_relaxed);
maybeLogCounters(ctx);
@@ -1130,6 +1209,7 @@ void mo2_readdirplus(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off,
fuse_reply_err(req, EINVAL);
return;
}
+ OpTimer _t(&ctx->readdir_ns);
ctx->readdir_count.fetch_add(1, std::memory_order_relaxed);
maybeLogCounters(ctx);
@@ -1227,6 +1307,7 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi)
fuse_reply_err(req, EINVAL);
return;
}
+ OpTimer _t(&ctx->open_ns);
ctx->open_count.fetch_add(1, std::memory_order_relaxed);
maybeLogCounters(ctx);
@@ -1262,6 +1343,10 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi)
// 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).
+ //
+ // Read strategy: always open a real backing fd at open() time so mo2_read
+ // can splice from it without re-opening per read call. Avoids N syscalls
+ // per file for games that stream large BSAs in many small chunks.
int fd = -1;
if (writable) {
@@ -1309,6 +1394,16 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi)
fuse_reply_err(req, EIO);
return;
}
+ } else {
+ // Read-only open: keep a real fd so mo2_read can splice without
+ // re-opening the backing file on every read() call.
+ 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);
+ }
+ // Non-fatal: fall back to lazy per-read open if we hit EMFILE etc.
+ // mo2_read handles fd=-1 by opening temporarily.
}
const uint64_t fh = ctx->next_fh.fetch_add(1, std::memory_order_relaxed);
@@ -1339,6 +1434,7 @@ void mo2_read(fuse_req_t req, fuse_ino_t /*ino*/, size_t size, off_t off,
fuse_reply_err(req, EINVAL);
return;
}
+ OpTimer _t(&ctx->read_ns);
ctx->read_count.fetch_add(1, std::memory_order_relaxed);
maybeLogCounters(ctx);
diff --git a/src/src/vfs/mo2filesystem.h b/src/src/vfs/mo2filesystem.h
index e105c07..624b330 100644
--- a/src/src/vfs/mo2filesystem.h
+++ b/src/src/vfs/mo2filesystem.h
@@ -26,7 +26,11 @@ struct Mo2FsContext
// Fast inode→VfsNode* cache. Pointers are valid while tree_mutex is held
// (shared for reads). Invalidated under exclusive tree_mutex during mutations.
- std::unordered_map<fuse_ino_t, const VfsNode*> node_cache; // protected by tree_mutex
+ // Access (read + write) is serialized by node_cache_mutex — tree_mutex-shared
+ // is NOT enough because multiple shared readers would race when populating
+ // the cache on miss.
+ std::unordered_map<fuse_ino_t, const VfsNode*> node_cache;
+ mutable std::mutex node_cache_mutex;
std::unique_ptr<OverwriteManager> overwrite;
std::shared_ptr<TrackedWrites> tracked_writes;
@@ -111,6 +115,13 @@ struct Mo2FsContext
std::atomic<uint64_t> read_count{0};
std::atomic<uint64_t> ioctl_count{0};
std::atomic<uint64_t> op_tick{0};
+ // Cumulative wall-clock nanoseconds spent inside each op handler.
+ // Paired with the *_count fields so we can report avg latency per op.
+ std::atomic<uint64_t> lookup_ns{0};
+ std::atomic<uint64_t> getattr_ns{0};
+ std::atomic<uint64_t> readdir_ns{0};
+ std::atomic<uint64_t> open_ns{0};
+ std::atomic<uint64_t> read_ns{0};
std::unordered_map<std::string, uint64_t> lookup_hit_paths;
std::unordered_map<std::string, uint64_t> lookup_miss_paths;
std::unordered_map<std::string, uint64_t> getattr_paths;