#!/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 (v3) -- offline forged authentication.

WHAT IS NEW vs v2
-----------------
  * Util::fetchClientConfig (9/1) answered for real, per-CFID, with a proper
    FetchConfigResponse{CONF: map<string,string>}.  Unknown CFID -> EMPTY MAP
    (a present-but-empty CONF), never an empty frame.
  * Authentication (component 0x0001):
        login   (1/0x0A)  -> forged offline LoginResponse (persona 33068179/CAGE)
        logout  (1/0x46)  -> empty REPLY, and logged as a FAILURE SIGNAL
        getAuthToken (1/0x24), listUserEntitlements2 (1/0x1D)
  * UserSessions (0x7802) NOTIFICATIONs pushed unsolicited:
        0x0008 UserAuthenticated          (Blaze::UserSessionLoginInfo)
        0x0001 UserSessionExtendedDataUpdate
        0x0002 UserAdded                  (Blaze::UserData)
  * Util::postAuth (9/8), setClientState (9/0x1C), userSettingsLoad (9/0x0A),
    setClientMetrics (9/0x16), AssociationLists::getLists (25/6),
    UserSessions::updateNetworkInfo (0x7802/0x14).
  * Optional local Nucleus OAuth stub on 42131 serving POST /connect/token, with
    nucleusConnect / nucleusConnectTrusted in the BlazeSDK config pointed at it.
  * PingResponse now carries ONLY STIM (FIFA 17's PingResponse @0x144875560 has
    exactly one member; v2's extra TIME was Mirror's-Edge-Catalyst's field).

TWO-LAYER ORDERING -- READ THIS FIRST
-------------------------------------
This file is layer 2.  Layer 1 is Origin/LSX on 127.0.0.1:4216.  The client's
`origin.nav` gates FUT on `OriginIsOnlineTrue` and only then runs
`futBlazeLogin`; without a layer-1 auth code the client sends
`Authentication::logout` (1/0x46) instead of `login` and shows
"Unable to connect to the EA Servers ... log in to Origin in Online Mode".
Start `lsx_responder.py` (before FIFA) or apply `lsx_force_online.py` FIRST.
Receiving 1/0x46 here means layer 1 is still broken.

CLEAN ROOM.  Every TDF tag/type below comes from FIFA17.exe's own in-process TDF
reflection metadata that we walked in live memory, plus our own captured wire
bytes.  Independent third-party clean-room BlazeSDK-15.x reimplementations were
consulted only to cross-check *structure*.  No EA/FIFA leaked source was used.

Run:      python3 blaze_responder_v3.py        (binds 42127 + 42130 [+ 42131])
Selftest: python3 blaze_responder_v3.py --selftest
Log:      /tmp/blaze_responder.log
Frames:   /tmp/blaze_rx/
"""

from __future__ import annotations

import binascii
import json
import os
import random
import socket
import ssl
import string
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 (  # noqa: E402
    INT, STRING, BLOB, STRUCT, LIST, MAP, encode_tdf, decode_tdf,
)

# ================================================================== identity
# SHARED CONSTANTS -- these MUST stay byte-identical to lsx_responder.py.
# Source: stp-origin_emu.ini [Globals] (PersonaId / PersonaName / Language).
# A mismatch is exactly what raises AUTH_ERR_INVALID_PERSONA (26),
# AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA and AUTH_ERR_PERSONA_NOT_FOUND.

PERSONA_ID = 33068179
PERSONA_NAME = "CAGE"
USER_ID = 33068179              # blazeId / userId; same value keeps BUID==UID==PID
EXT_ID = 33068179               # XREF externalId
EMAIL = "cage@openfut.local"
PERSONA_NAMESPACE = "cem_ea_id"  # must equal PreAuthResponse.NASP
CLIENT_PLATFORM = 4              # Blaze::ClientPlatformType -> pc
PERSONA_STATUS = 2               # PersonaStatus::Code -> ACTIVE (verified live: table 0x14487ad20, ACTIVE==2)
USER_SESSION_TYPE = 0            # Blaze::UserSessionType -> normal/console user
ACCOUNT_LOCALE_FALLBACK = 0x656E5553  # 'enUS'; overwritten by the client's own
                                      # PreAuthRequest LANG/LOC when we see it.

CONTENT_ID = "1027460"           # FIFA 17 EA offer id (retail)
ENTITLEMENT_TAG = "ONLINE_ACCESS"   # TRIAL_ONLINE_ACCESS for FIFA17_Trial.exe
ENTITLEMENT_GROUP = "FIFA17PC"

TITLE_ID = "309111"
CLIENT_ID = "FIFA17-PC-SERVER-BLAZE"
PLATFORM = "pc"
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n"

# ================================================================== config

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

# Serve a local OAuth stub and advertise it as nucleusConnect.  The client's
# LoginStateMachineImpl builds "<nucleusConnect>/connect/token", POSTs
# grant_type=client_credentials, and scrapes '"access_token" : "'.
NUCLEUS_STUB_ENABLED = True
EMIT_NUCLEUS_URLS = True
NUCLEUS_BASE = "http://%s:%d" % (HOST, NUCLEUS_PORT)

# Any RPC we do not implement still gets an empty REPLY so the client's request
# never times out.  Flip to False to find out what it truly blocks on.
REPLY_EMPTY_TO_UNKNOWN = True

# Push the UserAuthenticated notification BEFORE writing the login reply
# (grid-blaze order) or after (pamplona order).  Both are reported to work.
NOTIFY_BEFORE_LOGIN_REPLY = False

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)
        try:
            with open(LOG, "a") as fh:
                fh.write(line + "\n")
        except Exception:
            pass


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                (== notification id on NOTIFICATION)
#   [10:13] u24 msgNum
#   [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

# ---- Util (0x0009) command table, recovered from the binary's own
#      getCommandName switch (jump table 0x141b17af4).
CMD_FETCHCLIENTCONFIG = 0x0001
CMD_PING = 0x0002
CMD_PREAUTH = 0x0007
CMD_POSTAUTH = 0x0008
CMD_USERSETTINGSLOAD = 0x000A
CMD_USERSETTINGSSAVE = 0x000B
CMD_SETCLIENTMETRICS = 0x0016
CMD_SETCLIENTSTATE = 0x001C

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",
}

# ---- Authentication (0x0001) command table.  Recovered by CALLING the client's
#      own getCommandName (0x146e0d2a0) in-process over ids 1..320 -- the name
#      pool is Denuvo-mutated and statically unrecoverable.  Validated against
#      Util (reproduced preAuth=7/ping=2/fetchClientConfig=1) and cross-checked
#      against a static REST-binding struct (0x143896a80 -> trustedLogin=0x0B).
CMD_LOGIN = 0x000A
CMD_TRUSTEDLOGIN = 0x000B
CMD_LISTUSERENTITLEMENTS2 = 0x001D
CMD_GETAUTHTOKEN = 0x0024
CMD_EXPRESSLOGIN = 0x003C
CMD_LOGOUT = 0x0046          # <-- the "we gave up" RPC, NOT a login
CMD_GETPERSONA = 0x005A
CMD_LISTPERSONAS = 0x0064

AUTH_CMDS = {
    0x0A: "login", 0x0B: "trustedLogin", 0x14: "updateAccount",
    0x15: "upgradeAccount", 0x1D: "listUserEntitlements2", 0x1E: "getAccount",
    0x1F: "grantEntitlement", 0x20: "listEntitlements", 0x22: "getUseCount",
    0x23: "decrementUseCount", 0x24: "getAuthToken", 0x26: "getPasswordRules",
    0x27: "grantEntitlement2", 0x2B: "modifyEntitlement2", 0x2C: "consumecode",
    0x2D: "passwordForgot", 0x2F: "getPrivacyPolicyContent",
    0x30: "listPersonaEntitlements2", 0x33: "checkAgeReq", 0x34: "getOptIn",
    0x35: "enableOptIn", 0x36: "disableOptIn", 0x3C: "expressLogin",
    0x46: "logout", 0x5A: "getPersona", 0x64: "listPersonas",
    0x65: "expressCreateAccount", 0xE6: "createWalUserSession",
    0xF1: "acceptLegalDocs", 0xF2: "getEmailOptInSettings",
    0xF6: "getTermsOfServiceContent", 0x104: "getOriginPersona",
    0x10E: "checkEmail", 0x118: "getPersonaNameSuggestions", 0x122: "guestLogin",
}

# ---- UserSessions (0x7802).  Commands and NOTIFICATIONS live in separate
#      number spaces.  Notification ids decoded statically from the client's
#      own getNotificationName jump table at 0x141b03f70 (clean, unmutated).
NOTIFY_USER_EXTENDED_DATA_UPDATE = 0x0001
NOTIFY_USER_ADDED = 0x0002
NOTIFY_USER_REMOVED = 0x0003
NOTIFY_USER_UPDATED = 0x0005
NOTIFY_USER_AUTHENTICATED = 0x0008
NOTIFY_USER_UNAUTHENTICATED = 0x0009
NOTIFY_SERVER_DRAINING = 0x000C

USERSESSIONS_NOTIFY_NAMES = {
    0x01: "UserSessionExtendedDataUpdate", 0x02: "UserAdded",
    0x03: "UserRemoved", 0x05: "UserUpdated", 0x08: "UserAuthenticated",
    0x09: "UserUnauthenticated", 0x0C: "ServerDraining",
}

CMD_UPDATENETWORKINFO = 0x0014   # UserSessions command space
CMD_GETLISTS = 0x0006            # AssociationLists

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, msg_type: int = MESSAGE) -> str:
    comp = COMP_NAMES.get(component, "Component:0x%04x" % component)
    if component == COMP_USERSESSIONS and msg_type == NOTIFICATION:
        cmd = USERSESSIONS_NOTIFY_NAMES.get(command, "notify:0x%04x" % command)
        return "%s::<%s>" % (comp, cmd)
    if component == COMP_UTIL:
        cmd = UTIL_CMDS.get(command, "cmd:0x%04x" % command)
    elif component == COMP_AUTH:
        cmd = AUTH_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 (byte[13] = 0x20 for REPLY + userIndex 0)."""
    return fire2(hdr["component"], hdr["command"], hdr["msg_num"], msg_type,
                 payload, user_index=hdr["user_index"])


def notification(component: int, notify_id: int, payload: bytes = b"",
                 user_index: int = 0) -> bytes:
    """Unsolicited server push: msgType = NOTIFICATION (2) -> byte[13] = 0x40,
    msgNum = 0 (notifications are not correlated to a request)."""
    return fire2(component, notify_id, 0, NOTIFICATION, payload,
                 user_index=user_index)


# ================================================================== session

class Session(object):
    """Per-connection forged session state."""

    def __init__(self):
        self.session_key = make_session_key()
        self.auth_code = ""          # whatever LoginRequest.AUTH carried
        self.account_locale = ACCOUNT_LOCALE_FALLBACK
        self.service_name = "fifa-2017-pc"
        self.logged_in = False
        self.login_time = 0


def make_session_key() -> str:
    """Real Blaze session keys look like <16 hex>_<44 base64-ish chars>.
    The client never validates it -- grid-blaze literally ships "0" -- but the
    SAME string must appear in LoginResponse.SESS.KEY and in the
    UserAuthenticated notification's KEY, so we mint it once per session."""
    alpha = string.ascii_letters + string.digits + "$*"
    return ("%016x_" % random.getrandbits(64)) + \
           "".join(random.choice(alpha) for _ in range(44))


# ============================================== Util::fetchClientConfig (9/1)
#
# Response type: Blaze::Util::FetchConfigResponse @0x1448752e0 -- a SINGLE
# member `CONF` : map<string,string>.  NOT double-nested: the extra nesting only
# exists inside PreAuthResponse, where CONF is itself a FetchConfigResponse
# whose own single member is also called CONF.  Easy to get wrong.

# Keys verified present as string literals in FIFA17.exe (owner in comment).
# Anything not in the binary is silently ignored, so the map is kept minimal.
# All time values are MICROSECONDS -- the client divides by 1000 to get ms.
def blazesdk_config() -> list:
    cfg = [
        ("associationListSkipInitialSet", "1"),        # 0x143b6eb88 AssocListAPI
        ("autoReconnectEnabled",          "1"),        # 0x1438a0a68 ConnMgr
        ("connIdleTimeout",               "90000000"),  # 0x1438a0a58 ConnMgr
        ("defaultRequestTimeout",         "30000000"),  # 0x1438a0a40 ConnMgr
        ("enableQosBandwidthTest",        "false"),     # 0x1438a0a08 clears bit1
        ("enableQosFirewallTest",         "false"),     # 0x1438a09f0 clears bit0
        ("maxReconnectAttempts",          "5"),         # 0x1438a0a80 ConnMgr
        ("pingPeriod",                    "20000000"),  # 0x1438a0a30 ConnMgr
        ("userManagerMaxCachedUsers",     "128"),       # UserManager
        ("voipHeadsetUpdateRate",         "0"),         # VoIP
    ]
    if EMIT_NUCLEUS_URLS:
        # LoginStateMachineImpl (0x14389fd50-0x14389fef8) builds
        #   "<nucleusConnect>/connect/token", POSTs grant_type=client_credentials,
        # and scrapes '"access_token" : "' out of the reply.  Point it at our own
        # stub (see nucleus_handle) so it can never reach a real EA host.
        cfg += [
            ("nucleusConnect",        NUCLEUS_BASE),   # 0x14389fef8
            ("nucleusConnectTrusted", NUCLEUS_BASE),   # 0x14389fdf8
        ]
    return sorted(cfg)


# The OSDK_* sections are NOT Blaze plumbing.  They are FIFA's own OSDK
# (8.01.03.00-fifa.01) ResourceLoader tuning maps; every key falls back to a
# built-in default, which is why our empty replies did not by themselves kill
# the login.  Answering them non-empty is cheap insurance and removes a
# variable.  Key names are literals observed in FIFA17.exe.
OSDK_CORE = [
    ("OSDK_PRESENCE_DELAY", "5"),
    ("OSDK_PRESENCE_POLL", "60"),
    ("OSDK_ANTIGRIEFING_MAX_COUNT", "0"),
    ("OSDK_ARENA_ENABLED", "0"),
]
OSDK_CLIENT = [
    ("OSDK_CLUBS_MAX_SEARCH_RESULT", "50"),
    ("OSDK_CLUBS_LOAD_MEMBER_PAGE_SIZE", "25"),
    ("OSDK_CLUBS_MAX_USERS_FOR_GAME", "22"),
    ("OSDK_CLUBS_LEADERBOARD_CLUB_MAX", "100"),
    ("OSDK_CLUBS_INCOME_SEARCH_MAX", "100"),
]
# OSDK_NUCLEUS is Nucleus *tuning* only -- no URL keys were found in it.  The
# real Nucleus endpoints are BlazeSDK-level (nucleusConnect, above).
OSDK_NUCLEUS = [
    ("OSDK_NUCLEUS_ENABLED", "1"),
    ("OSDK_NUCLEUS_POLL", "60"),
    ("OSDK_NUCLEUS_RETRY_COUNT", "3"),
    ("OSDK_NUCLEUS_TIMEOUT", "30"),
]
# Keep the online storefront and abuse-report web views switched OFF: with no
# EA web backend reachable, an enabled one is a hang waiting to happen.
OSDK_WEBOFFER = [
    ("OSDK_WEBOFFER_ENABLED", "0"),
    ("OSDK_WEBOFFER_URL", ""),
]
OSDK_ABUSE_REPORTING = [
    ("OSDK_ABUSE_REPORTING_ENABLED", "0"),
    ("OSDK_ABUSE_NUM_TYPES", "0"),
]
OSDK_TICKER = [
    ("OSDK_TICKER_ENABLED", "0"),
]
# Not requested by FIFA 17 in our capture, but both independent clean-room
# emulators answer it identically; harmless to have ready.
IDENTITY_PARAMS = [
    ("display", "console2/welcome"),
    ("redirect_uri", "http://127.0.0.1/success"),
]

CLIENT_CONFIGS = {
    "BlazeSDK": None,                      # built dynamically, see below
    "OSDK_CORE": OSDK_CORE,
    "OSDK_CLIENT": OSDK_CLIENT,
    "OSDK_NUCLEUS": OSDK_NUCLEUS,
    "OSDK_WEBOFFER": OSDK_WEBOFFER,
    "OSDK_ABUSE_REPORTING": OSDK_ABUSE_REPORTING,
    "OSDK_XMS_ABUSE_REPORTING": OSDK_ABUSE_REPORTING,
    "OSDK_TICKER": OSDK_TICKER,
    "IdentityParams": IDENTITY_PARAMS,
}


def client_config_for(cfid: str) -> list:
    """-> sorted [(key, value)].  Unknown CFID -> [] (an EMPTY MAP, which we
    still wrap in a present CONF field -- never an empty frame)."""
    if cfid == "BlazeSDK":
        return blazesdk_config()
    return sorted(CLIENT_CONFIGS.get(cfid) or [])


def fetch_config_response_fields(cfid: str) -> "OrderedDict":
    """Blaze::Util::FetchConfigResponse -- single member CONF : map<str,str>."""
    return OrderedDict([
        ("CONF", (MAP, (STRING, STRING, client_config_for(cfid)))),
    ])


# ================================================== Util::preAuth (9/7) reply

COMPONENT_IDS = [
    COMP_AUTH, COMP_GAMEMANAGER, COMP_REDIRECTOR, COMP_STATS, COMP_UTIL,
    COMP_MESSAGING, COMP_ASSOCLISTS, COMP_GAMEREPORTING, COMP_USERSESSIONS,
]


def qos_config() -> "OrderedDict":
    """Blaze::QosConfigInfo -- 4 members per reflection (FIFA 17's descriptor
    has NO SVID, unlike Mirror's Edge Catalyst)."""
    return OrderedDict([
        ("BWPS", (STRUCT, OrderedDict([        # Blaze::QosPingSiteInfo
            ("PSA", (STRING, "127.0.0.1")),
            ("PSP", (INT, 17502)),
        ]))),
        ("LNP",  (INT, 10)),
        ("LTPS", (MAP, (STRING, STRUCT, []))),
        ("TIME", (INT, 5000000)),
    ])


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, fetch_config_response_fields("BlazeSDK"))),
        ("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::Util::PingResponse @0x144875560 has EXACTLY ONE member: STIM
    (serverTime, uint32).  v2 also sent TIME -- that is MEC's field, not
    FIFA 17's.  Dropped."""
    return OrderedDict([("STIM", (INT, int(time.time())))])


# ==================================== Authentication::login (1/0x0A) -- FORGED
#
# Blaze::Authentication::LoginResponse @0x14487d170 -- EXACTLY 5 members.
# NOTE the divergence from both MEC emulators: they emit CNTX, ERRC and a
# top-level SKEY.  FIFA 17's LoginResponse has NONE of those -- CNTX/ERRC are
# the Blaze *error metadata* block, and the session key lives at SESS.KEY.

def persona_details_fields(now: int) -> "OrderedDict":
    """Blaze::Authentication::PersonaDetails @0x14487cab0 -- 6 members."""
    return OrderedDict([
        ("DSNM", (STRING, PERSONA_NAME)),   # displayName        MUST be "CAGE"
        ("LAST", (INT, now)),               # lastAuthenticated  uint32
        ("PID",  (INT, PERSONA_ID)),        # personaId  int64   MUST be 33068179
        ("PLAT", (INT, CLIENT_PLATFORM)),   # clientPlatform enum -> pc
        ("STAS", (INT, PERSONA_STATUS)),    # PersonaStatus::Code -> ACTIVE
        ("XREF", (INT, EXT_ID)),            # extId uint64
    ])


def user_login_info_fields(sess: Session, now: int) -> "OrderedDict":
    """Blaze::Authentication::UserLoginInfo @0x14487cb00 -- 8 members.
    ('1CON' packs to 0x11 which sorts BELOW 'A'=0x21, so it is first.)"""
    return OrderedDict([
        ("1CON", (INT, 0)),                       # isFirstConsoleLogin = false
        ("BUID", (INT, USER_ID)),                 # blazeUserId  -- MUST be != 0
        ("FRST", (INT, 0)),                       # isFirstLogin = false
        ("KEY",  (STRING, sess.session_key)),     # sessionKey -- MUST be non-empty
        ("LLOG", (INT, now)),                     # lastLoginDateTime
        ("MAIL", (STRING, EMAIL)),                # email
        ("PDTL", (STRUCT, persona_details_fields(now))),
        ("UID",  (INT, USER_ID)),                 # userId -- MUST be != 0
    ])


def login_response_fields(sess: Session) -> "OrderedDict":
    """Blaze::Authentication::LoginResponse @0x14487d170 -- 5 members only."""
    now = int(time.time())
    return OrderedDict([
        ("ANON", (INT, 0)),   # isAnonymous        -- 1 would give a guest session
        ("NTOS", (INT, 0)),   # needsLegalDoc      -- 1 diverts to the legal-doc flow
        ("SESS", (STRUCT, user_login_info_fields(sess, now))),
        ("SPAM", (INT, 1)),   # isOfLegalContactAge
        ("UNDR", (INT, 0)),   # isUnderage         -- 1 strips online features
    ])


# ============================ UserSessions notifications (component 0x7802)
#
# Authentication (0x0001) publishes NO notifications at all -- its +0x28 slot is
# getRestResourceInfo, not getNotificationName.  The login-success notification
# lives on UserSessions, whose getNotificationName (0x146de19a0) is clean,
# unmutated code with a 12-entry jump table at 0x141b03f70:
#     1 UserSessionExtendedDataUpdate   2 UserAdded   3 UserRemoved
#     5 UserUpdated   8 UserAuthenticated   9 UserUnauthenticated
#    12 ServerDraining

# CGID (connectionGroupObjectId) is an ObjectId triple.  heat2's OBJID encoding
# is UNVERIFIED on the wire and a wrong encoding desynchronises the whole TDF
# parse, whereas an ABSENT member simply keeps its client-side default.  So we
# omit it.  Flip this once OBJID is confirmed against a real capture.
EMIT_OBJID_FIELDS = False


def user_session_login_info_fields(sess: Session, now: int) -> "OrderedDict":
    """Blaze::UserSessionLoginInfo @0x14486f920 -- 16 members.  This is a
    SUPERSET of UserLoginInfo with the persona fields flattened in rather than
    nested.  KEY must be byte-identical to LoginResponse.SESS.KEY."""
    f = OrderedDict([
        ("1CON", (INT, 0)),                        # isFirstConsoleLogin
        ("ALOC", (INT, sess.account_locale)),      # accountLocale (echo client's)
        ("BUID", (INT, USER_ID)),                  # blazeUserId
        ("DSNM", (STRING, PERSONA_NAME)),          # displayName
        ("FRST", (INT, 0)),                        # isFirstLogin
        ("KEY",  (STRING, sess.session_key)),      # sessionKey  <- SAME string
        ("LAST", (INT, now)),                      # lastAuthenticated
        ("LLOG", (INT, now)),                      # lastLoginDateTime
        ("MAIL", (STRING, EMAIL)),                 # email
        ("NASP", (STRING, PERSONA_NAMESPACE)),     # must match PreAuthResponse
        ("PID",  (INT, PERSONA_ID)),               # personaId
        ("PLAT", (INT, CLIENT_PLATFORM)),          # clientPlatform
        ("UID",  (INT, USER_ID)),                  # userId
        ("USTP", (INT, USER_SESSION_TYPE)),        # userSessionType
        ("XREF", (INT, EXT_ID)),                   # extId
    ])
    if EMIT_OBJID_FIELDS:
        from heat2 import OBJID
        f["CGID"] = (OBJID, (COMP_USERSESSIONS, 1, USER_ID))
    return f


def network_qos_data_fields() -> "OrderedDict":
    """Blaze::Util::NetworkQosData @0x14486e680 -- 5 members.
    NATT = NatType; 0 = OPEN, which is what we want offline."""
    return OrderedDict([
        ("BWHR", (INT, 0)),     # bandwidthHostedRate
        ("DBPS", (INT, 100000)),  # downstream bits/s
        ("NAHR", (INT, 0)),     # natHostedRate
        ("NATT", (INT, 0)),     # NatType -> OPEN
        ("UBPS", (INT, 100000)),  # upstream bits/s
    ])


def user_session_extended_data_fields() -> "OrderedDict":
    """Blaze::UserSessionExtendedData @0x144870390 -- 12 members.

    TWO FIFA-17-SPECIFIC DELTAS vs the MEC emulators: FIFA HAS `PSLM`
    (latencyList) which they lack, and FIFA has `BPS` as a TOP-LEVEL string
    member whereas they bury it inside the ADDR union.  Follow FIFA's layout.

    ADDR (NetworkAddress union), CVAR (variable) and ULST (list<ObjectId>) are
    omitted: heat2's UNION/OBJID encodings are unverified and a bad one breaks
    the whole parse, while an absent member just keeps its default."""
    return OrderedDict([
        ("BPS",  (STRING, "openfut")),               # bestPingSiteAlias
        ("CTY",  (STRING, "US")),                    # country
        ("DMAP", (MAP, (INT, INT, []))),             # dataMap  map<int64,uint32>
        ("HWFG", (INT, 0)),                          # hardwareFlags bitfield
        ("ISP",  (STRING, "OpenFUT")),               # iSP
        ("PSLM", (LIST, (INT, [0]))),                # latencyList  <- FIFA-only
        ("QDAT", (STRUCT, network_qos_data_fields())),
        ("TZ",   (STRING, "")),                      # timeZone
        ("UATT", (INT, 0)),                          # userInfoAttribute
    ])


def user_session_extended_data_update_fields() -> "OrderedDict":
    """Blaze::UserSessionExtendedDataUpdate @0x1448703e0 -- 3 members."""
    return OrderedDict([
        ("DATA", (STRUCT, user_session_extended_data_fields())),
        ("SUBS", (INT, 1)),          # subscribed
        ("USID", (INT, USER_ID)),    # userId
    ])


def user_identification_fields() -> "OrderedDict":
    """Blaze::UserIdentification @0x14486ebc0 -- 9 members."""
    return OrderedDict([
        ("AID",  (INT, USER_ID)),                  # accountId
        ("ALOC", (INT, ACCOUNT_LOCALE_FALLBACK)),  # accountLocale
        ("EXBB", (BLOB, b"")),                     # externalBlob
        ("EXID", (INT, EXT_ID)),                   # externalId
        ("ID",   (INT, USER_ID)),                  # blazeId
        ("NAME", (STRING, PERSONA_NAME)),          # name
        ("NASP", (STRING, PERSONA_NAMESPACE)),     # personaNamespace
        ("ORIG", (INT, PERSONA_ID)),               # originPersonaId
        ("PIDI", (INT, PERSONA_ID)),               # pidId
    ])


def user_data_fields() -> "OrderedDict":
    """Blaze::UserData @0x1448706b0 -- 3 members.  Payload of UserAdded (2).
    FLGS is a UserDataFlags bitfield; bit0 = online/authenticated."""
    return OrderedDict([
        ("EDAT", (STRUCT, user_session_extended_data_fields())),
        ("FLGS", (INT, 3)),
        ("USER", (STRUCT, user_identification_fields())),
    ])


def build_login_notifications(sess: Session, now: int) -> list:
    """The push sequence the client waits on after a successful login."""
    return [
        ("UserAuthenticated", notification(
            COMP_USERSESSIONS, NOTIFY_USER_AUTHENTICATED,
            encode_tdf(user_session_login_info_fields(sess, now)))),
        ("UserSessionExtendedDataUpdate", notification(
            COMP_USERSESSIONS, NOTIFY_USER_EXTENDED_DATA_UPDATE,
            encode_tdf(user_session_extended_data_update_fields()))),
        ("UserAdded", notification(
            COMP_USERSESSIONS, NOTIFY_USER_ADDED,
            encode_tdf(user_data_fields()))),
    ]


# ================================================ post-login RPC bodies

def post_auth_response_fields(sess: Session) -> "OrderedDict":
    """Blaze::Util::PostAuthResponse @0x144875810 -- TELE, TICK, UROP.
    Telemetry/ticker are pointed at a dead local port on purpose: we want the
    client to have a well-formed config and then fail to connect quietly rather
    than resolve a real EA hostname."""
    tele = OrderedDict([                      # GetTelemetryServerResponse (15)
        ("ADRS", (STRING, "127.0.0.1")),
        ("ANON", (INT, 0)),
        ("DISA", (STRING, "")),
        ("EDCT", (INT, 0)),
        ("FILT", (STRING, "")),
        ("LOC",  (INT, sess.account_locale)),
        ("MINR", (INT, 0)),
        ("NOOK", (STRING, "")),
        ("PORT", (INT, 9988)),
        ("SDLY", (INT, 15000)),
        ("SESS", (STRING, sess.session_key)),
        ("SKEY", (STRING, "")),
        ("SPCT", (INT, 75)),
        ("STIM", (STRING, "")),
        ("SVNM", (STRING, "telemetry-openfut")),
    ])
    tick = OrderedDict([                      # GetTickerServerResponse (3)
        ("ADRS", (STRING, "127.0.0.1")),
        ("PORT", (INT, 8999)),
        ("SKEY", (STRING, "")),
    ])
    urop = OrderedDict([                      # UserOptions (2)
        ("TMOP", (INT, 0)),                   # TelemetryOpt -> out/disabled
        ("UID",  (INT, USER_ID)),
    ])
    return OrderedDict([
        ("TELE", (STRUCT, tele)),
        ("TICK", (STRUCT, tick)),
        ("UROP", (STRUCT, urop)),
    ])


def entitlement_fields(now: int) -> "OrderedDict":
    """Blaze::Authentication::Entitlement @0x14487d490 -- 16 members.
    The retail exe requires TAG='ONLINE_ACCESS' tied to offer 1027460, STAT
    active, PID 33068179.  Failure modes: AUTH_ERR_NO_SUCH_ENTITLEMENT (63),
    AUTH_ERR_ENTITLEMENT_TAG_REQUIRED (74)."""
    day = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))
    return OrderedDict([
        ("DEVI", (STRING, "")),                    # deviceUri
        ("GDAY", (STRING, "2016-09-01T00:00:00Z")),  # grantDate
        ("GNAM", (STRING, ENTITLEMENT_GROUP)),     # groupName
        ("ID",   (INT, 1)),                        # id
        ("ISCO", (INT, 0)),                        # isConsumable
        ("PID",  (INT, PERSONA_ID)),               # personaId
        ("PJID", (STRING, CONTENT_ID)),            # projectId  (EA offer id)
        ("PRCA", (INT, 2)),                        # productCatalog
        ("PRID", (STRING, CONTENT_ID)),            # productId
        ("STAT", (INT, 1)),                        # EntitlementStatus -> ACTIVE (1, verified)
        ("STRC", (INT, 0)),                        # statusReasonCode
        ("TAG",  (STRING, ENTITLEMENT_TAG)),       # entitlementTag
        ("TDAY", (STRING, "")),                    # terminationDate (never)
        ("TYPE", (INT, 1)),                        # EntitlementType -> ONLINE_ACCESS (1, verified)
        ("UCNT", (INT, 0)),                        # useCount
        ("VER",  (INT, 1)),                        # version
    ])
    del day  # (kept for readability of the date format above)


def entitlements_response_fields() -> "OrderedDict":
    """Blaze::Authentication::Entitlements @0x14487d4e0 -- single member NLST."""
    now = int(time.time())
    return OrderedDict([
        ("NLST", (LIST, (STRUCT, [entitlement_fields(now)]))),
    ])


def get_auth_token_response_fields(sess: Session) -> "OrderedDict":
    """Blaze::Authentication::GetAuthTokenResponse @0x14487d080 -- 1 member."""
    tok = sess.auth_code or ("OPENFUT-" + sess.session_key[:16])
    return OrderedDict([("AUTH", (STRING, tok))])


def user_settings_response_fields() -> "OrderedDict":
    """TODO(verify): Util::userSettingsLoad's response descriptor was not
    reflected.  Both independent clean-room emulators use a single `DATA`
    string, and an unknown-tag payload is ignored rather than fatal, so an empty
    DATA is the safe minimum -- the client falls back to defaults."""
    return OrderedDict([("DATA", (STRING, ""))])


def get_lists_response_fields() -> "OrderedDict":
    """TODO(verify): AssociationLists::getLists (25/6) response is 3P-only
    (GetListsResponse{LMAP: list<AssociationList>}).  FIFA's list names are NOT
    verified -- do not invent them.  An EMPTY list is well-formed and means
    'this user has no association lists', which is true offline."""
    return OrderedDict([("LMAP", (LIST, (STRUCT, [])))])


# ================================================================== dispatch

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"


def find_nested_int(fields, tag: str):
    """Depth-first search for an INT member `tag` anywhere in a decoded TDF."""
    if not isinstance(fields, dict):
        return None
    for k, (t, v) in fields.items():
        if k == tag and t == INT:
            return v
        if t == STRUCT:
            r = find_nested_int(v, tag)
            if r is not None:
                return r
    return None


def get_str(fields, tag: str, default: str = "") -> str:
    try:
        tv = fields.get(tag)
        if tv and tv[0] == STRING:
            return tv[1]
    except Exception:
        pass
    return default


def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
    """-> list of frames to send back, in order (may be empty)."""
    comp, cmd, mtype = hdr["component"], hdr["command"], hdr["msg_type"]

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

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

    # ---------------------------------------------------------------- Util
    if comp == COMP_UTIL:
        if cmd == CMD_PREAUTH:
            sess.service_name = (extract_service_name(fields)
                                 if fields is not None else "fifa-2017-pc")
            loc = None
            if fields is not None:
                loc = find_nested_int(fields, "LANG")
                if loc is None:
                    loc = find_nested_int(fields, "LOC")
            if loc:
                sess.account_locale = loc
                log("  -- client locale 0x%08x captured for ALOC" % loc)
            resp = preauth_response_fields(service_name=sess.service_name)
            payload = encode_tdf(resp)
            log("  -> PreAuthResponse (INST=%r, %d payload bytes):\n%s"
                % (sess.service_name, len(payload), heat2.dump(resp)))
            return [reply_to(hdr, payload)]

        if cmd == CMD_PING:
            resp = ping_response_fields()
            log("  -> PingResponse STIM=%d" % resp["STIM"][1])
            return [reply_to(hdr, encode_tdf(resp))]

        if cmd == CMD_FETCHCLIENTCONFIG:
            cfid = get_str(fields or {}, "CFID", "")
            resp = fetch_config_response_fields(cfid)
            n = len(resp["CONF"][1][2])
            log("  -> FetchConfigResponse CFID=%r -> %d key(s)%s"
                % (cfid, n, "" if n else "  (EMPTY MAP, unknown CFID)"))
            for k, v in resp["CONF"][1][2]:
                log("       %-32s = %s" % (k, v))
            return [reply_to(hdr, encode_tdf(resp))]

        if cmd == CMD_POSTAUTH:
            resp = post_auth_response_fields(sess)
            log("  -> PostAuthResponse (TELE/TICK/UROP)")
            return [reply_to(hdr, encode_tdf(resp))]

        if cmd == CMD_SETCLIENTSTATE:
            log("  -> setClientState: empty REPLY (no response TDF)")
            return [reply_to(hdr, b"")]

        if cmd == CMD_SETCLIENTMETRICS:
            log("  -> setClientMetrics: empty REPLY (no response TDF)")
            return [reply_to(hdr, b"")]

        if cmd == CMD_USERSETTINGSLOAD:
            log("  -> UserSettingsResponse (empty DATA; TODO verify descriptor)")
            return [reply_to(hdr, encode_tdf(user_settings_response_fields()))]

        if cmd == CMD_USERSETTINGSSAVE:
            log("  -> userSettingsSave: empty REPLY (accepted, discarded)")
            return [reply_to(hdr, b"")]

        if cmd == 0x15:  # fetchQosConfig -> proven-good QosConfigInfo body
            log("  -> QosConfigInfo (fetchQosConfig)")
            return [reply_to(hdr, encode_tdf(qos_config()))]

    # ------------------------------------------------------ Authentication
    if comp == COMP_AUTH:
        if cmd == CMD_LOGIN:
            sess.auth_code = get_str(fields or {}, "AUTH", "")
            sess.logged_in = True
            sess.login_time = int(time.time())
            log("  == Authentication::login  AUTH=%r (accepted WITHOUT Nucleus "
                "validation -- forged offline session)" % sess.auth_code)
            resp = login_response_fields(sess)
            payload = encode_tdf(resp)
            log("  -> LoginResponse (%d bytes):\n%s"
                % (len(payload), heat2.dump(resp)))
            notifs = build_login_notifications(sess, sess.login_time)
            out = []
            if NOTIFY_BEFORE_LOGIN_REPLY:
                for name, fr in notifs:
                    log("  ~> NOTIFY 0x7802/0x%04x %s" %
                        (parse_fire2_header(fr)["command"], name))
                    out.append(fr)
                out.append(reply_to(hdr, payload))
            else:
                out.append(reply_to(hdr, payload))
                for name, fr in notifs:
                    log("  ~> NOTIFY 0x7802/0x%04x %s" %
                        (parse_fire2_header(fr)["command"], name))
                    out.append(fr)
            return out

        if cmd in (CMD_TRUSTEDLOGIN, CMD_EXPRESSLOGIN):
            # Same forged session; the request fields differ but we ignore them.
            sess.logged_in = True
            sess.login_time = int(time.time())
            log("  == Authentication::%s -> same forged LoginResponse"
                % AUTH_CMDS.get(cmd, cmd))
            out = [reply_to(hdr, encode_tdf(login_response_fields(sess)))]
            out += [fr for _, fr in build_login_notifications(sess,
                                                              sess.login_time)]
            return out

        if cmd == CMD_LOGOUT:
            # An empty REPLY was already correct on the wire (logout has NO
            # request and NO response TDF -- no LogoutRequest/LogoutResponse
            # exists in the client's type index).  But receiving this at all
            # means the client decided it had no credential BEFORE Blaze auth.
            log("  !! FAILURE SIGNAL: Authentication::logout (1/0x46) -- the "
                "client never sent login.  LAYER 1 (Origin/LSX on "
                "127.0.0.1:4216) is still returning offline / no auth code. "
                "Fix lsx_responder.py or lsx_force_online.py FIRST.")
            return [reply_to(hdr, b"")]

        if cmd in (CMD_LISTUSERENTITLEMENTS2, 0x20, 0x30, 0x27):
            # 0x1D listUserEntitlements2 / 0x20 listEntitlements /
            # 0x30 listPersonaEntitlements2 / 0x27 grantEntitlement2 -- all return
            # the ONLINE_ACCESS entitlement so the client sees it however it asks.
            log("  -> Entitlements{NLST:[%s / offer %s / ACTIVE]} (cmd 0x%02x)"
                % (ENTITLEMENT_TAG, CONTENT_ID, cmd))
            return [reply_to(hdr, encode_tdf(entitlements_response_fields()))]

        if cmd == CMD_GETAUTHTOKEN:
            resp = get_auth_token_response_fields(sess)
            log("  -> GetAuthTokenResponse AUTH=%r" % resp["AUTH"][1])
            return [reply_to(hdr, encode_tdf(resp))]

    # ------------------------------------------------------- UserSessions
    if comp == COMP_USERSESSIONS and cmd == CMD_UPDATENETWORKINFO:
        log("  -> updateNetworkInfo: empty REPLY, then re-push "
            "UserSessionExtendedDataUpdate")
        return [
            reply_to(hdr, b""),
            notification(COMP_USERSESSIONS, NOTIFY_USER_EXTENDED_DATA_UPDATE,
                         encode_tdf(user_session_extended_data_update_fields())),
        ]

    # --------------------------------------------------- AssociationLists
    if comp == COMP_ASSOCLISTS and cmd == CMD_GETLISTS:
        log("  -> GetListsResponse{LMAP: []}  (TODO verify FIFA's list names)")
        return [reply_to(hdr, encode_tdf(get_lists_response_fields()))]

    # ---------------------------------------------------------------- TODO
    # Still unimplemented, in the order they are expected to show up:
    #   Util::fetchQosConfig (9/0x15)          -> QosConfigInfo (see qos_config)
    #   Util::localizeStrings (9/4)            -> echo the requested ids
    #   Messaging::fetchMessages (15/2)        -> empty list
    #   Stats / GameReporting / GameManager    -> FUT-mode specific, later
    #   Authentication::getTermsOfServiceContent (1/0xF6) and
    #     getPrivacyPolicyContent (1/0x2F)     -> only reached if NTOS != 0
    # 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 on the wire.
    # ----------------------------------------------------------------------

    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, mtype))
        return [reply_to(hdr, b"")]

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


# ============================================================= 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,))
    sess = Session()
    log("    session key minted: %s" % sess.session_key)
    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"], hdr["msg_type"]),
                   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:
                    os.makedirs(RXDIR, exist_ok=True)
                    fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin"
                                      % (n, hdr["component"], hdr["command"]))
                    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:
                outs = dispatch(hdr, fields, payload, sess)
            except Exception as e:
                log("RX #%d DISPATCH ERROR: %r" % (n, e))
                outs = []

            for k, out in enumerate(outs):
                raw.sendall(out)
                ohdr = parse_fire2_header(out)
                log("TX #%d.%d %s msgType=%s msgNum=%d %dB total (%d payload)"
                    % (n, k,
                       rpc_name(ohdr["component"], ohdr["command"],
                                ohdr["msg_type"]),
                       MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]),
                       ohdr["msg_num"], len(out), ohdr["payload_len"]))
                log("TX #%d.%d HEX:\n%s" % (n, k, 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:
    # ServerInstanceInfo.address is a ServerAddress union -> Heat2 XML union is
    # <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


def make_tls_context():
    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")
    return ctx


_ctx = [None]


def redir_handle(raw: socket.socket, addr) -> None:
    try:
        tls = _ctx[0].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))


# ================================================ Nucleus OAuth stub (plain)
#
# LoginStateMachineImpl (string cluster 0x14389fd50-0x14389fef8) builds
#   "<nucleusConnect>/connect/token", POSTs "grant_type=client_credentials"
# with a "NEXUS_S2S " authorization header, and scrapes '"access_token" : "'
# out of the reply body.  We advertise nucleusConnect = this listener, so the
# client can never reach accounts.ea.com.  Note the exact spacing in the JSON:
# the client searches for the literal '"access_token" : "'.

def nucleus_handle(raw: socket.socket, addr) -> None:
    try:
        raw.settimeout(10)
        req = b""
        while b"\r\n\r\n" not in req and len(req) < 65536:
            c = raw.recv(4096)
            if not c:
                break
            req += c
        head, _, rest = req.partition(b"\r\n\r\n")
        line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else ""
        log("NUCLEUS REQ %s: %s" % (addr, line0))
        if head:
            log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace"))
        if rest:
            log("NUCLEUS BODY: %r" % rest[:512])

        token = "OPENFUT_" + "".join(
            random.choice(string.ascii_letters + string.digits) for _ in range(40))
        # Spacing matters: the client greps for the literal '"access_token" : "'.
        body = ('{\n  "access_token" : "%s",\n  "token_type" : "Bearer",\n'
                '  "expires_in" : 14400,\n  "id_token" : "%s",\n'
                '  "refresh_token" : "%s"\n}\n'
                % (token, token, token)).encode()
        out = (b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
               b"Cache-Control: no-store\r\nContent-Length: "
               + str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
        raw.sendall(out)
        log("NUCLEUS SENT %s %dB access_token=%s" % (addr, len(out), token))
    except Exception as e:
        log("NUCLEUS ERR %s: %s" % (addr, e))
    finally:
        try:
            raw.close()
        except Exception:
            pass


# ================================================================== 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()


# ================================================================== selftest

def _check_roundtrip(label: str, fields) -> bytes:
    payload = encode_tdf(fields)
    back = decode_tdf(payload)
    again = encode_tdf(back)
    assert again == payload, "%s: re-encode differs" % label
    assert list(back.keys()) == sorted(fields.keys(), key=heat2.tag_key), \
        "%s: tag order %r" % (label, list(back.keys()))
    return payload


def _selftest() -> None:
    print("=" * 72)
    print("blaze_responder_v3 selftest")
    print("=" * 72)

    sess = Session()
    sess.session_key = "0540000031e5dde8_OPENFUTselftestkeyOPENFUTselftestkeyOPENFUT0"
    sess.account_locale = 0x656E5553
    now = 1469000000

    # ---- 1. preAuth still round-trips (regression guard vs v2)
    pre = preauth_response_fields()
    p = _check_roundtrip("PreAuthResponse", pre)
    fr = fire2(COMP_UTIL, CMD_PREAUTH, 0, REPLY, p)
    assert fr[13] == 0x20, fr[13]
    assert list(decode_tdf(p).keys()) == [
        "ASRC", "CIDS", "CLID", "CONF", "ESRC", "INST", "MAID", "MINR",
        "NASP", "PILD", "PLAT", "QOSS", "RSRC", "SVER"]
    print("[ok] PreAuthResponse           %4d payload bytes" % len(p))

    # ---- 2. fetchClientConfig per CFID
    for cfid in ["BlazeSDK", "OSDK_CORE", "OSDK_CLIENT", "OSDK_NUCLEUS",
                 "OSDK_WEBOFFER", "OSDK_ABUSE_REPORTING",
                 "OSDK_XMS_ABUSE_REPORTING", "IdentityParams", "TOTALLY_UNKNOWN"]:
        f = fetch_config_response_fields(cfid)
        pb = _check_roundtrip("FetchConfigResponse/" + cfid, f)
        back = decode_tdf(pb)
        assert list(back.keys()) == ["CONF"], back.keys()
        kt, vt, items = back["CONF"][1]
        assert (kt, vt) == (STRING, STRING)
        assert items == client_config_for(cfid), cfid
        print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes"
              % (cfid, len(items), len(pb)))
    assert client_config_for("TOTALLY_UNKNOWN") == [], "unknown CFID must be []"
    assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \
        "unknown CFID must still carry a CONF field (empty map, not empty frame)"

    # ---- 3. LoginResponse
    lr = login_response_fields(sess)
    lp = _check_roundtrip("LoginResponse", lr)
    back = decode_tdf(lp)
    assert list(back.keys()) == ["ANON", "NTOS", "SESS", "SPAM", "UNDR"], back.keys()
    assert "CNTX" not in back and "ERRC" not in back and "SKEY" not in back
    s = back["SESS"][1]
    assert list(s.keys()) == ["1CON", "BUID", "FRST", "KEY", "LLOG", "MAIL",
                              "PDTL", "UID"], list(s.keys())
    assert s["KEY"][1] == sess.session_key
    assert s["BUID"][1] == USER_ID != 0 and s["UID"][1] == USER_ID
    d = s["PDTL"][1]
    assert list(d.keys()) == ["DSNM", "LAST", "PID", "PLAT", "STAS", "XREF"], \
        list(d.keys())
    assert d["PID"][1] == PERSONA_ID == 33068179
    assert d["DSNM"][1] == PERSONA_NAME == "CAGE"
    assert back["ANON"][1] == 0 and back["UNDR"][1] == 0 and back["NTOS"][1] == 0
    lfr = fire2(COMP_AUTH, CMD_LOGIN, 7, REPLY, lp)
    lh = parse_fire2_header(lfr)
    assert (lh["component"], lh["command"], lh["msg_type"]) == (1, 0x0A, REPLY)
    assert lfr[13] == 0x20
    print("[ok] LoginResponse             %4d payload bytes, hdr %s"
          % (len(lp), lfr[:16].hex(" ")))

    # ---- 4. UserAuthenticated notification
    ua = user_session_login_info_fields(sess, now)
    up = _check_roundtrip("UserSessionLoginInfo", ua)
    nb = decode_tdf(up)
    assert list(nb.keys()) == ["1CON", "ALOC", "BUID", "DSNM", "FRST", "KEY",
                               "LAST", "LLOG", "MAIL", "NASP", "PID", "PLAT",
                               "UID", "USTP", "XREF"], list(nb.keys())
    assert nb["KEY"][1] == sess.session_key, "notif KEY must match SESS.KEY"
    assert nb["PID"][1] == PERSONA_ID and nb["DSNM"][1] == PERSONA_NAME
    assert nb["NASP"][1] == PERSONA_NAMESPACE
    nfr = notification(COMP_USERSESSIONS, NOTIFY_USER_AUTHENTICATED, up)
    nh = parse_fire2_header(nfr)
    assert (nh["component"], nh["command"]) == (0x7802, 0x0008), nh
    assert nh["msg_type"] == NOTIFICATION and nh["msg_num"] == 0
    assert nfr[13] == 0x40, nfr[13]
    print("[ok] UserAuthenticated notif   %4d payload bytes, hdr %s"
          % (len(up), nfr[:16].hex(" ")))

    # ---- 5. the other two notifications + post-login RPCs
    for label, f in [
        ("UserSessionExtendedDataUpdate", user_session_extended_data_update_fields()),
        ("UserAdded (UserData)", user_data_fields()),
        ("PostAuthResponse", post_auth_response_fields(sess)),
        ("Entitlements", entitlements_response_fields()),
        ("GetAuthTokenResponse", get_auth_token_response_fields(sess)),
        ("UserSettingsResponse", user_settings_response_fields()),
        ("GetListsResponse", get_lists_response_fields()),
        ("PingResponse", ping_response_fields()),
    ]:
        b = _check_roundtrip(label, f)
        print("[ok] %-30s %4d payload bytes" % (label, len(b)))
    assert list(ping_response_fields().keys()) == ["STIM"], \
        "PingResponse must carry ONLY STIM (TIME is MEC's, not FIFA 17's)"

    # ---- 6. the full login burst, framed
    frames = [fire2(COMP_AUTH, CMD_LOGIN, 7, REPLY, lp)] + \
             [fr for _, fr in build_login_notifications(sess, now)]
    blob = b"".join(frames)
    seen, i = [], 0
    while i < len(blob):
        h = parse_fire2_header(blob[i:i + 16])
        tot = 16 + h["metadata_len"] + h["payload_len"]
        seen.append((h["component"], h["command"], h["msg_type"]))
        decode_tdf(blob[i + 16 + h["metadata_len"]:i + tot])
        i += tot
    assert seen == [(0x0001, 0x000A, REPLY),
                    (0x7802, 0x0008, NOTIFICATION),
                    (0x7802, 0x0001, NOTIFICATION),
                    (0x7802, 0x0002, NOTIFICATION)], seen
    print("[ok] login burst re-framed:    %d frames / %d bytes"
          % (len(frames), len(blob)))

    print()
    print("---- LoginResponse -------------------------------------------------")
    print(heat2.dump(lr))
    print()
    print("---- NOTIFY 0x7802/0x0008 UserAuthenticated ------------------------")
    print(heat2.dump(ua))
    print()
    print("ALL SELFTESTS PASSED")


# ================================================================== main

if __name__ == "__main__":
    if "--selftest" in sys.argv:
        _selftest()
        raise SystemExit(0)

    log("=== RESPONDER v3 START (redir %d / blaze %d%s) ==="
        % (REDIR_PORT, BLAZE_PORT,
           (" / nucleus %d" % NUCLEUS_PORT) if NUCLEUS_STUB_ENABLED else ""))
    log("    persona %d / %r  namespace %r  entitlement %s (offer %s)"
        % (PERSONA_ID, PERSONA_NAME, PERSONA_NAMESPACE, ENTITLEMENT_TAG,
           CONTENT_ID))
    log("    REMINDER: layer 1 first -- start lsx_responder.py BEFORE FIFA 17, "
        "or the client sends logout (1/0x46) instead of login (1/0x0A).")
    _ctx[0] = make_tls_context()
    threading.Thread(target=serve, args=(BLAZE_PORT, blaze_handle, "BLAZE"),
                     daemon=True).start()
    if NUCLEUS_STUB_ENABLED:
        threading.Thread(target=serve,
                         args=(NUCLEUS_PORT, nucleus_handle, "NUCLEUS"),
                         daemon=True).start()
    serve(REDIR_PORT, redir_handle, "REDIR")
