arkparser 0.4.2__tar.gz → 0.4.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {arkparser-0.4.2 → arkparser-0.4.3}/PKG-INFO +1 -1
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/__init__.py +1 -1
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/common/binary_reader.py +4 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/data_models.py +3 -1
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/export.py +101 -29
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/files/world_save.py +16 -3
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/game_objects/game_object.py +23 -2
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/properties/compound.py +12 -5
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser.egg-info/PKG-INFO +1 -1
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser.egg-info/SOURCES.txt +1 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/pyproject.toml +1 -1
- arkparser-0.4.3/tests/test_review_fixes.py +138 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/LICENSE +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/README.md +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/common/__init__.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/common/exceptions.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/common/map_config.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/common/normalization.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/common/types.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/common/version_detection.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/files/__init__.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/files/base.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/files/cloud_inventory.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/files/profile.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/files/tribe.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/game_objects/__init__.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/game_objects/container.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/game_objects/location.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/properties/__init__.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/properties/base.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/properties/byte_property.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/properties/primitives.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/properties/registry.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/structs/__init__.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/structs/base.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/structs/colors.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/structs/misc.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/structs/property_list.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/structs/registry.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser/structs/vectors.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser.egg-info/dependency_links.txt +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser.egg-info/requires.txt +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/arkparser.egg-info/top_level.txt +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/setup.cfg +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/tests/test_asa_header_position.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/tests/test_binary_reader.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/tests/test_binary_reader_layouts.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/tests/test_cloud_export.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/tests/test_cloud_inventory.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/tests/test_cryopod_export.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/tests/test_current_stats.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/tests/test_data_models.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/tests/test_export.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/tests/test_game_objects.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/tests/test_profile.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/tests/test_tribe.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/tests/test_v13_property_layouts.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/tests/test_version_detection.py +0 -0
- {arkparser-0.4.2 → arkparser-0.4.3}/tests/test_world_save.py +0 -0
|
@@ -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: [
|
|
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
|
-
|
|
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:
|
|
@@ -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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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 =
|
|
298
|
-
lon =
|
|
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
|
-
|
|
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
|
|
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":
|
|
1086
|
+
"id": display_id,
|
|
1058
1087
|
"tribeid": targeting_team,
|
|
1059
1088
|
"tribe": tribe_name or None,
|
|
1060
|
-
"tamer":
|
|
1061
|
-
"imprinter":
|
|
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":
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -2303,7 +2371,11 @@ def _load_cluster_inventories(
|
|
|
2303
2371
|
continue
|
|
2304
2372
|
try:
|
|
2305
2373
|
loaded.append(CloudInventory.load(entry))
|
|
2306
|
-
except (OSError,
|
|
2374
|
+
except (OSError, ArkParseError) as e:
|
|
2375
|
+
# Skip unreadable/corrupt cluster files but record which one —
|
|
2376
|
+
# a silent drop hides every upload in that file and looks like
|
|
2377
|
+
# the player simply has no uploads. Unexpected errors propagate.
|
|
2378
|
+
logger.warning("Skipping cluster file %s: %s", entry, e)
|
|
2307
2379
|
continue
|
|
2308
2380
|
return loaded
|
|
2309
2381
|
return [inv for inv in cluster if isinstance(inv, CloudInventory)]
|
|
@@ -642,14 +642,24 @@ class WorldSave:
|
|
|
642
642
|
save.is_asa = True
|
|
643
643
|
save._parse_errors = []
|
|
644
644
|
|
|
645
|
+
conn = None
|
|
645
646
|
try:
|
|
646
647
|
conn = sqlite3.connect(str(path))
|
|
647
648
|
save._read_asa_header(conn)
|
|
648
|
-
|
|
649
|
+
# Actor locations are positional enrichment, not load-critical. A
|
|
650
|
+
# malformed/padded ActorTransforms blob must not abort the whole
|
|
651
|
+
# save — record it and continue with object data only. (EndOfDataError
|
|
652
|
+
# subclasses ArkParseError, so this catches both.)
|
|
653
|
+
try:
|
|
654
|
+
save._read_asa_actor_locations(conn)
|
|
655
|
+
except ArkParseError as e:
|
|
656
|
+
save._parse_errors.append(f"ActorTransforms: {e}")
|
|
649
657
|
save._read_asa_game_objects(conn, load_properties, max_objects)
|
|
650
|
-
conn.close()
|
|
651
658
|
except sqlite3.Error as e:
|
|
652
659
|
raise ArkParseError(f"SQLite error reading ASA world save: {e}")
|
|
660
|
+
finally:
|
|
661
|
+
if conn is not None:
|
|
662
|
+
conn.close()
|
|
653
663
|
|
|
654
664
|
save.container = GameObjectContainer(objects=save.objects)
|
|
655
665
|
save.container.build_relationships()
|
|
@@ -721,7 +731,10 @@ class WorldSave:
|
|
|
721
731
|
reader = BinaryReader.from_bytes(row[0])
|
|
722
732
|
self.actor_locations = {}
|
|
723
733
|
|
|
724
|
-
|
|
734
|
+
# Each record is exactly 72 bytes: 16 (GUID) + 6*8 (xyz + pitch/yaw/roll)
|
|
735
|
+
# + 8 (pad). Guard on the full record size so a non-72-aligned tail can't
|
|
736
|
+
# underflow mid-record and raise instead of stopping cleanly.
|
|
737
|
+
while reader.remaining >= 72:
|
|
725
738
|
guid_bytes = reader.read_bytes(16)
|
|
726
739
|
if all(b == 0 for b in guid_bytes):
|
|
727
740
|
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
|
-
|
|
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.
|
|
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
|
|
556
|
-
#
|
|
557
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|