diff options
Diffstat (limited to 'libs/basic_games/games/game_openmw.py')
| -rw-r--r-- | libs/basic_games/games/game_openmw.py | 304 |
1 files changed, 304 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 |
