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,309 @@
1
+ """
2
+ Base class for ARK save file formats.
3
+
4
+ All file types (Profile, Tribe, CloudInventory) share the same basic structure:
5
+ 1. Version header (Int32)
6
+ 2. Object list (Int32 count + GameObject headers)
7
+ 3. Properties block (properties for each object)
8
+
9
+ This base class handles the common parsing logic.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import typing as t
15
+ from abc import ABC
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ from arkparser.common.binary_reader import BinaryReader
20
+ from arkparser.common.exceptions import ArkParseError
21
+ from arkparser.game_objects.container import GameObjectContainer
22
+ from arkparser.game_objects.game_object import GameObject
23
+
24
+
25
+ @dataclass
26
+ class ArkFile(ABC):
27
+ """
28
+ Abstract base class for ARK save file formats.
29
+
30
+ All ARK save files share this structure:
31
+ - Int32 version number
32
+ - Int32 object count
33
+ - Object headers (repeated object_count times)
34
+ - Properties block (properties for each object)
35
+
36
+ Subclasses define which version numbers are valid and which
37
+ class name identifies the "main" object in the file.
38
+ """
39
+
40
+ version: int
41
+ objects: list[GameObject] = field(default_factory=list)
42
+ container: GameObjectContainer = field(default_factory=GameObjectContainer)
43
+ is_asa: bool = False
44
+
45
+ # Subclasses must define these
46
+ VALID_VERSIONS: t.ClassVar[tuple[int, ...]] = ()
47
+ MAIN_CLASS_NAME: t.ClassVar[str] = ""
48
+
49
+ @property
50
+ def main_object(self) -> GameObject | None:
51
+ """
52
+ Get the main object for this file type.
53
+
54
+ For profiles, this is the PrimalPlayerData object.
55
+ For tribes, this is the PrimalTribeData object.
56
+ For cloud inventory, this is the ArkCloudInventoryData object.
57
+ """
58
+ for obj in self.objects:
59
+ # ASA uses full path like "/Script/ShooterGame.ArkCloudInventoryData"
60
+ # ASE uses just "ArkCloudInventoryData"
61
+ if self.MAIN_CLASS_NAME in obj.class_name:
62
+ return obj
63
+ return None
64
+
65
+ @classmethod
66
+ def load(cls, source: str | Path | bytes) -> t.Self:
67
+ """
68
+ Load a file from path or bytes.
69
+
70
+ Automatically detects ASE vs ASA format based on file structure.
71
+
72
+ Args:
73
+ source: File path (str or Path) or raw bytes
74
+
75
+ Returns:
76
+ Parsed file instance
77
+
78
+ Raises:
79
+ ArkParseError: If the file cannot be parsed
80
+ FileNotFoundError: If the file path doesn't exist
81
+ """
82
+ if isinstance(source, bytes):
83
+ reader = BinaryReader.from_bytes(source)
84
+ else:
85
+ path = Path(source)
86
+ if not path.exists():
87
+ raise FileNotFoundError(f"File not found: {path}")
88
+ reader = BinaryReader.from_file(path)
89
+
90
+ return cls._parse(reader)
91
+
92
+ @classmethod
93
+ def _parse(cls, reader: BinaryReader) -> t.Self:
94
+ """
95
+ Parse the file from a binary reader.
96
+
97
+ ASE File structure (profiles/tribes version 1, world saves version 5-6):
98
+ 1. Int32 version
99
+ 2. Int32 object_count
100
+ 3. (16 zero bytes padding for profiles/tribes)
101
+ 4. Object headers (object_count times)
102
+ 5. Properties for each object
103
+
104
+ ASA File structure (profiles/tribes version 6, cloud inventory version 7+):
105
+ For version 6 (profiles/tribes):
106
+ 1. Int32 version
107
+ 2. Int32 object_count
108
+ 3. No extra header - object headers with GUIDs follow immediately
109
+ 4. Object headers with embedded GUIDs
110
+ 5. Properties for each object
111
+
112
+ For version 7+ (cloud inventory):
113
+ 1. Int32 version
114
+ 2. Int32 unknown1 (extra field)
115
+ 3. Int32 unknown2 (extra field)
116
+ 4. Int32 object_count
117
+ 5. Object headers with GUIDs
118
+ 6. Properties for each object
119
+ """
120
+ # Read version
121
+ version = reader.read_int32()
122
+
123
+ if cls.VALID_VERSIONS and version not in cls.VALID_VERSIONS:
124
+ raise ArkParseError(f"Unsupported {cls.__name__} version {version}. Expected one of: {cls.VALID_VERSIONS}")
125
+
126
+ # Detect ASE vs ASA
127
+ is_asa = cls._detect_asa(reader, version)
128
+
129
+ if is_asa and version >= 7:
130
+ # Only cloud inventory (version 7+) has extra header fields before object count
131
+ _unknown1 = reader.read_int32()
132
+ _unknown2 = reader.read_int32()
133
+
134
+ # Read object count
135
+ object_count = reader.read_int32()
136
+
137
+ if object_count < 0 or object_count > 1000000:
138
+ raise ArkParseError(f"Invalid object count: {object_count}")
139
+
140
+ # Read object headers
141
+ objects: list[GameObject] = []
142
+ for i in range(object_count):
143
+ obj = cls._read_object_header(reader, obj_id=i, is_asa=is_asa, version=version)
144
+ objects.append(obj)
145
+
146
+ # For profile/tribe/obelisk files (version 1), propertiesOffset is absolute from file start
147
+ # For world save files (version 5-7+), propertiesOffset is relative to a base offset in the header
148
+ # Since we're parsing simple files here, we use absolute offsets (properties_block_offset = 0)
149
+ properties_block_offset = 0
150
+
151
+ # Load properties for each object
152
+ # Properties are read in order, with each object's properties
153
+ # starting at its propertiesOffset (absolute from file start for these file types)
154
+ for i, obj in enumerate(objects):
155
+ # Get next object for boundary checking (optional)
156
+ next_obj = objects[i + 1] if i + 1 < len(objects) else None
157
+ obj.load_properties(
158
+ reader, properties_block_offset=properties_block_offset, is_asa=is_asa, next_object=next_obj
159
+ )
160
+
161
+ # Build container with lookups
162
+ container = GameObjectContainer(objects=objects)
163
+ container.build_relationships()
164
+
165
+ return cls(
166
+ version=version,
167
+ objects=objects,
168
+ container=container,
169
+ is_asa=is_asa,
170
+ )
171
+
172
+ @classmethod
173
+ def _read_object_header(cls, reader: BinaryReader, obj_id: int, is_asa: bool, version: int = 7) -> GameObject:
174
+ """
175
+ Read an object header in ASE or ASA format.
176
+
177
+ ASE object header:
178
+ - Class name (string)
179
+ - Item1 flag (int32)
180
+ - Item2 flag (int32)
181
+ - Names (string array)
182
+ - IsItem flag (int32, 0 or 1)
183
+ - More fields...
184
+
185
+ ASA object header:
186
+ - GUID (16 bytes)
187
+ - Class name (string)
188
+ - Field1 (int32) - unknown purpose
189
+ - names_count (int32) - number of name strings to read
190
+ - Names array (names_count strings)
191
+ - Padding (20 bytes): 12 zeros + Int32 properties_offset + 4 zeros
192
+ """
193
+ if is_asa:
194
+ obj = GameObject(id=obj_id)
195
+
196
+ # Read GUID
197
+ guid = reader.read_guid()
198
+ obj.guid = str(guid)
199
+
200
+ # Class name
201
+ obj.class_name = reader.read_string()
202
+
203
+ # Unknown field and names count
204
+ _field1 = reader.read_int32()
205
+ names_count = reader.read_int32()
206
+
207
+ # Read all names (first is typically instance name, rest are world context)
208
+ names = []
209
+ for _ in range(names_count):
210
+ name = reader.read_string()
211
+ names.append(name)
212
+ obj.names = names
213
+
214
+ # Read padding/metadata after names:
215
+ # Layout: 12 zero bytes + int32 properties_offset + 4 zero bytes = 20 bytes
216
+ # The stored properties_offset for v7 points to one byte before the actual
217
+ # properties start. That byte is a 0x00 terminator in most single-object
218
+ # files; in multi-object files, that byte is the first byte of the next
219
+ # object's GUID (non-zero). Either way, actual properties = stored + 1 for v7.
220
+ # For v6, the stored offset IS the exact properties start (no +1 needed).
221
+ reader.skip(12) # 12 padding zeros
222
+ stored_props_offset = reader.read_int32() # stored absolute file offset
223
+ reader.skip(4) # 4 trailing zeros
224
+ # Consume the optional 0x00 terminator byte if present (most v7 single-obj files)
225
+ if version >= 7 and reader.remaining > 0 and reader.peek_bytes(1)[0] == 0x00:
226
+ reader.skip(1)
227
+
228
+ obj.properties_offset = stored_props_offset + (1 if version >= 7 else 0)
229
+
230
+ return obj
231
+ else:
232
+ return GameObject.read_header(reader, obj_id=obj_id, is_asa=False)
233
+
234
+ @classmethod
235
+ def _detect_asa(cls, reader: BinaryReader, version: int) -> bool:
236
+ """
237
+ Detect if this is an ASA format file.
238
+
239
+ Detection heuristics:
240
+ - Version 7+ is typically ASA (cloud inventory/obelisk)
241
+ - Version 6 with non-zero GUID at offset 8 is ASA (profiles/tribes)
242
+ - Version 6 with zeros at offset 8 is ASE
243
+
244
+ File structure at this point (after reading version):
245
+ - ASE: object_count (4) + zeros (16) + object headers
246
+ - ASA: object_count (4) + GUID (16) + object headers
247
+
248
+ The GUID check distinguishes ASE v6 world saves from ASA profiles.
249
+ """
250
+ # Version 7+ is always ASA
251
+ if version >= 7:
252
+ return True
253
+
254
+ # Version 6 could be either ASE world save or ASA profile/tribe
255
+ # Check for non-zero GUID at offset 8 (after version + object_count)
256
+ if version == 6:
257
+ # Save position after version read
258
+ current_pos = reader.position
259
+ # Skip object_count (4 bytes) to reach potential GUID position
260
+ reader.skip(4)
261
+ # Read 16 bytes that would be GUID in ASA or zeros in ASE
262
+ guid_bytes = reader.read_bytes(16)
263
+ # Restore position
264
+ reader.position = current_pos
265
+ # If any byte is non-zero, it's ASA (has a GUID)
266
+ if any(b != 0 for b in guid_bytes):
267
+ return True
268
+
269
+ return False
270
+
271
+ def get_property_value(self, name: str, default: t.Any = None, from_main: bool = True) -> t.Any:
272
+ """
273
+ Get a property value from the main object.
274
+
275
+ Args:
276
+ name: Property name to look up
277
+ default: Default value if not found
278
+ from_main: If True, get from main object. If False, search all objects.
279
+
280
+ Returns:
281
+ Property value or default
282
+ """
283
+ if from_main and self.main_object:
284
+ return self.main_object.get_property_value(name, default)
285
+
286
+ # Search all objects
287
+ for obj in self.objects:
288
+ value = obj.get_property_value(name)
289
+ if value is not None:
290
+ return value
291
+ return default
292
+
293
+ def to_dict(self) -> dict[str, t.Any]:
294
+ """
295
+ Convert to a dictionary representation.
296
+
297
+ Returns:
298
+ Dictionary with file data
299
+ """
300
+ return {
301
+ "version": self.version,
302
+ "is_asa": self.is_asa,
303
+ "object_count": len(self.objects),
304
+ "main_object": self.main_object.to_dict() if self.main_object else None,
305
+ "objects": [obj.to_dict() for obj in self.objects],
306
+ }
307
+
308
+ def __repr__(self) -> str:
309
+ return f"{self.__class__.__name__}(version={self.version}, objects={len(self.objects)}, is_asa={self.is_asa})"
@@ -0,0 +1,259 @@
1
+ """
2
+ Cloud inventory parser for obelisk/ARK data files.
3
+
4
+ Cloud inventory files contain uploaded data including:
5
+ - Uploaded creatures (dinos)
6
+ - Uploaded items (including cryopods with dinos inside)
7
+ - Uploaded characters
8
+ - Transfer timers
9
+
10
+ These files have no extension and are typically found in obelisk directories.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import typing as t
16
+ from dataclasses import dataclass
17
+
18
+ from arkparser.common.binary_reader import BinaryReader
19
+ from arkparser.common.exceptions import ArkParseError
20
+ from arkparser.data_models import UploadedCreature, UploadedItem
21
+ from arkparser.game_objects.container import GameObjectContainer
22
+ from arkparser.game_objects.game_object import GameObject
23
+
24
+ from .base import ArkFile
25
+
26
+
27
+ @dataclass
28
+ class CloudInventory(ArkFile):
29
+ """
30
+ Parser for obelisk/cloud inventory data files.
31
+
32
+ The main object has class name "ArkCloudInventoryData".
33
+
34
+ Supports both ASE (versions 1-6) and ASA (version 7+) formats.
35
+
36
+ Example usage:
37
+ >>> inv = CloudInventory.load("examples/ase/obelisk/2533274922942310")
38
+ >>> print(f"Creatures: {len(inv.uploaded_creatures)}")
39
+ >>> for creature in inv.uploaded_creatures:
40
+ ... print(f" {creature.name} ({creature.species}) Lvl {creature.level}")
41
+ >>> for item in inv.uploaded_items:
42
+ ... print(f" {item.display_name} ({item.quality_name})")
43
+ """
44
+
45
+ VALID_VERSIONS: t.ClassVar[tuple[int, ...]] = (1, 2, 3, 4, 5, 6, 7)
46
+ MAIN_CLASS_NAME: t.ClassVar[str] = "ArkCloudInventoryData"
47
+
48
+ @classmethod
49
+ def _parse(cls, reader: BinaryReader) -> t.Self:
50
+ """
51
+ Parse the cloud inventory file.
52
+
53
+ ASE format:
54
+ - Int32 version
55
+ - Int32 object_count
56
+ - Object headers (ASE format)
57
+ - Properties
58
+
59
+ ASA format:
60
+ - Int32 version
61
+ - Int32 unknown1 (extra field)
62
+ - Int32 unknown2 (extra field)
63
+ - Int32 object_count
64
+ - Object headers (ASA format with GUIDs)
65
+ - Properties
66
+ """
67
+ # Read version
68
+ version = reader.read_int32()
69
+
70
+ if cls.VALID_VERSIONS and version not in cls.VALID_VERSIONS:
71
+ raise ArkParseError(f"Unsupported {cls.__name__} version {version}. Expected one of: {cls.VALID_VERSIONS}")
72
+
73
+ # Detect ASE vs ASA
74
+ is_asa = cls._detect_asa(reader, version)
75
+
76
+ if is_asa:
77
+ # ASA has extra header fields
78
+ _unknown1 = reader.read_int32()
79
+ _unknown2 = reader.read_int32()
80
+
81
+ # Read object count
82
+ object_count = reader.read_int32()
83
+
84
+ if object_count < 0 or object_count > 1000000:
85
+ raise ArkParseError(f"Invalid object count: {object_count}")
86
+
87
+ # Read object headers
88
+ objects: list[GameObject] = []
89
+ for i in range(object_count):
90
+ if is_asa:
91
+ obj = cls._read_asa_object_header(reader, obj_id=i)
92
+ else:
93
+ obj = GameObject.read_header(reader, obj_id=i, is_asa=False)
94
+ objects.append(obj)
95
+
96
+ # Load properties for each object
97
+ properties_block_offset = 0
98
+ for i, obj in enumerate(objects):
99
+ next_obj = objects[i + 1] if i + 1 < len(objects) else None
100
+ obj.load_properties(
101
+ reader, properties_block_offset=properties_block_offset, is_asa=is_asa, next_object=next_obj
102
+ )
103
+
104
+ # Build container with lookups
105
+ container = GameObjectContainer(objects=objects)
106
+ container.build_relationships()
107
+
108
+ return cls(
109
+ version=version,
110
+ objects=objects,
111
+ container=container,
112
+ is_asa=is_asa,
113
+ )
114
+
115
+ @classmethod
116
+ def _read_asa_object_header(cls, reader: BinaryReader, obj_id: int) -> GameObject:
117
+ """
118
+ Read an ASA object header.
119
+
120
+ ASA obelisk object header format:
121
+ - GUID (16 bytes)
122
+ - Class name (string)
123
+ - Field1 (int32)
124
+ - Field2 (int32)
125
+ - Instance name (string)
126
+ - Padding (21 bytes)
127
+ """
128
+ obj = GameObject(id=obj_id)
129
+
130
+ # Read GUID
131
+ guid = reader.read_guid()
132
+ obj.guid = str(guid)
133
+
134
+ # Class name
135
+ obj.class_name = reader.read_string()
136
+
137
+ # Fields (not sure what these represent)
138
+ _field1 = reader.read_int32()
139
+ _field2 = reader.read_int32()
140
+
141
+ # Instance name
142
+ instance_name = reader.read_string()
143
+ obj.names = [instance_name] if instance_name else []
144
+
145
+ # Skip 21 bytes of padding
146
+ reader.skip(21)
147
+
148
+ # Properties offset will be set by sequential reading
149
+ obj.properties_offset = reader.position
150
+
151
+ return obj
152
+
153
+ # =========================================================================
154
+ # Data Extraction - Primary API
155
+ # =========================================================================
156
+
157
+ @property
158
+ def uploaded_creatures(self) -> list[UploadedCreature]:
159
+ """
160
+ Get all uploaded creatures as structured data.
161
+
162
+ Returns:
163
+ List of UploadedCreature objects with typed fields.
164
+ """
165
+ my_ark_data = self.get_property_value("MyArkData")
166
+ if not my_ark_data:
167
+ return []
168
+
169
+ dino_data_list = my_ark_data.get("ArkTamedDinosData", [])
170
+ return [UploadedCreature.from_ark_data(d) for d in dino_data_list]
171
+
172
+ @property
173
+ def uploaded_items(self) -> list[UploadedItem]:
174
+ """
175
+ Get all uploaded items as structured data.
176
+
177
+ Returns:
178
+ List of UploadedItem objects with typed fields.
179
+ """
180
+ my_ark_data = self.get_property_value("MyArkData")
181
+ if not my_ark_data:
182
+ return []
183
+
184
+ item_data_list = my_ark_data.get("ArkItems", [])
185
+ return [UploadedItem.from_ark_data(d) for d in item_data_list]
186
+
187
+ # =========================================================================
188
+ # Convenience Properties
189
+ # =========================================================================
190
+
191
+ @property
192
+ def creature_count(self) -> int:
193
+ """Get number of uploaded creatures."""
194
+ return len(self.uploaded_creatures)
195
+
196
+ @property
197
+ def item_count(self) -> int:
198
+ """Get number of uploaded items."""
199
+ return len(self.uploaded_items)
200
+
201
+ # =========================================================================
202
+ # Legacy API (for backward compatibility)
203
+ # =========================================================================
204
+
205
+ @property
206
+ def creatures(self) -> list[GameObject]:
207
+ """
208
+ Get creatures as raw GameObjects.
209
+
210
+ Note: Use `uploaded_creatures` for structured data access.
211
+ """
212
+ return self.container.get_creatures()
213
+
214
+ @property
215
+ def items(self) -> list[GameObject]:
216
+ """
217
+ Get items as raw GameObjects.
218
+
219
+ Note: Use `uploaded_items` for structured data access.
220
+ """
221
+ result = []
222
+ for obj in self.objects:
223
+ class_name = obj.class_name or ""
224
+ if "PrimalItem" in class_name or "Item" in class_name:
225
+ result.append(obj)
226
+ return result
227
+
228
+ @property
229
+ def characters(self) -> list[GameObject]:
230
+ """
231
+ Get all uploaded player characters.
232
+
233
+ Characters are identified by having PlayerPawnTest class name.
234
+ """
235
+ result = []
236
+ for obj in self.objects:
237
+ class_name = obj.class_name or ""
238
+ if "PlayerPawnTest" in class_name:
239
+ result.append(obj)
240
+ return result
241
+
242
+ @property
243
+ def character_count(self) -> int:
244
+ """Get number of uploaded characters."""
245
+ return len(self.characters)
246
+
247
+ def to_dict(self) -> dict[str, t.Any]:
248
+ """Convert to dictionary with cloud inventory-specific fields."""
249
+ base_dict = super().to_dict()
250
+ base_dict.update(
251
+ {
252
+ "creature_count": self.creature_count,
253
+ "item_count": self.item_count,
254
+ "character_count": self.character_count,
255
+ "uploaded_creatures": [c.to_dict() for c in self.uploaded_creatures],
256
+ "uploaded_items": [i.to_dict() for i in self.uploaded_items],
257
+ }
258
+ )
259
+ return base_dict