arkparser 0.6.0__tar.gz → 0.7.1__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 (64) hide show
  1. {arkparser-0.6.0 → arkparser-0.7.1}/PKG-INFO +13 -4
  2. {arkparser-0.6.0 → arkparser-0.7.1}/README.md +12 -3
  3. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/__init__.py +1 -1
  4. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/common/types.py +11 -0
  5. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/data_models.py +5 -1
  6. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/export.py +122 -74
  7. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/files/world_save.py +5 -7
  8. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/game_objects/game_object.py +11 -1
  9. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser.egg-info/PKG-INFO +13 -4
  10. {arkparser-0.6.0 → arkparser-0.7.1}/pyproject.toml +1 -1
  11. arkparser-0.7.1/tests/test_export.py +177 -0
  12. arkparser-0.6.0/tests/test_export.py +0 -92
  13. {arkparser-0.6.0 → arkparser-0.7.1}/LICENSE +0 -0
  14. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/common/__init__.py +0 -0
  15. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/common/binary_reader.py +0 -0
  16. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/common/exceptions.py +0 -0
  17. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/common/map_config.py +0 -0
  18. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/common/normalization.py +0 -0
  19. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/common/version_detection.py +0 -0
  20. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/files/__init__.py +0 -0
  21. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/files/base.py +0 -0
  22. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/files/cloud_inventory.py +0 -0
  23. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/files/profile.py +0 -0
  24. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/files/tribe.py +0 -0
  25. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/game_objects/__init__.py +0 -0
  26. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/game_objects/container.py +0 -0
  27. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/game_objects/location.py +0 -0
  28. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/properties/__init__.py +0 -0
  29. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/properties/base.py +0 -0
  30. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/properties/byte_property.py +0 -0
  31. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/properties/compound.py +0 -0
  32. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/properties/primitives.py +0 -0
  33. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/properties/registry.py +0 -0
  34. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/structs/__init__.py +0 -0
  35. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/structs/base.py +0 -0
  36. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/structs/colors.py +0 -0
  37. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/structs/misc.py +0 -0
  38. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/structs/property_list.py +0 -0
  39. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/structs/registry.py +0 -0
  40. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/structs/vectors.py +0 -0
  41. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser.egg-info/SOURCES.txt +0 -0
  42. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser.egg-info/dependency_links.txt +0 -0
  43. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser.egg-info/requires.txt +0 -0
  44. {arkparser-0.6.0 → arkparser-0.7.1}/arkparser.egg-info/top_level.txt +0 -0
  45. {arkparser-0.6.0 → arkparser-0.7.1}/setup.cfg +0 -0
  46. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_asa_header_position.py +0 -0
  47. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_asa_name_table.py +0 -0
  48. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_ase_cluster_drift.py +0 -0
  49. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_binary_reader.py +0 -0
  50. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_binary_reader_layouts.py +0 -0
  51. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_cloud_export.py +0 -0
  52. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_cloud_inventory.py +0 -0
  53. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_cryopod_export.py +0 -0
  54. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_current_stats.py +0 -0
  55. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_data_models.py +0 -0
  56. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_game_objects.py +0 -0
  57. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_golden_exports.py +0 -0
  58. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_lazy_properties.py +0 -0
  59. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_profile.py +0 -0
  60. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_review_fixes.py +0 -0
  61. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_tribe.py +0 -0
  62. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_v13_property_layouts.py +0 -0
  63. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_version_detection.py +0 -0
  64. {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_world_save.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkparser
3
- Version: 0.6.0
3
+ Version: 0.7.1
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
@@ -314,7 +314,7 @@ ARK keeps player data in **two distinct places**, and the parser builds `ASV_Pla
314
314
 
315
315
  The export pipeline (`export_players`) loops over `save.profiles` (a list the caller assembles). For each entry:
316
316
 
317
- - If it's a `Profile` instance → `_player_from_profile` runs: fills core identity (name, gender, level, stats, tribe id, engram count, experience, active datetime from `LastLoginTime`). Leaves `lat`/`lon`/`ccc` as zero, `inventory` empty, pawn-state flags (`is_sleeping`, `is_dead`, `chibi_levels`, …) absent.
317
+ - If it's a `Profile` instance → `_player_from_profile` runs: fills core identity (name, gender, level, stats, tribe id, engram count, experience, active datetime from `LastLoginTime`). It joins `PlayerDataID == LinkedPlayerDataID` against the world save's player pawns, so when a live pawn exists `lat`/`lon`/`ccc` come from that pawn's `.location`; players with no live pawn (dead / logged out) keep `lat`/`lon`/`ccc` at zero. `inventory` is empty and pawn-state flags (`is_sleeping`, `is_dead`, `chibi_levels`, …) absent (the profile carries neither).
318
318
  - Otherwise it's treated as a wrapped `(profile, objects)` pair pointing at an in-world `PlayerPawn` → `_player_from_object` runs: fills location, inventory, pawn-state flags, body/hair cosmetics, death timestamps, and `active` from `SavedLastTimeHadController`.
319
319
 
320
320
  For the richest output, hand `export_players` **both**, assemble a wrapper for each player that carries the `Profile` (for offline identity) *and* the in-world pawn (when present) so the merged record gets identity + live state. The current validation script only passes `Profile` instances, which is why fields like `is_sleeping` / `body_colors` / `current_weapon` show up empty in the validation output even though the parser supports them.
@@ -336,7 +336,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
336
336
  | `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. |
337
337
  | `steamid`, `dataFile` | legacy | platform net id and `{steamid}.arkprofile` filename |
338
338
  | `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. |
339
- | `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. |
339
+ | `lat`, `lon`, `ccc` | legacy (now populated) | In-world position. Built directly from a `PlayerPawn` GameObject when one is passed, and for profile-sourced records the parser joins `PlayerDataID == LinkedPlayerDataID` to the world save's player pawns and pulls the matched pawn's `.location`. Records whose player has no live pawn (dead / logged out / cluster-only) keep the legacy `0` / `"0 0 0"` placeholders since there is no world position to report. |
340
340
  | `inventory` | legacy (now populated) | Items from the pawn's `MyInventoryComponent` when built from an in-world pawn. Empty list otherwise. |
341
341
  | `engram_points` | added | `TotalEngramPoints` |
342
342
  | `experience` | added | `ExperiencePoints` (status), integer |
@@ -365,7 +365,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
365
365
  | `members` | legacy | list of `{ign, lvl, playerid, playername, steamid}` built from the allocated players. `lvl` and `steamid` populate from the matching `.arkprofile`; members with no profile (back-filled from the tribe's member list) carry `lvl=0`, `steamid=""`. |
366
366
  | `tames`, `structures` | legacy | counts derived from `WorldSave` (creatures + structures whose `TargetingTeam` matches) |
367
367
  | `uploadedTames` | legacy | reserved (currently `0`) |
368
- | `active` | legacy | ISO 8601 datetime of the most recent tribe log entry, converted from in-game "Day N, HH:MM:SS" via the save's anchor. `null` when no parseable log entries or anchors are missing. |
368
+ | `active` | legacy | ISO 8601 datetime, legacy `ContentTribe.LastActive`: max of the tribe file's write time (`.arktribe` mtime; world-save mtime for in-save tribes) and the allocated members' last-active times (`LastLoginTime` via the save anchor), discarding future values. `null` when no past candidate exists. Tribe-log "Day N" stamps are game-calendar time and are deliberately not used. |
369
369
  | `dataFile` | legacy | `"{tribeid}.arktribe"` filename pattern. |
370
370
  | `owner_id` | added | `OwnerPlayerDataID` / parser `Tribe.owner_player_id` |
371
371
  | `owner_name` | added | `OwnerPlayerName` (object form only; parser-tribe form has no equivalent) |
@@ -681,6 +681,15 @@ Schema policy:
681
681
 
682
682
  ASA worldsave property layouts differ between **v13** (`TheIsland_WP` and older single-player saves, legacy `AsaSavegameToolkit`-style `dataSize + position + typeRef + byte` body) and **v14+** (current production ASA, marker-based body). Both are parsed by version-aware property readers; `WorldSave.version` is the source of truth.
683
683
 
684
+ ### Parse diagnostics (logging)
685
+
686
+ The parser logs through the standard `logging` module on the `arkparser.*` logger hierarchy. At DEBUG level it reports recoverable parse anomalies, for example ASE objects whose property block terminates early (the run of successfully parsed properties is kept and the remaining bytes are preserved on `GameObject.extra_data`):
687
+
688
+ ```python
689
+ import logging
690
+ logging.getLogger("arkparser").setLevel(logging.DEBUG)
691
+ ```
692
+
684
693
  ### Known limitation: ASA cryopod property blocks are partially decoded
685
694
 
686
695
  ASA cryopods (`PrimalItem_WeaponEmptyCryopod_C`, `SoulTrap`, `Vivarium`, `DinoBall`) store the embedded creature snapshot inside the item's `CustomItemDatas.CustomDataBytes.ByteArrays[0]` as a **zlib + custom-RLE compressed `AsaDataStore` blob**. The parser surfaces the snapshot via the simpler `CustomDataStrings` / `CustomDataFloats` accessors, exposing **species, tamed name, level, color regions, and `CurrentStatusValues[0..11]`** on each cryopodded creature.
@@ -280,7 +280,7 @@ ARK keeps player data in **two distinct places**, and the parser builds `ASV_Pla
280
280
 
281
281
  The export pipeline (`export_players`) loops over `save.profiles` (a list the caller assembles). For each entry:
282
282
 
283
- - If it's a `Profile` instance → `_player_from_profile` runs: fills core identity (name, gender, level, stats, tribe id, engram count, experience, active datetime from `LastLoginTime`). Leaves `lat`/`lon`/`ccc` as zero, `inventory` empty, pawn-state flags (`is_sleeping`, `is_dead`, `chibi_levels`, …) absent.
283
+ - If it's a `Profile` instance → `_player_from_profile` runs: fills core identity (name, gender, level, stats, tribe id, engram count, experience, active datetime from `LastLoginTime`). It joins `PlayerDataID == LinkedPlayerDataID` against the world save's player pawns, so when a live pawn exists `lat`/`lon`/`ccc` come from that pawn's `.location`; players with no live pawn (dead / logged out) keep `lat`/`lon`/`ccc` at zero. `inventory` is empty and pawn-state flags (`is_sleeping`, `is_dead`, `chibi_levels`, …) absent (the profile carries neither).
284
284
  - Otherwise it's treated as a wrapped `(profile, objects)` pair pointing at an in-world `PlayerPawn` → `_player_from_object` runs: fills location, inventory, pawn-state flags, body/hair cosmetics, death timestamps, and `active` from `SavedLastTimeHadController`.
285
285
 
286
286
  For the richest output, hand `export_players` **both**, assemble a wrapper for each player that carries the `Profile` (for offline identity) *and* the in-world pawn (when present) so the merged record gets identity + live state. The current validation script only passes `Profile` instances, which is why fields like `is_sleeping` / `body_colors` / `current_weapon` show up empty in the validation output even though the parser supports them.
@@ -302,7 +302,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
302
302
  | `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. |
303
303
  | `steamid`, `dataFile` | legacy | platform net id and `{steamid}.arkprofile` filename |
304
304
  | `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. |
305
- | `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. |
305
+ | `lat`, `lon`, `ccc` | legacy (now populated) | In-world position. Built directly from a `PlayerPawn` GameObject when one is passed, and for profile-sourced records the parser joins `PlayerDataID == LinkedPlayerDataID` to the world save's player pawns and pulls the matched pawn's `.location`. Records whose player has no live pawn (dead / logged out / cluster-only) keep the legacy `0` / `"0 0 0"` placeholders since there is no world position to report. |
306
306
  | `inventory` | legacy (now populated) | Items from the pawn's `MyInventoryComponent` when built from an in-world pawn. Empty list otherwise. |
307
307
  | `engram_points` | added | `TotalEngramPoints` |
308
308
  | `experience` | added | `ExperiencePoints` (status), integer |
@@ -331,7 +331,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
331
331
  | `members` | legacy | list of `{ign, lvl, playerid, playername, steamid}` built from the allocated players. `lvl` and `steamid` populate from the matching `.arkprofile`; members with no profile (back-filled from the tribe's member list) carry `lvl=0`, `steamid=""`. |
332
332
  | `tames`, `structures` | legacy | counts derived from `WorldSave` (creatures + structures whose `TargetingTeam` matches) |
333
333
  | `uploadedTames` | legacy | reserved (currently `0`) |
334
- | `active` | legacy | ISO 8601 datetime of the most recent tribe log entry, converted from in-game "Day N, HH:MM:SS" via the save's anchor. `null` when no parseable log entries or anchors are missing. |
334
+ | `active` | legacy | ISO 8601 datetime, legacy `ContentTribe.LastActive`: max of the tribe file's write time (`.arktribe` mtime; world-save mtime for in-save tribes) and the allocated members' last-active times (`LastLoginTime` via the save anchor), discarding future values. `null` when no past candidate exists. Tribe-log "Day N" stamps are game-calendar time and are deliberately not used. |
335
335
  | `dataFile` | legacy | `"{tribeid}.arktribe"` filename pattern. |
336
336
  | `owner_id` | added | `OwnerPlayerDataID` / parser `Tribe.owner_player_id` |
337
337
  | `owner_name` | added | `OwnerPlayerName` (object form only; parser-tribe form has no equivalent) |
@@ -647,6 +647,15 @@ Schema policy:
647
647
 
648
648
  ASA worldsave property layouts differ between **v13** (`TheIsland_WP` and older single-player saves, legacy `AsaSavegameToolkit`-style `dataSize + position + typeRef + byte` body) and **v14+** (current production ASA, marker-based body). Both are parsed by version-aware property readers; `WorldSave.version` is the source of truth.
649
649
 
650
+ ### Parse diagnostics (logging)
651
+
652
+ The parser logs through the standard `logging` module on the `arkparser.*` logger hierarchy. At DEBUG level it reports recoverable parse anomalies, for example ASE objects whose property block terminates early (the run of successfully parsed properties is kept and the remaining bytes are preserved on `GameObject.extra_data`):
653
+
654
+ ```python
655
+ import logging
656
+ logging.getLogger("arkparser").setLevel(logging.DEBUG)
657
+ ```
658
+
650
659
  ### Known limitation: ASA cryopod property blocks are partially decoded
651
660
 
652
661
  ASA cryopods (`PrimalItem_WeaponEmptyCryopod_C`, `SoulTrap`, `Vivarium`, `DinoBall`) store the embedded creature snapshot inside the item's `CustomItemDatas.CustomDataBytes.ByteArrays[0]` as a **zlib + custom-RLE compressed `AsaDataStore` blob**. The parser surfaces the snapshot via the simpler `CustomDataStrings` / `CustomDataFloats` accessors, exposing **species, tamed name, level, color regions, and `CurrentStatusValues[0..11]`** on each cryopodded creature.
@@ -95,4 +95,4 @@ __all__ = [
95
95
  "ArkParseError",
96
96
  ]
97
97
 
98
- __version__ = "0.6.0"
98
+ __version__ = "0.7.0"
@@ -21,6 +21,17 @@ from dataclasses import dataclass
21
21
  # The "None" name is used as a terminator in property lists
22
22
  NAME_NONE = "None"
23
23
 
24
+ # Class-name / blueprint fragments that mark a creature-storage item.
25
+ # Single source of truth for cryopod detection: in-world iteration
26
+ # (WorldSave.iter_cryopod_creatures), inventory filtering (export), and
27
+ # uploaded-item checks (UploadedItem.is_cryopod) all match against this.
28
+ CRYOPOD_CLASS_PATTERNS: tuple[str, ...] = (
29
+ "Cryopod",
30
+ "SoulTrap",
31
+ "Vivarium",
32
+ "DinoBall",
33
+ )
34
+
24
35
 
25
36
  # =============================================================================
26
37
  # ArkName (FName)
@@ -15,10 +15,14 @@ from dataclasses import dataclass, field
15
15
 
16
16
  from .common.binary_reader import BinaryReader
17
17
  from .common.normalization import normalize_indexed_data, normalize_indexed_list
18
+ from .common.types import CRYOPOD_CLASS_PATTERNS
18
19
  from .properties.registry import read_properties
19
20
 
20
21
  logger = logging.getLogger(__name__)
21
22
 
23
+ # Uploaded items match against lowercased blueprint paths.
24
+ _CRYOPOD_PATTERNS_LOWER: tuple[str, ...] = tuple(p.lower() for p in CRYOPOD_CLASS_PATTERNS)
25
+
22
26
  # Trailing UE actor spawn-instance suffix on cryopod class names, e.g.
23
27
  # "Raptor_Character_BP_C_2145673735". ARK stores the full instance name in the
24
28
  # cryopod CustomDataStrings/SoftClasses blob; the worldsave name-table path
@@ -820,7 +824,7 @@ class UploadedItem:
820
824
  def is_cryopod(self) -> bool:
821
825
  """Check if this item is a cryopod (or similar creature storage item)."""
822
826
  bp_lower = self.blueprint.lower()
823
- return any(x in bp_lower for x in ["cryopod", "soultrap", "vivarium", "dinoball"])
827
+ return any(x in bp_lower for x in _CRYOPOD_PATTERNS_LOWER)
824
828
 
825
829
  @property
826
830
  def cryopod_creature(self) -> CryopodCreature | None:
@@ -44,21 +44,14 @@ from pathlib import Path
44
44
  from arkparser.common.exceptions import ArkParseError
45
45
  from arkparser.common.map_config import MapConfig
46
46
  from arkparser.common.normalization import normalize_indexed_data, normalize_indexed_list
47
+ from arkparser.common.types import CRYOPOD_CLASS_PATTERNS
47
48
  from arkparser.data_models import CryopodCreature
48
49
  from arkparser.files import CloudInventory, Profile, Tribe
49
50
 
50
51
  logger = logging.getLogger(__name__)
51
52
 
52
- _CRYOPOD_CLASS_PATTERNS: tuple[str, ...] = (
53
- "Cryopod",
54
- "SoulTrap",
55
- "Vivarium",
56
- "DinoBall",
57
- )
58
-
59
-
60
53
  def _is_cryopod_class(class_name: str) -> bool:
61
- return any(p in class_name for p in _CRYOPOD_CLASS_PATTERNS)
54
+ return any(p in class_name for p in CRYOPOD_CLASS_PATTERNS)
62
55
 
63
56
 
64
57
  def _decode_inventory_cryopod(item_obj: t.Any) -> CryopodCreature | None:
@@ -152,6 +145,10 @@ _LEGACY_STAT_ORDER: tuple[str, ...] = (
152
145
  )
153
146
  _EXTRA_STAT_ORDER: tuple[str, ...] = ("torp", "water", "temp", "fort")
154
147
  _FLAT_STAT_ORDER: tuple[str, ...] = _LEGACY_STAT_ORDER + _EXTRA_STAT_ORDER
148
+ # The order tuples are hand-maintained views of _STAT_NAMES; catch drift at
149
+ # import time before it silently misaligns exported stat JSON.
150
+ assert len(_FLAT_STAT_ORDER) == len(_STAT_NAMES), "stat order tuples out of sync"
151
+ assert set(_FLAT_STAT_ORDER) == set(_STAT_NAMES), "stat order tuples out of sync"
155
152
 
156
153
 
157
154
  def _prop(obj: t.Any, name: str, default: t.Any = None, index: int | None = None) -> t.Any:
@@ -2058,6 +2055,8 @@ def _player_from_profile(
2058
2055
  profile: Profile,
2059
2056
  save: t.Any = None,
2060
2057
  pawn_status_by_id: dict[int, t.Any] | None = None,
2058
+ pawn_by_data_id: dict[int, t.Any] | None = None,
2059
+ map_config: MapConfig | None = None,
2061
2060
  ) -> dict[str, t.Any]:
2062
2061
  stat_points = [_int(profile.get_stat(i)["added"]) for i in range(12)]
2063
2062
  gamertag = profile.player_name or ""
@@ -2069,8 +2068,18 @@ def _player_from_profile(
2069
2068
  # not in the profile. Resolve via PlayerDataID -> pawn -> status. Absent
2070
2069
  # when the player has no spawned body (never spawned / corpse cleared).
2071
2070
  status = None
2072
- if pawn_status_by_id and profile.player_id:
2073
- status = pawn_status_by_id.get(int(profile.player_id))
2071
+ pawn = None
2072
+ if profile.player_id:
2073
+ pid = int(profile.player_id)
2074
+ if pawn_status_by_id:
2075
+ status = pawn_status_by_id.get(pid)
2076
+ if pawn_by_data_id:
2077
+ pawn = pawn_by_data_id.get(pid)
2078
+
2079
+ # The profile carries no world position. When the player has a live pawn in
2080
+ # the save, pull lat/lon/ccc from it; otherwise keep the legacy 0/0 zeroes
2081
+ # (dead / logged-out players have no location to report).
2082
+ gps = _gps_payload(pawn, map_config, ndigits=2) if pawn is not None else {"ccc": "0 0 0", "lat": 0.0, "lon": 0.0}
2074
2083
 
2075
2084
  out: dict[str, t.Any] = {
2076
2085
  "playerid": profile.player_id or 0,
@@ -2080,11 +2089,11 @@ def _player_from_profile(
2080
2089
  "tribe": profile.tribe_name or "",
2081
2090
  "sex": "Female" if profile.is_female is True else "Male",
2082
2091
  "lvl": profile.level,
2083
- "lat": 0.0,
2084
- "lon": 0.0,
2092
+ "lat": gps["lat"],
2093
+ "lon": gps["lon"],
2085
2094
  **_flat_stats(stat_points),
2086
2095
  "active": active_dt.isoformat() if active_dt is not None else None,
2087
- "ccc": "0 0 0",
2096
+ "ccc": gps["ccc"],
2088
2097
  "achievements": [],
2089
2098
  "inventory": [],
2090
2099
  "netAddress": _str(profile.last_net_address),
@@ -2168,16 +2177,25 @@ def _player_from_object(
2168
2177
  return _compact(data, LEGACY_PLAYER_KEYS)
2169
2178
 
2170
2179
 
2171
- def _player_status_by_data_id(save: t.Any, lookup: dict[t.Any, t.Any]) -> dict[int, t.Any]:
2172
- """Index ``MyCharacterStatusComponent`` per player by ``LinkedPlayerDataID``.
2180
+ def _player_status_by_data_id(
2181
+ save: t.Any,
2182
+ lookup: dict[t.Any, t.Any],
2183
+ ) -> tuple[dict[int, t.Any], dict[int, t.Any]]:
2184
+ """Index per-player pawn + status component by ``LinkedPlayerDataID``.
2173
2185
 
2174
2186
  Walks every ``PlayerPawnTest_*_C`` / ``PlayerCharacter_*`` in the world
2175
2187
  save, reads its ``LinkedPlayerDataID``, follows ``MyCharacterStatusComponent``
2176
- via ``lookup``, and stores the resolved status object. Lets profile-based
2177
- player exports surface live HP/stamina/food/etc. without legacy ASVPack
2178
- having to do the same join.
2188
+ via ``lookup``, and stores both the resolved status object and the pawn
2189
+ itself. The status index lets profile-based player exports surface live
2190
+ HP/stamina/food/etc.; the pawn index lets them surface live lat/lon/ccc
2191
+ (the pawn carries ``.location``, the profile does not), without legacy
2192
+ ASVPack having to do the same join.
2193
+
2194
+ Returns ``(status_by_id, pawn_by_id)``; a pawn appears in ``pawn_by_id``
2195
+ even when its status component fails to resolve.
2179
2196
  """
2180
- out: dict[int, t.Any] = {}
2197
+ status_out: dict[int, t.Any] = {}
2198
+ pawn_out: dict[int, t.Any] = {}
2181
2199
  objects = getattr(save, "objects", None) or []
2182
2200
  for obj in objects:
2183
2201
  cn = str(getattr(obj, "class_name", "") or "")
@@ -2186,10 +2204,11 @@ def _player_status_by_data_id(save: t.Any, lookup: dict[t.Any, t.Any]) -> dict[i
2186
2204
  pid = _int(_prop(obj, "LinkedPlayerDataID"))
2187
2205
  if not pid:
2188
2206
  continue
2207
+ pawn_out[pid] = obj
2189
2208
  status = _status_for(obj, lookup)
2190
2209
  if status is not None:
2191
- out[pid] = status
2192
- return out
2210
+ status_out[pid] = status
2211
+ return status_out, pawn_out
2193
2212
 
2194
2213
 
2195
2214
  def _cluster_items_by_xuid(
@@ -2226,6 +2245,7 @@ def _player_record_for(
2226
2245
  entry: t.Any,
2227
2246
  save: t.Any,
2228
2247
  pawn_status_by_id: dict[int, t.Any],
2248
+ pawn_by_data_id: dict[int, t.Any],
2229
2249
  lookup: dict[t.Any, t.Any],
2230
2250
  map_config: MapConfig | None,
2231
2251
  ) -> tuple[dict[str, t.Any] | None, list[str]]:
@@ -2237,7 +2257,7 @@ def _player_record_for(
2237
2257
  """
2238
2258
  join_keys: list[str] = []
2239
2259
  if isinstance(entry, Profile):
2240
- record = _player_from_profile(entry, save, pawn_status_by_id)
2260
+ record = _player_from_profile(entry, save, pawn_status_by_id, pawn_by_data_id, map_config)
2241
2261
  if entry.source_path is not None and entry.source_path.stem:
2242
2262
  join_keys.append(entry.source_path.stem)
2243
2263
  if entry.unique_id:
@@ -2312,7 +2332,7 @@ def _iter_players(
2312
2332
  """Yield ASV_Players records one at a time (streaming form of export_players)."""
2313
2333
  profiles = _collection(save, "profiles", Profile)
2314
2334
  lookup = _save_lookup(save)
2315
- pawn_status_by_id = _player_status_by_data_id(save, lookup)
2335
+ pawn_status_by_id, pawn_by_data_id = _player_status_by_data_id(save, lookup)
2316
2336
  cluster_items = _cluster_items_by_xuid(cluster_inventories, save) if cluster_inventories else {}
2317
2337
  # Legacy emits each player's tribeid as the CONTAINING tribe's id (the one
2318
2338
  # whose member list / team claims them), not the profile's own field. The
@@ -2321,7 +2341,9 @@ def _iter_players(
2321
2341
  profile_tribeid = allocation["profile_tribeid"]
2322
2342
  tribe_names = allocation["names"]
2323
2343
  for entry in profiles:
2324
- record, join_keys = _player_record_for(entry, save, pawn_status_by_id, lookup, map_config)
2344
+ record, join_keys = _player_record_for(
2345
+ entry, save, pawn_status_by_id, pawn_by_data_id, lookup, map_config
2346
+ )
2325
2347
  if record is None:
2326
2348
  continue
2327
2349
  if isinstance(entry, Profile):
@@ -2388,45 +2410,67 @@ def _tribe_members_from_parser(
2388
2410
  return out
2389
2411
 
2390
2412
 
2391
- def _tribe_active_iso(logs: t.Iterable[str], save: t.Any) -> str | None:
2392
- """Derive ``active`` ISO datetime from the most recent tribe log entry.
2393
-
2394
- Tribe files don't carry an "active" timestamp directly. Each log entry
2395
- is formatted ``Day N, HH:MM:SS: <message>`` (game-time). The most recent
2396
- entry yields the highest game-second count, which combined with the
2397
- save's anchor (``file_mtime + (max_log_seconds - game_time)``) gives a
2398
- real wall-clock timestamp.
2413
+ def _tribe_file_date(tribe: t.Any, save: t.Any) -> dt.datetime | None:
2414
+ """Wall-clock write time of a tribe's backing file (legacy TribeFileDate).
2399
2415
 
2400
- Returns ``None`` when no parseable log entry is found or the save lacks
2401
- conversion anchors.
2416
+ File-backed tribes use the .arktribe mtime (ContentContainer.cs:1812);
2417
+ tribes parsed out of the world save fall back to the save's own mtime
2418
+ (ContentContainer.cs:734, GameSaveTime).
2402
2419
  """
2403
- max_seconds = 0
2404
- for raw in logs:
2405
- if not isinstance(raw, str):
2406
- continue
2407
- m = _LOG_RE.match(raw.strip())
2408
- if not m:
2409
- continue
2420
+ src = getattr(tribe, "source_path", None) if tribe is not None else None
2421
+ if src is not None:
2410
2422
  try:
2411
- day = int(m.group(1))
2412
- except (TypeError, ValueError):
2413
- continue
2414
- parts = m.group(2).split(":")
2415
- if len(parts) < 2:
2416
- continue
2417
- try:
2418
- h = int(parts[0])
2419
- mi = int(parts[1])
2420
- s = int(parts[2]) if len(parts) > 2 else 0
2421
- except (TypeError, ValueError):
2422
- continue
2423
- sec = day * 86400 + h * 3600 + mi * 60 + s
2424
- if sec > max_seconds:
2425
- max_seconds = sec
2426
- if not max_seconds:
2423
+ local_tz = dt.datetime.now().astimezone().tzinfo
2424
+ return dt.datetime.fromtimestamp(src.stat().st_mtime, tz=local_tz)
2425
+ except OSError:
2426
+ logger.debug("stat failed for tribe file %s", src)
2427
+ return getattr(save, "file_mtime", None) if save is not None else None
2428
+
2429
+
2430
+ def _tribe_active_iso(
2431
+ file_date: t.Any,
2432
+ member_ids: t.Iterable[int],
2433
+ profile_active: dict[int, dt.datetime],
2434
+ ) -> str | None:
2435
+ """Legacy ContentTribe.LastActive (ContentTribe.cs:35-49): the max of the
2436
+ tribe file date and the members' last-active datetimes, discarding values
2437
+ in the future. Tribe-log "Day N" stamps are game-calendar time, not real
2438
+ seconds, so they cannot be converted with the save anchor and are unused.
2439
+
2440
+ Returns ``None`` when no past candidate exists.
2441
+ """
2442
+ assert isinstance(profile_active, dict), "profile_active must be a dict"
2443
+ candidates: list[dt.datetime] = []
2444
+ if isinstance(file_date, dt.datetime):
2445
+ candidates.append(file_date if file_date.tzinfo else file_date.astimezone())
2446
+ for i, pid in enumerate(member_ids):
2447
+ assert i < _MAX_TRIBE_MEMBERS, "tribe member list exceeded bound"
2448
+ active = profile_active.get(_int(pid))
2449
+ if active is not None:
2450
+ candidates.append(active if active.tzinfo else active.astimezone())
2451
+ now = dt.datetime.now(dt.timezone.utc)
2452
+ past = [c for c in candidates if c <= now]
2453
+ if not past:
2427
2454
  return None
2428
- real = _approx_real_datetime(max_seconds, save)
2429
- return real.isoformat() if real is not None else None
2455
+ return max(past).isoformat()
2456
+
2457
+
2458
+ def _profile_actives(profiles: list[Profile], save: t.Any) -> dict[int, dt.datetime]:
2459
+ """``player_id -> last-active datetime`` for tribe activity rollups.
2460
+
2461
+ Mirrors legacy ContentPlayer.LastActiveDateTime: the profile's
2462
+ LastLoginTime converted through the save anchor.
2463
+ """
2464
+ assert isinstance(profiles, list), "profiles must be a list"
2465
+ out: dict[int, dt.datetime] = {}
2466
+ for prof in profiles:
2467
+ pid = _int(prof.player_id)
2468
+ if not pid:
2469
+ continue
2470
+ active = _approx_real_datetime(prof.last_login_time, save)
2471
+ if active is not None:
2472
+ out[pid] = active
2473
+ return out
2430
2474
 
2431
2475
 
2432
2476
  def _build_profile_index(save: t.Any) -> dict[int, Profile]:
@@ -2455,7 +2499,7 @@ def _tribe_from_parser(
2455
2499
  "tames": c.get("tames", 0),
2456
2500
  "uploadedTames": 0,
2457
2501
  "structures": c.get("structures", 0),
2458
- "active": _tribe_active_iso(tribe.log_entries, save),
2502
+ "active": _tribe_file_date(tribe, save),
2459
2503
  "dataFile": f"{tid}.arktribe" if tid else "",
2460
2504
  "owner_id": tribe.owner_player_id or 0,
2461
2505
  "alliance_ids": tribe.alliance_ids,
@@ -2472,8 +2516,7 @@ def _tribe_from_object(
2472
2516
  tribe_id = _int(_prop(obj, "TribeID")) or _int(_prop(obj, "TribeId"))
2473
2517
  owner_id = _int(_prop(obj, "OwnerPlayerDataID")) or _int(_prop(obj, "OwnerPlayerDataId"))
2474
2518
  members: list[dict[str, t.Any]] = []
2475
- i = 0
2476
- while True:
2519
+ for i in range(_MAX_TRIBE_MEMBERS):
2477
2520
  pid = _prop(obj, "MembersPlayerDataID", index=i)
2478
2521
  if pid is None:
2479
2522
  break
@@ -2489,15 +2532,14 @@ def _tribe_from_object(
2489
2532
  "steamid": (profile.unique_id or "") if profile is not None else "",
2490
2533
  }
2491
2534
  )
2492
- i += 1
2535
+ assert len(members) < _MAX_TRIBE_MEMBERS, "tribe member list exceeded bound"
2493
2536
  alliances: list[int] = []
2494
- j = 0
2495
- while True:
2537
+ for j in range(_MAX_TRIBE_MEMBERS):
2496
2538
  val = _prop(obj, "TribeAlliances", index=j)
2497
2539
  if val is None:
2498
2540
  break
2499
2541
  alliances.append(_int(val))
2500
- j += 1
2542
+ assert len(alliances) < _MAX_TRIBE_MEMBERS, "tribe alliance list exceeded bound"
2501
2543
  c = counts.get(tribe_id, {})
2502
2544
  data: dict[str, t.Any] = {
2503
2545
  "tribeid": tribe_id,
@@ -2507,7 +2549,7 @@ def _tribe_from_object(
2507
2549
  "tames": c.get("tames", 0),
2508
2550
  "uploadedTames": 0,
2509
2551
  "structures": c.get("structures", 0),
2510
- "active": _tribe_active_iso(_tribe_object_logs(obj), save),
2552
+ "active": _tribe_file_date(None, save),
2511
2553
  "dataFile": f"{tribe_id}.arktribe" if tribe_id else "",
2512
2554
  "owner_id": owner_id,
2513
2555
  "owner_name": _str(_prop(obj, "OwnerPlayerName")),
@@ -2522,14 +2564,15 @@ def _tribe_object_logs(obj: t.Any) -> list[str]:
2522
2564
  if isinstance(log_val, list):
2523
2565
  return [e for e in log_val if isinstance(e, str) and e.strip()]
2524
2566
  out: list[str] = []
2525
- k = 0
2526
- while True:
2567
+ entries = 0
2568
+ for k in range(_MAX_TRIBE_MEMBERS):
2527
2569
  v = _prop(obj, "TribeLog", index=k)
2528
2570
  if v is None:
2529
2571
  break
2572
+ entries += 1
2530
2573
  if isinstance(v, str) and v.strip():
2531
2574
  out.append(v)
2532
- k += 1
2575
+ assert entries < _MAX_TRIBE_MEMBERS, "tribe log list exceeded bound"
2533
2576
  return out
2534
2577
 
2535
2578
 
@@ -2725,6 +2768,7 @@ def _assemble_tribes(save: t.Any) -> dict[str, t.Any]:
2725
2768
  member_name.setdefault(pid, mname)
2726
2769
  file_tribe_ids = set(rich) | {_UNCLAIMED_TRIBE_ID, _ABANDONED_TRIBE_ID}
2727
2770
  profiles = _profile_entries(save)
2771
+ profile_active = _profile_actives(profiles, save)
2728
2772
  profile_tribeid, players_by_tribe = _allocate_profiles(profiles, file_tribe_ids, member_index)
2729
2773
  for prof in profiles:
2730
2774
  pid = _int(prof.player_id)
@@ -2750,6 +2794,7 @@ def _assemble_tribes(save: t.Any) -> dict[str, t.Any]:
2750
2794
  "logs": logs,
2751
2795
  "counts": counts,
2752
2796
  "profile_index": profile_index,
2797
+ "profile_active": profile_active,
2753
2798
  "players_by_tribe": players_by_tribe,
2754
2799
  "profile_tribeid": profile_tribeid,
2755
2800
  "member_stubs": member_stubs,
@@ -2796,10 +2841,12 @@ def _members_for(tid: int, a: dict[str, t.Any]) -> list[dict[str, t.Any]]:
2796
2841
  def _tribe_record(tid: int, a: dict[str, t.Any]) -> dict[str, t.Any]:
2797
2842
  """Final ASV_Tribes record for one assembled tribe id.
2798
2843
 
2799
- File-backed tribes reuse the rich record (active/dataFile/owner/alliances)
2800
- but have ``players``/``members`` overridden to the allocated-player set so
2844
+ File-backed tribes reuse the rich record (dataFile/owner/alliances) but
2845
+ have ``players``/``members`` overridden to the allocated-player set so
2801
2846
  they match the legacy writer (which counts allocated profiles, not the raw
2802
2847
  member list). Synthesized stubs carry id + name + world-derived counts.
2848
+ ``active`` finalizes here for both shapes: max of the tribe file date and
2849
+ the allocated members' last-active times (legacy ContentTribe.LastActive).
2803
2850
  """
2804
2851
  players = a["players_by_tribe"].get(tid, [])
2805
2852
  base = a["rich"].get(tid)
@@ -2807,6 +2854,7 @@ def _tribe_record(tid: int, a: dict[str, t.Any]) -> dict[str, t.Any]:
2807
2854
  rec = dict(base)
2808
2855
  rec["players"] = len(players)
2809
2856
  rec["members"] = _members_for(tid, a)
2857
+ rec["active"] = _tribe_active_iso(rec.get("active"), players, a["profile_active"])
2810
2858
  return rec
2811
2859
  c = a["counts"].get(tid, {})
2812
2860
  data: dict[str, t.Any] = {
@@ -2817,7 +2865,7 @@ def _tribe_record(tid: int, a: dict[str, t.Any]) -> dict[str, t.Any]:
2817
2865
  "tames": c.get("tames", 0),
2818
2866
  "uploadedTames": 0,
2819
2867
  "structures": c.get("structures", 0),
2820
- "active": None,
2868
+ "active": _tribe_active_iso(None, players, a["profile_active"]),
2821
2869
  "dataFile": "",
2822
2870
  }
2823
2871
  return _compact(data, LEGACY_TRIBE_KEYS)
@@ -34,6 +34,7 @@ from uuid import UUID
34
34
  from ..common.binary_reader import BinaryReader, guid_str_le
35
35
  from ..common.exceptions import ArkParseError, CorruptDataError
36
36
  from ..common.normalization import normalize_indexed_data, normalize_indexed_list
37
+ from ..common.types import CRYOPOD_CLASS_PATTERNS
37
38
  from ..data_models import CryopodCreature
38
39
  from ..game_objects.container import GameObjectContainer
39
40
  from ..game_objects.game_object import MAX_OBJECT_COUNT, GameObject
@@ -428,13 +429,10 @@ class WorldSave:
428
429
  """Return creature nest objects (wyvern, drake, etc.)."""
429
430
  return self.container.get_nests()
430
431
 
431
- # Class-name fragments that indicate a creature-storage item. Mirrors
432
- # data_models.UploadedItem.is_cryopod (which matches against blueprint
433
- # paths); the in-world version matches against the GameObject's
434
- # ``class_name`` directly.
435
- _CRYOPOD_PATTERNS: t.ClassVar[tuple[str, ...]] = (
436
- "Cryopod", "SoulTrap", "Vivarium", "DinoBall",
437
- )
432
+ # Shared with data_models.UploadedItem.is_cryopod (blueprint paths) and
433
+ # the export inventory filter; the in-world version matches against the
434
+ # GameObject's ``class_name`` directly.
435
+ _CRYOPOD_PATTERNS: t.ClassVar[tuple[str, ...]] = CRYOPOD_CLASS_PATTERNS
438
436
 
439
437
  def iter_cryopod_creatures(self) -> t.Iterator[tuple[GameObject, CryopodCreature]]:
440
438
  """Yield ``(item_obj, CryopodCreature)`` for every *filled* cryopod
@@ -7,6 +7,7 @@ Game objects represent creatures, items, structures, players, and other entities
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import logging
10
11
  import re
11
12
  import typing as t
12
13
  from collections import defaultdict
@@ -16,6 +17,8 @@ from ..common.exceptions import CorruptDataError
16
17
  from ..properties.registry import read_properties, read_property
17
18
  from .location import LocationData
18
19
 
20
+ logger = logging.getLogger(__name__)
21
+
19
22
  if t.TYPE_CHECKING:
20
23
  from ..common.binary_reader import BinaryReader
21
24
  from ..properties.base import Property
@@ -420,11 +423,18 @@ class GameObject:
420
423
  if prop is None:
421
424
  break
422
425
  properties.append(prop)
423
- except Exception:
426
+ except Exception as exc:
424
427
  self.properties = properties
425
428
  self._props_loaded = True
426
429
  if next_object is None:
427
430
  raise
431
+ logger.debug(
432
+ "property parse stopped early for %s after %d properties, "
433
+ "remainder kept as extra_data: %s",
434
+ self.class_name,
435
+ len(properties),
436
+ exc,
437
+ )
428
438
 
429
439
  next_offset = properties_block_offset + next_object.properties_offset
430
440
  remaining = next_offset - reader.position
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkparser
3
- Version: 0.6.0
3
+ Version: 0.7.1
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
@@ -314,7 +314,7 @@ ARK keeps player data in **two distinct places**, and the parser builds `ASV_Pla
314
314
 
315
315
  The export pipeline (`export_players`) loops over `save.profiles` (a list the caller assembles). For each entry:
316
316
 
317
- - If it's a `Profile` instance → `_player_from_profile` runs: fills core identity (name, gender, level, stats, tribe id, engram count, experience, active datetime from `LastLoginTime`). Leaves `lat`/`lon`/`ccc` as zero, `inventory` empty, pawn-state flags (`is_sleeping`, `is_dead`, `chibi_levels`, …) absent.
317
+ - If it's a `Profile` instance → `_player_from_profile` runs: fills core identity (name, gender, level, stats, tribe id, engram count, experience, active datetime from `LastLoginTime`). It joins `PlayerDataID == LinkedPlayerDataID` against the world save's player pawns, so when a live pawn exists `lat`/`lon`/`ccc` come from that pawn's `.location`; players with no live pawn (dead / logged out) keep `lat`/`lon`/`ccc` at zero. `inventory` is empty and pawn-state flags (`is_sleeping`, `is_dead`, `chibi_levels`, …) absent (the profile carries neither).
318
318
  - Otherwise it's treated as a wrapped `(profile, objects)` pair pointing at an in-world `PlayerPawn` → `_player_from_object` runs: fills location, inventory, pawn-state flags, body/hair cosmetics, death timestamps, and `active` from `SavedLastTimeHadController`.
319
319
 
320
320
  For the richest output, hand `export_players` **both**, assemble a wrapper for each player that carries the `Profile` (for offline identity) *and* the in-world pawn (when present) so the merged record gets identity + live state. The current validation script only passes `Profile` instances, which is why fields like `is_sleeping` / `body_colors` / `current_weapon` show up empty in the validation output even though the parser supports them.
@@ -336,7 +336,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
336
336
  | `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. |
337
337
  | `steamid`, `dataFile` | legacy | platform net id and `{steamid}.arkprofile` filename |
338
338
  | `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. |
339
- | `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. |
339
+ | `lat`, `lon`, `ccc` | legacy (now populated) | In-world position. Built directly from a `PlayerPawn` GameObject when one is passed, and for profile-sourced records the parser joins `PlayerDataID == LinkedPlayerDataID` to the world save's player pawns and pulls the matched pawn's `.location`. Records whose player has no live pawn (dead / logged out / cluster-only) keep the legacy `0` / `"0 0 0"` placeholders since there is no world position to report. |
340
340
  | `inventory` | legacy (now populated) | Items from the pawn's `MyInventoryComponent` when built from an in-world pawn. Empty list otherwise. |
341
341
  | `engram_points` | added | `TotalEngramPoints` |
342
342
  | `experience` | added | `ExperiencePoints` (status), integer |
@@ -365,7 +365,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
365
365
  | `members` | legacy | list of `{ign, lvl, playerid, playername, steamid}` built from the allocated players. `lvl` and `steamid` populate from the matching `.arkprofile`; members with no profile (back-filled from the tribe's member list) carry `lvl=0`, `steamid=""`. |
366
366
  | `tames`, `structures` | legacy | counts derived from `WorldSave` (creatures + structures whose `TargetingTeam` matches) |
367
367
  | `uploadedTames` | legacy | reserved (currently `0`) |
368
- | `active` | legacy | ISO 8601 datetime of the most recent tribe log entry, converted from in-game "Day N, HH:MM:SS" via the save's anchor. `null` when no parseable log entries or anchors are missing. |
368
+ | `active` | legacy | ISO 8601 datetime, legacy `ContentTribe.LastActive`: max of the tribe file's write time (`.arktribe` mtime; world-save mtime for in-save tribes) and the allocated members' last-active times (`LastLoginTime` via the save anchor), discarding future values. `null` when no past candidate exists. Tribe-log "Day N" stamps are game-calendar time and are deliberately not used. |
369
369
  | `dataFile` | legacy | `"{tribeid}.arktribe"` filename pattern. |
370
370
  | `owner_id` | added | `OwnerPlayerDataID` / parser `Tribe.owner_player_id` |
371
371
  | `owner_name` | added | `OwnerPlayerName` (object form only; parser-tribe form has no equivalent) |
@@ -681,6 +681,15 @@ Schema policy:
681
681
 
682
682
  ASA worldsave property layouts differ between **v13** (`TheIsland_WP` and older single-player saves, legacy `AsaSavegameToolkit`-style `dataSize + position + typeRef + byte` body) and **v14+** (current production ASA, marker-based body). Both are parsed by version-aware property readers; `WorldSave.version` is the source of truth.
683
683
 
684
+ ### Parse diagnostics (logging)
685
+
686
+ The parser logs through the standard `logging` module on the `arkparser.*` logger hierarchy. At DEBUG level it reports recoverable parse anomalies, for example ASE objects whose property block terminates early (the run of successfully parsed properties is kept and the remaining bytes are preserved on `GameObject.extra_data`):
687
+
688
+ ```python
689
+ import logging
690
+ logging.getLogger("arkparser").setLevel(logging.DEBUG)
691
+ ```
692
+
684
693
  ### Known limitation: ASA cryopod property blocks are partially decoded
685
694
 
686
695
  ASA cryopods (`PrimalItem_WeaponEmptyCryopod_C`, `SoulTrap`, `Vivarium`, `DinoBall`) store the embedded creature snapshot inside the item's `CustomItemDatas.CustomDataBytes.ByteArrays[0]` as a **zlib + custom-RLE compressed `AsaDataStore` blob**. The parser surfaces the snapshot via the simpler `CustomDataStrings` / `CustomDataFloats` accessors, exposing **species, tamed name, level, color regions, and `CurrentStatusValues[0..11]`** on each cryopodded creature.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "arkparser"
7
- version = "0.6.0"
7
+ version = "0.7.1"
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,177 @@
1
+ """Regression tests for export helpers against real parser objects."""
2
+
3
+ import datetime as dt
4
+ import os
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from arkparser import Profile, Tribe, WorldSave
11
+ from arkparser.export import (
12
+ export_all,
13
+ export_players,
14
+ export_structures,
15
+ export_tamed,
16
+ export_to_files,
17
+ export_tribe_logs,
18
+ export_tribes,
19
+ export_wild,
20
+ )
21
+
22
+ _EXAMPLES = Path(__file__).parent.parent / "references" / "examples"
23
+ _ASE_SCORCHED_EARTH = _EXAMPLES / "ase" / "maps" / "scorchedearth" / "ScorchedEarth_P.ark"
24
+ _MAP_DUMP_EXTINCTION = Path(__file__).parent.parent / "references" / "map_dumps" / "evolved" / "extinction"
25
+
26
+
27
+ def _first_dump_file(pattern: str) -> Path:
28
+ """First map-dump fixture matching ``pattern``, else skip the test."""
29
+ if _MAP_DUMP_EXTINCTION.is_dir():
30
+ files = sorted(_MAP_DUMP_EXTINCTION.glob(pattern))
31
+ if files:
32
+ return files[0]
33
+ pytest.skip(f"no {pattern} fixture under {_MAP_DUMP_EXTINCTION}")
34
+
35
+
36
+ def _profile_with_last_login() -> Profile:
37
+ """First map-dump profile carrying LastLoginTime, else skip the test."""
38
+ if _MAP_DUMP_EXTINCTION.is_dir():
39
+ for path in sorted(_MAP_DUMP_EXTINCTION.glob("*.arkprofile"))[:50]:
40
+ try:
41
+ profile = Profile.load(path)
42
+ except Exception: # noqa: BLE001 - corrupt fixture must not abort
43
+ continue
44
+ if profile.last_login_time and profile.player_id:
45
+ return profile
46
+ pytest.skip(f"no profile with LastLoginTime under {_MAP_DUMP_EXTINCTION}")
47
+
48
+ _ASV_NAMES = {
49
+ "ASV_Tamed",
50
+ "ASV_Wild",
51
+ "ASV_Players",
52
+ "ASV_Tribes",
53
+ "ASV_Structures",
54
+ "ASV_TribeLogs",
55
+ "ASV_MapStructures",
56
+ }
57
+
58
+
59
+ @pytest.fixture(scope="session")
60
+ def ase_export_world_save() -> WorldSave:
61
+ if not _ASE_SCORCHED_EARTH.exists():
62
+ pytest.skip(f"Fixture not available: {_ASE_SCORCHED_EARTH}")
63
+ return WorldSave.load(_ASE_SCORCHED_EARTH)
64
+
65
+
66
+ def test_export_tamed_matches_world_save(ase_export_world_save: WorldSave) -> None:
67
+ exported = export_tamed(ase_export_world_save)
68
+ assert len(exported) == len(ase_export_world_save.get_tamed_creatures())
69
+ assert any(creature.get("name") == "escobar" for creature in exported)
70
+
71
+
72
+ def test_export_wild_matches_world_save(ase_export_world_save: WorldSave) -> None:
73
+ exported = export_wild(ase_export_world_save)
74
+ assert len(exported) == len(ase_export_world_save.get_wild_creatures())
75
+ assert all(creature.get("tamer", "") == "" for creature in exported[:100])
76
+
77
+
78
+ def test_export_structures_matches_world_save(ase_export_world_save: WorldSave) -> None:
79
+ exported = export_structures(ase_export_world_save)
80
+ assert len(exported) == len(ase_export_world_save.get_structures())
81
+
82
+
83
+ def test_export_players_uses_profile_parser(ase_profile_path: Path) -> None:
84
+ profile = Profile.load(ase_profile_path)
85
+ exported = export_players(type("Holder", (), {"profiles": [profile]})())
86
+ assert exported[0]["playerid"] == profile.player_id
87
+ assert exported[0]["steam"] == profile.player_name
88
+ assert exported[0]["name"] == profile.character_name
89
+ assert exported[0]["sex"] == ("Female" if profile.is_female else "Male")
90
+ assert exported[0]["tribeid"] == profile.tribe_id
91
+
92
+
93
+ def test_export_tribes_uses_tribe_parser(ase_tribe_path: Path) -> None:
94
+ tribe = Tribe.load(ase_tribe_path)
95
+ exported = export_tribes(type("Holder", (), {"tribes": [tribe]})())
96
+ assert exported[0]["tribeid"] == tribe.tribe_id
97
+ assert exported[0]["tribe"] == tribe.name
98
+ assert exported[0]["players"] == tribe.member_count
99
+
100
+
101
+ def test_export_tribes_active_is_file_mtime(tmp_path: Path) -> None:
102
+ """Tribe ``active`` mirrors legacy ContentTribe.LastActive: the .arktribe
103
+ file's write time, never a value derived from in-game log day numbers
104
+ (those use the game calendar, not real seconds, and produced far-future
105
+ dates)."""
106
+ tribe_path = _first_dump_file("*.arktribe")
107
+ copy = tmp_path / tribe_path.name
108
+ shutil.copy2(tribe_path, copy)
109
+ local_tz = dt.datetime.now().astimezone().tzinfo
110
+ stamp = dt.datetime(2024, 5, 1, 12, 0, 0, tzinfo=local_tz)
111
+ os.utime(copy, (stamp.timestamp(), stamp.timestamp()))
112
+ tribe = Tribe.load(copy)
113
+ now = dt.datetime.now(tz=local_tz)
114
+ holder = type("Holder", (), {"tribes": [tribe], "file_mtime": now, "game_time": 5_000.0})()
115
+ exported = export_tribes(holder)
116
+ rec = next(r for r in exported if r["tribeid"] == tribe.tribe_id)
117
+ assert rec["active"] is not None
118
+ active = dt.datetime.fromisoformat(rec["active"])
119
+ assert active.timestamp() == pytest.approx(stamp.timestamp(), abs=2)
120
+ assert active <= now
121
+
122
+
123
+ def test_export_tribes_solo_active_from_profile_last_login() -> None:
124
+ """A profile-only (solo) tribe gets ``active`` from the member's
125
+ LastLoginTime converted through the save anchor, matching legacy
126
+ ContentTribe.LastActive over allocated players."""
127
+ profile = _profile_with_last_login()
128
+ local_tz = dt.datetime.now().astimezone().tzinfo
129
+ now = dt.datetime.now(tz=local_tz)
130
+ game_time = float(profile.last_login_time) + 3_600.0
131
+ holder = type(
132
+ "Holder",
133
+ (),
134
+ {"tribes": [], "profiles": [profile], "file_mtime": now, "game_time": game_time},
135
+ )()
136
+ exported = export_tribes(holder)
137
+ rec = next(r for r in exported if r["tribeid"] == profile.player_id)
138
+ assert rec["active"] is not None
139
+ active = dt.datetime.fromisoformat(rec["active"])
140
+ expected = now - dt.timedelta(seconds=3_600)
141
+ assert active.timestamp() == pytest.approx(expected.timestamp(), abs=2)
142
+
143
+
144
+ def test_export_tribes_active_never_future() -> None:
145
+ """Future candidates are discarded (legacy filters d <= DateTime.Now)."""
146
+ profile = _profile_with_last_login()
147
+ local_tz = dt.datetime.now().astimezone().tzinfo
148
+ now = dt.datetime.now(tz=local_tz)
149
+ game_time = max(float(profile.last_login_time) - 100_000_000.0, 1.0)
150
+ holder = type(
151
+ "Holder",
152
+ (),
153
+ {"tribes": [], "profiles": [profile], "file_mtime": now, "game_time": game_time},
154
+ )()
155
+ exported = export_tribes(holder)
156
+ rec = next(r for r in exported if r["tribeid"] == profile.player_id)
157
+ assert rec["active"] is None
158
+
159
+
160
+ def test_export_tribe_logs_uses_tribe_parser(ase_tribe_path: Path) -> None:
161
+ tribe = Tribe.load(ase_tribe_path)
162
+ exported = export_tribe_logs(type("Holder", (), {"tribes": [tribe]})())
163
+ assert exported[0]["tribeid"] == tribe.tribe_id
164
+ assert exported[0]["tribe"] == tribe.name
165
+ assert exported[0]["logs"] == tribe.log_entries
166
+
167
+
168
+ def test_export_all_uses_asv_names(ase_export_world_save: WorldSave) -> None:
169
+ exported = export_all(ase_export_world_save)
170
+ assert set(exported) == _ASV_NAMES
171
+
172
+
173
+ def test_export_to_files_uses_asv_names(
174
+ ase_export_world_save: WorldSave, tmp_path: Path
175
+ ) -> None:
176
+ created = export_to_files(ase_export_world_save, tmp_path)
177
+ assert {path.name for path in created} == {f"{name}.json" for name in _ASV_NAMES}
@@ -1,92 +0,0 @@
1
- """Regression tests for export helpers against real parser objects."""
2
-
3
- from pathlib import Path
4
-
5
- import pytest
6
-
7
- from arkparser import Profile, Tribe, WorldSave
8
- from arkparser.export import (
9
- export_all,
10
- export_players,
11
- export_structures,
12
- export_tamed,
13
- export_to_files,
14
- export_tribe_logs,
15
- export_tribes,
16
- export_wild,
17
- )
18
-
19
- _EXAMPLES = Path(__file__).parent.parent / "references" / "examples"
20
- _ASE_SCORCHED_EARTH = _EXAMPLES / "ase" / "maps" / "scorchedearth" / "ScorchedEarth_P.ark"
21
-
22
- _ASV_NAMES = {
23
- "ASV_Tamed",
24
- "ASV_Wild",
25
- "ASV_Players",
26
- "ASV_Tribes",
27
- "ASV_Structures",
28
- "ASV_TribeLogs",
29
- "ASV_MapStructures",
30
- }
31
-
32
-
33
- @pytest.fixture(scope="session")
34
- def ase_export_world_save() -> WorldSave:
35
- if not _ASE_SCORCHED_EARTH.exists():
36
- pytest.skip(f"Fixture not available: {_ASE_SCORCHED_EARTH}")
37
- return WorldSave.load(_ASE_SCORCHED_EARTH)
38
-
39
-
40
- def test_export_tamed_matches_world_save(ase_export_world_save: WorldSave) -> None:
41
- exported = export_tamed(ase_export_world_save)
42
- assert len(exported) == len(ase_export_world_save.get_tamed_creatures())
43
- assert any(creature.get("name") == "escobar" for creature in exported)
44
-
45
-
46
- def test_export_wild_matches_world_save(ase_export_world_save: WorldSave) -> None:
47
- exported = export_wild(ase_export_world_save)
48
- assert len(exported) == len(ase_export_world_save.get_wild_creatures())
49
- assert all(creature.get("tamer", "") == "" for creature in exported[:100])
50
-
51
-
52
- def test_export_structures_matches_world_save(ase_export_world_save: WorldSave) -> None:
53
- exported = export_structures(ase_export_world_save)
54
- assert len(exported) == len(ase_export_world_save.get_structures())
55
-
56
-
57
- def test_export_players_uses_profile_parser(ase_profile_path: Path) -> None:
58
- profile = Profile.load(ase_profile_path)
59
- exported = export_players(type("Holder", (), {"profiles": [profile]})())
60
- assert exported[0]["playerid"] == profile.player_id
61
- assert exported[0]["steam"] == profile.player_name
62
- assert exported[0]["name"] == profile.character_name
63
- assert exported[0]["sex"] == ("Female" if profile.is_female else "Male")
64
- assert exported[0]["tribeid"] == profile.tribe_id
65
-
66
-
67
- def test_export_tribes_uses_tribe_parser(ase_tribe_path: Path) -> None:
68
- tribe = Tribe.load(ase_tribe_path)
69
- exported = export_tribes(type("Holder", (), {"tribes": [tribe]})())
70
- assert exported[0]["tribeid"] == tribe.tribe_id
71
- assert exported[0]["tribe"] == tribe.name
72
- assert exported[0]["players"] == tribe.member_count
73
-
74
-
75
- def test_export_tribe_logs_uses_tribe_parser(ase_tribe_path: Path) -> None:
76
- tribe = Tribe.load(ase_tribe_path)
77
- exported = export_tribe_logs(type("Holder", (), {"tribes": [tribe]})())
78
- assert exported[0]["tribeid"] == tribe.tribe_id
79
- assert exported[0]["tribe"] == tribe.name
80
- assert exported[0]["logs"] == tribe.log_entries
81
-
82
-
83
- def test_export_all_uses_asv_names(ase_export_world_save: WorldSave) -> None:
84
- exported = export_all(ase_export_world_save)
85
- assert set(exported) == _ASV_NAMES
86
-
87
-
88
- def test_export_to_files_uses_asv_names(
89
- ase_export_world_save: WorldSave, tmp_path: Path
90
- ) -> None:
91
- created = export_to_files(ase_export_world_save, tmp_path)
92
- assert {path.name for path in created} == {f"{name}.json" for name in _ASV_NAMES}
File without changes
File without changes
File without changes