From a5ad5ddc9df898cffad7e7a0996051a455fc99fc Mon Sep 17 00:00:00 2001 From: tristan-iu Date: Sun, 21 Jun 2026 17:56:42 +0200 Subject: Add IPluginGame::usesVFS() game feature and python binding --- libs/plugin_python/src/mobase/wrappers/pyplugins.cpp | 1 + libs/plugin_python/src/mobase/wrappers/pyplugins.h | 4 ++++ libs/uibase/include/uibase/iplugingame.h | 13 +++++++++++++ 3 files changed, 18 insertions(+) diff --git a/libs/plugin_python/src/mobase/wrappers/pyplugins.cpp b/libs/plugin_python/src/mobase/wrappers/pyplugins.cpp index c744c7b..6f24438 100644 --- a/libs/plugin_python/src/mobase/wrappers/pyplugins.cpp +++ b/libs/plugin_python/src/mobase/wrappers/pyplugins.cpp @@ -53,6 +53,7 @@ namespace mo2::python { .def("listSaves", &IPluginGame::listSaves, "folder"_a) .def("isInstalled", &IPluginGame::isInstalled) .def("isNativeLinux", &IPluginGame::isNativeLinux) + .def("usesVFS", &IPluginGame::usesVFS) .def("gameIcon", &IPluginGame::gameIcon) .def("gameDirectory", &IPluginGame::gameDirectory) .def("dataDirectory", &IPluginGame::dataDirectory) diff --git a/libs/plugin_python/src/mobase/wrappers/pyplugins.h b/libs/plugin_python/src/mobase/wrappers/pyplugins.h index 1480690..cd33e96 100644 --- a/libs/plugin_python/src/mobase/wrappers/pyplugins.h +++ b/libs/plugin_python/src/mobase/wrappers/pyplugins.h @@ -434,6 +434,10 @@ namespace mo2::python { { PYBIND11_OVERRIDE(bool, IPluginGame, isNativeLinux, ); } + bool usesVFS() const override + { + PYBIND11_OVERRIDE(bool, IPluginGame, usesVFS, ); + } QIcon gameIcon() const override { PYBIND11_OVERRIDE_PURE(QIcon, IPluginGame, gameIcon, ); diff --git a/libs/uibase/include/uibase/iplugingame.h b/libs/uibase/include/uibase/iplugingame.h index c7760f1..86ed7b0 100644 --- a/libs/uibase/include/uibase/iplugingame.h +++ b/libs/uibase/include/uibase/iplugingame.h @@ -151,6 +151,19 @@ public: */ virtual bool isNativeLinux() const { return false; } + /** + * this function may be called before init() + * + * @return true if Fluorine should mount its FUSE VFS over the game's data + * directory for this game (the default). Return false for games that + * manage their own virtual file system — e.g. OpenMW, which reads mods + * from data= entries in openmw.cfg — so Fluorine skips the mount + * entirely and lets the engine resolve its own load order. This is + * distinct from isNativeLinux(): a native Linux game (e.g. Stardew + * Valley) can still rely on the VFS to expose its mods. + */ + virtual bool usesVFS() const { return true; } + /** * this function may be called before init() * -- cgit v1.3.1 From 9d78591d2bc36840b776f2cc5600471f7ea9992b Mon Sep 17 00:00:00 2001 From: tristan-iu Date: Sun, 21 Jun 2026 17:56:42 +0200 Subject: Skip FUSE mount for games that manage their own VFS --- src/src/organizercore.cpp | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp index 64772fa..2f62251 100644 --- a/src/src/organizercore.cpp +++ b/src/src/organizercore.cpp @@ -809,7 +809,14 @@ void OrganizerCore::prepareVFS() m_USVFS.setRootBuilderEnabled(vfsRootBuilder, storageDir.toStdString()); } - m_USVFS.updateMapping(fileMapping(m_CurrentProfile->name(), QString())); + // Games that manage their own VFS (e.g. OpenMW via openmw.cfg) opt out of the + // FUSE mount; Fluorine must not overlay Data Files for them. + if (managedGame() == nullptr || managedGame()->usesVFS()) { + m_USVFS.updateMapping(fileMapping(m_CurrentProfile->name(), QString())); + } else { + log::debug("prepareVFS: skipping FUSE mount; managed game manages its own " + "VFS (usesVFS=false)"); + } } void OrganizerCore::unmountVFS() @@ -2687,8 +2694,14 @@ bool OrganizerCore::beforeRun( } try { - m_USVFS.updateMapping(fileMapping(profileName, customOverwrite)); - m_USVFS.updateForcedLibraries(forcedLibraries); + // OpenMW and other self-managed-VFS games skip the FUSE mount (usesVFS()). + if (managedGame() == nullptr || managedGame()->usesVFS()) { + m_USVFS.updateMapping(fileMapping(profileName, customOverwrite)); + m_USVFS.updateForcedLibraries(forcedLibraries); + } else { + log::debug("beforeRun: skipping FUSE mount; managed game manages its own " + "VFS (usesVFS=false)"); + } } catch (const FuseConnectorException& e) { log::error("VFS mount failed: {}", e.what()); return false; -- cgit v1.3.1 From ce2ae9f4769f6181fe36a87056775af23faaeb59 Mon Sep 17 00:00:00 2001 From: tristan-iu Date: Sun, 21 Jun 2026 17:56:42 +0200 Subject: Forward both stdio channels on native launch to avoid engine SIGPIPE --- src/src/protonlauncher.cpp | 58 +++++++++++++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp index 0c876ec..f6059e3 100644 --- a/src/src/protonlauncher.cpp +++ b/src/src/protonlauncher.cpp @@ -457,7 +457,8 @@ void wrapInTerminal(QString& program, QStringList& arguments) bool startWithEnv(const QString& program, const QStringList& arguments, const QString& workingDir, - const QProcessEnvironment& environment, qint64& pid) + const QProcessEnvironment& environment, qint64& pid, + bool forwardAllChannels = false) { auto* process = new QProcess(); process->setProgram(program); @@ -468,23 +469,38 @@ bool startWithEnv(const QString& program, const QStringList& arguments, } process->setProcessEnvironment(environment); - process->setProcessChannelMode(QProcess::ForwardedOutputChannel); - - // Filter noisy Wine/Proton stderr (GStreamer warnings, etc.) while - // forwarding everything else to our stderr. - QObject::connect(process, &QProcess::readyReadStandardError, process, [process]() { - const QByteArray data = process->readAllStandardError(); - for (const QByteArray& line : data.split('\n')) { - if (line.isEmpty()) - continue; - if (line.contains("GStreamer-WARNING") || line.contains("Failed to load plugin") || - line.contains("ProtonFixes[") || line.contains("wineserver: NTSync") || - line.contains("[MANGOHUD]") || line.contains("radv is not a conformant")) - continue; - std::fwrite(line.constData(), 1, line.size(), stderr); - std::fputc('\n', stderr); - } - }); + + if (forwardAllChannels) { + // Forward BOTH stdout and stderr straight to our own fds, with no + // QProcess-managed pipe. This is required for native launches that hand + // off to a longer-lived child: e.g. openmw-launcher spawns the OpenMW + // engine, the engine inherits the launcher's stderr, then the launcher + // exits. With a piped stderr (ForwardedOutputChannel below), the QProcess + // is destroyed on `finished` and closes the pipe's read end — the engine's + // next write to stderr would hit a broken pipe and die with SIGPIPE (no + // coredump). Forwarding to our real stderr fd (which lives as long as + // Fluorine does) makes the child immune. Native games don't emit the + // Proton/Wine stderr noise the filter below targets, so we lose nothing. + process->setProcessChannelMode(QProcess::ForwardedChannels); + } else { + process->setProcessChannelMode(QProcess::ForwardedOutputChannel); + + // Filter noisy Wine/Proton stderr (GStreamer warnings, etc.) while + // forwarding everything else to our stderr. + QObject::connect(process, &QProcess::readyReadStandardError, process, [process]() { + const QByteArray data = process->readAllStandardError(); + for (const QByteArray& line : data.split('\n')) { + if (line.isEmpty()) + continue; + if (line.contains("GStreamer-WARNING") || line.contains("Failed to load plugin") || + line.contains("ProtonFixes[") || line.contains("wineserver: NTSync") || + line.contains("[MANGOHUD]") || line.contains("radv is not a conformant")) + continue; + std::fwrite(line.constData(), 1, line.size(), stderr); + std::fputc('\n', stderr); + } + }); + } QObject::connect(process, QOverload::of(&QProcess::finished), @@ -1022,7 +1038,11 @@ bool ProtonLauncher::launchDirect(qint64& pid) const wrapInTerminal(program, arguments); } - return startWithEnv(program, arguments, m_workingDir, env, pid); + // Native direct launch: forward both channels so a launcher-spawned engine + // (openmw-launcher -> openmw) can't be SIGPIPE'd when the launcher exits and + // the QProcess closes its stderr pipe. See startWithEnv for the full rationale. + return startWithEnv(program, arguments, m_workingDir, env, pid, + /*forwardAllChannels=*/true); } bool ProtonLauncher::ensureSteamRunning() -- cgit v1.3.1 From 0990b4dae74ffca4c033bc9a691981d52439b3c1 Mon Sep 17 00:00:00 2001 From: tristan-iu Date: Sun, 21 Jun 2026 17:56:42 +0200 Subject: Don't kill launcher-spawned engine on force-unlock for non-VFS games --- src/src/processrunner.cpp | 50 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/src/src/processrunner.cpp b/src/src/processrunner.cpp index 36108b8..01ed905 100644 --- a/src/src/processrunner.cpp +++ b/src/src/processrunner.cpp @@ -595,8 +595,16 @@ DWORD exitCodeFromWaitStatus(int status) return 0; } +// killTreeOnUnlock: when the user force-unlocks/cancels the lock dialog, SIGKILL +// the launched process tree (and the wineserver) so the wineprefix/FUSE VFS can +// be torn down cleanly. This is correct for Proton games and for native games +// that still rely on the FUSE VFS (e.g. Stardew Valley). It must be FALSE for +// native, non-VFS games like OpenMW (usesVFS()==false): there is nothing to tear +// down, and killing the tree would take down a launcher-spawned engine the user +// is actively using. See OrganizerCore::managedGame()->usesVFS() at the caller. ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, - UILocker::Session* ls, const QStringList& expected) + UILocker::Session* ls, const QStringList& expected, + bool killTreeOnUnlock) { if (pid <= 0) { return ProcessRunner::Error; @@ -777,6 +785,23 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, case UILocker::ForceUnlocked: case UILocker::Cancelled: { const bool cancelled = (UILocker::Session::result() == UILocker::Cancelled); + + // The teardown below exists only to clean up the wineprefix and let the + // FUSE VFS unmount. A native, non-VFS game (OpenMW, usesVFS()==false) + // has neither, and its launcher spawns the real engine as a child the + // user is still playing — so force-unlock here must release the lock + // without killing the process tree. + if (!killTreeOnUnlock) { + log::debug("waiting for {} {} by user; non-VFS game, releasing lock " + "but leaving the process tree running", + displayPid, cancelled ? "cancelled" : "force unlocked"); + if (exitCode != nullptr) { + *exitCode = 0; + } + return cancelled ? ProcessRunner::Cancelled + : ProcessRunner::ForceUnlocked; + } + log::debug("waiting for {} {} by user, terminating", displayPid, cancelled ? "cancelled" : "force unlocked"); @@ -825,14 +850,17 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, ProcessRunner::Results waitForProcess(HANDLE initialProcess, LPDWORD exitCode, UILocker::Session* ls, - const QStringList& expected) + const QStringList& expected, + bool killTreeOnUnlock) { - return waitForPid(handleToPid(initialProcess), exitCode, ls, expected); + return waitForPid(handleToPid(initialProcess), exitCode, ls, expected, + killTreeOnUnlock); } ProcessRunner::Results waitForProcesses(const std::vector& initialProcesses, UILocker::Session* ls, - const QStringList& expected) + const QStringList& expected, + bool killTreeOnUnlock) { if (initialProcesses.empty()) { return ProcessRunner::Completed; @@ -840,7 +868,8 @@ ProcessRunner::Results waitForProcesses(const std::vector& initialProces for (HANDLE h : initialProcesses) { DWORD ignored = 0; - const auto r = waitForPid(handleToPid(h), &ignored, ls, expected); + const auto r = waitForPid(handleToPid(h), &ignored, ls, expected, + killTreeOnUnlock); if (r != ProcessRunner::Completed) { return r; } @@ -1324,9 +1353,18 @@ ProcessRunner::Results ProcessRunner::postRun() } } + // Only tear down the launched process tree on force-unlock for games that + // have a wineprefix/FUSE VFS to clean up. Native, non-VFS games (OpenMW) + // must keep running when the user releases the lock — same usesVFS() gate as + // the FUSE mount in OrganizerCore (default true = unchanged for every other + // game). + const auto* game = m_core.managedGame(); + const bool killTreeOnUnlock = (game == nullptr) || game->usesVFS(); + auto r = Error; withLock([&](auto& ls) { - r = waitForProcess(m_handle.get(), &m_exitCode, &ls, expectedExecutables); + r = waitForProcess(m_handle.get(), &m_exitCode, &ls, expectedExecutables, + killTreeOnUnlock); }); if (shouldRefresh(r)) { -- cgit v1.3.1 From e490b76e6ca5c5bbc52429a2cf16b7fe91fffc09 Mon Sep 17 00:00:00 2001 From: tristan-iu Date: Sun, 21 Jun 2026 18:00:23 +0200 Subject: Add OpenMW support --- libs/basic_games/games/game_openmw.py | 304 +++++++++++++++++++++ libs/basic_games/games/openmw_support/__init__.py | 6 + .../basic_games/games/openmw_support/openmw_cfg.py | 169 ++++++++++++ 3 files changed, 479 insertions(+) create mode 100644 libs/basic_games/games/game_openmw.py create mode 100644 libs/basic_games/games/openmw_support/__init__.py create mode 100644 libs/basic_games/games/openmw_support/openmw_cfg.py diff --git a/libs/basic_games/games/game_openmw.py b/libs/basic_games/games/game_openmw.py new file mode 100644 index 0000000..74c56b5 --- /dev/null +++ b/libs/basic_games/games/game_openmw.py @@ -0,0 +1,304 @@ +""" +game_openmw.py — Fluorine game plugin for The Elder Scrolls III: Morrowind run +under OpenMW (the native Linux engine). + +Unlike the Windows Morrowind plugin this: + - is a native Linux launch (no Proton/Wine) via isNativeLinux(); + - does NOT rely on Fluorine's FUSE VFS — OpenMW has its own VFS, so we hand it + one data= directory per active mod (in priority order) plus the load order + as content= lines, written into openmw.cfg right before launch. The FUSE + mount is skipped for this game by returning usesVFS() == False (wired in the + C++ core; harmless until then); + - keeps OpenMW-native plugins (.omwaddon/.omwgame/.omwscripts) in the load + order instead of dropping them, and routes groundcover plugins to + groundcover= lines (listed in /groundcover.txt) so they don't tank + performance as content= entries. +""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +from PyQt6.QtCore import QDir, QFileInfo, qInfo, qWarning + +import mobase + +from ..basic_game import BasicGame +from .openmw_support.openmw_cfg import write_openmw_cfg + +_FLATPAK_ID = "org.openmw.OpenMW" + +# openmw.cfg candidates, Flatpak first (matches Amethyst's detection order). +_FLATPAK_CFG = ( + Path.home() / ".var" / "app" / _FLATPAK_ID / "config" / "openmw" / "openmw.cfg" +) + + +def _native_cfg() -> Path: + xdg = os.environ.get("XDG_CONFIG_HOME") + base = Path(xdg) if xdg else Path.home() / ".config" + return base / "openmw" / "openmw.cfg" + + +def _flatpak_installed() -> bool: + return (Path.home() / ".var" / "app" / _FLATPAK_ID).is_dir() + + +def _detect_openmw_cfg(prefer_flatpak: bool) -> Path | None: + """Return the openmw.cfg to manage, or None if none exists yet.""" + candidates = ( + [_FLATPAK_CFG, _native_cfg()] + if prefer_flatpak + else [_native_cfg(), _FLATPAK_CFG] + ) + for cfg in candidates: + if cfg.is_file(): + return cfg + return None + + +# Directories/extensions that mark a folder as valid OpenMW/Morrowind mod data. +_VALID_DIRS = { + "bookart", "fonts", "icons", "meshes", "music", "shaders", "sound", + "splash", "textures", "video", "mwse", "distantland", + "l10n", "mygui", "scripts", # OpenMW-native (Lua / localisation / GUI) +} +_PLUGIN_EXTS = {".esp", ".esm", ".omwaddon", ".omwgame", ".omwscripts"} + + +class OpenMWModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for entry in filetree: + if entry.isDir(): + if entry.name().lower() in _VALID_DIRS: + return mobase.ModDataChecker.VALID + else: + if Path(entry.name().lower()).suffix in _PLUGIN_EXTS: + return mobase.ModDataChecker.VALID + return mobase.ModDataChecker.INVALID + + +class OpenMWGame(BasicGame): + Name = "OpenMW Support Plugin" + Author = "Fluorine OpenMW contributors" + Version = "0.1.0" + + GameName = "Morrowind (OpenMW)" + GameShortName = "morrowind" + GameNexusName = "morrowind" + GameNexusId = 100 + GameSteamId = 22320 + # Detection only — the Steam Morrowind install owns Morrowind.exe. We never + # launch it; executables() returns the native OpenMW binary instead and + # isNativeLinux() keeps Proton out of the picture. + GameBinary = "Morrowind.exe" + GameLauncher = "openmw-launcher" + GameDataPath = "Data Files" + GameSaveExtension = "omwsave" + GameSupportURL = "https://openmw.org/" + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(OpenMWModDataChecker()) + organizer.onAboutToRun(self._export_openmw_cfg) + return True + + # OpenMW is always a native Linux launch — never Proton/Wine. + def isNativeLinux(self) -> bool: + return True + + # OpenMW manages its own VFS via data= dirs, so Fluorine must NOT FUSE-mount + # over Data Files for this game. Honoured by the C++ core once usesVFS() is + # wired there; defining it here makes the plugin forward-compatible. + def usesVFS(self) -> bool: + return False + + def documentsDirectory(self) -> QDir: + return self.gameDirectory() + + def executables(self) -> list[mobase.ExecutableInfo]: + out: list[mobase.ExecutableInfo] = [] + flatpak = shutil.which("flatpak") + if _flatpak_installed() and flatpak: + out.append( + mobase.ExecutableInfo("OpenMW (Flatpak)", QFileInfo(flatpak)) + .withArgument("run") + .withArgument(_FLATPAK_ID) + ) + out.append( + mobase.ExecutableInfo("OpenMW Launcher (Flatpak)", QFileInfo(flatpak)) + .withArgument("run") + .withArgument("--command=openmw-launcher") + .withArgument(_FLATPAK_ID) + ) + launcher = shutil.which("openmw-launcher") + if launcher: + out.append(mobase.ExecutableInfo("OpenMW Launcher", QFileInfo(launcher))) + openmw = shutil.which("openmw") + if openmw: + out.append(mobase.ExecutableInfo("OpenMW", QFileInfo(openmw))) + return out + + # ------------------------------------------------------------------ + # openmw.cfg export (runs on every launch via onAboutToRun) + # ------------------------------------------------------------------ + + def _is_openmw_binary(self, app_name: str) -> bool: + base = Path(app_name).name.lower() + # 'flatpak' here is our OpenMW launcher (we only register it for OpenMW). + return base in {"openmw", "openmw-launcher", "flatpak"} + + def _read_groundcover_txt(self) -> list[str]: + """Plugins the user has flagged as groundcover, from /groundcover.txt.""" + try: + profile_dir = Path(self._organizer.profile().absolutePath()) + except Exception: + return [] + gc_file = profile_dir / "groundcover.txt" + if not gc_file.is_file(): + return [] + out: list[str] = [] + for raw in gc_file.read_text(encoding="utf-8", errors="replace").splitlines(): + line = raw.strip().lstrip("*").strip() + if line and not line.startswith("#"): + out.append(line) + return out + + def _export_openmw_cfg(self, app_name: str) -> bool: + # onAboutToRun fires for every launched program; only act for OpenMW. + if not self._is_openmw_binary(app_name): + return True + try: + organizer = self._organizer + game_dir = Path(self.gameDirectory().absolutePath()) + data_files = game_dir / "Data Files" + + cfg = _detect_openmw_cfg(prefer_flatpak="flatpak" in Path(app_name).name.lower()) + if cfg is None: + qWarning( + "OpenMW: no openmw.cfg found. Run openmw-launcher once to " + "create it, then mods will be applied on the next launch." + ) + return True + + modlist = organizer.modList() + + # data=: vanilla Data Files first (lowest prio), then each active mod + # in profile-priority order, then Overwrite last (wins). Mirrors the + # ordering of AnyOldName3's MO2 exporter. + data_dirs: list[Path] = [data_files] + bsa_archives: list[str] = [] + # content= is built by scanning each active mod's directory for plugin + # files, NOT from the core plugin list. The core plugin list (right + # pane) is empty for this game: BasicGame returns the default + # loadOrderMechanism()==None, so pluginlist.cpp force-disables every + # esp/esm (loadOrder == -1) and there is no Plugins tab. So we are the + # source of truth. Tiers follow OpenMW convention: masters + # (.esm/.omwgame) before plugins (.esp/.omwaddon), and .omwscripts + # (Lua manifests, no records) last. Within a tier we keep mod-priority + # order (and alphabetical within a single mod). The user refines the + # final load order in OpenMW's own launcher. + masters: list[str] = [] # .esm / .omwgame + normal_plugins: list[str] = [] # .esp / .omwaddon + omw_scripts: list[str] = [] # .omwscripts + + def _scan_mod(path: Path) -> None: + data_dirs.append(path) + try: + entries = sorted(path.iterdir(), key=lambda p: p.name.lower()) + except OSError: + return + for f in entries: + if not f.is_file(): + continue + low = f.name.lower() + # Skip Kezyma "OpenMW Player" stub esps: empty TES3 esps named + # .omwaddon.esp / .omwscripts.esp that some MO2<->OpenMW + # tools drop next to the real .omwaddon/.omwscripts purely so the + # entry shows up in MO2's plugin list. The real file is scanned + # separately; loading the empty stub as content= is at best useless + # and at worst aborts OpenMW ("sub-record incomplete"). + if low.endswith((".omwaddon.esp", ".omwscripts.esp", ".omwgame.esp")): + continue + ext = f.suffix.lower() + if ext in {".esm", ".omwgame"}: + masters.append(f.name) + elif ext in {".esp", ".omwaddon"}: + normal_plugins.append(f.name) + elif ext == ".omwscripts": + omw_scripts.append(f.name) + elif ext == ".bsa": + bsa_archives.append(f.name) + + for name in modlist.allModsByProfilePriority(): + if name == "Overwrite": + continue + try: + if not (modlist.state(name) & mobase.ModState.ACTIVE): + continue + mod = modlist.getMod(name) + except Exception: + continue + if mod is None: + continue + mod_path = Path(mod.absolutePath()) + if mod_path.is_dir(): + _scan_mod(mod_path) + + try: + overwrite = modlist.getMod("Overwrite") + if overwrite is not None: + ov_path = Path(overwrite.absolutePath()) + if ov_path.is_dir() and any(ov_path.iterdir()): + _scan_mod(ov_path) + except Exception: + pass + + # content=: masters → normal plugins → Lua scripts (see _scan_mod), + # minus any the user routed to groundcover. build_managed_block + # prepends the vanilla masters and dedups case-insensitively, so a mod + # re-shipping a vanilla esm (or two mods sharing a plugin name) won't + # produce duplicate content= lines. + all_plugins = masters + normal_plugins + omw_scripts + plugin_lower = {p.lower() for p in all_plugins} + + groundcover = self._read_groundcover_txt() + gc_lower = {g.lower() for g in groundcover} + + content = [p for p in all_plugins if p.lower() not in gc_lower] + # Only emit groundcover= for plugins that are actually present/active. + active_groundcover = [g for g in groundcover if g.lower() in plugin_lower] + + # Helpful, non-destructive nudge: flag likely groundcover plugins the + # user hasn't listed yet (we never reroute automatically). + for p in masters + normal_plugins: + low = p.lower() + if low not in gc_lower and ("grass" in low or "groundcover" in low): + qInfo( + f"OpenMW: '{p}' looks like a groundcover plugin. If it is, " + f"add it to {Path(self._organizer.profile().absolutePath()) / 'groundcover.txt'} " + "so it loads as groundcover= (better performance)." + ) + + write_openmw_cfg( + cfg, + data_dirs=data_dirs, + content_plugins=content, + groundcover_plugins=active_groundcover, + fallback_archives=bsa_archives, + log_fn=lambda m: qInfo("OpenMW:" + m), + ) + qInfo( + f"OpenMW: wrote {len(data_dirs)} data dir(s) and " + f"{len(content)} content plugin(s) to {cfg}." + ) + except Exception as e: # never block a launch on export failure + qWarning(f"OpenMW: openmw.cfg export failed: {e}") + return True diff --git a/libs/basic_games/games/openmw_support/__init__.py b/libs/basic_games/games/openmw_support/__init__.py new file mode 100644 index 0000000..89852b1 --- /dev/null +++ b/libs/basic_games/games/openmw_support/__init__.py @@ -0,0 +1,6 @@ +# Support package for the OpenMW (Morrowind) game plugin. +# +# This is a plain subpackage, not an auto-discovered game module: basic_games' +# createPlugins() only imports top-level games/*.py files and games/**/plugins/ +# __init__.py entry points, so the helpers here are loaded only when +# game_openmw.py imports them. diff --git a/libs/basic_games/games/openmw_support/openmw_cfg.py b/libs/basic_games/games/openmw_support/openmw_cfg.py new file mode 100644 index 0000000..d6d0d01 --- /dev/null +++ b/libs/basic_games/games/openmw_support/openmw_cfg.py @@ -0,0 +1,169 @@ +""" +openmw_cfg.py — generate the managed block of an ``openmw.cfg``. + +OpenMW does not use a separate VFS injector: it reads its mods directly from an +ordered list of ``data=`` directories and loads plugins in the order of the +``content=`` lines. This module rewrites *only* the keys we own, leaving every +other line (engine settings, comments, unrelated keys) untouched: + + data= asset/plugin search dirs; LATER entries override earlier + content= ordered plugin load list (.esp/.esm/.omwaddon/.omwscripts) + groundcover= grass/groundcover plugins (kept out of content= for perf) + fallback-archive= .bsa archives; later entries override earlier + +It is deliberately pure standard library (no Qt / mobase) so it can be unit +tested on its own and reused by the OpenMW game plugin. The caller decides what +goes into ``data_dirs`` — a single merged dir (Fluorine FUSE) or one entry per +mod (native OpenMW VFS) — this module stays agnostic about that choice. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Iterable + +# Morrowind masters, in canonical load order. +# +# Do NOT list ``builtin.omwscripts`` here. It is OpenMW's built-in Lua bundle, +# shipped inside the engine's ``resources/vfs-mw`` and loaded *implicitly* by the +# engine itself. If we also emit ``content=builtin.omwscripts`` it ends up +# specified twice across the config chain and OpenMW aborts on startup with +# "Content file specified more than once: builtin.omwscripts. Aborting...". +VANILLA_MASTERS: list[str] = [ + "Morrowind.esm", + "Tribunal.esm", + "Bloodmoon.esm", +] + +# Vanilla BSAs, always emitted as the lowest-priority fallback-archive entries. +VANILLA_BSAS: list[str] = [ + "Morrowind.bsa", + "Tribunal.bsa", + "Bloodmoon.bsa", +] + +# Keys this module fully owns (lowercase, exact match). +_MANAGED_KEYS = frozenset({"data", "content", "groundcover", "fallback-archive"}) + + +def escape_data_path(path: str) -> str: + """Quote/escape a path for a ``data=`` line. + + openmw.cfg uses boost::filesystem quoting: ``&`` and ``"`` are escaped with a + leading ``&`` and the whole value is wrapped in double quotes. This matches + AnyOldName3's MO2 exporter so paths containing spaces or quotes round-trip. + """ + out = ['"'] + for ch in path: + if ch in ("&", '"'): + out.append("&") + out.append(ch) + out.append('"') + return "".join(out) + + +def _is_managed(line: str) -> bool: + s = line.strip() + if not s or s.startswith("#") or "=" not in s: + return False + return s.split("=", 1)[0].strip().lower() in _MANAGED_KEYS + + +def _preserve_non_managed(cfg_path: Path) -> list[str]: + """Return existing cfg lines minus the keys we manage, trailing blanks trimmed.""" + kept: list[str] = [] + if cfg_path.is_file(): + for raw in cfg_path.read_text(encoding="utf-8", errors="replace").splitlines(): + if not _is_managed(raw): + kept.append(raw) + while kept and not kept[-1].strip(): + kept.pop() + return kept + + +def _dedup_preserving_order( + base: Iterable[str], extra: Iterable[str] +) -> list[str]: + """Concatenate ``base`` then ``extra``, dropping case-insensitive duplicates.""" + result: list[str] = [] + seen: set[str] = set() + for item in (*base, *extra): + key = item.lower() + if key not in seen: + seen.add(key) + result.append(item) + return result + + +def build_managed_block( + data_dirs: Iterable[Path | str], + content_plugins: Iterable[str], + groundcover_plugins: Iterable[str] = (), + fallback_archives: Iterable[str] = (), + *, + vanilla_masters: Iterable[str] = VANILLA_MASTERS, + vanilla_bsas: Iterable[str] = VANILLA_BSAS, +) -> list[str]: + """Build the managed cfg lines (no I/O), in the order OpenMW expects.""" + content = _dedup_preserving_order(vanilla_masters, content_plugins) + archives = _dedup_preserving_order(vanilla_bsas, fallback_archives) + + block: list[str] = [""] # blank separator from the preserved section + block += [f"data={escape_data_path(str(d))}" for d in data_dirs] + block += [f"content={c}" for c in content] + block += [f"groundcover={g}" for g in groundcover_plugins] + block += [f"fallback-archive={a}" for a in archives] + return block + + +def write_openmw_cfg( + cfg_path: Path, + data_dirs: Iterable[Path | str], + content_plugins: Iterable[str], + groundcover_plugins: Iterable[str] = (), + fallback_archives: Iterable[str] = (), + *, + vanilla_masters: Iterable[str] = VANILLA_MASTERS, + vanilla_bsas: Iterable[str] = VANILLA_BSAS, + log_fn=None, +) -> None: + """Rewrite the managed data=/content=/groundcover=/fallback-archive= block.""" + _log = log_fn or (lambda _: None) + data_dirs = list(data_dirs) # consumed twice (block + log); avoid generator exhaustion + kept = _preserve_non_managed(cfg_path) + block = build_managed_block( + data_dirs, + content_plugins, + groundcover_plugins, + fallback_archives, + vanilla_masters=vanilla_masters, + vanilla_bsas=vanilla_bsas, + ) + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text("\n".join(kept + block) + "\n", encoding="utf-8") + _log(f" Wrote openmw.cfg: {len(data_dirs)} data dir(s) to {cfg_path}.") + + +def restore_openmw_cfg( + cfg_path: Path, + data_dirs: Iterable[Path | str], + *, + vanilla_masters: Iterable[str] = VANILLA_MASTERS, + vanilla_bsas: Iterable[str] = VANILLA_BSAS, + log_fn=None, +) -> None: + """Reset the managed block to vanilla-only (used on uninstall / 'clear').""" + _log = log_fn or (lambda _: None) + if not cfg_path.is_file(): + return + kept = _preserve_non_managed(cfg_path) + block = build_managed_block( + data_dirs, + (), + (), + (), + vanilla_masters=vanilla_masters, + vanilla_bsas=vanilla_bsas, + ) + cfg_path.write_text("\n".join(kept + block) + "\n", encoding="utf-8") + _log(f" Restored openmw.cfg to vanilla content at {cfg_path}.") -- cgit v1.3.1