bdo-toolkit 1.0.5__tar.gz → 1.0.7__tar.gz
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-1.0.5/src/bdo_toolkit.egg-info → bdo_toolkit-1.0.7}/PKG-INFO +11 -9
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/README.md +10 -8
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/__init__.py +4 -1
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/_records.py +36 -1
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_engine.py +18 -2
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/inventory.py +4 -4
- bdo_toolkit-1.0.7/src/bdo_toolkit/_observation_session.py +376 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_profile_runtime.py +10 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_reassembly.py +18 -11
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_version.py +1 -1
- bdo_toolkit-1.0.7/src/bdo_toolkit/agris/__init__.py +17 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/agris/_calibration_models.py +70 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/agris/_capture.py +87 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/agris/_discovery.py +315 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/agris/_profile.py +17 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/agris/_validation.py +13 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/agris/async_session.py +307 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/agris/calibration.py +116 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/agris/models.py +134 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/agris/replay.py +117 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/agris/session.py +75 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/capture.py +21 -2
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/cli.py +189 -1
- bdo_toolkit-1.0.7/src/bdo_toolkit/grind/__init__.py +5 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/grind/_pipeline.py +127 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/grind/models.py +71 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/grind/session.py +54 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/profiles.py +120 -1
- bdo_toolkit-1.0.7/src/bdo_toolkit/xp/__init__.py +13 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/xp/_tracker.py +109 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/xp/models.py +77 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/xp/profile.py +54 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/xp/replay.py +104 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/xp/session.py +56 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7/src/bdo_toolkit.egg-info}/PKG-INFO +11 -9
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/SOURCES.txt +34 -1
- bdo_toolkit-1.0.7/tests/test_agris_async.py +473 -0
- bdo_toolkit-1.0.7/tests/test_agris_cli.py +124 -0
- bdo_toolkit-1.0.7/tests/test_agris_discovery.py +382 -0
- bdo_toolkit-1.0.7/tests/test_agris_evidence.py +67 -0
- bdo_toolkit-1.0.7/tests/test_agris_models.py +98 -0
- bdo_toolkit-1.0.7/tests/test_agris_profiles.py +340 -0
- bdo_toolkit-1.0.7/tests/test_agris_replay.py +81 -0
- bdo_toolkit-1.0.7/tests/test_agris_session.py +305 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_calibration_progress.py +6 -1
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_character_state.py +95 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_direction.py +91 -1
- bdo_toolkit-1.0.7/tests/test_grind.py +534 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_reassembly.py +70 -0
- bdo_toolkit-1.0.7/tests/test_xp.py +229 -0
- bdo_toolkit-1.0.7/tests/test_xp_profiles.py +72 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/LICENSE +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/pyproject.toml +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/setup.cfg +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_async_sessions.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_async_utils.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/__init__.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/_constants.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/_formatting.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/analysis.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/capture.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/companions.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/live.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/models.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/observations.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/persistence.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/progress.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/validation.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/workflow.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_capture_backend.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_capture_options.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_capture_runtime.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_deposit_origin.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_framing.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/__init__.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/_constants.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/_records.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/assembly.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/formatting.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/models.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/session.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/storage.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/__init__.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/discovery.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/manual.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/models.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/tracker.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_profile_io.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_protocol.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_record_geometry.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_specs.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_storage_destination_validation.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_storage_hydration.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/calibration.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/capture_diagnosis.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/character_state.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/diagnostics.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/events.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/filters.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/item_state.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/origin_learning.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/py.typed +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/remote_profiles.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/__init__.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_constants.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_detail_learning.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_details.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_discovery.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_live_tracker.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_replay_capture.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_result.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_scanner.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_validation.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/async_session.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/models.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/replay.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/session.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/writers.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/dependency_links.txt +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/entry_points.txt +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/requires.txt +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/top_level.txt +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_async_sessions.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_calibration.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_calibration_conveniences.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_calibration_lifecycle.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_calibration_persistence.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_calibration_retention.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_capture_backend.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_capture_diagnosis.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_capture_replay.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_capture_runtime.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_cli.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_decoder_diagnostics.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_deposit_origin.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_dynamic_storage_calibration_matrix.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_engine.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_examples_and_writers.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_explicit_profile.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_fixture_catalog.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_fixture_regressions.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_framing.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_initial_load_storage.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_inventory_snapshots.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_item_state.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_live_capture_session.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_origin_learning.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_package_layout.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_profile_validation.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_remote_profiles.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_solare_api.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_solare_details.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_solare_learning.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_solare_learning_adversarial.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_solare_real_captures.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_solare_replay.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_solare_session.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_storage_destination_validation.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_storage_events.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_storage_hydration_tracker.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_storage_unknown_destination_calibration.py +0 -0
- {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_toolkit_api.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bdo-toolkit
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.7
|
|
4
4
|
Summary: Passive, read-only BDO packet parsing toolkit for app developers.
|
|
5
5
|
Author: bdo-toolkit contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -37,10 +37,10 @@ Dynamic: license-file
|
|
|
37
37
|
Passive, read-only Python tooling that turns live or recorded Black Desert
|
|
38
38
|
traffic into structured, application-ready data.
|
|
39
39
|
|
|
40
|
-
[Documentation](https://ychwu.github.io/bdo-toolkit/)
|
|
41
|
-
[Quickstart](https://ychwu.github.io/bdo-toolkit/#quickstart)
|
|
42
|
-
[Examples](https://ychwu.github.io/bdo-toolkit/#item-examples)
|
|
43
|
-
[API index](https://ychwu.github.io/bdo-toolkit/#api-index)
|
|
40
|
+
[Documentation](https://ychwu.github.io/bdo-toolkit/) ·
|
|
41
|
+
[Quickstart](https://ychwu.github.io/bdo-toolkit/#quickstart) ·
|
|
42
|
+
[Examples](https://ychwu.github.io/bdo-toolkit/#item-examples) ·
|
|
43
|
+
[API index](https://ychwu.github.io/bdo-toolkit/#api-index) ·
|
|
44
44
|
[Report an issue](https://github.com/ychwu/bdo-toolkit/issues)
|
|
45
45
|
|
|
46
46
|
> **Passive, read-only boundary.** bdo-toolkit observes local traffic or saved
|
|
@@ -60,16 +60,18 @@ no usable adapter is found. Discovery does not apply settings or verify decoding
|
|
|
60
60
|
|
|
61
61
|
## Capabilities
|
|
62
62
|
|
|
63
|
-
bdo-toolkit exposes
|
|
64
|
-
|
|
63
|
+
bdo-toolkit exposes four passive workflows. Item activity, item state, and
|
|
64
|
+
Solare support live capture and saved PCAP or PCAPNG replay. Grind tracking
|
|
65
|
+
combines supported observations in one live session.
|
|
65
66
|
|
|
66
67
|
| Capability | What it provides | Guide | Status |
|
|
67
68
|
| --- | --- | --- | --- |
|
|
68
69
|
| Item activity | A continuing stream of typed `BDOEvent` objects for supported loot, gathering, inventory, and storage changes | [Item events](https://ychwu.github.io/bdo-toolkit/#item-overview) | Stable |
|
|
69
70
|
| Inventory and town storage | A finite `ItemStateSnapshot` assembled from character-load traffic, with inventory, known balances, and observed town storage | [Inventory & town storage](https://ychwu.github.io/bdo-toolkit/#item-state-overview) | Beta |
|
|
70
71
|
| Arena of Solare leaderboards | A finite `SolareCaptureResult` containing overall rankings, class tables, and player statistics when the capture is complete | [Arena of Solare](https://ychwu.github.io/bdo-toolkit/#solare-overview) | Beta |
|
|
72
|
+
| Grind tracking | One capture for confirmed mob drops, with optional profile-based Agris and solo XP | [GrindSession](https://ychwu.github.io/bdo-toolkit/#grind-session) | Experimental |
|
|
71
73
|
|
|
72
|
-
|
|
74
|
+
The standalone workflows include synchronous and
|
|
73
75
|
[asyncio](https://ychwu.github.io/bdo-toolkit/#asyncio) sessions, capture and
|
|
74
76
|
decoder health diagnostics, console and JSONL event writers, and a
|
|
75
77
|
[command-line interface](https://ychwu.github.io/bdo-toolkit/#cli). Exact
|
|
@@ -115,7 +117,7 @@ installed with the Python wheel.
|
|
|
115
117
|
| Observe live item activity | [`examples/live_transfer_log.py`](https://github.com/ychwu/bdo-toolkit/blob/main/examples/live_transfer_log.py) |
|
|
116
118
|
| Capture inventory and town storage on character load | [`examples/live_character_load_snapshot.py`](https://github.com/ychwu/bdo-toolkit/blob/main/examples/live_character_load_snapshot.py) |
|
|
117
119
|
| Rebuild an item profile after a patch | [`examples/live_calibrate_profile.py`](https://github.com/ychwu/bdo-toolkit/blob/main/examples/live_calibrate_profile.py) |
|
|
118
|
-
| Observe calibration progress and stop on completion | [`examples/live_calibration_progress.py`](https://github.com/ychwu/bdo-toolkit/blob/main/examples/live_calibration_progress.py)
|
|
120
|
+
| Observe calibration progress and stop on completion | [`examples/live_calibration_progress.py`](https://github.com/ychwu/bdo-toolkit/blob/main/examples/live_calibration_progress.py) · [async](https://github.com/ychwu/bdo-toolkit/blob/main/examples/async_calibration_progress.py) |
|
|
119
121
|
| Capture an Arena of Solare leaderboard load | [`examples/solare_live_snapshot.py`](https://github.com/ychwu/bdo-toolkit/blob/main/examples/solare_live_snapshot.py) |
|
|
120
122
|
|
|
121
123
|
See the [Examples index](https://ychwu.github.io/bdo-toolkit/#item-examples)
|
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
Passive, read-only Python tooling that turns live or recorded Black Desert
|
|
11
11
|
traffic into structured, application-ready data.
|
|
12
12
|
|
|
13
|
-
[Documentation](https://ychwu.github.io/bdo-toolkit/)
|
|
14
|
-
[Quickstart](https://ychwu.github.io/bdo-toolkit/#quickstart)
|
|
15
|
-
[Examples](https://ychwu.github.io/bdo-toolkit/#item-examples)
|
|
16
|
-
[API index](https://ychwu.github.io/bdo-toolkit/#api-index)
|
|
13
|
+
[Documentation](https://ychwu.github.io/bdo-toolkit/) ·
|
|
14
|
+
[Quickstart](https://ychwu.github.io/bdo-toolkit/#quickstart) ·
|
|
15
|
+
[Examples](https://ychwu.github.io/bdo-toolkit/#item-examples) ·
|
|
16
|
+
[API index](https://ychwu.github.io/bdo-toolkit/#api-index) ·
|
|
17
17
|
[Report an issue](https://github.com/ychwu/bdo-toolkit/issues)
|
|
18
18
|
|
|
19
19
|
> **Passive, read-only boundary.** bdo-toolkit observes local traffic or saved
|
|
@@ -33,16 +33,18 @@ no usable adapter is found. Discovery does not apply settings or verify decoding
|
|
|
33
33
|
|
|
34
34
|
## Capabilities
|
|
35
35
|
|
|
36
|
-
bdo-toolkit exposes
|
|
37
|
-
|
|
36
|
+
bdo-toolkit exposes four passive workflows. Item activity, item state, and
|
|
37
|
+
Solare support live capture and saved PCAP or PCAPNG replay. Grind tracking
|
|
38
|
+
combines supported observations in one live session.
|
|
38
39
|
|
|
39
40
|
| Capability | What it provides | Guide | Status |
|
|
40
41
|
| --- | --- | --- | --- |
|
|
41
42
|
| Item activity | A continuing stream of typed `BDOEvent` objects for supported loot, gathering, inventory, and storage changes | [Item events](https://ychwu.github.io/bdo-toolkit/#item-overview) | Stable |
|
|
42
43
|
| Inventory and town storage | A finite `ItemStateSnapshot` assembled from character-load traffic, with inventory, known balances, and observed town storage | [Inventory & town storage](https://ychwu.github.io/bdo-toolkit/#item-state-overview) | Beta |
|
|
43
44
|
| Arena of Solare leaderboards | A finite `SolareCaptureResult` containing overall rankings, class tables, and player statistics when the capture is complete | [Arena of Solare](https://ychwu.github.io/bdo-toolkit/#solare-overview) | Beta |
|
|
45
|
+
| Grind tracking | One capture for confirmed mob drops, with optional profile-based Agris and solo XP | [GrindSession](https://ychwu.github.io/bdo-toolkit/#grind-session) | Experimental |
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
The standalone workflows include synchronous and
|
|
46
48
|
[asyncio](https://ychwu.github.io/bdo-toolkit/#asyncio) sessions, capture and
|
|
47
49
|
decoder health diagnostics, console and JSONL event writers, and a
|
|
48
50
|
[command-line interface](https://ychwu.github.io/bdo-toolkit/#cli). Exact
|
|
@@ -88,7 +90,7 @@ installed with the Python wheel.
|
|
|
88
90
|
| Observe live item activity | [`examples/live_transfer_log.py`](https://github.com/ychwu/bdo-toolkit/blob/main/examples/live_transfer_log.py) |
|
|
89
91
|
| Capture inventory and town storage on character load | [`examples/live_character_load_snapshot.py`](https://github.com/ychwu/bdo-toolkit/blob/main/examples/live_character_load_snapshot.py) |
|
|
90
92
|
| Rebuild an item profile after a patch | [`examples/live_calibrate_profile.py`](https://github.com/ychwu/bdo-toolkit/blob/main/examples/live_calibrate_profile.py) |
|
|
91
|
-
| Observe calibration progress and stop on completion | [`examples/live_calibration_progress.py`](https://github.com/ychwu/bdo-toolkit/blob/main/examples/live_calibration_progress.py)
|
|
93
|
+
| Observe calibration progress and stop on completion | [`examples/live_calibration_progress.py`](https://github.com/ychwu/bdo-toolkit/blob/main/examples/live_calibration_progress.py) · [async](https://github.com/ychwu/bdo-toolkit/blob/main/examples/async_calibration_progress.py) |
|
|
92
94
|
| Capture an Arena of Solare leaderboard load | [`examples/solare_live_snapshot.py`](https://github.com/ychwu/bdo-toolkit/blob/main/examples/solare_live_snapshot.py) |
|
|
93
95
|
|
|
94
96
|
See the [Examples index](https://ychwu.github.io/bdo-toolkit/#item-examples)
|
|
@@ -41,7 +41,7 @@ from .origin_learning import (
|
|
|
41
41
|
promote_origin_candidates,
|
|
42
42
|
)
|
|
43
43
|
from .writers import ConsoleEventWriter, JsonlEventWriter
|
|
44
|
-
from . import solare
|
|
44
|
+
from . import agris, grind, solare, xp
|
|
45
45
|
from ._version import __version__
|
|
46
46
|
|
|
47
47
|
__all__ = [
|
|
@@ -83,10 +83,13 @@ __all__ = [
|
|
|
83
83
|
"StorageLocation",
|
|
84
84
|
"__version__",
|
|
85
85
|
"capture_live",
|
|
86
|
+
"agris",
|
|
87
|
+
"grind",
|
|
86
88
|
"fetch_opcode_profile",
|
|
87
89
|
"load_opcode_profile",
|
|
88
90
|
"promote_origin_candidates",
|
|
89
91
|
"replay_pcap",
|
|
90
92
|
"storage_location",
|
|
91
93
|
"solare",
|
|
94
|
+
"xp",
|
|
92
95
|
]
|
|
@@ -508,10 +508,45 @@ def _discover_storage_context_offset(
|
|
|
508
508
|
before_offset=before_offset,
|
|
509
509
|
)
|
|
510
510
|
if len(candidates) == 1:
|
|
511
|
-
|
|
511
|
+
offset = candidates[0][0]
|
|
512
|
+
if _storage_candidate_aliases_batch_count(frame, before_offset, offset):
|
|
513
|
+
return None
|
|
514
|
+
return offset
|
|
512
515
|
return None
|
|
513
516
|
|
|
514
517
|
|
|
518
|
+
def _storage_candidate_aliases_batch_count(
|
|
519
|
+
frame: BDOFrame, item_offset: int, destination_offset: int,
|
|
520
|
+
) -> bool:
|
|
521
|
+
"""A batch count cannot also provide independent destination evidence.
|
|
522
|
+
|
|
523
|
+
Require complete, uniformly spaced records and a plausible normalized
|
|
524
|
+
envelope. This only discounts a single-frame town candidate when every
|
|
525
|
+
geometrically possible count column overlaps it. A separate count column
|
|
526
|
+
preserves the destination signal (and any intrinsic direction conflict).
|
|
527
|
+
Cross-frame destination/count authority is deliberately unchanged.
|
|
528
|
+
"""
|
|
529
|
+
if frame.length != len(frame.message):
|
|
530
|
+
return False
|
|
531
|
+
offsets = _full_transfer_record_offsets(frame, item_offset, item_offset + 35)
|
|
532
|
+
stride = uniform_stride(offsets) if len(offsets) > 1 else None
|
|
533
|
+
if stride is None or item_offset not in offsets:
|
|
534
|
+
return False
|
|
535
|
+
count = len(offsets)
|
|
536
|
+
base_length = frame.length - (count - 1) * stride
|
|
537
|
+
prefix_length = frame.length - count * stride
|
|
538
|
+
if not (200 <= base_length <= 300 and 5 <= prefix_length <= offsets[0]):
|
|
539
|
+
return False
|
|
540
|
+
count_offsets = [
|
|
541
|
+
offset for offset in range(5, prefix_length - 1)
|
|
542
|
+
if int.from_bytes(frame.message[offset:offset + 2], "little") == count
|
|
543
|
+
]
|
|
544
|
+
return bool(count_offsets) and all(
|
|
545
|
+
_ranges_overlap(offset, 2, destination_offset, 4)
|
|
546
|
+
for offset in count_offsets
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
|
|
515
550
|
def _discover_storage_context_offset_from_frames(
|
|
516
551
|
frames: Iterable[BDOFrame],
|
|
517
552
|
*,
|
|
@@ -41,16 +41,20 @@ class _TeeScanner:
|
|
|
41
41
|
primary: TargetMessageScanner,
|
|
42
42
|
tap: Optional[FrameCollectorScanner],
|
|
43
43
|
stream_observer: Optional[Callable[[bytes, PacketContext], None]],
|
|
44
|
+
unhinted_tap: Optional[FrameCollectorScanner] = None,
|
|
44
45
|
) -> None:
|
|
45
46
|
self._primary = primary
|
|
46
47
|
self._tap = tap
|
|
47
48
|
self._stream_observer = stream_observer
|
|
49
|
+
self._unhinted_tap = unhinted_tap
|
|
48
50
|
|
|
49
51
|
def feed(self, data, context) -> None:
|
|
50
52
|
if self._stream_observer is not None:
|
|
51
53
|
self._stream_observer(data, context)
|
|
52
54
|
if self._tap is not None:
|
|
53
55
|
self._tap.feed(data, context)
|
|
56
|
+
if self._unhinted_tap is not None:
|
|
57
|
+
self._unhinted_tap.feed(data, context)
|
|
54
58
|
self._primary.feed(data, context)
|
|
55
59
|
|
|
56
60
|
def scan_standalone(self, data, context) -> None:
|
|
@@ -58,6 +62,8 @@ class _TeeScanner:
|
|
|
58
62
|
self._stream_observer(data, context)
|
|
59
63
|
if self._tap is not None:
|
|
60
64
|
self._tap.scan_standalone(data, context)
|
|
65
|
+
if self._unhinted_tap is not None:
|
|
66
|
+
self._unhinted_tap.scan_standalone(data, context)
|
|
61
67
|
self._primary.scan_standalone(data, context)
|
|
62
68
|
|
|
63
69
|
def can_anchor_at_start(self, data: bytes) -> bool:
|
|
@@ -69,6 +75,8 @@ class _TeeScanner:
|
|
|
69
75
|
def reset(self) -> None:
|
|
70
76
|
if self._tap is not None:
|
|
71
77
|
self._tap.reset()
|
|
78
|
+
if self._unhinted_tap is not None:
|
|
79
|
+
self._unhinted_tap.reset()
|
|
72
80
|
self._primary.reset()
|
|
73
81
|
|
|
74
82
|
|
|
@@ -189,6 +197,8 @@ class PacketEngine:
|
|
|
189
197
|
message_observer: Optional[MessageObserver] = None,
|
|
190
198
|
max_pending_segments: int = MAX_PENDING_SEGMENTS,
|
|
191
199
|
max_pending_bytes: Optional[int] = None,
|
|
200
|
+
defer_gap_timeouts: bool = False,
|
|
201
|
+
unhinted_frame_observer: Optional[Callable[[BDOFrame], None]] = None,
|
|
192
202
|
) -> None:
|
|
193
203
|
self.event_specs = tuple(event_specs)
|
|
194
204
|
self.events_found = 0
|
|
@@ -203,7 +213,7 @@ class PacketEngine:
|
|
|
203
213
|
self.event_specs,
|
|
204
214
|
message_observer=message_observer,
|
|
205
215
|
)
|
|
206
|
-
if frame_observer is None and stream_observer is None:
|
|
216
|
+
if frame_observer is None and stream_observer is None and unhinted_frame_observer is None:
|
|
207
217
|
return primary
|
|
208
218
|
tap = (
|
|
209
219
|
FrameCollectorScanner(
|
|
@@ -213,7 +223,12 @@ class PacketEngine:
|
|
|
213
223
|
if frame_observer is not None
|
|
214
224
|
else None
|
|
215
225
|
)
|
|
216
|
-
|
|
226
|
+
# Optional telemetry must keep its standalone synchronization
|
|
227
|
+
# authority. Item opcode hints are only for the existing item tap;
|
|
228
|
+
# accidental headers in a midstream prefix must not steer XP/Agris.
|
|
229
|
+
unhinted_tap = (FrameCollectorScanner(unhinted_frame_observer)
|
|
230
|
+
if unhinted_frame_observer is not None else None)
|
|
231
|
+
return _TeeScanner(primary, tap, stream_observer, unhinted_tap)
|
|
217
232
|
|
|
218
233
|
self._flow_manager = FlowManager(
|
|
219
234
|
server_ports=server_ports,
|
|
@@ -226,6 +241,7 @@ class PacketEngine:
|
|
|
226
241
|
idle_timeout=_ITEM_FLOW_IDLE_SECONDS,
|
|
227
242
|
max_pending_segments=max_pending_segments,
|
|
228
243
|
max_pending_bytes=max_pending_bytes,
|
|
244
|
+
defer_gap_timeouts=defer_gap_timeouts,
|
|
229
245
|
)
|
|
230
246
|
self._seen_event_keys: set[
|
|
231
247
|
tuple[FlowKey, int, int, int, Optional[int], bytes]
|
|
@@ -185,17 +185,17 @@ def _discover_inventory_tail_layout(
|
|
|
185
185
|
observed_codes: set[int] = set()
|
|
186
186
|
valid = True
|
|
187
187
|
for frame, group, _, _ in frame_groups:
|
|
188
|
-
codes =
|
|
188
|
+
codes = [
|
|
189
189
|
frame.message[int(event.record_offset) + container_relative]
|
|
190
190
|
for event in group
|
|
191
191
|
if event.record_offset is not None
|
|
192
192
|
and int(event.record_offset) + container_relative
|
|
193
193
|
< len(frame.message)
|
|
194
|
-
|
|
195
|
-
if len(codes) != 1:
|
|
194
|
+
]
|
|
195
|
+
if len(codes) != len(group) or len(set(codes)) != 1:
|
|
196
196
|
valid = False
|
|
197
197
|
break
|
|
198
|
-
code =
|
|
198
|
+
code = codes[0]
|
|
199
199
|
if code not in _INVENTORY_CONTAINER_LABELS:
|
|
200
200
|
valid = False
|
|
201
201
|
break
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Private bounded observation-session lifecycle shared by Agris and solo XP."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
import math
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from queue import Empty, Full, Queue
|
|
8
|
+
from threading import Event, Lock, Thread, current_thread
|
|
9
|
+
import time
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import Any, Callable, Protocol, Self
|
|
12
|
+
|
|
13
|
+
from ._capture_options import LiveCaptureOptions
|
|
14
|
+
from ._capture_runtime import CaptureEndpoint, CaptureStats, LivePacketCapture, _attach_cleanup_owner
|
|
15
|
+
from ._capture_backend import SegmentConsumer
|
|
16
|
+
from .capture import CaptureIntegrityError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ObservationManager(SegmentConsumer, Protocol):
|
|
20
|
+
@property
|
|
21
|
+
def tcp_gap_resets(self) -> int: ...
|
|
22
|
+
|
|
23
|
+
def service_gaps(self, now: float) -> int: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ObservationSession[T, S]:
|
|
27
|
+
"""Single owner for acquisition, bounded delivery, deadlines and cleanup.
|
|
28
|
+
|
|
29
|
+
Domains own their decoder, status/health view and clean-stop result policy.
|
|
30
|
+
Factories are explicit so existing domain test seams remain unchanged.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
_JOIN_TIMEOUT = 15.0
|
|
34
|
+
_START_TIMEOUT = 15.0
|
|
35
|
+
|
|
36
|
+
def __init__(self, *, label: str, snapshot: Callable[[], S],
|
|
37
|
+
manager: ObservationManager, refresh: Callable[[float | None], None],
|
|
38
|
+
live_options: LiveCaptureOptions | None,
|
|
39
|
+
capture_seconds: float | None, save_pcap: str | Path | None,
|
|
40
|
+
capture_factory: Callable[..., LivePacketCapture],
|
|
41
|
+
handler_factory: Callable[..., Callable[[object], None]],
|
|
42
|
+
writer_factory: Callable[..., Any]) -> None:
|
|
43
|
+
if live_options is not None and not isinstance(live_options, LiveCaptureOptions):
|
|
44
|
+
raise TypeError("live_options must be LiveCaptureOptions or None")
|
|
45
|
+
if capture_seconds is not None and (isinstance(capture_seconds, bool)
|
|
46
|
+
or not isinstance(capture_seconds, (float, int))
|
|
47
|
+
or not math.isfinite(capture_seconds) or capture_seconds <= 0):
|
|
48
|
+
raise ValueError("capture_seconds must be finite and positive")
|
|
49
|
+
self._options = live_options or LiveCaptureOptions()
|
|
50
|
+
self._label, self._snapshot, self._refresh = label, snapshot, refresh
|
|
51
|
+
self._capture_factory, self._handler_factory, self._writer_factory = capture_factory, handler_factory, writer_factory
|
|
52
|
+
self._events: Queue[T] = Queue(self._options.event_queue_size)
|
|
53
|
+
self._packets: Queue[object] = Queue(self._options.packet_queue_size)
|
|
54
|
+
self._manager = manager
|
|
55
|
+
self._status = snapshot()
|
|
56
|
+
self._seconds = capture_seconds
|
|
57
|
+
self._save_path = Path(save_pcap).expanduser().resolve() if save_pcap is not None else None
|
|
58
|
+
if self._save_path is not None and self._save_path.suffix.lower() not in {".pcap", ".pcapng"}:
|
|
59
|
+
raise ValueError("save_pcap must end in .pcap or .pcapng")
|
|
60
|
+
self._state_lock = Lock()
|
|
61
|
+
self._lifecycle_lock = Lock()
|
|
62
|
+
self._consumer_lock = Lock()
|
|
63
|
+
self._stop_requested = Event()
|
|
64
|
+
self._ready = Event()
|
|
65
|
+
self._worker_done = Event()
|
|
66
|
+
self._stopped = Event()
|
|
67
|
+
self._worker: Thread | None = None
|
|
68
|
+
self._capture: LivePacketCapture | None = None
|
|
69
|
+
self._capture_started = False
|
|
70
|
+
self._start_attempted = False
|
|
71
|
+
self._cleanup_incomplete = False
|
|
72
|
+
self._handler: Callable[[object], None] | None = None
|
|
73
|
+
self._writer: Any = None
|
|
74
|
+
self._stats = CaptureStats()
|
|
75
|
+
self._error: BaseException | None = None
|
|
76
|
+
self._stop_reason: str | None = None
|
|
77
|
+
self._accepted = self._processed = self._packet_overflows = self._event_overflows = 0
|
|
78
|
+
|
|
79
|
+
def _on_clean_stop(self) -> None:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def endpoint(self) -> CaptureEndpoint | None:
|
|
84
|
+
return self._capture.endpoint if self._capture is not None else None
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def running(self) -> bool:
|
|
88
|
+
return self._ready.is_set() and not self._worker_done.is_set() and self.error is None
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def stopped(self) -> bool:
|
|
92
|
+
return self._stopped.is_set()
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def cleanup_incomplete(self) -> bool:
|
|
96
|
+
with self._state_lock:
|
|
97
|
+
return self._cleanup_incomplete
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def error(self) -> BaseException | None:
|
|
101
|
+
with self._state_lock:
|
|
102
|
+
return self._error
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def stop_reason(self) -> str | None:
|
|
106
|
+
with self._state_lock:
|
|
107
|
+
return self._stop_reason
|
|
108
|
+
|
|
109
|
+
def _record_error(self, error: BaseException) -> None:
|
|
110
|
+
with self._state_lock:
|
|
111
|
+
if self._error is None:
|
|
112
|
+
self._error = error
|
|
113
|
+
self._stop_reason = "error"
|
|
114
|
+
self._stop_requested.set()
|
|
115
|
+
|
|
116
|
+
def raise_if_failed(self) -> None:
|
|
117
|
+
error = self.error
|
|
118
|
+
if error is not None:
|
|
119
|
+
raise error
|
|
120
|
+
|
|
121
|
+
def _accept(self, packet: object) -> None:
|
|
122
|
+
if self._stop_requested.is_set():
|
|
123
|
+
return
|
|
124
|
+
try:
|
|
125
|
+
self._packets.put_nowait(packet)
|
|
126
|
+
except Full:
|
|
127
|
+
with self._state_lock:
|
|
128
|
+
self._packet_overflows += 1
|
|
129
|
+
error = CaptureIntegrityError(f"{self._label} packet queue overflow; capture incomplete")
|
|
130
|
+
self._record_error(error)
|
|
131
|
+
raise error
|
|
132
|
+
with self._state_lock:
|
|
133
|
+
self._accepted += 1
|
|
134
|
+
|
|
135
|
+
def _publish(self, balance: T) -> None:
|
|
136
|
+
try:
|
|
137
|
+
self._events.put_nowait(balance)
|
|
138
|
+
except Full as exc:
|
|
139
|
+
with self._state_lock:
|
|
140
|
+
self._event_overflows += 1
|
|
141
|
+
raise CaptureIntegrityError(f"{self._label} balance queue overflow; consumer too slow") from exc
|
|
142
|
+
|
|
143
|
+
def _update_status(self) -> None:
|
|
144
|
+
status = self._snapshot()
|
|
145
|
+
with self._state_lock:
|
|
146
|
+
self._status = status
|
|
147
|
+
|
|
148
|
+
def _read_stats(self, *, final: bool = False) -> None:
|
|
149
|
+
capture = self._capture
|
|
150
|
+
if capture is None:
|
|
151
|
+
return
|
|
152
|
+
stats = capture.stats if final else capture.snapshot_stats()
|
|
153
|
+
with self._state_lock:
|
|
154
|
+
self._stats = stats
|
|
155
|
+
if (stats.dropped or 0) > 0 or (stats.interface_dropped or 0) > 0:
|
|
156
|
+
raise CaptureIntegrityError(f"{self._label} capture backend reported dropped packets")
|
|
157
|
+
|
|
158
|
+
def start(self) -> None:
|
|
159
|
+
with self._lifecycle_lock:
|
|
160
|
+
if self._start_attempted:
|
|
161
|
+
raise RuntimeError(f"{self._label} sessions are single-use")
|
|
162
|
+
self._start_attempted = True
|
|
163
|
+
worker = Thread(target=self._run, name=f"bdo-toolkit-{self._label.lower()}", daemon=True)
|
|
164
|
+
self._worker = worker
|
|
165
|
+
try:
|
|
166
|
+
worker.start()
|
|
167
|
+
except BaseException as exc:
|
|
168
|
+
self._record_error(exc)
|
|
169
|
+
self._worker_done.set()
|
|
170
|
+
self._stopped.set()
|
|
171
|
+
raise
|
|
172
|
+
try:
|
|
173
|
+
if not self._ready.wait(self._START_TIMEOUT):
|
|
174
|
+
raise TimeoutError(f"{self._label} capture startup did not become ready")
|
|
175
|
+
self.raise_if_failed()
|
|
176
|
+
except BaseException as primary:
|
|
177
|
+
self._record_error(primary)
|
|
178
|
+
self._stop_requested.set()
|
|
179
|
+
try:
|
|
180
|
+
self.stop()
|
|
181
|
+
except BaseException as cleanup:
|
|
182
|
+
if cleanup is not primary:
|
|
183
|
+
primary.add_note(f"{self._label} startup cleanup also failed: {cleanup!r}")
|
|
184
|
+
if self.cleanup_incomplete:
|
|
185
|
+
_attach_cleanup_owner(primary, self, context=f"{self._label} startup")
|
|
186
|
+
raise
|
|
187
|
+
|
|
188
|
+
def _process(self, packet: object) -> None:
|
|
189
|
+
if self._writer is not None:
|
|
190
|
+
self._writer.write(packet)
|
|
191
|
+
# Keep raw accepted packets even when an earlier loss invalidated decode.
|
|
192
|
+
if self.error is None:
|
|
193
|
+
assert self._handler is not None
|
|
194
|
+
self._handler(packet)
|
|
195
|
+
self._refresh(None)
|
|
196
|
+
self._update_status()
|
|
197
|
+
with self._state_lock:
|
|
198
|
+
self._processed += 1
|
|
199
|
+
|
|
200
|
+
def _run(self) -> None:
|
|
201
|
+
try:
|
|
202
|
+
if self._save_path is not None:
|
|
203
|
+
if self._save_path.exists():
|
|
204
|
+
raise FileExistsError(f"refusing to overwrite existing capture: {self._save_path}")
|
|
205
|
+
self._save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
self._writer = self._writer_factory(self._save_path)
|
|
207
|
+
self._handler = self._handler_factory(self._manager)
|
|
208
|
+
self._capture = self._capture_factory(capture_options=self._options, on_packet=self._accept)
|
|
209
|
+
self._capture.start()
|
|
210
|
+
self._capture_started = True
|
|
211
|
+
self._capture.raise_if_failed()
|
|
212
|
+
self._ready.set()
|
|
213
|
+
deadline = time.monotonic() + self._seconds if self._seconds is not None else None
|
|
214
|
+
next_stats = 0.0
|
|
215
|
+
while not self._stop_requested.is_set():
|
|
216
|
+
self._capture.raise_if_failed()
|
|
217
|
+
if not self._capture.running:
|
|
218
|
+
raise CaptureIntegrityError(f"{self._label} capture backend stopped unexpectedly")
|
|
219
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
220
|
+
with self._state_lock:
|
|
221
|
+
self._stop_reason = "duration"
|
|
222
|
+
break
|
|
223
|
+
if time.monotonic() >= next_stats:
|
|
224
|
+
self._read_stats()
|
|
225
|
+
next_stats = time.monotonic() + 1.0
|
|
226
|
+
try:
|
|
227
|
+
packet = self._packets.get(timeout=0.05)
|
|
228
|
+
except Empty:
|
|
229
|
+
# No dequeued packet is outstanding here. A queued backlog
|
|
230
|
+
# must never be treated as a wall-clock TCP gap.
|
|
231
|
+
if self._packets.empty():
|
|
232
|
+
now = time.time()
|
|
233
|
+
self._manager.service_gaps(now)
|
|
234
|
+
self._refresh(now)
|
|
235
|
+
self._update_status()
|
|
236
|
+
else:
|
|
237
|
+
self._process(packet)
|
|
238
|
+
except BaseException as exc:
|
|
239
|
+
self._record_error(exc)
|
|
240
|
+
finally:
|
|
241
|
+
self._stop_requested.set()
|
|
242
|
+
try:
|
|
243
|
+
self._shutdown()
|
|
244
|
+
except BaseException as exc:
|
|
245
|
+
self._record_error(exc)
|
|
246
|
+
self._ready.set() # Unblocks failed startup with its original error.
|
|
247
|
+
self._worker_done.set()
|
|
248
|
+
|
|
249
|
+
def _shutdown(self) -> None:
|
|
250
|
+
capture = self._capture
|
|
251
|
+
if capture is not None:
|
|
252
|
+
try:
|
|
253
|
+
if not capture.stopped and (self._capture_started or capture.cleanup_incomplete):
|
|
254
|
+
capture.stop()
|
|
255
|
+
capture.raise_if_failed()
|
|
256
|
+
except BaseException as exc:
|
|
257
|
+
self._record_error(exc)
|
|
258
|
+
incomplete = capture.cleanup_incomplete or (self._capture_started and not capture.stopped)
|
|
259
|
+
if incomplete:
|
|
260
|
+
with self._state_lock:
|
|
261
|
+
self._cleanup_incomplete = True
|
|
262
|
+
self._record_error(RuntimeError(f"{self._label} native cleanup is incomplete; retry stop()"))
|
|
263
|
+
return
|
|
264
|
+
try:
|
|
265
|
+
self._read_stats(final=True)
|
|
266
|
+
except BaseException as exc:
|
|
267
|
+
self._record_error(exc)
|
|
268
|
+
while True:
|
|
269
|
+
try:
|
|
270
|
+
packet = self._packets.get_nowait()
|
|
271
|
+
except Empty:
|
|
272
|
+
break
|
|
273
|
+
try:
|
|
274
|
+
self._process(packet)
|
|
275
|
+
except BaseException as exc:
|
|
276
|
+
self._record_error(exc)
|
|
277
|
+
if self.error is None:
|
|
278
|
+
try:
|
|
279
|
+
self._manager.finish()
|
|
280
|
+
except BaseException as exc:
|
|
281
|
+
self._record_error(exc)
|
|
282
|
+
self._update_status()
|
|
283
|
+
if self._writer is not None:
|
|
284
|
+
try:
|
|
285
|
+
self._writer.close()
|
|
286
|
+
except BaseException as exc:
|
|
287
|
+
self._record_error(exc)
|
|
288
|
+
with self._state_lock:
|
|
289
|
+
self._cleanup_incomplete = True
|
|
290
|
+
return
|
|
291
|
+
self._writer = None
|
|
292
|
+
with self._state_lock:
|
|
293
|
+
self._cleanup_incomplete = False
|
|
294
|
+
self._stop_reason = self._stop_reason or "requested"
|
|
295
|
+
if self.error is None:
|
|
296
|
+
self._on_clean_stop()
|
|
297
|
+
self._stopped.set()
|
|
298
|
+
|
|
299
|
+
def stop(self) -> None:
|
|
300
|
+
if not self._start_attempted:
|
|
301
|
+
raise RuntimeError(f"{self._label} session was not started")
|
|
302
|
+
if self._worker is current_thread():
|
|
303
|
+
raise RuntimeError(f"{self._label} stop() cannot join its decoder thread")
|
|
304
|
+
self._stop_requested.set()
|
|
305
|
+
with self._lifecycle_lock:
|
|
306
|
+
worker = self._worker
|
|
307
|
+
if worker is not None and worker.is_alive():
|
|
308
|
+
worker.join(self._JOIN_TIMEOUT)
|
|
309
|
+
if worker is not None and worker.is_alive():
|
|
310
|
+
with self._state_lock:
|
|
311
|
+
self._cleanup_incomplete = True
|
|
312
|
+
self._record_error(RuntimeError(f"{self._label} decoder cleanup timed out; retry stop()"))
|
|
313
|
+
elif self.cleanup_incomplete:
|
|
314
|
+
self._shutdown()
|
|
315
|
+
error = self.error
|
|
316
|
+
if error is not None:
|
|
317
|
+
if self.cleanup_incomplete:
|
|
318
|
+
_attach_cleanup_owner(error, self, context=f"{self._label} stop")
|
|
319
|
+
raise error
|
|
320
|
+
|
|
321
|
+
def poll(self, timeout: float | None = None) -> T | None:
|
|
322
|
+
if timeout is not None and (isinstance(timeout, bool) or not isinstance(timeout, (float, int))
|
|
323
|
+
or not math.isfinite(timeout) or timeout < 0):
|
|
324
|
+
raise ValueError("timeout must be finite and nonnegative")
|
|
325
|
+
if not self._start_attempted:
|
|
326
|
+
raise RuntimeError(f"{self._label} session was not started")
|
|
327
|
+
if not self._consumer_lock.acquire(blocking=False):
|
|
328
|
+
raise RuntimeError(f"{self._label} sessions support one balance consumer")
|
|
329
|
+
try:
|
|
330
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
331
|
+
while True:
|
|
332
|
+
self.raise_if_failed()
|
|
333
|
+
try:
|
|
334
|
+
balance = self._events.get_nowait()
|
|
335
|
+
except Empty:
|
|
336
|
+
pass
|
|
337
|
+
else:
|
|
338
|
+
self.raise_if_failed()
|
|
339
|
+
return balance
|
|
340
|
+
if self.stopped:
|
|
341
|
+
return None
|
|
342
|
+
remaining = None if deadline is None else deadline - time.monotonic()
|
|
343
|
+
if remaining is not None and remaining <= 0:
|
|
344
|
+
return None
|
|
345
|
+
try:
|
|
346
|
+
balance = self._events.get(timeout=min(0.05, remaining) if remaining is not None else 0.05)
|
|
347
|
+
except Empty:
|
|
348
|
+
pass
|
|
349
|
+
else:
|
|
350
|
+
self.raise_if_failed()
|
|
351
|
+
return balance
|
|
352
|
+
finally:
|
|
353
|
+
self._consumer_lock.release()
|
|
354
|
+
|
|
355
|
+
def events(self) -> Iterator[T]:
|
|
356
|
+
while True:
|
|
357
|
+
balance = self.poll()
|
|
358
|
+
if balance is not None:
|
|
359
|
+
yield balance
|
|
360
|
+
elif self.stopped:
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
def __enter__(self) -> Self:
|
|
364
|
+
self.start()
|
|
365
|
+
return self
|
|
366
|
+
|
|
367
|
+
def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None,
|
|
368
|
+
traceback: TracebackType | None) -> None:
|
|
369
|
+
try:
|
|
370
|
+
self.stop()
|
|
371
|
+
except BaseException as cleanup:
|
|
372
|
+
if exc_value is None:
|
|
373
|
+
raise
|
|
374
|
+
if self.cleanup_incomplete:
|
|
375
|
+
_attach_cleanup_owner(exc_value, self, context=f"{self._label} context")
|
|
376
|
+
exc_value.add_note(f"{self._label} context cleanup also failed: {cleanup!r}")
|
|
@@ -14,6 +14,8 @@ from .profiles import (
|
|
|
14
14
|
OpcodeProfile,
|
|
15
15
|
OriginCompanionFamily,
|
|
16
16
|
ProfileError,
|
|
17
|
+
AgrisProfileLayout,
|
|
18
|
+
XPProfileLayout,
|
|
17
19
|
)
|
|
18
20
|
|
|
19
21
|
|
|
@@ -39,6 +41,14 @@ def validate_runtime_profile(profile: OpcodeProfile) -> RuntimeProfileValidation
|
|
|
39
41
|
)
|
|
40
42
|
if profile.active is not True:
|
|
41
43
|
raise ProfileError(f"Opcode profile is inactive: {profile.path}")
|
|
44
|
+
if profile.xp is not None:
|
|
45
|
+
if not isinstance(profile.xp, XPProfileLayout):
|
|
46
|
+
raise ProfileError("xp must be an XPProfileLayout")
|
|
47
|
+
profile.xp.__post_init__()
|
|
48
|
+
if profile.agris is not None:
|
|
49
|
+
if not isinstance(profile.agris, AgrisProfileLayout):
|
|
50
|
+
raise ProfileError("agris must be an AgrisProfileLayout")
|
|
51
|
+
profile.agris.__post_init__()
|
|
42
52
|
|
|
43
53
|
loaded_specs = event_specs_from_profile(profile)
|
|
44
54
|
decrement_specs = tuple(
|