arkparser 0.7.5__tar.gz → 0.9.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.7.5 → arkparser-0.9.0}/PKG-INFO +23 -1
- {arkparser-0.7.5 → arkparser-0.9.0}/README.md +22 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/__init__.py +3 -2
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/export.py +99 -35
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/files/__init__.py +2 -0
- arkparser-0.9.0/arkparser/files/embedded_store.py +261 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/files/world_save.py +32 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser.egg-info/PKG-INFO +23 -1
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser.egg-info/SOURCES.txt +2 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/pyproject.toml +1 -1
- arkparser-0.9.0/tests/test_embedded_store.py +197 -0
- arkparser-0.9.0/tests/test_export.py +367 -0
- arkparser-0.7.5/tests/test_export.py +0 -211
- {arkparser-0.7.5 → arkparser-0.9.0}/LICENSE +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/common/__init__.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/common/binary_reader.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/common/exceptions.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/common/map_config.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/common/normalization.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/common/types.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/common/version_detection.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/data_models.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/files/base.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/files/cloud_inventory.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/files/profile.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/files/tribe.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/game_objects/__init__.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/game_objects/container.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/game_objects/game_object.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/game_objects/location.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/properties/__init__.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/properties/base.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/properties/byte_property.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/properties/compound.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/properties/primitives.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/properties/registry.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/structs/__init__.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/structs/base.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/structs/colors.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/structs/misc.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/structs/property_list.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/structs/registry.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/structs/vectors.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser.egg-info/dependency_links.txt +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser.egg-info/requires.txt +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/arkparser.egg-info/top_level.txt +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/setup.cfg +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_asa_header_position.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_asa_name_table.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_asa_profile_drop.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_ase_cluster_drift.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_binary_reader.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_binary_reader_layouts.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_cloud_export.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_cloud_inventory.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_cryopod_export.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_current_stats.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_data_models.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_game_objects.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_golden_exports.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_lazy_properties.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_profile.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_review_fixes.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_tribe.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_v13_property_layouts.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_version_detection.py +0 -0
- {arkparser-0.7.5 → arkparser-0.9.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.9.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
|
|
@@ -133,6 +133,27 @@ print(f"Parse errors: {save.parse_error_count}")
|
|
|
133
133
|
print(f"Is ASA: {save.is_asa}")
|
|
134
134
|
```
|
|
135
135
|
|
|
136
|
+
### Player/tribe data packed in the save (ASA)
|
|
137
|
+
|
|
138
|
+
Some ASA servers (Nitrado observed) write no `.arkprofile` / `.arktribe` files.
|
|
139
|
+
The same files are packed inside the `.ark` instead. Read them and attach them
|
|
140
|
+
the same way you would attach sidecar files:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from arkparser import WorldSave, export_all
|
|
144
|
+
|
|
145
|
+
save = WorldSave.load("path/to/Ragnarok_WP.ark", lazy_properties=True)
|
|
146
|
+
store = save.read_embedded_store()
|
|
147
|
+
save.profiles, save.tribes = store.profiles, store.tribes
|
|
148
|
+
data = export_all(save)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`EmbeddedStore` holds `profiles`, `tribes`, `superseded_profiles` /
|
|
152
|
+
`superseded_tribes` (old copies the game leaves behind, skipped), and
|
|
153
|
+
`errors` (one line per skipped entry; a bad entry never fails the load). Saves
|
|
154
|
+
from servers that write sidecar files return an empty store. Not run by
|
|
155
|
+
`load()`, so it costs nothing unless called.
|
|
156
|
+
|
|
136
157
|
### Low-memory parsing (large saves)
|
|
137
158
|
|
|
138
159
|
By default every object's property block is parsed up front, which on a busy
|
|
@@ -577,6 +598,7 @@ Key methods:
|
|
|
577
598
|
| `get_object_by_guid(guid)` | `GameObject \| None` | Lookup by GUID (ASA) |
|
|
578
599
|
| `get_actor_location(guid)` | `LocationData \| None` | Actor location by GUID (ASA) |
|
|
579
600
|
| `iter_cryopod_creatures()` | `Iterator[(GameObject, CryopodCreature)]` | Walk filled cryopods |
|
|
601
|
+
| `read_embedded_store()` | `EmbeddedStore` | Player and tribe files packed inside an ASA save (servers that write no `.arkprofile` / `.arktribe`); see "Player/tribe data packed in the save" |
|
|
580
602
|
|
|
581
603
|
### Game Objects
|
|
582
604
|
|
|
@@ -99,6 +99,27 @@ print(f"Parse errors: {save.parse_error_count}")
|
|
|
99
99
|
print(f"Is ASA: {save.is_asa}")
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
+
### Player/tribe data packed in the save (ASA)
|
|
103
|
+
|
|
104
|
+
Some ASA servers (Nitrado observed) write no `.arkprofile` / `.arktribe` files.
|
|
105
|
+
The same files are packed inside the `.ark` instead. Read them and attach them
|
|
106
|
+
the same way you would attach sidecar files:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from arkparser import WorldSave, export_all
|
|
110
|
+
|
|
111
|
+
save = WorldSave.load("path/to/Ragnarok_WP.ark", lazy_properties=True)
|
|
112
|
+
store = save.read_embedded_store()
|
|
113
|
+
save.profiles, save.tribes = store.profiles, store.tribes
|
|
114
|
+
data = export_all(save)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`EmbeddedStore` holds `profiles`, `tribes`, `superseded_profiles` /
|
|
118
|
+
`superseded_tribes` (old copies the game leaves behind, skipped), and
|
|
119
|
+
`errors` (one line per skipped entry; a bad entry never fails the load). Saves
|
|
120
|
+
from servers that write sidecar files return an empty store. Not run by
|
|
121
|
+
`load()`, so it costs nothing unless called.
|
|
122
|
+
|
|
102
123
|
### Low-memory parsing (large saves)
|
|
103
124
|
|
|
104
125
|
By default every object's property block is parsed up front, which on a busy
|
|
@@ -543,6 +564,7 @@ Key methods:
|
|
|
543
564
|
| `get_object_by_guid(guid)` | `GameObject \| None` | Lookup by GUID (ASA) |
|
|
544
565
|
| `get_actor_location(guid)` | `LocationData \| None` | Actor location by GUID (ASA) |
|
|
545
566
|
| `iter_cryopod_creatures()` | `Iterator[(GameObject, CryopodCreature)]` | Walk filled cryopods |
|
|
567
|
+
| `read_embedded_store()` | `EmbeddedStore` | Player and tribe files packed inside an ASA save (servers that write no `.arkprofile` / `.arktribe`); see "Player/tribe data packed in the save" |
|
|
546
568
|
|
|
547
569
|
### Game Objects
|
|
548
570
|
|
|
@@ -48,7 +48,7 @@ from arkparser.export import (
|
|
|
48
48
|
export_tribes,
|
|
49
49
|
export_wild,
|
|
50
50
|
)
|
|
51
|
-
from arkparser.files import CloudInventory, Profile, Tribe, WorldSave
|
|
51
|
+
from arkparser.files import CloudInventory, EmbeddedStore, Profile, Tribe, WorldSave
|
|
52
52
|
from arkparser.game_objects import GameObject, GameObjectContainer, LocationData
|
|
53
53
|
|
|
54
54
|
# Convenience alias - users may know this as "Obelisk" from the game
|
|
@@ -61,6 +61,7 @@ __all__ = [
|
|
|
61
61
|
"CloudInventory",
|
|
62
62
|
"Obelisk",
|
|
63
63
|
"WorldSave",
|
|
64
|
+
"EmbeddedStore",
|
|
64
65
|
# Cloud-inventory data models
|
|
65
66
|
"UploadedCreature",
|
|
66
67
|
"UploadedItem",
|
|
@@ -95,4 +96,4 @@ __all__ = [
|
|
|
95
96
|
"ArkParseError",
|
|
96
97
|
]
|
|
97
98
|
|
|
98
|
-
__version__ = "0.
|
|
99
|
+
__version__ = "0.9.0"
|
|
@@ -326,6 +326,18 @@ LEGACY_TRIBE_KEYS: frozenset[str] = frozenset(
|
|
|
326
326
|
"dataFile",
|
|
327
327
|
}
|
|
328
328
|
)
|
|
329
|
+
# Tribe-record keys that must always emit, even when null/zero. The three
|
|
330
|
+
# last_member_login fields have "null means cannot tell" semantics and a real
|
|
331
|
+
# zero value (login within the final in-game second before the snapshot), so
|
|
332
|
+
# _compact must never prune them. Not part of LEGACY_TRIBE_KEYS: that set
|
|
333
|
+
# names the ASVExport parity contract and stays legacy-only.
|
|
334
|
+
TRIBE_KEEP_KEYS: frozenset[str] = LEGACY_TRIBE_KEYS | frozenset(
|
|
335
|
+
{
|
|
336
|
+
"last_member_login",
|
|
337
|
+
"last_member_login_player_id",
|
|
338
|
+
"last_member_login_game_seconds",
|
|
339
|
+
}
|
|
340
|
+
)
|
|
329
341
|
LEGACY_STRUCT_KEYS: frozenset[str] = frozenset(
|
|
330
342
|
{
|
|
331
343
|
"id",
|
|
@@ -2436,50 +2448,89 @@ def _tribe_file_date(tribe: t.Any, save: t.Any) -> dt.datetime | None:
|
|
|
2436
2448
|
return getattr(save, "file_mtime", None) if save is not None else None
|
|
2437
2449
|
|
|
2438
2450
|
|
|
2439
|
-
def
|
|
2440
|
-
file_date: t.Any,
|
|
2451
|
+
def _tribe_last_login(
|
|
2441
2452
|
member_ids: t.Iterable[int],
|
|
2442
2453
|
profile_active: dict[int, dt.datetime],
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2454
|
+
profile_login_raw: dict[int, float],
|
|
2455
|
+
game_time: float,
|
|
2456
|
+
) -> tuple[str | None, int | None, int | None]:
|
|
2457
|
+
"""Game-domain last-login rollup over a tribe's allocated members.
|
|
2458
|
+
|
|
2459
|
+
Winner is the argmax of raw ``LastLoginTime`` restricted to
|
|
2460
|
+
``login <= game_time``: profiles keep saving after the world snapshot, so
|
|
2461
|
+
logins can exceed the world's game clock; those are dropped, never
|
|
2462
|
+
clamped. Selection and filtering both run in the game-time domain, so the
|
|
2463
|
+
result never depends on any file mtime (the defect that ruins the legacy
|
|
2464
|
+
``active`` as an inactivity signal).
|
|
2465
|
+
|
|
2466
|
+
Returns ``(anchored_iso, player_id, game_seconds)``, all ``None`` when no
|
|
2467
|
+
member passes. ``anchored_iso`` alone may be ``None`` with the other two
|
|
2468
|
+
set, when the save lacks the mtime anchor. ``game_seconds`` is
|
|
2469
|
+
``int(game_time - login)``: a floored, non-negative JSON int.
|
|
2470
|
+
|
|
2471
|
+
Cluster profiles are out of scope by construction: nothing puts a
|
|
2472
|
+
foreign-map Profile into ``save.profiles``, so every candidate here lives
|
|
2473
|
+
on this map's game clock. No defensive logic needed.
|
|
2450
2474
|
"""
|
|
2451
|
-
assert isinstance(
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2475
|
+
assert isinstance(profile_login_raw, dict), "profile_login_raw must be a dict"
|
|
2476
|
+
if game_time <= 0:
|
|
2477
|
+
return None, None, None
|
|
2478
|
+
best_pid: int | None = None
|
|
2479
|
+
best_raw = float("-inf")
|
|
2455
2480
|
for i, pid in enumerate(member_ids):
|
|
2456
2481
|
assert i < _MAX_TRIBE_MEMBERS, "tribe member list exceeded bound"
|
|
2457
|
-
|
|
2458
|
-
if
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
if
|
|
2482
|
+
raw = profile_login_raw.get(_int(pid))
|
|
2483
|
+
if raw is None or raw > game_time or raw <= best_raw:
|
|
2484
|
+
continue
|
|
2485
|
+
best_pid = _int(pid)
|
|
2486
|
+
best_raw = raw
|
|
2487
|
+
if best_pid is None:
|
|
2488
|
+
return None, None, None
|
|
2489
|
+
seconds = int(game_time - best_raw)
|
|
2490
|
+
assert seconds >= 0, "game_seconds must be non-negative after the future filter"
|
|
2491
|
+
anchored = profile_active.get(best_pid)
|
|
2492
|
+
iso = anchored.isoformat() if anchored is not None else None
|
|
2493
|
+
return iso, best_pid, seconds
|
|
2494
|
+
|
|
2495
|
+
|
|
2496
|
+
def _file_date_iso(value: t.Any) -> str | None:
|
|
2497
|
+
"""ISO 8601 for a tribe file date, dropping future values (clock skew,
|
|
2498
|
+
bad rsync). Non-datetime input yields ``None``."""
|
|
2499
|
+
if not isinstance(value, dt.datetime):
|
|
2463
2500
|
return None
|
|
2464
|
-
|
|
2501
|
+
aware = value if value.tzinfo else value.astimezone()
|
|
2502
|
+
assert aware.tzinfo is not None, "file date must be timezone-aware here"
|
|
2503
|
+
now = dt.datetime.now(dt.timezone.utc)
|
|
2504
|
+
assert now.tzinfo is not None, "now must be timezone-aware"
|
|
2505
|
+
return aware.isoformat() if aware <= now else None
|
|
2465
2506
|
|
|
2466
2507
|
|
|
2467
|
-
def _profile_actives(
|
|
2468
|
-
|
|
2508
|
+
def _profile_actives(
|
|
2509
|
+
profiles: list[Profile], save: t.Any
|
|
2510
|
+
) -> tuple[dict[int, dt.datetime], dict[int, float]]:
|
|
2511
|
+
"""Per-player last-login maps for tribe activity rollups.
|
|
2469
2512
|
|
|
2470
|
-
|
|
2471
|
-
|
|
2513
|
+
Returns ``(anchored, raw)``: ``anchored`` is ``player_id -> LastLoginTime``
|
|
2514
|
+
converted through the save anchor (legacy ContentPlayer.LastActiveDateTime,
|
|
2515
|
+
inherits .ark mtime error); ``raw`` is ``player_id -> LastLoginTime`` as
|
|
2516
|
+
in-game seconds, untouched by any filesystem state. One pass over
|
|
2517
|
+
``profiles`` fills both.
|
|
2472
2518
|
"""
|
|
2473
2519
|
assert isinstance(profiles, list), "profiles must be a list"
|
|
2474
|
-
|
|
2520
|
+
anchored: dict[int, dt.datetime] = {}
|
|
2521
|
+
raw: dict[int, float] = {}
|
|
2475
2522
|
for prof in profiles:
|
|
2476
2523
|
pid = _int(prof.player_id)
|
|
2477
2524
|
if not pid:
|
|
2478
2525
|
continue
|
|
2526
|
+
login = _float(prof.last_login_time)
|
|
2527
|
+
if login > 0:
|
|
2528
|
+
raw[pid] = login
|
|
2479
2529
|
active = _approx_real_datetime(prof.last_login_time, save)
|
|
2480
2530
|
if active is not None:
|
|
2481
|
-
|
|
2482
|
-
|
|
2531
|
+
anchored[pid] = active
|
|
2532
|
+
assert len(anchored) <= len(profiles) and len(raw) <= len(profiles), "rollup maps exceed input"
|
|
2533
|
+
return anchored, raw
|
|
2483
2534
|
|
|
2484
2535
|
|
|
2485
2536
|
def _build_profile_index(save: t.Any) -> dict[int, Profile]:
|
|
@@ -2785,7 +2836,7 @@ def _assemble_tribes(save: t.Any) -> dict[str, t.Any]:
|
|
|
2785
2836
|
member_name.setdefault(pid, mname)
|
|
2786
2837
|
file_tribe_ids = set(rich) | {_UNCLAIMED_TRIBE_ID, _ABANDONED_TRIBE_ID}
|
|
2787
2838
|
profiles = _profile_entries(save)
|
|
2788
|
-
profile_active = _profile_actives(profiles, save)
|
|
2839
|
+
profile_active, profile_login_raw = _profile_actives(profiles, save)
|
|
2789
2840
|
profile_tribeid, players_by_tribe = _allocate_profiles(
|
|
2790
2841
|
profiles, file_tribe_ids, member_index, tribe_members
|
|
2791
2842
|
)
|
|
@@ -2814,6 +2865,8 @@ def _assemble_tribes(save: t.Any) -> dict[str, t.Any]:
|
|
|
2814
2865
|
"counts": counts,
|
|
2815
2866
|
"profile_index": profile_index,
|
|
2816
2867
|
"profile_active": profile_active,
|
|
2868
|
+
"profile_login_raw": profile_login_raw,
|
|
2869
|
+
"game_time": _float(getattr(save, "game_time", 0.0)),
|
|
2817
2870
|
"players_by_tribe": players_by_tribe,
|
|
2818
2871
|
"profile_tribeid": profile_tribeid,
|
|
2819
2872
|
"member_stubs": member_stubs,
|
|
@@ -2862,18 +2915,26 @@ def _tribe_record(tid: int, a: dict[str, t.Any]) -> dict[str, t.Any]:
|
|
|
2862
2915
|
|
|
2863
2916
|
File-backed tribes reuse the rich record (dataFile/owner/alliances) but
|
|
2864
2917
|
have ``players``/``members`` overridden to the allocated-player set so
|
|
2865
|
-
they match the legacy writer
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2918
|
+
they match the legacy writer. ``active`` is the anchored last member
|
|
2919
|
+
login when one exists, else the tribe file date (spec D1-b; deliberate
|
|
2920
|
+
deviation from legacy ContentTribe.LastActive, which was the file mtime
|
|
2921
|
+
on every file-backed tribe). The three ``last_member_login*`` fields are
|
|
2922
|
+
the mtime-immune signal and always emit, null meaning "cannot tell".
|
|
2869
2923
|
"""
|
|
2924
|
+
assert isinstance(a, dict), "assembled tribes must be a dict"
|
|
2870
2925
|
players = a["players_by_tribe"].get(tid, [])
|
|
2926
|
+
iso, login_pid, game_seconds = _tribe_last_login(
|
|
2927
|
+
players, a["profile_active"], a["profile_login_raw"], a["game_time"]
|
|
2928
|
+
)
|
|
2871
2929
|
base = a["rich"].get(tid)
|
|
2872
2930
|
if base is not None:
|
|
2873
2931
|
rec = dict(base)
|
|
2874
2932
|
rec["players"] = len(players)
|
|
2875
2933
|
rec["members"] = _members_for(tid, a)
|
|
2876
|
-
rec["active"] =
|
|
2934
|
+
rec["active"] = iso if iso is not None else _file_date_iso(rec.get("active"))
|
|
2935
|
+
rec["last_member_login"] = iso
|
|
2936
|
+
rec["last_member_login_player_id"] = login_pid
|
|
2937
|
+
rec["last_member_login_game_seconds"] = game_seconds
|
|
2877
2938
|
return rec
|
|
2878
2939
|
c = a["counts"].get(tid, {})
|
|
2879
2940
|
data: dict[str, t.Any] = {
|
|
@@ -2884,10 +2945,13 @@ def _tribe_record(tid: int, a: dict[str, t.Any]) -> dict[str, t.Any]:
|
|
|
2884
2945
|
"tames": c.get("tames", 0),
|
|
2885
2946
|
"uploadedTames": 0,
|
|
2886
2947
|
"structures": c.get("structures", 0),
|
|
2887
|
-
"active":
|
|
2948
|
+
"active": iso,
|
|
2888
2949
|
"dataFile": "",
|
|
2950
|
+
"last_member_login": iso,
|
|
2951
|
+
"last_member_login_player_id": login_pid,
|
|
2952
|
+
"last_member_login_game_seconds": game_seconds,
|
|
2889
2953
|
}
|
|
2890
|
-
return _compact(data,
|
|
2954
|
+
return _compact(data, TRIBE_KEEP_KEYS)
|
|
2891
2955
|
|
|
2892
2956
|
|
|
2893
2957
|
def export_tribes(save: t.Any) -> list[dict[str, t.Any]]:
|
|
@@ -12,6 +12,7 @@ All parsers support both ASE and ASA formats with automatic detection.
|
|
|
12
12
|
|
|
13
13
|
from .base import ArkFile
|
|
14
14
|
from .cloud_inventory import CloudInventory
|
|
15
|
+
from .embedded_store import EmbeddedStore
|
|
15
16
|
from .profile import Profile
|
|
16
17
|
from .tribe import Tribe
|
|
17
18
|
from .world_save import WorldSave
|
|
@@ -22,4 +23,5 @@ __all__ = [
|
|
|
22
23
|
"Tribe",
|
|
23
24
|
"CloudInventory",
|
|
24
25
|
"WorldSave",
|
|
26
|
+
"EmbeddedStore",
|
|
25
27
|
]
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Player and tribe store packed inside an ASA world save.
|
|
3
|
+
|
|
4
|
+
Some ASA servers (Nitrado observed) never write ``.arkprofile`` / ``.arktribe``
|
|
5
|
+
files. The same files are packed into the save's ``custom`` table under the
|
|
6
|
+
``GameModeCustomBytes`` key instead. Servers that do write the files leave that
|
|
7
|
+
row at a few dozen bytes.
|
|
8
|
+
|
|
9
|
+
Blob layout (int32 little-endian)::
|
|
10
|
+
|
|
11
|
+
Int32 format # 1
|
|
12
|
+
repeat (tribes, then profiles):
|
|
13
|
+
Int32 section_size # bytes of packed files that follow
|
|
14
|
+
Int32 count # size 0 + count 0 = terminator, ends the blob
|
|
15
|
+
Bytes files[section_size] # complete .arktribe / .arkprofile files
|
|
16
|
+
count x { Bytes(8) key, Int32 offset, Int32 size }
|
|
17
|
+
|
|
18
|
+
The store is append-style: each save writes a fresh copy of a changed tribe or
|
|
19
|
+
profile and zeroes the old copy's key. Only entries with a non-zero key are
|
|
20
|
+
current. Each packed file is byte-identical in format to a sidecar file, so it
|
|
21
|
+
loads with ``Tribe.load(bytes)`` / ``Profile.load(bytes)`` unchanged.
|
|
22
|
+
|
|
23
|
+
Pieces are read with ``substr()`` slices so a large store never sits in memory
|
|
24
|
+
whole: the index first, then one current file at a time.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
import sqlite3
|
|
31
|
+
import struct
|
|
32
|
+
import typing as t
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
|
|
35
|
+
from ..common.exceptions import CorruptDataError
|
|
36
|
+
from ..game_objects.game_object import MAX_OBJECT_COUNT
|
|
37
|
+
from .profile import Profile
|
|
38
|
+
from .tribe import Tribe
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
STORE_KEY = "GameModeCustomBytes"
|
|
43
|
+
STORE_FORMAT = 1
|
|
44
|
+
NO_STORE_FORMAT = 0
|
|
45
|
+
# Tribes + profiles + one spare for a future section type.
|
|
46
|
+
MAX_SECTIONS = 3
|
|
47
|
+
INDEX_ENTRY = struct.Struct("<8sii")
|
|
48
|
+
ZERO_KEY = b"\x00" * 8
|
|
49
|
+
# Where the class-name string sits in a v7 packed file: version, two header
|
|
50
|
+
# ints, object count, then the first object's 16-byte GUID.
|
|
51
|
+
CLASS_NAME_OFFSET = 32
|
|
52
|
+
MAX_CLASS_NAME = 256
|
|
53
|
+
|
|
54
|
+
_SIZE_SQL = "SELECT length(value) FROM custom WHERE key = ?"
|
|
55
|
+
_SLICE_SQL = "SELECT substr(value, ?, ?) FROM custom WHERE key = ?"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class EmbeddedStore:
|
|
60
|
+
"""Current profiles and tribes read out of a save's packed store.
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
profiles: One ``Profile`` per player (latest copy).
|
|
64
|
+
tribes: One ``Tribe`` per tribe (latest copy).
|
|
65
|
+
superseded_profiles: Old profile copies skipped (zero key).
|
|
66
|
+
superseded_tribes: Old tribe copies skipped (zero key).
|
|
67
|
+
errors: Problems found, one line each. A bad entry is skipped and the
|
|
68
|
+
rest still load; a bad layout empties the result.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
profiles: list[Profile] = field(default_factory=list)
|
|
72
|
+
tribes: list[Tribe] = field(default_factory=list)
|
|
73
|
+
superseded_profiles: int = 0
|
|
74
|
+
superseded_tribes: int = 0
|
|
75
|
+
errors: list[str] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class StoreSection:
|
|
80
|
+
"""One section's location in the blob plus its raw index entries."""
|
|
81
|
+
|
|
82
|
+
data_start: int
|
|
83
|
+
data_size: int
|
|
84
|
+
entries: list[tuple[bytes, int, int]]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class BlobSlicer:
|
|
88
|
+
"""Reads byte ranges of the store row without loading the whole value."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, conn: sqlite3.Connection, size: int) -> None:
|
|
91
|
+
assert size >= 0, "negative blob size"
|
|
92
|
+
self.conn = conn
|
|
93
|
+
self.size = size
|
|
94
|
+
|
|
95
|
+
def read(self, offset: int, length: int) -> bytes:
|
|
96
|
+
"""Return ``length`` bytes at 0-based ``offset``, or raise if out of range."""
|
|
97
|
+
if offset < 0 or length < 0 or offset + length > self.size:
|
|
98
|
+
raise CorruptDataError(
|
|
99
|
+
f"store read {offset}+{length} past blob end {self.size}"
|
|
100
|
+
)
|
|
101
|
+
if length == 0:
|
|
102
|
+
return b""
|
|
103
|
+
row = self.conn.execute(_SLICE_SQL, (offset + 1, length, STORE_KEY)).fetchone()
|
|
104
|
+
data = bytes(row[0]) if row is not None and row[0] is not None else b""
|
|
105
|
+
if len(data) != length:
|
|
106
|
+
raise CorruptDataError(f"store read {offset}+{length} returned {len(data)} bytes")
|
|
107
|
+
return data
|
|
108
|
+
|
|
109
|
+
def read_int32_pair(self, offset: int) -> tuple[int, int]:
|
|
110
|
+
"""Read two little-endian int32 values at ``offset``."""
|
|
111
|
+
first, second = struct.unpack("<2i", self.read(offset, 8))
|
|
112
|
+
return first, second
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_embedded_store(conn: sqlite3.Connection) -> EmbeddedStore:
|
|
116
|
+
"""Read the packed profile/tribe store from an open ASA save connection.
|
|
117
|
+
|
|
118
|
+
Pre: ``conn`` is an open connection to an ASA save (has a ``custom``
|
|
119
|
+
table). Post: returns the current entries; an absent or near-empty store
|
|
120
|
+
returns an empty result with no errors, and a malformed layout returns an
|
|
121
|
+
empty result with one error line. Never raises for bad store data.
|
|
122
|
+
"""
|
|
123
|
+
assert conn is not None, "connection required"
|
|
124
|
+
row = conn.execute(_SIZE_SQL, (STORE_KEY,)).fetchone()
|
|
125
|
+
size = int(row[0]) if row is not None and row[0] is not None else 0
|
|
126
|
+
store = EmbeddedStore()
|
|
127
|
+
if size <= 8:
|
|
128
|
+
return store
|
|
129
|
+
slicer = BlobSlicer(conn, size)
|
|
130
|
+
# Servers that write sidecar files leave this row all zeros (20-28 bytes
|
|
131
|
+
# observed), so format 0 means "no store", not a malformed one.
|
|
132
|
+
if struct.unpack("<i", slicer.read(0, 4))[0] == NO_STORE_FORMAT:
|
|
133
|
+
return store
|
|
134
|
+
try:
|
|
135
|
+
sections = read_sections(slicer)
|
|
136
|
+
except CorruptDataError as exc:
|
|
137
|
+
store.errors.append(f"store layout: {exc}")
|
|
138
|
+
return store
|
|
139
|
+
for number, section in enumerate(sections):
|
|
140
|
+
load_section(slicer, section, number, store)
|
|
141
|
+
assert len(store.profiles) + len(store.tribes) <= MAX_OBJECT_COUNT, "store overflow"
|
|
142
|
+
return store
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def read_sections(slicer: BlobSlicer) -> list[StoreSection]:
|
|
146
|
+
"""Walk the blob's section headers and index tables.
|
|
147
|
+
|
|
148
|
+
Pre: blob is longer than 8 bytes. Post: returns every section up to the
|
|
149
|
+
terminator, with bounds checked; raises ``CorruptDataError`` on any
|
|
150
|
+
impossible count, size, or a missing terminator.
|
|
151
|
+
"""
|
|
152
|
+
assert slicer.size > 8, "store too small to hold a section"
|
|
153
|
+
fmt = struct.unpack("<i", slicer.read(0, 4))[0]
|
|
154
|
+
if fmt != STORE_FORMAT:
|
|
155
|
+
raise CorruptDataError(f"unknown store format {fmt}")
|
|
156
|
+
sections: list[StoreSection] = []
|
|
157
|
+
pos = 4
|
|
158
|
+
for _ in range(MAX_SECTIONS + 1):
|
|
159
|
+
data_size, count = slicer.read_int32_pair(pos)
|
|
160
|
+
pos += 8
|
|
161
|
+
if data_size == 0 and count == 0:
|
|
162
|
+
assert pos <= slicer.size, "terminator past blob end"
|
|
163
|
+
return sections
|
|
164
|
+
if data_size < 0 or not 0 <= count <= MAX_OBJECT_COUNT:
|
|
165
|
+
raise CorruptDataError(f"section size {data_size} / count {count} out of range")
|
|
166
|
+
if len(sections) == MAX_SECTIONS:
|
|
167
|
+
raise CorruptDataError(f"more than {MAX_SECTIONS} sections")
|
|
168
|
+
index_start = pos + data_size
|
|
169
|
+
raw_index = slicer.read(index_start, count * INDEX_ENTRY.size)
|
|
170
|
+
entries = [tuple(e) for e in INDEX_ENTRY.iter_unpack(raw_index)]
|
|
171
|
+
sections.append(StoreSection(pos, data_size, t.cast(list[tuple[bytes, int, int]], entries)))
|
|
172
|
+
pos = index_start + count * INDEX_ENTRY.size
|
|
173
|
+
raise CorruptDataError("no section terminator")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def entry_in_bounds(section: StoreSection, offset: int, size: int) -> bool:
|
|
177
|
+
"""True when an index entry's file lies inside its section's data."""
|
|
178
|
+
assert section.data_size >= 0, "negative section size"
|
|
179
|
+
return 0 <= offset and 0 < size and offset + size <= section.data_size
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def section_kind(slicer: BlobSlicer, section: StoreSection) -> type[Profile] | type[Tribe] | None:
|
|
183
|
+
"""Classify a section by the class name in its first in-bounds file.
|
|
184
|
+
|
|
185
|
+
Pre: section bounds already validated. Post: ``Tribe``, ``Profile``, or
|
|
186
|
+
``None`` when no entry names a known class.
|
|
187
|
+
"""
|
|
188
|
+
assert section.entries is not None, "section has no index"
|
|
189
|
+
for _, offset, size in section.entries:
|
|
190
|
+
if not entry_in_bounds(section, offset, size) or size < CLASS_NAME_OFFSET + 4:
|
|
191
|
+
continue
|
|
192
|
+
head = slicer.read(section.data_start + offset, min(size, CLASS_NAME_OFFSET + 4 + MAX_CLASS_NAME))
|
|
193
|
+
length = struct.unpack_from("<i", head, CLASS_NAME_OFFSET)[0]
|
|
194
|
+
name = head[CLASS_NAME_OFFSET + 4 : CLASS_NAME_OFFSET + 4 + max(0, length)]
|
|
195
|
+
if Tribe.MAIN_CLASS_NAME.encode() in name:
|
|
196
|
+
return Tribe
|
|
197
|
+
if Profile.MAIN_CLASS_NAME.encode() in name:
|
|
198
|
+
return Profile
|
|
199
|
+
return None
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def load_section(slicer: BlobSlicer, section: StoreSection, number: int, store: EmbeddedStore) -> None:
|
|
204
|
+
"""Load the current entries of one section into ``store``.
|
|
205
|
+
|
|
206
|
+
Pre: ``section`` came from :func:`read_sections`. Post: current files are
|
|
207
|
+
parsed and de-duplicated by id (latest in index order wins); superseded
|
|
208
|
+
copies are counted; per-entry failures are recorded, never raised.
|
|
209
|
+
"""
|
|
210
|
+
assert number >= 0, "negative section number"
|
|
211
|
+
kind = section_kind(slicer, section)
|
|
212
|
+
if kind is None:
|
|
213
|
+
store.errors.append(f"section {number}: unknown content, skipped")
|
|
214
|
+
return
|
|
215
|
+
label = "tribe" if kind is Tribe else "profile"
|
|
216
|
+
latest: dict[int, Profile | Tribe] = {}
|
|
217
|
+
superseded = 0
|
|
218
|
+
for i, (key, offset, size) in enumerate(section.entries):
|
|
219
|
+
if key == ZERO_KEY:
|
|
220
|
+
superseded += 1
|
|
221
|
+
continue
|
|
222
|
+
loaded = load_entry(slicer, section, kind, offset, size)
|
|
223
|
+
if isinstance(loaded, str):
|
|
224
|
+
store.errors.append(f"{label} #{i}: {loaded}")
|
|
225
|
+
continue
|
|
226
|
+
ident = loaded.tribe_id if isinstance(loaded, Tribe) else loaded.player_id
|
|
227
|
+
latest[ident if isinstance(ident, int) else -1 - i] = loaded
|
|
228
|
+
if kind is Tribe:
|
|
229
|
+
store.tribes.extend(t.cast(list[Tribe], list(latest.values())))
|
|
230
|
+
store.superseded_tribes += superseded
|
|
231
|
+
else:
|
|
232
|
+
store.profiles.extend(t.cast(list[Profile], list(latest.values())))
|
|
233
|
+
store.superseded_profiles += superseded
|
|
234
|
+
assert superseded <= len(section.entries), "superseded count exceeds entries"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def load_entry(
|
|
238
|
+
slicer: BlobSlicer,
|
|
239
|
+
section: StoreSection,
|
|
240
|
+
kind: type[Profile] | type[Tribe],
|
|
241
|
+
offset: int,
|
|
242
|
+
size: int,
|
|
243
|
+
) -> Profile | Tribe | str:
|
|
244
|
+
"""Parse one packed file; return the object or an error string.
|
|
245
|
+
|
|
246
|
+
Pre: ``kind`` is ``Profile`` or ``Tribe``. Post: never raises for bad
|
|
247
|
+
entry data; out-of-bounds entries, unknown versions, and parse failures
|
|
248
|
+
come back as a one-line reason.
|
|
249
|
+
"""
|
|
250
|
+
assert kind in (Profile, Tribe), "unsupported entry kind"
|
|
251
|
+
if not entry_in_bounds(section, offset, size) or size < 4:
|
|
252
|
+
return f"offset {offset} size {size} outside section of {section.data_size} bytes"
|
|
253
|
+
raw = slicer.read(section.data_start + offset, size)
|
|
254
|
+
version = struct.unpack_from("<i", raw, 0)[0]
|
|
255
|
+
if version not in kind.VALID_VERSIONS:
|
|
256
|
+
return f"unsupported file version {version}"
|
|
257
|
+
try:
|
|
258
|
+
return kind.load(raw)
|
|
259
|
+
except Exception as exc: # noqa: BLE001 - one bad entry must not sink the store
|
|
260
|
+
logger.debug("Embedded %s entry failed to parse", kind.__name__, exc_info=True)
|
|
261
|
+
return f"{type(exc).__name__}: {exc}"
|
|
@@ -40,6 +40,7 @@ from ..game_objects.container import GameObjectContainer
|
|
|
40
40
|
from ..game_objects.game_object import MAX_OBJECT_COUNT, GameObject
|
|
41
41
|
from ..game_objects.location import LocationData
|
|
42
42
|
from ..properties.registry import read_properties, read_properties_partial
|
|
43
|
+
from .embedded_store import EmbeddedStore, load_embedded_store
|
|
43
44
|
|
|
44
45
|
logger = logging.getLogger(__name__)
|
|
45
46
|
|
|
@@ -171,6 +172,9 @@ class WorldSave:
|
|
|
171
172
|
# matching the legacy ContentContainer.GetApproxDateTimeOf formula:
|
|
172
173
|
# real = file_mtime + (object_time - game_time).
|
|
173
174
|
file_mtime: dt.datetime | None = None
|
|
175
|
+
# Path the save was loaded from (None when loaded from bytes). Lets
|
|
176
|
+
# read_embedded_store reopen an eagerly loaded ASA save's database.
|
|
177
|
+
source_path: Path | None = None
|
|
174
178
|
|
|
175
179
|
# ASE-specific
|
|
176
180
|
embedded_data: list[EmbeddedData] = field(default_factory=list)
|
|
@@ -324,6 +328,7 @@ class WorldSave:
|
|
|
324
328
|
reader = BinaryReader.from_file(path)
|
|
325
329
|
save = cls._parse_ase(reader, load_properties, lazy_properties)
|
|
326
330
|
save.file_mtime = mtime
|
|
331
|
+
save.source_path = path
|
|
327
332
|
return save
|
|
328
333
|
|
|
329
334
|
# ------------------------------------------------------------------
|
|
@@ -499,6 +504,33 @@ class WorldSave:
|
|
|
499
504
|
if cryo is not None:
|
|
500
505
|
yield obj, cryo
|
|
501
506
|
|
|
507
|
+
def read_embedded_store(self) -> EmbeddedStore:
|
|
508
|
+
"""Read the player/tribe files packed inside an ASA save.
|
|
509
|
+
|
|
510
|
+
Some ASA servers write no ``.arkprofile`` / ``.arktribe`` files and
|
|
511
|
+
pack them into the save instead (see ``embedded_store``). Not run by
|
|
512
|
+
:meth:`load`; callers that want the data ask for it, so load cost is
|
|
513
|
+
unchanged for everyone else.
|
|
514
|
+
|
|
515
|
+
Pre: the save was loaded from a path (ASA) or is ASE. Post: returns
|
|
516
|
+
the current profiles and tribes; ASE saves and saves without a store
|
|
517
|
+
return an empty :class:`EmbeddedStore`. Reuses the lazy connection
|
|
518
|
+
when present, otherwise opens and closes a read-only one.
|
|
519
|
+
"""
|
|
520
|
+
if not self.is_asa:
|
|
521
|
+
return EmbeddedStore()
|
|
522
|
+
if self._lazy_conn is not None:
|
|
523
|
+
return load_embedded_store(self._lazy_conn)
|
|
524
|
+
assert self.source_path is not None, "ASA saves always load from a path"
|
|
525
|
+
uri = self.source_path.resolve().as_uri() + "?mode=ro"
|
|
526
|
+
conn = sqlite3.connect(uri, uri=True)
|
|
527
|
+
try:
|
|
528
|
+
store = load_embedded_store(conn)
|
|
529
|
+
finally:
|
|
530
|
+
conn.close()
|
|
531
|
+
assert isinstance(store, EmbeddedStore), "store loader returned wrong type"
|
|
532
|
+
return store
|
|
533
|
+
|
|
502
534
|
# ------------------------------------------------------------------
|
|
503
535
|
# Properties
|
|
504
536
|
# ------------------------------------------------------------------
|