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
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 0x146f34068 →
OriginRegisterEventCallback @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@0x146f199b9→OriginRequestAuthCodeSync@0x1470db3c0→Origin::OriginSDK::RequestAuthCodeSync@0x1470e67f0→ LSX<GetAuthCode>. DoTickwalks a 2-slot request array atretriever+0x8and does nothing when both slots are null (0x146f199e0: mov rsi,[rbx]; test rsi,rsi; je <exit>).- Enqueue path:
RequestAuthCode@0x146f5b8ab, reached via thunk0x146f57bf0(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.txtstays empty andGetAuthCodenever appears in the LSX log. The SDK itself has no login gate insideRequestAuthCodeSync; 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>):
- after answering the first
GetProfile→ pushLOGIN_EVENT, thenONLINE_EVENT - after answering
GetInternetConnectedState→ pushLOGIN_EVENTagain - after answering
GetGameInfo UPTODATE→ pushLOGIN_EVENT+ONLINE_EVENTagain
Notes / gotchas:
sendermust be exactlyLOGIN_EVENT/ONLINE_STATUS_EVENT. A wrong or missingsendermakesHandleMessagereturn false and the message is silently dropped (no error, no crash) — which is exactly the failure mode to watch for.- An
idattribute on<Event>is optional. - Sending an event before
OriginSDK::Initializehas 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
GetAuthCodeafter this, the next thing to instrument isOriginMgr+0x13(byte) andOriginMgr+0x14(dword) via/proc/PID/mem(OriginMgr = *[0x1448acf50]): +0x13 flipping 0→1 proves the event landed and moves the investigation downstream toLoginStatePCLogin/ the BlazeAuthentication::logoutloop.
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'sTXT_NOT_LOGIN_TO_EBISUabort 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, listener0x147350e30), 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 FIFALoginStateMachinetransition out ofLoginStateLogout, which is a separate (Blaze-side) investigation.