hacktools 1.0.1__cp315-cp315-win32.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hacktools/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.1"
hacktools/arch.py ADDED
@@ -0,0 +1,331 @@
1
+ """Support for ARCH archives, including compression and decompression.
2
+
3
+ An ARCH archive starts with an "ARCH" magic followed by a header, a name
4
+ table, a file allocation table and the file data. Files can optionally be
5
+ compressed with a byte-pair encoding scheme, based on Tinke's implementation.
6
+ """
7
+ import os
8
+ from collections import Counter
9
+ from hacktools import common
10
+
11
+
12
+ class ARCHArchive:
13
+ """Structure of an ARCH archive header.
14
+
15
+ Attributes:
16
+ filenum: Number of files in the archive.
17
+ tableoff: Offset of the name table.
18
+ fatoff: Offset of the file allocation table.
19
+ nameindexoff: Offset of the name index.
20
+ dataoff: Offset of the file data.
21
+ files: List of files in the archive.
22
+ """
23
+ def __init__(self):
24
+ self.filenum: int = 0
25
+ self.tableoff: int = 0
26
+ self.fatoff: int = 0
27
+ self.nameindexoff: int = 0
28
+ self.dataoff: int = 0
29
+ self.files: list[ARCHFile] = []
30
+
31
+
32
+ class ARCHFile:
33
+ """Structure of a single file entry in an ARCH archive.
34
+
35
+ Attributes:
36
+ name: File name, read from the archive name table.
37
+ length: Length of the file data as stored in the archive.
38
+ declength: Decompressed length, equal to length if not encoded.
39
+ offset: Offset of the file data, relative to the archive dataoff.
40
+ nameoffset: Offset of the file name, relative to the archive tableoff.
41
+ encoded: Whether the file data is compressed.
42
+ """
43
+ def __init__(self):
44
+ self.name: str = ""
45
+ self.length: int = 0
46
+ self.declength: int = 0
47
+ self.offset: int = 0
48
+ self.nameoffset: int = 0
49
+ self.encoded: bool = False
50
+
51
+
52
+ def read(f: common.Stream) -> ARCHArchive:
53
+ """Read the header and file table of an ARCH archive.
54
+
55
+ Args:
56
+ f: Stream opened on the archive file.
57
+
58
+ Returns:
59
+ The parsed archive structure.
60
+ """
61
+ f.seek(4) # Magic: ARCH
62
+ archive = ARCHArchive()
63
+ archive.filenum = f.readUInt()
64
+ archive.tableoff = f.readUInt()
65
+ archive.fatoff = f.readUInt()
66
+ archive.nameindexoff = f.readUInt()
67
+ archive.dataoff = f.readUInt()
68
+ common.logDebug("Archive:", vars(archive))
69
+ for i in range(archive.filenum):
70
+ f.seek(archive.fatoff + i * 16)
71
+ subfile = ARCHFile()
72
+ subfile.length = f.readUInt()
73
+ subfile.declength = f.readUInt()
74
+ subfile.offset = f.readUInt()
75
+ subfile.nameoffset = f.readUShort()
76
+ subfile.encoded = f.readUShort() == 1
77
+ f.seek(archive.tableoff + subfile.nameoffset)
78
+ subfile.name = f.readNullString()
79
+ common.logDebug("File", i, vars(subfile))
80
+ archive.files.append(subfile)
81
+ return archive
82
+
83
+
84
+ def repack(fin: common.Stream, f: common.Stream, archive: ARCHArchive, infolder: str) -> None:
85
+ """Repack an ARCH archive, replacing the files found in a folder.
86
+
87
+ Files that exist in infolder are read from there, compressing the ones
88
+ marked as encoded in the archive, while the others are copied from fin.
89
+ File data is aligned to 16 bytes with zeros.
90
+
91
+ Args:
92
+ fin: Stream opened on the original archive file.
93
+ f: Stream opened on the output archive file.
94
+ archive: Archive structure returned by :func:`read`.
95
+ infolder: Path of the folder containing the replacement files.
96
+ """
97
+ # Copy everything up to dataoff
98
+ fin.seek(0)
99
+ f.seek(0)
100
+ f.write(fin.read(archive.dataoff))
101
+ # Loop the files
102
+ dataoff = 0
103
+ for i in range(archive.filenum):
104
+ subfile = archive.files[i]
105
+ filepath = infolder + subfile.name
106
+ if not os.path.isfile(filepath):
107
+ # Just update the offset and copy the file
108
+ f.seek(archive.fatoff + i * 16)
109
+ f.seek(8, 1)
110
+ f.writeUInt(dataoff)
111
+ fin.seek(archive.dataoff + subfile.offset)
112
+ f.seek(archive.dataoff + dataoff)
113
+ f.write(fin.read(subfile.length))
114
+ else:
115
+ size = os.path.getsize(filepath)
116
+ f.seek(archive.fatoff + i * 16)
117
+ # If the file was not compressed, just copy it
118
+ if not subfile.encoded:
119
+ f.writeUInt(size)
120
+ f.writeUInt(size)
121
+ f.writeUInt(dataoff)
122
+ f.seek(2, 1)
123
+ f.writeUShort(0)
124
+ f.seek(archive.dataoff + dataoff)
125
+ with common.Stream(filepath, "rb") as subf:
126
+ f.write(subf.read())
127
+ else:
128
+ common.logDebug("Compressing", subfile.name)
129
+ with common.Stream(filepath, "rb") as subf:
130
+ filedata = subf.read()
131
+ compdata = compress(filedata)
132
+ f.writeUInt(len(compdata))
133
+ f.writeUInt(size)
134
+ f.writeUInt(dataoff)
135
+ f.seek(2, 1)
136
+ f.writeUShort(1)
137
+ f.seek(archive.dataoff + dataoff)
138
+ f.write(compdata)
139
+ # Align with 0s
140
+ if f.tell() % 16 > 0:
141
+ f.writeZero(16 - (f.tell() % 16))
142
+ dataoff = f.tell() - archive.dataoff
143
+
144
+
145
+ def extract(f: common.Stream, archive: ARCHArchive, outfolder: str) -> None:
146
+ """Extract all the files of an ARCH archive to a folder.
147
+
148
+ Files marked as encoded are decompressed.
149
+
150
+ Args:
151
+ f: Stream opened on the archive file.
152
+ archive: Archive structure returned by :func:`read`.
153
+ outfolder: Path of the folder to extract the files to.
154
+ """
155
+ for subfile in archive.files:
156
+ with common.Stream(outfolder + subfile.name, "wb") as fout:
157
+ f.seek(archive.dataoff + subfile.offset)
158
+ if not subfile.encoded:
159
+ fout.write(f.read(subfile.length))
160
+ else:
161
+ fout.write(decompress(f.read(subfile.length), subfile.declength))
162
+
163
+
164
+ def compress(data: bytes) -> bytes:
165
+ """Compress data with the byte-pair encoding used by ARCH archives.
166
+
167
+ The most common byte pairs are recursively replaced with single bytes
168
+ that don't occur in the data, and a dictionary of these replacements is
169
+ written before the encoded content.
170
+
171
+ Args:
172
+ data: Data to compress.
173
+
174
+ Returns:
175
+ The compressed data.
176
+ """
177
+ # Find unused bytes in the data
178
+ dictkeys = []
179
+ for i in range(1, 0x100):
180
+ dictkeys.append(i)
181
+ for b in data:
182
+ if b in dictkeys:
183
+ dictkeys.remove(b)
184
+ dictvalues = {}
185
+ # Recursively find the most used pair and replace it in the copied data
186
+ content = bytearray(data)
187
+ while True:
188
+ if len(dictkeys) == 0:
189
+ break
190
+ # Write all the pairs in a list, for simplicity will just stick to halfwords
191
+ allpairs = []
192
+ for i in range(len(content) // 2):
193
+ allpairs.append((content[i], content[i+1]))
194
+ # Find the most common one
195
+ c = Counter(allpairs).most_common(1)
196
+ if len(c) < 1:
197
+ break
198
+ pair = c[0]
199
+ if pair[1] < 4:
200
+ break
201
+ dictkey = dictkeys.pop()
202
+ common.logDebug("setting pair", common.toHex(pair[0][0]), common.toHex(pair[0][1]), "with", pair[1], "occurrences as dict key", common.toHex(dictkey))
203
+ dictvalues[dictkey] = pair[0]
204
+ content = content.replace(bytes(pair[0]), bytes([dictkey]))
205
+ with common.Stream() as f:
206
+ # Write the dictionary values
207
+ currentkey = 0
208
+ ordkeys = list(dictvalues.keys())
209
+ ordkeys.sort()
210
+ isconsecutive = False
211
+ # Special case where there are no dict keys
212
+ if len(ordkeys) == 0:
213
+ f.writeByte(0x7f + 0x7f)
214
+ f.writeByte(0x7f)
215
+ f.writeByte(0x7f + 0x7f)
216
+ f.writeByte(0xff)
217
+ currentkey = 0x100
218
+ else:
219
+ for i in range(len(ordkeys)):
220
+ dictkey = ordkeys[i]
221
+ common.logDebug("Writing key", common.toHex(dictkey))
222
+ # If the key is not consecutive, we need to skip places
223
+ if dictkey > currentkey:
224
+ keydiff = dictkey - currentkey
225
+ # Since we can only skip 0x7f bytes, we need to do an additional skip if it's bigger
226
+ while keydiff > 0x7f:
227
+ f.writeByte(0x7f + 0x7f)
228
+ # Also write a byte equal to the index
229
+ f.writeByte(0x7f)
230
+ keydiff -= 0x80
231
+ f.writeByte(keydiff + 0x7f)
232
+ currentkey = dictkey
233
+ isconsecutive = False
234
+ elif not isconsecutive:
235
+ # If this is the first time we're writing a key, we need to check how many consecutive ones there are
236
+ consecutive = 1
237
+ for j in range(i+1, len(ordkeys)):
238
+ if ordkeys[j] == dictkey + consecutive:
239
+ consecutive += 1
240
+ f.writeByte(consecutive - 1)
241
+ isconsecutive = True
242
+ common.logDebug("Writing key pairs", common.toHex(dictvalues[dictkey][0]), common.toHex(dictvalues[dictkey][1]))
243
+ f.writeByte(dictvalues[dictkey][0])
244
+ # Don't write the 2nd byte if it's the same as the index (shouldn't happen)
245
+ if dictvalues[dictkey][1] != dictkey:
246
+ f.writeByte(dictvalues[dictkey][1])
247
+ currentkey += 1
248
+ # We're forced to write all indexes even if they aren't used
249
+ if currentkey < 0x100:
250
+ f.writeByte(0x100 - currentkey - 1)
251
+ while currentkey < 0x100:
252
+ f.writeByte(currentkey)
253
+ currentkey += 1
254
+ # Write the actual content
255
+ numloopspos = f.tell()
256
+ f.writeByte(0)
257
+ f.writeByte(0)
258
+ numloops = 0
259
+ for b in content:
260
+ f.writeByte(b)
261
+ numloops += 1
262
+ f.seek(numloopspos)
263
+ f.writeByte(numloops >> 8)
264
+ f.writeByte(numloops & 0xff)
265
+ f.seek(0)
266
+ return f.read()
267
+
268
+
269
+ def decompress(data: bytes, declen: int) -> bytes:
270
+ """Decompress byte-pair encoded data from an ARCH archive.
271
+
272
+ Args:
273
+ data: Data to decompress.
274
+ declen: Expected length of the decompressed data, currently unused.
275
+
276
+ Returns:
277
+ The decompressed data.
278
+ """
279
+ with common.Stream() as f:
280
+ with common.Stream() as fout:
281
+ f.write(data)
282
+ f.seek(0)
283
+ # Based on Tinke's ARCH implementation
284
+ buffer1 = []
285
+ buffer2 = []
286
+ for i in range(0x100):
287
+ buffer1.append(0)
288
+ buffer2.append(0)
289
+ while f.tell() < len(data):
290
+ # InitBuffer
291
+ for i in range(0x100):
292
+ buffer2[i] = i
293
+ # FillBuffer
294
+ index = 0
295
+ while index != 0x100:
296
+ bufid = f.readByte()
297
+ numloops = bufid
298
+ if bufid > 0x7f:
299
+ numloops = 0
300
+ index += bufid - 0x7f
301
+ if index == 0x100:
302
+ break
303
+ if numloops < 0:
304
+ continue
305
+ for i in range(numloops + 1):
306
+ byte = f.readByte()
307
+ buffer2[index] = byte
308
+ if byte != index:
309
+ buffer1[index] = f.readByte()
310
+ index += 1
311
+ # Process
312
+ numloops = (f.readByte() << 8) + f.readByte()
313
+ common.logDebug("Decompressing with", common.toHex(numloops), "loops starting at", common.toHex(f.tell()))
314
+ nextsamples: list[int] = []
315
+ while True:
316
+ if len(nextsamples) == 0:
317
+ if numloops == 0:
318
+ break
319
+ numloops -= 1
320
+ index = f.readByte()
321
+ else:
322
+ index = nextsamples.pop()
323
+ if buffer2[index] == index:
324
+ fout.writeByte(index)
325
+ else:
326
+ nextsamples.append(buffer1[index])
327
+ nextsamples.append(buffer2[index])
328
+ index = len(nextsamples)
329
+ common.logDebug("Finished at", common.toHex(f.tell()), "with numloops", common.toHex(numloops))
330
+ fout.seek(0)
331
+ return fout.read()
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file