bdo-toolkit 1.0.4__tar.gz → 1.0.5__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 (129) hide show
  1. {bdo_toolkit-1.0.4/src/bdo_toolkit.egg-info → bdo_toolkit-1.0.5}/PKG-INFO +17 -6
  2. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/README.md +16 -5
  3. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/__init__.py +5 -0
  4. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_version.py +1 -1
  5. bdo_toolkit-1.0.5/src/bdo_toolkit/capture_diagnosis.py +246 -0
  6. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/cli.py +29 -0
  7. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5/src/bdo_toolkit.egg-info}/PKG-INFO +17 -6
  8. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit.egg-info/SOURCES.txt +2 -0
  9. bdo_toolkit-1.0.5/tests/test_capture_diagnosis.py +215 -0
  10. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/LICENSE +0 -0
  11. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/pyproject.toml +0 -0
  12. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/setup.cfg +0 -0
  13. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_async_sessions.py +0 -0
  14. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_async_utils.py +0 -0
  15. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_calibration/__init__.py +0 -0
  16. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_calibration/_constants.py +0 -0
  17. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_calibration/_formatting.py +0 -0
  18. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_calibration/_records.py +0 -0
  19. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_calibration/analysis.py +0 -0
  20. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_calibration/capture.py +0 -0
  21. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_calibration/companions.py +0 -0
  22. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_calibration/live.py +0 -0
  23. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_calibration/models.py +0 -0
  24. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_calibration/observations.py +0 -0
  25. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_calibration/persistence.py +0 -0
  26. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_calibration/progress.py +0 -0
  27. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_calibration/validation.py +0 -0
  28. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_calibration/workflow.py +0 -0
  29. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_capture_backend.py +0 -0
  30. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_capture_options.py +0 -0
  31. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_capture_runtime.py +0 -0
  32. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_deposit_origin.py +0 -0
  33. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_engine.py +0 -0
  34. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_framing.py +0 -0
  35. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_item_state/__init__.py +0 -0
  36. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_item_state/_constants.py +0 -0
  37. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_item_state/_records.py +0 -0
  38. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_item_state/assembly.py +0 -0
  39. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_item_state/formatting.py +0 -0
  40. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_item_state/inventory.py +0 -0
  41. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_item_state/models.py +0 -0
  42. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_item_state/session.py +0 -0
  43. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_item_state/storage.py +0 -0
  44. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_origin/__init__.py +0 -0
  45. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_origin/discovery.py +0 -0
  46. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_origin/manual.py +0 -0
  47. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_origin/models.py +0 -0
  48. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_origin/tracker.py +0 -0
  49. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_profile_io.py +0 -0
  50. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_profile_runtime.py +0 -0
  51. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_protocol.py +0 -0
  52. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_reassembly.py +0 -0
  53. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_record_geometry.py +0 -0
  54. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_specs.py +0 -0
  55. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_storage_destination_validation.py +0 -0
  56. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/_storage_hydration.py +0 -0
  57. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/calibration.py +0 -0
  58. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/capture.py +0 -0
  59. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/character_state.py +0 -0
  60. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/diagnostics.py +0 -0
  61. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/events.py +0 -0
  62. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/filters.py +0 -0
  63. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/item_state.py +0 -0
  64. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/origin_learning.py +0 -0
  65. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/profiles.py +0 -0
  66. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/py.typed +0 -0
  67. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/remote_profiles.py +0 -0
  68. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/solare/__init__.py +0 -0
  69. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/solare/_constants.py +0 -0
  70. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/solare/_detail_learning.py +0 -0
  71. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/solare/_details.py +0 -0
  72. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/solare/_discovery.py +0 -0
  73. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/solare/_live_tracker.py +0 -0
  74. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/solare/_replay_capture.py +0 -0
  75. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/solare/_result.py +0 -0
  76. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/solare/_scanner.py +0 -0
  77. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/solare/_validation.py +0 -0
  78. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/solare/async_session.py +0 -0
  79. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/solare/models.py +0 -0
  80. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/solare/replay.py +0 -0
  81. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/solare/session.py +0 -0
  82. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit/writers.py +0 -0
  83. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit.egg-info/dependency_links.txt +0 -0
  84. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit.egg-info/entry_points.txt +0 -0
  85. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit.egg-info/requires.txt +0 -0
  86. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/src/bdo_toolkit.egg-info/top_level.txt +0 -0
  87. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_async_sessions.py +0 -0
  88. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_calibration.py +0 -0
  89. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_calibration_conveniences.py +0 -0
  90. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_calibration_lifecycle.py +0 -0
  91. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_calibration_persistence.py +0 -0
  92. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_calibration_progress.py +0 -0
  93. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_calibration_retention.py +0 -0
  94. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_capture_backend.py +0 -0
  95. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_capture_replay.py +0 -0
  96. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_capture_runtime.py +0 -0
  97. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_character_state.py +0 -0
  98. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_cli.py +0 -0
  99. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_decoder_diagnostics.py +0 -0
  100. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_deposit_origin.py +0 -0
  101. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_direction.py +0 -0
  102. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_dynamic_storage_calibration_matrix.py +0 -0
  103. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_engine.py +0 -0
  104. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_examples_and_writers.py +0 -0
  105. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_explicit_profile.py +0 -0
  106. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_fixture_catalog.py +0 -0
  107. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_fixture_regressions.py +0 -0
  108. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_framing.py +0 -0
  109. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_initial_load_storage.py +0 -0
  110. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_inventory_snapshots.py +0 -0
  111. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_item_state.py +0 -0
  112. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_live_capture_session.py +0 -0
  113. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_origin_learning.py +0 -0
  114. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_package_layout.py +0 -0
  115. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_profile_validation.py +0 -0
  116. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_reassembly.py +0 -0
  117. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_remote_profiles.py +0 -0
  118. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_solare_api.py +0 -0
  119. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_solare_details.py +0 -0
  120. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_solare_learning.py +0 -0
  121. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_solare_learning_adversarial.py +0 -0
  122. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_solare_real_captures.py +0 -0
  123. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_solare_replay.py +0 -0
  124. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_solare_session.py +0 -0
  125. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_storage_destination_validation.py +0 -0
  126. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_storage_events.py +0 -0
  127. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_storage_hydration_tracker.py +0 -0
  128. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/tests/test_storage_unknown_destination_calibration.py +0 -0
  129. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.5}/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.4
3
+ Version: 1.0.5
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,16 +37,27 @@ 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
47
47
  > captures. It does not send or modify packets, replay traffic to the game,
48
48
  > automate gameplay, inspect process memory, or bypass anti-cheat software.
49
49
 
50
+ ## Live-capture limitations
51
+
52
+ Live capture depends heavily on your network setup and on whether the capture
53
+ backend can see BDO traffic. VPNs and routing services such as ExitLag may require
54
+ manual interface, local IP, and port settings; automatic detection may select the
55
+ wrong connection. Traffic visible only as an encrypted tunnel cannot be decoded.
56
+ Follow [Diagnose capture when automatic selection fails](https://ychwu.github.io/bdo-toolkit/#capture-foundation/diagnose-network)
57
+ to inspect connections on Windows, select grouped capture settings, and verify
58
+ them with game activity. The guide covers VPNs, local proxies, and cases where
59
+ no usable adapter is found. Discovery does not apply settings or verify decoding.
60
+
50
61
  ## Capabilities
51
62
 
52
63
  bdo-toolkit exposes three passive workflows. Each can observe live traffic or
@@ -104,7 +115,7 @@ installed with the Python wheel.
104
115
  | Observe live item activity | [`examples/live_transfer_log.py`](https://github.com/ychwu/bdo-toolkit/blob/main/examples/live_transfer_log.py) |
105
116
  | 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) |
106
117
  | 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) |
107
- | 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) |
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) |
108
119
  | 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) |
109
120
 
110
121
  See the [Examples index](https://ychwu.github.io/bdo-toolkit/#item-examples)
@@ -10,16 +10,27 @@
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
20
20
  > captures. It does not send or modify packets, replay traffic to the game,
21
21
  > automate gameplay, inspect process memory, or bypass anti-cheat software.
22
22
 
23
+ ## Live-capture limitations
24
+
25
+ Live capture depends heavily on your network setup and on whether the capture
26
+ backend can see BDO traffic. VPNs and routing services such as ExitLag may require
27
+ manual interface, local IP, and port settings; automatic detection may select the
28
+ wrong connection. Traffic visible only as an encrypted tunnel cannot be decoded.
29
+ Follow [Diagnose capture when automatic selection fails](https://ychwu.github.io/bdo-toolkit/#capture-foundation/diagnose-network)
30
+ to inspect connections on Windows, select grouped capture settings, and verify
31
+ them with game activity. The guide covers VPNs, local proxies, and cases where
32
+ no usable adapter is found. Discovery does not apply settings or verify decoding.
33
+
23
34
  ## Capabilities
24
35
 
25
36
  bdo-toolkit exposes three passive workflows. Each can observe live traffic or
@@ -77,7 +88,7 @@ installed with the Python wheel.
77
88
  | Observe live item activity | [`examples/live_transfer_log.py`](https://github.com/ychwu/bdo-toolkit/blob/main/examples/live_transfer_log.py) |
78
89
  | 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) |
79
90
  | 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) |
80
- | 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) |
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) |
81
92
  | 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) |
82
93
 
83
94
  See the [Examples index](https://ychwu.github.io/bdo-toolkit/#item-examples)
@@ -3,6 +3,7 @@
3
3
  from ._async_sessions import AsyncCalibrationSession, AsyncLiveCaptureSession
4
4
  from ._capture_options import LiveCaptureOptions, PacketCaptureOptions
5
5
  from ._capture_runtime import CaptureEndpoint
6
+ from .capture_diagnosis import CaptureCandidate, CaptureDiagnosis, CaptureSettingsProposal, diagnose_capture
6
7
  from .capture import (
7
8
  CaptureIntegrityError,
8
9
  LiveCaptureHealth,
@@ -48,6 +49,10 @@ __all__ = [
48
49
  "AsyncLiveCaptureSession",
49
50
  "BDOEvent",
50
51
  "CaptureEndpoint",
52
+ "CaptureCandidate",
53
+ "CaptureDiagnosis",
54
+ "CaptureSettingsProposal",
55
+ "diagnose_capture",
51
56
  "CaptureIntegrityError",
52
57
  "ConsoleEventWriter",
53
58
  "DecoderDiagnostic",
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the BDO Toolkit release version."""
2
2
 
3
- __version__ = "1.0.4"
3
+ __version__ = "1.0.5"
@@ -0,0 +1,246 @@
1
+ """Read-only Windows connection discovery; candidates are not capture validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ import ipaddress
7
+ import json
8
+ import math
9
+ import subprocess
10
+ import sys
11
+ from typing import Any, Literal
12
+
13
+ from ._capture_options import LiveCaptureOptions
14
+ from ._protocol import DEFAULT_SERVER_PORTS
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class CaptureCandidate:
19
+ """One observed game connection mapped to a capture adapter, if available."""
20
+
21
+ process_id: int
22
+ local_ip: str
23
+ local_port: int
24
+ remote_ip: str
25
+ remote_port: int
26
+ interface: str | None
27
+ peer_process: str | None
28
+ kind: Literal["local_proxy", "game_port", "other_tcp"]
29
+ explanation: str
30
+
31
+ def to_live_options(self) -> LiveCaptureOptions:
32
+ """Build explicit options; reject candidates without a matching adapter."""
33
+ if self.interface is None:
34
+ raise ValueError("No matching capture adapter; manual investigation required")
35
+ return LiveCaptureOptions(
36
+ interface=self.interface, local_ip=self.local_ip,
37
+ ports=(self.remote_port,),
38
+ )
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class CaptureSettingsProposal:
43
+ """Compatible connections grouped for one explicit capture configuration."""
44
+
45
+ process_id: int
46
+ interface: str | None
47
+ local_ip: str
48
+ kind: Literal["local_proxy", "game_port", "other_tcp"]
49
+ ports: tuple[int, ...]
50
+ candidates: tuple[CaptureCandidate, ...]
51
+
52
+ def to_live_options(self) -> LiveCaptureOptions:
53
+ """Build settings with every observed port; never starts capture."""
54
+ if self.interface is None:
55
+ raise ValueError("No matching capture adapter; manual investigation required")
56
+ return LiveCaptureOptions(
57
+ interface=self.interface, local_ip=self.local_ip, ports=self.ports,
58
+ )
59
+
60
+ def to_dict(self) -> dict[str, Any]:
61
+ """Return JSON-compatible settings and underlying connection evidence."""
62
+ return {
63
+ "process_id": self.process_id, "interface": self.interface,
64
+ "local_ip": self.local_ip, "kind": self.kind,
65
+ "ports": list(self.ports),
66
+ "candidates": [asdict(candidate) for candidate in self.candidates],
67
+ }
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class CaptureDiagnosis:
72
+ """Snapshot of candidates and limitations; never automatically applied."""
73
+
74
+ status: Literal["candidates", "no_game", "no_connections", "unavailable"]
75
+ candidates: tuple[CaptureCandidate, ...] = ()
76
+ messages: tuple[str, ...] = ()
77
+
78
+ @property
79
+ def proposals(self) -> tuple[CaptureSettingsProposal, ...]:
80
+ """Group by process, adapter, local address, and kind in evidence order.
81
+
82
+ Local proxies additionally match peer address and process name. When
83
+ ownership is unknown, different peer ports remain separate. Candidates
84
+ without matching adapters remain visible but cannot produce options.
85
+ """
86
+ groups: dict[tuple[object, ...], list[CaptureCandidate]] = {}
87
+ for candidate in self.candidates:
88
+ key: tuple[object, ...] = (
89
+ candidate.process_id, candidate.interface,
90
+ candidate.local_ip, candidate.kind,
91
+ )
92
+ if candidate.kind == "local_proxy":
93
+ key += (candidate.remote_ip, candidate.peer_process)
94
+ if candidate.peer_process is None:
95
+ key += (candidate.remote_port,)
96
+ groups.setdefault(key, []).append(candidate)
97
+ return tuple(
98
+ CaptureSettingsProposal(
99
+ process_id=group[0].process_id, interface=group[0].interface,
100
+ local_ip=group[0].local_ip, kind=group[0].kind,
101
+ ports=tuple(sorted({candidate.remote_port for candidate in group})),
102
+ candidates=tuple(group),
103
+ )
104
+ for group in groups.values()
105
+ )
106
+
107
+ def to_dict(self) -> dict[str, Any]:
108
+ """Return JSON-compatible diagnostic data, including local endpoints."""
109
+ return {
110
+ "status": self.status,
111
+ "candidates": [asdict(candidate) for candidate in self.candidates],
112
+ "messages": list(self.messages),
113
+ "proposals": [proposal.to_dict() for proposal in self.proposals],
114
+ }
115
+
116
+
117
+ # Fixed script: no user text is interpolated into PowerShell. Read the connection
118
+ # table once, and correlate local proxies by the exact reversed TCP four-tuple.
119
+ _WINDOWS_SNAPSHOT = r"""
120
+ $ErrorActionPreference = 'Stop'
121
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
122
+ $processes = @(Get-Process)
123
+ $games = @($processes | Where-Object { $_.ProcessName -in @('BlackDesert64', 'BlackDesert') })
124
+ $connections = @(Get-NetTCPConnection)
125
+ $rows = @($connections | Where-Object { $_.State -eq 'Established' -and $_.OwningProcess -in $games.Id } | ForEach-Object {
126
+ $connection = $_
127
+ $peer = $connections | Where-Object {
128
+ $_.LocalAddress -eq $connection.RemoteAddress -and $_.LocalPort -eq $connection.RemotePort -and
129
+ $_.RemoteAddress -eq $connection.LocalAddress -and $_.RemotePort -eq $connection.LocalPort
130
+ } | Select-Object -First 1
131
+ $peerName = $null
132
+ if ($null -ne $peer) { $peerName = ($processes | Where-Object { $_.Id -eq $peer.OwningProcess } | Select-Object -First 1).ProcessName }
133
+ @{ process_id = [int]$connection.OwningProcess; local_ip = $connection.LocalAddress;
134
+ local_port = [int]$connection.LocalPort; remote_ip = $connection.RemoteAddress;
135
+ remote_port = [int]$connection.RemotePort; peer_process = $peerName }
136
+ })
137
+ @{ game_count = $games.Count; connections = $rows } | ConvertTo-Json -Depth 4 -Compress
138
+ """
139
+
140
+
141
+ def _windows_snapshot(timeout: float) -> dict[str, Any]:
142
+ result = subprocess.run(
143
+ ["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", _WINDOWS_SNAPSHOT],
144
+ capture_output=True, encoding="utf-8", errors="replace", timeout=timeout,
145
+ # subprocess exposes this constant only on Windows. Discovery already
146
+ # gates this helper by platform; getattr also permits non-Windows typing.
147
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
148
+ )
149
+ if result.returncode:
150
+ raise RuntimeError("Windows connection inspection failed; check permissions and NetTCPIP availability")
151
+ value = json.loads(result.stdout.lstrip("\ufeff"))
152
+ if not isinstance(value, dict) or not isinstance(value.get("connections"), list):
153
+ raise ValueError("Unexpected Windows connection snapshot")
154
+ if type(value.get("game_count")) is not int or value["game_count"] < 0:
155
+ raise ValueError("Unexpected Windows process count")
156
+ for row in value["connections"]:
157
+ if not isinstance(row, dict):
158
+ raise ValueError("Unexpected Windows connection row")
159
+ for name in ("local_ip", "remote_ip"):
160
+ if not isinstance(row.get(name), str):
161
+ raise ValueError("Missing Windows connection address")
162
+ ipaddress.ip_address(row[name])
163
+ for name, maximum in (("process_id", 0xFFFFFFFF), ("local_port", 65535), ("remote_port", 65535)):
164
+ if type(row.get(name)) is not int or not 1 <= row[name] <= maximum:
165
+ raise ValueError("Invalid Windows connection identifier or port")
166
+ if "peer_process" not in row or not isinstance(row["peer_process"], (str, type(None))):
167
+ raise ValueError("Invalid Windows peer process name")
168
+ return value
169
+
170
+
171
+ def _capture_adapters() -> list[tuple[str, tuple[str, ...]]]:
172
+ from scapy.all import conf # type: ignore
173
+
174
+ conf.ifaces.reload()
175
+ return [
176
+ (str(adapter.network_name), tuple(str(ip) for ip in adapter.ips.get(4, [])))
177
+ for adapter in conf.ifaces.values()
178
+ ]
179
+
180
+
181
+ def diagnose_capture(*, timeout: float = 15.0) -> CaptureDiagnosis:
182
+ """Inspect Windows BDO TCP connections and capture interfaces.
183
+
184
+ ``timeout`` bounds the Windows subprocess, not Scapy adapter enumeration.
185
+ Call from a worker in GUI applications. No packets are captured, no profile
186
+ is needed, and no settings are applied. IPv6 connections are reported as a
187
+ limitation. Other platforms return ``unavailable``. Operational inspection
188
+ failures are returned in messages; invalid timeout values raise ValueError.
189
+ Proxy ports and process IDs are valid only for the observed session.
190
+ """
191
+ if isinstance(timeout, bool) or not math.isfinite(timeout) or timeout <= 0:
192
+ raise ValueError("timeout must be finite and greater than zero")
193
+ if sys.platform != "win32":
194
+ return CaptureDiagnosis("unavailable", messages=("Connection discovery currently supports Windows only.",))
195
+ try:
196
+ snapshot = _windows_snapshot(timeout)
197
+ except subprocess.TimeoutExpired:
198
+ return CaptureDiagnosis("unavailable", messages=(
199
+ f"Windows connection inspection timed out after {timeout:g} seconds; retry or increase timeout.",
200
+ ))
201
+ except (OSError, RuntimeError, ValueError) as exc:
202
+ return CaptureDiagnosis("unavailable", messages=(f"Could not inspect BDO connections: {exc}",))
203
+ if not snapshot["game_count"]:
204
+ return CaptureDiagnosis("no_game", messages=("Start Black Desert, enter the game, and retry.",))
205
+ messages = ["Candidates are unverified: test with known in-game activity. No settings were changed."]
206
+ try:
207
+ adapters = _capture_adapters()
208
+ except Exception as exc:
209
+ adapters = []
210
+ messages.append(f"Could not enumerate capture adapters: {exc}")
211
+ candidates: list[CaptureCandidate] = []
212
+ for row in snapshot["connections"]:
213
+ local = ipaddress.ip_address(row["local_ip"])
214
+ remote = ipaddress.ip_address(row["remote_ip"])
215
+ if local.version != 4 or remote.version != 4:
216
+ messages.append("An established BDO IPv6 connection was skipped: capture decoding supports IPv4 only.")
217
+ continue
218
+ interfaces = sorted({
219
+ name for name, ips in adapters
220
+ if str(local) in ips or (local.is_loopback and name == r"\Device\NPF_Loopback")
221
+ })
222
+ kind: Literal["local_proxy", "game_port", "other_tcp"]
223
+ if local.is_loopback and remote.is_loopback:
224
+ kind = "local_proxy"
225
+ explanation = "BDO connects through a local TCP peer; capture loopback using the peer port. Recheck after reconnecting."
226
+ elif row["remote_port"] in DEFAULT_SERVER_PORTS:
227
+ kind = "game_port"
228
+ explanation = "Connection uses a default BDO game port; verify traffic is visible on this adapter."
229
+ else:
230
+ kind = "other_tcp"
231
+ explanation = "Nonstandard TCP peer; may be web, authentication, or other traffic rather than gameplay."
232
+ if not interfaces:
233
+ explanation += " No capture adapter matches the local IPv4 address."
234
+ if len(interfaces) > 1:
235
+ explanation += " Multiple adapters match; test each candidate explicitly."
236
+ matches: list[str | None] = list(interfaces) if interfaces else [None]
237
+ for interface in matches:
238
+ candidates.append(CaptureCandidate(
239
+ process_id=row["process_id"], local_ip=str(local), local_port=row["local_port"],
240
+ remote_ip=str(remote), remote_port=row["remote_port"], interface=interface,
241
+ peer_process=row["peer_process"], kind=kind, explanation=explanation,
242
+ ))
243
+ candidates.sort(key=lambda c: ({"local_proxy": 0, "game_port": 1, "other_tcp": 2}[c.kind], c.process_id, c.local_port, c.interface or ""))
244
+ if not candidates:
245
+ messages.append("No established IPv4 TCP candidates found. Enter a game server and retry.")
246
+ return CaptureDiagnosis("candidates" if candidates else "no_connections", tuple(candidates), tuple(dict.fromkeys(messages)))
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import json
6
7
  import math
7
8
  import sys
8
9
  from pathlib import Path
@@ -11,6 +12,7 @@ from typing import Optional
11
12
  from . import __version__
12
13
  from ._capture_options import LiveCaptureOptions, PacketCaptureOptions
13
14
  from .capture import capture_live, replay_pcap
15
+ from .capture_diagnosis import diagnose_capture
14
16
  from .diagnostics import DecoderDiagnostic
15
17
  from ._protocol import DEFAULT_SERVER_PORTS
16
18
  from .calibration import (
@@ -544,6 +546,27 @@ def _run_origin_promote(args: argparse.Namespace) -> int:
544
546
  return 0
545
547
 
546
548
 
549
+ def _run_diagnose_capture(args: argparse.Namespace) -> int:
550
+ result = diagnose_capture(timeout=args.timeout)
551
+ if args.json:
552
+ print(json.dumps(result.to_dict(), indent=2))
553
+ else:
554
+ print(f"Capture discovery: {result.status}")
555
+ for message in result.messages:
556
+ print(message)
557
+ for number, proposal in enumerate(result.proposals, 1):
558
+ print(f"\n{number}. {proposal.kind} (BDO PID {proposal.process_id})")
559
+ print(f" Interface: {proposal.interface or '(no matching adapter)'}")
560
+ print(f" Local IP: {proposal.local_ip}; ports: {','.join(map(str, proposal.ports))}")
561
+ for explanation in dict.fromkeys(c.explanation for c in proposal.candidates):
562
+ print(f" {explanation}")
563
+ print(f" Connections: {len(proposal.candidates)}")
564
+ for candidate in proposal.candidates:
565
+ peer = f" ({candidate.peer_process})" if candidate.peer_process else ""
566
+ print(f" {candidate.local_ip}:{candidate.local_port} -> {candidate.remote_ip}:{candidate.remote_port}{peer}")
567
+ return 2 if result.status == "unavailable" else 0
568
+
569
+
547
570
  def build_parser() -> argparse.ArgumentParser:
548
571
  parser = argparse.ArgumentParser(
549
572
  prog="bdo-toolkit",
@@ -557,6 +580,12 @@ def build_parser() -> argparse.ArgumentParser:
557
580
  "--version", action="version", version=f"%(prog)s {__version__}"
558
581
  )
559
582
  subparsers = parser.add_subparsers(dest="command", required=True)
583
+ diagnose = subparsers.add_parser(
584
+ "diagnose-capture", help="inspect Windows BDO connections and suggest capture settings",
585
+ )
586
+ diagnose.add_argument("--json", action="store_true", help="print structured JSON")
587
+ diagnose.add_argument("--timeout", type=_positive_float, default=15.0, help="Windows inspection timeout in seconds (default: 15)")
588
+ diagnose.set_defaults(func=_run_diagnose_capture)
560
589
 
561
590
  profile = subparsers.add_parser(
562
591
  "profile",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bdo-toolkit
3
- Version: 1.0.4
3
+ Version: 1.0.5
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,16 +37,27 @@ 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
47
47
  > captures. It does not send or modify packets, replay traffic to the game,
48
48
  > automate gameplay, inspect process memory, or bypass anti-cheat software.
49
49
 
50
+ ## Live-capture limitations
51
+
52
+ Live capture depends heavily on your network setup and on whether the capture
53
+ backend can see BDO traffic. VPNs and routing services such as ExitLag may require
54
+ manual interface, local IP, and port settings; automatic detection may select the
55
+ wrong connection. Traffic visible only as an encrypted tunnel cannot be decoded.
56
+ Follow [Diagnose capture when automatic selection fails](https://ychwu.github.io/bdo-toolkit/#capture-foundation/diagnose-network)
57
+ to inspect connections on Windows, select grouped capture settings, and verify
58
+ them with game activity. The guide covers VPNs, local proxies, and cases where
59
+ no usable adapter is found. Discovery does not apply settings or verify decoding.
60
+
50
61
  ## Capabilities
51
62
 
52
63
  bdo-toolkit exposes three passive workflows. Each can observe live traffic or
@@ -104,7 +115,7 @@ installed with the Python wheel.
104
115
  | Observe live item activity | [`examples/live_transfer_log.py`](https://github.com/ychwu/bdo-toolkit/blob/main/examples/live_transfer_log.py) |
105
116
  | 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) |
106
117
  | 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) |
107
- | 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) |
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) |
108
119
  | 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) |
109
120
 
110
121
  See the [Examples index](https://ychwu.github.io/bdo-toolkit/#item-examples)
@@ -21,6 +21,7 @@ src/bdo_toolkit/_storage_hydration.py
21
21
  src/bdo_toolkit/_version.py
22
22
  src/bdo_toolkit/calibration.py
23
23
  src/bdo_toolkit/capture.py
24
+ src/bdo_toolkit/capture_diagnosis.py
24
25
  src/bdo_toolkit/character_state.py
25
26
  src/bdo_toolkit/cli.py
26
27
  src/bdo_toolkit/diagnostics.py
@@ -88,6 +89,7 @@ tests/test_calibration_persistence.py
88
89
  tests/test_calibration_progress.py
89
90
  tests/test_calibration_retention.py
90
91
  tests/test_capture_backend.py
92
+ tests/test_capture_diagnosis.py
91
93
  tests/test_capture_replay.py
92
94
  tests/test_capture_runtime.py
93
95
  tests/test_character_state.py
@@ -0,0 +1,215 @@
1
+ """Synthetic network discovery: no game, capture driver, or network required."""
2
+
3
+ import json
4
+ import subprocess
5
+
6
+ import pytest
7
+
8
+ from bdo_toolkit import CaptureDiagnosis, diagnose_capture, cli
9
+ from bdo_toolkit import capture_diagnosis as diagnosis
10
+
11
+
12
+ def connection(**changes):
13
+ row = dict(process_id=12, local_ip="192.0.2.2", local_port=50000,
14
+ remote_ip="198.51.100.1", remote_port=8889, peer_process=None)
15
+ row.update(changes)
16
+ return row
17
+
18
+
19
+ @pytest.fixture
20
+ def snapshot(monkeypatch):
21
+ data = {"game_count": 1, "connections": [connection()]}
22
+ monkeypatch.setattr(diagnosis.sys, "platform", "win32")
23
+ monkeypatch.setattr(diagnosis, "_windows_snapshot", lambda timeout: data)
24
+ monkeypatch.setattr(diagnosis, "_capture_adapters", lambda: [
25
+ ("ethernet", ("192.0.2.2",)), (r"\Device\NPF_Loopback", ("127.0.0.1",)),
26
+ ])
27
+ return data
28
+
29
+
30
+ def test_direct_and_web_connections_are_distinguished(snapshot):
31
+ snapshot["connections"].append(connection(remote_port=443, local_port=50001))
32
+ result = diagnose_capture()
33
+ assert result.status == "candidates"
34
+ assert [c.kind for c in result.candidates] == ["game_port", "other_tcp"]
35
+ options = result.candidates[0].to_live_options()
36
+ assert (options.interface, options.local_ip, options.ports) == ("ethernet", "192.0.2.2", (8889,))
37
+ assert json.loads(json.dumps(result.to_dict()))["status"] == "candidates"
38
+
39
+
40
+ def test_proxy_uses_current_peer_port_and_loopback(snapshot):
41
+ snapshot["connections"] = [connection(local_ip="127.0.0.2", remote_ip="127.0.0.1",
42
+ remote_port=53123, peer_process="ExitLag")]
43
+ candidate, = diagnose_capture().candidates
44
+ assert candidate.kind == "local_proxy"
45
+ assert candidate.peer_process == "ExitLag"
46
+ options = candidate.to_live_options()
47
+ assert options.interface == r"\Device\NPF_Loopback"
48
+ assert options.ports == (53123,)
49
+ assert options.local_ip == "127.0.0.2"
50
+
51
+
52
+ def test_unidentified_proxy_still_has_evidence(snapshot):
53
+ snapshot["connections"] = [connection(local_ip="127.0.0.1", remote_ip="127.0.0.1", remote_port=53000)]
54
+ candidate, = diagnose_capture().candidates
55
+ assert candidate.peer_process is None
56
+ assert candidate.kind == "local_proxy"
57
+
58
+
59
+ def test_missing_adapter_does_not_fall_back_to_default(snapshot, monkeypatch):
60
+ monkeypatch.setattr(diagnosis, "_capture_adapters", lambda: [])
61
+ candidate, = diagnose_capture().candidates
62
+ assert candidate.interface is None
63
+ with pytest.raises(ValueError, match="No matching"):
64
+ candidate.to_live_options()
65
+
66
+
67
+ def test_ambiguous_adapters_are_all_returned(snapshot, monkeypatch):
68
+ monkeypatch.setattr(diagnosis, "_capture_adapters", lambda: [("a", ("192.0.2.2",)), ("b", ("192.0.2.2",))])
69
+ result = diagnose_capture()
70
+ assert [c.interface for c in result.candidates] == ["a", "b"]
71
+ assert all("Multiple adapters" in c.explanation for c in result.candidates)
72
+
73
+
74
+ def test_ipv6_is_not_suggested(snapshot):
75
+ snapshot["connections"] = [connection(local_ip="::1", remote_ip="::1")]
76
+ result = diagnose_capture()
77
+ assert result.status == "no_connections"
78
+ assert not result.candidates
79
+ assert any("IPv6" in m for m in result.messages)
80
+
81
+
82
+ def test_no_game_and_no_connections(snapshot):
83
+ snapshot["game_count"] = 0
84
+ assert diagnose_capture().status == "no_game"
85
+ snapshot.update(game_count=1, connections=[])
86
+ assert diagnose_capture().status == "no_connections"
87
+
88
+
89
+ @pytest.mark.parametrize("error", [OSError("denied"), subprocess.TimeoutExpired("powershell", 1), ValueError("bad JSON")])
90
+ def test_inspection_failure_is_structured(snapshot, monkeypatch, error):
91
+ def fail(timeout):
92
+ raise error
93
+ monkeypatch.setattr(diagnosis, "_windows_snapshot", fail)
94
+ result = diagnose_capture()
95
+ assert result.status == "unavailable"
96
+ assert result.messages
97
+
98
+
99
+ def test_adapter_failure_preserves_connection_evidence(snapshot, monkeypatch):
100
+ def fail():
101
+ raise RuntimeError("Npcap unavailable")
102
+ monkeypatch.setattr(diagnosis, "_capture_adapters", fail)
103
+ result = diagnose_capture()
104
+ assert result.candidates[0].interface is None
105
+ assert any("Npcap unavailable" in m for m in result.messages)
106
+
107
+
108
+ def test_other_platform_does_not_run_powershell(monkeypatch):
109
+ monkeypatch.setattr(diagnosis.sys, "platform", "linux")
110
+ assert diagnose_capture().status == "unavailable"
111
+
112
+
113
+ @pytest.mark.parametrize("timeout", [0, -1, float("inf"), float("nan"), True])
114
+ def test_invalid_timeout(timeout):
115
+ with pytest.raises(ValueError):
116
+ diagnose_capture(timeout=timeout)
117
+
118
+
119
+ def test_cli_json_and_text(snapshot, capsys):
120
+ assert cli.main(["diagnose-capture", "--json"]) == 0
121
+ assert json.loads(capsys.readouterr().out)["candidates"][0]["remote_port"] == 8889
122
+ assert cli.main(["diagnose-capture"]) == 0
123
+ output = capsys.readouterr().out
124
+ assert "Interface: ethernet" in output
125
+ assert "unverified" in output
126
+
127
+
128
+ def test_cli_unavailable_and_timeout_validation(monkeypatch, capsys):
129
+ monkeypatch.setattr(cli, "diagnose_capture", lambda **kw: CaptureDiagnosis("unavailable"))
130
+ assert cli.main(["diagnose-capture", "--json"]) == 2
131
+ assert json.loads(capsys.readouterr().out)["status"] == "unavailable"
132
+ with pytest.raises(SystemExit):
133
+ cli.main(["diagnose-capture", "--timeout", "0"])
134
+
135
+
136
+ def test_windows_collector_uses_fixed_script_timeout_and_hidden_window(monkeypatch):
137
+ monkeypatch.setattr(subprocess, "CREATE_NO_WINDOW", 0x08000000, raising=False)
138
+ def run(argv, **kwargs):
139
+ assert argv[-1] == diagnosis._WINDOWS_SNAPSHOT
140
+ assert kwargs["timeout"] == 3.0
141
+ assert kwargs["creationflags"] == 0x08000000
142
+ assert "shell" not in kwargs
143
+ return subprocess.CompletedProcess(argv, 0, '\ufeff' + json.dumps({"game_count": 1, "connections": [connection()]}))
144
+ monkeypatch.setattr(subprocess, "run", run)
145
+ assert diagnosis._windows_snapshot(3.0)["connections"][0]["remote_port"] == 8889
146
+
147
+
148
+ @pytest.mark.parametrize("payload", ["invalid", "[]", '{"connections":[]}',
149
+ json.dumps({"game_count": 1, "connections": [connection(remote_port=0)]}),
150
+ json.dumps({"game_count": 1, "connections": [connection(local_ip="bad")]}),
151
+ ])
152
+ def test_windows_collector_rejects_malformed_snapshot(monkeypatch, payload):
153
+ monkeypatch.setattr(subprocess, "CREATE_NO_WINDOW", 0x08000000, raising=False)
154
+ monkeypatch.setattr(subprocess, "run", lambda *a, **kw: subprocess.CompletedProcess(a, 0, payload))
155
+ with pytest.raises(ValueError):
156
+ diagnosis._windows_snapshot(1)
157
+
158
+
159
+ def test_windows_collector_failure_does_not_leak_command_output(monkeypatch):
160
+ monkeypatch.setattr(subprocess, "CREATE_NO_WINDOW", 0x08000000, raising=False)
161
+ monkeypatch.setattr(subprocess, "run", lambda *a, **kw: subprocess.CompletedProcess(a, 1, "", "private command output"))
162
+ with pytest.raises(RuntimeError, match="inspection failed") as error:
163
+ diagnosis._windows_snapshot(1)
164
+ assert "private" not in str(error.value)
165
+
166
+
167
+ def test_settings_proposals_merge_game_ports_but_not_web(snapshot):
168
+ snapshot["connections"] = [connection(remote_port=p, local_port=50000+i)
169
+ for i, p in enumerate([8889, 8885, 8884, 443, 443])]
170
+ result = diagnose_capture()
171
+ game, web = result.proposals
172
+ assert game.ports == (8884, 8885, 8889)
173
+ assert game.to_live_options().ports == game.ports
174
+ assert web.kind == "other_tcp" and web.ports == (443,)
175
+ assert len(game.candidates) == 3 and len(web.candidates) == 2
176
+ assert len(result.candidates) == 5
177
+ assert result.to_dict()["proposals"][0]["ports"] == [8884, 8885, 8889]
178
+
179
+
180
+ @pytest.mark.parametrize("change", [dict(process_id=13), dict(local_ip="192.0.2.3"),
181
+ dict(interface="another"), dict(kind="other_tcp")])
182
+ def test_proposal_group_boundaries(change):
183
+ from dataclasses import replace
184
+ from bdo_toolkit import CaptureCandidate
185
+ first = CaptureCandidate(**connection(), interface="ethernet", kind="game_port", explanation="test")
186
+ second = replace(first, **change)
187
+ assert len(CaptureDiagnosis("candidates", (first, second)).proposals) == 2
188
+
189
+
190
+ def test_proxy_grouping_preserves_unknown_ownership_boundaries():
191
+ from dataclasses import replace
192
+ from bdo_toolkit import CaptureCandidate
193
+ first = CaptureCandidate(**connection(local_ip="127.0.0.1", remote_ip="127.0.0.1", peer_process="ExitLag"),
194
+ interface="loopback", kind="local_proxy", explanation="test")
195
+ second = replace(first, remote_port=53000)
196
+ assert len(CaptureDiagnosis("candidates", (first, second)).proposals) == 1
197
+ for other in [replace(second, peer_process="Other"), replace(second, remote_ip="127.0.0.2")]:
198
+ assert len(CaptureDiagnosis("candidates", (first, other)).proposals) == 2
199
+ unknown = (replace(first, peer_process=None), replace(second, peer_process=None))
200
+ assert len(CaptureDiagnosis("candidates", unknown).proposals) == 2
201
+
202
+
203
+ def test_proposals_empty_and_missing_adapter(snapshot, monkeypatch):
204
+ assert CaptureDiagnosis("no_game").proposals == ()
205
+ monkeypatch.setattr(diagnosis, "_capture_adapters", lambda: [])
206
+ with pytest.raises(ValueError, match="No matching"):
207
+ diagnose_capture().proposals[0].to_live_options()
208
+
209
+
210
+ def test_cli_consolidates_ports(snapshot, capsys):
211
+ snapshot["connections"] = [connection(remote_port=port) for port in (8889, 8885, 8884)]
212
+ assert cli.main(["diagnose-capture"]) == 0
213
+ output = capsys.readouterr().out
214
+ assert output.count("Interface:") == 1
215
+ assert "ports: 8884,8885,8889" in output
File without changes
File without changes
File without changes