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