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