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,3223 @@
|
|
|
1
|
+
"""Opcode profile calibration.
|
|
2
|
+
|
|
3
|
+
After a game patch shifts opcodes or byte offsets, developers can rebuild a
|
|
4
|
+
local opcode profile from a capture of a known in-game action:
|
|
5
|
+
|
|
6
|
+
from bdo_toolkit.calibration import calibrate_pcap, update_profile
|
|
7
|
+
|
|
8
|
+
result = calibrate_pcap(
|
|
9
|
+
"unstackable_1_in_4_in_5_out.pcapng",
|
|
10
|
+
item_id=15156, # replace with the unstackable item used
|
|
11
|
+
quantity=1, # each serialized unstackable record has qty 1
|
|
12
|
+
action="auto",
|
|
13
|
+
)
|
|
14
|
+
update_profile(result, "opcodes.json")
|
|
15
|
+
|
|
16
|
+
Then point the decoding APIs at the local profile:
|
|
17
|
+
|
|
18
|
+
replay_pcap("session.pcapng", opcode_profile="opcodes.json")
|
|
19
|
+
|
|
20
|
+
Storage calibration requires two distinct validated record counts so a moving
|
|
21
|
+
wrapper flag cannot be mistaken for the authoritative count column. Capture,
|
|
22
|
+
for example, a deposit of one matching unstackable followed by a deposit of
|
|
23
|
+
four, then one withdrawal of all five in the same automatic session. The
|
|
24
|
+
single deposit also anchors manual-origin evidence, while the multi deposit and
|
|
25
|
+
withdrawal prove repeated geometry in both directions. The calibration
|
|
26
|
+
session only observes these user-performed actions: ``quantity=1`` remains the
|
|
27
|
+
expected value in every serialized record and is not changed to the action's
|
|
28
|
+
batch size. The calibration heuristics score every frame containing the watched
|
|
29
|
+
item ID and promote only structurally proven layouts.
|
|
30
|
+
|
|
31
|
+
The batch sizes are observed evidence, not API arguments or hard-coded values;
|
|
32
|
+
another valid sequence is deposit one, deposit six, then withdraw seven.
|
|
33
|
+
Repeating the same deposit count does not establish storage count authority.
|
|
34
|
+
``action="auto"`` covers transfer directions only. Loot preview requires a
|
|
35
|
+
separate ``action="loot-preview"`` capture; when its quantity is random, watch
|
|
36
|
+
the known item ID and leave ``quantity=None``.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from collections import deque
|
|
42
|
+
import datetime as dt
|
|
43
|
+
import json
|
|
44
|
+
import math
|
|
45
|
+
import os
|
|
46
|
+
import shutil
|
|
47
|
+
import tempfile
|
|
48
|
+
from dataclasses import dataclass, field, replace
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from threading import Lock, RLock
|
|
51
|
+
from typing import Any, Iterable, Optional
|
|
52
|
+
|
|
53
|
+
from ._capture_backend import (
|
|
54
|
+
make_packet_handler,
|
|
55
|
+
replay_pcap_file,
|
|
56
|
+
validate_server_ports,
|
|
57
|
+
)
|
|
58
|
+
from ._capture_options import PacketCaptureOptions
|
|
59
|
+
from ._capture_runtime import (
|
|
60
|
+
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
|
61
|
+
LivePacketCapture,
|
|
62
|
+
_attach_cleanup_owner,
|
|
63
|
+
)
|
|
64
|
+
from ._framing import FrameCollectorScanner
|
|
65
|
+
from ._protocol import (
|
|
66
|
+
CHARACTER_LOAD_CONTEXT,
|
|
67
|
+
DEFAULT_SERVER_PORTS,
|
|
68
|
+
LOOT_PREVIEW_SENTINEL_INSTANCE,
|
|
69
|
+
MAX_PLAUSIBLE_ITEM_ID,
|
|
70
|
+
SOURCE_CONTEXT_LABELS,
|
|
71
|
+
STORAGE_DELTA_CONTEXTS,
|
|
72
|
+
BDOFrame,
|
|
73
|
+
storage_destination_candidates,
|
|
74
|
+
)
|
|
75
|
+
from ._reassembly import FlowManager
|
|
76
|
+
from ._specs import _validate_loot_profile_entries
|
|
77
|
+
from .profiles import (
|
|
78
|
+
OPCODE_PROFILE_SCHEMA_VERSION,
|
|
79
|
+
ProfileError,
|
|
80
|
+
_validate_profile_entry,
|
|
81
|
+
load_opcode_profile,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
__all__ = [
|
|
85
|
+
"CALIBRATION_ACTIONS",
|
|
86
|
+
"DEFAULT_CALIBRATION_MAX_RETAINED_BYTES",
|
|
87
|
+
"DEFAULT_CALIBRATION_MAX_RETAINED_FRAMES",
|
|
88
|
+
"CalibrationAuthorityError",
|
|
89
|
+
"CalibrationResult",
|
|
90
|
+
"CalibrationRetention",
|
|
91
|
+
"CalibrationSession",
|
|
92
|
+
"DirectionEvidence",
|
|
93
|
+
"DirectionMismatchError",
|
|
94
|
+
"MessageSpec",
|
|
95
|
+
"ProfileError",
|
|
96
|
+
"ProfileUpdate",
|
|
97
|
+
"calibrate_and_update",
|
|
98
|
+
"calibrate_frames",
|
|
99
|
+
"calibrate_live",
|
|
100
|
+
"calibrate_pcap",
|
|
101
|
+
"collect_frames_pcap",
|
|
102
|
+
"detect_transfer_family",
|
|
103
|
+
"reset_profile",
|
|
104
|
+
"update_profile",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
CALIBRATION_ACTIONS = (
|
|
108
|
+
"loot-preview",
|
|
109
|
+
"storage-to-inventory",
|
|
110
|
+
"inventory-to-storage",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Live calibration retains the newest contiguous tail. These defaults cover
|
|
114
|
+
# ordinary short item-transfer workflows by a wide margin while placing a
|
|
115
|
+
# hard ceiling on an accidentally unattended session.
|
|
116
|
+
DEFAULT_CALIBRATION_MAX_RETAINED_FRAMES = 50_000
|
|
117
|
+
DEFAULT_CALIBRATION_MAX_RETAINED_BYTES = 64 * 1024 * 1024
|
|
118
|
+
_CALIBRATION_MAX_ACTIVE_FLOWS = 64
|
|
119
|
+
|
|
120
|
+
OPCODE_PROFILE_EVENTS = (
|
|
121
|
+
"LOOT_PREVIEW",
|
|
122
|
+
"INVENTORY_TRANSFER",
|
|
123
|
+
"SOURCE_CONTAINER_DECREMENT",
|
|
124
|
+
"SOURCE_STACK_DECREMENT",
|
|
125
|
+
"SOURCE_ITEM_REFERENCE",
|
|
126
|
+
"STORAGE_ITEM_DELTA",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class MessageSpec:
|
|
132
|
+
event: str
|
|
133
|
+
opcode: int
|
|
134
|
+
length: Optional[int]
|
|
135
|
+
item_id_offset: Optional[int] = None
|
|
136
|
+
quantity_offset: Optional[int] = None
|
|
137
|
+
item_instance_offset: Optional[int] = None
|
|
138
|
+
context_offset: Optional[int] = None
|
|
139
|
+
record_count_offset: Optional[int] = field(default=None, kw_only=True)
|
|
140
|
+
inventory_slot_offset: Optional[int] = None
|
|
141
|
+
repeat_stride: Optional[int] = None
|
|
142
|
+
source_instance_offset: Optional[int] = None
|
|
143
|
+
quantity_removed_offset: Optional[int] = None
|
|
144
|
+
quantity_added_offset: Optional[int] = None
|
|
145
|
+
destination_instance_offset: Optional[int] = None
|
|
146
|
+
confidence: str = "calibrated"
|
|
147
|
+
source: str = "auto-calibration"
|
|
148
|
+
observed_at: Optional[str] = None
|
|
149
|
+
score: Optional[float] = None
|
|
150
|
+
|
|
151
|
+
def __post_init__(self) -> None:
|
|
152
|
+
if self.event not in OPCODE_PROFILE_EVENTS:
|
|
153
|
+
raise ValueError(f"unknown profile event {self.event!r}")
|
|
154
|
+
if isinstance(self.opcode, bool) or not isinstance(self.opcode, int):
|
|
155
|
+
raise ValueError("opcode must be an integer")
|
|
156
|
+
if not 0 <= self.opcode <= 0xFFFF:
|
|
157
|
+
raise ValueError("opcode must be a uint16")
|
|
158
|
+
if self.length is not None and (
|
|
159
|
+
isinstance(self.length, bool)
|
|
160
|
+
or not isinstance(self.length, int)
|
|
161
|
+
or not 5 <= self.length <= 0xFFFF
|
|
162
|
+
):
|
|
163
|
+
raise ValueError("length must be None or an integer from 5 to 65535")
|
|
164
|
+
for name in (
|
|
165
|
+
"item_id_offset",
|
|
166
|
+
"quantity_offset",
|
|
167
|
+
"item_instance_offset",
|
|
168
|
+
"context_offset",
|
|
169
|
+
"record_count_offset",
|
|
170
|
+
"inventory_slot_offset",
|
|
171
|
+
"source_instance_offset",
|
|
172
|
+
"quantity_removed_offset",
|
|
173
|
+
"quantity_added_offset",
|
|
174
|
+
"destination_instance_offset",
|
|
175
|
+
):
|
|
176
|
+
value = getattr(self, name)
|
|
177
|
+
if value is not None and (
|
|
178
|
+
isinstance(value, bool) or not isinstance(value, int) or value < 0
|
|
179
|
+
):
|
|
180
|
+
raise ValueError(f"{name} must be None or a non-negative integer")
|
|
181
|
+
if self.repeat_stride is not None and (
|
|
182
|
+
isinstance(self.repeat_stride, bool)
|
|
183
|
+
or not isinstance(self.repeat_stride, int)
|
|
184
|
+
or self.repeat_stride <= 0
|
|
185
|
+
):
|
|
186
|
+
raise ValueError("repeat_stride must be None or a positive integer")
|
|
187
|
+
if self.score is not None and (
|
|
188
|
+
isinstance(self.score, bool)
|
|
189
|
+
or not isinstance(self.score, (int, float))
|
|
190
|
+
or not math.isfinite(self.score)
|
|
191
|
+
or not 0 <= self.score <= 1
|
|
192
|
+
):
|
|
193
|
+
raise ValueError("score must be None or a finite number from 0 to 1")
|
|
194
|
+
if self.length is not None:
|
|
195
|
+
field_widths = {
|
|
196
|
+
"item_id_offset": 4,
|
|
197
|
+
"quantity_offset": 4,
|
|
198
|
+
"item_instance_offset": 8,
|
|
199
|
+
"context_offset": 4,
|
|
200
|
+
"record_count_offset": 2,
|
|
201
|
+
"inventory_slot_offset": 1,
|
|
202
|
+
"source_instance_offset": 8,
|
|
203
|
+
"quantity_removed_offset": 4,
|
|
204
|
+
"quantity_added_offset": 4,
|
|
205
|
+
"destination_instance_offset": 8,
|
|
206
|
+
}
|
|
207
|
+
for name, width in field_widths.items():
|
|
208
|
+
value = getattr(self, name)
|
|
209
|
+
if value is not None and value + width > self.length:
|
|
210
|
+
raise ValueError(f"{name} extends beyond the declared length")
|
|
211
|
+
if self.event == "STORAGE_ITEM_DELTA" and self.item_id_offset is not None:
|
|
212
|
+
if (
|
|
213
|
+
self.context_offset is not None
|
|
214
|
+
and self.context_offset + 4 > self.item_id_offset
|
|
215
|
+
):
|
|
216
|
+
raise ValueError("context_offset must end before item_id_offset")
|
|
217
|
+
if (
|
|
218
|
+
self.record_count_offset is not None
|
|
219
|
+
and self.record_count_offset + 2 > self.item_id_offset
|
|
220
|
+
):
|
|
221
|
+
raise ValueError("record_count_offset must end before item_id_offset")
|
|
222
|
+
|
|
223
|
+
def dedupe_key(self) -> tuple[object, ...]:
|
|
224
|
+
return (
|
|
225
|
+
self.event,
|
|
226
|
+
self.opcode,
|
|
227
|
+
self.length,
|
|
228
|
+
self.item_id_offset,
|
|
229
|
+
self.quantity_offset,
|
|
230
|
+
self.item_instance_offset,
|
|
231
|
+
self.context_offset,
|
|
232
|
+
self.record_count_offset,
|
|
233
|
+
self.inventory_slot_offset,
|
|
234
|
+
self.source_instance_offset,
|
|
235
|
+
self.quantity_removed_offset,
|
|
236
|
+
self.quantity_added_offset,
|
|
237
|
+
self.destination_instance_offset,
|
|
238
|
+
self.repeat_stride,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def to_json_dict(self) -> dict[str, object]:
|
|
242
|
+
output: dict[str, object] = {
|
|
243
|
+
"event": self.event,
|
|
244
|
+
"opcode": f"0x{self.opcode:04X}",
|
|
245
|
+
"length": self.length,
|
|
246
|
+
"confidence": self.confidence,
|
|
247
|
+
"source": self.source,
|
|
248
|
+
}
|
|
249
|
+
optional_fields = {
|
|
250
|
+
"item_id_offset": self.item_id_offset,
|
|
251
|
+
"quantity_offset": self.quantity_offset,
|
|
252
|
+
"item_instance_offset": self.item_instance_offset,
|
|
253
|
+
"context_offset": self.context_offset,
|
|
254
|
+
"record_count_offset": self.record_count_offset,
|
|
255
|
+
"inventory_slot_offset": self.inventory_slot_offset,
|
|
256
|
+
"repeat_stride": self.repeat_stride,
|
|
257
|
+
"source_instance_offset": self.source_instance_offset,
|
|
258
|
+
"quantity_removed_offset": self.quantity_removed_offset,
|
|
259
|
+
"quantity_added_offset": self.quantity_added_offset,
|
|
260
|
+
"destination_instance_offset": self.destination_instance_offset,
|
|
261
|
+
"observed_at": self.observed_at,
|
|
262
|
+
"score": round(self.score, 3) if self.score is not None else None,
|
|
263
|
+
}
|
|
264
|
+
for key, value in optional_fields.items():
|
|
265
|
+
if value is not None:
|
|
266
|
+
output[key] = value
|
|
267
|
+
return output
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class DirectionMismatchError(ValueError):
|
|
271
|
+
"""A capture's structure contradicts the explicitly declared action.
|
|
272
|
+
|
|
273
|
+
Raised only in single-direction calibration (``action=`` set to a specific
|
|
274
|
+
transfer). Auto calibration never raises this; it classifies each direction
|
|
275
|
+
from structure and keeps whatever it can confirm.
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class CalibrationAuthorityError(ValueError):
|
|
280
|
+
"""A captured target exists but cannot yield a safe decoder profile."""
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@dataclass(frozen=True)
|
|
284
|
+
class DirectionEvidence:
|
|
285
|
+
"""Why a candidate record was assigned (or not) to a transfer family.
|
|
286
|
+
|
|
287
|
+
``detected_family`` is ``"into_storage"``, ``"into_inventory"``, or ``None``
|
|
288
|
+
when the two structural features disagree or neither fires. See
|
|
289
|
+
:func:`detect_transfer_family`.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
action: str
|
|
293
|
+
opcode: int
|
|
294
|
+
detected_family: Optional[str]
|
|
295
|
+
reference_frame: bool
|
|
296
|
+
context_label: bool
|
|
297
|
+
storage_context: bool = False
|
|
298
|
+
|
|
299
|
+
def to_json_dict(self) -> dict[str, object]:
|
|
300
|
+
return {
|
|
301
|
+
"action": self.action,
|
|
302
|
+
"opcode": f"0x{self.opcode:04X}",
|
|
303
|
+
"detected_family": self.detected_family,
|
|
304
|
+
"reference_frame": self.reference_frame,
|
|
305
|
+
"context_label": self.context_label,
|
|
306
|
+
"storage_context": self.storage_context,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
_FAMILY_LABELS = {
|
|
311
|
+
"into_inventory": "storage->inventory",
|
|
312
|
+
"into_storage": "inventory->storage",
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@dataclass(frozen=True)
|
|
317
|
+
class CalibrationRetention:
|
|
318
|
+
"""Observed-versus-retained live calibration evidence.
|
|
319
|
+
|
|
320
|
+
Live sessions keep the newest contiguous frame tail within both limits.
|
|
321
|
+
``truncated`` therefore means older evidence was intentionally evicted and
|
|
322
|
+
the resulting calibration describes only the retained tail.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
frames_observed: int
|
|
326
|
+
frames_retained: int
|
|
327
|
+
frames_discarded: int
|
|
328
|
+
bytes_observed: Optional[int]
|
|
329
|
+
bytes_retained: Optional[int]
|
|
330
|
+
bytes_discarded: Optional[int]
|
|
331
|
+
max_retained_frames: Optional[int] = None
|
|
332
|
+
max_retained_bytes: Optional[int] = None
|
|
333
|
+
|
|
334
|
+
def __post_init__(self) -> None:
|
|
335
|
+
for name in (
|
|
336
|
+
"frames_observed",
|
|
337
|
+
"frames_retained",
|
|
338
|
+
"frames_discarded",
|
|
339
|
+
):
|
|
340
|
+
value = getattr(self, name)
|
|
341
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
342
|
+
raise ValueError(f"{name} must be a non-negative integer")
|
|
343
|
+
if self.frames_retained + self.frames_discarded != self.frames_observed:
|
|
344
|
+
raise ValueError(
|
|
345
|
+
"retained and discarded frame counts must equal frames_observed"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
byte_values = (
|
|
349
|
+
self.bytes_observed,
|
|
350
|
+
self.bytes_retained,
|
|
351
|
+
self.bytes_discarded,
|
|
352
|
+
)
|
|
353
|
+
if any(value is None for value in byte_values):
|
|
354
|
+
if not all(value is None for value in byte_values):
|
|
355
|
+
raise ValueError("byte retention counters must be all set or all None")
|
|
356
|
+
else:
|
|
357
|
+
for name, value in zip(
|
|
358
|
+
("bytes_observed", "bytes_retained", "bytes_discarded"),
|
|
359
|
+
byte_values,
|
|
360
|
+
):
|
|
361
|
+
if (
|
|
362
|
+
isinstance(value, bool)
|
|
363
|
+
or not isinstance(value, int)
|
|
364
|
+
or value < 0
|
|
365
|
+
):
|
|
366
|
+
raise ValueError(f"{name} must be a non-negative integer")
|
|
367
|
+
assert self.bytes_observed is not None
|
|
368
|
+
assert self.bytes_retained is not None
|
|
369
|
+
assert self.bytes_discarded is not None
|
|
370
|
+
if self.bytes_retained + self.bytes_discarded != self.bytes_observed:
|
|
371
|
+
raise ValueError(
|
|
372
|
+
"retained and discarded byte counts must equal bytes_observed"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
for name in ("max_retained_frames", "max_retained_bytes"):
|
|
376
|
+
value = getattr(self, name)
|
|
377
|
+
if value is not None and (
|
|
378
|
+
isinstance(value, bool)
|
|
379
|
+
or not isinstance(value, int)
|
|
380
|
+
or value <= 0
|
|
381
|
+
):
|
|
382
|
+
raise ValueError(f"{name} must be None or a positive integer")
|
|
383
|
+
if (
|
|
384
|
+
self.max_retained_frames is not None
|
|
385
|
+
and self.frames_retained > self.max_retained_frames
|
|
386
|
+
):
|
|
387
|
+
raise ValueError("frames_retained exceeds max_retained_frames")
|
|
388
|
+
if (
|
|
389
|
+
self.max_retained_bytes is not None
|
|
390
|
+
and self.bytes_retained is not None
|
|
391
|
+
and self.bytes_retained > self.max_retained_bytes
|
|
392
|
+
):
|
|
393
|
+
raise ValueError("bytes_retained exceeds max_retained_bytes")
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def truncated(self) -> bool:
|
|
397
|
+
return self.frames_discarded > 0 or bool(self.bytes_discarded)
|
|
398
|
+
|
|
399
|
+
@property
|
|
400
|
+
def bounded(self) -> bool:
|
|
401
|
+
return (
|
|
402
|
+
self.max_retained_frames is not None
|
|
403
|
+
or self.max_retained_bytes is not None
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
def to_json_dict(self) -> dict[str, object]:
|
|
407
|
+
return {
|
|
408
|
+
"frames_observed": self.frames_observed,
|
|
409
|
+
"frames_retained": self.frames_retained,
|
|
410
|
+
"frames_discarded": self.frames_discarded,
|
|
411
|
+
"bytes_observed": self.bytes_observed,
|
|
412
|
+
"bytes_retained": self.bytes_retained,
|
|
413
|
+
"bytes_discarded": self.bytes_discarded,
|
|
414
|
+
"max_retained_frames": self.max_retained_frames,
|
|
415
|
+
"max_retained_bytes": self.max_retained_bytes,
|
|
416
|
+
"truncated": self.truncated,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@dataclass(frozen=True)
|
|
421
|
+
class CalibrationResult:
|
|
422
|
+
"""Promoted message specs plus diagnostics for rejected candidates.
|
|
423
|
+
|
|
424
|
+
The fields are the raw record; ``events_found`` / ``specs_by_event()`` /
|
|
425
|
+
``summary()`` / ``to_json_dict()`` are pure, read-only views of them.
|
|
426
|
+
There is deliberately no boolean ``ok``: success is per-event (a capture
|
|
427
|
+
can promote STORAGE_ITEM_DELTA yet miss its companion specs), so check
|
|
428
|
+
``events_found`` against the events you need instead.
|
|
429
|
+
"""
|
|
430
|
+
|
|
431
|
+
specs: tuple[MessageSpec, ...]
|
|
432
|
+
ignored: tuple[str, ...]
|
|
433
|
+
frames_scanned: int
|
|
434
|
+
evidence: tuple[DirectionEvidence, ...] = ()
|
|
435
|
+
calibration_item_id: Optional[int] = None
|
|
436
|
+
retention: CalibrationRetention = field(kw_only=True)
|
|
437
|
+
|
|
438
|
+
def __post_init__(self) -> None:
|
|
439
|
+
if not isinstance(self.retention, CalibrationRetention):
|
|
440
|
+
raise TypeError("retention must be a CalibrationRetention")
|
|
441
|
+
if self.frames_scanned != self.retention.frames_retained:
|
|
442
|
+
raise ValueError(
|
|
443
|
+
"frames_scanned must equal retention.frames_retained"
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
@property
|
|
447
|
+
def events_found(self) -> frozenset[str]:
|
|
448
|
+
"""Event names that got at least one promoted spec.
|
|
449
|
+
|
|
450
|
+
Supports readable completeness checks::
|
|
451
|
+
|
|
452
|
+
{"STORAGE_ITEM_DELTA", "SOURCE_STACK_DECREMENT"} <= result.events_found
|
|
453
|
+
"""
|
|
454
|
+
return frozenset(spec.event for spec in self.specs)
|
|
455
|
+
|
|
456
|
+
def specs_by_event(self) -> dict[str, tuple[MessageSpec, ...]]:
|
|
457
|
+
"""Promoted specs grouped by event name.
|
|
458
|
+
|
|
459
|
+
Values are tuples because a capture can promote more than one
|
|
460
|
+
candidate layout for the same event.
|
|
461
|
+
"""
|
|
462
|
+
grouped: dict[str, list[MessageSpec]] = {}
|
|
463
|
+
for spec in self.specs:
|
|
464
|
+
grouped.setdefault(spec.event, []).append(spec)
|
|
465
|
+
return {event: tuple(specs) for event, specs in grouped.items()}
|
|
466
|
+
|
|
467
|
+
def detected_directions(self) -> frozenset[str]:
|
|
468
|
+
"""Transfer directions confirmed by structure, as human-readable labels
|
|
469
|
+
(``"inventory->storage"`` / ``"storage->inventory"``)."""
|
|
470
|
+
return frozenset(
|
|
471
|
+
_FAMILY_LABELS[e.detected_family]
|
|
472
|
+
for e in self.evidence
|
|
473
|
+
if e.detected_family in _FAMILY_LABELS
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
def summary(self) -> str:
|
|
477
|
+
"""Human-readable multi-line report; print or log it as-is."""
|
|
478
|
+
lines = [f"scanned {self.frames_scanned} frames"]
|
|
479
|
+
retention = self.retention
|
|
480
|
+
if retention.bounded:
|
|
481
|
+
status = "truncated" if retention.truncated else "complete"
|
|
482
|
+
lines.append(
|
|
483
|
+
f"live retention {status}: observed {retention.frames_observed}, "
|
|
484
|
+
f"retained {retention.frames_retained}, "
|
|
485
|
+
f"discarded {retention.frames_discarded} frame(s)"
|
|
486
|
+
)
|
|
487
|
+
if self.specs:
|
|
488
|
+
found = ", ".join(
|
|
489
|
+
f"{spec.event} (0x{spec.opcode:04X})" for spec in self.specs
|
|
490
|
+
)
|
|
491
|
+
lines.append(f"promoted {len(self.specs)} spec(s): {found}")
|
|
492
|
+
else:
|
|
493
|
+
lines.append("no message specs promoted")
|
|
494
|
+
directions = self.detected_directions()
|
|
495
|
+
if directions:
|
|
496
|
+
lines.append(f"detected direction(s): {', '.join(sorted(directions))}")
|
|
497
|
+
if self.ignored:
|
|
498
|
+
lines.append(
|
|
499
|
+
f"ignored {len(self.ignored)} candidate(s) (see .ignored for reasons)"
|
|
500
|
+
)
|
|
501
|
+
return "\n".join(lines)
|
|
502
|
+
|
|
503
|
+
def to_json_dict(self) -> dict[str, object]:
|
|
504
|
+
"""The whole result as JSON-ready data — the shape to attach to bug
|
|
505
|
+
reports or logs. Mirrors ``MessageSpec.to_json_dict()`` for specs."""
|
|
506
|
+
return {
|
|
507
|
+
"frames_scanned": self.frames_scanned,
|
|
508
|
+
"calibration_item_id": self.calibration_item_id,
|
|
509
|
+
"retention": self.retention.to_json_dict(),
|
|
510
|
+
"specs": [spec.to_json_dict() for spec in self.specs],
|
|
511
|
+
"ignored": list(self.ignored),
|
|
512
|
+
"evidence": [e.to_json_dict() for e in self.evidence],
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
@dataclass(frozen=True)
|
|
517
|
+
class ProfileUpdate:
|
|
518
|
+
"""Outcome of persisting calibration specs into a profile file."""
|
|
519
|
+
|
|
520
|
+
path: Path
|
|
521
|
+
added: tuple[MessageSpec, ...]
|
|
522
|
+
replaced_events: tuple[str, ...]
|
|
523
|
+
backup_path: Optional[Path]
|
|
524
|
+
written: bool = True
|
|
525
|
+
|
|
526
|
+
def summary(self) -> str:
|
|
527
|
+
"""Human-readable multi-line report; print or log it as-is."""
|
|
528
|
+
if not self.written:
|
|
529
|
+
return (
|
|
530
|
+
f"no new specs added; no profile changes; "
|
|
531
|
+
f"{self.path} was not written"
|
|
532
|
+
)
|
|
533
|
+
lines = [f"wrote {self.path}"]
|
|
534
|
+
if self.backup_path is not None:
|
|
535
|
+
lines.append(f"backup at {self.backup_path}")
|
|
536
|
+
if self.replaced_events:
|
|
537
|
+
lines.append(f"replaced {', '.join(self.replaced_events)}")
|
|
538
|
+
if self.added:
|
|
539
|
+
for spec in self.added:
|
|
540
|
+
lines.append(f"added {spec.event} opcode=0x{spec.opcode:04X}")
|
|
541
|
+
else:
|
|
542
|
+
lines.append("no new specs added (all were already present)")
|
|
543
|
+
return "\n".join(lines)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class _FrameIndex:
|
|
547
|
+
"""One-pass same-flow position index for calibration context lookups."""
|
|
548
|
+
|
|
549
|
+
def __init__(self, frames: list[BDOFrame]) -> None:
|
|
550
|
+
by_flow: dict[tuple[object, int], list[BDOFrame]] = {}
|
|
551
|
+
positions: dict[
|
|
552
|
+
int,
|
|
553
|
+
Optional[tuple[tuple[object, int], int]],
|
|
554
|
+
] = {}
|
|
555
|
+
for frame in frames:
|
|
556
|
+
flow_identity = (
|
|
557
|
+
frame.context.flow,
|
|
558
|
+
frame.context.flow_generation,
|
|
559
|
+
)
|
|
560
|
+
flow_frames = by_flow.setdefault(flow_identity, [])
|
|
561
|
+
identity = id(frame)
|
|
562
|
+
location = (flow_identity, len(flow_frames))
|
|
563
|
+
positions[identity] = (
|
|
564
|
+
location if identity not in positions else None
|
|
565
|
+
)
|
|
566
|
+
flow_frames.append(frame)
|
|
567
|
+
self._by_flow = {
|
|
568
|
+
flow: tuple(flow_frames) for flow, flow_frames in by_flow.items()
|
|
569
|
+
}
|
|
570
|
+
self._positions = positions
|
|
571
|
+
|
|
572
|
+
def context_before(
|
|
573
|
+
self,
|
|
574
|
+
target_frame: BDOFrame,
|
|
575
|
+
context_frames: int,
|
|
576
|
+
) -> tuple[BDOFrame, ...]:
|
|
577
|
+
if context_frames <= 0:
|
|
578
|
+
return ()
|
|
579
|
+
flow_identity = (
|
|
580
|
+
target_frame.context.flow,
|
|
581
|
+
target_frame.context.flow_generation,
|
|
582
|
+
)
|
|
583
|
+
flow_frames = self._by_flow.get(flow_identity, ())
|
|
584
|
+
has_identity = id(target_frame) in self._positions
|
|
585
|
+
location = self._positions.get(id(target_frame))
|
|
586
|
+
index = None if location is None else location[1]
|
|
587
|
+
if not has_identity:
|
|
588
|
+
# Public helpers may be passed an equal reconstructed frame rather
|
|
589
|
+
# than the exact object from ``frames``. Accept one unambiguous
|
|
590
|
+
# equality match; fail closed if multiple positions compare equal.
|
|
591
|
+
matches = tuple(
|
|
592
|
+
candidate_index
|
|
593
|
+
for candidate_index, candidate in enumerate(flow_frames)
|
|
594
|
+
if candidate == target_frame
|
|
595
|
+
)
|
|
596
|
+
index = matches[0] if len(matches) == 1 else None
|
|
597
|
+
if index is None:
|
|
598
|
+
return ()
|
|
599
|
+
return flow_frames[max(0, index - context_frames) : index]
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@dataclass(frozen=True)
|
|
603
|
+
class _Options:
|
|
604
|
+
item_id: int
|
|
605
|
+
quantity: Optional[int]
|
|
606
|
+
action: str
|
|
607
|
+
context_frames: int
|
|
608
|
+
min_confidence: float
|
|
609
|
+
frame_index: Optional[_FrameIndex] = None
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
@dataclass(frozen=True)
|
|
613
|
+
class _CalibratedItemRecord:
|
|
614
|
+
frame: BDOFrame
|
|
615
|
+
item_offset: int
|
|
616
|
+
item_id: int
|
|
617
|
+
quantity: int
|
|
618
|
+
instance_offset: Optional[int]
|
|
619
|
+
instance: Optional[bytes]
|
|
620
|
+
confidence: float
|
|
621
|
+
reasons: tuple[str, ...]
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _validate_calibration_options(
|
|
625
|
+
*,
|
|
626
|
+
item_id: int,
|
|
627
|
+
quantity: Optional[int],
|
|
628
|
+
action: str,
|
|
629
|
+
context_frames: int,
|
|
630
|
+
min_confidence: float,
|
|
631
|
+
) -> None:
|
|
632
|
+
if isinstance(item_id, bool) or not isinstance(item_id, int):
|
|
633
|
+
raise ValueError("item_id must be an integer")
|
|
634
|
+
if not 1 <= item_id <= MAX_PLAUSIBLE_ITEM_ID:
|
|
635
|
+
raise ValueError(
|
|
636
|
+
f"item_id must be between 1 and {MAX_PLAUSIBLE_ITEM_ID}"
|
|
637
|
+
)
|
|
638
|
+
if quantity is not None and (
|
|
639
|
+
isinstance(quantity, bool)
|
|
640
|
+
or not isinstance(quantity, int)
|
|
641
|
+
or not 1 <= quantity <= 0xFFFFFFFF
|
|
642
|
+
):
|
|
643
|
+
raise ValueError("quantity must be None or a positive uint32")
|
|
644
|
+
if action != "auto" and action not in CALIBRATION_ACTIONS:
|
|
645
|
+
raise ValueError(
|
|
646
|
+
f"unknown calibration action {action!r}; "
|
|
647
|
+
f"expected one of {CALIBRATION_ACTIONS} or 'auto'"
|
|
648
|
+
)
|
|
649
|
+
if (
|
|
650
|
+
isinstance(context_frames, bool)
|
|
651
|
+
or not isinstance(context_frames, int)
|
|
652
|
+
or context_frames <= 0
|
|
653
|
+
):
|
|
654
|
+
raise ValueError("context_frames must be a positive integer")
|
|
655
|
+
if (
|
|
656
|
+
isinstance(min_confidence, bool)
|
|
657
|
+
or not isinstance(min_confidence, (int, float))
|
|
658
|
+
or not math.isfinite(min_confidence)
|
|
659
|
+
or not 0 <= min_confidence <= 1
|
|
660
|
+
):
|
|
661
|
+
raise ValueError("min_confidence must be a finite number from 0 to 1")
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _validate_calibration_retention_limits(
|
|
665
|
+
*,
|
|
666
|
+
max_retained_frames: int,
|
|
667
|
+
max_retained_bytes: int,
|
|
668
|
+
context_frames: int,
|
|
669
|
+
) -> None:
|
|
670
|
+
for name, value in (
|
|
671
|
+
("max_retained_frames", max_retained_frames),
|
|
672
|
+
("max_retained_bytes", max_retained_bytes),
|
|
673
|
+
):
|
|
674
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
675
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
676
|
+
if max_retained_frames <= context_frames:
|
|
677
|
+
raise ValueError(
|
|
678
|
+
"max_retained_frames must be greater than context_frames so one "
|
|
679
|
+
"candidate and its requested preceding context can be retained"
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def collect_frames_pcap(
|
|
684
|
+
path: str | Path,
|
|
685
|
+
*,
|
|
686
|
+
ports: tuple[int, ...] = DEFAULT_SERVER_PORTS,
|
|
687
|
+
) -> list[BDOFrame]:
|
|
688
|
+
"""Reassemble a pcap and return every generic BDO frame."""
|
|
689
|
+
validated_ports = validate_server_ports(ports)
|
|
690
|
+
frames: list[BDOFrame] = []
|
|
691
|
+
manager = FlowManager(
|
|
692
|
+
server_ports=validated_ports,
|
|
693
|
+
scanner_factory=lambda: FrameCollectorScanner(frames.append),
|
|
694
|
+
track_flow_generations=True,
|
|
695
|
+
)
|
|
696
|
+
replay_pcap_file(Path(path), manager)
|
|
697
|
+
return frames
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def calibrate_frames(
|
|
701
|
+
frames: list[BDOFrame],
|
|
702
|
+
*,
|
|
703
|
+
item_id: int,
|
|
704
|
+
quantity: Optional[int] = None,
|
|
705
|
+
action: str = "auto",
|
|
706
|
+
context_frames: int = 5,
|
|
707
|
+
min_confidence: float = 0.80,
|
|
708
|
+
) -> CalibrationResult:
|
|
709
|
+
"""Score collected frames and promote plausible message specs."""
|
|
710
|
+
_validate_calibration_options(
|
|
711
|
+
item_id=item_id,
|
|
712
|
+
quantity=quantity,
|
|
713
|
+
action=action,
|
|
714
|
+
context_frames=context_frames,
|
|
715
|
+
min_confidence=min_confidence,
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
frame_index = _FrameIndex(frames)
|
|
719
|
+
options = _Options(
|
|
720
|
+
item_id=item_id,
|
|
721
|
+
quantity=quantity,
|
|
722
|
+
action=action,
|
|
723
|
+
context_frames=context_frames,
|
|
724
|
+
min_confidence=min_confidence,
|
|
725
|
+
frame_index=frame_index,
|
|
726
|
+
)
|
|
727
|
+
ignored: list[str] = []
|
|
728
|
+
evidence: list[DirectionEvidence] = []
|
|
729
|
+
specs: list[MessageSpec] = []
|
|
730
|
+
|
|
731
|
+
# Auto covers both transfer directions and classifies each from structure.
|
|
732
|
+
# Storage authority additionally needs two distinct record counts so an
|
|
733
|
+
# unrelated small header integer cannot impersonate the count column. Use
|
|
734
|
+
# an unstackable item with quantity=1 and perform at least two different
|
|
735
|
+
# deposit sizes, plus one storage->inventory move. The guided example uses
|
|
736
|
+
# deposits of 1 and 4 followed by one withdrawal of all 5. Direction is
|
|
737
|
+
# never taken on faith. Loot preview needs a gathering action, so it stays
|
|
738
|
+
# an explicit, optional mode.
|
|
739
|
+
actions: tuple[str, ...]
|
|
740
|
+
if action == "auto":
|
|
741
|
+
actions = ("storage-to-inventory", "inventory-to-storage")
|
|
742
|
+
strict = False
|
|
743
|
+
else:
|
|
744
|
+
actions = (action,)
|
|
745
|
+
strict = True
|
|
746
|
+
|
|
747
|
+
for current_action in actions:
|
|
748
|
+
if current_action == "loot-preview":
|
|
749
|
+
specs.extend(_calibrate_loot_preview(frames, options, ignored))
|
|
750
|
+
elif current_action == "storage-to-inventory":
|
|
751
|
+
specs.extend(
|
|
752
|
+
_calibrate_storage_to_inventory(
|
|
753
|
+
frames, options, ignored, evidence, strict
|
|
754
|
+
)
|
|
755
|
+
)
|
|
756
|
+
elif current_action == "inventory-to-storage":
|
|
757
|
+
specs.extend(
|
|
758
|
+
_calibrate_inventory_to_storage(
|
|
759
|
+
frames, options, ignored, evidence, strict
|
|
760
|
+
)
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
retained_bytes = sum(len(frame.message) for frame in frames)
|
|
764
|
+
return CalibrationResult(
|
|
765
|
+
specs=tuple(_dedupe_message_specs(specs)),
|
|
766
|
+
ignored=tuple(ignored),
|
|
767
|
+
frames_scanned=len(frames),
|
|
768
|
+
evidence=tuple(evidence),
|
|
769
|
+
calibration_item_id=item_id,
|
|
770
|
+
retention=CalibrationRetention(
|
|
771
|
+
frames_observed=len(frames),
|
|
772
|
+
frames_retained=len(frames),
|
|
773
|
+
frames_discarded=0,
|
|
774
|
+
bytes_observed=retained_bytes,
|
|
775
|
+
bytes_retained=retained_bytes,
|
|
776
|
+
bytes_discarded=0,
|
|
777
|
+
),
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def calibrate_pcap(
|
|
782
|
+
path: str | Path,
|
|
783
|
+
*,
|
|
784
|
+
item_id: int,
|
|
785
|
+
quantity: Optional[int] = None,
|
|
786
|
+
action: str = "auto",
|
|
787
|
+
ports: tuple[int, ...] = DEFAULT_SERVER_PORTS,
|
|
788
|
+
context_frames: int = 5,
|
|
789
|
+
min_confidence: float = 0.80,
|
|
790
|
+
) -> CalibrationResult:
|
|
791
|
+
"""Calibrate message specs from a pcap of a known in-game action."""
|
|
792
|
+
_validate_calibration_options(
|
|
793
|
+
item_id=item_id,
|
|
794
|
+
quantity=quantity,
|
|
795
|
+
action=action,
|
|
796
|
+
context_frames=context_frames,
|
|
797
|
+
min_confidence=min_confidence,
|
|
798
|
+
)
|
|
799
|
+
frames = collect_frames_pcap(path, ports=ports)
|
|
800
|
+
return calibrate_frames(
|
|
801
|
+
frames,
|
|
802
|
+
item_id=item_id,
|
|
803
|
+
quantity=quantity,
|
|
804
|
+
action=action,
|
|
805
|
+
context_frames=context_frames,
|
|
806
|
+
min_confidence=min_confidence,
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
class CalibrationSession:
|
|
811
|
+
"""Live calibration with programmatic start/stop, for embedding in apps.
|
|
812
|
+
|
|
813
|
+
The session captures passively in the background between ``start()`` and
|
|
814
|
+
``stop()``. ``start()`` returns after the capture adapter reports ready,
|
|
815
|
+
or raises after a finite startup deadline. Typical app flow::
|
|
816
|
+
|
|
817
|
+
# quantity=1 matches each serialized unstackable record; it is not
|
|
818
|
+
# the number of items moved by each user-performed action.
|
|
819
|
+
session = CalibrationSession(item_id=15156, quantity=1)
|
|
820
|
+
session.start()
|
|
821
|
+
# ... deposit 1; deposit the remaining 4; withdraw all 5;
|
|
822
|
+
# then have the user click "Done" ...
|
|
823
|
+
result = session.stop()
|
|
824
|
+
if result.specs:
|
|
825
|
+
update_profile(result, my_profile_path)
|
|
826
|
+
|
|
827
|
+
Auto calibration (the default) classifies each transfer direction from
|
|
828
|
+
packet structure, so no ``action`` need be declared. Storage authority
|
|
829
|
+
requires at least two distinct deposit counts plus one withdrawal. The
|
|
830
|
+
guided five-unstackable sequence is deposit one, deposit four, withdraw
|
|
831
|
+
all five. The user performs those actions; the session passively observes
|
|
832
|
+
them, and ``quantity=1`` continues to describe every repeated item record.
|
|
833
|
+
The values 1, 4, and 5 are a recommended operator workflow rather than
|
|
834
|
+
constructor arguments; the session learns batch cardinality from traffic.
|
|
835
|
+
Loot preview is a separate explicit action and may use ``quantity=None``
|
|
836
|
+
when only the watched item ID is stable.
|
|
837
|
+
|
|
838
|
+
Live evidence is bounded by both ``max_retained_frames`` and
|
|
839
|
+
``max_retained_bytes``. The newest contiguous frame tail is retained so a
|
|
840
|
+
transfer performed shortly before ``stop()`` keeps its preceding context.
|
|
841
|
+
``frames_collected`` remains the total-observed progress count; use
|
|
842
|
+
``frames_retained``, ``frames_discarded``, or ``retention`` to surface
|
|
843
|
+
eviction. A truncated result calibrates only the retained tail.
|
|
844
|
+
|
|
845
|
+
TCP reassembly is also bounded to 64 active flows. Admitting another flow
|
|
846
|
+
finalizes the least-recently active state. FIN/RST or session finalization
|
|
847
|
+
releases remaining flow state; live calibration does not configure
|
|
848
|
+
time-based idle eviction.
|
|
849
|
+
|
|
850
|
+
Used as a context manager, the capture is stopped on exit even if the
|
|
851
|
+
block raises; call ``stop()`` inside the block to get the result.
|
|
852
|
+
"""
|
|
853
|
+
|
|
854
|
+
_STARTUP_TIMEOUT_SECONDS = DEFAULT_STARTUP_TIMEOUT_SECONDS
|
|
855
|
+
|
|
856
|
+
def __init__(
|
|
857
|
+
self,
|
|
858
|
+
*,
|
|
859
|
+
item_id: int,
|
|
860
|
+
quantity: Optional[int] = None,
|
|
861
|
+
action: str = "auto",
|
|
862
|
+
capture_options: Optional[PacketCaptureOptions] = None,
|
|
863
|
+
context_frames: int = 5,
|
|
864
|
+
min_confidence: float = 0.80,
|
|
865
|
+
max_retained_frames: int = DEFAULT_CALIBRATION_MAX_RETAINED_FRAMES,
|
|
866
|
+
max_retained_bytes: int = DEFAULT_CALIBRATION_MAX_RETAINED_BYTES,
|
|
867
|
+
) -> None:
|
|
868
|
+
_validate_calibration_options(
|
|
869
|
+
item_id=item_id,
|
|
870
|
+
quantity=quantity,
|
|
871
|
+
action=action,
|
|
872
|
+
context_frames=context_frames,
|
|
873
|
+
min_confidence=min_confidence,
|
|
874
|
+
)
|
|
875
|
+
_validate_calibration_retention_limits(
|
|
876
|
+
max_retained_frames=max_retained_frames,
|
|
877
|
+
max_retained_bytes=max_retained_bytes,
|
|
878
|
+
context_frames=context_frames,
|
|
879
|
+
)
|
|
880
|
+
if capture_options is not None and not isinstance(
|
|
881
|
+
capture_options, PacketCaptureOptions
|
|
882
|
+
):
|
|
883
|
+
raise TypeError(
|
|
884
|
+
"capture_options must be a PacketCaptureOptions or None"
|
|
885
|
+
)
|
|
886
|
+
self._item_id = item_id
|
|
887
|
+
self._quantity = quantity
|
|
888
|
+
self._action = action
|
|
889
|
+
self._capture_options = capture_options or PacketCaptureOptions()
|
|
890
|
+
self._context_frames = context_frames
|
|
891
|
+
self._min_confidence = min_confidence
|
|
892
|
+
self._max_retained_frames = max_retained_frames
|
|
893
|
+
self._max_retained_bytes = max_retained_bytes
|
|
894
|
+
self._frames: deque[BDOFrame] = deque()
|
|
895
|
+
self._frames_observed = 0
|
|
896
|
+
self._frames_discarded = 0
|
|
897
|
+
self._bytes_observed = 0
|
|
898
|
+
self._bytes_retained = 0
|
|
899
|
+
self._bytes_discarded = 0
|
|
900
|
+
self._manager: Optional[FlowManager] = None
|
|
901
|
+
self._capture: Optional[LivePacketCapture] = None
|
|
902
|
+
self._error: Optional[BaseException] = None
|
|
903
|
+
self._lifecycle_lock = RLock()
|
|
904
|
+
# Scanner callbacks run on the capture thread. Keep their short data
|
|
905
|
+
# lock independent from lifecycle operations that may join that thread.
|
|
906
|
+
self._retention_lock = Lock()
|
|
907
|
+
|
|
908
|
+
@property
|
|
909
|
+
def running(self) -> bool:
|
|
910
|
+
with self._lifecycle_lock:
|
|
911
|
+
capture = self._capture
|
|
912
|
+
return capture is not None and capture.running
|
|
913
|
+
|
|
914
|
+
@property
|
|
915
|
+
def cleanup_incomplete(self) -> bool:
|
|
916
|
+
"""Whether capture shutdown retained resources for a stop retry."""
|
|
917
|
+
|
|
918
|
+
with self._lifecycle_lock:
|
|
919
|
+
capture = self._capture
|
|
920
|
+
return capture is not None and capture.cleanup_incomplete
|
|
921
|
+
|
|
922
|
+
@property
|
|
923
|
+
def error(self) -> Optional[BaseException]:
|
|
924
|
+
"""First startup, callback, or shutdown failure for the current run."""
|
|
925
|
+
|
|
926
|
+
with self._lifecycle_lock:
|
|
927
|
+
if self._error is not None:
|
|
928
|
+
return self._error
|
|
929
|
+
capture = self._capture
|
|
930
|
+
return capture.error if capture is not None else None
|
|
931
|
+
|
|
932
|
+
@property
|
|
933
|
+
def frames_collected(self) -> int:
|
|
934
|
+
"""Total frames observed, including frames later evicted."""
|
|
935
|
+
|
|
936
|
+
return self.frames_observed
|
|
937
|
+
|
|
938
|
+
@property
|
|
939
|
+
def frames_observed(self) -> int:
|
|
940
|
+
with self._retention_lock:
|
|
941
|
+
return self._frames_observed
|
|
942
|
+
|
|
943
|
+
@property
|
|
944
|
+
def frames_retained(self) -> int:
|
|
945
|
+
with self._retention_lock:
|
|
946
|
+
return len(self._frames)
|
|
947
|
+
|
|
948
|
+
@property
|
|
949
|
+
def frames_discarded(self) -> int:
|
|
950
|
+
with self._retention_lock:
|
|
951
|
+
return self._frames_discarded
|
|
952
|
+
|
|
953
|
+
@property
|
|
954
|
+
def bytes_observed(self) -> int:
|
|
955
|
+
"""Total generic-frame payload bytes observed."""
|
|
956
|
+
|
|
957
|
+
with self._retention_lock:
|
|
958
|
+
return self._bytes_observed
|
|
959
|
+
|
|
960
|
+
@property
|
|
961
|
+
def bytes_retained(self) -> int:
|
|
962
|
+
"""Generic-frame payload bytes currently retained."""
|
|
963
|
+
|
|
964
|
+
with self._retention_lock:
|
|
965
|
+
return self._bytes_retained
|
|
966
|
+
|
|
967
|
+
@property
|
|
968
|
+
def bytes_discarded(self) -> int:
|
|
969
|
+
with self._retention_lock:
|
|
970
|
+
return self._bytes_discarded
|
|
971
|
+
|
|
972
|
+
@property
|
|
973
|
+
def retention_truncated(self) -> bool:
|
|
974
|
+
return self.retention.truncated
|
|
975
|
+
|
|
976
|
+
@property
|
|
977
|
+
def retention(self) -> CalibrationRetention:
|
|
978
|
+
"""Atomic snapshot of observed, retained, and discarded evidence."""
|
|
979
|
+
|
|
980
|
+
with self._retention_lock:
|
|
981
|
+
return self._retention_unlocked()
|
|
982
|
+
|
|
983
|
+
def start(self) -> None:
|
|
984
|
+
"""Begin passive capture and return once the adapter is ready."""
|
|
985
|
+
|
|
986
|
+
with self._lifecycle_lock:
|
|
987
|
+
if self._capture is not None or self._manager is not None:
|
|
988
|
+
raise RuntimeError("calibration session is already running")
|
|
989
|
+
|
|
990
|
+
self._reset_retention()
|
|
991
|
+
self._error = None
|
|
992
|
+
manager = FlowManager(
|
|
993
|
+
server_ports=self._capture_options.ports,
|
|
994
|
+
scanner_factory=lambda: FrameCollectorScanner(
|
|
995
|
+
self._retain_frame
|
|
996
|
+
),
|
|
997
|
+
max_flows=_CALIBRATION_MAX_ACTIVE_FLOWS,
|
|
998
|
+
track_flow_generations=True,
|
|
999
|
+
)
|
|
1000
|
+
capture = LivePacketCapture(
|
|
1001
|
+
capture_options=self._capture_options,
|
|
1002
|
+
on_packet=make_packet_handler(manager),
|
|
1003
|
+
startup_timeout=self._STARTUP_TIMEOUT_SECONDS,
|
|
1004
|
+
)
|
|
1005
|
+
self._manager = manager
|
|
1006
|
+
self._capture = capture
|
|
1007
|
+
try:
|
|
1008
|
+
capture.start()
|
|
1009
|
+
except BaseException as exc:
|
|
1010
|
+
self._record_error(exc)
|
|
1011
|
+
if capture.cleanup_incomplete:
|
|
1012
|
+
# The capture thread may still call into this manager. Keep
|
|
1013
|
+
# both objects alive so stop() can retry verified shutdown
|
|
1014
|
+
# before finalizing stream state.
|
|
1015
|
+
_attach_cleanup_owner(
|
|
1016
|
+
exc,
|
|
1017
|
+
self,
|
|
1018
|
+
context="live calibration startup",
|
|
1019
|
+
)
|
|
1020
|
+
raise
|
|
1021
|
+
try:
|
|
1022
|
+
manager.finish()
|
|
1023
|
+
except BaseException as cleanup_error:
|
|
1024
|
+
if hasattr(exc, "add_note"):
|
|
1025
|
+
exc.add_note(
|
|
1026
|
+
"calibration flow cleanup also failed: "
|
|
1027
|
+
f"{cleanup_error!r}"
|
|
1028
|
+
)
|
|
1029
|
+
self._manager = None
|
|
1030
|
+
self._capture = None
|
|
1031
|
+
raise
|
|
1032
|
+
|
|
1033
|
+
def stop(self) -> CalibrationResult:
|
|
1034
|
+
"""End the capture and calibrate the collected frames."""
|
|
1035
|
+
with self._lifecycle_lock:
|
|
1036
|
+
self._finish_capture()
|
|
1037
|
+
with self._retention_lock:
|
|
1038
|
+
frames = list(self._frames)
|
|
1039
|
+
retention = self._retention_unlocked()
|
|
1040
|
+
result = calibrate_frames(
|
|
1041
|
+
frames,
|
|
1042
|
+
item_id=self._item_id,
|
|
1043
|
+
quantity=self._quantity,
|
|
1044
|
+
action=self._action,
|
|
1045
|
+
context_frames=self._context_frames,
|
|
1046
|
+
min_confidence=self._min_confidence,
|
|
1047
|
+
)
|
|
1048
|
+
return replace(result, retention=retention)
|
|
1049
|
+
|
|
1050
|
+
def raise_if_failed(self) -> None:
|
|
1051
|
+
"""Re-raise a background capture failure in the calling thread."""
|
|
1052
|
+
|
|
1053
|
+
with self._lifecycle_lock:
|
|
1054
|
+
if self._error is not None:
|
|
1055
|
+
raise self._error
|
|
1056
|
+
capture = self._capture
|
|
1057
|
+
if capture is None:
|
|
1058
|
+
return
|
|
1059
|
+
try:
|
|
1060
|
+
capture.raise_if_failed()
|
|
1061
|
+
except BaseException as exc:
|
|
1062
|
+
self._record_error(exc)
|
|
1063
|
+
raise
|
|
1064
|
+
if not capture.running:
|
|
1065
|
+
error = RuntimeError(
|
|
1066
|
+
"live calibration capture ended unexpectedly"
|
|
1067
|
+
)
|
|
1068
|
+
self._record_error(error)
|
|
1069
|
+
raise error
|
|
1070
|
+
|
|
1071
|
+
def _finish_capture(self) -> None:
|
|
1072
|
+
capture = self._capture
|
|
1073
|
+
manager = self._manager
|
|
1074
|
+
if capture is None or manager is None:
|
|
1075
|
+
raise RuntimeError("calibration session was not started")
|
|
1076
|
+
|
|
1077
|
+
failures: list[BaseException] = []
|
|
1078
|
+
|
|
1079
|
+
def retain(error: BaseException) -> None:
|
|
1080
|
+
if not any(error is previous for previous in failures):
|
|
1081
|
+
failures.append(error)
|
|
1082
|
+
|
|
1083
|
+
if self._error is not None:
|
|
1084
|
+
retain(self._error)
|
|
1085
|
+
stop_failure: Optional[BaseException] = None
|
|
1086
|
+
try:
|
|
1087
|
+
capture.stop()
|
|
1088
|
+
except BaseException as exc:
|
|
1089
|
+
stop_failure = exc
|
|
1090
|
+
retain(exc)
|
|
1091
|
+
capture_stopped = bool(
|
|
1092
|
+
getattr(capture, "stopped", not capture.running)
|
|
1093
|
+
)
|
|
1094
|
+
if not capture_stopped:
|
|
1095
|
+
if stop_failure is None:
|
|
1096
|
+
stop_failure = capture.cleanup_error or RuntimeError(
|
|
1097
|
+
"live calibration capture cleanup is incomplete"
|
|
1098
|
+
)
|
|
1099
|
+
retain(stop_failure)
|
|
1100
|
+
self._record_error(failures[0])
|
|
1101
|
+
# Reassembly state is still reachable from the capture callback.
|
|
1102
|
+
# Do not finish or discard it until a later stop() verifies that
|
|
1103
|
+
# the capture thread has terminated.
|
|
1104
|
+
raise stop_failure
|
|
1105
|
+
try:
|
|
1106
|
+
capture.raise_if_failed()
|
|
1107
|
+
except BaseException as exc:
|
|
1108
|
+
retain(exc)
|
|
1109
|
+
try:
|
|
1110
|
+
manager.finish()
|
|
1111
|
+
except BaseException as exc:
|
|
1112
|
+
retain(exc)
|
|
1113
|
+
finally:
|
|
1114
|
+
self._capture = None
|
|
1115
|
+
self._manager = None
|
|
1116
|
+
|
|
1117
|
+
if failures:
|
|
1118
|
+
self._record_error(failures[0])
|
|
1119
|
+
raise failures[0]
|
|
1120
|
+
|
|
1121
|
+
def _record_error(self, error: BaseException) -> None:
|
|
1122
|
+
with self._lifecycle_lock:
|
|
1123
|
+
if self._error is None:
|
|
1124
|
+
self._error = error
|
|
1125
|
+
|
|
1126
|
+
def _reset_retention(self) -> None:
|
|
1127
|
+
with self._retention_lock:
|
|
1128
|
+
self._frames.clear()
|
|
1129
|
+
self._frames_observed = 0
|
|
1130
|
+
self._frames_discarded = 0
|
|
1131
|
+
self._bytes_observed = 0
|
|
1132
|
+
self._bytes_retained = 0
|
|
1133
|
+
self._bytes_discarded = 0
|
|
1134
|
+
|
|
1135
|
+
def _retain_frame(self, frame: BDOFrame) -> None:
|
|
1136
|
+
"""Retain one frame, evicting the oldest tail prefix as needed."""
|
|
1137
|
+
|
|
1138
|
+
payload_bytes = len(frame.message)
|
|
1139
|
+
with self._retention_lock:
|
|
1140
|
+
self._frames_observed += 1
|
|
1141
|
+
self._bytes_observed += payload_bytes
|
|
1142
|
+
self._frames.append(frame)
|
|
1143
|
+
self._bytes_retained += payload_bytes
|
|
1144
|
+
|
|
1145
|
+
while self._frames and (
|
|
1146
|
+
len(self._frames) > self._max_retained_frames
|
|
1147
|
+
or self._bytes_retained > self._max_retained_bytes
|
|
1148
|
+
):
|
|
1149
|
+
discarded = self._frames.popleft()
|
|
1150
|
+
discarded_bytes = len(discarded.message)
|
|
1151
|
+
self._frames_discarded += 1
|
|
1152
|
+
self._bytes_discarded += discarded_bytes
|
|
1153
|
+
self._bytes_retained -= discarded_bytes
|
|
1154
|
+
|
|
1155
|
+
def _retention_unlocked(self) -> CalibrationRetention:
|
|
1156
|
+
return CalibrationRetention(
|
|
1157
|
+
frames_observed=self._frames_observed,
|
|
1158
|
+
frames_retained=len(self._frames),
|
|
1159
|
+
frames_discarded=self._frames_discarded,
|
|
1160
|
+
bytes_observed=self._bytes_observed,
|
|
1161
|
+
bytes_retained=self._bytes_retained,
|
|
1162
|
+
bytes_discarded=self._bytes_discarded,
|
|
1163
|
+
max_retained_frames=self._max_retained_frames,
|
|
1164
|
+
max_retained_bytes=self._max_retained_bytes,
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
def __enter__(self) -> "CalibrationSession":
|
|
1168
|
+
with self._lifecycle_lock:
|
|
1169
|
+
if self._capture is None:
|
|
1170
|
+
self.start()
|
|
1171
|
+
return self
|
|
1172
|
+
|
|
1173
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
1174
|
+
# Safety net only: discard the capture if the block exited without
|
|
1175
|
+
# calling stop() (for example on an exception).
|
|
1176
|
+
with self._lifecycle_lock:
|
|
1177
|
+
if self._capture is None:
|
|
1178
|
+
return
|
|
1179
|
+
try:
|
|
1180
|
+
self._finish_capture()
|
|
1181
|
+
except BaseException as cleanup_error:
|
|
1182
|
+
if exc_value is None:
|
|
1183
|
+
raise
|
|
1184
|
+
if self.cleanup_incomplete:
|
|
1185
|
+
_attach_cleanup_owner(
|
|
1186
|
+
exc_value,
|
|
1187
|
+
self,
|
|
1188
|
+
context="live calibration context",
|
|
1189
|
+
)
|
|
1190
|
+
if hasattr(exc_value, "add_note"):
|
|
1191
|
+
exc_value.add_note(
|
|
1192
|
+
"calibration context cleanup also failed: "
|
|
1193
|
+
f"{cleanup_error!r}"
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def calibrate_live(
|
|
1198
|
+
*,
|
|
1199
|
+
item_id: int,
|
|
1200
|
+
capture_seconds: Optional[float] = None,
|
|
1201
|
+
quantity: Optional[int] = None,
|
|
1202
|
+
action: str = "auto",
|
|
1203
|
+
capture_options: Optional[PacketCaptureOptions] = None,
|
|
1204
|
+
context_frames: int = 5,
|
|
1205
|
+
min_confidence: float = 0.80,
|
|
1206
|
+
max_retained_frames: int = DEFAULT_CALIBRATION_MAX_RETAINED_FRAMES,
|
|
1207
|
+
max_retained_bytes: int = DEFAULT_CALIBRATION_MAX_RETAINED_BYTES,
|
|
1208
|
+
) -> CalibrationResult:
|
|
1209
|
+
"""Blocking convenience wrapper around :class:`CalibrationSession`.
|
|
1210
|
+
|
|
1211
|
+
Suited to console scripts: perform the required in-game sequence while the
|
|
1212
|
+
capture runs. For automatic transfer calibration, the guided sequence is
|
|
1213
|
+
deposit one matching unstackable, deposit four, then withdraw all five.
|
|
1214
|
+
The toolkit does not perform those actions; ``quantity=1`` matches every
|
|
1215
|
+
serialized item record rather than the batch totals 1, 4, or 5.
|
|
1216
|
+
These counts are observed from traffic and are not hard-coded. Loot preview
|
|
1217
|
+
is a separate explicit action; omit ``quantity`` when its displayed amount
|
|
1218
|
+
is random.
|
|
1219
|
+
With ``capture_seconds`` the capture stops automatically; without it, the
|
|
1220
|
+
capture runs until the user interrupts (Ctrl+C), which is treated as
|
|
1221
|
+
"actions performed, calibrate now" rather than as an abort. Apps with
|
|
1222
|
+
their own UI should use :class:`CalibrationSession` directly.
|
|
1223
|
+
"""
|
|
1224
|
+
import time
|
|
1225
|
+
|
|
1226
|
+
if capture_seconds is not None and (
|
|
1227
|
+
isinstance(capture_seconds, bool)
|
|
1228
|
+
or not isinstance(capture_seconds, (int, float))
|
|
1229
|
+
or not math.isfinite(capture_seconds)
|
|
1230
|
+
or capture_seconds < 0
|
|
1231
|
+
):
|
|
1232
|
+
raise ValueError("capture_seconds must be finite and non-negative")
|
|
1233
|
+
|
|
1234
|
+
session = CalibrationSession(
|
|
1235
|
+
item_id=item_id,
|
|
1236
|
+
quantity=quantity,
|
|
1237
|
+
action=action,
|
|
1238
|
+
capture_options=capture_options,
|
|
1239
|
+
context_frames=context_frames,
|
|
1240
|
+
min_confidence=min_confidence,
|
|
1241
|
+
max_retained_frames=max_retained_frames,
|
|
1242
|
+
max_retained_bytes=max_retained_bytes,
|
|
1243
|
+
)
|
|
1244
|
+
with session:
|
|
1245
|
+
deadline = (
|
|
1246
|
+
None
|
|
1247
|
+
if capture_seconds is None
|
|
1248
|
+
else time.monotonic() + capture_seconds
|
|
1249
|
+
)
|
|
1250
|
+
try:
|
|
1251
|
+
while True:
|
|
1252
|
+
session.raise_if_failed()
|
|
1253
|
+
if deadline is None:
|
|
1254
|
+
wait_seconds = 0.2
|
|
1255
|
+
else:
|
|
1256
|
+
remaining = deadline - time.monotonic()
|
|
1257
|
+
if remaining <= 0:
|
|
1258
|
+
break
|
|
1259
|
+
wait_seconds = min(0.2, remaining)
|
|
1260
|
+
time.sleep(wait_seconds)
|
|
1261
|
+
except KeyboardInterrupt:
|
|
1262
|
+
# Ctrl+C ends the listening window; the collected frames still get
|
|
1263
|
+
# calibrated, matching the legacy stop-to-finish workflow.
|
|
1264
|
+
pass
|
|
1265
|
+
return session.stop()
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def update_profile(
|
|
1269
|
+
result: CalibrationResult | Iterable[MessageSpec],
|
|
1270
|
+
path: str | Path,
|
|
1271
|
+
*,
|
|
1272
|
+
action: str = "auto",
|
|
1273
|
+
replace: bool = True,
|
|
1274
|
+
replace_entire_action: bool = False,
|
|
1275
|
+
backup: bool = True,
|
|
1276
|
+
calibration_item_id: Optional[int] = None,
|
|
1277
|
+
) -> ProfileUpdate:
|
|
1278
|
+
"""Persist promoted specs into a local opcode profile file.
|
|
1279
|
+
|
|
1280
|
+
By default, only the event families represented by the supplied specs are
|
|
1281
|
+
cleared first. Explicit-action and raw-spec callers can therefore apply a
|
|
1282
|
+
reviewed partial update without erasing unrelated evidence. Automatic
|
|
1283
|
+
transfer results must contain every runtime-required transfer family.
|
|
1284
|
+
Pass ``replace_entire_action=True`` for an explicit reset of every family
|
|
1285
|
+
belonging to ``action``. Pass ``replace=False`` only for an intentional
|
|
1286
|
+
advanced merge that preserves and deduplicates existing specs. The
|
|
1287
|
+
previous file is backed up next to it unless ``backup=False``.
|
|
1288
|
+
"""
|
|
1289
|
+
if action != "auto" and action not in CALIBRATION_ACTIONS:
|
|
1290
|
+
raise ValueError(
|
|
1291
|
+
f"unknown calibration action {action!r}; "
|
|
1292
|
+
f"expected one of {CALIBRATION_ACTIONS} or 'auto'"
|
|
1293
|
+
)
|
|
1294
|
+
if isinstance(result, CalibrationResult):
|
|
1295
|
+
from_calibration_result = True
|
|
1296
|
+
specs = tuple(result.specs)
|
|
1297
|
+
if calibration_item_id is None:
|
|
1298
|
+
calibration_item_id = result.calibration_item_id
|
|
1299
|
+
else:
|
|
1300
|
+
from_calibration_result = False
|
|
1301
|
+
specs = tuple(result)
|
|
1302
|
+
if any(not isinstance(spec, MessageSpec) for spec in specs):
|
|
1303
|
+
raise TypeError("update_profile expects MessageSpec objects")
|
|
1304
|
+
_validate_profile_replacement_options(replace, replace_entire_action)
|
|
1305
|
+
if from_calibration_result and action == "auto" and specs:
|
|
1306
|
+
transfer_events = {
|
|
1307
|
+
"INVENTORY_TRANSFER",
|
|
1308
|
+
"SOURCE_CONTAINER_DECREMENT",
|
|
1309
|
+
"SOURCE_STACK_DECREMENT",
|
|
1310
|
+
"SOURCE_ITEM_REFERENCE",
|
|
1311
|
+
"STORAGE_ITEM_DELTA",
|
|
1312
|
+
}
|
|
1313
|
+
observed_events = {spec.event for spec in specs}
|
|
1314
|
+
if observed_events & transfer_events:
|
|
1315
|
+
required = {
|
|
1316
|
+
"INVENTORY_TRANSFER",
|
|
1317
|
+
"SOURCE_STACK_DECREMENT",
|
|
1318
|
+
"STORAGE_ITEM_DELTA",
|
|
1319
|
+
}
|
|
1320
|
+
missing = sorted(required - observed_events)
|
|
1321
|
+
if missing:
|
|
1322
|
+
raise CalibrationAuthorityError(
|
|
1323
|
+
"auto calibration is incomplete and cannot safely replace a "
|
|
1324
|
+
"post-patch profile; missing required runtime family/families: "
|
|
1325
|
+
f"{', '.join(missing)}. Capture the complete guided transfer "
|
|
1326
|
+
"sequence so both directions and the source-stack decrement "
|
|
1327
|
+
"are observed, including an unstackable multi-record deposit, "
|
|
1328
|
+
"or pass the matching explicit action only for an intentional "
|
|
1329
|
+
"reviewed partial update. No profile was written."
|
|
1330
|
+
)
|
|
1331
|
+
profile_path = Path(path)
|
|
1332
|
+
if not specs:
|
|
1333
|
+
return ProfileUpdate(
|
|
1334
|
+
path=profile_path,
|
|
1335
|
+
added=(),
|
|
1336
|
+
replaced_events=(),
|
|
1337
|
+
backup_path=None,
|
|
1338
|
+
written=False,
|
|
1339
|
+
)
|
|
1340
|
+
if calibration_item_id is not None and (
|
|
1341
|
+
isinstance(calibration_item_id, bool)
|
|
1342
|
+
or not isinstance(calibration_item_id, int)
|
|
1343
|
+
or not 1 <= calibration_item_id <= 0xFFFFFFFF
|
|
1344
|
+
):
|
|
1345
|
+
raise ValueError("calibration_item_id must be None or a positive uint32")
|
|
1346
|
+
data = _load_profile_data(profile_path)
|
|
1347
|
+
|
|
1348
|
+
replaced_events: tuple[str, ...] = ()
|
|
1349
|
+
if replace and specs:
|
|
1350
|
+
replacement_scope = (
|
|
1351
|
+
_events_for_action(action)
|
|
1352
|
+
if replace_entire_action
|
|
1353
|
+
else tuple(dict.fromkeys(spec.event for spec in specs))
|
|
1354
|
+
)
|
|
1355
|
+
removed_events: list[str] = []
|
|
1356
|
+
for event in replacement_scope:
|
|
1357
|
+
if data["specs"].get(event):
|
|
1358
|
+
removed_events.append(event)
|
|
1359
|
+
data["specs"][event] = []
|
|
1360
|
+
replaced_events = tuple(removed_events)
|
|
1361
|
+
|
|
1362
|
+
existing_keys = _profile_dedupe_keys(data)
|
|
1363
|
+
added: list[MessageSpec] = []
|
|
1364
|
+
for spec in specs:
|
|
1365
|
+
key = spec.dedupe_key()
|
|
1366
|
+
if key in existing_keys:
|
|
1367
|
+
continue
|
|
1368
|
+
data["specs"].setdefault(spec.event, [])
|
|
1369
|
+
data["specs"][spec.event].append(spec.to_json_dict())
|
|
1370
|
+
existing_keys.add(key)
|
|
1371
|
+
added.append(spec)
|
|
1372
|
+
|
|
1373
|
+
if not added and not replaced_events:
|
|
1374
|
+
return ProfileUpdate(
|
|
1375
|
+
path=profile_path,
|
|
1376
|
+
added=(),
|
|
1377
|
+
replaced_events=(),
|
|
1378
|
+
backup_path=None,
|
|
1379
|
+
written=False,
|
|
1380
|
+
)
|
|
1381
|
+
|
|
1382
|
+
# Reject a LOOT merge that would make runtime layout selection impossible
|
|
1383
|
+
# before creating a backup or replacing the destination file. Other
|
|
1384
|
+
# calibration families may intentionally persist partial evidence that is
|
|
1385
|
+
# not yet a runtime-decodable spec.
|
|
1386
|
+
_validate_loot_profile_entries(
|
|
1387
|
+
data["specs"].get("LOOT_PREVIEW", ()),
|
|
1388
|
+
source=profile_path,
|
|
1389
|
+
)
|
|
1390
|
+
|
|
1391
|
+
data["profile_active"] = True
|
|
1392
|
+
data["updated_at"] = _utc_now_text()
|
|
1393
|
+
if calibration_item_id is not None:
|
|
1394
|
+
data["calibration_item_id"] = calibration_item_id
|
|
1395
|
+
|
|
1396
|
+
backup_path = None
|
|
1397
|
+
profile_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1398
|
+
if backup and profile_path.exists():
|
|
1399
|
+
backup_path = _backup_path(profile_path)
|
|
1400
|
+
shutil.copy2(profile_path, backup_path)
|
|
1401
|
+
|
|
1402
|
+
_atomic_write_text(
|
|
1403
|
+
profile_path,
|
|
1404
|
+
json.dumps(data, indent=2, sort_keys=True) + "\n",
|
|
1405
|
+
)
|
|
1406
|
+
|
|
1407
|
+
return ProfileUpdate(
|
|
1408
|
+
path=profile_path,
|
|
1409
|
+
added=tuple(added),
|
|
1410
|
+
replaced_events=replaced_events,
|
|
1411
|
+
backup_path=backup_path,
|
|
1412
|
+
)
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def calibrate_and_update(
|
|
1416
|
+
profile_path: str | Path,
|
|
1417
|
+
*,
|
|
1418
|
+
item_id: int,
|
|
1419
|
+
pcap: Optional[str | Path] = None,
|
|
1420
|
+
capture_seconds: Optional[float] = None,
|
|
1421
|
+
quantity: Optional[int] = None,
|
|
1422
|
+
action: str = "auto",
|
|
1423
|
+
capture_options: Optional[PacketCaptureOptions] = None,
|
|
1424
|
+
pcap_ports: Optional[tuple[int, ...]] = None,
|
|
1425
|
+
context_frames: int = 5,
|
|
1426
|
+
min_confidence: float = 0.80,
|
|
1427
|
+
max_retained_frames: int = DEFAULT_CALIBRATION_MAX_RETAINED_FRAMES,
|
|
1428
|
+
max_retained_bytes: int = DEFAULT_CALIBRATION_MAX_RETAINED_BYTES,
|
|
1429
|
+
replace: bool = True,
|
|
1430
|
+
replace_entire_action: bool = False,
|
|
1431
|
+
backup: bool = True,
|
|
1432
|
+
) -> tuple[CalibrationResult, Optional[ProfileUpdate]]:
|
|
1433
|
+
"""Calibrate and persist in one call — a facade over the two-step API.
|
|
1434
|
+
|
|
1435
|
+
With ``pcap`` set the capture is replayed from disk; otherwise a live
|
|
1436
|
+
capture runs (``capture_seconds`` timer, or Ctrl+C to stop, exactly like
|
|
1437
|
+
:func:`calibrate_live`). ``pcap_ports`` applies only to the recording;
|
|
1438
|
+
``capture_options`` applies only to live packet acquisition. If calibration
|
|
1439
|
+
promoted specs, they replace the applicable scope in ``profile_path`` by
|
|
1440
|
+
default and both objects come back; if it found nothing the profile file
|
|
1441
|
+
is left untouched and the update slot is ``None``::
|
|
1442
|
+
|
|
1443
|
+
result, update = calibrate_and_update(
|
|
1444
|
+
"opcodes.local",
|
|
1445
|
+
item_id=15156,
|
|
1446
|
+
quantity=1,
|
|
1447
|
+
)
|
|
1448
|
+
print(result.summary())
|
|
1449
|
+
if update is not None:
|
|
1450
|
+
print(update.summary())
|
|
1451
|
+
|
|
1452
|
+
Replacement is also the default on :func:`update_profile`: normal
|
|
1453
|
+
post-patch recalibration supersedes stale entries for the event families
|
|
1454
|
+
actually found. Pass ``replace_entire_action=True`` for an explicit reset
|
|
1455
|
+
of every family owned by ``action``. Pass ``replace=False`` only for an
|
|
1456
|
+
intentional reviewed merge, or use the two-step API when specs must be
|
|
1457
|
+
inspected or filtered before persistence.
|
|
1458
|
+
"""
|
|
1459
|
+
_validate_profile_replacement_options(replace, replace_entire_action)
|
|
1460
|
+
if pcap is not None:
|
|
1461
|
+
for name, value in (
|
|
1462
|
+
("capture_seconds", capture_seconds),
|
|
1463
|
+
("capture_options", capture_options),
|
|
1464
|
+
):
|
|
1465
|
+
if value is not None:
|
|
1466
|
+
raise ValueError(
|
|
1467
|
+
f"{name} applies to live calibration only; omit it with pcap"
|
|
1468
|
+
)
|
|
1469
|
+
for name, value, default in (
|
|
1470
|
+
(
|
|
1471
|
+
"max_retained_frames",
|
|
1472
|
+
max_retained_frames,
|
|
1473
|
+
DEFAULT_CALIBRATION_MAX_RETAINED_FRAMES,
|
|
1474
|
+
),
|
|
1475
|
+
(
|
|
1476
|
+
"max_retained_bytes",
|
|
1477
|
+
max_retained_bytes,
|
|
1478
|
+
DEFAULT_CALIBRATION_MAX_RETAINED_BYTES,
|
|
1479
|
+
),
|
|
1480
|
+
):
|
|
1481
|
+
if value != default:
|
|
1482
|
+
raise ValueError(
|
|
1483
|
+
f"{name} applies to live calibration only; omit it with pcap"
|
|
1484
|
+
)
|
|
1485
|
+
result = calibrate_pcap(
|
|
1486
|
+
pcap,
|
|
1487
|
+
item_id=item_id,
|
|
1488
|
+
quantity=quantity,
|
|
1489
|
+
action=action,
|
|
1490
|
+
ports=DEFAULT_SERVER_PORTS if pcap_ports is None else pcap_ports,
|
|
1491
|
+
context_frames=context_frames,
|
|
1492
|
+
min_confidence=min_confidence,
|
|
1493
|
+
)
|
|
1494
|
+
else:
|
|
1495
|
+
if pcap_ports is not None:
|
|
1496
|
+
raise ValueError(
|
|
1497
|
+
"pcap_ports applies to offline calibration only; omit it "
|
|
1498
|
+
"without pcap"
|
|
1499
|
+
)
|
|
1500
|
+
result = calibrate_live(
|
|
1501
|
+
item_id=item_id,
|
|
1502
|
+
capture_seconds=capture_seconds,
|
|
1503
|
+
quantity=quantity,
|
|
1504
|
+
action=action,
|
|
1505
|
+
capture_options=capture_options,
|
|
1506
|
+
context_frames=context_frames,
|
|
1507
|
+
min_confidence=min_confidence,
|
|
1508
|
+
max_retained_frames=max_retained_frames,
|
|
1509
|
+
max_retained_bytes=max_retained_bytes,
|
|
1510
|
+
)
|
|
1511
|
+
|
|
1512
|
+
if not result.specs:
|
|
1513
|
+
return result, None
|
|
1514
|
+
|
|
1515
|
+
update = update_profile(
|
|
1516
|
+
result,
|
|
1517
|
+
profile_path,
|
|
1518
|
+
action=action,
|
|
1519
|
+
replace=replace,
|
|
1520
|
+
replace_entire_action=replace_entire_action,
|
|
1521
|
+
backup=backup,
|
|
1522
|
+
)
|
|
1523
|
+
return result, update
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def reset_profile(
|
|
1527
|
+
path: str | Path,
|
|
1528
|
+
calibration_item_id: int = 15156,
|
|
1529
|
+
*,
|
|
1530
|
+
backup: bool = True,
|
|
1531
|
+
) -> Optional[Path]:
|
|
1532
|
+
"""Write an empty active profile, returning the backup path if any.
|
|
1533
|
+
|
|
1534
|
+
``calibration_item_id`` is maintenance metadata only. The default names the
|
|
1535
|
+
recommended unstackable calibration item and remains explicitly
|
|
1536
|
+
overrideable; resetting a profile does not itself calibrate that item.
|
|
1537
|
+
"""
|
|
1538
|
+
if (
|
|
1539
|
+
isinstance(calibration_item_id, bool)
|
|
1540
|
+
or not isinstance(calibration_item_id, int)
|
|
1541
|
+
or not 1 <= calibration_item_id <= 0xFFFFFFFF
|
|
1542
|
+
):
|
|
1543
|
+
raise ValueError("calibration_item_id must be a positive uint32")
|
|
1544
|
+
profile_path = Path(path)
|
|
1545
|
+
profile_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1546
|
+
backup_path = None
|
|
1547
|
+
if backup and profile_path.exists():
|
|
1548
|
+
backup_path = _backup_path(profile_path)
|
|
1549
|
+
shutil.copy2(profile_path, backup_path)
|
|
1550
|
+
|
|
1551
|
+
data = {
|
|
1552
|
+
"version": OPCODE_PROFILE_SCHEMA_VERSION,
|
|
1553
|
+
"updated_at": _utc_now_text(),
|
|
1554
|
+
"calibration_item_id": calibration_item_id,
|
|
1555
|
+
"profile_active": True,
|
|
1556
|
+
"specs": {event: [] for event in OPCODE_PROFILE_EVENTS},
|
|
1557
|
+
}
|
|
1558
|
+
_atomic_write_text(
|
|
1559
|
+
profile_path,
|
|
1560
|
+
json.dumps(data, indent=2, sort_keys=True) + "\n",
|
|
1561
|
+
)
|
|
1562
|
+
return backup_path
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
# --- opcode-free transfer-direction classification (2026-07-07 re-audit) ---
|
|
1566
|
+
|
|
1567
|
+
# Item-id-bearing frame lengths are bimodal in every labeled capture:
|
|
1568
|
+
# reference frames run 24-39 bytes (39 = a TWO-record worker deposit, and the
|
|
1569
|
+
# reference grows ~15 bytes per additional record), while record/wrapper
|
|
1570
|
+
# frames start at 251. The cut sits mid-gap so multi-record deposit
|
|
1571
|
+
# references stay classified without ever reaching wrapper territory.
|
|
1572
|
+
REFERENCE_FRAME_MAX_LENGTH = 128
|
|
1573
|
+
# Source-stack decrement batches are compact repeated records rather than item
|
|
1574
|
+
# wrappers. The observed five-record legacy batch is 144 bytes, so inspect a
|
|
1575
|
+
# wider but still bounded window only inside the instance-anchored decrement
|
|
1576
|
+
# detector; do not broaden direction classification's generic references.
|
|
1577
|
+
SOURCE_DECREMENT_FRAME_MAX_LENGTH = 512
|
|
1578
|
+
|
|
1579
|
+
# Context labels with real per-source entropy. The low-entropy storage-delta
|
|
1580
|
+
# reasons (05.., 20..) and the all-zero character-load context are excluded:
|
|
1581
|
+
# they appear on storage-delta frames and would blur the receipt signal.
|
|
1582
|
+
_HIGH_ENTROPY_CONTEXTS = tuple(
|
|
1583
|
+
value
|
|
1584
|
+
for value in SOURCE_CONTEXT_LABELS
|
|
1585
|
+
if value != CHARACTER_LOAD_CONTEXT and value not in STORAGE_DELTA_CONTEXTS
|
|
1586
|
+
)
|
|
1587
|
+
|
|
1588
|
+
# Which structural family each explicit transfer action expects to observe.
|
|
1589
|
+
_EXPECTED_FAMILY = {
|
|
1590
|
+
"storage-to-inventory": "into_inventory",
|
|
1591
|
+
"inventory-to-storage": "into_storage",
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
|
|
1595
|
+
def _has_context_label_before(frame: BDOFrame, before_offset: int) -> bool:
|
|
1596
|
+
for value in _HIGH_ENTROPY_CONTEXTS:
|
|
1597
|
+
offset = frame.message.find(value)
|
|
1598
|
+
if 0 <= offset < before_offset:
|
|
1599
|
+
return True
|
|
1600
|
+
return False
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def _has_item_reference_frame(
|
|
1604
|
+
frame_index: _FrameIndex,
|
|
1605
|
+
record_frame: BDOFrame,
|
|
1606
|
+
item_id: int,
|
|
1607
|
+
context_frames: int,
|
|
1608
|
+
) -> bool:
|
|
1609
|
+
"""A small same-flow frame carrying the raw item id, PRECEDING the record.
|
|
1610
|
+
|
|
1611
|
+
Only preceding frames are considered — the reference precedes its record
|
|
1612
|
+
in every labeled capture across both opcode generations — and the
|
|
1613
|
+
backward scan stops at the first frame that itself carries a plausible
|
|
1614
|
+
watched-item record: that frame belongs to an adjacent transaction, and
|
|
1615
|
+
its companion frames must not bleed into this record's classification.
|
|
1616
|
+
"""
|
|
1617
|
+
item_bytes = item_id.to_bytes(4, "little")
|
|
1618
|
+
for frame in reversed(frame_index.context_before(record_frame, context_frames)):
|
|
1619
|
+
if _plausible_record_offsets(frame, item_bytes):
|
|
1620
|
+
return False # adjacent transaction's record frame: boundary
|
|
1621
|
+
if frame.length <= REFERENCE_FRAME_MAX_LENGTH and item_bytes in frame.message:
|
|
1622
|
+
return True
|
|
1623
|
+
return False
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
def _has_storage_delta_context(frame: BDOFrame, before_offset: int) -> bool:
|
|
1627
|
+
"""Whether a validated storage destination field precedes the record."""
|
|
1628
|
+
return _discover_storage_context_offset(frame, before_offset) is not None
|
|
1629
|
+
|
|
1630
|
+
|
|
1631
|
+
def detect_transfer_family(
|
|
1632
|
+
frames: list[BDOFrame],
|
|
1633
|
+
record_frame: BDOFrame,
|
|
1634
|
+
item_offset: int,
|
|
1635
|
+
item_id: int,
|
|
1636
|
+
context_frames: int = 5,
|
|
1637
|
+
*,
|
|
1638
|
+
_frame_index: Optional[_FrameIndex] = None,
|
|
1639
|
+
) -> tuple[Optional[str], bool, bool, bool]:
|
|
1640
|
+
"""Classify a record frame's transfer direction, opcode-free.
|
|
1641
|
+
|
|
1642
|
+
Returns ``(family, reference_frame, context_label, storage_context)`` where
|
|
1643
|
+
``family`` is:
|
|
1644
|
+
|
|
1645
|
+
- ``"into_inventory"`` — the record frame carries a high-entropy source
|
|
1646
|
+
context label before the item record. The item is entering inventory (a
|
|
1647
|
+
receipt: storage pull, mob drop, gathering, mail, ...).
|
|
1648
|
+
- ``"into_storage"`` — the record frame carries a storage-delta reason at
|
|
1649
|
+
the known context offset (intrinsic), OR a small companion frame nearby
|
|
1650
|
+
carries the raw item id (windowed reference). The item is entering
|
|
1651
|
+
storage; covers player inventory->storage moves AND worker deposits.
|
|
1652
|
+
- ``None`` — no feature fires, or the two intrinsic features contradict.
|
|
1653
|
+
|
|
1654
|
+
Two INTRINSIC features (both in-frame, both validated across two opcode
|
|
1655
|
+
generations; see docs/PACKET_PROTOCOL_WIKI.md) decide direction and take
|
|
1656
|
+
priority: the high-entropy context label => into_inventory, the
|
|
1657
|
+
storage-delta context => into_storage. If both fire the frame is refused
|
|
1658
|
+
(``None``), never guessed. The WINDOWED reference frame is only a fallback
|
|
1659
|
+
for into_storage when no intrinsic feature fired (e.g. the legacy
|
|
1660
|
+
generation, whose storage delta has no offset-8 context) — it can bleed in
|
|
1661
|
+
from an adjacent transaction, so an intrinsic signal always outranks it.
|
|
1662
|
+
"""
|
|
1663
|
+
frame_index = _frame_index or _FrameIndex(frames)
|
|
1664
|
+
reference_frame = _has_item_reference_frame(
|
|
1665
|
+
frame_index, record_frame, item_id, context_frames
|
|
1666
|
+
)
|
|
1667
|
+
context_label = _has_context_label_before(record_frame, item_offset)
|
|
1668
|
+
storage_context = _has_storage_delta_context(record_frame, item_offset)
|
|
1669
|
+
|
|
1670
|
+
if context_label and storage_context:
|
|
1671
|
+
family: Optional[str] = None # contradictory intrinsic signals: refuse
|
|
1672
|
+
elif context_label:
|
|
1673
|
+
family = "into_inventory"
|
|
1674
|
+
elif storage_context:
|
|
1675
|
+
family = "into_storage"
|
|
1676
|
+
elif reference_frame:
|
|
1677
|
+
family = "into_storage"
|
|
1678
|
+
else:
|
|
1679
|
+
family = None
|
|
1680
|
+
return family, reference_frame, context_label, storage_context
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
def _select_records_by_family(
|
|
1684
|
+
frames: list[BDOFrame],
|
|
1685
|
+
records: list["_CalibratedItemRecord"],
|
|
1686
|
+
action: str,
|
|
1687
|
+
context_frames: int,
|
|
1688
|
+
evidence: list[DirectionEvidence],
|
|
1689
|
+
strict: bool,
|
|
1690
|
+
frame_index: _FrameIndex,
|
|
1691
|
+
allow_unclassified: bool = False,
|
|
1692
|
+
) -> list["_CalibratedItemRecord"]:
|
|
1693
|
+
"""Keep only records whose detected family matches ``action``.
|
|
1694
|
+
|
|
1695
|
+
Records the classification of every candidate in ``evidence``. In strict
|
|
1696
|
+
(explicit single-direction) mode, a candidate that clearly belongs to the
|
|
1697
|
+
opposite family with none matching raises :class:`DirectionMismatchError`.
|
|
1698
|
+
|
|
1699
|
+
``allow_unclassified`` keeps records neither feature can classify. It is
|
|
1700
|
+
set only for explicit inventory-to-storage calibration: an explicit
|
|
1701
|
+
declaration must stay usable even if a future patch silences both
|
|
1702
|
+
features (the post-patch recovery path), so strictness there means
|
|
1703
|
+
"refuse contradiction", not "require positive proof". Auto mode never
|
|
1704
|
+
allows unclassified records — with no declaration to fall back on, an
|
|
1705
|
+
unclassifiable record is dropped.
|
|
1706
|
+
"""
|
|
1707
|
+
expected = _EXPECTED_FAMILY[action]
|
|
1708
|
+
matched: list[_CalibratedItemRecord] = []
|
|
1709
|
+
opposite: Optional[str] = None
|
|
1710
|
+
contradictory_intrinsics = False
|
|
1711
|
+
for record in records:
|
|
1712
|
+
family, reference_frame, context_label, storage_context = detect_transfer_family(
|
|
1713
|
+
frames,
|
|
1714
|
+
record.frame,
|
|
1715
|
+
record.item_offset,
|
|
1716
|
+
record.item_id,
|
|
1717
|
+
context_frames,
|
|
1718
|
+
_frame_index=frame_index,
|
|
1719
|
+
)
|
|
1720
|
+
evidence.append(
|
|
1721
|
+
DirectionEvidence(
|
|
1722
|
+
action=action,
|
|
1723
|
+
opcode=record.frame.opcode,
|
|
1724
|
+
detected_family=family,
|
|
1725
|
+
reference_frame=reference_frame,
|
|
1726
|
+
context_label=context_label,
|
|
1727
|
+
storage_context=storage_context,
|
|
1728
|
+
)
|
|
1729
|
+
)
|
|
1730
|
+
contradictory = family is None and context_label and storage_context
|
|
1731
|
+
contradictory_intrinsics = contradictory_intrinsics or contradictory
|
|
1732
|
+
genuinely_unclassified = (
|
|
1733
|
+
family is None
|
|
1734
|
+
and not context_label
|
|
1735
|
+
and not storage_context
|
|
1736
|
+
and not reference_frame
|
|
1737
|
+
)
|
|
1738
|
+
if family == expected or (
|
|
1739
|
+
allow_unclassified and genuinely_unclassified
|
|
1740
|
+
):
|
|
1741
|
+
matched.append(record)
|
|
1742
|
+
elif family is not None:
|
|
1743
|
+
opposite = family
|
|
1744
|
+
|
|
1745
|
+
if not matched and contradictory_intrinsics and strict:
|
|
1746
|
+
raise DirectionMismatchError(
|
|
1747
|
+
f"declared action {action!r} but the capture contains a candidate "
|
|
1748
|
+
"with contradictory intrinsic direction signals; refusing to guess"
|
|
1749
|
+
)
|
|
1750
|
+
if not matched and opposite is not None and strict:
|
|
1751
|
+
observed = (
|
|
1752
|
+
"storage-to-inventory"
|
|
1753
|
+
if opposite == "into_inventory"
|
|
1754
|
+
else "inventory-to-storage"
|
|
1755
|
+
)
|
|
1756
|
+
raise DirectionMismatchError(
|
|
1757
|
+
f"declared action {action!r} but the capture's structure indicates "
|
|
1758
|
+
f"{observed!r} (item entering "
|
|
1759
|
+
f"{'inventory' if opposite == 'into_inventory' else 'storage'}). "
|
|
1760
|
+
"Perform the declared action, or use auto calibration."
|
|
1761
|
+
)
|
|
1762
|
+
return matched
|
|
1763
|
+
|
|
1764
|
+
|
|
1765
|
+
# --- calibration heuristics, ported unchanged from the research prototype ---
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
def _calibrate_loot_preview(
|
|
1769
|
+
frames: list[BDOFrame],
|
|
1770
|
+
options: _Options,
|
|
1771
|
+
ignored: list[str],
|
|
1772
|
+
) -> list[MessageSpec]:
|
|
1773
|
+
records = _find_calibration_item_records(frames, options, "loot-preview", ignored)
|
|
1774
|
+
preview_records = [
|
|
1775
|
+
record
|
|
1776
|
+
for record in records
|
|
1777
|
+
if record.instance == LOOT_PREVIEW_SENTINEL_INSTANCE
|
|
1778
|
+
and _passes_min_confidence(record.confidence, options.min_confidence)
|
|
1779
|
+
]
|
|
1780
|
+
if not preview_records:
|
|
1781
|
+
return []
|
|
1782
|
+
|
|
1783
|
+
best = max(preview_records, key=lambda record: record.confidence)
|
|
1784
|
+
return [
|
|
1785
|
+
MessageSpec(
|
|
1786
|
+
event="LOOT_PREVIEW",
|
|
1787
|
+
opcode=best.frame.opcode,
|
|
1788
|
+
length=best.frame.length,
|
|
1789
|
+
item_id_offset=best.item_offset,
|
|
1790
|
+
quantity_offset=best.item_offset + 4,
|
|
1791
|
+
item_instance_offset=best.instance_offset,
|
|
1792
|
+
confidence=_confidence_label(best.confidence),
|
|
1793
|
+
source=_calibration_source(options, "loot-preview"),
|
|
1794
|
+
observed_at=_iso_timestamp(best.frame.context.timestamp),
|
|
1795
|
+
score=best.confidence,
|
|
1796
|
+
)
|
|
1797
|
+
]
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
def _calibrate_storage_to_inventory(
|
|
1801
|
+
frames: list[BDOFrame],
|
|
1802
|
+
options: _Options,
|
|
1803
|
+
ignored: list[str],
|
|
1804
|
+
evidence: list[DirectionEvidence],
|
|
1805
|
+
strict: bool,
|
|
1806
|
+
) -> list[MessageSpec]:
|
|
1807
|
+
records = _find_calibration_item_records(
|
|
1808
|
+
frames,
|
|
1809
|
+
options,
|
|
1810
|
+
"storage-to-inventory",
|
|
1811
|
+
ignored,
|
|
1812
|
+
)
|
|
1813
|
+
receipt_records = [
|
|
1814
|
+
record
|
|
1815
|
+
for record in records
|
|
1816
|
+
if record.instance is not None
|
|
1817
|
+
and record.instance != LOOT_PREVIEW_SENTINEL_INSTANCE
|
|
1818
|
+
and _passes_min_confidence(record.confidence, options.min_confidence)
|
|
1819
|
+
]
|
|
1820
|
+
# Family selection subsumes the legacy "known context label before the
|
|
1821
|
+
# record" receipt filter (into_inventory fires on exactly that label), and
|
|
1822
|
+
# running it on ALL structural candidates makes strict mismatch detection
|
|
1823
|
+
# symmetric: a wrong-direction capture raises here with evidence recorded
|
|
1824
|
+
# instead of silently pre-filtering down to an empty result.
|
|
1825
|
+
frame_index = options.frame_index or _FrameIndex(frames)
|
|
1826
|
+
receipt_records = _select_records_by_family(
|
|
1827
|
+
frames,
|
|
1828
|
+
receipt_records,
|
|
1829
|
+
"storage-to-inventory",
|
|
1830
|
+
options.context_frames,
|
|
1831
|
+
evidence,
|
|
1832
|
+
strict,
|
|
1833
|
+
frame_index,
|
|
1834
|
+
)
|
|
1835
|
+
if not receipt_records:
|
|
1836
|
+
return []
|
|
1837
|
+
|
|
1838
|
+
# On ties (a multi-record frame yields one candidate per record, all with
|
|
1839
|
+
# equal confidence) prefer the FIRST record: spec offsets are relative to
|
|
1840
|
+
# the first record and later ones are reached via repeat_stride.
|
|
1841
|
+
best = max(
|
|
1842
|
+
receipt_records, key=lambda record: (record.confidence, -record.item_offset)
|
|
1843
|
+
)
|
|
1844
|
+
source_decrement = _discover_source_container_decrement(frames, best, options)
|
|
1845
|
+
if source_decrement is None:
|
|
1846
|
+
ignored.append(
|
|
1847
|
+
f'NOTE opcode=0x{best.frame.opcode:04X} '
|
|
1848
|
+
f'length={best.frame.length} item_offset={best.item_offset} '
|
|
1849
|
+
'reason="source-decrement-not-found;promoting-receipt-only"'
|
|
1850
|
+
)
|
|
1851
|
+
|
|
1852
|
+
# Write the SINGLE-record length even when calibrated from a multi-record
|
|
1853
|
+
# frame (unstackables): the recorded length acts as a minimum at load
|
|
1854
|
+
# time, so the observed multi-record length would block single transfers.
|
|
1855
|
+
layout_item_offset, layout_instance_offset = _first_transfer_record_layout(
|
|
1856
|
+
best.frame,
|
|
1857
|
+
best.item_offset,
|
|
1858
|
+
best.instance_offset,
|
|
1859
|
+
)
|
|
1860
|
+
single_record_length, observed_stride = _record_frame_shape(
|
|
1861
|
+
best.frame,
|
|
1862
|
+
best.item_id,
|
|
1863
|
+
layout_item_offset,
|
|
1864
|
+
layout_instance_offset,
|
|
1865
|
+
)
|
|
1866
|
+
specs = [
|
|
1867
|
+
MessageSpec(
|
|
1868
|
+
event="INVENTORY_TRANSFER",
|
|
1869
|
+
opcode=best.frame.opcode,
|
|
1870
|
+
length=single_record_length,
|
|
1871
|
+
item_id_offset=layout_item_offset,
|
|
1872
|
+
quantity_offset=layout_item_offset + 4,
|
|
1873
|
+
item_instance_offset=layout_instance_offset,
|
|
1874
|
+
context_offset=_discover_context_offset(best.frame, layout_item_offset),
|
|
1875
|
+
repeat_stride=observed_stride,
|
|
1876
|
+
confidence=_confidence_label(best.confidence),
|
|
1877
|
+
source=_calibration_source(options, "storage-to-inventory"),
|
|
1878
|
+
observed_at=_iso_timestamp(best.frame.context.timestamp),
|
|
1879
|
+
score=best.confidence,
|
|
1880
|
+
)
|
|
1881
|
+
]
|
|
1882
|
+
|
|
1883
|
+
if source_decrement is not None:
|
|
1884
|
+
specs.append(source_decrement)
|
|
1885
|
+
return specs
|
|
1886
|
+
|
|
1887
|
+
|
|
1888
|
+
def _calibrate_inventory_to_storage(
|
|
1889
|
+
frames: list[BDOFrame],
|
|
1890
|
+
options: _Options,
|
|
1891
|
+
ignored: list[str],
|
|
1892
|
+
evidence: list[DirectionEvidence],
|
|
1893
|
+
strict: bool,
|
|
1894
|
+
) -> list[MessageSpec]:
|
|
1895
|
+
records = _find_calibration_item_records(
|
|
1896
|
+
frames,
|
|
1897
|
+
options,
|
|
1898
|
+
"inventory-to-storage",
|
|
1899
|
+
ignored,
|
|
1900
|
+
)
|
|
1901
|
+
storage_records = [
|
|
1902
|
+
record
|
|
1903
|
+
for record in records
|
|
1904
|
+
if record.instance is not None
|
|
1905
|
+
and record.instance != LOOT_PREVIEW_SENTINEL_INSTANCE
|
|
1906
|
+
and _passes_min_confidence(record.confidence, options.min_confidence)
|
|
1907
|
+
]
|
|
1908
|
+
frame_index = options.frame_index or _FrameIndex(frames)
|
|
1909
|
+
storage_records = _select_records_by_family(
|
|
1910
|
+
frames,
|
|
1911
|
+
storage_records,
|
|
1912
|
+
"inventory-to-storage",
|
|
1913
|
+
options.context_frames,
|
|
1914
|
+
evidence,
|
|
1915
|
+
strict,
|
|
1916
|
+
frame_index,
|
|
1917
|
+
allow_unclassified=strict,
|
|
1918
|
+
)
|
|
1919
|
+
if not storage_records:
|
|
1920
|
+
return []
|
|
1921
|
+
|
|
1922
|
+
# Same first-record tie-break as the receipt path (multi-record frames).
|
|
1923
|
+
best = max(
|
|
1924
|
+
storage_records, key=lambda record: (record.confidence, -record.item_offset)
|
|
1925
|
+
)
|
|
1926
|
+
specs: list[MessageSpec] = []
|
|
1927
|
+
# The single-record wrapper normally wins the primary-record score because
|
|
1928
|
+
# its normalized message length is directly observable. Do not let that
|
|
1929
|
+
# choice discard stronger repeated decrement evidence from another
|
|
1930
|
+
# validated deposit in the same calibration run. Evaluate record zero of
|
|
1931
|
+
# every unique target deposit frame; repeated shapes already outrank their
|
|
1932
|
+
# single-record counterparts in companion scoring, while incompatible
|
|
1933
|
+
# equal-strength shapes still fail closed in the shared selector.
|
|
1934
|
+
first_storage_records: dict[int, _CalibratedItemRecord] = {}
|
|
1935
|
+
for record in storage_records:
|
|
1936
|
+
frame_identity = id(record.frame)
|
|
1937
|
+
previous = first_storage_records.get(frame_identity)
|
|
1938
|
+
if previous is None or record.item_offset < previous.item_offset:
|
|
1939
|
+
first_storage_records[frame_identity] = record
|
|
1940
|
+
source_stack_candidates: list[MessageSpec] = []
|
|
1941
|
+
for record in first_storage_records.values():
|
|
1942
|
+
candidate = _discover_source_stack_decrement(frames, record, options)
|
|
1943
|
+
if candidate is not None:
|
|
1944
|
+
source_stack_candidates.append(candidate)
|
|
1945
|
+
source_stack = _unique_best_companion_spec(source_stack_candidates)
|
|
1946
|
+
if source_stack is not None:
|
|
1947
|
+
specs.append(source_stack)
|
|
1948
|
+
|
|
1949
|
+
source_ref = _discover_source_item_reference(frames, best, options)
|
|
1950
|
+
if source_ref is not None:
|
|
1951
|
+
specs.append(source_ref)
|
|
1952
|
+
|
|
1953
|
+
# Same single-record length normalization as the receipt spec; also record
|
|
1954
|
+
# the observed stride so a multi-record storage delta (unstackable
|
|
1955
|
+
# deposits) decodes all records under the written profile.
|
|
1956
|
+
layout_item_offset, layout_instance_offset = _first_transfer_record_layout(
|
|
1957
|
+
best.frame,
|
|
1958
|
+
best.item_offset,
|
|
1959
|
+
best.instance_offset,
|
|
1960
|
+
)
|
|
1961
|
+
single_record_length, observed_stride = _record_frame_shape(
|
|
1962
|
+
best.frame,
|
|
1963
|
+
best.item_id,
|
|
1964
|
+
layout_item_offset,
|
|
1965
|
+
layout_instance_offset,
|
|
1966
|
+
)
|
|
1967
|
+
storage_context_offset = _discover_storage_context_offset_from_frames(
|
|
1968
|
+
(record.frame for record in storage_records),
|
|
1969
|
+
opcode=best.frame.opcode,
|
|
1970
|
+
item_offset=layout_item_offset,
|
|
1971
|
+
)
|
|
1972
|
+
record_count_offset = _discover_storage_record_count_offset(
|
|
1973
|
+
frames,
|
|
1974
|
+
records=storage_records,
|
|
1975
|
+
opcode=best.frame.opcode,
|
|
1976
|
+
item_offset=layout_item_offset,
|
|
1977
|
+
instance_offset=layout_instance_offset,
|
|
1978
|
+
single_record_length=single_record_length,
|
|
1979
|
+
)
|
|
1980
|
+
# The strongest target record can be the single-record action even when
|
|
1981
|
+
# the same guided run also contains the multi-record shape that proves the
|
|
1982
|
+
# wrapper stride. Learn that stride across every structurally compatible
|
|
1983
|
+
# same-opcode frame instead of coupling it to whichever record won the score
|
|
1984
|
+
# tie. This lets character-state analysis validate count-zero envelopes
|
|
1985
|
+
# even for an account whose storages are all empty after calibration.
|
|
1986
|
+
observed_strides = {observed_stride} if observed_stride is not None else set()
|
|
1987
|
+
seen_shape_messages: set[bytes] = set()
|
|
1988
|
+
for frame in frames:
|
|
1989
|
+
if frame.opcode != best.frame.opcode or frame.message in seen_shape_messages:
|
|
1990
|
+
continue
|
|
1991
|
+
seen_shape_messages.add(frame.message)
|
|
1992
|
+
candidate_base, candidate_stride = _record_frame_shape(
|
|
1993
|
+
frame,
|
|
1994
|
+
best.item_id,
|
|
1995
|
+
layout_item_offset,
|
|
1996
|
+
layout_instance_offset,
|
|
1997
|
+
)
|
|
1998
|
+
if candidate_base == single_record_length and candidate_stride is not None:
|
|
1999
|
+
observed_strides.add(candidate_stride)
|
|
2000
|
+
repeat_stride = (
|
|
2001
|
+
next(iter(observed_strides)) if len(observed_strides) == 1 else None
|
|
2002
|
+
)
|
|
2003
|
+
missing_authority: list[str] = []
|
|
2004
|
+
if storage_context_offset is None:
|
|
2005
|
+
missing_authority.append("destination-field")
|
|
2006
|
+
if record_count_offset is None:
|
|
2007
|
+
missing_authority.append("record-count-field")
|
|
2008
|
+
if missing_authority:
|
|
2009
|
+
missing_text = ", ".join(missing_authority)
|
|
2010
|
+
guidance: list[str] = []
|
|
2011
|
+
if "destination-field" in missing_authority:
|
|
2012
|
+
guidance.append(
|
|
2013
|
+
"repeat the deposit in an unambiguous registered town such as "
|
|
2014
|
+
"Velia or Heidel (or include controlled deposits to different towns)"
|
|
2015
|
+
)
|
|
2016
|
+
if "record-count-field" in missing_authority:
|
|
2017
|
+
guidance.append(
|
|
2018
|
+
"include two independently validated record counts (for example "
|
|
2019
|
+
"one single-record and one unstackable multi-record deposit, or "
|
|
2020
|
+
"two unstackable deposits with different counts)"
|
|
2021
|
+
)
|
|
2022
|
+
raise CalibrationAuthorityError(
|
|
2023
|
+
f"storage opcode 0x{best.frame.opcode:04X} was observed, but its "
|
|
2024
|
+
f"{missing_text} could not be uniquely proven. No calibration "
|
|
2025
|
+
"result was produced and no profile should be updated. To resolve "
|
|
2026
|
+
f"this, {'; and '.join(guidance)}. Then retry calibration."
|
|
2027
|
+
)
|
|
2028
|
+
specs.append(
|
|
2029
|
+
MessageSpec(
|
|
2030
|
+
event="STORAGE_ITEM_DELTA",
|
|
2031
|
+
opcode=best.frame.opcode,
|
|
2032
|
+
length=single_record_length,
|
|
2033
|
+
item_id_offset=layout_item_offset,
|
|
2034
|
+
quantity_added_offset=layout_item_offset + 4,
|
|
2035
|
+
destination_instance_offset=layout_instance_offset,
|
|
2036
|
+
context_offset=storage_context_offset,
|
|
2037
|
+
record_count_offset=record_count_offset,
|
|
2038
|
+
repeat_stride=repeat_stride,
|
|
2039
|
+
confidence=_confidence_label(best.confidence),
|
|
2040
|
+
source=_calibration_source(options, "inventory-to-storage"),
|
|
2041
|
+
observed_at=_iso_timestamp(best.frame.context.timestamp),
|
|
2042
|
+
score=best.confidence,
|
|
2043
|
+
)
|
|
2044
|
+
)
|
|
2045
|
+
return specs
|
|
2046
|
+
|
|
2047
|
+
|
|
2048
|
+
def _find_calibration_item_records(
|
|
2049
|
+
frames: list[BDOFrame],
|
|
2050
|
+
options: _Options,
|
|
2051
|
+
action: str,
|
|
2052
|
+
ignored: list[str],
|
|
2053
|
+
) -> list[_CalibratedItemRecord]:
|
|
2054
|
+
item_bytes = options.item_id.to_bytes(4, "little")
|
|
2055
|
+
records: list[_CalibratedItemRecord] = []
|
|
2056
|
+
|
|
2057
|
+
for frame in frames:
|
|
2058
|
+
frame_quantity_total = _sum_plausible_item_record_quantities(
|
|
2059
|
+
frame,
|
|
2060
|
+
item_bytes,
|
|
2061
|
+
)
|
|
2062
|
+
quantity_only = (
|
|
2063
|
+
options.quantity is not None
|
|
2064
|
+
and options.quantity.to_bytes(4, "little") in frame.message
|
|
2065
|
+
and item_bytes not in frame.message
|
|
2066
|
+
)
|
|
2067
|
+
if quantity_only:
|
|
2068
|
+
ignored.append(
|
|
2069
|
+
f'IGNORED opcode=0x{frame.opcode:04X} length={frame.length} '
|
|
2070
|
+
'reason="quantity-only"'
|
|
2071
|
+
)
|
|
2072
|
+
|
|
2073
|
+
search_at = 0
|
|
2074
|
+
while True:
|
|
2075
|
+
item_offset = frame.message.find(item_bytes, search_at)
|
|
2076
|
+
if item_offset < 0:
|
|
2077
|
+
break
|
|
2078
|
+
search_at = item_offset + 1
|
|
2079
|
+
|
|
2080
|
+
if item_offset + 8 > len(frame.message):
|
|
2081
|
+
ignored.append(
|
|
2082
|
+
f'IGNORED opcode=0x{frame.opcode:04X} length={frame.length} '
|
|
2083
|
+
f'item_offset={item_offset} reason="truncated-item-record"'
|
|
2084
|
+
)
|
|
2085
|
+
continue
|
|
2086
|
+
|
|
2087
|
+
quantity = int.from_bytes(
|
|
2088
|
+
frame.message[item_offset + 4 : item_offset + 8],
|
|
2089
|
+
"little",
|
|
2090
|
+
)
|
|
2091
|
+
instance_offset = item_offset + 35
|
|
2092
|
+
instance = (
|
|
2093
|
+
bytes(frame.message[instance_offset : instance_offset + 8])
|
|
2094
|
+
if instance_offset + 8 <= len(frame.message)
|
|
2095
|
+
else None
|
|
2096
|
+
)
|
|
2097
|
+
confidence, reasons = _score_item_record_candidate(
|
|
2098
|
+
frame=frame,
|
|
2099
|
+
quantity=quantity,
|
|
2100
|
+
instance=instance,
|
|
2101
|
+
options=options,
|
|
2102
|
+
action=action,
|
|
2103
|
+
frame_quantity_total=frame_quantity_total,
|
|
2104
|
+
)
|
|
2105
|
+
if not _passes_min_confidence(confidence, options.min_confidence):
|
|
2106
|
+
ignored.append(
|
|
2107
|
+
f'IGNORED opcode=0x{frame.opcode:04X} length={frame.length} '
|
|
2108
|
+
f'item_offset={item_offset} reason="low-confidence:{confidence:.2f}"'
|
|
2109
|
+
)
|
|
2110
|
+
continue
|
|
2111
|
+
|
|
2112
|
+
records.append(
|
|
2113
|
+
_CalibratedItemRecord(
|
|
2114
|
+
frame=frame,
|
|
2115
|
+
item_offset=item_offset,
|
|
2116
|
+
item_id=options.item_id,
|
|
2117
|
+
quantity=quantity,
|
|
2118
|
+
instance_offset=instance_offset if instance is not None else None,
|
|
2119
|
+
instance=instance,
|
|
2120
|
+
confidence=confidence,
|
|
2121
|
+
reasons=tuple(reasons),
|
|
2122
|
+
)
|
|
2123
|
+
)
|
|
2124
|
+
|
|
2125
|
+
return records
|
|
2126
|
+
|
|
2127
|
+
|
|
2128
|
+
def _passes_min_confidence(confidence: float, min_confidence: float) -> bool:
|
|
2129
|
+
return confidence + 1e-9 >= min_confidence
|
|
2130
|
+
|
|
2131
|
+
|
|
2132
|
+
def _score_item_record_candidate(
|
|
2133
|
+
*,
|
|
2134
|
+
frame: BDOFrame,
|
|
2135
|
+
quantity: int,
|
|
2136
|
+
instance: Optional[bytes],
|
|
2137
|
+
options: _Options,
|
|
2138
|
+
action: str,
|
|
2139
|
+
frame_quantity_total: Optional[int],
|
|
2140
|
+
) -> tuple[float, list[str]]:
|
|
2141
|
+
score = 0.35
|
|
2142
|
+
reasons = ["contains-watched-item"]
|
|
2143
|
+
|
|
2144
|
+
if 0 < quantity <= 1_000_000:
|
|
2145
|
+
reasons.append("plausible-quantity")
|
|
2146
|
+
if options.quantity is None:
|
|
2147
|
+
score += 0.15
|
|
2148
|
+
elif quantity == options.quantity:
|
|
2149
|
+
score += 0.25
|
|
2150
|
+
reasons.append("quantity-match")
|
|
2151
|
+
elif frame_quantity_total == options.quantity:
|
|
2152
|
+
score += 0.20
|
|
2153
|
+
reasons.append("multi-record-total-quantity-match")
|
|
2154
|
+
else:
|
|
2155
|
+
score -= 0.20
|
|
2156
|
+
reasons.append("quantity-mismatch")
|
|
2157
|
+
else:
|
|
2158
|
+
score -= 0.30
|
|
2159
|
+
reasons.append("implausible-quantity")
|
|
2160
|
+
|
|
2161
|
+
if instance is not None:
|
|
2162
|
+
score += 0.20
|
|
2163
|
+
reasons.append("instance-present")
|
|
2164
|
+
else:
|
|
2165
|
+
score -= 0.20
|
|
2166
|
+
reasons.append("instance-missing")
|
|
2167
|
+
|
|
2168
|
+
if 200 <= frame.length <= 300:
|
|
2169
|
+
score += 0.10
|
|
2170
|
+
reasons.append("plausible-wrapper-length")
|
|
2171
|
+
|
|
2172
|
+
score += 0.10
|
|
2173
|
+
reasons.append(f"action-window:{action}")
|
|
2174
|
+
|
|
2175
|
+
if action == "loot-preview":
|
|
2176
|
+
if instance == LOOT_PREVIEW_SENTINEL_INSTANCE:
|
|
2177
|
+
score += 0.10
|
|
2178
|
+
reasons.append("preview-sentinel-instance")
|
|
2179
|
+
else:
|
|
2180
|
+
score -= 0.20
|
|
2181
|
+
reasons.append("preview-instance-not-sentinel")
|
|
2182
|
+
elif action in {"storage-to-inventory", "inventory-to-storage"}:
|
|
2183
|
+
if instance == LOOT_PREVIEW_SENTINEL_INSTANCE:
|
|
2184
|
+
score -= 0.20
|
|
2185
|
+
reasons.append("real-transfer-has-preview-sentinel")
|
|
2186
|
+
|
|
2187
|
+
if instance is None and frame.length < 100:
|
|
2188
|
+
score -= 0.20
|
|
2189
|
+
reasons.append("tiny-hit-without-instance")
|
|
2190
|
+
|
|
2191
|
+
return max(0.0, min(1.0, score)), reasons
|
|
2192
|
+
|
|
2193
|
+
|
|
2194
|
+
def _plausible_record_offsets(frame: BDOFrame, item_bytes: bytes) -> list[int]:
|
|
2195
|
+
"""Offsets of plausible watched-item records (item id + qty + instance)."""
|
|
2196
|
+
offsets: list[int] = []
|
|
2197
|
+
search_at = 0
|
|
2198
|
+
while True:
|
|
2199
|
+
item_offset = frame.message.find(item_bytes, search_at)
|
|
2200
|
+
if item_offset < 0:
|
|
2201
|
+
return offsets
|
|
2202
|
+
search_at = item_offset + 1
|
|
2203
|
+
if item_offset + 43 > len(frame.message):
|
|
2204
|
+
continue
|
|
2205
|
+
quantity = int.from_bytes(
|
|
2206
|
+
frame.message[item_offset + 4 : item_offset + 8], "little"
|
|
2207
|
+
)
|
|
2208
|
+
instance = frame.message[item_offset + 35 : item_offset + 43]
|
|
2209
|
+
if 0 < quantity <= 1_000_000 and _is_plausible_instance(instance):
|
|
2210
|
+
offsets.append(item_offset)
|
|
2211
|
+
|
|
2212
|
+
|
|
2213
|
+
def _sum_plausible_item_record_quantities(
|
|
2214
|
+
frame: BDOFrame,
|
|
2215
|
+
item_bytes: bytes,
|
|
2216
|
+
) -> Optional[int]:
|
|
2217
|
+
offsets = _plausible_record_offsets(frame, item_bytes)
|
|
2218
|
+
if not offsets:
|
|
2219
|
+
return None
|
|
2220
|
+
return sum(
|
|
2221
|
+
int.from_bytes(frame.message[offset + 4 : offset + 8], "little")
|
|
2222
|
+
for offset in offsets
|
|
2223
|
+
)
|
|
2224
|
+
|
|
2225
|
+
|
|
2226
|
+
def _record_frame_shape(
|
|
2227
|
+
frame: BDOFrame,
|
|
2228
|
+
item_id: int,
|
|
2229
|
+
item_offset: int,
|
|
2230
|
+
instance_offset: Optional[int],
|
|
2231
|
+
) -> tuple[int, Optional[int]]:
|
|
2232
|
+
"""``(single_record_length, stride)`` for a repeated-record frame.
|
|
2233
|
+
|
|
2234
|
+
A frame carrying N watched-item records at a uniform stride (unstackables
|
|
2235
|
+
move as N records of quantity 1) must be written into the profile at its
|
|
2236
|
+
SINGLE-record length: the profile loader treats the recorded length as a
|
|
2237
|
+
minimum message length, so writing the observed multi-record length would
|
|
2238
|
+
produce a profile that cannot decode ordinary single transfers.
|
|
2239
|
+
|
|
2240
|
+
Full transfer-record markers are used first so mixed-item batches can be
|
|
2241
|
+
normalized too. Repeated watched-item offsets remain as a fallback for
|
|
2242
|
+
older layouts without those markers.
|
|
2243
|
+
"""
|
|
2244
|
+
offsets = _full_transfer_record_offsets(frame, item_offset, instance_offset)
|
|
2245
|
+
if len(offsets) < 2:
|
|
2246
|
+
offsets = _plausible_record_offsets(frame, item_id.to_bytes(4, "little"))
|
|
2247
|
+
if len(offsets) < 2:
|
|
2248
|
+
return frame.length, None
|
|
2249
|
+
deltas = {b - a for a, b in zip(offsets, offsets[1:])}
|
|
2250
|
+
if len(deltas) != 1:
|
|
2251
|
+
return frame.length, None
|
|
2252
|
+
stride = deltas.pop()
|
|
2253
|
+
return frame.length - (len(offsets) - 1) * stride, stride
|
|
2254
|
+
|
|
2255
|
+
|
|
2256
|
+
def _first_transfer_record_layout(
|
|
2257
|
+
frame: BDOFrame,
|
|
2258
|
+
item_offset: int,
|
|
2259
|
+
instance_offset: Optional[int],
|
|
2260
|
+
) -> tuple[int, Optional[int]]:
|
|
2261
|
+
"""Normalize a watched later batch item back to record zero's offsets."""
|
|
2262
|
+
if instance_offset is None:
|
|
2263
|
+
return item_offset, None
|
|
2264
|
+
instance_delta = instance_offset - item_offset
|
|
2265
|
+
offsets = _full_transfer_record_offsets(frame, item_offset, instance_offset)
|
|
2266
|
+
if not offsets:
|
|
2267
|
+
return item_offset, instance_offset
|
|
2268
|
+
first_item_offset = offsets[0]
|
|
2269
|
+
return first_item_offset, first_item_offset + instance_delta
|
|
2270
|
+
|
|
2271
|
+
|
|
2272
|
+
def _full_transfer_record_offsets(
|
|
2273
|
+
frame: BDOFrame,
|
|
2274
|
+
item_offset: int,
|
|
2275
|
+
instance_offset: Optional[int],
|
|
2276
|
+
) -> list[int]:
|
|
2277
|
+
"""Locate structurally complete item records, including mixed-item batches."""
|
|
2278
|
+
if instance_offset is None:
|
|
2279
|
+
return []
|
|
2280
|
+
instance_delta = instance_offset - item_offset
|
|
2281
|
+
if instance_delta < 8:
|
|
2282
|
+
return []
|
|
2283
|
+
return [
|
|
2284
|
+
offset
|
|
2285
|
+
for offset in range(5, len(frame.message))
|
|
2286
|
+
if _looks_like_transfer_record(frame, offset, instance_delta)
|
|
2287
|
+
]
|
|
2288
|
+
|
|
2289
|
+
|
|
2290
|
+
def _looks_like_transfer_record(
|
|
2291
|
+
frame: BDOFrame,
|
|
2292
|
+
item_offset: int,
|
|
2293
|
+
instance_delta: int,
|
|
2294
|
+
) -> bool:
|
|
2295
|
+
required_end = item_offset + max(20, instance_delta + 8)
|
|
2296
|
+
if required_end > len(frame.message):
|
|
2297
|
+
return False
|
|
2298
|
+
item_id = int.from_bytes(frame.message[item_offset : item_offset + 4], "little")
|
|
2299
|
+
quantity = int.from_bytes(
|
|
2300
|
+
frame.message[item_offset + 4 : item_offset + 8], "little"
|
|
2301
|
+
)
|
|
2302
|
+
instance = bytes(
|
|
2303
|
+
frame.message[
|
|
2304
|
+
item_offset + instance_delta : item_offset + instance_delta + 8
|
|
2305
|
+
]
|
|
2306
|
+
)
|
|
2307
|
+
return (
|
|
2308
|
+
0 < item_id <= MAX_PLAUSIBLE_ITEM_ID
|
|
2309
|
+
and 0 < quantity <= 1_000_000
|
|
2310
|
+
and _is_plausible_instance(instance)
|
|
2311
|
+
and frame.message[item_offset + 8 : item_offset + 12] == b"\x00" * 4
|
|
2312
|
+
and frame.message[item_offset + 12 : item_offset + 20] == b"\xff" * 8
|
|
2313
|
+
)
|
|
2314
|
+
|
|
2315
|
+
|
|
2316
|
+
def _discover_source_container_decrement(
|
|
2317
|
+
frames: list[BDOFrame],
|
|
2318
|
+
receipt: _CalibratedItemRecord,
|
|
2319
|
+
options: _Options,
|
|
2320
|
+
) -> Optional[MessageSpec]:
|
|
2321
|
+
"""Find the storage-side decrement that precedes an inventory receipt.
|
|
2322
|
+
|
|
2323
|
+
Companion layouts have changed field order across patches, so neither the
|
|
2324
|
+
source instance nor the context is located relative to a fixed field. The
|
|
2325
|
+
moved quantity and a source context identify the companion; an exact
|
|
2326
|
+
receipt-instance match strengthens the result and supplies its offset.
|
|
2327
|
+
"""
|
|
2328
|
+
item_bytes = receipt.item_id.to_bytes(4, "little")
|
|
2329
|
+
quantity_bytes = receipt.quantity.to_bytes(4, "little")
|
|
2330
|
+
candidates: list[MessageSpec] = []
|
|
2331
|
+
|
|
2332
|
+
context = _context_before(
|
|
2333
|
+
options.frame_index or _FrameIndex(frames),
|
|
2334
|
+
receipt.frame,
|
|
2335
|
+
options.context_frames,
|
|
2336
|
+
)
|
|
2337
|
+
for frame in reversed(context):
|
|
2338
|
+
if not 20 <= frame.length <= REFERENCE_FRAME_MAX_LENGTH:
|
|
2339
|
+
continue
|
|
2340
|
+
if item_bytes in frame.message:
|
|
2341
|
+
continue
|
|
2342
|
+
quantity_offsets = _find_all(frame.message, quantity_bytes)
|
|
2343
|
+
if not quantity_offsets:
|
|
2344
|
+
continue
|
|
2345
|
+
|
|
2346
|
+
instance_offsets = (
|
|
2347
|
+
_find_all(frame.message, receipt.instance)
|
|
2348
|
+
if receipt.instance is not None
|
|
2349
|
+
else []
|
|
2350
|
+
)
|
|
2351
|
+
# Multiple occurrences do not prove which field is the source
|
|
2352
|
+
# instance. Keep the family calibratable, but omit the uncertain
|
|
2353
|
+
# optional offset instead of choosing one by position.
|
|
2354
|
+
exact_instance_offset = (
|
|
2355
|
+
instance_offsets[0] if len(instance_offsets) == 1 else None
|
|
2356
|
+
)
|
|
2357
|
+
|
|
2358
|
+
for quantity_offset in quantity_offsets:
|
|
2359
|
+
# The current layout places context after instance; older layouts
|
|
2360
|
+
# place it before instance. Both put context before the quantity.
|
|
2361
|
+
context_offset = _discover_context_offset(frame, quantity_offset)
|
|
2362
|
+
if context_offset is None:
|
|
2363
|
+
continue
|
|
2364
|
+
if exact_instance_offset is not None and _ranges_overlap(
|
|
2365
|
+
exact_instance_offset, 8, quantity_offset, 4
|
|
2366
|
+
):
|
|
2367
|
+
continue
|
|
2368
|
+
structural_instance_offset = _source_container_structural_instance_offset(
|
|
2369
|
+
frame, quantity_offset
|
|
2370
|
+
)
|
|
2371
|
+
instance_offset: Optional[int] = exact_instance_offset
|
|
2372
|
+
if instance_offset is not None:
|
|
2373
|
+
score = 0.90
|
|
2374
|
+
elif structural_instance_offset is not None:
|
|
2375
|
+
instance_offset = structural_instance_offset
|
|
2376
|
+
score = 0.86
|
|
2377
|
+
else:
|
|
2378
|
+
score = 0.82
|
|
2379
|
+
candidates.append(
|
|
2380
|
+
MessageSpec(
|
|
2381
|
+
event="SOURCE_CONTAINER_DECREMENT",
|
|
2382
|
+
opcode=frame.opcode,
|
|
2383
|
+
length=frame.length,
|
|
2384
|
+
context_offset=context_offset,
|
|
2385
|
+
source_instance_offset=instance_offset,
|
|
2386
|
+
quantity_removed_offset=quantity_offset,
|
|
2387
|
+
confidence=_confidence_label(score),
|
|
2388
|
+
source=_calibration_source(options, "storage-to-inventory"),
|
|
2389
|
+
observed_at=_iso_timestamp(frame.context.timestamp),
|
|
2390
|
+
score=score,
|
|
2391
|
+
)
|
|
2392
|
+
)
|
|
2393
|
+
return _unique_best_companion_spec(candidates)
|
|
2394
|
+
|
|
2395
|
+
|
|
2396
|
+
def _discover_source_stack_decrement(
|
|
2397
|
+
frames: list[BDOFrame],
|
|
2398
|
+
storage_delta: _CalibratedItemRecord,
|
|
2399
|
+
options: _Options,
|
|
2400
|
+
) -> Optional[MessageSpec]:
|
|
2401
|
+
"""Find the inventory-side decrement that precedes a storage delta.
|
|
2402
|
+
|
|
2403
|
+
Older layouts put the source instance before the quantity; the current
|
|
2404
|
+
layout puts it after. Search for the exact instance independently. If it
|
|
2405
|
+
cannot be correlated, a unique decrement -> item-reference -> delta chain
|
|
2406
|
+
can still identify the family without inventing an instance offset.
|
|
2407
|
+
"""
|
|
2408
|
+
item_bytes = storage_delta.item_id.to_bytes(4, "little")
|
|
2409
|
+
quantity = (
|
|
2410
|
+
options.quantity
|
|
2411
|
+
if options.quantity is not None
|
|
2412
|
+
else storage_delta.quantity
|
|
2413
|
+
)
|
|
2414
|
+
quantity_bytes = quantity.to_bytes(4, "little")
|
|
2415
|
+
storage_record_offsets = _full_transfer_record_offsets(
|
|
2416
|
+
storage_delta.frame,
|
|
2417
|
+
storage_delta.item_offset,
|
|
2418
|
+
storage_delta.instance_offset,
|
|
2419
|
+
)
|
|
2420
|
+
expected_record_count = (
|
|
2421
|
+
len(storage_record_offsets) if len(storage_record_offsets) > 1 else None
|
|
2422
|
+
)
|
|
2423
|
+
context = _context_before(
|
|
2424
|
+
options.frame_index or _FrameIndex(frames),
|
|
2425
|
+
storage_delta.frame,
|
|
2426
|
+
options.context_frames,
|
|
2427
|
+
)
|
|
2428
|
+
candidates: list[MessageSpec] = []
|
|
2429
|
+
|
|
2430
|
+
for frame_index, frame in enumerate(context):
|
|
2431
|
+
if not 20 <= frame.length <= SOURCE_DECREMENT_FRAME_MAX_LENGTH:
|
|
2432
|
+
continue
|
|
2433
|
+
if item_bytes in frame.message:
|
|
2434
|
+
continue
|
|
2435
|
+
instance_offsets = (
|
|
2436
|
+
_find_all(frame.message, storage_delta.instance)
|
|
2437
|
+
if storage_delta.instance is not None
|
|
2438
|
+
else []
|
|
2439
|
+
)
|
|
2440
|
+
exact_instance_offset = (
|
|
2441
|
+
instance_offsets[0] if len(instance_offsets) == 1 else None
|
|
2442
|
+
)
|
|
2443
|
+
if (
|
|
2444
|
+
frame.length > REFERENCE_FRAME_MAX_LENGTH
|
|
2445
|
+
and exact_instance_offset is None
|
|
2446
|
+
):
|
|
2447
|
+
# Wider decrement batches are admitted only through an exact
|
|
2448
|
+
# cross-frame instance anchor. Otherwise ordinary context frames
|
|
2449
|
+
# carrying common quantities can tie the established compact
|
|
2450
|
+
# structural candidate.
|
|
2451
|
+
continue
|
|
2452
|
+
has_later_reference = any(
|
|
2453
|
+
_is_source_item_reference(candidate, item_bytes)
|
|
2454
|
+
for candidate in context[frame_index + 1 :]
|
|
2455
|
+
)
|
|
2456
|
+
if exact_instance_offset is None and not has_later_reference:
|
|
2457
|
+
continue
|
|
2458
|
+
|
|
2459
|
+
repeated_shape = _source_stack_repeated_shape(
|
|
2460
|
+
frame,
|
|
2461
|
+
quantity_bytes,
|
|
2462
|
+
exact_instance_offset,
|
|
2463
|
+
expected_record_count=expected_record_count,
|
|
2464
|
+
)
|
|
2465
|
+
if repeated_shape is not None:
|
|
2466
|
+
base_length, repeat_stride, instance_offset, quantity_offset = (
|
|
2467
|
+
repeated_shape
|
|
2468
|
+
)
|
|
2469
|
+
candidates.append(
|
|
2470
|
+
MessageSpec(
|
|
2471
|
+
event="SOURCE_STACK_DECREMENT",
|
|
2472
|
+
opcode=frame.opcode,
|
|
2473
|
+
length=base_length,
|
|
2474
|
+
repeat_stride=repeat_stride,
|
|
2475
|
+
source_instance_offset=instance_offset,
|
|
2476
|
+
quantity_removed_offset=quantity_offset,
|
|
2477
|
+
confidence=_confidence_label(0.90),
|
|
2478
|
+
source=_calibration_source(options, "inventory-to-storage"),
|
|
2479
|
+
observed_at=_iso_timestamp(frame.context.timestamp),
|
|
2480
|
+
score=0.90,
|
|
2481
|
+
)
|
|
2482
|
+
)
|
|
2483
|
+
continue
|
|
2484
|
+
|
|
2485
|
+
for quantity_offset in _find_all(frame.message, quantity_bytes):
|
|
2486
|
+
if exact_instance_offset is not None and _ranges_overlap(
|
|
2487
|
+
exact_instance_offset, 8, quantity_offset, 4
|
|
2488
|
+
):
|
|
2489
|
+
continue
|
|
2490
|
+
structural_instance_offset = _source_stack_structural_instance_offset(
|
|
2491
|
+
frame, quantity_offset
|
|
2492
|
+
)
|
|
2493
|
+
candidate_instance_offset: Optional[int] = exact_instance_offset
|
|
2494
|
+
if candidate_instance_offset is not None:
|
|
2495
|
+
score = 0.88
|
|
2496
|
+
elif structural_instance_offset is not None:
|
|
2497
|
+
candidate_instance_offset = structural_instance_offset
|
|
2498
|
+
score = 0.86
|
|
2499
|
+
else:
|
|
2500
|
+
score = 0.82
|
|
2501
|
+
candidates.append(
|
|
2502
|
+
MessageSpec(
|
|
2503
|
+
event="SOURCE_STACK_DECREMENT",
|
|
2504
|
+
opcode=frame.opcode,
|
|
2505
|
+
length=frame.length,
|
|
2506
|
+
source_instance_offset=candidate_instance_offset,
|
|
2507
|
+
quantity_removed_offset=quantity_offset,
|
|
2508
|
+
confidence=_confidence_label(score),
|
|
2509
|
+
source=_calibration_source(options, "inventory-to-storage"),
|
|
2510
|
+
observed_at=_iso_timestamp(frame.context.timestamp),
|
|
2511
|
+
score=score,
|
|
2512
|
+
)
|
|
2513
|
+
)
|
|
2514
|
+
return _unique_best_companion_spec(candidates)
|
|
2515
|
+
|
|
2516
|
+
|
|
2517
|
+
def _source_stack_repeated_shape(
|
|
2518
|
+
frame: BDOFrame,
|
|
2519
|
+
quantity_bytes: bytes,
|
|
2520
|
+
exact_instance_offset: Optional[int],
|
|
2521
|
+
*,
|
|
2522
|
+
expected_record_count: Optional[int] = None,
|
|
2523
|
+
) -> Optional[tuple[int, int, int, int]]:
|
|
2524
|
+
"""Normalize an instance-anchored decrement batch to record-one geometry.
|
|
2525
|
+
|
|
2526
|
+
The quantity/instance phase is part of the repeated record, not a stable
|
|
2527
|
+
patch constant. Anchor record zero with the exact destination instance,
|
|
2528
|
+
try every repeated quantity phase that keeps both fields inside one
|
|
2529
|
+
record, and retain only one longest valid geometry. Longer stride
|
|
2530
|
+
multiples can be aliases that skip records; equal-strength distinct
|
|
2531
|
+
phases are ambiguous and fail closed.
|
|
2532
|
+
"""
|
|
2533
|
+
|
|
2534
|
+
if exact_instance_offset is None:
|
|
2535
|
+
return None
|
|
2536
|
+
if expected_record_count is not None and expected_record_count < 2:
|
|
2537
|
+
return None
|
|
2538
|
+
quantity_offsets = tuple(sorted(set(_find_all(frame.message, quantity_bytes))))
|
|
2539
|
+
if len(quantity_offsets) < 2:
|
|
2540
|
+
return None
|
|
2541
|
+
quantity_offset_set = set(quantity_offsets)
|
|
2542
|
+
|
|
2543
|
+
# Four quantity bytes and eight instance bytes must coexist without
|
|
2544
|
+
# overlap inside one repeated record, so a smaller stride cannot be a
|
|
2545
|
+
# valid record geometry. This is a field-width invariant, not a layout
|
|
2546
|
+
# constant.
|
|
2547
|
+
minimum_stride = 12
|
|
2548
|
+
candidates: list[tuple[int, int, int, int]] = []
|
|
2549
|
+
for first_quantity_offset in quantity_offsets:
|
|
2550
|
+
if _ranges_overlap(
|
|
2551
|
+
first_quantity_offset,
|
|
2552
|
+
4,
|
|
2553
|
+
exact_instance_offset,
|
|
2554
|
+
8,
|
|
2555
|
+
):
|
|
2556
|
+
continue
|
|
2557
|
+
for later_quantity_offset in quantity_offsets:
|
|
2558
|
+
repeat_stride = later_quantity_offset - first_quantity_offset
|
|
2559
|
+
if repeat_stride < minimum_stride:
|
|
2560
|
+
continue
|
|
2561
|
+
|
|
2562
|
+
record_count = 0
|
|
2563
|
+
while True:
|
|
2564
|
+
delta = record_count * repeat_stride
|
|
2565
|
+
quantity_offset = first_quantity_offset + delta
|
|
2566
|
+
instance_offset = exact_instance_offset + delta
|
|
2567
|
+
if quantity_offset not in quantity_offset_set:
|
|
2568
|
+
break
|
|
2569
|
+
if (
|
|
2570
|
+
quantity_offset < 5
|
|
2571
|
+
or quantity_offset + 4 > frame.length
|
|
2572
|
+
or instance_offset < 5
|
|
2573
|
+
or instance_offset + 8 > frame.length
|
|
2574
|
+
or _ranges_overlap(
|
|
2575
|
+
quantity_offset,
|
|
2576
|
+
4,
|
|
2577
|
+
instance_offset,
|
|
2578
|
+
8,
|
|
2579
|
+
)
|
|
2580
|
+
or not _is_plausible_instance(
|
|
2581
|
+
frame.message[instance_offset : instance_offset + 8]
|
|
2582
|
+
)
|
|
2583
|
+
):
|
|
2584
|
+
break
|
|
2585
|
+
record_count += 1
|
|
2586
|
+
if record_count < 2 or (
|
|
2587
|
+
expected_record_count is not None
|
|
2588
|
+
and record_count != expected_record_count
|
|
2589
|
+
):
|
|
2590
|
+
continue
|
|
2591
|
+
|
|
2592
|
+
prefix_length = frame.length - record_count * repeat_stride
|
|
2593
|
+
base_length = prefix_length + repeat_stride
|
|
2594
|
+
if (
|
|
2595
|
+
prefix_length < 5
|
|
2596
|
+
or first_quantity_offset < prefix_length
|
|
2597
|
+
or exact_instance_offset < prefix_length
|
|
2598
|
+
or first_quantity_offset + 4 > base_length
|
|
2599
|
+
or exact_instance_offset + 8 > base_length
|
|
2600
|
+
):
|
|
2601
|
+
continue
|
|
2602
|
+
candidates.append(
|
|
2603
|
+
(
|
|
2604
|
+
record_count,
|
|
2605
|
+
base_length,
|
|
2606
|
+
repeat_stride,
|
|
2607
|
+
first_quantity_offset,
|
|
2608
|
+
)
|
|
2609
|
+
)
|
|
2610
|
+
|
|
2611
|
+
if not candidates:
|
|
2612
|
+
return None
|
|
2613
|
+
best_count = max(candidate[0] for candidate in candidates)
|
|
2614
|
+
best_shapes = {
|
|
2615
|
+
(base_length, repeat_stride, first_quantity_offset)
|
|
2616
|
+
for (
|
|
2617
|
+
record_count,
|
|
2618
|
+
base_length,
|
|
2619
|
+
repeat_stride,
|
|
2620
|
+
first_quantity_offset,
|
|
2621
|
+
) in candidates
|
|
2622
|
+
if record_count == best_count
|
|
2623
|
+
}
|
|
2624
|
+
if len(best_shapes) != 1:
|
|
2625
|
+
return None
|
|
2626
|
+
base_length, repeat_stride, first_quantity_offset = next(iter(best_shapes))
|
|
2627
|
+
return (
|
|
2628
|
+
base_length,
|
|
2629
|
+
repeat_stride,
|
|
2630
|
+
exact_instance_offset,
|
|
2631
|
+
first_quantity_offset,
|
|
2632
|
+
)
|
|
2633
|
+
|
|
2634
|
+
|
|
2635
|
+
def _source_container_structural_instance_offset(
|
|
2636
|
+
frame: BDOFrame,
|
|
2637
|
+
quantity_offset: int,
|
|
2638
|
+
) -> Optional[int]:
|
|
2639
|
+
"""Recognize the legacy ``instance + separator + quantity`` layout."""
|
|
2640
|
+
instance_offset = quantity_offset - 9
|
|
2641
|
+
separator = frame.message[quantity_offset - 1 : quantity_offset]
|
|
2642
|
+
if instance_offset < 5 or separator != b"\x02":
|
|
2643
|
+
return None
|
|
2644
|
+
instance = frame.message[instance_offset : instance_offset + 8]
|
|
2645
|
+
return instance_offset if _is_structural_source_instance(instance) else None
|
|
2646
|
+
|
|
2647
|
+
|
|
2648
|
+
def _source_stack_structural_instance_offset(
|
|
2649
|
+
frame: BDOFrame,
|
|
2650
|
+
quantity_offset: int,
|
|
2651
|
+
) -> Optional[int]:
|
|
2652
|
+
"""Recognize known pre- and post-quantity source-instance layouts.
|
|
2653
|
+
|
|
2654
|
+
The older family places the instance immediately before quantity. The
|
|
2655
|
+
current family uses ``quantity + uint32(0) + instance``. If a frame happens
|
|
2656
|
+
to satisfy both shapes, the instance remains unproven.
|
|
2657
|
+
"""
|
|
2658
|
+
offsets: set[int] = set()
|
|
2659
|
+
|
|
2660
|
+
before_offset = quantity_offset - 8
|
|
2661
|
+
if before_offset >= 5 and _is_structural_source_instance(
|
|
2662
|
+
frame.message[before_offset:quantity_offset]
|
|
2663
|
+
):
|
|
2664
|
+
offsets.add(before_offset)
|
|
2665
|
+
|
|
2666
|
+
after_offset = quantity_offset + 8
|
|
2667
|
+
if (
|
|
2668
|
+
after_offset + 8 <= frame.length
|
|
2669
|
+
and frame.message[quantity_offset + 4 : after_offset] == b"\x00" * 4
|
|
2670
|
+
and _is_structural_source_instance(
|
|
2671
|
+
frame.message[after_offset : after_offset + 8]
|
|
2672
|
+
)
|
|
2673
|
+
):
|
|
2674
|
+
offsets.add(after_offset)
|
|
2675
|
+
|
|
2676
|
+
return next(iter(offsets)) if len(offsets) == 1 else None
|
|
2677
|
+
|
|
2678
|
+
|
|
2679
|
+
def _is_structural_source_instance(value: bytes) -> bool:
|
|
2680
|
+
"""Stronger guard for an uncorrelated instance-shaped field.
|
|
2681
|
+
|
|
2682
|
+
Exact cross-frame matches use the broader instance validator. A field
|
|
2683
|
+
inferred only from layout must have entropy in both uint32 halves; this
|
|
2684
|
+
rejects current frames' incidental ``uint32(0) + small value`` at q-8.
|
|
2685
|
+
"""
|
|
2686
|
+
if not _is_plausible_instance(value):
|
|
2687
|
+
return False
|
|
2688
|
+
empty_halves = {b"\x00" * 4, b"\xff" * 4}
|
|
2689
|
+
return value[:4] not in empty_halves and value[4:] not in empty_halves
|
|
2690
|
+
|
|
2691
|
+
|
|
2692
|
+
def _ranges_overlap(
|
|
2693
|
+
first_offset: int,
|
|
2694
|
+
first_width: int,
|
|
2695
|
+
second_offset: int,
|
|
2696
|
+
second_width: int,
|
|
2697
|
+
) -> bool:
|
|
2698
|
+
return (
|
|
2699
|
+
first_offset < second_offset + second_width
|
|
2700
|
+
and second_offset < first_offset + first_width
|
|
2701
|
+
)
|
|
2702
|
+
|
|
2703
|
+
|
|
2704
|
+
def _is_source_item_reference(frame: BDOFrame, item_bytes: bytes) -> bool:
|
|
2705
|
+
"""Whether a small frame carries a non-record reference to the item."""
|
|
2706
|
+
if not 20 <= frame.length <= REFERENCE_FRAME_MAX_LENGTH:
|
|
2707
|
+
return False
|
|
2708
|
+
return any(
|
|
2709
|
+
not _looks_like_full_item_record(frame, item_offset)
|
|
2710
|
+
for item_offset in _find_all(frame.message, item_bytes)
|
|
2711
|
+
)
|
|
2712
|
+
|
|
2713
|
+
|
|
2714
|
+
def _unique_best_companion_spec(
|
|
2715
|
+
candidates: Iterable[MessageSpec],
|
|
2716
|
+
) -> Optional[MessageSpec]:
|
|
2717
|
+
"""Return one strongest companion candidate, refusing an equal-score tie."""
|
|
2718
|
+
unique = {candidate.dedupe_key(): candidate for candidate in candidates}
|
|
2719
|
+
if not unique:
|
|
2720
|
+
return None
|
|
2721
|
+
best_score = max(candidate.score or 0.0 for candidate in unique.values())
|
|
2722
|
+
best = [
|
|
2723
|
+
candidate
|
|
2724
|
+
for candidate in unique.values()
|
|
2725
|
+
if (candidate.score or 0.0) == best_score
|
|
2726
|
+
]
|
|
2727
|
+
return best[0] if len(best) == 1 else None
|
|
2728
|
+
|
|
2729
|
+
|
|
2730
|
+
def _discover_source_item_reference(
|
|
2731
|
+
frames: list[BDOFrame],
|
|
2732
|
+
storage_delta: _CalibratedItemRecord,
|
|
2733
|
+
options: _Options,
|
|
2734
|
+
) -> Optional[MessageSpec]:
|
|
2735
|
+
item_bytes = storage_delta.item_id.to_bytes(4, "little")
|
|
2736
|
+
|
|
2737
|
+
context = _context_before(
|
|
2738
|
+
options.frame_index or _FrameIndex(frames),
|
|
2739
|
+
storage_delta.frame,
|
|
2740
|
+
options.context_frames,
|
|
2741
|
+
)
|
|
2742
|
+
for frame in reversed(context):
|
|
2743
|
+
if not 20 <= frame.length <= REFERENCE_FRAME_MAX_LENGTH:
|
|
2744
|
+
continue
|
|
2745
|
+
item_offset = frame.message.find(item_bytes)
|
|
2746
|
+
if item_offset < 0:
|
|
2747
|
+
continue
|
|
2748
|
+
if _looks_like_full_item_record(frame, item_offset):
|
|
2749
|
+
continue
|
|
2750
|
+
return MessageSpec(
|
|
2751
|
+
event="SOURCE_ITEM_REFERENCE",
|
|
2752
|
+
opcode=frame.opcode,
|
|
2753
|
+
length=frame.length,
|
|
2754
|
+
item_id_offset=item_offset,
|
|
2755
|
+
confidence=_confidence_label(0.82),
|
|
2756
|
+
source=_calibration_source(options, "inventory-to-storage"),
|
|
2757
|
+
observed_at=_iso_timestamp(frame.context.timestamp),
|
|
2758
|
+
score=0.82,
|
|
2759
|
+
)
|
|
2760
|
+
return None
|
|
2761
|
+
|
|
2762
|
+
|
|
2763
|
+
def _context_before(
|
|
2764
|
+
frame_index: _FrameIndex,
|
|
2765
|
+
target_frame: BDOFrame,
|
|
2766
|
+
context_frames: int,
|
|
2767
|
+
) -> list[BDOFrame]:
|
|
2768
|
+
return list(frame_index.context_before(target_frame, context_frames))
|
|
2769
|
+
|
|
2770
|
+
|
|
2771
|
+
def _discover_context_offset(frame: BDOFrame, before_offset: int) -> Optional[int]:
|
|
2772
|
+
best_offset = None
|
|
2773
|
+
for context_bytes in SOURCE_CONTEXT_LABELS:
|
|
2774
|
+
if (
|
|
2775
|
+
context_bytes == CHARACTER_LOAD_CONTEXT
|
|
2776
|
+
or context_bytes in STORAGE_DELTA_CONTEXTS
|
|
2777
|
+
):
|
|
2778
|
+
continue
|
|
2779
|
+
search_at = 0
|
|
2780
|
+
while True:
|
|
2781
|
+
offset = frame.message.find(context_bytes, search_at)
|
|
2782
|
+
if offset < 0:
|
|
2783
|
+
break
|
|
2784
|
+
if offset < before_offset:
|
|
2785
|
+
best_offset = offset if best_offset is None else max(best_offset, offset)
|
|
2786
|
+
search_at = offset + 1
|
|
2787
|
+
return best_offset
|
|
2788
|
+
|
|
2789
|
+
|
|
2790
|
+
def _discover_storage_context_offset(
|
|
2791
|
+
frame: BDOFrame,
|
|
2792
|
+
before_offset: int,
|
|
2793
|
+
) -> Optional[int]:
|
|
2794
|
+
"""Return one unambiguous town column in a structurally valid wrapper."""
|
|
2795
|
+
if not _has_dynamic_storage_record_geometry(frame, before_offset):
|
|
2796
|
+
return None
|
|
2797
|
+
candidates = storage_destination_candidates(
|
|
2798
|
+
frame.message,
|
|
2799
|
+
before_offset=before_offset,
|
|
2800
|
+
)
|
|
2801
|
+
if len(candidates) == 1:
|
|
2802
|
+
return candidates[0][0]
|
|
2803
|
+
return None
|
|
2804
|
+
|
|
2805
|
+
|
|
2806
|
+
def _discover_storage_context_offset_from_frames(
|
|
2807
|
+
frames: Iterable[BDOFrame],
|
|
2808
|
+
*,
|
|
2809
|
+
opcode: int,
|
|
2810
|
+
item_offset: int,
|
|
2811
|
+
) -> Optional[int]:
|
|
2812
|
+
"""Learn the destination column by cross-frame offset consistency.
|
|
2813
|
+
|
|
2814
|
+
This intentionally assumes neither the byte envelope around a town ID nor
|
|
2815
|
+
an item-relative position. Registered-ID overlaps disappear when the
|
|
2816
|
+
same field column is intersected across different destination values.
|
|
2817
|
+
"""
|
|
2818
|
+
|
|
2819
|
+
candidate_intersection: Optional[set[int]] = None
|
|
2820
|
+
unregistered_messages: list[bytes] = []
|
|
2821
|
+
messages_seen: set[bytes] = set()
|
|
2822
|
+
for frame in frames:
|
|
2823
|
+
if (
|
|
2824
|
+
frame.opcode != opcode
|
|
2825
|
+
or frame.message in messages_seen
|
|
2826
|
+
or not _has_dynamic_storage_record_geometry(frame, item_offset)
|
|
2827
|
+
):
|
|
2828
|
+
continue
|
|
2829
|
+
candidates = {
|
|
2830
|
+
offset
|
|
2831
|
+
for offset, _storage_id in storage_destination_candidates(
|
|
2832
|
+
frame.message,
|
|
2833
|
+
before_offset=item_offset,
|
|
2834
|
+
)
|
|
2835
|
+
}
|
|
2836
|
+
messages_seen.add(frame.message)
|
|
2837
|
+
if not candidates:
|
|
2838
|
+
# A newly added town can be structurally valid before the toolkit
|
|
2839
|
+
# name registry knows its numeric key. Let registered destinations
|
|
2840
|
+
# establish the column, then require that same column to contain a
|
|
2841
|
+
# nonzero uint32 here. An unknown town must not veto an otherwise
|
|
2842
|
+
# provable patch schema or be relabeled from a decoy elsewhere.
|
|
2843
|
+
unregistered_messages.append(frame.message)
|
|
2844
|
+
continue
|
|
2845
|
+
candidate_intersection = (
|
|
2846
|
+
candidates
|
|
2847
|
+
if candidate_intersection is None
|
|
2848
|
+
else candidate_intersection & candidates
|
|
2849
|
+
)
|
|
2850
|
+
if not candidate_intersection:
|
|
2851
|
+
return None
|
|
2852
|
+
if candidate_intersection is None or len(candidate_intersection) != 1:
|
|
2853
|
+
return None
|
|
2854
|
+
selected = next(iter(candidate_intersection))
|
|
2855
|
+
if any(
|
|
2856
|
+
selected + 4 > item_offset
|
|
2857
|
+
or int.from_bytes(message[selected : selected + 4], "little") == 0
|
|
2858
|
+
for message in unregistered_messages
|
|
2859
|
+
):
|
|
2860
|
+
return None
|
|
2861
|
+
return selected
|
|
2862
|
+
|
|
2863
|
+
|
|
2864
|
+
def _has_dynamic_storage_record_geometry(
|
|
2865
|
+
frame: BDOFrame,
|
|
2866
|
+
item_offset: int,
|
|
2867
|
+
) -> bool:
|
|
2868
|
+
"""Whether some prefix count proves every full storage item record."""
|
|
2869
|
+
|
|
2870
|
+
if item_offset + 43 > frame.length:
|
|
2871
|
+
return False
|
|
2872
|
+
geometries: set[tuple[int, int]] = set()
|
|
2873
|
+
for count_offset in range(5, max(5, item_offset - 1)):
|
|
2874
|
+
count = int.from_bytes(
|
|
2875
|
+
frame.message[count_offset : count_offset + 2],
|
|
2876
|
+
"little",
|
|
2877
|
+
)
|
|
2878
|
+
if count <= 0:
|
|
2879
|
+
continue
|
|
2880
|
+
for prefix_length in range(max(5, count_offset + 2), item_offset + 1):
|
|
2881
|
+
record_bytes = frame.length - prefix_length
|
|
2882
|
+
if record_bytes <= 0 or record_bytes % count:
|
|
2883
|
+
continue
|
|
2884
|
+
stride = record_bytes // count
|
|
2885
|
+
relative_item_offset = item_offset - prefix_length
|
|
2886
|
+
if relative_item_offset < 0 or relative_item_offset + 43 > stride:
|
|
2887
|
+
continue
|
|
2888
|
+
if all(
|
|
2889
|
+
_looks_like_full_item_record(
|
|
2890
|
+
frame,
|
|
2891
|
+
item_offset + index * stride,
|
|
2892
|
+
)
|
|
2893
|
+
for index in range(count)
|
|
2894
|
+
):
|
|
2895
|
+
geometries.add((count, stride))
|
|
2896
|
+
return bool(geometries)
|
|
2897
|
+
|
|
2898
|
+
|
|
2899
|
+
def _discover_storage_record_count_offset(
|
|
2900
|
+
frames: Iterable[BDOFrame],
|
|
2901
|
+
*,
|
|
2902
|
+
records: Iterable[_CalibratedItemRecord],
|
|
2903
|
+
opcode: int,
|
|
2904
|
+
item_offset: int,
|
|
2905
|
+
instance_offset: Optional[int],
|
|
2906
|
+
single_record_length: int,
|
|
2907
|
+
) -> Optional[int]:
|
|
2908
|
+
"""Learn one authoritative uint16 count column from record geometry.
|
|
2909
|
+
|
|
2910
|
+
A single wrapper can contain another small integer equal to its item
|
|
2911
|
+
count. Intersecting candidates across independently validated frames and
|
|
2912
|
+
count shapes prevents such a field from silently impersonating the real
|
|
2913
|
+
declaration. No absolute or item-relative count position is assumed.
|
|
2914
|
+
"""
|
|
2915
|
+
|
|
2916
|
+
if instance_offset is None:
|
|
2917
|
+
return None
|
|
2918
|
+
records_by_message: dict[bytes, list[int]] = {}
|
|
2919
|
+
for record in records:
|
|
2920
|
+
if record.frame.opcode != opcode:
|
|
2921
|
+
continue
|
|
2922
|
+
records_by_message.setdefault(record.frame.message, []).append(
|
|
2923
|
+
record.item_offset
|
|
2924
|
+
)
|
|
2925
|
+
candidate_intersection: Optional[set[int]] = None
|
|
2926
|
+
messages_seen: set[bytes] = set()
|
|
2927
|
+
counts_seen: set[int] = set()
|
|
2928
|
+
for frame in frames:
|
|
2929
|
+
if frame.opcode != opcode or frame.message in messages_seen:
|
|
2930
|
+
continue
|
|
2931
|
+
offsets = _full_transfer_record_offsets(
|
|
2932
|
+
frame,
|
|
2933
|
+
item_offset,
|
|
2934
|
+
instance_offset,
|
|
2935
|
+
)
|
|
2936
|
+
if not offsets:
|
|
2937
|
+
offsets = sorted(set(records_by_message.get(frame.message, ())))
|
|
2938
|
+
if not offsets or offsets[0] != item_offset:
|
|
2939
|
+
continue
|
|
2940
|
+
count = len(offsets)
|
|
2941
|
+
if count == 1:
|
|
2942
|
+
if frame.length != single_record_length:
|
|
2943
|
+
continue
|
|
2944
|
+
else:
|
|
2945
|
+
strides = {later - earlier for earlier, later in zip(offsets, offsets[1:])}
|
|
2946
|
+
if len(strides) != 1:
|
|
2947
|
+
continue
|
|
2948
|
+
stride = next(iter(strides))
|
|
2949
|
+
if frame.length - (count - 1) * stride != single_record_length:
|
|
2950
|
+
continue
|
|
2951
|
+
|
|
2952
|
+
search_end = min(item_offset, len(frame.message))
|
|
2953
|
+
candidates = {
|
|
2954
|
+
offset
|
|
2955
|
+
for offset in range(5, max(5, search_end - 1))
|
|
2956
|
+
if int.from_bytes(frame.message[offset : offset + 2], "little")
|
|
2957
|
+
== count
|
|
2958
|
+
}
|
|
2959
|
+
if not candidates:
|
|
2960
|
+
return None
|
|
2961
|
+
messages_seen.add(frame.message)
|
|
2962
|
+
counts_seen.add(count)
|
|
2963
|
+
candidate_intersection = (
|
|
2964
|
+
candidates
|
|
2965
|
+
if candidate_intersection is None
|
|
2966
|
+
else candidate_intersection & candidates
|
|
2967
|
+
)
|
|
2968
|
+
if not candidate_intersection:
|
|
2969
|
+
return None
|
|
2970
|
+
|
|
2971
|
+
# One count shape cannot distinguish the declaration from an unrelated
|
|
2972
|
+
# header integer that happens to carry the same value. Two independently
|
|
2973
|
+
# validated shapes are the minimum patch-agnostic semantic proof.
|
|
2974
|
+
if (
|
|
2975
|
+
len(counts_seen) < 2
|
|
2976
|
+
or candidate_intersection is None
|
|
2977
|
+
or len(candidate_intersection) != 1
|
|
2978
|
+
):
|
|
2979
|
+
return None
|
|
2980
|
+
return next(iter(candidate_intersection))
|
|
2981
|
+
|
|
2982
|
+
|
|
2983
|
+
def _looks_like_full_item_record(frame: BDOFrame, item_offset: int) -> bool:
|
|
2984
|
+
if item_offset + 43 > len(frame.message):
|
|
2985
|
+
return False
|
|
2986
|
+
quantity = int.from_bytes(frame.message[item_offset + 4 : item_offset + 8], "little")
|
|
2987
|
+
instance = frame.message[item_offset + 35 : item_offset + 43]
|
|
2988
|
+
return 0 < quantity <= 1_000_000 and _is_plausible_instance(instance)
|
|
2989
|
+
|
|
2990
|
+
|
|
2991
|
+
def _is_plausible_instance(value: bytes) -> bool:
|
|
2992
|
+
return len(value) == 8 and value != b"\x00" * 8 and value != b"\xff" * 8
|
|
2993
|
+
|
|
2994
|
+
|
|
2995
|
+
def _find_all(haystack: bytes, needle: bytes) -> list[int]:
|
|
2996
|
+
offsets: list[int] = []
|
|
2997
|
+
search_at = 0
|
|
2998
|
+
while True:
|
|
2999
|
+
offset = haystack.find(needle, search_at)
|
|
3000
|
+
if offset < 0:
|
|
3001
|
+
return offsets
|
|
3002
|
+
offsets.append(offset)
|
|
3003
|
+
search_at = offset + 1
|
|
3004
|
+
|
|
3005
|
+
|
|
3006
|
+
def _dedupe_message_specs(specs: Iterable[MessageSpec]) -> list[MessageSpec]:
|
|
3007
|
+
output: list[MessageSpec] = []
|
|
3008
|
+
seen: set[tuple[object, ...]] = set()
|
|
3009
|
+
for spec in specs:
|
|
3010
|
+
key = spec.dedupe_key()
|
|
3011
|
+
if key in seen:
|
|
3012
|
+
continue
|
|
3013
|
+
seen.add(key)
|
|
3014
|
+
output.append(spec)
|
|
3015
|
+
return output
|
|
3016
|
+
|
|
3017
|
+
|
|
3018
|
+
def _confidence_label(score: float) -> str:
|
|
3019
|
+
level = "high" if score >= 0.90 else "medium"
|
|
3020
|
+
return f"calibrated-{level}"
|
|
3021
|
+
|
|
3022
|
+
|
|
3023
|
+
def _calibration_source(options: _Options, action: str) -> str:
|
|
3024
|
+
parts = [f"calibrate {action}", f"item_id={options.item_id}"]
|
|
3025
|
+
if options.quantity is not None:
|
|
3026
|
+
parts.append(f"qty={options.quantity}")
|
|
3027
|
+
return " ".join(parts)
|
|
3028
|
+
|
|
3029
|
+
|
|
3030
|
+
def _iso_timestamp(timestamp: float) -> str:
|
|
3031
|
+
return (
|
|
3032
|
+
dt.datetime.fromtimestamp(timestamp, tz=dt.timezone.utc)
|
|
3033
|
+
.isoformat(timespec="seconds")
|
|
3034
|
+
.replace("+00:00", "Z")
|
|
3035
|
+
)
|
|
3036
|
+
|
|
3037
|
+
|
|
3038
|
+
def _utc_now_text() -> str:
|
|
3039
|
+
return (
|
|
3040
|
+
dt.datetime.now(tz=dt.timezone.utc)
|
|
3041
|
+
.isoformat(timespec="seconds")
|
|
3042
|
+
.replace("+00:00", "Z")
|
|
3043
|
+
)
|
|
3044
|
+
|
|
3045
|
+
|
|
3046
|
+
def _events_for_action(action: str) -> tuple[str, ...]:
|
|
3047
|
+
if action == "loot-preview":
|
|
3048
|
+
return ("LOOT_PREVIEW",)
|
|
3049
|
+
if action == "storage-to-inventory":
|
|
3050
|
+
return ("INVENTORY_TRANSFER", "SOURCE_CONTAINER_DECREMENT")
|
|
3051
|
+
if action == "inventory-to-storage":
|
|
3052
|
+
return (
|
|
3053
|
+
"SOURCE_STACK_DECREMENT",
|
|
3054
|
+
"SOURCE_ITEM_REFERENCE",
|
|
3055
|
+
"STORAGE_ITEM_DELTA",
|
|
3056
|
+
)
|
|
3057
|
+
# ``auto`` observes both transfer directions but never owns the separate
|
|
3058
|
+
# loot-preview workflow.
|
|
3059
|
+
return tuple(event for event in OPCODE_PROFILE_EVENTS if event != "LOOT_PREVIEW")
|
|
3060
|
+
|
|
3061
|
+
|
|
3062
|
+
def _validate_profile_replacement_options(
|
|
3063
|
+
replace: bool,
|
|
3064
|
+
replace_entire_action: bool,
|
|
3065
|
+
) -> None:
|
|
3066
|
+
if not isinstance(replace, bool):
|
|
3067
|
+
raise TypeError("replace must be a boolean")
|
|
3068
|
+
if not isinstance(replace_entire_action, bool):
|
|
3069
|
+
raise TypeError("replace_entire_action must be a boolean")
|
|
3070
|
+
if replace_entire_action and not replace:
|
|
3071
|
+
raise ValueError(
|
|
3072
|
+
"replace_entire_action=True cannot be combined with replace=False"
|
|
3073
|
+
)
|
|
3074
|
+
|
|
3075
|
+
|
|
3076
|
+
def _load_profile_data(path: Path) -> dict[str, Any]:
|
|
3077
|
+
if path.exists():
|
|
3078
|
+
# Validate every supported top-level section, including explicitly
|
|
3079
|
+
# promoted origin companion families, before preserving the file.
|
|
3080
|
+
load_opcode_profile(path)
|
|
3081
|
+
try:
|
|
3082
|
+
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
3083
|
+
except (json.JSONDecodeError, UnicodeError) as exc:
|
|
3084
|
+
raise ProfileError(f"Could not parse opcodes JSON {path}: {exc}") from exc
|
|
3085
|
+
if not isinstance(data, dict):
|
|
3086
|
+
raise ProfileError(f"Opcodes JSON {path} must be a top-level object")
|
|
3087
|
+
else:
|
|
3088
|
+
data = {"version": OPCODE_PROFILE_SCHEMA_VERSION}
|
|
3089
|
+
|
|
3090
|
+
version = data.get("version")
|
|
3091
|
+
if (
|
|
3092
|
+
isinstance(version, bool)
|
|
3093
|
+
or not isinstance(version, int)
|
|
3094
|
+
or version != OPCODE_PROFILE_SCHEMA_VERSION
|
|
3095
|
+
):
|
|
3096
|
+
raise ProfileError(
|
|
3097
|
+
f"version in {path} must be {OPCODE_PROFILE_SCHEMA_VERSION}"
|
|
3098
|
+
)
|
|
3099
|
+
active = data.get("profile_active", False)
|
|
3100
|
+
if not isinstance(active, bool):
|
|
3101
|
+
raise ProfileError(f"profile_active in {path} must be a boolean")
|
|
3102
|
+
updated_at = data.get("updated_at")
|
|
3103
|
+
if updated_at is not None and not isinstance(updated_at, str):
|
|
3104
|
+
raise ProfileError(f"updated_at in {path} must be a string")
|
|
3105
|
+
calibration_item_id = data.get("calibration_item_id")
|
|
3106
|
+
if calibration_item_id is not None and (
|
|
3107
|
+
isinstance(calibration_item_id, bool)
|
|
3108
|
+
or not isinstance(calibration_item_id, int)
|
|
3109
|
+
or not 1 <= calibration_item_id <= 0xFFFFFFFF
|
|
3110
|
+
):
|
|
3111
|
+
raise ProfileError(
|
|
3112
|
+
f"calibration_item_id in {path} must be a positive uint32"
|
|
3113
|
+
)
|
|
3114
|
+
|
|
3115
|
+
specs = data.get("specs", {})
|
|
3116
|
+
if not isinstance(specs, dict):
|
|
3117
|
+
raise ProfileError(f"specs in {path} must be an object")
|
|
3118
|
+
for event, entries in specs.items():
|
|
3119
|
+
if not isinstance(event, str):
|
|
3120
|
+
raise ProfileError(f"spec event names in {path} must be strings")
|
|
3121
|
+
if not isinstance(entries, list):
|
|
3122
|
+
raise ProfileError(f"specs[{event!r}] in {path} must be a list")
|
|
3123
|
+
if any(not isinstance(entry, dict) for entry in entries):
|
|
3124
|
+
raise ProfileError(
|
|
3125
|
+
f"every specs[{event!r}] entry in {path} must be an object"
|
|
3126
|
+
)
|
|
3127
|
+
for index, entry in enumerate(entries):
|
|
3128
|
+
_validate_profile_entry(path, event, index, entry)
|
|
3129
|
+
for event in OPCODE_PROFILE_EVENTS:
|
|
3130
|
+
specs.setdefault(event, [])
|
|
3131
|
+
|
|
3132
|
+
data["version"] = version
|
|
3133
|
+
data["profile_active"] = active
|
|
3134
|
+
data["specs"] = specs
|
|
3135
|
+
data.setdefault("updated_at", _utc_now_text())
|
|
3136
|
+
return data
|
|
3137
|
+
|
|
3138
|
+
|
|
3139
|
+
def _profile_dedupe_keys(data: dict[str, Any]) -> set[tuple[object, ...]]:
|
|
3140
|
+
specs = data.get("specs", {})
|
|
3141
|
+
if not isinstance(specs, dict):
|
|
3142
|
+
return set()
|
|
3143
|
+
|
|
3144
|
+
keys: set[tuple[object, ...]] = set()
|
|
3145
|
+
for event, entries in specs.items():
|
|
3146
|
+
if not isinstance(entries, list):
|
|
3147
|
+
continue
|
|
3148
|
+
for entry in entries:
|
|
3149
|
+
if not isinstance(entry, dict):
|
|
3150
|
+
continue
|
|
3151
|
+
opcode = _profile_opcode(entry.get("opcode"), event)
|
|
3152
|
+
keys.add(
|
|
3153
|
+
(
|
|
3154
|
+
event,
|
|
3155
|
+
opcode,
|
|
3156
|
+
entry.get("length"),
|
|
3157
|
+
entry.get("item_id_offset"),
|
|
3158
|
+
entry.get("quantity_offset"),
|
|
3159
|
+
entry.get("item_instance_offset"),
|
|
3160
|
+
entry.get("context_offset"),
|
|
3161
|
+
entry.get("record_count_offset"),
|
|
3162
|
+
entry.get("inventory_slot_offset"),
|
|
3163
|
+
entry.get("source_instance_offset"),
|
|
3164
|
+
entry.get("quantity_removed_offset"),
|
|
3165
|
+
entry.get("quantity_added_offset"),
|
|
3166
|
+
entry.get("destination_instance_offset"),
|
|
3167
|
+
entry.get("repeat_stride"),
|
|
3168
|
+
)
|
|
3169
|
+
)
|
|
3170
|
+
return keys
|
|
3171
|
+
|
|
3172
|
+
|
|
3173
|
+
def _backup_path(path: Path) -> Path:
|
|
3174
|
+
backup_dir = path.parent / "opcodes_backups"
|
|
3175
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
3176
|
+
stamp = dt.datetime.now(tz=dt.timezone.utc).strftime("%Y%m%d%H%M%S%f")
|
|
3177
|
+
candidate = backup_dir / f"{path.name}.bak.{stamp}"
|
|
3178
|
+
suffix = 1
|
|
3179
|
+
while candidate.exists():
|
|
3180
|
+
candidate = backup_dir / f"{path.name}.bak.{stamp}.{suffix}"
|
|
3181
|
+
suffix += 1
|
|
3182
|
+
return candidate
|
|
3183
|
+
|
|
3184
|
+
|
|
3185
|
+
def _profile_opcode(value: object, event: str) -> int:
|
|
3186
|
+
if isinstance(value, bool):
|
|
3187
|
+
raise ProfileError(f"invalid opcode for {event}: {value!r}")
|
|
3188
|
+
if isinstance(value, int):
|
|
3189
|
+
opcode = value
|
|
3190
|
+
elif isinstance(value, str):
|
|
3191
|
+
try:
|
|
3192
|
+
opcode = int(value, 16 if value.lower().startswith("0x") else 10)
|
|
3193
|
+
except ValueError as exc:
|
|
3194
|
+
raise ProfileError(f"invalid opcode for {event}: {value!r}") from exc
|
|
3195
|
+
else:
|
|
3196
|
+
raise ProfileError(f"invalid opcode for {event}: {value!r}")
|
|
3197
|
+
if not 0 <= opcode <= 0xFFFF:
|
|
3198
|
+
raise ProfileError(f"opcode for {event} must be a uint16")
|
|
3199
|
+
return opcode
|
|
3200
|
+
|
|
3201
|
+
|
|
3202
|
+
def _atomic_write_text(path: Path, text: str) -> None:
|
|
3203
|
+
"""Atomically replace a UTF-8 text file in its destination directory."""
|
|
3204
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
3205
|
+
temporary_path: Optional[Path] = None
|
|
3206
|
+
try:
|
|
3207
|
+
with tempfile.NamedTemporaryFile(
|
|
3208
|
+
mode="w",
|
|
3209
|
+
encoding="utf-8",
|
|
3210
|
+
newline="\n",
|
|
3211
|
+
dir=path.parent,
|
|
3212
|
+
prefix=f".{path.name}.",
|
|
3213
|
+
suffix=".tmp",
|
|
3214
|
+
delete=False,
|
|
3215
|
+
) as handle:
|
|
3216
|
+
temporary_path = Path(handle.name)
|
|
3217
|
+
handle.write(text)
|
|
3218
|
+
handle.flush()
|
|
3219
|
+
os.fsync(handle.fileno())
|
|
3220
|
+
os.replace(temporary_path, path)
|
|
3221
|
+
finally:
|
|
3222
|
+
if temporary_path is not None and temporary_path.exists():
|
|
3223
|
+
temporary_path.unlink()
|