Skip to content

Commit 3ef9de1

Browse files
daeschaZashIn
authored andcommitted
Add official support for Baldur's Gate 3 (ModOrganizer2#188)
1 parent c3fe986 commit 3ef9de1

21 files changed

Lines changed: 1303 additions & 14 deletions

.github/workflows/linters.yml

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,16 @@ on: [push, pull_request]
55
jobs:
66
checks:
77
runs-on: ubuntu-latest
8+
defaults:
9+
run:
10+
working-directory: basic_games
811
steps:
912
- uses: actions/checkout@v4
1013
with:
11-
path: "basic_games"
12-
- name: Set up Python
13-
uses: actions/setup-python@v2
14+
path: basic_games
15+
- name: Setup and Cache Python Poetry
16+
uses: packetcoders/action-setup-cache-python-poetry@v1.2.0
1417
with:
1518
python-version: 3.12
16-
- uses: abatilo/actions-poetry@v2
17-
- name: Install
18-
run: |
19-
cd basic_games
20-
poetry --no-root install
2119
- name: Lint
22-
run: |
23-
cd basic_games
24-
poetry run poe lint
20+
run: poetry run poe lint

__init__.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,15 @@
33
import glob
44
import importlib
55
import os
6+
import pathlib
67
import site
78
import sys
89
import typing
910

11+
from PyQt6.QtCore import qWarning
12+
13+
from mobase import IPlugin
14+
1015
from .basic_game import BasicGame
1116
from .basic_game_ini import BasicIniGame
1217

@@ -18,7 +23,7 @@
1823

1924
def createPlugins():
2025
# List of game class from python:
21-
game_plugins: typing.List[BasicGame] = []
26+
game_plugins: typing.List[IPlugin] = []
2227

2328
# We are going to list all game plugins:
2429
curpath = os.path.abspath(os.path.dirname(__file__))
@@ -58,5 +63,25 @@ def createPlugins():
5863
"Failed to instantiate {}: {}".format(name, e),
5964
file=sys.stderr,
6065
)
66+
for path in pathlib.Path(escaped_games_path).rglob("plugins/__init__.py"):
67+
module_path = "." + os.path.relpath(path.parent, curpath).replace(os.sep, ".")
68+
try:
69+
module = importlib.import_module(module_path, __package__)
70+
if hasattr(module, "createPlugins") and callable(module.createPlugins):
71+
try:
72+
plugins: typing.Any = module.createPlugins()
73+
for item in plugins:
74+
if isinstance(item, IPlugin):
75+
game_plugins.append(item)
76+
except TypeError:
77+
pass
78+
if hasattr(module, "createPlugin") and callable(module.createPlugin):
79+
plugin = module.createPlugin()
80+
if isinstance(plugin, IPlugin):
81+
game_plugins.append(plugin)
82+
except ImportError as e:
83+
qWarning(f"Error importing module {module_path}: {e}")
84+
except Exception as e:
85+
qWarning(f"Error calling function createPlugin(s) in {module_path}: {e}")
6186

6287
return game_plugins

games/baldursgate3/__init__.py

Whitespace-only changes.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from pathlib import Path
2+
3+
import mobase
4+
5+
from ...basic_features import BasicModDataChecker, GlobPatterns, utils
6+
from . import bg3_utils
7+
8+
9+
class BG3ModDataChecker(BasicModDataChecker):
10+
def __init__(self):
11+
super().__init__(
12+
GlobPatterns(
13+
valid=[
14+
"*.pak",
15+
str(Path("Mods") / "*.pak"), # standard mods
16+
"bin", # native mods / Script Extender
17+
"Script Extender", # mods which are configured via jsons in this folder
18+
"Data", # loose file mods
19+
]
20+
+ [str(Path("*") / f) for f in bg3_utils.loose_file_folders],
21+
move={
22+
"Root/": "", # root builder not needed
23+
"*.dll": "bin/",
24+
"ScriptExtenderSettings.json": "bin/",
25+
}
26+
| {f: "Data/" for f in bg3_utils.loose_file_folders},
27+
delete=["info.json", "*.txt"],
28+
)
29+
)
30+
31+
def dataLooksValid(
32+
self, filetree: mobase.IFileTree
33+
) -> mobase.ModDataChecker.CheckReturn:
34+
status = mobase.ModDataChecker.INVALID
35+
rp = self._regex_patterns
36+
for entry in filetree:
37+
name = entry.name().casefold()
38+
if rp.unfold.match(name):
39+
if utils.is_directory(entry):
40+
status = self.dataLooksValid(entry)
41+
else:
42+
status = mobase.ModDataChecker.INVALID
43+
break
44+
elif rp.valid.match(name):
45+
if status is mobase.ModDataChecker.INVALID:
46+
status = mobase.ModDataChecker.VALID
47+
elif isinstance(entry, mobase.IFileTree):
48+
status = (
49+
mobase.ModDataChecker.VALID
50+
if all(rp.valid.match(e.pathFrom(filetree)) for e in entry)
51+
else mobase.ModDataChecker.INVALID
52+
)
53+
elif rp.delete.match(name) or rp.move_match(name) is not None:
54+
status = mobase.ModDataChecker.FIXABLE
55+
else:
56+
status = mobase.ModDataChecker.INVALID
57+
break
58+
return status
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from enum import IntEnum, auto
2+
3+
import mobase
4+
5+
from . import bg3_utils
6+
7+
8+
class Content(IntEnum):
9+
PAK = auto()
10+
WORKSPACE = auto()
11+
NATIVE = auto()
12+
LOOSE_FILES = auto()
13+
SE_FILES = auto()
14+
15+
16+
class BG3DataContent(mobase.ModDataContent):
17+
BG3_CONTENTS: list[tuple[Content, str, str, bool] | tuple[Content, str, str]] = [
18+
(Content.WORKSPACE, "Mod workspace", ":/MO/gui/content/script"),
19+
(Content.PAK, "Pak", ":/MO/gui/content/bsa"),
20+
(Content.LOOSE_FILES, "Loose file override mod", ":/MO/gui/content/texture"),
21+
(Content.SE_FILES, "Script Extender Files", ":/MO/gui/content/inifile"),
22+
(Content.NATIVE, "Native DLL mod", ":/MO/gui/content/plugin"),
23+
]
24+
25+
def getAllContents(self) -> list[mobase.ModDataContent.Content]:
26+
return [
27+
mobase.ModDataContent.Content(id, name, icon, *filter_only)
28+
for id, name, icon, *filter_only in self.BG3_CONTENTS
29+
]
30+
31+
def getContentsFor(self, filetree: mobase.IFileTree) -> list[int]:
32+
contents: set[int] = set()
33+
for entry in filetree:
34+
if isinstance(entry, mobase.IFileTree):
35+
match entry.name():
36+
case "Script Extender":
37+
contents.add(Content.SE_FILES)
38+
case "Data":
39+
contents.add(Content.LOOSE_FILES)
40+
case "Mods":
41+
for e in entry:
42+
if e.name().endswith(".pak"):
43+
contents.add(Content.PAK)
44+
break
45+
case "bin":
46+
contents.add(Content.NATIVE)
47+
case _:
48+
for e in entry:
49+
if e.name() in bg3_utils.loose_file_folders:
50+
contents.add(Content.WORKSPACE)
51+
break
52+
elif entry.name().endswith(".pak"):
53+
contents.add(Content.PAK)
54+
return list(contents)
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import functools
2+
import json
3+
import os
4+
from pathlib import Path
5+
from typing import Callable, Optional
6+
7+
import yaml
8+
from PyQt6.QtCore import QDir, qDebug, qInfo, qWarning
9+
from PyQt6.QtWidgets import QApplication
10+
11+
import mobase
12+
13+
from . import bg3_utils
14+
15+
16+
class BG3FileMapper(mobase.IPluginFileMapper):
17+
current_mappings: list[mobase.Mapping] = []
18+
19+
def __init__(self, utils: bg3_utils.BG3Utils, doc_dir: Callable[[], QDir]):
20+
super().__init__()
21+
self._utils = utils
22+
self.doc_dir = doc_dir
23+
24+
@functools.cached_property
25+
def doc_path(self):
26+
return Path(self.doc_dir().path())
27+
28+
def mappings(self) -> list[mobase.Mapping]:
29+
qInfo("creating custom bg3 mappings")
30+
self.current_mappings.clear()
31+
active_mods = self._utils.active_mods()
32+
doc_dir = Path(self.doc_dir().path())
33+
progress = self._utils.create_progress_window(
34+
"Mapping files to documents folder", len(active_mods) + 1
35+
)
36+
docs_path_mods = doc_dir / "Mods"
37+
docs_path_se = doc_dir / "Script Extender"
38+
for mod in active_mods:
39+
modpath = Path(mod.absolutePath())
40+
self.map_files(modpath, dest=docs_path_mods, pattern="*.pak", rel=False)
41+
self.map_files(modpath / "Script Extender", dest=docs_path_se)
42+
progress.setValue(progress.value() + 1)
43+
QApplication.processEvents()
44+
if progress.wasCanceled():
45+
qWarning("mapping canceled by user")
46+
return self.current_mappings
47+
self.map_files(self._utils.overwrite_path)
48+
self.create_mapping(
49+
self._utils.modsettings_path,
50+
doc_dir / "PlayerProfiles" / "Public" / self._utils.modsettings_path.name,
51+
)
52+
progress.setValue(len(active_mods) + 1)
53+
QApplication.processEvents()
54+
progress.close()
55+
return self.current_mappings
56+
57+
def map_files(
58+
self,
59+
path: Path,
60+
dest: Optional[Path] = None,
61+
pattern: str = "*",
62+
rel: bool = True,
63+
):
64+
dest = dest if dest else self.doc_path
65+
dest_func: Callable[[Path], str] = (
66+
(lambda f: os.path.relpath(f, path)) if rel else lambda f: f.name
67+
)
68+
found_jsons: set[Path] = set()
69+
for file in list(path.rglob(pattern)):
70+
if self._utils.convert_yamls_to_json and (
71+
file.name.endswith(".yaml") or file.name.endswith(".yml")
72+
):
73+
converted_path = file.parent / file.name.replace(
74+
".yaml", ".json"
75+
).replace(".yml", ".json")
76+
try:
77+
if not converted_path.exists() or os.path.getmtime(
78+
file
79+
) > os.path.getmtime(converted_path):
80+
with open(file, "r") as yaml_file:
81+
with open(converted_path, "w") as json_file:
82+
json.dump(
83+
yaml.safe_load(yaml_file), json_file, indent=2
84+
)
85+
qDebug(f"Converted {file} to JSON")
86+
found_jsons.add(converted_path)
87+
except OSError as e:
88+
qWarning(f"Error accessing file {converted_path}: {e}")
89+
elif file.name.endswith(".json"):
90+
found_jsons.add(file)
91+
else:
92+
self.create_mapping(file, dest / dest_func(file))
93+
for file in found_jsons:
94+
self.create_mapping(file, dest / dest_func(file))
95+
96+
def create_mapping(self, file: Path, dest: Path):
97+
self.current_mappings.append(
98+
mobase.Mapping(
99+
source=str(file),
100+
destination=str(dest),
101+
is_directory=file.is_dir(),
102+
create_target=True,
103+
)
104+
)

0 commit comments

Comments
 (0)