bdo-toolkit 1.0.6__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.6/src/bdo_toolkit.egg-info → bdo_toolkit-1.0.7}/PKG-INFO +6 -4
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/README.md +5 -3
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/__init__.py +3 -1
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_engine.py +18 -2
- bdo_toolkit-1.0.6/src/bdo_toolkit/agris/session.py → bdo_toolkit-1.0.7/src/bdo_toolkit/_observation_session.py +61 -71
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_profile_runtime.py +10 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_reassembly.py +18 -11
- {bdo_toolkit-1.0.6 → 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.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/agris/_discovery.py +28 -0
- bdo_toolkit-1.0.7/src/bdo_toolkit/agris/_profile.py +17 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/agris/async_session.py +14 -2
- bdo_toolkit-1.0.7/src/bdo_toolkit/agris/calibration.py +116 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/agris/replay.py +10 -5
- bdo_toolkit-1.0.7/src/bdo_toolkit/agris/session.py +75 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/capture.py +21 -2
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/cli.py +41 -8
- 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.6 → 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.6 → bdo_toolkit-1.0.7/src/bdo_toolkit.egg-info}/PKG-INFO +6 -4
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/SOURCES.txt +19 -1
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_agris_async.py +1 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_agris_evidence.py +16 -0
- bdo_toolkit-1.0.7/tests/test_agris_profiles.py +340 -0
- bdo_toolkit-1.0.7/tests/test_grind.py +534 -0
- {bdo_toolkit-1.0.6 → 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.6/src/bdo_toolkit/agris/__init__.py +0 -16
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/LICENSE +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/pyproject.toml +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/setup.cfg +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_async_sessions.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_async_utils.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/__init__.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/_constants.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/_formatting.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/_records.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/analysis.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/capture.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/companions.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/live.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/models.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/observations.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/persistence.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/progress.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/validation.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/workflow.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_capture_backend.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_capture_options.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_capture_runtime.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_deposit_origin.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_framing.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/__init__.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/_constants.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/_records.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/assembly.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/formatting.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/inventory.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/models.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/session.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/storage.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/__init__.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/discovery.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/manual.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/models.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/tracker.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_profile_io.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_protocol.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_record_geometry.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_specs.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_storage_destination_validation.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_storage_hydration.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/agris/_capture.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/agris/_validation.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/agris/models.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/calibration.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/capture_diagnosis.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/character_state.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/diagnostics.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/events.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/filters.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/item_state.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/origin_learning.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/py.typed +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/remote_profiles.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/__init__.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_constants.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_detail_learning.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_details.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_discovery.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_live_tracker.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_replay_capture.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_result.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_scanner.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_validation.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/async_session.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/models.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/replay.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/session.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit/writers.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/dependency_links.txt +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/entry_points.txt +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/requires.txt +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/top_level.txt +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_agris_cli.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_agris_discovery.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_agris_models.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_agris_replay.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_agris_session.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_async_sessions.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_calibration.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_calibration_conveniences.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_calibration_lifecycle.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_calibration_persistence.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_calibration_progress.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_calibration_retention.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_capture_backend.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_capture_diagnosis.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_capture_replay.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_capture_runtime.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_character_state.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_cli.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_decoder_diagnostics.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_deposit_origin.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_direction.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_dynamic_storage_calibration_matrix.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_engine.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_examples_and_writers.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_explicit_profile.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_fixture_catalog.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_fixture_regressions.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_framing.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_initial_load_storage.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_inventory_snapshots.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_item_state.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_live_capture_session.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_origin_learning.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_package_layout.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_profile_validation.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_remote_profiles.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_solare_api.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_solare_details.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_solare_learning.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_solare_learning_adversarial.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_solare_real_captures.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_solare_replay.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_solare_session.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_storage_destination_validation.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_storage_events.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_storage_hydration_tracker.py +0 -0
- {bdo_toolkit-1.0.6 → bdo_toolkit-1.0.7}/tests/test_storage_unknown_destination_calibration.py +0 -0
- {bdo_toolkit-1.0.6 → 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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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 agris, solare
|
|
44
|
+
from . import agris, grind, solare, xp
|
|
45
45
|
from ._version import __version__
|
|
46
46
|
|
|
47
47
|
__all__ = [
|
|
@@ -84,10 +84,12 @@ __all__ = [
|
|
|
84
84
|
"__version__",
|
|
85
85
|
"capture_live",
|
|
86
86
|
"agris",
|
|
87
|
+
"grind",
|
|
87
88
|
"fetch_opcode_profile",
|
|
88
89
|
"load_opcode_profile",
|
|
89
90
|
"promote_origin_candidates",
|
|
90
91
|
"replay_pcap",
|
|
91
92
|
"storage_location",
|
|
92
93
|
"solare",
|
|
94
|
+
"xp",
|
|
93
95
|
]
|
|
@@ -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]
|
|
@@ -1,42 +1,45 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Private bounded observation-session lifecycle shared by Agris and solo XP."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
from collections.abc import Iterator
|
|
5
|
-
from dataclasses import replace
|
|
6
5
|
import math
|
|
7
6
|
from pathlib import Path
|
|
8
7
|
from queue import Empty, Full, Queue
|
|
9
8
|
from threading import Event, Lock, Thread, current_thread
|
|
10
9
|
import time
|
|
11
10
|
from types import TracebackType
|
|
12
|
-
from typing import Any, Callable
|
|
11
|
+
from typing import Any, Callable, Protocol, Self
|
|
13
12
|
|
|
14
|
-
from
|
|
15
|
-
from
|
|
16
|
-
from
|
|
17
|
-
from
|
|
18
|
-
from ._capture import AgrisCaptureHealth, AgrisFlowManager
|
|
19
|
-
from ._discovery import AgrisTracker
|
|
20
|
-
from .models import AgrisBalance, AgrisDiscoveryOptions, AgrisStatus
|
|
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
|
|
21
17
|
|
|
22
18
|
|
|
23
|
-
class
|
|
24
|
-
|
|
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
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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.
|
|
30
31
|
"""
|
|
31
32
|
|
|
32
33
|
_JOIN_TIMEOUT = 15.0
|
|
33
34
|
_START_TIMEOUT = 15.0
|
|
34
35
|
|
|
35
|
-
def __init__(self, *,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
capture_seconds: float | None
|
|
39
|
-
|
|
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:
|
|
40
43
|
if live_options is not None and not isinstance(live_options, LiveCaptureOptions):
|
|
41
44
|
raise TypeError("live_options must be LiveCaptureOptions or None")
|
|
42
45
|
if capture_seconds is not None and (isinstance(capture_seconds, bool)
|
|
@@ -44,12 +47,12 @@ class LiveAgrisSession:
|
|
|
44
47
|
or not math.isfinite(capture_seconds) or capture_seconds <= 0):
|
|
45
48
|
raise ValueError("capture_seconds must be finite and positive")
|
|
46
49
|
self._options = live_options or LiveCaptureOptions()
|
|
47
|
-
self.
|
|
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)
|
|
48
53
|
self._packets: Queue[object] = Queue(self._options.packet_queue_size)
|
|
49
|
-
self.
|
|
50
|
-
|
|
51
|
-
self._manager = AgrisFlowManager(self._tracker, self._options.ports, live=True)
|
|
52
|
-
self._status = self._tracker.snapshot()
|
|
54
|
+
self._manager = manager
|
|
55
|
+
self._status = snapshot()
|
|
53
56
|
self._seconds = capture_seconds
|
|
54
57
|
self._save_path = Path(save_pcap).expanduser().resolve() if save_pcap is not None else None
|
|
55
58
|
if self._save_path is not None and self._save_path.suffix.lower() not in {".pcap", ".pcapng"}:
|
|
@@ -73,23 +76,8 @@ class LiveAgrisSession:
|
|
|
73
76
|
self._stop_reason: str | None = None
|
|
74
77
|
self._accepted = self._processed = self._packet_overflows = self._event_overflows = 0
|
|
75
78
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
with self._state_lock:
|
|
79
|
-
status, error = self._status, self._error
|
|
80
|
-
return replace(status, status="invalid", balance=None, reason=str(error)) if error else status
|
|
81
|
-
|
|
82
|
-
@property
|
|
83
|
-
def health(self) -> AgrisCaptureHealth:
|
|
84
|
-
with self._state_lock:
|
|
85
|
-
return AgrisCaptureHealth(
|
|
86
|
-
packets_processed=self._processed, packets_accepted=self._accepted,
|
|
87
|
-
packet_queue_overflows=self._packet_overflows, event_queue_overflows=self._event_overflows,
|
|
88
|
-
tcp_gap_resets=self._manager.tcp_gap_resets, flow_state_evictions=self._manager.evictions,
|
|
89
|
-
pcap_received=self._stats.received, pcap_dropped=self._stats.dropped,
|
|
90
|
-
pcap_interface_dropped=self._stats.interface_dropped,
|
|
91
|
-
capture_buffer_bytes=self._stats.capture_buffer_bytes,
|
|
92
|
-
cleanup_incomplete=self._cleanup_incomplete)
|
|
79
|
+
def _on_clean_stop(self) -> None:
|
|
80
|
+
pass
|
|
93
81
|
|
|
94
82
|
@property
|
|
95
83
|
def endpoint(self) -> CaptureEndpoint | None:
|
|
@@ -138,22 +126,22 @@ class LiveAgrisSession:
|
|
|
138
126
|
except Full:
|
|
139
127
|
with self._state_lock:
|
|
140
128
|
self._packet_overflows += 1
|
|
141
|
-
error = CaptureIntegrityError("
|
|
129
|
+
error = CaptureIntegrityError(f"{self._label} packet queue overflow; capture incomplete")
|
|
142
130
|
self._record_error(error)
|
|
143
131
|
raise error
|
|
144
132
|
with self._state_lock:
|
|
145
133
|
self._accepted += 1
|
|
146
134
|
|
|
147
|
-
def _publish(self, balance:
|
|
135
|
+
def _publish(self, balance: T) -> None:
|
|
148
136
|
try:
|
|
149
137
|
self._events.put_nowait(balance)
|
|
150
138
|
except Full as exc:
|
|
151
139
|
with self._state_lock:
|
|
152
140
|
self._event_overflows += 1
|
|
153
|
-
raise CaptureIntegrityError("
|
|
141
|
+
raise CaptureIntegrityError(f"{self._label} balance queue overflow; consumer too slow") from exc
|
|
154
142
|
|
|
155
143
|
def _update_status(self) -> None:
|
|
156
|
-
status = self.
|
|
144
|
+
status = self._snapshot()
|
|
157
145
|
with self._state_lock:
|
|
158
146
|
self._status = status
|
|
159
147
|
|
|
@@ -165,14 +153,14 @@ class LiveAgrisSession:
|
|
|
165
153
|
with self._state_lock:
|
|
166
154
|
self._stats = stats
|
|
167
155
|
if (stats.dropped or 0) > 0 or (stats.interface_dropped or 0) > 0:
|
|
168
|
-
raise CaptureIntegrityError("
|
|
156
|
+
raise CaptureIntegrityError(f"{self._label} capture backend reported dropped packets")
|
|
169
157
|
|
|
170
158
|
def start(self) -> None:
|
|
171
159
|
with self._lifecycle_lock:
|
|
172
160
|
if self._start_attempted:
|
|
173
|
-
raise RuntimeError("
|
|
161
|
+
raise RuntimeError(f"{self._label} sessions are single-use")
|
|
174
162
|
self._start_attempted = True
|
|
175
|
-
worker = Thread(target=self._run, name="bdo-toolkit-
|
|
163
|
+
worker = Thread(target=self._run, name=f"bdo-toolkit-{self._label.lower()}", daemon=True)
|
|
176
164
|
self._worker = worker
|
|
177
165
|
try:
|
|
178
166
|
worker.start()
|
|
@@ -183,7 +171,7 @@ class LiveAgrisSession:
|
|
|
183
171
|
raise
|
|
184
172
|
try:
|
|
185
173
|
if not self._ready.wait(self._START_TIMEOUT):
|
|
186
|
-
raise TimeoutError("
|
|
174
|
+
raise TimeoutError(f"{self._label} capture startup did not become ready")
|
|
187
175
|
self.raise_if_failed()
|
|
188
176
|
except BaseException as primary:
|
|
189
177
|
self._record_error(primary)
|
|
@@ -192,9 +180,9 @@ class LiveAgrisSession:
|
|
|
192
180
|
self.stop()
|
|
193
181
|
except BaseException as cleanup:
|
|
194
182
|
if cleanup is not primary:
|
|
195
|
-
primary.add_note(f"
|
|
183
|
+
primary.add_note(f"{self._label} startup cleanup also failed: {cleanup!r}")
|
|
196
184
|
if self.cleanup_incomplete:
|
|
197
|
-
_attach_cleanup_owner(primary, self, context="
|
|
185
|
+
_attach_cleanup_owner(primary, self, context=f"{self._label} startup")
|
|
198
186
|
raise
|
|
199
187
|
|
|
200
188
|
def _process(self, packet: object) -> None:
|
|
@@ -204,7 +192,7 @@ class LiveAgrisSession:
|
|
|
204
192
|
if self.error is None:
|
|
205
193
|
assert self._handler is not None
|
|
206
194
|
self._handler(packet)
|
|
207
|
-
self.
|
|
195
|
+
self._refresh(None)
|
|
208
196
|
self._update_status()
|
|
209
197
|
with self._state_lock:
|
|
210
198
|
self._processed += 1
|
|
@@ -215,9 +203,9 @@ class LiveAgrisSession:
|
|
|
215
203
|
if self._save_path.exists():
|
|
216
204
|
raise FileExistsError(f"refusing to overwrite existing capture: {self._save_path}")
|
|
217
205
|
self._save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
218
|
-
self._writer =
|
|
219
|
-
self._handler =
|
|
220
|
-
self._capture =
|
|
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)
|
|
221
209
|
self._capture.start()
|
|
222
210
|
self._capture_started = True
|
|
223
211
|
self._capture.raise_if_failed()
|
|
@@ -227,7 +215,7 @@ class LiveAgrisSession:
|
|
|
227
215
|
while not self._stop_requested.is_set():
|
|
228
216
|
self._capture.raise_if_failed()
|
|
229
217
|
if not self._capture.running:
|
|
230
|
-
raise CaptureIntegrityError("
|
|
218
|
+
raise CaptureIntegrityError(f"{self._label} capture backend stopped unexpectedly")
|
|
231
219
|
if deadline is not None and time.monotonic() >= deadline:
|
|
232
220
|
with self._state_lock:
|
|
233
221
|
self._stop_reason = "duration"
|
|
@@ -243,7 +231,7 @@ class LiveAgrisSession:
|
|
|
243
231
|
if self._packets.empty():
|
|
244
232
|
now = time.time()
|
|
245
233
|
self._manager.service_gaps(now)
|
|
246
|
-
self.
|
|
234
|
+
self._refresh(now)
|
|
247
235
|
self._update_status()
|
|
248
236
|
else:
|
|
249
237
|
self._process(packet)
|
|
@@ -271,7 +259,7 @@ class LiveAgrisSession:
|
|
|
271
259
|
if incomplete:
|
|
272
260
|
with self._state_lock:
|
|
273
261
|
self._cleanup_incomplete = True
|
|
274
|
-
self._record_error(RuntimeError("
|
|
262
|
+
self._record_error(RuntimeError(f"{self._label} native cleanup is incomplete; retry stop()"))
|
|
275
263
|
return
|
|
276
264
|
try:
|
|
277
265
|
self._read_stats(final=True)
|
|
@@ -304,13 +292,15 @@ class LiveAgrisSession:
|
|
|
304
292
|
with self._state_lock:
|
|
305
293
|
self._cleanup_incomplete = False
|
|
306
294
|
self._stop_reason = self._stop_reason or "requested"
|
|
295
|
+
if self.error is None:
|
|
296
|
+
self._on_clean_stop()
|
|
307
297
|
self._stopped.set()
|
|
308
298
|
|
|
309
299
|
def stop(self) -> None:
|
|
310
300
|
if not self._start_attempted:
|
|
311
|
-
raise RuntimeError("
|
|
301
|
+
raise RuntimeError(f"{self._label} session was not started")
|
|
312
302
|
if self._worker is current_thread():
|
|
313
|
-
raise RuntimeError("
|
|
303
|
+
raise RuntimeError(f"{self._label} stop() cannot join its decoder thread")
|
|
314
304
|
self._stop_requested.set()
|
|
315
305
|
with self._lifecycle_lock:
|
|
316
306
|
worker = self._worker
|
|
@@ -319,23 +309,23 @@ class LiveAgrisSession:
|
|
|
319
309
|
if worker is not None and worker.is_alive():
|
|
320
310
|
with self._state_lock:
|
|
321
311
|
self._cleanup_incomplete = True
|
|
322
|
-
self._record_error(RuntimeError("
|
|
312
|
+
self._record_error(RuntimeError(f"{self._label} decoder cleanup timed out; retry stop()"))
|
|
323
313
|
elif self.cleanup_incomplete:
|
|
324
314
|
self._shutdown()
|
|
325
315
|
error = self.error
|
|
326
316
|
if error is not None:
|
|
327
317
|
if self.cleanup_incomplete:
|
|
328
|
-
_attach_cleanup_owner(error, self, context="
|
|
318
|
+
_attach_cleanup_owner(error, self, context=f"{self._label} stop")
|
|
329
319
|
raise error
|
|
330
320
|
|
|
331
|
-
def poll(self, timeout: float | None = None) ->
|
|
321
|
+
def poll(self, timeout: float | None = None) -> T | None:
|
|
332
322
|
if timeout is not None and (isinstance(timeout, bool) or not isinstance(timeout, (float, int))
|
|
333
323
|
or not math.isfinite(timeout) or timeout < 0):
|
|
334
324
|
raise ValueError("timeout must be finite and nonnegative")
|
|
335
325
|
if not self._start_attempted:
|
|
336
|
-
raise RuntimeError("
|
|
326
|
+
raise RuntimeError(f"{self._label} session was not started")
|
|
337
327
|
if not self._consumer_lock.acquire(blocking=False):
|
|
338
|
-
raise RuntimeError("
|
|
328
|
+
raise RuntimeError(f"{self._label} sessions support one balance consumer")
|
|
339
329
|
try:
|
|
340
330
|
deadline = None if timeout is None else time.monotonic() + timeout
|
|
341
331
|
while True:
|
|
@@ -362,7 +352,7 @@ class LiveAgrisSession:
|
|
|
362
352
|
finally:
|
|
363
353
|
self._consumer_lock.release()
|
|
364
354
|
|
|
365
|
-
def events(self) -> Iterator[
|
|
355
|
+
def events(self) -> Iterator[T]:
|
|
366
356
|
while True:
|
|
367
357
|
balance = self.poll()
|
|
368
358
|
if balance is not None:
|
|
@@ -370,7 +360,7 @@ class LiveAgrisSession:
|
|
|
370
360
|
elif self.stopped:
|
|
371
361
|
return
|
|
372
362
|
|
|
373
|
-
def __enter__(self) ->
|
|
363
|
+
def __enter__(self) -> Self:
|
|
374
364
|
self.start()
|
|
375
365
|
return self
|
|
376
366
|
|
|
@@ -382,5 +372,5 @@ class LiveAgrisSession:
|
|
|
382
372
|
if exc_value is None:
|
|
383
373
|
raise
|
|
384
374
|
if self.cleanup_incomplete:
|
|
385
|
-
_attach_cleanup_owner(exc_value, self, context="
|
|
386
|
-
exc_value.add_note(f"
|
|
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(
|
|
@@ -357,19 +357,23 @@ class TCPFlowState:
|
|
|
357
357
|
and self.gap_started_at is not None
|
|
358
358
|
and now - self.gap_started_at >= GAP_RESET_SECONDS
|
|
359
359
|
):
|
|
360
|
-
|
|
361
|
-
self.fin_sequence,
|
|
362
|
-
self.next_sequence,
|
|
363
|
-
)
|
|
364
|
-
if self.next_sequence < fin_sequence:
|
|
365
|
-
self.scanner.reset()
|
|
366
|
-
if self.on_gap_reset is not None:
|
|
367
|
-
self.on_gap_reset(fin_sequence)
|
|
368
|
-
self.next_sequence = fin_sequence
|
|
369
|
-
self.gap_started_at = None
|
|
370
|
-
resets += 1
|
|
360
|
+
resets += self._resume_after_fin_gap()
|
|
371
361
|
return resets
|
|
372
362
|
|
|
363
|
+
def _resume_after_fin_gap(self) -> bool:
|
|
364
|
+
"""Report bytes proven missing by FIN, after pending payload is drained."""
|
|
365
|
+
if self.fin_sequence is None or self.next_sequence is None:
|
|
366
|
+
return False
|
|
367
|
+
fin_sequence = _unwrap_tcp_sequence(self.fin_sequence, self.next_sequence)
|
|
368
|
+
if self.next_sequence >= fin_sequence:
|
|
369
|
+
return False
|
|
370
|
+
self.scanner.reset()
|
|
371
|
+
if self.on_gap_reset is not None:
|
|
372
|
+
self.on_gap_reset(fin_sequence)
|
|
373
|
+
self.next_sequence = fin_sequence
|
|
374
|
+
self.gap_started_at = None
|
|
375
|
+
return True
|
|
376
|
+
|
|
373
377
|
def _resume_after_gap(self) -> None:
|
|
374
378
|
if not self.pending:
|
|
375
379
|
return
|
|
@@ -394,6 +398,9 @@ class TCPFlowState:
|
|
|
394
398
|
self._commit_unanchored()
|
|
395
399
|
while self.pending:
|
|
396
400
|
self._resume_after_gap()
|
|
401
|
+
# EOF alone cannot prove missing bytes. An observed FIN ahead of the
|
|
402
|
+
# drained stream can, even when shutdown precedes the live gap timer.
|
|
403
|
+
self._resume_after_fin_gap()
|
|
397
404
|
|
|
398
405
|
|
|
399
406
|
class FlowManager:
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Passive Agris balances with explicit profile decoding or required-cap discovery."""
|
|
2
|
+
from ..profiles import AgrisProfileLayout
|
|
3
|
+
from ._calibration_models import AgrisCalibrationError, AgrisCalibrationResult, AgrisProfileUpdate
|
|
4
|
+
from ._capture import AgrisCaptureHealth
|
|
5
|
+
from .async_session import AsyncLiveAgrisSession
|
|
6
|
+
from .models import AgrisBalance, AgrisDetectionError, AgrisDiscoveryOptions, AgrisLayout, AgrisStatus
|
|
7
|
+
from .replay import AgrisReplay, replay_agris
|
|
8
|
+
from .session import LiveAgrisSession
|
|
9
|
+
from .calibration import calibrate_agris_live, calibrate_agris_and_update, update_agris_profile
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AgrisBalance", "AgrisCaptureHealth", "AgrisDetectionError", "AgrisDiscoveryOptions",
|
|
13
|
+
"AgrisLayout", "AgrisReplay", "AgrisStatus", "AsyncLiveAgrisSession",
|
|
14
|
+
"LiveAgrisSession", "replay_agris",
|
|
15
|
+
"AgrisProfileLayout", "AgrisCalibrationError", "AgrisCalibrationResult", "AgrisProfileUpdate",
|
|
16
|
+
"calibrate_agris_live", "calibrate_agris_and_update", "update_agris_profile",
|
|
17
|
+
]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Final Agris calibration evidence and explicit profile-write outcomes."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ..profiles import AgrisProfileLayout
|
|
7
|
+
from ._capture import AgrisCaptureHealth
|
|
8
|
+
from ._validation import validate_expected_maximum_points
|
|
9
|
+
from .models import AgrisDetectionError, AgrisStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AgrisCalibrationError(AgrisDetectionError):
|
|
13
|
+
"""No final, clean discovered layout is available for persistence."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class AgrisCalibrationResult:
|
|
18
|
+
layout: AgrisProfileLayout
|
|
19
|
+
expected_maximum_points: int
|
|
20
|
+
observed_frames: int
|
|
21
|
+
health: AgrisCaptureHealth
|
|
22
|
+
completed_at: str
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
if not isinstance(self.layout, AgrisProfileLayout):
|
|
26
|
+
raise TypeError("layout must be AgrisProfileLayout")
|
|
27
|
+
self.layout.__post_init__()
|
|
28
|
+
validate_expected_maximum_points(self.expected_maximum_points)
|
|
29
|
+
if type(self.observed_frames) is not int or self.observed_frames < 3:
|
|
30
|
+
raise AgrisCalibrationError("Calibration requires at least three observed frames")
|
|
31
|
+
if not isinstance(self.health, AgrisCaptureHealth) or not self.health.capture_is_clean or self.health.cleanup_incomplete:
|
|
32
|
+
raise AgrisCalibrationError("Calibration requires clean, completed capture")
|
|
33
|
+
if not isinstance(self.completed_at, str):
|
|
34
|
+
raise TypeError("completed_at must be an ISO timestamp")
|
|
35
|
+
try:
|
|
36
|
+
if datetime.fromisoformat(self.completed_at).tzinfo is None:
|
|
37
|
+
raise ValueError
|
|
38
|
+
except ValueError as exc:
|
|
39
|
+
raise ValueError("completed_at must be a timezone-aware ISO timestamp") from exc
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict[str, object]:
|
|
42
|
+
return {"layout": self.layout.to_dict(), "expected_maximum_points": self.expected_maximum_points,
|
|
43
|
+
"observed_frames": self.observed_frames, "health": self.health.to_dict(),
|
|
44
|
+
"completed_at": self.completed_at}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class AgrisProfileUpdate:
|
|
49
|
+
path: Path
|
|
50
|
+
written: bool
|
|
51
|
+
backup_path: Path | None
|
|
52
|
+
layout: AgrisProfileLayout
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, object]:
|
|
55
|
+
return {"path": str(self.path), "written": self.written,
|
|
56
|
+
"backup_path": str(self.backup_path) if self.backup_path else None,
|
|
57
|
+
"layout": self.layout.to_dict()}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def final_calibration_result(status: AgrisStatus, health: AgrisCaptureHealth) -> AgrisCalibrationResult | None:
|
|
61
|
+
if status.status != "tracking" or status.balance is None or len(status.candidates) != 1:
|
|
62
|
+
return None
|
|
63
|
+
if not health.capture_is_clean or health.cleanup_incomplete:
|
|
64
|
+
return None
|
|
65
|
+
candidate = status.candidates[0]
|
|
66
|
+
layout = AgrisProfileLayout(opcode=candidate.opcode, message_length=candidate.message_length,
|
|
67
|
+
remaining_offset=candidate.remaining_offset, maximum_offset=candidate.maximum_offset,
|
|
68
|
+
observed_date=datetime.fromtimestamp(status.balance.observed_at, UTC).date().isoformat())
|
|
69
|
+
return AgrisCalibrationResult(layout, status.expected_maximum_points, status.observed_frames,
|
|
70
|
+
health, datetime.now(UTC).isoformat())
|