arkparser 0.4.0__tar.gz → 0.4.2__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 (59) hide show
  1. {arkparser-0.4.0 → arkparser-0.4.2}/PKG-INFO +6 -6
  2. {arkparser-0.4.0 → arkparser-0.4.2}/README.md +5 -5
  3. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/__init__.py +1 -1
  4. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/export.py +161 -39
  5. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser.egg-info/PKG-INFO +6 -6
  6. {arkparser-0.4.0 → arkparser-0.4.2}/pyproject.toml +1 -1
  7. arkparser-0.4.2/tests/test_cloud_export.py +98 -0
  8. arkparser-0.4.0/tests/test_cloud_export.py +0 -45
  9. {arkparser-0.4.0 → arkparser-0.4.2}/LICENSE +0 -0
  10. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/common/__init__.py +0 -0
  11. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/common/binary_reader.py +0 -0
  12. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/common/exceptions.py +0 -0
  13. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/common/map_config.py +0 -0
  14. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/common/normalization.py +0 -0
  15. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/common/types.py +0 -0
  16. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/common/version_detection.py +0 -0
  17. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/data_models.py +0 -0
  18. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/files/__init__.py +0 -0
  19. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/files/base.py +0 -0
  20. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/files/cloud_inventory.py +0 -0
  21. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/files/profile.py +0 -0
  22. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/files/tribe.py +0 -0
  23. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/files/world_save.py +0 -0
  24. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/game_objects/__init__.py +0 -0
  25. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/game_objects/container.py +0 -0
  26. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/game_objects/game_object.py +0 -0
  27. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/game_objects/location.py +0 -0
  28. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/properties/__init__.py +0 -0
  29. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/properties/base.py +0 -0
  30. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/properties/byte_property.py +0 -0
  31. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/properties/compound.py +0 -0
  32. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/properties/primitives.py +0 -0
  33. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/properties/registry.py +0 -0
  34. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/structs/__init__.py +0 -0
  35. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/structs/base.py +0 -0
  36. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/structs/colors.py +0 -0
  37. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/structs/misc.py +0 -0
  38. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/structs/property_list.py +0 -0
  39. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/structs/registry.py +0 -0
  40. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser/structs/vectors.py +0 -0
  41. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser.egg-info/SOURCES.txt +0 -0
  42. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser.egg-info/dependency_links.txt +0 -0
  43. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser.egg-info/requires.txt +0 -0
  44. {arkparser-0.4.0 → arkparser-0.4.2}/arkparser.egg-info/top_level.txt +0 -0
  45. {arkparser-0.4.0 → arkparser-0.4.2}/setup.cfg +0 -0
  46. {arkparser-0.4.0 → arkparser-0.4.2}/tests/test_asa_header_position.py +0 -0
  47. {arkparser-0.4.0 → arkparser-0.4.2}/tests/test_binary_reader.py +0 -0
  48. {arkparser-0.4.0 → arkparser-0.4.2}/tests/test_binary_reader_layouts.py +0 -0
  49. {arkparser-0.4.0 → arkparser-0.4.2}/tests/test_cloud_inventory.py +0 -0
  50. {arkparser-0.4.0 → arkparser-0.4.2}/tests/test_cryopod_export.py +0 -0
  51. {arkparser-0.4.0 → arkparser-0.4.2}/tests/test_current_stats.py +0 -0
  52. {arkparser-0.4.0 → arkparser-0.4.2}/tests/test_data_models.py +0 -0
  53. {arkparser-0.4.0 → arkparser-0.4.2}/tests/test_export.py +0 -0
  54. {arkparser-0.4.0 → arkparser-0.4.2}/tests/test_game_objects.py +0 -0
  55. {arkparser-0.4.0 → arkparser-0.4.2}/tests/test_profile.py +0 -0
  56. {arkparser-0.4.0 → arkparser-0.4.2}/tests/test_tribe.py +0 -0
  57. {arkparser-0.4.0 → arkparser-0.4.2}/tests/test_v13_property_layouts.py +0 -0
  58. {arkparser-0.4.0 → arkparser-0.4.2}/tests/test_version_detection.py +0 -0
  59. {arkparser-0.4.0 → arkparser-0.4.2}/tests/test_world_save.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkparser
3
- Version: 0.4.0
3
+ Version: 0.4.2
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
@@ -410,11 +410,11 @@ data = export_all(save, map_config, cluster="path/to/cluster")
410
410
  # data["ASV_Tamed"] - now includes cluster cryopod tames (cryo=True)
411
411
  # data["ASV_Players"] - each player's inventory now contains their
412
412
  # uploaded items (entries tagged "uploaded": true),
413
- # matched by cloud-file stem (xuid) ==
414
- # Profile.unique_id.
413
+ # matched by cloud-file stem == the player's
414
+ # .arkprofile filename stem.
415
415
  ```
416
416
 
417
- Cluster items are spliced into the owning player's `inventory`; no separate `ASV_ClusterItems` file is emitted. The match works because every cluster file is named after the owning player's Steam id / platform UUID, which is the same value as `Profile.unique_id`.
417
+ Cluster items are spliced into the owning player's `inventory`; no separate `ASV_ClusterItems` file is emitted. The match works because every cluster file and the player's `.arkprofile` share a stem — the Steam id on ASE, the hex platform UUID on ASA — so the join is keyed on the profile's source filename stem. (`Profile.unique_id` equals that stem only on ASE; on ASA it is the numeric net id, not the UUID filename, so loading profiles from real file paths — which sets `Profile.source_path` — is required for the ASA splice.)
418
418
 
419
419
  Pre-loaded `CloudInventory` instances also work (`cluster=[inv1, inv2, ...]`).
420
420
 
@@ -436,7 +436,7 @@ data = export_cloud_inventory(cloud)
436
436
  # }
437
437
  ```
438
438
 
439
- Each `ASV_Items` entry carries `itemId`, `qty`, `blueprint`, `id` (combined ItemID1_ItemID2), `upload_time` (ISO 8601 with UTC offset), and item-class-specific stats (`durability_max`, `damage`, `armor`, `hypo`, `hyper`, `crafter`, `crafter_tribe`, `skill_bonus`, `loaded_ammo`, `quality`, `rating`, `c0`..`c5` paint regions, `drop_location`, `egg_*` for fertilized eggs, `dino_*` for cryopod items, etc). Default / unset fields are filtered, NaN floats dropped, `"Unknown"` crafter strings nulled.
439
+ Each `ASV_Items` entry carries `itemId`, `qty`, `blueprint`, `id` (combined ItemID1_ItemID2), `uploadedTime` (ISO 8601 with UTC offset), and item-class-specific stats (`durability_max`, `damage`, `armor`, `hypo`, `hyper`, `crafter`, `crafter_tribe`, `skill_bonus`, `loaded_ammo`, `quality`, `rating`, `c0`..`c5` paint regions, `drop_location`, `egg_*` for fertilized eggs, `dino_*` for cryopod items, etc). Default / unset fields are filtered, NaN floats dropped, `"Unknown"` crafter strings nulled.
440
440
 
441
441
  The low-level helpers are also available:
442
442
  - `export_cluster_uploads(cluster_invs, map_config=None) -> list[dict]` — tamed-shape records (incl. cryopod-as-item dinos) across the supplied cloud files
@@ -589,7 +589,7 @@ All four expose `from_*` constructors and `to_dict()` for serialization.
589
589
  |---|---|---|
590
590
  | `export_tamed(save, map_config=None)` | `list[dict]` | `ASV_Tamed` records |
591
591
  | `export_wild(save, map_config=None)` | `list[dict]` | `ASV_Wild` records |
592
- | `export_players(save, map_config=None, cluster_inventories=None)` | `list[dict]` | `ASV_Players` records (from Profile parsers). Pass `cluster_inventories` to splice each player's uploaded items into their `inventory` (entries tagged `uploaded: true`, matched by xuid stem == `Profile.unique_id`) |
592
+ | `export_players(save, map_config=None, cluster_inventories=None)` | `list[dict]` | `ASV_Players` records (from Profile parsers). Pass `cluster_inventories` to splice each player's uploaded items into their `inventory` (entries tagged `uploaded: true`, matched by cloud-file stem == the player's `.arkprofile` filename stem) |
593
593
  | `export_tribes(save)` | `list[dict]` | `ASV_Tribes` records |
594
594
  | `export_structures(save, map_config=None)` | `list[dict]` | `ASV_Structures` records |
595
595
  | `export_tribe_logs(save)` | `list[dict]` | `ASV_TribeLogs` records |
@@ -376,11 +376,11 @@ data = export_all(save, map_config, cluster="path/to/cluster")
376
376
  # data["ASV_Tamed"] - now includes cluster cryopod tames (cryo=True)
377
377
  # data["ASV_Players"] - each player's inventory now contains their
378
378
  # uploaded items (entries tagged "uploaded": true),
379
- # matched by cloud-file stem (xuid) ==
380
- # Profile.unique_id.
379
+ # matched by cloud-file stem == the player's
380
+ # .arkprofile filename stem.
381
381
  ```
382
382
 
383
- Cluster items are spliced into the owning player's `inventory`; no separate `ASV_ClusterItems` file is emitted. The match works because every cluster file is named after the owning player's Steam id / platform UUID, which is the same value as `Profile.unique_id`.
383
+ Cluster items are spliced into the owning player's `inventory`; no separate `ASV_ClusterItems` file is emitted. The match works because every cluster file and the player's `.arkprofile` share a stem — the Steam id on ASE, the hex platform UUID on ASA — so the join is keyed on the profile's source filename stem. (`Profile.unique_id` equals that stem only on ASE; on ASA it is the numeric net id, not the UUID filename, so loading profiles from real file paths — which sets `Profile.source_path` — is required for the ASA splice.)
384
384
 
385
385
  Pre-loaded `CloudInventory` instances also work (`cluster=[inv1, inv2, ...]`).
386
386
 
@@ -402,7 +402,7 @@ data = export_cloud_inventory(cloud)
402
402
  # }
403
403
  ```
404
404
 
405
- Each `ASV_Items` entry carries `itemId`, `qty`, `blueprint`, `id` (combined ItemID1_ItemID2), `upload_time` (ISO 8601 with UTC offset), and item-class-specific stats (`durability_max`, `damage`, `armor`, `hypo`, `hyper`, `crafter`, `crafter_tribe`, `skill_bonus`, `loaded_ammo`, `quality`, `rating`, `c0`..`c5` paint regions, `drop_location`, `egg_*` for fertilized eggs, `dino_*` for cryopod items, etc). Default / unset fields are filtered, NaN floats dropped, `"Unknown"` crafter strings nulled.
405
+ Each `ASV_Items` entry carries `itemId`, `qty`, `blueprint`, `id` (combined ItemID1_ItemID2), `uploadedTime` (ISO 8601 with UTC offset), and item-class-specific stats (`durability_max`, `damage`, `armor`, `hypo`, `hyper`, `crafter`, `crafter_tribe`, `skill_bonus`, `loaded_ammo`, `quality`, `rating`, `c0`..`c5` paint regions, `drop_location`, `egg_*` for fertilized eggs, `dino_*` for cryopod items, etc). Default / unset fields are filtered, NaN floats dropped, `"Unknown"` crafter strings nulled.
406
406
 
407
407
  The low-level helpers are also available:
408
408
  - `export_cluster_uploads(cluster_invs, map_config=None) -> list[dict]` — tamed-shape records (incl. cryopod-as-item dinos) across the supplied cloud files
@@ -555,7 +555,7 @@ All four expose `from_*` constructors and `to_dict()` for serialization.
555
555
  |---|---|---|
556
556
  | `export_tamed(save, map_config=None)` | `list[dict]` | `ASV_Tamed` records |
557
557
  | `export_wild(save, map_config=None)` | `list[dict]` | `ASV_Wild` records |
558
- | `export_players(save, map_config=None, cluster_inventories=None)` | `list[dict]` | `ASV_Players` records (from Profile parsers). Pass `cluster_inventories` to splice each player's uploaded items into their `inventory` (entries tagged `uploaded: true`, matched by xuid stem == `Profile.unique_id`) |
558
+ | `export_players(save, map_config=None, cluster_inventories=None)` | `list[dict]` | `ASV_Players` records (from Profile parsers). Pass `cluster_inventories` to splice each player's uploaded items into their `inventory` (entries tagged `uploaded: true`, matched by cloud-file stem == the player's `.arkprofile` filename stem) |
559
559
  | `export_tribes(save)` | `list[dict]` | `ASV_Tribes` records |
560
560
  | `export_structures(save, map_config=None)` | `list[dict]` | `ASV_Structures` records |
561
561
  | `export_tribe_logs(save)` | `list[dict]` | `ASV_TribeLogs` records |
@@ -95,4 +95,4 @@ __all__ = [
95
95
  "ArkParseError",
96
96
  ]
97
97
 
98
- __version__ = "0.4.0"
98
+ __version__ = "0.4.2"
@@ -655,6 +655,7 @@ _EGG_ONLY_FIELDS: frozenset[str] = frozenset({
655
655
  "egg_random_mutations_female",
656
656
  "egg_random_mutations_male",
657
657
  "egg_number_mutations_applied",
658
+ "egg_number_of_mutations_applied", # "NumberOf" variant (matches EggNumberOfLevelUpPointsApplied naming)
658
659
  "egg_dino_gene_traits",
659
660
  })
660
661
 
@@ -711,7 +712,10 @@ def _is_default(key: str, value: t.Any) -> bool:
711
712
  a == b for a, b in zip(value, default)
712
713
  )
713
714
  if isinstance(default, dict) and isinstance(value, dict):
714
- return all(value.get(k, 0) == v for k, v in default.items())
715
+ # Vector structs are sometimes keyed X/Y/Z (ASA) instead of x/y/z;
716
+ # compare case-insensitively so a zero drop_location still prunes.
717
+ lowered = {str(k).lower(): v for k, v in value.items()}
718
+ return all(lowered.get(k, 0) == v for k, v in default.items())
715
719
  return value == default
716
720
 
717
721
 
@@ -732,7 +736,10 @@ def _flatten_color_array(value: t.Any) -> dict[str, int]:
732
736
  return out
733
737
  for idx, v in iterable:
734
738
  if 0 <= idx < 6 and v:
735
- out[f"c{idx}"] = int(v)
739
+ try:
740
+ out[f"c{idx}"] = int(v)
741
+ except (TypeError, ValueError):
742
+ continue # struct-valued color (LinearColor) etc; not a palette index
736
743
  return out
737
744
 
738
745
  # ARK universal 8-slot ItemStatValues map. Each slot is raw uint16; the
@@ -778,14 +785,27 @@ _STAT_NAME_ALIASES: dict[str, str] = {
778
785
 
779
786
 
780
787
  def _combine_item_id(value: t.Any) -> str | None:
781
- """Combine ItemID struct ``{ItemID1, ItemID2}`` to single string."""
788
+ """Combine ItemID struct ``{ItemID1, ItemID2}`` to single string.
789
+
790
+ Uses explicit ``is None`` fallback (not ``or``) so a legitimate
791
+ ``ItemID1 == 0`` is not silently replaced by an ``ItemID1_0`` variant
792
+ key. Non-numeric / corrupt id components return ``None`` rather than
793
+ raising and aborting the whole inventory export.
794
+ """
782
795
  if not isinstance(value, dict):
783
796
  return None
784
- id1 = value.get("ItemID1") or value.get("ItemID1_0")
785
- id2 = value.get("ItemID2") or value.get("ItemID2_0")
797
+ id1 = value.get("ItemID1")
798
+ if id1 is None:
799
+ id1 = value.get("ItemID1_0")
800
+ id2 = value.get("ItemID2")
801
+ if id2 is None:
802
+ id2 = value.get("ItemID2_0")
786
803
  if id1 is None and id2 is None:
787
804
  return None
788
- return f"{int(id1 or 0)}_{int(id2 or 0)}"
805
+ try:
806
+ return f"{int(id1 or 0)}_{int(id2 or 0)}"
807
+ except (TypeError, ValueError):
808
+ return None
789
809
 
790
810
 
791
811
  def _normalize_stat_value(name: str, value: t.Any) -> t.Any:
@@ -832,14 +852,40 @@ def _apply_stat_aliases(
832
852
  if _is_default(alias, val):
833
853
  continue
834
854
  out[alias] = val
855
+ out.update(_expand_stat_slots(raw_slot_values))
856
+ return out
857
+
858
+
859
+ def _expand_stat_slots(raw_slot_values: t.Any) -> dict[str, t.Any]:
860
+ """Map raw ItemStatValues to named slots, accepting either input shape.
861
+
862
+ Two shapes occur in practice:
863
+
864
+ - GameObject inventory path → a sparse ``{index: value}`` dict (see
865
+ :func:`_indexed_property_map`, which preserves the index even for a
866
+ single populated slot).
867
+ - Cloud / uploaded path → a dense 8-element ``list`` indexed 0..7
868
+ (``normalize_indexed_data`` collapses the indexed property to a list).
869
+
870
+ Anything else (a bare scalar from an upstream single-entry collapse that
871
+ lost its index) carries no recoverable slot and is ignored. Raw 0 means
872
+ "no stat roll", so zero slots are skipped.
873
+ """
874
+ pairs: t.Iterable[tuple[t.Any, t.Any]]
835
875
  if isinstance(raw_slot_values, dict):
836
- for k, v in raw_slot_values.items():
837
- try:
838
- idx = int(k)
839
- except (TypeError, ValueError):
840
- continue
841
- if 0 <= idx < len(_ITEM_STAT_SLOT_NAMES) and v is not None:
842
- out[_ITEM_STAT_SLOT_NAMES[idx]] = v
876
+ pairs = raw_slot_values.items()
877
+ elif isinstance(raw_slot_values, list):
878
+ pairs = enumerate(raw_slot_values)
879
+ else:
880
+ return {}
881
+ out: dict[str, t.Any] = {}
882
+ for k, v in pairs:
883
+ try:
884
+ idx = int(k)
885
+ except (TypeError, ValueError):
886
+ continue
887
+ if 0 <= idx < len(_ITEM_STAT_SLOT_NAMES) and v:
888
+ out[_ITEM_STAT_SLOT_NAMES[idx]] = v
843
889
  return out
844
890
 
845
891
  _PASCAL_SNAKE_RE_1 = re.compile(r"(.)([A-Z][a-z]+)")
@@ -852,6 +898,27 @@ def _pascal_to_snake(name: str) -> str:
852
898
  return _PASCAL_SNAKE_RE_2.sub(r"\1_\2", s).lower()
853
899
 
854
900
 
901
+ def _indexed_property_map(item_obj: t.Any, prop_name: str) -> dict[int, t.Any]:
902
+ """Read a multi-index property as ``{index: value}`` straight from the object.
903
+
904
+ ``_serialize_properties`` collapses a single-entry non-Byte property group
905
+ to a bare scalar, which destroys the slot index for indexed arrays such as
906
+ ``ItemStatValues`` / ``ItemColorID`` (an item with only one populated stat
907
+ slot would otherwise lose both the value and which slot it was). Reading the
908
+ raw ``Property`` list keeps every index, mirroring :func:`_colors`.
909
+ """
910
+ getter = getattr(item_obj, "get_properties_by_name", None)
911
+ if not callable(getter):
912
+ return {}
913
+ out: dict[int, t.Any] = {}
914
+ for prop in getter(prop_name):
915
+ idx = int(getattr(prop, "index", 0) or 0)
916
+ val = getattr(prop, "value", None)
917
+ if val is not None:
918
+ out[idx] = val
919
+ return out
920
+
921
+
855
922
  def _item_stats_dict(item_obj: t.Any, item_class: str = "") -> dict[str, t.Any]:
856
923
  """Surface every parseable property on an inventory item as snake_case.
857
924
 
@@ -880,6 +947,16 @@ def _item_stats_dict(item_obj: t.Any, item_class: str = "") -> dict[str, t.Any]:
880
947
  continue
881
948
  snake = _pascal_to_snake(name)
882
949
  pre[snake] = _normalize_stat_value(snake, value)
950
+ # ItemStatValues / ItemColorID are indexed UInt16 arrays. _serialize_properties
951
+ # collapses a single populated slot to a bare scalar (losing the index), so
952
+ # re-read them straight from the object to preserve {index: value}; a
953
+ # single-stat saddle/weapon would otherwise drop its only roll.
954
+ isv = _indexed_property_map(item_obj, "ItemStatValues")
955
+ if isv:
956
+ pre["item_stat_values"] = isv
957
+ color = _indexed_property_map(item_obj, "ItemColorID")
958
+ if color:
959
+ pre["item_color_id"] = color
883
960
  return _apply_stat_aliases(pre, item_class=item_class)
884
961
 
885
962
 
@@ -1231,6 +1308,38 @@ def _cryo_props_to_synthetic(
1231
1308
  return actor, status
1232
1309
 
1233
1310
 
1311
+ def _cryo_tamed_record(
1312
+ cryo: CryopodCreature,
1313
+ map_config: MapConfig | None,
1314
+ empty_lookup: dict[t.Any, t.Any],
1315
+ ) -> dict[str, t.Any]:
1316
+ """Decode one cryopod blob into a tamed record flagged ``cryo=True``."""
1317
+ actor, status = _cryo_props_to_synthetic(cryo)
1318
+ record = _tamed_dict(actor, status, empty_lookup, map_config)
1319
+ record["cryo"] = True
1320
+ return record
1321
+
1322
+
1323
+ def _append_unique_tame(
1324
+ out: list[dict[str, t.Any]],
1325
+ seen_ids: set[int],
1326
+ record: dict[str, t.Any],
1327
+ ) -> None:
1328
+ """Append ``record`` unless a tame with the same non-zero dino id is present.
1329
+
1330
+ Cluster cryopods can land in either ``ArkTamedDinosData`` or ``ArkItems``;
1331
+ a creature present in both would otherwise be emitted twice. Records whose
1332
+ dino id is 0 (unresolved, common on ASA partial decodes) are never deduped
1333
+ so distinct unidentified tames are all preserved.
1334
+ """
1335
+ did = _int(record.get("dinoid"))
1336
+ if did and did in seen_ids:
1337
+ return
1338
+ if did:
1339
+ seen_ids.add(did)
1340
+ out.append(record)
1341
+
1342
+
1234
1343
  def export_cluster_uploads(
1235
1344
  cluster_inventories: t.Iterable[CloudInventory],
1236
1345
  map_config: MapConfig | None = None,
@@ -1258,6 +1367,7 @@ def export_cluster_uploads(
1258
1367
  always stored in cryopods).
1259
1368
  """
1260
1369
  out: list[dict[str, t.Any]] = []
1370
+ seen_ids: set[int] = set()
1261
1371
  empty_lookup: dict[t.Any, t.Any] = {}
1262
1372
  for inv in cluster_inventories:
1263
1373
  my_ark_data = normalize_indexed_data(inv.get_property_value("MyArkData"))
@@ -1281,10 +1391,7 @@ def export_cluster_uploads(
1281
1391
  cryo = CryopodCreature.from_cryopod_bytes(byte_arr)
1282
1392
  if cryo is None:
1283
1393
  continue
1284
- actor, status = _cryo_props_to_synthetic(cryo)
1285
- record = _tamed_dict(actor, status, empty_lookup, map_config)
1286
- record["cryo"] = True
1287
- out.append(record)
1394
+ _append_unique_tame(out, seen_ids, _cryo_tamed_record(cryo, map_config, empty_lookup))
1288
1395
  # Cryopods uploaded as items (rare but present, especially in ASA
1289
1396
  # tribute transfers) embed a CryopodCreature in CustomItemDatas.
1290
1397
  for item in inv.uploaded_items:
@@ -1293,10 +1400,7 @@ def export_cluster_uploads(
1293
1400
  cryo = item.cryopod_creature
1294
1401
  if cryo is None:
1295
1402
  continue
1296
- actor, status = _cryo_props_to_synthetic(cryo)
1297
- record = _tamed_dict(actor, status, empty_lookup, map_config)
1298
- record["cryo"] = True
1299
- out.append(record)
1403
+ _append_unique_tame(out, seen_ids, _cryo_tamed_record(cryo, map_config, empty_lookup))
1300
1404
  return out
1301
1405
 
1302
1406
 
@@ -1349,12 +1453,14 @@ def _uploaded_item_dict(item: t.Any) -> dict[str, t.Any]:
1349
1453
  entry.update(_apply_stat_aliases(pre, item_class=class_name))
1350
1454
  upload_time = item.upload_time
1351
1455
  if upload_time:
1456
+ # Key name matches the legacy ASV item schema (uploadedTime), which
1457
+ # downstream consumers use as the "is uploaded" discriminator.
1352
1458
  try:
1353
- entry["upload_time"] = dt.datetime.fromtimestamp(
1459
+ entry["uploadedTime"] = dt.datetime.fromtimestamp(
1354
1460
  float(upload_time), tz=dt.timezone.utc
1355
1461
  ).isoformat()
1356
- except (OverflowError, OSError, ValueError):
1357
- entry["upload_time"] = upload_time
1462
+ except (OverflowError, OSError, ValueError, TypeError):
1463
+ entry["uploadedTime"] = upload_time
1358
1464
  return entry
1359
1465
 
1360
1466
 
@@ -1383,9 +1489,10 @@ def export_cluster_items(
1383
1489
  ) -> list[dict[str, t.Any]]:
1384
1490
  """Export every uploaded item across the supplied cloud inventories.
1385
1491
 
1386
- Each item carries its own snake_case ``stats`` subdict surfacing the
1387
- full ``ArkTributeItem`` payload (damage, durability, crafter info,
1388
- etc). Cryopod items also include ``dino_*`` keys with the embedded
1492
+ Each item carries its snake_case stats flattened in at the top level
1493
+ (``itemId``/``qty``/``blueprint`` plus ``armor``/``durability_max``/
1494
+ ``damage``/``rating``/``crafter`` etc) surfacing the full
1495
+ ``ArkTributeItem`` payload. Cryopod items also include ``dino_*`` keys with the embedded
1389
1496
  creature's identifying info; the dino's full stats record is emitted
1390
1497
  by :func:`export_cluster_uploads`.
1391
1498
  """
@@ -1409,8 +1516,8 @@ def export_cloud_inventory(
1409
1516
  - ``ASV_Tamed``: every dino in the file (both ``ArkTamedDinosData``
1410
1517
  entries and cryopod items in ``ArkItems``), shaped like a regular
1411
1518
  tamed record so consumers can reuse existing rendering.
1412
- - ``ASV_Items``: every uploaded item, snake_case ``stats`` subdict
1413
- included.
1519
+ - ``ASV_Items``: every uploaded item, snake_case stats flattened in
1520
+ at the top level (see :func:`export_cluster_items`).
1414
1521
 
1415
1522
  Useful for inspecting a single user's cluster transfer file in
1416
1523
  isolation, without scanning a whole cluster directory.
@@ -1623,9 +1730,11 @@ def _cluster_items_by_xuid(
1623
1730
  """Group uploaded items by cloud-file stem (= player's unique_id / xuid).
1624
1731
 
1625
1732
  Each cluster file is named after the owning player's Steam id (ASE) or
1626
- platform UUID (ASA). The file stem matches ``Profile.unique_id`` for
1627
- that player, giving a stable join key without needing to crack any
1628
- in-file ownership data.
1733
+ platform UUID (ASA). That same stem names the player's ``.arkprofile``,
1734
+ so :func:`export_players` joins on the profile's source filename stem
1735
+ (see there) — a stable key on both platforms without cracking any
1736
+ in-file ownership data. (``Profile.unique_id`` equals the stem only on
1737
+ ASE; on ASA it is the numeric net id, not the UUID filename.)
1629
1738
  """
1630
1739
  out: dict[str, list[dict[str, t.Any]]] = {}
1631
1740
  for inv in cluster_inventories:
@@ -1658,8 +1767,19 @@ def export_players(
1658
1767
  results: list[dict[str, t.Any]] = []
1659
1768
  for entry in profiles:
1660
1769
  record: dict[str, t.Any]
1770
+ # Keys to match against the cloud-file stem, in priority order.
1771
+ join_keys: list[str] = []
1661
1772
  if isinstance(entry, Profile):
1662
1773
  record = _player_from_profile(entry, save, pawn_status_by_id)
1774
+ # The cloud file and the .arkprofile for one player share a stem:
1775
+ # the Steam id on ASE, the hex platform UUID on ASA. The profile's
1776
+ # own source filename stem is therefore the reliable cross-platform
1777
+ # join key. ``unique_id`` only equals it on ASE (on ASA it is the
1778
+ # numeric net id, not the UUID filename), so it is a fallback.
1779
+ if entry.source_path is not None and entry.source_path.stem:
1780
+ join_keys.append(entry.source_path.stem)
1781
+ if entry.unique_id:
1782
+ join_keys.append(entry.unique_id)
1663
1783
  else:
1664
1784
  profile_obj = getattr(entry, "profile", None)
1665
1785
  if profile_obj is None and getattr(entry, "objects", None):
@@ -1673,17 +1793,19 @@ def export_players(
1673
1793
  status_obj = o
1674
1794
  break
1675
1795
  record = _player_from_object(profile_obj, status_obj, lookup, map_config, save)
1796
+ sid = record.get("steamid")
1797
+ if sid:
1798
+ join_keys.append(str(sid))
1676
1799
  # Splice cluster-uploaded items into the player's inventory list,
1677
1800
  # tagged ``uploaded: true`` so consumers can distinguish them from
1678
- # carried items. Matched by the cloud file's stem == profile's
1679
- # unique_id (Steam id / platform UUID).
1680
- steam_id = record.get("steamid") or ""
1681
- if steam_id and steam_id in cluster_items:
1801
+ # carried items.
1802
+ spliced = next((cluster_items[k] for k in join_keys if k in cluster_items), None)
1803
+ if spliced:
1682
1804
  inv_list = record.get("inventory")
1683
1805
  if not isinstance(inv_list, list):
1684
1806
  inv_list = []
1685
1807
  record["inventory"] = inv_list
1686
- inv_list.extend(cluster_items[steam_id])
1808
+ inv_list.extend(spliced)
1687
1809
  results.append(record)
1688
1810
  return results
1689
1811
 
@@ -1723,7 +1845,7 @@ def _tribe_members_from_parser(
1723
1845
  profile = profile_index.get(pid) if profile_index else None
1724
1846
  out.append({
1725
1847
  "ign": _str(m.get("name")),
1726
- "lvl": profile.level if profile is not None else "",
1848
+ "lvl": int(profile.level) if profile is not None else 0,
1727
1849
  "playerid": str(pid),
1728
1850
  "playername": _str(m.get("name")),
1729
1851
  "steamid": (profile.unique_id or "") if profile is not None else "",
@@ -1828,7 +1950,7 @@ def _tribe_from_object(
1828
1950
  profile = profile_index.get(pid_int) if profile_index else None
1829
1951
  members.append({
1830
1952
  "ign": name,
1831
- "lvl": profile.level if profile is not None else "",
1953
+ "lvl": int(profile.level) if profile is not None else 0,
1832
1954
  "playerid": str(pid_int),
1833
1955
  "playername": name,
1834
1956
  "steamid": (profile.unique_id or "") if profile is not None else "",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkparser
3
- Version: 0.4.0
3
+ Version: 0.4.2
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
@@ -410,11 +410,11 @@ data = export_all(save, map_config, cluster="path/to/cluster")
410
410
  # data["ASV_Tamed"] - now includes cluster cryopod tames (cryo=True)
411
411
  # data["ASV_Players"] - each player's inventory now contains their
412
412
  # uploaded items (entries tagged "uploaded": true),
413
- # matched by cloud-file stem (xuid) ==
414
- # Profile.unique_id.
413
+ # matched by cloud-file stem == the player's
414
+ # .arkprofile filename stem.
415
415
  ```
416
416
 
417
- Cluster items are spliced into the owning player's `inventory`; no separate `ASV_ClusterItems` file is emitted. The match works because every cluster file is named after the owning player's Steam id / platform UUID, which is the same value as `Profile.unique_id`.
417
+ Cluster items are spliced into the owning player's `inventory`; no separate `ASV_ClusterItems` file is emitted. The match works because every cluster file and the player's `.arkprofile` share a stem — the Steam id on ASE, the hex platform UUID on ASA — so the join is keyed on the profile's source filename stem. (`Profile.unique_id` equals that stem only on ASE; on ASA it is the numeric net id, not the UUID filename, so loading profiles from real file paths — which sets `Profile.source_path` — is required for the ASA splice.)
418
418
 
419
419
  Pre-loaded `CloudInventory` instances also work (`cluster=[inv1, inv2, ...]`).
420
420
 
@@ -436,7 +436,7 @@ data = export_cloud_inventory(cloud)
436
436
  # }
437
437
  ```
438
438
 
439
- Each `ASV_Items` entry carries `itemId`, `qty`, `blueprint`, `id` (combined ItemID1_ItemID2), `upload_time` (ISO 8601 with UTC offset), and item-class-specific stats (`durability_max`, `damage`, `armor`, `hypo`, `hyper`, `crafter`, `crafter_tribe`, `skill_bonus`, `loaded_ammo`, `quality`, `rating`, `c0`..`c5` paint regions, `drop_location`, `egg_*` for fertilized eggs, `dino_*` for cryopod items, etc). Default / unset fields are filtered, NaN floats dropped, `"Unknown"` crafter strings nulled.
439
+ Each `ASV_Items` entry carries `itemId`, `qty`, `blueprint`, `id` (combined ItemID1_ItemID2), `uploadedTime` (ISO 8601 with UTC offset), and item-class-specific stats (`durability_max`, `damage`, `armor`, `hypo`, `hyper`, `crafter`, `crafter_tribe`, `skill_bonus`, `loaded_ammo`, `quality`, `rating`, `c0`..`c5` paint regions, `drop_location`, `egg_*` for fertilized eggs, `dino_*` for cryopod items, etc). Default / unset fields are filtered, NaN floats dropped, `"Unknown"` crafter strings nulled.
440
440
 
441
441
  The low-level helpers are also available:
442
442
  - `export_cluster_uploads(cluster_invs, map_config=None) -> list[dict]` &mdash; tamed-shape records (incl. cryopod-as-item dinos) across the supplied cloud files
@@ -589,7 +589,7 @@ All four expose `from_*` constructors and `to_dict()` for serialization.
589
589
  |---|---|---|
590
590
  | `export_tamed(save, map_config=None)` | `list[dict]` | `ASV_Tamed` records |
591
591
  | `export_wild(save, map_config=None)` | `list[dict]` | `ASV_Wild` records |
592
- | `export_players(save, map_config=None, cluster_inventories=None)` | `list[dict]` | `ASV_Players` records (from Profile parsers). Pass `cluster_inventories` to splice each player's uploaded items into their `inventory` (entries tagged `uploaded: true`, matched by xuid stem == `Profile.unique_id`) |
592
+ | `export_players(save, map_config=None, cluster_inventories=None)` | `list[dict]` | `ASV_Players` records (from Profile parsers). Pass `cluster_inventories` to splice each player's uploaded items into their `inventory` (entries tagged `uploaded: true`, matched by cloud-file stem == the player's `.arkprofile` filename stem) |
593
593
  | `export_tribes(save)` | `list[dict]` | `ASV_Tribes` records |
594
594
  | `export_structures(save, map_config=None)` | `list[dict]` | `ASV_Structures` records |
595
595
  | `export_tribe_logs(save)` | `list[dict]` | `ASV_TribeLogs` records |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "arkparser"
7
- version = "0.4.0"
7
+ version = "0.4.2"
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,98 @@
1
+ """Tests for cloud-inventory export functions + inventory item stats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from arkparser import (
8
+ CloudInventory,
9
+ export_cloud_inventory,
10
+ export_cluster_items,
11
+ )
12
+ from arkparser.export import (
13
+ _apply_stat_aliases,
14
+ _combine_item_id,
15
+ _expand_stat_slots,
16
+ _pascal_to_snake,
17
+ _uploaded_item_dict,
18
+ )
19
+
20
+ # Top-level inventory-entry keys that intentionally keep legacy (camelCase)
21
+ # casing for ASV schema parity; everything else is snake_case.
22
+ _LEGACY_CAMEL_KEYS = {"itemId"}
23
+
24
+
25
+ def test_pascal_to_snake_basic() -> None:
26
+ assert _pascal_to_snake("ItemStatValues") == "item_stat_values"
27
+ assert _pascal_to_snake("CrafterCharacterName") == "crafter_character_name"
28
+ assert _pascal_to_snake("bIsBlueprint") == "b_is_blueprint"
29
+ assert _pascal_to_snake("ItemID") == "item_id"
30
+
31
+
32
+ def test_expand_stat_slots_dict_and_list_agree() -> None:
33
+ # Sparse dict (GameObject inventory path) and dense list (cloud path) must
34
+ # produce identical named slots. Regression: the list shape was dropped
35
+ # entirely because only dict was handled.
36
+ sparse = {1: 6378, 2: 7106, 5: 11736, 7: 12857}
37
+ dense = [0, 6378, 7106, 0, 0, 11736, 0, 12857]
38
+ expected = {
39
+ "armor": 6378,
40
+ "durability_max": 7106,
41
+ "hypo": 11736,
42
+ "hyper": 12857,
43
+ }
44
+ assert _expand_stat_slots(sparse) == expected
45
+ assert _expand_stat_slots(dense) == expected
46
+
47
+
48
+ def test_expand_stat_slots_single_slot_dict() -> None:
49
+ # A single populated slot must keep its index (regression: collapsed to a
50
+ # bare scalar upstream, which dropped the stat entirely).
51
+ assert _expand_stat_slots({2: 2624}) == {"durability_max": 2624}
52
+ # A bare scalar has no recoverable index -> ignored, no crash.
53
+ assert _expand_stat_slots(2624) == {}
54
+ assert _expand_stat_slots(None) == {}
55
+
56
+
57
+ def test_apply_stat_aliases_emits_slots_for_list_input() -> None:
58
+ out = _apply_stat_aliases({"item_stat_values": [0, 6378, 7106, 0, 0, 0, 0, 0]})
59
+ assert out["armor"] == 6378
60
+ assert out["durability_max"] == 7106
61
+
62
+
63
+ def test_combine_item_id_keeps_legit_zero() -> None:
64
+ # Falsy-zero trap: ItemID1 == 0 must not be replaced by an _0 variant key.
65
+ assert _combine_item_id({"ItemID1": 0, "ItemID1_0": 77, "ItemID2": 5}) == "0_5"
66
+ assert _combine_item_id({"ItemID1": 12, "ItemID2": 34}) == "12_34"
67
+ # Non-numeric / corrupt components return None instead of raising.
68
+ assert _combine_item_id({"ItemID1": "NaN", "ItemID2": 5}) is None
69
+ assert _combine_item_id("not-a-dict") is None
70
+
71
+
72
+ def test_export_cloud_inventory_shape(ase_obelisk_path: Path) -> None:
73
+ cloud = CloudInventory.load(ase_obelisk_path)
74
+ out = export_cloud_inventory(cloud)
75
+ assert set(out.keys()) == {"ASV_Tamed", "ASV_Items"}
76
+ assert isinstance(out["ASV_Tamed"], list)
77
+ assert isinstance(out["ASV_Items"], list)
78
+
79
+
80
+ def test_cloud_items_have_flat_stats(ase_obelisk_path: Path) -> None:
81
+ cloud = CloudInventory.load(ase_obelisk_path)
82
+ items = export_cluster_items([cloud])
83
+ assert items, "ASE obelisk fixture has uploaded items"
84
+ item = items[0]
85
+ assert "itemId" in item
86
+ assert "qty" in item
87
+ # Stat keys are snake_case; only the legacy top-level keys keep camelCase.
88
+ assert all(
89
+ k == k.lower() or k in _LEGACY_CAMEL_KEYS for k in item
90
+ ), f"unexpected non-snake_case key in {sorted(item)}"
91
+
92
+
93
+ def test_uploaded_item_dict_no_skipped_keys(ase_obelisk_path: Path) -> None:
94
+ cloud = CloudInventory.load(ase_obelisk_path)
95
+ item = cloud.uploaded_items[0]
96
+ entry = _uploaded_item_dict(item)
97
+ for skip in ("ItemQuantity", "bIsBlueprint", "CustomItemDatas"):
98
+ assert _pascal_to_snake(skip) not in entry
@@ -1,45 +0,0 @@
1
- """Tests for cloud-inventory export functions + inventory item stats."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
-
7
- from arkparser import (
8
- CloudInventory,
9
- export_cloud_inventory,
10
- export_cluster_items,
11
- )
12
- from arkparser.export import _pascal_to_snake, _uploaded_item_dict
13
-
14
-
15
- def test_pascal_to_snake_basic() -> None:
16
- assert _pascal_to_snake("ItemStatValues") == "item_stat_values"
17
- assert _pascal_to_snake("CrafterCharacterName") == "crafter_character_name"
18
- assert _pascal_to_snake("bIsBlueprint") == "b_is_blueprint"
19
- assert _pascal_to_snake("ItemID") == "item_id"
20
-
21
-
22
- def test_export_cloud_inventory_shape(ase_obelisk_path: Path) -> None:
23
- cloud = CloudInventory.load(ase_obelisk_path)
24
- out = export_cloud_inventory(cloud)
25
- assert set(out.keys()) == {"ASV_Tamed", "ASV_Items"}
26
- assert isinstance(out["ASV_Tamed"], list)
27
- assert isinstance(out["ASV_Items"], list)
28
-
29
-
30
- def test_cloud_items_have_flat_stats(ase_obelisk_path: Path) -> None:
31
- cloud = CloudInventory.load(ase_obelisk_path)
32
- items = export_cluster_items([cloud])
33
- assert items, "ASE obelisk fixture has uploaded items"
34
- item = items[0]
35
- assert "itemId" in item
36
- assert "qty" in item
37
- assert all(k == k.lower() for k in item), "all keys snake_case-ish"
38
-
39
-
40
- def test_uploaded_item_dict_no_skipped_keys(ase_obelisk_path: Path) -> None:
41
- cloud = CloudInventory.load(ase_obelisk_path)
42
- item = cloud.uploaded_items[0]
43
- entry = _uploaded_item_dict(item)
44
- for skip in ("ItemQuantity", "bIsBlueprint", "CustomItemDatas"):
45
- assert _pascal_to_snake(skip) not in entry
File without changes
File without changes
File without changes