bdo-toolkit 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. bdo_toolkit/__init__.py +87 -0
  2. bdo_toolkit/_async_sessions.py +651 -0
  3. bdo_toolkit/_capture_backend.py +194 -0
  4. bdo_toolkit/_capture_options.py +68 -0
  5. bdo_toolkit/_capture_runtime.py +626 -0
  6. bdo_toolkit/_deposit_origin.py +1599 -0
  7. bdo_toolkit/_engine.py +327 -0
  8. bdo_toolkit/_framing.py +904 -0
  9. bdo_toolkit/_profile_runtime.py +157 -0
  10. bdo_toolkit/_protocol.py +386 -0
  11. bdo_toolkit/_reassembly.py +654 -0
  12. bdo_toolkit/_specs.py +285 -0
  13. bdo_toolkit/_storage_destination_validation.py +167 -0
  14. bdo_toolkit/_storage_hydration.py +241 -0
  15. bdo_toolkit/_version.py +3 -0
  16. bdo_toolkit/calibration.py +3223 -0
  17. bdo_toolkit/capture.py +1713 -0
  18. bdo_toolkit/character_state.py +3506 -0
  19. bdo_toolkit/cli.py +948 -0
  20. bdo_toolkit/diagnostics.py +51 -0
  21. bdo_toolkit/events.py +214 -0
  22. bdo_toolkit/filters.py +105 -0
  23. bdo_toolkit/item_state.py +48 -0
  24. bdo_toolkit/origin_learning.py +779 -0
  25. bdo_toolkit/profiles.py +370 -0
  26. bdo_toolkit/py.typed +1 -0
  27. bdo_toolkit/remote_profiles.py +358 -0
  28. bdo_toolkit/solare/__init__.py +50 -0
  29. bdo_toolkit/solare/_constants.py +94 -0
  30. bdo_toolkit/solare/_detail_learning.py +1437 -0
  31. bdo_toolkit/solare/_details.py +796 -0
  32. bdo_toolkit/solare/_discovery.py +1212 -0
  33. bdo_toolkit/solare/_live_tracker.py +472 -0
  34. bdo_toolkit/solare/_replay_capture.py +182 -0
  35. bdo_toolkit/solare/_result.py +441 -0
  36. bdo_toolkit/solare/_scanner.py +203 -0
  37. bdo_toolkit/solare/_validation.py +11 -0
  38. bdo_toolkit/solare/async_session.py +444 -0
  39. bdo_toolkit/solare/models.py +806 -0
  40. bdo_toolkit/solare/replay.py +62 -0
  41. bdo_toolkit/solare/session.py +1051 -0
  42. bdo_toolkit/writers.py +30 -0
  43. bdo_toolkit-1.0.0.dist-info/METADATA +143 -0
  44. bdo_toolkit-1.0.0.dist-info/RECORD +48 -0
  45. bdo_toolkit-1.0.0.dist-info/WHEEL +5 -0
  46. bdo_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
  47. bdo_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
  48. bdo_toolkit-1.0.0.dist-info/top_level.txt +1 -0
bdo_toolkit/_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
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the BDO Toolkit release version."""
2
+
3
+ __version__ = "1.0.0"