From 15789ca9559cf44600b1048d2a2003913921c687 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Thu, 24 Apr 2025 17:00:14 -0500 Subject: [PATCH 01/32] WIP: Basic oblivion remaster support --- games/game_oblivion_remaster.py | 349 ++++++++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 games/game_oblivion_remaster.py diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py new file mode 100644 index 00000000..484be236 --- /dev/null +++ b/games/game_oblivion_remaster.py @@ -0,0 +1,349 @@ +from functools import cmp_to_key +from pathlib import Path +from typing import Dict, Sequence + +import PyQt6.QtCore +import mobase +from PyQt6.QtCore import QByteArray, QDir, QFileInfo, QFile, QDateTime, QCoreApplication, QStandardPaths, \ + QStringEncoder, QStringConverter, qCritical, qDebug + +from ..basic_features import BasicLocalSavegames, BasicGameSaveGameInfo, BasicModDataChecker, GlobPatterns +from ..basic_features.utils import is_directory +from ..basic_game import BasicGame + +class OblivionRemasteredModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + + def dataLooksValid(self, filetree: mobase.IFileTree) -> mobase.ModDataChecker.CheckReturn: + status = mobase.ModDataChecker.INVALID + dirs = [ + 'meshes', + 'textures', + 'music', + 'scripts', + 'fonts', + 'interface', + 'shaders', + 'strings', + 'materials' + ] + extensions = [ + '.esm', + '.esp', + '.bsa', + '.ini' + ] + if filetree.parent() is None: + paks = filetree.find(r'OblivionRemastered\Content\Paks\~mods', mobase.FileTreeEntry.FileTypes.DIRECTORY) + if paks is not None: + return mobase.ModDataChecker.FIXABLE + data = filetree.find(r'OblivionRemastered\Content\Dev\ObvData\Data', mobase.FileTreeEntry.FileTypes.DIRECTORY) + if data is not None: + return mobase.ModDataChecker.FIXABLE + for entry in filetree: + name = entry.name().casefold() + + if entry.parent().parent() is None: + if is_directory(entry): + if name in dirs: + status = mobase.ModDataChecker.VALID + break + else: + for sub_entry in entry: + if not is_directory(sub_entry): + sub_name = sub_entry.name().casefold() + if sub_name.endswith('.pak'): + status = mobase.ModDataChecker.VALID + break + elif sub_name.endswith(tuple(extensions)): + status = mobase.ModDataChecker.FIXABLE + else: + new_status = self.dataLooksValid(entry) + if new_status != mobase.ModDataChecker.INVALID: + status = new_status + if status == mobase.ModDataChecker.VALID: + break + else: + if name.endswith(tuple(extensions + ['.pak'])): + status = mobase.ModDataChecker.VALID + break + + else: + if is_directory(entry): + new_status = self.dataLooksValid(entry) + if new_status != mobase.ModDataChecker.INVALID: + status = new_status + else: + if name.endswith(tuple(extensions + ['.pak'])): + status = mobase.ModDataChecker.FIXABLE + + return status + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + paks = filetree.find(r'OblivionRemastered\Content\Paks\~mods', mobase.FileTreeEntry.FileTypes.DIRECTORY) + if paks is not None: + filetree.merge(paks) + filetree.find('OblivionRemastered').detach() + data = filetree.find(r'OblivionRemastered\Content\Dev\ObvData\Data', mobase.FileTreeEntry.FileTypes.DIRECTORY) + if data is not None: + filetree.merge(data) + filetree.find('OblivionRemastered').detach() + for entry in filetree: + if is_directory(entry): + filetree = self.parse_directory(filetree, entry) + + return filetree + + def parse_directory(self, main_filetree: mobase.IFileTree, next_dir: mobase.IFileTree) -> mobase.IFileTree: + extensions = [ + '.esm', + '.esp', + '.bsa', + '.ini' + ] + for entry in next_dir: + name = entry.name().casefold() + if is_directory(entry): + self.parse_directory(main_filetree, entry) + else: + if name.endswith(tuple(extensions)): + main_filetree.merge(next_dir) + next_dir.detach() + elif name.endswith('.pak'): + main_filetree.move(next_dir, '/') + + return main_filetree + + +class OblivionRemasteredGamePlugins(mobase.GamePlugins): + def __init__(self, organizer: mobase.IOrganizer): + super().__init__() + self._last_read = QDateTime().currentDateTime() + self._organizer = organizer + # What are these for? + self._plugin_blacklist = ['TamrielLevelledRegion.esp', 'AltarGymNavigation.esp'] + + def writePluginLists(self, plugin_list: mobase.IPluginList) -> None: + if not self._last_read.isValid(): + return + self.writePluginList(plugin_list, self._organizer.profile().absolutePath() + "/plugins.txt") + self.writeLoadOrderList(plugin_list, self._organizer.profile().absolutePath() + "/loadorder.txt") + self._last_read = QDateTime.currentDateTime() + + def readPluginLists(self, plugin_list: mobase.IPluginList) -> None: + load_order_path = self._organizer.profile().absolutePath() + "/loadorder.txt" + load_order = self.readLoadOrderList(plugin_list, load_order_path) + plugin_list.setLoadOrder(load_order) + self.readPluginList(plugin_list) + self._last_read = QDateTime.currentDateTime() + + def getLoadOrder(self) -> Sequence[str]: + load_order_path = self._organizer.profile().absolutePath() + "/loadorder.txt" + plugins_path = self._organizer.profile().absolutePath() + "/plugins.txt" + + load_order_is_new = (not self._last_read.isValid() or not QFileInfo(load_order_path).exists() + or QFileInfo(load_order_path).lastModified() > self._last_read) + plugins_is_new = not self._last_read.isValid() or QFileInfo(plugins_path).lastModified() > self._last_read + + if load_order_is_new or not plugins_is_new: + return self.readLoadOrderList(self._organizer.pluginList(), load_order_path) + else: + return self.readPluginList(self._organizer.pluginList()) + + def writePluginList(self, plugin_list: mobase.IPluginList, filePath: str): + self.writeList(plugin_list, filePath, False) + + def writeLoadOrderList(self, plugin_list: mobase.IPluginList, filePath: str): + self.writeList(plugin_list, filePath, True) + + def writeList(self, plugin_list: mobase.IPluginList, filePath: str, load_order: bool): + plugins_file = open(filePath, 'w') + encoder = QStringEncoder(QStringConverter.Encoding.Utf8) if load_order else QStringEncoder(QStringConverter.Encoding.System) + plugins_text = '# This file was automatically generated by Mod Organizer.\n' + invalid_filenames = False + written_count = 0 + plugins = plugin_list.pluginNames() + plugins_sorted = sorted(plugins, key=cmp_to_key(lambda lhs, rhs: plugin_list.priority(lhs) - plugin_list.priority(rhs))) + for plugin_name in plugins_sorted: + if load_order or plugin_list.state(plugin_name) == mobase.PluginState.ACTIVE: + result = encoder.encode(plugin_name) + if encoder.hasError(): + invalid_filenames = True + qCritical('invalid plugin name %s', plugin_name) + plugins_text += result.data().decode() + '\n' + written_count += 1 + + if invalid_filenames: + PyQt6.QtCore.qCritical(QCoreApplication.translate("MainWindow", + "Some of your plugins have invalid names! These " + + "plugins can not be loaded by the game. Please see " + + "mo_interface.log for a list of affected plugins " + + "and rename them.")) + + if written_count == 0: + PyQt6.QtCore.qWarning("plugin list would be empty, this is almost certainly wrong. Not saving.") + else: + plugins_file.write(plugins_text) + plugins_file.close() + + def readLoadOrderList(self, plugin_list: mobase.IPluginList, file_path: str) -> list[str]: + plugin_names = [plugin for plugin in self._organizer.managedGame().primaryPlugins()] + plugin_lookup = [] + for name in plugin_names: + if name.lower() not in plugin_lookup: + plugin_lookup.append(name.lower()) + + try: + with open(file_path, "r") as file: + for line in file: + if line.lower() not in plugin_lookup: + plugin_lookup.append(line.lower()) + plugin_names.append(line) + except FileNotFoundError: + pass + + return plugin_names + + def readPluginList(self, plugin_list: mobase.IPluginList) -> list[str]: + plugins = [plugin for plugin in plugin_list.pluginNames()] + primary = [plugin for plugin in self._organizer.managedGame().primaryPlugins()] + primary_lower = [plugin.lower() for plugin in primary] + load_order = plugins.copy() + for plugin_name in primary: + if plugin_list.state(plugin_name) != mobase.PluginState.MISSING: + plugin_list.setState(plugin_name, mobase.PluginState.ACTIVE) + plugin_remove = [plugin for plugin in plugins if plugin.lower() in primary_lower] + for plugin in plugin_remove: + plugins.remove(plugin) + + plugins_txt_exists = True + file_path = self._organizer.profile().absolutePath() + "/plugins.txt" + file = QFile(file_path) + if not file.open(QFile.OpenModeFlag.ReadOnly): + plugins_txt_exists = False + if file.size() == 0: + plugins_txt_exists = False + if plugins_txt_exists: + while not file.atEnd(): + line = file.readLine() + file_plugin_name = QByteArray() + if line.size() > 0 and line.at(0).decode() != '#': + encoder = QStringEncoder(QStringEncoder.Encoding.System) + file_plugin_name = encoder.encode(line.trimmed().data().decode()) + if file_plugin_name.size() > 0: + plugin_list.setState(file_plugin_name.data().decode(), mobase.PluginState.ACTIVE) + if file_plugin_name.data().decode() in plugins: + plugins.remove(file_plugin_name.data().decode()) + + file.close() + + for plugin_name in plugins: + plugin_list.setState(plugin_name, mobase.PluginState.INACTIVE) + else: + for plugin_name in plugins: + plugin_list.setState(plugin_name, mobase.PluginState.INACTIVE) + + return load_order + + def lightPluginsAreSupported(self) -> bool: + return False + + def mediumPluginsAreSupported(self) -> bool: + return False + + def blueprintPluginsAreSupported(self) -> bool: + return False + + +class OblivionRemasteredGame(BasicGame, mobase.IPluginFileMapper): + Name = "Oblivion Remake Support Plugin" + Author = "Silarn" + Version = "0.0.0-a.1" + + GameName = "Oblivion Remastered" + GameShortName = "oblivionremastered" + GameNexusId = 7587 + GameSteamId = 2623190 + #GameGogId = 2049187585 + GameBinary = "OblivionRemastered.exe" + GameDataPath = r"%GAME_PATH%\OblivionRemastered\Content\Dev\ObvData\Data" + UserHome = QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.HomeLocation + ) + GameSavesDirectory = r"{}\Documents\My Games\Oblivion Remastered\Saved\SaveGames".format(UserHome) + GameSaveExtension = "sav" + + def __init__(self): + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(BasicLocalSavegames(self.savesDirectory())) + self._register_feature(BasicGameSaveGameInfo()) + self._register_feature(OblivionRemasteredGamePlugins(self._organizer)) + self._register_feature(OblivionRemasteredModDataChecker()) + return True + + def executables(self): + return [ + mobase.ExecutableInfo( + "Oblivion Remastered", + QFileInfo( + self.gameDirectory(), + self.binaryName(), + ), + ), + ] + + def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: + try: + efls = super().executableForcedLoads() + except AttributeError: + efls = [] + + libraries = ["xinput1_3.dll"] + exePath = (Path(self.gameDirectory().absolutePath()) / 'OblivionRemastered' / 'Binaries' / + 'Win64' / 'OblivionRemastered-Win64-Shipping.exe') + efls.extend( + mobase.ExecutableForcedLoadSetting( + str(exePath.absolute()), lib + ).withEnabled(True) + for lib in libraries + ) + return efls + + 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' + ] + + def secondaryDataDirectories(self) -> Dict[str, QDir]: + return { + 'pak_data': QDir(self.gameDirectory().absolutePath() + '/OblivionRemastered/Content/Paks/~mods') + } + + def loadOrderMechanism(self) -> mobase.LoadOrderMechanism: + return mobase.LoadOrderMechanism.PLUGINS_TXT + + 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)) + return mappings From 5d9a778f43753fb0fc7f75831a5167c866f7c395 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Fri, 25 Apr 2025 22:05:26 -0500 Subject: [PATCH 02/32] Numerous updates - Fix load order reader - Add OBSE64 detector - Make ~mods dir if missing - Set DLC and force-enabled plugins --- games/game_oblivion_remaster.py | 114 +++++++++++++++++++++++--------- 1 file changed, 82 insertions(+), 32 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 484be236..574b59fb 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -1,3 +1,4 @@ +import os.path from functools import cmp_to_key from pathlib import Path from typing import Dict, Sequence @@ -189,30 +190,34 @@ def writeList(self, plugin_list: mobase.IPluginList, filePath: str, load_order: def readLoadOrderList(self, plugin_list: mobase.IPluginList, file_path: str) -> list[str]: plugin_names = [plugin for plugin in self._organizer.managedGame().primaryPlugins()] - plugin_lookup = [] + plugin_lookup = set() for name in plugin_names: if name.lower() not in plugin_lookup: - plugin_lookup.append(name.lower()) + plugin_lookup.add(name.lower()) try: - with open(file_path, "r") as file: + with open(file_path) as file: for line in file: - if line.lower() not in plugin_lookup: - plugin_lookup.append(line.lower()) - plugin_names.append(line) + if line.startswith('#'): + continue + plugin_file = line.rstrip('\n') + if plugin_file.lower() not in plugin_lookup: + plugin_lookup.add(plugin_file.lower()) + plugin_names.append(plugin_file) except FileNotFoundError: - pass + return self.readPluginList(plugin_list) return plugin_names def readPluginList(self, plugin_list: mobase.IPluginList) -> list[str]: plugins = [plugin for plugin in plugin_list.pluginNames()] + sorted_plugins = [] primary = [plugin for plugin in self._organizer.managedGame().primaryPlugins()] primary_lower = [plugin.lower() for plugin in primary] - load_order = plugins.copy() for plugin_name in primary: if plugin_list.state(plugin_name) != mobase.PluginState.MISSING: plugin_list.setState(plugin_name, mobase.PluginState.ACTIVE) + sorted_plugins.append(plugin_name) plugin_remove = [plugin for plugin in plugins if plugin.lower() in primary_lower] for plugin in plugin_remove: plugins.remove(plugin) @@ -232,8 +237,9 @@ def readPluginList(self, plugin_list: mobase.IPluginList) -> list[str]: encoder = QStringEncoder(QStringEncoder.Encoding.System) file_plugin_name = encoder.encode(line.trimmed().data().decode()) if file_plugin_name.size() > 0: - plugin_list.setState(file_plugin_name.data().decode(), mobase.PluginState.ACTIVE) - if file_plugin_name.data().decode() in plugins: + if file_plugin_name.data().decode().lower() in [plugin.lower() for plugin in plugins]: + plugin_list.setState(file_plugin_name.data().decode(), mobase.PluginState.ACTIVE) + sorted_plugins.append(file_plugin_name.data().decode()) plugins.remove(file_plugin_name.data().decode()) file.close() @@ -244,7 +250,7 @@ def readPluginList(self, plugin_list: mobase.IPluginList) -> list[str]: for plugin_name in plugins: plugin_list.setState(plugin_name, mobase.PluginState.INACTIVE) - return load_order + return sorted_plugins + plugins def lightPluginsAreSupported(self) -> bool: return False @@ -256,16 +262,47 @@ def blueprintPluginsAreSupported(self) -> bool: return False +class OblivionRemasteredScriptExtender(mobase.ScriptExtender): + def __init__(self, game: mobase.IPluginGame): + super().__init__() + self._game = game + + def binaryName(self): + return 'obse64_loader.exe' + + def loaderName(self) -> str: + return self.binaryName() + + def loaderPath(self) -> str: + return self._game.gameDirectory().absolutePath() + '\\OblivionRemastered\\Binaries\\Win64\\' + self.loaderName() + + def pluginPath(self) -> str: + return 'OBSE/Plugins' + + def savegameExtension(self) -> str: + return '' + + def isInstalled(self) -> bool: + return os.path.exists(self.loaderPath()) + + def getExtenderVersion(self) -> str: + return mobase.getFileVersion(self.loaderPath()) + + def getArch(self) -> int: + return 0x8664 if self.isInstalled() else 0x0 + + + class OblivionRemasteredGame(BasicGame, mobase.IPluginFileMapper): - Name = "Oblivion Remake Support Plugin" + Name = "Oblivion Remastered Support Plugin" Author = "Silarn" - Version = "0.0.0-a.1" + Version = "0.1.0-b.1" + Description = "TES IV: Oblivion Remastered; an unholy hybrid of Gamebryo and Unreal" GameName = "Oblivion Remastered" GameShortName = "oblivionremastered" GameNexusId = 7587 GameSteamId = 2623190 - #GameGogId = 2049187585 GameBinary = "OblivionRemastered.exe" GameDataPath = r"%GAME_PATH%\OblivionRemastered\Content\Dev\ObvData\Data" UserHome = QStandardPaths.writableLocation( @@ -284,9 +321,15 @@ def init(self, organizer: mobase.IOrganizer) -> bool: self._register_feature(BasicGameSaveGameInfo()) self._register_feature(OblivionRemasteredGamePlugins(self._organizer)) self._register_feature(OblivionRemasteredModDataChecker()) + self._register_feature(OblivionRemasteredScriptExtender(self)) + self.detectGame() + paks_dir = QDir(self.gameDirectory().absolutePath() + r'\OblivionRemastered\Content\Paks') + if paks_dir.exists() and not paks_dir.exists('~mods'): + paks_dir.mkdir('~mods') return True def executables(self): + qDebug(self._organizer.gameFeatures().gameFeature(mobase.ScriptExtender).loaderPath()) return [ mobase.ExecutableInfo( "Oblivion Remastered", @@ -295,28 +338,21 @@ def executables(self): self.binaryName(), ), ), + mobase.ExecutableInfo( + "OBSE64", + QFileInfo( + self._organizer.gameFeatures().gameFeature(mobase.ScriptExtender).loaderPath() + ) + ) ] - def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: - try: - efls = super().executableForcedLoads() - except AttributeError: - efls = [] - - libraries = ["xinput1_3.dll"] - exePath = (Path(self.gameDirectory().absolutePath()) / 'OblivionRemastered' / 'Binaries' / - 'Win64' / 'OblivionRemastered-Win64-Shipping.exe') - efls.extend( - mobase.ExecutableForcedLoadSetting( - str(exePath.absolute()), lib - ).withEnabled(True) - for lib in libraries - ) - return efls - def primaryPlugins(self) -> list[str]: return [ - 'Oblivion.esm', + 'Oblivion.esm' + ] + + def enabledPlugins(self) -> list[str]: + return [ 'DLCBattlehornCastle.esp', 'DLCFrostcrag.esp', 'DLCHorseArmor.esp', @@ -332,6 +368,20 @@ def primaryPlugins(self) -> list[str]: 'AltarESPLocal.esp' ] + def DLCPlugins(self) -> list[str]: + return [ + 'DLCBattlehornCastle.esp', + 'DLCFrostcrag.esp', + 'DLCHorseArmor.esp', + 'DLCMehrunesRazor.esp', + 'DLCOrrery.esp', + 'DLCShiveringIsles.esp', + 'DLCSpellTomes.esp', + 'DLCThievesDen.esp', + 'DLCVileLair.esp', + 'Knights.esp' + ] + def secondaryDataDirectories(self) -> Dict[str, QDir]: return { 'pak_data': QDir(self.gameDirectory().absolutePath() + '/OblivionRemastered/Content/Paks/~mods') From 35d08fb33e174b826eefe972b91dda89a5f24dc7 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Sat, 26 Apr 2025 00:37:19 -0500 Subject: [PATCH 03/32] More file map updates --- games/game_oblivion_remaster.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 574b59fb..21c24003 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -1,6 +1,5 @@ import os.path from functools import cmp_to_key -from pathlib import Path from typing import Dict, Sequence import PyQt6.QtCore @@ -8,7 +7,7 @@ from PyQt6.QtCore import QByteArray, QDir, QFileInfo, QFile, QDateTime, QCoreApplication, QStandardPaths, \ QStringEncoder, QStringConverter, qCritical, qDebug -from ..basic_features import BasicLocalSavegames, BasicGameSaveGameInfo, BasicModDataChecker, GlobPatterns +from ..basic_features import BasicLocalSavegames, BasicGameSaveGameInfo from ..basic_features.utils import is_directory from ..basic_game import BasicGame @@ -60,6 +59,9 @@ def dataLooksValid(self, filetree: mobase.IFileTree) -> mobase.ModDataChecker.Ch elif sub_name.endswith(tuple(extensions)): status = mobase.ModDataChecker.FIXABLE else: + if name == 'Paks': + status = mobase.ModDataChecker.FIXABLE + break new_status = self.dataLooksValid(entry) if new_status != mobase.ModDataChecker.INVALID: status = new_status @@ -106,13 +108,28 @@ def parse_directory(self, main_filetree: mobase.IFileTree, next_dir: mobase.IFil for entry in next_dir: name = entry.name().casefold() if is_directory(entry): + if name == '~mods': + main_filetree.merge(entry) + entry.detach() + continue self.parse_directory(main_filetree, entry) else: if name.endswith(tuple(extensions)): main_filetree.merge(next_dir) - next_dir.detach() + parent = next_dir.parent() if next_dir.parent().parent() is not None else next_dir + while parent.parent().parent() is not None: + parent = parent.parent() + parent.detach() elif name.endswith('.pak'): - main_filetree.move(next_dir, '/') + if entry.parent().name().casefold() == 'Paks': + main_filetree.merge(entry.parent()) + parent = entry.parent() if entry.parent().parent() is not None else entry + while parent.parent().parent() is not None: + parent = parent.parent() + parent.detach() + return main_filetree + else: + main_filetree.move(next_dir, '/') return main_filetree From 78ffffd97ce8e23150f7a1a5d8aca6ed3984182d Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Sat, 26 Apr 2025 23:58:39 -0500 Subject: [PATCH 04/32] Many changes - Revamped to use new mod directory mapping - Updated file validation code - INI support (including profile INIs) - Removed profile save support (which didn't work) --- games/game_oblivion_remaster.py | 279 ++++++++++++++++++++++---------- 1 file changed, 191 insertions(+), 88 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 21c24003..52efde1a 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -1,6 +1,8 @@ import os.path +import shutil from functools import cmp_to_key -from typing import Dict, Sequence +from pathlib import Path +from typing import Sequence import PyQt6.QtCore import mobase @@ -12,127 +14,189 @@ from ..basic_game import BasicGame class OblivionRemasteredModDataChecker(mobase.ModDataChecker): + _dirs = [ + 'Data', + 'Paks', + 'OBSE' + ] + _data_dirs = [ + 'meshes', + 'textures', + 'music', + 'scripts', + 'fonts', + 'interface', + 'shaders', + 'strings', + 'materials' + ] + _extensions = [ + '.esm', + '.esp', + '.bsa', + '.ini', + '.dll' + ] def __init__(self): super().__init__() def dataLooksValid(self, filetree: mobase.IFileTree) -> mobase.ModDataChecker.CheckReturn: status = mobase.ModDataChecker.INVALID - dirs = [ - 'meshes', - 'textures', - 'music', - 'scripts', - 'fonts', - 'interface', - 'shaders', - 'strings', - 'materials' - ] - extensions = [ - '.esm', - '.esp', - '.bsa', - '.ini' - ] - if filetree.parent() is None: - paks = filetree.find(r'OblivionRemastered\Content\Paks\~mods', mobase.FileTreeEntry.FileTypes.DIRECTORY) - if paks is not None: - return mobase.ModDataChecker.FIXABLE - data = filetree.find(r'OblivionRemastered\Content\Dev\ObvData\Data', mobase.FileTreeEntry.FileTypes.DIRECTORY) - if data is not None: - return mobase.ModDataChecker.FIXABLE for entry in filetree: name = entry.name().casefold() - if entry.parent().parent() is None: if is_directory(entry): - if name in dirs: - status = mobase.ModDataChecker.VALID - break + if name in [dirname.lower() for dirname in self._dirs]: + if name == 'obse': + status = mobase.ModDataChecker.VALID + break + for sub_entry in entry: + if not is_directory(sub_entry): + sub_name = sub_entry.name().casefold() + if name == 'paks': + if sub_name.endswith('.pak'): + status = mobase.ModDataChecker.VALID + break + if name == 'data': + if sub_name.endswith(tuple(self._extensions)): + status = mobase.ModDataChecker.VALID + break + else: + if name == 'paks': + for paks_entry in sub_entry: + if not is_directory(paks_entry): + paks_name = paks_entry.name().casefold() + if paks_name.endswith('.pak'): + status = mobase.ModDataChecker.VALID + break + if status == mobase.ModDataChecker.VALID: + break else: for sub_entry in entry: if not is_directory(sub_entry): sub_name = sub_entry.name().casefold() if sub_name.endswith('.pak'): - status = mobase.ModDataChecker.VALID - break - elif sub_name.endswith(tuple(extensions)): + status = mobase.ModDataChecker.FIXABLE + elif sub_name.endswith(tuple(self._extensions)): status = mobase.ModDataChecker.FIXABLE else: if name == 'Paks': status = mobase.ModDataChecker.FIXABLE - break - new_status = self.dataLooksValid(entry) - if new_status != mobase.ModDataChecker.INVALID: - status = new_status + new_status = self.dataLooksValid(entry) + if new_status != mobase.ModDataChecker.INVALID: + status = new_status if status == mobase.ModDataChecker.VALID: break else: - if name.endswith(tuple(extensions + ['.pak'])): - status = mobase.ModDataChecker.VALID - break - + if name.endswith(tuple(self._extensions + ['.pak'])): + status = mobase.ModDataChecker.FIXABLE else: if is_directory(entry): - new_status = self.dataLooksValid(entry) - if new_status != mobase.ModDataChecker.INVALID: - status = new_status + if name in [dir_name.lower() for dir_name in self._dirs]: + status = mobase.ModDataChecker.FIXABLE + if name in [dir_name.lower() for dir_name in self._data_dirs]: + status = mobase.ModDataChecker.FIXABLE + else: + new_status = self.dataLooksValid(entry) + if new_status != mobase.ModDataChecker.INVALID: + status = new_status else: - if name.endswith(tuple(extensions + ['.pak'])): + if name.endswith(tuple(self._extensions + ['.pak'])): status = mobase.ModDataChecker.FIXABLE - + if status == mobase.ModDataChecker.VALID: + break return status def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: - paks = filetree.find(r'OblivionRemastered\Content\Paks\~mods', mobase.FileTreeEntry.FileTypes.DIRECTORY) - if paks is not None: - filetree.merge(paks) - filetree.find('OblivionRemastered').detach() - data = filetree.find(r'OblivionRemastered\Content\Dev\ObvData\Data', mobase.FileTreeEntry.FileTypes.DIRECTORY) - if data is not None: - filetree.merge(data) - filetree.find('OblivionRemastered').detach() for entry in filetree: - if is_directory(entry): - filetree = self.parse_directory(filetree, entry) - + if entry is not None: + if is_directory(entry): + if entry.name().casefold() in [dirname.lower() for dirname in self._data_dirs]: + data_dir = filetree.find('Data') + if data_dir is None: + data_dir = filetree.addDirectory('Data') + entry.moveTo(data_dir) + elif entry.name().casefold() not in [dirname.lower() for dirname in self._dirs]: + filetree = self.parse_directory(filetree, entry) + else: + name = entry.name().casefold() + if name.endswith('.pak'): + paks_dir = filetree.find('Paks') + if paks_dir is None: + paks_dir = filetree.addDirectory('Paks') + pak_files: list[mobase.FileTreeEntry] = [] + for file in entry.parent(): + if file is not None: + if not is_directory(file): + if file.name().casefold().endswith(('.pak', '.ucas', '.utoc')): + pak_files.append(file) + for pak_file in pak_files: + pak_file.moveTo(paks_dir) + elif name.endswith(tuple(self._extensions)): + data_dir = filetree.find('Data') + if data_dir is None: + data_dir = filetree.addDirectory('Data') + data_files: list[mobase.FileTreeEntry] = [] + for file in entry.parent(): + data_files.append(file) + for data_file in data_files: + data_file.moveTo(data_dir) return filetree def parse_directory(self, main_filetree: mobase.IFileTree, next_dir: mobase.IFileTree) -> mobase.IFileTree: - extensions = [ - '.esm', - '.esp', - '.bsa', - '.ini' - ] for entry in next_dir: name = entry.name().casefold() if is_directory(entry): + for dir_name in self._dirs: + if name == dir_name.lower(): + main_dir = main_filetree.find(dir_name) + if main_dir is None: + main_dir = main_filetree.addDirectory(dir_name) + main_dir.merge(entry) + self.detach_parents(entry) + return main_filetree if name == '~mods': - main_filetree.merge(entry) - entry.detach() + paks_dir = main_filetree.find('Paks') + if paks_dir is None: + paks_dir = main_filetree.addDirectory('Paks') + paks_dir.merge(entry) + self.detach_parents(entry) + continue + elif name in [dirname.lower() for dirname in self._data_dirs]: + data_dir = main_filetree.find('Data') + if data_dir is None: + data_dir = main_filetree.addDirectory('Data') + data_dir.merge(entry) + self.detach_parents(entry) continue - self.parse_directory(main_filetree, entry) + return self.parse_directory(main_filetree, entry) else: - if name.endswith(tuple(extensions)): - main_filetree.merge(next_dir) - parent = next_dir.parent() if next_dir.parent().parent() is not None else next_dir - while parent.parent().parent() is not None: - parent = parent.parent() - parent.detach() + if name.endswith(tuple(self._extensions)): + data_dir = main_filetree.find('Data') + if data_dir is None: + data_dir = main_filetree.addDirectory('Data') + data_dir.merge(next_dir) + self.detach_parents(next_dir) elif name.endswith('.pak'): - if entry.parent().name().casefold() == 'Paks': - main_filetree.merge(entry.parent()) - parent = entry.parent() if entry.parent().parent() is not None else entry - while parent.parent().parent() is not None: - parent = parent.parent() - parent.detach() + paks_dir = main_filetree.find('Paks') + if paks_dir is None: + paks_dir = main_filetree.addDirectory('Paks') + if next_dir.name().casefold() == 'paks': + paks_dir.merge(next_dir) + self.detach_parents(next_dir) return main_filetree else: - main_filetree.move(next_dir, '/') + main_filetree.move(next_dir, 'Paks/') + return main_filetree return main_filetree + def detach_parents(self, directory: mobase.IFileTree) -> None: + parent = directory.parent() if directory.parent().parent() is not None else directory + while parent.parent().parent() is not None: + parent = parent.parent() + parent.detach() + class OblivionRemasteredGamePlugins(mobase.GamePlugins): def __init__(self, organizer: mobase.IOrganizer): @@ -322,10 +386,12 @@ class OblivionRemasteredGame(BasicGame, mobase.IPluginFileMapper): 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 ) - GameSavesDirectory = r"{}\Documents\My Games\Oblivion Remastered\Saved\SaveGames".format(UserHome) + MyDocumentsDirectory = rf'{UserHome}\Documents\My Games\{GameName}' + GameSavesDirectory = rf'{MyDocumentsDirectory}\Saved\SaveGames' GameSaveExtension = "sav" def __init__(self): @@ -334,19 +400,18 @@ def __init__(self): def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) - self._register_feature(BasicLocalSavegames(self.savesDirectory())) self._register_feature(BasicGameSaveGameInfo()) self._register_feature(OblivionRemasteredGamePlugins(self._organizer)) self._register_feature(OblivionRemasteredModDataChecker()) self._register_feature(OblivionRemasteredScriptExtender(self)) self.detectGame() - paks_dir = QDir(self.gameDirectory().absolutePath() + r'\OblivionRemastered\Content\Paks') - if paks_dir.exists() and not paks_dir.exists('~mods'): - paks_dir.mkdir('~mods') + if self.paksDirectory().exists() and not self.paksDirectory().exists('~mods'): + self.paksDirectory().mkdir('~mods') + if not self.obseDirectory().exists(): + os.mkdir(self.obseDirectory().absolutePath()) return True def executables(self): - qDebug(self._organizer.gameFeatures().gameFeature(mobase.ScriptExtender).loaderPath()) return [ mobase.ExecutableInfo( "Oblivion Remastered", @@ -399,14 +464,47 @@ def DLCPlugins(self) -> list[str]: 'Knights.esp' ] - def secondaryDataDirectories(self) -> Dict[str, QDir]: - return { - 'pak_data': QDir(self.gameDirectory().absolutePath() + '/OblivionRemastered/Content/Paks/~mods') - } + def modDataDirectory(self) -> str: + return 'Data' + + def paksDirectory(self) -> QDir: + return QDir(self.gameDirectory().absolutePath() + '/OblivionRemastered/Content/Paks/~mods') + + def obseDirectory(self) -> QDir: + return QDir(self.gameDirectory().absolutePath() + '/OblivionRemastered/Binaries/Win64/OBSE') def loadOrderMechanism(self) -> mobase.LoadOrderMechanism: return mobase.LoadOrderMechanism.PLUGINS_TXT + 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() + + 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']: @@ -414,3 +512,8 @@ def mappings(self) -> list[mobase.Mapping]: self.dataDirectory().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()]} From 8d55dde8a953a5c9fee0812f0fbafaafbb0d9d29 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Sun, 27 Apr 2025 02:55:35 -0500 Subject: [PATCH 05/32] Fix detach parent if directory parent is root --- games/game_oblivion_remaster.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 52efde1a..73b3a621 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -192,10 +192,13 @@ def parse_directory(self, main_filetree: mobase.IFileTree, next_dir: mobase.IFil return main_filetree def detach_parents(self, directory: mobase.IFileTree) -> None: - parent = directory.parent() if directory.parent().parent() is not None else directory - while parent.parent().parent() is not None: - parent = parent.parent() - parent.detach() + if directory.parent() is not None: + parent = directory.parent() if directory.parent().parent() is not None else directory + while parent.parent().parent() is not None: + parent = parent.parent() + parent.detach() + else: + directory.detach() class OblivionRemasteredGamePlugins(mobase.GamePlugins): @@ -405,8 +408,8 @@ def init(self, organizer: mobase.IOrganizer) -> bool: self._register_feature(OblivionRemasteredModDataChecker()) self._register_feature(OblivionRemasteredScriptExtender(self)) self.detectGame() - if self.paksDirectory().exists() and not self.paksDirectory().exists('~mods'): - self.paksDirectory().mkdir('~mods') + if not self.paksDirectory().exists(): + os.mkdir(self.paksDirectory().absolutePath()) if not self.obseDirectory().exists(): os.mkdir(self.obseDirectory().absolutePath()) return True From 96c578ddf90200b0e2ccce93b39257dfa94a8245 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Mon, 28 Apr 2025 02:58:07 -0500 Subject: [PATCH 06/32] Fixes for init - Use 'initializeProfile' to create required directories - Use 'makedirs' just in case --- games/game_oblivion_remaster.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 73b3a621..193f2953 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -407,11 +407,6 @@ def init(self, organizer: mobase.IOrganizer) -> bool: self._register_feature(OblivionRemasteredGamePlugins(self._organizer)) self._register_feature(OblivionRemasteredModDataChecker()) self._register_feature(OblivionRemasteredScriptExtender(self)) - self.detectGame() - if not self.paksDirectory().exists(): - os.mkdir(self.paksDirectory().absolutePath()) - if not self.obseDirectory().exists(): - os.mkdir(self.obseDirectory().absolutePath()) return True def executables(self): @@ -505,6 +500,12 @@ def initializeProfile( profile_ini ).touch() + 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()) + def iniFiles(self) -> list[str]: return ['Oblivion.ini'] From 592acd1df856276ad47d1ccec061a4f62a06c996 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Mon, 28 Apr 2025 23:35:29 -0500 Subject: [PATCH 07/32] Various updates - Formatted file - Added Movies and UE4SS directories to map - ModDataChecker updates for new types --- games/game_oblivion_remaster.py | 428 ++++++++++++++++++++------------ 1 file changed, 275 insertions(+), 153 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 193f2953..839e3936 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -5,68 +5,73 @@ from typing import Sequence import PyQt6.QtCore +from PyQt6.QtCore import ( + QByteArray, + QCoreApplication, + QDateTime, + QDir, + QFile, + QFileInfo, + QStandardPaths, + QStringConverter, + QStringEncoder, + qCritical, +) + import mobase -from PyQt6.QtCore import QByteArray, QDir, QFileInfo, QFile, QDateTime, QCoreApplication, QStandardPaths, \ - QStringEncoder, QStringConverter, qCritical, qDebug -from ..basic_features import BasicLocalSavegames, BasicGameSaveGameInfo +from ..basic_features import BasicGameSaveGameInfo from ..basic_features.utils import is_directory from ..basic_game import BasicGame + class OblivionRemasteredModDataChecker(mobase.ModDataChecker): - _dirs = [ - 'Data', - 'Paks', - 'OBSE' - ] + _dirs = ["Data", "Paks", "OBSE", "Movies", "UE4SS"] _data_dirs = [ - 'meshes', - 'textures', - 'music', - 'scripts', - 'fonts', - 'interface', - 'shaders', - 'strings', - 'materials' - ] - _extensions = [ - '.esm', - '.esp', - '.bsa', - '.ini', - '.dll' + "meshes", + "textures", + "music", + # "scripts", + "fonts", + "interface", + "shaders", + "strings", + "materials", ] + _extensions = [".esm", ".esp", ".bsa", ".ini", ".dll"] + def __init__(self): super().__init__() - def dataLooksValid(self, filetree: mobase.IFileTree) -> mobase.ModDataChecker.CheckReturn: + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: status = mobase.ModDataChecker.INVALID for entry in filetree: name = entry.name().casefold() if entry.parent().parent() is None: if is_directory(entry): if name in [dirname.lower() for dirname in self._dirs]: - if name == 'obse': + if name in ["obse", "ue4ss", "movies"]: status = mobase.ModDataChecker.VALID break for sub_entry in entry: if not is_directory(sub_entry): sub_name = sub_entry.name().casefold() - if name == 'paks': - if sub_name.endswith('.pak'): + if name == "paks": + if sub_name.endswith(".pak"): status = mobase.ModDataChecker.VALID break - if name == 'data': + if name == "data": if sub_name.endswith(tuple(self._extensions)): status = mobase.ModDataChecker.VALID break else: - if name == 'paks': + if name == "paks": for paks_entry in sub_entry: if not is_directory(paks_entry): paks_name = paks_entry.name().casefold() - if paks_name.endswith('.pak'): + if paks_name.endswith(".pak"): status = mobase.ModDataChecker.VALID break if status == mobase.ModDataChecker.VALID: @@ -75,12 +80,12 @@ def dataLooksValid(self, filetree: mobase.IFileTree) -> mobase.ModDataChecker.Ch for sub_entry in entry: if not is_directory(sub_entry): sub_name = sub_entry.name().casefold() - if sub_name.endswith('.pak'): + if sub_name.endswith((".pak", ".lua", ".bk2")): status = mobase.ModDataChecker.FIXABLE elif sub_name.endswith(tuple(self._extensions)): status = mobase.ModDataChecker.FIXABLE else: - if name == 'Paks': + if name == "Paks": status = mobase.ModDataChecker.FIXABLE new_status = self.dataLooksValid(entry) if new_status != mobase.ModDataChecker.INVALID: @@ -88,7 +93,9 @@ def dataLooksValid(self, filetree: mobase.IFileTree) -> mobase.ModDataChecker.Ch if status == mobase.ModDataChecker.VALID: break else: - if name.endswith(tuple(self._extensions + ['.pak'])): + if name.endswith( + tuple(self._extensions + [".pak", ".lua", ".bk2"]) + ): status = mobase.ModDataChecker.FIXABLE else: if is_directory(entry): @@ -101,7 +108,9 @@ def dataLooksValid(self, filetree: mobase.IFileTree) -> mobase.ModDataChecker.Ch if new_status != mobase.ModDataChecker.INVALID: status = new_status else: - if name.endswith(tuple(self._extensions + ['.pak'])): + if name.endswith( + tuple(self._extensions + [".pak", ".lua", ".bk2"]) + ): status = mobase.ModDataChecker.FIXABLE if status == mobase.ModDataChecker.VALID: break @@ -111,31 +120,51 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: for entry in filetree: if entry is not None: if is_directory(entry): - if entry.name().casefold() in [dirname.lower() for dirname in self._data_dirs]: - data_dir = filetree.find('Data') + if entry.name().casefold() in [ + dirname.lower() for dirname in self._data_dirs + ]: + data_dir = filetree.find("Data") if data_dir is None: - data_dir = filetree.addDirectory('Data') + data_dir = filetree.addDirectory("Data") entry.moveTo(data_dir) - elif entry.name().casefold() not in [dirname.lower() for dirname in self._dirs]: + elif entry.name().casefold() not in [ + dirname.lower() for dirname in self._dirs + ]: filetree = self.parse_directory(filetree, entry) else: name = entry.name().casefold() - if name.endswith('.pak'): - paks_dir = filetree.find('Paks') + if name.endswith(".pak"): + paks_dir = filetree.find("Paks") if paks_dir is None: - paks_dir = filetree.addDirectory('Paks') + paks_dir = filetree.addDirectory("Paks") pak_files: list[mobase.FileTreeEntry] = [] for file in entry.parent(): if file is not None: if not is_directory(file): - if file.name().casefold().endswith(('.pak', '.ucas', '.utoc')): + if ( + file.name() + .casefold() + .endswith((".pak", ".ucas", ".utoc")) + ): pak_files.append(file) for pak_file in pak_files: pak_file.moveTo(paks_dir) + elif name.endswith(".bk2"): + movies_dir = filetree.find("Movies/Modern") + if movies_dir is None: + movies_dir = filetree.addDirectory("Movies/Modern") + movie_files: list[mobase.FileTreeEntry] = [] + for file in entry.parent(): + if file is not None: + if not is_directory(file): + if file.name().casefold().endswith(".bk2"): + movie_files.append(file) + for movie_file in movie_files: + movie_file.moveTo(movies_dir) elif name.endswith(tuple(self._extensions)): - data_dir = filetree.find('Data') + data_dir = filetree.find("Data") if data_dir is None: - data_dir = filetree.addDirectory('Data') + data_dir = filetree.addDirectory("Data") data_files: list[mobase.FileTreeEntry] = [] for file in entry.parent(): data_files.append(file) @@ -143,57 +172,91 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: data_file.moveTo(data_dir) return filetree - def parse_directory(self, main_filetree: mobase.IFileTree, next_dir: mobase.IFileTree) -> mobase.IFileTree: + def parse_directory( + self, main_filetree: mobase.IFileTree, next_dir: mobase.IFileTree + ) -> mobase.IFileTree: for entry in next_dir: name = entry.name().casefold() if is_directory(entry): for dir_name in self._dirs: if name == dir_name.lower(): + if name == "ue4ss": + ue4ss_mods = next_dir.find("Mods") + if ue4ss_mods: + if main_filetree.find("UE4SS") is None: + main_filetree.addDirectory("UE4SS") + main_filetree.find("UE4SS").merge(ue4ss_mods) + else: + main_filetree.move(next_dir, "") + self.detach_parents(next_dir) + continue + elif name == "paks": + if entry.find("~mods"): + main_filetree = self.parse_directory( + main_filetree, entry + ) + continue main_dir = main_filetree.find(dir_name) if main_dir is None: main_dir = main_filetree.addDirectory(dir_name) main_dir.merge(entry) self.detach_parents(entry) - return main_filetree - if name == '~mods': - paks_dir = main_filetree.find('Paks') + if name == "~mods": + paks_dir = main_filetree.find("Paks") if paks_dir is None: - paks_dir = main_filetree.addDirectory('Paks') + paks_dir = main_filetree.addDirectory("Paks") paks_dir.merge(entry) self.detach_parents(entry) continue elif name in [dirname.lower() for dirname in self._data_dirs]: - data_dir = main_filetree.find('Data') + data_dir = main_filetree.find("Data") if data_dir is None: - data_dir = main_filetree.addDirectory('Data') + data_dir = main_filetree.addDirectory("Data") data_dir.merge(entry) self.detach_parents(entry) continue - return self.parse_directory(main_filetree, entry) + main_filetree = self.parse_directory(main_filetree, entry) else: if name.endswith(tuple(self._extensions)): - data_dir = main_filetree.find('Data') + data_dir = main_filetree.find("Data") if data_dir is None: - data_dir = main_filetree.addDirectory('Data') + data_dir = main_filetree.addDirectory("Data") data_dir.merge(next_dir) self.detach_parents(next_dir) - elif name.endswith('.pak'): - paks_dir = main_filetree.find('Paks') + elif name.endswith(".pak"): + paks_dir = main_filetree.find("Paks") if paks_dir is None: - paks_dir = main_filetree.addDirectory('Paks') - if next_dir.name().casefold() == 'paks': + paks_dir = main_filetree.addDirectory("Paks") + if next_dir.name().casefold() == "paks": paks_dir.merge(next_dir) self.detach_parents(next_dir) return main_filetree else: - main_filetree.move(next_dir, 'Paks/') + main_filetree.move(next_dir, "Paks/") return main_filetree + elif name.endswith(".lua"): + if next_dir.parent() and next_dir.parent() != main_filetree: + if main_filetree.find("UE4SS") is None: + main_filetree.addDirectory("UE4SS") + main_filetree.move(next_dir.parent(), "UE4SS/") + self.detach_parents(main_filetree) + return main_filetree + elif name.endswith(".bk2"): + movies_dir = main_filetree.find("Movies/Modern") + if movies_dir is None: + movies_dir = main_filetree.addDirectory("Movies/Modern") + movies_dir.merge(next_dir) + self.detach_parents(next_dir) return main_filetree def detach_parents(self, directory: mobase.IFileTree) -> None: if directory.parent() is not None: - parent = directory.parent() if directory.parent().parent() is not None else directory + parent = ( + directory.parent() + if directory.parent().parent() is not None + else directory + ) while parent.parent().parent() is not None: parent = parent.parent() parent.detach() @@ -207,13 +270,17 @@ def __init__(self, organizer: mobase.IOrganizer): self._last_read = QDateTime().currentDateTime() self._organizer = organizer # What are these for? - self._plugin_blacklist = ['TamrielLevelledRegion.esp', 'AltarGymNavigation.esp'] + self._plugin_blacklist = ["TamrielLevelledRegion.esp", "AltarGymNavigation.esp"] def writePluginLists(self, plugin_list: mobase.IPluginList) -> None: if not self._last_read.isValid(): return - self.writePluginList(plugin_list, self._organizer.profile().absolutePath() + "/plugins.txt") - self.writeLoadOrderList(plugin_list, self._organizer.profile().absolutePath() + "/loadorder.txt") + self.writePluginList( + plugin_list, self._organizer.profile().absolutePath() + "/plugins.txt" + ) + self.writeLoadOrderList( + plugin_list, self._organizer.profile().absolutePath() + "/loadorder.txt" + ) self._last_read = QDateTime.currentDateTime() def readPluginLists(self, plugin_list: mobase.IPluginList) -> None: @@ -225,11 +292,17 @@ def readPluginLists(self, plugin_list: mobase.IPluginList) -> None: def getLoadOrder(self) -> Sequence[str]: load_order_path = self._organizer.profile().absolutePath() + "/loadorder.txt" - plugins_path = self._organizer.profile().absolutePath() + "/plugins.txt" - - load_order_is_new = (not self._last_read.isValid() or not QFileInfo(load_order_path).exists() - or QFileInfo(load_order_path).lastModified() > self._last_read) - plugins_is_new = not self._last_read.isValid() or QFileInfo(plugins_path).lastModified() > self._last_read + plugins_path = self._organizer.profile().absolutePath() + "/plugins.txt" + + load_order_is_new = ( + not self._last_read.isValid() + or not QFileInfo(load_order_path).exists() + or QFileInfo(load_order_path).lastModified() > self._last_read + ) + plugins_is_new = ( + not self._last_read.isValid() + or QFileInfo(plugins_path).lastModified() > self._last_read + ) if load_order_is_new or not plugins_is_new: return self.readLoadOrderList(self._organizer.pluginList(), load_order_path) @@ -242,38 +315,62 @@ def writePluginList(self, plugin_list: mobase.IPluginList, filePath: str): def writeLoadOrderList(self, plugin_list: mobase.IPluginList, filePath: str): self.writeList(plugin_list, filePath, True) - def writeList(self, plugin_list: mobase.IPluginList, filePath: str, load_order: bool): - plugins_file = open(filePath, 'w') - encoder = QStringEncoder(QStringConverter.Encoding.Utf8) if load_order else QStringEncoder(QStringConverter.Encoding.System) - plugins_text = '# This file was automatically generated by Mod Organizer.\n' + def writeList( + self, plugin_list: mobase.IPluginList, filePath: str, load_order: bool + ): + plugins_file = open(filePath, "w") + encoder = ( + QStringEncoder(QStringConverter.Encoding.Utf8) + if load_order + else QStringEncoder(QStringConverter.Encoding.System) + ) + plugins_text = "# This file was automatically generated by Mod Organizer.\n" invalid_filenames = False written_count = 0 plugins = plugin_list.pluginNames() - plugins_sorted = sorted(plugins, key=cmp_to_key(lambda lhs, rhs: plugin_list.priority(lhs) - plugin_list.priority(rhs))) + plugins_sorted = sorted( + plugins, + key=cmp_to_key( + lambda lhs, rhs: plugin_list.priority(lhs) - plugin_list.priority(rhs) + ), + ) for plugin_name in plugins_sorted: - if load_order or plugin_list.state(plugin_name) == mobase.PluginState.ACTIVE: + if ( + load_order + or plugin_list.state(plugin_name) == mobase.PluginState.ACTIVE + ): result = encoder.encode(plugin_name) if encoder.hasError(): invalid_filenames = True - qCritical('invalid plugin name %s', plugin_name) - plugins_text += result.data().decode() + '\n' + qCritical("invalid plugin name %s", plugin_name) + plugins_text += result.data().decode() + "\n" written_count += 1 if invalid_filenames: - PyQt6.QtCore.qCritical(QCoreApplication.translate("MainWindow", - "Some of your plugins have invalid names! These " + - "plugins can not be loaded by the game. Please see " + - "mo_interface.log for a list of affected plugins " + - "and rename them.")) + PyQt6.QtCore.qCritical( + QCoreApplication.translate( + "MainWindow", + "Some of your plugins have invalid names! These " + + "plugins can not be loaded by the game. Please see " + + "mo_interface.log for a list of affected plugins " + + "and rename them.", + ) + ) if written_count == 0: - PyQt6.QtCore.qWarning("plugin list would be empty, this is almost certainly wrong. Not saving.") + PyQt6.QtCore.qWarning( + "plugin list would be empty, this is almost certainly wrong. Not saving." + ) else: plugins_file.write(plugins_text) plugins_file.close() - def readLoadOrderList(self, plugin_list: mobase.IPluginList, file_path: str) -> list[str]: - plugin_names = [plugin for plugin in self._organizer.managedGame().primaryPlugins()] + def readLoadOrderList( + self, plugin_list: mobase.IPluginList, file_path: str + ) -> list[str]: + plugin_names = [ + plugin for plugin in self._organizer.managedGame().primaryPlugins() + ] plugin_lookup = set() for name in plugin_names: if name.lower() not in plugin_lookup: @@ -282,9 +379,9 @@ def readLoadOrderList(self, plugin_list: mobase.IPluginList, file_path: str) -> try: with open(file_path) as file: for line in file: - if line.startswith('#'): + if line.startswith("#"): continue - plugin_file = line.rstrip('\n') + plugin_file = line.rstrip("\n") if plugin_file.lower() not in plugin_lookup: plugin_lookup.add(plugin_file.lower()) plugin_names.append(plugin_file) @@ -302,7 +399,9 @@ def readPluginList(self, plugin_list: mobase.IPluginList) -> list[str]: if plugin_list.state(plugin_name) != mobase.PluginState.MISSING: plugin_list.setState(plugin_name, mobase.PluginState.ACTIVE) sorted_plugins.append(plugin_name) - plugin_remove = [plugin for plugin in plugins if plugin.lower() in primary_lower] + plugin_remove = [ + plugin for plugin in plugins if plugin.lower() in primary_lower + ] for plugin in plugin_remove: plugins.remove(plugin) @@ -317,12 +416,16 @@ def readPluginList(self, plugin_list: mobase.IPluginList) -> list[str]: while not file.atEnd(): line = file.readLine() file_plugin_name = QByteArray() - if line.size() > 0 and line.at(0).decode() != '#': + if line.size() > 0 and line.at(0).decode() != "#": encoder = QStringEncoder(QStringEncoder.Encoding.System) file_plugin_name = encoder.encode(line.trimmed().data().decode()) if file_plugin_name.size() > 0: - if file_plugin_name.data().decode().lower() in [plugin.lower() for plugin in plugins]: - plugin_list.setState(file_plugin_name.data().decode(), mobase.PluginState.ACTIVE) + if file_plugin_name.data().decode().lower() in [ + plugin.lower() for plugin in plugins + ]: + plugin_list.setState( + file_plugin_name.data().decode(), mobase.PluginState.ACTIVE + ) sorted_plugins.append(file_plugin_name.data().decode()) plugins.remove(file_plugin_name.data().decode()) @@ -352,19 +455,23 @@ def __init__(self, game: mobase.IPluginGame): self._game = game def binaryName(self): - return 'obse64_loader.exe' + return "obse64_loader.exe" def loaderName(self) -> str: return self.binaryName() def loaderPath(self) -> str: - return self._game.gameDirectory().absolutePath() + '\\OblivionRemastered\\Binaries\\Win64\\' + self.loaderName() + return ( + self._game.gameDirectory().absolutePath() + + "\\OblivionRemastered\\Binaries\\Win64\\" + + self.loaderName() + ) def pluginPath(self) -> str: - return 'OBSE/Plugins' + return "OBSE/Plugins" def savegameExtension(self) -> str: - return '' + return "" def isInstalled(self) -> bool: return os.path.exists(self.loaderPath()) @@ -376,7 +483,6 @@ def getArch(self) -> int: return 0x8664 if self.isInstalled() else 0x0 - class OblivionRemasteredGame(BasicGame, mobase.IPluginFileMapper): Name = "Oblivion Remastered Support Plugin" Author = "Silarn" @@ -393,8 +499,8 @@ class OblivionRemasteredGame(BasicGame, mobase.IPluginFileMapper): UserHome = QStandardPaths.writableLocation( QStandardPaths.StandardLocation.HomeLocation ) - MyDocumentsDirectory = rf'{UserHome}\Documents\My Games\{GameName}' - GameSavesDirectory = rf'{MyDocumentsDirectory}\Saved\SaveGames' + MyDocumentsDirectory = rf"{UserHome}\Documents\My Games\{GameName}" + GameSavesDirectory = rf"{MyDocumentsDirectory}\Saved\SaveGames" GameSaveExtension = "sav" def __init__(self): @@ -421,55 +527,56 @@ def executables(self): mobase.ExecutableInfo( "OBSE64", QFileInfo( - self._organizer.gameFeatures().gameFeature(mobase.ScriptExtender).loaderPath() - ) - ) + self._organizer.gameFeatures() + .gameFeature(mobase.ScriptExtender) + .loaderPath() + ), + ), ] def primaryPlugins(self) -> list[str]: return [ - 'Oblivion.esm' - ] - - def enabledPlugins(self) -> list[str]: - return [ - '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' - ] - - def DLCPlugins(self) -> list[str]: - return [ - 'DLCBattlehornCastle.esp', - 'DLCFrostcrag.esp', - 'DLCHorseArmor.esp', - 'DLCMehrunesRazor.esp', - 'DLCOrrery.esp', - 'DLCShiveringIsles.esp', - 'DLCSpellTomes.esp', - 'DLCThievesDen.esp', - 'DLCVileLair.esp', - 'Knights.esp' + "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", ] def modDataDirectory(self) -> str: - return 'Data' + 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/~mods') + return QDir( + self.gameDirectory().absolutePath() + + "/OblivionRemastered/Content/Paks/~mods" + ) def obseDirectory(self) -> QDir: - return QDir(self.gameDirectory().absolutePath() + '/OblivionRemastered/Binaries/Win64/OBSE') + return QDir( + self.gameDirectory().absolutePath() + + "/OblivionRemastered/Binaries/Win64/OBSE" + ) + + def ue4ssDirectory(self) -> QDir: + return QDir( + self.gameDirectory().absolutePath() + + "/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) def loadOrderMechanism(self) -> mobase.LoadOrderMechanism: return mobase.LoadOrderMechanism.PLUGINS_TXT @@ -478,11 +585,15 @@ 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()) + 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( @@ -496,28 +607,39 @@ def initializeProfile( profile_ini, ) else: - Path( - profile_ini - ).touch() + Path(profile_ini).touch() - if self._organizer.managedGame() and self._organizer.managedGame().gameName() == self.gameName(): + 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 iniFiles(self) -> list[str]: - return ['Oblivion.ini'] + 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 ["plugins.txt", "loadorder.txt"]: + mappings.append( + mobase.Mapping( + self._organizer.profilePath() + "/" + profile_file, + self.dataDirectory().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()]} + return { + "Data": [self.dataDirectory().absolutePath()], + "Paks": [self.paksDirectory().absolutePath()], + "OBSE": [self.obseDirectory().absolutePath()], + "Movies": [self.moviesDirectory().absolutePath()], + "UE4SS": [self.ue4ssDirectory().absolutePath()], + } From 3586642d82e7ecadf0d151ee34b69f84a722caa8 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Sat, 3 May 2025 02:46:27 -0500 Subject: [PATCH 08/32] Add LOOT support --- games/game_oblivion_remaster.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 839e3936..e61c00fb 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -1,5 +1,6 @@ import os.path import shutil +import winreg from functools import cmp_to_key from pathlib import Path from typing import Sequence @@ -25,6 +26,15 @@ from ..basic_game import BasicGame +def getLootPath() -> Path | None: + try: + with winreg.OpenKeyEx(winreg.HKEY_LOCAL_MACHINE, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{BF634210-A0D4-443F-A657-0DCE38040374}_is1") as key: + value = winreg.QueryValueEx(key, "InstallLocation") + return Path((value[0]+"/LOOT.exe").replace("/", "\\")) + except FileNotFoundError: + return None + + class OblivionRemasteredModDataChecker(mobase.ModDataChecker): _dirs = ["Data", "Paks", "OBSE", "Movies", "UE4SS"] _data_dirs = [ @@ -532,6 +542,12 @@ def executables(self): .loaderPath() ), ), + mobase.ExecutableInfo( + "LOOT", + QFileInfo(str(getLootPath())) + ).withArgument( + '--game="Oblivion Remastered"' + ) ] def primaryPlugins(self) -> list[str]: @@ -581,6 +597,9 @@ def ue4ssDirectory(self) -> QDir: 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: From 98cbe8740026bb29717eaddf3fcd39bc3b02b418 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Sun, 4 May 2025 00:10:01 -0500 Subject: [PATCH 09/32] Reformat --- games/game_oblivion_remaster.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index e61c00fb..98a162ce 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -28,9 +28,12 @@ def getLootPath() -> Path | None: try: - with winreg.OpenKeyEx(winreg.HKEY_LOCAL_MACHINE, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{BF634210-A0D4-443F-A657-0DCE38040374}_is1") as key: + with winreg.OpenKeyEx( + winreg.HKEY_LOCAL_MACHINE, + "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{BF634210-A0D4-443F-A657-0DCE38040374}_is1", + ) as key: value = winreg.QueryValueEx(key, "InstallLocation") - return Path((value[0]+"/LOOT.exe").replace("/", "\\")) + return Path((value[0] + "/LOOT.exe").replace("/", "\\")) except FileNotFoundError: return None @@ -542,12 +545,9 @@ def executables(self): .loaderPath() ), ), - mobase.ExecutableInfo( - "LOOT", - QFileInfo(str(getLootPath())) - ).withArgument( + mobase.ExecutableInfo("LOOT", QFileInfo(str(getLootPath()))).withArgument( '--game="Oblivion Remastered"' - ) + ), ] def primaryPlugins(self) -> list[str]: From 1db99fe6cb604f2bd6e04d231041f9ac867af05c Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Sun, 4 May 2025 00:29:41 -0500 Subject: [PATCH 10/32] Check all possible LOOT reg entries --- games/game_oblivion_remaster.py | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 98a162ce..59ff66f2 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -27,15 +27,31 @@ def getLootPath() -> Path | None: - try: - with winreg.OpenKeyEx( + 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", - ) as key: - value = winreg.QueryValueEx(key, "InstallLocation") - return Path((value[0] + "/LOOT.exe").replace("/", "\\")) - except FileNotFoundError: - return None + "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 OblivionRemasteredModDataChecker(mobase.ModDataChecker): From d9af1fff25e6166dbdc401ff1752a8a67f9dbe14 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Sun, 4 May 2025 00:50:02 -0500 Subject: [PATCH 11/32] Rework UE4SS to install to Root --- games/game_oblivion_remaster.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 59ff66f2..c382dbaa 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -55,7 +55,7 @@ def getLootPath() -> Path | None: class OblivionRemasteredModDataChecker(mobase.ModDataChecker): - _dirs = ["Data", "Paks", "OBSE", "Movies", "UE4SS"] + _dirs = ["Data", "Paks", "OBSE", "Movies", "Root"] _data_dirs = [ "meshes", "textures", @@ -81,7 +81,7 @@ def dataLooksValid( if entry.parent().parent() is None: if is_directory(entry): if name in [dirname.lower() for dirname in self._dirs]: - if name in ["obse", "ue4ss", "movies"]: + if name in ["obse", "root", "movies"]: status = mobase.ModDataChecker.VALID break for sub_entry in entry: @@ -265,9 +265,9 @@ def parse_directory( return main_filetree elif name.endswith(".lua"): if next_dir.parent() and next_dir.parent() != main_filetree: - if main_filetree.find("UE4SS") is None: - main_filetree.addDirectory("UE4SS") - main_filetree.move(next_dir.parent(), "UE4SS/") + if main_filetree.find("Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods") is None: + main_filetree.addDirectory("Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods") + main_filetree.move(next_dir.parent(), "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods/") self.detach_parents(main_filetree) return main_filetree elif name.endswith(".bk2"): @@ -675,6 +675,5 @@ def getModMappings(self) -> dict[str, list[str]]: "Data": [self.dataDirectory().absolutePath()], "Paks": [self.paksDirectory().absolutePath()], "OBSE": [self.obseDirectory().absolutePath()], - "Movies": [self.moviesDirectory().absolutePath()], - "UE4SS": [self.ue4ssDirectory().absolutePath()], + "Movies": [self.moviesDirectory().absolutePath()] } From b23e8929505074e0c433fa0d620d0a9d38d24e6f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 05:50:20 +0000 Subject: [PATCH 12/32] [pre-commit.ci] Auto fixes from pre-commit.com hooks. --- games/game_oblivion_remaster.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index c382dbaa..4b940762 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -265,9 +265,19 @@ def parse_directory( return main_filetree elif name.endswith(".lua"): if next_dir.parent() and next_dir.parent() != main_filetree: - if main_filetree.find("Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods") is None: - main_filetree.addDirectory("Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods") - main_filetree.move(next_dir.parent(), "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods/") + if ( + main_filetree.find( + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) + is None + ): + main_filetree.addDirectory( + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) + main_filetree.move( + next_dir.parent(), + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods/", + ) self.detach_parents(main_filetree) return main_filetree elif name.endswith(".bk2"): @@ -675,5 +685,5 @@ def getModMappings(self) -> dict[str, list[str]]: "Data": [self.dataDirectory().absolutePath()], "Paks": [self.paksDirectory().absolutePath()], "OBSE": [self.obseDirectory().absolutePath()], - "Movies": [self.moviesDirectory().absolutePath()] + "Movies": [self.moviesDirectory().absolutePath()], } From eec1cd755fa836ba12fadfa0d6f82320eaea1afe Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Sun, 4 May 2025 21:35:13 -0500 Subject: [PATCH 13/32] Improved mod handling - Map 'Paks' to game 'Paks' (instead of ~mods) - Validate LogicMods Paks dir - Handle core OBSE / UE4SS files better - Add 'get_dir' function to simplify code --- games/game_oblivion_remaster.py | 182 +++++++++++++++----------------- 1 file changed, 88 insertions(+), 94 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 4b940762..90b83f4d 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -76,12 +76,14 @@ def dataLooksValid( self, filetree: mobase.IFileTree ) -> mobase.ModDataChecker.CheckReturn: status = mobase.ModDataChecker.INVALID + if filetree.find("ue4ss/UE4SS.dll") is not None: + return mobase.ModDataChecker.FIXABLE for entry in filetree: name = entry.name().casefold() if entry.parent().parent() is None: if is_directory(entry): if name in [dirname.lower() for dirname in self._dirs]: - if name in ["obse", "root", "movies"]: + if name in ["obse", "root", "movies", "paks"]: status = mobase.ModDataChecker.VALID break for sub_entry in entry: @@ -98,11 +100,14 @@ def dataLooksValid( else: if name == "paks": for paks_entry in sub_entry: - if not is_directory(paks_entry): - paks_name = paks_entry.name().casefold() - if paks_name.endswith(".pak"): + paks_name = paks_entry.name().casefold() + if is_directory(paks_entry): + if paks_name in ["~mods", "logicmods"]: status = mobase.ModDataChecker.VALID break + else: + if paks_name.endswith(".pak"): + return mobase.ModDataChecker.VALID if status == mobase.ModDataChecker.VALID: break else: @@ -122,6 +127,8 @@ def dataLooksValid( if status == mobase.ModDataChecker.VALID: break else: + if name == "obse64_loader.exe": + return mobase.ModDataChecker.INVALID if name.endswith( tuple(self._extensions + [".pak", ".lua", ".bk2"]) ): @@ -146,15 +153,24 @@ def dataLooksValid( return status def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + if filetree.find("ue4ss/UE4SS.dll") is not None: + entries = [] + for entry in filetree: + entries.append(entry) + for entry in entries: + filetree.move( + entry, + "Root/OblivionRemastered/Binaries/Win64/", + mobase.IFileTree.MERGE, + ) + return filetree for entry in filetree: if entry is not None: if is_directory(entry): if entry.name().casefold() in [ dirname.lower() for dirname in self._data_dirs ]: - data_dir = filetree.find("Data") - if data_dir is None: - data_dir = filetree.addDirectory("Data") + data_dir = self.get_dir(filetree, "Data") entry.moveTo(data_dir) elif entry.name().casefold() not in [ dirname.lower() for dirname in self._dirs @@ -163,9 +179,7 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: else: name = entry.name().casefold() if name.endswith(".pak"): - paks_dir = filetree.find("Paks") - if paks_dir is None: - paks_dir = filetree.addDirectory("Paks") + paks_dir = self.get_dir(filetree, "Paks/~mods") pak_files: list[mobase.FileTreeEntry] = [] for file in entry.parent(): if file is not None: @@ -179,9 +193,7 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: for pak_file in pak_files: pak_file.moveTo(paks_dir) elif name.endswith(".bk2"): - movies_dir = filetree.find("Movies/Modern") - if movies_dir is None: - movies_dir = filetree.addDirectory("Movies/Modern") + movies_dir = self.get_dir(filetree, "Movies/Modern") movie_files: list[mobase.FileTreeEntry] = [] for file in entry.parent(): if file is not None: @@ -191,9 +203,7 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: for movie_file in movie_files: movie_file.moveTo(movies_dir) elif name.endswith(tuple(self._extensions)): - data_dir = filetree.find("Data") - if data_dir is None: - data_dir = filetree.addDirectory("Data") + data_dir = self.get_dir(filetree, "Data") data_files: list[mobase.FileTreeEntry] = [] for file in entry.parent(): data_files.append(file) @@ -205,87 +215,66 @@ def parse_directory( self, main_filetree: mobase.IFileTree, next_dir: mobase.IFileTree ) -> mobase.IFileTree: for entry in next_dir: - name = entry.name().casefold() - if is_directory(entry): - for dir_name in self._dirs: - if name == dir_name.lower(): - if name == "ue4ss": - ue4ss_mods = next_dir.find("Mods") - if ue4ss_mods: - if main_filetree.find("UE4SS") is None: - main_filetree.addDirectory("UE4SS") - main_filetree.find("UE4SS").merge(ue4ss_mods) - else: - main_filetree.move(next_dir, "") - self.detach_parents(next_dir) - continue - elif name == "paks": - if entry.find("~mods"): - main_filetree = self.parse_directory( - main_filetree, entry - ) + if entry is not None: + name = entry.name().casefold() + if is_directory(entry): + for dir_name in self._dirs: + if name == dir_name.lower(): + if name == "paks": + paks_dir = self.get_dir(main_filetree, "Paks") + paks_dir.merge(entry) + self.detach_parents(entry) continue - main_dir = main_filetree.find(dir_name) - if main_dir is None: - main_dir = main_filetree.addDirectory(dir_name) - main_dir.merge(entry) + main_dir = self.get_dir(main_filetree, dir_name) + main_dir.merge(entry) + self.detach_parents(entry) + if name in ["~mods", "logicmods"]: + paks_dir = self.get_dir(main_filetree, "Paks") + entry.moveTo(paks_dir) + continue + elif name in [dirname.lower() for dirname in self._data_dirs]: + data_dir = self.get_dir(main_filetree, "Data") + data_dir.merge(entry) self.detach_parents(entry) - if name == "~mods": - paks_dir = main_filetree.find("Paks") - if paks_dir is None: - paks_dir = main_filetree.addDirectory("Paks") - paks_dir.merge(entry) - self.detach_parents(entry) - continue - elif name in [dirname.lower() for dirname in self._data_dirs]: - data_dir = main_filetree.find("Data") - if data_dir is None: - data_dir = main_filetree.addDirectory("Data") - data_dir.merge(entry) - self.detach_parents(entry) - continue - main_filetree = self.parse_directory(main_filetree, entry) - else: - if name.endswith(tuple(self._extensions)): - data_dir = main_filetree.find("Data") - if data_dir is None: - data_dir = main_filetree.addDirectory("Data") - data_dir.merge(next_dir) - self.detach_parents(next_dir) - elif name.endswith(".pak"): - paks_dir = main_filetree.find("Paks") - if paks_dir is None: - paks_dir = main_filetree.addDirectory("Paks") - if next_dir.name().casefold() == "paks": - paks_dir.merge(next_dir) + continue + main_filetree = self.parse_directory(main_filetree, entry) + else: + if name.endswith(tuple(self._extensions)): + data_dir = self.get_dir(main_filetree, "Data") + data_dir.merge(next_dir) self.detach_parents(next_dir) - return main_filetree - else: - main_filetree.move(next_dir, "Paks/") - return main_filetree - elif name.endswith(".lua"): - if next_dir.parent() and next_dir.parent() != main_filetree: - if ( - main_filetree.find( - "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + elif name.endswith(".pak"): + paks_dir = self.get_dir(main_filetree, "Paks") + if next_dir.name().casefold() == "paks": + paks_dir.merge(next_dir) + self.detach_parents(next_dir) + return main_filetree + else: + main_filetree.move( + next_dir, "Paks/~mods/", mobase.IFileTree.MERGE ) - is None - ): - main_filetree.addDirectory( - "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + return main_filetree + elif name.endswith(".lua"): + if next_dir.parent() and next_dir.parent() != main_filetree: + if ( + main_filetree.find( + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) + is None + ): + main_filetree.addDirectory( + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) + main_filetree.move( + next_dir.parent(), + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods/", ) - main_filetree.move( - next_dir.parent(), - "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods/", - ) - self.detach_parents(main_filetree) - return main_filetree - elif name.endswith(".bk2"): - movies_dir = main_filetree.find("Movies/Modern") - if movies_dir is None: - movies_dir = main_filetree.addDirectory("Movies/Modern") - movies_dir.merge(next_dir) - self.detach_parents(next_dir) + self.detach_parents(main_filetree) + return main_filetree + elif name.endswith(".bk2"): + movies_dir = self.get_dir(main_filetree, "Movies/Modern") + movies_dir.merge(next_dir) + self.detach_parents(next_dir) return main_filetree @@ -302,6 +291,12 @@ def detach_parents(self, directory: mobase.IFileTree) -> None: else: directory.detach() + def get_dir(self, filetree: mobase.IFileTree, directory: str) -> mobase.IFileTree: + tree_dir = filetree.find(directory) + if tree_dir is None: + tree_dir = filetree.addDirectory(directory) + return tree_dir + class OblivionRemasteredGamePlugins(mobase.GamePlugins): def __init__(self, organizer: mobase.IOrganizer): @@ -604,8 +599,7 @@ def moviesDirectory(self) -> QDir: def paksDirectory(self) -> QDir: return QDir( - self.gameDirectory().absolutePath() - + "/OblivionRemastered/Content/Paks/~mods" + self.gameDirectory().absolutePath() + "/OblivionRemastered/Content/Paks" ) def obseDirectory(self) -> QDir: From e8291de12390c2866523049e629070c287785978 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Sun, 4 May 2025 23:30:58 -0500 Subject: [PATCH 14/32] Handle top level directory being a Paks subdirectory --- games/game_oblivion_remaster.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 90b83f4d..66d4b419 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -249,6 +249,9 @@ def parse_directory( paks_dir.merge(next_dir) self.detach_parents(next_dir) return main_filetree + elif next_dir.name().casefold() in ['~mods', '~logicmods']: + next_dir.moveTo(paks_dir) + return main_filetree else: main_filetree.move( next_dir, "Paks/~mods/", mobase.IFileTree.MERGE From 2b710d16bf981fab2d43a218450de23fd34729f4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 5 May 2025 04:31:14 +0000 Subject: [PATCH 15/32] [pre-commit.ci] Auto fixes from pre-commit.com hooks. --- games/game_oblivion_remaster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 66d4b419..c9eba733 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -249,7 +249,7 @@ def parse_directory( paks_dir.merge(next_dir) self.detach_parents(next_dir) return main_filetree - elif next_dir.name().casefold() in ['~mods', '~logicmods']: + elif next_dir.name().casefold() in ["~mods", "~logicmods"]: next_dir.moveTo(paks_dir) return main_filetree else: From 146af8b84ffade260da5243d5819445ff295dc4b Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Tue, 6 May 2025 03:16:44 -0500 Subject: [PATCH 16/32] More datachecker refinements --- games/game_oblivion_remaster.py | 69 +++++++++++++++------------------ 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 66d4b419..9661d5a8 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -66,8 +66,9 @@ class OblivionRemasteredModDataChecker(mobase.ModDataChecker): "shaders", "strings", "materials", + "magicloader", ] - _extensions = [".esm", ".esp", ".bsa", ".ini", ".dll"] + _extensions = [".esm", ".esp", ".bsa", ".ini", ".dll", ".json"] def __init__(self): super().__init__() @@ -78,42 +79,24 @@ def dataLooksValid( status = mobase.ModDataChecker.INVALID if filetree.find("ue4ss/UE4SS.dll") is not None: return mobase.ModDataChecker.FIXABLE + elif ( + filetree.find("OblivionRemastered/Binaries/Win64/ue4ss/UE4SS.dll") + is not None + ): + return mobase.ModDataChecker.FIXABLE for entry in filetree: name = entry.name().casefold() if entry.parent().parent() is None: if is_directory(entry): if name in [dirname.lower() for dirname in self._dirs]: - if name in ["obse", "root", "movies", "paks"]: - status = mobase.ModDataChecker.VALID - break - for sub_entry in entry: - if not is_directory(sub_entry): - sub_name = sub_entry.name().casefold() - if name == "paks": - if sub_name.endswith(".pak"): - status = mobase.ModDataChecker.VALID - break - if name == "data": - if sub_name.endswith(tuple(self._extensions)): - status = mobase.ModDataChecker.VALID - break - else: - if name == "paks": - for paks_entry in sub_entry: - paks_name = paks_entry.name().casefold() - if is_directory(paks_entry): - if paks_name in ["~mods", "logicmods"]: - status = mobase.ModDataChecker.VALID - break - else: - if paks_name.endswith(".pak"): - return mobase.ModDataChecker.VALID - if status == mobase.ModDataChecker.VALID: - break + status = mobase.ModDataChecker.VALID + break else: for sub_entry in entry: if not is_directory(sub_entry): sub_name = sub_entry.name().casefold() + if sub_name.endswith(".exe"): + return mobase.ModDataChecker.INVALID if sub_name.endswith((".pak", ".lua", ".bk2")): status = mobase.ModDataChecker.FIXABLE elif sub_name.endswith(tuple(self._extensions)): @@ -127,7 +110,7 @@ def dataLooksValid( if status == mobase.ModDataChecker.VALID: break else: - if name == "obse64_loader.exe": + if name.endswith(".exe"): return mobase.ModDataChecker.INVALID if name.endswith( tuple(self._extensions + [".pak", ".lua", ".bk2"]) @@ -144,6 +127,8 @@ def dataLooksValid( if new_status != mobase.ModDataChecker.INVALID: status = new_status else: + if name.endswith(".exe"): + return mobase.ModDataChecker.INVALID if name.endswith( tuple(self._extensions + [".pak", ".lua", ".bk2"]) ): @@ -153,9 +138,14 @@ def dataLooksValid( return status def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: - if filetree.find("ue4ss/UE4SS.dll") is not None: + ue4ss_dll = filetree.find("ue4ss/UE4SS.dll") + if ue4ss_dll is None: + ue4ss_dll = filetree.find( + "OblivionRemastered/Binaries/Win64/ue4ss/UE4SS.dll" + ) + if ue4ss_dll is not None: entries = [] - for entry in filetree: + for entry in ue4ss_dll.parent().parent(): entries.append(entry) for entry in entries: filetree.move( @@ -163,7 +153,6 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: "Root/OblivionRemastered/Binaries/Win64/", mobase.IFileTree.MERGE, ) - return filetree for entry in filetree: if entry is not None: if is_directory(entry): @@ -172,6 +161,10 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: ]: data_dir = self.get_dir(filetree, "Data") entry.moveTo(data_dir) + elif entry.name().casefold() in [ + dirname.lower() for dirname in self._dirs + ]: + break elif entry.name().casefold() not in [ dirname.lower() for dirname in self._dirs ]: @@ -218,16 +211,16 @@ def parse_directory( if entry is not None: name = entry.name().casefold() if is_directory(entry): + stop = False for dir_name in self._dirs: if name == dir_name.lower(): - if name == "paks": - paks_dir = self.get_dir(main_filetree, "Paks") - paks_dir.merge(entry) - self.detach_parents(entry) - continue main_dir = self.get_dir(main_filetree, dir_name) main_dir.merge(entry) self.detach_parents(entry) + stop = True + break + if stop: + continue if name in ["~mods", "logicmods"]: paks_dir = self.get_dir(main_filetree, "Paks") entry.moveTo(paks_dir) @@ -249,7 +242,7 @@ def parse_directory( paks_dir.merge(next_dir) self.detach_parents(next_dir) return main_filetree - elif next_dir.name().casefold() in ['~mods', '~logicmods']: + elif next_dir.name().casefold() in ["~mods", "logicmods"]: next_dir.moveTo(paks_dir) return main_filetree else: From f2b28edb7963500b244d247f9f59e6e5595daafe Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Tue, 6 May 2025 21:46:07 -0500 Subject: [PATCH 17/32] Big restructure of datachecker --- games/game_oblivion_remaster.py | 166 +++++++++++++++++--------------- 1 file changed, 88 insertions(+), 78 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 9661d5a8..ef21946b 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -66,9 +66,8 @@ class OblivionRemasteredModDataChecker(mobase.ModDataChecker): "shaders", "strings", "materials", - "magicloader", ] - _extensions = [".esm", ".esp", ".bsa", ".ini", ".dll", ".json"] + _extensions = [".esm", ".esp", ".bsa", ".ini", ".dll"] def __init__(self): super().__init__() @@ -91,13 +90,15 @@ def dataLooksValid( if name in [dirname.lower() for dirname in self._dirs]: status = mobase.ModDataChecker.VALID break + elif name in [dirname.lower() for dirname in self._data_dirs]: + status = mobase.ModDataChecker.FIXABLE else: for sub_entry in entry: if not is_directory(sub_entry): sub_name = sub_entry.name().casefold() if sub_name.endswith(".exe"): return mobase.ModDataChecker.INVALID - if sub_name.endswith((".pak", ".lua", ".bk2")): + if sub_name.endswith((".pak", ".bk2")): status = mobase.ModDataChecker.FIXABLE elif sub_name.endswith(tuple(self._extensions)): status = mobase.ModDataChecker.FIXABLE @@ -112,9 +113,7 @@ def dataLooksValid( else: if name.endswith(".exe"): return mobase.ModDataChecker.INVALID - if name.endswith( - tuple(self._extensions + [".pak", ".lua", ".bk2"]) - ): + if name.endswith(tuple(self._extensions + [".pak", ".bk2"])): status = mobase.ModDataChecker.FIXABLE else: if is_directory(entry): @@ -153,23 +152,24 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: "Root/OblivionRemastered/Binaries/Win64/", mobase.IFileTree.MERGE, ) + directories = [] for entry in filetree: if entry is not None: if is_directory(entry): - if entry.name().casefold() in [ - dirname.lower() for dirname in self._data_dirs - ]: - data_dir = self.get_dir(filetree, "Data") - entry.moveTo(data_dir) - elif entry.name().casefold() in [ - dirname.lower() for dirname in self._dirs - ]: - break - elif entry.name().casefold() not in [ - dirname.lower() for dirname in self._dirs - ]: - filetree = self.parse_directory(filetree, entry) - else: + directories.append(entry) + for directory in directories: + if directory.name().casefold() in [ + dirname.lower() for dirname in self._data_dirs + ]: + data_dir = self.get_dir(filetree, "Data") + directory.moveTo(data_dir) + elif directory.name().casefold() not in [ + dirname.lower() for dirname in self._dirs + ]: + filetree = self.parse_directory(filetree, directory) + for entry in filetree: + if entry is not None: + if not is_directory(entry): name = entry.name().casefold() if name.endswith(".pak"): paks_dir = self.get_dir(filetree, "Paks/~mods") @@ -207,70 +207,80 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: def parse_directory( self, main_filetree: mobase.IFileTree, next_dir: mobase.IFileTree ) -> mobase.IFileTree: + directories = [] for entry in next_dir: if entry is not None: - name = entry.name().casefold() if is_directory(entry): - stop = False - for dir_name in self._dirs: - if name == dir_name.lower(): - main_dir = self.get_dir(main_filetree, dir_name) - main_dir.merge(entry) - self.detach_parents(entry) - stop = True - break - if stop: - continue - if name in ["~mods", "logicmods"]: - paks_dir = self.get_dir(main_filetree, "Paks") - entry.moveTo(paks_dir) - continue - elif name in [dirname.lower() for dirname in self._data_dirs]: - data_dir = self.get_dir(main_filetree, "Data") - data_dir.merge(entry) - self.detach_parents(entry) - continue - main_filetree = self.parse_directory(main_filetree, entry) - else: - if name.endswith(tuple(self._extensions)): - data_dir = self.get_dir(main_filetree, "Data") - data_dir.merge(next_dir) + directories.append(entry) + for directory in directories: + name = directory.name().casefold() + stop = False + for dir_name in self._dirs: + if name == dir_name.lower(): + main_dir = self.get_dir(main_filetree, dir_name) + main_dir.merge(directory) + self.detach_parents(directory) + stop = True + break + if stop: + continue + if name in ["~mods", "logicmods"]: + paks_dir = self.get_dir(main_filetree, "Paks") + directory.moveTo(paks_dir) + continue + elif name in [dirname.lower() for dirname in self._data_dirs]: + data_dir = self.get_dir(main_filetree, "Data") + data_dir.merge(directory) + self.detach_parents(directory) + continue + main_filetree = self.parse_directory(main_filetree, directory) + for entry in next_dir: + if not is_directory(entry): + name = entry.name().casefold() + if name.endswith(tuple(self._extensions)): + data_dir = self.get_dir(main_filetree, "Data") + data_dir.merge(next_dir) + self.detach_parents(next_dir) + elif name.endswith(".pak"): + paks_dir = self.get_dir(main_filetree, "Paks") + if next_dir.name().casefold() == "paks": + paks_dir.merge(next_dir) self.detach_parents(next_dir) - elif name.endswith(".pak"): - paks_dir = self.get_dir(main_filetree, "Paks") - if next_dir.name().casefold() == "paks": - paks_dir.merge(next_dir) - self.detach_parents(next_dir) - return main_filetree - elif next_dir.name().casefold() in ["~mods", "logicmods"]: - next_dir.moveTo(paks_dir) - return main_filetree - else: - main_filetree.move( - next_dir, "Paks/~mods/", mobase.IFileTree.MERGE + return main_filetree + elif next_dir.name().casefold() in ["~mods", "logicmods"]: + next_dir.moveTo(paks_dir) + return main_filetree + else: + parent = next_dir.parent() + main_filetree.move( + next_dir, "Paks/~mods/", mobase.IFileTree.MERGE + ) + self.detach_parents(parent) + return main_filetree + elif name.endswith(".lua"): + if next_dir.parent() and next_dir.parent() != main_filetree: + if ( + main_filetree.find( + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" ) - return main_filetree - elif name.endswith(".lua"): - if next_dir.parent() and next_dir.parent() != main_filetree: - if ( - main_filetree.find( - "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" - ) - is None - ): - main_filetree.addDirectory( - "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" - ) - main_filetree.move( - next_dir.parent(), - "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods/", + is None + ): + main_filetree.addDirectory( + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" ) - self.detach_parents(main_filetree) - return main_filetree - elif name.endswith(".bk2"): - movies_dir = self.get_dir(main_filetree, "Movies/Modern") - movies_dir.merge(next_dir) - self.detach_parents(next_dir) + parent = next_dir.parent().parent() + main_filetree.move( + next_dir.parent(), + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods/", + mobase.IFileTree.MERGE, + ) + if parent is not None: + self.detach_parents(parent) + return main_filetree + elif name.endswith(".bk2"): + movies_dir = self.get_dir(main_filetree, "Movies/Modern") + movies_dir.merge(next_dir) + self.detach_parents(next_dir) return main_filetree From 79a269c8184da88f72ecb4b1db86b0a9f7820889 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Wed, 7 May 2025 22:12:32 -0500 Subject: [PATCH 18/32] Updates - Use 'real' exe for default game executable - Limit Data extensions to BGS-only files - Search for 'Win64' path in fixer for 'Root' files - Add some protection to 'detach_parents' --- games/game_oblivion_remaster.py | 35 ++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index ef21946b..f04bcf7e 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -67,7 +67,7 @@ class OblivionRemasteredModDataChecker(mobase.ModDataChecker): "strings", "materials", ] - _extensions = [".esm", ".esp", ".bsa", ".ini", ".dll"] + _data_extensions = [".esm", ".esp", ".bsa"] def __init__(self): super().__init__() @@ -100,7 +100,7 @@ def dataLooksValid( return mobase.ModDataChecker.INVALID if sub_name.endswith((".pak", ".bk2")): status = mobase.ModDataChecker.FIXABLE - elif sub_name.endswith(tuple(self._extensions)): + elif sub_name.endswith(tuple(self._data_extensions)): status = mobase.ModDataChecker.FIXABLE else: if name == "Paks": @@ -113,7 +113,7 @@ def dataLooksValid( else: if name.endswith(".exe"): return mobase.ModDataChecker.INVALID - if name.endswith(tuple(self._extensions + [".pak", ".bk2"])): + if name.endswith(tuple(self._data_extensions + [".pak", ".bk2"])): status = mobase.ModDataChecker.FIXABLE else: if is_directory(entry): @@ -129,7 +129,7 @@ def dataLooksValid( if name.endswith(".exe"): return mobase.ModDataChecker.INVALID if name.endswith( - tuple(self._extensions + [".pak", ".lua", ".bk2"]) + tuple(self._data_extensions + [".pak", ".lua", ".bk2"]) ): status = mobase.ModDataChecker.FIXABLE if status == mobase.ModDataChecker.VALID: @@ -152,6 +152,14 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: "Root/OblivionRemastered/Binaries/Win64/", mobase.IFileTree.MERGE, ) + exe_dir = filetree.find(r"OblivionRemastered\Binaries\Win64") + if exe_dir is not None: + root_exe_dir = self.get_dir( + filetree, "Root/OblivionRemastered/Binaries/Win64" + ) + parent = exe_dir.parent() + exe_dir.moveTo(root_exe_dir) + self.detach_parents(parent) directories = [] for entry in filetree: if entry is not None: @@ -195,7 +203,7 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: movie_files.append(file) for movie_file in movie_files: movie_file.moveTo(movies_dir) - elif name.endswith(tuple(self._extensions)): + elif name.endswith(tuple(self._data_extensions)): data_dir = self.get_dir(filetree, "Data") data_files: list[mobase.FileTreeEntry] = [] for file in entry.parent(): @@ -237,7 +245,7 @@ def parse_directory( for entry in next_dir: if not is_directory(entry): name = entry.name().casefold() - if name.endswith(tuple(self._extensions)): + if name.endswith(tuple(self._data_extensions)): data_dir = self.get_dir(main_filetree, "Data") data_dir.merge(next_dir) self.detach_parents(next_dir) @@ -285,17 +293,18 @@ def parse_directory( return main_filetree def detach_parents(self, directory: mobase.IFileTree) -> None: - if directory.parent() is not None: + if directory.parent() is not None and len(directory.parent()) == 1: parent = ( directory.parent() if directory.parent().parent() is not None else directory ) - while parent.parent().parent() is not None: + while parent.parent().parent() is not None and len(parent.parent()) == 1: parent = parent.parent() parent.detach() else: - directory.detach() + if len(directory) == 1: + directory.detach() def get_dir(self, filetree: mobase.IFileTree, directory: str) -> mobase.IFileTree: tree_dir = filetree.find(directory) @@ -560,8 +569,12 @@ def executables(self): mobase.ExecutableInfo( "Oblivion Remastered", QFileInfo( - self.gameDirectory(), - self.binaryName(), + QDir( + self.gameDirectory().absoluteFilePath( + "OblivionRemastered/Binaries/Win64" + ) + ), + "OblivionRemastered-Win64-Shipping.exe", ), ), mobase.ExecutableInfo( From 2f379c92027e47b0ea39615bdbc5d9a10836425a Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Mon, 12 May 2025 03:49:26 -0500 Subject: [PATCH 19/32] Updates - Re-add UE4SS mod directory - Add game content feature - Add diagnosis for UE4SS loader DLL --- games/game_oblivion_remaster.py | 182 +++++++++++++++++++++++++++----- 1 file changed, 154 insertions(+), 28 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index f04bcf7e..610c1deb 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -1,11 +1,11 @@ import os.path import shutil import winreg +from enum import IntEnum, auto from functools import cmp_to_key from pathlib import Path from typing import Sequence -import PyQt6.QtCore from PyQt6.QtCore import ( QByteArray, QCoreApplication, @@ -17,6 +17,7 @@ QStringConverter, QStringEncoder, qCritical, + qWarning, ) import mobase @@ -54,8 +55,23 @@ def getLootPath() -> Path | None: return None +class Content(IntEnum): + PLUGIN = auto() + BSA = auto() + PAK = auto() + OBSE = auto() + OBSE_FILES = auto() + MOVIE = auto() + UE4SS = auto() + MAGIC_LOADER = auto() + + +class Problems(IntEnum): + UE4SS_LOADER = auto() + + class OblivionRemasteredModDataChecker(mobase.ModDataChecker): - _dirs = ["Data", "Paks", "OBSE", "Movies", "Root"] + _dirs = ["Data", "Paks", "OBSE", "Movies", "UE4SS", "Root"] _data_dirs = [ "meshes", "textures", @@ -154,12 +170,25 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: ) exe_dir = filetree.find(r"OblivionRemastered\Binaries\Win64") if exe_dir is not None: - root_exe_dir = self.get_dir( - filetree, "Root/OblivionRemastered/Binaries/Win64" - ) - parent = exe_dir.parent() - exe_dir.moveTo(root_exe_dir) - self.detach_parents(parent) + obse_dir = exe_dir.find("OBSE") + if obse_dir: + obse_main = self.get_dir(filetree, "OBSE") + obse_main.merge(obse_dir, True) + obse_dir.detach() + ue4ss_mod_dir = exe_dir.find("ue4ss/Mods") + if ue4ss_mod_dir: + ue4ss_main = self.get_dir(filetree, "UE4SS") + ue4ss_main.merge(ue4ss_mod_dir, True) + ue4ss_mod_dir.detach() + if len(exe_dir): + root_exe_dir = self.get_dir( + filetree, "Root/OblivionRemastered/Binaries" + ) + parent = exe_dir.parent() + exe_dir.moveTo(root_exe_dir) + self.detach_parents(parent) + else: + self.detach_parents(exe_dir) directories = [] for entry in filetree: if entry is not None: @@ -267,19 +296,12 @@ def parse_directory( return main_filetree elif name.endswith(".lua"): if next_dir.parent() and next_dir.parent() != main_filetree: - if ( - main_filetree.find( - "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" - ) - is None - ): - main_filetree.addDirectory( - "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" - ) + if main_filetree.find("UE4SS") is None: + main_filetree.addDirectory("UE4SS") parent = next_dir.parent().parent() main_filetree.move( next_dir.parent(), - "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods/", + "UE4SS/", mobase.IFileTree.MERGE, ) if parent is not None: @@ -313,6 +335,66 @@ def get_dir(self, filetree: mobase.IFileTree, directory: str) -> mobase.IFileTre return tree_dir +class OblivionRemasteredDataContent(mobase.ModDataContent): + OR_CONTENTS: tuple[Content, str, str, bool | None] = [ + (Content.PLUGIN, "Plugins (ESM/ESP)", ":/MO/gui/content/plugin"), + (Content.BSA, "Bethesda Archive", ":/MO/gui/content/bsa"), + (Content.PAK, "Paks", ":/MO/gui/content/geometries"), + (Content.OBSE, "Script Extender Plugin", ":/MO/gui/content/skse"), + (Content.OBSE_FILES, "Script Extender Files", "", True), + (Content.MOVIE, "Movies", ":/MO/gui/content/media"), + (Content.UE4SS, "UE4SS Mods", ":/MO/gui/content/script"), + (Content.MAGIC_LOADER, "Magic Loader Mod", ":/MO/gui/content/inifile"), + ] + + def getAllContents(self) -> list[mobase.ModDataContent.Content]: + return [mobase.ModDataContent.Content(*content) for content in self.OR_CONTENTS] + + def getContentsFor(self, filetree: mobase.IFileTree) -> list[int]: + contents: set[int] = set() + + for entry in filetree: + if is_directory(entry): + match entry.name().casefold(): + case "data": + for data_entry in entry: + if not is_directory(data_entry): + match data_entry.suffix().casefold(): + case "esm" | "esp": + contents.add(Content.PLUGIN) + case "bsa": + contents.add(Content.BSA) + case _: + pass + else: + match data_entry.name().casefold(): + case "magicloader": + contents.add(Content.MAGIC_LOADER) + case "obse": + contents.add(Content.OBSE_FILES) + plugins_dir = entry.find("Plugins") + if plugins_dir: + for plugin_entry in plugins_dir: + if plugin_entry.suffix().casefold() == "dll": + contents.add(Content.OBSE) + break + case "paks": + contents.add(Content.PAK) + for paks_entry in entry: + if is_directory(paks_entry): + if paks_entry.name().casefold() == "~mods": + if paks_entry.find("MagicLoader"): + contents.add(Content.MAGIC_LOADER) + if paks_entry.name().casefold() == "logicmods": + contents.add(Content.UE4SS) + case "movies": + contents.add(Content.MOVIE) + case "ue4ss": + contents.add(Content.UE4SS) + + return list(contents) + + class OblivionRemasteredGamePlugins(mobase.GamePlugins): def __init__(self, organizer: mobase.IOrganizer): super().__init__() @@ -396,7 +478,7 @@ def writeList( written_count += 1 if invalid_filenames: - PyQt6.QtCore.qCritical( + qCritical( QCoreApplication.translate( "MainWindow", "Some of your plugins have invalid names! These " @@ -407,7 +489,7 @@ def writeList( ) if written_count == 0: - PyQt6.QtCore.qWarning( + qWarning( "plugin list would be empty, this is almost certainly wrong. Not saving." ) else: @@ -532,7 +614,9 @@ def getArch(self) -> int: return 0x8664 if self.isInstalled() else 0x0 -class OblivionRemasteredGame(BasicGame, mobase.IPluginFileMapper): +class OblivionRemasteredGame( + BasicGame, mobase.IPluginFileMapper, mobase.IPluginDiagnose +): Name = "Oblivion Remastered Support Plugin" Author = "Silarn" Version = "0.1.0-b.1" @@ -555,6 +639,7 @@ class OblivionRemasteredGame(BasicGame, mobase.IPluginFileMapper): def __init__(self): BasicGame.__init__(self) mobase.IPluginFileMapper.__init__(self) + mobase.IPluginDiagnose.__init__(self) def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) @@ -562,6 +647,7 @@ def init(self, organizer: mobase.IOrganizer) -> bool: self._register_feature(OblivionRemasteredGamePlugins(self._organizer)) self._register_feature(OblivionRemasteredModDataChecker()) self._register_feature(OblivionRemasteredScriptExtender(self)) + self._register_feature(OblivionRemasteredDataContent()) return True def executables(self): @@ -621,17 +707,16 @@ def paksDirectory(self) -> QDir: self.gameDirectory().absolutePath() + "/OblivionRemastered/Content/Paks" ) - def obseDirectory(self) -> QDir: + def exeDirectory(self) -> QDir: return QDir( - self.gameDirectory().absolutePath() - + "/OblivionRemastered/Binaries/Win64/OBSE" + self.gameDirectory().absolutePath() + "/OblivionRemastered/Binaries/Win64" ) + def obseDirectory(self) -> QDir: + return QDir(self.exeDirectory().absolutePath() + "/OBSE") + def ue4ssDirectory(self) -> QDir: - return QDir( - self.gameDirectory().absolutePath() - + "/OblivionRemastered/Binaries/Win64/ue4ss/Mods" - ) + return QDir(self.exeDirectory().absolutePath() + "/ue4ss/Mods") def loadOrderMechanism(self) -> mobase.LoadOrderMechanism: return mobase.LoadOrderMechanism.PLUGINS_TXT @@ -699,4 +784,45 @@ def getModMappings(self) -> dict[str, list[str]]: "Paks": [self.paksDirectory().absolutePath()], "OBSE": [self.obseDirectory().absolutePath()], "Movies": [self.moviesDirectory().absolutePath()], + "UE4SS": [self.ue4ssDirectory().absolutePath()], } + + def activeProblems(self) -> list[int]: + if self._organizer.managedGame() == self: + problems: set[Problems] = set() + ue4ss_loader = QFileInfo(self.exeDirectory().absoluteFilePath("dwmapi.dll")) + if ue4ss_loader.exists(): + problems.add(Problems.UE4SS_LOADER) + 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, the DLL should be renamed (ex. 'ue4ss_loader.dll') and set to force load with the game exe.\n\n" + + "Do this for any executable which runs the game, such as the OBSE64 loader." + ) + return "" + + def hasGuidedFix(self, key: int) -> bool: + match key: + case Problems.UE4SS_LOADER: + return True + return False + + def shortDescription(self, key: int) -> str: + match key: + case Problems.UE4SS_LOADER: + return "The UE4SS loader DLL is present (dwmapi.dll)." + 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"), + ) + pass From 3c5ca96eebf06273f59ac79e3d07920efb47cb41 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Thu, 15 May 2025 03:45:42 -0500 Subject: [PATCH 20/32] Implement UE4SS mod management tab --- games/game_oblivion_remaster.py | 343 +++++++++++++++++++++++++++++++- 1 file changed, 340 insertions(+), 3 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 610c1deb..7045eb40 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -1,10 +1,11 @@ +import json import os.path import shutil import winreg from enum import IntEnum, auto from functools import cmp_to_key from pathlib import Path -from typing import Sequence +from typing import Any, Sequence from PyQt6.QtCore import ( QByteArray, @@ -13,12 +14,26 @@ QDir, QFile, QFileInfo, + QMimeData, + QModelIndex, QStandardPaths, QStringConverter, QStringEncoder, + QStringListModel, + Qt, + pyqtSignal, qCritical, qWarning, ) +from PyQt6.QtGui import QDropEvent +from PyQt6.QtWidgets import ( + QAbstractItemView, + QGridLayout, + QListView, + QMainWindow, + QTabWidget, + QWidget, +) import mobase @@ -26,6 +41,8 @@ from ..basic_features.utils import is_directory from ..basic_game import BasicGame +DEFAULT_UE4SS_MODS = ["BPML_GenericFunctions", "BPModLoaderMod"] + def getLootPath() -> Path | None: paths = [ @@ -70,6 +87,275 @@ class Problems(IntEnum): UE4SS_LOADER = auto() +class UE4SSListModel(QStringListModel): + def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): + super().__init__(parent) + self._checked_items: set[str] = set() + self._organizer = organizer + self._init_mod_states() + + def _init_mod_states(self): + profile = QDir(self._organizer.profilePath()) + mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) + if mods_json.exists(): + with open(mods_json.absoluteFilePath(), "r") as json_file: + mod_data = json.load(json_file) + for mod in mod_data: + if mod["mod_enabled"]: + self._checked_items.add(mod["mod_name"]) + + def _set_mod_states(self): + profile = QDir(self._organizer.profilePath()) + mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) + mod_list: dict[str, bool] = {} + if mods_json.exists(): + with open(mods_json.absoluteFilePath(), "r") as json_file: + mod_data = json.load(json_file) + for mod in mod_data: + mod_list[mod["mod_name"]] = mod["mod_enabled"] + for i in range(self.rowCount()): + item = self.index(i, 0) + name = self.data(item, Qt.ItemDataRole.DisplayRole) + if name in mod_list: + self.setData( + item, + True if mod_list[name] else False, + Qt.ItemDataRole.CheckStateRole, + ) + else: + self.setData(item, True, Qt.ItemDataRole.CheckStateRole) + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + flags = super().flags(index) + if not index.isValid(): + return ( + Qt.ItemFlag.ItemIsSelectable + | Qt.ItemFlag.ItemIsDragEnabled + | Qt.ItemFlag.ItemIsDropEnabled + | Qt.ItemFlag.ItemIsEnabled + ) + return ( + flags + | Qt.ItemFlag.ItemIsUserCheckable + | Qt.ItemFlag.ItemIsDragEnabled & Qt.ItemFlag.ItemIsEditable + ) + + def setData(self, index: QModelIndex, value: Any, role: int = ...) -> bool: + if not index.isValid() or role != Qt.ItemDataRole.CheckStateRole: + return False + + if ( + bool(value) + and self.data(index, Qt.ItemDataRole.DisplayRole) not in self._checked_items + ): + self._checked_items.add(self.data(index, Qt.ItemDataRole.DisplayRole)) + elif ( + not bool(value) + and self.data(index, Qt.ItemDataRole.DisplayRole) in self._checked_items + ): + self._checked_items.remove(self.data(index, Qt.ItemDataRole.DisplayRole)) + self.dataChanged.emit(index, index, [role]) + return True + + def setStringList(self, strings: list[str]): + super().setStringList(strings) + self._set_mod_states() + + def data(self, index: QModelIndex, role: int = ...) -> Any: + if not index.isValid(): + return None + + if role == Qt.ItemDataRole.CheckStateRole: + return ( + Qt.CheckState.Checked + if self.data(index, Qt.ItemDataRole.DisplayRole) in self._checked_items + else Qt.CheckState.Unchecked + ) + + return super().data(index, role) + + def canDropMimeData( + self, + data: QMimeData | None, + action: Qt.DropAction, + row: int, + column: int, + parent: QModelIndex, + ) -> bool: + if action == Qt.DropAction.MoveAction and (row != -1 or column != -1): + return True + return False + + +class UE4SSView(QListView): + data_dropped = pyqtSignal() + + def __init__(self, parent: QWidget | None): + super().__init__(parent) + self.setAcceptDrops(True) + self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDropIndicatorShown(False) + self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.setDefaultDropAction(Qt.DropAction.MoveAction) + self.viewport().setAcceptDrops(True) + + def dropEvent(self, e: QDropEvent | None): + super().dropEvent(e) + self.data_dropped.emit() + + def dataChanged(self, topLeft, bottomRight, roles=...): + super().dataChanged(topLeft, bottomRight, roles) + self.repaint() + + +class UE4SSTabWidget(QWidget): + def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): + super().__init__(parent) + self._organizer = organizer + self._view = UE4SSView(self) + self._layout = QGridLayout(self) + self._layout.addWidget(self._view) + self._model = UE4SSListModel(self._view, organizer) + self._view.setModel(self._model) + self._model.dataChanged.connect(self.write_mod_list) + self._view.data_dropped.connect(self.write_mod_list) + self._parse_mod_files() + + def update_mod_files(self, state: dict[str, mobase.ModState]): + for mod, state in state.items(): + tree = self._organizer.modList().getMod(mod).fileTree() + ue4ss_files = tree.find("UE4SS") + if not ue4ss_files: + ue4ss_files = tree.find( + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) + if ue4ss_files: + for entry in ue4ss_files: + if entry.isDir(): + if entry.find("enabled.txt"): + enabled_txt: mobase.FileTreeEntry = entry.find( + "enabled.txt" + ) + try: + os.remove( + self._organizer.modList().getMod(mod).absolutePath() + + "/" + + enabled_txt.path("/") + ) + self._organizer.modDataChanged( + self._organizer.modList().getMod(mod) + ) + except FileNotFoundError: + pass + + self._parse_mod_files() + + def _parse_mod_files(self): + mod_list = set() + for mod in self._organizer.modList().allMods(): + if ( + mobase.ModState(self._organizer.modList().state(mod)) + & mobase.ModState.ACTIVE + ): + tree = self._organizer.modList().getMod(mod).fileTree() + ue4ss_files = tree.find("UE4SS") + if not ue4ss_files: + ue4ss_files = tree.find( + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) + if ue4ss_files: + for entry in ue4ss_files: + if entry.isDir(): + if entry.find("scripts/main.lua"): + mod_list.add(entry.name()) + if entry.find("enabled.txt"): + enabled_txt: mobase.FileTreeEntry = entry.find( + "enabled.txt" + ) + try: + os.remove( + self._organizer.modList() + .getMod(mod) + .absolutePath() + + "/" + + enabled_txt.path("/") + ) + self._organizer.modDataChanged( + self._organizer.modList().getMod(mod) + ) + except FileNotFoundError: + pass + + game = self._organizer.managedGame() # type: OblivionRemasteredGame + if type(game) is OblivionRemasteredGame: + if game.ue4ssDirectory().exists(): + for dir_info in game.ue4ssDirectory().entryInfoList( + QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot + ): + if QFileInfo( + QDir(dir_info.absoluteFilePath()).absoluteFilePath( + "scripts/main.lua" + ) + ).exists(): + mod_list.add(dir_info.fileName()) + if QFileInfo( + QDir(dir_info.absoluteFilePath()).absoluteFilePath( + "enabled.txt" + ) + ).exists(): + os.remove(dir_info.dir().absoluteFilePath("enabled.txt")) + final_list = sorted(mod_list, key=cmp_to_key(self.sort_mods)) + self._model.setStringList(final_list) + + def write_mod_list(self): + mod_list: list[dict] = [] + profile = QDir(self._organizer.profilePath()) + mods_txt = QFileInfo(profile.absoluteFilePath("mods.txt")) + with open(mods_txt.absoluteFilePath(), "w") as txt_file: + for i in range(self._model.rowCount()): + item = self._model.index(i, 0) + name = self._model.data(item, Qt.ItemDataRole.DisplayRole) + active = ( + True + if self._model.data(item, Qt.ItemDataRole.CheckStateRole) + == Qt.CheckState.Checked + else False + ) + mod_list.append({"mod_name": name, "mod_enabled": active}) + txt_file.write(f"{name} : {1 if active else 0}\n") + mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) + with open(mods_json.absoluteFilePath(), "w") as json_file: + json_file.write(json.dumps(mod_list, indent=4)) + + def sort_mods(self, mod_a: str, mod_b: str) -> int: + profile = QDir(self._organizer.profilePath()) + mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) + mods_list = [] + if mods_json.exists() and mods_json.isFile(): + with open(mods_json.absoluteFilePath(), "r") as json_file: + mods = json.load(json_file) + for mod in mods: + if mod["mod_enabled"]: + mods_list.append(mod["mod_name"]) + index_a = -1 + if mod_a in mods_list: + index_a = mods_list.index(mod_a) + index_b = -1 + if mod_b in mods_list: + index_b = mods_list.index(mod_b) + if index_a != -1 and index_b != -1: + return index_a - index_b + if index_a != -1: + return -1 + if index_b != -1: + return 1 + if mod_a < mod_b: + return -1 + return 1 + + class OblivionRemasteredModDataChecker(mobase.ModDataChecker): _dirs = ["Data", "Paks", "OBSE", "Movies", "UE4SS", "Root"] _data_dirs = [ @@ -255,7 +541,14 @@ def parse_directory( for dir_name in self._dirs: if name == dir_name.lower(): main_dir = self.get_dir(main_filetree, dir_name) - main_dir.merge(directory) + if name == "ue4ss": + mod_dir = directory.find("Mods") + if mod_dir: + main_dir.merge(mod_dir) + else: + main_dir.merge(directory) + else: + main_dir.merge(directory) self.detach_parents(directory) stop = True break @@ -640,6 +933,8 @@ def __init__(self): BasicGame.__init__(self) mobase.IPluginFileMapper.__init__(self) mobase.IPluginDiagnose.__init__(self) + self._main_window = None + self._ue4ss_tab = None def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) @@ -648,8 +943,28 @@ def init(self, organizer: mobase.IOrganizer) -> bool: self._register_feature(OblivionRemasteredModDataChecker()) self._register_feature(OblivionRemasteredScriptExtender(self)) self._register_feature(OblivionRemasteredDataContent()) + + organizer.onUserInterfaceInitialized(self.init_tab) return True + def init_tab(self, main_window: QMainWindow): + 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) + self._organizer.modList().onModStateChanged(self._ue4ss_tab.update_mod_files) + + plugin_tab = tab_widget.findChild(QWidget, "espTab") + tab_index = tab_widget.indexOf(plugin_tab) + 1 + if not tab_widget.isTabVisible(tab_widget.indexOf(plugin_tab)): + tab_index += 1 + tab_widget.insertTab(tab_index, self._ue4ss_tab, "UE4SS Mods") + def executables(self): return [ mobase.ExecutableInfo( @@ -751,7 +1066,7 @@ def initializeProfile( ) else: Path(profile_ini).touch() - + self.write_default_mods(directory) if ( self._organizer.managedGame() and self._organizer.managedGame().gameName() == self.gameName() @@ -763,6 +1078,20 @@ def initializeProfile( if not self.ue4ssDirectory().exists(): os.makedirs(self.ue4ssDirectory().absolutePath()) + def write_default_mods(self, profile: QDir): + 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} : 1\n") + if not ue4ss_mods_json.exists(): + mods_data = [] + for mod in DEFAULT_UE4SS_MODS: + mods_data.append({"mod_name": mod, "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"] @@ -776,6 +1105,14 @@ def mappings(self) -> list[mobase.Mapping]: 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]]: From e7d5fdcb20d5b11fb7b7c7166af1959728065360 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Tue, 20 May 2025 16:02:40 -0500 Subject: [PATCH 21/32] Paks management and more --- games/game_oblivion_remaster.py | 702 ++++++++++++++++++++++++++++++-- 1 file changed, 670 insertions(+), 32 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 7045eb40..47d344c0 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -1,6 +1,8 @@ import json import os.path +import re import shutil +import typing import winreg from enum import IntEnum, auto from functools import cmp_to_key @@ -8,8 +10,10 @@ from typing import Any, Sequence from PyQt6.QtCore import ( + QAbstractItemModel, QByteArray, QCoreApplication, + QDataStream, QDateTime, QDir, QFile, @@ -21,8 +25,10 @@ QStringEncoder, QStringListModel, Qt, + QVariant, pyqtSignal, qCritical, + qDebug, qWarning, ) from PyQt6.QtGui import QDropEvent @@ -32,6 +38,7 @@ QListView, QMainWindow, QTabWidget, + QTreeView, QWidget, ) @@ -41,6 +48,7 @@ from ..basic_features.utils import is_directory from ..basic_game import BasicGame +PLUGIN_NAME = "Oblivion Remastered Support Plugin" DEFAULT_UE4SS_MODS = ["BPML_GenericFunctions", "BPModLoaderMod"] @@ -72,6 +80,24 @@ def getLootPath() -> Path | None: return None +def pak_sort(a: tuple[str, str], b: tuple[str, str]) -> int: + a_pak = a[0] + b_pak = b[0] + a_str = a[1] if a[1] else a[0] + b_str = b[1] if b[1] else b[0] + if (a_pak.casefold()[-2:] == "_p" and b_pak.casefold()[-2:] == "_p") or ( + a_pak.casefold()[-2:] != "_p" and b_pak.casefold()[-2:] != "_p" + ): + if sorted((a_str.casefold(), b_str.casefold()))[0] == a_str.casefold(): + return 1 + return -1 + if a_pak.casefold()[-2:] == "_p": + return 1 + if b_pak.casefold()[-2:] == "_p": + return -1 + return 0 + + class Content(IntEnum): PLUGIN = auto() BSA = auto() @@ -85,6 +111,443 @@ class Content(IntEnum): class Problems(IntEnum): UE4SS_LOADER = auto() + INVALID_UE4SS_MOD_NAME = auto() + + +class PaksColumns(IntEnum): + PRIORITY = auto() + PAK_NAME = auto() + SOURCE = auto() + + +class PaksModel(QAbstractItemModel): + def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): + super().__init__(parent) + self.paks: dict[int, tuple[str, str, str, str]] = {} + self._organizer = organizer + self._init_mod_states() + + def _init_mod_states(self): + profile = QDir(self._organizer.profilePath()) + paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt")) + if paks_txt.exists(): + with open(paks_txt.absoluteFilePath(), "r") as paks_file: + index = 0 + for line in paks_file: + self.paks[index] = (line, "", "", "") + index += 1 + + def set_paks(self, paks: dict[int, tuple[str, str, str, str]]): + self.layoutAboutToBeChanged.emit() + self.paks = paks + self.layoutChanged.emit() + self.dataChanged.emit( + self.index(0, 0), + self.index(self.rowCount(), self.columnCount()), + [Qt.ItemDataRole.DisplayRole], + ) + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + if not index.isValid(): + return ( + Qt.ItemFlag.ItemIsSelectable + | Qt.ItemFlag.ItemIsDragEnabled + | Qt.ItemFlag.ItemIsDropEnabled + | Qt.ItemFlag.ItemIsEnabled + ) + return ( + super().flags(index) + | Qt.ItemFlag.ItemIsDragEnabled + | Qt.ItemFlag.ItemIsDropEnabled & Qt.ItemFlag.ItemIsEditable + ) + + def columnCount(self, parent: QModelIndex = ...) -> int: + return len(PaksColumns) + + def index(self, row: int, column: int, parent: QModelIndex = ...) -> QModelIndex: + if ( + row < 0 + or row >= self.rowCount() + or column < 0 + or column >= self.columnCount() + ): + return QModelIndex() + return self.createIndex(row, column, row) + + def parent(self, child: QModelIndex) -> QModelIndex: + return QModelIndex() + + def rowCount(self, parent: QModelIndex = ...) -> int: + return len(self.paks) + + def setData(self, index: QModelIndex, value: Any, role: int = ...) -> bool: + return False + + def headerData(self, section: int, orientation, role=...) -> typing.Any: + if ( + orientation != Qt.Orientation.Horizontal + or role != Qt.ItemDataRole.DisplayRole + ): + return QVariant() + + column = PaksColumns(section + 1) + match column: + case PaksColumns.PAK_NAME: + return "Pak Group" + case PaksColumns.PRIORITY: + return "Priority" + case PaksColumns.SOURCE: + return "Source" + + return QVariant() + + def data(self, index: QModelIndex, role: int = ...) -> Any: + if not index.isValid(): + return None + if index.column() + 1 == PaksColumns.PAK_NAME: + if role == Qt.ItemDataRole.DisplayRole: + return self.paks[index.row()][0] + elif index.column() + 1 == PaksColumns.PRIORITY: + if role == Qt.ItemDataRole.DisplayRole: + return index.row() + elif index.column() + 1 == PaksColumns.SOURCE: + if role == Qt.ItemDataRole.DisplayRole: + return self.paks[index.row()][1] + return QVariant() + + def canDropMimeData( + self, + data: QMimeData | None, + action: Qt.DropAction, + row: int, + column: int, + parent: QModelIndex, + ) -> bool: + if action == Qt.DropAction.MoveAction and (row != -1 or column != -1): + return True + return False + + def supportedDropActions(self) -> Qt.DropAction: + return Qt.DropAction.MoveAction + + def dropMimeData( + self, + data: QMimeData | None, + action: Qt.DropAction, + row: int, + column: int, + parent: QModelIndex, + ) -> bool: + if action == Qt.DropAction.IgnoreAction: + return True + + encoded: QByteArray = data.data("application/x-qabstractitemmodeldatalist") + stream: QDataStream = QDataStream(encoded, QDataStream.OpenModeFlag.ReadOnly) + source_rows: list[int] = [] + + while not stream.atEnd(): + source_row = stream.readInt() + col = stream.readInt() + size = stream.readInt() + item_data = {} + for _ in range(size): + role = stream.readInt() + value = stream.readQVariant() + item_data[role] = value + if col == 0: + source_rows.append(source_row) + + if row == -1: + row = parent.row() + + if row < 0 or row >= len(self.paks): + new_priority = len(self.paks) + else: + new_priority = row + + new_paks = {} + before_paks = [] + moved_paks = [] + after_paks = [] + before_paks_p = [] + moved_paks_p = [] + after_paks_p = [] + for row, paks in sorted(self.paks.items()): + if row < new_priority: + if row in source_rows: + if paks[0].casefold()[-2:] == "_p": + moved_paks_p.append(paks) + else: + moved_paks.append(paks) + else: + if paks[0].casefold()[-2:] == "_p": + before_paks_p.append(paks) + else: + before_paks.append(paks) + if row >= new_priority: + if row in source_rows: + if paks[0].casefold()[-2:] == "_p": + moved_paks_p.append(paks) + else: + moved_paks.append(paks) + else: + if paks[0].casefold()[-2:] == "_p": + after_paks_p.append(paks) + else: + after_paks.append(paks) + i = 0 + for pak in before_paks: + new_paks[i] = pak + i += 1 + for pak in moved_paks: + new_paks[i] = pak + i += 1 + for pak in after_paks: + new_paks[i] = pak + i += 1 + for pak in before_paks_p: + new_paks[i] = pak + i += 1 + for pak in moved_paks_p: + new_paks[i] = pak + i += 1 + for pak in after_paks_p: + new_paks[i] = pak + i += 1 + index = 9999 + for row, pak in new_paks.items(): + current_dir = QDir(pak[2]) + parent_dir = QDir(pak[2]) + parent_dir.cdUp() + if current_dir.exists() and parent_dir.dirName().casefold() == "~mods": + new_paks[row] = ( + pak[0], + pak[1], + pak[2], + parent_dir.absoluteFilePath(str(index).zfill(4)), + ) + index -= 1 + + self.set_paks(new_paks) + return False + + +class PaksView(QTreeView): + data_dropped = pyqtSignal() + + def __init__(self, parent: QWidget | None): + super().__init__(parent) + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.setDefaultDropAction(Qt.DropAction.MoveAction) + self.viewport().setAcceptDrops(True) + self.setItemsExpandable(False) + self.setRootIsDecorated(False) + + def dropEvent(self, e: QDropEvent | None): + super().dropEvent(e) + self.clearSelection() + self.data_dropped.emit() + + def dataChanged(self, topLeft, bottomRight, roles=...): + super().dataChanged(topLeft, bottomRight, roles) + self.repaint() + + +class PaksTabWidget(QWidget): + def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): + super().__init__(parent) + self._organizer = organizer + self._view = PaksView(self) + self._layout = QGridLayout(self) + self._layout.addWidget(self._view) + self._model = PaksModel(self._view, organizer) + self._view.setModel(self._model) + self._model.dataChanged.connect(self.write_paks_list) + self._view.data_dropped.connect(self.write_paks_list) + organizer.onProfileChanged(lambda profile_a, profile_b: self._parse_pak_files()) + organizer.modList().onModInstalled(lambda mod: self._parse_pak_files()) + organizer.modList().onModRemoved(lambda mod: self._parse_pak_files()) + organizer.modList().onModStateChanged(lambda mods: self._parse_pak_files()) + self._parse_pak_files() + + def write_paks_list(self): + profile = QDir(self._organizer.profilePath()) + paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt")) + with open(paks_txt.absoluteFilePath(), "w") as paks_file: + for _, pak in sorted(self._model.paks.items()): + name, _, _, _ = pak + paks_file.write(f"{name}\n") + self.write_pak_files() + + def write_pak_files(self): + for index, pak in sorted(self._model.paks.items()): + name, source, current_path, target_path = pak + if current_path and current_path != target_path: + path_dir = QDir(current_path) + target_dir = QDir(target_path) + if not target_dir.exists(): + os.mkdir(target_dir.absolutePath()) + if path_dir.exists(): + for entry in path_dir.entryInfoList(QDir.Filter.Files): + if entry.suffix().casefold() == "pak": + match = re.match(r"^(\d{4}_)?(.*)", entry.baseName()) + match_name = ( + match.group(2) if match.group(2) else match.group(1) + ) + if match_name == name: + pak_file = Path(entry.absoluteFilePath()) + ucas_file = Path( + entry.absolutePath() + + "/" + + entry.baseName() + + "." + + "ucas" + ) + utoc_file = Path( + entry.absolutePath() + + "/" + + entry.baseName() + + "." + + "utoc" + ) + if pak_file.exists(): + try: + os.rename( + pak_file.absolute(), + target_dir.absoluteFilePath(f"{name}.pak"), + ) + if ucas_file.exists(): + os.rename( + ucas_file.absolute(), + target_dir.absoluteFilePath( + f"{name}.ucas" + ), + ) + if utoc_file.exists(): + os.rename( + utoc_file.absolute(), + target_dir.absoluteFilePath( + f"{name}.utoc" + ), + ) + data = self._model.paks[index] + self._model.paks[index] = ( + data[0], + data[1], + data[3], + data[3], + ) + except FileExistsError: + pass + if path_dir.isEmpty(): + path_dir.removeRecursively() + + def _parse_pak_files(self): + mods = self._organizer.modList().allMods() + paks: dict[str, str] = {} + pak_paths: dict[str, tuple[str, str]] = {} + pak_source: dict[str, str] = {} + for mod in mods: + mod_item = self._organizer.modList().getMod(mod) + if not self._organizer.modList().state(mod) & mobase.ModState.ACTIVE: + continue + filetree = mod_item.fileTree() + pak_mods = filetree.find("Paks/~mods") + if not pak_mods: + pak_mods = filetree.find("Root/OblivionRemastered/Content/Paks/~mods") + if pak_mods: + for entry in pak_mods: + if is_directory(entry): + if entry.name().casefold() == "magicloader": + continue + for sub_entry in entry: + if ( + sub_entry.isFile() + and sub_entry.suffix().casefold() == "pak" + ): + paks[ + sub_entry.name()[: -1 - len(sub_entry.suffix())] + ] = entry.name() + pak_paths[ + sub_entry.name()[: -1 - len(sub_entry.suffix())] + ] = ( + mod_item.absolutePath() + + "/" + + sub_entry.parent().path("/"), + mod_item.absolutePath() + "/" + pak_mods.path("/"), + ) + pak_source[ + sub_entry.name()[: -1 - len(sub_entry.suffix())] + ] = mod_item.name() + else: + if entry.suffix().casefold() == "pak": + paks[entry.name()[: -1 - len(entry.suffix())]] = "" + pak_paths[entry.name()[: -1 - len(entry.suffix())]] = ( + mod_item.absolutePath() + + "/" + + entry.parent().path("/"), + mod_item.absolutePath() + "/" + pak_mods.path("/"), + ) + pak_source[entry.name()[: -1 - len(entry.suffix())]] = ( + mod_item.name() + ) + game = self._organizer.managedGame() # type: OblivionRemasteredGame + if type(game) is OblivionRemasteredGame: + pak_mods = QFileInfo(game.paksDirectory().absoluteFilePath("~mods")) + if pak_mods.exists() and pak_mods.isDir(): + for entry in QDir(pak_mods.absoluteFilePath()).entryInfoList( + QDir.Filter.Dirs | QDir.Filter.Files | QDir.Filter.NoDotAndDotDot + ): + if entry.isDir(): + if entry.baseName().casefold() == "magicloader": + continue + for sub_entry in QDir(entry.absoluteFilePath()).entryInfoList( + QDir.Filter.Files + ): + if ( + sub_entry.isFile() + and sub_entry.suffix().casefold() == "pak" + ): + paks[sub_entry.baseName()] = entry.baseName() + pak_paths[sub_entry.baseName()] = ( + sub_entry.absolutePath(), + pak_mods.absolutePath(), + ) + pak_source[sub_entry.baseName()] = "Game Directory" + else: + if entry.suffix().casefold() == "pak": + paks[entry.name()[: -1 - len(entry.suffix())]] = "" + pak_paths[entry.baseName()] = ( + entry.absolutePath(), + pak_mods.absolutePath(), + ) + pak_source[entry.baseName()] = "Game Directory" + sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort))) + qDebug("Sorted Paks:") + for pak, directory in sorted_paks.items(): + item = directory if directory else pak + qDebug(item) + final_paks = {} + pak_index = 9999 + for pak in sorted_paks.keys(): + match = re.match(r"^(\d{4}_)?(.*)$", pak) + name = match.group(2) if match.group(2) else match.group(1) + target_dir = pak_paths[pak][1] + "/" + str(pak_index).zfill(4) + final_paks[name] = (pak_source[pak], pak_paths[pak][0], target_dir) + pak_index -= 1 + new_data_paks: dict[int, tuple[str, str, str, str]] = {} + i = 0 + qDebug("Final Paks:") + for pak, data in final_paks.items(): + qDebug(pak) + source, current_path, target_path = data + new_data_paks[i] = (pak, source, current_path, target_path) + i += 1 + self._model.set_paks(new_data_paks) class UE4SSListModel(QStringListModel): @@ -196,7 +659,7 @@ def __init__(self, parent: QWidget | None): self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.setDragEnabled(True) self.setAcceptDrops(True) - self.setDropIndicatorShown(False) + self.setDropIndicatorShown(True) self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.setDefaultDropAction(Qt.DropAction.MoveAction) self.viewport().setAcceptDrops(True) @@ -221,11 +684,35 @@ def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): self._view.setModel(self._model) self._model.dataChanged.connect(self.write_mod_list) self._view.data_dropped.connect(self.write_mod_list) + organizer.onProfileChanged(lambda profile_a, profile_b: self._parse_mod_files()) + organizer.modList().onModInstalled(self.update_mod_files) + organizer.modList().onModRemoved(lambda mod: self._parse_mod_files()) + organizer.modList().onModStateChanged(self.update_mod_files) self._parse_mod_files() - def update_mod_files(self, state: dict[str, mobase.ModState]): - for mod, state in state.items(): - tree = self._organizer.modList().getMod(mod).fileTree() + def get_mod_list(self) -> list[str]: + mod_list = [] + for index in range(self._model.rowCount()): + mod_list.append( + self._model.data( + self._model.index(index, 0), Qt.ItemDataRole.DisplayRole + ) + ) + return mod_list + + def update_mod_files( + self, mods: dict[str, mobase.ModState] | mobase.IModInterface | str + ): + mod_list: list[mobase.IModInterface] = [] + if type(mods) is dict: + for mod in mods.keys(): + mod_list.append(self._organizer.modList().getMod(mod)) + elif type(mods) is mobase.IModInterface: + mod_list.append(mods) + else: + mod_list.append(self._organizer.modList().getMod(mods)) + for mod in mod_list: + tree = mod.fileTree() ue4ss_files = tree.find("UE4SS") if not ue4ss_files: ue4ss_files = tree.find( @@ -240,13 +727,9 @@ def update_mod_files(self, state: dict[str, mobase.ModState]): ) try: os.remove( - self._organizer.modList().getMod(mod).absolutePath() - + "/" - + enabled_txt.path("/") - ) - self._organizer.modDataChanged( - self._organizer.modList().getMod(mod) + mod.absolutePath() + "/" + enabled_txt.path("/") ) + self._organizer.modDataChanged(mod) except FileNotFoundError: pass @@ -305,7 +788,11 @@ def _parse_mod_files(self): "enabled.txt" ) ).exists(): - os.remove(dir_info.dir().absoluteFilePath("enabled.txt")) + os.remove( + QDir(dir_info.absoluteFilePath()).absoluteFilePath( + "enabled.txt" + ) + ) final_list = sorted(mod_list, key=cmp_to_key(self.sort_mods)) self._model.setStringList(final_list) @@ -371,8 +858,9 @@ class OblivionRemasteredModDataChecker(mobase.ModDataChecker): ] _data_extensions = [".esm", ".esp", ".bsa"] - def __init__(self): + def __init__(self, organizer: mobase.IOrganizer): super().__init__() + self._organizer = organizer def dataLooksValid( self, filetree: mobase.IFileTree @@ -390,8 +878,29 @@ def dataLooksValid( if entry.parent().parent() is None: if is_directory(entry): if name in [dirname.lower() for dirname in self._dirs]: - status = mobase.ModDataChecker.VALID - break + if name == "ue4ss": + if entry.find("Mods"): + for sub_entry in entry.find("Mods"): + if is_directory(sub_entry): + if sub_entry.find("scripts/main.lua"): + status = mobase.ModDataChecker.FIXABLE + break + if sub_entry.name().casefold() == "shared": + status = mobase.ModDataChecker.FIXABLE + break + else: + for sub_entry in entry: + if is_directory(sub_entry): + if sub_entry.find("scripts/main.lua"): + status = mobase.ModDataChecker.VALID + break + if sub_entry.name().casefold() == "shared": + status = mobase.ModDataChecker.VALID + break + else: + status = mobase.ModDataChecker.VALID + if status == mobase.ModDataChecker.VALID: + break elif name in [dirname.lower() for dirname in self._data_dirs]: status = mobase.ModDataChecker.FIXABLE else: @@ -463,7 +972,12 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: obse_dir.detach() ue4ss_mod_dir = exe_dir.find("ue4ss/Mods") if ue4ss_mod_dir: - ue4ss_main = self.get_dir(filetree, "UE4SS") + if self._organizer.pluginSetting(PLUGIN_NAME, "ue4ss_use_root_builder"): + ue4ss_main = self.get_dir( + filetree, "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) + else: + ue4ss_main = self.get_dir(filetree, "UE4SS") ue4ss_main.merge(ue4ss_mod_dir, True) ue4ss_mod_dir.detach() if len(exe_dir): @@ -486,6 +1000,27 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: ]: data_dir = self.get_dir(filetree, "Data") directory.moveTo(data_dir) + elif directory.name().casefold() == "ue4ss": + if directory.find("Mods"): + for sub_entry in directory.find("Mods"): + if is_directory(sub_entry): + if ( + sub_entry.find("scripts/main.lua") + or sub_entry.name().casefold() == "shared" + ): + if self._organizer.pluginSetting( + PLUGIN_NAME, "ue4ss_use_root_builder" + ): + ue4ss_main = self.get_dir( + filetree, + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods", + ) + sub_entry.moveTo(ue4ss_main) + self.detach_parents(directory) + else: + parent = sub_entry.parent() + sub_entry.moveTo(directory) + self.detach_parents(parent) elif directory.name().casefold() not in [ dirname.lower() for dirname in self._dirs ]: @@ -542,11 +1077,20 @@ def parse_directory( if name == dir_name.lower(): main_dir = self.get_dir(main_filetree, dir_name) if name == "ue4ss": - mod_dir = directory.find("Mods") - if mod_dir: - main_dir.merge(mod_dir) + if self._organizer.pluginSetting( + PLUGIN_NAME, "ue4ss_use_root_builder" + ): + ue4ss_dir = self.get_dir( + main_filetree, + "Root/OblivionRemastered/Binaries/Win64/ue4ss", + ) + ue4ss_dir.merge(directory) else: - main_dir.merge(directory) + mod_dir = directory.find("Mods") + if mod_dir: + main_dir.merge(mod_dir) + else: + main_dir.merge(directory) else: main_dir.merge(directory) self.detach_parents(directory) @@ -589,14 +1133,23 @@ def parse_directory( return main_filetree elif name.endswith(".lua"): if next_dir.parent() and next_dir.parent() != main_filetree: - if main_filetree.find("UE4SS") is None: - main_filetree.addDirectory("UE4SS") parent = next_dir.parent().parent() - main_filetree.move( - next_dir.parent(), - "UE4SS/", - mobase.IFileTree.MERGE, - ) + if self._organizer.pluginSetting( + PLUGIN_NAME, "ue4ss_use_root_builder" + ): + ue4ss_main = self.get_dir( + main_filetree, + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods", + ) + next_dir.parent().moveTo(ue4ss_main) + else: + if main_filetree.find("UE4SS") is None: + main_filetree.addDirectory("UE4SS") + main_filetree.move( + next_dir.parent(), + "UE4SS/", + mobase.IFileTree.MERGE, + ) if parent is not None: self.detach_parents(parent) return main_filetree @@ -910,7 +1463,7 @@ def getArch(self) -> int: class OblivionRemasteredGame( BasicGame, mobase.IPluginFileMapper, mobase.IPluginDiagnose ): - Name = "Oblivion Remastered Support Plugin" + Name = PLUGIN_NAME Author = "Silarn" Version = "0.1.0-b.1" Description = "TES IV: Oblivion Remastered; an unholy hybrid of Gamebryo and Unreal" @@ -928,19 +1481,24 @@ class OblivionRemasteredGame( 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" + ) def __init__(self): BasicGame.__init__(self) mobase.IPluginFileMapper.__init__(self) mobase.IPluginDiagnose.__init__(self) - self._main_window = None - self._ue4ss_tab = None + self._main_window: QMainWindow | None = None + self._ue4ss_tab: UE4SSTabWidget | None = None + self._paks_tab: PaksTabWidget | None = None def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) self._register_feature(BasicGameSaveGameInfo()) self._register_feature(OblivionRemasteredGamePlugins(self._organizer)) - self._register_feature(OblivionRemasteredModDataChecker()) + self._register_feature(OblivionRemasteredModDataChecker(self._organizer)) self._register_feature(OblivionRemasteredScriptExtender(self)) self._register_feature(OblivionRemasteredDataContent()) @@ -957,7 +1515,6 @@ def init_tab(self, main_window: QMainWindow): return self._ue4ss_tab = UE4SSTabWidget(main_window, self._organizer) - self._organizer.modList().onModStateChanged(self._ue4ss_tab.update_mod_files) plugin_tab = tab_widget.findChild(QWidget, "espTab") tab_index = tab_widget.indexOf(plugin_tab) + 1 @@ -965,6 +1522,18 @@ def init_tab(self, main_window: QMainWindow): tab_index += 1 tab_widget.insertTab(tab_index, self._ue4ss_tab, "UE4SS Mods") + self._paks_tab = PaksTabWidget(main_window, self._organizer) + + tab_index = tab_widget.count() + 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): return [ mobase.ExecutableInfo( @@ -1130,6 +1699,10 @@ def activeProblems(self) -> list[int]: ue4ss_loader = QFileInfo(self.exeDirectory().absoluteFilePath("dwmapi.dll")) if ue4ss_loader.exists(): problems.add(Problems.UE4SS_LOADER) + for mod in self._ue4ss_tab.get_mod_list(): + if " " in mod: + problems.add(Problems.INVALID_UE4SS_MOD_NAME) + break return list(problems) return [] @@ -1138,21 +1711,31 @@ def fullDescription(self, key: int) -> str: 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, the DLL should be renamed (ex. 'ue4ss_loader.dll') and set to force load with the game exe.\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." + ) return "" def hasGuidedFix(self, key: int) -> bool: match key: case Problems.UE4SS_LOADER: return True + case Problems.INVALID_UE4SS_MOD_NAME: + return True 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." return "" def startGuidedFix(self, key: int) -> None: @@ -1162,4 +1745,59 @@ def startGuidedFix(self, key: int) -> None: 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 ue4ss_mod: + for entry in ue4ss_mod: + if is_directory(entry) and entry.find("scripts/main.lua"): + if " " in entry.name(): + mod_dir = QDir( + self._organizer.modList() + .getMod(mod) + .absolutePath() + ) + mod_path = mod_dir.absoluteFilePath(entry.path("/")) + fixed_path = ( + mod_dir.absoluteFilePath( + entry.parent().path("/") + ) + + "/" + + entry.name().replace(" ", "") + ) + try: + os.rename(mod_path, 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 + pass From 6447d3c62487cd9aadad39f6c140a625678e1a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mika=C3=ABl=20Capelle?= Date: Wed, 21 May 2025 21:22:48 +0200 Subject: [PATCH 22/32] Fix linting issues. --- games/game_oblivion_remaster.py | 1499 +------------------ games/oblivion_remaster/__init__.py | 0 games/oblivion_remaster/constants.py | 1 + games/oblivion_remaster/game_plugins.py | 201 +++ games/oblivion_remaster/mod_data_checker.py | 357 +++++ games/oblivion_remaster/mod_data_content.py | 81 + games/oblivion_remaster/paks/__init__.py | 0 games/oblivion_remaster/paks/model.py | 253 ++++ games/oblivion_remaster/paks/view.py | 33 + games/oblivion_remaster/paks/widget.py | 206 +++ games/oblivion_remaster/script_extender.py | 37 + games/oblivion_remaster/ue4ss/__init__.py | 0 games/oblivion_remaster/ue4ss/model.py | 116 ++ games/oblivion_remaster/ue4ss/view.py | 31 + games/oblivion_remaster/ue4ss/widget.py | 176 +++ pyproject.toml | 3 + 16 files changed, 1548 insertions(+), 1446 deletions(-) create mode 100644 games/oblivion_remaster/__init__.py create mode 100644 games/oblivion_remaster/constants.py create mode 100644 games/oblivion_remaster/game_plugins.py create mode 100644 games/oblivion_remaster/mod_data_checker.py create mode 100644 games/oblivion_remaster/mod_data_content.py create mode 100644 games/oblivion_remaster/paks/__init__.py create mode 100644 games/oblivion_remaster/paks/model.py create mode 100644 games/oblivion_remaster/paks/view.py create mode 100644 games/oblivion_remaster/paks/widget.py create mode 100644 games/oblivion_remaster/script_extender.py create mode 100644 games/oblivion_remaster/ue4ss/__init__.py create mode 100644 games/oblivion_remaster/ue4ss/model.py create mode 100644 games/oblivion_remaster/ue4ss/view.py create mode 100644 games/oblivion_remaster/ue4ss/widget.py diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 47d344c0..54587fcb 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -1,54 +1,22 @@ import json import os.path -import re import shutil -import typing import winreg from enum import IntEnum, auto -from functools import cmp_to_key from pathlib import Path -from typing import Any, Sequence +from typing import cast -from PyQt6.QtCore import ( - QAbstractItemModel, - QByteArray, - QCoreApplication, - QDataStream, - QDateTime, - QDir, - QFile, - QFileInfo, - QMimeData, - QModelIndex, - QStandardPaths, - QStringConverter, - QStringEncoder, - QStringListModel, - Qt, - QVariant, - pyqtSignal, - qCritical, - qDebug, - qWarning, -) -from PyQt6.QtGui import QDropEvent -from PyQt6.QtWidgets import ( - QAbstractItemView, - QGridLayout, - QListView, - QMainWindow, - QTabWidget, - QTreeView, - QWidget, -) +from PyQt6.QtCore import QDir, QFileInfo, QStandardPaths +from PyQt6.QtWidgets import QMainWindow, QTabWidget, QWidget import mobase from ..basic_features import BasicGameSaveGameInfo -from ..basic_features.utils import is_directory from ..basic_game import BasicGame +from .oblivion_remaster.constants import PLUGIN_NAME +from .oblivion_remaster.paks.widget import PaksTabWidget +from .oblivion_remaster.ue4ss.widget import UE4SSModInfo, UE4SSTabWidget -PLUGIN_NAME = "Oblivion Remastered Support Plugin" DEFAULT_UE4SS_MODS = ["BPML_GenericFunctions", "BPModLoaderMod"] @@ -80,1386 +48,11 @@ def getLootPath() -> Path | None: return None -def pak_sort(a: tuple[str, str], b: tuple[str, str]) -> int: - a_pak = a[0] - b_pak = b[0] - a_str = a[1] if a[1] else a[0] - b_str = b[1] if b[1] else b[0] - if (a_pak.casefold()[-2:] == "_p" and b_pak.casefold()[-2:] == "_p") or ( - a_pak.casefold()[-2:] != "_p" and b_pak.casefold()[-2:] != "_p" - ): - if sorted((a_str.casefold(), b_str.casefold()))[0] == a_str.casefold(): - return 1 - return -1 - if a_pak.casefold()[-2:] == "_p": - return 1 - if b_pak.casefold()[-2:] == "_p": - return -1 - return 0 - - -class Content(IntEnum): - PLUGIN = auto() - BSA = auto() - PAK = auto() - OBSE = auto() - OBSE_FILES = auto() - MOVIE = auto() - UE4SS = auto() - MAGIC_LOADER = auto() - - class Problems(IntEnum): UE4SS_LOADER = auto() INVALID_UE4SS_MOD_NAME = auto() -class PaksColumns(IntEnum): - PRIORITY = auto() - PAK_NAME = auto() - SOURCE = auto() - - -class PaksModel(QAbstractItemModel): - def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): - super().__init__(parent) - self.paks: dict[int, tuple[str, str, str, str]] = {} - self._organizer = organizer - self._init_mod_states() - - def _init_mod_states(self): - profile = QDir(self._organizer.profilePath()) - paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt")) - if paks_txt.exists(): - with open(paks_txt.absoluteFilePath(), "r") as paks_file: - index = 0 - for line in paks_file: - self.paks[index] = (line, "", "", "") - index += 1 - - def set_paks(self, paks: dict[int, tuple[str, str, str, str]]): - self.layoutAboutToBeChanged.emit() - self.paks = paks - self.layoutChanged.emit() - self.dataChanged.emit( - self.index(0, 0), - self.index(self.rowCount(), self.columnCount()), - [Qt.ItemDataRole.DisplayRole], - ) - - def flags(self, index: QModelIndex) -> Qt.ItemFlag: - if not index.isValid(): - return ( - Qt.ItemFlag.ItemIsSelectable - | Qt.ItemFlag.ItemIsDragEnabled - | Qt.ItemFlag.ItemIsDropEnabled - | Qt.ItemFlag.ItemIsEnabled - ) - return ( - super().flags(index) - | Qt.ItemFlag.ItemIsDragEnabled - | Qt.ItemFlag.ItemIsDropEnabled & Qt.ItemFlag.ItemIsEditable - ) - - def columnCount(self, parent: QModelIndex = ...) -> int: - return len(PaksColumns) - - def index(self, row: int, column: int, parent: QModelIndex = ...) -> QModelIndex: - if ( - row < 0 - or row >= self.rowCount() - or column < 0 - or column >= self.columnCount() - ): - return QModelIndex() - return self.createIndex(row, column, row) - - def parent(self, child: QModelIndex) -> QModelIndex: - return QModelIndex() - - def rowCount(self, parent: QModelIndex = ...) -> int: - return len(self.paks) - - def setData(self, index: QModelIndex, value: Any, role: int = ...) -> bool: - return False - - def headerData(self, section: int, orientation, role=...) -> typing.Any: - if ( - orientation != Qt.Orientation.Horizontal - or role != Qt.ItemDataRole.DisplayRole - ): - return QVariant() - - column = PaksColumns(section + 1) - match column: - case PaksColumns.PAK_NAME: - return "Pak Group" - case PaksColumns.PRIORITY: - return "Priority" - case PaksColumns.SOURCE: - return "Source" - - return QVariant() - - def data(self, index: QModelIndex, role: int = ...) -> Any: - if not index.isValid(): - return None - if index.column() + 1 == PaksColumns.PAK_NAME: - if role == Qt.ItemDataRole.DisplayRole: - return self.paks[index.row()][0] - elif index.column() + 1 == PaksColumns.PRIORITY: - if role == Qt.ItemDataRole.DisplayRole: - return index.row() - elif index.column() + 1 == PaksColumns.SOURCE: - if role == Qt.ItemDataRole.DisplayRole: - return self.paks[index.row()][1] - return QVariant() - - def canDropMimeData( - self, - data: QMimeData | None, - action: Qt.DropAction, - row: int, - column: int, - parent: QModelIndex, - ) -> bool: - if action == Qt.DropAction.MoveAction and (row != -1 or column != -1): - return True - return False - - def supportedDropActions(self) -> Qt.DropAction: - return Qt.DropAction.MoveAction - - def dropMimeData( - self, - data: QMimeData | None, - action: Qt.DropAction, - row: int, - column: int, - parent: QModelIndex, - ) -> bool: - if action == Qt.DropAction.IgnoreAction: - return True - - encoded: QByteArray = data.data("application/x-qabstractitemmodeldatalist") - stream: QDataStream = QDataStream(encoded, QDataStream.OpenModeFlag.ReadOnly) - source_rows: list[int] = [] - - while not stream.atEnd(): - source_row = stream.readInt() - col = stream.readInt() - size = stream.readInt() - item_data = {} - for _ in range(size): - role = stream.readInt() - value = stream.readQVariant() - item_data[role] = value - if col == 0: - source_rows.append(source_row) - - if row == -1: - row = parent.row() - - if row < 0 or row >= len(self.paks): - new_priority = len(self.paks) - else: - new_priority = row - - new_paks = {} - before_paks = [] - moved_paks = [] - after_paks = [] - before_paks_p = [] - moved_paks_p = [] - after_paks_p = [] - for row, paks in sorted(self.paks.items()): - if row < new_priority: - if row in source_rows: - if paks[0].casefold()[-2:] == "_p": - moved_paks_p.append(paks) - else: - moved_paks.append(paks) - else: - if paks[0].casefold()[-2:] == "_p": - before_paks_p.append(paks) - else: - before_paks.append(paks) - if row >= new_priority: - if row in source_rows: - if paks[0].casefold()[-2:] == "_p": - moved_paks_p.append(paks) - else: - moved_paks.append(paks) - else: - if paks[0].casefold()[-2:] == "_p": - after_paks_p.append(paks) - else: - after_paks.append(paks) - i = 0 - for pak in before_paks: - new_paks[i] = pak - i += 1 - for pak in moved_paks: - new_paks[i] = pak - i += 1 - for pak in after_paks: - new_paks[i] = pak - i += 1 - for pak in before_paks_p: - new_paks[i] = pak - i += 1 - for pak in moved_paks_p: - new_paks[i] = pak - i += 1 - for pak in after_paks_p: - new_paks[i] = pak - i += 1 - index = 9999 - for row, pak in new_paks.items(): - current_dir = QDir(pak[2]) - parent_dir = QDir(pak[2]) - parent_dir.cdUp() - if current_dir.exists() and parent_dir.dirName().casefold() == "~mods": - new_paks[row] = ( - pak[0], - pak[1], - pak[2], - parent_dir.absoluteFilePath(str(index).zfill(4)), - ) - index -= 1 - - self.set_paks(new_paks) - return False - - -class PaksView(QTreeView): - data_dropped = pyqtSignal() - - def __init__(self, parent: QWidget | None): - super().__init__(parent) - self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - self.setDragEnabled(True) - self.setAcceptDrops(True) - self.setDropIndicatorShown(True) - self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) - self.setDefaultDropAction(Qt.DropAction.MoveAction) - self.viewport().setAcceptDrops(True) - self.setItemsExpandable(False) - self.setRootIsDecorated(False) - - def dropEvent(self, e: QDropEvent | None): - super().dropEvent(e) - self.clearSelection() - self.data_dropped.emit() - - def dataChanged(self, topLeft, bottomRight, roles=...): - super().dataChanged(topLeft, bottomRight, roles) - self.repaint() - - -class PaksTabWidget(QWidget): - def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): - super().__init__(parent) - self._organizer = organizer - self._view = PaksView(self) - self._layout = QGridLayout(self) - self._layout.addWidget(self._view) - self._model = PaksModel(self._view, organizer) - self._view.setModel(self._model) - self._model.dataChanged.connect(self.write_paks_list) - self._view.data_dropped.connect(self.write_paks_list) - organizer.onProfileChanged(lambda profile_a, profile_b: self._parse_pak_files()) - organizer.modList().onModInstalled(lambda mod: self._parse_pak_files()) - organizer.modList().onModRemoved(lambda mod: self._parse_pak_files()) - organizer.modList().onModStateChanged(lambda mods: self._parse_pak_files()) - self._parse_pak_files() - - def write_paks_list(self): - profile = QDir(self._organizer.profilePath()) - paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt")) - with open(paks_txt.absoluteFilePath(), "w") as paks_file: - for _, pak in sorted(self._model.paks.items()): - name, _, _, _ = pak - paks_file.write(f"{name}\n") - self.write_pak_files() - - def write_pak_files(self): - for index, pak in sorted(self._model.paks.items()): - name, source, current_path, target_path = pak - if current_path and current_path != target_path: - path_dir = QDir(current_path) - target_dir = QDir(target_path) - if not target_dir.exists(): - os.mkdir(target_dir.absolutePath()) - if path_dir.exists(): - for entry in path_dir.entryInfoList(QDir.Filter.Files): - if entry.suffix().casefold() == "pak": - match = re.match(r"^(\d{4}_)?(.*)", entry.baseName()) - match_name = ( - match.group(2) if match.group(2) else match.group(1) - ) - if match_name == name: - pak_file = Path(entry.absoluteFilePath()) - ucas_file = Path( - entry.absolutePath() - + "/" - + entry.baseName() - + "." - + "ucas" - ) - utoc_file = Path( - entry.absolutePath() - + "/" - + entry.baseName() - + "." - + "utoc" - ) - if pak_file.exists(): - try: - os.rename( - pak_file.absolute(), - target_dir.absoluteFilePath(f"{name}.pak"), - ) - if ucas_file.exists(): - os.rename( - ucas_file.absolute(), - target_dir.absoluteFilePath( - f"{name}.ucas" - ), - ) - if utoc_file.exists(): - os.rename( - utoc_file.absolute(), - target_dir.absoluteFilePath( - f"{name}.utoc" - ), - ) - data = self._model.paks[index] - self._model.paks[index] = ( - data[0], - data[1], - data[3], - data[3], - ) - except FileExistsError: - pass - if path_dir.isEmpty(): - path_dir.removeRecursively() - - def _parse_pak_files(self): - mods = self._organizer.modList().allMods() - paks: dict[str, str] = {} - pak_paths: dict[str, tuple[str, str]] = {} - pak_source: dict[str, str] = {} - for mod in mods: - mod_item = self._organizer.modList().getMod(mod) - if not self._organizer.modList().state(mod) & mobase.ModState.ACTIVE: - continue - filetree = mod_item.fileTree() - pak_mods = filetree.find("Paks/~mods") - if not pak_mods: - pak_mods = filetree.find("Root/OblivionRemastered/Content/Paks/~mods") - if pak_mods: - for entry in pak_mods: - if is_directory(entry): - if entry.name().casefold() == "magicloader": - continue - for sub_entry in entry: - if ( - sub_entry.isFile() - and sub_entry.suffix().casefold() == "pak" - ): - paks[ - sub_entry.name()[: -1 - len(sub_entry.suffix())] - ] = entry.name() - pak_paths[ - sub_entry.name()[: -1 - len(sub_entry.suffix())] - ] = ( - mod_item.absolutePath() - + "/" - + sub_entry.parent().path("/"), - mod_item.absolutePath() + "/" + pak_mods.path("/"), - ) - pak_source[ - sub_entry.name()[: -1 - len(sub_entry.suffix())] - ] = mod_item.name() - else: - if entry.suffix().casefold() == "pak": - paks[entry.name()[: -1 - len(entry.suffix())]] = "" - pak_paths[entry.name()[: -1 - len(entry.suffix())]] = ( - mod_item.absolutePath() - + "/" - + entry.parent().path("/"), - mod_item.absolutePath() + "/" + pak_mods.path("/"), - ) - pak_source[entry.name()[: -1 - len(entry.suffix())]] = ( - mod_item.name() - ) - game = self._organizer.managedGame() # type: OblivionRemasteredGame - if type(game) is OblivionRemasteredGame: - pak_mods = QFileInfo(game.paksDirectory().absoluteFilePath("~mods")) - if pak_mods.exists() and pak_mods.isDir(): - for entry in QDir(pak_mods.absoluteFilePath()).entryInfoList( - QDir.Filter.Dirs | QDir.Filter.Files | QDir.Filter.NoDotAndDotDot - ): - if entry.isDir(): - if entry.baseName().casefold() == "magicloader": - continue - for sub_entry in QDir(entry.absoluteFilePath()).entryInfoList( - QDir.Filter.Files - ): - if ( - sub_entry.isFile() - and sub_entry.suffix().casefold() == "pak" - ): - paks[sub_entry.baseName()] = entry.baseName() - pak_paths[sub_entry.baseName()] = ( - sub_entry.absolutePath(), - pak_mods.absolutePath(), - ) - pak_source[sub_entry.baseName()] = "Game Directory" - else: - if entry.suffix().casefold() == "pak": - paks[entry.name()[: -1 - len(entry.suffix())]] = "" - pak_paths[entry.baseName()] = ( - entry.absolutePath(), - pak_mods.absolutePath(), - ) - pak_source[entry.baseName()] = "Game Directory" - sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort))) - qDebug("Sorted Paks:") - for pak, directory in sorted_paks.items(): - item = directory if directory else pak - qDebug(item) - final_paks = {} - pak_index = 9999 - for pak in sorted_paks.keys(): - match = re.match(r"^(\d{4}_)?(.*)$", pak) - name = match.group(2) if match.group(2) else match.group(1) - target_dir = pak_paths[pak][1] + "/" + str(pak_index).zfill(4) - final_paks[name] = (pak_source[pak], pak_paths[pak][0], target_dir) - pak_index -= 1 - new_data_paks: dict[int, tuple[str, str, str, str]] = {} - i = 0 - qDebug("Final Paks:") - for pak, data in final_paks.items(): - qDebug(pak) - source, current_path, target_path = data - new_data_paks[i] = (pak, source, current_path, target_path) - i += 1 - self._model.set_paks(new_data_paks) - - -class UE4SSListModel(QStringListModel): - def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): - super().__init__(parent) - self._checked_items: set[str] = set() - self._organizer = organizer - self._init_mod_states() - - def _init_mod_states(self): - profile = QDir(self._organizer.profilePath()) - mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) - if mods_json.exists(): - with open(mods_json.absoluteFilePath(), "r") as json_file: - mod_data = json.load(json_file) - for mod in mod_data: - if mod["mod_enabled"]: - self._checked_items.add(mod["mod_name"]) - - def _set_mod_states(self): - profile = QDir(self._organizer.profilePath()) - mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) - mod_list: dict[str, bool] = {} - if mods_json.exists(): - with open(mods_json.absoluteFilePath(), "r") as json_file: - mod_data = json.load(json_file) - for mod in mod_data: - mod_list[mod["mod_name"]] = mod["mod_enabled"] - for i in range(self.rowCount()): - item = self.index(i, 0) - name = self.data(item, Qt.ItemDataRole.DisplayRole) - if name in mod_list: - self.setData( - item, - True if mod_list[name] else False, - Qt.ItemDataRole.CheckStateRole, - ) - else: - self.setData(item, True, Qt.ItemDataRole.CheckStateRole) - - def flags(self, index: QModelIndex) -> Qt.ItemFlag: - flags = super().flags(index) - if not index.isValid(): - return ( - Qt.ItemFlag.ItemIsSelectable - | Qt.ItemFlag.ItemIsDragEnabled - | Qt.ItemFlag.ItemIsDropEnabled - | Qt.ItemFlag.ItemIsEnabled - ) - return ( - flags - | Qt.ItemFlag.ItemIsUserCheckable - | Qt.ItemFlag.ItemIsDragEnabled & Qt.ItemFlag.ItemIsEditable - ) - - def setData(self, index: QModelIndex, value: Any, role: int = ...) -> bool: - if not index.isValid() or role != Qt.ItemDataRole.CheckStateRole: - return False - - if ( - bool(value) - and self.data(index, Qt.ItemDataRole.DisplayRole) not in self._checked_items - ): - self._checked_items.add(self.data(index, Qt.ItemDataRole.DisplayRole)) - elif ( - not bool(value) - and self.data(index, Qt.ItemDataRole.DisplayRole) in self._checked_items - ): - self._checked_items.remove(self.data(index, Qt.ItemDataRole.DisplayRole)) - self.dataChanged.emit(index, index, [role]) - return True - - def setStringList(self, strings: list[str]): - super().setStringList(strings) - self._set_mod_states() - - def data(self, index: QModelIndex, role: int = ...) -> Any: - if not index.isValid(): - return None - - if role == Qt.ItemDataRole.CheckStateRole: - return ( - Qt.CheckState.Checked - if self.data(index, Qt.ItemDataRole.DisplayRole) in self._checked_items - else Qt.CheckState.Unchecked - ) - - return super().data(index, role) - - def canDropMimeData( - self, - data: QMimeData | None, - action: Qt.DropAction, - row: int, - column: int, - parent: QModelIndex, - ) -> bool: - if action == Qt.DropAction.MoveAction and (row != -1 or column != -1): - return True - return False - - -class UE4SSView(QListView): - data_dropped = pyqtSignal() - - def __init__(self, parent: QWidget | None): - super().__init__(parent) - self.setAcceptDrops(True) - self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) - self.setDragEnabled(True) - self.setAcceptDrops(True) - self.setDropIndicatorShown(True) - self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) - self.setDefaultDropAction(Qt.DropAction.MoveAction) - self.viewport().setAcceptDrops(True) - - def dropEvent(self, e: QDropEvent | None): - super().dropEvent(e) - self.data_dropped.emit() - - def dataChanged(self, topLeft, bottomRight, roles=...): - super().dataChanged(topLeft, bottomRight, roles) - self.repaint() - - -class UE4SSTabWidget(QWidget): - def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): - super().__init__(parent) - self._organizer = organizer - self._view = UE4SSView(self) - self._layout = QGridLayout(self) - self._layout.addWidget(self._view) - self._model = UE4SSListModel(self._view, organizer) - self._view.setModel(self._model) - self._model.dataChanged.connect(self.write_mod_list) - self._view.data_dropped.connect(self.write_mod_list) - organizer.onProfileChanged(lambda profile_a, profile_b: self._parse_mod_files()) - organizer.modList().onModInstalled(self.update_mod_files) - organizer.modList().onModRemoved(lambda mod: self._parse_mod_files()) - organizer.modList().onModStateChanged(self.update_mod_files) - self._parse_mod_files() - - def get_mod_list(self) -> list[str]: - mod_list = [] - for index in range(self._model.rowCount()): - mod_list.append( - self._model.data( - self._model.index(index, 0), Qt.ItemDataRole.DisplayRole - ) - ) - return mod_list - - def update_mod_files( - self, mods: dict[str, mobase.ModState] | mobase.IModInterface | str - ): - mod_list: list[mobase.IModInterface] = [] - if type(mods) is dict: - for mod in mods.keys(): - mod_list.append(self._organizer.modList().getMod(mod)) - elif type(mods) is mobase.IModInterface: - mod_list.append(mods) - else: - mod_list.append(self._organizer.modList().getMod(mods)) - for mod in mod_list: - tree = mod.fileTree() - ue4ss_files = tree.find("UE4SS") - if not ue4ss_files: - ue4ss_files = tree.find( - "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" - ) - if ue4ss_files: - for entry in ue4ss_files: - if entry.isDir(): - if entry.find("enabled.txt"): - enabled_txt: mobase.FileTreeEntry = entry.find( - "enabled.txt" - ) - try: - os.remove( - mod.absolutePath() + "/" + enabled_txt.path("/") - ) - self._organizer.modDataChanged(mod) - except FileNotFoundError: - pass - - self._parse_mod_files() - - def _parse_mod_files(self): - mod_list = set() - for mod in self._organizer.modList().allMods(): - if ( - mobase.ModState(self._organizer.modList().state(mod)) - & mobase.ModState.ACTIVE - ): - tree = self._organizer.modList().getMod(mod).fileTree() - ue4ss_files = tree.find("UE4SS") - if not ue4ss_files: - ue4ss_files = tree.find( - "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" - ) - if ue4ss_files: - for entry in ue4ss_files: - if entry.isDir(): - if entry.find("scripts/main.lua"): - mod_list.add(entry.name()) - if entry.find("enabled.txt"): - enabled_txt: mobase.FileTreeEntry = entry.find( - "enabled.txt" - ) - try: - os.remove( - self._organizer.modList() - .getMod(mod) - .absolutePath() - + "/" - + enabled_txt.path("/") - ) - self._organizer.modDataChanged( - self._organizer.modList().getMod(mod) - ) - except FileNotFoundError: - pass - - game = self._organizer.managedGame() # type: OblivionRemasteredGame - if type(game) is OblivionRemasteredGame: - if game.ue4ssDirectory().exists(): - for dir_info in game.ue4ssDirectory().entryInfoList( - QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot - ): - if QFileInfo( - QDir(dir_info.absoluteFilePath()).absoluteFilePath( - "scripts/main.lua" - ) - ).exists(): - mod_list.add(dir_info.fileName()) - if QFileInfo( - QDir(dir_info.absoluteFilePath()).absoluteFilePath( - "enabled.txt" - ) - ).exists(): - os.remove( - QDir(dir_info.absoluteFilePath()).absoluteFilePath( - "enabled.txt" - ) - ) - final_list = sorted(mod_list, key=cmp_to_key(self.sort_mods)) - self._model.setStringList(final_list) - - def write_mod_list(self): - mod_list: list[dict] = [] - profile = QDir(self._organizer.profilePath()) - mods_txt = QFileInfo(profile.absoluteFilePath("mods.txt")) - with open(mods_txt.absoluteFilePath(), "w") as txt_file: - for i in range(self._model.rowCount()): - item = self._model.index(i, 0) - name = self._model.data(item, Qt.ItemDataRole.DisplayRole) - active = ( - True - if self._model.data(item, Qt.ItemDataRole.CheckStateRole) - == Qt.CheckState.Checked - else False - ) - mod_list.append({"mod_name": name, "mod_enabled": active}) - txt_file.write(f"{name} : {1 if active else 0}\n") - mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) - with open(mods_json.absoluteFilePath(), "w") as json_file: - json_file.write(json.dumps(mod_list, indent=4)) - - def sort_mods(self, mod_a: str, mod_b: str) -> int: - profile = QDir(self._organizer.profilePath()) - mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) - mods_list = [] - if mods_json.exists() and mods_json.isFile(): - with open(mods_json.absoluteFilePath(), "r") as json_file: - mods = json.load(json_file) - for mod in mods: - if mod["mod_enabled"]: - mods_list.append(mod["mod_name"]) - index_a = -1 - if mod_a in mods_list: - index_a = mods_list.index(mod_a) - index_b = -1 - if mod_b in mods_list: - index_b = mods_list.index(mod_b) - if index_a != -1 and index_b != -1: - return index_a - index_b - if index_a != -1: - return -1 - if index_b != -1: - return 1 - if mod_a < mod_b: - return -1 - return 1 - - -class OblivionRemasteredModDataChecker(mobase.ModDataChecker): - _dirs = ["Data", "Paks", "OBSE", "Movies", "UE4SS", "Root"] - _data_dirs = [ - "meshes", - "textures", - "music", - # "scripts", - "fonts", - "interface", - "shaders", - "strings", - "materials", - ] - _data_extensions = [".esm", ".esp", ".bsa"] - - def __init__(self, organizer: mobase.IOrganizer): - super().__init__() - self._organizer = organizer - - def dataLooksValid( - self, filetree: mobase.IFileTree - ) -> mobase.ModDataChecker.CheckReturn: - status = mobase.ModDataChecker.INVALID - if filetree.find("ue4ss/UE4SS.dll") is not None: - return mobase.ModDataChecker.FIXABLE - elif ( - filetree.find("OblivionRemastered/Binaries/Win64/ue4ss/UE4SS.dll") - is not None - ): - return mobase.ModDataChecker.FIXABLE - for entry in filetree: - name = entry.name().casefold() - if entry.parent().parent() is None: - if is_directory(entry): - if name in [dirname.lower() for dirname in self._dirs]: - if name == "ue4ss": - if entry.find("Mods"): - for sub_entry in entry.find("Mods"): - if is_directory(sub_entry): - if sub_entry.find("scripts/main.lua"): - status = mobase.ModDataChecker.FIXABLE - break - if sub_entry.name().casefold() == "shared": - status = mobase.ModDataChecker.FIXABLE - break - else: - for sub_entry in entry: - if is_directory(sub_entry): - if sub_entry.find("scripts/main.lua"): - status = mobase.ModDataChecker.VALID - break - if sub_entry.name().casefold() == "shared": - status = mobase.ModDataChecker.VALID - break - else: - status = mobase.ModDataChecker.VALID - if status == mobase.ModDataChecker.VALID: - break - elif name in [dirname.lower() for dirname in self._data_dirs]: - status = mobase.ModDataChecker.FIXABLE - else: - for sub_entry in entry: - if not is_directory(sub_entry): - sub_name = sub_entry.name().casefold() - if sub_name.endswith(".exe"): - return mobase.ModDataChecker.INVALID - if sub_name.endswith((".pak", ".bk2")): - status = mobase.ModDataChecker.FIXABLE - elif sub_name.endswith(tuple(self._data_extensions)): - status = mobase.ModDataChecker.FIXABLE - else: - if name == "Paks": - status = mobase.ModDataChecker.FIXABLE - new_status = self.dataLooksValid(entry) - if new_status != mobase.ModDataChecker.INVALID: - status = new_status - if status == mobase.ModDataChecker.VALID: - break - else: - if name.endswith(".exe"): - return mobase.ModDataChecker.INVALID - if name.endswith(tuple(self._data_extensions + [".pak", ".bk2"])): - status = mobase.ModDataChecker.FIXABLE - else: - if is_directory(entry): - if name in [dir_name.lower() for dir_name in self._dirs]: - status = mobase.ModDataChecker.FIXABLE - if name in [dir_name.lower() for dir_name in self._data_dirs]: - status = mobase.ModDataChecker.FIXABLE - else: - new_status = self.dataLooksValid(entry) - if new_status != mobase.ModDataChecker.INVALID: - status = new_status - else: - if name.endswith(".exe"): - return mobase.ModDataChecker.INVALID - if name.endswith( - tuple(self._data_extensions + [".pak", ".lua", ".bk2"]) - ): - status = mobase.ModDataChecker.FIXABLE - if status == mobase.ModDataChecker.VALID: - break - return status - - def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: - ue4ss_dll = filetree.find("ue4ss/UE4SS.dll") - if ue4ss_dll is None: - ue4ss_dll = filetree.find( - "OblivionRemastered/Binaries/Win64/ue4ss/UE4SS.dll" - ) - if ue4ss_dll is not None: - entries = [] - for entry in ue4ss_dll.parent().parent(): - entries.append(entry) - for entry in entries: - filetree.move( - entry, - "Root/OblivionRemastered/Binaries/Win64/", - mobase.IFileTree.MERGE, - ) - exe_dir = filetree.find(r"OblivionRemastered\Binaries\Win64") - if exe_dir is not None: - obse_dir = exe_dir.find("OBSE") - if obse_dir: - obse_main = self.get_dir(filetree, "OBSE") - obse_main.merge(obse_dir, True) - obse_dir.detach() - ue4ss_mod_dir = exe_dir.find("ue4ss/Mods") - if ue4ss_mod_dir: - if self._organizer.pluginSetting(PLUGIN_NAME, "ue4ss_use_root_builder"): - ue4ss_main = self.get_dir( - filetree, "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" - ) - else: - ue4ss_main = self.get_dir(filetree, "UE4SS") - ue4ss_main.merge(ue4ss_mod_dir, True) - ue4ss_mod_dir.detach() - if len(exe_dir): - root_exe_dir = self.get_dir( - filetree, "Root/OblivionRemastered/Binaries" - ) - parent = exe_dir.parent() - exe_dir.moveTo(root_exe_dir) - self.detach_parents(parent) - else: - self.detach_parents(exe_dir) - directories = [] - for entry in filetree: - if entry is not None: - if is_directory(entry): - directories.append(entry) - for directory in directories: - if directory.name().casefold() in [ - dirname.lower() for dirname in self._data_dirs - ]: - data_dir = self.get_dir(filetree, "Data") - directory.moveTo(data_dir) - elif directory.name().casefold() == "ue4ss": - if directory.find("Mods"): - for sub_entry in directory.find("Mods"): - if is_directory(sub_entry): - if ( - sub_entry.find("scripts/main.lua") - or sub_entry.name().casefold() == "shared" - ): - if self._organizer.pluginSetting( - PLUGIN_NAME, "ue4ss_use_root_builder" - ): - ue4ss_main = self.get_dir( - filetree, - "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods", - ) - sub_entry.moveTo(ue4ss_main) - self.detach_parents(directory) - else: - parent = sub_entry.parent() - sub_entry.moveTo(directory) - self.detach_parents(parent) - elif directory.name().casefold() not in [ - dirname.lower() for dirname in self._dirs - ]: - filetree = self.parse_directory(filetree, directory) - for entry in filetree: - if entry is not None: - if not is_directory(entry): - name = entry.name().casefold() - if name.endswith(".pak"): - paks_dir = self.get_dir(filetree, "Paks/~mods") - pak_files: list[mobase.FileTreeEntry] = [] - for file in entry.parent(): - if file is not None: - if not is_directory(file): - if ( - file.name() - .casefold() - .endswith((".pak", ".ucas", ".utoc")) - ): - pak_files.append(file) - for pak_file in pak_files: - pak_file.moveTo(paks_dir) - elif name.endswith(".bk2"): - movies_dir = self.get_dir(filetree, "Movies/Modern") - movie_files: list[mobase.FileTreeEntry] = [] - for file in entry.parent(): - if file is not None: - if not is_directory(file): - if file.name().casefold().endswith(".bk2"): - movie_files.append(file) - for movie_file in movie_files: - movie_file.moveTo(movies_dir) - elif name.endswith(tuple(self._data_extensions)): - data_dir = self.get_dir(filetree, "Data") - data_files: list[mobase.FileTreeEntry] = [] - for file in entry.parent(): - data_files.append(file) - for data_file in data_files: - data_file.moveTo(data_dir) - return filetree - - def parse_directory( - self, main_filetree: mobase.IFileTree, next_dir: mobase.IFileTree - ) -> mobase.IFileTree: - directories = [] - for entry in next_dir: - if entry is not None: - if is_directory(entry): - directories.append(entry) - for directory in directories: - name = directory.name().casefold() - stop = False - for dir_name in self._dirs: - if name == dir_name.lower(): - main_dir = self.get_dir(main_filetree, dir_name) - if name == "ue4ss": - if self._organizer.pluginSetting( - PLUGIN_NAME, "ue4ss_use_root_builder" - ): - ue4ss_dir = self.get_dir( - main_filetree, - "Root/OblivionRemastered/Binaries/Win64/ue4ss", - ) - ue4ss_dir.merge(directory) - else: - mod_dir = directory.find("Mods") - if mod_dir: - main_dir.merge(mod_dir) - else: - main_dir.merge(directory) - else: - main_dir.merge(directory) - self.detach_parents(directory) - stop = True - break - if stop: - continue - if name in ["~mods", "logicmods"]: - paks_dir = self.get_dir(main_filetree, "Paks") - directory.moveTo(paks_dir) - continue - elif name in [dirname.lower() for dirname in self._data_dirs]: - data_dir = self.get_dir(main_filetree, "Data") - data_dir.merge(directory) - self.detach_parents(directory) - continue - main_filetree = self.parse_directory(main_filetree, directory) - for entry in next_dir: - if not is_directory(entry): - name = entry.name().casefold() - if name.endswith(tuple(self._data_extensions)): - data_dir = self.get_dir(main_filetree, "Data") - data_dir.merge(next_dir) - self.detach_parents(next_dir) - elif name.endswith(".pak"): - paks_dir = self.get_dir(main_filetree, "Paks") - if next_dir.name().casefold() == "paks": - paks_dir.merge(next_dir) - self.detach_parents(next_dir) - return main_filetree - elif next_dir.name().casefold() in ["~mods", "logicmods"]: - next_dir.moveTo(paks_dir) - return main_filetree - else: - parent = next_dir.parent() - main_filetree.move( - next_dir, "Paks/~mods/", mobase.IFileTree.MERGE - ) - self.detach_parents(parent) - return main_filetree - elif name.endswith(".lua"): - if next_dir.parent() and next_dir.parent() != main_filetree: - parent = next_dir.parent().parent() - if self._organizer.pluginSetting( - PLUGIN_NAME, "ue4ss_use_root_builder" - ): - ue4ss_main = self.get_dir( - main_filetree, - "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods", - ) - next_dir.parent().moveTo(ue4ss_main) - else: - if main_filetree.find("UE4SS") is None: - main_filetree.addDirectory("UE4SS") - main_filetree.move( - next_dir.parent(), - "UE4SS/", - mobase.IFileTree.MERGE, - ) - if parent is not None: - self.detach_parents(parent) - return main_filetree - elif name.endswith(".bk2"): - movies_dir = self.get_dir(main_filetree, "Movies/Modern") - movies_dir.merge(next_dir) - self.detach_parents(next_dir) - - return main_filetree - - def detach_parents(self, directory: mobase.IFileTree) -> None: - if directory.parent() is not None and len(directory.parent()) == 1: - parent = ( - directory.parent() - if directory.parent().parent() is not None - else directory - ) - while parent.parent().parent() is not None and len(parent.parent()) == 1: - parent = parent.parent() - parent.detach() - else: - if len(directory) == 1: - directory.detach() - - def get_dir(self, filetree: mobase.IFileTree, directory: str) -> mobase.IFileTree: - tree_dir = filetree.find(directory) - if tree_dir is None: - tree_dir = filetree.addDirectory(directory) - return tree_dir - - -class OblivionRemasteredDataContent(mobase.ModDataContent): - OR_CONTENTS: tuple[Content, str, str, bool | None] = [ - (Content.PLUGIN, "Plugins (ESM/ESP)", ":/MO/gui/content/plugin"), - (Content.BSA, "Bethesda Archive", ":/MO/gui/content/bsa"), - (Content.PAK, "Paks", ":/MO/gui/content/geometries"), - (Content.OBSE, "Script Extender Plugin", ":/MO/gui/content/skse"), - (Content.OBSE_FILES, "Script Extender Files", "", True), - (Content.MOVIE, "Movies", ":/MO/gui/content/media"), - (Content.UE4SS, "UE4SS Mods", ":/MO/gui/content/script"), - (Content.MAGIC_LOADER, "Magic Loader Mod", ":/MO/gui/content/inifile"), - ] - - def getAllContents(self) -> list[mobase.ModDataContent.Content]: - return [mobase.ModDataContent.Content(*content) for content in self.OR_CONTENTS] - - def getContentsFor(self, filetree: mobase.IFileTree) -> list[int]: - contents: set[int] = set() - - for entry in filetree: - if is_directory(entry): - match entry.name().casefold(): - case "data": - for data_entry in entry: - if not is_directory(data_entry): - match data_entry.suffix().casefold(): - case "esm" | "esp": - contents.add(Content.PLUGIN) - case "bsa": - contents.add(Content.BSA) - case _: - pass - else: - match data_entry.name().casefold(): - case "magicloader": - contents.add(Content.MAGIC_LOADER) - case "obse": - contents.add(Content.OBSE_FILES) - plugins_dir = entry.find("Plugins") - if plugins_dir: - for plugin_entry in plugins_dir: - if plugin_entry.suffix().casefold() == "dll": - contents.add(Content.OBSE) - break - case "paks": - contents.add(Content.PAK) - for paks_entry in entry: - if is_directory(paks_entry): - if paks_entry.name().casefold() == "~mods": - if paks_entry.find("MagicLoader"): - contents.add(Content.MAGIC_LOADER) - if paks_entry.name().casefold() == "logicmods": - contents.add(Content.UE4SS) - case "movies": - contents.add(Content.MOVIE) - case "ue4ss": - contents.add(Content.UE4SS) - - return list(contents) - - -class OblivionRemasteredGamePlugins(mobase.GamePlugins): - def __init__(self, organizer: mobase.IOrganizer): - super().__init__() - self._last_read = QDateTime().currentDateTime() - self._organizer = organizer - # What are these for? - self._plugin_blacklist = ["TamrielLevelledRegion.esp", "AltarGymNavigation.esp"] - - def writePluginLists(self, plugin_list: mobase.IPluginList) -> None: - if not self._last_read.isValid(): - return - self.writePluginList( - plugin_list, self._organizer.profile().absolutePath() + "/plugins.txt" - ) - self.writeLoadOrderList( - plugin_list, self._organizer.profile().absolutePath() + "/loadorder.txt" - ) - self._last_read = QDateTime.currentDateTime() - - def readPluginLists(self, plugin_list: mobase.IPluginList) -> None: - load_order_path = self._organizer.profile().absolutePath() + "/loadorder.txt" - load_order = self.readLoadOrderList(plugin_list, load_order_path) - plugin_list.setLoadOrder(load_order) - self.readPluginList(plugin_list) - self._last_read = QDateTime.currentDateTime() - - def getLoadOrder(self) -> Sequence[str]: - load_order_path = self._organizer.profile().absolutePath() + "/loadorder.txt" - plugins_path = self._organizer.profile().absolutePath() + "/plugins.txt" - - load_order_is_new = ( - not self._last_read.isValid() - or not QFileInfo(load_order_path).exists() - or QFileInfo(load_order_path).lastModified() > self._last_read - ) - plugins_is_new = ( - not self._last_read.isValid() - or QFileInfo(plugins_path).lastModified() > self._last_read - ) - - if load_order_is_new or not plugins_is_new: - return self.readLoadOrderList(self._organizer.pluginList(), load_order_path) - else: - return self.readPluginList(self._organizer.pluginList()) - - def writePluginList(self, plugin_list: mobase.IPluginList, filePath: str): - self.writeList(plugin_list, filePath, False) - - def writeLoadOrderList(self, plugin_list: mobase.IPluginList, filePath: str): - self.writeList(plugin_list, filePath, True) - - def writeList( - self, plugin_list: mobase.IPluginList, filePath: str, load_order: bool - ): - plugins_file = open(filePath, "w") - encoder = ( - QStringEncoder(QStringConverter.Encoding.Utf8) - if load_order - else QStringEncoder(QStringConverter.Encoding.System) - ) - plugins_text = "# This file was automatically generated by Mod Organizer.\n" - invalid_filenames = False - written_count = 0 - plugins = plugin_list.pluginNames() - plugins_sorted = sorted( - plugins, - key=cmp_to_key( - lambda lhs, rhs: plugin_list.priority(lhs) - plugin_list.priority(rhs) - ), - ) - for plugin_name in plugins_sorted: - if ( - load_order - or plugin_list.state(plugin_name) == mobase.PluginState.ACTIVE - ): - result = encoder.encode(plugin_name) - if encoder.hasError(): - invalid_filenames = True - qCritical("invalid plugin name %s", plugin_name) - plugins_text += result.data().decode() + "\n" - written_count += 1 - - if invalid_filenames: - qCritical( - QCoreApplication.translate( - "MainWindow", - "Some of your plugins have invalid names! These " - + "plugins can not be loaded by the game. Please see " - + "mo_interface.log for a list of affected plugins " - + "and rename them.", - ) - ) - - if written_count == 0: - qWarning( - "plugin list would be empty, this is almost certainly wrong. Not saving." - ) - else: - plugins_file.write(plugins_text) - plugins_file.close() - - def readLoadOrderList( - self, plugin_list: mobase.IPluginList, file_path: str - ) -> list[str]: - plugin_names = [ - plugin for plugin in self._organizer.managedGame().primaryPlugins() - ] - plugin_lookup = set() - for name in plugin_names: - if name.lower() not in plugin_lookup: - plugin_lookup.add(name.lower()) - - try: - with open(file_path) as file: - for line in file: - if line.startswith("#"): - continue - plugin_file = line.rstrip("\n") - if plugin_file.lower() not in plugin_lookup: - plugin_lookup.add(plugin_file.lower()) - plugin_names.append(plugin_file) - except FileNotFoundError: - return self.readPluginList(plugin_list) - - return plugin_names - - def readPluginList(self, plugin_list: mobase.IPluginList) -> list[str]: - plugins = [plugin for plugin in plugin_list.pluginNames()] - sorted_plugins = [] - primary = [plugin for plugin in self._organizer.managedGame().primaryPlugins()] - primary_lower = [plugin.lower() for plugin in primary] - for plugin_name in primary: - if plugin_list.state(plugin_name) != mobase.PluginState.MISSING: - plugin_list.setState(plugin_name, mobase.PluginState.ACTIVE) - sorted_plugins.append(plugin_name) - plugin_remove = [ - plugin for plugin in plugins if plugin.lower() in primary_lower - ] - for plugin in plugin_remove: - plugins.remove(plugin) - - plugins_txt_exists = True - file_path = self._organizer.profile().absolutePath() + "/plugins.txt" - file = QFile(file_path) - if not file.open(QFile.OpenModeFlag.ReadOnly): - plugins_txt_exists = False - if file.size() == 0: - plugins_txt_exists = False - if plugins_txt_exists: - while not file.atEnd(): - line = file.readLine() - file_plugin_name = QByteArray() - if line.size() > 0 and line.at(0).decode() != "#": - encoder = QStringEncoder(QStringEncoder.Encoding.System) - file_plugin_name = encoder.encode(line.trimmed().data().decode()) - if file_plugin_name.size() > 0: - if file_plugin_name.data().decode().lower() in [ - plugin.lower() for plugin in plugins - ]: - plugin_list.setState( - file_plugin_name.data().decode(), mobase.PluginState.ACTIVE - ) - sorted_plugins.append(file_plugin_name.data().decode()) - plugins.remove(file_plugin_name.data().decode()) - - file.close() - - for plugin_name in plugins: - plugin_list.setState(plugin_name, mobase.PluginState.INACTIVE) - else: - for plugin_name in plugins: - plugin_list.setState(plugin_name, mobase.PluginState.INACTIVE) - - return sorted_plugins + plugins - - def lightPluginsAreSupported(self) -> bool: - return False - - def mediumPluginsAreSupported(self) -> bool: - return False - - def blueprintPluginsAreSupported(self) -> bool: - return False - - -class OblivionRemasteredScriptExtender(mobase.ScriptExtender): - def __init__(self, game: mobase.IPluginGame): - super().__init__() - self._game = game - - def binaryName(self): - return "obse64_loader.exe" - - def loaderName(self) -> str: - return self.binaryName() - - def loaderPath(self) -> str: - return ( - self._game.gameDirectory().absolutePath() - + "\\OblivionRemastered\\Binaries\\Win64\\" - + self.loaderName() - ) - - def pluginPath(self) -> str: - return "OBSE/Plugins" - - def savegameExtension(self) -> str: - return "" - - def isInstalled(self) -> bool: - return os.path.exists(self.loaderPath()) - - def getExtenderVersion(self) -> str: - return mobase.getFileVersion(self.loaderPath()) - - def getArch(self) -> int: - return 0x8664 if self.isInstalled() else 0x0 - - class OblivionRemasteredGame( BasicGame, mobase.IPluginFileMapper, mobase.IPluginDiagnose ): @@ -1486,15 +79,21 @@ class OblivionRemasteredGame( "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) - self._main_window: QMainWindow | None = None - self._ue4ss_tab: UE4SSTabWidget | None = None - self._paks_tab: PaksTabWidget | None = None 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)) @@ -1535,7 +134,7 @@ def settings(self) -> list[mobase.PluginSetting]: ] def executables(self): - return [ + execs = [ mobase.ExecutableInfo( "Oblivion Remastered", QFileInfo( @@ -1546,19 +145,23 @@ def executables(self): ), "OblivionRemastered-Win64-Shipping.exe", ), - ), - mobase.ExecutableInfo( - "OBSE64", - QFileInfo( - self._organizer.gameFeatures() - .gameFeature(mobase.ScriptExtender) - .loaderPath() - ), - ), - mobase.ExecutableInfo("LOOT", QFileInfo(str(getLootPath()))).withArgument( - '--game="Oblivion Remastered"' - ), + ) ] + 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"' + ) + ) + + return execs def primaryPlugins(self) -> list[str]: return [ @@ -1655,7 +258,7 @@ def write_default_mods(self, profile: QDir): for mod in DEFAULT_UE4SS_MODS: mods_txt.write(f"{mod} : 1\n") if not ue4ss_mods_json.exists(): - mods_data = [] + mods_data: list[UE4SSModInfo] = [] for mod in DEFAULT_UE4SS_MODS: mods_data.append({"mod_name": mod, "mod_enabled": True}) with open(ue4ss_mods_json.absoluteFilePath(), "w") as mods_json: @@ -1720,7 +323,8 @@ def fullDescription(self, key: int) -> str: "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." ) - return "" + case _: + return "" def hasGuidedFix(self, key: int) -> bool: match key: @@ -1728,7 +332,8 @@ def hasGuidedFix(self, key: int) -> bool: return True case Problems.INVALID_UE4SS_MOD_NAME: return True - return False + case _: + return False def shortDescription(self, key: int) -> str: match key: @@ -1736,7 +341,8 @@ def shortDescription(self, key: int) -> str: return "The UE4SS loader DLL is present (dwmapi.dll)." case Problems.INVALID_UE4SS_MOD_NAME: return "A UE4SS mod name contains a space." - return "" + case _: + return "" def startGuidedFix(self, key: int) -> None: match key: @@ -1753,25 +359,26 @@ def startGuidedFix(self, key: int) -> None: filetree.find( "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" ) - if ue4ss_mod: + if isinstance(ue4ss_mod, mobase.IFileTree): for entry in ue4ss_mod: - if is_directory(entry) and entry.find("scripts/main.lua"): + if isinstance(entry, mobase.IFileTree) and entry.find( + "scripts/main.lua" + ): if " " in entry.name(): - mod_dir = QDir( + mod_dir = Path( self._organizer.modList() .getMod(mod) .absolutePath() ) - mod_path = mod_dir.absoluteFilePath(entry.path("/")) - fixed_path = ( - mod_dir.absoluteFilePath( - entry.parent().path("/") - ) - + "/" - + entry.name().replace(" ", "") + mod_path = mod_dir.joinpath(entry.path("/")) + fixed_path = mod_dir.joinpath( + cast(mobase.IFileTree, entry.parent()).path( + "/" + ), + entry.name().replace(" ", ""), ) try: - os.rename(mod_path, fixed_path) + mod_path.rename(fixed_path) self._organizer.modDataChanged( self._organizer.modList().getMod(mod) ) @@ -1799,5 +406,5 @@ def startGuidedFix(self, key: int) -> None: pass except FileNotFoundError: pass - - pass + case _: + pass diff --git a/games/oblivion_remaster/__init__.py b/games/oblivion_remaster/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/games/oblivion_remaster/constants.py b/games/oblivion_remaster/constants.py new file mode 100644 index 00000000..bce80634 --- /dev/null +++ b/games/oblivion_remaster/constants.py @@ -0,0 +1 @@ +PLUGIN_NAME = "Oblivion Remastered Support Plugin" diff --git a/games/oblivion_remaster/game_plugins.py b/games/oblivion_remaster/game_plugins.py new file mode 100644 index 00000000..9ff10485 --- /dev/null +++ b/games/oblivion_remaster/game_plugins.py @@ -0,0 +1,201 @@ +from functools import cmp_to_key +from typing import Sequence + +from PyQt6.QtCore import ( + QByteArray, + QCoreApplication, + QDateTime, + QFile, + QFileInfo, + QStringConverter, + QStringEncoder, + qCritical, + qWarning, +) + +import mobase + + +class OblivionRemasteredGamePlugins(mobase.GamePlugins): + def __init__(self, organizer: mobase.IOrganizer): + super().__init__() + self._last_read = QDateTime().currentDateTime() + self._organizer = organizer + # What are these for? + self._plugin_blacklist = ["TamrielLevelledRegion.esp", "AltarGymNavigation.esp"] + + def writePluginLists(self, plugin_list: mobase.IPluginList) -> None: + if not self._last_read.isValid(): + return + self.writePluginList( + plugin_list, self._organizer.profile().absolutePath() + "/plugins.txt" + ) + self.writeLoadOrderList( + plugin_list, self._organizer.profile().absolutePath() + "/loadorder.txt" + ) + self._last_read = QDateTime.currentDateTime() + + def readPluginLists(self, plugin_list: mobase.IPluginList) -> None: + load_order_path = self._organizer.profile().absolutePath() + "/loadorder.txt" + load_order = self.readLoadOrderList(plugin_list, load_order_path) + plugin_list.setLoadOrder(load_order) + self.readPluginList(plugin_list) + self._last_read = QDateTime.currentDateTime() + + def getLoadOrder(self) -> Sequence[str]: + load_order_path = self._organizer.profile().absolutePath() + "/loadorder.txt" + plugins_path = self._organizer.profile().absolutePath() + "/plugins.txt" + + load_order_is_new = ( + not self._last_read.isValid() + or not QFileInfo(load_order_path).exists() + or QFileInfo(load_order_path).lastModified() > self._last_read + ) + plugins_is_new = ( + not self._last_read.isValid() + or QFileInfo(plugins_path).lastModified() > self._last_read + ) + + if load_order_is_new or not plugins_is_new: + return self.readLoadOrderList(self._organizer.pluginList(), load_order_path) + else: + return self.readPluginList(self._organizer.pluginList()) + + def writePluginList(self, plugin_list: mobase.IPluginList, filePath: str): + self.writeList(plugin_list, filePath, False) + + def writeLoadOrderList(self, plugin_list: mobase.IPluginList, filePath: str): + self.writeList(plugin_list, filePath, True) + + def writeList( + self, plugin_list: mobase.IPluginList, filePath: str, load_order: bool + ): + plugins_file = open(filePath, "w") + encoder = ( + QStringEncoder(QStringConverter.Encoding.Utf8) + if load_order + else QStringEncoder(QStringConverter.Encoding.System) + ) + plugins_text = "# This file was automatically generated by Mod Organizer.\n" + invalid_filenames = False + written_count = 0 + plugins = plugin_list.pluginNames() + plugins_sorted = sorted( + plugins, + key=cmp_to_key( + lambda lhs, rhs: plugin_list.priority(lhs) - plugin_list.priority(rhs) + ), + ) + for plugin_name in plugins_sorted: + if ( + load_order + or plugin_list.state(plugin_name) == mobase.PluginState.ACTIVE + ): + result = encoder.encode(plugin_name) + if encoder.hasError(): + invalid_filenames = True + qCritical("invalid plugin name %s" % plugin_name) + plugins_text += result.data().decode() + "\n" + written_count += 1 + + if invalid_filenames: + qCritical( + QCoreApplication.translate( + "MainWindow", + "Some of your plugins have invalid names! These " + + "plugins can not be loaded by the game. Please see " + + "mo_interface.log for a list of affected plugins " + + "and rename them.", + ) + ) + + if written_count == 0: + qWarning( + "plugin list would be empty, this is almost certainly wrong. Not saving." + ) + else: + plugins_file.write(plugins_text) + plugins_file.close() + + def readLoadOrderList( + self, plugin_list: mobase.IPluginList, file_path: str + ) -> list[str]: + plugin_names = [ + plugin for plugin in self._organizer.managedGame().primaryPlugins() + ] + plugin_lookup: set[str] = set() + for name in plugin_names: + if name.lower() not in plugin_lookup: + plugin_lookup.add(name.lower()) + + try: + with open(file_path) as file: + for line in file: + if line.startswith("#"): + continue + plugin_file = line.rstrip("\n") + if plugin_file.lower() not in plugin_lookup: + plugin_lookup.add(plugin_file.lower()) + plugin_names.append(plugin_file) + except FileNotFoundError: + return self.readPluginList(plugin_list) + + return plugin_names + + def readPluginList(self, plugin_list: mobase.IPluginList) -> list[str]: + plugins = [plugin for plugin in plugin_list.pluginNames()] + sorted_plugins: list[str] = [] + primary = [plugin for plugin in self._organizer.managedGame().primaryPlugins()] + primary_lower = [plugin.lower() for plugin in primary] + for plugin_name in primary: + if plugin_list.state(plugin_name) != mobase.PluginState.MISSING: + plugin_list.setState(plugin_name, mobase.PluginState.ACTIVE) + sorted_plugins.append(plugin_name) + plugin_remove = [ + plugin for plugin in plugins if plugin.lower() in primary_lower + ] + for plugin in plugin_remove: + plugins.remove(plugin) + + plugins_txt_exists = True + file_path = self._organizer.profile().absolutePath() + "/plugins.txt" + file = QFile(file_path) + if not file.open(QFile.OpenModeFlag.ReadOnly): + plugins_txt_exists = False + if file.size() == 0: + plugins_txt_exists = False + if plugins_txt_exists: + while not file.atEnd(): + line = file.readLine() + file_plugin_name = QByteArray() + if line.size() > 0 and line.at(0).decode() != "#": + encoder = QStringEncoder(QStringEncoder.Encoding.System) + file_plugin_name = encoder.encode(line.trimmed().data().decode()) + if file_plugin_name.size() > 0: + if file_plugin_name.data().decode().lower() in [ + plugin.lower() for plugin in plugins + ]: + plugin_list.setState( + file_plugin_name.data().decode(), mobase.PluginState.ACTIVE + ) + sorted_plugins.append(file_plugin_name.data().decode()) + plugins.remove(file_plugin_name.data().decode()) + + file.close() + + for plugin_name in plugins: + plugin_list.setState(plugin_name, mobase.PluginState.INACTIVE) + else: + for plugin_name in plugins: + plugin_list.setState(plugin_name, mobase.PluginState.INACTIVE) + + return sorted_plugins + plugins + + def lightPluginsAreSupported(self) -> bool: + return False + + def mediumPluginsAreSupported(self) -> bool: + return False + + def blueprintPluginsAreSupported(self) -> bool: + return False diff --git a/games/oblivion_remaster/mod_data_checker.py b/games/oblivion_remaster/mod_data_checker.py new file mode 100644 index 00000000..3ab9460b --- /dev/null +++ b/games/oblivion_remaster/mod_data_checker.py @@ -0,0 +1,357 @@ +from typing import cast + +import mobase + +from .constants import PLUGIN_NAME + + +def _parent(entry: mobase.FileTreeEntry): + """ + Same as entry.parent() but always returns a mobase.IFileTree and never None. + """ + return cast(mobase.IFileTree, entry.parent()) + + +class OblivionRemasteredModDataChecker(mobase.ModDataChecker): + _dirs = ["Data", "Paks", "OBSE", "Movies", "UE4SS", "Root"] + _data_dirs = [ + "meshes", + "textures", + "music", + # "scripts", + "fonts", + "interface", + "shaders", + "strings", + "materials", + ] + _data_extensions = [".esm", ".esp", ".bsa"] + + def __init__(self, organizer: mobase.IOrganizer): + super().__init__() + self._organizer = organizer + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + status = mobase.ModDataChecker.INVALID + if filetree.find("ue4ss/UE4SS.dll") is not None: + return mobase.ModDataChecker.FIXABLE + elif ( + filetree.find("OblivionRemastered/Binaries/Win64/ue4ss/UE4SS.dll") + is not None + ): + return mobase.ModDataChecker.FIXABLE + for entry in filetree: + name = entry.name().casefold() + parent = entry.parent() + assert parent is not None + if parent.parent() is None: + if isinstance(entry, mobase.IFileTree): + if name in [dirname.lower() for dirname in self._dirs]: + if name == "ue4ss": + mods = entry.find("Mods") + if isinstance(mods, mobase.IFileTree): + for sub_entry in mods: + if isinstance(sub_entry, mobase.IFileTree): + if sub_entry.find("scripts/main.lua"): + status = mobase.ModDataChecker.FIXABLE + break + if sub_entry.name().casefold() == "shared": + status = mobase.ModDataChecker.FIXABLE + break + else: + for sub_entry in entry: + if isinstance(sub_entry, mobase.IFileTree): + if sub_entry.find("scripts/main.lua"): + status = mobase.ModDataChecker.VALID + break + if sub_entry.name().casefold() == "shared": + status = mobase.ModDataChecker.VALID + break + else: + status = mobase.ModDataChecker.VALID + if status == mobase.ModDataChecker.VALID: + break + elif name in [dirname.lower() for dirname in self._data_dirs]: + status = mobase.ModDataChecker.FIXABLE + else: + for sub_entry in entry: + if sub_entry.isFile(): + sub_name = sub_entry.name().casefold() + if sub_name.endswith(".exe"): + return mobase.ModDataChecker.INVALID + if sub_name.endswith((".pak", ".bk2")): + status = mobase.ModDataChecker.FIXABLE + elif sub_name.endswith(tuple(self._data_extensions)): + status = mobase.ModDataChecker.FIXABLE + else: + if name == "Paks": + status = mobase.ModDataChecker.FIXABLE + new_status = self.dataLooksValid(entry) + if new_status != mobase.ModDataChecker.INVALID: + status = new_status + if status == mobase.ModDataChecker.VALID: + break + else: + if name.endswith(".exe"): + return mobase.ModDataChecker.INVALID + if name.endswith(tuple(self._data_extensions + [".pak", ".bk2"])): + status = mobase.ModDataChecker.FIXABLE + else: + if isinstance(entry, mobase.IFileTree): + if name in [dir_name.lower() for dir_name in self._dirs]: + status = mobase.ModDataChecker.FIXABLE + if name in [dir_name.lower() for dir_name in self._data_dirs]: + status = mobase.ModDataChecker.FIXABLE + else: + new_status = self.dataLooksValid(entry) + if new_status != mobase.ModDataChecker.INVALID: + status = new_status + else: + if name.endswith(".exe"): + return mobase.ModDataChecker.INVALID + if name.endswith( + tuple(self._data_extensions + [".pak", ".lua", ".bk2"]) + ): + status = mobase.ModDataChecker.FIXABLE + if status == mobase.ModDataChecker.VALID: + break + return status + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + ue4ss_dll = filetree.find("ue4ss/UE4SS.dll") + if ue4ss_dll is None: + ue4ss_dll = filetree.find( + "OblivionRemastered/Binaries/Win64/ue4ss/UE4SS.dll" + ) + if ue4ss_dll is not None and (ue4ss_folder := ue4ss_dll.parent()) is not None: + entries: list[mobase.FileTreeEntry] = [] + for entry in _parent(ue4ss_folder): + entries.append(entry) + for entry in entries: + filetree.move( + entry, + "Root/OblivionRemastered/Binaries/Win64/", + mobase.IFileTree.MERGE, + ) + exe_dir = filetree.find(r"OblivionRemastered\Binaries\Win64") + if isinstance(exe_dir, mobase.IFileTree): + obse_dir = exe_dir.find("OBSE") + if isinstance(obse_dir, mobase.IFileTree): + obse_main = self.get_dir(filetree, "OBSE") + obse_main.merge(obse_dir, True) + obse_dir.detach() + ue4ss_mod_dir = exe_dir.find("ue4ss/Mods") + if isinstance(ue4ss_mod_dir, mobase.IFileTree): + if self._organizer.pluginSetting(PLUGIN_NAME, "ue4ss_use_root_builder"): + ue4ss_main = self.get_dir( + filetree, "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) + else: + ue4ss_main = self.get_dir(filetree, "UE4SS") + ue4ss_main.merge(ue4ss_mod_dir, True) + ue4ss_mod_dir.detach() + if len(exe_dir): + root_exe_dir = self.get_dir( + filetree, "Root/OblivionRemastered/Binaries" + ) + parent = _parent(exe_dir) + exe_dir.moveTo(root_exe_dir) + self.detach_parents(parent) + else: + self.detach_parents(exe_dir) + directories: list[mobase.IFileTree] = [] + for entry in filetree: + if isinstance(entry, mobase.IFileTree): + directories.append(entry) + for directory in directories: + if directory.name().casefold() in [ + dirname.lower() for dirname in self._data_dirs + ]: + data_dir = self.get_dir(filetree, "Data") + directory.moveTo(data_dir) + elif directory.name().casefold() == "ue4ss": + mods = directory.find("Mods") + if isinstance(mods, mobase.IFileTree): + for sub_entry in mods: + if isinstance(sub_entry, mobase.IFileTree): + if ( + sub_entry.find("scripts/main.lua") + or sub_entry.name().casefold() == "shared" + ): + if self._organizer.pluginSetting( + PLUGIN_NAME, "ue4ss_use_root_builder" + ): + ue4ss_main = self.get_dir( + filetree, + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods", + ) + sub_entry.moveTo(ue4ss_main) + self.detach_parents(directory) + else: + parent = _parent(sub_entry) + sub_entry.moveTo(directory) + self.detach_parents(parent) + elif directory.name().casefold() not in [ + dirname.lower() for dirname in self._dirs + ]: + filetree = self.parse_directory(filetree, directory) + for entry in filetree: + if entry.isFile(): + name = entry.name().casefold() + if name.endswith(".pak"): + paks_dir = self.get_dir(filetree, "Paks/~mods") + pak_files: list[mobase.FileTreeEntry] = [] + for file in _parent(entry): + if file.isFile(): + if ( + file.name() + .casefold() + .endswith((".pak", ".ucas", ".utoc")) + ): + pak_files.append(file) + for pak_file in pak_files: + pak_file.moveTo(paks_dir) + elif name.endswith(".bk2"): + movies_dir = self.get_dir(filetree, "Movies/Modern") + movie_files: list[mobase.FileTreeEntry] = [] + for file in _parent(entry): + if file.isFile(): + if file.name().casefold().endswith(".bk2"): + movie_files.append(file) + for movie_file in movie_files: + movie_file.moveTo(movies_dir) + elif name.endswith(tuple(self._data_extensions)): + data_dir = self.get_dir(filetree, "Data") + data_files: list[mobase.FileTreeEntry] = [] + for file in _parent(entry): + data_files.append(file) + for data_file in data_files: + data_file.moveTo(data_dir) + return filetree + + def parse_directory( + self, main_filetree: mobase.IFileTree, next_dir: mobase.IFileTree + ) -> mobase.IFileTree: + directories: list[mobase.IFileTree] = [] + for entry in next_dir: + if isinstance(entry, mobase.IFileTree): + directories.append(entry) + for directory in directories: + name = directory.name().casefold() + stop = False + for dir_name in self._dirs: + if name == dir_name.lower(): + main_dir = self.get_dir(main_filetree, dir_name) + if name == "ue4ss": + if self._organizer.pluginSetting( + PLUGIN_NAME, "ue4ss_use_root_builder" + ): + ue4ss_dir = self.get_dir( + main_filetree, + "Root/OblivionRemastered/Binaries/Win64/ue4ss", + ) + ue4ss_dir.merge(directory) + else: + mod_dir = directory.find("Mods") + if isinstance(mod_dir, mobase.IFileTree): + main_dir.merge(mod_dir) + else: + main_dir.merge(directory) + else: + main_dir.merge(directory) + self.detach_parents(directory) + stop = True + break + if stop: + continue + if name in ["~mods", "logicmods"]: + paks_dir = self.get_dir(main_filetree, "Paks") + directory.moveTo(paks_dir) + continue + elif name in [dirname.lower() for dirname in self._data_dirs]: + data_dir = self.get_dir(main_filetree, "Data") + data_dir.merge(directory) + self.detach_parents(directory) + continue + main_filetree = self.parse_directory(main_filetree, directory) + for entry in next_dir: + if entry.isFile(): + name = entry.name().casefold() + if name.endswith(tuple(self._data_extensions)): + data_dir = self.get_dir(main_filetree, "Data") + data_dir.merge(next_dir) + self.detach_parents(next_dir) + elif name.endswith(".pak"): + paks_dir = self.get_dir(main_filetree, "Paks") + if next_dir.name().casefold() == "paks": + paks_dir.merge(next_dir) + self.detach_parents(next_dir) + return main_filetree + elif next_dir.name().casefold() in ["~mods", "logicmods"]: + next_dir.moveTo(paks_dir) + return main_filetree + else: + parent = _parent(next_dir) + main_filetree.move( + next_dir, "Paks/~mods/", mobase.IFileTree.MERGE + ) + + self.detach_parents(parent) + return main_filetree + elif name.endswith(".lua"): + if next_dir.parent() and next_dir.parent() != main_filetree: + parent = _parent(next_dir).parent() + if self._organizer.pluginSetting( + PLUGIN_NAME, "ue4ss_use_root_builder" + ): + ue4ss_main = self.get_dir( + main_filetree, + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods", + ) + _parent(next_dir).moveTo(ue4ss_main) + else: + if main_filetree.find("UE4SS") is None: + main_filetree.addDirectory("UE4SS") + main_filetree.move( + _parent(next_dir), + "UE4SS/", + mobase.IFileTree.MERGE, + ) + if parent is not None: + self.detach_parents(parent) + return main_filetree + elif name.endswith(".bk2"): + movies_dir = self.get_dir(main_filetree, "Movies/Modern") + movies_dir.merge(next_dir) + self.detach_parents(next_dir) + + return main_filetree + + def detach_parents(self, directory: mobase.IFileTree) -> None: + if ( + directory.parent() is not None + and (parent := directory.parent()) is not None + and len(parent) == 1 + ): + parent = parent if parent.parent() is not None else directory + while ( + parent + and (p_parent := parent.parent()) is not None + and (pp_parent := p_parent.parent()) is not None + and len(pp_parent) == 1 + ): + parent = parent.parent() + + assert parent is not None + parent.detach() + else: + if len(directory) == 1: + directory.detach() + + def get_dir(self, filetree: mobase.IFileTree, directory: str) -> mobase.IFileTree: + tree_dir = filetree.find(directory) + if not isinstance(tree_dir, mobase.IFileTree): + tree_dir = filetree.addDirectory(directory) + return tree_dir diff --git a/games/oblivion_remaster/mod_data_content.py b/games/oblivion_remaster/mod_data_content.py new file mode 100644 index 00000000..d2aab4a9 --- /dev/null +++ b/games/oblivion_remaster/mod_data_content.py @@ -0,0 +1,81 @@ +from enum import IntEnum, auto + +import mobase + + +class Content(IntEnum): + PLUGIN = auto() + BSA = auto() + PAK = auto() + OBSE = auto() + OBSE_FILES = auto() + MOVIE = auto() + UE4SS = auto() + MAGIC_LOADER = auto() + + +class OblivionRemasteredDataContent(mobase.ModDataContent): + OR_CONTENTS: list[tuple[Content, str, str, bool] | tuple[Content, str, str]] = [ + (Content.PLUGIN, "Plugins (ESM/ESP)", ":/MO/gui/content/plugin"), + (Content.BSA, "Bethesda Archive", ":/MO/gui/content/bsa"), + (Content.PAK, "Paks", ":/MO/gui/content/geometries"), + (Content.OBSE, "Script Extender Plugin", ":/MO/gui/content/skse"), + (Content.OBSE_FILES, "Script Extender Files", "", True), + (Content.MOVIE, "Movies", ":/MO/gui/content/media"), + (Content.UE4SS, "UE4SS Mods", ":/MO/gui/content/script"), + (Content.MAGIC_LOADER, "Magic Loader Mod", ":/MO/gui/content/inifile"), + ] + + def getAllContents(self) -> list[mobase.ModDataContent.Content]: + return [ + mobase.ModDataContent.Content(id, name, icon, *filter_only) + for id, name, icon, *filter_only in self.OR_CONTENTS + ] + + def getContentsFor(self, filetree: mobase.IFileTree) -> list[int]: + contents: set[int] = set() + + for entry in filetree: + if isinstance(entry, mobase.IFileTree): + match entry.name().casefold(): + case "data": + for data_entry in entry: + if data_entry.isFile(): + match data_entry.suffix().casefold(): + case "esm" | "esp": + contents.add(Content.PLUGIN) + case "bsa": + contents.add(Content.BSA) + case _: + pass + else: + match data_entry.name().casefold(): + case "magicloader": + contents.add(Content.MAGIC_LOADER) + case _: + pass + case "obse": + contents.add(Content.OBSE_FILES) + plugins_dir = entry.find("Plugins") + if isinstance(plugins_dir, mobase.IFileTree): + for plugin_entry in plugins_dir: + if plugin_entry.suffix().casefold() == "dll": + contents.add(Content.OBSE) + break + case "paks": + contents.add(Content.PAK) + for paks_entry in entry: + if isinstance(paks_entry, mobase.IFileTree): + if paks_entry.name().casefold() == "~mods": + if paks_entry.find("MagicLoader"): + contents.add(Content.MAGIC_LOADER) + if paks_entry.name().casefold() == "logicmods": + contents.add(Content.UE4SS) + case "movies": + contents.add(Content.MOVIE) + case "ue4ss": + contents.add(Content.UE4SS) + case _: + pass + + return list(contents) diff --git a/games/oblivion_remaster/paks/__init__.py b/games/oblivion_remaster/paks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/games/oblivion_remaster/paks/model.py b/games/oblivion_remaster/paks/model.py new file mode 100644 index 00000000..8cdfdc0c --- /dev/null +++ b/games/oblivion_remaster/paks/model.py @@ -0,0 +1,253 @@ +import itertools +import typing +from enum import IntEnum, auto +from typing import Any, TypeAlias, overload + +from PyQt6.QtCore import ( + QAbstractItemModel, + QByteArray, + QDataStream, + QDir, + QFileInfo, + QMimeData, + QModelIndex, + QObject, + Qt, + QVariant, +) +from PyQt6.QtWidgets import QWidget + +import mobase + +_PakInfo: TypeAlias = tuple[str, str, str, str] + + +class PaksColumns(IntEnum): + PRIORITY = auto() + PAK_NAME = auto() + SOURCE = auto() + + +class PaksModel(QAbstractItemModel): + def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): + super().__init__(parent) + self.paks: dict[int, tuple[str, str, str, str]] = {} + self._organizer = organizer + self._init_mod_states() + + def _init_mod_states(self): + profile = QDir(self._organizer.profilePath()) + paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt")) + if paks_txt.exists(): + with open(paks_txt.absoluteFilePath(), "r") as paks_file: + index = 0 + for line in paks_file: + self.paks[index] = (line, "", "", "") + index += 1 + + def set_paks(self, paks: dict[int, tuple[str, str, str, str]]): + self.layoutAboutToBeChanged.emit() + self.paks = paks + self.layoutChanged.emit() + self.dataChanged.emit( + self.index(0, 0), + self.index(self.rowCount(), self.columnCount()), + [Qt.ItemDataRole.DisplayRole], + ) + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + if not index.isValid(): + return ( + Qt.ItemFlag.ItemIsSelectable + | Qt.ItemFlag.ItemIsDragEnabled + | Qt.ItemFlag.ItemIsDropEnabled + | Qt.ItemFlag.ItemIsEnabled + ) + return ( + super().flags(index) + | Qt.ItemFlag.ItemIsDragEnabled + | Qt.ItemFlag.ItemIsDropEnabled & Qt.ItemFlag.ItemIsEditable + ) + + def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: + return len(PaksColumns) + + def index( + self, row: int, column: int, parent: QModelIndex = QModelIndex() + ) -> QModelIndex: + if ( + row < 0 + or row >= self.rowCount() + or column < 0 + or column >= self.columnCount() + ): + return QModelIndex() + return self.createIndex(row, column, row) + + @overload + def parent(self, child: QModelIndex) -> QModelIndex: ... + @overload + def parent(self) -> QObject | None: ... + + def parent(self, child: QModelIndex | None = None) -> QModelIndex | QObject | None: + if child is None: + return super().parent() + return QModelIndex() + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + return len(self.paks) + + def setData( + self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole + ) -> bool: + return False + + def headerData( + self, + section: int, + orientation: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, + ) -> typing.Any: + if ( + orientation != Qt.Orientation.Horizontal + or role != Qt.ItemDataRole.DisplayRole + ): + return QVariant() + + column = PaksColumns(section + 1) + match column: + case PaksColumns.PAK_NAME: + return "Pak Group" + case PaksColumns.PRIORITY: + return "Priority" + case PaksColumns.SOURCE: + return "Source" + + return QVariant() + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not index.isValid(): + return None + if index.column() + 1 == PaksColumns.PAK_NAME: + if role == Qt.ItemDataRole.DisplayRole: + return self.paks[index.row()][0] + elif index.column() + 1 == PaksColumns.PRIORITY: + if role == Qt.ItemDataRole.DisplayRole: + return index.row() + elif index.column() + 1 == PaksColumns.SOURCE: + if role == Qt.ItemDataRole.DisplayRole: + return self.paks[index.row()][1] + return QVariant() + + def canDropMimeData( + self, + data: QMimeData | None, + action: Qt.DropAction, + row: int, + column: int, + parent: QModelIndex, + ) -> bool: + if action == Qt.DropAction.MoveAction and (row != -1 or column != -1): + return True + return False + + def supportedDropActions(self) -> Qt.DropAction: + return Qt.DropAction.MoveAction + + def dropMimeData( + self, + data: QMimeData | None, + action: Qt.DropAction, + row: int, + column: int, + parent: QModelIndex, + ) -> bool: + if action == Qt.DropAction.IgnoreAction: + return True + + if data is None: + return False + + encoded: QByteArray = data.data("application/x-qabstractitemmodeldatalist") + stream: QDataStream = QDataStream(encoded, QDataStream.OpenModeFlag.ReadOnly) + source_rows: list[int] = [] + + while not stream.atEnd(): + source_row = stream.readInt() + col = stream.readInt() + size = stream.readInt() + item_data = {} + for _ in range(size): + role = stream.readInt() + value = stream.readQVariant() + item_data[role] = value + if col == 0: + source_rows.append(source_row) + + if row == -1: + row = parent.row() + + if row < 0 or row >= len(self.paks): + new_priority = len(self.paks) + else: + new_priority = row + + before_paks: list[_PakInfo] = [] + moved_paks: list[_PakInfo] = [] + after_paks: list[_PakInfo] = [] + before_paks_p: list[_PakInfo] = [] + moved_paks_p: list[_PakInfo] = [] + after_paks_p: list[_PakInfo] = [] + for row, paks in sorted(self.paks.items()): + if row < new_priority: + if row in source_rows: + if paks[0].casefold()[-2:] == "_p": + moved_paks_p.append(paks) + else: + moved_paks.append(paks) + else: + if paks[0].casefold()[-2:] == "_p": + before_paks_p.append(paks) + else: + before_paks.append(paks) + if row >= new_priority: + if row in source_rows: + if paks[0].casefold()[-2:] == "_p": + moved_paks_p.append(paks) + else: + moved_paks.append(paks) + else: + if paks[0].casefold()[-2:] == "_p": + after_paks_p.append(paks) + else: + after_paks.append(paks) + + new_paks = dict( + enumerate( + itertools.chain( + before_paks, + moved_paks, + after_paks, + before_paks_p, + moved_paks_p, + after_paks_p, + ) + ) + ) + + index = 9999 + for row, pak in new_paks.items(): + current_dir = QDir(pak[2]) + parent_dir = QDir(pak[2]) + parent_dir.cdUp() + if current_dir.exists() and parent_dir.dirName().casefold() == "~mods": + new_paks[row] = ( + pak[0], + pak[1], + pak[2], + parent_dir.absoluteFilePath(str(index).zfill(4)), + ) + index -= 1 + + self.set_paks(new_paks) + return False diff --git a/games/oblivion_remaster/paks/view.py b/games/oblivion_remaster/paks/view.py new file mode 100644 index 00000000..a56b0cdd --- /dev/null +++ b/games/oblivion_remaster/paks/view.py @@ -0,0 +1,33 @@ +from typing import Iterable + +from PyQt6.QtCore import QModelIndex, Qt, pyqtSignal +from PyQt6.QtGui import QDropEvent +from PyQt6.QtWidgets import QAbstractItemView, QTreeView, QWidget + + +class PaksView(QTreeView): + data_dropped = pyqtSignal() + + def __init__(self, parent: QWidget | None): + super().__init__(parent) + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.setDefaultDropAction(Qt.DropAction.MoveAction) + if (viewport := self.viewport()) is not None: + viewport.setAcceptDrops(True) + self.setItemsExpandable(False) + self.setRootIsDecorated(False) + + def dropEvent(self, e: QDropEvent | None): + super().dropEvent(e) + self.clearSelection() + self.data_dropped.emit() + + def dataChanged( + self, topLeft: QModelIndex, bottomRight: QModelIndex, roles: Iterable[int] = () + ): + super().dataChanged(topLeft, bottomRight, roles) + self.repaint() diff --git a/games/oblivion_remaster/paks/widget.py b/games/oblivion_remaster/paks/widget.py new file mode 100644 index 00000000..48e84bfc --- /dev/null +++ b/games/oblivion_remaster/paks/widget.py @@ -0,0 +1,206 @@ +import re +from functools import cmp_to_key +from pathlib import Path +from typing import cast + +from PyQt6.QtCore import QDir, QFileInfo, qDebug +from PyQt6.QtWidgets import QGridLayout, QWidget + +import mobase + +from ....basic_features.utils import is_directory +from .model import PaksModel +from .view import PaksView + + +def pak_sort(a: tuple[str, str], b: tuple[str, str]) -> int: + a_pak, a_str = a[0], a[1] or a[0] + b_pak, b_str = b[0], b[1] or b[0] + + a_pak_ends_p = a_pak.casefold().endswith("_p") + b_pak_ends_p = b_pak.casefold().endswith("_p") + + if a_pak_ends_p == b_pak_ends_p: + if a_str.casefold() <= b_str.casefold(): + return 1 + return -1 + elif a_pak_ends_p: + return 1 + elif b_pak_ends_p: + return -1 + return 0 + + +class PaksTabWidget(QWidget): + def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): + super().__init__(parent) + self._organizer = organizer + self._view = PaksView(self) + self._layout = QGridLayout(self) + self._layout.addWidget(self._view) + self._model = PaksModel(self._view, organizer) + self._view.setModel(self._model) + self._model.dataChanged.connect(self.write_paks_list) # type: ignore + self._view.data_dropped.connect(self.write_paks_list) # type: ignore + organizer.onProfileChanged(lambda profile_a, profile_b: self._parse_pak_files()) + organizer.modList().onModInstalled(lambda mod: self._parse_pak_files()) + organizer.modList().onModRemoved(lambda mod: self._parse_pak_files()) + organizer.modList().onModStateChanged(lambda mods: self._parse_pak_files()) + self._parse_pak_files() + + def write_paks_list(self): + profile = QDir(self._organizer.profilePath()) + paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt")) + with open(paks_txt.absoluteFilePath(), "w") as paks_file: + for _, pak in sorted(self._model.paks.items()): + name, _, _, _ = pak + paks_file.write(f"{name}\n") + self.write_pak_files() + + def write_pak_files(self): + for index, pak in sorted(self._model.paks.items()): + name, _, current_path, target_path = pak + if current_path and current_path != target_path: + path_dir = Path(current_path) + target_dir = Path(target_path) + if not target_dir.exists(): + target_dir.mkdir(parents=True, exist_ok=True) + if path_dir.exists(): + for pak_file in path_dir.glob("*.pak"): + match = re.match(r"^(\d{4}_)?(.*)", pak_file.stem) + if not match: + continue + match_name = ( + match.group(2) if match.group(2) else match.group(1) + ) + if match_name == name: + ucas_file = pak_file.with_suffix(".ucas") + utoc_file = pak_file.with_suffix(".utoc") + for file in (pak_file, ucas_file, utoc_file): + if not file.exists(): + continue + try: + file.rename(target_dir.joinpath(file.name)) + except FileExistsError: + pass + data = self._model.paks[index] + self._model.paks[index] = ( + data[0], + data[1], + data[3], + data[3], + ) + break + if not list(path_dir.iterdir()): + path_dir.unlink() + + def _parse_pak_files(self): + from ...game_oblivion_remaster import OblivionRemasteredGame + + mods = self._organizer.modList().allMods() + paks: dict[str, str] = {} + pak_paths: dict[str, tuple[str, str]] = {} + pak_source: dict[str, str] = {} + for mod in mods: + mod_item = self._organizer.modList().getMod(mod) + if not self._organizer.modList().state(mod) & mobase.ModState.ACTIVE: + continue + filetree = mod_item.fileTree() + pak_mods = filetree.find("Paks/~mods") + if not pak_mods: + pak_mods = filetree.find("Root/OblivionRemastered/Content/Paks/~mods") + if isinstance(pak_mods, mobase.IFileTree): + for entry in pak_mods: + if is_directory(entry): + if entry.name().casefold() == "magicloader": + continue + for sub_entry in entry: + if ( + sub_entry.isFile() + and sub_entry.suffix().casefold() == "pak" + ): + paks[ + sub_entry.name()[: -1 - len(sub_entry.suffix())] + ] = entry.name() + pak_paths[ + sub_entry.name()[: -1 - len(sub_entry.suffix())] + ] = ( + mod_item.absolutePath() + + "/" + + cast(mobase.IFileTree, sub_entry.parent()).path( + "/" + ), + mod_item.absolutePath() + "/" + pak_mods.path("/"), + ) + pak_source[ + sub_entry.name()[: -1 - len(sub_entry.suffix())] + ] = mod_item.name() + else: + if entry.suffix().casefold() == "pak": + paks[entry.name()[: -1 - len(entry.suffix())]] = "" + pak_paths[entry.name()[: -1 - len(entry.suffix())]] = ( + mod_item.absolutePath() + + "/" + + cast(mobase.IFileTree, entry.parent()).path("/"), + mod_item.absolutePath() + "/" + pak_mods.path("/"), + ) + pak_source[entry.name()[: -1 - len(entry.suffix())]] = ( + mod_item.name() + ) + game = self._organizer.managedGame() + if isinstance(game, OblivionRemasteredGame): + pak_mods = QFileInfo(game.paksDirectory().absoluteFilePath("~mods")) + if pak_mods.exists() and pak_mods.isDir(): + for entry in QDir(pak_mods.absoluteFilePath()).entryInfoList( + QDir.Filter.Dirs | QDir.Filter.Files | QDir.Filter.NoDotAndDotDot + ): + if entry.isDir(): + if entry.baseName().casefold() == "magicloader": + continue + for sub_entry in QDir(entry.absoluteFilePath()).entryInfoList( + QDir.Filter.Files + ): + if ( + sub_entry.isFile() + and sub_entry.suffix().casefold() == "pak" + ): + paks[sub_entry.baseName()] = entry.baseName() + pak_paths[sub_entry.baseName()] = ( + sub_entry.absolutePath(), + pak_mods.absolutePath(), + ) + pak_source[sub_entry.baseName()] = "Game Directory" + else: + if entry.suffix().casefold() == "pak": + paks[ + entry.completeBaseName()[: -1 - len(entry.suffix())] + ] = "" + pak_paths[entry.baseName()] = ( + entry.absolutePath(), + pak_mods.absolutePath(), + ) + pak_source[entry.baseName()] = "Game Directory" + sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort))) + qDebug("Sorted Paks:") + for pak, directory in sorted_paks.items(): + item = directory if directory else pak + qDebug(item) + final_paks: dict[str, tuple[str, str, str]] = {} + pak_index = 9999 + for pak in sorted_paks.keys(): + match = re.match(r"^(\d{4}_)?(.*)$", pak) + if not match: + continue + name = match.group(2) if match.group(2) else match.group(1) + target_dir = pak_paths[pak][1] + "/" + str(pak_index).zfill(4) + final_paks[name] = (pak_source[pak], pak_paths[pak][0], target_dir) + pak_index -= 1 + new_data_paks: dict[int, tuple[str, str, str, str]] = {} + i = 0 + qDebug("Final Paks:") + for pak, data in final_paks.items(): + qDebug(pak) + source, current_path, target_path = data + new_data_paks[i] = (pak, source, current_path, target_path) + i += 1 + self._model.set_paks(new_data_paks) diff --git a/games/oblivion_remaster/script_extender.py b/games/oblivion_remaster/script_extender.py new file mode 100644 index 00000000..571e6758 --- /dev/null +++ b/games/oblivion_remaster/script_extender.py @@ -0,0 +1,37 @@ +from pathlib import Path + +import mobase + + +class OblivionRemasteredScriptExtender(mobase.ScriptExtender): + def __init__(self, game: mobase.IPluginGame): + super().__init__() + self._game = game + + def binaryName(self): + return "obse64_loader.exe" + + def loaderName(self) -> str: + return self.binaryName() + + def loaderPath(self) -> str: + return ( + self._game.gameDirectory().absolutePath() + + "\\OblivionRemastered\\Binaries\\Win64\\" + + self.loaderName() + ) + + def pluginPath(self) -> str: + return "OBSE/Plugins" + + def savegameExtension(self) -> str: + return "" + + def isInstalled(self) -> bool: + return Path(self.loaderPath()).exists() + + def getExtenderVersion(self) -> str: + return mobase.getFileVersion(self.loaderPath()) + + def getArch(self) -> int: + return 0x8664 if self.isInstalled() else 0x0 diff --git a/games/oblivion_remaster/ue4ss/__init__.py b/games/oblivion_remaster/ue4ss/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/games/oblivion_remaster/ue4ss/model.py b/games/oblivion_remaster/ue4ss/model.py new file mode 100644 index 00000000..9caf7c51 --- /dev/null +++ b/games/oblivion_remaster/ue4ss/model.py @@ -0,0 +1,116 @@ +import json +from typing import Any, Iterable + +from PyQt6.QtCore import ( + QDir, + QFileInfo, + QMimeData, + QModelIndex, + QStringListModel, + Qt, +) +from PyQt6.QtWidgets import QWidget + +import mobase + + +class UE4SSListModel(QStringListModel): + def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): + super().__init__(parent) + self._checked_items: set[str] = set() + self._organizer = organizer + self._init_mod_states() + + def _init_mod_states(self): + profile = QDir(self._organizer.profilePath()) + mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) + if mods_json.exists(): + with open(mods_json.absoluteFilePath(), "r") as json_file: + mod_data = json.load(json_file) + for mod in mod_data: + if mod["mod_enabled"]: + self._checked_items.add(mod["mod_name"]) + + def _set_mod_states(self): + profile = QDir(self._organizer.profilePath()) + mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) + mod_list: dict[str, bool] = {} + if mods_json.exists(): + with open(mods_json.absoluteFilePath(), "r") as json_file: + mod_data = json.load(json_file) + for mod in mod_data: + mod_list[mod["mod_name"]] = mod["mod_enabled"] + for i in range(self.rowCount()): + item = self.index(i, 0) + name = self.data(item, Qt.ItemDataRole.DisplayRole) + if name in mod_list: + self.setData( + item, + True if mod_list[name] else False, + Qt.ItemDataRole.CheckStateRole, + ) + else: + self.setData(item, True, Qt.ItemDataRole.CheckStateRole) + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + flags = super().flags(index) + if not index.isValid(): + return ( + Qt.ItemFlag.ItemIsSelectable + | Qt.ItemFlag.ItemIsDragEnabled + | Qt.ItemFlag.ItemIsDropEnabled + | Qt.ItemFlag.ItemIsEnabled + ) + return ( + flags + | Qt.ItemFlag.ItemIsUserCheckable + | Qt.ItemFlag.ItemIsDragEnabled & Qt.ItemFlag.ItemIsEditable + ) + + def setData( + self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole + ) -> bool: + if not index.isValid() or role != Qt.ItemDataRole.CheckStateRole: + return False + + if ( + bool(value) + and self.data(index, Qt.ItemDataRole.DisplayRole) not in self._checked_items + ): + self._checked_items.add(self.data(index, Qt.ItemDataRole.DisplayRole)) + elif ( + not bool(value) + and self.data(index, Qt.ItemDataRole.DisplayRole) in self._checked_items + ): + self._checked_items.remove(self.data(index, Qt.ItemDataRole.DisplayRole)) + self.dataChanged.emit(index, index, [role]) + return True + + def setStringList(self, strings: Iterable[str | None]): + super().setStringList(strings) + self._set_mod_states() + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not index.isValid(): + return None + + if role == Qt.ItemDataRole.CheckStateRole: + return ( + Qt.CheckState.Checked + if self.data(index, Qt.ItemDataRole.DisplayRole) in self._checked_items + else Qt.CheckState.Unchecked + ) + + return super().data(index, role) + + def canDropMimeData( + self, + data: QMimeData | None, + action: Qt.DropAction, + row: int, + column: int, + parent: QModelIndex, + ) -> bool: + if action == Qt.DropAction.MoveAction and (row != -1 or column != -1): + return True + return False diff --git a/games/oblivion_remaster/ue4ss/view.py b/games/oblivion_remaster/ue4ss/view.py new file mode 100644 index 00000000..bb994ca6 --- /dev/null +++ b/games/oblivion_remaster/ue4ss/view.py @@ -0,0 +1,31 @@ +from typing import Iterable + +from PyQt6.QtCore import QModelIndex, Qt, pyqtSignal +from PyQt6.QtGui import QDropEvent +from PyQt6.QtWidgets import QAbstractItemView, QListView, QWidget + + +class UE4SSView(QListView): + data_dropped = pyqtSignal() + + def __init__(self, parent: QWidget | None): + super().__init__(parent) + self.setAcceptDrops(True) + self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.setDefaultDropAction(Qt.DropAction.MoveAction) + if (viewport := self.viewport()) is not None: + viewport.setAcceptDrops(True) + + def dropEvent(self, e: QDropEvent | None): + super().dropEvent(e) + self.data_dropped.emit() + + def dataChanged( + self, topLeft: QModelIndex, bottomRight: QModelIndex, roles: Iterable[int] = () + ): + super().dataChanged(topLeft, bottomRight, roles) + self.repaint() diff --git a/games/oblivion_remaster/ue4ss/widget.py b/games/oblivion_remaster/ue4ss/widget.py new file mode 100644 index 00000000..59900d1f --- /dev/null +++ b/games/oblivion_remaster/ue4ss/widget.py @@ -0,0 +1,176 @@ +import json +from functools import cmp_to_key +from pathlib import Path +from typing import TypedDict + +from PyQt6.QtCore import QDir, QFileInfo, Qt +from PyQt6.QtWidgets import QGridLayout, QWidget + +import mobase + +from .model import UE4SSListModel +from .view import UE4SSView + + +class UE4SSModInfo(TypedDict): + mod_name: str + mod_enabled: bool + + +class UE4SSTabWidget(QWidget): + def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): + super().__init__(parent) + self._organizer = organizer + self._view = UE4SSView(self) + self._layout = QGridLayout(self) + self._layout.addWidget(self._view) + self._model = UE4SSListModel(self._view, organizer) + self._view.setModel(self._model) + self._model.dataChanged.connect(self.write_mod_list) # type: ignore + self._view.data_dropped.connect(self.write_mod_list) # type: ignore + organizer.onProfileChanged(lambda profile_a, profile_b: self._parse_mod_files()) + organizer.modList().onModInstalled(self.update_mod_files) + organizer.modList().onModRemoved(lambda mod: self._parse_mod_files()) + organizer.modList().onModStateChanged(self.update_mod_files) + self._parse_mod_files() + + def get_mod_list(self) -> list[str]: + mod_list: list[str] = [] + for index in range(self._model.rowCount()): + mod_list.append( + self._model.data( + self._model.index(index, 0), Qt.ItemDataRole.DisplayRole + ) + ) + return mod_list + + def update_mod_files( + self, mods: dict[str, mobase.ModState] | mobase.IModInterface | str + ): + mod_list: list[mobase.IModInterface] = [] + if isinstance(mods, dict): + for mod in mods.keys(): + mod_list.append(self._organizer.modList().getMod(mod)) + elif isinstance(mods, mobase.IModInterface): + mod_list.append(mods) + else: + mod_list.append(self._organizer.modList().getMod(mods)) + + for mod in mod_list: + tree = mod.fileTree() + ue4ss_files = tree.find("UE4SS") + if not ue4ss_files: + ue4ss_files = tree.find( + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) + if isinstance(ue4ss_files, mobase.IFileTree): + for entry in ue4ss_files: + if isinstance(entry, mobase.IFileTree): + if enabled_txt := entry.find("enabled.txt"): + try: + Path(mod.absolutePath(), enabled_txt.path("/")).unlink() + self._organizer.modDataChanged(mod) + except FileNotFoundError: + pass + + self._parse_mod_files() + + def _parse_mod_files(self): + from ...game_oblivion_remaster import OblivionRemasteredGame + + mod_list: set[str] = set() + for mod in self._organizer.modList().allMods(): + if ( + mobase.ModState(self._organizer.modList().state(mod)) + & mobase.ModState.ACTIVE + ): + tree = self._organizer.modList().getMod(mod).fileTree() + ue4ss_files = tree.find("UE4SS") + if not ue4ss_files: + ue4ss_files = tree.find( + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) + if isinstance(ue4ss_files, mobase.IFileTree): + for entry in ue4ss_files: + if isinstance(entry, mobase.IFileTree): + if entry.find("scripts/main.lua"): + mod_list.add(entry.name()) + if enabled_txt := entry.find("enabled.txt"): + try: + Path( + self._organizer.modList() + .getMod(mod) + .absolutePath(), + enabled_txt.path("/"), + ).unlink() + self._organizer.modDataChanged( + self._organizer.modList().getMod(mod) + ) + except FileNotFoundError: + pass + + game = self._organizer.managedGame() + if isinstance(game, OblivionRemasteredGame): + if game.ue4ssDirectory().exists(): + for dir_info in game.ue4ssDirectory().entryInfoList( + QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot + ): + if QFileInfo( + QDir(dir_info.absoluteFilePath()).absoluteFilePath( + "scripts/main.lua" + ) + ).exists(): + mod_list.add(dir_info.fileName()) + if QFileInfo( + QDir(dir_info.absoluteFilePath()).absoluteFilePath( + "enabled.txt" + ) + ).exists(): + Path(dir_info.absoluteFilePath(), "enabled.txt").unlink() + + final_list = sorted(mod_list, key=cmp_to_key(self.sort_mods)) + self._model.setStringList(final_list) + + def write_mod_list(self): + mod_list: list[UE4SSModInfo] = [] + profile = QDir(self._organizer.profilePath()) + mods_txt = QFileInfo(profile.absoluteFilePath("mods.txt")) + with open(mods_txt.absoluteFilePath(), "w") as txt_file: + for i in range(self._model.rowCount()): + item = self._model.index(i, 0) + name = self._model.data(item, Qt.ItemDataRole.DisplayRole) + active = ( + self._model.data(item, Qt.ItemDataRole.CheckStateRole) + == Qt.CheckState.Checked + ) + mod_list.append({"mod_name": name, "mod_enabled": active}) + txt_file.write(f"{name} : {1 if active else 0}\n") + mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) + with open(mods_json.absoluteFilePath(), "w") as json_file: + json_file.write(json.dumps(mod_list, indent=4)) + + def sort_mods(self, mod_a: str, mod_b: str) -> int: + profile = QDir(self._organizer.profilePath()) + mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) + mods_list: list[str] = [] + if mods_json.exists() and mods_json.isFile(): + with open(mods_json.absoluteFilePath(), "r") as json_file: + mods = json.load(json_file) + for mod in mods: + if mod["mod_enabled"]: + mods_list.append(mod["mod_name"]) + index_a = -1 + if mod_a in mods_list: + index_a = mods_list.index(mod_a) + index_b = -1 + if mod_b in mods_list: + index_b = mods_list.index(mod_b) + if index_a != -1 and index_b != -1: + return index_a - index_b + if index_a != -1: + return -1 + if index_b != -1: + return 1 + if mod_a < mod_b: + return -1 + return 1 diff --git a/pyproject.toml b/pyproject.toml index 3ec5c87d..8316dd98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,9 @@ target-version = "py310" [tool.ruff.lint] extend-select = ["B", "Q", "I"] +[tool.ruff.lint.flake8-bugbear] +extend-immutable-calls = ["PyQt6.QtCore.QModelIndex"] + [tool.ruff.lint.isort] known-first-party = ["mobase"] From a46b9e4d580450a99a5a7dc76455ae12649f4b3c Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Wed, 21 May 2025 19:14:41 -0500 Subject: [PATCH 23/32] Pak fixes - Revise directory cleaning after refactor - Implement paks.txt load order cache --- games/game_oblivion_remaster.py | 2 +- games/oblivion_remaster/paks/model.py | 4 +- games/oblivion_remaster/paks/widget.py | 73 ++++++++++++++++++-------- 3 files changed, 54 insertions(+), 25 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 54587fcb..7a3273a3 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -59,7 +59,7 @@ class OblivionRemasteredGame( Name = PLUGIN_NAME Author = "Silarn" Version = "0.1.0-b.1" - Description = "TES IV: Oblivion Remastered; an unholy hybrid of Gamebryo and Unreal" + Description = "TES IV: Oblivion Remastered; an unholy hybrid of Bethesda and Unreal" GameName = "Oblivion Remastered" GameShortName = "oblivionremastered" diff --git a/games/oblivion_remaster/paks/model.py b/games/oblivion_remaster/paks/model.py index 8cdfdc0c..3b60d2f0 100644 --- a/games/oblivion_remaster/paks/model.py +++ b/games/oblivion_remaster/paks/model.py @@ -31,7 +31,7 @@ class PaksColumns(IntEnum): class PaksModel(QAbstractItemModel): def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): super().__init__(parent) - self.paks: dict[int, tuple[str, str, str, str]] = {} + self.paks: dict[int, _PakInfo] = {} self._organizer = organizer self._init_mod_states() @@ -45,7 +45,7 @@ def _init_mod_states(self): self.paks[index] = (line, "", "", "") index += 1 - def set_paks(self, paks: dict[int, tuple[str, str, str, str]]): + def set_paks(self, paks: dict[int, _PakInfo]): self.layoutAboutToBeChanged.emit() self.paks = paks self.layoutChanged.emit() diff --git a/games/oblivion_remaster/paks/widget.py b/games/oblivion_remaster/paks/widget.py index 48e84bfc..6db484b4 100644 --- a/games/oblivion_remaster/paks/widget.py +++ b/games/oblivion_remaster/paks/widget.py @@ -9,7 +9,7 @@ import mobase from ....basic_features.utils import is_directory -from .model import PaksModel +from .model import PaksModel, _PakInfo from .view import PaksView @@ -48,6 +48,16 @@ def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): organizer.modList().onModStateChanged(lambda mods: self._parse_pak_files()) self._parse_pak_files() + def load_paks_list(self) -> list[str]: + profile = QDir(self._organizer.profilePath()) + paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt")) + paks_list: list[str] = [] + if paks_txt.exists(): + with open(paks_txt.absoluteFilePath(), "r") as paks_file: + for line in paks_file: + paks_list.append(line.strip()) + return paks_list + def write_paks_list(self): profile = QDir(self._organizer.profilePath()) paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt")) @@ -91,8 +101,29 @@ def write_pak_files(self): data[3], ) break - if not list(path_dir.iterdir()): - path_dir.unlink() + if not list(path_dir.iterdir()): + path_dir.rmdir() + + def _shake_paks(self, sorted_paks: dict[str, str]) -> list[str]: + shaken_paks: list[str] = [] + shaken_paks_p: list[str] = [] + paks_list = self.load_paks_list() + qDebug("Read paks...") + for pak in paks_list: + qDebug(pak) + for pak in paks_list: + if pak in sorted_paks.keys(): + if pak.casefold().endswith("_p"): + shaken_paks_p.append(pak) + else: + shaken_paks.append(pak) + sorted_paks.pop(pak) + for pak in sorted_paks.keys(): + if pak.casefold().endswith("_p"): + shaken_paks_p.append(pak) + else: + shaken_paks.append(pak) + return shaken_paks + shaken_paks_p def _parse_pak_files(self): from ...game_oblivion_remaster import OblivionRemasteredGame @@ -155,7 +186,7 @@ def _parse_pak_files(self): QDir.Filter.Dirs | QDir.Filter.Files | QDir.Filter.NoDotAndDotDot ): if entry.isDir(): - if entry.baseName().casefold() == "magicloader": + if entry.completeBaseName().casefold() == "magicloader": continue for sub_entry in QDir(entry.absoluteFilePath()).entryInfoList( QDir.Filter.Files @@ -164,42 +195,40 @@ def _parse_pak_files(self): sub_entry.isFile() and sub_entry.suffix().casefold() == "pak" ): - paks[sub_entry.baseName()] = entry.baseName() - pak_paths[sub_entry.baseName()] = ( + paks[sub_entry.completeBaseName()] = ( + entry.completeBaseName() + ) + pak_paths[sub_entry.completeBaseName()] = ( sub_entry.absolutePath(), pak_mods.absolutePath(), ) - pak_source[sub_entry.baseName()] = "Game Directory" + pak_source[sub_entry.completeBaseName()] = ( + "Game Directory" + ) else: if entry.suffix().casefold() == "pak": paks[ entry.completeBaseName()[: -1 - len(entry.suffix())] ] = "" - pak_paths[entry.baseName()] = ( + pak_paths[entry.completeBaseName()] = ( entry.absolutePath(), pak_mods.absolutePath(), ) - pak_source[entry.baseName()] = "Game Directory" + pak_source[entry.completeBaseName()] = "Game Directory" sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort))) - qDebug("Sorted Paks:") - for pak, directory in sorted_paks.items(): - item = directory if directory else pak - qDebug(item) + shaken_paks: list[str] = self._shake_paks(sorted_paks) + qDebug("Shaken paks:") + for pak in shaken_paks: + qDebug(pak) final_paks: dict[str, tuple[str, str, str]] = {} pak_index = 9999 - for pak in sorted_paks.keys(): - match = re.match(r"^(\d{4}_)?(.*)$", pak) - if not match: - continue - name = match.group(2) if match.group(2) else match.group(1) + for pak in shaken_paks: target_dir = pak_paths[pak][1] + "/" + str(pak_index).zfill(4) - final_paks[name] = (pak_source[pak], pak_paths[pak][0], target_dir) + final_paks[pak] = (pak_source[pak], pak_paths[pak][0], target_dir) pak_index -= 1 - new_data_paks: dict[int, tuple[str, str, str, str]] = {} + new_data_paks: dict[int, _PakInfo] = {} i = 0 - qDebug("Final Paks:") for pak, data in final_paks.items(): - qDebug(pak) source, current_path, target_path = data new_data_paks[i] = (pak, source, current_path, target_path) i += 1 From ca4d9ad5ec5a81c4380e5b222d9a8635448d8b73 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Wed, 21 May 2025 19:17:13 -0500 Subject: [PATCH 24/32] Don't use private type --- games/oblivion_remaster/paks/widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/games/oblivion_remaster/paks/widget.py b/games/oblivion_remaster/paks/widget.py index 6db484b4..d6a7636b 100644 --- a/games/oblivion_remaster/paks/widget.py +++ b/games/oblivion_remaster/paks/widget.py @@ -9,7 +9,7 @@ import mobase from ....basic_features.utils import is_directory -from .model import PaksModel, _PakInfo +from .model import PaksModel from .view import PaksView @@ -226,7 +226,7 @@ def _parse_pak_files(self): target_dir = pak_paths[pak][1] + "/" + str(pak_index).zfill(4) final_paks[pak] = (pak_source[pak], pak_paths[pak][0], target_dir) pak_index -= 1 - new_data_paks: dict[int, _PakInfo] = {} + new_data_paks: dict[int, tuple[str, str, str, str]] = {} i = 0 for pak, data in final_paks.items(): source, current_path, target_path = data From 5fe88cfe88de6effe5237da27a44ab0b22ff8c00 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Wed, 21 May 2025 19:17:51 -0500 Subject: [PATCH 25/32] Remove debug lines --- games/oblivion_remaster/paks/widget.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/games/oblivion_remaster/paks/widget.py b/games/oblivion_remaster/paks/widget.py index d6a7636b..cc3a0d12 100644 --- a/games/oblivion_remaster/paks/widget.py +++ b/games/oblivion_remaster/paks/widget.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import cast -from PyQt6.QtCore import QDir, QFileInfo, qDebug +from PyQt6.QtCore import QDir, QFileInfo from PyQt6.QtWidgets import QGridLayout, QWidget import mobase @@ -108,9 +108,6 @@ def _shake_paks(self, sorted_paks: dict[str, str]) -> list[str]: shaken_paks: list[str] = [] shaken_paks_p: list[str] = [] paks_list = self.load_paks_list() - qDebug("Read paks...") - for pak in paks_list: - qDebug(pak) for pak in paks_list: if pak in sorted_paks.keys(): if pak.casefold().endswith("_p"): @@ -217,9 +214,6 @@ def _parse_pak_files(self): pak_source[entry.completeBaseName()] = "Game Directory" sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort))) shaken_paks: list[str] = self._shake_paks(sorted_paks) - qDebug("Shaken paks:") - for pak in shaken_paks: - qDebug(pak) final_paks: dict[str, tuple[str, str, str]] = {} pak_index = 9999 for pak in shaken_paks: From 5020934d55161fec8e0bd26faaa4578bc5a18a05 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Wed, 21 May 2025 19:39:54 -0500 Subject: [PATCH 26/32] Fix for null entries --- games/oblivion_remaster/mod_data_checker.py | 63 +++++++++++---------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/games/oblivion_remaster/mod_data_checker.py b/games/oblivion_remaster/mod_data_checker.py index 3ab9460b..d34fef52 100644 --- a/games/oblivion_remaster/mod_data_checker.py +++ b/games/oblivion_remaster/mod_data_checker.py @@ -198,37 +198,38 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: ]: filetree = self.parse_directory(filetree, directory) for entry in filetree: - if entry.isFile(): - name = entry.name().casefold() - if name.endswith(".pak"): - paks_dir = self.get_dir(filetree, "Paks/~mods") - pak_files: list[mobase.FileTreeEntry] = [] - for file in _parent(entry): - if file.isFile(): - if ( - file.name() - .casefold() - .endswith((".pak", ".ucas", ".utoc")) - ): - pak_files.append(file) - for pak_file in pak_files: - pak_file.moveTo(paks_dir) - elif name.endswith(".bk2"): - movies_dir = self.get_dir(filetree, "Movies/Modern") - movie_files: list[mobase.FileTreeEntry] = [] - for file in _parent(entry): - if file.isFile(): - if file.name().casefold().endswith(".bk2"): - movie_files.append(file) - for movie_file in movie_files: - movie_file.moveTo(movies_dir) - elif name.endswith(tuple(self._data_extensions)): - data_dir = self.get_dir(filetree, "Data") - data_files: list[mobase.FileTreeEntry] = [] - for file in _parent(entry): - data_files.append(file) - for data_file in data_files: - data_file.moveTo(data_dir) + if entry is not None: + if entry.isFile(): + name = entry.name().casefold() + if name.endswith(".pak"): + paks_dir = self.get_dir(filetree, "Paks/~mods") + pak_files: list[mobase.FileTreeEntry] = [] + for file in _parent(entry): + if file.isFile(): + if ( + file.name() + .casefold() + .endswith((".pak", ".ucas", ".utoc")) + ): + pak_files.append(file) + for pak_file in pak_files: + pak_file.moveTo(paks_dir) + elif name.endswith(".bk2"): + movies_dir = self.get_dir(filetree, "Movies/Modern") + movie_files: list[mobase.FileTreeEntry] = [] + for file in _parent(entry): + if file.isFile(): + if file.name().casefold().endswith(".bk2"): + movie_files.append(file) + for movie_file in movie_files: + movie_file.moveTo(movies_dir) + elif name.endswith(tuple(self._data_extensions)): + data_dir = self.get_dir(filetree, "Data") + data_files: list[mobase.FileTreeEntry] = [] + for file in _parent(entry): + data_files.append(file) + for data_file in data_files: + data_file.moveTo(data_dir) return filetree def parse_directory( From 86a1a6f2eca922e1b42957df8a383640d3a8db97 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Thu, 22 May 2025 12:29:53 -0500 Subject: [PATCH 27/32] Fix extension removal for game dir --- games/oblivion_remaster/paks/widget.py | 47 +++++++++++--------------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/games/oblivion_remaster/paks/widget.py b/games/oblivion_remaster/paks/widget.py index cc3a0d12..f2ee63f9 100644 --- a/games/oblivion_remaster/paks/widget.py +++ b/games/oblivion_remaster/paks/widget.py @@ -147,12 +147,11 @@ def _parse_pak_files(self): sub_entry.isFile() and sub_entry.suffix().casefold() == "pak" ): - paks[ - sub_entry.name()[: -1 - len(sub_entry.suffix())] - ] = entry.name() - pak_paths[ - sub_entry.name()[: -1 - len(sub_entry.suffix())] - ] = ( + pak_name = sub_entry.name()[ + : -1 - len(sub_entry.suffix()) + ] + paks[pak_name] = entry.name() + pak_paths[pak_name] = ( mod_item.absolutePath() + "/" + cast(mobase.IFileTree, sub_entry.parent()).path( @@ -160,21 +159,18 @@ def _parse_pak_files(self): ), mod_item.absolutePath() + "/" + pak_mods.path("/"), ) - pak_source[ - sub_entry.name()[: -1 - len(sub_entry.suffix())] - ] = mod_item.name() + pak_source[pak_name] = mod_item.name() else: if entry.suffix().casefold() == "pak": - paks[entry.name()[: -1 - len(entry.suffix())]] = "" - pak_paths[entry.name()[: -1 - len(entry.suffix())]] = ( + pak_name = entry.name()[: -1 - len(entry.suffix())] + paks[pak_name] = "" + pak_paths[pak_name] = ( mod_item.absolutePath() + "/" + cast(mobase.IFileTree, entry.parent()).path("/"), mod_item.absolutePath() + "/" + pak_mods.path("/"), ) - pak_source[entry.name()[: -1 - len(entry.suffix())]] = ( - mod_item.name() - ) + pak_source[pak_name] = mod_item.name() game = self._organizer.managedGame() if isinstance(game, OblivionRemasteredGame): pak_mods = QFileInfo(game.paksDirectory().absoluteFilePath("~mods")) @@ -192,26 +188,21 @@ def _parse_pak_files(self): sub_entry.isFile() and sub_entry.suffix().casefold() == "pak" ): - paks[sub_entry.completeBaseName()] = ( - entry.completeBaseName() - ) - pak_paths[sub_entry.completeBaseName()] = ( + pak_name = sub_entry.completeBaseName() + paks[pak_name] = entry.completeBaseName() + pak_paths[pak_name] = ( sub_entry.absolutePath(), pak_mods.absolutePath(), ) - pak_source[sub_entry.completeBaseName()] = ( - "Game Directory" - ) + pak_source[pak_name] = "Game Directory" else: if entry.suffix().casefold() == "pak": - paks[ - entry.completeBaseName()[: -1 - len(entry.suffix())] - ] = "" - pak_paths[entry.completeBaseName()] = ( - entry.absolutePath(), - pak_mods.absolutePath(), + pak_name = entry.completeBaseName() + paks[pak_name] = "" + pak_paths[pak_name] = ( + entry.absolutePath(), pak_mods.absolutePath(), ) - pak_source[entry.completeBaseName()] = "Game Directory" + pak_source[pak_name] = "Game Directory" sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort))) shaken_paks: list[str] = self._shake_paks(sorted_paks) final_paks: dict[str, tuple[str, str, str]] = {} From aa535d3b3caca177e9123d66476445cfd729afa3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 22 May 2025 17:30:49 +0000 Subject: [PATCH 28/32] [pre-commit.ci] Auto fixes from pre-commit.com hooks. --- games/oblivion_remaster/paks/widget.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/games/oblivion_remaster/paks/widget.py b/games/oblivion_remaster/paks/widget.py index f2ee63f9..472413ad 100644 --- a/games/oblivion_remaster/paks/widget.py +++ b/games/oblivion_remaster/paks/widget.py @@ -200,7 +200,8 @@ def _parse_pak_files(self): pak_name = entry.completeBaseName() paks[pak_name] = "" pak_paths[pak_name] = ( - entry.absolutePath(), pak_mods.absolutePath(), + entry.absolutePath(), + pak_mods.absolutePath(), ) pak_source[pak_name] = "Game Directory" sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort))) From 18f8e2356384239b222c1fe73afa836d4eecbf4d Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Thu, 22 May 2025 12:51:41 -0500 Subject: [PATCH 29/32] Alternate check to appease Pyright --- games/oblivion_remaster/mod_data_checker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/games/oblivion_remaster/mod_data_checker.py b/games/oblivion_remaster/mod_data_checker.py index d34fef52..f18fdc0d 100644 --- a/games/oblivion_remaster/mod_data_checker.py +++ b/games/oblivion_remaster/mod_data_checker.py @@ -198,7 +198,7 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: ]: filetree = self.parse_directory(filetree, directory) for entry in filetree: - if entry is not None: + if isinstance(entry, mobase.FileTreeEntry): if entry.isFile(): name = entry.name().casefold() if name.endswith(".pak"): From d292c1cf2177dbb14f17651fd85531a3f54ff435 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Thu, 22 May 2025 13:00:25 -0500 Subject: [PATCH 30/32] Process entries out of the iterator --- games/oblivion_remaster/mod_data_checker.py | 66 +++++++++++---------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/games/oblivion_remaster/mod_data_checker.py b/games/oblivion_remaster/mod_data_checker.py index f18fdc0d..8468910b 100644 --- a/games/oblivion_remaster/mod_data_checker.py +++ b/games/oblivion_remaster/mod_data_checker.py @@ -197,39 +197,41 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: dirname.lower() for dirname in self._dirs ]: filetree = self.parse_directory(filetree, directory) + entries: list[mobase.FileTreeEntry] = [] for entry in filetree: - if isinstance(entry, mobase.FileTreeEntry): - if entry.isFile(): - name = entry.name().casefold() - if name.endswith(".pak"): - paks_dir = self.get_dir(filetree, "Paks/~mods") - pak_files: list[mobase.FileTreeEntry] = [] - for file in _parent(entry): - if file.isFile(): - if ( - file.name() - .casefold() - .endswith((".pak", ".ucas", ".utoc")) - ): - pak_files.append(file) - for pak_file in pak_files: - pak_file.moveTo(paks_dir) - elif name.endswith(".bk2"): - movies_dir = self.get_dir(filetree, "Movies/Modern") - movie_files: list[mobase.FileTreeEntry] = [] - for file in _parent(entry): - if file.isFile(): - if file.name().casefold().endswith(".bk2"): - movie_files.append(file) - for movie_file in movie_files: - movie_file.moveTo(movies_dir) - elif name.endswith(tuple(self._data_extensions)): - data_dir = self.get_dir(filetree, "Data") - data_files: list[mobase.FileTreeEntry] = [] - for file in _parent(entry): - data_files.append(file) - for data_file in data_files: - data_file.moveTo(data_dir) + entries.append(entry) + for entry in entries: + if entry.parent() == filetree and entry.isFile(): + name = entry.name().casefold() + if name.endswith(".pak"): + paks_dir = self.get_dir(filetree, "Paks/~mods") + pak_files: list[mobase.FileTreeEntry] = [] + for file in _parent(entry): + if file.isFile(): + if ( + file.name() + .casefold() + .endswith((".pak", ".ucas", ".utoc")) + ): + pak_files.append(file) + for pak_file in pak_files: + pak_file.moveTo(paks_dir) + elif name.endswith(".bk2"): + movies_dir = self.get_dir(filetree, "Movies/Modern") + movie_files: list[mobase.FileTreeEntry] = [] + for file in _parent(entry): + if file.isFile(): + if file.name().casefold().endswith(".bk2"): + movie_files.append(file) + for movie_file in movie_files: + movie_file.moveTo(movies_dir) + elif name.endswith(tuple(self._data_extensions)): + data_dir = self.get_dir(filetree, "Data") + data_files: list[mobase.FileTreeEntry] = [] + for file in _parent(entry): + data_files.append(file) + for data_file in data_files: + data_file.moveTo(data_dir) return filetree def parse_directory( From 8751c246c06ad1389a066fe0a3295ea8b3352890 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Thu, 22 May 2025 17:47:47 -0500 Subject: [PATCH 31/32] Several improvements - GameSettings support - ML exe detection - Validation for body morph and npc appearance directories --- games/game_oblivion_remaster.py | 10 +++++-- games/oblivion_remaster/mod_data_checker.py | 32 ++++++++++++++------- games/oblivion_remaster/mod_data_content.py | 26 +++++++++++++++-- 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index 7a3273a3..c44857cd 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -123,7 +123,7 @@ def init_tab(self, main_window: QMainWindow): self._paks_tab = PaksTabWidget(main_window, self._organizer) - tab_index = tab_widget.count() + tab_index += 1 tab_widget.insertTab(tab_index, self._paks_tab, "Paks") def settings(self) -> list[mobase.PluginSetting]: @@ -153,13 +153,18 @@ def executables(self): 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 @@ -294,6 +299,7 @@ def getModMappings(self) -> dict[str, list[str]]: "OBSE": [self.obseDirectory().absolutePath()], "Movies": [self.moviesDirectory().absolutePath()], "UE4SS": [self.ue4ssDirectory().absolutePath()], + "GameSettings": [self.exeDirectory().absoluteFilePath("GameSettings")], } def activeProblems(self) -> list[int]: diff --git a/games/oblivion_remaster/mod_data_checker.py b/games/oblivion_remaster/mod_data_checker.py index 8468910b..dfafb3e3 100644 --- a/games/oblivion_remaster/mod_data_checker.py +++ b/games/oblivion_remaster/mod_data_checker.py @@ -13,12 +13,11 @@ def _parent(entry: mobase.FileTreeEntry): class OblivionRemasteredModDataChecker(mobase.ModDataChecker): - _dirs = ["Data", "Paks", "OBSE", "Movies", "UE4SS", "Root"] + _dirs = ["Data", "Paks", "OBSE", "Movies", "UE4SS", "GameSettings", "Root"] _data_dirs = [ "meshes", "textures", "music", - # "scripts", "fonts", "interface", "shaders", @@ -57,7 +56,11 @@ def dataLooksValid( if sub_entry.find("scripts/main.lua"): status = mobase.ModDataChecker.FIXABLE break - if sub_entry.name().casefold() == "shared": + if sub_entry.name().casefold() in [ + "shared", + "npcappearancemanager", + "naturalbodymorph", + ]: status = mobase.ModDataChecker.FIXABLE break else: @@ -66,7 +69,11 @@ def dataLooksValid( if sub_entry.find("scripts/main.lua"): status = mobase.ModDataChecker.VALID break - if sub_entry.name().casefold() == "shared": + if sub_entry.name().casefold() in [ + "shared", + "npcappearancemanager", + "naturalbodymorph", + ]: status = mobase.ModDataChecker.VALID break else: @@ -137,11 +144,16 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: ) exe_dir = filetree.find(r"OblivionRemastered\Binaries\Win64") if isinstance(exe_dir, mobase.IFileTree): + gamesettings_dir = exe_dir.find("GameSettings") + if isinstance(gamesettings_dir, mobase.IFileTree): + gamesettings_main = self.get_dir(filetree, "GameSettings") + gamesettings_main.merge(gamesettings_dir, True) + self.detach_parents(gamesettings_dir) obse_dir = exe_dir.find("OBSE") if isinstance(obse_dir, mobase.IFileTree): obse_main = self.get_dir(filetree, "OBSE") obse_main.merge(obse_dir, True) - obse_dir.detach() + self.detach_parents(obse_dir) ue4ss_mod_dir = exe_dir.find("ue4ss/Mods") if isinstance(ue4ss_mod_dir, mobase.IFileTree): if self._organizer.pluginSetting(PLUGIN_NAME, "ue4ss_use_root_builder"): @@ -151,14 +163,15 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: else: ue4ss_main = self.get_dir(filetree, "UE4SS") ue4ss_main.merge(ue4ss_mod_dir, True) - ue4ss_mod_dir.detach() + self.detach_parents(ue4ss_mod_dir) if len(exe_dir): root_exe_dir = self.get_dir( filetree, "Root/OblivionRemastered/Binaries" ) - parent = _parent(exe_dir) + parent = exe_dir.parent() exe_dir.moveTo(root_exe_dir) - self.detach_parents(parent) + if parent: + self.detach_parents(parent) else: self.detach_parents(exe_dir) directories: list[mobase.IFileTree] = [] @@ -350,8 +363,7 @@ def detach_parents(self, directory: mobase.IFileTree) -> None: assert parent is not None parent.detach() else: - if len(directory) == 1: - directory.detach() + directory.detach() def get_dir(self, filetree: mobase.IFileTree, directory: str) -> mobase.IFileTree: tree_dir = filetree.find(directory) diff --git a/games/oblivion_remaster/mod_data_content.py b/games/oblivion_remaster/mod_data_content.py index d2aab4a9..32fb282b 100644 --- a/games/oblivion_remaster/mod_data_content.py +++ b/games/oblivion_remaster/mod_data_content.py @@ -12,6 +12,7 @@ class Content(IntEnum): MOVIE = auto() UE4SS = auto() MAGIC_LOADER = auto() + GAME_SETTINGS = auto() class OblivionRemasteredDataContent(mobase.ModDataContent): @@ -24,6 +25,7 @@ class OblivionRemasteredDataContent(mobase.ModDataContent): (Content.MOVIE, "Movies", ":/MO/gui/content/media"), (Content.UE4SS, "UE4SS Mods", ":/MO/gui/content/script"), (Content.MAGIC_LOADER, "Magic Loader Mod", ":/MO/gui/content/inifile"), + (Content.GAME_SETTINGS, "Game Settings", ":/MO/gui/content/menu"), ] def getAllContents(self) -> list[mobase.ModDataContent.Content]: @@ -59,9 +61,22 @@ def getContentsFor(self, filetree: mobase.IFileTree) -> list[int]: plugins_dir = entry.find("Plugins") if isinstance(plugins_dir, mobase.IFileTree): for plugin_entry in plugins_dir: - if plugin_entry.suffix().casefold() == "dll": + if ( + plugin_entry.isFile() + and plugin_entry.suffix().casefold() == "dll" + ): contents.add(Content.OBSE) - break + if ( + isinstance(plugin_entry, mobase.IFileTree) + and plugins_dir.name().casefold() == "gamesettings" + ): + for settings_file in plugin_entry: + if ( + settings_file.isFile() + and settings_file.suffix().casefold() + == "ini" + ): + contents.add(Content.GAME_SETTINGS) case "paks": contents.add(Content.PAK) for paks_entry in entry: @@ -75,6 +90,13 @@ def getContentsFor(self, filetree: mobase.IFileTree) -> list[int]: contents.add(Content.MOVIE) case "ue4ss": contents.add(Content.UE4SS) + case "gamesettings": + for settings_file in entry: + if ( + settings_file.isFile() + and settings_file.suffix().casefold() == "ini" + ): + contents.add(Content.GAME_SETTINGS) case _: pass From a9b9c8869aec519a4071de68d90c27ee351a8735 Mon Sep 17 00:00:00 2001 From: Jeremy Rimpo Date: Fri, 23 May 2025 10:50:14 -0500 Subject: [PATCH 32/32] Documentation first pass --- games/game_oblivion_remaster.py | 29 +++++++- games/oblivion_remaster/game_plugins.py | 8 ++- games/oblivion_remaster/mod_data_checker.py | 79 +++++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/games/game_oblivion_remaster.py b/games/game_oblivion_remaster.py index c44857cd..84731ad3 100644 --- a/games/game_oblivion_remaster.py +++ b/games/game_oblivion_remaster.py @@ -21,6 +21,10 @@ 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, @@ -49,7 +53,13 @@ def getLootPath() -> Path | 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() @@ -71,6 +81,8 @@ class OblivionRemasteredGame( 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" @@ -105,6 +117,10 @@ def init(self, organizer: mobase.IOrganizer) -> bool: 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 @@ -115,8 +131,11 @@ def init_tab(self, main_window: QMainWindow): 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") @@ -183,7 +202,7 @@ def primaryPlugins(self) -> list[str]: "Knights.esp", "AltarESPMain.esp", "AltarDeluxe.esp", - "AltarESPLocal.esp", + "AltarESPLocal.esp", # Not actually shipped with the game files but present in plugins.txt. ] def modDataDirectory(self) -> str: @@ -243,7 +262,9 @@ def initializeProfile( ) 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() @@ -256,6 +277,10 @@ def initializeProfile( 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(): @@ -305,9 +330,11 @@ def getModMappings(self) -> dict[str, list[str]]: 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) diff --git a/games/oblivion_remaster/game_plugins.py b/games/oblivion_remaster/game_plugins.py index 9ff10485..6777da68 100644 --- a/games/oblivion_remaster/game_plugins.py +++ b/games/oblivion_remaster/game_plugins.py @@ -17,11 +17,17 @@ class OblivionRemasteredGamePlugins(mobase.GamePlugins): + """ + Reimplementation of GameGamebryo "GamePlugins" code, in the Skyrim style. + + Should properly account for disabled plugins and the loadorder.txt profile file. + """ + def __init__(self, organizer: mobase.IOrganizer): super().__init__() self._last_read = QDateTime().currentDateTime() self._organizer = organizer - # What are these for? + # Not currently used. These plugins exist in the base game but are not enabled by default. self._plugin_blacklist = ["TamrielLevelledRegion.esp", "AltarGymNavigation.esp"] def writePluginLists(self, plugin_list: mobase.IPluginList) -> None: diff --git a/games/oblivion_remaster/mod_data_checker.py b/games/oblivion_remaster/mod_data_checker.py index dfafb3e3..51875b2b 100644 --- a/games/oblivion_remaster/mod_data_checker.py +++ b/games/oblivion_remaster/mod_data_checker.py @@ -13,7 +13,10 @@ def _parent(entry: mobase.FileTreeEntry): class OblivionRemasteredModDataChecker(mobase.ModDataChecker): + # These directories are generally considered valid, but may require additional checks. + # These represent top level directories in the mod. _dirs = ["Data", "Paks", "OBSE", "Movies", "UE4SS", "GameSettings", "Root"] + # Directories considered valid for 'Data' though many of these may be obsolete. _data_dirs = [ "meshes", "textures", @@ -24,6 +27,7 @@ class OblivionRemasteredModDataChecker(mobase.ModDataChecker): "strings", "materials", ] + # Data file extensions considered valid. Unclear if BSAs are actually used. _data_extensions = [".esm", ".esp", ".bsa"] def __init__(self, organizer: mobase.IOrganizer): @@ -34,6 +38,8 @@ def dataLooksValid( self, filetree: mobase.IFileTree ) -> mobase.ModDataChecker.CheckReturn: status = mobase.ModDataChecker.INVALID + # These represent common mod structures that include UE4SS base files. + # These should generally be pruned or moved into a Root Builder path. if filetree.find("ue4ss/UE4SS.dll") is not None: return mobase.ModDataChecker.FIXABLE elif ( @@ -41,16 +47,29 @@ def dataLooksValid( is not None ): return mobase.ModDataChecker.FIXABLE + + # Crawl the directory tree to check mod structure. for entry in filetree: name = entry.name().casefold() parent = entry.parent() assert parent is not None + # These are top-level file entries. if parent.parent() is None: if isinstance(entry, mobase.IFileTree): + # Look for valid top level directories. if name in [dirname.lower() for dirname in self._dirs]: if name == "ue4ss": + """ + The UE4SS mod directory should contain either mod directories with + a 'scripts/main.lua' file, or 'shared' library files. Certain common + 'preset settings' files are also acceptable. + """ mods = entry.find("Mods") if isinstance(mods, mobase.IFileTree): + """ + UE4SS intrinsically maps to the 'Mods' directory, so if this directory + is present, it should be relocated. + """ for sub_entry in mods: if isinstance(sub_entry, mobase.IFileTree): if sub_entry.find("scripts/main.lua"): @@ -66,6 +85,7 @@ def dataLooksValid( else: for sub_entry in entry: if isinstance(sub_entry, mobase.IFileTree): + # Files are present in the correct directory. Mark valid. if sub_entry.find("scripts/main.lua"): status = mobase.ModDataChecker.VALID break @@ -77,24 +97,33 @@ def dataLooksValid( status = mobase.ModDataChecker.VALID break else: + # All other base directories are considered valid status = mobase.ModDataChecker.VALID + # No need to continue checks if the directory looks valid if status == mobase.ModDataChecker.VALID: break elif name in [dirname.lower() for dirname in self._data_dirs]: + # Found a 'Data' subdirectory. Should be moved into 'Data'. status = mobase.ModDataChecker.FIXABLE else: + # Parse other directories for potential mod files. for sub_entry in entry: if sub_entry.isFile(): sub_name = sub_entry.name().casefold() if sub_name.endswith(".exe"): + # Trying to handle EXE files is problematic, let the user figure it out return mobase.ModDataChecker.INVALID if sub_name.endswith((".pak", ".bk2")): + # Found Pak files or movie files, should be fixable status = mobase.ModDataChecker.FIXABLE elif sub_name.endswith(tuple(self._data_extensions)): + # Found plugins or BSA files, should be fixable status = mobase.ModDataChecker.FIXABLE else: if name == "Paks": + # Found Paks directory as subdirectory, should be fixable status = mobase.ModDataChecker.FIXABLE + # Iterate into subdirectories so we can check the entire archive new_status = self.dataLooksValid(entry) if new_status != mobase.ModDataChecker.INVALID: status = new_status @@ -106,6 +135,7 @@ def dataLooksValid( if name.endswith(tuple(self._data_extensions + [".pak", ".bk2"])): status = mobase.ModDataChecker.FIXABLE else: + # Section is for parsing subdirectories if isinstance(entry, mobase.IFileTree): if name in [dir_name.lower() for dir_name in self._dirs]: status = mobase.ModDataChecker.FIXABLE @@ -127,6 +157,12 @@ def dataLooksValid( return status def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + """ + Main fixer function. Iterates files, using 'parse_directory' for subdirectory search and fixing. + """ + + # UE4SS is packaged with many mods (or standalone) and should be processed into Root if found. + # This can avoid a lot of unnecessary iterations. ue4ss_dll = filetree.find("ue4ss/UE4SS.dll") if ue4ss_dll is None: ue4ss_dll = filetree.find( @@ -142,6 +178,9 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: "Root/OblivionRemastered/Binaries/Win64/", mobase.IFileTree.MERGE, ) + + # Similar to the above, many mods pack files relative to the root game directory. Some common paths can be + # automatically moved into the appropriate directory structures to avoid needless iteration. exe_dir = filetree.find(r"OblivionRemastered\Binaries\Win64") if isinstance(exe_dir, mobase.IFileTree): gamesettings_dir = exe_dir.find("GameSettings") @@ -174,6 +213,8 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: self.detach_parents(parent) else: self.detach_parents(exe_dir) + + # Start the main directory iteration code directories: list[mobase.IFileTree] = [] for entry in filetree: if isinstance(entry, mobase.IFileTree): @@ -182,9 +223,12 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: if directory.name().casefold() in [ dirname.lower() for dirname in self._data_dirs ]: + # Move detected 'Data' directories into 'Data' data_dir = self.get_dir(filetree, "Data") directory.moveTo(data_dir) elif directory.name().casefold() == "ue4ss": + # Validate and correct UE4SS mod files into 'UE4SS' + # Alternately, can use Root Builder path per user request mods = directory.find("Mods") if isinstance(mods, mobase.IFileTree): for sub_entry in mods: @@ -209,14 +253,18 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: elif directory.name().casefold() not in [ dirname.lower() for dirname in self._dirs ]: + # For non-valid directories, iterate into the directory filetree = self.parse_directory(filetree, directory) + # Parsing top-level files entries: list[mobase.FileTreeEntry] = [] + # As we are changing the filetree, cache the current directory list and iterate on that for entry in filetree: entries.append(entry) for entry in entries: if entry.parent() == filetree and entry.isFile(): name = entry.name().casefold() if name.endswith(".pak"): + # Move all pak|ucas|utoc files into "Paks\~mods" paks_dir = self.get_dir(filetree, "Paks/~mods") pak_files: list[mobase.FileTreeEntry] = [] for file in _parent(entry): @@ -230,6 +278,7 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: for pak_file in pak_files: pak_file.moveTo(paks_dir) elif name.endswith(".bk2"): + # Top-level bk2 files should be moved to "Movies\Modern" movies_dir = self.get_dir(filetree, "Movies/Modern") movie_files: list[mobase.FileTreeEntry] = [] for file in _parent(entry): @@ -239,6 +288,7 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: for movie_file in movie_files: movie_file.moveTo(movies_dir) elif name.endswith(tuple(self._data_extensions)): + # Files matching Data file extensions should be moved to "Data" data_dir = self.get_dir(filetree, "Data") data_files: list[mobase.FileTreeEntry] = [] for file in _parent(entry): @@ -250,6 +300,13 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: def parse_directory( self, main_filetree: mobase.IFileTree, next_dir: mobase.IFileTree ) -> mobase.IFileTree: + """ + Subdirectory iterator for fix(). Allows parsing all subdirectory files. + + :param main_filetree: The main mod filetree that should be returned. + :param next_dir: The currently processed subdirectory of main_filetree. + :returns: The updated main_filetree. + """ directories: list[mobase.IFileTree] = [] for entry in next_dir: if isinstance(entry, mobase.IFileTree): @@ -261,6 +318,7 @@ def parse_directory( if name == dir_name.lower(): main_dir = self.get_dir(main_filetree, dir_name) if name == "ue4ss": + # UE4SS directories should presumably map to 'UE4SS' but check for a 'Mods' directory and move that instead. if self._organizer.pluginSetting( PLUGIN_NAME, "ue4ss_use_root_builder" ): @@ -283,10 +341,12 @@ def parse_directory( if stop: continue if name in ["~mods", "logicmods"]: + # These directories should represent Paks mods and should be moved into that directory. paks_dir = self.get_dir(main_filetree, "Paks") directory.moveTo(paks_dir) continue elif name in [dirname.lower() for dirname in self._data_dirs]: + # These directories are typically associated with Data and should be moved into that directory. data_dir = self.get_dir(main_filetree, "Data") data_dir.merge(directory) self.detach_parents(directory) @@ -296,10 +356,12 @@ def parse_directory( if entry.isFile(): name = entry.name().casefold() if name.endswith(tuple(self._data_extensions)): + # Files matching Data extensions should be moved into 'Data' data_dir = self.get_dir(main_filetree, "Data") data_dir.merge(next_dir) self.detach_parents(next_dir) elif name.endswith(".pak"): + # Loose pak files most likely should be installed to 'Paks\~mods' but check the parent directory paks_dir = self.get_dir(main_filetree, "Paks") if next_dir.name().casefold() == "paks": paks_dir.merge(next_dir) @@ -317,6 +379,7 @@ def parse_directory( self.detach_parents(parent) return main_filetree elif name.endswith(".lua"): + # LUA files are generally associated with UE4SS mods. Most will probably be found before this point. if next_dir.parent() and next_dir.parent() != main_filetree: parent = _parent(next_dir).parent() if self._organizer.pluginSetting( @@ -346,6 +409,14 @@ def parse_directory( return main_filetree def detach_parents(self, directory: mobase.IFileTree) -> None: + """ + Attempts to clean up empty archive directories after files have been moved. + Find the top-most directory of an empty file tree where only the child directory is contained. + Remove that filetree. + + :param directory: The directory tree to be pruned (starting with the lowest element). + """ + if ( directory.parent() is not None and (parent := directory.parent()) is not None @@ -366,6 +437,14 @@ def detach_parents(self, directory: mobase.IFileTree) -> None: directory.detach() def get_dir(self, filetree: mobase.IFileTree, directory: str) -> mobase.IFileTree: + """ + Simple helper function that finds or creates a directory within a given filetree. + + :param filetree: The filetree in which to file or create the given directory + :param directory: The directory name to return + :return: The filetree of the created or found directory. + """ + tree_dir = filetree.find(directory) if not isinstance(tree_dir, mobase.IFileTree): tree_dir = filetree.addDirectory(directory)