#!/usr/bin/env python3
"""FIFA17 Blaze redirector + SESSION SERVER (v3b) -- offline forged authentication.

WHAT IS NEW vs v3 (this file; applies the adversarial-verify blocker/major fixes)
---------------------------------------------------------------------------------
  * BLOCKER: blazesdk_config() now serves the four client-id keys
    (blazeSdkClientId/blazeServerClientId/blazeSdkClientSecret/identityRedirectUri).
    Without a non-empty blazeSdkClientId, OriginRequestAuthCodeSync rejects the
    request before any LSX GetAuthCode is issued.  (Also mirrored into OSDK_NUCLEUS.)
  * BLOCKER: implemented getAccount (1/0x1E) -> AccountInfo (16 members, reversed
    from descriptor 0x14487c810), getPersona (1/0x5A) -> GetPersonaResponse and
    listPersonas (1/0x64) -> ListPersonasResponse.  Previously these fell through
    to an empty reply and reproduced the account-info popup one layer later.
  * MAJOR: dropped 9 PHANTOM OSDK config keys (not string literals in FIFA17.exe);
    added the real SV_ENABLE_SERVER_VERSIONING/SV_CLIENT_CHANGELIST/SV_SERVER_VERSION
    to OSDK_CORE (read by LoginStateVersionCheck, state 700); pointed the real
    NUCLEUS_CREATE_URL/NUCLEUS_ADDED_URL at the local stub; added the netres CFID.
  * MINOR: UserIdentification.ALOC now echoes sess.account_locale (was a hardcoded
    fallback, could diverge from UserAuthenticated for a non-enUS client).
  * The logout (1/0x46) log no longer shouts "FAILURE SIGNAL": it is the normal
    LoginStateLogout (state 500) between Connect (400) and PCLogin (800).

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
# SOURCED FROM fut_account.ACCOUNT -- the single source of truth shared with
# lsx_responder_v2.py, fut_store.py, fut_seed.py and utas_server.py.
#
# THE CONSTRAINT IS CROSS-LAYER CONSISTENCY, NOT ANY PARTICULAR VALUE.
# Whatever Blaze asserts here (LoginResponse.SESS.PDTL) must equal what LSX
# asserts (GetProfileResponse) and what UTAS serves (userInfo / squad.personaId).
# Reading them all from one module is what guarantees that.
#
# CORRECTION to the comment this replaces: it claimed these came from
# stp-origin_emu.ini [Globals] and that a mismatch raises AUTH_ERR_INVALID_PERSONA
# (26) / AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA / AUTH_ERR_PERSONA_NOT_FOUND. That
# justification is wrong twice over: those are Blaze *server* error codes and WE
# are the server, and a byte-scan found "CAGE" and "33068179" ZERO times in
# FIFA17.exe, CardsDLL, dbdata.dll and _fifa17.exe. 33068179 appears only inside
# stp-origin_emu.dll, as that emu's own ini default. The client does not demand
# these values -- they are what the currently-working stack asserts, which is
# why they stay the defaults in fut_account.py.

from fut_account import ACCOUNT  # noqa: E402

PERSONA_ID = ACCOUNT.persona_id
PERSONA_NAME = ACCOUNT.persona_name
USER_ID = ACCOUNT.user_id       # blazeId / userId; derived, keeps BUID==UID==PID
EXT_ID = ACCOUNT.ext_id         # XREF externalId; derived from persona_id
EMAIL = ACCOUNT.email
PERSONA_NAMESPACE = ACCOUNT.NAMESPACE      # must equal PreAuthResponse.NASP
CLIENT_PLATFORM = ACCOUNT.CLIENT_PLATFORM  # Blaze::ClientPlatformType -> pc
PERSONA_STATUS = ACCOUNT.PERSONA_STATUS    # PersonaStatus::Code -> ACTIVE (live: table 0x14487ad20)
USER_SESSION_TYPE = ACCOUNT.USER_SESSION_TYPE  # Blaze::UserSessionType -> normal user
ACCOUNT_LOCALE_FALLBACK = ACCOUNT.account_locale_int  # 'enUS'; overwritten by the
                                      # client's own PreAuthRequest LANG/LOC.

CONTENT_ID = ACCOUNT.CONTENT_ID          # FIFA 17 EA offer id (retail)
ENTITLEMENT_TAG = ACCOUNT.ENTITLEMENT_TAG  # TRIAL_ONLINE_ACCESS for FIFA17_Trial.exe
ENTITLEMENT_GROUP = ACCOUNT.ENTITLEMENT_GROUP  # was "FIFA17PC" -> matched NEITHER strstr
# needle in EntitlementComponent::onListEntitlements (0x146f27440): FUT keeps an
# entitlement only if GNAM contains "FIFA17PCBoxContent" OR "FIFA16PC" (needles
# @0x144334030), TAG non-empty, STAT==1. "FIFA17PC" survived none -> empty store.

TITLE_ID = ACCOUNT.TITLE_ID
CLIENT_ID = ACCOUNT.CLIENT_ID
PLATFORM = ACCOUNT.PLATFORM
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
COMP_CENSUSDATA = 0x000A          # id built at 0x147e639cd; "CensusDataComponent" @0x143b72368

# CensusData (0x000A) command table, from getCommandName @0x147e64170.
CMD_SUBSCRIBETOCENSUSDATAUPDATES = 0x0005
CENSUSDATA_CMDS = {
    0x01: "subscribeToCensusData", 0x02: "unsubscribeFromCensusData",
    0x03: "getRegionCounts",       0x04: "getLatestCensusData",
    0x05: "subscribeToCensusDataUpdates",
}
NOTIFY_SERVER_CENSUS_DATA = 0x0001   # getNotificationName @0x147e645e0

# ---- 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_GETACCOUNT = 0x001E
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",
    COMP_CENSUSDATA: "CensusData",
    0x000B: "Clubs", 0x081C: "SponsoredEvents", 0x08C9: "OSDKSettings",
}


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)
    elif component == COMP_CENSUSDATA:
        cmd = CENSUSDATA_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 = [
        # --- BLOCKER FIX (verify: identity-and-next-gate) ---------------------
        # OriginRequestAuthCodeSync (real impl 0x1470e67f0) hard-rejects an empty
        # ClientId BEFORE any LSX GetAuthCode is issued:
        #   1470e6831: test r8,r8 / je   (NULL clientId)
        #   1470e683a: cmp BYTE [r8],0 / je   (empty clientId)
        # Its caller 0x147237440 sources the clientId from THIS BlazeSDK config
        # map: 147237484 lea rdx,->"blazeSdkClientId"; call [rax+0x48].  Without
        # a non-empty value here, flipping m_isLoggedIn alone can NEVER produce a
        # GetAuthCode.  All four key names verified present as string literals in
        # FIFA17.exe (blazeSdkClientId@0x1439726a8, blazeServerClientId@0x143972690,
        # blazeSdkClientSecret@0x1439726c0, identityRedirectUri@0x1439726d8).
        # Non-empty is the only hard requirement; the actual value the client
        # presents will be echoed back to /tmp/openfut_lsx_clientid.txt.
        ("blazeSdkClientId",     "FIFA17PC"),
        ("blazeServerClientId",  "FIFA17PC-SERVER"),
        ("blazeSdkClientSecret", "openfut-secret"),
        ("identityRedirectUri",  "http://127.0.0.1/login_successful.html"),
        # ---------------------------------------------------------------------
        # CONF value TYPES matter. Keys read via ConnMgr vt+0x50 (0x146e1bb80) go
        # through atoi -> plain integers are fine. Keys read via vt+0x58 (0x146e1bda0)
        # go through TimeValue::parse (0x1479b2d50), which REQUIRES a unit suffix
        # (y/d/h/m/ms/s) and silently yields 0 for a bare integer (0x146e1bdca discards
        # the parser's bool). Sending "30000000" landed defaultRequestTimeout=0 and
        # connIdleTimeout=0 -> every Blaze conn died at first idle -> "Unable to connect
        # to the EA servers" on the go-online preAuth. Durations MUST be unit-suffixed.
        ("associationListSkipInitialSet", "1"),        # 0x143b6eb88 AssocListAPI (atoi)
        ("autoReconnectEnabled",          "1"),        # 0x1438a0a68 ConnMgr (atoi)
        ("connIdleTimeout",               "90s"),      # 0x1438a0a58 TimeValue -> 90000ms
        ("defaultRequestTimeout",         "30s"),      # 0x1438a0a40 TimeValue -> 30000ms
        ("enableQosBandwidthTest",        "false"),     # 0x1438a0a08 clears bit1
        ("enableQosFirewallTest",         "false"),     # 0x1438a09f0 clears bit0
        ("maxReconnectAttempts",          "5"),         # 0x1438a0a80 ConnMgr (atoi)
        ("pingPeriod",                    "20s"),      # 0x1438a0a30 TimeValue -> 20000ms
        ("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"),            # verified string literal in image
    ("OSDK_PRESENCE_POLL", "60"),            # verified
    ("OSDK_ANTIGRIEFING_MAX_COUNT", "0"),    # verified
    ("OSDK_ARENA_ENABLED", "0"),             # verified
    # --- MAJOR FIX (verify: identity-and-next-gate) ----------------------
    # LoginStateVersionCheck (OSDK state 700, runs right after the normal
    # Logout 500) reads these three SV_* keys; a version mismatch trips the
    # "Client/server version mismatch!" string @0x14395d1d0.  All three are
    # REAL string literals in FIFA17.exe (SV_ENABLE_SERVER_VERSIONING
    # @0x14395d148 xref 0x14717f8fa, SV_CLIENT_CHANGELIST @0x14395d168,
    # SV_SERVER_VERSION @0x14395d180).  Disable server versioning so the
    # check cannot fail.
    ("SV_ENABLE_SERVER_VERSIONING", "0"),
    ("SV_CLIENT_CHANGELIST", "0"),
    ("SV_SERVER_VERSION", "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"),
]
# --- MAJOR FIX (verify: identity-and-next-gate) --------------------------
# The four OSDK_NUCLEUS_* keys below were PHANTOM -- none of them exist as
# string literals in FIFA17.exe, so serving them was cosmetic (the log said
# "4 key(s)" but the client ignored every one).  Dropped.  The only real
# NUCLEUS_*_URL literals in the image are NUCLEUS_CREATE_URL (0x14395eb60)
# and NUCLEUS_ADDED_URL (0x14395ebb0); point them at the local stub.  The four
# blazeSDK client-id keys are mirrored here as zero-cost insurance in case the
# client sources ClientId from this CFID rather than BlazeSDK (which CFID is
# authoritative is an inference; the LSX log will report which value wins).
OSDK_NUCLEUS = [
    ("NUCLEUS_CREATE_URL", NUCLEUS_BASE),   # verified literal @0x14395eb60
    ("NUCLEUS_ADDED_URL",  NUCLEUS_BASE),   # verified literal @0x14395ebb0
    ("blazeSdkClientId",     "FIFA17PC"),
    ("blazeServerClientId",  "FIFA17PC-SERVER"),
    ("blazeSdkClientSecret", "openfut-secret"),
    ("identityRedirectUri",  "http://127.0.0.1/login_successful.html"),
]
# OSDK_WEBOFFER_ENABLED / OSDK_WEBOFFER_URL were BOTH phantom (absent in the
# image).  Nothing real to serve here -> empty map (still a present CONF).
OSDK_WEBOFFER = []
# OSDK_ABUSE_REPORTING_ENABLED was phantom; OSDK_ABUSE_NUM_TYPES is real.
OSDK_ABUSE_REPORTING = [
    ("OSDK_ABUSE_NUM_TYPES", "0"),          # verified literal in image
]
# OSDK_TICKER_ENABLED was phantom -> empty map.
OSDK_TICKER = []
# --- FUT LOADING GATE (COMPONENT: FUT_LOADING_PLAN.md) --------------------
# FIFA fetches fetchClientConfig(CFID='OSDK_ROSTER') at FUT entry (RX #22).
# ROSTERUPDATE_URL is the ONLY remaining source for the FUT roster-XML URL --
# the two ini keys (FUT/ROSTERUPDATE_URL @0x143af1810, ROSTERUPDATE_URL
# @0x143af1828) are absent on disk, so the resolver 0x147a77410 falls through to
# cfg->getString("ROSTERUPDATE_URL","",...) @0x147a77562; an EMPTY result makes
# 0x147a77577 return WITHOUT issuing the HTTP request -> flow CheckFUTRosterUpdateXML
# never gets advance/back -> the silent FUT loading-screen hang. The store is the
# MERGED '_all' section (getSection @0x14719e050), so any fetched CFID works; this
# branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url.
# Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject
# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK.
ROSTER_HOST = "127.0.0.1:8081"
OSDK_ROSTER = [
    ("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
    ("ROSTER_URL",       "https://%s/fifa17/roster/" % ROSTER_HOST),  # @0x143973aa0
    ("ROSTER_VER",       "0"),                                        # @0x143973ab0
    ("ROSTER_CSUM",      ""),                                         # @0x143973ad0
]
# "netres" is itself a CFID (config section) -- it sits immediately before
# OSDK_CORE in the CFID-name table @0x143962be0, so the client may request
# fetchClientConfig(CFID="netres").  We have no verified keys for it; serve a
# present-but-empty map (unknown CFID would already do this, but make it
# explicit so the log shows it was handled, not defaulted).
OSDK_NETRES = []
# 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"),
]

# --- POW / EASFC redirect (the "EA FC servers unreachable" gate) --------------
# The reconnect banner comes from the EASFC layer in powdll_Win64_retail.dll, a
# THIRD HTTP API (default host pas.gt.easfc.ea.com:8094) that nothing has ever
# served. powdll FUN_18005a460 reads its base URLs out of THIS store -- the merged
# '_all' section, same path that already delivers ROSTERUPDATE_URL -- via
# cfg->vtbl[0x30] = getString(key, default, &out), and picks an http:// vs https://
# prefix (PTR_s_http____18010aee0 / PTR_s_https____18010aee8). So pointing POW at
# our own server needs NO /etc/hosts entry and NO root: just answer these keys.
# POW_IS_ON (read by the same function, getBool, default TRUE) is the kill switch.
#
# DEFAULT IS OFF. Serving these keys sends the client somewhere it has never been
# and pow_server.py cannot yet answer POW properly (the response schemas are not
# reversed -- only the 58 request paths are). Enable for a CAPTURE run with
# FUT_POW=1, which is what turns the schemas into reversing targets:
#     FUT_POW=1 ./openfut-fut.sh restart
# and read /tmp/pow_server.log. FUT_POW=off is the instant fallback.
POW_HOST = os.environ.get("POW_HOST", "127.0.0.1:8094")
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
_POW_ON = os.environ.get("FUT_POW", "").lower() in ("1", "true", "on", "yes")
OSDK_POW = [
    ("FIFA_POW_URL",                "http://%s/" % POW_HOST),
    ("FIFA_POW_CONTENT_SERVER_URL", "http://%s/" % POW_CONTENT_HOST),
    ("FIFA_POW_NUCLEUS_PROXY_URL",  "http://%s/" % POW_HOST),
    ("POW_IS_ON",                   "1"),
] if _POW_ON else []

CLIENT_CONFIGS = {
    "BlazeSDK": None,                      # built dynamically, see below
    "netres": OSDK_NETRES,                 # CFID (verified @0x143962be0)
    "OSDK_POW": OSDK_POW,                  # EASFC/POW redirect (opt-in, FUT_POW=1)
    "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,
    "OSDK_ROSTER": OSDK_ROSTER,            # FUT roster-XML URL (loading gate)
    "IdentityParams": IDENTITY_PARAMS,
}

# --- FUT / UTAS (RS4) BASE URL (FUT_UTAS_PLAN.md) --------------------------
# CardsDLL RS4::ServerSettings::resolve @0x180124270 sets each of the 125 endpoint
# descriptors' baseUrl in passes, last non-empty wins: (1) hardcoded default
# "http://easw.easports.com:8099/" (DEAD host -> "error connecting"); (2) cfg
# ["FUT_RS4_APIURL_<MODULE_NAME>"]; (3) cfg["FUT_RS4_URL_<CALL_TAG>"]. Value used
# VERBATIM when it has "://" + a trailing '/' forced. Belt-and-braces alongside the
# /etc/hosts easw.easports.com->127.0.0.1 redirect. MUST be exactly "http://127.0.0.1:8099/"
# (scheme + trailing slash mandatory on the auth path). Do NOT serve FUT_TARGET_PORT
# (bug @0x1801808e8 reads FUT_MAX_HOPS instead) nor FUT/MODULE_BASEURL_* (dead code).
UTAS_BASE = "http://127.0.0.1:8099/"
FUT_RS4_MODULES = [
    "AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD",
    "DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER",
    "DELETEUSER", "ITEMS", "ITEMS_BY_RES", "DELETEITEMS", "MATCH", "SBC",
    "TOURNAMENT", "TOURNAMENTUSER", "TOURNAMENTQUIT", "SEASON", "SEASONUSER",
    "SEASONUSER_ALTER", "SEASONRESET", "FRIENDLYSEASON", "PURCHASED", "STORE",
    "WATCHLIST", "DELETEWATCHLIST", "TRADEPILE", "TRADE", "DELETETRADE",
    "MARKETDATA", "CLIENTDATA", "AUTH", "DELETE_AUTH", "PHISHING", "CAPTCHA",
    "TFA", "SQUADMODE", "DRAFT", "CHAMPIONS", "V2STORE", "LIVEMESSAGE",
    "ADMIN", "DEBUG", "MAINTENANCE",
]
# EVERY RS4 call name in the client's table at 0x18021e250-0x18021fa60 (163 of them),
# not just the 17 boot ones. THE CLIENT RESOLVES A PER-CALL URL KEY FIRST:
# FUT_RS4_URL_<CALL> takes precedence over the per-module FUT_RS4_APIURL_<MODULE>.
# Serving only the boot subset left 146 calls unresolved, so anything past boot --
# VIEWCARDS, ASSIGNCARD, CLUBSTATS, GETCLUBUSERS, MOVECARD's follow-ups -- fell back
# to a default (real EA) host, failed at the transport, and the client raised
# "We are sorry but there has been an error connecting to FIFA 17 Ultimate Team."
# That is the pack "Send to Club" kick: the move itself succeeded, the FOLLOW-UP
# request never reached us. Live-diagnosed 2026-08-04 from the on-screen error text
# plus GET /club never once appearing in the log.
FUT_RS4_CALLS = [
    "AUCTIONHOUSE", "CLUB_USER", "DREAM", "SQUAD", "DELETE_SQUAD", "LBOPTIONS",
    "LBDEFAULT", "PAFPRACTICE", "USER", "DELETEUSER", "ITEMS", "ITEMS_BY_RES",
    "DELETEITEMS", "TOURNAMENT", "TOURNAMENTUSER", "TOURNAMENTQUIT", "SEASONUSER",
    "SEASONUSER_ALTER", "SEASONRESET", "FRIENDLYSEASON", "PURCHASED", "STORE", "WATCHLIST",
    "DELETEWATCHLIST", "TRADEPILE", "TRADE", "DELETETRADE", "MARKETDATA", "CLIENTDATA",
    "AUTH", "DELETE_AUTH", "PHISHING", "CAPTCHA", "SQUADMODE", "DRAFT", "CHAMPIONS",
    "V2STORE", "LIVEMESSAGE", "ADMIN", "DEBUG", "MAINTENANCE", "ISSEARCH", "ISOFFERTRADE",
    "ISSTART", "RELISTALL", "GETCLUBUSERS", "GETCLUBINFO", "CLUBSEARCH", "CLUBSTATS",
    "STAFFSTATS", "CONSUMABLESSEARCH", "DREAMSQUADSEARCH", "GETSQUADINFO",
    "UPDATESQUADNAME", "RETRIEVESQUAD", "LOADACTIVESQUAD", "DELETESQUAD", "SAVESQUAD",
    "SQUADLIST", "GETLBOPTIONS", "GETLBENTRIES", "GETLBENTRYDATA", "GETSETTINGS",
    "AUTHENTICATION", "LOGIN", "LOGOUT", "RESETUSER", "USERRELIABILITYINFO", "CREATEUSER",
    "GETUSERINFO", "SETUSERINFO", "GETHISTORICAL", "SETTUTDATA", "GETTOWDATA",
    "SETTOWDATA", "SETFAVDATA", "GETUSERCREDITS", "GETUSERDATA", "VIEWCARDS", "ASSIGNCARD",
    "APPLYCARD", "APPLYCARDBYRES", "ACTIVATECARD", "CONSUMECARD", "DISCARDCARD",
    "DISCARDCARDBYRES", "DISCARDACARD", "MOVECARD", "MOVECARDBYRES", "SWAPCARD",
    "CREATEMATCH", "MATCHREADY", "DESTROYMATCH", "PLAYGAME", "RESETMATCH", "KEEPALIVE",
    "LOADCATEGORYDETAILS", "LOADSETCHALLENGES", "STARTCHALLENGE", "LOADSQUADCHALLENGE",
    "SAVESQUADCHALLENGE", "SUBMITCHALLENGE", "TAGSETS", "SETSBCDATA", "TOURNAMENTLIST",
    "TOURNAMENTTEAMS", "GETACTIVETOURNAMENTS", "UPDATETOURNAMENT", "TOURNAMENTLOADDATA",
    "SEASONLIST", "SEASONUPDATE", "SEASONLOADDATA", "SEASONQUIT", "SEASONHISTORY",
    "PURCHASEDITEMS", "PURCHASEPACK", "PURCHASEITEMS", "STOREPACKTYPES",
    "STOREPACKQUANTITIES", "ISREMOVEWATCH", "ISWATCHTRADE", "ISWATCHLIST", "ISVIEWTRADE",
    "GETTRADEPILE", "GETAUCTIONCOUNT", "ISREMOVETRADE", "GETSUGGESTEDPRICING",
    "CHANGECLUBNAME", "GETPHISHINGQUESTION", "SETPHISHINGANSWER", "VALIDATEPHISHINGANSWER",
    "GETTRUSTEDCONSOLELIST", "GETCAPTCHA", "EXCHANGECAPTCHA", "VALIDATECAPTCHA",
    "VALIDATETFA", "UPDATEUSERACTION", "GETUSERACTION", "GETMANAGERQUESTREWARD",
    "SETMANAGERQUESTCOMPLETE", "GETHUBDATA", "GETUSERMASSINFO", "FIFAPOINTSTRANSFER",
    "FRIENDLYSEASONUPDATE", "FRIENDLYSEASONLOAD", "FRIENDLYSEASONHISTORY",
    "GETAVAILABLELOANPLAYERS", "SIGNLOANPLAYER", "GETCHEMISTRYATTR",
    "GETDRAFTCURRENTSTATE", "GETDRAFTCHOICES", "GETDRAFTSTATS", "GETDRAFTAWARD",
    "PURCHASEDRAFTMODE", "PICKDRAFTCHOICE", "PICKDRAFTAUTOCHOICE", "GETSTORYMODEREWARD",
    "CHAMPIONSHUB", "CHAMPIONSTOPX", "CHAMPIONSRANK", "CHAMPIONSFRIENDS",
    "GRANTPRIZECHAMPIONUSER", "REGISTERCHAMPIONSLEAGUE"
]
FUT_RS4_CONFIG = (
    [("FUT_RS4_APIURL_%s" % m, UTAS_BASE) for m in FUT_RS4_MODULES]
    + [("FUT_RS4_URL_%s" % c, UTAS_BASE) for c in FUT_RS4_CALLS]
    + [("FUT_RS4_BASE_URL", UTAS_BASE)]
    # STORE gate: FIFA shows "store not available" unless these config flags are
    # true. The store-screen entitlement checks (CardsDLL 0x18001749d vtable+0x138 /
    # 0x1800175a2 vtable+0x280) read the IS_*/*_PURCHASE_ENABLED booleans -- SEPARATE
    # from storeEnabled. Full confirmed list from ENDPOINT_MAP store § (grepped in
    # cardsdll.strings). (Also gated by GetSystemMetrics > 1024x768, client-side.)
    + [(k, "1") for k in (
        "storeEnabled", "cardPackStoreEnabled", "pointsPackStoreEnabled",
        "cardPackStoreEnabled_JP", "coinEnabled", "coinEnabled_JP",
        "IS_STORE_ENABLED", "IS_COIN_PURCHASABLE", "IS_FIFAPOINT_AVAILABLE",
        "IS_FIFAPOINT_PURCHASABLE", "IS_EASTORE_SERVICE_READY",
        "COINS_PURCHASE_ENABLED", "POINTS_PURCHASE_ENABLED", "MONEY_PURCHASE_ENABLED",
    )]
    # NOTE: do NOT advertise itemDbVersion/checkServerDbVersion here or in any
    # response -- proven inert (wf_96b6c0c5): they are JSON field names that route
    # to the value-SKIP handler 0x180135ff0, never compared. See docs/CARD_SYSTEM.md.
    # EXPERIMENTAL SBC (env-gated, default OFF to keep the baseline clean): the SBC
    # set-list deser (0x180154990) checks FUT/SBC_USE_STUBS -- FIFA may render
    # built-in stub SBCs with no server content. enableSquadBuildingSetsFeature gates
    # the SBC menu. Set FUT_SBC=1 to try it live (ENDPOINT_MAP SBC leads).
    + ([("enableSquadBuildingSetsFeature", "1"), ("FUT/SBC_USE_STUBS", "1")]
       if os.environ.get("FUT_SBC") else [])
)


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).
    FUT_RS4_* base-URL keys ride on EVERY CFID (merged '_all' store; which section
    CardsDLL reads is unproven, so serve them everywhere)."""
    # OSDK_POW rides on EVERY CFID for the same reason FUT_RS4_* does: powdll's
    # FUN_18005a460 reads FIFA_POW_URL out of the merged '_all' store, and which
    # section it happens to read is unproven. Empty list when FUT_POW is unset, so
    # this is a no-op by default. (Putting the keys ONLY under a hypothetical
    # "OSDK_POW" CFID would be dead code -- nothing is known to request that name.)
    if cfid == "BlazeSDK":
        return sorted(blazesdk_config() + FUT_RS4_CONFIG + OSDK_POW)
    return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG + OSDK_POW)


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 subscribe_census_data_updates_response_fields() -> "OrderedDict":
    """Blaze::CensusData::SubscribeToCensusDataUpdatesResponse (classinfo 0x144c11dc0,
    tdfId 0x746128a3, 3 members, ALL TDF type 0x0e TimeValue -> Heat2 INT varint of
    MICROSECONDS). The client reply cb 0x147e57050 computes
        delay_ms = (CNP + NTMT) / 1000
    and arms a FunctorJob (0x147e57380) that re-sends this RPC with RSUB=1. All three
    default to 0, so our old EMPTY reply gave delay=0 -> the scheduler's zero-delay
    branch (0x146dbae7c) put it on the READY list -> one re-subscribe per idle tick =
    the observed ~30/s storm. 30s+90s -> the client re-subscribes every 120s instead.
    Tags ascend (8eec00 < bb4b74 < cb4b74) = correct Heat2 order."""
    return OrderedDict([
        ("CNP",  (INT,  30 * 1000000)),   # censusNotificationPeriod
        ("NTMT", (INT,  90 * 1000000)),   # notificationTimeout
        ("RTMT", (INT, 300 * 1000000)),   # resubscribeTimeout (error path only)
    ])


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/PID: no particular value is demanded by the client (see the
        # identity block at the top). What IS required is that they equal LSX
        # GetProfileResponse Persona/PersonaId and UTAS userInfo.personaId --
        # hence fut_account.ACCOUNT.
        ("DSNM", (STRING, PERSONA_NAME)),   # displayName        == LSX Persona
        ("LAST", (INT, now)),               # lastAuthenticated  uint32
        ("PID",  (INT, PERSONA_ID)),        # personaId  int64   == LSX PersonaId
        ("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
    ])


# =========================== Authentication::getAccount (1/0x1E) -- FORGED
#
# BLOCKER FIX (verify: identity-and-next-gate).  getAccount was NOT implemented
# (it fell through to REPLY_EMPTY_TO_UNKNOWN -> an AccountInfo with UID=0/CO=""),
# which reproduces the "Unable to retrieve account information" popup one layer
# later.  Blaze::Authentication::AccountInfo @0x14487c810 has EXACTLY 16 members,
# reversed byte-for-byte from FIFA17.exe's own reflection metadata:
#   * class name + member count (16) read from the class descriptor header;
#   * member tags read from the tag table @0x1448775a0 (stride 0x30, tag
#     immediate at entry+0x20 in the 0xTTTTTT00 packing);
#   * per-member WIRE TYPE read from each member's subtype-descriptor pointer:
#       subtype 0x144867628 == string  -> ASRC CO DOB DTCR LATH LN MAIL PML (8)
#       int / enum subtypes            -> AMU GOPT RC STAS STAT TPOT UDU UID (8)
# Enum values resolved from the in-image enum tables:
#       STAS = AccountStatus::Code ACTIVE   = 1  (table 0x14487a6c0)
#       STAT = EmailStatus::Code   VERIFIED = 2  (table 0x14487aa00)
#       RC   = StatusReason::Code  none      = 0
# Ascending packed-tag order (what heat2 emits): AMU ASRC CO DOB DTCR GOPT LATH
# LN MAIL PML RC STAS STAT TPOT UDU UID.  IDENTITY: MAIL/UID/ASRC MUST byte-match
# LoginResponse.SESS.MAIL/UID and PreAuthResponse.NASP.  Empty request.

ACCOUNT_LOCALE_STR = ACCOUNT.locale   # AccountInfo.LN (language); "en_US" default


def account_info_fields(sess: "Session", now: int) -> "OrderedDict":
    day = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))
    return OrderedDict([
        ("AMU",  (INT, 0)),                          # (int)
        ("ASRC", (STRING, PERSONA_NAMESPACE)),       # == PreAuthResponse.NASP
        ("CO",   (STRING, "US")),                    # country
        ("DOB",  (STRING, "1990-01-01T00:00:00Z")),  # dateOfBirth (ISO-8601)
        ("DTCR", (STRING, "2016-09-01T00:00:00Z")),  # dateCreated (ISO-8601)
        ("GOPT", (INT, 0)),                          # globalOptin
        ("LATH", (STRING, day)),                     # lastAuth (ISO-8601)
        ("LN",   (STRING, ACCOUNT_LOCALE_STR)),      # language
        ("MAIL", (STRING, EMAIL)),                   # == LoginResponse.SESS.MAIL
        ("PML",  (STRING, "")),                      # parentalEmail
        ("RC",   (INT, 0)),                          # StatusReason::Code
        ("STAS", (INT, 1)),                          # AccountStatus ACTIVE = 1
        ("STAT", (INT, 2)),                          # EmailStatus VERIFIED = 2
        ("TPOT", (INT, 0)),                          # thirdPartyOptin
        ("UDU",  (INT, 0)),                          # underageUser
        ("UID",  (INT, USER_ID)),                    # == LoginResponse.SESS.UID
    ])


# ============= Authentication::getPersona (1/0x5A) / listPersonas (1/0x64)
#
# Blaze::Authentication::PersonaInfo @0x14487c7c0 -- 7 members (tags from the
# reflection table @0x144877430): DSNM DTCR LADT NSNM PID STAS STRC.  DSNM/DTCR/
# NSNM share the string subtype 0x144867628; PID is int; STAS is
# PersonaStatus::Code (ACTIVE = 2, table 0x14487ad20); STRC is StatusReason::Code.
# LADT (lastAuthenticated) has a distinct subtype -- emitted as an INT timestamp
# (best-guess; only reached via getPersona/listPersonas, off the critical
# getAccount path, so a wrong type here cannot re-raise the account-info popup).

def persona_info_fields(now: int) -> "OrderedDict":
    return OrderedDict([
        ("DSNM", (STRING, PERSONA_NAME)),            # displayName "CAGE"
        ("DTCR", (STRING, "2016-09-01T00:00:00Z")),  # dateCreated (string)
        ("LADT", (INT, now)),                        # lastAuthenticated (best-guess int)
        ("NSNM", (STRING, PERSONA_NAMESPACE)),       # nameSpaceName "cem_ea_id"
        ("PID",  (INT, PERSONA_ID)),                 # personaId 33068179
        ("STAS", (INT, 2)),                          # PersonaStatus ACTIVE = 2
        ("STRC", (INT, 0)),                          # statusReasonCode
    ])


def get_persona_response_fields(now: int) -> "OrderedDict":
    """Blaze::Authentication::GetPersonaResponse @0x14487d1c0 -- 2 members:
    PINF (PersonaInfo) + UID (int)."""
    return OrderedDict([
        ("PINF", (STRUCT, persona_info_fields(now))),
        ("UID",  (INT, USER_ID)),
    ])


def list_personas_response_fields(now: int) -> "OrderedDict":
    """Blaze::Authentication::ListPersonasResponse @0x14487d210 -- 1 member:
    PINF (list<PersonaInfo>)."""
    return OrderedDict([
        ("PINF", (LIST, (STRUCT, [persona_info_fields(now)]))),
    ])


# ============================ 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(sess: "Session") -> "OrderedDict":
    """Blaze::UserIdentification @0x14486ebc0 -- 9 members.
    ALOC echoes the session's captured locale so a client that ever sends a
    non-enUS PreAuth LANG cannot see two different locales for one session
    (UserAuthenticated already uses sess.account_locale)."""
    return OrderedDict([
        ("AID",  (INT, USER_ID)),                  # accountId
        ("ALOC", (INT, sess.account_locale)),      # accountLocale (echo client's)
        ("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(sess: "Session") -> "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(sess))),
    ])


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(sess)))),
    ]


# ================================================ 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, group=None, tag=None, eid=1) -> "OrderedDict":
    """Blaze::Authentication::Entitlement @0x14487d490 -- 16 members.
    FUT's client filter onListEntitlements (0x146f27440) keeps a record only if
    GNAM contains "FIFA17PCBoxContent" OR "FIFA16PC", TAG non-empty, STAT==1.
    PRID/GNAM/TAG must contain no '|' and no '/' (re-serialised as PRID|GNAM|TAG|UCNT/)."""
    group = group or ENTITLEMENT_GROUP
    tag = tag or ENTITLEMENT_TAG
    return OrderedDict([
        ("DEVI", (STRING, "")),                    # deviceUri
        ("GDAY", (STRING, "2016-09-01T00:00:00Z")),  # grantDate
        ("GNAM", (STRING, group)),                 # groupName (MUST contain the needle)
        ("ID",   (INT, eid)),                      # 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 (MUST be 1)
        ("STRC", (INT, 0)),                        # statusReasonCode
        ("TAG",  (STRING, tag)),                   # entitlementTag (MUST be non-empty)
        ("TDAY", (STRING, "")),                    # terminationDate (never)
        ("TYPE", (INT, 1)),                        # EntitlementType
        ("UCNT", (INT, 0)),                        # useCount
        ("VER",  (INT, 1)),                        # version
    ])


def entitlements_response_fields() -> "OrderedDict":
    """Blaze::Authentication::Entitlements @0x14487d4e0 -- single member NLST.
    Emit BOTH groups FUT accepts so byte[entMgr+0x88] "loaded" flag flips."""
    now = int(time.time())
    return OrderedDict([
        ("NLST", (LIST, (STRUCT, [
            entitlement_fields(now, "FIFA17PCBoxContent", "ONLINE_ACCESS", 1),
            entitlement_fields(now, "FIFA16PC",           "ONLINE_ACCESS", 2),
        ]))),
    ])


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.
            # NOTE: logout here is NORMAL, not a failure.  The OSDK state table
            # (0x147159154-0x147159680) orders Connect 400 -> Logout 500 ->
            # VersionCheck 700 -> PCLogin 800, so LoginStateLogout is the routine
            # "drop any stale session" step BEFORE PCLogin/GetAuthCode.  Only
            # treat it as a problem if we NEVER subsequently see login (1/0x0A).
            if sess.logged_in:
                log("  -- Authentication::logout (1/0x46) after a login "
                    "(session teardown; expected)")
            else:
                log("  -- Authentication::logout (1/0x46), no login yet -- "
                    "normal LoginStateLogout (state 500). If GetAuthCode / login "
                    "(1/0x0A) never follow, layer 1 (LSX :4216) or blazeSdkClientId "
                    "is the blocker; see lsx_responder_v2.py + blazesdk_config().")
            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))]

        if cmd == CMD_GETACCOUNT:
            # BLOCKER FIX: the RPC behind the "Unable to retrieve account
            # information" popup.  Forged AccountInfo, identity-consistent.
            resp = account_info_fields(sess, int(time.time()))
            payload = encode_tdf(resp)
            log("  == Authentication::getAccount (1/0x1E) -> AccountInfo "
                "UID=%d MAIL=%r ASRC=%r (%d bytes)"
                % (USER_ID, EMAIL, PERSONA_NAMESPACE, len(payload)))
            return [reply_to(hdr, payload)]

        if cmd == CMD_GETPERSONA:
            resp = get_persona_response_fields(int(time.time()))
            log("  -> GetPersonaResponse (1/0x5A) persona %d/%r"
                % (PERSONA_ID, PERSONA_NAME))
            return [reply_to(hdr, encode_tdf(resp))]

        if cmd == CMD_LISTPERSONAS:
            resp = list_personas_response_fields(int(time.time()))
            log("  -> ListPersonasResponse (1/0x64) [%s]" % PERSONA_NAME)
            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.
    # ----------------------------------------------------------------------

    # ----------------------------------------------------------- CensusData
    # subscribeToCensusDataUpdates (0x000A/0x0005): the client re-arms a resend
    # timer with delay = (CNP+NTMT)/1000; an EMPTY reply -> delay 0 -> ~30/s storm
    # that hangs the FUT loading screen. Return non-zero TimeValues so it settles
    # to one re-subscribe per 120s. (COMPONENT_000A_PLAN.md, wire-verified.)
    if comp == COMP_CENSUSDATA and cmd == CMD_SUBSCRIBETOCENSUSDATAUPDATES:
        rsub = find_nested_int(fields, "RSUB") if fields is not None else None
        log("  -> subscribeToCensusDataUpdates(RSUB=%s): Response{CNP=30s,NTMT=90s,"
            "RTMT=300s} -> client re-subscribes in (CNP+NTMT)/1000 = 120000 ms" % rsub)
        return [reply_to(hdr, encode_tdf(
            subscribe_census_data_updates_response_fields()))]

    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)

    # ---- 0. identity is sourced from the shared account, not local literals
    assert PERSONA_ID == ACCOUNT.persona_id and PERSONA_NAME == ACCOUNT.persona_name
    assert USER_ID == EXT_ID == ACCOUNT.persona_id, "BUID/UID/XREF are derived"
    assert PERSONA_NAMESPACE == ACCOUNT.NAMESPACE == "cem_ea_id"
    assert EMAIL == ACCOUNT.email and ACCOUNT_LOCALE_STR == ACCOUNT.locale
    print("[ok] identity from fut_account  %r" % (ACCOUNT,))

    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())
    # Identity is now configurable (fut_account.ACCOUNT), so assert CONSISTENCY
    # with the shared account rather than the old hardcoded 33068179/"CAGE".
    assert d["PID"][1] == PERSONA_ID == ACCOUNT.persona_id
    assert d["DSNM"][1] == PERSONA_NAME == ACCOUNT.persona_name
    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(sess)),
        ("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)"

    # ---- 5b. BLOCKER-FIX responses: AccountInfo / getPersona / listPersonas
    ai = account_info_fields(sess, now)
    aip = _check_roundtrip("AccountInfo", ai)
    aib = decode_tdf(aip)
    assert list(aib.keys()) == ["AMU", "ASRC", "CO", "DOB", "DTCR", "GOPT",
                                "LATH", "LN", "MAIL", "PML", "RC", "STAS",
                                "STAT", "TPOT", "UDU", "UID"], list(aib.keys())
    assert len(aib) == 16
    # 8 strings / 8 ints exactly, as reversed from the descriptor
    strs = [k for k, (t, v) in aib.items() if t == STRING]
    ints = [k for k, (t, v) in aib.items() if t == INT]
    assert strs == ["ASRC", "CO", "DOB", "DTCR", "LATH", "LN", "MAIL", "PML"], strs
    assert ints == ["AMU", "GOPT", "RC", "STAS", "STAT", "TPOT", "UDU", "UID"], ints
    # identity consistency vs LoginResponse.SESS
    assert aib["UID"][1] == USER_ID and aib["MAIL"][1] == EMAIL
    assert aib["ASRC"][1] == PERSONA_NAMESPACE == "cem_ea_id"
    assert aib["STAS"][1] == 1 and aib["STAT"][1] == 2   # ACTIVE / VERIFIED
    afr = fire2(COMP_AUTH, CMD_GETACCOUNT, 9, REPLY, aip)
    assert parse_fire2_header(afr)["command"] == 0x1E
    print("[ok] AccountInfo (getAccount 1/0x1E)  %4d payload bytes" % len(aip))

    gp = get_persona_response_fields(now)
    gpp = _check_roundtrip("GetPersonaResponse", gp)
    gpb = decode_tdf(gpp)
    assert list(gpb.keys()) == ["PINF", "UID"], list(gpb.keys())
    pinf = gpb["PINF"][1]
    assert list(pinf.keys()) == ["DSNM", "DTCR", "LADT", "NSNM", "PID",
                                 "STAS", "STRC"], list(pinf.keys())
    assert pinf["PID"][1] == PERSONA_ID and pinf["DSNM"][1] == PERSONA_NAME
    assert gpb["UID"][1] == USER_ID
    print("[ok] GetPersonaResponse (1/0x5A)      %4d payload bytes" % len(gpp))

    lpz = list_personas_response_fields(now)
    lpp = _check_roundtrip("ListPersonasResponse", lpz)
    lpb = decode_tdf(lpp)
    assert list(lpb.keys()) == ["PINF"], list(lpb.keys())
    et, items = lpb["PINF"][1]
    assert et == STRUCT and len(items) == 1
    assert items[0]["PID"][1] == PERSONA_ID
    print("[ok] ListPersonasResponse (1/0x64)    %4d payload bytes" % len(lpp))

    # ---- 5c. dispatch actually returns them (no fall-through to empty reply)
    class _H(dict):
        pass
    for command, want_kind in ((CMD_GETACCOUNT, "AccountInfo"),
                               (CMD_GETPERSONA, "GetPersona"),
                               (CMD_LISTPERSONAS, "ListPersonas")):
        h = {"component": COMP_AUTH, "command": command, "msg_type": MESSAGE,
             "msg_num": 1, "user_index": 0, "options": 0}
        outs = dispatch(h, OrderedDict(), b"", sess)
        assert len(outs) == 1, (command, outs)
        oh = parse_fire2_header(outs[0])
        assert oh["payload_len"] > 0, "%s produced an EMPTY reply" % want_kind
        print("[ok] dispatch 1/%#04x -> %s (%d payload bytes)"
              % (command, want_kind, oh["payload_len"]))

    # ---- 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")
