Skip to content

Commit 48cddc6

Browse files
authored
Make bg3 plugin more robust (#204)
* fix plugin icons not showing up when building with mob by embedding svgs instead * gate yaml import in bg3_file_mapper to ensure its only imported if user is using the functionality * bail out of mapping function early if modlist is empty * ensure spacing is consistent in generated modsettings.xml by creating a function to share the creating of the xml strings for ModuleShortDesc * dont do diffing if file doesnt exist or debug logging is not enabled * ensure dialog boxes are exec'd so the user can see them * mv log.txt files to logs dir, only make one logging statement for file moving * remove empty folders left behind by plugin in documents dir when executable finishes, log folders removed * add conversion of yamls in bin folder that wont get mapped to the documents directory * ensure all directories are created when they are needed * add catch for oserror in pak parsing function * add a small sleep after creation of modsettings.xml to ensure the executable does not launch too fast to see it * create directories that should be sent to overwrite folder from the documents folder if they dont exist in the mapping function, log all directories mapped if debug is enabled
1 parent d856199 commit 48cddc6

13 files changed

+227
-81
lines changed

games/baldursgate3/bg3_file_mapper.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import functools
2-
import json
32
import os
43
from pathlib import Path
54
from typing import Callable, Optional
65

7-
import yaml
8-
from PyQt6.QtCore import QDir, qDebug, qInfo, qWarning
6+
from PyQt6.QtCore import QDir, QLoggingCategory, qDebug, qInfo, qWarning
97
from PyQt6.QtWidgets import QApplication
108

119
import mobase
@@ -29,29 +27,49 @@ def mappings(self) -> list[mobase.Mapping]:
2927
qInfo("creating custom bg3 mappings")
3028
self.current_mappings.clear()
3129
active_mods = self._utils.active_mods()
32-
doc_dir = Path(self.doc_dir().path())
30+
if not active_mods:
31+
return []
3332
progress = self._utils.create_progress_window(
3433
"Mapping files to documents folder", len(active_mods) + 1
3534
)
36-
docs_path_mods = doc_dir / "Mods"
37-
docs_path_se = doc_dir / "Script Extender"
35+
docs_path_mods = self.doc_path / "Mods"
36+
docs_path_se = self.doc_path / "Script Extender"
3837
for mod in active_mods:
3938
modpath = Path(mod.absolutePath())
4039
self.map_files(modpath, dest=docs_path_mods, pattern="*.pak", rel=False)
4140
self.map_files(modpath / "Script Extender", dest=docs_path_se)
41+
if self._utils.convert_yamls_to_json:
42+
self.map_files(modpath / "bin", only_convert=True)
4243
progress.setValue(progress.value() + 1)
4344
QApplication.processEvents()
4445
if progress.wasCanceled():
4546
qWarning("mapping canceled by user")
4647
return self.current_mappings
48+
(self._utils.overwrite_path / "Script Extender").mkdir(
49+
parents=True, exist_ok=True
50+
)
51+
(self._utils.overwrite_path / "Stats").mkdir(parents=True, exist_ok=True)
52+
(self._utils.overwrite_path / "Temp").mkdir(parents=True, exist_ok=True)
53+
(self._utils.overwrite_path / "LevelCache").mkdir(parents=True, exist_ok=True)
54+
(self._utils.overwrite_path / "Stats").mkdir(parents=True, exist_ok=True)
55+
(self._utils.overwrite_path / "Mods").mkdir(parents=True, exist_ok=True)
56+
(self._utils.overwrite_path / "GMCampaigns").mkdir(parents=True, exist_ok=True)
4757
self.map_files(self._utils.overwrite_path)
4858
self.create_mapping(
4959
self._utils.modsettings_path,
50-
doc_dir / "PlayerProfiles" / "Public" / self._utils.modsettings_path.name,
60+
self.doc_path
61+
/ "PlayerProfiles"
62+
/ "Public"
63+
/ self._utils.modsettings_path.name,
5164
)
5265
progress.setValue(len(active_mods) + 1)
5366
QApplication.processEvents()
5467
progress.close()
68+
cat = QLoggingCategory.defaultCategory()
69+
if cat is not None and cat.isDebugEnabled():
70+
qDebug(
71+
f"resolved mappings: { {m.source: m.destination for m in self.current_mappings} }"
72+
)
5573
return self.current_mappings
5674

5775
def map_files(
@@ -60,6 +78,7 @@ def map_files(
6078
dest: Optional[Path] = None,
6179
pattern: str = "*",
6280
rel: bool = True,
81+
only_convert: bool = False,
6382
):
6483
dest = dest if dest else self.doc_path
6584
dest_func: Callable[[Path], str] = (
@@ -77,6 +96,10 @@ def map_files(
7796
if not converted_path.exists() or os.path.getmtime(
7897
file
7998
) > os.path.getmtime(converted_path):
99+
import json
100+
101+
import yaml
102+
80103
with open(file, "r") as yaml_file:
81104
with open(converted_path, "w") as json_file:
82105
json.dump(
@@ -88,12 +111,16 @@ def map_files(
88111
qWarning(f"Error accessing file {converted_path}: {e}")
89112
elif file.name.endswith(".json"):
90113
found_jsons.add(file)
91-
else:
114+
elif not only_convert:
92115
self.create_mapping(file, dest / dest_func(file))
116+
if only_convert:
117+
return
93118
for file in found_jsons:
94119
self.create_mapping(file, dest / dest_func(file))
95120

96121
def create_mapping(self, file: Path, dest: Path):
122+
bg3_utils.create_dir_if_needed(dest)
123+
97124
self.current_mappings.append(
98125
mobase.Mapping(
99126
source=str(file),

games/baldursgate3/bg3_utils.py

Lines changed: 60 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import shutil
33
import typing
44
from pathlib import Path
5+
from time import sleep
56

67
from PyQt6.QtCore import (
78
QCoreApplication,
@@ -27,30 +28,47 @@
2728
}
2829

2930

31+
def get_node_string(
32+
folder: str = "",
33+
md5: str = "",
34+
name: str = "",
35+
publish_handle: str = "0",
36+
uuid: str = "",
37+
version64: str = "0",
38+
) -> str:
39+
return f"""
40+
<node id="ModuleShortDesc">
41+
<attribute id="Folder" type="LSString" value="{folder}"/>
42+
<attribute id="MD5" type="LSString" value="{md5}"/>
43+
<attribute id="Name" type="LSString" value="{name}"/>
44+
<attribute id="PublishHandle" type="uint64" value="{publish_handle}"/>
45+
<attribute id="UUID" type="guid" value="{uuid}"/>
46+
<attribute id="Version64" type="int64" value="{version64}"/>
47+
</node>"""
48+
49+
3050
class BG3Utils:
31-
_mod_settings_xml_start = """<?xml version="1.0" encoding="UTF-8"?>
32-
<save>
33-
<version major="4" minor="8" revision="0" build="200"/>
34-
<region id="ModuleSettings">
35-
<node id="root">
36-
<children>
37-
<node id="Mods">
38-
<children>
39-
<node id="ModuleShortDesc">
40-
<attribute id="Folder" type="LSString" value="GustavX"/>
41-
<attribute id="MD5" type="LSString" value=""/>
42-
<attribute id="Name" type="LSString" value="GustavX"/>
43-
<attribute id="PublishHandle" type="uint64" value="0"/>
44-
<attribute id="UUID" type="guid" value="cb555efe-2d9e-131f-8195-a89329d218ea"/>
45-
<attribute id="Version64" type="int64" value="36028797018963968"/>
46-
</node>"""
51+
_mod_settings_xml_start = """\
52+
<?xml version="1.0" encoding="UTF-8"?>
53+
<save>
54+
<version major="4" minor="8" revision="0" build="500"/>
55+
<region id="ModuleSettings">
56+
<node id="root">
57+
<children>
58+
<node id="Mods">
59+
<children>""" + get_node_string(
60+
folder="GustavX",
61+
name="GustavX",
62+
uuid="cb555efe-2d9e-131f-8195-a89329d218ea",
63+
version64="36028797018963968",
64+
)
4765
_mod_settings_xml_end = """
48-
</children>
49-
</node>
50-
</children>
51-
</node>
52-
</region>
53-
</save>"""
66+
</children>
67+
</node>
68+
</children>
69+
</node>
70+
</region>
71+
</save>"""
5472

5573
def __init__(self, name: str):
5674
self.main_window = None
@@ -89,28 +107,32 @@ def convert_yamls_to_json(self):
89107

90108
@functools.cached_property
91109
def log_dir(self):
92-
return Path(self._organizer.basePath()) / "logs"
110+
return create_dir_if_needed(Path(self._organizer.basePath()) / "logs")
93111

94112
@functools.cached_property
95113
def modsettings_backup(self):
96-
return self.plugin_data_path / "temp" / "modsettings.lsx"
114+
return create_dir_if_needed(self.plugin_data_path / "temp" / "modsettings.lsx")
97115

98116
@functools.cached_property
99117
def modsettings_path(self):
100-
return Path(self._organizer.profilePath()) / "modsettings.lsx"
118+
return create_dir_if_needed(
119+
Path(self._organizer.profilePath()) / "modsettings.lsx"
120+
)
101121

102122
@functools.cached_property
103123
def plugin_data_path(self) -> Path:
104124
"""Gets the path to the data folder for the current plugin."""
105-
return Path(self._organizer.pluginDataPath(), self._name).absolute()
125+
return create_dir_if_needed(
126+
Path(self._organizer.pluginDataPath(), self._name).absolute()
127+
)
106128

107129
@functools.cached_property
108130
def tools_dir(self):
109-
return self.plugin_data_path / "tools"
131+
return create_dir_if_needed(self.plugin_data_path / "tools")
110132

111133
@functools.cached_property
112134
def overwrite_path(self):
113-
return Path(self._organizer.overwritePath())
135+
return create_dir_if_needed(Path(self._organizer.overwritePath()))
114136

115137
def active_mods(self) -> list[mobase.IModInterface]:
116138
modlist = self._organizer.modList()
@@ -234,9 +256,19 @@ def retrieve_mod_metadata_in_new_thread(mod: mobase.IModInterface):
234256
f"backing up generated file {self.modsettings_path} to {self.modsettings_backup}, "
235257
f"check the backup after the executable runs for differences with the file used by the game if you encounter issues"
236258
)
259+
self.modsettings_backup.parent.mkdir(parents=True, exist_ok=True)
237260
shutil.copy(self.modsettings_path, self.modsettings_backup)
261+
sleep(0.5)
238262
return True
239263

240264
def on_mod_installed(self, mod: mobase.IModInterface) -> None:
241265
if self.lslib_retriever.download_lslib_if_missing():
242266
self._pak_parser.get_metadata_for_files_in_mod(mod, True)
267+
268+
269+
def create_dir_if_needed(path: Path) -> Path:
270+
if "." not in path.name[1:]:
271+
path.mkdir(parents=True, exist_ok=True)
272+
else:
273+
path.parent.mkdir(parents=True, exist_ok=True)
274+
return path

games/baldursgate3/lslib_retriever.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ def reporthook(block_num: int, block_size: int, total_size: int) -> None:
8686
err.setText(
8787
"LSLib tools are required for the proper generation of the modsettings.xml file, file will not be generated"
8888
)
89+
err.exec()
8990
return False
9091
else:
9192
progress = self._utils.create_progress_window(
@@ -106,6 +107,7 @@ def reporthook(block_num: int, block_size: int, total_size: int) -> None:
106107
new_msg.setText(
107108
self._utils.tr("Latest version of LSLib already downloaded!")
108109
)
110+
new_msg.exec()
109111

110112
except Exception as e:
111113
qDebug(f"Download failed: {e}")

games/baldursgate3/pak_parser.py

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -161,17 +161,21 @@ def _get_metadata_for_file(
161161
)
162162
build_pak = True
163163
if pak_path.exists():
164-
pak_creation_time = os.path.getmtime(pak_path)
165-
for root, _, files in os.walk(file):
166-
for f in files:
167-
file_path = os.path.join(root, f)
168-
try:
169-
if os.path.getmtime(file_path) > pak_creation_time:
164+
try:
165+
pak_creation_time = os.path.getmtime(pak_path)
166+
for root, _, files in file.walk():
167+
for f in files:
168+
file_path = root.joinpath(f)
169+
try:
170+
if os.path.getmtime(file_path) > pak_creation_time:
171+
break
172+
except OSError as e:
173+
qDebug(f"Error accessing file {file_path}: {e}")
170174
break
171-
except OSError as e:
172-
qDebug(f"Error accessing file {file_path}: {e}")
173-
break
174-
else:
175+
else:
176+
build_pak = False
177+
except OSError as e:
178+
qDebug(f"Error accessing file {pak_path}: {e}")
175179
build_pak = False
176180
if build_pak:
177181
pak_path.unlink(missing_ok=True)
@@ -274,18 +278,18 @@ def metadata_to_ini(
274278

275279

276280
def get_module_short_desc(config: configparser.ConfigParser, file: Path) -> str:
281+
if not config.has_section(file.name):
282+
return ""
283+
section: configparser.SectionProxy = config[file.name]
277284
return (
278285
""
279-
if not config.has_section(file.name)
280-
or "override" in config[file.name].keys()
281-
or "Name" not in config[file.name].keys()
282-
else f"""
283-
<node id="ModuleShortDesc">
284-
<attribute id="Folder" type="LSString" value="{config[file.name]["Folder"]}"/>
285-
<attribute id="MD5" type="LSString" value="{config[file.name]["MD5"]}"/>
286-
<attribute id="Name" type="LSString" value="{config[file.name]["Name"]}"/>
287-
<attribute id="PublishHandle" type="uint64" value="{config[file.name]["PublishHandle"]}"/>
288-
<attribute id="UUID" type="guid" value="{config[file.name]["UUID"]}"/>
289-
<attribute id="Version64" type="int64" value="{config[file.name]["Version64"]}"/>
290-
</node>"""
286+
if "override" in section.keys() or "Name" not in section.keys()
287+
else bg3_utils.get_node_string(
288+
folder=section["Folder"],
289+
md5=section["MD5"],
290+
name=section["Name"],
291+
publish_handle=section["PublishHandle"],
292+
uuid=section["UUID"],
293+
version64=section["Version64"],
294+
)
291295
)

games/baldursgate3/plugins/bg3_tool_plugin.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
1-
from pathlib import Path
2-
31
from PyQt6.QtCore import QCoreApplication
4-
from PyQt6.QtGui import QIcon
2+
from PyQt6.QtGui import QIcon, QPixmap
53

64
import mobase
75

86

97
class BG3ToolPlugin(mobase.IPluginTool, mobase.IPlugin):
10-
icon_file = desc = sub_name = ""
8+
desc = sub_name = ""
9+
icon_bytes: bytes
1110

1211
def __init__(self):
1312
mobase.IPluginTool.__init__(self)
1413
mobase.IPlugin.__init__(self)
1514
self._pluginName = self._displayName = "BG3 Tools"
1615
self._pluginVersion = mobase.VersionInfo(1, 0, 0)
16+
pixmap = QPixmap()
17+
pixmap.loadFromData(self.icon_bytes, "SVG")
18+
self.qicon = QIcon(pixmap)
1719

1820
def init(self, organizer: mobase.IOrganizer) -> bool:
1921
self._organizer = organizer
@@ -41,7 +43,7 @@ def settings(self) -> list[mobase.PluginSetting]:
4143
return []
4244

4345
def icon(self) -> QIcon:
44-
return QIcon(str(Path(__file__).parent / "icons" / self.icon_file))
46+
return self.qicon
4547

4648
def description(self) -> str:
4749
return QCoreApplication.translate(self._pluginName, self.desc)

games/baldursgate3/plugins/check_for_lslib_updates_plugin.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from .bg3_tool_plugin import BG3ToolPlugin
2+
from .icons import download
23

34

45
class BG3ToolCheckForLsLibUpdates(BG3ToolPlugin):
5-
icon_file = "ui-update.ico"
6+
icon_bytes = download
67
sub_name = "Check For LsLib Updates"
78
desc = "Check to see if there has been a new release of LSLib and create download dialog if so."
89

games/baldursgate3/plugins/convert_jsons_to_yaml_plugin.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
from PyQt6.QtWidgets import QApplication
77

88
from .bg3_tool_plugin import BG3ToolPlugin
9+
from .icons import exchange
910

1011

1112
class BG3ToolConvertJsonsToYaml(BG3ToolPlugin):
12-
icon_file = "ui-next.ico"
13+
icon_bytes = exchange
1314
sub_name = "Convert JSONS to YAML"
1415
desc = "Convert all jsons in active mods to yaml immediately."
1516

@@ -39,14 +40,14 @@ def display(self):
3940

4041

4142
def _convert_jsons_in_dir_to_yaml(path: Path):
42-
import yaml
43-
4443
for file in list(path.rglob("*.json")):
4544
converted_path = file.parent / file.name.replace(".json", ".yaml")
4645
try:
4746
if not converted_path.exists() or os.path.getmtime(file) > os.path.getmtime(
4847
converted_path
4948
):
49+
import yaml
50+
5051
with open(file, "r") as json_file:
5152
with open(converted_path, "w") as yaml_file:
5253
yaml.dump(

0 commit comments

Comments
 (0)