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
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""Reader and writer for UE5 IoStore containers (.utoc and .ucas).
|
|
2
|
+
|
|
3
|
+
Zero Company ships every package in IoStore and will only mount mods that are
|
|
4
|
+
IoStore containers too, so this is the format mods have to be written in.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import struct
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from blake3 import blake3
|
|
16
|
+
from cityhash import CityHash64
|
|
17
|
+
|
|
18
|
+
from . import oodle
|
|
19
|
+
from .pak import PakWriter
|
|
20
|
+
|
|
21
|
+
TOC_MAGIC = b"-==--==--==--==-"
|
|
22
|
+
HEADER_SIZE = 144
|
|
23
|
+
DEFAULT_BLOCK_SIZE = 64 * 1024
|
|
24
|
+
MOUNT_POINT = "../../../"
|
|
25
|
+
|
|
26
|
+
# EIoChunkType5
|
|
27
|
+
CHUNK_EXPORT_BUNDLE_DATA = 1
|
|
28
|
+
CHUNK_CONTAINER_HEADER = 6
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def package_id(package_path: str) -> int:
|
|
32
|
+
"""FPackageId. CityHash64 of the lowercased package name as UTF-16."""
|
|
33
|
+
return CityHash64(package_path.lower().encode("utf-16-le"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def chunk_id(
|
|
37
|
+
pkg_id: int, index: int = 0, chunk_type: int = CHUNK_EXPORT_BUNDLE_DATA
|
|
38
|
+
) -> bytes:
|
|
39
|
+
"""FIoChunkId. An 8-byte package id, a 2-byte index, a pad byte, a type."""
|
|
40
|
+
return struct.pack("<QHBB", pkg_id, index, 0, chunk_type)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def chunk_hash(data: bytes) -> bytes:
|
|
44
|
+
"""FIoChunkHash. BLAKE3-160, padded out to 24 bytes with zeroes."""
|
|
45
|
+
return blake3(data).digest(length=32)[:20] + bytes(4)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Chunk:
|
|
50
|
+
"""One chunk inside a container."""
|
|
51
|
+
|
|
52
|
+
id: bytes
|
|
53
|
+
offset: int
|
|
54
|
+
length: int
|
|
55
|
+
name: str | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def package_id(self) -> int:
|
|
59
|
+
return struct.unpack_from("<Q", self.id, 0)[0]
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def index(self) -> int:
|
|
63
|
+
"""Tells apart several chunks of the same type on one package."""
|
|
64
|
+
return struct.unpack_from("<H", self.id, 8)[0]
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def type(self) -> int:
|
|
68
|
+
return self.id[11]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class TocHeader:
|
|
73
|
+
version: int = 8
|
|
74
|
+
entry_count: int = 0
|
|
75
|
+
block_count: int = 0
|
|
76
|
+
block_entry_size: int = 12
|
|
77
|
+
method_count: int = 0
|
|
78
|
+
method_name_len: int = 32
|
|
79
|
+
block_size: int = DEFAULT_BLOCK_SIZE
|
|
80
|
+
dir_index_size: int = 0
|
|
81
|
+
partition_count: int = 1
|
|
82
|
+
container_id: int = 0
|
|
83
|
+
container_flags: int = 8
|
|
84
|
+
perfect_hash_seed_count: int = 0
|
|
85
|
+
partition_size: int = 0xFFFFFFFFFFFFFFFF
|
|
86
|
+
without_perfect_hash_count: int = 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _rstr(b: io.BytesIO) -> str:
|
|
90
|
+
(n,) = struct.unpack("<i", b.read(4))
|
|
91
|
+
if n == 0:
|
|
92
|
+
return ""
|
|
93
|
+
if n < 0:
|
|
94
|
+
return b.read(-n * 2).decode("utf-16-le").rstrip(chr(0))
|
|
95
|
+
return b.read(n).decode("latin-1").rstrip(chr(0))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _wstr(s: str) -> bytes:
|
|
99
|
+
raw = s.encode("latin-1") + bytes([0])
|
|
100
|
+
return struct.pack("<i", len(raw)) + raw
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class IoStoreReader:
|
|
104
|
+
"""Reads chunks out of a .utoc and .ucas pair."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, utoc: str | Path):
|
|
107
|
+
self.utoc_path = Path(utoc)
|
|
108
|
+
self.ucas_path = self.utoc_path.with_suffix(".ucas")
|
|
109
|
+
raw = self.utoc_path.read_bytes()
|
|
110
|
+
if raw[:16] != TOC_MAGIC:
|
|
111
|
+
raise ValueError(f"{self.utoc_path.name}: not a .utoc")
|
|
112
|
+
self._parse(raw)
|
|
113
|
+
self._ucas = open(self.ucas_path, "rb") # noqa: SIM115, closed by close()
|
|
114
|
+
|
|
115
|
+
def close(self) -> None:
|
|
116
|
+
self._ucas.close()
|
|
117
|
+
|
|
118
|
+
def __enter__(self):
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def __exit__(self, *exc):
|
|
122
|
+
self.close()
|
|
123
|
+
|
|
124
|
+
def _parse(self, raw: bytes) -> None:
|
|
125
|
+
h = TocHeader()
|
|
126
|
+
h.version = raw[16]
|
|
127
|
+
(
|
|
128
|
+
hdr_size,
|
|
129
|
+
h.entry_count,
|
|
130
|
+
h.block_count,
|
|
131
|
+
h.block_entry_size,
|
|
132
|
+
h.method_count,
|
|
133
|
+
h.method_name_len,
|
|
134
|
+
h.block_size,
|
|
135
|
+
h.dir_index_size,
|
|
136
|
+
h.partition_count,
|
|
137
|
+
) = struct.unpack_from("<9I", raw, 20)
|
|
138
|
+
(h.container_id,) = struct.unpack_from("<Q", raw, 56)
|
|
139
|
+
h.container_flags = raw[80]
|
|
140
|
+
(h.perfect_hash_seed_count,) = struct.unpack_from("<I", raw, 84)
|
|
141
|
+
(h.partition_size,) = struct.unpack_from("<Q", raw, 88)
|
|
142
|
+
(h.without_perfect_hash_count,) = struct.unpack_from("<I", raw, 96)
|
|
143
|
+
self.header = h
|
|
144
|
+
|
|
145
|
+
p = hdr_size
|
|
146
|
+
ids = [raw[p + i * 12 : p + (i + 1) * 12] for i in range(h.entry_count)]
|
|
147
|
+
p += h.entry_count * 12
|
|
148
|
+
self.chunks: list[Chunk] = []
|
|
149
|
+
for i in range(h.entry_count):
|
|
150
|
+
ol = raw[p + i * 10 : p + (i + 1) * 10]
|
|
151
|
+
self.chunks.append(
|
|
152
|
+
Chunk(
|
|
153
|
+
ids[i],
|
|
154
|
+
int.from_bytes(ol[0:5], "big"),
|
|
155
|
+
int.from_bytes(ol[5:10], "big"),
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
p += h.entry_count * 10
|
|
159
|
+
p += h.perfect_hash_seed_count * 4
|
|
160
|
+
p += h.without_perfect_hash_count * 4
|
|
161
|
+
|
|
162
|
+
self.blocks: list[tuple[int, int, int, int]] = []
|
|
163
|
+
for i in range(h.block_count):
|
|
164
|
+
b = raw[p + i * 12 : p + (i + 1) * 12]
|
|
165
|
+
self.blocks.append(
|
|
166
|
+
(
|
|
167
|
+
int.from_bytes(b[0:5], "little"),
|
|
168
|
+
int.from_bytes(b[5:8], "little"),
|
|
169
|
+
int.from_bytes(b[8:11], "little"),
|
|
170
|
+
b[11],
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
p += h.block_count * 12
|
|
174
|
+
|
|
175
|
+
self.methods = ["None"]
|
|
176
|
+
for _ in range(h.method_count):
|
|
177
|
+
name = raw[p : p + h.method_name_len].split(bytes([0]))[0].decode("latin-1")
|
|
178
|
+
self.methods.append(name)
|
|
179
|
+
p += h.method_name_len
|
|
180
|
+
|
|
181
|
+
self.mount_point = MOUNT_POINT
|
|
182
|
+
self.files: dict[str, int] = {}
|
|
183
|
+
if h.dir_index_size:
|
|
184
|
+
self._parse_dir_index(raw[p : p + h.dir_index_size])
|
|
185
|
+
p += h.dir_index_size
|
|
186
|
+
self._meta_offset = p
|
|
187
|
+
|
|
188
|
+
self.by_package: dict[int, Chunk] = {}
|
|
189
|
+
self._all_by_package: dict[int, list[Chunk]] = {}
|
|
190
|
+
self._store: dict[int, list[int]] | None = None
|
|
191
|
+
for c in self.chunks:
|
|
192
|
+
if c.type == CHUNK_CONTAINER_HEADER:
|
|
193
|
+
continue
|
|
194
|
+
self._all_by_package.setdefault(c.package_id, []).append(c)
|
|
195
|
+
if c.type == CHUNK_EXPORT_BUNDLE_DATA:
|
|
196
|
+
self.by_package.setdefault(c.package_id, c)
|
|
197
|
+
|
|
198
|
+
def imported_packages(self, pkg_id: int) -> list[int]:
|
|
199
|
+
"""The package ids this package depends on, from the container header.
|
|
200
|
+
|
|
201
|
+
The Zen loader uses this to pull dependencies in first. Ship a package
|
|
202
|
+
claiming no imports when it has some and it quietly fails to load, so a
|
|
203
|
+
mod has to carry the real list through.
|
|
204
|
+
"""
|
|
205
|
+
if self._store is None:
|
|
206
|
+
self._store = self._read_store_entries()
|
|
207
|
+
return list(self._store.get(pkg_id, ()))
|
|
208
|
+
|
|
209
|
+
def _read_store_entries(self) -> dict[int, list[int]]:
|
|
210
|
+
header = next(
|
|
211
|
+
(c for c in self.chunks if c.type == CHUNK_CONTAINER_HEADER), None
|
|
212
|
+
)
|
|
213
|
+
if header is None:
|
|
214
|
+
return {}
|
|
215
|
+
d = self.read_chunk(header)
|
|
216
|
+
(count,) = struct.unpack_from("<I", d, 16)
|
|
217
|
+
ids = struct.unpack_from(f"<{count}Q", d, 20)
|
|
218
|
+
entries_at = 20 + count * 8 + 4
|
|
219
|
+
out: dict[int, list[int]] = {}
|
|
220
|
+
for i, pid in enumerate(ids):
|
|
221
|
+
at = entries_at + i * 16
|
|
222
|
+
num, offset = struct.unpack_from("<ii", d, at)
|
|
223
|
+
if num:
|
|
224
|
+
out[pid] = list(struct.unpack_from(f"<{num}Q", d, at + offset))
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
def chunks_for(self, pkg_id: int) -> list[Chunk]:
|
|
228
|
+
"""Every chunk a package owns, not just its export data.
|
|
229
|
+
|
|
230
|
+
About a quarter of this game's packages come with a bulk data chunk
|
|
231
|
+
alongside. Ship the package without it and the asset is broken.
|
|
232
|
+
"""
|
|
233
|
+
return list(self._all_by_package.get(pkg_id, ()))
|
|
234
|
+
|
|
235
|
+
def _parse_dir_index(self, blob: bytes) -> None:
|
|
236
|
+
b = io.BytesIO(blob)
|
|
237
|
+
self.mount_point = _rstr(b)
|
|
238
|
+
(ndirs,) = struct.unpack("<i", b.read(4))
|
|
239
|
+
dirs = [struct.unpack("<4I", b.read(16)) for _ in range(ndirs)]
|
|
240
|
+
(nfiles,) = struct.unpack("<i", b.read(4))
|
|
241
|
+
files = [struct.unpack("<3I", b.read(12)) for _ in range(nfiles)]
|
|
242
|
+
(nstr,) = struct.unpack("<i", b.read(4))
|
|
243
|
+
strings = [_rstr(b) for _ in range(nstr)]
|
|
244
|
+
|
|
245
|
+
INVALID = 0xFFFFFFFF
|
|
246
|
+
|
|
247
|
+
def walk(dir_idx: int, prefix: str) -> None:
|
|
248
|
+
while dir_idx != INVALID:
|
|
249
|
+
name, first_child, next_sib, first_file = dirs[dir_idx]
|
|
250
|
+
path = prefix if name == INVALID else prefix + strings[name] + "/"
|
|
251
|
+
fi = first_file
|
|
252
|
+
while fi != INVALID:
|
|
253
|
+
fname, next_file, user = files[fi]
|
|
254
|
+
self.files[path + strings[fname]] = user
|
|
255
|
+
fi = next_file
|
|
256
|
+
if first_child != INVALID:
|
|
257
|
+
walk(first_child, path)
|
|
258
|
+
dir_idx = next_sib
|
|
259
|
+
|
|
260
|
+
if dirs:
|
|
261
|
+
walk(0, "")
|
|
262
|
+
|
|
263
|
+
def read_chunk(self, chunk: Chunk) -> bytes:
|
|
264
|
+
"""Read one chunk, decompressing it if it needs it."""
|
|
265
|
+
bs = self.header.block_size
|
|
266
|
+
first = chunk.offset // bs
|
|
267
|
+
last = (chunk.offset + chunk.length - 1) // bs
|
|
268
|
+
out = bytearray()
|
|
269
|
+
for i in range(first, last + 1):
|
|
270
|
+
off, csize, usize, method = self.blocks[i]
|
|
271
|
+
self._ucas.seek(off)
|
|
272
|
+
raw = self._ucas.read(csize)
|
|
273
|
+
out += raw if method == 0 else oodle.decompress(raw, usize)
|
|
274
|
+
start = chunk.offset - first * bs
|
|
275
|
+
return bytes(out[start : start + chunk.length])
|
|
276
|
+
|
|
277
|
+
def read_package(self, package_path: str) -> bytes:
|
|
278
|
+
"""Read a cooked package by its /Game/... path."""
|
|
279
|
+
c = self.by_package.get(package_id(package_path))
|
|
280
|
+
if c is None:
|
|
281
|
+
raise KeyError(package_path)
|
|
282
|
+
return self.read_chunk(c)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# The rest of FIoContainerHeader after the store entries. Optional segment
|
|
286
|
+
# arrays, then an offset into the trailing bytes, then constants. The offset is
|
|
287
|
+
# always 36 past the end of the store section, checked against every container
|
|
288
|
+
# the game ships and against a working community mod.
|
|
289
|
+
_TAIL_OFFSET_FROM_STORE_END = 36
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _header_tail(store_end: int) -> bytes:
|
|
293
|
+
"""The bytes that follow the store entries in a container header."""
|
|
294
|
+
out = bytearray(20) # empty optional-segment arrays
|
|
295
|
+
out += struct.pack("<i", store_end + _TAIL_OFFSET_FROM_STORE_END)
|
|
296
|
+
out += struct.pack("<i", 0)
|
|
297
|
+
out += struct.pack("<i", 4)
|
|
298
|
+
out += bytes(16)
|
|
299
|
+
return bytes(out)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def container_header(
|
|
303
|
+
container_id: int, packages: Sequence[tuple[int, Sequence[int]]]
|
|
304
|
+
) -> bytes:
|
|
305
|
+
"""Build the type 6 container header chunk.
|
|
306
|
+
|
|
307
|
+
`packages` is (package id, imported package ids) for each package. The
|
|
308
|
+
imports matter: the loader reads them to pull dependencies in first, and a
|
|
309
|
+
package that claims none when it has some will not load.
|
|
310
|
+
"""
|
|
311
|
+
# The loader looks packages up by binary search, so the ids have to be in
|
|
312
|
+
# ascending order and the store entries have to line up with them. Every
|
|
313
|
+
# container the game ships is sorted this way.
|
|
314
|
+
packages = sorted(packages, key=lambda item: item[0])
|
|
315
|
+
|
|
316
|
+
out = bytearray(b"nCoI")
|
|
317
|
+
out += struct.pack("<I", 5) # version
|
|
318
|
+
out += struct.pack("<Q", container_id)
|
|
319
|
+
out += struct.pack("<I", len(packages))
|
|
320
|
+
for pid, _ in packages:
|
|
321
|
+
out += struct.pack("<Q", pid)
|
|
322
|
+
|
|
323
|
+
# Each FFilePackageStoreEntry is two array views of (count, offset), and the
|
|
324
|
+
# offset is measured from the view itself. The arrays they point at follow
|
|
325
|
+
# the entries.
|
|
326
|
+
entries = bytearray()
|
|
327
|
+
data = bytearray()
|
|
328
|
+
entries_size = len(packages) * 16
|
|
329
|
+
for i, (_, imports) in enumerate(packages):
|
|
330
|
+
view_at = i * 16
|
|
331
|
+
if imports:
|
|
332
|
+
offset = entries_size + len(data) - view_at
|
|
333
|
+
entries += struct.pack("<ii", len(imports), offset)
|
|
334
|
+
for imported in imports:
|
|
335
|
+
data += struct.pack("<Q", imported)
|
|
336
|
+
else:
|
|
337
|
+
entries += struct.pack("<ii", 0, 0)
|
|
338
|
+
entries += struct.pack("<ii", 0, 0) # no shader map hashes
|
|
339
|
+
|
|
340
|
+
store = bytes(entries) + bytes(data)
|
|
341
|
+
out += struct.pack("<I", len(store)) + store
|
|
342
|
+
out += _header_tail(len(out))
|
|
343
|
+
return bytes(out)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _stub_pak() -> bytes:
|
|
347
|
+
"""An empty .pak. The engine needs one next to the container to spot the mod."""
|
|
348
|
+
import tempfile
|
|
349
|
+
|
|
350
|
+
import repak
|
|
351
|
+
|
|
352
|
+
with tempfile.TemporaryDirectory() as td:
|
|
353
|
+
out = Path(td) / "stub.pak"
|
|
354
|
+
w = repak.PakBuilder().writer(
|
|
355
|
+
str(out), version=repak.Version.V11, mount_point=MOUNT_POINT
|
|
356
|
+
)
|
|
357
|
+
w.write_index()
|
|
358
|
+
return out.read_bytes()
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class IoStoreWriter:
|
|
362
|
+
"""Builds a .utoc and .ucas pair, plus the stub .pak that gets it noticed."""
|
|
363
|
+
|
|
364
|
+
def __init__(
|
|
365
|
+
self,
|
|
366
|
+
container_id: int | None = None,
|
|
367
|
+
mount_point: str = MOUNT_POINT,
|
|
368
|
+
block_size: int = DEFAULT_BLOCK_SIZE,
|
|
369
|
+
):
|
|
370
|
+
self.container_id = container_id if container_id is not None else 0
|
|
371
|
+
self.mount_point = mount_point
|
|
372
|
+
self.block_size = block_size
|
|
373
|
+
#: (chunk id, payload, directory-index name or None)
|
|
374
|
+
self._entries: list[tuple[bytes, bytes, str | None]] = []
|
|
375
|
+
self._packages: list[tuple[int, Sequence[int]]] = []
|
|
376
|
+
|
|
377
|
+
def add_package(
|
|
378
|
+
self,
|
|
379
|
+
package_path: str,
|
|
380
|
+
data: bytes,
|
|
381
|
+
filename: str | None = None,
|
|
382
|
+
companions: Sequence[tuple[bytes, bytes]] = (),
|
|
383
|
+
imports: Sequence[int] = (),
|
|
384
|
+
) -> None:
|
|
385
|
+
"""Add a cooked package under its /Game/... path.
|
|
386
|
+
|
|
387
|
+
`companions` is any other chunk the package owns, usually bulk data,
|
|
388
|
+
as (chunk id, payload) pairs. Ship them. Leave them out and the game
|
|
389
|
+
gets a package whose bulk data has gone missing.
|
|
390
|
+
|
|
391
|
+
`imports` is the package ids this one depends on, copied from the game's
|
|
392
|
+
own container header. Leave them out and the package will not load.
|
|
393
|
+
"""
|
|
394
|
+
pid = package_id(package_path)
|
|
395
|
+
name = filename or package_path.rsplit("/", 1)[-1] + ".uasset"
|
|
396
|
+
self._packages.append((pid, tuple(imports)))
|
|
397
|
+
self._entries.append((chunk_id(pid), data, name))
|
|
398
|
+
for cid, payload in companions:
|
|
399
|
+
self._entries.append((cid, payload, None))
|
|
400
|
+
|
|
401
|
+
def _directory_index(self, files: list[tuple[str, int]]) -> bytes:
|
|
402
|
+
"""Index of (file name, chunk index). Only named entries go in."""
|
|
403
|
+
INVALID = 0xFFFFFFFF
|
|
404
|
+
out = bytearray(_wstr(self.mount_point))
|
|
405
|
+
out += struct.pack("<i", 1)
|
|
406
|
+
out += struct.pack("<4I", INVALID, INVALID, INVALID, 0) # root directory
|
|
407
|
+
out += struct.pack("<i", len(files))
|
|
408
|
+
for i, (_, chunk_index) in enumerate(files):
|
|
409
|
+
nxt = i + 1 if i + 1 < len(files) else INVALID
|
|
410
|
+
out += struct.pack("<3I", i, nxt, chunk_index) # name, next, chunk
|
|
411
|
+
out += struct.pack("<i", len(files))
|
|
412
|
+
for name, _ in files:
|
|
413
|
+
out += _wstr(name)
|
|
414
|
+
return bytes(out)
|
|
415
|
+
|
|
416
|
+
def write(
|
|
417
|
+
self, base: str | Path, pak_files: dict[str, bytes] | None = None
|
|
418
|
+
) -> tuple[Path, Path, Path]:
|
|
419
|
+
"""Write out .utoc, .ucas and .pak, and give back the three paths.
|
|
420
|
+
|
|
421
|
+
`pak_files` puts real files in the .pak that normally goes out empty.
|
|
422
|
+
Assets belong in the container, not here, but a few things the engine
|
|
423
|
+
wants are plain files rather than packages.
|
|
424
|
+
"""
|
|
425
|
+
base = Path(base)
|
|
426
|
+
base.parent.mkdir(parents=True, exist_ok=True)
|
|
427
|
+
bs = self.block_size
|
|
428
|
+
|
|
429
|
+
payloads = [d for _, d, _ in self._entries]
|
|
430
|
+
payloads.append(container_header(self.container_id, self._packages))
|
|
431
|
+
ids = [cid for cid, _, _ in self._entries]
|
|
432
|
+
ids.append(chunk_id(self.container_id, 0, CHUNK_CONTAINER_HEADER))
|
|
433
|
+
|
|
434
|
+
ucas = bytearray()
|
|
435
|
+
chunks: list[tuple[int, int]] = []
|
|
436
|
+
blocks: list[tuple[int, int, int, int]] = []
|
|
437
|
+
logical = 0
|
|
438
|
+
for data in payloads:
|
|
439
|
+
chunks.append((logical, len(data)))
|
|
440
|
+
for start in range(0, max(len(data), 1), bs):
|
|
441
|
+
part = data[start : start + bs]
|
|
442
|
+
blocks.append((len(ucas), len(part), len(part), 0))
|
|
443
|
+
ucas += part
|
|
444
|
+
logical += ((len(data) + bs - 1) // bs) * bs
|
|
445
|
+
|
|
446
|
+
dir_index = self._directory_index(
|
|
447
|
+
[(n, i) for i, (_, _, n) in enumerate(self._entries) if n is not None]
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
toc = bytearray(TOC_MAGIC)
|
|
451
|
+
toc += bytes([8, 0, 0, 0])
|
|
452
|
+
toc += struct.pack(
|
|
453
|
+
"<9I",
|
|
454
|
+
HEADER_SIZE,
|
|
455
|
+
len(payloads),
|
|
456
|
+
len(blocks),
|
|
457
|
+
12,
|
|
458
|
+
0,
|
|
459
|
+
32,
|
|
460
|
+
bs,
|
|
461
|
+
len(dir_index),
|
|
462
|
+
1,
|
|
463
|
+
)
|
|
464
|
+
toc += struct.pack("<Q", self.container_id)
|
|
465
|
+
toc += bytes(16) # encryption key guid
|
|
466
|
+
toc += bytes([8, 0, 0, 0]) # container flags
|
|
467
|
+
toc += struct.pack("<I", 0) # perfect hash seeds
|
|
468
|
+
toc += struct.pack("<Q", 0xFFFFFFFFFFFFFFFF) # partition size
|
|
469
|
+
toc += struct.pack("<I", 0) + struct.pack("<I", 0)
|
|
470
|
+
toc += bytes(HEADER_SIZE - len(toc))
|
|
471
|
+
for cid in ids:
|
|
472
|
+
toc += cid
|
|
473
|
+
for off, ln in chunks:
|
|
474
|
+
toc += off.to_bytes(5, "big") + ln.to_bytes(5, "big")
|
|
475
|
+
for off, csize, usize, method in blocks:
|
|
476
|
+
toc += (
|
|
477
|
+
off.to_bytes(5, "little")
|
|
478
|
+
+ csize.to_bytes(3, "little")
|
|
479
|
+
+ usize.to_bytes(3, "little")
|
|
480
|
+
+ bytes([method])
|
|
481
|
+
)
|
|
482
|
+
toc += dir_index
|
|
483
|
+
for data in payloads:
|
|
484
|
+
toc += chunk_hash(data)
|
|
485
|
+
|
|
486
|
+
utoc, ucas_p = base.with_suffix(".utoc"), base.with_suffix(".ucas")
|
|
487
|
+
utoc.write_bytes(bytes(toc))
|
|
488
|
+
ucas_p.write_bytes(bytes(ucas))
|
|
489
|
+
pak = base.with_suffix(".pak")
|
|
490
|
+
if pak_files:
|
|
491
|
+
builder = PakWriter()
|
|
492
|
+
for name, blob in pak_files.items():
|
|
493
|
+
builder.add(name, blob)
|
|
494
|
+
builder.write(pak)
|
|
495
|
+
else:
|
|
496
|
+
pak.write_bytes(_stub_pak())
|
|
497
|
+
return utoc, ucas_p, pak
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Read and edit Unreal .locres text tables.
|
|
2
|
+
|
|
3
|
+
Edits copy the original index bytes through untouched, every namespace, key and
|
|
4
|
+
hash of them. Only the string array on the end gets rebuilt, and a key is moved
|
|
5
|
+
by patching its 4-byte string index. That way none of this depends on knowing
|
|
6
|
+
Unreal's string hash.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import struct
|
|
13
|
+
|
|
14
|
+
MAGIC = bytes.fromhex("0e147475674a03fc4a15909dc3377f1b")
|
|
15
|
+
NUL = bytes([0])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _rstr(st: io.BytesIO) -> str:
|
|
19
|
+
(n,) = struct.unpack("<i", st.read(4))
|
|
20
|
+
if n == 0:
|
|
21
|
+
return ""
|
|
22
|
+
if n < 0:
|
|
23
|
+
return st.read(-n * 2).decode("utf-16-le").rstrip(chr(0))
|
|
24
|
+
return st.read(n).decode("latin-1").rstrip(chr(0))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _wstr(s: str) -> bytes:
|
|
28
|
+
"""Write an FString. Falls back to UTF-16 if the text is not ASCII."""
|
|
29
|
+
if s.isascii():
|
|
30
|
+
raw = s.encode("ascii") + NUL
|
|
31
|
+
return struct.pack("<i", len(raw)) + raw
|
|
32
|
+
raw = s.encode("utf-16-le") + NUL * 2
|
|
33
|
+
return struct.pack("<i", -(len(raw) // 2)) + raw
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LocRes:
|
|
37
|
+
"""A text table keyed by (namespace, key)."""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._data = bytearray()
|
|
41
|
+
self._array_offset = 0
|
|
42
|
+
self._strings: list[str] = []
|
|
43
|
+
self._refs: list[int] = []
|
|
44
|
+
self._slots: dict[tuple[str, str], tuple[int, int]] = {}
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def loads(cls, data: bytes) -> LocRes:
|
|
48
|
+
"""Parse a .locres file."""
|
|
49
|
+
self = cls()
|
|
50
|
+
self._data = bytearray(data)
|
|
51
|
+
b = io.BytesIO(data)
|
|
52
|
+
if b.read(16) != MAGIC:
|
|
53
|
+
raise ValueError("not a .locres file")
|
|
54
|
+
self.version = b.read(1)[0]
|
|
55
|
+
(self._array_offset,) = struct.unpack("<q", b.read(8))
|
|
56
|
+
if self.version >= 3:
|
|
57
|
+
b.read(4)
|
|
58
|
+
(ns_count,) = struct.unpack("<I", b.read(4))
|
|
59
|
+
|
|
60
|
+
for _ in range(ns_count):
|
|
61
|
+
b.read(4)
|
|
62
|
+
ns = _rstr(b)
|
|
63
|
+
(key_count,) = struct.unpack("<I", b.read(4))
|
|
64
|
+
for _ in range(key_count):
|
|
65
|
+
b.read(4)
|
|
66
|
+
key = _rstr(b)
|
|
67
|
+
b.read(4) # source string hash
|
|
68
|
+
at = b.tell() # byte offset of the string index
|
|
69
|
+
(idx,) = struct.unpack("<i", b.read(4))
|
|
70
|
+
self._slots[(ns, key)] = (idx, at)
|
|
71
|
+
|
|
72
|
+
sb = io.BytesIO(data)
|
|
73
|
+
sb.seek(self._array_offset)
|
|
74
|
+
(count,) = struct.unpack("<i", sb.read(4))
|
|
75
|
+
for _ in range(count):
|
|
76
|
+
self._strings.append(_rstr(sb))
|
|
77
|
+
self._refs.append(
|
|
78
|
+
struct.unpack("<i", sb.read(4))[0] if self.version >= 3 else 1
|
|
79
|
+
)
|
|
80
|
+
# Hang on to the shipped array bytes so edits only ever append.
|
|
81
|
+
self._shipped_count = count
|
|
82
|
+
self._shipped_body = bytes(data[self._array_offset + 4 : sb.tell()])
|
|
83
|
+
self._trailing = bytes(data[sb.tell() :])
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def entries(self) -> dict[tuple[str, str], str]:
|
|
88
|
+
"""Every (namespace, key) to text pair."""
|
|
89
|
+
return {
|
|
90
|
+
k: self._strings[i]
|
|
91
|
+
for k, (i, _) in self._slots.items()
|
|
92
|
+
if 0 <= i < len(self._strings)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def get(self, namespace: str, key: str) -> str | None:
|
|
96
|
+
"""Look one entry up."""
|
|
97
|
+
slot = self._slots.get((namespace, key))
|
|
98
|
+
if slot is None or not 0 <= slot[0] < len(self._strings):
|
|
99
|
+
return None
|
|
100
|
+
return self._strings[slot[0]]
|
|
101
|
+
|
|
102
|
+
def find(self, text: str) -> list[tuple[str, str, str]]:
|
|
103
|
+
"""Every entry whose text is exactly `text`."""
|
|
104
|
+
return [(ns, k, v) for (ns, k), v in self.entries.items() if v == text]
|
|
105
|
+
|
|
106
|
+
def set(self, namespace: str, key: str, value: str) -> None:
|
|
107
|
+
"""Point an entry at `value`, adding it to the string array if it is new."""
|
|
108
|
+
slot = self._slots.get((namespace, key))
|
|
109
|
+
if slot is None:
|
|
110
|
+
raise KeyError(f"{namespace} / {key} is not in this table")
|
|
111
|
+
try:
|
|
112
|
+
idx = self._strings.index(value)
|
|
113
|
+
except ValueError:
|
|
114
|
+
self._strings.append(value)
|
|
115
|
+
self._refs.append(0)
|
|
116
|
+
idx = len(self._strings) - 1
|
|
117
|
+
old_idx, at = slot
|
|
118
|
+
if 0 <= old_idx < len(self._refs):
|
|
119
|
+
self._refs[old_idx] = max(0, self._refs[old_idx] - 1)
|
|
120
|
+
self._refs[idx] += 1
|
|
121
|
+
self._slots[(namespace, key)] = (idx, at)
|
|
122
|
+
struct.pack_into("<i", self._data, at, idx)
|
|
123
|
+
|
|
124
|
+
def dumps(self) -> bytes:
|
|
125
|
+
"""Write it back out as .locres bytes.
|
|
126
|
+
|
|
127
|
+
The header and index are the original bytes with only string indices
|
|
128
|
+
moved. The string array gets rebuilt. Since the array is last in the
|
|
129
|
+
file its length can change without disturbing anything.
|
|
130
|
+
"""
|
|
131
|
+
out = bytearray(self._data[: self._array_offset])
|
|
132
|
+
out += struct.pack("<i", len(self._strings))
|
|
133
|
+
out += self._shipped_body
|
|
134
|
+
for i in range(self._shipped_count, len(self._strings)):
|
|
135
|
+
out += _wstr(self._strings[i])
|
|
136
|
+
if self.version >= 3:
|
|
137
|
+
out += struct.pack("<i", self._refs[i])
|
|
138
|
+
out += self._trailing
|
|
139
|
+
return bytes(out)
|