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,1051 @@
1
+ """Controlled passive live capture for Arena of Solare snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import time
7
+ from collections.abc import Callable, Iterator
8
+ from contextvars import ContextVar
9
+ from dataclasses import replace
10
+ from pathlib import Path
11
+ from queue import Empty, Full, Queue
12
+ from threading import Event, Lock, Thread, current_thread
13
+ from types import TracebackType
14
+ from typing import Any, Optional
15
+
16
+ from bdo_toolkit._capture_backend import make_packet_handler
17
+ from bdo_toolkit._capture_options import PacketCaptureOptions
18
+ from bdo_toolkit._capture_runtime import (
19
+ CaptureStats,
20
+ LivePacketCapture,
21
+ _attach_cleanup_owner,
22
+ )
23
+
24
+ from ._constants import (
25
+ LIVE_CAPTURE_BUFFER_BYTES,
26
+ LIVE_PACKET_QUEUE_MAX,
27
+ LIVE_UPDATE_QUEUE_MAX,
28
+ SOLARE_DEFAULT_CAPTURE_SECONDS,
29
+ )
30
+ from ._live_tracker import LiveSolareDiscoveryTracker
31
+ from ._replay_capture import SolareFrameCollector
32
+ from ._result import _resource_loss_summary, build_solare_result
33
+ from ._validation import validate_retain_raw_extensions
34
+ from .models import (
35
+ SolareCaptureEndpoint,
36
+ SolareCaptureHealth,
37
+ SolareCaptureResult,
38
+ SolareUpdate,
39
+ SolareUpdateKind,
40
+ )
41
+
42
+
43
+ _POLL_INTERVAL_SECONDS = 0.2
44
+ _ACTIVE_UPDATE_SESSIONS: ContextVar[tuple[object, ...]] = ContextVar(
45
+ "bdo_toolkit_active_solare_update_sessions",
46
+ default=(),
47
+ )
48
+
49
+
50
+ def _validate_timeout(value: Optional[float], *, name: str) -> None:
51
+ if value is None:
52
+ return
53
+ if (
54
+ isinstance(value, bool)
55
+ or not isinstance(value, (int, float))
56
+ or not math.isfinite(value)
57
+ or value < 0
58
+ ):
59
+ raise ValueError(f"{name} must be finite and non-negative")
60
+
61
+
62
+ class LiveSolareSession:
63
+ """Single-use live session that assembles one structural snapshot.
64
+
65
+ Packet acquisition runs in Scapy's thread, while pcap writing, TCP
66
+ reassembly, discovery, and detailed decoding run in a separate worker.
67
+ This keeps the capture callback small enough for the multi-megabyte
68
+ leaderboard burst. Structural discovery receives no known Solare opcode or
69
+ saved layout; validated detail fingerprints are considered only after the
70
+ ranking tables independently confirm.
71
+ """
72
+
73
+ # Unknown Solare detail geometries are learned only after the complete
74
+ # snapshot has been structurally confirmed. That bounded, fail-closed
75
+ # finalization can legitimately take several seconds on the full 720
76
+ # records, so leave headroom beyond the historical five-second decoder
77
+ # cleanup deadline while still bounding a genuinely stuck worker.
78
+ _WORKER_STOP_TIMEOUT_SECONDS = 15.0
79
+
80
+ def __init__(
81
+ self,
82
+ *,
83
+ capture_options: Optional[PacketCaptureOptions] = None,
84
+ save_pcap: str | Path | None = None,
85
+ stop_on_complete: bool = True,
86
+ retain_raw_extensions: bool = False,
87
+ on_update: Optional[Callable[[SolareUpdate], None]] = None,
88
+ capture_buffer_bytes: int = LIVE_CAPTURE_BUFFER_BYTES,
89
+ ) -> None:
90
+ if capture_options is not None and not isinstance(
91
+ capture_options, PacketCaptureOptions
92
+ ):
93
+ raise TypeError("capture_options must be a PacketCaptureOptions or None")
94
+ if not isinstance(stop_on_complete, bool):
95
+ raise TypeError("stop_on_complete must be a boolean")
96
+ retain_raw_extensions = validate_retain_raw_extensions(
97
+ retain_raw_extensions
98
+ )
99
+ if on_update is not None and not callable(on_update):
100
+ raise TypeError("on_update must be callable or None")
101
+
102
+ self._capture_options = capture_options or PacketCaptureOptions()
103
+ self._save_pcap = Path(save_pcap) if save_pcap is not None else None
104
+ self._stop_on_complete = stop_on_complete
105
+ self._retain_raw_extensions = retain_raw_extensions
106
+ self._on_update = on_update
107
+ self._capture_buffer_bytes = capture_buffer_bytes
108
+
109
+ self._packet_queue: Queue[object] = Queue(maxsize=LIVE_PACKET_QUEUE_MAX)
110
+ self._update_queue: Queue[SolareUpdate] = Queue(
111
+ maxsize=LIVE_UPDATE_QUEUE_MAX
112
+ )
113
+ self._stop_requested = Event()
114
+ self._stopped = Event()
115
+ self._worker_ready = Event()
116
+ self._state_lock = Lock()
117
+ self._finish_lock = Lock()
118
+
119
+ self._start_attempted = False
120
+ self._started = False
121
+ self._capture: Optional[LivePacketCapture] = None
122
+ self._collector: Optional[SolareFrameCollector] = None
123
+ self._tracker: Optional[LiveSolareDiscoveryTracker] = None
124
+ self._worker: Optional[Thread] = None
125
+ self._packet_handler: Optional[Callable[[object], None]] = None
126
+ self._writer: Any = None
127
+ self._saved_packets = 0
128
+ self._traffic_announced = False
129
+ self._tcp_gap_warning_announced = False
130
+ self._confirmed_health: Optional[SolareCaptureHealth] = None
131
+ self._result: Optional[SolareCaptureResult] = None
132
+ self._error: Optional[BaseException] = None
133
+ self._stop_reason: Optional[str] = None
134
+ self._endpoint: Optional[SolareCaptureEndpoint] = None
135
+ self._packet_queue_peak = 0
136
+ self._packet_queue_overflows = 0
137
+ self._decoder_failed = False
138
+ self._cleanup_incomplete = False
139
+
140
+ @property
141
+ def running(self) -> bool:
142
+ return self._started and not self._stopped.is_set()
143
+
144
+ @property
145
+ def stopped(self) -> bool:
146
+ return self._stopped.is_set()
147
+
148
+ @property
149
+ def cleanup_incomplete(self) -> bool:
150
+ """Whether capture cleanup remains owned and retryable."""
151
+
152
+ return self._cleanup_incomplete
153
+
154
+ @property
155
+ def result(self) -> Optional[SolareCaptureResult]:
156
+ """The final result after manual or automatic shutdown."""
157
+
158
+ with self._state_lock:
159
+ return self._result
160
+
161
+ @property
162
+ def error(self) -> Optional[BaseException]:
163
+ with self._state_lock:
164
+ return self._error
165
+
166
+ @property
167
+ def stop_reason(self) -> Optional[str]:
168
+ with self._state_lock:
169
+ return self._stop_reason
170
+
171
+ @property
172
+ def endpoint(self) -> Optional[SolareCaptureEndpoint]:
173
+ """Resolved live-capture target, exposed as a Solare public model."""
174
+
175
+ return self._endpoint
176
+
177
+ def start(self) -> None:
178
+ """Open live capture and return once the adapter is ready."""
179
+
180
+ # Final progress callbacks run while the worker owns ``_finish_lock``.
181
+ # A same-session recursive start is always invalid, so reject the
182
+ # already-attempted state before touching that lock instead of letting
183
+ # callback misuse deadlock finalization. The check inside the lock
184
+ # remains authoritative for concurrent first-start attempts.
185
+ if self._start_attempted:
186
+ raise RuntimeError("live Solare session was already started")
187
+ ready_update: Optional[SolareUpdate] = None
188
+ with self._finish_lock:
189
+ if self._start_attempted:
190
+ raise RuntimeError("live Solare session was already started")
191
+ self._start_attempted = True
192
+ capture: Optional[LivePacketCapture] = None
193
+ try:
194
+ save_path = self._prepare_save_path()
195
+ tracker = LiveSolareDiscoveryTracker(self._emit)
196
+ collector = SolareFrameCollector(
197
+ self._capture_options.ports,
198
+ on_frame=tracker.observe,
199
+ on_traffic=self._announce_traffic,
200
+ retain_frames=False,
201
+ )
202
+ capture = LivePacketCapture(
203
+ capture_options=self._capture_options,
204
+ on_packet=self._enqueue_packet,
205
+ capture_buffer_bytes=self._capture_buffer_bytes,
206
+ require_capture_buffer=True,
207
+ )
208
+
209
+ self._tracker = tracker
210
+ self._collector = collector
211
+ self._capture = capture
212
+ self._packet_handler = make_packet_handler(collector)
213
+
214
+ capture.start()
215
+ if save_path is not None:
216
+ self._writer = self._open_writer(save_path)
217
+
218
+ endpoint = capture.endpoint
219
+ if endpoint is not None:
220
+ self._endpoint = SolareCaptureEndpoint(
221
+ interface=endpoint.interface,
222
+ local_ip=endpoint.local_ip,
223
+ bpf_filter=endpoint.bpf_filter,
224
+ )
225
+ endpoint_text = (
226
+ endpoint.interface if endpoint is not None else None
227
+ ) or "Scapy default"
228
+ endpoint_details = ""
229
+ if endpoint is not None:
230
+ endpoint_details = (
231
+ f", local IP {endpoint.local_ip or 'any'}, "
232
+ f"filter {endpoint.bpf_filter or 'Python lfilter'}"
233
+ )
234
+ recording = (
235
+ f", recording {save_path}" if save_path is not None else ""
236
+ )
237
+
238
+ worker = Thread(
239
+ target=self._run_worker_when_ready,
240
+ name="bdo-toolkit-solare",
241
+ daemon=True,
242
+ )
243
+ self._worker = worker
244
+ worker.start()
245
+ self._started = True
246
+ ready_update = SolareUpdate(
247
+ kind=SolareUpdateKind.CAPTURE_READY,
248
+ message=(
249
+ f"passive Solare capture is ready on {endpoint_text}"
250
+ f"{endpoint_details}{recording}; "
251
+ "open the Leaderboard tab for its first load after "
252
+ "restarting the game"
253
+ ),
254
+ )
255
+ self._queue_update(ready_update)
256
+ except BaseException as exc:
257
+ self._worker_ready.set()
258
+ self._record_error(exc)
259
+ if self._writer is not None:
260
+ try:
261
+ self._writer.close()
262
+ except BaseException as cleanup_error:
263
+ self._record_error(cleanup_error)
264
+ self._writer = None
265
+ if capture is not None and not capture.stopped:
266
+ try:
267
+ capture.stop()
268
+ except BaseException as cleanup_error:
269
+ self._record_error(cleanup_error)
270
+ if capture is not None and not capture.stopped:
271
+ # The native callback still owns this session through
272
+ # _enqueue_packet(). Reject new packets, but retain every
273
+ # capture/decoder owner so stop() can retry cleanup.
274
+ self._cleanup_incomplete = True
275
+ self._started = True
276
+ self._stop_requested.set()
277
+ with self._state_lock:
278
+ self._stop_reason = "error"
279
+ _attach_cleanup_owner(
280
+ exc,
281
+ self,
282
+ context="live Solare startup",
283
+ )
284
+ raise
285
+ while True:
286
+ try:
287
+ self._packet_queue.get_nowait()
288
+ except Empty:
289
+ break
290
+ self._tracker = None
291
+ self._collector = None
292
+ self._capture = None
293
+ self._packet_handler = None
294
+ self._worker = None
295
+ with self._state_lock:
296
+ self._stop_reason = "error"
297
+ self._stopped.set()
298
+ raise
299
+
300
+ assert ready_update is not None
301
+ try:
302
+ self._notify_update(ready_update)
303
+ finally:
304
+ self._worker_ready.set()
305
+
306
+ def _run_worker_when_ready(self) -> None:
307
+ self._worker_ready.wait()
308
+ self._run_worker()
309
+
310
+ def stop(self) -> SolareCaptureResult:
311
+ """Stop capture, drain every queued packet, and return the best result."""
312
+
313
+ self._require_started()
314
+ if not self._stopped.is_set():
315
+ if self._inside_update_callback():
316
+ raise RuntimeError(
317
+ "stop() cannot block inside on_update; use "
318
+ "request_stop() instead"
319
+ )
320
+ self.request_stop()
321
+ self._worker_ready.set()
322
+ worker = self._worker
323
+ if (
324
+ worker is not None
325
+ and worker is not current_thread()
326
+ and worker.is_alive()
327
+ ):
328
+ worker.join(timeout=self._WORKER_STOP_TIMEOUT_SECONDS)
329
+ if worker.is_alive():
330
+ cleanup_error = RuntimeError(
331
+ "live Solare worker cleanup is incomplete after the "
332
+ f"{self._WORKER_STOP_TIMEOUT_SECONDS:g}-second deadline"
333
+ )
334
+ self._record_error(cleanup_error)
335
+ self._cleanup_incomplete = True
336
+ capture = self._capture
337
+ if capture is not None and not capture.stopped:
338
+ try:
339
+ capture.stop()
340
+ except BaseException as exc:
341
+ self._record_error(exc)
342
+ # The worker may still own tracker/collector callbacks.
343
+ # Never finalize or release those dependencies in parallel.
344
+ raise cleanup_error
345
+ if not self._stopped.is_set():
346
+ self._finish_worker(self.stop_reason or "requested")
347
+ self.raise_if_failed()
348
+ result = self.result
349
+ if result is None:
350
+ raise RuntimeError("live Solare session stopped without a result")
351
+ return result
352
+
353
+ def request_stop(self) -> None:
354
+ """Request orderly shutdown without waiting for finalization.
355
+
356
+ This is the callback-safe control method. The terminal result remains
357
+ available through :meth:`wait`, :meth:`stop`, or the ``finished``
358
+ update after the callback returns.
359
+ """
360
+
361
+ self._require_started()
362
+ if self._stopped.is_set() or self.result is not None:
363
+ return
364
+ self._set_requested_reason("requested")
365
+ self._stop_requested.set()
366
+
367
+ def wait(self, timeout: Optional[float] = None) -> Optional[SolareCaptureResult]:
368
+ """Wait for automatic/manual completion, returning ``None`` on timeout."""
369
+
370
+ self._require_started()
371
+ _validate_timeout(timeout, name="timeout")
372
+ if self._inside_update_callback() and not self._stopped.is_set():
373
+ result = self.result
374
+ if result is not None:
375
+ self.raise_if_failed()
376
+ return result
377
+ raise RuntimeError(
378
+ "wait() cannot block inside on_update; use request_stop() "
379
+ "and wait after the callback returns"
380
+ )
381
+ deadline = None if timeout is None else time.monotonic() + timeout
382
+ while not self._stopped.is_set():
383
+ if self._cleanup_incomplete:
384
+ self.raise_if_failed()
385
+ if deadline is None:
386
+ wait_seconds = _POLL_INTERVAL_SECONDS
387
+ else:
388
+ remaining = deadline - time.monotonic()
389
+ if remaining <= 0:
390
+ return None
391
+ wait_seconds = min(_POLL_INTERVAL_SECONDS, remaining)
392
+ if self._stopped.wait(timeout=wait_seconds):
393
+ break
394
+ self.raise_if_failed()
395
+ return self.result
396
+
397
+ def poll(self, timeout: Optional[float] = None) -> Optional[SolareUpdate]:
398
+ """Return one progress update, or ``None`` on timeout/end-of-stream."""
399
+
400
+ self._require_started()
401
+ _validate_timeout(timeout, name="timeout")
402
+ if self._inside_update_callback():
403
+ raise RuntimeError(
404
+ "poll() cannot consume updates inside on_update; consume "
405
+ "them outside the callback"
406
+ )
407
+ deadline = None if timeout is None else time.monotonic() + timeout
408
+ while True:
409
+ try:
410
+ return self._update_queue.get_nowait()
411
+ except Empty:
412
+ pass
413
+ if self._stopped.is_set():
414
+ self.raise_if_failed()
415
+ return None
416
+ if self._cleanup_incomplete:
417
+ self.raise_if_failed()
418
+ if deadline is None:
419
+ wait_seconds = _POLL_INTERVAL_SECONDS
420
+ else:
421
+ remaining = deadline - time.monotonic()
422
+ if remaining <= 0:
423
+ return None
424
+ wait_seconds = min(_POLL_INTERVAL_SECONDS, remaining)
425
+ try:
426
+ return self._update_queue.get(timeout=wait_seconds)
427
+ except Empty:
428
+ continue
429
+
430
+ def updates(self) -> Iterator[SolareUpdate]:
431
+ """Yield structured progress until the final update is drained."""
432
+
433
+ while True:
434
+ update = self.poll()
435
+ if update is None:
436
+ return
437
+ yield update
438
+
439
+ def raise_if_failed(self) -> None:
440
+ error = self.error
441
+ if error is not None:
442
+ raise error
443
+
444
+ def _run_worker(self) -> None:
445
+ reason = "capture-ended"
446
+ try:
447
+ while not self._stop_requested.is_set():
448
+ capture = self._capture
449
+ if capture is None:
450
+ raise RuntimeError("live capture owner disappeared")
451
+ capture_error = capture.error
452
+ if capture_error is not None:
453
+ raise capture_error
454
+
455
+ try:
456
+ packet = self._packet_queue.get(timeout=_POLL_INTERVAL_SECONDS)
457
+ except Empty:
458
+ if not capture.running:
459
+ reason = "capture-ended"
460
+ break
461
+ self._service_drained_clocks()
462
+ tracker = self._tracker
463
+ if (
464
+ self._stop_on_complete
465
+ and tracker is not None
466
+ and tracker.complete
467
+ ):
468
+ reason = "complete-snapshot"
469
+ break
470
+ continue
471
+
472
+ self._process_packet(packet)
473
+ self._service_drained_clocks()
474
+ tracker = self._tracker
475
+ if (
476
+ self._stop_on_complete
477
+ and tracker is not None
478
+ and tracker.complete
479
+ ):
480
+ reason = "complete-snapshot"
481
+ break
482
+
483
+ requested_reason = self.stop_reason
484
+ if requested_reason is not None:
485
+ reason = requested_reason
486
+ except BaseException as exc:
487
+ self._record_error(exc)
488
+ reason = "error"
489
+ finally:
490
+ self._finish_worker(reason)
491
+
492
+ def _finish_worker(self, reason: str) -> None:
493
+ with self._finish_lock:
494
+ if self._stopped.is_set():
495
+ return
496
+ self._stop_requested.set()
497
+ capture = self._capture
498
+ collector = self._collector
499
+ tracker = self._tracker
500
+ stats = None
501
+ result: Optional[SolareCaptureResult] = None
502
+ stop_failure: Optional[BaseException] = None
503
+ if capture is not None:
504
+ try:
505
+ stats = capture.stop()
506
+ except BaseException as exc:
507
+ stop_failure = exc
508
+ self._record_error(exc)
509
+ try:
510
+ if capture.error is not None:
511
+ self._record_error(capture.error)
512
+ except BaseException as exc:
513
+ self._record_error(exc)
514
+ if not capture.stopped:
515
+ cleanup_error = (
516
+ getattr(capture, "cleanup_error", None)
517
+ or stop_failure
518
+ or RuntimeError(
519
+ "live Solare capture cleanup is incomplete"
520
+ )
521
+ )
522
+ self._record_error(cleanup_error)
523
+ self._cleanup_incomplete = True
524
+ return
525
+ try:
526
+ while True:
527
+ try:
528
+ packet = self._packet_queue.get_nowait()
529
+ except Empty:
530
+ break
531
+ if self._decoder_failed:
532
+ self._write_packet(packet)
533
+ continue
534
+ try:
535
+ self._process_packet(packet)
536
+ except BaseException as exc:
537
+ self._record_error(exc)
538
+ self._decoder_failed = True
539
+
540
+ if collector is None or tracker is None:
541
+ self._record_error(
542
+ RuntimeError("live Solare decoder was not initialized")
543
+ )
544
+ self._decoder_failed = True
545
+ else:
546
+ try:
547
+ collector.finish()
548
+ except BaseException as exc:
549
+ self._record_error(exc)
550
+ self._decoder_failed = True
551
+ if not self._decoder_failed:
552
+ try:
553
+ tracker.refresh()
554
+ except BaseException as exc:
555
+ self._record_error(exc)
556
+ self._decoder_failed = True
557
+
558
+ self._close_writer()
559
+
560
+ health: Optional[SolareCaptureHealth] = None
561
+ if collector is not None:
562
+ try:
563
+ health = self._collector_health(collector, stats)
564
+ except BaseException as exc:
565
+ self._record_error(exc)
566
+
567
+ if (
568
+ collector is not None
569
+ and tracker is not None
570
+ and health is not None
571
+ and not self._decoder_failed
572
+ ):
573
+ try:
574
+ confirmed_frames = getattr(
575
+ tracker,
576
+ "confirmed_frames",
577
+ None,
578
+ )
579
+ retained_frames = getattr(
580
+ tracker,
581
+ "retained_frames",
582
+ (),
583
+ )
584
+ result_frames = (
585
+ confirmed_frames
586
+ or retained_frames
587
+ or collector.frames
588
+ )
589
+ discovery = getattr(tracker, "result", None)
590
+ if discovery is None:
591
+ result = build_solare_result(
592
+ result_frames,
593
+ self._confirmed_health or health,
594
+ retain_raw_extensions=self._retain_raw_extensions,
595
+ )
596
+ else:
597
+ result = build_solare_result(
598
+ result_frames,
599
+ self._confirmed_health or health,
600
+ retain_raw_extensions=self._retain_raw_extensions,
601
+ _discovery=discovery,
602
+ )
603
+ resource_loss = _resource_loss_summary(health)
604
+ if (
605
+ result.complete
606
+ and self._confirmed_health is not None
607
+ and self._confirmed_health.capture_is_clean
608
+ and resource_loss is not None
609
+ ):
610
+ base_message = result.message or (
611
+ "structurally confirmed Solare leaderboard"
612
+ )
613
+ if (
614
+ reason == "resource-limit"
615
+ or self.stop_reason == "resource-limit"
616
+ ):
617
+ continuation = (
618
+ "the already-confirmed snapshot remains "
619
+ "valid, but continued capture ended due to "
620
+ f"resource pressure: {resource_loss}"
621
+ )
622
+ else:
623
+ continuation = (
624
+ "the already-confirmed snapshot remains "
625
+ "valid, but continued capture later lost "
626
+ f"integrity because {resource_loss}"
627
+ )
628
+ result = replace(
629
+ result,
630
+ message=f"{base_message}; {continuation}",
631
+ )
632
+ except BaseException as exc:
633
+ self._record_error(exc)
634
+
635
+ with self._state_lock:
636
+ self._result = result
637
+
638
+ if result is not None:
639
+ resource_loss = (
640
+ _resource_loss_summary(health)
641
+ if health is not None
642
+ else None
643
+ )
644
+ if resource_loss is not None:
645
+ self._emit(
646
+ SolareUpdate(
647
+ kind=SolareUpdateKind.WARNING,
648
+ message=(
649
+ "live capture integrity was lost because "
650
+ f"{resource_loss}; retry after reducing "
651
+ "capture load"
652
+ ),
653
+ ranked_players=result.evidence.ranked_players,
654
+ overall_players=result.evidence.overall_players,
655
+ exact_cross_check=(
656
+ result.evidence.exact_cross_check
657
+ ),
658
+ )
659
+ )
660
+ self._emit(
661
+ SolareUpdate(
662
+ kind=SolareUpdateKind.FINISHED,
663
+ message=(
664
+ f"Solare capture finished with status "
665
+ f"{result.status.value}"
666
+ ),
667
+ ranked_players=result.evidence.ranked_players,
668
+ overall_players=result.evidence.overall_players,
669
+ exact_cross_check=result.evidence.exact_cross_check,
670
+ result=result,
671
+ )
672
+ )
673
+ except BaseException as exc:
674
+ self._record_error(exc)
675
+ finally:
676
+ self._close_writer()
677
+ with self._state_lock:
678
+ if self._error is not None:
679
+ self._stop_reason = "error"
680
+ elif self._stop_reason is None:
681
+ self._stop_reason = reason
682
+ self._collector = None
683
+ self._tracker = None
684
+ self._packet_handler = None
685
+ self._capture = None
686
+ self._confirmed_health = None
687
+ self._worker = None
688
+ self._cleanup_incomplete = False
689
+ self._stopped.set()
690
+
691
+ def _process_packet(self, packet: object) -> None:
692
+ # Record the exact packet accepted by the capture callback before any
693
+ # semantic work can fail. This makes an opt-in PCAP useful for patch
694
+ # drift and decoder failures, while all writing still stays off the
695
+ # native capture thread.
696
+ self._write_packet(packet)
697
+ handler = self._packet_handler
698
+ if handler is None:
699
+ raise RuntimeError("live Solare packet handler was not initialized")
700
+ try:
701
+ handler(packet)
702
+ except BaseException:
703
+ self._decoder_failed = True
704
+ raise
705
+ self._announce_tcp_gap_loss()
706
+
707
+ tracker = self._tracker
708
+ if tracker is not None and tracker.complete:
709
+ self._latch_confirmation()
710
+
711
+ def _service_drained_clocks(self) -> None:
712
+ """Advance live-only clocks only after queued packet work is caught up.
713
+
714
+ Wall-clock TCP-gap recovery or candidate-tail closure while packets
715
+ remain queued could skip a delayed prefix or a later same-family
716
+ frame that Npcap already delivered. Waiting for an empty decode queue
717
+ preserves that evidence. The second check protects candidate
718
+ finalization from packets enqueued while gap service was running.
719
+ """
720
+
721
+ if not self._packet_queue.empty():
722
+ return
723
+
724
+ collector = self._collector
725
+ if collector is not None:
726
+ collector.service_gaps(time.time())
727
+ self._announce_tcp_gap_loss()
728
+
729
+ if not self._packet_queue.empty():
730
+ return
731
+
732
+ tracker = self._tracker
733
+ if tracker is not None:
734
+ tracker.service_candidate_idle(time.monotonic())
735
+ self._latch_confirmation()
736
+
737
+ def _write_packet(self, packet: object) -> None:
738
+ if self._writer is not None:
739
+ try:
740
+ self._writer.write(packet)
741
+ self._saved_packets += 1
742
+ if self._saved_packets % 128 == 0:
743
+ self._writer.flush()
744
+ except BaseException as exc:
745
+ self._record_error(exc)
746
+ self._set_requested_reason("error")
747
+ self._stop_requested.set()
748
+ self._close_writer()
749
+
750
+ def _latch_confirmation(self) -> None:
751
+ tracker = self._tracker
752
+ collector = self._collector
753
+ if tracker is None or not tracker.complete or collector is None:
754
+ return
755
+ if self._confirmed_health is None:
756
+ capture = self._capture
757
+ stats = capture.snapshot_stats() if capture is not None else None
758
+ self._confirmed_health = self._collector_health(collector, stats)
759
+ collector.stop_retaining()
760
+
761
+ def _collector_health(
762
+ self,
763
+ collector: SolareFrameCollector,
764
+ stats: Optional[CaptureStats],
765
+ ) -> SolareCaptureHealth:
766
+ tracker = self._tracker
767
+ return collector.health(
768
+ saved_packets=self._saved_packets,
769
+ pcap_received=stats.received if stats is not None else None,
770
+ pcap_dropped=stats.dropped if stats is not None else None,
771
+ pcap_interface_dropped=(
772
+ stats.interface_dropped if stats is not None else None
773
+ ),
774
+ capture_buffer_bytes=(
775
+ stats.capture_buffer_bytes if stats is not None else None
776
+ ),
777
+ retained_large_messages=getattr(
778
+ tracker,
779
+ "observed_frame_count",
780
+ len(collector.frames),
781
+ ),
782
+ candidate_messages_observed=getattr(
783
+ tracker,
784
+ "observed_frame_count",
785
+ 0,
786
+ ),
787
+ candidate_frames_retained=getattr(
788
+ tracker,
789
+ "retained_frame_count",
790
+ 0,
791
+ ),
792
+ candidate_bytes_retained=getattr(
793
+ tracker,
794
+ "retained_bytes",
795
+ 0,
796
+ ),
797
+ peak_candidate_frames=getattr(
798
+ tracker,
799
+ "peak_retained_frame_count",
800
+ 0,
801
+ ),
802
+ peak_candidate_bytes=getattr(
803
+ tracker,
804
+ "peak_retained_bytes",
805
+ 0,
806
+ ),
807
+ candidate_frames_evicted=getattr(
808
+ tracker,
809
+ "evicted_frame_count",
810
+ 0,
811
+ ),
812
+ candidate_bytes_evicted=getattr(
813
+ tracker,
814
+ "evicted_bytes",
815
+ 0,
816
+ ),
817
+ candidate_history_rolled_over=getattr(
818
+ tracker,
819
+ "history_rolled_over",
820
+ False,
821
+ ),
822
+ packet_queue_peak=self._packet_queue_peak,
823
+ packet_queue_overflows=self._packet_queue_overflows,
824
+ )
825
+
826
+ def _announce_traffic(self) -> None:
827
+ if self._traffic_announced:
828
+ return
829
+ self._traffic_announced = True
830
+ self._emit(
831
+ SolareUpdate(
832
+ kind=SolareUpdateKind.TRAFFIC,
833
+ message="inbound game-server traffic is flowing",
834
+ )
835
+ )
836
+
837
+ def _announce_tcp_gap_loss(self) -> None:
838
+ """Warn once when this pre-confirmation attempt becomes ineligible."""
839
+
840
+ collector = self._collector
841
+ if (
842
+ self._tcp_gap_warning_announced
843
+ or self._confirmed_health is not None
844
+ or collector is None
845
+ or collector.tcp_gap_resets == 0
846
+ ):
847
+ return
848
+ self._tcp_gap_warning_announced = True
849
+ count = collector.tcp_gap_resets
850
+ self._emit(
851
+ SolareUpdate(
852
+ kind=SolareUpdateKind.WARNING,
853
+ message=(
854
+ "TCP reassembly reset "
855
+ f"{count} time{'s' if count != 1 else ''} after an "
856
+ "unresolved sequence gap; this capture attempt will "
857
+ "remain fail-closed. Stop and retry with a fresh capture"
858
+ ),
859
+ )
860
+ )
861
+
862
+ def _enqueue_packet(self, packet: object) -> None:
863
+ if self._stop_requested.is_set():
864
+ return
865
+ try:
866
+ self._packet_queue.put_nowait(packet)
867
+ except Full:
868
+ with self._state_lock:
869
+ self._packet_queue_overflows += 1
870
+ if self._stop_reason is None:
871
+ self._stop_reason = "resource-limit"
872
+ self._stop_requested.set()
873
+ return
874
+ depth = self._packet_queue.qsize()
875
+ with self._state_lock:
876
+ self._packet_queue_peak = max(self._packet_queue_peak, depth)
877
+
878
+ def _emit(self, update: SolareUpdate) -> None:
879
+ # Structural confirmation is atomic before user code observes it.
880
+ # A slow callback may allow later packets or resource loss to arrive,
881
+ # but those events cannot retroactively invalidate the proven window.
882
+ if update.kind is SolareUpdateKind.SNAPSHOT_CONFIRMED:
883
+ self._latch_confirmation()
884
+ self._queue_update(update)
885
+ self._notify_update(update)
886
+
887
+ def _queue_update(self, update: SolareUpdate) -> None:
888
+ while True:
889
+ try:
890
+ self._update_queue.put_nowait(update)
891
+ break
892
+ except Full:
893
+ try:
894
+ self._update_queue.get_nowait()
895
+ except Empty:
896
+ continue
897
+
898
+ def _notify_update(self, update: SolareUpdate) -> None:
899
+ if self._on_update is not None:
900
+ active_sessions = _ACTIVE_UPDATE_SESSIONS.get()
901
+ token = _ACTIVE_UPDATE_SESSIONS.set(active_sessions + (self,))
902
+ try:
903
+ self._on_update(update)
904
+ except BaseException as exc:
905
+ self._record_error(exc)
906
+ self._set_requested_reason("error")
907
+ self._stop_requested.set()
908
+ finally:
909
+ _ACTIVE_UPDATE_SESSIONS.reset(token)
910
+
911
+ def _inside_update_callback(self) -> bool:
912
+ return self in _ACTIVE_UPDATE_SESSIONS.get()
913
+
914
+ def _close_writer(self) -> None:
915
+ writer = self._writer
916
+ if writer is None:
917
+ return
918
+ self._writer = None
919
+ try:
920
+ writer.close()
921
+ except BaseException as exc:
922
+ self._record_error(exc)
923
+
924
+ def _record_error(self, error: BaseException) -> None:
925
+ with self._state_lock:
926
+ if self._error is None:
927
+ self._error = error
928
+
929
+ def _set_requested_reason(self, reason: str) -> None:
930
+ with self._state_lock:
931
+ if self._stop_reason is None:
932
+ self._stop_reason = reason
933
+
934
+ def _prepare_save_path(self) -> Optional[Path]:
935
+ if self._save_pcap is None:
936
+ return None
937
+ path = self._save_pcap.expanduser().resolve()
938
+ if path.suffix.lower() not in {".pcap", ".pcapng"}:
939
+ raise ValueError("save_pcap must end in .pcap or .pcapng")
940
+ if path.exists():
941
+ raise FileExistsError(f"refusing to overwrite existing capture: {path}")
942
+ path.parent.mkdir(parents=True, exist_ok=True)
943
+ return path
944
+
945
+ @staticmethod
946
+ def _open_writer(path: Path) -> Any:
947
+ from scapy.utils import PcapNgWriter, PcapWriter # type: ignore
948
+
949
+ if path.suffix.lower() == ".pcapng":
950
+ return PcapNgWriter(str(path))
951
+ return PcapWriter(str(path), sync=True)
952
+
953
+ def _require_started(self) -> None:
954
+ if not self._started:
955
+ raise RuntimeError("live Solare session was not started")
956
+
957
+ def __enter__(self) -> "LiveSolareSession":
958
+ self.start()
959
+ return self
960
+
961
+ def __exit__(
962
+ self,
963
+ exc_type: type[BaseException] | None,
964
+ exc_value: BaseException | None,
965
+ traceback: TracebackType | None,
966
+ ) -> None:
967
+ if not self._started:
968
+ return
969
+ if exc_value is None:
970
+ self.stop()
971
+ return
972
+ try:
973
+ self.stop()
974
+ except BaseException as cleanup_error:
975
+ # The exception already leaving the user's block remains primary;
976
+ # the first session failure is still available through ``error``.
977
+ if self.cleanup_incomplete:
978
+ _attach_cleanup_owner(
979
+ exc_value,
980
+ self,
981
+ context="live Solare context",
982
+ )
983
+ if hasattr(exc_value, "add_note"):
984
+ exc_value.add_note(
985
+ "live Solare context cleanup also failed: "
986
+ f"{cleanup_error!r}"
987
+ )
988
+
989
+
990
+ def capture_solare_snapshot(
991
+ *,
992
+ capture_options: Optional[PacketCaptureOptions] = None,
993
+ capture_seconds: Optional[float] = SOLARE_DEFAULT_CAPTURE_SECONDS,
994
+ save_pcap: str | Path | None = None,
995
+ stop_on_complete: bool = True,
996
+ retain_raw_extensions: bool = False,
997
+ on_update: Optional[Callable[[SolareUpdate], None]] = None,
998
+ capture_buffer_bytes: int = LIVE_CAPTURE_BUFFER_BYTES,
999
+ ) -> SolareCaptureResult:
1000
+ """Blocking convenience wrapper around :class:`LiveSolareSession`."""
1001
+
1002
+ _validate_timeout(capture_seconds, name="capture_seconds")
1003
+ session = LiveSolareSession(
1004
+ capture_options=capture_options,
1005
+ save_pcap=save_pcap,
1006
+ stop_on_complete=stop_on_complete,
1007
+ retain_raw_extensions=retain_raw_extensions,
1008
+ on_update=on_update,
1009
+ capture_buffer_bytes=capture_buffer_bytes,
1010
+ )
1011
+ session.start()
1012
+ started_at = time.monotonic()
1013
+ try:
1014
+ while True:
1015
+ timeout = _POLL_INTERVAL_SECONDS
1016
+ if capture_seconds is not None:
1017
+ remaining = capture_seconds - (time.monotonic() - started_at)
1018
+ if remaining <= 0:
1019
+ return session.stop()
1020
+ timeout = min(timeout, remaining)
1021
+ result = session.wait(timeout=timeout)
1022
+ if result is not None:
1023
+ return result
1024
+ except KeyboardInterrupt:
1025
+ try:
1026
+ return session.stop()
1027
+ except BaseException as exc:
1028
+ if session.cleanup_incomplete:
1029
+ _attach_cleanup_owner(
1030
+ exc,
1031
+ session,
1032
+ context="live Solare convenience wrapper",
1033
+ )
1034
+ raise
1035
+ except BaseException as exc:
1036
+ if session.running:
1037
+ try:
1038
+ session.stop()
1039
+ except BaseException as cleanup_error:
1040
+ if session.cleanup_incomplete:
1041
+ _attach_cleanup_owner(
1042
+ exc,
1043
+ session,
1044
+ context="live Solare convenience wrapper",
1045
+ )
1046
+ if cleanup_error is not exc and hasattr(exc, "add_note"):
1047
+ exc.add_note(
1048
+ "live Solare convenience cleanup also failed: "
1049
+ f"{cleanup_error!r}"
1050
+ )
1051
+ raise