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
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