From 90af705e88a78d807e60866588d9f86f764ae1fc Mon Sep 17 00:00:00 2001 From: Zash Date: Thu, 7 Aug 2025 10:05:08 +0200 Subject: [PATCH 01/20] Fix is_gog(): setGamePath() is not setting gogAPPId (#189) --- basic_game.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basic_game.py b/basic_game.py index 09fca10a..b3d80734 100644 --- a/basic_game.py +++ b/basic_game.py @@ -640,7 +640,7 @@ def setGamePath(self, path: Path | str) -> None: self._mappings.steamAPPId.set_value(steamid) for gogid, gogpath in BasicGame.gog_games.items(): if gogpath == path: - self._mappings.steamAPPId.set_value(gogid) + self._mappings.gogAPPId.set_value(gogid) for originid, originpath in BasicGame.origin_games.items(): if originpath == path: self._mappings.originManifestIds.set_value(originid) From 705452e55b73645c3189b0e9471daf9e69742204 Mon Sep 17 00:00:00 2001 From: Savathun Date: Thu, 24 Jul 2025 17:02:40 -0600 Subject: [PATCH 02/20] add baldurs gate plugin --- games/game_baldursgate3.py | 801 +++++++++++++++++++++++++++++++++++++ 1 file changed, 801 insertions(+) create mode 100644 games/game_baldursgate3.py diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py new file mode 100644 index 00000000..b78c1de1 --- /dev/null +++ b/games/game_baldursgate3.py @@ -0,0 +1,801 @@ +import configparser +import datetime +import difflib +import hashlib +import itertools +import json +import os +import re +import shutil +import subprocess +import traceback +import urllib.request +import zipfile +from configparser import SectionProxy +from functools import cached_property +from pathlib import Path +from typing import Any, Callable, Optional +from xml.etree import ElementTree +from xml.etree.ElementTree import Element + +from PyQt6 import QtCore +from PyQt6.QtCore import ( + QCoreApplication, + QEventLoop, + QRunnable, + Qt, + QThreadPool, + qDebug, + qInfo, + qWarning, +) +from PyQt6.QtWidgets import ( + QApplication, + QMainWindow, + QMessageBox, + QProgressDialog, +) + +import mobase + +from ..basic_features import ( + BasicGameSaveGameInfo, + BasicLocalSavegames, + BasicModDataChecker, + GlobPatterns, +) +from ..basic_game import BasicGame + +_loose_file_folders = ["Public", "Mods", "Generated", "Localization", "ScriptExtender"] + + +class BG3ModDataChecker(BasicModDataChecker): + def __init__(self): + super().__init__( + GlobPatterns( + valid=[ + "*.pak", # standard mods + "bin", # native mods / Script Extender + "Script Extender", # mods which are configured via jsons in this folder + "Data", # loose file mods + ], + move={ + "Root/": "", + "*.dll": "bin/", + "ScriptExtenderSettings.json": "bin/", + } # root builder not needed + | {f: "Data/" for f in _loose_file_folders}, + delete=["info.json", "*.txt"], + ) + ) + + +class Worker(QRunnable): + def __init__( + self, + fn: Callable[[mobase.IModInterface, bool], str], + mod: mobase.IModInterface, + auto_build_paks: bool, + metadata: dict[str, str], + ): + super().__init__() + self.fn = fn + self.mod = mod + self.autoBuildPaks = auto_build_paks + self.metadata = metadata + + def run(self): + self.metadata.update({self.mod.name(): self.fn(self.mod, self.autoBuildPaks)}) + + +class BG3Game(BasicGame, mobase.IPluginFileMapper): + Name = "Baldur's Gate 3 Plugin" + Author = "daescha" + Version = "0.1.0" + GameName = "Baldur's Gate 3" + GameShortName = "baldursgate3" + GameNexusName = "baldursgate3" + GameValidShortNames = ["bg3"] + GameLauncher = "Launcher/LariLauncher.exe" + GameBinary = "bin/bg3.exe" + GameDataPath = "" + GameDocumentsDirectory = ( + "%USERPROFILE%/AppData/Local/Larian Studios/Baldur's Gate 3" + ) + GameSavesDirectory = "%GAME_DOCUMENTS%/PlayerProfiles/Public/Savegames/Story" + GameSaveExtension = "lsv" + GameNexusId = 3474 + GameSteamId = 1086940 + GameGogId = 1456460669 + + _mod_cache: dict[Path, bool] = {} + _types = { + "Folder": "", + "MD5": "", + "Name": "", + "PublishHandle": "0", + "UUID": "", + "Version64": "0", + } + _mod_settings_xml_start = """ + + + + + + + + + + + + + + + """ + _mod_settings_xml_end = """ + + + + + +""" + + def __init__(self): + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(BG3ModDataChecker()) + self._register_feature(BasicGameSaveGameInfo(lambda s: s.with_suffix(".webp"))) + self._register_feature(BasicLocalSavegames(self.savesDirectory())) + organizer.onAboutToRun(self._construct_modsettings_xml) + organizer.onFinishedRun(self._on_finished_run) + organizer.onUserInterfaceInitialized(self._on_user_interface_initialized) + organizer.modList().onModInstalled(self._on_mod_installed) + organizer.onPluginSettingChanged(self._on_settings_changed) + return True + + def settings(self): + base_settings = super().settings() + custom_settings = [ + mobase.PluginSetting( + "force_load_dlls", + "Force load all dlls detected in active mods. Removes the need for 'Native Mod Loader' and similar mods.", + True, + ), + mobase.PluginSetting( + "log_diff", + "Log a diff of the modsettings.xml file before and after the game runs to check for differences.", + False, + ), + mobase.PluginSetting( + "delete_levelcache_folders_older_than_x_days", + "Maximum number of days a file in overwrite/LevelCache is allowed to exist before being deleted " + "after the executable finishes. Set to negative to disable.", + 3, + ), + mobase.PluginSetting( + "autobuild_paks", + "Autobuild folders likely to be PAK folders with every run of an executable.", + True, + ), + mobase.PluginSetting( + "remove_extracted_metadata", + "Remove extracted meta.lsx files when they are no longer needed.", + True, + ), + mobase.PluginSetting( + "force_reparse_metadata", + "Force reparsing mod metadata immediately.", + False, + ), + mobase.PluginSetting( + "check_for_lslib_updates", + "Check to see if there has been a new release of LSLib and create download dialog if so.", + False, + ), + ] + for setting in custom_settings: + setting.description = self.__tr(setting.description) + base_settings.append(setting) + return base_settings + + def executables(self) -> list[mobase.ExecutableInfo]: + return [ + mobase.ExecutableInfo( + f"{self.gameName()}: DX11", + self.gameDirectory().absoluteFilePath("bin/bg3_dx11.exe"), + ), + mobase.ExecutableInfo( + f"{self.gameName()}: Vulkan", + self.gameDirectory().absoluteFilePath(self.binaryName()), + ), + mobase.ExecutableInfo( + "Larian Launcher", + self.gameDirectory().absoluteFilePath(self.getLauncherName()), + ), + ] + + def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: + try: + efls = super().executableForcedLoads() + except AttributeError: + efls = [] + if self._get_setting("force_load_dlls"): + qInfo("detecting dlls in enabled mods") + libs: set[str] = set() + tree: mobase.IFileTree | mobase.FileTreeEntry | None = ( + self._organizer.virtualFileTree().find("bin") + ) + if type(tree) is not mobase.IFileTree: + return efls + + def find_dlls( + _: Any, entry: mobase.FileTreeEntry + ) -> mobase.IFileTree.WalkReturn: + relpath = entry.pathFrom(tree) + if ( + relpath + and entry.hasSuffix("dll") + and relpath not in self._base_dlls + ): + libs.add(relpath) + return mobase.IFileTree.WalkReturn.CONTINUE + + tree.walk(find_dlls) + exes = self.executables() + qDebug(f"dlls to force load: {libs}") + efls = efls + [ + mobase.ExecutableForcedLoadSetting( + exe.binary().fileName(), lib + ).withEnabled(True) + for lib in libs + for exe in exes + ] + return efls + + def mappings(self) -> list[mobase.Mapping]: + qInfo("creating custom bg3 mappings") + mappings: list[mobase.Mapping] = [] + docs_path = Path(self.documentsDirectory().path()) + + def map_files( + path: Path, + pattern: str = "*", + rel: bool = True, + ): + dest_func: Callable[[Path], str] = ( + (lambda f: os.path.relpath(f, path)) + if rel + else lambda f: f"Mods/{f.name}" + ) + for file in list(path.rglob(pattern)): + mappings.append( + mobase.Mapping( + source=str(file), + destination=str(docs_path / dest_func(file)), + is_directory=file.is_dir(), + create_target=True, + ) + ) + + progress = self._create_progress_window( + "Mapping files to documents folder", len(self._active_mods()) + 1 + ) + for mod in self._active_mods(): + modpath = Path(mod.absolutePath()) + map_files(modpath, pattern="*.pak", rel=False) + map_files(modpath / "Script Extender") + progress.setValue(progress.value() + 1) + QApplication.processEvents() + map_files(self._overwrite_path) + progress.setValue(len(self._active_mods()) + 1) + QApplication.processEvents() + return mappings + + @cached_property + def _base_dlls(self) -> set[str]: + base_bin = Path(self.gameDirectory().absoluteFilePath("bin")) + return {str(f.relative_to(base_bin)) for f in base_bin.glob("*.dll")} + + @cached_property + def _plugin_data_path(self) -> Path: + """Gets the path to the data folder for the current plugin.""" + return Path(self._organizer.pluginDataPath(), self.name()).absolute() + + @cached_property + def _overwrite_path(self): + return Path(self._organizer.overwritePath()) + + @cached_property + def _log_dir(self): + return Path(self._organizer.basePath()) / "logs/" + + @cached_property + def _modsettings_backup(self): + return self._plugin_data_path / "temp/modsettings.lsx" + + @cached_property + def _modsettings_path(self): + return self._overwrite_path / "PlayerProfiles/Public/modsettings.lsx" + + @cached_property + def _divine_command(self): + return f"{self._tools_dir / 'Divine.exe'} -g bg3 -l info" + + @cached_property + def _folder_pattern(self): + return re.compile("Data|Script Extender|bin") + + @cached_property + def _tools_dir(self): + return self._plugin_data_path / "tools" + + @cached_property + def _needed_lslib_files(self): + return { + self._tools_dir / x + for x in { + "CommandLineArgumentsParser.dll", + "Divine.dll", + "Divine.dll.config", + "Divine.exe", + "Divine.runtimeconfig.json", + "LSLib.dll", + "LSLibNative.dll", + "LZ4.dll", + "System.IO.Hashing.dll", + "ZstdSharp.dll", + } + } + + 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 __tr(self, trstr: str) -> str: + return QCoreApplication.translate(self.name(), trstr) + + def _active_mods(self) -> list[mobase.IModInterface]: + modlist = self._organizer.modList() + return [ + modlist.getMod(mod_name) + for mod_name in filter( + lambda mod: modlist.state(mod) & mobase.ModState.ACTIVE, + modlist.allModsByProfilePriority(), + ) + ] + + def _create_progress_window( + self, title: str, max_progress: int, msg: str = "" + ) -> QProgressDialog: + progress = QProgressDialog( + self.__tr(msg if msg else title), + self.__tr("Cancel"), + 0, + max_progress, + self._main_window, + ) + progress.setWindowTitle(self.__tr(f"BG3 Plugin: {title}")) + progress.setWindowModality(Qt.WindowModality.ApplicationModal) + progress.show() + return progress + + def _on_settings_changed( + self, + plugin_name: str, + setting: str, + old: mobase.MoVariant, + new: mobase.MoVariant, + ) -> None: + if self.name() != plugin_name or not new: + return + if setting == "check_for_lslib_updates": + try: + self._download_lslib_if_missing() + finally: + self._set_setting(setting, False) + elif setting == "force_reparse_metadata": + try: + self._construct_modsettings_xml() + finally: + self._set_setting(setting, False) + + def _on_user_interface_initialized(self, window: QMainWindow) -> None: + self._main_window = window + pass + + def _on_finished_run(self, x: str, y: int): + if self._get_setting("log_diff"): + for x in difflib.unified_diff( + open(self._modsettings_backup).readlines(), + open(self._modsettings_path).readlines(), + fromfile=str(self._modsettings_backup), + tofile=str(self._modsettings_path), + lineterm="", + ): + qDebug(x) + for path in self._overwrite_path.rglob("*.log"): + try: + (self._log_dir / path.name).unlink(missing_ok=True) + qDebug(f"moving {path} to {self._log_dir}") + shutil.move(path, self._log_dir) + except PermissionError as e: + qDebug(str(e)) + days = self._get_setting("delete_levelcache_folders_older_than_x_days") + if type(days) is int and days >= 0: + cutoff_time = datetime.datetime.now() - datetime.timedelta(days=days) + qDebug(f"cleaning folders in overwrite/LevelCache older than {cutoff_time}") + for path in self._overwrite_path.glob("LevelCache/*"): + if ( + datetime.datetime.fromtimestamp(os.path.getmtime(path)) + < cutoff_time + ): + shutil.rmtree(path, ignore_errors=True) + qDebug("cleaning empty dirs from overwrite directory") + for folder in sorted(list(os.walk(self._overwrite_path))[1:], reverse=True): + try: + os.rmdir(folder[0]) + except OSError: + pass + + def _construct_modsettings_xml(self, _: str = "") -> bool: + if not self._download_lslib_if_missing(): + return True + active_mods = self._active_mods() + autobuild_paks = self._get_setting("autobuild_paks") + progress = self._create_progress_window( + "Generating modsettings.xml", len(active_mods) + ) + threadpool = QThreadPool.globalInstance() + if threadpool is None or type(autobuild_paks) is not bool: + return False + metadata: dict[str, str] = {} + + def get_runnable(mod: mobase.IModInterface): + threadpool.start( + QRunnable.create( + lambda: metadata.update( + self._get_metadata_for_files_in_mod(mod, autobuild_paks) + ) + ) + ) + + for mod in active_mods: + get_runnable(mod) + count = 0 + num_active_mods = len(active_mods) + total_intervals_to_wait = (num_active_mods * 2) + 20 + while len(metadata.keys()) < num_active_mods: + progress.setValue(len(metadata.keys())) + QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 100) + count += 1 + if count == total_intervals_to_wait: + remaining_mods = {mod.name() for mod in active_mods} - metadata.keys() + qWarning(f"processing did not finish in time for: {remaining_mods}") + progress.setValue(num_active_mods) + break + QtCore.QThread.msleep(100) + qInfo(f"writing mod load order to {self._modsettings_path}") + self._modsettings_path.write_text( + ( + self._mod_settings_xml_start + + "".join( + metadata[mod.name()] + for mod in active_mods + if mod.name() in metadata + ) + + self._mod_settings_xml_end + ) + ) + qInfo( + f"backing up generated file {self._modsettings_path} to {self._modsettings_backup}, " + f"check the backup after the executable runs for differences with the file used by the game if you encounter issues" + ) + shutil.copy(self._modsettings_path, self._modsettings_backup) + return True + + def _on_mod_installed(self, mod: mobase.IModInterface) -> None: + if self._download_lslib_if_missing(): + self._get_metadata_for_files_in_mod( + mod, bool(self._get_setting("autobuild_paks")) + ) + + def _get_metadata_for_files_in_mod( + self, mod: mobase.IModInterface, auto_build_paks: bool + ): + return { + mod.name(): "".join( + [ + self._get_metadata_for_file(mod, file) + for file in sorted( + list(Path(mod.absolutePath()).rglob("*.pak")) + + ( + [ + f + for f in Path(mod.absolutePath()).glob("*") + if f.is_dir() + ] + if auto_build_paks + else [] + ) + ) + ] + ) + } + + def _get_metadata_for_file( + self, + mod: mobase.IModInterface, + file: Path, + force_recreate: Optional[bool] = None, + rm_extracted: Optional[bool] = None, + ) -> str: + def run_divine( + action: str, source: Path | str, extra_args: str = "" + ) -> subprocess.CompletedProcess[str]: + command = f'{self._divine_command} -a "{action}" -s "{source}" {extra_args}' + result = subprocess.run( + command, + creationflags=subprocess.CREATE_NO_WINDOW, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode: + qWarning( + f"{command.replace(str(Path.home()), '~', 1).replace(str(Path.home()), '$HOME')}" + f" returned stdout: {result.stdout}, stderr: {result.stderr}, code {result.returncode}" + ) + return result + + def get_module_short_desc() -> str: + return ( + "" + if not config.has_section(file.name) + or "override" in config[file.name].keys() + or "Name" not in config[file.name].keys() + else f""" + + + + + + + + """ + ) + + def get_attr_value(root: Element, attr_id: str) -> str: + default_val = self._types.get(attr_id) or "" + attr = root.find(f".//attribute[@id='{attr_id}']") + return default_val if attr is None else attr.get("value", default_val) + + def extract_data(output_file: Path) -> bool: + if run_divine( + "extract-single-file", + file, + extra_args=f'-d "{output_file}" -f meta.lsx', + ).returncode: + return False + if not output_file.exists(): + qInfo( + f"No meta.lsx files found in {file.name}, {file.name} determined to be an override mod" + ) + return False + return True + + def parse_meta_lsx(meta_file: Path, section: SectionProxy): + root = ( + ElementTree.parse(meta_file).getroot().find(".//node[@id='ModuleInfo']") + ) + if root is None: + qInfo(f"No ModuleInfo node found in meta.lsx for {mod.name()} ") + return + folder_name = get_attr_value(root, "Folder") + if file.is_dir(): + self._mod_cache[file] = ( + len(list(file.glob(f"*/{folder_name}/**"))) > 1 + or len(list(file.glob("Public/Engine/Timeline/MaterialGroups/*"))) + > 0 + ) + elif file not in self._mod_cache: + # a mod which has a meta.lsx and is not an override mod meets at least one of three conditions: + # 1. it has files in Public/Engine/Timeline/MaterialGroups, or + # 2. it has files in Mods// other than the meta.lsx file, or + # 3. it has files in Public/ + result = run_divine( + "list-package", + file, + extra_args=f'--use-regex -x "(/{folder_name}/(?!meta\\.lsx))|(Public/Engine/Timeline/MaterialGroups)"', + ) + self._mod_cache[file] = ( + result.returncode == 0 and result.stdout.strip() != "" + ) + if self._mod_cache[file]: + for key in self._types: + section[key] = get_attr_value(root, key) + else: + qInfo(f"pak {file.name} determined to be an override mod") + section["override"] = "True" + section["Folder"] = folder_name + + def metadata_to_ini(condition: bool, to_parse: Callable[[], Path]): + config[file.name] = {} + if condition: + parse_meta_lsx(to_parse(), config[file.name]) + else: + config[file.name]["override"] = "True" + with open(meta_ini, "w+", encoding="utf-8") as f: + config.write(f) + return get_module_short_desc() + + if force_recreate is None: + force_recreate = bool(self._get_setting("force_reparse_metadata")) + if rm_extracted is None: + rm_extracted = bool(self._get_setting("remove_extracted_metadata")) + meta_ini = Path(mod.absolutePath()) / "meta.ini" + config = configparser.ConfigParser() + config.read(meta_ini, encoding="utf-8") + try: + if file.name.endswith("pak"): + meta_file = ( + self._plugin_data_path + / f"temp/extracted_metadata/{file.name[: int(len(file.name) / 2)]}-{hashlib.md5(str(file).encode(), usedforsecurity=False).hexdigest()[:5]}.lsx" + ) + try: + if ( + not force_recreate + and config.has_section(file.name) + and ( + "override" in config[file.name].keys() + or "Folder" in config[file.name].keys() + ) + ): + return get_module_short_desc() + meta_file.parent.mkdir(parents=True, exist_ok=True) + meta_file.unlink(missing_ok=True) + return metadata_to_ini(extract_data(meta_file), lambda: meta_file) + finally: + if rm_extracted: + meta_file.unlink(missing_ok=True) + elif file.is_dir() and self._folder_pattern.search(file.name): + # qDebug(f"directory is not packable: {file}") + return "" + elif next( + itertools.chain( + file.glob(f"{folder}/*") for folder in _loose_file_folders + ), + False, + ): + qInfo(f"packable dir: {file}") + pak_path = self._overwrite_path / f"Mods/{file.name}.pak" + pak_path.unlink(missing_ok=True) + if run_divine( + "create-package", file, extra_args=f'-d "{pak_path}"' + ).returncode: + return "" + meta_files = list(file.glob("Mods/*/meta.lsx")) + return metadata_to_ini(len(meta_files) > 0, lambda: meta_files[0]) + else: + # qDebug(f"non packable dir, unlikely to be used by the game: {file}") + return "" + except Exception: + qWarning(traceback.format_exc()) + return "" + + def _download_lslib_if_missing(self): + if not self._get_setting("check_for_lslib_updates") and all( + x.exists() for x in self._needed_lslib_files + ): + return True + try: + self._tools_dir.mkdir(exist_ok=True, parents=True) + downloaded = False + + def reporthook(block_num: int, block_size: int, total_size: int) -> None: + if total_size > 0: + progress.setValue( + min(int(block_num * block_size * 100 / total_size), 100) + ) + QApplication.processEvents() + + with urllib.request.urlopen( + "https://api.github.com/repos/Norbyte/lslib/releases/latest" + ) as response: + assets = json.loads(response.read().decode("utf-8"))["assets"][0] + zip_path = self._tools_dir / assets["name"] + if not zip_path.exists(): + old_archives = list(self._tools_dir.glob("*.zip")) + msg_box = QMessageBox(self._main_window) + msg_box.setWindowTitle( + self.__tr("Baldur's Gate 3 Plugin - Missing dependencies") + ) + if old_archives: + msg_box.setText(self.__tr("LSLib update available.")) + else: + msg_box.setText( + self.__tr( + "LSLib tools are missing.\nThese are necessary for the plugin to create the load order file for BG3." + ) + ) + msg_box.addButton( + self.__tr("Download"), QMessageBox.ButtonRole.DestructiveRole + ) + exit_btn = msg_box.addButton( + self.__tr("Exit"), QMessageBox.ButtonRole.ActionRole + ) + msg_box.setIcon(QMessageBox.Icon.Warning) + msg_box.exec() + + if msg_box.clickedButton() == exit_btn: + if not old_archives: + err = QMessageBox(self._main_window) + err.setIcon(QMessageBox.Icon.Critical) + err.setText( + "LSLib tools are required for the proper generation of the modsettings.xml file, file will not be generated" + ) + return False + else: + progress = self._create_progress_window( + "Downloading LSLib", 100 + ) + urllib.request.urlretrieve( + assets["browser_download_url"], str(zip_path), reporthook + ) + downloaded = True + for archive in old_archives: + archive.unlink() + old_archives = [] + else: + old_archives = [] + new_msg = QMessageBox(self._main_window) + new_msg.setIcon(QMessageBox.Icon.Information) + new_msg.setText( + self.__tr("Latest version of LSLib already downloaded!") + ) + + except Exception as e: + qDebug(f"Download failed: {e}") + err = QMessageBox(self._main_window) + err.setIcon(QMessageBox.Icon.Critical) + err.setText(f"Failed to download LSLib tools:\n{traceback.format_exc()}") + err.exec() + return False + try: + if old_archives: + zip_path = sorted(old_archives)[-1] + if old_archives or not downloaded: + dialog_message = "Ensuring all necessary LSLib files have been extracted from archive..." + win_title = "Verifying LSLib files" + else: + dialog_message = "Extracting/Updating LSLib files..." + win_title = "Extracting LSLib" + x_progress = self._create_progress_window( + win_title, len(self._needed_lslib_files), msg=dialog_message + ) + with zipfile.ZipFile(zip_path, "r") as zip_ref: + for file in self._needed_lslib_files: + if downloaded or not file.exists(): + shutil.move( + zip_ref.extract( + f"Packed/Tools/{file.name}", self._tools_dir + ), + file, + ) + x_progress.setValue(x_progress.value() + 1) + QApplication.processEvents() + shutil.rmtree(self._tools_dir / "Packed", ignore_errors=True) + except Exception as e: + qDebug(f"Extraction failed: {e}") + err = QMessageBox(self._main_window) + err.setIcon(QMessageBox.Icon.Critical) + err.setText(f"Failed to extract LSLib tools:\n{traceback.format_exc()}") + err.exec() + return False + return True From 2da1311a4460de20cef4301e93b4aa92e692b3ed Mon Sep 17 00:00:00 2001 From: Savathun Date: Sat, 26 Jul 2025 16:12:20 -0600 Subject: [PATCH 03/20] add extract_full_pak setting --- games/game_baldursgate3.py | 157 +++++++++++++++++++++++-------------- 1 file changed, 100 insertions(+), 57 deletions(-) diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py index b78c1de1..1a8c8269 100644 --- a/games/game_baldursgate3.py +++ b/games/game_baldursgate3.py @@ -21,6 +21,7 @@ from PyQt6 import QtCore from PyQt6.QtCore import ( QCoreApplication, + QDir, QEventLoop, QRunnable, Qt, @@ -70,24 +71,6 @@ def __init__(self): ) -class Worker(QRunnable): - def __init__( - self, - fn: Callable[[mobase.IModInterface, bool], str], - mod: mobase.IModInterface, - auto_build_paks: bool, - metadata: dict[str, str], - ): - super().__init__() - self.fn = fn - self.mod = mod - self.autoBuildPaks = auto_build_paks - self.metadata = metadata - - def run(self): - self.metadata.update({self.mod.name(): self.fn(self.mod, self.autoBuildPaks)}) - - class BG3Game(BasicGame, mobase.IPluginFileMapper): Name = "Baldur's Gate 3 Plugin" Author = "daescha" @@ -196,6 +179,11 @@ def settings(self): "Check to see if there has been a new release of LSLib and create download dialog if so.", False, ), + mobase.PluginSetting( + "extract_full_package", + "Extract the full pak when parsing metadata, instead of just meta.lsx.", + False, + ), ] for setting in custom_settings: setting.description = self.__tr(setting.description) @@ -223,7 +211,7 @@ def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: efls = super().executableForcedLoads() except AttributeError: efls = [] - if self._get_setting("force_load_dlls"): + if self._force_load_dlls: qInfo("detecting dlls in enabled mods") libs: set[str] = set() tree: mobase.IFileTree | mobase.FileTreeEntry | None = ( @@ -260,6 +248,7 @@ def mappings(self) -> list[mobase.Mapping]: qInfo("creating custom bg3 mappings") mappings: list[mobase.Mapping] = [] docs_path = Path(self.documentsDirectory().path()) + active_mods = self._active_mods() def map_files( path: Path, @@ -282,16 +271,16 @@ def map_files( ) progress = self._create_progress_window( - "Mapping files to documents folder", len(self._active_mods()) + 1 + "Mapping files to documents folder", len(active_mods) + 1 ) - for mod in self._active_mods(): + for mod in active_mods: modpath = Path(mod.absolutePath()) map_files(modpath, pattern="*.pak", rel=False) map_files(modpath / "Script Extender") progress.setValue(progress.value() + 1) QApplication.processEvents() map_files(self._overwrite_path) - progress.setValue(len(self._active_mods()) + 1) + progress.setValue(len(active_mods) + 1) QApplication.processEvents() return mappings @@ -333,6 +322,26 @@ def _folder_pattern(self): def _tools_dir(self): return self._plugin_data_path / "tools" + @cached_property + def _autobuild_paks(self): + return bool(self._get_setting("autobuild_paks")) + + @cached_property + def _extract_full_package(self): + return bool(self._get_setting("extract_full_package")) + + @cached_property + def _remove_extracted_metadata(self): + return bool(self._get_setting("remove_extracted_metadata")) + + @cached_property + def _force_load_dlls(self): + return bool(self._get_setting("force_load_dlls")) + + @cached_property + def _log_diff(self): + return bool(self._get_setting("log_diff")) + @cached_property def _needed_lslib_files(self): return { @@ -385,6 +394,10 @@ def _create_progress_window( progress.show() return progress + def _refresh_attr(self, setting: str): + if hasattr(self, setting): + delattr(self, setting) + def _on_settings_changed( self, plugin_name: str, @@ -392,25 +405,37 @@ def _on_settings_changed( old: mobase.MoVariant, new: mobase.MoVariant, ) -> None: - if self.name() != plugin_name or not new: + if self.name() != plugin_name: return - if setting == "check_for_lslib_updates": + if new and setting == "check_for_lslib_updates": try: self._download_lslib_if_missing() finally: self._set_setting(setting, False) - elif setting == "force_reparse_metadata": + elif new and setting == "force_reparse_metadata": try: - self._construct_modsettings_xml() + self._construct_modsettings_xml( + exec_path="bin/bg3", force_reparse_metadata=True + ) finally: self._set_setting(setting, False) + elif setting in { + "extract_full_package", + "autobuild_paks", + "remove_extracted_metadata", + "force_load_dlls", + "log_diff", + }: + self._refresh_attr(f"_{setting}") def _on_user_interface_initialized(self, window: QMainWindow) -> None: self._main_window = window pass - def _on_finished_run(self, x: str, y: int): - if self._get_setting("log_diff"): + def _on_finished_run(self, exec_path: str, exit_code: int): + if "bin/bg3" not in exec_path: + return + if self._log_diff: for x in difflib.unified_diff( open(self._modsettings_backup).readlines(), open(self._modsettings_path).readlines(), @@ -443,16 +468,23 @@ def _on_finished_run(self, x: str, y: int): except OSError: pass - def _construct_modsettings_xml(self, _: str = "") -> bool: + def _construct_modsettings_xml( + self, + exec_path: str = "", + working_dir: Optional[QDir] = None, + args: str = "", + force_reparse_metadata: bool = False, + ) -> bool: + if "bin/bg3" not in exec_path: + return True if not self._download_lslib_if_missing(): return True active_mods = self._active_mods() - autobuild_paks = self._get_setting("autobuild_paks") progress = self._create_progress_window( "Generating modsettings.xml", len(active_mods) ) threadpool = QThreadPool.globalInstance() - if threadpool is None or type(autobuild_paks) is not bool: + if threadpool is None: return False metadata: dict[str, str] = {} @@ -460,7 +492,7 @@ def get_runnable(mod: mobase.IModInterface): threadpool.start( QRunnable.create( lambda: metadata.update( - self._get_metadata_for_files_in_mod(mod, autobuild_paks) + self._get_metadata_for_files_in_mod(mod, force_reparse_metadata) ) ) ) @@ -477,9 +509,11 @@ def get_runnable(mod: mobase.IModInterface): if count == total_intervals_to_wait: remaining_mods = {mod.name() for mod in active_mods} - metadata.keys() qWarning(f"processing did not finish in time for: {remaining_mods}") - progress.setValue(num_active_mods) + progress.cancel() break QtCore.QThread.msleep(100) + progress.setValue(num_active_mods) + QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 100) qInfo(f"writing mod load order to {self._modsettings_path}") self._modsettings_path.write_text( ( @@ -501,17 +535,15 @@ def get_runnable(mod: mobase.IModInterface): def _on_mod_installed(self, mod: mobase.IModInterface) -> None: if self._download_lslib_if_missing(): - self._get_metadata_for_files_in_mod( - mod, bool(self._get_setting("autobuild_paks")) - ) + self._get_metadata_for_files_in_mod(mod, True) def _get_metadata_for_files_in_mod( - self, mod: mobase.IModInterface, auto_build_paks: bool + self, mod: mobase.IModInterface, force_reparse_metadata: bool ): return { mod.name(): "".join( [ - self._get_metadata_for_file(mod, file) + self._get_metadata_for_file(mod, file, force_reparse_metadata) for file in sorted( list(Path(mod.absolutePath()).rglob("*.pak")) + ( @@ -520,7 +552,7 @@ def _get_metadata_for_files_in_mod( for f in Path(mod.absolutePath()).glob("*") if f.is_dir() ] - if auto_build_paks + if self._autobuild_paks else [] ) ) @@ -532,13 +564,12 @@ def _get_metadata_for_file( self, mod: mobase.IModInterface, file: Path, - force_recreate: Optional[bool] = None, - rm_extracted: Optional[bool] = None, + force_reparse_metadata: bool, ) -> str: def run_divine( - action: str, source: Path | str, extra_args: str = "" + action: str, source: Path | str ) -> subprocess.CompletedProcess[str]: - command = f'{self._divine_command} -a "{action}" -s "{source}" {extra_args}' + command = f'{self._divine_command} -a {action} -s "{source}"' result = subprocess.run( command, creationflags=subprocess.CREATE_NO_WINDOW, @@ -577,13 +608,30 @@ def get_attr_value(root: Element, attr_id: str) -> str: return default_val if attr is None else attr.get("value", default_val) def extract_data(output_file: Path) -> bool: + out_dir = str(output_file)[:-4] if self._extract_full_package else "" if run_divine( - "extract-single-file", + f'{"extract-package" if self._extract_full_package else "extract-single-file -f meta.lsx"} -d "{output_file if not self._extract_full_package else out_dir}"', file, - extra_args=f'-d "{output_file}" -f meta.lsx', ).returncode: return False - if not output_file.exists(): + if self._extract_full_package: + qDebug(f"archive {file} extracted to {out_dir}") + if run_divine( + f'convert-resources -d "{out_dir}" -i lsf -o lsx -x "*.lsf"', + out_dir, + ).returncode: + qDebug(f"failed to convert lsf files in {out_dir} to readable lsx") + extracted_meta_files = list(Path(out_dir).rglob("meta.lsx")) + if len(extracted_meta_files) == 0: + qInfo( + f"No meta.lsx files found in {file.name}, {file.name} determined to be an override mod" + ) + return False + shutil.copyfile( + extracted_meta_files[0], + output_file, + ) + elif not output_file.exists(): qInfo( f"No meta.lsx files found in {file.name}, {file.name} determined to be an override mod" ) @@ -610,9 +658,8 @@ def parse_meta_lsx(meta_file: Path, section: SectionProxy): # 2. it has files in Mods// other than the meta.lsx file, or # 3. it has files in Public/ result = run_divine( - "list-package", + f'list-package --use-regex -x "(/{folder_name}/(?!meta\\.lsx))|(Public/Engine/Timeline/MaterialGroups)"', file, - extra_args=f'--use-regex -x "(/{folder_name}/(?!meta\\.lsx))|(Public/Engine/Timeline/MaterialGroups)"', ) self._mod_cache[file] = ( result.returncode == 0 and result.stdout.strip() != "" @@ -635,10 +682,6 @@ def metadata_to_ini(condition: bool, to_parse: Callable[[], Path]): config.write(f) return get_module_short_desc() - if force_recreate is None: - force_recreate = bool(self._get_setting("force_reparse_metadata")) - if rm_extracted is None: - rm_extracted = bool(self._get_setting("remove_extracted_metadata")) meta_ini = Path(mod.absolutePath()) / "meta.ini" config = configparser.ConfigParser() config.read(meta_ini, encoding="utf-8") @@ -650,7 +693,7 @@ def metadata_to_ini(condition: bool, to_parse: Callable[[], Path]): ) try: if ( - not force_recreate + not force_reparse_metadata and config.has_section(file.name) and ( "override" in config[file.name].keys() @@ -662,8 +705,10 @@ def metadata_to_ini(condition: bool, to_parse: Callable[[], Path]): meta_file.unlink(missing_ok=True) return metadata_to_ini(extract_data(meta_file), lambda: meta_file) finally: - if rm_extracted: + if self._remove_extracted_metadata: meta_file.unlink(missing_ok=True) + if self._extract_full_package: + Path(str(meta_file)[:-4]).unlink(missing_ok=True) elif file.is_dir() and self._folder_pattern.search(file.name): # qDebug(f"directory is not packable: {file}") return "" @@ -676,9 +721,7 @@ def metadata_to_ini(condition: bool, to_parse: Callable[[], Path]): qInfo(f"packable dir: {file}") pak_path = self._overwrite_path / f"Mods/{file.name}.pak" pak_path.unlink(missing_ok=True) - if run_divine( - "create-package", file, extra_args=f'-d "{pak_path}"' - ).returncode: + if run_divine(f'create-package -d "{pak_path}"', file).returncode: return "" meta_files = list(file.glob("Mods/*/meta.lsx")) return metadata_to_ini(len(meta_files) > 0, lambda: meta_files[0]) From 1d3d0ac17b3b43b63120d93620b14855123c9551 Mon Sep 17 00:00:00 2001 From: Savathun Date: Sun, 27 Jul 2025 20:09:20 -0600 Subject: [PATCH 04/20] improve pak autobuilding --- games/game_baldursgate3.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py index 1a8c8269..68af2408 100644 --- a/games/game_baldursgate3.py +++ b/games/game_baldursgate3.py @@ -719,10 +719,33 @@ def metadata_to_ini(condition: bool, to_parse: Callable[[], Path]): False, ): qInfo(f"packable dir: {file}") - pak_path = self._overwrite_path / f"Mods/{file.name}.pak" - pak_path.unlink(missing_ok=True) - if run_divine(f'create-package -d "{pak_path}"', file).returncode: + if (file.parent / f"{file.name}.pak").exists(): + qInfo( + f"pak with same name as packable dir exists in mod directory. not packing dir {file}" + ) return "" + pak_path = self._overwrite_path / f"Mods/{file.name}.pak" + build_pak = True + if pak_path.exists(): + pak_creation_time = os.path.getmtime(pak_path) + + def changes_since_creation(): + for root, _, files in os.walk(file): + for f in files: + file_path = os.path.join(root, f) + try: + if os.path.getmtime(file_path) > pak_creation_time: + return True + except OSError as e: + qDebug(f"Error accessing file {file_path}: {e}") + return True + return False + + build_pak = changes_since_creation() + if build_pak: + pak_path.unlink(missing_ok=True) + if run_divine(f'create-package -d "{pak_path}"', file).returncode: + return "" meta_files = list(file.glob("Mods/*/meta.lsx")) return metadata_to_ini(len(meta_files) > 0, lambda: meta_files[0]) else: From 3f5eee5faaa36a2a2ef909b66b4acf45ea51b5d8 Mon Sep 17 00:00:00 2001 From: Savathun Date: Mon, 28 Jul 2025 11:54:08 -0600 Subject: [PATCH 05/20] fix script extender mapped paths --- games/game_baldursgate3.py | 131 +++++++++++++++++++------------------ 1 file changed, 67 insertions(+), 64 deletions(-) diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py index 68af2408..0254579a 100644 --- a/games/game_baldursgate3.py +++ b/games/game_baldursgate3.py @@ -11,7 +11,6 @@ import traceback import urllib.request import zipfile -from configparser import SectionProxy from functools import cached_property from pathlib import Path from typing import Any, Callable, Optional @@ -252,19 +251,18 @@ def mappings(self) -> list[mobase.Mapping]: def map_files( path: Path, + dest: Path = docs_path, pattern: str = "*", rel: bool = True, ): dest_func: Callable[[Path], str] = ( - (lambda f: os.path.relpath(f, path)) - if rel - else lambda f: f"Mods/{f.name}" + (lambda f: os.path.relpath(f, path)) if rel else lambda f: f.name ) for file in list(path.rglob(pattern)): mappings.append( mobase.Mapping( source=str(file), - destination=str(docs_path / dest_func(file)), + destination=str(dest / dest_func(file)), is_directory=file.is_dir(), create_target=True, ) @@ -273,15 +271,18 @@ def map_files( progress = self._create_progress_window( "Mapping files to documents folder", len(active_mods) + 1 ) + docs_path_mods = docs_path / "Mods" + docs_path_se = docs_path / "Script Extender" for mod in active_mods: modpath = Path(mod.absolutePath()) - map_files(modpath, pattern="*.pak", rel=False) - map_files(modpath / "Script Extender") + map_files(modpath, docs_path_mods, pattern="*.pak", rel=False) + map_files(modpath / "Script Extender", docs_path_se) progress.setValue(progress.value() + 1) QApplication.processEvents() map_files(self._overwrite_path) progress.setValue(len(active_mods) + 1) QApplication.processEvents() + progress.close() return mappings @cached_property @@ -316,7 +317,7 @@ def _divine_command(self): @cached_property def _folder_pattern(self): - return re.compile("Data|Script Extender|bin") + return re.compile("Data|Script Extender|bin|Mods") @cached_property def _tools_dir(self): @@ -394,10 +395,6 @@ def _create_progress_window( progress.show() return progress - def _refresh_attr(self, setting: str): - if hasattr(self, setting): - delattr(self, setting) - def _on_settings_changed( self, plugin_name: str, @@ -425,8 +422,8 @@ def _on_settings_changed( "remove_extracted_metadata", "force_load_dlls", "log_diff", - }: - self._refresh_attr(f"_{setting}") + } and hasattr(self, f"_{setting}"): + delattr(self, f"_{setting}") def _on_user_interface_initialized(self, window: QMainWindow) -> None: self._main_window = window @@ -514,6 +511,7 @@ def get_runnable(mod: mobase.IModInterface): QtCore.QThread.msleep(100) progress.setValue(num_active_mods) QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 100) + progress.close() qInfo(f"writing mod load order to {self._modsettings_path}") self._modsettings_path.write_text( ( @@ -638,44 +636,48 @@ def extract_data(output_file: Path) -> bool: return False return True - def parse_meta_lsx(meta_file: Path, section: SectionProxy): - root = ( - ElementTree.parse(meta_file).getroot().find(".//node[@id='ModuleInfo']") - ) - if root is None: - qInfo(f"No ModuleInfo node found in meta.lsx for {mod.name()} ") - return - folder_name = get_attr_value(root, "Folder") - if file.is_dir(): - self._mod_cache[file] = ( - len(list(file.glob(f"*/{folder_name}/**"))) > 1 - or len(list(file.glob("Public/Engine/Timeline/MaterialGroups/*"))) - > 0 - ) - elif file not in self._mod_cache: - # a mod which has a meta.lsx and is not an override mod meets at least one of three conditions: - # 1. it has files in Public/Engine/Timeline/MaterialGroups, or - # 2. it has files in Mods// other than the meta.lsx file, or - # 3. it has files in Public/ - result = run_divine( - f'list-package --use-regex -x "(/{folder_name}/(?!meta\\.lsx))|(Public/Engine/Timeline/MaterialGroups)"', - file, - ) - self._mod_cache[file] = ( - result.returncode == 0 and result.stdout.strip() != "" - ) - if self._mod_cache[file]: - for key in self._types: - section[key] = get_attr_value(root, key) - else: - qInfo(f"pak {file.name} determined to be an override mod") - section["override"] = "True" - section["Folder"] = folder_name - def metadata_to_ini(condition: bool, to_parse: Callable[[], Path]): config[file.name] = {} if condition: - parse_meta_lsx(to_parse(), config[file.name]) + root = ( + ElementTree.parse(to_parse()) + .getroot() + .find(".//node[@id='ModuleInfo']") + ) + if root is None: + qInfo(f"No ModuleInfo node found in meta.lsx for {mod.name()} ") + else: + section = config[file.name] + folder_name = get_attr_value(root, "Folder") + if file.is_dir(): + self._mod_cache[file] = ( + len(list(file.glob(f"*/{folder_name}/**"))) > 1 + or len( + list( + file.glob("Public/Engine/Timeline/MaterialGroups/*") + ) + ) + > 0 + ) + elif file not in self._mod_cache: + # a mod which has a meta.lsx and is not an override mod meets at least one of three conditions: + # 1. it has files in Public/Engine/Timeline/MaterialGroups, or + # 2. it has files in Mods// other than the meta.lsx file, or + # 3. it has files in Public/ + result = run_divine( + f'list-package --use-regex -x "(/{folder_name}/(?!meta\\.lsx))|(Public/Engine/Timeline/MaterialGroups)"', + file, + ) + self._mod_cache[file] = ( + result.returncode == 0 and result.stdout.strip() != "" + ) + if self._mod_cache[file]: + for key in self._types: + section[key] = get_attr_value(root, key) + else: + qInfo(f"pak {file.name} determined to be an override mod") + section["override"] = "True" + section["Folder"] = folder_name else: config[file.name]["override"] = "True" with open(meta_ini, "w+", encoding="utf-8") as f: @@ -719,7 +721,9 @@ def metadata_to_ini(condition: bool, to_parse: Callable[[], Path]): False, ): qInfo(f"packable dir: {file}") - if (file.parent / f"{file.name}.pak").exists(): + if (file.parent / f"{file.name}.pak").exists() or ( + file.parent / f"Mods/{file.name}.pak" + ).exists(): qInfo( f"pak with same name as packable dir exists in mod directory. not packing dir {file}" ) @@ -728,20 +732,17 @@ def metadata_to_ini(condition: bool, to_parse: Callable[[], Path]): build_pak = True if pak_path.exists(): pak_creation_time = os.path.getmtime(pak_path) - - def changes_since_creation(): - for root, _, files in os.walk(file): - for f in files: - file_path = os.path.join(root, f) - try: - if os.path.getmtime(file_path) > pak_creation_time: - return True - except OSError as e: - qDebug(f"Error accessing file {file_path}: {e}") - return True - return False - - build_pak = changes_since_creation() + for root, _, files in os.walk(file): + for f in files: + file_path = os.path.join(root, f) + try: + if os.path.getmtime(file_path) > pak_creation_time: + break + except OSError as e: + qDebug(f"Error accessing file {file_path}: {e}") + break + else: + build_pak = False if build_pak: pak_path.unlink(missing_ok=True) if run_divine(f'create-package -d "{pak_path}"', file).returncode: @@ -814,6 +815,7 @@ def reporthook(block_num: int, block_size: int, total_size: int) -> None: urllib.request.urlretrieve( assets["browser_download_url"], str(zip_path), reporthook ) + progress.close() downloaded = True for archive in old_archives: archive.unlink() @@ -856,6 +858,7 @@ def reporthook(block_num: int, block_size: int, total_size: int) -> None: ) x_progress.setValue(x_progress.value() + 1) QApplication.processEvents() + x_progress.close() shutil.rmtree(self._tools_dir / "Packed", ignore_errors=True) except Exception as e: qDebug(f"Extraction failed: {e}") From f24168a9e5209a80abbcef5b7b8e4d29df5c7ed9 Mon Sep 17 00:00:00 2001 From: Savathun Date: Sat, 2 Aug 2025 18:29:34 -0600 Subject: [PATCH 06/20] override dataLooksValid to get it to work with mod workspaces --- games/game_baldursgate3.py | 84 ++++++++++++++++++++++++++------------ 1 file changed, 59 insertions(+), 25 deletions(-) diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py index 0254579a..488531f3 100644 --- a/games/game_baldursgate3.py +++ b/games/game_baldursgate3.py @@ -17,6 +17,7 @@ from xml.etree import ElementTree from xml.etree.ElementTree import Element +import mobase from PyQt6 import QtCore from PyQt6.QtCore import ( QCoreApplication, @@ -36,38 +37,45 @@ QProgressDialog, ) -import mobase - from ..basic_features import ( BasicGameSaveGameInfo, BasicLocalSavegames, BasicModDataChecker, GlobPatterns, + utils, ) from ..basic_game import BasicGame -_loose_file_folders = ["Public", "Mods", "Generated", "Localization", "ScriptExtender"] - class BG3ModDataChecker(BasicModDataChecker): - def __init__(self): - super().__init__( - GlobPatterns( - valid=[ - "*.pak", # standard mods - "bin", # native mods / Script Extender - "Script Extender", # mods which are configured via jsons in this folder - "Data", # loose file mods - ], - move={ - "Root/": "", - "*.dll": "bin/", - "ScriptExtenderSettings.json": "bin/", - } # root builder not needed - | {f: "Data/" for f in _loose_file_folders}, - delete=["info.json", "*.txt"], - ) - ) + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + status = mobase.ModDataChecker.INVALID + rp = self._regex_patterns + for entry in filetree: + name = entry.name().casefold() + if rp.unfold.match(name): + if utils.is_directory(entry): + status = self.dataLooksValid(entry) + else: + status = mobase.ModDataChecker.INVALID + break + elif rp.valid.match(name): + if status is mobase.ModDataChecker.INVALID: + status = mobase.ModDataChecker.VALID + elif isinstance(entry, mobase.IFileTree): + status = ( + mobase.ModDataChecker.VALID + if all(rp.valid.match(e.pathFrom(filetree)) for e in entry) + else mobase.ModDataChecker.INVALID + ) + elif rp.delete.match(name) or rp.move_match(name) is not None: + status = mobase.ModDataChecker.FIXABLE + else: + status = mobase.ModDataChecker.INVALID + break + return status class BG3Game(BasicGame, mobase.IPluginFileMapper): @@ -89,7 +97,13 @@ class BG3Game(BasicGame, mobase.IPluginFileMapper): GameNexusId = 3474 GameSteamId = 1086940 GameGogId = 1456460669 - + _loose_file_folders = { + "Public", + "Mods", + "Generated", + "Localization", + "ScriptExtender", + } _mod_cache: dict[Path, bool] = {} _types = { "Folder": "", @@ -129,7 +143,27 @@ def __init__(self): def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) - self._register_feature(BG3ModDataChecker()) + self._register_feature( + BG3ModDataChecker( + GlobPatterns( + valid=[ + "*.pak", + str(Path("Mods") / "*.pak"), # standard mods + "bin", # native mods / Script Extender + "Script Extender", # mods which are configured via jsons in this folder + "Data", # loose file mods + ] + + [str(Path("*") / f) for f in self._loose_file_folders], + move={ + "Root/": "", + "*.dll": "bin/", + "ScriptExtenderSettings.json": "bin/", + } # root builder not needed + | {f: "Data/" for f in self._loose_file_folders}, + delete=["info.json", "*.txt"], + ) + ) + ) self._register_feature(BasicGameSaveGameInfo(lambda s: s.with_suffix(".webp"))) self._register_feature(BasicLocalSavegames(self.savesDirectory())) organizer.onAboutToRun(self._construct_modsettings_xml) @@ -716,7 +750,7 @@ def metadata_to_ini(condition: bool, to_parse: Callable[[], Path]): return "" elif next( itertools.chain( - file.glob(f"{folder}/*") for folder in _loose_file_folders + file.glob(f"{folder}/*") for folder in self._loose_file_folders ), False, ): From 4ac2a122f5ba37c614df28abb0f160b306f36d2e Mon Sep 17 00:00:00 2001 From: Savathun Date: Sat, 2 Aug 2025 18:37:21 -0600 Subject: [PATCH 07/20] ensure dirs exist before writing modsettings --- games/game_baldursgate3.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py index 488531f3..424edfd3 100644 --- a/games/game_baldursgate3.py +++ b/games/game_baldursgate3.py @@ -17,7 +17,6 @@ from xml.etree import ElementTree from xml.etree.ElementTree import Element -import mobase from PyQt6 import QtCore from PyQt6.QtCore import ( QCoreApplication, @@ -37,6 +36,8 @@ QProgressDialog, ) +import mobase + from ..basic_features import ( BasicGameSaveGameInfo, BasicLocalSavegames, @@ -477,9 +478,8 @@ def _on_finished_run(self, exec_path: str, exit_code: int): qDebug(x) for path in self._overwrite_path.rglob("*.log"): try: - (self._log_dir / path.name).unlink(missing_ok=True) qDebug(f"moving {path} to {self._log_dir}") - shutil.move(path, self._log_dir) + shutil.move(path, self._log_dir / path.name) except PermissionError as e: qDebug(str(e)) days = self._get_setting("delete_levelcache_folders_older_than_x_days") @@ -547,6 +547,7 @@ def get_runnable(mod: mobase.IModInterface): QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 100) progress.close() qInfo(f"writing mod load order to {self._modsettings_path}") + self._modsettings_path.parent.mkdir(parents=True, exist_ok=True) self._modsettings_path.write_text( ( self._mod_settings_xml_start From bfc72b2391453096fa248d56585074fc44004736 Mon Sep 17 00:00:00 2001 From: Savathun Date: Tue, 5 Aug 2025 19:29:39 -0600 Subject: [PATCH 08/20] honor cancellations of progress dialogs where possible --- games/game_baldursgate3.py | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py index 424edfd3..1ad0580a 100644 --- a/games/game_baldursgate3.py +++ b/games/game_baldursgate3.py @@ -314,6 +314,9 @@ def map_files( map_files(modpath / "Script Extender", docs_path_se) progress.setValue(progress.value() + 1) QApplication.processEvents() + if progress.wasCanceled(): + qWarning("mapping canceled by user") + return mappings map_files(self._overwrite_path) progress.setValue(len(active_mods) + 1) QApplication.processEvents() @@ -416,11 +419,11 @@ def _active_mods(self) -> list[mobase.IModInterface]: ] def _create_progress_window( - self, title: str, max_progress: int, msg: str = "" + self, title: str, max_progress: int, msg: str = "", cancelable: bool = True ) -> QProgressDialog: progress = QProgressDialog( self.__tr(msg if msg else title), - self.__tr("Cancel"), + self.__tr("Cancel") if cancelable else None, 0, max_progress, self._main_window, @@ -506,9 +509,7 @@ def _construct_modsettings_xml( args: str = "", force_reparse_metadata: bool = False, ) -> bool: - if "bin/bg3" not in exec_path: - return True - if not self._download_lslib_if_missing(): + if "bin/bg3" not in exec_path or not self._download_lslib_if_missing(): return True active_mods = self._active_mods() progress = self._create_progress_window( @@ -519,17 +520,16 @@ def _construct_modsettings_xml( return False metadata: dict[str, str] = {} - def get_runnable(mod: mobase.IModInterface): - threadpool.start( - QRunnable.create( - lambda: metadata.update( - self._get_metadata_for_files_in_mod(mod, force_reparse_metadata) - ) - ) + def retrieve_mod_metadata_in_new_thread(mod: mobase.IModInterface): + return lambda: metadata.update( + self._get_metadata_for_files_in_mod(mod, force_reparse_metadata) ) for mod in active_mods: - get_runnable(mod) + if progress.wasCanceled(): + qWarning("processing canceled by user") + return False + threadpool.start(QRunnable.create(retrieve_mod_metadata_in_new_thread(mod))) count = 0 num_active_mods = len(active_mods) total_intervals_to_wait = (num_active_mods * 2) + 20 @@ -537,10 +537,10 @@ def get_runnable(mod: mobase.IModInterface): progress.setValue(len(metadata.keys())) QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 100) count += 1 - if count == total_intervals_to_wait: + if count == total_intervals_to_wait or progress.wasCanceled(): remaining_mods = {mod.name() for mod in active_mods} - metadata.keys() qWarning(f"processing did not finish in time for: {remaining_mods}") - progress.cancel() + progress.close() break QtCore.QThread.msleep(100) progress.setValue(num_active_mods) @@ -845,7 +845,7 @@ def reporthook(block_num: int, block_size: int, total_size: int) -> None: return False else: progress = self._create_progress_window( - "Downloading LSLib", 100 + "Downloading LSLib", 100, cancelable=False ) urllib.request.urlretrieve( assets["browser_download_url"], str(zip_path), reporthook @@ -893,6 +893,9 @@ def reporthook(block_num: int, block_size: int, total_size: int) -> None: ) x_progress.setValue(x_progress.value() + 1) QApplication.processEvents() + if x_progress.wasCanceled(): + qWarning("processing canceled by user") + return False x_progress.close() shutil.rmtree(self._tools_dir / "Packed", ignore_errors=True) except Exception as e: From 3fcb85b48d6f082f674df876f2c3f458bb6cc209 Mon Sep 17 00:00:00 2001 From: Savathun Date: Wed, 6 Aug 2025 00:33:05 -0600 Subject: [PATCH 09/20] add yaml <-> json conversion options --- games/game_baldursgate3.py | 90 +++++++++++++++++++++++++++++++++++++- plugin-requirements.txt | 1 + poetry.lock | 4 +- pyproject.toml | 1 + 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py index 1ad0580a..6d23b056 100644 --- a/games/game_baldursgate3.py +++ b/games/game_baldursgate3.py @@ -17,6 +17,7 @@ from xml.etree import ElementTree from xml.etree.ElementTree import Element +import yaml from PyQt6 import QtCore from PyQt6.QtCore import ( QCoreApplication, @@ -208,6 +209,11 @@ def settings(self): "Force reparsing mod metadata immediately.", False, ), + mobase.PluginSetting( + "convert_jsons_to_yaml", + "Convert all jsons in active mods to yaml immediately.", + False, + ), mobase.PluginSetting( "check_for_lslib_updates", "Check to see if there has been a new release of LSLib and create download dialog if so.", @@ -218,6 +224,16 @@ def settings(self): "Extract the full pak when parsing metadata, instead of just meta.lsx.", False, ), + mobase.PluginSetting( + "convert_yamls_to_json", + "Convert YAMLs to JSONs when executable runs. Allows one to configure ScriptExtender and related mods with YAML files.", + False, + ), + mobase.PluginSetting( + "convert_yamls_to_json", + "Convert YAMLs to JSONs when executable runs. Allows one to configure ScriptExtender and related mods with YAML files.", + False, + ), ] for setting in custom_settings: setting.description = self.__tr(setting.description) @@ -293,7 +309,9 @@ def map_files( dest_func: Callable[[Path], str] = ( (lambda f: os.path.relpath(f, path)) if rel else lambda f: f.name ) - for file in list(path.rglob(pattern)): + found_jsons: set[Path] = set() + + def add_mapping(file: Path): mappings.append( mobase.Mapping( source=str(file), @@ -303,6 +321,33 @@ def map_files( ) ) + for file in list(path.rglob(pattern)): + if self._convert_yamls_to_json and ( + file.name.endswith(".yaml") or file.name.endswith(".yml") + ): + converted_path = file.parent / file.name.replace( + ".yaml", ".json" + ).replace(".yml", ".json") + try: + if not converted_path.exists() or os.path.getmtime( + file + ) > os.path.getmtime(converted_path): + with open(file, "r") as yaml_file: + with open(converted_path, "w") as json_file: + json.dump( + yaml.safe_load(yaml_file), json_file, indent=2 + ) + qDebug(f"Converted {file} to JSON") + found_jsons.add(converted_path) + except OSError as e: + qWarning(f"Error accessing file {converted_path}: {e}") + elif file.name.endswith(".json"): + found_jsons.add(file) + else: + add_mapping(file) + for file in found_jsons: + add_mapping(file) + progress = self._create_progress_window( "Mapping files to documents folder", len(active_mods) + 1 ) @@ -323,6 +368,39 @@ def map_files( progress.close() return mappings + def _convert_jsons_to_yaml(self): + qInfo("converting all json files to yaml") + active_mods = self._active_mods() + + def map_files(path: Path): + for file in list(path.rglob("*.json")): + converted_path = file.parent / file.name.replace(".json", ".yaml") + try: + if not converted_path.exists() or os.path.getmtime( + file + ) > os.path.getmtime(converted_path): + with open(file, "r") as json_file: + with open(converted_path, "w") as yaml_file: + yaml.dump(json.load(json_file), yaml_file, indent=2) + qInfo(f"Converted {file} to YAML") + except OSError as e: + qWarning(f"Error accessing file {converted_path}: {e}") + + progress = self._create_progress_window( + "Converting all json files to yaml", len(active_mods) + 1 + ) + for mod in active_mods: + map_files(Path(mod.absolutePath())) + progress.setValue(progress.value() + 1) + QApplication.processEvents() + if progress.wasCanceled(): + qWarning("conversion canceled by user") + return + map_files(self._overwrite_path) + progress.setValue(len(active_mods) + 1) + QApplication.processEvents() + progress.close() + @cached_property def _base_dlls(self) -> set[str]: base_bin = Path(self.gameDirectory().absoluteFilePath("bin")) @@ -381,6 +459,10 @@ def _force_load_dlls(self): def _log_diff(self): return bool(self._get_setting("log_diff")) + @cached_property + def _convert_yamls_to_json(self): + return bool(self._get_setting("convert_yamls_to_json")) + @cached_property def _needed_lslib_files(self): return { @@ -454,12 +536,18 @@ def _on_settings_changed( ) finally: self._set_setting(setting, False) + elif new and setting == "convert_jsons_to_yaml": + try: + self._convert_jsons_to_yaml() + finally: + self._set_setting(setting, False) elif setting in { "extract_full_package", "autobuild_paks", "remove_extracted_metadata", "force_load_dlls", "log_diff", + "convert_yamls_to_json", } and hasattr(self, f"_{setting}"): delattr(self, f"_{setting}") diff --git a/plugin-requirements.txt b/plugin-requirements.txt index 1f1295ea..f945efdf 100644 --- a/plugin-requirements.txt +++ b/plugin-requirements.txt @@ -1,3 +1,4 @@ psutil==5.8.0 vdf==3.4 lzokay==1.1.5 +pyyaml==6.0.2 diff --git a/poetry.lock b/poetry.lock index b4ac5a80..a4066dd5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -210,7 +210,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -334,4 +334,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "10b882dbbaae72534c631994926861048e8183e18ebbb85c2c46376bdbc70446" +content-hash = "b3a65da37845a56d4b259c4d73913b8957a61382499eeda8bdce2343a48e8c66" diff --git a/pyproject.toml b/pyproject.toml index f5313f5b..89778ca2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ psutil = "^5.8.0" vdf = "3.4" lzokay = "1.1.5" pyqt6 = "6.7.0" +pyyaml = "^6.0.2" [tool.poetry.group.dev.dependencies] mobase-stubs = "^2.5.2" From b63f5820bfd8200ffb255b4b177b9b6379c6390d Mon Sep 17 00:00:00 2001 From: Savathun Date: Sat, 9 Aug 2025 17:05:45 -0600 Subject: [PATCH 10/20] move bg3 data checker class to baldursgate3/bg3_data_checker.py --- games/baldursgate3/__init__.py | 0 games/baldursgate3/bg3_data_checker.py | 34 ++++++++++++++++++++++ games/game_baldursgate3.py | 39 ++------------------------ 3 files changed, 37 insertions(+), 36 deletions(-) create mode 100644 games/baldursgate3/__init__.py create mode 100644 games/baldursgate3/bg3_data_checker.py diff --git a/games/baldursgate3/__init__.py b/games/baldursgate3/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/games/baldursgate3/bg3_data_checker.py b/games/baldursgate3/bg3_data_checker.py new file mode 100644 index 00000000..05e60a9a --- /dev/null +++ b/games/baldursgate3/bg3_data_checker.py @@ -0,0 +1,34 @@ +import mobase + +from basic_features import BasicModDataChecker, utils + + +class BG3ModDataChecker(BasicModDataChecker): + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + status = mobase.ModDataChecker.INVALID + rp = self._regex_patterns + for entry in filetree: + name = entry.name().casefold() + if rp.unfold.match(name): + if utils.is_directory(entry): + status = self.dataLooksValid(entry) + else: + status = mobase.ModDataChecker.INVALID + break + elif rp.valid.match(name): + if status is mobase.ModDataChecker.INVALID: + status = mobase.ModDataChecker.VALID + elif isinstance(entry, mobase.IFileTree): + status = ( + mobase.ModDataChecker.VALID + if all(rp.valid.match(e.pathFrom(filetree)) for e in entry) + else mobase.ModDataChecker.INVALID + ) + elif rp.delete.match(name) or rp.move_match(name) is not None: + status = mobase.ModDataChecker.FIXABLE + else: + status = mobase.ModDataChecker.INVALID + break + return status diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py index 6d23b056..bee02df0 100644 --- a/games/game_baldursgate3.py +++ b/games/game_baldursgate3.py @@ -17,6 +17,7 @@ from xml.etree import ElementTree from xml.etree.ElementTree import Element +import mobase import yaml from PyQt6 import QtCore from PyQt6.QtCore import ( @@ -37,49 +38,14 @@ QProgressDialog, ) -import mobase - from ..basic_features import ( BasicGameSaveGameInfo, BasicLocalSavegames, - BasicModDataChecker, GlobPatterns, - utils, ) from ..basic_game import BasicGame -class BG3ModDataChecker(BasicModDataChecker): - def dataLooksValid( - self, filetree: mobase.IFileTree - ) -> mobase.ModDataChecker.CheckReturn: - status = mobase.ModDataChecker.INVALID - rp = self._regex_patterns - for entry in filetree: - name = entry.name().casefold() - if rp.unfold.match(name): - if utils.is_directory(entry): - status = self.dataLooksValid(entry) - else: - status = mobase.ModDataChecker.INVALID - break - elif rp.valid.match(name): - if status is mobase.ModDataChecker.INVALID: - status = mobase.ModDataChecker.VALID - elif isinstance(entry, mobase.IFileTree): - status = ( - mobase.ModDataChecker.VALID - if all(rp.valid.match(e.pathFrom(filetree)) for e in entry) - else mobase.ModDataChecker.INVALID - ) - elif rp.delete.match(name) or rp.move_match(name) is not None: - status = mobase.ModDataChecker.FIXABLE - else: - status = mobase.ModDataChecker.INVALID - break - return status - - class BG3Game(BasicGame, mobase.IPluginFileMapper): Name = "Baldur's Gate 3 Plugin" Author = "daescha" @@ -145,8 +111,9 @@ def __init__(self): def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) + from baldursgate3 import bg3_data_checker self._register_feature( - BG3ModDataChecker( + bg3_data_checker.BG3ModDataChecker( GlobPatterns( valid=[ "*.pak", From 848367d4bcc2e89bc3b7c31d35cb38d9b784eaae Mon Sep 17 00:00:00 2001 From: Savathun Date: Sat, 9 Aug 2025 17:06:30 -0600 Subject: [PATCH 11/20] move various utils into bg3utils, move lslib retrieval into lslib_retriever.py --- games/baldursgate3/bg3_utils.py | 51 +++++++ games/baldursgate3/lslib_retriever.py | 148 ++++++++++++++++++ games/game_baldursgate3.py | 206 +++----------------------- 3 files changed, 218 insertions(+), 187 deletions(-) create mode 100644 games/baldursgate3/bg3_utils.py create mode 100644 games/baldursgate3/lslib_retriever.py diff --git a/games/baldursgate3/bg3_utils.py b/games/baldursgate3/bg3_utils.py new file mode 100644 index 00000000..ae00d14f --- /dev/null +++ b/games/baldursgate3/bg3_utils.py @@ -0,0 +1,51 @@ +from functools import cached_property +from pathlib import Path + +import mobase +from PyQt6.QtCore import QCoreApplication, Qt +from PyQt6.QtWidgets import QProgressDialog, QMainWindow + +from games.baldursgate3.lslib_retriever import LSLibRetriever + + +class BG3Utils: + loose_file_folders = { + "Public", + "Mods", + "Generated", + "Localization", + "ScriptExtender", + } + def __init__(self, organizer: mobase.IOrganizer, name: str): + self.main_window = None + self._organizer = organizer + self._name = name + self.lslib_retriever = LSLibRetriever(self) + @cached_property + def plugin_data_path(self) -> Path: + """Gets the path to the data folder for the current plugin.""" + return Path(self._organizer.pluginDataPath(), self._name).absolute() + @cached_property + def tools_dir(self): + return self.plugin_data_path / "tools" + def get_setting(self, key: str) -> mobase.MoVariant: + return self._organizer.pluginSetting(self._name, key) + def tr(self, trstr: str) -> str: + return QCoreApplication.translate(self._name, trstr) + def create_progress_window( + self, title: str, max_progress: int, msg: str = "", cancelable: bool = True + ) -> QProgressDialog: + progress = QProgressDialog( + self.tr(msg if msg else title), + self.tr("Cancel") if cancelable else None, + 0, + max_progress, + self.main_window, + ) + progress.setWindowTitle(self.tr(f"BG3 Plugin: {title}")) + progress.setWindowModality(Qt.WindowModality.ApplicationModal) + progress.show() + return progress + def on_user_interface_initialized(self, window: QMainWindow) -> None: + self.main_window = window + pass diff --git a/games/baldursgate3/lslib_retriever.py b/games/baldursgate3/lslib_retriever.py new file mode 100644 index 00000000..726e1e7c --- /dev/null +++ b/games/baldursgate3/lslib_retriever.py @@ -0,0 +1,148 @@ +import json +import shutil +import traceback +import urllib.request +import zipfile +from functools import cached_property + +from PyQt6.QtCore import qDebug, qWarning +from PyQt6.QtWidgets import QMessageBox, QApplication + +from games.baldursgate3 import bg3_utils + + +class LSLibRetriever: + def __init__(self, utils: bg3_utils.BG3Utils): + self._utils = utils + @cached_property + def _needed_lslib_files(self): + return { + self._utils.tools_dir / x + for x in { + "CommandLineArgumentsParser.dll", + "Divine.dll", + "Divine.dll.config", + "Divine.exe", + "Divine.runtimeconfig.json", + "LSLib.dll", + "LSLibNative.dll", + "LZ4.dll", + "System.IO.Hashing.dll", + "ZstdSharp.dll", + } + } + def download_lslib_if_missing(self): + if not self._utils.get_setting("check_for_lslib_updates") and all( + x.exists() for x in self._needed_lslib_files + ): + return True + try: + self._utils.tools_dir.mkdir(exist_ok=True, parents=True) + downloaded = False + + def reporthook(block_num: int, block_size: int, total_size: int) -> None: + if total_size > 0: + progress.setValue( + min(int(block_num * block_size * 100 / total_size), 100) + ) + QApplication.processEvents() + + with urllib.request.urlopen( + "https://api.github.com/repos/Norbyte/lslib/releases/latest" + ) as response: + assets = json.loads(response.read().decode("utf-8"))["assets"][0] + zip_path = self._utils.tools_dir / assets["name"] + if not zip_path.exists(): + old_archives = list(self._utils.tools_dir.glob("*.zip")) + msg_box = QMessageBox(self._utils.main_window) + msg_box.setWindowTitle( + self._utils.tr("Baldur's Gate 3 Plugin - Missing dependencies") + ) + if old_archives: + msg_box.setText(self._utils.tr("LSLib update available.")) + else: + msg_box.setText( + self._utils.tr( + "LSLib tools are missing.\nThese are necessary for the plugin to create the load order file for BG3." + ) + ) + msg_box.addButton( + self._utils.tr("Download"), QMessageBox.ButtonRole.DestructiveRole + ) + exit_btn = msg_box.addButton( + self._utils.tr("Exit"), QMessageBox.ButtonRole.ActionRole + ) + msg_box.setIcon(QMessageBox.Icon.Warning) + msg_box.exec() + + if msg_box.clickedButton() == exit_btn: + if not old_archives: + err = QMessageBox(self._utils.main_window) + err.setIcon(QMessageBox.Icon.Critical) + err.setText( + "LSLib tools are required for the proper generation of the modsettings.xml file, file will not be generated" + ) + return False + else: + progress = self._utils.create_progress_window( + "Downloading LSLib", 100, cancelable=False + ) + urllib.request.urlretrieve( + assets["browser_download_url"], str(zip_path), reporthook + ) + progress.close() + downloaded = True + for archive in old_archives: + archive.unlink() + old_archives = [] + else: + old_archives = [] + new_msg = QMessageBox(self._utils.main_window) + new_msg.setIcon(QMessageBox.Icon.Information) + new_msg.setText( + self._utils.tr("Latest version of LSLib already downloaded!") + ) + + except Exception as e: + qDebug(f"Download failed: {e}") + err = QMessageBox(self._utils.main_window) + err.setIcon(QMessageBox.Icon.Critical) + err.setText(f"Failed to download LSLib tools:\n{traceback.format_exc()}") + err.exec() + return False + try: + if old_archives: + zip_path = sorted(old_archives)[-1] + if old_archives or not downloaded: + dialog_message = "Ensuring all necessary LSLib files have been extracted from archive..." + win_title = "Verifying LSLib files" + else: + dialog_message = "Extracting/Updating LSLib files..." + win_title = "Extracting LSLib" + x_progress = self._utils.create_progress_window( + win_title, len(self._needed_lslib_files), msg=dialog_message + ) + with zipfile.ZipFile(zip_path, "r") as zip_ref: + for file in self._needed_lslib_files: + if downloaded or not file.exists(): + shutil.move( + zip_ref.extract( + f"Packed/Tools/{file.name}", self._utils.tools_dir + ), + file, + ) + x_progress.setValue(x_progress.value() + 1) + QApplication.processEvents() + if x_progress.wasCanceled(): + qWarning("processing canceled by user") + return False + x_progress.close() + shutil.rmtree(self._utils.tools_dir / "Packed", ignore_errors=True) + except Exception as e: + qDebug(f"Extraction failed: {e}") + err = QMessageBox(self._utils.main_window) + err.setIcon(QMessageBox.Icon.Critical) + err.setText(f"Failed to extract LSLib tools:\n{traceback.format_exc()}") + err.exec() + return False + return True diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py index bee02df0..52efe99f 100644 --- a/games/game_baldursgate3.py +++ b/games/game_baldursgate3.py @@ -9,8 +9,6 @@ import shutil import subprocess import traceback -import urllib.request -import zipfile from functools import cached_property from pathlib import Path from typing import Any, Callable, Optional @@ -21,11 +19,9 @@ import yaml from PyQt6 import QtCore from PyQt6.QtCore import ( - QCoreApplication, QDir, QEventLoop, QRunnable, - Qt, QThreadPool, qDebug, qInfo, @@ -33,9 +29,6 @@ ) from PyQt6.QtWidgets import ( QApplication, - QMainWindow, - QMessageBox, - QProgressDialog, ) from ..basic_features import ( @@ -108,10 +101,12 @@ class BG3Game(BasicGame, mobase.IPluginFileMapper): def __init__(self): BasicGame.__init__(self) mobase.IPluginFileMapper.__init__(self) + self._utils = None def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) - from baldursgate3 import bg3_data_checker + from baldursgate3 import bg3_data_checker, bg3_utils + self._utils = bg3_utils.BG3Utils(organizer, self.name()) self._register_feature( bg3_data_checker.BG3ModDataChecker( GlobPatterns( @@ -137,7 +132,7 @@ def init(self, organizer: mobase.IOrganizer) -> bool: self._register_feature(BasicLocalSavegames(self.savesDirectory())) organizer.onAboutToRun(self._construct_modsettings_xml) organizer.onFinishedRun(self._on_finished_run) - organizer.onUserInterfaceInitialized(self._on_user_interface_initialized) + organizer.onUserInterfaceInitialized(self._utils.on_user_interface_initialized) organizer.modList().onModInstalled(self._on_mod_installed) organizer.onPluginSettingChanged(self._on_settings_changed) return True @@ -203,7 +198,7 @@ def settings(self): ), ] for setting in custom_settings: - setting.description = self.__tr(setting.description) + setting.description = self._utils.tr(setting.description) base_settings.append(setting) return base_settings @@ -315,7 +310,7 @@ def add_mapping(file: Path): for file in found_jsons: add_mapping(file) - progress = self._create_progress_window( + progress = self._utils.create_progress_window( "Mapping files to documents folder", len(active_mods) + 1 ) docs_path_mods = docs_path / "Mods" @@ -353,7 +348,7 @@ def map_files(path: Path): except OSError as e: qWarning(f"Error accessing file {converted_path}: {e}") - progress = self._create_progress_window( + progress = self._utils.create_progress_window( "Converting all json files to yaml", len(active_mods) + 1 ) for mod in active_mods: @@ -396,67 +391,39 @@ def _modsettings_path(self): @cached_property def _divine_command(self): - return f"{self._tools_dir / 'Divine.exe'} -g bg3 -l info" + return f"{self._utils.tools_dir / 'Divine.exe'} -g bg3 -l info" @cached_property def _folder_pattern(self): return re.compile("Data|Script Extender|bin|Mods") - @cached_property - def _tools_dir(self): - return self._plugin_data_path / "tools" - @cached_property def _autobuild_paks(self): - return bool(self._get_setting("autobuild_paks")) + return bool(self._utils.get_setting("autobuild_paks")) @cached_property def _extract_full_package(self): - return bool(self._get_setting("extract_full_package")) + return bool(self._utils.get_setting("extract_full_package")) @cached_property def _remove_extracted_metadata(self): - return bool(self._get_setting("remove_extracted_metadata")) + return bool(self._utils.get_setting("remove_extracted_metadata")) @cached_property def _force_load_dlls(self): - return bool(self._get_setting("force_load_dlls")) + return bool(self._utils.get_setting("force_load_dlls")) @cached_property def _log_diff(self): - return bool(self._get_setting("log_diff")) + return bool(self._utils.get_setting("log_diff")) @cached_property def _convert_yamls_to_json(self): - return bool(self._get_setting("convert_yamls_to_json")) - - @cached_property - def _needed_lslib_files(self): - return { - self._tools_dir / x - for x in { - "CommandLineArgumentsParser.dll", - "Divine.dll", - "Divine.dll.config", - "Divine.exe", - "Divine.runtimeconfig.json", - "LSLib.dll", - "LSLibNative.dll", - "LZ4.dll", - "System.IO.Hashing.dll", - "ZstdSharp.dll", - } - } - - def _get_setting(self, key: str) -> mobase.MoVariant: - return self._organizer.pluginSetting(self.name(), key) + return bool(self._utils.get_setting("convert_yamls_to_json")) def _set_setting(self, key: str, value: mobase.MoVariant): self._organizer.setPluginSetting(self.name(), key, value) - def __tr(self, trstr: str) -> str: - return QCoreApplication.translate(self.name(), trstr) - def _active_mods(self) -> list[mobase.IModInterface]: modlist = self._organizer.modList() return [ @@ -467,21 +434,6 @@ def _active_mods(self) -> list[mobase.IModInterface]: ) ] - def _create_progress_window( - self, title: str, max_progress: int, msg: str = "", cancelable: bool = True - ) -> QProgressDialog: - progress = QProgressDialog( - self.__tr(msg if msg else title), - self.__tr("Cancel") if cancelable else None, - 0, - max_progress, - self._main_window, - ) - progress.setWindowTitle(self.__tr(f"BG3 Plugin: {title}")) - progress.setWindowModality(Qt.WindowModality.ApplicationModal) - progress.show() - return progress - def _on_settings_changed( self, plugin_name: str, @@ -493,7 +445,7 @@ def _on_settings_changed( return if new and setting == "check_for_lslib_updates": try: - self._download_lslib_if_missing() + self._utils.lslib_retriever.download_lslib_if_missing() finally: self._set_setting(setting, False) elif new and setting == "force_reparse_metadata": @@ -518,10 +470,6 @@ def _on_settings_changed( } and hasattr(self, f"_{setting}"): delattr(self, f"_{setting}") - def _on_user_interface_initialized(self, window: QMainWindow) -> None: - self._main_window = window - pass - def _on_finished_run(self, exec_path: str, exit_code: int): if "bin/bg3" not in exec_path: return @@ -540,7 +488,7 @@ def _on_finished_run(self, exec_path: str, exit_code: int): shutil.move(path, self._log_dir / path.name) except PermissionError as e: qDebug(str(e)) - days = self._get_setting("delete_levelcache_folders_older_than_x_days") + days = self._utils.get_setting("delete_levelcache_folders_older_than_x_days") if type(days) is int and days >= 0: cutoff_time = datetime.datetime.now() - datetime.timedelta(days=days) qDebug(f"cleaning folders in overwrite/LevelCache older than {cutoff_time}") @@ -564,10 +512,10 @@ def _construct_modsettings_xml( args: str = "", force_reparse_metadata: bool = False, ) -> bool: - if "bin/bg3" not in exec_path or not self._download_lslib_if_missing(): + if "bin/bg3" not in exec_path or not self._utils.lslib_retriever.download_lslib_if_missing(): return True active_mods = self._active_mods() - progress = self._create_progress_window( + progress = self._utils.create_progress_window( "Generating modsettings.xml", len(active_mods) ) threadpool = QThreadPool.globalInstance() @@ -622,7 +570,7 @@ def retrieve_mod_metadata_in_new_thread(mod: mobase.IModInterface): return True def _on_mod_installed(self, mod: mobase.IModInterface) -> None: - if self._download_lslib_if_missing(): + if self._utils.lslib_retriever.download_lslib_if_missing(): self._get_metadata_for_files_in_mod(mod, True) def _get_metadata_for_files_in_mod( @@ -845,119 +793,3 @@ def metadata_to_ini(condition: bool, to_parse: Callable[[], Path]): except Exception: qWarning(traceback.format_exc()) return "" - - def _download_lslib_if_missing(self): - if not self._get_setting("check_for_lslib_updates") and all( - x.exists() for x in self._needed_lslib_files - ): - return True - try: - self._tools_dir.mkdir(exist_ok=True, parents=True) - downloaded = False - - def reporthook(block_num: int, block_size: int, total_size: int) -> None: - if total_size > 0: - progress.setValue( - min(int(block_num * block_size * 100 / total_size), 100) - ) - QApplication.processEvents() - - with urllib.request.urlopen( - "https://api.github.com/repos/Norbyte/lslib/releases/latest" - ) as response: - assets = json.loads(response.read().decode("utf-8"))["assets"][0] - zip_path = self._tools_dir / assets["name"] - if not zip_path.exists(): - old_archives = list(self._tools_dir.glob("*.zip")) - msg_box = QMessageBox(self._main_window) - msg_box.setWindowTitle( - self.__tr("Baldur's Gate 3 Plugin - Missing dependencies") - ) - if old_archives: - msg_box.setText(self.__tr("LSLib update available.")) - else: - msg_box.setText( - self.__tr( - "LSLib tools are missing.\nThese are necessary for the plugin to create the load order file for BG3." - ) - ) - msg_box.addButton( - self.__tr("Download"), QMessageBox.ButtonRole.DestructiveRole - ) - exit_btn = msg_box.addButton( - self.__tr("Exit"), QMessageBox.ButtonRole.ActionRole - ) - msg_box.setIcon(QMessageBox.Icon.Warning) - msg_box.exec() - - if msg_box.clickedButton() == exit_btn: - if not old_archives: - err = QMessageBox(self._main_window) - err.setIcon(QMessageBox.Icon.Critical) - err.setText( - "LSLib tools are required for the proper generation of the modsettings.xml file, file will not be generated" - ) - return False - else: - progress = self._create_progress_window( - "Downloading LSLib", 100, cancelable=False - ) - urllib.request.urlretrieve( - assets["browser_download_url"], str(zip_path), reporthook - ) - progress.close() - downloaded = True - for archive in old_archives: - archive.unlink() - old_archives = [] - else: - old_archives = [] - new_msg = QMessageBox(self._main_window) - new_msg.setIcon(QMessageBox.Icon.Information) - new_msg.setText( - self.__tr("Latest version of LSLib already downloaded!") - ) - - except Exception as e: - qDebug(f"Download failed: {e}") - err = QMessageBox(self._main_window) - err.setIcon(QMessageBox.Icon.Critical) - err.setText(f"Failed to download LSLib tools:\n{traceback.format_exc()}") - err.exec() - return False - try: - if old_archives: - zip_path = sorted(old_archives)[-1] - if old_archives or not downloaded: - dialog_message = "Ensuring all necessary LSLib files have been extracted from archive..." - win_title = "Verifying LSLib files" - else: - dialog_message = "Extracting/Updating LSLib files..." - win_title = "Extracting LSLib" - x_progress = self._create_progress_window( - win_title, len(self._needed_lslib_files), msg=dialog_message - ) - with zipfile.ZipFile(zip_path, "r") as zip_ref: - for file in self._needed_lslib_files: - if downloaded or not file.exists(): - shutil.move( - zip_ref.extract( - f"Packed/Tools/{file.name}", self._tools_dir - ), - file, - ) - x_progress.setValue(x_progress.value() + 1) - QApplication.processEvents() - if x_progress.wasCanceled(): - qWarning("processing canceled by user") - return False - x_progress.close() - shutil.rmtree(self._tools_dir / "Packed", ignore_errors=True) - except Exception as e: - qDebug(f"Extraction failed: {e}") - err = QMessageBox(self._main_window) - err.setIcon(QMessageBox.Icon.Critical) - err.setText(f"Failed to extract LSLib tools:\n{traceback.format_exc()}") - err.exec() - return False - return True From e32e3586d5d69a9547fef4084ed9ee62c2f3bd80 Mon Sep 17 00:00:00 2001 From: Savathun Date: Sat, 9 Aug 2025 17:07:46 -0600 Subject: [PATCH 12/20] move various utils into bg3utils, move pak parsing into pak_parser --- games/baldursgate3/bg3_data_checker.py | 2 +- games/baldursgate3/bg3_utils.py | 263 +++++++++++- games/baldursgate3/lslib_retriever.py | 9 +- games/baldursgate3/pak_parser.py | 288 ++++++++++++++ games/game_baldursgate3.py | 530 ++----------------------- 5 files changed, 578 insertions(+), 514 deletions(-) create mode 100644 games/baldursgate3/pak_parser.py diff --git a/games/baldursgate3/bg3_data_checker.py b/games/baldursgate3/bg3_data_checker.py index 05e60a9a..d18134b5 100644 --- a/games/baldursgate3/bg3_data_checker.py +++ b/games/baldursgate3/bg3_data_checker.py @@ -1,6 +1,6 @@ import mobase -from basic_features import BasicModDataChecker, utils +from ...basic_features import BasicModDataChecker, utils class BG3ModDataChecker(BasicModDataChecker): diff --git a/games/baldursgate3/bg3_utils.py b/games/baldursgate3/bg3_utils.py index ae00d14f..89d5b53b 100644 --- a/games/baldursgate3/bg3_utils.py +++ b/games/baldursgate3/bg3_utils.py @@ -1,11 +1,24 @@ -from functools import cached_property +import functools +import json +import os +import shutil +import typing from pathlib import Path import mobase -from PyQt6.QtCore import QCoreApplication, Qt -from PyQt6.QtWidgets import QProgressDialog, QMainWindow - -from games.baldursgate3.lslib_retriever import LSLibRetriever +import yaml +from PyQt6.QtCore import ( + QCoreApplication, + QDir, + QEventLoop, + QRunnable, + Qt, + QThread, + QThreadPool, + qInfo, + qWarning, +) +from PyQt6.QtWidgets import QApplication, QMainWindow, QProgressDialog class BG3Utils: @@ -16,22 +29,109 @@ class BG3Utils: "Localization", "ScriptExtender", } - def __init__(self, organizer: mobase.IOrganizer, name: str): + _mod_settings_xml_start = """ + + + + + + + + + + + + + + + """ + _mod_settings_xml_end = """ + + + + + + """ + + def __init__(self, name: str): self.main_window = None - self._organizer = organizer self._name = name - self.lslib_retriever = LSLibRetriever(self) - @cached_property + from . import lslib_retriever, pak_parser + + self._lslib_retriever = lslib_retriever.LSLibRetriever(self) + self._pak_parser = pak_parser.BG3PakParser(self) + + def init(self, organizer: mobase.IOrganizer): + self._organizer = organizer + + @functools.cached_property + def autobuild_paks(self): + return bool(self.get_setting("autobuild_paks")) + + @functools.cached_property + def extract_full_package(self): + return bool(self.get_setting("extract_full_package")) + + @functools.cached_property + def remove_extracted_metadata(self): + return bool(self.get_setting("remove_extracted_metadata")) + + @functools.cached_property + def force_load_dlls(self): + return bool(self.get_setting("force_load_dlls")) + + @functools.cached_property + def log_diff(self): + return bool(self.get_setting("log_diff")) + + @functools.cached_property + def convert_yamls_to_json(self): + return bool(self.get_setting("convert_yamls_to_json")) + + @functools.cached_property + def log_dir(self): + return Path(self._organizer.basePath()) / "logs/" + + @functools.cached_property + def modsettings_backup(self): + return self.plugin_data_path / "temp/modsettings.lsx" + + @functools.cached_property + def modsettings_path(self): + return self.overwrite_path / "PlayerProfiles/Public/modsettings.lsx" + + @functools.cached_property def plugin_data_path(self) -> Path: """Gets the path to the data folder for the current plugin.""" return Path(self._organizer.pluginDataPath(), self._name).absolute() - @cached_property + + @functools.cached_property def tools_dir(self): return self.plugin_data_path / "tools" + + @functools.cached_property + def overwrite_path(self): + return Path(self._organizer.overwritePath()) + + def active_mods(self) -> list[mobase.IModInterface]: + modlist = self._organizer.modList() + return [ + modlist.getMod(mod_name) + for mod_name in filter( + lambda mod: modlist.state(mod) & mobase.ModState.ACTIVE, + modlist.allModsByProfilePriority(), + ) + ] + + def _set_setting(self, key: str, value: mobase.MoVariant): + self._organizer.setPluginSetting(self._name, key, value) + def get_setting(self, key: str) -> mobase.MoVariant: return self._organizer.pluginSetting(self._name, key) + def tr(self, trstr: str) -> str: return QCoreApplication.translate(self._name, trstr) + def create_progress_window( self, title: str, max_progress: int, msg: str = "", cancelable: bool = True ) -> QProgressDialog: @@ -46,6 +146,149 @@ def create_progress_window( progress.setWindowModality(Qt.WindowModality.ApplicationModal) progress.show() return progress + def on_user_interface_initialized(self, window: QMainWindow) -> None: self.main_window = window pass + + def on_settings_changed( + self, + plugin_name: str, + setting: str, + old: mobase.MoVariant, + new: mobase.MoVariant, + ) -> None: + if self._name != plugin_name: + return + if new and setting == "check_for_lslib_updates": + try: + self._lslib_retriever.download_lslib_if_missing() + finally: + self._set_setting(setting, False) + elif new and setting == "force_reparse_metadata": + try: + self.construct_modsettings_xml( + exec_path="bin/bg3", force_reparse_metadata=True + ) + finally: + self._set_setting(setting, False) + elif new and setting == "convert_jsons_to_yaml": + try: + self._convert_jsons_to_yaml() + finally: + self._set_setting(setting, False) + elif setting in { + "extract_full_package", + "autobuild_paks", + "remove_extracted_metadata", + "force_load_dlls", + "log_diff", + "convert_yamls_to_json", + } and hasattr(self, setting): + delattr(self, setting) + + def construct_modsettings_xml( + self, + exec_path: str = "", + working_dir: typing.Optional[QDir] = None, + args: str = "", + force_reparse_metadata: bool = False, + ) -> bool: + if ( + "bin/bg3" not in exec_path + or not self._lslib_retriever.download_lslib_if_missing() + ): + return True + active_mods = self.active_mods() + progress = self.create_progress_window( + "Generating modsettings.xml", len(active_mods) + ) + threadpool = QThreadPool.globalInstance() + if threadpool is None: + return False + metadata: dict[str, str] = {} + + def retrieve_mod_metadata_in_new_thread(mod: mobase.IModInterface): + return lambda: metadata.update( + self._pak_parser.get_metadata_for_files_in_mod( + mod, force_reparse_metadata + ) + ) + + for mod in active_mods: + if progress.wasCanceled(): + qWarning("processing canceled by user") + return False + threadpool.start(QRunnable.create(retrieve_mod_metadata_in_new_thread(mod))) + count = 0 + num_active_mods = len(active_mods) + total_intervals_to_wait = (num_active_mods * 2) + 20 + while len(metadata.keys()) < num_active_mods: + progress.setValue(len(metadata.keys())) + QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 100) + count += 1 + if count == total_intervals_to_wait or progress.wasCanceled(): + remaining_mods = {mod.name() for mod in active_mods} - metadata.keys() + qWarning(f"processing did not finish in time for: {remaining_mods}") + progress.close() + break + QThread.msleep(100) + progress.setValue(num_active_mods) + QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 100) + progress.close() + qInfo(f"writing mod load order to {self.modsettings_path}") + self.modsettings_path.parent.mkdir(parents=True, exist_ok=True) + self.modsettings_path.write_text( + ( + self._mod_settings_xml_start + + "".join( + metadata[mod.name()] + for mod in active_mods + if mod.name() in metadata + ) + + self._mod_settings_xml_end + ) + ) + qInfo( + f"backing up generated file {self.modsettings_path} to {self.modsettings_backup}, " + f"check the backup after the executable runs for differences with the file used by the game if you encounter issues" + ) + shutil.copy(self.modsettings_path, self.modsettings_backup) + return True + + def _convert_jsons_to_yaml(self): + qInfo("converting all json files to yaml") + active_mods = self.active_mods() + progress = self.create_progress_window( + "Converting all json files to yaml", len(active_mods) + 1 + ) + for mod in active_mods: + _convert_jsons_in_dir_to_yaml(Path(mod.absolutePath())) + progress.setValue(progress.value() + 1) + QApplication.processEvents() + if progress.wasCanceled(): + qWarning("conversion canceled by user") + return + _convert_jsons_in_dir_to_yaml(self.overwrite_path) + progress.setValue(len(active_mods) + 1) + QApplication.processEvents() + progress.close() + + def on_mod_installed(self, mod: mobase.IModInterface) -> None: + if self._lslib_retriever.download_lslib_if_missing(): + self._pak_parser.get_metadata_for_files_in_mod(mod, True) + + +def _convert_jsons_in_dir_to_yaml(path: Path): + for file in list(path.rglob("*.json")): + converted_path = file.parent / file.name.replace(".json", ".yaml") + try: + if not converted_path.exists() or os.path.getmtime(file) > os.path.getmtime( + converted_path + ): + with open(file, "r") as json_file: + with open(converted_path, "w") as yaml_file: + yaml.dump(json.load(json_file), yaml_file, indent=2) + qInfo(f"Converted {file} to YAML") + except OSError as e: + qWarning(f"Error accessing file {converted_path}: {e}") diff --git a/games/baldursgate3/lslib_retriever.py b/games/baldursgate3/lslib_retriever.py index 726e1e7c..297950b1 100644 --- a/games/baldursgate3/lslib_retriever.py +++ b/games/baldursgate3/lslib_retriever.py @@ -6,14 +6,15 @@ from functools import cached_property from PyQt6.QtCore import qDebug, qWarning -from PyQt6.QtWidgets import QMessageBox, QApplication +from PyQt6.QtWidgets import QApplication, QMessageBox -from games.baldursgate3 import bg3_utils +from . import bg3_utils class LSLibRetriever: def __init__(self, utils: bg3_utils.BG3Utils): self._utils = utils + @cached_property def _needed_lslib_files(self): return { @@ -31,6 +32,7 @@ def _needed_lslib_files(self): "ZstdSharp.dll", } } + def download_lslib_if_missing(self): if not self._utils.get_setting("check_for_lslib_updates") and all( x.exists() for x in self._needed_lslib_files @@ -67,7 +69,8 @@ def reporthook(block_num: int, block_size: int, total_size: int) -> None: ) ) msg_box.addButton( - self._utils.tr("Download"), QMessageBox.ButtonRole.DestructiveRole + self._utils.tr("Download"), + QMessageBox.ButtonRole.DestructiveRole, ) exit_btn = msg_box.addButton( self._utils.tr("Exit"), QMessageBox.ButtonRole.ActionRole diff --git a/games/baldursgate3/pak_parser.py b/games/baldursgate3/pak_parser.py new file mode 100644 index 00000000..50d46c59 --- /dev/null +++ b/games/baldursgate3/pak_parser.py @@ -0,0 +1,288 @@ +import configparser +import hashlib +import itertools +import os +import re +import shutil +import subprocess +import traceback +from functools import cached_property +from pathlib import Path +from typing import Callable +from xml.etree import ElementTree +from xml.etree.ElementTree import Element + +import mobase +from PyQt6.QtCore import ( + qDebug, + qInfo, + qWarning, +) + +from . import bg3_utils + + +class BG3PakParser: + def __init__(self, utils: bg3_utils.BG3Utils): + self._utils = utils + + _mod_cache: dict[Path, bool] = {} + _types = { + "Folder": "", + "MD5": "", + "Name": "", + "PublishHandle": "0", + "UUID": "", + "Version64": "0", + } + + @cached_property + def _divine_command(self): + return f"{self._utils.tools_dir / 'Divine.exe'} -g bg3 -l info" + + @cached_property + def _folder_pattern(self): + return re.compile("Data|Script Extender|bin|Mods") + + def get_metadata_for_files_in_mod( + self, mod: mobase.IModInterface, force_reparse_metadata: bool + ): + return { + mod.name(): "".join( + [ + self._get_metadata_for_file(mod, file, force_reparse_metadata) + for file in sorted( + list(Path(mod.absolutePath()).rglob("*.pak")) + + ( + [ + f + for f in Path(mod.absolutePath()).glob("*") + if f.is_dir() + ] + if self._utils.autobuild_paks + else [] + ) + ) + ] + ) + } + + def _get_metadata_for_file( + self, + mod: mobase.IModInterface, + file: Path, + force_reparse_metadata: bool, + ) -> str: + meta_ini = Path(mod.absolutePath()) / "meta.ini" + config = configparser.ConfigParser() + config.read(meta_ini, encoding="utf-8") + try: + if file.name.endswith("pak"): + meta_file = ( + self._utils.plugin_data_path + / f"temp/extracted_metadata/{file.name[: int(len(file.name) / 2)]}-{hashlib.md5(str(file).encode(), usedforsecurity=False).hexdigest()[:5]}.lsx" + ) + try: + if ( + not force_reparse_metadata + and config.has_section(file.name) + and ( + "override" in config[file.name].keys() + or "Folder" in config[file.name].keys() + ) + ): + return get_module_short_desc(config, file) + meta_file.parent.mkdir(parents=True, exist_ok=True) + meta_file.unlink(missing_ok=True) + out_dir = ( + str(meta_file)[:-4] if self._utils.extract_full_package else "" + ) + can_continue = True + if self.run_divine( + f'{"extract-package" if self._utils.extract_full_package else "extract-single-file -f meta.lsx"} -d "{meta_file if not self._utils.extract_full_package else out_dir}"', + file, + ).returncode: + can_continue = False + if can_continue and self._utils.extract_full_package: + qDebug(f"archive {file} extracted to {out_dir}") + if self.run_divine( + f'convert-resources -d "{out_dir}" -i lsf -o lsx -x "*.lsf"', + out_dir, + ).returncode: + qDebug( + f"failed to convert lsf files in {out_dir} to readable lsx" + ) + extracted_meta_files = list(Path(out_dir).rglob("meta.lsx")) + if len(extracted_meta_files) == 0: + qInfo( + f"No meta.lsx files found in {file.name}, {file.name} determined to be an override mod" + ) + can_continue = False + else: + shutil.copyfile( + extracted_meta_files[0], + meta_file, + ) + elif can_continue and not meta_file.exists(): + qInfo( + f"No meta.lsx files found in {file.name}, {file.name} determined to be an override mod" + ) + can_continue = False + return self.metadata_to_ini( + config, file, mod, meta_ini, can_continue, lambda: meta_file + ) + finally: + if self._utils.remove_extracted_metadata: + meta_file.unlink(missing_ok=True) + if self._utils.extract_full_package: + Path(str(meta_file)[:-4]).unlink(missing_ok=True) + elif file.is_dir() and self._folder_pattern.search(file.name): + # qDebug(f"directory is not packable: {file}") + return "" + elif next( + itertools.chain( + file.glob(f"{folder}/*") + for folder in self._utils.loose_file_folders + ), + False, + ): + qInfo(f"packable dir: {file}") + if (file.parent / f"{file.name}.pak").exists() or ( + file.parent / f"Mods/{file.name}.pak" + ).exists(): + qInfo( + f"pak with same name as packable dir exists in mod directory. not packing dir {file}" + ) + return "" + pak_path = self._utils.overwrite_path / f"Mods/{file.name}.pak" + build_pak = True + if pak_path.exists(): + pak_creation_time = os.path.getmtime(pak_path) + for root, _, files in os.walk(file): + for f in files: + file_path = os.path.join(root, f) + try: + if os.path.getmtime(file_path) > pak_creation_time: + break + except OSError as e: + qDebug(f"Error accessing file {file_path}: {e}") + break + else: + build_pak = False + if build_pak: + pak_path.unlink(missing_ok=True) + if self.run_divine( + f'create-package -d "{pak_path}"', file + ).returncode: + return "" + meta_files = list(file.glob("Mods/*/meta.lsx")) + return self.metadata_to_ini( + config, + file, + mod, + meta_ini, + len(meta_files) > 0, + lambda: meta_files[0], + ) + else: + # qDebug(f"non packable dir, unlikely to be used by the game: {file}") + return "" + except Exception: + qWarning(traceback.format_exc()) + return "" + + def run_divine( + self, action: str, source: Path | str + ) -> subprocess.CompletedProcess[str]: + command = f'{self._divine_command} -a {action} -s "{source}"' + result = subprocess.run( + command, + creationflags=subprocess.CREATE_NO_WINDOW, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode: + qWarning( + f"{command.replace(str(Path.home()), '~', 1).replace(str(Path.home()), '$HOME')}" + f" returned stdout: {result.stdout}, stderr: {result.stderr}, code {result.returncode}" + ) + return result + + def get_attr_value(self, root: Element, attr_id: str) -> str: + default_val = self._types.get(attr_id) or "" + attr = root.find(f".//attribute[@id='{attr_id}']") + return default_val if attr is None else attr.get("value", default_val) + + def metadata_to_ini( + self, + config: configparser.ConfigParser, + file: Path, + mod: mobase.IModInterface, + meta_ini: Path, + condition: bool, + to_parse: Callable[[], Path], + ): + config[file.name] = {} + if condition: + root = ( + ElementTree.parse(to_parse()) + .getroot() + .find(".//node[@id='ModuleInfo']") + ) + if root is None: + qInfo(f"No ModuleInfo node found in meta.lsx for {mod.name()} ") + else: + section = config[file.name] + folder_name = self.get_attr_value(root, "Folder") + if file.is_dir(): + self._mod_cache[file] = ( + len(list(file.glob(f"*/{folder_name}/**"))) > 1 + or len( + list(file.glob("Public/Engine/Timeline/MaterialGroups/*")) + ) + > 0 + ) + elif file not in self._mod_cache: + # a mod which has a meta.lsx and is not an override mod meets at least one of three conditions: + # 1. it has files in Public/Engine/Timeline/MaterialGroups, or + # 2. it has files in Mods// other than the meta.lsx file, or + # 3. it has files in Public/ + result = self.run_divine( + f'list-package --use-regex -x "(/{folder_name}/(?!meta\\.lsx))|(Public/Engine/Timeline/MaterialGroups)"', + file, + ) + self._mod_cache[file] = ( + result.returncode == 0 and result.stdout.strip() != "" + ) + if self._mod_cache[file]: + for key in self._types: + section[key] = self.get_attr_value(root, key) + else: + qInfo(f"pak {file.name} determined to be an override mod") + section["override"] = "True" + section["Folder"] = folder_name + else: + config[file.name]["override"] = "True" + with open(meta_ini, "w+", encoding="utf-8") as f: + config.write(f) + return get_module_short_desc(config, file) + + +def get_module_short_desc(config: configparser.ConfigParser, file: Path) -> str: + return ( + "" + if not config.has_section(file.name) + or "override" in config[file.name].keys() + or "Name" not in config[file.name].keys() + else f""" + + + + + + + + """ + ) diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py index 52efe99f..b9d6357a 100644 --- a/games/game_baldursgate3.py +++ b/games/game_baldursgate3.py @@ -1,28 +1,14 @@ -import configparser import datetime import difflib -import hashlib -import itertools import json import os -import re import shutil -import subprocess -import traceback from functools import cached_property from pathlib import Path -from typing import Any, Callable, Optional -from xml.etree import ElementTree -from xml.etree.ElementTree import Element +from typing import Any, Callable -import mobase import yaml -from PyQt6 import QtCore from PyQt6.QtCore import ( - QDir, - QEventLoop, - QRunnable, - QThreadPool, qDebug, qInfo, qWarning, @@ -31,6 +17,8 @@ QApplication, ) +import mobase + from ..basic_features import ( BasicGameSaveGameInfo, BasicLocalSavegames, @@ -58,55 +46,19 @@ class BG3Game(BasicGame, mobase.IPluginFileMapper): GameNexusId = 3474 GameSteamId = 1086940 GameGogId = 1456460669 - _loose_file_folders = { - "Public", - "Mods", - "Generated", - "Localization", - "ScriptExtender", - } - _mod_cache: dict[Path, bool] = {} - _types = { - "Folder": "", - "MD5": "", - "Name": "", - "PublishHandle": "0", - "UUID": "", - "Version64": "0", - } - _mod_settings_xml_start = """ - - - - - - - - - - - - - - - """ - _mod_settings_xml_end = """ - - - - - -""" def __init__(self): BasicGame.__init__(self) mobase.IPluginFileMapper.__init__(self) - self._utils = None + from .baldursgate3 import bg3_utils + + self._utils = bg3_utils.BG3Utils(self.name()) def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) - from baldursgate3 import bg3_data_checker, bg3_utils - self._utils = bg3_utils.BG3Utils(organizer, self.name()) + self._utils.init(organizer) + from .baldursgate3 import bg3_data_checker + self._register_feature( bg3_data_checker.BG3ModDataChecker( GlobPatterns( @@ -117,24 +69,24 @@ def init(self, organizer: mobase.IOrganizer) -> bool: "Script Extender", # mods which are configured via jsons in this folder "Data", # loose file mods ] - + [str(Path("*") / f) for f in self._loose_file_folders], + + [str(Path("*") / f) for f in self._utils.loose_file_folders], move={ "Root/": "", "*.dll": "bin/", "ScriptExtenderSettings.json": "bin/", } # root builder not needed - | {f: "Data/" for f in self._loose_file_folders}, + | {f: "Data/" for f in self._utils.loose_file_folders}, delete=["info.json", "*.txt"], ) ) ) self._register_feature(BasicGameSaveGameInfo(lambda s: s.with_suffix(".webp"))) self._register_feature(BasicLocalSavegames(self.savesDirectory())) - organizer.onAboutToRun(self._construct_modsettings_xml) + organizer.onAboutToRun(self._utils.construct_modsettings_xml) organizer.onFinishedRun(self._on_finished_run) organizer.onUserInterfaceInitialized(self._utils.on_user_interface_initialized) - organizer.modList().onModInstalled(self._on_mod_installed) - organizer.onPluginSettingChanged(self._on_settings_changed) + organizer.modList().onModInstalled(self._utils.on_mod_installed) + organizer.onPluginSettingChanged(self._utils.on_settings_changed) return True def settings(self): @@ -223,7 +175,7 @@ def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: efls = super().executableForcedLoads() except AttributeError: efls = [] - if self._force_load_dlls: + if self._utils.force_load_dlls: qInfo("detecting dlls in enabled mods") libs: set[str] = set() tree: mobase.IFileTree | mobase.FileTreeEntry | None = ( @@ -260,7 +212,7 @@ def mappings(self) -> list[mobase.Mapping]: qInfo("creating custom bg3 mappings") mappings: list[mobase.Mapping] = [] docs_path = Path(self.documentsDirectory().path()) - active_mods = self._active_mods() + active_mods = self._utils.active_mods() def map_files( path: Path, @@ -284,7 +236,7 @@ def add_mapping(file: Path): ) for file in list(path.rglob(pattern)): - if self._convert_yamls_to_json and ( + if self._utils.convert_yamls_to_json and ( file.name.endswith(".yaml") or file.name.endswith(".yml") ): converted_path = file.parent / file.name.replace( @@ -324,472 +276,50 @@ def add_mapping(file: Path): if progress.wasCanceled(): qWarning("mapping canceled by user") return mappings - map_files(self._overwrite_path) + map_files(self._utils.overwrite_path) progress.setValue(len(active_mods) + 1) QApplication.processEvents() progress.close() return mappings - def _convert_jsons_to_yaml(self): - qInfo("converting all json files to yaml") - active_mods = self._active_mods() - - def map_files(path: Path): - for file in list(path.rglob("*.json")): - converted_path = file.parent / file.name.replace(".json", ".yaml") - try: - if not converted_path.exists() or os.path.getmtime( - file - ) > os.path.getmtime(converted_path): - with open(file, "r") as json_file: - with open(converted_path, "w") as yaml_file: - yaml.dump(json.load(json_file), yaml_file, indent=2) - qInfo(f"Converted {file} to YAML") - except OSError as e: - qWarning(f"Error accessing file {converted_path}: {e}") - - progress = self._utils.create_progress_window( - "Converting all json files to yaml", len(active_mods) + 1 - ) - for mod in active_mods: - map_files(Path(mod.absolutePath())) - progress.setValue(progress.value() + 1) - QApplication.processEvents() - if progress.wasCanceled(): - qWarning("conversion canceled by user") - return - map_files(self._overwrite_path) - progress.setValue(len(active_mods) + 1) - QApplication.processEvents() - progress.close() - @cached_property def _base_dlls(self) -> set[str]: base_bin = Path(self.gameDirectory().absoluteFilePath("bin")) return {str(f.relative_to(base_bin)) for f in base_bin.glob("*.dll")} - @cached_property - def _plugin_data_path(self) -> Path: - """Gets the path to the data folder for the current plugin.""" - return Path(self._organizer.pluginDataPath(), self.name()).absolute() - - @cached_property - def _overwrite_path(self): - return Path(self._organizer.overwritePath()) - - @cached_property - def _log_dir(self): - return Path(self._organizer.basePath()) / "logs/" - - @cached_property - def _modsettings_backup(self): - return self._plugin_data_path / "temp/modsettings.lsx" - - @cached_property - def _modsettings_path(self): - return self._overwrite_path / "PlayerProfiles/Public/modsettings.lsx" - - @cached_property - def _divine_command(self): - return f"{self._utils.tools_dir / 'Divine.exe'} -g bg3 -l info" - - @cached_property - def _folder_pattern(self): - return re.compile("Data|Script Extender|bin|Mods") - - @cached_property - def _autobuild_paks(self): - return bool(self._utils.get_setting("autobuild_paks")) - - @cached_property - def _extract_full_package(self): - return bool(self._utils.get_setting("extract_full_package")) - - @cached_property - def _remove_extracted_metadata(self): - return bool(self._utils.get_setting("remove_extracted_metadata")) - - @cached_property - def _force_load_dlls(self): - return bool(self._utils.get_setting("force_load_dlls")) - - @cached_property - def _log_diff(self): - return bool(self._utils.get_setting("log_diff")) - - @cached_property - def _convert_yamls_to_json(self): - return bool(self._utils.get_setting("convert_yamls_to_json")) - - def _set_setting(self, key: str, value: mobase.MoVariant): - self._organizer.setPluginSetting(self.name(), key, value) - - def _active_mods(self) -> list[mobase.IModInterface]: - modlist = self._organizer.modList() - return [ - modlist.getMod(mod_name) - for mod_name in filter( - lambda mod: modlist.state(mod) & mobase.ModState.ACTIVE, - modlist.allModsByProfilePriority(), - ) - ] - - def _on_settings_changed( - self, - plugin_name: str, - setting: str, - old: mobase.MoVariant, - new: mobase.MoVariant, - ) -> None: - if self.name() != plugin_name: - return - if new and setting == "check_for_lslib_updates": - try: - self._utils.lslib_retriever.download_lslib_if_missing() - finally: - self._set_setting(setting, False) - elif new and setting == "force_reparse_metadata": - try: - self._construct_modsettings_xml( - exec_path="bin/bg3", force_reparse_metadata=True - ) - finally: - self._set_setting(setting, False) - elif new and setting == "convert_jsons_to_yaml": - try: - self._convert_jsons_to_yaml() - finally: - self._set_setting(setting, False) - elif setting in { - "extract_full_package", - "autobuild_paks", - "remove_extracted_metadata", - "force_load_dlls", - "log_diff", - "convert_yamls_to_json", - } and hasattr(self, f"_{setting}"): - delattr(self, f"_{setting}") - def _on_finished_run(self, exec_path: str, exit_code: int): if "bin/bg3" not in exec_path: return - if self._log_diff: + if self._utils.log_diff: for x in difflib.unified_diff( - open(self._modsettings_backup).readlines(), - open(self._modsettings_path).readlines(), - fromfile=str(self._modsettings_backup), - tofile=str(self._modsettings_path), + open(self._utils.modsettings_backup).readlines(), + open(self._utils.modsettings_path).readlines(), + fromfile=str(self._utils.modsettings_backup), + tofile=str(self._utils.modsettings_path), lineterm="", ): qDebug(x) - for path in self._overwrite_path.rglob("*.log"): + for path in self._utils.overwrite_path.rglob("*.log"): try: - qDebug(f"moving {path} to {self._log_dir}") - shutil.move(path, self._log_dir / path.name) + qDebug(f"moving {path} to {self._utils.log_dir}") + shutil.move(path, self._utils.log_dir / path.name) except PermissionError as e: qDebug(str(e)) days = self._utils.get_setting("delete_levelcache_folders_older_than_x_days") if type(days) is int and days >= 0: cutoff_time = datetime.datetime.now() - datetime.timedelta(days=days) qDebug(f"cleaning folders in overwrite/LevelCache older than {cutoff_time}") - for path in self._overwrite_path.glob("LevelCache/*"): + for path in self._utils.overwrite_path.glob("LevelCache/*"): if ( datetime.datetime.fromtimestamp(os.path.getmtime(path)) < cutoff_time ): shutil.rmtree(path, ignore_errors=True) qDebug("cleaning empty dirs from overwrite directory") - for folder in sorted(list(os.walk(self._overwrite_path))[1:], reverse=True): + for folder in sorted( + list(os.walk(self._utils.overwrite_path))[1:], reverse=True + ): try: os.rmdir(folder[0]) except OSError: pass - - def _construct_modsettings_xml( - self, - exec_path: str = "", - working_dir: Optional[QDir] = None, - args: str = "", - force_reparse_metadata: bool = False, - ) -> bool: - if "bin/bg3" not in exec_path or not self._utils.lslib_retriever.download_lslib_if_missing(): - return True - active_mods = self._active_mods() - progress = self._utils.create_progress_window( - "Generating modsettings.xml", len(active_mods) - ) - threadpool = QThreadPool.globalInstance() - if threadpool is None: - return False - metadata: dict[str, str] = {} - - def retrieve_mod_metadata_in_new_thread(mod: mobase.IModInterface): - return lambda: metadata.update( - self._get_metadata_for_files_in_mod(mod, force_reparse_metadata) - ) - - for mod in active_mods: - if progress.wasCanceled(): - qWarning("processing canceled by user") - return False - threadpool.start(QRunnable.create(retrieve_mod_metadata_in_new_thread(mod))) - count = 0 - num_active_mods = len(active_mods) - total_intervals_to_wait = (num_active_mods * 2) + 20 - while len(metadata.keys()) < num_active_mods: - progress.setValue(len(metadata.keys())) - QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 100) - count += 1 - if count == total_intervals_to_wait or progress.wasCanceled(): - remaining_mods = {mod.name() for mod in active_mods} - metadata.keys() - qWarning(f"processing did not finish in time for: {remaining_mods}") - progress.close() - break - QtCore.QThread.msleep(100) - progress.setValue(num_active_mods) - QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 100) - progress.close() - qInfo(f"writing mod load order to {self._modsettings_path}") - self._modsettings_path.parent.mkdir(parents=True, exist_ok=True) - self._modsettings_path.write_text( - ( - self._mod_settings_xml_start - + "".join( - metadata[mod.name()] - for mod in active_mods - if mod.name() in metadata - ) - + self._mod_settings_xml_end - ) - ) - qInfo( - f"backing up generated file {self._modsettings_path} to {self._modsettings_backup}, " - f"check the backup after the executable runs for differences with the file used by the game if you encounter issues" - ) - shutil.copy(self._modsettings_path, self._modsettings_backup) - return True - - def _on_mod_installed(self, mod: mobase.IModInterface) -> None: - if self._utils.lslib_retriever.download_lslib_if_missing(): - self._get_metadata_for_files_in_mod(mod, True) - - def _get_metadata_for_files_in_mod( - self, mod: mobase.IModInterface, force_reparse_metadata: bool - ): - return { - mod.name(): "".join( - [ - self._get_metadata_for_file(mod, file, force_reparse_metadata) - for file in sorted( - list(Path(mod.absolutePath()).rglob("*.pak")) - + ( - [ - f - for f in Path(mod.absolutePath()).glob("*") - if f.is_dir() - ] - if self._autobuild_paks - else [] - ) - ) - ] - ) - } - - def _get_metadata_for_file( - self, - mod: mobase.IModInterface, - file: Path, - force_reparse_metadata: bool, - ) -> str: - def run_divine( - action: str, source: Path | str - ) -> subprocess.CompletedProcess[str]: - command = f'{self._divine_command} -a {action} -s "{source}"' - result = subprocess.run( - command, - creationflags=subprocess.CREATE_NO_WINDOW, - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - if result.returncode: - qWarning( - f"{command.replace(str(Path.home()), '~', 1).replace(str(Path.home()), '$HOME')}" - f" returned stdout: {result.stdout}, stderr: {result.stderr}, code {result.returncode}" - ) - return result - - def get_module_short_desc() -> str: - return ( - "" - if not config.has_section(file.name) - or "override" in config[file.name].keys() - or "Name" not in config[file.name].keys() - else f""" - - - - - - - - """ - ) - - def get_attr_value(root: Element, attr_id: str) -> str: - default_val = self._types.get(attr_id) or "" - attr = root.find(f".//attribute[@id='{attr_id}']") - return default_val if attr is None else attr.get("value", default_val) - - def extract_data(output_file: Path) -> bool: - out_dir = str(output_file)[:-4] if self._extract_full_package else "" - if run_divine( - f'{"extract-package" if self._extract_full_package else "extract-single-file -f meta.lsx"} -d "{output_file if not self._extract_full_package else out_dir}"', - file, - ).returncode: - return False - if self._extract_full_package: - qDebug(f"archive {file} extracted to {out_dir}") - if run_divine( - f'convert-resources -d "{out_dir}" -i lsf -o lsx -x "*.lsf"', - out_dir, - ).returncode: - qDebug(f"failed to convert lsf files in {out_dir} to readable lsx") - extracted_meta_files = list(Path(out_dir).rglob("meta.lsx")) - if len(extracted_meta_files) == 0: - qInfo( - f"No meta.lsx files found in {file.name}, {file.name} determined to be an override mod" - ) - return False - shutil.copyfile( - extracted_meta_files[0], - output_file, - ) - elif not output_file.exists(): - qInfo( - f"No meta.lsx files found in {file.name}, {file.name} determined to be an override mod" - ) - return False - return True - - def metadata_to_ini(condition: bool, to_parse: Callable[[], Path]): - config[file.name] = {} - if condition: - root = ( - ElementTree.parse(to_parse()) - .getroot() - .find(".//node[@id='ModuleInfo']") - ) - if root is None: - qInfo(f"No ModuleInfo node found in meta.lsx for {mod.name()} ") - else: - section = config[file.name] - folder_name = get_attr_value(root, "Folder") - if file.is_dir(): - self._mod_cache[file] = ( - len(list(file.glob(f"*/{folder_name}/**"))) > 1 - or len( - list( - file.glob("Public/Engine/Timeline/MaterialGroups/*") - ) - ) - > 0 - ) - elif file not in self._mod_cache: - # a mod which has a meta.lsx and is not an override mod meets at least one of three conditions: - # 1. it has files in Public/Engine/Timeline/MaterialGroups, or - # 2. it has files in Mods// other than the meta.lsx file, or - # 3. it has files in Public/ - result = run_divine( - f'list-package --use-regex -x "(/{folder_name}/(?!meta\\.lsx))|(Public/Engine/Timeline/MaterialGroups)"', - file, - ) - self._mod_cache[file] = ( - result.returncode == 0 and result.stdout.strip() != "" - ) - if self._mod_cache[file]: - for key in self._types: - section[key] = get_attr_value(root, key) - else: - qInfo(f"pak {file.name} determined to be an override mod") - section["override"] = "True" - section["Folder"] = folder_name - else: - config[file.name]["override"] = "True" - with open(meta_ini, "w+", encoding="utf-8") as f: - config.write(f) - return get_module_short_desc() - - meta_ini = Path(mod.absolutePath()) / "meta.ini" - config = configparser.ConfigParser() - config.read(meta_ini, encoding="utf-8") - try: - if file.name.endswith("pak"): - meta_file = ( - self._plugin_data_path - / f"temp/extracted_metadata/{file.name[: int(len(file.name) / 2)]}-{hashlib.md5(str(file).encode(), usedforsecurity=False).hexdigest()[:5]}.lsx" - ) - try: - if ( - not force_reparse_metadata - and config.has_section(file.name) - and ( - "override" in config[file.name].keys() - or "Folder" in config[file.name].keys() - ) - ): - return get_module_short_desc() - meta_file.parent.mkdir(parents=True, exist_ok=True) - meta_file.unlink(missing_ok=True) - return metadata_to_ini(extract_data(meta_file), lambda: meta_file) - finally: - if self._remove_extracted_metadata: - meta_file.unlink(missing_ok=True) - if self._extract_full_package: - Path(str(meta_file)[:-4]).unlink(missing_ok=True) - elif file.is_dir() and self._folder_pattern.search(file.name): - # qDebug(f"directory is not packable: {file}") - return "" - elif next( - itertools.chain( - file.glob(f"{folder}/*") for folder in self._loose_file_folders - ), - False, - ): - qInfo(f"packable dir: {file}") - if (file.parent / f"{file.name}.pak").exists() or ( - file.parent / f"Mods/{file.name}.pak" - ).exists(): - qInfo( - f"pak with same name as packable dir exists in mod directory. not packing dir {file}" - ) - return "" - pak_path = self._overwrite_path / f"Mods/{file.name}.pak" - build_pak = True - if pak_path.exists(): - pak_creation_time = os.path.getmtime(pak_path) - for root, _, files in os.walk(file): - for f in files: - file_path = os.path.join(root, f) - try: - if os.path.getmtime(file_path) > pak_creation_time: - break - except OSError as e: - qDebug(f"Error accessing file {file_path}: {e}") - break - else: - build_pak = False - if build_pak: - pak_path.unlink(missing_ok=True) - if run_divine(f'create-package -d "{pak_path}"', file).returncode: - return "" - meta_files = list(file.glob("Mods/*/meta.lsx")) - return metadata_to_ini(len(meta_files) > 0, lambda: meta_files[0]) - else: - # qDebug(f"non packable dir, unlikely to be used by the game: {file}") - return "" - except Exception: - qWarning(traceback.format_exc()) - return "" From 8860d08a9327f204f009023615bbad04d11ce354 Mon Sep 17 00:00:00 2001 From: Savathun Date: Sat, 9 Aug 2025 18:26:13 -0600 Subject: [PATCH 13/20] add bg3_data_content --- games/baldursgate3/bg3_data_content.py | 58 ++++++++++++++++++++++++++ games/game_baldursgate3.py | 9 ++-- 2 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 games/baldursgate3/bg3_data_content.py diff --git a/games/baldursgate3/bg3_data_content.py b/games/baldursgate3/bg3_data_content.py new file mode 100644 index 00000000..fe17feed --- /dev/null +++ b/games/baldursgate3/bg3_data_content.py @@ -0,0 +1,58 @@ +from enum import IntEnum, auto + +import mobase + + +class Content(IntEnum): + PAK = auto() + WORKSPACE = auto() + NATIVE = auto() + LOOSE_FILES = auto() + SE_FILES = auto() + + +class BG3DataContent(mobase.ModDataContent): + BG3_CONTENTS: list[tuple[Content, str, str, bool] | tuple[Content, str, str]] = [ + (Content.WORKSPACE, "Mod workspace", ":/MO/gui/content/script"), + (Content.PAK, "Pak", ":/MO/gui/content/bsa"), + (Content.LOOSE_FILES, "Loose file override mod", ":/MO/gui/content/texture"), + (Content.SE_FILES, "Script Extender Files", ":/MO/gui/content/inifile"), + (Content.NATIVE, "Native DLL mod", ":/MO/gui/content/plugin"), + ] + + def getAllContents(self) -> list[mobase.ModDataContent.Content]: + return [ + mobase.ModDataContent.Content(id, name, icon, *filter_only) + for id, name, icon, *filter_only in self.BG3_CONTENTS + ] + + def getContentsFor(self, filetree: mobase.IFileTree) -> list[int]: + contents: set[int] = set() + for entry in filetree: + if isinstance(entry, mobase.IFileTree): + match entry.name(): + case "Script Extender": + contents.add(Content.SE_FILES) + case "Data": + contents.add(Content.LOOSE_FILES) + case "Mods": + for e in entry: + if e.name().endswith(".pak"): + contents.add(Content.PAK) + break + case "bin": + contents.add(Content.NATIVE) + case _: + for e in entry: + if e.name() in { + "Mods", + "Localization", + "ScriptExtender", + "Public", + "Generated", + }: + contents.add(Content.WORKSPACE) + break + elif entry.name().endswith(".pak"): + contents.add(Content.PAK) + return list(contents) diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py index b9d6357a..49cbc0ff 100644 --- a/games/game_baldursgate3.py +++ b/games/game_baldursgate3.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any, Callable +import mobase import yaml from PyQt6.QtCore import ( qDebug, @@ -17,8 +18,6 @@ QApplication, ) -import mobase - from ..basic_features import ( BasicGameSaveGameInfo, BasicLocalSavegames, @@ -57,7 +56,7 @@ def __init__(self): def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) self._utils.init(organizer) - from .baldursgate3 import bg3_data_checker + from .baldursgate3 import bg3_data_checker, bg3_data_content self._register_feature( bg3_data_checker.BG3ModDataChecker( @@ -80,6 +79,7 @@ def init(self, organizer: mobase.IOrganizer) -> bool: ) ) ) + self._register_feature(bg3_data_content.BG3DataContent()) self._register_feature(BasicGameSaveGameInfo(lambda s: s.with_suffix(".webp"))) self._register_feature(BasicLocalSavegames(self.savesDirectory())) organizer.onAboutToRun(self._utils.construct_modsettings_xml) @@ -282,6 +282,9 @@ def add_mapping(file: Path): progress.close() return mappings + def loadOrderMechanism(self) -> mobase.LoadOrderMechanism: + return mobase.LoadOrderMechanism.PLUGINS_TXT + @cached_property def _base_dlls(self) -> set[str]: base_bin = Path(self.gameDirectory().absoluteFilePath("bin")) From 5f9710bd6ae27b7e37dbf7df6fec71d5757ecfdb Mon Sep 17 00:00:00 2001 From: Savathun Date: Sat, 9 Aug 2025 18:46:11 -0600 Subject: [PATCH 14/20] add scriptextender class --- games/baldursgate3/bg3_script_extender.py | 37 +++++++++++++++++++++++ games/game_baldursgate3.py | 7 ++++- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 games/baldursgate3/bg3_script_extender.py diff --git a/games/baldursgate3/bg3_script_extender.py b/games/baldursgate3/bg3_script_extender.py new file mode 100644 index 00000000..7e297924 --- /dev/null +++ b/games/baldursgate3/bg3_script_extender.py @@ -0,0 +1,37 @@ +import pathlib + +import mobase + + +class BG3ScriptExtender(mobase.ScriptExtender): + def __init__(self, game: mobase.IPluginGame): + super().__init__() + self._game = game + + def loaderName(self) -> str: + return "DWrite.dll" + + def loaderPath(self) -> str: + return str( + pathlib.Path(self._game.gameDirectory().absolutePath()) + / "bin" + / self.loaderName() + ) + + def isInstalled(self) -> bool: + return pathlib.Path(self.loaderPath()).exists() + + def getExtenderVersion(self) -> str: + return mobase.getFileVersion(self.loaderPath()) + + def getArch(self) -> int: + return 0x8664 if self.isInstalled() else 0x0 + + def binaryName(self): + return "" + + def pluginPath(self): + return "" + + def savegameExtension(self): + return "" diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py index 49cbc0ff..e3437019 100644 --- a/games/game_baldursgate3.py +++ b/games/game_baldursgate3.py @@ -56,7 +56,11 @@ def __init__(self): def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) self._utils.init(organizer) - from .baldursgate3 import bg3_data_checker, bg3_data_content + from .baldursgate3 import ( + bg3_data_checker, + bg3_data_content, + bg3_script_extender, + ) self._register_feature( bg3_data_checker.BG3ModDataChecker( @@ -79,6 +83,7 @@ def init(self, organizer: mobase.IOrganizer) -> bool: ) ) ) + self._register_feature(bg3_script_extender.BG3ScriptExtender(self)) self._register_feature(bg3_data_content.BG3DataContent()) self._register_feature(BasicGameSaveGameInfo(lambda s: s.with_suffix(".webp"))) self._register_feature(BasicLocalSavegames(self.savesDirectory())) From 49f4bed6dd834879a2c6de3e60b9c6b159735031 Mon Sep 17 00:00:00 2001 From: Savathun Date: Sun, 10 Aug 2025 12:31:13 -0600 Subject: [PATCH 15/20] move globpattern declaration to bg3_data_checker.py --- games/baldursgate3/bg3_data_checker.py | 26 +++++++++++++++++++++++++- games/baldursgate3/bg3_data_content.py | 10 +++------- games/baldursgate3/bg3_utils.py | 19 +++++++++++-------- games/baldursgate3/pak_parser.py | 3 +-- games/game_baldursgate3.py | 23 +---------------------- 5 files changed, 41 insertions(+), 40 deletions(-) diff --git a/games/baldursgate3/bg3_data_checker.py b/games/baldursgate3/bg3_data_checker.py index d18134b5..4cf8e8e7 100644 --- a/games/baldursgate3/bg3_data_checker.py +++ b/games/baldursgate3/bg3_data_checker.py @@ -1,9 +1,33 @@ +from pathlib import Path + import mobase -from ...basic_features import BasicModDataChecker, utils +from . import bg3_utils +from ...basic_features import BasicModDataChecker, utils, GlobPatterns class BG3ModDataChecker(BasicModDataChecker): + def __init__(self): + super().__init__( + GlobPatterns( + valid=[ + "*.pak", + str(Path("Mods") / "*.pak"), # standard mods + "bin", # native mods / Script Extender + "Script Extender", # mods which are configured via jsons in this folder + "Data", # loose file mods + ] + + [str(Path("*") / f) for f in bg3_utils.loose_file_folders], + move={ + "Root/": "", # root builder not needed + "*.dll": "bin/", + "ScriptExtenderSettings.json": "bin/", + } + | {f: "Data/" for f in bg3_utils.loose_file_folders}, + delete=["info.json", "*.txt"], + ) + ) + def dataLooksValid( self, filetree: mobase.IFileTree ) -> mobase.ModDataChecker.CheckReturn: diff --git a/games/baldursgate3/bg3_data_content.py b/games/baldursgate3/bg3_data_content.py index fe17feed..c378520e 100644 --- a/games/baldursgate3/bg3_data_content.py +++ b/games/baldursgate3/bg3_data_content.py @@ -2,6 +2,8 @@ import mobase +from . import bg3_utils + class Content(IntEnum): PAK = auto() @@ -44,13 +46,7 @@ def getContentsFor(self, filetree: mobase.IFileTree) -> list[int]: contents.add(Content.NATIVE) case _: for e in entry: - if e.name() in { - "Mods", - "Localization", - "ScriptExtender", - "Public", - "Generated", - }: + if e.name() in bg3_utils.loose_file_folders: contents.add(Content.WORKSPACE) break elif entry.name().endswith(".pak"): diff --git a/games/baldursgate3/bg3_utils.py b/games/baldursgate3/bg3_utils.py index 89d5b53b..d5d40082 100644 --- a/games/baldursgate3/bg3_utils.py +++ b/games/baldursgate3/bg3_utils.py @@ -20,15 +20,16 @@ ) from PyQt6.QtWidgets import QApplication, QMainWindow, QProgressDialog +loose_file_folders = { + "Public", + "Mods", + "Generated", + "Localization", + "ScriptExtender", +} + class BG3Utils: - loose_file_folders = { - "Public", - "Mods", - "Generated", - "Localization", - "ScriptExtender", - } _mod_settings_xml_start = """ @@ -288,7 +289,9 @@ def _convert_jsons_in_dir_to_yaml(path: Path): ): with open(file, "r") as json_file: with open(converted_path, "w") as yaml_file: - yaml.dump(json.load(json_file), yaml_file, indent=2) + yaml.dump( + json.load(json_file), yaml_file, indent=2, sort_keys=False + ) qInfo(f"Converted {file} to YAML") except OSError as e: qWarning(f"Error accessing file {converted_path}: {e}") diff --git a/games/baldursgate3/pak_parser.py b/games/baldursgate3/pak_parser.py index 50d46c59..164d0be4 100644 --- a/games/baldursgate3/pak_parser.py +++ b/games/baldursgate3/pak_parser.py @@ -141,8 +141,7 @@ def _get_metadata_for_file( return "" elif next( itertools.chain( - file.glob(f"{folder}/*") - for folder in self._utils.loose_file_folders + file.glob(f"{folder}/*") for folder in bg3_utils.loose_file_folders ), False, ): diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py index e3437019..82207a02 100644 --- a/games/game_baldursgate3.py +++ b/games/game_baldursgate3.py @@ -21,7 +21,6 @@ from ..basic_features import ( BasicGameSaveGameInfo, BasicLocalSavegames, - GlobPatterns, ) from ..basic_game import BasicGame @@ -62,27 +61,7 @@ def init(self, organizer: mobase.IOrganizer) -> bool: bg3_script_extender, ) - self._register_feature( - bg3_data_checker.BG3ModDataChecker( - GlobPatterns( - valid=[ - "*.pak", - str(Path("Mods") / "*.pak"), # standard mods - "bin", # native mods / Script Extender - "Script Extender", # mods which are configured via jsons in this folder - "Data", # loose file mods - ] - + [str(Path("*") / f) for f in self._utils.loose_file_folders], - move={ - "Root/": "", - "*.dll": "bin/", - "ScriptExtenderSettings.json": "bin/", - } # root builder not needed - | {f: "Data/" for f in self._utils.loose_file_folders}, - delete=["info.json", "*.txt"], - ) - ) - ) + self._register_feature(bg3_data_checker.BG3ModDataChecker()) self._register_feature(bg3_script_extender.BG3ScriptExtender(self)) self._register_feature(bg3_data_content.BG3DataContent()) self._register_feature(BasicGameSaveGameInfo(lambda s: s.with_suffix(".webp"))) From 8565599ed634b20bc7d9c901ed6642f9b99f0ce5 Mon Sep 17 00:00:00 2001 From: Savathun Date: Sun, 10 Aug 2025 12:45:57 -0600 Subject: [PATCH 16/20] move modsettings to profile path --- games/baldursgate3/bg3_utils.py | 6 +++--- games/baldursgate3/pak_parser.py | 6 ++++-- games/game_baldursgate3.py | 13 +++++++++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/games/baldursgate3/bg3_utils.py b/games/baldursgate3/bg3_utils.py index d5d40082..8318ad07 100644 --- a/games/baldursgate3/bg3_utils.py +++ b/games/baldursgate3/bg3_utils.py @@ -91,15 +91,15 @@ def convert_yamls_to_json(self): @functools.cached_property def log_dir(self): - return Path(self._organizer.basePath()) / "logs/" + return Path(self._organizer.basePath()) / "logs" @functools.cached_property def modsettings_backup(self): - return self.plugin_data_path / "temp/modsettings.lsx" + return self.plugin_data_path / "temp" / "modsettings.lsx" @functools.cached_property def modsettings_path(self): - return self.overwrite_path / "PlayerProfiles/Public/modsettings.lsx" + return Path(self._organizer.profilePath()) / "modsettings.lsx" @functools.cached_property def plugin_data_path(self) -> Path: diff --git a/games/baldursgate3/pak_parser.py b/games/baldursgate3/pak_parser.py index 164d0be4..29117cb4 100644 --- a/games/baldursgate3/pak_parser.py +++ b/games/baldursgate3/pak_parser.py @@ -80,7 +80,9 @@ def _get_metadata_for_file( if file.name.endswith("pak"): meta_file = ( self._utils.plugin_data_path - / f"temp/extracted_metadata/{file.name[: int(len(file.name) / 2)]}-{hashlib.md5(str(file).encode(), usedforsecurity=False).hexdigest()[:5]}.lsx" + / "temp" + / "extracted_metadata" + / f"{file.name[: int(len(file.name) / 2)]}-{hashlib.md5(str(file).encode(), usedforsecurity=False).hexdigest()[:5]}.lsx" ) try: if ( @@ -147,7 +149,7 @@ def _get_metadata_for_file( ): qInfo(f"packable dir: {file}") if (file.parent / f"{file.name}.pak").exists() or ( - file.parent / f"Mods/{file.name}.pak" + file.parent / "Mods" / f"{file.name}.pak" ).exists(): qInfo( f"pak with same name as packable dir exists in mod directory. not packing dir {file}" diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py index 82207a02..3a811c6a 100644 --- a/games/game_baldursgate3.py +++ b/games/game_baldursgate3.py @@ -261,6 +261,19 @@ def add_mapping(file: Path): qWarning("mapping canceled by user") return mappings map_files(self._utils.overwrite_path) + mappings.append( + mobase.Mapping( + source=str(self._utils.modsettings_path), + destination=str( + docs_path + / "PlayerProfiles" + / "Public" + / self._utils.modsettings_path.name + ), + is_directory=False, + create_target=True, + ) + ) progress.setValue(len(active_mods) + 1) QApplication.processEvents() progress.close() From 503dc9540d0c5d0f2fe19d6217ddd35a4f9076fc Mon Sep 17 00:00:00 2001 From: Savathun Date: Sun, 10 Aug 2025 14:16:02 -0600 Subject: [PATCH 17/20] move file mapping definition to its own class --- games/baldursgate3/bg3_file_mapper.py | 103 ++++++++++++++++++++++++++ games/game_baldursgate3.py | 102 ++----------------------- 2 files changed, 109 insertions(+), 96 deletions(-) create mode 100644 games/baldursgate3/bg3_file_mapper.py diff --git a/games/baldursgate3/bg3_file_mapper.py b/games/baldursgate3/bg3_file_mapper.py new file mode 100644 index 00000000..3bf038c0 --- /dev/null +++ b/games/baldursgate3/bg3_file_mapper.py @@ -0,0 +1,103 @@ +import functools +import json +import os +from pathlib import Path +from typing import Callable, Optional + +import mobase +import yaml +from PyQt6.QtCore import qInfo, qDebug, qWarning, QDir +from PyQt6.QtWidgets import QApplication + +from . import bg3_utils + + +class BG3FileMapper(mobase.IPluginFileMapper): + current_mappings: list[mobase.Mapping] = [] + + def __init__(self, utils: bg3_utils.BG3Utils, doc_dir: Callable[[], QDir]): + super().__init__() + self._utils = utils + self.doc_dir = doc_dir + + @functools.cached_property + def doc_path(self): + return Path(self.doc_dir().path()) + + def mappings(self) -> list[mobase.Mapping]: + qInfo("creating custom bg3 mappings") + self.current_mappings.clear() + active_mods = self._utils.active_mods() + doc_dir = Path(self.doc_dir().path()) + progress = self._utils.create_progress_window( + "Mapping files to documents folder", len(active_mods) + 1 + ) + docs_path_mods = doc_dir / "Mods" + docs_path_se = doc_dir / "Script Extender" + for mod in active_mods: + modpath = Path(mod.absolutePath()) + self.map_files(modpath, dest=docs_path_mods, pattern="*.pak", rel=False) + self.map_files(modpath / "Script Extender", dest=docs_path_se) + progress.setValue(progress.value() + 1) + QApplication.processEvents() + if progress.wasCanceled(): + qWarning("mapping canceled by user") + return self.current_mappings + self.map_files(self._utils.overwrite_path) + self.create_mapping( + self._utils.modsettings_path, + doc_dir / "PlayerProfiles" / "Public" / self._utils.modsettings_path.name, + ) + progress.setValue(len(active_mods) + 1) + QApplication.processEvents() + progress.close() + return self.current_mappings + + def map_files( + self, + path: Path, + dest: Optional[Path] = None, + pattern: str = "*", + rel: bool = True, + ): + dest = dest if dest else self.doc_path + dest_func: Callable[[Path], str] = ( + (lambda f: os.path.relpath(f, path)) if rel else lambda f: f.name + ) + found_jsons: set[Path] = set() + for file in list(path.rglob(pattern)): + if self._utils.convert_yamls_to_json and ( + file.name.endswith(".yaml") or file.name.endswith(".yml") + ): + converted_path = file.parent / file.name.replace( + ".yaml", ".json" + ).replace(".yml", ".json") + try: + if not converted_path.exists() or os.path.getmtime( + file + ) > os.path.getmtime(converted_path): + with open(file, "r") as yaml_file: + with open(converted_path, "w") as json_file: + json.dump( + yaml.safe_load(yaml_file), json_file, indent=2 + ) + qDebug(f"Converted {file} to JSON") + found_jsons.add(converted_path) + except OSError as e: + qWarning(f"Error accessing file {converted_path}: {e}") + elif file.name.endswith(".json"): + found_jsons.add(file) + else: + self.create_mapping(file, dest / dest_func(file)) + for file in found_jsons: + self.create_mapping(file, dest / dest_func(file)) + + def create_mapping(self, file: Path, dest: Path): + self.current_mappings.append( + mobase.Mapping( + source=str(file), + destination=str(dest), + is_directory=file.is_dir(), + create_target=True, + ) + ) diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py index 3a811c6a..80ce129d 100644 --- a/games/game_baldursgate3.py +++ b/games/game_baldursgate3.py @@ -1,23 +1,18 @@ import datetime import difflib -import json import os import shutil from functools import cached_property from pathlib import Path -from typing import Any, Callable +from typing import Any import mobase -import yaml from PyQt6.QtCore import ( qDebug, qInfo, - qWarning, -) -from PyQt6.QtWidgets import ( - QApplication, ) +from .baldursgate3 import bg3_file_mapper from ..basic_features import ( BasicGameSaveGameInfo, BasicLocalSavegames, @@ -25,7 +20,7 @@ from ..basic_game import BasicGame -class BG3Game(BasicGame, mobase.IPluginFileMapper): +class BG3Game(BasicGame, bg3_file_mapper.BG3FileMapper): Name = "Baldur's Gate 3 Plugin" Author = "daescha" Version = "0.1.0" @@ -47,10 +42,12 @@ class BG3Game(BasicGame, mobase.IPluginFileMapper): def __init__(self): BasicGame.__init__(self) - mobase.IPluginFileMapper.__init__(self) from .baldursgate3 import bg3_utils self._utils = bg3_utils.BG3Utils(self.name()) + bg3_file_mapper.BG3FileMapper.__init__( + self, self._utils, self.documentsDirectory + ) def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) @@ -192,93 +189,6 @@ def find_dlls( ] return efls - def mappings(self) -> list[mobase.Mapping]: - qInfo("creating custom bg3 mappings") - mappings: list[mobase.Mapping] = [] - docs_path = Path(self.documentsDirectory().path()) - active_mods = self._utils.active_mods() - - def map_files( - path: Path, - dest: Path = docs_path, - pattern: str = "*", - rel: bool = True, - ): - dest_func: Callable[[Path], str] = ( - (lambda f: os.path.relpath(f, path)) if rel else lambda f: f.name - ) - found_jsons: set[Path] = set() - - def add_mapping(file: Path): - mappings.append( - mobase.Mapping( - source=str(file), - destination=str(dest / dest_func(file)), - is_directory=file.is_dir(), - create_target=True, - ) - ) - - for file in list(path.rglob(pattern)): - if self._utils.convert_yamls_to_json and ( - file.name.endswith(".yaml") or file.name.endswith(".yml") - ): - converted_path = file.parent / file.name.replace( - ".yaml", ".json" - ).replace(".yml", ".json") - try: - if not converted_path.exists() or os.path.getmtime( - file - ) > os.path.getmtime(converted_path): - with open(file, "r") as yaml_file: - with open(converted_path, "w") as json_file: - json.dump( - yaml.safe_load(yaml_file), json_file, indent=2 - ) - qDebug(f"Converted {file} to JSON") - found_jsons.add(converted_path) - except OSError as e: - qWarning(f"Error accessing file {converted_path}: {e}") - elif file.name.endswith(".json"): - found_jsons.add(file) - else: - add_mapping(file) - for file in found_jsons: - add_mapping(file) - - progress = self._utils.create_progress_window( - "Mapping files to documents folder", len(active_mods) + 1 - ) - docs_path_mods = docs_path / "Mods" - docs_path_se = docs_path / "Script Extender" - for mod in active_mods: - modpath = Path(mod.absolutePath()) - map_files(modpath, docs_path_mods, pattern="*.pak", rel=False) - map_files(modpath / "Script Extender", docs_path_se) - progress.setValue(progress.value() + 1) - QApplication.processEvents() - if progress.wasCanceled(): - qWarning("mapping canceled by user") - return mappings - map_files(self._utils.overwrite_path) - mappings.append( - mobase.Mapping( - source=str(self._utils.modsettings_path), - destination=str( - docs_path - / "PlayerProfiles" - / "Public" - / self._utils.modsettings_path.name - ), - is_directory=False, - create_target=True, - ) - ) - progress.setValue(len(active_mods) + 1) - QApplication.processEvents() - progress.close() - return mappings - def loadOrderMechanism(self) -> mobase.LoadOrderMechanism: return mobase.LoadOrderMechanism.PLUGINS_TXT From a0119a8aa2378e9a8a53ce812196202cf25e87a4 Mon Sep 17 00:00:00 2001 From: Savathun Date: Sat, 16 Aug 2025 00:28:27 -0600 Subject: [PATCH 18/20] remove bg3_script_extender.py --- games/baldursgate3/bg3_script_extender.py | 37 ----------------------- games/game_baldursgate3.py | 2 -- 2 files changed, 39 deletions(-) delete mode 100644 games/baldursgate3/bg3_script_extender.py diff --git a/games/baldursgate3/bg3_script_extender.py b/games/baldursgate3/bg3_script_extender.py deleted file mode 100644 index 7e297924..00000000 --- a/games/baldursgate3/bg3_script_extender.py +++ /dev/null @@ -1,37 +0,0 @@ -import pathlib - -import mobase - - -class BG3ScriptExtender(mobase.ScriptExtender): - def __init__(self, game: mobase.IPluginGame): - super().__init__() - self._game = game - - def loaderName(self) -> str: - return "DWrite.dll" - - def loaderPath(self) -> str: - return str( - pathlib.Path(self._game.gameDirectory().absolutePath()) - / "bin" - / self.loaderName() - ) - - def isInstalled(self) -> bool: - return pathlib.Path(self.loaderPath()).exists() - - def getExtenderVersion(self) -> str: - return mobase.getFileVersion(self.loaderPath()) - - def getArch(self) -> int: - return 0x8664 if self.isInstalled() else 0x0 - - def binaryName(self): - return "" - - def pluginPath(self): - return "" - - def savegameExtension(self): - return "" diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py index 80ce129d..d299e170 100644 --- a/games/game_baldursgate3.py +++ b/games/game_baldursgate3.py @@ -55,11 +55,9 @@ def init(self, organizer: mobase.IOrganizer) -> bool: from .baldursgate3 import ( bg3_data_checker, bg3_data_content, - bg3_script_extender, ) self._register_feature(bg3_data_checker.BG3ModDataChecker()) - self._register_feature(bg3_script_extender.BG3ScriptExtender(self)) self._register_feature(bg3_data_content.BG3DataContent()) self._register_feature(BasicGameSaveGameInfo(lambda s: s.with_suffix(".webp"))) self._register_feature(BasicLocalSavegames(self.savesDirectory())) From af050b5abba3082416a2c5684ef8eb27b7c100d3 Mon Sep 17 00:00:00 2001 From: Savathun Date: Sat, 16 Aug 2025 21:06:22 -0600 Subject: [PATCH 19/20] add bg3 tool plugins for conveniently invoking quick actions in ui --- __init__.py | 26 ++++++- games/baldursgate3/bg3_utils.py | 64 ++---------------- games/baldursgate3/lslib_retriever.py | 18 +++-- games/baldursgate3/plugins/__init__.py | 13 ++++ games/baldursgate3/plugins/bg3_tool_plugin.py | 46 +++++++++++++ .../plugins/check_for_lslib_updates_plugin.py | 14 ++++ .../plugins/convert_jsons_to_yaml_plugin.py | 57 ++++++++++++++++ games/baldursgate3/plugins/icons/ui-next.ico | Bin 0 -> 110588 bytes .../baldursgate3/plugins/icons/ui-refresh.ico | Bin 0 -> 115044 bytes .../baldursgate3/plugins/icons/ui-update.ico | Bin 0 -> 120408 bytes .../plugins/reparse_pak_metadata_plugin.py | 16 +++++ games/game_baldursgate3.py | 60 ++++++---------- 12 files changed, 207 insertions(+), 107 deletions(-) create mode 100644 games/baldursgate3/plugins/__init__.py create mode 100644 games/baldursgate3/plugins/bg3_tool_plugin.py create mode 100644 games/baldursgate3/plugins/check_for_lslib_updates_plugin.py create mode 100644 games/baldursgate3/plugins/convert_jsons_to_yaml_plugin.py create mode 100644 games/baldursgate3/plugins/icons/ui-next.ico create mode 100644 games/baldursgate3/plugins/icons/ui-refresh.ico create mode 100644 games/baldursgate3/plugins/icons/ui-update.ico create mode 100644 games/baldursgate3/plugins/reparse_pak_metadata_plugin.py diff --git a/__init__.py b/__init__.py index 9d636b2b..1b89a757 100644 --- a/__init__.py +++ b/__init__.py @@ -3,10 +3,14 @@ import glob import importlib import os +import pathlib import site import sys import typing +from PyQt6.QtCore import qWarning +from mobase import IPlugin + from .basic_game import BasicGame from .basic_game_ini import BasicIniGame @@ -18,7 +22,7 @@ def createPlugins(): # List of game class from python: - game_plugins: typing.List[BasicGame] = [] + game_plugins: typing.List[IPlugin] = [] # We are going to list all game plugins: curpath = os.path.abspath(os.path.dirname(__file__)) @@ -58,5 +62,25 @@ def createPlugins(): "Failed to instantiate {}: {}".format(name, e), file=sys.stderr, ) + for path in pathlib.Path(escaped_games_path).rglob("plugins/__init__.py"): + module_path = "." + os.path.relpath(path.parent, curpath).replace(os.sep, ".") + try: + module = importlib.import_module(module_path, __package__) + if hasattr(module, "createPlugins") and callable(module.createPlugins): + try: + plugins: typing.Any = module.createPlugins() + for item in plugins: + if isinstance(item, IPlugin): + game_plugins.append(item) + except TypeError: + pass + if hasattr(module, "createPlugin") and callable(module.createPlugin): + plugin = module.createPlugin() + if isinstance(plugin, IPlugin): + game_plugins.append(plugin) + except ImportError as e: + qWarning(f"Error importing module {module_path}: {e}") + except Exception as e: + qWarning(f"Error calling function createPlugin(s) in {module_path}: {e}") return game_plugins diff --git a/games/baldursgate3/bg3_utils.py b/games/baldursgate3/bg3_utils.py index 8318ad07..fad6516e 100644 --- a/games/baldursgate3/bg3_utils.py +++ b/games/baldursgate3/bg3_utils.py @@ -1,12 +1,9 @@ import functools -import json -import os import shutil import typing from pathlib import Path import mobase -import yaml from PyQt6.QtCore import ( QCoreApplication, QDir, @@ -59,7 +56,7 @@ def __init__(self, name: str): self._name = name from . import lslib_retriever, pak_parser - self._lslib_retriever = lslib_retriever.LSLibRetriever(self) + self.lslib_retriever = lslib_retriever.LSLibRetriever(self) self._pak_parser = pak_parser.BG3PakParser(self) def init(self, organizer: mobase.IOrganizer): @@ -150,7 +147,6 @@ def create_progress_window( def on_user_interface_initialized(self, window: QMainWindow) -> None: self.main_window = window - pass def on_settings_changed( self, @@ -161,24 +157,7 @@ def on_settings_changed( ) -> None: if self._name != plugin_name: return - if new and setting == "check_for_lslib_updates": - try: - self._lslib_retriever.download_lslib_if_missing() - finally: - self._set_setting(setting, False) - elif new and setting == "force_reparse_metadata": - try: - self.construct_modsettings_xml( - exec_path="bin/bg3", force_reparse_metadata=True - ) - finally: - self._set_setting(setting, False) - elif new and setting == "convert_jsons_to_yaml": - try: - self._convert_jsons_to_yaml() - finally: - self._set_setting(setting, False) - elif setting in { + if setting in { "extract_full_package", "autobuild_paks", "remove_extracted_metadata", @@ -197,7 +176,7 @@ def construct_modsettings_xml( ) -> bool: if ( "bin/bg3" not in exec_path - or not self._lslib_retriever.download_lslib_if_missing() + or not self.lslib_retriever.download_lslib_if_missing() ): return True active_mods = self.active_mods() @@ -257,41 +236,6 @@ def retrieve_mod_metadata_in_new_thread(mod: mobase.IModInterface): shutil.copy(self.modsettings_path, self.modsettings_backup) return True - def _convert_jsons_to_yaml(self): - qInfo("converting all json files to yaml") - active_mods = self.active_mods() - progress = self.create_progress_window( - "Converting all json files to yaml", len(active_mods) + 1 - ) - for mod in active_mods: - _convert_jsons_in_dir_to_yaml(Path(mod.absolutePath())) - progress.setValue(progress.value() + 1) - QApplication.processEvents() - if progress.wasCanceled(): - qWarning("conversion canceled by user") - return - _convert_jsons_in_dir_to_yaml(self.overwrite_path) - progress.setValue(len(active_mods) + 1) - QApplication.processEvents() - progress.close() - def on_mod_installed(self, mod: mobase.IModInterface) -> None: - if self._lslib_retriever.download_lslib_if_missing(): + if self.lslib_retriever.download_lslib_if_missing(): self._pak_parser.get_metadata_for_files_in_mod(mod, True) - - -def _convert_jsons_in_dir_to_yaml(path: Path): - for file in list(path.rglob("*.json")): - converted_path = file.parent / file.name.replace(".json", ".yaml") - try: - if not converted_path.exists() or os.path.getmtime(file) > os.path.getmtime( - converted_path - ): - with open(file, "r") as json_file: - with open(converted_path, "w") as yaml_file: - yaml.dump( - json.load(json_file), yaml_file, indent=2, sort_keys=False - ) - qInfo(f"Converted {file} to YAML") - except OSError as e: - qWarning(f"Error accessing file {converted_path}: {e}") diff --git a/games/baldursgate3/lslib_retriever.py b/games/baldursgate3/lslib_retriever.py index 297950b1..9a7b7af1 100644 --- a/games/baldursgate3/lslib_retriever.py +++ b/games/baldursgate3/lslib_retriever.py @@ -33,10 +33,8 @@ def _needed_lslib_files(self): } } - def download_lslib_if_missing(self): - if not self._utils.get_setting("check_for_lslib_updates") and all( - x.exists() for x in self._needed_lslib_files - ): + def download_lslib_if_missing(self, force: bool = False) -> bool: + if not force and all(x.exists() for x in self._needed_lslib_files): return True try: self._utils.tools_dir.mkdir(exist_ok=True, parents=True) @@ -110,7 +108,11 @@ def reporthook(block_num: int, block_size: int, total_size: int) -> None: qDebug(f"Download failed: {e}") err = QMessageBox(self._utils.main_window) err.setIcon(QMessageBox.Icon.Critical) - err.setText(f"Failed to download LSLib tools:\n{traceback.format_exc()}") + err.setText( + self._utils.tr( + f"Failed to download LSLib tools:\n{traceback.format_exc()}" + ) + ) err.exec() return False try: @@ -145,7 +147,11 @@ def reporthook(block_num: int, block_size: int, total_size: int) -> None: qDebug(f"Extraction failed: {e}") err = QMessageBox(self._utils.main_window) err.setIcon(QMessageBox.Icon.Critical) - err.setText(f"Failed to extract LSLib tools:\n{traceback.format_exc()}") + err.setText( + self._utils.tr( + f"Failed to extract LSLib tools:\n{traceback.format_exc()}" + ) + ) err.exec() return False return True diff --git a/games/baldursgate3/plugins/__init__.py b/games/baldursgate3/plugins/__init__.py new file mode 100644 index 00000000..1b69fafd --- /dev/null +++ b/games/baldursgate3/plugins/__init__.py @@ -0,0 +1,13 @@ +import mobase + +from .check_for_lslib_updates_plugin import BG3ToolCheckForLsLibUpdates +from .convert_jsons_to_yaml_plugin import BG3ToolConvertJsonsToYaml +from .reparse_pak_metadata_plugin import BG3ToolReparsePakMetadata + + +def createPlugins() -> list[mobase.IPluginTool]: + return [ + BG3ToolCheckForLsLibUpdates(), + BG3ToolReparsePakMetadata(), + BG3ToolConvertJsonsToYaml(), + ] diff --git a/games/baldursgate3/plugins/bg3_tool_plugin.py b/games/baldursgate3/plugins/bg3_tool_plugin.py new file mode 100644 index 00000000..07709126 --- /dev/null +++ b/games/baldursgate3/plugins/bg3_tool_plugin.py @@ -0,0 +1,46 @@ +from pathlib import Path + +import mobase +from PyQt6.QtCore import QCoreApplication +from PyQt6.QtGui import QIcon + + +class BG3ToolPlugin(mobase.IPluginTool, mobase.IPlugin): + icon_file = desc = sub_name = "" + + def __init__(self): + mobase.IPluginTool.__init__(self) + mobase.IPlugin.__init__(self) + self._pluginName = self._displayName = "BG3 Tools" + self._pluginVersion = mobase.VersionInfo(1, 0, 0) + + def init(self, organizer: mobase.IOrganizer) -> bool: + self._organizer = organizer + return True + + def version(self): + return self._pluginVersion + + def author(self): + return "daescha" + + def name(self): + return f"{self._pluginName}: {self.sub_name}" + + def displayName(self): + return f"{self._displayName}/{self.sub_name}" + + def tooltip(self): + return self.description() + + def enabledByDefault(self): + return self._organizer.managedGame().name() == "Baldur's Gate 3 Plugin" + + def settings(self) -> list[mobase.PluginSetting]: + return [] + + def icon(self) -> QIcon: + return QIcon(str(Path(__file__).parent / "icons" / self.icon_file)) + + def description(self) -> str: + return QCoreApplication.translate(self._pluginName, self.desc) diff --git a/games/baldursgate3/plugins/check_for_lslib_updates_plugin.py b/games/baldursgate3/plugins/check_for_lslib_updates_plugin.py new file mode 100644 index 00000000..bc4df8cd --- /dev/null +++ b/games/baldursgate3/plugins/check_for_lslib_updates_plugin.py @@ -0,0 +1,14 @@ +from .bg3_tool_plugin import BG3ToolPlugin + + +class BG3ToolCheckForLsLibUpdates(BG3ToolPlugin): + icon_file = "ui-update.ico" + sub_name = "Check For LsLib Updates" + desc = "Check to see if there has been a new release of LSLib and create download dialog if so." + + def display(self): + from ...game_baldursgate3 import BG3Game + + game_plugin = self._organizer.managedGame() + if isinstance(game_plugin, BG3Game): + game_plugin.utils.lslib_retriever.download_lslib_if_missing(True) diff --git a/games/baldursgate3/plugins/convert_jsons_to_yaml_plugin.py b/games/baldursgate3/plugins/convert_jsons_to_yaml_plugin.py new file mode 100644 index 00000000..d6321aed --- /dev/null +++ b/games/baldursgate3/plugins/convert_jsons_to_yaml_plugin.py @@ -0,0 +1,57 @@ +import json +import os +from pathlib import Path + +from PyQt6.QtCore import qWarning, qInfo +from PyQt6.QtWidgets import QApplication + +from .bg3_tool_plugin import BG3ToolPlugin + + +class BG3ToolConvertJsonsToYaml(BG3ToolPlugin): + icon_file = "ui-next.ico" + sub_name = "Convert JSONS to YAML" + desc = "Convert all jsons in active mods to yaml immediately." + + def display(self): + from ...game_baldursgate3 import BG3Game + + game_plugin = self._organizer.managedGame() + if not isinstance(game_plugin, BG3Game): + return + utils = game_plugin.utils + qInfo("converting all json files to yaml") + active_mods = utils.active_mods() + progress = utils.create_progress_window( + "Converting all json files to yaml", len(active_mods) + 1 + ) + for mod in active_mods: + _convert_jsons_in_dir_to_yaml(Path(mod.absolutePath())) + progress.setValue(progress.value() + 1) + QApplication.processEvents() + if progress.wasCanceled(): + qWarning("conversion canceled by user") + return + _convert_jsons_in_dir_to_yaml(utils.overwrite_path) + progress.setValue(len(active_mods) + 1) + QApplication.processEvents() + progress.close() + + +def _convert_jsons_in_dir_to_yaml(path: Path): + import yaml + + for file in list(path.rglob("*.json")): + converted_path = file.parent / file.name.replace(".json", ".yaml") + try: + if not converted_path.exists() or os.path.getmtime(file) > os.path.getmtime( + converted_path + ): + with open(file, "r") as json_file: + with open(converted_path, "w") as yaml_file: + yaml.dump( + json.load(json_file), yaml_file, indent=2, sort_keys=False + ) + qInfo(f"Converted {file} to YAML") + except OSError as e: + qWarning(f"Error accessing file {converted_path}: {e}") diff --git a/games/baldursgate3/plugins/icons/ui-next.ico b/games/baldursgate3/plugins/icons/ui-next.ico new file mode 100644 index 0000000000000000000000000000000000000000..1ef6e3b2fa1479d0c2c92a183d941e6d39fa9ba5 GIT binary patch literal 110588 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 z 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 S^=DpVALXQ#rD`NhLjEtO^(lV< literal 0 HcmV?d00001 diff --git a/games/baldursgate3/plugins/reparse_pak_metadata_plugin.py b/games/baldursgate3/plugins/reparse_pak_metadata_plugin.py new file mode 100644 index 00000000..4f753710 --- /dev/null +++ b/games/baldursgate3/plugins/reparse_pak_metadata_plugin.py @@ -0,0 +1,16 @@ +from .bg3_tool_plugin import BG3ToolPlugin + + +class BG3ToolReparsePakMetadata(BG3ToolPlugin): + icon_file = "ui-refresh.ico" + sub_name = "Reparse Pak Metadata" + desc = "Force reparsing mod metadata immediately." + + def display(self): + from ...game_baldursgate3 import BG3Game + + game_plugin = self._organizer.managedGame() + if isinstance(game_plugin, BG3Game): + game_plugin.utils.construct_modsettings_xml( + exec_path="bin/bg3", force_reparse_metadata=True + ) diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py index d299e170..a165744f 100644 --- a/games/game_baldursgate3.py +++ b/games/game_baldursgate3.py @@ -44,14 +44,14 @@ def __init__(self): BasicGame.__init__(self) from .baldursgate3 import bg3_utils - self._utils = bg3_utils.BG3Utils(self.name()) + self.utils = bg3_utils.BG3Utils(self.name()) bg3_file_mapper.BG3FileMapper.__init__( - self, self._utils, self.documentsDirectory + self, self.utils, self.documentsDirectory ) def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) - self._utils.init(organizer) + self.utils.init(organizer) from .baldursgate3 import ( bg3_data_checker, bg3_data_content, @@ -61,11 +61,11 @@ def init(self, organizer: mobase.IOrganizer) -> bool: self._register_feature(bg3_data_content.BG3DataContent()) self._register_feature(BasicGameSaveGameInfo(lambda s: s.with_suffix(".webp"))) self._register_feature(BasicLocalSavegames(self.savesDirectory())) - organizer.onAboutToRun(self._utils.construct_modsettings_xml) + organizer.onAboutToRun(self.utils.construct_modsettings_xml) organizer.onFinishedRun(self._on_finished_run) - organizer.onUserInterfaceInitialized(self._utils.on_user_interface_initialized) - organizer.modList().onModInstalled(self._utils.on_mod_installed) - organizer.onPluginSettingChanged(self._utils.on_settings_changed) + organizer.onUserInterfaceInitialized(self.utils.on_user_interface_initialized) + organizer.modList().onModInstalled(self.utils.on_mod_installed) + organizer.onPluginSettingChanged(self.utils.on_settings_changed) return True def settings(self): @@ -97,21 +97,6 @@ def settings(self): "Remove extracted meta.lsx files when they are no longer needed.", True, ), - mobase.PluginSetting( - "force_reparse_metadata", - "Force reparsing mod metadata immediately.", - False, - ), - mobase.PluginSetting( - "convert_jsons_to_yaml", - "Convert all jsons in active mods to yaml immediately.", - False, - ), - mobase.PluginSetting( - "check_for_lslib_updates", - "Check to see if there has been a new release of LSLib and create download dialog if so.", - False, - ), mobase.PluginSetting( "extract_full_package", "Extract the full pak when parsing metadata, instead of just meta.lsx.", @@ -122,14 +107,9 @@ def settings(self): "Convert YAMLs to JSONs when executable runs. Allows one to configure ScriptExtender and related mods with YAML files.", False, ), - mobase.PluginSetting( - "convert_yamls_to_json", - "Convert YAMLs to JSONs when executable runs. Allows one to configure ScriptExtender and related mods with YAML files.", - False, - ), ] for setting in custom_settings: - setting.description = self._utils.tr(setting.description) + setting.description = self.utils.tr(setting.description) base_settings.append(setting) return base_settings @@ -154,7 +134,7 @@ def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: efls = super().executableForcedLoads() except AttributeError: efls = [] - if self._utils.force_load_dlls: + if self.utils.force_load_dlls: qInfo("detecting dlls in enabled mods") libs: set[str] = set() tree: mobase.IFileTree | mobase.FileTreeEntry | None = ( @@ -198,26 +178,26 @@ def _base_dlls(self) -> set[str]: def _on_finished_run(self, exec_path: str, exit_code: int): if "bin/bg3" not in exec_path: return - if self._utils.log_diff: + if self.utils.log_diff: for x in difflib.unified_diff( - open(self._utils.modsettings_backup).readlines(), - open(self._utils.modsettings_path).readlines(), - fromfile=str(self._utils.modsettings_backup), - tofile=str(self._utils.modsettings_path), + open(self.utils.modsettings_backup).readlines(), + open(self.utils.modsettings_path).readlines(), + fromfile=str(self.utils.modsettings_backup), + tofile=str(self.utils.modsettings_path), lineterm="", ): qDebug(x) - for path in self._utils.overwrite_path.rglob("*.log"): + for path in self.utils.overwrite_path.rglob("*.log"): try: - qDebug(f"moving {path} to {self._utils.log_dir}") - shutil.move(path, self._utils.log_dir / path.name) + qDebug(f"moving {path} to {self.utils.log_dir}") + shutil.move(path, self.utils.log_dir / path.name) except PermissionError as e: qDebug(str(e)) - days = self._utils.get_setting("delete_levelcache_folders_older_than_x_days") + days = self.utils.get_setting("delete_levelcache_folders_older_than_x_days") if type(days) is int and days >= 0: cutoff_time = datetime.datetime.now() - datetime.timedelta(days=days) qDebug(f"cleaning folders in overwrite/LevelCache older than {cutoff_time}") - for path in self._utils.overwrite_path.glob("LevelCache/*"): + for path in self.utils.overwrite_path.glob("LevelCache/*"): if ( datetime.datetime.fromtimestamp(os.path.getmtime(path)) < cutoff_time @@ -225,7 +205,7 @@ def _on_finished_run(self, exec_path: str, exit_code: int): shutil.rmtree(path, ignore_errors=True) qDebug("cleaning empty dirs from overwrite directory") for folder in sorted( - list(os.walk(self._utils.overwrite_path))[1:], reverse=True + list(os.walk(self.utils.overwrite_path))[1:], reverse=True ): try: os.rmdir(folder[0]) From 5793b67905b25a4a609522647e5a1b4c5067453a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 17 Aug 2025 03:14:14 +0000 Subject: [PATCH 20/20] [pre-commit.ci] Auto fixes from pre-commit.com hooks. --- __init__.py | 1 + games/baldursgate3/bg3_data_checker.py | 2 +- games/baldursgate3/bg3_file_mapper.py | 5 +++-- games/baldursgate3/bg3_utils.py | 3 ++- games/baldursgate3/pak_parser.py | 3 ++- games/baldursgate3/plugins/bg3_tool_plugin.py | 3 ++- games/baldursgate3/plugins/convert_jsons_to_yaml_plugin.py | 2 +- games/game_baldursgate3.py | 5 +++-- 8 files changed, 15 insertions(+), 9 deletions(-) diff --git a/__init__.py b/__init__.py index 1b89a757..f04289be 100644 --- a/__init__.py +++ b/__init__.py @@ -9,6 +9,7 @@ import typing from PyQt6.QtCore import qWarning + from mobase import IPlugin from .basic_game import BasicGame diff --git a/games/baldursgate3/bg3_data_checker.py b/games/baldursgate3/bg3_data_checker.py index 4cf8e8e7..695d21cd 100644 --- a/games/baldursgate3/bg3_data_checker.py +++ b/games/baldursgate3/bg3_data_checker.py @@ -2,8 +2,8 @@ import mobase +from ...basic_features import BasicModDataChecker, GlobPatterns, utils from . import bg3_utils -from ...basic_features import BasicModDataChecker, utils, GlobPatterns class BG3ModDataChecker(BasicModDataChecker): diff --git a/games/baldursgate3/bg3_file_mapper.py b/games/baldursgate3/bg3_file_mapper.py index 3bf038c0..ba0b987e 100644 --- a/games/baldursgate3/bg3_file_mapper.py +++ b/games/baldursgate3/bg3_file_mapper.py @@ -4,11 +4,12 @@ from pathlib import Path from typing import Callable, Optional -import mobase import yaml -from PyQt6.QtCore import qInfo, qDebug, qWarning, QDir +from PyQt6.QtCore import QDir, qDebug, qInfo, qWarning from PyQt6.QtWidgets import QApplication +import mobase + from . import bg3_utils diff --git a/games/baldursgate3/bg3_utils.py b/games/baldursgate3/bg3_utils.py index fad6516e..a61890c6 100644 --- a/games/baldursgate3/bg3_utils.py +++ b/games/baldursgate3/bg3_utils.py @@ -3,7 +3,6 @@ import typing from pathlib import Path -import mobase from PyQt6.QtCore import ( QCoreApplication, QDir, @@ -17,6 +16,8 @@ ) from PyQt6.QtWidgets import QApplication, QMainWindow, QProgressDialog +import mobase + loose_file_folders = { "Public", "Mods", diff --git a/games/baldursgate3/pak_parser.py b/games/baldursgate3/pak_parser.py index 29117cb4..89debd51 100644 --- a/games/baldursgate3/pak_parser.py +++ b/games/baldursgate3/pak_parser.py @@ -12,13 +12,14 @@ from xml.etree import ElementTree from xml.etree.ElementTree import Element -import mobase from PyQt6.QtCore import ( qDebug, qInfo, qWarning, ) +import mobase + from . import bg3_utils diff --git a/games/baldursgate3/plugins/bg3_tool_plugin.py b/games/baldursgate3/plugins/bg3_tool_plugin.py index 07709126..d77c5fcf 100644 --- a/games/baldursgate3/plugins/bg3_tool_plugin.py +++ b/games/baldursgate3/plugins/bg3_tool_plugin.py @@ -1,9 +1,10 @@ from pathlib import Path -import mobase from PyQt6.QtCore import QCoreApplication from PyQt6.QtGui import QIcon +import mobase + class BG3ToolPlugin(mobase.IPluginTool, mobase.IPlugin): icon_file = desc = sub_name = "" diff --git a/games/baldursgate3/plugins/convert_jsons_to_yaml_plugin.py b/games/baldursgate3/plugins/convert_jsons_to_yaml_plugin.py index d6321aed..58a51bb4 100644 --- a/games/baldursgate3/plugins/convert_jsons_to_yaml_plugin.py +++ b/games/baldursgate3/plugins/convert_jsons_to_yaml_plugin.py @@ -2,7 +2,7 @@ import os from pathlib import Path -from PyQt6.QtCore import qWarning, qInfo +from PyQt6.QtCore import qInfo, qWarning from PyQt6.QtWidgets import QApplication from .bg3_tool_plugin import BG3ToolPlugin diff --git a/games/game_baldursgate3.py b/games/game_baldursgate3.py index a165744f..03bd8a09 100644 --- a/games/game_baldursgate3.py +++ b/games/game_baldursgate3.py @@ -6,18 +6,19 @@ from pathlib import Path from typing import Any -import mobase from PyQt6.QtCore import ( qDebug, qInfo, ) -from .baldursgate3 import bg3_file_mapper +import mobase + from ..basic_features import ( BasicGameSaveGameInfo, BasicLocalSavegames, ) from ..basic_game import BasicGame +from .baldursgate3 import bg3_file_mapper class BG3Game(BasicGame, bg3_file_mapper.BG3FileMapper):