arkparser 0.4.5__tar.gz → 0.4.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {arkparser-0.4.5 → arkparser-0.4.6}/PKG-INFO +5 -5
  2. {arkparser-0.4.5 → arkparser-0.4.6}/README.md +4 -4
  3. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/__init__.py +1 -1
  4. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/data_models.py +19 -3
  5. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/export.py +48 -8
  6. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/files/world_save.py +33 -18
  7. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/properties/compound.py +11 -2
  8. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser.egg-info/PKG-INFO +5 -5
  9. {arkparser-0.4.5 → arkparser-0.4.6}/pyproject.toml +1 -1
  10. {arkparser-0.4.5 → arkparser-0.4.6}/tests/test_cloud_inventory.py +0 -1
  11. {arkparser-0.4.5 → arkparser-0.4.6}/tests/test_profile.py +0 -1
  12. arkparser-0.4.6/tests/test_review_fixes.py +270 -0
  13. {arkparser-0.4.5 → arkparser-0.4.6}/tests/test_tribe.py +0 -1
  14. {arkparser-0.4.5 → arkparser-0.4.6}/tests/test_world_save.py +1 -1
  15. arkparser-0.4.5/tests/test_review_fixes.py +0 -138
  16. {arkparser-0.4.5 → arkparser-0.4.6}/LICENSE +0 -0
  17. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/common/__init__.py +0 -0
  18. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/common/binary_reader.py +0 -0
  19. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/common/exceptions.py +0 -0
  20. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/common/map_config.py +0 -0
  21. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/common/normalization.py +0 -0
  22. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/common/types.py +0 -0
  23. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/common/version_detection.py +0 -0
  24. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/files/__init__.py +0 -0
  25. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/files/base.py +0 -0
  26. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/files/cloud_inventory.py +0 -0
  27. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/files/profile.py +0 -0
  28. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/files/tribe.py +0 -0
  29. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/game_objects/__init__.py +0 -0
  30. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/game_objects/container.py +0 -0
  31. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/game_objects/game_object.py +0 -0
  32. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/game_objects/location.py +0 -0
  33. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/properties/__init__.py +0 -0
  34. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/properties/base.py +0 -0
  35. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/properties/byte_property.py +0 -0
  36. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/properties/primitives.py +0 -0
  37. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/properties/registry.py +0 -0
  38. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/structs/__init__.py +0 -0
  39. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/structs/base.py +0 -0
  40. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/structs/colors.py +0 -0
  41. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/structs/misc.py +0 -0
  42. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/structs/property_list.py +0 -0
  43. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/structs/registry.py +0 -0
  44. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser/structs/vectors.py +0 -0
  45. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser.egg-info/SOURCES.txt +0 -0
  46. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser.egg-info/dependency_links.txt +0 -0
  47. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser.egg-info/requires.txt +0 -0
  48. {arkparser-0.4.5 → arkparser-0.4.6}/arkparser.egg-info/top_level.txt +0 -0
  49. {arkparser-0.4.5 → arkparser-0.4.6}/setup.cfg +0 -0
  50. {arkparser-0.4.5 → arkparser-0.4.6}/tests/test_asa_header_position.py +0 -0
  51. {arkparser-0.4.5 → arkparser-0.4.6}/tests/test_asa_name_table.py +0 -0
  52. {arkparser-0.4.5 → arkparser-0.4.6}/tests/test_ase_cluster_drift.py +0 -0
  53. {arkparser-0.4.5 → arkparser-0.4.6}/tests/test_binary_reader.py +0 -0
  54. {arkparser-0.4.5 → arkparser-0.4.6}/tests/test_binary_reader_layouts.py +0 -0
  55. {arkparser-0.4.5 → arkparser-0.4.6}/tests/test_cloud_export.py +0 -0
  56. {arkparser-0.4.5 → arkparser-0.4.6}/tests/test_cryopod_export.py +0 -0
  57. {arkparser-0.4.5 → arkparser-0.4.6}/tests/test_current_stats.py +0 -0
  58. {arkparser-0.4.5 → arkparser-0.4.6}/tests/test_data_models.py +0 -0
  59. {arkparser-0.4.5 → arkparser-0.4.6}/tests/test_export.py +0 -0
  60. {arkparser-0.4.5 → arkparser-0.4.6}/tests/test_game_objects.py +0 -0
  61. {arkparser-0.4.5 → arkparser-0.4.6}/tests/test_v13_property_layouts.py +0 -0
  62. {arkparser-0.4.5 → arkparser-0.4.6}/tests/test_version_detection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkparser
3
- Version: 0.4.5
3
+ Version: 0.4.6
4
4
  Summary: Pure Python parser for ARK: Survival Evolved and ARK: Survival Ascended save files
5
5
  Author: Vertyco
6
6
  License-Expression: MIT
@@ -212,14 +212,14 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
212
212
  | `mut-f`, `mut-m` | legacy | **Ancestor-line totals.** `RandomMutationsFemale` and `RandomMutationsMale`, single integers counting the total number of mutations that occurred down the maternal and paternal ancestry lines respectively. These are *not* per-stat, they share the `-m` token with the per-stat mutation block below but mean a different thing. Kept under the legacy names for ASVExport parity. |
213
213
  | `cryo` | legacy | `True` for creatures embedded inside cryopod / soultrap / vivarium / dinoball items in the world save, `False` for actor-in-world tames. `export_tamed` walks `WorldSave.iter_cryopod_creatures()` and emits one ASV_Tamed record per embedded creature in addition to the actor-in-world tames; on busy PvE servers cryopodded tames are the majority of the roster (e.g. 10,277 of 11,054 on a live Ragnarok_WP). Cluster-uploaded tames also surface here (via `export_cluster_uploads`) with `cryo=True`. |
214
214
  | `ccc` | legacy | `"{x} {y} {z}"` from `LocationData` |
215
- | `dinoid` | legacy | string form of `id` |
215
+ | `dinoid` | legacy | string form of the dino id. **ASA**: decimal of the combined 64-bit `id`. **ASE**: the two halves concatenated as signed-int32 decimals (`str(DinoID1) + str(DinoID2)`), matching `ASVExport`. |
216
216
  | `isMating` | legacy | `bEnableTamedMating` |
217
217
  | `isNeutered` | legacy | `bNeutered` |
218
218
  | `isClone` | legacy | `bIsClone` or `bIsCloneDino` |
219
219
  | `tamedServer` | legacy | `TamedOnServerName` |
220
220
  | `uploadedServer` | legacy | `UploadedFromServerName` |
221
- | `maturation` | legacy | `str(int(BabyAge * 100))` (string for legacy parity) |
222
- | `traits` | legacy | `CreatureTraits` (full list of mutation trait class names) |
221
+ | `maturation` | legacy | `str(int(BabyAge * 100))` — integer maturation percent (a baby with no `BabyAge` is `0`, a newborn). Note: legacy emits the full float string (e.g. `"7.5131035"`); arkparser truncates to the integer percent. Semantically equal and the downstream consumer coerces to `int` either way. |
222
+ | `traits` | legacy | `CreatureTraits` as a list of `{"trait": <class>}` objects (matches `ASVExport`'s shape, not a flat string list) |
223
223
  | `inventory` | legacy | items from `MyInventoryComponent.InventoryItems`. Each entry carries `itemId`, `qty`, `blueprint`, plus a full snake_case property dump flattened in at the top level (`id`, `rating`, `durability`, `quality`, `damage`, `armor`, `durability_max`, `hypo`, `hyper`, `clip_size`, `weight`, `crafter`, `crafter_tribe`, `skill_bonus`, `loaded_ammo`, `spoils_at`, `spoiled_at`, `c0`..`c5`, `egg_*`, etc). `item_stat_values` is unpacked into the universal 8-slot ARK map (slot 0 `gen_quality`, 1 `armor`, 2 `durability_max`, 3 `damage`, 4 `clip_size`, 5 `hypo`, 6 `weight`, 7 `hyperthermal_insulation`); raw uint16s scaled by the per-blueprint multiplier (which lives in the UE blueprint, not the save). Default / unset values are filtered (no `craft_queue=0`, `skin=-1`, `color_pre_skin=[0]*6`, NaN spoil timers, etc). When the item is a **cryopod / soultrap / vivarium / dinoball** with an embedded creature, the entry is enriched with `dino_id` (combined 64-bit id matching the corresponding `ASV_Tamed` record), `dino_creature` (species / class name), and `dino_name` (`TamedName` if set). Cryopods stored in containers (cryofridges, vaults, dedicated storage) get the same enrichment. |
224
224
  | `father_id`, `mother_id` | added | combined dino id from the first `DinoAncestors` entry (`null` when missing) |
225
225
  | `father_name`, `mother_name` | added | name strings from the first `DinoAncestors` entry |
@@ -256,7 +256,7 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
256
256
 
257
257
  | Field | Origin | Source / formula |
258
258
  |---|---|---|
259
- | `id`, `dinoid` | legacy | `(DinoID1 << 32) \| DinoID2` and its string form |
259
+ | `id`, `dinoid` | legacy | `id` = `(DinoID1 << 32) \| DinoID2`. `dinoid` = its string form on **ASA**, or `str(DinoID1) + str(DinoID2)` (signed int32) on **ASE**, matching `ASVExport`. |
260
260
  | `creature` | legacy | `GameObject.class_name` |
261
261
  | `sex` | legacy | `"Female"` if `bIsFemale` else `"Male"` |
262
262
  | `lvl` | legacy | `BaseCharacterLevel` (status) |
@@ -178,14 +178,14 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
178
178
  | `mut-f`, `mut-m` | legacy | **Ancestor-line totals.** `RandomMutationsFemale` and `RandomMutationsMale`, single integers counting the total number of mutations that occurred down the maternal and paternal ancestry lines respectively. These are *not* per-stat, they share the `-m` token with the per-stat mutation block below but mean a different thing. Kept under the legacy names for ASVExport parity. |
179
179
  | `cryo` | legacy | `True` for creatures embedded inside cryopod / soultrap / vivarium / dinoball items in the world save, `False` for actor-in-world tames. `export_tamed` walks `WorldSave.iter_cryopod_creatures()` and emits one ASV_Tamed record per embedded creature in addition to the actor-in-world tames; on busy PvE servers cryopodded tames are the majority of the roster (e.g. 10,277 of 11,054 on a live Ragnarok_WP). Cluster-uploaded tames also surface here (via `export_cluster_uploads`) with `cryo=True`. |
180
180
  | `ccc` | legacy | `"{x} {y} {z}"` from `LocationData` |
181
- | `dinoid` | legacy | string form of `id` |
181
+ | `dinoid` | legacy | string form of the dino id. **ASA**: decimal of the combined 64-bit `id`. **ASE**: the two halves concatenated as signed-int32 decimals (`str(DinoID1) + str(DinoID2)`), matching `ASVExport`. |
182
182
  | `isMating` | legacy | `bEnableTamedMating` |
183
183
  | `isNeutered` | legacy | `bNeutered` |
184
184
  | `isClone` | legacy | `bIsClone` or `bIsCloneDino` |
185
185
  | `tamedServer` | legacy | `TamedOnServerName` |
186
186
  | `uploadedServer` | legacy | `UploadedFromServerName` |
187
- | `maturation` | legacy | `str(int(BabyAge * 100))` (string for legacy parity) |
188
- | `traits` | legacy | `CreatureTraits` (full list of mutation trait class names) |
187
+ | `maturation` | legacy | `str(int(BabyAge * 100))` — integer maturation percent (a baby with no `BabyAge` is `0`, a newborn). Note: legacy emits the full float string (e.g. `"7.5131035"`); arkparser truncates to the integer percent. Semantically equal and the downstream consumer coerces to `int` either way. |
188
+ | `traits` | legacy | `CreatureTraits` as a list of `{"trait": <class>}` objects (matches `ASVExport`'s shape, not a flat string list) |
189
189
  | `inventory` | legacy | items from `MyInventoryComponent.InventoryItems`. Each entry carries `itemId`, `qty`, `blueprint`, plus a full snake_case property dump flattened in at the top level (`id`, `rating`, `durability`, `quality`, `damage`, `armor`, `durability_max`, `hypo`, `hyper`, `clip_size`, `weight`, `crafter`, `crafter_tribe`, `skill_bonus`, `loaded_ammo`, `spoils_at`, `spoiled_at`, `c0`..`c5`, `egg_*`, etc). `item_stat_values` is unpacked into the universal 8-slot ARK map (slot 0 `gen_quality`, 1 `armor`, 2 `durability_max`, 3 `damage`, 4 `clip_size`, 5 `hypo`, 6 `weight`, 7 `hyperthermal_insulation`); raw uint16s scaled by the per-blueprint multiplier (which lives in the UE blueprint, not the save). Default / unset values are filtered (no `craft_queue=0`, `skin=-1`, `color_pre_skin=[0]*6`, NaN spoil timers, etc). When the item is a **cryopod / soultrap / vivarium / dinoball** with an embedded creature, the entry is enriched with `dino_id` (combined 64-bit id matching the corresponding `ASV_Tamed` record), `dino_creature` (species / class name), and `dino_name` (`TamedName` if set). Cryopods stored in containers (cryofridges, vaults, dedicated storage) get the same enrichment. |
190
190
  | `father_id`, `mother_id` | added | combined dino id from the first `DinoAncestors` entry (`null` when missing) |
191
191
  | `father_name`, `mother_name` | added | name strings from the first `DinoAncestors` entry |
@@ -222,7 +222,7 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
222
222
 
223
223
  | Field | Origin | Source / formula |
224
224
  |---|---|---|
225
- | `id`, `dinoid` | legacy | `(DinoID1 << 32) \| DinoID2` and its string form |
225
+ | `id`, `dinoid` | legacy | `id` = `(DinoID1 << 32) \| DinoID2`. `dinoid` = its string form on **ASA**, or `str(DinoID1) + str(DinoID2)` (signed int32) on **ASE**, matching `ASVExport`. |
226
226
  | `creature` | legacy | `GameObject.class_name` |
227
227
  | `sex` | legacy | `"Female"` if `bIsFemale` else `"Male"` |
228
228
  | `lvl` | legacy | `BaseCharacterLevel` (status) |
@@ -95,4 +95,4 @@ __all__ = [
95
95
  "ArkParseError",
96
96
  ]
97
97
 
98
- __version__ = "0.4.5"
98
+ __version__ = "0.4.6"
@@ -8,6 +8,7 @@ property data from ARK save files.
8
8
  from __future__ import annotations
9
9
 
10
10
  import logging
11
+ import math
11
12
  import typing as t
12
13
  from dataclasses import dataclass, field
13
14
 
@@ -18,6 +19,21 @@ from .properties.registry import read_properties
18
19
  logger = logging.getLogger(__name__)
19
20
 
20
21
 
22
+ def _finite(value: t.Any, default: float) -> float:
23
+ """Coerce to a finite float; NaN / inf / non-numeric collapse to ``default``.
24
+
25
+ Non-finite floats are invalid JSON tokens that crash strict serializers
26
+ (``json.dumps(allow_nan=False)``, JS ``JSON.parse``, pydantic). Legacy
27
+ ASVExport substitutes ``0.0001`` for a NaN item rating (ContentItem.cs:62);
28
+ other non-finite floats fall back to ``0.0``.
29
+ """
30
+ try:
31
+ result = float(value)
32
+ except (TypeError, ValueError):
33
+ return default
34
+ return result if math.isfinite(result) else default
35
+
36
+
21
37
  @dataclass
22
38
  class DinoStats:
23
39
  """Statistics for a creature."""
@@ -201,7 +217,7 @@ class UploadedCreature:
201
217
  dino_id1=data.get("DinoID1", 0),
202
218
  dino_id2=data.get("DinoID2", 0),
203
219
  level=level,
204
- experience=data.get("DinoExperiencePoints", 0.0),
220
+ experience=_finite(data.get("DinoExperiencePoints", 0.0), 0.0),
205
221
  stats=stats,
206
222
  upload_time=data.get("UploadTime", 0),
207
223
  version=data.get("Version", 0.0),
@@ -735,8 +751,8 @@ class UploadedItem:
735
751
  item_id2=item_id.get("ItemID2", 0) if isinstance(item_id, dict) else 0,
736
752
  quantity=ark_tribute.get("ItemQuantity", 1) or 1,
737
753
  quality_index=ark_tribute.get("ItemQualityIndex", 0),
738
- durability=ark_tribute.get("ItemDurability", 0.0),
739
- rating=ark_tribute.get("ItemRating", 0.0),
754
+ durability=_finite(ark_tribute.get("ItemDurability", 0.0), 0.0),
755
+ rating=_finite(ark_tribute.get("ItemRating", 0.0), 0.0001),
740
756
  slot_index=ark_tribute.get("SlotIndex", 0),
741
757
  is_blueprint=ark_tribute.get("bIsBlueprint", False),
742
758
  is_engram=ark_tribute.get("bIsEngram", False),
@@ -355,6 +355,31 @@ def _combine_dino_id(id1: t.Any, id2: t.Any) -> int:
355
355
  return (a << 32) | (b & 0xFFFFFFFF)
356
356
 
357
357
 
358
+ def _dino_id_str(id1: t.Any, id2: t.Any, is_asa: bool) -> str:
359
+ """Legacy ``dinoid`` string form (engine-dependent).
360
+
361
+ ASA emits the decimal of the combined 64-bit id (legacy
362
+ ``ContentCreature.cs:224`` ``DinoId = Id.ToString()``). ASE concatenates the
363
+ two id halves as *signed* int32 decimals (legacy ``ContentCreature.cs:378``
364
+ ``DinoID1.ToString() + DinoID2.ToString()``, where ``GetPropertyValue<int>``
365
+ reinterprets each stored uint32 as a signed int). The forms differ, so the
366
+ engine must be known; passing the wrong flag re-introduces the divergence.
367
+ """
368
+ assert isinstance(is_asa, bool), "is_asa must be bool"
369
+ a, b = _int(id1), _int(id2)
370
+ if a == 0 and b == 0:
371
+ return "0"
372
+ if is_asa:
373
+ return str((a << 32) | (b & 0xFFFFFFFF))
374
+ a32 = a & 0xFFFFFFFF
375
+ b32 = b & 0xFFFFFFFF
376
+ a32 = a32 - 0x100000000 if a32 & 0x80000000 else a32
377
+ b32 = b32 - 0x100000000 if b32 & 0x80000000 else b32
378
+ result = f"{a32}{b32}"
379
+ assert result, "dinoid string must be non-empty"
380
+ return result
381
+
382
+
358
383
  def _colors(obj: t.Any) -> list[int]:
359
384
  out = [0] * 6
360
385
  getter = getattr(obj, "get_properties_by_name", None)
@@ -1052,7 +1077,10 @@ def _tamed_dict(
1052
1077
  mut_pts = _stat_array(status, "NumberOfMutationsAppliedTamed")
1053
1078
  base_level = _int(_prop(status, "BaseCharacterLevel"), default=1) or 1
1054
1079
  extra_level = _int(_prop(status, "ExtraCharacterLevel"))
1055
- dino_id = _combine_dino_id(_prop(obj, "DinoID1"), _prop(obj, "DinoID2"))
1080
+ is_asa = bool(getattr(save, "is_asa", False))
1081
+ raw_id1 = _prop(obj, "DinoID1")
1082
+ raw_id2 = _prop(obj, "DinoID2")
1083
+ dino_id = _combine_dino_id(raw_id1, raw_id2)
1056
1084
  # Legacy negates the id of stored (cryo/vivarium) creatures so they don't
1057
1085
  # collide with live tames (ContentTamedCreature.cs:122-126/228-232). The
1058
1086
  # dinoid field stays positive (C# sets DinoId = Id.ToString() before negating).
@@ -1073,7 +1101,10 @@ def _tamed_dict(
1073
1101
  is_female = bool(_prop(obj, "bIsFemale", default=False))
1074
1102
  targeting_team = _int(_prop(obj, "TargetingTeam"))
1075
1103
  baby = bool(_prop(obj, "bIsBaby", default=False))
1076
- baby_age = _float(_prop(obj, "BabyAge"), default=1.0) if baby else 1.0
1104
+ # A baby with no BabyAge property is a newborn (maturation 0), not an adult.
1105
+ # Legacy reads BabyAge with default 0 (ContentCreature.cs:98). Non-babies
1106
+ # stay at 1.0 -> maturation "100".
1107
+ baby_age = _float(_prop(obj, "BabyAge"), default=0.0) if baby else 1.0
1077
1108
  father_id, father_name = _ancestor_parent(obj, "Male")
1078
1109
  mother_id, mother_name = _ancestor_parent(obj, "Female")
1079
1110
  tribe_name = _str(_prop(obj, "TribeName"))
@@ -1100,7 +1131,7 @@ def _tamed_dict(
1100
1131
  "mut-f": _int(_prop(obj, "RandomMutationsFemale")),
1101
1132
  "mut-m": _int(_prop(obj, "RandomMutationsMale")),
1102
1133
  "cryo": bool(_prop(obj, "IsInCryo", default=False)),
1103
- "dinoid": str(dino_id),
1134
+ "dinoid": _dino_id_str(raw_id1, raw_id2, is_asa),
1104
1135
  "isMating": bool(_prop(obj, "bEnableTamedMating", default=False)),
1105
1136
  "isNeutered": bool(_prop(obj, "bNeutered", default=False)),
1106
1137
  "isClone": bool(_prop(obj, "bIsClone", default=False))
@@ -1109,7 +1140,9 @@ def _tamed_dict(
1109
1140
  "uploadedServer": _str(_prop(obj, "UploadedFromServerName")),
1110
1141
  "maturation": str(int(baby_age * 100)),
1111
1142
  **_flat_stats(mut_pts, "m"),
1112
- "traits": _traits(obj),
1143
+ # Legacy emits tamed traits as a list of objects ([{"trait": <class>}]),
1144
+ # not a flat string list (ContentPack.cs:723-735). Match that shape.
1145
+ "traits": [{"trait": tr} for tr in _traits(obj)],
1113
1146
  "inventory": _inventory_items(obj, lookup),
1114
1147
  "father_id": father_id,
1115
1148
  "mother_id": mother_id,
@@ -1615,12 +1648,15 @@ def _wild_dict(
1615
1648
  obj: t.Any,
1616
1649
  status: t.Any,
1617
1650
  map_config: MapConfig | None,
1651
+ is_asa: bool = False,
1618
1652
  ) -> dict[str, t.Any]:
1619
1653
  base_pts = _stat_array(status, "NumberOfLevelUpPointsApplied")
1620
1654
  base_level = _int(_prop(status, "BaseCharacterLevel"), default=1) or 1
1621
1655
  colors = _colors(obj)
1622
1656
  is_female = bool(_prop(obj, "bIsFemale", default=False))
1623
- dino_id = _combine_dino_id(_prop(obj, "DinoID1"), _prop(obj, "DinoID2"))
1657
+ raw_id1 = _prop(obj, "DinoID1")
1658
+ raw_id2 = _prop(obj, "DinoID2")
1659
+ dino_id = _combine_dino_id(raw_id1, raw_id2)
1624
1660
  traits = _traits(obj)
1625
1661
  class_name = getattr(obj, "class_name", "") or ""
1626
1662
  tameable = _is_tameable(class_name, obj)
@@ -1632,7 +1668,7 @@ def _wild_dict(
1632
1668
  "lvl": base_level,
1633
1669
  **_flat_stats(base_pts),
1634
1670
  **{f"c{i}": colors[i] for i in range(6)},
1635
- "dinoid": str(dino_id),
1671
+ "dinoid": _dino_id_str(raw_id1, raw_id2, is_asa),
1636
1672
  "tameable": tameable,
1637
1673
  # Legacy emits the first trait as a singular ``trait``; the full
1638
1674
  # CreatureTraits list is exposed alongside as ``traits``.
@@ -1647,8 +1683,9 @@ def _wild_dict(
1647
1683
 
1648
1684
  def export_wild(save: t.Any, map_config: MapConfig | None = None) -> list[dict[str, t.Any]]:
1649
1685
  objects = _world_objects(save, "get_wild_creatures", "wild_objects")
1686
+ is_asa = bool(getattr(save, "is_asa", False))
1650
1687
  lookup = _save_lookup(save)
1651
- return [_wild_dict(obj, _status_for(obj, lookup), map_config) for obj in objects]
1688
+ return [_wild_dict(obj, _status_for(obj, lookup), map_config, is_asa) for obj in objects]
1652
1689
 
1653
1690
 
1654
1691
  def _player_from_profile(
@@ -2210,7 +2247,10 @@ def _structure_dict(
2210
2247
  "struct": class_name,
2211
2248
  "name": box_name,
2212
2249
  "locked": locked,
2213
- "created": _structure_created(obj, save),
2250
+ # Legacy CreatedDateTime is DateTime? -> a null interpolates to "" in
2251
+ # the JSON, never JSON null. Match that (avoids a null-vs-string flip
2252
+ # for structures whose creation time can't be resolved).
2253
+ "created": _structure_created(obj, save) or "",
2214
2254
  "inventory": _inventory_items(obj, lookup),
2215
2255
  "decay_reset": bool(_prop(obj, "bHasResetDecayTime", default=False)),
2216
2256
  "last_ally_in_range": (
@@ -31,11 +31,11 @@ from pathlib import Path
31
31
  from uuid import UUID
32
32
 
33
33
  from ..common.binary_reader import BinaryReader
34
- from ..common.exceptions import ArkParseError
34
+ from ..common.exceptions import ArkParseError, CorruptDataError
35
35
  from ..common.normalization import normalize_indexed_data, normalize_indexed_list
36
36
  from ..data_models import CryopodCreature
37
37
  from ..game_objects.container import GameObjectContainer
38
- from ..game_objects.game_object import GameObject
38
+ from ..game_objects.game_object import MAX_OBJECT_COUNT, GameObject
39
39
  from ..game_objects.location import LocationData
40
40
  from ..properties.registry import read_properties
41
41
 
@@ -50,6 +50,23 @@ _ZERO_GUID = b"\x00" * 16
50
50
  _MAX_ASA_NAME_TABLE = 1_000_000
51
51
 
52
52
 
53
+ def _checked_count(reader: BinaryReader, label: str, maximum: int = MAX_OBJECT_COUNT) -> int:
54
+ """Read a length-prefix int32 and reject corrupt/implausible values.
55
+
56
+ Power-of-10 rule 2 (fixed loop bounds): every count that drives a read
57
+ loop must be provably bounded. A negative or absurd count means a
58
+ misaligned/corrupt header; raise ``CorruptDataError`` (NOT ``assert`` -
59
+ asserts vanish under ``python -O``) rather than looping over billions of
60
+ phantom entries (OOM/hang).
61
+ """
62
+ if reader.remaining < 4:
63
+ raise CorruptDataError(f"{label}: truncated before count int32")
64
+ count = reader.read_int32()
65
+ if not 0 <= count <= maximum:
66
+ raise CorruptDataError(f"{label}: implausible count {count} (corrupt/misaligned read)")
67
+ return count
68
+
69
+
53
70
  @dataclass
54
71
  class EmbeddedData:
55
72
  """
@@ -540,25 +557,25 @@ class WorldSave:
540
557
  saved = reader.position
541
558
  reader.position = self._name_table_offset
542
559
 
543
- count = reader.read_int32()
560
+ count = _checked_count(reader, "ASE name table")
544
561
  self.name_table = [reader.read_string() for _ in range(count)]
545
562
 
546
563
  reader.position = saved
547
564
 
548
565
  def _read_ase_data_files(self, reader: BinaryReader) -> None:
549
- count = reader.read_int32()
566
+ count = _checked_count(reader, "ASE data files")
550
567
  self.data_files = [reader.read_string() for _ in range(count)]
551
568
 
552
569
  def _read_ase_embedded_data(self, reader: BinaryReader) -> None:
553
- count = reader.read_int32()
570
+ count = _checked_count(reader, "ASE embedded data")
554
571
  self.embedded_data = [EmbeddedData.read(reader) for _ in range(count)]
555
572
 
556
573
  def _read_ase_data_files_object_map(self, reader: BinaryReader) -> None:
557
- count = reader.read_int32()
574
+ count = _checked_count(reader, "ASE data-files object map")
558
575
  self.data_files_object_map = {}
559
576
  for _ in range(count):
560
577
  level = reader.read_int32()
561
- name_count = reader.read_int32()
578
+ name_count = _checked_count(reader, "ASE data-files object names")
562
579
  names = [reader.read_string() for _ in range(name_count)]
563
580
  self.data_files_object_map.setdefault(level, []).append(names)
564
581
 
@@ -613,7 +630,7 @@ class WorldSave:
613
630
  return obj
614
631
 
615
632
  def _read_ase_objects(self, reader: BinaryReader) -> None:
616
- count = reader.read_int32()
633
+ count = _checked_count(reader, "ASE objects")
617
634
  self.objects = [self._read_ase_object_header(reader, i) for i in range(count)]
618
635
 
619
636
  def _read_ase_object_properties(self, reader: BinaryReader) -> None:
@@ -701,7 +718,7 @@ class WorldSave:
701
718
  _unknown2 = reader.read_int32()
702
719
 
703
720
  # Data files (immediately follow the header; populate self.data_files).
704
- count = reader.read_int32()
721
+ count = _checked_count(reader, "ASA data files")
705
722
  self.data_files = []
706
723
  for _ in range(count):
707
724
  self.data_files.append(reader.read_string())
@@ -720,10 +737,12 @@ class WorldSave:
720
737
  # Where the gap is zero (TheIsland, Aberration, Extinction) the seek is
721
738
  # a no-op; where it drifts it recovers every leaked class name.
722
739
  if self.version >= 14:
723
- assert 0 <= name_table_offset <= reader.size, (
724
- f"ASA name-table offset {name_table_offset} out of range "
725
- f"(blob size {reader.size})"
726
- )
740
+ # Explicit raise (not assert) so the bound survives ``python -O``.
741
+ if not 0 <= name_table_offset <= reader.size:
742
+ raise CorruptDataError(
743
+ f"ASA name-table offset {name_table_offset} out of range "
744
+ f"(blob size {reader.size})"
745
+ )
727
746
  reader.position = name_table_offset
728
747
  self.name_table = self._read_asa_name_table(reader)
729
748
  else:
@@ -747,11 +766,7 @@ class WorldSave:
747
766
  user-placed-actor entries (``idx == 1``) on the v13 sequential path; it
748
767
  is unused on the v14 seek path.
749
768
  """
750
- assert reader.remaining >= 4, "no room for ASA name-table count"
751
- name_count = reader.read_int32()
752
- assert 0 <= name_count <= _MAX_ASA_NAME_TABLE, (
753
- f"implausible ASA name-table count {name_count} - misaligned read"
754
- )
769
+ name_count = _checked_count(reader, "ASA name table", _MAX_ASA_NAME_TABLE)
755
770
  nt: dict[int, str] = {}
756
771
  for _ in range(name_count):
757
772
  idx = reader.read_int32()
@@ -418,8 +418,17 @@ def _read_array_elements(
418
418
  for _ in range(count):
419
419
  values.append(reader.read_int8())
420
420
  elif array_type == "ByteProperty":
421
- for _ in range(count):
422
- values.append(reader.read_uint8())
421
+ # ASE enum byte-arrays store each element as an 8-byte name-table
422
+ # reference, not a raw uint8. Legacy ArkArrayByteHandler discriminates
423
+ # on size: data_size > count+4 -> name refs, data_size == count+4 ->
424
+ # raw uint8. Mirror it so a name-valued byte array doesn't under-read
425
+ # and drift the cursor into the next property (the cluster-drift class).
426
+ if not is_asa and count > 0 and data_size > count + 4:
427
+ for _ in range(count):
428
+ values.append(read_name(reader, name_table))
429
+ else:
430
+ for _ in range(count):
431
+ values.append(reader.read_uint8())
423
432
  elif array_type == "FloatProperty":
424
433
  for _ in range(count):
425
434
  values.append(reader.read_float())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkparser
3
- Version: 0.4.5
3
+ Version: 0.4.6
4
4
  Summary: Pure Python parser for ARK: Survival Evolved and ARK: Survival Ascended save files
5
5
  Author: Vertyco
6
6
  License-Expression: MIT
@@ -212,14 +212,14 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
212
212
  | `mut-f`, `mut-m` | legacy | **Ancestor-line totals.** `RandomMutationsFemale` and `RandomMutationsMale`, single integers counting the total number of mutations that occurred down the maternal and paternal ancestry lines respectively. These are *not* per-stat, they share the `-m` token with the per-stat mutation block below but mean a different thing. Kept under the legacy names for ASVExport parity. |
213
213
  | `cryo` | legacy | `True` for creatures embedded inside cryopod / soultrap / vivarium / dinoball items in the world save, `False` for actor-in-world tames. `export_tamed` walks `WorldSave.iter_cryopod_creatures()` and emits one ASV_Tamed record per embedded creature in addition to the actor-in-world tames; on busy PvE servers cryopodded tames are the majority of the roster (e.g. 10,277 of 11,054 on a live Ragnarok_WP). Cluster-uploaded tames also surface here (via `export_cluster_uploads`) with `cryo=True`. |
214
214
  | `ccc` | legacy | `"{x} {y} {z}"` from `LocationData` |
215
- | `dinoid` | legacy | string form of `id` |
215
+ | `dinoid` | legacy | string form of the dino id. **ASA**: decimal of the combined 64-bit `id`. **ASE**: the two halves concatenated as signed-int32 decimals (`str(DinoID1) + str(DinoID2)`), matching `ASVExport`. |
216
216
  | `isMating` | legacy | `bEnableTamedMating` |
217
217
  | `isNeutered` | legacy | `bNeutered` |
218
218
  | `isClone` | legacy | `bIsClone` or `bIsCloneDino` |
219
219
  | `tamedServer` | legacy | `TamedOnServerName` |
220
220
  | `uploadedServer` | legacy | `UploadedFromServerName` |
221
- | `maturation` | legacy | `str(int(BabyAge * 100))` (string for legacy parity) |
222
- | `traits` | legacy | `CreatureTraits` (full list of mutation trait class names) |
221
+ | `maturation` | legacy | `str(int(BabyAge * 100))` — integer maturation percent (a baby with no `BabyAge` is `0`, a newborn). Note: legacy emits the full float string (e.g. `"7.5131035"`); arkparser truncates to the integer percent. Semantically equal and the downstream consumer coerces to `int` either way. |
222
+ | `traits` | legacy | `CreatureTraits` as a list of `{"trait": <class>}` objects (matches `ASVExport`'s shape, not a flat string list) |
223
223
  | `inventory` | legacy | items from `MyInventoryComponent.InventoryItems`. Each entry carries `itemId`, `qty`, `blueprint`, plus a full snake_case property dump flattened in at the top level (`id`, `rating`, `durability`, `quality`, `damage`, `armor`, `durability_max`, `hypo`, `hyper`, `clip_size`, `weight`, `crafter`, `crafter_tribe`, `skill_bonus`, `loaded_ammo`, `spoils_at`, `spoiled_at`, `c0`..`c5`, `egg_*`, etc). `item_stat_values` is unpacked into the universal 8-slot ARK map (slot 0 `gen_quality`, 1 `armor`, 2 `durability_max`, 3 `damage`, 4 `clip_size`, 5 `hypo`, 6 `weight`, 7 `hyperthermal_insulation`); raw uint16s scaled by the per-blueprint multiplier (which lives in the UE blueprint, not the save). Default / unset values are filtered (no `craft_queue=0`, `skin=-1`, `color_pre_skin=[0]*6`, NaN spoil timers, etc). When the item is a **cryopod / soultrap / vivarium / dinoball** with an embedded creature, the entry is enriched with `dino_id` (combined 64-bit id matching the corresponding `ASV_Tamed` record), `dino_creature` (species / class name), and `dino_name` (`TamedName` if set). Cryopods stored in containers (cryofridges, vaults, dedicated storage) get the same enrichment. |
224
224
  | `father_id`, `mother_id` | added | combined dino id from the first `DinoAncestors` entry (`null` when missing) |
225
225
  | `father_name`, `mother_name` | added | name strings from the first `DinoAncestors` entry |
@@ -256,7 +256,7 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
256
256
 
257
257
  | Field | Origin | Source / formula |
258
258
  |---|---|---|
259
- | `id`, `dinoid` | legacy | `(DinoID1 << 32) \| DinoID2` and its string form |
259
+ | `id`, `dinoid` | legacy | `id` = `(DinoID1 << 32) \| DinoID2`. `dinoid` = its string form on **ASA**, or `str(DinoID1) + str(DinoID2)` (signed int32) on **ASE**, matching `ASVExport`. |
260
260
  | `creature` | legacy | `GameObject.class_name` |
261
261
  | `sex` | legacy | `"Female"` if `bIsFemale` else `"Male"` |
262
262
  | `lvl` | legacy | `BaseCharacterLevel` (status) |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "arkparser"
7
- version = "0.4.5"
7
+ version = "0.4.6"
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"
@@ -4,7 +4,6 @@ Tests for cloud inventory (obelisk) parsing - both ASE and ASA formats.
4
4
 
5
5
  from pathlib import Path
6
6
 
7
- import pytest
8
7
 
9
8
  from arkparser import CloudInventory
10
9
 
@@ -4,7 +4,6 @@ Tests for player profile parsing - both ASE and ASA formats.
4
4
 
5
5
  from pathlib import Path
6
6
 
7
- import pytest
8
7
 
9
8
  from arkparser import Profile
10
9
 
@@ -0,0 +1,270 @@
1
+ """Regression tests for the code-review fix batch (fixture-free, pure logic).
2
+
3
+ Each test pins a specific reviewed defect so it cannot silently regress:
4
+ #1 tamer blanked when a creature is imprinted
5
+ #2 inf/nan floats never reach JSON (strict-parser safe)
6
+ #5 in-world cryo/vivarium id negated; dinoid stays positive
7
+ #13 read_string bounds-checks the length==1 / length==-1 fast paths
8
+ #15 read_object_list rejects an absurd (corrupt-header) object count
9
+ """
10
+
11
+ import json
12
+ import types
13
+
14
+ import pytest
15
+
16
+ from arkparser.common.binary_reader import BinaryReader
17
+ from arkparser.common.exceptions import CorruptDataError, EndOfDataError
18
+ from arkparser.data_models import UploadedCreature, UploadedItem
19
+ from arkparser.export import (
20
+ _SyntheticGameObject,
21
+ _dino_id_str,
22
+ _float,
23
+ _gps_payload,
24
+ _structure_dict,
25
+ _tamed_dict,
26
+ _wild_dict,
27
+ )
28
+ from arkparser.files.world_save import _checked_count
29
+ from arkparser.game_objects.game_object import MAX_OBJECT_COUNT, read_object_list
30
+ from arkparser.properties.compound import _read_array_elements
31
+
32
+
33
+ def _status() -> _SyntheticGameObject:
34
+ return _SyntheticGameObject("DinoCharacterStatusComponent_BP_C", {})
35
+
36
+
37
+ # --- #2: finite floats only -------------------------------------------------
38
+
39
+ def test_float_coerces_non_finite_to_default() -> None:
40
+ assert _float(float("inf")) == 0.0
41
+ assert _float(float("-inf")) == 0.0
42
+ assert _float(float("nan")) == 0.0
43
+ assert _float(float("inf"), default=1.5) == 1.5
44
+ # finite values pass through unchanged
45
+ assert _float(3.25) == 3.25
46
+ assert _float(0.0) == 0.0
47
+
48
+
49
+ def test_gps_payload_is_strict_json_safe() -> None:
50
+ loc = types.SimpleNamespace(x=float("inf"), y=float("nan"), z=-float("inf"))
51
+ # _gps_payload only reads ``obj.location``; a namespace stand-in is enough.
52
+ obj = types.SimpleNamespace(location=loc)
53
+ payload = _gps_payload(obj, None)
54
+ # No NaN/Infinity tokens survive — strict parsers (JS, Pydantic) reject them.
55
+ text = json.dumps(payload)
56
+ assert "Infinity" not in text and "NaN" not in text
57
+ json.loads(text, parse_constant=lambda tok: pytest.fail(f"non-finite: {tok}"))
58
+ assert payload["ccc"] == "0.0 0.0 0.0"
59
+
60
+
61
+ # --- #1: tamer blanked on imprint ------------------------------------------
62
+
63
+ def test_tamed_dict_blanks_tamer_when_imprinted_by_player_id() -> None:
64
+ actor = _SyntheticGameObject(
65
+ "Dodo_C", {"TamerString": "Bob", "ImprinterPlayerDataID": 42}
66
+ )
67
+ rec = _tamed_dict(actor, _status(), {}, None)
68
+ assert rec["tamer"] == ""
69
+ assert rec["imprinter_player_id"] == 42
70
+
71
+
72
+ def test_tamed_dict_blanks_tamer_when_imprinter_name_present() -> None:
73
+ actor = _SyntheticGameObject(
74
+ "Dodo_C", {"TamerString": "Bob", "ImprinterName": "Alice"}
75
+ )
76
+ rec = _tamed_dict(actor, _status(), {}, None)
77
+ assert rec["tamer"] == ""
78
+ assert rec["imprinter"] == "Alice"
79
+
80
+
81
+ def test_tamed_dict_keeps_tamer_when_not_imprinted() -> None:
82
+ actor = _SyntheticGameObject("Dodo_C", {"TamerString": "Bob"})
83
+ rec = _tamed_dict(actor, _status(), {}, None)
84
+ assert rec["tamer"] == "Bob"
85
+
86
+
87
+ # --- #5: cryo/vivarium id negation (dinoid stays positive) ------------------
88
+
89
+ def test_tamed_dict_negates_id_when_stored() -> None:
90
+ actor = _SyntheticGameObject("Dodo_C", {"DinoID1": 100, "DinoID2": 200})
91
+ live = _tamed_dict(actor, _status(), {}, None, stored=False)
92
+ stored = _tamed_dict(actor, _status(), {}, None, stored=True)
93
+ assert live["id"] > 0
94
+ assert stored["id"] == -live["id"]
95
+ # dinoid is the stable positive identity in BOTH cases (legacy parity).
96
+ assert stored["dinoid"] == live["dinoid"]
97
+ assert not stored["dinoid"].startswith("-")
98
+
99
+
100
+ def test_tamed_dict_negates_id_via_is_in_cryo_prop() -> None:
101
+ actor = _SyntheticGameObject(
102
+ "Dodo_C", {"DinoID1": 1, "DinoID2": 2, "IsInCryo": True}
103
+ )
104
+ rec = _tamed_dict(actor, _status(), {}, None)
105
+ assert rec["id"] < 0
106
+ assert not rec["dinoid"].startswith("-")
107
+
108
+
109
+ def test_tamed_dict_zero_id_not_negated() -> None:
110
+ actor = _SyntheticGameObject("Dodo_C", {"DinoID1": 0, "DinoID2": 0})
111
+ rec = _tamed_dict(actor, _status(), {}, None, stored=True)
112
+ assert rec["id"] == 0
113
+
114
+
115
+ # --- #13: read_string fast-path bounds checks -------------------------------
116
+
117
+ def test_read_string_len1_truncated_raises() -> None:
118
+ # length prefix == 1 (single null byte) but no byte follows it.
119
+ reader = BinaryReader.from_bytes((1).to_bytes(4, "little"))
120
+ with pytest.raises(EndOfDataError):
121
+ reader.read_string()
122
+
123
+
124
+ def test_read_string_utf16_null_truncated_raises() -> None:
125
+ # length prefix == -1 (UTF-16 null, needs 2 bytes) but none follow.
126
+ reader = BinaryReader.from_bytes((-1).to_bytes(4, "little", signed=True))
127
+ with pytest.raises(EndOfDataError):
128
+ reader.read_string()
129
+
130
+
131
+ def test_read_string_len1_valid_roundtrips() -> None:
132
+ # Happy path still works: prefix 1 + the null byte present -> "".
133
+ reader = BinaryReader.from_bytes((1).to_bytes(4, "little") + b"\x00")
134
+ assert reader.read_string() == ""
135
+
136
+
137
+ # --- #15: object-count sanity bound -----------------------------------------
138
+
139
+ def test_read_object_list_rejects_absurd_count() -> None:
140
+ # 0xFFFFFFFF as uint32 is far above MAX_OBJECT_COUNT (a misaligned header).
141
+ reader = BinaryReader.from_bytes(b"\xff\xff\xff\xff")
142
+ with pytest.raises(CorruptDataError):
143
+ read_object_list(reader, is_asa=False)
144
+
145
+
146
+ def test_read_object_list_zero_count_ok() -> None:
147
+ reader = BinaryReader.from_bytes((0).to_bytes(4, "little"))
148
+ assert read_object_list(reader, is_asa=False) == []
149
+ assert MAX_OBJECT_COUNT > 1_000_000
150
+
151
+
152
+ # --- code-review (max): tamed `traits` shape is legacy object-list -----------
153
+
154
+ def test_tamed_traits_emitted_as_objects() -> None:
155
+ # Legacy ASVExport emits tamed traits as [{"trait": <class>}], not [str]
156
+ # (ContentPack.cs:723-735).
157
+ actor = _SyntheticGameObject(
158
+ "Dodo_C", {"CreatureTraits": ["Rabid_Tier1", "Aggressive_Tier2"]}
159
+ )
160
+ rec = _tamed_dict(actor, _status(), {}, None)
161
+ assert rec["traits"] == [{"trait": "Rabid_Tier1"}, {"trait": "Aggressive_Tier2"}]
162
+
163
+
164
+ def test_tamed_traits_empty_list_when_absent() -> None:
165
+ rec = _tamed_dict(_SyntheticGameObject("Dodo_C", {}), _status(), {}, None)
166
+ assert rec["traits"] == []
167
+
168
+
169
+ # --- code-review (max): ASE/ASA `dinoid` string form ------------------------
170
+
171
+ def test_tamed_dinoid_ase_is_decimal_concat() -> None:
172
+ # ASE (save absent -> is_asa False): str(DinoID1) + str(DinoID2).
173
+ actor = _SyntheticGameObject("Dodo_C", {"DinoID1": 475230717, "DinoID2": 97170314})
174
+ rec = _tamed_dict(actor, _status(), {}, None)
175
+ assert rec["dinoid"] == "47523071797170314"
176
+
177
+
178
+ def test_tamed_dinoid_asa_is_combined_id() -> None:
179
+ # ASA: decimal of the combined 64-bit id (== the positive `id`).
180
+ actor = _SyntheticGameObject("Dodo_C", {"DinoID1": 475230717, "DinoID2": 97170314})
181
+ save = types.SimpleNamespace(is_asa=True)
182
+ rec = _tamed_dict(actor, _status(), {}, None, save=save)
183
+ assert rec["dinoid"] == "2041100387666801546"
184
+ assert rec["dinoid"] == str(rec["id"])
185
+
186
+
187
+ def test_wild_dinoid_ase_vs_asa() -> None:
188
+ obj = _SyntheticGameObject("Raptor_C", {"DinoID1": 475230717, "DinoID2": 97170314})
189
+ assert _wild_dict(obj, _status(), None, False)["dinoid"] == "47523071797170314"
190
+ assert _wild_dict(obj, _status(), None, True)["dinoid"] == "2041100387666801546"
191
+
192
+
193
+ def test_dino_id_str_ase_halves_are_signed_int32() -> None:
194
+ # ASE concat reinterprets each uint32 half as signed int32 (matches C#
195
+ # GetPropertyValue<int>); both-zero collapses to "0" not "00".
196
+ assert _dino_id_str(0x90000000, 5, is_asa=False) == "-18790481925"
197
+ assert _dino_id_str(0, 0, is_asa=False) == "0"
198
+ assert _dino_id_str(0, 0, is_asa=True) == "0"
199
+
200
+
201
+ # --- code-review (max): maturation newborn default --------------------------
202
+
203
+ def test_tamed_maturation_newborn_is_zero() -> None:
204
+ # A baby with no BabyAge is a newborn -> "0" (legacy default), not "100".
205
+ baby = _SyntheticGameObject("Dodo_C", {"bIsBaby": True})
206
+ assert _tamed_dict(baby, _status(), {}, None)["maturation"] == "0"
207
+
208
+
209
+ def test_tamed_maturation_adult_is_hundred() -> None:
210
+ adult = _SyntheticGameObject("Dodo_C", {})
211
+ assert _tamed_dict(adult, _status(), {}, None)["maturation"] == "100"
212
+
213
+
214
+ # --- code-review (max): structure `created` is "" not null ------------------
215
+
216
+ def test_structure_created_empty_string_when_no_anchor() -> None:
217
+ # Legacy CreatedDateTime is DateTime? -> interpolates to "" (never null).
218
+ struct = _SyntheticGameObject("Wall_C", {})
219
+ assert _structure_dict(struct, None, {}, None)["created"] == ""
220
+
221
+
222
+ # --- code-review (max): non-finite floats never crash strict JSON -----------
223
+
224
+ def test_uploaded_item_nan_rating_is_legacy_sentinel() -> None:
225
+ item = UploadedItem.from_ark_data(
226
+ {"ArkTributeItem": {"ItemRating": float("nan"), "ItemDurability": float("inf")}}
227
+ )
228
+ assert item.rating == 0.0001 # legacy ContentItem.cs:62 substitution
229
+ assert item.durability == 0.0
230
+ json.dumps(item.to_dict()) # strict serialization must not raise
231
+
232
+
233
+ def test_uploaded_creature_nan_experience_zeroed() -> None:
234
+ c = UploadedCreature.from_ark_data({"DinoExperiencePoints": float("nan")})
235
+ assert c.experience == 0.0
236
+
237
+
238
+ # --- code-review (max): ASE byte-array enum-name discriminator (C1) ----------
239
+
240
+ def test_byte_array_raw_uint8_path_unchanged() -> None:
241
+ # data_size == count+4 -> raw uint8 (common case; must not regress).
242
+ reader = BinaryReader.from_bytes(b"\x0a\x14\x1e")
243
+ vals = _read_array_elements(reader, "ByteProperty", 3, 3 + 4, "Foo", False, None)
244
+ assert vals == [10, 20, 30]
245
+
246
+
247
+ def test_byte_array_enum_name_path_no_drift() -> None:
248
+ # data_size > count+4 -> 8-byte name refs, not 1-byte uint8 (would drift).
249
+ def _ref(index: int, instance: int) -> bytes:
250
+ return index.to_bytes(4, "little") + instance.to_bytes(4, "little")
251
+
252
+ reader = BinaryReader.from_bytes(_ref(1, 0) + _ref(2, 0))
253
+ vals = _read_array_elements(reader, "ByteProperty", 2, 2 * 8 + 4, "Colors", False, ["Foo", "Bar"])
254
+ assert vals == ["Foo", "Bar"]
255
+ assert reader.remaining == 0 # consumed 8 bytes/elem -> no cursor drift
256
+
257
+
258
+ # --- code-review (max): corrupt count caps survive python -O (D1/D2) ---------
259
+
260
+ def test_checked_count_rejects_absurd_and_negative() -> None:
261
+ absurd = BinaryReader.from_bytes((MAX_OBJECT_COUNT + 1).to_bytes(4, "little"))
262
+ with pytest.raises(CorruptDataError):
263
+ _checked_count(absurd, "test")
264
+ negative = BinaryReader.from_bytes((-1).to_bytes(4, "little", signed=True))
265
+ with pytest.raises(CorruptDataError):
266
+ _checked_count(negative, "test")
267
+
268
+
269
+ def test_checked_count_accepts_valid() -> None:
270
+ assert _checked_count(BinaryReader.from_bytes((42).to_bytes(4, "little")), "test") == 42
@@ -4,7 +4,6 @@ Tests for tribe (.arktribe) parsing - both ASE and ASA formats.
4
4
 
5
5
  from pathlib import Path
6
6
 
7
- import pytest
8
7
 
9
8
  from arkparser import Tribe
10
9
 
@@ -39,7 +39,7 @@ import pytest
39
39
  from arkparser import WorldSave
40
40
  from arkparser.game_objects.game_object import GameObject
41
41
  from arkparser.game_objects.location import LocationData
42
- from arkparser.export import _ancestor_parent, _combine_dino_id
42
+ from arkparser.export import _ancestor_parent
43
43
 
44
44
  _EXAMPLES = Path(__file__).parent.parent / "references" / "examples"
45
45
 
@@ -1,138 +0,0 @@
1
- """Regression tests for the code-review fix batch (fixture-free, pure logic).
2
-
3
- Each test pins a specific reviewed defect so it cannot silently regress:
4
- #1 tamer blanked when a creature is imprinted
5
- #2 inf/nan floats never reach JSON (strict-parser safe)
6
- #5 in-world cryo/vivarium id negated; dinoid stays positive
7
- #13 read_string bounds-checks the length==1 / length==-1 fast paths
8
- #15 read_object_list rejects an absurd (corrupt-header) object count
9
- """
10
-
11
- import json
12
- import types
13
-
14
- import pytest
15
-
16
- from arkparser.common.binary_reader import BinaryReader
17
- from arkparser.common.exceptions import CorruptDataError, EndOfDataError
18
- from arkparser.export import _SyntheticGameObject, _float, _gps_payload, _tamed_dict
19
- from arkparser.game_objects.game_object import MAX_OBJECT_COUNT, read_object_list
20
-
21
-
22
- def _status() -> _SyntheticGameObject:
23
- return _SyntheticGameObject("DinoCharacterStatusComponent_BP_C", {})
24
-
25
-
26
- # --- #2: finite floats only -------------------------------------------------
27
-
28
- def test_float_coerces_non_finite_to_default() -> None:
29
- assert _float(float("inf")) == 0.0
30
- assert _float(float("-inf")) == 0.0
31
- assert _float(float("nan")) == 0.0
32
- assert _float(float("inf"), default=1.5) == 1.5
33
- # finite values pass through unchanged
34
- assert _float(3.25) == 3.25
35
- assert _float(0.0) == 0.0
36
-
37
-
38
- def test_gps_payload_is_strict_json_safe() -> None:
39
- loc = types.SimpleNamespace(x=float("inf"), y=float("nan"), z=-float("inf"))
40
- # _gps_payload only reads ``obj.location``; a namespace stand-in is enough.
41
- obj = types.SimpleNamespace(location=loc)
42
- payload = _gps_payload(obj, None)
43
- # No NaN/Infinity tokens survive — strict parsers (JS, Pydantic) reject them.
44
- text = json.dumps(payload)
45
- assert "Infinity" not in text and "NaN" not in text
46
- json.loads(text, parse_constant=lambda tok: pytest.fail(f"non-finite: {tok}"))
47
- assert payload["ccc"] == "0.0 0.0 0.0"
48
-
49
-
50
- # --- #1: tamer blanked on imprint ------------------------------------------
51
-
52
- def test_tamed_dict_blanks_tamer_when_imprinted_by_player_id() -> None:
53
- actor = _SyntheticGameObject(
54
- "Dodo_C", {"TamerString": "Bob", "ImprinterPlayerDataID": 42}
55
- )
56
- rec = _tamed_dict(actor, _status(), {}, None)
57
- assert rec["tamer"] == ""
58
- assert rec["imprinter_player_id"] == 42
59
-
60
-
61
- def test_tamed_dict_blanks_tamer_when_imprinter_name_present() -> None:
62
- actor = _SyntheticGameObject(
63
- "Dodo_C", {"TamerString": "Bob", "ImprinterName": "Alice"}
64
- )
65
- rec = _tamed_dict(actor, _status(), {}, None)
66
- assert rec["tamer"] == ""
67
- assert rec["imprinter"] == "Alice"
68
-
69
-
70
- def test_tamed_dict_keeps_tamer_when_not_imprinted() -> None:
71
- actor = _SyntheticGameObject("Dodo_C", {"TamerString": "Bob"})
72
- rec = _tamed_dict(actor, _status(), {}, None)
73
- assert rec["tamer"] == "Bob"
74
-
75
-
76
- # --- #5: cryo/vivarium id negation (dinoid stays positive) ------------------
77
-
78
- def test_tamed_dict_negates_id_when_stored() -> None:
79
- actor = _SyntheticGameObject("Dodo_C", {"DinoID1": 100, "DinoID2": 200})
80
- live = _tamed_dict(actor, _status(), {}, None, stored=False)
81
- stored = _tamed_dict(actor, _status(), {}, None, stored=True)
82
- assert live["id"] > 0
83
- assert stored["id"] == -live["id"]
84
- # dinoid is the stable positive identity in BOTH cases (legacy parity).
85
- assert stored["dinoid"] == live["dinoid"]
86
- assert not stored["dinoid"].startswith("-")
87
-
88
-
89
- def test_tamed_dict_negates_id_via_is_in_cryo_prop() -> None:
90
- actor = _SyntheticGameObject(
91
- "Dodo_C", {"DinoID1": 1, "DinoID2": 2, "IsInCryo": True}
92
- )
93
- rec = _tamed_dict(actor, _status(), {}, None)
94
- assert rec["id"] < 0
95
- assert not rec["dinoid"].startswith("-")
96
-
97
-
98
- def test_tamed_dict_zero_id_not_negated() -> None:
99
- actor = _SyntheticGameObject("Dodo_C", {"DinoID1": 0, "DinoID2": 0})
100
- rec = _tamed_dict(actor, _status(), {}, None, stored=True)
101
- assert rec["id"] == 0
102
-
103
-
104
- # --- #13: read_string fast-path bounds checks -------------------------------
105
-
106
- def test_read_string_len1_truncated_raises() -> None:
107
- # length prefix == 1 (single null byte) but no byte follows it.
108
- reader = BinaryReader.from_bytes((1).to_bytes(4, "little"))
109
- with pytest.raises(EndOfDataError):
110
- reader.read_string()
111
-
112
-
113
- def test_read_string_utf16_null_truncated_raises() -> None:
114
- # length prefix == -1 (UTF-16 null, needs 2 bytes) but none follow.
115
- reader = BinaryReader.from_bytes((-1).to_bytes(4, "little", signed=True))
116
- with pytest.raises(EndOfDataError):
117
- reader.read_string()
118
-
119
-
120
- def test_read_string_len1_valid_roundtrips() -> None:
121
- # Happy path still works: prefix 1 + the null byte present -> "".
122
- reader = BinaryReader.from_bytes((1).to_bytes(4, "little") + b"\x00")
123
- assert reader.read_string() == ""
124
-
125
-
126
- # --- #15: object-count sanity bound -----------------------------------------
127
-
128
- def test_read_object_list_rejects_absurd_count() -> None:
129
- # 0xFFFFFFFF as uint32 is far above MAX_OBJECT_COUNT (a misaligned header).
130
- reader = BinaryReader.from_bytes(b"\xff\xff\xff\xff")
131
- with pytest.raises(CorruptDataError):
132
- read_object_list(reader, is_asa=False)
133
-
134
-
135
- def test_read_object_list_zero_count_ok() -> None:
136
- reader = BinaryReader.from_bytes((0).to_bytes(4, "little"))
137
- assert read_object_list(reader, is_asa=False) == []
138
- assert MAX_OBJECT_COUNT > 1_000_000
File without changes
File without changes