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
275 lines
14 KiB
Markdown
275 lines
14 KiB
Markdown
# FIFA 17 offline auth — error trace & state machine
|
||
|
||
Clean-room. All facts below come from (a) the running `FIFA17.exe` we own (PID 19517,
|
||
`/proc/PID/mem`, ptrace_scope=0), (b) the Steampunks Origin stub on disk/in memory, and
|
||
(c) our own Blaze session log. **No leak material used.**
|
||
|
||
Module base `0x140000000` (Wine maps the PE flat).
|
||
|
||
---
|
||
|
||
## 0. Verdict (answer to the CRITICAL question)
|
||
|
||
**The blocker is LAYER 1 — the ORIGIN / LSX online-state check. It sits in front of Blaze
|
||
auth as a hard gate, and Blaze login is never even attempted.**
|
||
|
||
Three independent proofs:
|
||
|
||
1. **The frontend flow graph gates Blaze login behind the Origin check.** Recovered verbatim
|
||
from the live FeFlow JSON (`0x41bc5df4`):
|
||
|
||
```json
|
||
{ "name":"launchFUTFlow", "type":"external", "file":"/online/origin.nav",
|
||
"outputs":{ "OriginIsOnlineTrue":"startFutBlazeLogin", "quit":"mainMenu" } },
|
||
{ "name":"futBlazeLogin", "type":"external", "file":"/online/onlineLoginFlow.nav",
|
||
"inputs":{ "startFutBlazeLogin":"startLoginWithoutMultiplayerCheck" },
|
||
"outputs":{ "loginSuccess":"CheckFUTRosters", "loginFail":"mainMenu" } },
|
||
{ "name":"CheckFUTRosters", "type":"external", "file":"/checkFUTRostersFlow.nav",
|
||
"outputs":{ "advance":"postFUTBlazeLogin", "back":"mainMenu" } },
|
||
{ "name":"postFUTBlazeLogin", ... "transitions":[{"event":"advanceRequest","targets":["futFlow"]}] }
|
||
```
|
||
|
||
`origin.nav` has exactly two exits. Only `OriginIsOnlineTrue` reaches `startFutBlazeLogin`;
|
||
anything else is `quit` → `mainMenu`. **Blaze is strictly downstream of Origin.**
|
||
|
||
2. **The Origin stub is physically incapable of reporting online.** Unpacked image of
|
||
`/mnt/games/FIFA 17/stp-origin_emu.dll` at `0x6ffffc931000-0x6ffffc93d000` contains its
|
||
*entire* response repertoire — 11 templates + 1 event:
|
||
|
||
```
|
||
0x6ffffc9353b0 <LSX><Response id="%d" sender=""><InternetConnectedState connected="0"/></Response></LSX>
|
||
0x6ffffc9351d0 <LSX><Response id="%d" sender=""><ErrorSuccess Code="0" Description=""/></Response></LSX>
|
||
0x6ffffc9352b0 <LSX><Response id="%d" sender="EbisuSDK"><GetProfileResponse IsSubscriber="true"
|
||
PersonaId="%llu" AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US"
|
||
UserId="%llu" Persona="%s" IsUnderAge="false" CommerceCurrency="USD"/></Response></LSX>
|
||
0x6ffffc935470 ...GetConfigResponse Config="false"...
|
||
0x6ffffc935050/0x935170/0x935530 ...GetSettingResponse Setting="%s" / "false" / "production"...
|
||
0x6ffffc9350b0/0x9354d0 ...GetGameInfoResponse GameInfo="<locales>" / "false"...
|
||
0x6ffffc935230 ...IsProgressiveInstallationAvailableResponse ItemId="" Available="false"...
|
||
0x6ffffc935410 <LSX><Response id="%d" sender="EALS"><ChallengeAccepted response="%s"/></Response></LSX>
|
||
0x6ffffc935590 <LSX><Event sender="EALS"><Challenge key="2b8ee..." build="release" version="10,4,13,6637"/></Event></LSX>
|
||
```
|
||
|
||
`connected="0"` is a **hardcoded literal — there is no `connected="1"` variant anywhere in
|
||
the module**. There is likewise **no `AuthCodeResponse`, no `GetAuthTokenResponse`, no
|
||
`QueryEntitlementsResponse`** template. Its only imports are `GetPrivateProfileIntA/StringA`
|
||
(reads `stp-origin_emu.ini`), `SetEnvironmentVariableA`, `CreateThread`, `getaddrinfo`,
|
||
`sprintf_s`, `sscanf_s`, `strstr`. It is an offline activation stub, not an Origin emulator.
|
||
|
||
3. **The client never asked for an auth code.** Scanning all of live memory for LSX frames
|
||
finds `GetInternetConnectedState`, `GetGameInfo`, `GetSetting`, `GetProfile`,
|
||
`IsProgressiveInstallationAvailable` requests/responses — but **zero `GetAuthCode`
|
||
requests**. It stops at the online check.
|
||
|
||
---
|
||
|
||
## 1. The error string → exact trigger
|
||
|
||
The message is a **localization entry**, not a code literal (all copies live in heap/loc data,
|
||
none in the module), so it is reached by loc key:
|
||
|
||
| Item | VA | Value |
|
||
|---|---|---|
|
||
| loc key | `0x143b16360` | `TXT_ORIGIN_OFFLINE_POPUP_TEXT` |
|
||
| popup spec | `0x143b16380` | `ORIGIN_OFFLINE_POPUP\|%s\|Ok\|Ok` |
|
||
|
||
Both have **exactly one xref each**, inside one function:
|
||
|
||
```
|
||
0x147c3c050 <- function entry (the FeFlow action "onlineLoginPopupShow" handler)
|
||
0x147c3c09a call 0x146f38aa0 ; -> returns g_originOnline
|
||
0x147c3c09f test al,al
|
||
0x147c3c0a1 jne 0x147c3c1b3 ; ONLINE -> skip, fall through to normal login popups
|
||
... ; OFFLINE -> build the Origin popup:
|
||
0x147c3c108 lea r8, [0x143b16360] ; "TXT_ORIGIN_OFFLINE_POPUP_TEXT"
|
||
0x147c3c121 lea rdx,[0x143b16380] ; "ORIGIN_OFFLINE_POPUP|%s|Ok|Ok"
|
||
0x147c3c147 lea r8, [0x1438feea8] ; "ShowPopup"
|
||
0x147c3c14e lea rdx,[0x1438fc240] ; "_global"
|
||
```
|
||
|
||
The predicate is a bare global read:
|
||
|
||
```
|
||
0x146f38aa0 call 0x147199590 ; singleton getter: mov rax,[0x144b86bf0]; ret (no refresh)
|
||
0x146f38aa9 movzx eax, BYTE PTR [0x1443337f8] ; <<< g_originOnline
|
||
0x146f38ab4 ret
|
||
```
|
||
|
||
`g_originOnline @ 0x1443337f8` has exactly **one writer** — the LSX
|
||
`GetInternetConnectedState` callback:
|
||
|
||
```
|
||
0x146f1e6b0 sub rsp,0x58
|
||
0x146f1e6b4 test r8,r8 ; je out ; r8 = result struct
|
||
0x146f1e6d0 movzx eax, BYTE PTR [r8] ; the parsed `connected` attribute
|
||
0x146f1e6d9 mov BYTE PTR [0x1443337f8], al ; <<< store
|
||
0x146f1e6e8 lea rdx,[0x1438fe758] ; "FE::FIFA::OriginOnlineEvent"
|
||
0x146f1e737 call [r10+0x48] ; broadcast the event
|
||
```
|
||
|
||
**Chain:** LSX `GetInternetConnectedState` → callback `0x146f1e6b0` → `g_originOnline` +
|
||
`FE::FIFA::OriginOnlineEvent` → FeFlow events → `origin.nav` output → and, on the popup path,
|
||
`onlineLoginPopupShow` → `ORIGIN_OFFLINE_POPUP` / `TXT_ORIGIN_OFFLINE_POPUP_TEXT`.
|
||
|
||
### FeFlow IDs (from the registration table at `0x147de8480`+)
|
||
|
||
| Name | VA | FeFlow ID |
|
||
|---|---|---|
|
||
| `onlineLoginPopupHide` | `0x143b4c918` | `0x27a9` |
|
||
| `onlineLoginPopupShow` | `0x143b4c900` | `0x27aa` |
|
||
| `checkOriginConnected` | `0x143b4cb40` | `0x27e1` |
|
||
| `OriginIsOnline` | `0x143b4cb58` | `0x27e2` |
|
||
| `OriginIsOffline` | `0x143b4cb68` | `0x27e3` |
|
||
|
||
### Caveat worth knowing (honest reading of the live state)
|
||
|
||
`g_originOnline` currently reads **`0x01`**, not 0. The stub answered the *first*
|
||
`GetInternetConnectedState` (id 17) with a well-formed `connected="0"`, but answered the
|
||
**later ones (ids 19–22) with a generic `ErrorSuccess Code="0"`** — a *type-mismatched* reply
|
||
to a `GetInternetConnectedState` request (observed verbatim at `0x28793008`/`0x28793668`/
|
||
`0x28793728`…). The client's Origin SDK finds no `connected` attribute to parse, so the byte
|
||
it stores is stale/garbage. Net effect: **the online flag is non-deterministic garbage, never
|
||
a genuine "online".** This is consistent with the flow still failing while the byte happens to
|
||
read 1, and it means fixing the LSX layer must make *every* `GetInternetConnectedState` return
|
||
a well-formed `connected="1"`, not just the first.
|
||
|
||
---
|
||
|
||
## 2. Order of operations the client actually performs
|
||
|
||
**Observed LSX order (boot, on 127.0.0.1:4216, in-process):**
|
||
`IsProgressiveInstallationAvailable` (id 9) → `GetSetting` (→"production") → `GetGameInfo`
|
||
(`GameInfoId="FREETRIAL"`, id 15) → `GetGameInfo` (id 16) → **`GetInternetConnectedState`
|
||
(id 17) → `connected="0"`** → `GetProfile` (→ `Persona="CAGE"`) → repeated
|
||
`GetInternetConnectedState` polls (ids 19–22) → all answered `ErrorSuccess`.
|
||
**`GetAuthCode` is never reached.**
|
||
|
||
**FeFlow order:**
|
||
`mainMenu` → `launchFUTFlow` (`/online/origin.nav`, action `checkOriginConnected` 0x27e1)
|
||
→ **[GATE]** `OriginIsOnline` 0x27e2 → output `OriginIsOnlineTrue`
|
||
→ `futBlazeLogin` (`/online/onlineLoginFlow.nav`, entry state `startLoginWithoutMultiplayerCheck`,
|
||
which fires `sendScreenEvent ["OnlineLogin","0"]`)
|
||
→ on success event `loginSuccess` → `TrialWelcomeCheck` → `CheckFUTRosters`
|
||
→ `advance` → `postFUTBlazeLogin` → `futFlow` (`/fut/futFlow.nav`).
|
||
|
||
Failure branches inside `onlineLoginFlow.nav`: `evt_onlineLoginFailurePopup` /
|
||
`evt_onlineBootLoginFailurePopup` → `onlineFailureLoginPopup` → `processLoginFailure`
|
||
→ `loginFail` → `mainMenu`. Every one of those popups renders via the C++ action
|
||
`onlineLoginPopupShow` — i.e. the same function that prints the Origin-offline text.
|
||
|
||
**Observed Blaze order (our session log):** `Util::preAuth` (9/0x07) → `Util::ping` (9/0x02)
|
||
→ `Util::fetchClientConfig` (9/0x01) ×6 for `OSDK_CORE`, `OSDK_CLIENT`, `OSDK_NUCLEUS`,
|
||
`OSDK_WEBOFFER`, `OSDK_ABUSE_REPORTING`, `OSDK_XMS_ABUSE_REPORTING` → `Authentication` (1/0x46,
|
||
empty payload) → reconnect loop. **`Authentication::login` is never sent** — consistent with the
|
||
Origin gate blocking upstream.
|
||
|
||
---
|
||
|
||
## 3. What makes it stop looping — the notification it waits on
|
||
|
||
Recovered the **UserSessions notification name table** (jump table `0x141b03f70`,
|
||
switch at `0x146de19b1`, ids 1-based):
|
||
|
||
| ID | Notification |
|
||
|---|---|
|
||
| `0x01` | `UserSessionExtendedDataUpdate` |
|
||
| `0x02` | `UserAdded` |
|
||
| `0x03` | `UserRemoved` |
|
||
| `0x05` | `UserUpdated` |
|
||
| **`0x08`** | **`UserAuthenticated`** |
|
||
| `0x09` | `UserUnauthenticated` |
|
||
| `0x0c` | `ServerDraining` |
|
||
|
||
**`UserAuthenticated` (notification `0x08`) is the signal that flips the session to
|
||
authenticated.** Expect to also need `UserAdded` (0x02) and
|
||
`UserSessionExtendedDataUpdate` (0x01) so the local user object is populated.
|
||
|
||
### Util component (0x0009) — full command table, recovered
|
||
|
||
Jump table `0x141b17af4`, switch `0x146df6dd5`. This *validates the whole technique*: it
|
||
matches our observed traffic exactly (preAuth=0x07, ping=0x02, fetchClientConfig=0x01).
|
||
|
||
| ID | Command | | ID | Command |
|
||
|---|---|---|---|---|
|
||
| `0x01` | `fetchClientConfig` | | `0x0f` | `userSettingsLoadMultiple` |
|
||
| `0x02` | `ping` | | `0x14` | `filterForProfanity` |
|
||
| `0x03` | `setClientData` | | `0x15` | `fetchQosConfig` |
|
||
| `0x04` | `localizeStrings` | | `0x16` | `setClientMetrics` |
|
||
| `0x05` | `getTelemetryServer` | | `0x17` | `setConnectionState` |
|
||
| `0x06` | `getTickerServer` | | `0x19` | `getUserOptions` |
|
||
| `0x07` | `preAuth` | | `0x1a` | `setUserOptions` |
|
||
| **`0x08`** | **`postAuth`** | | `0x1b` | `suspendUserPing` |
|
||
| `0x0a` | `userSettingsLoad` | | **`0x1c`** | **`setClientState`** |
|
||
| `0x0b` | `userSettingsSave` | | `0x0e` | `deleteUserSettings` |
|
||
| `0x0c` | `userSettingsLoadAll` | | | |
|
||
|
||
### Unresolved: Authentication (0x0001) command `0x46`
|
||
|
||
The Authentication component's `getCommandName` name table is **not compiled into this
|
||
binary** (I scanned every MSVC jump-table switch in the module, both the dword-index and
|
||
byte-index forms — 13 tables total; Util, UserSessions, Stats, Messaging, AssociationLists,
|
||
Tournaments, GameReporting, OSDK are present, Authentication is not). So `0x46` cannot be named
|
||
by table lookup. What *is* known: the client sends it with an **empty payload**, immediately
|
||
after the six `fetchClientConfig` calls and **before** any login, and accepts our empty reply
|
||
without erroring. Available Authentication TDF types are catalogued in
|
||
`scratchpad/all_types.txt` (`Blaze::Authentication::*`, 290 entries incl. `ExpressLoginRequest`,
|
||
`GetAuthTokenResponse`, `Entitlements`, `AcceptLegalDocsRequest`, `CheckLegalDocRequest`).
|
||
To resolve it properly, use the descriptor-reflection method from
|
||
`fifa17-recon/tools/preauth_schema_reflection.md` against the handler that dispatches
|
||
component 1 replies.
|
||
|
||
---
|
||
|
||
## 4. Concrete fix order
|
||
|
||
**a4 (LSX / Origin) must be done first — it is the true first blocker.** Replace or shim the
|
||
Steampunks stub's LSX server on `127.0.0.1:4216` (it is in-process, both socket ends are
|
||
`FIFA17.exe`, so this means either patching `stp-origin_emu.dll`, or hooking its WS2_32 use,
|
||
or supplying our own LSX responder). Required, all consistent with
|
||
`stp-origin_emu.ini [Globals] PersonaId=33068179, PersonaName=CAGE, Language=en_US`:
|
||
|
||
1. `GetInternetConnectedState` → `<InternetConnectedState connected="1"/>` — **for every
|
||
request id, not just the first.** (Client verb strings live at `0x14394dd40`; the
|
||
`connected` attribute name at `0x14394e0a8`.)
|
||
2. `GetProfile` → `GetProfileResponse` with `PersonaId="33068179"`, `UserId="33068179"`,
|
||
`Persona="CAGE"` — mismatches here trip `AUTH_ERR_INVALID_PERSONA` /
|
||
`AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA` / `AUTH_ERR_PERSONA_NOT_FOUND` later.
|
||
3. `GetAuthCode` → `AuthCodeResponse` (verb string at `0x14394dd30`) with a synthetic code —
|
||
**the stub implements nothing here today**; this is the token the game then presents to
|
||
Blaze `Authentication`.
|
||
4. `QueryEntitlements` → `QueryEntitlementsResponseT` carrying `OriginItemT` with the
|
||
`ONLINE_ACCESS` tag for offer/content id `1027460` (retail exe). Missing this yields
|
||
`AUTH_ERR_NO_SUCH_ENTITLEMENT` / `AUTH_ERR_ENTITLEMENT_TAG_REQUIRED`.
|
||
5. Keep `ChallengeResponse`/`ChallengeAccepted` working (the stub already handles it).
|
||
|
||
Only once `origin.nav` emits `OriginIsOnlineTrue` does the client enter `futBlazeLogin`.
|
||
|
||
**Then a1/a3 (Blaze), in this order:**
|
||
|
||
1. `Util::preAuth` (9/0x07) — already accepted.
|
||
2. `Util::fetchClientConfig` (9/0x01) — return real config maps for `OSDK_CORE`,
|
||
`OSDK_CLIENT`, `OSDK_NUCLEUS`, `OSDK_WEBOFFER`, `OSDK_ABUSE_REPORTING`,
|
||
`OSDK_XMS_ABUSE_REPORTING` (currently empty — a known hole).
|
||
3. `Authentication` (1/0x46) — currently answered empty and tolerated; revisit after naming it.
|
||
4. `Authentication::login` with the Origin auth code from step a4.3 → reply must carry the
|
||
session key / persona consistent with PersonaId 33068179 / `CAGE`.
|
||
5. Push `UserSessions` notifications: `UserAdded` (0x02),
|
||
`UserSessionExtendedDataUpdate` (0x01), and **`UserAuthenticated` (0x08)** — this is the
|
||
one that makes the client consider itself logged in.
|
||
6. `Util::postAuth` (9/0x08), then `Util::setClientState` (9/0x1c) to enable online features.
|
||
|
||
---
|
||
|
||
## Tooling produced (scratchpad, reusable)
|
||
|
||
| File | Purpose |
|
||
|---|---|
|
||
| `hunt.py` | ASCII+UTF-16 keyword search over all live memory |
|
||
| `dumprange.py` | printable-string dump of an arbitrary VA range |
|
||
| `modstrings.py` / `modstrings.txt` | full module string index (930 795 strings) — grep instead of re-scanning |
|
||
| `xrefs2.py` | fast numpy rip-relative + abs-pointer xref finder |
|
||
| `switchtab.py` / `switchtab2.py` | recover MSVC jump-table switches (dword-index / byte-index) → Blaze command & notification name tables |
|
||
| `feflow.py` | locate + window-dump FeFlow nav JSON blobs |
|
||
| `lsxscan.py` | recover resident LSX frames |
|
||
| `alltabs.txt`, `origin_emu_strings.txt`, `clusters.json` | captured outputs |
|