mirror of
https://github.com/ModOrganizer2/modorganizer-basic_games.git
synced 2026-07-27 14:07:29 -07:00
446 lines
18 KiB
Python
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
|