mirror of
https://github.com/ModOrganizer2/modorganizer-basic_games.git
synced 2026-07-27 14:07:29 -07:00
Cyberpunk: forced load libraries instead of RB (#163)
* Remove Cyberpunk RootBuilder option, forced load libraries works now with CET and RED4ext * Fix CrashReporter error with CET installed in VFS by disabling it. * Add Message on startup to convert RootBuilder mods * Fix FileNotFoundError when updating overwrite/r6/cache
This commit is contained in:
+254
-149
@@ -2,14 +2,23 @@ import filecmp
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import textwrap
|
||||
from collections import Counter
|
||||
from collections.abc import Iterable, Mapping
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypeVar
|
||||
|
||||
import mobase
|
||||
from PyQt6.QtCore import QDateTime, QDir, qCritical, qInfo, qWarning
|
||||
from PyQt6.QtCore import QDateTime, QDir, Qt, qCritical, qInfo, qWarning
|
||||
from PyQt6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QProgressDialog,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..basic_features import BasicLocalSavegames, BasicModDataChecker, GlobPatterns
|
||||
from ..basic_features.basic_save_game_info import (
|
||||
@@ -17,7 +26,6 @@ from ..basic_features.basic_save_game_info import (
|
||||
BasicGameSaveGameInfo,
|
||||
format_date,
|
||||
)
|
||||
from ..basic_features.utils import is_directory
|
||||
from ..basic_game import BasicGame
|
||||
|
||||
|
||||
@@ -36,103 +44,12 @@ class CyberpunkModDataChecker(BasicModDataChecker):
|
||||
"engine",
|
||||
"r6",
|
||||
"mods", # RedMod
|
||||
"red4ext", # red4ext/RED4ext.dll is moved to root in .fix()
|
||||
"red4ext",
|
||||
"bin", # CET etc. gets handled below
|
||||
"root", # RootBuilder: hardlink / copy to game root
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
_extra_files_to_move = {
|
||||
# Red4ext: only .dll files
|
||||
"red4ext/RED4ext.dll": "root/red4ext/",
|
||||
"bin/x64/winmm.dll": "root/bin/x64/",
|
||||
# CET: all files, folder gets handled in .fix()
|
||||
"bin/x64/version.dll": "root/bin/x64/",
|
||||
"bin/x64/global.ini": "root/bin/x64/",
|
||||
"bin/x64/plugins/cyber_engine_tweaks.asi": "root/bin/x64/plugins/",
|
||||
}
|
||||
_cet_path = "bin/x64/plugins/cyber_engine_tweaks/"
|
||||
|
||||
def dataLooksValid(
|
||||
self, filetree: mobase.IFileTree
|
||||
) -> mobase.ModDataChecker.CheckReturn:
|
||||
# fix: single root folders get traversed by Simple Installer
|
||||
parent = filetree.parent()
|
||||
if parent is not None and self.dataLooksValid(parent) is self.FIXABLE:
|
||||
return self.FIXABLE
|
||||
status = mobase.ModDataChecker.INVALID
|
||||
# Check extra fixes
|
||||
if any(filetree.exists(p) for p in self._extra_files_to_move):
|
||||
return mobase.ModDataChecker.FIXABLE
|
||||
rp = self._regex_patterns
|
||||
for entry in filetree:
|
||||
name = entry.name().casefold()
|
||||
if rp.move_match(name) is not None:
|
||||
status = mobase.ModDataChecker.FIXABLE
|
||||
elif rp.valid.match(name):
|
||||
if status is mobase.ModDataChecker.INVALID:
|
||||
status = mobase.ModDataChecker.VALID
|
||||
elif self._valid_redmod(entry):
|
||||
# Archive with REDmod folders, not in mods/
|
||||
status = mobase.ModDataChecker.FIXABLE
|
||||
# Accept any other entry
|
||||
return status
|
||||
|
||||
def _valid_redmod(self, filetree: mobase.IFileTree | mobase.FileTreeEntry) -> bool:
|
||||
return isinstance(filetree, mobase.IFileTree) and bool(
|
||||
filetree and filetree.find("info.json")
|
||||
)
|
||||
|
||||
def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree:
|
||||
for source, target in self._extra_files_to_move.items():
|
||||
if file := filetree.find(source):
|
||||
parent = file.parent()
|
||||
filetree.move(file, target)
|
||||
clear_empty_folder(parent)
|
||||
if filetree := super().fix(filetree):
|
||||
filetree = self._fix_cet_framework(filetree)
|
||||
# REDmod
|
||||
for entry in list(filetree):
|
||||
if not self._regex_patterns.valid.match(
|
||||
entry.name().casefold()
|
||||
) and self._valid_redmod(entry):
|
||||
filetree.move(entry, "mods/")
|
||||
return filetree
|
||||
|
||||
def _fix_cet_framework(self, filetree: mobase.IFileTree) -> mobase.IFileTree:
|
||||
"""Move CET framework to `root/`, except for `mods`.
|
||||
Only CET >= v1.27.0 (Patch 2.01) works with USVFS.
|
||||
|
||||
See: https://github.com/maximegmd/CyberEngineTweaks/pull/877
|
||||
"""
|
||||
if cet_folder := filetree.find(
|
||||
self._cet_path, mobase.FileTreeEntry.FileTypes.DIRECTORY
|
||||
):
|
||||
assert is_directory(cet_folder)
|
||||
root_cet_path = f"root/{self._cet_path}"
|
||||
if not cet_folder.exists("mods"):
|
||||
parent = cet_folder.parent()
|
||||
filetree.move(cet_folder, root_cet_path.rstrip("/\\"))
|
||||
else:
|
||||
parent = cet_folder
|
||||
for entry in list(cet_folder):
|
||||
if entry.name() != "mods":
|
||||
filetree.move(entry, root_cet_path)
|
||||
clear_empty_folder(parent)
|
||||
return filetree
|
||||
|
||||
|
||||
def clear_empty_folder(filetree: mobase.IFileTree | None):
|
||||
if filetree is None:
|
||||
return
|
||||
while not filetree:
|
||||
parent = filetree.parent()
|
||||
filetree.detach()
|
||||
if parent is None:
|
||||
break
|
||||
filetree = parent
|
||||
|
||||
|
||||
def time_from_seconds(s: int | float) -> str:
|
||||
m, s = divmod(int(s), 60)
|
||||
@@ -269,27 +186,10 @@ class ModListFileManager(dict[_MOD_TYPE, ModListFile]):
|
||||
yield mods_path / mod
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginDefaultSettings:
|
||||
organizer: mobase.IOrganizer
|
||||
plugin_name: str
|
||||
settings: Mapping[str, mobase.MoVariant]
|
||||
|
||||
def is_plugin_enabled(self) -> bool:
|
||||
return self.organizer.isPluginEnabled(self.plugin_name)
|
||||
|
||||
def apply(self) -> bool:
|
||||
if not self.is_plugin_enabled():
|
||||
return False
|
||||
for setting, value in self.settings.items():
|
||||
self.organizer.setPluginSetting(self.plugin_name, setting, value)
|
||||
return True
|
||||
|
||||
|
||||
class Cyberpunk2077Game(BasicGame):
|
||||
Name = "Cyberpunk 2077 Support Plugin"
|
||||
Author = "6788, Zash"
|
||||
Version = "2.3.1"
|
||||
Version = "3.0.0"
|
||||
|
||||
GameName = "Cyberpunk 2077"
|
||||
GameShortName = "cyberpunk2077"
|
||||
@@ -306,12 +206,19 @@ class Cyberpunk2077Game(BasicGame):
|
||||
"Game:-Cyberpunk-2077"
|
||||
)
|
||||
|
||||
# CET and RED4ext, relative to Cyberpunk2077.exe
|
||||
_forced_libraries = ["version.dll", "winmm.dll"]
|
||||
_crashreporter_path = "bin/x64/CrashReporter/CrashReporter.exe"
|
||||
|
||||
_redmod_binary = Path("tools/redmod/bin/redMod.exe")
|
||||
_redmod_log = Path("tools/redmod/bin/REDmodLog.txt")
|
||||
_redmod_deploy_path = Path("r6/cache/modded/")
|
||||
_redmod_deploy_args = "deploy -reportProgress"
|
||||
"""Deploy arguments for `redmod.exe`, -modlist=... is added."""
|
||||
|
||||
_parentWidget: QWidget
|
||||
"""Set with `_organizer.onUserInterfaceInitialized()`"""
|
||||
|
||||
def init(self, organizer: mobase.IOrganizer) -> bool:
|
||||
super().init(organizer)
|
||||
self._register_feature(BasicLocalSavegames(self.savesDirectory()))
|
||||
@@ -336,38 +243,10 @@ class Cyberpunk2077Game(BasicGame):
|
||||
reversed_priority=bool(self._get_setting("reverse_redmod_load_order")),
|
||||
),
|
||||
)
|
||||
self._rootbuilder_settings = PluginDefaultSettings(
|
||||
organizer,
|
||||
"RootBuilder",
|
||||
{
|
||||
"usvfsmode": False,
|
||||
"linkmode": False,
|
||||
# Available with RootBuilder v4.5+
|
||||
# Currently bugged / incompatible with MO 2.5.2 (Python 3.12)
|
||||
# https://github.com/Kezyma/ModOrganizer-Plugins/issues/36
|
||||
"linkonlymode": False,
|
||||
"backup": True,
|
||||
"cache": True,
|
||||
"autobuild": True,
|
||||
"redirect": False,
|
||||
"installer": False,
|
||||
"exclusions": "archive,setup_redlauncher.exe,tools",
|
||||
"linkextensions": "dll,exe",
|
||||
},
|
||||
)
|
||||
|
||||
def apply_rootbuilder_settings_once(*args: Any):
|
||||
if not self.isActive() or not self._get_setting("configure_RootBuilder"):
|
||||
return
|
||||
if self._rootbuilder_settings.apply():
|
||||
qInfo(f"RootBuilder configured for {self.gameName()}")
|
||||
self._set_setting("configure_RootBuilder", False)
|
||||
|
||||
organizer.onUserInterfaceInitialized(apply_rootbuilder_settings_once)
|
||||
organizer.onPluginEnabled("RootBuilder", apply_rootbuilder_settings_once)
|
||||
organizer.onAboutToRun(self._onAboutToRun)
|
||||
|
||||
organizer.onPluginSettingChanged(self._on_settings_changed)
|
||||
organizer.modList().onModInstalled(self._check_disable_crashreporter)
|
||||
organizer.onUserInterfaceInitialized(self._on_user_interface_initialized)
|
||||
return True
|
||||
|
||||
def _on_settings_changed(
|
||||
@@ -377,11 +256,94 @@ class Cyberpunk2077Game(BasicGame):
|
||||
old: mobase.MoVariant,
|
||||
new: mobase.MoVariant,
|
||||
):
|
||||
if self.name() == plugin_name:
|
||||
if setting == "reverse_archive_load_order":
|
||||
if self.name() != plugin_name:
|
||||
return
|
||||
match setting:
|
||||
case "reverse_archive_load_order":
|
||||
self._modlist_files["archive"].reversed_priority = bool(new)
|
||||
elif setting == "reverse_remod_load_order":
|
||||
case "reverse_remod_load_order":
|
||||
self._modlist_files["redmod"].reversed_priority = bool(new)
|
||||
case "show_rootbuilder_conversion":
|
||||
if new and (dialog := self._get_rootbuilder_conversion_dialog()):
|
||||
dialog.open() # type: ignore
|
||||
case _:
|
||||
return
|
||||
|
||||
def _on_user_interface_initialized(self, window: QMainWindow):
|
||||
self._parentWidget = window
|
||||
if not self.isActive():
|
||||
return
|
||||
if dialog := self._get_rootbuilder_conversion_dialog(window):
|
||||
dialog.open() # type: ignore
|
||||
else:
|
||||
self._check_disable_crashreporter()
|
||||
|
||||
def _check_disable_crashreporter(
|
||||
self, mod: mobase.IModInterface | None | Any = None
|
||||
):
|
||||
"""
|
||||
Disable Crashreporter with CET installed in VFS, since it crashes
|
||||
when trying to resolve `version.dll`.
|
||||
"""
|
||||
if not self.isActive() or not self._get_setting("disable_crashreporter"):
|
||||
return
|
||||
cet_mod_name = self._find_cet_mod_name(
|
||||
mod if isinstance(mod, mobase.IModInterface) else None
|
||||
)
|
||||
if (
|
||||
cet_mod_name
|
||||
and (cr_origin := self._organizer.getFileOrigins(self._crashreporter_path))
|
||||
and cr_origin[0] == "data"
|
||||
):
|
||||
self._create_dummy_crashreporter_mod(
|
||||
self._organizer.modList().priority(cet_mod_name) + 1
|
||||
)
|
||||
|
||||
def _find_cet_mod_name(self, mod: mobase.IModInterface | None = None) -> str:
|
||||
"""
|
||||
Find the mod containing `version.dll`.
|
||||
|
||||
Args:
|
||||
mod (optional): check the mods filetree instead. Defaults to None.
|
||||
|
||||
Returns:
|
||||
The mods name if `version.dll` is found, else ''.
|
||||
"""
|
||||
cet_mod_name = ""
|
||||
if mod:
|
||||
if mod.fileTree().find("bin/x64/version.dll"):
|
||||
cet_mod_name = mod.name()
|
||||
elif dll_origins := self._organizer.getFileOrigins("bin/x64/version.dll"):
|
||||
cet_mod_name = dll_origins[0]
|
||||
return cet_mod_name if cet_mod_name != "data" else ""
|
||||
|
||||
def _create_dummy_crashreporter_mod(self, priority: int = 0):
|
||||
"""
|
||||
Disables CrashReporter by creating an empty dummy file to replace
|
||||
`CrashReporter.exe`.
|
||||
"""
|
||||
mod_name = "disable CrashReporter (MO CET fix)"
|
||||
modlist = self._organizer.modList()
|
||||
if modlist.getMod(mod_name):
|
||||
return
|
||||
qInfo(f"CET VFS fix: creating mod {mod_name}")
|
||||
new_mod = self._organizer.createMod(mobase.GuessedString(mod_name))
|
||||
if not new_mod:
|
||||
return
|
||||
mod_name = new_mod.name()
|
||||
new_mod.setGameName(self.gameShortName())
|
||||
new_mod.setUrl(f"{self.getSupportURL()}#crashreporter")
|
||||
crashReporter = Path(new_mod.absolutePath(), self._crashreporter_path)
|
||||
crashReporter.parent.mkdir(parents=True)
|
||||
crashReporter.touch()
|
||||
|
||||
def callback():
|
||||
modlist.setActive(mod_name, True)
|
||||
modlist.setPriority(mod_name, priority)
|
||||
|
||||
self._organizer.onNextRefresh(callback, False)
|
||||
self._organizer.refresh()
|
||||
self._set_setting("disable_crashreporter", False)
|
||||
|
||||
def iniFiles(self):
|
||||
return ["UserSettings.json"]
|
||||
@@ -447,8 +409,19 @@ class Cyberpunk2077Game(BasicGame):
|
||||
True,
|
||||
),
|
||||
mobase.PluginSetting(
|
||||
"configure_RootBuilder",
|
||||
"Configures RootBuilder for Cyberpunk if installed and enabled",
|
||||
"disable_crashreporter",
|
||||
(
|
||||
"Creates a dummy mod/file to disable CrashReporter.exe, "
|
||||
"which is not compatible with VFS version.dll, see wiki"
|
||||
),
|
||||
True,
|
||||
),
|
||||
mobase.PluginSetting(
|
||||
"show_rootbuilder_conversion",
|
||||
(
|
||||
"Shows a dialog to convert legacy RootBuilder mods to native MO mods,"
|
||||
" using force load libraries"
|
||||
),
|
||||
True,
|
||||
),
|
||||
]
|
||||
@@ -489,6 +462,13 @@ class Cyberpunk2077Game(BasicGame):
|
||||
).withArgument(f"{skip_start_screen}"),
|
||||
]
|
||||
|
||||
def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]:
|
||||
exe = Path(self.binaryName()).name
|
||||
return [
|
||||
mobase.ExecutableForcedLoadSetting(exe, lib).withEnabled(True)
|
||||
for lib in self._forced_libraries
|
||||
]
|
||||
|
||||
def _get_redmod_binary(self) -> Path:
|
||||
"""Absolute path to redmod binary"""
|
||||
return Path(self.gameDirectory().absolutePath(), self._redmod_binary)
|
||||
@@ -601,7 +581,10 @@ class Cyberpunk2077Game(BasicGame):
|
||||
self._is_cache_file_updated(file, data_path) for file in cache_files
|
||||
):
|
||||
qInfo('Updated game files detected, clearing "overwrite/r6/cache/*"')
|
||||
shutil.rmtree(overwrite_path / "r6/cache")
|
||||
try:
|
||||
shutil.rmtree(overwrite_path / "r6/cache")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
new_cache_files = cache_files
|
||||
else:
|
||||
new_cache_files = list(self._unmapped_cache_files(data_path))
|
||||
@@ -643,3 +626,125 @@ class Cyberpunk2077Game(BasicGame):
|
||||
yield Path(file).absolute().relative_to(data_path)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
def _get_rootbuilder_conversion_dialog(
|
||||
self, parent_widget: QWidget | None = None
|
||||
) -> QMessageBox | None:
|
||||
"""
|
||||
Dialog to convert any mods with `root` folder, if applicable.
|
||||
CET and RED4ext work with forced load libraries since ~ Cyberpunk v2.12,
|
||||
making RootBuilder unnecessary.
|
||||
"""
|
||||
setting = "show_rootbuilder_conversion"
|
||||
if not self.isActive() or not self._get_setting(setting):
|
||||
return None
|
||||
if not (
|
||||
(root_folder := self._organizer.virtualFileTree().find("root"))
|
||||
and root_folder.isDir()
|
||||
):
|
||||
return None
|
||||
parent_widget = parent_widget or self._parentWidget
|
||||
message_box = QMessageBox(
|
||||
QMessageBox.Icon.Question,
|
||||
"RootBuilder obsolete",
|
||||
textwrap.dedent(
|
||||
"""
|
||||
Mod Organizer now supports Cyberpunk Engine Tweaks (CET) and
|
||||
RED4ext native via forced load libraries, making RootBuilder
|
||||
unnecessary.
|
||||
|
||||
Do you want to convert all mods with a `root` folder now?
|
||||
<br/>This usually only affects CET, RED4ext and overwrite.
|
||||
|
||||
You can disable RootBuilder afterwards.
|
||||
"""
|
||||
),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
parent_widget,
|
||||
)
|
||||
message_box.setTextFormat(Qt.TextFormat.MarkdownText)
|
||||
checkbox = QCheckBox("&Do not show again*", parent_widget)
|
||||
checkbox.setChecked(True)
|
||||
checkbox.setToolTip(f"Settings/Plugins/{self.name()}/{setting}")
|
||||
message_box.setCheckBox(checkbox)
|
||||
|
||||
def accept_callback():
|
||||
if unfolded_mods := unfold_root_folders(self._organizer, parent_widget):
|
||||
n_mods = len(unfolded_mods)
|
||||
info = QMessageBox(
|
||||
QMessageBox.Icon.Information,
|
||||
"Root mods converted",
|
||||
(
|
||||
f"{n_mods} mod{'s' if n_mods > 1 else ''} converted."
|
||||
"You can disable RootBuilder in the settings now."
|
||||
),
|
||||
parent=parent_widget,
|
||||
)
|
||||
info.setDetailedText(f"Converted mods:\n{'\n'.join(unfolded_mods)}")
|
||||
info.open() # type: ignore
|
||||
|
||||
message_box.accepted.connect(accept_callback) # type: ignore
|
||||
|
||||
def finished_callback():
|
||||
if checkbox.isChecked():
|
||||
self._set_setting(setting, False)
|
||||
self._check_disable_crashreporter()
|
||||
|
||||
message_box.finished.connect(finished_callback) # type: ignore
|
||||
return message_box
|
||||
|
||||
|
||||
def unfold_root_folders(
|
||||
organizer: mobase.IOrganizer, parent_widget: QWidget | None = None
|
||||
) -> list[str]:
|
||||
"""Unfolds (RootBuilders) root folders of all mods (excluding backups)."""
|
||||
mods = organizer.modList().allMods()
|
||||
progress = None
|
||||
unfolded_mods: list[str] = []
|
||||
if parent_widget:
|
||||
progress = QProgressDialog(
|
||||
"Merging/unfolding root folders...",
|
||||
"Abort",
|
||||
0,
|
||||
len(mods),
|
||||
parent_widget,
|
||||
)
|
||||
progress.setWindowModality(Qt.WindowModality.WindowModal)
|
||||
for i, mod_name in enumerate(mods):
|
||||
if progress:
|
||||
if progress.wasCanceled():
|
||||
break
|
||||
progress.setValue(i)
|
||||
if mod_name == "data":
|
||||
continue
|
||||
mod = organizer.modList().getMod(mod_name)
|
||||
if mod.isBackup() or mod.isSeparator() or mod.isForeign():
|
||||
continue
|
||||
root_folder = mod.fileTree().find("root")
|
||||
if root_folder is None or not root_folder.isDir():
|
||||
continue
|
||||
qInfo(f"Merging root folder of {mod_name}")
|
||||
mod_path = Path(mod.absolutePath())
|
||||
root_folder_path = mod_path / "root"
|
||||
unfold_folder(root_folder_path)
|
||||
unfolded_mods.append(mod_name)
|
||||
if progress:
|
||||
progress.setValue(len(mods))
|
||||
organizer.refresh()
|
||||
return unfolded_mods
|
||||
|
||||
|
||||
def unfold_folder(src_path: Path):
|
||||
"""
|
||||
Unfolds a folder (`parent/src/* -> parent/*`), overwriting existing files/folders.
|
||||
Preserves a subfolder with same name (`parent/src/src -> parent/src`).
|
||||
"""
|
||||
parent = src_path.parent
|
||||
if (src_path / src_path.name).exists():
|
||||
# Contains a file/folder with same name
|
||||
with tempfile.TemporaryDirectory(dir=parent) as temp_folder:
|
||||
src_path = src_path.rename(parent / temp_folder / src_path.name)
|
||||
shutil.copytree(src_path, parent, symlinks=True, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copytree(src_path, parent, symlinks=True, dirs_exist_ok=True)
|
||||
shutil.rmtree(src_path)
|
||||
|
||||
Reference in New Issue
Block a user