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
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
|
# -*- encoding: utf-8 -*-
from __future__ import annotations
"""
Root Builder plugin for Mod Organizer 2 (Linux port).
Deploys files from mod Root/ subdirectories to the game's root directory.
Supports copy (with reflink/CoW) and symlink modes, with automatic
deploy/clear on game launch/close.
"""
import json
import os
import shutil
import subprocess
import mobase
from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
)
MANIFEST_NAME = ".rootbuilder_manifest.json"
BACKUP_DIR_NAME = ".rootbuilder_backup"
def _find_root_dir(mod_path: str) -> str | None:
"""Find a 'Root' subdirectory (case-insensitive) inside a mod."""
try:
for entry in os.scandir(mod_path):
if entry.is_dir() and entry.name.lower() == "root":
return entry.path
except OSError:
pass
return None
def _walk_files(root_dir: str):
"""Yield all file paths under root_dir recursively."""
for dirpath, _dirnames, filenames in os.walk(root_dir):
for name in filenames:
yield os.path.join(dirpath, name)
def _reflink_copy(src: str, dst: str):
"""Copy with reflink (CoW) if supported, fallback to regular copy."""
try:
subprocess.run(
["cp", "--reflink=auto", "--", src, dst],
check=True,
capture_output=True,
)
except (subprocess.CalledProcessError, FileNotFoundError):
shutil.copy2(src, dst)
def _manifest_path(game_dir: str) -> str:
return os.path.join(game_dir, MANIFEST_NAME)
def _load_manifest(game_dir: str) -> dict | None:
path = _manifest_path(game_dir)
if not os.path.isfile(path):
return None
try:
with open(path, "r") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return None
def _save_manifest(game_dir: str, manifest: dict):
path = _manifest_path(game_dir)
with open(path, "w") as f:
json.dump(manifest, f, indent=2)
def _remove_manifest(game_dir: str):
path = _manifest_path(game_dir)
if os.path.isfile(path):
os.remove(path)
def _cleanup_empty_dirs(game_dir: str, deployed: list[str]):
"""Remove empty directories left behind after clearing deployed files."""
dirs_to_check = set()
for path in deployed:
parent = os.path.dirname(path)
while parent and parent != game_dir and not os.path.samefile(parent, game_dir):
dirs_to_check.add(parent)
parent = os.path.dirname(parent)
for d in sorted(dirs_to_check, key=len, reverse=True):
try:
if os.path.isdir(d) and not os.listdir(d):
os.rmdir(d)
except OSError:
pass
class RootBuilderDialog(QDialog):
"""Small settings/control dialog shown from the Tools menu."""
def __init__(self, organizer: mobase.IOrganizer, build_fn, clear_fn, parent=None):
super().__init__(parent)
self._organizer = organizer
self._build_fn = build_fn
self._clear_fn = clear_fn
self._plugin_name = "Root Builder"
self.setWindowTitle("Root Builder")
self.resize(350, 220)
layout = QVBoxLayout(self)
desc = QLabel("Deploys files from mod Root/ folders to the game directory.")
desc.setWordWrap(True)
layout.addWidget(desc)
# Enable checkbox
self._enableCheck = QCheckBox("Auto-deploy on game launch")
self._enableCheck.setChecked(
bool(organizer.pluginSetting(self._plugin_name, "enabled"))
)
layout.addWidget(self._enableCheck)
# Mode selector
mode_layout = QHBoxLayout()
mode_layout.addWidget(QLabel("Deploy mode:"))
self._modeCombo = QComboBox()
self._modeCombo.addItems(["copy", "link"])
current = organizer.pluginSetting(self._plugin_name, "mode")
self._modeCombo.setCurrentText(current if current else "copy")
mode_layout.addWidget(self._modeCombo)
layout.addLayout(mode_layout)
# Manual build/clear buttons
btn_layout = QHBoxLayout()
build_btn = QPushButton("Build Now")
build_btn.clicked.connect(self._on_build)
clear_btn = QPushButton("Clear Now")
clear_btn.clicked.connect(self._on_clear)
btn_layout.addWidget(build_btn)
btn_layout.addWidget(clear_btn)
layout.addLayout(btn_layout)
# Status label
self._status = QLabel("")
layout.addWidget(self._status)
# Close button
close_btn = QPushButton("Close")
close_btn.clicked.connect(self.accept)
layout.addWidget(close_btn)
def _save_settings(self):
self._organizer.setPluginSetting(
self._plugin_name, "enabled", self._enableCheck.isChecked()
)
self._organizer.setPluginSetting(
self._plugin_name, "mode", self._modeCombo.currentText()
)
def _on_build(self):
self._save_settings()
count = self._build_fn()
self._status.setText(f"Deployed {count} file(s).")
def _on_clear(self):
self._save_settings()
count = self._clear_fn()
self._status.setText(f"Cleared {count} file(s).")
def accept(self):
self._save_settings()
super().accept()
class RootBuilder(mobase.IPluginTool):
_organizer: mobase.IOrganizer
def __init__(self):
super().__init__()
self.__parentWidget = None
# --- IPlugin ---
def init(self, organizer: mobase.IOrganizer) -> bool:
self._organizer = organizer
self._check_third_party_rootbuilder()
organizer.onAboutToRun(self._on_about_to_run)
organizer.onFinishedRun(self._on_finished_run)
return True
def _check_third_party_rootbuilder(self):
"""Move any third-party Root Builder plugins into DisabledPlugins/."""
plugins_dir = os.path.dirname(os.path.abspath(__file__))
disabled_dir = os.path.join(os.path.dirname(plugins_dir), "DisabledPlugins")
my_file = os.path.basename(__file__)
# Collect conflicts first, then move (don't modify dir during iteration)
conflicts = []
for entry in os.scandir(plugins_dir):
# Kezyma's standard install: plugins/rootbuilder/
if entry.is_dir() and entry.name.lower() == "rootbuilder":
conflicts.append((entry.name, entry.path))
# Other rootbuilder*.py files that aren't us
elif (
entry.is_file()
and entry.name.lower().startswith("rootbuilder")
and entry.name.lower().endswith(".py")
and entry.name != my_file
):
conflicts.append((entry.name, entry.path))
for name, path in conflicts:
dst = os.path.join(disabled_dir, name)
try:
os.makedirs(disabled_dir, exist_ok=True)
shutil.move(path, dst)
mobase.log(
mobase.LogLevel.INFO,
f"Root Builder: moved incompatible third-party plugin "
f"'{name}' to DisabledPlugins/. "
f"It uses Windows-only USVFS and cannot work on Linux.",
)
except OSError as e:
mobase.log(
mobase.LogLevel.WARNING,
f"Root Builder: failed to move third-party plugin "
f"'{name}' to DisabledPlugins/: {e}",
)
def name(self) -> str:
return "Root Builder"
def localizedName(self) -> str:
return "Root Builder"
def author(self) -> str:
return "Fluorine Manager"
def description(self) -> str:
return (
"Deploys mod files from Root/ subdirectories to the game's root directory. "
"Supports copy and symlink modes with auto-deploy on launch."
)
def version(self) -> mobase.VersionInfo:
return mobase.VersionInfo(1, 0, 0)
def enabledByDefault(self) -> bool:
return False
def settings(self) -> list[mobase.PluginSetting]:
return [
mobase.PluginSetting("mode", "Deploy mode: copy or link", "copy"),
mobase.PluginSetting(
"enabled", "Auto-deploy root files on launch", True
),
]
# --- IPluginTool ---
def displayName(self) -> str:
return "Root Builder"
def tooltip(self) -> str:
return "Deploy mod Root/ files to the game directory"
def icon(self) -> QIcon:
return QIcon()
def setParentWidget(self, widget):
self.__parentWidget = widget
def display(self):
dialog = RootBuilderDialog(
self._organizer, self._build, self._clear, self.__parentWidget
)
dialog.exec()
# --- Hooks ---
def _on_about_to_run(self, executable: str) -> bool:
if self._organizer.pluginSetting(self.name(), "enabled"):
self._build()
return True
def _on_finished_run(self, executable: str, exit_code: int):
if self._organizer.pluginSetting(self.name(), "enabled"):
self._clear()
# --- Build / Clear ---
def _build(self) -> int:
"""Deploy root files from all active mods. Returns number of files deployed."""
game_dir = self._organizer.managedGame().gameDirectory().absolutePath()
mod_list = self._organizer.modList()
mods = mod_list.allModsByProfilePriority()
mode = self._organizer.pluginSetting(self.name(), "mode") or "copy"
# Clear any previous deployment first
if _load_manifest(game_dir) is not None:
self._clear()
manifest = {"deployed": [], "backups": {}}
backup_dir = os.path.join(game_dir, BACKUP_DIR_NAME)
deployed_set = set()
for mod_name in mods:
if not (mod_list.state(mod_name) & mobase.ModState.ACTIVE):
continue
mod = mod_list.getMod(mod_name)
if mod is None:
continue
if mod.isSeparator() or mod.isBackup() or mod.isForeign():
continue
mod_path = mod.absolutePath()
root_dir = _find_root_dir(mod_path)
if root_dir is None:
continue
for src_file in _walk_files(root_dir):
rel = os.path.relpath(src_file, root_dir)
dst = os.path.join(game_dir, rel)
# Backup existing file if not already deployed by us
if os.path.exists(dst) and dst not in deployed_set:
bak = os.path.join(backup_dir, rel)
os.makedirs(os.path.dirname(bak), exist_ok=True)
shutil.move(dst, bak)
manifest["backups"][dst] = bak
os.makedirs(os.path.dirname(dst), exist_ok=True)
if os.path.lexists(dst):
os.remove(dst)
# In link mode, .exe and .dll must be copied — Wine/Proton
# resolves a symlinked exe's path to the target, so the
# process can't find sibling files in the game directory.
ext = os.path.splitext(src_file)[1].lower()
if mode == "link" and ext not in (".exe", ".dll"):
os.symlink(src_file, dst)
else:
_reflink_copy(src_file, dst)
if dst not in deployed_set:
manifest["deployed"].append(dst)
deployed_set.add(dst)
_save_manifest(game_dir, manifest)
return len(manifest["deployed"])
def _clear(self) -> int:
"""Remove deployed files and restore backups. Returns count of removed files."""
game_dir = self._organizer.managedGame().gameDirectory().absolutePath()
manifest = _load_manifest(game_dir)
if manifest is None:
return 0
count = 0
# Remove deployed files
for path in manifest["deployed"]:
if os.path.lexists(path):
os.remove(path)
count += 1
# Restore backups
for dst, bak in manifest["backups"].items():
if os.path.exists(bak):
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.move(bak, dst)
# Clean up backup dir
backup_dir = os.path.join(game_dir, BACKUP_DIR_NAME)
if os.path.isdir(backup_dir):
shutil.rmtree(backup_dir, ignore_errors=True)
_remove_manifest(game_dir)
_cleanup_empty_dirs(game_dir, manifest["deployed"])
return count
def createPlugin() -> mobase.IPlugin:
return RootBuilder()
|