aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-05-03 09:53:32 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-05-03 09:53:32 -0500
commitf9fbaf60cdb8e242554227eb41b163c12591fdf2 (patch)
treee2a734c0f67808509cd92fe46e11aa50c9624a9e
parentca8107ee48396b39599f1e766f8799c65c487b1b (diff)
vfs: persistent scan cache to skip mod walk on warm boot
Saves the merged VfsTree to ~/.local/share/fluorine/vfs_cache/ keyed by data_dir + mods + overwrite. On a hit, mounting reads the tree from a single binary file instead of re-walking every mod, which dominates mount time on heavy modlists. Validation: modlist.txt mtime+size, data/overwrite dir mtime, mod-dir mtimes in priority order. Any drift invalidates. Session-scoped state (extra-file injection, plugin timestamp stamping) is re-applied after load and never cached. rebuild() also refreshes the cache so the next cold mount hits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
-rw-r--r--src/src/fluorinepaths.cpp5
-rw-r--r--src/src/fluorinepaths.h4
-rw-r--r--src/src/fuseconnector.cpp54
-rw-r--r--src/src/vfs/scancache.cpp246
-rw-r--r--src/src/vfs/scancache.h55
-rw-r--r--src/src/vfs/vfstree.cpp135
-rw-r--r--src/src/vfs/vfstree.h7
7 files changed, 499 insertions, 7 deletions
diff --git a/src/src/fluorinepaths.cpp b/src/src/fluorinepaths.cpp
index 141a1bc..6c52ec4 100644
--- a/src/src/fluorinepaths.cpp
+++ b/src/src/fluorinepaths.cpp
@@ -17,6 +17,11 @@ QString fluorineDataDir()
return QDir::homePath() + "/.local/share/fluorine";
}
+QString fluorineVfsCacheDir()
+{
+ return fluorineDataDir() + "/vfs_cache";
+}
+
void fluorineMigrateDataDir()
{
const QString oldRoot = OldFlatpakRoot;
diff --git a/src/src/fluorinepaths.h b/src/src/fluorinepaths.h
index 2bdde33..eef60b2 100644
--- a/src/src/fluorinepaths.h
+++ b/src/src/fluorinepaths.h
@@ -6,6 +6,10 @@
/// Returns the Fluorine data directory: ~/.local/share/fluorine
QString fluorineDataDir();
+/// Returns the VFS scan-cache directory: ~/.local/share/fluorine/vfs_cache
+/// Created on demand by the cache writer.
+QString fluorineVfsCacheDir();
+
/// One-time migration from ~/.var/app/com.fluorine.manager/ to
/// ~/.local/share/fluorine/. Call before initLogging().
void fluorineMigrateDataDir();
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp
index 6495223..f66d1bb 100644
--- a/src/src/fuseconnector.cpp
+++ b/src/src/fuseconnector.cpp
@@ -1,6 +1,7 @@
#include "fuseconnector.h"
#include "settings.h"
+#include "vfs/scancache.h"
#include "vfs/vfstree.h"
#include <QCoreApplication>
@@ -406,15 +407,41 @@ bool FuseConnector::mount(
.arg(QString::fromStdString(m_dataDirPath)));
}
- // Build tree using cached base files + mods + overwrite
+ // Build tree using cached base files + mods + overwrite.
+ // Try the persistent scan cache first — on a hit we skip the parallel
+ // mod walk entirely, which is the dominant cost on heavy modlists.
const auto treeStart = std::chrono::steady_clock::now();
- auto tree = std::make_shared<VfsTree>(
- buildDataDirVfs(m_baseFileCache, m_dataDirPath, mods, m_overwriteDir));
+ ScanCacheKey cacheKey;
+ cacheKey.data_dir = m_dataDirPath;
+ cacheKey.overwrite_dir = m_overwriteDir;
+ cacheKey.mods = mods; // priority order matches buildDataDirVfs
+ ScanCache scanCache(ScanCache::cacheFilePath(cacheKey));
- // Inject file-level data-dir mappings (e.g. plugins.txt, loadorder.txt)
+ std::shared_ptr<VfsTree> tree;
+ bool cacheHit = false;
+ if (auto cached = scanCache.tryLoad(cacheKey)) {
+ tree = std::move(cached);
+ cacheHit = true;
+ std::fprintf(stderr,
+ "[VFS] [scancache] hit (%zu files, %zu dirs)\n",
+ tree->file_count, tree->dir_count);
+ } else {
+ tree = std::make_shared<VfsTree>(
+ buildDataDirVfs(m_baseFileCache, m_dataDirPath, mods, m_overwriteDir));
+ if (!scanCache.save(cacheKey, *tree)) {
+ std::fprintf(stderr, "[VFS] [scancache] save failed (non-fatal)\n");
+ } else {
+ std::fprintf(stderr, "[VFS] [scancache] miss; persisted (%zu files, %zu dirs)\n",
+ tree->file_count, tree->dir_count);
+ }
+ }
+
+ // Inject file-level data-dir mappings (e.g. plugins.txt, loadorder.txt).
+ // Always re-applied after load — these are session-scoped, not cached.
injectExtraFiles(*tree, m_extraVfsFiles);
- // Stamp plugin timestamps to match load order so LOOT sees unambiguous ordering
+ // Stamp plugin timestamps to match load order so LOOT sees unambiguous ordering.
+ // Also session-scoped; load order can change between launches.
if (!m_pluginLoadOrder.empty()) {
stampPluginTimestamps(*tree, m_pluginLoadOrder);
}
@@ -422,8 +449,10 @@ bool FuseConnector::mount(
{
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - treeStart).count();
- std::fprintf(stderr, "[VFS] built tree (%zu files, %zu dirs) in %lldms\n",
- tree->file_count, tree->dir_count, static_cast<long long>(ms));
+ std::fprintf(stderr, "[VFS] built tree (%zu files, %zu dirs) in %lldms (%s)\n",
+ tree->file_count, tree->dir_count,
+ static_cast<long long>(ms),
+ cacheHit ? "cache" : "fresh");
}
// Load tracked writes (files user moved from Overwrite to a mod)
@@ -643,6 +672,17 @@ void FuseConnector::rebuild(
auto newTree = std::make_shared<VfsTree>(
buildDataDirVfs(m_baseFileCache, m_dataDirPath, mods, m_overwriteDir));
+ // Refresh persistent scan cache so the next cold mount gets a hit.
+ // Save before injection/stamping for the same reason as mount(): those
+ // are session-scoped and re-applied on every load.
+ {
+ ScanCacheKey rebuildKey;
+ rebuildKey.data_dir = m_dataDirPath;
+ rebuildKey.overwrite_dir = m_overwriteDir;
+ rebuildKey.mods = mods;
+ ScanCache(ScanCache::cacheFilePath(rebuildKey)).save(rebuildKey, *newTree);
+ }
+
// Inject file-level data-dir mappings (e.g. plugins.txt, loadorder.txt)
injectExtraFiles(*newTree, m_extraVfsFiles);
diff --git a/src/src/vfs/scancache.cpp b/src/src/vfs/scancache.cpp
new file mode 100644
index 0000000..6c8e9e0
--- /dev/null
+++ b/src/src/vfs/scancache.cpp
@@ -0,0 +1,246 @@
+#include "scancache.h"
+
+#include "../fluorinepaths.h"
+
+#include <QDir>
+#include <QString>
+
+#include <chrono>
+#include <cstdint>
+#include <cstdio>
+#include <cstring>
+#include <fstream>
+#include <functional>
+#include <system_error>
+
+namespace fs = std::filesystem;
+
+namespace
+{
+constexpr uint32_t kMagic = 0x43564C46u; // "FLVC"
+constexpr uint32_t kVersion = 1u;
+
+template <typename T>
+bool writePod(std::ostream& out, const T& v)
+{
+ out.write(reinterpret_cast<const char*>(&v), sizeof(T));
+ return out.good();
+}
+
+template <typename T>
+bool readPod(std::istream& in, T& v)
+{
+ in.read(reinterpret_cast<char*>(&v), sizeof(T));
+ return in.good();
+}
+
+bool writeStr(std::ostream& out, const std::string& s)
+{
+ uint32_t len = static_cast<uint32_t>(s.size());
+ if (!writePod(out, len)) return false;
+ if (len > 0) out.write(s.data(), len);
+ return out.good();
+}
+
+bool readStr(std::istream& in, std::string& s)
+{
+ uint32_t len = 0;
+ if (!readPod(in, len)) return false;
+ // Sanity bound: 1 MB per string is enough for any path we care about.
+ if (len > 1u * 1024u * 1024u) return false;
+ s.resize(len);
+ if (len > 0) in.read(s.data(), len);
+ return in.good();
+}
+
+// Returns -1 if stat fails. Nanosecond precision; fs::file_time_type's
+// epoch is implementation-defined, so we go through the same conversion
+// used elsewhere by reading via std::filesystem and comparing as int64.
+int64_t mtimeNs(const fs::path& p)
+{
+ std::error_code ec;
+ auto t = fs::last_write_time(p, ec);
+ if (ec) return -1;
+ return std::chrono::duration_cast<std::chrono::nanoseconds>(
+ t.time_since_epoch())
+ .count();
+}
+
+uint64_t fileSize(const fs::path& p)
+{
+ std::error_code ec;
+ auto s = fs::file_size(p, ec);
+ if (ec) return 0;
+ return static_cast<uint64_t>(s);
+}
+
+// Stable 64-bit hash (FNV-1a) over a string. Used to derive a unique
+// cache filename from the mount config so different instances don't
+// step on each other's cache.
+uint64_t hash64(const std::string& s)
+{
+ uint64_t h = 1469598103934665603ULL;
+ for (unsigned char c : s) {
+ h ^= c;
+ h *= 1099511628211ULL;
+ }
+ return h;
+}
+} // namespace
+
+fs::path ScanCache::cacheFilePath(const ScanCacheKey& key)
+{
+ // Cache key fingerprint = data_dir + overwrite_dir + ordered mod paths.
+ // Hashing keeps the filename short and filesystem-safe; the full key
+ // is re-validated inside tryLoad() so collisions can't produce a stale
+ // hit, only a wasted load attempt.
+ std::string fp;
+ fp.reserve(4096);
+ fp += key.data_dir.string();
+ fp += '\0';
+ fp += key.overwrite_dir.string();
+ fp += '\0';
+ for (const auto& [name, path] : key.mods) {
+ fp += name;
+ fp += '\0';
+ fp += path;
+ fp += '\0';
+ }
+
+ char buf[32];
+ std::snprintf(buf, sizeof(buf), "%016llx.vfscache",
+ static_cast<unsigned long long>(hash64(fp)));
+
+ const QString dir = fluorineVfsCacheDir();
+ return fs::path(dir.toStdString()) / buf;
+}
+
+ScanCache::ScanCache(fs::path cache_file)
+ : m_cache_file(std::move(cache_file))
+{}
+
+std::shared_ptr<VfsTree> ScanCache::tryLoad(const ScanCacheKey& key)
+{
+ std::ifstream in(m_cache_file, std::ios::binary);
+ if (!in) return nullptr;
+
+ // 1. Header
+ uint32_t magic = 0, version = 0;
+ if (!readPod(in, magic) || magic != kMagic) return nullptr;
+ if (!readPod(in, version) || version != kVersion) return nullptr;
+
+ // 2. modlist.txt mtime + size (skipped if path empty)
+ uint8_t hasModlist = 0;
+ if (!readPod(in, hasModlist)) return nullptr;
+ if (hasModlist) {
+ int64_t cachedMtime = 0;
+ uint64_t cachedSize = 0;
+ if (!readPod(in, cachedMtime) || !readPod(in, cachedSize)) return nullptr;
+ if (cachedMtime != mtimeNs(key.modlist_txt)) return nullptr;
+ if (cachedSize != fileSize(key.modlist_txt)) return nullptr;
+ }
+
+ // 3. data_dir mtime
+ int64_t dataMtime = 0;
+ if (!readPod(in, dataMtime)) return nullptr;
+ if (dataMtime != mtimeNs(key.data_dir)) return nullptr;
+
+ // 4. overwrite_dir mtime
+ int64_t overwriteMtime = 0;
+ if (!readPod(in, overwriteMtime)) return nullptr;
+ if (overwriteMtime != mtimeNs(key.overwrite_dir)) return nullptr;
+
+ // 5. Per-mod check (count + name + path + dir mtime, in order)
+ uint32_t modCount = 0;
+ if (!readPod(in, modCount)) return nullptr;
+ if (modCount != key.mods.size()) return nullptr;
+
+ for (uint32_t i = 0; i < modCount; ++i) {
+ std::string name, path;
+ int64_t modMtime = 0;
+ if (!readStr(in, name) || !readStr(in, path) || !readPod(in, modMtime)) {
+ return nullptr;
+ }
+ if (name != key.mods[i].first) return nullptr;
+ if (path != key.mods[i].second) return nullptr;
+ if (modMtime != mtimeNs(path)) return nullptr;
+ }
+
+ // 6. Tree counts
+ uint64_t fileCount = 0, dirCount = 0;
+ if (!readPod(in, fileCount) || !readPod(in, dirCount)) return nullptr;
+
+ // 7. Deserialize tree
+ auto tree = std::make_shared<VfsTree>();
+ tree->root.is_directory = true;
+ if (!deserializeNode(in, tree->root)) return nullptr;
+
+ tree->file_count = static_cast<size_t>(fileCount);
+ tree->dir_count = static_cast<size_t>(dirCount);
+ return tree;
+}
+
+bool ScanCache::save(const ScanCacheKey& key, const VfsTree& tree)
+{
+ std::error_code ec;
+ fs::create_directories(m_cache_file.parent_path(), ec);
+
+ // Atomic write: serialize to temp, rename over. A torn write is
+ // detectable on read (header check or short read) but renaming
+ // avoids leaving a partial cache file behind on crash.
+ fs::path tmp = m_cache_file;
+ tmp += ".tmp";
+
+ {
+ std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
+ if (!out) return false;
+
+ if (!writePod(out, kMagic)) return false;
+ if (!writePod(out, kVersion)) return false;
+
+ uint8_t hasModlist = key.modlist_txt.empty() ? 0 : 1;
+ if (!writePod(out, hasModlist)) return false;
+ if (hasModlist) {
+ int64_t mtime = mtimeNs(key.modlist_txt);
+ uint64_t size = fileSize(key.modlist_txt);
+ if (!writePod(out, mtime)) return false;
+ if (!writePod(out, size)) return false;
+ }
+
+ int64_t dataMtime = mtimeNs(key.data_dir);
+ if (!writePod(out, dataMtime)) return false;
+ int64_t overwriteMtime = mtimeNs(key.overwrite_dir);
+ if (!writePod(out, overwriteMtime)) return false;
+
+ uint32_t modCount = static_cast<uint32_t>(key.mods.size());
+ if (!writePod(out, modCount)) return false;
+ for (const auto& [name, path] : key.mods) {
+ if (!writeStr(out, name)) return false;
+ if (!writeStr(out, path)) return false;
+ int64_t modMtime = mtimeNs(path);
+ if (!writePod(out, modMtime)) return false;
+ }
+
+ uint64_t fileCount = static_cast<uint64_t>(tree.file_count);
+ uint64_t dirCount = static_cast<uint64_t>(tree.dir_count);
+ if (!writePod(out, fileCount)) return false;
+ if (!writePod(out, dirCount)) return false;
+
+ if (!serializeNode(out, tree.root)) return false;
+ out.flush();
+ if (!out.good()) return false;
+ }
+
+ fs::rename(tmp, m_cache_file, ec);
+ if (ec) {
+ fs::remove(tmp, ec);
+ return false;
+ }
+ return true;
+}
+
+void ScanCache::invalidate()
+{
+ std::error_code ec;
+ fs::remove(m_cache_file, ec);
+}
diff --git a/src/src/vfs/scancache.h b/src/src/vfs/scancache.h
new file mode 100644
index 0000000..4269c04
--- /dev/null
+++ b/src/src/vfs/scancache.h
@@ -0,0 +1,55 @@
+#ifndef VFS_SCANCACHE_H
+#define VFS_SCANCACHE_H
+
+#include "vfstree.h"
+
+#include <filesystem>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+// Persistent merged-VFS cache. Saves the post-build VfsTree to disk,
+// validated by modlist + per-mod dir mtimes + overwrite/data dir mtimes.
+//
+// On a hit, mounting skips both scanDataDir() and buildDataDirVfs() —
+// the entire tree comes back from a single binary read in ~tens of ms.
+// On miss/drift, the caller falls back to the normal scan path and is
+// expected to call save() with the freshly-built tree.
+struct ScanCacheKey
+{
+ // For mtime+size validation; cache invalidates if either drifts.
+ std::filesystem::path modlist_txt;
+
+ // Whole-dir mtime check; bumps on any direct-child add/remove/rename.
+ std::filesystem::path data_dir;
+ std::filesystem::path overwrite_dir;
+
+ // (origin name, absolute mod dir) in priority order. Order matters —
+ // any reorder invalidates the cache.
+ std::vector<std::pair<std::string, std::string>> mods;
+};
+
+class ScanCache
+{
+public:
+ // Cache file path is derived from a hash of the data_dir + mods so
+ // each unique mount config gets its own cache file.
+ static std::filesystem::path cacheFilePath(const ScanCacheKey& key);
+
+ explicit ScanCache(std::filesystem::path cache_file);
+
+ // Returns a populated tree on hit, nullptr on miss/drift/corruption.
+ std::shared_ptr<VfsTree> tryLoad(const ScanCacheKey& key);
+
+ // Best-effort persist; returns false on I/O failure but never throws.
+ bool save(const ScanCacheKey& key, const VfsTree& tree);
+
+ // Force the next tryLoad() to miss by removing the cache file.
+ void invalidate();
+
+private:
+ std::filesystem::path m_cache_file;
+};
+
+#endif // VFS_SCANCACHE_H
diff --git a/src/src/vfs/vfstree.cpp b/src/src/vfs/vfstree.cpp
index 89c6f4b..ca1df0c 100644
--- a/src/src/vfs/vfstree.cpp
+++ b/src/src/vfs/vfstree.cpp
@@ -3,9 +3,12 @@
#include <algorithm>
#include <cctype>
#include <cstdio>
+#include <cstring>
#include <filesystem>
#include <future>
+#include <istream>
#include <mutex>
+#include <ostream>
namespace
{
@@ -546,6 +549,138 @@ void injectExtraFiles(
}
}
+namespace
+{
+// Binary I/O helpers — fixed-size little-endian, raw memcpy.
+// Host is x86_64 little-endian; cache files are not portable across endian.
+template <typename T>
+bool writePod(std::ostream& out, const T& v)
+{
+ out.write(reinterpret_cast<const char*>(&v), sizeof(T));
+ return out.good();
+}
+
+template <typename T>
+bool readPod(std::istream& in, T& v)
+{
+ in.read(reinterpret_cast<char*>(&v), sizeof(T));
+ return in.good();
+}
+
+bool writeStr(std::ostream& out, const std::string& s)
+{
+ if (s.size() > 0xFFFF) {
+ return false; // length capped at u16
+ }
+ uint16_t len = static_cast<uint16_t>(s.size());
+ if (!writePod(out, len)) return false;
+ if (len > 0) {
+ out.write(s.data(), len);
+ }
+ return out.good();
+}
+
+bool readStr(std::istream& in, std::string& s)
+{
+ uint16_t len = 0;
+ if (!readPod(in, len)) return false;
+ s.resize(len);
+ if (len > 0) {
+ in.read(s.data(), len);
+ }
+ return in.good();
+}
+
+int64_t timeToNs(std::chrono::system_clock::time_point t)
+{
+ return std::chrono::duration_cast<std::chrono::nanoseconds>(
+ t.time_since_epoch()).count();
+}
+
+std::chrono::system_clock::time_point nsToTime(int64_t ns)
+{
+ return std::chrono::system_clock::time_point(
+ std::chrono::duration_cast<std::chrono::system_clock::duration>(
+ std::chrono::nanoseconds(ns)));
+}
+} // namespace
+
+bool serializeNode(std::ostream& out, const VfsNode& node)
+{
+ uint8_t isDir = node.is_directory ? 1 : 0;
+ if (!writePod(out, isDir)) return false;
+
+ if (!node.is_directory) {
+ if (!writeStr(out, node.file_info.real_path)) return false;
+ if (!writePod(out, node.file_info.size)) return false;
+ int64_t ns = timeToNs(node.file_info.mtime);
+ if (!writePod(out, ns)) return false;
+ if (!writeStr(out, node.file_info.origin)) return false;
+ uint8_t backing = node.file_info.is_backing ? 1 : 0;
+ if (!writePod(out, backing)) return false;
+ uint32_t mode = node.file_info.cached_mode;
+ if (!writePod(out, mode)) return false;
+ return true;
+ }
+
+ uint32_t childCount = static_cast<uint32_t>(node.dir_info.children.size());
+ if (!writePod(out, childCount)) return false;
+
+ for (const auto& [key, child] : node.dir_info.children) {
+ if (!writeStr(out, key)) return false;
+ auto it = node.dir_info.display_names.find(key);
+ const std::string& display = (it != node.dir_info.display_names.end())
+ ? it->second
+ : key;
+ if (!writeStr(out, display)) return false;
+ if (!serializeNode(out, *child)) return false;
+ }
+ return true;
+}
+
+bool deserializeNode(std::istream& in, VfsNode& node)
+{
+ uint8_t isDir = 0;
+ if (!readPod(in, isDir)) return false;
+ node.is_directory = (isDir != 0);
+
+ if (!node.is_directory) {
+ if (!readStr(in, node.file_info.real_path)) return false;
+ if (!readPod(in, node.file_info.size)) return false;
+ int64_t ns = 0;
+ if (!readPod(in, ns)) return false;
+ node.file_info.mtime = nsToTime(ns);
+ if (!readStr(in, node.file_info.origin)) return false;
+ uint8_t backing = 0;
+ if (!readPod(in, backing)) return false;
+ node.file_info.is_backing = (backing != 0);
+ uint32_t mode = 0;
+ if (!readPod(in, mode)) return false;
+ node.file_info.cached_mode = static_cast<mode_t>(mode);
+ return true;
+ }
+
+ uint32_t childCount = 0;
+ if (!readPod(in, childCount)) return false;
+
+ // Sanity bound — refuse absurd counts so corrupt files do not OOM us.
+ // 16M children per dir is far beyond any real modlist.
+ if (childCount > 16u * 1024u * 1024u) return false;
+
+ node.dir_info.children.reserve(childCount);
+ node.dir_info.display_names.reserve(childCount);
+ for (uint32_t i = 0; i < childCount; ++i) {
+ std::string key, display;
+ if (!readStr(in, key)) return false;
+ if (!readStr(in, display)) return false;
+ auto child = std::make_unique<VfsNode>();
+ if (!deserializeNode(in, *child)) return false;
+ node.dir_info.display_names.emplace(key, std::move(display));
+ node.dir_info.children.emplace(std::move(key), std::move(child));
+ }
+ return true;
+}
+
void stampPluginTimestamps(VfsTree& tree,
const std::vector<std::string>& load_order)
{
diff --git a/src/src/vfs/vfstree.h b/src/src/vfs/vfstree.h
index 77df308..fc564a4 100644
--- a/src/src/vfs/vfstree.h
+++ b/src/src/vfs/vfstree.h
@@ -3,6 +3,7 @@
#include <chrono>
#include <cstdint>
+#include <iosfwd>
#include <memory>
#include <string>
#include <unordered_map>
@@ -85,6 +86,12 @@ void injectExtraFiles(
VfsTree& tree,
const std::vector<std::pair<std::string, std::string>>& extra_files);
+// Binary serialize/deserialize a VfsNode subtree (depth-first).
+// Returns true on success. Used by ScanCache to persist the merged
+// tree to disk and skip the full mod walk on warm boot.
+bool serializeNode(std::ostream& out, const VfsNode& node);
+bool deserializeNode(std::istream& in, VfsNode& node);
+
// Stamp plugin files (.esp/.esm/.esl) in the VFS with incrementing
// timestamps matching their position in the load order. This ensures
// tools like LOOT see an unambiguous timestamp-based ordering that