nte-exporter/src/nte_history_exporter/decoder/protocol.py
Golumpa 0e78906e3a 0.1.1 - Add libpcap/Npcap backend and robust live capture
Introduce a cross-platform libpcap/Npcap capture backend with a Windows raw-socket fallback and plumbing to select backends via --capture-backend. Add ctypes-based libpcap wrapper (open_libpcap_capture, CaptureStats, LibpcapUnavailable) and a backend factory; refactor live capture runner to use the new backend, StopKeyMonitor, and improved clipboard handling. Make session and protocol decoders resilient to pipelined/multi-page responses and non-byte-aligned streams (alignment iterator, alignment-aware decoding, embedded-record trimming, slice support). Update mitmproxy flows adapter to reuse LiveHistorySession. Expand console messages and docs/README to explain cross-platform requirements, usage, and capture diagnostics. Minor: update package description in pyproject and adjust ARC/response handling and row bookkeeping to record row indices.
2026-06-12 12:58:15 +01:00

285 lines
10 KiB
Python

from __future__ import annotations
import struct
from datetime import datetime, timezone
from typing import Any, Iterator
from nte_history_exporter.constants import (
DOTNET_UNIX_EPOCH_SECONDS,
HISTORY_REQUEST_BANNER,
HISTORY_REQUEST_LENGTH,
HISTORY_PAGE_CURSOR_MULTIPLIER,
LIMITED_CHARACTER_SELECTOR,
MARKERS,
PERMANENT_SELECTOR,
TIMESTAMP_TICKS_PER_SECOND,
VALID_DICE_FIELDS,
)
from nte_history_exporter.mappings import REWARDS_BY_ID
REWARD_ID_CHARS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_")
def decode_reward_key(raw: bytes) -> str:
"""Decode a Monopoly reward key into its reward id string.
Keys encode the id one character per byte as ASCII*4 with carry chaining
(Arc history uses the same scheme at ASCII*2). The final byte is either the
pending carry (0 or 1) acting as a terminator, or a regular character byte.
The carry bit makes some bytes ambiguous, so decode with backtracking and
accept only parses made of identifier characters.
"""
results: list[str] = []
def walk(index: int, carry: int, acc: str) -> None:
if results:
return
if index == len(raw):
if carry == 0:
results.append(acc)
return
byte = raw[index]
if index == len(raw) - 1 and byte == carry:
results.append(acc)
return
for carry_out in (0, 1):
value = byte - carry + 256 * carry_out
if value % 4 == 0 and chr(value // 4) in REWARD_ID_CHARS:
walk(index + 1, carry_out, acc + chr(value // 4))
walk(0, 0, "")
return results[0] if results else ""
def infer_reward_type(reward_id: str) -> str:
if not reward_id:
return ""
if reward_id.startswith("fork_"):
return "arc"
if reward_id.isdigit():
return "character"
if reward_id.startswith("Fashion_"):
return "cosmetic"
return "item"
def decode_history_timestamp(raw8: bytes) -> tuple[int, float, str]:
if len(raw8) != 8:
raise ValueError("history timestamps must be exactly 8 bytes")
ticks = struct.unpack("<Q", raw8)[0]
unix_seconds = ticks / TIMESTAMP_TICKS_PER_SECOND - DOTNET_UNIX_EPOCH_SECONDS
decoded = datetime.fromtimestamp(unix_seconds, timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
return ticks, unix_seconds, decoded
def history_request_kind(content: bytes) -> str:
if len(content) < HISTORY_REQUEST_LENGTH or struct.unpack_from("<I", content, 35)[0] != HISTORY_REQUEST_BANNER:
return ""
selector = struct.unpack_from("<I", content, 40)[0]
if selector == PERMANENT_SELECTOR:
return "permanent"
if selector == LIMITED_CHARACTER_SELECTOR:
return "limited_character"
return ""
def is_history_request(content: bytes) -> bool:
return bool(history_request_kind(content))
def request_page(content: bytes) -> int:
return struct.unpack_from("<I", content, 31)[0] // HISTORY_PAGE_CURSOR_MULTIPLIER
def response_contains_history_marker(content: bytes) -> bool:
return any(
marker in candidate
for candidate in iter_history_response_alignments(content)
for marker in MARKERS
)
def iter_history_response_alignments(content: bytes) -> Iterator[bytes]:
yield content
for bit_offset in range(1, 8):
mask = (1 << bit_offset) - 1
yield bytes(
(content[index] >> bit_offset)
| ((content[index + 1] & mask) << (8 - bit_offset))
for index in range(len(content) - 1)
)
def extract_key(chunk_without_marker: bytes) -> str:
fashion_prefix = bytes.fromhex("1885cda1a5bdb97d")
fashion_pos = chunk_without_marker.rfind(fashion_prefix)
if fashion_pos != -1:
return chunk_without_marker[fashion_pos:].hex()
char_prefix = bytes.fromhex("c4c0")
char_pos = chunk_without_marker.find(char_prefix)
if char_pos != -1 and char_pos + 5 <= len(chunk_without_marker):
return chunk_without_marker[char_pos : char_pos + 5].hex()
best = None
for prefix in [bytes.fromhex("98bdc9ad"), bytes.fromhex("10a58d95")]:
pos = chunk_without_marker.rfind(prefix)
if pos != -1 and (best is None or pos > best):
best = pos
return "" if best is None else chunk_without_marker[best:].hex()
def extract_dice(chunk_without_marker: bytes) -> tuple[int | None, int | None, int | None]:
for off in range(0, min(16, max(0, len(chunk_without_marker) - 3))):
val = struct.unpack_from("<I", chunk_without_marker, off)[0]
if val in VALID_DICE_FIELDS:
return (0 if val == 0 else val // 4), val, off
return None, None, None
def classify_result_type(
chunk_without_marker: bytes,
dice: int | None,
dice_offset: int | None,
) -> tuple[str, int | None]:
if dice is None or dice_offset is None:
return "unknown", None
if dice == 0:
return "points_gift", 0
source_off = dice_offset + 4
if source_off + 4 <= len(chunk_without_marker):
source_val = struct.unpack_from("<i", chunk_without_marker, source_off)[0]
if source_val == 0:
return "points_gift", source_val
if source_val == -4:
return "chase_reward", source_val
return "dice", source_val
return "dice", None
def guess_quantity(chunk_hex: str, reward_id: str, result_type: str | None = None) -> int | None:
if reward_id == "Dice_ticket_01":
if result_type == "chase_reward":
return 30
return 4
if reward_id == "DiceNormal":
return 1
if reward_id == "Dice_ticket_02":
if "c8b0d4c0" in chunk_hex:
return 50
if "c8b0ccc0" in chunk_hex:
return 30
return None
if reward_id:
return 1
return None
def _decode_aligned_response_records(response_content: bytes) -> list[dict[str, Any]]:
marker = b""
marker_offsets: list[int] = []
for candidate_marker in MARKERS:
offsets = [i for i in range(len(response_content)) if response_content.startswith(candidate_marker, i)]
if offsets:
marker = candidate_marker
marker_offsets = offsets
break
if not marker_offsets:
return []
rows: list[dict[str, Any]] = []
prev = 0x50
for row_index, marker_offset in enumerate(marker_offsets, start=1):
record_start = prev
chunk = response_content[prev:marker_offset]
full_record = response_content[prev : marker_offset + len(marker) + 8]
dice, dice_raw, dice_offset = extract_dice(chunk)
if dice is None and len(chunk) > 32:
embedded_candidates = []
original_key = extract_key(chunk)
original_key_bytes = bytes.fromhex(original_key) if original_key else b""
for trim in range(1, min(96, len(chunk))):
candidate = chunk[trim:]
candidate_dice, candidate_raw, candidate_offset = extract_dice(candidate)
if (
candidate_dice is not None
and candidate_offset in (0, 5)
and extract_key(candidate) == original_key
):
key_count = candidate.count(original_key_bytes) if original_key_bytes else 0
key_position = candidate.find(original_key_bytes) if original_key_bytes else len(candidate)
embedded_candidates.append(
(
-key_count,
candidate_offset != 5,
key_position,
-trim,
trim,
candidate,
candidate_dice,
candidate_raw,
candidate_offset,
)
)
if embedded_candidates:
_, _, _, _, trim, chunk, dice, dice_raw, dice_offset = min(embedded_candidates)
record_start = prev + trim
full_record = response_content[prev + trim : marker_offset + len(marker) + 8]
result_type, result_source_raw = classify_result_type(chunk, dice, dice_offset)
if result_type == "points_gift":
dice = 0
dice_raw = 0
elif result_type == "chase_reward":
dice = -4
dice_raw = -4
key_hex = extract_key(chunk)
reward_id = decode_reward_key(bytes.fromhex(key_hex)) if key_hex else ""
reward = REWARDS_BY_ID.get(reward_id, {})
timestamp_raw = response_content[marker_offset + len(marker) : marker_offset + len(marker) + 8]
timestamp_ticks, timestamp_unix, timestamp_decoded = decode_history_timestamp(timestamp_raw)
chunk_hex = chunk.hex()
rows.append(
{
"row": row_index,
"record_start": record_start,
"record_end": marker_offset + len(marker) + 8,
"record_len": len(full_record),
"dice": dice,
"roll_result": (
"Points Gift"
if result_type == "points_gift"
else ("Chase Reward" if result_type == "chase_reward" else (f"Dice {dice}" if dice else ""))
),
"result_type": result_type,
"result_source_raw": result_source_raw,
"dice_raw_u32": dice_raw,
"dice_offset_in_record": dice_offset,
"reward_key_hex": key_hex,
"reward_type": reward.get("type") or infer_reward_type(reward_id),
"reward_id": reward_id,
"reward_name": reward.get("name", ""),
"reward_rank": reward.get("rank"),
"quantity": guess_quantity(chunk_hex, reward_id, result_type),
"timestamp_raw_hex": timestamp_raw.hex(),
"timestamp_ticks": timestamp_ticks,
"timestamp_unix": f"{timestamp_unix:.6f}",
"timestamp_decoded": timestamp_decoded,
"record_hex": full_record.hex(),
}
)
prev = marker_offset + len(marker) + 8
return rows
def decode_response_records(response_content: bytes) -> list[dict[str, Any]]:
for candidate in iter_history_response_alignments(response_content):
if not any(marker in candidate for marker in MARKERS):
continue
try:
rows = _decode_aligned_response_records(candidate)
except (OSError, OverflowError, ValueError):
continue
if rows:
return rows
return []