a3fd51692f
Ships the club-item subtype correction and the tradeable plumbing, and records two
refutations of claims made earlier in the same session. Nothing here is a working fix
for trading; the honest state is that the gate is still closed and we now know more
about why.
REFUTED 1: "the Blaze client-config store opens the trading gate". It does not, and the
flag is inert. IS_TRADING_ENABLED is an OUTPUT NAME. FUN_18006cc60 is a publisher: at
0x18006ccc6 it calls [rax+0x270] to READ gate byte 0x1fd2e, then lea rdx,[
IS_TRADING_ENABLED] and hands the value out under that name. The only rip-relative
reference to the literal 0x1801fc118 in all of .text is that lea; there is no comparison
against it anywhere, so no client-config key of that name can be read as an input. That
also undermines the IS_* store keys shipped beside it: their apparent success was never
actually attributed to them.
REFUTED 2: "the gate byte flipped to 1". It reads 0. It was measured as 1 shortly after
CardsDLL mapped and that was over-claimed as a success; a thorough re-measurement read 0
on the SAME pid and model pointer, and a fresh session reads 0 with an unambiguous raw
dump (model+0x1fd18.. = 01000000 00000000 00000000 00000000 3c000000 01 01 00 01, the 00
being 0x1fd2e). Either the first read was transient or something clears it after login.
The only writer is FUN_18011dc50 at 0x18011dc91, so a 0 means something RAN and wrote it.
AND THE "/settings IS DEAD" CLAIM FALLS TOO. FUN_18011dc50 is not unreachable: it is a
VIRTUAL method at model vtable slot +0x988 (absolute pointer 0x18021cc28). A direct-call
search found no callers because Ghidra does not resolve virtual calls, which is the same
dispatch-form trap that has now produced seven wrong verdicts here. The real chain is
settings response -> FUN_180174630 -> FUN_18013c6d0 (deser)
-> completion callback FUN_180173e00 -> vt+0x988 and vt+0x998 -> gate bytes
and FUN_180173e00 bails before applying anything unless the int at response+0x1c is
zero. Which atom writes +0x1c is unknown and is the thing worth chasing.
The measurement behind that claim also had a gap: it checked +0x1fd14, +0x1fd4c and
+0x1fd54 for the maximumTradePileSize=77 probe but NOT +0x1fd1c, which is the actual
TRADE_PILE_SIZE (read via vt+0xa58 = FUN_18011bf30). So the probe never tested the field
it needed to. Serving 77 and reading +0x1fd1c is the clean falsifier and is still open.
Recovered and worth keeping: an authoritative slot-to-name table from the publisher.
vt+0x270 IS_TRADING_ENABLED -> +0x1fd2e vt+0x2b0 IS_FRIENDLY_SEASON_ENABLED -> +0x1fd3a
vt+0x2b8 IS_TOURNAMENT_QUIT_ENABLED -> +0x1fd3b vt+0x2c0 IS_PROCESSING_STATE_ENABLED -> +0x1fd3c
vt+0x2c8 IS_DRAFT_MODE_ENABLED -> +0x1fd3d vt+0x2d8 IS_STORY_MODE_REWARD_ENABLED -> +0x1fd3f
vt+0x2f0 IS_RETURNING_USER_REWARDS_SCREEN -> +0x1fd40 vt+0xa58 TRADE_PILE_SIZE -> +0x1fd1c
That also locates the red TRANSFER LIST 0/0: it is +0x1fd1c, currently 0.
WHAT IS ACTUALLY SHIPPED HERE, all default off:
* FUT_TRADEABLE sends untradeable=false. Verified landing at item+0x49 (stored
INVERTED by case 0x361) on a live club record. Applied on every READ path, not only
in _item(), because the save holds 246 items minted before the flag existed and the
club route serves them straight from the save. That gap was caught by reading the
served JSON, not by unit-testing the factory.
* FUT_TRADING adds tradingEnabled and IS_TRADING_ENABLED to the Blaze config. Kept
only as a record of the refutation, with the reasoning inline so nobody retries it.
* fut_clubitems FAMILIES subtypes corrected: kit 9, stadium 10, badge 11 (cardtype 7,
not 9), ball 30, league logo 31. Every previous value sat in the 0x91..0x96 TROPHY
block. probe_shelf's candidate set lacked 9, 10 and 11, so the probe route the docs
preferred could never have answered this for three of five families.
* Club kits and badges now carry teamid, reintroduced ALONE after the 2026-08-05 crash
(which was never bisected; value is the established suspect and that response also
carried 30 items across five wrong subtypes). itemType dropped: it was unobserved and
never copied into the record.
Live: 439 contract checks, 414 card-family checks, market suite, all pass. The transfer
market still refuses with zero requests and the menu entries are still greyed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1902 lines
91 KiB
Python
1902 lines
91 KiB
Python
#!/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",
|
|
)]
|
|
# FUT_TRADING: the transfer-market equivalent of the store block above.
|
|
#
|
|
# WHY THIS IS HERE AND NOT IN /settings. "Place on Transfer List" and "List on
|
|
# Transfer Market" are greyed out because the TO_TRADE_PILE predicate
|
|
# FUN_1801a7260 needs a service gate at vtable+0x270, which is
|
|
# `movzx eax, byte [rcx+0x1fd2e]; ret`. That byte is the tradingEnabled gate and it
|
|
# reads 0.
|
|
#
|
|
# Sending tradingEnabled through /settings does NOT move it, PROVEN live 2026-08-06:
|
|
# the arm is right (case 0x336 writes param_2[10]) and the applier is right
|
|
# (0x1fd2e = param_2[10] == 1), but the applier has NO caller Ghidra can see and is
|
|
# not reachable from the settings deserializer. The decisive measurement: we served
|
|
# maximumTradePileSize=77 and NO int gate field carries 77 (+0x1fd14=0, +0x1fd4c=0,
|
|
# +0x1fd54=480). Every gate byte is a constructor default. That also explains
|
|
# storeEnabled reading 1: a default, never our value.
|
|
#
|
|
# REFUTED 2026-08-06, KEPT ONLY AS A RECORD. THIS DOES NOT WORK. Do not turn it on
|
|
# expecting an effect, and do not reason from it.
|
|
#
|
|
# The reasoning above was wrong in two places and the flag is inert:
|
|
#
|
|
# 1. IS_TRADING_ENABLED IS AN OUTPUT NAME, NOT AN INPUT. FUN_18006cc60 is a
|
|
# PUBLISHER: at 0x18006ccc6 it does `call [rax+0x270]` (which reads gate byte
|
|
# 0x1fd2e), then `lea rdx,[IS_TRADING_ENABLED]` and hands the value OUT under
|
|
# that name. The only rip-relative reference to the literal 0x1801fc118 in the
|
|
# whole of .text is that lea. There is no comparison against it anywhere, so a
|
|
# client-config key of that name cannot be read as an input by anything. The same
|
|
# is true of the IS_* store keys above, which means the store block may also be
|
|
# inert and its apparent success was never actually attributed.
|
|
# 2. The gate byte was briefly measured as 1 and that was over-claimed as a success.
|
|
# On a fresh session it reads 0, and a thorough re-measurement read 0 on the very
|
|
# pid where it had read 1. Either the first read was transient or something clears
|
|
# it after login. The only writer of 0x1fd2e is FUN_18011dc50 at 0x18011dc91.
|
|
#
|
|
# What IS now known, and supersedes the "/settings is dead" claim in the note above:
|
|
# FUN_18011dc50 is NOT unreachable. It is a VIRTUAL method at model vtable slot
|
|
# +0x988 (absolute pointer at 0x18021cc28), which is why a direct-call search found
|
|
# no callers. The real chain is
|
|
# settings response -> FUN_180174630 -> FUN_18013c6d0 (deser)
|
|
# -> completion callback FUN_180173e00 -> vt+0x988 / vt+0x998 -> gate bytes
|
|
# and FUN_180173e00 bails before applying anything unless the int at response+0x1c
|
|
# is zero. Which atom writes +0x1c is UNKNOWN and is the thing worth chasing.
|
|
#
|
|
# Default OFF and it should stay off.
|
|
+ ([(k, "1") for k in ("tradingEnabled", "IS_TRADING_ENABLED")]
|
|
if os.environ.get("FUT_TRADING") else [])
|
|
# 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")
|