arkparser 0.4.2__tar.gz → 0.4.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {arkparser-0.4.2 → arkparser-0.4.4}/PKG-INFO +2 -3
  2. {arkparser-0.4.2 → arkparser-0.4.4}/README.md +1 -2
  3. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/__init__.py +1 -1
  4. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/common/binary_reader.py +4 -0
  5. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/data_models.py +3 -1
  6. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/export.py +108 -32
  7. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/files/world_save.py +23 -3
  8. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/game_objects/game_object.py +23 -2
  9. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/properties/compound.py +12 -5
  10. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser.egg-info/PKG-INFO +2 -3
  11. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser.egg-info/SOURCES.txt +1 -0
  12. {arkparser-0.4.2 → arkparser-0.4.4}/pyproject.toml +1 -1
  13. arkparser-0.4.4/tests/test_review_fixes.py +138 -0
  14. {arkparser-0.4.2 → arkparser-0.4.4}/LICENSE +0 -0
  15. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/common/__init__.py +0 -0
  16. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/common/exceptions.py +0 -0
  17. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/common/map_config.py +0 -0
  18. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/common/normalization.py +0 -0
  19. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/common/types.py +0 -0
  20. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/common/version_detection.py +0 -0
  21. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/files/__init__.py +0 -0
  22. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/files/base.py +0 -0
  23. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/files/cloud_inventory.py +0 -0
  24. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/files/profile.py +0 -0
  25. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/files/tribe.py +0 -0
  26. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/game_objects/__init__.py +0 -0
  27. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/game_objects/container.py +0 -0
  28. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/game_objects/location.py +0 -0
  29. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/properties/__init__.py +0 -0
  30. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/properties/base.py +0 -0
  31. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/properties/byte_property.py +0 -0
  32. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/properties/primitives.py +0 -0
  33. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/properties/registry.py +0 -0
  34. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/structs/__init__.py +0 -0
  35. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/structs/base.py +0 -0
  36. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/structs/colors.py +0 -0
  37. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/structs/misc.py +0 -0
  38. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/structs/property_list.py +0 -0
  39. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/structs/registry.py +0 -0
  40. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser/structs/vectors.py +0 -0
  41. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser.egg-info/dependency_links.txt +0 -0
  42. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser.egg-info/requires.txt +0 -0
  43. {arkparser-0.4.2 → arkparser-0.4.4}/arkparser.egg-info/top_level.txt +0 -0
  44. {arkparser-0.4.2 → arkparser-0.4.4}/setup.cfg +0 -0
  45. {arkparser-0.4.2 → arkparser-0.4.4}/tests/test_asa_header_position.py +0 -0
  46. {arkparser-0.4.2 → arkparser-0.4.4}/tests/test_binary_reader.py +0 -0
  47. {arkparser-0.4.2 → arkparser-0.4.4}/tests/test_binary_reader_layouts.py +0 -0
  48. {arkparser-0.4.2 → arkparser-0.4.4}/tests/test_cloud_export.py +0 -0
  49. {arkparser-0.4.2 → arkparser-0.4.4}/tests/test_cloud_inventory.py +0 -0
  50. {arkparser-0.4.2 → arkparser-0.4.4}/tests/test_cryopod_export.py +0 -0
  51. {arkparser-0.4.2 → arkparser-0.4.4}/tests/test_current_stats.py +0 -0
  52. {arkparser-0.4.2 → arkparser-0.4.4}/tests/test_data_models.py +0 -0
  53. {arkparser-0.4.2 → arkparser-0.4.4}/tests/test_export.py +0 -0
  54. {arkparser-0.4.2 → arkparser-0.4.4}/tests/test_game_objects.py +0 -0
  55. {arkparser-0.4.2 → arkparser-0.4.4}/tests/test_profile.py +0 -0
  56. {arkparser-0.4.2 → arkparser-0.4.4}/tests/test_tribe.py +0 -0
  57. {arkparser-0.4.2 → arkparser-0.4.4}/tests/test_v13_property_layouts.py +0 -0
  58. {arkparser-0.4.2 → arkparser-0.4.4}/tests/test_version_detection.py +0 -0
  59. {arkparser-0.4.2 → arkparser-0.4.4}/tests/test_world_save.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkparser
3
- Version: 0.4.2
3
+ Version: 0.4.4
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
@@ -359,8 +359,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
359
359
  | `created` | legacy (richer) | ISO 8601 datetime with the local TZ of the parser machine, computed `save.file_mtime + (OriginalCreationTime - game_time)` (mirrors legacy `ContentContainer.GetApproxDateTimeOf`). `null` when the anchors are missing. |
360
360
  | `inventory` | legacy | items from `MyInventoryComponent.InventoryItems` |
361
361
  | `lat`, `lon`, `ccc` | legacy | location via `MapConfig`, **rounded to 2 decimals** (parser-only nicety, not legacy parity) |
362
- | `powered` | added | `bIsPowered` or `bHasFuel` |
363
- | `switched_on` | added | `bContainerActivated` (lamps / fridges / etc.) |
362
+ | `isSwitchedOn` | legacy | `bContainerActivated`, emitted only when the structure is powered (`bIsPowered` or `bHasFuel`); omitted otherwise. Mirrors legacy `ContentStructure.cs` / `ContentPack.cs` (`IsSwitchedOn.HasValue`). |
364
363
  | `decay_reset` | added | `bHasResetDecayTime` |
365
364
  | `last_ally_in_range_seconds` | added | raw `LastInAllyRangeTime` / `LastInAllyRangeTimeSerialized` / `LastInAllyRangeSerialized` (in-game seconds, float) |
366
365
  | `last_ally_in_range` | added | ISO 8601 datetime with local TZ. `null` when the save lacks the anchors. |
@@ -325,8 +325,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
325
325
  | `created` | legacy (richer) | ISO 8601 datetime with the local TZ of the parser machine, computed `save.file_mtime + (OriginalCreationTime - game_time)` (mirrors legacy `ContentContainer.GetApproxDateTimeOf`). `null` when the anchors are missing. |
326
326
  | `inventory` | legacy | items from `MyInventoryComponent.InventoryItems` |
327
327
  | `lat`, `lon`, `ccc` | legacy | location via `MapConfig`, **rounded to 2 decimals** (parser-only nicety, not legacy parity) |
328
- | `powered` | added | `bIsPowered` or `bHasFuel` |
329
- | `switched_on` | added | `bContainerActivated` (lamps / fridges / etc.) |
328
+ | `isSwitchedOn` | legacy | `bContainerActivated`, emitted only when the structure is powered (`bIsPowered` or `bHasFuel`); omitted otherwise. Mirrors legacy `ContentStructure.cs` / `ContentPack.cs` (`IsSwitchedOn.HasValue`). |
330
329
  | `decay_reset` | added | `bHasResetDecayTime` |
331
330
  | `last_ally_in_range_seconds` | added | raw `LastInAllyRangeTime` / `LastInAllyRangeTimeSerialized` / `LastInAllyRangeSerialized` (in-game seconds, float) |
332
331
  | `last_ally_in_range` | added | ISO 8601 datetime with local TZ. `null` when the save lacks the anchors. |
@@ -95,4 +95,4 @@ __all__ = [
95
95
  "ArkParseError",
96
96
  ]
97
97
 
98
- __version__ = "0.4.2"
98
+ __version__ = "0.4.4"
@@ -242,9 +242,13 @@ class BinaryReader:
242
242
  if length == 0:
243
243
  return ""
244
244
  if length == 1:
245
+ if self._pos + 1 > self._size:
246
+ raise EndOfDataError(1, self._size - self._pos)
245
247
  self._pos += 1 # single null byte
246
248
  return ""
247
249
  if length == -1:
250
+ if self._pos + 2 > self._size:
251
+ raise EndOfDataError(2, self._size - self._pos)
248
252
  self._pos += 2 # UTF-16 null
249
253
  return ""
250
254
  if length < 0:
@@ -472,7 +472,9 @@ class CryopodCreature:
472
472
  Both ASA and ASE store cryopod creature data using:
473
473
  - CustomDataStrings: [class_name, display_name, colors_str, ?, gender, ?, ?, ...]
474
474
  - ASE has 7 strings, ASA has 10+ strings (with species at index 9)
475
- - CustomDataFloats: [current_stats x 12, max_stats x 12, ...]
475
+ - CustomDataFloats: ASE = [current x 12, max x 12, +1] (25); ASA = [current
476
+ x 11 (no CraftingSkill), max x 11, extras] (36). See the per-format
477
+ branch below — the layouts genuinely differ in current-stat width.
476
478
  - CustomDataNames: Color names for the 6 color regions
477
479
 
478
480
  Args:
@@ -33,15 +33,20 @@ from __future__ import annotations
33
33
 
34
34
  import datetime as dt
35
35
  import json
36
+ import logging
37
+ import math
36
38
  import re
37
39
  import typing as t
38
40
  from pathlib import Path
39
41
 
42
+ from arkparser.common.exceptions import ArkParseError
40
43
  from arkparser.common.map_config import MapConfig
41
44
  from arkparser.common.normalization import normalize_indexed_data, normalize_indexed_list
42
45
  from arkparser.data_models import CryopodCreature
43
46
  from arkparser.files import CloudInventory, Profile, Tribe
44
47
 
48
+ logger = logging.getLogger(__name__)
49
+
45
50
  _CRYOPOD_CLASS_PATTERNS: tuple[str, ...] = (
46
51
  "Cryopod", "SoulTrap", "Vivarium", "DinoBall",
47
52
  )
@@ -122,9 +127,12 @@ def _float(val: t.Any, default: float = 0.0) -> float:
122
127
  if val is None or val is False:
123
128
  return default
124
129
  try:
125
- return float(val)
130
+ result = float(val)
126
131
  except (TypeError, ValueError):
127
132
  return default
133
+ # inf/nan (corrupt or extreme bit patterns) are not valid JSON tokens and
134
+ # crash strict downstream parsers (JS JSON.parse, Pydantic). Coerce to default.
135
+ return result if math.isfinite(result) else default
128
136
 
129
137
 
130
138
  def _str(val: t.Any) -> str:
@@ -180,7 +188,7 @@ LEGACY_TRIBE_KEYS: frozenset[str] = frozenset({
180
188
  })
181
189
  LEGACY_STRUCT_KEYS: frozenset[str] = frozenset({
182
190
  "id", "tribeid", "tribe", "struct", "name", "locked", "created", "inventory",
183
- "lat", "lon", "ccc",
191
+ "lat", "lon", "ccc", "isSwitchedOn",
184
192
  })
185
193
  LEGACY_MAP_STRUCT_KEYS: frozenset[str] = frozenset({
186
194
  "struct", "inventory", "lat", "lon", "ccc",
@@ -285,17 +293,19 @@ def _gps_payload(
285
293
  loc = getattr(obj, "location", None)
286
294
  if loc is None:
287
295
  return {"ccc": "0 0 0", "lat": 0.0, "lon": 0.0}
288
- x = getattr(loc, "x", 0.0) or 0.0
289
- y = getattr(loc, "y", 0.0) or 0.0
290
- z = getattr(loc, "z", 0.0) or 0.0
296
+ # _float coerces non-finite (inf/nan) coords to 0.0 those are invalid
297
+ # JSON tokens that crash strict downstream parsers.
298
+ x = _float(getattr(loc, "x", 0.0))
299
+ y = _float(getattr(loc, "y", 0.0))
300
+ z = _float(getattr(loc, "z", 0.0))
291
301
  if ndigits is not None:
292
302
  x = round(x, ndigits)
293
303
  y = round(y, ndigits)
294
304
  z = round(z, ndigits)
295
305
  out: dict[str, t.Any] = {"ccc": f"{x} {y} {z}"}
296
306
  if map_config is not None:
297
- lat = float(map_config.ue_to_lat(y))
298
- lon = float(map_config.ue_to_lon(x))
307
+ lat = _float(map_config.ue_to_lat(y))
308
+ lon = _float(map_config.ue_to_lon(x))
299
309
  if ndigits is not None:
300
310
  lat = round(lat, ndigits)
301
311
  lon = round(lon, ndigits)
@@ -331,9 +341,11 @@ def _approx_real_datetime(
331
341
  return None
332
342
  try:
333
343
  offset = float(in_game_time) - game_time
334
- except (TypeError, ValueError):
344
+ return mtime + dt.timedelta(seconds=offset)
345
+ except (TypeError, ValueError, OverflowError, OSError):
346
+ # Mirror legacy GetApproxDateTimeOf's try/catch: a garbage/huge in-game
347
+ # time overflows datetime arithmetic — legacy returns null, so do we.
335
348
  return None
336
- return mtime + dt.timedelta(seconds=offset)
337
349
 
338
350
 
339
351
  def _combine_dino_id(id1: t.Any, id2: t.Any) -> int:
@@ -672,7 +684,7 @@ def _is_meaningful_value(value: t.Any) -> bool:
672
684
  return False
673
685
  if isinstance(value, (list, dict, str)) and len(value) == 0:
674
686
  return False
675
- if isinstance(value, float) and value != value: # NaN
687
+ if isinstance(value, float) and not math.isfinite(value): # NaN or +/-inf
676
688
  return False
677
689
  if isinstance(value, str) and value == "Unknown":
678
690
  return False
@@ -1033,6 +1045,7 @@ def _tamed_dict(
1033
1045
  lookup: dict[t.Any, t.Any],
1034
1046
  map_config: MapConfig | None,
1035
1047
  save: t.Any = None,
1048
+ stored: bool = False,
1036
1049
  ) -> dict[str, t.Any]:
1037
1050
  base_pts = _stat_array(status, "NumberOfLevelUpPointsApplied")
1038
1051
  tamed_pts = _stat_array(status, "NumberOfLevelUpPointsAppliedTamed")
@@ -1040,6 +1053,22 @@ def _tamed_dict(
1040
1053
  base_level = _int(_prop(status, "BaseCharacterLevel"), default=1) or 1
1041
1054
  extra_level = _int(_prop(status, "ExtraCharacterLevel"))
1042
1055
  dino_id = _combine_dino_id(_prop(obj, "DinoID1"), _prop(obj, "DinoID2"))
1056
+ # Legacy negates the id of stored (cryo/vivarium) creatures so they don't
1057
+ # collide with live tames (ContentTamedCreature.cs:122-126/228-232). The
1058
+ # dinoid field stays positive (C# sets DinoId = Id.ToString() before negating).
1059
+ is_stored = (
1060
+ stored
1061
+ or bool(_prop(obj, "IsInCryo", default=False))
1062
+ or bool(_prop(obj, "IsInVivarium", default=False))
1063
+ )
1064
+ display_id = -dino_id if (is_stored and dino_id != 0) else dino_id
1065
+ # Legacy blanks the tamer once a creature is imprinted (ContentTamedCreature
1066
+ # .cs:109-114/215-220): imprinted dinos report an imprinter, not a tamer.
1067
+ imprinter_player_id = _int(_prop(obj, "ImprinterPlayerDataID"))
1068
+ imprinter_name = _str(_prop(obj, "ImprinterName"))
1069
+ tamer = _str(_prop(obj, "TamerString"))
1070
+ if imprinter_player_id > 0 or imprinter_name:
1071
+ tamer = ""
1043
1072
  colors = _colors(obj)
1044
1073
  is_female = bool(_prop(obj, "bIsFemale", default=False))
1045
1074
  targeting_team = _int(_prop(obj, "TargetingTeam"))
@@ -1054,11 +1083,11 @@ def _tamed_dict(
1054
1083
  _, cuddle_iso = _iso_pair(obj, "BabyNextCuddleTime", save)
1055
1084
 
1056
1085
  data: dict[str, t.Any] = {
1057
- "id": dino_id,
1086
+ "id": display_id,
1058
1087
  "tribeid": targeting_team,
1059
1088
  "tribe": tribe_name or None,
1060
- "tamer": _str(_prop(obj, "TamerString")),
1061
- "imprinter": _str(_prop(obj, "ImprinterName")),
1089
+ "tamer": tamer,
1090
+ "imprinter": imprinter_name,
1062
1091
  "imprint": _float(_prop(status, "DinoImprintingQuality")),
1063
1092
  "creature": getattr(obj, "class_name", "") or "",
1064
1093
  "name": _str(_prop(obj, "TamedName")),
@@ -1104,7 +1133,7 @@ def _tamed_dict(
1104
1133
  else None
1105
1134
  ),
1106
1135
  "current_stats": _current_stats_dict(status),
1107
- "imprinter_player_id": _int(_prop(obj, "ImprinterPlayerDataID")),
1136
+ "imprinter_player_id": imprinter_player_id,
1108
1137
  "imprinter_net_id": _str(_prop(obj, "ImprinterPlayerUniqueNetId")),
1109
1138
  "taming_team_id": _int(_prop(obj, "TamingTeamID")),
1110
1139
  "owning_player_id": _int(_prop(obj, "OwningPlayerID")),
@@ -1251,7 +1280,7 @@ def _export_world_cryopods(
1251
1280
  for key, val in owner_info.items():
1252
1281
  if val and not cryo.creature_props.get(key):
1253
1282
  cryo.creature_props[key] = val
1254
- record = _tamed_dict(actor, status, empty_lookup, map_config, save)
1283
+ record = _tamed_dict(actor, status, empty_lookup, map_config, save, stored=True)
1255
1284
  # The synthetic actor carries no IsInCryo property; force the legacy
1256
1285
  # flag so consumers can distinguish in-world tames from stored ones.
1257
1286
  record["cryo"] = True
@@ -1312,11 +1341,29 @@ def _cryo_tamed_record(
1312
1341
  cryo: CryopodCreature,
1313
1342
  map_config: MapConfig | None,
1314
1343
  empty_lookup: dict[t.Any, t.Any],
1344
+ upload_time: int = 0,
1315
1345
  ) -> dict[str, t.Any]:
1316
- """Decode one cryopod blob into a tamed record flagged ``cryo=True``."""
1346
+ """Decode one cryopod blob into a tamed record flagged ``cryo=True``.
1347
+
1348
+ ``upload_time`` (unix seconds; cluster uploads only) populates
1349
+ ``uploadedTime`` to match legacy (ContentContainer.cs:387-389); 0 omits it.
1350
+ """
1317
1351
  actor, status = _cryo_props_to_synthetic(cryo)
1352
+ # Cluster-UPLOADED creatures are not cryo/vivarium in legacy terms (their
1353
+ # IsInCryo is false), so legacy keeps their id positive — do NOT negate.
1354
+ # Only genuine in-world cryopod/vivarium creatures negate, via
1355
+ # _export_world_cryopods passing stored=True directly to _tamed_dict.
1318
1356
  record = _tamed_dict(actor, status, empty_lookup, map_config)
1319
1357
  record["cryo"] = True
1358
+ ut = _int(upload_time)
1359
+ if ut:
1360
+ # uploadedTime doubles as the downstream "is uploaded" discriminator.
1361
+ try:
1362
+ record["uploadedTime"] = dt.datetime.fromtimestamp(
1363
+ ut, tz=dt.timezone.utc
1364
+ ).isoformat()
1365
+ except (OverflowError, OSError, ValueError):
1366
+ pass
1320
1367
  return record
1321
1368
 
1322
1369
 
@@ -1391,7 +1438,11 @@ def export_cluster_uploads(
1391
1438
  cryo = CryopodCreature.from_cryopod_bytes(byte_arr)
1392
1439
  if cryo is None:
1393
1440
  continue
1394
- _append_unique_tame(out, seen_ids, _cryo_tamed_record(cryo, map_config, empty_lookup))
1441
+ upload_time = _int(entry.get("UploadTime"))
1442
+ _append_unique_tame(
1443
+ out, seen_ids,
1444
+ _cryo_tamed_record(cryo, map_config, empty_lookup, upload_time),
1445
+ )
1395
1446
  # Cryopods uploaded as items (rare but present, especially in ASA
1396
1447
  # tribute transfers) embed a CryopodCreature in CustomItemDatas.
1397
1448
  for item in inv.uploaded_items:
@@ -1400,11 +1451,14 @@ def export_cluster_uploads(
1400
1451
  cryo = item.cryopod_creature
1401
1452
  if cryo is None:
1402
1453
  continue
1403
- _append_unique_tame(out, seen_ids, _cryo_tamed_record(cryo, map_config, empty_lookup))
1454
+ _append_unique_tame(
1455
+ out, seen_ids,
1456
+ _cryo_tamed_record(cryo, map_config, empty_lookup, _int(item.upload_time)),
1457
+ )
1404
1458
  return out
1405
1459
 
1406
1460
 
1407
- def _uploaded_item_dict(item: t.Any) -> dict[str, t.Any]:
1461
+ def _uploaded_item_dict(item: t.Any, save: t.Any = None) -> dict[str, t.Any]:
1408
1462
  """Shape a single ``UploadedItem`` as an inventory-style entry.
1409
1463
 
1410
1464
  Mirrors ``_inventory_items``: ``itemId``/``qty``/``blueprint`` at top,
@@ -1451,16 +1505,28 @@ def _uploaded_item_dict(item: t.Any) -> dict[str, t.Any]:
1451
1505
  snake = _pascal_to_snake(name)
1452
1506
  pre[snake] = _normalize_stat_value(snake, value)
1453
1507
  entry.update(_apply_stat_aliases(pre, item_class=class_name))
1454
- upload_time = item.upload_time
1455
- if upload_time:
1456
- # Key name matches the legacy ASV item schema (uploadedTime), which
1457
- # downstream consumers use as the "is uploaded" discriminator.
1508
+ # Legacy derives item uploadedTime from the inner ArkTributeItem CreationTime
1509
+ # via the in-game anchor file_mtime + (t - game_time) (ContentItem.cs:52 +
1510
+ # ContentContainer.cs:301-303). ASA cluster items instead carry a real unix
1511
+ # epoch in the outer UploadTime (CreationTime is 0), so fall back to that.
1512
+ # Always emit an ISO string (never a raw int) so the field type is stable.
1513
+ iso: str | None = None
1514
+ creation_time = (
1515
+ _float(ark_tribute.get("CreationTime")) if isinstance(ark_tribute, dict) else 0.0
1516
+ )
1517
+ if creation_time and save is not None:
1518
+ anchored = _approx_real_datetime(creation_time, save)
1519
+ if anchored is not None:
1520
+ iso = anchored.isoformat()
1521
+ if iso is None and item.upload_time:
1458
1522
  try:
1459
- entry["uploadedTime"] = dt.datetime.fromtimestamp(
1460
- float(upload_time), tz=dt.timezone.utc
1523
+ iso = dt.datetime.fromtimestamp(
1524
+ float(item.upload_time), tz=dt.timezone.utc
1461
1525
  ).isoformat()
1462
1526
  except (OverflowError, OSError, ValueError, TypeError):
1463
- entry["uploadedTime"] = upload_time
1527
+ iso = None
1528
+ if iso is not None:
1529
+ entry["uploadedTime"] = iso
1464
1530
  return entry
1465
1531
 
1466
1532
 
@@ -1486,6 +1552,7 @@ def _is_placeholder_item(item: t.Any) -> bool:
1486
1552
 
1487
1553
  def export_cluster_items(
1488
1554
  cluster_inventories: t.Iterable[CloudInventory],
1555
+ save: t.Any = None,
1489
1556
  ) -> list[dict[str, t.Any]]:
1490
1557
  """Export every uploaded item across the supplied cloud inventories.
1491
1558
 
@@ -1501,7 +1568,7 @@ def export_cluster_items(
1501
1568
  for item in inv.uploaded_items:
1502
1569
  if _is_placeholder_item(item):
1503
1570
  continue
1504
- out.append(_uploaded_item_dict(item))
1571
+ out.append(_uploaded_item_dict(item, save))
1505
1572
  return out
1506
1573
 
1507
1574
 
@@ -1726,6 +1793,7 @@ def _player_status_by_data_id(save: t.Any, lookup: dict[t.Any, t.Any]) -> dict[i
1726
1793
 
1727
1794
  def _cluster_items_by_xuid(
1728
1795
  cluster_inventories: t.Iterable[CloudInventory],
1796
+ save: t.Any = None,
1729
1797
  ) -> dict[str, list[dict[str, t.Any]]]:
1730
1798
  """Group uploaded items by cloud-file stem (= player's unique_id / xuid).
1731
1799
 
@@ -1747,7 +1815,7 @@ def _cluster_items_by_xuid(
1747
1815
  for item in inv.uploaded_items:
1748
1816
  if _is_placeholder_item(item):
1749
1817
  continue
1750
- entry = _uploaded_item_dict(item)
1818
+ entry = _uploaded_item_dict(item, save)
1751
1819
  entry["uploaded"] = True
1752
1820
  bucket.append(entry)
1753
1821
  return out
@@ -1762,7 +1830,7 @@ def export_players(
1762
1830
  lookup = _save_lookup(save)
1763
1831
  pawn_status_by_id = _player_status_by_data_id(save, lookup)
1764
1832
  cluster_items = (
1765
- _cluster_items_by_xuid(cluster_inventories) if cluster_inventories else {}
1833
+ _cluster_items_by_xuid(cluster_inventories, save) if cluster_inventories else {}
1766
1834
  )
1767
1835
  results: list[dict[str, t.Any]] = []
1768
1836
  for entry in profiles:
@@ -2144,8 +2212,6 @@ def _structure_dict(
2144
2212
  "locked": locked,
2145
2213
  "created": _structure_created(obj, save),
2146
2214
  "inventory": _inventory_items(obj, lookup),
2147
- "powered": powered,
2148
- "switched_on": bool(_prop(obj, "bContainerActivated", default=False)),
2149
2215
  "decay_reset": bool(_prop(obj, "bHasResetDecayTime", default=False)),
2150
2216
  "last_ally_in_range": (
2151
2217
  d.isoformat()
@@ -2188,6 +2254,12 @@ def _structure_dict(
2188
2254
  "pin_code": _pin_code(obj),
2189
2255
  }
2190
2256
  data.update(_gps_payload(obj, map_config, ndigits=2))
2257
+ # Legacy ASVExport emits isSwitchedOn only for powered structures (ContentStructure.cs:58):
2258
+ # bContainerActivated when (bIsPowered or bHasFuel), omitted otherwise. Mirror that exactly so
2259
+ # on/off state stays a single field. Kept in LEGACY_STRUCT_KEYS so a powered-but-off False is
2260
+ # not pruned by _compact.
2261
+ if powered:
2262
+ data["isSwitchedOn"] = bool(_prop(obj, "bContainerActivated", default=False))
2191
2263
  return _compact(data, LEGACY_STRUCT_KEYS)
2192
2264
 
2193
2265
 
@@ -2303,7 +2375,11 @@ def _load_cluster_inventories(
2303
2375
  continue
2304
2376
  try:
2305
2377
  loaded.append(CloudInventory.load(entry))
2306
- except (OSError, ValueError, Exception): # noqa: BLE001
2378
+ except (OSError, ArkParseError) as e:
2379
+ # Skip unreadable/corrupt cluster files but record which one —
2380
+ # a silent drop hides every upload in that file and looks like
2381
+ # the player simply has no uploads. Unexpected errors propagate.
2382
+ logger.warning("Skipping cluster file %s: %s", entry, e)
2307
2383
  continue
2308
2384
  return loaded
2309
2385
  return [inv for inv in cluster if isinstance(inv, CloudInventory)]
@@ -151,6 +151,13 @@ class WorldSave:
151
151
  # ASA-specific
152
152
  actor_locations: dict[str, LocationData] = field(default_factory=dict)
153
153
 
154
+ # Caller-assembled sidecars (NOT parsed from the .ark): the orchestrator
155
+ # globs the map dir for *.arkprofile / *.arktribe and assigns these before
156
+ # calling export_all, which reads them to enrich player/tribe records.
157
+ # Default to empty lists so export still runs when no sidecars are loaded.
158
+ profiles: list[t.Any] = field(default_factory=list)
159
+ tribes: list[t.Any] = field(default_factory=list)
160
+
154
161
  # ------------------------------------------------------------------
155
162
  # Internal state
156
163
  # ------------------------------------------------------------------
@@ -642,14 +649,24 @@ class WorldSave:
642
649
  save.is_asa = True
643
650
  save._parse_errors = []
644
651
 
652
+ conn = None
645
653
  try:
646
654
  conn = sqlite3.connect(str(path))
647
655
  save._read_asa_header(conn)
648
- save._read_asa_actor_locations(conn)
656
+ # Actor locations are positional enrichment, not load-critical. A
657
+ # malformed/padded ActorTransforms blob must not abort the whole
658
+ # save — record it and continue with object data only. (EndOfDataError
659
+ # subclasses ArkParseError, so this catches both.)
660
+ try:
661
+ save._read_asa_actor_locations(conn)
662
+ except ArkParseError as e:
663
+ save._parse_errors.append(f"ActorTransforms: {e}")
649
664
  save._read_asa_game_objects(conn, load_properties, max_objects)
650
- conn.close()
651
665
  except sqlite3.Error as e:
652
666
  raise ArkParseError(f"SQLite error reading ASA world save: {e}")
667
+ finally:
668
+ if conn is not None:
669
+ conn.close()
653
670
 
654
671
  save.container = GameObjectContainer(objects=save.objects)
655
672
  save.container.build_relationships()
@@ -721,7 +738,10 @@ class WorldSave:
721
738
  reader = BinaryReader.from_bytes(row[0])
722
739
  self.actor_locations = {}
723
740
 
724
- while reader.remaining >= 16:
741
+ # Each record is exactly 72 bytes: 16 (GUID) + 6*8 (xyz + pitch/yaw/roll)
742
+ # + 8 (pad). Guard on the full record size so a non-72-aligned tail can't
743
+ # underflow mid-record and raise instead of stopping cleanly.
744
+ while reader.remaining >= 72:
725
745
  guid_bytes = reader.read_bytes(16)
726
746
  if all(b == 0 for b in guid_bytes):
727
747
  break
@@ -12,6 +12,7 @@ import typing as t
12
12
  from collections import defaultdict
13
13
  from dataclasses import dataclass, field
14
14
 
15
+ from ..common.exceptions import CorruptDataError
15
16
  from ..properties.registry import read_properties, read_property
16
17
  from .location import LocationData
17
18
 
@@ -19,6 +20,11 @@ if t.TYPE_CHECKING:
19
20
  from ..common.binary_reader import BinaryReader
20
21
  from ..properties.base import Property
21
22
 
23
+ # Upper sanity bound for the ASE object-table count. Legitimate maps top out
24
+ # in the low millions; a misaligned header decodes a garbage count (commonly a
25
+ # negative int32 read as a ~4-billion uint), so anything past this is corruption.
26
+ MAX_OBJECT_COUNT = 100_000_000
27
+
22
28
 
23
29
  @dataclass(slots=True)
24
30
  class GameObject:
@@ -69,6 +75,11 @@ class GameObject:
69
75
  if bucket is None:
70
76
  idx[prop.name] = {prop.index: prop}
71
77
  else:
78
+ # First-writer wins, matching legacy C# GetPropertyValue
79
+ # (IPropertyContainer.cs:45) which returns the first (name,
80
+ # index) match. _serialize_properties keeps all occurrences in
81
+ # the additive `properties` dict; lookups intentionally mirror
82
+ # legacy here. Do not switch to last-wins.
72
83
  bucket.setdefault(prop.index, prop)
73
84
  self._prop_index = idx
74
85
  return idx
@@ -163,7 +174,12 @@ class GameObject:
163
174
  return True
164
175
  return False
165
176
  if prop.type_name == "ArrayProperty" and getattr(prop, "array_type", "") == "ObjectProperty":
166
- return all(isinstance(v, (tuple, list)) and len(v) == 2 and v[0] == "id" for v in prop.value)
177
+ # all() over an empty list is True; require a non-empty value so an
178
+ # empty object-ref array is serialized rather than silently dropped.
179
+ return bool(prop.value) and all(
180
+ isinstance(v, (tuple, list)) and len(v) == 2 and v[0] == "id"
181
+ for v in prop.value
182
+ )
167
183
  return False
168
184
 
169
185
  @staticmethod
@@ -374,7 +390,12 @@ def read_object_list(
374
390
  Returns:
375
391
  List of GameObject instances with headers populated.
376
392
  """
377
- count = reader.read_int32()
393
+ count = reader.read_uint32()
394
+ if count > MAX_OBJECT_COUNT:
395
+ raise CorruptDataError(
396
+ f"object count {count} exceeds maximum {MAX_OBJECT_COUNT} "
397
+ "(likely a misaligned object table header)"
398
+ )
378
399
  objects: list[GameObject] = []
379
400
 
380
401
  for i in range(count):
@@ -13,6 +13,7 @@ import typing as t
13
13
  import uuid
14
14
  from dataclasses import dataclass, field
15
15
 
16
+ from ..common.exceptions import UnknownPropertyError
16
17
  from ..structs import registry as struct_registry
17
18
  from .base import Property, PropertyHeader, read_name
18
19
 
@@ -552,9 +553,12 @@ def _read_array_elements(
552
553
  else:
553
554
  values.append(struct)
554
555
  else:
555
- # Unknown array type - read as raw bytes
556
- # We can't determine element size, so just note it
557
- values.append(f"<UnknownArray({array_type}): {count} elements>")
556
+ # Unknown element type: element sizes are indeterminate, so reading on
557
+ # would desync the stream and corrupt every later property in this
558
+ # object. Raise instead of emitting a placeholder — callers parse each
559
+ # object in isolation (per-blob in ASA, per-offset in ASE) and record
560
+ # the failure, so the damage stays contained to this one object.
561
+ raise UnknownPropertyError(f"ArrayProperty element type {array_type!r}")
558
562
 
559
563
  return values
560
564
 
@@ -665,8 +669,11 @@ def _read_worldsave_array_elements(
665
669
  _padding = reader.read_int32()
666
670
  values.append(ref_name)
667
671
  else:
668
- # Unknown type
669
- values.append(f"<UnknownWorldSaveArray({element_type}): {count} elements>")
672
+ # Unknown element type: bail rather than desync the object stream (see
673
+ # _read_array_elements). The per-object parse guard contains the failure.
674
+ raise UnknownPropertyError(
675
+ f"WorldSave ArrayProperty element type {element_type!r}"
676
+ )
670
677
 
671
678
  return values
672
679
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkparser
3
- Version: 0.4.2
3
+ Version: 0.4.4
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
@@ -359,8 +359,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
359
359
  | `created` | legacy (richer) | ISO 8601 datetime with the local TZ of the parser machine, computed `save.file_mtime + (OriginalCreationTime - game_time)` (mirrors legacy `ContentContainer.GetApproxDateTimeOf`). `null` when the anchors are missing. |
360
360
  | `inventory` | legacy | items from `MyInventoryComponent.InventoryItems` |
361
361
  | `lat`, `lon`, `ccc` | legacy | location via `MapConfig`, **rounded to 2 decimals** (parser-only nicety, not legacy parity) |
362
- | `powered` | added | `bIsPowered` or `bHasFuel` |
363
- | `switched_on` | added | `bContainerActivated` (lamps / fridges / etc.) |
362
+ | `isSwitchedOn` | legacy | `bContainerActivated`, emitted only when the structure is powered (`bIsPowered` or `bHasFuel`); omitted otherwise. Mirrors legacy `ContentStructure.cs` / `ContentPack.cs` (`IsSwitchedOn.HasValue`). |
364
363
  | `decay_reset` | added | `bHasResetDecayTime` |
365
364
  | `last_ally_in_range_seconds` | added | raw `LastInAllyRangeTime` / `LastInAllyRangeTimeSerialized` / `LastInAllyRangeSerialized` (in-game seconds, float) |
366
365
  | `last_ally_in_range` | added | ISO 8601 datetime with local TZ. `null` when the save lacks the anchors. |
@@ -50,6 +50,7 @@ tests/test_data_models.py
50
50
  tests/test_export.py
51
51
  tests/test_game_objects.py
52
52
  tests/test_profile.py
53
+ tests/test_review_fixes.py
53
54
  tests/test_tribe.py
54
55
  tests/test_v13_property_layouts.py
55
56
  tests/test_version_detection.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "arkparser"
7
- version = "0.4.2"
7
+ version = "0.4.4"
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,138 @@
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
File without changes