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,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]
|
zcmodkit/formats/pak.py
ADDED
|
@@ -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
|