#!/usr/bin/env python3
"""
OpenFUT clean-room LSX responder for FIFA 17 -- v2 (EVENT-PUSHING).

v2 vs v1 (lsx_responder.py): v1 was REQUEST-DRIVEN ONLY.  It answered every verb
the client asked for and never sent an unsolicited frame.  That is exactly why
the client never issued GetAuthCode and never sent Blaze Authentication::login.

    THE ORIGIN SDK HAS TWO INDEPENDENT FLAGS, FED BY TWO DIFFERENT MECHANISMS:

      (1) "internet is reachable"  -> OriginMgr online byte  [0x1448a3ac0]
          fed by the REQUEST verb  GetInternetConnectedState -> connected="1"
          (v1 already beat this; live-confirmed == 1)

      (2) "a user is LOGGED IN"    -> OriginMgr.m_isLoggedIn  [OriginMgr+0x13]
          fed ONLY by a server-PUSHED  <Event sender="LOGIN_EVENT"><Login/>
          There is NO request verb that can set it.

    v1 fed (1) and never fed (2), so m_isLoggedIn was 0 for the whole session,
    FIFA never enqueued an auth-code request into FirstPartyAuthTokenRetriever
    (both request slots live-read as 0x0), DoTick @0x146f199c0 exited immediately,
    OriginRequestAuthCodeSync @0x1470db3c0 was never called, LoginRequest.AUTH
    could never be filled -> no Blaze login -> "Unable to retrieve account
    information."

BINARY EVIDENCE (all re-verified byte-for-byte from our own live dumps, not
from any leak; see the PROVENANCE block at the bottom of this docstring):

  * Origin event dispatcher @0x146f1e060, case edx==2 (OriginEventT::Login) is
    the ONLY case in the whole dispatcher that mutates state:
        146f1e09e:  41 83 39 01     cmp DWORD PTR [r9],0x1      ; IsLoggedIn==1
        146f1e0ab:  c6 41 13 01     mov BYTE PTR [rcx+0x13],1   ; m_isLoggedIn=TRUE
        146f1e0af:  c7 41 14 00..   mov DWORD PTR [rcx+0x14],0  ; clear login error
        146f1e0b8:  c6 41 13 00     mov BYTE PTR [rcx+0x13],0   ; else FALSE
  * <Login> element matcher @0x147102880:
        - reads attribute "sender" (literal @0x143938028) off the <Event> node
          via vtbl+0x70;  `test rax,rax; je fail`  -> sender MUST be present
        - inline strcmp of that value against the handler's registered sender
          -> a mismatched sender is SILENTLY DROPPED
        - then requires the child element name == "Login" (@0x14393d0ac)
  * Handler sender strings come from the service-name tables.  NOTE there are
    TWO parallel structures, so do not "fix" one stride into the other:
      - the const char* INIT table @0x144341420 is STRIDE 8;
      - the runtime std::string array the SDK actually indexes (sdk+0x3b0, via
        GetServiceName @0x1470e4870 with `shl rax,0x5`) is STRIDE 0x20, max
        index 0x21.
    Both resolve index 14 == LOGIN_EVENT, so the conclusion is the same.
    Verified contents of the index space:
        idx 0 SDK   1 PROFILE   2 PRESENCE  3 FRIENDS   4 COMMERCE
        idx 5 RECENTPLAYER      6 IGO       7 MISC      8 LOGIN
        idx 9 UTILITY          10 XMPP     11 CHAT     12 IGO_EVENT
        idx13 EALS_EVENTS      14 LOGIN_EVENT          15 INVITE_EVENT
        idx16 PROFILE_EVENT ... 27 ONLINE_STATUS_EVENT
    "LOGIN_EVENT" (@0x14394c790) is referenced from EXACTLY ONE place in the
    whole image: table slot 0x144341490 == index 14.  Likewise
    "ONLINE_STATUS_EVENT" (@0x14394c868) only from 0x1443414f8 == index 27.
  * <Login> attribute parser @0x147138660: opens namespace "lsx", reads attribute
    "IsLoggedIn" (@0x14394e0f0), then @0x14713ffa0 does
        strcmp(value,"false"); setne al; mov BYTE PTR [rdi],al
    -> ANY value except the literal string "false" means TRUE.
  * <OnlineStatusEvent> parser @0x147139e00 reads attribute "isOnline"
    (@0x14394e180, lower-case i) in the same shape.
  * Symbols proving the handler templates are instantiated in this build:
        Origin::EventHandler<struct lsx::LoginT,unsigned int>::HandleMessage
        Origin::EventHandler<struct lsx::OnlineStatusEventT,bool>::HandleMessage
    (payload type `unsigned int` matches `cmp DWORD PTR [r9],1` above.)
  * Structural proof that unsolicited <Event> frames are consumable: the LSX
    handshake itself is one -- <LSX><Event sender="EALS"><Challenge/></Event>.

WHAT WE DELIBERATELY DID NOT CHANGE
  * The crypto (challenge / session-key derivation / AES-ECB+PKCS7+hex+NUL) is
    byte-verified against a captured real session; it is copied verbatim.
  * Every verb v1 answered is answered identically.  Nothing was removed.

WIRE PROTOCOL (unchanged, reversed from stp-origin_emu.dll @ 0x6ffffc930000):
  transport : TCP 127.0.0.1:4216, each message NUL-terminated (send strlen+1).
  handshake : server sends <Challenge key="..."> IN PLAINTEXT;
              client replies plaintext with response=/key=;
              server replies <ChallengeAccepted response="H"> where
                  H = hex(AES128_ECB(K_FIXED, PKCS7pad16(clientKeyAscii)))
                  K_FIXED = 000102...0f  (emu .rdata 0x935038)
  session   : every later frame (BOTH directions, Responses AND Events) is
                  hex_lower(AES128_ECB(SESSION_KEY, pkcs7pad16(xml))) + b"\0"
              SESSION_KEY derived from H via the MSVCR srand/rand LCG.

USAGE: bind BEFORE launching FIFA 17 so the Steampunks stub's bind() fails.
       (This file does NOT auto-start anything; the main session owns processes.)

PROVENANCE / CLEAN ROOM: every constant and algorithm here was recovered by our
own static+dynamic analysis of binaries we own (FIFA17.exe unpacked in our own
process, stp-origin_emu.dll as loaded) plus traffic we ourselves captured.
Nothing is derived from the 2021 EA/FIFA leak.
"""
import os
import re
import socket
import sys
import threading
import time

from Crypto.Cipher import AES

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fut_account import ACCOUNT  # noqa: E402

# ---------------------------------------------------------------- identity
# SOURCED FROM fut_account.ACCOUNT, shared with blaze_responder_v3b.py,
# fut_store.py, fut_seed.py and utas_server.py.
#
# THE CONSTRAINT IS CROSS-LAYER CONSISTENCY, NOT ANY PARTICULAR VALUE: what LSX
# reports here must equal what Blaze returns in LoginResponse.SESS.PDTL and what
# UTAS serves as userInfo.personaId. (The previous comment blamed a mismatch for
# AUTH_ERR_INVALID_PERSONA / AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA /
# AUTH_ERR_PERSONA_NOT_FOUND -- those are Blaze *server* error codes and we are
# the server. Neither "CAGE" nor "33068179" appears in FIFA17.exe, CardsDLL or
# dbdata.dll; 33068179 lives only in stp-origin_emu.dll's own ini default. They
# stay the defaults because they are what the working stack asserts.)
PERSONA_ID = ACCOUNT.persona_id
PERSONA_NAME = ACCOUNT.persona_name
USER_ID = ACCOUNT.user_id            # derived from persona_id
CONTENT_ID = ACCOUNT.CONTENT_ID      # FIFA 17 EA offer id
ENTITLEMENT_TAG = ACCOUNT.ENTITLEMENT_TAG
LOCALE = ACCOUNT.locale

AUTHCODE_FILE = "/tmp/openfut_authcode.txt"
CLIENTID_FILE = "/tmp/openfut_lsx_clientid.txt"

# Recovered from stp-origin_emu.dll .rdata @ VA 0x6ffffc935038
K_FIXED = bytes(range(16))      # 000102030405060708090a0b0c0d0e0f

# Emu's own advertised challenge (any 32 hex chars work; the client echoes it back)
CHALLENGE_KEY = "2b8ee7faea76e8a34f5f5d20e5328e32"
BUILD = "release"
VERSION = "10,4,13,6637"

# ------------------------------------------------------------- event tuning
# Pushes are idempotent state notifications, so re-sending is harmless and is
# cheap insurance against FIFA registering its <Login> handler later than our
# first push.  Set OPENFUT_LSX_EVENTS=0 to fall back to v1 behaviour (useful as
# an A/B control if you want to prove the events are what moved the needle).
EVENTS_ENABLED = os.environ.get("OPENFUT_LSX_EVENTS", "1") != "0"
EVENT_HEARTBEAT_SECS = float(os.environ.get("OPENFUT_LSX_EVENT_PERIOD", "5"))
EVENT_HEARTBEAT_COUNT = int(os.environ.get("OPENFUT_LSX_EVENT_COUNT", "24"))

# EXPERIMENT: push the Login Event in PLAINTEXT right after ChallengeAccepted
# (before the stream goes encrypted) instead of via the encrypted heartbeat.
# Tests the workflow's strongest remaining hypothesis -- that FIFA drops
# encrypted mid-session Events (the emu's only Event, the Challenge, is plaintext
# and pre-key).  See serve() step 3b and REPACK_INTEL.md sec.4 step 2.
LOGIN_PLAINTEXT = os.environ.get("OPENFUT_LSX_LOGIN_PLAINTEXT", "0") != "0"

# A/B-control integrity: v1 (lsx_responder.py) answered GetGameInfo
# FULLGAME_PURCHASED with "false" (it fell through to the default).  v2 had
# silently changed it to "true", which meant OPENFUT_LSX_EVENTS=0 was NOT a
# byte-identical control any more.  Keep it OFF by default so events-off ==
# v1 exactly; flip OPENFUT_LSX_FULLGAME=1 to run the FULLGAME="true" experiment
# on its own.
FULLGAME_PURCHASED_TRUE = os.environ.get("OPENFUT_LSX_FULLGAME", "0") != "0"


def log(*a):
    print("[lsx]", *a, flush=True)


# ---------------------------------------------------------------- crypto
# (verbatim from v1 -- verified end-to-end by decrypting captured
#  captures/lsx/lsx_raw/C1_ENC-IN_*.bin.  DO NOT TOUCH.)
def msvcr_rand(seed):
    """MSVCR120 srand/rand LCG (verified: srand(7); rand() == 61)."""
    s = seed & 0xFFFFFFFF
    while True:
        s = (s * 214013 + 2531011) & 0xFFFFFFFF
        yield (s >> 16) & 0x7FFF


def derive_session_key(resp_hex: str) -> bytes:
    """Reimplementation of emu sub_0x6ffffc931f10 tail (0x9320bf-0x932101).

        srand(7); r0 = rand()                       -> r0 == 61
        bx  = (u16)((resp[0] << 8) + resp[1])       (16-bit wrap)
        srand(bx + r0)
        key[i] = (uint8_t)rand()   for i in 0..15
    """
    r0 = next(msvcr_rand(7))                                    # == 61
    bx = ((ord(resp_hex[0]) << 8) + ord(resp_hex[1])) & 0xFFFF
    g = msvcr_rand((bx + r0) & 0xFFFFFFFF)
    return bytes(next(g) & 0xFF for _ in range(16))


# AES128-ECB(K_FIXED, 0x10*16) -- the constant the emu appends as the 3rd hex
# block (== PKCS7 pad block of an aligned 32-byte key).  See REPACK_INTEL.md sec.0-B.
_TAIL_CONST = AES.new(K_FIXED, AES.MODE_ECB).encrypt(b"\x10" * 16).hex()


def challenge_response(client_key_ascii: str, client_response_attr: str = "") -> str:
    """Emu-exact ChallengeAccepted.response (stp-origin_emu.dll 0x180001f10).

    The emu computes only TWO AES blocks from the 32-ASCII client key, then
    strcat_s's the client's OWN response[64:] verbatim (@0x1800020a9) -> 96 hex.
    Our older 3-block PKCS7 form is numerically identical *while the client
    PKCS7-pads its 3rd block* (REPACK_INTEL.md sec.0-A/0-B, workflow-confirmed
    byte-exact).  We now reproduce the emu exactly and, when the client's
    response= is available, echo its tail and assert the constant so a future
    client that randomises block 3 fails LOUDLY instead of silently."""
    two = AES.new(K_FIXED, AES.MODE_ECB).encrypt(client_key_ascii.encode()).hex()
    if len(client_response_attr) >= 64:
        tail = client_response_attr[64:]
        assert tail == _TAIL_CONST, f"unexpected ChallengeResponse tail {tail!r}"
        return two + tail
    return two + _TAIL_CONST


def lsx_encrypt(xml: str, key: bytes) -> bytes:
    """pkcs7-pad to 16, AES-128-ECB, lowercase hex, NUL-terminated."""
    b = xml.encode()
    pad = 16 - (len(b) % 16)          # emu always pads (pad==16 when aligned)
    b += bytes([pad]) * pad
    return AES.new(key, AES.MODE_ECB).encrypt(b).hex().encode() + b"\0"


def lsx_decrypt(data: bytes, key: bytes) -> str:
    h = data.split(b"\0")[0].strip()
    raw = AES.new(key, AES.MODE_ECB).decrypt(bytes.fromhex(h.decode()))
    pad = raw[-1]
    if 0 < pad <= 16 and all(c == pad for c in raw[-pad:]):
        raw = raw[:-pad]
    return raw.split(b"\0")[0].decode(errors="replace")


# ------------------------------------------------------- PUSHED EVENTS (NEW)
#
# Frame shape is identical to the server-initiated <Challenge> that already
# works, i.e.   <LSX><Event sender="..."><Element .../></Event></LSX>
# No id attribute (the Challenge has none; the matcher never reads one).
#
# `sender` is strcmp'd against the handler's registered service name.  A
# mismatch is silently dropped -- costs us nothing -- so for the Login element
# we emit BOTH candidate senders: "LOGIN_EVENT" (table index 14, the one the
# event-handler factory uses) and "LOGIN" (table index 8, the plain service
# name).  Exactly one of them will match; the other is a no-op.
# Event handlers are keyed on serviceNames[facility] too (registrar 0x14710df80);
# with our empty GetConfigResponse those names are "", so the handlers expect
# sender="".  "" first; the named variants are harmless no-ops (dropped silently)
# and become correct once GetConfigResponse populates the table (RANK 2).
LOGIN_EVENT_SENDERS = ("", "LOGIN_EVENT", "LOGIN")
ONLINE_EVENT_SENDERS = ("", "ONLINE_STATUS_EVENT")


def event(sender: str, element: str) -> str:
    return f'<LSX><Event sender="{sender}"><{element}/></Event></LSX>'


def login_event_frames() -> list:
    """The frames that flip OriginMgr.m_isLoggedIn ([OriginMgr+0x13]) to 1.

    IsLoggedIn is parsed as `strcmp(v,"false") != 0`, so "true" -> TRUE.
    Keep the value literally "true" anyway: it is what a real Origin client
    sends and it keeps the log readable."""
    out = [event(s, 'Login IsLoggedIn="true"') for s in LOGIN_EVENT_SENDERS]
    out += [event(s, 'OnlineStatusEvent isOnline="true"')
            for s in ONLINE_EVENT_SENDERS]
    return out


class Conn:
    """Socket + session key + a send lock.

    The lock matters: pushes come from a heartbeat thread while the request
    loop may be writing a Response.  LSX frames are NUL-delimited, so two
    interleaved sendall()s would corrupt the stream and the client would drop
    the connection (which would look exactly like a protocol bug)."""

    def __init__(self, sock, addr):
        self.sock = sock
        self.addr = addr
        self.key = None
        self.lock = threading.Lock()
        self.alive = True
        self.pushed_login = False
        # Set True once GetAuthCode has been issued, so the heartbeat stops
        # re-pushing Login/OnlineStatus events.  Re-pushing after the auth code
        # is granted re-enters FIFA's state-mutating Origin event dispatcher
        # (case 2 @0x146f1e0ab sets m_isLoggedIn + clears loginError + rebroadcasts
        # on the FE bus) ~24 more times DURING Blaze login, which we do not want.
        self.stop_events = False

    def send_plain(self, xml: str):
        with self.lock:
            self.sock.sendall(xml.encode() + b"\0")

    def send_enc(self, xml: str):
        with self.lock:
            self.sock.sendall(lsx_encrypt(xml, self.key))

    def push_login_state(self, why: str):
        if not EVENTS_ENABLED:
            return
        for frame in login_event_frames():
            try:
                self.send_enc(frame)
            except Exception as e:
                self.alive = False
                log("push failed:", e)
                return
            log(f"PUSH ({why}) >> {frame}")
        if not self.pushed_login:
            self.pushed_login = True
            log("*** first <Login IsLoggedIn=\"true\"> pushed. Watch for "
                "GetAuthCode next. ***")

    def heartbeat(self):
        """Re-push the login state a bounded number of times.

        FIFA builds its Origin event handlers lazily; if our first push lands
        before the <Login> handler is registered the matcher simply finds no
        handler and drops it.  Re-pushing removes that race without needing to
        guess the exact registration moment."""
        for _ in range(EVENT_HEARTBEAT_COUNT):
            time.sleep(EVENT_HEARTBEAT_SECS)
            if not self.alive or self.stop_events:
                return
            self.push_login_state("heartbeat")


# ---------------------------------------------------------------- responses
def resp(mid, body, sender=""):
    return f'<LSX><Response id="{mid}" sender="{sender}"><{body}/></Response></LSX>'


def build_reply(mid, req_name, attrs, conn, recipient=""):
    """Request-DRIVEN dispatch (the Steampunks stub was a blind fixed script).

    CRITICAL (2026-07-31, connect-reverse workflow): FIFA's response matcher
    0x1471189b0 rejects any <Response> whose `sender` attribute does not
    byte-equal the `recipient` the client put on the matching <Request> (it
    reads serviceNames[facility]; with our empty GetConfigResponse all 34 names
    are "" so recipient="" for every verb after GetConfig, which itself uses the
    hard-coded literal "EbisuSDK").  We were answering GetProfile/GetAuthCode/
    QueryEntitlements with sender="EbisuSDK" -> silently discarded -> GetProfile
    (the SOLE writer of OriginSDK+0x3a0 default-user) never took -> the whole
    online-login chain stalled at OSDK_INVALID_USER.  FIX = ECHO the request's
    recipient back as the response sender.  This local `resp` shadows the module
    one and makes `sender` default to `recipient`."""
    def resp(mid, body, sender=None):
        s = recipient if sender is None else sender
        return f'<LSX><Response id="{mid}" sender="{s}"><{body}/></Response></LSX>'

    if req_name == "GetInternetConnectedState":
        # FLAG (1): "internet is reachable".  Stub hardcoded connected="0"
        # -> "log in to Origin".  This is NOT the logged-in flag; see the
        # module docstring.
        return resp(mid, 'InternetConnectedState connected="1"')

    if req_name == "GetAuthCode":
        # Request shape is built at 0x14713b8d0:
        #     <GetAuthCode ClientId="..." Scope="..."/>
        # Response is matched at 0x1470e2b60: outer "LSX", element "AuthCode".
        #
        # THE ATTRIBUTE NAME IS "value" -- verified, not guessed:
        # the "AuthCode" element match at 0x1470e2b63 tail-jumps to 0x14712fac0
        # -> 0x1471312a0 = the lsx::AuthCodeT deserializer.  It builds one
        # attribute name (ns-prefix for "lsx" @0x14394def0, then "value"
        # @0x1436c7768, concat at 0x14712d130) and does exactly ONE
        # get-attribute-as-string call 0x14713fe50(node, "value", &dest).
        # dest is ctx+0x00 == LSXRequest+0xb8, whose std::string size lands at
        # +0xc8 -- which is what OriginRequestAuthCodeSync's impl 0x1470e67f0
        # reads back at 0x1470e6924 (`mov rbx,[rdi+0xc8]`) as *out_len.
        # Code=/Return= are NEVER read; with them alone the parsed string is
        # empty -> out_len 0 -> EbisuMgr+0x948 stays NULL -> the OSDK classifier
        # 0x14717d5d0 falls into its `test rbp,rbp / je` arm and reports
        # OSDK_UNDERAGE_ERROR (a mislabelled "no auth code" fallback).
        # Code=/Return= are kept only as harmless padding.
        client_id = attrs.get("ClientId", "")
        scope = attrs.get("Scope", "")
        code = os.environ.get("OPENFUT_AUTHCODE", "OPENFUT-" + "0" * 24)
        # Only touch the run's success-signal files on a REAL request (conn is a
        # live socket).  --selftest calls build_reply(..., conn=None); if it
        # wrote these files it would pre-satisfy watch-step "authcode.txt becomes
        # non-empty" and make a non-event read as success on the next live run.
        if conn is not None:
            for path, val in ((AUTHCODE_FILE, code), (CLIENTID_FILE, client_id)):
                try:
                    with open(path, "w") as fh:
                        fh.write(val)
                except Exception:
                    pass
            # GetAuthCode has fired: stop the heartbeat so we do not keep
            # re-pushing Login/OnlineStatus events during Blaze login.
            conn.stop_events = True
        log("*** GetAuthCode ISSUED ***")
        log(f"    ClientId={client_id!r}  Scope={scope!r}")
        log(f"    code={code}  -- this must arrive as Blaze "
            f"LoginRequest.AUTH in Authentication::login (1/0x0A)")
        return resp(mid,
                    f'AuthCode value="{code}" Code="{code}" Return="{code}"')

    if req_name == "QueryEntitlements":
        item = (f'<OriginItem ItemId="{ENTITLEMENT_TAG}" EntitlementId="1" '
                f'ResourceId="{CONTENT_ID}" OfferId="{CONTENT_ID}" '
                f'GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>')
        return (f'<LSX><Response id="{mid}" sender="{recipient}">'
                f'<QueryEntitlementsResponse>{item}</QueryEntitlementsResponse>'
                f'</Response></LSX>')

    if req_name == "GetProfile":
        # This is the ONLY feed for OriginSDK[+0x3a0]/[+0x3a8]
        # (OriginGetDefaultUser @0x1470da6d0 / OriginGetDefaultPersona
        # @0x1470da680 are bare reads of those fields, written only by
        # OriginSDK::Initialize @0x1470e5ad5/0x1470e5ae1).  Keep it complete.
        # ONLY PersonaId/UserId/Persona are substituted from ACCOUNT; the rest
        # of this template (Country/CommerceCountry/GeoCountry/CommerceCurrency/
        # AvatarId/IsSubscriber/IsUnderAge) is byte-exact per REPACK_INTEL 1.4
        # and is latched into OriginSDK[+0x3a0]/[+0x3a8] -- leave it verbatim.
        return resp(mid,
                    f'GetProfileResponse IsSubscriber="true" PersonaId="{PERSONA_ID}" '
                    f'AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US" '
                    f'UserId="{USER_ID}" Persona="{PERSONA_NAME}" IsUnderAge="false" '
                    f'CommerceCurrency="USD"')

    if req_name == "GetGameInfo":
        gi = attrs.get("GameInfoId")
        if gi == "LANGUAGES":
            return resp(mid, 'GetGameInfoResponse GameInfo="ar_SA,cs_CZ,da_DK,de_DE,'
                             'en_US,es_ES,es_MX,fr_FR,it_IT,nl_NL,no_NO,pl_PL,pt_BR,'
                             'pt_PT,ru_RU,sv_SE,tr_TR,zh_TW"')
        if gi == "UPTODATE":
            # MUST be true or the client shows "Your title version is
            # outdated" and blocks all online features.
            return resp(mid, 'GetGameInfoResponse GameInfo="true"')
        if gi == "FULLGAME_PURCHASED" and FULLGAME_PURCHASED_TRUE:
            # OFF by default: v1 answered "false" here (fell through to default).
            # Keeping this gated makes OPENFUT_LSX_EVENTS=0 byte-identical to v1.
            return resp(mid, 'GetGameInfoResponse GameInfo="true"')
        # FREETRIAL / FULLGAME_PURCHASED etc. -> false (retail, not a trial;
        # matches v1 exactly)
        return resp(mid, 'GetGameInfoResponse GameInfo="false"')

    if req_name == "GetSetting":
        sid = attrs.get("SettingId", "").upper()   # client asks UPPERCASE
        if sid in ("ENVIRONMENT", "ENVIRONMENTNAME"):
            return resp(mid, 'GetSettingResponse Setting="production"')
        if sid == "LANGUAGE":
            return resp(mid, f'GetSettingResponse Setting="{LOCALE}"')
        return resp(mid, 'GetSettingResponse Setting="false"')

    if req_name == "GetConfig":
        return resp(mid, 'GetConfigResponse Config="false"')

    if req_name == "IsProgressiveInstallationAvailable":
        return resp(mid, 'IsProgressiveInstallationAvailableResponse ItemId="" '
                         'Available="false"')

    return resp(mid, 'ErrorSuccess Code="0" Description=""')


# Trigger points: push right after answering these verbs.  GetProfile is the
# earliest safe moment -- by then the SDK has built its handler set and has a
# default user, so a Login event has somewhere to land.
PUSH_AFTER = {
    "GetProfile":                 "after GetProfile",
    "GetInternetConnectedState":  "after GetInternetConnectedState",
    "GetGameInfo":                "after GetGameInfo UPTODATE",
}

REQ_RE = re.compile(r'<Request[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>')
ATTR_RE = re.compile(r'(\w+)="([^"]*)"')
# The response `sender` must byte-equal the request's `recipient` (matcher
# 0x1471189b0).  Captured separately (default "") so a frame that ever lacks
# `recipient` still gets answered fast instead of a 15s stall.
RECIP_RE = re.compile(r'<Request[^>]*\brecipient="([^"]*)"')


def serve(sock, addr):
    conn = Conn(sock, addr)
    hb = None
    try:
        # 1. plaintext Challenge
        conn.send_plain(f'<LSX><Event sender="EALS"><Challenge key="{CHALLENGE_KEY}" '
                        f'build="{BUILD}" version="{VERSION}"/></Event></LSX>')

        # 2. plaintext ChallengeResponse from client.  The emu parses response="
        #    BEFORE key=" (0x180001f10); extract both so challenge_response can
        #    echo the client's own 3rd block (REPACK_INTEL.md C1/C2).
        data = sock.recv(4096)
        txt = data.decode(errors="replace")
        mk = re.search(r'key="([^"]*)"', txt)
        mr = re.search(r'response="([^"]*)"', txt)
        client_key = mk.group(1) if mk else CHALLENGE_KEY
        client_resp = mr.group(1) if mr else ""
        h = challenge_response(client_key, client_resp)
        conn.key = derive_session_key(h)
        log(f"client key={client_key} response={h[:16]}... "
            f"session_key={conn.key.hex()}")

        # 3. plaintext ChallengeAccepted
        conn.send_plain(resp(1, f'ChallengeAccepted response="{h}"', "EALS"))

        # 3b. EXPERIMENT (OPENFUT_LSX_LOGIN_PLAINTEXT=1): the shipped emu's ONLY
        #     unsolicited Event is the plaintext, pre-session-key Challenge; there
        #     is zero evidence an *encrypted mid-session* Event routes to the same
        #     parser (REPACK_INTEL.md sec.4 step 2).  So push the Login Event here,
        #     in PLAINTEXT, right after ChallengeAccepted -- before the stream goes
        #     encrypted -- and suppress the encrypted heartbeat to keep the A/B clean.
        if LOGIN_PLAINTEXT and EVENTS_ENABLED:
            conn.stop_events = True
            for frame in login_event_frames():
                conn.send_plain(frame)
                log(f"PUSH (plaintext post-accept) >> {frame}")

        # 4. encrypted request/response loop
        buf = b""
        while True:
            data = sock.recv(65536)
            if not data:
                break
            # Buffer partial frames: a 64 KiB recv can straddle a NUL boundary,
            # and split() would silently drop the trailing partial (C3).
            buf += data
            *frames, buf = buf.split(b"\0")
            for chunk in filter(None, frames):
                try:
                    xml = lsx_decrypt(chunk + b"\0", conn.key)
                except Exception as e:
                    log("decrypt fail:", e)
                    continue
                mm = REQ_RE.search(xml)
                if not mm:
                    log("<<", xml)
                    continue
                mid, name, rest = mm.group(1), mm.group(2), mm.group(3)
                attrs = dict(ATTR_RE.findall(rest))
                rm = RECIP_RE.search(xml)
                recip = rm.group(1) if rm else ""
                reply = build_reply(mid, name, attrs, conn, recip)
                log(f"<< id={mid} {name} recipient={recip!r} {attrs}")
                log(f">> {reply}")
                conn.send_enc(reply)

                why = PUSH_AFTER.get(name)
                if why and EVENTS_ENABLED:
                    # For GetGameInfo only fire on UPTODATE, otherwise we would
                    # push three times per boot for FREETRIAL/LANGUAGES too.
                    if name != "GetGameInfo" or attrs.get("GameInfoId") == "UPTODATE":
                        conn.push_login_state(why)
                        if hb is None:
                            hb = threading.Thread(target=conn.heartbeat,
                                                  daemon=True)
                            hb.start()
    except Exception as e:
        log("connection error:", e)
    finally:
        conn.alive = False
        try:
            sock.close()
        except Exception:
            pass
        log("connection closed", addr)


def main():
    s = socket.socket()
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(("127.0.0.1", 4216))
    s.listen(8)
    log("v2 listening on 127.0.0.1:4216 (start FIFA 17 now)")
    log(f"login-state event push: {'ENABLED' if EVENTS_ENABLED else 'DISABLED'}"
        f"  (period={EVENT_HEARTBEAT_SECS}s count={EVENT_HEARTBEAT_COUNT})")
    while True:
        c, a = s.accept()
        log("connection from", a)
        threading.Thread(target=serve, args=(c, a), daemon=True).start()


# ---------------------------------------------------------------- self-test
def selftest():
    """No live game needed.  Proves the crypto is untouched and the new event
    frames encrypt/decrypt cleanly through our own codec."""
    h = challenge_response("18a70055a3541fb27ab8e0f47afad18c")
    assert h.startswith("e4f5166209929e15"), h
    k = derive_session_key(h)
    assert k.hex() == "6a9da3e78615153cc2f10eec25ae6382", k.hex()
    print("[ok] crypto matches the captured 2026-07-30 session verbatim")
    frames = login_event_frames()
    assert len(frames) == len(LOGIN_EVENT_SENDERS) + len(ONLINE_EVENT_SENDERS)
    for f in frames:
        assert lsx_decrypt(lsx_encrypt(f, k), k) == f
        print("[ok] round-trip:", f)
    # "" sender first (correct for the current empty service-name table)
    assert '<Event sender=""><Login IsLoggedIn="true"/></Event>' in frames[0]
    r = build_reply(42, "GetAuthCode", {"ClientId": "X", "Scope": "Y"}, None)
    # 'value' is the only attribute lsx::AuthCodeT's deserializer (0x1471312a0)
    # actually reads; Code=/Return= are legacy padding.
    assert '<AuthCode value=' in r, r
    print("[ok] GetAuthCode ->", r)
    print("[ok] selftest passed")


if __name__ == "__main__":
    if "--selftest" in sys.argv:
        selftest()
    else:
        main()
