arkparser 0.7.2__tar.gz → 0.7.4__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.2 → arkparser-0.7.4}/PKG-INFO +1 -1
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/__init__.py +1 -1
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/export.py +15 -5
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/files/base.py +13 -8
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/properties/__init__.py +2 -1
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/properties/byte_property.py +79 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/properties/registry.py +4 -1
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser.egg-info/PKG-INFO +1 -1
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser.egg-info/SOURCES.txt +1 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/pyproject.toml +1 -1
- arkparser-0.7.4/tests/test_asa_profile_drop.py +186 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_export.py +34 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/LICENSE +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/README.md +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/common/__init__.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/common/binary_reader.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/common/exceptions.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/common/map_config.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/common/normalization.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/common/types.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/common/version_detection.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/data_models.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/files/__init__.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/files/cloud_inventory.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/files/profile.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/files/tribe.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/files/world_save.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/game_objects/__init__.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/game_objects/container.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/game_objects/game_object.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/game_objects/location.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/properties/base.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/properties/compound.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/properties/primitives.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/structs/__init__.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/structs/base.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/structs/colors.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/structs/misc.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/structs/property_list.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/structs/registry.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser/structs/vectors.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser.egg-info/dependency_links.txt +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser.egg-info/requires.txt +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/arkparser.egg-info/top_level.txt +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/setup.cfg +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_asa_header_position.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_asa_name_table.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_ase_cluster_drift.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_binary_reader.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_binary_reader_layouts.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_cloud_export.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_cloud_inventory.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_cryopod_export.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_current_stats.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_data_models.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_game_objects.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_golden_exports.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_lazy_properties.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_profile.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_review_fixes.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_tribe.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_v13_property_layouts.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_version_detection.py +0 -0
- {arkparser-0.7.2 → arkparser-0.7.4}/tests/test_world_save.py +0 -0
|
@@ -2699,12 +2699,18 @@ def _allocate_profiles(
|
|
|
2699
2699
|
profiles: list[Profile],
|
|
2700
2700
|
file_tribe_ids: set[int],
|
|
2701
2701
|
member_index: dict[int, int],
|
|
2702
|
+
tribe_members: dict[int, set[int]],
|
|
2702
2703
|
) -> tuple[dict[int, int], dict[int, list[int]]]:
|
|
2703
2704
|
"""Allocate each profile to one tribe id (ContentContainer.cs:760-790).
|
|
2704
2705
|
|
|
2705
|
-
Priority: explicit profile tribe id when it names an existing tribe
|
|
2706
|
-
|
|
2707
|
-
the player
|
|
2706
|
+
Priority: explicit profile tribe id when it names an existing tribe AND
|
|
2707
|
+
that tribe's member roster still lists the player -> the tribe whose
|
|
2708
|
+
member list contains the player -> a solo tribe keyed on the player id.
|
|
2709
|
+
The roster check diverges from live legacy on purpose: the game leaves
|
|
2710
|
+
``TribeID`` stale in the profile of a member removed while offline, and
|
|
2711
|
+
honoring it unconditionally re-attaches ex-members (rosters then exceed
|
|
2712
|
+
the server tribe cap). The ``.arktribe`` member list is authoritative.
|
|
2713
|
+
Returns ``(player_id -> tribe_id, tribe_id -> [player_id])``.
|
|
2708
2714
|
"""
|
|
2709
2715
|
profile_tribeid: dict[int, int] = {}
|
|
2710
2716
|
players_by_tribe: dict[int, list[int]] = {}
|
|
@@ -2713,7 +2719,7 @@ def _allocate_profiles(
|
|
|
2713
2719
|
if not pid:
|
|
2714
2720
|
continue
|
|
2715
2721
|
explicit = _int(prof.raw_tribe_id)
|
|
2716
|
-
if explicit and explicit in file_tribe_ids:
|
|
2722
|
+
if explicit and explicit in file_tribe_ids and pid in tribe_members.get(explicit, ()):
|
|
2717
2723
|
target = explicit
|
|
2718
2724
|
elif pid in member_index:
|
|
2719
2725
|
target = member_index[pid]
|
|
@@ -2754,6 +2760,7 @@ def _assemble_tribes(save: t.Any) -> dict[str, t.Any]:
|
|
|
2754
2760
|
logs: dict[int, list[str]] = {}
|
|
2755
2761
|
member_index: dict[int, int] = {}
|
|
2756
2762
|
member_name: dict[int, str] = {}
|
|
2763
|
+
tribe_members: dict[int, set[int]] = {}
|
|
2757
2764
|
order: list[int] = []
|
|
2758
2765
|
for sid, sname in (
|
|
2759
2766
|
(_UNCLAIMED_TRIBE_ID, "[ASV Unclaimed]"),
|
|
@@ -2773,12 +2780,15 @@ def _assemble_tribes(save: t.Any) -> dict[str, t.Any]:
|
|
|
2773
2780
|
logs[tid] = log_entries
|
|
2774
2781
|
for pid, mname in members:
|
|
2775
2782
|
member_index.setdefault(pid, tid)
|
|
2783
|
+
tribe_members.setdefault(tid, set()).add(pid)
|
|
2776
2784
|
if mname:
|
|
2777
2785
|
member_name.setdefault(pid, mname)
|
|
2778
2786
|
file_tribe_ids = set(rich) | {_UNCLAIMED_TRIBE_ID, _ABANDONED_TRIBE_ID}
|
|
2779
2787
|
profiles = _profile_entries(save)
|
|
2780
2788
|
profile_active = _profile_actives(profiles, save)
|
|
2781
|
-
profile_tribeid, players_by_tribe = _allocate_profiles(
|
|
2789
|
+
profile_tribeid, players_by_tribe = _allocate_profiles(
|
|
2790
|
+
profiles, file_tribe_ids, member_index, tribe_members
|
|
2791
|
+
)
|
|
2782
2792
|
for prof in profiles:
|
|
2783
2793
|
pid = _int(prof.player_id)
|
|
2784
2794
|
target = profile_tribeid.get(pid)
|
|
@@ -196,6 +196,12 @@ class ArkFile(ABC):
|
|
|
196
196
|
- names_count (int32) - number of name strings to read
|
|
197
197
|
- Names array (names_count strings)
|
|
198
198
|
- Padding (20 bytes): 12 zeros + Int32 properties_offset + 4 zeros
|
|
199
|
+
|
|
200
|
+
Preconditions: reader is positioned at the first byte of this header.
|
|
201
|
+
Postconditions: reader is positioned at the first byte of the *next*
|
|
202
|
+
object's header; the returned object carries an absolute
|
|
203
|
+
``properties_offset``. Nothing between headers is consumed, so headers
|
|
204
|
+
stay walkable regardless of GUID content.
|
|
199
205
|
"""
|
|
200
206
|
if is_asa:
|
|
201
207
|
obj = GameObject(id=obj_id)
|
|
@@ -220,18 +226,17 @@ class ArkFile(ABC):
|
|
|
220
226
|
|
|
221
227
|
# Read padding/metadata after names:
|
|
222
228
|
# Layout: 12 zero bytes + int32 properties_offset + 4 zero bytes = 20 bytes
|
|
223
|
-
# The
|
|
224
|
-
#
|
|
225
|
-
#
|
|
226
|
-
#
|
|
227
|
-
#
|
|
229
|
+
# The header is fixed-width, so the next object's header begins exactly
|
|
230
|
+
# here and properties are reached by absolute seek (see
|
|
231
|
+
# GameObject.load_properties). Never sniff the upcoming byte to decide
|
|
232
|
+
# whether to advance: for every object but the last that byte is the first
|
|
233
|
+
# byte of the next object's GUID, which is 0x00 for ~1 in 256 profiles.
|
|
228
234
|
reader.skip(12) # 12 padding zeros
|
|
229
235
|
stored_props_offset = reader.read_int32() # stored absolute file offset
|
|
230
236
|
reader.skip(4) # 4 trailing zeros
|
|
231
|
-
# Consume the optional 0x00 terminator byte if present (most v7 single-obj files)
|
|
232
|
-
if version >= 7 and reader.remaining > 0 and reader.peek_bytes(1)[0] == 0x00:
|
|
233
|
-
reader.skip(1)
|
|
234
237
|
|
|
238
|
+
# v7 stores the offset of the 0x00 byte that terminates the header block;
|
|
239
|
+
# properties begin one byte later. v6 stores the exact properties start.
|
|
235
240
|
obj.properties_offset = stored_props_offset + (1 if version >= 7 else 0)
|
|
236
241
|
|
|
237
242
|
return obj
|
|
@@ -16,7 +16,7 @@ Usage:
|
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
18
|
from .base import Property, PropertyHeader, read_property_header
|
|
19
|
-
from .byte_property import ByteProperty
|
|
19
|
+
from .byte_property import ByteProperty, EnumProperty
|
|
20
20
|
from .compound import ArrayProperty, MapProperty, StructProperty
|
|
21
21
|
from .primitives import (
|
|
22
22
|
BoolProperty,
|
|
@@ -64,6 +64,7 @@ __all__ = [
|
|
|
64
64
|
"SoftObjectProperty",
|
|
65
65
|
# Byte
|
|
66
66
|
"ByteProperty",
|
|
67
|
+
"EnumProperty",
|
|
67
68
|
# Compound
|
|
68
69
|
"ArrayProperty",
|
|
69
70
|
"StructProperty",
|
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
import typing as t
|
|
14
14
|
from dataclasses import dataclass
|
|
15
15
|
|
|
16
|
+
from ..common.exceptions import ArkParseError
|
|
16
17
|
from .base import Property, PropertyHeader, read_name
|
|
17
18
|
|
|
18
19
|
if t.TYPE_CHECKING:
|
|
@@ -265,3 +266,81 @@ class ByteProperty(Property):
|
|
|
265
266
|
enum_name=enum_name,
|
|
266
267
|
_enum_value=enum_value,
|
|
267
268
|
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class EnumProperty(ByteProperty):
|
|
272
|
+
"""Unreal scoped-enum property, e.g. a Dragon Horn's ``LinkState``.
|
|
273
|
+
|
|
274
|
+
Unreal emits ByteProperty for the legacy ``TEnumAsByte`` and EnumProperty for
|
|
275
|
+
an ``enum class``. The body is an enum-form ByteProperty's plus a nested tag
|
|
276
|
+
naming the underlying storage type, so it needs its own reader.
|
|
277
|
+
|
|
278
|
+
Kept a distinct type rather than a registry alias because ``type_name`` is
|
|
279
|
+
load-bearing: GameObject forces ByteProperty groups to keep indexed dict form
|
|
280
|
+
for stat arrays, while a scoped enum is scalar and should collapse to a bare
|
|
281
|
+
value. ``__slots__ = ()`` keeps this free per instance.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
__slots__ = ()
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def type_name(self) -> str:
|
|
288
|
+
return "EnumProperty"
|
|
289
|
+
|
|
290
|
+
@classmethod
|
|
291
|
+
def read(
|
|
292
|
+
cls,
|
|
293
|
+
reader: BinaryReader,
|
|
294
|
+
header: PropertyHeader,
|
|
295
|
+
is_asa: bool = False,
|
|
296
|
+
name_table: list[str] | None = None, # noqa: ARG003 - unused; ASA enums are string-based
|
|
297
|
+
worldsave_format: bool = False,
|
|
298
|
+
) -> EnumProperty:
|
|
299
|
+
"""Read an EnumProperty body (ASA string-based files only).
|
|
300
|
+
|
|
301
|
+
Layout, verified byte-exactly against live ASA profiles::
|
|
302
|
+
|
|
303
|
+
enum_type_name (header.position bytes, null-terminated)
|
|
304
|
+
extra1 (int32, observed 1)
|
|
305
|
+
blueprint_path (string, e.g. "/Script/ShooterGame")
|
|
306
|
+
zeros (int32)
|
|
307
|
+
underlying_type (string, e.g. "ByteProperty") <- absent on ByteProperty
|
|
308
|
+
zeros2 (int32) <- absent on ByteProperty
|
|
309
|
+
data_size (int32, counts the enum_value field)
|
|
310
|
+
flag (uint8, bit 0 -> an array index follows)
|
|
311
|
+
enum_value (string, e.g. "EDragonHornLinkState::Live")
|
|
312
|
+
|
|
313
|
+
Preconditions: ``header.position`` is the enum type name's length, which
|
|
314
|
+
is always > 1 because a scoped enum always names its type.
|
|
315
|
+
Postconditions: reader sits on the next property header.
|
|
316
|
+
Failure modes: raises ArkParseError on the ASE and worldsave paths, whose
|
|
317
|
+
layouts are unverified - EnumProperty appears in no ASE fixture and in no
|
|
318
|
+
ASA worldsave name table, so guessing a layout there would risk silently
|
|
319
|
+
desyncing the stream instead of failing loudly.
|
|
320
|
+
"""
|
|
321
|
+
if worldsave_format or not is_asa:
|
|
322
|
+
raise ArkParseError(
|
|
323
|
+
f"EnumProperty {header.name!r} in an "
|
|
324
|
+
f"{'ASA worldsave' if worldsave_format else 'ASE'} stream: layout unverified. "
|
|
325
|
+
"Capture the save and confirm the byte layout before enabling this path."
|
|
326
|
+
)
|
|
327
|
+
assert header.position > 1, f"EnumProperty {header.name!r} must name its enum type"
|
|
328
|
+
|
|
329
|
+
enum_name = reader.read_bytes(header.position)[:-1].decode("latin-1")
|
|
330
|
+
_extra1 = reader.read_int32()
|
|
331
|
+
_blueprint_path = reader.read_string()
|
|
332
|
+
_zeros = reader.read_int32()
|
|
333
|
+
_underlying_type = reader.read_string()
|
|
334
|
+
_zeros2 = reader.read_int32()
|
|
335
|
+
_data_size = reader.read_int32()
|
|
336
|
+
flag = reader.read_uint8()
|
|
337
|
+
array_index = reader.read_int32() if flag & 0x01 else 0
|
|
338
|
+
enum_value = reader.read_string()
|
|
339
|
+
|
|
340
|
+
assert enum_value, f"EnumProperty {header.name!r} produced an empty value"
|
|
341
|
+
return cls(
|
|
342
|
+
name=header.name,
|
|
343
|
+
index=array_index,
|
|
344
|
+
enum_name=enum_name,
|
|
345
|
+
_enum_value=enum_value,
|
|
346
|
+
)
|
|
@@ -15,7 +15,7 @@ from ..common.exceptions import ArkParseError, UnknownPropertyError
|
|
|
15
15
|
from ..structs import registry as struct_registry
|
|
16
16
|
from . import compound as compound_properties
|
|
17
17
|
from .base import NameTable, Property, PropertyHeader, read_property_header
|
|
18
|
-
from .byte_property import ByteProperty
|
|
18
|
+
from .byte_property import ByteProperty, EnumProperty
|
|
19
19
|
from .compound import ArrayProperty, MapProperty, StructProperty
|
|
20
20
|
from .primitives import (
|
|
21
21
|
BoolProperty,
|
|
@@ -51,6 +51,7 @@ _NEEDS_NAME_TABLE: frozenset[str] = frozenset({
|
|
|
51
51
|
"StructProperty",
|
|
52
52
|
"MapProperty",
|
|
53
53
|
"ByteProperty",
|
|
54
|
+
"EnumProperty",
|
|
54
55
|
"NameProperty",
|
|
55
56
|
"ObjectProperty",
|
|
56
57
|
})
|
|
@@ -78,6 +79,8 @@ PROPERTY_REGISTRY: dict[str, type[Property]] = {
|
|
|
78
79
|
"SoftObjectProperty": SoftObjectProperty,
|
|
79
80
|
# Byte (can be raw or enum)
|
|
80
81
|
"ByteProperty": ByteProperty,
|
|
82
|
+
# Scoped enum - same wire layout as an enum-form ByteProperty
|
|
83
|
+
"EnumProperty": EnumProperty,
|
|
81
84
|
# Compound types
|
|
82
85
|
"ArrayProperty": ArrayProperty,
|
|
83
86
|
"StructProperty": StructProperty,
|
|
@@ -41,6 +41,7 @@ arkparser/structs/registry.py
|
|
|
41
41
|
arkparser/structs/vectors.py
|
|
42
42
|
tests/test_asa_header_position.py
|
|
43
43
|
tests/test_asa_name_table.py
|
|
44
|
+
tests/test_asa_profile_drop.py
|
|
44
45
|
tests/test_ase_cluster_drift.py
|
|
45
46
|
tests/test_binary_reader.py
|
|
46
47
|
tests/test_binary_reader_layouts.py
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Regression tests for the ASA profile drop (~12% of the live PvE playerbase).
|
|
2
|
+
|
|
3
|
+
Two independent defects made ``Profile.load`` raise on real ASA profiles, and
|
|
4
|
+
because every consumer skips a profile that fails to parse, the affected players
|
|
5
|
+
survived only as tribe-file stubs with a blank EOS id, invisible to anything that
|
|
6
|
+
matches on that id.
|
|
7
|
+
|
|
8
|
+
1. A terminator-skip heuristic in the ASA object header sniffed the upcoming byte
|
|
9
|
+
and consumed it when it was 0x00. For every object but the last that byte is
|
|
10
|
+
the first byte of the *next* object's GUID, so ~1 in 256 profiles drifted one
|
|
11
|
+
byte and died on a garbage string length.
|
|
12
|
+
2. ``EnumProperty`` (Unreal's scoped enum, carried by the Dragon Horn) had no
|
|
13
|
+
reader, so any player holding one lost their whole profile.
|
|
14
|
+
|
|
15
|
+
Fixtures under ``asa_pve_rag_profiles`` are staged off a live server and are
|
|
16
|
+
gitignored, so every test that needs them skips cleanly when they are absent.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import typing as t
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from uuid import UUID
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
from arkparser import Profile
|
|
28
|
+
from arkparser.common.binary_reader import BinaryReader
|
|
29
|
+
from arkparser.common.exceptions import ArkParseError
|
|
30
|
+
from arkparser.properties import ByteProperty, EnumProperty
|
|
31
|
+
from arkparser.properties.base import PropertyHeader
|
|
32
|
+
|
|
33
|
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
34
|
+
ASA_DIR = PROJECT_ROOT / "references" / "local_saves" / "survival_ascended"
|
|
35
|
+
PVE_RAG_DIR = PROJECT_ROOT / "references" / "local_saves" / "asa_pve_rag_profiles"
|
|
36
|
+
|
|
37
|
+
# Reproduces defect 1 on its own: object 1's GUID starts with 0x00, so the old
|
|
38
|
+
# heuristic ate it. Lives in the regular ASA snapshot, so this guard holds even
|
|
39
|
+
# without the staged live fixtures.
|
|
40
|
+
DRIFT_FIXTURE = ASA_DIR / "ragnarok_wp" / "00025da8dfe647d08e8cc55dc09228df.arkprofile"
|
|
41
|
+
|
|
42
|
+
# Staged live fixtures, from the handoff. The 3 EnumProperty carriers, and the
|
|
43
|
+
# control that always parsed.
|
|
44
|
+
ENUM_FIXTURES = (
|
|
45
|
+
"0002b4979928425cbfb0751cf6ba615d",
|
|
46
|
+
"000233c5118447119bd608975a16e01b",
|
|
47
|
+
"000258582e594c1f89aa8584833b5d26",
|
|
48
|
+
)
|
|
49
|
+
DRIFT_FIXTURES = (
|
|
50
|
+
"00025da8dfe647d08e8cc55dc09228df",
|
|
51
|
+
"000274f09cae4b05a8b819082498d46f",
|
|
52
|
+
"00028651190a4946aaabe01f0172faca",
|
|
53
|
+
"00029cac9b9741f489776a58d0f53e19",
|
|
54
|
+
)
|
|
55
|
+
CONTROL_FIXTURE = "0002009b5c8a46a996e2b651c290bcff"
|
|
56
|
+
ALL_FIXTURES = (*ENUM_FIXTURES, *DRIFT_FIXTURES, CONTROL_FIXTURE)
|
|
57
|
+
|
|
58
|
+
MAX_PROFILES = 100_000
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _staged(game_id: str) -> Path:
|
|
62
|
+
path = PVE_RAG_DIR / f"{game_id}.arkprofile"
|
|
63
|
+
if not path.is_file():
|
|
64
|
+
pytest.skip(f"live PvE fixture not staged: {path.name}")
|
|
65
|
+
return path
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _enum_props(profile: Profile) -> list[EnumProperty]:
|
|
69
|
+
return [p for obj in profile.objects for p in obj.properties if isinstance(p, EnumProperty)]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_next_object_guid_starting_with_zero_still_parses() -> None:
|
|
73
|
+
"""A 0x00-leading GUID on a following object must not drift the reader.
|
|
74
|
+
|
|
75
|
+
This is the whole of defect 1: the header block is fixed-width, so the byte
|
|
76
|
+
after one header is the next header's first GUID byte, never a terminator to
|
|
77
|
+
be consumed.
|
|
78
|
+
"""
|
|
79
|
+
if not DRIFT_FIXTURE.is_file():
|
|
80
|
+
pytest.skip(f"fixture not present: {DRIFT_FIXTURE}")
|
|
81
|
+
|
|
82
|
+
profile = Profile.load(DRIFT_FIXTURE)
|
|
83
|
+
|
|
84
|
+
assert len(profile.objects) == 2, "fixture should carry a player object plus a buff"
|
|
85
|
+
# GUIDs are read little-endian, so the on-disk first byte is bytes_le[0].
|
|
86
|
+
on_disk_first_byte = UUID(profile.objects[1].guid).bytes_le[0]
|
|
87
|
+
assert on_disk_first_byte == 0x00, "fixture must keep its 0x00-leading GUID to be a regression"
|
|
88
|
+
assert profile.unique_id, "player must resolve an EOS id, not a blank stub"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_every_local_asa_profile_parses() -> None:
|
|
92
|
+
"""No ASA profile in the local snapshot may fail to parse.
|
|
93
|
+
|
|
94
|
+
Guards the class of bug rather than one fixture: both defects presented as a
|
|
95
|
+
small, silent fraction of an otherwise healthy corpus.
|
|
96
|
+
"""
|
|
97
|
+
if not ASA_DIR.is_dir():
|
|
98
|
+
pytest.skip(f"ASA snapshot not present: {ASA_DIR}")
|
|
99
|
+
|
|
100
|
+
files = sorted(ASA_DIR.glob("*/*.arkprofile"))
|
|
101
|
+
if not files:
|
|
102
|
+
pytest.skip("no ASA profiles on disk")
|
|
103
|
+
assert len(files) < MAX_PROFILES, "profile count exceeded bound"
|
|
104
|
+
|
|
105
|
+
failures: list[str] = []
|
|
106
|
+
for path in files:
|
|
107
|
+
try:
|
|
108
|
+
Profile.load(path)
|
|
109
|
+
except Exception as exc: # noqa: BLE001 - collecting, so one bad file does not mask the rest
|
|
110
|
+
failures.append(f"{path.parent.name}/{path.name}: {type(exc).__name__}: {exc}")
|
|
111
|
+
|
|
112
|
+
assert not failures, f"{len(failures)} of {len(files)} ASA profiles failed to parse:\n " + "\n ".join(
|
|
113
|
+
failures[:10]
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.mark.parametrize("game_id", ALL_FIXTURES)
|
|
118
|
+
def test_reported_fixture_loads_with_identity(game_id: str) -> None:
|
|
119
|
+
"""Every fixture from the handoff loads and resolves its EOS id.
|
|
120
|
+
|
|
121
|
+
The blank id is what actually broke ``.mybase``: a stub carries no id, so the
|
|
122
|
+
player can never be matched back to their tribe.
|
|
123
|
+
"""
|
|
124
|
+
profile = Profile.load(_staged(game_id))
|
|
125
|
+
|
|
126
|
+
assert profile.unique_id == game_id, f"EOS id should round-trip to the filename stem, got {profile.unique_id!r}"
|
|
127
|
+
assert profile.level > 0, "a real profile has a level; 0 means a stub"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.parametrize("game_id", ENUM_FIXTURES)
|
|
131
|
+
def test_dragon_horn_enum_property(game_id: str) -> None:
|
|
132
|
+
"""A Dragon Horn's LinkState decodes as a scoped enum.
|
|
133
|
+
|
|
134
|
+
Wire layout differs from an enum-form ByteProperty by a nested tag naming the
|
|
135
|
+
underlying storage type, so this pins the reader, not just the registration.
|
|
136
|
+
"""
|
|
137
|
+
profile = Profile.load(_staged(game_id))
|
|
138
|
+
|
|
139
|
+
enums = _enum_props(profile)
|
|
140
|
+
assert enums, f"{game_id} should carry a Dragon Horn EnumProperty"
|
|
141
|
+
|
|
142
|
+
for prop in enums:
|
|
143
|
+
assert prop.type_name == "EnumProperty", "must not be reported as a ByteProperty"
|
|
144
|
+
assert prop.is_enum is True
|
|
145
|
+
assert prop.enum_name == "EDragonHornLinkState"
|
|
146
|
+
assert prop.enum_value is not None
|
|
147
|
+
assert prop.enum_value.startswith("EDragonHornLinkState::"), f"unexpected enum value {prop.enum_value!r}"
|
|
148
|
+
assert prop.byte_value is None, "a scoped enum carries a name, not a raw byte"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_enum_property_is_a_byte_property_subclass() -> None:
|
|
152
|
+
"""``isinstance(prop, ByteProperty)`` must keep holding for existing consumers."""
|
|
153
|
+
assert issubclass(EnumProperty, ByteProperty)
|
|
154
|
+
assert EnumProperty.__slots__ == (), (
|
|
155
|
+
"subclass must stay free per instance; ByteProperty is allocated in the millions"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_enum_property_rejects_unverified_layouts() -> None:
|
|
160
|
+
"""ASE and worldsave EnumProperty layouts are unverified, so they must fail loudly.
|
|
161
|
+
|
|
162
|
+
EnumProperty appears in no ASE fixture and in no ASA worldsave name table.
|
|
163
|
+
Guessing a layout there would desync the stream silently; raising keeps the
|
|
164
|
+
failure as loud as it was before the type existed.
|
|
165
|
+
"""
|
|
166
|
+
header = PropertyHeader(name="LinkState", type_name="EnumProperty", data_size=2, index=0, position=21)
|
|
167
|
+
reader = BinaryReader.from_bytes(b"\x00" * 64)
|
|
168
|
+
|
|
169
|
+
with pytest.raises(ArkParseError, match="layout unverified"):
|
|
170
|
+
EnumProperty.read(reader, header, is_asa=True, worldsave_format=True)
|
|
171
|
+
with pytest.raises(ArkParseError, match="layout unverified"):
|
|
172
|
+
EnumProperty.read(reader, header, is_asa=False)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_dragon_horn_stream_stays_aligned() -> None:
|
|
176
|
+
"""Properties after the EnumProperty must still decode.
|
|
177
|
+
|
|
178
|
+
A wrong body length would not raise here, it would silently shift every later
|
|
179
|
+
property, so assert on a known field that sits after the Dragon Horn.
|
|
180
|
+
"""
|
|
181
|
+
profile = Profile.load(_staged(ENUM_FIXTURES[0]))
|
|
182
|
+
|
|
183
|
+
assert _enum_props(profile), "fixture must actually carry the enum for this to prove alignment"
|
|
184
|
+
stats: dict[str, t.Any] = profile._persistent_stats
|
|
185
|
+
assert stats, "persistent character stats parse after the Dragon Horn, proving the reader stayed aligned"
|
|
186
|
+
assert profile.level == 105, "known level for this fixture"
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import datetime as dt
|
|
4
4
|
import os
|
|
5
5
|
import shutil
|
|
6
|
+
import typing as t
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
import pytest
|
|
@@ -157,6 +158,39 @@ def test_export_tribes_active_never_future() -> None:
|
|
|
157
158
|
assert rec["active"] is None
|
|
158
159
|
|
|
159
160
|
|
|
161
|
+
def test_allocate_profiles_ignores_stale_profile_tribe_id() -> None:
|
|
162
|
+
"""A profile whose TribeID names a tribe that no longer lists the player
|
|
163
|
+
in its member roster must NOT be allocated to that tribe (the game leaves
|
|
164
|
+
TribeID stale in profiles of members removed while offline). It falls
|
|
165
|
+
through to a solo tribe keyed on the player id."""
|
|
166
|
+
from arkparser.export import _allocate_profiles
|
|
167
|
+
|
|
168
|
+
current = t.cast(Profile, type("P", (), {"player_id": 1, "raw_tribe_id": 555})())
|
|
169
|
+
stale = t.cast(Profile, type("P", (), {"player_id": 987, "raw_tribe_id": 555})())
|
|
170
|
+
member_index = {1: 555, 2: 555}
|
|
171
|
+
tribe_members = {555: {1, 2}}
|
|
172
|
+
profile_tribeid, players_by_tribe = _allocate_profiles(
|
|
173
|
+
[current, stale], {555}, member_index, tribe_members
|
|
174
|
+
)
|
|
175
|
+
assert profile_tribeid[1] == 555
|
|
176
|
+
assert profile_tribeid[987] == 987
|
|
177
|
+
assert players_by_tribe[555] == [1]
|
|
178
|
+
assert players_by_tribe[987] == [987]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_allocate_profiles_member_list_still_wins_without_explicit_id() -> None:
|
|
182
|
+
"""A profile with no usable TribeID but present in a tribe's roster is
|
|
183
|
+
still allocated to that tribe via the member-list fallback."""
|
|
184
|
+
from arkparser.export import _allocate_profiles
|
|
185
|
+
|
|
186
|
+
prof = t.cast(Profile, type("P", (), {"player_id": 2, "raw_tribe_id": 0})())
|
|
187
|
+
profile_tribeid, players_by_tribe = _allocate_profiles(
|
|
188
|
+
[prof], {555}, {2: 555}, {555: {2}}
|
|
189
|
+
)
|
|
190
|
+
assert profile_tribeid[2] == 555
|
|
191
|
+
assert players_by_tribe[555] == [2]
|
|
192
|
+
|
|
193
|
+
|
|
160
194
|
def test_export_tribe_logs_uses_tribe_parser(ase_tribe_path: Path) -> None:
|
|
161
195
|
tribe = Tribe.load(ase_tribe_path)
|
|
162
196
|
exported = export_tribe_logs(type("Holder", (), {"tribes": [tribe]})())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|