aboutsummaryrefslogtreecommitdiff
path: root/libs/basic_games/games/oblivion_remaster
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-11 02:37:39 -0600
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-11 02:37:39 -0600
commit7ee008e150bc5bcf76082d726f719ee0fdfda982 (patch)
tree27fb39be241fdb5ac2734c574de678977d1856d0 /libs/basic_games/games/oblivion_remaster
Fluorine Manager: full Linux port of Mod Organizer 2
Complete native Linux port with FUSE-based virtual filesystem, Proton/umu-run integration, and Flatpak packaging. Key features: - FUSE VFS replacing Windows USVFS (in-process + standalone helper for Flatpak) - Proton/GE-Proton/umu-run launcher with env var forwarding - Flatpak support (sandbox-aware VFS, NXM handler, umu-run) - Wine prefix management UI - Case-insensitive path resolution for Linux filesystems - QSettings-safe INI handling (avoids Bethesda INI corruption) - Portable instance support with auto-generated launcher scripts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'libs/basic_games/games/oblivion_remaster')
-rw-r--r--libs/basic_games/games/oblivion_remaster/__init__.py0
-rw-r--r--libs/basic_games/games/oblivion_remaster/constants.py14
-rw-r--r--libs/basic_games/games/oblivion_remaster/game_plugins.py207
-rw-r--r--libs/basic_games/games/oblivion_remaster/mod_data_checker.py451
-rw-r--r--libs/basic_games/games/oblivion_remaster/mod_data_content.py109
-rw-r--r--libs/basic_games/games/oblivion_remaster/paks/__init__.py0
-rw-r--r--libs/basic_games/games/oblivion_remaster/paks/model.py253
-rw-r--r--libs/basic_games/games/oblivion_remaster/paks/view.py33
-rw-r--r--libs/basic_games/games/oblivion_remaster/paks/widget.py213
-rw-r--r--libs/basic_games/games/oblivion_remaster/script_extender.py37
-rw-r--r--libs/basic_games/games/oblivion_remaster/ue4ss/__init__.py0
-rw-r--r--libs/basic_games/games/oblivion_remaster/ue4ss/model.py125
-rw-r--r--libs/basic_games/games/oblivion_remaster/ue4ss/view.py31
-rw-r--r--libs/basic_games/games/oblivion_remaster/ue4ss/widget.py175
14 files changed, 1648 insertions, 0 deletions
diff --git a/libs/basic_games/games/oblivion_remaster/__init__.py b/libs/basic_games/games/oblivion_remaster/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/libs/basic_games/games/oblivion_remaster/__init__.py
diff --git a/libs/basic_games/games/oblivion_remaster/constants.py b/libs/basic_games/games/oblivion_remaster/constants.py
new file mode 100644
index 0000000..656e296
--- /dev/null
+++ b/libs/basic_games/games/oblivion_remaster/constants.py
@@ -0,0 +1,14 @@
+from typing import TypedDict
+
+PLUGIN_NAME = "Oblivion Remastered Support Plugin"
+
+
+class UE4SSModInfo(TypedDict):
+ mod_name: str
+ mod_enabled: bool
+
+
+DEFAULT_UE4SS_MODS: list[UE4SSModInfo] = [
+ {"mod_name": "BPML_GenericFunctions", "mod_enabled": True},
+ {"mod_name": "BPModLoaderMod", "mod_enabled": True},
+]
diff --git a/libs/basic_games/games/oblivion_remaster/game_plugins.py b/libs/basic_games/games/oblivion_remaster/game_plugins.py
new file mode 100644
index 0000000..6777da6
--- /dev/null
+++ b/libs/basic_games/games/oblivion_remaster/game_plugins.py
@@ -0,0 +1,207 @@
+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):
+ """
+ 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
+ # 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:
+ 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/libs/basic_games/games/oblivion_remaster/mod_data_checker.py b/libs/basic_games/games/oblivion_remaster/mod_data_checker.py
new file mode 100644
index 0000000..51875b2
--- /dev/null
+++ b/libs/basic_games/games/oblivion_remaster/mod_data_checker.py
@@ -0,0 +1,451 @@
+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):
+ # 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",
+ "music",
+ "fonts",
+ "interface",
+ "shaders",
+ "strings",
+ "materials",
+ ]
+ # Data file extensions considered valid. Unclear if BSAs are actually used.
+ _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
+ # 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 (
+ filetree.find("OblivionRemastered/Binaries/Win64/ue4ss/UE4SS.dll")
+ 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"):
+ status = mobase.ModDataChecker.FIXABLE
+ break
+ if sub_entry.name().casefold() in [
+ "shared",
+ "npcappearancemanager",
+ "naturalbodymorph",
+ ]:
+ status = mobase.ModDataChecker.FIXABLE
+ break
+ 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
+ if sub_entry.name().casefold() in [
+ "shared",
+ "npcappearancemanager",
+ "naturalbodymorph",
+ ]:
+ 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
+ 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:
+ # 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
+ 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:
+ """
+ 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(
+ "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,
+ )
+
+ # 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")
+ 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)
+ 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"):
+ 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)
+ self.detach_parents(ue4ss_mod_dir)
+ if len(exe_dir):
+ root_exe_dir = self.get_dir(
+ filetree, "Root/OblivionRemastered/Binaries"
+ )
+ parent = exe_dir.parent()
+ exe_dir.moveTo(root_exe_dir)
+ if parent:
+ 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):
+ directories.append(entry)
+ for directory in directories:
+ 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:
+ 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
+ ]:
+ # 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):
+ 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"):
+ # 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):
+ 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)):
+ # 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):
+ 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:
+ """
+ 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):
+ 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":
+ # 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"
+ ):
+ 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"]:
+ # 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)
+ 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)):
+ # 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)
+ 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"):
+ # 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(
+ 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:
+ """
+ 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
+ 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:
+ 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)
+ return tree_dir
diff --git a/libs/basic_games/games/oblivion_remaster/mod_data_content.py b/libs/basic_games/games/oblivion_remaster/mod_data_content.py
new file mode 100644
index 0000000..2ee7e5f
--- /dev/null
+++ b/libs/basic_games/games/oblivion_remaster/mod_data_content.py
@@ -0,0 +1,109 @@
+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()
+ GAME_SETTINGS = 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"),
+ (Content.GAME_SETTINGS, "Game Settings", ":/MO/gui/content/menu"),
+ ]
+
+ 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.isFile()
+ and plugin_entry.suffix().casefold() == "dll"
+ ):
+ contents.add(Content.OBSE)
+ 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:
+ if isinstance(paks_entry, mobase.IFileTree):
+ if paks_entry.name().casefold() == "~mods":
+ for mods_entry in paks_entry:
+ if isinstance(mods_entry, mobase.IFileTree):
+ if (
+ "magicloader"
+ in mods_entry.name().casefold()
+ ):
+ contents.add(Content.MAGIC_LOADER)
+ break
+ if paks_entry.name().casefold() == "logicmods":
+ contents.add(Content.UE4SS)
+ case "movies":
+ 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
+
+ return list(contents)
diff --git a/libs/basic_games/games/oblivion_remaster/paks/__init__.py b/libs/basic_games/games/oblivion_remaster/paks/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/libs/basic_games/games/oblivion_remaster/paks/__init__.py
diff --git a/libs/basic_games/games/oblivion_remaster/paks/model.py b/libs/basic_games/games/oblivion_remaster/paks/model.py
new file mode 100644
index 0000000..c43cec0
--- /dev/null
+++ b/libs/basic_games/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, _PakInfo] = {}
+ 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, _PakInfo]):
+ 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 = 8999
+ 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/libs/basic_games/games/oblivion_remaster/paks/view.py b/libs/basic_games/games/oblivion_remaster/paks/view.py
new file mode 100644
index 0000000..a56b0cd
--- /dev/null
+++ b/libs/basic_games/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/libs/basic_games/games/oblivion_remaster/paks/widget.py b/libs/basic_games/games/oblivion_remaster/paks/widget.py
new file mode 100644
index 0000000..efc3038
--- /dev/null
+++ b/libs/basic_games/games/oblivion_remaster/paks/widget.py
@@ -0,0 +1,213 @@
+from functools import cmp_to_key
+from pathlib import Path
+from typing import cast
+
+from PyQt6.QtCore import QDir, QFileInfo
+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 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"))
+ 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()):
+ _, _, 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"):
+ 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.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()
+ 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
+
+ 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 "magicloader" in entry.name().casefold():
+ continue
+ for sub_entry in entry:
+ if (
+ sub_entry.isFile()
+ and sub_entry.suffix().casefold() == "pak"
+ ):
+ 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(
+ "/"
+ ),
+ mod_item.absolutePath() + "/" + pak_mods.path("/"),
+ )
+ pak_source[pak_name] = mod_item.name()
+ else:
+ if entry.suffix().casefold() == "pak":
+ 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[pak_name] = 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 "magicloader" in entry.completeBaseName().casefold():
+ continue
+ for sub_entry in QDir(entry.absoluteFilePath()).entryInfoList(
+ QDir.Filter.Files
+ ):
+ if (
+ sub_entry.isFile()
+ and sub_entry.suffix().casefold() == "pak"
+ ):
+ pak_name = sub_entry.completeBaseName()
+ paks[pak_name] = entry.completeBaseName()
+ pak_paths[pak_name] = (
+ sub_entry.absolutePath(),
+ pak_mods.absolutePath(),
+ )
+ pak_source[pak_name] = "Game Directory"
+ else:
+ if entry.suffix().casefold() == "pak":
+ pak_name = entry.completeBaseName()
+ paks[pak_name] = ""
+ pak_paths[pak_name] = (
+ entry.absolutePath(),
+ pak_mods.absolutePath(),
+ )
+ 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]] = {}
+ pak_index = 8999
+ for pak in shaken_paks:
+ 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, tuple[str, str, str, str]] = {}
+ i = 0
+ for pak, data in final_paks.items():
+ 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/libs/basic_games/games/oblivion_remaster/script_extender.py b/libs/basic_games/games/oblivion_remaster/script_extender.py
new file mode 100644
index 0000000..571e675
--- /dev/null
+++ b/libs/basic_games/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/libs/basic_games/games/oblivion_remaster/ue4ss/__init__.py b/libs/basic_games/games/oblivion_remaster/ue4ss/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/libs/basic_games/games/oblivion_remaster/ue4ss/__init__.py
diff --git a/libs/basic_games/games/oblivion_remaster/ue4ss/model.py b/libs/basic_games/games/oblivion_remaster/ue4ss/model.py
new file mode 100644
index 0000000..a3804fe
--- /dev/null
+++ b/libs/basic_games/games/oblivion_remaster/ue4ss/model.py
@@ -0,0 +1,125 @@
+import json
+from json import JSONDecodeError
+from typing import Any, Iterable
+
+from PyQt6.QtCore import (
+ QDir,
+ QFileInfo,
+ QMimeData,
+ QModelIndex,
+ QStringListModel,
+ Qt,
+)
+from PyQt6.QtWidgets import QWidget
+
+import mobase
+
+from ..constants import DEFAULT_UE4SS_MODS
+
+
+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:
+ try:
+ mod_data = json.load(json_file)
+ except JSONDecodeError:
+ mod_data = DEFAULT_UE4SS_MODS
+ 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:
+ try:
+ mod_data = json.load(json_file)
+ except JSONDecodeError:
+ mod_data = DEFAULT_UE4SS_MODS
+ 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/libs/basic_games/games/oblivion_remaster/ue4ss/view.py b/libs/basic_games/games/oblivion_remaster/ue4ss/view.py
new file mode 100644
index 0000000..bb994ca
--- /dev/null
+++ b/libs/basic_games/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/libs/basic_games/games/oblivion_remaster/ue4ss/widget.py b/libs/basic_games/games/oblivion_remaster/ue4ss/widget.py
new file mode 100644
index 0000000..246b7fa
--- /dev/null
+++ b/libs/basic_games/games/oblivion_remaster/ue4ss/widget.py
@@ -0,0 +1,175 @@
+import json
+from functools import cmp_to_key
+from json import JSONDecodeError
+from pathlib import Path
+
+from PyQt6.QtCore import QDir, QFileInfo, Qt
+from PyQt6.QtWidgets import QGridLayout, QWidget
+
+import mobase
+
+from ..constants import DEFAULT_UE4SS_MODS, UE4SSModInfo
+from .model import UE4SSListModel
+from .view import UE4SSView
+
+
+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:
+ try:
+ mods = json.load(json_file)
+ except JSONDecodeError:
+ mods = DEFAULT_UE4SS_MODS
+ 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