Skip to content

Commit a45ff2d

Browse files
committed
Add FF12 basic game plugin
Adds support for Final Fantasy XII The Zodiac Age Steam/PC version.
1 parent 83c4319 commit a45ff2d

12 files changed

Lines changed: 1654 additions & 0 deletions

File tree

games/ff12/Archive/Loader.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from pathlib import Path
2+
3+
from PyQt6.QtCore import QThread, pyqtSignal
4+
5+
from .Reader import ArchiveReader
6+
7+
8+
class ArchiveLoader(QThread):
9+
"""Background thread for loading archive."""
10+
finished = pyqtSignal(object)
11+
error = pyqtSignal(str)
12+
13+
def __init__(self, archive_path: Path):
14+
super().__init__()
15+
self._archive_path = archive_path
16+
17+
def run(self):
18+
"""Load archive in background thread."""
19+
try:
20+
reader = ArchiveReader(self._archive_path)
21+
self.finished.emit(reader)
22+
except Exception as e:
23+
self.error.emit(str(e))

games/ff12/Archive/Model.py

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
from enum import IntEnum, auto
2+
3+
from PyQt6.QtCore import QAbstractItemModel, QFileInfo, QModelIndex, Qt
4+
from PyQt6.QtWidgets import QFileIconProvider
5+
6+
from .Reader import ArchiveReader
7+
8+
9+
class TreeNode:
10+
def __init__(self, name: str, parent = None, is_dir = False, size = 0, entry = None):
11+
self.name = name
12+
self.parent = parent
13+
self.is_dir = is_dir
14+
self.size = size
15+
self.children = []
16+
self.entry = entry
17+
18+
if parent:
19+
parent.children.append(self)
20+
21+
def child_count(self) -> int:
22+
return len(self.children)
23+
24+
def child(self, row: int) -> 'TreeNode | None':
25+
if 0 <= row < len(self.children):
26+
return self.children[row]
27+
return None
28+
29+
def row(self) -> int:
30+
if self.parent:
31+
return self.parent.children.index(self)
32+
return 0
33+
34+
def path(self) -> str:
35+
"""Get the full path to this node."""
36+
if self.parent and self.parent.parent:
37+
return self.parent.path() + "/" + self.name
38+
return self.name
39+
40+
def sort_children(self, column: int, order: Qt.SortOrder):
41+
"""Sort children by the specified column and order.
42+
Directories always come before files.
43+
Directories only reorder when column == NAME.
44+
"""
45+
dirs = [i for i in self.children if i.is_dir]
46+
if column == ArchiveColumn.NAME:
47+
dirs.sort(key=lambda node: node.name.lower(),
48+
reverse=(order == Qt.SortOrder.DescendingOrder))
49+
50+
files = [i for i in self.children if not i.is_dir]
51+
if column == ArchiveColumn.NAME:
52+
files.sort(key=lambda node: node.name.lower(),
53+
reverse=(order == Qt.SortOrder.DescendingOrder))
54+
elif column == ArchiveColumn.TYPE:
55+
files.sort(key=lambda node: QFileInfo(node.name).suffix().lower(),
56+
reverse=(order == Qt.SortOrder.DescendingOrder))
57+
elif column == ArchiveColumn.SIZE:
58+
files.sort(key=lambda node: node.size,
59+
reverse=(order == Qt.SortOrder.DescendingOrder))
60+
61+
self.children = dirs + files
62+
for i in dirs:
63+
i.sort_children(column, order)
64+
65+
class ArchiveColumn(IntEnum):
66+
NAME = 0
67+
TYPE = auto()
68+
SIZE = auto()
69+
70+
class ArchiveModel(QAbstractItemModel):
71+
def __init__(self, parent = None):
72+
super().__init__(parent)
73+
self._reader = None
74+
self._icon_provider = QFileIconProvider()
75+
self._root_node = TreeNode("", None, True)
76+
self._sort_column = ArchiveColumn.NAME
77+
self._sort_order = Qt.SortOrder.AscendingOrder
78+
79+
def set_data(self, reader: ArchiveReader):
80+
self.beginResetModel()
81+
self._reader = reader
82+
self._build_tree()
83+
self._sort_tree()
84+
self.endResetModel()
85+
86+
def _build_tree(self):
87+
"""Build the tree structure from archive entries."""
88+
dir_nodes: dict[str, TreeNode] = {"": self._root_node}
89+
sorted_entries = sorted(self._reader._entries.items(), key = lambda x: x[0])
90+
91+
for name, entry in sorted_entries:
92+
path_parts = name.split('/')
93+
current_path = ""
94+
current_node = self._root_node
95+
96+
for i, part in enumerate(path_parts[:-1]):
97+
if current_path:
98+
current_path += "/" + part
99+
else:
100+
current_path = part
101+
102+
if current_path not in dir_nodes:
103+
dir_node = TreeNode(part, current_node, True)
104+
dir_nodes[current_path] = dir_node
105+
current_node = dir_node
106+
else:
107+
current_node = dir_nodes[current_path]
108+
109+
filename = path_parts[-1]
110+
TreeNode(filename, current_node, False, entry.original_size, entry)
111+
112+
def _sort_tree(self):
113+
"""Sort the entire tree."""
114+
self._root_node.sort_children(self._sort_column, self._sort_order)
115+
116+
def sort(self, column: int, order: Qt.SortOrder):
117+
"""Implement sorting with proper persistent index handling"""
118+
persistent_indexes = self.persistentIndexList()
119+
120+
old_nodes = []
121+
for index in persistent_indexes:
122+
if index.isValid():
123+
old_nodes.append(index.internalPointer())
124+
else:
125+
old_nodes.append(None)
126+
127+
self.layoutAboutToBeChanged.emit([], QAbstractItemModel.LayoutChangeHint.VerticalSortHint)
128+
129+
self._sort_column = column
130+
self._sort_order = order
131+
self._sort_tree()
132+
133+
new_indexes = []
134+
for node in old_nodes:
135+
if node is not None:
136+
new_index = self._find_index_for_node(node)
137+
new_indexes.append(new_index)
138+
else:
139+
new_indexes.append(QModelIndex())
140+
141+
for old_index, new_index in zip(persistent_indexes, new_indexes):
142+
self.changePersistentIndex(old_index, new_index)
143+
144+
self.layoutChanged.emit([], QAbstractItemModel.LayoutChangeHint.VerticalSortHint)
145+
146+
def _find_index_for_node(self, target_node: TreeNode) -> QModelIndex:
147+
"""Find the QModelIndex for a specific TreeNode after sorting"""
148+
if target_node == self._root_node:
149+
return QModelIndex()
150+
151+
path = []
152+
current = target_node
153+
while current and current != self._root_node:
154+
path.append(current)
155+
current = current.parent
156+
157+
current_index = QModelIndex()
158+
for node in reversed(path):
159+
parent_node = node.parent if node.parent else self._root_node
160+
row = parent_node.children.index(node)
161+
current_index = self.index(row, 0, current_index)
162+
163+
return current_index
164+
165+
def rowCount(self, parent: QModelIndex) -> int:
166+
if not parent.isValid():
167+
parent_node = self._root_node
168+
else:
169+
parent_node = parent.internalPointer()
170+
171+
return parent_node.child_count()
172+
173+
def columnCount(self, parent: QModelIndex) -> int:
174+
return len(ArchiveColumn)
175+
176+
def index(self, row: int, column: int, parent: QModelIndex) -> QModelIndex:
177+
if not self.hasIndex(row, column, parent):
178+
return QModelIndex()
179+
180+
if not parent.isValid():
181+
parent_node = self._root_node
182+
else:
183+
parent_node = parent.internalPointer()
184+
185+
child_node = parent_node.child(row)
186+
if child_node:
187+
return self.createIndex(row, column, child_node)
188+
189+
return QModelIndex()
190+
191+
def parent(self, index: QModelIndex) -> QModelIndex:
192+
if not index.isValid():
193+
return QModelIndex()
194+
195+
child_node = index.internalPointer()
196+
parent_node = child_node.parent
197+
198+
if parent_node == self._root_node or parent_node is None:
199+
return QModelIndex()
200+
201+
return self.createIndex(parent_node.row(), 0, parent_node)
202+
203+
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
204+
if not index.isValid():
205+
return None
206+
207+
node = index.internalPointer()
208+
column = index.column()
209+
210+
if role == Qt.ItemDataRole.DisplayRole:
211+
if column == ArchiveColumn.NAME:
212+
return node.name
213+
elif column == ArchiveColumn.TYPE:
214+
if node.is_dir:
215+
return "Folder"
216+
else:
217+
file_info = QFileInfo(node.name)
218+
return file_info.suffix()
219+
elif column == ArchiveColumn.SIZE:
220+
if node.is_dir:
221+
return ""
222+
else:
223+
return self._format_file_size(node.size)
224+
225+
elif role == Qt.ItemDataRole.DecorationRole and column == ArchiveColumn.NAME:
226+
if node.is_dir:
227+
return self._icon_provider.icon(QFileIconProvider.IconType.Folder)
228+
else:
229+
file_info = QFileInfo(node.name)
230+
return self._icon_provider.icon(file_info)
231+
232+
return None
233+
234+
def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole) -> str | None:
235+
if orientation != Qt.Orientation.Horizontal or role != Qt.ItemDataRole.DisplayRole:
236+
return None
237+
238+
match ArchiveColumn(section):
239+
case ArchiveColumn.NAME:
240+
return "Name"
241+
case ArchiveColumn.TYPE:
242+
return "Type"
243+
case ArchiveColumn.SIZE:
244+
return "Size"
245+
246+
return None
247+
248+
def get_node(self, index: QModelIndex) -> TreeNode | None:
249+
"""Get the TreeNode for a given index."""
250+
if index.isValid():
251+
return index.internalPointer()
252+
return None
253+
254+
@staticmethod
255+
def _format_file_size(size: int) -> str:
256+
"""Format file size (B, KB, MB, GB) with proper fallback."""
257+
if size < 1024:
258+
return f"{size} B"
259+
260+
size_f = float(size)
261+
for unit in ['KB', 'MB', 'GB']:
262+
size_f /= 1024.0
263+
if size_f < 1024.0:
264+
if size_f < 10:
265+
return f"{size_f:.2f} {unit}"
266+
elif size_f < 100:
267+
return f"{size_f:.1f} {unit}"
268+
else:
269+
return f"{size_f:.0f} {unit}"
270+
271+
return f"{size_f:.2f} GB"

0 commit comments

Comments
 (0)