aboutsummaryrefslogtreecommitdiff
path: root/libs/basic_games/games/game_oblivion_remaster.py
blob: 218bb048fa79ccc3dd28a0c6099a2d72da9ad950 (plain)
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
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
import json
import os.path
import shutil
import winreg
from enum import IntEnum, auto
from pathlib import Path
from typing import cast

from PyQt6.QtCore import QDir, QFileInfo, QStandardPaths
from PyQt6.QtWidgets import QMainWindow, QTabWidget, QWidget

import mobase

from ..basic_features import BasicGameSaveGameInfo
from ..basic_game import BasicGame
from .oblivion_remaster.constants import DEFAULT_UE4SS_MODS, PLUGIN_NAME, UE4SSModInfo
from .oblivion_remaster.paks.widget import PaksTabWidget
from .oblivion_remaster.ue4ss.widget import UE4SSTabWidget


def getLootPath() -> Path | None:
    """
    Parse the LOOT path using either the modern InnoSetup registry entries (local vs. global installs) or the
    old registry path.
    """
    paths = [
        (
            winreg.HKEY_CURRENT_USER,
            "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{BF634210-A0D4-443F-A657-0DCE38040374}_is1",
            "InstallLocation",
        ),
        (
            winreg.HKEY_LOCAL_MACHINE,
            "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{BF634210-A0D4-443F-A657-0DCE38040374}_is1",
            "InstallLocation",
        ),
        (winreg.HKEY_LOCAL_MACHINE, "Software\\LOOT", "Installed Path"),
    ]

    for path in paths:
        try:
            with winreg.OpenKeyEx(
                path[0],
                path[1],
            ) as key:
                value = winreg.QueryValueEx(key, path[2])
                return Path((value[0] + "/LOOT.exe").replace("/", "\\"))
        except FileNotFoundError:
            pass
    return None


class Problems(IntEnum):
    """
    Enums for IPluginDiagnose.
    """

    # The 'dwmapi.dll' is present in the game EXE directory.
    UE4SS_LOADER = auto()
    # A UE4SS mod has a space in the directory name.
    INVALID_UE4SS_MOD_NAME = auto()


class OblivionRemasteredGame(
    BasicGame, mobase.IPluginFileMapper, mobase.IPluginDiagnose
):
    Name = PLUGIN_NAME
    Author = "Silarn"
    Version = "0.1.0-b.1"
    Description = "TES IV: Oblivion Remastered; an unholy hybrid of Bethesda and Unreal"

    GameName = "Oblivion Remastered"
    GameShortName = "oblivionremastered"
    GameNexusId = 7587
    GameSteamId = 2623190
    GameBinary = "OblivionRemastered.exe"
    GameDataPath = r"%GAME_PATH%\OblivionRemastered\Content\Dev\ObvData\Data"
    GameDocumentsDirectory = r"%GAME_PATH%\OblivionRemastered\Content\Dev\ObvData"
    UserHome = QStandardPaths.writableLocation(
        QStandardPaths.StandardLocation.HomeLocation
    )
    # Oblivion Remastered does not use the expanded Documents path but instead always uses the
    # base user directory path, even when this disagrees with Windows.
    MyDocumentsDirectory = rf"{UserHome}\Documents\My Games\{GameName}"
    GameSavesDirectory = rf"{MyDocumentsDirectory}\Saved\SaveGames"
    GameSaveExtension = "sav"
    GameSupportURL = (
        r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/"
        "Game:-Elder-Scrolls-IV:-Oblivion-Remastered"
    )

    _main_window: QMainWindow
    _ue4ss_tab: UE4SSTabWidget
    _paks_tab: PaksTabWidget

    def __init__(self):
        BasicGame.__init__(self)
        mobase.IPluginFileMapper.__init__(self)
        mobase.IPluginDiagnose.__init__(self)

    def init(self, organizer: mobase.IOrganizer) -> bool:
        from .oblivion_remaster.game_plugins import OblivionRemasteredGamePlugins
        from .oblivion_remaster.mod_data_checker import OblivionRemasteredModDataChecker
        from .oblivion_remaster.mod_data_content import OblivionRemasteredDataContent
        from .oblivion_remaster.script_extender import OblivionRemasteredScriptExtender

        super().init(organizer)
        self._register_feature(BasicGameSaveGameInfo())
        self._register_feature(OblivionRemasteredGamePlugins(self._organizer))
        self._register_feature(OblivionRemasteredModDataChecker(self._organizer))
        self._register_feature(OblivionRemasteredScriptExtender(self))
        self._register_feature(OblivionRemasteredDataContent())

        organizer.onUserInterfaceInitialized(self.init_tab)
        return True

    def init_tab(self, main_window: QMainWindow):
        """
        Initializes tabs unique to Oblivion Remastered.
        The "UE4SS Mods" tab and "Paks" tab.
        """
        if self._organizer.managedGame() != self:
            return

        self._main_window = main_window
        tab_widget: QTabWidget = main_window.findChild(QTabWidget, "tabWidget")
        if not tab_widget or not tab_widget.findChild(QWidget, "espTab"):
            return

        self._ue4ss_tab = UE4SSTabWidget(main_window, self._organizer)

        # Find the default "Plugins" tab
        plugin_tab = tab_widget.findChild(QWidget, "espTab")
        tab_index = tab_widget.indexOf(plugin_tab) + 1
        # The "Bethesda Plugins Manager" plugin hides the default Plugins tab and inserts itself after.
        # If the default tab is hidden, increment our position by one to account for it.
        if not tab_widget.isTabVisible(tab_widget.indexOf(plugin_tab)):
            tab_index += 1
        tab_widget.insertTab(tab_index, self._ue4ss_tab, "UE4SS Mods")

        self._paks_tab = PaksTabWidget(main_window, self._organizer)

        tab_index += 1
        tab_widget.insertTab(tab_index, self._paks_tab, "Paks")

    def settings(self) -> list[mobase.PluginSetting]:
        return [
            mobase.PluginSetting(
                "ue4ss_use_root_builder", "Use Root Builder paths for UE4SS mods", False
            )
        ]

    def executables(self):
        execs = [
            mobase.ExecutableInfo(
                "Oblivion Remastered",
                QFileInfo(
                    QDir(
                        self.gameDirectory().absoluteFilePath(
                            "OblivionRemastered/Binaries/Win64"
                        )
                    ),
                    "OblivionRemastered-Win64-Shipping.exe",
                ),
            )
        ]
        if extender := self._organizer.gameFeatures().gameFeature(
            mobase.ScriptExtender
        ):
            execs.append(
                mobase.ExecutableInfo("OBSE64", QFileInfo(extender.loaderPath()))  # type: ignore
            )
        if lootPath := getLootPath():
            execs.append(
                mobase.ExecutableInfo("LOOT", QFileInfo(str(lootPath))).withArgument(
                    '--game="Oblivion Remastered"'
                )
            )
        if magicLoaderPath := self.gameDirectory().absoluteFilePath(
            "MagicLoader/MagicLoader.exe"
        ):
            execs.append(
                mobase.ExecutableInfo("MagicLoader", QFileInfo(magicLoaderPath))
            )

        return execs

    def primaryPlugins(self) -> list[str]:
        return [
            "Oblivion.esm",
            "DLCBattlehornCastle.esp",
            "DLCFrostcrag.esp",
            "DLCHorseArmor.esp",
            "DLCMehrunesRazor.esp",
            "DLCOrrery.esp",
            "DLCShiveringIsles.esp",
            "DLCSpellTomes.esp",
            "DLCThievesDen.esp",
            "DLCVileLair.esp",
            "Knights.esp",
            "AltarESPMain.esp",
            "AltarDeluxe.esp",
            "AltarESPLocal.esp",  # Not actually shipped with the game files but present in plugins.txt.
        ]

    def modDataDirectory(self) -> str:
        return "Data"

    def moviesDirectory(self) -> QDir:
        return QDir(
            self.gameDirectory().absolutePath() + "/OblivionRemastered/Content/Movies"
        )

    def paksDirectory(self) -> QDir:
        return QDir(
            self.gameDirectory().absolutePath() + "/OblivionRemastered/Content/Paks"
        )

    def exeDirectory(self) -> QDir:
        return QDir(
            self.gameDirectory().absolutePath() + "/OblivionRemastered/Binaries/Win64"
        )

    def obseDirectory(self) -> QDir:
        return QDir(self.exeDirectory().absolutePath() + "/OBSE")

    def ue4ssDirectory(self) -> QDir:
        return QDir(self.exeDirectory().absolutePath() + "/ue4ss/Mods")

    def loadOrderMechanism(self) -> mobase.LoadOrderMechanism:
        return mobase.LoadOrderMechanism.PLUGINS_TXT

    def sortMechanism(self) -> mobase.SortMechanism:
        return mobase.SortMechanism.LOOT

    def initializeProfile(
        self, directory: QDir, settings: mobase.ProfileSetting
    ) -> None:
        if settings & mobase.ProfileSetting.CONFIGURATION:
            game_ini_file = self.gameDirectory().absoluteFilePath(
                r"OblivionRemastered\Content\Dev\ObvData\Oblivion.ini"
            )
            game_default_ini = self.gameDirectory().absoluteFilePath(
                r"OblivionRemastered\Content\Dev\ObvData\Oblivion_default.ini"
            )
            profile_ini = directory.absoluteFilePath(
                QFileInfo("Oblivion.ini").fileName()
            )
            if not os.path.exists(profile_ini):
                try:
                    shutil.copyfile(
                        game_ini_file,
                        profile_ini,
                    )
                except FileNotFoundError:
                    if os.path.exists(game_ini_file):
                        shutil.copyfile(
                            game_default_ini,
                            profile_ini,
                        )
                    else:
                        Path(profile_ini).touch()
        # Initialize a default UE4SS mods.ini and mods.json with the core mods included
        self.write_default_mods(directory)
        # Bootstrap common mod directories used by the USVFS map
        if (
            self._organizer.managedGame()
            and self._organizer.managedGame().gameName() == self.gameName()
        ):
            if not self.paksDirectory().exists():
                os.makedirs(self.paksDirectory().absolutePath())
            if not self.obseDirectory().exists():
                os.makedirs(self.obseDirectory().absolutePath())
            if not self.ue4ssDirectory().exists():
                os.makedirs(self.ue4ssDirectory().absolutePath())

    def write_default_mods(self, profile: QDir):
        """
        Writer for the default UE4SS 'mods.txt' and 'mods.json' profile files.
        """

        ue4ss_mods_txt = QFileInfo(profile.absoluteFilePath("mods.txt"))
        ue4ss_mods_json = QFileInfo(profile.absoluteFilePath("mods.json"))
        if not ue4ss_mods_txt.exists():
            with open(ue4ss_mods_txt.absoluteFilePath(), "w") as mods_txt:
                for mod in DEFAULT_UE4SS_MODS:
                    mods_txt.write(f"{mod['mod_name']} : 1\n")
        if not ue4ss_mods_json.exists():
            mods_data: list[UE4SSModInfo] = []
            for mod in DEFAULT_UE4SS_MODS:
                mods_data.append({"mod_name": mod["mod_name"], "mod_enabled": True})
            with open(ue4ss_mods_json.absoluteFilePath(), "w") as mods_json:
                mods_json.write(json.dumps(mods_data, indent=4))

    def iniFiles(self) -> list[str]:
        return ["Oblivion.ini"]

    def mappings(self) -> list[mobase.Mapping]:
        mappings: list[mobase.Mapping] = []
        for profile_file in ["plugins.txt", "loadorder.txt"]:
            mappings.append(
                mobase.Mapping(
                    self._organizer.profilePath() + "/" + profile_file,
                    self.dataDirectory().absolutePath() + "/" + profile_file,
                    False,
                )
            )
        for profile_file in ["mods.txt", "mods.json"]:
            mappings.append(
                mobase.Mapping(
                    self._organizer.profilePath() + "/" + profile_file,
                    self.ue4ssDirectory().absolutePath() + "/" + profile_file,
                    False,
                )
            )
        return mappings

    def getModMappings(self) -> dict[str, list[str]]:
        return {
            "Data": [self.dataDirectory().absolutePath()],
            "Paks": [self.paksDirectory().absolutePath()],
            "OBSE": [self.obseDirectory().absolutePath()],
            "Movies": [self.moviesDirectory().absolutePath()],
            "UE4SS": [self.ue4ssDirectory().absolutePath()],
            "GameSettings": [self.exeDirectory().absoluteFilePath("GameSettings")],
        }

    def activeProblems(self) -> list[int]:
        if self._organizer.managedGame() == self:
            problems: set[Problems] = set()
            # The dwmapi.dll loader should not be used with USVFS
            ue4ss_loader = QFileInfo(self.exeDirectory().absoluteFilePath("dwmapi.dll"))
            if ue4ss_loader.exists():
                problems.add(Problems.UE4SS_LOADER)
            # Leverage UE4SS mod tab to find mod names with spaces
            for mod in self._ue4ss_tab.get_mod_list():
                if " " in mod:
                    problems.add(Problems.INVALID_UE4SS_MOD_NAME)
                    break
            return list(problems)
        return []

    def fullDescription(self, key: int) -> str:
        match key:
            case Problems.UE4SS_LOADER:
                return (
                    "The UE4SS loader DLL is present (dwmapi.dll). This will not function out-of-the box with MO2's virtual filesystem.\n\n"
                    + "In order to resolve this, either delete the DLL and use the OBSE UE4SS Loader plugin, or rename "
                    + "the DLL (ex. 'ue4ss_loader.dll') and set it to force load with the game exe.\n\n"
                    + "Do this for any executable which runs the game, such as the OBSE64 loader."
                )
            case Problems.INVALID_UE4SS_MOD_NAME:
                return (
                    "UE4SS mods do not load properly with spaces in the mod name. These are stripped when parsing mods.txt and then"
                    "fail to match up when parsing the mods.json. Simply remove the spaces and they should load correctly."
                )
            case _:
                return ""

    def hasGuidedFix(self, key: int) -> bool:
        match key:
            case Problems.UE4SS_LOADER:
                return True
            case Problems.INVALID_UE4SS_MOD_NAME:
                return True
            case _:
                return False

    def shortDescription(self, key: int) -> str:
        match key:
            case Problems.UE4SS_LOADER:
                return "The UE4SS loader DLL is present (dwmapi.dll)."
            case Problems.INVALID_UE4SS_MOD_NAME:
                return "A UE4SS mod name contains a space."
            case _:
                return ""

    def startGuidedFix(self, key: int) -> None:
        match key:
            case Problems.UE4SS_LOADER:
                os.rename(
                    self.exeDirectory().absoluteFilePath("dwmapi.dll"),
                    self.exeDirectory().absoluteFilePath("ue4ss_loader.dll"),
                )
            case Problems.INVALID_UE4SS_MOD_NAME:
                for mod in self._organizer.modList().allMods():
                    filetree = self._organizer.modList().getMod(mod).fileTree()
                    ue4ss_mod = filetree.find("UE4SS")
                    if not ue4ss_mod:
                        filetree.find(
                            "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods"
                        )
                    if isinstance(ue4ss_mod, mobase.IFileTree):
                        for entry in ue4ss_mod:
                            if isinstance(entry, mobase.IFileTree) and entry.find(
                                "scripts/main.lua"
                            ):
                                if " " in entry.name():
                                    mod_dir = Path(
                                        self._organizer.modList()
                                        .getMod(mod)
                                        .absolutePath()
                                    )
                                    mod_path = mod_dir.joinpath(entry.path("/"))
                                    fixed_path = mod_dir.joinpath(
                                        cast(mobase.IFileTree, entry.parent()).path(
                                            "/"
                                        ),
                                        entry.name().replace(" ", ""),
                                    )
                                    try:
                                        mod_path.rename(fixed_path)
                                        self._organizer.modDataChanged(
                                            self._organizer.modList().getMod(mod)
                                        )
                                        self._ue4ss_tab.update_mod_files(mod)
                                    except FileExistsError:
                                        pass
                                    except FileNotFoundError:
                                        pass
                for entry in self.ue4ssDirectory().entryInfoList(
                    QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot
                ):
                    entry_dir = QDir(entry.absoluteFilePath())
                    if QFileInfo(
                        entry_dir.absoluteFilePath("scripts/main.lua")
                    ).exists():
                        if " " in entry_dir.dirName():
                            dest = (
                                entry_dir.absoluteFilePath("..")
                                + "/"
                                + entry_dir.dirName().replace(" ", "")
                            )
                            try:
                                os.rename(entry_dir.absolutePath(), dest)
                            except FileExistsError:
                                pass
                            except FileNotFoundError:
                                pass
            case _:
                pass