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,275 @@
|
|
|
1
|
+
"""Finding and editing properties inside cooked export data.
|
|
2
|
+
|
|
3
|
+
Cooked exports use Unreal's unversioned property serialization. A small header
|
|
4
|
+
says which of a schema's properties are there, then the values follow one after
|
|
5
|
+
another with nothing to name them.
|
|
6
|
+
|
|
7
|
+
So reading one value means walking every property in front of it. That is what
|
|
8
|
+
this does, using schemas out of a mappings file.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import struct
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from ..formats.usmap import Mappings, PropertyType
|
|
17
|
+
|
|
18
|
+
#: Types that are always the same size, and how many bytes they take.
|
|
19
|
+
FIXED_WIDTH = {
|
|
20
|
+
PropertyType.INT8: 1,
|
|
21
|
+
PropertyType.BYTE: 1,
|
|
22
|
+
PropertyType.BOOL: 1,
|
|
23
|
+
PropertyType.INT16: 2,
|
|
24
|
+
PropertyType.UINT16: 2,
|
|
25
|
+
PropertyType.INT: 4,
|
|
26
|
+
PropertyType.UINT32: 4,
|
|
27
|
+
PropertyType.FLOAT: 4,
|
|
28
|
+
PropertyType.INT64: 8,
|
|
29
|
+
PropertyType.UINT64: 8,
|
|
30
|
+
PropertyType.DOUBLE: 8,
|
|
31
|
+
PropertyType.NAME: 8, # FName: mapped index + number
|
|
32
|
+
PropertyType.OBJECT: 4, # package object index
|
|
33
|
+
PropertyType.WEAK_OBJECT: 4, # serialized the same way as a hard reference
|
|
34
|
+
PropertyType.LAZY_OBJECT: 16, # FUniqueObjectGuid
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#: struct format for each numeric type.
|
|
38
|
+
NUMERIC_FORMAT = {
|
|
39
|
+
PropertyType.INT8: "<b",
|
|
40
|
+
PropertyType.BYTE: "<B",
|
|
41
|
+
PropertyType.INT16: "<h",
|
|
42
|
+
PropertyType.UINT16: "<H",
|
|
43
|
+
PropertyType.INT: "<i",
|
|
44
|
+
PropertyType.UINT32: "<I",
|
|
45
|
+
PropertyType.FLOAT: "<f",
|
|
46
|
+
PropertyType.INT64: "<q",
|
|
47
|
+
PropertyType.UINT64: "<Q",
|
|
48
|
+
PropertyType.DOUBLE: "<d",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PropertyError(ValueError):
|
|
53
|
+
"""A property could not be found, or its type is not handled yet."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Fragment:
|
|
58
|
+
"""One run of the header. Skip a few properties, then read some values."""
|
|
59
|
+
|
|
60
|
+
skip: int
|
|
61
|
+
has_zeroes: bool
|
|
62
|
+
values: int
|
|
63
|
+
is_last: bool
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def unpack(cls, packed: int) -> Fragment:
|
|
67
|
+
return cls(
|
|
68
|
+
skip=packed & 0x007F,
|
|
69
|
+
has_zeroes=bool(packed & 0x0080),
|
|
70
|
+
is_last=bool(packed & 0x0100),
|
|
71
|
+
values=packed >> 9,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Located:
|
|
77
|
+
"""Where a property's value sits, and what it reads as right now."""
|
|
78
|
+
|
|
79
|
+
name: str
|
|
80
|
+
offset: int
|
|
81
|
+
size: int
|
|
82
|
+
type_kind: int
|
|
83
|
+
value: object
|
|
84
|
+
|
|
85
|
+
def __repr__(self) -> str:
|
|
86
|
+
return f"<{self.name} @{self.offset} +{self.size} = {self.value!r}>"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def read_header(data: bytes, at: int) -> tuple[list[int], list[bool], int]:
|
|
90
|
+
"""Parse an unversioned header.
|
|
91
|
+
|
|
92
|
+
Gives back the schema indices that are present, whether each one was zeroed
|
|
93
|
+
out and therefore not written, and where the values start.
|
|
94
|
+
"""
|
|
95
|
+
present: list[int] = []
|
|
96
|
+
zeroed: list[bool] = []
|
|
97
|
+
zero_flags: list[bool] = []
|
|
98
|
+
index = 0
|
|
99
|
+
while True:
|
|
100
|
+
(packed,) = struct.unpack_from("<H", data, at)
|
|
101
|
+
at += 2
|
|
102
|
+
frag = Fragment.unpack(packed)
|
|
103
|
+
index += frag.skip
|
|
104
|
+
for _ in range(frag.values):
|
|
105
|
+
present.append(index)
|
|
106
|
+
zero_flags.append(frag.has_zeroes)
|
|
107
|
+
index += 1
|
|
108
|
+
if frag.is_last:
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
# Properties in zero-marked fragments each get a bit saying whether the
|
|
112
|
+
# value was left out because it was zero.
|
|
113
|
+
zero_count = sum(1 for flag in zero_flags if flag)
|
|
114
|
+
if zero_count:
|
|
115
|
+
mask_bytes = (zero_count + 7) // 8
|
|
116
|
+
mask = int.from_bytes(data[at : at + mask_bytes], "little")
|
|
117
|
+
at += mask_bytes
|
|
118
|
+
bit = 0
|
|
119
|
+
for flag in zero_flags:
|
|
120
|
+
if flag:
|
|
121
|
+
zeroed.append(bool(mask >> bit & 1))
|
|
122
|
+
bit += 1
|
|
123
|
+
else:
|
|
124
|
+
zeroed.append(False)
|
|
125
|
+
else:
|
|
126
|
+
zeroed = [False] * len(present)
|
|
127
|
+
return present, zeroed, at
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
#: Structs Unreal writes with its own code instead of as properties.
|
|
131
|
+
#: Sizes are what they take up in cooked data.
|
|
132
|
+
NATIVE_STRUCTS = {
|
|
133
|
+
"GameplayTag": 8, # a single FName
|
|
134
|
+
"Guid": 16,
|
|
135
|
+
"Color": 4,
|
|
136
|
+
"LinearColor": 16,
|
|
137
|
+
"IntPoint": 8,
|
|
138
|
+
"Vector2D": 16,
|
|
139
|
+
"Vector": 24,
|
|
140
|
+
"Vector4": 32,
|
|
141
|
+
"Rotator": 24,
|
|
142
|
+
"Quat": 32,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _fname(data: bytes, at: int) -> tuple[int, int]:
|
|
147
|
+
"""An FName in cooked data. A name-map index and a number."""
|
|
148
|
+
return struct.unpack_from("<II", data, at)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def value_size(data: bytes, at: int, info, mappings: Mappings) -> int:
|
|
152
|
+
"""How many bytes the value at `at` takes up."""
|
|
153
|
+
kind = info.kind
|
|
154
|
+
if kind in FIXED_WIDTH:
|
|
155
|
+
return FIXED_WIDTH[kind]
|
|
156
|
+
if kind == PropertyType.STRUCT:
|
|
157
|
+
name = info.struct_name
|
|
158
|
+
# If a struct has a schema it is written as unversioned properties,
|
|
159
|
+
# even when it looks like one Unreal handles itself. FGameplayTag has
|
|
160
|
+
# its own header, for example. Only fall back to the native sizes when
|
|
161
|
+
# the mappings have nothing for it.
|
|
162
|
+
if name in mappings.schemas:
|
|
163
|
+
return skip_struct(data, at, name, mappings) - at
|
|
164
|
+
if name in NATIVE_STRUCTS:
|
|
165
|
+
return NATIVE_STRUCTS[name]
|
|
166
|
+
raise PropertyError(f"struct {name!r} has no schema and is not a known native")
|
|
167
|
+
if kind == PropertyType.STR:
|
|
168
|
+
(length,) = struct.unpack_from("<i", data, at)
|
|
169
|
+
return 4 + (abs(length) * 2 if length < 0 else length)
|
|
170
|
+
if kind in (PropertyType.SOFT_OBJECT, PropertyType.ASSET_OBJECT):
|
|
171
|
+
return 12 # FName plus a sub-path FString index
|
|
172
|
+
if kind == PropertyType.INTERFACE:
|
|
173
|
+
return 4 # just the object reference
|
|
174
|
+
if kind == PropertyType.DELEGATE:
|
|
175
|
+
return 12 # FScriptDelegate: object reference plus a function name
|
|
176
|
+
if kind == PropertyType.MULTICAST_DELEGATE:
|
|
177
|
+
# An invocation list of FScriptDelegate. Cooked defaults are normally
|
|
178
|
+
# empty, since bindings happen at runtime.
|
|
179
|
+
(count,) = struct.unpack_from("<i", data, at)
|
|
180
|
+
return 4 + count * 12
|
|
181
|
+
if kind == PropertyType.ARRAY:
|
|
182
|
+
(count,) = struct.unpack_from("<i", data, at)
|
|
183
|
+
cursor = at + 4
|
|
184
|
+
for _ in range(count):
|
|
185
|
+
cursor += value_size(data, cursor, info.inner[0], mappings)
|
|
186
|
+
return cursor - at
|
|
187
|
+
if kind in (PropertyType.MAP, PropertyType.SET):
|
|
188
|
+
# Both start with a count of keys to remove, which is always zero in
|
|
189
|
+
# cooked data because there is no base object to remove them from.
|
|
190
|
+
(removals,) = struct.unpack_from("<i", data, at)
|
|
191
|
+
if removals:
|
|
192
|
+
raise PropertyError(
|
|
193
|
+
f"{info!r} wants to remove {removals} keys, which cooked data "
|
|
194
|
+
"should never do"
|
|
195
|
+
)
|
|
196
|
+
(count,) = struct.unpack_from("<i", data, at + 4)
|
|
197
|
+
cursor = at + 8
|
|
198
|
+
for _ in range(count):
|
|
199
|
+
for inner in info.inner:
|
|
200
|
+
cursor += value_size(data, cursor, inner, mappings)
|
|
201
|
+
return cursor - at
|
|
202
|
+
raise PropertyError(f"unsupported property type {info!r}")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def skip_struct(data: bytes, at: int, struct_name: str, mappings: Mappings) -> int:
|
|
206
|
+
"""Where a struct written as unversioned properties finishes."""
|
|
207
|
+
present, zeroed, cursor = read_header(data, at)
|
|
208
|
+
props = mappings.properties(struct_name)
|
|
209
|
+
by_index = {p.schema_index: p for p in props}
|
|
210
|
+
for schema_index, is_zero in zip(present, zeroed, strict=True):
|
|
211
|
+
if is_zero:
|
|
212
|
+
continue
|
|
213
|
+
info = by_index.get(schema_index)
|
|
214
|
+
if info is None:
|
|
215
|
+
raise PropertyError(
|
|
216
|
+
f"{struct_name} has no property at schema index {schema_index}; "
|
|
217
|
+
"the mappings do not match this asset"
|
|
218
|
+
)
|
|
219
|
+
cursor += value_size(data, cursor, info.type, mappings)
|
|
220
|
+
return cursor
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def read_struct(
|
|
224
|
+
data: bytes, at: int, struct_name: str, mappings: Mappings
|
|
225
|
+
) -> tuple[dict[str, Located], int]:
|
|
226
|
+
"""Every property in a struct, and where the struct finishes."""
|
|
227
|
+
present, zeroed, cursor = read_header(data, at)
|
|
228
|
+
by_index = {p.schema_index: p for p in mappings.properties(struct_name)}
|
|
229
|
+
out: dict[str, Located] = {}
|
|
230
|
+
for schema_index, is_zero in zip(present, zeroed, strict=True):
|
|
231
|
+
info = by_index.get(schema_index)
|
|
232
|
+
if info is None:
|
|
233
|
+
raise PropertyError(
|
|
234
|
+
f"{struct_name} has no property at schema index {schema_index}; "
|
|
235
|
+
"the mappings do not match this asset"
|
|
236
|
+
)
|
|
237
|
+
if is_zero:
|
|
238
|
+
out[info.name] = Located(info.name, -1, 0, info.type.kind, 0)
|
|
239
|
+
continue
|
|
240
|
+
size = value_size(data, cursor, info.type, mappings)
|
|
241
|
+
out[info.name] = Located(
|
|
242
|
+
info.name,
|
|
243
|
+
cursor,
|
|
244
|
+
size,
|
|
245
|
+
info.type.kind,
|
|
246
|
+
_decode(data, cursor, info.type.kind),
|
|
247
|
+
)
|
|
248
|
+
cursor += size
|
|
249
|
+
return out, cursor
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _decode(data: bytes, at: int, kind: int):
|
|
253
|
+
"""The Python value for a fixed-width property, or None if it is not one."""
|
|
254
|
+
fmt = NUMERIC_FORMAT.get(kind)
|
|
255
|
+
if fmt:
|
|
256
|
+
return struct.unpack_from(fmt, data, at)[0]
|
|
257
|
+
if kind == PropertyType.NAME:
|
|
258
|
+
return _fname(data, at)
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def write_number(data: bytearray, located: Located, value: float) -> None:
|
|
263
|
+
"""Overwrite a fixed-width number where it sits.
|
|
264
|
+
|
|
265
|
+
Numbers never change width, so nothing else in the package has to move.
|
|
266
|
+
"""
|
|
267
|
+
fmt = NUMERIC_FORMAT.get(located.type_kind)
|
|
268
|
+
if fmt is None:
|
|
269
|
+
raise PropertyError(f"{located.name} is not a numeric property")
|
|
270
|
+
if located.offset < 0:
|
|
271
|
+
raise PropertyError(
|
|
272
|
+
f"{located.name} was omitted from this asset because it is zero, "
|
|
273
|
+
"so there is nothing to overwrite"
|
|
274
|
+
)
|
|
275
|
+
struct.pack_into(fmt, data, located.offset, value)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Readers and writers for the file formats Unreal keeps game data in."""
|
|
2
|
+
|
|
3
|
+
from .iostore import IoStoreReader, IoStoreWriter, chunk_hash, chunk_id, package_id
|
|
4
|
+
from .locres import LocRes
|
|
5
|
+
from .pak import Entry, PakReader, PakWriter
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Entry",
|
|
9
|
+
"IoStoreReader",
|
|
10
|
+
"IoStoreWriter",
|
|
11
|
+
"LocRes",
|
|
12
|
+
"PakReader",
|
|
13
|
+
"PakWriter",
|
|
14
|
+
"chunk_hash",
|
|
15
|
+
"chunk_id",
|
|
16
|
+
"package_id",
|
|
17
|
+
]
|