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.
@@ -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
+ ]