arkparser 0.7.2__tar.gz → 0.7.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {arkparser-0.7.2 → arkparser-0.7.3}/PKG-INFO +1 -1
  2. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/__init__.py +1 -1
  3. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/files/base.py +13 -8
  4. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/properties/__init__.py +2 -1
  5. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/properties/byte_property.py +79 -0
  6. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/properties/registry.py +4 -1
  7. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser.egg-info/PKG-INFO +1 -1
  8. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser.egg-info/SOURCES.txt +1 -0
  9. {arkparser-0.7.2 → arkparser-0.7.3}/pyproject.toml +1 -1
  10. arkparser-0.7.3/tests/test_asa_profile_drop.py +186 -0
  11. {arkparser-0.7.2 → arkparser-0.7.3}/LICENSE +0 -0
  12. {arkparser-0.7.2 → arkparser-0.7.3}/README.md +0 -0
  13. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/common/__init__.py +0 -0
  14. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/common/binary_reader.py +0 -0
  15. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/common/exceptions.py +0 -0
  16. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/common/map_config.py +0 -0
  17. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/common/normalization.py +0 -0
  18. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/common/types.py +0 -0
  19. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/common/version_detection.py +0 -0
  20. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/data_models.py +0 -0
  21. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/export.py +0 -0
  22. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/files/__init__.py +0 -0
  23. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/files/cloud_inventory.py +0 -0
  24. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/files/profile.py +0 -0
  25. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/files/tribe.py +0 -0
  26. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/files/world_save.py +0 -0
  27. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/game_objects/__init__.py +0 -0
  28. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/game_objects/container.py +0 -0
  29. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/game_objects/game_object.py +0 -0
  30. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/game_objects/location.py +0 -0
  31. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/properties/base.py +0 -0
  32. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/properties/compound.py +0 -0
  33. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/properties/primitives.py +0 -0
  34. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/structs/__init__.py +0 -0
  35. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/structs/base.py +0 -0
  36. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/structs/colors.py +0 -0
  37. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/structs/misc.py +0 -0
  38. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/structs/property_list.py +0 -0
  39. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/structs/registry.py +0 -0
  40. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser/structs/vectors.py +0 -0
  41. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser.egg-info/dependency_links.txt +0 -0
  42. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser.egg-info/requires.txt +0 -0
  43. {arkparser-0.7.2 → arkparser-0.7.3}/arkparser.egg-info/top_level.txt +0 -0
  44. {arkparser-0.7.2 → arkparser-0.7.3}/setup.cfg +0 -0
  45. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_asa_header_position.py +0 -0
  46. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_asa_name_table.py +0 -0
  47. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_ase_cluster_drift.py +0 -0
  48. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_binary_reader.py +0 -0
  49. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_binary_reader_layouts.py +0 -0
  50. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_cloud_export.py +0 -0
  51. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_cloud_inventory.py +0 -0
  52. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_cryopod_export.py +0 -0
  53. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_current_stats.py +0 -0
  54. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_data_models.py +0 -0
  55. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_export.py +0 -0
  56. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_game_objects.py +0 -0
  57. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_golden_exports.py +0 -0
  58. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_lazy_properties.py +0 -0
  59. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_profile.py +0 -0
  60. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_review_fixes.py +0 -0
  61. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_tribe.py +0 -0
  62. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_v13_property_layouts.py +0 -0
  63. {arkparser-0.7.2 → arkparser-0.7.3}/tests/test_version_detection.py +0 -0
  64. {arkparser-0.7.2 → arkparser-0.7.3}/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.2
3
+ Version: 0.7.3
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
@@ -95,4 +95,4 @@ __all__ = [
95
95
  "ArkParseError",
96
96
  ]
97
97
 
98
- __version__ = "0.7.2"
98
+ __version__ = "0.7.3"
@@ -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 stored properties_offset for v7 points to one byte before the actual
224
- # properties start. That byte is a 0x00 terminator in most single-object
225
- # files; in multi-object files, that byte is the first byte of the next
226
- # object's GUID (non-zero). Either way, actual properties = stored + 1 for v7.
227
- # For v6, the stored offset IS the exact properties start (no +1 needed).
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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkparser
3
- Version: 0.7.2
3
+ Version: 0.7.3
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
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "arkparser"
7
- version = "0.7.2"
7
+ version = "0.7.3"
8
8
  description = "Pure Python parser for ARK: Survival Evolved and ARK: Survival Ascended save files"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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"
File without changes
File without changes
File without changes
File without changes
File without changes