0.1.3 - Add user UID, capture source & TCP support

Introduce automatic user UID extraction and optional --user-uid override, include user_uid and capture_source in exported JSON, and prepend UID to export filenames (falls back to unknown). Propagate detected protocol (tcp/udp) through live/libpcap parsing and accept TCP packets for UID detection; adjust libpcap filter to capture TCP as well. Update CLI flags (--copy-clipboard semantics, --user-uid), prompt user when UID is missing, and wire UID through mitmproxy and live capture flows. Bump package/exporter version to 0.1.3 and add tests for UID extraction and filename behavior.
This commit is contained in:
Golumpa 2026-06-17 11:20:31 +01:00
parent 98d97cb36a
commit df1add6cf1
15 changed files with 283 additions and 49 deletions

View file

@ -1 +1 @@
__version__ = "0.1.2"
__version__ = "0.1.3"

View file

@ -1,11 +1,13 @@
from __future__ import annotations
from collections import Counter
from pathlib import Path
from typing import Any
from nte_history_exporter.decoder.boundary import select_continuous_run_from_page_1
from nte_history_exporter.decoder.arc import build_arc_rows_from_pairs, select_continuous_arc_run
from nte_history_exporter.decoder.run import build_rows_from_pairs
from nte_history_exporter.decoder.user_uid import extract_user_uid_candidates
from nte_history_exporter.live_capture.session import LiveHistorySession, UdpPacket
@ -77,6 +79,12 @@ def find_udp_flow(flows: list[Any], preferred_index: int | None = None) -> tuple
def decode_mitmproxy_flows(path: str | Path, flow_index: int | None = None) -> dict[str, Any]:
flows = read_flows(path)
user_uid_candidates: Counter[str] = Counter()
for flow in flows:
for msg in flow.get(b"messages", []):
user_uid_candidates.update(extract_user_uid_candidates(msg[1]))
user_uid = user_uid_candidates.most_common(1)[0][0] if user_uid_candidates else None
resolved_flow_index, flow = find_udp_flow(flows, flow_index)
messages = flow[b"messages"]
@ -110,4 +118,5 @@ def decode_mitmproxy_flows(path: str | Path, flow_index: int | None = None) -> d
"best_arc_run": best_arc_run,
"arc_rows": arc_rows,
"arc_warnings": arc_warnings,
"user_uid": session.user_uid or user_uid,
}

View file

@ -26,8 +26,9 @@ def build_parser() -> argparse.ArgumentParser:
default="auto",
help="live capture backend; auto prefers Npcap/libpcap and falls back to raw sockets on Windows",
)
parser.add_argument("--no-clipboard", action="store_true", help="do not copy live exports to clipboard")
parser.add_argument("--copy-clipboard", action="store_true", help="copy a single live export to clipboard")
parser.add_argument("--debug", action="store_true", help="also write research CSVs next to the JSON exports")
parser.add_argument("--user-uid", default=None, help="override the auto-detected NTE user UID in the JSON export")
return parser
@ -39,8 +40,9 @@ def main(argv: list[str] | None = None) -> int:
run_live_capture(
interface_ip=args.interface_ip,
capture_backend=args.capture_backend,
copy_clipboard=not args.no_clipboard,
copy_clipboard=args.copy_clipboard,
write_debug_csv=args.debug,
user_uid=args.user_uid,
)
return 0
except (LibpcapUnavailable, PermissionError) as exc:
@ -62,13 +64,19 @@ def main(argv: list[str] | None = None) -> int:
best_run = decoded["best_run"]
pair_count = len(decoded["pairs"])
out_path, json_path = export_paths(kind)
resolved_user_uid = args.user_uid or decoded.get("user_uid")
if not resolved_user_uid:
resolved_user_uid = console.prompt_user_uid()
out_path, json_path = export_paths(kind, resolved_user_uid)
if args.debug:
write_csv(out_path, rows)
export = build_export_json(
rows,
warnings,
source="packet_capture",
capture_source="mitmproxy_flows",
user_uid=resolved_user_uid,
flow_index=decoded["flow_index"],
candidate_request_response_pairs=pair_count,
pages_seen=[p[0] for p in best_run],

View file

@ -68,10 +68,10 @@ def print_live_instructions(local_ip: str, backend: str = "windows_raw", detail:
print(style(f" Capture backend: {backend}{backend_detail}", DIM))
print()
print(style(" How to export your pull history", BOLD))
print(" 1. This tool must be running BEFORE you press Start on")
print(" the game's main menu, or the game connection cannot")
print(" be captured. Already in game? Log out to the main")
print(" menu and enter again.")
print(" 1. For automatic user UID detection, start this tool")
print(" before pressing Start on the game's main menu.")
print(" Already in game? You can still capture history;")
print(" the tool will ask for your UID if it cannot detect it.")
print(" 2. Open a supported history screen:")
print(style(" Monopoly > Standard Board history", CYAN))
print(style(" Monopoly > Limited Character Board history", CYAN))
@ -151,3 +151,12 @@ def print_success(text: str) -> None:
def print_problem(text: str) -> None:
print(style(f" {text}", YELLOW, BOLD))
def prompt_user_uid() -> str | None:
print()
print_problem("User UID was not detected in this capture.")
print_note("Enter your NTE user UID so the export can be named and linked correctly.")
print_note("Leaving this blank may prevent import on some trackers.")
value = input(" User UID: ").strip()
return value or None

View file

@ -11,7 +11,7 @@ LIMITED_CHARACTER_BANNER_NAME = "Limited Character Board"
ARC_BANNER_ID = "Arc_MiracleBox"
ARC_BANNER_NAME = "Arc Miracle Box"
EXPORTER_NAME = "nte-history-exporter"
EXPORTER_VERSION = "0.1.2"
EXPORTER_VERSION = "0.1.3"
HISTORY_REQUEST_BANNER = 4220
HISTORY_REQUEST_LENGTH = 45

View file

@ -0,0 +1,46 @@
from __future__ import annotations
import struct
from collections import Counter
MIN_USER_UID = 100_000_000_000
MAX_USER_UID = 999_999_999_999
USER_UID_RECORD_OFFSETS = (
(b"TagOthers", (16,)),
(b"PrivateSpawnInfoRecord", (40,)),
(b"SimpleQuestRecord", (20, 52)),
(b"FurnitureLayoutDataRec", (56, 64)),
(b"StoreBrandItemSalesVolumeRecord", (16,)),
(b"StorePropertyRecord", (20,)),
)
def _plausible_user_uid(value: int) -> bool:
return MIN_USER_UID <= value <= MAX_USER_UID
def extract_user_uid_candidates(payload: bytes) -> list[str]:
results = []
for marker, distances in USER_UID_RECORD_OFFSETS:
search_from = 0
while True:
marker_pos = payload.find(marker, search_from)
if marker_pos == -1:
break
for distance in distances:
offset = marker_pos - distance
if offset < 0:
continue
value = struct.unpack_from("<Q", payload, offset)[0]
if _plausible_user_uid(value):
results.append(str(value))
search_from = marker_pos + len(marker)
return results
def extract_user_uid(payload: bytes) -> str | None:
candidates = extract_user_uid_candidates(payload)
if not candidates:
return None
return Counter(candidates).most_common(1)[0][0]

View file

@ -17,6 +17,8 @@ def build_export_json(
warnings: list[dict[str, Any]],
*,
source: str = "packet_capture",
capture_source: str | None = None,
user_uid: str | None = None,
flow_index: int | None = None,
candidate_request_response_pairs: int | None = None,
pages_seen: list[int] | None = None,
@ -40,21 +42,31 @@ def build_export_json(
if pages_seen is not None:
scan["pages_seen"] = pages_seen
return {
normalized_user_uid = user_uid.strip() if user_uid else ""
export: dict[str, Any] = {
"format": "nte-history-export",
"format_version": 1,
"game": GAME_NAME,
"source": source,
"exporter": {"name": EXPORTER_NAME, "version": __version__},
"banner": {
"id": pool["id"],
"name": pool["name"],
"system": pool["system"],
"shared_pity": pool["shared_pity"],
},
"scan": scan,
"records": [_record_for_export(r) for r in exported],
}
if capture_source:
export["capture_source"] = capture_source
export["exporter"] = {"name": EXPORTER_NAME, "version": __version__}
export.update(
{
"banner": {
"id": pool["id"],
"name": pool["name"],
"system": pool["system"],
"shared_pity": pool["shared_pity"],
},
"scan": scan,
}
)
if normalized_user_uid:
export["user_uid"] = normalized_user_uid
export["records"] = [_record_for_export(r) for r in exported]
return export
def _record_for_export(row: dict[str, Any]) -> dict[str, Any]:

View file

@ -10,7 +10,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Iterator
from nte_history_exporter.live_capture.windows_raw import ParsedIpUdpPacket, parse_ipv4_udp_packet
from nte_history_exporter.live_capture.windows_raw import ParsedIpUdpPacket, parse_ipv4_packet
PCAP_ERRBUF_SIZE = 256
SNAP_LENGTH = 65535
@ -351,7 +351,7 @@ class LibpcapCapture:
raise LibpcapUnavailable(_pcap_error(self.lib, self.handle))
program = _BpfProgram()
filter_expression = f"udp and host {local_ip}".encode("ascii")
filter_expression = f"host {local_ip} and (udp or tcp)".encode("ascii")
if self.lib.pcap_compile(self.handle, ctypes.byref(program), filter_expression, 1, 0xFFFFFFFF) != 0:
raise LibpcapUnavailable(_pcap_error(self.lib, self.handle))
try:
@ -382,7 +382,7 @@ class LibpcapCapture:
ipv4_packet = _extract_ipv4_frame(frame, self.datalink)
if ipv4_packet is None:
continue
packet = parse_ipv4_udp_packet(ipv4_packet)
packet = parse_ipv4_packet(ipv4_packet)
if packet is not None:
yield packet

View file

@ -24,6 +24,10 @@ EXPORT_PREFIXES = {
"arc_miracle_box": "Arc",
}
CAPTURE_SOURCE_LABELS = {
"windows_raw": "windows_packet",
}
def copy_to_clipboard(text: str) -> bool:
try:
@ -60,8 +64,9 @@ def run_live_capture(
*,
interface_ip: str | None = None,
capture_backend: str = "auto",
copy_clipboard: bool = True,
copy_clipboard: bool = False,
write_debug_csv: bool = False,
user_uid: str | None = None,
) -> dict:
local_ip = interface_ip or detect_local_ipv4()
session = LiveHistorySession(local_ip)
@ -88,6 +93,7 @@ def run_live_capture(
src_port=packet.src_port,
dst_port=packet.dst_port,
payload=packet.payload,
protocol=packet.protocol,
)
)
if matched:
@ -130,6 +136,10 @@ def run_live_capture(
)
exports = []
resolved_user_uid = user_uid or session.user_uid
if session.kinds_seen() and not resolved_user_uid:
resolved_user_uid = console.prompt_user_uid()
capture_source = CAPTURE_SOURCE_LABELS.get(capture.name, capture.name)
for kind in session.kinds_seen():
pairs = session.pairs_for_kind(kind)
best_run, run_warnings = select_continuous_run_from_page_1(pairs)
@ -138,13 +148,15 @@ def run_live_capture(
rows = annotate_groups(rows)
warnings = run_warnings
pages_seen = [p[0] for p in best_run]
csv_path, json_path = export_paths(kind)
csv_path, json_path = export_paths(kind, resolved_user_uid)
if write_debug_csv:
write_csv(csv_path, rows)
export = build_export_json(
rows,
warnings,
source="live_capture",
capture_source=capture_source,
user_uid=resolved_user_uid,
pages_seen=pages_seen,
)
payload = json.dumps(export, ensure_ascii=False, indent=2)
@ -162,9 +174,9 @@ def run_live_capture(
console.print_results_header()
if not exports:
console.print_problem("No history pages were captured.")
console.print_note("This tool must already be running when you press Start on the")
console.print_note("game's main menu. Log out to the main menu, enter the game")
console.print_note("again, then reopen the history screen.")
console.print_note("Make sure the capture backend is running, then reopen the")
console.print_note("history screen and scroll from page 1. If no page messages")
console.print_note("appear, return to the main menu and re-enter the game.")
return {"exports": []}
for item in exports:
@ -189,24 +201,33 @@ def run_live_capture(
console.print_success("Export copied to clipboard - paste it straight into your tracker.")
else:
console.print_note("Clipboard tool unavailable; use the JSON file shown above.")
elif len(exports) > 1:
elif copy_clipboard and len(exports) > 1:
console.print_note("Multiple banners captured; clipboard copy skipped so one export")
console.print_note("does not overwrite another.")
return {"exports": exports}
def export_paths(kind: str) -> tuple[Path, Path]:
def export_paths(kind: str, user_uid: str | None = None) -> tuple[Path, Path]:
export_dir = Path("exports")
export_dir.mkdir(parents=True, exist_ok=True)
prefix = EXPORT_PREFIXES.get(kind, "History")
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
base = export_dir / f"{prefix}_{stamp}"
uid_prefix = _safe_filename_part(user_uid) if user_uid else "unknown"
base_name = f"{uid_prefix}_{prefix}_{stamp}"
base = export_dir / base_name
csv_path = base.with_suffix(".csv")
json_path = base.with_suffix(".json")
counter = 2
while csv_path.exists() or json_path.exists():
base = export_dir / f"{prefix}_{stamp}_{counter}"
base = export_dir / f"{base_name}_{counter}"
csv_path = base.with_suffix(".csv")
json_path = base.with_suffix(".json")
counter += 1
return csv_path, json_path
def _safe_filename_part(value: str | None) -> str:
if not value:
return "unknown"
cleaned = "".join(ch for ch in value.strip() if ch.isalnum() or ch in ("-", "_"))
return cleaned or "unknown"

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from collections import deque
from collections import Counter, deque
from dataclasses import dataclass
from typing import Any
@ -19,6 +19,7 @@ from nte_history_exporter.decoder.protocol import (
request_page,
)
from nte_history_exporter.decoder.run import build_rows_from_pairs
from nte_history_exporter.decoder.user_uid import extract_user_uid_candidates
@dataclass
@ -29,6 +30,7 @@ class UdpPacket:
src_port: int
dst_port: int
payload: bytes
protocol: str = "udp"
@dataclass
@ -57,6 +59,8 @@ class LiveHistorySession:
self.last_capture_was_replacement = False
self.requested_pages: dict[str, set[int]] = {}
self.unanswered_pages: dict[str, dict[int, str]] = {}
self.user_uid: str | None = None
self.user_uid_candidates: Counter[str] = Counter()
def _mark_unanswered(self, request: PendingRequest) -> None:
if request.response_candidates:
@ -93,6 +97,12 @@ class LiveHistorySession:
def process_packet(self, packet: UdpPacket) -> bool:
self.packet_count += 1
candidates = extract_user_uid_candidates(packet.payload)
if candidates:
self.user_uid_candidates.update(candidates)
self.user_uid = self.user_uid_candidates.most_common(1)[0][0]
if packet.protocol != "udp":
return False
if packet.src_ip == self.local_ip and is_history_request(packet.payload):
offset = int.from_bytes(packet.payload[31:35], "little")

View file

@ -10,12 +10,16 @@ RECEIVE_BUFFER_SIZE = 4 * 1024 * 1024
@dataclass
class ParsedIpUdpPacket:
class ParsedIpPacket:
src_ip: str
dst_ip: str
src_port: int
dst_port: int
payload: bytes
protocol: str = "udp"
ParsedIpUdpPacket = ParsedIpPacket
def detect_local_ipv4() -> str:
@ -42,24 +46,41 @@ def detect_local_ipv4() -> str:
return candidates[0]
def parse_ipv4_udp_packet(data: bytes) -> ParsedIpUdpPacket | None:
def parse_ipv4_packet(data: bytes) -> ParsedIpPacket | None:
if len(data) < 28:
return None
version_ihl = data[0]
if version_ihl >> 4 != 4:
return None
ihl = (version_ihl & 0x0F) * 4
total_length = struct.unpack_from("!H", data, 2)[0]
if total_length > 0:
data = data[:total_length]
if len(data) < ihl + 8:
return None
protocol = data[9]
if protocol != 17:
return None
src_ip = socket.inet_ntoa(data[12:16])
dst_ip = socket.inet_ntoa(data[16:20])
src_port, dst_port, udp_len, _checksum = struct.unpack_from("!HHHH", data, ihl)
payload = data[ihl + 8 : ihl + udp_len]
return ParsedIpUdpPacket(src_ip, dst_ip, src_port, dst_port, payload)
if protocol == 17:
src_port, dst_port, udp_len, _checksum = struct.unpack_from("!HHHH", data, ihl)
payload = data[ihl + 8 : ihl + udp_len]
return ParsedIpPacket(src_ip, dst_ip, src_port, dst_port, payload, "udp")
if protocol == 6:
if len(data) < ihl + 20:
return None
src_port, dst_port = struct.unpack_from("!HH", data, ihl)
tcp_header_len = (data[ihl + 12] >> 4) * 4
if tcp_header_len < 20 or len(data) < ihl + tcp_header_len:
return None
payload = data[ihl + tcp_header_len :]
return ParsedIpPacket(src_ip, dst_ip, src_port, dst_port, payload, "tcp")
return None
def parse_ipv4_udp_packet(data: bytes) -> ParsedIpPacket | None:
packet = parse_ipv4_packet(data)
return packet if packet and packet.protocol == "udp" else None
def open_raw_udp_socket(local_ip: str) -> socket.socket:
@ -79,7 +100,7 @@ def read_packets(sock: socket.socket):
except socket.timeout:
yield None
continue
packet = parse_ipv4_udp_packet(data)
packet = parse_ipv4_packet(data)
if packet is None:
continue
yield packet