6ddd5e9d47
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
291 lines
15 KiB
Markdown
291 lines
15 KiB
Markdown
# 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`):
|
|
|
|
```xml
|
|
<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):
|
|
|
|
```xml
|
|
<LSX><Event sender="ONLINE_STATUS_EVENT"><OnlineStatusEvent isOnline="true"/></Event></LSX>
|
|
```
|
|
|
|
Optional, keeps the profile consistent:
|
|
|
|
```xml
|
|
<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:
|
|
|
|
```xml
|
|
<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>`.
|
|
* `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)))`):
|
|
|
|
```python
|
|
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.
|