1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
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
|