zcmodkit 0.1.0__py3-none-any.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.
- zcmodkit/__init__.py +211 -0
- zcmodkit/data/BUILD.txt +1 -0
- zcmodkit/data/mappings.usmap +0 -0
- zcmodkit/domains/__init__.py +6 -0
- zcmodkit/domains/assets.py +522 -0
- zcmodkit/domains/datatable.py +162 -0
- zcmodkit/domains/properties.py +275 -0
- zcmodkit/formats/__init__.py +17 -0
- zcmodkit/formats/iostore.py +497 -0
- zcmodkit/formats/locres.py +139 -0
- zcmodkit/formats/oodle.py +72 -0
- zcmodkit/formats/pak.py +248 -0
- zcmodkit/formats/usmap.py +328 -0
- zcmodkit/formats/zen.py +498 -0
- zcmodkit/game.py +52 -0
- zcmodkit/mod.py +117 -0
- zcmodkit-0.1.0.dist-info/METADATA +891 -0
- zcmodkit-0.1.0.dist-info/RECORD +21 -0
- zcmodkit-0.1.0.dist-info/WHEEL +5 -0
- zcmodkit-0.1.0.dist-info/licenses/LICENSE +674 -0
- zcmodkit-0.1.0.dist-info/top_level.txt +1 -0
zcmodkit/formats/zen.py
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""Reading UE5 "Zen" cooked packages, which is how assets look inside IoStore.
|
|
2
|
+
|
|
3
|
+
A package is a header followed by export data. The header holds a summary of
|
|
4
|
+
section offsets, a name map, an import map and an export map. The export map
|
|
5
|
+
says where each export starts and how big it is.
|
|
6
|
+
|
|
7
|
+
Edit anything in the export data and those sizes have to be kept straight, so
|
|
8
|
+
all the parsing lives here instead of being spread around as raw offsets.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import struct
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from cityhash import CityHash64
|
|
17
|
+
|
|
18
|
+
#: Where the name map starts. The summary in front of it is 60 bytes in UE 5.6.
|
|
19
|
+
#: It was 52 until 5.5 added two Verse cell map offsets.
|
|
20
|
+
NAME_MAP_OFFSET = 60
|
|
21
|
+
|
|
22
|
+
#: Size of one FExportMapEntry.
|
|
23
|
+
EXPORT_ENTRY_SIZE = 72
|
|
24
|
+
|
|
25
|
+
#: Where an FExportMapEntry keeps its FPackageObjectIndex fields. Outer is
|
|
26
|
+
#: always an export or null in practice, but class, super and template all
|
|
27
|
+
#: point at imported packages, so renumbering has to cover them.
|
|
28
|
+
EXPORT_REF_FIELDS = (24, 32, 40, 48)
|
|
29
|
+
|
|
30
|
+
#: Where the section offset table starts in the summary.
|
|
31
|
+
SECTION_OFFSETS_AT = 24
|
|
32
|
+
|
|
33
|
+
#: The nine section offsets, in the order the summary writes them. The two cell
|
|
34
|
+
#: maps came in with UE 5.5 and sit at the end, which is why the summary grew
|
|
35
|
+
#: from 52 bytes to 60.
|
|
36
|
+
SECTION_NAMES = (
|
|
37
|
+
"imported_public_export_hashes",
|
|
38
|
+
"import_map",
|
|
39
|
+
"export_map",
|
|
40
|
+
"export_bundle_entries",
|
|
41
|
+
"dependency_bundle_headers",
|
|
42
|
+
"dependency_bundle_entries",
|
|
43
|
+
"imported_package_names",
|
|
44
|
+
"cell_import_map",
|
|
45
|
+
"cell_export_map",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
#: Verse cell maps. Empty in every package this game ships. They came in with
|
|
49
|
+
#: UE 5.5, and physically they sit between the export map and the export bundle
|
|
50
|
+
#: entries even though the summary writes their offsets last.
|
|
51
|
+
CELL_SECTIONS = ("cell_import_map", "cell_export_map")
|
|
52
|
+
|
|
53
|
+
#: The sections in the order they are laid out, which is not the order the
|
|
54
|
+
#: summary lists them. A section runs to wherever the next one in this order
|
|
55
|
+
#: starts, so an empty section has its start and end in the same place.
|
|
56
|
+
LAYOUT_ORDER = (
|
|
57
|
+
"imported_public_export_hashes",
|
|
58
|
+
"import_map",
|
|
59
|
+
"export_map",
|
|
60
|
+
"cell_import_map",
|
|
61
|
+
"cell_export_map",
|
|
62
|
+
"export_bundle_entries",
|
|
63
|
+
"dependency_bundle_headers",
|
|
64
|
+
"dependency_bundle_entries",
|
|
65
|
+
"imported_package_names",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
#: Version stamp Unreal writes into the name map for its hash algorithm.
|
|
69
|
+
NAME_HASH_VERSION = 0xC1640000
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
#: The four kinds of FPackageObjectIndex, taken from the top two bits.
|
|
73
|
+
IMPORT_EXPORT = 0
|
|
74
|
+
IMPORT_SCRIPT = 1
|
|
75
|
+
IMPORT_PACKAGE = 2
|
|
76
|
+
IMPORT_NULL = 3
|
|
77
|
+
|
|
78
|
+
_INDEX_BITS = 62
|
|
79
|
+
_INDEX_MASK = (1 << _INDEX_BITS) - 1
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def name_hash(text: str) -> int:
|
|
83
|
+
"""The hash a Zen name map stores next to a name.
|
|
84
|
+
|
|
85
|
+
CityHash64 of the lowercased name. Narrow names hash their UTF-8 bytes,
|
|
86
|
+
wide ones hash UTF-16. Checked against 223,196 names out of the shipped
|
|
87
|
+
game and it matched every one. Every name in this game is narrow, so the
|
|
88
|
+
wide branch is what the engine does rather than something confirmed here.
|
|
89
|
+
"""
|
|
90
|
+
lowered = text.lower()
|
|
91
|
+
if text.isascii():
|
|
92
|
+
return CityHash64(lowered.encode("utf-8"))
|
|
93
|
+
return CityHash64(lowered.encode("utf-16-le"))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def package_import(package_index: int, hash_index: int) -> int:
|
|
97
|
+
"""An FPackageObjectIndex pointing at an export of an imported package.
|
|
98
|
+
|
|
99
|
+
The two indexes are positions in this package's own tables: which imported
|
|
100
|
+
package, and which entry of ImportedPublicExportHashes. Confirmed against
|
|
101
|
+
40,036 of them, none out of range.
|
|
102
|
+
"""
|
|
103
|
+
return (IMPORT_PACKAGE << _INDEX_BITS) | (package_index << 32) | hash_index
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def split_import(value: int) -> tuple[int, int, int]:
|
|
107
|
+
"""Pull an FPackageObjectIndex apart into (kind, package index, hash index).
|
|
108
|
+
|
|
109
|
+
The two indexes only mean anything when the kind is IMPORT_PACKAGE.
|
|
110
|
+
"""
|
|
111
|
+
kind = value >> _INDEX_BITS
|
|
112
|
+
index = value & _INDEX_MASK
|
|
113
|
+
return kind, index >> 32, index & 0xFFFFFFFF
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class NameMap:
|
|
118
|
+
"""A parsed Zen name map. Counts, hashes, a header per name, the strings."""
|
|
119
|
+
|
|
120
|
+
names: list[str]
|
|
121
|
+
hashes: list[int]
|
|
122
|
+
start: int
|
|
123
|
+
end: int
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def size(self) -> int:
|
|
127
|
+
return self.end - self.start
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def read_name_map(data: bytes | bytearray, at: int) -> NameMap:
|
|
131
|
+
"""Parse the name map that starts at `at`.
|
|
132
|
+
|
|
133
|
+
Packages carry two of these. The main one right after the summary, and
|
|
134
|
+
ImportedPackageNames at the back of the header.
|
|
135
|
+
"""
|
|
136
|
+
(count,) = struct.unpack_from("<I", data, at)
|
|
137
|
+
if count == 0:
|
|
138
|
+
# An empty batch is just the count. Nothing follows it, so reading on
|
|
139
|
+
# would walk straight into whatever section comes next.
|
|
140
|
+
return NameMap([], [], at, at + 4)
|
|
141
|
+
(string_bytes,) = struct.unpack_from("<I", data, at + 4)
|
|
142
|
+
hashes_at = at + 16
|
|
143
|
+
headers_at = hashes_at + count * 8
|
|
144
|
+
strings_at = headers_at + count * 2
|
|
145
|
+
if strings_at + string_bytes > len(data):
|
|
146
|
+
raise PackageError(f"name map at {at} runs past the end of the package")
|
|
147
|
+
|
|
148
|
+
names, hashes, pos = [], [], strings_at
|
|
149
|
+
for i in range(count):
|
|
150
|
+
first, second = data[headers_at + i * 2], data[headers_at + i * 2 + 1]
|
|
151
|
+
wide = bool(first & 0x80)
|
|
152
|
+
length = ((first & 0x7F) << 8) | second
|
|
153
|
+
width = length * 2 if wide else length
|
|
154
|
+
raw = bytes(data[pos : pos + width])
|
|
155
|
+
if len(raw) != width:
|
|
156
|
+
raise PackageError(f"name {i} in the map at {at} is truncated")
|
|
157
|
+
names.append(
|
|
158
|
+
raw.decode("utf-16-le") if wide else raw.decode("utf-8", "replace")
|
|
159
|
+
)
|
|
160
|
+
hashes.append(struct.unpack_from("<Q", data, hashes_at + i * 8)[0])
|
|
161
|
+
pos += width
|
|
162
|
+
if pos - strings_at != string_bytes:
|
|
163
|
+
raise PackageError(
|
|
164
|
+
f"name map at {at} consumed {pos - strings_at} bytes, "
|
|
165
|
+
f"header declares {string_bytes}"
|
|
166
|
+
)
|
|
167
|
+
return NameMap(names, hashes, at, pos)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def write_name_map(names: list[str]) -> bytes:
|
|
171
|
+
"""Build a name map blob. Order is kept, the hashes get recomputed.
|
|
172
|
+
|
|
173
|
+
Nothing sorts these, so a new name can go on the end. Checked 4,000 real
|
|
174
|
+
packages and not one of them had a sorted map.
|
|
175
|
+
"""
|
|
176
|
+
if not names:
|
|
177
|
+
return struct.pack("<I", 0)
|
|
178
|
+
encoded, headers = [], bytearray()
|
|
179
|
+
for name in names:
|
|
180
|
+
wide = not name.isascii()
|
|
181
|
+
raw = name.encode("utf-16-le") if wide else name.encode("utf-8")
|
|
182
|
+
length = len(raw) // 2 if wide else len(raw)
|
|
183
|
+
if length > 0x7FFF:
|
|
184
|
+
raise PackageError(f"name is too long for a name map: {name!r}")
|
|
185
|
+
headers += bytes([(0x80 if wide else 0) | (length >> 8), length & 0xFF])
|
|
186
|
+
encoded.append(raw)
|
|
187
|
+
body = b"".join(encoded)
|
|
188
|
+
out = bytearray(struct.pack("<II", len(names), len(body)))
|
|
189
|
+
out += struct.pack("<Q", NAME_HASH_VERSION)
|
|
190
|
+
out += b"".join(struct.pack("<Q", name_hash(n)) for n in names)
|
|
191
|
+
return bytes(out + headers + body)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class PackageError(ValueError):
|
|
195
|
+
"""A cooked package would not parse, or failed one of the checks."""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(frozen=True)
|
|
199
|
+
class Export:
|
|
200
|
+
"""One export's slice of the export data, measured from the header end."""
|
|
201
|
+
|
|
202
|
+
offset: int
|
|
203
|
+
size: int
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def end(self) -> int:
|
|
207
|
+
return self.offset + self.size
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class ZenPackage:
|
|
211
|
+
"""A parsed view of cooked package bytes.
|
|
212
|
+
|
|
213
|
+
Parsing is cheap and read only. To change something, edit the buffer and
|
|
214
|
+
parse it again rather than poking at this object.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def __init__(self, data: bytes | bytearray):
|
|
218
|
+
self.data = data
|
|
219
|
+
if len(data) < NAME_MAP_OFFSET:
|
|
220
|
+
raise PackageError(f"too short to be a package: {len(data)} bytes")
|
|
221
|
+
self._read_summary()
|
|
222
|
+
self._read_names()
|
|
223
|
+
self._read_exports()
|
|
224
|
+
|
|
225
|
+
# -- summary ---------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def _read_summary(self) -> None:
|
|
228
|
+
d = self.data
|
|
229
|
+
(self.has_versioning,) = struct.unpack_from("<I", d, 0)
|
|
230
|
+
(self.header_size,) = struct.unpack_from("<I", d, 4)
|
|
231
|
+
(self.package_flags,) = struct.unpack_from("<I", d, 16)
|
|
232
|
+
(self.cooked_header_size,) = struct.unpack_from("<I", d, 20)
|
|
233
|
+
self.section_offsets = list(struct.unpack_from("<9i", d, SECTION_OFFSETS_AT))
|
|
234
|
+
for i, name in enumerate(SECTION_NAMES):
|
|
235
|
+
setattr(self, name + "_offset", self.section_offsets[i])
|
|
236
|
+
self.import_map_offset = self.section_offsets[1]
|
|
237
|
+
self.export_map_offset = self.section_offsets[2]
|
|
238
|
+
# The export map runs up to wherever the next section starts.
|
|
239
|
+
self.export_map_end = self.section_offsets[3]
|
|
240
|
+
if self.has_versioning:
|
|
241
|
+
raise PackageError("versioned packages are not supported")
|
|
242
|
+
if not 0 < self.header_size <= len(d):
|
|
243
|
+
raise PackageError(
|
|
244
|
+
f"headerSize {self.header_size} outside package of {len(d)} bytes"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# -- name map --------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def _read_names(self) -> None:
|
|
250
|
+
name_map = read_name_map(self.data, NAME_MAP_OFFSET)
|
|
251
|
+
if name_map.end > self.header_size:
|
|
252
|
+
raise PackageError("name map runs past the end of the header")
|
|
253
|
+
(self.name_hash_version,) = struct.unpack_from(
|
|
254
|
+
"<Q", self.data, NAME_MAP_OFFSET + 8
|
|
255
|
+
)
|
|
256
|
+
self.name_map = name_map
|
|
257
|
+
self.names = name_map.names
|
|
258
|
+
self.name_map_end = name_map.end
|
|
259
|
+
|
|
260
|
+
# -- export map ------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
def _read_exports(self) -> None:
|
|
263
|
+
span = self.export_map_end - self.export_map_offset
|
|
264
|
+
if span < 0 or span % EXPORT_ENTRY_SIZE:
|
|
265
|
+
raise PackageError(
|
|
266
|
+
f"export map span {span} is not a whole number of entries"
|
|
267
|
+
)
|
|
268
|
+
self.exports = []
|
|
269
|
+
for i in range(span // EXPORT_ENTRY_SIZE):
|
|
270
|
+
at = self.export_map_offset + i * EXPORT_ENTRY_SIZE
|
|
271
|
+
self.exports.append(Export(*struct.unpack_from("<QQ", self.data, at)))
|
|
272
|
+
|
|
273
|
+
def export_entry_offset(self, index: int) -> int:
|
|
274
|
+
"""Where an export map entry sits, for callers that need to patch it."""
|
|
275
|
+
return self.export_map_offset + index * EXPORT_ENTRY_SIZE
|
|
276
|
+
|
|
277
|
+
# -- sections --------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def section_range(self, name: str) -> tuple[int, int]:
|
|
280
|
+
"""Where one header section starts and stops.
|
|
281
|
+
|
|
282
|
+
A section runs to wherever the next one starts, going by LAYOUT_ORDER
|
|
283
|
+
rather than by the order the summary lists them. Plenty of sections are
|
|
284
|
+
empty, and an empty one shares its offset with the section after it, so
|
|
285
|
+
picking the next largest offset instead would skip a whole section.
|
|
286
|
+
"""
|
|
287
|
+
try:
|
|
288
|
+
i = LAYOUT_ORDER.index(name)
|
|
289
|
+
except ValueError:
|
|
290
|
+
raise PackageError(f"no header section called {name!r}") from None
|
|
291
|
+
start = self.section_offsets[SECTION_NAMES.index(name)]
|
|
292
|
+
if i + 1 == len(LAYOUT_ORDER):
|
|
293
|
+
return start, self.header_size
|
|
294
|
+
nxt = self.section_offsets[SECTION_NAMES.index(LAYOUT_ORDER[i + 1])]
|
|
295
|
+
return start, nxt
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def public_export_hashes(self) -> list[int]:
|
|
299
|
+
"""Export hashes of imported objects, indexed by the import map."""
|
|
300
|
+
start, end = self.section_range("imported_public_export_hashes")
|
|
301
|
+
return list(struct.unpack_from(f"<{(end - start) // 8}Q", self.data, start))
|
|
302
|
+
|
|
303
|
+
@property
|
|
304
|
+
def imports(self) -> list[int]:
|
|
305
|
+
"""The import map, as raw FPackageObjectIndex values."""
|
|
306
|
+
start, end = self.section_range("import_map")
|
|
307
|
+
return list(struct.unpack_from(f"<{(end - start) // 8}Q", self.data, start))
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def imported_package_names(self) -> list[str]:
|
|
311
|
+
"""Names of the packages this one imports, in container order.
|
|
312
|
+
|
|
313
|
+
Runs alongside the imported package ids in the container header, so the
|
|
314
|
+
two have to be kept in step. Worth knowing that the loader resolves by
|
|
315
|
+
id and never reads these: 68 packages out of 2,686 sampled carry a name
|
|
316
|
+
that does not hash to the id sat next to it, left over from a rename,
|
|
317
|
+
and they load fine.
|
|
318
|
+
"""
|
|
319
|
+
return read_name_map(self.data, self.section_offsets[6]).names
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def imported_package_tail(self) -> list[int]:
|
|
323
|
+
"""One uint32 per imported package, tucked behind ImportedPackageNames.
|
|
324
|
+
|
|
325
|
+
The summary has no offset for this, so the only way to find it is to
|
|
326
|
+
parse the name batch and take what is left before headerSize. It came
|
|
327
|
+
out as exactly four bytes per imported package on all 8,000 packages
|
|
328
|
+
sampled, and 97% of the values are zero.
|
|
329
|
+
|
|
330
|
+
What the numbers mean is not something worked out here. They are kept
|
|
331
|
+
as they are, and a new imported package gets a zero, which is what
|
|
332
|
+
every other entry holds in the assets this is used on.
|
|
333
|
+
"""
|
|
334
|
+
end = read_name_map(self.data, self.section_offsets[6]).end
|
|
335
|
+
count = (self.header_size - end) // 4
|
|
336
|
+
return list(struct.unpack_from(f"<{count}I", self.data, end))
|
|
337
|
+
|
|
338
|
+
# -- regions ---------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
def export_data_range(self) -> tuple[int, int]:
|
|
341
|
+
"""Byte range of the export data, everything past the header."""
|
|
342
|
+
return self.header_size, len(self.data)
|
|
343
|
+
|
|
344
|
+
def export_at(self, relative_offset: int) -> int | None:
|
|
345
|
+
"""Which export covers an offset, counting from the header end."""
|
|
346
|
+
for i, e in enumerate(self.exports):
|
|
347
|
+
if e.offset <= relative_offset < e.end:
|
|
348
|
+
return i
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
def __repr__(self) -> str:
|
|
352
|
+
return (
|
|
353
|
+
f"<ZenPackage {len(self.data)} bytes, header={self.header_size}, "
|
|
354
|
+
f"names={len(self.names)}, exports={len(self.exports)}>"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def verify(data: bytes | bytearray) -> ZenPackage:
|
|
359
|
+
"""Parse a package and check it hangs together.
|
|
360
|
+
|
|
361
|
+
Raises `PackageError` on the first problem it finds. Returns the parsed
|
|
362
|
+
package so the caller does not have to do the work twice.
|
|
363
|
+
"""
|
|
364
|
+
pkg = ZenPackage(data)
|
|
365
|
+
|
|
366
|
+
if not pkg.exports:
|
|
367
|
+
raise PackageError("package declares no exports")
|
|
368
|
+
|
|
369
|
+
expected = 0
|
|
370
|
+
for i, e in enumerate(pkg.exports):
|
|
371
|
+
if e.offset != expected:
|
|
372
|
+
raise PackageError(
|
|
373
|
+
f"export {i} starts at {e.offset}, expected {expected} "
|
|
374
|
+
"(exports must be contiguous from 0)"
|
|
375
|
+
)
|
|
376
|
+
expected += e.size
|
|
377
|
+
|
|
378
|
+
total = pkg.header_size + expected
|
|
379
|
+
if total != len(data):
|
|
380
|
+
raise PackageError(
|
|
381
|
+
f"headerSize {pkg.header_size} + exports {expected} = {total}, "
|
|
382
|
+
f"but the package is {len(data)} bytes"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
if pkg.name_hash_version != NAME_HASH_VERSION:
|
|
386
|
+
raise PackageError(f"unexpected name hash version 0x{pkg.name_hash_version:x}")
|
|
387
|
+
|
|
388
|
+
names_end = read_name_map(pkg.data, pkg.section_offsets[6]).end
|
|
389
|
+
tail = pkg.header_size - names_end
|
|
390
|
+
if tail != 4 * len(pkg.imported_package_names):
|
|
391
|
+
raise PackageError(
|
|
392
|
+
f"{tail} bytes sit behind ImportedPackageNames, expected "
|
|
393
|
+
f"{4 * len(pkg.imported_package_names)}, one per imported package"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if any(pkg.section_offsets[i] != pkg.section_offsets[3] for i in (7, 8)):
|
|
397
|
+
raise PackageError("package has Verse cell maps, which this cannot lay out")
|
|
398
|
+
|
|
399
|
+
if not pkg.import_map_offset <= pkg.export_map_offset <= pkg.export_map_end:
|
|
400
|
+
raise PackageError("header section offsets are out of order")
|
|
401
|
+
if pkg.export_map_end > pkg.header_size:
|
|
402
|
+
raise PackageError("export map runs past the end of the header")
|
|
403
|
+
|
|
404
|
+
return pkg
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def splice_section(
|
|
408
|
+
data: bytes | bytearray,
|
|
409
|
+
section: str,
|
|
410
|
+
at: int,
|
|
411
|
+
old_len: int,
|
|
412
|
+
new: bytes,
|
|
413
|
+
) -> bytearray:
|
|
414
|
+
"""Swap `old_len` bytes at `at` for `new`, inside the named section.
|
|
415
|
+
|
|
416
|
+
Header sections are found by absolute offset, so growing one moves every
|
|
417
|
+
section after it. Which sections those are cannot be worked out from the
|
|
418
|
+
offset alone: empty sections share their offset with the section that
|
|
419
|
+
follows, so naming the section being edited is the only way to say whether
|
|
420
|
+
the bytes go before or after it. Sections later in LAYOUT_ORDER shift by
|
|
421
|
+
the size change, the target and everything before it stay put, and
|
|
422
|
+
headerSize moves with the total.
|
|
423
|
+
|
|
424
|
+
Export data is left alone. Export offsets are measured from the end of the
|
|
425
|
+
header rather than the start of the file, so they stay correct by
|
|
426
|
+
themselves.
|
|
427
|
+
|
|
428
|
+
Pass `section="name_map"` for the name map, which sits ahead of all nine
|
|
429
|
+
sections and shifts every one of them.
|
|
430
|
+
"""
|
|
431
|
+
pkg = ZenPackage(data)
|
|
432
|
+
if section == "name_map":
|
|
433
|
+
first_moved, bounds = 0, (NAME_MAP_OFFSET, pkg.name_map_end)
|
|
434
|
+
else:
|
|
435
|
+
try:
|
|
436
|
+
first_moved = LAYOUT_ORDER.index(section) + 1
|
|
437
|
+
except ValueError:
|
|
438
|
+
raise PackageError(f"no header section called {section!r}") from None
|
|
439
|
+
bounds = pkg.section_range(section)
|
|
440
|
+
|
|
441
|
+
end = at + old_len
|
|
442
|
+
if not bounds[0] <= at <= end <= bounds[1]:
|
|
443
|
+
raise PackageError(
|
|
444
|
+
f"splice at {at}..{end} is outside {section} ({bounds[0]}..{bounds[1]})"
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
delta = len(new) - old_len
|
|
448
|
+
out = bytearray(data)
|
|
449
|
+
out[at:end] = new
|
|
450
|
+
if delta:
|
|
451
|
+
moved = {SECTION_NAMES.index(n) for n in LAYOUT_ORDER[first_moved:]}
|
|
452
|
+
for i in moved:
|
|
453
|
+
struct.pack_into(
|
|
454
|
+
"<i", out, SECTION_OFFSETS_AT + i * 4, pkg.section_offsets[i] + delta
|
|
455
|
+
)
|
|
456
|
+
struct.pack_into("<I", out, 4, pkg.header_size + delta)
|
|
457
|
+
return out
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def append_to_section(data: bytes | bytearray, section: str, new: bytes) -> bytearray:
|
|
461
|
+
"""Stick bytes on the end of a header section."""
|
|
462
|
+
if section == "name_map":
|
|
463
|
+
at = ZenPackage(data).name_map_end
|
|
464
|
+
else:
|
|
465
|
+
at = ZenPackage(data).section_range(section)[1]
|
|
466
|
+
return splice_section(data, section, at, 0, new)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def renumber_imported_packages(
|
|
470
|
+
data: bytes | bytearray, first: int, delta: int
|
|
471
|
+
) -> bytearray:
|
|
472
|
+
"""Shift the imported-package index of every reference at or past `first`.
|
|
473
|
+
|
|
474
|
+
Adding a package to the container's import list moves everything after it
|
|
475
|
+
along, and each FPackageObjectIndex holds that position rather than a
|
|
476
|
+
package id. Both the import map and the export map carry these, so both
|
|
477
|
+
get walked. Miss the export map and a class or template quietly resolves to
|
|
478
|
+
the wrong asset.
|
|
479
|
+
"""
|
|
480
|
+
pkg = ZenPackage(data)
|
|
481
|
+
out = bytearray(data)
|
|
482
|
+
|
|
483
|
+
def fix(at: int) -> None:
|
|
484
|
+
(value,) = struct.unpack_from("<Q", out, at)
|
|
485
|
+
kind, package_index, hash_index = split_import(value)
|
|
486
|
+
if kind == IMPORT_PACKAGE and package_index >= first:
|
|
487
|
+
struct.pack_into(
|
|
488
|
+
"<Q", out, at, package_import(package_index + delta, hash_index)
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
import_start, import_end = pkg.section_range("import_map")
|
|
492
|
+
for at in range(import_start, import_end, 8):
|
|
493
|
+
fix(at)
|
|
494
|
+
for i in range(len(pkg.exports)):
|
|
495
|
+
base = pkg.export_entry_offset(i)
|
|
496
|
+
for field in EXPORT_REF_FIELDS:
|
|
497
|
+
fix(base + field)
|
|
498
|
+
return out
|
zcmodkit/game.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Finding where Zero Company is installed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_RELATIVE_PAKS = Path("SWZeroCompany") / "Content" / "Paks"
|
|
9
|
+
|
|
10
|
+
_COMMON_ROOTS = [
|
|
11
|
+
Path(r"C:\Program Files (x86)\Steam\steamapps\common\Star Wars Zero Company"),
|
|
12
|
+
Path(r"C:\Program Files\Epic Games\Star Wars Zero Company"),
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _steam_libraries() -> list[Path]:
|
|
17
|
+
"""Check Steam's libraryfolders.vdf for games on other drives."""
|
|
18
|
+
vdf = Path(r"C:\Program Files (x86)\Steam\steamapps\libraryfolders.vdf")
|
|
19
|
+
out: list[Path] = []
|
|
20
|
+
if not vdf.is_file():
|
|
21
|
+
return out
|
|
22
|
+
for line in vdf.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
23
|
+
if '"path"' in line:
|
|
24
|
+
parts = line.split('"')
|
|
25
|
+
if len(parts) >= 4:
|
|
26
|
+
out.append(
|
|
27
|
+
Path(parts[3]) / "steamapps" / "common" / "Star Wars Zero Company"
|
|
28
|
+
)
|
|
29
|
+
return out
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def find_install(explicit: str | os.PathLike | None = None) -> Path:
|
|
33
|
+
"""The game root, meaning the folder that holds SWZeroCompany/."""
|
|
34
|
+
if explicit:
|
|
35
|
+
root = Path(explicit)
|
|
36
|
+
if not (root / _RELATIVE_PAKS).is_dir():
|
|
37
|
+
raise FileNotFoundError(f"No {_RELATIVE_PAKS} under {root}")
|
|
38
|
+
return root
|
|
39
|
+
env = os.environ.get("ZCMODKIT_GAME")
|
|
40
|
+
candidates = ([Path(env)] if env else []) + _COMMON_ROOTS + _steam_libraries()
|
|
41
|
+
for root in candidates:
|
|
42
|
+
if (root / _RELATIVE_PAKS).is_dir():
|
|
43
|
+
return root
|
|
44
|
+
raise FileNotFoundError(
|
|
45
|
+
"Could not find Star Wars Zero Company. Pass the path explicitly, "
|
|
46
|
+
"e.g. zcmodkit.open(r'D:/Games/Star Wars Zero Company')."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def paks_dir(root: Path) -> Path:
|
|
51
|
+
"""Where the Paks live under a game root."""
|
|
52
|
+
return root / _RELATIVE_PAKS
|
zcmodkit/mod.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""The Mod object. Collect up edits, then build or install."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .domains import AssetEditor, DataTable
|
|
9
|
+
from .formats import IoStoreWriter, package_id
|
|
10
|
+
from .formats.zen import PackageError
|
|
11
|
+
from .formats.zen import verify as zen_verify
|
|
12
|
+
|
|
13
|
+
#: What a mod gets if it does not ask for a priority. Sits in the middle so
|
|
14
|
+
#: other mods can be pushed either side of it.
|
|
15
|
+
DEFAULT_PRIORITY = 100
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Mod:
|
|
19
|
+
"""One mod. Edit some assets, then build or install it."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, kit, name: str, priority: int = DEFAULT_PRIORITY):
|
|
22
|
+
"""`priority` runs 0 to 999. Lower wins when two mods touch one asset."""
|
|
23
|
+
self.kit = kit
|
|
24
|
+
self.name = name
|
|
25
|
+
self.priority = priority
|
|
26
|
+
self._assets: dict[str, AssetEditor] = {}
|
|
27
|
+
self._tables: dict[str, DataTable] = {}
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def basename(self) -> str:
|
|
31
|
+
"""The name all three files share, with the priority in front.
|
|
32
|
+
|
|
33
|
+
The engine mounts containers in reverse filename order, so the lowest
|
|
34
|
+
number goes on last and wins. Checked in game with 000_ and 999_ both
|
|
35
|
+
editing the same asset, and 000_ was the one that took.
|
|
36
|
+
"""
|
|
37
|
+
return f"{self.priority:03d}_{self.name}_P"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def changes(self) -> int:
|
|
41
|
+
return sum(a.changes for a in self._assets.values()) + sum(
|
|
42
|
+
t.changes for t in self._tables.values()
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def asset(self, package_path: str) -> AssetEditor:
|
|
46
|
+
"""Open a cooked asset for editing, by its /Game/... package path."""
|
|
47
|
+
if package_path not in self._assets:
|
|
48
|
+
self._assets[package_path] = AssetEditor(
|
|
49
|
+
package_path,
|
|
50
|
+
self.kit.read_package(package_path),
|
|
51
|
+
self.kit.imported_packages(package_path),
|
|
52
|
+
)
|
|
53
|
+
return self._assets[package_path]
|
|
54
|
+
|
|
55
|
+
def table(self, package_path: str) -> DataTable:
|
|
56
|
+
"""Open a cooked DataTable for editing, by its /Game/... package path."""
|
|
57
|
+
if package_path not in self._tables:
|
|
58
|
+
self._tables[package_path] = DataTable(
|
|
59
|
+
package_path, bytearray(self.kit.read_package(package_path))
|
|
60
|
+
)
|
|
61
|
+
return self._tables[package_path]
|
|
62
|
+
|
|
63
|
+
def build(
|
|
64
|
+
self, out_dir: str | Path = "./build", verify: bool = False
|
|
65
|
+
) -> list[Path]:
|
|
66
|
+
"""Write the mod's three files into `out_dir`.
|
|
67
|
+
|
|
68
|
+
Pass `verify=True` to parse every edited package again and check it
|
|
69
|
+
first. A broken one raises instead of getting shipped.
|
|
70
|
+
"""
|
|
71
|
+
edits: dict[str, bytes] = {p: bytes(a.data) for p, a in self._assets.items()}
|
|
72
|
+
edits.update({p: bytes(t.data) for p, t in self._tables.items()})
|
|
73
|
+
# add_import can grow an asset's import list, so take it from the
|
|
74
|
+
# editor where there is one rather than going back to the game.
|
|
75
|
+
imports = {p: a.imported_packages for p, a in self._assets.items()}
|
|
76
|
+
if not edits:
|
|
77
|
+
raise ValueError("Mod has no changes; nothing to build.")
|
|
78
|
+
writer = IoStoreWriter(container_id=package_id(f"/zcmodkit/{self.name}"))
|
|
79
|
+
for path, data in edits.items():
|
|
80
|
+
if verify:
|
|
81
|
+
try:
|
|
82
|
+
zen_verify(data)
|
|
83
|
+
except PackageError as exc:
|
|
84
|
+
raise PackageError(f"{path}: {exc}") from exc
|
|
85
|
+
writer.add_package(
|
|
86
|
+
path,
|
|
87
|
+
data,
|
|
88
|
+
companions=self.kit.read_companions(path),
|
|
89
|
+
imports=imports.get(path) or self.kit.imported_packages(path),
|
|
90
|
+
)
|
|
91
|
+
return list(writer.write(Path(out_dir) / self.basename))
|
|
92
|
+
|
|
93
|
+
def install(self) -> list[Path]:
|
|
94
|
+
"""Build the mod and drop it into the game's ~mods folder."""
|
|
95
|
+
built = self.build(Path.home() / ".zcmodkit" / "build")
|
|
96
|
+
dest_dir = self.kit.paks / "~mods"
|
|
97
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
out = []
|
|
99
|
+
for f in built:
|
|
100
|
+
dest = dest_dir / f.name
|
|
101
|
+
shutil.copy2(f, dest)
|
|
102
|
+
out.append(dest)
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
def uninstall(self) -> int:
|
|
106
|
+
"""Take this mod back out of the game. Says how many files went."""
|
|
107
|
+
n = 0
|
|
108
|
+
for ext in (".pak", ".utoc", ".ucas"):
|
|
109
|
+
f = self.kit.paks / "~mods" / (self.basename + ext)
|
|
110
|
+
if f.is_file():
|
|
111
|
+
f.unlink()
|
|
112
|
+
n += 1
|
|
113
|
+
return n
|
|
114
|
+
|
|
115
|
+
def __repr__(self) -> str:
|
|
116
|
+
n = len(self._assets) + len(self._tables)
|
|
117
|
+
return f"<Mod {self.name!r} assets={n} changes={self.changes}>"
|