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
bdo_toolkit/cli.py ADDED
@@ -0,0 +1,948 @@
1
+ """Command-line interface: ``bdo-toolkit <command>``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import math
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from . import __version__
12
+ from ._capture_options import LiveCaptureOptions, PacketCaptureOptions
13
+ from .capture import capture_live, replay_pcap
14
+ from .diagnostics import DecoderDiagnostic
15
+ from ._protocol import DEFAULT_SERVER_PORTS
16
+ from .calibration import (
17
+ CalibrationAuthorityError,
18
+ CALIBRATION_ACTIONS,
19
+ CalibrationResult,
20
+ DirectionMismatchError,
21
+ calibrate_live,
22
+ calibrate_pcap,
23
+ reset_profile,
24
+ update_profile,
25
+ )
26
+ from .origin_learning import (
27
+ CompanionObservation,
28
+ OriginLearner,
29
+ promote_origin_candidates,
30
+ )
31
+ from .remote_profiles import (
32
+ DEFAULT_REMOTE_PROFILE_MAX_BYTES,
33
+ DEFAULT_REMOTE_PROFILE_TIMEOUT_SECONDS,
34
+ fetch_opcode_profile,
35
+ )
36
+ from .filters import EventFilter
37
+ from .writers import ConsoleEventWriter, JsonlEventWriter
38
+ from .solare import (
39
+ SolareCaptureResult,
40
+ SolareUpdate,
41
+ SolareUpdateKind,
42
+ capture_solare_snapshot,
43
+ replay_solare,
44
+ )
45
+ from .solare._constants import SOLARE_DEFAULT_CAPTURE_SECONDS
46
+
47
+
48
+ def _positive_int(value: str) -> int:
49
+ try:
50
+ number = int(value)
51
+ except ValueError as exc:
52
+ raise argparse.ArgumentTypeError(f"expected an integer: {value!r}") from exc
53
+ if not 1 <= number <= 0xFFFFFFFF:
54
+ raise argparse.ArgumentTypeError("value must be between 1 and 4294967295")
55
+ return number
56
+
57
+
58
+ def _storage_id(value: str) -> int:
59
+ try:
60
+ number = int(value, 16 if value.lower().startswith("0x") else 10)
61
+ except ValueError as exc:
62
+ raise argparse.ArgumentTypeError(
63
+ f"expected a decimal or 0x-prefixed integer: {value!r}"
64
+ ) from exc
65
+ if not 1 <= number <= 0xFFFFFFFF:
66
+ raise argparse.ArgumentTypeError("value must be between 1 and 0xFFFFFFFF")
67
+ return number
68
+
69
+
70
+ def _nonnegative_float(value: str) -> float:
71
+ try:
72
+ number = float(value)
73
+ except ValueError as exc:
74
+ raise argparse.ArgumentTypeError(f"expected a number: {value!r}") from exc
75
+ if not math.isfinite(number) or number < 0:
76
+ raise argparse.ArgumentTypeError("value must be finite and non-negative")
77
+ return number
78
+
79
+
80
+ def _positive_float(value: str) -> float:
81
+ number = _nonnegative_float(value)
82
+ if number == 0:
83
+ raise argparse.ArgumentTypeError("value must be greater than zero")
84
+ return number
85
+
86
+
87
+ def _probability(value: str) -> float:
88
+ number = _nonnegative_float(value)
89
+ if number > 1:
90
+ raise argparse.ArgumentTypeError("value must be between 0 and 1")
91
+ return number
92
+
93
+
94
+ def _parse_ports(value: str) -> tuple[int, ...]:
95
+ ports: list[int] = []
96
+ for piece in value.split(","):
97
+ piece = piece.strip()
98
+ if not piece:
99
+ continue
100
+ try:
101
+ port = int(piece)
102
+ except ValueError as exc:
103
+ raise argparse.ArgumentTypeError(f"invalid port: {piece!r}") from exc
104
+ if not 1 <= port <= 65535:
105
+ raise argparse.ArgumentTypeError(f"port out of range: {port}")
106
+ ports.append(port)
107
+ if not ports:
108
+ raise argparse.ArgumentTypeError("at least one port is required")
109
+ return tuple(dict.fromkeys(ports))
110
+
111
+
112
+ def _add_decode_arguments(parser: argparse.ArgumentParser) -> None:
113
+ parser.add_argument(
114
+ "--profile",
115
+ type=Path,
116
+ required=True,
117
+ help="path to the active opcode profile",
118
+ )
119
+ parser.add_argument(
120
+ "--ports",
121
+ type=_parse_ports,
122
+ default=DEFAULT_SERVER_PORTS,
123
+ metavar="PORTS",
124
+ help="comma-separated BDO server source ports (default: 8884,8885,8889)",
125
+ )
126
+ parser.add_argument(
127
+ "--event-type",
128
+ action="append",
129
+ dest="event_types",
130
+ metavar="TYPE",
131
+ help="only yield this event type (repeatable), e.g. storage_delta",
132
+ )
133
+ parser.add_argument(
134
+ "--source",
135
+ action="append",
136
+ dest="sources",
137
+ metavar="SOURCE",
138
+ help=(
139
+ 'only yield this semantic source (repeatable), e.g. "Mob Drop" '
140
+ 'or "Worker Production"'
141
+ ),
142
+ )
143
+ parser.add_argument(
144
+ "--storage-id",
145
+ action="append",
146
+ dest="storage_ids",
147
+ type=_storage_id,
148
+ metavar="ID",
149
+ help="only yield this storage destination ID (repeatable; decimal or 0x...)",
150
+ )
151
+ parser.add_argument(
152
+ "--item-id",
153
+ action="append",
154
+ dest="item_ids",
155
+ type=_positive_int,
156
+ metavar="ITEM_ID",
157
+ help="only yield this item id (repeatable)",
158
+ )
159
+ parser.add_argument(
160
+ "--jsonl",
161
+ action="store_true",
162
+ help="emit newline-delimited JSON instead of human-readable lines",
163
+ )
164
+
165
+
166
+ def _writer(args: argparse.Namespace):
167
+ return JsonlEventWriter() if args.jsonl else ConsoleEventWriter()
168
+
169
+
170
+ def _decode_filter(args: argparse.Namespace) -> EventFilter | None:
171
+ if not any(
172
+ (
173
+ args.event_types,
174
+ args.sources,
175
+ args.storage_ids,
176
+ args.item_ids,
177
+ )
178
+ ):
179
+ return None
180
+ return EventFilter(
181
+ event_types=args.event_types,
182
+ sources=args.sources,
183
+ storage_ids=args.storage_ids,
184
+ item_ids=args.item_ids,
185
+ )
186
+
187
+
188
+ def _report_decoder_diagnostic(diagnostic: DecoderDiagnostic) -> None:
189
+ print(
190
+ f"decoder {diagnostic.severity} [{diagnostic.code}]: "
191
+ f"{diagnostic.message}",
192
+ file=sys.stderr,
193
+ )
194
+
195
+
196
+ def _run_replay(args: argparse.Namespace) -> int:
197
+ writer = _writer(args)
198
+ count = 0
199
+ for event in replay_pcap(
200
+ args.pcap,
201
+ opcode_profile=args.profile,
202
+ ports=args.ports,
203
+ event_filter=_decode_filter(args),
204
+ on_diagnostic=_report_decoder_diagnostic,
205
+ ):
206
+ writer.write(event)
207
+ count += 1
208
+ print(f"decoded {count} events", file=sys.stderr)
209
+ return 0
210
+
211
+
212
+ def _run_live(args: argparse.Namespace) -> int:
213
+ writer = _writer(args)
214
+ try:
215
+ for event in capture_live(
216
+ opcode_profile=args.profile,
217
+ live_options=LiveCaptureOptions(
218
+ interface=args.iface,
219
+ ports=args.ports,
220
+ event_queue_size=args.event_queue_size,
221
+ ),
222
+ event_filter=_decode_filter(args),
223
+ capture_seconds=args.capture_seconds,
224
+ on_diagnostic=_report_decoder_diagnostic,
225
+ ):
226
+ writer.write(event)
227
+ except KeyboardInterrupt:
228
+ pass
229
+ return 0
230
+
231
+
232
+ def _run_profile_fetch(args: argparse.Namespace) -> int:
233
+ result = fetch_opcode_profile(
234
+ args.url,
235
+ args.output,
236
+ timeout=args.timeout,
237
+ max_bytes=args.max_bytes,
238
+ backup=args.backup,
239
+ )
240
+ print(
241
+ f"installed opcode profile revision {result.revision} at {result.path}",
242
+ file=sys.stderr,
243
+ )
244
+ if result.backup_path is not None:
245
+ print(f"backup at {result.backup_path}", file=sys.stderr)
246
+ return 0
247
+
248
+
249
+ def _write_solare_result(
250
+ result: SolareCaptureResult,
251
+ *,
252
+ output: Path | None,
253
+ include_raw: bool,
254
+ ) -> None:
255
+ serialized = result.to_json(include_raw=include_raw) + "\n"
256
+ if output is None:
257
+ sys.stdout.write(serialized)
258
+ return
259
+ output.parent.mkdir(parents=True, exist_ok=True)
260
+ output.write_text(serialized, encoding="utf-8")
261
+ print(f"wrote Solare result to {output}", file=sys.stderr)
262
+
263
+
264
+ def _same_output_path(left: Path | None, right: Path | None) -> bool:
265
+ if left is None or right is None:
266
+ return False
267
+ left = left.expanduser()
268
+ right = right.expanduser()
269
+ try:
270
+ if left.exists() and right.exists() and left.samefile(right):
271
+ return True
272
+ except OSError:
273
+ pass
274
+ return left.resolve() == right.resolve()
275
+
276
+
277
+ def _run_solare_replay(args: argparse.Namespace) -> int:
278
+ if _same_output_path(args.pcap, args.output):
279
+ raise ValueError("--output must not overwrite the replay input capture")
280
+ result = replay_solare(
281
+ args.pcap,
282
+ ports=args.ports,
283
+ retain_raw_extensions=args.include_raw,
284
+ )
285
+ _write_solare_result(
286
+ result,
287
+ output=args.output,
288
+ include_raw=args.include_raw,
289
+ )
290
+ return 0 if result.complete else 1
291
+
292
+
293
+ def _run_solare_live(args: argparse.Namespace) -> int:
294
+ if _same_output_path(args.save_pcap, args.output):
295
+ raise ValueError("--output and --save-pcap must use different paths")
296
+
297
+ def report(update: SolareUpdate) -> None:
298
+ if args.quiet or update.kind is SolareUpdateKind.FINISHED:
299
+ return
300
+ print(f"[{update.kind.value}] {update.message}", file=sys.stderr, flush=True)
301
+
302
+ result = capture_solare_snapshot(
303
+ capture_options=PacketCaptureOptions(
304
+ interface=args.iface,
305
+ local_ip=args.local_ip,
306
+ ports=args.ports,
307
+ use_bpf=not args.no_bpf,
308
+ ),
309
+ capture_seconds=args.capture_seconds,
310
+ save_pcap=args.save_pcap,
311
+ stop_on_complete=args.stop_on_complete,
312
+ retain_raw_extensions=args.include_raw,
313
+ on_update=report,
314
+ )
315
+ _write_solare_result(
316
+ result,
317
+ output=args.output,
318
+ include_raw=args.include_raw,
319
+ )
320
+ return 0 if result.complete else 1
321
+
322
+
323
+ def _print_calibration_result(result: CalibrationResult, verbose: bool) -> None:
324
+ print(f"scanned {result.frames_scanned} frames", file=sys.stderr)
325
+ if not result.specs:
326
+ print("no message specs promoted", file=sys.stderr)
327
+ for spec in result.specs:
328
+ fields = [spec.event, f"opcode=0x{spec.opcode:04X}", f"length={spec.length}"]
329
+ for name in (
330
+ "item_id_offset",
331
+ "quantity_offset",
332
+ "item_instance_offset",
333
+ "context_offset",
334
+ "record_count_offset",
335
+ "inventory_slot_offset",
336
+ "repeat_stride",
337
+ "source_instance_offset",
338
+ "quantity_removed_offset",
339
+ "quantity_added_offset",
340
+ "destination_instance_offset",
341
+ ):
342
+ value = getattr(spec, name)
343
+ if value is not None:
344
+ fields.append(f"{name}={value}")
345
+ if spec.score is not None:
346
+ fields.append(f"confidence={spec.score:.2f}")
347
+ print("discovered " + " ".join(fields))
348
+ _FAMILY_LABEL = {
349
+ "into_inventory": "storage->inventory",
350
+ "into_storage": "inventory->storage",
351
+ }
352
+ detected = {
353
+ _FAMILY_LABEL[e.detected_family]
354
+ for e in result.evidence
355
+ if e.detected_family is not None
356
+ }
357
+ if detected:
358
+ print(f"detected direction(s): {', '.join(sorted(detected))}", file=sys.stderr)
359
+ if verbose:
360
+ for line in result.ignored:
361
+ print(line, file=sys.stderr)
362
+ for e in result.evidence:
363
+ print(
364
+ f"classify opcode=0x{e.opcode:04X} family={e.detected_family} "
365
+ f"reference_frame={e.reference_frame} context_label={e.context_label} "
366
+ f"storage_context={e.storage_context}",
367
+ file=sys.stderr,
368
+ )
369
+
370
+
371
+ def _run_calibrate(args: argparse.Namespace) -> int:
372
+ if args.pcap is not None and args.capture_seconds is not None:
373
+ print(
374
+ "error: --capture-seconds applies to live calibration only; "
375
+ "omit it when using --pcap",
376
+ file=sys.stderr,
377
+ )
378
+ return 2
379
+
380
+ if args.action == "auto":
381
+ instruction = (
382
+ f"with five matching unstackable items having raw ID {args.item_id}: "
383
+ "perform the in-game actions deposit 1, deposit 4, then withdraw "
384
+ "all 5 while capture passively listens"
385
+ )
386
+ elif args.action == "inventory-to-storage":
387
+ instruction = (
388
+ f"deposit matching item {args.item_id} records using at least "
389
+ "two different batch counts"
390
+ )
391
+ else:
392
+ instruction = f"perform the {args.action} action with item {args.item_id} once"
393
+
394
+ try:
395
+ if args.pcap is not None:
396
+ result = calibrate_pcap(
397
+ args.pcap,
398
+ item_id=args.item_id,
399
+ quantity=args.qty,
400
+ action=args.action,
401
+ ports=args.ports,
402
+ min_confidence=args.min_confidence,
403
+ )
404
+ else:
405
+ if args.capture_seconds is not None:
406
+ stop_instruction = (
407
+ f"stopping automatically after {args.capture_seconds:g}s"
408
+ )
409
+ else:
410
+ stop_instruction = "press Ctrl+C when done"
411
+ print(f"listening -- {instruction}, {stop_instruction}", file=sys.stderr)
412
+ result = calibrate_live(
413
+ item_id=args.item_id,
414
+ quantity=args.qty,
415
+ action=args.action,
416
+ capture_options=PacketCaptureOptions(
417
+ interface=args.iface,
418
+ ports=args.ports,
419
+ ),
420
+ capture_seconds=args.capture_seconds,
421
+ min_confidence=args.min_confidence,
422
+ )
423
+ except (DirectionMismatchError, CalibrationAuthorityError) as exc:
424
+ print(f"error: {exc}", file=sys.stderr)
425
+ return 2
426
+
427
+ _print_calibration_result(result, args.verbose)
428
+
429
+ if not args.write:
430
+ if result.specs:
431
+ write_description = (
432
+ "merge these specs into a profile"
433
+ if args.merge
434
+ else "write these specs and replace stale applicable profile entries"
435
+ )
436
+ print(
437
+ f"dry run: pass --write PATH to {write_description}",
438
+ file=sys.stderr,
439
+ )
440
+ return 0 if result.specs else 1
441
+
442
+ if not result.specs:
443
+ print("nothing to write", file=sys.stderr)
444
+ return 1
445
+
446
+ try:
447
+ update = update_profile(
448
+ result,
449
+ args.write,
450
+ action=args.action,
451
+ replace=not args.merge,
452
+ )
453
+ except CalibrationAuthorityError as exc:
454
+ print(f"error: {exc}", file=sys.stderr)
455
+ return 2
456
+ print(update.summary(), file=sys.stderr)
457
+ return 0
458
+
459
+
460
+ def _run_reset_profile(args: argparse.Namespace) -> int:
461
+ backup = reset_profile(args.path, args.calibration_item_id)
462
+ print(f"reset {args.path}", file=sys.stderr)
463
+ if backup is not None:
464
+ print(f"backup at {backup}", file=sys.stderr)
465
+ return 0
466
+
467
+
468
+ def _run_origin_learn(args: argparse.Namespace) -> int:
469
+ learner = OriginLearner.load(
470
+ args.candidates,
471
+ min_observations=args.min_observations,
472
+ )
473
+
474
+ def observe(observation: CompanionObservation) -> None:
475
+ candidate = learner.observe(observation)
476
+ status = (
477
+ "confirmed"
478
+ if candidate.confirmed(learner.min_observations)
479
+ else "candidate"
480
+ )
481
+ pair = " -> ".join(f"0x{opcode:04X}" for opcode in candidate.companion_opcodes)
482
+ print(
483
+ f"origin {status}: delta=0x{candidate.delta_opcode:04X} "
484
+ f"companions={pair} observations={candidate.observations}",
485
+ file=sys.stderr,
486
+ )
487
+
488
+ try:
489
+ if args.pcaps:
490
+ for pcap in args.pcaps:
491
+ for _ in replay_pcap(
492
+ pcap,
493
+ opcode_profile=args.profile,
494
+ ports=args.ports,
495
+ origin_observer=observe,
496
+ ):
497
+ pass
498
+ else:
499
+ print(
500
+ "listening for structurally correlated worker deposits; "
501
+ "press Ctrl+C when done",
502
+ file=sys.stderr,
503
+ )
504
+ for _ in capture_live(
505
+ opcode_profile=args.profile,
506
+ live_options=LiveCaptureOptions(
507
+ interface=args.iface,
508
+ ports=args.ports,
509
+ ),
510
+ capture_seconds=args.capture_seconds,
511
+ origin_observer=observe,
512
+ ):
513
+ pass
514
+ except KeyboardInterrupt:
515
+ pass
516
+ finally:
517
+ learner.save(args.candidates)
518
+
519
+ print(
520
+ f"saved {len(learner.candidates)} origin candidate family/families "
521
+ f"to {args.candidates}",
522
+ file=sys.stderr,
523
+ )
524
+ print(learner.summary())
525
+ return 0 if learner.candidates else 1
526
+
527
+
528
+ def _run_origin_promote(args: argparse.Namespace) -> int:
529
+ result = promote_origin_candidates(
530
+ args.candidates,
531
+ args.profile,
532
+ min_observations=args.min_observations,
533
+ )
534
+ if not result.written:
535
+ print("no new confirmed origin companion families to promote", file=sys.stderr)
536
+ return 1
537
+ print(
538
+ f"promoted {len(result.added)} origin companion family/families "
539
+ f"into {result.path}",
540
+ file=sys.stderr,
541
+ )
542
+ if result.backup_path is not None:
543
+ print(f"backup at {result.backup_path}", file=sys.stderr)
544
+ return 0
545
+
546
+
547
+ def build_parser() -> argparse.ArgumentParser:
548
+ parser = argparse.ArgumentParser(
549
+ prog="bdo-toolkit",
550
+ description=(
551
+ "Passive, read-only BDO packet telemetry: decode pcaps or live "
552
+ "capture into structured item events, and calibrate opcode "
553
+ "profiles after game patches."
554
+ ),
555
+ )
556
+ parser.add_argument(
557
+ "--version", action="version", version=f"%(prog)s {__version__}"
558
+ )
559
+ subparsers = parser.add_subparsers(dest="command", required=True)
560
+
561
+ profile = subparsers.add_parser(
562
+ "profile",
563
+ help="retrieve and manage explicit opcode profiles",
564
+ )
565
+ profile_subparsers = profile.add_subparsers(
566
+ dest="profile_command",
567
+ required=True,
568
+ )
569
+ profile_fetch = profile_subparsers.add_parser(
570
+ "fetch",
571
+ help="fetch, verify, and atomically install a remote opcode profile",
572
+ description=(
573
+ "Fetch, verify, and atomically install a remote opcode profile. "
574
+ "Use only one writer per output path; cross-process locking is "
575
+ "the caller's responsibility."
576
+ ),
577
+ )
578
+ profile_fetch.add_argument("url", help="HTTPS URL of a profile envelope")
579
+ profile_fetch.add_argument(
580
+ "--output",
581
+ type=Path,
582
+ required=True,
583
+ metavar="PATH",
584
+ help="local profile path to create or replace",
585
+ )
586
+ profile_fetch.add_argument(
587
+ "--timeout",
588
+ type=_positive_float,
589
+ default=DEFAULT_REMOTE_PROFILE_TIMEOUT_SECONDS,
590
+ metavar="SECONDS",
591
+ help=(
592
+ "network timeout in seconds "
593
+ f"(default: {DEFAULT_REMOTE_PROFILE_TIMEOUT_SECONDS:g})"
594
+ ),
595
+ )
596
+ profile_fetch.add_argument(
597
+ "--max-bytes",
598
+ type=_positive_int,
599
+ default=DEFAULT_REMOTE_PROFILE_MAX_BYTES,
600
+ metavar="BYTES",
601
+ help=(
602
+ "maximum accepted response size "
603
+ f"(default: {DEFAULT_REMOTE_PROFILE_MAX_BYTES})"
604
+ ),
605
+ )
606
+ profile_fetch.add_argument(
607
+ "--no-backup",
608
+ dest="backup",
609
+ action="store_false",
610
+ help="replace an existing output without preserving a backup",
611
+ )
612
+ profile_fetch.set_defaults(func=_run_profile_fetch, backup=True)
613
+
614
+ replay = subparsers.add_parser(
615
+ "replay", help="decode a .pcap/.pcapng file into events"
616
+ )
617
+ replay.add_argument("pcap", type=Path, help="capture file to decode")
618
+ _add_decode_arguments(replay)
619
+ replay.set_defaults(func=_run_replay)
620
+
621
+ live = subparsers.add_parser(
622
+ "live", help="passively capture live traffic and decode events"
623
+ )
624
+ _add_decode_arguments(live)
625
+ live.add_argument("--iface", help="capture interface (default: auto-detect)")
626
+ live.add_argument(
627
+ "--capture-seconds",
628
+ type=_nonnegative_float,
629
+ default=None,
630
+ metavar="SECONDS",
631
+ help="stop automatically after this many seconds (default: Ctrl+C)",
632
+ )
633
+ live.set_defaults(func=_run_live)
634
+ live.add_argument(
635
+ "--event-queue-size",
636
+ type=_positive_int,
637
+ default=1024,
638
+ metavar="COUNT",
639
+ help="maximum decoded events buffered for the consumer (default: 1024)",
640
+ )
641
+
642
+ solare = subparsers.add_parser(
643
+ "solare",
644
+ help="capture or replay an experimental Arena of Solare snapshot",
645
+ description="Experimental Arena of Solare leaderboard capture and replay.",
646
+ )
647
+ solare_subparsers = solare.add_subparsers(
648
+ dest="solare_command",
649
+ required=True,
650
+ )
651
+
652
+ solare_replay = solare_subparsers.add_parser(
653
+ "replay",
654
+ help="discover a Solare leaderboard in a .pcap/.pcapng file",
655
+ description="Replay a capture with the Experimental Arena of Solare API.",
656
+ )
657
+ solare_replay.add_argument("pcap", type=Path, help="capture file to inspect")
658
+ solare_replay.add_argument(
659
+ "--ports",
660
+ type=_parse_ports,
661
+ default=DEFAULT_SERVER_PORTS,
662
+ metavar="PORTS",
663
+ help="comma-separated BDO server source ports (default: 8884,8885,8889)",
664
+ )
665
+ solare_replay.add_argument(
666
+ "--output",
667
+ type=Path,
668
+ default=None,
669
+ metavar="PATH",
670
+ help="write the result JSON here instead of stdout",
671
+ )
672
+ solare_replay.add_argument(
673
+ "--include-raw",
674
+ action="store_true",
675
+ help=(
676
+ "retain and include large raw gear and skill-addon buffers as hex"
677
+ ),
678
+ )
679
+ solare_replay.set_defaults(func=_run_solare_replay)
680
+
681
+ solare_live = solare_subparsers.add_parser(
682
+ "live",
683
+ help="listen until a complete Solare snapshot is confirmed",
684
+ description="Capture one result with the Experimental Arena of Solare API.",
685
+ )
686
+ solare_live.add_argument(
687
+ "--ports",
688
+ type=_parse_ports,
689
+ default=DEFAULT_SERVER_PORTS,
690
+ metavar="PORTS",
691
+ help="comma-separated BDO server source ports (default: 8884,8885,8889)",
692
+ )
693
+ solare_live.add_argument(
694
+ "--iface",
695
+ help="capture interface (default: auto-detect)",
696
+ )
697
+ solare_live.add_argument(
698
+ "--local-ip",
699
+ default=None,
700
+ help="local destination IPv4 address (default: auto-detect)",
701
+ )
702
+ solare_live.add_argument(
703
+ "--no-bpf",
704
+ action="store_true",
705
+ help="use a Python packet filter instead of BPF",
706
+ )
707
+ solare_duration = solare_live.add_mutually_exclusive_group()
708
+ solare_duration.add_argument(
709
+ "--capture-seconds",
710
+ type=_nonnegative_float,
711
+ default=SOLARE_DEFAULT_CAPTURE_SECONDS,
712
+ metavar="SECONDS",
713
+ help=(
714
+ "stop after this many seconds "
715
+ f"(default: {SOLARE_DEFAULT_CAPTURE_SECONDS:g})"
716
+ ),
717
+ )
718
+ solare_duration.add_argument(
719
+ "--wait-forever",
720
+ action="store_const",
721
+ const=None,
722
+ dest="capture_seconds",
723
+ help="disable the default deadline and wait for Ctrl+C or completion",
724
+ )
725
+ solare_live.add_argument(
726
+ "--save-pcap",
727
+ type=Path,
728
+ default=None,
729
+ metavar="PATH",
730
+ help="record matching packets to a new .pcap or .pcapng file",
731
+ )
732
+ solare_live.add_argument(
733
+ "--keep-listening",
734
+ action="store_false",
735
+ dest="stop_on_complete",
736
+ help="continue after confirmation instead of stopping automatically",
737
+ )
738
+ solare_live.add_argument(
739
+ "--quiet",
740
+ action="store_true",
741
+ help="suppress progress messages on stderr",
742
+ )
743
+ solare_live.add_argument(
744
+ "--output",
745
+ type=Path,
746
+ default=None,
747
+ metavar="PATH",
748
+ help="write the result JSON here instead of stdout",
749
+ )
750
+ solare_live.add_argument(
751
+ "--include-raw",
752
+ action="store_true",
753
+ help=(
754
+ "retain and include large raw gear and skill-addon buffers as hex"
755
+ ),
756
+ )
757
+ solare_live.set_defaults(func=_run_solare_live, stop_on_complete=True)
758
+
759
+ calibrate = subparsers.add_parser(
760
+ "calibrate",
761
+ help="discover opcode specs from a capture of a known in-game action",
762
+ )
763
+ calibrate.add_argument(
764
+ "--pcap",
765
+ type=Path,
766
+ default=None,
767
+ help=(
768
+ "calibrate offline from this capture file; omit to listen live "
769
+ "(perform the action, then press Ctrl+C to calibrate)"
770
+ ),
771
+ )
772
+ calibrate.add_argument(
773
+ "--capture-seconds",
774
+ type=_nonnegative_float,
775
+ default=None,
776
+ metavar="SECONDS",
777
+ help="stop the live listening window automatically after this many seconds",
778
+ )
779
+ calibrate.add_argument(
780
+ "--item-id",
781
+ type=_positive_int,
782
+ required=True,
783
+ help="decimal item id used for calibration (use matching unstackables)",
784
+ )
785
+ calibrate.add_argument(
786
+ "--qty",
787
+ type=_positive_int,
788
+ default=None,
789
+ help=(
790
+ "expected per-record quantity (normally 1 for unstackables); "
791
+ "omit for a loot-preview item whose displayed quantity is random"
792
+ ),
793
+ )
794
+ calibrate.add_argument(
795
+ "--action",
796
+ choices=CALIBRATION_ACTIONS + ("auto",),
797
+ default="auto",
798
+ help=(
799
+ "calibration workflow (default: auto). auto detects both transfer "
800
+ "directions from packet structure. With five matching unstackables "
801
+ "and --qty 1, the user deposits 1, deposits 4, then withdraws all 5 "
802
+ "while capture listens; --qty remains the value in each record, not "
803
+ "the batch size. "
804
+ "Explicit directions are strict and refuse a capture whose structure "
805
+ "contradicts the declared action. loot-preview is a separate optional "
806
+ "gathering calibration; watch a known item id and omit --qty when its "
807
+ "preview quantity is random."
808
+ ),
809
+ )
810
+ calibrate.add_argument(
811
+ "--ports",
812
+ type=_parse_ports,
813
+ default=DEFAULT_SERVER_PORTS,
814
+ metavar="PORTS",
815
+ help="comma-separated BDO server source ports (default: 8884,8885,8889)",
816
+ )
817
+ calibrate.add_argument("--iface", help="capture interface for live calibration")
818
+ calibrate.add_argument(
819
+ "--min-confidence",
820
+ type=_probability,
821
+ default=0.80,
822
+ metavar="FLOAT",
823
+ help="minimum calibration confidence from 0 to 1 (default: 0.80)",
824
+ )
825
+ calibrate.add_argument(
826
+ "--write",
827
+ type=Path,
828
+ default=None,
829
+ metavar="PATH",
830
+ help=(
831
+ "write discovered specs to this local profile path, replacing the "
832
+ "applicable existing entries (default: dry run)"
833
+ ),
834
+ )
835
+ write_mode = calibrate.add_mutually_exclusive_group()
836
+ write_mode.add_argument(
837
+ "--merge",
838
+ action="store_true",
839
+ help=(
840
+ "advanced: preserve and deduplicate existing entries instead of "
841
+ "replacing the applicable profile-family scope"
842
+ ),
843
+ )
844
+ write_mode.add_argument(
845
+ "--replace",
846
+ dest="merge",
847
+ action="store_false",
848
+ help=argparse.SUPPRESS,
849
+ )
850
+ calibrate.add_argument(
851
+ "--verbose",
852
+ action="store_true",
853
+ help="also print ignored calibration candidates and reasons",
854
+ )
855
+ calibrate.set_defaults(func=_run_calibrate, merge=False)
856
+
857
+ reset = subparsers.add_parser(
858
+ "reset-profile", help="write an empty active opcode profile"
859
+ )
860
+ reset.add_argument("path", type=Path, help="profile file to reset")
861
+ reset.add_argument(
862
+ "--calibration-item-id",
863
+ type=_positive_int,
864
+ default=15156,
865
+ help="item id recorded in the fresh profile (default: 15156)",
866
+ )
867
+ reset.set_defaults(func=_run_reset_profile)
868
+
869
+ origin_learn = subparsers.add_parser(
870
+ "origin-learn",
871
+ help="observe structural worker companions and persist candidate families",
872
+ )
873
+ origin_learn.add_argument(
874
+ "--pcap",
875
+ action="append",
876
+ dest="pcaps",
877
+ type=Path,
878
+ default=None,
879
+ help="inspect this capture instead of listening live (repeatable)",
880
+ )
881
+ origin_learn.add_argument(
882
+ "--profile",
883
+ type=Path,
884
+ required=True,
885
+ help="active opcode profile used to decode storage-delta frames",
886
+ )
887
+ origin_learn.add_argument(
888
+ "--candidates",
889
+ type=Path,
890
+ default=Path("opcodes.origin-candidates.json"),
891
+ help=("candidate output file " "(default: opcodes.origin-candidates.json)"),
892
+ )
893
+ origin_learn.add_argument(
894
+ "--min-observations",
895
+ type=_positive_int,
896
+ default=2,
897
+ help="observations required for confirmed status (default: 2)",
898
+ )
899
+ origin_learn.add_argument(
900
+ "--ports",
901
+ type=_parse_ports,
902
+ default=DEFAULT_SERVER_PORTS,
903
+ metavar="PORTS",
904
+ help="comma-separated BDO server source ports (default: 8884,8885,8889)",
905
+ )
906
+ origin_learn.add_argument("--iface", help="capture interface for live learning")
907
+ origin_learn.add_argument(
908
+ "--capture-seconds",
909
+ type=_nonnegative_float,
910
+ default=None,
911
+ metavar="SECONDS",
912
+ help="stop live learning automatically after this many seconds",
913
+ )
914
+ origin_learn.set_defaults(func=_run_origin_learn)
915
+
916
+ origin_promote = subparsers.add_parser(
917
+ "origin-promote",
918
+ help="explicitly promote confirmed origin candidates into a profile",
919
+ )
920
+ origin_promote.add_argument("candidates", type=Path, help="candidate JSON file")
921
+ origin_promote.add_argument(
922
+ "--profile",
923
+ type=Path,
924
+ required=True,
925
+ help="opcode profile to update",
926
+ )
927
+ origin_promote.add_argument(
928
+ "--min-observations",
929
+ type=_positive_int,
930
+ default=None,
931
+ help="override the candidate file's confirmation threshold",
932
+ )
933
+ origin_promote.set_defaults(func=_run_origin_promote)
934
+
935
+ return parser
936
+
937
+
938
+ def main(argv: Optional[list[str]] = None) -> int:
939
+ args = build_parser().parse_args(argv)
940
+ try:
941
+ return args.func(args)
942
+ except (OSError, RuntimeError, ValueError) as exc:
943
+ print(f"error: {exc}", file=sys.stderr)
944
+ return 2
945
+
946
+
947
+ if __name__ == "__main__":
948
+ raise SystemExit(main())