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,72 @@
1
+ """Oodle decompression, for reading compressed entries out of the game's paks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import os
7
+ from pathlib import Path
8
+
9
+ _SEARCH_NAMES = ("oodle-data-shared.dll", "oo2core_9_win64.dll", "oo2core_8_win64.dll")
10
+ _lib = None
11
+ _fn = None
12
+
13
+
14
+ def _candidates() -> list[Path]:
15
+ """The usual places an Oodle DLL turns up."""
16
+ out: list[Path] = []
17
+ env = os.environ.get("ZCMODKIT_OODLE")
18
+ if env:
19
+ out.append(Path(env))
20
+ for base in (Path.cwd(), Path(__file__).parent, Path.home() / "Downloads"):
21
+ out.extend(base / name for name in _SEARCH_NAMES)
22
+ # FModel keeps one in its Output/.data folder
23
+ out.extend((Path.home() / "Downloads").glob("*/Output/.data/oodle-data-shared.dll"))
24
+ return out
25
+
26
+
27
+ def load(path: str | os.PathLike | None = None):
28
+ """Load the Oodle DLL and hand back its decompress function."""
29
+ global _lib, _fn
30
+ if _fn is not None and path is None:
31
+ return _fn
32
+ tried = [Path(path)] if path else _candidates()
33
+ for p in tried:
34
+ if not p.is_file():
35
+ continue
36
+ lib = ctypes.CDLL(str(p))
37
+ fn = lib.OodleLZ_Decompress
38
+ fn.restype = ctypes.c_ssize_t
39
+ fn.argtypes = [
40
+ ctypes.c_void_p,
41
+ ctypes.c_ssize_t,
42
+ ctypes.c_void_p,
43
+ ctypes.c_ssize_t,
44
+ ctypes.c_int,
45
+ ctypes.c_int,
46
+ ctypes.c_int,
47
+ ctypes.c_void_p,
48
+ ctypes.c_ssize_t,
49
+ ctypes.c_void_p,
50
+ ctypes.c_void_p,
51
+ ctypes.c_void_p,
52
+ ctypes.c_ssize_t,
53
+ ctypes.c_int,
54
+ ]
55
+ _lib, _fn = lib, fn
56
+ return fn
57
+ raise FileNotFoundError(
58
+ "Could not find an Oodle DLL. Set ZCMODKIT_OODLE to the full path of "
59
+ "oodle-data-shared.dll (FModel ships one in its Output/.data folder)."
60
+ )
61
+
62
+
63
+ def decompress(
64
+ data: bytes, out_size: int, dll: str | os.PathLike | None = None
65
+ ) -> bytes:
66
+ """Decompress one Oodle block."""
67
+ fn = load(dll)
68
+ dst = ctypes.create_string_buffer(out_size)
69
+ n = fn(data, len(data), dst, out_size, 1, 0, 0, None, 0, None, None, None, 0, 3)
70
+ if n != out_size:
71
+ raise RuntimeError(f"Oodle decompress failed: got {n}, expected {out_size}")
72
+ return dst.raw[:out_size]
@@ -0,0 +1,248 @@
1
+ """Small reader and writer for UE4 and UE5 .pak archives, version 11, no encryption."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import io
7
+ import struct
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from . import oodle
12
+
13
+ PAK_MAGIC = 0x5A6F12E1
14
+ NUL = bytes([0])
15
+
16
+
17
+ def _rstr(st: io.BytesIO) -> str:
18
+ """Read an FString."""
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. ASCII, null terminated."""
29
+ raw = s.encode("latin-1") + NUL
30
+ return struct.pack("<i", len(raw)) + raw
31
+
32
+
33
+ @dataclass
34
+ class Entry:
35
+ """One file inside a pak."""
36
+
37
+ path: str
38
+ offset: int
39
+ size: int
40
+ uncompressed_size: int
41
+ compression_index: int
42
+ block_count: int
43
+
44
+
45
+ class PakReader:
46
+ """Reads a .pak index and pulls files back out."""
47
+
48
+ def __init__(self, path: str | Path):
49
+ self.path = Path(path)
50
+ self._f = open(self.path, "rb") # noqa: SIM115, closed by close()/__exit__
51
+ self.entries: dict[str, Entry] = {}
52
+ self._read_index()
53
+
54
+ def close(self) -> None:
55
+ self._f.close()
56
+
57
+ def __enter__(self):
58
+ return self
59
+
60
+ def __exit__(self, *exc):
61
+ self.close()
62
+
63
+ def _read_index(self) -> None:
64
+ f = self._f
65
+ f.seek(0, 2)
66
+ size = f.tell()
67
+ tail_len = min(4096, size)
68
+ f.seek(size - tail_len)
69
+ tail = f.read(tail_len)
70
+ h = tail.rfind(struct.pack("<I", PAK_MAGIC))
71
+ if h < 0:
72
+ raise ValueError(f"{self.path.name}: not a pak file (no magic)")
73
+ (self.version,) = struct.unpack_from("<i", tail, h + 4)
74
+ idx_off, idx_size = struct.unpack_from("<qq", tail, h + 8)
75
+
76
+ f.seek(idx_off)
77
+ idx = io.BytesIO(f.read(idx_size))
78
+ self.mount_point = _rstr(idx)
79
+ idx.read(4 + 8) # NumEntries, PathHashSeed
80
+ if struct.unpack("<i", idx.read(4))[0]:
81
+ idx.read(36) # path hash index offset/size/hash
82
+ fdi_off = fdi_size = None
83
+ if struct.unpack("<i", idx.read(4))[0]:
84
+ fdi_off, fdi_size = struct.unpack("<qq", idx.read(16))
85
+ idx.read(20)
86
+ (enc_size,) = struct.unpack("<i", idx.read(4))
87
+ encoded = idx.read(enc_size)
88
+ if fdi_off is None:
89
+ raise ValueError("pak has no full directory index; unsupported")
90
+
91
+ f.seek(fdi_off)
92
+ fdi = io.BytesIO(f.read(fdi_size))
93
+ (ndirs,) = struct.unpack("<i", fdi.read(4))
94
+ for _ in range(ndirs):
95
+ d = _rstr(fdi)
96
+ (nfiles,) = struct.unpack("<i", fdi.read(4))
97
+ for _ in range(nfiles):
98
+ name = _rstr(fdi)
99
+ (eo,) = struct.unpack("<i", fdi.read(4))
100
+ self.entries[d + name] = self._decode(d + name, encoded, eo)
101
+
102
+ @staticmethod
103
+ def _decode(path: str, buf: bytes, at: int) -> Entry:
104
+ """Unpack one bit-packed index entry."""
105
+ (v,) = struct.unpack_from("<I", buf, at)
106
+ p = at + 4
107
+ cmi = (v >> 23) & 0x3F
108
+ fmt = "<I" if v & (1 << 31) else "<q"
109
+ (offset,) = struct.unpack_from(fmt, buf, p)
110
+ p += 4 if v & (1 << 31) else 8
111
+ fmt = "<I" if v & (1 << 30) else "<q"
112
+ (usize,) = struct.unpack_from(fmt, buf, p)
113
+ p += 4 if v & (1 << 30) else 8
114
+ if cmi:
115
+ fmt = "<I" if v & (1 << 29) else "<q"
116
+ (size,) = struct.unpack_from(fmt, buf, p)
117
+ else:
118
+ size = usize
119
+ return Entry(path, offset, size, usize, cmi, (v >> 6) & 0xFFFF)
120
+
121
+ def read(self, path: str) -> bytes:
122
+ """Pull one file out of the pak, decompressing it if it needs it."""
123
+ e = self.entries.get(path)
124
+ if e is None:
125
+ raise KeyError(path)
126
+ f = self._f
127
+ f.seek(e.offset)
128
+ head = f.read(64 + e.block_count * 16 + 32)
129
+ # header: Offset, Size, UncompressedSize, CompressionMethodIndex,
130
+ # Hash, [blocks], Flags, BlockSize
131
+ p = 24
132
+ (cmi,) = struct.unpack_from("<i", head, p)
133
+ p += 4
134
+ p += 20
135
+ blocks = []
136
+ if cmi:
137
+ (nb,) = struct.unpack_from("<i", head, p)
138
+ p += 4
139
+ for _ in range(nb):
140
+ s, t = struct.unpack_from("<qq", head, p)
141
+ p += 16
142
+ blocks.append((s, t))
143
+ p += 1
144
+ (block_size,) = struct.unpack_from("<i", head, p)
145
+ p += 4
146
+
147
+ if not cmi:
148
+ f.seek(e.offset + p)
149
+ return f.read(e.size)
150
+
151
+ out = bytearray()
152
+ base = e.offset if blocks and blocks[0][0] < e.offset else 0
153
+ for s, t in blocks:
154
+ f.seek(base + s)
155
+ comp = f.read(t - s)
156
+ want = min(block_size, e.uncompressed_size - len(out))
157
+ out += oodle.decompress(comp, want)
158
+ return bytes(out)
159
+
160
+
161
+ class PakWriter:
162
+ """Builds a version 11 pak, uncompressed and unencrypted."""
163
+
164
+ VERSION = 11
165
+ MOUNT_POINT = "../../../"
166
+
167
+ def __init__(self):
168
+ self._files: dict[str, bytes] = {}
169
+
170
+ def add(self, path: str, data: bytes) -> None:
171
+ """Queue up a file at an in-pak path like 'SWZeroCompany/Content/...'."""
172
+ self._files[path.replace("\\", "/")] = data
173
+
174
+ @staticmethod
175
+ def _header(offset: int, size: int, sha: bytes) -> bytes:
176
+ # UnrealPak puts 0 in the inline header Offset field, not the real one.
177
+ del offset
178
+ return (
179
+ struct.pack("<qqq", 0, size, size)
180
+ + struct.pack("<i", 0)
181
+ + sha
182
+ + bytes([0])
183
+ + struct.pack("<i", 0)
184
+ )
185
+
186
+ @staticmethod
187
+ def _encoded(offset: int, size: int) -> bytes:
188
+ flags = 0
189
+ if offset <= 0xFFFFFFFF:
190
+ flags |= 1 << 31
191
+ if size <= 0xFFFFFFFF:
192
+ flags |= 1 << 30
193
+ out = struct.pack("<I", flags)
194
+ out += struct.pack("<I" if offset <= 0xFFFFFFFF else "<q", offset)
195
+ out += struct.pack("<I" if size <= 0xFFFFFFFF else "<q", size)
196
+ return out
197
+
198
+ def write(self, dest: str | Path) -> Path:
199
+ """Write the pak out and give back its path."""
200
+ dest = Path(dest)
201
+ dest.parent.mkdir(parents=True, exist_ok=True)
202
+ body = bytearray()
203
+ encoded = bytearray()
204
+ offsets: dict[str, int] = {}
205
+ for path, data in self._files.items():
206
+ offset = len(body)
207
+ sha = hashlib.sha1(data).digest()
208
+ body += self._header(offset, len(data), sha) + data
209
+ offsets[path] = len(encoded)
210
+ encoded += self._encoded(offset, len(data))
211
+
212
+ # full directory index: {directory: {filename: encoded entry offset}}
213
+ dirs: dict[str, dict[str, int]] = {}
214
+ for path in self._files:
215
+ d, _, name = path.rpartition("/")
216
+ dirs.setdefault(d + "/", {})[name] = offsets[path]
217
+ fdi = bytearray(struct.pack("<i", len(dirs)))
218
+ for d, files in dirs.items():
219
+ fdi += _wstr(d) + struct.pack("<i", len(files))
220
+ for name, eo in files.items():
221
+ fdi += _wstr(name) + struct.pack("<i", eo)
222
+
223
+ index_offset = len(body)
224
+ fdi_offset = index_offset # patched below once primary size is known
225
+ primary = bytearray()
226
+ primary += _wstr(self.MOUNT_POINT)
227
+ primary += struct.pack("<i", len(self._files))
228
+ primary += struct.pack("<Q", 0) # path hash seed
229
+ primary += struct.pack("<i", 0) # no path hash index
230
+ primary += struct.pack("<i", 1) # has full directory index
231
+ fdi_ptr = len(primary)
232
+ primary += struct.pack("<qq", 0, len(fdi)) + bytes(20)
233
+ primary += struct.pack("<i", len(encoded)) + bytes(encoded)
234
+ primary += struct.pack("<i", 0) # no non-encoded entries
235
+
236
+ fdi_offset = index_offset + len(primary)
237
+ struct.pack_into("<q", primary, fdi_ptr, fdi_offset)
238
+ struct.pack_into("<qq", primary, fdi_ptr, fdi_offset, len(fdi))
239
+ primary[fdi_ptr + 16 : fdi_ptr + 36] = hashlib.sha1(bytes(fdi)).digest()
240
+
241
+ out = bytes(body) + bytes(primary) + bytes(fdi)
242
+ footer = bytes(16) + bytes([0])
243
+ footer += struct.pack("<I", PAK_MAGIC) + struct.pack("<i", self.VERSION)
244
+ footer += struct.pack("<qq", index_offset, len(primary))
245
+ footer += hashlib.sha1(bytes(primary)).digest()
246
+ footer += bytes(32 * 5)
247
+ dest.write_bytes(out + footer)
248
+ return dest
@@ -0,0 +1,328 @@
1
+ """Reading .usmap mappings files.
2
+
3
+ Cooked packages store property values but not the names or types of the fields
4
+ they belong to. A mappings file fills that in, which is the only reason editing
5
+ a value by name is possible at all.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import struct
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import ClassVar
14
+
15
+ MAGIC = 0x30C4
16
+
17
+ #: The format revisions that change how we read things.
18
+ VERSION_LONG_FNAME = 2
19
+ VERSION_LARGE_ENUMS = 3
20
+
21
+
22
+ class MappingsError(ValueError):
23
+ """A mappings file would not parse, or does not match this asset."""
24
+
25
+
26
+ class PropertyType:
27
+ """EPropertyType, the way a mappings file writes it."""
28
+
29
+ BYTE = 0
30
+ BOOL = 1
31
+ INT = 2
32
+ FLOAT = 3
33
+ OBJECT = 4
34
+ NAME = 5
35
+ DELEGATE = 6
36
+ DOUBLE = 7
37
+ ARRAY = 8
38
+ STRUCT = 9
39
+ STR = 10
40
+ TEXT = 11
41
+ INTERFACE = 12
42
+ MULTICAST_DELEGATE = 13
43
+ WEAK_OBJECT = 14
44
+ LAZY_OBJECT = 15
45
+ ASSET_OBJECT = 16
46
+ SOFT_OBJECT = 17
47
+ UINT64 = 18
48
+ UINT32 = 19
49
+ UINT16 = 20
50
+ INT64 = 21
51
+ INT16 = 22
52
+ INT8 = 23
53
+ MAP = 24
54
+ SET = 25
55
+ ENUM = 26
56
+ FIELD_PATH = 27
57
+ OPTIONAL = 28
58
+
59
+ NAMES: ClassVar[dict[int, str]] = {
60
+ BYTE: "Byte",
61
+ BOOL: "Bool",
62
+ INT: "Int",
63
+ FLOAT: "Float",
64
+ OBJECT: "Object",
65
+ NAME: "Name",
66
+ DELEGATE: "Delegate",
67
+ DOUBLE: "Double",
68
+ ARRAY: "Array",
69
+ STRUCT: "Struct",
70
+ STR: "Str",
71
+ TEXT: "Text",
72
+ INTERFACE: "Interface",
73
+ MULTICAST_DELEGATE: "MulticastDelegate",
74
+ WEAK_OBJECT: "WeakObject",
75
+ LAZY_OBJECT: "LazyObject",
76
+ ASSET_OBJECT: "AssetObject",
77
+ SOFT_OBJECT: "SoftObject",
78
+ UINT64: "UInt64",
79
+ UINT32: "UInt32",
80
+ UINT16: "UInt16",
81
+ INT64: "Int64",
82
+ INT16: "Int16",
83
+ INT8: "Int8",
84
+ MAP: "Map",
85
+ SET: "Set",
86
+ ENUM: "Enum",
87
+ FIELD_PATH: "FieldPath",
88
+ OPTIONAL: "Optional",
89
+ }
90
+
91
+
92
+ @dataclass
93
+ class TypeInfo:
94
+ """A property's type, plus any inner types it wraps."""
95
+
96
+ kind: int
97
+ struct_name: str | None = None
98
+ enum_name: str | None = None
99
+ inner: list[TypeInfo] = field(default_factory=list)
100
+
101
+ @property
102
+ def name(self) -> str:
103
+ return PropertyType.NAMES.get(self.kind, f"Unknown({self.kind})")
104
+
105
+ def __repr__(self) -> str:
106
+ extra = self.struct_name or self.enum_name or ""
107
+ return f"{self.name}{'<' + extra + '>' if extra else ''}"
108
+
109
+
110
+ @dataclass
111
+ class PropertyInfo:
112
+ """One property of a schema that actually gets written out."""
113
+
114
+ name: str
115
+ schema_index: int
116
+ array_size: int
117
+ type: TypeInfo
118
+
119
+ def __repr__(self) -> str:
120
+ return f"<Property {self.name} {self.type!r} @{self.schema_index}>"
121
+
122
+
123
+ @dataclass
124
+ class Schema:
125
+ """A class or struct, and its properties in the order they get written."""
126
+
127
+ name: str
128
+ super_name: str | None
129
+ property_count: int
130
+ properties: list[PropertyInfo]
131
+
132
+ def __repr__(self) -> str:
133
+ return f"<Schema {self.name} properties={len(self.properties)}>"
134
+
135
+
136
+ class Mappings:
137
+ """Schemas for every class and struct in one build of the game."""
138
+
139
+ def __init__(
140
+ self,
141
+ names: list[str],
142
+ enums: dict[str, dict[int, str]],
143
+ schemas: dict[str, Schema],
144
+ version: int,
145
+ ):
146
+ self.names = names
147
+ self.enums = enums
148
+ self.schemas = schemas
149
+ self.version = version
150
+
151
+ @classmethod
152
+ def load(cls, path: str | Path) -> Mappings:
153
+ """Parse a .usmap off disk."""
154
+ return cls.loads(Path(path).read_bytes())
155
+
156
+ @classmethod
157
+ def loads(cls, data: bytes) -> Mappings:
158
+ """Parse .usmap bytes."""
159
+ (magic,) = struct.unpack_from("<H", data, 0)
160
+ if magic != MAGIC:
161
+ raise MappingsError(f"not a .usmap file (magic 0x{magic:04x})")
162
+ version = data[2]
163
+ # bHasVersioning is written as a 32-bit bool.
164
+ (has_versioning,) = struct.unpack_from("<I", data, 3)
165
+ if has_versioning:
166
+ raise MappingsError("versioned mappings files are not supported")
167
+ compression = data[7]
168
+ compressed, _uncompressed = struct.unpack_from("<II", data, 8)
169
+ body = data[16:]
170
+ if compression != 0:
171
+ raise MappingsError(
172
+ f"compressed mappings are not supported (method {compression}); "
173
+ "export an uncompressed .usmap"
174
+ )
175
+ if compressed != len(body):
176
+ raise MappingsError(
177
+ f"mappings declare {compressed} bytes but {len(body)} follow the header"
178
+ )
179
+ return cls._read_body(body, version)
180
+
181
+ @staticmethod
182
+ def _read_body(b: bytes, version: int) -> Mappings:
183
+ at = 0
184
+
185
+ def u8() -> int:
186
+ nonlocal at
187
+ at += 1
188
+ return b[at - 1]
189
+
190
+ def u16() -> int:
191
+ nonlocal at
192
+ v = struct.unpack_from("<H", b, at)[0]
193
+ at += 2
194
+ return v
195
+
196
+ def u32() -> int:
197
+ nonlocal at
198
+ v = struct.unpack_from("<I", b, at)[0]
199
+ at += 4
200
+ return v
201
+
202
+ count = u32()
203
+ names: list[str] = []
204
+ for _ in range(count):
205
+ length = u16() if version >= VERSION_LONG_FNAME else u8()
206
+ names.append(b[at : at + length].decode("utf-8", "replace"))
207
+ at += length
208
+
209
+ def u64() -> int:
210
+ nonlocal at
211
+ v = struct.unpack_from("<Q", b, at)[0]
212
+ at += 8
213
+ return v
214
+
215
+ # Every entry has its numeric value first, then its name.
216
+ enums: dict[str, dict[int, str]] = {}
217
+ for _ in range(u32()):
218
+ enum_name = names[u32()]
219
+ entries = u16() if version >= VERSION_LARGE_ENUMS else u8()
220
+ enums[enum_name] = {u64(): names[u32()] for _ in range(entries)}
221
+
222
+ def read_type() -> TypeInfo:
223
+ kind = u8()
224
+ info = TypeInfo(kind)
225
+ if kind == PropertyType.ENUM:
226
+ info.inner.append(read_type())
227
+ info.enum_name = names[u32()]
228
+ elif kind == PropertyType.STRUCT:
229
+ info.struct_name = names[u32()]
230
+ elif kind in (PropertyType.ARRAY, PropertyType.SET, PropertyType.OPTIONAL):
231
+ info.inner.append(read_type())
232
+ elif kind == PropertyType.MAP:
233
+ info.inner.append(read_type())
234
+ info.inner.append(read_type())
235
+ return info
236
+
237
+ schemas: dict[str, Schema] = {}
238
+ for _ in range(u32()):
239
+ schema_name = names[u32()]
240
+ super_index = u32()
241
+ super_name = None if super_index == 0xFFFFFFFF else names[super_index]
242
+ prop_count = u16()
243
+ serializable = u16()
244
+ props: list[PropertyInfo] = []
245
+ for _ in range(serializable):
246
+ schema_index = u16()
247
+ array_size = u8()
248
+ prop_name = names[u32()]
249
+ props.append(
250
+ PropertyInfo(prop_name, schema_index, array_size, read_type())
251
+ )
252
+ schemas[schema_name] = Schema(schema_name, super_name, prop_count, props)
253
+
254
+ # Later revisions tack on sections we do not need, module paths and
255
+ # so on. Stopping after the schemas is fine. Running off the end is not.
256
+ if at > len(b):
257
+ raise MappingsError("mappings body is truncated")
258
+ return Mappings(names, enums, schemas, version)
259
+
260
+ def schema(self, name: str) -> Schema:
261
+ """Look up a schema by class or struct name."""
262
+ try:
263
+ return self.schemas[name]
264
+ except KeyError:
265
+ raise MappingsError(f"no schema for {name!r} in these mappings") from None
266
+
267
+ def properties(self, name: str) -> list[PropertyInfo]:
268
+ """A schema's properties, inherited ones included, base class first.
269
+
270
+ A .usmap numbers each schema's properties from zero, but serialization
271
+ numbers them straight through the whole chain. So a class's own
272
+ properties start after however many its ancestors have between them,
273
+ and every inherited index gets shifted up to match. Without that, a
274
+ derived class collides with its own base and the walk reads the wrong
275
+ property.
276
+ """
277
+ chain, current = [], name
278
+ seen = set()
279
+ while current and current not in seen:
280
+ seen.add(current)
281
+ schema = self.schemas.get(current)
282
+ if schema is None:
283
+ break
284
+ chain.append(schema)
285
+ current = schema.super_name
286
+
287
+ out: list[PropertyInfo] = []
288
+ base = 0
289
+ for schema in reversed(chain):
290
+ out.extend(
291
+ PropertyInfo(
292
+ prop.name, base + prop.schema_index, prop.array_size, prop.type
293
+ )
294
+ for prop in schema.properties
295
+ )
296
+ base += schema.property_count
297
+ return out
298
+
299
+ def __repr__(self) -> str:
300
+ return (
301
+ f"<Mappings version={self.version} names={len(self.names)} "
302
+ f"enums={len(self.enums)} schemas={len(self.schemas)}>"
303
+ )
304
+
305
+
306
+ #: Which build of the game these bundled mappings came from.
307
+ BUNDLED_BUILD = "SWZeroCompany-5.6.1-196320+++ProjectBruno+Stable-a1e7f571"
308
+
309
+ _bundled: Mappings | None = None
310
+
311
+
312
+ def bundled() -> Mappings:
313
+ """The mappings that ship with zcmodkit, parsed once and kept around.
314
+
315
+ They describe one build of the game. If a patch moves a struct around, the
316
+ parse stops landing exactly where it should and the editor raises instead
317
+ of writing a value to the wrong place.
318
+ """
319
+ global _bundled
320
+ if _bundled is None:
321
+ path = Path(__file__).parent.parent / "data" / "mappings.usmap"
322
+ if not path.is_file():
323
+ raise MappingsError(
324
+ f"bundled mappings are missing from the install ({path}). "
325
+ "Editing values needs them; text edits do not."
326
+ )
327
+ _bundled = Mappings.load(path)
328
+ return _bundled