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.
Files changed (48) hide show
  1. bdo_toolkit/__init__.py +87 -0
  2. bdo_toolkit/_async_sessions.py +651 -0
  3. bdo_toolkit/_capture_backend.py +194 -0
  4. bdo_toolkit/_capture_options.py +68 -0
  5. bdo_toolkit/_capture_runtime.py +626 -0
  6. bdo_toolkit/_deposit_origin.py +1599 -0
  7. bdo_toolkit/_engine.py +327 -0
  8. bdo_toolkit/_framing.py +904 -0
  9. bdo_toolkit/_profile_runtime.py +157 -0
  10. bdo_toolkit/_protocol.py +386 -0
  11. bdo_toolkit/_reassembly.py +654 -0
  12. bdo_toolkit/_specs.py +285 -0
  13. bdo_toolkit/_storage_destination_validation.py +167 -0
  14. bdo_toolkit/_storage_hydration.py +241 -0
  15. bdo_toolkit/_version.py +3 -0
  16. bdo_toolkit/calibration.py +3223 -0
  17. bdo_toolkit/capture.py +1713 -0
  18. bdo_toolkit/character_state.py +3506 -0
  19. bdo_toolkit/cli.py +948 -0
  20. bdo_toolkit/diagnostics.py +51 -0
  21. bdo_toolkit/events.py +214 -0
  22. bdo_toolkit/filters.py +105 -0
  23. bdo_toolkit/item_state.py +48 -0
  24. bdo_toolkit/origin_learning.py +779 -0
  25. bdo_toolkit/profiles.py +370 -0
  26. bdo_toolkit/py.typed +1 -0
  27. bdo_toolkit/remote_profiles.py +358 -0
  28. bdo_toolkit/solare/__init__.py +50 -0
  29. bdo_toolkit/solare/_constants.py +94 -0
  30. bdo_toolkit/solare/_detail_learning.py +1437 -0
  31. bdo_toolkit/solare/_details.py +796 -0
  32. bdo_toolkit/solare/_discovery.py +1212 -0
  33. bdo_toolkit/solare/_live_tracker.py +472 -0
  34. bdo_toolkit/solare/_replay_capture.py +182 -0
  35. bdo_toolkit/solare/_result.py +441 -0
  36. bdo_toolkit/solare/_scanner.py +203 -0
  37. bdo_toolkit/solare/_validation.py +11 -0
  38. bdo_toolkit/solare/async_session.py +444 -0
  39. bdo_toolkit/solare/models.py +806 -0
  40. bdo_toolkit/solare/replay.py +62 -0
  41. bdo_toolkit/solare/session.py +1051 -0
  42. bdo_toolkit/writers.py +30 -0
  43. bdo_toolkit-1.0.0.dist-info/METADATA +143 -0
  44. bdo_toolkit-1.0.0.dist-info/RECORD +48 -0
  45. bdo_toolkit-1.0.0.dist-info/WHEEL +5 -0
  46. bdo_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
  47. bdo_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
  48. 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()