bdo-toolkit 1.0.4__tar.gz → 1.0.6__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 (144) hide show
  1. {bdo_toolkit-1.0.4/src/bdo_toolkit.egg-info → bdo_toolkit-1.0.6}/PKG-INFO +12 -1
  2. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/README.md +11 -0
  3. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/__init__.py +7 -1
  4. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_calibration/_records.py +36 -1
  5. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_item_state/inventory.py +4 -4
  6. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_version.py +1 -1
  7. bdo_toolkit-1.0.6/src/bdo_toolkit/agris/__init__.py +16 -0
  8. bdo_toolkit-1.0.6/src/bdo_toolkit/agris/_capture.py +87 -0
  9. bdo_toolkit-1.0.6/src/bdo_toolkit/agris/_discovery.py +287 -0
  10. bdo_toolkit-1.0.6/src/bdo_toolkit/agris/_validation.py +13 -0
  11. bdo_toolkit-1.0.6/src/bdo_toolkit/agris/async_session.py +295 -0
  12. bdo_toolkit-1.0.6/src/bdo_toolkit/agris/models.py +134 -0
  13. bdo_toolkit-1.0.6/src/bdo_toolkit/agris/replay.py +112 -0
  14. bdo_toolkit-1.0.6/src/bdo_toolkit/agris/session.py +386 -0
  15. bdo_toolkit-1.0.6/src/bdo_toolkit/capture_diagnosis.py +246 -0
  16. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/cli.py +185 -1
  17. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6/src/bdo_toolkit.egg-info}/PKG-INFO +12 -1
  18. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit.egg-info/SOURCES.txt +17 -0
  19. bdo_toolkit-1.0.6/tests/test_agris_async.py +472 -0
  20. bdo_toolkit-1.0.6/tests/test_agris_cli.py +124 -0
  21. bdo_toolkit-1.0.6/tests/test_agris_discovery.py +382 -0
  22. bdo_toolkit-1.0.6/tests/test_agris_evidence.py +51 -0
  23. bdo_toolkit-1.0.6/tests/test_agris_models.py +98 -0
  24. bdo_toolkit-1.0.6/tests/test_agris_replay.py +81 -0
  25. bdo_toolkit-1.0.6/tests/test_agris_session.py +305 -0
  26. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_calibration_progress.py +6 -1
  27. bdo_toolkit-1.0.6/tests/test_capture_diagnosis.py +215 -0
  28. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_character_state.py +95 -0
  29. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_direction.py +91 -1
  30. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/LICENSE +0 -0
  31. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/pyproject.toml +0 -0
  32. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/setup.cfg +0 -0
  33. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_async_sessions.py +0 -0
  34. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_async_utils.py +0 -0
  35. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_calibration/__init__.py +0 -0
  36. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_calibration/_constants.py +0 -0
  37. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_calibration/_formatting.py +0 -0
  38. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_calibration/analysis.py +0 -0
  39. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_calibration/capture.py +0 -0
  40. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_calibration/companions.py +0 -0
  41. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_calibration/live.py +0 -0
  42. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_calibration/models.py +0 -0
  43. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_calibration/observations.py +0 -0
  44. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_calibration/persistence.py +0 -0
  45. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_calibration/progress.py +0 -0
  46. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_calibration/validation.py +0 -0
  47. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_calibration/workflow.py +0 -0
  48. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_capture_backend.py +0 -0
  49. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_capture_options.py +0 -0
  50. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_capture_runtime.py +0 -0
  51. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_deposit_origin.py +0 -0
  52. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_engine.py +0 -0
  53. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_framing.py +0 -0
  54. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_item_state/__init__.py +0 -0
  55. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_item_state/_constants.py +0 -0
  56. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_item_state/_records.py +0 -0
  57. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_item_state/assembly.py +0 -0
  58. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_item_state/formatting.py +0 -0
  59. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_item_state/models.py +0 -0
  60. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_item_state/session.py +0 -0
  61. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_item_state/storage.py +0 -0
  62. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_origin/__init__.py +0 -0
  63. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_origin/discovery.py +0 -0
  64. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_origin/manual.py +0 -0
  65. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_origin/models.py +0 -0
  66. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_origin/tracker.py +0 -0
  67. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_profile_io.py +0 -0
  68. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_profile_runtime.py +0 -0
  69. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_protocol.py +0 -0
  70. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_reassembly.py +0 -0
  71. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_record_geometry.py +0 -0
  72. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_specs.py +0 -0
  73. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_storage_destination_validation.py +0 -0
  74. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/_storage_hydration.py +0 -0
  75. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/calibration.py +0 -0
  76. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/capture.py +0 -0
  77. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/character_state.py +0 -0
  78. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/diagnostics.py +0 -0
  79. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/events.py +0 -0
  80. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/filters.py +0 -0
  81. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/item_state.py +0 -0
  82. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/origin_learning.py +0 -0
  83. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/profiles.py +0 -0
  84. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/py.typed +0 -0
  85. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/remote_profiles.py +0 -0
  86. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/solare/__init__.py +0 -0
  87. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/solare/_constants.py +0 -0
  88. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/solare/_detail_learning.py +0 -0
  89. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/solare/_details.py +0 -0
  90. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/solare/_discovery.py +0 -0
  91. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/solare/_live_tracker.py +0 -0
  92. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/solare/_replay_capture.py +0 -0
  93. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/solare/_result.py +0 -0
  94. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/solare/_scanner.py +0 -0
  95. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/solare/_validation.py +0 -0
  96. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/solare/async_session.py +0 -0
  97. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/solare/models.py +0 -0
  98. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/solare/replay.py +0 -0
  99. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/solare/session.py +0 -0
  100. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit/writers.py +0 -0
  101. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit.egg-info/dependency_links.txt +0 -0
  102. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit.egg-info/entry_points.txt +0 -0
  103. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit.egg-info/requires.txt +0 -0
  104. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/src/bdo_toolkit.egg-info/top_level.txt +0 -0
  105. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_async_sessions.py +0 -0
  106. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_calibration.py +0 -0
  107. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_calibration_conveniences.py +0 -0
  108. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_calibration_lifecycle.py +0 -0
  109. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_calibration_persistence.py +0 -0
  110. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_calibration_retention.py +0 -0
  111. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_capture_backend.py +0 -0
  112. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_capture_replay.py +0 -0
  113. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_capture_runtime.py +0 -0
  114. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_cli.py +0 -0
  115. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_decoder_diagnostics.py +0 -0
  116. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_deposit_origin.py +0 -0
  117. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_dynamic_storage_calibration_matrix.py +0 -0
  118. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_engine.py +0 -0
  119. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_examples_and_writers.py +0 -0
  120. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_explicit_profile.py +0 -0
  121. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_fixture_catalog.py +0 -0
  122. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_fixture_regressions.py +0 -0
  123. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_framing.py +0 -0
  124. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_initial_load_storage.py +0 -0
  125. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_inventory_snapshots.py +0 -0
  126. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_item_state.py +0 -0
  127. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_live_capture_session.py +0 -0
  128. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_origin_learning.py +0 -0
  129. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_package_layout.py +0 -0
  130. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_profile_validation.py +0 -0
  131. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_reassembly.py +0 -0
  132. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_remote_profiles.py +0 -0
  133. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_solare_api.py +0 -0
  134. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_solare_details.py +0 -0
  135. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_solare_learning.py +0 -0
  136. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_solare_learning_adversarial.py +0 -0
  137. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_solare_real_captures.py +0 -0
  138. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_solare_replay.py +0 -0
  139. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_solare_session.py +0 -0
  140. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_storage_destination_validation.py +0 -0
  141. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_storage_events.py +0 -0
  142. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_storage_hydration_tracker.py +0 -0
  143. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/tests/test_storage_unknown_destination_calibration.py +0 -0
  144. {bdo_toolkit-1.0.4 → bdo_toolkit-1.0.6}/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.6
4
4
  Summary: Passive, read-only BDO packet parsing toolkit for app developers.
5
5
  Author: bdo-toolkit contributors
6
6
  License-Expression: MIT
@@ -47,6 +47,17 @@ traffic into structured, application-ready data.
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
@@ -20,6 +20,17 @@ traffic into structured, application-ready data.
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
@@ -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,
@@ -40,7 +41,7 @@ from .origin_learning import (
40
41
  promote_origin_candidates,
41
42
  )
42
43
  from .writers import ConsoleEventWriter, JsonlEventWriter
43
- from . import solare
44
+ from . import agris, solare
44
45
  from ._version import __version__
45
46
 
46
47
  __all__ = [
@@ -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",
@@ -78,6 +83,7 @@ __all__ = [
78
83
  "StorageLocation",
79
84
  "__version__",
80
85
  "capture_live",
86
+ "agris",
81
87
  "fetch_opcode_profile",
82
88
  "load_opcode_profile",
83
89
  "promote_origin_candidates",
@@ -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
  *,
@@ -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
@@ -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.6"
@@ -0,0 +1,16 @@
1
+ """Experimental passive Agris balances with required-cap cold discovery.
2
+
3
+ No item profile, persisted layout, starting balance, or consumption calculation
4
+ is used. A uniquely matching inferred layout is not universal semantic proof.
5
+ """
6
+ from ._capture import AgrisCaptureHealth
7
+ from .async_session import AsyncLiveAgrisSession
8
+ from .models import AgrisBalance, AgrisDetectionError, AgrisDiscoveryOptions, AgrisLayout, AgrisStatus
9
+ from .replay import AgrisReplay, replay_agris
10
+ from .session import LiveAgrisSession
11
+
12
+ __all__ = [
13
+ "AgrisBalance", "AgrisCaptureHealth", "AgrisDetectionError", "AgrisDiscoveryOptions",
14
+ "AgrisLayout", "AgrisReplay", "AgrisStatus", "AsyncLiveAgrisSession",
15
+ "LiveAgrisSession", "replay_agris",
16
+ ]
@@ -0,0 +1,87 @@
1
+ """Shared Agris transport adapter and immutable capture diagnostics."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import asdict, dataclass
5
+ from typing import Iterable
6
+
7
+ from .._capture_backend import validate_server_ports
8
+ from .._framing import FrameCollectorScanner
9
+ from .._protocol import FlowKey
10
+ from .._reassembly import FlowManager
11
+ from ._discovery import AgrisTracker
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class AgrisCaptureHealth:
16
+ """Loss indicators; unavailable native counters remain None, not zero."""
17
+
18
+ packets_processed: int = 0
19
+ packets_accepted: int = 0
20
+ packet_queue_overflows: int = 0
21
+ event_queue_overflows: int = 0
22
+ tcp_gap_resets: int = 0
23
+ flow_state_evictions: int = 0
24
+ pcap_received: int | None = None
25
+ pcap_dropped: int | None = None
26
+ pcap_interface_dropped: int | None = None
27
+ capture_buffer_bytes: int | None = None
28
+ cleanup_incomplete: bool = False
29
+
30
+ @property
31
+ def capture_is_clean(self) -> bool:
32
+ """No known loss; unknown kernel counters are not proof of completeness."""
33
+ return not (self.packet_queue_overflows or self.event_queue_overflows
34
+ or self.tcp_gap_resets or self.flow_state_evictions
35
+ or self.pcap_dropped or self.pcap_interface_dropped)
36
+
37
+ def to_dict(self) -> dict[str, object]:
38
+ return {**asdict(self), "capture_is_clean": self.capture_is_clean}
39
+
40
+
41
+ class AgrisFlowManager(FlowManager):
42
+ """Generic framing, with no opcode hints or item-profile dependency."""
43
+
44
+ def __init__(self, tracker: AgrisTracker, ports: Iterable[int], *, live: bool = False):
45
+ self.tracker = tracker
46
+ self.finishing = False
47
+ self.clock = 0.0
48
+ self.evictions = 0
49
+ super().__init__(server_ports=validate_server_ports(ports),
50
+ scanner_factory=lambda: FrameCollectorScanner(tracker.observe),
51
+ max_flows=64, max_pending_bytes=4 * 1024 * 1024,
52
+ track_flow_generations=True, defer_gap_timeouts=live,
53
+ on_flow_reset=tracker.gap_reset, on_flow_close=self._closed,
54
+ on_flow_eviction=self._evicted)
55
+
56
+ def _closed(self, flow: FlowKey) -> None:
57
+ if not self.finishing:
58
+ self.tracker.close_flow(flow)
59
+
60
+ def _evicted(self) -> None:
61
+ self.evictions += 1
62
+ self.tracker.invalidate("Agris flow-state limit reached; discovery is incomplete")
63
+
64
+ def process_tcp_segment(self, *, source_ip: str, source_port: int,
65
+ destination_ip: str, destination_port: int, sequence: int,
66
+ payload: bytes, timestamp: float, syn: bool = False,
67
+ rst: bool = False, fin: bool = False) -> None:
68
+ super().process_tcp_segment(source_ip=source_ip, source_port=source_port,
69
+ destination_ip=destination_ip, destination_port=destination_port,
70
+ sequence=sequence, payload=payload, timestamp=timestamp,
71
+ syn=syn, rst=rst, fin=fin)
72
+ if source_port in self.server_ports:
73
+ self.clock = max(self.clock, timestamp)
74
+
75
+ def refresh(self, now: float | None = None) -> None:
76
+ self.clock = max(self.clock, now if now is not None else self.clock)
77
+ self.tracker.refresh(self.clock)
78
+
79
+ def finish(self) -> None:
80
+ # EOF is not a network disconnect. Preserve the final observation, but
81
+ # do not forgive actual gaps exposed by draining buffered segments.
82
+ self.finishing = True
83
+ try:
84
+ super().finish()
85
+ self.refresh()
86
+ finally:
87
+ self.finishing = False
@@ -0,0 +1,287 @@
1
+ """Bounded cold discovery, separate from selected-layout balance decoding.
2
+
3
+ No remembered opcode, message size, field offset, or profile participates in
4
+ identity. A unique capped decreasing uint32LE column remains an inference,
5
+ not proof that an unrelated resource cannot share those characteristics.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from typing import NoReturn
14
+
15
+ from .._protocol import BDOFrame, FlowKey
16
+ from ..events import Flow
17
+ from ._validation import validate_expected_maximum_points
18
+ from .models import (
19
+ AgrisBalance,
20
+ AgrisDetectionError,
21
+ AgrisDiscoveryOptions,
22
+ AgrisDiscoveryState,
23
+ AgrisLayout,
24
+ AgrisStatus,
25
+ )
26
+
27
+
28
+ @dataclass
29
+ class _Column:
30
+ last: int
31
+ distinct: int = 1
32
+
33
+
34
+ @dataclass
35
+ class _Family:
36
+ caps: set[int]
37
+ columns: dict[int, _Column]
38
+ observed_at: float
39
+
40
+
41
+ type _FamilyKey = tuple[FlowKey, int, int, int]
42
+ type _CandidateKey = tuple[_FamilyKey, int, int]
43
+
44
+
45
+ def _public_flow(flow: FlowKey) -> Flow:
46
+ return Flow(flow.source_ip, flow.source_port, flow.destination_ip, flow.destination_port)
47
+
48
+
49
+ class AgrisTracker:
50
+ """Single-owner tracker; no raw frame history or consumption arithmetic.
51
+
52
+ Before selection, a family's surviving columns must be nonincreasing and
53
+ in range from its first observation. Contradicted hypotheses never restart
54
+ midway through that family. After selection, direct decoding permits equal
55
+ and increasing balances while discovery still checks competing hypotheses.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ *,
61
+ expected_maximum_points: int,
62
+ options: AgrisDiscoveryOptions | None = None,
63
+ on_balance: Callable[[AgrisBalance], None] | None = None,
64
+ ) -> None:
65
+ self.expected_maximum_points = validate_expected_maximum_points(expected_maximum_points)
66
+ if options is not None and not isinstance(options, AgrisDiscoveryOptions):
67
+ raise TypeError("options must be AgrisDiscoveryOptions or None")
68
+ if on_balance is not None and not callable(on_balance):
69
+ raise TypeError("on_balance must be callable or None")
70
+ self.options = options if options is not None else AgrisDiscoveryOptions()
71
+ self._on_balance = on_balance
72
+ self._families: dict[_FamilyKey, _Family] = {}
73
+ self._column_count = 0
74
+ self._observed_frames = 0
75
+ self._clock = 0.0
76
+ self._selected: _CandidateKey | None = None
77
+ self._unique: _CandidateKey | None = None
78
+ self._unique_since = 0.0
79
+ self._candidates: tuple[_CandidateKey, ...] = ()
80
+ self._candidates_dirty = False
81
+ self._invalid_reason: str | None = None
82
+ self._latest_balance: AgrisBalance | None = None
83
+ self._selected_version = 0
84
+ self._published_version = -1
85
+ self._tracking = False
86
+
87
+ @property
88
+ def clock(self) -> float:
89
+ """Latest settling clock, distinct from a balance's packet timestamp."""
90
+
91
+ return self._clock
92
+
93
+ def invalidate(self, reason: str) -> NoReturn:
94
+ """Latch the first integrity failure and withdraw current authority."""
95
+
96
+ if self._invalid_reason is None:
97
+ self._invalid_reason = reason
98
+ self._tracking = False
99
+ raise AgrisDetectionError(self._invalid_reason)
100
+
101
+ def observe(self, frame: BDOFrame) -> None:
102
+ if self._invalid_reason is not None:
103
+ raise AgrisDetectionError(self._invalid_reason)
104
+ if not math.isfinite(frame.context.timestamp):
105
+ self.invalidate("A frame has a non-finite observation timestamp.")
106
+ self._observed_frames += 1
107
+ self._clock = max(self._clock, frame.context.timestamp)
108
+ message = frame.message
109
+ if self._selected is not None:
110
+ chosen = self._selected[0]
111
+ if frame.context.flow == chosen[0] and frame.context.flow_generation != chosen[1]:
112
+ self.invalidate("Selected connection epoch changed; restart discovery.")
113
+ if len(message) < 5:
114
+ self.refresh()
115
+ return
116
+ key = (frame.context.flow, frame.context.flow_generation, frame.opcode, len(message))
117
+ if self._selected is not None:
118
+ chosen = self._selected[0]
119
+ if key[:3] == chosen[:3]:
120
+ if key != chosen or frame.flag != 0 or frame.length != len(message):
121
+ self.invalidate("Selected message shape changed; restart discovery.")
122
+ self._decode_selected(frame)
123
+ if frame.flag != 0 or frame.length != len(message):
124
+ self.refresh()
125
+ return
126
+ if key not in self._families:
127
+ self._add_family(key, message, frame.context.timestamp)
128
+ else:
129
+ self._update_family(key, message, frame.context.timestamp)
130
+ self.refresh()
131
+
132
+ def _add_family(self, key: _FamilyKey, message: bytes, observed_at: float) -> None:
133
+ if len(self._families) >= self.options.max_families:
134
+ self.invalidate("Discovery family limit reached; no hypotheses were silently evicted.")
135
+ cap_bytes = self.expected_maximum_points.to_bytes(4, "little")
136
+ caps: set[int] = set()
137
+ offset = message.find(cap_bytes, 5)
138
+ while offset != -1:
139
+ caps.add(offset)
140
+ offset = message.find(cap_bytes, offset + 1)
141
+ columns: dict[int, _Column] = {}
142
+ if caps:
143
+ for offset in range(5, len(message) - 3):
144
+ value = int.from_bytes(message[offset : offset + 4], "little")
145
+ if value <= self.expected_maximum_points:
146
+ if self._column_count + len(columns) >= self.options.max_columns:
147
+ self.invalidate("Discovery column limit reached; no hypotheses were silently evicted.")
148
+ columns[offset] = _Column(value)
149
+ self._column_count += len(columns)
150
+ self._families[key] = _Family(caps, columns, observed_at)
151
+ self._candidates_dirty = True
152
+
153
+ def _update_family(self, key: _FamilyKey, message: bytes, observed_at: float) -> None:
154
+ family = self._families[key]
155
+ family.observed_at = observed_at
156
+ family.caps.intersection_update(
157
+ offset for offset in tuple(family.caps)
158
+ if int.from_bytes(message[offset : offset + 4], "little") == self.expected_maximum_points
159
+ )
160
+ for offset, column in tuple(family.columns.items()):
161
+ value = int.from_bytes(message[offset : offset + 4], "little")
162
+ selected_column = self._selected is not None and self._selected[:2] == (key, offset)
163
+ if not family.caps or value > self.expected_maximum_points or (value > column.last and not selected_column):
164
+ del family.columns[offset]
165
+ self._column_count -= 1
166
+ else:
167
+ # Only comparison counts support inference: never derive spending.
168
+ if value < column.last and column.distinct < self.options.minimum_updates:
169
+ column.distinct += 1
170
+ column.last = value
171
+ self._candidates_dirty = True
172
+
173
+ def _decode_selected(self, frame: BDOFrame) -> None:
174
+ assert self._selected is not None
175
+ _, offset, cap_offset = self._selected
176
+ remaining = int.from_bytes(frame.message[offset : offset + 4], "little")
177
+ maximum = int.from_bytes(frame.message[cap_offset : cap_offset + 4], "little")
178
+ if maximum != self.expected_maximum_points or remaining > maximum:
179
+ self.invalidate("Selected balance or cap is outside the validated layout constraints.")
180
+ self._latest_balance = AgrisBalance(
181
+ remaining_points=remaining,
182
+ maximum_points=maximum,
183
+ observed_at=frame.context.timestamp,
184
+ flow=_public_flow(frame.context.flow),
185
+ connection_epoch=frame.context.flow_generation,
186
+ )
187
+ self._selected_version += 1
188
+
189
+ def _find_candidates(self) -> None:
190
+ if not self._candidates_dirty:
191
+ return
192
+ candidates: list[_CandidateKey] = []
193
+ for key, family in self._families.items():
194
+ for offset, column in family.columns.items():
195
+ if column.distinct < self.options.minimum_updates:
196
+ continue
197
+ for cap_offset in sorted(family.caps):
198
+ if abs(cap_offset - offset) < 4:
199
+ continue
200
+ if len(candidates) >= self.options.max_candidates:
201
+ self.invalidate("Discovery candidate limit reached; uniqueness is unresolved.")
202
+ candidates.append((key, offset, cap_offset))
203
+ self._candidates = tuple(candidates)
204
+ self._candidates_dirty = False
205
+
206
+ def refresh(self, now: float | None = None) -> AgrisStatus:
207
+ """Advance observation time and publish only the newest authorized balance.
208
+
209
+ Live owners must not advance time while accepted packets remain queued.
210
+ Offline owners use capture timestamps, never today's wall clock.
211
+ """
212
+
213
+ if now is not None:
214
+ if isinstance(now, bool) or not isinstance(now, (int, float)):
215
+ raise TypeError("now must be a finite number or None")
216
+ if not math.isfinite(now):
217
+ raise ValueError("now must be finite")
218
+ self._clock = max(self._clock, now)
219
+ if self._invalid_reason is not None:
220
+ return self.snapshot()
221
+ self._find_candidates()
222
+ unique = self._candidates[0] if len(self._candidates) == 1 else None
223
+ if unique != self._unique:
224
+ self._unique = unique
225
+ self._unique_since = self._clock
226
+ was_tracking = self._tracking
227
+ self._tracking = False
228
+ if unique is not None and self._clock - self._unique_since >= self.options.settle_seconds:
229
+ if self._selected is not None and unique != self._selected:
230
+ self.invalidate("A different hypothesis replaced the selected layout; restart discovery.")
231
+ if self._selected is None:
232
+ self._selected = unique
233
+ key, offset, _ = unique
234
+ family = self._families[key]
235
+ self._latest_balance = AgrisBalance(
236
+ remaining_points=family.columns[offset].last,
237
+ maximum_points=self.expected_maximum_points,
238
+ observed_at=family.observed_at,
239
+ flow=_public_flow(key[0]),
240
+ connection_epoch=key[1],
241
+ )
242
+ self._tracking = True
243
+ if self._tracking and (not was_tracking or self._published_version != self._selected_version):
244
+ assert self._latest_balance is not None
245
+ self._published_version = self._selected_version
246
+ if self._on_balance is not None:
247
+ self._on_balance(self._latest_balance)
248
+ return self.snapshot()
249
+
250
+ def snapshot(self) -> AgrisStatus:
251
+ """Read immutable state without advancing time or emitting events."""
252
+
253
+ state: AgrisDiscoveryState
254
+ if self._invalid_reason is not None:
255
+ state = "invalid"
256
+ elif len(self._candidates) > 1:
257
+ state = "ambiguous"
258
+ elif not self._candidates:
259
+ state = "searching"
260
+ elif self._tracking:
261
+ state = "tracking"
262
+ else:
263
+ state = "settling"
264
+ return AgrisStatus(
265
+ status=state,
266
+ expected_maximum_points=self.expected_maximum_points,
267
+ balance=self._latest_balance if state == "tracking" else None,
268
+ candidates=tuple(
269
+ AgrisLayout(key[2], key[3], offset, cap_offset, _public_flow(key[0]), key[1])
270
+ for key, offset, cap_offset in self._candidates
271
+ ),
272
+ observed_frames=self._observed_frames,
273
+ reason=self._invalid_reason,
274
+ )
275
+
276
+ def close_flow(self, flow: FlowKey) -> None:
277
+ if self._selected is not None and self._selected[0][0] == flow:
278
+ self.invalidate("Selected connection ended; restart discovery for a new connection.")
279
+ for key in tuple(self._families):
280
+ if key[0] == flow:
281
+ self._column_count -= len(self._families.pop(key).columns)
282
+ self._candidates_dirty = True
283
+ self.refresh()
284
+
285
+ def gap_reset(self, flow: FlowKey, generation: int, resume_sequence: int) -> None:
286
+ # Reassembly gap recovery need not increment the connection generation.
287
+ self.invalidate("TCP gap detected; restart discovery with an intact capture.")
@@ -0,0 +1,13 @@
1
+ """Validation shared by every Agris acquisition entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def validate_expected_maximum_points(value: object) -> int:
7
+ """Require an explicit, positive uint32 cap; never infer a default."""
8
+
9
+ if isinstance(value, bool) or not isinstance(value, int):
10
+ raise TypeError("expected_maximum_points must be an integer")
11
+ if not 1 <= value <= 0xFFFFFFFF:
12
+ raise ValueError("expected_maximum_points must be between 1 and 4294967295")
13
+ return value