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.
Files changed (162) hide show
  1. {bdo_toolkit-1.0.5/src/bdo_toolkit.egg-info → bdo_toolkit-1.0.7}/PKG-INFO +11 -9
  2. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/README.md +10 -8
  3. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/__init__.py +4 -1
  4. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/_records.py +36 -1
  5. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_engine.py +18 -2
  6. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/inventory.py +4 -4
  7. bdo_toolkit-1.0.7/src/bdo_toolkit/_observation_session.py +376 -0
  8. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_profile_runtime.py +10 -0
  9. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_reassembly.py +18 -11
  10. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_version.py +1 -1
  11. bdo_toolkit-1.0.7/src/bdo_toolkit/agris/__init__.py +17 -0
  12. bdo_toolkit-1.0.7/src/bdo_toolkit/agris/_calibration_models.py +70 -0
  13. bdo_toolkit-1.0.7/src/bdo_toolkit/agris/_capture.py +87 -0
  14. bdo_toolkit-1.0.7/src/bdo_toolkit/agris/_discovery.py +315 -0
  15. bdo_toolkit-1.0.7/src/bdo_toolkit/agris/_profile.py +17 -0
  16. bdo_toolkit-1.0.7/src/bdo_toolkit/agris/_validation.py +13 -0
  17. bdo_toolkit-1.0.7/src/bdo_toolkit/agris/async_session.py +307 -0
  18. bdo_toolkit-1.0.7/src/bdo_toolkit/agris/calibration.py +116 -0
  19. bdo_toolkit-1.0.7/src/bdo_toolkit/agris/models.py +134 -0
  20. bdo_toolkit-1.0.7/src/bdo_toolkit/agris/replay.py +117 -0
  21. bdo_toolkit-1.0.7/src/bdo_toolkit/agris/session.py +75 -0
  22. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/capture.py +21 -2
  23. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/cli.py +189 -1
  24. bdo_toolkit-1.0.7/src/bdo_toolkit/grind/__init__.py +5 -0
  25. bdo_toolkit-1.0.7/src/bdo_toolkit/grind/_pipeline.py +127 -0
  26. bdo_toolkit-1.0.7/src/bdo_toolkit/grind/models.py +71 -0
  27. bdo_toolkit-1.0.7/src/bdo_toolkit/grind/session.py +54 -0
  28. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/profiles.py +120 -1
  29. bdo_toolkit-1.0.7/src/bdo_toolkit/xp/__init__.py +13 -0
  30. bdo_toolkit-1.0.7/src/bdo_toolkit/xp/_tracker.py +109 -0
  31. bdo_toolkit-1.0.7/src/bdo_toolkit/xp/models.py +77 -0
  32. bdo_toolkit-1.0.7/src/bdo_toolkit/xp/profile.py +54 -0
  33. bdo_toolkit-1.0.7/src/bdo_toolkit/xp/replay.py +104 -0
  34. bdo_toolkit-1.0.7/src/bdo_toolkit/xp/session.py +56 -0
  35. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7/src/bdo_toolkit.egg-info}/PKG-INFO +11 -9
  36. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/SOURCES.txt +34 -1
  37. bdo_toolkit-1.0.7/tests/test_agris_async.py +473 -0
  38. bdo_toolkit-1.0.7/tests/test_agris_cli.py +124 -0
  39. bdo_toolkit-1.0.7/tests/test_agris_discovery.py +382 -0
  40. bdo_toolkit-1.0.7/tests/test_agris_evidence.py +67 -0
  41. bdo_toolkit-1.0.7/tests/test_agris_models.py +98 -0
  42. bdo_toolkit-1.0.7/tests/test_agris_profiles.py +340 -0
  43. bdo_toolkit-1.0.7/tests/test_agris_replay.py +81 -0
  44. bdo_toolkit-1.0.7/tests/test_agris_session.py +305 -0
  45. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_calibration_progress.py +6 -1
  46. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_character_state.py +95 -0
  47. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_direction.py +91 -1
  48. bdo_toolkit-1.0.7/tests/test_grind.py +534 -0
  49. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_reassembly.py +70 -0
  50. bdo_toolkit-1.0.7/tests/test_xp.py +229 -0
  51. bdo_toolkit-1.0.7/tests/test_xp_profiles.py +72 -0
  52. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/LICENSE +0 -0
  53. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/pyproject.toml +0 -0
  54. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/setup.cfg +0 -0
  55. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_async_sessions.py +0 -0
  56. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_async_utils.py +0 -0
  57. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/__init__.py +0 -0
  58. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/_constants.py +0 -0
  59. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/_formatting.py +0 -0
  60. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/analysis.py +0 -0
  61. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/capture.py +0 -0
  62. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/companions.py +0 -0
  63. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/live.py +0 -0
  64. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/models.py +0 -0
  65. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/observations.py +0 -0
  66. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/persistence.py +0 -0
  67. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/progress.py +0 -0
  68. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/validation.py +0 -0
  69. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_calibration/workflow.py +0 -0
  70. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_capture_backend.py +0 -0
  71. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_capture_options.py +0 -0
  72. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_capture_runtime.py +0 -0
  73. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_deposit_origin.py +0 -0
  74. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_framing.py +0 -0
  75. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/__init__.py +0 -0
  76. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/_constants.py +0 -0
  77. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/_records.py +0 -0
  78. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/assembly.py +0 -0
  79. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/formatting.py +0 -0
  80. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/models.py +0 -0
  81. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/session.py +0 -0
  82. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_item_state/storage.py +0 -0
  83. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/__init__.py +0 -0
  84. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/discovery.py +0 -0
  85. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/manual.py +0 -0
  86. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/models.py +0 -0
  87. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_origin/tracker.py +0 -0
  88. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_profile_io.py +0 -0
  89. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_protocol.py +0 -0
  90. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_record_geometry.py +0 -0
  91. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_specs.py +0 -0
  92. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_storage_destination_validation.py +0 -0
  93. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/_storage_hydration.py +0 -0
  94. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/calibration.py +0 -0
  95. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/capture_diagnosis.py +0 -0
  96. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/character_state.py +0 -0
  97. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/diagnostics.py +0 -0
  98. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/events.py +0 -0
  99. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/filters.py +0 -0
  100. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/item_state.py +0 -0
  101. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/origin_learning.py +0 -0
  102. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/py.typed +0 -0
  103. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/remote_profiles.py +0 -0
  104. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/__init__.py +0 -0
  105. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_constants.py +0 -0
  106. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_detail_learning.py +0 -0
  107. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_details.py +0 -0
  108. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_discovery.py +0 -0
  109. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_live_tracker.py +0 -0
  110. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_replay_capture.py +0 -0
  111. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_result.py +0 -0
  112. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_scanner.py +0 -0
  113. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/_validation.py +0 -0
  114. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/async_session.py +0 -0
  115. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/models.py +0 -0
  116. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/replay.py +0 -0
  117. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/solare/session.py +0 -0
  118. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit/writers.py +0 -0
  119. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/dependency_links.txt +0 -0
  120. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/entry_points.txt +0 -0
  121. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/requires.txt +0 -0
  122. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/src/bdo_toolkit.egg-info/top_level.txt +0 -0
  123. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_async_sessions.py +0 -0
  124. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_calibration.py +0 -0
  125. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_calibration_conveniences.py +0 -0
  126. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_calibration_lifecycle.py +0 -0
  127. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_calibration_persistence.py +0 -0
  128. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_calibration_retention.py +0 -0
  129. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_capture_backend.py +0 -0
  130. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_capture_diagnosis.py +0 -0
  131. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_capture_replay.py +0 -0
  132. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_capture_runtime.py +0 -0
  133. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_cli.py +0 -0
  134. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_decoder_diagnostics.py +0 -0
  135. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_deposit_origin.py +0 -0
  136. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_dynamic_storage_calibration_matrix.py +0 -0
  137. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_engine.py +0 -0
  138. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_examples_and_writers.py +0 -0
  139. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_explicit_profile.py +0 -0
  140. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_fixture_catalog.py +0 -0
  141. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_fixture_regressions.py +0 -0
  142. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_framing.py +0 -0
  143. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_initial_load_storage.py +0 -0
  144. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_inventory_snapshots.py +0 -0
  145. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_item_state.py +0 -0
  146. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_live_capture_session.py +0 -0
  147. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_origin_learning.py +0 -0
  148. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_package_layout.py +0 -0
  149. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_profile_validation.py +0 -0
  150. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_remote_profiles.py +0 -0
  151. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_solare_api.py +0 -0
  152. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_solare_details.py +0 -0
  153. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_solare_learning.py +0 -0
  154. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_solare_learning_adversarial.py +0 -0
  155. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_solare_real_captures.py +0 -0
  156. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_solare_replay.py +0 -0
  157. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_solare_session.py +0 -0
  158. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_storage_destination_validation.py +0 -0
  159. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_storage_events.py +0 -0
  160. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_storage_hydration_tracker.py +0 -0
  161. {bdo_toolkit-1.0.5 → bdo_toolkit-1.0.7}/tests/test_storage_unknown_destination_calibration.py +0 -0
  162. {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.5
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 three passive workflows. Each can observe live traffic or
64
- replay a saved PCAP or PCAPNG file.
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
- These workflows include synchronous and
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) · [async](https://github.com/ychwu/bdo-toolkit/blob/main/examples/async_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 three passive workflows. Each can observe live traffic or
37
- replay a saved PCAP or PCAPNG file.
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
- These workflows include synchronous and
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) · [async](https://github.com/ychwu/bdo-toolkit/blob/main/examples/async_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
- return candidates[0][0]
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
- return _TeeScanner(primary, tap, stream_observer)
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 = next(iter(codes))
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(