arkparser 0.4.3__tar.gz → 0.4.5__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.3 → arkparser-0.4.5}/PKG-INFO +4 -4
- {arkparser-0.4.3 → arkparser-0.4.5}/README.md +3 -3
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/__init__.py +1 -1
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/common/binary_reader.py +0 -1
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/common/map_config.py +0 -1
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/common/version_detection.py +0 -1
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/export.py +8 -4
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/files/profile.py +19 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/files/world_save.py +65 -24
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/properties/compound.py +30 -8
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser.egg-info/PKG-INFO +4 -4
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser.egg-info/SOURCES.txt +2 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/pyproject.toml +1 -1
- arkparser-0.4.5/tests/test_asa_name_table.py +51 -0
- arkparser-0.4.5/tests/test_ase_cluster_drift.py +51 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/LICENSE +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/common/__init__.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/common/exceptions.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/common/normalization.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/common/types.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/data_models.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/files/__init__.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/files/base.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/files/cloud_inventory.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/files/tribe.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/game_objects/__init__.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/game_objects/container.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/game_objects/game_object.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/game_objects/location.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/properties/__init__.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/properties/base.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/properties/byte_property.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/properties/primitives.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/properties/registry.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/structs/__init__.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/structs/base.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/structs/colors.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/structs/misc.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/structs/property_list.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/structs/registry.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser/structs/vectors.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser.egg-info/dependency_links.txt +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser.egg-info/requires.txt +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/arkparser.egg-info/top_level.txt +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/setup.cfg +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/tests/test_asa_header_position.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/tests/test_binary_reader.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/tests/test_binary_reader_layouts.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/tests/test_cloud_export.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/tests/test_cloud_inventory.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/tests/test_cryopod_export.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/tests/test_current_stats.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/tests/test_data_models.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/tests/test_export.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/tests/test_game_objects.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/tests/test_profile.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/tests/test_review_fixes.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/tests/test_tribe.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/tests/test_v13_property_layouts.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/tests/test_version_detection.py +0 -0
- {arkparser-0.4.3 → arkparser-0.4.5}/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.
|
|
3
|
+
Version: 0.4.5
|
|
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
|
|
@@ -302,7 +302,8 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
|
|
|
302
302
|
| `torp`, `temp` | added | same source, indices legacy never emitted |
|
|
303
303
|
| `active` | legacy | last-active datetime; currently `null` (placeholder for parity) |
|
|
304
304
|
| `ccc` | legacy | `"0 0 0"` (no in-world position from profile/cluster source) |
|
|
305
|
-
| `achievements
|
|
305
|
+
| `achievements` | legacy | reserved array (currently empty for parity) |
|
|
306
|
+
| `netAddress` | legacy (now populated) | Last client IP ARK persisted (`SavedNetworkAddress` in profile `MyData`). Legacy ASVExport reads the same field (ContentPlayer.cs:157 ASE / :341 ASA). `""` when the profile lacks it (e.g. never-played placeholders). ASA stores a clean IPv4/IPv6 string; some ASE saves store an engine-truncated value (e.g. `"[2001"`) reproduced verbatim, matching legacy. |
|
|
306
307
|
| `steamid`, `dataFile` | legacy | platform net id and `{steamid}.arkprofile` filename |
|
|
307
308
|
| `active` | legacy (now populated) | ISO 8601 datetime of last login, converted from profile `LastLoginTime` or in-world pawn `SavedLastTimeHadController`. Legacy schema reserved the field but the old C# exporter only filled it for in-world pawns; the parser fills it for profiles too. `null` when neither source is present. |
|
|
308
309
|
| `lat`, `lon`, `ccc` | legacy (now populated) | In-world position when the record was built from a `PlayerPawn` GameObject. Records sourced from profile / cluster files keep the legacy `0` / `"0 0 0"` placeholders since profiles carry no world position. |
|
|
@@ -359,8 +360,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
|
|
|
359
360
|
| `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
361
|
| `inventory` | legacy | items from `MyInventoryComponent.InventoryItems` |
|
|
361
362
|
| `lat`, `lon`, `ccc` | legacy | location via `MapConfig`, **rounded to 2 decimals** (parser-only nicety, not legacy parity) |
|
|
362
|
-
| `
|
|
363
|
-
| `switched_on` | added | `bContainerActivated` (lamps / fridges / etc.) |
|
|
363
|
+
| `isSwitchedOn` | legacy | `bContainerActivated`, emitted only when the structure is powered (`bIsPowered` or `bHasFuel`); omitted otherwise. Mirrors legacy `ContentStructure.cs` / `ContentPack.cs` (`IsSwitchedOn.HasValue`). |
|
|
364
364
|
| `decay_reset` | added | `bHasResetDecayTime` |
|
|
365
365
|
| `last_ally_in_range_seconds` | added | raw `LastInAllyRangeTime` / `LastInAllyRangeTimeSerialized` / `LastInAllyRangeSerialized` (in-game seconds, float) |
|
|
366
366
|
| `last_ally_in_range` | added | ISO 8601 datetime with local TZ. `null` when the save lacks the anchors. |
|
|
@@ -268,7 +268,8 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
|
|
|
268
268
|
| `torp`, `temp` | added | same source, indices legacy never emitted |
|
|
269
269
|
| `active` | legacy | last-active datetime; currently `null` (placeholder for parity) |
|
|
270
270
|
| `ccc` | legacy | `"0 0 0"` (no in-world position from profile/cluster source) |
|
|
271
|
-
| `achievements
|
|
271
|
+
| `achievements` | legacy | reserved array (currently empty for parity) |
|
|
272
|
+
| `netAddress` | legacy (now populated) | Last client IP ARK persisted (`SavedNetworkAddress` in profile `MyData`). Legacy ASVExport reads the same field (ContentPlayer.cs:157 ASE / :341 ASA). `""` when the profile lacks it (e.g. never-played placeholders). ASA stores a clean IPv4/IPv6 string; some ASE saves store an engine-truncated value (e.g. `"[2001"`) reproduced verbatim, matching legacy. |
|
|
272
273
|
| `steamid`, `dataFile` | legacy | platform net id and `{steamid}.arkprofile` filename |
|
|
273
274
|
| `active` | legacy (now populated) | ISO 8601 datetime of last login, converted from profile `LastLoginTime` or in-world pawn `SavedLastTimeHadController`. Legacy schema reserved the field but the old C# exporter only filled it for in-world pawns; the parser fills it for profiles too. `null` when neither source is present. |
|
|
274
275
|
| `lat`, `lon`, `ccc` | legacy (now populated) | In-world position when the record was built from a `PlayerPawn` GameObject. Records sourced from profile / cluster files keep the legacy `0` / `"0 0 0"` placeholders since profiles carry no world position. |
|
|
@@ -325,8 +326,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
|
|
|
325
326
|
| `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
327
|
| `inventory` | legacy | items from `MyInventoryComponent.InventoryItems` |
|
|
327
328
|
| `lat`, `lon`, `ccc` | legacy | location via `MapConfig`, **rounded to 2 decimals** (parser-only nicety, not legacy parity) |
|
|
328
|
-
| `
|
|
329
|
-
| `switched_on` | added | `bContainerActivated` (lamps / fridges / etc.) |
|
|
329
|
+
| `isSwitchedOn` | legacy | `bContainerActivated`, emitted only when the structure is powered (`bIsPowered` or `bHasFuel`); omitted otherwise. Mirrors legacy `ContentStructure.cs` / `ContentPack.cs` (`IsSwitchedOn.HasValue`). |
|
|
330
330
|
| `decay_reset` | added | `bHasResetDecayTime` |
|
|
331
331
|
| `last_ally_in_range_seconds` | added | raw `LastInAllyRangeTime` / `LastInAllyRangeTimeSerialized` / `LastInAllyRangeSerialized` (in-game seconds, float) |
|
|
332
332
|
| `last_ally_in_range` | added | ISO 8601 datetime with local TZ. `null` when the save lacks the anchors. |
|
|
@@ -188,7 +188,7 @@ LEGACY_TRIBE_KEYS: frozenset[str] = frozenset({
|
|
|
188
188
|
})
|
|
189
189
|
LEGACY_STRUCT_KEYS: frozenset[str] = frozenset({
|
|
190
190
|
"id", "tribeid", "tribe", "struct", "name", "locked", "created", "inventory",
|
|
191
|
-
"lat", "lon", "ccc",
|
|
191
|
+
"lat", "lon", "ccc", "isSwitchedOn",
|
|
192
192
|
})
|
|
193
193
|
LEGACY_MAP_STRUCT_KEYS: frozenset[str] = frozenset({
|
|
194
194
|
"struct", "inventory", "lat", "lon", "ccc",
|
|
@@ -1684,7 +1684,7 @@ def _player_from_profile(
|
|
|
1684
1684
|
"ccc": "0 0 0",
|
|
1685
1685
|
"achievements": [],
|
|
1686
1686
|
"inventory": [],
|
|
1687
|
-
"netAddress":
|
|
1687
|
+
"netAddress": _str(profile.last_net_address),
|
|
1688
1688
|
"engram_points": profile.total_engram_points,
|
|
1689
1689
|
"experience": _int(profile.experience),
|
|
1690
1690
|
"current_stats": _current_stats_dict(status),
|
|
@@ -2212,8 +2212,6 @@ def _structure_dict(
|
|
|
2212
2212
|
"locked": locked,
|
|
2213
2213
|
"created": _structure_created(obj, save),
|
|
2214
2214
|
"inventory": _inventory_items(obj, lookup),
|
|
2215
|
-
"powered": powered,
|
|
2216
|
-
"switched_on": bool(_prop(obj, "bContainerActivated", default=False)),
|
|
2217
2215
|
"decay_reset": bool(_prop(obj, "bHasResetDecayTime", default=False)),
|
|
2218
2216
|
"last_ally_in_range": (
|
|
2219
2217
|
d.isoformat()
|
|
@@ -2256,6 +2254,12 @@ def _structure_dict(
|
|
|
2256
2254
|
"pin_code": _pin_code(obj),
|
|
2257
2255
|
}
|
|
2258
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))
|
|
2259
2263
|
return _compact(data, LEGACY_STRUCT_KEYS)
|
|
2260
2264
|
|
|
2261
2265
|
|
|
@@ -207,6 +207,25 @@ class Profile(ArkFile):
|
|
|
207
207
|
except (TypeError, ValueError):
|
|
208
208
|
return None
|
|
209
209
|
|
|
210
|
+
@property
|
|
211
|
+
def last_net_address(self) -> str | None:
|
|
212
|
+
"""Last client IP / network address ARK persisted for this player.
|
|
213
|
+
|
|
214
|
+
Read from ``SavedNetworkAddress`` on the profile's ``MyData`` struct
|
|
215
|
+
(same level as ``LastLoginTime``). Legacy ASVExport reads this exact
|
|
216
|
+
field (ContentPlayer.cs:157 ASE / :341 ASA) and emits it as the
|
|
217
|
+
``netAddress`` player key. Returns ``None`` when the field is absent
|
|
218
|
+
(e.g. empty placeholder profiles that were never played).
|
|
219
|
+
|
|
220
|
+
ASA stores a clean IPv4/IPv6 string; some ASE saves store an
|
|
221
|
+
engine-truncated value (e.g. ``"[2001"``) which is reproduced
|
|
222
|
+
verbatim - matching what legacy ASVExport emits for the same save.
|
|
223
|
+
"""
|
|
224
|
+
val = self._player_data.get("SavedNetworkAddress")
|
|
225
|
+
if val is None:
|
|
226
|
+
return None
|
|
227
|
+
return str(val)
|
|
228
|
+
|
|
210
229
|
@property
|
|
211
230
|
def total_engram_points(self) -> int:
|
|
212
231
|
"""Get total engram points spent."""
|
|
@@ -44,6 +44,11 @@ logger = logging.getLogger(__name__)
|
|
|
44
44
|
# ASE GUIDs are always all-zero; precomputed sentinel skips UUID construction.
|
|
45
45
|
_ZERO_GUID = b"\x00" * 16
|
|
46
46
|
|
|
47
|
+
# Upper bound on ASA name-table entries (largest observed real table ~4.6k).
|
|
48
|
+
# A count above this means the read is misaligned (a garbage int32 length),
|
|
49
|
+
# so we fail loudly instead of looping over ~billions of phantom entries.
|
|
50
|
+
_MAX_ASA_NAME_TABLE = 1_000_000
|
|
51
|
+
|
|
47
52
|
|
|
48
53
|
@dataclass
|
|
49
54
|
class EmbeddedData:
|
|
@@ -151,6 +156,13 @@ class WorldSave:
|
|
|
151
156
|
# ASA-specific
|
|
152
157
|
actor_locations: dict[str, LocationData] = field(default_factory=dict)
|
|
153
158
|
|
|
159
|
+
# Caller-assembled sidecars (NOT parsed from the .ark): the orchestrator
|
|
160
|
+
# globs the map dir for *.arkprofile / *.arktribe and assigns these before
|
|
161
|
+
# calling export_all, which reads them to enrich player/tribe records.
|
|
162
|
+
# Default to empty lists so export still runs when no sidecars are loaded.
|
|
163
|
+
profiles: list[t.Any] = field(default_factory=list)
|
|
164
|
+
tribes: list[t.Any] = field(default_factory=list)
|
|
165
|
+
|
|
154
166
|
# ------------------------------------------------------------------
|
|
155
167
|
# Internal state
|
|
156
168
|
# ------------------------------------------------------------------
|
|
@@ -676,50 +688,79 @@ class WorldSave:
|
|
|
676
688
|
reader = BinaryReader.from_bytes(row[0])
|
|
677
689
|
|
|
678
690
|
self.version = reader.read_int16()
|
|
679
|
-
|
|
680
|
-
# v14+ adds two int32 fields (unk1,
|
|
691
|
+
legacy_offset = reader.read_int32()
|
|
692
|
+
# v14+ adds two int32 fields (unk1, name_table_offset) between
|
|
681
693
|
# legacy_offset and game_time; v13 saves jump straight to game_time.
|
|
682
694
|
# Without this version gate we mis-align the read by 8 bytes and the
|
|
683
|
-
# data_files
|
|
695
|
+
# data_files loop walks into garbage.
|
|
696
|
+
name_table_offset = legacy_offset # v13: table follows the parts section
|
|
684
697
|
if self.version >= 14:
|
|
685
698
|
_unknown1 = reader.read_int32()
|
|
686
|
-
|
|
699
|
+
name_table_offset = reader.read_int32() # absolute offset of name table
|
|
687
700
|
self.game_time = reader.read_double()
|
|
688
701
|
_unknown2 = reader.read_int32()
|
|
689
702
|
|
|
690
|
-
# Data files
|
|
703
|
+
# Data files (immediately follow the header; populate self.data_files).
|
|
691
704
|
count = reader.read_int32()
|
|
692
705
|
self.data_files = []
|
|
693
706
|
for _ in range(count):
|
|
694
707
|
self.data_files.append(reader.read_string())
|
|
695
|
-
_term = reader.read_int32()
|
|
708
|
+
_term = reader.read_int32() # per-entry terminator, always -1
|
|
696
709
|
|
|
697
|
-
|
|
698
|
-
_pad2 = reader.read_int32()
|
|
699
|
-
|
|
700
|
-
# Name table (dict keyed by hash for ASA).
|
|
710
|
+
# Name table (dict keyed by FName hash for ASA).
|
|
701
711
|
#
|
|
702
|
-
#
|
|
703
|
-
#
|
|
704
|
-
#
|
|
705
|
-
#
|
|
706
|
-
#
|
|
707
|
-
#
|
|
708
|
-
#
|
|
709
|
-
#
|
|
710
|
-
#
|
|
711
|
-
#
|
|
712
|
+
# v14+: the engine stores the table at an explicit absolute offset
|
|
713
|
+
# (name_table_offset = the 2nd int32 after legacy_offset). The bytes
|
|
714
|
+
# between the data-files section and that offset are NOT a fixed pad
|
|
715
|
+
# pair on every map - on busy/modded saves (measured: Ragnarok +26 B,
|
|
716
|
+
# Scorched Earth +31 B past where a sequential read lands) the table
|
|
717
|
+
# would be read truncated, leaving ~half the class refs resolving to
|
|
718
|
+
# __UNKNOWN_CLASS_<hash>__. Seek to the offset like the C# reference
|
|
719
|
+
# (AsaSavegame.readNametable: ``archive.Position = nameTableOffset``).
|
|
720
|
+
# Where the gap is zero (TheIsland, Aberration, Extinction) the seek is
|
|
721
|
+
# a no-op; where it drifts it recovers every leaked class name.
|
|
722
|
+
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
|
+
)
|
|
727
|
+
reader.position = name_table_offset
|
|
728
|
+
self.name_table = self._read_asa_name_table(reader)
|
|
729
|
+
else:
|
|
730
|
+
# v13: no explicit offset field. Retain the historical sequential
|
|
731
|
+
# read (two pad int32s, then the table) - there is no live v13
|
|
732
|
+
# fixture to validate a seek against. The idx==1 sentinel consumes
|
|
733
|
+
# the 4-byte trailer on user-placed-actor entries.
|
|
734
|
+
reader.read_int32() # pad1
|
|
735
|
+
reader.read_int32() # pad2
|
|
736
|
+
self.name_table = self._read_asa_name_table(reader, sentinel=True)
|
|
737
|
+
|
|
738
|
+
def _read_asa_name_table(
|
|
739
|
+
self, reader: BinaryReader, sentinel: bool = False
|
|
740
|
+
) -> dict[int, str]:
|
|
741
|
+
"""Read an ASA name table (FName-hash -> class string) at ``reader``'s position.
|
|
742
|
+
|
|
743
|
+
Preconditions: ``reader`` is positioned at the table's int32 entry
|
|
744
|
+
count. Postconditions: returns ``{hash: trimmed_class_string}`` (the
|
|
745
|
+
substring after the last ``.``) and advances the reader past the table.
|
|
746
|
+
``sentinel`` consumes the extra 4-byte trailer following
|
|
747
|
+
user-placed-actor entries (``idx == 1``) on the v13 sequential path; it
|
|
748
|
+
is unused on the v14 seek path.
|
|
749
|
+
"""
|
|
750
|
+
assert reader.remaining >= 4, "no room for ASA name-table count"
|
|
712
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
|
+
)
|
|
713
755
|
nt: dict[int, str] = {}
|
|
714
756
|
for _ in range(name_count):
|
|
715
757
|
idx = reader.read_int32()
|
|
716
758
|
raw = reader.read_string()
|
|
717
759
|
nt[idx] = raw.rsplit(".", 1)[-1] if "." in raw else raw
|
|
718
|
-
if idx == 1:
|
|
719
|
-
# User-placed-actor
|
|
720
|
-
# trailing tag that follows on these entries.
|
|
760
|
+
if sentinel and idx == 1:
|
|
761
|
+
# User-placed-actor sentinel: skip the trailing 4-byte tag.
|
|
721
762
|
reader.skip(4)
|
|
722
|
-
|
|
763
|
+
return nt
|
|
723
764
|
|
|
724
765
|
def _read_asa_actor_locations(self, conn: sqlite3.Connection) -> None:
|
|
725
766
|
"""Parse the ``ActorTransforms`` blob."""
|
|
@@ -540,14 +540,36 @@ def _read_array_elements(
|
|
|
540
540
|
if reader.position < array_data_end:
|
|
541
541
|
reader.skip(array_data_end - reader.position)
|
|
542
542
|
else:
|
|
543
|
-
# ASE struct arrays
|
|
543
|
+
# ASE struct arrays. Resolve the element struct type the way legacy
|
|
544
|
+
# does (ArkArrayStruct.Init): the array-name map first, else INFER a
|
|
545
|
+
# native fixed-size type from the body size, where the body is the
|
|
546
|
+
# int32 count prefix (4) plus count * element_size. Without the size
|
|
547
|
+
# inference, native struct arrays addressed only by name (e.g.
|
|
548
|
+
# CustomItemColors = Color[6], data_size 28 = 6*4+4) are misread as
|
|
549
|
+
# property-list structs, the cursor drifts, and every later property
|
|
550
|
+
# in the object is corrupted (EndOfDataError on a garbage length).
|
|
551
|
+
struct_type = struct_registry.get_array_struct_type(array_name)
|
|
552
|
+
# ASE only: infer the native element type from the body size when the
|
|
553
|
+
# array name isn't mapped (legacy ArkArrayStruct.Init). Gated to ASE
|
|
554
|
+
# (not is_asa) so the ASA v6 struct-array path is untouched. The check
|
|
555
|
+
# is exact, and a property-list element is >= 9 bytes (min "None"
|
|
556
|
+
# terminator), so these equalities never match a property-list array.
|
|
557
|
+
if struct_type is None and not is_asa and count > 0:
|
|
558
|
+
if count * 4 + 4 == data_size:
|
|
559
|
+
struct_type = "Color"
|
|
560
|
+
elif count * 12 + 4 == data_size:
|
|
561
|
+
struct_type = "Vector"
|
|
562
|
+
elif count * 16 + 4 == data_size:
|
|
563
|
+
struct_type = "LinearColor"
|
|
544
564
|
for _ in range(count):
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
565
|
+
if struct_type is not None:
|
|
566
|
+
struct = struct_registry.read_struct(
|
|
567
|
+
reader, struct_type, is_asa, name_table=name_table
|
|
568
|
+
)
|
|
569
|
+
else:
|
|
570
|
+
struct = struct_registry.read_struct_for_array(
|
|
571
|
+
reader, array_name, is_asa, name_table=name_table
|
|
572
|
+
)
|
|
551
573
|
if hasattr(struct, "to_dict"):
|
|
552
574
|
values.append(struct.to_dict())
|
|
553
575
|
else:
|
|
@@ -1206,7 +1228,7 @@ class MapProperty(Property):
|
|
|
1206
1228
|
# Handle value type sub-header
|
|
1207
1229
|
if value_type == "StructProperty":
|
|
1208
1230
|
_struct_marker = reader.read_int32() # Usually 1, sometimes 2+
|
|
1209
|
-
|
|
1231
|
+
_struct_type_id = reader.read_int32()
|
|
1210
1232
|
_struct_type_inst = reader.read_int32()
|
|
1211
1233
|
_script_marker = reader.read_int32()
|
|
1212
1234
|
_script_path_id = reader.read_int32()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arkparser
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.5
|
|
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
|
|
@@ -302,7 +302,8 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
|
|
|
302
302
|
| `torp`, `temp` | added | same source, indices legacy never emitted |
|
|
303
303
|
| `active` | legacy | last-active datetime; currently `null` (placeholder for parity) |
|
|
304
304
|
| `ccc` | legacy | `"0 0 0"` (no in-world position from profile/cluster source) |
|
|
305
|
-
| `achievements
|
|
305
|
+
| `achievements` | legacy | reserved array (currently empty for parity) |
|
|
306
|
+
| `netAddress` | legacy (now populated) | Last client IP ARK persisted (`SavedNetworkAddress` in profile `MyData`). Legacy ASVExport reads the same field (ContentPlayer.cs:157 ASE / :341 ASA). `""` when the profile lacks it (e.g. never-played placeholders). ASA stores a clean IPv4/IPv6 string; some ASE saves store an engine-truncated value (e.g. `"[2001"`) reproduced verbatim, matching legacy. |
|
|
306
307
|
| `steamid`, `dataFile` | legacy | platform net id and `{steamid}.arkprofile` filename |
|
|
307
308
|
| `active` | legacy (now populated) | ISO 8601 datetime of last login, converted from profile `LastLoginTime` or in-world pawn `SavedLastTimeHadController`. Legacy schema reserved the field but the old C# exporter only filled it for in-world pawns; the parser fills it for profiles too. `null` when neither source is present. |
|
|
308
309
|
| `lat`, `lon`, `ccc` | legacy (now populated) | In-world position when the record was built from a `PlayerPawn` GameObject. Records sourced from profile / cluster files keep the legacy `0` / `"0 0 0"` placeholders since profiles carry no world position. |
|
|
@@ -359,8 +360,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
|
|
|
359
360
|
| `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
361
|
| `inventory` | legacy | items from `MyInventoryComponent.InventoryItems` |
|
|
361
362
|
| `lat`, `lon`, `ccc` | legacy | location via `MapConfig`, **rounded to 2 decimals** (parser-only nicety, not legacy parity) |
|
|
362
|
-
| `
|
|
363
|
-
| `switched_on` | added | `bContainerActivated` (lamps / fridges / etc.) |
|
|
363
|
+
| `isSwitchedOn` | legacy | `bContainerActivated`, emitted only when the structure is powered (`bIsPowered` or `bHasFuel`); omitted otherwise. Mirrors legacy `ContentStructure.cs` / `ContentPack.cs` (`IsSwitchedOn.HasValue`). |
|
|
364
364
|
| `decay_reset` | added | `bHasResetDecayTime` |
|
|
365
365
|
| `last_ally_in_range_seconds` | added | raw `LastInAllyRangeTime` / `LastInAllyRangeTimeSerialized` / `LastInAllyRangeSerialized` (in-game seconds, float) |
|
|
366
366
|
| `last_ally_in_range` | added | ISO 8601 datetime with local TZ. `null` when the save lacks the anchors. |
|
|
@@ -40,6 +40,8 @@ arkparser/structs/property_list.py
|
|
|
40
40
|
arkparser/structs/registry.py
|
|
41
41
|
arkparser/structs/vectors.py
|
|
42
42
|
tests/test_asa_header_position.py
|
|
43
|
+
tests/test_asa_name_table.py
|
|
44
|
+
tests/test_ase_cluster_drift.py
|
|
43
45
|
tests/test_binary_reader.py
|
|
44
46
|
tests/test_binary_reader_layouts.py
|
|
45
47
|
tests/test_cloud_export.py
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Regression test for KNOWN_ISSUES #1: ASA ``__UNKNOWN_CLASS_`` name-table leak.
|
|
2
|
+
|
|
3
|
+
Root cause: arkparser read the ASA worldsave name table sequentially after the
|
|
4
|
+
data-files section instead of seeking to the v14 header's name-table offset
|
|
5
|
+
(``actual_offset``). On maps where the sequential cursor landed short of that
|
|
6
|
+
offset (ragnarok, scorchedearth) the table was truncated and ~half the class
|
|
7
|
+
references resolved to ``__UNKNOWN_CLASS_<hash>__``. Fix: seek to
|
|
8
|
+
``actual_offset`` on v14+ (mirrors C# ``AsaSavegame.readNametable``).
|
|
9
|
+
|
|
10
|
+
Fixture-gated: uses real saves under
|
|
11
|
+
``references/local_saves/survival_ascended/`` which are not committed; skips
|
|
12
|
+
when absent (same convention as ``conftest`` example fixtures).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from arkparser import WorldSave
|
|
22
|
+
|
|
23
|
+
SAVES = (
|
|
24
|
+
Path(__file__).resolve().parent.parent
|
|
25
|
+
/ "references"
|
|
26
|
+
/ "local_saves"
|
|
27
|
+
/ "survival_ascended"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ark_or_skip(map_name: str) -> Path:
|
|
32
|
+
d = SAVES / map_name
|
|
33
|
+
arks = sorted(d.glob("*_WP.ark")) if d.exists() else []
|
|
34
|
+
if not arks:
|
|
35
|
+
pytest.skip(f"Fixture not available: {d}\\*_WP.ark")
|
|
36
|
+
return arks[0]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.parametrize("map_name", ["scorchedearth_wp", "ragnarok_wp"])
|
|
40
|
+
def test_asa_name_table_no_unknown_class(map_name: str) -> None:
|
|
41
|
+
"""Every parsed object resolves to a real class name (no name-table drift)."""
|
|
42
|
+
save = WorldSave.load(_ark_or_skip(map_name))
|
|
43
|
+
unknown = [
|
|
44
|
+
o.class_name
|
|
45
|
+
for o in save.objects
|
|
46
|
+
if str(getattr(o, "class_name", "") or "").startswith("__UNKNOWN_CLASS_")
|
|
47
|
+
]
|
|
48
|
+
assert not unknown, (
|
|
49
|
+
f"{map_name}: {len(unknown)} objects leaked __UNKNOWN_CLASS_ "
|
|
50
|
+
f"(e.g. {unknown[:3]})"
|
|
51
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Regression test for KNOWN_ISSUES #2: ASE cloud/cluster cursor drift.
|
|
2
|
+
|
|
3
|
+
Root cause: ASE struct arrays of native fixed-size structs addressed only by
|
|
4
|
+
name (e.g. ``CustomItemColors`` = ``Color[]``) were read as property-list
|
|
5
|
+
structs because the array name was not in ``ARRAY_NAME_TO_STRUCT_TYPE``. That
|
|
6
|
+
mis-read drifted the cursor and a later property-name length read exploded with
|
|
7
|
+
``EndOfDataError``, so the whole cluster file (and all its uploads) was dropped.
|
|
8
|
+
|
|
9
|
+
Fix: ``_read_array_elements`` now infers the native element type from the array
|
|
10
|
+
body size when the name is unmapped (mirrors legacy ``ArkArrayStruct.Init``:
|
|
11
|
+
``count*4+4 == data_size`` -> Color, ``*12`` -> Vector, ``*16`` -> LinearColor).
|
|
12
|
+
|
|
13
|
+
Fixture-gated: uses real cluster files under
|
|
14
|
+
``references/local_saves/ase_cluster_drift/`` (not committed - they hold player
|
|
15
|
+
upload data); skips when absent.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import pytest
|
|
23
|
+
|
|
24
|
+
from arkparser import CloudInventory
|
|
25
|
+
|
|
26
|
+
DRIFT_DIR = (
|
|
27
|
+
Path(__file__).resolve().parent.parent
|
|
28
|
+
/ "references"
|
|
29
|
+
/ "local_saves"
|
|
30
|
+
/ "ase_cluster_drift"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# (filename, minimum uploaded-item count) for files that used to raise
|
|
34
|
+
# EndOfDataError mid-parse on the CustomItemColors native-struct array.
|
|
35
|
+
CASES = [
|
|
36
|
+
("2533274829298794", 50),
|
|
37
|
+
("2533274854487560", 9),
|
|
38
|
+
("2533274905839355", 3),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize(("name", "min_items"), CASES)
|
|
43
|
+
def test_ase_cluster_native_struct_array_no_drift(name: str, min_items: int) -> None:
|
|
44
|
+
"""Previously-drifting cluster files parse fully and recover their uploads."""
|
|
45
|
+
p = DRIFT_DIR / name
|
|
46
|
+
if not p.exists():
|
|
47
|
+
pytest.skip(f"Fixture not available: {p}")
|
|
48
|
+
inv = CloudInventory.load(p) # used to raise EndOfDataError mid-parse
|
|
49
|
+
assert inv.item_count >= min_items, (
|
|
50
|
+
f"{name}: expected >= {min_items} items, got {inv.item_count}"
|
|
51
|
+
)
|
|
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
|