-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathmod.py
More file actions
398 lines (319 loc) · 13.8 KB
/
mod.py
File metadata and controls
398 lines (319 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
from __future__ import annotations
import inspect
import sys
import warnings
from dataclasses import dataclass, field
from enum import Enum, Flag, auto
from functools import cache
from pathlib import Path
from typing import TYPE_CHECKING, Literal
from unrealsdk import logging
from .command import AbstractCommand
from .hook import HookType
from .keybinds import KeybindType
from .options import BaseOption, GroupedOption, KeybindOption, NestedOption
from .settings import default_load_mod_settings, default_save_mod_settings
if TYPE_CHECKING:
from collections.abc import Callable, Iterator, Sequence
_WARNING_SKIPS: tuple[str] = (str(Path(__file__).parent),)
class Game(Flag):
"""A flags enum of the supported games."""
BL1 = auto()
BL1E = auto()
BL2 = auto()
TPS = auto()
AoDK = auto()
BL3 = auto()
WL = auto()
BL4 = auto()
type _GameOnly = Literal[
Game.BL1,
Game.BL1E,
Game.BL2,
Game.TPS,
Game.AoDK,
Game.BL3,
Game.WL,
Game.BL4,
]
Willow1 = BL1 | BL1E
Willow2 = BL2 | TPS | AoDK
Oak = BL3 | WL
Oak2 = BL4
type _TreeOnly = Literal[
Game.Willow1,
Game.Willow2,
Game.Oak,
Game.Oak2,
]
@staticmethod
@cache
def get_current() -> _GameOnly:
"""Gets the current game."""
# As a bit of safety, we can use the architecture to limit which games are allowed
is_64bits = sys.maxsize > 2**32
lower_exe_names: dict[str, Game._GameOnly]
default_game: Game._GameOnly
if is_64bits:
lower_exe_names = {
"borderlandsgoty.exe": Game.BL1E,
"borderlands3.exe": Game.BL3,
"borderlands4.exe": Game.BL4,
"wonderlands.exe": Game.WL,
}
default_game = Game.BL3
else:
lower_exe_names = {
"borderlands.exe": Game.BL1,
"borderlands2.exe": Game.BL2,
"borderlandspresequel.exe": Game.TPS,
"tinytina.exe": Game.AoDK,
}
default_game = Game.BL2
exe = Path(sys.executable).name
exe_lower = exe.lower()
if exe_lower not in lower_exe_names:
# We've occasionally seen the executable corrupt in the old willow sdk
# Instead of throwing, we'll still try return something sane, to keep stuff working
logging.error(f"Unknown executable name '{exe}'! Assuming {default_game.name}.")
return default_game
return lower_exe_names[exe_lower]
@staticmethod
@cache
def get_tree() -> _TreeOnly:
"""
Gets the "tree" the game we're currently running belongs to.
Gearbox code names games using tree names. For the games based on same engine, like BL2/TPS,
they of course reuse the same code name a lot (since they don't touch the base engine). We
use these to categorise engine versions, where mods are likely to be cross compatible.
Returns:
The current game's tree.
"""
match Game.get_current():
case Game.BL1 | Game.BL1E:
return Game.Willow1
case Game.BL2 | Game.TPS | Game.AoDK:
return Game.Willow2
case Game.BL3 | Game.WL:
return Game.Oak
case Game.BL4:
return Game.Oak2
class ModType(Enum):
"""
What type of mod this is.
This does not influence functionality. It's only used for categorization - e.g. influencing
ordering in the mod list.
"""
Standard = auto()
Library = auto()
class CoopSupport(Enum):
"""Enum for how well a mod supports coop. This is informational only."""
Unknown = auto()
Incompatible = auto()
RequiresAllPlayers = auto()
ClientSide = auto()
HostOnly = auto()
@dataclass
class Mod:
"""
A mod instance to display in the mods menu.
The various display strings may contain HTML tags + entities. All mod menus are expected to
handle them, parsing or striping as appropriate. Other forms of markup are allowed, but may be
handled incorrectly by some mod menus.
Attributes - Metadata:
name: The mod's name.
author: The mod's author(s).
description: A short description of the mod.
version: A string holding the mod's version. This is purely a display value, the module
level attributes should be used for version checking.
mod_type: What type of mod this is. This does not influence functionality.
supported_games: The games this mod supports. When loaded in an unsupported game, a warning
will be displayed and the mod will be blocked from enabling.
coop_support: How well the mod supports coop, if known. This is purely a display value.
settings_file: The file to save settings to. If None (the default), won't save settings.
Attributes - Functionality:
keybinds: The mod's keybinds. If not given, searches for them in instance variables.
options: The mod's options. If not given, searches for them in instance variables.
hooks: The mod's hooks. If not given, searches for them in instance variables.
commands: The mod's commands. If not given, searches for them in instance variables.
Attributes - Enabling:
enabling_locked: If true, the mod cannot be enabled or disabled, it's locked in it's current
state. Set automatically, not available in constructor.
is_enabled: True if the mod is currently considered enabled. Not available in constructor.
auto_enable: True if to enable the mod on launch if it was also enabled last time.
on_enable: A no-arg callback to run on mod enable. Useful when constructing via dataclass.
on_disable: A no-arg callback to run on mod disable. Useful when constructing via dataclass.
"""
name: str
author: str = "Unknown Author"
description: str = ""
version: str = "Unknown Version"
mod_type: ModType = ModType.Standard
supported_games: Game = field(default=Game.get_tree())
coop_support: CoopSupport = CoopSupport.Unknown
settings_file: Path | None = None
# Set the default to None so we can detect when these aren't provided
# Don't type them as possibly None though, since we're going to fix it immediately in the
# constructor, and it'd force you to do None checks whenever you're accessing them
keybinds: Sequence[KeybindType] = field(default=None) # type: ignore
options: Sequence[BaseOption] = field(default=None) # type: ignore
hooks: Sequence[HookType] = field(default=None) # type: ignore
commands: Sequence[AbstractCommand] = field(default=None) # type: ignore
enabling_locked: bool = field(init=False)
is_enabled: bool = field(default=False, init=False)
auto_enable: bool = True
on_enable: Callable[[], None] | None = None
on_disable: Callable[[], None] | None = None
def __post_init__(self) -> None: # noqa: C901 - difficult to split up
need_to_search_instance_vars = False
new_keybinds: list[KeybindType] = []
if find_keybinds := self.keybinds is None: # type: ignore
self.keybinds = new_keybinds
need_to_search_instance_vars = True
new_options: list[BaseOption] = []
if find_options := self.options is None: # type: ignore
self.options = new_options
need_to_search_instance_vars = True
new_hooks: list[HookType] = []
if find_hooks := self.hooks is None: # type: ignore
self.hooks = new_hooks
need_to_search_instance_vars = True
new_commands: list[AbstractCommand] = []
if find_commands := self.commands is None: # type: ignore
self.commands = new_commands
need_to_search_instance_vars = True
if need_to_search_instance_vars:
for name, value in inspect.getmembers(self):
match value:
case KeybindType() if find_keybinds:
new_keybinds.append(value)
case GroupedOption() | NestedOption() if find_options:
warnings.warn(
f"{self.name}: {type(value).__name__} instances must be explicitly"
f" specified in the options list!",
stacklevel=2,
skip_file_prefixes=_WARNING_SKIPS,
)
case BaseOption() if find_options:
new_options.append(value)
case HookType() if find_hooks:
bound_hook: HookType = value.bind(self) # pyright: ignore[reportUnknownVariableType]
new_hooks.append(bound_hook)
setattr(self, name, bound_hook)
case AbstractCommand() if find_commands:
new_commands.append(value)
case _:
pass
self.enabling_locked = Game.get_current() not in self.supported_games
def associate_options(options: Sequence[BaseOption]) -> None:
for option in options:
if isinstance(option, GroupedOption | NestedOption):
associate_options(option.children)
option.mod = self
associate_options(self.options)
def enable(self) -> None:
"""Called to enable the mod."""
if self.enabling_locked:
return
if self.is_enabled:
return
self.is_enabled = True
for keybind in self.keybinds:
keybind.enable()
for hook in self.hooks:
hook.enable()
for command in self.commands:
command.enable()
if self.on_enable is not None:
self.on_enable()
if self.auto_enable:
self.save_settings()
def disable(self, dont_update_setting: bool = False) -> None:
"""
Called to disable the mod.
Args:
dont_update_setting: If true, prevents updating the enabled flag in the settings file.
Should be set for automated disables, and clear for manual ones.
"""
if self.enabling_locked:
return
if not self.is_enabled:
return
self.is_enabled = False
for keybind in self.keybinds:
keybind.disable()
for hook in self.hooks:
hook.disable()
for command in self.commands:
command.disable()
if self.on_disable is not None:
self.on_disable()
if self.auto_enable and not dont_update_setting:
self.save_settings()
def load_settings(self) -> None:
"""
Loads data for this mod from it's settings file - including auto enabling if needed.
This is called during `register_mod`, you generally won't need to call it yourself.
"""
default_load_mod_settings(self)
def save_settings(self) -> None:
"""Saves the current state of the mod to it's settings file."""
default_save_mod_settings(self)
def iter_display_options(self) -> Iterator[BaseOption]:
"""
Iterates through the options to display in the options menu.
This may yield options not in the options list, to customize how the menu is displayed.
Yields:
Options, in the order they should be displayed.
"""
if any(not opt.is_hidden for opt in self.options):
yield GroupedOption("Options", self.options)
if any(not kb.is_hidden for kb in self.keybinds):
yield GroupedOption(
"Keybinds",
[KeybindOption.from_keybind(bind) for bind in self.keybinds],
)
def get_status(self) -> str:
"""Gets the current status of this mod. Should be a single line."""
if Game.get_current() not in self.supported_games:
return "<font color='#ffff00'>Incompatible</font>"
if self.is_enabled:
return "<font color='#00ff00'>Enabled</font>"
return "<font color='#ff0000'>Disabled</font>"
@dataclass
class Library(Mod):
"""Helper subclass for libraries, which are always enabled."""
mod_type: Literal[ModType.Library] = ModType.Library # pyright: ignore[reportIncompatibleVariableOverride]
# Don't auto enable, since we're always enabled
auto_enable: Literal[False] = False # pyright: ignore[reportIncompatibleVariableOverride]
def __post_init__(self) -> None:
super().__post_init__()
# Enable if not already locked due to an incompatible game
if not self.enabling_locked:
self.enable()
# And then lock
self.enabling_locked = True
def get_status(self) -> str:
"""Gets the current status of this mod."""
if Game.get_current() not in self.supported_games:
return "<font color='#ffff00'>Incompatible</font>"
return "<font color='#00ff00'>Loaded</font>"
@dataclass
class RestartToDisable(Mod):
"""
Helper subclass for mods which cannot be fully disabled without restarting the game.
Mods will still run the normal enable/disable logic. However, after being disabled, the status
will show that the mod requires a restart.
"""
_ever_enabled: bool = field(default=False, init=False, repr=False)
def enable(self) -> None:
"""Called to enable the mod."""
super().enable()
# In case we weren't allowed to enable
if self.is_enabled:
self._ever_enabled = True
def get_status(self) -> str:
"""Gets the current status of this mod."""
if self._ever_enabled and not self.is_enabled:
return "<font color='#ff6060'>Disabling on Restart</font>"
return super().get_status()