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,522 @@
|
|
|
1
|
+
"""Editing cooked assets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import struct
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from ..formats.iostore import package_id
|
|
9
|
+
from ..formats.zen import (
|
|
10
|
+
EXPORT_ENTRY_SIZE,
|
|
11
|
+
IMPORT_PACKAGE,
|
|
12
|
+
ZenPackage,
|
|
13
|
+
append_to_section,
|
|
14
|
+
package_import,
|
|
15
|
+
read_name_map,
|
|
16
|
+
renumber_imported_packages,
|
|
17
|
+
splice_section,
|
|
18
|
+
split_import,
|
|
19
|
+
write_name_map,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AssetEditor:
|
|
24
|
+
"""Edits strings inside one cooked asset.
|
|
25
|
+
|
|
26
|
+
A cooked package records how long each of its exports is. Change the length
|
|
27
|
+
of a string and those numbers go stale, so this fixes them up afterwards.
|
|
28
|
+
That is why the replacement text can be any length.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self, package_path: str, data: bytes, imported_packages: list[int] | None = None
|
|
33
|
+
):
|
|
34
|
+
self.package_path = package_path
|
|
35
|
+
self.data = bytearray(data)
|
|
36
|
+
self.changes = 0
|
|
37
|
+
# The ids of the packages this one imports live in the container header
|
|
38
|
+
# rather than the package, so they get carried alongside. add_import
|
|
39
|
+
# adds to this list and the builder ships whatever is here.
|
|
40
|
+
self.imported_packages = list(imported_packages or [])
|
|
41
|
+
self._reparse()
|
|
42
|
+
|
|
43
|
+
def _reparse(self) -> None:
|
|
44
|
+
self.package = ZenPackage(self.data)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def names(self) -> list[str]:
|
|
48
|
+
"""The package name map: class names, object paths, gameplay tags."""
|
|
49
|
+
return self.package.names
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _fstrings(text: str) -> list[bytes]:
|
|
53
|
+
"""Both ways a cooked package might have stored `text`.
|
|
54
|
+
|
|
55
|
+
Unreal uses the narrow encoding where it can, but an asset authored
|
|
56
|
+
with wide strings keeps them, so check for both.
|
|
57
|
+
"""
|
|
58
|
+
forms = []
|
|
59
|
+
if text.isascii():
|
|
60
|
+
raw = text.encode("ascii") + b"\x00"
|
|
61
|
+
forms.append(struct.pack("<i", len(raw)) + raw)
|
|
62
|
+
wide = text.encode("utf-16-le") + b"\x00\x00"
|
|
63
|
+
forms.append(struct.pack("<i", -(len(wide) // 2)) + wide)
|
|
64
|
+
return forms
|
|
65
|
+
|
|
66
|
+
def _find(self, text: str) -> tuple[bytes, list[int]]:
|
|
67
|
+
"""The stored form of `text`, and where it turns up in the export data.
|
|
68
|
+
|
|
69
|
+
Only the export data gets searched. Anything matching in the header
|
|
70
|
+
belongs to the name map or one of the other indexes, and splicing bytes
|
|
71
|
+
in there would wreck the package.
|
|
72
|
+
"""
|
|
73
|
+
start, end = self.package.export_data_range()
|
|
74
|
+
for form in self._fstrings(text):
|
|
75
|
+
hits, at = [], start
|
|
76
|
+
while (i := self.data.find(form, at, end)) >= 0:
|
|
77
|
+
hits.append(i)
|
|
78
|
+
at = i + 1
|
|
79
|
+
if hits:
|
|
80
|
+
return form, hits
|
|
81
|
+
return b"", []
|
|
82
|
+
|
|
83
|
+
def find_text(self, text: str) -> list[int]:
|
|
84
|
+
"""Where every string equal to `text` sits in the export data."""
|
|
85
|
+
return self._find(text)[1]
|
|
86
|
+
|
|
87
|
+
def replace_text(self, old: str, new: str) -> AssetEditor:
|
|
88
|
+
"""Replace every string equal to `old`. Any length is allowed."""
|
|
89
|
+
old_raw, hits = self._find(old)
|
|
90
|
+
if not hits:
|
|
91
|
+
in_names = any(old == n for n in self.names)
|
|
92
|
+
hint = (
|
|
93
|
+
" It is in the package name map, which replace_text cannot "
|
|
94
|
+
"resize; try retarget_name()."
|
|
95
|
+
if in_names
|
|
96
|
+
else ""
|
|
97
|
+
)
|
|
98
|
+
raise KeyError(f"{old!r} is not a string in {self.package_path}.{hint}")
|
|
99
|
+
|
|
100
|
+
# Keep whatever encoding the asset already used.
|
|
101
|
+
wide = struct.unpack_from("<i", old_raw)[0] < 0
|
|
102
|
+
new_raw = self._fstrings(new)[-1 if wide else 0]
|
|
103
|
+
delta = len(new_raw) - len(old_raw)
|
|
104
|
+
|
|
105
|
+
# Work backwards so the offsets we already found stay put.
|
|
106
|
+
for pos in reversed(hits):
|
|
107
|
+
self.data[pos : pos + len(old_raw)] = new_raw
|
|
108
|
+
self.changes += 1
|
|
109
|
+
if delta:
|
|
110
|
+
self._shift_exports(pos - self.package.header_size, delta)
|
|
111
|
+
self._reparse()
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def retarget_name(
|
|
115
|
+
self, old: str, new: str, expect: int | None = None
|
|
116
|
+
) -> AssetEditor:
|
|
117
|
+
"""Point references to the name `old` at the name `new` instead.
|
|
118
|
+
|
|
119
|
+
Gameplay tags, class names and object paths are not text in the export
|
|
120
|
+
data. They sit in the package name map, and the export data points at
|
|
121
|
+
them by index. Changing which index a reference uses means the name map
|
|
122
|
+
never moves, nothing gets resized, and no hash needs recomputing.
|
|
123
|
+
|
|
124
|
+
Both names have to be in the package name map already. Pass `expect` if
|
|
125
|
+
you know how many references there should be.
|
|
126
|
+
"""
|
|
127
|
+
names = self.names
|
|
128
|
+
if old not in names:
|
|
129
|
+
raise KeyError(
|
|
130
|
+
f"{old!r} is not in the name map of {self.package_path}. "
|
|
131
|
+
f"It has {len(names)} names."
|
|
132
|
+
)
|
|
133
|
+
if new not in names:
|
|
134
|
+
raise KeyError(
|
|
135
|
+
f"{new!r} is not in the name map of {self.package_path}, and "
|
|
136
|
+
"names cannot be added yet. Pick one the package already uses."
|
|
137
|
+
)
|
|
138
|
+
old_index, new_index = names.index(old), names.index(new)
|
|
139
|
+
|
|
140
|
+
# An FName is a name-map index plus an instance number. Match both so
|
|
141
|
+
# we do not hit some unrelated pair of integers that looks the same.
|
|
142
|
+
start, end = self.package.export_data_range()
|
|
143
|
+
needle = struct.pack("<II", old_index, 0)
|
|
144
|
+
replacement = struct.pack("<II", new_index, 0)
|
|
145
|
+
hits, at = [], start
|
|
146
|
+
while (i := self.data.find(needle, at, end)) >= 0:
|
|
147
|
+
hits.append(i)
|
|
148
|
+
at = i + 1
|
|
149
|
+
|
|
150
|
+
if expect is not None and len(hits) != expect:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"expected {expect} reference(s) to {old!r} in "
|
|
153
|
+
f"{self.package_path}, found {len(hits)}"
|
|
154
|
+
)
|
|
155
|
+
if not hits:
|
|
156
|
+
raise KeyError(f"{old!r} is in the name map but never referenced")
|
|
157
|
+
|
|
158
|
+
for pos in hits:
|
|
159
|
+
self.data[pos : pos + 8] = replacement
|
|
160
|
+
self.changes += 1
|
|
161
|
+
return self
|
|
162
|
+
|
|
163
|
+
# -- growing the header ----------------------------------------------
|
|
164
|
+
|
|
165
|
+
def add_name(self, name: str) -> int:
|
|
166
|
+
"""Put a new name in the package name map and give back its index.
|
|
167
|
+
|
|
168
|
+
Names go on the end. Nothing sorts them, so nothing else has to move,
|
|
169
|
+
and the hash gets worked out from the name itself. If the name is
|
|
170
|
+
already there this just returns where it is.
|
|
171
|
+
"""
|
|
172
|
+
if name in self.names:
|
|
173
|
+
return self.names.index(name)
|
|
174
|
+
old = read_name_map(self.data, self.package.name_map.start)
|
|
175
|
+
rebuilt = write_name_map([*old.names, name])
|
|
176
|
+
self.data = splice_section(self.data, "name_map", old.start, old.size, rebuilt)
|
|
177
|
+
self._reparse()
|
|
178
|
+
self.changes += 1
|
|
179
|
+
return len(self.names) - 1
|
|
180
|
+
|
|
181
|
+
def add_imported_package(self, package_path: str) -> int:
|
|
182
|
+
"""Add a package to the list of ones this asset imports.
|
|
183
|
+
|
|
184
|
+
The container keeps these ids in ascending order and the loader binary
|
|
185
|
+
searches them, so a new one lands in the middle rather than on the end.
|
|
186
|
+
Everything after it moves along by one, and every reference holding one
|
|
187
|
+
of those positions has to be renumbered to match.
|
|
188
|
+
"""
|
|
189
|
+
pid = package_id(package_path)
|
|
190
|
+
if pid in self.imported_packages:
|
|
191
|
+
return self.imported_packages.index(pid)
|
|
192
|
+
|
|
193
|
+
at = sum(1 for existing in self.imported_packages if existing < pid)
|
|
194
|
+
self.data = renumber_imported_packages(self.data, at, 1)
|
|
195
|
+
|
|
196
|
+
# Two things run alongside the ids and both need an entry at the same
|
|
197
|
+
# spot: the names, and the uint32 array behind them. The summary has no
|
|
198
|
+
# offset for that array, so the whole section gets rebuilt together
|
|
199
|
+
# rather than spliced in two places.
|
|
200
|
+
names = self.package.imported_package_names
|
|
201
|
+
tail = self.package.imported_package_tail
|
|
202
|
+
start, end = self.package.section_range("imported_package_names")
|
|
203
|
+
rebuilt = write_name_map([*names[:at], package_path, *names[at:]])
|
|
204
|
+
rebuilt += struct.pack(f"<{len(tail) + 1}I", *tail[:at], 0, *tail[at:])
|
|
205
|
+
self.data = splice_section(
|
|
206
|
+
self.data, "imported_package_names", start, end - start, rebuilt
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
self.imported_packages.insert(at, pid)
|
|
210
|
+
self._reparse()
|
|
211
|
+
self.changes += 1
|
|
212
|
+
return at
|
|
213
|
+
|
|
214
|
+
def add_import(self, package_path: str, export_hash: int) -> int:
|
|
215
|
+
"""Import one object out of another package. Gives back its import index.
|
|
216
|
+
|
|
217
|
+
`export_hash` is the public export hash of the object being pointed at,
|
|
218
|
+
which is how the loader finds it once the package is mounted. Three
|
|
219
|
+
things get added: the package to the import list, the hash to
|
|
220
|
+
ImportedPublicExportHashes, and an entry to the import map that ties
|
|
221
|
+
the two together.
|
|
222
|
+
"""
|
|
223
|
+
package_index = self.add_imported_package(package_path)
|
|
224
|
+
|
|
225
|
+
hashes = self.package.public_export_hashes
|
|
226
|
+
if export_hash in hashes:
|
|
227
|
+
hash_index = hashes.index(export_hash)
|
|
228
|
+
else:
|
|
229
|
+
hash_index = len(hashes)
|
|
230
|
+
self.data = append_to_section(
|
|
231
|
+
self.data,
|
|
232
|
+
"imported_public_export_hashes",
|
|
233
|
+
struct.pack("<Q", export_hash),
|
|
234
|
+
)
|
|
235
|
+
self._reparse()
|
|
236
|
+
|
|
237
|
+
entry = package_import(package_index, hash_index)
|
|
238
|
+
existing = self.package.imports
|
|
239
|
+
if entry in existing:
|
|
240
|
+
return existing.index(entry)
|
|
241
|
+
|
|
242
|
+
self.data = append_to_section(self.data, "import_map", struct.pack("<Q", entry))
|
|
243
|
+
self._reparse()
|
|
244
|
+
self.changes += 1
|
|
245
|
+
return len(existing)
|
|
246
|
+
|
|
247
|
+
# -- editing export data ----------------------------------------------
|
|
248
|
+
|
|
249
|
+
def splice_export_data(self, at: int, old_len: int, new: bytes) -> AssetEditor:
|
|
250
|
+
"""Swap `old_len` bytes at `at` for `new`, inside the export data.
|
|
251
|
+
|
|
252
|
+
The export holding those bytes grows or shrinks to match and every
|
|
253
|
+
export after it slides along. `at` is an absolute offset into the
|
|
254
|
+
package and has to land inside an export, not on the seam between two,
|
|
255
|
+
because otherwise there is no saying which of them the new bytes join.
|
|
256
|
+
"""
|
|
257
|
+
start, end = self.package.export_data_range()
|
|
258
|
+
if not start <= at <= at + old_len <= end:
|
|
259
|
+
raise ValueError(
|
|
260
|
+
f"splice at {at}..{at + old_len} is outside the export data "
|
|
261
|
+
f"({start}..{end})"
|
|
262
|
+
)
|
|
263
|
+
relative = at - self.package.header_size
|
|
264
|
+
if self.package.export_at(relative) is None:
|
|
265
|
+
raise ValueError(f"offset {at} is not inside any export")
|
|
266
|
+
|
|
267
|
+
delta = len(new) - old_len
|
|
268
|
+
self.data[at : at + old_len] = new
|
|
269
|
+
if delta:
|
|
270
|
+
self._shift_exports(relative, delta)
|
|
271
|
+
else:
|
|
272
|
+
self._reparse()
|
|
273
|
+
self.changes += 1
|
|
274
|
+
return self
|
|
275
|
+
|
|
276
|
+
def import_ref(self, import_index: int) -> int:
|
|
277
|
+
"""How the export data refers to an import.
|
|
278
|
+
|
|
279
|
+
Object references in cooked data are one signed int: positive counts
|
|
280
|
+
exports from one, negative counts imports from one, zero is null.
|
|
281
|
+
"""
|
|
282
|
+
return -(import_index + 1)
|
|
283
|
+
|
|
284
|
+
def resolve_ref(self, ref: int) -> str | None:
|
|
285
|
+
"""What an object reference points at, for checking an edit landed."""
|
|
286
|
+
if ref == 0:
|
|
287
|
+
return None
|
|
288
|
+
if ref > 0:
|
|
289
|
+
index = ref - 1
|
|
290
|
+
return f"export[{index}]" if index < len(self.package.exports) else None
|
|
291
|
+
index = -ref - 1
|
|
292
|
+
if index >= len(self.package.imports):
|
|
293
|
+
return None
|
|
294
|
+
kind, package, _hash = split_import(self.package.imports[index])
|
|
295
|
+
if kind != IMPORT_PACKAGE:
|
|
296
|
+
return f"import[{index}]"
|
|
297
|
+
names = self.package.imported_package_names
|
|
298
|
+
return names[package] if package < len(names) else None
|
|
299
|
+
|
|
300
|
+
def find_object_map(
|
|
301
|
+
self, *, entries: int | None = None, referencing: str | None = None
|
|
302
|
+
) -> ObjectMap:
|
|
303
|
+
"""Find a Map<Object, Object> in the export data by what it holds.
|
|
304
|
+
|
|
305
|
+
A cooked map is a zero (nothing to remove), a count, then that many
|
|
306
|
+
key and value pairs, each one an object reference. Rather than walking
|
|
307
|
+
every property in front of it, this scans for that shape and keeps only
|
|
308
|
+
the ones where every reference resolves to something real, which throws
|
|
309
|
+
out almost everything by itself.
|
|
310
|
+
|
|
311
|
+
Narrow it with `entries` for the number of pairs and `referencing` for
|
|
312
|
+
a substring one of the packages has to contain. Raises unless exactly
|
|
313
|
+
one map matches, so a change never lands on a lucky second candidate.
|
|
314
|
+
"""
|
|
315
|
+
found = [
|
|
316
|
+
m
|
|
317
|
+
for m in self.object_maps()
|
|
318
|
+
if (entries is None or len(m.entries) == entries)
|
|
319
|
+
and (referencing is None or any(referencing in p for p in m.packages))
|
|
320
|
+
]
|
|
321
|
+
if len(found) != 1:
|
|
322
|
+
what = f"entries={entries} referencing={referencing!r}"
|
|
323
|
+
raise LookupError(
|
|
324
|
+
f"wanted exactly one object map in {self.package_path} ({what}), "
|
|
325
|
+
f"found {len(found)}"
|
|
326
|
+
)
|
|
327
|
+
return found[0]
|
|
328
|
+
|
|
329
|
+
def object_maps(
|
|
330
|
+
self, min_entries: int = 1, max_entries: int = 256
|
|
331
|
+
) -> list[ObjectMap]:
|
|
332
|
+
"""Every run of bytes in the export data shaped like an object map."""
|
|
333
|
+
start, end = self.package.export_data_range()
|
|
334
|
+
out = []
|
|
335
|
+
for at in range(start, end - 8):
|
|
336
|
+
removals, count = struct.unpack_from("<ii", self.data, at)
|
|
337
|
+
if removals != 0 or not min_entries <= count <= max_entries:
|
|
338
|
+
continue
|
|
339
|
+
if at + 8 + count * 8 > end:
|
|
340
|
+
continue
|
|
341
|
+
pairs, packages, ok = [], [], True
|
|
342
|
+
for i in range(count):
|
|
343
|
+
key, value = struct.unpack_from("<ii", self.data, at + 8 + i * 8)
|
|
344
|
+
names = []
|
|
345
|
+
for ref in (key, value):
|
|
346
|
+
# A null reference is a real thing to store, so it counts
|
|
347
|
+
# as resolved. Anything else that will not resolve means
|
|
348
|
+
# this run of bytes is not a map.
|
|
349
|
+
if ref == 0:
|
|
350
|
+
names.append("")
|
|
351
|
+
continue
|
|
352
|
+
resolved = self.resolve_ref(ref)
|
|
353
|
+
if resolved is None:
|
|
354
|
+
ok = False
|
|
355
|
+
break
|
|
356
|
+
names.append(resolved)
|
|
357
|
+
if not ok:
|
|
358
|
+
break
|
|
359
|
+
pairs.append((key, value))
|
|
360
|
+
packages.extend(names)
|
|
361
|
+
if ok:
|
|
362
|
+
out.append(ObjectMap(self, at, pairs, packages))
|
|
363
|
+
return out
|
|
364
|
+
|
|
365
|
+
def add_gameplay_tag(
|
|
366
|
+
self, beside: str, tag: str, expect: int | None = None
|
|
367
|
+
) -> AssetEditor:
|
|
368
|
+
"""Add a tag to the container that already holds `beside`.
|
|
369
|
+
|
|
370
|
+
Containers are not labelled in cooked data, so the way to point at one
|
|
371
|
+
is to name a tag already in it. The new tag goes on the end of that
|
|
372
|
+
container, the count goes up by one, and the name map gains an entry if
|
|
373
|
+
it does not have the tag already.
|
|
374
|
+
"""
|
|
375
|
+
# The name map sits in front of the export data, so growing it first
|
|
376
|
+
# keeps the offsets found below valid.
|
|
377
|
+
index = self.add_name(tag)
|
|
378
|
+
|
|
379
|
+
names = self.names
|
|
380
|
+
if beside not in names:
|
|
381
|
+
raise KeyError(f"{beside!r} is not in the name map of {self.package_path}")
|
|
382
|
+
anchor = names.index(beside)
|
|
383
|
+
|
|
384
|
+
start, end = self.package.export_data_range()
|
|
385
|
+
needle = struct.pack("<II", anchor, 0)
|
|
386
|
+
hits, at = [], start
|
|
387
|
+
while (i := self.data.find(needle, at, end)) >= 0:
|
|
388
|
+
hits.append(i)
|
|
389
|
+
at = i + 1
|
|
390
|
+
if expect is not None and len(hits) != expect:
|
|
391
|
+
raise ValueError(
|
|
392
|
+
f"expected {expect} container(s) holding {beside!r} in "
|
|
393
|
+
f"{self.package_path}, found {len(hits)}"
|
|
394
|
+
)
|
|
395
|
+
if not hits:
|
|
396
|
+
raise KeyError(f"{beside!r} is in the name map but never referenced")
|
|
397
|
+
|
|
398
|
+
for pos in reversed(hits):
|
|
399
|
+
count_at, count = self._tag_container_count(pos, start)
|
|
400
|
+
struct.pack_into("<i", self.data, count_at, count + 1)
|
|
401
|
+
after = count_at + 4 + count * 8
|
|
402
|
+
self.splice_export_data(after, 0, struct.pack("<II", index, 0))
|
|
403
|
+
return self
|
|
404
|
+
|
|
405
|
+
def remove_gameplay_tag(self, tag: str, expect: int | None = None) -> AssetEditor:
|
|
406
|
+
"""Take a tag out of the tag container it sits in.
|
|
407
|
+
|
|
408
|
+
A container is a count followed by that many FNames. retarget_name can
|
|
409
|
+
point an entry somewhere else but the count stays put, so the container
|
|
410
|
+
keeps its length. Some of this game's checks care about that, so this
|
|
411
|
+
drops the entry and brings the count down with it.
|
|
412
|
+
|
|
413
|
+
Finding the count means stepping back one entry at a time from the tag
|
|
414
|
+
until a number turns up that describes the entries after it, with every
|
|
415
|
+
one of them a real name. Pass `expect` if you know how many containers
|
|
416
|
+
should be touched.
|
|
417
|
+
"""
|
|
418
|
+
names = self.names
|
|
419
|
+
if tag not in names:
|
|
420
|
+
raise KeyError(f"{tag!r} is not in the name map of {self.package_path}")
|
|
421
|
+
index = names.index(tag)
|
|
422
|
+
|
|
423
|
+
start, end = self.package.export_data_range()
|
|
424
|
+
needle = struct.pack("<II", index, 0)
|
|
425
|
+
hits, at = [], start
|
|
426
|
+
while (i := self.data.find(needle, at, end)) >= 0:
|
|
427
|
+
hits.append(i)
|
|
428
|
+
at = i + 1
|
|
429
|
+
if expect is not None and len(hits) != expect:
|
|
430
|
+
raise ValueError(
|
|
431
|
+
f"expected {expect} container(s) holding {tag!r} in "
|
|
432
|
+
f"{self.package_path}, found {len(hits)}"
|
|
433
|
+
)
|
|
434
|
+
if not hits:
|
|
435
|
+
raise KeyError(f"{tag!r} is in the name map but never referenced")
|
|
436
|
+
|
|
437
|
+
# Work backwards so the offsets found above stay put.
|
|
438
|
+
for pos in reversed(hits):
|
|
439
|
+
count_at, count = self._tag_container_count(pos, start)
|
|
440
|
+
struct.pack_into("<i", self.data, count_at, count - 1)
|
|
441
|
+
self.splice_export_data(pos, 8, b"")
|
|
442
|
+
return self
|
|
443
|
+
|
|
444
|
+
def _tag_container_count(self, entry_at: int, start: int) -> tuple[int, int]:
|
|
445
|
+
"""Where the count of the container holding this entry sits, and its value.
|
|
446
|
+
|
|
447
|
+
The entry could be anywhere in the container, so try each position in
|
|
448
|
+
turn. A candidate only counts if the number is right for how many
|
|
449
|
+
entries follow it and every one of them reads as a name this package
|
|
450
|
+
actually has.
|
|
451
|
+
"""
|
|
452
|
+
for slot in range(64):
|
|
453
|
+
count_at = entry_at - 8 * slot - 4
|
|
454
|
+
if count_at < start:
|
|
455
|
+
break
|
|
456
|
+
(count,) = struct.unpack_from("<i", self.data, count_at)
|
|
457
|
+
if not slot < count <= 64:
|
|
458
|
+
continue
|
|
459
|
+
if count_at + 4 + count * 8 > len(self.data):
|
|
460
|
+
continue
|
|
461
|
+
if all(
|
|
462
|
+
struct.unpack_from("<II", self.data, count_at + 4 + i * 8)[0]
|
|
463
|
+
< len(self.names)
|
|
464
|
+
and struct.unpack_from("<II", self.data, count_at + 4 + i * 8)[1] == 0
|
|
465
|
+
for i in range(count)
|
|
466
|
+
):
|
|
467
|
+
return count_at, count
|
|
468
|
+
raise ValueError(
|
|
469
|
+
f"could not find the container holding the tag at {entry_at} in "
|
|
470
|
+
f"{self.package_path}"
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
def _shift_exports(self, rel_pos: int, delta: int) -> None:
|
|
474
|
+
"""Resize the export holding rel_pos, and move the ones after it."""
|
|
475
|
+
for i, export in enumerate(self.package.exports):
|
|
476
|
+
at = self.package.export_map_offset + i * EXPORT_ENTRY_SIZE
|
|
477
|
+
if export.offset <= rel_pos < export.end:
|
|
478
|
+
struct.pack_into("<Q", self.data, at + 8, export.size + delta)
|
|
479
|
+
elif export.offset > rel_pos:
|
|
480
|
+
struct.pack_into("<Q", self.data, at, export.offset + delta)
|
|
481
|
+
self._reparse()
|
|
482
|
+
|
|
483
|
+
def __repr__(self) -> str:
|
|
484
|
+
return f"<AssetEditor {self.package_path!r} changes={self.changes}>"
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@dataclass
|
|
488
|
+
class ObjectMap:
|
|
489
|
+
"""A Map<Object, Object> sat in an asset's export data.
|
|
490
|
+
|
|
491
|
+
`entries` holds the raw object references, `packages` the names they
|
|
492
|
+
resolve to, so an edit can be checked against what is actually there.
|
|
493
|
+
"""
|
|
494
|
+
|
|
495
|
+
editor: AssetEditor
|
|
496
|
+
offset: int
|
|
497
|
+
entries: list[tuple[int, int]]
|
|
498
|
+
packages: list[str]
|
|
499
|
+
|
|
500
|
+
def append(self, key_import: int, value_import: int | None) -> ObjectMap:
|
|
501
|
+
"""Add a pair, given the import indexes to point the two halves at.
|
|
502
|
+
|
|
503
|
+
Pass None for the value to store a null reference, which is what the
|
|
504
|
+
game does for a primary specialisation that has no secondary.
|
|
505
|
+
"""
|
|
506
|
+
key = self.editor.import_ref(key_import)
|
|
507
|
+
value = 0 if value_import is None else self.editor.import_ref(value_import)
|
|
508
|
+
end = self.offset + 8 + len(self.entries) * 8
|
|
509
|
+
# Bump the count first. It lives in front of the pairs, so inserting
|
|
510
|
+
# afterwards leaves it where it is.
|
|
511
|
+
struct.pack_into("<i", self.editor.data, self.offset + 4, len(self.entries) + 1)
|
|
512
|
+
self.editor.splice_export_data(end, 0, struct.pack("<ii", key, value))
|
|
513
|
+
self.entries.append((key, value))
|
|
514
|
+
return self
|
|
515
|
+
|
|
516
|
+
@property
|
|
517
|
+
def named_entries(self) -> list[tuple[str, str]]:
|
|
518
|
+
"""The pairs as package names, which is what you want when checking."""
|
|
519
|
+
return list(zip(self.packages[0::2], self.packages[1::2], strict=True))
|
|
520
|
+
|
|
521
|
+
def __repr__(self) -> str:
|
|
522
|
+
return f"<ObjectMap @{self.offset} entries={len(self.entries)}>"
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Editing DataTable rows.
|
|
2
|
+
|
|
3
|
+
A cooked DataTable is its own properties, then a row count, then every row as a
|
|
4
|
+
name followed by a struct. Most of the game's tuning sits in tables like this.
|
|
5
|
+
Ability costs, weapon stats, upgrade curves. That makes them the most useful
|
|
6
|
+
thing to be able to edit by name.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import struct
|
|
12
|
+
|
|
13
|
+
from ..formats.usmap import Mappings, bundled
|
|
14
|
+
from ..formats.zen import ZenPackage
|
|
15
|
+
from .properties import Located, PropertyError, read_struct, write_number
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DataTableError(ValueError):
|
|
19
|
+
"""A package was not a DataTable, or would not parse as one."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DataTable:
|
|
23
|
+
"""Row by row access to a cooked DataTable."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
package_path: str,
|
|
28
|
+
data: bytearray,
|
|
29
|
+
mappings: Mappings | None = None,
|
|
30
|
+
row_struct: str | None = None,
|
|
31
|
+
):
|
|
32
|
+
self.package_path = package_path
|
|
33
|
+
self.data = data
|
|
34
|
+
self.mappings = mappings or bundled()
|
|
35
|
+
self.row_struct = row_struct
|
|
36
|
+
self.changes = 0
|
|
37
|
+
self._read()
|
|
38
|
+
|
|
39
|
+
def _read(self) -> None:
|
|
40
|
+
pkg = ZenPackage(self.data)
|
|
41
|
+
self.package = pkg
|
|
42
|
+
try:
|
|
43
|
+
_, after = read_struct(
|
|
44
|
+
self.data, pkg.header_size, "DataTable", self.mappings
|
|
45
|
+
)
|
|
46
|
+
except PropertyError as exc:
|
|
47
|
+
raise DataTableError(
|
|
48
|
+
f"{self.package_path} is not a DataTable: {exc}"
|
|
49
|
+
) from exc
|
|
50
|
+
|
|
51
|
+
at = after + 4 # marker between the table properties and the rows
|
|
52
|
+
(count,) = struct.unpack_from("<i", self.data, at)
|
|
53
|
+
self._rows_at = at + 4
|
|
54
|
+
if not 0 <= count < 100_000:
|
|
55
|
+
raise DataTableError(
|
|
56
|
+
f"implausible row count {count} in {self.package_path}"
|
|
57
|
+
)
|
|
58
|
+
self._row_count = count
|
|
59
|
+
|
|
60
|
+
if self.row_struct is None:
|
|
61
|
+
self.row_struct = self._resolve_row_struct()
|
|
62
|
+
self.rows = self._walk(self.row_struct)
|
|
63
|
+
|
|
64
|
+
def _walk(self, row_struct: str) -> dict[str, dict[str, Located]]:
|
|
65
|
+
"""Read every row, and insist the walk finishes exactly at the end.
|
|
66
|
+
|
|
67
|
+
Finishing anywhere else means the schema is wrong for this asset.
|
|
68
|
+
Either the row struct is not the right one, or the mappings came from a
|
|
69
|
+
different build. Either way none of the offsets can be trusted.
|
|
70
|
+
"""
|
|
71
|
+
pkg, at = self.package, self._rows_at
|
|
72
|
+
rows: dict[str, dict[str, Located]] = {}
|
|
73
|
+
for _ in range(self._row_count):
|
|
74
|
+
(name_index, _number) = struct.unpack_from("<II", self.data, at)
|
|
75
|
+
at += 8
|
|
76
|
+
name = (
|
|
77
|
+
pkg.names[name_index]
|
|
78
|
+
if name_index < len(pkg.names)
|
|
79
|
+
else str(name_index)
|
|
80
|
+
)
|
|
81
|
+
props, at = read_struct(self.data, at, row_struct, self.mappings)
|
|
82
|
+
rows[name] = props
|
|
83
|
+
if at != len(self.data):
|
|
84
|
+
raise DataTableError(
|
|
85
|
+
f"{self.package_path}: rows ended at {at} of {len(self.data)} "
|
|
86
|
+
f"bytes using {row_struct}"
|
|
87
|
+
)
|
|
88
|
+
return rows
|
|
89
|
+
|
|
90
|
+
def _resolve_row_struct(self) -> str:
|
|
91
|
+
"""Work out the row struct by trying the likely ones.
|
|
92
|
+
|
|
93
|
+
A DataTable points at its row struct through the import map, not the
|
|
94
|
+
name map, so there is no name to read off. Guessing is safe here
|
|
95
|
+
because only the correct struct walks the table byte for byte.
|
|
96
|
+
"""
|
|
97
|
+
candidates = [n for n in self.mappings.schemas if n.endswith("TableRow")]
|
|
98
|
+
candidates += [
|
|
99
|
+
n
|
|
100
|
+
for n in self.mappings.schemas
|
|
101
|
+
if n.endswith("Row") and not n.endswith("TableRow")
|
|
102
|
+
]
|
|
103
|
+
for name in candidates:
|
|
104
|
+
try:
|
|
105
|
+
self._walk(name)
|
|
106
|
+
except (DataTableError, PropertyError, struct.error, IndexError):
|
|
107
|
+
continue
|
|
108
|
+
return name
|
|
109
|
+
raise DataTableError(
|
|
110
|
+
f"could not work out the row struct for {self.package_path}. "
|
|
111
|
+
"Pass row_struct= if you know it."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def row(self, name: str) -> dict[str, Located]:
|
|
115
|
+
"""One row's properties, by row name."""
|
|
116
|
+
try:
|
|
117
|
+
return self.rows[name]
|
|
118
|
+
except KeyError:
|
|
119
|
+
raise DataTableError(
|
|
120
|
+
f"{self.package_path} has no row {name!r}. "
|
|
121
|
+
f"It has {len(self.rows)}, for example {list(self.rows)[:3]}"
|
|
122
|
+
) from None
|
|
123
|
+
|
|
124
|
+
def get(self, row: str, field: str):
|
|
125
|
+
"""One value from one row."""
|
|
126
|
+
props = self.row(row)
|
|
127
|
+
if field not in props:
|
|
128
|
+
raise DataTableError(
|
|
129
|
+
f"row {row!r} has no field {field!r}; it has {sorted(props)}"
|
|
130
|
+
)
|
|
131
|
+
return props[field].value
|
|
132
|
+
|
|
133
|
+
def set(self, row: str, field: str, value: float) -> DataTable:
|
|
134
|
+
"""Overwrite a number. Numbers are fixed width, so nothing shifts."""
|
|
135
|
+
props = self.row(row)
|
|
136
|
+
if field not in props:
|
|
137
|
+
raise DataTableError(
|
|
138
|
+
f"row {row!r} has no field {field!r}; it has {sorted(props)}"
|
|
139
|
+
)
|
|
140
|
+
write_number(self.data, props[field], value)
|
|
141
|
+
self.changes += 1
|
|
142
|
+
self._read() # refresh the cached values
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
def set_all(self, field: str, value: float) -> DataTable:
|
|
146
|
+
"""Overwrite a field in every row that has one."""
|
|
147
|
+
for name, props in list(self.rows.items()):
|
|
148
|
+
if field in props and props[field].offset >= 0:
|
|
149
|
+
write_number(self.data, props[field], value)
|
|
150
|
+
self.changes += 1
|
|
151
|
+
del name
|
|
152
|
+
self._read()
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
def __len__(self) -> int:
|
|
156
|
+
return len(self.rows)
|
|
157
|
+
|
|
158
|
+
def __repr__(self) -> str:
|
|
159
|
+
return (
|
|
160
|
+
f"<DataTable {self.package_path.rsplit('/', 1)[-1]} "
|
|
161
|
+
f"rows={len(self.rows)} struct={self.row_struct} changes={self.changes}>"
|
|
162
|
+
)
|