aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-03-22 14:40:24 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-03-22 14:40:24 -0500
commit613a0e702403d5f8122861bb76a6ba2e184efe97 (patch)
tree5d31450749ba01cff79677db9bf466f41c39dd4b /src
parent5d58c14ee0bde26e709d6bd2256238a4d0567bc1 (diff)
Multi-dir plugin loading, VFS Root Builder, Wine registry check, column visibility fix
Plugin loading: - Remove symlink-based ensureBundledPluginsLinked(), replace with multi-dir search (bundled dir takes priority, instance dir adds extras only) - Auto-clean stale symlinks from instance plugins/ dirs on startup - Add mergedProxyList() for proxy plugin discovery across both dirs VFS Root Builder (replaces Python rootbuilder.py): - Native C++ Root Builder integrated into FuseConnector - Deploy mod Root/ files to game dir before FUSE mount, clear on unmount - Per-instance toggle in Instance Manager (default: on) - Root Builder path normalization in processrunner.cpp: rewrite binary and start-in paths from mods/.../Root/ to Game Root for correct Wine path resolution (fixes SKSE + EngineFixes versionlib lookup) - Expose game dir and prefix to SLR pressure-vessel via --filesystem= Wine registry check: - Read/write Wine prefix system.reg (HKLM) with timestamp-aware parsing - Auto-check game install path before launch, prompt to fix if mismatched - Updates both Software\ and Wow6432Node\ registry keys Column visibility fix: - Guard updateGroupByProxy() during QHeaderView::restoreState() to prevent sort indicator restoration from triggering setSourceModel() which resets all columns to visible Python proxy: - Skip FixGameRegKey.py, crashlogtools, rootbuilder.py (Windows-only or replaced by native implementations) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/plugins/rootbuilder.py544
-rw-r--r--src/src/fuseconnector.cpp228
-rw-r--r--src/src/fuseconnector.h12
-rw-r--r--src/src/instancemanagerdialog.cpp16
-rw-r--r--src/src/instancemanagerdialog.ui13
-rw-r--r--src/src/modlistview.cpp12
-rw-r--r--src/src/modlistview.h4
-rw-r--r--src/src/organizercore.cpp117
-rw-r--r--src/src/organizercore.h4
-rw-r--r--src/src/plugincontainer.cpp172
-rw-r--r--src/src/plugincontainer.h10
-rw-r--r--src/src/processrunner.cpp66
-rw-r--r--src/src/protonlauncher.cpp14
-rw-r--r--src/src/wineprefix.cpp171
-rw-r--r--src/src/wineprefix.h14
15 files changed, 763 insertions, 634 deletions
diff --git a/src/plugins/rootbuilder.py b/src/plugins/rootbuilder.py
deleted file mode 100644
index 342e1a0..0000000
--- a/src/plugins/rootbuilder.py
+++ /dev/null
@@ -1,544 +0,0 @@
-# -*- encoding: utf-8 -*-
-from __future__ import annotations
-
-"""
-Root Builder plugin for Mod Organizer 2 (Linux port).
-
-Deploys files from mod Root/ subdirectories to the game's root directory.
-Supports copy (with reflink/CoW) and symlink modes, with automatic
-deploy/clear on game launch/close.
-"""
-
-import json
-import os
-import shutil
-import subprocess
-
-import mobase
-from PyQt6.QtCore import qInfo, qWarning
-from PyQt6.QtGui import QIcon
-from PyQt6.QtWidgets import (
- QCheckBox,
- QComboBox,
- QDialog,
- QHBoxLayout,
- QLabel,
- QPushButton,
- QVBoxLayout,
-)
-
-# Storage lives under <instance_dir>/rootbuilder/ — NOT the game directory,
-# which Steam/Wine may make read-only during gameplay.
-_STORAGE_SUBDIR = "rootbuilder"
-_MANIFEST_NAME = "manifest.json"
-_SETTINGS_NAME = "settings.json"
-_BACKUP_SUBDIR = "backup"
-
-# Legacy names (stored in game dir by older versions)
-_LEGACY_MANIFEST = ".rootbuilder_manifest.json"
-_LEGACY_BACKUP = ".rootbuilder_backup"
-
-
-def _find_root_dir(mod_path: str) -> str | None:
- """Find a 'Root' subdirectory (case-insensitive) inside a mod."""
- try:
- for entry in os.scandir(mod_path):
- if entry.is_dir() and entry.name.lower() == "root":
- return entry.path
- except OSError:
- pass
- return None
-
-
-def _walk_files(root_dir: str):
- """Yield all file paths under root_dir recursively."""
- for dirpath, _dirnames, filenames in os.walk(root_dir):
- for name in filenames:
- yield os.path.join(dirpath, name)
-
-
-def _ensure_readable(path: str):
- """Ensure a file has owner-read permission (mod archives sometimes strip it)."""
- try:
- st = os.stat(path)
- if not (st.st_mode & 0o400):
- os.chmod(path, st.st_mode | 0o400)
- except OSError:
- pass
-
-
-def _reflink_copy(src: str, dst: str):
- """Copy with reflink (CoW) if supported, fallback to regular copy."""
- _ensure_readable(src)
- last_err = None
- try:
- subprocess.run(
- ["cp", "--reflink=auto", "-f", "--", src, dst],
- check=True,
- capture_output=True,
- )
- return
- except subprocess.CalledProcessError as e:
- last_err = f"cp failed (exit {e.returncode}): {e.stderr.decode(errors='replace').strip()}"
- except FileNotFoundError:
- last_err = "cp command not found"
- try:
- shutil.copy2(src, dst)
- return
- except OSError as e:
- last_err = f"{e.strerror} (errno {e.errno})"
- raise OSError(f"Root Builder: failed to copy {src} -> {dst}: {last_err}")
-
-
-def _ensure_writable(path: str):
- """Make a file or directory writable by the owner."""
- try:
- st = os.stat(path)
- os.chmod(path, st.st_mode | 0o200)
- except OSError:
- pass
-
-
-def _force_remove(path: str) -> bool:
- """Remove a file, fixing permissions if needed. Returns True on success."""
- try:
- os.remove(path)
- return True
- except PermissionError:
- pass
- try:
- _ensure_writable(os.path.dirname(path))
- _ensure_writable(path)
- os.remove(path)
- return True
- except OSError:
- pass
- qWarning(f"Root Builder: could not remove {path}")
- return False
-
-
-def _rmtree_onerror(_func, path, _exc_info):
- """onerror handler for shutil.rmtree that fixes permissions and retries."""
- try:
- _ensure_writable(os.path.dirname(path))
- _ensure_writable(path)
- os.remove(path)
- except OSError:
- pass
-
-
-def _cleanup_empty_dirs(base_dir: str, paths: list[str]):
- """Remove empty directories left behind after clearing deployed files."""
- dirs_to_check = set()
- for path in paths:
- parent = os.path.dirname(path)
- while parent and parent != base_dir:
- try:
- if os.path.samefile(parent, base_dir):
- break
- except OSError:
- break
- dirs_to_check.add(parent)
- parent = os.path.dirname(parent)
-
- for d in sorted(dirs_to_check, key=len, reverse=True):
- try:
- if os.path.isdir(d) and not os.listdir(d):
- os.rmdir(d)
- except OSError:
- pass
-
-
-# --- Manifest helpers (operate on storage_dir, NOT game dir) ---
-
-def _load_manifest(storage_dir: str) -> dict | None:
- path = os.path.join(storage_dir, _MANIFEST_NAME)
- if not os.path.isfile(path):
- return None
- try:
- with open(path, "r") as f:
- return json.load(f)
- except (OSError, json.JSONDecodeError):
- return None
-
-
-def _save_manifest(storage_dir: str, manifest: dict):
- os.makedirs(storage_dir, exist_ok=True)
- path = os.path.join(storage_dir, _MANIFEST_NAME)
- with open(path, "w") as f:
- json.dump(manifest, f, indent=2)
-
-
-def _remove_manifest(storage_dir: str):
- path = os.path.join(storage_dir, _MANIFEST_NAME)
- if os.path.isfile(path):
- try:
- os.remove(path)
- except OSError:
- pass
-
-
-class RootBuilderDialog(QDialog):
- """Small settings/control dialog shown from the Tools menu."""
-
- def __init__(self, settings: dict, save_fn, build_fn, clear_fn, parent=None):
- super().__init__(parent)
- self._settings = settings
- self._save_fn = save_fn
- self._build_fn = build_fn
- self._clear_fn = clear_fn
-
- self.setWindowTitle("Root Builder")
- self.resize(350, 220)
-
- layout = QVBoxLayout(self)
-
- desc = QLabel("Deploys files from mod Root/ folders to the game directory.")
- desc.setWordWrap(True)
- layout.addWidget(desc)
-
- # Enable checkbox
- self._enableCheck = QCheckBox("Auto-deploy on game launch")
- self._enableCheck.setChecked(settings.get("enabled", False) is True)
- layout.addWidget(self._enableCheck)
-
- # Mode selector
- mode_layout = QHBoxLayout()
- mode_layout.addWidget(QLabel("Deploy mode:"))
- self._modeCombo = QComboBox()
- self._modeCombo.addItems(["copy", "link"])
- self._modeCombo.setCurrentText(settings.get("mode", "copy"))
- mode_layout.addWidget(self._modeCombo)
- layout.addLayout(mode_layout)
-
- # Manual build/clear buttons
- btn_layout = QHBoxLayout()
- build_btn = QPushButton("Build Now")
- build_btn.clicked.connect(self._on_build)
- clear_btn = QPushButton("Clear Now")
- clear_btn.clicked.connect(self._on_clear)
- btn_layout.addWidget(build_btn)
- btn_layout.addWidget(clear_btn)
- layout.addLayout(btn_layout)
-
- # Status label
- self._status = QLabel("")
- layout.addWidget(self._status)
-
- # Close button
- close_btn = QPushButton("Close")
- close_btn.clicked.connect(self.accept)
- layout.addWidget(close_btn)
-
- # Save settings whenever the user changes them
- self._enableCheck.stateChanged.connect(lambda _: self._do_save())
- self._modeCombo.currentTextChanged.connect(lambda _: self._do_save())
-
- def _do_save(self):
- self._settings["enabled"] = self._enableCheck.isChecked()
- self._settings["mode"] = self._modeCombo.currentText()
- self._save_fn(self._settings)
-
- def _on_build(self):
- count = self._build_fn()
- self._status.setText(f"Deployed {count} file(s).")
-
- def _on_clear(self):
- count = self._clear_fn()
- self._status.setText(f"Cleared {count} file(s).")
-
-
-class RootBuilder(mobase.IPluginTool):
- _organizer: mobase.IOrganizer
-
- def __init__(self):
- super().__init__()
- self.__parentWidget = None
-
- # --- IPlugin ---
-
- def init(self, organizer: mobase.IOrganizer) -> bool:
- self._organizer = organizer
- self._migrate_legacy()
- self._check_third_party_rootbuilder()
- organizer.onAboutToRun(self._on_about_to_run)
- organizer.onFinishedRun(self._on_finished_run)
- return True
-
- # --- Storage paths (instance dir, always writable) ---
-
- def _storage_dir(self) -> str:
- d = os.path.join(self._organizer.basePath(), _STORAGE_SUBDIR)
- os.makedirs(d, exist_ok=True)
- return d
-
- # --- Settings (our own JSON, not pluginSetting) ---
-
- def _load_settings(self) -> dict:
- path = os.path.join(self._storage_dir(), _SETTINGS_NAME)
- try:
- with open(path, "r") as f:
- return json.load(f)
- except (OSError, json.JSONDecodeError, ValueError):
- return {"enabled": False, "mode": "copy"}
-
- def _save_settings(self, settings: dict):
- path = os.path.join(self._storage_dir(), _SETTINGS_NAME)
- with open(path, "w") as f:
- json.dump(settings, f, indent=2)
-
- def _is_enabled(self) -> bool:
- return self._load_settings().get("enabled", False) is True
-
- # --- Legacy migration ---
-
- def _migrate_legacy(self):
- """Move legacy manifest/backup from game dir to our storage dir."""
- game = self._organizer.managedGame()
- if game is None:
- return
- game_dir = game.gameDirectory().absolutePath()
- storage = self._storage_dir()
-
- old_manifest = os.path.join(game_dir, _LEGACY_MANIFEST)
- if os.path.isfile(old_manifest):
- try:
- new_path = os.path.join(storage, _MANIFEST_NAME)
- if not os.path.isfile(new_path):
- shutil.copy2(old_manifest, new_path)
- _force_remove(old_manifest)
- except OSError:
- pass
-
- old_backup = os.path.join(game_dir, _LEGACY_BACKUP)
- if os.path.isdir(old_backup):
- try:
- new_backup = os.path.join(storage, _BACKUP_SUBDIR)
- if not os.path.isdir(new_backup):
- shutil.copytree(old_backup, new_backup)
- shutil.rmtree(old_backup, onerror=_rmtree_onerror)
- except OSError:
- pass
-
- def _check_third_party_rootbuilder(self):
- """Move any third-party Root Builder plugins into DisabledPlugins/."""
- plugins_dir = os.path.dirname(os.path.abspath(__file__))
- disabled_dir = os.path.join(os.path.dirname(plugins_dir), "DisabledPlugins")
- my_file = os.path.basename(__file__)
-
- conflicts = []
- for entry in os.scandir(plugins_dir):
- if entry.is_dir() and entry.name.lower() == "rootbuilder":
- conflicts.append((entry.name, entry.path))
- elif (
- entry.is_file()
- and entry.name.lower().startswith("rootbuilder")
- and entry.name.lower().endswith(".py")
- and entry.name != my_file
- ):
- conflicts.append((entry.name, entry.path))
-
- for name, path in conflicts:
- dst = os.path.join(disabled_dir, name)
- try:
- os.makedirs(disabled_dir, exist_ok=True)
- shutil.move(path, dst)
- qInfo(
- f"Root Builder: moved incompatible third-party plugin "
- f"'{name}' to DisabledPlugins/. "
- f"It uses Windows-only USVFS and cannot work on Linux."
- )
- except OSError as e:
- qWarning(
- f"Root Builder: failed to move third-party plugin "
- f"'{name}' to DisabledPlugins/: {e}"
- )
-
- def name(self) -> str:
- return "Root Builder"
-
- def localizedName(self) -> str:
- return "Root Builder"
-
- def author(self) -> str:
- return "Fluorine Manager"
-
- def description(self) -> str:
- return (
- "Deploys mod files from Root/ subdirectories to the game's root directory. "
- "Supports copy and symlink modes with auto-deploy on launch."
- )
-
- def version(self) -> mobase.VersionInfo:
- return mobase.VersionInfo(1, 0, 0)
-
- def enabledByDefault(self) -> bool:
- return True
-
- def settings(self) -> list[mobase.PluginSetting]:
- return []
-
- # --- IPluginTool ---
-
- def displayName(self) -> str:
- return "Root Builder"
-
- def tooltip(self) -> str:
- return "Deploy mod Root/ files to the game directory"
-
- def icon(self) -> QIcon:
- return QIcon()
-
- def setParentWidget(self, widget):
- self.__parentWidget = widget
-
- def display(self):
- settings = self._load_settings()
- dialog = RootBuilderDialog(
- settings, self._save_settings, self._build, self._clear,
- self.__parentWidget,
- )
- dialog.exec()
-
- # --- Hooks ---
-
- def _on_about_to_run(self, executable: str) -> bool:
- if self._is_enabled():
- self._build()
- return True
-
- def _on_finished_run(self, executable: str, exit_code: int):
- if self._is_enabled():
- self._clear()
-
- # --- Build / Clear ---
-
- def _build(self) -> int:
- """Deploy root files from all active mods. Returns number of files deployed."""
- game_dir = self._organizer.managedGame().gameDirectory().absolutePath()
- storage = self._storage_dir()
- mod_list = self._organizer.modList()
- mods = mod_list.allModsByProfilePriority()
- mode = self._load_settings().get("mode", "copy")
-
- # Clear any previous deployment first
- if _load_manifest(storage) is not None:
- self._clear()
-
- manifest = {"deployed": [], "backups": {}}
- backup_dir = os.path.join(storage, _BACKUP_SUBDIR)
- deployed_set = set()
-
- for mod_name in mods:
- if not (mod_list.state(mod_name) & mobase.ModState.ACTIVE):
- continue
-
- mod = mod_list.getMod(mod_name)
- if mod is None:
- continue
- if mod.isSeparator() or mod.isBackup() or mod.isForeign():
- continue
-
- mod_path = mod.absolutePath()
- root_dir = _find_root_dir(mod_path)
- if root_dir is None:
- continue
-
- for src_file in _walk_files(root_dir):
- rel = os.path.relpath(src_file, root_dir)
- dst = os.path.join(game_dir, rel)
-
- try:
- # Backup existing file if not already deployed by us
- if os.path.exists(dst) and dst not in deployed_set:
- bak = os.path.join(backup_dir, rel)
- os.makedirs(os.path.dirname(bak), exist_ok=True)
- try:
- shutil.copy2(dst, bak)
- except PermissionError:
- _ensure_writable(dst)
- shutil.copy2(dst, bak)
- manifest["backups"][dst] = bak
-
- os.makedirs(os.path.dirname(dst), exist_ok=True)
-
- if os.path.lexists(dst):
- _force_remove(dst)
-
- # In link mode, .exe and .dll must be copied — Wine/Proton
- # resolves a symlinked exe's path to the target, so the
- # process can't find sibling files in the game directory.
- ext = os.path.splitext(src_file)[1].lower()
- if mode == "link" and ext not in (".exe", ".dll"):
- os.symlink(src_file, dst)
- else:
- _reflink_copy(src_file, dst)
-
- if dst not in deployed_set:
- manifest["deployed"].append(dst)
- deployed_set.add(dst)
- except OSError as e:
- qWarning(f"Root Builder: could not deploy {rel}: {e}")
-
- _save_manifest(storage, manifest)
- return len(manifest["deployed"])
-
- def _clear(self) -> int:
- """Remove deployed files and restore backups. Returns count of removed files."""
- game_dir = self._organizer.managedGame().gameDirectory().absolutePath()
- storage = self._storage_dir()
- manifest = _load_manifest(storage)
- if manifest is None:
- return 0
-
- count = 0
- failed = []
-
- # Remove deployed files
- for path in manifest["deployed"]:
- if os.path.lexists(path):
- if _force_remove(path):
- count += 1
- else:
- failed.append(path)
-
- # Restore backups
- for dst, bak in manifest["backups"].items():
- if os.path.exists(bak):
- try:
- parent = os.path.dirname(dst)
- os.makedirs(parent, exist_ok=True)
- _ensure_writable(parent)
- if os.path.lexists(dst):
- _force_remove(dst)
- shutil.move(bak, dst)
- except OSError:
- qWarning(
- f"Root Builder: could not restore backup "
- f"{bak} -> {dst}"
- )
-
- # Clean up backup dir
- backup_dir = os.path.join(storage, _BACKUP_SUBDIR)
- if os.path.isdir(backup_dir):
- shutil.rmtree(backup_dir, ignore_errors=True)
-
- if failed:
- # Update manifest to only contain files we couldn't remove,
- # so the next clear attempt can retry them.
- manifest["deployed"] = failed
- manifest["backups"] = {}
- _save_manifest(storage, manifest)
- qWarning(
- f"Root Builder: {len(failed)} file(s) could not be removed. "
- f"They will be retried on next clear."
- )
- else:
- _remove_manifest(storage)
-
- _cleanup_empty_dirs(game_dir, [p for p in manifest["deployed"] if p not in failed])
- return count
-
-
-def createPlugin() -> mobase.IPlugin:
- return RootBuilder()
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp
index c1aa38a..b6bd4e6 100644
--- a/src/src/fuseconnector.cpp
+++ b/src/src/fuseconnector.cpp
@@ -9,6 +9,9 @@
#include <QFileInfo>
#include <QProcess>
#include <QStandardPaths>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
#include <QTextStream>
#include <QVariant>
@@ -423,6 +426,11 @@ void FuseConnector::unmount()
// Clean up symlinks created for non-data-dir mappings.
cleanupExternalMappings();
+ // VFS Root Builder: remove deployed root files and restore backups.
+ if (m_rootBuilderEnabled) {
+ clearRootFiles();
+ }
+
{
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - unmountStart).count();
@@ -565,6 +573,13 @@ void FuseConnector::updateMapping(const MappingType& mapping)
static_cast<long long>(ms));
}
+ // VFS Root Builder: deploy Root/ files to game dir BEFORE mounting.
+ // This ensures files deployed under Data/ are included in the base file scan.
+ if (m_rootBuilderEnabled) {
+ deployRootFiles(mods);
+ m_baseFileCache.clear(); // force rescan to include root-deployed Data/ files
+ }
+
if (!m_mounted) {
mount(dataDirPath, overwriteDir, gameDir, dataDirName, mods);
} else {
@@ -896,3 +911,216 @@ void FuseConnector::tryCleanupStaleMount(const QString& path)
log::warn("stale FUSE mount detected at '{}', attempting cleanup", path);
doUnmount(path);
}
+
+// ── VFS Root Builder ─────────────────────────────────────────────────────────
+
+void FuseConnector::setRootBuilderEnabled(bool enabled,
+ const std::string& storageDir)
+{
+ m_rootBuilderEnabled = enabled;
+ m_rootStorageDir = storageDir;
+}
+
+static std::string findRootDir(const std::string& modPath)
+{
+ // Case-insensitive search for "Root" subdirectory
+ namespace fs = std::filesystem;
+ std::error_code ec;
+ for (const auto& entry : fs::directory_iterator(modPath, ec)) {
+ if (entry.is_directory(ec)) {
+ const auto name = entry.path().filename().string();
+ if (name.size() == 4) {
+ std::string lower = name;
+ std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower);
+ if (lower == "root") {
+ return entry.path().string();
+ }
+ }
+ }
+ }
+ return {};
+}
+
+static bool reflinkCopy(const std::string& src, const std::string& dst)
+{
+ namespace fs = std::filesystem;
+ std::error_code ec;
+ fs::create_directories(fs::path(dst).parent_path(), ec);
+
+ // Try reflink (CoW) first via cp --reflink=auto
+ if (QProcess::execute("cp", {"--reflink=auto", "--no-preserve=mode",
+ QString::fromStdString(src),
+ QString::fromStdString(dst)}) == 0) {
+ return true;
+ }
+
+ // Fallback to regular copy
+ return fs::copy_file(src, dst, fs::copy_options::overwrite_existing, ec);
+}
+
+static void loadRootManifest(const std::string& storageDir,
+ std::vector<std::string>& deployed,
+ std::map<std::string, std::string>& backups)
+{
+ namespace fs = std::filesystem;
+ const auto manifestPath = fs::path(storageDir) / "manifest.json";
+ std::ifstream in(manifestPath);
+ if (!in.is_open()) return;
+
+ try {
+ std::string content((std::istreambuf_iterator<char>(in)),
+ std::istreambuf_iterator<char>());
+ // Simple JSON parsing — the manifest is { "deployed": [...], "backups": {...} }
+ // Use Qt's JSON for simplicity
+ QJsonDocument doc = QJsonDocument::fromJson(
+ QByteArray::fromStdString(content));
+ if (doc.isNull()) return;
+
+ const auto obj = doc.object();
+ for (const auto& v : obj["deployed"].toArray()) {
+ deployed.push_back(v.toString().toStdString());
+ }
+ const auto bk = obj["backups"].toObject();
+ for (auto it = bk.begin(); it != bk.end(); ++it) {
+ backups[it.key().toStdString()] = it.value().toString().toStdString();
+ }
+ } catch (...) {}
+}
+
+static void saveRootManifest(const std::string& storageDir,
+ const std::vector<std::string>& deployed,
+ const std::map<std::string, std::string>& backups)
+{
+ namespace fs = std::filesystem;
+ std::error_code ec;
+ fs::create_directories(storageDir, ec);
+
+ QJsonArray arr;
+ for (const auto& f : deployed) {
+ arr.append(QString::fromStdString(f));
+ }
+
+ QJsonObject bk;
+ for (const auto& [dst, bak] : backups) {
+ bk[QString::fromStdString(dst)] = QString::fromStdString(bak);
+ }
+
+ QJsonObject obj;
+ obj["deployed"] = arr;
+ obj["backups"] = bk;
+
+ const auto manifestPath = fs::path(storageDir) / "manifest.json";
+ std::ofstream out(manifestPath);
+ if (out.is_open()) {
+ out << QJsonDocument(obj).toJson().toStdString();
+ }
+}
+
+void FuseConnector::deployRootFiles(
+ const std::vector<std::pair<std::string, std::string>>& mods)
+{
+ if (!m_rootBuilderEnabled || m_gameDir.empty() || m_rootStorageDir.empty()) {
+ return;
+ }
+
+ namespace fs = std::filesystem;
+ const auto t0 = std::chrono::steady_clock::now();
+
+ // Clear any previous deployment
+ clearRootFiles();
+
+ m_rootDeployedFiles.clear();
+ m_rootBackups.clear();
+
+ const std::string backupDir = (fs::path(m_rootStorageDir) / "backup").string();
+ std::set<std::string> deployedSet;
+
+ for (const auto& [modName, modPath] : mods) {
+ const auto rootDir = findRootDir(modPath);
+ if (rootDir.empty()) continue;
+
+ std::error_code ec;
+ for (const auto& entry :
+ fs::recursive_directory_iterator(rootDir, ec)) {
+ if (!entry.is_regular_file(ec)) continue;
+
+ const auto relPath = fs::relative(entry.path(), rootDir, ec).string();
+ const auto dst = (fs::path(m_gameDir) / relPath).string();
+
+ if (deployedSet.count(dst)) continue; // higher-priority mod already deployed
+
+ // Backup existing file
+ if (fs::exists(dst, ec) && !deployedSet.count(dst)) {
+ const auto bak = (fs::path(backupDir) / relPath).string();
+ fs::create_directories(fs::path(bak).parent_path(), ec);
+ fs::copy_file(dst, bak, fs::copy_options::overwrite_existing, ec);
+ m_rootBackups[dst] = bak;
+ }
+
+ // Deploy: always copy (exe/dll need it, and symlinks can confuse Wine)
+ if (fs::exists(dst, ec) || fs::is_symlink(dst, ec)) {
+ fs::remove(dst, ec);
+ }
+ fs::create_directories(fs::path(dst).parent_path(), ec);
+
+ if (!reflinkCopy(entry.path().string(), dst)) {
+ std::fprintf(stderr, "[RootBuilder] failed to copy '%s' -> '%s'\n",
+ entry.path().c_str(), dst.c_str());
+ continue;
+ }
+
+ m_rootDeployedFiles.push_back(dst);
+ deployedSet.insert(dst);
+ }
+ }
+
+ saveRootManifest(m_rootStorageDir, m_rootDeployedFiles, m_rootBackups);
+
+ const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
+ std::chrono::steady_clock::now() - t0).count();
+ std::fprintf(stderr, "[RootBuilder] deployed %zu files (%zu backups) in %lldms\n",
+ m_rootDeployedFiles.size(), m_rootBackups.size(),
+ static_cast<long long>(ms));
+}
+
+void FuseConnector::clearRootFiles()
+{
+ if (m_rootStorageDir.empty()) return;
+
+ namespace fs = std::filesystem;
+ std::error_code ec;
+
+ // Load manifest if we don't have in-memory state
+ if (m_rootDeployedFiles.empty()) {
+ loadRootManifest(m_rootStorageDir, m_rootDeployedFiles, m_rootBackups);
+ }
+
+ if (m_rootDeployedFiles.empty()) return;
+
+ int removed = 0;
+ for (const auto& dst : m_rootDeployedFiles) {
+ if (fs::exists(dst, ec) || fs::is_symlink(dst, ec)) {
+ fs::remove(dst, ec);
+ ++removed;
+ }
+ }
+
+ // Restore backups
+ for (const auto& [dst, bak] : m_rootBackups) {
+ if (fs::exists(bak, ec)) {
+ fs::create_directories(fs::path(dst).parent_path(), ec);
+ fs::rename(bak, dst, ec);
+ }
+ }
+
+ // Clean up backup directory and manifest
+ const auto backupDir = fs::path(m_rootStorageDir) / "backup";
+ fs::remove_all(backupDir, ec);
+ fs::remove(fs::path(m_rootStorageDir) / "manifest.json", ec);
+
+ std::fprintf(stderr, "[RootBuilder] cleared %d deployed files, restored %zu backups\n",
+ removed, m_rootBackups.size());
+
+ m_rootDeployedFiles.clear();
+ m_rootBackups.clear();
+}
diff --git a/src/src/fuseconnector.h b/src/src/fuseconnector.h
index a13f014..412f794 100644
--- a/src/src/fuseconnector.h
+++ b/src/src/fuseconnector.h
@@ -63,6 +63,12 @@ public:
static void tryCleanupStaleMount(const QString& path);
+ // VFS Root Builder: deploy/clear mod Root/ files to the game directory.
+ void setRootBuilderEnabled(bool enabled, const std::string& storageDir = {});
+ void deployRootFiles(
+ const std::vector<std::pair<std::string, std::string>>& mods);
+ void clearRootFiles();
+
private:
void flushStaging();
void deployExternalMappings(const MappingType& mapping, const QString& dataDir);
@@ -96,6 +102,12 @@ private:
std::thread m_fuseThread;
bool m_mounted = false;
bool m_discardStaging = false;
+
+ // VFS Root Builder state
+ bool m_rootBuilderEnabled = false;
+ std::string m_rootStorageDir;
+ std::vector<std::string> m_rootDeployedFiles;
+ std::map<std::string, std::string> m_rootBackups; // dst -> backup path
};
#endif
diff --git a/src/src/instancemanagerdialog.cpp b/src/src/instancemanagerdialog.cpp
index 5f46402..d3b9f77 100644
--- a/src/src/instancemanagerdialog.cpp
+++ b/src/src/instancemanagerdialog.cpp
@@ -243,6 +243,15 @@ InstanceManagerDialog::InstanceManagerDialog(PluginContainer& pc, QWidget* paren
}
});
+ connect(ui->vfsRootBuilderCheckBox, &QCheckBox::toggled, [&](bool checked) {
+ const auto* inst = singleSelection();
+ if (!inst) return;
+ const QString ini = inst->iniPath();
+ if (ini.isEmpty()) return;
+ QSettings s(ini, QSettings::IniFormat);
+ s.setValue("fluorine/vfs_root_builder", checked);
+ });
+
connect(ui->switchToInstance, &QPushButton::clicked, [&] {
openSelectedInstance();
});
@@ -816,6 +825,10 @@ void InstanceManagerDialog::fillData(const Instance& ii)
ui->steamLinuxRuntimeCheckBox->blockSignals(true);
ui->steamLinuxRuntimeCheckBox->setChecked(s.value("fluorine/use_slr", true).toBool());
ui->steamLinuxRuntimeCheckBox->blockSignals(false);
+
+ ui->vfsRootBuilderCheckBox->blockSignals(true);
+ ui->vfsRootBuilderCheckBox->setChecked(s.value("fluorine/vfs_root_builder", true).toBool());
+ ui->vfsRootBuilderCheckBox->blockSignals(false);
} else {
ui->prefixPath->clear();
ui->protonVersion->clear();
@@ -825,6 +838,9 @@ void InstanceManagerDialog::fillData(const Instance& ii)
ui->steamLinuxRuntimeCheckBox->blockSignals(true);
ui->steamLinuxRuntimeCheckBox->setChecked(true);
ui->steamLinuxRuntimeCheckBox->blockSignals(false);
+ ui->vfsRootBuilderCheckBox->blockSignals(true);
+ ui->vfsRootBuilderCheckBox->setChecked(false);
+ ui->vfsRootBuilderCheckBox->blockSignals(false);
}
}
diff --git a/src/src/instancemanagerdialog.ui b/src/src/instancemanagerdialog.ui
index 6039edc..6ec5455 100644
--- a/src/src/instancemanagerdialog.ui
+++ b/src/src/instancemanagerdialog.ui
@@ -337,6 +337,19 @@
</property>
</widget>
</item>
+ <item row="12" column="0" colspan="2">
+ <widget class="QCheckBox" name="vfsRootBuilderCheckBox">
+ <property name="text">
+ <string>Enable VFS Root Builder</string>
+ </property>
+ <property name="toolTip">
+ <string>Automatically deploy mod Root/ files (SKSE, ENB, etc.) to the game directory before launch. Disable if using a third-party Root Builder plugin.</string>
+ </property>
+ <property name="checked">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
</layout>
</widget>
</item>
diff --git a/src/src/modlistview.cpp b/src/src/modlistview.cpp
index 2b2dda2..2cd6ba4 100644
--- a/src/src/modlistview.cpp
+++ b/src/src/modlistview.cpp
@@ -658,6 +658,10 @@ bool ModListView::toggleSelectionState()
void ModListView::updateGroupByProxy()
{
+ if (m_restoringHeaderState) {
+ return;
+ }
+
int groupIndex = ui.groupBy->currentIndex();
auto* previousModel = m_sortProxy->sourceModel();
@@ -806,7 +810,11 @@ void ModListView::setup(OrganizerCore& core, CategoryFactory& factory, MainWindo
setItemDelegateForColumn(ModList::COL_VERSION,
new ModListVersionDelegate(this, core.settings()));
- if (m_core->settings().geometry().restoreState(header())) {
+ m_restoringHeaderState = true;
+ const bool headerRestored = m_core->settings().geometry().restoreState(header());
+ m_restoringHeaderState = false;
+
+ if (headerRestored) {
// hack: force the resize-signal to be triggered because restoreState doesn't seem
// to do that
for (int column = 0; column <= ModList::COL_LASTCOLUMN; ++column) {
@@ -893,7 +901,9 @@ void ModListView::restoreState(const Settings& s)
// (column visibility, widths, order) is restored.
s.widgets().restoreIndex(ui.groupBy);
+ m_restoringHeaderState = true;
s.geometry().restoreState(header());
+ m_restoringHeaderState = false;
s.widgets().restoreTreeExpandState(this);
diff --git a/src/src/modlistview.h b/src/src/modlistview.h
index c3ffac6..597af4d 100644
--- a/src/src/modlistview.h
+++ b/src/src/modlistview.h
@@ -339,6 +339,10 @@ public: // member variables
// losing them on model reset
std::map<QAbstractItemModel*, std::set<QString>> m_collapsed;
+ // guard: suppresses updateGroupByProxy() during header state restoration
+ // to prevent setSourceModel() from resetting column visibility
+ bool m_restoringHeaderState = false;
+
MarkerInfos m_markers;
ViewMarkingScrollBar* m_scrollbar;
diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp
index 372e9ac..a58f33d 100644
--- a/src/src/organizercore.cpp
+++ b/src/src/organizercore.cpp
@@ -2247,6 +2247,102 @@ ProcessRunner OrganizerCore::processRunner()
return ProcessRunner(*this, m_UserInterface);
}
+#ifndef _WIN32
+bool OrganizerCore::checkGameRegistryKey()
+{
+ // Map of game short names to their registry key info.
+ // Format: { shortName, { subKey, valueName } }
+ static const QMap<QString, QPair<QString, QString>> gameRegistryKeys = {
+ {"Enderal", {"Software\\SureAI\\Enderal", "Install_Path"}},
+ {"EnderalSE", {"Software\\SureAI\\EnderalSE", "Install_Path"}},
+ {"Fallout3", {"Software\\Bethesda Softworks\\Fallout3", "Installed Path"}},
+ {"Fallout4", {"Software\\Bethesda Softworks\\Fallout4", "Installed Path"}},
+ {"Fallout4VR", {"Software\\Bethesda Softworks\\Fallout 4 VR", "Installed Path"}},
+ {"FalloutNV", {"Software\\Bethesda Softworks\\FalloutNV", "Installed Path"}},
+ {"Morrowind", {"Software\\Bethesda Softworks\\Morrowind", "Installed Path"}},
+ {"Oblivion", {"Software\\Bethesda Softworks\\Oblivion", "Installed Path"}},
+ {"Skyrim", {"Software\\Bethesda Softworks\\Skyrim", "Installed Path"}},
+ {"SkyrimSE", {"Software\\Bethesda Softworks\\Skyrim Special Edition", "Installed Path"}},
+ {"SkyrimVR", {"Software\\Bethesda Softworks\\Skyrim VR", "Installed Path"}},
+ {"TTW", {"Software\\Bethesda Softworks\\FalloutNV", "Installed Path"}},
+ };
+
+ const auto* game = managedGame();
+ if (!game) return true;
+
+ const QString shortName = game->gameShortName();
+ auto it = gameRegistryKeys.find(shortName);
+ if (it == gameRegistryKeys.end()) {
+ return true; // unknown game, nothing to check
+ }
+
+ auto cfg = FluorineConfig::load();
+ if (!cfg || cfg->prefix_path.isEmpty()) {
+ return true; // no prefix configured
+ }
+
+ WinePrefix prefix(cfg->prefix_path);
+ if (!prefix.isValid()) {
+ return true; // prefix doesn't exist yet
+ }
+
+ const QString& subKey = it.value().first;
+ const QString& valueName = it.value().second;
+
+ // The game directory MO2 is configured to use — convert to Wine path
+ const QString gameDir = game->gameDirectory().canonicalPath();
+ if (gameDir.isEmpty()) {
+ return true;
+ }
+
+ // Convert Linux path to Wine Z: path for comparison
+ const QString winePath = "Z:" + QString(gameDir).replace("/", "\\");
+
+ // Read the current registry value (check both normal and Wow6432Node)
+ QString registryPath = prefix.readHklmValue(subKey, valueName);
+ if (registryPath.isEmpty()) {
+ const QString wow64Key = "Software\\Wow6432Node\\" + subKey.mid(9);
+ registryPath = prefix.readHklmValue(wow64Key, valueName);
+ }
+
+ if (registryPath.isEmpty() ||
+ registryPath.compare(winePath, Qt::CaseInsensitive) != 0) {
+ const QString displayRegPath = registryPath.isEmpty()
+ ? tr("<not set>") : registryPath;
+
+ QWidget* parent = nullptr;
+ if (m_UserInterface) {
+ parent = m_UserInterface->mainWindow();
+ }
+
+ const auto answer = QMessageBox::question(
+ parent,
+ tr("Registry key does not match"),
+ tr("The game's installation path in the Wine registry does not match "
+ "the managed game path.\n\n"
+ "Registry Game Path:\n\t%1\n"
+ "Managed Game Path:\n\t%2\n\n"
+ "Change the path in the registry to match the managed game path?")
+ .arg(displayRegPath, winePath),
+ QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel,
+ QMessageBox::Yes);
+
+ if (answer == QMessageBox::Yes) {
+ if (!prefix.writeHklmValue(subKey, valueName, winePath)) {
+ log::error("Failed to update game registry key");
+ }
+ // Also update the Wow6432Node copy (32-bit registry view)
+ const QString wow64Key = "Software\\Wow6432Node\\" + subKey.mid(9); // skip "Software\\"
+ prefix.writeHklmValue(wow64Key, valueName, winePath);
+ } else if (answer == QMessageBox::Cancel) {
+ return false; // cancel launch
+ }
+ }
+
+ return true;
+}
+#endif
+
bool OrganizerCore::beforeRun(
const QFileInfo& binary, const QDir& cwd, const QString& arguments,
const QString& profileName, const QString& customOverwrite,
@@ -2272,6 +2368,27 @@ bool OrganizerCore::beforeRun(
return false;
}
+#ifndef _WIN32
+ // Check the game's registry key in the Wine prefix and fix if needed.
+ if (!checkGameRegistryKey()) {
+ return false; // user cancelled
+ }
+#endif
+
+#ifndef _WIN32
+ // VFS Root Builder: read per-instance setting and configure.
+ {
+ bool vfsRootBuilder = false;
+ if (const auto* s = Settings::maybeInstance()) {
+ const QSettings instanceIni(s->filename(), QSettings::IniFormat);
+ vfsRootBuilder = instanceIni.value("fluorine/vfs_root_builder", true).toBool();
+ }
+ const QString storageDir =
+ QDir(QDir::fromNativeSeparators(basePath())).filePath("rootbuilder");
+ m_USVFS.setRootBuilderEnabled(vfsRootBuilder, storageDir.toStdString());
+ }
+#endif
+
try {
m_USVFS.updateMapping(fileMapping(profileName, customOverwrite));
m_USVFS.updateForcedLibraries(forcedLibraries);
diff --git a/src/src/organizercore.h b/src/src/organizercore.h
index a3ee7f6..99b0d86 100644
--- a/src/src/organizercore.h
+++ b/src/src/organizercore.h
@@ -316,6 +316,10 @@ public:
const QString& profileName, const QString& customOverwrite,
const QList<MOBase::ExecutableForcedLoadSetting>& forcedLibraries);
+#ifndef _WIN32
+ bool checkGameRegistryKey();
+#endif
+
void afterRun(const QFileInfo& binary, DWORD exitCode);
ProcessRunner::Results
diff --git a/src/src/plugincontainer.cpp b/src/src/plugincontainer.cpp
index c7e39e9..0c8c89b 100644
--- a/src/src/plugincontainer.cpp
+++ b/src/src/plugincontainer.cpp
@@ -23,52 +23,6 @@ using namespace MOShared;
namespace bf = boost::fusion;
-#ifndef _WIN32
-// Ensure the instance plugin directory contains symlinks to all bundled plugins.
-// Real user files (not matching any bundled plugin name) are left untouched.
-// Stale copies of bundled plugins are replaced with symlinks so that RPATH
-// resolution works correctly (critical for Flatpak where libs live in /app/).
-static void ensureBundledPluginsLinked(const QString& bundledDir,
- const QString& instanceDir)
-{
- QDir().mkpath(instanceDir);
- QDirIterator it(bundledDir, QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot);
- while (it.hasNext()) {
- it.next();
- const QString target = QDir(instanceDir).filePath(it.fileName());
- const QFileInfo targetInfo(target);
-
- if (targetInfo.isSymLink()) {
- // Already a symlink — check if it points to the right place.
- // Use canonicalFilePath() on both sides so /home/ ↔ /var/home/ symlinks
- // (Bazzite/Fedora immutable distros) don't cause false stale-symlink
- // replacements that can delete the .so before re-linking it.
- const QString existingTarget =
- QFileInfo(targetInfo.symLinkTarget()).canonicalFilePath();
- const QString expectedTarget = QFileInfo(it.filePath()).canonicalFilePath();
- if (!expectedTarget.isEmpty() && existingTarget == expectedTarget) {
- continue; // correct symlink, nothing to do
- }
- // Stale symlink pointing elsewhere — replace it.
- QFile::remove(target);
- } else if (targetInfo.exists()) {
- // Real file/dir with the same name as a bundled plugin — this is a
- // stale copy (not a user plugin). Replace with a symlink so RPATH works.
- if (targetInfo.isDir()) {
- QDir(target).removeRecursively();
- } else {
- QFile::remove(target);
- }
- }
-
- if (!QFile::link(it.filePath(), target)) {
- log::warn("ensureBundledPluginsLinked: failed to symlink '{}' -> '{}'",
- it.filePath(), target);
- }
- }
-}
-#endif
-
static void printPluginDiagToStderr(const QString&)
{
}
@@ -445,6 +399,26 @@ bool PluginContainer::isBetterInterface(QObject* lhs, QObject* rhs) const
return lhsIdx < rhsIdx;
}
+QStringList PluginContainer::mergedProxyList(IPluginProxy* proxy) const
+{
+ const QString bundled =
+ m_BundledPluginPath.isEmpty() ? AppConfig::pluginsPath() : m_BundledPluginPath;
+ const QString instance =
+ m_PluginPath.isEmpty() ? AppConfig::pluginsPath() : m_PluginPath;
+
+ QMap<QString, QString> merged;
+ // Instance plugins first (lower priority)
+ if (instance != bundled) {
+ for (const auto& p : proxy->pluginList(instance))
+ merged[QFileInfo(p).fileName()] = p;
+ }
+ // Bundled plugins overwrite (higher priority)
+ for (const auto& p : proxy->pluginList(bundled))
+ merged[QFileInfo(p).fileName()] = p;
+
+ return merged.values();
+}
+
QStringList PluginContainer::pluginFileNames() const
{
QStringList result;
@@ -453,9 +427,7 @@ QStringList PluginContainer::pluginFileNames() const
}
std::vector<IPluginProxy*> proxyList = bf::at_key<IPluginProxy>(m_Plugins);
for (IPluginProxy* proxy : proxyList) {
- QStringList proxiedPlugins = proxy->pluginList(
- m_PluginPath.isEmpty() ? AppConfig::pluginsPath() : m_PluginPath);
- result.append(proxiedPlugins);
+ result.append(mergedProxyList(proxy));
}
return result;
}
@@ -655,17 +627,13 @@ IPlugin* PluginContainer::registerPlugin(QObject* plugin, const QString& filepat
bf::at_key<IPluginProxy>(m_Plugins).push_back(proxy);
emit pluginRegistered(proxy);
- const QString pluginRoot =
- m_PluginPath.isEmpty() ? AppConfig::pluginsPath() : m_PluginPath;
- QStringList filepaths = proxy->pluginList(pluginRoot);
- log::debug("proxy '{}' discovered {} proxied plugin candidate(s) in '{}'",
- proxy->name(), filepaths.size(),
- QDir::toNativeSeparators(pluginRoot));
+ QStringList filepaths = mergedProxyList(proxy);
+ log::debug("proxy '{}' discovered {} proxied plugin candidate(s)",
+ proxy->name(), filepaths.size());
printPluginDiagToStderr(
- QString("proxy '%1' discovered %2 proxied plugin candidate(s) in '%3'")
+ QString("proxy '%1' discovered %2 proxied plugin candidate(s)")
.arg(proxy->name())
- .arg(filepaths.size())
- .arg(QDir::toNativeSeparators(pluginRoot)));
+ .arg(filepaths.size()));
for (const QString& filepath : filepaths) {
log::debug("proxy '{}' candidate: '{}'", proxy->name(),
QDir::toNativeSeparators(filepath));
@@ -1036,8 +1004,7 @@ void PluginContainer::loadPlugin(QString const& filepath)
} else {
// We need to check if this can be handled by a proxy.
for (auto* proxy : this->plugins<IPluginProxy>()) {
- auto filepaths = proxy->pluginList(
- m_PluginPath.isEmpty() ? AppConfig::pluginsPath() : m_PluginPath);
+ auto filepaths = mergedProxyList(proxy);
if (filepaths.contains(filepath)) {
plugins = loadProxied(filepath, proxy);
break;
@@ -1255,32 +1222,51 @@ void PluginContainer::loadPlugins()
}
}
- QString pluginPath = AppConfig::pluginsPath();
+ m_BundledPluginPath = AppConfig::pluginsPath();
#ifndef _WIN32
- // Per-instance plugin directory: symlink bundled plugins, then load from there.
- // This allows each instance to have its own set of plugins (bundled + custom).
if (m_Organizer) {
QString instancePluginPath =
QDir(QDir::fromNativeSeparators(m_Organizer->basePath())).filePath("plugins");
- if (QDir::cleanPath(instancePluginPath) != QDir::cleanPath(pluginPath)) {
- ensureBundledPluginsLinked(pluginPath, instancePluginPath);
- pluginPath = instancePluginPath;
- log::debug("Using per-instance plugin directory: {}",
- QDir::toNativeSeparators(pluginPath));
+ if (QDir::cleanPath(instancePluginPath) != QDir::cleanPath(m_BundledPluginPath)) {
+ QDir().mkpath(instancePluginPath);
+ m_PluginPath = instancePluginPath;
+ log::debug("instance plugin directory: {}",
+ QDir::toNativeSeparators(m_PluginPath));
+
+ // Migration: remove stale symlinks left by the old ensureBundledPluginsLinked()
+ // approach. Only symlinks are removed; real user files are left untouched.
+ {
+ QDirIterator cleanIter(instancePluginPath,
+ QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot);
+ while (cleanIter.hasNext()) {
+ cleanIter.next();
+ if (QFileInfo(cleanIter.filePath()).isSymLink()) {
+ log::debug("removing stale plugin symlink '{}'",
+ QDir::toNativeSeparators(cleanIter.filePath()));
+ QFile::remove(cleanIter.filePath());
+ }
+ }
+ }
+ } else {
+ m_PluginPath = m_BundledPluginPath;
}
+ } else {
+ m_PluginPath = m_BundledPluginPath;
}
+#else
+ m_PluginPath = m_BundledPluginPath;
#endif
- m_PluginPath = pluginPath;
- log::debug("looking for plugins in {}", QDir::toNativeSeparators(pluginPath));
+ log::debug("bundled plugins: {}", QDir::toNativeSeparators(m_BundledPluginPath));
+ log::debug("looking for plugins in {}", QDir::toNativeSeparators(m_PluginPath));
// Linux is case-sensitive; keep only the canonical Fallout NV plugin filename.
// Older builds may leave a stale lowercase artifact that causes duplicate
// registration warnings at startup.
- {
- const QString nvCanonical = pluginPath + "/libgame_falloutNV.so";
- const QString nvStale = pluginPath + "/libgame_falloutnv.so";
+ auto cleanStaleNvPlugin = [](const QString& dir) {
+ const QString nvCanonical = dir + "/libgame_falloutNV.so";
+ const QString nvStale = dir + "/libgame_falloutnv.so";
if (QFile::exists(nvCanonical) && QFile::exists(nvStale)) {
if (QFile::remove(nvStale)) {
log::debug("removed stale plugin artifact '{}'",
@@ -1290,32 +1276,54 @@ void PluginContainer::loadPlugins()
QDir::toNativeSeparators(nvStale));
}
}
+ };
+ cleanStaleNvPlugin(m_BundledPluginPath);
+ if (m_PluginPath != m_BundledPluginPath) {
+ cleanStaleNvPlugin(m_PluginPath);
}
- QDirIterator iter(pluginPath, QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot);
+ // Build merged plugin map: instance extras first (low priority),
+ // then bundled plugins overwrite (high priority).
+ QMap<QString, QString> pluginMap; // filename -> full path
- while (iter.hasNext()) {
- iter.next();
+ if (m_PluginPath != m_BundledPluginPath) {
+ QDirIterator instanceIter(m_PluginPath,
+ QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot);
+ while (instanceIter.hasNext()) {
+ instanceIter.next();
+ pluginMap[instanceIter.fileName()] = instanceIter.filePath();
+ }
+ }
+
+ QDirIterator bundledIter(m_BundledPluginPath,
+ QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot);
+ while (bundledIter.hasNext()) {
+ bundledIter.next();
+ pluginMap[bundledIter.fileName()] = bundledIter.filePath();
+ }
+
+ for (auto it = pluginMap.cbegin(); it != pluginMap.cend(); ++it) {
+ const QString& fileName = it.key();
+ const QString& filepath = it.value();
- if (skipPlugin == iter.fileName()) {
- log::debug("plugin \"{}\" skipped for this session", iter.fileName());
+ if (skipPlugin == fileName) {
+ log::debug("plugin \"{}\" skipped for this session", fileName);
continue;
}
if (m_Organizer) {
- if (m_Organizer->settings().plugins().blacklisted(iter.fileName())) {
- log::debug("plugin \"{}\" blacklisted", iter.fileName());
+ if (m_Organizer->settings().plugins().blacklisted(fileName)) {
+ log::debug("plugin \"{}\" blacklisted", fileName);
continue;
}
}
if (loadCheck.isOpen()) {
- loadCheck.write(iter.fileName().toUtf8());
+ loadCheck.write(fileName.toUtf8());
loadCheck.write("\n");
loadCheck.flush();
}
- QString filepath = iter.filePath();
if (QLibrary::isLibrary(filepath)) {
loadQtPlugin(filepath);
} else if (auto p = isQtPluginFolder(filepath)) {
diff --git a/src/src/plugincontainer.h b/src/src/plugincontainer.h
index 4ae1744..a11990a 100644
--- a/src/src/plugincontainer.h
+++ b/src/src/plugincontainer.h
@@ -354,6 +354,11 @@ public:
*/
QStringList pluginFileNames() const;
+ /**
+ * @brief Merged proxy plugin list from bundled + instance dirs (bundled wins).
+ */
+ QStringList mergedProxyList(MOBase::IPluginProxy* proxy) const;
+
public: // IPluginDiagnose interface
virtual std::vector<unsigned int> activeProblems() const;
virtual QString shortDescription(unsigned int key) const;
@@ -501,7 +506,10 @@ private:
QFile m_PluginsCheck;
- // Resolved plugin path (may be per-instance on Linux global installs)
+ // Bundled plugin path (app's read-only plugins/ dir, always takes priority)
+ QString m_BundledPluginPath;
+
+ // Instance plugin path (user additions; only extras not in bundled dir are loaded)
QString m_PluginPath;
};
diff --git a/src/src/processrunner.cpp b/src/src/processrunner.cpp
index dee7b73..1a50849 100644
--- a/src/src/processrunner.cpp
+++ b/src/src/processrunner.cpp
@@ -82,15 +82,71 @@ void adjustForVirtualized(const IPluginGame *game, spawn::SpawnParameters &sp,
// Root Builder deploys Root/ contents to the game directory root,
// stripping the "Root/" prefix. Fix paths that were remapped to
// <dataDir>/Root/... so they point to <gameDir>/... instead.
+ // Also handle direct mods/.../Root/ paths (not just dataDir/Root/).
const QString gameDir = game->gameDirectory().absolutePath();
const QString dataDir = game->dataDirectory().absolutePath();
- const QString rootTag = dataDir + QStringLiteral("/Root/");
- if (binPath.startsWith(rootTag, Qt::CaseInsensitive)) {
- binPath = gameDir + QStringLiteral("/") + binPath.mid(rootTag.length());
+ // Normalize any path that was remapped to dataDir/Root/... → gameDir/...
+ // Handles both dataDir/Root/file and dataDir/Root (exact, no trailing content).
+ auto normalizeRootPath = [&](QString& path) {
+ const QString rootWithSlash = dataDir + QStringLiteral("/Root/");
+ const QString rootExact = dataDir + QStringLiteral("/Root");
+ if (path.startsWith(rootWithSlash, Qt::CaseInsensitive)) {
+ const QString after = path.mid(rootWithSlash.length());
+ path = after.isEmpty() ? gameDir
+ : gameDir + QStringLiteral("/") + after;
+ return true;
+ }
+ if (path.compare(rootExact, Qt::CaseInsensitive) == 0) {
+ path = gameDir;
+ return true;
+ }
+ return false;
+ };
+
+ bool binNormalized = normalizeRootPath(binPath);
+ bool cwdNormalized = normalizeRootPath(cwdPath);
+
+ if (binNormalized) {
+ log::info("Root Builder: rewrote binary -> '{}'", binPath);
}
- if (cwdPath.startsWith(rootTag, Qt::CaseInsensitive)) {
- cwdPath = gameDir + QStringLiteral("/") + cwdPath.mid(rootTag.length());
+ if (cwdNormalized) {
+ log::info("Root Builder: rewrote start-in -> '{}'", cwdPath);
+ }
+
+ // If neither was caught by the dataDir/Root/ check, the path might
+ // still be the original mods/.../Root/ path (not yet remapped).
+ // This happens when the first remapping (lines 61-66) produced
+ // something that didn't match the dataDir/Root pattern.
+ if (!binNormalized && binPath.startsWith(trailedModsPath, Qt::CaseInsensitive)) {
+ int rootIdx = binPath.indexOf("/Root/", trailedModsPath.length(),
+ Qt::CaseInsensitive);
+ if (rootIdx < 0)
+ rootIdx = binPath.indexOf("/Root", trailedModsPath.length(),
+ Qt::CaseInsensitive);
+ if (rootIdx >= 0) {
+ int afterRootStart = rootIdx + 5; // skip "/Root"
+ if (afterRootStart < binPath.length() && binPath[afterRootStart] == '/')
+ ++afterRootStart; // skip trailing slash
+ const QString afterRoot = binPath.mid(afterRootStart);
+ const QString modRoot = binPath.left(rootIdx + 5); // up to "/Root"
+
+ binPath = afterRoot.isEmpty() ? gameDir
+ : gameDir + QStringLiteral("/") + afterRoot;
+ log::info("Root Builder: rewrote binary (mod path) -> '{}'", binPath);
+
+ // Normalize start-in if it's under the same mod's Root/
+ if (!cwdNormalized &&
+ cwdPath.startsWith(modRoot, Qt::CaseInsensitive)) {
+ int cwdAfterStart = modRoot.length();
+ if (cwdAfterStart < cwdPath.length() && cwdPath[cwdAfterStart] == '/')
+ ++cwdAfterStart;
+ const QString cwdAfter = cwdPath.mid(cwdAfterStart);
+ cwdPath = cwdAfter.isEmpty() ? gameDir
+ : gameDir + QStringLiteral("/") + cwdAfter;
+ log::info("Root Builder: rewrote start-in (mod path) -> '{}'", cwdPath);
+ }
+ }
}
sp.binary = QFileInfo(binPath);
diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp
index 57a88fc..3c279fa 100644
--- a/src/src/protonlauncher.cpp
+++ b/src/src/protonlauncher.cpp
@@ -460,8 +460,20 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const
const QString runScript = QString::fromUtf8(slrScript);
nak_string_free(slrScript);
MOBase::log::info("SLR: wrapping launch with {}", runScript);
- // Build: [wrappers] run_script -- proton_script protonArgs
+ // Build: [wrappers] run_script [--filesystem=...] -- proton_script protonArgs
QStringList slrArgs;
+
+ // Expose the game directory (and its FUSE-mounted Data/) to the
+ // pressure-vessel container. Without this, the container's mount
+ // namespace may not see FUSE mounts on the host.
+ if (!m_binary.isEmpty()) {
+ const QString gameDir = QFileInfo(m_binary).absolutePath();
+ slrArgs << QStringLiteral("--filesystem=%1").arg(gameDir);
+ }
+ if (!m_prefixPath.isEmpty()) {
+ slrArgs << QStringLiteral("--filesystem=%1").arg(m_prefixPath);
+ }
+
slrArgs << "--" << protonScript << protonArgs;
wrapProgram(m_wrapperCommands, runScript, slrArgs, program, arguments);
} else {
diff --git a/src/src/wineprefix.cpp b/src/src/wineprefix.cpp
index e144460..de61172 100644
--- a/src/src/wineprefix.cpp
+++ b/src/src/wineprefix.cpp
@@ -531,3 +531,174 @@ bool WinePrefix::syncProfileInisBack(
return allCopied;
}
+
+// ── Wine registry (.reg file) access ─────────────────────────────────────────
+
+// Wine .reg files use doubled backslashes in key paths:
+// [Software\\Bethesda Softworks\\Skyrim Special Edition]
+// Values are stored as:
+// "Installed Path"="C:\\path\\to\\game"
+
+static QString escapeRegKey(const QString& key)
+{
+ // Convert normal backslash path to Wine .reg double-backslash format
+ QString escaped = key;
+ escaped.replace("\\", "\\\\");
+ return escaped;
+}
+
+static QString unescapeRegValue(const QString& val)
+{
+ // Wine .reg files escape backslashes in string values
+ QString unescaped = val;
+ unescaped.replace("\\\\", "\\");
+ return unescaped;
+}
+
+static QString escapeRegValue(const QString& val)
+{
+ QString escaped = val;
+ escaped.replace("\\", "\\\\");
+ return escaped;
+}
+
+QString WinePrefix::readRegistryValue(const QString& regFile,
+ const QString& subKey,
+ const QString& valueName) const
+{
+ const QString filePath = m_prefixPath + "/" + regFile;
+ QFile file(filePath);
+ if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ return {};
+ }
+
+ // Wine .reg section headers have an optional trailing timestamp:
+ // [Software\\Bethesda Softworks\\Skyrim Special Edition] 1774203819
+ const QString sectionPrefix =
+ "[" + escapeRegKey(subKey) + "]";
+ const QString valuePrefix = "\"" + valueName + "\"=";
+
+ bool inSection = false;
+ QTextStream in(&file);
+ while (!in.atEnd()) {
+ const QString line = in.readLine().trimmed();
+
+ if (line.startsWith('[')) {
+ inSection = line.startsWith(sectionPrefix, Qt::CaseInsensitive);
+ continue;
+ }
+
+ if (inSection && line.startsWith(valuePrefix, Qt::CaseInsensitive)) {
+ // Extract value: "Name"="value" or "Name"=str(2):"value"
+ int eqPos = line.indexOf('=');
+ if (eqPos < 0) continue;
+ QString rhs = line.mid(eqPos + 1);
+
+ // Handle str(2):"..." (REG_EXPAND_SZ) and regular "..." (REG_SZ)
+ int firstQuote = rhs.indexOf('"');
+ int lastQuote = rhs.lastIndexOf('"');
+ if (firstQuote >= 0 && lastQuote > firstQuote) {
+ return unescapeRegValue(rhs.mid(firstQuote + 1, lastQuote - firstQuote - 1));
+ }
+ return {};
+ }
+ }
+
+ return {};
+}
+
+bool WinePrefix::writeRegistryValue(const QString& regFile,
+ const QString& subKey,
+ const QString& valueName,
+ const QString& value) const
+{
+ const QString filePath = m_prefixPath + "/" + regFile;
+ QFile file(filePath);
+ if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ MOBase::log::error("writeRegistryValue: cannot open '{}'", filePath);
+ return false;
+ }
+
+ const QString sectionPrefix = "[" + escapeRegKey(subKey) + "]";
+ const QString valuePrefix = "\"" + valueName + "\"=";
+ const QString newLine = "\"" + valueName + "\"=\"" + escapeRegValue(value) + "\"";
+
+ QStringList lines;
+ bool inSection = false;
+ bool replaced = false;
+ bool sectionFound = false;
+
+ QTextStream in(&file);
+ while (!in.atEnd()) {
+ QString line = in.readLine();
+ const QString trimmed = line.trimmed();
+
+ if (trimmed.startsWith('[')) {
+ if (inSection && !replaced) {
+ // End of our section without finding the value — insert it
+ lines.append(newLine);
+ replaced = true;
+ }
+ inSection = trimmed.startsWith(sectionPrefix, Qt::CaseInsensitive);
+ if (inSection) sectionFound = true;
+ }
+
+ if (inSection && trimmed.startsWith(valuePrefix, Qt::CaseInsensitive)) {
+ lines.append(newLine);
+ replaced = true;
+ continue;
+ }
+
+ lines.append(line);
+ }
+ file.close();
+
+ // If section existed but value wasn't found (and wasn't inserted above)
+ if (sectionFound && !replaced) {
+ for (int i = 0; i < lines.size(); ++i) {
+ if (lines[i].trimmed().startsWith(sectionPrefix, Qt::CaseInsensitive)) {
+ lines.insert(i + 1, newLine);
+ replaced = true;
+ break;
+ }
+ }
+ }
+
+ // If section doesn't exist at all, append it
+ if (!sectionFound) {
+ lines.append("");
+ lines.append(sectionPrefix);
+ lines.append(newLine);
+ replaced = true;
+ }
+
+ if (!replaced) {
+ return false;
+ }
+
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
+ MOBase::log::error("writeRegistryValue: cannot write '{}'", filePath);
+ return false;
+ }
+ QTextStream out(&file);
+ for (const auto& l : lines) {
+ out << l << "\n";
+ }
+
+ MOBase::log::info("Updated Wine registry: [{}] \"{}\"=\"{}\" in {}",
+ subKey, valueName, value, regFile);
+ return true;
+}
+
+QString WinePrefix::readHklmValue(const QString& subKey,
+ const QString& valueName) const
+{
+ return readRegistryValue("system.reg", subKey, valueName);
+}
+
+bool WinePrefix::writeHklmValue(const QString& subKey,
+ const QString& valueName,
+ const QString& value) const
+{
+ return writeRegistryValue("system.reg", subKey, valueName, value);
+}
diff --git a/src/src/wineprefix.h b/src/src/wineprefix.h
index e195e70..ac8f0a2 100644
--- a/src/src/wineprefix.h
+++ b/src/src/wineprefix.h
@@ -36,6 +36,20 @@ public:
// Should be called at startup before any game runs.
void restoreStaleBackups() const;
+ // Wine registry (system.reg / user.reg) access.
+ // subKey uses Wine format: "Software\\\\Bethesda Softworks\\\\Skyrim Special Edition"
+ // (double-escaped backslashes as stored in .reg files).
+ // Convenience overload accepts normal backslash paths and escapes internally.
+ QString readRegistryValue(const QString& regFile, const QString& subKey,
+ const QString& valueName) const;
+ bool writeRegistryValue(const QString& regFile, const QString& subKey,
+ const QString& valueName, const QString& value) const;
+
+ // High-level: read/write HKLM values via system.reg
+ QString readHklmValue(const QString& subKey, const QString& valueName) const;
+ bool writeHklmValue(const QString& subKey, const QString& valueName,
+ const QString& value) const;
+
private:
QString m_prefixPath;
};