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,522 @@
1
+ """Editing cooked assets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import struct
6
+ from dataclasses import dataclass
7
+
8
+ from ..formats.iostore import package_id
9
+ from ..formats.zen import (
10
+ EXPORT_ENTRY_SIZE,
11
+ IMPORT_PACKAGE,
12
+ ZenPackage,
13
+ append_to_section,
14
+ package_import,
15
+ read_name_map,
16
+ renumber_imported_packages,
17
+ splice_section,
18
+ split_import,
19
+ write_name_map,
20
+ )
21
+
22
+
23
+ class AssetEditor:
24
+ """Edits strings inside one cooked asset.
25
+
26
+ A cooked package records how long each of its exports is. Change the length
27
+ of a string and those numbers go stale, so this fixes them up afterwards.
28
+ That is why the replacement text can be any length.
29
+ """
30
+
31
+ def __init__(
32
+ self, package_path: str, data: bytes, imported_packages: list[int] | None = None
33
+ ):
34
+ self.package_path = package_path
35
+ self.data = bytearray(data)
36
+ self.changes = 0
37
+ # The ids of the packages this one imports live in the container header
38
+ # rather than the package, so they get carried alongside. add_import
39
+ # adds to this list and the builder ships whatever is here.
40
+ self.imported_packages = list(imported_packages or [])
41
+ self._reparse()
42
+
43
+ def _reparse(self) -> None:
44
+ self.package = ZenPackage(self.data)
45
+
46
+ @property
47
+ def names(self) -> list[str]:
48
+ """The package name map: class names, object paths, gameplay tags."""
49
+ return self.package.names
50
+
51
+ @staticmethod
52
+ def _fstrings(text: str) -> list[bytes]:
53
+ """Both ways a cooked package might have stored `text`.
54
+
55
+ Unreal uses the narrow encoding where it can, but an asset authored
56
+ with wide strings keeps them, so check for both.
57
+ """
58
+ forms = []
59
+ if text.isascii():
60
+ raw = text.encode("ascii") + b"\x00"
61
+ forms.append(struct.pack("<i", len(raw)) + raw)
62
+ wide = text.encode("utf-16-le") + b"\x00\x00"
63
+ forms.append(struct.pack("<i", -(len(wide) // 2)) + wide)
64
+ return forms
65
+
66
+ def _find(self, text: str) -> tuple[bytes, list[int]]:
67
+ """The stored form of `text`, and where it turns up in the export data.
68
+
69
+ Only the export data gets searched. Anything matching in the header
70
+ belongs to the name map or one of the other indexes, and splicing bytes
71
+ in there would wreck the package.
72
+ """
73
+ start, end = self.package.export_data_range()
74
+ for form in self._fstrings(text):
75
+ hits, at = [], start
76
+ while (i := self.data.find(form, at, end)) >= 0:
77
+ hits.append(i)
78
+ at = i + 1
79
+ if hits:
80
+ return form, hits
81
+ return b"", []
82
+
83
+ def find_text(self, text: str) -> list[int]:
84
+ """Where every string equal to `text` sits in the export data."""
85
+ return self._find(text)[1]
86
+
87
+ def replace_text(self, old: str, new: str) -> AssetEditor:
88
+ """Replace every string equal to `old`. Any length is allowed."""
89
+ old_raw, hits = self._find(old)
90
+ if not hits:
91
+ in_names = any(old == n for n in self.names)
92
+ hint = (
93
+ " It is in the package name map, which replace_text cannot "
94
+ "resize; try retarget_name()."
95
+ if in_names
96
+ else ""
97
+ )
98
+ raise KeyError(f"{old!r} is not a string in {self.package_path}.{hint}")
99
+
100
+ # Keep whatever encoding the asset already used.
101
+ wide = struct.unpack_from("<i", old_raw)[0] < 0
102
+ new_raw = self._fstrings(new)[-1 if wide else 0]
103
+ delta = len(new_raw) - len(old_raw)
104
+
105
+ # Work backwards so the offsets we already found stay put.
106
+ for pos in reversed(hits):
107
+ self.data[pos : pos + len(old_raw)] = new_raw
108
+ self.changes += 1
109
+ if delta:
110
+ self._shift_exports(pos - self.package.header_size, delta)
111
+ self._reparse()
112
+ return self
113
+
114
+ def retarget_name(
115
+ self, old: str, new: str, expect: int | None = None
116
+ ) -> AssetEditor:
117
+ """Point references to the name `old` at the name `new` instead.
118
+
119
+ Gameplay tags, class names and object paths are not text in the export
120
+ data. They sit in the package name map, and the export data points at
121
+ them by index. Changing which index a reference uses means the name map
122
+ never moves, nothing gets resized, and no hash needs recomputing.
123
+
124
+ Both names have to be in the package name map already. Pass `expect` if
125
+ you know how many references there should be.
126
+ """
127
+ names = self.names
128
+ if old not in names:
129
+ raise KeyError(
130
+ f"{old!r} is not in the name map of {self.package_path}. "
131
+ f"It has {len(names)} names."
132
+ )
133
+ if new not in names:
134
+ raise KeyError(
135
+ f"{new!r} is not in the name map of {self.package_path}, and "
136
+ "names cannot be added yet. Pick one the package already uses."
137
+ )
138
+ old_index, new_index = names.index(old), names.index(new)
139
+
140
+ # An FName is a name-map index plus an instance number. Match both so
141
+ # we do not hit some unrelated pair of integers that looks the same.
142
+ start, end = self.package.export_data_range()
143
+ needle = struct.pack("<II", old_index, 0)
144
+ replacement = struct.pack("<II", new_index, 0)
145
+ hits, at = [], start
146
+ while (i := self.data.find(needle, at, end)) >= 0:
147
+ hits.append(i)
148
+ at = i + 1
149
+
150
+ if expect is not None and len(hits) != expect:
151
+ raise ValueError(
152
+ f"expected {expect} reference(s) to {old!r} in "
153
+ f"{self.package_path}, found {len(hits)}"
154
+ )
155
+ if not hits:
156
+ raise KeyError(f"{old!r} is in the name map but never referenced")
157
+
158
+ for pos in hits:
159
+ self.data[pos : pos + 8] = replacement
160
+ self.changes += 1
161
+ return self
162
+
163
+ # -- growing the header ----------------------------------------------
164
+
165
+ def add_name(self, name: str) -> int:
166
+ """Put a new name in the package name map and give back its index.
167
+
168
+ Names go on the end. Nothing sorts them, so nothing else has to move,
169
+ and the hash gets worked out from the name itself. If the name is
170
+ already there this just returns where it is.
171
+ """
172
+ if name in self.names:
173
+ return self.names.index(name)
174
+ old = read_name_map(self.data, self.package.name_map.start)
175
+ rebuilt = write_name_map([*old.names, name])
176
+ self.data = splice_section(self.data, "name_map", old.start, old.size, rebuilt)
177
+ self._reparse()
178
+ self.changes += 1
179
+ return len(self.names) - 1
180
+
181
+ def add_imported_package(self, package_path: str) -> int:
182
+ """Add a package to the list of ones this asset imports.
183
+
184
+ The container keeps these ids in ascending order and the loader binary
185
+ searches them, so a new one lands in the middle rather than on the end.
186
+ Everything after it moves along by one, and every reference holding one
187
+ of those positions has to be renumbered to match.
188
+ """
189
+ pid = package_id(package_path)
190
+ if pid in self.imported_packages:
191
+ return self.imported_packages.index(pid)
192
+
193
+ at = sum(1 for existing in self.imported_packages if existing < pid)
194
+ self.data = renumber_imported_packages(self.data, at, 1)
195
+
196
+ # Two things run alongside the ids and both need an entry at the same
197
+ # spot: the names, and the uint32 array behind them. The summary has no
198
+ # offset for that array, so the whole section gets rebuilt together
199
+ # rather than spliced in two places.
200
+ names = self.package.imported_package_names
201
+ tail = self.package.imported_package_tail
202
+ start, end = self.package.section_range("imported_package_names")
203
+ rebuilt = write_name_map([*names[:at], package_path, *names[at:]])
204
+ rebuilt += struct.pack(f"<{len(tail) + 1}I", *tail[:at], 0, *tail[at:])
205
+ self.data = splice_section(
206
+ self.data, "imported_package_names", start, end - start, rebuilt
207
+ )
208
+
209
+ self.imported_packages.insert(at, pid)
210
+ self._reparse()
211
+ self.changes += 1
212
+ return at
213
+
214
+ def add_import(self, package_path: str, export_hash: int) -> int:
215
+ """Import one object out of another package. Gives back its import index.
216
+
217
+ `export_hash` is the public export hash of the object being pointed at,
218
+ which is how the loader finds it once the package is mounted. Three
219
+ things get added: the package to the import list, the hash to
220
+ ImportedPublicExportHashes, and an entry to the import map that ties
221
+ the two together.
222
+ """
223
+ package_index = self.add_imported_package(package_path)
224
+
225
+ hashes = self.package.public_export_hashes
226
+ if export_hash in hashes:
227
+ hash_index = hashes.index(export_hash)
228
+ else:
229
+ hash_index = len(hashes)
230
+ self.data = append_to_section(
231
+ self.data,
232
+ "imported_public_export_hashes",
233
+ struct.pack("<Q", export_hash),
234
+ )
235
+ self._reparse()
236
+
237
+ entry = package_import(package_index, hash_index)
238
+ existing = self.package.imports
239
+ if entry in existing:
240
+ return existing.index(entry)
241
+
242
+ self.data = append_to_section(self.data, "import_map", struct.pack("<Q", entry))
243
+ self._reparse()
244
+ self.changes += 1
245
+ return len(existing)
246
+
247
+ # -- editing export data ----------------------------------------------
248
+
249
+ def splice_export_data(self, at: int, old_len: int, new: bytes) -> AssetEditor:
250
+ """Swap `old_len` bytes at `at` for `new`, inside the export data.
251
+
252
+ The export holding those bytes grows or shrinks to match and every
253
+ export after it slides along. `at` is an absolute offset into the
254
+ package and has to land inside an export, not on the seam between two,
255
+ because otherwise there is no saying which of them the new bytes join.
256
+ """
257
+ start, end = self.package.export_data_range()
258
+ if not start <= at <= at + old_len <= end:
259
+ raise ValueError(
260
+ f"splice at {at}..{at + old_len} is outside the export data "
261
+ f"({start}..{end})"
262
+ )
263
+ relative = at - self.package.header_size
264
+ if self.package.export_at(relative) is None:
265
+ raise ValueError(f"offset {at} is not inside any export")
266
+
267
+ delta = len(new) - old_len
268
+ self.data[at : at + old_len] = new
269
+ if delta:
270
+ self._shift_exports(relative, delta)
271
+ else:
272
+ self._reparse()
273
+ self.changes += 1
274
+ return self
275
+
276
+ def import_ref(self, import_index: int) -> int:
277
+ """How the export data refers to an import.
278
+
279
+ Object references in cooked data are one signed int: positive counts
280
+ exports from one, negative counts imports from one, zero is null.
281
+ """
282
+ return -(import_index + 1)
283
+
284
+ def resolve_ref(self, ref: int) -> str | None:
285
+ """What an object reference points at, for checking an edit landed."""
286
+ if ref == 0:
287
+ return None
288
+ if ref > 0:
289
+ index = ref - 1
290
+ return f"export[{index}]" if index < len(self.package.exports) else None
291
+ index = -ref - 1
292
+ if index >= len(self.package.imports):
293
+ return None
294
+ kind, package, _hash = split_import(self.package.imports[index])
295
+ if kind != IMPORT_PACKAGE:
296
+ return f"import[{index}]"
297
+ names = self.package.imported_package_names
298
+ return names[package] if package < len(names) else None
299
+
300
+ def find_object_map(
301
+ self, *, entries: int | None = None, referencing: str | None = None
302
+ ) -> ObjectMap:
303
+ """Find a Map<Object, Object> in the export data by what it holds.
304
+
305
+ A cooked map is a zero (nothing to remove), a count, then that many
306
+ key and value pairs, each one an object reference. Rather than walking
307
+ every property in front of it, this scans for that shape and keeps only
308
+ the ones where every reference resolves to something real, which throws
309
+ out almost everything by itself.
310
+
311
+ Narrow it with `entries` for the number of pairs and `referencing` for
312
+ a substring one of the packages has to contain. Raises unless exactly
313
+ one map matches, so a change never lands on a lucky second candidate.
314
+ """
315
+ found = [
316
+ m
317
+ for m in self.object_maps()
318
+ if (entries is None or len(m.entries) == entries)
319
+ and (referencing is None or any(referencing in p for p in m.packages))
320
+ ]
321
+ if len(found) != 1:
322
+ what = f"entries={entries} referencing={referencing!r}"
323
+ raise LookupError(
324
+ f"wanted exactly one object map in {self.package_path} ({what}), "
325
+ f"found {len(found)}"
326
+ )
327
+ return found[0]
328
+
329
+ def object_maps(
330
+ self, min_entries: int = 1, max_entries: int = 256
331
+ ) -> list[ObjectMap]:
332
+ """Every run of bytes in the export data shaped like an object map."""
333
+ start, end = self.package.export_data_range()
334
+ out = []
335
+ for at in range(start, end - 8):
336
+ removals, count = struct.unpack_from("<ii", self.data, at)
337
+ if removals != 0 or not min_entries <= count <= max_entries:
338
+ continue
339
+ if at + 8 + count * 8 > end:
340
+ continue
341
+ pairs, packages, ok = [], [], True
342
+ for i in range(count):
343
+ key, value = struct.unpack_from("<ii", self.data, at + 8 + i * 8)
344
+ names = []
345
+ for ref in (key, value):
346
+ # A null reference is a real thing to store, so it counts
347
+ # as resolved. Anything else that will not resolve means
348
+ # this run of bytes is not a map.
349
+ if ref == 0:
350
+ names.append("")
351
+ continue
352
+ resolved = self.resolve_ref(ref)
353
+ if resolved is None:
354
+ ok = False
355
+ break
356
+ names.append(resolved)
357
+ if not ok:
358
+ break
359
+ pairs.append((key, value))
360
+ packages.extend(names)
361
+ if ok:
362
+ out.append(ObjectMap(self, at, pairs, packages))
363
+ return out
364
+
365
+ def add_gameplay_tag(
366
+ self, beside: str, tag: str, expect: int | None = None
367
+ ) -> AssetEditor:
368
+ """Add a tag to the container that already holds `beside`.
369
+
370
+ Containers are not labelled in cooked data, so the way to point at one
371
+ is to name a tag already in it. The new tag goes on the end of that
372
+ container, the count goes up by one, and the name map gains an entry if
373
+ it does not have the tag already.
374
+ """
375
+ # The name map sits in front of the export data, so growing it first
376
+ # keeps the offsets found below valid.
377
+ index = self.add_name(tag)
378
+
379
+ names = self.names
380
+ if beside not in names:
381
+ raise KeyError(f"{beside!r} is not in the name map of {self.package_path}")
382
+ anchor = names.index(beside)
383
+
384
+ start, end = self.package.export_data_range()
385
+ needle = struct.pack("<II", anchor, 0)
386
+ hits, at = [], start
387
+ while (i := self.data.find(needle, at, end)) >= 0:
388
+ hits.append(i)
389
+ at = i + 1
390
+ if expect is not None and len(hits) != expect:
391
+ raise ValueError(
392
+ f"expected {expect} container(s) holding {beside!r} in "
393
+ f"{self.package_path}, found {len(hits)}"
394
+ )
395
+ if not hits:
396
+ raise KeyError(f"{beside!r} is in the name map but never referenced")
397
+
398
+ for pos in reversed(hits):
399
+ count_at, count = self._tag_container_count(pos, start)
400
+ struct.pack_into("<i", self.data, count_at, count + 1)
401
+ after = count_at + 4 + count * 8
402
+ self.splice_export_data(after, 0, struct.pack("<II", index, 0))
403
+ return self
404
+
405
+ def remove_gameplay_tag(self, tag: str, expect: int | None = None) -> AssetEditor:
406
+ """Take a tag out of the tag container it sits in.
407
+
408
+ A container is a count followed by that many FNames. retarget_name can
409
+ point an entry somewhere else but the count stays put, so the container
410
+ keeps its length. Some of this game's checks care about that, so this
411
+ drops the entry and brings the count down with it.
412
+
413
+ Finding the count means stepping back one entry at a time from the tag
414
+ until a number turns up that describes the entries after it, with every
415
+ one of them a real name. Pass `expect` if you know how many containers
416
+ should be touched.
417
+ """
418
+ names = self.names
419
+ if tag not in names:
420
+ raise KeyError(f"{tag!r} is not in the name map of {self.package_path}")
421
+ index = names.index(tag)
422
+
423
+ start, end = self.package.export_data_range()
424
+ needle = struct.pack("<II", index, 0)
425
+ hits, at = [], start
426
+ while (i := self.data.find(needle, at, end)) >= 0:
427
+ hits.append(i)
428
+ at = i + 1
429
+ if expect is not None and len(hits) != expect:
430
+ raise ValueError(
431
+ f"expected {expect} container(s) holding {tag!r} in "
432
+ f"{self.package_path}, found {len(hits)}"
433
+ )
434
+ if not hits:
435
+ raise KeyError(f"{tag!r} is in the name map but never referenced")
436
+
437
+ # Work backwards so the offsets found above stay put.
438
+ for pos in reversed(hits):
439
+ count_at, count = self._tag_container_count(pos, start)
440
+ struct.pack_into("<i", self.data, count_at, count - 1)
441
+ self.splice_export_data(pos, 8, b"")
442
+ return self
443
+
444
+ def _tag_container_count(self, entry_at: int, start: int) -> tuple[int, int]:
445
+ """Where the count of the container holding this entry sits, and its value.
446
+
447
+ The entry could be anywhere in the container, so try each position in
448
+ turn. A candidate only counts if the number is right for how many
449
+ entries follow it and every one of them reads as a name this package
450
+ actually has.
451
+ """
452
+ for slot in range(64):
453
+ count_at = entry_at - 8 * slot - 4
454
+ if count_at < start:
455
+ break
456
+ (count,) = struct.unpack_from("<i", self.data, count_at)
457
+ if not slot < count <= 64:
458
+ continue
459
+ if count_at + 4 + count * 8 > len(self.data):
460
+ continue
461
+ if all(
462
+ struct.unpack_from("<II", self.data, count_at + 4 + i * 8)[0]
463
+ < len(self.names)
464
+ and struct.unpack_from("<II", self.data, count_at + 4 + i * 8)[1] == 0
465
+ for i in range(count)
466
+ ):
467
+ return count_at, count
468
+ raise ValueError(
469
+ f"could not find the container holding the tag at {entry_at} in "
470
+ f"{self.package_path}"
471
+ )
472
+
473
+ def _shift_exports(self, rel_pos: int, delta: int) -> None:
474
+ """Resize the export holding rel_pos, and move the ones after it."""
475
+ for i, export in enumerate(self.package.exports):
476
+ at = self.package.export_map_offset + i * EXPORT_ENTRY_SIZE
477
+ if export.offset <= rel_pos < export.end:
478
+ struct.pack_into("<Q", self.data, at + 8, export.size + delta)
479
+ elif export.offset > rel_pos:
480
+ struct.pack_into("<Q", self.data, at, export.offset + delta)
481
+ self._reparse()
482
+
483
+ def __repr__(self) -> str:
484
+ return f"<AssetEditor {self.package_path!r} changes={self.changes}>"
485
+
486
+
487
+ @dataclass
488
+ class ObjectMap:
489
+ """A Map<Object, Object> sat in an asset's export data.
490
+
491
+ `entries` holds the raw object references, `packages` the names they
492
+ resolve to, so an edit can be checked against what is actually there.
493
+ """
494
+
495
+ editor: AssetEditor
496
+ offset: int
497
+ entries: list[tuple[int, int]]
498
+ packages: list[str]
499
+
500
+ def append(self, key_import: int, value_import: int | None) -> ObjectMap:
501
+ """Add a pair, given the import indexes to point the two halves at.
502
+
503
+ Pass None for the value to store a null reference, which is what the
504
+ game does for a primary specialisation that has no secondary.
505
+ """
506
+ key = self.editor.import_ref(key_import)
507
+ value = 0 if value_import is None else self.editor.import_ref(value_import)
508
+ end = self.offset + 8 + len(self.entries) * 8
509
+ # Bump the count first. It lives in front of the pairs, so inserting
510
+ # afterwards leaves it where it is.
511
+ struct.pack_into("<i", self.editor.data, self.offset + 4, len(self.entries) + 1)
512
+ self.editor.splice_export_data(end, 0, struct.pack("<ii", key, value))
513
+ self.entries.append((key, value))
514
+ return self
515
+
516
+ @property
517
+ def named_entries(self) -> list[tuple[str, str]]:
518
+ """The pairs as package names, which is what you want when checking."""
519
+ return list(zip(self.packages[0::2], self.packages[1::2], strict=True))
520
+
521
+ def __repr__(self) -> str:
522
+ return f"<ObjectMap @{self.offset} entries={len(self.entries)}>"
@@ -0,0 +1,162 @@
1
+ """Editing DataTable rows.
2
+
3
+ A cooked DataTable is its own properties, then a row count, then every row as a
4
+ name followed by a struct. Most of the game's tuning sits in tables like this.
5
+ Ability costs, weapon stats, upgrade curves. That makes them the most useful
6
+ thing to be able to edit by name.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import struct
12
+
13
+ from ..formats.usmap import Mappings, bundled
14
+ from ..formats.zen import ZenPackage
15
+ from .properties import Located, PropertyError, read_struct, write_number
16
+
17
+
18
+ class DataTableError(ValueError):
19
+ """A package was not a DataTable, or would not parse as one."""
20
+
21
+
22
+ class DataTable:
23
+ """Row by row access to a cooked DataTable."""
24
+
25
+ def __init__(
26
+ self,
27
+ package_path: str,
28
+ data: bytearray,
29
+ mappings: Mappings | None = None,
30
+ row_struct: str | None = None,
31
+ ):
32
+ self.package_path = package_path
33
+ self.data = data
34
+ self.mappings = mappings or bundled()
35
+ self.row_struct = row_struct
36
+ self.changes = 0
37
+ self._read()
38
+
39
+ def _read(self) -> None:
40
+ pkg = ZenPackage(self.data)
41
+ self.package = pkg
42
+ try:
43
+ _, after = read_struct(
44
+ self.data, pkg.header_size, "DataTable", self.mappings
45
+ )
46
+ except PropertyError as exc:
47
+ raise DataTableError(
48
+ f"{self.package_path} is not a DataTable: {exc}"
49
+ ) from exc
50
+
51
+ at = after + 4 # marker between the table properties and the rows
52
+ (count,) = struct.unpack_from("<i", self.data, at)
53
+ self._rows_at = at + 4
54
+ if not 0 <= count < 100_000:
55
+ raise DataTableError(
56
+ f"implausible row count {count} in {self.package_path}"
57
+ )
58
+ self._row_count = count
59
+
60
+ if self.row_struct is None:
61
+ self.row_struct = self._resolve_row_struct()
62
+ self.rows = self._walk(self.row_struct)
63
+
64
+ def _walk(self, row_struct: str) -> dict[str, dict[str, Located]]:
65
+ """Read every row, and insist the walk finishes exactly at the end.
66
+
67
+ Finishing anywhere else means the schema is wrong for this asset.
68
+ Either the row struct is not the right one, or the mappings came from a
69
+ different build. Either way none of the offsets can be trusted.
70
+ """
71
+ pkg, at = self.package, self._rows_at
72
+ rows: dict[str, dict[str, Located]] = {}
73
+ for _ in range(self._row_count):
74
+ (name_index, _number) = struct.unpack_from("<II", self.data, at)
75
+ at += 8
76
+ name = (
77
+ pkg.names[name_index]
78
+ if name_index < len(pkg.names)
79
+ else str(name_index)
80
+ )
81
+ props, at = read_struct(self.data, at, row_struct, self.mappings)
82
+ rows[name] = props
83
+ if at != len(self.data):
84
+ raise DataTableError(
85
+ f"{self.package_path}: rows ended at {at} of {len(self.data)} "
86
+ f"bytes using {row_struct}"
87
+ )
88
+ return rows
89
+
90
+ def _resolve_row_struct(self) -> str:
91
+ """Work out the row struct by trying the likely ones.
92
+
93
+ A DataTable points at its row struct through the import map, not the
94
+ name map, so there is no name to read off. Guessing is safe here
95
+ because only the correct struct walks the table byte for byte.
96
+ """
97
+ candidates = [n for n in self.mappings.schemas if n.endswith("TableRow")]
98
+ candidates += [
99
+ n
100
+ for n in self.mappings.schemas
101
+ if n.endswith("Row") and not n.endswith("TableRow")
102
+ ]
103
+ for name in candidates:
104
+ try:
105
+ self._walk(name)
106
+ except (DataTableError, PropertyError, struct.error, IndexError):
107
+ continue
108
+ return name
109
+ raise DataTableError(
110
+ f"could not work out the row struct for {self.package_path}. "
111
+ "Pass row_struct= if you know it."
112
+ )
113
+
114
+ def row(self, name: str) -> dict[str, Located]:
115
+ """One row's properties, by row name."""
116
+ try:
117
+ return self.rows[name]
118
+ except KeyError:
119
+ raise DataTableError(
120
+ f"{self.package_path} has no row {name!r}. "
121
+ f"It has {len(self.rows)}, for example {list(self.rows)[:3]}"
122
+ ) from None
123
+
124
+ def get(self, row: str, field: str):
125
+ """One value from one row."""
126
+ props = self.row(row)
127
+ if field not in props:
128
+ raise DataTableError(
129
+ f"row {row!r} has no field {field!r}; it has {sorted(props)}"
130
+ )
131
+ return props[field].value
132
+
133
+ def set(self, row: str, field: str, value: float) -> DataTable:
134
+ """Overwrite a number. Numbers are fixed width, so nothing shifts."""
135
+ props = self.row(row)
136
+ if field not in props:
137
+ raise DataTableError(
138
+ f"row {row!r} has no field {field!r}; it has {sorted(props)}"
139
+ )
140
+ write_number(self.data, props[field], value)
141
+ self.changes += 1
142
+ self._read() # refresh the cached values
143
+ return self
144
+
145
+ def set_all(self, field: str, value: float) -> DataTable:
146
+ """Overwrite a field in every row that has one."""
147
+ for name, props in list(self.rows.items()):
148
+ if field in props and props[field].offset >= 0:
149
+ write_number(self.data, props[field], value)
150
+ self.changes += 1
151
+ del name
152
+ self._read()
153
+ return self
154
+
155
+ def __len__(self) -> int:
156
+ return len(self.rows)
157
+
158
+ def __repr__(self) -> str:
159
+ return (
160
+ f"<DataTable {self.package_path.rsplit('/', 1)[-1]} "
161
+ f"rows={len(self.rows)} struct={self.row_struct} changes={self.changes}>"
162
+ )