arkparser 0.3.1__tar.gz → 0.3.3__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 (57) hide show
  1. {arkparser-0.3.1 → arkparser-0.3.3}/PKG-INFO +5 -2
  2. {arkparser-0.3.1 → arkparser-0.3.3}/README.md +4 -1
  3. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/__init__.py +1 -1
  4. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/data_models.py +45 -10
  5. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/export.py +137 -3
  6. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser.egg-info/PKG-INFO +5 -2
  7. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser.egg-info/SOURCES.txt +2 -0
  8. {arkparser-0.3.1 → arkparser-0.3.3}/pyproject.toml +1 -1
  9. arkparser-0.3.3/tests/test_cryopod_export.py +122 -0
  10. arkparser-0.3.3/tests/test_current_stats.py +133 -0
  11. {arkparser-0.3.1 → arkparser-0.3.3}/LICENSE +0 -0
  12. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/common/__init__.py +0 -0
  13. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/common/binary_reader.py +0 -0
  14. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/common/exceptions.py +0 -0
  15. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/common/map_config.py +0 -0
  16. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/common/normalization.py +0 -0
  17. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/common/types.py +0 -0
  18. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/common/version_detection.py +0 -0
  19. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/files/__init__.py +0 -0
  20. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/files/base.py +0 -0
  21. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/files/cloud_inventory.py +0 -0
  22. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/files/profile.py +0 -0
  23. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/files/tribe.py +0 -0
  24. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/files/world_save.py +0 -0
  25. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/game_objects/__init__.py +0 -0
  26. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/game_objects/container.py +0 -0
  27. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/game_objects/game_object.py +0 -0
  28. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/game_objects/location.py +0 -0
  29. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/properties/__init__.py +0 -0
  30. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/properties/base.py +0 -0
  31. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/properties/byte_property.py +0 -0
  32. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/properties/compound.py +0 -0
  33. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/properties/primitives.py +0 -0
  34. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/properties/registry.py +0 -0
  35. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/structs/__init__.py +0 -0
  36. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/structs/base.py +0 -0
  37. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/structs/colors.py +0 -0
  38. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/structs/misc.py +0 -0
  39. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/structs/property_list.py +0 -0
  40. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/structs/registry.py +0 -0
  41. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser/structs/vectors.py +0 -0
  42. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser.egg-info/dependency_links.txt +0 -0
  43. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser.egg-info/requires.txt +0 -0
  44. {arkparser-0.3.1 → arkparser-0.3.3}/arkparser.egg-info/top_level.txt +0 -0
  45. {arkparser-0.3.1 → arkparser-0.3.3}/setup.cfg +0 -0
  46. {arkparser-0.3.1 → arkparser-0.3.3}/tests/test_asa_header_position.py +0 -0
  47. {arkparser-0.3.1 → arkparser-0.3.3}/tests/test_binary_reader.py +0 -0
  48. {arkparser-0.3.1 → arkparser-0.3.3}/tests/test_binary_reader_layouts.py +0 -0
  49. {arkparser-0.3.1 → arkparser-0.3.3}/tests/test_cloud_inventory.py +0 -0
  50. {arkparser-0.3.1 → arkparser-0.3.3}/tests/test_data_models.py +0 -0
  51. {arkparser-0.3.1 → arkparser-0.3.3}/tests/test_export.py +0 -0
  52. {arkparser-0.3.1 → arkparser-0.3.3}/tests/test_game_objects.py +0 -0
  53. {arkparser-0.3.1 → arkparser-0.3.3}/tests/test_profile.py +0 -0
  54. {arkparser-0.3.1 → arkparser-0.3.3}/tests/test_tribe.py +0 -0
  55. {arkparser-0.3.1 → arkparser-0.3.3}/tests/test_v13_property_layouts.py +0 -0
  56. {arkparser-0.3.1 → arkparser-0.3.3}/tests/test_version_detection.py +0 -0
  57. {arkparser-0.3.1 → arkparser-0.3.3}/tests/test_world_save.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkparser
3
- Version: 0.3.1
3
+ Version: 0.3.3
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
@@ -210,7 +210,7 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
210
210
  | `hp-m` .. `fort-m` (all 12) | added | `NumberOfMutationsAppliedTamed[i]` (mutation point counts per stat) |
211
211
  | `c0` .. `c5` | legacy | `ColorSetIndices[i]` |
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
- | `cryo` | legacy | `IsInCryo` |
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
215
  | `dinoid` | legacy | string form of `id` |
216
216
  | `isMating` | legacy | `bEnableTamedMating` |
@@ -250,6 +250,7 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
250
250
  | `last_baby_age_update` | added | ISO 8601 datetime of `LastUpdatedBabyAgeAtTime`. |
251
251
  | `last_gestation_update` | added | ISO 8601 datetime of `LastUpdatedGestationAtTime`. |
252
252
  | `next_cuddle` | added | ISO 8601 datetime of `BabyNextCuddleTime`. |
253
+ | `current_stats` | added | Live in-world stat values from the dino's status component (`CurrentStatusValues[0..11]`) as a `{hp, stam, torp, oxy, food, water, temp, weight, melee, speed, fort, craft}` dict of floats. These are the *current* values (e.g. `hp: 11013.62` = current HP, drops as the dino takes damage). Max values are NOT persisted by ARK — compute downstream from species stat tables + `*-w`/`*-t` points + `imprint` + server multipliers if you need them. `null` when the status component carries no `CurrentStatusValues` entries (e.g. uninitialised baby actor). |
253
254
 
254
255
  #### `ASV_Wild` schema
255
256
 
@@ -267,6 +268,7 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
267
268
  | `trait` | legacy | first entry of `CreatureTraits` (or empty string) |
268
269
  | `traits` | added | full `CreatureTraits` list |
269
270
  | `wild_spawn_region` | added | `OriginalNPCVolumeName` — `NPCZoneVolume` the creature spawned in. |
271
+ | `current_stats` | added | Live in-world stat values from the creature's status component (`CurrentStatusValues[0..11]`) as a `{hp, stam, torp, oxy, food, water, temp, weight, melee, speed, fort, craft}` dict of floats. Max values are NOT in the save (would need species stat tables). `null` when uninitialised. |
270
272
 
271
273
  #### Player data: `.arkprofile` vs in-world pawn
272
274
 
@@ -318,6 +320,7 @@ For the richest output, hand `export_players` **both** — assemble a wrapper fo
318
320
  | `corpse_destruction` | added | ISO 8601 datetime of `CorpseDestructionTime`. |
319
321
  | `chibi_levels` | added | `NumChibiLevelUps` — bonus levels from chibi pets. |
320
322
  | `ascensions_scorched` | added | `NumAscensionsScorched` — ASE ascension counter (legacy `ContentPlayer` parses the ASA ascension block differently; this is the ASE-specific field). |
323
+ | `current_stats` | added | Live in-world stat values for the player from the pawn's `MyCharacterStatusComponent` (`CurrentStatusValues[0..11]`) as a `{hp, stam, torp, oxy, food, water, temp, weight, melee, speed, fort, craft}` dict of floats. For profile-sourced records the parser joins on `PlayerDataID == LinkedPlayerDataID` to find the spawned pawn in the world save. `null` when the player has no in-world pawn (never spawned this server / corpse cleared) or the status component has no values — only currently / recently spawned characters have live stats. Max values are NOT persisted by ARK. |
321
324
 
322
325
  #### `ASV_Tribes` schema
323
326
 
@@ -176,7 +176,7 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
176
176
  | `hp-m` .. `fort-m` (all 12) | added | `NumberOfMutationsAppliedTamed[i]` (mutation point counts per stat) |
177
177
  | `c0` .. `c5` | legacy | `ColorSetIndices[i]` |
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
- | `cryo` | legacy | `IsInCryo` |
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
181
  | `dinoid` | legacy | string form of `id` |
182
182
  | `isMating` | legacy | `bEnableTamedMating` |
@@ -216,6 +216,7 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
216
216
  | `last_baby_age_update` | added | ISO 8601 datetime of `LastUpdatedBabyAgeAtTime`. |
217
217
  | `last_gestation_update` | added | ISO 8601 datetime of `LastUpdatedGestationAtTime`. |
218
218
  | `next_cuddle` | added | ISO 8601 datetime of `BabyNextCuddleTime`. |
219
+ | `current_stats` | added | Live in-world stat values from the dino's status component (`CurrentStatusValues[0..11]`) as a `{hp, stam, torp, oxy, food, water, temp, weight, melee, speed, fort, craft}` dict of floats. These are the *current* values (e.g. `hp: 11013.62` = current HP, drops as the dino takes damage). Max values are NOT persisted by ARK — compute downstream from species stat tables + `*-w`/`*-t` points + `imprint` + server multipliers if you need them. `null` when the status component carries no `CurrentStatusValues` entries (e.g. uninitialised baby actor). |
219
220
 
220
221
  #### `ASV_Wild` schema
221
222
 
@@ -233,6 +234,7 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
233
234
  | `trait` | legacy | first entry of `CreatureTraits` (or empty string) |
234
235
  | `traits` | added | full `CreatureTraits` list |
235
236
  | `wild_spawn_region` | added | `OriginalNPCVolumeName` — `NPCZoneVolume` the creature spawned in. |
237
+ | `current_stats` | added | Live in-world stat values from the creature's status component (`CurrentStatusValues[0..11]`) as a `{hp, stam, torp, oxy, food, water, temp, weight, melee, speed, fort, craft}` dict of floats. Max values are NOT in the save (would need species stat tables). `null` when uninitialised. |
236
238
 
237
239
  #### Player data: `.arkprofile` vs in-world pawn
238
240
 
@@ -284,6 +286,7 @@ For the richest output, hand `export_players` **both** — assemble a wrapper fo
284
286
  | `corpse_destruction` | added | ISO 8601 datetime of `CorpseDestructionTime`. |
285
287
  | `chibi_levels` | added | `NumChibiLevelUps` — bonus levels from chibi pets. |
286
288
  | `ascensions_scorched` | added | `NumAscensionsScorched` — ASE ascension counter (legacy `ContentPlayer` parses the ASA ascension block differently; this is the ASE-specific field). |
289
+ | `current_stats` | added | Live in-world stat values for the player from the pawn's `MyCharacterStatusComponent` (`CurrentStatusValues[0..11]`) as a `{hp, stam, torp, oxy, food, water, temp, weight, melee, speed, fort, craft}` dict of floats. For profile-sourced records the parser joins on `PlayerDataID == LinkedPlayerDataID` to find the spawned pawn in the world save. `null` when the player has no in-world pawn (never spawned this server / corpse cleared) or the status component has no values — only currently / recently spawned characters have live stats. Max values are NOT persisted by ARK. |
287
290
 
288
291
  #### `ASV_Tribes` schema
289
292
 
@@ -91,4 +91,4 @@ __all__ = [
91
91
  "ArkParseError",
92
92
  ]
93
93
 
94
- __version__ = "0.3.1"
94
+ __version__ = "0.3.3"
@@ -547,20 +547,29 @@ class CryopodCreature:
547
547
  ]
548
548
 
549
549
  if len(floats) >= 22:
550
- # Determine offset based on array length
551
- # ASA has 36 floats with offset 11, ASE has 25 floats with offset 12
552
- max_offset = 11 if len(floats) >= 36 else 12
553
-
554
- # Current stats start at 0
555
- for i, stat_name in enumerate(stat_names):
550
+ # ASA cryopod blob: 11 current stats + 11 max stats + extras (36 floats).
551
+ # ASE cryopod blob: 12 current stats + 12 max stats + 1 extra (25 floats).
552
+ # Without the per-format count, the ASA loop walks 12 names against an
553
+ # 11-wide current block, leaking max[0] (which equals current[0] at full
554
+ # HP) into current_stats["CraftingSkill"] and surfacing hp=craft on every
555
+ # cryopod tame in exports.
556
+ if len(floats) >= 36:
557
+ current_count = 11
558
+ max_offset = 11
559
+ else:
560
+ current_count = 12
561
+ max_offset = 12
562
+
563
+ # Current stats: only consume the per-format slot count
564
+ for i in range(min(current_count, len(stat_names))):
556
565
  if i < len(floats):
557
- cryo.current_stats[stat_name] = floats[i]
566
+ cryo.current_stats[stat_names[i]] = floats[i]
558
567
 
559
- # Max stats start at offset
560
- for i, stat_name in enumerate(stat_names):
568
+ # Max stats: same count, starting at offset
569
+ for i in range(min(current_count, len(stat_names))):
561
570
  max_idx = i + max_offset
562
571
  if max_idx < len(floats):
563
- cryo.max_stats[stat_name] = floats[max_idx]
572
+ cryo.max_stats[stat_names[i]] = floats[max_idx]
564
573
 
565
574
  # Parse soft class for blueprint reference
566
575
  soft_classes = normalize_indexed_list(custom_data.get("CustomDataSoftClasses"))
@@ -569,6 +578,32 @@ class CryopodCreature:
569
578
  if isinstance(first_class, dict):
570
579
  cryo.class_name = first_class.get("name", cryo.class_name)
571
580
 
581
+ # Populate raw creature_props / status_props so the export
582
+ # pipeline's _SyntheticGameObject adapter (which calls
583
+ # ``get_property_value``) sees the fields it expects. ARK
584
+ # cryopod CustomItemDatas blobs only carry a small subset of
585
+ # the original creature properties (no DinoID, no TribeID, no
586
+ # tamer string, etc.) — surface what we have, leave the rest
587
+ # absent so consumers can detect the gap.
588
+ _stat_to_idx = {
589
+ "Health": 0, "Stamina": 1, "Torpidity": 2, "Oxygen": 3,
590
+ "Food": 4, "Water": 5, "Temperature": 6, "Weight": 7,
591
+ "MeleeDamage": 8, "MovementSpeed": 9, "Fortitude": 10,
592
+ "CraftingSkill": 11,
593
+ }
594
+ if cryo.name:
595
+ cryo.creature_props["TamedName"] = cryo.name
596
+ for i, color in enumerate(cryo.colors[:6]):
597
+ cryo.creature_props[f"ColorSetIndices_{i}" if i > 0 else "ColorSetIndices"] = color
598
+ if cryo.level:
599
+ cryo.status_props["BaseCharacterLevel"] = cryo.level
600
+ for stat_name, value in cryo.current_stats.items():
601
+ idx = _stat_to_idx.get(stat_name)
602
+ if idx is None:
603
+ continue
604
+ key = "CurrentStatusValues" if idx == 0 else f"CurrentStatusValues_{idx}"
605
+ cryo.status_props[key] = value
606
+
572
607
  return cryo
573
608
 
574
609
  except Exception:
@@ -203,6 +203,57 @@ def _stat_array(status: t.Any, prop_name: str) -> list[int]:
203
203
  return out
204
204
 
205
205
 
206
+ def _current_stat_floats(status: t.Any) -> list[float] | None:
207
+ """Read ``CurrentStatusValues[0..11]`` from a character status component.
208
+
209
+ Status components persist the live in-world values of all 12 stats. The
210
+ array is indexed by ARK's ``EPrimalCharacterStatusValue`` enum:
211
+ 0=hp, 1=stam, 2=torpor, 3=oxy, 4=food, 5=water, 6=temp, 7=weight,
212
+ 8=melee, 9=speed, 10=temp-fortitude, 11=crafting.
213
+
214
+ Returns ``None`` when the status component is missing or carries no
215
+ CurrentStatusValues entries (e.g. uninitialised baby actor) so callers
216
+ can distinguish "no data" from "all zeros".
217
+ """
218
+ if status is None:
219
+ return None
220
+ out: list[float] = [0.0] * 12
221
+ seen = False
222
+ getter = getattr(status, "get_properties_by_name", None)
223
+ if callable(getter):
224
+ # Real GameObject: get_properties_by_name surfaces every indexed
225
+ # entry. Empty result == component carries no CurrentStatusValues
226
+ # at all (uninitialised); return None so callers can distinguish
227
+ # "no data" from "all zeros".
228
+ for prop in getter("CurrentStatusValues"):
229
+ idx = getattr(prop, "index", 0)
230
+ if 0 <= idx < 12:
231
+ out[idx] = _float(getattr(prop, "value", 0.0))
232
+ seen = True
233
+ return out if seen else None
234
+ # Synthetic / cryopod stand-in: per-index get_property_value lookups.
235
+ for i in range(12):
236
+ val = _prop(status, "CurrentStatusValues", default=None, index=i)
237
+ if val is not None:
238
+ out[i] = _float(val)
239
+ seen = True
240
+ return out if seen else None
241
+
242
+
243
+ def _current_stats_dict(status: t.Any) -> dict[str, float] | None:
244
+ """Return current stat values keyed by short stat name.
245
+
246
+ Pre-conditions: ``status`` is the character status component (creature or
247
+ player). May be ``None`` (e.g. offline player with no spawned pawn).
248
+ Post-conditions: returns a dict ``{hp, stam, ..., craft}`` of floats, or
249
+ ``None`` when no CurrentStatusValues are persisted on the component.
250
+ """
251
+ floats = _current_stat_floats(status)
252
+ if floats is None:
253
+ return None
254
+ return {_STAT_NAMES[i]: floats[i] for i in range(12)}
255
+
256
+
206
257
  def _flat_stats(points: list[int], suffix: str = "") -> dict[str, int]:
207
258
  """Emit all 12 stats as a flat dict.
208
259
 
@@ -674,6 +725,7 @@ def _tamed_dict(
674
725
  )) is not None
675
726
  else None
676
727
  ),
728
+ "current_stats": _current_stats_dict(status),
677
729
  "imprinter_player_id": _int(_prop(obj, "ImprinterPlayerDataID")),
678
730
  "imprinter_net_id": _str(_prop(obj, "ImprinterPlayerUniqueNetId")),
679
731
  "taming_team_id": _int(_prop(obj, "TamingTeamID")),
@@ -719,12 +771,55 @@ def _tamed_dict(
719
771
 
720
772
 
721
773
  def export_tamed(save: t.Any, map_config: MapConfig | None = None) -> list[dict[str, t.Any]]:
774
+ """Emit ASV_Tamed records.
775
+
776
+ Pre-conditions: ``save`` exposes ``get_tamed_creatures()`` (in-world
777
+ tames) and ideally ``iter_cryopod_creatures()`` (creatures whose actor
778
+ has been removed from the world and re-embedded inside a cryopod /
779
+ soultrap / vivarium / dinoball item).
780
+
781
+ Post-conditions: returned list combines (a) every in-world tame and
782
+ (b) every cryopod-embedded tame, with the latter carrying ``cryo=True``
783
+ and inheriting the cryopod item's world location for GPS fields.
784
+ """
722
785
  objects = _world_objects(save, "get_tamed_creatures", "tamed_objects")
723
786
  lookup = _save_lookup(save)
724
- return [
787
+ results: list[dict[str, t.Any]] = [
725
788
  _tamed_dict(obj, _status_for(obj, lookup), lookup, map_config, save)
726
789
  for obj in objects
727
790
  ]
791
+ results.extend(_export_world_cryopods(save, map_config))
792
+ return results
793
+
794
+
795
+ def _export_world_cryopods(
796
+ save: t.Any,
797
+ map_config: MapConfig | None,
798
+ ) -> list[dict[str, t.Any]]:
799
+ """Build ASV_Tamed records for cryopod-embedded creatures on the map.
800
+
801
+ ARK strips the actor for any creature stuffed into a cryopod / soultrap
802
+ / vivarium / dinoball and serialises a snapshot into the item's
803
+ ``CustomItemDatas``. ``get_tamed_creatures()`` therefore misses them
804
+ entirely. We walk ``iter_cryopod_creatures()`` (when available),
805
+ decode each embedded blob, and produce ``ASV_Tamed`` entries with
806
+ ``cryo=True`` and the cryopod item's location.
807
+ """
808
+ iter_cryos = getattr(save, "iter_cryopod_creatures", None)
809
+ if not callable(iter_cryos):
810
+ return []
811
+ out: list[dict[str, t.Any]] = []
812
+ empty_lookup: dict[t.Any, t.Any] = {}
813
+ for item_obj, cryo in iter_cryos():
814
+ actor, status = _cryo_props_to_synthetic(cryo)
815
+ # Inherit the cryopod's world location so GPS fields populate.
816
+ actor.location = getattr(item_obj, "location", None)
817
+ record = _tamed_dict(actor, status, empty_lookup, map_config, save)
818
+ # The synthetic actor carries no IsInCryo property; force the legacy
819
+ # flag so consumers can distinguish in-world tames from stored ones.
820
+ record["cryo"] = True
821
+ out.append(record)
822
+ return out
728
823
 
729
824
 
730
825
  class _SyntheticGameObject:
@@ -870,6 +965,7 @@ def _wild_dict(
870
965
  # CreatureTraits list is exposed alongside as ``traits``.
871
966
  "trait": traits[0] if traits else "",
872
967
  "traits": traits,
968
+ "current_stats": _current_stats_dict(status),
873
969
  "wild_spawn_region": _str(_prop(obj, "OriginalNPCVolumeName")),
874
970
  }
875
971
  data.update(_gps_payload(obj, map_config))
@@ -882,13 +978,24 @@ def export_wild(save: t.Any, map_config: MapConfig | None = None) -> list[dict[s
882
978
  return [_wild_dict(obj, _status_for(obj, lookup), map_config) for obj in objects]
883
979
 
884
980
 
885
- def _player_from_profile(profile: Profile, save: t.Any = None) -> dict[str, t.Any]:
981
+ def _player_from_profile(
982
+ profile: Profile,
983
+ save: t.Any = None,
984
+ pawn_status_by_id: dict[int, t.Any] | None = None,
985
+ ) -> dict[str, t.Any]:
886
986
  stat_points = [_int(profile.get_stat(i)["added"]) for i in range(12)]
887
987
  gamertag = profile.player_name or ""
888
988
  character = profile.character_name or gamertag
889
989
  steam_id = profile.unique_id or ""
890
990
  active_dt = _approx_real_datetime(profile.last_login_time, save)
891
991
 
992
+ # Live HP/stam/etc live on the player's in-world pawn's status component,
993
+ # not in the profile. Resolve via PlayerDataID -> pawn -> status. Absent
994
+ # when the player has no spawned body (never spawned / corpse cleared).
995
+ status = None
996
+ if pawn_status_by_id and profile.player_id:
997
+ status = pawn_status_by_id.get(int(profile.player_id))
998
+
892
999
  out: dict[str, t.Any] = {
893
1000
  "playerid": profile.player_id or 0,
894
1001
  "steam": _str(gamertag),
@@ -907,6 +1014,7 @@ def _player_from_profile(profile: Profile, save: t.Any = None) -> dict[str, t.An
907
1014
  "netAddress": "",
908
1015
  "engram_points": profile.total_engram_points,
909
1016
  "experience": _int(profile.experience),
1017
+ "current_stats": _current_stats_dict(status),
910
1018
  }
911
1019
  if steam_id:
912
1020
  out["steamid"] = steam_id
@@ -981,17 +1089,43 @@ def _player_from_object(
981
1089
  "body_colors": body_colors,
982
1090
  "died_at": died_iso,
983
1091
  "corpse_destruction": corpse_iso,
1092
+ "current_stats": _current_stats_dict(status),
984
1093
  }
985
1094
  return _compact(data, LEGACY_PLAYER_KEYS)
986
1095
 
987
1096
 
1097
+ def _player_status_by_data_id(save: t.Any, lookup: dict[t.Any, t.Any]) -> dict[int, t.Any]:
1098
+ """Index ``MyCharacterStatusComponent`` per player by ``LinkedPlayerDataID``.
1099
+
1100
+ Walks every ``PlayerPawnTest_*_C`` / ``PlayerCharacter_*`` in the world
1101
+ save, reads its ``LinkedPlayerDataID``, follows ``MyCharacterStatusComponent``
1102
+ via ``lookup``, and stores the resolved status object. Lets profile-based
1103
+ player exports surface live HP/stamina/food/etc. without legacy ASVPack
1104
+ having to do the same join.
1105
+ """
1106
+ out: dict[int, t.Any] = {}
1107
+ objects = getattr(save, "objects", None) or []
1108
+ for obj in objects:
1109
+ cn = str(getattr(obj, "class_name", "") or "")
1110
+ if "PlayerPawn" not in cn and "PlayerCharacter" not in cn:
1111
+ continue
1112
+ pid = _int(_prop(obj, "LinkedPlayerDataID"))
1113
+ if not pid:
1114
+ continue
1115
+ status = _status_for(obj, lookup)
1116
+ if status is not None:
1117
+ out[pid] = status
1118
+ return out
1119
+
1120
+
988
1121
  def export_players(save: t.Any, map_config: MapConfig | None = None) -> list[dict[str, t.Any]]:
989
1122
  profiles = _collection(save, "profiles", Profile)
990
1123
  lookup = _save_lookup(save)
1124
+ pawn_status_by_id = _player_status_by_data_id(save, lookup)
991
1125
  results: list[dict[str, t.Any]] = []
992
1126
  for entry in profiles:
993
1127
  if isinstance(entry, Profile):
994
- results.append(_player_from_profile(entry, save))
1128
+ results.append(_player_from_profile(entry, save, pawn_status_by_id))
995
1129
  continue
996
1130
  profile_obj = getattr(entry, "profile", None)
997
1131
  if profile_obj is None and getattr(entry, "objects", None):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkparser
3
- Version: 0.3.1
3
+ Version: 0.3.3
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
@@ -210,7 +210,7 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
210
210
  | `hp-m` .. `fort-m` (all 12) | added | `NumberOfMutationsAppliedTamed[i]` (mutation point counts per stat) |
211
211
  | `c0` .. `c5` | legacy | `ColorSetIndices[i]` |
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
- | `cryo` | legacy | `IsInCryo` |
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
215
  | `dinoid` | legacy | string form of `id` |
216
216
  | `isMating` | legacy | `bEnableTamedMating` |
@@ -250,6 +250,7 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
250
250
  | `last_baby_age_update` | added | ISO 8601 datetime of `LastUpdatedBabyAgeAtTime`. |
251
251
  | `last_gestation_update` | added | ISO 8601 datetime of `LastUpdatedGestationAtTime`. |
252
252
  | `next_cuddle` | added | ISO 8601 datetime of `BabyNextCuddleTime`. |
253
+ | `current_stats` | added | Live in-world stat values from the dino's status component (`CurrentStatusValues[0..11]`) as a `{hp, stam, torp, oxy, food, water, temp, weight, melee, speed, fort, craft}` dict of floats. These are the *current* values (e.g. `hp: 11013.62` = current HP, drops as the dino takes damage). Max values are NOT persisted by ARK — compute downstream from species stat tables + `*-w`/`*-t` points + `imprint` + server multipliers if you need them. `null` when the status component carries no `CurrentStatusValues` entries (e.g. uninitialised baby actor). |
253
254
 
254
255
  #### `ASV_Wild` schema
255
256
 
@@ -267,6 +268,7 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
267
268
  | `trait` | legacy | first entry of `CreatureTraits` (or empty string) |
268
269
  | `traits` | added | full `CreatureTraits` list |
269
270
  | `wild_spawn_region` | added | `OriginalNPCVolumeName` — `NPCZoneVolume` the creature spawned in. |
271
+ | `current_stats` | added | Live in-world stat values from the creature's status component (`CurrentStatusValues[0..11]`) as a `{hp, stam, torp, oxy, food, water, temp, weight, melee, speed, fort, craft}` dict of floats. Max values are NOT in the save (would need species stat tables). `null` when uninitialised. |
270
272
 
271
273
  #### Player data: `.arkprofile` vs in-world pawn
272
274
 
@@ -318,6 +320,7 @@ For the richest output, hand `export_players` **both** — assemble a wrapper fo
318
320
  | `corpse_destruction` | added | ISO 8601 datetime of `CorpseDestructionTime`. |
319
321
  | `chibi_levels` | added | `NumChibiLevelUps` — bonus levels from chibi pets. |
320
322
  | `ascensions_scorched` | added | `NumAscensionsScorched` — ASE ascension counter (legacy `ContentPlayer` parses the ASA ascension block differently; this is the ASE-specific field). |
323
+ | `current_stats` | added | Live in-world stat values for the player from the pawn's `MyCharacterStatusComponent` (`CurrentStatusValues[0..11]`) as a `{hp, stam, torp, oxy, food, water, temp, weight, melee, speed, fort, craft}` dict of floats. For profile-sourced records the parser joins on `PlayerDataID == LinkedPlayerDataID` to find the spawned pawn in the world save. `null` when the player has no in-world pawn (never spawned this server / corpse cleared) or the status component has no values — only currently / recently spawned characters have live stats. Max values are NOT persisted by ARK. |
321
324
 
322
325
  #### `ASV_Tribes` schema
323
326
 
@@ -43,6 +43,8 @@ tests/test_asa_header_position.py
43
43
  tests/test_binary_reader.py
44
44
  tests/test_binary_reader_layouts.py
45
45
  tests/test_cloud_inventory.py
46
+ tests/test_cryopod_export.py
47
+ tests/test_current_stats.py
46
48
  tests/test_data_models.py
47
49
  tests/test_export.py
48
50
  tests/test_game_objects.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "arkparser"
7
- version = "0.3.1"
7
+ version = "0.3.3"
8
8
  description = "Pure Python parser for ARK: Survival Evolved and ARK: Survival Ascended save files"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,122 @@
1
+ """Tests pinning ASA cryopod creature decode + export integration.
2
+
3
+ ARK strips the actor for every cryopodded / soultrapped / vivariumed /
4
+ dinoballed creature and embeds a serialised snapshot into the item's
5
+ ``CustomItemDatas``. ``WorldSave.iter_cryopod_creatures`` walks these
6
+ items, and ``export_tamed`` then surfaces them as ``ASV_Tamed`` entries
7
+ with ``cryo=True``. Two regressions tested here:
8
+
9
+ - ASA cryopod blobs carry 11 current stats (no CraftingSkill in the
10
+ current block), 11 max stats, and 14 extras (36 floats total). The
11
+ pre-0.3.3 parser walked 12 names against an 11-wide block, leaking
12
+ ``max[0]`` (Health) into ``current_stats["CraftingSkill"]`` and
13
+ surfacing ``hp == craft`` on every cryopod tame.
14
+ - ``from_asa_cryopod_data`` did not populate ``creature_props`` /
15
+ ``status_props``; the ``_SyntheticGameObject`` adapter the exporter
16
+ uses then saw empty dicts and emitted records with ``lvl=1``,
17
+ ``name=""``, all-zero colors, and ``current_stats: null``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from arkparser.data_models import CryopodCreature
23
+
24
+
25
+ def _asa_blob(current: list[float], max_: list[float]) -> dict[str, object]:
26
+ """Synthesise the smallest ASA cryopod CustomItemDatas entry we can.
27
+
28
+ 36 floats total: 11 current + 11 max + 14 extras (zeros).
29
+ """
30
+ assert len(current) == 11
31
+ assert len(max_) == 11
32
+ floats = current + max_ + [0.0] * 14
33
+ return {
34
+ "CustomDataName": "Dino",
35
+ "CustomDataStrings": [
36
+ "Argent_Character_BP_C_2094073921", # [0] class_name
37
+ "dada - Lvl 259 (Argentavis)", # [1] display
38
+ "37,0,37,0,0,0,", # [2] colors
39
+ "", "", "", "", "", "", "Argentavis", # [9] species
40
+ ],
41
+ "CustomDataFloats": floats,
42
+ "CustomDataNames": [],
43
+ }
44
+
45
+
46
+ def test_asa_cryopod_eleven_current_stats_does_not_leak_max_into_craft() -> None:
47
+ """ASA cryopod blob: current[0..10] then max[0..10]. craft must come
48
+ from current[11] — which does not exist, so it must stay 0.0, NOT pick
49
+ up max[0]."""
50
+ current = [6206.46, 2411.2, 0.0, 750.0, 13153.03, 100.0, 0.0, 63.6, 6.69, 0.09, 0.0]
51
+ max_ = [6206.46, 2411.2, 0.0, 750.0, 13153.03, 100.0, 0.0, 63.6, 6.69, 0.09, 0.0]
52
+ cryo = CryopodCreature.from_asa_cryopod_data(_asa_blob(current, max_))
53
+ assert cryo is not None
54
+ assert "Health" in cryo.current_stats
55
+ assert cryo.current_stats["Health"] == 6206.46
56
+ # CraftingSkill must NOT be in current_stats on ASA cryopods (only
57
+ # 11 slots are populated). Pre-0.3.3 set it equal to max[0]=Health.
58
+ assert "CraftingSkill" not in cryo.current_stats
59
+ # Max block round-trips properly
60
+ assert cryo.max_stats["Health"] == 6206.46
61
+
62
+
63
+ def test_asa_cryopod_populates_creature_and_status_props() -> None:
64
+ """The exporter adapter (``_SyntheticGameObject``) reads via
65
+ ``get_property_value`` keyed by ARK property name + index suffix. The
66
+ ASA cryopod decoder must mirror those keys into ``creature_props`` /
67
+ ``status_props`` so the synthetic adapter surfaces tamed-name, level,
68
+ colors, and CurrentStatusValues per stat index."""
69
+ current = [3000.0, 800.0, 0.0, 500.0, 5000.0, 100.0, 0.0, 200.0, 2.5, 0.05, 0.0]
70
+ max_ = [3000.0] * 11
71
+ cryo = CryopodCreature.from_asa_cryopod_data(_asa_blob(current, max_))
72
+ assert cryo is not None
73
+
74
+ # TamedName from display-name parse
75
+ assert cryo.creature_props.get("TamedName") == "dada"
76
+ # Level from display-name parse
77
+ assert cryo.status_props.get("BaseCharacterLevel") == 259
78
+ # Colors at expected indices (first non-zero is c0=37, then c2=37)
79
+ assert cryo.creature_props.get("ColorSetIndices") == 37
80
+ assert cryo.creature_props.get("ColorSetIndices_2") == 37
81
+ # CurrentStatusValues indexed by EPrimalCharacterStatusValue
82
+ # 0=Health, 1=Stamina, 3=Oxygen, 4=Food, 7=Weight, 8=MeleeDamage
83
+ assert cryo.status_props.get("CurrentStatusValues") == 3000.0 # health
84
+ assert cryo.status_props.get("CurrentStatusValues_1") == 800.0 # stam
85
+ assert cryo.status_props.get("CurrentStatusValues_4") == 5000.0 # food
86
+ assert cryo.status_props.get("CurrentStatusValues_7") == 200.0 # weight
87
+ assert cryo.status_props.get("CurrentStatusValues_8") == 2.5 # melee
88
+
89
+
90
+ def test_asa_cryopod_display_name_parses_level_and_species() -> None:
91
+ """Display-name format ``"Name - Lvl N (Species)"`` must yield all three."""
92
+ cryo = CryopodCreature.from_asa_cryopod_data(_asa_blob([0.0] * 11, [0.0] * 11))
93
+ assert cryo is not None
94
+ assert cryo.name == "dada"
95
+ assert cryo.level == 259
96
+ # Species comes from strings[9] when present, falling back to display parse
97
+ assert cryo.species == "Argentavis"
98
+
99
+
100
+ def test_ase_cryopod_keeps_twelve_stat_layout() -> None:
101
+ """ASE cryopod blobs carry 12 current stats + 12 max stats (25 floats).
102
+ The ASA fix must not break the ASE path: walking all 12 names is the
103
+ correct behaviour when ``len(floats) < 36``."""
104
+ # 25 floats = 12 current + 12 max + 1 extra
105
+ floats = [float(i + 1) for i in range(12)] + [float(i + 100) for i in range(12)] + [0.0]
106
+ blob = {
107
+ "CustomDataName": "Dino",
108
+ "CustomDataStrings": [
109
+ "Raptor_Character_BP_C", "Bluey - Lvl 30 (Raptor)", "0,0,0,0,0,0,",
110
+ ],
111
+ "CustomDataFloats": floats,
112
+ "CustomDataNames": [],
113
+ }
114
+ cryo = CryopodCreature.from_asa_cryopod_data(blob)
115
+ assert cryo is not None
116
+ # All 12 current and 12 max should be populated on ASE blobs
117
+ assert len(cryo.current_stats) == 12
118
+ assert len(cryo.max_stats) == 12
119
+ assert cryo.current_stats["Health"] == 1.0
120
+ assert cryo.current_stats["CraftingSkill"] == 12.0
121
+ assert cryo.max_stats["Health"] == 100.0
122
+ assert cryo.max_stats["CraftingSkill"] == 111.0
@@ -0,0 +1,133 @@
1
+ """Unit tests for live CurrentStatusValues extraction.
2
+
3
+ ARK persists every character's instantaneous stat values on the status
4
+ component as ``CurrentStatusValues[0..11]``. ``_current_stats_dict`` reads
5
+ those entries (using either ``get_properties_by_name`` for objects that
6
+ expose it or per-index ``get_property_value`` lookups for synthetic /
7
+ cryopod objects) and returns ``{hp, stam, torp, oxy, food, water, temp,
8
+ weight, melee, speed, fort, craft}``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import typing as t
14
+ from dataclasses import dataclass
15
+
16
+ from arkparser.export import _current_stat_floats, _current_stats_dict
17
+
18
+ _STAT_ORDER = (
19
+ "hp", "stam", "torp", "oxy", "food", "water",
20
+ "temp", "weight", "melee", "speed", "fort", "craft",
21
+ )
22
+
23
+
24
+ @dataclass
25
+ class _IndexedProp:
26
+ """Stand-in for an arkparser property with an array index."""
27
+
28
+ name: str
29
+ index: int
30
+ value: float
31
+
32
+
33
+ class _FakeStatus:
34
+ """Minimal stand-in for a character status component.
35
+
36
+ Supports the ``get_properties_by_name`` interface used by
37
+ ``_current_stat_floats``.
38
+ """
39
+
40
+ def __init__(self, current: list[tuple[int, float]]) -> None:
41
+ self._props = [_IndexedProp("CurrentStatusValues", idx, val) for idx, val in current]
42
+
43
+ def get_properties_by_name(self, name: str) -> list[_IndexedProp]:
44
+ return [p for p in self._props if p.name == name]
45
+
46
+
47
+ class _PerIndexStatus:
48
+ """Synthetic stand-in (matches ``_SyntheticGameObject`` interface).
49
+
50
+ Stores values as ``CurrentStatusValues_{idx}`` keys, and
51
+ ``get_property_value`` returns the indexed value.
52
+ """
53
+
54
+ def __init__(self, current: dict[int, float]) -> None:
55
+ self._values = current
56
+
57
+ def get_property_value(
58
+ self,
59
+ name: str,
60
+ default: t.Any = None,
61
+ index: int | None = None,
62
+ ) -> t.Any:
63
+ if name != "CurrentStatusValues":
64
+ return default
65
+ if index is None:
66
+ return default
67
+ return self._values.get(index, default)
68
+
69
+
70
+ def test_none_status_returns_none() -> None:
71
+ assert _current_stat_floats(None) is None
72
+ assert _current_stats_dict(None) is None
73
+
74
+
75
+ def test_empty_status_returns_none() -> None:
76
+ """Status component carrying no CurrentStatusValues at all -> ``None``."""
77
+ status = _FakeStatus(current=[])
78
+ assert _current_stat_floats(status) is None
79
+ assert _current_stats_dict(status) is None
80
+
81
+
82
+ def test_sparse_indices_fill_with_zeros() -> None:
83
+ """Real saves only persist the stats that diverge from defaults; the
84
+ rest get zero. Wyvern test: hp + stam + oxy set, the rest zero."""
85
+ status = _FakeStatus(current=[
86
+ (0, 25238.14), # hp
87
+ (1, 806.40), # stam
88
+ (3, 660.0), # oxy
89
+ ])
90
+ result = _current_stats_dict(status)
91
+ assert result is not None
92
+ assert result["hp"] == 25238.14
93
+ assert result["stam"] == 806.40
94
+ assert result["oxy"] == 660.0
95
+ assert result["torp"] == 0.0
96
+ assert result["food"] == 0.0
97
+ assert result["water"] == 0.0
98
+ assert result["weight"] == 0.0
99
+ assert result["melee"] == 0.0
100
+ assert result["speed"] == 0.0
101
+ assert result["temp"] == 0.0
102
+ assert result["fort"] == 0.0
103
+ assert result["craft"] == 0.0
104
+
105
+
106
+ def test_all_twelve_indices_round_trip() -> None:
107
+ """Verify every stat slot maps to the correct name (catches off-by-one)."""
108
+ status = _FakeStatus(current=[(i, float(i + 1) * 10) for i in range(12)])
109
+ result = _current_stats_dict(status)
110
+ assert result is not None
111
+ for i, name in enumerate(_STAT_ORDER):
112
+ assert result[name] == float(i + 1) * 10, f"slot {i} ({name}) mismatched"
113
+
114
+
115
+ def test_synthetic_object_uses_per_index_getter() -> None:
116
+ """Cryopod-decoded creatures expose status via ``get_property_value``
117
+ with an explicit ``index`` arg, not ``get_properties_by_name``."""
118
+ status = _PerIndexStatus(current={0: 1000.0, 1: 500.0, 4: 3000.0})
119
+ result = _current_stats_dict(status)
120
+ assert result is not None
121
+ assert result["hp"] == 1000.0
122
+ assert result["stam"] == 500.0
123
+ assert result["food"] == 3000.0
124
+ assert result["torp"] == 0.0
125
+
126
+
127
+ def test_out_of_range_indices_are_ignored() -> None:
128
+ """Defensive: stray index 12+ entries must not blow past the 12-slot array."""
129
+ status = _FakeStatus(current=[(0, 100.0), (12, 999.0), (15, 999.0)])
130
+ result = _current_stats_dict(status)
131
+ assert result is not None
132
+ assert result["hp"] == 100.0
133
+ assert len(result) == 12
File without changes
File without changes
File without changes