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,1212 @@
|
|
|
1
|
+
"""Opcode-agnostic structural classifier for Arena of Solare leaderboards."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from collections import Counter, OrderedDict, defaultdict
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import DefaultDict, Iterable, Optional, Sequence, cast
|
|
9
|
+
|
|
10
|
+
from bdo_toolkit._protocol import BDOFrame, FlowKey
|
|
11
|
+
|
|
12
|
+
from ._constants import (
|
|
13
|
+
BDO_HEADER_SIZE,
|
|
14
|
+
CLASS_NAMES,
|
|
15
|
+
DISCOVERY_FIELD_SCAN_LIMIT,
|
|
16
|
+
DISCOVERY_MAX_CLASS_GROUPS,
|
|
17
|
+
DISCOVERY_MAX_FAMILY_DISTANCE,
|
|
18
|
+
DISCOVERY_MAX_FRAME_LENGTH,
|
|
19
|
+
DISCOVERY_MIN_FAMILY_FRAMES,
|
|
20
|
+
DISCOVERY_MIN_FRAME_LENGTH,
|
|
21
|
+
DISCOVERY_MIN_PARTIAL_OVERALL_RECORDS,
|
|
22
|
+
DISCOVERY_MIN_RICH_RECORDS,
|
|
23
|
+
DISCOVERY_NAME_PAIR_TOLERANCE,
|
|
24
|
+
PLAYER_NAME_BYTES,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_FamilyKey = tuple[FlowKey, int, int, int]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _family_key(frame: BDOFrame) -> _FamilyKey:
|
|
32
|
+
return (
|
|
33
|
+
frame.context.flow,
|
|
34
|
+
frame.context.flow_generation,
|
|
35
|
+
frame.opcode,
|
|
36
|
+
frame.length,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _u32le(data: bytes, offset: int) -> int:
|
|
41
|
+
return int.from_bytes(data[offset : offset + 4], "little")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _stream_offset(frame: BDOFrame) -> int:
|
|
45
|
+
return frame.stream_sequence if frame.stream_sequence is not None else frame.index
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _stream_end(frame: BDOFrame) -> int:
|
|
49
|
+
if frame.stream_sequence is not None:
|
|
50
|
+
return frame.stream_sequence + frame.length
|
|
51
|
+
# ``frame.index`` is an ordinal, not a byte coordinate.
|
|
52
|
+
return frame.index + 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class DiscoveredSolareFamily:
|
|
57
|
+
"""One structurally identified family; opcode is reported, not trusted."""
|
|
58
|
+
|
|
59
|
+
role: str
|
|
60
|
+
flow: FlowKey
|
|
61
|
+
opcode: int
|
|
62
|
+
frame_length: int
|
|
63
|
+
frame_count: int
|
|
64
|
+
record_stride: int
|
|
65
|
+
name_offset: int
|
|
66
|
+
rank_offset: int
|
|
67
|
+
names: tuple[str, ...]
|
|
68
|
+
ranks: tuple[int, ...]
|
|
69
|
+
first_stream_offset: int
|
|
70
|
+
last_stream_end: int
|
|
71
|
+
class_offset: Optional[int] = None
|
|
72
|
+
class_codes: tuple[int, ...] = ()
|
|
73
|
+
class_counts: tuple[tuple[int, int], ...] = ()
|
|
74
|
+
first_observation_ordinal: int = 0
|
|
75
|
+
completion_observation_ordinal: int = 0
|
|
76
|
+
frames: tuple[BDOFrame, ...] = field(
|
|
77
|
+
default=(),
|
|
78
|
+
repr=False,
|
|
79
|
+
compare=False,
|
|
80
|
+
)
|
|
81
|
+
flow_generation: int = 0
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def record_count(self) -> int:
|
|
85
|
+
return len(self.names)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class SolareMenuContext:
|
|
90
|
+
flow: FlowKey
|
|
91
|
+
opcode: int
|
|
92
|
+
frame_length: int
|
|
93
|
+
frame_count: int
|
|
94
|
+
player_slots_per_frame: int
|
|
95
|
+
record_stride: int
|
|
96
|
+
unique_names: int
|
|
97
|
+
flow_generation: int = 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class SolareDiscoveryResult:
|
|
102
|
+
scanned_frames: int
|
|
103
|
+
candidate_families: tuple[tuple[int, int, int], ...]
|
|
104
|
+
rich: Optional[DiscoveredSolareFamily] = None
|
|
105
|
+
overall: Optional[DiscoveredSolareFamily] = None
|
|
106
|
+
ranked_candidate: Optional[DiscoveredSolareFamily] = None
|
|
107
|
+
partial_overall: Optional[DiscoveredSolareFamily] = None
|
|
108
|
+
menu_context: Optional[SolareMenuContext] = None
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def confirmed(self) -> bool:
|
|
112
|
+
return self.rich is not None and self.overall is not None
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def exact_cross_check(self) -> int:
|
|
116
|
+
"""Number of shared rank/name pairs supporting the best overall table."""
|
|
117
|
+
|
|
118
|
+
ranked = self.rich or self.ranked_candidate
|
|
119
|
+
overall = self.overall or self.partial_overall
|
|
120
|
+
if ranked is None or overall is None:
|
|
121
|
+
return 0
|
|
122
|
+
overlap = _cross_check_overlap(ranked, overall.names, overall.ranks)
|
|
123
|
+
return overlap or 0
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def status(self) -> str:
|
|
127
|
+
if self.confirmed:
|
|
128
|
+
return "complete"
|
|
129
|
+
if self.ranked_candidate is not None and self.partial_overall is not None:
|
|
130
|
+
return "detected-incomplete"
|
|
131
|
+
if self.rich is not None:
|
|
132
|
+
return "rich-candidate"
|
|
133
|
+
if self.ranked_candidate is not None:
|
|
134
|
+
return "ranked-partial"
|
|
135
|
+
if self.menu_context is not None:
|
|
136
|
+
return "menu-context"
|
|
137
|
+
return "inconclusive"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class _NamePair:
|
|
142
|
+
first_offset: int
|
|
143
|
+
second_offset: int
|
|
144
|
+
stride: int
|
|
145
|
+
names: tuple[str, ...]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _read_player_identifier(data: bytes, offset: int, max_chars: int = 32) -> str:
|
|
149
|
+
if offset < 0:
|
|
150
|
+
return ""
|
|
151
|
+
raw = bytearray()
|
|
152
|
+
for index in range(max_chars):
|
|
153
|
+
position = offset + index * 2
|
|
154
|
+
if position + 1 >= len(data):
|
|
155
|
+
return ""
|
|
156
|
+
low = data[position]
|
|
157
|
+
high = data[position + 1]
|
|
158
|
+
if low == 0 and high == 0:
|
|
159
|
+
return raw.decode("ascii") if raw else ""
|
|
160
|
+
if high != 0 or low not in PLAYER_NAME_BYTES:
|
|
161
|
+
return ""
|
|
162
|
+
raw.append(low)
|
|
163
|
+
return ""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _identifier_starts_here(data: bytes, offset: int) -> bool:
|
|
167
|
+
"""Return whether ``offset`` is the start, not a suffix, of an identifier."""
|
|
168
|
+
|
|
169
|
+
if not _read_player_identifier(data, offset):
|
|
170
|
+
return False
|
|
171
|
+
if (
|
|
172
|
+
offset >= 2
|
|
173
|
+
and data[offset - 1] == 0
|
|
174
|
+
and data[offset - 2] in PLAYER_NAME_BYTES
|
|
175
|
+
):
|
|
176
|
+
return False
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _candidate_name_pairs(frames: Sequence[BDOFrame]) -> list[_NamePair]:
|
|
181
|
+
if not frames:
|
|
182
|
+
return []
|
|
183
|
+
first = frames[0].message
|
|
184
|
+
columns: list[tuple[int, tuple[str, ...]]] = []
|
|
185
|
+
for offset in range(BDO_HEADER_SIZE, len(first) - 1):
|
|
186
|
+
if not _identifier_starts_here(first, offset):
|
|
187
|
+
continue
|
|
188
|
+
values = tuple(
|
|
189
|
+
_read_player_identifier(frame.message, offset) for frame in frames
|
|
190
|
+
)
|
|
191
|
+
if not all(values) or len(set(values)) != len(values):
|
|
192
|
+
continue
|
|
193
|
+
columns.append((offset, values))
|
|
194
|
+
|
|
195
|
+
pairs: list[_NamePair] = []
|
|
196
|
+
for index, (first_offset, first_values) in enumerate(columns):
|
|
197
|
+
for second_offset, second_values in columns[index + 1 :]:
|
|
198
|
+
stride = second_offset - first_offset
|
|
199
|
+
if abs(stride - (len(first) / 2)) > DISCOVERY_NAME_PAIR_TOLERANCE:
|
|
200
|
+
continue
|
|
201
|
+
interleaved = tuple(
|
|
202
|
+
value for pair in zip(first_values, second_values) for value in pair
|
|
203
|
+
)
|
|
204
|
+
if len(set(interleaved)) != len(interleaved):
|
|
205
|
+
continue
|
|
206
|
+
pairs.append(
|
|
207
|
+
_NamePair(
|
|
208
|
+
first_offset=first_offset,
|
|
209
|
+
second_offset=second_offset,
|
|
210
|
+
stride=stride,
|
|
211
|
+
names=interleaved,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
return pairs
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _infer_increasing_rank_field(
|
|
218
|
+
frames: Sequence[BDOFrame], record_stride: int
|
|
219
|
+
) -> Optional[tuple[int, tuple[int, ...]]]:
|
|
220
|
+
"""Find the class-table rank column without requiring ranks 21..100.
|
|
221
|
+
|
|
222
|
+
The rich response is the union of twenty entries per class. Global ranks
|
|
223
|
+
1 through 20 must therefore be present regardless of class distribution:
|
|
224
|
+
no player in that prefix can have twenty same-class players ahead of them.
|
|
225
|
+
Rank 21 and later may legitimately be absent when one class contributes
|
|
226
|
+
more than twenty players to the independent overall top 100.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
scan_end = min(record_stride - 4, DISCOVERY_FIELD_SCAN_LIMIT)
|
|
230
|
+
for offset in range(BDO_HEADER_SIZE, max(BDO_HEADER_SIZE, scan_end + 1)):
|
|
231
|
+
if record_stride + offset + 4 > frames[0].length:
|
|
232
|
+
continue
|
|
233
|
+
prefix_frames = frames[:10]
|
|
234
|
+
prefix = tuple(
|
|
235
|
+
value
|
|
236
|
+
for frame in prefix_frames
|
|
237
|
+
for value in (
|
|
238
|
+
_u32le(frame.message, offset),
|
|
239
|
+
_u32le(frame.message, record_stride + offset),
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
if prefix != tuple(range(1, len(prefix) + 1)):
|
|
243
|
+
continue
|
|
244
|
+
values = prefix + tuple(
|
|
245
|
+
value
|
|
246
|
+
for frame in frames[len(prefix_frames) :]
|
|
247
|
+
for value in (
|
|
248
|
+
_u32le(frame.message, offset),
|
|
249
|
+
_u32le(frame.message, record_stride + offset),
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
if len(values) < 100 or len(set(values)) != len(values):
|
|
253
|
+
continue
|
|
254
|
+
if any(value <= 0 for value in values):
|
|
255
|
+
continue
|
|
256
|
+
if any(left >= right for left, right in zip(values, values[1:])):
|
|
257
|
+
continue
|
|
258
|
+
return offset, values
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _infer_partial_overall_rank_field(
|
|
263
|
+
frames: Sequence[BDOFrame], record_stride: int
|
|
264
|
+
) -> Optional[tuple[int, tuple[int, ...]]]:
|
|
265
|
+
scan_end = min(record_stride - 4, DISCOVERY_FIELD_SCAN_LIMIT)
|
|
266
|
+
for offset in range(BDO_HEADER_SIZE, max(BDO_HEADER_SIZE, scan_end + 1)):
|
|
267
|
+
if record_stride + offset + 4 > frames[0].length:
|
|
268
|
+
continue
|
|
269
|
+
values = tuple(
|
|
270
|
+
value
|
|
271
|
+
for frame in frames
|
|
272
|
+
for value in (
|
|
273
|
+
_u32le(frame.message, offset),
|
|
274
|
+
_u32le(frame.message, record_stride + offset),
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
if len(values) < DISCOVERY_MIN_PARTIAL_OVERALL_RECORDS:
|
|
278
|
+
continue
|
|
279
|
+
if len(set(values)) != len(values):
|
|
280
|
+
continue
|
|
281
|
+
if not all(1 <= value <= 100 for value in values):
|
|
282
|
+
continue
|
|
283
|
+
if any(left >= right for left, right in zip(values, values[1:])):
|
|
284
|
+
continue
|
|
285
|
+
return offset, values
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _infer_class_field(
|
|
290
|
+
frames: Sequence[BDOFrame], record_stride: int
|
|
291
|
+
) -> Optional[tuple[int, tuple[int, ...], tuple[tuple[int, int], ...]]]:
|
|
292
|
+
scan_end = min(record_stride - 1, DISCOVERY_FIELD_SCAN_LIMIT)
|
|
293
|
+
candidates: list[
|
|
294
|
+
tuple[int, tuple[int, ...], tuple[tuple[int, int], ...]]
|
|
295
|
+
] = []
|
|
296
|
+
for offset in range(BDO_HEADER_SIZE, max(BDO_HEADER_SIZE, scan_end + 1)):
|
|
297
|
+
if record_stride + offset >= frames[0].length:
|
|
298
|
+
continue
|
|
299
|
+
values = tuple(
|
|
300
|
+
value
|
|
301
|
+
for frame in frames
|
|
302
|
+
for value in (
|
|
303
|
+
frame.message[offset],
|
|
304
|
+
frame.message[record_stride + offset],
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
counts = Counter(values)
|
|
308
|
+
if not 20 <= len(counts) <= DISCOVERY_MAX_CLASS_GROUPS:
|
|
309
|
+
continue
|
|
310
|
+
if not counts or any(count != 20 for count in counts.values()):
|
|
311
|
+
continue
|
|
312
|
+
candidates.append((offset, values, tuple(sorted(counts.items()))))
|
|
313
|
+
|
|
314
|
+
if not candidates:
|
|
315
|
+
return None
|
|
316
|
+
return max(
|
|
317
|
+
candidates,
|
|
318
|
+
key=lambda candidate: (
|
|
319
|
+
sum(code in CLASS_NAMES for code, _count in candidate[2]),
|
|
320
|
+
-candidate[0],
|
|
321
|
+
),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _looks_like_class_table_prefix(
|
|
326
|
+
frames: Sequence[BDOFrame], record_stride: int
|
|
327
|
+
) -> bool:
|
|
328
|
+
"""Reject an exact top-100 prefix cut from another rich class table.
|
|
329
|
+
|
|
330
|
+
A rich response is arranged in consecutive 20-player class groups. Its
|
|
331
|
+
first 50 frames therefore expose five distinct known class codes in five
|
|
332
|
+
exact runs of twenty. An idle capture boundary is not evidence that such
|
|
333
|
+
a prefix is the independent overall table.
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
record_count = len(frames) * 2
|
|
337
|
+
if record_count != 100:
|
|
338
|
+
return False
|
|
339
|
+
scan_end = min(record_stride - 1, DISCOVERY_FIELD_SCAN_LIMIT)
|
|
340
|
+
for offset in range(BDO_HEADER_SIZE, max(BDO_HEADER_SIZE, scan_end + 1)):
|
|
341
|
+
if record_stride + offset >= frames[0].length:
|
|
342
|
+
continue
|
|
343
|
+
values = tuple(
|
|
344
|
+
value
|
|
345
|
+
for frame in frames
|
|
346
|
+
for value in (
|
|
347
|
+
frame.message[offset],
|
|
348
|
+
frame.message[record_stride + offset],
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
groups = tuple(
|
|
352
|
+
values[start : start + 20]
|
|
353
|
+
for start in range(0, record_count, 20)
|
|
354
|
+
)
|
|
355
|
+
codes = tuple(group[0] for group in groups)
|
|
356
|
+
if (
|
|
357
|
+
len(set(codes)) == len(codes)
|
|
358
|
+
and all(code in CLASS_NAMES for code in codes)
|
|
359
|
+
and all(all(value == group[0] for value in group) for group in groups)
|
|
360
|
+
):
|
|
361
|
+
return True
|
|
362
|
+
return False
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _observation_bounds(
|
|
366
|
+
frames: Sequence[BDOFrame],
|
|
367
|
+
observation_ordinals: dict[int, int],
|
|
368
|
+
) -> tuple[int, int]:
|
|
369
|
+
ordinals = tuple(observation_ordinals[id(frame)] for frame in frames)
|
|
370
|
+
return min(ordinals), max(ordinals)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _discover_ranked_family(
|
|
374
|
+
key: _FamilyKey,
|
|
375
|
+
frames: Sequence[BDOFrame],
|
|
376
|
+
observation_ordinals: dict[int, int],
|
|
377
|
+
) -> Optional[DiscoveredSolareFamily]:
|
|
378
|
+
if len(frames) * 2 < DISCOVERY_MIN_RICH_RECORDS:
|
|
379
|
+
return None
|
|
380
|
+
for name_pair in _candidate_name_pairs(frames):
|
|
381
|
+
rank_field = _infer_increasing_rank_field(frames, name_pair.stride)
|
|
382
|
+
if rank_field is None:
|
|
383
|
+
continue
|
|
384
|
+
rank_offset, ranks = rank_field
|
|
385
|
+
flow, flow_generation, opcode, frame_length = key
|
|
386
|
+
first_observation, completion_observation = _observation_bounds(
|
|
387
|
+
frames,
|
|
388
|
+
observation_ordinals,
|
|
389
|
+
)
|
|
390
|
+
return DiscoveredSolareFamily(
|
|
391
|
+
role="ranked-partial",
|
|
392
|
+
flow=flow,
|
|
393
|
+
opcode=opcode,
|
|
394
|
+
frame_length=frame_length,
|
|
395
|
+
frame_count=len(frames),
|
|
396
|
+
record_stride=name_pair.stride,
|
|
397
|
+
name_offset=name_pair.first_offset,
|
|
398
|
+
rank_offset=rank_offset,
|
|
399
|
+
names=name_pair.names,
|
|
400
|
+
ranks=ranks,
|
|
401
|
+
first_stream_offset=min(_stream_offset(frame) for frame in frames),
|
|
402
|
+
last_stream_end=max(_stream_end(frame) for frame in frames),
|
|
403
|
+
flow_generation=flow_generation,
|
|
404
|
+
first_observation_ordinal=first_observation,
|
|
405
|
+
completion_observation_ordinal=completion_observation,
|
|
406
|
+
frames=tuple(frames),
|
|
407
|
+
)
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _discover_rich_family(
|
|
412
|
+
key: _FamilyKey,
|
|
413
|
+
frames: Sequence[BDOFrame],
|
|
414
|
+
ranked: DiscoveredSolareFamily,
|
|
415
|
+
) -> Optional[DiscoveredSolareFamily]:
|
|
416
|
+
class_field = _infer_class_field(frames, ranked.record_stride)
|
|
417
|
+
if class_field is None:
|
|
418
|
+
return None
|
|
419
|
+
class_offset, class_codes, class_counts = class_field
|
|
420
|
+
if len(ranked.names) != sum(count for _code, count in class_counts):
|
|
421
|
+
return None
|
|
422
|
+
# A balanced prefix is not a complete leaderboard. The three validated
|
|
423
|
+
# generations all carry every currently known Arena class exactly twenty
|
|
424
|
+
# times (31 x 20 = 620). Requiring that full roster prevents a capture gap
|
|
425
|
+
# that happens to leave 20 balanced class groups from being promoted as an
|
|
426
|
+
# atomic snapshot. A newly added/removed class therefore fails closed until
|
|
427
|
+
# the toolkit's class registry is reviewed.
|
|
428
|
+
expected_class_counts = tuple((code, 20) for code in sorted(CLASS_NAMES))
|
|
429
|
+
if class_counts != expected_class_counts:
|
|
430
|
+
return None
|
|
431
|
+
return DiscoveredSolareFamily(
|
|
432
|
+
role="rich",
|
|
433
|
+
flow=ranked.flow,
|
|
434
|
+
opcode=ranked.opcode,
|
|
435
|
+
frame_length=ranked.frame_length,
|
|
436
|
+
frame_count=ranked.frame_count,
|
|
437
|
+
record_stride=ranked.record_stride,
|
|
438
|
+
name_offset=ranked.name_offset,
|
|
439
|
+
rank_offset=ranked.rank_offset,
|
|
440
|
+
class_offset=class_offset,
|
|
441
|
+
names=ranked.names,
|
|
442
|
+
ranks=ranked.ranks,
|
|
443
|
+
class_codes=class_codes,
|
|
444
|
+
class_counts=class_counts,
|
|
445
|
+
first_stream_offset=ranked.first_stream_offset,
|
|
446
|
+
last_stream_end=ranked.last_stream_end,
|
|
447
|
+
flow_generation=ranked.flow_generation,
|
|
448
|
+
first_observation_ordinal=ranked.first_observation_ordinal,
|
|
449
|
+
completion_observation_ordinal=ranked.completion_observation_ordinal,
|
|
450
|
+
frames=ranked.frames,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _discover_overall_family(
|
|
455
|
+
key: _FamilyKey,
|
|
456
|
+
frames: Sequence[BDOFrame],
|
|
457
|
+
rich: DiscoveredSolareFamily,
|
|
458
|
+
observation_ordinals: dict[int, int],
|
|
459
|
+
) -> Optional[DiscoveredSolareFamily]:
|
|
460
|
+
if (
|
|
461
|
+
len(frames) != 50
|
|
462
|
+
or key[0] != rich.flow
|
|
463
|
+
or key[1] != rich.flow_generation
|
|
464
|
+
):
|
|
465
|
+
return None
|
|
466
|
+
first_stream_offset = min(_stream_offset(frame) for frame in frames)
|
|
467
|
+
distance = first_stream_offset - rich.last_stream_end
|
|
468
|
+
if not 0 <= distance <= DISCOVERY_MAX_FAMILY_DISTANCE:
|
|
469
|
+
return None
|
|
470
|
+
class_prefix_by_stride: dict[int, bool] = {}
|
|
471
|
+
for name_pair in _candidate_name_pairs(frames):
|
|
472
|
+
if name_pair.stride not in class_prefix_by_stride:
|
|
473
|
+
class_prefix_by_stride[name_pair.stride] = (
|
|
474
|
+
_looks_like_class_table_prefix(frames, name_pair.stride)
|
|
475
|
+
)
|
|
476
|
+
if class_prefix_by_stride[name_pair.stride]:
|
|
477
|
+
continue
|
|
478
|
+
rank_field = _infer_increasing_rank_field(frames, name_pair.stride)
|
|
479
|
+
if rank_field is None:
|
|
480
|
+
continue
|
|
481
|
+
rank_offset, ranks = rank_field
|
|
482
|
+
if ranks != tuple(range(1, 101)):
|
|
483
|
+
continue
|
|
484
|
+
if _cross_check_overlap(rich, name_pair.names, ranks) is None:
|
|
485
|
+
continue
|
|
486
|
+
flow, flow_generation, opcode, frame_length = key
|
|
487
|
+
first_observation, completion_observation = _observation_bounds(
|
|
488
|
+
frames,
|
|
489
|
+
observation_ordinals,
|
|
490
|
+
)
|
|
491
|
+
return DiscoveredSolareFamily(
|
|
492
|
+
role="overall",
|
|
493
|
+
flow=flow,
|
|
494
|
+
opcode=opcode,
|
|
495
|
+
frame_length=frame_length,
|
|
496
|
+
frame_count=len(frames),
|
|
497
|
+
record_stride=name_pair.stride,
|
|
498
|
+
name_offset=name_pair.first_offset,
|
|
499
|
+
rank_offset=rank_offset,
|
|
500
|
+
names=name_pair.names,
|
|
501
|
+
ranks=ranks,
|
|
502
|
+
first_stream_offset=first_stream_offset,
|
|
503
|
+
last_stream_end=max(_stream_end(frame) for frame in frames),
|
|
504
|
+
flow_generation=flow_generation,
|
|
505
|
+
first_observation_ordinal=first_observation,
|
|
506
|
+
completion_observation_ordinal=completion_observation,
|
|
507
|
+
frames=tuple(frames),
|
|
508
|
+
)
|
|
509
|
+
return None
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _discover_partial_overall_family(
|
|
513
|
+
key: _FamilyKey,
|
|
514
|
+
frames: Sequence[BDOFrame],
|
|
515
|
+
ranked: DiscoveredSolareFamily,
|
|
516
|
+
observation_ordinals: dict[int, int],
|
|
517
|
+
) -> Optional[DiscoveredSolareFamily]:
|
|
518
|
+
if len(frames) * 2 < DISCOVERY_MIN_PARTIAL_OVERALL_RECORDS:
|
|
519
|
+
return None
|
|
520
|
+
if (
|
|
521
|
+
len(frames) >= 50
|
|
522
|
+
or key[0] != ranked.flow
|
|
523
|
+
or key[1] != ranked.flow_generation
|
|
524
|
+
):
|
|
525
|
+
return None
|
|
526
|
+
first_stream_offset = min(_stream_offset(frame) for frame in frames)
|
|
527
|
+
distance = first_stream_offset - ranked.last_stream_end
|
|
528
|
+
if not 0 <= distance <= DISCOVERY_MAX_FAMILY_DISTANCE:
|
|
529
|
+
return None
|
|
530
|
+
for name_pair in _candidate_name_pairs(frames):
|
|
531
|
+
rank_field = _infer_partial_overall_rank_field(frames, name_pair.stride)
|
|
532
|
+
if rank_field is None:
|
|
533
|
+
continue
|
|
534
|
+
rank_offset, ranks = rank_field
|
|
535
|
+
if _cross_check_overlap(ranked, name_pair.names, ranks) is None:
|
|
536
|
+
continue
|
|
537
|
+
flow, flow_generation, opcode, frame_length = key
|
|
538
|
+
first_observation, completion_observation = _observation_bounds(
|
|
539
|
+
frames,
|
|
540
|
+
observation_ordinals,
|
|
541
|
+
)
|
|
542
|
+
return DiscoveredSolareFamily(
|
|
543
|
+
role="overall-partial",
|
|
544
|
+
flow=flow,
|
|
545
|
+
opcode=opcode,
|
|
546
|
+
frame_length=frame_length,
|
|
547
|
+
frame_count=len(frames),
|
|
548
|
+
record_stride=name_pair.stride,
|
|
549
|
+
name_offset=name_pair.first_offset,
|
|
550
|
+
rank_offset=rank_offset,
|
|
551
|
+
names=name_pair.names,
|
|
552
|
+
ranks=ranks,
|
|
553
|
+
first_stream_offset=first_stream_offset,
|
|
554
|
+
last_stream_end=max(_stream_end(frame) for frame in frames),
|
|
555
|
+
flow_generation=flow_generation,
|
|
556
|
+
first_observation_ordinal=first_observation,
|
|
557
|
+
completion_observation_ordinal=completion_observation,
|
|
558
|
+
frames=tuple(frames),
|
|
559
|
+
)
|
|
560
|
+
return None
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _cross_check_overlap(
|
|
564
|
+
ranked: DiscoveredSolareFamily,
|
|
565
|
+
overall_names: Sequence[str],
|
|
566
|
+
overall_ranks: Sequence[int],
|
|
567
|
+
) -> Optional[int]:
|
|
568
|
+
"""Return exact shared pairs, or ``None`` on weak/contradictory evidence.
|
|
569
|
+
|
|
570
|
+
Rank and name are checked in both directions. The second direction catches
|
|
571
|
+
an overall-only rank reusing a rich-table name at a different rank. Twenty
|
|
572
|
+
exact overlaps are required because that is the guaranteed prefix for a
|
|
573
|
+
union of class leaderboards capped at twenty entries each.
|
|
574
|
+
"""
|
|
575
|
+
|
|
576
|
+
ranked_by_rank = dict(zip(ranked.ranks, ranked.names))
|
|
577
|
+
ranked_by_name = dict(zip(ranked.names, ranked.ranks))
|
|
578
|
+
overlap = 0
|
|
579
|
+
for rank, name in zip(overall_ranks, overall_names):
|
|
580
|
+
ranked_name = ranked_by_rank.get(rank)
|
|
581
|
+
if ranked_name is not None:
|
|
582
|
+
if ranked_name != name:
|
|
583
|
+
return None
|
|
584
|
+
overlap += 1
|
|
585
|
+
ranked_rank = ranked_by_name.get(name)
|
|
586
|
+
if ranked_rank is not None and ranked_rank != rank:
|
|
587
|
+
return None
|
|
588
|
+
return overlap if overlap >= 20 else None
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _discover_menu_context(
|
|
592
|
+
key: _FamilyKey, frames: Sequence[BDOFrame]
|
|
593
|
+
) -> Optional[SolareMenuContext]:
|
|
594
|
+
if len(frames) != 24:
|
|
595
|
+
return None
|
|
596
|
+
columns: list[tuple[int, tuple[str, ...]]] = []
|
|
597
|
+
first = frames[0].message
|
|
598
|
+
for offset in range(BDO_HEADER_SIZE, len(first) - 1):
|
|
599
|
+
# Most byte offsets cannot begin a UTF-16LE player identifier. Reject
|
|
600
|
+
# those from the first frame before reading the same offset from all 24
|
|
601
|
+
# frames; this keeps menu-context probing cheap on unrelated traffic.
|
|
602
|
+
if not _identifier_starts_here(first, offset):
|
|
603
|
+
continue
|
|
604
|
+
values = tuple(
|
|
605
|
+
_read_player_identifier(frame.message, offset) for frame in frames
|
|
606
|
+
)
|
|
607
|
+
if not all(values) or len(set(values)) < 12:
|
|
608
|
+
continue
|
|
609
|
+
columns.append((offset, values))
|
|
610
|
+
|
|
611
|
+
collapsed: list[tuple[int, tuple[str, ...]]] = []
|
|
612
|
+
for offset, values in columns:
|
|
613
|
+
if collapsed and offset - collapsed[-1][0] <= 64:
|
|
614
|
+
continue
|
|
615
|
+
collapsed.append((offset, values))
|
|
616
|
+
if len(collapsed) != 5:
|
|
617
|
+
return None
|
|
618
|
+
offsets = [offset for offset, _values in collapsed]
|
|
619
|
+
strides = [right - left for left, right in zip(offsets, offsets[1:])]
|
|
620
|
+
if len(set(strides)) != 1:
|
|
621
|
+
return None
|
|
622
|
+
all_names = [name for _offset, values in collapsed for name in values]
|
|
623
|
+
if not 40 <= len(set(all_names)) <= len(all_names):
|
|
624
|
+
return None
|
|
625
|
+
flow, flow_generation, opcode, frame_length = key
|
|
626
|
+
return SolareMenuContext(
|
|
627
|
+
flow=flow,
|
|
628
|
+
opcode=opcode,
|
|
629
|
+
frame_length=frame_length,
|
|
630
|
+
frame_count=len(frames),
|
|
631
|
+
player_slots_per_frame=len(collapsed),
|
|
632
|
+
record_stride=strides[0],
|
|
633
|
+
unique_names=len(set(all_names)),
|
|
634
|
+
flow_generation=flow_generation,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _edge_windows(
|
|
639
|
+
frames: Sequence[BDOFrame], expected_count: int
|
|
640
|
+
) -> tuple[Sequence[BDOFrame], ...]:
|
|
641
|
+
"""Return chronological edge windows for repeated same-family responses.
|
|
642
|
+
|
|
643
|
+
A capture may begin midway through one refresh and then contain a complete
|
|
644
|
+
retry, or may contain one complete refresh followed by a partial retry.
|
|
645
|
+
Testing the earliest and latest exact-size windows lets either complete
|
|
646
|
+
response survive without accepting a mixed window across the rank reset.
|
|
647
|
+
"""
|
|
648
|
+
|
|
649
|
+
if len(frames) <= expected_count:
|
|
650
|
+
return (frames,)
|
|
651
|
+
first = frames[:expected_count]
|
|
652
|
+
last = frames[-expected_count:]
|
|
653
|
+
if first[0] is last[0]:
|
|
654
|
+
return (first,)
|
|
655
|
+
return (first, last)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _rank_reset_starts(frames: Sequence[BDOFrame]) -> tuple[int, ...]:
|
|
659
|
+
"""Locate response boundaries from the invariant rank-1..20 prefix.
|
|
660
|
+
|
|
661
|
+
Both Solare ranked families carry two rows per frame. A complete rich
|
|
662
|
+
response and the overall response therefore begin with ranks 1/2, 3/4,
|
|
663
|
+
... 19/20 at one shared field/stride geometry. Finding that short prefix
|
|
664
|
+
is enough to split repeated refreshes without knowing an opcode or layout.
|
|
665
|
+
"""
|
|
666
|
+
|
|
667
|
+
if len(frames) < 10:
|
|
668
|
+
return ()
|
|
669
|
+
|
|
670
|
+
encoded_one = (1).to_bytes(4, "little")
|
|
671
|
+
encoded_two = (2).to_bytes(4, "little")
|
|
672
|
+
starts: list[int] = []
|
|
673
|
+
for start in range(len(frames) - 9):
|
|
674
|
+
first = frames[start].message
|
|
675
|
+
scan_end = min(len(first) - 4, DISCOVERY_FIELD_SCAN_LIMIT)
|
|
676
|
+
rank_offset = first.find(
|
|
677
|
+
encoded_one,
|
|
678
|
+
BDO_HEADER_SIZE,
|
|
679
|
+
scan_end + 4,
|
|
680
|
+
)
|
|
681
|
+
found = False
|
|
682
|
+
while 0 <= rank_offset <= scan_end and not found:
|
|
683
|
+
half = len(first) // 2
|
|
684
|
+
second_start = max(
|
|
685
|
+
rank_offset + 1,
|
|
686
|
+
rank_offset + half - DISCOVERY_NAME_PAIR_TOLERANCE,
|
|
687
|
+
)
|
|
688
|
+
second_end = min(
|
|
689
|
+
len(first) - 4,
|
|
690
|
+
rank_offset + half + DISCOVERY_NAME_PAIR_TOLERANCE,
|
|
691
|
+
)
|
|
692
|
+
second_offset = first.find(
|
|
693
|
+
encoded_two,
|
|
694
|
+
second_start,
|
|
695
|
+
second_end + 4,
|
|
696
|
+
)
|
|
697
|
+
while 0 <= second_offset <= second_end:
|
|
698
|
+
stride = second_offset - rank_offset
|
|
699
|
+
if all(
|
|
700
|
+
_u32le(frame.message, rank_offset) == ordinal * 2 + 1
|
|
701
|
+
and _u32le(frame.message, rank_offset + stride)
|
|
702
|
+
== ordinal * 2 + 2
|
|
703
|
+
for ordinal, frame in enumerate(frames[start : start + 10])
|
|
704
|
+
):
|
|
705
|
+
starts.append(start)
|
|
706
|
+
found = True
|
|
707
|
+
break
|
|
708
|
+
second_offset = first.find(
|
|
709
|
+
encoded_two,
|
|
710
|
+
second_offset + 1,
|
|
711
|
+
second_end + 4,
|
|
712
|
+
)
|
|
713
|
+
rank_offset = first.find(
|
|
714
|
+
encoded_one,
|
|
715
|
+
rank_offset + 1,
|
|
716
|
+
scan_end + 4,
|
|
717
|
+
)
|
|
718
|
+
return tuple(starts)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _rank_reset_windows(
|
|
722
|
+
frames: Sequence[BDOFrame],
|
|
723
|
+
expected_count: int,
|
|
724
|
+
*,
|
|
725
|
+
_starts: Optional[tuple[int, ...]] = None,
|
|
726
|
+
) -> tuple[Sequence[BDOFrame], ...]:
|
|
727
|
+
"""Return each reset-aligned generation, capped at its expected size."""
|
|
728
|
+
|
|
729
|
+
starts = _rank_reset_starts(frames) if _starts is None else _starts
|
|
730
|
+
if not starts:
|
|
731
|
+
return ()
|
|
732
|
+
|
|
733
|
+
windows: list[Sequence[BDOFrame]] = []
|
|
734
|
+
for index, start in enumerate(starts):
|
|
735
|
+
next_start = starts[index + 1] if index + 1 < len(starts) else len(frames)
|
|
736
|
+
stop = min(start + expected_count, next_start)
|
|
737
|
+
windows.append(frames[start:stop])
|
|
738
|
+
return tuple(windows)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _overall_generation_is_closed(
|
|
742
|
+
group: Sequence[BDOFrame],
|
|
743
|
+
window: Sequence[BDOFrame],
|
|
744
|
+
overall: DiscoveredSolareFamily,
|
|
745
|
+
*,
|
|
746
|
+
allow_trailing_overall: bool,
|
|
747
|
+
) -> bool:
|
|
748
|
+
"""Prove that a 50-frame overall candidate does not continue as rich.
|
|
749
|
+
|
|
750
|
+
Another frame in the same family must restart at ranks 1/2; ranks 101/102
|
|
751
|
+
prove that the candidate was only a rich prefix. A trailing live candidate
|
|
752
|
+
remains tentative until the caller declares an end-of-burst boundary.
|
|
753
|
+
"""
|
|
754
|
+
|
|
755
|
+
last_position = next(
|
|
756
|
+
index for index, frame in enumerate(group) if frame is window[-1]
|
|
757
|
+
)
|
|
758
|
+
if last_position + 1 < len(group):
|
|
759
|
+
following = group[last_position + 1]
|
|
760
|
+
return (
|
|
761
|
+
_u32le(following.message, overall.rank_offset) == 1
|
|
762
|
+
and _u32le(
|
|
763
|
+
following.message,
|
|
764
|
+
overall.rank_offset + overall.record_stride,
|
|
765
|
+
)
|
|
766
|
+
== 2
|
|
767
|
+
)
|
|
768
|
+
if allow_trailing_overall:
|
|
769
|
+
return True
|
|
770
|
+
return False
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
_ANALYSIS_CACHE_MAX_ENTRIES = 64
|
|
774
|
+
_CACHE_MISS = object()
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _family_analysis_identity(
|
|
778
|
+
family: DiscoveredSolareFamily,
|
|
779
|
+
) -> tuple[object, ...]:
|
|
780
|
+
"""Return a compact identity for one cache-owned structural result."""
|
|
781
|
+
|
|
782
|
+
first_frame = id(family.frames[0]) if family.frames else None
|
|
783
|
+
last_frame = id(family.frames[-1]) if family.frames else None
|
|
784
|
+
return (
|
|
785
|
+
family.flow,
|
|
786
|
+
family.flow_generation,
|
|
787
|
+
family.opcode,
|
|
788
|
+
family.frame_length,
|
|
789
|
+
family.frame_count,
|
|
790
|
+
family.record_stride,
|
|
791
|
+
family.name_offset,
|
|
792
|
+
family.rank_offset,
|
|
793
|
+
family.class_offset,
|
|
794
|
+
family.first_stream_offset,
|
|
795
|
+
family.last_stream_end,
|
|
796
|
+
first_frame,
|
|
797
|
+
last_frame,
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
class _DiscoveryAnalysisCache:
|
|
802
|
+
"""Bound repeated structural work to exact retained frame windows.
|
|
803
|
+
|
|
804
|
+
The live tracker owns one instance and clears it whenever its retained
|
|
805
|
+
deque evicts history. Cache keys contain frame identities rather than
|
|
806
|
+
frame references, while successful values may own only frames that are
|
|
807
|
+
already retained by that deque. A small LRU ceiling also keeps manual,
|
|
808
|
+
unusually frequent refreshes from accumulating quadratic window results.
|
|
809
|
+
"""
|
|
810
|
+
|
|
811
|
+
def __init__(self) -> None:
|
|
812
|
+
self._items: OrderedDict[tuple[object, ...], object] = OrderedDict()
|
|
813
|
+
|
|
814
|
+
@property
|
|
815
|
+
def entry_count(self) -> int:
|
|
816
|
+
return len(self._items)
|
|
817
|
+
|
|
818
|
+
def clear(self) -> None:
|
|
819
|
+
self._items.clear()
|
|
820
|
+
|
|
821
|
+
def _window_key(
|
|
822
|
+
self,
|
|
823
|
+
role: str,
|
|
824
|
+
key: _FamilyKey,
|
|
825
|
+
frames: Sequence[BDOFrame],
|
|
826
|
+
*extra: object,
|
|
827
|
+
) -> tuple[object, ...]:
|
|
828
|
+
return (role, key, tuple(id(frame) for frame in frames), *extra)
|
|
829
|
+
|
|
830
|
+
def _get(self, key: tuple[object, ...]) -> object:
|
|
831
|
+
value = self._items.get(key, _CACHE_MISS)
|
|
832
|
+
if value is not _CACHE_MISS:
|
|
833
|
+
self._items.move_to_end(key)
|
|
834
|
+
return value
|
|
835
|
+
|
|
836
|
+
def _put(self, key: tuple[object, ...], value: object) -> None:
|
|
837
|
+
self._items[key] = value
|
|
838
|
+
self._items.move_to_end(key)
|
|
839
|
+
while len(self._items) > _ANALYSIS_CACHE_MAX_ENTRIES:
|
|
840
|
+
self._items.popitem(last=False)
|
|
841
|
+
|
|
842
|
+
def rank_reset_starts(
|
|
843
|
+
self,
|
|
844
|
+
key: _FamilyKey,
|
|
845
|
+
frames: Sequence[BDOFrame],
|
|
846
|
+
) -> tuple[int, ...]:
|
|
847
|
+
cache_key = self._window_key("rank-resets", key, frames)
|
|
848
|
+
cached = self._get(cache_key)
|
|
849
|
+
if cached is not _CACHE_MISS:
|
|
850
|
+
return cast(tuple[int, ...], cached)
|
|
851
|
+
result = _rank_reset_starts(frames)
|
|
852
|
+
self._put(cache_key, result)
|
|
853
|
+
return result
|
|
854
|
+
|
|
855
|
+
def menu_context(
|
|
856
|
+
self,
|
|
857
|
+
key: _FamilyKey,
|
|
858
|
+
frames: Sequence[BDOFrame],
|
|
859
|
+
) -> Optional[SolareMenuContext]:
|
|
860
|
+
cache_key = self._window_key("menu", key, frames)
|
|
861
|
+
cached = self._get(cache_key)
|
|
862
|
+
if cached is not _CACHE_MISS:
|
|
863
|
+
return cast(Optional[SolareMenuContext], cached)
|
|
864
|
+
result = _discover_menu_context(key, frames)
|
|
865
|
+
self._put(cache_key, result)
|
|
866
|
+
return result
|
|
867
|
+
|
|
868
|
+
def ranked_and_rich(
|
|
869
|
+
self,
|
|
870
|
+
key: _FamilyKey,
|
|
871
|
+
frames: Sequence[BDOFrame],
|
|
872
|
+
observation_ordinals: dict[int, int],
|
|
873
|
+
) -> tuple[
|
|
874
|
+
Optional[DiscoveredSolareFamily],
|
|
875
|
+
Optional[DiscoveredSolareFamily],
|
|
876
|
+
]:
|
|
877
|
+
observation_bounds = _observation_bounds(frames, observation_ordinals)
|
|
878
|
+
cache_key = self._window_key(
|
|
879
|
+
"ranked-rich",
|
|
880
|
+
key,
|
|
881
|
+
frames,
|
|
882
|
+
observation_bounds,
|
|
883
|
+
)
|
|
884
|
+
cached = self._get(cache_key)
|
|
885
|
+
if cached is not _CACHE_MISS:
|
|
886
|
+
return cast(
|
|
887
|
+
tuple[
|
|
888
|
+
Optional[DiscoveredSolareFamily],
|
|
889
|
+
Optional[DiscoveredSolareFamily],
|
|
890
|
+
],
|
|
891
|
+
cached,
|
|
892
|
+
)
|
|
893
|
+
ranked = _discover_ranked_family(key, frames, observation_ordinals)
|
|
894
|
+
rich = (
|
|
895
|
+
_discover_rich_family(key, frames, ranked)
|
|
896
|
+
if ranked is not None
|
|
897
|
+
else None
|
|
898
|
+
)
|
|
899
|
+
result = (ranked, rich)
|
|
900
|
+
self._put(cache_key, result)
|
|
901
|
+
return result
|
|
902
|
+
|
|
903
|
+
def overall_family(
|
|
904
|
+
self,
|
|
905
|
+
key: _FamilyKey,
|
|
906
|
+
frames: Sequence[BDOFrame],
|
|
907
|
+
rich: DiscoveredSolareFamily,
|
|
908
|
+
observation_ordinals: dict[int, int],
|
|
909
|
+
) -> Optional[DiscoveredSolareFamily]:
|
|
910
|
+
observation_bounds = _observation_bounds(frames, observation_ordinals)
|
|
911
|
+
cache_key = self._window_key(
|
|
912
|
+
"overall",
|
|
913
|
+
key,
|
|
914
|
+
frames,
|
|
915
|
+
_family_analysis_identity(rich),
|
|
916
|
+
observation_bounds,
|
|
917
|
+
)
|
|
918
|
+
cached = self._get(cache_key)
|
|
919
|
+
if cached is not _CACHE_MISS:
|
|
920
|
+
return cast(Optional[DiscoveredSolareFamily], cached)
|
|
921
|
+
result = _discover_overall_family(
|
|
922
|
+
key,
|
|
923
|
+
frames,
|
|
924
|
+
rich,
|
|
925
|
+
observation_ordinals,
|
|
926
|
+
)
|
|
927
|
+
self._put(cache_key, result)
|
|
928
|
+
return result
|
|
929
|
+
|
|
930
|
+
def partial_overall_family(
|
|
931
|
+
self,
|
|
932
|
+
key: _FamilyKey,
|
|
933
|
+
frames: Sequence[BDOFrame],
|
|
934
|
+
ranked: DiscoveredSolareFamily,
|
|
935
|
+
observation_ordinals: dict[int, int],
|
|
936
|
+
) -> Optional[DiscoveredSolareFamily]:
|
|
937
|
+
observation_bounds = _observation_bounds(frames, observation_ordinals)
|
|
938
|
+
cache_key = self._window_key(
|
|
939
|
+
"partial-overall",
|
|
940
|
+
key,
|
|
941
|
+
frames,
|
|
942
|
+
_family_analysis_identity(ranked),
|
|
943
|
+
observation_bounds,
|
|
944
|
+
)
|
|
945
|
+
cached = self._get(cache_key)
|
|
946
|
+
if cached is not _CACHE_MISS:
|
|
947
|
+
return cast(Optional[DiscoveredSolareFamily], cached)
|
|
948
|
+
result = _discover_partial_overall_family(
|
|
949
|
+
key,
|
|
950
|
+
frames,
|
|
951
|
+
ranked,
|
|
952
|
+
observation_ordinals,
|
|
953
|
+
)
|
|
954
|
+
self._put(cache_key, result)
|
|
955
|
+
return result
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def discover_solare(
|
|
959
|
+
frames: Iterable[BDOFrame],
|
|
960
|
+
*,
|
|
961
|
+
allow_trailing_overall: bool = True,
|
|
962
|
+
_analysis_cache: Optional[_DiscoveryAnalysisCache] = None,
|
|
963
|
+
) -> SolareDiscoveryResult:
|
|
964
|
+
"""Identify a complete Solare snapshot without seeded opcode values.
|
|
965
|
+
|
|
966
|
+
``allow_trailing_overall`` is false during an active live burst. That
|
|
967
|
+
prevents a still-growing rich generation from being accepted at its
|
|
968
|
+
50-frame prefix. Replay/end-of-burst callers pass a finalized input and
|
|
969
|
+
may prove an exact 50-frame generation at the tail.
|
|
970
|
+
"""
|
|
971
|
+
|
|
972
|
+
analysis_cache = (
|
|
973
|
+
_analysis_cache
|
|
974
|
+
if _analysis_cache is not None
|
|
975
|
+
else _DiscoveryAnalysisCache()
|
|
976
|
+
)
|
|
977
|
+
unique_frames: list[BDOFrame] = []
|
|
978
|
+
observation_ordinals: dict[int, int] = {}
|
|
979
|
+
seen: set[tuple[object, ...]] = set()
|
|
980
|
+
for frame in frames:
|
|
981
|
+
identity = (
|
|
982
|
+
frame.context.flow,
|
|
983
|
+
frame.context.flow_generation,
|
|
984
|
+
(
|
|
985
|
+
frame.stream_sequence
|
|
986
|
+
if frame.stream_sequence is not None
|
|
987
|
+
else frame.index
|
|
988
|
+
),
|
|
989
|
+
frame.length,
|
|
990
|
+
hashlib.blake2s(frame.message, digest_size=8).digest(),
|
|
991
|
+
)
|
|
992
|
+
if identity in seen:
|
|
993
|
+
continue
|
|
994
|
+
seen.add(identity)
|
|
995
|
+
observation_ordinals[id(frame)] = len(unique_frames)
|
|
996
|
+
unique_frames.append(frame)
|
|
997
|
+
|
|
998
|
+
grouped: DefaultDict[_FamilyKey, list[BDOFrame]] = defaultdict(list)
|
|
999
|
+
for frame in unique_frames:
|
|
1000
|
+
if frame.flag != 0:
|
|
1001
|
+
continue
|
|
1002
|
+
if not DISCOVERY_MIN_FRAME_LENGTH <= frame.length <= DISCOVERY_MAX_FRAME_LENGTH:
|
|
1003
|
+
continue
|
|
1004
|
+
grouped[_family_key(frame)].append(frame)
|
|
1005
|
+
|
|
1006
|
+
eligible = {
|
|
1007
|
+
key: sorted(group, key=_stream_offset)
|
|
1008
|
+
for key, group in grouped.items()
|
|
1009
|
+
if len(group) >= DISCOVERY_MIN_FAMILY_FRAMES
|
|
1010
|
+
}
|
|
1011
|
+
# Preserve the public summary shape/count semantics even though internal
|
|
1012
|
+
# classification now keeps connection generations separate.
|
|
1013
|
+
summary_counts: Counter[tuple[int, int]] = Counter()
|
|
1014
|
+
for key, group in eligible.items():
|
|
1015
|
+
summary_counts[(key[2], key[3])] += len(group)
|
|
1016
|
+
summaries = tuple(
|
|
1017
|
+
sorted(
|
|
1018
|
+
(
|
|
1019
|
+
(opcode, frame_length, count)
|
|
1020
|
+
for (opcode, frame_length), count in summary_counts.items()
|
|
1021
|
+
),
|
|
1022
|
+
key=lambda summary: (-summary[2], summary[0], summary[1]),
|
|
1023
|
+
)
|
|
1024
|
+
)
|
|
1025
|
+
menu_contexts: list[SolareMenuContext] = []
|
|
1026
|
+
ranked_candidates: list[DiscoveredSolareFamily] = []
|
|
1027
|
+
rich_candidates: list[DiscoveredSolareFamily] = []
|
|
1028
|
+
for key, group in eligible.items():
|
|
1029
|
+
# Menu/history context is one exact 24-frame response. Probe the
|
|
1030
|
+
# first window once (the tracker cache reuses it as a family grows).
|
|
1031
|
+
# If capture began in a partial response, the finalized trailing edge
|
|
1032
|
+
# gets one retry without making every ranked milestone rescan 24 large
|
|
1033
|
+
# frames. A validated first window also preserves repeated menus of
|
|
1034
|
+
# any length instead of losing context after the second response.
|
|
1035
|
+
if len(group) >= 24:
|
|
1036
|
+
first_menu_window = group[:24]
|
|
1037
|
+
menu_context = analysis_cache.menu_context(key, first_menu_window)
|
|
1038
|
+
if menu_context is not None:
|
|
1039
|
+
menu_contexts.append(menu_context)
|
|
1040
|
+
elif len(group) <= 48 or allow_trailing_overall:
|
|
1041
|
+
last_menu_window = group[-24:]
|
|
1042
|
+
if last_menu_window[0] is not first_menu_window[0]:
|
|
1043
|
+
menu_context = analysis_cache.menu_context(
|
|
1044
|
+
key,
|
|
1045
|
+
last_menu_window,
|
|
1046
|
+
)
|
|
1047
|
+
if menu_context is not None:
|
|
1048
|
+
menu_contexts.append(menu_context)
|
|
1049
|
+
reset_starts = analysis_cache.rank_reset_starts(key, group)
|
|
1050
|
+
for window in _rank_reset_windows(
|
|
1051
|
+
group,
|
|
1052
|
+
310,
|
|
1053
|
+
_starts=reset_starts,
|
|
1054
|
+
):
|
|
1055
|
+
ranked, rich = analysis_cache.ranked_and_rich(
|
|
1056
|
+
key,
|
|
1057
|
+
window,
|
|
1058
|
+
observation_ordinals,
|
|
1059
|
+
)
|
|
1060
|
+
if ranked is not None:
|
|
1061
|
+
ranked_candidates.append(ranked)
|
|
1062
|
+
if rich is not None:
|
|
1063
|
+
rich_candidates.append(rich)
|
|
1064
|
+
|
|
1065
|
+
ranked_candidates.sort(
|
|
1066
|
+
key=lambda candidate: (
|
|
1067
|
+
-candidate.record_count,
|
|
1068
|
+
candidate.completion_observation_ordinal,
|
|
1069
|
+
candidate.first_observation_ordinal,
|
|
1070
|
+
)
|
|
1071
|
+
)
|
|
1072
|
+
rich_candidates.sort(
|
|
1073
|
+
key=lambda candidate: (
|
|
1074
|
+
candidate.completion_observation_ordinal,
|
|
1075
|
+
candidate.first_observation_ordinal,
|
|
1076
|
+
candidate.opcode,
|
|
1077
|
+
candidate.frame_length,
|
|
1078
|
+
)
|
|
1079
|
+
)
|
|
1080
|
+
complete_pairs: list[
|
|
1081
|
+
tuple[DiscoveredSolareFamily, DiscoveredSolareFamily]
|
|
1082
|
+
] = []
|
|
1083
|
+
for rich in rich_candidates:
|
|
1084
|
+
rich_key = (
|
|
1085
|
+
rich.flow,
|
|
1086
|
+
rich.flow_generation,
|
|
1087
|
+
rich.opcode,
|
|
1088
|
+
rich.frame_length,
|
|
1089
|
+
)
|
|
1090
|
+
for key, group in eligible.items():
|
|
1091
|
+
if key == rich_key:
|
|
1092
|
+
continue
|
|
1093
|
+
reset_starts = analysis_cache.rank_reset_starts(key, group)
|
|
1094
|
+
for window in _rank_reset_windows(
|
|
1095
|
+
group,
|
|
1096
|
+
50,
|
|
1097
|
+
_starts=reset_starts,
|
|
1098
|
+
):
|
|
1099
|
+
overall = analysis_cache.overall_family(
|
|
1100
|
+
key,
|
|
1101
|
+
window,
|
|
1102
|
+
rich,
|
|
1103
|
+
observation_ordinals,
|
|
1104
|
+
)
|
|
1105
|
+
if overall is not None and _overall_generation_is_closed(
|
|
1106
|
+
group,
|
|
1107
|
+
window,
|
|
1108
|
+
overall,
|
|
1109
|
+
allow_trailing_overall=allow_trailing_overall,
|
|
1110
|
+
):
|
|
1111
|
+
complete_pairs.append((rich, overall))
|
|
1112
|
+
|
|
1113
|
+
if complete_pairs:
|
|
1114
|
+
rich, overall = min(
|
|
1115
|
+
complete_pairs,
|
|
1116
|
+
key=lambda pair: (
|
|
1117
|
+
max(
|
|
1118
|
+
pair[0].completion_observation_ordinal,
|
|
1119
|
+
pair[1].completion_observation_ordinal,
|
|
1120
|
+
),
|
|
1121
|
+
min(
|
|
1122
|
+
pair[0].first_observation_ordinal,
|
|
1123
|
+
pair[1].first_observation_ordinal,
|
|
1124
|
+
),
|
|
1125
|
+
pair[0].opcode,
|
|
1126
|
+
pair[1].opcode,
|
|
1127
|
+
),
|
|
1128
|
+
)
|
|
1129
|
+
return SolareDiscoveryResult(
|
|
1130
|
+
scanned_frames=len(unique_frames),
|
|
1131
|
+
candidate_families=summaries,
|
|
1132
|
+
rich=rich,
|
|
1133
|
+
overall=overall,
|
|
1134
|
+
ranked_candidate=rich,
|
|
1135
|
+
partial_overall=overall,
|
|
1136
|
+
menu_context=menu_contexts[0] if menu_contexts else None,
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
rich_by_key: dict[_FamilyKey, DiscoveredSolareFamily] = {}
|
|
1140
|
+
for item in rich_candidates:
|
|
1141
|
+
rich_by_key.setdefault(
|
|
1142
|
+
(
|
|
1143
|
+
item.flow,
|
|
1144
|
+
item.flow_generation,
|
|
1145
|
+
item.opcode,
|
|
1146
|
+
item.frame_length,
|
|
1147
|
+
),
|
|
1148
|
+
item,
|
|
1149
|
+
)
|
|
1150
|
+
for ranked in ranked_candidates:
|
|
1151
|
+
ranked_key = (
|
|
1152
|
+
ranked.flow,
|
|
1153
|
+
ranked.flow_generation,
|
|
1154
|
+
ranked.opcode,
|
|
1155
|
+
ranked.frame_length,
|
|
1156
|
+
)
|
|
1157
|
+
for key, group in eligible.items():
|
|
1158
|
+
if key == ranked_key:
|
|
1159
|
+
continue
|
|
1160
|
+
for window in _edge_windows(group, 50):
|
|
1161
|
+
partial_overall = analysis_cache.partial_overall_family(
|
|
1162
|
+
key,
|
|
1163
|
+
window,
|
|
1164
|
+
ranked,
|
|
1165
|
+
observation_ordinals,
|
|
1166
|
+
)
|
|
1167
|
+
if partial_overall is not None:
|
|
1168
|
+
return SolareDiscoveryResult(
|
|
1169
|
+
scanned_frames=len(unique_frames),
|
|
1170
|
+
candidate_families=summaries,
|
|
1171
|
+
rich=rich_by_key.get(ranked_key),
|
|
1172
|
+
ranked_candidate=ranked,
|
|
1173
|
+
partial_overall=partial_overall,
|
|
1174
|
+
menu_context=menu_contexts[0] if menu_contexts else None,
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
return SolareDiscoveryResult(
|
|
1178
|
+
scanned_frames=len(unique_frames),
|
|
1179
|
+
candidate_families=summaries,
|
|
1180
|
+
rich=rich_candidates[0] if rich_candidates else None,
|
|
1181
|
+
ranked_candidate=(
|
|
1182
|
+
rich_candidates[0]
|
|
1183
|
+
if rich_candidates
|
|
1184
|
+
else (ranked_candidates[0] if ranked_candidates else None)
|
|
1185
|
+
),
|
|
1186
|
+
menu_context=menu_contexts[0] if menu_contexts else None,
|
|
1187
|
+
)
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def family_frames(
|
|
1191
|
+
frames: Iterable[BDOFrame], family: DiscoveredSolareFamily
|
|
1192
|
+
) -> tuple[BDOFrame, ...]:
|
|
1193
|
+
"""Return ordered frames belonging to one discovered family."""
|
|
1194
|
+
|
|
1195
|
+
if family.frames:
|
|
1196
|
+
return family.frames
|
|
1197
|
+
|
|
1198
|
+
return tuple(
|
|
1199
|
+
sorted(
|
|
1200
|
+
(
|
|
1201
|
+
frame
|
|
1202
|
+
for frame in frames
|
|
1203
|
+
if frame.context.flow == family.flow
|
|
1204
|
+
and frame.context.flow_generation == family.flow_generation
|
|
1205
|
+
and frame.opcode == family.opcode
|
|
1206
|
+
and frame.length == family.frame_length
|
|
1207
|
+
and _stream_offset(frame) >= family.first_stream_offset
|
|
1208
|
+
and _stream_end(frame) <= family.last_stream_end
|
|
1209
|
+
),
|
|
1210
|
+
key=_stream_offset,
|
|
1211
|
+
)
|
|
1212
|
+
)
|