1- from functools import cached_property
1+ import json
2+ import os
3+ import shutil
4+ import functools
25from pathlib import Path
6+ import typing
37
48import mobase
5- from PyQt6 .QtCore import QCoreApplication , Qt
6- from PyQt6 .QtWidgets import QProgressDialog , QMainWindow
9+ import yaml
10+ from PyQt6 .QtCore import (
11+ QCoreApplication ,
12+ Qt ,
13+ qInfo ,
14+ qWarning ,
15+ QEventLoop ,
16+ QRunnable ,
17+ QDir ,
18+ QThreadPool ,
19+ QThread ,
20+ )
21+ from PyQt6 .QtWidgets import QProgressDialog , QMainWindow , QApplication
722
8- from games .baldursgate3 . lslib_retriever import LSLibRetriever
23+ from games .baldursgate3 import pak_parser , lslib_retriever
924
1025
1126class BG3Utils :
@@ -16,22 +31,105 @@ class BG3Utils:
1631 "Localization" ,
1732 "ScriptExtender" ,
1833 }
34+ _mod_settings_xml_start = """<?xml version="1.0" encoding="UTF-8"?>
35+ <save>
36+ <version major="4" minor="8" revision="0" build="200"/>
37+ <region id="ModuleSettings">
38+ <node id="root">
39+ <children>
40+ <node id="Mods">
41+ <children>
42+ <node id="ModuleShortDesc">
43+ <attribute id="Folder" type="LSString" value="GustavX"/>
44+ <attribute id="MD5" type="LSString" value=""/>
45+ <attribute id="Name" type="LSString" value="GustavX"/>
46+ <attribute id="PublishHandle" type="uint64" value="0"/>
47+ <attribute id="UUID" type="guid" value="cb555efe-2d9e-131f-8195-a89329d218ea"/>
48+ <attribute id="Version64" type="int64" value="36028797018963968"/>
49+ </node>"""
50+ _mod_settings_xml_end = """
51+ </children>
52+ </node>
53+ </children>
54+ </node>
55+ </region>
56+ </save>"""
57+
1958 def __init__ (self , organizer : mobase .IOrganizer , name : str ):
2059 self .main_window = None
2160 self ._organizer = organizer
2261 self ._name = name
23- self .lslib_retriever = LSLibRetriever (self )
24- @cached_property
62+ self ._lslib_retriever = lslib_retriever .LSLibRetriever (self )
63+ self ._pak_parser = pak_parser .BG3PakParser (self )
64+
65+ @functools .cached_property
66+ def autobuild_paks (self ):
67+ return bool (self .get_setting ("autobuild_paks" ))
68+
69+ @functools .cached_property
70+ def extract_full_package (self ):
71+ return bool (self .get_setting ("extract_full_package" ))
72+
73+ @functools .cached_property
74+ def remove_extracted_metadata (self ):
75+ return bool (self .get_setting ("remove_extracted_metadata" ))
76+
77+ @functools .cached_property
78+ def force_load_dlls (self ):
79+ return bool (self .get_setting ("force_load_dlls" ))
80+
81+ @functools .cached_property
82+ def log_diff (self ):
83+ return bool (self .get_setting ("log_diff" ))
84+
85+ @functools .cached_property
86+ def convert_yamls_to_json (self ):
87+ return bool (self .get_setting ("convert_yamls_to_json" ))
88+
89+ @functools .cached_property
90+ def log_dir (self ):
91+ return Path (self ._organizer .basePath ()) / "logs/"
92+
93+ @functools .cached_property
94+ def modsettings_backup (self ):
95+ return self .plugin_data_path / "temp/modsettings.lsx"
96+
97+ @functools .cached_property
98+ def modsettings_path (self ):
99+ return self .overwrite_path / "PlayerProfiles/Public/modsettings.lsx"
100+
101+ @functools .cached_property
25102 def plugin_data_path (self ) -> Path :
26103 """Gets the path to the data folder for the current plugin."""
27104 return Path (self ._organizer .pluginDataPath (), self ._name ).absolute ()
28- @cached_property
105+
106+ @functools .cached_property
29107 def tools_dir (self ):
30108 return self .plugin_data_path / "tools"
109+
110+ @functools .cached_property
111+ def overwrite_path (self ):
112+ return Path (self ._organizer .overwritePath ())
113+
114+ def active_mods (self ) -> list [mobase .IModInterface ]:
115+ modlist = self ._organizer .modList ()
116+ return [
117+ modlist .getMod (mod_name )
118+ for mod_name in filter (
119+ lambda mod : modlist .state (mod ) & mobase .ModState .ACTIVE ,
120+ modlist .allModsByProfilePriority (),
121+ )
122+ ]
123+
124+ def _set_setting (self , key : str , value : mobase .MoVariant ):
125+ self ._organizer .setPluginSetting (self ._name , key , value )
126+
31127 def get_setting (self , key : str ) -> mobase .MoVariant :
32128 return self ._organizer .pluginSetting (self ._name , key )
129+
33130 def tr (self , trstr : str ) -> str :
34131 return QCoreApplication .translate (self ._name , trstr )
132+
35133 def create_progress_window (
36134 self , title : str , max_progress : int , msg : str = "" , cancelable : bool = True
37135 ) -> QProgressDialog :
@@ -46,6 +144,149 @@ def create_progress_window(
46144 progress .setWindowModality (Qt .WindowModality .ApplicationModal )
47145 progress .show ()
48146 return progress
147+
49148 def on_user_interface_initialized (self , window : QMainWindow ) -> None :
50149 self .main_window = window
51150 pass
151+
152+ def on_settings_changed (
153+ self ,
154+ plugin_name : str ,
155+ setting : str ,
156+ old : mobase .MoVariant ,
157+ new : mobase .MoVariant ,
158+ ) -> None :
159+ if self ._name != plugin_name :
160+ return
161+ if new and setting == "check_for_lslib_updates" :
162+ try :
163+ self ._lslib_retriever .download_lslib_if_missing ()
164+ finally :
165+ self ._set_setting (setting , False )
166+ elif new and setting == "force_reparse_metadata" :
167+ try :
168+ self .construct_modsettings_xml (
169+ exec_path = "bin/bg3" , force_reparse_metadata = True
170+ )
171+ finally :
172+ self ._set_setting (setting , False )
173+ elif new and setting == "convert_jsons_to_yaml" :
174+ try :
175+ self ._convert_jsons_to_yaml ()
176+ finally :
177+ self ._set_setting (setting , False )
178+ elif setting in {
179+ "extract_full_package" ,
180+ "autobuild_paks" ,
181+ "remove_extracted_metadata" ,
182+ "force_load_dlls" ,
183+ "log_diff" ,
184+ "convert_yamls_to_json" ,
185+ } and hasattr (self , setting ):
186+ delattr (self , setting )
187+
188+ def construct_modsettings_xml (
189+ self ,
190+ exec_path : str = "" ,
191+ working_dir : typing .Optional [QDir ] = None ,
192+ args : str = "" ,
193+ force_reparse_metadata : bool = False ,
194+ ) -> bool :
195+ if (
196+ "bin/bg3" not in exec_path
197+ or not self ._lslib_retriever .download_lslib_if_missing ()
198+ ):
199+ return True
200+ active_mods = self .active_mods ()
201+ progress = self .create_progress_window (
202+ "Generating modsettings.xml" , len (active_mods )
203+ )
204+ threadpool = QThreadPool .globalInstance ()
205+ if threadpool is None :
206+ return False
207+ metadata : dict [str , str ] = {}
208+
209+ def retrieve_mod_metadata_in_new_thread (mod : mobase .IModInterface ):
210+ return lambda : metadata .update (
211+ self ._pak_parser .get_metadata_for_files_in_mod (
212+ mod , force_reparse_metadata
213+ )
214+ )
215+
216+ for mod in active_mods :
217+ if progress .wasCanceled ():
218+ qWarning ("processing canceled by user" )
219+ return False
220+ threadpool .start (QRunnable .create (retrieve_mod_metadata_in_new_thread (mod )))
221+ count = 0
222+ num_active_mods = len (active_mods )
223+ total_intervals_to_wait = (num_active_mods * 2 ) + 20
224+ while len (metadata .keys ()) < num_active_mods :
225+ progress .setValue (len (metadata .keys ()))
226+ QApplication .processEvents (QEventLoop .ProcessEventsFlag .AllEvents , 100 )
227+ count += 1
228+ if count == total_intervals_to_wait or progress .wasCanceled ():
229+ remaining_mods = {mod .name () for mod in active_mods } - metadata .keys ()
230+ qWarning (f"processing did not finish in time for: { remaining_mods } " )
231+ progress .close ()
232+ break
233+ QThread .msleep (100 )
234+ progress .setValue (num_active_mods )
235+ QApplication .processEvents (QEventLoop .ProcessEventsFlag .AllEvents , 100 )
236+ progress .close ()
237+ qInfo (f"writing mod load order to { self .modsettings_path } " )
238+ self .modsettings_path .parent .mkdir (parents = True , exist_ok = True )
239+ self .modsettings_path .write_text (
240+ (
241+ self ._mod_settings_xml_start
242+ + "" .join (
243+ metadata [mod .name ()]
244+ for mod in active_mods
245+ if mod .name () in metadata
246+ )
247+ + self ._mod_settings_xml_end
248+ )
249+ )
250+ qInfo (
251+ f"backing up generated file { self .modsettings_path } to { self .modsettings_backup } , "
252+ f"check the backup after the executable runs for differences with the file used by the game if you encounter issues"
253+ )
254+ shutil .copy (self .modsettings_path , self .modsettings_backup )
255+ return True
256+
257+ def _convert_jsons_to_yaml (self ):
258+ qInfo ("converting all json files to yaml" )
259+ active_mods = self .active_mods ()
260+ progress = self .create_progress_window (
261+ "Converting all json files to yaml" , len (active_mods ) + 1
262+ )
263+ for mod in active_mods :
264+ _convert_jsons_in_dir_to_yaml (Path (mod .absolutePath ()))
265+ progress .setValue (progress .value () + 1 )
266+ QApplication .processEvents ()
267+ if progress .wasCanceled ():
268+ qWarning ("conversion canceled by user" )
269+ return
270+ _convert_jsons_in_dir_to_yaml (self .overwrite_path )
271+ progress .setValue (len (active_mods ) + 1 )
272+ QApplication .processEvents ()
273+ progress .close ()
274+
275+ def on_mod_installed (self , mod : mobase .IModInterface ) -> None :
276+ if self ._lslib_retriever .download_lslib_if_missing ():
277+ self ._pak_parser .get_metadata_for_files_in_mod (mod , True )
278+
279+
280+ def _convert_jsons_in_dir_to_yaml (path : Path ):
281+ for file in list (path .rglob ("*.json" )):
282+ converted_path = file .parent / file .name .replace (".json" , ".yaml" )
283+ try :
284+ if not converted_path .exists () or os .path .getmtime (file ) > os .path .getmtime (
285+ converted_path
286+ ):
287+ with open (file , "r" ) as json_file :
288+ with open (converted_path , "w" ) as yaml_file :
289+ yaml .dump (json .load (json_file ), yaml_file , indent = 2 )
290+ qInfo (f"Converted { file } to YAML" )
291+ except OSError as e :
292+ qWarning (f"Error accessing file { converted_path } : { e } " )
0 commit comments