aboutsummaryrefslogtreecommitdiff
Commit message (Collapse)AuthorAgeFilesLines
* Bundle Qt libs missing from PyQt6 dep scan (fixes Bazzite crash)SulfurNitride2026-03-141-0/+16
| | | | | | | | | | | | PyQt6 is staged after the main dep-collection loop, so its .so deps (e.g. libQt6OpenGLWidgets) were never copied to lib/. The dynamic linker then fell back to the host's Qt RPM build, which exports Qt_6.*_PRIVATE_API version symbols that aqtinstall's Qt doesn't — causing a crash on Fedora- based distros (Bazzite, etc.) whenever a Python plugin imported PyQt6 OpenGL. Add a post-PyQt6 dep scan that bundles any Qt libs not yet present in lib/. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Bundle libxcb-cursor to fix Qt xcb platform plugin crashSulfurNitride2026-03-141-0/+11
| | | | | | | | | Qt >= 6.5.0 requires libxcb-cursor0 to load the xcb platform plugin, but it's frequently absent on user systems. All other libxcb libs are skipped (must match host X server ABI), but libxcb-cursor is a pure utility library safe to bundle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Update README: add Building section and NixOS runtime requirementsSulfurNitride2026-03-131-0/+35
| | | | | | | | | - Replace Flatpak build/install instructions with Docker-based build.sh - Add Runtime Requirements section with unbundled library list - Add NixOS nix-ld configuration snippet (from PR #37 by Anseryde) - Update Installing section to reflect native tarball distribution Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix image link and clean up README.mdSulfurNitride2026-03-131-2/+1
| | | Updated image link in README.md and removed unnecessary HTML tags.
* Revise installation and running instructions in READMESulfurNitride2026-03-131-13/+2
| | | Updated installation instructions and clarified running commands.
* Fix DDS and winreg missing in Python pluginsSulfurNitride2026-03-132-3/+17
| | | | | | | | | | | - Stage DDS/ package from source directly to plugins/data/DDS/ in build-inner.sh (cmake MO2_STAGE_PYTHON_PLUGIN_PAYLOAD is OFF by default so the cmake-sourced path was silently skipped) - Stage winreg.py from libs/ directly as well (same root cause) - Guard `import winreg` in gog_utils.py with try/except ImportError and early-exit to Heroic GOG path if the shim is unavailable Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Bundle PBS Python 3.12 + PyQt6, restore Python plugins as .py filesSulfurNitride2026-03-139-510/+122
| | | | | | | | | | | | | | | | | | | | | | | | | | | | - Replace portable Python approach with python-build-standalone 3.12.13 (20260310 release) bundled directly in the distribution. Python is now always available without user setup — removes the Python settings tab. - PyQt6 staged to plugins/libs/PyQt6/ with bundled Qt stripped out; patchelf'd to use our existing bundled Qt in lib/ instead of duplicating it. - Restored native-converted plugins back to .py files: Form43Checker, ScriptExtenderPluginChecker, DDSPreview, basic_games, rootbuilder, installer_omod. Removed all *_native CMake targets. - pythonrunner.cpp: removed venv/MO2_PYTHON_DIR lookup; always uses bundled Python at <exe_dir>/python, system Python as last-resort fallback. - plugincontainer.cpp: removed fluorine/python_enabled gate (Python always loads), demoted proxy loading log messages from warn to debug, silenced [plugin-diag] stderr spam. - Dockerfile: switched from uv venv to PBS install_only tarball; PBS Python used for both build-time pybind11 compilation and runtime distribution. - build-inner.sh: aggressive Python staging (strip test/tkinter/ensurepip/ distutils/lib2to3/idlelib; wipe build-time site-packages, restore psutil+vdf). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Replace wrestool with native Rust PE icon extraction (pelite)SulfurNitride2026-03-1219-84/+463
| | | | | | | | | | | | | | | | | - Add icons.rs to nak crate: parses PE resource sections, extracts RT_GROUP_ICON/RT_ICON and builds ICO files in memory - Expose nak_extract_exe_icon() FFI for C++ consumption - Update iconForExecutable() to use Rust FFI instead of shelling out to wrestool; reads all ICO entries for correct color depth - Fix BasicGamePlugin::gameIcon() to call iconForExecutable() (was returning empty QIcon, causing placeholder icons for BG3/Cyberpunk) - Move app icon setup: setDesktopFileName + setWindowIcon to MOApplication constructor (fixes Wayland taskbar/decoration icon) - Move data/com.fluorine.* to data/icons/, install to XDG paths at launch for Wayland compositor icon resolution - Remove icoutils dependency from Docker image and build output Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Upgrade bundled Qt from 6.9 to 6.10 via aqtinstallSulfurNitride2026-03-122-9/+35
| | | | | | | | NixOS/Arch users have Qt 6.10 system-wide, causing Qt_6.10 symbol errors when loading mobase.so against the bundled Qt 6.9. Switch Docker build to install Qt 6.10.2 from aqtinstall instead of Ubuntu's Qt 6.9 packages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix mobase RPATH: plugins/libs/ needs $ORIGIN/../../lib not $ORIGIN/../libSulfurNitride2026-03-121-1/+2
| | | | | | | | mobase.so in plugins/libs/ couldn't find bundled Qt/uibase because the RPATH was only one level up (plugins/lib/) instead of two (lib/). This caused "initialization failed" on NixOS and potentially other distros. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix NixOS CI: use nix python for cmake, venv only for pybind11 pinSulfurNitride2026-03-123-62/+71
| | | | | | | FindPython needs the real nix Python (with dev headers), not the venv wrapper. Save NIX_PYTHON3 before venv overrides PATH. Tested locally. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix NixOS CI: use venv for pinned pybind11 (nix Python is immutable)SulfurNitride2026-03-121-7/+16
| | | | | | | | Nix Python environments can't be modified with pip directly. Create a temporary venv with --system-site-packages and install pybind11==2.13.6 into it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix NixOS CI: pin pybind11 to 2.13.6 via pip (ABI compat with Docker build)SulfurNitride2026-03-121-3/+4
| | | | | | | Nixpkgs unstable has a newer pybind11 that removed the internal 'holder' member used by shared_cpp_owner.h. Pin to 2.13.6 to match our Docker build. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix NixOS CI: add Rust toolchain, cmake configure needs nak_ffi targetSulfurNitride2026-03-122-3/+3
| | | | Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix NixOS CI: tinyxml2 renamed to tinyxml-2 in nixpkgsSulfurNitride2026-03-121-1/+1
| | | | Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add NixOS mobase build to CI for Python plugin compatibilitySulfurNitride2026-03-122-0/+113
| | | | | | | | NixOS users can't load our Ubuntu-compiled mobase.so due to ABI differences. Add a parallel CI job that builds mobase.so in a Nix environment so NixOS users can download and drop it into plugins/libs/. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix CI: pull pre-built Docker image instead of rebuilding every runSulfurNitride2026-03-122-14/+10
| | | | | | | | | ci.yml was rebuilding the Docker image from scratch on every push because it used a different cache backend (registry) than docker.yml (gha). Now CI pulls the pre-built image from GHCR directly, and docker.yml uses consistent registry caching. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix Python plugins: bundle mobase pybind11 module in plugins/libs/SulfurNitride2026-03-121-0/+5
| | | | | | | | The Python proxy expects mobase.cpython-313-x86_64-linux-gnu.so in plugins/libs/ but build-inner.sh was not copying it. This caused "No module named 'mobase'" when loading Python plugins. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix build: add missing Qt6 find_package for OMOD installer, fix default argSulfurNitride2026-03-122-1/+4
| | | | | | | - Add find_package(Qt6) and qt_standard_project_setup() to installer_omod_native CMakeLists - Remove duplicate default argument from fillStatForFile definition in mo2filesystem.cpp Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add native Root Builder and OMOD Installer pluginsSulfurNitride2026-03-1210-1/+1368
| | | | | | | | | | | | | | | - libs/rootbuilder_native/: Native C++ port of rootbuilder.py (IPluginTool) - Root/ dir scanning, copy (reflink/CoW) and symlink modes - JSON manifest tracking, backup/restore, auto-deploy hooks - Legacy migration and third-party conflict detection - libs/installer_omod_native/: Native C++ port of installer_omod.py - Handles .omod archives (Oblivion Mod Manager format) - Binary config parsing, .NET string format, CRC file parsing - zlib raw deflate and LZMA decompression - Repackages to zip for MO2's installation manager - Added both to build-inner.sh plugin packaging Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Replace Python plugins with native C++ .so, remove bundled Python (~95MB)SulfurNitride2026-03-1241-267/+4640
| | | | | | | | | | | | | | - Add native plugins: form43_checker, script_extender_checker, preview_dds, basic_games (75 game defs via IPluginProxy pattern with Steam/GOG detection) - Remove portable Python from Dockerfile and build-inner.sh - Add Python settings tab with venv support (system python3 + PyQt6) - Skip loading plugin_python.so when Python disabled (default: off) - Update pythonrunner.cpp to use venv at ~/.local/share/fluorine/python-venv/ - Preserve real file permissions in FUSE VFS (fixes native executable +x bits) - Add FUSE_SET_ATTR_MODE handler so chmod works through VFS - Fix desktop shortcut Exec= to use fluorine-manager launcher, not bare binary Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add per-executable Proton toggle and terminal launch optionSulfurNitride2026-03-129-40/+197
| | | | | | | | | | | | | New checkboxes in Edit Executables dialog: - "Use Proton": controls whether the executable launches through Proton (default on for backward compat). Disable for native Linux tools. - "Open in terminal": launches the executable in a terminal window showing stdout/stderr output, useful for debugging. Auto-detects terminal emulator (konsole, gnome-terminal, alacritty, kitty, foot, xterm). Terminal stays open after process exits showing the exit code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix build: use QString::fromLocal8Bit instead of QStringEncoder::encodeSulfurNitride2026-03-111-2/+1
| | | | | | | | QStringEncoder::encode() returns DecodedData which can't be directly assigned to QString in a declaration context. Use fromLocal8Bit() which is simpler and equivalent for system-encoded byte arrays. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix LOOT disabling plugins: handle Bethesda asterisk format in plugins.txtSulfurNitride2026-03-111-6/+30
| | | | | | | | | | | | | LOOT writes plugins.txt in Bethesda format where *Plugin.esp = enabled and Plugin.esp (no prefix) = disabled. MO2's reader didn't strip the asterisk, so it tried to find plugins literally named "*Plugin.esp", failed to match, and marked them all as inactive. Two-pass approach: detect whether file uses asterisks. If yes (LOOT/Bethesda format), only *-prefixed plugins are active. If no (MO2's own format), all listed plugins are active. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix rmdir for empty overwrite directory cleanupSulfurNitride2026-03-111-1/+1
| | | | | | | QDir(path).rmdir(".") doesn't work on Linux — can't rmdir("."). Use QDir().rmdir(path) to remove by absolute path instead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix empty directory cleanup after overwrite moveSulfurNitride2026-03-111-3/+7
| | | | | | | | Sort directories by path length descending (deepest first) before rmdir, so leaf directories like NVSE/Plugins/Tweaks/INIs are removed before their parents. The previous prepend approach didn't guarantee depth-first order. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix "Create Mod..." and "Move content to Mod..." overwrite context menu actionsSulfurNitride2026-03-111-37/+47
| | | | | | | | | | | | The previous implementation used Windows SHFileOperation semantics that don't work on Linux: literal wildcard in paths, QFile::copy on directories, absolutePath() instead of absoluteFilePath(), and GetLastError() for errors. Rewrite moveOverwriteContentsTo() to recursively iterate all files, compute relative paths, and move each file individually with cross-filesystem fallback. Empty directories in overwrite are cleaned up after the move. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix tracked writes false positives, stale entries, portability, and launcher ↵SulfurNitride2026-03-114-18/+93
| | | | | | | | | | | | | | | | self-destruct - Remove initialScan() from mount and unmount — its heuristic ("file exists in both overwrite and a mod") caused false-positive tracking of game-generated config files. Tracking now only happens through explicit UI actions or snapshot-based detectManualMoves(). - Prune stale tracked entries on load when mod folder no longer exists on disk. - Store mod paths as relative (to JSON file parent) for instance portability; legacy absolute paths auto-migrate on next save. - Guard launcher sync against HERE==BIN_DST to prevent rm -rf self-destruct when running directly from ~/.local/share/fluorine/bin/. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix CI: upload build directory instead of removed tar.gzSulfurNitride2026-03-111-18/+3
| | | | Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove FUSE passthrough, flake.nix, and stop archiving build outputSulfurNitride2026-03-1110-865/+11
| | | | | | | | | | | | | FUSE passthrough caused Wine DLL loading failures (ERROR_NOACCESS 0x3E6) for all file types, not just executables. Root cause is a kernel-level incompatibility between FUSE passthrough I/O and Wine's file access patterns. Removed all passthrough code, the passthrough helper binary, libcap dependency, and the settings UI toggle. Also removed flake.nix (unused with Docker build system) and stopped tar.gz'ing the build output since GitHub zips release assets automatically. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Check CAP_SYS_ADMIN at runtime before negotiating FUSE passthroughSulfurNitride2026-03-114-2/+33
| | | | | | | | | Negotiating passthrough without the capability caused Wine/Proton DLL loading failures (SKSE plugins returning error 0x36). Now checks the process's effective capability set via libcap before telling the kernel we want passthrough. Also adds qt.conf for AT_SECURE compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix cap_sys_admin apply: copy-patch-replace to avoid text file busySulfurNitride2026-03-112-6/+12
| | | | | | | | patchelf can't modify a running binary. Now copies the binary to a temp file, patches RPATH and applies setcap on the copy, then replaces the original with mv. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Patch RPATH to absolute paths when applying cap_sys_adminSulfurNitride2026-03-112-10/+29
| | | | | | | | | glibc's ld.so ignores $ORIGIN in RPATH for capability binaries (AT_SECURE mode), causing "cannot open shared object" errors. Now patchelf rewrites RPATH to absolute paths before setcap runs, both in the settings dialog and the auto-apply at mount time. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix flake.nix: add libloot hash, remove stale placeholder commentSulfurNitride2026-03-111-1/+1
| | | | Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix LOOT ambiguous load order: use loadorder.txt as canonical orderSulfurNitride2026-03-111-26/+23
| | | | | | | | Instead of prepending base ESMs to plugins.txt (wrong order), generate plugins.txt from loadorder.txt with active/inactive markers from the original plugins.txt. This ensures both files agree on plugin order. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix LOOT "ambiguous load order" by merging base ESMs into plugins.txtSulfurNitride2026-03-111-4/+53
| | | | | | | | | MO2's plugins.txt omits implicitly-active base game ESMs — LOOT needs every plugin listed to determine unambiguous load order. Now merges entries from loadorder.txt that are missing from plugins.txt before passing the files to LOOT. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix LOOT launch: clean QT_PLUGIN_PATH and LD_LIBRARY_PATH from envSulfurNitride2026-03-111-2/+11
| | | | | | | | LOOT's AppImage bundles its own Qt — inheriting Fluorine's QT_PLUGIN_PATH caused incompatible Qt plugin loading (exit code 6). Also clean LD_LIBRARY_PATH when the original was empty. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix RPATH patching for .so.* files (libproxy.so.1, libpxbackend)SulfurNitride2026-03-111-1/+1
| | | | | | | The find glob only matched *.so, missing versioned shared libraries like libproxy.so.1 which retained hardcoded Docker build paths. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix FUSE passthrough: use proper API, drop LD_LIBRARY_PATH, auto-apply capSulfurNitride2026-03-116-50/+101
| | | | | | | | | | | | | | | | - Use fuse_passthrough_open/close API instead of raw fd assignment to fi->backing_id, which caused EIO on every file open through the VFS - Remove LD_LIBRARY_PATH from launcher script — DT_RPATH handles lib resolution, and LD_LIBRARY_PATH triggers AT_SECURE which drops file capabilities (cap_sys_admin) needed for passthrough - Auto-detect missing cap_sys_admin at mount time and prompt via pkexec to re-apply it (handles binary copy/update/extract losing the cap) - Disable passthrough for session if fuse_passthrough_open fails, avoiding "Operation not permitted" spam in terminal - Fix Nix flake: tinyxml2 → tinyxml-2, xorg.lib* → top-level names - CI: eliminate artifact zip round-trip for GitHub releases Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix flake.nix: escape bash ${var} in Nix '' stringsSulfurNitride2026-03-111-26/+26
| | | | | | | Nix interprets ${...} inside '' strings as its own interpolation. Use ''${...} to emit literal ${...} in the generated launcher script. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Sync entire app to ~/.local/share/fluorine/bin/ for stable symlinksSulfurNitride2026-03-112-45/+52
| | | | | | | | | The launcher now deploys the full application (not just Python) to the central data directory on launch. Instance plugin symlinks always point to this stable location instead of the tarball extraction dir, so they survive moves/deletes of the original archive. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* CI: build tarball instead of AppImage, publish .tar.gz in releasesSulfurNitride2026-03-111-17/+8
| | | | Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* FUSE VFS performance, tracked writes fix, remove steam-run, add NixOS flakeSulfurNitride2026-03-1118-256/+1165
| | | | | | | | | | | | | - FUSE: disable AUTO_INVAL_DATA, enable readdirplus, 24h TTLs, clone_fd, FUSE_USE_VERSION=312 — cuts game load time from 2:40 to 1:57 - Fix tracked writes going to wrong (lower-priority) mod in both detectManualMoves and syncOverwrite - Remove steam-run entirely (UI, C++, Rust layers) - Build system: default to tarball, add installer/appimage as opt-in modes - Add flake.nix for NixOS — builds from source with Rust crates as separate pure derivations, FHS wrapper, NixOS module with FUSE passthrough Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Track files moved from Overwrite to mods for in-place write-backSulfurNitride2026-03-1010-21/+769
| | | | | | | | | | When users move generated files (logs, INIs) from Overwrite into a dedicated mod folder, future VFS writes to those files now go directly to the mod instead of creating duplicates in Overwrite. Tracking persists across sessions via a JSON file and detects manual moves by comparing overwrite snapshots between sessions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove COW from FUSE VFS — write mod files in-placeSulfurNitride2026-03-092-106/+8
| | | | | | | | Mod .ini/.log files now stay in their original mod folder instead of being copied to Overwrite. All files pass through reads and writes to their real location. No copy-on-write needed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* FUSE VFS: lazy copy-on-write to prevent Overwrite folder pollutionSulfurNitride2026-03-092-24/+74
| | | | | | | | Defer COW from open() to first write(). Wine/Proton opens mod files O_RDWR for metadata ops (SetFileTime, etc.) without actually writing, which was copying SKSE plugin .ini/.log files into Overwrite needlessly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Extract icons from .exe files for desktop shortcuts, add cache invalidationSulfurNitride2026-03-081-26/+295
| | | | | | | | | Parse PE resources to extract the largest embedded icon from Windows executables and save as PNG to ~/.local/share/icons/fluorine/. If a cached icon is just the Fluorine fallback (same file size), re-attempt extraction in case the executable changed or was previously unavailable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix LOOT sort for PluginsTxt games, improve desktop shortcuts, add Browse VFSSulfurNitride2026-03-087-25/+201
| | | | | | | | | | | | | | | | | | | LOOT sort fix: For PluginsTxt games (FO4, SkyrimSE), LOOT only modifies plugins.txt, not loadorder.txt. Previously the stale loadorder.txt was copied back, causing readPluginLists() to use the old order. Now we regenerate loadorder.txt from the sorted plugins.txt. Desktop shortcuts: Add "Application Launcher" option on Linux that creates .desktop entries in ~/.local/share/applications/. Include instance name in both filename (New-Vegas-NVSE.desktop) and Name= field (NVSE (New Vegas)) to avoid collisions. Icons saved to ~/.local/share/icons/fluorine/ with game-specific names. Browse VFS: Add button in Data tab that mounts the FUSE VFS and opens the game Data folder in the system file manager, allowing users to browse the merged mod files as the game sees them. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove FUSE passthrough (CAP_SYS_ADMIN incompatible with AppImage), remove ↵SulfurNitride2026-03-086-19/+21
| | | | | | | | | | | bsplugins, fix bsatk types - Remove FUSE passthrough code: requesting FUSE_CAP_PASSTHROUGH without CAP_SYS_ADMIN caused session cleanup crash on shutdown - Remove bsplugins library (unneeded port) - Fix bsatk: use uint32_t instead of unsigned long for BSAULong (LP64 compat) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* FUSE VFS performance: attr cache, selective invalidation, init tuningSulfurNitride2026-03-086-89/+1066
| | | | | | | | | | | | - Increase ATTR_CACHE_SECONDS from 1.0s to 30.0s (90% getattr reduction) - Replace nuclear clearDirCache() with selective invalidateDirCache() that only clears the affected parent directory instead of all caches - Add mo2_init callback: enable FUSE_CAP_AUTO_INVAL_DATA for kernel page cache retention across open/close, increase max_readahead/max_write to 1MB - Switch open_files_mutex to shared_mutex for concurrent read access - Add perf handoff doc for VFS optimization history Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>