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,166 @@
1
+ """
2
+ Map configuration for UE coordinate to GPS lat/lon conversion.
3
+
4
+ Formula (from C# reference):
5
+ latitude = lat_shift + (y / lat_div)
6
+ longitude = lon_shift + (x / lon_div)
7
+
8
+ Where x, y are Unreal Engine world coordinates.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import typing as t
14
+ from dataclasses import dataclass
15
+
16
+
17
+ @dataclass
18
+ class MapConfig:
19
+ """
20
+ Configuration for a specific ARK map.
21
+
22
+ Used to convert Unreal Engine coordinates to GPS lat/lon.
23
+
24
+ Attributes:
25
+ name: Human-readable map name.
26
+ filename: Save file name (e.g., "theisland.ark").
27
+ lat_shift: Latitude origin offset.
28
+ lat_div: Latitude divisor (scale).
29
+ lon_shift: Longitude origin offset.
30
+ lon_div: Longitude divisor (scale).
31
+ """
32
+
33
+ name: str
34
+ filename: str
35
+ lat_shift: float = 50.0
36
+ lat_div: float = 8000.0
37
+ lon_shift: float = 50.0
38
+ lon_div: float = 8000.0
39
+
40
+ def ue_to_lat(self, y: float) -> float:
41
+ """Convert UE Y coordinate to latitude."""
42
+ return self.lat_shift + (y / self.lat_div)
43
+
44
+ def ue_to_lon(self, x: float) -> float:
45
+ """Convert UE X coordinate to longitude."""
46
+ return self.lon_shift + (x / self.lon_div)
47
+
48
+ def ue_to_gps(self, x: float, y: float) -> tuple[float, float]:
49
+ """
50
+ Convert UE coordinates to GPS (latitude, longitude).
51
+
52
+ Returns:
53
+ Tuple of (latitude, longitude).
54
+ """
55
+ return (self.ue_to_lat(y), self.ue_to_lon(x))
56
+
57
+ def ccc_string(self, x: float, y: float, z: float) -> str:
58
+ """Format coordinates as a cheat setplayerpos string."""
59
+ return f"{x} {y} {z}"
60
+
61
+
62
+ # ============================================================================
63
+ # Built-in map configurations from C# ArkViewer maps.json
64
+ # ============================================================================
65
+
66
+ # fmt: off
67
+ _MAP_CONFIGS: list[MapConfig] = [
68
+ # Official ASE Maps
69
+ MapConfig("The Island (Evolved)", "theisland.ark", 50.0, 8000.0, 50.0, 8000.0),
70
+ MapConfig("Scorched Earth", "scorchedearth_p.ark", 50.0, 8000.0, 50.0, 8000.0),
71
+ MapConfig("Aberration (Evolved)", "aberration_p.ark", 50.0, 8000.0, 50.0, 8000.0),
72
+ MapConfig("Extinction (Evolved)", "extinction.ark", 50.0, 8000.0, 50.0, 8000.0),
73
+ MapConfig("The Center (Evolved)", "thecenter.ark", 30.34223747253418, 9584.0, 55.10416793823242, 9600.0),
74
+ MapConfig("Ragnarok", "ragnarok.ark", 50.009388, 13100.0, 50.009388, 13100.0),
75
+ MapConfig("Valguero", "valguero_p.ark", 50.0, 8161.0, 50.0, 8161.0),
76
+ MapConfig("Crystal Isles", "crystalisles.ark", 48.687, 15882.02, 49.9481, 16988.76),
77
+ MapConfig("Genesis", "genesis.ark", 50.0, 10500.0, 50.0, 10500.0),
78
+ MapConfig("Genesis 2", "gen2.ark", 49.6, 14500.0, 49.6, 14500.0),
79
+ MapConfig("Lost Island", "lostisland.ark", 51.6, 15300.0, 49.0, 15300.0),
80
+ MapConfig("Fjordur", "fjordur.ark", 50.0, 7140.0, 50.0, 7140.0),
81
+
82
+ # Official ASA Maps
83
+ MapConfig("The Island (Ascended)", "theisland_wp.ark", 50.0, 6850.0, 50.0, 6850.0),
84
+ MapConfig("The Center (Ascended)", "thecenter_wp.ark", 32.5, 10380.52, 50.5, 10374.29),
85
+ MapConfig("Scorched Earth (Ascended)", "scorchedearth_wp.ark", 50.0, 8000.0, 50.0, 8000.0),
86
+ MapConfig("Aberration (Ascended)", "aberration_wp.ark", 50.0, 8000.0, 50.0, 8000.0),
87
+ MapConfig("Extinction (Ascended)", "extinction_wp.ark", 50.0, 6850.0, 50.0, 6850.0),
88
+ MapConfig("Ragnarok (Ascended)", "ragnarok_wp.ark", 50.009388, 13100.0, 50.009388, 13100.0),
89
+ MapConfig("Valguero (Ascended)", "valguero_wp.ark", 50.0, 8161.0, 50.0, 8161.0),
90
+
91
+ # Community / Modded Maps
92
+ MapConfig("Astral ARK", "astralark.ark", 50.0, 2000.0, 50.0, 2000.0),
93
+ MapConfig("Hope", "hope.ark", 50.0, 6850.0, 50.0, 6850.0),
94
+ MapConfig("Tunguska", "tunguska_p.ark", 46.8, 14000.0, 49.29, 13300.0),
95
+ MapConfig("Caballus", "caballus_p.ark", 50.0, 8125.0, 50.0, 8125.0),
96
+ MapConfig("Tiamat Prime", "tiamatprime.ark", 50.0, 8000.0, 50.0, 8000.0),
97
+ MapConfig("Glacius", "glacius_p.ark", 50.0, 16250.0, 50.0, 16250.0),
98
+ MapConfig("Antartika", "antartika.ark", 50.0, 8000.0, 50.0, 8000.0),
99
+ MapConfig("Amissa (Evolved)", "amissa.ark", 49.9, 10900.0, 49.9, 10850.0),
100
+ MapConfig("Amissa (Ascended)", "amissa_wp.ark", 46.9, 11375.0, 48.1, 11400.0),
101
+ MapConfig("Olympus", "olympus.ark", 0.0, 8130.0, 0.0, 8130.0),
102
+ MapConfig("Ebenus Astrum", "ebenusastrum.ark", 52.9, 8650.0, 25.0, 18500.0),
103
+ MapConfig("ARKForum Event Map", "arkforum_eventmap.ark", 50.0, 1500.0, 50.0, 1500.0),
104
+ MapConfig("The Volcano", "thevolcano.ark", 50.0, 9200.0, 50.0, 9200.0),
105
+ MapConfig("The Earrion", "earrion_p.ark", 50.0, 6250.0, 50.0, 6250.0),
106
+ MapConfig("Alemia", "Alemia_P.ark", 50.0, 8150.0, 50.0, 8150.0),
107
+ MapConfig("Velius", "Velius_P.ark", 50.0, 8575.0, 50.0, 8575.0),
108
+ MapConfig("Svartalfheim (Evolved)", "Svartalfheim.ark", 50.0, 4065.0, 50.0, 4065.0),
109
+ MapConfig("Svartalfheim (Ascended)", "Svartalfheim_WP.ark", 50.0, 4055.0, 50.0, 4055.0),
110
+ MapConfig("TaeniaStella", "TaeniaStella.ark", 48.9, 15500.0, 48.9, 15500.0),
111
+ MapConfig("Forglar (Ascended)", "forglar_wp.ark", 61.4, 7150.0, 69.8, 7945.0),
112
+ MapConfig("Insaluna (Ascended)", "insaluna_wp.ark", 50.0, 9400.0, 50.0, 9400.0),
113
+ MapConfig("Temptress Lagoon (Ascended)", "temptress_wp.ark", 50.0, 8150.0, 50.0, 8150.0),
114
+ MapConfig("Reverence (Ascended)", "reverence_wp.ark", 50.0, 8125.0, 50.0, 8125.0),
115
+ MapConfig("Nyrandil (Ascended)", "nyrandil.ark", 50.0, 8175.0, 50.0, 8175.0),
116
+ MapConfig("Astraeos (Ascended)", "astraeos_wp.ark", 50.0, 16000.0, 50.0, 16000.0),
117
+ MapConfig("Gun Smoke", "gunsmoke.ark", 12.1, 7900.0, 10.8, 7850.0),
118
+ MapConfig("Fjell", "viking_p.ark", 50.0, 7140.0, 50.0, 7140.0),
119
+ ]
120
+ # fmt: on
121
+
122
+ # Build lookup by filename (case-insensitive)
123
+ _MAP_BY_FILENAME: dict[str, MapConfig] = {cfg.filename.lower(): cfg for cfg in _MAP_CONFIGS}
124
+
125
+ # Default config for unknown maps
126
+ DEFAULT_MAP_CONFIG = MapConfig("Unknown", "unknown.ark", 50.0, 8000.0, 50.0, 8000.0)
127
+
128
+
129
+ def get_map_config(filename: str) -> MapConfig:
130
+ """
131
+ Get map configuration by save filename.
132
+
133
+ Args:
134
+ filename: The save file name (e.g., "ragnarok.ark").
135
+
136
+ Returns:
137
+ MapConfig for the map, or DEFAULT_MAP_CONFIG if not found.
138
+ """
139
+ return _MAP_BY_FILENAME.get(filename.lower(), DEFAULT_MAP_CONFIG)
140
+
141
+
142
+ def get_map_config_by_name(name: str) -> MapConfig:
143
+ """
144
+ Get map configuration by display name (case-insensitive partial match).
145
+
146
+ Args:
147
+ name: Map display name or partial match (e.g., "Ragnarok", "The Island").
148
+
149
+ Returns:
150
+ MapConfig for the map, or DEFAULT_MAP_CONFIG if not found.
151
+ """
152
+ name_lower = name.lower()
153
+ for cfg in _MAP_CONFIGS:
154
+ if name_lower in cfg.name.lower():
155
+ return cfg
156
+ return DEFAULT_MAP_CONFIG
157
+
158
+
159
+ def list_maps() -> list[MapConfig]:
160
+ """
161
+ Get all available map configurations.
162
+
163
+ Returns:
164
+ List of all registered MapConfig instances.
165
+ """
166
+ return list(_MAP_CONFIGS)
@@ -0,0 +1,249 @@
1
+ """
2
+ Core ARK Types.
3
+
4
+ This module defines the fundamental types used throughout ARK save files:
5
+ - ArkName: Unreal Engine's FName type (name + instance index)
6
+ - ObjectReference: References to other game objects
7
+
8
+ These types mirror Unreal Engine 4's serialization format.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import typing as t
15
+ from dataclasses import dataclass
16
+
17
+ # =============================================================================
18
+ # Constants
19
+ # =============================================================================
20
+
21
+ # The "None" name is used as a terminator in property lists
22
+ NAME_NONE = "None"
23
+
24
+
25
+ # =============================================================================
26
+ # ArkName (FName)
27
+ # =============================================================================
28
+
29
+ # Pattern to extract instance index from name strings like "MyName_5"
30
+ _NAME_INDEX_PATTERN = re.compile(r"^(.+)_(\d+)$")
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class ArkName:
35
+ """
36
+ Unreal Engine FName type.
37
+
38
+ FNames consist of a base name and an instance index. They're used
39
+ extensively for property names, class names, and identifiers.
40
+
41
+ Format in files:
42
+ - Without name table: String with optional "_N" suffix
43
+ - With name table: Int32 index + Int32 instance
44
+
45
+ Instance numbering:
46
+ - Instance 0 = no suffix (or first occurrence)
47
+ - Instance 1 = "_0" suffix
48
+ - Instance 2 = "_1" suffix
49
+ - etc.
50
+
51
+ Examples:
52
+ >>> ArkName.from_string("Health")
53
+ ArkName(name='Health', instance=0)
54
+
55
+ >>> ArkName.from_string("MyDino_5")
56
+ ArkName(name='MyDino', instance=6) # instance = parsed + 1
57
+
58
+ >>> str(ArkName("MyDino", 6))
59
+ 'MyDino_5'
60
+
61
+ Attributes:
62
+ name: The base name string.
63
+ instance: The instance index (0 = no suffix).
64
+ """
65
+
66
+ name: str
67
+ instance: int = 0
68
+
69
+ def __str__(self) -> str:
70
+ """Convert to string representation."""
71
+ if self.instance == 0:
72
+ return self.name
73
+ return f"{self.name}_{self.instance - 1}"
74
+
75
+ def __repr__(self) -> str:
76
+ """Detailed representation for debugging."""
77
+ if self.instance == 0:
78
+ return f"ArkName({self.name!r})"
79
+ return f"ArkName({self.name!r}, instance={self.instance})"
80
+
81
+ def __eq__(self, other: object) -> bool:
82
+ """Check equality with another ArkName."""
83
+ if isinstance(other, ArkName):
84
+ return self.name == other.name and self.instance == other.instance
85
+ return NotImplemented
86
+
87
+ def __hash__(self) -> int:
88
+ """Hash for use in dicts/sets."""
89
+ return hash((self.name, self.instance))
90
+
91
+ @property
92
+ def is_none(self) -> bool:
93
+ """Check if this is the 'None' terminator name."""
94
+ return self.name == NAME_NONE and self.instance == 0
95
+
96
+ @classmethod
97
+ def from_string(cls, value: str) -> ArkName:
98
+ """
99
+ Parse an ArkName from a string.
100
+
101
+ Handles the "_N" suffix convention:
102
+ - "Health" -> ArkName("Health", 0)
103
+ - "MyDino_0" -> ArkName("MyDino", 1)
104
+ - "Item_5" -> ArkName("Item", 6)
105
+
106
+ Args:
107
+ value: The string to parse.
108
+
109
+ Returns:
110
+ An ArkName instance.
111
+ """
112
+ if not value:
113
+ return cls("", 0)
114
+
115
+ match = _NAME_INDEX_PATTERN.match(value)
116
+ if match:
117
+ name = match.group(1)
118
+ index = int(match.group(2))
119
+ return cls(name, index + 1)
120
+
121
+ return cls(value, 0)
122
+
123
+ @classmethod
124
+ def from_parts(cls, name: str, instance: int) -> ArkName:
125
+ """
126
+ Create an ArkName from separate name and instance.
127
+
128
+ Used when reading from a name table where name and instance
129
+ are stored separately.
130
+
131
+ Args:
132
+ name: The base name.
133
+ instance: The instance index.
134
+
135
+ Returns:
136
+ An ArkName instance.
137
+ """
138
+ return cls(name, instance)
139
+
140
+ @classmethod
141
+ def none(cls) -> ArkName:
142
+ """Return the 'None' terminator name."""
143
+ return cls(NAME_NONE, 0)
144
+
145
+
146
+ # =============================================================================
147
+ # ObjectReference
148
+ # =============================================================================
149
+
150
+
151
+ @dataclass(frozen=True, slots=True)
152
+ class ObjectReference:
153
+ """
154
+ Reference to another game object.
155
+
156
+ Objects in ARK save files can reference each other. The format differs
157
+ between ASE and ASA:
158
+
159
+ ASE format:
160
+ - Int32 type (0 = index, 1 = name)
161
+ - If type 0: Int32 object index
162
+ - If type 1: ArkName
163
+
164
+ ASA format:
165
+ - Int16 isName flag
166
+ - If isName = 0: 16-byte GUID
167
+ - If isName = 1: ArkName
168
+
169
+ Attributes:
170
+ object_id: The object index (ASE) or None.
171
+ object_guid: The object GUID as hex string (ASA) or None.
172
+ object_name: The object name (when referencing by name) or None.
173
+ is_null: True if this is a null reference.
174
+ """
175
+
176
+ object_id: int | None = None
177
+ object_guid: str | None = None
178
+ object_name: ArkName | None = None
179
+
180
+ @property
181
+ def is_null(self) -> bool:
182
+ """Check if this is a null/empty reference."""
183
+ return self.object_id is None and self.object_guid is None and self.object_name is None
184
+
185
+ @property
186
+ def is_id_reference(self) -> bool:
187
+ """Check if this references by ID (ASE style)."""
188
+ return self.object_id is not None
189
+
190
+ @property
191
+ def is_guid_reference(self) -> bool:
192
+ """Check if this references by GUID (ASA style)."""
193
+ return self.object_guid is not None
194
+
195
+ @property
196
+ def is_name_reference(self) -> bool:
197
+ """Check if this references by name."""
198
+ return self.object_name is not None
199
+
200
+ @classmethod
201
+ def null(cls) -> ObjectReference:
202
+ """Create a null reference."""
203
+ return cls()
204
+
205
+ @classmethod
206
+ def from_id(cls, object_id: int) -> ObjectReference:
207
+ """Create a reference from an object ID (ASE)."""
208
+ return cls(object_id=object_id)
209
+
210
+ @classmethod
211
+ def from_guid(cls, guid: str) -> ObjectReference:
212
+ """Create a reference from a GUID string (ASA)."""
213
+ return cls(object_guid=guid)
214
+
215
+ @classmethod
216
+ def from_name(cls, name: ArkName) -> ObjectReference:
217
+ """Create a reference from an object name."""
218
+ return cls(object_name=name)
219
+
220
+ def __str__(self) -> str:
221
+ """String representation."""
222
+ if self.is_null:
223
+ return "ObjectReference(null)"
224
+ if self.object_id is not None:
225
+ return f"ObjectReference(id={self.object_id})"
226
+ if self.object_guid is not None:
227
+ return f"ObjectReference(guid={self.object_guid})"
228
+ if self.object_name is not None:
229
+ return f"ObjectReference(name={self.object_name})"
230
+ return "ObjectReference()"
231
+
232
+
233
+ # =============================================================================
234
+ # Type Aliases
235
+ # =============================================================================
236
+
237
+ # For type hints in generic contexts
238
+ PropertyValue = t.Union[
239
+ int,
240
+ float,
241
+ bool,
242
+ str,
243
+ bytes,
244
+ ArkName,
245
+ ObjectReference,
246
+ list[t.Any],
247
+ dict[str, t.Any],
248
+ None,
249
+ ]
@@ -0,0 +1,195 @@
1
+ """
2
+ ARK File Format Detection.
3
+
4
+ Detects whether an ARK save file is in ASE or ASA format based on
5
+ header information. This allows the parser to automatically choose
6
+ the correct parsing strategy.
7
+
8
+ Detection Strategy for profiles/tribes/cloud data:
9
+ 1. Read the version number from the file header (Int32)
10
+ 2. Version 7+ is always ASA
11
+ 3. For versions 1-6, check for a GUID at bytes 8-24:
12
+ - All zeros = ASE
13
+ - Non-zero = ASA (uses GUIDs for object identification)
14
+
15
+ World saves (.ark files) have different headers:
16
+ - ASE: Int16 version at offset 0 (typically 5-7)
17
+ - ASA: SQLite database format
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import typing as t
23
+ from enum import Enum
24
+ from pathlib import Path
25
+
26
+
27
+ class ArkFileFormat(Enum):
28
+ """
29
+ ARK save file format types.
30
+
31
+ ASE (ARK: Survival Evolved):
32
+ - Save format versions 5-6
33
+ - Uses floats for vectors
34
+ - Object references by index
35
+
36
+ ASA (ARK: Survival Ascended):
37
+ - Save format version 7+
38
+ - Uses doubles for vectors
39
+ - Object references by GUID
40
+ - World saves use SQLite database format
41
+ """
42
+
43
+ ASE = "ASE"
44
+ ASA = "ASA"
45
+ UNKNOWN = "UNKNOWN"
46
+
47
+
48
+ class ArkFileType(Enum):
49
+ """
50
+ ARK save file types based on extension and content.
51
+ """
52
+
53
+ PROFILE = "profile" # .arkprofile
54
+ TRIBE = "tribe" # .arktribe
55
+ CLOUD_INVENTORY = "cloud_inventory" # No extension (obelisk data)
56
+ WORLD_SAVE = "world_save" # .ark
57
+ UNKNOWN = "unknown"
58
+
59
+
60
+ def detect_file_type(source: bytes | str | Path) -> ArkFileType:
61
+ """
62
+ Detect the type of ARK save file based on extension.
63
+
64
+ Args:
65
+ source: File path string, Path object, or bytes (for bytes, returns UNKNOWN).
66
+
67
+ Returns:
68
+ The detected file type.
69
+
70
+ Example:
71
+ >>> detect_file_type("player.arkprofile")
72
+ ArkFileType.PROFILE
73
+ """
74
+ if isinstance(source, bytes):
75
+ return ArkFileType.UNKNOWN
76
+
77
+ path = Path(source)
78
+ suffix = path.suffix.lower()
79
+
80
+ if suffix == ".arkprofile":
81
+ return ArkFileType.PROFILE
82
+ elif suffix == ".arktribe":
83
+ return ArkFileType.TRIBE
84
+ elif suffix == ".ark":
85
+ return ArkFileType.WORLD_SAVE
86
+ elif suffix == "":
87
+ # No extension - likely cloud inventory / obelisk data
88
+ return ArkFileType.CLOUD_INVENTORY
89
+
90
+ return ArkFileType.UNKNOWN
91
+
92
+
93
+ def detect_format(source: bytes | str | Path) -> ArkFileFormat:
94
+ """
95
+ Detect the format of an ARK save file.
96
+
97
+ Automatically determines whether a file uses ASE or ASA format
98
+ by examining the file header. Works for:
99
+ - .arkprofile (player profiles)
100
+ - .arktribe (tribe data)
101
+ - Cloud/obelisk data (no extension)
102
+ - .ark world saves
103
+
104
+ Args:
105
+ source: Raw bytes, file path string, or Path object.
106
+
107
+ Returns:
108
+ ArkFileFormat.ASE, ArkFileFormat.ASA, or ArkFileFormat.UNKNOWN.
109
+
110
+ Example:
111
+ >>> format = detect_format("examples/ase/map_save/1446520645.arktribe")
112
+ >>> print(format)
113
+ ArkFileFormat.ASE
114
+ """
115
+ # Load data if given a path
116
+ path: Path | None = None
117
+ if isinstance(source, (str, Path)):
118
+ path = Path(source)
119
+ if not path.exists() or path.stat().st_size == 0:
120
+ return ArkFileFormat.UNKNOWN
121
+ data = path.read_bytes()
122
+ else:
123
+ data = source
124
+
125
+ # Need at least some bytes to detect
126
+ if len(data) < 24:
127
+ return ArkFileFormat.UNKNOWN
128
+
129
+ # Check for SQLite header (ASA world saves)
130
+ if data[:16] == b"SQLite format 3\x00":
131
+ return ArkFileFormat.ASA
132
+
133
+ # Check if this is a world save by file extension
134
+ is_world_save = path is not None and path.suffix.lower() == ".ark"
135
+
136
+ if is_world_save:
137
+ # World saves use Int16 version at offset 0
138
+ int16_version = int.from_bytes(data[0:2], "little", signed=True)
139
+ # ASE world saves have versions 5-12
140
+ if 5 <= int16_version <= 12:
141
+ return ArkFileFormat.ASE
142
+ return ArkFileFormat.UNKNOWN
143
+
144
+ # For profiles/tribes/cloud data, version is Int32
145
+ version = int.from_bytes(data[0:4], "little", signed=True)
146
+
147
+ # Version 7+ is ASA for profiles/tribes/cloud
148
+ if version >= 7:
149
+ return ArkFileFormat.ASA
150
+
151
+ # For versions 1-6, check for GUID at bytes 8-24
152
+ # ASE files have all zeros here; ASA files have a non-zero GUID
153
+ if 1 <= version <= 6:
154
+ guid_bytes = data[8:24]
155
+ has_guid = any(b != 0 for b in guid_bytes)
156
+ return ArkFileFormat.ASA if has_guid else ArkFileFormat.ASE
157
+
158
+ return ArkFileFormat.UNKNOWN
159
+
160
+
161
+ def get_save_version(source: bytes | str | Path) -> int:
162
+ """
163
+ Read the save version number from a file header.
164
+
165
+ For profiles/tribes/cloud data, this reads an Int32.
166
+ For world saves, this reads an Int16.
167
+
168
+ Args:
169
+ source: Raw bytes, file path string, or Path object.
170
+
171
+ Returns:
172
+ The version number, or -1 if the file is invalid.
173
+ """
174
+ if isinstance(source, (str, Path)):
175
+ path = Path(source)
176
+ if not path.exists() or path.stat().st_size == 0:
177
+ return -1
178
+ data = path.read_bytes()
179
+ else:
180
+ data = source
181
+
182
+ if len(data) < 4:
183
+ return -1
184
+
185
+ # Check for SQLite (ASA world save)
186
+ if data[:16] == b"SQLite format 3\x00":
187
+ return -1 # SQLite doesn't have a simple version
188
+
189
+ # Check if it looks like a world save (Int16 version 5-9)
190
+ int16_version = int.from_bytes(data[0:2], "little", signed=True)
191
+ if 5 <= int16_version <= 9:
192
+ return int16_version
193
+
194
+ # Otherwise read as Int32
195
+ return int.from_bytes(data[0:4], "little", signed=True)