aboutsummaryrefslogtreecommitdiff
path: root/libs/basic_games/games/oblivion_remaster/paks
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-11 02:37:39 -0600
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-11 02:37:39 -0600
commit7ee008e150bc5bcf76082d726f719ee0fdfda982 (patch)
tree27fb39be241fdb5ac2734c574de678977d1856d0 /libs/basic_games/games/oblivion_remaster/paks
Fluorine Manager: full Linux port of Mod Organizer 2
Complete native Linux port with FUSE-based virtual filesystem, Proton/umu-run integration, and Flatpak packaging. Key features: - FUSE VFS replacing Windows USVFS (in-process + standalone helper for Flatpak) - Proton/GE-Proton/umu-run launcher with env var forwarding - Flatpak support (sandbox-aware VFS, NXM handler, umu-run) - Wine prefix management UI - Case-insensitive path resolution for Linux filesystems - QSettings-safe INI handling (avoids Bethesda INI corruption) - Portable instance support with auto-generated launcher scripts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'libs/basic_games/games/oblivion_remaster/paks')
-rw-r--r--libs/basic_games/games/oblivion_remaster/paks/__init__.py0
-rw-r--r--libs/basic_games/games/oblivion_remaster/paks/model.py253
-rw-r--r--libs/basic_games/games/oblivion_remaster/paks/view.py33
-rw-r--r--libs/basic_games/games/oblivion_remaster/paks/widget.py213
4 files changed, 499 insertions, 0 deletions
diff --git a/libs/basic_games/games/oblivion_remaster/paks/__init__.py b/libs/basic_games/games/oblivion_remaster/paks/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/libs/basic_games/games/oblivion_remaster/paks/__init__.py
diff --git a/libs/basic_games/games/oblivion_remaster/paks/model.py b/libs/basic_games/games/oblivion_remaster/paks/model.py
new file mode 100644
index 0000000..c43cec0
--- /dev/null
+++ b/libs/basic_games/games/oblivion_remaster/paks/model.py
@@ -0,0 +1,253 @@
+import itertools
+import typing
+from enum import IntEnum, auto
+from typing import Any, TypeAlias, overload
+
+from PyQt6.QtCore import (
+ QAbstractItemModel,
+ QByteArray,
+ QDataStream,
+ QDir,
+ QFileInfo,
+ QMimeData,
+ QModelIndex,
+ QObject,
+ Qt,
+ QVariant,
+)
+from PyQt6.QtWidgets import QWidget
+
+import mobase
+
+_PakInfo: TypeAlias = tuple[str, str, str, str]
+
+
+class PaksColumns(IntEnum):
+ PRIORITY = auto()
+ PAK_NAME = auto()
+ SOURCE = auto()
+
+
+class PaksModel(QAbstractItemModel):
+ def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer):
+ super().__init__(parent)
+ self.paks: dict[int, _PakInfo] = {}
+ self._organizer = organizer
+ self._init_mod_states()
+
+ def _init_mod_states(self):
+ profile = QDir(self._organizer.profilePath())
+ paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt"))
+ if paks_txt.exists():
+ with open(paks_txt.absoluteFilePath(), "r") as paks_file:
+ index = 0
+ for line in paks_file:
+ self.paks[index] = (line, "", "", "")
+ index += 1
+
+ def set_paks(self, paks: dict[int, _PakInfo]):
+ self.layoutAboutToBeChanged.emit()
+ self.paks = paks
+ self.layoutChanged.emit()
+ self.dataChanged.emit(
+ self.index(0, 0),
+ self.index(self.rowCount(), self.columnCount()),
+ [Qt.ItemDataRole.DisplayRole],
+ )
+
+ def flags(self, index: QModelIndex) -> Qt.ItemFlag:
+ if not index.isValid():
+ return (
+ Qt.ItemFlag.ItemIsSelectable
+ | Qt.ItemFlag.ItemIsDragEnabled
+ | Qt.ItemFlag.ItemIsDropEnabled
+ | Qt.ItemFlag.ItemIsEnabled
+ )
+ return (
+ super().flags(index)
+ | Qt.ItemFlag.ItemIsDragEnabled
+ | Qt.ItemFlag.ItemIsDropEnabled & Qt.ItemFlag.ItemIsEditable
+ )
+
+ def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
+ return len(PaksColumns)
+
+ def index(
+ self, row: int, column: int, parent: QModelIndex = QModelIndex()
+ ) -> QModelIndex:
+ if (
+ row < 0
+ or row >= self.rowCount()
+ or column < 0
+ or column >= self.columnCount()
+ ):
+ return QModelIndex()
+ return self.createIndex(row, column, row)
+
+ @overload
+ def parent(self, child: QModelIndex) -> QModelIndex: ...
+ @overload
+ def parent(self) -> QObject | None: ...
+
+ def parent(self, child: QModelIndex | None = None) -> QModelIndex | QObject | None:
+ if child is None:
+ return super().parent()
+ return QModelIndex()
+
+ def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
+ return len(self.paks)
+
+ def setData(
+ self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole
+ ) -> bool:
+ return False
+
+ def headerData(
+ self,
+ section: int,
+ orientation: Qt.Orientation,
+ role: int = Qt.ItemDataRole.DisplayRole,
+ ) -> typing.Any:
+ if (
+ orientation != Qt.Orientation.Horizontal
+ or role != Qt.ItemDataRole.DisplayRole
+ ):
+ return QVariant()
+
+ column = PaksColumns(section + 1)
+ match column:
+ case PaksColumns.PAK_NAME:
+ return "Pak Group"
+ case PaksColumns.PRIORITY:
+ return "Priority"
+ case PaksColumns.SOURCE:
+ return "Source"
+
+ return QVariant()
+
+ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
+ if not index.isValid():
+ return None
+ if index.column() + 1 == PaksColumns.PAK_NAME:
+ if role == Qt.ItemDataRole.DisplayRole:
+ return self.paks[index.row()][0]
+ elif index.column() + 1 == PaksColumns.PRIORITY:
+ if role == Qt.ItemDataRole.DisplayRole:
+ return index.row()
+ elif index.column() + 1 == PaksColumns.SOURCE:
+ if role == Qt.ItemDataRole.DisplayRole:
+ return self.paks[index.row()][1]
+ return QVariant()
+
+ def canDropMimeData(
+ self,
+ data: QMimeData | None,
+ action: Qt.DropAction,
+ row: int,
+ column: int,
+ parent: QModelIndex,
+ ) -> bool:
+ if action == Qt.DropAction.MoveAction and (row != -1 or column != -1):
+ return True
+ return False
+
+ def supportedDropActions(self) -> Qt.DropAction:
+ return Qt.DropAction.MoveAction
+
+ def dropMimeData(
+ self,
+ data: QMimeData | None,
+ action: Qt.DropAction,
+ row: int,
+ column: int,
+ parent: QModelIndex,
+ ) -> bool:
+ if action == Qt.DropAction.IgnoreAction:
+ return True
+
+ if data is None:
+ return False
+
+ encoded: QByteArray = data.data("application/x-qabstractitemmodeldatalist")
+ stream: QDataStream = QDataStream(encoded, QDataStream.OpenModeFlag.ReadOnly)
+ source_rows: list[int] = []
+
+ while not stream.atEnd():
+ source_row = stream.readInt()
+ col = stream.readInt()
+ size = stream.readInt()
+ item_data = {}
+ for _ in range(size):
+ role = stream.readInt()
+ value = stream.readQVariant()
+ item_data[role] = value
+ if col == 0:
+ source_rows.append(source_row)
+
+ if row == -1:
+ row = parent.row()
+
+ if row < 0 or row >= len(self.paks):
+ new_priority = len(self.paks)
+ else:
+ new_priority = row
+
+ before_paks: list[_PakInfo] = []
+ moved_paks: list[_PakInfo] = []
+ after_paks: list[_PakInfo] = []
+ before_paks_p: list[_PakInfo] = []
+ moved_paks_p: list[_PakInfo] = []
+ after_paks_p: list[_PakInfo] = []
+ for row, paks in sorted(self.paks.items()):
+ if row < new_priority:
+ if row in source_rows:
+ if paks[0].casefold()[-2:] == "_p":
+ moved_paks_p.append(paks)
+ else:
+ moved_paks.append(paks)
+ else:
+ if paks[0].casefold()[-2:] == "_p":
+ before_paks_p.append(paks)
+ else:
+ before_paks.append(paks)
+ if row >= new_priority:
+ if row in source_rows:
+ if paks[0].casefold()[-2:] == "_p":
+ moved_paks_p.append(paks)
+ else:
+ moved_paks.append(paks)
+ else:
+ if paks[0].casefold()[-2:] == "_p":
+ after_paks_p.append(paks)
+ else:
+ after_paks.append(paks)
+
+ new_paks = dict(
+ enumerate(
+ itertools.chain(
+ before_paks,
+ moved_paks,
+ after_paks,
+ before_paks_p,
+ moved_paks_p,
+ after_paks_p,
+ )
+ )
+ )
+
+ index = 8999
+ for row, pak in new_paks.items():
+ current_dir = QDir(pak[2])
+ parent_dir = QDir(pak[2])
+ parent_dir.cdUp()
+ if current_dir.exists() and parent_dir.dirName().casefold() == "~mods":
+ new_paks[row] = (
+ pak[0],
+ pak[1],
+ pak[2],
+ parent_dir.absoluteFilePath(str(index).zfill(4)),
+ )
+ index -= 1
+
+ self.set_paks(new_paks)
+ return False
diff --git a/libs/basic_games/games/oblivion_remaster/paks/view.py b/libs/basic_games/games/oblivion_remaster/paks/view.py
new file mode 100644
index 0000000..a56b0cd
--- /dev/null
+++ b/libs/basic_games/games/oblivion_remaster/paks/view.py
@@ -0,0 +1,33 @@
+from typing import Iterable
+
+from PyQt6.QtCore import QModelIndex, Qt, pyqtSignal
+from PyQt6.QtGui import QDropEvent
+from PyQt6.QtWidgets import QAbstractItemView, QTreeView, QWidget
+
+
+class PaksView(QTreeView):
+ data_dropped = pyqtSignal()
+
+ def __init__(self, parent: QWidget | None):
+ super().__init__(parent)
+ self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
+ self.setDragEnabled(True)
+ self.setAcceptDrops(True)
+ self.setDropIndicatorShown(True)
+ self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
+ self.setDefaultDropAction(Qt.DropAction.MoveAction)
+ if (viewport := self.viewport()) is not None:
+ viewport.setAcceptDrops(True)
+ self.setItemsExpandable(False)
+ self.setRootIsDecorated(False)
+
+ def dropEvent(self, e: QDropEvent | None):
+ super().dropEvent(e)
+ self.clearSelection()
+ self.data_dropped.emit()
+
+ def dataChanged(
+ self, topLeft: QModelIndex, bottomRight: QModelIndex, roles: Iterable[int] = ()
+ ):
+ super().dataChanged(topLeft, bottomRight, roles)
+ self.repaint()
diff --git a/libs/basic_games/games/oblivion_remaster/paks/widget.py b/libs/basic_games/games/oblivion_remaster/paks/widget.py
new file mode 100644
index 0000000..efc3038
--- /dev/null
+++ b/libs/basic_games/games/oblivion_remaster/paks/widget.py
@@ -0,0 +1,213 @@
+from functools import cmp_to_key
+from pathlib import Path
+from typing import cast
+
+from PyQt6.QtCore import QDir, QFileInfo
+from PyQt6.QtWidgets import QGridLayout, QWidget
+
+import mobase
+
+from ....basic_features.utils import is_directory
+from .model import PaksModel
+from .view import PaksView
+
+
+def pak_sort(a: tuple[str, str], b: tuple[str, str]) -> int:
+ a_pak, a_str = a[0], a[1] or a[0]
+ b_pak, b_str = b[0], b[1] or b[0]
+
+ a_pak_ends_p = a_pak.casefold().endswith("_p")
+ b_pak_ends_p = b_pak.casefold().endswith("_p")
+
+ if a_pak_ends_p == b_pak_ends_p:
+ if a_str.casefold() <= b_str.casefold():
+ return 1
+ return -1
+ elif a_pak_ends_p:
+ return 1
+ elif b_pak_ends_p:
+ return -1
+ return 0
+
+
+class PaksTabWidget(QWidget):
+ def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer):
+ super().__init__(parent)
+ self._organizer = organizer
+ self._view = PaksView(self)
+ self._layout = QGridLayout(self)
+ self._layout.addWidget(self._view)
+ self._model = PaksModel(self._view, organizer)
+ self._view.setModel(self._model)
+ self._model.dataChanged.connect(self.write_paks_list) # type: ignore
+ self._view.data_dropped.connect(self.write_paks_list) # type: ignore
+ organizer.onProfileChanged(lambda profile_a, profile_b: self._parse_pak_files())
+ organizer.modList().onModInstalled(lambda mod: self._parse_pak_files())
+ organizer.modList().onModRemoved(lambda mod: self._parse_pak_files())
+ organizer.modList().onModStateChanged(lambda mods: self._parse_pak_files())
+ self._parse_pak_files()
+
+ def load_paks_list(self) -> list[str]:
+ profile = QDir(self._organizer.profilePath())
+ paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt"))
+ paks_list: list[str] = []
+ if paks_txt.exists():
+ with open(paks_txt.absoluteFilePath(), "r") as paks_file:
+ for line in paks_file:
+ paks_list.append(line.strip())
+ return paks_list
+
+ def write_paks_list(self):
+ profile = QDir(self._organizer.profilePath())
+ paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt"))
+ with open(paks_txt.absoluteFilePath(), "w") as paks_file:
+ for _, pak in sorted(self._model.paks.items()):
+ name, _, _, _ = pak
+ paks_file.write(f"{name}\n")
+ self.write_pak_files()
+
+ def write_pak_files(self):
+ for index, pak in sorted(self._model.paks.items()):
+ _, _, current_path, target_path = pak
+ if current_path and current_path != target_path:
+ path_dir = Path(current_path)
+ target_dir = Path(target_path)
+ if not target_dir.exists():
+ target_dir.mkdir(parents=True, exist_ok=True)
+ if path_dir.exists():
+ for pak_file in path_dir.glob("*.pak"):
+ ucas_file = pak_file.with_suffix(".ucas")
+ utoc_file = pak_file.with_suffix(".utoc")
+ for file in (pak_file, ucas_file, utoc_file):
+ if not file.exists():
+ continue
+ try:
+ file.rename(target_dir.joinpath(file.name))
+ except FileExistsError:
+ pass
+ data = self._model.paks[index]
+ self._model.paks[index] = (
+ data[0],
+ data[1],
+ data[3],
+ data[3],
+ )
+ break
+ if not list(path_dir.iterdir()):
+ path_dir.rmdir()
+
+ def _shake_paks(self, sorted_paks: dict[str, str]) -> list[str]:
+ shaken_paks: list[str] = []
+ shaken_paks_p: list[str] = []
+ paks_list = self.load_paks_list()
+ for pak in paks_list:
+ if pak in sorted_paks.keys():
+ if pak.casefold().endswith("_p"):
+ shaken_paks_p.append(pak)
+ else:
+ shaken_paks.append(pak)
+ sorted_paks.pop(pak)
+ for pak in sorted_paks.keys():
+ if pak.casefold().endswith("_p"):
+ shaken_paks_p.append(pak)
+ else:
+ shaken_paks.append(pak)
+ return shaken_paks + shaken_paks_p
+
+ def _parse_pak_files(self):
+ from ...game_oblivion_remaster import OblivionRemasteredGame
+
+ mods = self._organizer.modList().allMods()
+ paks: dict[str, str] = {}
+ pak_paths: dict[str, tuple[str, str]] = {}
+ pak_source: dict[str, str] = {}
+ for mod in mods:
+ mod_item = self._organizer.modList().getMod(mod)
+ if not self._organizer.modList().state(mod) & mobase.ModState.ACTIVE:
+ continue
+ filetree = mod_item.fileTree()
+ pak_mods = filetree.find("Paks/~mods")
+ if not pak_mods:
+ pak_mods = filetree.find("Root/OblivionRemastered/Content/Paks/~mods")
+ if isinstance(pak_mods, mobase.IFileTree):
+ for entry in pak_mods:
+ if is_directory(entry):
+ if "magicloader" in entry.name().casefold():
+ continue
+ for sub_entry in entry:
+ if (
+ sub_entry.isFile()
+ and sub_entry.suffix().casefold() == "pak"
+ ):
+ pak_name = sub_entry.name()[
+ : -1 - len(sub_entry.suffix())
+ ]
+ paks[pak_name] = entry.name()
+ pak_paths[pak_name] = (
+ mod_item.absolutePath()
+ + "/"
+ + cast(mobase.IFileTree, sub_entry.parent()).path(
+ "/"
+ ),
+ mod_item.absolutePath() + "/" + pak_mods.path("/"),
+ )
+ pak_source[pak_name] = mod_item.name()
+ else:
+ if entry.suffix().casefold() == "pak":
+ pak_name = entry.name()[: -1 - len(entry.suffix())]
+ paks[pak_name] = ""
+ pak_paths[pak_name] = (
+ mod_item.absolutePath()
+ + "/"
+ + cast(mobase.IFileTree, entry.parent()).path("/"),
+ mod_item.absolutePath() + "/" + pak_mods.path("/"),
+ )
+ pak_source[pak_name] = mod_item.name()
+ game = self._organizer.managedGame()
+ if isinstance(game, OblivionRemasteredGame):
+ pak_mods = QFileInfo(game.paksDirectory().absoluteFilePath("~mods"))
+ if pak_mods.exists() and pak_mods.isDir():
+ for entry in QDir(pak_mods.absoluteFilePath()).entryInfoList(
+ QDir.Filter.Dirs | QDir.Filter.Files | QDir.Filter.NoDotAndDotDot
+ ):
+ if entry.isDir():
+ if "magicloader" in entry.completeBaseName().casefold():
+ continue
+ for sub_entry in QDir(entry.absoluteFilePath()).entryInfoList(
+ QDir.Filter.Files
+ ):
+ if (
+ sub_entry.isFile()
+ and sub_entry.suffix().casefold() == "pak"
+ ):
+ pak_name = sub_entry.completeBaseName()
+ paks[pak_name] = entry.completeBaseName()
+ pak_paths[pak_name] = (
+ sub_entry.absolutePath(),
+ pak_mods.absolutePath(),
+ )
+ pak_source[pak_name] = "Game Directory"
+ else:
+ if entry.suffix().casefold() == "pak":
+ pak_name = entry.completeBaseName()
+ paks[pak_name] = ""
+ pak_paths[pak_name] = (
+ entry.absolutePath(),
+ pak_mods.absolutePath(),
+ )
+ pak_source[pak_name] = "Game Directory"
+ sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort)))
+ shaken_paks: list[str] = self._shake_paks(sorted_paks)
+ final_paks: dict[str, tuple[str, str, str]] = {}
+ pak_index = 8999
+ for pak in shaken_paks:
+ target_dir = pak_paths[pak][1] + "/" + str(pak_index).zfill(4)
+ final_paks[pak] = (pak_source[pak], pak_paths[pak][0], target_dir)
+ pak_index -= 1
+ new_data_paks: dict[int, tuple[str, str, str, str]] = {}
+ i = 0
+ for pak, data in final_paks.items():
+ source, current_path, target_path = data
+ new_data_paks[i] = (pak, source, current_path, target_path)
+ i += 1
+ self._model.set_paks(new_data_paks)