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,358 @@
|
|
|
1
|
+
"""Explicit retrieval and atomic installation of verified opcode profiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import tempfile
|
|
14
|
+
from dataclasses import dataclass, replace
|
|
15
|
+
from http.client import HTTPException
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
from urllib.error import HTTPError, URLError
|
|
19
|
+
from urllib.parse import urlsplit
|
|
20
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
21
|
+
|
|
22
|
+
from ._profile_runtime import validate_runtime_profile
|
|
23
|
+
from .profiles import OpcodeProfile, ProfileError, load_opcode_profile
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DEFAULT_REMOTE_PROFILE_TIMEOUT_SECONDS = 10.0
|
|
27
|
+
DEFAULT_REMOTE_PROFILE_MAX_BYTES = 1024 * 1024
|
|
28
|
+
REMOTE_PROFILE_ENVELOPE_VERSION = 1
|
|
29
|
+
_REVISION_PATTERN = re.compile(r"[a-z0-9][a-z0-9._-]{0,127}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RemoteProfileError(ProfileError):
|
|
33
|
+
"""Raised when a remote profile cannot be securely fetched or installed."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ProfileFetchResult:
|
|
38
|
+
"""Result of installing one verified remote opcode-profile envelope."""
|
|
39
|
+
|
|
40
|
+
profile: OpcodeProfile
|
|
41
|
+
source_url: str
|
|
42
|
+
revision: str
|
|
43
|
+
etag: Optional[str]
|
|
44
|
+
backup_path: Optional[Path]
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def path(self) -> Path:
|
|
48
|
+
"""Installed destination, canonically owned by the loaded profile."""
|
|
49
|
+
|
|
50
|
+
return self.profile.path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _HttpsOnlyRedirectHandler(HTTPRedirectHandler):
|
|
54
|
+
def redirect_request(
|
|
55
|
+
self,
|
|
56
|
+
request: Request,
|
|
57
|
+
file_pointer: Any,
|
|
58
|
+
code: int,
|
|
59
|
+
message: str,
|
|
60
|
+
headers: Any,
|
|
61
|
+
new_url: str,
|
|
62
|
+
) -> Optional[Request]:
|
|
63
|
+
_validate_https_url(new_url)
|
|
64
|
+
return super().redirect_request(
|
|
65
|
+
request,
|
|
66
|
+
file_pointer,
|
|
67
|
+
code,
|
|
68
|
+
message,
|
|
69
|
+
headers,
|
|
70
|
+
new_url,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def fetch_opcode_profile(
|
|
75
|
+
url: str,
|
|
76
|
+
destination: str | Path,
|
|
77
|
+
*,
|
|
78
|
+
timeout: float = DEFAULT_REMOTE_PROFILE_TIMEOUT_SECONDS,
|
|
79
|
+
max_bytes: int = DEFAULT_REMOTE_PROFILE_MAX_BYTES,
|
|
80
|
+
backup: bool = True,
|
|
81
|
+
) -> ProfileFetchResult:
|
|
82
|
+
"""Fetch, verify, and atomically install one remote opcode profile.
|
|
83
|
+
|
|
84
|
+
The endpoint must return a version-1 envelope containing a manifest and an
|
|
85
|
+
embedded profile. The manifest's SHA-256 covers the canonical JSON encoding
|
|
86
|
+
of the embedded profile, so manifest metadata and profile bytes arrive in
|
|
87
|
+
one envelope response. HTTPS redirects are permitted only to credential-free
|
|
88
|
+
HTTPS URLs. Capture and replay APIs never call this function implicitly.
|
|
89
|
+
|
|
90
|
+
Installation uses atomic replacement but no inter-process lock. The caller
|
|
91
|
+
must enforce one writer per destination path; concurrent fetches targeting
|
|
92
|
+
the same file are unsupported.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
_validate_https_url(url)
|
|
96
|
+
validated_timeout = _validate_timeout(timeout)
|
|
97
|
+
validated_max_bytes = _validate_max_bytes(max_bytes)
|
|
98
|
+
if not isinstance(backup, bool):
|
|
99
|
+
raise TypeError("backup must be a boolean")
|
|
100
|
+
try:
|
|
101
|
+
destination_path = Path(destination)
|
|
102
|
+
except TypeError as exc:
|
|
103
|
+
raise TypeError("destination must be a path string or Path") from exc
|
|
104
|
+
if destination_path.exists() and not destination_path.is_file():
|
|
105
|
+
raise IsADirectoryError(
|
|
106
|
+
f"Opcode profile destination is not a file: {destination_path}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
payload, source_url, etag = _fetch_envelope_bytes(
|
|
110
|
+
url,
|
|
111
|
+
timeout=validated_timeout,
|
|
112
|
+
max_bytes=validated_max_bytes,
|
|
113
|
+
)
|
|
114
|
+
profile_data, revision, expected_digest = _decode_envelope(payload)
|
|
115
|
+
actual_digest = hashlib.sha256(_canonical_profile_bytes(profile_data)).hexdigest()
|
|
116
|
+
if not hmac.compare_digest(actual_digest, expected_digest):
|
|
117
|
+
raise RemoteProfileError(
|
|
118
|
+
"remote profile hash mismatch: manifest declared "
|
|
119
|
+
f"{expected_digest}, decoded profile was {actual_digest}"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
rendered_profile = _render_profile(profile_data)
|
|
123
|
+
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
temporary_path: Optional[Path] = None
|
|
125
|
+
backup_path: Optional[Path] = None
|
|
126
|
+
try:
|
|
127
|
+
with tempfile.NamedTemporaryFile(
|
|
128
|
+
mode="wb",
|
|
129
|
+
dir=destination_path.parent,
|
|
130
|
+
prefix=f".{destination_path.name}.",
|
|
131
|
+
suffix=".tmp",
|
|
132
|
+
delete=False,
|
|
133
|
+
) as handle:
|
|
134
|
+
temporary_path = Path(handle.name)
|
|
135
|
+
handle.write(rendered_profile)
|
|
136
|
+
handle.flush()
|
|
137
|
+
os.fsync(handle.fileno())
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
installed_profile = load_opcode_profile(temporary_path)
|
|
141
|
+
if not installed_profile.active:
|
|
142
|
+
raise RemoteProfileError(
|
|
143
|
+
"remote opcode profile is inactive and was not installed"
|
|
144
|
+
)
|
|
145
|
+
validate_runtime_profile(installed_profile)
|
|
146
|
+
except RemoteProfileError:
|
|
147
|
+
raise
|
|
148
|
+
except (
|
|
149
|
+
ProfileError,
|
|
150
|
+
RecursionError,
|
|
151
|
+
TypeError,
|
|
152
|
+
UnicodeError,
|
|
153
|
+
ValueError,
|
|
154
|
+
) as exc:
|
|
155
|
+
raise RemoteProfileError(
|
|
156
|
+
f"remote opcode profile failed runtime validation: {exc}"
|
|
157
|
+
) from exc
|
|
158
|
+
|
|
159
|
+
if backup and destination_path.exists():
|
|
160
|
+
backup_path = _next_backup_path(destination_path)
|
|
161
|
+
shutil.copy2(destination_path, backup_path)
|
|
162
|
+
os.replace(temporary_path, destination_path)
|
|
163
|
+
temporary_path = None
|
|
164
|
+
finally:
|
|
165
|
+
if temporary_path is not None and temporary_path.exists():
|
|
166
|
+
temporary_path.unlink()
|
|
167
|
+
|
|
168
|
+
installed_profile = replace(installed_profile, path=destination_path)
|
|
169
|
+
return ProfileFetchResult(
|
|
170
|
+
profile=installed_profile,
|
|
171
|
+
source_url=source_url,
|
|
172
|
+
revision=revision,
|
|
173
|
+
etag=etag,
|
|
174
|
+
backup_path=backup_path,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _fetch_envelope_bytes(
|
|
179
|
+
url: str,
|
|
180
|
+
*,
|
|
181
|
+
timeout: float,
|
|
182
|
+
max_bytes: int,
|
|
183
|
+
) -> tuple[bytes, str, Optional[str]]:
|
|
184
|
+
try:
|
|
185
|
+
request = Request(
|
|
186
|
+
url,
|
|
187
|
+
headers={
|
|
188
|
+
"Accept": "application/json",
|
|
189
|
+
"User-Agent": "bdo-toolkit-profile-fetch/1",
|
|
190
|
+
},
|
|
191
|
+
method="GET",
|
|
192
|
+
)
|
|
193
|
+
opener = build_opener(_HttpsOnlyRedirectHandler())
|
|
194
|
+
with opener.open(request, timeout=timeout) as response:
|
|
195
|
+
final_url = response.geturl()
|
|
196
|
+
_validate_https_url(final_url)
|
|
197
|
+
content_length = response.headers.get("Content-Length")
|
|
198
|
+
if content_length is not None:
|
|
199
|
+
try:
|
|
200
|
+
declared_length = int(content_length)
|
|
201
|
+
except ValueError:
|
|
202
|
+
declared_length = -1
|
|
203
|
+
if declared_length > max_bytes:
|
|
204
|
+
raise RemoteProfileError(
|
|
205
|
+
"remote profile envelope exceeds the configured "
|
|
206
|
+
f"{max_bytes}-byte limit"
|
|
207
|
+
)
|
|
208
|
+
payload = response.read(max_bytes + 1)
|
|
209
|
+
if len(payload) > max_bytes:
|
|
210
|
+
raise RemoteProfileError(
|
|
211
|
+
"remote profile envelope exceeds the configured "
|
|
212
|
+
f"{max_bytes}-byte limit"
|
|
213
|
+
)
|
|
214
|
+
etag_value = response.headers.get("ETag")
|
|
215
|
+
etag = etag_value if isinstance(etag_value, str) else None
|
|
216
|
+
return payload, final_url, etag
|
|
217
|
+
except RemoteProfileError:
|
|
218
|
+
raise
|
|
219
|
+
except (
|
|
220
|
+
HTTPError,
|
|
221
|
+
URLError,
|
|
222
|
+
HTTPException,
|
|
223
|
+
TimeoutError,
|
|
224
|
+
OSError,
|
|
225
|
+
ValueError,
|
|
226
|
+
) as exc:
|
|
227
|
+
raise RemoteProfileError(f"could not fetch remote opcode profile: {exc}") from exc
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _decode_envelope(payload: bytes) -> tuple[dict[str, Any], str, str]:
|
|
231
|
+
try:
|
|
232
|
+
envelope = json.loads(payload.decode("utf-8-sig"))
|
|
233
|
+
except (UnicodeError, json.JSONDecodeError, RecursionError) as exc:
|
|
234
|
+
raise RemoteProfileError(
|
|
235
|
+
f"remote profile envelope is not valid UTF-8 JSON: {exc}"
|
|
236
|
+
) from exc
|
|
237
|
+
if not isinstance(envelope, dict):
|
|
238
|
+
raise RemoteProfileError("remote profile envelope must be a JSON object")
|
|
239
|
+
schema_version = envelope.get("schema_version")
|
|
240
|
+
if (
|
|
241
|
+
isinstance(schema_version, bool)
|
|
242
|
+
or schema_version != REMOTE_PROFILE_ENVELOPE_VERSION
|
|
243
|
+
):
|
|
244
|
+
raise RemoteProfileError(
|
|
245
|
+
"unsupported remote profile envelope schema_version; expected "
|
|
246
|
+
f"{REMOTE_PROFILE_ENVELOPE_VERSION}"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
manifest = envelope.get("manifest")
|
|
250
|
+
if not isinstance(manifest, dict):
|
|
251
|
+
raise RemoteProfileError("remote profile envelope manifest must be an object")
|
|
252
|
+
revision_value = manifest.get("revision")
|
|
253
|
+
if (
|
|
254
|
+
not isinstance(revision_value, str)
|
|
255
|
+
or _REVISION_PATTERN.fullmatch(revision_value) is None
|
|
256
|
+
):
|
|
257
|
+
raise RemoteProfileError(
|
|
258
|
+
"remote profile envelope manifest.revision must be a lowercase "
|
|
259
|
+
"slug of 1 to 128 ASCII letters, digits, dots, underscores, or hyphens"
|
|
260
|
+
)
|
|
261
|
+
digest_value = manifest.get("profile_sha256")
|
|
262
|
+
if not isinstance(digest_value, str) or not _is_sha256(digest_value):
|
|
263
|
+
raise RemoteProfileError(
|
|
264
|
+
"remote profile envelope manifest.profile_sha256 must be 64 hex characters"
|
|
265
|
+
)
|
|
266
|
+
profile = envelope.get("profile")
|
|
267
|
+
if not isinstance(profile, dict):
|
|
268
|
+
raise RemoteProfileError("remote profile envelope profile must be an object")
|
|
269
|
+
return profile, revision_value, digest_value.casefold()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _canonical_profile_bytes(profile: dict[str, Any]) -> bytes:
|
|
273
|
+
try:
|
|
274
|
+
rendered = json.dumps(
|
|
275
|
+
profile,
|
|
276
|
+
ensure_ascii=False,
|
|
277
|
+
allow_nan=False,
|
|
278
|
+
sort_keys=True,
|
|
279
|
+
separators=(",", ":"),
|
|
280
|
+
)
|
|
281
|
+
return rendered.encode("utf-8")
|
|
282
|
+
except (RecursionError, TypeError, UnicodeError, ValueError) as exc:
|
|
283
|
+
raise RemoteProfileError(
|
|
284
|
+
f"remote profile cannot be canonically encoded: {exc}"
|
|
285
|
+
) from exc
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _render_profile(profile: dict[str, Any]) -> bytes:
|
|
289
|
+
try:
|
|
290
|
+
rendered = json.dumps(
|
|
291
|
+
profile,
|
|
292
|
+
ensure_ascii=False,
|
|
293
|
+
allow_nan=False,
|
|
294
|
+
sort_keys=True,
|
|
295
|
+
indent=2,
|
|
296
|
+
)
|
|
297
|
+
return (rendered + "\n").encode("utf-8")
|
|
298
|
+
except (RecursionError, TypeError, UnicodeError, ValueError) as exc:
|
|
299
|
+
raise RemoteProfileError(f"remote profile cannot be encoded: {exc}") from exc
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _validate_https_url(url: str) -> None:
|
|
303
|
+
if not isinstance(url, str):
|
|
304
|
+
raise TypeError("url must be a string")
|
|
305
|
+
try:
|
|
306
|
+
parsed = urlsplit(url)
|
|
307
|
+
hostname = parsed.hostname
|
|
308
|
+
except ValueError as exc:
|
|
309
|
+
raise RemoteProfileError(f"invalid remote opcode profile URL: {exc}") from exc
|
|
310
|
+
if parsed.scheme.casefold() != "https" or not hostname:
|
|
311
|
+
raise RemoteProfileError("remote opcode profile URL must use HTTPS")
|
|
312
|
+
if parsed.username is not None or parsed.password is not None:
|
|
313
|
+
raise RemoteProfileError("remote opcode profile URL must not contain credentials")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _validate_timeout(timeout: float) -> float:
|
|
317
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
|
|
318
|
+
raise TypeError("timeout must be a finite positive number")
|
|
319
|
+
result = float(timeout)
|
|
320
|
+
if not math.isfinite(result) or result <= 0:
|
|
321
|
+
raise ValueError("timeout must be a finite positive number")
|
|
322
|
+
return result
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _validate_max_bytes(max_bytes: int) -> int:
|
|
326
|
+
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int):
|
|
327
|
+
raise TypeError("max_bytes must be a positive integer")
|
|
328
|
+
if max_bytes <= 0:
|
|
329
|
+
raise ValueError("max_bytes must be a positive integer")
|
|
330
|
+
return max_bytes
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _is_sha256(value: str) -> bool:
|
|
334
|
+
return len(value) == 64 and all(
|
|
335
|
+
character in "0123456789abcdefABCDEF" for character in value
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _next_backup_path(path: Path) -> Path:
|
|
340
|
+
backup_dir = path.parent / "opcodes_backups"
|
|
341
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
342
|
+
stamp = dt.datetime.now(tz=dt.timezone.utc).strftime("%Y%m%d%H%M%S%f")
|
|
343
|
+
candidate = backup_dir / f"{path.name}.bak.{stamp}"
|
|
344
|
+
suffix = 1
|
|
345
|
+
while candidate.exists():
|
|
346
|
+
candidate = backup_dir / f"{path.name}.bak.{stamp}.{suffix}"
|
|
347
|
+
suffix += 1
|
|
348
|
+
return candidate
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
__all__ = [
|
|
352
|
+
"DEFAULT_REMOTE_PROFILE_MAX_BYTES",
|
|
353
|
+
"DEFAULT_REMOTE_PROFILE_TIMEOUT_SECONDS",
|
|
354
|
+
"ProfileFetchResult",
|
|
355
|
+
"REMOTE_PROFILE_ENVELOPE_VERSION",
|
|
356
|
+
"RemoteProfileError",
|
|
357
|
+
"fetch_opcode_profile",
|
|
358
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Public experimental Arena of Solare leaderboard API.
|
|
2
|
+
|
|
3
|
+
This domain shares the toolkit's passive packet acquisition and TCP
|
|
4
|
+
reassembly, but intentionally does not emit :class:`bdo_toolkit.BDOEvent`.
|
|
5
|
+
Solare is a finite snapshot protocol with structured discovery progress, so it
|
|
6
|
+
uses its own result models and sessions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .async_session import AsyncLiveSolareSession
|
|
10
|
+
from .models import (
|
|
11
|
+
SolareCaptureEndpoint,
|
|
12
|
+
SolareCaptureHealth,
|
|
13
|
+
SolareCaptureResult,
|
|
14
|
+
SolareClass,
|
|
15
|
+
SolareClassPerformance,
|
|
16
|
+
SolareDetectionStatus,
|
|
17
|
+
SolareEvidence,
|
|
18
|
+
SolareFamilyLayout,
|
|
19
|
+
SolareLeaderboardSnapshot,
|
|
20
|
+
SolareOverallEntry,
|
|
21
|
+
SolarePlayer,
|
|
22
|
+
SolareRawSection,
|
|
23
|
+
SolareSpecialization,
|
|
24
|
+
SolareUpdate,
|
|
25
|
+
SolareUpdateKind,
|
|
26
|
+
)
|
|
27
|
+
from .replay import replay_solare
|
|
28
|
+
from .session import LiveSolareSession, capture_solare_snapshot
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AsyncLiveSolareSession",
|
|
32
|
+
"LiveSolareSession",
|
|
33
|
+
"SolareCaptureEndpoint",
|
|
34
|
+
"SolareCaptureHealth",
|
|
35
|
+
"SolareCaptureResult",
|
|
36
|
+
"SolareClass",
|
|
37
|
+
"SolareClassPerformance",
|
|
38
|
+
"SolareDetectionStatus",
|
|
39
|
+
"SolareEvidence",
|
|
40
|
+
"SolareFamilyLayout",
|
|
41
|
+
"SolareLeaderboardSnapshot",
|
|
42
|
+
"SolareOverallEntry",
|
|
43
|
+
"SolarePlayer",
|
|
44
|
+
"SolareRawSection",
|
|
45
|
+
"SolareSpecialization",
|
|
46
|
+
"SolareUpdate",
|
|
47
|
+
"SolareUpdateKind",
|
|
48
|
+
"capture_solare_snapshot",
|
|
49
|
+
"replay_solare",
|
|
50
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Arena of Solare protocol constants that are not opcode authorities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
BDO_HEADER_SIZE = 5
|
|
9
|
+
BDO_MAX_FRAME_SIZE = 0xFFFF
|
|
10
|
+
|
|
11
|
+
EMPTY_CLASS_CODE = 101
|
|
12
|
+
EMPTY_SPEC_CODE = 3
|
|
13
|
+
|
|
14
|
+
DISCOVERY_MIN_FRAME_LENGTH = 8 * 1024
|
|
15
|
+
DISCOVERY_MAX_FRAME_LENGTH = 32 * 1024
|
|
16
|
+
DISCOVERY_MIN_FAMILY_FRAMES = 10
|
|
17
|
+
DISCOVERY_MIN_RICH_RECORDS = 20 * 20
|
|
18
|
+
DISCOVERY_MIN_PARTIAL_OVERALL_RECORDS = 20
|
|
19
|
+
DISCOVERY_MAX_CLASS_GROUPS = 64
|
|
20
|
+
DISCOVERY_NAME_PAIR_TOLERANCE = 512
|
|
21
|
+
DISCOVERY_FIELD_SCAN_LIMIT = 512
|
|
22
|
+
DISCOVERY_MAX_FAMILY_DISTANCE = 1024 * 1024
|
|
23
|
+
DISCOVERY_SYNC_HEADERS = 3
|
|
24
|
+
DISCOVERY_SYNC_BUFFER_LIMIT = (BDO_MAX_FRAME_SIZE * 2) + BDO_HEADER_SIZE
|
|
25
|
+
LIVE_CAPTURE_BUFFER_BYTES = 64 * 1024 * 1024
|
|
26
|
+
SOLARE_DEFAULT_CAPTURE_SECONDS = 120.0
|
|
27
|
+
# An exact trailing 50-frame family remains provisional while another Solare-
|
|
28
|
+
# sized frame could still extend it into a rich-table prefix. Ordinary small
|
|
29
|
+
# game traffic must not keep that candidate boundary open indefinitely.
|
|
30
|
+
LIVE_CANDIDATE_IDLE_SECONDS = 1.5
|
|
31
|
+
DISCOVERY_RETENTION_MAX_FRAMES = 768
|
|
32
|
+
DISCOVERY_RETENTION_MAX_BYTES = 16 * 1024 * 1024
|
|
33
|
+
LIVE_PACKET_QUEUE_MAX = 4096
|
|
34
|
+
LIVE_UPDATE_QUEUE_MAX = 64
|
|
35
|
+
SOLARE_MAX_ACTIVE_FLOWS = 64
|
|
36
|
+
# Windows/Npcap can deliver one lossless receive burst hundreds of TCP
|
|
37
|
+
# callbacks out of sequence. Solare permits a larger per-flow reorder count
|
|
38
|
+
# than the generic item route, while an explicit byte ceiling preserves the
|
|
39
|
+
# existing bounded-memory posture.
|
|
40
|
+
SOLARE_MAX_PENDING_SEGMENTS = 2048
|
|
41
|
+
SOLARE_MAX_PENDING_BYTES = 8 * 1024 * 1024
|
|
42
|
+
|
|
43
|
+
PLAYER_NAME_BYTES = frozenset(
|
|
44
|
+
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
CLASS_NAMES: dict[int, str] = {
|
|
48
|
+
0: "Warrior",
|
|
49
|
+
1: "Hashashin",
|
|
50
|
+
2: "Sage",
|
|
51
|
+
3: "Wukong",
|
|
52
|
+
4: "Ranger",
|
|
53
|
+
5: "Guardian",
|
|
54
|
+
6: "Scholar",
|
|
55
|
+
7: "Drakania",
|
|
56
|
+
8: "Sorceress",
|
|
57
|
+
9: "Nova",
|
|
58
|
+
10: "Corsair",
|
|
59
|
+
11: "Lahn",
|
|
60
|
+
12: "Berserker",
|
|
61
|
+
15: "Maegu",
|
|
62
|
+
16: "Tamer",
|
|
63
|
+
17: "Shai",
|
|
64
|
+
19: "Striker",
|
|
65
|
+
20: "Musa",
|
|
66
|
+
21: "Maehwa",
|
|
67
|
+
23: "Mystic",
|
|
68
|
+
24: "Valkyrie",
|
|
69
|
+
25: "Kunoichi",
|
|
70
|
+
26: "Ninja",
|
|
71
|
+
27: "Dark Knight",
|
|
72
|
+
28: "Wizard",
|
|
73
|
+
29: "Archer",
|
|
74
|
+
30: "Woosa",
|
|
75
|
+
31: "Witch",
|
|
76
|
+
32: "Seraph",
|
|
77
|
+
33: "Dosa",
|
|
78
|
+
34: "Deadeye",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
ADVANCED_SPEC_BY_CLASS: dict[int, str] = {
|
|
82
|
+
3: "Ascension",
|
|
83
|
+
6: "Ascension",
|
|
84
|
+
17: "Talent",
|
|
85
|
+
29: "Ascension",
|
|
86
|
+
32: "Ascension",
|
|
87
|
+
34: "Ascension",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def class_name(code: int) -> Optional[str]:
|
|
92
|
+
if code == EMPTY_CLASS_CODE:
|
|
93
|
+
return None
|
|
94
|
+
return CLASS_NAMES.get(code)
|