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
|
@@ -0,0 +1,1599 @@
|
|
|
1
|
+
"""Fail-closed worker/manual classification for storage events.
|
|
2
|
+
|
|
3
|
+
Manual deposits carry a calibrated source-stack decrement immediately before
|
|
4
|
+
the storage delta. Worker deposits carry two ordered companion frames within a
|
|
5
|
+
bounded lookahead window that repeat the same high-entropy token from the
|
|
6
|
+
delta's pre-record prefix. The relationship survived a patch that changed
|
|
7
|
+
every involved opcode, length, and token offset, so classification does not
|
|
8
|
+
trust raw opcode values.
|
|
9
|
+
|
|
10
|
+
Missing or conflicting evidence leaves ``source=None`` on an already-live
|
|
11
|
+
``storage_delta``. A neutral ``storage_record`` is promoted to a live delta
|
|
12
|
+
only when the same independent evidence proves a manual or worker mutation;
|
|
13
|
+
otherwise it remains neutral. Storage context bytes identify the endpoint,
|
|
14
|
+
not whether player inventory or worker production supplied the item.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import dataclasses
|
|
20
|
+
from collections import deque
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from threading import RLock
|
|
23
|
+
from typing import Callable, Iterable, Optional
|
|
24
|
+
|
|
25
|
+
from ._protocol import MAX_TARGET_MESSAGE_LENGTH, BDOFrame, FlowKey, PacketContext
|
|
26
|
+
from .events import BDOEvent
|
|
27
|
+
from .origin_learning import (
|
|
28
|
+
CompanionObservation,
|
|
29
|
+
TOKEN_WIDTH,
|
|
30
|
+
discover_companion_observation,
|
|
31
|
+
)
|
|
32
|
+
from .profiles import OriginCompanionFamily
|
|
33
|
+
|
|
34
|
+
ORIGIN_WORKER = "worker"
|
|
35
|
+
ORIGIN_MANUAL = "manual"
|
|
36
|
+
ORIGIN_UNKNOWN = "unknown"
|
|
37
|
+
SOURCE_WORKER_PRODUCTION = "Worker Production"
|
|
38
|
+
SOURCE_PLAYER_INVENTORY = "Player Inventory"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class DecrementSpec:
|
|
43
|
+
"""One source-stack-decrement shape to test candidates against."""
|
|
44
|
+
|
|
45
|
+
opcode: int
|
|
46
|
+
min_message_length: int
|
|
47
|
+
quantity_offset: int
|
|
48
|
+
source_instance_offset: Optional[int] = None
|
|
49
|
+
repeat_stride: Optional[int] = None
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
if (
|
|
53
|
+
isinstance(self.opcode, bool)
|
|
54
|
+
or not isinstance(self.opcode, int)
|
|
55
|
+
or not 0 <= self.opcode <= 0xFFFF
|
|
56
|
+
):
|
|
57
|
+
raise ValueError("decrement opcode must be a uint16")
|
|
58
|
+
if (
|
|
59
|
+
isinstance(self.min_message_length, bool)
|
|
60
|
+
or not isinstance(self.min_message_length, int)
|
|
61
|
+
or self.min_message_length < 5
|
|
62
|
+
):
|
|
63
|
+
raise ValueError("decrement minimum message length must be at least 5")
|
|
64
|
+
if (
|
|
65
|
+
isinstance(self.quantity_offset, bool)
|
|
66
|
+
or not isinstance(self.quantity_offset, int)
|
|
67
|
+
or not 0 <= self.quantity_offset <= self.min_message_length - 4
|
|
68
|
+
):
|
|
69
|
+
raise ValueError("decrement quantity offset must fit its minimum shape")
|
|
70
|
+
if self.source_instance_offset is not None and (
|
|
71
|
+
isinstance(self.source_instance_offset, bool)
|
|
72
|
+
or not isinstance(self.source_instance_offset, int)
|
|
73
|
+
or self.source_instance_offset < 0
|
|
74
|
+
or self.source_instance_offset + 8 > self.min_message_length
|
|
75
|
+
):
|
|
76
|
+
raise ValueError(
|
|
77
|
+
"decrement source instance offset must fit its minimum shape"
|
|
78
|
+
)
|
|
79
|
+
if self.repeat_stride is not None and (
|
|
80
|
+
isinstance(self.repeat_stride, bool)
|
|
81
|
+
or not isinstance(self.repeat_stride, int)
|
|
82
|
+
or self.repeat_stride <= 0
|
|
83
|
+
):
|
|
84
|
+
raise ValueError("decrement repeat stride must be positive or None")
|
|
85
|
+
if self.repeat_stride is not None:
|
|
86
|
+
prefix_length = self.min_message_length - self.repeat_stride
|
|
87
|
+
if (
|
|
88
|
+
prefix_length < 5
|
|
89
|
+
or self.quantity_offset < prefix_length
|
|
90
|
+
or (
|
|
91
|
+
self.source_instance_offset is not None
|
|
92
|
+
and self.source_instance_offset < prefix_length
|
|
93
|
+
)
|
|
94
|
+
):
|
|
95
|
+
raise ValueError(
|
|
96
|
+
"decrement repeat stride must place repeated fields "
|
|
97
|
+
"after the frame prefix"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class _ManualDecrementMatch:
|
|
103
|
+
"""Strength and anchored geometry of one manual-deposit signal."""
|
|
104
|
+
|
|
105
|
+
opcode: int
|
|
106
|
+
message_length: int
|
|
107
|
+
quantity_offset: int
|
|
108
|
+
source_instance_offset: Optional[int]
|
|
109
|
+
match_kind: str
|
|
110
|
+
confidence: str
|
|
111
|
+
instance_matches_destination: Optional[bool]
|
|
112
|
+
|
|
113
|
+
def to_dict(self) -> dict[str, object]:
|
|
114
|
+
output: dict[str, object] = {
|
|
115
|
+
"opcode": f"0x{self.opcode:04X}",
|
|
116
|
+
"message_length": self.message_length,
|
|
117
|
+
"quantity_offset": self.quantity_offset,
|
|
118
|
+
"match_kind": self.match_kind,
|
|
119
|
+
"confidence": self.confidence,
|
|
120
|
+
}
|
|
121
|
+
if self.source_instance_offset is not None:
|
|
122
|
+
output["source_instance_offset"] = self.source_instance_offset
|
|
123
|
+
if self.instance_matches_destination is not None:
|
|
124
|
+
output["instance_matches_destination"] = (
|
|
125
|
+
self.instance_matches_destination
|
|
126
|
+
)
|
|
127
|
+
return output
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class _PendingDeposit:
|
|
132
|
+
event: BDOEvent
|
|
133
|
+
flow: FlowKey
|
|
134
|
+
stream_sequence: Optional[int]
|
|
135
|
+
timestamp: float
|
|
136
|
+
matching_decrement: bool
|
|
137
|
+
events: tuple[BDOEvent, ...] = ()
|
|
138
|
+
matching_decrement_record_indexes: tuple[int, ...] = ()
|
|
139
|
+
manual_decrement_matches: tuple[
|
|
140
|
+
tuple[int, _ManualDecrementMatch], ...
|
|
141
|
+
] = ()
|
|
142
|
+
frames_after: int = 0
|
|
143
|
+
end_sequence: Optional[int] = None
|
|
144
|
+
companion_observation: Optional[CompanionObservation] = None
|
|
145
|
+
delta_message: Optional[bytes] = None
|
|
146
|
+
delta_prefix_end: Optional[int] = None
|
|
147
|
+
candidate_observations: dict[
|
|
148
|
+
tuple[int, int, int, int, int], CompanionObservation
|
|
149
|
+
] = field(default_factory=dict)
|
|
150
|
+
awaiting_storage_boundaries: frozenset[int] = frozenset()
|
|
151
|
+
finalized: bool = False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True)
|
|
155
|
+
class _StreamSpan:
|
|
156
|
+
start: int
|
|
157
|
+
data: bytes
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def end(self) -> int:
|
|
161
|
+
return self.start + len(self.data)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True)
|
|
165
|
+
class _CompanionScan:
|
|
166
|
+
observations: tuple[CompanionObservation, ...]
|
|
167
|
+
complete: bool
|
|
168
|
+
immediate_family_keys: frozenset[tuple[int, int, int, int, int]] = frozenset()
|
|
169
|
+
awaiting_storage_boundaries: frozenset[int] = frozenset()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class _StagedNeutralBatch:
|
|
174
|
+
"""Records from one unfamiliar-mode wrapper awaiting atomic routing."""
|
|
175
|
+
|
|
176
|
+
expected_count: int
|
|
177
|
+
entries: dict[int, tuple[BDOEvent, Optional[bytes]]] = field(
|
|
178
|
+
default_factory=dict
|
|
179
|
+
)
|
|
180
|
+
invalid: bool = False
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class DepositOriginTracker:
|
|
184
|
+
"""Correlate live-or-neutral storage records with origin evidence."""
|
|
185
|
+
|
|
186
|
+
# Worker companions can be interleaved with ordinary inventory and
|
|
187
|
+
# storage traffic. Keep a modestly wider message horizon than the original
|
|
188
|
+
# eight-frame window, while the independent time and pending-count bounds
|
|
189
|
+
# below prevent unbounded retention or scanning.
|
|
190
|
+
LOOKAHEAD_FRAMES = 32
|
|
191
|
+
MAX_PENDING_OPERATIONS_PER_FLOW = 64
|
|
192
|
+
MAX_PENDING_OPERATIONS_TOTAL = 4096
|
|
193
|
+
STALE_SECONDS = 2.0
|
|
194
|
+
BACKWARD_WINDOW = 16
|
|
195
|
+
STREAM_SPAN_HISTORY_LIMIT = 64
|
|
196
|
+
MANUAL_LOOKBACK_FRAMES = 2
|
|
197
|
+
OBSERVATION_HISTORY_LIMIT = 4096
|
|
198
|
+
COMPANION_PAIR_HISTORY_LIMIT = 4096
|
|
199
|
+
RECORD_BOUNDARY_HISTORY_LIMIT = 4096
|
|
200
|
+
RUNTIME_CONFIRMED_FAMILY_LIMIT = 4096
|
|
201
|
+
FAMILY_CONFIRMATION_OBSERVATIONS = 2
|
|
202
|
+
|
|
203
|
+
def __init__(
|
|
204
|
+
self,
|
|
205
|
+
*,
|
|
206
|
+
decrement_specs: Iterable[DecrementSpec],
|
|
207
|
+
emit: Callable[[BDOEvent], None],
|
|
208
|
+
origin_observer: Optional[Callable[[CompanionObservation], object]] = None,
|
|
209
|
+
known_companion_families: Iterable[OriginCompanionFamily] = (),
|
|
210
|
+
storage_delta_opcodes: Iterable[int] = (),
|
|
211
|
+
) -> None:
|
|
212
|
+
known_families = tuple(known_companion_families)
|
|
213
|
+
self._decrement_specs: dict[int, list[DecrementSpec]] = {}
|
|
214
|
+
for spec in decrement_specs:
|
|
215
|
+
matches = self._decrement_specs.setdefault(spec.opcode, [])
|
|
216
|
+
if spec not in matches:
|
|
217
|
+
matches.append(spec)
|
|
218
|
+
self._emit_callback = emit
|
|
219
|
+
self._origin_observer = origin_observer
|
|
220
|
+
# Live capture mutates correlation state from its decoder worker while
|
|
221
|
+
# the application consumer can call ``flush_stale``. Keep every state
|
|
222
|
+
# transition under one lock and queue callbacks in that same order.
|
|
223
|
+
# Exactly one caller owns outbox dispatch at a time, but callbacks run
|
|
224
|
+
# without *any* tracker lock held: a callback may need a session lock
|
|
225
|
+
# while another thread holding that session lock enters the tracker.
|
|
226
|
+
self._state_lock = RLock()
|
|
227
|
+
self._outbox: deque[tuple[str, object]] = deque()
|
|
228
|
+
self._dispatching = False
|
|
229
|
+
self._known_companion_families = {
|
|
230
|
+
family.family_key for family in known_families
|
|
231
|
+
}
|
|
232
|
+
self._confirmed_companion_families = set(self._known_companion_families)
|
|
233
|
+
self._family_confirmation = {
|
|
234
|
+
family_key: "profile" for family_key in self._known_companion_families
|
|
235
|
+
}
|
|
236
|
+
self._runtime_confirmed_family_order: deque[
|
|
237
|
+
tuple[int, int, int, int, int]
|
|
238
|
+
] = deque()
|
|
239
|
+
self._family_chains: dict[
|
|
240
|
+
tuple[int, int, int, int, int],
|
|
241
|
+
set[tuple[FlowKey, Optional[int]]],
|
|
242
|
+
] = {}
|
|
243
|
+
self._family_chain_order: deque[
|
|
244
|
+
tuple[
|
|
245
|
+
tuple[int, int, int, int, int],
|
|
246
|
+
tuple[FlowKey, Optional[int]],
|
|
247
|
+
]
|
|
248
|
+
] = deque()
|
|
249
|
+
self._storage_delta_opcodes: set[int] = {
|
|
250
|
+
family.delta_opcode for family in known_families
|
|
251
|
+
}
|
|
252
|
+
self._storage_delta_opcodes.update(storage_delta_opcodes)
|
|
253
|
+
self._recent: dict[FlowKey, deque[BDOFrame]] = {}
|
|
254
|
+
self._stream_spans: dict[FlowKey, deque[_StreamSpan]] = {}
|
|
255
|
+
self._pending: list[_PendingDeposit] = []
|
|
256
|
+
self._staged_neutral_batches: dict[
|
|
257
|
+
tuple[FlowKey, Optional[int], Optional[int], Optional[int], float],
|
|
258
|
+
_StagedNeutralBatch,
|
|
259
|
+
] = {}
|
|
260
|
+
self._observed_chains: set[
|
|
261
|
+
tuple[
|
|
262
|
+
FlowKey,
|
|
263
|
+
Optional[int],
|
|
264
|
+
tuple[int, int, int, int, int],
|
|
265
|
+
]
|
|
266
|
+
] = set()
|
|
267
|
+
self._observed_chain_order: deque[
|
|
268
|
+
tuple[
|
|
269
|
+
FlowKey,
|
|
270
|
+
Optional[int],
|
|
271
|
+
tuple[int, int, int, int, int],
|
|
272
|
+
]
|
|
273
|
+
] = deque()
|
|
274
|
+
self._contested_companion_pairs: set[
|
|
275
|
+
tuple[FlowKey, int, int]
|
|
276
|
+
] = set()
|
|
277
|
+
self._contested_companion_pair_order: deque[
|
|
278
|
+
tuple[FlowKey, int, int]
|
|
279
|
+
] = deque()
|
|
280
|
+
self._companion_contest_overflow_flows: set[FlowKey] = set()
|
|
281
|
+
self._first_record_boundaries: dict[tuple[FlowKey, int], int] = {}
|
|
282
|
+
self._first_record_boundary_order: deque[tuple[FlowKey, int]] = deque()
|
|
283
|
+
|
|
284
|
+
# --- frame stream ---
|
|
285
|
+
|
|
286
|
+
def observe_stream(self, data: bytes, context: PacketContext) -> None:
|
|
287
|
+
"""Observe reassembled bytes as one serialized tracker operation."""
|
|
288
|
+
with self._state_lock:
|
|
289
|
+
self._observe_stream_locked(data, context)
|
|
290
|
+
self._drain_outbox()
|
|
291
|
+
|
|
292
|
+
def _observe_stream_locked(self, data: bytes, context: PacketContext) -> None:
|
|
293
|
+
"""Retain raw reassembled bytes for alignment-independent correlation.
|
|
294
|
+
|
|
295
|
+
The generic frame tap can begin in the middle of an application frame
|
|
296
|
+
when live capture starts on an established TCP connection. Target
|
|
297
|
+
opcodes can still resynchronize in that situation, so worker evidence
|
|
298
|
+
must not depend exclusively on the generic tap finding a boundary.
|
|
299
|
+
"""
|
|
300
|
+
if not data or context.stream_start is None:
|
|
301
|
+
return
|
|
302
|
+
span = _StreamSpan(context.stream_start, bytes(data))
|
|
303
|
+
window = self._stream_spans.setdefault(
|
|
304
|
+
context.flow,
|
|
305
|
+
deque(maxlen=self.STREAM_SPAN_HISTORY_LIMIT),
|
|
306
|
+
)
|
|
307
|
+
window.append(span)
|
|
308
|
+
|
|
309
|
+
if not self._pending:
|
|
310
|
+
return
|
|
311
|
+
still: list[_PendingDeposit] = []
|
|
312
|
+
for pending in self._pending:
|
|
313
|
+
if pending.finalized:
|
|
314
|
+
continue
|
|
315
|
+
if pending.flow != context.flow:
|
|
316
|
+
still.append(pending)
|
|
317
|
+
continue
|
|
318
|
+
if pending.end_sequence is not None and span.end <= pending.end_sequence:
|
|
319
|
+
still.append(pending)
|
|
320
|
+
continue
|
|
321
|
+
if context.timestamp - pending.timestamp > self.STALE_SECONDS:
|
|
322
|
+
self._close_pending(pending)
|
|
323
|
+
continue
|
|
324
|
+
if not self._resolve_companions(pending):
|
|
325
|
+
still.append(pending)
|
|
326
|
+
self._pending = still
|
|
327
|
+
|
|
328
|
+
def observe_frame(self, frame: BDOFrame) -> None:
|
|
329
|
+
"""Observe one generic frame as one serialized tracker operation."""
|
|
330
|
+
with self._state_lock:
|
|
331
|
+
self._observe_frame_locked(frame)
|
|
332
|
+
self._drain_outbox()
|
|
333
|
+
|
|
334
|
+
def _observe_frame_locked(self, frame: BDOFrame) -> None:
|
|
335
|
+
window = self._recent.setdefault(
|
|
336
|
+
frame.context.flow,
|
|
337
|
+
deque(maxlen=self.BACKWARD_WINDOW),
|
|
338
|
+
)
|
|
339
|
+
window.append(frame)
|
|
340
|
+
if not self._pending:
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
still: list[_PendingDeposit] = []
|
|
344
|
+
for pending in self._pending:
|
|
345
|
+
if pending.finalized:
|
|
346
|
+
continue
|
|
347
|
+
if pending.flow != frame.context.flow:
|
|
348
|
+
if frame.context.timestamp - pending.timestamp > self.STALE_SECONDS:
|
|
349
|
+
self._close_pending(pending)
|
|
350
|
+
else:
|
|
351
|
+
still.append(pending)
|
|
352
|
+
continue
|
|
353
|
+
if not self._frame_is_after(frame, pending):
|
|
354
|
+
still.append(pending)
|
|
355
|
+
continue
|
|
356
|
+
if frame.context.timestamp - pending.timestamp > self.STALE_SECONDS:
|
|
357
|
+
self._close_pending(pending)
|
|
358
|
+
continue
|
|
359
|
+
if self._resolve_companions(pending):
|
|
360
|
+
continue
|
|
361
|
+
pending.frames_after += 1
|
|
362
|
+
if (
|
|
363
|
+
not pending.awaiting_storage_boundaries
|
|
364
|
+
and pending.frames_after >= self.LOOKAHEAD_FRAMES
|
|
365
|
+
):
|
|
366
|
+
self._close_pending(pending)
|
|
367
|
+
else:
|
|
368
|
+
still.append(pending)
|
|
369
|
+
self._pending = still
|
|
370
|
+
|
|
371
|
+
@staticmethod
|
|
372
|
+
def _frame_is_after(frame: BDOFrame, pending: _PendingDeposit) -> bool:
|
|
373
|
+
if pending.stream_sequence is None or frame.stream_sequence is None:
|
|
374
|
+
return True
|
|
375
|
+
return frame.stream_sequence > pending.stream_sequence
|
|
376
|
+
|
|
377
|
+
# --- event stream ---
|
|
378
|
+
|
|
379
|
+
def register(
|
|
380
|
+
self,
|
|
381
|
+
event: BDOEvent,
|
|
382
|
+
raw_message: Optional[bytes] = None,
|
|
383
|
+
) -> None:
|
|
384
|
+
"""Defer one live-or-neutral storage event until evidence resolves."""
|
|
385
|
+
with self._state_lock:
|
|
386
|
+
self._register_locked(event, raw_message)
|
|
387
|
+
self._drain_outbox()
|
|
388
|
+
|
|
389
|
+
def _register_locked(
|
|
390
|
+
self,
|
|
391
|
+
event: BDOEvent,
|
|
392
|
+
raw_message: Optional[bytes] = None,
|
|
393
|
+
) -> None:
|
|
394
|
+
flow = FlowKey(
|
|
395
|
+
source_ip=event.flow.source_ip,
|
|
396
|
+
source_port=event.flow.source_port,
|
|
397
|
+
destination_ip=event.flow.destination_ip,
|
|
398
|
+
destination_port=event.flow.destination_port,
|
|
399
|
+
)
|
|
400
|
+
raw_sequence = event.extra.get("stream_sequence")
|
|
401
|
+
stream_sequence = raw_sequence if isinstance(raw_sequence, int) else None
|
|
402
|
+
|
|
403
|
+
if self._stage_neutral_batch(
|
|
404
|
+
event,
|
|
405
|
+
raw_message,
|
|
406
|
+
flow=flow,
|
|
407
|
+
stream_sequence=stream_sequence,
|
|
408
|
+
):
|
|
409
|
+
return
|
|
410
|
+
self._register_group(
|
|
411
|
+
(event,),
|
|
412
|
+
raw_message,
|
|
413
|
+
flow=flow,
|
|
414
|
+
stream_sequence=stream_sequence,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
def _stage_neutral_batch(
|
|
418
|
+
self,
|
|
419
|
+
event: BDOEvent,
|
|
420
|
+
raw_message: Optional[bytes],
|
|
421
|
+
*,
|
|
422
|
+
flow: FlowKey,
|
|
423
|
+
stream_sequence: Optional[int],
|
|
424
|
+
) -> bool:
|
|
425
|
+
"""Buffer unknown-operation multi-record wrappers as one decision."""
|
|
426
|
+
record_index = event.record_index
|
|
427
|
+
record_count = event.record_count
|
|
428
|
+
if (
|
|
429
|
+
event.event_type != "storage_record"
|
|
430
|
+
or isinstance(record_index, bool)
|
|
431
|
+
or not isinstance(record_index, int)
|
|
432
|
+
or isinstance(record_count, bool)
|
|
433
|
+
or not isinstance(record_count, int)
|
|
434
|
+
or record_count <= 1
|
|
435
|
+
or not 1 <= record_index <= record_count
|
|
436
|
+
):
|
|
437
|
+
return False
|
|
438
|
+
|
|
439
|
+
key = (
|
|
440
|
+
flow,
|
|
441
|
+
stream_sequence,
|
|
442
|
+
event.opcode,
|
|
443
|
+
event.message_length,
|
|
444
|
+
event.timestamp,
|
|
445
|
+
)
|
|
446
|
+
staged = self._staged_neutral_batches.get(key)
|
|
447
|
+
if staged is None:
|
|
448
|
+
staged = _StagedNeutralBatch(expected_count=record_count)
|
|
449
|
+
self._staged_neutral_batches[key] = staged
|
|
450
|
+
elif staged.expected_count != record_count:
|
|
451
|
+
staged.invalid = True
|
|
452
|
+
|
|
453
|
+
previous = staged.entries.get(record_index)
|
|
454
|
+
if previous is not None:
|
|
455
|
+
if previous[0] != event or previous[1] != raw_message:
|
|
456
|
+
staged.invalid = True
|
|
457
|
+
else:
|
|
458
|
+
staged.entries[record_index] = (event, raw_message)
|
|
459
|
+
|
|
460
|
+
if len(staged.entries) < staged.expected_count:
|
|
461
|
+
return True
|
|
462
|
+
|
|
463
|
+
self._staged_neutral_batches.pop(key, None)
|
|
464
|
+
expected_indexes = set(range(1, staged.expected_count + 1))
|
|
465
|
+
if staged.invalid or set(staged.entries) != expected_indexes:
|
|
466
|
+
self._emit_neutral_entries(staged)
|
|
467
|
+
return True
|
|
468
|
+
|
|
469
|
+
ordered = tuple(staged.entries[index] for index in sorted(staged.entries))
|
|
470
|
+
messages = {entry[1] for entry in ordered if entry[1] is not None}
|
|
471
|
+
if len(messages) > 1:
|
|
472
|
+
self._emit_neutral_entries(staged)
|
|
473
|
+
return True
|
|
474
|
+
group_raw = next(iter(messages), None)
|
|
475
|
+
self._register_group(
|
|
476
|
+
tuple(entry[0] for entry in ordered),
|
|
477
|
+
group_raw,
|
|
478
|
+
flow=flow,
|
|
479
|
+
stream_sequence=stream_sequence,
|
|
480
|
+
)
|
|
481
|
+
return True
|
|
482
|
+
|
|
483
|
+
def _emit_neutral_entries(self, staged: _StagedNeutralBatch) -> None:
|
|
484
|
+
for record_index in sorted(staged.entries):
|
|
485
|
+
self._queue_emit(staged.entries[record_index][0])
|
|
486
|
+
|
|
487
|
+
def _register_group(
|
|
488
|
+
self,
|
|
489
|
+
events: tuple[BDOEvent, ...],
|
|
490
|
+
raw_message: Optional[bytes],
|
|
491
|
+
*,
|
|
492
|
+
flow: FlowKey,
|
|
493
|
+
stream_sequence: Optional[int],
|
|
494
|
+
) -> None:
|
|
495
|
+
"""Register one raw storage message, emitting all records atomically."""
|
|
496
|
+
event = events[0]
|
|
497
|
+
if event.opcode is not None:
|
|
498
|
+
self._storage_delta_opcodes.add(event.opcode)
|
|
499
|
+
end_sequence = None
|
|
500
|
+
if stream_sequence is not None and event.message_length is not None:
|
|
501
|
+
end_sequence = stream_sequence + event.message_length
|
|
502
|
+
delta_message = None
|
|
503
|
+
if raw_message is not None and event.message_length is not None:
|
|
504
|
+
candidate = bytes(raw_message)
|
|
505
|
+
if (
|
|
506
|
+
len(candidate) == event.message_length
|
|
507
|
+
and len(candidate) >= 5
|
|
508
|
+
and int.from_bytes(candidate[0:2], "little") == len(candidate)
|
|
509
|
+
):
|
|
510
|
+
delta_message = candidate
|
|
511
|
+
record_boundaries = [
|
|
512
|
+
candidate.record_offset
|
|
513
|
+
for candidate in events
|
|
514
|
+
if isinstance(candidate.record_offset, int)
|
|
515
|
+
and not isinstance(candidate.record_offset, bool)
|
|
516
|
+
and candidate.message_length is not None
|
|
517
|
+
and 5 + 8 <= candidate.record_offset <= candidate.message_length
|
|
518
|
+
]
|
|
519
|
+
delta_prefix_end = min(record_boundaries) if record_boundaries else None
|
|
520
|
+
if delta_prefix_end is not None and stream_sequence is not None:
|
|
521
|
+
boundary_key = (flow, stream_sequence)
|
|
522
|
+
previous = self._first_record_boundaries.get(boundary_key)
|
|
523
|
+
if previous is None:
|
|
524
|
+
if (
|
|
525
|
+
len(self._first_record_boundary_order)
|
|
526
|
+
>= self.RECORD_BOUNDARY_HISTORY_LIMIT
|
|
527
|
+
):
|
|
528
|
+
expired = self._first_record_boundary_order.popleft()
|
|
529
|
+
self._first_record_boundaries.pop(expired, None)
|
|
530
|
+
self._first_record_boundary_order.append(boundary_key)
|
|
531
|
+
self._first_record_boundaries[boundary_key] = delta_prefix_end
|
|
532
|
+
else:
|
|
533
|
+
delta_prefix_end = min(previous, delta_prefix_end)
|
|
534
|
+
self._first_record_boundaries[boundary_key] = delta_prefix_end
|
|
535
|
+
# The raw stream/frame observers run before target records are
|
|
536
|
+
# decoded. An older operation may therefore have crossed this
|
|
537
|
+
# wrapper without yet knowing where its transaction prefix ends.
|
|
538
|
+
# Retry it now that the authoritative first-record boundary is
|
|
539
|
+
# available; never substitute the entire record body as a prefix.
|
|
540
|
+
self._retry_boundary_waiters(flow, stream_sequence)
|
|
541
|
+
manual_matches: list[tuple[int, _ManualDecrementMatch]] = []
|
|
542
|
+
for index, candidate_event in enumerate(events, 1):
|
|
543
|
+
match = self._matching_decrement(
|
|
544
|
+
flow,
|
|
545
|
+
stream_sequence,
|
|
546
|
+
candidate_event,
|
|
547
|
+
)
|
|
548
|
+
if match is not None:
|
|
549
|
+
manual_matches.append(
|
|
550
|
+
(candidate_event.record_index or index, match)
|
|
551
|
+
)
|
|
552
|
+
matching_indexes = tuple(index for index, _match in manual_matches)
|
|
553
|
+
pending = _PendingDeposit(
|
|
554
|
+
event=event,
|
|
555
|
+
flow=flow,
|
|
556
|
+
stream_sequence=stream_sequence,
|
|
557
|
+
timestamp=event.timestamp,
|
|
558
|
+
matching_decrement=bool(matching_indexes),
|
|
559
|
+
events=events,
|
|
560
|
+
matching_decrement_record_indexes=matching_indexes,
|
|
561
|
+
manual_decrement_matches=tuple(manual_matches),
|
|
562
|
+
end_sequence=end_sequence,
|
|
563
|
+
delta_message=delta_message,
|
|
564
|
+
delta_prefix_end=delta_prefix_end,
|
|
565
|
+
)
|
|
566
|
+
if self._resolve_companions(pending):
|
|
567
|
+
return
|
|
568
|
+
|
|
569
|
+
# The frame tap runs before event decoding. Credit already observed
|
|
570
|
+
# frames when the full TCP segment contained the delta and lookahead.
|
|
571
|
+
for frame in self._recent.get(flow, ()):
|
|
572
|
+
if self._frame_is_after(frame, pending):
|
|
573
|
+
pending.frames_after += 1
|
|
574
|
+
if (
|
|
575
|
+
pending.frames_after >= self.LOOKAHEAD_FRAMES
|
|
576
|
+
and not pending.awaiting_storage_boundaries
|
|
577
|
+
):
|
|
578
|
+
self._close_pending(pending)
|
|
579
|
+
else:
|
|
580
|
+
self._append_pending(pending)
|
|
581
|
+
|
|
582
|
+
def _retry_boundary_waiters(
|
|
583
|
+
self,
|
|
584
|
+
flow: FlowKey,
|
|
585
|
+
stream_sequence: int,
|
|
586
|
+
) -> None:
|
|
587
|
+
"""Retry older operations deferred on one crossed storage wrapper."""
|
|
588
|
+
|
|
589
|
+
still: list[_PendingDeposit] = []
|
|
590
|
+
for pending in self._pending:
|
|
591
|
+
if pending.finalized:
|
|
592
|
+
continue
|
|
593
|
+
if (
|
|
594
|
+
pending.flow != flow
|
|
595
|
+
or stream_sequence not in pending.awaiting_storage_boundaries
|
|
596
|
+
):
|
|
597
|
+
still.append(pending)
|
|
598
|
+
continue
|
|
599
|
+
if self._resolve_companions(pending):
|
|
600
|
+
continue
|
|
601
|
+
if (
|
|
602
|
+
pending.frames_after >= self.LOOKAHEAD_FRAMES
|
|
603
|
+
and not pending.awaiting_storage_boundaries
|
|
604
|
+
):
|
|
605
|
+
self._close_pending(pending)
|
|
606
|
+
continue
|
|
607
|
+
still.append(pending)
|
|
608
|
+
self._pending = [entry for entry in still if not entry.finalized]
|
|
609
|
+
|
|
610
|
+
def _append_pending(self, pending: _PendingDeposit) -> None:
|
|
611
|
+
"""Retain one unresolved operation under a hard operation bound."""
|
|
612
|
+
|
|
613
|
+
self._pending = [entry for entry in self._pending if not entry.finalized]
|
|
614
|
+
pending_key = self._pending_operation_key(pending)
|
|
615
|
+
operation_keys = self._pending_operation_keys()
|
|
616
|
+
flow_operation_keys = {
|
|
617
|
+
key for key in operation_keys if key[0] == pending.flow
|
|
618
|
+
}
|
|
619
|
+
while pending_key not in operation_keys and (
|
|
620
|
+
len(flow_operation_keys) >= self.MAX_PENDING_OPERATIONS_PER_FLOW
|
|
621
|
+
or len(operation_keys) >= self.MAX_PENDING_OPERATIONS_TOTAL
|
|
622
|
+
):
|
|
623
|
+
if len(flow_operation_keys) >= self.MAX_PENDING_OPERATIONS_PER_FLOW:
|
|
624
|
+
oldest = next(
|
|
625
|
+
entry for entry in self._pending if entry.flow == pending.flow
|
|
626
|
+
)
|
|
627
|
+
else:
|
|
628
|
+
oldest = self._pending[0]
|
|
629
|
+
oldest_key = self._pending_operation_key(oldest)
|
|
630
|
+
oldest_group = [
|
|
631
|
+
entry
|
|
632
|
+
for entry in self._pending
|
|
633
|
+
if self._pending_operation_key(entry) == oldest_key
|
|
634
|
+
]
|
|
635
|
+
self._pending = [
|
|
636
|
+
entry
|
|
637
|
+
for entry in self._pending
|
|
638
|
+
if self._pending_operation_key(entry) != oldest_key
|
|
639
|
+
]
|
|
640
|
+
for oldest in oldest_group:
|
|
641
|
+
self._evict_pending_fail_closed(oldest)
|
|
642
|
+
self._pending = [
|
|
643
|
+
entry for entry in self._pending if not entry.finalized
|
|
644
|
+
]
|
|
645
|
+
operation_keys = self._pending_operation_keys()
|
|
646
|
+
flow_operation_keys = {
|
|
647
|
+
key for key in operation_keys if key[0] == pending.flow
|
|
648
|
+
}
|
|
649
|
+
self._pending.append(pending)
|
|
650
|
+
|
|
651
|
+
def _evict_pending_fail_closed(self, pending: _PendingDeposit) -> None:
|
|
652
|
+
"""Finalize under resource pressure without trusting partial chains."""
|
|
653
|
+
|
|
654
|
+
if pending.finalized:
|
|
655
|
+
return
|
|
656
|
+
pending.candidate_observations.clear()
|
|
657
|
+
pending.companion_observation = None
|
|
658
|
+
pending.awaiting_storage_boundaries = frozenset()
|
|
659
|
+
self._finalize(pending)
|
|
660
|
+
|
|
661
|
+
def _pending_operation_keys(
|
|
662
|
+
self,
|
|
663
|
+
) -> set[tuple[FlowKey, Optional[int], Optional[int], Optional[int], float]]:
|
|
664
|
+
return {self._pending_operation_key(entry) for entry in self._pending}
|
|
665
|
+
|
|
666
|
+
@staticmethod
|
|
667
|
+
def _pending_operation_key(
|
|
668
|
+
pending: _PendingDeposit,
|
|
669
|
+
) -> tuple[FlowKey, Optional[int], Optional[int], Optional[int], float]:
|
|
670
|
+
return (
|
|
671
|
+
pending.flow,
|
|
672
|
+
pending.stream_sequence,
|
|
673
|
+
pending.event.opcode,
|
|
674
|
+
pending.event.message_length,
|
|
675
|
+
pending.timestamp,
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
# --- anchored structural companion scan ---
|
|
679
|
+
|
|
680
|
+
def _read_span(self, flow: FlowKey, start: int, length: int) -> Optional[bytes]:
|
|
681
|
+
"""Read stream bytes ``[start, start + length)`` from frame spans."""
|
|
682
|
+
if length <= 0:
|
|
683
|
+
return None
|
|
684
|
+
output = bytearray()
|
|
685
|
+
position = start
|
|
686
|
+
remaining = length
|
|
687
|
+
while remaining > 0:
|
|
688
|
+
source_start = None
|
|
689
|
+
source_data = None
|
|
690
|
+
# Prefer raw reassembled spans. Iterate newest-first so an overlap
|
|
691
|
+
# or standalone retransmission cannot shadow newer contiguous data.
|
|
692
|
+
for span in reversed(self._stream_spans.get(flow, ())):
|
|
693
|
+
offset = position - span.start
|
|
694
|
+
if 0 <= offset < len(span.data):
|
|
695
|
+
source_start = span.start
|
|
696
|
+
source_data = span.data
|
|
697
|
+
break
|
|
698
|
+
if source_data is None:
|
|
699
|
+
# Unit-level callers and older integrations may only provide
|
|
700
|
+
# generic frames; retain that compatible fallback.
|
|
701
|
+
for frame in reversed(self._recent.get(flow, ())):
|
|
702
|
+
if frame.stream_sequence is None:
|
|
703
|
+
continue
|
|
704
|
+
offset = position - frame.stream_sequence
|
|
705
|
+
if 0 <= offset < len(frame.message):
|
|
706
|
+
source_start = frame.stream_sequence
|
|
707
|
+
source_data = frame.message
|
|
708
|
+
break
|
|
709
|
+
if source_data is None or source_start is None:
|
|
710
|
+
return None
|
|
711
|
+
offset = position - source_start
|
|
712
|
+
chunk = source_data[offset : offset + remaining]
|
|
713
|
+
if not chunk:
|
|
714
|
+
return None
|
|
715
|
+
output += chunk
|
|
716
|
+
position += len(chunk)
|
|
717
|
+
remaining -= len(chunk)
|
|
718
|
+
return bytes(output)
|
|
719
|
+
|
|
720
|
+
def _scan_companions_after(
|
|
721
|
+
self,
|
|
722
|
+
pending: _PendingDeposit,
|
|
723
|
+
) -> Optional[_CompanionScan]:
|
|
724
|
+
"""Scan a bounded message window, skipping unrelated message families."""
|
|
725
|
+
if (
|
|
726
|
+
pending.end_sequence is None
|
|
727
|
+
or pending.stream_sequence is None
|
|
728
|
+
or pending.event.message_length is None
|
|
729
|
+
or pending.delta_prefix_end is None
|
|
730
|
+
):
|
|
731
|
+
return None
|
|
732
|
+
delta_message = pending.delta_message or self._read_span(
|
|
733
|
+
pending.flow, pending.stream_sequence, pending.event.message_length
|
|
734
|
+
)
|
|
735
|
+
if delta_message is None:
|
|
736
|
+
return None
|
|
737
|
+
|
|
738
|
+
position = pending.end_sequence
|
|
739
|
+
following: list[tuple[int, bytes]] = []
|
|
740
|
+
crossed_storage_messages: list[tuple[int, bytes]] = []
|
|
741
|
+
awaiting_storage_boundaries: set[int] = set()
|
|
742
|
+
observations: dict[
|
|
743
|
+
tuple[int, int, int, int, int], CompanionObservation
|
|
744
|
+
] = {}
|
|
745
|
+
immediate: set[tuple[int, int, int, int, int]] = set()
|
|
746
|
+
for message_index in range(self.LOOKAHEAD_FRAMES):
|
|
747
|
+
header = self._read_span(pending.flow, position, 5)
|
|
748
|
+
if header is None:
|
|
749
|
+
return _CompanionScan(
|
|
750
|
+
tuple(observations.values()),
|
|
751
|
+
False,
|
|
752
|
+
frozenset(immediate),
|
|
753
|
+
frozenset(awaiting_storage_boundaries),
|
|
754
|
+
)
|
|
755
|
+
length = int.from_bytes(header[0:2], "little")
|
|
756
|
+
if not 5 <= length <= MAX_TARGET_MESSAGE_LENGTH:
|
|
757
|
+
return _CompanionScan(
|
|
758
|
+
tuple(observations.values()),
|
|
759
|
+
True,
|
|
760
|
+
frozenset(immediate),
|
|
761
|
+
frozenset(awaiting_storage_boundaries),
|
|
762
|
+
)
|
|
763
|
+
message = self._read_span(pending.flow, position, length)
|
|
764
|
+
if message is None:
|
|
765
|
+
return _CompanionScan(
|
|
766
|
+
tuple(observations.values()),
|
|
767
|
+
False,
|
|
768
|
+
frozenset(immediate),
|
|
769
|
+
frozenset(awaiting_storage_boundaries),
|
|
770
|
+
)
|
|
771
|
+
opcode = int.from_bytes(message[3:5], "little")
|
|
772
|
+
if opcode in self._storage_delta_opcodes:
|
|
773
|
+
# A manual or independent storage action can be serialized
|
|
774
|
+
# between a worker delta and its companion pair. Do not use a
|
|
775
|
+
# storage wrapper as a companion, but do continue scanning:
|
|
776
|
+
# the high-entropy token below owns the eventual pair. If the
|
|
777
|
+
# intervening wrapper repeats that same token, ownership is
|
|
778
|
+
# ambiguous and the older candidate fails closed.
|
|
779
|
+
crossed_storage_messages.append((position, message))
|
|
780
|
+
position += length
|
|
781
|
+
continue
|
|
782
|
+
for first_index, (first_sequence, first_message) in enumerate(following):
|
|
783
|
+
observation = discover_companion_observation(
|
|
784
|
+
delta_message=delta_message,
|
|
785
|
+
first_message=first_message,
|
|
786
|
+
second_message=message,
|
|
787
|
+
timestamp=pending.timestamp,
|
|
788
|
+
flow=pending.flow,
|
|
789
|
+
stream_sequence=pending.stream_sequence,
|
|
790
|
+
delta_prefix_end=pending.delta_prefix_end,
|
|
791
|
+
)
|
|
792
|
+
if observation is None:
|
|
793
|
+
continue
|
|
794
|
+
missing_boundaries = {
|
|
795
|
+
sequence
|
|
796
|
+
for sequence, _message in crossed_storage_messages
|
|
797
|
+
if (pending.flow, sequence)
|
|
798
|
+
not in self._first_record_boundaries
|
|
799
|
+
}
|
|
800
|
+
if missing_boundaries:
|
|
801
|
+
# Target decoding will register these authoritative
|
|
802
|
+
# boundaries later in the same scanner pass. Defer this
|
|
803
|
+
# ownership decision instead of searching record bodies.
|
|
804
|
+
awaiting_storage_boundaries.update(missing_boundaries)
|
|
805
|
+
continue
|
|
806
|
+
bounded_storage_messages = (
|
|
807
|
+
(
|
|
808
|
+
sequence,
|
|
809
|
+
storage_message,
|
|
810
|
+
self._first_record_boundaries[(pending.flow, sequence)],
|
|
811
|
+
)
|
|
812
|
+
for sequence, storage_message in crossed_storage_messages
|
|
813
|
+
)
|
|
814
|
+
if not self._observation_has_unique_pending_owner(
|
|
815
|
+
pending,
|
|
816
|
+
observation,
|
|
817
|
+
bounded_storage_messages,
|
|
818
|
+
first_message=first_message,
|
|
819
|
+
second_message=message,
|
|
820
|
+
pair_key=(pending.flow, first_sequence, position),
|
|
821
|
+
):
|
|
822
|
+
continue
|
|
823
|
+
observations.setdefault(observation.family_key, observation)
|
|
824
|
+
if (
|
|
825
|
+
not crossed_storage_messages
|
|
826
|
+
and first_index == 0
|
|
827
|
+
and message_index == 1
|
|
828
|
+
):
|
|
829
|
+
immediate.add(observation.family_key)
|
|
830
|
+
following.append((position, message))
|
|
831
|
+
position += length
|
|
832
|
+
|
|
833
|
+
return _CompanionScan(
|
|
834
|
+
tuple(observations.values()),
|
|
835
|
+
True,
|
|
836
|
+
frozenset(immediate),
|
|
837
|
+
frozenset(awaiting_storage_boundaries),
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
def _observation_has_unique_pending_owner(
|
|
841
|
+
self,
|
|
842
|
+
pending: _PendingDeposit,
|
|
843
|
+
observation: CompanionObservation,
|
|
844
|
+
crossed_storage_messages: Iterable[tuple[int, bytes, int]],
|
|
845
|
+
*,
|
|
846
|
+
first_message: bytes,
|
|
847
|
+
second_message: bytes,
|
|
848
|
+
pair_key: tuple[FlowKey, int, int],
|
|
849
|
+
) -> bool:
|
|
850
|
+
"""Reject a shared token claimed by another overlapping storage op.
|
|
851
|
+
|
|
852
|
+
Companion discovery already proves that the token occurs in this
|
|
853
|
+
pending delta and both companion messages. Crossing a storage wrapper
|
|
854
|
+
is safe only when that wrapper does not repeat the same token and no
|
|
855
|
+
other active pending delta prefix claims it. This keeps overlapping
|
|
856
|
+
operations separable without borrowing a later worker's companions.
|
|
857
|
+
"""
|
|
858
|
+
|
|
859
|
+
if (
|
|
860
|
+
pending.flow in self._companion_contest_overflow_flows
|
|
861
|
+
or pair_key in self._contested_companion_pairs
|
|
862
|
+
):
|
|
863
|
+
return False
|
|
864
|
+
|
|
865
|
+
delta_message = pending.delta_message
|
|
866
|
+
if delta_message is None:
|
|
867
|
+
if (
|
|
868
|
+
pending.stream_sequence is None
|
|
869
|
+
or pending.event.message_length is None
|
|
870
|
+
):
|
|
871
|
+
return False
|
|
872
|
+
delta_message = self._read_span(
|
|
873
|
+
pending.flow,
|
|
874
|
+
pending.stream_sequence,
|
|
875
|
+
pending.event.message_length,
|
|
876
|
+
)
|
|
877
|
+
if delta_message is None:
|
|
878
|
+
return False
|
|
879
|
+
token_offset = observation.token_offsets[0]
|
|
880
|
+
token = delta_message[token_offset : token_offset + TOKEN_WIDTH]
|
|
881
|
+
if len(token) != TOKEN_WIDTH:
|
|
882
|
+
return False
|
|
883
|
+
|
|
884
|
+
competing = False
|
|
885
|
+
for _sequence, message, boundary in crossed_storage_messages:
|
|
886
|
+
if self._message_claims_companion_pair(
|
|
887
|
+
message,
|
|
888
|
+
boundary,
|
|
889
|
+
pending,
|
|
890
|
+
first_message,
|
|
891
|
+
second_message,
|
|
892
|
+
):
|
|
893
|
+
competing = True
|
|
894
|
+
break
|
|
895
|
+
|
|
896
|
+
for other in self._pending:
|
|
897
|
+
if other is pending or other.finalized or other.flow != pending.flow:
|
|
898
|
+
continue
|
|
899
|
+
if (
|
|
900
|
+
pending.stream_sequence is not None
|
|
901
|
+
and other.stream_sequence == pending.stream_sequence
|
|
902
|
+
and other.event.opcode == pending.event.opcode
|
|
903
|
+
and other.event.message_length == pending.event.message_length
|
|
904
|
+
and other.timestamp == pending.timestamp
|
|
905
|
+
):
|
|
906
|
+
# Multiple decoded records from one raw storage wrapper share
|
|
907
|
+
# one transaction token and are one claimant, not competing
|
|
908
|
+
# operations. Neutral batches are grouped earlier; retain the
|
|
909
|
+
# same rule for already-live multi-record wrappers.
|
|
910
|
+
continue
|
|
911
|
+
other_message = other.delta_message
|
|
912
|
+
if other_message is None:
|
|
913
|
+
if (
|
|
914
|
+
other.stream_sequence is None
|
|
915
|
+
or other.event.message_length is None
|
|
916
|
+
):
|
|
917
|
+
continue
|
|
918
|
+
other_message = self._read_span(
|
|
919
|
+
other.flow,
|
|
920
|
+
other.stream_sequence,
|
|
921
|
+
other.event.message_length,
|
|
922
|
+
)
|
|
923
|
+
if other_message is None or other.delta_prefix_end is None:
|
|
924
|
+
continue
|
|
925
|
+
prefix_end = min(other.delta_prefix_end, len(other_message))
|
|
926
|
+
if self._message_claims_companion_pair(
|
|
927
|
+
other_message,
|
|
928
|
+
prefix_end,
|
|
929
|
+
other,
|
|
930
|
+
first_message,
|
|
931
|
+
second_message,
|
|
932
|
+
):
|
|
933
|
+
competing = True
|
|
934
|
+
break
|
|
935
|
+
if competing:
|
|
936
|
+
self._mark_companion_pair_contested(pair_key)
|
|
937
|
+
return False
|
|
938
|
+
return True
|
|
939
|
+
|
|
940
|
+
@staticmethod
|
|
941
|
+
def _message_claims_companion_pair(
|
|
942
|
+
message: bytes,
|
|
943
|
+
prefix_end: int,
|
|
944
|
+
pending: _PendingDeposit,
|
|
945
|
+
first_message: bytes,
|
|
946
|
+
second_message: bytes,
|
|
947
|
+
) -> bool:
|
|
948
|
+
if prefix_end < 5 + TOKEN_WIDTH:
|
|
949
|
+
return False
|
|
950
|
+
return (
|
|
951
|
+
discover_companion_observation(
|
|
952
|
+
delta_message=message,
|
|
953
|
+
first_message=first_message,
|
|
954
|
+
second_message=second_message,
|
|
955
|
+
timestamp=pending.timestamp,
|
|
956
|
+
flow=pending.flow,
|
|
957
|
+
stream_sequence=pending.stream_sequence,
|
|
958
|
+
delta_prefix_end=min(prefix_end, len(message)),
|
|
959
|
+
)
|
|
960
|
+
is not None
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
def _mark_companion_pair_contested(
|
|
964
|
+
self,
|
|
965
|
+
pair_key: tuple[FlowKey, int, int],
|
|
966
|
+
) -> None:
|
|
967
|
+
flow = pair_key[0]
|
|
968
|
+
if (
|
|
969
|
+
flow in self._companion_contest_overflow_flows
|
|
970
|
+
or pair_key in self._contested_companion_pairs
|
|
971
|
+
):
|
|
972
|
+
return
|
|
973
|
+
if self.COMPANION_PAIR_HISTORY_LIMIT <= 0:
|
|
974
|
+
self._suppress_companion_candidates_for_flow(flow)
|
|
975
|
+
return
|
|
976
|
+
if (
|
|
977
|
+
len(self._contested_companion_pair_order)
|
|
978
|
+
>= self.COMPANION_PAIR_HISTORY_LIMIT
|
|
979
|
+
):
|
|
980
|
+
expired = self._contested_companion_pair_order.popleft()
|
|
981
|
+
self._contested_companion_pairs.discard(expired)
|
|
982
|
+
# Never let bounded-history pressure turn a previously contested
|
|
983
|
+
# pair back into acceptable evidence. Conservatively suppress
|
|
984
|
+
# worker-chain attribution on that flow until it closes; manual
|
|
985
|
+
# decrement evidence and neutral delivery remain available.
|
|
986
|
+
self._suppress_companion_candidates_for_flow(expired[0])
|
|
987
|
+
if flow in self._companion_contest_overflow_flows:
|
|
988
|
+
return
|
|
989
|
+
self._contested_companion_pairs.add(pair_key)
|
|
990
|
+
self._contested_companion_pair_order.append(pair_key)
|
|
991
|
+
|
|
992
|
+
def _suppress_companion_candidates_for_flow(self, flow: FlowKey) -> None:
|
|
993
|
+
self._companion_contest_overflow_flows.add(flow)
|
|
994
|
+
for pending in self._pending:
|
|
995
|
+
if pending.flow != flow or pending.finalized:
|
|
996
|
+
continue
|
|
997
|
+
pending.candidate_observations.clear()
|
|
998
|
+
pending.companion_observation = None
|
|
999
|
+
|
|
1000
|
+
def _resolve_companions(self, pending: _PendingDeposit) -> bool:
|
|
1001
|
+
if pending.finalized:
|
|
1002
|
+
return True
|
|
1003
|
+
if self._select_confirmed_observation(pending):
|
|
1004
|
+
self._finalize(pending)
|
|
1005
|
+
return True
|
|
1006
|
+
result = self._scan_companions_after(pending)
|
|
1007
|
+
if result is None:
|
|
1008
|
+
return False
|
|
1009
|
+
pending.awaiting_storage_boundaries = (
|
|
1010
|
+
result.awaiting_storage_boundaries
|
|
1011
|
+
)
|
|
1012
|
+
for observation in result.observations:
|
|
1013
|
+
pending.candidate_observations.setdefault(
|
|
1014
|
+
observation.family_key,
|
|
1015
|
+
observation,
|
|
1016
|
+
)
|
|
1017
|
+
self._record_family_observation(observation)
|
|
1018
|
+
if pending.finalized:
|
|
1019
|
+
return True
|
|
1020
|
+
if self._select_confirmed_observation(pending):
|
|
1021
|
+
self._finalize(pending)
|
|
1022
|
+
return True
|
|
1023
|
+
|
|
1024
|
+
# An adjacent, unique pair is strong enough to bootstrap a family
|
|
1025
|
+
# without a separately promoted profile. Delayed/ambiguous pairs wait
|
|
1026
|
+
# for the bounded window to close or for repeated-family confirmation.
|
|
1027
|
+
immediate = result.immediate_family_keys.intersection(
|
|
1028
|
+
pending.candidate_observations
|
|
1029
|
+
)
|
|
1030
|
+
if len(immediate) == 1 and len(pending.candidate_observations) == 1:
|
|
1031
|
+
self._confirm_family(next(iter(immediate)), "adjacent-structural-chain")
|
|
1032
|
+
if not pending.finalized:
|
|
1033
|
+
self._select_confirmed_observation(pending)
|
|
1034
|
+
self._finalize(pending)
|
|
1035
|
+
return True
|
|
1036
|
+
|
|
1037
|
+
if pending.awaiting_storage_boundaries:
|
|
1038
|
+
return False
|
|
1039
|
+
if result.complete:
|
|
1040
|
+
self._close_pending(pending)
|
|
1041
|
+
return pending.finalized
|
|
1042
|
+
return False
|
|
1043
|
+
|
|
1044
|
+
def _record_family_observation(
|
|
1045
|
+
self,
|
|
1046
|
+
observation: CompanionObservation,
|
|
1047
|
+
) -> None:
|
|
1048
|
+
chain_key = (observation.flow, observation.stream_sequence)
|
|
1049
|
+
if observation.family_key in self._confirmed_companion_families:
|
|
1050
|
+
self._notify_origin_observer(observation)
|
|
1051
|
+
return
|
|
1052
|
+
chains = self._family_chains.get(observation.family_key)
|
|
1053
|
+
is_new = chains is None or chain_key not in chains
|
|
1054
|
+
if is_new:
|
|
1055
|
+
if len(self._family_chain_order) >= self.OBSERVATION_HISTORY_LIMIT:
|
|
1056
|
+
expired_family, expired_chain = self._family_chain_order.popleft()
|
|
1057
|
+
expired_chains = self._family_chains.get(expired_family)
|
|
1058
|
+
if expired_chains is not None:
|
|
1059
|
+
expired_chains.discard(expired_chain)
|
|
1060
|
+
if not expired_chains:
|
|
1061
|
+
self._family_chains.pop(expired_family, None)
|
|
1062
|
+
chains = self._family_chains.setdefault(observation.family_key, set())
|
|
1063
|
+
chains.add(chain_key)
|
|
1064
|
+
self._family_chain_order.append((observation.family_key, chain_key))
|
|
1065
|
+
self._notify_origin_observer(observation)
|
|
1066
|
+
assert chains is not None
|
|
1067
|
+
if len(chains) >= self.FAMILY_CONFIRMATION_OBSERVATIONS:
|
|
1068
|
+
self._confirm_family(observation.family_key, "repeated-structural-chain")
|
|
1069
|
+
|
|
1070
|
+
def _confirm_family(
|
|
1071
|
+
self,
|
|
1072
|
+
family_key: tuple[int, int, int, int, int],
|
|
1073
|
+
reason: str,
|
|
1074
|
+
) -> None:
|
|
1075
|
+
if family_key in self._confirmed_companion_families:
|
|
1076
|
+
return
|
|
1077
|
+
self._confirmed_companion_families.add(family_key)
|
|
1078
|
+
self._family_confirmation[family_key] = reason
|
|
1079
|
+
if family_key not in self._known_companion_families:
|
|
1080
|
+
if (
|
|
1081
|
+
len(self._runtime_confirmed_family_order)
|
|
1082
|
+
>= self.RUNTIME_CONFIRMED_FAMILY_LIMIT
|
|
1083
|
+
):
|
|
1084
|
+
expired = self._runtime_confirmed_family_order.popleft()
|
|
1085
|
+
self._confirmed_companion_families.discard(expired)
|
|
1086
|
+
self._family_confirmation.pop(expired, None)
|
|
1087
|
+
self._runtime_confirmed_family_order.append(family_key)
|
|
1088
|
+
self._family_chains.pop(family_key, None)
|
|
1089
|
+
# A later independent chain can resolve an earlier ambiguous one.
|
|
1090
|
+
for pending in self._pending:
|
|
1091
|
+
if pending.finalized or family_key not in pending.candidate_observations:
|
|
1092
|
+
continue
|
|
1093
|
+
if self._select_confirmed_observation(pending):
|
|
1094
|
+
self._finalize(pending)
|
|
1095
|
+
|
|
1096
|
+
def _select_confirmed_observation(self, pending: _PendingDeposit) -> bool:
|
|
1097
|
+
if pending.flow in self._companion_contest_overflow_flows:
|
|
1098
|
+
pending.candidate_observations.clear()
|
|
1099
|
+
pending.companion_observation = None
|
|
1100
|
+
return False
|
|
1101
|
+
eligible = [
|
|
1102
|
+
key
|
|
1103
|
+
for key in pending.candidate_observations
|
|
1104
|
+
if key in self._confirmed_companion_families
|
|
1105
|
+
]
|
|
1106
|
+
if not eligible:
|
|
1107
|
+
return False
|
|
1108
|
+
# An explicitly promoted profile wins if an ambiguous window happens
|
|
1109
|
+
# to contain more than one confirmed candidate family.
|
|
1110
|
+
eligible.sort(
|
|
1111
|
+
key=lambda key: (key not in self._known_companion_families, key)
|
|
1112
|
+
)
|
|
1113
|
+
pending.companion_observation = pending.candidate_observations[eligible[0]]
|
|
1114
|
+
return True
|
|
1115
|
+
|
|
1116
|
+
def _close_pending(self, pending: _PendingDeposit) -> None:
|
|
1117
|
+
"""Close a complete/stale window, auto-confirming only if unambiguous."""
|
|
1118
|
+
if pending.finalized:
|
|
1119
|
+
return
|
|
1120
|
+
if self._select_confirmed_observation(pending):
|
|
1121
|
+
self._finalize(pending)
|
|
1122
|
+
return
|
|
1123
|
+
if len(pending.candidate_observations) == 1:
|
|
1124
|
+
family_key = next(iter(pending.candidate_observations))
|
|
1125
|
+
self._confirm_family(family_key, "unambiguous-bounded-window")
|
|
1126
|
+
if not pending.finalized:
|
|
1127
|
+
self._select_confirmed_observation(pending)
|
|
1128
|
+
self._finalize(pending)
|
|
1129
|
+
|
|
1130
|
+
def _notify_origin_observer(self, observation: CompanionObservation) -> None:
|
|
1131
|
+
# A multi-record storage frame registers several events but represents
|
|
1132
|
+
# one independent companion-family observation.
|
|
1133
|
+
key = (observation.flow, observation.stream_sequence, observation.family_key)
|
|
1134
|
+
if key in self._observed_chains:
|
|
1135
|
+
return
|
|
1136
|
+
if len(self._observed_chain_order) >= self.OBSERVATION_HISTORY_LIMIT:
|
|
1137
|
+
expired = self._observed_chain_order.popleft()
|
|
1138
|
+
self._observed_chains.discard(expired)
|
|
1139
|
+
self._observed_chains.add(key)
|
|
1140
|
+
self._observed_chain_order.append(key)
|
|
1141
|
+
if self._origin_observer is not None:
|
|
1142
|
+
self._outbox.append(("observer", observation))
|
|
1143
|
+
|
|
1144
|
+
# --- calibrated manual-decrement signal ---
|
|
1145
|
+
|
|
1146
|
+
def _matching_decrement(
|
|
1147
|
+
self,
|
|
1148
|
+
flow: FlowKey,
|
|
1149
|
+
stream_sequence: Optional[int],
|
|
1150
|
+
event: BDOEvent,
|
|
1151
|
+
) -> Optional[_ManualDecrementMatch]:
|
|
1152
|
+
quantity_bytes = event.quantity.to_bytes(4, "little")
|
|
1153
|
+
destination_instance = self._event_storage_instance(event)
|
|
1154
|
+
eligible = [
|
|
1155
|
+
frame
|
|
1156
|
+
for frame in self._recent.get(flow, ())
|
|
1157
|
+
if not (
|
|
1158
|
+
stream_sequence is not None
|
|
1159
|
+
and frame.stream_sequence is not None
|
|
1160
|
+
and frame.stream_sequence >= stream_sequence
|
|
1161
|
+
)
|
|
1162
|
+
][-self.MANUAL_LOOKBACK_FRAMES :]
|
|
1163
|
+
matches: list[_ManualDecrementMatch] = []
|
|
1164
|
+
for frame in eligible:
|
|
1165
|
+
for spec in self._decrement_specs.get(frame.opcode, ()):
|
|
1166
|
+
# Profile lengths are calibrated single-record minima. Batch
|
|
1167
|
+
# decrements append more records to the same frame.
|
|
1168
|
+
if len(frame.message) < spec.min_message_length:
|
|
1169
|
+
continue
|
|
1170
|
+
inferred_repeat_stride: Optional[int] = None
|
|
1171
|
+
if spec.repeat_stride is not None:
|
|
1172
|
+
extra_length = len(frame.message) - spec.min_message_length
|
|
1173
|
+
if extra_length % spec.repeat_stride:
|
|
1174
|
+
continue
|
|
1175
|
+
record_deltas: Iterable[int] = range(
|
|
1176
|
+
0,
|
|
1177
|
+
extra_length + 1,
|
|
1178
|
+
spec.repeat_stride,
|
|
1179
|
+
)
|
|
1180
|
+
elif (
|
|
1181
|
+
spec.source_instance_offset is not None
|
|
1182
|
+
and destination_instance is not None
|
|
1183
|
+
and isinstance(event.record_index, int)
|
|
1184
|
+
and not isinstance(event.record_index, bool)
|
|
1185
|
+
and isinstance(event.record_count, int)
|
|
1186
|
+
and not isinstance(event.record_count, bool)
|
|
1187
|
+
and event.record_count > 1
|
|
1188
|
+
and 1 <= event.record_index <= event.record_count
|
|
1189
|
+
):
|
|
1190
|
+
# Older calibrated profiles predate repeat_stride, but
|
|
1191
|
+
# their multi-record captures still expose enough
|
|
1192
|
+
# geometry to recover it safely: the captured decrement
|
|
1193
|
+
# and storage batch cardinalities align, while exact
|
|
1194
|
+
# source/destination instance equality validates every
|
|
1195
|
+
# inferred record after the first. This never expands
|
|
1196
|
+
# quantity-only matching.
|
|
1197
|
+
extra_length = len(frame.message) - spec.min_message_length
|
|
1198
|
+
divisor = event.record_count - 1
|
|
1199
|
+
if extra_length <= 0 or extra_length % divisor:
|
|
1200
|
+
record_deltas = (0,)
|
|
1201
|
+
else:
|
|
1202
|
+
inferred_stride = extra_length // divisor
|
|
1203
|
+
prefix_length = (
|
|
1204
|
+
spec.min_message_length - inferred_stride
|
|
1205
|
+
)
|
|
1206
|
+
if (
|
|
1207
|
+
inferred_stride <= 0
|
|
1208
|
+
or prefix_length < 5
|
|
1209
|
+
or spec.quantity_offset < prefix_length
|
|
1210
|
+
or spec.source_instance_offset < prefix_length
|
|
1211
|
+
):
|
|
1212
|
+
record_deltas = (0,)
|
|
1213
|
+
else:
|
|
1214
|
+
inferred_repeat_stride = inferred_stride
|
|
1215
|
+
record_deltas = range(
|
|
1216
|
+
0,
|
|
1217
|
+
extra_length + 1,
|
|
1218
|
+
inferred_stride,
|
|
1219
|
+
)
|
|
1220
|
+
else:
|
|
1221
|
+
record_deltas = (0,)
|
|
1222
|
+
|
|
1223
|
+
for delta in record_deltas:
|
|
1224
|
+
quantity_offset = spec.quantity_offset + delta
|
|
1225
|
+
quantity_end = quantity_offset + 4
|
|
1226
|
+
if (
|
|
1227
|
+
quantity_end > len(frame.message)
|
|
1228
|
+
or frame.message[quantity_offset:quantity_end]
|
|
1229
|
+
!= quantity_bytes
|
|
1230
|
+
):
|
|
1231
|
+
continue
|
|
1232
|
+
|
|
1233
|
+
source_offset = spec.source_instance_offset
|
|
1234
|
+
if source_offset is None:
|
|
1235
|
+
matches.append(
|
|
1236
|
+
_ManualDecrementMatch(
|
|
1237
|
+
opcode=frame.opcode,
|
|
1238
|
+
message_length=len(frame.message),
|
|
1239
|
+
quantity_offset=quantity_offset,
|
|
1240
|
+
source_instance_offset=None,
|
|
1241
|
+
match_kind="quantity-only",
|
|
1242
|
+
confidence="heuristic",
|
|
1243
|
+
instance_matches_destination=None,
|
|
1244
|
+
)
|
|
1245
|
+
)
|
|
1246
|
+
continue
|
|
1247
|
+
|
|
1248
|
+
source_offset += delta
|
|
1249
|
+
source_end = source_offset + 8
|
|
1250
|
+
if source_end > len(frame.message):
|
|
1251
|
+
continue
|
|
1252
|
+
source_instance = bytes(
|
|
1253
|
+
frame.message[source_offset:source_end]
|
|
1254
|
+
)
|
|
1255
|
+
if not self._nonempty_source_instance(source_instance):
|
|
1256
|
+
# A declared instance field that is empty invalidates
|
|
1257
|
+
# this candidate. Do not fall back to a common
|
|
1258
|
+
# quantity elsewhere in the frame.
|
|
1259
|
+
continue
|
|
1260
|
+
|
|
1261
|
+
exact = (
|
|
1262
|
+
destination_instance is not None
|
|
1263
|
+
and source_instance == destination_instance
|
|
1264
|
+
)
|
|
1265
|
+
if not exact:
|
|
1266
|
+
if inferred_repeat_stride is not None and delta:
|
|
1267
|
+
# The profile did not declare this repeated field
|
|
1268
|
+
# location. Exact identity is the guard that makes
|
|
1269
|
+
# the inferred nonzero record offset trustworthy.
|
|
1270
|
+
continue
|
|
1271
|
+
# A partial stack move can allocate a different
|
|
1272
|
+
# destination instance: the controlled legacy
|
|
1273
|
+
# new_potato_1_1_1 capture proves that mismatch is not
|
|
1274
|
+
# contradictory. Retain it as anchored structural
|
|
1275
|
+
# evidence, but require entropy in both halves. Exact
|
|
1276
|
+
# cross-frame equality needs no such heuristic guard.
|
|
1277
|
+
if not self._structural_source_instance(
|
|
1278
|
+
source_instance
|
|
1279
|
+
):
|
|
1280
|
+
continue
|
|
1281
|
+
matches.append(
|
|
1282
|
+
_ManualDecrementMatch(
|
|
1283
|
+
opcode=frame.opcode,
|
|
1284
|
+
message_length=len(frame.message),
|
|
1285
|
+
quantity_offset=quantity_offset,
|
|
1286
|
+
source_instance_offset=source_offset,
|
|
1287
|
+
match_kind=(
|
|
1288
|
+
"instance-and-quantity"
|
|
1289
|
+
if exact
|
|
1290
|
+
else "anchored-instance-and-quantity"
|
|
1291
|
+
),
|
|
1292
|
+
confidence="observed" if exact else "structural",
|
|
1293
|
+
instance_matches_destination=(
|
|
1294
|
+
exact if destination_instance is not None else None
|
|
1295
|
+
),
|
|
1296
|
+
)
|
|
1297
|
+
)
|
|
1298
|
+
|
|
1299
|
+
if not matches:
|
|
1300
|
+
return None
|
|
1301
|
+
rank = {"observed": 0, "structural": 1, "heuristic": 2}
|
|
1302
|
+
matches.sort(key=lambda match: rank[match.confidence])
|
|
1303
|
+
return matches[0]
|
|
1304
|
+
|
|
1305
|
+
@staticmethod
|
|
1306
|
+
def _event_storage_instance(event: BDOEvent) -> Optional[bytes]:
|
|
1307
|
+
value = event.storage_instance
|
|
1308
|
+
if not isinstance(value, str) or not value.startswith("0x"):
|
|
1309
|
+
return None
|
|
1310
|
+
try:
|
|
1311
|
+
decoded = bytes.fromhex(value[2:])
|
|
1312
|
+
except ValueError:
|
|
1313
|
+
return None
|
|
1314
|
+
return decoded if len(decoded) == 8 else None
|
|
1315
|
+
|
|
1316
|
+
@staticmethod
|
|
1317
|
+
def _nonempty_source_instance(value: bytes) -> bool:
|
|
1318
|
+
return len(value) == 8 and value not in (b"\x00" * 8, b"\xff" * 8)
|
|
1319
|
+
|
|
1320
|
+
@classmethod
|
|
1321
|
+
def _structural_source_instance(cls, value: bytes) -> bool:
|
|
1322
|
+
if not cls._nonempty_source_instance(value):
|
|
1323
|
+
return False
|
|
1324
|
+
empty_halves = {b"\x00" * 4, b"\xff" * 4}
|
|
1325
|
+
return value[:4] not in empty_halves and value[4:] not in empty_halves
|
|
1326
|
+
|
|
1327
|
+
# --- flushing ---
|
|
1328
|
+
|
|
1329
|
+
def flush_stale(self, now: float) -> None:
|
|
1330
|
+
"""Finalize expired evidence without racing producer-side mutation."""
|
|
1331
|
+
with self._state_lock:
|
|
1332
|
+
self._flush_stale_locked(now)
|
|
1333
|
+
self._drain_outbox()
|
|
1334
|
+
|
|
1335
|
+
def close_flow(self, flow: FlowKey) -> None:
|
|
1336
|
+
"""Finalize one closed TCP flow and release all flow-keyed history."""
|
|
1337
|
+
|
|
1338
|
+
with self._state_lock:
|
|
1339
|
+
staged_keys = [
|
|
1340
|
+
key for key in self._staged_neutral_batches if key[0] == flow
|
|
1341
|
+
]
|
|
1342
|
+
for key in staged_keys:
|
|
1343
|
+
self._emit_neutral_entries(self._staged_neutral_batches.pop(key))
|
|
1344
|
+
|
|
1345
|
+
for pending in tuple(self._pending):
|
|
1346
|
+
if pending.flow == flow and not pending.finalized:
|
|
1347
|
+
self._close_pending(pending)
|
|
1348
|
+
self._pending = [
|
|
1349
|
+
pending
|
|
1350
|
+
for pending in self._pending
|
|
1351
|
+
if pending.flow != flow and not pending.finalized
|
|
1352
|
+
]
|
|
1353
|
+
self._purge_flow_history_locked(flow)
|
|
1354
|
+
self._drain_outbox()
|
|
1355
|
+
|
|
1356
|
+
def _purge_flow_history_locked(self, flow: FlowKey) -> None:
|
|
1357
|
+
self._recent.pop(flow, None)
|
|
1358
|
+
self._stream_spans.pop(flow, None)
|
|
1359
|
+
|
|
1360
|
+
self._family_chain_order = deque(
|
|
1361
|
+
(family, chain)
|
|
1362
|
+
for family, chain in self._family_chain_order
|
|
1363
|
+
if chain[0] != flow
|
|
1364
|
+
)
|
|
1365
|
+
for family, chains in tuple(self._family_chains.items()):
|
|
1366
|
+
remaining = {chain for chain in chains if chain[0] != flow}
|
|
1367
|
+
if remaining:
|
|
1368
|
+
self._family_chains[family] = remaining
|
|
1369
|
+
else:
|
|
1370
|
+
self._family_chains.pop(family, None)
|
|
1371
|
+
|
|
1372
|
+
self._observed_chain_order = deque(
|
|
1373
|
+
key for key in self._observed_chain_order if key[0] != flow
|
|
1374
|
+
)
|
|
1375
|
+
self._observed_chains = {
|
|
1376
|
+
key for key in self._observed_chains if key[0] != flow
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
self._contested_companion_pair_order = deque(
|
|
1380
|
+
key for key in self._contested_companion_pair_order if key[0] != flow
|
|
1381
|
+
)
|
|
1382
|
+
self._contested_companion_pairs = {
|
|
1383
|
+
key for key in self._contested_companion_pairs if key[0] != flow
|
|
1384
|
+
}
|
|
1385
|
+
self._companion_contest_overflow_flows.discard(flow)
|
|
1386
|
+
|
|
1387
|
+
self._first_record_boundary_order = deque(
|
|
1388
|
+
key for key in self._first_record_boundary_order if key[0] != flow
|
|
1389
|
+
)
|
|
1390
|
+
self._first_record_boundaries = {
|
|
1391
|
+
key: value
|
|
1392
|
+
for key, value in self._first_record_boundaries.items()
|
|
1393
|
+
if key[0] != flow
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
def _flush_stale_locked(self, now: float) -> None:
|
|
1397
|
+
stale_batches = [
|
|
1398
|
+
key
|
|
1399
|
+
for key, staged in self._staged_neutral_batches.items()
|
|
1400
|
+
if staged.entries
|
|
1401
|
+
and now
|
|
1402
|
+
- min(entry[0].timestamp for entry in staged.entries.values())
|
|
1403
|
+
> self.STALE_SECONDS
|
|
1404
|
+
]
|
|
1405
|
+
for key in stale_batches:
|
|
1406
|
+
self._emit_neutral_entries(self._staged_neutral_batches.pop(key))
|
|
1407
|
+
|
|
1408
|
+
still: list[_PendingDeposit] = []
|
|
1409
|
+
for pending in self._pending:
|
|
1410
|
+
if now - pending.timestamp > self.STALE_SECONDS:
|
|
1411
|
+
self._close_pending(pending)
|
|
1412
|
+
else:
|
|
1413
|
+
still.append(pending)
|
|
1414
|
+
self._pending = still
|
|
1415
|
+
|
|
1416
|
+
def finalize_all(self) -> None:
|
|
1417
|
+
"""Finalize every pending decision and dispatch it in tracker order."""
|
|
1418
|
+
with self._state_lock:
|
|
1419
|
+
self._finalize_all_locked()
|
|
1420
|
+
self._drain_outbox()
|
|
1421
|
+
|
|
1422
|
+
def _finalize_all_locked(self) -> None:
|
|
1423
|
+
staged, self._staged_neutral_batches = self._staged_neutral_batches, {}
|
|
1424
|
+
for batch in staged.values():
|
|
1425
|
+
self._emit_neutral_entries(batch)
|
|
1426
|
+
|
|
1427
|
+
pending, self._pending = self._pending, []
|
|
1428
|
+
for deposit in pending:
|
|
1429
|
+
self._close_pending(deposit)
|
|
1430
|
+
self._recent.clear()
|
|
1431
|
+
self._stream_spans.clear()
|
|
1432
|
+
self._family_chains.clear()
|
|
1433
|
+
self._family_chain_order.clear()
|
|
1434
|
+
self._observed_chains.clear()
|
|
1435
|
+
self._observed_chain_order.clear()
|
|
1436
|
+
self._contested_companion_pairs.clear()
|
|
1437
|
+
self._contested_companion_pair_order.clear()
|
|
1438
|
+
self._companion_contest_overflow_flows.clear()
|
|
1439
|
+
self._first_record_boundaries.clear()
|
|
1440
|
+
self._first_record_boundary_order.clear()
|
|
1441
|
+
|
|
1442
|
+
def _finalize(self, pending: _PendingDeposit) -> None:
|
|
1443
|
+
if pending.finalized:
|
|
1444
|
+
return
|
|
1445
|
+
pending.finalized = True
|
|
1446
|
+
companions = pending.companion_observation is not None
|
|
1447
|
+
# The shared-token chain remains stronger than any backward manual
|
|
1448
|
+
# signal, including an exact instance match, because it proves the
|
|
1449
|
+
# worker-specific three-frame relation for this operation.
|
|
1450
|
+
if companions:
|
|
1451
|
+
origin = ORIGIN_WORKER
|
|
1452
|
+
elif pending.matching_decrement:
|
|
1453
|
+
origin = ORIGIN_MANUAL
|
|
1454
|
+
else:
|
|
1455
|
+
origin = ORIGIN_UNKNOWN
|
|
1456
|
+
|
|
1457
|
+
events = pending.events or (pending.event,)
|
|
1458
|
+
was_neutral = pending.event.event_type == "storage_record"
|
|
1459
|
+
if was_neutral and origin == ORIGIN_UNKNOWN:
|
|
1460
|
+
# An unfamiliar wrapper mode is not a deposit merely because the
|
|
1461
|
+
# target opcode and item fields decoded. Preserve the original
|
|
1462
|
+
# fail-closed event exactly when neither independent live signal
|
|
1463
|
+
# was present.
|
|
1464
|
+
for event in events:
|
|
1465
|
+
self._queue_emit(event)
|
|
1466
|
+
return
|
|
1467
|
+
|
|
1468
|
+
evidence: dict[str, object] = {
|
|
1469
|
+
"worker_companions": companions,
|
|
1470
|
+
"matching_decrement": pending.matching_decrement,
|
|
1471
|
+
}
|
|
1472
|
+
if pending.manual_decrement_matches:
|
|
1473
|
+
if len(pending.manual_decrement_matches) > 1:
|
|
1474
|
+
evidence["manual_decrement_matches"] = tuple(
|
|
1475
|
+
{
|
|
1476
|
+
"record_index": candidate_index,
|
|
1477
|
+
**candidate.to_dict(),
|
|
1478
|
+
}
|
|
1479
|
+
for candidate_index, candidate in pending.manual_decrement_matches
|
|
1480
|
+
)
|
|
1481
|
+
if pending.companion_observation is not None:
|
|
1482
|
+
companion_evidence = pending.companion_observation.to_dict()
|
|
1483
|
+
companion_evidence["known_family"] = (
|
|
1484
|
+
pending.companion_observation.family_key
|
|
1485
|
+
in self._known_companion_families
|
|
1486
|
+
)
|
|
1487
|
+
companion_evidence["confirmed_family"] = (
|
|
1488
|
+
pending.companion_observation.family_key
|
|
1489
|
+
in self._confirmed_companion_families
|
|
1490
|
+
)
|
|
1491
|
+
companion_evidence["confirmation"] = self._family_confirmation.get(
|
|
1492
|
+
pending.companion_observation.family_key
|
|
1493
|
+
)
|
|
1494
|
+
evidence["companion_chain"] = companion_evidence
|
|
1495
|
+
if (
|
|
1496
|
+
was_neutral
|
|
1497
|
+
and len(events) > 1
|
|
1498
|
+
and pending.matching_decrement_record_indexes
|
|
1499
|
+
):
|
|
1500
|
+
evidence["matching_decrement_record_indexes"] = (
|
|
1501
|
+
pending.matching_decrement_record_indexes
|
|
1502
|
+
)
|
|
1503
|
+
|
|
1504
|
+
for original in events:
|
|
1505
|
+
event_evidence = dict(evidence)
|
|
1506
|
+
if pending.manual_decrement_matches:
|
|
1507
|
+
rank = {"observed": 0, "structural": 1, "heuristic": 2}
|
|
1508
|
+
matching_this_record = tuple(
|
|
1509
|
+
candidate
|
|
1510
|
+
for candidate in pending.manual_decrement_matches
|
|
1511
|
+
if candidate[0] == original.record_index
|
|
1512
|
+
)
|
|
1513
|
+
selection_pool = (
|
|
1514
|
+
matching_this_record
|
|
1515
|
+
if matching_this_record
|
|
1516
|
+
else pending.manual_decrement_matches
|
|
1517
|
+
)
|
|
1518
|
+
record_index, selected_manual = min(
|
|
1519
|
+
selection_pool,
|
|
1520
|
+
key=lambda item: rank[item[1].confidence],
|
|
1521
|
+
)
|
|
1522
|
+
selected_payload = selected_manual.to_dict()
|
|
1523
|
+
selected_payload["record_index"] = record_index
|
|
1524
|
+
event_evidence["manual_decrement"] = selected_payload
|
|
1525
|
+
extra = {
|
|
1526
|
+
**original.extra,
|
|
1527
|
+
"deposit_origin_evidence": event_evidence,
|
|
1528
|
+
}
|
|
1529
|
+
if was_neutral:
|
|
1530
|
+
# A calibrated source decrement or confirmed worker chain
|
|
1531
|
+
# proves that this unfamiliar wire mode is nevertheless a
|
|
1532
|
+
# live mutation. Preserve that inference in additive audit
|
|
1533
|
+
# metadata and promote before EventFilter is applied.
|
|
1534
|
+
extra["storage_operation_evidence"] = {
|
|
1535
|
+
"wire_operation": "unknown",
|
|
1536
|
+
"inferred_operation": "live",
|
|
1537
|
+
"signal": (
|
|
1538
|
+
"worker_companions" if companions else "matching_decrement"
|
|
1539
|
+
),
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
event = dataclasses.replace(
|
|
1543
|
+
original,
|
|
1544
|
+
event_type=("storage_delta" if was_neutral else original.event_type),
|
|
1545
|
+
source=(
|
|
1546
|
+
SOURCE_WORKER_PRODUCTION
|
|
1547
|
+
if origin == ORIGIN_WORKER
|
|
1548
|
+
else SOURCE_PLAYER_INVENTORY
|
|
1549
|
+
if origin == ORIGIN_MANUAL
|
|
1550
|
+
else None
|
|
1551
|
+
),
|
|
1552
|
+
extra=extra,
|
|
1553
|
+
)
|
|
1554
|
+
self._queue_emit(event)
|
|
1555
|
+
|
|
1556
|
+
def _queue_emit(self, event: BDOEvent) -> None:
|
|
1557
|
+
"""Queue an application event while tracker state is locked."""
|
|
1558
|
+
self._outbox.append(("emit", event))
|
|
1559
|
+
|
|
1560
|
+
def _drain_outbox(self) -> None:
|
|
1561
|
+
"""Dispatch callbacks in mutation order without holding tracker locks.
|
|
1562
|
+
|
|
1563
|
+
A concurrent or re-entrant caller only appends to the protected outbox;
|
|
1564
|
+
the active dispatcher observes that append before relinquishing
|
|
1565
|
+
ownership. Claiming and relinquishing ownership while ``_state_lock``
|
|
1566
|
+
is held avoids the empty-outbox handoff race.
|
|
1567
|
+
"""
|
|
1568
|
+
|
|
1569
|
+
with self._state_lock:
|
|
1570
|
+
if self._dispatching:
|
|
1571
|
+
return
|
|
1572
|
+
self._dispatching = True
|
|
1573
|
+
|
|
1574
|
+
try:
|
|
1575
|
+
while True:
|
|
1576
|
+
with self._state_lock:
|
|
1577
|
+
if not self._outbox:
|
|
1578
|
+
self._dispatching = False
|
|
1579
|
+
return
|
|
1580
|
+
kind, payload = self._outbox.popleft()
|
|
1581
|
+
|
|
1582
|
+
# Do not move either callback under ``_state_lock`` or add a
|
|
1583
|
+
# dispatch lock around it. Live delivery takes a session lock
|
|
1584
|
+
# that can be held by a concurrent ``flush_stale`` caller.
|
|
1585
|
+
if kind == "emit":
|
|
1586
|
+
assert isinstance(payload, BDOEvent)
|
|
1587
|
+
self._emit_callback(payload)
|
|
1588
|
+
else:
|
|
1589
|
+
assert kind == "observer"
|
|
1590
|
+
assert isinstance(payload, CompanionObservation)
|
|
1591
|
+
observer = self._origin_observer
|
|
1592
|
+
if observer is not None:
|
|
1593
|
+
observer(payload)
|
|
1594
|
+
except BaseException:
|
|
1595
|
+
# Preserve callback exception propagation while allowing cleanup
|
|
1596
|
+
# or a later tracker operation to resume any queued deliveries.
|
|
1597
|
+
with self._state_lock:
|
|
1598
|
+
self._dispatching = False
|
|
1599
|
+
raise
|