aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--libs/basic_games/games/game_openmw.py304
-rw-r--r--libs/basic_games/games/openmw_support/__init__.py6
-rw-r--r--libs/basic_games/games/openmw_support/openmw_cfg.py169
-rw-r--r--libs/plugin_python/src/mobase/wrappers/pyplugins.cpp1
-rw-r--r--libs/plugin_python/src/mobase/wrappers/pyplugins.h4
-rw-r--r--libs/uibase/include/uibase/iplugingame.h13
-rw-r--r--src/src/organizercore.cpp19
-rw-r--r--src/src/processrunner.cpp50
-rw-r--r--src/src/protonlauncher.cpp56
9 files changed, 595 insertions, 27 deletions
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 <profile>/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 <profile>/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
+ # <name>.omwaddon.esp / <name>.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}.")
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
@@ -154,6 +154,19 @@ public:
/**
* 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()
+ *
* @return an icon for this game
*/
virtual QIcon gameIcon() const = 0;
diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp
index 1ab4166..107a607 100644
--- a/src/src/organizercore.cpp
+++ b/src/src/organizercore.cpp
@@ -963,7 +963,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()
@@ -2841,8 +2848,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;
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<HANDLE>& 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<HANDLE>& 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)) {
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<int, QProcess::ExitStatus>::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()