From b1ce38db496c9692bca512e6bd76050ef79f61c2 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Wed, 11 Mar 2026 02:50:50 -0500 Subject: FUSE VFS performance, tracked writes fix, remove steam-run, add NixOS flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- build.sh | 43 ++- docker/Dockerfile | 38 +-- docker/build-inner.sh | 372 +++++++++++++++++-------- flake.nix | 574 +++++++++++++++++++++++++++++++++++++++ libs/nak/src/runtime_wrap.rs | 27 -- src/src/CMakeLists.txt | 2 +- src/src/fuseconnector.cpp | 39 ++- src/src/fuseconnector.h | 2 + src/src/organizercore.cpp | 32 ++- src/src/protonlauncher.cpp | 23 +- src/src/protonlauncher.h | 2 - src/src/settingsdialog.ui | 44 ++- src/src/settingsdialogproton.cpp | 69 +++-- src/src/settingsdialogproton.h | 3 +- src/src/spawn.cpp | 2 - src/src/vfs/mo2filesystem.cpp | 125 ++++++++- src/src/vfs/mo2filesystem.h | 12 + src/src/vfs/trackedwrites.cpp | 20 +- 18 files changed, 1169 insertions(+), 260 deletions(-) create mode 100644 flake.nix diff --git a/build.sh b/build.sh index 751a916..dac1aec 100755 --- a/build.sh +++ b/build.sh @@ -4,7 +4,11 @@ set -euo pipefail # Build Fluorine Manager using Docker. # # Usage: -# ./build.sh # Build AppImage + staging dir +# ./build.sh # Build portable .tar.gz (default) +# ./build.sh tarball # Build portable .tar.gz only +# ./build.sh installer # Build self-extracting .bin installer only +# ./build.sh appimage # Build AppImage (legacy) +# ./build.sh all # Build tarball + AppImage (if available) # ./build.sh shell # Drop into the build container for debugging # # Prerequisites: Docker or Podman @@ -25,15 +29,36 @@ CONTAINER_NAME="fluorine-build-$$" cd "${SCRIPT_DIR}" -# Build the Docker image if it doesn't exist or Dockerfile changed +# Determine build mode from first argument +BUILD_MODE="${1:-tarball}" +case "${BUILD_MODE}" in + tarball|installer|appimage|all|shell) ;; + *) + echo "Usage: ./build.sh [tarball|installer|appimage|all|shell]" + echo "" + echo " tarball Build portable .tar.gz" + echo " installer Build self-extracting .bin installer" + echo " appimage Build AppImage (legacy)" + echo " all Build tarball (+ AppImage if available)" + echo " shell Drop into build container" + exit 1 + ;; +esac + +# Build the Docker image if it doesn't exist or Dockerfile changed. +# Pass BUILD_APPIMAGE=1 only when AppImage builds are requested. echo "=== Ensuring build image is up to date ===" -${DOCKER} build -t "${IMAGE_NAME}" docker/ +DOCKER_BUILD_ARGS=() +if [ "${BUILD_MODE}" = "appimage" ] || [ "${BUILD_MODE}" = "all" ]; then + DOCKER_BUILD_ARGS+=(--build-arg BUILD_APPIMAGE=1) +fi +${DOCKER} build "${DOCKER_BUILD_ARGS[@]}" -t "${IMAGE_NAME}" docker/ # Persistent ccache directory for faster rebuilds. CCACHE_DIR="${HOME}/.cache/fluorine-ccache" mkdir -p "${CCACHE_DIR}" -if [ "${1:-}" = "shell" ]; then +if [ "${BUILD_MODE}" = "shell" ]; then echo "=== Dropping into build container shell ===" exec ${DOCKER} run --rm -it \ -v "${SCRIPT_DIR}:/src:rw" \ @@ -46,11 +71,12 @@ if [ "${1:-}" = "shell" ]; then bash fi -echo "=== Starting build ===" +echo "=== Starting build (mode: ${BUILD_MODE}) ===" ${DOCKER} run --rm \ -v "${SCRIPT_DIR}:/src:rw" \ -v "${CCACHE_DIR}:/ccache:rw" \ -e CCACHE_DIR=/ccache \ + -e BUILD_MODE="${BUILD_MODE}" \ -w /src \ --device /dev/fuse \ --cap-add SYS_ADMIN \ @@ -60,7 +86,6 @@ ${DOCKER} run --rm \ echo "" echo "=== Done ===" -if ls build/*.AppImage >/dev/null 2>&1; then - echo "AppImage: $(ls build/*.AppImage)" -fi -echo "Staging: build/staging/" +echo "Build outputs:" +ls -lh build/fluorine-manager.tar.gz build/fluorine-manager.bin build/*.AppImage 2>/dev/null || echo " (none found)" +echo "Staging: build/staging/" diff --git a/docker/Dockerfile b/docker/Dockerfile index 153ff62..7f319e4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -87,21 +87,27 @@ RUN git clone --depth 1 --branch ${LIBLOOT_REF} \ ldconfig && \ rm -rf /tmp/libloot -# ── Pre-download linuxdeploy tooling ── -RUN mkdir -p /opt/linuxdeploy && \ - curl -L --retry 3 -o /opt/linuxdeploy/linuxdeploy-x86_64.AppImage \ - https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage && \ - curl -L --retry 3 -o /opt/linuxdeploy/linuxdeploy-plugin-qt-x86_64.AppImage \ - https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage && \ - chmod +x /opt/linuxdeploy/linuxdeploy-x86_64.AppImage \ - /opt/linuxdeploy/linuxdeploy-plugin-qt-x86_64.AppImage && \ - # Extract Qt plugin (avoids FUSE requirement at runtime) - cd /opt/linuxdeploy && \ - ./linuxdeploy-plugin-qt-x86_64.AppImage --appimage-extract >/dev/null && \ - mv squashfs-root linuxdeploy-plugin-qt.AppDir && \ - chmod +x linuxdeploy-plugin-qt.AppDir/AppRun && \ - ln -sf /opt/linuxdeploy/linuxdeploy-plugin-qt.AppDir/AppRun \ - /opt/linuxdeploy/linuxdeploy-plugin-qt && \ - chmod -x /opt/linuxdeploy/linuxdeploy-plugin-qt-x86_64.AppImage +# ── Pre-download linuxdeploy tooling (optional, only for AppImage builds) ── +# Set BUILD_APPIMAGE=1 to include linuxdeploy in the image. +# Without it, only tarball and installer builds are available. +ARG BUILD_APPIMAGE=0 +RUN if [ "${BUILD_APPIMAGE}" = "1" ]; then \ + mkdir -p /opt/linuxdeploy && \ + curl -L --retry 3 -o /opt/linuxdeploy/linuxdeploy-x86_64.AppImage \ + https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage && \ + curl -L --retry 3 -o /opt/linuxdeploy/linuxdeploy-plugin-qt-x86_64.AppImage \ + https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage && \ + chmod +x /opt/linuxdeploy/linuxdeploy-x86_64.AppImage \ + /opt/linuxdeploy/linuxdeploy-plugin-qt-x86_64.AppImage && \ + cd /opt/linuxdeploy && \ + ./linuxdeploy-plugin-qt-x86_64.AppImage --appimage-extract >/dev/null && \ + mv squashfs-root linuxdeploy-plugin-qt.AppDir && \ + chmod +x linuxdeploy-plugin-qt.AppDir/AppRun && \ + ln -sf /opt/linuxdeploy/linuxdeploy-plugin-qt.AppDir/AppRun \ + /opt/linuxdeploy/linuxdeploy-plugin-qt && \ + chmod -x /opt/linuxdeploy/linuxdeploy-plugin-qt-x86_64.AppImage; \ + else \ + echo "Skipping linuxdeploy (BUILD_APPIMAGE not set)"; \ + fi WORKDIR /build diff --git a/docker/build-inner.sh b/docker/build-inner.sh index 1bc33e1..bd0ed73 100755 --- a/docker/build-inner.sh +++ b/docker/build-inner.sh @@ -98,7 +98,6 @@ fi SO7="build/src/src/dlls/7z.so" if [ -f "${SO7}" ]; then cp -f "${SO7}" "${OUT_DIR}/dlls/7z.so" - cp -f "${SO7}" "${OUT_DIR}/dlls/7zip.dll" fi # ── Project-specific shared libraries ── @@ -291,12 +290,16 @@ for tool in wrestool icotool lootcli; do done # ── Fix RPATH so binaries find libs without LD_LIBRARY_PATH ── +# IMPORTANT: Use --force-rpath to set DT_RPATH (not DT_RUNPATH). +# DT_RPATH is honored even when the binary has file capabilities +# (e.g. cap_sys_admin for FUSE passthrough). DT_RUNPATH and +# LD_LIBRARY_PATH are both ignored by the kernel for capability binaries. echo "Patching RPATH..." -patchelf --set-rpath '$ORIGIN/lib:$ORIGIN/python/lib' "${OUT_DIR}/ModOrganizer-core" -[ -f "${OUT_DIR}/lootcli" ] && patchelf --set-rpath '$ORIGIN/lib' "${OUT_DIR}/lootcli" -find "${OUT_DIR}/plugins" -name "*.so" -exec patchelf --set-rpath '$ORIGIN/../lib:$ORIGIN/../python/lib' {} \; 2>/dev/null || true +patchelf --force-rpath --set-rpath '$ORIGIN/lib:$ORIGIN/python/lib' "${OUT_DIR}/ModOrganizer-core" +[ -f "${OUT_DIR}/lootcli" ] && patchelf --force-rpath --set-rpath '$ORIGIN/lib' "${OUT_DIR}/lootcli" +find "${OUT_DIR}/plugins" -name "*.so" -exec patchelf --force-rpath --set-rpath '$ORIGIN/../lib:$ORIGIN/../python/lib' {} \; 2>/dev/null || true # Libraries in lib/ (e.g. librunner.so) need to find sibling libs and python/lib. -find "${OUT_DIR}/lib" -name "*.so" -exec patchelf --set-rpath '$ORIGIN:$ORIGIN/../python/lib' {} \; 2>/dev/null || true +find "${OUT_DIR}/lib" -name "*.so" -exec patchelf --force-rpath --set-rpath '$ORIGIN:$ORIGIN/../python/lib' {} \; 2>/dev/null || true # ── Validate embedded Python runtime ── if ! PYTHONHOME="${OUT_DIR}/python" \ @@ -316,6 +319,14 @@ SELF="$(readlink -f "$0")" HERE="$(cd "$(dirname "$SELF")" && pwd)" export PATH="${HERE}:${PATH}" +# Save the original environment so game launches (Proton/Wine) can restore it. +# Without this, our bundled LD_LIBRARY_PATH leaks into game processes and +# causes library conflicts. +export FLUORINE_ORIG_LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}" +export FLUORINE_ORIG_PATH="${PATH}" +export FLUORINE_ORIG_XDG_DATA_DIRS="${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" +export FLUORINE_ORIG_QT_PLUGIN_PATH="${QT_PLUGIN_PATH:-}" + # ── Sync portable Python to data dir (only when outdated) ── FLUORINE_DATA="${HOME}/.local/share/fluorine" PYTHON_DST="${FLUORINE_DATA}/python" @@ -353,73 +364,200 @@ exec env PYTHONHOME="${MO2_PYTHONHOME}" "${HERE}/ModOrganizer-core" "$@" LAUNCH chmod +x "${OUT_DIR}/fluorine-manager" -# ── Desktop integration files (for AppImage) ── +# ── Desktop integration files ── cp -f /src/data/com.fluorine.manager.desktop "${OUT_DIR}/" cp -f /src/data/com.fluorine.manager.png "${OUT_DIR}/" cp -f /src/data/com.fluorine.manager.metainfo.xml "${OUT_DIR}/" -# ── Build AppImage ── +# ── Determine build mode ── +# BUILD_MODE is passed from build.sh: tarball (default), installer, appimage, all +BUILD_MODE="${BUILD_MODE:-tarball}" + +# ── Build tarball (portable distribution) ── +build_tarball() { + echo "" + echo "=== Building tarball ===" + cd /src/build + # Create a clean directory name for the archive + TARBALL_NAME="fluorine-manager" + rm -rf "${TARBALL_NAME}" + cp -a staging "${TARBALL_NAME}" + tar czf "${TARBALL_NAME}.tar.gz" "${TARBALL_NAME}"/ + rm -rf "${TARBALL_NAME}" + echo "Tarball: /src/build/${TARBALL_NAME}.tar.gz" + ls -lh "/src/build/${TARBALL_NAME}.tar.gz" +} + +# ── Build self-extracting installer (.bin frontloader) ── +build_installer() { + echo "" + echo "=== Building installer ===" + + # Create the installer header script + INSTALLER_SCRIPT="/src/build/installer-header.sh" + cat > "${INSTALLER_SCRIPT}" <<'INSTALLER_HEADER' +#!/usr/bin/env bash +set -euo pipefail + +APP_NAME="Fluorine Manager" +INSTALL_DIR="${HOME}/.local/share/fluorine/bin" +DESKTOP_DIR="${HOME}/.local/share/applications" +ICON_DIR="${HOME}/.local/share/icons/hicolor/256x256/apps" + echo "" -echo "=== Building AppImage ===" - -APPDIR="/src/build/AppDir" -rm -rf "${APPDIR}" -mkdir -p "${APPDIR}/usr/bin" "${APPDIR}/usr/lib" "${APPDIR}/usr/share/applications" \ - "${APPDIR}/usr/plugins" \ - "${APPDIR}/usr/share/icons/hicolor/256x256/apps" \ - "${APPDIR}/usr/share/metainfo" - -# Copy staging into AppDir layout -cp -a "${OUT_DIR}"/. "${APPDIR}/usr/bin/" -# Move libs to standard location -mv "${APPDIR}/usr/bin/lib"/* "${APPDIR}/usr/lib/" 2>/dev/null || true -rmdir "${APPDIR}/usr/bin/lib" 2>/dev/null || true -# Flatpak runtime exposed Qt plugins from a stable system location. Mirror that -# layout in AppImage by placing bundled Qt plugins under /usr/plugins. -if [ -d "${APPDIR}/usr/bin/qt6plugins" ]; then - cp -a "${APPDIR}/usr/bin/qt6plugins"/. "${APPDIR}/usr/plugins/" +echo "╔══════════════════════════════════════════╗" +echo "║ Fluorine Manager Installer ║" +echo "╚══════════════════════════════════════════╝" +echo "" + +# Detect existing installation +if [ -d "${INSTALL_DIR}" ] && [ -f "${INSTALL_DIR}/ModOrganizer-core" ]; then + echo "Existing installation detected at: ${INSTALL_DIR}" + echo "" + echo " 1) Update existing installation" + echo " 2) Extract portable copy here (./fluorine-manager/)" + echo " 3) Cancel" + echo "" + read -rp "Choose [1/2/3]: " CHOICE +else + echo " 1) Install to ${INSTALL_DIR} (global)" + echo " 2) Extract portable copy here (./fluorine-manager/)" + echo " 3) Cancel" + echo "" + read -rp "Choose [1/2/3]: " CHOICE fi -# Symlink the portable Python's libpython into usr/lib/ so that: -# 1) linuxdeploy sees the dependency as already satisfied and doesn't bundle -# the container's system libpython (which lacks static built-in modules) -# 2) librunner.so (which lives in usr/lib/) can resolve libpython via $ORIGIN -PP_APPDIR_LIB="${APPDIR}/usr/bin/python/lib" -for pp_lib in "${PP_APPDIR_LIB}"/libpython*.so*; do - [ -e "${pp_lib}" ] || continue - pp_name="$(basename "${pp_lib}")" - # Remove any copy linuxdeploy might have placed, then symlink to portable. - rm -f "${APPDIR}/usr/lib/${pp_name}" - ln -sf ../bin/python/lib/"${pp_name}" "${APPDIR}/usr/lib/${pp_name}" -done +case "${CHOICE}" in + 1) + echo "" + echo "Installing to ${INSTALL_DIR}..." + mkdir -p "${INSTALL_DIR}" + # Extract payload (everything after the __PAYLOAD__ marker) + ARCHIVE_START=$(awk '/^__PAYLOAD__$/{print NR + 1; exit 0;}' "$0") + tail -n +"${ARCHIVE_START}" "$0" | tar xzf - -C "${INSTALL_DIR}" --strip-components=1 + + # Create desktop shortcut + mkdir -p "${DESKTOP_DIR}" "${ICON_DIR}" + cp -f "${INSTALL_DIR}/com.fluorine.manager.png" "${ICON_DIR}/" + + cat > "${DESKTOP_DIR}/com.fluorine.manager.desktop" </dev/null 2>&1 && \ + update-desktop-database "${DESKTOP_DIR}" 2>/dev/null || true + + echo "" + echo "Installation complete!" + echo " Binary: ${INSTALL_DIR}/fluorine-manager" + echo " Shortcut: ${DESKTOP_DIR}/com.fluorine.manager.desktop" + echo "" + read -rp "Launch now? [Y/n]: " LAUNCH + if [ "${LAUNCH,,}" != "n" ]; then + exec "${INSTALL_DIR}/fluorine-manager" "$@" + fi + ;; + 2) + echo "" + PORTABLE_DIR="$(pwd)/fluorine-manager" + echo "Extracting portable copy to ${PORTABLE_DIR}..." + mkdir -p "${PORTABLE_DIR}" + ARCHIVE_START=$(awk '/^__PAYLOAD__$/{print NR + 1; exit 0;}' "$0") + tail -n +"${ARCHIVE_START}" "$0" | tar xzf - -C "${PORTABLE_DIR}" --strip-components=1 + + echo "" + echo "Portable extraction complete!" + echo " Run: ${PORTABLE_DIR}/fluorine-manager" + ;; + *) + echo "Cancelled." + exit 0 + ;; +esac +exit 0 +__PAYLOAD__ +INSTALLER_HEADER + + # Build the tarball payload + cd /src/build + TARBALL_NAME="fluorine-manager" + rm -rf "${TARBALL_NAME}" + cp -a staging "${TARBALL_NAME}" + tar czf "${TARBALL_NAME}-payload.tar.gz" "${TARBALL_NAME}"/ + rm -rf "${TARBALL_NAME}" + + # Combine header + payload into self-extracting .bin + cat "${INSTALLER_SCRIPT}" "${TARBALL_NAME}-payload.tar.gz" > "${TARBALL_NAME}.bin" + chmod +x "${TARBALL_NAME}.bin" + rm -f "${INSTALLER_SCRIPT}" "${TARBALL_NAME}-payload.tar.gz" + + echo "Installer: /src/build/${TARBALL_NAME}.bin" + ls -lh "/src/build/${TARBALL_NAME}.bin" +} -# Desktop integration -cp -f "${OUT_DIR}/com.fluorine.manager.desktop" "${APPDIR}/usr/share/applications/" -cp -f "${OUT_DIR}/com.fluorine.manager.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/" -cp -f "${OUT_DIR}/com.fluorine.manager.metainfo.xml" "${APPDIR}/usr/share/metainfo/" - -# Bundle icon themes so QIcon::fromTheme() calls resolve inside the AppImage. -# Flatpak runtime provided this globally; AppImage must carry it explicitly. -mkdir -p "${APPDIR}/usr/share/icons" -for theme_dir in /usr/share/icons/*; do - [ -d "${theme_dir}" ] || continue - cp -a "${theme_dir}" "${APPDIR}/usr/share/icons/" -done -if [ -f "/usr/share/icons/default/index.theme" ]; then - mkdir -p "${APPDIR}/usr/share/icons/default" - cp -f "/usr/share/icons/default/index.theme" "${APPDIR}/usr/share/icons/default/" -fi +# ── Build AppImage (legacy, optional) ── +build_appimage() { + echo "" + echo "=== Building AppImage ===" + + if [ ! -d /opt/linuxdeploy ]; then + echo "ERROR: linuxdeploy not available. Rebuild Docker image with --build-arg BUILD_APPIMAGE=1" + return 1 + fi + + APPDIR="/src/build/AppDir" + rm -rf "${APPDIR}" + mkdir -p "${APPDIR}/usr/bin" "${APPDIR}/usr/lib" "${APPDIR}/usr/share/applications" \ + "${APPDIR}/usr/plugins" \ + "${APPDIR}/usr/share/icons/hicolor/256x256/apps" \ + "${APPDIR}/usr/share/metainfo" + + cp -a "${OUT_DIR}"/. "${APPDIR}/usr/bin/" + mv "${APPDIR}/usr/bin/lib"/* "${APPDIR}/usr/lib/" 2>/dev/null || true + rmdir "${APPDIR}/usr/bin/lib" 2>/dev/null || true + if [ -d "${APPDIR}/usr/bin/qt6plugins" ]; then + cp -a "${APPDIR}/usr/bin/qt6plugins"/. "${APPDIR}/usr/plugins/" + fi + + PP_APPDIR_LIB="${APPDIR}/usr/bin/python/lib" + for pp_lib in "${PP_APPDIR_LIB}"/libpython*.so*; do + [ -e "${pp_lib}" ] || continue + pp_name="$(basename "${pp_lib}")" + rm -f "${APPDIR}/usr/lib/${pp_name}" + ln -sf ../bin/python/lib/"${pp_name}" "${APPDIR}/usr/lib/${pp_name}" + done + + cp -f "${OUT_DIR}/com.fluorine.manager.desktop" "${APPDIR}/usr/share/applications/" + cp -f "${OUT_DIR}/com.fluorine.manager.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/" + cp -f "${OUT_DIR}/com.fluorine.manager.metainfo.xml" "${APPDIR}/usr/share/metainfo/" + + mkdir -p "${APPDIR}/usr/share/icons" + for theme_dir in /usr/share/icons/*; do + [ -d "${theme_dir}" ] || continue + cp -a "${theme_dir}" "${APPDIR}/usr/share/icons/" + done + if [ -f "/usr/share/icons/default/index.theme" ]; then + mkdir -p "${APPDIR}/usr/share/icons/default" + cp -f "/usr/share/icons/default/index.theme" "${APPDIR}/usr/share/icons/default/" + fi -# Update RPATH for new lib location. -# usr/lib/ contains librunner.so and other MO2 libs. The symlinks above make -# libpython resolvable via $ORIGIN for libraries in usr/lib/. -patchelf --set-rpath '$ORIGIN/../lib:$ORIGIN/python/lib' "${APPDIR}/usr/bin/ModOrganizer-core" -[ -f "${APPDIR}/usr/bin/lootcli" ] && patchelf --set-rpath '$ORIGIN/../lib' "${APPDIR}/usr/bin/lootcli" -find "${APPDIR}/usr/bin/plugins" -name "*.so" -exec patchelf --set-rpath '$ORIGIN/../../lib' {} \; 2>/dev/null || true -find "${APPDIR}/usr/lib" -name "*.so" -not -name "libpython*" -exec patchelf --set-rpath '$ORIGIN' {} \; 2>/dev/null || true + patchelf --force-rpath --set-rpath '$ORIGIN/../lib:$ORIGIN/python/lib' "${APPDIR}/usr/bin/ModOrganizer-core" + [ -f "${APPDIR}/usr/bin/lootcli" ] && patchelf --force-rpath --set-rpath '$ORIGIN/../lib' "${APPDIR}/usr/bin/lootcli" + find "${APPDIR}/usr/bin/plugins" -name "*.so" -exec patchelf --force-rpath --set-rpath '$ORIGIN/../../lib' {} \; 2>/dev/null || true + find "${APPDIR}/usr/lib" -name "*.so" -not -name "libpython*" -exec patchelf --force-rpath --set-rpath '$ORIGIN' {} \; 2>/dev/null || true -# Create AppRun wrapper -cat > "${APPDIR}/AppRun" <<'APPRUN' + cat > "${APPDIR}/AppRun" <<'APPRUN' #!/usr/bin/env bash set -euo pipefail SELF="$(readlink -f "$0")" @@ -427,9 +565,6 @@ HERE="$(cd "$(dirname "$SELF")" && pwd)" BIN="${HERE}/usr/bin" APPIMAGE_DIR="$(dirname "$(readlink -f "${APPIMAGE:-$0}")")" -# ── Sync portable Python to data dir (only when outdated) ── -# Plugins and dlls are read-only and loaded directly from the squashfs mount. -# Python needs a writable copy because pip/plugins may modify site-packages. FLUORINE_DATA="${HOME}/.local/share/fluorine" PYTHON_DST="${FLUORINE_DATA}/python" PYTHON_SRC="${BIN}/python" @@ -448,9 +583,6 @@ if [ -d "${PYTHON_SRC}" ]; then fi fi -# Save the original (pre-AppImage) environment so game launches can restore it. -# Without this, AppImage's LD_LIBRARY_PATH/PATH leak into Proton and -# cause library conflicts that make games crash. export FLUORINE_ORIG_LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}" export FLUORINE_ORIG_PATH="${PATH}" export FLUORINE_ORIG_XDG_DATA_DIRS="${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" @@ -466,7 +598,6 @@ export MO2_PYTHON_DIR="${PYTHON_DST}" unset PYTHONPATH PYTHONNOUSERSITE PYTHONHOME -# Use bundled Qt6 plugins. export QT_PLUGIN_PATH="${HERE}/usr/plugins" export QT_QPA_PLATFORM_PLUGIN_PATH="${HERE}/usr/plugins/platforms" export XDG_DATA_DIRS="${HERE}/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" @@ -477,57 +608,66 @@ export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-KDE}" cd "${APPIMAGE_DIR}" exec "${BIN}/ModOrganizer-core" "$@" APPRUN -chmod +x "${APPDIR}/AppRun" - -# Symlinks required by AppImage spec -ln -sf usr/share/applications/com.fluorine.manager.desktop "${APPDIR}/com.fluorine.manager.desktop" -ln -sf usr/share/icons/hicolor/256x256/apps/com.fluorine.manager.png "${APPDIR}/com.fluorine.manager.png" -ln -sf usr/share/icons/hicolor/256x256/apps/com.fluorine.manager.png "${APPDIR}/.DirIcon" - -# Extract linuxdeploy (can't use FUSE inside Docker) -DEPLOY_DIR="/tmp/linuxdeploy-extract" -if [ ! -d "${DEPLOY_DIR}" ]; then - cd /opt/linuxdeploy - ./linuxdeploy-x86_64.AppImage --appimage-extract >/dev/null - mv squashfs-root "${DEPLOY_DIR}" - chmod +x "${DEPLOY_DIR}/AppRun" -fi + chmod +x "${APPDIR}/AppRun" + + ln -sf usr/share/applications/com.fluorine.manager.desktop "${APPDIR}/com.fluorine.manager.desktop" + ln -sf usr/share/icons/hicolor/256x256/apps/com.fluorine.manager.png "${APPDIR}/com.fluorine.manager.png" + ln -sf usr/share/icons/hicolor/256x256/apps/com.fluorine.manager.png "${APPDIR}/.DirIcon" + + DEPLOY_DIR="/tmp/linuxdeploy-extract" + if [ ! -d "${DEPLOY_DIR}" ]; then + cd /opt/linuxdeploy + ./linuxdeploy-x86_64.AppImage --appimage-extract >/dev/null + mv squashfs-root "${DEPLOY_DIR}" + chmod +x "${DEPLOY_DIR}/AppRun" + fi -# Use linuxdeploy to generate the AppImage. -# We skip the Qt plugin since we already handle Qt plugin paths at runtime -# and our deps are already staged. -# -# linuxdeploy scans binary deps and bundles them. We must prevent it from -# bundling the container's system libpython (which lacks statically-compiled -# extension modules like _ctypes, resource, _lzma). The portable Python -# runtime in usr/bin/python/lib/ is the correct copy. Symlinks in usr/lib/ -# point to it, and --exclude-library prevents linuxdeploy from overwriting them. -export ARCH=x86_64 -# Tell linuxdeploy to skip libpython — we bundle the portable Python's copy -# via symlinks in usr/lib/ and must not replace it with the container's version. -export LINUXDEPLOY_EXCLUDE_MODULES="libpython" -"${DEPLOY_DIR}/AppRun" \ - --appdir "${APPDIR}" \ - --output appimage \ - --desktop-file "${APPDIR}/usr/share/applications/com.fluorine.manager.desktop" \ - --icon-file "${APPDIR}/usr/share/icons/hicolor/256x256/apps/com.fluorine.manager.png" \ - --exclude-library "libpython*" \ - 2>&1 || { - echo "WARNING: linuxdeploy AppImage generation failed, falling back to staging dir output" + export ARCH=x86_64 + export LINUXDEPLOY_EXCLUDE_MODULES="libpython" + "${DEPLOY_DIR}/AppRun" \ + --appdir "${APPDIR}" \ + --output appimage \ + --desktop-file "${APPDIR}/usr/share/applications/com.fluorine.manager.desktop" \ + --icon-file "${APPDIR}/usr/share/icons/hicolor/256x256/apps/com.fluorine.manager.png" \ + --exclude-library "libpython*" \ + 2>&1 || { + echo "WARNING: linuxdeploy AppImage generation failed" + } + + APPIMAGE_FILE=$(ls -1 Fluorine*.AppImage 2>/dev/null || ls -1 *.AppImage 2>/dev/null || true) + if [ -n "${APPIMAGE_FILE}" ]; then + mv "${APPIMAGE_FILE}" "/src/build/" + echo "AppImage: /src/build/${APPIMAGE_FILE}" + ls -lh "/src/build/"*.AppImage + fi } -# Move AppImage to output -APPIMAGE_FILE=$(ls -1 Fluorine*.AppImage 2>/dev/null || ls -1 *.AppImage 2>/dev/null || true) -if [ -n "${APPIMAGE_FILE}" ]; then - mv "${APPIMAGE_FILE}" "/src/build/" - echo "" - echo "=== AppImage built ===" - ls -lh "/src/build/"*.AppImage -else - echo "" - echo "=== Staging complete (AppImage generation skipped) ===" -fi +# ── Execute requested build mode ── +case "${BUILD_MODE}" in + tarball) + build_tarball + ;; + installer) + build_installer + ;; + appimage) + build_appimage + ;; + all) + build_tarball + if [ -d /opt/linuxdeploy ]; then + build_appimage + fi + ;; + *) + echo "ERROR: Unknown BUILD_MODE '${BUILD_MODE}'. Use: tarball, installer, appimage, all" + exit 1 + ;; +esac echo "" echo "=== Build Summary ===" du -sh "${OUT_DIR}"/*/ "${OUT_DIR}"/ModOrganizer-core 2>/dev/null | sort -rh +echo "" +echo "Build outputs:" +ls -lh /src/build/fluorine-manager.tar.gz /src/build/fluorine-manager.bin /src/build/*.AppImage 2>/dev/null || echo " (none found)" diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..192f3ee --- /dev/null +++ b/flake.nix @@ -0,0 +1,574 @@ +{ + description = "Fluorine Manager — Mod Organizer 2 for Linux"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = { self, nixpkgs }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { inherit system; }; + + version = "0.1.0"; + pythonVersion = "3.13"; + portablePythonVersion = "3.13.9"; + + # ── libloot (not in nixpkgs — build from source) ── + libloot = pkgs.stdenv.mkDerivation { + pname = "libloot"; + version = "0.29.0"; + + src = pkgs.fetchFromGitHub { + owner = "loot"; + repo = "libloot"; + rev = "master"; + hash = ""; # nix will tell you the correct hash on first build + fetchSubmodules = true; + }; + + nativeBuildInputs = with pkgs; [ cmake ninja ]; + + cmakeDir = "../cpp"; + cmakeFlags = [ + "-DBUILD_SHARED_LIBS=ON" + "-DLIBLOOT_INSTALL_DOCS=OFF" + "-DBUILD_TESTING=OFF" + ]; + + postInstall = '' + mkdir -p $out/lib/pkgconfig + cat > $out/lib/pkgconfig/libloot.pc </dev/null || true + + # ── Boost (from nix store) ── + for boost_lib in ${pkgs.boost}/lib/libboost_program_options.so* \ + ${pkgs.boost}/lib/libboost_thread.so*; do + [ -f "$boost_lib" ] && cp -Lf "$boost_lib" $out/opt/fluorine-manager/lib/ + done + + # ── Qt6 plugins ── + QT6_PLUGIN_DIR="${pkgs.qt6.qtbase}/lib/qt-6/plugins" + for plugin_type in platforms tls networkinformation styles \ + imageformats iconengines xcbglintegrations \ + egldeviceintegrations; do + [ -d "$QT6_PLUGIN_DIR/$plugin_type" ] && \ + cp -a "$QT6_PLUGIN_DIR/$plugin_type" $out/opt/fluorine-manager/qt6plugins/ + done + # Wayland plugins. + WAYLAND_PLUGIN_DIR="${pkgs.qt6.qtwayland}/lib/qt-6/plugins" + for plugin_type in wayland-shell-integration \ + wayland-decoration-client wayland-graphics-integration-client; do + [ -d "$WAYLAND_PLUGIN_DIR/$plugin_type" ] && \ + cp -a "$WAYLAND_PLUGIN_DIR/$plugin_type" $out/opt/fluorine-manager/qt6plugins/ + done + # SVG plugin. + SVG_PLUGIN_DIR="${pkgs.qt6.qtsvg}/lib/qt-6/plugins" + [ -d "$SVG_PLUGIN_DIR/imageformats" ] && \ + cp -a "$SVG_PLUGIN_DIR/imageformats"/. $out/opt/fluorine-manager/qt6plugins/imageformats/ + + # ── Bundle shared library dependencies ── + SKIP_PATTERN="linux-vdso|ld-linux|libc\.so|libm\.so|libdl\.so|librt\.so|libpthread|libresolv|libnss|libgcc_s|libstdc\+\+" + SKIP_PATTERN="$SKIP_PATTERN|libGL\.so|libEGL|libGLX|libGLdispatch|libdrm|libvulkan|libX11|libxcb|libwayland|libxkbcommon" + SKIP_PATTERN="$SKIP_PATTERN|libpython" + + collect_and_bundle() { + ldd "$1" 2>/dev/null | grep "=>" | awk '{print $3}' | grep "^/" | while read -r dep; do + dep_name="$(basename "$dep")" + echo "$dep_name" | grep -qE "$SKIP_PATTERN" && continue + [ -f "$out/opt/fluorine-manager/lib/$dep_name" ] && continue + cp -Lf "$dep" "$out/opt/fluorine-manager/lib/" 2>/dev/null || true + done + } + collect_and_bundle "$out/opt/fluorine-manager/ModOrganizer-core" + find "$out/opt/fluorine-manager/plugins" -name "*.so" -exec bash -c 'ldd "$1" 2>/dev/null | grep "=>" | awk "{print \$3}" | grep "^/" | while read dep; do + dep_name="$(basename "$dep")" + echo "$dep_name" | grep -qE "'"$SKIP_PATTERN"'" && continue + [ -f "'"$out"'/opt/fluorine-manager/lib/$dep_name" ] && continue + cp -Lf "$dep" "'"$out"'/opt/fluorine-manager/lib/" 2>/dev/null || true + done' _ {} \; + find "$out/opt/fluorine-manager/lib" -name "*.so*" -exec bash -c 'ldd "$1" 2>/dev/null | grep "=>" | awk "{print \$3}" | grep "^/" | while read dep; do + dep_name="$(basename "$dep")" + echo "$dep_name" | grep -qE "'"$SKIP_PATTERN"'" && continue + [ -f "'"$out"'/opt/fluorine-manager/lib/$dep_name" ] && continue + cp -Lf "$dep" "'"$out"'/opt/fluorine-manager/lib/" 2>/dev/null || true + done' _ {} \; + [ -f "$out/opt/fluorine-manager/lootcli" ] && collect_and_bundle "$out/opt/fluorine-manager/lootcli" + rm -f "$out/opt/fluorine-manager/lib"/libpython*.so* 2>/dev/null || true + + # ── Portable Python runtime ── + PP_SRC=$(echo ${portable-python}/python-headless-*) + if [ ! -d "$PP_SRC" ]; then + PP_SRC="${portable-python}" + fi + cp -a "$PP_SRC" $out/opt/fluorine-manager/python + chmod -R u+w $out/opt/fluorine-manager/python + + # Trim portable Python. + PP_STDLIB="$out/opt/fluorine-manager/python/lib/python$PY_MM" + rm -rf "$PP_STDLIB/test" "$PP_STDLIB/idlelib" "$PP_STDLIB/tkinter" \ + "$PP_STDLIB/turtledemo" "$out/opt/fluorine-manager/python/include" \ + "$out/opt/fluorine-manager/python/share" 2>/dev/null || true + find "$out/opt/fluorine-manager/python" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + + # Patch portable Python SONAME. + PP_LIBPY="$out/opt/fluorine-manager/python/lib/libpython$PY_MM.so" + if [ -f "$PP_LIBPY" ]; then + CURRENT_SONAME="$(readelf -d "$PP_LIBPY" 2>/dev/null | grep SONAME | sed 's/.*\[//' | sed 's/\]//')" + if [ "$CURRENT_SONAME" = "libpython$PY_MM.so" ]; then + patchelf --set-soname "libpython$PY_MM.so.1.0" "$PP_LIBPY" + mv "$PP_LIBPY" "$PP_LIBPY.1.0" + ln -sf "libpython$PY_MM.so.1.0" "$PP_LIBPY" + fi + [ ! -e "$PP_LIBPY.1.0" ] && ln -sf "libpython$PY_MM.so" "$PP_LIBPY.1.0" + fi + + # Bundle PyQt6 into portable Python site-packages. + PYSITE="$out/opt/fluorine-manager/python/lib/python$PY_MM/site-packages" + mkdir -p "$PYSITE" + for search_dir in ${pkgs.python313Packages.pyqt6}/lib/python${pythonVersion}/site-packages; do + if [ -d "$search_dir/PyQt6" ]; then + cp -a "$search_dir/PyQt6" "$PYSITE/" + [ -d "$search_dir/PyQt6_sip" ] && cp -a "$search_dir/PyQt6_sip" "$PYSITE/" + [ -d "$search_dir/sip" ] && cp -a "$search_dir/sip" "$PYSITE/" + break + fi + done + + # Build-tree Python plugin payload. + [ -d src/src/python ] && cp -a src/src/python/. $out/opt/fluorine-manager/python/ + + # ── icoutils (for .exe icon extraction) ── + cp -f ${pkgs.icoutils}/bin/wrestool $out/opt/fluorine-manager/ 2>/dev/null || true + cp -f ${pkgs.icoutils}/bin/icotool $out/opt/fluorine-manager/ 2>/dev/null || true + + # ── Strip binaries ── + strip --strip-unneeded $out/opt/fluorine-manager/ModOrganizer-core 2>/dev/null || true + find $out/opt/fluorine-manager/plugins -name "*.so" -exec strip --strip-unneeded {} \; 2>/dev/null || true + find $out/opt/fluorine-manager/lib -name "*.so" -exec strip --strip-unneeded {} \; 2>/dev/null || true + [ -f "$out/opt/fluorine-manager/lootcli" ] && strip --strip-unneeded "$out/opt/fluorine-manager/lootcli" 2>/dev/null || true + + # ── Patch RPATH ── + patchelf --force-rpath --set-rpath '$ORIGIN/lib:$ORIGIN/python/lib' \ + $out/opt/fluorine-manager/ModOrganizer-core + [ -f "$out/opt/fluorine-manager/lootcli" ] && \ + patchelf --force-rpath --set-rpath '$ORIGIN/lib' $out/opt/fluorine-manager/lootcli + find $out/opt/fluorine-manager/plugins -name "*.so" \ + -exec patchelf --force-rpath --set-rpath '$ORIGIN/../lib:$ORIGIN/../python/lib' {} \; 2>/dev/null || true + find $out/opt/fluorine-manager/lib -name "*.so" \ + -exec patchelf --force-rpath --set-rpath '$ORIGIN:$ORIGIN/../python/lib' {} \; 2>/dev/null || true + + # ── Launcher script ── + cat > $out/opt/fluorine-manager/fluorine-manager <<'LAUNCH' +#!/usr/bin/env bash +set -euo pipefail +SELF="$(readlink -f "$0")" +HERE="$(cd "$(dirname "$SELF")" && pwd)" +export PATH="${HERE}:${PATH}" + +export FLUORINE_ORIG_LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}" +export FLUORINE_ORIG_PATH="${PATH}" +export FLUORINE_ORIG_XDG_DATA_DIRS="${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" +export FLUORINE_ORIG_QT_PLUGIN_PATH="${QT_PLUGIN_PATH:-}" + +FLUORINE_DATA="${HOME}/.local/share/fluorine" +PYTHON_DST="${FLUORINE_DATA}/python" +PYTHON_SRC="${HERE}/python" + +if [ -d "${PYTHON_SRC}" ]; then + CURRENT_VER="$(stat -c '%i:%Y' "${PYTHON_SRC}/bin/python3" 2>/dev/null || echo "unknown")" + MARKER="${PYTHON_DST}/.version" + if [ ! -f "${MARKER}" ] || [ "$(cat "${MARKER}" 2>/dev/null)" != "${CURRENT_VER}" ]; then + echo "Syncing Python runtime to ${PYTHON_DST}..." >&2 + rm -rf "${PYTHON_DST}" + mkdir -p "${PYTHON_DST}" + (cd "${PYTHON_SRC}" && tar --exclude-vcs -cf - .) | (cd "${PYTHON_DST}" && tar -xf -) + echo "${CURRENT_VER}" > "${MARKER}" + fi +fi + +export LD_LIBRARY_PATH="${HERE}/lib:${PYTHON_DST}/lib:${LD_LIBRARY_PATH:-}" +export MO2_BASE_DIR="${HERE}" +export MO2_PLUGINS_DIR="${HERE}/plugins" +export MO2_DLLS_DIR="${HERE}/dlls" +export MO2_PYTHON_DIR="${PYTHON_DST}" +MO2_PYTHONHOME="${PYTHON_DST}" +unset PYTHONPATH PYTHONNOUSERSITE PYTHONHOME + +export QT_PLUGIN_PATH="${HERE}/qt6plugins" + +cd "${HERE}" +exec env PYTHONHOME="${MO2_PYTHONHOME}" "${HERE}/ModOrganizer-core" "$@" +LAUNCH + chmod +x $out/opt/fluorine-manager/fluorine-manager + + # ── Desktop integration files ── + cp -f ../data/com.fluorine.manager.desktop $out/opt/fluorine-manager/ 2>/dev/null || true + cp -f ../data/com.fluorine.manager.png $out/opt/fluorine-manager/ 2>/dev/null || true + cp -f ../data/com.fluorine.manager.metainfo.xml $out/opt/fluorine-manager/ 2>/dev/null || true + + mkdir -p $out/share/icons/hicolor/256x256/apps + mkdir -p $out/share/metainfo + cp $out/opt/fluorine-manager/com.fluorine.manager.png \ + $out/share/icons/hicolor/256x256/apps/ 2>/dev/null || true + cp $out/opt/fluorine-manager/com.fluorine.manager.metainfo.xml \ + $out/share/metainfo/ 2>/dev/null || true + + runHook postInstall + ''; + }; + + desktopItem = pkgs.makeDesktopItem { + name = "com.fluorine.manager"; + exec = "fluorine-manager"; + icon = "com.fluorine.manager"; + desktopName = "Fluorine Manager"; + genericName = "Mod Manager"; + comment = "Mod Organizer 2 for Linux — manage your game mods"; + categories = [ "Game" "Utility" ]; + mimeTypes = [ "x-scheme-handler/nxm" ]; + startupWMClass = "ModOrganizer"; + keywords = [ "mod" "organizer" "modding" "skyrim" "fallout" ]; + }; + + # ── FHS wrapper ── + fluorine-manager = pkgs.buildFHSEnv { + pname = "fluorine-manager"; + inherit version; + + runScript = "${fluorine-unwrapped}/opt/fluorine-manager/fluorine-manager"; + + targetPkgs = pkgs: with pkgs; [ + glibc + stdenv.cc.cc.lib + + libGL + libglvnd + libdrm + vulkan-loader + mesa + + xorg.libX11 + xorg.libxcb + xorg.libXext + xorg.libXrandr + xorg.libXcursor + xorg.libXi + xorg.libXfixes + xorg.libXrender + xorg.libXcomposite + xorg.libXdamage + xorg.libXtst + xorg.libXScrnSaver + xorg.libXinerama + libxkbcommon + + wayland + + fuse3 + + fontconfig + freetype + + nss + nspr + + zlib + dbus + glib + udev + libcap + polkit + ]; + + multiPkgs = pkgs: with pkgs; [ + glibc + libGL + libglvnd + vulkan-loader + libdrm + udev + alsa-lib + libpulseaudio + dbus + freetype + fontconfig + zlib + glib + xorg.libX11 + xorg.libxcb + xorg.libXext + xorg.libXrandr + xorg.libXcursor + xorg.libXi + xorg.libXfixes + xorg.libXrender + xorg.libXcomposite + xorg.libXdamage + xorg.libXtst + xorg.libXinerama + wayland + libxkbcommon + gnutls + SDL2 + ncurses + cups + ]; + + multiArch = true; + + unshareIpc = false; + unsharePid = false; + + extraInstallCommands = '' + mkdir -p $out/share + ln -s ${desktopItem}/share/applications $out/share/applications + ln -s ${fluorine-unwrapped}/share/icons $out/share/icons + ln -s ${fluorine-unwrapped}/share/metainfo $out/share/metainfo + ''; + + meta = with pkgs.lib; { + description = "Mod Organizer 2 for Linux"; + homepage = "https://github.com/SulfurNitride/Fluorine-Manager"; + license = licenses.gpl3; + platforms = [ "x86_64-linux" ]; + mainProgram = "fluorine-manager"; + }; + }; + + in { + packages.${system} = { + default = fluorine-manager; + inherit fluorine-manager fluorine-unwrapped libloot bsa-ffi nak-ffi; + }; + + apps.${system}.default = { + type = "app"; + program = "${fluorine-manager}/bin/fluorine-manager"; + }; + + # NixOS module for system-wide installation. + nixosModules.default = { config, lib, ... }: { + options.programs.fluorine-manager = { + enable = lib.mkEnableOption "Fluorine Manager (Mod Organizer 2 for Linux)"; + + fusePassthrough = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Enable FUSE passthrough (kernel-direct I/O for mod files). + Grants CAP_SYS_ADMIN to the Fluorine binary via a + security wrapper. Requires kernel 6.9+. + ''; + }; + }; + + config = lib.mkIf config.programs.fluorine-manager.enable { + environment.systemPackages = [ self.packages.${system}.default ]; + + programs.fuse.userAllowOther = true; + + security.wrappers = lib.mkIf config.programs.fluorine-manager.fusePassthrough { + fluorine-manager = { + source = "${self.packages.${system}.default}/bin/fluorine-manager"; + capabilities = "cap_sys_admin+ep"; + owner = "root"; + group = "root"; + }; + }; + }; + }; + }; +} diff --git a/libs/nak/src/runtime_wrap.rs b/libs/nak/src/runtime_wrap.rs index 69f693e..37980d3 100644 --- a/libs/nak/src/runtime_wrap.rs +++ b/libs/nak/src/runtime_wrap.rs @@ -1,38 +1,11 @@ -use std::env; use std::ffi::OsStr; use std::process::Command; -fn env_flag(name: &str) -> bool { - matches!( - env::var(name) - .unwrap_or_default() - .trim() - .to_ascii_lowercase() - .as_str(), - "1" | "true" | "yes" | "on" - ) -} - -pub fn use_steam_run() -> bool { - env_flag("NAK_USE_STEAM_RUN") -} - /// Build a command to run `exe` with the given environment variables. -/// -/// In steam-run mode, the command is wrapped with `steam-run`. -/// Otherwise the command runs directly. pub fn build_command>( exe: impl AsRef, envs: &[(&str, S)], ) -> Command { - if use_steam_run() { - let mut cmd = Command::new("steam-run"); - cmd.arg(exe.as_ref()); - for (key, value) in envs { - cmd.env(key, value.as_ref()); - } - return cmd; - } let mut cmd = Command::new(exe); for (key, value) in envs { cmd.env(key, value.as_ref()); diff --git a/src/src/CMakeLists.txt b/src/src/CMakeLists.txt index e69a814..d7a7642 100644 --- a/src/src/CMakeLists.txt +++ b/src/src/CMakeLists.txt @@ -115,7 +115,7 @@ target_link_libraries(organizer PRIVATE target_compile_definitions(organizer PRIVATE SPDLOG_USE_STD_FORMAT - FUSE_USE_VERSION=35 + FUSE_USE_VERSION=312 $<$:MO2_WEBENGINE>) if(NOT WIN32) diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp index c749d69..b146359 100644 --- a/src/src/fuseconnector.cpp +++ b/src/src/fuseconnector.cpp @@ -160,9 +160,10 @@ void setupFuseOps(struct fuse_lowlevel_ops* ops) ops->init = mo2_init; ops->lookup = mo2_lookup; ops->getattr = mo2_getattr; - ops->opendir = mo2_opendir; - ops->readdir = mo2_readdir; - ops->open = mo2_open; + ops->opendir = mo2_opendir; + ops->readdir = mo2_readdir; + ops->readdirplus = mo2_readdirplus; + ops->open = mo2_open; ops->read = mo2_read; ops->write = mo2_write; ops->create = mo2_create; @@ -291,14 +292,15 @@ bool FuseConnector::mount( std::fprintf(stderr, "[VFS] WARNING: tracking file path is empty!\n"); } - m_context = std::make_shared(); - m_context->tree = tree; - m_context->inodes = std::make_unique(); - m_context->overwrite = std::make_unique(m_stagingDir, m_overwriteDir); - m_context->tracked_writes = m_trackedWrites; - m_context->backing_dir_fd = m_backingFd; - m_context->uid = ::getuid(); - m_context->gid = ::getgid(); + m_context = std::make_shared(); + m_context->tree = tree; + m_context->inodes = std::make_unique(); + m_context->overwrite = std::make_unique(m_stagingDir, m_overwriteDir); + m_context->tracked_writes = m_trackedWrites; + m_context->backing_dir_fd = m_backingFd; + m_context->uid = ::getuid(); + m_context->gid = ::getgid(); + m_context->passthrough_requested = m_passthroughEnabled; // NOTE: Do NOT include mount_point here — low-level API passes it // separately to fuse_session_mount(). Including it here causes @@ -335,7 +337,13 @@ bool FuseConnector::mount( } m_fuseThread = std::thread([this]() { - fuse_session_loop_mt(m_session, nullptr); + // Enable clone_fd: each worker thread gets its own /dev/fuse fd, + // eliminating contention on a single fd lock under heavy parallel I/O. + struct fuse_loop_config* cfg = fuse_loop_cfg_create(); + fuse_loop_cfg_set_clone_fd(cfg, 1); + fuse_loop_cfg_set_max_threads(cfg, 16); + fuse_session_loop_mt(m_session, cfg); + fuse_loop_cfg_destroy(cfg); }); m_mounted = true; @@ -438,6 +446,13 @@ void FuseConnector::setTrackingFilePath(const std::string& path) std::fprintf(stderr, "[VFS] setTrackingFilePath: '%s'\n", path.c_str()); } +void FuseConnector::setPassthroughEnabled(bool enabled) +{ + m_passthroughEnabled = enabled; + std::fprintf(stderr, "[VFS] passthrough %s by user\n", + enabled ? "enabled" : "disabled"); +} + std::shared_ptr FuseConnector::trackedWrites() const { return m_trackedWrites; diff --git a/src/src/fuseconnector.h b/src/src/fuseconnector.h index a13f014..6d089bb 100644 --- a/src/src/fuseconnector.h +++ b/src/src/fuseconnector.h @@ -42,6 +42,7 @@ public: void setPluginLoadOrder(const std::vector& load_order); void setTrackingFilePath(const std::string& path); + void setPassthroughEnabled(bool enabled); std::shared_ptr trackedWrites() const; void unmount(); @@ -96,6 +97,7 @@ private: std::thread m_fuseThread; bool m_mounted = false; bool m_discardStaging = false; + bool m_passthroughEnabled = false; }; #endif diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp index c0f1bf9..3896039 100644 --- a/src/src/organizercore.cpp +++ b/src/src/organizercore.cpp @@ -661,6 +661,13 @@ void OrganizerCore::prepareVFS() owPath.toStdString().c_str(), trackPath.toStdString().c_str()); m_USVFS.setTrackingFilePath(trackPath.toStdString()); } + + // FUSE passthrough: read the per-instance setting and pass it to the VFS. + { + const bool passthrough = + QSettings().value("fluorine/fuse_passthrough", false).toBool(); + m_USVFS.setPassthroughEnabled(passthrough); + } #endif m_USVFS.updateMapping(fileMapping(m_CurrentProfile->name(), QString())); } @@ -2100,20 +2107,29 @@ void OrganizerCore::syncOverwrite() #ifndef _WIN32 // Track files that were moved out of overwrite to mods. // Files that existed before but are gone now were synced to a mod. + // Use profile priority order (highest-priority mod wins) so writes + // go to the correct mod when multiple mods contain the same path. const QString modsDir = QDir::fromNativeSeparators(m_Settings.paths().mods()); + + // Mod names in ascending priority order (last = highest priority). + const QStringList modsByPriority = + m_ModList.allModsByProfilePriority(m_CurrentProfile.get()); + for (const auto& relPath : beforeFiles) { const QString owFile = modInfo->absolutePath() + "/" + relPath; if (!QFile::exists(owFile)) { - // Find which mod folder it ended up in - QDirIterator modDirIter(modsDir, QDir::Dirs | QDir::NoDotAndDotDot); - while (modDirIter.hasNext()) { - modDirIter.next(); - const QString candidate = modDirIter.filePath() + "/" + relPath; - if (QFile::exists(candidate)) { - trackOverwriteMove(relPath, modDirIter.filePath()); - break; + // Find highest-priority mod that has this file. + QString bestModPath; + for (const auto& modName : modsByPriority) { + const QString modPath = modsDir + "/" + modName; + if (QFile::exists(modPath + "/" + relPath)) { + bestModPath = modPath; + // Don't break — keep going to find the highest-priority match. } } + if (!bestModPath.isEmpty()) { + trackOverwriteMove(relPath, bestModPath); + } } } #endif diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp index 4339920..e2895e2 100644 --- a/src/src/protonlauncher.cpp +++ b/src/src/protonlauncher.cpp @@ -200,19 +200,6 @@ void wrapProgram(const QStringList& wrapperCommands, const QString& program, wrappedArguments.append(arguments); } -void maybeWrapWithSteamRun(bool useSteamRun, QString& program, QStringList& arguments) -{ - if (!useSteamRun) { - return; - } - - QStringList wrappedArgs; - wrappedArgs.append(program); - wrappedArgs.append(arguments); - program = QStringLiteral("steam-run"); - arguments = wrappedArgs; -} - bool isValidEnvKey(const QString& key) { if (key.isEmpty()) { @@ -252,7 +239,7 @@ bool parseEnvAssignment(const QString& token, QString& keyOut, QString& valueOut } // namespace ProtonLauncher::ProtonLauncher() - : m_steamAppId(0), m_useSteamRun(false), m_useSteamDrm(true) + : m_steamAppId(0), m_useSteamDrm(true) {} ProtonLauncher& ProtonLauncher::setBinary(const QString& path) @@ -313,12 +300,6 @@ ProtonLauncher& ProtonLauncher::setWrapper(const QString& wrapperCmd) return *this; } -ProtonLauncher& ProtonLauncher::setUseSteamRun(bool useSteamRun) -{ - m_useSteamRun = useSteamRun; - return *this; -} - ProtonLauncher& ProtonLauncher::setSteamDrm(bool useSteamDrm) { m_useSteamDrm = useSteamDrm; @@ -375,7 +356,6 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const QString program; QStringList arguments; wrapProgram(m_wrapperCommands, protonScript, protonArgs, program, arguments); - maybeWrapWithSteamRun(m_useSteamRun, program, arguments); QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); env.remove("PYTHONHOME"); @@ -448,7 +428,6 @@ bool ProtonLauncher::launchDirect(qint64& pid) const QString program; QStringList arguments; wrapProgram(m_wrapperCommands, m_binary, m_arguments, program, arguments); - maybeWrapWithSteamRun(m_useSteamRun, program, arguments); QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); env.remove("PYTHONHOME"); diff --git a/src/src/protonlauncher.h b/src/src/protonlauncher.h index 6b3ee42..363d92b 100644 --- a/src/src/protonlauncher.h +++ b/src/src/protonlauncher.h @@ -20,7 +20,6 @@ public: ProtonLauncher& setPrefix(const QString& path); ProtonLauncher& setSteamAppId(uint32_t id); ProtonLauncher& setWrapper(const QString& wrapperCmd); - ProtonLauncher& setUseSteamRun(bool useSteamRun); ProtonLauncher& setSteamDrm(bool useSteamDrm); ProtonLauncher& setStoreVariant(const QString& variant); ProtonLauncher& addEnvVar(const QString& key, const QString& value); @@ -40,7 +39,6 @@ private: QString m_prefixPath; uint32_t m_steamAppId; QStringList m_wrapperCommands; - bool m_useSteamRun; bool m_useSteamDrm; QString m_storeVariant; // "GOG", "Epic", or empty for Steam QMap m_envVars; diff --git a/src/src/settingsdialog.ui b/src/src/settingsdialog.ui index 6588a71..7704b56 100644 --- a/src/src/settingsdialog.ui +++ b/src/src/settingsdialog.ui @@ -1865,16 +1865,6 @@ If you disable this feature, MO will only display official DLCs this way. Please Launch Settings - - - - Wrap launches with steam-run - - - Run launch and prefix setup commands through steam-run. Useful on NixOS and other immutable setups. - - - @@ -1892,6 +1882,40 @@ If you disable this feature, MO will only display official DLCs this way. Please + + + + VFS Performance + + + + + + Enable FUSE Passthrough (near-native I/O) + + + Let the kernel serve game file reads directly, bypassing userspace. Dramatically improves I/O performance for loading textures, meshes, and archives. Requires a one-time privilege grant (sudo prompt). Needs kernel 6.9+ and libfuse 3.16+. + + + + + + + + + + true + + + + 9 + + + + + + + diff --git a/src/src/settingsdialogproton.cpp b/src/src/settingsdialogproton.cpp index 0199744..36838c4 100644 --- a/src/src/settingsdialogproton.cpp +++ b/src/src/settingsdialogproton.cpp @@ -9,7 +9,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -40,12 +42,56 @@ ProtonSettingsTab::ProtonSettingsTab(Settings& s, SettingsDialog& d) ui->protonProgressBar->setValue(0); ui->protonProgressBar->setVisible(false); - ui->steamRunCheckBox->setChecked( - QSettings().value("fluorine/use_steam_run", false).toBool()); - ui->launchWrapperEdit->setPlaceholderText("mangohud --dlsym"); ui->launchWrapperEdit->setText(QSettings().value("fluorine/launch_wrapper").toString()); + // FUSE passthrough toggle — requires CAP_SYS_ADMIN on the binary. + ui->fusePassthroughCheckBox->setChecked( + QSettings().value("fluorine/fuse_passthrough", false).toBool()); + ui->passthroughStatusLabel->setText(QString()); + + QObject::connect(ui->fusePassthroughCheckBox, &QCheckBox::toggled, this, + [this](bool checked) { + if (!checked) { + // Disabling doesn't need sudo — just save the setting. + QSettings().setValue("fluorine/fuse_passthrough", false); + ui->passthroughStatusLabel->setText(tr("Passthrough disabled. Takes effect on next game launch.")); + return; + } + + // Enabling requires setting CAP_SYS_ADMIN on the binary. + // Find the actual binary path (ModOrganizer-core). + const QString binary = QCoreApplication::applicationFilePath(); + if (binary.isEmpty()) { + ui->fusePassthroughCheckBox->setChecked(false); + ui->passthroughStatusLabel->setText(tr("Could not determine binary path.")); + return; + } + + ui->passthroughStatusLabel->setText( + tr("Granting FUSE passthrough capability... (sudo prompt)")); + + // Use pkexec (graphical sudo) to set the file capability. + // This is a one-time operation — the capability persists across runs. + QProcess proc; + proc.setProgram("pkexec"); + proc.setArguments({"setcap", "cap_sys_admin+ep", binary}); + proc.start(); + proc.waitForFinished(60000); // 60s timeout for user to auth + + if (proc.exitCode() == 0) { + QSettings().setValue("fluorine/fuse_passthrough", true); + ui->passthroughStatusLabel->setText( + tr("Passthrough enabled. Takes effect on next game launch.")); + } else { + ui->fusePassthroughCheckBox->setChecked(false); + const QString err = QString::fromUtf8(proc.readAllStandardError()).trimmed(); + ui->passthroughStatusLabel->setText( + tr("Failed to set capability: %1").arg( + err.isEmpty() ? tr("authorization denied or pkexec not found") : err)); + } + }); + populateProtons(); QObject::connect(ui->protonVersionCombo, &QComboBox::currentIndexChanged, this, @@ -114,8 +160,6 @@ ProtonSettingsTab::ProtonSettingsTab(Settings& s, SettingsDialog& d) void ProtonSettingsTab::update() { - QSettings().setValue("fluorine/use_steam_run", - ui->steamRunCheckBox->isChecked()); QSettings().setValue("fluorine/launch_wrapper", ui->launchWrapperEdit->text()); } @@ -237,8 +281,7 @@ void ProtonSettingsTab::onCreatePrefix() ui->nakInstallLog->setVisible(true); ui->toggleInstallLog->setChecked(true); - startInstallTask(0, pfxPath, protonName, protonPath, - ui->steamRunCheckBox->isChecked()); + startInstallTask(0, pfxPath, protonName, protonPath); } void ProtonSettingsTab::onDeletePrefix() @@ -287,8 +330,7 @@ void ProtonSettingsTab::onRecreatePrefix() ui->toggleInstallLog->setChecked(true); startInstallTask(cfg->app_id, cfg->prefix_path, cfg->proton_name, - cfg->proton_path, - ui->steamRunCheckBox->isChecked()); + cfg->proton_path); } void ProtonSettingsTab::onOpenPrefixFolder() @@ -579,8 +621,7 @@ void ProtonSettingsTab::showGameRegistryDialog() void ProtonSettingsTab::startInstallTask(uint32_t appId, const QString& prefixPath, const QString& protonName, - const QString& protonPath, - bool useSteamRun) + const QString& protonPath) { m_pendingAppId = appId; m_pendingPrefixPath = prefixPath; @@ -595,14 +636,11 @@ void ProtonSettingsTab::startInstallTask(uint32_t appId, const QString& prefixPa appId, prefixPath, protonName, - protonPath, - useSteamRun]() -> InstallResult { + protonPath]() -> InstallResult { const QByteArray prefixPathUtf8 = prefixPath.toUtf8(); const QByteArray protonNameUtf8 = protonName.toUtf8(); const QByteArray protonPathUtf8 = protonPath.toUtf8(); - qputenv("NAK_USE_STEAM_RUN", useSteamRun ? "1" : "0"); - // Set WINEPREFIX so NAK (and its child processes like winetricks) always // target the correct prefix during Proton init. qputenv("WINEPREFIX", prefixPathUtf8); @@ -629,7 +667,6 @@ void ProtonSettingsTab::startInstallTask(uint32_t appId, const QString& prefixPa } const auto restoreNakEnv = qScopeGuard([protonWineUtf8] { - qunsetenv("NAK_USE_STEAM_RUN"); qunsetenv("WINEPREFIX"); if (!protonWineUtf8.isEmpty()) { qunsetenv("WINE"); diff --git a/src/src/settingsdialogproton.h b/src/src/settingsdialogproton.h index 58c43f2..a1221c7 100644 --- a/src/src/settingsdialogproton.h +++ b/src/src/settingsdialogproton.h @@ -39,8 +39,7 @@ private: QString findProtonWine(const QString& protonPath); void startInstallTask(uint32_t appId, const QString& prefixPath, - const QString& protonName, const QString& protonPath, - bool useSteamRun); + const QString& protonName, const QString& protonPath); void enqueueStatus(const QString& message); void enqueueProgress(float progress); diff --git a/src/src/spawn.cpp b/src/src/spawn.cpp index 28010d0..4c91fbe 100644 --- a/src/src/spawn.cpp +++ b/src/src/spawn.cpp @@ -656,8 +656,6 @@ int spawn(const SpawnParameters &sp, pid_t &processId) { .setArguments(argList) .setWorkingDir(cwd) .setSteamAppId(parseSteamAppId(sp.steamAppID)) - .setUseSteamRun( - QSettings().value("fluorine/use_steam_run", false).toBool()) .setSteamDrm(useSteamDrm) .setStoreVariant(storeVariant); diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp index 962a458..42e90c0 100644 --- a/src/src/vfs/mo2filesystem.cpp +++ b/src/src/vfs/mo2filesystem.cpp @@ -18,9 +18,12 @@ namespace { namespace fs = std::filesystem; -constexpr double TTL_SECONDS = 30.0; -constexpr double NEGATIVE_TTL_SECONDS = 5.0; -constexpr double ATTR_CACHE_SECONDS = 30.0; +// Mod files are immutable during a game session, so cache aggressively. +// The VFS tree is built once at mount time and only mutated by our own +// create/rename/unlink handlers (which invalidate affected entries). +constexpr double TTL_SECONDS = 86400.0; // 24 hours +constexpr double NEGATIVE_TTL_SECONDS = 3600.0; // 1 hour — Wine probes many non-existent files +constexpr double ATTR_CACHE_SECONDS = 86400.0; void fillStatForDir(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid); void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid, @@ -513,11 +516,80 @@ void mo2_init(void* userdata, struct fuse_conn_info* conn) { auto* ctx = static_cast(userdata); - // Keep kernel page cache valid across open/close as long as mtime/size are - // unchanged. Mod files are immutable during a game session, so this avoids - // re-reading file data from userspace on every open(). - if (conn->capable & FUSE_CAP_AUTO_INVAL_DATA) { - conn->want |= FUSE_CAP_AUTO_INVAL_DATA; + // ── 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 + // reduction. Our VFS tree is immutable during a session — we handle + // invalidation ourselves via fuse_lowlevel_notify_inval_inode() when + // files are created/renamed/deleted through our own handlers. + conn->want &= ~FUSE_CAP_AUTO_INVAL_DATA; + + // Let us control page cache invalidation explicitly. + if (conn->capable & FUSE_CAP_EXPLICIT_INVAL_DATA) { + conn->want |= FUSE_CAP_EXPLICIT_INVAL_DATA; + } + + // Force readdirplus always (unset adaptive mode). Our stat info is + // free (in-memory tree), so always return full entries to pre-populate + // the kernel dentry + attr caches on every directory listing. + if (conn->capable & FUSE_CAP_READDIRPLUS) { + conn->want |= FUSE_CAP_READDIRPLUS; + conn->want &= ~FUSE_CAP_READDIRPLUS_AUTO; + } + + // NOTE: FUSE_CAP_WRITEBACK_CACHE intentionally NOT enabled. + // It causes extra getattr calls for cache coherency, which hurts + // our read-heavy VFS more than the write buffering helps. + + // Cache symlink targets in the kernel page cache. + if (conn->capable & FUSE_CAP_CACHE_SYMLINKS) { + conn->want |= FUSE_CAP_CACHE_SYMLINKS; + } + + // Allow concurrent lookup()/readdir() on the same directory. + if (conn->capable & FUSE_CAP_PARALLEL_DIROPS) { + conn->want |= FUSE_CAP_PARALLEL_DIROPS; + } + + // Splice: reduce kernel↔userspace data copies for reads and writes. + if (conn->capable & FUSE_CAP_SPLICE_WRITE) { + conn->want |= FUSE_CAP_SPLICE_WRITE; + } + if (conn->capable & FUSE_CAP_SPLICE_MOVE) { + conn->want |= FUSE_CAP_SPLICE_MOVE; + } + if (conn->capable & FUSE_CAP_SPLICE_READ) { + conn->want |= FUSE_CAP_SPLICE_READ; + } + + // More async readahead/writeback slots (default is 12, far too low). + conn->max_background = 128; + conn->congestion_threshold = 96; + + // FUSE passthrough: if requested by the user and supported by the kernel, + // enable kernel-level passthrough for read-only file opens. This lets the + // kernel serve reads directly from the backing file without round-tripping + // through userspace — near-native I/O performance for game file reads. + // Requires: kernel 6.9+, libfuse 3.16+, CAP_SYS_ADMIN on the binary. + ctx->passthrough_active = false; + if constexpr (FUSE_CAP_PASSTHROUGH != 0) { + if (ctx->passthrough_requested && + (conn->capable & FUSE_CAP_PASSTHROUGH)) { + conn->want |= FUSE_CAP_PASSTHROUGH; + ctx->passthrough_active = true; + std::fprintf(stderr, "[VFS] FUSE passthrough enabled (kernel supports it)\n"); + } else if (ctx->passthrough_requested) { + std::fprintf(stderr, + "[VFS] FUSE passthrough requested but NOT supported by kernel " + "(capable=0x%x). Falling back to userspace I/O.\n", + conn->capable); + } + } else { + if (ctx->passthrough_requested) { + std::fprintf(stderr, + "[VFS] FUSE passthrough requested but NOT available at compile time " + "(libfuse too old). Falling back to userspace I/O.\n"); + } } // Increase max read/write buffer to 1MB (kernel 4.20+ supports this). @@ -531,9 +603,13 @@ void mo2_init(void* userdata, struct fuse_conn_info* conn) } std::fprintf(stderr, - "[VFS] init: auto_inval=%d max_readahead=%u max_write=%u\n", + "[VFS] init: auto_inval=%d explicit_inval=%d readdirplus=%d " + "passthrough=%d max_bg=%u max_readahead=%u\n", (conn->want & FUSE_CAP_AUTO_INVAL_DATA) ? 1 : 0, - conn->max_readahead, conn->max_write); + (conn->want & FUSE_CAP_EXPLICIT_INVAL_DATA) ? 1 : 0, + (conn->want & FUSE_CAP_READDIRPLUS) ? 1 : 0, + ctx->passthrough_active ? 1 : 0, + conn->max_background, conn->max_readahead); } void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name) @@ -685,7 +761,8 @@ void mo2_opendir(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) } fi->fh = dh; - fi->cache_readdir = 1; + fi->keep_cache = 1; // Don't invalidate cached readdir on reopen + fi->cache_readdir = 1; // Let kernel cache directory entries fuse_reply_open(req, fi); } @@ -964,6 +1041,32 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) fi->fh = fh; fi->keep_cache = 1; + + // FUSE passthrough: for read-only opens, let the kernel serve reads directly + // from the backing file. This bypasses userspace entirely — the kernel handles + // read() syscalls by reading the real file, giving near-native I/O performance. + // Only eligible for non-writable, non-COW files (pure reads). + if constexpr (FUSE_CAP_PASSTHROUGH != 0) { + if (ctx->passthrough_active && !writable && !cowPending && fd < 0) { + // Open the real file so the kernel can passthrough reads from it. + int ptFd = -1; + if (isBacking && ctx->backing_dir_fd >= 0) { + ptFd = openat(ctx->backing_dir_fd, realPath.c_str(), O_RDONLY); + } else { + ptFd = open(realPath.c_str(), O_RDONLY); + } + if (ptFd >= 0) { + fi->backing_id = static_cast(ptFd); + // Store the fd so we can close it on release + std::scoped_lock lock(ctx->open_files_mutex); + auto it = ctx->open_files.find(fh); + if (it != ctx->open_files.end()) { + it->second.fd = ptFd; + } + } + } + } + fuse_reply_open(req, fi); } diff --git a/src/src/vfs/mo2filesystem.h b/src/src/vfs/mo2filesystem.h index 2c4b257..2883a18 100644 --- a/src/src/vfs/mo2filesystem.h +++ b/src/src/vfs/mo2filesystem.h @@ -16,6 +16,13 @@ #include #include +// FUSE passthrough support (kernel 6.9+, libfuse 3.16+). +// When available, the kernel serves reads directly from the backing file +// without round-tripping through userspace — near-native I/O performance. +#ifndef FUSE_CAP_PASSTHROUGH +#define FUSE_CAP_PASSTHROUGH 0 +#endif + struct Mo2FsContext { std::shared_ptr tree; @@ -90,6 +97,11 @@ struct Mo2FsContext uid_t uid = 0; gid_t gid = 0; + + // FUSE passthrough: when true AND kernel supports it, read-only file opens + // use kernel-level passthrough (zero-copy I/O bypassing userspace). + bool passthrough_requested = false; + bool passthrough_active = false; // set in mo2_init after capability check }; void mo2_init(void* userdata, struct fuse_conn_info* conn); diff --git a/src/src/vfs/trackedwrites.cpp b/src/src/vfs/trackedwrites.cpp index 66e83e5..4392db3 100644 --- a/src/src/vfs/trackedwrites.cpp +++ b/src/src/vfs/trackedwrites.cpp @@ -272,7 +272,8 @@ void TrackedWrites::detectManualMoves( if (missing.empty()) return; - // For each missing file, check if it now exists in any mod folder + // For each missing file, check if it now exists in any mod folder. + // Use the LAST match (highest priority — mods are ordered low→high). int detected = 0; for (const auto& relPath : missing) { const std::string key = toLower(relPath); @@ -280,16 +281,23 @@ void TrackedWrites::detectManualMoves( if (m_tracked.count(key)) continue; + std::string matchedMod; + std::string matchedPath; for (const auto& [modName, modPath] : mods) { std::error_code ec; if (fs::exists(fs::path(modPath) / relPath, ec)) { - m_tracked[key] = modPath; - ++detected; - std::fprintf(stderr, "[VFS] detected manual move: %s -> %s\n", - relPath.c_str(), modName.c_str()); - break; + matchedMod = modName; + matchedPath = modPath; + // Don't break — keep going to find the highest-priority match. } } + + if (!matchedPath.empty()) { + m_tracked[key] = matchedPath; + ++detected; + std::fprintf(stderr, "[VFS] detected manual move: %s -> %s\n", + relPath.c_str(), matchedMod.c_str()); + } } if (detected > 0) -- cgit v1.3.1