Files
modorganizer-basic_games/games/game_oblivion_remaster.py

446 lines
18 KiB
Python

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",
"AltarESPLocal.esp", # Not actually shipped with the game files but present in plugins.txt.
]
def enabledPlugins(self) -> list[str]:
return [
"AltarESPMain.esp",
"AltarDeluxe.esp",
]
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