summarylogtreecommitdiffstats
path: root/fluorine-manager-order-openmw-content-by-loadorder.patch
blob: 15cda1618ecc7d88108dc21a700d4528db62bd41 (plain)
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
From cd6237f448314d72fb4b2631d04a872f99bcd4a3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Niccol=C3=B2=20Belli?= <niccolo.belli@linuxsystems.it>
Date: Tue, 7 Jul 2026 16:37:11 +0200
Subject: [PATCH 1/2] Order OpenMW content= by loadorder.txt

---
 libs/basic_games/games/game_openmw.py | 31 +++++++++++++++++++++++++--
 1 file changed, 29 insertions(+), 2 deletions(-)

diff --git a/libs/basic_games/games/game_openmw.py b/libs/basic_games/games/game_openmw.py
index 74c56b5..832ca9b 100644
--- a/libs/basic_games/games/game_openmw.py
+++ b/libs/basic_games/games/game_openmw.py
@@ -171,6 +171,22 @@ def _read_groundcover_txt(self) -> list[str]:
                 out.append(line)
         return out
 
+    def _read_loadorder_txt(self) -> list[str]:
+        """Plugin load order from <profile>/loadorder.txt (MO2 right-pane order)."""
+        try:
+            profile_dir = Path(self._organizer.profile().absolutePath())
+        except Exception:
+            return []
+        lo_file = profile_dir / "loadorder.txt"
+        if not lo_file.is_file():
+            return []
+        out: list[str] = []
+        for raw in lo_file.read_text(encoding="utf-8", errors="replace").splitlines():
+            line = raw.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):
@@ -203,8 +219,10 @@ def _export_openmw_cfg(self, app_name: str) -> bool:
             # 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.
+            # order (and alphabetical within a single mod). The tier order is the
+            # fallback; when <profile>/loadorder.txt exists it is the authoritative
+            # order and we stable-sort by it (unranked OpenMW-native plugins stay
+            # after the ranked ones, keeping their tier order).
             masters: list[str] = []         # .esm / .omwgame
             normal_plugins: list[str] = []  # .esp / .omwaddon
             omw_scripts: list[str] = []     # .omwscripts
@@ -267,6 +285,15 @@ def _scan_mod(path: Path) -> None:
             # 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
+            loadorder = self._read_loadorder_txt()
+            if loadorder:
+                rank = {name.lower(): i for i, name in enumerate(loadorder)}
+                # Stable sort: ranked plugins by loadorder.txt position, unranked
+                # (.omwaddon/.omwscripts/.omwgame not in MO2's list) keep their
+                # current order after all ranked ones.
+                all_plugins.sort(
+                    key=lambda p: rank.get(p.lower(), len(rank))
+                )
             plugin_lower = {p.lower() for p in all_plugins}
 
             groundcover = self._read_groundcover_txt()

From b4aee081a9182a856fb42df6de2eb9489351f1f2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Niccol=C3=B2=20Belli?= <niccolo.belli@linuxsystems.it>
Date: Thu, 9 Jul 2026 21:32:58 +0200
Subject: [PATCH 2/2] Map Kezyma stub ESP names to real plugins in loadorder
 sort

---
 libs/basic_games/games/game_openmw.py | 41 +++++++++++++++++++++++++--
 1 file changed, 38 insertions(+), 3 deletions(-)

diff --git a/libs/basic_games/games/game_openmw.py b/libs/basic_games/games/game_openmw.py
index 832ca9b..1248c4a 100644
--- a/libs/basic_games/games/game_openmw.py
+++ b/libs/basic_games/games/game_openmw.py
@@ -67,6 +67,31 @@ def _detect_openmw_cfg(prefer_flatpak: bool) -> Path | None:
 }
 _PLUGIN_EXTS = {".esp", ".esm", ".omwaddon", ".omwgame", ".omwscripts"}
 
+# Kezyma's "OpenMW Player" drops an empty TES3 stub ESP next to each
+# OpenMW-native plugin so MO2's right pane can list and order it. The stub is
+# named "<plugin>.esp" (e.g. "Sun's Dusk.omwaddon.esp" for the real
+# "Sun's Dusk.omwaddon"). MO2's loadorder.txt therefore records STUB names, but
+# we emit the real files (_scan_mod skips the stubs), so a stub's rank must be
+# mapped onto the real name when sorting content= — otherwise every
+# .omwaddon/.omwscripts/.omwgame plugin is unranked and the content= sort
+# ignores master-before-dependent order (e.g. SDServiceRefusal.omwaddon before
+# its parent Sun's Dusk.omwaddon, which makes OpenMW abort on launch).
+_KEZYMA_STUB_SUFFIXES = (".omwaddon.esp", ".omwscripts.esp", ".omwgame.esp")
+
+
+def _destub_plugin_name(name: str) -> str:
+    """Return the real OpenMW-native plugin name for a Kezyma stub, else ``name``.
+
+    Strips the trailing ``.esp`` wrapper from names like
+    ``Sun's Dusk.omwaddon.esp`` -> ``Sun's Dusk.omwaddon``. Real .esp/.esm plugins
+    (no OpenMW-native stem) and names that are already real pass through
+    unchanged. The suffix check is case-insensitive; the returned name
+    preserves the original casing of the stem.
+    """
+    if name.lower().endswith(_KEZYMA_STUB_SUFFIXES):
+        return name[:-4]  # strip the trailing ".esp" wrapper
+    return name
+
 
 class OpenMWModDataChecker(mobase.ModDataChecker):
     def __init__(self):
@@ -172,7 +197,17 @@ def _read_groundcover_txt(self) -> list[str]:
         return out
 
     def _read_loadorder_txt(self) -> list[str]:
-        """Plugin load order from <profile>/loadorder.txt (MO2 right-pane order)."""
+        """Plugin load order from <profile>/loadorder.txt (MO2 right-pane order).
+
+        loadorder.txt records Kezyma stub names for OpenMW-native plugins (e.g.
+        ``Sun's Dusk.omwaddon.esp``), but we emit the real files (the stubs are
+        skipped in _scan_mod). Map each entry through _destub_plugin_name so the
+        returned names match the content= plugins we sort against — otherwise
+        every .omwaddon/.omwscripts/.omwgame plugin is unranked and the sort
+        falls back to scan order, ignoring master-before-dependent ordering
+        (e.g. SDServiceRefusal.omwaddon before its parent Sun's Dusk.omwaddon,
+        which makes OpenMW abort on launch).
+        """
         try:
             profile_dir = Path(self._organizer.profile().absolutePath())
         except Exception:
@@ -184,7 +219,7 @@ def _read_loadorder_txt(self) -> list[str]:
         for raw in lo_file.read_text(encoding="utf-8", errors="replace").splitlines():
             line = raw.strip()
             if line and not line.startswith("#"):
-                out.append(line)
+                out.append(_destub_plugin_name(line))
         return out
 
     def _export_openmw_cfg(self, app_name: str) -> bool:
@@ -243,7 +278,7 @@ def _scan_mod(path: Path) -> None:
                     # 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")):
+                    if low.endswith(_KEZYMA_STUB_SUFFIXES):
                         continue
                     ext = f.suffix.lower()
                     if ext in {".esm", ".omwgame"}: