arkparser 0.5.3__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.
- {arkparser-0.5.3 → arkparser-0.6.0}/PKG-INFO +48 -8
- {arkparser-0.5.3 → arkparser-0.6.0}/README.md +47 -7
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/__init__.py +98 -98
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/common/binary_reader.py +98 -5
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/common/normalization.py +15 -2
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/data_models.py +27 -4
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/export.py +317 -74
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/files/world_save.py +378 -43
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/game_objects/container.py +149 -20
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/game_objects/game_object.py +77 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/properties/base.py +32 -10
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/properties/compound.py +1 -1
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/properties/primitives.py +5 -5
- arkparser-0.6.0/arkparser/properties/registry.py +434 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser.egg-info/PKG-INFO +48 -8
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser.egg-info/SOURCES.txt +2 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/pyproject.toml +86 -86
- {arkparser-0.5.3 → arkparser-0.6.0}/tests/test_binary_reader.py +19 -1
- {arkparser-0.5.3 → arkparser-0.6.0}/tests/test_cryopod_export.py +1 -1
- arkparser-0.6.0/tests/test_golden_exports.py +78 -0
- arkparser-0.6.0/tests/test_lazy_properties.py +164 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/tests/test_review_fixes.py +1 -1
- arkparser-0.5.3/arkparser/properties/registry.py +0 -256
- {arkparser-0.5.3 → arkparser-0.6.0}/LICENSE +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/common/__init__.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/common/exceptions.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/common/map_config.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/common/types.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/common/version_detection.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/files/__init__.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/files/base.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/files/cloud_inventory.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/files/profile.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/files/tribe.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/game_objects/__init__.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/game_objects/location.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/properties/__init__.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/properties/byte_property.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/structs/__init__.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/structs/base.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/structs/colors.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/structs/misc.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/structs/property_list.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/structs/registry.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser/structs/vectors.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser.egg-info/dependency_links.txt +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser.egg-info/requires.txt +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/arkparser.egg-info/top_level.txt +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/setup.cfg +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/tests/test_asa_header_position.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/tests/test_asa_name_table.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/tests/test_ase_cluster_drift.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/tests/test_binary_reader_layouts.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/tests/test_cloud_export.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/tests/test_cloud_inventory.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/tests/test_current_stats.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/tests/test_data_models.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/tests/test_export.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/tests/test_game_objects.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/tests/test_profile.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/tests/test_tribe.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/tests/test_v13_property_layouts.py +0 -0
- {arkparser-0.5.3 → arkparser-0.6.0}/tests/test_version_detection.py +0 -0
- {arkparser-0.5.3 → 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.
|
|
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))
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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))
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
44
|
-
# significantly faster than memoryview slicing for the
|
|
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
|
-
#
|
|
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 [
|
|
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] =
|
|
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
|
|