bdo-toolkit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bdo_toolkit/__init__.py +87 -0
- bdo_toolkit/_async_sessions.py +651 -0
- bdo_toolkit/_capture_backend.py +194 -0
- bdo_toolkit/_capture_options.py +68 -0
- bdo_toolkit/_capture_runtime.py +626 -0
- bdo_toolkit/_deposit_origin.py +1599 -0
- bdo_toolkit/_engine.py +327 -0
- bdo_toolkit/_framing.py +904 -0
- bdo_toolkit/_profile_runtime.py +157 -0
- bdo_toolkit/_protocol.py +386 -0
- bdo_toolkit/_reassembly.py +654 -0
- bdo_toolkit/_specs.py +285 -0
- bdo_toolkit/_storage_destination_validation.py +167 -0
- bdo_toolkit/_storage_hydration.py +241 -0
- bdo_toolkit/_version.py +3 -0
- bdo_toolkit/calibration.py +3223 -0
- bdo_toolkit/capture.py +1713 -0
- bdo_toolkit/character_state.py +3506 -0
- bdo_toolkit/cli.py +948 -0
- bdo_toolkit/diagnostics.py +51 -0
- bdo_toolkit/events.py +214 -0
- bdo_toolkit/filters.py +105 -0
- bdo_toolkit/item_state.py +48 -0
- bdo_toolkit/origin_learning.py +779 -0
- bdo_toolkit/profiles.py +370 -0
- bdo_toolkit/py.typed +1 -0
- bdo_toolkit/remote_profiles.py +358 -0
- bdo_toolkit/solare/__init__.py +50 -0
- bdo_toolkit/solare/_constants.py +94 -0
- bdo_toolkit/solare/_detail_learning.py +1437 -0
- bdo_toolkit/solare/_details.py +796 -0
- bdo_toolkit/solare/_discovery.py +1212 -0
- bdo_toolkit/solare/_live_tracker.py +472 -0
- bdo_toolkit/solare/_replay_capture.py +182 -0
- bdo_toolkit/solare/_result.py +441 -0
- bdo_toolkit/solare/_scanner.py +203 -0
- bdo_toolkit/solare/_validation.py +11 -0
- bdo_toolkit/solare/async_session.py +444 -0
- bdo_toolkit/solare/models.py +806 -0
- bdo_toolkit/solare/replay.py +62 -0
- bdo_toolkit/solare/session.py +1051 -0
- bdo_toolkit/writers.py +30 -0
- bdo_toolkit-1.0.0.dist-info/METADATA +143 -0
- bdo_toolkit-1.0.0.dist-info/RECORD +48 -0
- bdo_toolkit-1.0.0.dist-info/WHEEL +5 -0
- bdo_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
- bdo_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
- bdo_toolkit-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Validate every profile shape consumed by the runtime decoder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from ._deposit_origin import DecrementSpec
|
|
10
|
+
from ._protocol import MAX_TARGET_MESSAGE_LENGTH
|
|
11
|
+
from ._specs import LoadedSpecProfile, _parse_opcode, event_specs_from_profile
|
|
12
|
+
from .profiles import (
|
|
13
|
+
OPCODE_PROFILE_SCHEMA_VERSION,
|
|
14
|
+
OpcodeProfile,
|
|
15
|
+
OriginCompanionFamily,
|
|
16
|
+
ProfileError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class RuntimeProfileValidation:
|
|
22
|
+
"""Validated event layouts and non-event companion layouts."""
|
|
23
|
+
|
|
24
|
+
loaded_specs: LoadedSpecProfile
|
|
25
|
+
decrement_specs: tuple[DecrementSpec, ...]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_runtime_profile(profile: OpcodeProfile) -> RuntimeProfileValidation:
|
|
29
|
+
"""Validate one immutable profile as complete runtime authority."""
|
|
30
|
+
|
|
31
|
+
if (
|
|
32
|
+
isinstance(profile.version, bool)
|
|
33
|
+
or not isinstance(profile.version, int)
|
|
34
|
+
or profile.version != OPCODE_PROFILE_SCHEMA_VERSION
|
|
35
|
+
):
|
|
36
|
+
raise ProfileError(
|
|
37
|
+
f"Opcode profile version in {profile.path} must be "
|
|
38
|
+
f"{OPCODE_PROFILE_SCHEMA_VERSION}"
|
|
39
|
+
)
|
|
40
|
+
if profile.active is not True:
|
|
41
|
+
raise ProfileError(f"Opcode profile is inactive: {profile.path}")
|
|
42
|
+
|
|
43
|
+
loaded_specs = event_specs_from_profile(profile)
|
|
44
|
+
decrement_specs = tuple(
|
|
45
|
+
_decrement_spec(profile, index, entry)
|
|
46
|
+
for index, entry in enumerate(
|
|
47
|
+
profile.specs.get("SOURCE_STACK_DECREMENT", ())
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
for index, family in enumerate(profile.origin_companion_families):
|
|
51
|
+
_validate_origin_companion_family(profile, index, family)
|
|
52
|
+
return RuntimeProfileValidation(
|
|
53
|
+
loaded_specs=loaded_specs,
|
|
54
|
+
decrement_specs=decrement_specs,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _decrement_spec(
|
|
59
|
+
profile: OpcodeProfile,
|
|
60
|
+
index: int,
|
|
61
|
+
entry: Mapping[str, object],
|
|
62
|
+
) -> DecrementSpec:
|
|
63
|
+
location = f"SOURCE_STACK_DECREMENT[{index}] in {profile.path}"
|
|
64
|
+
opcode = _parse_opcode(entry.get("opcode"))
|
|
65
|
+
if opcode is None:
|
|
66
|
+
raise ProfileError(f"Invalid {location}: opcode must be a uint16")
|
|
67
|
+
length = _required_int(entry.get("length"), "length", location)
|
|
68
|
+
if not 5 <= length <= MAX_TARGET_MESSAGE_LENGTH:
|
|
69
|
+
raise ProfileError(
|
|
70
|
+
f"Invalid {location}: length must be from 5 to "
|
|
71
|
+
f"{MAX_TARGET_MESSAGE_LENGTH}"
|
|
72
|
+
)
|
|
73
|
+
quantity_offset = _required_int(
|
|
74
|
+
entry.get("quantity_removed_offset"),
|
|
75
|
+
"quantity_removed_offset",
|
|
76
|
+
location,
|
|
77
|
+
)
|
|
78
|
+
source_instance_offset = _optional_int(
|
|
79
|
+
entry.get("source_instance_offset"),
|
|
80
|
+
"source_instance_offset",
|
|
81
|
+
location,
|
|
82
|
+
)
|
|
83
|
+
repeat_stride = _optional_int(
|
|
84
|
+
entry.get("repeat_stride"),
|
|
85
|
+
"repeat_stride",
|
|
86
|
+
location,
|
|
87
|
+
)
|
|
88
|
+
try:
|
|
89
|
+
return DecrementSpec(
|
|
90
|
+
opcode=opcode,
|
|
91
|
+
min_message_length=length,
|
|
92
|
+
quantity_offset=quantity_offset,
|
|
93
|
+
source_instance_offset=source_instance_offset,
|
|
94
|
+
repeat_stride=repeat_stride,
|
|
95
|
+
)
|
|
96
|
+
except ValueError as exc:
|
|
97
|
+
raise ProfileError(f"Invalid {location}: {exc}") from exc
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _validate_origin_companion_family(
|
|
101
|
+
profile: OpcodeProfile,
|
|
102
|
+
index: int,
|
|
103
|
+
family: OriginCompanionFamily,
|
|
104
|
+
) -> None:
|
|
105
|
+
location = f"origin_companion_families[{index}] in {profile.path}"
|
|
106
|
+
if not isinstance(family, OriginCompanionFamily):
|
|
107
|
+
raise ProfileError(f"Invalid {location}: expected OriginCompanionFamily")
|
|
108
|
+
if len(family.companion_opcodes) != 2:
|
|
109
|
+
raise ProfileError(
|
|
110
|
+
f"Invalid {location}: exactly two companion opcodes required"
|
|
111
|
+
)
|
|
112
|
+
opcodes = (family.delta_opcode, *family.companion_opcodes)
|
|
113
|
+
if any(
|
|
114
|
+
isinstance(opcode, bool)
|
|
115
|
+
or not isinstance(opcode, int)
|
|
116
|
+
or not 0 <= opcode <= 0xFFFF
|
|
117
|
+
for opcode in opcodes
|
|
118
|
+
):
|
|
119
|
+
raise ProfileError(f"Invalid {location}: opcodes must be uint16 values")
|
|
120
|
+
if len(family.companion_lengths) != 2 or any(
|
|
121
|
+
isinstance(length, bool)
|
|
122
|
+
or not isinstance(length, int)
|
|
123
|
+
or not 5 <= length <= 0xFFFF
|
|
124
|
+
for length in family.companion_lengths
|
|
125
|
+
):
|
|
126
|
+
raise ProfileError(
|
|
127
|
+
f"Invalid {location}: exactly two companion lengths from 5 to 65535 required"
|
|
128
|
+
)
|
|
129
|
+
if family.detection != "shared-token-chain-v1":
|
|
130
|
+
raise ProfileError(
|
|
131
|
+
f"Invalid {location}: unsupported companion detection method"
|
|
132
|
+
)
|
|
133
|
+
if (
|
|
134
|
+
isinstance(family.observations, bool)
|
|
135
|
+
or not isinstance(family.observations, int)
|
|
136
|
+
or family.observations <= 0
|
|
137
|
+
):
|
|
138
|
+
raise ProfileError(f"Invalid {location}: observations must be positive")
|
|
139
|
+
if family.promoted_at is not None and not isinstance(family.promoted_at, str):
|
|
140
|
+
raise ProfileError(f"Invalid {location}: promoted_at must be a string or null")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _required_int(value: object, field: str, location: str) -> int:
|
|
144
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
145
|
+
raise ProfileError(
|
|
146
|
+
f"Invalid {location}: {field} must be a non-negative integer"
|
|
147
|
+
)
|
|
148
|
+
return value
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _optional_int(value: object, field: str, location: str) -> Optional[int]:
|
|
152
|
+
if value is None:
|
|
153
|
+
return None
|
|
154
|
+
return _required_int(value, field, location)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
__all__ = ["RuntimeProfileValidation", "validate_runtime_profile"]
|
bdo_toolkit/_protocol.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Core BDO wire-protocol model: constants, event specs, and decoded records.
|
|
2
|
+
|
|
3
|
+
The BDO application-message header observed in captures is:
|
|
4
|
+
|
|
5
|
+
uint16_le message_length
|
|
6
|
+
uint8 flags/unknown
|
|
7
|
+
uint16_le opcode
|
|
8
|
+
|
|
9
|
+
Offsets and labels are provisional observations and may change after a game
|
|
10
|
+
patch. Everything here is read-only protocol knowledge; no packets are ever
|
|
11
|
+
sent or modified.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Callable, Optional
|
|
18
|
+
|
|
19
|
+
DEFAULT_SERVER_PORTS = (8884, 8885, 8889)
|
|
20
|
+
# The frame header carries a uint16 length. The older 4096-byte ceiling
|
|
21
|
+
# silently rejected otherwise-valid storage batches at 18 records
|
|
22
|
+
# (35 + 18 * 226 = 4103 bytes), so accept the full wire range and rely on the
|
|
23
|
+
# structural guards in the scanners to reject false candidates.
|
|
24
|
+
MAX_TARGET_MESSAGE_LENGTH = 0xFFFF
|
|
25
|
+
BASE_ITEM_ID_MASK = 0x00FFFFFF
|
|
26
|
+
MAX_ENHANCEMENT_LEVEL = 20
|
|
27
|
+
MAX_PLAUSIBLE_ITEM_ID = (MAX_ENHANCEMENT_LEVEL << 24) | BASE_ITEM_ID_MASK
|
|
28
|
+
MAX_PENDING_SEGMENTS = 128
|
|
29
|
+
GAP_RESET_SECONDS = 1.5
|
|
30
|
+
DEDUP_HISTORY_LIMIT = 4096
|
|
31
|
+
TCP_SEQUENCE_MODULUS = 1 << 32
|
|
32
|
+
TCP_SEQUENCE_HALF_RANGE = TCP_SEQUENCE_MODULUS >> 1
|
|
33
|
+
LOOT_PREVIEW_SENTINEL_INSTANCE = b"\xff" * 8
|
|
34
|
+
|
|
35
|
+
CHARACTER_LOAD_CONTEXT = b"\x00" * 4
|
|
36
|
+
# Legacy storage-delta signatures that calibration has observed at varying
|
|
37
|
+
# pre-record offsets. The complete location registry below must not be used as
|
|
38
|
+
# a free-form signature list: small town IDs can coincide with ordinary uint32
|
|
39
|
+
# fields elsewhere in a message.
|
|
40
|
+
STORAGE_DELTA_CONTEXTS = tuple(
|
|
41
|
+
storage_id.to_bytes(4, "little") for storage_id in (0x0005, 0x0020, 0x058C)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
ENHANCEMENT_LABELS: dict[int, str] = {
|
|
45
|
+
1: "PRI",
|
|
46
|
+
2: "DUO",
|
|
47
|
+
3: "TRI",
|
|
48
|
+
4: "TET",
|
|
49
|
+
5: "PEN",
|
|
50
|
+
6: "HEX",
|
|
51
|
+
7: "SEP",
|
|
52
|
+
8: "OCT",
|
|
53
|
+
9: "NOV",
|
|
54
|
+
10: "DEC",
|
|
55
|
+
16: "PRI",
|
|
56
|
+
17: "DUO",
|
|
57
|
+
18: "TRI",
|
|
58
|
+
19: "TET",
|
|
59
|
+
20: "PEN",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
SOURCE_CONTEXT_LABELS: dict[bytes, str] = {
|
|
63
|
+
CHARACTER_LOAD_CONTEXT: "Character Load",
|
|
64
|
+
bytes.fromhex("0471ee0e"): "Gathering",
|
|
65
|
+
bytes.fromhex("85fa5745"): "Mob Drop",
|
|
66
|
+
bytes.fromhex("d0f205a3"): "Storage",
|
|
67
|
+
STORAGE_DELTA_CONTEXTS[0]: "Velia",
|
|
68
|
+
STORAGE_DELTA_CONTEXTS[1]: "Heidel",
|
|
69
|
+
STORAGE_DELTA_CONTEXTS[2]: "Yukjo Street",
|
|
70
|
+
bytes.fromhex("43ce1321"): "Central Market",
|
|
71
|
+
bytes.fromhex("89fa09af"): "Black Spirit Safe",
|
|
72
|
+
bytes.fromhex("35bd5d70"): "Challenges",
|
|
73
|
+
bytes.fromhex("ef6b9b51"): "In-Game Mail",
|
|
74
|
+
bytes.fromhex("8f92e3de"): "Box/Bundle",
|
|
75
|
+
bytes.fromhex("8b7c3a13"): "NPC Exchange",
|
|
76
|
+
bytes.fromhex("721f296d"): "NPC Shop",
|
|
77
|
+
bytes.fromhex("56687f25"): "Choose Your Rewards Box",
|
|
78
|
+
bytes.fromhex("52e89da8"): "NPC Sell",
|
|
79
|
+
bytes.fromhex("60260000"): "Event Adventures",
|
|
80
|
+
bytes.fromhex("3e010000"): "Remote Inventory",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class StorageLocation:
|
|
86
|
+
"""Best-known name for a numeric storage destination key.
|
|
87
|
+
|
|
88
|
+
``confidence`` distinguishes names directly proven by a unique occupied
|
|
89
|
+
record count or controlled storage action from names inferred within
|
|
90
|
+
equal-count groups, and names of empty destinations that are still
|
|
91
|
+
provisional. The key itself is the durable protocol value; applications
|
|
92
|
+
should not treat a provisional name as an identity.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
name: str
|
|
96
|
+
confidence: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Numeric little-endian town/storage keys observed in the 2026-07-17 initial
|
|
100
|
+
# game-load storage snapshot. Some older profiles placed the field at another
|
|
101
|
+
# offset, but captures confirm that it was still the destination key.
|
|
102
|
+
STORAGE_LOCATIONS: dict[int, StorageLocation] = {
|
|
103
|
+
# Direct observations from unique occupied-record counts or controlled
|
|
104
|
+
# storage actions.
|
|
105
|
+
0x0005: StorageLocation("Velia", "observed"),
|
|
106
|
+
0x0020: StorageLocation("Heidel", "observed"),
|
|
107
|
+
0x0034: StorageLocation("Glish", "observed"),
|
|
108
|
+
0x004D: StorageLocation("Calpheon City", "observed"),
|
|
109
|
+
0x0058: StorageLocation("Olvia", "observed"),
|
|
110
|
+
0x0078: StorageLocation("Port Epheria", "observed"),
|
|
111
|
+
0x00CA: StorageLocation("Altinova", "observed"),
|
|
112
|
+
0x00DA: StorageLocation("Asparkan", "observed"),
|
|
113
|
+
0x00DD: StorageLocation("Tarif", "observed"),
|
|
114
|
+
0x025D: StorageLocation("Sand Grain Bazaar", "observed"),
|
|
115
|
+
0x02B5: StorageLocation("Arehaza", "observed"),
|
|
116
|
+
0x02C2: StorageLocation("Old Wisdom Tree", "observed"),
|
|
117
|
+
0x0369: StorageLocation("Duvencrune", "observed"),
|
|
118
|
+
0x03BB: StorageLocation("O'draxxia", "observed"),
|
|
119
|
+
0x03E8: StorageLocation("Oquilla's Eye", "observed"),
|
|
120
|
+
0x0464: StorageLocation("Eilton", "observed"),
|
|
121
|
+
0x04C3: StorageLocation("Nampo's Moodle Village", "observed"),
|
|
122
|
+
0x04DE: StorageLocation("Nopsae's Byeot County", "observed"),
|
|
123
|
+
0x055F: StorageLocation("Muzgar", "observed"),
|
|
124
|
+
0x0566: StorageLocation("Velandir", "observed"),
|
|
125
|
+
0x058C: StorageLocation("Yukjo Street", "observed"),
|
|
126
|
+
# Confirmed by controlled manual deposits on 2026-07-17. These wrappers
|
|
127
|
+
# carried the expected numeric destination key and normalized town name.
|
|
128
|
+
0x0590: StorageLocation("Godu Village", "observed"),
|
|
129
|
+
0x05A4: StorageLocation("Bukpo", "observed"),
|
|
130
|
+
# Confirmed by the operator-labeled 2026-08-13 Angavu Outpost character
|
|
131
|
+
# hydration capture. The newly observed destination key carried the one
|
|
132
|
+
# occupied stack deliberately left in that storage.
|
|
133
|
+
0x06C5: StorageLocation("Angavu Outpost", "observed"),
|
|
134
|
+
# The capture fixes each key to an equal-count group; the exact name in
|
|
135
|
+
# each group follows the established region-key ordering and is inferred.
|
|
136
|
+
0x006B: StorageLocation("Keplan", "inferred"),
|
|
137
|
+
0x007E: StorageLocation("Trent", "inferred"),
|
|
138
|
+
0x00B6: StorageLocation("Iliya Island", "inferred"),
|
|
139
|
+
0x00E5: StorageLocation("Shakatu", "inferred"),
|
|
140
|
+
0x0259: StorageLocation("Valencia City", "inferred"),
|
|
141
|
+
0x026B: StorageLocation("Ancado Inner Harbor", "inferred"),
|
|
142
|
+
0x02DF: StorageLocation("Grana", "inferred"),
|
|
143
|
+
0x04BA: StorageLocation("Dalbeol Village", "inferred"),
|
|
144
|
+
# Empty messages carry no item content with which to prove the name.
|
|
145
|
+
0x02B6: StorageLocation("Muiquun", "probable"),
|
|
146
|
+
0x0611: StorageLocation("Hakinza Sanctuary", "probable"),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def storage_location(storage_id: Optional[int]) -> Optional[StorageLocation]:
|
|
151
|
+
"""Return the best-known location metadata for a storage key."""
|
|
152
|
+
|
|
153
|
+
if storage_id is None:
|
|
154
|
+
return None
|
|
155
|
+
return STORAGE_LOCATIONS.get(storage_id)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def storage_destination_candidates(
|
|
159
|
+
message: bytes,
|
|
160
|
+
*,
|
|
161
|
+
before_offset: int,
|
|
162
|
+
) -> tuple[tuple[int, int], ...]:
|
|
163
|
+
"""Return registered four-byte destination keys in a wrapper prefix.
|
|
164
|
+
|
|
165
|
+
The destination field has moved to unrelated absolute and item-relative
|
|
166
|
+
positions across every observed storage generation. Its stable wire
|
|
167
|
+
invariant is the little-endian numeric key itself, so callers discover
|
|
168
|
+
candidates across the validated prefix and resolve ambiguity with either
|
|
169
|
+
calibration evidence or cross-frame field consistency.
|
|
170
|
+
|
|
171
|
+
This helper deliberately does not choose a candidate. A true ``0x05xx``
|
|
172
|
+
destination can overlap a false Velia key one byte later, so selecting the
|
|
173
|
+
first, last, or closest match would silently assign the wrong town.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
if before_offset <= 5:
|
|
177
|
+
return ()
|
|
178
|
+
search_end = min(before_offset, len(message))
|
|
179
|
+
return tuple(
|
|
180
|
+
(offset, storage_id)
|
|
181
|
+
for offset in range(5, max(5, search_end - 3))
|
|
182
|
+
if (
|
|
183
|
+
storage_id := int.from_bytes(
|
|
184
|
+
message[offset : offset + 4],
|
|
185
|
+
"little",
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
in STORAGE_LOCATIONS
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True)
|
|
193
|
+
class EventSpec:
|
|
194
|
+
label: str
|
|
195
|
+
opcode: int
|
|
196
|
+
item_offset: int
|
|
197
|
+
quantity_offset: int
|
|
198
|
+
min_message_length: int
|
|
199
|
+
inventory_slot_offset: Optional[int] = None
|
|
200
|
+
source_context_offset: Optional[int] = None
|
|
201
|
+
source_context_length: int = 4
|
|
202
|
+
record_count_offset: Optional[int] = None
|
|
203
|
+
item_instance_offset: Optional[int] = None
|
|
204
|
+
storage_instance_offset: Optional[int] = None
|
|
205
|
+
repeat_stride: Optional[int] = None
|
|
206
|
+
single_record_message_length: Optional[int] = None
|
|
207
|
+
default_context: Optional[str] = None
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def signature(self) -> bytes:
|
|
211
|
+
# Header bytes at message offsets 2..4: unknown/flags byte + opcode LE.
|
|
212
|
+
return b"\x00" + self.opcode.to_bytes(2, "little")
|
|
213
|
+
|
|
214
|
+
def __post_init__(self) -> None:
|
|
215
|
+
if not self.label:
|
|
216
|
+
raise ValueError("event spec label must not be empty")
|
|
217
|
+
if (
|
|
218
|
+
isinstance(self.opcode, bool)
|
|
219
|
+
or not isinstance(self.opcode, int)
|
|
220
|
+
or not 0 <= self.opcode <= 0xFFFF
|
|
221
|
+
):
|
|
222
|
+
raise ValueError(f"{self.label} opcode must be a uint16")
|
|
223
|
+
if self.item_offset < 0:
|
|
224
|
+
raise ValueError(f"{self.label} item_offset must be >= 0")
|
|
225
|
+
if self.quantity_offset < 0:
|
|
226
|
+
raise ValueError(f"{self.label} quantity_offset must be >= 0")
|
|
227
|
+
if not 5 <= self.min_message_length <= MAX_TARGET_MESSAGE_LENGTH:
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"{self.label} min_message_length must be between 5 and "
|
|
230
|
+
f"{MAX_TARGET_MESSAGE_LENGTH}"
|
|
231
|
+
)
|
|
232
|
+
if self.inventory_slot_offset is not None and self.inventory_slot_offset < 0:
|
|
233
|
+
raise ValueError(f"{self.label} inventory_slot_offset must be >= 0")
|
|
234
|
+
if self.source_context_offset is not None:
|
|
235
|
+
if self.source_context_offset < 0:
|
|
236
|
+
raise ValueError(f"{self.label} source_context_offset must be >= 0")
|
|
237
|
+
if self.source_context_length <= 0:
|
|
238
|
+
raise ValueError(f"{self.label} source_context_length must be > 0")
|
|
239
|
+
if self.record_count_offset is not None and self.record_count_offset < 0:
|
|
240
|
+
raise ValueError(f"{self.label} record_count_offset must be >= 0")
|
|
241
|
+
if self.label == "INVENTORY_TO_STORAGE":
|
|
242
|
+
if (
|
|
243
|
+
self.source_context_offset is not None
|
|
244
|
+
and self.source_context_offset + self.source_context_length
|
|
245
|
+
> self.item_offset
|
|
246
|
+
):
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"{self.label} source_context_offset must end before item_offset"
|
|
249
|
+
)
|
|
250
|
+
if (
|
|
251
|
+
self.record_count_offset is not None
|
|
252
|
+
and self.record_count_offset + 2 > self.item_offset
|
|
253
|
+
):
|
|
254
|
+
raise ValueError(
|
|
255
|
+
f"{self.label} record_count_offset must end before item_offset"
|
|
256
|
+
)
|
|
257
|
+
if self.storage_instance_offset is not None and self.storage_instance_offset < 0:
|
|
258
|
+
raise ValueError(f"{self.label} storage_instance_offset must be >= 0")
|
|
259
|
+
if self.item_instance_offset is not None and self.item_instance_offset < 0:
|
|
260
|
+
raise ValueError(f"{self.label} item_instance_offset must be >= 0")
|
|
261
|
+
if self.repeat_stride is not None and self.repeat_stride <= 0:
|
|
262
|
+
raise ValueError(f"{self.label} repeat_stride must be > 0")
|
|
263
|
+
if self.single_record_message_length is not None:
|
|
264
|
+
if (
|
|
265
|
+
isinstance(self.single_record_message_length, bool)
|
|
266
|
+
or not isinstance(self.single_record_message_length, int)
|
|
267
|
+
):
|
|
268
|
+
raise ValueError(
|
|
269
|
+
f"{self.label} single_record_message_length must be an integer"
|
|
270
|
+
)
|
|
271
|
+
if not (
|
|
272
|
+
self.min_message_length
|
|
273
|
+
<= self.single_record_message_length
|
|
274
|
+
<= MAX_TARGET_MESSAGE_LENGTH
|
|
275
|
+
):
|
|
276
|
+
raise ValueError(
|
|
277
|
+
f"{self.label} single_record_message_length must be between "
|
|
278
|
+
"min_message_length and 65535"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass(frozen=True)
|
|
283
|
+
class FlowKey:
|
|
284
|
+
source_ip: str
|
|
285
|
+
source_port: int
|
|
286
|
+
destination_ip: str
|
|
287
|
+
destination_port: int
|
|
288
|
+
|
|
289
|
+
def __str__(self) -> str:
|
|
290
|
+
return (
|
|
291
|
+
f"{self.source_ip}:{self.source_port} -> "
|
|
292
|
+
f"{self.destination_ip}:{self.destination_port}"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclass(frozen=True)
|
|
297
|
+
class PacketContext:
|
|
298
|
+
timestamp: float
|
|
299
|
+
flow: FlowKey
|
|
300
|
+
stream_start: Optional[int] = None
|
|
301
|
+
# A FlowKey identifies a TCP four-tuple, not one connection lifetime.
|
|
302
|
+
# FlowManager assigns a new generation whenever that tuple is opened
|
|
303
|
+
# again so consumers that retain frames cannot merge independent streams.
|
|
304
|
+
flow_generation: int = 0
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@dataclass(frozen=True)
|
|
308
|
+
class LootEvent:
|
|
309
|
+
"""One decoded item record, pre-normalization."""
|
|
310
|
+
|
|
311
|
+
label: str
|
|
312
|
+
opcode: int
|
|
313
|
+
item_id: int
|
|
314
|
+
quantity: int
|
|
315
|
+
inventory_slot: Optional[int]
|
|
316
|
+
source_context_candidate: Optional[bytes]
|
|
317
|
+
item_instance: Optional[bytes]
|
|
318
|
+
storage_instance: Optional[bytes]
|
|
319
|
+
message_length: int
|
|
320
|
+
default_context: Optional[str]
|
|
321
|
+
context: PacketContext
|
|
322
|
+
stream_sequence: Optional[int] = None
|
|
323
|
+
record_offset: Optional[int] = None
|
|
324
|
+
record_index: Optional[int] = None
|
|
325
|
+
record_count: Optional[int] = None
|
|
326
|
+
# Storage-family metadata. Normalization can derive ``storage_id`` from the
|
|
327
|
+
# calibration-selected four-byte destination field when a producer leaves
|
|
328
|
+
# the redundant normalized value unset.
|
|
329
|
+
storage_id: Optional[int] = None
|
|
330
|
+
storage_operation: Optional[str] = None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
EventCallback = Callable[[LootEvent, bytes], None]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@dataclass(frozen=True)
|
|
337
|
+
class BDOFrame:
|
|
338
|
+
"""One generic length-framed BDO message, used by calibration."""
|
|
339
|
+
|
|
340
|
+
index: int
|
|
341
|
+
message: bytes
|
|
342
|
+
context: PacketContext
|
|
343
|
+
stream_sequence: Optional[int]
|
|
344
|
+
|
|
345
|
+
@property
|
|
346
|
+
def length(self) -> int:
|
|
347
|
+
return int.from_bytes(self.message[0:2], "little")
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def flag(self) -> int:
|
|
351
|
+
return self.message[2]
|
|
352
|
+
|
|
353
|
+
@property
|
|
354
|
+
def opcode(self) -> int:
|
|
355
|
+
return int.from_bytes(self.message[3:5], "little")
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def payload(self) -> bytes:
|
|
359
|
+
return self.message[5:]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def split_item_id_enhancement(item_id: int) -> tuple[int, Optional[int], Optional[str]]:
|
|
363
|
+
enhancement_level = item_id >> 24
|
|
364
|
+
base_item_id = item_id & BASE_ITEM_ID_MASK
|
|
365
|
+
if 1 <= enhancement_level <= MAX_ENHANCEMENT_LEVEL:
|
|
366
|
+
return base_item_id, enhancement_level, ENHANCEMENT_LABELS.get(enhancement_level)
|
|
367
|
+
return item_id, None, None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def source_label(
|
|
371
|
+
candidate: Optional[bytes],
|
|
372
|
+
default_context: Optional[str] = None,
|
|
373
|
+
) -> Optional[str]:
|
|
374
|
+
"""App-facing source label for a raw context candidate.
|
|
375
|
+
|
|
376
|
+
Known contexts map to their label. The spec default applies only when the
|
|
377
|
+
message carries no context bytes at all; an unrecognized candidate is
|
|
378
|
+
preserved as ``UNKNOWN(0x...)`` so new contexts stay visible to apps
|
|
379
|
+
instead of silently matching an existing source filter.
|
|
380
|
+
"""
|
|
381
|
+
if candidate is None:
|
|
382
|
+
return default_context
|
|
383
|
+
label = SOURCE_CONTEXT_LABELS.get(candidate)
|
|
384
|
+
if label is not None:
|
|
385
|
+
return label
|
|
386
|
+
return f"UNKNOWN(0x{candidate.hex()})"
|