diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-05-03 09:53:32 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-05-03 09:53:32 -0500 |
| commit | f9fbaf60cdb8e242554227eb41b163c12591fdf2 (patch) | |
| tree | e2a734c0f67808509cd92fe46e11aa50c9624a9e | |
| parent | ca8107ee48396b39599f1e766f8799c65c487b1b (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.cpp | 5 | ||||
| -rw-r--r-- | src/src/fluorinepaths.h | 4 | ||||
| -rw-r--r-- | src/src/fuseconnector.cpp | 54 | ||||
| -rw-r--r-- | src/src/vfs/scancache.cpp | 246 | ||||
| -rw-r--r-- | src/src/vfs/scancache.h | 55 | ||||
| -rw-r--r-- | src/src/vfs/vfstree.cpp | 135 | ||||
| -rw-r--r-- | src/src/vfs/vfstree.h | 7 |
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 |
