diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-12 21:04:15 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-12 21:04:15 -0500 |
| commit | 3949dcfce95af4bd305f258ff5b170d7d50435f6 (patch) | |
| tree | 4c768be256c40f31794c3311f0a2c22933a6705a /src | |
| parent | 892070f3dec1dc690983fd862880e37e711c2516 (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.cpp | 100 | ||||
| -rw-r--r-- | src/src/fuseconnector.cpp | 10 | ||||
| -rw-r--r-- | src/src/icondelegate.cpp | 7 | ||||
| -rw-r--r-- | src/src/moapplication.cpp | 47 | ||||
| -rw-r--r-- | src/src/pluginlist.cpp | 6 | ||||
| -rw-r--r-- | src/src/protonlauncher.cpp | 16 | ||||
| -rw-r--r-- | src/src/resources/contents/empty-chessboard.png | bin | 537 -> 910 bytes | |||
| -rw-r--r-- | src/src/resources/contents/facegen.png | bin | 246 -> 638 bytes | |||
| -rw-r--r-- | src/src/vfs/mo2filesystem.cpp | 172 | ||||
| -rw-r--r-- | src/src/vfs/mo2filesystem.h | 13 |
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 Binary files differindex fccb3aa..8d3b776 100644 --- a/src/src/resources/contents/empty-chessboard.png +++ b/src/src/resources/contents/empty-chessboard.png diff --git a/src/src/resources/contents/facegen.png b/src/src/resources/contents/facegen.png Binary files differindex 8f5eeac..b05240d 100644 --- a/src/src/resources/contents/facegen.png +++ b/src/src/resources/contents/facegen.png 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; |
