diff options
Diffstat (limited to 'libs/basic_games/games/game_cyberpunk2077.py')
| -rw-r--r-- | libs/basic_games/games/game_cyberpunk2077.py | 798 |
1 files changed, 798 insertions, 0 deletions
diff --git a/libs/basic_games/games/game_cyberpunk2077.py b/libs/basic_games/games/game_cyberpunk2077.py new file mode 100644 index 0000000..ab44b8b --- /dev/null +++ b/libs/basic_games/games/game_cyberpunk2077.py @@ -0,0 +1,798 @@ +import filecmp +import json +import re +import shutil +import tempfile +import textwrap +from collections import Counter +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal, TypeVar + +from PyQt6.QtCore import QDateTime, QDir, Qt, qCritical, qInfo, qWarning +from PyQt6.QtWidgets import ( + QCheckBox, + QMainWindow, + QMessageBox, + QProgressDialog, + QWidget, +) + +import mobase + +from ..basic_features import BasicLocalSavegames, BasicModDataChecker, GlobPatterns +from ..basic_features.basic_save_game_info import ( + BasicGameSaveGame, + BasicGameSaveGameInfo, + format_date, +) +from ..basic_game import BasicGame + + +class CyberpunkModDataChecker(BasicModDataChecker): + def __init__(self): + super().__init__( + GlobPatterns( + delete=[ + "*.gif", + "*.jpg", + "*.jpeg", + "*.jxl", + "*.md", + "*.png", + "*.txt", + "*.webp", + ], + move={ + # archive and ArchiveXL + "*.archive": "archive/pc/mod/", + "*.xl": "archive/pc/mod/", + }, + valid=[ + "archive", + # redscript + "engine", + "r6", + "mods", # RedMod + "red4ext", + "bin", # CET etc. gets handled below + ], + ) + ) + + +def time_from_seconds(s: int | float) -> str: + m, s = divmod(int(s), 60) + h, m = divmod(int(m), 60) + return f"{h:02}:{m:02}:{s:02}" + + +def parse_cyberpunk_save_metadata(save_path: Path, save: mobase.ISaveGame): + metadata_file = save_path / "metadata.9.json" + try: + with open(metadata_file) as file: + meta_data = json.load(file)["Data"]["metadata"] + name = meta_data["name"] + if name != (save_name := save.getName()): + name = f"{save_name} ({name})" + return { + "Name": name, + "Date": format_date(meta_data["timestampString"], "hh:mm:ss, d.M.yyyy"), + "Play Time": time_from_seconds(meta_data["playthroughTime"]), + "Quest": meta_data["trackedQuestEntry"], + "Level": int(meta_data["level"]), + "Street Cred": int(meta_data["streetCred"]), + "Life Path": meta_data["lifePath"], + "Difficulty": meta_data["difficulty"], + "Gender": f"{meta_data['bodyGender']} / {meta_data['brainGender']}", + "Game version": meta_data["buildPatch"], + } + except (FileNotFoundError, json.JSONDecodeError): + return None + + +class CyberpunkSaveGame(BasicGameSaveGame): + _name_file = "NamedSave.txt" # from mod: Named Saves + + def __init__(self, filepath: Path): + super().__init__(filepath) + try: # Custom name from Named Saves + with open(filepath / self._name_file) as file: + self._name = file.readline() + except FileNotFoundError: + self._name = "" + + def getName(self) -> str: + return self._name or super().getName() + + def getCreationTime(self) -> QDateTime: + return QDateTime.fromSecsSinceEpoch( + int((self._filepath / "sav.dat").stat().st_mtime) + ) + + +@dataclass +class ModListFile: + list_path: Path + mod_search_pattern: str + reversed_priority: bool = False + """True: load order priority is reversed compared to MO (first mod has priority).""" + + +_MOD_TYPE = TypeVar("_MOD_TYPE") + + +class ModListFileManager(dict[_MOD_TYPE, ModListFile]): + """Manages modlist files for specific mod types.""" + + def __init__(self, organizer: mobase.IOrganizer, **kwargs: ModListFile) -> None: + self._organizer = organizer + super().__init__(**kwargs) + + def update_modlist( + self, mod_type: _MOD_TYPE, mod_files: list[str] | None = None + ) -> tuple[Path, list[str], list[str]]: + """ + Updates the mod list file for `mod_type` with the current load order. + Removes the file if it is not needed. + + Args: + mod_type: Which modlist to update. + mod_files (optional): By default mod files in order of `mod_type` priority + (respecting `self[mod_type].reversed_priority`). + + Returns: + `(modlist_path, new_mod_list, old_mod_list)` + """ + if mod_files is None: + mod_files = list(self.modfile_names(mod_type)) + modlist_path = self.absolute_modlist_path(mod_type) + old_modlist = ( + modlist_path.read_text().splitlines() if modlist_path.exists() else [] + ) + if not mod_files or len(mod_files) == 1: + # No load order required + if old_modlist: + qInfo(f"Removing {mod_type} load order {modlist_path}") + modlist_path.unlink() + return modlist_path, [], old_modlist + else: + qInfo(f'Updating {mod_type} load order "{modlist_path}" with: {mod_files}') + modlist_path.parent.mkdir(parents=True, exist_ok=True) + modlist_path.write_text("\n".join(mod_files)) + return modlist_path, mod_files, old_modlist + + def absolute_modlist_path(self, mod_type: _MOD_TYPE) -> Path: + modlist_path = self[mod_type].list_path + if not modlist_path.is_absolute(): + existing = self._organizer.findFiles(modlist_path.parent, modlist_path.name) + overwrite = self._organizer.overwritePath() + modlist_path = ( + Path(existing[0]) if (existing) else Path(overwrite, modlist_path) + ) + return modlist_path + + def modfile_names(self, mod_type: _MOD_TYPE) -> Iterable[str]: + """Get all file names from the `mod_type` in MOs load order + (reversed with `self[mod_type].reversed_priority = True`). + """ + yield from (file.name for file in self.modfiles(mod_type)) + + def modfiles(self, mod_type: _MOD_TYPE) -> Iterable[Path]: + """Get all files from the `mod_type` in MOs load order + (reversed with `self[mod_type].reversed_priority = True`). + """ + mod_search_pattern = self[mod_type].mod_search_pattern + for mod_path in self.active_mod_paths(self[mod_type].reversed_priority): + yield from mod_path.glob(mod_search_pattern) + + def active_mod_paths(self, reverse: bool = False) -> Iterable[Path]: + """Yield the path to active mods in MOs load order.""" + mods_path = Path(self._organizer.modsPath()) + modlist = self._organizer.modList() + mods_load_order = modlist.allModsByProfilePriority() + for mod in reversed(mods_load_order) if reverse else mods_load_order: + if modlist.state(mod) & mobase.ModState.ACTIVE: + yield mods_path / mod + + +class Cyberpunk2077Game(BasicGame): + Name = "Cyberpunk 2077 Support Plugin" + Author = "6788, Zash" + Version = "3.0.1" + + GameName = "Cyberpunk 2077" + GameShortName = "cyberpunk2077" + GameBinary = "bin/x64/Cyberpunk2077.exe" + GameLauncher = "REDprelauncher.exe" + GameDataPath = "%GAME_PATH%" + GameDocumentsDirectory = "%USERPROFILE%/AppData/Local/CD Projekt Red/Cyberpunk 2077" + GameSavesDirectory = "%USERPROFILE%/Saved Games/CD Projekt Red/Cyberpunk 2077" + GameSaveExtension = "dat" + GameSteamId = 1091500 + GameGogId = 1423049311 + GameEpicId = "77f2b98e2cef40c8a7437518bf420e47" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "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)) + self._register_feature( + BasicGameSaveGameInfo( + lambda p: Path(p or "", "screenshot.png"), + parse_cyberpunk_save_metadata, + ) + ) + self._register_feature(CyberpunkModDataChecker()) + + self._modlist_files = ModListFileManager[Literal["archive", "redmod"]]( + organizer, + archive=ModListFile( + Path("archive/pc/mod/modlist.txt"), + "archive/pc/mod/*.archive", + reversed_priority=bool(self._get_setting("reverse_archive_load_order")), + ), + redmod=ModListFile( + Path(self._redmod_deploy_path, "MO_REDmod_load_order.txt"), + "mods/*/", + reversed_priority=bool(self._get_setting("reverse_redmod_load_order")), + ), + ) + organizer.onAboutToRun(self._onAboutToRun) + organizer.onFinishedRun(self._onFinishedRun) + 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( + self, + plugin_name: str, + setting: str, + old: mobase.MoVariant, + new: mobase.MoVariant, + ): + if self.name() != plugin_name: + return + match setting: + case "reverse_archive_load_order": + self._modlist_files["archive"].reversed_priority = bool(new) + 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"] + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + CyberpunkSaveGame(path.parent) + for path in Path(folder.absolutePath()).glob(f"**/*.{ext}") + ] + + def settings(self) -> list[mobase.PluginSetting]: + return [ + mobase.PluginSetting( + "skipStartScreen", + ( + 'Skips the "Breaching..." start screen on game launch' + " (can also skip loading of GOG rewards)" + ), + False, + ), + mobase.PluginSetting( + "enforce_archive_load_order", + ( + "Enforce the current load order via" + " <code>archive/pc/mod/modlist.txt</code>" + ), + False, + ), + mobase.PluginSetting( + "reverse_archive_load_order", + ( + "Reverse MOs load order in" + " <code>archive/pc/mod/modlist.txt</code>" + " (first loaded mod wins = last one / highest prio in MO)" + ), + False, + ), + mobase.PluginSetting( + "enforce_redmod_load_order", + "Enforce the current load order on redmod deployment", + True, + ), + mobase.PluginSetting( + "reverse_redmod_load_order", + ( + "Reverse MOs load order on redmod deployment" + " (first loaded mod wins = last one / highest prio in MO)" + ), + False, + ), + mobase.PluginSetting( + "auto_deploy_redmod", + "Deploy redmod before game launch if necessary", + True, + ), + mobase.PluginSetting( + "clear_cache_after_game_update", + ( + 'Clears "overwrite/r6/cache/*" if the original game files changed' + " (after update)" + ), + True, + ), + mobase.PluginSetting( + "disable_crashreporter", + ( + "Creates a dummy mod/file to disable CrashReporter.exe, " + "which is not compatible with VFS version.dll, see wiki" + ), + True, + ), + mobase.PluginSetting( + "crash_message", + ("Show a crash message as replacement of disabled CrashReporter"), + True, + ), + mobase.PluginSetting( + "show_rootbuilder_conversion", + ( + "Shows a dialog to convert legacy RootBuilder mods to native MO mods," + " using force load libraries" + ), + True, + ), + ] + + def _get_setting(self, key: str) -> mobase.MoVariant: + return self._organizer.pluginSetting(self.name(), key) + + def _set_setting(self, key: str, value: mobase.MoVariant): + self._organizer.setPluginSetting(self.name(), key, value) + + def executables(self) -> list[mobase.ExecutableInfo]: + game_name = self.gameName() + game_dir = self.gameDirectory() + bin_path = game_dir.absoluteFilePath(self.binaryName()) + skip_start_screen = ( + "-skipStartScreen" if self._get_setting("skipStartScreen") else "" + ) + return [ + # Default, runs REDmod deploy if necessary + mobase.ExecutableInfo( + f"{game_name} (REDmod)", + bin_path, + ).withArgument(f"--launcher-skip -modded {skip_start_screen}"), + # Start game without REDmod + mobase.ExecutableInfo( + f"{game_name}", + bin_path, + ).withArgument(f"--launcher-skip {skip_start_screen}"), + # Deploy REDmods only + mobase.ExecutableInfo( + "REDmod", + self._get_redmod_binary(), + ).withArgument("deploy -reportProgress -force %modlist%"), + # Launcher + mobase.ExecutableInfo( + "REDprelauncher", + game_dir.absoluteFilePath(self.getLauncherName()), + ).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) + + def _onAboutToRun(self, app_path_str: str, wd: QDir, args: str) -> bool: + if not self.isActive(): + return True + app_path = Path(app_path_str) + if app_path == self._get_redmod_binary(): + if m := re.search(r"%modlist%", args, re.I): + # Manual deployment: replace %modlist% variable + ( + modlist_path, + modlist, + _, + ) = self._modlist_files.update_modlist("redmod") + modlist_param = f'-modlist="{modlist_path}"' if modlist else "" + args = f"{args[: m.start()]}{modlist_param}{args[m.end() :]}" + qInfo(f"Manual modlist deployment: replacing {m[0]}, new args = {args}") + self._check_redmod_result( + self._organizer.waitForApplication( + self._organizer.startApplication(app_path_str, [args], wd), + False, + ) + ) + return False # redmod with new args started + return True # No recursive redmod call + if ( + self._get_setting("auto_deploy_redmod") + and app_path == Path(self.gameDirectory().absolutePath(), self.binaryName()) + and "-modded" in args + and not self._check_redmod_result(self._deploy_redmod()) + ): + qWarning("Aborting game launch.") + return False # Auto deploy failed + self._map_cache_files() + if self._get_setting("enforce_archive_load_order"): + self._modlist_files.update_modlist("archive") + return True + + def _onFinishedRun(self, path: str, exit_code: int) -> None: + if not self._get_setting("crash_message"): + return + if path.endswith(self.binaryName()) and exit_code > 0: + crash_message = QMessageBox( + QMessageBox.Icon.Critical, + "Cyberpunk Crashed", + textwrap.dedent( + f""" + Cyberpunk crashed. Tips: + - disable mods (create backup of modlist or use new profile) + - clear overwrite or delete at least overwrite/r6/cache (to keep mod settings) + - check log files of CET/redscript/RED4ext (in overwrite) + - read [FAQ & Troubleshooting]({self.GameSupportURL}#faq--troubleshooting) + """ + ), + QMessageBox.StandardButton.Ok, + self._parentWidget, + ) + crash_message.setTextFormat(Qt.TextFormat.MarkdownText) + hide_cb = QCheckBox("&Do not show again*", crash_message) + hide_cb.setToolTip(f"Settings/Plugins/{self.name()}/crash_message") + crash_message.setCheckBox(hide_cb) + crash_message.open( # type: ignore + lambda: hide_cb.isChecked() + and self._set_setting("crash_message", False) + ) + + def _check_redmod_result(self, result: tuple[bool, int]) -> bool: + if result == (True, 0): + return True + if result[1] < 0: + qWarning(f"REDmod deployment aborted (exit code {result[1]}).") + else: + qCritical( + f"REDmod deployment failed with exit code {result[1]} !" + f" Check {Path('GAME_FOLDER/', self._redmod_log)}" + ) + return False + + def _deploy_redmod(self) -> tuple[bool, int]: + """Deploys redmod. Clears deployed files if no redmods are active. + Recreates deployed files to force load order when necessary. + + Returns: + (success?, exit code) + """ + # Add REDmod load order if none is specified + redmod_list = list(self._modlist_files.modfile_names("redmod")) + if not redmod_list: + qInfo("Cleaning up redmod deployed files") + self._clean_deployed_redmod() + return True, 0 + args = self._redmod_deploy_args + if self._get_setting("enforce_redmod_load_order"): + modlist_path, _, old_redmods = self._modlist_files.update_modlist( + "redmod", redmod_list + ) + if ( + Counter(redmod_list) == Counter(old_redmods) + and not redmod_list == old_redmods + ): + # Only load order changed: recreate redmod deploys + # Fix for redmod not detecting change of load order. + # Faster than -force https://github.com/E1337Kat/cyberpunk2077_ext_redux/issues/297 # noqa: E501 + qInfo("Redmod order changed, recreate deployed files") + self._clean_deployed_redmod(modlist_path) + qInfo(f"Deploying redmod with modlist: {modlist_path}") + args += f' -modlist="{modlist_path}"' + else: + qInfo("Deploying redmod") + redmod_binary = self._get_redmod_binary() + return self._organizer.waitForApplication( + self._organizer.startApplication( + redmod_binary, [args], redmod_binary.parent + ), + False, + ) + + def _clean_deployed_redmod(self, modlist_path: Path | None = None): + """Delete all files from `_redmod_deploy_path` except for `modlist_path`.""" + for file in self._organizer.findFiles(self._redmod_deploy_path, "*"): + file_path = Path(file) + if modlist_path is None or file_path != modlist_path: + file_path.unlink() + + def _map_cache_files(self): + """ + Copy cache files (`final.redscript` etc.) to overwrite to catch + overwritten game files. + """ + data_path = Path(self.dataDirectory().absolutePath()) + overwrite_path = Path(self._organizer.overwritePath()) + data_cache_path = data_path / "r6/cache" + overwrite_cache_path = overwrite_path / "r6/cache" + cache_files = ( + file.relative_to(data_path) for file in data_cache_path.glob("*") + ) + if self._get_setting("clear_cache_after_game_update") and any( + self._is_cache_file_updated(file, data_path) for file in cache_files + ): + qInfo('Updated game files detected, clearing "overwrite/r6/cache/*"') + try: + shutil.rmtree(overwrite_cache_path) + except FileNotFoundError: + pass + qInfo('Updating "r6/cache" in overwrite') + shutil.copytree(data_cache_path, overwrite_cache_path, dirs_exist_ok=True) + else: + for file in self._unmapped_cache_files(data_path): + qInfo(f'Copying "{file}" to overwrite (to catch file overwrites)') + dst = overwrite_path / file + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(data_path / file, dst) + + def _is_cache_file_updated(self, file: Path, data_path: Path) -> bool: + """Checks if a cache file is updated (in game dir). + + Args: + file: Relative to data dir. + """ + game_file = data_path.absolute() / file + mapped_files = self._organizer.findFiles(file.parent, file.name) + return bool( + mapped_files + and (mapped_file := mapped_files[0]) + and not ( + game_file.samefile(mapped_file) + or filecmp.cmp(game_file, mapped_file) + or ( # different backup file + ( + backup_files := self._organizer.findFiles( + file.parent, f"{file.name}.bk" + ) + ) + and filecmp.cmp(game_file, backup_files[0]) + ) + ) + ) + + def _unmapped_cache_files(self, data_path: Path) -> Iterable[Path]: + """Yields unmapped cache files relative to `data_path`.""" + for file in self._organizer.findFiles("r6/cache", "*"): + try: + 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) |
