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.
- bdo_toolkit/__init__.py +87 -0
- bdo_toolkit/_async_sessions.py +651 -0
- bdo_toolkit/_capture_backend.py +194 -0
- bdo_toolkit/_capture_options.py +68 -0
- bdo_toolkit/_capture_runtime.py +626 -0
- bdo_toolkit/_deposit_origin.py +1599 -0
- bdo_toolkit/_engine.py +327 -0
- bdo_toolkit/_framing.py +904 -0
- bdo_toolkit/_profile_runtime.py +157 -0
- bdo_toolkit/_protocol.py +386 -0
- bdo_toolkit/_reassembly.py +654 -0
- bdo_toolkit/_specs.py +285 -0
- bdo_toolkit/_storage_destination_validation.py +167 -0
- bdo_toolkit/_storage_hydration.py +241 -0
- bdo_toolkit/_version.py +3 -0
- bdo_toolkit/calibration.py +3223 -0
- bdo_toolkit/capture.py +1713 -0
- bdo_toolkit/character_state.py +3506 -0
- bdo_toolkit/cli.py +948 -0
- bdo_toolkit/diagnostics.py +51 -0
- bdo_toolkit/events.py +214 -0
- bdo_toolkit/filters.py +105 -0
- bdo_toolkit/item_state.py +48 -0
- bdo_toolkit/origin_learning.py +779 -0
- bdo_toolkit/profiles.py +370 -0
- bdo_toolkit/py.typed +1 -0
- bdo_toolkit/remote_profiles.py +358 -0
- bdo_toolkit/solare/__init__.py +50 -0
- bdo_toolkit/solare/_constants.py +94 -0
- bdo_toolkit/solare/_detail_learning.py +1437 -0
- bdo_toolkit/solare/_details.py +796 -0
- bdo_toolkit/solare/_discovery.py +1212 -0
- bdo_toolkit/solare/_live_tracker.py +472 -0
- bdo_toolkit/solare/_replay_capture.py +182 -0
- bdo_toolkit/solare/_result.py +441 -0
- bdo_toolkit/solare/_scanner.py +203 -0
- bdo_toolkit/solare/_validation.py +11 -0
- bdo_toolkit/solare/async_session.py +444 -0
- bdo_toolkit/solare/models.py +806 -0
- bdo_toolkit/solare/replay.py +62 -0
- bdo_toolkit/solare/session.py +1051 -0
- bdo_toolkit/writers.py +30 -0
- bdo_toolkit-1.0.0.dist-info/METADATA +143 -0
- bdo_toolkit-1.0.0.dist-info/RECORD +48 -0
- bdo_toolkit-1.0.0.dist-info/WHEEL +5 -0
- bdo_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
- bdo_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
- bdo_toolkit-1.0.0.dist-info/top_level.txt +1 -0
bdo_toolkit/_framing.py
ADDED
|
@@ -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
|