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] 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 47d344c..54587fc 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 @@ class OblivionRemasteredGame( ] def executables(self): - return [ + execs = [ mobase.ExecutableInfo( "Oblivion Remastered", QFileInfo( @@ -1546,19 +145,23 @@ class OblivionRemasteredGame( ), "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 @@ class OblivionRemasteredGame( 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 @@ class OblivionRemasteredGame( "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 @@ class OblivionRemasteredGame( 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 @@ class OblivionRemasteredGame( 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 @@ class OblivionRemasteredGame( 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 @@ class OblivionRemasteredGame( 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 0000000..e69de29 diff --git a/games/oblivion_remaster/constants.py b/games/oblivion_remaster/constants.py new file mode 100644 index 0000000..bce8063 --- /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 0000000..9ff1048 --- /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 0000000..3ab9460 --- /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 0000000..d2aab4a --- /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 0000000..e69de29 diff --git a/games/oblivion_remaster/paks/model.py b/games/oblivion_remaster/paks/model.py new file mode 100644 index 0000000..8cdfdc0 --- /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 0000000..a56b0cd --- /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 0000000..48e84bf --- /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 0000000..571e675 --- /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 0000000..e69de29 diff --git a/games/oblivion_remaster/ue4ss/model.py b/games/oblivion_remaster/ue4ss/model.py new file mode 100644 index 0000000..9caf7c5 --- /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 0000000..bb994ca --- /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 0000000..59900d1 --- /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 3ec5c87..8316dd9 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"]