Files
OpenFUT/fifa17-recon/tools/origin_loginstate.md
T
funman300 6ddd5e9d47 fifa17-recon: offline FUT squad-shell working + full card-system RE
Milestone: FIFA 17 Ultimate Team boots end-to-end on our offline backend
past every EA gate into the hub and a live Squads editor (correct 4-4-2,
5-star squad, no freezes).

Key findings this session:
- userMassInfo MUST stay {} (any content desyncs the massinfo parser
  0x180174630 -> tokenizer busy-loop freeze). Deliver the squad via
  GET /squad/0 (fetched on Squads-tab entry) instead.
- Player cards render generic because the card view-model (0x1800d7920)
  reads identity/rating/face from a resolved record at item+0x10, filled
  by a lookup (0x18011cca0) in the FUT item-definition std::map at
  CardsDb+0x160c0 -- which is EMPTY offline -> default blank record.
- Version advertising (itemDbVersion/checkServerDbVersion) is proven inert
  (JSON fields routed to the skip handler). Owned items don't auto-trigger
  a definition fetch. In-place map overwrite is dead (map stays empty).
- Definition-serving endpoints (item/resource, defid, item?idList) built +
  ready; the fetch trigger lives in the packed FIFA17.exe.

New: docs/CARD_SYSTEM.md (findings + ordered next-steps plan for real
player cards: patch-POC, dbdata extractor, drive FIFA17.exe fetch, or
live-memory store injection). Plus tools: fut_seed.py (squad ladder +
definition serving), fifadrive.sh, vgamepad.py, and the login-RE toolset.

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

15 KiB

FIFA 17 OriginSDK — what makes the game consider the user LOGGED IN (LSX events)

Clean-room RE. Sources: /mnt/games/FIFA 17/FIFA17.exe (fully symboled; .text is runtime-decrypted, so all code analysis was done on live /proc/PID/mem dumps taken from the running game — live_code.bin @0x140001000, live_high.bin @0x144ed3000, both in this scratchpad), plus our own LSX captures. No leaked material used.

Helper tools written for this pass (scratchpad): dumplive.py, disasm_helper.py, fndis.py (disassemble + auto-annotate rip-relative string refs), fnstart.py, callers.py, xref2.py, bulkxref.py, symmap.py (turns the binary's own Class::Method log strings into a symbol map).


VERDICT

The prime hypothesis is CORRECT. The OriginSDK has a distinct "user is logged in" state that is delivered ONLY as a server-PUSHED LSX <Login> event. Our request-only responder never sends it, so FIFA's Origin manager has had m_isLoggedIn = false for the entire session — independently of GetInternetConnectedState connected="1".

Add this pushed message to lsx_responder.py (encrypted like any other post-handshake message: hex(AES128-ECB(session_key, pkcs7(xml))) + \0):

<LSX><Event sender="LOGIN_EVENT"><Login IsLoggedIn="true"/></Event></LSX>

sender="LOGIN_EVENT" is mandatory and exact (proof below). IsLoggedIn is parsed as a bool by literal comparison against "false" — anything that is not the exact string false means true, so "true" or "1" both work; "false" / missing attribute means logged out.

Strongly recommended companion (FIFA also subscribes to this one, and it is likewise push-only — never requestable):

<LSX><Event sender="ONLINE_STATUS_EVENT"><OnlineStatusEvent isOnline="true"/></Event></LSX>

Optional, keeps the profile consistent:

<LSX><Event sender="PROFILE_EVENT"><ProfileEvent Changed="0" UserId="33068179"/></Event></LSX>

When to send: any time after the client's first post-handshake request (the event handler array is built inside Origin::OriginSDK::Initialize, which completes before the client issues GetConfig). Practical recipe: push <Login> right after we answer the first GetProfile (id=3), and push it again after answering GetInternetConnectedState (id=16/18) and after GetGameInfo UPTODATE. Re-sending is harmless — the handler is idempotent (it just sets a flag and broadcasts).


Evidence chain

1. The SDK's LSX event surface (28 push-only messages)

Origin::OriginSDK::RegisterEventCallback (0x14710e710) indexes a fixed array of pre-constructed handlers at sdk + 0x278 + 8*eventEnum (29 slots — lea ebp,[rdx+0x1d] in the unregister-all path).

Every handler's matcher has the identical shape (e.g. Origin::EventHandler<lsx::LoginT,unsigned int>::HandleMessage @0x14710c7d0, matcher @0x147102800):

root element must be "LSX"
enter element   <Event>                      (name comes from handler, always "Event")
optional attr    id="..."                    (HasAttribute check — may be omitted)
REQUIRED attr    sender="..."                (must be PRESENT and strcmp-equal to handler->sender)
enter element   <Login> / <OnlineStatusEvent> / ...   (hardcoded per handler)
then parse that element's attributes

So the wire form for every event is:

<LSX><Event sender="<SERVICE>"><ElementName attr="..."/></Event></LSX>

Full set of inner element names the SDK listens for (extracted from the 28 matchers in 0x147101000..0x14710a000):

AchievementSets            BlockListUpdated         BroadcastEvent        ChatMessageEvent
ChunkStatus                CoreContentUpdated       CurrentUserPresenceEvent
GameMessageEvent           GetPresenceResponse      GroupEnterEvent       GroupEvent
GroupInviteEvent           GroupLeaveEvent          IGOEvent              IGOUnavailable
Login                      MinimizeRequest          MultiplayerInvite     MultiplayerInvitePending
OnlineStatusEvent          PresenceVisibilityEvent  ProfileEvent          PurchaseEvent
QueryEntitlementsResponse  QueryFriendsResponse     RestoreRequest        UserInvitedEvent
VoipStatusEvent

(plus the separate LSXEvent<lsx::ChallengeT> = the <Challenge> handshake event we already send.)

2. sender value = the SDK's service-name enum. Login ⇒ LOGIN_EVENT

0x14710df7f is the SDK's "create all event handlers" routine. For each handler it does:

edx = <service enum>;  rax = GetServiceName(sdk, edx)     ; 0x1470e4870 -> [sdk+0x3b0] + 0x20*edx
call <MakeHandlerN>(sdk, rax /*sender string*/, r8 = sdk+0x278 /*array*/)

GetServiceName's runtime table is initialised from the static string-pointer table at 0x144341420:

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

The handler's sender string lands at handler+0x48, which is this+0x10 for the IEventHandler sub-object (secondary vtable at object+0x38) — exactly what HandleMessage reads (mov rbx,[rcx+0x10]) and strcmp's the sender attribute against.

Resolved handler table (service enum → array slot → OriginEventT enum → element):

OriginEventT array slot service enum sender string element
0 +0x00 0x0c IGO_EVENT IGOEvent
1 +0x08 0x0f INVITE_EVENT MultiplayerInvite
2 +0x10 0x0e LOGIN_EVENT Login
3 +0x18 0x10 PROFILE_EVENT ProfileEvent
4 +0x20 0x11 PRESENCE_EVENT GetPresenceResponse
5 +0x28 0x12 FRIENDS_EVENT QueryFriendsResponse
6 +0x30 0x13 COMMERCE_EVENT PurchaseEvent
7 +0x38 0x15 DOWNLOAD_EVENT CoreContentUpdated
8 +0x40 0x19 BLOCKED_USER_EVENT BlockListUpdated
9 +0x48 0x1b ONLINE_STATUS_EVENT OnlineStatusEvent
10 +0x50 0x1d ACHIEVEMENT_EVENT AchievementSets
11 +0x58 0x0f INVITE_EVENT MultiplayerInvitePending
12 +0x60 0x14 CHAT_EVENT ChatMessageEvent
13 +0x68 0x11 PRESENCE_EVENT CurrentUserPresenceEvent
14 +0x70 0x1e BROADCAST_EVENT BroadcastEvent
15 +0x78 0x11 PRESENCE_EVENT PresenceVisibilityEvent
16 +0x80 0x13 COMMERCE_EVENT QueryEntitlementsResponse
17 +0x88 0x20 PROGRESSIVE_INSTALLATION_EVENT ChunkStatus
18 +0x90 0x0c IGO_EVENT IGOUnavailable

(13 independent service-enum/element pairings all agree with the table — the mapping is not a guess.)

3. <Login> carries exactly one attribute: IsLoggedIn, parsed as a bool

Deserializer 0x147138640 (reached via 0x14712fd80 from the Login matcher) reads exactly one attribute name, "IsLoggedIn" (string @0x14394e0f0), then converts with 0x14713ffa0:

0x14713ffc8:  lea rdx, "false"
0x14713ffd2:  call strcmp
0x14713ffda:  setne al          ; value != "false"  =>  true
0x14713ffdd:  mov [rdi], al     ; stored as a byte

Same helper is used for <OnlineStatusEvent isOnline="..."> (0x147139e6e) and <PresenceVisibilityEvent Visible="...">. <ProfileEvent> carries Changed + UserId.

4. FIFA subscribes to the Login event, and it is the ONLY event that mutates

FIFA's Origin login state

FIFA registers 9 Origin event callbacks in a loop at 0x146f33e90+ (call site 0x146f34068OriginRegisterEventCallback @0x1470db310), from a constant array:

xmm0 @0x143565970 = {0,1,2,3}
xmm1 @0x143902060 = {4,5,6,7}
plus                 9
=> enums {0,1,2,3,4,5,6,7,9}   (all with the same callback 0x146f20c20)

Cross-referenced against the table above, FIFA subscribes to exactly: IGOEvent, MultiplayerInvite, Login, ProfileEvent, GetPresenceResponse, QueryFriendsResponse, PurchaseEvent, CoreContentUpdated, OnlineStatusEvent.

0x146f20c20 forwards to the FIFA Origin-manager dispatcher 0x146f1e060 (manager singleton via 0x146f28790, global [0x1448acf50]). Its case 2 is the only case that writes state:

0x146f1e099:  cmp edx, 2                  ; eventEnum == 2 (Login)
0x146f1e09e:  cmp DWORD PTR [r9], 1       ; converted IsLoggedIn == 1 ?
0x146f1e0a2:  lea rbx,[rcx+0x80]          ; listener list for event 2
0x146f1e0ab:  mov BYTE  PTR [rcx+0x13], 1 ; <== OriginMgr.m_isLoggedIn = TRUE
0x146f1e0af:  mov DWORD PTR [rcx+0x14], 0 ; <== clear login error/reason
   (else)
0x146f1e0b8:  mov BYTE  PTR [rcx+0x13], 0 ; m_isLoggedIn = FALSE
0x146f1e116:  broadcast to listener list

All other cases (0,1,3,4,5,6,7,9) only broadcast to their listener list. OriginMgr+0x13 is written from exactly two places in the whole image: this Login-event case, and 0x146f20c80 (the async login/CheckOnline completion callback, which also clears +0x18). There is no requestable LSX verb that sets it — it is unreachable without a pushed <Login>.

5. Why this matters: GetAuthCode is enqueue-driven and has never been enqueued

  • FIFA-level auth-code issuer: FifaOnline::FirstPartyAuthTokenRetriever::DoTick @0x146f199b9OriginRequestAuthCodeSync @0x1470db3c0Origin::OriginSDK::RequestAuthCodeSync @0x1470e67f0 → LSX <GetAuthCode>.
  • DoTick walks a 2-slot request array at retriever+0x8 and does nothing when both slots are null (0x146f199e0: mov rsi,[rbx]; test rsi,rsi; je <exit>).
  • Enqueue path: RequestAuthCode @0x146f5b8ab, reached via thunk 0x146f57bf0 (mgr = *[0x1448a3b20] gated by byte [0x1448a3ac3]; retriever = mgr + 0x4e98).
  • Live read of the stuck game (PID 3362053) confirmed: [0x1448a3ac3] = 1, mgr = 0x43dc3e70, retriever = 0x43dc8d08, both slots = 0x0 — no auth-code request has ever been created. This is why /tmp/openfut_authcode.txt stays empty and GetAuthCode never appears in the LSX log. The SDK itself has no login gate inside RequestAuthCodeSync; the gate is entirely FIFA-side, upstream of the enqueue.

6. Corroborating: LoginStatePCLogin has an explicit "not logged in to Origin" abort

FIFA's Blaze login state machine (LoginStateMachineImpl, LoginStateBase subclasses; per-state GetName thunks in 0x14719b360..0x14719b4a7; states: ShowMaintenance, Isp, LoadIspAccountInfo, Connect, LoadConfig, Logout, VersionCheck, Login, PCLogin, VerifyAccount, UpgradeAccount, LoginComplete, Unsuspend, CheckUser, RecheckUser, WebOffer).

LoginStatePCLogin vtable @0x14395c188; its driver is 0x1471b58e0 (a 0x19-case sub-state machine on this+0x260). Its very first sub-state does:

0x1471b59a5:  rcx = *[0x144b86bf0]; call [vt+0x60]      ; get the Origin/Ebisu session object
0x1471b59b5:  test rax,rax
0x1471b59b8:  je   0x1471b5b42                          ; NULL -> failure branch
...
0x1471b5b64:  lea rax, "TXT_NOT_LOGIN_TO_EBISU"         ; loc key stored at state+0x80
0x1471b5b72:  mov DWORD PTR [r14+0x260], 1              ; -> error sub-state

("Ebisu" is EA's internal codename for Origin; the sibling key TXT_ORIGIN_GAME_VERSION_OUT_OF_DATE is the "title version outdated" gate we already beat via GetGameInfo UPTODATE. Both live at 0x1439633e8 / 0x14395bfc0-ish in the same loc-key block.)

Also proven live: FIFA's separate "Origin is online" byte [0x1448a3ac0] was 1 during the stuck session, so the internet/online gate (fed by GetInternetConnectedState, callback 0x146f1e6ae, broadcasts FE::FIFA::OriginOnlineEvent) is already satisfied. Online ≠ logged in. They are two different flags with two different feeds; we only ever fed the first one.


Wire recipe for lsx_responder.py

Same framing as the existing <Challenge> event we already push successfully (NUL-terminated; after the handshake everything is hex(AES128-ECB(session_key, pkcs7(xml)))):

def push_event(conn, key, xml):
    conn.sendall(lsx_encrypt(xml, key))

LOGIN_EVENT  = '<LSX><Event sender="LOGIN_EVENT"><Login IsLoggedIn="true"/></Event></LSX>'
ONLINE_EVENT = '<LSX><Event sender="ONLINE_STATUS_EVENT"><OnlineStatusEvent isOnline="true"/></Event></LSX>'
PROFILE_EVT  = '<LSX><Event sender="PROFILE_EVENT"><ProfileEvent Changed="0" UserId="33068179"/></Event></LSX>'

Trigger points (after sending our normal <Response>):

  1. after answering the first GetProfile → push LOGIN_EVENT, then ONLINE_EVENT
  2. after answering GetInternetConnectedState → push LOGIN_EVENT again
  3. after answering GetGameInfo UPTODATE → push LOGIN_EVENT + ONLINE_EVENT again

Notes / gotchas:

  • sender must be exactly LOGIN_EVENT / ONLINE_STATUS_EVENT. A wrong or missing sender makes HandleMessage return false and the message is silently dropped (no error, no crash) — which is exactly the failure mode to watch for.
  • An id attribute on <Event> is optional.
  • Sending an event before OriginSDK::Initialize has built the handler array is a silent no-op, so never send before the client's first request.
  • If the game still doesn't call GetAuthCode after this, the next thing to instrument is OriginMgr+0x13 (byte) and OriginMgr+0x14 (dword) via /proc/PID/mem (OriginMgr = *[0x1448acf50]): +0x13 flipping 0→1 proves the event landed and moves the investigation downstream to LoginStatePCLogin / the Blaze Authentication::logout loop.

Open / not proven

  • I could not statically locate the consumer that reads OriginMgr+0x13 (no matching [reg+0x13] byte-read in the FIFA online region) — it is presumably an inlined accessor or a reaction to the event-2 broadcast. So "flag false ⇒ GetAuthCode never enqueued" is a strong inference from (a) the flag being login-specific, (b) LoginStatePCLogin's TXT_NOT_LOGIN_TO_EBISU abort and (c) the live-confirmed empty auth-code slots — but the exact read site is unconfirmed. Instrumenting +0x13 live is the cheap way to close this.
  • The one identified event-2 listener list subscriber is the EAStore/DLC subsystem (registrar 0x14735077f, listener 0x147350e30), not the login flow — consistent with the login gate reading the flag rather than listening.
  • The Blaze side still ends in Authentication::logout (1/0x46) → disconnect →3s ping-reconnect loop (/tmp/blaze_responder.log). If the Login event does not change that, the second candidate is the FIFA LoginStateMachine transition out of LoginStateLogout, which is a separate (Blaze-side) investigation.