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,904 @@
1
+ """Scan a reassembled application byte stream for target BDO messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable, Iterable, Optional
6
+
7
+ from ._protocol import (
8
+ CHARACTER_LOAD_CONTEXT,
9
+ MAX_PLAUSIBLE_ITEM_ID,
10
+ MAX_TARGET_MESSAGE_LENGTH,
11
+ BDOFrame,
12
+ EventCallback,
13
+ EventSpec,
14
+ LootEvent,
15
+ PacketContext,
16
+ )
17
+
18
+ _TRANSFER_RECORD_MARKER = b"\x00" * 4 + b"\xff" * 8
19
+ _TRANSFER_RECORD_MARKER_DELTA = 8
20
+
21
+ MessageObserver = Callable[
22
+ [int, int, str, int, PacketContext, Optional[int]],
23
+ object,
24
+ ]
25
+
26
+
27
+ def _structural_instance_offset(spec: EventSpec) -> Optional[int]:
28
+ if spec.label == "INVENTORY_TRANSFER":
29
+ return spec.item_instance_offset
30
+ if spec.label == "INVENTORY_TO_STORAGE":
31
+ return spec.storage_instance_offset
32
+ return None
33
+
34
+
35
+ def _supports_structural_record_scan(spec: EventSpec) -> bool:
36
+ instance_offset = _structural_instance_offset(spec)
37
+ return (
38
+ instance_offset is not None
39
+ and spec.quantity_offset - spec.item_offset == 4
40
+ and instance_offset - spec.item_offset >= 20
41
+ )
42
+
43
+
44
+ def _looks_like_transfer_record(
45
+ message: bytes,
46
+ item_offset: int,
47
+ instance_delta: int,
48
+ ) -> bool:
49
+ required_end = item_offset + max(20, instance_delta + 8)
50
+ if item_offset < 0 or required_end > len(message):
51
+ return False
52
+ item_id = int.from_bytes(message[item_offset : item_offset + 4], "little")
53
+ quantity = int.from_bytes(message[item_offset + 4 : item_offset + 8], "little")
54
+ instance = message[item_offset + instance_delta : item_offset + instance_delta + 8]
55
+ return (
56
+ 0 < item_id <= MAX_PLAUSIBLE_ITEM_ID
57
+ and quantity > 0
58
+ and instance not in (b"\x00" * 8, b"\xff" * 8)
59
+ and message[
60
+ item_offset
61
+ + _TRANSFER_RECORD_MARKER_DELTA : item_offset
62
+ + _TRANSFER_RECORD_MARKER_DELTA
63
+ + len(_TRANSFER_RECORD_MARKER)
64
+ ]
65
+ == _TRANSFER_RECORD_MARKER
66
+ )
67
+
68
+
69
+ def _has_plausible_transfer_record_values(
70
+ message: bytes,
71
+ *,
72
+ item_offset: int,
73
+ quantity_offset: int,
74
+ instance_offset: int,
75
+ ) -> bool:
76
+ """Validate the value-bearing fields that every transfer record needs."""
77
+ required_end = max(item_offset + 4, quantity_offset + 4, instance_offset + 8)
78
+ if min(item_offset, quantity_offset, instance_offset) < 0:
79
+ return False
80
+ if required_end > len(message):
81
+ return False
82
+
83
+ item_id = int.from_bytes(message[item_offset : item_offset + 4], "little")
84
+ quantity = int.from_bytes(message[quantity_offset : quantity_offset + 4], "little")
85
+ instance = message[instance_offset : instance_offset + 8]
86
+ return (
87
+ 0 < item_id <= MAX_PLAUSIBLE_ITEM_ID
88
+ and quantity > 0
89
+ and instance not in (b"\x00" * 8, b"\xff" * 8)
90
+ )
91
+
92
+
93
+ def _declared_inventory_snapshot_record_deltas(
94
+ spec: EventSpec,
95
+ message: bytes,
96
+ ) -> Optional[list[int]]:
97
+ """Prove a character-load inventory batch from its declared count.
98
+
99
+ The wrapper's uint16 count has moved between patches, so search the framed
100
+ header rather than pinning its offset. Each viable declaration must imply
101
+ the same ``length = base + (count - 1) * stride`` geometry, and every
102
+ declared item/quantity/instance tuple must validate. This makes a
103
+ zero-context load atomic: a corrupt record invalidates the whole frame.
104
+
105
+ Multiple copies of the same count are harmless. Competing declarations
106
+ that imply different geometries are ambiguous and fail closed.
107
+ """
108
+ if spec.label != "INVENTORY_TRANSFER":
109
+ return None
110
+ instance_offset = spec.item_instance_offset
111
+ base_length = spec.single_record_message_length
112
+ if instance_offset is None or base_length is None:
113
+ return None
114
+
115
+ # Counts are wrapper metadata and therefore must precede the calibrated
116
+ # first item. Skip the five-byte generic frame header itself.
117
+ search_end = min(spec.item_offset, len(message))
118
+ geometries: dict[tuple[int, Optional[int]], list[int]] = {}
119
+ for count_offset in range(5, max(5, search_end - 1)):
120
+ declared_count = int.from_bytes(
121
+ message[count_offset : count_offset + 2], "little"
122
+ )
123
+ if declared_count <= 0:
124
+ continue
125
+
126
+ if declared_count == 1:
127
+ if len(message) != base_length:
128
+ continue
129
+ stride: Optional[int] = None
130
+ deltas = [0]
131
+ else:
132
+ extra_length = len(message) - base_length
133
+ divisor = declared_count - 1
134
+ if extra_length <= 0 or extra_length % divisor:
135
+ continue
136
+ stride = extra_length // divisor
137
+ prefix_length = base_length - stride
138
+ if prefix_length < 5 or count_offset + 2 > prefix_length:
139
+ continue
140
+ if len(message) - prefix_length != declared_count * stride:
141
+ continue
142
+
143
+ relative_offsets = (
144
+ spec.item_offset - prefix_length,
145
+ spec.quantity_offset - prefix_length,
146
+ instance_offset - prefix_length,
147
+ )
148
+ if min(relative_offsets) < 0:
149
+ continue
150
+ required_record_end = max(
151
+ relative_offsets[0] + 4,
152
+ relative_offsets[1] + 4,
153
+ relative_offsets[2] + 8,
154
+ )
155
+ if required_record_end > stride:
156
+ continue
157
+ deltas = [stride * index for index in range(declared_count)]
158
+
159
+ if not all(
160
+ _has_plausible_transfer_record_values(
161
+ message,
162
+ item_offset=spec.item_offset + delta,
163
+ quantity_offset=spec.quantity_offset + delta,
164
+ instance_offset=instance_offset + delta,
165
+ )
166
+ for delta in deltas
167
+ ):
168
+ continue
169
+ geometries[(declared_count, stride)] = deltas
170
+
171
+ if len(geometries) != 1:
172
+ return None
173
+ return next(iter(geometries.values()))
174
+
175
+
176
+ def _declared_storage_record_deltas(
177
+ spec: EventSpec,
178
+ message: bytes,
179
+ ) -> Optional[list[int]]:
180
+ """Validate storage records against the calibration-learned count field.
181
+
182
+ The count position is profile data discovered from record geometry; it is
183
+ not a decoder layout constant. A different header byte can coincidentally
184
+ equal the right count, so production decoding must never select a count
185
+ column independently for each message. Missing authority or any
186
+ contradiction returns an empty list and therefore cannot fall back to
187
+ marker-only partial decoding.
188
+ """
189
+ if spec.label != "INVENTORY_TO_STORAGE":
190
+ return None
191
+ instance_offset = _structural_instance_offset(spec)
192
+ base_length = spec.single_record_message_length
193
+ count_offset = spec.record_count_offset
194
+ if instance_offset is None or base_length is None or count_offset is None:
195
+ return []
196
+ if count_offset < 5 or count_offset + 2 > min(spec.item_offset, len(message)):
197
+ return []
198
+
199
+ declared_count = int.from_bytes(
200
+ message[count_offset : count_offset + 2],
201
+ "little",
202
+ )
203
+ if declared_count <= 0:
204
+ return []
205
+ if declared_count == 1:
206
+ if len(message) != base_length:
207
+ return []
208
+ deltas = [0]
209
+ else:
210
+ extra_length = len(message) - base_length
211
+ divisor = declared_count - 1
212
+ if extra_length <= 0 or extra_length % divisor:
213
+ return []
214
+ stride = extra_length // divisor
215
+ prefix_length = base_length - stride
216
+ if (
217
+ prefix_length < 5
218
+ or count_offset + 2 > prefix_length
219
+ or len(message) - prefix_length != declared_count * stride
220
+ ):
221
+ return []
222
+
223
+ relative_offsets = (
224
+ spec.item_offset - prefix_length,
225
+ spec.quantity_offset - prefix_length,
226
+ instance_offset - prefix_length,
227
+ )
228
+ if min(relative_offsets) < 0:
229
+ return []
230
+ required_record_end = max(
231
+ relative_offsets[0] + 4,
232
+ relative_offsets[1] + 4,
233
+ relative_offsets[2] + 8,
234
+ )
235
+ if required_record_end > stride:
236
+ return []
237
+ deltas = [stride * index for index in range(declared_count)]
238
+
239
+ if not all(
240
+ _has_plausible_transfer_record_values(
241
+ message,
242
+ item_offset=spec.item_offset + delta,
243
+ quantity_offset=spec.quantity_offset + delta,
244
+ instance_offset=instance_offset + delta,
245
+ )
246
+ for delta in deltas
247
+ ):
248
+ return []
249
+ return deltas
250
+
251
+
252
+ def _structural_record_deltas(
253
+ spec: EventSpec,
254
+ message: bytes,
255
+ declared_storage_deltas: Optional[list[int]],
256
+ ) -> Optional[list[int]]:
257
+ """Find every repeated transfer record without trusting a saved stride.
258
+
259
+ Profiles calibrated from a single action know the first-record offsets and
260
+ base message length but cannot know the distance to a record that was never
261
+ observed. Storage wrappers use their calibration-owned count field above;
262
+ other transfer families may use their item-record marker and relative
263
+ instance offset. The base-length equation guards against marker-like bytes
264
+ elsewhere in the same message.
265
+ """
266
+ if declared_storage_deltas is not None:
267
+ return declared_storage_deltas
268
+
269
+ instance_offset = _structural_instance_offset(spec)
270
+ if instance_offset is None or not _supports_structural_record_scan(spec):
271
+ return None
272
+ instance_delta = instance_offset - spec.item_offset
273
+
274
+ offsets: list[int] = []
275
+ search_at = spec.item_offset + _TRANSFER_RECORD_MARKER_DELTA
276
+ while True:
277
+ marker_at = message.find(_TRANSFER_RECORD_MARKER, search_at)
278
+ if marker_at < 0:
279
+ break
280
+ search_at = marker_at + 1
281
+ item_offset = marker_at - _TRANSFER_RECORD_MARKER_DELTA
282
+ if item_offset < spec.item_offset:
283
+ continue
284
+ if _looks_like_transfer_record(message, item_offset, instance_delta):
285
+ offsets.append(item_offset)
286
+
287
+ # The calibrated first record is the anchor. Refuse a partial or shifted
288
+ # match instead of turning an embedded item structure into an event.
289
+ if not offsets or offsets[0] != spec.item_offset:
290
+ return None
291
+
292
+ if len(offsets) > 1:
293
+ strides = [b - a for a, b in zip(offsets, offsets[1:])]
294
+ minimum_stride = max(20, instance_delta + 8)
295
+ if any(stride < minimum_stride for stride in strides):
296
+ return None
297
+ if len(set(strides)) != 1:
298
+ return None
299
+ inferred_stride = strides[0]
300
+ else:
301
+ inferred_stride = 0
302
+
303
+ base_length = spec.single_record_message_length
304
+ if base_length is not None:
305
+ expected_length = base_length + (len(offsets) - 1) * inferred_stride
306
+ if len(message) != expected_length:
307
+ return None
308
+
309
+ return [offset - spec.item_offset for offset in offsets]
310
+
311
+
312
+ def _configured_message_length_matches_spec(
313
+ spec: EventSpec,
314
+ message_length: int,
315
+ ) -> bool:
316
+ base_length = spec.single_record_message_length
317
+ if base_length is None:
318
+ return True
319
+ if spec.repeat_stride is None:
320
+ return message_length == base_length
321
+ extra_records_length = message_length - base_length
322
+ return extra_records_length >= 0 and extra_records_length % spec.repeat_stride == 0
323
+
324
+
325
+ class FrameCollectorScanner:
326
+ """Collect generic length-framed BDO messages with midstream recovery.
327
+
328
+ A live capture can attach in the middle of an established application
329
+ frame. In that state the first two bytes are arbitrary payload, not a
330
+ trustworthy length. Known target opcodes provide an immediate anchor;
331
+ opcode-free calibration instead waits for two consecutive complete frame
332
+ boundaries (or one exact standalone frame) before declaring sync.
333
+ """
334
+
335
+ _MAX_UNSYNCHRONIZED_BUFFER = MAX_TARGET_MESSAGE_LENGTH + 4
336
+
337
+ def __init__(
338
+ self,
339
+ callback: Callable[[BDOFrame], None],
340
+ known_opcodes: Iterable[int] = (),
341
+ ) -> None:
342
+ self._callback = callback
343
+ self._known_opcodes = frozenset(known_opcodes)
344
+ self._buffer = bytearray()
345
+ self._buffer_start_sequence: Optional[int] = None
346
+ self._frame_index = 0
347
+ self._synchronized = False
348
+
349
+ def reset(self) -> None:
350
+ self._buffer.clear()
351
+ self._buffer_start_sequence = None
352
+ self._synchronized = False
353
+
354
+ def can_anchor_at_start(self, data: bytes) -> bool:
355
+ """Return whether byte zero is an evidence-backed frame boundary."""
356
+ if len(data) < 5:
357
+ return False
358
+ first_length = int.from_bytes(data[0:2], "little")
359
+ if not 5 <= first_length <= MAX_TARGET_MESSAGE_LENGTH:
360
+ return False
361
+ opcode = int.from_bytes(data[3:5], "little")
362
+ if opcode in self._known_opcodes:
363
+ return True
364
+ if first_length == len(data):
365
+ return True
366
+ if first_length + 5 > len(data):
367
+ return False
368
+ second_length = int.from_bytes(
369
+ data[first_length : first_length + 2], "little"
370
+ )
371
+ return (
372
+ 5 <= second_length <= MAX_TARGET_MESSAGE_LENGTH
373
+ and first_length + second_length <= len(data)
374
+ )
375
+
376
+ def feed(self, data: bytes, context: PacketContext) -> None:
377
+ if not data:
378
+ return
379
+ if not self._buffer:
380
+ self._buffer_start_sequence = context.stream_start
381
+ self._buffer.extend(data)
382
+ self._scan(context)
383
+
384
+ def scan_standalone(self, data: bytes, context: PacketContext) -> None:
385
+ if not data:
386
+ return
387
+ saved_buffer = self._buffer
388
+ saved_buffer_start_sequence = self._buffer_start_sequence
389
+ saved_synchronized = self._synchronized
390
+ self._buffer = bytearray(data)
391
+ self._buffer_start_sequence = context.stream_start
392
+ self._synchronized = False
393
+ try:
394
+ self._scan(context)
395
+ finally:
396
+ self._buffer = saved_buffer
397
+ self._buffer_start_sequence = saved_buffer_start_sequence
398
+ self._synchronized = saved_synchronized
399
+
400
+ def _discard_prefix(self, byte_count: int) -> None:
401
+ if byte_count <= 0:
402
+ return
403
+ del self._buffer[:byte_count]
404
+ if self._buffer_start_sequence is not None:
405
+ self._buffer_start_sequence += byte_count
406
+ if not self._buffer:
407
+ self._buffer_start_sequence = None
408
+
409
+ def _scan(self, context: PacketContext) -> None:
410
+ while len(self._buffer) >= 5:
411
+ if not self._synchronized:
412
+ candidate_start = self._find_synchronization_candidate()
413
+ if candidate_start is None:
414
+ self._bound_unsynchronized_buffer()
415
+ return
416
+ if candidate_start:
417
+ self._discard_prefix(candidate_start)
418
+ self._synchronized = True
419
+
420
+ message_length = int.from_bytes(self._buffer[0:2], "little")
421
+ if not 5 <= message_length <= MAX_TARGET_MESSAGE_LENGTH:
422
+ self._synchronized = False
423
+ self._discard_prefix(1)
424
+ continue
425
+
426
+ if message_length > len(self._buffer):
427
+ # Fragmentation is normal after a boundary has been proven.
428
+ # Do not reinterpret item bytes inside this incomplete frame
429
+ # as a later opcode anchor; FlowManager.reset() explicitly
430
+ # drops synchronization after a real TCP gap.
431
+ return
432
+
433
+ message = bytes(self._buffer[:message_length])
434
+ frame = BDOFrame(
435
+ index=self._frame_index,
436
+ message=message,
437
+ context=context,
438
+ stream_sequence=self._buffer_start_sequence,
439
+ )
440
+ self._frame_index += 1
441
+ self._callback(frame)
442
+ self._discard_prefix(message_length)
443
+
444
+ def _find_synchronization_candidate(
445
+ self,
446
+ *,
447
+ start_at: int = 0,
448
+ ) -> Optional[int]:
449
+ """Return the earliest defensible frame boundary in the buffer."""
450
+ limit = len(self._buffer) - 4
451
+ for start in range(start_at, max(start_at, limit)):
452
+ first_length = int.from_bytes(self._buffer[start : start + 2], "little")
453
+ if not 5 <= first_length <= MAX_TARGET_MESSAGE_LENGTH:
454
+ continue
455
+ first_end = start + first_length
456
+ if first_end > len(self._buffer):
457
+ continue
458
+
459
+ opcode = int.from_bytes(self._buffer[start + 3 : start + 5], "little")
460
+ if opcode in self._known_opcodes:
461
+ return start
462
+
463
+ # Preserve exact standalone/single-frame collection when capture
464
+ # begins at a real boundary. A candidate found after discarded
465
+ # prefix bytes still needs stronger evidence.
466
+ if start == 0 and first_end == len(self._buffer):
467
+ return start
468
+
469
+ if first_end + 5 <= len(self._buffer):
470
+ second_length = int.from_bytes(
471
+ self._buffer[first_end : first_end + 2], "little"
472
+ )
473
+ if (
474
+ 5 <= second_length <= MAX_TARGET_MESSAGE_LENGTH
475
+ and first_end + second_length <= len(self._buffer)
476
+ ):
477
+ return start
478
+ return None
479
+
480
+ def _bound_unsynchronized_buffer(self) -> None:
481
+ """Retain at most one maximum frame plus a split header."""
482
+ excess = len(self._buffer) - self._MAX_UNSYNCHRONIZED_BUFFER
483
+ if excess > 0:
484
+ self._discard_prefix(excess)
485
+
486
+
487
+ class TargetMessageScanner:
488
+ """Find target BDO messages in a contiguous application byte stream."""
489
+
490
+ def __init__(
491
+ self,
492
+ callback: EventCallback,
493
+ event_specs: Iterable[EventSpec],
494
+ message_observer: Optional[MessageObserver] = None,
495
+ ) -> None:
496
+ self._buffer = bytearray()
497
+ self._buffer_start_sequence: Optional[int] = None
498
+ self._callback = callback
499
+ self._message_observer = message_observer
500
+ signature_groups: dict[bytes, list[EventSpec]] = {}
501
+ for spec in event_specs:
502
+ signature_groups.setdefault(spec.signature, []).append(spec)
503
+ self._signature_groups = tuple(
504
+ (signature, tuple(specs))
505
+ for signature, specs in signature_groups.items()
506
+ )
507
+
508
+ def reset(self) -> None:
509
+ self._buffer.clear()
510
+ self._buffer_start_sequence = None
511
+
512
+ def can_anchor_at_start(self, data: bytes) -> bool:
513
+ """Return whether the bytes contain a configured recovery header."""
514
+ if len(data) < 5:
515
+ return False
516
+ for signature, specs in self._signature_groups:
517
+ signature_at = data.find(signature, 2)
518
+ while signature_at >= 0:
519
+ message_start = signature_at - 2
520
+ message_length = int.from_bytes(
521
+ data[message_start:signature_at], "little"
522
+ )
523
+ for spec in specs:
524
+ if not (
525
+ spec.min_message_length
526
+ <= message_length
527
+ <= MAX_TARGET_MESSAGE_LENGTH
528
+ ):
529
+ continue
530
+ if self._message_length_matches_spec(spec, message_length):
531
+ return True
532
+ signature_at = data.find(signature, signature_at + 1)
533
+ return False
534
+
535
+ def feed(self, data: bytes, context: PacketContext) -> None:
536
+ if not data:
537
+ return
538
+ if not self._buffer:
539
+ self._buffer_start_sequence = context.stream_start
540
+ self._buffer.extend(data)
541
+ self._scan(context)
542
+
543
+ def scan_standalone(self, data: bytes, context: PacketContext) -> None:
544
+ if not data:
545
+ return
546
+ saved_buffer = self._buffer
547
+ saved_buffer_start_sequence = self._buffer_start_sequence
548
+ self._buffer = bytearray(data)
549
+ self._buffer_start_sequence = context.stream_start
550
+ try:
551
+ self._scan(context)
552
+ finally:
553
+ self._buffer = saved_buffer
554
+ self._buffer_start_sequence = saved_buffer_start_sequence
555
+
556
+ def _discard_prefix(self, byte_count: int) -> None:
557
+ if byte_count <= 0:
558
+ return
559
+ del self._buffer[:byte_count]
560
+ if self._buffer_start_sequence is not None:
561
+ self._buffer_start_sequence += byte_count
562
+ if not self._buffer:
563
+ self._buffer_start_sequence = None
564
+
565
+ def _scan(self, context: PacketContext) -> None:
566
+ while True:
567
+ complete_candidates: list[tuple[int, int, EventSpec]] = []
568
+ incomplete_candidate: Optional[tuple[int, int, EventSpec]] = None
569
+
570
+ # Search all known opcode signatures. A signature begins at header
571
+ # byte 2, so the two bytes immediately before it are the length.
572
+ for signature, specs in self._signature_groups:
573
+ search_at = 0
574
+ while True:
575
+ signature_at = self._buffer.find(signature, search_at)
576
+ if signature_at < 0:
577
+ break
578
+ search_at = signature_at + 1
579
+
580
+ message_start = signature_at - 2
581
+ if message_start < 0:
582
+ continue
583
+
584
+ message_length = int.from_bytes(
585
+ self._buffer[message_start : message_start + 2], "little"
586
+ )
587
+ for spec in specs:
588
+ if not (
589
+ spec.min_message_length
590
+ <= message_length
591
+ <= MAX_TARGET_MESSAGE_LENGTH
592
+ ):
593
+ continue
594
+ if not self._message_length_matches_spec(
595
+ spec, message_length
596
+ ):
597
+ continue
598
+
599
+ candidate = (message_start, message_length, spec)
600
+ if message_start + message_length <= len(self._buffer):
601
+ if (
602
+ not complete_candidates
603
+ or message_start < complete_candidates[0][0]
604
+ ):
605
+ complete_candidates = [candidate]
606
+ elif message_start == complete_candidates[0][0]:
607
+ complete_candidates.append(candidate)
608
+ elif (
609
+ incomplete_candidate is None
610
+ or message_start < incomplete_candidate[0]
611
+ ):
612
+ incomplete_candidate = candidate
613
+
614
+ if not complete_candidates:
615
+ if incomplete_candidate is not None:
616
+ # Retain the incomplete target frame and wait for more TCP
617
+ # bytes. Bytes before it cannot be part of that frame.
618
+ message_start = incomplete_candidate[0]
619
+ if message_start:
620
+ self._discard_prefix(message_start)
621
+ else:
622
+ # Retain enough trailing bytes to catch a five-byte header
623
+ # split across the next TCP segment.
624
+ if len(self._buffer) > 4:
625
+ self._discard_prefix(len(self._buffer) - 4)
626
+ return
627
+
628
+ # An earlier incomplete frame may contain a later complete-looking
629
+ # signature in its payload. Preserve it until its declared bytes
630
+ # arrive instead of skipping ahead and decoding the nested bytes.
631
+ if (
632
+ incomplete_candidate is not None
633
+ and incomplete_candidate[0] < complete_candidates[0][0]
634
+ ):
635
+ message_start = incomplete_candidate[0]
636
+ if message_start:
637
+ self._discard_prefix(message_start)
638
+ return
639
+
640
+ message_start, message_length, _ = complete_candidates[0]
641
+ message_end = message_start + message_length
642
+ message = bytes(self._buffer[message_start:message_end])
643
+ stream_sequence = (
644
+ self._buffer_start_sequence + message_start
645
+ if self._buffer_start_sequence is not None
646
+ else None
647
+ )
648
+
649
+ valid_decodes: list[tuple[EventSpec, list[LootEvent]]] = []
650
+ for _, candidate_length, spec in complete_candidates:
651
+ source_context_candidate = None
652
+ if spec.source_context_offset is not None:
653
+ source_context_end = (
654
+ spec.source_context_offset + spec.source_context_length
655
+ )
656
+ source_context_candidate = bytes(
657
+ message[spec.source_context_offset : source_context_end]
658
+ )
659
+
660
+ decoded_events = self._decode_events_from_message(
661
+ spec=spec,
662
+ message=message,
663
+ message_length=candidate_length,
664
+ source_context_candidate=source_context_candidate,
665
+ context=context,
666
+ stream_sequence=stream_sequence,
667
+ )
668
+ if decoded_events is not None:
669
+ valid_decodes.append((spec, decoded_events))
670
+
671
+ storage_candidate = next(
672
+ (
673
+ spec
674
+ for _, _, spec in complete_candidates
675
+ if spec.label == "INVENTORY_TO_STORAGE"
676
+ ),
677
+ None,
678
+ )
679
+ if storage_candidate is not None and self._message_observer is not None:
680
+ storage_decodes = tuple(
681
+ decoded
682
+ for decoded in valid_decodes
683
+ if decoded[0].label == "INVENTORY_TO_STORAGE" and decoded[1]
684
+ )
685
+ # A different same-opcode layout that validates uniquely is not
686
+ # a rejected storage wrapper. It belongs to that other family.
687
+ if not (valid_decodes and not storage_decodes):
688
+ decoded_storage = (
689
+ storage_decodes[0]
690
+ if len(valid_decodes) == 1 and len(storage_decodes) == 1
691
+ else None
692
+ )
693
+ self._message_observer(
694
+ storage_candidate.opcode,
695
+ message_length,
696
+ "decoded" if decoded_storage is not None else "rejected",
697
+ len(decoded_storage[1]) if decoded_storage is not None else 0,
698
+ context,
699
+ stream_sequence,
700
+ )
701
+
702
+ # Same-opcode layouts are alternatives, not an order-dependent
703
+ # fallback list. Decode only when exactly one layout proves its
704
+ # geometry; reject both malformed and genuinely ambiguous frames.
705
+ if len(valid_decodes) != 1:
706
+ self._discard_prefix(message_start + 1)
707
+ continue
708
+
709
+ for event in valid_decodes[0][1]:
710
+ self._callback(event, message)
711
+
712
+ # Consume through the end of the decoded message and continue in
713
+ # case another target message is already buffered.
714
+ self._discard_prefix(message_end)
715
+
716
+ @staticmethod
717
+ def _message_length_matches_spec(spec: EventSpec, message_length: int) -> bool:
718
+ # A structurally self-validating transfer may carry a stride that was
719
+ # absent from a single-record calibration or changed after a patch.
720
+ # Let the complete message reach the structural validator below.
721
+ if _supports_structural_record_scan(spec):
722
+ return True
723
+ return _configured_message_length_matches_spec(spec, message_length)
724
+
725
+ def _decode_events_from_message(
726
+ self,
727
+ *,
728
+ spec: EventSpec,
729
+ message: bytes,
730
+ message_length: int,
731
+ source_context_candidate: Optional[bytes],
732
+ context: PacketContext,
733
+ stream_sequence: Optional[int],
734
+ ) -> Optional[list[LootEvent]]:
735
+ is_inventory_snapshot = (
736
+ spec.label == "INVENTORY_TRANSFER"
737
+ and source_context_candidate == CHARACTER_LOAD_CONTEXT
738
+ )
739
+ declared_storage_deltas = (
740
+ _declared_storage_record_deltas(spec, message)
741
+ if spec.label == "INVENTORY_TO_STORAGE"
742
+ else None
743
+ )
744
+
745
+ if is_inventory_snapshot:
746
+ # Character-load frames are all-or-nothing. They may resemble a
747
+ # normal transfer record, but must never fall back to record-one
748
+ # or marker scanning when their declared layout is malformed.
749
+ snapshot_deltas = _declared_inventory_snapshot_record_deltas(
750
+ spec,
751
+ message,
752
+ )
753
+ if snapshot_deltas is None:
754
+ return None
755
+ candidate_deltas = snapshot_deltas
756
+ else:
757
+ structural_deltas = _structural_record_deltas(
758
+ spec,
759
+ message,
760
+ declared_storage_deltas,
761
+ )
762
+ if structural_deltas is not None:
763
+ candidate_deltas = structural_deltas
764
+ else:
765
+ # Structural discovery is deliberately strict. Preserve support
766
+ # for older/synthetic layouts through the calibrated stride, but
767
+ # never accept an off-stride message merely because record 1 fits.
768
+ if not _configured_message_length_matches_spec(spec, message_length):
769
+ return None
770
+ candidate_deltas = []
771
+ offset_delta = 0
772
+ while True:
773
+ required_end = max(
774
+ spec.item_offset + offset_delta + 4,
775
+ spec.quantity_offset + offset_delta + 4,
776
+ )
777
+ if spec.inventory_slot_offset is not None:
778
+ required_end = max(
779
+ required_end,
780
+ spec.inventory_slot_offset + offset_delta + 1,
781
+ )
782
+ if spec.item_instance_offset is not None:
783
+ required_end = max(
784
+ required_end,
785
+ spec.item_instance_offset + offset_delta + 8,
786
+ )
787
+ if spec.storage_instance_offset is not None:
788
+ required_end = max(
789
+ required_end,
790
+ spec.storage_instance_offset + offset_delta + 8,
791
+ )
792
+ if required_end > len(message):
793
+ break
794
+ candidate_deltas.append(offset_delta)
795
+ if spec.repeat_stride is None:
796
+ break
797
+ offset_delta += spec.repeat_stride
798
+
799
+ records: list[tuple[int, int]] = []
800
+ for offset_delta in candidate_deltas:
801
+ item_offset = spec.item_offset + offset_delta
802
+ quantity_offset = spec.quantity_offset + offset_delta
803
+ required_end = max(item_offset + 4, quantity_offset + 4)
804
+ if spec.inventory_slot_offset is not None:
805
+ required_end = max(
806
+ required_end, spec.inventory_slot_offset + offset_delta + 1
807
+ )
808
+ if spec.item_instance_offset is not None:
809
+ required_end = max(
810
+ required_end, spec.item_instance_offset + offset_delta + 8
811
+ )
812
+ if spec.storage_instance_offset is not None:
813
+ required_end = max(
814
+ required_end, spec.storage_instance_offset + offset_delta + 8
815
+ )
816
+ if required_end > len(message):
817
+ continue
818
+ records.append((offset_delta, item_offset))
819
+
820
+ storage_id: Optional[int] = None
821
+ storage_operation: Optional[str] = None
822
+ if spec.label == "INVENTORY_TO_STORAGE" and records:
823
+ if (
824
+ spec.source_context_offset is not None
825
+ and source_context_candidate is not None
826
+ and len(source_context_candidate) == 4
827
+ ):
828
+ # Calibration owns this column. Runtime never scans for a
829
+ # known-looking town key: an incomplete profile stays
830
+ # unresolved, and a new numeric ID stays attached to its true
831
+ # field for an explicit registry diagnostic.
832
+ storage_id = int.from_bytes(source_context_candidate, "little")
833
+ # Framing proves records and destination only. Operation semantics
834
+ # are stateful: decrement evidence proves manual live activity,
835
+ # the shared-token companion chain proves worker live activity,
836
+ # and a hydration cohort proves snapshot state. No patch-specific
837
+ # mode or token byte is consulted here.
838
+ storage_operation = "unknown"
839
+
840
+ events: list[LootEvent] = []
841
+ for record_index, (offset_delta, item_offset) in enumerate(records, 1):
842
+ quantity_offset = spec.quantity_offset + offset_delta
843
+ item_id = int.from_bytes(message[item_offset : item_offset + 4], "little")
844
+ quantity = int.from_bytes(
845
+ message[quantity_offset : quantity_offset + 4],
846
+ "little",
847
+ )
848
+
849
+ if item_id <= 0 or item_id > MAX_PLAUSIBLE_ITEM_ID or quantity <= 0:
850
+ continue
851
+
852
+ inventory_slot = (
853
+ message[spec.inventory_slot_offset + offset_delta]
854
+ if spec.inventory_slot_offset is not None
855
+ else None
856
+ )
857
+ item_instance = (
858
+ bytes(
859
+ message[
860
+ spec.item_instance_offset
861
+ + offset_delta : spec.item_instance_offset
862
+ + offset_delta
863
+ + 8
864
+ ]
865
+ )
866
+ if spec.item_instance_offset is not None
867
+ else None
868
+ )
869
+ storage_instance = (
870
+ bytes(
871
+ message[
872
+ spec.storage_instance_offset
873
+ + offset_delta : spec.storage_instance_offset
874
+ + offset_delta
875
+ + 8
876
+ ]
877
+ )
878
+ if spec.storage_instance_offset is not None
879
+ else None
880
+ )
881
+ events.append(
882
+ LootEvent(
883
+ label=spec.label,
884
+ opcode=spec.opcode,
885
+ item_id=item_id,
886
+ quantity=quantity,
887
+ inventory_slot=inventory_slot,
888
+ source_context_candidate=source_context_candidate,
889
+ item_instance=item_instance,
890
+ storage_instance=storage_instance,
891
+ message_length=message_length,
892
+ default_context=spec.default_context,
893
+ context=context,
894
+ stream_sequence=stream_sequence,
895
+ record_offset=item_offset,
896
+ record_index=record_index if len(records) > 1 else None,
897
+ record_count=len(records) if len(records) > 1 else None,
898
+ storage_id=storage_id,
899
+ storage_operation=storage_operation,
900
+ )
901
+ )
902
+ if events:
903
+ return events
904
+ return [] if is_inventory_snapshot and not candidate_deltas else None