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/_engine.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Native packet engine: flow tracking, dedup, and event normalization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
import hashlib
|
|
7
|
+
from threading import Lock
|
|
8
|
+
from typing import Callable, Iterable, Optional
|
|
9
|
+
|
|
10
|
+
from ._framing import FrameCollectorScanner, MessageObserver, TargetMessageScanner
|
|
11
|
+
from ._protocol import (
|
|
12
|
+
CHARACTER_LOAD_CONTEXT,
|
|
13
|
+
DEDUP_HISTORY_LIMIT,
|
|
14
|
+
BDOFrame,
|
|
15
|
+
EventSpec,
|
|
16
|
+
FlowKey,
|
|
17
|
+
LootEvent,
|
|
18
|
+
source_label,
|
|
19
|
+
split_item_id_enhancement,
|
|
20
|
+
storage_location,
|
|
21
|
+
PacketContext,
|
|
22
|
+
)
|
|
23
|
+
from ._reassembly import FlowManager
|
|
24
|
+
from .events import BDOEvent, Flow
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_ITEM_MAX_ACTIVE_FLOWS = 64
|
|
28
|
+
_ITEM_FLOW_IDLE_SECONDS = 300.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _TeeScanner:
|
|
32
|
+
"""Feed one reassembled stream to the target scanner and observers.
|
|
33
|
+
|
|
34
|
+
Observers run FIRST so correlation logic already holds every raw span and
|
|
35
|
+
generic frame of a TCP segment when the target scanner emits events.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
primary: TargetMessageScanner,
|
|
41
|
+
tap: Optional[FrameCollectorScanner],
|
|
42
|
+
stream_observer: Optional[Callable[[bytes, PacketContext], None]],
|
|
43
|
+
) -> None:
|
|
44
|
+
self._primary = primary
|
|
45
|
+
self._tap = tap
|
|
46
|
+
self._stream_observer = stream_observer
|
|
47
|
+
|
|
48
|
+
def feed(self, data, context) -> None:
|
|
49
|
+
if self._stream_observer is not None:
|
|
50
|
+
self._stream_observer(data, context)
|
|
51
|
+
if self._tap is not None:
|
|
52
|
+
self._tap.feed(data, context)
|
|
53
|
+
self._primary.feed(data, context)
|
|
54
|
+
|
|
55
|
+
def scan_standalone(self, data, context) -> None:
|
|
56
|
+
if self._stream_observer is not None:
|
|
57
|
+
self._stream_observer(data, context)
|
|
58
|
+
if self._tap is not None:
|
|
59
|
+
self._tap.scan_standalone(data, context)
|
|
60
|
+
self._primary.scan_standalone(data, context)
|
|
61
|
+
|
|
62
|
+
def can_anchor_at_start(self, data: bytes) -> bool:
|
|
63
|
+
# Observers must never make primary event reassembly less conservative.
|
|
64
|
+
# The generic tap accepts weaker standalone-frame evidence that is safe
|
|
65
|
+
# for observation but can be a coincidental header in a target suffix.
|
|
66
|
+
return self._primary.can_anchor_at_start(data)
|
|
67
|
+
|
|
68
|
+
def reset(self) -> None:
|
|
69
|
+
if self._tap is not None:
|
|
70
|
+
self._tap.reset()
|
|
71
|
+
self._primary.reset()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def toolkit_event_from_record(event: LootEvent) -> BDOEvent:
|
|
75
|
+
"""Normalize a decoded protocol record into the stable app-facing event."""
|
|
76
|
+
decoded_base_item_id, enhancement_level, enhancement = split_item_id_enhancement(
|
|
77
|
+
event.item_id
|
|
78
|
+
)
|
|
79
|
+
base_item_id: Optional[int] = (
|
|
80
|
+
decoded_base_item_id if enhancement_level is not None else None
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
is_storage = event.label == "INVENTORY_TO_STORAGE"
|
|
84
|
+
storage_id = event.storage_id
|
|
85
|
+
if storage_id is not None and not 0 < storage_id <= 0xFFFFFFFF:
|
|
86
|
+
storage_id = None
|
|
87
|
+
if (
|
|
88
|
+
is_storage
|
|
89
|
+
and storage_id is None
|
|
90
|
+
and event.source_context_candidate is not None
|
|
91
|
+
and len(event.source_context_candidate) == 4
|
|
92
|
+
and event.source_context_candidate != b"\x00" * 4
|
|
93
|
+
):
|
|
94
|
+
storage_id = int.from_bytes(event.source_context_candidate, "little")
|
|
95
|
+
location = storage_location(storage_id) if is_storage else None
|
|
96
|
+
source = (
|
|
97
|
+
None
|
|
98
|
+
if is_storage
|
|
99
|
+
else source_label(event.source_context_candidate, event.default_context)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
extra = {}
|
|
103
|
+
if event.stream_sequence is not None:
|
|
104
|
+
extra["stream_sequence"] = event.stream_sequence
|
|
105
|
+
return BDOEvent(
|
|
106
|
+
event_type=_event_type_for_record(event),
|
|
107
|
+
timestamp=event.context.timestamp,
|
|
108
|
+
flow=Flow(
|
|
109
|
+
source_ip=event.context.flow.source_ip,
|
|
110
|
+
source_port=event.context.flow.source_port,
|
|
111
|
+
destination_ip=event.context.flow.destination_ip,
|
|
112
|
+
destination_port=event.context.flow.destination_port,
|
|
113
|
+
),
|
|
114
|
+
item_id=event.item_id,
|
|
115
|
+
quantity=event.quantity,
|
|
116
|
+
source=source,
|
|
117
|
+
raw_context=_hex(event.source_context_candidate),
|
|
118
|
+
opcode=event.opcode,
|
|
119
|
+
message_length=event.message_length,
|
|
120
|
+
base_item_id=base_item_id,
|
|
121
|
+
enhancement_level=enhancement_level,
|
|
122
|
+
enhancement=enhancement,
|
|
123
|
+
inventory_slot=event.inventory_slot,
|
|
124
|
+
item_instance=_hex(event.item_instance),
|
|
125
|
+
storage_instance=_hex(event.storage_instance),
|
|
126
|
+
storage_id=storage_id,
|
|
127
|
+
storage_name=location.name if location is not None else None,
|
|
128
|
+
storage_name_confidence=(
|
|
129
|
+
location.confidence if location is not None else None
|
|
130
|
+
),
|
|
131
|
+
record_index=event.record_index,
|
|
132
|
+
record_count=event.record_count,
|
|
133
|
+
record_offset=event.record_offset,
|
|
134
|
+
confidence="observed",
|
|
135
|
+
extra=extra,
|
|
136
|
+
_flow_generation=event.context.flow_generation,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _event_type_for_label(label: str) -> str:
|
|
141
|
+
return {
|
|
142
|
+
"LOOT_PREVIEW": "loot_preview",
|
|
143
|
+
"INVENTORY_TRANSFER": "item_received",
|
|
144
|
+
"INVENTORY_TO_STORAGE": "storage_delta",
|
|
145
|
+
}.get(label, label.lower())
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _event_type_for_record(event: LootEvent) -> str:
|
|
149
|
+
if event.label == "INVENTORY_TRANSFER":
|
|
150
|
+
if event.source_context_candidate == CHARACTER_LOAD_CONTEXT:
|
|
151
|
+
return "inventory_snapshot"
|
|
152
|
+
if event.source_context_candidate is None:
|
|
153
|
+
# Without a calibrated context field, hydration and ordinary
|
|
154
|
+
# receipts cannot be separated safely. Keep the record neutral so
|
|
155
|
+
# an incomplete post-patch profile cannot flood activity filters.
|
|
156
|
+
return "inventory_record"
|
|
157
|
+
return "item_received"
|
|
158
|
+
if event.label == "INVENTORY_TO_STORAGE":
|
|
159
|
+
if event.storage_operation == "snapshot":
|
|
160
|
+
return "storage_snapshot"
|
|
161
|
+
if event.storage_operation == "live":
|
|
162
|
+
return "storage_delta"
|
|
163
|
+
return "storage_record"
|
|
164
|
+
return _event_type_for_label(event.label)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _hex(value: Optional[bytes]) -> Optional[str]:
|
|
168
|
+
if value is None:
|
|
169
|
+
return None
|
|
170
|
+
return f"0x{value.hex()}"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class PacketEngine:
|
|
174
|
+
"""Reassemble server-to-client TCP flows and emit deduplicated events."""
|
|
175
|
+
|
|
176
|
+
def __init__(
|
|
177
|
+
self,
|
|
178
|
+
*,
|
|
179
|
+
server_ports: Iterable[int],
|
|
180
|
+
event_specs: Iterable[EventSpec],
|
|
181
|
+
on_event: Callable[[LootEvent, bytes], None],
|
|
182
|
+
frame_observer: Optional[Callable[[BDOFrame], None]] = None,
|
|
183
|
+
stream_observer: Optional[Callable[[bytes, PacketContext], None]] = None,
|
|
184
|
+
flow_close_observer: Optional[Callable[[FlowKey], None]] = None,
|
|
185
|
+
message_observer: Optional[MessageObserver] = None,
|
|
186
|
+
) -> None:
|
|
187
|
+
self.event_specs = tuple(event_specs)
|
|
188
|
+
self.events_found = 0
|
|
189
|
+
self._on_event = on_event
|
|
190
|
+
self._flow_state_evictions = 0
|
|
191
|
+
# Counter-only: never extend across flow work, callbacks, or delivery.
|
|
192
|
+
self._diagnostics_lock = Lock()
|
|
193
|
+
|
|
194
|
+
def build_scanner():
|
|
195
|
+
primary = TargetMessageScanner(
|
|
196
|
+
self._handle_record,
|
|
197
|
+
self.event_specs,
|
|
198
|
+
message_observer=message_observer,
|
|
199
|
+
)
|
|
200
|
+
if frame_observer is None and stream_observer is None:
|
|
201
|
+
return primary
|
|
202
|
+
tap = (
|
|
203
|
+
FrameCollectorScanner(
|
|
204
|
+
frame_observer,
|
|
205
|
+
known_opcodes=(spec.opcode for spec in self.event_specs),
|
|
206
|
+
)
|
|
207
|
+
if frame_observer is not None
|
|
208
|
+
else None
|
|
209
|
+
)
|
|
210
|
+
return _TeeScanner(primary, tap, stream_observer)
|
|
211
|
+
|
|
212
|
+
self._flow_manager = FlowManager(
|
|
213
|
+
server_ports=server_ports,
|
|
214
|
+
scanner_factory=build_scanner,
|
|
215
|
+
track_flow_generations=True,
|
|
216
|
+
max_flows=_ITEM_MAX_ACTIVE_FLOWS,
|
|
217
|
+
on_flow_eviction=self._count_flow_state_eviction,
|
|
218
|
+
on_flow_close=flow_close_observer,
|
|
219
|
+
idle_timeout=_ITEM_FLOW_IDLE_SECONDS,
|
|
220
|
+
)
|
|
221
|
+
self._seen_event_keys: set[
|
|
222
|
+
tuple[FlowKey, int, int, int, Optional[int], bytes]
|
|
223
|
+
] = set()
|
|
224
|
+
self._seen_event_order: deque[
|
|
225
|
+
tuple[FlowKey, int, int, int, Optional[int], bytes]
|
|
226
|
+
] = deque()
|
|
227
|
+
self._last_raw_message: Optional[bytes] = None
|
|
228
|
+
self._last_raw_message_digest: Optional[bytes] = None
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def server_ports(self) -> frozenset[int]:
|
|
232
|
+
return self._flow_manager.server_ports
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def tcp_gap_resets(self) -> int:
|
|
236
|
+
"""Cumulative reassembly resets caused by missing TCP segments."""
|
|
237
|
+
|
|
238
|
+
return self._flow_manager.tcp_gap_resets
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def flow_state_evictions(self) -> int:
|
|
242
|
+
"""Number of active flow states lost to the defensive resource cap."""
|
|
243
|
+
|
|
244
|
+
with self._diagnostics_lock:
|
|
245
|
+
return self._flow_state_evictions
|
|
246
|
+
|
|
247
|
+
def service_gaps(self, now: float) -> int:
|
|
248
|
+
"""Advance TCP gap deadlines even when no new packet arrives."""
|
|
249
|
+
|
|
250
|
+
return self._flow_manager.service_gaps(now)
|
|
251
|
+
|
|
252
|
+
def _count_flow_state_eviction(self) -> None:
|
|
253
|
+
with self._diagnostics_lock:
|
|
254
|
+
self._flow_state_evictions += 1
|
|
255
|
+
|
|
256
|
+
def process_tcp_segment(
|
|
257
|
+
self,
|
|
258
|
+
*,
|
|
259
|
+
source_ip: str,
|
|
260
|
+
source_port: int,
|
|
261
|
+
destination_ip: str,
|
|
262
|
+
destination_port: int,
|
|
263
|
+
sequence: int,
|
|
264
|
+
payload: bytes,
|
|
265
|
+
timestamp: float,
|
|
266
|
+
syn: bool = False,
|
|
267
|
+
rst: bool = False,
|
|
268
|
+
fin: bool = False,
|
|
269
|
+
) -> None:
|
|
270
|
+
self._flow_manager.process_tcp_segment(
|
|
271
|
+
source_ip=source_ip,
|
|
272
|
+
source_port=source_port,
|
|
273
|
+
destination_ip=destination_ip,
|
|
274
|
+
destination_port=destination_port,
|
|
275
|
+
sequence=sequence,
|
|
276
|
+
payload=payload,
|
|
277
|
+
timestamp=timestamp,
|
|
278
|
+
syn=syn,
|
|
279
|
+
rst=rst,
|
|
280
|
+
fin=fin,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def finish(self) -> None:
|
|
284
|
+
"""Drain per-flow segments still pending at end of capture."""
|
|
285
|
+
self._flow_manager.finish()
|
|
286
|
+
|
|
287
|
+
def _handle_record(self, event: LootEvent, raw_message: bytes) -> None:
|
|
288
|
+
if self._is_duplicate_event(event, raw_message):
|
|
289
|
+
return
|
|
290
|
+
self.events_found += 1
|
|
291
|
+
self._on_event(event, raw_message)
|
|
292
|
+
|
|
293
|
+
def _is_duplicate_event(self, event: LootEvent, raw_message: bytes) -> bool:
|
|
294
|
+
if event.stream_sequence is None:
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
key = (
|
|
298
|
+
event.context.flow,
|
|
299
|
+
event.context.flow_generation,
|
|
300
|
+
event.stream_sequence,
|
|
301
|
+
event.opcode,
|
|
302
|
+
event.record_offset,
|
|
303
|
+
self._raw_message_digest(raw_message),
|
|
304
|
+
)
|
|
305
|
+
if key in self._seen_event_keys:
|
|
306
|
+
return True
|
|
307
|
+
|
|
308
|
+
self._seen_event_keys.add(key)
|
|
309
|
+
self._seen_event_order.append(key)
|
|
310
|
+
while len(self._seen_event_order) > DEDUP_HISTORY_LIMIT:
|
|
311
|
+
expired_key = self._seen_event_order.popleft()
|
|
312
|
+
self._seen_event_keys.discard(expired_key)
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
def _raw_message_digest(self, raw_message: bytes) -> bytes:
|
|
316
|
+
# TargetMessageScanner delivers every record from one batch with the
|
|
317
|
+
# same immutable bytes object. Cache by identity so a 72-record load
|
|
318
|
+
# hashes its message once rather than 72 times.
|
|
319
|
+
if (
|
|
320
|
+
raw_message is self._last_raw_message
|
|
321
|
+
and self._last_raw_message_digest is not None
|
|
322
|
+
):
|
|
323
|
+
return self._last_raw_message_digest
|
|
324
|
+
digest = hashlib.blake2b(raw_message, digest_size=16).digest()
|
|
325
|
+
self._last_raw_message = raw_message
|
|
326
|
+
self._last_raw_message_digest = digest
|
|
327
|
+
return digest
|