arkparser 0.4.4__tar.gz → 0.4.6__tar.gz

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 (62) hide show
  1. {arkparser-0.4.4 → arkparser-0.4.6}/PKG-INFO +7 -6
  2. {arkparser-0.4.4 → arkparser-0.4.6}/README.md +6 -5
  3. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/__init__.py +1 -1
  4. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/common/binary_reader.py +0 -1
  5. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/common/map_config.py +0 -1
  6. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/common/version_detection.py +0 -1
  7. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/data_models.py +19 -3
  8. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/export.py +49 -9
  9. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/files/profile.py +19 -0
  10. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/files/world_save.py +83 -34
  11. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/properties/compound.py +41 -10
  12. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser.egg-info/PKG-INFO +7 -6
  13. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser.egg-info/SOURCES.txt +2 -0
  14. {arkparser-0.4.4 → arkparser-0.4.6}/pyproject.toml +1 -1
  15. arkparser-0.4.6/tests/test_asa_name_table.py +51 -0
  16. arkparser-0.4.6/tests/test_ase_cluster_drift.py +51 -0
  17. {arkparser-0.4.4 → arkparser-0.4.6}/tests/test_cloud_inventory.py +0 -1
  18. {arkparser-0.4.4 → arkparser-0.4.6}/tests/test_profile.py +0 -1
  19. arkparser-0.4.6/tests/test_review_fixes.py +270 -0
  20. {arkparser-0.4.4 → arkparser-0.4.6}/tests/test_tribe.py +0 -1
  21. {arkparser-0.4.4 → arkparser-0.4.6}/tests/test_world_save.py +1 -1
  22. arkparser-0.4.4/tests/test_review_fixes.py +0 -138
  23. {arkparser-0.4.4 → arkparser-0.4.6}/LICENSE +0 -0
  24. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/common/__init__.py +0 -0
  25. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/common/exceptions.py +0 -0
  26. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/common/normalization.py +0 -0
  27. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/common/types.py +0 -0
  28. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/files/__init__.py +0 -0
  29. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/files/base.py +0 -0
  30. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/files/cloud_inventory.py +0 -0
  31. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/files/tribe.py +0 -0
  32. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/game_objects/__init__.py +0 -0
  33. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/game_objects/container.py +0 -0
  34. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/game_objects/game_object.py +0 -0
  35. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/game_objects/location.py +0 -0
  36. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/properties/__init__.py +0 -0
  37. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/properties/base.py +0 -0
  38. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/properties/byte_property.py +0 -0
  39. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/properties/primitives.py +0 -0
  40. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/properties/registry.py +0 -0
  41. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/structs/__init__.py +0 -0
  42. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/structs/base.py +0 -0
  43. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/structs/colors.py +0 -0
  44. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/structs/misc.py +0 -0
  45. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/structs/property_list.py +0 -0
  46. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/structs/registry.py +0 -0
  47. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser/structs/vectors.py +0 -0
  48. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser.egg-info/dependency_links.txt +0 -0
  49. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser.egg-info/requires.txt +0 -0
  50. {arkparser-0.4.4 → arkparser-0.4.6}/arkparser.egg-info/top_level.txt +0 -0
  51. {arkparser-0.4.4 → arkparser-0.4.6}/setup.cfg +0 -0
  52. {arkparser-0.4.4 → arkparser-0.4.6}/tests/test_asa_header_position.py +0 -0
  53. {arkparser-0.4.4 → arkparser-0.4.6}/tests/test_binary_reader.py +0 -0
  54. {arkparser-0.4.4 → arkparser-0.4.6}/tests/test_binary_reader_layouts.py +0 -0
  55. {arkparser-0.4.4 → arkparser-0.4.6}/tests/test_cloud_export.py +0 -0
  56. {arkparser-0.4.4 → arkparser-0.4.6}/tests/test_cryopod_export.py +0 -0
  57. {arkparser-0.4.4 → arkparser-0.4.6}/tests/test_current_stats.py +0 -0
  58. {arkparser-0.4.4 → arkparser-0.4.6}/tests/test_data_models.py +0 -0
  59. {arkparser-0.4.4 → arkparser-0.4.6}/tests/test_export.py +0 -0
  60. {arkparser-0.4.4 → arkparser-0.4.6}/tests/test_game_objects.py +0 -0
  61. {arkparser-0.4.4 → arkparser-0.4.6}/tests/test_v13_property_layouts.py +0 -0
  62. {arkparser-0.4.4 → arkparser-0.4.6}/tests/test_version_detection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkparser
3
- Version: 0.4.4
3
+ Version: 0.4.6
4
4
  Summary: Pure Python parser for ARK: Survival Evolved and ARK: Survival Ascended save files
5
5
  Author: Vertyco
6
6
  License-Expression: MIT
@@ -212,14 +212,14 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
212
212
  | `mut-f`, `mut-m` | legacy | **Ancestor-line totals.** `RandomMutationsFemale` and `RandomMutationsMale`, single integers counting the total number of mutations that occurred down the maternal and paternal ancestry lines respectively. These are *not* per-stat, they share the `-m` token with the per-stat mutation block below but mean a different thing. Kept under the legacy names for ASVExport parity. |
213
213
  | `cryo` | legacy | `True` for creatures embedded inside cryopod / soultrap / vivarium / dinoball items in the world save, `False` for actor-in-world tames. `export_tamed` walks `WorldSave.iter_cryopod_creatures()` and emits one ASV_Tamed record per embedded creature in addition to the actor-in-world tames; on busy PvE servers cryopodded tames are the majority of the roster (e.g. 10,277 of 11,054 on a live Ragnarok_WP). Cluster-uploaded tames also surface here (via `export_cluster_uploads`) with `cryo=True`. |
214
214
  | `ccc` | legacy | `"{x} {y} {z}"` from `LocationData` |
215
- | `dinoid` | legacy | string form of `id` |
215
+ | `dinoid` | legacy | string form of the dino id. **ASA**: decimal of the combined 64-bit `id`. **ASE**: the two halves concatenated as signed-int32 decimals (`str(DinoID1) + str(DinoID2)`), matching `ASVExport`. |
216
216
  | `isMating` | legacy | `bEnableTamedMating` |
217
217
  | `isNeutered` | legacy | `bNeutered` |
218
218
  | `isClone` | legacy | `bIsClone` or `bIsCloneDino` |
219
219
  | `tamedServer` | legacy | `TamedOnServerName` |
220
220
  | `uploadedServer` | legacy | `UploadedFromServerName` |
221
- | `maturation` | legacy | `str(int(BabyAge * 100))` (string for legacy parity) |
222
- | `traits` | legacy | `CreatureTraits` (full list of mutation trait class names) |
221
+ | `maturation` | legacy | `str(int(BabyAge * 100))` — integer maturation percent (a baby with no `BabyAge` is `0`, a newborn). Note: legacy emits the full float string (e.g. `"7.5131035"`); arkparser truncates to the integer percent. Semantically equal and the downstream consumer coerces to `int` either way. |
222
+ | `traits` | legacy | `CreatureTraits` as a list of `{"trait": <class>}` objects (matches `ASVExport`'s shape, not a flat string list) |
223
223
  | `inventory` | legacy | items from `MyInventoryComponent.InventoryItems`. Each entry carries `itemId`, `qty`, `blueprint`, plus a full snake_case property dump flattened in at the top level (`id`, `rating`, `durability`, `quality`, `damage`, `armor`, `durability_max`, `hypo`, `hyper`, `clip_size`, `weight`, `crafter`, `crafter_tribe`, `skill_bonus`, `loaded_ammo`, `spoils_at`, `spoiled_at`, `c0`..`c5`, `egg_*`, etc). `item_stat_values` is unpacked into the universal 8-slot ARK map (slot 0 `gen_quality`, 1 `armor`, 2 `durability_max`, 3 `damage`, 4 `clip_size`, 5 `hypo`, 6 `weight`, 7 `hyperthermal_insulation`); raw uint16s scaled by the per-blueprint multiplier (which lives in the UE blueprint, not the save). Default / unset values are filtered (no `craft_queue=0`, `skin=-1`, `color_pre_skin=[0]*6`, NaN spoil timers, etc). When the item is a **cryopod / soultrap / vivarium / dinoball** with an embedded creature, the entry is enriched with `dino_id` (combined 64-bit id matching the corresponding `ASV_Tamed` record), `dino_creature` (species / class name), and `dino_name` (`TamedName` if set). Cryopods stored in containers (cryofridges, vaults, dedicated storage) get the same enrichment. |
224
224
  | `father_id`, `mother_id` | added | combined dino id from the first `DinoAncestors` entry (`null` when missing) |
225
225
  | `father_name`, `mother_name` | added | name strings from the first `DinoAncestors` entry |
@@ -256,7 +256,7 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
256
256
 
257
257
  | Field | Origin | Source / formula |
258
258
  |---|---|---|
259
- | `id`, `dinoid` | legacy | `(DinoID1 << 32) \| DinoID2` and its string form |
259
+ | `id`, `dinoid` | legacy | `id` = `(DinoID1 << 32) \| DinoID2`. `dinoid` = its string form on **ASA**, or `str(DinoID1) + str(DinoID2)` (signed int32) on **ASE**, matching `ASVExport`. |
260
260
  | `creature` | legacy | `GameObject.class_name` |
261
261
  | `sex` | legacy | `"Female"` if `bIsFemale` else `"Male"` |
262
262
  | `lvl` | legacy | `BaseCharacterLevel` (status) |
@@ -302,7 +302,8 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
302
302
  | `torp`, `temp` | added | same source, indices legacy never emitted |
303
303
  | `active` | legacy | last-active datetime; currently `null` (placeholder for parity) |
304
304
  | `ccc` | legacy | `"0 0 0"` (no in-world position from profile/cluster source) |
305
- | `achievements`, `inventory`, `netAddress` | legacy | reserved arrays/string (currently empty for parity) |
305
+ | `achievements` | legacy | reserved array (currently empty for parity) |
306
+ | `netAddress` | legacy (now populated) | Last client IP ARK persisted (`SavedNetworkAddress` in profile `MyData`). Legacy ASVExport reads the same field (ContentPlayer.cs:157 ASE / :341 ASA). `""` when the profile lacks it (e.g. never-played placeholders). ASA stores a clean IPv4/IPv6 string; some ASE saves store an engine-truncated value (e.g. `"[2001"`) reproduced verbatim, matching legacy. |
306
307
  | `steamid`, `dataFile` | legacy | platform net id and `{steamid}.arkprofile` filename |
307
308
  | `active` | legacy (now populated) | ISO 8601 datetime of last login, converted from profile `LastLoginTime` or in-world pawn `SavedLastTimeHadController`. Legacy schema reserved the field but the old C# exporter only filled it for in-world pawns; the parser fills it for profiles too. `null` when neither source is present. |
308
309
  | `lat`, `lon`, `ccc` | legacy (now populated) | In-world position when the record was built from a `PlayerPawn` GameObject. Records sourced from profile / cluster files keep the legacy `0` / `"0 0 0"` placeholders since profiles carry no world position. |
@@ -178,14 +178,14 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
178
178
  | `mut-f`, `mut-m` | legacy | **Ancestor-line totals.** `RandomMutationsFemale` and `RandomMutationsMale`, single integers counting the total number of mutations that occurred down the maternal and paternal ancestry lines respectively. These are *not* per-stat, they share the `-m` token with the per-stat mutation block below but mean a different thing. Kept under the legacy names for ASVExport parity. |
179
179
  | `cryo` | legacy | `True` for creatures embedded inside cryopod / soultrap / vivarium / dinoball items in the world save, `False` for actor-in-world tames. `export_tamed` walks `WorldSave.iter_cryopod_creatures()` and emits one ASV_Tamed record per embedded creature in addition to the actor-in-world tames; on busy PvE servers cryopodded tames are the majority of the roster (e.g. 10,277 of 11,054 on a live Ragnarok_WP). Cluster-uploaded tames also surface here (via `export_cluster_uploads`) with `cryo=True`. |
180
180
  | `ccc` | legacy | `"{x} {y} {z}"` from `LocationData` |
181
- | `dinoid` | legacy | string form of `id` |
181
+ | `dinoid` | legacy | string form of the dino id. **ASA**: decimal of the combined 64-bit `id`. **ASE**: the two halves concatenated as signed-int32 decimals (`str(DinoID1) + str(DinoID2)`), matching `ASVExport`. |
182
182
  | `isMating` | legacy | `bEnableTamedMating` |
183
183
  | `isNeutered` | legacy | `bNeutered` |
184
184
  | `isClone` | legacy | `bIsClone` or `bIsCloneDino` |
185
185
  | `tamedServer` | legacy | `TamedOnServerName` |
186
186
  | `uploadedServer` | legacy | `UploadedFromServerName` |
187
- | `maturation` | legacy | `str(int(BabyAge * 100))` (string for legacy parity) |
188
- | `traits` | legacy | `CreatureTraits` (full list of mutation trait class names) |
187
+ | `maturation` | legacy | `str(int(BabyAge * 100))` — integer maturation percent (a baby with no `BabyAge` is `0`, a newborn). Note: legacy emits the full float string (e.g. `"7.5131035"`); arkparser truncates to the integer percent. Semantically equal and the downstream consumer coerces to `int` either way. |
188
+ | `traits` | legacy | `CreatureTraits` as a list of `{"trait": <class>}` objects (matches `ASVExport`'s shape, not a flat string list) |
189
189
  | `inventory` | legacy | items from `MyInventoryComponent.InventoryItems`. Each entry carries `itemId`, `qty`, `blueprint`, plus a full snake_case property dump flattened in at the top level (`id`, `rating`, `durability`, `quality`, `damage`, `armor`, `durability_max`, `hypo`, `hyper`, `clip_size`, `weight`, `crafter`, `crafter_tribe`, `skill_bonus`, `loaded_ammo`, `spoils_at`, `spoiled_at`, `c0`..`c5`, `egg_*`, etc). `item_stat_values` is unpacked into the universal 8-slot ARK map (slot 0 `gen_quality`, 1 `armor`, 2 `durability_max`, 3 `damage`, 4 `clip_size`, 5 `hypo`, 6 `weight`, 7 `hyperthermal_insulation`); raw uint16s scaled by the per-blueprint multiplier (which lives in the UE blueprint, not the save). Default / unset values are filtered (no `craft_queue=0`, `skin=-1`, `color_pre_skin=[0]*6`, NaN spoil timers, etc). When the item is a **cryopod / soultrap / vivarium / dinoball** with an embedded creature, the entry is enriched with `dino_id` (combined 64-bit id matching the corresponding `ASV_Tamed` record), `dino_creature` (species / class name), and `dino_name` (`TamedName` if set). Cryopods stored in containers (cryofridges, vaults, dedicated storage) get the same enrichment. |
190
190
  | `father_id`, `mother_id` | added | combined dino id from the first `DinoAncestors` entry (`null` when missing) |
191
191
  | `father_name`, `mother_name` | added | name strings from the first `DinoAncestors` entry |
@@ -222,7 +222,7 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
222
222
 
223
223
  | Field | Origin | Source / formula |
224
224
  |---|---|---|
225
- | `id`, `dinoid` | legacy | `(DinoID1 << 32) \| DinoID2` and its string form |
225
+ | `id`, `dinoid` | legacy | `id` = `(DinoID1 << 32) \| DinoID2`. `dinoid` = its string form on **ASA**, or `str(DinoID1) + str(DinoID2)` (signed int32) on **ASE**, matching `ASVExport`. |
226
226
  | `creature` | legacy | `GameObject.class_name` |
227
227
  | `sex` | legacy | `"Female"` if `bIsFemale` else `"Male"` |
228
228
  | `lvl` | legacy | `BaseCharacterLevel` (status) |
@@ -268,7 +268,8 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
268
268
  | `torp`, `temp` | added | same source, indices legacy never emitted |
269
269
  | `active` | legacy | last-active datetime; currently `null` (placeholder for parity) |
270
270
  | `ccc` | legacy | `"0 0 0"` (no in-world position from profile/cluster source) |
271
- | `achievements`, `inventory`, `netAddress` | legacy | reserved arrays/string (currently empty for parity) |
271
+ | `achievements` | legacy | reserved array (currently empty for parity) |
272
+ | `netAddress` | legacy (now populated) | Last client IP ARK persisted (`SavedNetworkAddress` in profile `MyData`). Legacy ASVExport reads the same field (ContentPlayer.cs:157 ASE / :341 ASA). `""` when the profile lacks it (e.g. never-played placeholders). ASA stores a clean IPv4/IPv6 string; some ASE saves store an engine-truncated value (e.g. `"[2001"`) reproduced verbatim, matching legacy. |
272
273
  | `steamid`, `dataFile` | legacy | platform net id and `{steamid}.arkprofile` filename |
273
274
  | `active` | legacy (now populated) | ISO 8601 datetime of last login, converted from profile `LastLoginTime` or in-world pawn `SavedLastTimeHadController`. Legacy schema reserved the field but the old C# exporter only filled it for in-world pawns; the parser fills it for profiles too. `null` when neither source is present. |
274
275
  | `lat`, `lon`, `ccc` | legacy (now populated) | In-world position when the record was built from a `PlayerPawn` GameObject. Records sourced from profile / cluster files keep the legacy `0` / `"0 0 0"` placeholders since profiles carry no world position. |
@@ -95,4 +95,4 @@ __all__ = [
95
95
  "ArkParseError",
96
96
  ]
97
97
 
98
- __version__ = "0.4.4"
98
+ __version__ = "0.4.6"
@@ -22,7 +22,6 @@ Example:
22
22
  from __future__ import annotations
23
23
 
24
24
  import struct
25
- import typing as t
26
25
  from pathlib import Path
27
26
  from uuid import UUID
28
27
 
@@ -11,7 +11,6 @@ Where x, y are Unreal Engine world coordinates.
11
11
  from __future__ import annotations
12
12
 
13
13
  import logging
14
- import typing as t
15
14
  from dataclasses import dataclass
16
15
  from pathlib import Path
17
16
 
@@ -19,7 +19,6 @@ World saves (.ark files) have different headers:
19
19
 
20
20
  from __future__ import annotations
21
21
 
22
- import typing as t
23
22
  from enum import Enum
24
23
  from pathlib import Path
25
24
 
@@ -8,6 +8,7 @@ property data from ARK save files.
8
8
  from __future__ import annotations
9
9
 
10
10
  import logging
11
+ import math
11
12
  import typing as t
12
13
  from dataclasses import dataclass, field
13
14
 
@@ -18,6 +19,21 @@ from .properties.registry import read_properties
18
19
  logger = logging.getLogger(__name__)
19
20
 
20
21
 
22
+ def _finite(value: t.Any, default: float) -> float:
23
+ """Coerce to a finite float; NaN / inf / non-numeric collapse to ``default``.
24
+
25
+ Non-finite floats are invalid JSON tokens that crash strict serializers
26
+ (``json.dumps(allow_nan=False)``, JS ``JSON.parse``, pydantic). Legacy
27
+ ASVExport substitutes ``0.0001`` for a NaN item rating (ContentItem.cs:62);
28
+ other non-finite floats fall back to ``0.0``.
29
+ """
30
+ try:
31
+ result = float(value)
32
+ except (TypeError, ValueError):
33
+ return default
34
+ return result if math.isfinite(result) else default
35
+
36
+
21
37
  @dataclass
22
38
  class DinoStats:
23
39
  """Statistics for a creature."""
@@ -201,7 +217,7 @@ class UploadedCreature:
201
217
  dino_id1=data.get("DinoID1", 0),
202
218
  dino_id2=data.get("DinoID2", 0),
203
219
  level=level,
204
- experience=data.get("DinoExperiencePoints", 0.0),
220
+ experience=_finite(data.get("DinoExperiencePoints", 0.0), 0.0),
205
221
  stats=stats,
206
222
  upload_time=data.get("UploadTime", 0),
207
223
  version=data.get("Version", 0.0),
@@ -735,8 +751,8 @@ class UploadedItem:
735
751
  item_id2=item_id.get("ItemID2", 0) if isinstance(item_id, dict) else 0,
736
752
  quantity=ark_tribute.get("ItemQuantity", 1) or 1,
737
753
  quality_index=ark_tribute.get("ItemQualityIndex", 0),
738
- durability=ark_tribute.get("ItemDurability", 0.0),
739
- rating=ark_tribute.get("ItemRating", 0.0),
754
+ durability=_finite(ark_tribute.get("ItemDurability", 0.0), 0.0),
755
+ rating=_finite(ark_tribute.get("ItemRating", 0.0), 0.0001),
740
756
  slot_index=ark_tribute.get("SlotIndex", 0),
741
757
  is_blueprint=ark_tribute.get("bIsBlueprint", False),
742
758
  is_engram=ark_tribute.get("bIsEngram", False),
@@ -355,6 +355,31 @@ def _combine_dino_id(id1: t.Any, id2: t.Any) -> int:
355
355
  return (a << 32) | (b & 0xFFFFFFFF)
356
356
 
357
357
 
358
+ def _dino_id_str(id1: t.Any, id2: t.Any, is_asa: bool) -> str:
359
+ """Legacy ``dinoid`` string form (engine-dependent).
360
+
361
+ ASA emits the decimal of the combined 64-bit id (legacy
362
+ ``ContentCreature.cs:224`` ``DinoId = Id.ToString()``). ASE concatenates the
363
+ two id halves as *signed* int32 decimals (legacy ``ContentCreature.cs:378``
364
+ ``DinoID1.ToString() + DinoID2.ToString()``, where ``GetPropertyValue<int>``
365
+ reinterprets each stored uint32 as a signed int). The forms differ, so the
366
+ engine must be known; passing the wrong flag re-introduces the divergence.
367
+ """
368
+ assert isinstance(is_asa, bool), "is_asa must be bool"
369
+ a, b = _int(id1), _int(id2)
370
+ if a == 0 and b == 0:
371
+ return "0"
372
+ if is_asa:
373
+ return str((a << 32) | (b & 0xFFFFFFFF))
374
+ a32 = a & 0xFFFFFFFF
375
+ b32 = b & 0xFFFFFFFF
376
+ a32 = a32 - 0x100000000 if a32 & 0x80000000 else a32
377
+ b32 = b32 - 0x100000000 if b32 & 0x80000000 else b32
378
+ result = f"{a32}{b32}"
379
+ assert result, "dinoid string must be non-empty"
380
+ return result
381
+
382
+
358
383
  def _colors(obj: t.Any) -> list[int]:
359
384
  out = [0] * 6
360
385
  getter = getattr(obj, "get_properties_by_name", None)
@@ -1052,7 +1077,10 @@ def _tamed_dict(
1052
1077
  mut_pts = _stat_array(status, "NumberOfMutationsAppliedTamed")
1053
1078
  base_level = _int(_prop(status, "BaseCharacterLevel"), default=1) or 1
1054
1079
  extra_level = _int(_prop(status, "ExtraCharacterLevel"))
1055
- dino_id = _combine_dino_id(_prop(obj, "DinoID1"), _prop(obj, "DinoID2"))
1080
+ is_asa = bool(getattr(save, "is_asa", False))
1081
+ raw_id1 = _prop(obj, "DinoID1")
1082
+ raw_id2 = _prop(obj, "DinoID2")
1083
+ dino_id = _combine_dino_id(raw_id1, raw_id2)
1056
1084
  # Legacy negates the id of stored (cryo/vivarium) creatures so they don't
1057
1085
  # collide with live tames (ContentTamedCreature.cs:122-126/228-232). The
1058
1086
  # dinoid field stays positive (C# sets DinoId = Id.ToString() before negating).
@@ -1073,7 +1101,10 @@ def _tamed_dict(
1073
1101
  is_female = bool(_prop(obj, "bIsFemale", default=False))
1074
1102
  targeting_team = _int(_prop(obj, "TargetingTeam"))
1075
1103
  baby = bool(_prop(obj, "bIsBaby", default=False))
1076
- baby_age = _float(_prop(obj, "BabyAge"), default=1.0) if baby else 1.0
1104
+ # A baby with no BabyAge property is a newborn (maturation 0), not an adult.
1105
+ # Legacy reads BabyAge with default 0 (ContentCreature.cs:98). Non-babies
1106
+ # stay at 1.0 -> maturation "100".
1107
+ baby_age = _float(_prop(obj, "BabyAge"), default=0.0) if baby else 1.0
1077
1108
  father_id, father_name = _ancestor_parent(obj, "Male")
1078
1109
  mother_id, mother_name = _ancestor_parent(obj, "Female")
1079
1110
  tribe_name = _str(_prop(obj, "TribeName"))
@@ -1100,7 +1131,7 @@ def _tamed_dict(
1100
1131
  "mut-f": _int(_prop(obj, "RandomMutationsFemale")),
1101
1132
  "mut-m": _int(_prop(obj, "RandomMutationsMale")),
1102
1133
  "cryo": bool(_prop(obj, "IsInCryo", default=False)),
1103
- "dinoid": str(dino_id),
1134
+ "dinoid": _dino_id_str(raw_id1, raw_id2, is_asa),
1104
1135
  "isMating": bool(_prop(obj, "bEnableTamedMating", default=False)),
1105
1136
  "isNeutered": bool(_prop(obj, "bNeutered", default=False)),
1106
1137
  "isClone": bool(_prop(obj, "bIsClone", default=False))
@@ -1109,7 +1140,9 @@ def _tamed_dict(
1109
1140
  "uploadedServer": _str(_prop(obj, "UploadedFromServerName")),
1110
1141
  "maturation": str(int(baby_age * 100)),
1111
1142
  **_flat_stats(mut_pts, "m"),
1112
- "traits": _traits(obj),
1143
+ # Legacy emits tamed traits as a list of objects ([{"trait": <class>}]),
1144
+ # not a flat string list (ContentPack.cs:723-735). Match that shape.
1145
+ "traits": [{"trait": tr} for tr in _traits(obj)],
1113
1146
  "inventory": _inventory_items(obj, lookup),
1114
1147
  "father_id": father_id,
1115
1148
  "mother_id": mother_id,
@@ -1615,12 +1648,15 @@ def _wild_dict(
1615
1648
  obj: t.Any,
1616
1649
  status: t.Any,
1617
1650
  map_config: MapConfig | None,
1651
+ is_asa: bool = False,
1618
1652
  ) -> dict[str, t.Any]:
1619
1653
  base_pts = _stat_array(status, "NumberOfLevelUpPointsApplied")
1620
1654
  base_level = _int(_prop(status, "BaseCharacterLevel"), default=1) or 1
1621
1655
  colors = _colors(obj)
1622
1656
  is_female = bool(_prop(obj, "bIsFemale", default=False))
1623
- dino_id = _combine_dino_id(_prop(obj, "DinoID1"), _prop(obj, "DinoID2"))
1657
+ raw_id1 = _prop(obj, "DinoID1")
1658
+ raw_id2 = _prop(obj, "DinoID2")
1659
+ dino_id = _combine_dino_id(raw_id1, raw_id2)
1624
1660
  traits = _traits(obj)
1625
1661
  class_name = getattr(obj, "class_name", "") or ""
1626
1662
  tameable = _is_tameable(class_name, obj)
@@ -1632,7 +1668,7 @@ def _wild_dict(
1632
1668
  "lvl": base_level,
1633
1669
  **_flat_stats(base_pts),
1634
1670
  **{f"c{i}": colors[i] for i in range(6)},
1635
- "dinoid": str(dino_id),
1671
+ "dinoid": _dino_id_str(raw_id1, raw_id2, is_asa),
1636
1672
  "tameable": tameable,
1637
1673
  # Legacy emits the first trait as a singular ``trait``; the full
1638
1674
  # CreatureTraits list is exposed alongside as ``traits``.
@@ -1647,8 +1683,9 @@ def _wild_dict(
1647
1683
 
1648
1684
  def export_wild(save: t.Any, map_config: MapConfig | None = None) -> list[dict[str, t.Any]]:
1649
1685
  objects = _world_objects(save, "get_wild_creatures", "wild_objects")
1686
+ is_asa = bool(getattr(save, "is_asa", False))
1650
1687
  lookup = _save_lookup(save)
1651
- return [_wild_dict(obj, _status_for(obj, lookup), map_config) for obj in objects]
1688
+ return [_wild_dict(obj, _status_for(obj, lookup), map_config, is_asa) for obj in objects]
1652
1689
 
1653
1690
 
1654
1691
  def _player_from_profile(
@@ -1684,7 +1721,7 @@ def _player_from_profile(
1684
1721
  "ccc": "0 0 0",
1685
1722
  "achievements": [],
1686
1723
  "inventory": [],
1687
- "netAddress": "",
1724
+ "netAddress": _str(profile.last_net_address),
1688
1725
  "engram_points": profile.total_engram_points,
1689
1726
  "experience": _int(profile.experience),
1690
1727
  "current_stats": _current_stats_dict(status),
@@ -2210,7 +2247,10 @@ def _structure_dict(
2210
2247
  "struct": class_name,
2211
2248
  "name": box_name,
2212
2249
  "locked": locked,
2213
- "created": _structure_created(obj, save),
2250
+ # Legacy CreatedDateTime is DateTime? -> a null interpolates to "" in
2251
+ # the JSON, never JSON null. Match that (avoids a null-vs-string flip
2252
+ # for structures whose creation time can't be resolved).
2253
+ "created": _structure_created(obj, save) or "",
2214
2254
  "inventory": _inventory_items(obj, lookup),
2215
2255
  "decay_reset": bool(_prop(obj, "bHasResetDecayTime", default=False)),
2216
2256
  "last_ally_in_range": (
@@ -207,6 +207,25 @@ class Profile(ArkFile):
207
207
  except (TypeError, ValueError):
208
208
  return None
209
209
 
210
+ @property
211
+ def last_net_address(self) -> str | None:
212
+ """Last client IP / network address ARK persisted for this player.
213
+
214
+ Read from ``SavedNetworkAddress`` on the profile's ``MyData`` struct
215
+ (same level as ``LastLoginTime``). Legacy ASVExport reads this exact
216
+ field (ContentPlayer.cs:157 ASE / :341 ASA) and emits it as the
217
+ ``netAddress`` player key. Returns ``None`` when the field is absent
218
+ (e.g. empty placeholder profiles that were never played).
219
+
220
+ ASA stores a clean IPv4/IPv6 string; some ASE saves store an
221
+ engine-truncated value (e.g. ``"[2001"``) which is reproduced
222
+ verbatim - matching what legacy ASVExport emits for the same save.
223
+ """
224
+ val = self._player_data.get("SavedNetworkAddress")
225
+ if val is None:
226
+ return None
227
+ return str(val)
228
+
210
229
  @property
211
230
  def total_engram_points(self) -> int:
212
231
  """Get total engram points spent."""
@@ -31,11 +31,11 @@ from pathlib import Path
31
31
  from uuid import UUID
32
32
 
33
33
  from ..common.binary_reader import BinaryReader
34
- from ..common.exceptions import ArkParseError
34
+ from ..common.exceptions import ArkParseError, CorruptDataError
35
35
  from ..common.normalization import normalize_indexed_data, normalize_indexed_list
36
36
  from ..data_models import CryopodCreature
37
37
  from ..game_objects.container import GameObjectContainer
38
- from ..game_objects.game_object import GameObject
38
+ from ..game_objects.game_object import MAX_OBJECT_COUNT, GameObject
39
39
  from ..game_objects.location import LocationData
40
40
  from ..properties.registry import read_properties
41
41
 
@@ -44,6 +44,28 @@ logger = logging.getLogger(__name__)
44
44
  # ASE GUIDs are always all-zero; precomputed sentinel skips UUID construction.
45
45
  _ZERO_GUID = b"\x00" * 16
46
46
 
47
+ # Upper bound on ASA name-table entries (largest observed real table ~4.6k).
48
+ # A count above this means the read is misaligned (a garbage int32 length),
49
+ # so we fail loudly instead of looping over ~billions of phantom entries.
50
+ _MAX_ASA_NAME_TABLE = 1_000_000
51
+
52
+
53
+ def _checked_count(reader: BinaryReader, label: str, maximum: int = MAX_OBJECT_COUNT) -> int:
54
+ """Read a length-prefix int32 and reject corrupt/implausible values.
55
+
56
+ Power-of-10 rule 2 (fixed loop bounds): every count that drives a read
57
+ loop must be provably bounded. A negative or absurd count means a
58
+ misaligned/corrupt header; raise ``CorruptDataError`` (NOT ``assert`` -
59
+ asserts vanish under ``python -O``) rather than looping over billions of
60
+ phantom entries (OOM/hang).
61
+ """
62
+ if reader.remaining < 4:
63
+ raise CorruptDataError(f"{label}: truncated before count int32")
64
+ count = reader.read_int32()
65
+ if not 0 <= count <= maximum:
66
+ raise CorruptDataError(f"{label}: implausible count {count} (corrupt/misaligned read)")
67
+ return count
68
+
47
69
 
48
70
  @dataclass
49
71
  class EmbeddedData:
@@ -535,25 +557,25 @@ class WorldSave:
535
557
  saved = reader.position
536
558
  reader.position = self._name_table_offset
537
559
 
538
- count = reader.read_int32()
560
+ count = _checked_count(reader, "ASE name table")
539
561
  self.name_table = [reader.read_string() for _ in range(count)]
540
562
 
541
563
  reader.position = saved
542
564
 
543
565
  def _read_ase_data_files(self, reader: BinaryReader) -> None:
544
- count = reader.read_int32()
566
+ count = _checked_count(reader, "ASE data files")
545
567
  self.data_files = [reader.read_string() for _ in range(count)]
546
568
 
547
569
  def _read_ase_embedded_data(self, reader: BinaryReader) -> None:
548
- count = reader.read_int32()
570
+ count = _checked_count(reader, "ASE embedded data")
549
571
  self.embedded_data = [EmbeddedData.read(reader) for _ in range(count)]
550
572
 
551
573
  def _read_ase_data_files_object_map(self, reader: BinaryReader) -> None:
552
- count = reader.read_int32()
574
+ count = _checked_count(reader, "ASE data-files object map")
553
575
  self.data_files_object_map = {}
554
576
  for _ in range(count):
555
577
  level = reader.read_int32()
556
- name_count = reader.read_int32()
578
+ name_count = _checked_count(reader, "ASE data-files object names")
557
579
  names = [reader.read_string() for _ in range(name_count)]
558
580
  self.data_files_object_map.setdefault(level, []).append(names)
559
581
 
@@ -608,7 +630,7 @@ class WorldSave:
608
630
  return obj
609
631
 
610
632
  def _read_ase_objects(self, reader: BinaryReader) -> None:
611
- count = reader.read_int32()
633
+ count = _checked_count(reader, "ASE objects")
612
634
  self.objects = [self._read_ase_object_header(reader, i) for i in range(count)]
613
635
 
614
636
  def _read_ase_object_properties(self, reader: BinaryReader) -> None:
@@ -683,50 +705,77 @@ class WorldSave:
683
705
  reader = BinaryReader.from_bytes(row[0])
684
706
 
685
707
  self.version = reader.read_int16()
686
- _legacy_offset = reader.read_int32()
687
- # v14+ adds two int32 fields (unk1, actual_offset) between
708
+ legacy_offset = reader.read_int32()
709
+ # v14+ adds two int32 fields (unk1, name_table_offset) between
688
710
  # legacy_offset and game_time; v13 saves jump straight to game_time.
689
711
  # Without this version gate we mis-align the read by 8 bytes and the
690
- # data_files / name_table loops walk into garbage.
712
+ # data_files loop walks into garbage.
713
+ name_table_offset = legacy_offset # v13: table follows the parts section
691
714
  if self.version >= 14:
692
715
  _unknown1 = reader.read_int32()
693
- _actual_offset = reader.read_int32()
716
+ name_table_offset = reader.read_int32() # absolute offset of name table
694
717
  self.game_time = reader.read_double()
695
718
  _unknown2 = reader.read_int32()
696
719
 
697
- # Data files
698
- count = reader.read_int32()
720
+ # Data files (immediately follow the header; populate self.data_files).
721
+ count = _checked_count(reader, "ASA data files")
699
722
  self.data_files = []
700
723
  for _ in range(count):
701
724
  self.data_files.append(reader.read_string())
702
- _term = reader.read_int32()
703
-
704
- _pad1 = reader.read_int32()
705
- _pad2 = reader.read_int32()
725
+ _term = reader.read_int32() # per-entry terminator, always -1
706
726
 
707
- # Name table (dict keyed by hash for ASA).
727
+ # Name table (dict keyed by FName hash for ASA).
708
728
  #
709
- # Most ASA saves serialize the table as a flat ``(hash, strlen,
710
- # string)`` triple per entry. Some maps (observed on Ragnarok,
711
- # Scorched Earth and TheIsland on busy servers) prepend
712
- # user-placed-structure name entries that carry an extra 4-byte
713
- # trailer after the string; those entries always have
714
- # ``hash == 1`` as a sentinel. Detect that sentinel per-entry and
715
- # consume the trailer so the rest of the table parses normally.
716
- # Without this, parsing the next entry's hash misreads the trailer
717
- # as a string length and explodes with EndOfDataError on a ~500 MB
718
- # phantom string.
719
- name_count = reader.read_int32()
729
+ # v14+: the engine stores the table at an explicit absolute offset
730
+ # (name_table_offset = the 2nd int32 after legacy_offset). The bytes
731
+ # between the data-files section and that offset are NOT a fixed pad
732
+ # pair on every map - on busy/modded saves (measured: Ragnarok +26 B,
733
+ # Scorched Earth +31 B past where a sequential read lands) the table
734
+ # would be read truncated, leaving ~half the class refs resolving to
735
+ # __UNKNOWN_CLASS_<hash>__. Seek to the offset like the C# reference
736
+ # (AsaSavegame.readNametable: ``archive.Position = nameTableOffset``).
737
+ # Where the gap is zero (TheIsland, Aberration, Extinction) the seek is
738
+ # a no-op; where it drifts it recovers every leaked class name.
739
+ if self.version >= 14:
740
+ # Explicit raise (not assert) so the bound survives ``python -O``.
741
+ if not 0 <= name_table_offset <= reader.size:
742
+ raise CorruptDataError(
743
+ f"ASA name-table offset {name_table_offset} out of range "
744
+ f"(blob size {reader.size})"
745
+ )
746
+ reader.position = name_table_offset
747
+ self.name_table = self._read_asa_name_table(reader)
748
+ else:
749
+ # v13: no explicit offset field. Retain the historical sequential
750
+ # read (two pad int32s, then the table) - there is no live v13
751
+ # fixture to validate a seek against. The idx==1 sentinel consumes
752
+ # the 4-byte trailer on user-placed-actor entries.
753
+ reader.read_int32() # pad1
754
+ reader.read_int32() # pad2
755
+ self.name_table = self._read_asa_name_table(reader, sentinel=True)
756
+
757
+ def _read_asa_name_table(
758
+ self, reader: BinaryReader, sentinel: bool = False
759
+ ) -> dict[int, str]:
760
+ """Read an ASA name table (FName-hash -> class string) at ``reader``'s position.
761
+
762
+ Preconditions: ``reader`` is positioned at the table's int32 entry
763
+ count. Postconditions: returns ``{hash: trimmed_class_string}`` (the
764
+ substring after the last ``.``) and advances the reader past the table.
765
+ ``sentinel`` consumes the extra 4-byte trailer following
766
+ user-placed-actor entries (``idx == 1``) on the v13 sequential path; it
767
+ is unused on the v14 seek path.
768
+ """
769
+ name_count = _checked_count(reader, "ASA name table", _MAX_ASA_NAME_TABLE)
720
770
  nt: dict[int, str] = {}
721
771
  for _ in range(name_count):
722
772
  idx = reader.read_int32()
723
773
  raw = reader.read_string()
724
774
  nt[idx] = raw.rsplit(".", 1)[-1] if "." in raw else raw
725
- if idx == 1:
726
- # User-placed-actor name sentinel: skip the 4-byte sub-id /
727
- # trailing tag that follows on these entries.
775
+ if sentinel and idx == 1:
776
+ # User-placed-actor sentinel: skip the trailing 4-byte tag.
728
777
  reader.skip(4)
729
- self.name_table = nt
778
+ return nt
730
779
 
731
780
  def _read_asa_actor_locations(self, conn: sqlite3.Connection) -> None:
732
781
  """Parse the ``ActorTransforms`` blob."""
@@ -418,8 +418,17 @@ def _read_array_elements(
418
418
  for _ in range(count):
419
419
  values.append(reader.read_int8())
420
420
  elif array_type == "ByteProperty":
421
- for _ in range(count):
422
- values.append(reader.read_uint8())
421
+ # ASE enum byte-arrays store each element as an 8-byte name-table
422
+ # reference, not a raw uint8. Legacy ArkArrayByteHandler discriminates
423
+ # on size: data_size > count+4 -> name refs, data_size == count+4 ->
424
+ # raw uint8. Mirror it so a name-valued byte array doesn't under-read
425
+ # and drift the cursor into the next property (the cluster-drift class).
426
+ if not is_asa and count > 0 and data_size > count + 4:
427
+ for _ in range(count):
428
+ values.append(read_name(reader, name_table))
429
+ else:
430
+ for _ in range(count):
431
+ values.append(reader.read_uint8())
423
432
  elif array_type == "FloatProperty":
424
433
  for _ in range(count):
425
434
  values.append(reader.read_float())
@@ -540,14 +549,36 @@ def _read_array_elements(
540
549
  if reader.position < array_data_end:
541
550
  reader.skip(array_data_end - reader.position)
542
551
  else:
543
- # ASE struct arrays - read as property-based structs
552
+ # ASE struct arrays. Resolve the element struct type the way legacy
553
+ # does (ArkArrayStruct.Init): the array-name map first, else INFER a
554
+ # native fixed-size type from the body size, where the body is the
555
+ # int32 count prefix (4) plus count * element_size. Without the size
556
+ # inference, native struct arrays addressed only by name (e.g.
557
+ # CustomItemColors = Color[6], data_size 28 = 6*4+4) are misread as
558
+ # property-list structs, the cursor drifts, and every later property
559
+ # in the object is corrupted (EndOfDataError on a garbage length).
560
+ struct_type = struct_registry.get_array_struct_type(array_name)
561
+ # ASE only: infer the native element type from the body size when the
562
+ # array name isn't mapped (legacy ArkArrayStruct.Init). Gated to ASE
563
+ # (not is_asa) so the ASA v6 struct-array path is untouched. The check
564
+ # is exact, and a property-list element is >= 9 bytes (min "None"
565
+ # terminator), so these equalities never match a property-list array.
566
+ if struct_type is None and not is_asa and count > 0:
567
+ if count * 4 + 4 == data_size:
568
+ struct_type = "Color"
569
+ elif count * 12 + 4 == data_size:
570
+ struct_type = "Vector"
571
+ elif count * 16 + 4 == data_size:
572
+ struct_type = "LinearColor"
544
573
  for _ in range(count):
545
- struct = struct_registry.read_struct_for_array(
546
- reader,
547
- array_name,
548
- is_asa,
549
- name_table=name_table,
550
- )
574
+ if struct_type is not None:
575
+ struct = struct_registry.read_struct(
576
+ reader, struct_type, is_asa, name_table=name_table
577
+ )
578
+ else:
579
+ struct = struct_registry.read_struct_for_array(
580
+ reader, array_name, is_asa, name_table=name_table
581
+ )
551
582
  if hasattr(struct, "to_dict"):
552
583
  values.append(struct.to_dict())
553
584
  else:
@@ -1206,7 +1237,7 @@ class MapProperty(Property):
1206
1237
  # Handle value type sub-header
1207
1238
  if value_type == "StructProperty":
1208
1239
  _struct_marker = reader.read_int32() # Usually 1, sometimes 2+
1209
- struct_type_id = reader.read_int32()
1240
+ _struct_type_id = reader.read_int32()
1210
1241
  _struct_type_inst = reader.read_int32()
1211
1242
  _script_marker = reader.read_int32()
1212
1243
  _script_path_id = reader.read_int32()