Added server detection

Implemented server detection at the request of NTEWIZ. If not detected, it will ask after collecting pull data.
This commit is contained in:
Golumpa 2026-07-16 01:34:38 +01:00
parent 9eae9b4a15
commit cc88955baf
15 changed files with 242 additions and 7 deletions

View file

@ -1 +1 @@
__version__ = "0.1.11"
__version__ = "0.1.12"

View file

@ -12,6 +12,7 @@ from nte_history_exporter.decoder.mystery_box import (
select_continuous_mystery_box_run,
)
from nte_history_exporter.decoder.user_uid import extract_user_uid_candidates
from nte_history_exporter.decoder.server_region import extract_server_id
from nte_history_exporter.live_capture.session import LiveHistorySession, UdpPacket
@ -84,10 +85,15 @@ 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()
server_id_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]))
server_id = extract_server_id(msg[1])
if server_id:
server_id_candidates.update([server_id])
user_uid = user_uid_candidates.most_common(1)[0][0] if user_uid_candidates else None
server_id = server_id_candidates.most_common(1)[0][0] if server_id_candidates else None
resolved_flow_index, flow = find_udp_flow(flows, flow_index)
messages = flow[b"messages"]
@ -136,5 +142,6 @@ def decode_mitmproxy_flows(path: str | Path, flow_index: int | None = None) -> d
"mystery_box_rows": mystery_box_rows,
"mystery_box_warnings": mystery_box_warnings,
"user_uid": session.user_uid or user_uid,
"server_id": session.server_id or server_id,
"capture_diagnostics": session.diagnostic_report(),
}

View file

@ -57,6 +57,7 @@ def main(argv: list[str] | None = None) -> int:
write_debug_csv=args.debug,
user_uid=args.user_uid,
)
console.wait_for_close()
return 0
except (LibpcapUnavailable, PermissionError) as exc:
console.print_problem(str(exc))
@ -88,6 +89,9 @@ def main(argv: list[str] | None = None) -> int:
resolved_user_uid = args.user_uid or decoded.get("user_uid")
if not resolved_user_uid:
resolved_user_uid = console.prompt_user_uid()
resolved_server_id = decoded.get("server_id")
if not resolved_server_id:
resolved_server_id = console.prompt_server_id()
out_path, json_path = export_paths(kind, resolved_user_uid)
diagnostics_path = None
@ -101,6 +105,7 @@ def main(argv: list[str] | None = None) -> int:
source="packet_capture",
capture_source="mitmproxy_flows",
user_uid=resolved_user_uid,
server_id=resolved_server_id,
flow_index=decoded["flow_index"],
candidate_request_response_pairs=pair_count,
pages_seen=[p[0] for p in best_run],

View file

@ -4,6 +4,8 @@ import os
import sys
from nte_history_exporter.constants import EXPORTER_VERSION, GAME_NAME
from nte_history_exporter.decoder.server_region import SERVER_REGIONS
from nte_history_exporter.live_capture.stop_key import wait_for_keypress
WIDTH = 58
REPOSITORY_URL = "https://github.com/Golumpa/nte-exporter"
@ -179,3 +181,30 @@ def prompt_user_uid() -> str | None:
print_note("Leaving this blank may prevent import on some trackers.")
value = input(" User UID: ").strip()
return value or None
def prompt_server_id() -> str | None:
print()
print_problem("Account server was not detected in this capture.")
print_note("Choose the server used by this account:")
choices = tuple(
(str(index), server_id, details["name"])
for index, (server_id, details) in enumerate(SERVER_REGIONS.items(), start=1)
)
for key, _server_id, name in choices:
print(f" {key}. {name}")
print_note("Leave this blank to omit server information.")
by_key = {key: server_id for key, server_id, _name in choices}
while True:
value = input(" Server [1-4]: ").strip()
if not value:
return None
if value in by_key:
return by_key[value]
print_problem("Enter 1, 2, 3, or 4; or leave it blank.")
def wait_for_close() -> None:
print()
print_success("Press any key to close the exporter.")
wait_for_keypress()

View file

@ -13,7 +13,7 @@ ARC_BANNER_NAME = "Arc Miracle Box"
MYSTERY_BOX_BANNER_ID = "Gashapon_MysteryBox"
MYSTERY_BOX_BANNER_NAME = "Mystery Box"
EXPORTER_NAME = "nte-history-exporter"
EXPORTER_VERSION = "0.1.11"
EXPORTER_VERSION = "0.1.12"
HISTORY_REQUEST_BANNER = 4220
HISTORY_REQUEST_LENGTH = 45

View file

@ -0,0 +1,55 @@
from __future__ import annotations
import ipaddress
import struct
# Production entries from clientRes/OB-NTE-ServerList/serverlist_hhOS.json.
# Test and certification servers are intentionally excluded.
SERVER_REGIONS = {
"23001": {"account_region": "AS", "name": "Asia"},
"23002": {"account_region": "NA_SA", "name": "America"},
"23003": {"account_region": "EU", "name": "Europe"},
"23004": {"account_region": "SE", "name": "SEA"},
}
def account_region_for_server(server_id: str | None) -> str | None:
if not server_id:
return None
server = SERVER_REGIONS.get(str(server_id).strip())
return server["account_region"] if server else None
def extract_server_id(payload: bytes) -> str | None:
"""Extract DistrictId from the initial server-selection TCP response.
This response is a length-prefixed FlatBuffers message. DistrictId is at
byte 96 in the current schema. Validate the frame and its following server
address before accepting the value so an arbitrary integer at that offset
is not mistaken for a server ID.
"""
if len(payload) < 168:
return None
frame_size = struct.unpack_from("<I", payload, 0)[0] + 4
if frame_size > len(payload) or frame_size < 168:
return None
if struct.unpack_from("<I", payload, 4)[0] != 20:
return None
server_id = struct.unpack_from("<I", payload, 96)[0]
if not 20_000 <= server_id <= 99_999:
return None
address_length = struct.unpack_from("<I", payload, 132)[0]
address_end = 136 + address_length
if not 1 <= address_length <= 255 or address_end > frame_size:
return None
try:
address = payload[136:address_end].decode("ascii")
ipaddress.ip_address(address)
except (UnicodeDecodeError, ValueError):
return None
return str(server_id)

View file

@ -11,6 +11,7 @@ from nte_history_exporter.constants import (
POOL_META,
MYSTERY_BOX_BANNER_ID,
)
from nte_history_exporter.decoder.server_region import account_region_for_server
def build_export_json(
@ -20,6 +21,7 @@ def build_export_json(
source: str = "packet_capture",
capture_source: str | None = None,
user_uid: str | None = None,
server_id: str | None = None,
flow_index: int | None = None,
candidate_request_response_pairs: int | None = None,
pages_seen: list[int] | None = None,
@ -44,6 +46,7 @@ def build_export_json(
scan["pages_seen"] = pages_seen
normalized_user_uid = user_uid.strip() if user_uid else ""
normalized_server_id = str(server_id).strip() if server_id else ""
export: dict[str, Any] = {
"format": "nte-history-export",
"format_version": 1,
@ -66,6 +69,11 @@ def build_export_json(
)
if normalized_user_uid:
export["user_uid"] = normalized_user_uid
if normalized_server_id:
export["server_id"] = normalized_server_id
account_region = account_region_for_server(normalized_server_id)
if account_region:
export["account_region"] = account_region
export["records"] = [_record_for_export(r) for r in exported]
return export

View file

@ -144,6 +144,9 @@ def run_live_capture(
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()
resolved_server_id = session.server_id
if session.kinds_seen() and not resolved_server_id:
resolved_server_id = console.prompt_server_id()
capture_source = CAPTURE_SOURCE_LABELS.get(capture.name, capture.name)
for kind in session.kinds_seen():
pairs = session.pairs_for_kind(kind)
@ -162,6 +165,7 @@ def run_live_capture(
source="live_capture",
capture_source=capture_source,
user_uid=resolved_user_uid,
server_id=resolved_server_id,
pages_seen=pages_seen,
)
payload = json.dumps(export, ensure_ascii=False, indent=2)

View file

@ -30,6 +30,7 @@ from nte_history_exporter.decoder.mystery_box import (
from nte_history_exporter.decoder.structured_protocol import FORK_MARKER, MONOPOLY_MARKER
from nte_history_exporter.constants import MYSTERY_BOX_MARKER
from nte_history_exporter.decoder.user_uid import extract_user_uid_candidates
from nte_history_exporter.decoder.server_region import extract_server_id
from nte_history_exporter.live_capture.diagnostics import CaptureDiagnostics
@ -72,6 +73,7 @@ class LiveHistorySession:
self.unanswered_pages: dict[str, dict[int, str]] = {}
self.user_uid: str | None = None
self.user_uid_candidates: Counter[str] = Counter()
self.server_id: str | None = None
self.diagnostics = CaptureDiagnostics()
def _mark_unanswered(self, request: PendingRequest) -> None:
@ -122,6 +124,11 @@ class LiveHistorySession:
if candidates:
self.user_uid_candidates.update(candidates)
self.user_uid = self.user_uid_candidates.most_common(1)[0][0]
if packet.protocol == "tcp" and packet.dst_ip == self.local_ip and not self.server_id:
self.server_id = extract_server_id(packet.payload)
if self.server_id:
self.diagnostics.counters["server_ids_detected"] += 1
self.diagnostics.add_event("SERVER_ID_DETECTED", self.packet_count)
if packet.protocol != "udp":
return False

View file

@ -5,6 +5,41 @@ import select
import sys
def _read_windows_key() -> None:
import msvcrt
key = msvcrt.getch()
# Function and arrow keys are emitted as a two-byte sequence. Consume the
# second byte so it cannot immediately satisfy the next key prompt.
if key in {b"\x00", b"\xe0"}:
msvcrt.getch()
def wait_for_keypress() -> None:
"""Wait for one key on an interactive terminal, or Enter on redirected input."""
if os.name == "nt" and sys.stdin.isatty():
_read_windows_key()
return
if not sys.stdin.isatty():
try:
input()
except EOFError:
pass
return
import termios
import tty
fd = sys.stdin.fileno()
original_terminal = termios.tcgetattr(fd)
try:
tty.setcbreak(fd)
os.read(fd, 1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, original_terminal)
class StopKeyMonitor:
def __init__(self) -> None:
self._windows = os.name == "nt"
@ -29,7 +64,7 @@ class StopKeyMonitor:
if not msvcrt.kbhit():
return False
msvcrt.getch()
_read_windows_key()
return True
if self._fd is None: