arkparser 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.
Files changed (46) hide show
  1. arkparser/__init__.py +117 -0
  2. arkparser/common/__init__.py +72 -0
  3. arkparser/common/binary_reader.py +402 -0
  4. arkparser/common/exceptions.py +99 -0
  5. arkparser/common/map_config.py +166 -0
  6. arkparser/common/types.py +249 -0
  7. arkparser/common/version_detection.py +195 -0
  8. arkparser/data_models.py +801 -0
  9. arkparser/export.py +485 -0
  10. arkparser/files/__init__.py +25 -0
  11. arkparser/files/base.py +309 -0
  12. arkparser/files/cloud_inventory.py +259 -0
  13. arkparser/files/profile.py +205 -0
  14. arkparser/files/tribe.py +155 -0
  15. arkparser/files/world_save.py +699 -0
  16. arkparser/game_objects/__init__.py +32 -0
  17. arkparser/game_objects/container.py +180 -0
  18. arkparser/game_objects/game_object.py +273 -0
  19. arkparser/game_objects/location.py +87 -0
  20. arkparser/models/__init__.py +29 -0
  21. arkparser/models/character.py +227 -0
  22. arkparser/models/creature.py +642 -0
  23. arkparser/models/item.py +207 -0
  24. arkparser/models/player.py +263 -0
  25. arkparser/models/stats.py +226 -0
  26. arkparser/models/structure.py +176 -0
  27. arkparser/models/tribe.py +291 -0
  28. arkparser/properties/__init__.py +77 -0
  29. arkparser/properties/base.py +329 -0
  30. arkparser/properties/byte_property.py +230 -0
  31. arkparser/properties/compound.py +1125 -0
  32. arkparser/properties/primitives.py +803 -0
  33. arkparser/properties/registry.py +236 -0
  34. arkparser/py.typed +0 -0
  35. arkparser/structs/__init__.py +60 -0
  36. arkparser/structs/base.py +63 -0
  37. arkparser/structs/colors.py +108 -0
  38. arkparser/structs/misc.py +133 -0
  39. arkparser/structs/property_list.py +101 -0
  40. arkparser/structs/registry.py +140 -0
  41. arkparser/structs/vectors.py +221 -0
  42. arkparser-0.1.0.dist-info/METADATA +833 -0
  43. arkparser-0.1.0.dist-info/RECORD +46 -0
  44. arkparser-0.1.0.dist-info/WHEEL +5 -0
  45. arkparser-0.1.0.dist-info/licenses/LICENSE +21 -0
  46. arkparser-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,699 @@
1
+ """
2
+ World Save parser for .ark map save files.
3
+
4
+ WorldSave files contain the complete state of an ARK map including:
5
+ - All creatures (wild, tamed, hibernating)
6
+ - All structures
7
+ - All items dropped in the world
8
+ - Player and tribe data (in later versions)
9
+ - Hibernation data
10
+
11
+ This is the most complex file type, containing all game objects.
12
+
13
+ Supports both formats transparently via ``WorldSave.load()``:
14
+
15
+ ASE World Saves:
16
+ Binary files with version 5-12, using the traditional ARK binary format.
17
+
18
+ ASA World Saves:
19
+ SQLite databases with tables: game (objects), custom (header/locations/tribes/profiles).
20
+ The key is a 16-byte GUID, the value is a binary blob.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import sqlite3
27
+ import typing as t
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from uuid import UUID
31
+
32
+ from ..common.binary_reader import BinaryReader
33
+ from ..common.exceptions import ArkParseError
34
+ from ..game_objects.container import GameObjectContainer
35
+ from ..game_objects.game_object import GameObject
36
+ from ..game_objects.location import LocationData
37
+ from ..properties.registry import read_properties
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ @dataclass
43
+ class EmbeddedData:
44
+ """
45
+ Embedded data structure for single-player saves.
46
+
47
+ Embedded data is used to store additional map data in single-player
48
+ saves. In server saves, this is typically empty.
49
+
50
+ Attributes:
51
+ path: The file path/identifier for this embedded data.
52
+ data: 3D array of byte blobs organized as [parts][blobs][bytes].
53
+ """
54
+
55
+ path: str = ""
56
+ data: list[list[bytes]] = field(default_factory=list)
57
+
58
+ @classmethod
59
+ def read(cls, reader: BinaryReader) -> EmbeddedData:
60
+ """Read embedded data from binary."""
61
+ path = reader.read_string()
62
+
63
+ part_count = reader.read_int32()
64
+ data: list[list[bytes]] = []
65
+
66
+ for _ in range(part_count):
67
+ blob_count = reader.read_int32()
68
+ part_data: list[bytes] = []
69
+
70
+ for _ in range(blob_count):
71
+ blob_size = reader.read_int32() * 4 # Size is in 32-bit units
72
+ blob_bytes = reader.read_bytes(blob_size)
73
+ part_data.append(blob_bytes)
74
+
75
+ data.append(part_data)
76
+
77
+ return cls(path=path, data=data)
78
+
79
+ @classmethod
80
+ def skip(cls, reader: BinaryReader) -> None:
81
+ """Skip embedded data without parsing."""
82
+ reader.read_string() # Skip path
83
+
84
+ part_count = reader.read_int32()
85
+ for _ in range(part_count):
86
+ blob_count = reader.read_int32()
87
+ for _ in range(blob_count):
88
+ blob_size = reader.read_int32() * 4
89
+ reader.skip(blob_size)
90
+
91
+
92
+ @dataclass
93
+ class WorldSave:
94
+ """
95
+ Unified parser for ``.ark`` world save files (ASE binary **and** ASA SQLite).
96
+
97
+ Call :meth:`load` with any ``.ark`` file — the format is auto‑detected:
98
+
99
+ * **ASE** (versions 5‑12): traditional binary format.
100
+ * **ASA** (SQLite): tables ``game`` (objects) and ``custom``
101
+ (header / locations / tribes / profiles).
102
+
103
+ Attributes:
104
+ version: Save format version.
105
+ game_time: In-game time in seconds.
106
+ save_count: Number of times the map has been saved (ASE v9+ only).
107
+ data_files: References to external data files.
108
+ embedded_data: Embedded map data (single-player saves, ASE only).
109
+ data_files_object_map: Maps data-file indices to object names (ASE only).
110
+ name_table: Deduplicated name strings used during parsing.
111
+ objects: All parsed game objects (always a flat list).
112
+ container: Relationship-aware object container (ASE only — ``None`` for ASA).
113
+ actor_locations: GUID → location mapping (ASA only — empty dict for ASE).
114
+ is_asa: Whether this save was loaded from an ASA SQLite file.
115
+
116
+ Example::
117
+
118
+ >>> from arkparser import WorldSave
119
+ >>> save = WorldSave.load("path/to/TheIsland.ark") # ASE binary
120
+ >>> save = WorldSave.load("path/to/Extinction_WP.ark") # ASA SQLite
121
+ >>> print(save.object_count, save.is_asa)
122
+ """
123
+
124
+ # ------------------------------------------------------------------
125
+ # Public fields (both formats)
126
+ # ------------------------------------------------------------------
127
+ version: int = 0
128
+ game_time: float = 0.0
129
+ save_count: int = 0
130
+ data_files: list[str] = field(default_factory=list)
131
+ name_table: list[str] | dict[int, str] = field(default_factory=list)
132
+ objects: list[GameObject] = field(default_factory=list)
133
+ is_asa: bool = False
134
+
135
+ # ASE-specific
136
+ embedded_data: list[EmbeddedData] = field(default_factory=list)
137
+ data_files_object_map: dict[int, list[list[str]]] = field(default_factory=dict)
138
+ container: GameObjectContainer | None = None
139
+
140
+ # ASA-specific
141
+ actor_locations: dict[str, LocationData] = field(default_factory=dict)
142
+
143
+ # ------------------------------------------------------------------
144
+ # Internal state
145
+ # ------------------------------------------------------------------
146
+ _objects_by_guid: dict[str, GameObject] = field(default_factory=dict, repr=False)
147
+ _parse_errors: list[str] = field(default_factory=list, repr=False)
148
+
149
+ # ASE header offsets
150
+ _name_table_offset: int = field(default=0, repr=False)
151
+ _properties_block_offset: int = field(default=0, repr=False)
152
+ _hibernation_offset: int = field(default=0, repr=False)
153
+
154
+ # Valid ASE save versions
155
+ VALID_ASE_VERSIONS: t.ClassVar[tuple[int, ...]] = (5, 6, 7, 8, 9, 10, 11, 12)
156
+
157
+ # ==================== public API ====================
158
+
159
+ @classmethod
160
+ def load(
161
+ cls,
162
+ source: str | Path | bytes,
163
+ load_properties: bool = True,
164
+ max_objects: int | None = None,
165
+ ) -> WorldSave:
166
+ """
167
+ Load a world save from path or bytes.
168
+
169
+ Automatically detects ASE (binary) vs ASA (SQLite) format.
170
+
171
+ Args:
172
+ source: File path (``str`` or ``Path``) or raw ``bytes``.
173
+ load_properties: Whether to parse per-object properties.
174
+ max_objects: Maximum number of objects to load (ASA only, useful for testing).
175
+
176
+ Returns:
177
+ A fully-parsed :class:`WorldSave` instance.
178
+
179
+ Raises:
180
+ ArkParseError: If the file cannot be parsed.
181
+ FileNotFoundError: If the file path does not exist.
182
+ """
183
+ SQLITE_MAGIC = b"SQLite format 3\x00"
184
+
185
+ if isinstance(source, bytes):
186
+ if source.startswith(SQLITE_MAGIC):
187
+ raise ArkParseError("ASA world saves from raw bytes are not supported. Pass a file path instead.")
188
+ reader = BinaryReader.from_bytes(source)
189
+ return cls._parse_ase(reader, load_properties)
190
+
191
+ path = Path(source)
192
+ if not path.exists():
193
+ raise FileNotFoundError(f"File not found: {path}")
194
+
195
+ # Peek at first 16 bytes to detect format
196
+ with open(path, "rb") as fh:
197
+ header = fh.read(16)
198
+
199
+ if header.startswith(SQLITE_MAGIC):
200
+ return cls._parse_asa(path, load_properties, max_objects)
201
+
202
+ reader = BinaryReader.from_file(path)
203
+ return cls._parse_ase(reader, load_properties)
204
+
205
+ # ------------------------------------------------------------------
206
+ # Convenience queries
207
+ # ------------------------------------------------------------------
208
+
209
+ def get_object_by_guid(self, guid: str) -> GameObject | None:
210
+ """Get an object by its GUID string (efficient for ASA saves)."""
211
+ return self._objects_by_guid.get(guid)
212
+
213
+ def get_actor_location(self, guid: str) -> LocationData | None:
214
+ """Get the location of an actor by GUID (ASA saves only)."""
215
+ return self.actor_locations.get(guid)
216
+
217
+ def get_objects_by_class(self, class_name: str) -> list[GameObject]:
218
+ """Return all objects whose ``class_name`` contains *class_name*."""
219
+ return [obj for obj in self.objects if class_name in obj.class_name]
220
+
221
+ # Class-name patterns that are never structures even though they may
222
+ # carry ``TargetingTeam``. Checked via ``any(pat in cn for pat ...)``.
223
+ _NON_STRUCTURE_PATTERNS: t.ClassVar[tuple[str, ...]] = (
224
+ "_Character_BP", # Creatures / tamed dinos
225
+ "DinoCharacter", # Creature variants
226
+ "PlayerPawn", # Player avatars on the map
227
+ "Buff_", # Active buffs
228
+ "PrimalBuff", # Persistent buff data
229
+ "Weap", # Held weapons
230
+ "StatusComponent", # Character/dino status components
231
+ "Inventory", # Inventory components
232
+ "DroppedItem", # Dropped items
233
+ "DeathItemCache", # Death caches
234
+ "NPCZone", # NPC spawn zones
235
+ "DinoDropInventory", # Dino death drops
236
+ )
237
+
238
+ def get_creatures(self) -> list[GameObject]:
239
+ """Return all creature objects (tamed **and** wild)."""
240
+ return [
241
+ obj
242
+ for obj in self.objects
243
+ if "_Character_BP" in obj.class_name or "DinoCharacter" in obj.class_name
244
+ ]
245
+
246
+ def get_tamed_creatures(self) -> list[GameObject]:
247
+ """Return tamed creatures (have ``TamingTeamID`` property)."""
248
+ return [
249
+ obj
250
+ for obj in self.get_creatures()
251
+ if obj.get_property_value("TamingTeamID") is not None
252
+ ]
253
+
254
+ def get_wild_creatures(self) -> list[GameObject]:
255
+ """Return wild creatures (no ``TamingTeamID`` property)."""
256
+ return [
257
+ obj
258
+ for obj in self.get_creatures()
259
+ if obj.get_property_value("TamingTeamID") is None
260
+ ]
261
+
262
+ def get_structures(self) -> list[GameObject]:
263
+ """Return tribe-owned placed structures.
264
+
265
+ Uses property-based classification:
266
+ 1. Must have ``TargetingTeam`` (placed by a player/tribe).
267
+ 2. Must not have ``DinoID1`` (that would be a creature).
268
+ 3. Must not match any non-structure class-name pattern (players,
269
+ buffs, weapons, status components, etc.).
270
+ """
271
+ results: list[GameObject] = []
272
+ for obj in self.objects:
273
+ cn = obj.class_name
274
+ if obj.get_property_value("TargetingTeam") is None:
275
+ continue
276
+ if obj.get_property_value("DinoID1") is not None:
277
+ continue
278
+ if any(pat in cn for pat in self._NON_STRUCTURE_PATTERNS):
279
+ continue
280
+ results.append(obj)
281
+ return results
282
+
283
+ def get_player_pawns(self) -> list[GameObject]:
284
+ """Return player character objects currently on the map.
285
+
286
+ These are the in-world player avatars (``PlayerPawnTest_*``). Each
287
+ carries ``PlayerName``, ``LinkedPlayerDataID``, ``TribeName``,
288
+ ``TargetingTeam``, a location, and component references for stats
289
+ and inventory.
290
+ """
291
+ return [obj for obj in self.objects if "PlayerPawn" in obj.class_name]
292
+
293
+ def get_items(self) -> list[GameObject]:
294
+ """Return objects with ``is_item`` set."""
295
+ return [obj for obj in self.objects if obj.is_item]
296
+
297
+ # ------------------------------------------------------------------
298
+ # Properties
299
+ # ------------------------------------------------------------------
300
+
301
+ @property
302
+ def object_count(self) -> int:
303
+ """Total number of parsed objects."""
304
+ return len(self.objects)
305
+
306
+ @property
307
+ def location_count(self) -> int:
308
+ """Total number of actor locations (ASA only)."""
309
+ return len(self.actor_locations)
310
+
311
+ @property
312
+ def parse_error_count(self) -> int:
313
+ """Number of parsing errors encountered."""
314
+ return len(self._parse_errors)
315
+
316
+ @property
317
+ def parse_errors(self) -> list[str]:
318
+ """Parsing error messages (read-only copy)."""
319
+ return list(self._parse_errors)
320
+
321
+ # ------------------------------------------------------------------
322
+ # Serialisation helpers
323
+ # ------------------------------------------------------------------
324
+
325
+ def to_dict(self) -> dict[str, t.Any]:
326
+ """Convert to a dictionary representation (metadata only)."""
327
+ d: dict[str, t.Any] = {
328
+ "version": self.version,
329
+ "game_time": self.game_time,
330
+ "is_asa": self.is_asa,
331
+ "data_files": self.data_files,
332
+ "data_files_count": len(self.data_files),
333
+ "object_count": self.object_count,
334
+ "parse_errors": self.parse_error_count,
335
+ }
336
+ if self.is_asa:
337
+ d["name_table_count"] = len(self.name_table)
338
+ d["location_count"] = self.location_count
339
+ else:
340
+ d["save_count"] = self.save_count
341
+ d["embedded_data_count"] = len(self.embedded_data)
342
+ d["name_table_count"] = len(self.name_table)
343
+ return d
344
+
345
+ def __repr__(self) -> str:
346
+ tag = "ASA" if self.is_asa else "ASE"
347
+ return (
348
+ f"WorldSave({tag}, version={self.version}, "
349
+ f"game_time={self.game_time:.1f}s, "
350
+ f"objects={self.object_count}, "
351
+ f"errors={self.parse_error_count})"
352
+ )
353
+
354
+ # ==================================================================
355
+ # ASE parsing (binary)
356
+ # ==================================================================
357
+
358
+ @classmethod
359
+ def _parse_ase(cls, reader: BinaryReader, load_properties: bool = True) -> WorldSave:
360
+ """
361
+ Parse an ASE binary world save.
362
+
363
+ Order:
364
+ 1. Header (version, offsets, game_time)
365
+ 2. Name table (v6+, at nameTableOffset)
366
+ 3. Data files list
367
+ 4. Embedded data
368
+ 5. Data files object map
369
+ 6. Object headers
370
+ 7. Object properties
371
+ """
372
+ save = cls()
373
+ save.is_asa = False
374
+ save._parse_errors = []
375
+
376
+ save._read_ase_header(reader)
377
+
378
+ if save.version > 5 and save._name_table_offset > 0:
379
+ save._read_ase_name_table(reader)
380
+
381
+ save._read_ase_data_files(reader)
382
+ save._read_ase_embedded_data(reader)
383
+ save._read_ase_data_files_object_map(reader)
384
+ save._read_ase_objects(reader)
385
+
386
+ if load_properties:
387
+ save._read_ase_object_properties(reader)
388
+
389
+ save.container = GameObjectContainer(objects=save.objects)
390
+ save.container.build_relationships()
391
+
392
+ return save
393
+
394
+ def _read_ase_header(self, reader: BinaryReader) -> None:
395
+ """Read the ASE binary header (varies by version)."""
396
+ self.version = reader.read_int16()
397
+
398
+ if self.version not in self.VALID_ASE_VERSIONS:
399
+ raise ArkParseError(
400
+ f"Unsupported WorldSave version {self.version}. Expected one of: {self.VALID_ASE_VERSIONS}"
401
+ )
402
+
403
+ # Version 11+ has stored-data offsets
404
+ if self.version > 10:
405
+ for _ in range(4):
406
+ _offset = reader.read_int64()
407
+ _size = reader.read_int64()
408
+
409
+ # Version 7+ has hibernation offset
410
+ if self.version > 6:
411
+ self._hibernation_offset = reader.read_int32()
412
+ _should_be_zero = reader.read_int32()
413
+
414
+ # Version 6+ has name-table / properties-block offsets
415
+ if self.version > 5:
416
+ self._name_table_offset = reader.read_int32()
417
+ self._properties_block_offset = reader.read_int32()
418
+
419
+ self.game_time = reader.read_float()
420
+
421
+ if self.version > 8:
422
+ self.save_count = reader.read_int32()
423
+
424
+ def _read_ase_name_table(self, reader: BinaryReader) -> None:
425
+ """Jump to *nameTableOffset* and read the name table."""
426
+ if self._name_table_offset == 0:
427
+ return
428
+
429
+ saved = reader.position
430
+ reader.position = self._name_table_offset
431
+
432
+ count = reader.read_int32()
433
+ self.name_table = [reader.read_string() for _ in range(count)]
434
+
435
+ reader.position = saved
436
+
437
+ def _read_ase_data_files(self, reader: BinaryReader) -> None:
438
+ count = reader.read_int32()
439
+ self.data_files = [reader.read_string() for _ in range(count)]
440
+
441
+ def _read_ase_embedded_data(self, reader: BinaryReader) -> None:
442
+ count = reader.read_int32()
443
+ self.embedded_data = [EmbeddedData.read(reader) for _ in range(count)]
444
+
445
+ def _read_ase_data_files_object_map(self, reader: BinaryReader) -> None:
446
+ count = reader.read_int32()
447
+ self.data_files_object_map = {}
448
+ for _ in range(count):
449
+ level = reader.read_int32()
450
+ name_count = reader.read_int32()
451
+ names = [reader.read_string() for _ in range(name_count)]
452
+ self.data_files_object_map.setdefault(level, []).append(names)
453
+
454
+ def _read_ase_name_from_table(self, reader: BinaryReader) -> str:
455
+ """Read a name-table reference (index + instance)."""
456
+ index = reader.read_int32()
457
+ internal = index - 1
458
+ nt = self.name_table
459
+ if isinstance(nt, list) and 0 <= internal < len(nt):
460
+ name = nt[internal]
461
+ else:
462
+ name = f"__INVALID_NAME_INDEX_{index}__"
463
+ instance = reader.read_int32()
464
+ return f"{name}_{instance - 1}" if instance > 0 else name
465
+
466
+ def _read_ase_object_header(self, reader: BinaryReader, obj_id: int) -> GameObject:
467
+ """Read a single ASE object header."""
468
+ obj = GameObject(id=obj_id)
469
+
470
+ guid = reader.read_guid()
471
+ obj.guid = str(guid) if any(b != 0 for b in guid.bytes) else ""
472
+
473
+ if self.version > 5 and isinstance(self.name_table, list) and self.name_table:
474
+ obj.class_name = self._read_ase_name_from_table(reader)
475
+ else:
476
+ obj.class_name = reader.read_string()
477
+
478
+ obj.is_item = reader.read_uint32() != 0
479
+
480
+ name_count = reader.read_int32()
481
+ obj.names = []
482
+ for _ in range(name_count):
483
+ if self.version > 5 and isinstance(self.name_table, list) and self.name_table:
484
+ obj.names.append(self._read_ase_name_from_table(reader))
485
+ else:
486
+ obj.names.append(reader.read_string())
487
+
488
+ obj.from_data_file = reader.read_uint32() != 0
489
+ obj.data_file_index = reader.read_int32()
490
+
491
+ has_location = reader.read_uint32() != 0
492
+ if has_location:
493
+ obj.location = LocationData.read(reader, False)
494
+
495
+ obj.properties_offset = reader.read_int32()
496
+ _unknown = reader.read_int32()
497
+
498
+ return obj
499
+
500
+ def _read_ase_objects(self, reader: BinaryReader) -> None:
501
+ count = reader.read_int32()
502
+ self.objects = [self._read_ase_object_header(reader, i) for i in range(count)]
503
+
504
+ def _read_ase_object_properties(self, reader: BinaryReader) -> None:
505
+ """Load properties for every ASE object."""
506
+ name_table = self.name_table if self.version > 5 and isinstance(self.name_table, list) else None
507
+ failures = 0
508
+
509
+ for i, obj in enumerate(self.objects):
510
+ next_obj = self.objects[i + 1] if i + 1 < len(self.objects) else None
511
+ try:
512
+ obj.load_properties(
513
+ reader,
514
+ properties_block_offset=self._properties_block_offset,
515
+ is_asa=False,
516
+ next_object=next_obj,
517
+ name_table=name_table,
518
+ )
519
+ except Exception:
520
+ logger.debug(
521
+ "Failed to load properties for object %s",
522
+ obj.class_name,
523
+ exc_info=True,
524
+ )
525
+ failures += 1
526
+
527
+ # ==================================================================
528
+ # ASA parsing (SQLite)
529
+ # ==================================================================
530
+
531
+ @classmethod
532
+ def _parse_asa(
533
+ cls,
534
+ path: Path,
535
+ load_properties: bool = True,
536
+ max_objects: int | None = None,
537
+ ) -> WorldSave:
538
+ """Parse an ASA SQLite world save."""
539
+ save = cls()
540
+ save.is_asa = True
541
+ save._parse_errors = []
542
+
543
+ try:
544
+ conn = sqlite3.connect(str(path))
545
+ save._read_asa_header(conn)
546
+ save._read_asa_actor_locations(conn)
547
+ save._read_asa_game_objects(conn, load_properties, max_objects)
548
+ conn.close()
549
+ except sqlite3.Error as e:
550
+ raise ArkParseError(f"SQLite error reading ASA world save: {e}")
551
+
552
+ return save
553
+
554
+ def _read_asa_header(self, conn: sqlite3.Connection) -> None:
555
+ """Parse the ``SaveHeader`` blob."""
556
+ cursor = conn.execute("SELECT value FROM custom WHERE key = 'SaveHeader'")
557
+ row = cursor.fetchone()
558
+ if row is None:
559
+ raise ArkParseError("SaveHeader not found in ASA world save")
560
+
561
+ reader = BinaryReader.from_bytes(row[0])
562
+
563
+ self.version = reader.read_int16()
564
+ _legacy_offset = reader.read_int32()
565
+ _unknown1 = reader.read_int32()
566
+ _actual_offset = reader.read_int32()
567
+ self.game_time = reader.read_double()
568
+ _unknown2 = reader.read_int32()
569
+
570
+ # Data files
571
+ count = reader.read_int32()
572
+ self.data_files = []
573
+ for _ in range(count):
574
+ self.data_files.append(reader.read_string())
575
+ _term = reader.read_int32()
576
+
577
+ _pad1 = reader.read_int32()
578
+ _pad2 = reader.read_int32()
579
+
580
+ # Name table (dict keyed by hash for ASA)
581
+ name_count = reader.read_int32()
582
+ nt: dict[int, str] = {}
583
+ for _ in range(name_count):
584
+ idx = reader.read_int32()
585
+ raw = reader.read_string()
586
+ nt[idx] = raw.rsplit(".", 1)[-1] if "." in raw else raw
587
+ self.name_table = nt
588
+
589
+ def _read_asa_actor_locations(self, conn: sqlite3.Connection) -> None:
590
+ """Parse the ``ActorTransforms`` blob."""
591
+ cursor = conn.execute("SELECT value FROM custom WHERE key = 'ActorTransforms'")
592
+ row = cursor.fetchone()
593
+ if row is None:
594
+ return
595
+
596
+ reader = BinaryReader.from_bytes(row[0])
597
+ self.actor_locations = {}
598
+
599
+ while reader.remaining >= 16:
600
+ guid_bytes = reader.read_bytes(16)
601
+ if all(b == 0 for b in guid_bytes):
602
+ break
603
+
604
+ guid_str = str(UUID(bytes_le=guid_bytes))
605
+ x, y, z = reader.read_double(), reader.read_double(), reader.read_double()
606
+ pitch, yaw, roll = reader.read_double(), reader.read_double(), reader.read_double()
607
+ reader.skip(8)
608
+
609
+ self.actor_locations[guid_str] = LocationData(
610
+ x=x,
611
+ y=y,
612
+ z=z,
613
+ pitch=pitch,
614
+ yaw=yaw,
615
+ roll=roll,
616
+ )
617
+
618
+ def _read_asa_game_objects(
619
+ self,
620
+ conn: sqlite3.Connection,
621
+ load_properties: bool = True,
622
+ max_objects: int | None = None,
623
+ ) -> None:
624
+ """Read all game objects from the ``game`` table."""
625
+ query = "SELECT key, value FROM game"
626
+ if max_objects is not None:
627
+ query += f" LIMIT {max_objects}"
628
+
629
+ cursor = conn.execute(query)
630
+ self.objects = []
631
+ self._objects_by_guid = {}
632
+ obj_id = 0
633
+
634
+ for key_bytes, value_bytes in cursor:
635
+ guid_str = str(UUID(bytes_le=key_bytes))
636
+ try:
637
+ obj = self._parse_asa_game_object(guid_str, value_bytes, obj_id, load_properties)
638
+ if guid_str in self.actor_locations:
639
+ obj.location = self.actor_locations[guid_str]
640
+ self.objects.append(obj)
641
+ self._objects_by_guid[guid_str] = obj
642
+ obj_id += 1
643
+ except Exception as e:
644
+ self._parse_errors.append(f"GUID {guid_str}: {e}")
645
+
646
+ def _parse_asa_game_object(
647
+ self,
648
+ guid_str: str,
649
+ blob: bytes,
650
+ obj_id: int,
651
+ load_properties: bool = True,
652
+ ) -> GameObject:
653
+ """Parse a single ASA game object from its binary blob."""
654
+ reader = BinaryReader.from_bytes(blob)
655
+ obj = GameObject(id=obj_id, guid=guid_str)
656
+ nt = self.name_table
657
+ assert isinstance(nt, dict)
658
+
659
+ # Class name from name-table index
660
+ class_idx = reader.read_int32()
661
+ obj.class_name = nt.get(class_idx, f"__UNKNOWN_CLASS_{class_idx}__")
662
+ _class_inst = reader.read_int32()
663
+ _zeros = reader.read_int32()
664
+
665
+ # Inline names
666
+ name_count = reader.read_int32()
667
+ obj.names = [reader.read_string() for _ in range(name_count)]
668
+ _end_marker = reader.read_int32()
669
+
670
+ # Object type flag
671
+ if reader.remaining < 2:
672
+ obj.is_item = False
673
+ obj.properties_offset = reader.position
674
+ return obj
675
+
676
+ obj.is_item = reader.read_uint16() == 1
677
+ obj.properties_offset = reader.position
678
+
679
+ if load_properties:
680
+ try:
681
+ obj.properties = read_properties(
682
+ reader,
683
+ is_asa=True,
684
+ name_table=nt,
685
+ worldsave_format=True,
686
+ )
687
+ except Exception as e:
688
+ self._parse_errors.append(f"Properties for {obj.class_name} ({guid_str}): {e}")
689
+
690
+ return obj
691
+
692
+ def _read_asa_name_from_table(self, reader: BinaryReader) -> str:
693
+ """Read a name-table reference in ASA format."""
694
+ index = reader.read_int32()
695
+ nt = self.name_table
696
+ assert isinstance(nt, dict)
697
+ name = nt.get(index, f"__UNKNOWN_NAME_{index}__")
698
+ instance = reader.read_int32()
699
+ return f"{name}_{instance - 1}" if instance > 0 else name