arkparser 0.5.0__tar.gz → 0.5.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.
Files changed (61) hide show
  1. {arkparser-0.5.0 → arkparser-0.5.3}/PKG-INFO +1 -1
  2. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/__init__.py +98 -98
  3. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/common/normalization.py +6 -0
  4. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/export.py +149 -37
  5. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/game_objects/game_object.py +40 -20
  6. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/properties/base.py +2 -2
  7. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/properties/byte_property.py +1 -1
  8. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/properties/compound.py +14 -13
  9. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/properties/primitives.py +14 -14
  10. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser.egg-info/PKG-INFO +1 -1
  11. {arkparser-0.5.0 → arkparser-0.5.3}/pyproject.toml +86 -86
  12. {arkparser-0.5.0 → arkparser-0.5.3}/tests/test_review_fixes.py +18 -3
  13. {arkparser-0.5.0 → arkparser-0.5.3}/LICENSE +0 -0
  14. {arkparser-0.5.0 → arkparser-0.5.3}/README.md +0 -0
  15. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/common/__init__.py +0 -0
  16. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/common/binary_reader.py +0 -0
  17. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/common/exceptions.py +0 -0
  18. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/common/map_config.py +0 -0
  19. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/common/types.py +0 -0
  20. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/common/version_detection.py +0 -0
  21. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/data_models.py +0 -0
  22. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/files/__init__.py +0 -0
  23. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/files/base.py +0 -0
  24. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/files/cloud_inventory.py +0 -0
  25. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/files/profile.py +0 -0
  26. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/files/tribe.py +0 -0
  27. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/files/world_save.py +0 -0
  28. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/game_objects/__init__.py +0 -0
  29. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/game_objects/container.py +0 -0
  30. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/game_objects/location.py +0 -0
  31. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/properties/__init__.py +0 -0
  32. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/properties/registry.py +0 -0
  33. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/structs/__init__.py +0 -0
  34. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/structs/base.py +0 -0
  35. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/structs/colors.py +0 -0
  36. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/structs/misc.py +0 -0
  37. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/structs/property_list.py +0 -0
  38. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/structs/registry.py +0 -0
  39. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser/structs/vectors.py +0 -0
  40. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser.egg-info/SOURCES.txt +0 -0
  41. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser.egg-info/dependency_links.txt +0 -0
  42. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser.egg-info/requires.txt +0 -0
  43. {arkparser-0.5.0 → arkparser-0.5.3}/arkparser.egg-info/top_level.txt +0 -0
  44. {arkparser-0.5.0 → arkparser-0.5.3}/setup.cfg +0 -0
  45. {arkparser-0.5.0 → arkparser-0.5.3}/tests/test_asa_header_position.py +0 -0
  46. {arkparser-0.5.0 → arkparser-0.5.3}/tests/test_asa_name_table.py +0 -0
  47. {arkparser-0.5.0 → arkparser-0.5.3}/tests/test_ase_cluster_drift.py +0 -0
  48. {arkparser-0.5.0 → arkparser-0.5.3}/tests/test_binary_reader.py +0 -0
  49. {arkparser-0.5.0 → arkparser-0.5.3}/tests/test_binary_reader_layouts.py +0 -0
  50. {arkparser-0.5.0 → arkparser-0.5.3}/tests/test_cloud_export.py +0 -0
  51. {arkparser-0.5.0 → arkparser-0.5.3}/tests/test_cloud_inventory.py +0 -0
  52. {arkparser-0.5.0 → arkparser-0.5.3}/tests/test_cryopod_export.py +0 -0
  53. {arkparser-0.5.0 → arkparser-0.5.3}/tests/test_current_stats.py +0 -0
  54. {arkparser-0.5.0 → arkparser-0.5.3}/tests/test_data_models.py +0 -0
  55. {arkparser-0.5.0 → arkparser-0.5.3}/tests/test_export.py +0 -0
  56. {arkparser-0.5.0 → arkparser-0.5.3}/tests/test_game_objects.py +0 -0
  57. {arkparser-0.5.0 → arkparser-0.5.3}/tests/test_profile.py +0 -0
  58. {arkparser-0.5.0 → arkparser-0.5.3}/tests/test_tribe.py +0 -0
  59. {arkparser-0.5.0 → arkparser-0.5.3}/tests/test_v13_property_layouts.py +0 -0
  60. {arkparser-0.5.0 → arkparser-0.5.3}/tests/test_version_detection.py +0 -0
  61. {arkparser-0.5.0 → arkparser-0.5.3}/tests/test_world_save.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkparser
3
- Version: 0.5.0
3
+ Version: 0.5.3
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
@@ -1,98 +1,98 @@
1
- """
2
- ARK Save Parser - Parse ARK: Survival Evolved/Ascended save files.
3
-
4
- This package provides tools to parse various ARK save file formats:
5
-
6
- - Profile: Player profile data (.arkprofile)
7
- - Tribe: Tribe data (.arktribe)
8
- - CloudInventory: Obelisk/cloud inventory data (no extension)
9
- - WorldSave: World save data (.ark), auto-detects ASE binary and ASA SQLite
10
-
11
- Supports both ASE (ARK: Survival Evolved) and ASA (ARK: Survival Ascended)
12
- formats with automatic detection.
13
-
14
- Example usage:
15
- >>> from arkparser import Profile, Tribe, CloudInventory, WorldSave
16
- >>> profile = Profile.load("path/to/profile.arkprofile")
17
- >>> tribe = Tribe.load("path/to/tribe.arktribe")
18
- >>> inv = CloudInventory.load("path/to/obelisk_file")
19
- >>> save = WorldSave.load("path/to/TheIsland.ark") # ASE
20
- >>> save = WorldSave.load("path/to/Extinction_WP.ark") # ASA
21
- """
22
-
23
- from arkparser.common.exceptions import ArkParseError
24
- from arkparser.common.map_config import MapConfig, get_map_config, get_map_config_by_name
25
- from arkparser.common.version_detection import (
26
- ArkFileFormat,
27
- ArkFileType,
28
- detect_file_type,
29
- detect_format,
30
- )
31
- from arkparser.data_models import (
32
- CryopodCreature,
33
- DinoStats,
34
- UploadedCreature,
35
- UploadedItem,
36
- )
37
- from arkparser.export import (
38
- export_all,
39
- export_cloud_inventory,
40
- export_cluster_items,
41
- export_cluster_uploads,
42
- export_map_structures,
43
- export_players,
44
- export_structures,
45
- export_tamed,
46
- export_to_files,
47
- export_tribe_logs,
48
- export_tribes,
49
- export_wild,
50
- )
51
- from arkparser.files import CloudInventory, Profile, Tribe, WorldSave
52
- from arkparser.game_objects import GameObject, GameObjectContainer, LocationData
53
-
54
- # Convenience alias - users may know this as "Obelisk" from the game
55
- Obelisk = CloudInventory
56
-
57
- __all__ = [
58
- # File parsers
59
- "Profile",
60
- "Tribe",
61
- "CloudInventory",
62
- "Obelisk",
63
- "WorldSave",
64
- # Cloud-inventory data models
65
- "UploadedCreature",
66
- "UploadedItem",
67
- "CryopodCreature",
68
- "DinoStats",
69
- # Game objects
70
- "GameObject",
71
- "GameObjectContainer",
72
- "LocationData",
73
- # Map config
74
- "MapConfig",
75
- "get_map_config",
76
- "get_map_config_by_name",
77
- # Export
78
- "export_all",
79
- "export_tamed",
80
- "export_wild",
81
- "export_players",
82
- "export_tribes",
83
- "export_structures",
84
- "export_tribe_logs",
85
- "export_map_structures",
86
- "export_cluster_uploads",
87
- "export_cluster_items",
88
- "export_cloud_inventory",
89
- "export_to_files",
90
- # Utilities
91
- "detect_format",
92
- "detect_file_type",
93
- "ArkFileFormat",
94
- "ArkFileType",
95
- "ArkParseError",
96
- ]
97
-
98
- __version__ = "0.5.0"
1
+ """
2
+ ARK Save Parser - Parse ARK: Survival Evolved/Ascended save files.
3
+
4
+ This package provides tools to parse various ARK save file formats:
5
+
6
+ - Profile: Player profile data (.arkprofile)
7
+ - Tribe: Tribe data (.arktribe)
8
+ - CloudInventory: Obelisk/cloud inventory data (no extension)
9
+ - WorldSave: World save data (.ark), auto-detects ASE binary and ASA SQLite
10
+
11
+ Supports both ASE (ARK: Survival Evolved) and ASA (ARK: Survival Ascended)
12
+ formats with automatic detection.
13
+
14
+ Example usage:
15
+ >>> from arkparser import Profile, Tribe, CloudInventory, WorldSave
16
+ >>> profile = Profile.load("path/to/profile.arkprofile")
17
+ >>> tribe = Tribe.load("path/to/tribe.arktribe")
18
+ >>> inv = CloudInventory.load("path/to/obelisk_file")
19
+ >>> save = WorldSave.load("path/to/TheIsland.ark") # ASE
20
+ >>> save = WorldSave.load("path/to/Extinction_WP.ark") # ASA
21
+ """
22
+
23
+ from arkparser.common.exceptions import ArkParseError
24
+ from arkparser.common.map_config import MapConfig, get_map_config, get_map_config_by_name
25
+ from arkparser.common.version_detection import (
26
+ ArkFileFormat,
27
+ ArkFileType,
28
+ detect_file_type,
29
+ detect_format,
30
+ )
31
+ from arkparser.data_models import (
32
+ CryopodCreature,
33
+ DinoStats,
34
+ UploadedCreature,
35
+ UploadedItem,
36
+ )
37
+ from arkparser.export import (
38
+ export_all,
39
+ export_cloud_inventory,
40
+ export_cluster_items,
41
+ export_cluster_uploads,
42
+ export_map_structures,
43
+ export_players,
44
+ export_structures,
45
+ export_tamed,
46
+ export_to_files,
47
+ export_tribe_logs,
48
+ export_tribes,
49
+ export_wild,
50
+ )
51
+ from arkparser.files import CloudInventory, Profile, Tribe, WorldSave
52
+ from arkparser.game_objects import GameObject, GameObjectContainer, LocationData
53
+
54
+ # Convenience alias - users may know this as "Obelisk" from the game
55
+ Obelisk = CloudInventory
56
+
57
+ __all__ = [
58
+ # File parsers
59
+ "Profile",
60
+ "Tribe",
61
+ "CloudInventory",
62
+ "Obelisk",
63
+ "WorldSave",
64
+ # Cloud-inventory data models
65
+ "UploadedCreature",
66
+ "UploadedItem",
67
+ "CryopodCreature",
68
+ "DinoStats",
69
+ # Game objects
70
+ "GameObject",
71
+ "GameObjectContainer",
72
+ "LocationData",
73
+ # Map config
74
+ "MapConfig",
75
+ "get_map_config",
76
+ "get_map_config_by_name",
77
+ # Export
78
+ "export_all",
79
+ "export_tamed",
80
+ "export_wild",
81
+ "export_players",
82
+ "export_tribes",
83
+ "export_structures",
84
+ "export_tribe_logs",
85
+ "export_map_structures",
86
+ "export_cluster_uploads",
87
+ "export_cluster_items",
88
+ "export_cloud_inventory",
89
+ "export_to_files",
90
+ # Utilities
91
+ "detect_format",
92
+ "detect_file_type",
93
+ "ArkFileFormat",
94
+ "ArkFileType",
95
+ "ArkParseError",
96
+ ]
97
+
98
+ __version__ = "0.5.3"
@@ -54,4 +54,10 @@ def normalize_indexed_list(value: t.Any) -> list[t.Any]:
54
54
  return []
55
55
  if isinstance(normalized, list):
56
56
  return normalized
57
+ # A raw ByteProperty array is stored as `bytes` for memory efficiency
58
+ # (8x lighter than list[int]); expose it element-wise as a list of ints so
59
+ # consumers that iterate it (e.g. tribe MembersRankGroups -> int(rank))
60
+ # see the same shape they did before byte arrays were stored as bytes.
61
+ if isinstance(normalized, (bytes, bytearray)):
62
+ return list(normalized)
57
63
  return [normalized]
@@ -32,6 +32,7 @@ Performance:
32
32
  from __future__ import annotations
33
33
 
34
34
  import datetime as dt
35
+ import itertools
35
36
  import json
36
37
  import logging
37
38
  import math
@@ -1366,13 +1367,21 @@ def export_tamed(save: t.Any, map_config: MapConfig | None = None) -> list[dict[
1366
1367
  (b) every cryopod-embedded tame, with the latter carrying ``cryo=True``
1367
1368
  and inheriting the cryopod item's world location for GPS fields.
1368
1369
  """
1370
+ return list(_iter_tamed(save, map_config))
1371
+
1372
+
1373
+ def _iter_tamed(save: t.Any, map_config: MapConfig | None) -> t.Iterator[dict[str, t.Any]]:
1374
+ """Yield ASV_Tamed records one at a time (see :func:`export_tamed`).
1375
+
1376
+ Generator form so :func:`export_to_files` can stream each record to disk
1377
+ and release it instead of materializing the whole list (the list is the
1378
+ dominant export-time allocation on large PvE saves).
1379
+ """
1369
1380
  objects = _world_objects(save, "get_tamed_creatures", "tamed_objects")
1370
1381
  lookup = _save_lookup(save)
1371
- results: list[dict[str, t.Any]] = [
1372
- _tamed_dict(obj, _status_for(obj, lookup), lookup, map_config, save) for obj in objects
1373
- ]
1374
- results.extend(_export_world_cryopods(save, map_config))
1375
- return results
1382
+ for obj in objects:
1383
+ yield _tamed_dict(obj, _status_for(obj, lookup), lookup, map_config, save)
1384
+ yield from _export_world_cryopods(save, map_config)
1376
1385
 
1377
1386
 
1378
1387
  def _build_item_owner_lookup(save: t.Any, lookup: dict[t.Any, t.Any]) -> dict[t.Any, dict[str, t.Any]]:
@@ -1820,10 +1829,16 @@ def _wild_dict(
1820
1829
 
1821
1830
 
1822
1831
  def export_wild(save: t.Any, map_config: MapConfig | None = None) -> list[dict[str, t.Any]]:
1832
+ return list(_iter_wild(save, map_config))
1833
+
1834
+
1835
+ def _iter_wild(save: t.Any, map_config: MapConfig | None) -> t.Iterator[dict[str, t.Any]]:
1836
+ """Yield ASV_Wild records one at a time (streaming form of export_wild)."""
1823
1837
  objects = _world_objects(save, "get_wild_creatures", "wild_objects")
1824
1838
  is_asa = bool(getattr(save, "is_asa", False))
1825
1839
  lookup = _save_lookup(save)
1826
- return [_wild_dict(obj, _status_for(obj, lookup), map_config, is_asa) for obj in objects]
1840
+ for obj in objects:
1841
+ yield _wild_dict(obj, _status_for(obj, lookup), map_config, is_asa)
1827
1842
 
1828
1843
 
1829
1844
  def _player_from_profile(
@@ -2073,6 +2088,15 @@ def export_players(
2073
2088
  map_config: MapConfig | None = None,
2074
2089
  cluster_inventories: t.Iterable[CloudInventory] | None = None,
2075
2090
  ) -> list[dict[str, t.Any]]:
2091
+ return list(_iter_players(save, map_config, cluster_inventories))
2092
+
2093
+
2094
+ def _iter_players(
2095
+ save: t.Any,
2096
+ map_config: MapConfig | None,
2097
+ cluster_inventories: t.Iterable[CloudInventory] | None,
2098
+ ) -> t.Iterator[dict[str, t.Any]]:
2099
+ """Yield ASV_Players records one at a time (streaming form of export_players)."""
2076
2100
  profiles = _collection(save, "profiles", Profile)
2077
2101
  lookup = _save_lookup(save)
2078
2102
  pawn_status_by_id = _player_status_by_data_id(save, lookup)
@@ -2083,7 +2107,6 @@ def export_players(
2083
2107
  allocation = _assemble_tribes(save)
2084
2108
  profile_tribeid = allocation["profile_tribeid"]
2085
2109
  tribe_names = allocation["names"]
2086
- results: list[dict[str, t.Any]] = []
2087
2110
  for entry in profiles:
2088
2111
  record, join_keys = _player_record_for(entry, save, pawn_status_by_id, lookup, map_config)
2089
2112
  if record is None:
@@ -2100,11 +2123,10 @@ def export_players(
2100
2123
  inv_list = []
2101
2124
  record["inventory"] = inv_list
2102
2125
  inv_list.extend(spliced)
2103
- results.append(record)
2126
+ yield record
2104
2127
  # Tribe members with no .arkprofile surface as stub players (legacy +N).
2105
2128
  for tid, pid, name in allocation["member_stubs"]:
2106
- results.append(_member_stub_player(tid, pid, name, tribe_names.get(tid, "")))
2107
- return results
2129
+ yield _member_stub_player(tid, pid, name, tribe_names.get(tid, ""))
2108
2130
 
2109
2131
 
2110
2132
  def _strip_rich_color(msg: str) -> str:
@@ -2761,20 +2783,23 @@ def _structure_dict(
2761
2783
 
2762
2784
 
2763
2785
  def export_structures(save: t.Any, map_config: MapConfig | None = None) -> list[dict[str, t.Any]]:
2786
+ return list(_iter_structures(save, map_config))
2787
+
2788
+
2789
+ def _iter_structures(save: t.Any, map_config: MapConfig | None) -> t.Iterator[dict[str, t.Any]]:
2790
+ """Yield ASV_Structures records one at a time (streaming form of export_structures)."""
2764
2791
  objects = _world_objects(save, "get_structures", "structure_objects")
2765
2792
  lookup = _save_lookup(save)
2766
2793
  # Reuse the assembled tribe-name map so a structure's ``tribe`` resolves to
2767
2794
  # the same name the tribes export uses (file TribeName / stub OwnerName).
2768
2795
  tribe_names = _assemble_tribes(save)["names"]
2769
- results: list[dict[str, t.Any]] = []
2770
2796
  for obj in objects:
2771
2797
  team = _int(_prop(obj, "TargetingTeam"))
2772
2798
  if team < _PLAYER_TEAM_THRESHOLD and _is_excluded_abandoned(getattr(obj, "class_name", "") or ""):
2773
2799
  # Unowned map element / crate / debug actor: legacy drops these
2774
2800
  # from ASV_Structures (surfaced via ASV_MapStructures instead).
2775
2801
  continue
2776
- results.append(_structure_dict(obj, save, lookup, map_config, tribe_names))
2777
- return results
2802
+ yield _structure_dict(obj, save, lookup, map_config, tribe_names)
2778
2803
 
2779
2804
 
2780
2805
  # Mirrors C# ContentContainer.cs:846. Order matters: first match wins.
@@ -2810,10 +2835,14 @@ def export_map_structures(
2810
2835
  save: t.Any,
2811
2836
  map_config: MapConfig | None = None,
2812
2837
  ) -> list[dict[str, t.Any]]:
2838
+ return list(_iter_map_structures(save, map_config))
2839
+
2840
+
2841
+ def _iter_map_structures(save: t.Any, map_config: MapConfig | None) -> t.Iterator[dict[str, t.Any]]:
2842
+ """Yield ASV_MapStructures records one at a time (streaming form of export_map_structures)."""
2813
2843
  objects = getattr(save, "objects", None) or []
2814
- all_objs = list(objects.values()) if isinstance(objects, dict) else list(objects)
2844
+ all_objs = objects.values() if isinstance(objects, dict) else objects
2815
2845
  lookup = _save_lookup(save)
2816
- results: list[dict[str, t.Any]] = []
2817
2846
  for obj in all_objs:
2818
2847
  cn = getattr(obj, "class_name", "") or ""
2819
2848
  label = _asv_map_struct_label(cn)
@@ -2829,8 +2858,7 @@ def export_map_structures(
2829
2858
  "inventory": _inventory_items(obj, lookup),
2830
2859
  }
2831
2860
  data.update(_gps_payload(obj, map_config))
2832
- results.append(data)
2833
- return results
2861
+ yield data
2834
2862
 
2835
2863
 
2836
2864
  # Legacy ASVExport.exe filenames. Single canonical schema.
@@ -2845,12 +2873,8 @@ _EXPORT_NAMES: dict[str, str] = {
2845
2873
  }
2846
2874
 
2847
2875
 
2848
- def _wrap_with_meta(
2849
- data: list[dict[str, t.Any]],
2850
- save: t.Any,
2851
- map_config: MapConfig | None = None,
2852
- ) -> dict[str, t.Any]:
2853
- """Wrap a payload in legacy ``{map, day, time, data}`` envelope."""
2876
+ def _meta_head(save: t.Any, map_config: MapConfig | None = None) -> dict[str, t.Any]:
2877
+ """Return the legacy envelope head ``{map, day, time}`` (no ``data`` key)."""
2854
2878
  map_name = getattr(map_config, "name", "") if map_config is not None else ""
2855
2879
  day = 0
2856
2880
  time_str = "00:00"
@@ -2859,7 +2883,74 @@ def _wrap_with_meta(
2859
2883
  day = int(game_time // 86400)
2860
2884
  rem = int(game_time % 86400)
2861
2885
  time_str = f"{rem // 3600:02d}:{(rem % 3600) // 60:02d}"
2862
- return {"map": map_name, "day": day, "time": time_str, "data": data}
2886
+ return {"map": map_name, "day": day, "time": time_str}
2887
+
2888
+
2889
+ def _wrap_with_meta(
2890
+ data: list[dict[str, t.Any]],
2891
+ save: t.Any,
2892
+ map_config: MapConfig | None = None,
2893
+ ) -> dict[str, t.Any]:
2894
+ """Wrap a payload in the legacy ``{map, day, time, data}`` envelope.
2895
+
2896
+ Retained for callers that already hold a materialized list (e.g. the
2897
+ validation harness diffing against legacy). :func:`export_to_files`
2898
+ streams via :func:`_stream_dump` instead and does not use this.
2899
+ """
2900
+ return {**_meta_head(save, map_config), "data": data}
2901
+
2902
+
2903
+ def _stream_dump(
2904
+ fh: t.TextIO,
2905
+ head: dict[str, t.Any] | None,
2906
+ records: t.Iterable[dict[str, t.Any]],
2907
+ dump_kwargs: dict[str, t.Any],
2908
+ ) -> None:
2909
+ """Stream a JSON array to ``fh``, one record at a time.
2910
+
2911
+ Writes a bare ``[records...]`` array when ``head`` is ``None``, otherwise
2912
+ splices the records into ``head`` under a ``"data"`` key
2913
+ (``{..head.., "data": [records...]}``). Each record is encoded and written
2914
+ individually, then released — the full record list never co-resides in
2915
+ memory. The result round-trips identically (at the value level) to
2916
+ ``json.dump(head | {"data": list(records)}, fh, **dump_kwargs)``; only
2917
+ insignificant whitespace differs.
2918
+
2919
+ Pre-conditions: ``records`` is a finite iterable of JSON-serializable
2920
+ dicts; ``dump_kwargs`` carries the same ``indent`` / ``separators`` /
2921
+ ``default`` used elsewhere. Post-conditions: ``fh`` holds one complete,
2922
+ valid JSON document.
2923
+ """
2924
+ indent = dump_kwargs.get("indent")
2925
+ rec_kwargs: dict[str, t.Any] = {"default": dump_kwargs.get("default", str)}
2926
+ if indent:
2927
+ rec_kwargs["indent"] = indent
2928
+ else:
2929
+ rec_kwargs["separators"] = (",", ":")
2930
+ nl = "\n" if indent else ""
2931
+ if head is not None:
2932
+ head_json = json.dumps(head, **rec_kwargs)
2933
+ assert head_json.endswith("}"), "envelope head must serialize to a JSON object"
2934
+ # Splice the data array in just before the head object's closing brace
2935
+ # (indented dumps leave a trailing "\n}", compact a bare "}").
2936
+ fh.write(head_json[: -2 if indent else -1])
2937
+ fh.write(f',{nl}{" " * indent}"data": [' if indent else ',"data":[')
2938
+ rec_pad = " " * (indent * 2) if indent else ""
2939
+ close_pad = " " * indent if indent else ""
2940
+ else:
2941
+ fh.write("[")
2942
+ rec_pad = " " * indent if indent else ""
2943
+ close_pad = ""
2944
+ first = True
2945
+ for rec in records:
2946
+ chunk = json.dumps(rec, **rec_kwargs)
2947
+ if indent:
2948
+ chunk = rec_pad + chunk.replace("\n", "\n" + rec_pad)
2949
+ fh.write(("" if first else ",") + nl + chunk)
2950
+ first = False
2951
+ fh.write("]" if first else nl + close_pad + "]")
2952
+ if head is not None:
2953
+ fh.write(nl + "}")
2863
2954
 
2864
2955
 
2865
2956
  def _load_cluster_inventories(
@@ -2909,18 +3000,33 @@ def export_all(
2909
3000
  Note: returns flat lists per type (not the legacy ``{map, day, time, data}``
2910
3001
  envelope). ``export_to_files`` adds the envelope when writing.
2911
3002
  """
3003
+ return {name: list(records) for name, records in _iter_exports(save, map_config, cluster)}
3004
+
3005
+
3006
+ def _iter_exports(
3007
+ save: t.Any,
3008
+ map_config: MapConfig | None,
3009
+ cluster: str | Path | t.Iterable[CloudInventory] | None,
3010
+ ) -> t.Iterator[tuple[str, t.Iterable[dict[str, t.Any]]]]:
3011
+ """Yield ``(ASV_name, record_iterable)`` one export type at a time.
3012
+
3013
+ The heavy types (tamed / wild / players / structures / map_structures)
3014
+ yield **lazy generators** so :func:`export_to_files` can stream each
3015
+ record to disk and release it — the full per-type list never
3016
+ materializes (it is the dominant export-time allocation on large PvE
3017
+ saves). The small types (tribes / tribe_logs) stay eager lists.
3018
+ :func:`export_all` wraps each iterable in ``list()`` for callers that
3019
+ want every record in memory.
3020
+ """
2912
3021
  cluster_invs = _load_cluster_inventories(cluster)
2913
3022
  cluster_tamed = export_cluster_uploads(cluster_invs, map_config) if cluster_invs else []
2914
- tamed = export_tamed(save, map_config) + cluster_tamed
2915
- return {
2916
- _EXPORT_NAMES["tamed"]: tamed,
2917
- _EXPORT_NAMES["wild"]: export_wild(save, map_config),
2918
- _EXPORT_NAMES["players"]: export_players(save, map_config, cluster_invs or None),
2919
- _EXPORT_NAMES["tribes"]: export_tribes(save),
2920
- _EXPORT_NAMES["structures"]: export_structures(save, map_config),
2921
- _EXPORT_NAMES["tribe_logs"]: export_tribe_logs(save),
2922
- _EXPORT_NAMES["map_structures"]: export_map_structures(save, map_config),
2923
- }
3023
+ yield _EXPORT_NAMES["tamed"], itertools.chain(_iter_tamed(save, map_config), cluster_tamed)
3024
+ yield _EXPORT_NAMES["wild"], _iter_wild(save, map_config)
3025
+ yield _EXPORT_NAMES["players"], _iter_players(save, map_config, cluster_invs or None)
3026
+ yield _EXPORT_NAMES["tribes"], export_tribes(save)
3027
+ yield _EXPORT_NAMES["structures"], _iter_structures(save, map_config)
3028
+ yield _EXPORT_NAMES["tribe_logs"], export_tribe_logs(save)
3029
+ yield _EXPORT_NAMES["map_structures"], _iter_map_structures(save, map_config)
2924
3030
 
2925
3031
 
2926
3032
  def export_to_files(
@@ -2952,9 +3058,15 @@ def export_to_files(
2952
3058
  dump_kwargs: dict[str, t.Any] = {"separators": (",", ":"), "default": str}
2953
3059
  else:
2954
3060
  dump_kwargs = {"indent": 2, "default": str}
2955
- for name, data in export_all(save, map_config, cluster=cluster).items():
2956
- payload: t.Any = _wrap_with_meta(data, save, map_config) if wrap else data
3061
+ # Stream each export type record-by-record straight to its file: a record
3062
+ # is built, encoded, written, then released. The full per-type list never
3063
+ # materializes and no whole-file JSON string is built — both were the
3064
+ # measured peak-RAM drivers on large PvE saves (the structures list alone
3065
+ # was hundreds of MB of nested inventory dicts).
3066
+ head = _meta_head(save, map_config) if wrap else None
3067
+ for name, records in _iter_exports(save, map_config, cluster):
2957
3068
  path = out / f"{name}.json"
2958
- path.write_text(json.dumps(payload, **dump_kwargs), encoding="utf-8")
3069
+ with path.open("w", encoding="utf-8") as fh:
3070
+ _stream_dump(fh, head, records, dump_kwargs)
2959
3071
  created.append(path)
2960
3072
  return created
@@ -64,23 +64,34 @@ class GameObject:
64
64
  parent: GameObject | None = field(default=None, repr=False)
65
65
  components: dict[str, GameObject] = field(default_factory=dict, repr=False)
66
66
 
67
- # Lazy property index: name -> {index: Property}. Built on first lookup,
68
- # invalidated by setting to None when properties is mutated.
69
- _prop_index: dict[str, dict[int, "Property"]] | None = field(default=None, repr=False, compare=False)
67
+ # Lazy property index: name -> Property (single-index, the common case) OR
68
+ # name -> {index: Property} (only when a name carries >1 distinct index).
69
+ # Storing the bare Property for single-index names avoids allocating one
70
+ # inner dict per (object, name): on a 300k-object save that was ~5.4M dicts
71
+ # / ~600 MB of pure index overhead built during export. Built on first
72
+ # lookup, invalidated by setting to None when properties is mutated.
73
+ _prop_index: dict[str, "Property | dict[int, Property]"] | None = field(
74
+ default=None, repr=False, compare=False
75
+ )
70
76
 
71
- def _build_prop_index(self) -> dict[str, dict[int, "Property"]]:
72
- idx: dict[str, dict[int, "Property"]] = {}
77
+ def _build_prop_index(self) -> dict[str, "Property | dict[int, Property]"]:
78
+ # First-writer wins, matching legacy C# GetPropertyValue
79
+ # (IPropertyContainer.cs:45) which returns the first (name, index)
80
+ # match. _serialize_properties keeps all occurrences in the additive
81
+ # `properties` dict; lookups intentionally mirror legacy here. Do not
82
+ # switch to last-wins.
83
+ idx: dict[str, "Property | dict[int, Property]"] = {}
73
84
  for prop in self.properties:
74
- bucket = idx.get(prop.name)
75
- if bucket is None:
76
- idx[prop.name] = {prop.index: prop}
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.
83
- bucket.setdefault(prop.index, prop)
85
+ existing = idx.get(prop.name)
86
+ if existing is None:
87
+ idx[prop.name] = prop # common case: store the bare Property
88
+ elif isinstance(existing, dict):
89
+ existing.setdefault(prop.index, prop)
90
+ elif existing.index != prop.index:
91
+ # Second distinct index for this name: promote to a dict.
92
+ # `existing` first preserves first-writer iteration order.
93
+ idx[prop.name] = {existing.index: existing, prop.index: prop}
94
+ # else: duplicate (name, index) -> keep first-writer `existing`.
84
95
  self._prop_index = idx
85
96
  return idx
86
97
 
@@ -108,11 +119,16 @@ class GameObject:
108
119
  """Get a property by name and optional index (None = first by insertion)."""
109
120
  idx = self._prop_index if self._prop_index is not None else self._build_prop_index()
110
121
  bucket = idx.get(name)
111
- if not bucket:
122
+ if bucket is None:
112
123
  return None
113
- if index is None:
114
- return next(iter(bucket.values()))
115
- return bucket.get(index)
124
+ if isinstance(bucket, dict):
125
+ if index is None:
126
+ return next(iter(bucket.values()))
127
+ return bucket.get(index)
128
+ # Single-index bucket: the bare Property.
129
+ if index is None or bucket.index == index:
130
+ return bucket
131
+ return None
116
132
 
117
133
  def get_property_value(self, name: str, default: t.Any = None, index: int | None = None) -> t.Any:
118
134
  """Get a property value by name (returns default if missing)."""
@@ -123,7 +139,11 @@ class GameObject:
123
139
  """Get all properties with the given name (any index)."""
124
140
  idx = self._prop_index if self._prop_index is not None else self._build_prop_index()
125
141
  bucket = idx.get(name)
126
- return list(bucket.values()) if bucket else []
142
+ if bucket is None:
143
+ return []
144
+ if isinstance(bucket, dict):
145
+ return list(bucket.values())
146
+ return [bucket]
127
147
 
128
148
  def has_property(self, name: str) -> bool:
129
149
  """Check if this object has a property with the given name."""
@@ -23,7 +23,7 @@ if t.TYPE_CHECKING:
23
23
  from ..common.binary_reader import BinaryReader
24
24
 
25
25
 
26
- @dataclass
26
+ @dataclass(slots=True)
27
27
  class Property(ABC):
28
28
  """
29
29
  Base class for all ARK properties.
@@ -82,7 +82,7 @@ class Property(ABC):
82
82
  raise NotImplementedError(f"{cls.__name__} must implement read()")
83
83
 
84
84
 
85
- @dataclass
85
+ @dataclass(slots=True)
86
86
  class PropertyHeader:
87
87
  """
88
88
  Property header data read before the value.
@@ -19,7 +19,7 @@ if t.TYPE_CHECKING:
19
19
  from ..common.binary_reader import BinaryReader
20
20
 
21
21
 
22
- @dataclass
22
+ @dataclass(slots=True)
23
23
  class ByteProperty(Property):
24
24
  """
25
25
  Byte property - can be either a raw byte or an enum value.
@@ -46,7 +46,7 @@ if t.TYPE_CHECKING:
46
46
  # =============================================================================
47
47
 
48
48
 
49
- @dataclass
49
+ @dataclass(slots=True)
50
50
  class ArrayProperty(Property):
51
51
  """
52
52
  Array property - contains a list of values of the same type.
@@ -66,14 +66,14 @@ class ArrayProperty(Property):
66
66
  name: str
67
67
  index: int = 0
68
68
  array_type: str = ""
69
- _values: list[t.Any] = field(default_factory=list)
69
+ _values: list[t.Any] | bytes = field(default_factory=list)
70
70
 
71
71
  @property
72
72
  def type_name(self) -> str:
73
73
  return "ArrayProperty"
74
74
 
75
75
  @property
76
- def value(self) -> list[t.Any]:
76
+ def value(self) -> list[t.Any] | bytes:
77
77
  return self._values
78
78
 
79
79
  @property
@@ -378,7 +378,7 @@ def _read_array_elements(
378
378
  array_name: str,
379
379
  is_asa: bool,
380
380
  name_table: list[str] | None = None,
381
- ) -> list[t.Any]:
381
+ ) -> list[t.Any] | bytes:
382
382
  """
383
383
  Read array elements based on the array type.
384
384
 
@@ -393,7 +393,7 @@ def _read_array_elements(
393
393
  is_asa: True for ASA format.
394
394
  name_table: Optional name table for world saves (version 6+).
395
395
  """
396
- values: list[t.Any] = []
396
+ values: list[t.Any] | bytes = []
397
397
 
398
398
  # Simple numeric types
399
399
  if array_type == "IntProperty":
@@ -427,8 +427,9 @@ def _read_array_elements(
427
427
  for _ in range(count):
428
428
  values.append(read_name(reader, name_table))
429
429
  else:
430
- for _ in range(count):
431
- values.append(reader.read_uint8())
430
+ # Raw byte array -> one bytes blob (8x lighter than list[int];
431
+ # consumers accept bytes; never reaches JSON output).
432
+ values = reader.read_bytes(count)
432
433
  elif array_type == "FloatProperty":
433
434
  for _ in range(count):
434
435
  values.append(reader.read_float())
@@ -599,7 +600,7 @@ def _read_worldsave_array_elements(
599
600
  element_type: str,
600
601
  count: int,
601
602
  name_table: dict[int, str],
602
- ) -> list[t.Any]:
603
+ ) -> list[t.Any] | bytes:
603
604
  """
604
605
  Read array elements for ASA WorldSave format.
605
606
 
@@ -616,7 +617,7 @@ def _read_worldsave_array_elements(
616
617
  Returns:
617
618
  List of element values.
618
619
  """
619
- values: list[t.Any] = []
620
+ values: list[t.Any] | bytes = []
620
621
 
621
622
  # Simple numeric types - same as other formats
622
623
  if element_type == "IntProperty":
@@ -641,8 +642,8 @@ def _read_worldsave_array_elements(
641
642
  for _ in range(count):
642
643
  values.append(reader.read_int8())
643
644
  elif element_type == "ByteProperty":
644
- for _ in range(count):
645
- values.append(reader.read_uint8())
645
+ # Raw byte array -> one bytes blob (see ASE branch).
646
+ values = reader.read_bytes(count)
646
647
  elif element_type == "FloatProperty":
647
648
  for _ in range(count):
648
649
  values.append(reader.read_float())
@@ -792,7 +793,7 @@ def _read_worldsave_struct_array_elements(
792
793
  # =============================================================================
793
794
 
794
795
 
795
- @dataclass
796
+ @dataclass(slots=True)
796
797
  class StructProperty(Property):
797
798
  """
798
799
  Struct property - contains structured data.
@@ -1064,7 +1065,7 @@ class StructProperty(Property):
1064
1065
  # =============================================================================
1065
1066
 
1066
1067
 
1067
- @dataclass
1068
+ @dataclass(slots=True)
1068
1069
  class MapProperty(Property):
1069
1070
  """
1070
1071
  Map property - contains key-value pairs.
@@ -63,7 +63,7 @@ def _read_worldsave_simple_prefix(reader: BinaryReader) -> tuple[int, int, int]:
63
63
  # =============================================================================
64
64
 
65
65
 
66
- @dataclass
66
+ @dataclass(slots=True)
67
67
  class Int8Property(Property):
68
68
  """Signed 8-bit integer property."""
69
69
 
@@ -100,7 +100,7 @@ class Int8Property(Property):
100
100
  return cls(name=header.name, index=index, _value=value)
101
101
 
102
102
 
103
- @dataclass
103
+ @dataclass(slots=True)
104
104
  class Int16Property(Property):
105
105
  """Signed 16-bit integer property."""
106
106
 
@@ -137,7 +137,7 @@ class Int16Property(Property):
137
137
  return cls(name=header.name, index=index, _value=value)
138
138
 
139
139
 
140
- @dataclass
140
+ @dataclass(slots=True)
141
141
  class IntProperty(Property):
142
142
  """Signed 32-bit integer property (most common integer type)."""
143
143
 
@@ -174,7 +174,7 @@ class IntProperty(Property):
174
174
  return cls(name=header.name, index=index, _value=value)
175
175
 
176
176
 
177
- @dataclass
177
+ @dataclass(slots=True)
178
178
  class Int64Property(Property):
179
179
  """Signed 64-bit integer property."""
180
180
 
@@ -211,7 +211,7 @@ class Int64Property(Property):
211
211
  return cls(name=header.name, index=index, _value=value)
212
212
 
213
213
 
214
- @dataclass
214
+ @dataclass(slots=True)
215
215
  class UInt16Property(Property):
216
216
  """Unsigned 16-bit integer property."""
217
217
 
@@ -248,7 +248,7 @@ class UInt16Property(Property):
248
248
  return cls(name=header.name, index=index, _value=value)
249
249
 
250
250
 
251
- @dataclass
251
+ @dataclass(slots=True)
252
252
  class UInt32Property(Property):
253
253
  """Unsigned 32-bit integer property."""
254
254
 
@@ -285,7 +285,7 @@ class UInt32Property(Property):
285
285
  return cls(name=header.name, index=index, _value=value)
286
286
 
287
287
 
288
- @dataclass
288
+ @dataclass(slots=True)
289
289
  class UInt64Property(Property):
290
290
  """Unsigned 64-bit integer property."""
291
291
 
@@ -322,7 +322,7 @@ class UInt64Property(Property):
322
322
  return cls(name=header.name, index=index, _value=value)
323
323
 
324
324
 
325
- @dataclass
325
+ @dataclass(slots=True)
326
326
  class FloatProperty(Property):
327
327
  """32-bit floating point property."""
328
328
 
@@ -359,7 +359,7 @@ class FloatProperty(Property):
359
359
  return cls(name=header.name, index=index, _value=value)
360
360
 
361
361
 
362
- @dataclass
362
+ @dataclass(slots=True)
363
363
  class DoubleProperty(Property):
364
364
  """64-bit floating point property."""
365
365
 
@@ -401,7 +401,7 @@ class DoubleProperty(Property):
401
401
  # =============================================================================
402
402
 
403
403
 
404
- @dataclass
404
+ @dataclass(slots=True)
405
405
  class BoolProperty(Property):
406
406
  """
407
407
  Boolean property.
@@ -471,7 +471,7 @@ class BoolProperty(Property):
471
471
  # =============================================================================
472
472
 
473
473
 
474
- @dataclass
474
+ @dataclass(slots=True)
475
475
  class StrProperty(Property):
476
476
  """String property (length-prefixed string)."""
477
477
 
@@ -508,7 +508,7 @@ class StrProperty(Property):
508
508
  return cls(name=header.name, index=index, _value=value)
509
509
 
510
510
 
511
- @dataclass
511
+ @dataclass(slots=True)
512
512
  class NameProperty(Property):
513
513
  """
514
514
  Name property (UE4 FName).
@@ -573,7 +573,7 @@ class NameProperty(Property):
573
573
  # =============================================================================
574
574
 
575
575
 
576
- @dataclass
576
+ @dataclass(slots=True)
577
577
  class ObjectProperty(Property):
578
578
  """
579
579
  Object reference property.
@@ -725,7 +725,7 @@ class ObjectProperty(Property):
725
725
  # =============================================================================
726
726
 
727
727
 
728
- @dataclass
728
+ @dataclass(slots=True)
729
729
  class SoftObjectProperty(Property):
730
730
  """
731
731
  Soft object reference property.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkparser
3
- Version: 0.5.0
3
+ Version: 0.5.3
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
@@ -1,86 +1,86 @@
1
- [build-system]
2
- requires = ["setuptools>=69"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "arkparser"
7
- version = "0.5.0"
8
- description = "Pure Python parser for ARK: Survival Evolved and ARK: Survival Ascended save files"
9
- readme = "README.md"
10
- license = "MIT"
11
- license-files = ["LICENSE"]
12
- requires-python = ">=3.10"
13
- authors = [{ name = "Vertyco" }]
14
- keywords = [
15
- "ark",
16
- "survival",
17
- "evolved",
18
- "ascended",
19
- "savegame",
20
- "parser",
21
- "binary",
22
- "ase",
23
- "asa",
24
- "game",
25
- "save-file",
26
- ]
27
- classifiers = [
28
- "Development Status :: 4 - Beta",
29
- "Intended Audience :: Developers",
30
- "Operating System :: OS Independent",
31
- "Programming Language :: Python :: 3",
32
- "Programming Language :: Python :: 3 :: Only",
33
- "Programming Language :: Python :: 3.10",
34
- "Programming Language :: Python :: 3.11",
35
- "Programming Language :: Python :: 3.12",
36
- "Programming Language :: Python :: 3.13",
37
- "Programming Language :: Python :: 3.14",
38
- "Topic :: Games/Entertainment",
39
- "Topic :: Software Development :: Libraries :: Python Modules",
40
- "Typing :: Typed",
41
- ]
42
-
43
- [project.optional-dependencies]
44
- dev = ["pytest>=9.0", "ruff>=0.15", "build>=1.4", "twine>=6.0"]
45
-
46
- [project.urls]
47
- Homepage = "https://github.com/vertyco/arkparser"
48
- Repository = "https://github.com/vertyco/arkparser"
49
- Documentation = "https://github.com/vertyco/arkparser#readme"
50
- Issues = "https://github.com/vertyco/arkparser/issues"
51
-
52
- [tool.setuptools.packages.find]
53
- include = ["arkparser*"]
54
- exclude = ["*tests*", "references*"]
55
-
56
- [tool.setuptools.package-data]
57
- arkparser = ["py.typed"]
58
-
59
- [tool.ruff]
60
- line-length = 120
61
- target-version = "py310"
62
-
63
- exclude = ["references/", ".venv", "build", "dist"]
64
-
65
- [tool.ruff.lint]
66
- select = ["E4", "E7", "E9", "F"]
67
- ignore = ["E501"]
68
- fixable = ["ALL"]
69
- unfixable = []
70
- dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
71
-
72
- [tool.ruff.lint.mccabe]
73
- max-complexity = 10
74
-
75
- [tool.ruff.format]
76
- quote-style = "double"
77
- indent-style = "space"
78
- skip-magic-trailing-comma = false
79
- line-ending = "auto"
80
- docstring-code-line-length = "dynamic"
81
-
82
- [tool.pytest.ini_options]
83
- testpaths = ["tests"]
84
- python_files = ["test_*.py", "*_test.py"]
85
- python_classes = ["Test*"]
86
- python_functions = ["test_*"]
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "arkparser"
7
+ version = "0.5.3"
8
+ description = "Pure Python parser for ARK: Survival Evolved and ARK: Survival Ascended save files"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Vertyco" }]
14
+ keywords = [
15
+ "ark",
16
+ "survival",
17
+ "evolved",
18
+ "ascended",
19
+ "savegame",
20
+ "parser",
21
+ "binary",
22
+ "ase",
23
+ "asa",
24
+ "game",
25
+ "save-file",
26
+ ]
27
+ classifiers = [
28
+ "Development Status :: 4 - Beta",
29
+ "Intended Audience :: Developers",
30
+ "Operating System :: OS Independent",
31
+ "Programming Language :: Python :: 3",
32
+ "Programming Language :: Python :: 3 :: Only",
33
+ "Programming Language :: Python :: 3.10",
34
+ "Programming Language :: Python :: 3.11",
35
+ "Programming Language :: Python :: 3.12",
36
+ "Programming Language :: Python :: 3.13",
37
+ "Programming Language :: Python :: 3.14",
38
+ "Topic :: Games/Entertainment",
39
+ "Topic :: Software Development :: Libraries :: Python Modules",
40
+ "Typing :: Typed",
41
+ ]
42
+
43
+ [project.optional-dependencies]
44
+ dev = ["pytest>=9.0", "ruff>=0.15", "build>=1.4", "twine>=6.0"]
45
+
46
+ [project.urls]
47
+ Homepage = "https://github.com/vertyco/arkparser"
48
+ Repository = "https://github.com/vertyco/arkparser"
49
+ Documentation = "https://github.com/vertyco/arkparser#readme"
50
+ Issues = "https://github.com/vertyco/arkparser/issues"
51
+
52
+ [tool.setuptools.packages.find]
53
+ include = ["arkparser*"]
54
+ exclude = ["*tests*", "references*"]
55
+
56
+ [tool.setuptools.package-data]
57
+ arkparser = ["py.typed"]
58
+
59
+ [tool.ruff]
60
+ line-length = 120
61
+ target-version = "py310"
62
+
63
+ exclude = ["references/", ".venv", "build", "dist"]
64
+
65
+ [tool.ruff.lint]
66
+ select = ["E4", "E7", "E9", "F"]
67
+ ignore = ["E501"]
68
+ fixable = ["ALL"]
69
+ unfixable = []
70
+ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
71
+
72
+ [tool.ruff.lint.mccabe]
73
+ max-complexity = 10
74
+
75
+ [tool.ruff.format]
76
+ quote-style = "double"
77
+ indent-style = "space"
78
+ skip-magic-trailing-comma = false
79
+ line-ending = "auto"
80
+ docstring-code-line-length = "dynamic"
81
+
82
+ [tool.pytest.ini_options]
83
+ testpaths = ["tests"]
84
+ python_files = ["test_*.py", "*_test.py"]
85
+ python_classes = ["Test*"]
86
+ python_functions = ["test_*"]
@@ -15,6 +15,7 @@ import pytest
15
15
 
16
16
  from arkparser.common.binary_reader import BinaryReader
17
17
  from arkparser.common.exceptions import CorruptDataError, EndOfDataError
18
+ from arkparser.common.normalization import normalize_indexed_list
18
19
  from arkparser.data_models import UploadedCreature, UploadedItem
19
20
  from arkparser.export import (
20
21
  _SyntheticGameObject,
@@ -238,11 +239,25 @@ def test_uploaded_creature_nan_experience_zeroed() -> None:
238
239
 
239
240
  # --- code-review (max): ASE byte-array enum-name discriminator (C1) ----------
240
241
 
241
- def test_byte_array_raw_uint8_path_unchanged() -> None:
242
- # data_size == count+4 -> raw uint8 (common case; must not regress).
242
+ def test_byte_array_raw_uint8_path_bytes() -> None:
243
+ # data_size == count+4 -> raw uint8 array. Since 0.5.2 the raw path returns
244
+ # a single bytes blob (lighter than list[int]); the no-drift invariant
245
+ # (reader fully consumed) is what this guards.
243
246
  reader = BinaryReader.from_bytes(b"\x0a\x14\x1e")
244
247
  vals = _read_array_elements(reader, "ByteProperty", 3, 3 + 4, "Foo", False, None)
245
- assert vals == [10, 20, 30]
248
+ assert vals == bytes([10, 20, 30])
249
+ assert isinstance(vals, bytes)
250
+ assert reader.remaining == 0
251
+
252
+
253
+ def test_normalize_indexed_list_expands_bytes_to_ints() -> None:
254
+ # Regression: once raw ByteProperty arrays became `bytes` (0.5.2), element
255
+ # consumers that iterate them (tribe MembersRankGroups -> int(rank)) blew up
256
+ # because normalize_indexed_list wrapped the blob as [b'...'] instead of
257
+ # exposing its ints. A bytes value must normalize to a list of ints.
258
+ assert normalize_indexed_list(bytes([0, 2, 5])) == [0, 2, 5]
259
+ assert normalize_indexed_list(b"") == []
260
+ assert [int(r) for r in normalize_indexed_list(b"\x00")] == [0]
246
261
 
247
262
 
248
263
  def test_byte_array_enum_name_path_no_drift() -> None:
File without changes
File without changes
File without changes
File without changes