|
1 | | -from functools import cmp_to_key |
2 | | -from pathlib import Path |
3 | | -from typing import cast |
4 | | - |
5 | | -from PyQt6.QtCore import QDir, QFileInfo |
6 | | -from PyQt6.QtWidgets import QGridLayout, QWidget |
7 | | - |
8 | | -import mobase |
9 | | - |
10 | | -from ....basic_features.utils import is_directory |
11 | | -from .model import S2HoCPaksModel |
12 | | -from .view import S2HoCPaksView |
13 | | - |
14 | | - |
15 | | -def pak_sort(a: tuple[str, str], b: tuple[str, str]) -> int: |
16 | | - """Sort function for PAK files""" |
17 | | - if a[0] < b[0]: |
18 | | - return -1 |
19 | | - elif a[0] > b[0]: |
20 | | - return 1 |
21 | | - else: |
22 | | - return 0 |
23 | | - |
24 | | - |
25 | | -class S2HoCPaksTabWidget(QWidget): |
26 | | - """ |
27 | | - Widget for managing PAK files in Stalker 2: Heart of Chornobyl. |
28 | | - """ |
29 | | - |
30 | | - def __init__(self, parent: QWidget, organizer: mobase.IOrganizer): |
31 | | - super().__init__(parent) |
32 | | - self._organizer = organizer |
33 | | - self._view = S2HoCPaksView(self) |
34 | | - self._layout = QGridLayout(self) |
35 | | - self._layout.addWidget(self._view) |
36 | | - self._model = S2HoCPaksModel(self._view, organizer) |
37 | | - self._view.setModel(self._model) |
38 | | - self._model.dataChanged.connect(self.write_paks_list) |
39 | | - self._view.data_dropped.connect(self.write_paks_list) |
40 | | - organizer.onProfileChanged(lambda profile_a, profile_b: self._parse_pak_files()) |
41 | | - organizer.modList().onModInstalled(lambda mod: self._parse_pak_files()) |
42 | | - organizer.modList().onModRemoved(lambda mod: self._parse_pak_files()) |
43 | | - organizer.modList().onModStateChanged(lambda mods: self._parse_pak_files()) |
44 | | - self._parse_pak_files() |
45 | | - |
46 | | - def load_paks_list(self) -> list[str]: |
47 | | - profile = QDir(self._organizer.profilePath()) |
48 | | - paks_txt = QFileInfo(profile.absoluteFilePath("stalker2_paks.txt")) |
49 | | - paks_list: list[str] = [] |
50 | | - if paks_txt.exists(): |
51 | | - with open(paks_txt.absoluteFilePath(), "r") as paks_file: |
52 | | - for line in paks_file: |
53 | | - paks_list.append(line.strip()) |
54 | | - return paks_list |
55 | | - |
56 | | - def write_paks_list(self): |
57 | | - """Write the PAK list to file and then move the files""" |
58 | | - profile = QDir(self._organizer.profilePath()) |
59 | | - paks_txt = QFileInfo(profile.absoluteFilePath("stalker2_paks.txt")) |
60 | | - with open(paks_txt.absoluteFilePath(), "w") as paks_file: |
61 | | - for _, pak in sorted(self._model.paks.items()): |
62 | | - name, _, _, _ = pak |
63 | | - paks_file.write(f"{name}\n") |
64 | | - self.write_pak_files() |
65 | | - |
66 | | - def write_pak_files(self): |
67 | | - """Move PAK files to their target numbered directories""" |
68 | | - for index, pak in sorted(self._model.paks.items()): |
69 | | - _, _, current_path, target_path = pak |
70 | | - if current_path and current_path != target_path: |
71 | | - path_dir = Path(current_path) |
72 | | - target_dir = Path(target_path) |
73 | | - if not target_dir.exists(): |
74 | | - target_dir.mkdir(parents=True, exist_ok=True) |
75 | | - if path_dir.exists(): |
76 | | - for pak_file in path_dir.glob("*.pak"): |
77 | | - ucas_file = pak_file.with_suffix(".ucas") |
78 | | - utoc_file = pak_file.with_suffix(".utoc") |
79 | | - for file in (pak_file, ucas_file, utoc_file): |
80 | | - if not file.exists(): |
81 | | - continue |
82 | | - try: |
83 | | - file.rename(target_dir.joinpath(file.name)) |
84 | | - except FileExistsError: |
85 | | - pass |
86 | | - data = self._model.paks[index] |
87 | | - self._model.paks[index] = ( |
88 | | - data[0], |
89 | | - data[1], |
90 | | - data[3], |
91 | | - data[3], |
92 | | - ) |
93 | | - break |
94 | | - if not list(path_dir.iterdir()): |
95 | | - path_dir.rmdir() |
96 | | - |
97 | | - def _shake_paks(self, sorted_paks: dict[str, str]) -> list[str]: |
98 | | - """Preserve order from paks.txt if it exists, otherwise use alphabetical""" |
99 | | - shaken_paks: list[str] = [] |
100 | | - shaken_paks_p: list[str] = [] |
101 | | - paks_list = self.load_paks_list() |
102 | | - for pak in paks_list: |
103 | | - if pak in sorted_paks.keys(): |
104 | | - if pak.casefold().endswith("_p"): |
105 | | - shaken_paks_p.append(pak) |
106 | | - else: |
107 | | - shaken_paks.append(pak) |
108 | | - sorted_paks.pop(pak) |
109 | | - for pak in sorted_paks.keys(): |
110 | | - if pak.casefold().endswith("_p"): |
111 | | - shaken_paks_p.append(pak) |
112 | | - else: |
113 | | - shaken_paks.append(pak) |
114 | | - return shaken_paks + shaken_paks_p |
115 | | - |
116 | | - def _parse_pak_files(self): |
117 | | - """Parse PAK files from mods, following numbered folder assignment pattern""" |
118 | | - from ...game_stalker2heartofchornobyl import S2HoCGame |
119 | | - |
120 | | - mods = self._organizer.modList().allMods() |
121 | | - paks: dict[str, str] = {} |
122 | | - pak_paths: dict[str, tuple[str, str]] = {} |
123 | | - pak_source: dict[str, str] = {} |
124 | | - existing_folders: set[int] = set() |
125 | | -<<<<<<< HEAD |
126 | | - |
127 | | -======= |
128 | | - |
129 | | - print(f"[PAK Debug] Starting scan of {len(mods)} mods") |
130 | | - print("[PAK Debug] ONLY scanning ~mods directories, EXCLUDING LogicMods") |
131 | | - |
132 | | - # First, scan what numbered folders already exist to avoid double-assignment |
133 | | ->>>>>>> ab91432d429d5ec75630e299423146320437832d |
134 | | - game = self._organizer.managedGame() |
135 | | - if isinstance(game, S2HoCGame): |
136 | | - pak_mods_dir = QFileInfo(game.paksModsDirectory().absolutePath()) |
137 | | - if pak_mods_dir.exists() and pak_mods_dir.isDir(): |
138 | | -<<<<<<< HEAD |
139 | | -======= |
140 | | - print( |
141 | | - f"[PAK Debug] Scanning existing folders in: {pak_mods_dir.absoluteFilePath()}" |
142 | | - ) |
143 | | ->>>>>>> ab91432d429d5ec75630e299423146320437832d |
144 | | - for entry in QDir(pak_mods_dir.absoluteFilePath()).entryInfoList( |
145 | | - QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot |
146 | | - ): |
147 | | - try: |
148 | | - folder_num = int(entry.completeBaseName()) |
149 | | - existing_folders.add(folder_num) |
150 | | -<<<<<<< HEAD |
151 | | - except ValueError: |
152 | | - pass |
153 | | - |
154 | | -======= |
155 | | - print( |
156 | | - f"[PAK Debug] Found existing numbered folder: {folder_num}" |
157 | | - ) |
158 | | - except ValueError: |
159 | | - print( |
160 | | - f"[PAK Debug] Skipping non-numbered folder: {entry.completeBaseName()}" |
161 | | - ) |
162 | | - |
163 | | - # Scan mods for PAK files ONLY in Content/Paks/~mods structure (exclude LogicMods) |
164 | | ->>>>>>> ab91432d429d5ec75630e299423146320437832d |
165 | | - for mod in mods: |
166 | | - mod_item = self._organizer.modList().getMod(mod) |
167 | | - if not self._organizer.modList().state(mod) & mobase.ModState.ACTIVE: |
168 | | - continue |
169 | | - filetree = mod_item.fileTree() |
170 | | - |
171 | | -<<<<<<< HEAD |
172 | | - has_logicmods = ( |
173 | | - filetree.find("Content/Paks/LogicMods") or filetree.find("Paks/LogicMods") |
174 | | - ) |
175 | | - if isinstance(has_logicmods, mobase.IFileTree): |
176 | | -======= |
177 | | - # If this mod contains a LogicMods directory, skip it entirely for the PAK tab |
178 | | - has_logicmods = filetree.find("Content/Paks/LogicMods") or filetree.find( |
179 | | - "Paks/LogicMods" |
180 | | - ) |
181 | | - if isinstance(has_logicmods, mobase.IFileTree): |
182 | | - print( |
183 | | - f"[PAK Debug] Skipping mod '{mod_item.name()}' because it contains a LogicMods directory." |
184 | | - ) |
185 | | ->>>>>>> ab91432d429d5ec75630e299423146320437832d |
186 | | - continue |
187 | | - |
188 | | - pak_mods = filetree.find("Paks/~mods") |
189 | | - if not pak_mods: |
190 | | - pak_mods = filetree.find("Content/Paks/~mods") |
191 | | - if isinstance(pak_mods, mobase.IFileTree) and pak_mods.name() == "~mods": |
192 | | - for entry in pak_mods: |
193 | | - if is_directory(entry): |
194 | | - for sub_entry in entry: |
195 | | - if ( |
196 | | - sub_entry.isFile() |
197 | | - and sub_entry.suffix().casefold() == "pak" |
198 | | - ): |
199 | | - pak_name = sub_entry.name()[ |
200 | | - : -1 - len(sub_entry.suffix()) |
201 | | - ] |
202 | | - paks[pak_name] = entry.name() |
203 | | - pak_paths[pak_name] = ( |
204 | | - mod_item.absolutePath() |
205 | | - + "/" |
206 | | - + cast(mobase.IFileTree, sub_entry.parent()).path( |
207 | | - "/" |
208 | | - ), |
209 | | - mod_item.absolutePath() + "/" + pak_mods.path("/"), |
210 | | - ) |
211 | | - pak_source[pak_name] = mod_item.name() |
212 | | -<<<<<<< HEAD |
213 | | -======= |
214 | | - print( |
215 | | - f"[PAK Debug] ✅ Added PAK from ~mods numbered folder: {pak_name} in {entry.name()}" |
216 | | - ) |
217 | | ->>>>>>> ab91432d429d5ec75630e299423146320437832d |
218 | | - else: |
219 | | - if entry.suffix().casefold() == "pak": |
220 | | - pak_name = entry.name()[: -1 - len(entry.suffix())] |
221 | | - paks[pak_name] = "" |
222 | | - pak_paths[pak_name] = ( |
223 | | - mod_item.absolutePath() |
224 | | - + "/" |
225 | | - + cast(mobase.IFileTree, entry.parent()).path("/"), |
226 | | - mod_item.absolutePath() + "/" + pak_mods.path("/"), |
227 | | - ) |
228 | | - pak_source[pak_name] = mod_item.name() |
229 | | -<<<<<<< HEAD |
230 | | - |
231 | | - sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort))) |
232 | | - shaken_paks: list[str] = self._shake_paks(sorted_paks) |
233 | | - |
234 | | -======= |
235 | | - print( |
236 | | - f"[PAK Debug] ✅ Added loose PAK from ~mods: {pak_name}" |
237 | | - ) |
238 | | - else: |
239 | | - # Check if this mod has LogicMods (for debugging purposes) |
240 | | - logic_mods = filetree.find("Content/Paks/LogicMods") |
241 | | - if not logic_mods: |
242 | | - logic_mods = filetree.find("Paks/LogicMods") |
243 | | - if isinstance(logic_mods, mobase.IFileTree): |
244 | | - print( |
245 | | - f"[PAK Debug] Mod {mod_item.name()} has LogicMods (not included in PAK tab)" |
246 | | - ) |
247 | | - |
248 | | - # NOTE: Removed game directory scanning to prevent LogicMods PAKs from appearing |
249 | | - # We only want PAKs from mod files, not from game directory |
250 | | - print("[PAK Debug] Skipping game directory scan to prevent LogicMods inclusion") |
251 | | - |
252 | | - # Sort PAKs and shake them (preserve order from paks.txt if it exists) |
253 | | - sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort))) |
254 | | - shaken_paks: list[str] = self._shake_paks(sorted_paks) |
255 | | - |
256 | | - # Assign target directories with numbered folders (like Oblivion Remastered) |
257 | | - # Skip numbers that already exist, use next available number starting from 8999 |
258 | | ->>>>>>> ab91432d429d5ec75630e299423146320437832d |
259 | | - final_paks: dict[str, tuple[str, str, str]] = {} |
260 | | - pak_index = 8999 |
261 | | - |
262 | | - for pak in shaken_paks: |
263 | | - while pak_index in existing_folders: |
264 | | - pak_index -= 1 |
265 | | -<<<<<<< HEAD |
266 | | - |
267 | | -======= |
268 | | - |
269 | | - # If PAK is already in a numbered folder, keep its current assignment |
270 | | ->>>>>>> ab91432d429d5ec75630e299423146320437832d |
271 | | - current_folder = paks[pak] |
272 | | - if current_folder.isdigit(): |
273 | | - target_dir = pak_paths[pak][1] + "/" + current_folder |
274 | | - existing_folders.add(int(current_folder)) |
275 | | - else: |
276 | | - target_dir = pak_paths[pak][1] + "/" + str(pak_index).zfill(4) |
277 | | - existing_folders.add(pak_index) |
278 | | - pak_index -= 1 |
279 | | - |
280 | | - final_paks[pak] = (pak_source[pak], pak_paths[pak][0], target_dir) |
281 | | -<<<<<<< HEAD |
282 | | - |
283 | | -======= |
284 | | - |
285 | | - # Convert to model format (4-tuple matching Oblivion Remastered) |
286 | | ->>>>>>> ab91432d429d5ec75630e299423146320437832d |
287 | | - new_data_paks: dict[int, tuple[str, str, str, str]] = {} |
288 | | - i = 0 |
289 | | - for pak, data in final_paks.items(): |
290 | | - source, current_path, target_path = data |
291 | | - new_data_paks[i] = (pak, source, current_path, target_path) |
292 | | - i += 1 |
293 | | -<<<<<<< HEAD |
294 | | - |
295 | | -======= |
296 | | - |
297 | | - print(f"[PAK Debug] Final PAK count: {len(new_data_paks)}") |
298 | | ->>>>>>> ab91432d429d5ec75630e299423146320437832d |
299 | | - self._model.set_paks(new_data_paks) |
| 1 | +Creating |
| 2 | +clean |
| 3 | +widget.py |
0 commit comments