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.
- arkparser/__init__.py +117 -0
- arkparser/common/__init__.py +72 -0
- arkparser/common/binary_reader.py +402 -0
- arkparser/common/exceptions.py +99 -0
- arkparser/common/map_config.py +166 -0
- arkparser/common/types.py +249 -0
- arkparser/common/version_detection.py +195 -0
- arkparser/data_models.py +801 -0
- arkparser/export.py +485 -0
- arkparser/files/__init__.py +25 -0
- arkparser/files/base.py +309 -0
- arkparser/files/cloud_inventory.py +259 -0
- arkparser/files/profile.py +205 -0
- arkparser/files/tribe.py +155 -0
- arkparser/files/world_save.py +699 -0
- arkparser/game_objects/__init__.py +32 -0
- arkparser/game_objects/container.py +180 -0
- arkparser/game_objects/game_object.py +273 -0
- arkparser/game_objects/location.py +87 -0
- arkparser/models/__init__.py +29 -0
- arkparser/models/character.py +227 -0
- arkparser/models/creature.py +642 -0
- arkparser/models/item.py +207 -0
- arkparser/models/player.py +263 -0
- arkparser/models/stats.py +226 -0
- arkparser/models/structure.py +176 -0
- arkparser/models/tribe.py +291 -0
- arkparser/properties/__init__.py +77 -0
- arkparser/properties/base.py +329 -0
- arkparser/properties/byte_property.py +230 -0
- arkparser/properties/compound.py +1125 -0
- arkparser/properties/primitives.py +803 -0
- arkparser/properties/registry.py +236 -0
- arkparser/py.typed +0 -0
- arkparser/structs/__init__.py +60 -0
- arkparser/structs/base.py +63 -0
- arkparser/structs/colors.py +108 -0
- arkparser/structs/misc.py +133 -0
- arkparser/structs/property_list.py +101 -0
- arkparser/structs/registry.py +140 -0
- arkparser/structs/vectors.py +221 -0
- arkparser-0.1.0.dist-info/METADATA +833 -0
- arkparser-0.1.0.dist-info/RECORD +46 -0
- arkparser-0.1.0.dist-info/WHEEL +5 -0
- arkparser-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|