From d78cf05198da6448671e90e6e49357e960c1a748 Mon Sep 17 00:00:00 2001 From: MKHATERS <42836901+MK-HATERS@users.noreply.github.com> Date: Sat, 21 Jun 2025 20:33:30 -0600 Subject: [PATCH 01/14] stalker 2 support with a pak tab --- games/game_stalker2heartofchornobyl.py | 241 +++++++++++++++++ games/stalker2heartofchornobyl/__init__.py | 8 + .../stalker2heartofchornobyl/paks/__init__.py | 5 + games/stalker2heartofchornobyl/paks/model.py | 253 ++++++++++++++++++ games/stalker2heartofchornobyl/paks/view.py | 33 +++ games/stalker2heartofchornobyl/paks/widget.py | 247 +++++++++++++++++ 6 files changed, 787 insertions(+) create mode 100644 games/game_stalker2heartofchornobyl.py create mode 100644 games/stalker2heartofchornobyl/__init__.py create mode 100644 games/stalker2heartofchornobyl/paks/__init__.py create mode 100644 games/stalker2heartofchornobyl/paks/model.py create mode 100644 games/stalker2heartofchornobyl/paks/view.py create mode 100644 games/stalker2heartofchornobyl/paks/widget.py diff --git a/games/game_stalker2heartofchornobyl.py b/games/game_stalker2heartofchornobyl.py new file mode 100644 index 0000000..9c3037a --- /dev/null +++ b/games/game_stalker2heartofchornobyl.py @@ -0,0 +1,241 @@ +from typing import List +from enum import IntEnum, auto +from pathlib import Path +from PyQt6.QtCore import QDir, QFileInfo +from PyQt6.QtWidgets import QMainWindow, QTabWidget, QWidget +import mobase +import os +from ..basic_game import BasicGame +from ..basic_features import BasicModDataChecker, GlobPatterns, BasicLocalSavegames + + +class Problems(IntEnum): + """ + Enums for IPluginDiagnose. + """ + # PAK files placed in incorrect locations + MISPLACED_PAK_FILES = auto() + # Missing mod directory structure + MISSING_MOD_DIRECTORIES = auto() + + +class S2HoCGame(BasicGame, mobase.IPluginFileMapper, mobase.IPluginDiagnose): + Name = "Stalker 2: Heart of Chornobyl Plugin" + Author = "MkHaters" + Version = "1.1.0" + + # Game details for MO2, using paths common for Unreal Engine-based games. + GameName = "Stalker 2: Heart of Chornobyl" + GameShortName = "stalker2heartofchornobyl" + GameNexusName = "stalker2heartofchornobyl" + GameDocumentsDirectory = "%USERPROFILE%/AppData/Local/Stalker2" + GameSavesDirectory = "%GAME_DOCUMENTS%/Saved/Steam/SaveGames/Data" + GameSaveExtension = "sav" + GameNexusId = 6944 + GameSteamId = 1643320 + GameGogId = 1529799785 + GameBinary = "Stalker2.exe" + GameDataPath = "%GAME_PATH%/Stalker2" + GameIniFiles = [ + "%GAME_DOCUMENTS%/Saved/Config/Windows/Game.ini", + "%GAME_DOCUMENTS%/Saved/Config/Windows/GameUserSettings.ini", + "%GAME_DOCUMENTS%/Saved/Config/Windows/Engine.ini" + ] + + _main_window: QMainWindow + _paks_tab: QWidget # Will be S2HoCPaksTabWidget when imported + + def __init__(self): + # Initialize parent classes. + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + mobase.IPluginDiagnose.__init__(self) + + def resolve_path(self, path: str) -> str: + # Replace MO2 variables with actual paths + path = path.replace("%USERPROFILE%", os.environ.get("USERPROFILE", "")) + path = path.replace("%GAME_DOCUMENTS%", self.GameDocumentsDirectory) + path = path.replace("%GAME_PATH%", self.GameDataPath) + return path + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(S2HoCModDataChecker()) + self._register_feature( + BasicLocalSavegames(QDir(self.resolve_path(self.GameSavesDirectory))) + ) + + # Create the directory more reliably + if ( + self._organizer.managedGame() + and self._organizer.managedGame().gameName() == self.gameName() + ): + # Get the absolute path as a string + mod_path = self.paksModsDirectory().absolutePath() + try: + # Create the directory with parents if needed + os.makedirs(mod_path, exist_ok=True) + # Verify the directory was actually created + if not os.path.exists(mod_path): + print(f"Warning: Failed to create directory: {mod_path}") + except Exception as e: + print(f"Error creating mod directory: {e}") + + # Initialize PAK tab when UI is ready + organizer.onUserInterfaceInitialized(self.init_tab) + + return True + + def init_tab(self, main_window: QMainWindow): + """ + Initializes the PAK management tab for Stalker 2. + """ + try: + if self._organizer.managedGame() != self: + return + + self._main_window = main_window + tab_widget: QTabWidget = main_window.findChild(QTabWidget, "tabWidget") + if not tab_widget: + print("No main tab widget found!") + return + + # Import here to avoid circular imports + from .stalker2heartofchornobyl.paks import S2HoCPaksTabWidget + self._paks_tab = S2HoCPaksTabWidget(main_window, self._organizer) + + # Insert after the last tab (like Oblivion Remastered) + tab_widget.addTab(self._paks_tab, "PAK Files") + print("PAK Files tab added!") + except Exception as e: + print(f"Error initializing PAK tab: {e}") + import traceback + traceback.print_exc() + + def mappings(self) -> List[mobase.Mapping]: + return [ + mobase.Mapping("*.pak", "Content/Paks/~mods/", False), + mobase.Mapping("*.utoc", "Content/Paks/~mods/", False), + mobase.Mapping("*.ucas", "Content/Paks/~mods/", False), + mobase.Mapping("Paks/*.pak", "Content/Paks/~mods/", False), + mobase.Mapping("Paks/*.utoc", "Content/Paks/~mods/", False), + mobase.Mapping("Paks/*.ucas", "Content/Paks/~mods/", False), + mobase.Mapping("~mods/*.pak", "Content/Paks/~mods/", False), + mobase.Mapping("~mods/*.utoc", "Content/Paks/~mods/", False), + mobase.Mapping("~mods/*.ucas", "Content/Paks/~mods/", False), + mobase.Mapping("Content/Paks/~mods/*.pak", "Content/Paks/~mods/", False), + mobase.Mapping("Content/Paks/~mods/*.utoc", "Content/Paks/~mods/", False), + mobase.Mapping("Content/Paks/~mods/*.ucas", "Content/Paks/~mods/", False), + ] + + def gameDirectory(self) -> QDir: + return QDir(self._gamePath) + + def paksDirectory(self) -> QDir: + return QDir(self.gameDirectory().absolutePath() + "/Stalker2/Content/Paks") + + def paksModsDirectory(self) -> QDir: + # Use os.path.join for more reliable path construction + path = os.path.join(self.paksDirectory().absolutePath(), "~mods") + return QDir(path) + + def logicModsDirectory(self) -> QDir: + # Update path to place LogicMods under Paks + return QDir(self.gameDirectory().absolutePath() + "/Stalker2/Content/Paks/LogicMods") + + def binariesDirectory(self) -> QDir: + return QDir(self.gameDirectory().absolutePath() + "/Stalker2/Binaries/Win64") + + def getModMappings(self) -> dict[str, list[str]]: + return { + "Content/Paks/~mods": [self.paksModsDirectory().absolutePath()], + } + + def activeProblems(self) -> list[int]: + problems = set() + if self._organizer.managedGame() == self: + + # More reliable directory check using os.path + mod_path = self.paksModsDirectory().absolutePath() + if not os.path.isdir(mod_path): + problems.add(Problems.MISSING_MOD_DIRECTORIES) + print(f"Missing mod directory: {mod_path}") + + # Check for misplaced PAK files + for mod in self._organizer.modList().allMods(): + mod_info = self._organizer.modList().getMod(mod) + filetree = mod_info.fileTree() + + # Check for PAK files at the root level (remove LogicMods paths) + for entry in filetree: + if entry.name().endswith(('.pak', '.utoc', '.ucas')) and not any( + entry.path().startswith(p) for p in ['Content/Paks/~mods', 'Paks', '~mods'] + ): + problems.add(Problems.MISPLACED_PAK_FILES) + break + + return list(problems) + + def fullDescription(self, key: int) -> str: + match key: + case Problems.MISPLACED_PAK_FILES: + return ( + "Some mod packages contain PAK files that are not placed in the correct directory structure.\n\n" + "PAK files should be placed in one of the following locations within the mod:\n" + "- Content/Paks/~mods/\n" + "- Paks/\n" + "- ~mods/\n\n" + "Please restructure your mods to follow this directory layout." + ) + case Problems.MISSING_MOD_DIRECTORIES: + return ( + "Required mod directory is missing in the game folder.\n\n" + "The following directory should exist:\n" + "- Stalker2/Content/Paks/~mods\n\n" + "This will be created automatically when you restart Mod Organizer 2." + ) + case _: + return "" + + def hasGuidedFix(self, key: int) -> bool: + match key: + case Problems.MISSING_MOD_DIRECTORIES: + return True + case _: + return False + + def shortDescription(self, key: int) -> str: + match key: + case Problems.MISPLACED_PAK_FILES: + return "Some mods have PAK files in incorrect locations." + case Problems.MISSING_MOD_DIRECTORIES: + return "Required mod directories are missing." + case _: + return "" + + def startGuidedFix(self, key: int) -> None: + match key: + case Problems.MISSING_MOD_DIRECTORIES: + # Create only the ~mods directory + os.makedirs(self.paksModsDirectory().absolutePath(), exist_ok=True) + case _: + pass + + +class S2HoCModDataChecker(BasicModDataChecker): + def __init__(self, patterns: GlobPatterns = GlobPatterns()): + # Define valid mod directories and the file movement rules. + move_patterns = { + "*.pak": "Content/Paks/~mods/", + "*.utoc": "Content/Paks/~mods/", + "*.ucas": "Content/Paks/~mods/" + } + # Define valid mod roots - remove LogicMods + valid_roots = ["Content", "Paks", "~mods"] + base_patterns = GlobPatterns(valid=valid_roots, move=move_patterns) + merged_patterns = base_patterns.merge(patterns) + super().__init__(merged_patterns) + + +def createPlugin(): + return S2HoCGame() \ No newline at end of file diff --git a/games/stalker2heartofchornobyl/__init__.py b/games/stalker2heartofchornobyl/__init__.py new file mode 100644 index 0000000..599e66f --- /dev/null +++ b/games/stalker2heartofchornobyl/__init__.py @@ -0,0 +1,8 @@ + +from .paks import S2HoCPaksTabWidget, S2HoCPaksModel, S2HoCPaksView + +__all__ = [ + "S2HoCPaksTabWidget", + "S2HoCPaksModel", + "S2HoCPaksView" +] \ No newline at end of file diff --git a/games/stalker2heartofchornobyl/paks/__init__.py b/games/stalker2heartofchornobyl/paks/__init__.py new file mode 100644 index 0000000..8a34e9f --- /dev/null +++ b/games/stalker2heartofchornobyl/paks/__init__.py @@ -0,0 +1,5 @@ +from .model import S2HoCPaksModel +from .view import S2HoCPaksView +from .widget import S2HoCPaksTabWidget + +__all__ = ["S2HoCPaksTabWidget", "S2HoCPaksModel", "S2HoCPaksView"] \ No newline at end of file diff --git a/games/stalker2heartofchornobyl/paks/model.py b/games/stalker2heartofchornobyl/paks/model.py new file mode 100644 index 0000000..3dcd382 --- /dev/null +++ b/games/stalker2heartofchornobyl/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 S2HoCPaksColumns(IntEnum): + PRIORITY = auto() + PAK_NAME = auto() + SOURCE = auto() + + +class S2HoCPaksModel(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("stalker2_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.strip(), "", "", "") + 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(S2HoCPaksColumns) + + 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 = S2HoCPaksColumns(section + 1) + match column: + case S2HoCPaksColumns.PAK_NAME: + return "PAK Name" + case S2HoCPaksColumns.PRIORITY: + return "Priority" + case S2HoCPaksColumns.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 == S2HoCPaksColumns.PAK_NAME: + if role == Qt.ItemDataRole.DisplayRole: + return self.paks[index.row()][0] + elif index.column() + 1 == S2HoCPaksColumns.PRIORITY: + if role == Qt.ItemDataRole.DisplayRole: + return index.row() + elif index.column() + 1 == S2HoCPaksColumns.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 \ No newline at end of file diff --git a/games/stalker2heartofchornobyl/paks/view.py b/games/stalker2heartofchornobyl/paks/view.py new file mode 100644 index 0000000..8b396cc --- /dev/null +++ b/games/stalker2heartofchornobyl/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 S2HoCPaksView(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() \ No newline at end of file diff --git a/games/stalker2heartofchornobyl/paks/widget.py b/games/stalker2heartofchornobyl/paks/widget.py new file mode 100644 index 0000000..df230f3 --- /dev/null +++ b/games/stalker2heartofchornobyl/paks/widget.py @@ -0,0 +1,247 @@ +import os +from functools import cmp_to_key +from pathlib import Path +from typing import List, Tuple, 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 S2HoCPaksModel +from .view import S2HoCPaksView + + +def pak_sort(a: tuple[str, str], b: tuple[str, str]) -> int: + """Sort function for PAK files (like Oblivion Remastered)""" + if a[0] < b[0]: + return -1 + elif a[0] > b[0]: + return 1 + else: + return 0 + + +class S2HoCPaksTabWidget(QWidget): + """ + Widget for managing PAK files in Stalker 2: Heart of Chornobyl. + """ + + def __init__(self, parent: QWidget, organizer: mobase.IOrganizer): + super().__init__(parent) + self._organizer = organizer + self._view = S2HoCPaksView(self) + self._layout = QGridLayout(self) + self._layout.addWidget(self._view) + self._model = S2HoCPaksModel(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("stalker2_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): + """Write the PAK list to file and then move the files""" + profile = QDir(self._organizer.profilePath()) + paks_txt = QFileInfo(profile.absoluteFilePath("stalker2_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") + # Also move the files after updating the list + self.write_pak_files() + + def write_pak_files(self): + """Move PAK files to their target numbered directories""" + 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]: + """Preserve order from paks.txt if it exists, otherwise use alphabetical (like Oblivion Remastered)""" + 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): + """Parse PAK files from mods, following Oblivion Remastered pattern for numbered folder assignment""" + from ...game_stalker2heartofchornobyl import S2HoCGame + + mods = self._organizer.modList().allMods() + paks: dict[str, str] = {} + pak_paths: dict[str, tuple[str, str]] = {} + pak_source: dict[str, str] = {} + existing_folders: set[int] = set() + + print(f"[PAK Debug] Starting scan of {len(mods)} mods") + print(f"[PAK Debug] ONLY scanning ~mods directories, EXCLUDING LogicMods") + + # First, scan what numbered folders already exist to avoid double-assignment + game = self._organizer.managedGame() + if isinstance(game, S2HoCGame): + pak_mods_dir = QFileInfo(game.paksModsDirectory().absolutePath()) + if pak_mods_dir.exists() and pak_mods_dir.isDir(): + print(f"[PAK Debug] Scanning existing folders in: {pak_mods_dir.absoluteFilePath()}") + for entry in QDir(pak_mods_dir.absoluteFilePath()).entryInfoList( + QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot + ): + try: + folder_num = int(entry.completeBaseName()) + existing_folders.add(folder_num) + print(f"[PAK Debug] Found existing numbered folder: {folder_num}") + except ValueError: + print(f"[PAK Debug] Skipping non-numbered folder: {entry.completeBaseName()}") + + # Scan mods for PAK files ONLY in Content/Paks/~mods structure (exclude LogicMods) + 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() + + # If this mod contains a LogicMods directory, skip it entirely for the PAK tab + has_logicmods = ( + filetree.find("Content/Paks/LogicMods") or filetree.find("Paks/LogicMods") + ) + if isinstance(has_logicmods, mobase.IFileTree): + print(f"[PAK Debug] Skipping mod '{mod_item.name()}' because it contains a LogicMods directory.") + continue + + # ONLY look for ~mods directories + pak_mods = filetree.find("Paks/~mods") + if not pak_mods: + pak_mods = filetree.find("Content/Paks/~mods") + if isinstance(pak_mods, mobase.IFileTree) and pak_mods.name() == "~mods": + print(f"[PAK Debug] Found ~mods directory in: {mod_item.name()}") + for entry in pak_mods: + if is_directory(entry): + 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() + print(f"[PAK Debug] ✅ Added PAK from ~mods numbered folder: {pak_name} in {entry.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() + print(f"[PAK Debug] ✅ Added loose PAK from ~mods: {pak_name}") + else: + # Check if this mod has LogicMods (for debugging purposes) + logic_mods = filetree.find("Content/Paks/LogicMods") + if not logic_mods: + logic_mods = filetree.find("Paks/LogicMods") + if isinstance(logic_mods, mobase.IFileTree): + print(f"[PAK Debug] Mod {mod_item.name()} has LogicMods (not included in PAK tab)") + + # NOTE: Removed game directory scanning to prevent LogicMods PAKs from appearing + # We only want PAKs from mod files, not from game directory + print(f"[PAK Debug] Skipping game directory scan to prevent LogicMods inclusion") + + # Sort PAKs and shake them (preserve order from paks.txt if it exists) + sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort))) + shaken_paks: list[str] = self._shake_paks(sorted_paks) + + # Assign target directories with numbered folders (like Oblivion Remastered) + # Skip numbers that already exist, use next available number starting from 8999 + final_paks: dict[str, tuple[str, str, str]] = {} + pak_index = 8999 + + for pak in shaken_paks: + # Find next available number (skip existing folders) + while pak_index in existing_folders: + pak_index -= 1 + + # If PAK is already in a numbered folder, keep its current assignment + current_folder = paks[pak] + if current_folder.isdigit(): + target_dir = pak_paths[pak][1] + "/" + current_folder + existing_folders.add(int(current_folder)) + print(f"[PAK Debug] Keeping {pak} in existing folder {current_folder}") + else: + # Assign new numbered folder for loose PAKs + target_dir = pak_paths[pak][1] + "/" + str(pak_index).zfill(4) + existing_folders.add(pak_index) + print(f"[PAK Debug] Assigning {pak} to new folder {pak_index}") + pak_index -= 1 + + final_paks[pak] = (pak_source[pak], pak_paths[pak][0], target_dir) + + # Convert to model format (4-tuple matching Oblivion Remastered) + 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 + + print(f"[PAK Debug] Final PAK count: {len(new_data_paks)}") + self._model.set_paks(new_data_paks) From ab91432d429d5ec75630e299423146320437832d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 22 Jun 2025 02:40:27 +0000 Subject: [PATCH 02/14] [pre-commit.ci] Auto fixes from pre-commit.com hooks. --- games/game_stalker2heartofchornobyl.py | 53 +++++++------- games/stalker2heartofchornobyl/__init__.py | 9 +-- .../stalker2heartofchornobyl/paks/__init__.py | 4 +- games/stalker2heartofchornobyl/paks/model.py | 2 +- games/stalker2heartofchornobyl/paks/view.py | 2 +- games/stalker2heartofchornobyl/paks/widget.py | 69 ++++++++++++------- 6 files changed, 79 insertions(+), 60 deletions(-) diff --git a/games/game_stalker2heartofchornobyl.py b/games/game_stalker2heartofchornobyl.py index 9c3037a..5702dfb 100644 --- a/games/game_stalker2heartofchornobyl.py +++ b/games/game_stalker2heartofchornobyl.py @@ -1,18 +1,21 @@ -from typing import List +import os from enum import IntEnum, auto -from pathlib import Path -from PyQt6.QtCore import QDir, QFileInfo +from typing import List + +from PyQt6.QtCore import QDir from PyQt6.QtWidgets import QMainWindow, QTabWidget, QWidget + import mobase -import os + +from ..basic_features import BasicLocalSavegames, BasicModDataChecker, GlobPatterns from ..basic_game import BasicGame -from ..basic_features import BasicModDataChecker, GlobPatterns, BasicLocalSavegames class Problems(IntEnum): """ Enums for IPluginDiagnose. """ + # PAK files placed in incorrect locations MISPLACED_PAK_FILES = auto() # Missing mod directory structure @@ -39,7 +42,7 @@ class S2HoCGame(BasicGame, mobase.IPluginFileMapper, mobase.IPluginDiagnose): GameIniFiles = [ "%GAME_DOCUMENTS%/Saved/Config/Windows/Game.ini", "%GAME_DOCUMENTS%/Saved/Config/Windows/GameUserSettings.ini", - "%GAME_DOCUMENTS%/Saved/Config/Windows/Engine.ini" + "%GAME_DOCUMENTS%/Saved/Config/Windows/Engine.ini", ] _main_window: QMainWindow @@ -64,7 +67,7 @@ def init(self, organizer: mobase.IOrganizer) -> bool: self._register_feature( BasicLocalSavegames(QDir(self.resolve_path(self.GameSavesDirectory))) ) - + # Create the directory more reliably if ( self._organizer.managedGame() @@ -80,10 +83,10 @@ def init(self, organizer: mobase.IOrganizer) -> bool: print(f"Warning: Failed to create directory: {mod_path}") except Exception as e: print(f"Error creating mod directory: {e}") - + # Initialize PAK tab when UI is ready organizer.onUserInterfaceInitialized(self.init_tab) - + return True def init_tab(self, main_window: QMainWindow): @@ -102,6 +105,7 @@ def init_tab(self, main_window: QMainWindow): # Import here to avoid circular imports from .stalker2heartofchornobyl.paks import S2HoCPaksTabWidget + self._paks_tab = S2HoCPaksTabWidget(main_window, self._organizer) # Insert after the last tab (like Oblivion Remastered) @@ -110,6 +114,7 @@ def init_tab(self, main_window: QMainWindow): except Exception as e: print(f"Error initializing PAK tab: {e}") import traceback + traceback.print_exc() def mappings(self) -> List[mobase.Mapping]: @@ -130,46 +135,48 @@ def mappings(self) -> List[mobase.Mapping]: def gameDirectory(self) -> QDir: return QDir(self._gamePath) - + def paksDirectory(self) -> QDir: return QDir(self.gameDirectory().absolutePath() + "/Stalker2/Content/Paks") - + def paksModsDirectory(self) -> QDir: # Use os.path.join for more reliable path construction path = os.path.join(self.paksDirectory().absolutePath(), "~mods") return QDir(path) - + def logicModsDirectory(self) -> QDir: # Update path to place LogicMods under Paks - return QDir(self.gameDirectory().absolutePath() + "/Stalker2/Content/Paks/LogicMods") - + return QDir( + self.gameDirectory().absolutePath() + "/Stalker2/Content/Paks/LogicMods" + ) + def binariesDirectory(self) -> QDir: return QDir(self.gameDirectory().absolutePath() + "/Stalker2/Binaries/Win64") - + def getModMappings(self) -> dict[str, list[str]]: return { "Content/Paks/~mods": [self.paksModsDirectory().absolutePath()], } - + def activeProblems(self) -> list[int]: problems = set() if self._organizer.managedGame() == self: - # More reliable directory check using os.path mod_path = self.paksModsDirectory().absolutePath() if not os.path.isdir(mod_path): problems.add(Problems.MISSING_MOD_DIRECTORIES) print(f"Missing mod directory: {mod_path}") - + # Check for misplaced PAK files for mod in self._organizer.modList().allMods(): mod_info = self._organizer.modList().getMod(mod) filetree = mod_info.fileTree() - + # Check for PAK files at the root level (remove LogicMods paths) for entry in filetree: - if entry.name().endswith(('.pak', '.utoc', '.ucas')) and not any( - entry.path().startswith(p) for p in ['Content/Paks/~mods', 'Paks', '~mods'] + if entry.name().endswith((".pak", ".utoc", ".ucas")) and not any( + entry.path().startswith(p) + for p in ["Content/Paks/~mods", "Paks", "~mods"] ): problems.add(Problems.MISPLACED_PAK_FILES) break @@ -228,7 +235,7 @@ def __init__(self, patterns: GlobPatterns = GlobPatterns()): move_patterns = { "*.pak": "Content/Paks/~mods/", "*.utoc": "Content/Paks/~mods/", - "*.ucas": "Content/Paks/~mods/" + "*.ucas": "Content/Paks/~mods/", } # Define valid mod roots - remove LogicMods valid_roots = ["Content", "Paks", "~mods"] @@ -238,4 +245,4 @@ def __init__(self, patterns: GlobPatterns = GlobPatterns()): def createPlugin(): - return S2HoCGame() \ No newline at end of file + return S2HoCGame() diff --git a/games/stalker2heartofchornobyl/__init__.py b/games/stalker2heartofchornobyl/__init__.py index 599e66f..f53b86d 100644 --- a/games/stalker2heartofchornobyl/__init__.py +++ b/games/stalker2heartofchornobyl/__init__.py @@ -1,8 +1,3 @@ +from .paks import S2HoCPaksModel, S2HoCPaksTabWidget, S2HoCPaksView -from .paks import S2HoCPaksTabWidget, S2HoCPaksModel, S2HoCPaksView - -__all__ = [ - "S2HoCPaksTabWidget", - "S2HoCPaksModel", - "S2HoCPaksView" -] \ No newline at end of file +__all__ = ["S2HoCPaksTabWidget", "S2HoCPaksModel", "S2HoCPaksView"] diff --git a/games/stalker2heartofchornobyl/paks/__init__.py b/games/stalker2heartofchornobyl/paks/__init__.py index 8a34e9f..96f3564 100644 --- a/games/stalker2heartofchornobyl/paks/__init__.py +++ b/games/stalker2heartofchornobyl/paks/__init__.py @@ -1,5 +1,5 @@ from .model import S2HoCPaksModel -from .view import S2HoCPaksView +from .view import S2HoCPaksView from .widget import S2HoCPaksTabWidget -__all__ = ["S2HoCPaksTabWidget", "S2HoCPaksModel", "S2HoCPaksView"] \ No newline at end of file +__all__ = ["S2HoCPaksTabWidget", "S2HoCPaksModel", "S2HoCPaksView"] diff --git a/games/stalker2heartofchornobyl/paks/model.py b/games/stalker2heartofchornobyl/paks/model.py index 3dcd382..fd61692 100644 --- a/games/stalker2heartofchornobyl/paks/model.py +++ b/games/stalker2heartofchornobyl/paks/model.py @@ -250,4 +250,4 @@ def dropMimeData( index -= 1 self.set_paks(new_paks) - return False \ No newline at end of file + return False diff --git a/games/stalker2heartofchornobyl/paks/view.py b/games/stalker2heartofchornobyl/paks/view.py index 8b396cc..d98231f 100644 --- a/games/stalker2heartofchornobyl/paks/view.py +++ b/games/stalker2heartofchornobyl/paks/view.py @@ -30,4 +30,4 @@ def dataChanged( self, topLeft: QModelIndex, bottomRight: QModelIndex, roles: Iterable[int] = () ): super().dataChanged(topLeft, bottomRight, roles) - self.repaint() \ No newline at end of file + self.repaint() diff --git a/games/stalker2heartofchornobyl/paks/widget.py b/games/stalker2heartofchornobyl/paks/widget.py index df230f3..a683516 100644 --- a/games/stalker2heartofchornobyl/paks/widget.py +++ b/games/stalker2heartofchornobyl/paks/widget.py @@ -1,7 +1,6 @@ -import os from functools import cmp_to_key from pathlib import Path -from typing import List, Tuple, cast +from typing import cast from PyQt6.QtCore import QDir, QFileInfo from PyQt6.QtWidgets import QGridLayout, QWidget @@ -124,26 +123,32 @@ def _parse_pak_files(self): pak_paths: dict[str, tuple[str, str]] = {} pak_source: dict[str, str] = {} existing_folders: set[int] = set() - + print(f"[PAK Debug] Starting scan of {len(mods)} mods") - print(f"[PAK Debug] ONLY scanning ~mods directories, EXCLUDING LogicMods") - + print("[PAK Debug] ONLY scanning ~mods directories, EXCLUDING LogicMods") + # First, scan what numbered folders already exist to avoid double-assignment game = self._organizer.managedGame() if isinstance(game, S2HoCGame): pak_mods_dir = QFileInfo(game.paksModsDirectory().absolutePath()) if pak_mods_dir.exists() and pak_mods_dir.isDir(): - print(f"[PAK Debug] Scanning existing folders in: {pak_mods_dir.absoluteFilePath()}") + print( + f"[PAK Debug] Scanning existing folders in: {pak_mods_dir.absoluteFilePath()}" + ) for entry in QDir(pak_mods_dir.absoluteFilePath()).entryInfoList( QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot ): try: folder_num = int(entry.completeBaseName()) existing_folders.add(folder_num) - print(f"[PAK Debug] Found existing numbered folder: {folder_num}") + print( + f"[PAK Debug] Found existing numbered folder: {folder_num}" + ) except ValueError: - print(f"[PAK Debug] Skipping non-numbered folder: {entry.completeBaseName()}") - + print( + f"[PAK Debug] Skipping non-numbered folder: {entry.completeBaseName()}" + ) + # Scan mods for PAK files ONLY in Content/Paks/~mods structure (exclude LogicMods) for mod in mods: mod_item = self._organizer.modList().getMod(mod) @@ -152,11 +157,13 @@ def _parse_pak_files(self): filetree = mod_item.fileTree() # If this mod contains a LogicMods directory, skip it entirely for the PAK tab - has_logicmods = ( - filetree.find("Content/Paks/LogicMods") or filetree.find("Paks/LogicMods") + has_logicmods = filetree.find("Content/Paks/LogicMods") or filetree.find( + "Paks/LogicMods" ) if isinstance(has_logicmods, mobase.IFileTree): - print(f"[PAK Debug] Skipping mod '{mod_item.name()}' because it contains a LogicMods directory.") + print( + f"[PAK Debug] Skipping mod '{mod_item.name()}' because it contains a LogicMods directory." + ) continue # ONLY look for ~mods directories @@ -172,16 +179,22 @@ def _parse_pak_files(self): sub_entry.isFile() and sub_entry.suffix().casefold() == "pak" ): - pak_name = sub_entry.name()[: -1 - len(sub_entry.suffix())] + pak_name = sub_entry.name()[ + : -1 - len(sub_entry.suffix()) + ] paks[pak_name] = entry.name() pak_paths[pak_name] = ( mod_item.absolutePath() + "/" - + cast(mobase.IFileTree, sub_entry.parent()).path("/"), - mod_item.absolutePath() + "/" + pak_mods.path("/") + + cast(mobase.IFileTree, sub_entry.parent()).path( + "/" + ), + mod_item.absolutePath() + "/" + pak_mods.path("/"), ) pak_source[pak_name] = mod_item.name() - print(f"[PAK Debug] ✅ Added PAK from ~mods numbered folder: {pak_name} in {entry.name()}") + print( + f"[PAK Debug] ✅ Added PAK from ~mods numbered folder: {pak_name} in {entry.name()}" + ) else: if entry.suffix().casefold() == "pak": pak_name = entry.name()[: -1 - len(entry.suffix())] @@ -190,36 +203,40 @@ def _parse_pak_files(self): mod_item.absolutePath() + "/" + cast(mobase.IFileTree, entry.parent()).path("/"), - mod_item.absolutePath() + "/" + pak_mods.path("/") + mod_item.absolutePath() + "/" + pak_mods.path("/"), ) pak_source[pak_name] = mod_item.name() - print(f"[PAK Debug] ✅ Added loose PAK from ~mods: {pak_name}") + print( + f"[PAK Debug] ✅ Added loose PAK from ~mods: {pak_name}" + ) else: # Check if this mod has LogicMods (for debugging purposes) logic_mods = filetree.find("Content/Paks/LogicMods") if not logic_mods: logic_mods = filetree.find("Paks/LogicMods") if isinstance(logic_mods, mobase.IFileTree): - print(f"[PAK Debug] Mod {mod_item.name()} has LogicMods (not included in PAK tab)") + print( + f"[PAK Debug] Mod {mod_item.name()} has LogicMods (not included in PAK tab)" + ) # NOTE: Removed game directory scanning to prevent LogicMods PAKs from appearing # We only want PAKs from mod files, not from game directory - print(f"[PAK Debug] Skipping game directory scan to prevent LogicMods inclusion") + print("[PAK Debug] Skipping game directory scan to prevent LogicMods inclusion") # Sort PAKs and shake them (preserve order from paks.txt if it exists) sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort))) shaken_paks: list[str] = self._shake_paks(sorted_paks) - + # Assign target directories with numbered folders (like Oblivion Remastered) # Skip numbers that already exist, use next available number starting from 8999 final_paks: dict[str, tuple[str, str, str]] = {} pak_index = 8999 - + for pak in shaken_paks: # Find next available number (skip existing folders) while pak_index in existing_folders: pak_index -= 1 - + # If PAK is already in a numbered folder, keep its current assignment current_folder = paks[pak] if current_folder.isdigit(): @@ -232,9 +249,9 @@ def _parse_pak_files(self): existing_folders.add(pak_index) print(f"[PAK Debug] Assigning {pak} to new folder {pak_index}") pak_index -= 1 - + final_paks[pak] = (pak_source[pak], pak_paths[pak][0], target_dir) - + # Convert to model format (4-tuple matching Oblivion Remastered) new_data_paks: dict[int, tuple[str, str, str, str]] = {} i = 0 @@ -242,6 +259,6 @@ def _parse_pak_files(self): source, current_path, target_path = data new_data_paks[i] = (pak, source, current_path, target_path) i += 1 - + print(f"[PAK Debug] Final PAK count: {len(new_data_paks)}") self._model.set_paks(new_data_paks) From 109daf605ffd7ccb95999b9dea0b9d9e7273255d Mon Sep 17 00:00:00 2001 From: MKHATERS <42836901+MK-HATERS@users.noreply.github.com> Date: Sat, 21 Jun 2025 21:03:43 -0600 Subject: [PATCH 03/14] updated some comments and redudencies --- games/game_stalker2heartofchornobyl.py | 103 +++++++++--------- games/stalker2heartofchornobyl/paks/model.py | 21 ++-- games/stalker2heartofchornobyl/paks/view.py | 7 +- games/stalker2heartofchornobyl/paks/widget.py | 49 ++------- 4 files changed, 77 insertions(+), 103 deletions(-) diff --git a/games/game_stalker2heartofchornobyl.py b/games/game_stalker2heartofchornobyl.py index 9c3037a..8b208a7 100644 --- a/games/game_stalker2heartofchornobyl.py +++ b/games/game_stalker2heartofchornobyl.py @@ -13,9 +13,7 @@ class Problems(IntEnum): """ Enums for IPluginDiagnose. """ - # PAK files placed in incorrect locations MISPLACED_PAK_FILES = auto() - # Missing mod directory structure MISSING_MOD_DIRECTORIES = auto() @@ -24,7 +22,6 @@ class S2HoCGame(BasicGame, mobase.IPluginFileMapper, mobase.IPluginDiagnose): Author = "MkHaters" Version = "1.1.0" - # Game details for MO2, using paths common for Unreal Engine-based games. GameName = "Stalker 2: Heart of Chornobyl" GameShortName = "stalker2heartofchornobyl" GameNexusName = "stalker2heartofchornobyl" @@ -35,7 +32,7 @@ class S2HoCGame(BasicGame, mobase.IPluginFileMapper, mobase.IPluginDiagnose): GameSteamId = 1643320 GameGogId = 1529799785 GameBinary = "Stalker2.exe" - GameDataPath = "%GAME_PATH%/Stalker2" + GameDataPath = "Stalker2" GameIniFiles = [ "%GAME_DOCUMENTS%/Saved/Config/Windows/Game.ini", "%GAME_DOCUMENTS%/Saved/Config/Windows/GameUserSettings.ini", @@ -43,19 +40,24 @@ class S2HoCGame(BasicGame, mobase.IPluginFileMapper, mobase.IPluginDiagnose): ] _main_window: QMainWindow - _paks_tab: QWidget # Will be S2HoCPaksTabWidget when imported + _paks_tab: QWidget def __init__(self): - # Initialize parent classes. BasicGame.__init__(self) mobase.IPluginFileMapper.__init__(self) mobase.IPluginDiagnose.__init__(self) def resolve_path(self, path: str) -> str: - # Replace MO2 variables with actual paths path = path.replace("%USERPROFILE%", os.environ.get("USERPROFILE", "")) - path = path.replace("%GAME_DOCUMENTS%", self.GameDocumentsDirectory) - path = path.replace("%GAME_PATH%", self.GameDataPath) + + if "%GAME_DOCUMENTS%" in path: + game_docs = self.GameDocumentsDirectory.replace("%USERPROFILE%", os.environ.get("USERPROFILE", "")) + path = path.replace("%GAME_DOCUMENTS%", game_docs) + + if "%GAME_PATH%" in path: + game_path = self._gamePath if hasattr(self, '_gamePath') else "" + path = path.replace("%GAME_PATH%", game_path) + return path def init(self, organizer: mobase.IOrganizer) -> bool: @@ -65,25 +67,21 @@ def init(self, organizer: mobase.IOrganizer) -> bool: BasicLocalSavegames(QDir(self.resolve_path(self.GameSavesDirectory))) ) - # Create the directory more reliably if ( self._organizer.managedGame() and self._organizer.managedGame().gameName() == self.gameName() ): - # Get the absolute path as a string mod_path = self.paksModsDirectory().absolutePath() try: - # Create the directory with parents if needed os.makedirs(mod_path, exist_ok=True) - # Verify the directory was actually created if not os.path.exists(mod_path): - print(f"Warning: Failed to create directory: {mod_path}") + self._organizer.log(mobase.LogLevel.WARNING, f"Failed to create directory: {mod_path}") + except OSError as e: + self._organizer.log(mobase.LogLevel.ERROR, f"OS error creating mod directory: {e}") except Exception as e: - print(f"Error creating mod directory: {e}") + self._organizer.log(mobase.LogLevel.ERROR, f"Unexpected error creating mod directory: {e}") - # Initialize PAK tab when UI is ready organizer.onUserInterfaceInitialized(self.init_tab) - return True def init_tab(self, main_window: QMainWindow): @@ -97,54 +95,59 @@ def init_tab(self, main_window: QMainWindow): self._main_window = main_window tab_widget: QTabWidget = main_window.findChild(QTabWidget, "tabWidget") if not tab_widget: - print("No main tab widget found!") + self._organizer.log(mobase.LogLevel.WARNING, "No main tab widget found!") return - # Import here to avoid circular imports from .stalker2heartofchornobyl.paks import S2HoCPaksTabWidget self._paks_tab = S2HoCPaksTabWidget(main_window, self._organizer) - # Insert after the last tab (like Oblivion Remastered) tab_widget.addTab(self._paks_tab, "PAK Files") - print("PAK Files tab added!") + self._organizer.log(mobase.LogLevel.INFO, "PAK Files tab added!") + except ImportError as e: + self._organizer.log(mobase.LogLevel.ERROR, f"Failed to import PAK tab widget: {e}") except Exception as e: - print(f"Error initializing PAK tab: {e}") + self._organizer.log(mobase.LogLevel.ERROR, f"Error initializing PAK tab: {e}") import traceback traceback.print_exc() def mappings(self) -> List[mobase.Mapping]: - return [ - mobase.Mapping("*.pak", "Content/Paks/~mods/", False), - mobase.Mapping("*.utoc", "Content/Paks/~mods/", False), - mobase.Mapping("*.ucas", "Content/Paks/~mods/", False), - mobase.Mapping("Paks/*.pak", "Content/Paks/~mods/", False), - mobase.Mapping("Paks/*.utoc", "Content/Paks/~mods/", False), - mobase.Mapping("Paks/*.ucas", "Content/Paks/~mods/", False), - mobase.Mapping("~mods/*.pak", "Content/Paks/~mods/", False), - mobase.Mapping("~mods/*.utoc", "Content/Paks/~mods/", False), - mobase.Mapping("~mods/*.ucas", "Content/Paks/~mods/", False), - mobase.Mapping("Content/Paks/~mods/*.pak", "Content/Paks/~mods/", False), - mobase.Mapping("Content/Paks/~mods/*.utoc", "Content/Paks/~mods/", False), - mobase.Mapping("Content/Paks/~mods/*.ucas", "Content/Paks/~mods/", False), - ] + pak_extensions = ["*.pak", "*.utoc", "*.ucas"] + target_dir = "Content/Paks/~mods/" + + mappings = [] + + for ext in pak_extensions: + mappings.append(mobase.Mapping(ext, target_dir, False)) + + source_dirs = ["Paks/", "~mods/", "Content/Paks/~mods/"] + for source_dir in source_dirs: + for ext in pak_extensions: + mappings.append(mobase.Mapping(f"{source_dir}{ext}", target_dir, False)) + + return mappings def gameDirectory(self) -> QDir: return QDir(self._gamePath) def paksDirectory(self) -> QDir: - return QDir(self.gameDirectory().absolutePath() + "/Stalker2/Content/Paks") + path = os.path.join(self.gameDirectory().absolutePath(), self.GameDataPath, "Content", "Paks") + return QDir(path) def paksModsDirectory(self) -> QDir: - # Use os.path.join for more reliable path construction - path = os.path.join(self.paksDirectory().absolutePath(), "~mods") - return QDir(path) + try: + path = os.path.join(self.paksDirectory().absolutePath(), "~mods") + return QDir(path) + except Exception as e: + fallback = os.path.join(self.gameDirectory().absolutePath(), self.GameDataPath, "Content", "Paks", "~mods") + return QDir(fallback) def logicModsDirectory(self) -> QDir: - # Update path to place LogicMods under Paks - return QDir(self.gameDirectory().absolutePath() + "/Stalker2/Content/Paks/LogicMods") + path = os.path.join(self.gameDirectory().absolutePath(), self.GameDataPath, "Content", "Paks", "LogicMods") + return QDir(path) def binariesDirectory(self) -> QDir: - return QDir(self.gameDirectory().absolutePath() + "/Stalker2/Binaries/Win64") + path = os.path.join(self.gameDirectory().absolutePath(), self.GameDataPath, "Binaries", "Win64") + return QDir(path) def getModMappings(self) -> dict[str, list[str]]: return { @@ -155,18 +158,15 @@ def activeProblems(self) -> list[int]: problems = set() if self._organizer.managedGame() == self: - # More reliable directory check using os.path mod_path = self.paksModsDirectory().absolutePath() if not os.path.isdir(mod_path): problems.add(Problems.MISSING_MOD_DIRECTORIES) - print(f"Missing mod directory: {mod_path}") + self._organizer.log(mobase.LogLevel.DEBUG, f"Missing mod directory: {mod_path}") - # Check for misplaced PAK files for mod in self._organizer.modList().allMods(): mod_info = self._organizer.modList().getMod(mod) filetree = mod_info.fileTree() - # Check for PAK files at the root level (remove LogicMods paths) for entry in filetree: if entry.name().endswith(('.pak', '.utoc', '.ucas')) and not any( entry.path().startswith(p) for p in ['Content/Paks/~mods', 'Paks', '~mods'] @@ -216,21 +216,22 @@ def shortDescription(self, key: int) -> str: def startGuidedFix(self, key: int) -> None: match key: case Problems.MISSING_MOD_DIRECTORIES: - # Create only the ~mods directory - os.makedirs(self.paksModsDirectory().absolutePath(), exist_ok=True) + try: + os.makedirs(self.paksModsDirectory().absolutePath(), exist_ok=True) + self._organizer.log(mobase.LogLevel.INFO, "Created missing mod directories") + except Exception as e: + self._organizer.log(mobase.LogLevel.ERROR, f"Failed to create mod directories: {e}") case _: pass class S2HoCModDataChecker(BasicModDataChecker): def __init__(self, patterns: GlobPatterns = GlobPatterns()): - # Define valid mod directories and the file movement rules. move_patterns = { "*.pak": "Content/Paks/~mods/", "*.utoc": "Content/Paks/~mods/", "*.ucas": "Content/Paks/~mods/" } - # Define valid mod roots - remove LogicMods valid_roots = ["Content", "Paks", "~mods"] base_patterns = GlobPatterns(valid=valid_roots, move=move_patterns) merged_patterns = base_patterns.merge(patterns) diff --git a/games/stalker2heartofchornobyl/paks/model.py b/games/stalker2heartofchornobyl/paks/model.py index 3dcd382..466fb57 100644 --- a/games/stalker2heartofchornobyl/paks/model.py +++ b/games/stalker2heartofchornobyl/paks/model.py @@ -39,11 +39,16 @@ def _init_mod_states(self): profile = QDir(self._organizer.profilePath()) paks_txt = QFileInfo(profile.absoluteFilePath("stalker2_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.strip(), "", "", "") - index += 1 + try: + with open(paks_txt.absoluteFilePath(), "r", encoding="utf-8") as paks_file: + index = 0 + for line in paks_file: + stripped_line = line.strip() + if stripped_line: + self.paks[index] = (stripped_line, "", "", "") + index += 1 + except (IOError, OSError) as e: + pass def set_paks(self, paks: dict[int, _PakInfo]): self.layoutAboutToBeChanged.emit() @@ -66,7 +71,8 @@ def flags(self, index: QModelIndex) -> Qt.ItemFlag: return ( super().flags(index) | Qt.ItemFlag.ItemIsDragEnabled - | Qt.ItemFlag.ItemIsDropEnabled & Qt.ItemFlag.ItemIsEditable + | Qt.ItemFlag.ItemIsDropEnabled + | Qt.ItemFlag.ItemIsEditable ) def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: @@ -198,6 +204,7 @@ def dropMimeData( 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: @@ -250,4 +257,4 @@ def dropMimeData( index -= 1 self.set_paks(new_paks) - return False \ No newline at end of file + return True \ No newline at end of file diff --git a/games/stalker2heartofchornobyl/paks/view.py b/games/stalker2heartofchornobyl/paks/view.py index 8b396cc..fc5a63c 100644 --- a/games/stalker2heartofchornobyl/paks/view.py +++ b/games/stalker2heartofchornobyl/paks/view.py @@ -22,9 +22,10 @@ def __init__(self, parent: QWidget | None): self.setRootIsDecorated(False) def dropEvent(self, e: QDropEvent | None): - super().dropEvent(e) - self.clearSelection() - self.data_dropped.emit() + if e is not None: + super().dropEvent(e) + self.clearSelection() + self.data_dropped.emit() def dataChanged( self, topLeft: QModelIndex, bottomRight: QModelIndex, roles: Iterable[int] = () diff --git a/games/stalker2heartofchornobyl/paks/widget.py b/games/stalker2heartofchornobyl/paks/widget.py index df230f3..d5c8f07 100644 --- a/games/stalker2heartofchornobyl/paks/widget.py +++ b/games/stalker2heartofchornobyl/paks/widget.py @@ -14,7 +14,7 @@ def pak_sort(a: tuple[str, str], b: tuple[str, str]) -> int: - """Sort function for PAK files (like Oblivion Remastered)""" + """Sort function for PAK files""" if a[0] < b[0]: return -1 elif a[0] > b[0]: @@ -36,8 +36,8 @@ def __init__(self, parent: QWidget, organizer: mobase.IOrganizer): self._layout.addWidget(self._view) self._model = S2HoCPaksModel(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 + 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()) @@ -62,7 +62,6 @@ def write_paks_list(self): for _, pak in sorted(self._model.paks.items()): name, _, _, _ = pak paks_file.write(f"{name}\n") - # Also move the files after updating the list self.write_pak_files() def write_pak_files(self): @@ -97,7 +96,7 @@ def write_pak_files(self): path_dir.rmdir() def _shake_paks(self, sorted_paks: dict[str, str]) -> list[str]: - """Preserve order from paks.txt if it exists, otherwise use alphabetical (like Oblivion Remastered)""" + """Preserve order from paks.txt if it exists, otherwise use alphabetical""" shaken_paks: list[str] = [] shaken_paks_p: list[str] = [] paks_list = self.load_paks_list() @@ -116,7 +115,7 @@ def _shake_paks(self, sorted_paks: dict[str, str]) -> list[str]: return shaken_paks + shaken_paks_p def _parse_pak_files(self): - """Parse PAK files from mods, following Oblivion Remastered pattern for numbered folder assignment""" + """Parse PAK files from mods, following numbered folder assignment pattern""" from ...game_stalker2heartofchornobyl import S2HoCGame mods = self._organizer.modList().allMods() @@ -125,46 +124,35 @@ def _parse_pak_files(self): pak_source: dict[str, str] = {} existing_folders: set[int] = set() - print(f"[PAK Debug] Starting scan of {len(mods)} mods") - print(f"[PAK Debug] ONLY scanning ~mods directories, EXCLUDING LogicMods") - - # First, scan what numbered folders already exist to avoid double-assignment game = self._organizer.managedGame() if isinstance(game, S2HoCGame): pak_mods_dir = QFileInfo(game.paksModsDirectory().absolutePath()) if pak_mods_dir.exists() and pak_mods_dir.isDir(): - print(f"[PAK Debug] Scanning existing folders in: {pak_mods_dir.absoluteFilePath()}") for entry in QDir(pak_mods_dir.absoluteFilePath()).entryInfoList( QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot ): try: folder_num = int(entry.completeBaseName()) existing_folders.add(folder_num) - print(f"[PAK Debug] Found existing numbered folder: {folder_num}") except ValueError: - print(f"[PAK Debug] Skipping non-numbered folder: {entry.completeBaseName()}") + pass - # Scan mods for PAK files ONLY in Content/Paks/~mods structure (exclude LogicMods) 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() - # If this mod contains a LogicMods directory, skip it entirely for the PAK tab has_logicmods = ( filetree.find("Content/Paks/LogicMods") or filetree.find("Paks/LogicMods") ) if isinstance(has_logicmods, mobase.IFileTree): - print(f"[PAK Debug] Skipping mod '{mod_item.name()}' because it contains a LogicMods directory.") continue - # ONLY look for ~mods directories pak_mods = filetree.find("Paks/~mods") if not pak_mods: pak_mods = filetree.find("Content/Paks/~mods") if isinstance(pak_mods, mobase.IFileTree) and pak_mods.name() == "~mods": - print(f"[PAK Debug] Found ~mods directory in: {mod_item.name()}") for entry in pak_mods: if is_directory(entry): for sub_entry in entry: @@ -181,7 +169,6 @@ def _parse_pak_files(self): mod_item.absolutePath() + "/" + pak_mods.path("/") ) pak_source[pak_name] = mod_item.name() - print(f"[PAK Debug] ✅ Added PAK from ~mods numbered folder: {pak_name} in {entry.name()}") else: if entry.suffix().casefold() == "pak": pak_name = entry.name()[: -1 - len(entry.suffix())] @@ -193,49 +180,28 @@ def _parse_pak_files(self): mod_item.absolutePath() + "/" + pak_mods.path("/") ) pak_source[pak_name] = mod_item.name() - print(f"[PAK Debug] ✅ Added loose PAK from ~mods: {pak_name}") - else: - # Check if this mod has LogicMods (for debugging purposes) - logic_mods = filetree.find("Content/Paks/LogicMods") - if not logic_mods: - logic_mods = filetree.find("Paks/LogicMods") - if isinstance(logic_mods, mobase.IFileTree): - print(f"[PAK Debug] Mod {mod_item.name()} has LogicMods (not included in PAK tab)") - - # NOTE: Removed game directory scanning to prevent LogicMods PAKs from appearing - # We only want PAKs from mod files, not from game directory - print(f"[PAK Debug] Skipping game directory scan to prevent LogicMods inclusion") - - # Sort PAKs and shake them (preserve order from paks.txt if it exists) + sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort))) shaken_paks: list[str] = self._shake_paks(sorted_paks) - # Assign target directories with numbered folders (like Oblivion Remastered) - # Skip numbers that already exist, use next available number starting from 8999 final_paks: dict[str, tuple[str, str, str]] = {} pak_index = 8999 for pak in shaken_paks: - # Find next available number (skip existing folders) while pak_index in existing_folders: pak_index -= 1 - # If PAK is already in a numbered folder, keep its current assignment current_folder = paks[pak] if current_folder.isdigit(): target_dir = pak_paths[pak][1] + "/" + current_folder existing_folders.add(int(current_folder)) - print(f"[PAK Debug] Keeping {pak} in existing folder {current_folder}") else: - # Assign new numbered folder for loose PAKs target_dir = pak_paths[pak][1] + "/" + str(pak_index).zfill(4) existing_folders.add(pak_index) - print(f"[PAK Debug] Assigning {pak} to new folder {pak_index}") pak_index -= 1 final_paks[pak] = (pak_source[pak], pak_paths[pak][0], target_dir) - # Convert to model format (4-tuple matching Oblivion Remastered) new_data_paks: dict[int, tuple[str, str, str, str]] = {} i = 0 for pak, data in final_paks.items(): @@ -243,5 +209,4 @@ def _parse_pak_files(self): new_data_paks[i] = (pak, source, current_path, target_path) i += 1 - print(f"[PAK Debug] Final PAK count: {len(new_data_paks)}") self._model.set_paks(new_data_paks) From d24e955b45117e0abc9841468318b14c78a4e1cd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 22 Jun 2025 03:05:17 +0000 Subject: [PATCH 04/14] [pre-commit.ci] Auto fixes from pre-commit.com hooks. --- games/game_stalker2heartofchornobyl.py | 32 +++++++++---------- games/stalker2heartofchornobyl/paks/model.py | 2 +- games/stalker2heartofchornobyl/paks/widget.py | 12 +++---- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/games/game_stalker2heartofchornobyl.py b/games/game_stalker2heartofchornobyl.py index c47ddc3..13528fd 100644 --- a/games/game_stalker2heartofchornobyl.py +++ b/games/game_stalker2heartofchornobyl.py @@ -56,15 +56,15 @@ def __init__(self): def resolve_path(self, path: str) -> str: path = path.replace("%USERPROFILE%", os.environ.get("USERPROFILE", "")) - + if "%GAME_DOCUMENTS%" in path: game_docs = self.GameDocumentsDirectory.replace("%USERPROFILE%", os.environ.get("USERPROFILE", "")) path = path.replace("%GAME_DOCUMENTS%", game_docs) - + if "%GAME_PATH%" in path: game_path = self._gamePath if hasattr(self, '_gamePath') else "" path = path.replace("%GAME_PATH%", game_path) - + return path def init(self, organizer: mobase.IOrganizer) -> bool: @@ -74,7 +74,7 @@ def init(self, organizer: mobase.IOrganizer) -> bool: BasicLocalSavegames(QDir(self.resolve_path(self.GameSavesDirectory))) ) <<<<<<< HEAD - + ======= # Create the directory more reliably @@ -93,7 +93,7 @@ def init(self, organizer: mobase.IOrganizer) -> bool: except Exception as e: <<<<<<< HEAD self._organizer.log(mobase.LogLevel.ERROR, f"Unexpected error creating mod directory: {e}") - + organizer.onUserInterfaceInitialized(self.init_tab) ======= print(f"Error creating mod directory: {e}") @@ -135,17 +135,17 @@ def init_tab(self, main_window: QMainWindow): def mappings(self) -> List[mobase.Mapping]: pak_extensions = ["*.pak", "*.utoc", "*.ucas"] target_dir = "Content/Paks/~mods/" - + mappings = [] - + for ext in pak_extensions: mappings.append(mobase.Mapping(ext, target_dir, False)) - + source_dirs = ["Paks/", "~mods/", "Content/Paks/~mods/"] for source_dir in source_dirs: for ext in pak_extensions: mappings.append(mobase.Mapping(f"{source_dir}{ext}", target_dir, False)) - + return mappings def gameDirectory(self) -> QDir: @@ -155,7 +155,7 @@ def paksDirectory(self) -> QDir: <<<<<<< HEAD path = os.path.join(self.gameDirectory().absolutePath(), self.GameDataPath, "Content", "Paks") return QDir(path) - + def paksModsDirectory(self) -> QDir: try: path = os.path.join(self.paksDirectory().absolutePath(), "~mods") @@ -163,15 +163,15 @@ def paksModsDirectory(self) -> QDir: except Exception as e: fallback = os.path.join(self.gameDirectory().absolutePath(), self.GameDataPath, "Content", "Paks", "~mods") return QDir(fallback) - + def logicModsDirectory(self) -> QDir: path = os.path.join(self.gameDirectory().absolutePath(), self.GameDataPath, "Content", "Paks", "LogicMods") return QDir(path) - + def binariesDirectory(self) -> QDir: path = os.path.join(self.gameDirectory().absolutePath(), self.GameDataPath, "Binaries", "Win64") return QDir(path) - + ======= return QDir(self.gameDirectory().absolutePath() + "/Stalker2/Content/Paks") @@ -199,16 +199,16 @@ def activeProblems(self) -> list[int]: problems = set() if self._organizer.managedGame() == self: <<<<<<< HEAD - + mod_path = self.paksModsDirectory().absolutePath() if not os.path.isdir(mod_path): problems.add(Problems.MISSING_MOD_DIRECTORIES) self._organizer.log(mobase.LogLevel.DEBUG, f"Missing mod directory: {mod_path}") - + for mod in self._organizer.modList().allMods(): mod_info = self._organizer.modList().getMod(mod) filetree = mod_info.fileTree() - + ======= # More reliable directory check using os.path mod_path = self.paksModsDirectory().absolutePath() diff --git a/games/stalker2heartofchornobyl/paks/model.py b/games/stalker2heartofchornobyl/paks/model.py index bfb87b8..d992822 100644 --- a/games/stalker2heartofchornobyl/paks/model.py +++ b/games/stalker2heartofchornobyl/paks/model.py @@ -204,7 +204,7 @@ def dropMimeData( 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: diff --git a/games/stalker2heartofchornobyl/paks/widget.py b/games/stalker2heartofchornobyl/paks/widget.py index ec6582b..a57d989 100644 --- a/games/stalker2heartofchornobyl/paks/widget.py +++ b/games/stalker2heartofchornobyl/paks/widget.py @@ -123,7 +123,7 @@ def _parse_pak_files(self): pak_source: dict[str, str] = {} existing_folders: set[int] = set() <<<<<<< HEAD - + ======= print(f"[PAK Debug] Starting scan of {len(mods)} mods") @@ -150,7 +150,7 @@ def _parse_pak_files(self): <<<<<<< HEAD except ValueError: pass - + ======= print( f"[PAK Debug] Found existing numbered folder: {folder_num}" @@ -230,7 +230,7 @@ def _parse_pak_files(self): sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort))) shaken_paks: list[str] = self._shake_paks(sorted_paks) - + ======= print( f"[PAK Debug] ✅ Added loose PAK from ~mods: {pak_name}" @@ -263,7 +263,7 @@ def _parse_pak_files(self): while pak_index in existing_folders: pak_index -= 1 <<<<<<< HEAD - + ======= # If PAK is already in a numbered folder, keep its current assignment @@ -279,7 +279,7 @@ def _parse_pak_files(self): final_paks[pak] = (pak_source[pak], pak_paths[pak][0], target_dir) <<<<<<< HEAD - + ======= # Convert to model format (4-tuple matching Oblivion Remastered) @@ -291,7 +291,7 @@ def _parse_pak_files(self): new_data_paks[i] = (pak, source, current_path, target_path) i += 1 <<<<<<< HEAD - + ======= print(f"[PAK Debug] Final PAK count: {len(new_data_paks)}") From 060b3ffa2edc6a737997786967a404aebc1c25c3 Mon Sep 17 00:00:00 2001 From: MKHATERS <42836901+MK-HATERS@users.noreply.github.com> Date: Sat, 21 Jun 2025 21:08:58 -0600 Subject: [PATCH 05/14] fixing lint for stalker 2 pak tab game support --- games/game_stalker2heartofchornobyl.py | 143 ++++++++++++------------- 1 file changed, 66 insertions(+), 77 deletions(-) diff --git a/games/game_stalker2heartofchornobyl.py b/games/game_stalker2heartofchornobyl.py index c47ddc3..6a86765 100644 --- a/games/game_stalker2heartofchornobyl.py +++ b/games/game_stalker2heartofchornobyl.py @@ -15,11 +15,7 @@ class Problems(IntEnum): """ Enums for IPluginDiagnose. """ -<<<<<<< HEAD -======= - # PAK files placed in incorrect locations ->>>>>>> ab91432d429d5ec75630e299423146320437832d MISPLACED_PAK_FILES = auto() MISSING_MOD_DIRECTORIES = auto() @@ -56,15 +52,17 @@ def __init__(self): def resolve_path(self, path: str) -> str: path = path.replace("%USERPROFILE%", os.environ.get("USERPROFILE", "")) - + if "%GAME_DOCUMENTS%" in path: - game_docs = self.GameDocumentsDirectory.replace("%USERPROFILE%", os.environ.get("USERPROFILE", "")) + game_docs = self.GameDocumentsDirectory.replace( + "%USERPROFILE%", os.environ.get("USERPROFILE", "") + ) path = path.replace("%GAME_DOCUMENTS%", game_docs) - + if "%GAME_PATH%" in path: - game_path = self._gamePath if hasattr(self, '_gamePath') else "" + game_path = self._gamePath if hasattr(self, "_gamePath") else "" path = path.replace("%GAME_PATH%", game_path) - + return path def init(self, organizer: mobase.IOrganizer) -> bool: @@ -73,12 +71,7 @@ def init(self, organizer: mobase.IOrganizer) -> bool: self._register_feature( BasicLocalSavegames(QDir(self.resolve_path(self.GameSavesDirectory))) ) -<<<<<<< HEAD - -======= - # Create the directory more reliably ->>>>>>> ab91432d429d5ec75630e299423146320437832d if ( self._organizer.managedGame() and self._organizer.managedGame().gameName() == self.gameName() @@ -87,21 +80,21 @@ def init(self, organizer: mobase.IOrganizer) -> bool: try: os.makedirs(mod_path, exist_ok=True) if not os.path.exists(mod_path): - self._organizer.log(mobase.LogLevel.WARNING, f"Failed to create directory: {mod_path}") + self._organizer.log( + mobase.LogLevel.WARNING, + f"Failed to create directory: {mod_path}", + ) except OSError as e: - self._organizer.log(mobase.LogLevel.ERROR, f"OS error creating mod directory: {e}") + self._organizer.log( + mobase.LogLevel.ERROR, f"OS error creating mod directory: {e}" + ) except Exception as e: -<<<<<<< HEAD - self._organizer.log(mobase.LogLevel.ERROR, f"Unexpected error creating mod directory: {e}") - - organizer.onUserInterfaceInitialized(self.init_tab) -======= - print(f"Error creating mod directory: {e}") + self._organizer.log( + mobase.LogLevel.ERROR, + f"Unexpected error creating mod directory: {e}", + ) - # Initialize PAK tab when UI is ready organizer.onUserInterfaceInitialized(self.init_tab) - ->>>>>>> ab91432d429d5ec75630e299423146320437832d return True def init_tab(self, main_window: QMainWindow): @@ -115,7 +108,9 @@ def init_tab(self, main_window: QMainWindow): self._main_window = main_window tab_widget: QTabWidget = main_window.findChild(QTabWidget, "tabWidget") if not tab_widget: - self._organizer.log(mobase.LogLevel.WARNING, "No main tab widget found!") + self._organizer.log( + mobase.LogLevel.WARNING, "No main tab widget found!" + ) return from .stalker2heartofchornobyl.paks import S2HoCPaksTabWidget @@ -125,9 +120,13 @@ def init_tab(self, main_window: QMainWindow): tab_widget.addTab(self._paks_tab, "PAK Files") self._organizer.log(mobase.LogLevel.INFO, "PAK Files tab added!") except ImportError as e: - self._organizer.log(mobase.LogLevel.ERROR, f"Failed to import PAK tab widget: {e}") + self._organizer.log( + mobase.LogLevel.ERROR, f"Failed to import PAK tab widget: {e}" + ) except Exception as e: - self._organizer.log(mobase.LogLevel.ERROR, f"Error initializing PAK tab: {e}") + self._organizer.log( + mobase.LogLevel.ERROR, f"Error initializing PAK tab: {e}" + ) import traceback traceback.print_exc() @@ -135,61 +134,61 @@ def init_tab(self, main_window: QMainWindow): def mappings(self) -> List[mobase.Mapping]: pak_extensions = ["*.pak", "*.utoc", "*.ucas"] target_dir = "Content/Paks/~mods/" - + mappings = [] - + for ext in pak_extensions: mappings.append(mobase.Mapping(ext, target_dir, False)) - + source_dirs = ["Paks/", "~mods/", "Content/Paks/~mods/"] for source_dir in source_dirs: for ext in pak_extensions: mappings.append(mobase.Mapping(f"{source_dir}{ext}", target_dir, False)) - + return mappings def gameDirectory(self) -> QDir: return QDir(self._gamePath) def paksDirectory(self) -> QDir: -<<<<<<< HEAD - path = os.path.join(self.gameDirectory().absolutePath(), self.GameDataPath, "Content", "Paks") + path = os.path.join( + self.gameDirectory().absolutePath(), self.GameDataPath, "Content", "Paks" + ) return QDir(path) - + def paksModsDirectory(self) -> QDir: try: path = os.path.join(self.paksDirectory().absolutePath(), "~mods") return QDir(path) - except Exception as e: - fallback = os.path.join(self.gameDirectory().absolutePath(), self.GameDataPath, "Content", "Paks", "~mods") + except Exception: + fallback = os.path.join( + self.gameDirectory().absolutePath(), + self.GameDataPath, + "Content", + "Paks", + "~mods", + ) return QDir(fallback) - - def logicModsDirectory(self) -> QDir: - path = os.path.join(self.gameDirectory().absolutePath(), self.GameDataPath, "Content", "Paks", "LogicMods") - return QDir(path) - - def binariesDirectory(self) -> QDir: - path = os.path.join(self.gameDirectory().absolutePath(), self.GameDataPath, "Binaries", "Win64") - return QDir(path) - -======= - return QDir(self.gameDirectory().absolutePath() + "/Stalker2/Content/Paks") - - def paksModsDirectory(self) -> QDir: - # Use os.path.join for more reliable path construction - path = os.path.join(self.paksDirectory().absolutePath(), "~mods") - return QDir(path) def logicModsDirectory(self) -> QDir: - # Update path to place LogicMods under Paks - return QDir( - self.gameDirectory().absolutePath() + "/Stalker2/Content/Paks/LogicMods" + path = os.path.join( + self.gameDirectory().absolutePath(), + self.GameDataPath, + "Content", + "Paks", + "LogicMods", ) + return QDir(path) def binariesDirectory(self) -> QDir: - return QDir(self.gameDirectory().absolutePath() + "/Stalker2/Binaries/Win64") + path = os.path.join( + self.gameDirectory().absolutePath(), + self.GameDataPath, + "Binaries", + "Win64", + ) + return QDir(path) ->>>>>>> ab91432d429d5ec75630e299423146320437832d def getModMappings(self) -> dict[str, list[str]]: return { "Content/Paks/~mods": [self.paksModsDirectory().absolutePath()], @@ -198,31 +197,17 @@ def getModMappings(self) -> dict[str, list[str]]: def activeProblems(self) -> list[int]: problems = set() if self._organizer.managedGame() == self: -<<<<<<< HEAD - - mod_path = self.paksModsDirectory().absolutePath() - if not os.path.isdir(mod_path): - problems.add(Problems.MISSING_MOD_DIRECTORIES) - self._organizer.log(mobase.LogLevel.DEBUG, f"Missing mod directory: {mod_path}") - - for mod in self._organizer.modList().allMods(): - mod_info = self._organizer.modList().getMod(mod) - filetree = mod_info.fileTree() - -======= - # More reliable directory check using os.path mod_path = self.paksModsDirectory().absolutePath() if not os.path.isdir(mod_path): problems.add(Problems.MISSING_MOD_DIRECTORIES) - print(f"Missing mod directory: {mod_path}") + self._organizer.log( + mobase.LogLevel.DEBUG, f"Missing mod directory: {mod_path}" + ) - # Check for misplaced PAK files for mod in self._organizer.modList().allMods(): mod_info = self._organizer.modList().getMod(mod) filetree = mod_info.fileTree() - # Check for PAK files at the root level (remove LogicMods paths) ->>>>>>> ab91432d429d5ec75630e299423146320437832d for entry in filetree: if entry.name().endswith((".pak", ".utoc", ".ucas")) and not any( entry.path().startswith(p) @@ -275,9 +260,13 @@ def startGuidedFix(self, key: int) -> None: case Problems.MISSING_MOD_DIRECTORIES: try: os.makedirs(self.paksModsDirectory().absolutePath(), exist_ok=True) - self._organizer.log(mobase.LogLevel.INFO, "Created missing mod directories") + self._organizer.log( + mobase.LogLevel.INFO, "Created missing mod directories" + ) except Exception as e: - self._organizer.log(mobase.LogLevel.ERROR, f"Failed to create mod directories: {e}") + self._organizer.log( + mobase.LogLevel.ERROR, f"Failed to create mod directories: {e}" + ) case _: pass From 6008acbc2af1155c0d1e59754a1b6bc6c27b8b94 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 22 Jun 2025 03:10:48 +0000 Subject: [PATCH 06/14] [pre-commit.ci] Auto fixes from pre-commit.com hooks. --- games/game_stalker2heartofchornobyl.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/games/game_stalker2heartofchornobyl.py b/games/game_stalker2heartofchornobyl.py index 3703059..6a86765 100644 --- a/games/game_stalker2heartofchornobyl.py +++ b/games/game_stalker2heartofchornobyl.py @@ -53,19 +53,16 @@ def __init__(self): def resolve_path(self, path: str) -> str: path = path.replace("%USERPROFILE%", os.environ.get("USERPROFILE", "")) - if "%GAME_DOCUMENTS%" in path: game_docs = self.GameDocumentsDirectory.replace( "%USERPROFILE%", os.environ.get("USERPROFILE", "") ) path = path.replace("%GAME_DOCUMENTS%", game_docs) - if "%GAME_PATH%" in path: game_path = self._gamePath if hasattr(self, "_gamePath") else "" path = path.replace("%GAME_PATH%", game_path) - return path def init(self, organizer: mobase.IOrganizer) -> bool: @@ -138,20 +135,16 @@ def mappings(self) -> List[mobase.Mapping]: pak_extensions = ["*.pak", "*.utoc", "*.ucas"] target_dir = "Content/Paks/~mods/" - mappings = [] - for ext in pak_extensions: mappings.append(mobase.Mapping(ext, target_dir, False)) - source_dirs = ["Paks/", "~mods/", "Content/Paks/~mods/"] for source_dir in source_dirs: for ext in pak_extensions: mappings.append(mobase.Mapping(f"{source_dir}{ext}", target_dir, False)) - return mappings def gameDirectory(self) -> QDir: @@ -163,7 +156,6 @@ def paksDirectory(self) -> QDir: ) return QDir(path) - def paksModsDirectory(self) -> QDir: try: path = os.path.join(self.paksDirectory().absolutePath(), "~mods") @@ -178,7 +170,6 @@ def paksModsDirectory(self) -> QDir: ) return QDir(fallback) - def logicModsDirectory(self) -> QDir: path = os.path.join( self.gameDirectory().absolutePath(), @@ -189,7 +180,6 @@ def logicModsDirectory(self) -> QDir: ) return QDir(path) - def binariesDirectory(self) -> QDir: path = os.path.join( self.gameDirectory().absolutePath(), From e980eeabbcff6ca4c55fe810816f6d4e3c5f6947 Mon Sep 17 00:00:00 2001 From: MKHATERS <42836901+MK-HATERS@users.noreply.github.com> Date: Sat, 21 Jun 2025 21:13:09 -0600 Subject: [PATCH 07/14] Update game_stalker2heartofchornobyl.py --- games/game_stalker2heartofchornobyl.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/games/game_stalker2heartofchornobyl.py b/games/game_stalker2heartofchornobyl.py index 3703059..c9c99e8 100644 --- a/games/game_stalker2heartofchornobyl.py +++ b/games/game_stalker2heartofchornobyl.py @@ -53,19 +53,16 @@ def __init__(self): def resolve_path(self, path: str) -> str: path = path.replace("%USERPROFILE%", os.environ.get("USERPROFILE", "")) - if "%GAME_DOCUMENTS%" in path: game_docs = self.GameDocumentsDirectory.replace( "%USERPROFILE%", os.environ.get("USERPROFILE", "") ) path = path.replace("%GAME_DOCUMENTS%", game_docs) - if "%GAME_PATH%" in path: game_path = self._gamePath if hasattr(self, "_gamePath") else "" path = path.replace("%GAME_PATH%", game_path) - return path def init(self, organizer: mobase.IOrganizer) -> bool: @@ -138,20 +135,16 @@ def mappings(self) -> List[mobase.Mapping]: pak_extensions = ["*.pak", "*.utoc", "*.ucas"] target_dir = "Content/Paks/~mods/" - mappings = [] - for ext in pak_extensions: mappings.append(mobase.Mapping(ext, target_dir, False)) - source_dirs = ["Paks/", "~mods/", "Content/Paks/~mods/"] for source_dir in source_dirs: for ext in pak_extensions: mappings.append(mobase.Mapping(f"{source_dir}{ext}", target_dir, False)) - return mappings def gameDirectory(self) -> QDir: @@ -163,7 +156,6 @@ def paksDirectory(self) -> QDir: ) return QDir(path) - def paksModsDirectory(self) -> QDir: try: path = os.path.join(self.paksDirectory().absolutePath(), "~mods") @@ -178,7 +170,6 @@ def paksModsDirectory(self) -> QDir: ) return QDir(fallback) - def logicModsDirectory(self) -> QDir: path = os.path.join( self.gameDirectory().absolutePath(), @@ -189,7 +180,6 @@ def logicModsDirectory(self) -> QDir: ) return QDir(path) - def binariesDirectory(self) -> QDir: path = os.path.join( self.gameDirectory().absolutePath(), @@ -232,8 +222,10 @@ def fullDescription(self, key: int) -> str: match key: case Problems.MISPLACED_PAK_FILES: return ( - "Some mod packages contain PAK files that are not placed in the correct directory structure.\n\n" - "PAK files should be placed in one of the following locations within the mod:\n" + "Some mod packages contain PAK files that are not placed in the " + "correct directory structure.\n\n" + "PAK files should be placed in one of the following locations " + "within the mod:\n" "- Content/Paks/~mods/\n" "- Paks/\n" "- ~mods/\n\n" @@ -244,7 +236,8 @@ def fullDescription(self, key: int) -> str: "Required mod directory is missing in the game folder.\n\n" "The following directory should exist:\n" "- Stalker2/Content/Paks/~mods\n\n" - "This will be created automatically when you restart Mod Organizer 2." + "This will be created automatically when you restart " + "Mod Organizer 2." ) case _: return "" From 8c683988b8215fc22c79992acd231e10f5bfc72c Mon Sep 17 00:00:00 2001 From: MKHATERS <42836901+MK-HATERS@users.noreply.github.com> Date: Sat, 21 Jun 2025 22:13:05 -0600 Subject: [PATCH 08/14] lint adjustments s2hoc pak tab --- games/game_stalker2heartofchornobyl.py | 19 +- games/stalker2heartofchornobyl/paks/widget.py | 302 +----------------- 2 files changed, 15 insertions(+), 306 deletions(-) diff --git a/games/game_stalker2heartofchornobyl.py b/games/game_stalker2heartofchornobyl.py index c9c99e8..62e088c 100644 --- a/games/game_stalker2heartofchornobyl.py +++ b/games/game_stalker2heartofchornobyl.py @@ -143,7 +143,9 @@ def mappings(self) -> List[mobase.Mapping]: source_dirs = ["Paks/", "~mods/", "Content/Paks/~mods/"] for source_dir in source_dirs: for ext in pak_extensions: - mappings.append(mobase.Mapping(f"{source_dir}{ext}", target_dir, False)) + mappings.append( + mobase.Mapping(f"{source_dir}{ext}", target_dir, False) + ) return mappings @@ -209,7 +211,9 @@ def activeProblems(self) -> list[int]: filetree = mod_info.fileTree() for entry in filetree: - if entry.name().endswith((".pak", ".utoc", ".ucas")) and not any( + if entry.name().endswith( + (".pak", ".utoc", ".ucas") + ) and not any( entry.path().startswith(p) for p in ["Content/Paks/~mods", "Paks", "~mods"] ): @@ -222,14 +226,15 @@ def fullDescription(self, key: int) -> str: match key: case Problems.MISPLACED_PAK_FILES: return ( - "Some mod packages contain PAK files that are not placed in the " - "correct directory structure.\n\n" - "PAK files should be placed in one of the following locations " - "within the mod:\n" + "Some mod packages contain PAK files that are not placed in " + "the correct directory structure.\n\n" + "PAK files should be placed in one of the following " + "locations within the mod:\n" "- Content/Paks/~mods/\n" "- Paks/\n" "- ~mods/\n\n" - "Please restructure your mods to follow this directory layout." + "Please restructure your mods to follow this directory " + "layout." ) case Problems.MISSING_MOD_DIRECTORIES: return ( diff --git a/games/stalker2heartofchornobyl/paks/widget.py b/games/stalker2heartofchornobyl/paks/widget.py index a57d989..0dbadda 100644 --- a/games/stalker2heartofchornobyl/paks/widget.py +++ b/games/stalker2heartofchornobyl/paks/widget.py @@ -1,299 +1,3 @@ -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 S2HoCPaksModel -from .view import S2HoCPaksView - - -def pak_sort(a: tuple[str, str], b: tuple[str, str]) -> int: - """Sort function for PAK files""" - if a[0] < b[0]: - return -1 - elif a[0] > b[0]: - return 1 - else: - return 0 - - -class S2HoCPaksTabWidget(QWidget): - """ - Widget for managing PAK files in Stalker 2: Heart of Chornobyl. - """ - - def __init__(self, parent: QWidget, organizer: mobase.IOrganizer): - super().__init__(parent) - self._organizer = organizer - self._view = S2HoCPaksView(self) - self._layout = QGridLayout(self) - self._layout.addWidget(self._view) - self._model = S2HoCPaksModel(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 load_paks_list(self) -> list[str]: - profile = QDir(self._organizer.profilePath()) - paks_txt = QFileInfo(profile.absoluteFilePath("stalker2_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): - """Write the PAK list to file and then move the files""" - profile = QDir(self._organizer.profilePath()) - paks_txt = QFileInfo(profile.absoluteFilePath("stalker2_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): - """Move PAK files to their target numbered directories""" - 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]: - """Preserve order from paks.txt if it exists, otherwise use alphabetical""" - 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): - """Parse PAK files from mods, following numbered folder assignment pattern""" - from ...game_stalker2heartofchornobyl import S2HoCGame - - mods = self._organizer.modList().allMods() - paks: dict[str, str] = {} - pak_paths: dict[str, tuple[str, str]] = {} - pak_source: dict[str, str] = {} - existing_folders: set[int] = set() -<<<<<<< HEAD - -======= - - print(f"[PAK Debug] Starting scan of {len(mods)} mods") - print("[PAK Debug] ONLY scanning ~mods directories, EXCLUDING LogicMods") - - # First, scan what numbered folders already exist to avoid double-assignment ->>>>>>> ab91432d429d5ec75630e299423146320437832d - game = self._organizer.managedGame() - if isinstance(game, S2HoCGame): - pak_mods_dir = QFileInfo(game.paksModsDirectory().absolutePath()) - if pak_mods_dir.exists() and pak_mods_dir.isDir(): -<<<<<<< HEAD -======= - print( - f"[PAK Debug] Scanning existing folders in: {pak_mods_dir.absoluteFilePath()}" - ) ->>>>>>> ab91432d429d5ec75630e299423146320437832d - for entry in QDir(pak_mods_dir.absoluteFilePath()).entryInfoList( - QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot - ): - try: - folder_num = int(entry.completeBaseName()) - existing_folders.add(folder_num) -<<<<<<< HEAD - except ValueError: - pass - -======= - print( - f"[PAK Debug] Found existing numbered folder: {folder_num}" - ) - except ValueError: - print( - f"[PAK Debug] Skipping non-numbered folder: {entry.completeBaseName()}" - ) - - # Scan mods for PAK files ONLY in Content/Paks/~mods structure (exclude LogicMods) ->>>>>>> ab91432d429d5ec75630e299423146320437832d - 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() - -<<<<<<< HEAD - has_logicmods = ( - filetree.find("Content/Paks/LogicMods") or filetree.find("Paks/LogicMods") - ) - if isinstance(has_logicmods, mobase.IFileTree): -======= - # If this mod contains a LogicMods directory, skip it entirely for the PAK tab - has_logicmods = filetree.find("Content/Paks/LogicMods") or filetree.find( - "Paks/LogicMods" - ) - if isinstance(has_logicmods, mobase.IFileTree): - print( - f"[PAK Debug] Skipping mod '{mod_item.name()}' because it contains a LogicMods directory." - ) ->>>>>>> ab91432d429d5ec75630e299423146320437832d - continue - - pak_mods = filetree.find("Paks/~mods") - if not pak_mods: - pak_mods = filetree.find("Content/Paks/~mods") - if isinstance(pak_mods, mobase.IFileTree) and pak_mods.name() == "~mods": - for entry in pak_mods: - if is_directory(entry): - 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() -<<<<<<< HEAD -======= - print( - f"[PAK Debug] ✅ Added PAK from ~mods numbered folder: {pak_name} in {entry.name()}" - ) ->>>>>>> ab91432d429d5ec75630e299423146320437832d - 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() -<<<<<<< HEAD - - sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort))) - shaken_paks: list[str] = self._shake_paks(sorted_paks) - -======= - print( - f"[PAK Debug] ✅ Added loose PAK from ~mods: {pak_name}" - ) - else: - # Check if this mod has LogicMods (for debugging purposes) - logic_mods = filetree.find("Content/Paks/LogicMods") - if not logic_mods: - logic_mods = filetree.find("Paks/LogicMods") - if isinstance(logic_mods, mobase.IFileTree): - print( - f"[PAK Debug] Mod {mod_item.name()} has LogicMods (not included in PAK tab)" - ) - - # NOTE: Removed game directory scanning to prevent LogicMods PAKs from appearing - # We only want PAKs from mod files, not from game directory - print("[PAK Debug] Skipping game directory scan to prevent LogicMods inclusion") - - # Sort PAKs and shake them (preserve order from paks.txt if it exists) - sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort))) - shaken_paks: list[str] = self._shake_paks(sorted_paks) - - # Assign target directories with numbered folders (like Oblivion Remastered) - # Skip numbers that already exist, use next available number starting from 8999 ->>>>>>> ab91432d429d5ec75630e299423146320437832d - final_paks: dict[str, tuple[str, str, str]] = {} - pak_index = 8999 - - for pak in shaken_paks: - while pak_index in existing_folders: - pak_index -= 1 -<<<<<<< HEAD - -======= - - # If PAK is already in a numbered folder, keep its current assignment ->>>>>>> ab91432d429d5ec75630e299423146320437832d - current_folder = paks[pak] - if current_folder.isdigit(): - target_dir = pak_paths[pak][1] + "/" + current_folder - existing_folders.add(int(current_folder)) - else: - target_dir = pak_paths[pak][1] + "/" + str(pak_index).zfill(4) - existing_folders.add(pak_index) - pak_index -= 1 - - final_paks[pak] = (pak_source[pak], pak_paths[pak][0], target_dir) -<<<<<<< HEAD - -======= - - # Convert to model format (4-tuple matching Oblivion Remastered) ->>>>>>> ab91432d429d5ec75630e299423146320437832d - 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 -<<<<<<< HEAD - -======= - - print(f"[PAK Debug] Final PAK count: {len(new_data_paks)}") ->>>>>>> ab91432d429d5ec75630e299423146320437832d - self._model.set_paks(new_data_paks) +Creating +clean +widget.py From 358924fabb1a352b28ea3ec96c9b55395ac273da Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 22 Jun 2025 04:13:14 +0000 Subject: [PATCH 09/14] [pre-commit.ci] Auto fixes from pre-commit.com hooks. --- games/game_stalker2heartofchornobyl.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/games/game_stalker2heartofchornobyl.py b/games/game_stalker2heartofchornobyl.py index 62e088c..53dfe9b 100644 --- a/games/game_stalker2heartofchornobyl.py +++ b/games/game_stalker2heartofchornobyl.py @@ -143,9 +143,7 @@ def mappings(self) -> List[mobase.Mapping]: source_dirs = ["Paks/", "~mods/", "Content/Paks/~mods/"] for source_dir in source_dirs: for ext in pak_extensions: - mappings.append( - mobase.Mapping(f"{source_dir}{ext}", target_dir, False) - ) + mappings.append(mobase.Mapping(f"{source_dir}{ext}", target_dir, False)) return mappings @@ -211,9 +209,7 @@ def activeProblems(self) -> list[int]: filetree = mod_info.fileTree() for entry in filetree: - if entry.name().endswith( - (".pak", ".utoc", ".ucas") - ) and not any( + if entry.name().endswith((".pak", ".utoc", ".ucas")) and not any( entry.path().startswith(p) for p in ["Content/Paks/~mods", "Paks", "~mods"] ): From 2e357d6b60ff80e18bd3af53aac86a9bc5d4cd58 Mon Sep 17 00:00:00 2001 From: MKHATERS <42836901+MK-HATERS@users.noreply.github.com> Date: Sun, 22 Jun 2025 09:17:56 -0600 Subject: [PATCH 10/14] adjusted widget for s2hoc pak tab game support --- games/game_stalker2heartofchornobyl.py | 40 +-- games/stalker2heartofchornobyl/paks/model.py | 4 - games/stalker2heartofchornobyl/paks/widget.py | 228 +++++++++++++++++- 3 files changed, 235 insertions(+), 37 deletions(-) diff --git a/games/game_stalker2heartofchornobyl.py b/games/game_stalker2heartofchornobyl.py index 53dfe9b..5a4fa01 100644 --- a/games/game_stalker2heartofchornobyl.py +++ b/games/game_stalker2heartofchornobyl.py @@ -80,19 +80,11 @@ def init(self, organizer: mobase.IOrganizer) -> bool: try: os.makedirs(mod_path, exist_ok=True) if not os.path.exists(mod_path): - self._organizer.log( - mobase.LogLevel.WARNING, - f"Failed to create directory: {mod_path}", - ) + print(f"Failed to create directory: {mod_path}") except OSError as e: - self._organizer.log( - mobase.LogLevel.ERROR, f"OS error creating mod directory: {e}" - ) + print(f"OS error creating mod directory: {e}") except Exception as e: - self._organizer.log( - mobase.LogLevel.ERROR, - f"Unexpected error creating mod directory: {e}", - ) + print(f"Unexpected error creating mod directory: {e}") organizer.onUserInterfaceInitialized(self.init_tab) return True @@ -108,9 +100,7 @@ def init_tab(self, main_window: QMainWindow): self._main_window = main_window tab_widget: QTabWidget = main_window.findChild(QTabWidget, "tabWidget") if not tab_widget: - self._organizer.log( - mobase.LogLevel.WARNING, "No main tab widget found!" - ) + print("No main tab widget found!") return from .stalker2heartofchornobyl.paks import S2HoCPaksTabWidget @@ -118,15 +108,11 @@ def init_tab(self, main_window: QMainWindow): self._paks_tab = S2HoCPaksTabWidget(main_window, self._organizer) tab_widget.addTab(self._paks_tab, "PAK Files") - self._organizer.log(mobase.LogLevel.INFO, "PAK Files tab added!") + print("PAK Files tab added!") except ImportError as e: - self._organizer.log( - mobase.LogLevel.ERROR, f"Failed to import PAK tab widget: {e}" - ) + print(f"Failed to import PAK tab widget: {e}") except Exception as e: - self._organizer.log( - mobase.LogLevel.ERROR, f"Error initializing PAK tab: {e}" - ) + print(f"Error initializing PAK tab: {e}") import traceback traceback.print_exc() @@ -200,9 +186,7 @@ def activeProblems(self) -> list[int]: mod_path = self.paksModsDirectory().absolutePath() if not os.path.isdir(mod_path): problems.add(Problems.MISSING_MOD_DIRECTORIES) - self._organizer.log( - mobase.LogLevel.DEBUG, f"Missing mod directory: {mod_path}" - ) + print(f"Missing mod directory: {mod_path}") for mod in self._organizer.modList().allMods(): mod_info = self._organizer.modList().getMod(mod) @@ -264,13 +248,9 @@ def startGuidedFix(self, key: int) -> None: case Problems.MISSING_MOD_DIRECTORIES: try: os.makedirs(self.paksModsDirectory().absolutePath(), exist_ok=True) - self._organizer.log( - mobase.LogLevel.INFO, "Created missing mod directories" - ) + print("Created missing mod directories") except Exception as e: - self._organizer.log( - mobase.LogLevel.ERROR, f"Failed to create mod directories: {e}" - ) + print(f"Failed to create mod directories: {e}") case _: pass diff --git a/games/stalker2heartofchornobyl/paks/model.py b/games/stalker2heartofchornobyl/paks/model.py index d992822..7829222 100644 --- a/games/stalker2heartofchornobyl/paks/model.py +++ b/games/stalker2heartofchornobyl/paks/model.py @@ -257,8 +257,4 @@ def dropMimeData( index -= 1 self.set_paks(new_paks) -<<<<<<< HEAD return True -======= - return False ->>>>>>> ab91432d429d5ec75630e299423146320437832d diff --git a/games/stalker2heartofchornobyl/paks/widget.py b/games/stalker2heartofchornobyl/paks/widget.py index 0dbadda..f862ee7 100644 --- a/games/stalker2heartofchornobyl/paks/widget.py +++ b/games/stalker2heartofchornobyl/paks/widget.py @@ -1,3 +1,225 @@ -Creating -clean -widget.py +import os +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 S2HoCPaksModel +from .view import S2HoCPaksView + + +def pak_sort(a: tuple[str, str], b: tuple[str, str]) -> int: + """Sort function for PAK files""" + if a[0] < b[0]: + return -1 + elif a[0] > b[0]: + return 1 + else: + return 0 + + +class S2HoCPaksTabWidget(QWidget): + """ + Widget for managing PAK files in Stalker 2: Heart of Chornobyl. + """ + + def __init__(self, parent: QWidget, organizer: mobase.IOrganizer): + super().__init__(parent) + self._organizer = organizer + self._view = S2HoCPaksView(self) + self._layout = QGridLayout(self) + self._layout.addWidget(self._view) + self._model = S2HoCPaksModel(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 load_paks_list(self) -> list[str]: + profile = QDir(self._organizer.profilePath()) + paks_txt = QFileInfo(profile.absoluteFilePath("stalker2_paks.txt")) + paks_list: list[str] = [] + if paks_txt.exists(): + try: + with open(paks_txt.absoluteFilePath(), "r", encoding="utf-8") as paks_file: + for line in paks_file: + stripped_line = line.strip() + if stripped_line: + paks_list.append(stripped_line) + except (IOError, OSError): + pass + return paks_list + + def write_paks_list(self): + """Write the PAK list to file and then move the files""" + profile = QDir(self._organizer.profilePath()) + paks_txt = QFileInfo(profile.absoluteFilePath("stalker2_paks.txt")) + try: + with open(paks_txt.absoluteFilePath(), "w", encoding="utf-8") as paks_file: + for _, pak in sorted(self._model.paks.items()): + name, _, _, _ = pak + paks_file.write(f"{name}\n") + self.write_pak_files() + except (IOError, OSError) as e: + print(f"Error writing PAK list: {e}") + + def write_pak_files(self): + """Move PAK files to their target numbered directories""" + 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]: + """Preserve order from paks.txt if it exists, otherwise use alphabetical""" + 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): + """Parse PAK files from mods, following numbered folder assignment pattern""" + from ...game_stalker2heartofchornobyl import S2HoCGame + + mods = self._organizer.modList().allMods() + paks: dict[str, str] = {} + pak_paths: dict[str, tuple[str, str]] = {} + pak_source: dict[str, str] = {} + existing_folders: set[int] = set() + + game = self._organizer.managedGame() + if isinstance(game, S2HoCGame): + pak_mods_dir = QFileInfo(game.paksModsDirectory().absolutePath()) + if pak_mods_dir.exists() and pak_mods_dir.isDir(): + for entry in QDir(pak_mods_dir.absoluteFilePath()).entryInfoList( + QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot + ): + try: + folder_num = int(entry.completeBaseName()) + existing_folders.add(folder_num) + except ValueError: + pass + + 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() + + has_logicmods = ( + filetree.find("Content/Paks/LogicMods") + or filetree.find("Paks/LogicMods") + ) + if isinstance(has_logicmods, mobase.IFileTree): + continue + + pak_mods = filetree.find("Paks/~mods") + if not pak_mods: + pak_mods = filetree.find("Content/Paks/~mods") + if isinstance(pak_mods, mobase.IFileTree) and pak_mods.name() == "~mods": + for entry in pak_mods: + if is_directory(entry): + 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() + + 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: + while pak_index in existing_folders: + pak_index -= 1 + + current_folder = paks[pak] + if current_folder.isdigit(): + target_dir = pak_paths[pak][1] + "/" + current_folder + existing_folders.add(int(current_folder)) + else: + target_dir = pak_paths[pak][1] + "/" + str(pak_index).zfill(4) + existing_folders.add(pak_index) + pak_index -= 1 + + final_paks[pak] = (pak_source[pak], pak_paths[pak][0], target_dir) + + 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) From 5ab9cc8f553aec2fcb41965487b1f0db56e22d88 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 22 Jun 2025 15:18:04 +0000 Subject: [PATCH 11/14] [pre-commit.ci] Auto fixes from pre-commit.com hooks. --- games/stalker2heartofchornobyl/paks/model.py | 6 ++++-- games/stalker2heartofchornobyl/paks/widget.py | 16 ++++++++-------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/games/stalker2heartofchornobyl/paks/model.py b/games/stalker2heartofchornobyl/paks/model.py index 7829222..9e93f8e 100644 --- a/games/stalker2heartofchornobyl/paks/model.py +++ b/games/stalker2heartofchornobyl/paks/model.py @@ -40,14 +40,16 @@ def _init_mod_states(self): paks_txt = QFileInfo(profile.absoluteFilePath("stalker2_paks.txt")) if paks_txt.exists(): try: - with open(paks_txt.absoluteFilePath(), "r", encoding="utf-8") as paks_file: + with open( + paks_txt.absoluteFilePath(), "r", encoding="utf-8" + ) as paks_file: index = 0 for line in paks_file: stripped_line = line.strip() if stripped_line: self.paks[index] = (stripped_line, "", "", "") index += 1 - except (IOError, OSError) as e: + except (IOError, OSError): pass def set_paks(self, paks: dict[int, _PakInfo]): diff --git a/games/stalker2heartofchornobyl/paks/widget.py b/games/stalker2heartofchornobyl/paks/widget.py index f862ee7..45ef9b4 100644 --- a/games/stalker2heartofchornobyl/paks/widget.py +++ b/games/stalker2heartofchornobyl/paks/widget.py @@ -1,4 +1,3 @@ -import os from functools import cmp_to_key from pathlib import Path from typing import cast @@ -50,7 +49,9 @@ def load_paks_list(self) -> list[str]: paks_list: list[str] = [] if paks_txt.exists(): try: - with open(paks_txt.absoluteFilePath(), "r", encoding="utf-8") as paks_file: + with open( + paks_txt.absoluteFilePath(), "r", encoding="utf-8" + ) as paks_file: for line in paks_file: stripped_line = line.strip() if stripped_line: @@ -151,9 +152,8 @@ def _parse_pak_files(self): continue filetree = mod_item.fileTree() - has_logicmods = ( - filetree.find("Content/Paks/LogicMods") - or filetree.find("Paks/LogicMods") + has_logicmods = filetree.find("Content/Paks/LogicMods") or filetree.find( + "Paks/LogicMods" ) if isinstance(has_logicmods, mobase.IFileTree): continue @@ -176,9 +176,9 @@ def _parse_pak_files(self): pak_paths[pak_name] = ( mod_item.absolutePath() + "/" - + cast( - mobase.IFileTree, sub_entry.parent() - ).path("/"), + + cast(mobase.IFileTree, sub_entry.parent()).path( + "/" + ), mod_item.absolutePath() + "/" + pak_mods.path("/"), ) pak_source[pak_name] = mod_item.name() From cf91ef33598a3ed506d72cfb3fe0938bf5a86800 Mon Sep 17 00:00:00 2001 From: MKHATERS <42836901+MK-HATERS@users.noreply.github.com> Date: Sun, 22 Jun 2025 09:20:42 -0600 Subject: [PATCH 12/14] Update game_stalker2heartofchornobyl.py --- games/game_stalker2heartofchornobyl.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/games/game_stalker2heartofchornobyl.py b/games/game_stalker2heartofchornobyl.py index 5a4fa01..ed0b95d 100644 --- a/games/game_stalker2heartofchornobyl.py +++ b/games/game_stalker2heartofchornobyl.py @@ -1,6 +1,5 @@ import os from enum import IntEnum, auto -from typing import List from PyQt6.QtCore import QDir from PyQt6.QtWidgets import QMainWindow, QTabWidget, QWidget @@ -117,7 +116,7 @@ def init_tab(self, main_window: QMainWindow): traceback.print_exc() - def mappings(self) -> List[mobase.Mapping]: + def mappings(self) -> list[mobase.Mapping]: pak_extensions = ["*.pak", "*.utoc", "*.ucas"] target_dir = "Content/Paks/~mods/" From 1b1f066bded3f41804e0bff73f7b581e2c470738 Mon Sep 17 00:00:00 2001 From: MKHATERS <42836901+MK-HATERS@users.noreply.github.com> Date: Sun, 22 Jun 2025 09:23:16 -0600 Subject: [PATCH 13/14] Update game_stalker2heartofchornobyl.py removed import os --- games/game_stalker2heartofchornobyl.py | 1 - 1 file changed, 1 deletion(-) diff --git a/games/game_stalker2heartofchornobyl.py b/games/game_stalker2heartofchornobyl.py index ed0b95d..743d5bd 100644 --- a/games/game_stalker2heartofchornobyl.py +++ b/games/game_stalker2heartofchornobyl.py @@ -1,4 +1,3 @@ -import os from enum import IntEnum, auto from PyQt6.QtCore import QDir From baad9af28baf5c4e58067daba6c8efa511c5c2c9 Mon Sep 17 00:00:00 2001 From: MKHATERS <42836901+MK-HATERS@users.noreply.github.com> Date: Sun, 22 Jun 2025 09:26:34 -0600 Subject: [PATCH 14/14] Update game_stalker2heartofchornobyl.py reimported the os --- games/game_stalker2heartofchornobyl.py | 1 + 1 file changed, 1 insertion(+) diff --git a/games/game_stalker2heartofchornobyl.py b/games/game_stalker2heartofchornobyl.py index 743d5bd..ed0b95d 100644 --- a/games/game_stalker2heartofchornobyl.py +++ b/games/game_stalker2heartofchornobyl.py @@ -1,3 +1,4 @@ +import os from enum import IntEnum, auto from PyQt6.QtCore import QDir