diff options
| -rwxr-xr-x | docker/build-inner.sh | 1 | ||||
| -rw-r--r-- | libs/plugin_python/src/proxy/proxypython.cpp | 8 | ||||
| -rw-r--r-- | src/plugins/rootbuilder.py | 544 | ||||
| -rw-r--r-- | src/src/fuseconnector.cpp | 228 | ||||
| -rw-r--r-- | src/src/fuseconnector.h | 12 | ||||
| -rw-r--r-- | src/src/instancemanagerdialog.cpp | 16 | ||||
| -rw-r--r-- | src/src/instancemanagerdialog.ui | 13 | ||||
| -rw-r--r-- | src/src/modlistview.cpp | 12 | ||||
| -rw-r--r-- | src/src/modlistview.h | 4 | ||||
| -rw-r--r-- | src/src/organizercore.cpp | 117 | ||||
| -rw-r--r-- | src/src/organizercore.h | 4 | ||||
| -rw-r--r-- | src/src/plugincontainer.cpp | 172 | ||||
| -rw-r--r-- | src/src/plugincontainer.h | 10 | ||||
| -rw-r--r-- | src/src/processrunner.cpp | 66 | ||||
| -rw-r--r-- | src/src/protonlauncher.cpp | 14 | ||||
| -rw-r--r-- | src/src/wineprefix.cpp | 171 | ||||
| -rw-r--r-- | src/src/wineprefix.h | 14 |
17 files changed, 770 insertions, 636 deletions
diff --git a/docker/build-inner.sh b/docker/build-inner.sh index 10c1818..e810ad4 100755 --- a/docker/build-inner.sh +++ b/docker/build-inner.sh @@ -90,7 +90,6 @@ for pyfile in \ "libs/form43_checker/src/Form43Checker.py" \ "libs/script_extender_plugin_checker/src/ScriptExtenderPluginChecker.py" \ "libs/preview_dds/src/DDSPreview.py" \ - "src/plugins/rootbuilder.py" \ "src/plugins/installer_omod.py"; do [ -f "${pyfile}" ] && cp -f "${pyfile}" "${OUT_DIR}/plugins/" done diff --git a/libs/plugin_python/src/proxy/proxypython.cpp b/libs/plugin_python/src/proxy/proxypython.cpp index c9c7ba2..6a1430d 100644 --- a/libs/plugin_python/src/proxy/proxypython.cpp +++ b/libs/plugin_python/src/proxy/proxypython.cpp @@ -284,12 +284,18 @@ QStringList ProxyPython::pluginList(const QDir& pluginPath) const // loaded as MO2 plugins. if (baseName == "winreg.py" || baseName == "lzokay.py" || baseName == "FNISPatches.py" || baseName == "FNISTool.py" || - baseName == "FNISToolReset.py") { + baseName == "FNISToolReset.py" || + baseName == "FixGameRegKey.py" || + baseName == "rootbuilder.py") { continue; } result.append(name); } else if (info.isDir() && QDir(info.absoluteFilePath()).exists("__init__.py")) { + // Skip Windows-only directory plugins that use ctypes.windll etc. + if (baseName == "crashlogtools") { + continue; + } result.append(name); } } 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; }; |
