#!/usr/bin/env python3
# FROZEN: not started by openfut-fut.sh (see its SERVERS array); identity is NOT
# sourced from fut_account.py here. The live pair is lsx_responder_v2.py +
# blaze_responder_v3b.py -- edit those. Kept for reference/bisecting only.
"""FIFA17 Blaze redirector + SESSION SERVER (v2).

Two listeners:

  * TLS on 42127  -- the redirector.  Answers POST /redirector/getServerInstance
    with a <serverinstanceinfo> pointing the client at 127.0.0.1:BLAZE_PORT
    (secure=0).  UNCHANGED from blaze_responder.py -- it already works.

  * Plain TCP on 42130 -- the Blaze session server.  Properly frames Fire2,
    decodes the Heat2 TDF body, logs everything, and ANSWERS:
        Util(0x0009)/preAuth(0x0007)  -> PreAuthResponse   (the current gate)
        Util(0x0009)/ping(0x0002)     -> PingResponse {STIM, TIME}
        msgType PING(4)               -> PING_REPLY(5), empty body
    Everything else is logged in full and (optionally) answered with an empty
    REPLY so the client is never left hanging.  See the TODO block near
    dispatch() for the next RPCs on the path.

CLEAN ROOM.  Schema comes from (a) FIFA17.exe's own in-process TDF reflection
metadata that we walked in live memory, (b) our own captured preAuth REQUEST,
and (c) independent third-party clean-room BlazeSDK-15.x reimplementations used
only to cross-check structure.  No EA/FIFA leaked source was consulted.

Run:  python3 blaze_responder_v2.py     (binds 42127 + 42130)
Log:  /tmp/blaze_responder.log
"""

from __future__ import annotations

import binascii
import os
import socket
import ssl
import struct
import sys
import threading
import time
from collections import OrderedDict

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import heat2  # noqa: E402
from heat2 import INT, STRING, STRUCT, LIST, MAP, encode_tdf, decode_tdf  # noqa: E402

# ------------------------------------------------------------------ config

HOST = "127.0.0.1"
REDIR_PORT = 42127
BLAZE_PORT = 42130
BLAZE_IP_STR = "127.0.0.1"
BLAZE_IP_U32 = (127 << 24) | 1          # 2130706433
LOG = "/tmp/blaze_responder.log"
HERE = os.path.dirname(os.path.abspath(__file__))
CERT = os.path.join(HERE, "redir_cert.pem")
KEY = os.path.join(HERE, "redir_key.pem")

# If True, any RPC we do not implement still gets an empty REPLY frame so the
# client's request does not time out.  Flip to False to see which RPC the
# client is actually blocking on.
REPLY_EMPTY_TO_UNKNOWN = True

# Dump every frame we receive to /tmp/blaze_rx_<comp>_<cmd>_<n>.bin
DUMP_FRAMES = True

_log_lock = threading.Lock()


def log(m: str) -> None:
    line = "[%s] %s" % (time.strftime("%H:%M:%S"), m)
    with _log_lock:
        print(line, flush=True)
        with open(LOG, "a") as fh:
            fh.write(line + "\n")


def hexdump(b: bytes, limit: int = 512) -> str:
    out = []
    for i in range(0, min(len(b), limit), 16):
        chunk = b[i:i + 16]
        txt = "".join(chr(c) if 32 <= c < 127 else "." for c in chunk)
        out.append("  %04x: %-47s  %s"
                   % (i, binascii.hexlify(chunk, " ").decode(), txt))
    if len(b) > limit:
        out.append("  ... (%d more bytes)" % (len(b) - limit))
    return "\n".join(out)


# ------------------------------------------------------------------ Fire2
#
# CORRECTED 16-byte big-endian header (heat2.build_fire2_frame /
# heat2.parse_fire2_frame encode the OLD, WRONG layout -- do not use them):
#
#   [0:4]   u32 payload length
#   [4:6]   u16 metadata length
#   [6:8]   u16 component
#   [8:10]  u16 command
#   [10:13] u24 msgNum          <- 3 bytes; what we once read as "msgType"
#   [13]    u8  (msgType << 5) | (userIndex & 0x1F)
#   [14]    u8  options
#   [15]    u8  reserved
#   wire = header(16) || metadata || payload
#
# There is NO error field in the Fire2 header (that is Fire v1's 12-byte frame).

FIRE2_HDR = 16

MESSAGE, REPLY, NOTIFICATION, ERROR_REPLY, PING, PING_REPLY = range(6)
MSGTYPE_NAME = {0: "MESSAGE", 1: "REPLY", 2: "NOTIFICATION",
                3: "ERROR_REPLY", 4: "PING", 5: "PING_REPLY"}

COMP_AUTH = 0x0001
COMP_GAMEMANAGER = 0x0004
COMP_REDIRECTOR = 0x0005
COMP_STATS = 0x0007
COMP_UTIL = 0x0009
COMP_MESSAGING = 0x000F
COMP_ASSOCLISTS = 0x0019
COMP_GAMEREPORTING = 0x001C
COMP_USERSESSIONS = 0x7802

CMD_FETCHCLIENTCONFIG = 0x0001
CMD_PING = 0x0002
CMD_PREAUTH = 0x0007
CMD_POSTAUTH = 0x0008
CMD_SETCLIENTSTATE = 0x001C

# Util command table recovered from the binary's getCommandName switch.
UTIL_CMDS = {
    0x01: "fetchClientConfig", 0x02: "ping", 0x03: "setClientData",
    0x04: "localizeStrings", 0x05: "getTelemetryServer", 0x06: "getTickerServer",
    0x07: "preAuth", 0x08: "postAuth", 0x0A: "userSettingsLoad",
    0x0B: "userSettingsSave", 0x0C: "userSettingsLoadAll",
    0x0E: "userSettingsDelete", 0x0F: "userSettingsLoadAllForUser",
    0x14: "filterForProfanity", 0x15: "fetchQosConfig",
    0x16: "setClientMetrics", 0x17: "setConnectionState",
    0x19: "getUserOptions", 0x1A: "setUserOptions", 0x1B: "suspendUserPing",
    0x1C: "setClientState",
}
COMP_NAMES = {
    COMP_AUTH: "Authentication", COMP_GAMEMANAGER: "GameManager",
    COMP_REDIRECTOR: "Redirector", COMP_STATS: "Stats", COMP_UTIL: "Util",
    COMP_MESSAGING: "Messaging", COMP_ASSOCLISTS: "AssociationLists",
    COMP_GAMEREPORTING: "GameReporting", COMP_USERSESSIONS: "UserSessions",
}


def rpc_name(component: int, command: int) -> str:
    comp = COMP_NAMES.get(component, "Component:0x%04x" % component)
    if component == COMP_UTIL:
        cmd = UTIL_CMDS.get(command, "cmd:0x%04x" % command)
    else:
        cmd = "cmd:0x%04x" % command
    return "%s::%s" % (comp, cmd)


def fire2(component: int, command: int, msg_num: int, msg_type: int,
          payload: bytes = b"", metadata: bytes = b"",
          user_index: int = 0, options: int = 0) -> bytes:
    h = bytearray(16)
    struct.pack_into(">I", h, 0, len(payload))
    struct.pack_into(">H", h, 4, len(metadata))
    struct.pack_into(">H", h, 6, component & 0xFFFF)
    struct.pack_into(">H", h, 8, command & 0xFFFF)
    h[10] = (msg_num >> 16) & 0xFF
    h[11] = (msg_num >> 8) & 0xFF
    h[12] = msg_num & 0xFF
    h[13] = ((msg_type & 0x07) << 5) | (user_index & 0x1F)
    h[14] = options & 0xFF
    h[15] = 0
    return bytes(h) + metadata + payload


def parse_fire2_header(buf: bytes) -> dict:
    return dict(
        payload_len=struct.unpack_from(">I", buf, 0)[0],
        metadata_len=struct.unpack_from(">H", buf, 4)[0],
        component=struct.unpack_from(">H", buf, 6)[0],
        command=struct.unpack_from(">H", buf, 8)[0],
        msg_num=(buf[10] << 16) | (buf[11] << 8) | buf[12],
        msg_type=(buf[13] >> 5) & 0x07,
        user_index=buf[13] & 0x1F,
        options=buf[14],
        reserved=buf[15],
    )


def reply_to(hdr: dict, payload: bytes = b"", msg_type: int = REPLY) -> bytes:
    """A Blaze reply echoes component/command/msgNum/userIndex verbatim and
    only overwrites the msgType bits."""
    return fire2(hdr["component"], hdr["command"], hdr["msg_num"], msg_type,
                 payload, user_index=hdr["user_index"])


# ------------------------------------------------------- PreAuthResponse
#
# Reconciled schema: reflection descriptor VA 0x144875600 (14 members) INTERSECT
# the independent clean-room emulators.  Members are emitted in ascending
# packed-tag order; heat2.encode_tdf enforces that automatically.

# EA numeric title id.  NOT reverse engineered -- onPreAuthResponse only
# memcpy's ASRC/ESRC/RSRC, so any value is accepted here; Authentication
# (component 1) may care later.
TITLE_ID = "309111"

# Nucleus client id.  Plausible convention, not RE'd.
CLIENT_ID = "FIFA17-PC-SERVER-BLAZE"

# Persona namespace.  Client caps this field at 32 bytes.
PERSONA_NAMESPACE = "cem_ea_id"

PLATFORM = "pc"
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n"   # EA's real value ends in \n

# Component ids recovered from each component's own notification dispatcher in
# FIFA17.exe.  This is the client's view of "which components exist server
# side"; later components look themselves up in this list.
COMPONENT_IDS = [
    COMP_AUTH,           # 1     Authentication
    COMP_GAMEMANAGER,    # 4     GameManager
    COMP_REDIRECTOR,     # 5     Redirector
    COMP_STATS,          # 7     Stats
    COMP_UTIL,           # 9     Util
    COMP_MESSAGING,      # 15    Messaging
    COMP_ASSOCLISTS,     # 25    AssociationLists
    COMP_GAMEREPORTING,  # 28    GameReporting
    COMP_USERSESSIONS,   # 30722 UserSessions
]

# The request carried FCCR{CFID="BlazeSDK"}, i.e. an embedded fetchClientConfig
# for the "BlazeSDK" section -- so CONF.CONF is that section.  These five keys
# are the ones ConnectionManager::onPreAuthResponse actually reads (verified by
# disassembly); every one has a fallback, so nothing here is strictly required.
# Time values are MICROSECONDS: the client divides by 1000 to get ms.
BLAZESDK_CONFIG = [
    ("connIdleTimeout",        "90000000"),   # 90 s
    ("defaultRequestTimeout",  "30000000"),   # 30 s
    ("enableQosBandwidthTest", "false"),      # exact string "false" clears bit 1
    ("enableQosFirewallTest",  "false"),      # exact string "false" clears bit 0
    ("pingPeriod",             "20000000"),   # 20 s (default would be 15000 ms)
]

# TODO(auth gate): the BlazeSDK section also carries the Nucleus endpoints
#   nucleusConnect / nucleusConnectTrusted / nucleusPortal / nucleusProxy.
#   Pointing those at a local HTTPS shim is our lever for offline login.
#   Not emitted yet -- unread at preAuth, and wrong values may send the client
#   at a real EA host during Authentication::login.


def qos_config() -> "OrderedDict":
    """Blaze::QosConfigInfo -- 4 members per reflection (there is NO SVID in
    FIFA17's descriptor, unlike Mirror's Edge Catalyst)."""
    return OrderedDict([
        ("BWPS", (STRUCT, OrderedDict([          # Blaze::QosPingSiteInfo
            ("PSA", (STRING, "127.0.0.1")),      # address
            ("PSP", (INT, 17502)),               # port
        ]))),
        ("LNP",  (INT, 10)),                     # numLatencyProbes
        ("LTPS", (MAP, (STRING, STRUCT, []))),   # pingSiteInfoByAliasMap: EMPTY
        ("TIME", (INT, 5000000)),                # timeout, microseconds
    ])


def preauth_response_fields(service_name: str = "fifa-2017-pc") -> "OrderedDict":
    return OrderedDict([
        ("ASRC", (STRING, TITLE_ID)),            # authenticationSource
        ("CIDS", (LIST, (INT, COMPONENT_IDS))),  # componentIds
        ("CLID", (STRING, CLIENT_ID)),           # clientId
        ("CONF", (STRUCT, OrderedDict([          # Util::FetchConfigResponse
            ("CONF", (MAP, (STRING, STRING, list(BLAZESDK_CONFIG)))),
        ]))),
        ("ESRC", (STRING, TITLE_ID)),            # entitlementSource
        ("INST", (STRING, service_name)),        # serviceName -- echo CDAT.SVCN
        ("MAID", (INT, 0)),                      # machineId
        ("MINR", (INT, 0)),                      # underageSupported = false
        ("NASP", (STRING, PERSONA_NAMESPACE)),   # personaNamespace
        ("PILD", (STRING, "")),                  # legalDocGameIdentifier
        ("PLAT", (STRING, PLATFORM)),            # platform
        ("QOSS", (STRUCT, qos_config())),        # qosSettings
        ("RSRC", (STRING, TITLE_ID)),            # registrationSource
        ("SVER", (STRING, SERVER_VERSION)),      # serverVersion
    ])


def ping_response_fields() -> "OrderedDict":
    """Blaze 15.1.1.1.0+ reads STIM, 15.1.1.0.x reads TIME.  FIFA17 reports
    BSDK 15.1.1.3.0, so STIM is the live one -- but unknown tags are ignored,
    so emit both and stay version-proof.  (Tag order STIM < TIME is handled by
    heat2's ascending-tag sort.)"""
    now = int(time.time())
    return OrderedDict([("STIM", (INT, now)), ("TIME", (INT, now))])


def extract_service_name(fields) -> str:
    """PreAuthRequest.CDAT.SVCN -- echo it back as INST."""
    try:
        cdat = fields.get("CDAT")
        if cdat and cdat[0] == STRUCT:
            svcn = cdat[1].get("SVCN")
            if svcn and svcn[0] == STRING and svcn[1]:
                return svcn[1]
    except Exception:
        pass
    return "fifa-2017-pc"


# ------------------------------------------------------------------ dispatch

def dispatch(hdr: dict, fields, raw_payload: bytes):
    """-> bytes to send back, or None to stay silent."""
    comp, cmd, mtype = hdr["component"], hdr["command"], hdr["msg_type"]

    # Transport-level PING frame (msgType 4) -- answer with PING_REPLY (5).
    if mtype == PING:
        log("  -> transport PING, answering PING_REPLY (empty)")
        return reply_to(hdr, b"", msg_type=PING_REPLY)

    if mtype not in (MESSAGE, PING):
        log("  -> msgType %s is not a request; not answering"
            % MSGTYPE_NAME.get(mtype, mtype))
        return None

    if comp == COMP_UTIL and cmd == CMD_PREAUTH:
        svcn = extract_service_name(fields) if fields is not None else "fifa-2017-pc"
        resp = preauth_response_fields(service_name=svcn)
        payload = encode_tdf(resp)
        log("  -> PreAuthResponse (INST=%r, %d payload bytes):\n%s"
            % (svcn, len(payload), heat2.dump(resp)))
        return reply_to(hdr, payload)

    if comp == COMP_UTIL and cmd == CMD_PING:
        resp = ping_response_fields()
        log("  -> PingResponse %s" % dict((k, v[1]) for k, v in resp.items()))
        return reply_to(hdr, encode_tdf(resp))

    # ---------------------------------------------------------------- TODO
    # Expected next RPCs on the FIFA17 login path (in order):
    #
    #  1. Util::fetchClientConfig (9/1) with FCCR/CFID="IdentityParams"
    #       -> FetchConfigResponse{CONF: map<str,str>} carrying `display` and
    #          `redirect_uri`; this drives the Nucleus web login overlay.
    #  2. Authentication::login (1/0x0A) with AUTH=<nucleus auth code>
    #       -> plus server NOTIFICATION 0x7802/8 UserAuthenticated.
    #  3. Util::postAuth (9/8)
    #       -> PostAuthResponse{TELE, TICK, UROP}; plus notifications
    #          0x7802/5 and 0x7802/1|2 (UserExtendedData).
    #  4. Util::setClientState (9/0x1C), Authentication::getAuthToken (1/0x24),
    #     AssociationLists::getLists (25/6), UserSessions::updateNetworkInfo
    #     (0x7802/0x14).
    #
    # Notifications are msgType=2 with msgNum=0 and are pushed unsolicited.
    # Error replies are msgType=3 but the ERROR-CODE placement is UNRESOLVED
    # (three clean-room sources disagree: header[14:16] vs metadata ERRC vs
    # payload CNTX/ERRC) -- do not emit one until it is verified.
    # ----------------------------------------------------------------------

    if REPLY_EMPTY_TO_UNKNOWN:
        log("  -> UNIMPLEMENTED %s; sending EMPTY REPLY so the client does not "
            "hang (all fields fall back to client-side defaults)"
            % rpc_name(comp, cmd))
        return reply_to(hdr, b"")

    log("  -> UNIMPLEMENTED %s; staying silent" % rpc_name(comp, cmd))
    return None


# ------------------------------------------------------------- blaze server

def recv_exactly(sock: socket.socket, n: int, buf: bytearray) -> bool:
    """Fill `buf` to at least n bytes.  False on clean EOF / short close."""
    while len(buf) < n:
        try:
            chunk = sock.recv(65536)
        except socket.timeout:
            return False
        if not chunk:
            return False
        buf += chunk
    return True


_frame_counter = [0]


def blaze_handle(raw: socket.socket, addr) -> None:
    log("*** BLAZE CONNECT from %s ***" % (addr,))
    buf = bytearray()
    raw.settimeout(300)
    try:
        while True:
            if not recv_exactly(raw, FIRE2_HDR, buf):
                break
            hdr = parse_fire2_header(bytes(buf[:FIRE2_HDR]))
            total = FIRE2_HDR + hdr["metadata_len"] + hdr["payload_len"]
            if hdr["payload_len"] > 4 * 1024 * 1024:
                log("BLAZE %s: absurd payload_len %d, dropping connection\n%s"
                    % (addr, hdr["payload_len"], hexdump(bytes(buf[:64]))))
                break
            if not recv_exactly(raw, total, buf):
                log("BLAZE %s: EOF mid-frame (want %d, have %d)"
                    % (addr, total, len(buf)))
                break

            frame = bytes(buf[:total])
            del buf[:total]
            metadata = frame[FIRE2_HDR:FIRE2_HDR + hdr["metadata_len"]]
            payload = frame[FIRE2_HDR + hdr["metadata_len"]:]

            _frame_counter[0] += 1
            n = _frame_counter[0]
            log("RX #%d %s  msgType=%s msgNum=%d userIdx=%d opts=0x%02x "
                "meta=%dB payload=%dB"
                % (n, rpc_name(hdr["component"], hdr["command"]),
                   MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]),
                   hdr["msg_num"], hdr["user_index"], hdr["options"],
                   hdr["metadata_len"], hdr["payload_len"]))
            log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
            if metadata:
                log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
            if DUMP_FRAMES:
                try:
                    fn = "/tmp/blaze_rx_%04x_%04x_%d.bin" % (
                        hdr["component"], hdr["command"], n)
                    with open(fn, "wb") as fh:
                        fh.write(frame)
                    log("RX #%d saved -> %s" % (n, fn))
                except Exception as e:
                    log("RX #%d save failed: %s" % (n, e))

            fields = None
            if payload:
                try:
                    fields = decode_tdf(payload)
                    log("RX #%d TDF:\n%s" % (n, heat2.dump(fields)))
                except Exception as e:
                    log("RX #%d TDF DECODE FAILED: %s" % (n, e))
            else:
                log("RX #%d TDF: (empty payload)" % n)

            try:
                out = dispatch(hdr, fields, payload)
            except Exception as e:
                log("RX #%d DISPATCH ERROR: %r" % (n, e))
                out = None

            if out:
                raw.sendall(out)
                ohdr = parse_fire2_header(out)
                log("TX #%d %s msgType=%s msgNum=%d %dB total (%d payload)"
                    % (n, rpc_name(ohdr["component"], ohdr["command"]),
                       MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]),
                       ohdr["msg_num"], len(out), ohdr["payload_len"]))
                log("TX #%d HEX:\n%s" % (n, hexdump(out, limit=1024)))
    except ConnectionResetError:
        log("BLAZE %s: connection reset by client" % (addr,))
    except Exception as e:
        log("BLAZE %s ERR: %r" % (addr, e))
    finally:
        try:
            raw.close()
        except Exception:
            pass
        log("BLAZE %s: closed" % (addr,))


# --------------------------------------------------------- redirector (TLS)

def build_redirect_response() -> bytes:
    # Confirmed schema (clean-room, MEC Catalyst): ServerInstanceInfo.address is
    # a ServerAddress union -> Heat2 XML union = <address member="N"><valu>...
    # member="0" = ipAddress variant {hostname, ip(uint32 decimal), port(uint16)}
    body = (
        '<?xml version="1.0" encoding="UTF-8"?>\n'
        '<serverinstanceinfo>\n'
        '\t<address member="0">\n'
        '\t\t<valu>\n'
        f'\t\t\t<hostname>{BLAZE_IP_STR}</hostname>\n'
        f'\t\t\t<ip>{BLAZE_IP_U32}</ip>\n'
        f'\t\t\t<port>{BLAZE_PORT}</port>\n'
        '\t\t</valu>\n'
        '\t</address>\n'
        '\t<secure>0</secure>\n'
        '\t<trialservicename></trialservicename>\n'
        '\t<defaultdnsaddress>0</defaultdnsaddress>\n'
        '</serverinstanceinfo>\n'
    )
    b = body.encode()
    hdr = ("HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\n"
           f"Content-Length: {len(b)}\r\nConnection: close\r\n\r\n").encode()
    return hdr + b


ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(CERT, KEY)
ctx.minimum_version = ssl.TLSVersion.TLSv1
ctx.set_ciphers("ALL:@SECLEVEL=0")


def redir_handle(raw: socket.socket, addr) -> None:
    try:
        tls = ctx.wrap_socket(raw, server_side=True)
    except ssl.SSLError as e:
        log("REDIR REJECTED %s: %s" % (addr, e))
        raw.close()
        return
    log("REDIR TLS-OK %s cipher=%s" % (addr, tls.cipher()[0]))
    try:
        tls.settimeout(8)
        req = b""
        while b"\r\n\r\n" not in req:
            c = tls.recv(4096)
            if not c:
                break
            req += c
        if b"content-length:" in req.lower():
            head, _, rest = req.partition(b"\r\n\r\n")
            cl = int([l.split(b":")[1] for l in head.split(b"\r\n")
                      if l.lower().startswith(b"content-length")][0])
            while len(rest) < cl:
                c = tls.recv(4096)
                if not c:
                    break
                rest += c
            req = head + b"\r\n\r\n" + rest
        line0 = req.split(b"\r\n", 1)[0].decode(errors="replace")
        log("REDIR REQ %s: %s" % (addr, line0))
        resp = build_redirect_response()
        tls.sendall(resp)
        log("REDIR SENT %s %dB serverinstanceinfo -> %s:%d"
            % (addr, len(resp), BLAZE_IP_STR, BLAZE_PORT))
        time.sleep(0.3)
        tls.close()
    except Exception as e:
        log("REDIR ERR %s: %s" % (addr, e))


# ------------------------------------------------------------------ serve

def serve(port: int, handler, name: str) -> None:
    s = socket.socket()
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind((HOST, port))
    s.listen(16)
    log("%s listening on %s:%d" % (name, HOST, port))
    while True:
        c, a = s.accept()
        threading.Thread(target=handler, args=(c, a), daemon=True).start()


def _selftest() -> None:
    """Sanity: build the preAuth reply and round-trip it through the decoder."""
    fields = preauth_response_fields()
    payload = encode_tdf(fields)
    frame = fire2(COMP_UTIL, CMD_PREAUTH, 0, REPLY, payload)
    h = parse_fire2_header(frame)
    assert h["component"] == COMP_UTIL and h["command"] == CMD_PREAUTH
    assert h["msg_type"] == REPLY and h["payload_len"] == len(payload)
    assert frame[13] == 0x20, frame[13]
    back = decode_tdf(frame[16:])
    assert list(back.keys()) == ["ASRC", "CIDS", "CLID", "CONF", "ESRC", "INST",
                                "MAID", "MINR", "NASP", "PILD", "PLAT", "QOSS",
                                "RSRC", "SVER"], list(back.keys())
    assert encode_tdf(back) == payload
    print("selftest OK: preAuth reply = %d bytes (%d payload)"
          % (len(frame), len(payload)))
    print("header:", frame[:16].hex(" "))
    print(heat2.dump(fields))


if __name__ == "__main__":
    if "--selftest" in sys.argv:
        _selftest()
        raise SystemExit(0)
    log("=== RESPONDER v2 START (redir %d / blaze %d) ===" % (REDIR_PORT, BLAZE_PORT))
    threading.Thread(target=serve, args=(BLAZE_PORT, blaze_handle, "BLAZE"),
                     daemon=True).start()
    serve(REDIR_PORT, redir_handle, "REDIR")
