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,796 @@
1
+ """Fail-closed decoding for validated Arena of Solare detail layouts.
2
+
3
+ Leaderboard discovery establishes semantic identity without trusting an
4
+ opcode. This module is deliberately a second, stricter layer: it decodes
5
+ optional Elo, performance, and raw-extension fields only when the
6
+ discovered message geometry matches a layout validated against a complete
7
+ capture and the corresponding record-level invariants still hold.
8
+
9
+ The rich class tables and independent overall table are decoded from their own
10
+ wire records. Neither decoder uses the other table as a data source. When
11
+ explicitly requested, raw gear and skill-addon sections are retained
12
+ byte-for-byte; their internal semantics remain intentionally opaque.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from collections import Counter
19
+ from dataclasses import dataclass
20
+ from typing import Optional, Sequence
21
+
22
+ from bdo_toolkit._protocol import BDOFrame
23
+
24
+ from ._constants import (
25
+ ADVANCED_SPEC_BY_CLASS,
26
+ EMPTY_CLASS_CODE,
27
+ EMPTY_SPEC_CODE,
28
+ class_name,
29
+ )
30
+ from ._discovery import DiscoveredSolareFamily
31
+ from ._validation import validate_retain_raw_extensions
32
+ from .models import (
33
+ SolareClass,
34
+ SolareClassPerformance,
35
+ SolareOverallEntry,
36
+ SolarePlayer,
37
+ SolareRawSection,
38
+ SolareSpecialization,
39
+ )
40
+
41
+ _EXPECTED_RICH_RECORDS = 620
42
+ _EXPECTED_RICH_FRAMES = _EXPECTED_RICH_RECORDS // 2
43
+ _EXPECTED_OVERALL_RECORDS = 100
44
+ _EXPECTED_OVERALL_FRAMES = _EXPECTED_OVERALL_RECORDS // 2
45
+ _CLASS_SLOT_COUNT = 3
46
+
47
+ _HISTORY_SLOT_SIZE = 0x65
48
+ _GEAR_SLOT_SIZE = 0x7D1
49
+ _ADDON_SLOT_SIZE = 0x1F5
50
+ _MAX_PLAUSIBLE_MATCHES = 1_000_000
51
+ _MAX_PLAUSIBLE_ELO = 1_000_000
52
+ _HISTORY_PATTERN = re.compile(r"[01](?:,[01]){0,9}")
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class _RichDetailLayout:
57
+ layout_id: str
58
+ frame_length: int
59
+ record_stride: int
60
+ name_offset: int
61
+ rank_offset: int
62
+ class_offset: int
63
+ matches_offset: int
64
+ elo_offset: int
65
+ spec_offset: int
66
+ history_offset: int
67
+ gear_offset: int
68
+ draws_offset: int
69
+ addons_offset: int
70
+ wins_offset: int
71
+ losses_offset: int
72
+
73
+ @property
74
+ def geometry(self) -> tuple[int, int, int, int, int]:
75
+ return (
76
+ self.frame_length,
77
+ self.record_stride,
78
+ self.name_offset,
79
+ self.rank_offset,
80
+ self.class_offset,
81
+ )
82
+
83
+ @property
84
+ def required_record_end(self) -> int:
85
+ return max(
86
+ self.matches_offset + 12,
87
+ self.elo_offset + 4,
88
+ self.spec_offset + _CLASS_SLOT_COUNT,
89
+ self.history_offset + _CLASS_SLOT_COUNT * _HISTORY_SLOT_SIZE,
90
+ self.gear_offset + _CLASS_SLOT_COUNT * _GEAR_SLOT_SIZE,
91
+ self.draws_offset + 12,
92
+ self.addons_offset + _CLASS_SLOT_COUNT * _ADDON_SLOT_SIZE,
93
+ self.wins_offset + 12,
94
+ self.losses_offset + 12,
95
+ )
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class _OverallDetailLayout:
100
+ layout_id: str
101
+ frame_length: int
102
+ record_stride: int
103
+ name_offset: int
104
+ rank_offset: int
105
+ elo_offset: int
106
+ aggregate_wins_offset: int
107
+ aggregate_draws_offset: int
108
+ aggregate_losses_offset: int
109
+ class_offset: int
110
+ spec_offset: int
111
+ matches_offset: int
112
+ wins_offset: int
113
+ draws_offset: int
114
+ losses_offset: int
115
+ history_offset: int
116
+ gear_offset: int
117
+ addons_offset: int
118
+
119
+ @property
120
+ def geometry(self) -> tuple[int, int, int, int]:
121
+ return (
122
+ self.frame_length,
123
+ self.record_stride,
124
+ self.name_offset,
125
+ self.rank_offset,
126
+ )
127
+
128
+ @property
129
+ def required_record_end(self) -> int:
130
+ return max(
131
+ self.elo_offset + 4,
132
+ self.aggregate_wins_offset + 4,
133
+ self.aggregate_draws_offset + 4,
134
+ self.aggregate_losses_offset + 4,
135
+ self.class_offset + _CLASS_SLOT_COUNT,
136
+ self.spec_offset + _CLASS_SLOT_COUNT,
137
+ self.matches_offset + 12,
138
+ self.wins_offset + 12,
139
+ self.draws_offset + 12,
140
+ self.losses_offset + 12,
141
+ self.history_offset + _CLASS_SLOT_COUNT * _HISTORY_SLOT_SIZE,
142
+ self.gear_offset + _CLASS_SLOT_COUNT * _GEAR_SLOT_SIZE,
143
+ self.addons_offset + _CLASS_SLOT_COUNT * _ADDON_SLOT_SIZE,
144
+ )
145
+
146
+
147
+ # These fingerprints contain no opcode. An opcode-only patch therefore keeps
148
+ # decoding, while any unvalidated geometry change disables optional detail
149
+ # capabilities and leaves structural rankings available.
150
+ _RICH_DETAIL_LAYOUTS = (
151
+ _RichDetailLayout(
152
+ layout_id="solare-rich-2026-06-24-v1",
153
+ frame_length=15900,
154
+ record_stride=0x1F08,
155
+ name_offset=0x14,
156
+ rank_offset=0x52,
157
+ class_offset=0x58,
158
+ matches_offset=0x5B,
159
+ elo_offset=0x67,
160
+ spec_offset=0x6B,
161
+ history_offset=0x6E,
162
+ gear_offset=0x019D,
163
+ draws_offset=0x1910,
164
+ addons_offset=0x191C,
165
+ wins_offset=0x1EFB,
166
+ losses_offset=0x1F07,
167
+ ),
168
+ _RichDetailLayout(
169
+ layout_id="solare-rich-2026-07-14-v1",
170
+ frame_length=15930,
171
+ record_stride=0x1F15,
172
+ name_offset=0x19,
173
+ rank_offset=0x10,
174
+ class_offset=0x62,
175
+ matches_offset=0x68,
176
+ elo_offset=0x74,
177
+ spec_offset=0x1926,
178
+ history_offset=0x17EB,
179
+ gear_offset=0x0078,
180
+ draws_offset=0x1F19,
181
+ addons_offset=0x1929,
182
+ wins_offset=0x191A,
183
+ losses_offset=0x1F0D,
184
+ ),
185
+ _RichDetailLayout(
186
+ layout_id="solare-rich-2026-07-17-v1",
187
+ frame_length=15914,
188
+ record_stride=0x1F0F,
189
+ name_offset=0x1F,
190
+ rank_offset=0x0C,
191
+ class_offset=0x5D,
192
+ matches_offset=0x1F0F,
193
+ elo_offset=0x1EFC,
194
+ spec_offset=0x077A,
195
+ history_offset=0x064B,
196
+ gear_offset=0x077D,
197
+ draws_offset=0x063F,
198
+ addons_offset=0x0060,
199
+ wins_offset=0x1F03,
200
+ losses_offset=0x1EF0,
201
+ ),
202
+ )
203
+
204
+
205
+ _OVERALL_DETAIL_LAYOUTS = (
206
+ _OverallDetailLayout(
207
+ layout_id="solare-overall-2026-06-24-v1",
208
+ frame_length=16148,
209
+ record_stride=0x1F80,
210
+ name_offset=0x17,
211
+ rank_offset=0xCA,
212
+ elo_offset=0xD0,
213
+ aggregate_wins_offset=0xD4,
214
+ aggregate_draws_offset=0x61,
215
+ aggregate_losses_offset=0x5D,
216
+ class_offset=0xD8,
217
+ spec_offset=0x1F82,
218
+ matches_offset=0x1F85,
219
+ wins_offset=0xDB,
220
+ draws_offset=0x7F7,
221
+ losses_offset=0x803,
222
+ history_offset=0xE7,
223
+ gear_offset=0x80F,
224
+ addons_offset=0x218,
225
+ ),
226
+ _OverallDetailLayout(
227
+ layout_id="solare-overall-2026-07-14-v1",
228
+ frame_length=16145,
229
+ record_stride=0x1F80,
230
+ name_offset=0x98,
231
+ rank_offset=0x81,
232
+ elo_offset=0x8D,
233
+ aggregate_wins_offset=0x89,
234
+ aggregate_draws_offset=0x85,
235
+ aggregate_losses_offset=0x91,
236
+ class_offset=0xD6,
237
+ spec_offset=0x6C4,
238
+ matches_offset=0x1F79,
239
+ wins_offset=0x1F85,
240
+ draws_offset=0xD9,
241
+ losses_offset=0x6C9,
242
+ history_offset=0x1E4A,
243
+ gear_offset=0x6D5,
244
+ addons_offset=0xE5,
245
+ ),
246
+ _OverallDetailLayout(
247
+ layout_id="solare-overall-2026-07-17-v1",
248
+ frame_length=16143,
249
+ record_stride=0x1F7F,
250
+ name_offset=0x8A,
251
+ rank_offset=0x7E,
252
+ elo_offset=0x11,
253
+ aggregate_wins_offset=0xCC,
254
+ aggregate_draws_offset=0x15,
255
+ aggregate_losses_offset=0xC8,
256
+ class_offset=0xD0,
257
+ spec_offset=0x1861,
258
+ matches_offset=0x19A5,
259
+ wins_offset=0x1855,
260
+ draws_offset=0x186A,
261
+ losses_offset=0x1849,
262
+ history_offset=0x1876,
263
+ gear_offset=0xD3,
264
+ addons_offset=0x19B1,
265
+ ),
266
+ )
267
+
268
+
269
+ @dataclass(frozen=True)
270
+ class SolareDetailDecode:
271
+ """A completely validated rich-table decode."""
272
+
273
+ layout_id: str
274
+ players: tuple[SolarePlayer, ...]
275
+ capabilities: frozenset[str]
276
+
277
+
278
+ @dataclass(frozen=True)
279
+ class SolareOverallDetailDecode:
280
+ """Independently decoded overall-table detail groups."""
281
+
282
+ layout_id: str
283
+ entries: tuple[SolareOverallEntry, ...]
284
+ capabilities: frozenset[str]
285
+
286
+
287
+ def _u32le(data: bytes, offset: int) -> int:
288
+ return int.from_bytes(data[offset : offset + 4], "little")
289
+
290
+
291
+ def _read_name(data: bytes, offset: int, max_chars: int = 32) -> str:
292
+ raw = bytearray()
293
+ for index in range(max_chars):
294
+ position = offset + index * 2
295
+ if position + 1 >= len(data):
296
+ return ""
297
+ low = data[position]
298
+ high = data[position + 1]
299
+ if low == 0 and high == 0:
300
+ return raw.decode("ascii") if raw else ""
301
+ if high != 0 or not (
302
+ 48 <= low <= 57 or 65 <= low <= 90 or 97 <= low <= 122 or low in (45, 95)
303
+ ):
304
+ return ""
305
+ raw.append(low)
306
+ return ""
307
+
308
+
309
+ def _read_u32_slots(data: bytes, base: int, offset: int) -> tuple[int, int, int]:
310
+ return tuple(
311
+ _u32le(data, base + offset + index * 4) for index in range(_CLASS_SLOT_COUNT)
312
+ ) # type: ignore[return-value]
313
+
314
+
315
+ def _decode_history_region(region: bytes) -> Optional[tuple[str, tuple[int, ...]]]:
316
+ nul = region.find(b"\x00")
317
+ if nul < 0:
318
+ return None
319
+ encoded = region[:nul]
320
+ try:
321
+ raw = encoded.decode("ascii")
322
+ except UnicodeDecodeError:
323
+ return None
324
+ if not raw:
325
+ return "", ()
326
+ if _HISTORY_PATTERN.fullmatch(raw) is None:
327
+ return None
328
+ return raw, tuple(int(value) for value in raw.split(","))
329
+
330
+
331
+ def _specialization(class_code: int, code: int) -> SolareSpecialization:
332
+ if code == 1:
333
+ return SolareSpecialization(
334
+ code=code,
335
+ branch="advanced",
336
+ name=ADVANCED_SPEC_BY_CLASS.get(class_code, "Awakening"),
337
+ )
338
+ return SolareSpecialization(code=code, branch="succession", name="Succession")
339
+
340
+
341
+ def _rich_layout_for(
342
+ family: DiscoveredSolareFamily,
343
+ ) -> Optional[_RichDetailLayout]:
344
+ if family.class_offset is None:
345
+ return None
346
+ geometry = (
347
+ family.frame_length,
348
+ family.record_stride,
349
+ family.name_offset,
350
+ family.rank_offset,
351
+ family.class_offset,
352
+ )
353
+ matches = [layout for layout in _RICH_DETAIL_LAYOUTS if layout.geometry == geometry]
354
+ return matches[0] if len(matches) == 1 else None
355
+
356
+
357
+ def _overall_layout_for(
358
+ family: DiscoveredSolareFamily,
359
+ ) -> Optional[_OverallDetailLayout]:
360
+ geometry = (
361
+ family.frame_length,
362
+ family.record_stride,
363
+ family.name_offset,
364
+ family.rank_offset,
365
+ )
366
+ matches = [
367
+ layout for layout in _OVERALL_DETAIL_LAYOUTS if layout.geometry == geometry
368
+ ]
369
+ return matches[0] if len(matches) == 1 else None
370
+
371
+
372
+ def _record_locations(
373
+ frames: Sequence[BDOFrame],
374
+ *,
375
+ expected_frames: int,
376
+ frame_length: int,
377
+ record_stride: int,
378
+ required_record_end: int,
379
+ ) -> Optional[tuple[tuple[bytes, int], ...]]:
380
+ if len(frames) != expected_frames:
381
+ return None
382
+ locations: list[tuple[bytes, int]] = []
383
+ for frame in frames:
384
+ if frame.length != frame_length or len(frame.message) != frame_length:
385
+ return None
386
+ for base in (0, record_stride):
387
+ if base + required_record_end > len(frame.message):
388
+ return None
389
+ locations.append((frame.message, base))
390
+ return tuple(locations)
391
+
392
+
393
+ def _occupied_slot_match_summary(
394
+ data: bytes,
395
+ base: int,
396
+ layout: _OverallDetailLayout,
397
+ ) -> Optional[tuple[int, int]]:
398
+ """Validate only occupied class slots and their match counters.
399
+
400
+ Aggregate W/D/L is an independent overall-record group. Its validation
401
+ needs to know how many class slots are exposed and how many matches those
402
+ slots account for, but it must not inherit the deeper performance group's
403
+ specialization, history, per-outcome, or opaque-section requirements.
404
+ """
405
+
406
+ class_codes = tuple(
407
+ data[base + layout.class_offset + index]
408
+ for index in range(_CLASS_SLOT_COUNT)
409
+ )
410
+ matches = _read_u32_slots(data, base, layout.matches_offset)
411
+
412
+ occupied = 0
413
+ exposed_matches = 0
414
+ seen_empty = False
415
+ occupied_codes: set[int] = set()
416
+ for class_code, slot_matches in zip(class_codes, matches):
417
+ if class_code == EMPTY_CLASS_CODE:
418
+ seen_empty = True
419
+ if slot_matches != 0:
420
+ return None
421
+ continue
422
+
423
+ if (
424
+ seen_empty
425
+ or class_name(class_code) is None
426
+ or class_code in occupied_codes
427
+ or not 0 < slot_matches <= _MAX_PLAUSIBLE_MATCHES
428
+ ):
429
+ return None
430
+ occupied_codes.add(class_code)
431
+ occupied += 1
432
+ exposed_matches += slot_matches
433
+
434
+ if (
435
+ occupied == 0
436
+ or exposed_matches <= 0
437
+ or exposed_matches > _MAX_PLAUSIBLE_MATCHES
438
+ ):
439
+ return None
440
+ return occupied, exposed_matches
441
+
442
+
443
+ def _decode_overall_aggregate_performance(
444
+ data: bytes,
445
+ base: int,
446
+ layout: _OverallDetailLayout,
447
+ ) -> Optional[tuple[int, int, int]]:
448
+ """Decode one independently validated overall aggregate W/D/L tuple."""
449
+
450
+ summary = _occupied_slot_match_summary(data, base, layout)
451
+ if summary is None:
452
+ return None
453
+ occupied_slots, exposed_matches = summary
454
+
455
+ wins = _u32le(data, base + layout.aggregate_wins_offset)
456
+ draws = _u32le(data, base + layout.aggregate_draws_offset)
457
+ losses = _u32le(data, base + layout.aggregate_losses_offset)
458
+ components = (wins, draws, losses)
459
+ if any(value > _MAX_PLAUSIBLE_MATCHES for value in components):
460
+ return None
461
+
462
+ total_matches = wins + draws + losses
463
+ if not 0 < total_matches <= _MAX_PLAUSIBLE_MATCHES:
464
+ return None
465
+ if total_matches < exposed_matches:
466
+ return None
467
+ if occupied_slots < _CLASS_SLOT_COUNT and total_matches != exposed_matches:
468
+ return None
469
+ return components
470
+
471
+
472
+ def _decode_performance_slots(
473
+ data: bytes,
474
+ base: int,
475
+ layout: _RichDetailLayout | _OverallDetailLayout,
476
+ *,
477
+ retain_raw_extensions: bool,
478
+ ) -> Optional[tuple[SolareClassPerformance, ...]]:
479
+ class_codes = tuple(
480
+ data[base + layout.class_offset + index] for index in range(_CLASS_SLOT_COUNT)
481
+ )
482
+ spec_codes = tuple(
483
+ data[base + layout.spec_offset + index] for index in range(_CLASS_SLOT_COUNT)
484
+ )
485
+ matches = _read_u32_slots(data, base, layout.matches_offset)
486
+ wins = _read_u32_slots(data, base, layout.wins_offset)
487
+ draws = _read_u32_slots(data, base, layout.draws_offset)
488
+ losses = _read_u32_slots(data, base, layout.losses_offset)
489
+
490
+ performances: list[SolareClassPerformance] = []
491
+ seen_empty = False
492
+ occupied_codes: set[int] = set()
493
+ for slot in range(_CLASS_SLOT_COUNT):
494
+ class_code = class_codes[slot]
495
+ spec_code = spec_codes[slot]
496
+ slot_matches = matches[slot]
497
+ slot_wins = wins[slot]
498
+ slot_draws = draws[slot]
499
+ slot_losses = losses[slot]
500
+
501
+ history_start = base + layout.history_offset + slot * _HISTORY_SLOT_SIZE
502
+ history_region = bytes(data[history_start : history_start + _HISTORY_SLOT_SIZE])
503
+ history = _decode_history_region(history_region)
504
+ if history is None:
505
+ return None
506
+ history_raw, history_codes = history
507
+
508
+ is_empty = class_code == EMPTY_CLASS_CODE
509
+ if is_empty:
510
+ seen_empty = True
511
+ if (
512
+ spec_code != EMPTY_SPEC_CODE
513
+ or any((slot_matches, slot_wins, slot_draws, slot_losses))
514
+ or history_raw
515
+ ):
516
+ return None
517
+ continue
518
+
519
+ display_name = class_name(class_code)
520
+ if (
521
+ seen_empty
522
+ or display_name is None
523
+ or class_code in occupied_codes
524
+ or spec_code not in (1, 2)
525
+ or not 0 < slot_matches <= _MAX_PLAUSIBLE_MATCHES
526
+ or any(
527
+ value > slot_matches for value in (slot_wins, slot_draws, slot_losses)
528
+ )
529
+ or slot_wins + slot_draws + slot_losses != slot_matches
530
+ or not history_raw
531
+ ):
532
+ return None
533
+ occupied_codes.add(class_code)
534
+
535
+ gear_loadout_raw: Optional[SolareRawSection] = None
536
+ skill_addons_raw: Optional[SolareRawSection] = None
537
+ if retain_raw_extensions:
538
+ gear_offset = layout.gear_offset + slot * _GEAR_SLOT_SIZE
539
+ addon_offset = layout.addons_offset + slot * _ADDON_SLOT_SIZE
540
+ gear = bytes(
541
+ data[base + gear_offset : base + gear_offset + _GEAR_SLOT_SIZE]
542
+ )
543
+ addons = bytes(
544
+ data[base + addon_offset : base + addon_offset + _ADDON_SLOT_SIZE]
545
+ )
546
+ if len(gear) != _GEAR_SLOT_SIZE or len(addons) != _ADDON_SLOT_SIZE:
547
+ return None
548
+ gear_loadout_raw = SolareRawSection(offset=gear_offset, data=gear)
549
+ skill_addons_raw = SolareRawSection(
550
+ offset=addon_offset,
551
+ data=addons,
552
+ )
553
+
554
+ player_class = SolareClass(class_code, display_name)
555
+ performances.append(
556
+ SolareClassPerformance(
557
+ slot=slot,
558
+ primary=slot == 0,
559
+ player_class=player_class,
560
+ specialization=_specialization(class_code, spec_code),
561
+ matches=slot_matches,
562
+ wins=slot_wins,
563
+ draws=slot_draws,
564
+ losses=slot_losses,
565
+ recent_results_raw=history_codes,
566
+ recent_results_wire_text=history_raw,
567
+ gear_loadout_raw=gear_loadout_raw,
568
+ skill_addons_raw=skill_addons_raw,
569
+ )
570
+ )
571
+
572
+ if not performances or not performances[0].primary:
573
+ return None
574
+ return tuple(performances)
575
+
576
+
577
+ def decode_solare_details(
578
+ rich_frames: Sequence[BDOFrame],
579
+ rich: DiscoveredSolareFamily,
580
+ *,
581
+ retain_raw_extensions: bool = False,
582
+ ) -> Optional[SolareDetailDecode]:
583
+ """Decode a complete rich table or return ``None`` on any contradiction.
584
+
585
+ ``rich_frames`` must already belong to the discovered rich family and be
586
+ in wire order. The overall table is deliberately not an input.
587
+ """
588
+
589
+ retain_raw_extensions = validate_retain_raw_extensions(retain_raw_extensions)
590
+ layout = _rich_layout_for(rich)
591
+ if layout is None:
592
+ return None
593
+ locations = _record_locations(
594
+ rich_frames,
595
+ expected_frames=_EXPECTED_RICH_FRAMES,
596
+ frame_length=layout.frame_length,
597
+ record_stride=layout.record_stride,
598
+ required_record_end=layout.required_record_end,
599
+ )
600
+ if locations is None or len(locations) != _EXPECTED_RICH_RECORDS:
601
+ return None
602
+ if not (
603
+ len(rich.names)
604
+ == len(rich.ranks)
605
+ == len(rich.class_codes)
606
+ == _EXPECTED_RICH_RECORDS
607
+ ):
608
+ return None
609
+
610
+ players: list[SolarePlayer] = []
611
+ primary_codes: list[int] = []
612
+ previous_elo: Optional[int] = None
613
+
614
+ for ordinal, (data, base) in enumerate(locations):
615
+ name = _read_name(data, base + layout.name_offset)
616
+ rank = _u32le(data, base + layout.rank_offset)
617
+ class_code = data[base + layout.class_offset]
618
+ elo = _u32le(data, base + layout.elo_offset)
619
+
620
+ if (
621
+ name != rich.names[ordinal]
622
+ or rank != rich.ranks[ordinal]
623
+ or class_code != rich.class_codes[ordinal]
624
+ or not 0 < elo <= _MAX_PLAUSIBLE_ELO
625
+ or (previous_elo is not None and previous_elo < elo)
626
+ ):
627
+ return None
628
+ previous_elo = elo
629
+
630
+ performances = _decode_performance_slots(
631
+ data,
632
+ base,
633
+ layout,
634
+ retain_raw_extensions=retain_raw_extensions,
635
+ )
636
+ if performances is None:
637
+ return None
638
+ primary = performances[0].player_class
639
+ players.append(
640
+ SolarePlayer(
641
+ name=name,
642
+ global_rank=rank,
643
+ primary_class=primary,
644
+ elo=elo,
645
+ classes_played=performances,
646
+ )
647
+ )
648
+ primary_codes.append(primary.code)
649
+
650
+ if (
651
+ len({player.name for player in players}) != _EXPECTED_RICH_RECORDS
652
+ or len({player.global_rank for player in players}) != _EXPECTED_RICH_RECORDS
653
+ or tuple(player.global_rank for player in players[:20]) != tuple(range(1, 21))
654
+ or any(
655
+ left.global_rank >= right.global_rank
656
+ for left, right in zip(players, players[1:])
657
+ )
658
+ ):
659
+ return None
660
+
661
+ class_counts = tuple(sorted(Counter(primary_codes).items()))
662
+ if class_counts != rich.class_counts or any(
663
+ count != 20 for _, count in class_counts
664
+ ):
665
+ return None
666
+
667
+ capabilities = {"rankings", "elo", "performance"}
668
+ if retain_raw_extensions:
669
+ capabilities.add("raw_extensions")
670
+ return SolareDetailDecode(
671
+ layout.layout_id,
672
+ tuple(players),
673
+ frozenset(capabilities),
674
+ )
675
+
676
+
677
+ def decode_solare_overall_details(
678
+ overall_frames: Sequence[BDOFrame],
679
+ overall: DiscoveredSolareFamily,
680
+ *,
681
+ retain_raw_extensions: bool = False,
682
+ ) -> Optional[SolareOverallDetailDecode]:
683
+ """Decode independently validated detail groups from the overall table.
684
+
685
+ Rank/name identity and record geometry are mandatory. Elo, aggregate W/D/L,
686
+ and class performance are each all-table groups: one contradiction withholds
687
+ that field family from every entry without contaminating independently valid
688
+ groups.
689
+ """
690
+
691
+ retain_raw_extensions = validate_retain_raw_extensions(retain_raw_extensions)
692
+ layout = _overall_layout_for(overall)
693
+ if layout is None:
694
+ return None
695
+ locations = _record_locations(
696
+ overall_frames,
697
+ expected_frames=_EXPECTED_OVERALL_FRAMES,
698
+ frame_length=layout.frame_length,
699
+ record_stride=layout.record_stride,
700
+ required_record_end=layout.required_record_end,
701
+ )
702
+ if locations is None or len(locations) != _EXPECTED_OVERALL_RECORDS:
703
+ return None
704
+ if not (
705
+ len(overall.names) == len(overall.ranks) == _EXPECTED_OVERALL_RECORDS
706
+ and overall.ranks == tuple(range(1, _EXPECTED_OVERALL_RECORDS + 1))
707
+ and len(set(overall.names)) == _EXPECTED_OVERALL_RECORDS
708
+ ):
709
+ return None
710
+
711
+ elos: list[int] = []
712
+ aggregates: list[Optional[tuple[int, int, int]]] = []
713
+ performances: list[Optional[tuple[SolareClassPerformance, ...]]] = []
714
+ for ordinal, (data, base) in enumerate(locations):
715
+ name = _read_name(data, base + layout.name_offset)
716
+ rank = _u32le(data, base + layout.rank_offset)
717
+ if name != overall.names[ordinal] or rank != overall.ranks[ordinal]:
718
+ return None
719
+ elos.append(_u32le(data, base + layout.elo_offset))
720
+ aggregates.append(
721
+ _decode_overall_aggregate_performance(data, base, layout)
722
+ )
723
+ performances.append(
724
+ _decode_performance_slots(
725
+ data,
726
+ base,
727
+ layout,
728
+ retain_raw_extensions=False,
729
+ )
730
+ )
731
+
732
+ elo_valid = all(0 < elo <= _MAX_PLAUSIBLE_ELO for elo in elos) and all(
733
+ left >= right for left, right in zip(elos, elos[1:])
734
+ )
735
+ aggregate_valid = all(item is not None for item in aggregates)
736
+ performance_valid = all(item is not None for item in performances)
737
+
738
+ selected_performances = performances
739
+ raw_valid = False
740
+ if performance_valid and retain_raw_extensions:
741
+ with_raw = [
742
+ _decode_performance_slots(
743
+ data,
744
+ base,
745
+ layout,
746
+ retain_raw_extensions=True,
747
+ )
748
+ for data, base in locations
749
+ ]
750
+ if all(item is not None for item in with_raw):
751
+ selected_performances = with_raw
752
+ raw_valid = True
753
+
754
+ capabilities = {"rankings"}
755
+ if elo_valid:
756
+ capabilities.add("elo")
757
+ if aggregate_valid:
758
+ capabilities.add("aggregate_performance")
759
+ if performance_valid:
760
+ capabilities.add("performance")
761
+ if raw_valid:
762
+ capabilities.add("raw_extensions")
763
+
764
+ def entry_performances(
765
+ ordinal: int,
766
+ ) -> tuple[SolareClassPerformance, ...]:
767
+ item = selected_performances[ordinal]
768
+ return item if performance_valid and item is not None else ()
769
+
770
+ def entry_aggregate(
771
+ ordinal: int,
772
+ ) -> tuple[Optional[int], Optional[int], Optional[int]]:
773
+ item = aggregates[ordinal]
774
+ if aggregate_valid and item is not None:
775
+ return item
776
+ return None, None, None
777
+
778
+ entries: list[SolareOverallEntry] = []
779
+ for ordinal, (name, rank) in enumerate(zip(overall.names, overall.ranks)):
780
+ total_wins, total_draws, total_losses = entry_aggregate(ordinal)
781
+ entries.append(
782
+ SolareOverallEntry(
783
+ name=name,
784
+ global_rank=rank,
785
+ elo=elos[ordinal] if elo_valid else None,
786
+ total_wins=total_wins,
787
+ total_draws=total_draws,
788
+ total_losses=total_losses,
789
+ classes_played=entry_performances(ordinal),
790
+ )
791
+ )
792
+ return SolareOverallDetailDecode(
793
+ layout.layout_id,
794
+ tuple(entries),
795
+ frozenset(capabilities),
796
+ )