arkparser 0.5.4__tar.gz → 0.6.0__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 (64) hide show
  1. {arkparser-0.5.4 → arkparser-0.6.0}/PKG-INFO +48 -8
  2. {arkparser-0.5.4 → arkparser-0.6.0}/README.md +47 -7
  3. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/__init__.py +98 -98
  4. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/common/binary_reader.py +98 -5
  5. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/common/normalization.py +15 -2
  6. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/data_models.py +2 -2
  7. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/export.py +317 -74
  8. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/files/world_save.py +378 -43
  9. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/game_objects/container.py +149 -20
  10. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/game_objects/game_object.py +77 -0
  11. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/properties/base.py +32 -10
  12. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/properties/compound.py +1 -1
  13. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/properties/primitives.py +5 -5
  14. arkparser-0.6.0/arkparser/properties/registry.py +434 -0
  15. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser.egg-info/PKG-INFO +48 -8
  16. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser.egg-info/SOURCES.txt +2 -0
  17. {arkparser-0.5.4 → arkparser-0.6.0}/pyproject.toml +86 -86
  18. {arkparser-0.5.4 → arkparser-0.6.0}/tests/test_binary_reader.py +19 -1
  19. {arkparser-0.5.4 → arkparser-0.6.0}/tests/test_cryopod_export.py +1 -1
  20. arkparser-0.6.0/tests/test_golden_exports.py +78 -0
  21. arkparser-0.6.0/tests/test_lazy_properties.py +164 -0
  22. {arkparser-0.5.4 → arkparser-0.6.0}/tests/test_review_fixes.py +1 -1
  23. arkparser-0.5.4/arkparser/properties/registry.py +0 -256
  24. {arkparser-0.5.4 → arkparser-0.6.0}/LICENSE +0 -0
  25. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/common/__init__.py +0 -0
  26. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/common/exceptions.py +0 -0
  27. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/common/map_config.py +0 -0
  28. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/common/types.py +0 -0
  29. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/common/version_detection.py +0 -0
  30. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/files/__init__.py +0 -0
  31. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/files/base.py +0 -0
  32. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/files/cloud_inventory.py +0 -0
  33. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/files/profile.py +0 -0
  34. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/files/tribe.py +0 -0
  35. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/game_objects/__init__.py +0 -0
  36. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/game_objects/location.py +0 -0
  37. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/properties/__init__.py +0 -0
  38. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/properties/byte_property.py +0 -0
  39. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/structs/__init__.py +0 -0
  40. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/structs/base.py +0 -0
  41. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/structs/colors.py +0 -0
  42. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/structs/misc.py +0 -0
  43. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/structs/property_list.py +0 -0
  44. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/structs/registry.py +0 -0
  45. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser/structs/vectors.py +0 -0
  46. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser.egg-info/dependency_links.txt +0 -0
  47. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser.egg-info/requires.txt +0 -0
  48. {arkparser-0.5.4 → arkparser-0.6.0}/arkparser.egg-info/top_level.txt +0 -0
  49. {arkparser-0.5.4 → arkparser-0.6.0}/setup.cfg +0 -0
  50. {arkparser-0.5.4 → arkparser-0.6.0}/tests/test_asa_header_position.py +0 -0
  51. {arkparser-0.5.4 → arkparser-0.6.0}/tests/test_asa_name_table.py +0 -0
  52. {arkparser-0.5.4 → arkparser-0.6.0}/tests/test_ase_cluster_drift.py +0 -0
  53. {arkparser-0.5.4 → arkparser-0.6.0}/tests/test_binary_reader_layouts.py +0 -0
  54. {arkparser-0.5.4 → arkparser-0.6.0}/tests/test_cloud_export.py +0 -0
  55. {arkparser-0.5.4 → arkparser-0.6.0}/tests/test_cloud_inventory.py +0 -0
  56. {arkparser-0.5.4 → arkparser-0.6.0}/tests/test_current_stats.py +0 -0
  57. {arkparser-0.5.4 → arkparser-0.6.0}/tests/test_data_models.py +0 -0
  58. {arkparser-0.5.4 → arkparser-0.6.0}/tests/test_export.py +0 -0
  59. {arkparser-0.5.4 → arkparser-0.6.0}/tests/test_game_objects.py +0 -0
  60. {arkparser-0.5.4 → arkparser-0.6.0}/tests/test_profile.py +0 -0
  61. {arkparser-0.5.4 → arkparser-0.6.0}/tests/test_tribe.py +0 -0
  62. {arkparser-0.5.4 → arkparser-0.6.0}/tests/test_v13_property_layouts.py +0 -0
  63. {arkparser-0.5.4 → arkparser-0.6.0}/tests/test_version_detection.py +0 -0
  64. {arkparser-0.5.4 → arkparser-0.6.0}/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.4
3
+ Version: 0.6.0
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
@@ -58,6 +58,7 @@ A pure-Python library for parsing ARK: Survival Evolved (ASE) and ARK: Survival
58
58
  - **Dual format**: automatic ASE (v5-12) / ASA (v13-14+, SQLite) detection
59
59
  - **Legacy-parity export**: drop-in JSON output matching `ASVExport.exe` schema, plus parser-only extras under descriptive snake_case keys (no namespace prefix; never overloading a legacy key)
60
60
  - **Fast**: pure-Python `BinaryReader` (`int.from_bytes` + `struct.Struct` unpackers, slots-based dataclasses), a 30 MB ASE save (65k objects) loads in ~3s on CPython 3.14
61
+ - **Memory-bounded**: streaming JSON export plus an opt-in lazy parse mode (`lazy_properties=True`, ASE and ASA) that materializes property blocks on demand and evicts them as records stream to disk; a 1.8 GB / 1.79M-object busy-PvE ASE save exports in 2.5 GB peak RSS instead of 7.1 GB, and a 217k-object ASA save in 307 MB instead of 853 MB (and slightly faster)
61
62
 
62
63
  ## Installation
63
64
 
@@ -132,6 +133,38 @@ print(f"Parse errors: {save.parse_error_count}")
132
133
  print(f"Is ASA: {save.is_asa}")
133
134
  ```
134
135
 
136
+ ### Low-memory parsing (large saves)
137
+
138
+ By default every object's property block is parsed up front, which on a busy
139
+ multi-GB PvE save means a multi-GB resident object graph. `lazy_properties=True`
140
+ parses object headers eagerly but defers each property block until something
141
+ reads it, then the export drivers evict it again once the record has been
142
+ written:
143
+
144
+ ```python
145
+ from arkparser import WorldSave, export_to_files
146
+ from arkparser.common import get_map_config
147
+
148
+ save = WorldSave.load("path/to/Fjordur.ark", lazy_properties=True) # ASE
149
+ save = WorldSave.load("path/to/TheIsland_WP.ark", lazy_properties=True) # ASA
150
+ export_to_files(save, "output/", get_map_config("fjordur.ark"))
151
+ ```
152
+
153
+ Output is identical to eager mode (validated record-for-record). ASE retains
154
+ the file reader and re-seeks each object's block on demand; ASA retains the
155
+ SQLite connection (one held read transaction) and re-fetches row blobs by
156
+ GUID. On ASA v14+ saves the export pipeline additionally uses partial decodes:
157
+ a verified byte-exact skip walk that parses only the property names a record
158
+ needs, with any out-of-whitelist read transparently upgrading to the full
159
+ block.
160
+
161
+ Measured load + export, same machine: a 1.8 GB Fjordur PvE ASE save (1.79M
162
+ objects) drops from ~7.1 GB peak RSS / 348 s eager to ~2.5 GB / 287 s lazy; a
163
+ 233 MB ASA TheIsland save (217k objects) drops from 853 MB / 38 s eager to
164
+ 307 MB / ~36 s lazy. Property access on a lazy save is transparent: any
165
+ `get_property_value` call materializes the block on demand, so all getters and
166
+ exports work unchanged.
167
+
135
168
  ### Cryopod-stored Creatures
136
169
 
137
170
  Tamed creatures stored inside in-world cryopods aren't returned by `get_tamed_creatures()`. Iterate them via `WorldSave.iter_cryopod_creatures()`:
@@ -218,7 +251,7 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
218
251
  | `isClone` | legacy | `bIsClone` or `bIsCloneDino` |
219
252
  | `tamedServer` | legacy | `TamedOnServerName` |
220
253
  | `uploadedServer` | legacy | `UploadedFromServerName` |
221
- | `maturation` | legacy | `str(int(BabyAge * 100))` integer maturation percent (a baby with no `BabyAge` is `0`, a newborn). Note: legacy emits the full float string (e.g. `"7.5131035"`); arkparser truncates to the integer percent. Semantically equal and the downstream consumer coerces to `int` either way. |
254
+ | `maturation` | legacy | `str(int(BabyAge * 100))`, integer maturation percent (a baby with no `BabyAge` is `0`, a newborn). Note: legacy emits the full float string (e.g. `"7.5131035"`); arkparser truncates to the integer percent. Semantically equal and the downstream consumer coerces to `int` either way. |
222
255
  | `traits` | legacy | `CreatureTraits` as a list of `{"trait": <class>}` objects (matches `ASVExport`'s shape, not a flat string list) |
223
256
  | `inventory` | legacy | items from `MyInventoryComponent.InventoryItems`. Each entry carries `itemId`, `qty`, `blueprint`, plus a full snake_case property dump flattened in at the top level (`id`, `rating`, `durability`, `quality`, `damage`, `armor`, `durability_max`, `hypo`, `hyper`, `clip_size`, `weight`, `crafter`, `crafter_tribe`, `skill_bonus`, `loaded_ammo`, `spoils_at`, `spoiled_at`, `c0`..`c5`, `egg_*`, etc). `item_stat_values` is unpacked into the universal 8-slot ARK map (slot 0 `gen_quality`, 1 `armor`, 2 `durability_max`, 3 `damage`, 4 `clip_size`, 5 `hypo`, 6 `weight`, 7 `hyperthermal_insulation`); raw uint16s scaled by the per-blueprint multiplier (which lives in the UE blueprint, not the save). Default / unset values are filtered (no `craft_queue=0`, `skin=-1`, `color_pre_skin=[0]*6`, NaN spoil timers, etc). When the item is a **cryopod / soultrap / vivarium / dinoball** with an embedded creature, the entry is enriched with `dino_id` (combined 64-bit id matching the corresponding `ASV_Tamed` record), `dino_creature` (species / class name), and `dino_name` (`TamedName` if set). Cryopods stored in containers (cryofridges, vaults, dedicated storage) get the same enrichment. |
224
257
  | `father_id`, `mother_id` | added | combined dino id from the first `DinoAncestors` entry (`null` when missing) |
@@ -322,13 +355,13 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
322
355
 
323
356
  #### `ASV_Tribes` schema
324
357
 
325
- `ASV_Tribes` (and `ASV_TribeLogs` / `ASV_Players`, which iterate the same list) is a **superset** of the loaded `.arktribe` files, mirroring legacy `ContentContainer`: it seeds two sentinels (`[ASV Unclaimed]` id `2000000000`, `[ASV Abandoned]` id `-2147483648`), adds every file-backed tribe, then synthesizes a stub tribe for each player profile not already in a tribe (`Tribe of <name>`, id = `PlayerDataID`), each distinct structure `TargetingTeam` (`>= 50000`), and each distinct in-world tame `TargetingTeam` deduped by id. Cross-server tribes that exist **only** in cluster cloud-inventory files (no map presence) are not synthesized.
358
+ `ASV_Tribes` (and `ASV_TribeLogs` / `ASV_Players`, which iterate the same list) is a **superset** of the loaded `.arktribe` files, mirroring legacy `ContentContainer`: it seeds two sentinels (`[ASV Unclaimed]` id `2000000000`, `[ASV Abandoned]` id `-2147483648`), adds every file-backed tribe, then synthesizes a stub tribe for each player profile not already in a tribe (`Tribe of <name>`, id = `PlayerDataID`), each distinct structure `TargetingTeam` (`>= 50000`), and each distinct in-world tame `TargetingTeam`, deduped by id. Cross-server tribes that exist **only** in cluster cloud-inventory files (no map presence) are not synthesized.
326
359
 
327
360
  | Field | Origin | Source / formula |
328
361
  |---|---|---|
329
362
  | `tribeid` | legacy | `TribeID` / parser `Tribe.tribe_id`, or a synthesized stub/sentinel id (see above) |
330
363
  | `tribe` | legacy | tribe name (file `TribeName`; for stubs, `OwnerName`/`TamerString`; or `Tribe of <character>` for a solo) |
331
- | `players` | legacy | count of players **allocated** to this tribe (profiles whose explicit team / membership / solo id resolves here, plus member back-fill), matching legacy `Players.Count` not the raw `.arktribe` member count |
364
+ | `players` | legacy | count of players **allocated** to this tribe (profiles whose explicit team / membership / solo id resolves here, plus member back-fill), matching legacy `Players.Count`, not the raw `.arktribe` member count |
332
365
  | `members` | legacy | list of `{ign, lvl, playerid, playername, steamid}` built from the allocated players. `lvl` and `steamid` populate from the matching `.arkprofile`; members with no profile (back-filled from the tribe's member list) carry `lvl=0`, `steamid=""`. |
333
366
  | `tames`, `structures` | legacy | counts derived from `WorldSave` (creatures + structures whose `TargetingTeam` matches) |
334
367
  | `uploadedTames` | legacy | reserved (currently `0`) |
@@ -353,7 +386,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
353
386
  | `id` | added | `GameObject.id`, internal numeric identifier. Cross-references the values in other records' `linked_structures` / `saddle_structures` / `attached_dino_id` lists. |
354
387
  | `tribeid` | legacy | `TargetingTeam` for player-owned structures (`>= 50000`). Unowned structures fall to the synthetic `[ASV Abandoned]` tribe id `-2147483648` (legacy `int.MinValue`), mirroring `ContentContainer.cs`. |
355
388
  | `tribe` | legacy | resolved owning-tribe name: the loaded `.arktribe` `TribeName`, else the structure's `OwnerName` / `TamerString`; `"[ASV Abandoned]"` for unowned. (Legacy emits the resolved tribe name, not the raw `OwnerName`.) |
356
- | `struct` | legacy | `GameObject.class_name`. Unowned map elements / crates / debug actors (`Button_*`, `*Vein_*`, `*Nest_*`, `*Beaver*`, `BeeHive_C`, `ArtifactCrate_*`, `TributeTerminal_*`, `SupplyCrate_*`) are excluded surfaced via `ASV_MapStructures` instead, matching legacy's abandoned-structure filter. |
389
+ | `struct` | legacy | `GameObject.class_name`. Unowned map elements / crates / debug actors (`Button_*`, `*Vein_*`, `*Nest_*`, `*Beaver*`, `BeeHive_C`, `ArtifactCrate_*`, `TributeTerminal_*`, `SupplyCrate_*`) are excluded; surfaced via `ASV_MapStructures` instead, matching legacy's abandoned-structure filter. |
357
390
  | `name` | legacy | `BoxName` (empty when it matches the class name, mirroring legacy's no-rename strip) |
358
391
  | `locked` | legacy | `bIsPinLocked` or `bIsLocked` |
359
392
  | `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. |
@@ -412,7 +445,7 @@ data = export_all(save, map_config, cluster="path/to/cluster")
412
445
  # .arkprofile filename stem.
413
446
  ```
414
447
 
415
- Cluster items are spliced into the owning player's `inventory`; no separate `ASV_ClusterItems` file is emitted. The match works because every cluster file and the player's `.arkprofile` share a stem the Steam id on ASE, the hex platform UUID on ASA so the join is keyed on the profile's source filename stem. (`Profile.unique_id` equals that stem only on ASE; on ASA it is the numeric net id, not the UUID filename, so loading profiles from real file paths which sets `Profile.source_path` is required for the ASA splice.)
448
+ Cluster items are spliced into the owning player's `inventory`; no separate `ASV_ClusterItems` file is emitted. The match works because every cluster file and the player's `.arkprofile` share a stem (the Steam id on ASE, the hex platform UUID on ASA) so the join is keyed on the profile's source filename stem. (`Profile.unique_id` equals that stem only on ASE; on ASA it is the numeric net id, not the UUID filename, so loading profiles from real file paths (which sets `Profile.source_path`) is required for the ASA splice.)
416
449
 
417
450
  Pre-loaded `CloudInventory` instances also work (`cluster=[inv1, inv2, ...]`).
418
451
 
@@ -530,7 +563,9 @@ Key methods:
530
563
 
531
564
  | Method | Returns | Description |
532
565
  |---|---|---|
533
- | `load(source, load_properties=True, max_objects=None)` | `WorldSave` | Load and parse a world save |
566
+ | `load(source, load_properties=True, max_objects=None, lazy_properties=False)` | `WorldSave` | Load and parse a world save (`lazy_properties`: on-demand property parsing, ASE + ASA, see Quick Start) |
567
+ | `materialize_object(obj, names=None)` | `None` | Parse one lazy object's deferred property block (called automatically on property access; `names` is an ASA v14+ partial-decode hint) |
568
+ | `evict_materialized()` | `int` | Release every property block materialized since the last call (lazy saves; no-op eager) |
534
569
  | `get_creatures()` | `list[GameObject]` | All creatures |
535
570
  | `get_tamed_creatures()` / `get_wild_creatures()` | `list[GameObject]` | Filtered creature sets |
536
571
  | `get_structures()` | `list[GameObject]` | Tribe-owned placed structures |
@@ -558,7 +593,7 @@ Key methods:
558
593
  | `properties` | `list[Property]` | Parsed property list |
559
594
  | `parent` / `components` | `GameObject \| None` / `dict[str, GameObject]` | Relationships |
560
595
 
561
- Lookup helpers: `get_property(name, index=None)`, `get_property_value(name, default=None, index=None)`, `get_properties_by_name(name)`, `has_property(name)`, `to_dict()`.
596
+ Lookup helpers: `get_property(name, index=None)`, `get_property_value(name, default=None, index=None)`, `get_properties_by_name(name)`, `has_property(name)`, `to_dict()`. On lazy saves every helper materializes the deferred property block transparently; `evict_properties()` releases it again (re-access re-parses, so eviction is always safe).
562
597
 
563
598
  #### GameObjectContainer (`arkparser.GameObjectContainer`)
564
599
 
@@ -674,6 +709,11 @@ python -m pytest tests/ -v
674
709
 
675
710
  Tests live in `tests/`. Byte-level layout tests (`test_v13_property_layouts.py`, `test_binary_reader_layouts.py`) pin the canonical v13/v14 property body byte sequences with no file-fixture dependency. Integration tests (`test_world_save.py`, `test_export.py`, etc.) skip cleanly when their referenced save files are not present under `references/examples/`.
676
711
 
712
+ Two suites guard export output against drift:
713
+
714
+ - **Golden manifests** (`tests/test_golden_exports.py` + `tests/golden/*.json`): a per-map fingerprint of `export_all` (record count, field-key union, order-independent sha256) for 17 real saves (10 ASE map dumps + 7 ASA). Any change to parsing or export that alters output fails the matching map. Regenerate intentionally with `ARKPARSER_UPDATE_GOLDEN=1`.
715
+ - **Lazy parity** (`tests/test_lazy_properties.py`): proves `lazy_properties=True` yields property-for-property identical objects (ASE and ASA), that evict/re-materialize round-trips, and that full lazy exports match the committed eager goldens. The ASA partial-decode skip walk is additionally verified byte-exact against the full parser over every object of every local ASA fixture by `references/scripts/verify_partial_walk.py`.
716
+
677
717
  ## Credits
678
718
 
679
719
  Built by reverse-engineering ARK save formats with heavy reference to [ASV (Ark Save Visualizer)](https://github.com/miragedmuk/ASV) by **miragedmuk**. The C# implementation in ASV is the primary reference for porting binary parsing logic to Python.
@@ -24,6 +24,7 @@ A pure-Python library for parsing ARK: Survival Evolved (ASE) and ARK: Survival
24
24
  - **Dual format**: automatic ASE (v5-12) / ASA (v13-14+, SQLite) detection
25
25
  - **Legacy-parity export**: drop-in JSON output matching `ASVExport.exe` schema, plus parser-only extras under descriptive snake_case keys (no namespace prefix; never overloading a legacy key)
26
26
  - **Fast**: pure-Python `BinaryReader` (`int.from_bytes` + `struct.Struct` unpackers, slots-based dataclasses), a 30 MB ASE save (65k objects) loads in ~3s on CPython 3.14
27
+ - **Memory-bounded**: streaming JSON export plus an opt-in lazy parse mode (`lazy_properties=True`, ASE and ASA) that materializes property blocks on demand and evicts them as records stream to disk; a 1.8 GB / 1.79M-object busy-PvE ASE save exports in 2.5 GB peak RSS instead of 7.1 GB, and a 217k-object ASA save in 307 MB instead of 853 MB (and slightly faster)
27
28
 
28
29
  ## Installation
29
30
 
@@ -98,6 +99,38 @@ print(f"Parse errors: {save.parse_error_count}")
98
99
  print(f"Is ASA: {save.is_asa}")
99
100
  ```
100
101
 
102
+ ### Low-memory parsing (large saves)
103
+
104
+ By default every object's property block is parsed up front, which on a busy
105
+ multi-GB PvE save means a multi-GB resident object graph. `lazy_properties=True`
106
+ parses object headers eagerly but defers each property block until something
107
+ reads it, then the export drivers evict it again once the record has been
108
+ written:
109
+
110
+ ```python
111
+ from arkparser import WorldSave, export_to_files
112
+ from arkparser.common import get_map_config
113
+
114
+ save = WorldSave.load("path/to/Fjordur.ark", lazy_properties=True) # ASE
115
+ save = WorldSave.load("path/to/TheIsland_WP.ark", lazy_properties=True) # ASA
116
+ export_to_files(save, "output/", get_map_config("fjordur.ark"))
117
+ ```
118
+
119
+ Output is identical to eager mode (validated record-for-record). ASE retains
120
+ the file reader and re-seeks each object's block on demand; ASA retains the
121
+ SQLite connection (one held read transaction) and re-fetches row blobs by
122
+ GUID. On ASA v14+ saves the export pipeline additionally uses partial decodes:
123
+ a verified byte-exact skip walk that parses only the property names a record
124
+ needs, with any out-of-whitelist read transparently upgrading to the full
125
+ block.
126
+
127
+ Measured load + export, same machine: a 1.8 GB Fjordur PvE ASE save (1.79M
128
+ objects) drops from ~7.1 GB peak RSS / 348 s eager to ~2.5 GB / 287 s lazy; a
129
+ 233 MB ASA TheIsland save (217k objects) drops from 853 MB / 38 s eager to
130
+ 307 MB / ~36 s lazy. Property access on a lazy save is transparent: any
131
+ `get_property_value` call materializes the block on demand, so all getters and
132
+ exports work unchanged.
133
+
101
134
  ### Cryopod-stored Creatures
102
135
 
103
136
  Tamed creatures stored inside in-world cryopods aren't returned by `get_tamed_creatures()`. Iterate them via `WorldSave.iter_cryopod_creatures()`:
@@ -184,7 +217,7 @@ The legacy ASVExport.exe emitted only the visible 8 stats (`hp`, `stam`, `melee`
184
217
  | `isClone` | legacy | `bIsClone` or `bIsCloneDino` |
185
218
  | `tamedServer` | legacy | `TamedOnServerName` |
186
219
  | `uploadedServer` | legacy | `UploadedFromServerName` |
187
- | `maturation` | legacy | `str(int(BabyAge * 100))` integer maturation percent (a baby with no `BabyAge` is `0`, a newborn). Note: legacy emits the full float string (e.g. `"7.5131035"`); arkparser truncates to the integer percent. Semantically equal and the downstream consumer coerces to `int` either way. |
220
+ | `maturation` | legacy | `str(int(BabyAge * 100))`, integer maturation percent (a baby with no `BabyAge` is `0`, a newborn). Note: legacy emits the full float string (e.g. `"7.5131035"`); arkparser truncates to the integer percent. Semantically equal and the downstream consumer coerces to `int` either way. |
188
221
  | `traits` | legacy | `CreatureTraits` as a list of `{"trait": <class>}` objects (matches `ASVExport`'s shape, not a flat string list) |
189
222
  | `inventory` | legacy | items from `MyInventoryComponent.InventoryItems`. Each entry carries `itemId`, `qty`, `blueprint`, plus a full snake_case property dump flattened in at the top level (`id`, `rating`, `durability`, `quality`, `damage`, `armor`, `durability_max`, `hypo`, `hyper`, `clip_size`, `weight`, `crafter`, `crafter_tribe`, `skill_bonus`, `loaded_ammo`, `spoils_at`, `spoiled_at`, `c0`..`c5`, `egg_*`, etc). `item_stat_values` is unpacked into the universal 8-slot ARK map (slot 0 `gen_quality`, 1 `armor`, 2 `durability_max`, 3 `damage`, 4 `clip_size`, 5 `hypo`, 6 `weight`, 7 `hyperthermal_insulation`); raw uint16s scaled by the per-blueprint multiplier (which lives in the UE blueprint, not the save). Default / unset values are filtered (no `craft_queue=0`, `skin=-1`, `color_pre_skin=[0]*6`, NaN spoil timers, etc). When the item is a **cryopod / soultrap / vivarium / dinoball** with an embedded creature, the entry is enriched with `dino_id` (combined 64-bit id matching the corresponding `ASV_Tamed` record), `dino_creature` (species / class name), and `dino_name` (`TamedName` if set). Cryopods stored in containers (cryofridges, vaults, dedicated storage) get the same enrichment. |
190
223
  | `father_id`, `mother_id` | added | combined dino id from the first `DinoAncestors` entry (`null` when missing) |
@@ -288,13 +321,13 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
288
321
 
289
322
  #### `ASV_Tribes` schema
290
323
 
291
- `ASV_Tribes` (and `ASV_TribeLogs` / `ASV_Players`, which iterate the same list) is a **superset** of the loaded `.arktribe` files, mirroring legacy `ContentContainer`: it seeds two sentinels (`[ASV Unclaimed]` id `2000000000`, `[ASV Abandoned]` id `-2147483648`), adds every file-backed tribe, then synthesizes a stub tribe for each player profile not already in a tribe (`Tribe of <name>`, id = `PlayerDataID`), each distinct structure `TargetingTeam` (`>= 50000`), and each distinct in-world tame `TargetingTeam` deduped by id. Cross-server tribes that exist **only** in cluster cloud-inventory files (no map presence) are not synthesized.
324
+ `ASV_Tribes` (and `ASV_TribeLogs` / `ASV_Players`, which iterate the same list) is a **superset** of the loaded `.arktribe` files, mirroring legacy `ContentContainer`: it seeds two sentinels (`[ASV Unclaimed]` id `2000000000`, `[ASV Abandoned]` id `-2147483648`), adds every file-backed tribe, then synthesizes a stub tribe for each player profile not already in a tribe (`Tribe of <name>`, id = `PlayerDataID`), each distinct structure `TargetingTeam` (`>= 50000`), and each distinct in-world tame `TargetingTeam`, deduped by id. Cross-server tribes that exist **only** in cluster cloud-inventory files (no map presence) are not synthesized.
292
325
 
293
326
  | Field | Origin | Source / formula |
294
327
  |---|---|---|
295
328
  | `tribeid` | legacy | `TribeID` / parser `Tribe.tribe_id`, or a synthesized stub/sentinel id (see above) |
296
329
  | `tribe` | legacy | tribe name (file `TribeName`; for stubs, `OwnerName`/`TamerString`; or `Tribe of <character>` for a solo) |
297
- | `players` | legacy | count of players **allocated** to this tribe (profiles whose explicit team / membership / solo id resolves here, plus member back-fill), matching legacy `Players.Count` not the raw `.arktribe` member count |
330
+ | `players` | legacy | count of players **allocated** to this tribe (profiles whose explicit team / membership / solo id resolves here, plus member back-fill), matching legacy `Players.Count`, not the raw `.arktribe` member count |
298
331
  | `members` | legacy | list of `{ign, lvl, playerid, playername, steamid}` built from the allocated players. `lvl` and `steamid` populate from the matching `.arkprofile`; members with no profile (back-filled from the tribe's member list) carry `lvl=0`, `steamid=""`. |
299
332
  | `tames`, `structures` | legacy | counts derived from `WorldSave` (creatures + structures whose `TargetingTeam` matches) |
300
333
  | `uploadedTames` | legacy | reserved (currently `0`) |
@@ -319,7 +352,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
319
352
  | `id` | added | `GameObject.id`, internal numeric identifier. Cross-references the values in other records' `linked_structures` / `saddle_structures` / `attached_dino_id` lists. |
320
353
  | `tribeid` | legacy | `TargetingTeam` for player-owned structures (`>= 50000`). Unowned structures fall to the synthetic `[ASV Abandoned]` tribe id `-2147483648` (legacy `int.MinValue`), mirroring `ContentContainer.cs`. |
321
354
  | `tribe` | legacy | resolved owning-tribe name: the loaded `.arktribe` `TribeName`, else the structure's `OwnerName` / `TamerString`; `"[ASV Abandoned]"` for unowned. (Legacy emits the resolved tribe name, not the raw `OwnerName`.) |
322
- | `struct` | legacy | `GameObject.class_name`. Unowned map elements / crates / debug actors (`Button_*`, `*Vein_*`, `*Nest_*`, `*Beaver*`, `BeeHive_C`, `ArtifactCrate_*`, `TributeTerminal_*`, `SupplyCrate_*`) are excluded surfaced via `ASV_MapStructures` instead, matching legacy's abandoned-structure filter. |
355
+ | `struct` | legacy | `GameObject.class_name`. Unowned map elements / crates / debug actors (`Button_*`, `*Vein_*`, `*Nest_*`, `*Beaver*`, `BeeHive_C`, `ArtifactCrate_*`, `TributeTerminal_*`, `SupplyCrate_*`) are excluded; surfaced via `ASV_MapStructures` instead, matching legacy's abandoned-structure filter. |
323
356
  | `name` | legacy | `BoxName` (empty when it matches the class name, mirroring legacy's no-rename strip) |
324
357
  | `locked` | legacy | `bIsPinLocked` or `bIsLocked` |
325
358
  | `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. |
@@ -378,7 +411,7 @@ data = export_all(save, map_config, cluster="path/to/cluster")
378
411
  # .arkprofile filename stem.
379
412
  ```
380
413
 
381
- Cluster items are spliced into the owning player's `inventory`; no separate `ASV_ClusterItems` file is emitted. The match works because every cluster file and the player's `.arkprofile` share a stem the Steam id on ASE, the hex platform UUID on ASA so the join is keyed on the profile's source filename stem. (`Profile.unique_id` equals that stem only on ASE; on ASA it is the numeric net id, not the UUID filename, so loading profiles from real file paths which sets `Profile.source_path` is required for the ASA splice.)
414
+ Cluster items are spliced into the owning player's `inventory`; no separate `ASV_ClusterItems` file is emitted. The match works because every cluster file and the player's `.arkprofile` share a stem (the Steam id on ASE, the hex platform UUID on ASA) so the join is keyed on the profile's source filename stem. (`Profile.unique_id` equals that stem only on ASE; on ASA it is the numeric net id, not the UUID filename, so loading profiles from real file paths (which sets `Profile.source_path`) is required for the ASA splice.)
382
415
 
383
416
  Pre-loaded `CloudInventory` instances also work (`cluster=[inv1, inv2, ...]`).
384
417
 
@@ -496,7 +529,9 @@ Key methods:
496
529
 
497
530
  | Method | Returns | Description |
498
531
  |---|---|---|
499
- | `load(source, load_properties=True, max_objects=None)` | `WorldSave` | Load and parse a world save |
532
+ | `load(source, load_properties=True, max_objects=None, lazy_properties=False)` | `WorldSave` | Load and parse a world save (`lazy_properties`: on-demand property parsing, ASE + ASA, see Quick Start) |
533
+ | `materialize_object(obj, names=None)` | `None` | Parse one lazy object's deferred property block (called automatically on property access; `names` is an ASA v14+ partial-decode hint) |
534
+ | `evict_materialized()` | `int` | Release every property block materialized since the last call (lazy saves; no-op eager) |
500
535
  | `get_creatures()` | `list[GameObject]` | All creatures |
501
536
  | `get_tamed_creatures()` / `get_wild_creatures()` | `list[GameObject]` | Filtered creature sets |
502
537
  | `get_structures()` | `list[GameObject]` | Tribe-owned placed structures |
@@ -524,7 +559,7 @@ Key methods:
524
559
  | `properties` | `list[Property]` | Parsed property list |
525
560
  | `parent` / `components` | `GameObject \| None` / `dict[str, GameObject]` | Relationships |
526
561
 
527
- Lookup helpers: `get_property(name, index=None)`, `get_property_value(name, default=None, index=None)`, `get_properties_by_name(name)`, `has_property(name)`, `to_dict()`.
562
+ Lookup helpers: `get_property(name, index=None)`, `get_property_value(name, default=None, index=None)`, `get_properties_by_name(name)`, `has_property(name)`, `to_dict()`. On lazy saves every helper materializes the deferred property block transparently; `evict_properties()` releases it again (re-access re-parses, so eviction is always safe).
528
563
 
529
564
  #### GameObjectContainer (`arkparser.GameObjectContainer`)
530
565
 
@@ -640,6 +675,11 @@ python -m pytest tests/ -v
640
675
 
641
676
  Tests live in `tests/`. Byte-level layout tests (`test_v13_property_layouts.py`, `test_binary_reader_layouts.py`) pin the canonical v13/v14 property body byte sequences with no file-fixture dependency. Integration tests (`test_world_save.py`, `test_export.py`, etc.) skip cleanly when their referenced save files are not present under `references/examples/`.
642
677
 
678
+ Two suites guard export output against drift:
679
+
680
+ - **Golden manifests** (`tests/test_golden_exports.py` + `tests/golden/*.json`): a per-map fingerprint of `export_all` (record count, field-key union, order-independent sha256) for 17 real saves (10 ASE map dumps + 7 ASA). Any change to parsing or export that alters output fails the matching map. Regenerate intentionally with `ARKPARSER_UPDATE_GOLDEN=1`.
681
+ - **Lazy parity** (`tests/test_lazy_properties.py`): proves `lazy_properties=True` yields property-for-property identical objects (ASE and ASA), that evict/re-materialize round-trips, and that full lazy exports match the committed eager goldens. The ASA partial-decode skip walk is additionally verified byte-exact against the full parser over every object of every local ASA fixture by `references/scripts/verify_partial_walk.py`.
682
+
643
683
  ## Credits
644
684
 
645
685
  Built by reverse-engineering ARK save formats with heavy reference to [ASV (Ark Save Visualizer)](https://github.com/miragedmuk/ASV) by **miragedmuk**. The C# implementation in ASV is the primary reference for porting binary parsing logic to Python.
@@ -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.4"
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.6.0"
@@ -21,7 +21,10 @@ Example:
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
+ import ctypes
25
+ import mmap
24
26
  import struct
27
+ import sys
25
28
  from pathlib import Path
26
29
  from uuid import UUID
27
30
 
@@ -32,6 +35,25 @@ from .exceptions import EndOfDataError
32
35
  _S_FLOAT = struct.Struct("<f")
33
36
  _S_DOUBLE = struct.Struct("<d")
34
37
  _S_INT32_PAIR = struct.Struct("<ii")
38
+ _S_INT32_X4 = struct.Struct("<4i")
39
+
40
+
41
+ def guid_str_le(raw: bytes) -> str:
42
+ """Format a 16-byte little-endian GUID as its canonical string.
43
+
44
+ Byte-for-byte equivalent to ``str(uuid.UUID(bytes_le=raw))`` (asserted in
45
+ tests) at ~40% of the cost; ASA saves key every game row and actor
46
+ transform by GUID, so this runs hundreds of thousands of times per load.
47
+ """
48
+ assert len(raw) == 16, "GUID must be 16 bytes"
49
+ h = raw.hex()
50
+ return (
51
+ h[6:8] + h[4:6] + h[2:4] + h[0:2]
52
+ + "-" + h[10:12] + h[8:10]
53
+ + "-" + h[14:16] + h[12:14]
54
+ + "-" + h[16:20]
55
+ + "-" + h[20:32]
56
+ )
35
57
 
36
58
 
37
59
  class BinaryReader:
@@ -39,10 +61,14 @@ class BinaryReader:
39
61
 
40
62
  __slots__ = ("_buf", "_pos", "_size", "save_version")
41
63
 
42
- def __init__(self, data: bytes | memoryview, save_version: int = 0) -> None:
43
- # Materialize to bytes once. CPython 3.14 small-bytes slicing is
44
- # significantly faster than memoryview slicing for the per-read
45
- # ``int.from_bytes(buf[a:b], ...)`` hot path used here.
64
+ def __init__(self, data: bytes | memoryview | mmap.mmap, save_version: int = 0) -> None:
65
+ # Materialize memoryviews to bytes once. CPython 3.14 small-bytes
66
+ # slicing is significantly faster than memoryview slicing for the
67
+ # per-read ``int.from_bytes(buf[a:b], ...)`` hot path used here.
68
+ # ``mmap`` buffers are kept as-is: slicing an mmap returns plain
69
+ # ``bytes``, so every read path behaves identically while the file
70
+ # contents stay out of the Python heap (used by lazy world saves,
71
+ # which retain the reader for the whole export).
46
72
  if isinstance(data, memoryview):
47
73
  self._buf = bytes(data)
48
74
  else:
@@ -61,9 +87,46 @@ class BinaryReader:
61
87
  self.close()
62
88
 
63
89
  def close(self) -> None:
64
- # Nothing to release; underlying buffer is plain bytes.
90
+ # Plain-bytes buffers have nothing to release; mmap buffers unmap.
91
+ if isinstance(self._buf, mmap.mmap):
92
+ self._buf.close()
65
93
  return None
66
94
 
95
+ def trim_working_set(self) -> bool:
96
+ """Drop this reader's clean mmap pages from the process working set.
97
+
98
+ A lazy world save touches every property page over the course of an
99
+ export. Those pages are clean and file-backed, but they accumulate in
100
+ the working set until peak RSS approaches heap + whole file size. On
101
+ POSIX, madvise(MADV_DONTNEED) releases just this mapping. Windows has
102
+ no per-range call usable on a read-only mapping, so EmptyWorkingSet
103
+ trims the whole process; evicted heap pages soft-fault back from the
104
+ standby list with no disk I/O.
105
+
106
+ Pre: none (safe on any reader). Post: returns True only when the
107
+ reader is mmap-backed and a trim was actually issued; plain-bytes
108
+ readers return False and do nothing.
109
+ """
110
+ if not isinstance(self._buf, mmap.mmap):
111
+ return False
112
+ if sys.platform == "win32":
113
+ kernel32 = ctypes.windll.kernel32
114
+ kernel32.GetCurrentProcess.restype = ctypes.c_void_p
115
+ handle = kernel32.GetCurrentProcess()
116
+ # EmptyWorkingSet lives in psapi.dll on older Windows and is
117
+ # forwarded from kernel32 as K32EmptyWorkingSet on newer builds.
118
+ empty = getattr(kernel32, "K32EmptyWorkingSet", None)
119
+ if empty is None:
120
+ empty = ctypes.windll.psapi.EmptyWorkingSet
121
+ empty.argtypes = [ctypes.c_void_p]
122
+ empty.restype = ctypes.c_int
123
+ return bool(empty(handle))
124
+ madvise_flag = getattr(mmap, "MADV_DONTNEED", None)
125
+ if madvise_flag is None:
126
+ return False
127
+ self._buf.madvise(madvise_flag)
128
+ return True
129
+
67
130
  # =========================================================================
68
131
  # Factory Methods
69
132
  # =========================================================================
@@ -72,6 +135,23 @@ class BinaryReader:
72
135
  def from_file(cls, path: str | Path) -> BinaryReader:
73
136
  return cls(Path(path).read_bytes())
74
137
 
138
+ @classmethod
139
+ def from_file_mmap(cls, path: str | Path) -> BinaryReader:
140
+ """Memory-map the file instead of reading it onto the heap.
141
+
142
+ For readers retained over a long export (lazy world saves): the OS
143
+ pages the contents in on demand and may reclaim them under pressure,
144
+ so the file bytes never count against the Python heap. Falls back to
145
+ :meth:`from_file` for empty files (Windows cannot mmap length 0).
146
+ """
147
+ p = Path(path)
148
+ if p.stat().st_size == 0:
149
+ return cls.from_file(p)
150
+ with open(p, "rb") as fh:
151
+ # mmap duplicates the OS handle; closing fh afterwards is safe.
152
+ buf = mmap.mmap(fh.fileno(), 0, access=mmap.ACCESS_READ)
153
+ return cls(buf)
154
+
75
155
  @classmethod
76
156
  def from_bytes(cls, data: bytes) -> BinaryReader:
77
157
  return cls(bytes(data) if not isinstance(data, bytes) else data)
@@ -200,6 +280,19 @@ class BinaryReader:
200
280
  self._pos += 8
201
281
  return a, b
202
282
 
283
+ def read_int32_x4(self) -> tuple[int, int, int, int]:
284
+ """Read four signed int32 values in a single struct unpack call.
285
+
286
+ Hot path for ASE property headers: after the name pair, the type
287
+ name-ref (index + instance) and data_size + index are 16 contiguous
288
+ bytes. One call beats two read_int32_pair calls.
289
+ """
290
+ if self._pos + 16 > self._size:
291
+ raise EndOfDataError(16, self._size - self._pos)
292
+ vals = _S_INT32_X4.unpack_from(self._buf, self._pos)
293
+ self._pos += 16
294
+ return vals
295
+
203
296
  # =========================================================================
204
297
  # Floating Point Types
205
298
  # =========================================================================
@@ -21,8 +21,17 @@ def normalize_indexed_data(value: t.Any) -> t.Any:
21
21
  and collapsed to a single value when only one indexed entry exists.
22
22
  - Other dicts are normalized value-by-value while keeping their keys.
23
23
  """
24
+ # Scalars are by far the most common input. Inline the recursion's leaf
25
+ # case at every call site below (``x if not container else recurse``) so a
26
+ # scalar element costs one ``isinstance`` instead of a full recursive call;
27
+ # this function was the hottest frame in the export profile (~2.5M calls).
24
28
  if isinstance(value, list):
25
- return [normalize_indexed_data(item) for item in value]
29
+ return [
30
+ item
31
+ if not isinstance(item, (list, dict))
32
+ else normalize_indexed_data(item)
33
+ for item in value
34
+ ]
26
35
 
27
36
  if not isinstance(value, dict):
28
37
  return value
@@ -30,7 +39,11 @@ def normalize_indexed_data(value: t.Any) -> t.Any:
30
39
  all_int = True
31
40
  out: dict[t.Any, t.Any] = {}
32
41
  for key, item in value.items():
33
- out[key] = normalize_indexed_data(item)
42
+ out[key] = (
43
+ item
44
+ if not isinstance(item, (list, dict))
45
+ else normalize_indexed_data(item)
46
+ )
34
47
  if all_int and not isinstance(key, int):
35
48
  all_int = False
36
49