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.
Files changed (67) hide show
  1. {arkparser-0.7.5 → arkparser-0.9.0}/PKG-INFO +23 -1
  2. {arkparser-0.7.5 → arkparser-0.9.0}/README.md +22 -0
  3. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/__init__.py +3 -2
  4. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/export.py +99 -35
  5. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/files/__init__.py +2 -0
  6. arkparser-0.9.0/arkparser/files/embedded_store.py +261 -0
  7. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/files/world_save.py +32 -0
  8. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser.egg-info/PKG-INFO +23 -1
  9. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser.egg-info/SOURCES.txt +2 -0
  10. {arkparser-0.7.5 → arkparser-0.9.0}/pyproject.toml +1 -1
  11. arkparser-0.9.0/tests/test_embedded_store.py +197 -0
  12. arkparser-0.9.0/tests/test_export.py +367 -0
  13. arkparser-0.7.5/tests/test_export.py +0 -211
  14. {arkparser-0.7.5 → arkparser-0.9.0}/LICENSE +0 -0
  15. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/common/__init__.py +0 -0
  16. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/common/binary_reader.py +0 -0
  17. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/common/exceptions.py +0 -0
  18. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/common/map_config.py +0 -0
  19. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/common/normalization.py +0 -0
  20. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/common/types.py +0 -0
  21. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/common/version_detection.py +0 -0
  22. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/data_models.py +0 -0
  23. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/files/base.py +0 -0
  24. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/files/cloud_inventory.py +0 -0
  25. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/files/profile.py +0 -0
  26. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/files/tribe.py +0 -0
  27. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/game_objects/__init__.py +0 -0
  28. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/game_objects/container.py +0 -0
  29. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/game_objects/game_object.py +0 -0
  30. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/game_objects/location.py +0 -0
  31. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/properties/__init__.py +0 -0
  32. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/properties/base.py +0 -0
  33. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/properties/byte_property.py +0 -0
  34. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/properties/compound.py +0 -0
  35. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/properties/primitives.py +0 -0
  36. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/properties/registry.py +0 -0
  37. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/structs/__init__.py +0 -0
  38. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/structs/base.py +0 -0
  39. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/structs/colors.py +0 -0
  40. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/structs/misc.py +0 -0
  41. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/structs/property_list.py +0 -0
  42. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/structs/registry.py +0 -0
  43. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser/structs/vectors.py +0 -0
  44. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser.egg-info/dependency_links.txt +0 -0
  45. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser.egg-info/requires.txt +0 -0
  46. {arkparser-0.7.5 → arkparser-0.9.0}/arkparser.egg-info/top_level.txt +0 -0
  47. {arkparser-0.7.5 → arkparser-0.9.0}/setup.cfg +0 -0
  48. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_asa_header_position.py +0 -0
  49. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_asa_name_table.py +0 -0
  50. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_asa_profile_drop.py +0 -0
  51. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_ase_cluster_drift.py +0 -0
  52. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_binary_reader.py +0 -0
  53. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_binary_reader_layouts.py +0 -0
  54. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_cloud_export.py +0 -0
  55. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_cloud_inventory.py +0 -0
  56. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_cryopod_export.py +0 -0
  57. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_current_stats.py +0 -0
  58. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_data_models.py +0 -0
  59. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_game_objects.py +0 -0
  60. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_golden_exports.py +0 -0
  61. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_lazy_properties.py +0 -0
  62. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_profile.py +0 -0
  63. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_review_fixes.py +0 -0
  64. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_tribe.py +0 -0
  65. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_v13_property_layouts.py +0 -0
  66. {arkparser-0.7.5 → arkparser-0.9.0}/tests/test_version_detection.py +0 -0
  67. {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.7.5
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.7.5"
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 _tribe_active_iso(
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
- ) -> str | None:
2444
- """Legacy ContentTribe.LastActive (ContentTribe.cs:35-49): the max of the
2445
- tribe file date and the members' last-active datetimes, discarding values
2446
- in the future. Tribe-log "Day N" stamps are game-calendar time, not real
2447
- seconds, so they cannot be converted with the save anchor and are unused.
2448
-
2449
- Returns ``None`` when no past candidate exists.
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(profile_active, dict), "profile_active must be a dict"
2452
- candidates: list[dt.datetime] = []
2453
- if isinstance(file_date, dt.datetime):
2454
- candidates.append(file_date if file_date.tzinfo else file_date.astimezone())
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
- active = profile_active.get(_int(pid))
2458
- if active is not None:
2459
- candidates.append(active if active.tzinfo else active.astimezone())
2460
- now = dt.datetime.now(dt.timezone.utc)
2461
- past = [c for c in candidates if c <= now]
2462
- if not past:
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
- return max(past).isoformat()
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(profiles: list[Profile], save: t.Any) -> dict[int, dt.datetime]:
2468
- """``player_id -> last-active datetime`` for tribe activity rollups.
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
- Mirrors legacy ContentPlayer.LastActiveDateTime: the profile's
2471
- LastLoginTime converted through the save anchor.
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
- out: dict[int, dt.datetime] = {}
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
- out[pid] = active
2482
- return out
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 (which counts allocated profiles, not the raw
2866
- member list). Synthesized stubs carry id + name + world-derived counts.
2867
- ``active`` finalizes here for both shapes: max of the tribe file date and
2868
- the allocated members' last-active times (legacy ContentTribe.LastActive).
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"] = _tribe_active_iso(rec.get("active"), players, a["profile_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": _tribe_active_iso(None, players, a["profile_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, LEGACY_TRIBE_KEYS)
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
  # ------------------------------------------------------------------