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
@@ -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
+ )