bdo-toolkit 1.0.0__py3-none-any.whl

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 (48) hide show
  1. bdo_toolkit/__init__.py +87 -0
  2. bdo_toolkit/_async_sessions.py +651 -0
  3. bdo_toolkit/_capture_backend.py +194 -0
  4. bdo_toolkit/_capture_options.py +68 -0
  5. bdo_toolkit/_capture_runtime.py +626 -0
  6. bdo_toolkit/_deposit_origin.py +1599 -0
  7. bdo_toolkit/_engine.py +327 -0
  8. bdo_toolkit/_framing.py +904 -0
  9. bdo_toolkit/_profile_runtime.py +157 -0
  10. bdo_toolkit/_protocol.py +386 -0
  11. bdo_toolkit/_reassembly.py +654 -0
  12. bdo_toolkit/_specs.py +285 -0
  13. bdo_toolkit/_storage_destination_validation.py +167 -0
  14. bdo_toolkit/_storage_hydration.py +241 -0
  15. bdo_toolkit/_version.py +3 -0
  16. bdo_toolkit/calibration.py +3223 -0
  17. bdo_toolkit/capture.py +1713 -0
  18. bdo_toolkit/character_state.py +3506 -0
  19. bdo_toolkit/cli.py +948 -0
  20. bdo_toolkit/diagnostics.py +51 -0
  21. bdo_toolkit/events.py +214 -0
  22. bdo_toolkit/filters.py +105 -0
  23. bdo_toolkit/item_state.py +48 -0
  24. bdo_toolkit/origin_learning.py +779 -0
  25. bdo_toolkit/profiles.py +370 -0
  26. bdo_toolkit/py.typed +1 -0
  27. bdo_toolkit/remote_profiles.py +358 -0
  28. bdo_toolkit/solare/__init__.py +50 -0
  29. bdo_toolkit/solare/_constants.py +94 -0
  30. bdo_toolkit/solare/_detail_learning.py +1437 -0
  31. bdo_toolkit/solare/_details.py +796 -0
  32. bdo_toolkit/solare/_discovery.py +1212 -0
  33. bdo_toolkit/solare/_live_tracker.py +472 -0
  34. bdo_toolkit/solare/_replay_capture.py +182 -0
  35. bdo_toolkit/solare/_result.py +441 -0
  36. bdo_toolkit/solare/_scanner.py +203 -0
  37. bdo_toolkit/solare/_validation.py +11 -0
  38. bdo_toolkit/solare/async_session.py +444 -0
  39. bdo_toolkit/solare/models.py +806 -0
  40. bdo_toolkit/solare/replay.py +62 -0
  41. bdo_toolkit/solare/session.py +1051 -0
  42. bdo_toolkit/writers.py +30 -0
  43. bdo_toolkit-1.0.0.dist-info/METADATA +143 -0
  44. bdo_toolkit-1.0.0.dist-info/RECORD +48 -0
  45. bdo_toolkit-1.0.0.dist-info/WHEEL +5 -0
  46. bdo_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
  47. bdo_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
  48. bdo_toolkit-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,157 @@
1
+ """Validate every profile shape consumed by the runtime decoder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Optional
8
+
9
+ from ._deposit_origin import DecrementSpec
10
+ from ._protocol import MAX_TARGET_MESSAGE_LENGTH
11
+ from ._specs import LoadedSpecProfile, _parse_opcode, event_specs_from_profile
12
+ from .profiles import (
13
+ OPCODE_PROFILE_SCHEMA_VERSION,
14
+ OpcodeProfile,
15
+ OriginCompanionFamily,
16
+ ProfileError,
17
+ )
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class RuntimeProfileValidation:
22
+ """Validated event layouts and non-event companion layouts."""
23
+
24
+ loaded_specs: LoadedSpecProfile
25
+ decrement_specs: tuple[DecrementSpec, ...]
26
+
27
+
28
+ def validate_runtime_profile(profile: OpcodeProfile) -> RuntimeProfileValidation:
29
+ """Validate one immutable profile as complete runtime authority."""
30
+
31
+ if (
32
+ isinstance(profile.version, bool)
33
+ or not isinstance(profile.version, int)
34
+ or profile.version != OPCODE_PROFILE_SCHEMA_VERSION
35
+ ):
36
+ raise ProfileError(
37
+ f"Opcode profile version in {profile.path} must be "
38
+ f"{OPCODE_PROFILE_SCHEMA_VERSION}"
39
+ )
40
+ if profile.active is not True:
41
+ raise ProfileError(f"Opcode profile is inactive: {profile.path}")
42
+
43
+ loaded_specs = event_specs_from_profile(profile)
44
+ decrement_specs = tuple(
45
+ _decrement_spec(profile, index, entry)
46
+ for index, entry in enumerate(
47
+ profile.specs.get("SOURCE_STACK_DECREMENT", ())
48
+ )
49
+ )
50
+ for index, family in enumerate(profile.origin_companion_families):
51
+ _validate_origin_companion_family(profile, index, family)
52
+ return RuntimeProfileValidation(
53
+ loaded_specs=loaded_specs,
54
+ decrement_specs=decrement_specs,
55
+ )
56
+
57
+
58
+ def _decrement_spec(
59
+ profile: OpcodeProfile,
60
+ index: int,
61
+ entry: Mapping[str, object],
62
+ ) -> DecrementSpec:
63
+ location = f"SOURCE_STACK_DECREMENT[{index}] in {profile.path}"
64
+ opcode = _parse_opcode(entry.get("opcode"))
65
+ if opcode is None:
66
+ raise ProfileError(f"Invalid {location}: opcode must be a uint16")
67
+ length = _required_int(entry.get("length"), "length", location)
68
+ if not 5 <= length <= MAX_TARGET_MESSAGE_LENGTH:
69
+ raise ProfileError(
70
+ f"Invalid {location}: length must be from 5 to "
71
+ f"{MAX_TARGET_MESSAGE_LENGTH}"
72
+ )
73
+ quantity_offset = _required_int(
74
+ entry.get("quantity_removed_offset"),
75
+ "quantity_removed_offset",
76
+ location,
77
+ )
78
+ source_instance_offset = _optional_int(
79
+ entry.get("source_instance_offset"),
80
+ "source_instance_offset",
81
+ location,
82
+ )
83
+ repeat_stride = _optional_int(
84
+ entry.get("repeat_stride"),
85
+ "repeat_stride",
86
+ location,
87
+ )
88
+ try:
89
+ return DecrementSpec(
90
+ opcode=opcode,
91
+ min_message_length=length,
92
+ quantity_offset=quantity_offset,
93
+ source_instance_offset=source_instance_offset,
94
+ repeat_stride=repeat_stride,
95
+ )
96
+ except ValueError as exc:
97
+ raise ProfileError(f"Invalid {location}: {exc}") from exc
98
+
99
+
100
+ def _validate_origin_companion_family(
101
+ profile: OpcodeProfile,
102
+ index: int,
103
+ family: OriginCompanionFamily,
104
+ ) -> None:
105
+ location = f"origin_companion_families[{index}] in {profile.path}"
106
+ if not isinstance(family, OriginCompanionFamily):
107
+ raise ProfileError(f"Invalid {location}: expected OriginCompanionFamily")
108
+ if len(family.companion_opcodes) != 2:
109
+ raise ProfileError(
110
+ f"Invalid {location}: exactly two companion opcodes required"
111
+ )
112
+ opcodes = (family.delta_opcode, *family.companion_opcodes)
113
+ if any(
114
+ isinstance(opcode, bool)
115
+ or not isinstance(opcode, int)
116
+ or not 0 <= opcode <= 0xFFFF
117
+ for opcode in opcodes
118
+ ):
119
+ raise ProfileError(f"Invalid {location}: opcodes must be uint16 values")
120
+ if len(family.companion_lengths) != 2 or any(
121
+ isinstance(length, bool)
122
+ or not isinstance(length, int)
123
+ or not 5 <= length <= 0xFFFF
124
+ for length in family.companion_lengths
125
+ ):
126
+ raise ProfileError(
127
+ f"Invalid {location}: exactly two companion lengths from 5 to 65535 required"
128
+ )
129
+ if family.detection != "shared-token-chain-v1":
130
+ raise ProfileError(
131
+ f"Invalid {location}: unsupported companion detection method"
132
+ )
133
+ if (
134
+ isinstance(family.observations, bool)
135
+ or not isinstance(family.observations, int)
136
+ or family.observations <= 0
137
+ ):
138
+ raise ProfileError(f"Invalid {location}: observations must be positive")
139
+ if family.promoted_at is not None and not isinstance(family.promoted_at, str):
140
+ raise ProfileError(f"Invalid {location}: promoted_at must be a string or null")
141
+
142
+
143
+ def _required_int(value: object, field: str, location: str) -> int:
144
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
145
+ raise ProfileError(
146
+ f"Invalid {location}: {field} must be a non-negative integer"
147
+ )
148
+ return value
149
+
150
+
151
+ def _optional_int(value: object, field: str, location: str) -> Optional[int]:
152
+ if value is None:
153
+ return None
154
+ return _required_int(value, field, location)
155
+
156
+
157
+ __all__ = ["RuntimeProfileValidation", "validate_runtime_profile"]
@@ -0,0 +1,386 @@
1
+ """Core BDO wire-protocol model: constants, event specs, and decoded records.
2
+
3
+ The BDO application-message header observed in captures is:
4
+
5
+ uint16_le message_length
6
+ uint8 flags/unknown
7
+ uint16_le opcode
8
+
9
+ Offsets and labels are provisional observations and may change after a game
10
+ patch. Everything here is read-only protocol knowledge; no packets are ever
11
+ sent or modified.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Callable, Optional
18
+
19
+ DEFAULT_SERVER_PORTS = (8884, 8885, 8889)
20
+ # The frame header carries a uint16 length. The older 4096-byte ceiling
21
+ # silently rejected otherwise-valid storage batches at 18 records
22
+ # (35 + 18 * 226 = 4103 bytes), so accept the full wire range and rely on the
23
+ # structural guards in the scanners to reject false candidates.
24
+ MAX_TARGET_MESSAGE_LENGTH = 0xFFFF
25
+ BASE_ITEM_ID_MASK = 0x00FFFFFF
26
+ MAX_ENHANCEMENT_LEVEL = 20
27
+ MAX_PLAUSIBLE_ITEM_ID = (MAX_ENHANCEMENT_LEVEL << 24) | BASE_ITEM_ID_MASK
28
+ MAX_PENDING_SEGMENTS = 128
29
+ GAP_RESET_SECONDS = 1.5
30
+ DEDUP_HISTORY_LIMIT = 4096
31
+ TCP_SEQUENCE_MODULUS = 1 << 32
32
+ TCP_SEQUENCE_HALF_RANGE = TCP_SEQUENCE_MODULUS >> 1
33
+ LOOT_PREVIEW_SENTINEL_INSTANCE = b"\xff" * 8
34
+
35
+ CHARACTER_LOAD_CONTEXT = b"\x00" * 4
36
+ # Legacy storage-delta signatures that calibration has observed at varying
37
+ # pre-record offsets. The complete location registry below must not be used as
38
+ # a free-form signature list: small town IDs can coincide with ordinary uint32
39
+ # fields elsewhere in a message.
40
+ STORAGE_DELTA_CONTEXTS = tuple(
41
+ storage_id.to_bytes(4, "little") for storage_id in (0x0005, 0x0020, 0x058C)
42
+ )
43
+
44
+ ENHANCEMENT_LABELS: dict[int, str] = {
45
+ 1: "PRI",
46
+ 2: "DUO",
47
+ 3: "TRI",
48
+ 4: "TET",
49
+ 5: "PEN",
50
+ 6: "HEX",
51
+ 7: "SEP",
52
+ 8: "OCT",
53
+ 9: "NOV",
54
+ 10: "DEC",
55
+ 16: "PRI",
56
+ 17: "DUO",
57
+ 18: "TRI",
58
+ 19: "TET",
59
+ 20: "PEN",
60
+ }
61
+
62
+ SOURCE_CONTEXT_LABELS: dict[bytes, str] = {
63
+ CHARACTER_LOAD_CONTEXT: "Character Load",
64
+ bytes.fromhex("0471ee0e"): "Gathering",
65
+ bytes.fromhex("85fa5745"): "Mob Drop",
66
+ bytes.fromhex("d0f205a3"): "Storage",
67
+ STORAGE_DELTA_CONTEXTS[0]: "Velia",
68
+ STORAGE_DELTA_CONTEXTS[1]: "Heidel",
69
+ STORAGE_DELTA_CONTEXTS[2]: "Yukjo Street",
70
+ bytes.fromhex("43ce1321"): "Central Market",
71
+ bytes.fromhex("89fa09af"): "Black Spirit Safe",
72
+ bytes.fromhex("35bd5d70"): "Challenges",
73
+ bytes.fromhex("ef6b9b51"): "In-Game Mail",
74
+ bytes.fromhex("8f92e3de"): "Box/Bundle",
75
+ bytes.fromhex("8b7c3a13"): "NPC Exchange",
76
+ bytes.fromhex("721f296d"): "NPC Shop",
77
+ bytes.fromhex("56687f25"): "Choose Your Rewards Box",
78
+ bytes.fromhex("52e89da8"): "NPC Sell",
79
+ bytes.fromhex("60260000"): "Event Adventures",
80
+ bytes.fromhex("3e010000"): "Remote Inventory",
81
+ }
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class StorageLocation:
86
+ """Best-known name for a numeric storage destination key.
87
+
88
+ ``confidence`` distinguishes names directly proven by a unique occupied
89
+ record count or controlled storage action from names inferred within
90
+ equal-count groups, and names of empty destinations that are still
91
+ provisional. The key itself is the durable protocol value; applications
92
+ should not treat a provisional name as an identity.
93
+ """
94
+
95
+ name: str
96
+ confidence: str
97
+
98
+
99
+ # Numeric little-endian town/storage keys observed in the 2026-07-17 initial
100
+ # game-load storage snapshot. Some older profiles placed the field at another
101
+ # offset, but captures confirm that it was still the destination key.
102
+ STORAGE_LOCATIONS: dict[int, StorageLocation] = {
103
+ # Direct observations from unique occupied-record counts or controlled
104
+ # storage actions.
105
+ 0x0005: StorageLocation("Velia", "observed"),
106
+ 0x0020: StorageLocation("Heidel", "observed"),
107
+ 0x0034: StorageLocation("Glish", "observed"),
108
+ 0x004D: StorageLocation("Calpheon City", "observed"),
109
+ 0x0058: StorageLocation("Olvia", "observed"),
110
+ 0x0078: StorageLocation("Port Epheria", "observed"),
111
+ 0x00CA: StorageLocation("Altinova", "observed"),
112
+ 0x00DA: StorageLocation("Asparkan", "observed"),
113
+ 0x00DD: StorageLocation("Tarif", "observed"),
114
+ 0x025D: StorageLocation("Sand Grain Bazaar", "observed"),
115
+ 0x02B5: StorageLocation("Arehaza", "observed"),
116
+ 0x02C2: StorageLocation("Old Wisdom Tree", "observed"),
117
+ 0x0369: StorageLocation("Duvencrune", "observed"),
118
+ 0x03BB: StorageLocation("O'draxxia", "observed"),
119
+ 0x03E8: StorageLocation("Oquilla's Eye", "observed"),
120
+ 0x0464: StorageLocation("Eilton", "observed"),
121
+ 0x04C3: StorageLocation("Nampo's Moodle Village", "observed"),
122
+ 0x04DE: StorageLocation("Nopsae's Byeot County", "observed"),
123
+ 0x055F: StorageLocation("Muzgar", "observed"),
124
+ 0x0566: StorageLocation("Velandir", "observed"),
125
+ 0x058C: StorageLocation("Yukjo Street", "observed"),
126
+ # Confirmed by controlled manual deposits on 2026-07-17. These wrappers
127
+ # carried the expected numeric destination key and normalized town name.
128
+ 0x0590: StorageLocation("Godu Village", "observed"),
129
+ 0x05A4: StorageLocation("Bukpo", "observed"),
130
+ # Confirmed by the operator-labeled 2026-08-13 Angavu Outpost character
131
+ # hydration capture. The newly observed destination key carried the one
132
+ # occupied stack deliberately left in that storage.
133
+ 0x06C5: StorageLocation("Angavu Outpost", "observed"),
134
+ # The capture fixes each key to an equal-count group; the exact name in
135
+ # each group follows the established region-key ordering and is inferred.
136
+ 0x006B: StorageLocation("Keplan", "inferred"),
137
+ 0x007E: StorageLocation("Trent", "inferred"),
138
+ 0x00B6: StorageLocation("Iliya Island", "inferred"),
139
+ 0x00E5: StorageLocation("Shakatu", "inferred"),
140
+ 0x0259: StorageLocation("Valencia City", "inferred"),
141
+ 0x026B: StorageLocation("Ancado Inner Harbor", "inferred"),
142
+ 0x02DF: StorageLocation("Grana", "inferred"),
143
+ 0x04BA: StorageLocation("Dalbeol Village", "inferred"),
144
+ # Empty messages carry no item content with which to prove the name.
145
+ 0x02B6: StorageLocation("Muiquun", "probable"),
146
+ 0x0611: StorageLocation("Hakinza Sanctuary", "probable"),
147
+ }
148
+
149
+
150
+ def storage_location(storage_id: Optional[int]) -> Optional[StorageLocation]:
151
+ """Return the best-known location metadata for a storage key."""
152
+
153
+ if storage_id is None:
154
+ return None
155
+ return STORAGE_LOCATIONS.get(storage_id)
156
+
157
+
158
+ def storage_destination_candidates(
159
+ message: bytes,
160
+ *,
161
+ before_offset: int,
162
+ ) -> tuple[tuple[int, int], ...]:
163
+ """Return registered four-byte destination keys in a wrapper prefix.
164
+
165
+ The destination field has moved to unrelated absolute and item-relative
166
+ positions across every observed storage generation. Its stable wire
167
+ invariant is the little-endian numeric key itself, so callers discover
168
+ candidates across the validated prefix and resolve ambiguity with either
169
+ calibration evidence or cross-frame field consistency.
170
+
171
+ This helper deliberately does not choose a candidate. A true ``0x05xx``
172
+ destination can overlap a false Velia key one byte later, so selecting the
173
+ first, last, or closest match would silently assign the wrong town.
174
+ """
175
+
176
+ if before_offset <= 5:
177
+ return ()
178
+ search_end = min(before_offset, len(message))
179
+ return tuple(
180
+ (offset, storage_id)
181
+ for offset in range(5, max(5, search_end - 3))
182
+ if (
183
+ storage_id := int.from_bytes(
184
+ message[offset : offset + 4],
185
+ "little",
186
+ )
187
+ )
188
+ in STORAGE_LOCATIONS
189
+ )
190
+
191
+
192
+ @dataclass(frozen=True)
193
+ class EventSpec:
194
+ label: str
195
+ opcode: int
196
+ item_offset: int
197
+ quantity_offset: int
198
+ min_message_length: int
199
+ inventory_slot_offset: Optional[int] = None
200
+ source_context_offset: Optional[int] = None
201
+ source_context_length: int = 4
202
+ record_count_offset: Optional[int] = None
203
+ item_instance_offset: Optional[int] = None
204
+ storage_instance_offset: Optional[int] = None
205
+ repeat_stride: Optional[int] = None
206
+ single_record_message_length: Optional[int] = None
207
+ default_context: Optional[str] = None
208
+
209
+ @property
210
+ def signature(self) -> bytes:
211
+ # Header bytes at message offsets 2..4: unknown/flags byte + opcode LE.
212
+ return b"\x00" + self.opcode.to_bytes(2, "little")
213
+
214
+ def __post_init__(self) -> None:
215
+ if not self.label:
216
+ raise ValueError("event spec label must not be empty")
217
+ if (
218
+ isinstance(self.opcode, bool)
219
+ or not isinstance(self.opcode, int)
220
+ or not 0 <= self.opcode <= 0xFFFF
221
+ ):
222
+ raise ValueError(f"{self.label} opcode must be a uint16")
223
+ if self.item_offset < 0:
224
+ raise ValueError(f"{self.label} item_offset must be >= 0")
225
+ if self.quantity_offset < 0:
226
+ raise ValueError(f"{self.label} quantity_offset must be >= 0")
227
+ if not 5 <= self.min_message_length <= MAX_TARGET_MESSAGE_LENGTH:
228
+ raise ValueError(
229
+ f"{self.label} min_message_length must be between 5 and "
230
+ f"{MAX_TARGET_MESSAGE_LENGTH}"
231
+ )
232
+ if self.inventory_slot_offset is not None and self.inventory_slot_offset < 0:
233
+ raise ValueError(f"{self.label} inventory_slot_offset must be >= 0")
234
+ if self.source_context_offset is not None:
235
+ if self.source_context_offset < 0:
236
+ raise ValueError(f"{self.label} source_context_offset must be >= 0")
237
+ if self.source_context_length <= 0:
238
+ raise ValueError(f"{self.label} source_context_length must be > 0")
239
+ if self.record_count_offset is not None and self.record_count_offset < 0:
240
+ raise ValueError(f"{self.label} record_count_offset must be >= 0")
241
+ if self.label == "INVENTORY_TO_STORAGE":
242
+ if (
243
+ self.source_context_offset is not None
244
+ and self.source_context_offset + self.source_context_length
245
+ > self.item_offset
246
+ ):
247
+ raise ValueError(
248
+ f"{self.label} source_context_offset must end before item_offset"
249
+ )
250
+ if (
251
+ self.record_count_offset is not None
252
+ and self.record_count_offset + 2 > self.item_offset
253
+ ):
254
+ raise ValueError(
255
+ f"{self.label} record_count_offset must end before item_offset"
256
+ )
257
+ if self.storage_instance_offset is not None and self.storage_instance_offset < 0:
258
+ raise ValueError(f"{self.label} storage_instance_offset must be >= 0")
259
+ if self.item_instance_offset is not None and self.item_instance_offset < 0:
260
+ raise ValueError(f"{self.label} item_instance_offset must be >= 0")
261
+ if self.repeat_stride is not None and self.repeat_stride <= 0:
262
+ raise ValueError(f"{self.label} repeat_stride must be > 0")
263
+ if self.single_record_message_length is not None:
264
+ if (
265
+ isinstance(self.single_record_message_length, bool)
266
+ or not isinstance(self.single_record_message_length, int)
267
+ ):
268
+ raise ValueError(
269
+ f"{self.label} single_record_message_length must be an integer"
270
+ )
271
+ if not (
272
+ self.min_message_length
273
+ <= self.single_record_message_length
274
+ <= MAX_TARGET_MESSAGE_LENGTH
275
+ ):
276
+ raise ValueError(
277
+ f"{self.label} single_record_message_length must be between "
278
+ "min_message_length and 65535"
279
+ )
280
+
281
+
282
+ @dataclass(frozen=True)
283
+ class FlowKey:
284
+ source_ip: str
285
+ source_port: int
286
+ destination_ip: str
287
+ destination_port: int
288
+
289
+ def __str__(self) -> str:
290
+ return (
291
+ f"{self.source_ip}:{self.source_port} -> "
292
+ f"{self.destination_ip}:{self.destination_port}"
293
+ )
294
+
295
+
296
+ @dataclass(frozen=True)
297
+ class PacketContext:
298
+ timestamp: float
299
+ flow: FlowKey
300
+ stream_start: Optional[int] = None
301
+ # A FlowKey identifies a TCP four-tuple, not one connection lifetime.
302
+ # FlowManager assigns a new generation whenever that tuple is opened
303
+ # again so consumers that retain frames cannot merge independent streams.
304
+ flow_generation: int = 0
305
+
306
+
307
+ @dataclass(frozen=True)
308
+ class LootEvent:
309
+ """One decoded item record, pre-normalization."""
310
+
311
+ label: str
312
+ opcode: int
313
+ item_id: int
314
+ quantity: int
315
+ inventory_slot: Optional[int]
316
+ source_context_candidate: Optional[bytes]
317
+ item_instance: Optional[bytes]
318
+ storage_instance: Optional[bytes]
319
+ message_length: int
320
+ default_context: Optional[str]
321
+ context: PacketContext
322
+ stream_sequence: Optional[int] = None
323
+ record_offset: Optional[int] = None
324
+ record_index: Optional[int] = None
325
+ record_count: Optional[int] = None
326
+ # Storage-family metadata. Normalization can derive ``storage_id`` from the
327
+ # calibration-selected four-byte destination field when a producer leaves
328
+ # the redundant normalized value unset.
329
+ storage_id: Optional[int] = None
330
+ storage_operation: Optional[str] = None
331
+
332
+
333
+ EventCallback = Callable[[LootEvent, bytes], None]
334
+
335
+
336
+ @dataclass(frozen=True)
337
+ class BDOFrame:
338
+ """One generic length-framed BDO message, used by calibration."""
339
+
340
+ index: int
341
+ message: bytes
342
+ context: PacketContext
343
+ stream_sequence: Optional[int]
344
+
345
+ @property
346
+ def length(self) -> int:
347
+ return int.from_bytes(self.message[0:2], "little")
348
+
349
+ @property
350
+ def flag(self) -> int:
351
+ return self.message[2]
352
+
353
+ @property
354
+ def opcode(self) -> int:
355
+ return int.from_bytes(self.message[3:5], "little")
356
+
357
+ @property
358
+ def payload(self) -> bytes:
359
+ return self.message[5:]
360
+
361
+
362
+ def split_item_id_enhancement(item_id: int) -> tuple[int, Optional[int], Optional[str]]:
363
+ enhancement_level = item_id >> 24
364
+ base_item_id = item_id & BASE_ITEM_ID_MASK
365
+ if 1 <= enhancement_level <= MAX_ENHANCEMENT_LEVEL:
366
+ return base_item_id, enhancement_level, ENHANCEMENT_LABELS.get(enhancement_level)
367
+ return item_id, None, None
368
+
369
+
370
+ def source_label(
371
+ candidate: Optional[bytes],
372
+ default_context: Optional[str] = None,
373
+ ) -> Optional[str]:
374
+ """App-facing source label for a raw context candidate.
375
+
376
+ Known contexts map to their label. The spec default applies only when the
377
+ message carries no context bytes at all; an unrecognized candidate is
378
+ preserved as ``UNKNOWN(0x...)`` so new contexts stay visible to apps
379
+ instead of silently matching an existing source filter.
380
+ """
381
+ if candidate is None:
382
+ return default_context
383
+ label = SOURCE_CONTEXT_LABELS.get(candidate)
384
+ if label is not None:
385
+ return label
386
+ return f"UNKNOWN(0x{candidate.hex()})"