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/_specs.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Turn opcode profile JSON entries into decodable event specs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Iterable, Optional
|
|
8
|
+
|
|
9
|
+
from ._protocol import MAX_TARGET_MESSAGE_LENGTH, EventSpec
|
|
10
|
+
from .profiles import OpcodeProfile, ProfileError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class LoadedSpecProfile:
|
|
15
|
+
active: bool
|
|
16
|
+
specs: tuple[EventSpec, ...]
|
|
17
|
+
source: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def event_specs_from_profile(profile: OpcodeProfile) -> LoadedSpecProfile:
|
|
21
|
+
"""Convert one validated profile object into decoder event specs."""
|
|
22
|
+
if not profile.active:
|
|
23
|
+
return LoadedSpecProfile(active=False, specs=(), source=str(profile.path))
|
|
24
|
+
|
|
25
|
+
specs: list[EventSpec] = []
|
|
26
|
+
decodable_events = {"LOOT_PREVIEW", "INVENTORY_TRANSFER", "STORAGE_ITEM_DELTA"}
|
|
27
|
+
for event, entries in profile.specs.items():
|
|
28
|
+
for entry in entries:
|
|
29
|
+
try:
|
|
30
|
+
spec = _event_spec_from_entry(event, entry)
|
|
31
|
+
except ValueError as exc:
|
|
32
|
+
raise ProfileError(
|
|
33
|
+
f"Invalid {event} spec in {profile.path}: {exc}"
|
|
34
|
+
) from exc
|
|
35
|
+
if spec is not None:
|
|
36
|
+
specs.append(spec)
|
|
37
|
+
elif event in decodable_events:
|
|
38
|
+
raise ProfileError(
|
|
39
|
+
f"Invalid {event} spec in {profile.path}: "
|
|
40
|
+
"missing or invalid required fields"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
normalized = tuple(_dedupe_event_specs(specs))
|
|
44
|
+
_validate_unambiguous_loot_specs(normalized, source=profile.path)
|
|
45
|
+
return LoadedSpecProfile(
|
|
46
|
+
active=True,
|
|
47
|
+
specs=normalized,
|
|
48
|
+
source=str(profile.path),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _validate_loot_profile_entries(
|
|
53
|
+
entries: Iterable[Mapping[str, object]],
|
|
54
|
+
*,
|
|
55
|
+
source: object,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Validate only the runtime ambiguity introduced by raw LOOT entries."""
|
|
58
|
+
|
|
59
|
+
specs: list[EventSpec] = []
|
|
60
|
+
for entry in entries:
|
|
61
|
+
try:
|
|
62
|
+
spec = _event_spec_from_entry("LOOT_PREVIEW", entry)
|
|
63
|
+
except ValueError as exc:
|
|
64
|
+
raise ProfileError(
|
|
65
|
+
f"Invalid LOOT_PREVIEW spec in {source}: {exc}"
|
|
66
|
+
) from exc
|
|
67
|
+
if spec is None:
|
|
68
|
+
raise ProfileError(
|
|
69
|
+
f"Invalid LOOT_PREVIEW spec in {source}: "
|
|
70
|
+
"missing or invalid required fields"
|
|
71
|
+
)
|
|
72
|
+
specs.append(spec)
|
|
73
|
+
|
|
74
|
+
_validate_unambiguous_loot_specs(
|
|
75
|
+
_dedupe_event_specs(specs),
|
|
76
|
+
source=source,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _validate_unambiguous_loot_specs(
|
|
81
|
+
specs: Iterable[EventSpec],
|
|
82
|
+
*,
|
|
83
|
+
source: object,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Require one non-overlapping runtime length domain per LOOT opcode."""
|
|
86
|
+
|
|
87
|
+
grouped: dict[int, list[EventSpec]] = {}
|
|
88
|
+
for spec in specs:
|
|
89
|
+
if spec.label != "LOOT_PREVIEW":
|
|
90
|
+
continue
|
|
91
|
+
grouped.setdefault(spec.opcode, []).append(spec)
|
|
92
|
+
|
|
93
|
+
for opcode, candidates in grouped.items():
|
|
94
|
+
for index, first in enumerate(candidates):
|
|
95
|
+
first_low, first_high = _loot_message_length_domain(first)
|
|
96
|
+
for second in candidates[index + 1 :]:
|
|
97
|
+
second_low, second_high = _loot_message_length_domain(second)
|
|
98
|
+
if max(first_low, second_low) > min(first_high, second_high):
|
|
99
|
+
continue
|
|
100
|
+
raise ProfileError(
|
|
101
|
+
f"Ambiguous LOOT_PREVIEW specs in {source}: opcode "
|
|
102
|
+
f"0x{opcode:04X} has distinct layouts with overlapping "
|
|
103
|
+
"runtime message-length domains: "
|
|
104
|
+
f"{_loot_layout_description(first)} and "
|
|
105
|
+
f"{_loot_layout_description(second)}. Keep only the layout for "
|
|
106
|
+
"the captured game patch, or recalibrate and replace the "
|
|
107
|
+
"LOOT_PREVIEW family instead of merging it."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _loot_message_length_domain(spec: EventSpec) -> tuple[int, int]:
|
|
112
|
+
exact_length = spec.single_record_message_length
|
|
113
|
+
if exact_length is not None:
|
|
114
|
+
return exact_length, exact_length
|
|
115
|
+
return spec.min_message_length, MAX_TARGET_MESSAGE_LENGTH
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _loot_layout_description(spec: EventSpec) -> str:
|
|
119
|
+
exact_length = spec.single_record_message_length
|
|
120
|
+
length = (
|
|
121
|
+
str(exact_length)
|
|
122
|
+
if exact_length is not None
|
|
123
|
+
else f"{spec.min_message_length}..{MAX_TARGET_MESSAGE_LENGTH}"
|
|
124
|
+
)
|
|
125
|
+
return (
|
|
126
|
+
f"(length={length}, item_id_offset={spec.item_offset}, "
|
|
127
|
+
f"quantity_offset={spec.quantity_offset}, "
|
|
128
|
+
f"item_instance_offset={spec.item_instance_offset!r})"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _event_spec_from_entry(
|
|
133
|
+
event: str,
|
|
134
|
+
entry: Mapping[str, object],
|
|
135
|
+
) -> Optional[EventSpec]:
|
|
136
|
+
opcode = _parse_opcode(entry.get("opcode"))
|
|
137
|
+
if opcode is None:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
length = _optional_int(entry.get("length"))
|
|
141
|
+
item_id_offset = _optional_int(entry.get("item_id_offset"))
|
|
142
|
+
quantity_offset = _optional_int(entry.get("quantity_offset"))
|
|
143
|
+
|
|
144
|
+
if event == "LOOT_PREVIEW":
|
|
145
|
+
if item_id_offset is None or quantity_offset is None:
|
|
146
|
+
return None
|
|
147
|
+
item_instance_offset = _optional_int(entry.get("item_instance_offset"))
|
|
148
|
+
return EventSpec(
|
|
149
|
+
label="LOOT_PREVIEW",
|
|
150
|
+
opcode=opcode,
|
|
151
|
+
item_offset=item_id_offset,
|
|
152
|
+
quantity_offset=quantity_offset,
|
|
153
|
+
min_message_length=_minimum_event_length(
|
|
154
|
+
length,
|
|
155
|
+
item_id_offset + 4,
|
|
156
|
+
quantity_offset + 4,
|
|
157
|
+
(
|
|
158
|
+
item_instance_offset + 8
|
|
159
|
+
if item_instance_offset is not None
|
|
160
|
+
else None
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
item_instance_offset=item_instance_offset,
|
|
164
|
+
single_record_message_length=length,
|
|
165
|
+
default_context="Gathering",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if event == "INVENTORY_TRANSFER":
|
|
169
|
+
if item_id_offset is None or quantity_offset is None:
|
|
170
|
+
return None
|
|
171
|
+
inventory_slot_offset = _optional_int(entry.get("inventory_slot_offset"))
|
|
172
|
+
context_offset = _optional_int(entry.get("context_offset"))
|
|
173
|
+
item_instance_offset = _optional_int(entry.get("item_instance_offset"))
|
|
174
|
+
repeat_stride = _optional_int(entry.get("repeat_stride"))
|
|
175
|
+
return EventSpec(
|
|
176
|
+
label="INVENTORY_TRANSFER",
|
|
177
|
+
opcode=opcode,
|
|
178
|
+
item_offset=item_id_offset,
|
|
179
|
+
quantity_offset=quantity_offset,
|
|
180
|
+
min_message_length=_minimum_event_length(
|
|
181
|
+
length,
|
|
182
|
+
item_id_offset + 4,
|
|
183
|
+
quantity_offset + 4,
|
|
184
|
+
item_instance_offset + 8 if item_instance_offset is not None else None,
|
|
185
|
+
context_offset + 4 if context_offset is not None else None,
|
|
186
|
+
inventory_slot_offset + 1 if inventory_slot_offset is not None else None,
|
|
187
|
+
),
|
|
188
|
+
inventory_slot_offset=inventory_slot_offset,
|
|
189
|
+
source_context_offset=context_offset,
|
|
190
|
+
item_instance_offset=item_instance_offset,
|
|
191
|
+
repeat_stride=repeat_stride,
|
|
192
|
+
# Preserve the calibrated base length even when a single-record
|
|
193
|
+
# capture could not reveal a repeat stride. Runtime structural
|
|
194
|
+
# discovery uses it to validate later multi-record messages.
|
|
195
|
+
single_record_message_length=length,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if event == "STORAGE_ITEM_DELTA":
|
|
199
|
+
quantity_added_offset = _optional_int(entry.get("quantity_added_offset"))
|
|
200
|
+
destination_instance_offset = _optional_int(
|
|
201
|
+
entry.get("destination_instance_offset")
|
|
202
|
+
)
|
|
203
|
+
if item_id_offset is None or quantity_added_offset is None:
|
|
204
|
+
return None
|
|
205
|
+
context_offset = _optional_int(entry.get("context_offset"))
|
|
206
|
+
record_count_offset = _optional_int(entry.get("record_count_offset"))
|
|
207
|
+
repeat_stride = _optional_int(entry.get("repeat_stride"))
|
|
208
|
+
return EventSpec(
|
|
209
|
+
label="INVENTORY_TO_STORAGE",
|
|
210
|
+
opcode=opcode,
|
|
211
|
+
item_offset=item_id_offset,
|
|
212
|
+
quantity_offset=quantity_added_offset,
|
|
213
|
+
min_message_length=_minimum_event_length(
|
|
214
|
+
length,
|
|
215
|
+
item_id_offset + 4,
|
|
216
|
+
quantity_added_offset + 4,
|
|
217
|
+
(
|
|
218
|
+
destination_instance_offset + 8
|
|
219
|
+
if destination_instance_offset is not None
|
|
220
|
+
else None
|
|
221
|
+
),
|
|
222
|
+
context_offset + 4 if context_offset is not None else None,
|
|
223
|
+
(
|
|
224
|
+
record_count_offset + 2
|
|
225
|
+
if record_count_offset is not None
|
|
226
|
+
else None
|
|
227
|
+
),
|
|
228
|
+
),
|
|
229
|
+
source_context_offset=context_offset,
|
|
230
|
+
record_count_offset=record_count_offset,
|
|
231
|
+
storage_instance_offset=destination_instance_offset,
|
|
232
|
+
repeat_stride=repeat_stride,
|
|
233
|
+
single_record_message_length=length,
|
|
234
|
+
default_context="Storage",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _parse_opcode(value: object) -> Optional[int]:
|
|
241
|
+
if isinstance(value, bool):
|
|
242
|
+
return None
|
|
243
|
+
if isinstance(value, int):
|
|
244
|
+
opcode = value
|
|
245
|
+
elif isinstance(value, str):
|
|
246
|
+
try:
|
|
247
|
+
opcode = int(value, 16 if value.lower().startswith("0x") else 10)
|
|
248
|
+
except ValueError:
|
|
249
|
+
return None
|
|
250
|
+
else:
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
if not 0 <= opcode <= 0xFFFF:
|
|
254
|
+
return None
|
|
255
|
+
return opcode
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _optional_int(value: object) -> Optional[int]:
|
|
259
|
+
if value is None:
|
|
260
|
+
return None
|
|
261
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
262
|
+
raise ValueError(f"expected a non-negative integer, got {value!r}")
|
|
263
|
+
return value
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _minimum_event_length(length: Optional[int], *ends: Optional[int]) -> int:
|
|
267
|
+
minimum = max((end for end in ends if end is not None), default=5)
|
|
268
|
+
if length is not None and length > 0:
|
|
269
|
+
minimum = max(minimum, length)
|
|
270
|
+
return minimum
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _dedupe_event_specs(specs: Iterable[EventSpec]) -> list[EventSpec]:
|
|
274
|
+
output: list[EventSpec] = []
|
|
275
|
+
# EventSpec is a frozen value object whose fields are all decode-affecting.
|
|
276
|
+
# Using the complete value as identity preserves same-opcode layouts that
|
|
277
|
+
# differ in base length, instance offsets, repeat geometry, context width,
|
|
278
|
+
# or fallback context while still removing literal duplicates.
|
|
279
|
+
seen: set[EventSpec] = set()
|
|
280
|
+
for spec in specs:
|
|
281
|
+
if spec in seen:
|
|
282
|
+
continue
|
|
283
|
+
seen.add(spec)
|
|
284
|
+
output.append(spec)
|
|
285
|
+
return output
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Conservative runtime revalidation for calibrated storage destinations.
|
|
2
|
+
|
|
3
|
+
Calibration remains authoritative for event decoding. This module only looks
|
|
4
|
+
for strong cross-wrapper evidence that the calibrated destination column has
|
|
5
|
+
gone stale; it never selects a replacement column or changes an event.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import OrderedDict, defaultdict
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from hashlib import blake2s
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from ._protocol import FlowKey, storage_destination_candidates
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Four distinct wrappers prevent a short transaction pair from becoming a
|
|
19
|
+
# schema verdict. Three distinct registered destination values distinguish a
|
|
20
|
+
# real column from the common ``0x05xx -> 0x0005`` one-byte overlap. A proven
|
|
21
|
+
# column must occur in every candidate-bearing wrapper; there is no majority
|
|
22
|
+
# guess and ambiguous evidence remains silent.
|
|
23
|
+
_MIN_PROOF_WRAPPERS = 4
|
|
24
|
+
_MIN_PROOF_DESTINATIONS = 3
|
|
25
|
+
|
|
26
|
+
# One character-load hydration currently needs fewer than 80 distinct storage
|
|
27
|
+
# wrappers. These limits retain a complete observed cohort with headroom while
|
|
28
|
+
# bounding adversarial/reconnect state to small fixed metadata (not packets).
|
|
29
|
+
_MAX_COHORTS = 64
|
|
30
|
+
_MAX_WRAPPERS_PER_COHORT = 128
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class StorageDestinationSchemaMismatch:
|
|
35
|
+
"""Strong evidence that a profile's destination column is stale."""
|
|
36
|
+
|
|
37
|
+
opcode: int
|
|
38
|
+
configured_offset: int
|
|
39
|
+
observed_offset: int
|
|
40
|
+
wrapper_count: int
|
|
41
|
+
distinct_destinations: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class _DestinationCohort:
|
|
46
|
+
wrappers: OrderedDict[bytes, tuple[tuple[int, int], ...]] = field(
|
|
47
|
+
default_factory=OrderedDict
|
|
48
|
+
)
|
|
49
|
+
mismatch_reported: bool = False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class StorageDestinationValidator:
|
|
53
|
+
"""Revalidate one destination column across bounded connection cohorts.
|
|
54
|
+
|
|
55
|
+
Evidence is isolated by TCP four-tuple generation and opcode. Identical
|
|
56
|
+
wrappers are counted once, and only registered destination candidates
|
|
57
|
+
before the first item record participate. Unknown-town-only wrappers are
|
|
58
|
+
retained for deduplication but do not vote for any column.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
self._cohorts: OrderedDict[
|
|
63
|
+
tuple[FlowKey, int, int, int, int], _DestinationCohort
|
|
64
|
+
] = OrderedDict()
|
|
65
|
+
|
|
66
|
+
def observe(
|
|
67
|
+
self,
|
|
68
|
+
*,
|
|
69
|
+
flow: FlowKey,
|
|
70
|
+
flow_generation: int,
|
|
71
|
+
opcode: int,
|
|
72
|
+
message: bytes,
|
|
73
|
+
first_item_offset: int,
|
|
74
|
+
configured_offset: Optional[int],
|
|
75
|
+
) -> Optional[StorageDestinationSchemaMismatch]:
|
|
76
|
+
"""Return a mismatch only after one alternate column is proven."""
|
|
77
|
+
|
|
78
|
+
if configured_offset is None or first_item_offset <= 5:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
# One opcode may legitimately select more than one structural layout.
|
|
82
|
+
# Destination evidence is comparable only when both the calibrated
|
|
83
|
+
# destination column and the first-record boundary are the same.
|
|
84
|
+
# Without this identity, a correct second layout can inherit the
|
|
85
|
+
# first layout's candidate column and be diagnosed as stale.
|
|
86
|
+
key = (
|
|
87
|
+
flow,
|
|
88
|
+
flow_generation,
|
|
89
|
+
opcode,
|
|
90
|
+
first_item_offset,
|
|
91
|
+
configured_offset,
|
|
92
|
+
)
|
|
93
|
+
cohort = self._cohorts.pop(key, None)
|
|
94
|
+
if cohort is None:
|
|
95
|
+
cohort = _DestinationCohort()
|
|
96
|
+
self._cohorts[key] = cohort
|
|
97
|
+
while len(self._cohorts) > _MAX_COHORTS:
|
|
98
|
+
self._cohorts.popitem(last=False)
|
|
99
|
+
|
|
100
|
+
# A compact cryptographic fingerprint avoids retaining private packet
|
|
101
|
+
# contents merely to suppress the per-record callbacks of one wrapper.
|
|
102
|
+
fingerprint = blake2s(message, digest_size=16).digest()
|
|
103
|
+
if fingerprint in cohort.wrappers:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
cohort.wrappers[fingerprint] = storage_destination_candidates(
|
|
107
|
+
message,
|
|
108
|
+
before_offset=first_item_offset,
|
|
109
|
+
)
|
|
110
|
+
while len(cohort.wrappers) > _MAX_WRAPPERS_PER_COHORT:
|
|
111
|
+
cohort.wrappers.popitem(last=False)
|
|
112
|
+
|
|
113
|
+
if cohort.mismatch_reported:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
candidate_wrappers = tuple(
|
|
117
|
+
candidates for candidates in cohort.wrappers.values() if candidates
|
|
118
|
+
)
|
|
119
|
+
if len(candidate_wrappers) < _MIN_PROOF_WRAPPERS:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
support: dict[int, int] = defaultdict(int)
|
|
123
|
+
destination_ids: dict[int, set[int]] = defaultdict(set)
|
|
124
|
+
for candidates in candidate_wrappers:
|
|
125
|
+
for offset, storage_id in candidates:
|
|
126
|
+
support[offset] += 1
|
|
127
|
+
destination_ids[offset].add(storage_id)
|
|
128
|
+
|
|
129
|
+
proven_offsets = tuple(
|
|
130
|
+
offset
|
|
131
|
+
for offset, count in support.items()
|
|
132
|
+
if count == len(candidate_wrappers)
|
|
133
|
+
and len(destination_ids[offset]) >= _MIN_PROOF_DESTINATIONS
|
|
134
|
+
)
|
|
135
|
+
if len(proven_offsets) != 1:
|
|
136
|
+
return None
|
|
137
|
+
observed_offset = proven_offsets[0]
|
|
138
|
+
if observed_offset == configured_offset:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
cohort.mismatch_reported = True
|
|
142
|
+
return StorageDestinationSchemaMismatch(
|
|
143
|
+
opcode=opcode,
|
|
144
|
+
configured_offset=configured_offset,
|
|
145
|
+
observed_offset=observed_offset,
|
|
146
|
+
wrapper_count=len(candidate_wrappers),
|
|
147
|
+
distinct_destinations=len(destination_ids[observed_offset]),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def close_flow(self, flow: FlowKey) -> None:
|
|
151
|
+
"""Discard every generation retained for a closed TCP four-tuple."""
|
|
152
|
+
|
|
153
|
+
for key in tuple(self._cohorts):
|
|
154
|
+
if key[0] == flow:
|
|
155
|
+
del self._cohorts[key]
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def cohort_count(self) -> int:
|
|
159
|
+
"""Number of retained flow-generation/opcode cohorts (for health tests)."""
|
|
160
|
+
|
|
161
|
+
return len(self._cohorts)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def retained_wrapper_count(self) -> int:
|
|
165
|
+
"""Number of compact wrapper fingerprints currently retained."""
|
|
166
|
+
|
|
167
|
+
return sum(len(cohort.wrappers) for cohort in self._cohorts.values())
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Stateful, offset-free storage hydration classification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from collections import deque
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from threading import RLock
|
|
9
|
+
from typing import Callable, Optional
|
|
10
|
+
|
|
11
|
+
from .events import BDOEvent, Flow
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_HydrationKey = tuple[Flow, int]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _hydration_key(event: BDOEvent) -> _HydrationKey:
|
|
18
|
+
return event.flow, event._flow_generation
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class _HydrationBurst:
|
|
23
|
+
first_timestamp: float
|
|
24
|
+
last_timestamp: float
|
|
25
|
+
anchored: bool
|
|
26
|
+
opcode: Optional[int]
|
|
27
|
+
events: list[BDOEvent] = field(default_factory=list)
|
|
28
|
+
destinations: set[int] = field(default_factory=set)
|
|
29
|
+
max_record_count: int = 0
|
|
30
|
+
confirmed: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class StorageHydrationTracker:
|
|
34
|
+
"""Promote neutral storage cohorts to snapshots before app filtering.
|
|
35
|
+
|
|
36
|
+
Inventory hydration opens a bounded epoch on the same flow. Neutral
|
|
37
|
+
storage wrappers are grouped by wire timestamp after manual/worker origin
|
|
38
|
+
evidence has had first refusal. A multi-destination cohort proves state
|
|
39
|
+
hydration without consulting any patch-specific mode or token byte.
|
|
40
|
+
|
|
41
|
+
A broader unanchored threshold retains storage-only snapshot evidence when
|
|
42
|
+
inventory framing was missed. Isolated unresolved records remain neutral
|
|
43
|
+
and therefore never enter ordinary activity filters.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
BURST_GAP_SECONDS = 0.5
|
|
47
|
+
MAX_BURST_SECONDS = 1.0
|
|
48
|
+
HYDRATION_EPOCH_SECONDS = 30.0
|
|
49
|
+
INVENTORY_GENERATION_GAP_SECONDS = 1.0
|
|
50
|
+
MIN_ANCHORED_DESTINATIONS = 8
|
|
51
|
+
MIN_UNANCHORED_DESTINATIONS = 17
|
|
52
|
+
MIN_UNANCHORED_RECORD_COUNT = 8
|
|
53
|
+
MAX_PENDING_EVENTS = 10_000
|
|
54
|
+
|
|
55
|
+
def __init__(self, emit: Callable[[BDOEvent], object]) -> None:
|
|
56
|
+
self._emit = emit
|
|
57
|
+
self._lock = RLock()
|
|
58
|
+
self._inventory_anchor: dict[_HydrationKey, float] = {}
|
|
59
|
+
self._inventory_last: dict[_HydrationKey, float] = {}
|
|
60
|
+
self._bursts: dict[_HydrationKey, _HydrationBurst] = {}
|
|
61
|
+
self._outbox: deque[BDOEvent] = deque()
|
|
62
|
+
self._dispatching = False
|
|
63
|
+
|
|
64
|
+
def observe_inventory(self, event: BDOEvent) -> None:
|
|
65
|
+
"""Record a proven inventory-hydration boundary and emit the event."""
|
|
66
|
+
key = _hydration_key(event)
|
|
67
|
+
with self._lock:
|
|
68
|
+
last = self._inventory_last.get(key)
|
|
69
|
+
if (
|
|
70
|
+
last is None
|
|
71
|
+
or event.timestamp - last > self.INVENTORY_GENERATION_GAP_SECONDS
|
|
72
|
+
):
|
|
73
|
+
self._flush_key_locked(key)
|
|
74
|
+
self._inventory_anchor[key] = event.timestamp
|
|
75
|
+
self._inventory_last[key] = event.timestamp
|
|
76
|
+
self._queue_locked(event)
|
|
77
|
+
self._drain_outbox()
|
|
78
|
+
|
|
79
|
+
def observe_storage(self, event: BDOEvent) -> None:
|
|
80
|
+
"""Stage one neutral record or pass through an independently live one."""
|
|
81
|
+
key = _hydration_key(event)
|
|
82
|
+
if event.event_type != "storage_record":
|
|
83
|
+
with self._lock:
|
|
84
|
+
# Positive mutation evidence is an explicit semantic boundary.
|
|
85
|
+
# It must not inherit a prior confirmed hydration burst, and a
|
|
86
|
+
# subsequent unknown record must prove a new epoch. Origin
|
|
87
|
+
# lookahead can deliver an older live event after a newer
|
|
88
|
+
# hydration anchor, so only retire state at or before this
|
|
89
|
+
# event's own wire timestamp.
|
|
90
|
+
burst = self._bursts.get(key)
|
|
91
|
+
if burst is None or event.timestamp >= burst.first_timestamp:
|
|
92
|
+
self._flush_key_locked(key)
|
|
93
|
+
anchor = self._inventory_anchor.get(key)
|
|
94
|
+
if anchor is None or event.timestamp >= anchor:
|
|
95
|
+
self._inventory_anchor.pop(key, None)
|
|
96
|
+
self._inventory_last.pop(key, None)
|
|
97
|
+
self._queue_locked(event)
|
|
98
|
+
self._drain_outbox()
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
with self._lock:
|
|
102
|
+
anchor = self._inventory_anchor.get(key)
|
|
103
|
+
anchored = (
|
|
104
|
+
anchor is not None
|
|
105
|
+
and anchor <= event.timestamp
|
|
106
|
+
and event.timestamp - anchor <= self.HYDRATION_EPOCH_SECONDS
|
|
107
|
+
)
|
|
108
|
+
burst = self._bursts.get(key)
|
|
109
|
+
if (
|
|
110
|
+
burst is None
|
|
111
|
+
or event.timestamp - burst.last_timestamp > self.BURST_GAP_SECONDS
|
|
112
|
+
or event.timestamp - burst.first_timestamp > self.MAX_BURST_SECONDS
|
|
113
|
+
or event.timestamp < burst.last_timestamp
|
|
114
|
+
or burst.anchored != anchored
|
|
115
|
+
or burst.opcode != event.opcode
|
|
116
|
+
):
|
|
117
|
+
self._flush_key_locked(key)
|
|
118
|
+
burst = _HydrationBurst(
|
|
119
|
+
first_timestamp=event.timestamp,
|
|
120
|
+
last_timestamp=event.timestamp,
|
|
121
|
+
anchored=anchored,
|
|
122
|
+
opcode=event.opcode,
|
|
123
|
+
)
|
|
124
|
+
self._bursts[key] = burst
|
|
125
|
+
|
|
126
|
+
burst.last_timestamp = event.timestamp
|
|
127
|
+
if event.storage_id is not None:
|
|
128
|
+
burst.destinations.add(event.storage_id)
|
|
129
|
+
if isinstance(event.record_count, int) and not isinstance(
|
|
130
|
+
event.record_count, bool
|
|
131
|
+
):
|
|
132
|
+
burst.max_record_count = max(
|
|
133
|
+
burst.max_record_count,
|
|
134
|
+
event.record_count,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if burst.confirmed:
|
|
138
|
+
self._queue_locked(self._as_snapshot(event, burst))
|
|
139
|
+
else:
|
|
140
|
+
burst.events.append(event)
|
|
141
|
+
enough_destinations = len(burst.destinations) >= (
|
|
142
|
+
self.MIN_ANCHORED_DESTINATIONS
|
|
143
|
+
if anchored
|
|
144
|
+
else self.MIN_UNANCHORED_DESTINATIONS
|
|
145
|
+
)
|
|
146
|
+
credible_unanchored = (
|
|
147
|
+
anchored
|
|
148
|
+
or burst.max_record_count >= self.MIN_UNANCHORED_RECORD_COUNT
|
|
149
|
+
)
|
|
150
|
+
if enough_destinations and credible_unanchored:
|
|
151
|
+
burst.confirmed = True
|
|
152
|
+
for pending in burst.events:
|
|
153
|
+
self._queue_locked(self._as_snapshot(pending, burst))
|
|
154
|
+
burst.events.clear()
|
|
155
|
+
elif len(burst.events) > self.MAX_PENDING_EVENTS:
|
|
156
|
+
# Bounded fail-neutral behavior: an unproven cohort must not
|
|
157
|
+
# consume unbounded memory or become activity by default.
|
|
158
|
+
self._flush_key_locked(key)
|
|
159
|
+
self._drain_outbox()
|
|
160
|
+
|
|
161
|
+
def flush_stale(self, now: float) -> None:
|
|
162
|
+
with self._lock:
|
|
163
|
+
stale = tuple(
|
|
164
|
+
key
|
|
165
|
+
for key, burst in self._bursts.items()
|
|
166
|
+
if now - burst.last_timestamp > self.BURST_GAP_SECONDS
|
|
167
|
+
)
|
|
168
|
+
for key in stale:
|
|
169
|
+
self._flush_key_locked(key)
|
|
170
|
+
expired_anchors = tuple(
|
|
171
|
+
key
|
|
172
|
+
for key, anchor in self._inventory_anchor.items()
|
|
173
|
+
if now - anchor > self.HYDRATION_EPOCH_SECONDS
|
|
174
|
+
)
|
|
175
|
+
for key in expired_anchors:
|
|
176
|
+
self._inventory_anchor.pop(key, None)
|
|
177
|
+
self._inventory_last.pop(key, None)
|
|
178
|
+
self._drain_outbox()
|
|
179
|
+
|
|
180
|
+
def close_flow(self, flow: Flow) -> None:
|
|
181
|
+
with self._lock:
|
|
182
|
+
keys = {
|
|
183
|
+
key
|
|
184
|
+
for key in (
|
|
185
|
+
set(self._bursts)
|
|
186
|
+
| set(self._inventory_anchor)
|
|
187
|
+
| set(self._inventory_last)
|
|
188
|
+
)
|
|
189
|
+
if key[0] == flow
|
|
190
|
+
}
|
|
191
|
+
for key in keys:
|
|
192
|
+
self._flush_key_locked(key)
|
|
193
|
+
self._inventory_anchor.pop(key, None)
|
|
194
|
+
self._inventory_last.pop(key, None)
|
|
195
|
+
self._drain_outbox()
|
|
196
|
+
|
|
197
|
+
def finalize_all(self) -> None:
|
|
198
|
+
with self._lock:
|
|
199
|
+
for key in tuple(self._bursts):
|
|
200
|
+
self._flush_key_locked(key)
|
|
201
|
+
self._inventory_anchor.clear()
|
|
202
|
+
self._inventory_last.clear()
|
|
203
|
+
self._drain_outbox()
|
|
204
|
+
|
|
205
|
+
def _flush_key_locked(self, key: _HydrationKey) -> None:
|
|
206
|
+
burst = self._bursts.pop(key, None)
|
|
207
|
+
if burst is None:
|
|
208
|
+
return
|
|
209
|
+
# Confirmed events were emitted as they arrived. Only an unconfirmed
|
|
210
|
+
# burst retains entries, and those remain explicitly neutral.
|
|
211
|
+
for event in burst.events:
|
|
212
|
+
self._queue_locked(event)
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def _as_snapshot(event: BDOEvent, burst: _HydrationBurst) -> BDOEvent:
|
|
216
|
+
return dataclasses.replace(
|
|
217
|
+
event,
|
|
218
|
+
event_type="storage_snapshot",
|
|
219
|
+
source=None,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def _queue_locked(self, event: BDOEvent) -> None:
|
|
223
|
+
self._outbox.append(event)
|
|
224
|
+
|
|
225
|
+
def _drain_outbox(self) -> None:
|
|
226
|
+
with self._lock:
|
|
227
|
+
if self._dispatching:
|
|
228
|
+
return
|
|
229
|
+
self._dispatching = True
|
|
230
|
+
try:
|
|
231
|
+
while True:
|
|
232
|
+
with self._lock:
|
|
233
|
+
if not self._outbox:
|
|
234
|
+
self._dispatching = False
|
|
235
|
+
return
|
|
236
|
+
event = self._outbox.popleft()
|
|
237
|
+
self._emit(event)
|
|
238
|
+
except BaseException:
|
|
239
|
+
with self._lock:
|
|
240
|
+
self._dispatching = False
|
|
241
|
+
raise
|
bdo_toolkit/_version.py
ADDED