Files
OpenFUT/fifa17-recon/tools/login_dump/CONNECT_PLAN.md
T
funman300 edab23f04a fifa17-recon: package the working offline FUT backend
Emulates FIFA 17's full online + Ultimate Team stack against an offline,
clean-room backend (no EA servers). Proven end-to-end 2026-08-01:
Origin login -> Blaze login -> device-trust -> the FUT hub.

Package:
- tools/openfut-fut.sh   one-command orchestrator (start/stop/status/restart)
- tools/root_arm.sh      idempotent host arm (sysctls, DNAT, /etc/hosts easw)
- tools/{lsx_responder_v2,blaze_responder_v3b,roster_server,utas_server,autopatch}.py
  the 5 servers (Origin LSX :4216, Blaze :42127/42130/42131, roster :8081,
  FUT/UTAS :8099) + heat2.py (Fire2/Heat2 TDF codec)
- FUT-RUNBOOK.md         runbook + gate-ladder troubleshooting
- docs/, tools/login_dump/*.md   the reverse-engineering write-ups

All findings are clean-room, from binaries we own; nothing from any leak.
The wire protocol maps 1:1 to FIFA 23.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PN5bmpDVQR1aXgefyWAt7o
2026-08-01 09:12:17 -07:00

30 KiB
Raw Blame History

CONNECT_PLAN — Origin-SDK connect + Blaze-login trigger (clean-room synthesis)

Synthesis of four independent reverses (Origin connect writer, connect-wait guard, "service 0x1e8" dispatcher, Blaze LoginManager), plus my own live re-verification against pid 13643 (FIFA17.exe, still up at time of writing). Every VA/byte below is from our decrypted dumps, /proc/13643/mem, captures/lsx/full_origin_init_session.log, /tmp/lsx.log or /tmp/blaze_responder.log.


0. HEADLINE — BRIEF3's two premises were both wrong, and the real cause is one line of our responder

BRIEF3 said Truth (live-verified)
0x1470dbfa0 = service/interface lookup; "service 0x1e8 returns NULL → not connected" 0x1470dbfa0 is the Origin SDK allocator thunk (jmp [0x144b7c778], live target 0x1470d9380 = aligned-alloc). 0x1e8 is an allocation size (488 = sizeof(LSXRequest<GetAuthCodeT,…>)), and the neighbouring 0x1e6/0x1e8/0x1ea are __LINE__ args to the logger. There is no service table and nothing to register.
0xa2080000 comes from the auth path failing a service lookup 0xa2080000 is the LSXRequest/EventHandler "response failed to validate" default, written at 0x1471192dd[req+0x1e0]. It means we replied and the client threw our reply away.
Empty Blaze fetchClientConfig leaves [cfg+0x750] NULL and blocks login [cfg+0x750] is live non-NULL = 0x43c47ff0 (verified: *[0x144b86bf8]=0x43c46c70, +0x360=0x43c47330, +0x750=0x43c47ff0). It is fed by Util::preAuth's CONF map, not by fetchClientConfig. No fetchClientConfig payload can change it.

The actual blocker is a one-attribute protocol mismatch in lsx_responder_v2.py. Every LSX <Response> is accepted only if its sender attribute byte-equals the recipient the client put on the matching <Request>. FIFA sends recipient="" for everything after GetConfig (because our GetConfigResponse is empty, so its 34-entry service-name table is all empty strings), but we answer GetProfile, GetAuthCode and QueryEntitlements with sender="EbisuSDK". Those three replies — and only those three — are discarded. GetProfile is the sole writer of OriginSDK+0x3a0/+0x3a8, so the whole chain (default user → auth code → OSDK login state → Blaze Authentication::login) never starts.


1. THE CONNECT MECHANISM — end to end

1.1 The writer is a synchronous statement, not an event

0x1470e5ad5 (the +0x3a0 write) lives inside Origin::OriginSDK::Initialize, function start 0x1470e5770 (prologue push rbp/rsi/rdi/r12/r13/r14/r15; sub rsp,0xf0, epilogue 0x1470e5caf). Its logger passes # 0x143937c78 = "Origin::OriginSDK::Initialize" and # 0x143937b70 = "E:\p4\fifafb\rl\empatch\TnT\Code\External\EA\OriginSDK\src\impl\OriginSDKimpl.cpp" (both read live). Sequence inside Initialize:

0x1470e5959..96   connect loop: call 0x14712ca40(conn=sdk+0x168, …, port)
                  on false -> Sleep(1000); inc ebx; cmp ebx,0x1e; jl   => 30 x 1s   (NOT 15 s)
0x1470e59fb       version gate: cmp eax,0x9b00000; jb -> 0xa0020007    (passes today)
0x1470e5a2f       ecx=0x1a8; call 0x1470dbfa0      <-- ALLOC 424 bytes for the GetConfig request
0x1470e5a46       lea rdx,[rip..] # 0x143937d58 = "EbisuSDK"   <-- HARD-CODED recipient/sender
                  ... SendSync(GetConfig) ...
0x1470e5a96       lea rdx,[rbx+0x80]; call 0x1470e0520          <-- consume GetConfigResponse
                                                                    -> fills serviceNames[0..33]
0x1470e5aa8       call 0x14710df80(sdk)                          <-- register ~19 event handlers,
                                                                    each keyed on serviceNames[i]
0x1470e5ab7..c7   lea r9,[rsp+0x70]; xor edx,edx; mov r8d,0x3a98(=15000ms); mov rcx,rdi
                  call 0x147118d80                               <-- GetProfileSync(idx=0)
0x1470e5ad5       mov rax,[rdi+0x80]; mov [rbp+0x3a0],rax        <-- UserId
0x1470e5ae1       mov rax,[rdi+0x88]; mov [rbp+0x3a8],rax        <-- PersonaId

So: the only stimulus for the +0x3a0 write is a well-formed <GetProfileResponse> to the <GetProfile index="0"> FIFA already sends at boot (log id=3). There is nothing else to serve, no event to push, no "connect" verb we are missing. +0x3a0/+0x3a8 are u64 scalars (UserId / PersonaId), not object pointers — the deserializer 0x147136140 maps UserId→resp+0x00, PersonaId→+0x08, Persona→+0x10, AvatarId→+0x30, Country→+0x50, IsUnderAge→+0x70, IsSubscriber→+0x71, GeoCountry→+0x78, CommerceCountry→+0x98, CommerceCurrency→+0xb8, and the response sub-object sits at request+0x80.

0x147118d80 = Origin::OriginSDK::GetProfileSync (both exits converge on lea rax,[rip..] # 0x143945d00 = "Origin::OriginSDK::GetProfileSync"). It is not a poll loop: it builds the request, calls the send-and-wait template 0x1471186f0, which serializes <LSX><Request recipient="X" id="N"><GetProfile index="0" version="3"/></Request></LSX>, then blocks on a timed condvar (0x145e318f0(&req+0x1e8,&req+0x230)) until the reader thread sets [req+0x228]=1. Success is [req+0x1e0]==0 (0x147118851: cmp DWORD PTR [rbx+0x1e0],0x0; sete al).

1.2 The rejection — verified byte-for-byte, live

Response matcher 0x1471189b0 (disassembled from /proc/13643/mem this session):

1471189c6  call [rax+0x10]                       ; reader ok?           -> je FAIL
1471189d7  call [rax+0x58]                       ; root element name
1471189dd  lea rdx,[rip..] # 0x143938024         ; "LSX"  (4-byte compare loop) -> jne FAIL
147118a14  call [rax+0x18]  rdx=[this+0xd8]      ; child "Response"     -> je FAIL
147118a2a  lea rdx,[rip..] # 0x14355de00 = "id"  ; optional -> atoi -> [this+0x138]
147118a5d  lea rdx,[rip..] # 0x143938028 = "sender"
147118a7d  call [rax+0x70]                       ; GetAttributeValue("sender")
147118a80  test rax,rax ; je 0x147118ad9         ; ATTRIBUTE MUST BE PRESENT
147118a85  lea r8,[rdi+0xf8]                     ; expected sender (std::string, SSO at +0x18)
147118a99..aae  inline byte-wise strcmp          ; MISMATCH -> jne FAIL (0x147118ad9)
147118ab3  lea rdx,[rip..] # 0x143945ce8 = "GetProfileResponse" -> je FAIL
147118acf  tail-jump to deserializer

On FAIL, HandleMessage 0x147119210 does call 0x1470e2e30 (look for an <Error> node), and when there is none:

1471192dd  mov edx,0xc347a20f
1471192e4  lea edx,[rdx-0x213fa20f]     ; = 0xA2080000
1471192ef  mov DWORD PTR [rbx+0x1e0],edx
147119311  call 0x145e30d20             ; signal condvar (so it fails FAST, never times out)
147119316  mov BYTE PTR [rbx+0x228],1

The expected string [req+0xf8] and the emitted recipient are the same std::string, taken from sdk->serviceNames[facility] via accessor 0x1470e4870 (cmp edx,0x21; movsxd rax,edx; shl rax,5; add rax,[rcx+0x3b0]), and used symmetrically by the request ctor 0x147117fe0 ("Request"@0x1435a43b8 and "Response"@0x143938198 both get arg2).

Facilities per verb (from the add rdx,0xNN after mov rdx,[sdk+0x3b0], NN/0x20 = idx):

Verb site facility
GetConfig 0x1470e5a46 literal "EbisuSDK" (not table-driven)
GetProfile 0x147118df2 (lea edx,[r12+1]) 1 PROFILE
GetAuthCode 0x1470e6882 (add rdx,0x120) 9 UTILITY
GetSetting / GetGameInfo 0x1470e4903 / 0x1470e3683 (no add) 0 SDK
QueryUserId 0x1470e62b4 26 GET_USERID
IsProgressiveInstallationAvailable / SetDownloaderUtilization 0x147129ddb / 0x14712b821 31 PROGRESSIVE_INSTALLATION (inferred)

1.3 Live state of the table (re-verified myself, pid 13643)

OriginSDK = *[0x144b7c7a0] = 0x25c98c50
  +0x1b8 = 0x1fc                 (conn+0x50 socket, ALIVE -> transport healthy)
  +0x1e8 = 0xffffffffffffffff    (conn+0x80, a SECOND handle, still unset - see §5)
  +0x218 = 0x0                   (pending-request map size)
  +0x270 = 0x1c                  (28 requests issued == our lsx.log last id 27 + 1)
  +0x3a0 = 0x0                   <-- default user STILL NULL
  +0x3a8 = 0x0
  +0x3b0 = 0x25cb93e0 .. +0x3b8 = 0x25cb9820   -> (0x440)/0x20 = 34 entries
  all 34 std::strings: size == 0  ("all-empty")

34 entries allocated proves Initialize did reach and accept our GetConfigResponse (0x1470e0520: lea rbp,[rcx+0x3b0]; lea edx,[rbx+0x22]; call 0x1470eadd0 resizes to 34) — and all empty proves it contained zero <Service> children, because we answer <GetConfigResponse Config="false"/>.

1.4 On the wire — the smoking gun

captures/lsx/full_origin_init_session.log:

id=1  <Request recipient="EALS"     …><ChallengeResponse …>      (plaintext handshake)
id=2  <Request recipient="EbisuSDK" …><GetConfig version="3"/>   -> we reply sender="EbisuSDK"  MATCH  ✔
id=3  <Request recipient=""         …><GetProfile index="0"/>    -> we reply sender="EbisuSDK"  MISMATCH ✘
id=4  <Request recipient=""         …><GetSetting …/>            -> we reply sender=""          MATCH  ✔
id=5  <Request recipient=""         …><GetGameInfo …/>           -> we reply sender=""          MATCH  ✔
… ids 6..14 all recipient=""

Independent live proof that an empty sender attribute really does validate (i.e. vt[0x70] returns a pointer to "", not NULL): FIFA's OSDK failure classifier 0x14717d5d0 only reaches its cmp eax,0xa2000003 branch after OriginGetGameInfoSync(id=0) returns rc==0 and the buffer strncmp-equals "true"@0x14354be6c; otherwise it would set TXT_ORIGIN_GAME_VERSION_OUT_OF_DATE. Live [0x43d189d8+0x80] = 0x14395ca10 = "OSDK_INVALID_USER" and [+0x260] = 16 — so our GetGameInfo(UPTODATE) → GameInfo="true" reply, sent with sender="", was accepted. (It also pins GameInfoId enum 0 == UPTODATE.)

1.5 Events are broken by the same mechanism

Event validator 0x1471015e0 (per report 3) requires root "LSX"@0x143938024, child literal "Event"@0x14365d8fc (len 5), optional id, then call [rax+0x70] for "sender"@0x143938028test rax,rax; je FAIL → inline strcmp against the per-handler string [handler+0x10]. I disassembled the registrar 0x14710df80 live and confirmed that string is serviceNames[facility] — every registration is mov edx,<idx>; call 0x1470e4870; mov rdx,rax; call <per-event registrar>:

0x14710df8a  edx=0x0c IGO_EVENT            -> 0x1470eb6a0
0x14710dfac  edx=0x0f INVITE_EVENT         -> 0x1470eb060      (0xe7ffa20f+0x18005e00 = 0x0f)
0x14710dfcd  edx=0x0e LOGIN_EVENT          -> 0x1470eb100      (0xc390050f+0x3c6ffaff = 0x0e)  << ours
0x14710dfee  edx=0x10 PROFILE_EVENT        -> 0x1470eb1a0
0x14710e009  edx=0x11 PRESENCE_EVENT       -> 0x1470eb240
0x14710e024  edx=0x12 FRIENDS_EVENT        -> 0x1470eb2e0
0x14710e03f  edx=0x13 COMMERCE_EVENT       -> 0x1470eb380
0x14710e05a  edx=0x15 DOWNLOAD_EVENT       -> 0x1470eb420
0x14710e075  edx=0x19 BLOCKED_USER_EVENT   -> 0x1470eb4c0
0x14710e090  edx=0x14 CHAT_EVENT           -> 0x1470ebfc0
0x14710e0ab  edx=0x1b ONLINE_STATUS_EVENT  -> 0x1470eb560      << ours
0x14710e0c6  edx=0x1d ACHIEVEMENT_EVENT    -> 0x1470eb600
0x14710e0e1  edx=0x0f INVITE_EVENT (2nd)   -> 0x1470ebf20
… 0x1e BROADCAST_EVENT, 0x20 PROGRESSIVE_INSTALLATION_EVENT, plus repeats of 0x11/0x13

This corrects report 1's open question #5: LOGIN_EVENT (14) and ONLINE_STATUS_EVENT (27) handlers ARE registered here — they exist and are listening. They just expect sender == "" right now, while we push sender="LOGIN_EVENT" / "LOGIN" / "ONLINE_STATUS_EVENT". All 3219 pushed <Event> frames in /tmp/lsx.log have been silently dropped.

1.6 The connect story, one paragraph

There is no separate "connect" handshake to serve. Initialize connects the socket (already alive, [sdk+0x1b8]=0x1fc), passes the version gate ([sdk+0x360]="10,4,13,6637"), does GetConfig (accepted, table sized to 34 but left empty), registers ~19 event handlers keyed on the now-empty names, and then calls GetProfileSync. Our GetProfileResponse is thrown away for a sender mismatch, so +0x3a0/+0x3a8 stay 0, OriginGetDefaultUser (0x1470da6d0) keeps returning NULL, OriginRequestAuthCodeSync (0x1470e67f0) keeps failing its test rdx,rdx / cmp rdx,[rcx+0x3a0] guards with 0xa2000003, and FIFA's OSDK classifier turns that into OSDK_INVALID_USER.


2. THE BLAZE-LOGIN TRIGGER

2.1 login is compiled in and is exactly 1/0x0A — it is simply never invoked

  • Stub 0x146e15070 ends mov r9d,0xa; movzx r8d,WORD PTR [rdi+0x10]; call 0x146df0e80 (generic sendRequest). Live component id: WORD[0x7c17170+0x10] = 0x0001 = Authentication. Our responder's 1/0x0A = login mapping is correct.
  • Its only caller 0x146e12d83 sits in 0x146e12d20, which appears at vtable slot +0xd0 of all four LoginState vtables (0x14389f828/…938/…a70/…b98).
  • Live: all four LoginManagerImpl (vptr 0x14389f5a0) are still in LoginStateInit (stateMachine+0x28 == +0x08 == stateInit, +0x30 stateId = 0xFFFFFFFF). The state machine has never been asked to advance.
  • Authentication::logout (1/0x46) comes from LoginStateBase::logout = vt+0x48 (0x146e15eb0 → RPC stub 0x146e109d0). It is a normal step of FIFA's OSDK sequence (Connect → LoadConfig → Logout → VersionCheck → Login) and matches our wire log exactly (preAuth → 6× fetchClientConfig → logout, 37 occurrences in /tmp/blaze_responder.log). It is not an error signal and our empty REPLY to it is correct.

2.2 The precondition FIFA checks — it is an Origin check, not a Blaze one

FIFA's own OSDK login state (0x43d189d8, vptr 0x14395c180, Update 0x1471b58e0, jump table 0x141e7f55c):

  • case 0 → mov rcx,[0x144b86bf0]; call [rax+0x60] — live vt[0x60] = 0x146f82070 = xor eax,eax; ret, so it always falls to 0x1471b5b42, sets TXT_NOT_LOGIN_TO_EBISU@0x1439633e8, [state+0x260]=1, and calls the classifier 0x14717d5d0.
  • Classifier 0x14717d5d0: OriginGetGameInfoSync(0) must be rc==0 and "true" (it is — see §1.4), then reads the Ebisu/Origin manager's last error via [rsi]->vt[0x80]() and cmp eax,0xa2000003; jne. On match → [state+0x80] = "OSDK_INVALID_USER"@0x14395ca10, call 0x147190d50(state, 0xe).
  • Live right now: [0x43d189d8+0x260] = 16, [+0x80] = "OSDK_INVALID_USER".

0xa2000003 is precisely OriginRequestAuthCodeSync's "NULL/invalid user" return — OriginSDK+0x3a0 == 0. The dword 0xa2000003 is resident at 0x43d320f8, adjacent to the state heap.

2.3 fetchClientConfig is a dead end for this gate — do not touch it

  • [cfg+0x750] is live non-NULL (0x43c47ff0), holding the Blaze ConnectionManager whose string map at +0x1218 (16 entries, 0x30 bytes each, begin 0x43f56450) is byte-for-byte our Util::preAuth CONFblazeSdkClientId=FIFA17PC, blazeServerClientId=FIFA17PC-SERVER, blazeSdkClientSecret=openfut-secret, identityRedirectUri=http://127.0.0.1/login_successful.html, etc. NUCLEUS_ADDED_URL/NUCLEUS_CREATE_URL (which we only send via fetchClientConfig(CFID=OSDK_NUCLEUS)) are absent, proving the two containers are different.
  • The getter the Blaze auth fetchers use is vt[0x48] = 0x146e1bc50, a binary search over that same [this+0x1218] map; the keys it asks for are "blazeServerClientId"@0x143972690, "blazeSdkClientId"@0x1439726a8, "blazeSdkClientSecret"@0x1439726c0, "identityRedirectUri"@0x1439726d8 — all present.
  • The class that owns blaze_authfetch1/2 (vtable base 0x143972480, not 0x143972560) has zero live instances (full-memory scan for the vptr: 0 hits, while the same scanner finds exactly 1 hit for the config vtable 0x1438a0850). That code never runs. Even if it did, it would bail on call 0x1470da6d0 (GetDefaultUser) → 0x1470db3c00xa2000003, storing it at [this+0x950].

Conclusion: the Blaze-login trigger is OriginSDK+0x3a0 != 0. Once GetProfile validates, the Origin default user exists, OriginRequestAuthCodeSync succeeds, the classifier stops writing OSDK_INVALID_USER, the OSDK machine advances past sub-state 16, and LoginState vt+0xd0 (0x146e12d20) fires Authentication::login (1/0x0A) with no Blaze-side change at all.


3. CONCRETE SERVER-SIDE CHANGES (ranked, minimal)

RANK 1 — lsx_responder_v2.py: echo the request's recipient back as the response's sender ★ do this first

This is the whole fix, and it is correct for both the current empty-table state and any future populated table.

Edits (line numbers as of today's file):

  1. L418 — widen the request regex so we capture recipient:

    REQ_RE = re.compile(
        r'<Request[^>]*\brecipient="([^"]*)"[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>')
    

    (recipient precedes id in every captured frame — ids 1..14 all have <Request recipient="…" id="N">. If you prefer robustness, keep the old REQ_RE and add a separate RECIP_RE = re.compile(r'<Request[^>]*\brecipient="([^"]*)"'), defaulting to "".)

  2. L475-481 — thread it through:

    recip, mid, name, rest = mm.group(1), mm.group(2), mm.group(3), mm.group(4)
    attrs = dict(ATTR_RE.findall(rest))
    reply = build_reply(mid, name, attrs, conn, recip)
    
  3. L312 / L316 — make it the default sender:

    def resp(mid, body, sender=""):        # unchanged
        return f'<LSX><Response id="{mid}" sender="{sender}"><{body}/></Response></LSX>'
    
    def build_reply(mid, req_name, attrs, conn, recipient=""):
        ...   # pass sender=recipient at every return site
    
  4. DELETE the three sender="EbisuSDK" overridesL352 (GetAuthCode), L358 (QueryEntitlements), L371 (GetProfile). These are the only three rejected verbs. L400 (GetConfig) may stay as-is or become sender=recipient; with echo it is identical, since FIFA really does send recipient="EbisuSDK" there (its literal is hard-coded at 0x1470e5a46).

  5. Always emit the attribute, even when empty. 0x147118a80: test rax,rax; je FAIL rejects a missing sender; an empty-valued one is fine (proven in §1.4). resp() already does this — don't "optimise" it away.

  6. L231-232 / L235 — events: with the table empty, the handler strings are empty too, so push sender="":

    LOGIN_EVENT_SENDERS  = ("", "LOGIN_EVENT", "LOGIN")     # "" first; the others are harmless no-ops
    ONLINE_EVENT_SENDERS = ("", "ONLINE_STATUS_EVENT")
    

    Mismatched senders are dropped silently and cost nothing, so keeping the old candidates as a hedge is free. Optionally add id="N" to <Event> (read at 0x14710164c-72handler+0x78, optional).

Observables (restart FIFA; boot GetProfile is id=3):

# Signal How to read
1 Origin Error(a2080000) disappears for GetProfile/GetAuthCode /tmp/lsx.log, FIFA log
2 OriginSDK+0x3a0 and +0x3a8 become 0x1f89493 (33068179) SDK=*[0x144b7c7a0], read +0x3a0 — this is the pass/fail bit
3 [0x43d189d8+0x80] stops being "OSDK_INVALID_USER", [+0x260] leaves 16 live read (state VA may move across restarts — re-find via vptr 0x14395c180)
4 FIFA issues <GetAuthCode ClientId="FIFA17PC"> without our forging anything /tmp/lsx.log
5 Authentication::login (1/0x0A) appears /tmp/blaze_responder.log (currently: 37× logout, 0× login)

Note: [SDK+0x1e8] (= conn+0x80, live -1) is not "service 0x1e8" and is not the success signal BRIEF3 asked for — there is no service to become non-null. Use signal #2 instead. Whether +0x1e8 flips as a side effect is a bonus observation (see §5).


RANK 2 — lsx_responder_v2.py: make GetConfigResponse actually populate the service-name table (EA-faithful; do AFTER rank 1 confirms)

Not required once rank 1 is in (echo is correct for empty names), but it restores the real protocol, gives events non-empty senders, and de-risks any code path that dislikes empty names.

Consumer chain, all verified: matcher 0x1470e304f checks child "GetConfigResponse"@0x143937b30 → deserializer 0x147135610 builds the child element name as prefix + "Service"@0x14394dd78 (len 7; namespaced variant ":Service"@0x14394e1a8 with ns prefix "lsx"@0x14394def0) and loops 0x14713af50 per element → reads "Name"@0x1435604fc into entry+0x00 and "Facility"@0x14394e198 via 0x1471400a00x147140010, which linearly strcmps the value against the 34-pointer table at 0x144341420 and writes the matched index to entry+0x20 (init 0). Then 0x1470e0520 assigns names[entry.index] = entry.name, skipping index > 0x21 and empty names. Records are 40 bytes ({std::string name; int32 index;}), which is why 0x1470e0520 divides by 0x28.

Facility enum strings — read live from 0x144341420 this session (case-exact, required):

 0 SDK                 1 PROFILE              2 PRESENCE            3 FRIENDS
 4 COMMERCE            5 RECENTPLAYER         6 IGO                 7 MISC
 8 LOGIN               9 UTILITY             10 XMPP               11 CHAT
12 IGO_EVENT          13 EALS_EVENTS         14 LOGIN_EVENT        15 INVITE_EVENT
16 PROFILE_EVENT      17 PRESENCE_EVENT      18 FRIENDS_EVENT      19 COMMERCE_EVENT
20 CHAT_EVENT         21 DOWNLOAD_EVENT      22 PERMISSION         23 RESOURCES
24 BLOCKED_USERS      25 BLOCKED_USER_EVENT  26 GET_USERID         27 ONLINE_STATUS_EVENT
28 ACHIEVEMENT        29 ACHIEVEMENT_EVENT   30 BROADCAST_EVENT    31 PROGRESSIVE_INSTALLATION
32 PROGRESSIVE_INSTALLATION_EVENT            33 CONTENT

Reply (keep the envelope sender = echo of recipient, i.e. "EbisuSDK"):

<LSX><Response id="{id}" sender="EbisuSDK"><GetConfigResponse>
  <Service Name="SDK"      Facility="SDK"/>
  <Service Name="PROFILE"  Facility="PROFILE"/>
  ...one line per facility, Name == the Facility enum name...
  <Service Name="CONTENT"  Facility="CONTENT"/>
</GetConfigResponse></Response></LSX>

Choosing Name == Facility is deliberate: with rank 1's echo, every response stays correct automatically, and the pushed events' original senders ("LOGIN_EVENT", "ONLINE_STATUS_EVENT") become correct for the first time. Name is free-form as far as anything we found goes (we own both ends), but see §5.

Ordering hazard (important): populating the table while any verb still hard-codes sender="EbisuSDK" — or while events still push "" — breaks verbs that work today. Rank 1 must land first (or in the same commit). Once echo is in place, rank 2 is a pure superset.

Observable: all 34 std::strings at [SDK+0x3b0] become non-empty (script in §5), and the <Event sender="LOGIN_EVENT"> push stops being dropped.


RANK 3 — blaze_responder_v3b.py: change nothing that matters now; pre-stage the post-login RPCs

  • DO NOT enlarge fetchClientConfig hoping to fill [cfg+0x750] — it is already 0x43c47ff0 and is fed by preAuth. Keep blazeSdkClientId / blazeServerClientId / blazeSdkClientSecret / identityRedirectUri exactly where they are, in blazesdk_config() (preAuth CONF); moving them would break getter 0x146e1bc50.
  • Keep the empty REPLY to Authentication::logout (1/0x46) — normal, not a failure. Fix the misleading log line (~L5935/6777/6947 pattern, emitted around blaze_responder_v3b.py:1091): it currently blames "layer 1 or blazeSdkClientId". Replace with: "logout is a normal OSDK step; the login gate is OriginSDK+0x3a0 != 0 (LSX GetProfile), not Blaze config."
  • Pre-stage Authentication handlers for the stubs actually compiled into this client (found by enumerating all 188 rel32 callers of sendRequest 0x146df0e80 and reading the preceding mov r9d,imm32): 0x0A login, 0x14, 0x1E, 0x26, 0x2D, 0x2F, 0x46 logout, 0xF1, 0xF2, 0xF6, 0x122. 0x1E is the one that will stall you next: callers 0x146e15a6e/0x146e15cbe (vtable 0x14389fb98 slots +0xe8/+0xe0, discriminated by a bool arg) = getTermsOfServiceContent / getPrivacyPolicyContent; LoginStateAuthenticated caches the result at state+0x68/+0x78 (strings "LoginStateAuthenticated::mTermsOfServiceBuffer"@0x14389fe98, "…mPrivacypolicyBuffer"@0x14389fec8) and will hang on an empty body. Serve non-empty text.
  • Expect login's LoginRequest.AUTH to carry the auth code we hand out at lsx_responder_v2.py:352 (OPENFUT_AUTHCODE, default OPENFUT-000000000000000000000000).

4. MEMORY-FORGE SHORTCUT (fallback only — server-side is strictly better)

If rank 1 somehow does not take, you can force the same state. Note +0x3a0 is a u64 scalar (UserId), not a pointer — ENQUEUE_PLAN's "point it at the SDK object" advice works only because the guards are !=0 and ==[sdk+0x3a0]; the correct value is our UserId.

# force_defaultuser.py   (ptrace_scope=0)
import struct
pid = 13643                                   # pgrep -x FIFA17.exe
f   = open(f"/proc/{pid}/mem","r+b")
def q(va): f.seek(va); return struct.unpack('<Q', f.read(8))[0]
sdk = q(0x144b7c7a0)                          # live 0x25c98c50
f.seek(sdk+0x3a0); f.write(struct.pack('<Q', 33068179))   # UserId    0x1f89493
f.seek(sdk+0x3a8); f.write(struct.pack('<Q', 33068179))   # PersonaId 0x1f89493

Raw bytes: at SDK+0x3a0 write 93 94 f8 01 00 00 00 00, and the same eight bytes at SDK+0x3a8.

This alone satisfies OriginGetDefaultUser (0x1470da6d0) and both guards in OriginRequestAuthCodeSync (0x1470e67f0) (test rdx,rdx / cmp rdx,[rcx+0x3a0]). It does not fix the response matcher, so the resulting <GetAuthCode> reply would still be rejected with 0xa2080000 unless rank 1 is also in — i.e. the forge is only useful as a same-run A/B control ("does +0x3a0 != 0 really clear OSDK_INVALID_USER?"), not as a shipping path.

Explicitly not recommended any more: forging the FirstPartyAuthCodeFutureImpl node (ENQUEUE_PLAN §1/§3a). With the matcher fixed, FIFA enqueues it itself, and the forged-node route leaves the OSDK state machine untouched — which is why the earlier forge produced a <GetAuthCode> on the wire but never a Blaze login.


5. STILL NEEDS A LIVE EXPERIMENT

  1. The whole of §3 rank 1 needs a FIFA restart. GetProfile has not recurred since log id=18 (line 1068 of 3385) and +0x3a0 is still 0 in pid 13643 — the running instance will not self-heal. Initialize runs once.
  2. Name semantics in <Service>. Nothing in 0x1470d0000-0x147160000 compares a service Name against a literal, but the search was not exhaustive. If some path does, Name == Facility-enum-name would break and real Origin-client Names would be needed. Cheap probe: rank 2 with Name==Facility; if a verb that worked under rank 1 starts failing, that is the culprit.
  3. Facility index for GetInternetConnectedState, SetDownloaderUtilization, IsProgressiveInstallationAvailable is inferred (9 / 31 / 31). Echo-back makes this moot for rank 1; it only matters if you ever hard-code senders again. Confirm by reading the add rdx,0xNN after mov rdx,[sdk+0x3b0] at 0x1470e040a, 0x14712b821, 0x147129ddb.
  4. [SDK+0x1e8] (= conn+0x80, live -1) — a second connection handle, read by 0x14712cc00 and waited on by 0x1470e6b70 (condvar [sdk+0xf8], alt-wake [sdk+0x240], both live 0). Its writer is untraced; the conn object at SDK+0x168 has vptr 0x14394c218 with 2 slots (0x14712c9b0, 0x14712ce00) — disassemble those. Watch whether it flips after rank 1; if it stays -1 and something blocks on 0x1470e6b70, that is the next gate. Also unidentified: 0x14712cc30(SDK+0x168, &outString), called after that wait, whose result is inserted into the map at SDK+0x1f8 (size [SDK+0x218], live 0).
  5. Which of UserId / PersonaId lands in +0x3a0 vs +0x3a8 is unobservable while we send the same number for both. Make them differ once, then read both slots.
  6. Does the fixed chain reach Blaze in one go, or is there a further OSDK sub-state after 16? Watch [0x43d189d8+0x260] (re-find via vptr 0x14395c180) walk off 16 and LoginManagerImpl+0x148 → stateMachine+0x30 leave 0xFFFFFFFF.
  7. After login fires, Authentication 0x1E (ToS / privacy content) is the next likely stall — see rank 3.
  8. Post-fix event senders. Confirm the LOGIN_EVENT/ONLINE_STATUS_EVENT handlers stop dropping frames: under rank 1 push sender=""; under rank 2 push sender="LOGIN_EVENT". Note the earlier claim that 0xa2080000 in the log means failure is weak for events — with 73 EventHandler template instantiations, non-matching handlers log it as ordinary fan-out noise. Use OriginMgr(*[0x1448acf50])+0x13 (m_isLoggedIn) flipping to 1 by itself as the event observable.

Live verification snippet (run before and after)

#!/usr/bin/env python3
import struct, glob
pid = next(int(d.split('/')[-1]) for d in glob.glob('/proc/[0-9]*')
           if open(d+'/comm').read().strip() == 'FIFA17.exe')
f = open(f"/proc/{pid}/mem", "rb")
def rd(va,n): f.seek(va); return f.read(n)
def q(va):    return struct.unpack('<Q', rd(va,8))[0]
sdk = q(0x144b7c7a0)
print(f"SDK={sdk:#x} +0x3a0={q(sdk+0x3a0):#x} +0x3a8={q(sdk+0x3a8):#x} "
      f"+0x1e8={q(sdk+0x1e8):#x} +0x270={q(sdk+0x270):#x}")
b, e = q(sdk+0x3b0), q(sdk+0x3b8)
for i in range((e-b)//0x20):
    s = rd(b+i*0x20, 0x20)
    n, cap = struct.unpack('<Q', s[0x10:0x18])[0], struct.unpack('<Q', s[0x18:0x20])[0]
    if n:
        print(i, (rd(struct.unpack('<Q', s[:8])[0], n) if cap >= 16 else s[:n]).decode())

Baseline recorded today (pid 13643): +0x3a0=0x0 +0x3a8=0x0 +0x1e8=0xffffffffffffffff +0x270=0x1c, all 34 names empty, [0x43d189d8+0x80]="OSDK_INVALID_USER", [+0x260]=16, /tmp/blaze_responder.log: 37× logout, 0× login.