Skip to content

Commit c490db4

Browse files
committed
Add Message on startup to convert RootBuilder mods
1 parent fdce3f4 commit c490db4

1 file changed

Lines changed: 163 additions & 5 deletions

File tree

games/game_cyberpunk2077.py

Lines changed: 163 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,23 @@
22
import json
33
import re
44
import shutil
5+
import tempfile
6+
import textwrap
57
from collections import Counter
68
from collections.abc import Iterable
79
from dataclasses import dataclass
810
from pathlib import Path
911
from typing import Any, Literal, TypeVar
1012

1113
import mobase
12-
from PyQt6.QtCore import QDateTime, QDir, qCritical, qInfo, qWarning
14+
from PyQt6.QtCore import QDateTime, QDir, Qt, qCritical, qInfo, qWarning
15+
from PyQt6.QtWidgets import (
16+
QCheckBox,
17+
QMainWindow,
18+
QMessageBox,
19+
QProgressDialog,
20+
QWidget,
21+
)
1322

1423
from ..basic_features import BasicLocalSavegames, BasicModDataChecker, GlobPatterns
1524
from ..basic_features.basic_save_game_info import (
@@ -207,6 +216,9 @@ class Cyberpunk2077Game(BasicGame):
207216
_redmod_deploy_args = "deploy -reportProgress"
208217
"""Deploy arguments for `redmod.exe`, -modlist=... is added."""
209218

219+
_parentWidget: QWidget
220+
"""Set with `_organizer.onUserInterfaceInitialized()`"""
221+
210222
def init(self, organizer: mobase.IOrganizer) -> bool:
211223
super().init(organizer)
212224
self._register_feature(BasicLocalSavegames(self.savesDirectory()))
@@ -234,7 +246,7 @@ def init(self, organizer: mobase.IOrganizer) -> bool:
234246
organizer.onAboutToRun(self._onAboutToRun)
235247
organizer.onPluginSettingChanged(self._on_settings_changed)
236248
organizer.modList().onModInstalled(self._check_disable_crashreporter)
237-
organizer.onUserInterfaceInitialized(self._check_disable_crashreporter)
249+
organizer.onUserInterfaceInitialized(self._on_user_interface_initialized)
238250
return True
239251

240252
def _on_settings_changed(
@@ -244,11 +256,27 @@ def _on_settings_changed(
244256
old: mobase.MoVariant,
245257
new: mobase.MoVariant,
246258
):
247-
if self.name() == plugin_name:
248-
if setting == "reverse_archive_load_order":
259+
if self.name() != plugin_name:
260+
return
261+
match setting:
262+
case "reverse_archive_load_order":
249263
self._modlist_files["archive"].reversed_priority = bool(new)
250-
elif setting == "reverse_remod_load_order":
264+
case "reverse_remod_load_order":
251265
self._modlist_files["redmod"].reversed_priority = bool(new)
266+
case "show_rootbuilder_conversion":
267+
if new and (dialog := self._get_rootbuilder_conversion_dialog()):
268+
dialog.open() # type: ignore
269+
case _:
270+
return
271+
272+
def _on_user_interface_initialized(self, window: QMainWindow):
273+
self._parentWidget = window
274+
if not self.isActive():
275+
return
276+
if dialog := self._get_rootbuilder_conversion_dialog(window):
277+
dialog.open() # type: ignore
278+
else:
279+
self._check_disable_crashreporter()
252280

253281
def _check_disable_crashreporter(
254282
self, mod: mobase.IModInterface | None | Any = None
@@ -388,6 +416,14 @@ def settings(self) -> list[mobase.PluginSetting]:
388416
),
389417
True,
390418
),
419+
mobase.PluginSetting(
420+
"show_rootbuilder_conversion",
421+
(
422+
"Shows a dialog to convert legacy RootBuilder mods to native MO mods,"
423+
" using force load libraries"
424+
),
425+
True,
426+
),
391427
]
392428

393429
def _get_setting(self, key: str) -> mobase.MoVariant:
@@ -587,3 +623,125 @@ def _unmapped_cache_files(self, data_path: Path) -> Iterable[Path]:
587623
yield Path(file).absolute().relative_to(data_path)
588624
except ValueError:
589625
continue
626+
627+
def _get_rootbuilder_conversion_dialog(
628+
self, parent_widget: QWidget | None = None
629+
) -> QMessageBox | None:
630+
"""
631+
Dialog to convert any mods with `root` folder, if applicable.
632+
CET and RED4ext work with forced load libraries since ~ Cyberpunk v2.12,
633+
making RootBuilder unnecessary.
634+
"""
635+
setting = "show_rootbuilder_conversion"
636+
if not self.isActive() or not self._get_setting(setting):
637+
return None
638+
if not (
639+
(root_folder := self._organizer.virtualFileTree().find("root"))
640+
and root_folder.isDir()
641+
):
642+
return None
643+
parent_widget = parent_widget or self._parentWidget
644+
message_box = QMessageBox(
645+
QMessageBox.Icon.Question,
646+
"RootBuilder obsolete",
647+
textwrap.dedent(
648+
"""
649+
Mod Organizer now supports Cyberpunk Engine Tweaks (CET) and
650+
RED4ext native via forced load libraries, making RootBuilder
651+
unnecessary.
652+
653+
Do you want to convert all mods with a `root` folder now?
654+
<br/>This usually only affects CET, RED4ext and overwrite.
655+
656+
You can disable RootBuilder afterwards.
657+
"""
658+
),
659+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
660+
parent_widget,
661+
)
662+
message_box.setTextFormat(Qt.TextFormat.MarkdownText)
663+
checkbox = QCheckBox("&Do not show again*", parent_widget)
664+
checkbox.setChecked(True)
665+
checkbox.setToolTip(f"Settings/Plugins/{self.name()}/{setting}")
666+
message_box.setCheckBox(checkbox)
667+
668+
def accept_callback():
669+
if unfolded_mods := unfold_root_folders(self._organizer, parent_widget):
670+
n_mods = len(unfolded_mods)
671+
info = QMessageBox(
672+
QMessageBox.Icon.Information,
673+
"Root mods converted",
674+
(
675+
f"{n_mods} mod{'s' if n_mods > 1 else ''} converted."
676+
"You can disable RootBuilder in the settings now."
677+
),
678+
parent=parent_widget,
679+
)
680+
info.setDetailedText(f"Converted mods:\n{'\n'.join(unfolded_mods)}")
681+
info.open() # type: ignore
682+
683+
message_box.accepted.connect(accept_callback) # type: ignore
684+
685+
def finished_callback():
686+
if checkbox.isChecked():
687+
self._set_setting(setting, False)
688+
self._check_disable_crashreporter()
689+
690+
message_box.finished.connect(finished_callback) # type: ignore
691+
return message_box
692+
693+
694+
def unfold_root_folders(
695+
organizer: mobase.IOrganizer, parent_widget: QWidget | None = None
696+
) -> list[str]:
697+
"""Unfolds (RootBuilders) root folders of all mods (excluding backups)."""
698+
mods = organizer.modList().allMods()
699+
progress = None
700+
unfolded_mods: list[str] = []
701+
if parent_widget:
702+
progress = QProgressDialog(
703+
"Merging/unfolding root folders...",
704+
"Abort",
705+
0,
706+
len(mods),
707+
parent_widget,
708+
)
709+
progress.setWindowModality(Qt.WindowModality.WindowModal)
710+
for i, mod_name in enumerate(mods):
711+
if progress:
712+
if progress.wasCanceled():
713+
break
714+
progress.setValue(i)
715+
if mod_name == "data":
716+
continue
717+
mod = organizer.modList().getMod(mod_name)
718+
if mod.isBackup() or mod.isSeparator() or mod.isForeign():
719+
continue
720+
root_folder = mod.fileTree().find("root")
721+
if root_folder is None or not root_folder.isDir():
722+
continue
723+
qInfo(f"Merging root folder of {mod_name}")
724+
mod_path = Path(mod.absolutePath())
725+
root_folder_path = mod_path / "root"
726+
unfold_folder(root_folder_path)
727+
unfolded_mods.append(mod_name)
728+
if progress:
729+
progress.setValue(len(mods))
730+
organizer.refresh()
731+
return unfolded_mods
732+
733+
734+
def unfold_folder(src_path: Path):
735+
"""
736+
Unfolds a folder (`parent/src/* -> parent/*`), overwriting existing files/folders.
737+
Preserves a subfolder with same name (`parent/src/src -> parent/src`).
738+
"""
739+
parent = src_path.parent
740+
if (src_path / src_path.name).exists():
741+
# Contains a file/folder with same name
742+
with tempfile.TemporaryDirectory(dir=parent) as temp_folder:
743+
src_path = src_path.rename(parent / temp_folder / src_path.name)
744+
shutil.copytree(src_path, parent, symlinks=True, dirs_exist_ok=True)
745+
else:
746+
shutil.copytree(src_path, parent, symlinks=True, dirs_exist_ok=True)
747+
shutil.rmtree(src_path)

0 commit comments

Comments
 (0)