diff --git a/games/game_stalker2heartofchornobyl.py b/games/game_stalker2heartofchornobyl.py new file mode 100644 index 0000000..ed0b95d --- /dev/null +++ b/games/game_stalker2heartofchornobyl.py @@ -0,0 +1,271 @@ +import os +from enum import IntEnum, auto + +from PyQt6.QtCore import QDir +from PyQt6.QtWidgets import QMainWindow, QTabWidget, QWidget + +import mobase + +from ..basic_features import BasicLocalSavegames, BasicModDataChecker, GlobPatterns +from ..basic_game import BasicGame + + +class Problems(IntEnum): + """ + Enums for IPluginDiagnose. + """ + + MISPLACED_PAK_FILES = auto() + MISSING_MOD_DIRECTORIES = auto() + + +class S2HoCGame(BasicGame, mobase.IPluginFileMapper, mobase.IPluginDiagnose): + Name = "Stalker 2: Heart of Chornobyl Plugin" + Author = "MkHaters" + Version = "1.1.0" + + 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 = "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 + + def __init__(self): + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + mobase.IPluginDiagnose.__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: + super().init(organizer) + self._register_feature(S2HoCModDataChecker()) + self._register_feature( + BasicLocalSavegames(QDir(self.resolve_path(self.GameSavesDirectory))) + ) + + if ( + self._organizer.managedGame() + and self._organizer.managedGame().gameName() == self.gameName() + ): + mod_path = self.paksModsDirectory().absolutePath() + try: + os.makedirs(mod_path, exist_ok=True) + if not os.path.exists(mod_path): + print(f"Failed to create directory: {mod_path}") + except OSError as e: + print(f"OS error creating mod directory: {e}") + except Exception as e: + print(f"Unexpected error creating mod directory: {e}") + + 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 + + from .stalker2heartofchornobyl.paks import S2HoCPaksTabWidget + + self._paks_tab = S2HoCPaksTabWidget(main_window, self._organizer) + + tab_widget.addTab(self._paks_tab, "PAK Files") + print("PAK Files tab added!") + except ImportError as e: + print(f"Failed to import PAK tab widget: {e}") + except Exception as e: + print(f"Error initializing PAK tab: {e}") + import traceback + + traceback.print_exc() + + 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: + 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: + 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) + + 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: + 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}") + + for mod in self._organizer.modList().allMods(): + mod_info = self._organizer.modList().getMod(mod) + filetree = mod_info.fileTree() + + 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: + try: + os.makedirs(self.paksModsDirectory().absolutePath(), exist_ok=True) + print("Created missing mod directories") + except Exception as e: + print(f"Failed to create mod directories: {e}") + case _: + pass + + +class S2HoCModDataChecker(BasicModDataChecker): + def __init__(self, patterns: GlobPatterns = GlobPatterns()): + move_patterns = { + "*.pak": "Content/Paks/~mods/", + "*.utoc": "Content/Paks/~mods/", + "*.ucas": "Content/Paks/~mods/", + } + 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() diff --git a/games/stalker2heartofchornobyl/__init__.py b/games/stalker2heartofchornobyl/__init__.py new file mode 100644 index 0000000..f53b86d --- /dev/null +++ b/games/stalker2heartofchornobyl/__init__.py @@ -0,0 +1,3 @@ +from .paks import S2HoCPaksModel, S2HoCPaksTabWidget, S2HoCPaksView + +__all__ = ["S2HoCPaksTabWidget", "S2HoCPaksModel", "S2HoCPaksView"] diff --git a/games/stalker2heartofchornobyl/paks/__init__.py b/games/stalker2heartofchornobyl/paks/__init__.py new file mode 100644 index 0000000..96f3564 --- /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"] diff --git a/games/stalker2heartofchornobyl/paks/model.py b/games/stalker2heartofchornobyl/paks/model.py new file mode 100644 index 0000000..9e93f8e --- /dev/null +++ b/games/stalker2heartofchornobyl/paks/model.py @@ -0,0 +1,262 @@ +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(): + 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): + pass + + 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 True diff --git a/games/stalker2heartofchornobyl/paks/view.py b/games/stalker2heartofchornobyl/paks/view.py new file mode 100644 index 0000000..5cb6ae6 --- /dev/null +++ b/games/stalker2heartofchornobyl/paks/view.py @@ -0,0 +1,34 @@ +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): + if e is not None: + super().dropEvent(e) + self.clearSelection() + self.data_dropped.emit() + + def dataChanged( + self, topLeft: QModelIndex, bottomRight: QModelIndex, roles: Iterable[int] = () + ): + super().dataChanged(topLeft, bottomRight, roles) + self.repaint() diff --git a/games/stalker2heartofchornobyl/paks/widget.py b/games/stalker2heartofchornobyl/paks/widget.py new file mode 100644 index 0000000..45ef9b4 --- /dev/null +++ b/games/stalker2heartofchornobyl/paks/widget.py @@ -0,0 +1,225 @@ +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)