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,472 @@
|
|
|
1
|
+
"""Structured progress tracking for opcode-agnostic Solare capture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import Counter, deque
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from typing import Deque, Optional
|
|
10
|
+
|
|
11
|
+
from bdo_toolkit._protocol import BDOFrame
|
|
12
|
+
|
|
13
|
+
from ._constants import (
|
|
14
|
+
DISCOVERY_MAX_FRAME_LENGTH,
|
|
15
|
+
DISCOVERY_MIN_FAMILY_FRAMES,
|
|
16
|
+
DISCOVERY_MIN_FRAME_LENGTH,
|
|
17
|
+
DISCOVERY_MIN_RICH_RECORDS,
|
|
18
|
+
DISCOVERY_RETENTION_MAX_BYTES,
|
|
19
|
+
DISCOVERY_RETENTION_MAX_FRAMES,
|
|
20
|
+
LIVE_CANDIDATE_IDLE_SECONDS,
|
|
21
|
+
)
|
|
22
|
+
from ._discovery import (
|
|
23
|
+
_DiscoveryAnalysisCache,
|
|
24
|
+
DiscoveredSolareFamily,
|
|
25
|
+
SolareDiscoveryResult,
|
|
26
|
+
_family_key,
|
|
27
|
+
_rank_reset_starts,
|
|
28
|
+
discover_solare,
|
|
29
|
+
)
|
|
30
|
+
from .models import SolareUpdate, SolareUpdateKind
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _discovery_score(result: SolareDiscoveryResult) -> tuple[int, int, int]:
|
|
34
|
+
ranked = result.rich or result.ranked_candidate
|
|
35
|
+
partial = result.overall or result.partial_overall
|
|
36
|
+
if result.confirmed:
|
|
37
|
+
stage = 5
|
|
38
|
+
elif result.rich is not None and result.partial_overall is not None:
|
|
39
|
+
stage = 4
|
|
40
|
+
elif result.rich is not None:
|
|
41
|
+
stage = 3
|
|
42
|
+
elif ranked is not None and partial is not None:
|
|
43
|
+
stage = 2
|
|
44
|
+
elif ranked is not None:
|
|
45
|
+
stage = 1
|
|
46
|
+
elif result.menu_context is not None:
|
|
47
|
+
stage = 0
|
|
48
|
+
else:
|
|
49
|
+
stage = -1
|
|
50
|
+
return (
|
|
51
|
+
stage,
|
|
52
|
+
ranked.record_count if ranked is not None else 0,
|
|
53
|
+
partial.record_count if partial is not None else 0,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _compact_discovery(result: SolareDiscoveryResult) -> SolareDiscoveryResult:
|
|
58
|
+
"""Drop raw frame ownership while retaining compact structural evidence."""
|
|
59
|
+
|
|
60
|
+
def compact(
|
|
61
|
+
family: Optional[DiscoveredSolareFamily],
|
|
62
|
+
) -> Optional[DiscoveredSolareFamily]:
|
|
63
|
+
if family is None:
|
|
64
|
+
return None
|
|
65
|
+
return replace(family, frames=())
|
|
66
|
+
|
|
67
|
+
return replace(
|
|
68
|
+
result,
|
|
69
|
+
rich=compact(result.rich),
|
|
70
|
+
overall=compact(result.overall),
|
|
71
|
+
ranked_candidate=compact(result.ranked_candidate),
|
|
72
|
+
partial_overall=compact(result.partial_overall),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LiveSolareDiscoveryTracker:
|
|
77
|
+
"""Refresh structural discovery at useful milestones and emit updates."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, emit: Callable[[SolareUpdate], None]) -> None:
|
|
80
|
+
self._emit = emit
|
|
81
|
+
self._frames: Deque[BDOFrame] = deque()
|
|
82
|
+
self._retained_bytes = 0
|
|
83
|
+
self._peak_retained_frame_count = 0
|
|
84
|
+
self._peak_retained_bytes = 0
|
|
85
|
+
self._evicted_frame_count = 0
|
|
86
|
+
self._evicted_bytes = 0
|
|
87
|
+
self._observed_frame_count = 0
|
|
88
|
+
self._rollover_warned = False
|
|
89
|
+
self._analysis_cache = _DiscoveryAnalysisCache()
|
|
90
|
+
self._family_counts: Counter[tuple[object, int, int, int]] = Counter()
|
|
91
|
+
self._retained_family_counts: Counter[
|
|
92
|
+
tuple[object, int, int, int]
|
|
93
|
+
] = Counter()
|
|
94
|
+
self._family_recent: dict[
|
|
95
|
+
tuple[object, int, int, int], Deque[BDOFrame]
|
|
96
|
+
] = {}
|
|
97
|
+
self._family_targets: dict[tuple[object, int, int, int], set[int]] = {}
|
|
98
|
+
self._announced_menu = False
|
|
99
|
+
self._announced_ranked_players = 300
|
|
100
|
+
self._announced_rich: Optional[tuple[object, ...]] = None
|
|
101
|
+
self._announced_cross_check = 0
|
|
102
|
+
self._announced_confirmed = False
|
|
103
|
+
self._confirmed_frames: Optional[tuple[BDOFrame, ...]] = None
|
|
104
|
+
self._dirty = False
|
|
105
|
+
self._tail_finalized = False
|
|
106
|
+
self._last_candidate_activity_at: Optional[float] = None
|
|
107
|
+
self.result = SolareDiscoveryResult(0, ())
|
|
108
|
+
self._best_result = self.result
|
|
109
|
+
|
|
110
|
+
def observe(self, frame: BDOFrame) -> None:
|
|
111
|
+
if self._confirmed_frames is not None:
|
|
112
|
+
return
|
|
113
|
+
if not (
|
|
114
|
+
frame.flag == 0
|
|
115
|
+
and DISCOVERY_MIN_FRAME_LENGTH
|
|
116
|
+
<= frame.length
|
|
117
|
+
<= DISCOVERY_MAX_FRAME_LENGTH
|
|
118
|
+
):
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
self._last_candidate_activity_at = time.monotonic()
|
|
122
|
+
rolled_over = self._make_room_for(frame)
|
|
123
|
+
if self.complete:
|
|
124
|
+
return
|
|
125
|
+
self._frames.append(frame)
|
|
126
|
+
self._retained_bytes += frame.length
|
|
127
|
+
self._observed_frame_count += 1
|
|
128
|
+
self._update_peaks()
|
|
129
|
+
self._dirty = True
|
|
130
|
+
self._tail_finalized = False
|
|
131
|
+
family_key = _family_key(frame)
|
|
132
|
+
self._family_counts[family_key] += 1
|
|
133
|
+
self._retained_family_counts[family_key] += 1
|
|
134
|
+
family_count = self._family_counts[family_key]
|
|
135
|
+
|
|
136
|
+
recent = self._family_recent.setdefault(family_key, deque(maxlen=10))
|
|
137
|
+
recent.append(frame)
|
|
138
|
+
if len(recent) == 10 and _rank_reset_starts(tuple(recent)) == (0,):
|
|
139
|
+
first_count = family_count - 9
|
|
140
|
+
targets = self._family_targets.setdefault(family_key, set())
|
|
141
|
+
targets.update(
|
|
142
|
+
{
|
|
143
|
+
first_count + 49,
|
|
144
|
+
first_count + 199,
|
|
145
|
+
first_count + 249,
|
|
146
|
+
first_count + 299,
|
|
147
|
+
first_count + 309,
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
reset_interval = family_count in self._family_targets.get(
|
|
151
|
+
family_key,
|
|
152
|
+
(),
|
|
153
|
+
)
|
|
154
|
+
if reset_interval:
|
|
155
|
+
self._family_targets[family_key].discard(family_count)
|
|
156
|
+
|
|
157
|
+
ranked_known = self.result.ranked_candidate is not None
|
|
158
|
+
rich_interval = family_count in {
|
|
159
|
+
DISCOVERY_MIN_RICH_RECORDS // 2,
|
|
160
|
+
250,
|
|
161
|
+
300,
|
|
162
|
+
310,
|
|
163
|
+
}
|
|
164
|
+
related_interval = (
|
|
165
|
+
ranked_known
|
|
166
|
+
and family_count >= DISCOVERY_MIN_FAMILY_FRAMES
|
|
167
|
+
and family_count in {10, 50}
|
|
168
|
+
)
|
|
169
|
+
if (
|
|
170
|
+
family_count == 24
|
|
171
|
+
or rich_interval
|
|
172
|
+
or related_interval
|
|
173
|
+
or reset_interval
|
|
174
|
+
) and not rolled_over:
|
|
175
|
+
self.refresh(end_of_burst=False)
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def complete(self) -> bool:
|
|
179
|
+
return self._confirmed_frames is not None
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def confirmed_frames(self) -> Optional[tuple[BDOFrame, ...]]:
|
|
183
|
+
"""Exact first structurally confirmed window, latched permanently."""
|
|
184
|
+
|
|
185
|
+
return self._confirmed_frames
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def retained_frames(self) -> tuple[BDOFrame, ...]:
|
|
189
|
+
"""Current bounded discovery input, or the exact confirmed window."""
|
|
190
|
+
|
|
191
|
+
return tuple(self._frames)
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def retained_frame_count(self) -> int:
|
|
195
|
+
return len(self._frames)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def retained_bytes(self) -> int:
|
|
199
|
+
return self._retained_bytes
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def peak_retained_frame_count(self) -> int:
|
|
203
|
+
return self._peak_retained_frame_count
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def peak_retained_bytes(self) -> int:
|
|
207
|
+
return self._peak_retained_bytes
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def evicted_frame_count(self) -> int:
|
|
211
|
+
return self._evicted_frame_count
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def evicted_bytes(self) -> int:
|
|
215
|
+
return self._evicted_bytes
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def observed_frame_count(self) -> int:
|
|
219
|
+
return self._observed_frame_count
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def history_rolled_over(self) -> bool:
|
|
223
|
+
return self._rollover_warned
|
|
224
|
+
|
|
225
|
+
def refresh(
|
|
226
|
+
self,
|
|
227
|
+
*,
|
|
228
|
+
end_of_burst: bool = True,
|
|
229
|
+
) -> SolareDiscoveryResult:
|
|
230
|
+
if self._confirmed_frames is not None:
|
|
231
|
+
return self.result
|
|
232
|
+
if not self._dirty and (not end_of_burst or self._tail_finalized):
|
|
233
|
+
return self.result
|
|
234
|
+
candidate = discover_solare(
|
|
235
|
+
self._frames,
|
|
236
|
+
allow_trailing_overall=end_of_burst,
|
|
237
|
+
_analysis_cache=self._analysis_cache,
|
|
238
|
+
)
|
|
239
|
+
candidate = replace(
|
|
240
|
+
candidate,
|
|
241
|
+
scanned_frames=self._observed_frame_count,
|
|
242
|
+
)
|
|
243
|
+
self._dirty = False
|
|
244
|
+
self._tail_finalized = end_of_burst
|
|
245
|
+
if candidate.confirmed:
|
|
246
|
+
self._analysis_cache.clear()
|
|
247
|
+
self.result = candidate
|
|
248
|
+
assert candidate.rich is not None
|
|
249
|
+
assert candidate.overall is not None
|
|
250
|
+
selected: list[BDOFrame] = []
|
|
251
|
+
seen: set[int] = set()
|
|
252
|
+
for frame in (*candidate.rich.frames, *candidate.overall.frames):
|
|
253
|
+
if id(frame) in seen:
|
|
254
|
+
continue
|
|
255
|
+
seen.add(id(frame))
|
|
256
|
+
selected.append(frame)
|
|
257
|
+
for frame in self._frames:
|
|
258
|
+
if id(frame) not in seen:
|
|
259
|
+
self._evicted_frame_count += 1
|
|
260
|
+
self._evicted_bytes += frame.length
|
|
261
|
+
self._confirmed_frames = tuple(selected)
|
|
262
|
+
self._frames = deque(self._confirmed_frames)
|
|
263
|
+
self._retained_bytes = sum(
|
|
264
|
+
frame.length for frame in self._confirmed_frames
|
|
265
|
+
)
|
|
266
|
+
# The selected discovery result and ``_frames`` are the only
|
|
267
|
+
# intentional owners after confirmation. The rolling reset
|
|
268
|
+
# indexes are useful only while acquiring and must not keep
|
|
269
|
+
# evicted or unrelated packet buffers alive behind the public
|
|
270
|
+
# retained-frame/byte accounting.
|
|
271
|
+
self._family_counts.clear()
|
|
272
|
+
self._retained_family_counts.clear()
|
|
273
|
+
self._family_recent.clear()
|
|
274
|
+
self._family_targets.clear()
|
|
275
|
+
else:
|
|
276
|
+
compact = _compact_discovery(candidate)
|
|
277
|
+
if _discovery_score(compact) >= _discovery_score(self._best_result):
|
|
278
|
+
self._best_result = compact
|
|
279
|
+
self.result = replace(
|
|
280
|
+
self._best_result,
|
|
281
|
+
scanned_frames=self._observed_frame_count,
|
|
282
|
+
)
|
|
283
|
+
self._announce_progress()
|
|
284
|
+
return self.result
|
|
285
|
+
|
|
286
|
+
def service_candidate_idle(self, now: float) -> bool:
|
|
287
|
+
"""Finalize a quiet candidate tail despite unrelated game traffic.
|
|
288
|
+
|
|
289
|
+
Returns whether this call changed the tracker from provisional to
|
|
290
|
+
structurally complete. The ordinary discovery checks—including rich-
|
|
291
|
+
prefix rejection—remain authoritative at this boundary.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
if (
|
|
295
|
+
self.complete
|
|
296
|
+
or self._tail_finalized
|
|
297
|
+
or self._last_candidate_activity_at is None
|
|
298
|
+
or now - self._last_candidate_activity_at
|
|
299
|
+
< LIVE_CANDIDATE_IDLE_SECONDS
|
|
300
|
+
):
|
|
301
|
+
return False
|
|
302
|
+
self.refresh()
|
|
303
|
+
return self.complete
|
|
304
|
+
|
|
305
|
+
def _make_room_for(self, frame: BDOFrame) -> bool:
|
|
306
|
+
if (
|
|
307
|
+
len(self._frames) < DISCOVERY_RETENTION_MAX_FRAMES
|
|
308
|
+
and self._retained_bytes + frame.length
|
|
309
|
+
<= DISCOVERY_RETENTION_MAX_BYTES
|
|
310
|
+
):
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
# Evaluate the complete retained window before making room. The
|
|
314
|
+
# low-water marks retain more than the largest structural snapshot, so
|
|
315
|
+
# the incoming frame can still complete a candidate without ownership
|
|
316
|
+
# ever exceeding the advertised hard limits.
|
|
317
|
+
self.refresh(end_of_burst=False)
|
|
318
|
+
if self.complete:
|
|
319
|
+
return True
|
|
320
|
+
|
|
321
|
+
target_frames = DISCOVERY_RETENTION_MAX_FRAMES - max(
|
|
322
|
+
64,
|
|
323
|
+
DISCOVERY_RETENTION_MAX_FRAMES // 8,
|
|
324
|
+
)
|
|
325
|
+
target_bytes = (DISCOVERY_RETENTION_MAX_BYTES * 7) // 8
|
|
326
|
+
self._analysis_cache.clear()
|
|
327
|
+
while self._frames and (
|
|
328
|
+
len(self._frames) >= target_frames
|
|
329
|
+
or self._retained_bytes + frame.length > target_bytes
|
|
330
|
+
):
|
|
331
|
+
evicted = self._frames.popleft()
|
|
332
|
+
self._retained_bytes -= evicted.length
|
|
333
|
+
self._evicted_frame_count += 1
|
|
334
|
+
self._evicted_bytes += evicted.length
|
|
335
|
+
key = _family_key(evicted)
|
|
336
|
+
self._retained_family_counts[key] -= 1
|
|
337
|
+
recent = self._family_recent.get(key)
|
|
338
|
+
if recent is not None and any(item is evicted for item in recent):
|
|
339
|
+
kept = (item for item in recent if item is not evicted)
|
|
340
|
+
replacement: Deque[BDOFrame] = deque(kept, maxlen=10)
|
|
341
|
+
if replacement:
|
|
342
|
+
self._family_recent[key] = replacement
|
|
343
|
+
else:
|
|
344
|
+
self._family_recent.pop(key, None)
|
|
345
|
+
if self._retained_family_counts[key] <= 0:
|
|
346
|
+
self._retained_family_counts.pop(key, None)
|
|
347
|
+
self._family_counts.pop(key, None)
|
|
348
|
+
self._family_recent.pop(key, None)
|
|
349
|
+
self._family_targets.pop(key, None)
|
|
350
|
+
|
|
351
|
+
if not self._rollover_warned:
|
|
352
|
+
self._rollover_warned = True
|
|
353
|
+
self._emit(
|
|
354
|
+
SolareUpdate(
|
|
355
|
+
kind=SolareUpdateKind.WARNING,
|
|
356
|
+
message=(
|
|
357
|
+
"Solare discovery history reached its bounded retention "
|
|
358
|
+
"window; older candidates were discarded after evaluation"
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
self._update_peaks()
|
|
363
|
+
return True
|
|
364
|
+
|
|
365
|
+
def _update_peaks(self) -> None:
|
|
366
|
+
self._peak_retained_frame_count = max(
|
|
367
|
+
self._peak_retained_frame_count,
|
|
368
|
+
len(self._frames),
|
|
369
|
+
)
|
|
370
|
+
self._peak_retained_bytes = max(
|
|
371
|
+
self._peak_retained_bytes,
|
|
372
|
+
self._retained_bytes,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
def _announce_progress(self) -> None:
|
|
376
|
+
result = self.result
|
|
377
|
+
menu = result.menu_context
|
|
378
|
+
if menu is not None and not self._announced_menu:
|
|
379
|
+
self._announced_menu = True
|
|
380
|
+
self._emit(
|
|
381
|
+
SolareUpdate(
|
|
382
|
+
kind=SolareUpdateKind.MENU_CONTEXT,
|
|
383
|
+
message=(
|
|
384
|
+
"Solare menu/history-like traffic was observed, but no "
|
|
385
|
+
"leaderboard load was sent; if the Leaderboard was "
|
|
386
|
+
"already opened, restart the game and retry capture "
|
|
387
|
+
"before opening it"
|
|
388
|
+
),
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
ranked = result.ranked_candidate
|
|
393
|
+
if (
|
|
394
|
+
not result.confirmed
|
|
395
|
+
and result.partial_overall is None
|
|
396
|
+
and ranked is not None
|
|
397
|
+
and ranked.record_count >= self._announced_ranked_players + 100
|
|
398
|
+
):
|
|
399
|
+
self._announced_ranked_players = (
|
|
400
|
+
ranked.record_count // 100
|
|
401
|
+
) * 100
|
|
402
|
+
self._emit(
|
|
403
|
+
SolareUpdate(
|
|
404
|
+
kind=SolareUpdateKind.RANKED_PROGRESS,
|
|
405
|
+
message=(
|
|
406
|
+
f"{ranked.record_count} unique ranked players recovered; "
|
|
407
|
+
"waiting for class balance and the top-100 cross-check"
|
|
408
|
+
),
|
|
409
|
+
ranked_players=ranked.record_count,
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
rich = result.rich
|
|
414
|
+
if rich is not None:
|
|
415
|
+
identity = (
|
|
416
|
+
rich.flow,
|
|
417
|
+
rich.frame_length,
|
|
418
|
+
rich.record_stride,
|
|
419
|
+
rich.name_offset,
|
|
420
|
+
rich.rank_offset,
|
|
421
|
+
rich.class_offset,
|
|
422
|
+
)
|
|
423
|
+
if identity != self._announced_rich:
|
|
424
|
+
self._announced_rich = identity
|
|
425
|
+
self._emit(
|
|
426
|
+
SolareUpdate(
|
|
427
|
+
kind=SolareUpdateKind.RICH_CANDIDATE,
|
|
428
|
+
message=(
|
|
429
|
+
f"structurally valid rich table: {rich.record_count} "
|
|
430
|
+
f"players across {len(rich.class_counts)} class groups"
|
|
431
|
+
),
|
|
432
|
+
ranked_players=rich.record_count,
|
|
433
|
+
)
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
partial = result.partial_overall
|
|
437
|
+
if (
|
|
438
|
+
not result.confirmed
|
|
439
|
+
and ranked is not None
|
|
440
|
+
and partial is not None
|
|
441
|
+
and self._announced_cross_check == 0
|
|
442
|
+
):
|
|
443
|
+
self._announced_cross_check = result.exact_cross_check
|
|
444
|
+
self._emit(
|
|
445
|
+
SolareUpdate(
|
|
446
|
+
kind=SolareUpdateKind.CROSS_CHECK,
|
|
447
|
+
message=(
|
|
448
|
+
f"{result.exact_cross_check}/{partial.record_count} shared "
|
|
449
|
+
"overall rank/name pairs match; continuing for 100 "
|
|
450
|
+
"overall entries"
|
|
451
|
+
),
|
|
452
|
+
ranked_players=ranked.record_count,
|
|
453
|
+
overall_players=partial.record_count,
|
|
454
|
+
exact_cross_check=result.exact_cross_check,
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
if result.confirmed and not self._announced_confirmed:
|
|
459
|
+
self._announced_confirmed = True
|
|
460
|
+
assert result.rich is not None
|
|
461
|
+
assert result.overall is not None
|
|
462
|
+
self._emit(
|
|
463
|
+
SolareUpdate(
|
|
464
|
+
kind=SolareUpdateKind.SNAPSHOT_CONFIRMED,
|
|
465
|
+
message=(
|
|
466
|
+
"opcode-agnostic Arena of Solare leaderboard confirmed"
|
|
467
|
+
),
|
|
468
|
+
ranked_players=result.rich.record_count,
|
|
469
|
+
overall_players=result.overall.record_count,
|
|
470
|
+
exact_cross_check=result.exact_cross_check,
|
|
471
|
+
)
|
|
472
|
+
)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""TCP reassembly and health accounting shared by Solare replay/live APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterable
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from bdo_toolkit._capture_backend import validate_server_ports
|
|
9
|
+
from bdo_toolkit._protocol import BDOFrame
|
|
10
|
+
from bdo_toolkit._reassembly import FlowManager
|
|
11
|
+
|
|
12
|
+
from ._constants import (
|
|
13
|
+
DISCOVERY_MAX_FRAME_LENGTH,
|
|
14
|
+
DISCOVERY_MIN_FRAME_LENGTH,
|
|
15
|
+
SOLARE_MAX_ACTIVE_FLOWS,
|
|
16
|
+
SOLARE_MAX_PENDING_BYTES,
|
|
17
|
+
SOLARE_MAX_PENDING_SEGMENTS,
|
|
18
|
+
)
|
|
19
|
+
from ._scanner import SolareDiscoveryStreamScanner
|
|
20
|
+
from .models import SolareCaptureHealth
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SolareFrameCollector:
|
|
24
|
+
"""Collect generic server frames without selecting a Solare opcode.
|
|
25
|
+
|
|
26
|
+
This object implements the segment-consumer protocol used by pcap replay
|
|
27
|
+
and live packet capture. It deliberately counts only inbound payload on
|
|
28
|
+
the selected game-server ports, matching the bytes that can contribute to
|
|
29
|
+
discovery.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
ports: Iterable[int],
|
|
35
|
+
on_frame: Optional[Callable[[BDOFrame], None]] = None,
|
|
36
|
+
on_traffic: Optional[Callable[[], None]] = None,
|
|
37
|
+
*,
|
|
38
|
+
retain_frames: bool = True,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.ports = validate_server_ports(ports)
|
|
41
|
+
self.frames: list[BDOFrame] = []
|
|
42
|
+
self.payload_segments = 0
|
|
43
|
+
self.payload_bytes = 0
|
|
44
|
+
self.tcp_gap_resets = 0
|
|
45
|
+
self.flow_state_evictions = 0
|
|
46
|
+
self.synchronized_messages = 0
|
|
47
|
+
self._retention_enabled = True
|
|
48
|
+
self._retain_frames = retain_frames
|
|
49
|
+
self._on_frame = on_frame
|
|
50
|
+
self._on_traffic = on_traffic
|
|
51
|
+
self._manager = FlowManager(
|
|
52
|
+
server_ports=self.ports,
|
|
53
|
+
scanner_factory=lambda: SolareDiscoveryStreamScanner(
|
|
54
|
+
self._accept_frame,
|
|
55
|
+
self._count_gap_reset,
|
|
56
|
+
),
|
|
57
|
+
max_flows=SOLARE_MAX_ACTIVE_FLOWS,
|
|
58
|
+
max_pending_segments=SOLARE_MAX_PENDING_SEGMENTS,
|
|
59
|
+
max_pending_bytes=SOLARE_MAX_PENDING_BYTES,
|
|
60
|
+
on_flow_eviction=self._count_flow_eviction,
|
|
61
|
+
track_flow_generations=True,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def _accept_frame(self, frame: BDOFrame) -> None:
|
|
65
|
+
self.synchronized_messages += 1
|
|
66
|
+
if not self._retention_enabled:
|
|
67
|
+
return
|
|
68
|
+
if self._on_frame is not None:
|
|
69
|
+
self._on_frame(frame)
|
|
70
|
+
if (
|
|
71
|
+
self._retain_frames
|
|
72
|
+
and frame.flag == 0
|
|
73
|
+
and DISCOVERY_MIN_FRAME_LENGTH
|
|
74
|
+
<= frame.length
|
|
75
|
+
<= DISCOVERY_MAX_FRAME_LENGTH
|
|
76
|
+
):
|
|
77
|
+
self.frames.append(frame)
|
|
78
|
+
|
|
79
|
+
def stop_retaining(self) -> None:
|
|
80
|
+
"""Freeze discovery input after the first snapshot is latched.
|
|
81
|
+
|
|
82
|
+
Packet capture and health accounting may continue (for example with
|
|
83
|
+
``stop_on_complete=False``), but later frames cannot change the atomic
|
|
84
|
+
result and therefore must not grow memory without bound.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
self._retention_enabled = False
|
|
88
|
+
|
|
89
|
+
def _count_gap_reset(self) -> None:
|
|
90
|
+
self.tcp_gap_resets += 1
|
|
91
|
+
|
|
92
|
+
def _count_flow_eviction(self) -> None:
|
|
93
|
+
self.flow_state_evictions += 1
|
|
94
|
+
|
|
95
|
+
def process_tcp_segment(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
source_ip: str,
|
|
99
|
+
source_port: int,
|
|
100
|
+
destination_ip: str,
|
|
101
|
+
destination_port: int,
|
|
102
|
+
sequence: int,
|
|
103
|
+
payload: bytes,
|
|
104
|
+
timestamp: float,
|
|
105
|
+
syn: bool = False,
|
|
106
|
+
rst: bool = False,
|
|
107
|
+
fin: bool = False,
|
|
108
|
+
) -> None:
|
|
109
|
+
if source_port in self.ports and payload:
|
|
110
|
+
self.payload_segments += 1
|
|
111
|
+
self.payload_bytes += len(payload)
|
|
112
|
+
if self._on_traffic is not None:
|
|
113
|
+
self._on_traffic()
|
|
114
|
+
self._manager.process_tcp_segment(
|
|
115
|
+
source_ip=source_ip,
|
|
116
|
+
source_port=source_port,
|
|
117
|
+
destination_ip=destination_ip,
|
|
118
|
+
destination_port=destination_port,
|
|
119
|
+
sequence=sequence,
|
|
120
|
+
payload=payload,
|
|
121
|
+
timestamp=timestamp,
|
|
122
|
+
syn=syn,
|
|
123
|
+
rst=rst,
|
|
124
|
+
fin=fin,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def finish(self) -> None:
|
|
128
|
+
self._manager.finish()
|
|
129
|
+
|
|
130
|
+
def service_gaps(self, now: float) -> int:
|
|
131
|
+
"""Advance TCP gap clocks after the live decode queue is drained."""
|
|
132
|
+
|
|
133
|
+
return self._manager.service_gaps(now)
|
|
134
|
+
|
|
135
|
+
def health(
|
|
136
|
+
self,
|
|
137
|
+
*,
|
|
138
|
+
saved_packets: int = 0,
|
|
139
|
+
pcap_received: Optional[int] = None,
|
|
140
|
+
pcap_dropped: Optional[int] = None,
|
|
141
|
+
pcap_interface_dropped: Optional[int] = None,
|
|
142
|
+
capture_buffer_bytes: Optional[int] = None,
|
|
143
|
+
retained_large_messages: Optional[int] = None,
|
|
144
|
+
candidate_messages_observed: int = 0,
|
|
145
|
+
candidate_frames_retained: int = 0,
|
|
146
|
+
candidate_bytes_retained: int = 0,
|
|
147
|
+
peak_candidate_frames: int = 0,
|
|
148
|
+
peak_candidate_bytes: int = 0,
|
|
149
|
+
candidate_frames_evicted: int = 0,
|
|
150
|
+
candidate_bytes_evicted: int = 0,
|
|
151
|
+
candidate_history_rolled_over: bool = False,
|
|
152
|
+
packet_queue_peak: int = 0,
|
|
153
|
+
packet_queue_overflows: int = 0,
|
|
154
|
+
flow_state_evictions: int = 0,
|
|
155
|
+
) -> SolareCaptureHealth:
|
|
156
|
+
return SolareCaptureHealth(
|
|
157
|
+
payload_segments=self.payload_segments,
|
|
158
|
+
payload_bytes=self.payload_bytes,
|
|
159
|
+
synchronized_messages=self.synchronized_messages,
|
|
160
|
+
retained_large_messages=(
|
|
161
|
+
len(self.frames)
|
|
162
|
+
if retained_large_messages is None
|
|
163
|
+
else retained_large_messages
|
|
164
|
+
),
|
|
165
|
+
tcp_gap_resets=self.tcp_gap_resets,
|
|
166
|
+
pcap_received=pcap_received,
|
|
167
|
+
pcap_dropped=pcap_dropped,
|
|
168
|
+
pcap_interface_dropped=pcap_interface_dropped,
|
|
169
|
+
capture_buffer_bytes=capture_buffer_bytes,
|
|
170
|
+
saved_packets=saved_packets,
|
|
171
|
+
candidate_messages_observed=candidate_messages_observed,
|
|
172
|
+
candidate_frames_retained=candidate_frames_retained,
|
|
173
|
+
candidate_bytes_retained=candidate_bytes_retained,
|
|
174
|
+
peak_candidate_frames=peak_candidate_frames,
|
|
175
|
+
peak_candidate_bytes=peak_candidate_bytes,
|
|
176
|
+
candidate_frames_evicted=candidate_frames_evicted,
|
|
177
|
+
candidate_bytes_evicted=candidate_bytes_evicted,
|
|
178
|
+
candidate_history_rolled_over=candidate_history_rolled_over,
|
|
179
|
+
packet_queue_peak=packet_queue_peak,
|
|
180
|
+
packet_queue_overflows=packet_queue_overflows,
|
|
181
|
+
flow_state_evictions=self.flow_state_evictions + flow_state_evictions,
|
|
182
|
+
)
|