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,497 @@
1
+ """Reader and writer for UE5 IoStore containers (.utoc and .ucas).
2
+
3
+ Zero Company ships every package in IoStore and will only mount mods that are
4
+ IoStore containers too, so this is the format mods have to be written in.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import struct
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from blake3 import blake3
16
+ from cityhash import CityHash64
17
+
18
+ from . import oodle
19
+ from .pak import PakWriter
20
+
21
+ TOC_MAGIC = b"-==--==--==--==-"
22
+ HEADER_SIZE = 144
23
+ DEFAULT_BLOCK_SIZE = 64 * 1024
24
+ MOUNT_POINT = "../../../"
25
+
26
+ # EIoChunkType5
27
+ CHUNK_EXPORT_BUNDLE_DATA = 1
28
+ CHUNK_CONTAINER_HEADER = 6
29
+
30
+
31
+ def package_id(package_path: str) -> int:
32
+ """FPackageId. CityHash64 of the lowercased package name as UTF-16."""
33
+ return CityHash64(package_path.lower().encode("utf-16-le"))
34
+
35
+
36
+ def chunk_id(
37
+ pkg_id: int, index: int = 0, chunk_type: int = CHUNK_EXPORT_BUNDLE_DATA
38
+ ) -> bytes:
39
+ """FIoChunkId. An 8-byte package id, a 2-byte index, a pad byte, a type."""
40
+ return struct.pack("<QHBB", pkg_id, index, 0, chunk_type)
41
+
42
+
43
+ def chunk_hash(data: bytes) -> bytes:
44
+ """FIoChunkHash. BLAKE3-160, padded out to 24 bytes with zeroes."""
45
+ return blake3(data).digest(length=32)[:20] + bytes(4)
46
+
47
+
48
+ @dataclass
49
+ class Chunk:
50
+ """One chunk inside a container."""
51
+
52
+ id: bytes
53
+ offset: int
54
+ length: int
55
+ name: str | None = None
56
+
57
+ @property
58
+ def package_id(self) -> int:
59
+ return struct.unpack_from("<Q", self.id, 0)[0]
60
+
61
+ @property
62
+ def index(self) -> int:
63
+ """Tells apart several chunks of the same type on one package."""
64
+ return struct.unpack_from("<H", self.id, 8)[0]
65
+
66
+ @property
67
+ def type(self) -> int:
68
+ return self.id[11]
69
+
70
+
71
+ @dataclass
72
+ class TocHeader:
73
+ version: int = 8
74
+ entry_count: int = 0
75
+ block_count: int = 0
76
+ block_entry_size: int = 12
77
+ method_count: int = 0
78
+ method_name_len: int = 32
79
+ block_size: int = DEFAULT_BLOCK_SIZE
80
+ dir_index_size: int = 0
81
+ partition_count: int = 1
82
+ container_id: int = 0
83
+ container_flags: int = 8
84
+ perfect_hash_seed_count: int = 0
85
+ partition_size: int = 0xFFFFFFFFFFFFFFFF
86
+ without_perfect_hash_count: int = 0
87
+
88
+
89
+ def _rstr(b: io.BytesIO) -> str:
90
+ (n,) = struct.unpack("<i", b.read(4))
91
+ if n == 0:
92
+ return ""
93
+ if n < 0:
94
+ return b.read(-n * 2).decode("utf-16-le").rstrip(chr(0))
95
+ return b.read(n).decode("latin-1").rstrip(chr(0))
96
+
97
+
98
+ def _wstr(s: str) -> bytes:
99
+ raw = s.encode("latin-1") + bytes([0])
100
+ return struct.pack("<i", len(raw)) + raw
101
+
102
+
103
+ class IoStoreReader:
104
+ """Reads chunks out of a .utoc and .ucas pair."""
105
+
106
+ def __init__(self, utoc: str | Path):
107
+ self.utoc_path = Path(utoc)
108
+ self.ucas_path = self.utoc_path.with_suffix(".ucas")
109
+ raw = self.utoc_path.read_bytes()
110
+ if raw[:16] != TOC_MAGIC:
111
+ raise ValueError(f"{self.utoc_path.name}: not a .utoc")
112
+ self._parse(raw)
113
+ self._ucas = open(self.ucas_path, "rb") # noqa: SIM115, closed by close()
114
+
115
+ def close(self) -> None:
116
+ self._ucas.close()
117
+
118
+ def __enter__(self):
119
+ return self
120
+
121
+ def __exit__(self, *exc):
122
+ self.close()
123
+
124
+ def _parse(self, raw: bytes) -> None:
125
+ h = TocHeader()
126
+ h.version = raw[16]
127
+ (
128
+ hdr_size,
129
+ h.entry_count,
130
+ h.block_count,
131
+ h.block_entry_size,
132
+ h.method_count,
133
+ h.method_name_len,
134
+ h.block_size,
135
+ h.dir_index_size,
136
+ h.partition_count,
137
+ ) = struct.unpack_from("<9I", raw, 20)
138
+ (h.container_id,) = struct.unpack_from("<Q", raw, 56)
139
+ h.container_flags = raw[80]
140
+ (h.perfect_hash_seed_count,) = struct.unpack_from("<I", raw, 84)
141
+ (h.partition_size,) = struct.unpack_from("<Q", raw, 88)
142
+ (h.without_perfect_hash_count,) = struct.unpack_from("<I", raw, 96)
143
+ self.header = h
144
+
145
+ p = hdr_size
146
+ ids = [raw[p + i * 12 : p + (i + 1) * 12] for i in range(h.entry_count)]
147
+ p += h.entry_count * 12
148
+ self.chunks: list[Chunk] = []
149
+ for i in range(h.entry_count):
150
+ ol = raw[p + i * 10 : p + (i + 1) * 10]
151
+ self.chunks.append(
152
+ Chunk(
153
+ ids[i],
154
+ int.from_bytes(ol[0:5], "big"),
155
+ int.from_bytes(ol[5:10], "big"),
156
+ )
157
+ )
158
+ p += h.entry_count * 10
159
+ p += h.perfect_hash_seed_count * 4
160
+ p += h.without_perfect_hash_count * 4
161
+
162
+ self.blocks: list[tuple[int, int, int, int]] = []
163
+ for i in range(h.block_count):
164
+ b = raw[p + i * 12 : p + (i + 1) * 12]
165
+ self.blocks.append(
166
+ (
167
+ int.from_bytes(b[0:5], "little"),
168
+ int.from_bytes(b[5:8], "little"),
169
+ int.from_bytes(b[8:11], "little"),
170
+ b[11],
171
+ )
172
+ )
173
+ p += h.block_count * 12
174
+
175
+ self.methods = ["None"]
176
+ for _ in range(h.method_count):
177
+ name = raw[p : p + h.method_name_len].split(bytes([0]))[0].decode("latin-1")
178
+ self.methods.append(name)
179
+ p += h.method_name_len
180
+
181
+ self.mount_point = MOUNT_POINT
182
+ self.files: dict[str, int] = {}
183
+ if h.dir_index_size:
184
+ self._parse_dir_index(raw[p : p + h.dir_index_size])
185
+ p += h.dir_index_size
186
+ self._meta_offset = p
187
+
188
+ self.by_package: dict[int, Chunk] = {}
189
+ self._all_by_package: dict[int, list[Chunk]] = {}
190
+ self._store: dict[int, list[int]] | None = None
191
+ for c in self.chunks:
192
+ if c.type == CHUNK_CONTAINER_HEADER:
193
+ continue
194
+ self._all_by_package.setdefault(c.package_id, []).append(c)
195
+ if c.type == CHUNK_EXPORT_BUNDLE_DATA:
196
+ self.by_package.setdefault(c.package_id, c)
197
+
198
+ def imported_packages(self, pkg_id: int) -> list[int]:
199
+ """The package ids this package depends on, from the container header.
200
+
201
+ The Zen loader uses this to pull dependencies in first. Ship a package
202
+ claiming no imports when it has some and it quietly fails to load, so a
203
+ mod has to carry the real list through.
204
+ """
205
+ if self._store is None:
206
+ self._store = self._read_store_entries()
207
+ return list(self._store.get(pkg_id, ()))
208
+
209
+ def _read_store_entries(self) -> dict[int, list[int]]:
210
+ header = next(
211
+ (c for c in self.chunks if c.type == CHUNK_CONTAINER_HEADER), None
212
+ )
213
+ if header is None:
214
+ return {}
215
+ d = self.read_chunk(header)
216
+ (count,) = struct.unpack_from("<I", d, 16)
217
+ ids = struct.unpack_from(f"<{count}Q", d, 20)
218
+ entries_at = 20 + count * 8 + 4
219
+ out: dict[int, list[int]] = {}
220
+ for i, pid in enumerate(ids):
221
+ at = entries_at + i * 16
222
+ num, offset = struct.unpack_from("<ii", d, at)
223
+ if num:
224
+ out[pid] = list(struct.unpack_from(f"<{num}Q", d, at + offset))
225
+ return out
226
+
227
+ def chunks_for(self, pkg_id: int) -> list[Chunk]:
228
+ """Every chunk a package owns, not just its export data.
229
+
230
+ About a quarter of this game's packages come with a bulk data chunk
231
+ alongside. Ship the package without it and the asset is broken.
232
+ """
233
+ return list(self._all_by_package.get(pkg_id, ()))
234
+
235
+ def _parse_dir_index(self, blob: bytes) -> None:
236
+ b = io.BytesIO(blob)
237
+ self.mount_point = _rstr(b)
238
+ (ndirs,) = struct.unpack("<i", b.read(4))
239
+ dirs = [struct.unpack("<4I", b.read(16)) for _ in range(ndirs)]
240
+ (nfiles,) = struct.unpack("<i", b.read(4))
241
+ files = [struct.unpack("<3I", b.read(12)) for _ in range(nfiles)]
242
+ (nstr,) = struct.unpack("<i", b.read(4))
243
+ strings = [_rstr(b) for _ in range(nstr)]
244
+
245
+ INVALID = 0xFFFFFFFF
246
+
247
+ def walk(dir_idx: int, prefix: str) -> None:
248
+ while dir_idx != INVALID:
249
+ name, first_child, next_sib, first_file = dirs[dir_idx]
250
+ path = prefix if name == INVALID else prefix + strings[name] + "/"
251
+ fi = first_file
252
+ while fi != INVALID:
253
+ fname, next_file, user = files[fi]
254
+ self.files[path + strings[fname]] = user
255
+ fi = next_file
256
+ if first_child != INVALID:
257
+ walk(first_child, path)
258
+ dir_idx = next_sib
259
+
260
+ if dirs:
261
+ walk(0, "")
262
+
263
+ def read_chunk(self, chunk: Chunk) -> bytes:
264
+ """Read one chunk, decompressing it if it needs it."""
265
+ bs = self.header.block_size
266
+ first = chunk.offset // bs
267
+ last = (chunk.offset + chunk.length - 1) // bs
268
+ out = bytearray()
269
+ for i in range(first, last + 1):
270
+ off, csize, usize, method = self.blocks[i]
271
+ self._ucas.seek(off)
272
+ raw = self._ucas.read(csize)
273
+ out += raw if method == 0 else oodle.decompress(raw, usize)
274
+ start = chunk.offset - first * bs
275
+ return bytes(out[start : start + chunk.length])
276
+
277
+ def read_package(self, package_path: str) -> bytes:
278
+ """Read a cooked package by its /Game/... path."""
279
+ c = self.by_package.get(package_id(package_path))
280
+ if c is None:
281
+ raise KeyError(package_path)
282
+ return self.read_chunk(c)
283
+
284
+
285
+ # The rest of FIoContainerHeader after the store entries. Optional segment
286
+ # arrays, then an offset into the trailing bytes, then constants. The offset is
287
+ # always 36 past the end of the store section, checked against every container
288
+ # the game ships and against a working community mod.
289
+ _TAIL_OFFSET_FROM_STORE_END = 36
290
+
291
+
292
+ def _header_tail(store_end: int) -> bytes:
293
+ """The bytes that follow the store entries in a container header."""
294
+ out = bytearray(20) # empty optional-segment arrays
295
+ out += struct.pack("<i", store_end + _TAIL_OFFSET_FROM_STORE_END)
296
+ out += struct.pack("<i", 0)
297
+ out += struct.pack("<i", 4)
298
+ out += bytes(16)
299
+ return bytes(out)
300
+
301
+
302
+ def container_header(
303
+ container_id: int, packages: Sequence[tuple[int, Sequence[int]]]
304
+ ) -> bytes:
305
+ """Build the type 6 container header chunk.
306
+
307
+ `packages` is (package id, imported package ids) for each package. The
308
+ imports matter: the loader reads them to pull dependencies in first, and a
309
+ package that claims none when it has some will not load.
310
+ """
311
+ # The loader looks packages up by binary search, so the ids have to be in
312
+ # ascending order and the store entries have to line up with them. Every
313
+ # container the game ships is sorted this way.
314
+ packages = sorted(packages, key=lambda item: item[0])
315
+
316
+ out = bytearray(b"nCoI")
317
+ out += struct.pack("<I", 5) # version
318
+ out += struct.pack("<Q", container_id)
319
+ out += struct.pack("<I", len(packages))
320
+ for pid, _ in packages:
321
+ out += struct.pack("<Q", pid)
322
+
323
+ # Each FFilePackageStoreEntry is two array views of (count, offset), and the
324
+ # offset is measured from the view itself. The arrays they point at follow
325
+ # the entries.
326
+ entries = bytearray()
327
+ data = bytearray()
328
+ entries_size = len(packages) * 16
329
+ for i, (_, imports) in enumerate(packages):
330
+ view_at = i * 16
331
+ if imports:
332
+ offset = entries_size + len(data) - view_at
333
+ entries += struct.pack("<ii", len(imports), offset)
334
+ for imported in imports:
335
+ data += struct.pack("<Q", imported)
336
+ else:
337
+ entries += struct.pack("<ii", 0, 0)
338
+ entries += struct.pack("<ii", 0, 0) # no shader map hashes
339
+
340
+ store = bytes(entries) + bytes(data)
341
+ out += struct.pack("<I", len(store)) + store
342
+ out += _header_tail(len(out))
343
+ return bytes(out)
344
+
345
+
346
+ def _stub_pak() -> bytes:
347
+ """An empty .pak. The engine needs one next to the container to spot the mod."""
348
+ import tempfile
349
+
350
+ import repak
351
+
352
+ with tempfile.TemporaryDirectory() as td:
353
+ out = Path(td) / "stub.pak"
354
+ w = repak.PakBuilder().writer(
355
+ str(out), version=repak.Version.V11, mount_point=MOUNT_POINT
356
+ )
357
+ w.write_index()
358
+ return out.read_bytes()
359
+
360
+
361
+ class IoStoreWriter:
362
+ """Builds a .utoc and .ucas pair, plus the stub .pak that gets it noticed."""
363
+
364
+ def __init__(
365
+ self,
366
+ container_id: int | None = None,
367
+ mount_point: str = MOUNT_POINT,
368
+ block_size: int = DEFAULT_BLOCK_SIZE,
369
+ ):
370
+ self.container_id = container_id if container_id is not None else 0
371
+ self.mount_point = mount_point
372
+ self.block_size = block_size
373
+ #: (chunk id, payload, directory-index name or None)
374
+ self._entries: list[tuple[bytes, bytes, str | None]] = []
375
+ self._packages: list[tuple[int, Sequence[int]]] = []
376
+
377
+ def add_package(
378
+ self,
379
+ package_path: str,
380
+ data: bytes,
381
+ filename: str | None = None,
382
+ companions: Sequence[tuple[bytes, bytes]] = (),
383
+ imports: Sequence[int] = (),
384
+ ) -> None:
385
+ """Add a cooked package under its /Game/... path.
386
+
387
+ `companions` is any other chunk the package owns, usually bulk data,
388
+ as (chunk id, payload) pairs. Ship them. Leave them out and the game
389
+ gets a package whose bulk data has gone missing.
390
+
391
+ `imports` is the package ids this one depends on, copied from the game's
392
+ own container header. Leave them out and the package will not load.
393
+ """
394
+ pid = package_id(package_path)
395
+ name = filename or package_path.rsplit("/", 1)[-1] + ".uasset"
396
+ self._packages.append((pid, tuple(imports)))
397
+ self._entries.append((chunk_id(pid), data, name))
398
+ for cid, payload in companions:
399
+ self._entries.append((cid, payload, None))
400
+
401
+ def _directory_index(self, files: list[tuple[str, int]]) -> bytes:
402
+ """Index of (file name, chunk index). Only named entries go in."""
403
+ INVALID = 0xFFFFFFFF
404
+ out = bytearray(_wstr(self.mount_point))
405
+ out += struct.pack("<i", 1)
406
+ out += struct.pack("<4I", INVALID, INVALID, INVALID, 0) # root directory
407
+ out += struct.pack("<i", len(files))
408
+ for i, (_, chunk_index) in enumerate(files):
409
+ nxt = i + 1 if i + 1 < len(files) else INVALID
410
+ out += struct.pack("<3I", i, nxt, chunk_index) # name, next, chunk
411
+ out += struct.pack("<i", len(files))
412
+ for name, _ in files:
413
+ out += _wstr(name)
414
+ return bytes(out)
415
+
416
+ def write(
417
+ self, base: str | Path, pak_files: dict[str, bytes] | None = None
418
+ ) -> tuple[Path, Path, Path]:
419
+ """Write out .utoc, .ucas and .pak, and give back the three paths.
420
+
421
+ `pak_files` puts real files in the .pak that normally goes out empty.
422
+ Assets belong in the container, not here, but a few things the engine
423
+ wants are plain files rather than packages.
424
+ """
425
+ base = Path(base)
426
+ base.parent.mkdir(parents=True, exist_ok=True)
427
+ bs = self.block_size
428
+
429
+ payloads = [d for _, d, _ in self._entries]
430
+ payloads.append(container_header(self.container_id, self._packages))
431
+ ids = [cid for cid, _, _ in self._entries]
432
+ ids.append(chunk_id(self.container_id, 0, CHUNK_CONTAINER_HEADER))
433
+
434
+ ucas = bytearray()
435
+ chunks: list[tuple[int, int]] = []
436
+ blocks: list[tuple[int, int, int, int]] = []
437
+ logical = 0
438
+ for data in payloads:
439
+ chunks.append((logical, len(data)))
440
+ for start in range(0, max(len(data), 1), bs):
441
+ part = data[start : start + bs]
442
+ blocks.append((len(ucas), len(part), len(part), 0))
443
+ ucas += part
444
+ logical += ((len(data) + bs - 1) // bs) * bs
445
+
446
+ dir_index = self._directory_index(
447
+ [(n, i) for i, (_, _, n) in enumerate(self._entries) if n is not None]
448
+ )
449
+
450
+ toc = bytearray(TOC_MAGIC)
451
+ toc += bytes([8, 0, 0, 0])
452
+ toc += struct.pack(
453
+ "<9I",
454
+ HEADER_SIZE,
455
+ len(payloads),
456
+ len(blocks),
457
+ 12,
458
+ 0,
459
+ 32,
460
+ bs,
461
+ len(dir_index),
462
+ 1,
463
+ )
464
+ toc += struct.pack("<Q", self.container_id)
465
+ toc += bytes(16) # encryption key guid
466
+ toc += bytes([8, 0, 0, 0]) # container flags
467
+ toc += struct.pack("<I", 0) # perfect hash seeds
468
+ toc += struct.pack("<Q", 0xFFFFFFFFFFFFFFFF) # partition size
469
+ toc += struct.pack("<I", 0) + struct.pack("<I", 0)
470
+ toc += bytes(HEADER_SIZE - len(toc))
471
+ for cid in ids:
472
+ toc += cid
473
+ for off, ln in chunks:
474
+ toc += off.to_bytes(5, "big") + ln.to_bytes(5, "big")
475
+ for off, csize, usize, method in blocks:
476
+ toc += (
477
+ off.to_bytes(5, "little")
478
+ + csize.to_bytes(3, "little")
479
+ + usize.to_bytes(3, "little")
480
+ + bytes([method])
481
+ )
482
+ toc += dir_index
483
+ for data in payloads:
484
+ toc += chunk_hash(data)
485
+
486
+ utoc, ucas_p = base.with_suffix(".utoc"), base.with_suffix(".ucas")
487
+ utoc.write_bytes(bytes(toc))
488
+ ucas_p.write_bytes(bytes(ucas))
489
+ pak = base.with_suffix(".pak")
490
+ if pak_files:
491
+ builder = PakWriter()
492
+ for name, blob in pak_files.items():
493
+ builder.add(name, blob)
494
+ builder.write(pak)
495
+ else:
496
+ pak.write_bytes(_stub_pak())
497
+ return utoc, ucas_p, pak
@@ -0,0 +1,139 @@
1
+ """Read and edit Unreal .locres text tables.
2
+
3
+ Edits copy the original index bytes through untouched, every namespace, key and
4
+ hash of them. Only the string array on the end gets rebuilt, and a key is moved
5
+ by patching its 4-byte string index. That way none of this depends on knowing
6
+ Unreal's string hash.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import struct
13
+
14
+ MAGIC = bytes.fromhex("0e147475674a03fc4a15909dc3377f1b")
15
+ NUL = bytes([0])
16
+
17
+
18
+ def _rstr(st: io.BytesIO) -> str:
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. Falls back to UTF-16 if the text is not ASCII."""
29
+ if s.isascii():
30
+ raw = s.encode("ascii") + NUL
31
+ return struct.pack("<i", len(raw)) + raw
32
+ raw = s.encode("utf-16-le") + NUL * 2
33
+ return struct.pack("<i", -(len(raw) // 2)) + raw
34
+
35
+
36
+ class LocRes:
37
+ """A text table keyed by (namespace, key)."""
38
+
39
+ def __init__(self) -> None:
40
+ self._data = bytearray()
41
+ self._array_offset = 0
42
+ self._strings: list[str] = []
43
+ self._refs: list[int] = []
44
+ self._slots: dict[tuple[str, str], tuple[int, int]] = {}
45
+
46
+ @classmethod
47
+ def loads(cls, data: bytes) -> LocRes:
48
+ """Parse a .locres file."""
49
+ self = cls()
50
+ self._data = bytearray(data)
51
+ b = io.BytesIO(data)
52
+ if b.read(16) != MAGIC:
53
+ raise ValueError("not a .locres file")
54
+ self.version = b.read(1)[0]
55
+ (self._array_offset,) = struct.unpack("<q", b.read(8))
56
+ if self.version >= 3:
57
+ b.read(4)
58
+ (ns_count,) = struct.unpack("<I", b.read(4))
59
+
60
+ for _ in range(ns_count):
61
+ b.read(4)
62
+ ns = _rstr(b)
63
+ (key_count,) = struct.unpack("<I", b.read(4))
64
+ for _ in range(key_count):
65
+ b.read(4)
66
+ key = _rstr(b)
67
+ b.read(4) # source string hash
68
+ at = b.tell() # byte offset of the string index
69
+ (idx,) = struct.unpack("<i", b.read(4))
70
+ self._slots[(ns, key)] = (idx, at)
71
+
72
+ sb = io.BytesIO(data)
73
+ sb.seek(self._array_offset)
74
+ (count,) = struct.unpack("<i", sb.read(4))
75
+ for _ in range(count):
76
+ self._strings.append(_rstr(sb))
77
+ self._refs.append(
78
+ struct.unpack("<i", sb.read(4))[0] if self.version >= 3 else 1
79
+ )
80
+ # Hang on to the shipped array bytes so edits only ever append.
81
+ self._shipped_count = count
82
+ self._shipped_body = bytes(data[self._array_offset + 4 : sb.tell()])
83
+ self._trailing = bytes(data[sb.tell() :])
84
+ return self
85
+
86
+ @property
87
+ def entries(self) -> dict[tuple[str, str], str]:
88
+ """Every (namespace, key) to text pair."""
89
+ return {
90
+ k: self._strings[i]
91
+ for k, (i, _) in self._slots.items()
92
+ if 0 <= i < len(self._strings)
93
+ }
94
+
95
+ def get(self, namespace: str, key: str) -> str | None:
96
+ """Look one entry up."""
97
+ slot = self._slots.get((namespace, key))
98
+ if slot is None or not 0 <= slot[0] < len(self._strings):
99
+ return None
100
+ return self._strings[slot[0]]
101
+
102
+ def find(self, text: str) -> list[tuple[str, str, str]]:
103
+ """Every entry whose text is exactly `text`."""
104
+ return [(ns, k, v) for (ns, k), v in self.entries.items() if v == text]
105
+
106
+ def set(self, namespace: str, key: str, value: str) -> None:
107
+ """Point an entry at `value`, adding it to the string array if it is new."""
108
+ slot = self._slots.get((namespace, key))
109
+ if slot is None:
110
+ raise KeyError(f"{namespace} / {key} is not in this table")
111
+ try:
112
+ idx = self._strings.index(value)
113
+ except ValueError:
114
+ self._strings.append(value)
115
+ self._refs.append(0)
116
+ idx = len(self._strings) - 1
117
+ old_idx, at = slot
118
+ if 0 <= old_idx < len(self._refs):
119
+ self._refs[old_idx] = max(0, self._refs[old_idx] - 1)
120
+ self._refs[idx] += 1
121
+ self._slots[(namespace, key)] = (idx, at)
122
+ struct.pack_into("<i", self._data, at, idx)
123
+
124
+ def dumps(self) -> bytes:
125
+ """Write it back out as .locres bytes.
126
+
127
+ The header and index are the original bytes with only string indices
128
+ moved. The string array gets rebuilt. Since the array is last in the
129
+ file its length can change without disturbing anything.
130
+ """
131
+ out = bytearray(self._data[: self._array_offset])
132
+ out += struct.pack("<i", len(self._strings))
133
+ out += self._shipped_body
134
+ for i in range(self._shipped_count, len(self._strings)):
135
+ out += _wstr(self._strings[i])
136
+ if self.version >= 3:
137
+ out += struct.pack("<i", self._refs[i])
138
+ out += self._trailing
139
+ return bytes(out)