arkparser 0.4.4__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.
Files changed (61) hide show
  1. {arkparser-0.4.4 → arkparser-0.4.5}/PKG-INFO +3 -2
  2. {arkparser-0.4.4 → arkparser-0.4.5}/README.md +2 -1
  3. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/__init__.py +1 -1
  4. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/common/binary_reader.py +0 -1
  5. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/common/map_config.py +0 -1
  6. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/common/version_detection.py +0 -1
  7. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/export.py +1 -1
  8. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/files/profile.py +19 -0
  9. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/files/world_save.py +58 -24
  10. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/properties/compound.py +30 -8
  11. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser.egg-info/PKG-INFO +3 -2
  12. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser.egg-info/SOURCES.txt +2 -0
  13. {arkparser-0.4.4 → arkparser-0.4.5}/pyproject.toml +1 -1
  14. arkparser-0.4.5/tests/test_asa_name_table.py +51 -0
  15. arkparser-0.4.5/tests/test_ase_cluster_drift.py +51 -0
  16. {arkparser-0.4.4 → arkparser-0.4.5}/LICENSE +0 -0
  17. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/common/__init__.py +0 -0
  18. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/common/exceptions.py +0 -0
  19. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/common/normalization.py +0 -0
  20. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/common/types.py +0 -0
  21. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/data_models.py +0 -0
  22. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/files/__init__.py +0 -0
  23. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/files/base.py +0 -0
  24. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/files/cloud_inventory.py +0 -0
  25. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/files/tribe.py +0 -0
  26. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/game_objects/__init__.py +0 -0
  27. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/game_objects/container.py +0 -0
  28. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/game_objects/game_object.py +0 -0
  29. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/game_objects/location.py +0 -0
  30. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/properties/__init__.py +0 -0
  31. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/properties/base.py +0 -0
  32. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/properties/byte_property.py +0 -0
  33. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/properties/primitives.py +0 -0
  34. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/properties/registry.py +0 -0
  35. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/structs/__init__.py +0 -0
  36. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/structs/base.py +0 -0
  37. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/structs/colors.py +0 -0
  38. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/structs/misc.py +0 -0
  39. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/structs/property_list.py +0 -0
  40. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/structs/registry.py +0 -0
  41. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser/structs/vectors.py +0 -0
  42. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser.egg-info/dependency_links.txt +0 -0
  43. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser.egg-info/requires.txt +0 -0
  44. {arkparser-0.4.4 → arkparser-0.4.5}/arkparser.egg-info/top_level.txt +0 -0
  45. {arkparser-0.4.4 → arkparser-0.4.5}/setup.cfg +0 -0
  46. {arkparser-0.4.4 → arkparser-0.4.5}/tests/test_asa_header_position.py +0 -0
  47. {arkparser-0.4.4 → arkparser-0.4.5}/tests/test_binary_reader.py +0 -0
  48. {arkparser-0.4.4 → arkparser-0.4.5}/tests/test_binary_reader_layouts.py +0 -0
  49. {arkparser-0.4.4 → arkparser-0.4.5}/tests/test_cloud_export.py +0 -0
  50. {arkparser-0.4.4 → arkparser-0.4.5}/tests/test_cloud_inventory.py +0 -0
  51. {arkparser-0.4.4 → arkparser-0.4.5}/tests/test_cryopod_export.py +0 -0
  52. {arkparser-0.4.4 → arkparser-0.4.5}/tests/test_current_stats.py +0 -0
  53. {arkparser-0.4.4 → arkparser-0.4.5}/tests/test_data_models.py +0 -0
  54. {arkparser-0.4.4 → arkparser-0.4.5}/tests/test_export.py +0 -0
  55. {arkparser-0.4.4 → arkparser-0.4.5}/tests/test_game_objects.py +0 -0
  56. {arkparser-0.4.4 → arkparser-0.4.5}/tests/test_profile.py +0 -0
  57. {arkparser-0.4.4 → arkparser-0.4.5}/tests/test_review_fixes.py +0 -0
  58. {arkparser-0.4.4 → arkparser-0.4.5}/tests/test_tribe.py +0 -0
  59. {arkparser-0.4.4 → arkparser-0.4.5}/tests/test_v13_property_layouts.py +0 -0
  60. {arkparser-0.4.4 → arkparser-0.4.5}/tests/test_version_detection.py +0 -0
  61. {arkparser-0.4.4 → 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.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`, `inventory`, `netAddress` | legacy | reserved arrays/string (currently empty for parity) |
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. |
@@ -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`, `inventory`, `netAddress` | legacy | reserved arrays/string (currently empty for parity) |
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. |
@@ -95,4 +95,4 @@ __all__ = [
95
95
  "ArkParseError",
96
96
  ]
97
97
 
98
- __version__ = "0.4.4"
98
+ __version__ = "0.4.5"
@@ -22,7 +22,6 @@ Example:
22
22
  from __future__ import annotations
23
23
 
24
24
  import struct
25
- import typing as t
26
25
  from pathlib import Path
27
26
  from uuid import UUID
28
27
 
@@ -11,7 +11,6 @@ Where x, y are Unreal Engine world coordinates.
11
11
  from __future__ import annotations
12
12
 
13
13
  import logging
14
- import typing as t
15
14
  from dataclasses import dataclass
16
15
  from pathlib import Path
17
16
 
@@ -19,7 +19,6 @@ World saves (.ark files) have different headers:
19
19
 
20
20
  from __future__ import annotations
21
21
 
22
- import typing as t
23
22
  from enum import Enum
24
23
  from pathlib import Path
25
24
 
@@ -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),
@@ -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:
@@ -683,50 +688,79 @@ class WorldSave:
683
688
  reader = BinaryReader.from_bytes(row[0])
684
689
 
685
690
  self.version = reader.read_int16()
686
- _legacy_offset = reader.read_int32()
687
- # v14+ adds two int32 fields (unk1, actual_offset) between
691
+ legacy_offset = reader.read_int32()
692
+ # v14+ adds two int32 fields (unk1, name_table_offset) between
688
693
  # legacy_offset and game_time; v13 saves jump straight to game_time.
689
694
  # Without this version gate we mis-align the read by 8 bytes and the
690
- # data_files / name_table loops walk into garbage.
695
+ # data_files loop walks into garbage.
696
+ name_table_offset = legacy_offset # v13: table follows the parts section
691
697
  if self.version >= 14:
692
698
  _unknown1 = reader.read_int32()
693
- _actual_offset = reader.read_int32()
699
+ name_table_offset = reader.read_int32() # absolute offset of name table
694
700
  self.game_time = reader.read_double()
695
701
  _unknown2 = reader.read_int32()
696
702
 
697
- # Data files
703
+ # Data files (immediately follow the header; populate self.data_files).
698
704
  count = reader.read_int32()
699
705
  self.data_files = []
700
706
  for _ in range(count):
701
707
  self.data_files.append(reader.read_string())
702
- _term = reader.read_int32()
703
-
704
- _pad1 = reader.read_int32()
705
- _pad2 = reader.read_int32()
708
+ _term = reader.read_int32() # per-entry terminator, always -1
706
709
 
707
- # Name table (dict keyed by hash for ASA).
710
+ # Name table (dict keyed by FName hash for ASA).
708
711
  #
709
- # Most ASA saves serialize the table as a flat ``(hash, strlen,
710
- # string)`` triple per entry. Some maps (observed on Ragnarok,
711
- # Scorched Earth and TheIsland on busy servers) prepend
712
- # user-placed-structure name entries that carry an extra 4-byte
713
- # trailer after the string; those entries always have
714
- # ``hash == 1`` as a sentinel. Detect that sentinel per-entry and
715
- # consume the trailer so the rest of the table parses normally.
716
- # Without this, parsing the next entry's hash misreads the trailer
717
- # as a string length and explodes with EndOfDataError on a ~500 MB
718
- # phantom string.
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"
719
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
+ )
720
755
  nt: dict[int, str] = {}
721
756
  for _ in range(name_count):
722
757
  idx = reader.read_int32()
723
758
  raw = reader.read_string()
724
759
  nt[idx] = raw.rsplit(".", 1)[-1] if "." in raw else raw
725
- if idx == 1:
726
- # User-placed-actor name sentinel: skip the 4-byte sub-id /
727
- # trailing tag that follows on these entries.
760
+ if sentinel and idx == 1:
761
+ # User-placed-actor sentinel: skip the trailing 4-byte tag.
728
762
  reader.skip(4)
729
- self.name_table = nt
763
+ return nt
730
764
 
731
765
  def _read_asa_actor_locations(self, conn: sqlite3.Connection) -> None:
732
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 - read as property-based structs
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
- struct = struct_registry.read_struct_for_array(
546
- reader,
547
- array_name,
548
- is_asa,
549
- name_table=name_table,
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
- struct_type_id = reader.read_int32()
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.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`, `inventory`, `netAddress` | legacy | reserved arrays/string (currently empty for parity) |
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. |
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "arkparser"
7
- version = "0.4.4"
7
+ version = "0.4.5"
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,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