diff options
| author | SulfurNitride <lukew19@proton.me> | 2026-06-24 22:37:19 -0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-24 22:37:19 -0500 |
| commit | e4fefc7f186a77174c93d6894b5c9252785b98cc (patch) | |
| tree | 1cfd7d2b23cfeb8b8792ea93621600013974527f /libs | |
| parent | 1be46fbb193010d6a5d1ea79167eca08a99e8d4c (diff) | |
| parent | e490b76e6ca5c5bbc52429a2cf16b7fe91fffc09 (diff) | |
Merge pull request #108 from tristan-iu/openmw-support
OpenMW support
Diffstat (limited to 'libs')
| -rw-r--r-- | libs/basic_games/games/game_openmw.py | 304 | ||||
| -rw-r--r-- | libs/basic_games/games/openmw_support/__init__.py | 6 | ||||
| -rw-r--r-- | libs/basic_games/games/openmw_support/openmw_cfg.py | 169 | ||||
| -rw-r--r-- | libs/plugin_python/src/mobase/wrappers/pyplugins.cpp | 1 | ||||
| -rw-r--r-- | libs/plugin_python/src/mobase/wrappers/pyplugins.h | 4 | ||||
| -rw-r--r-- | libs/uibase/include/uibase/iplugingame.h | 13 |
6 files changed, 497 insertions, 0 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; |
