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
266 lines
16 KiB
Markdown
266 lines
16 KiB
Markdown
# FIFA 17 Blaze `Util::preAuth` — reflection-reversed schema
|
||
|
||
**Date:** 2026-07-30 · **Method:** live `/proc/<pid>/mem` reflection walk of `FIFA17.exe` (PID 7618, alive throughout) + targeted disassembly of the response handler.
|
||
|
||
## Clean-room provenance
|
||
|
||
Everything below came from one of three sources, all allowed:
|
||
|
||
1. **The client's own runtime reflection metadata**, read out of the process we own. FIFA 17's BlazeSDK ships full TDF type descriptors (class names, member names, wire tags, struct offsets) in `.data`. This is the bulk of the findings.
|
||
2. **Disassembly of code in the binary we own** (`ConnectionManager::onPreAuthResponse` and the Util component's `getCommandName` switch).
|
||
3. **Our own captured wire bytes** (`fifa17-recon/captures/blaze/*.bin`).
|
||
|
||
One item — the Fire2 header field layout — was **cross-checked** against three independent third-party clean-room BlazeSDK-15.x reimplementations cloned in the scratchpad (`pamplona-future`, `catalyst-mitm`, `grid-blaze`, all Mirror's Edge Catalyst / Mass Effect era). That is flagged inline. **No EA/FIFA leaked source was consulted at any point.**
|
||
|
||
---
|
||
|
||
## 0. Correction: the Fire2 header layout we were using is wrong
|
||
|
||
Our working assumption was `[10:12]=u16 error/msgId, [12]=u8 msgType`. That is **incorrect**, and it matters: a reply built with it would put the message type in the wrong byte and drop the sequence number, so the client would never match the reply to its pending request.
|
||
|
||
Correct 16-byte header (all big-endian):
|
||
|
||
| Bytes | Field |
|
||
|---|---|
|
||
| `[0:4]` | u32 payload length |
|
||
| `[4:6]` | u16 metadata length (always 0 observed) |
|
||
| `[6:8]` | u16 component |
|
||
| `[8:10]` | u16 command |
|
||
| `[10:13]` | **u24 msgNum** (3 bytes, not 2) |
|
||
| `[13]` | **msgType << 5** |
|
||
| `[14]` | options |
|
||
| `[15]` | reserved |
|
||
|
||
`msgType`: `0=MESSAGE 1=REPLY 2=NOTIFICATION 3=ERROR_REPLY 4=PING 5=PING_REPLY`.
|
||
|
||
**Evidence this is right, independent of the reference repos:** byte `[12]` across our 8 captured frames takes the values `0,1,2,3,4,5,6,7` — a monotonic counter, alternating `preAuth`(even) / `ping`(odd). Under the old reading that would be eight different "message types", which is nonsense. Under the new reading every captured frame is `msgNum = 0..7, msgType = 0 (MESSAGE)` — i.e. all eight are client *requests*, which is exactly what we expect since we never replied. This also retires the earlier "ping/pong msgType 0x01/0x03" note: both 16-byte frames are plain `Util::ping` **requests** (command 0x0002, see §2), not a ping/pong pair.
|
||
|
||
`heat2.py`'s `build_fire2_frame` / `parse_fire2_frame` still encode the old layout and should be replaced by the versions in `build_preauth_response.py`. Its **TDF value encoding is fine** — it round-trips the 219-byte capture byte-for-byte.
|
||
|
||
---
|
||
|
||
## 1. `Blaze::Util::PreAuthResponse` — 14 members
|
||
|
||
Descriptor at VA `0x144875600`, member table `0x144874a90`, count 14. Members are listed and must be **serialized in ascending packed-tag order** (which is what the table itself is sorted by, and what the captured request does).
|
||
|
||
| Tag | Member name | TDF type | Wire type | Struct offset | Confidence |
|
||
|---|---|---|---|---|---|
|
||
| `ASRC` | `authenticationSource` | string | `0x01` | +0x1d8 | certain |
|
||
| `CIDS` | `componentIds` | list\<uint16\> | `0x04` | +0x070 | certain |
|
||
| `CLID` | `clientId` | string | `0x01` | +0x058 | certain |
|
||
| `CONF` | `config` | `Blaze::Util::FetchConfigResponse` | `0x03` | +0x0b8 | certain |
|
||
| `ESRC` | `entitlementSource` | string | `0x01` | +0x228 | certain |
|
||
| `INST` | `serviceName` | string | `0x01` | +0x040 | certain |
|
||
| `MAID` | `machineId` | uint32 | `0x00` | +0x240 | certain |
|
||
| `MINR` | `underageSupported` | bool | `0x00` | +0x220 | certain |
|
||
| `NASP` | `personaNamespace` | string | `0x01` | +0x1c0 | certain |
|
||
| `PILD` | `legalDocGameIdentifier` | string | `0x01` | +0x208 | certain |
|
||
| `PLAT` | `platform` | string | `0x01` | +0x028 | certain |
|
||
| `QOSS` | `qosSettings` | `Blaze::QosConfigInfo` | `0x03` | +0x120 | certain |
|
||
| `RSRC` | `registrationSource` | string | `0x01` | +0x1f0 | certain |
|
||
| `SVER` | `serverVersion` | string | `0x01` | +0x010 | certain |
|
||
|
||
Tag decoding is validated: the request's four member tags round-trip exactly (`0x8e4874`→`CDAT`, `0x8e9ba6`→`CINF`, `0x9a38f2`→`FCCR`, `0xb21924`→`LADD`), matching the capture.
|
||
|
||
### Nested types
|
||
|
||
```
|
||
Blaze::Util::FetchConfigResponse (CONF, 1 member)
|
||
CONF config map<string,string>
|
||
|
||
Blaze::QosConfigInfo (QOSS, 4 members)
|
||
BWPS bandwidthPingSiteInfo Blaze::QosPingSiteInfo (struct)
|
||
LNP numLatencyProbes uint16
|
||
LTPS pingSiteInfoByAliasMap map<string, Blaze::QosPingSiteInfo>
|
||
TIME timeout TimeValue (int, microseconds)
|
||
|
||
Blaze::QosPingSiteInfo (2 members)
|
||
PSA address string
|
||
PSP port uint16
|
||
```
|
||
|
||
Note `CONF` nests a struct whose single member is *also* tagged `CONF` — the outer is the `FetchConfigResponse` struct, the inner is the map. Easy to get wrong.
|
||
|
||
**Map key/value order:** the key descriptor is at `+0x28`, the value at `+0x30`. The binary's own `map<A,B>` *name string* is written **value-first**, so it reads backwards — don't trust it. Confirmed against two maps whose semantics are unambiguous: `pingSiteLatencyByAliasMap` is named `map<int32_t,string>` but is really `map<string alias, int32 latency>`; `permissionsByComponent` is named `map<list<string>,string>` but is really `map<string component, list<string> permissions>`. So `LTPS` = `map<string, QosPingSiteInfo>`.
|
||
|
||
### Request side, for reference
|
||
|
||
`Blaze::Util::PreAuthRequest` (4 members) — matches our capture exactly, which is what validated the whole descriptor-walking method:
|
||
|
||
| Tag | Member | Type |
|
||
|---|---|---|
|
||
| `CDAT` | `clientData` | `Blaze::Util::ClientData` |
|
||
| `CINF` | `clientInfo` | `Blaze::ClientInfo` (10 members) |
|
||
| `FCCR` | `fetchClientConfig` | `Blaze::Util::FetchClientConfigRequest` { `CFID` configSection: string } |
|
||
| `LADD` | `localAddress` | uint32 |
|
||
|
||
`Blaze::Util::ClientData` = { `IITO` ignoreInactivityTimeout: bool, `LANG` locale: uint32, `SVCN` serviceName: string, `TYPE` clientType: enum }.
|
||
|
||
---
|
||
|
||
## 2. Util component RPC table — **complete**
|
||
|
||
Component id **0x0009**, confirmed directly in the binary: the Util notification dispatcher compares against `0x00c80009`, `0x00980009`, `0x00640009`, `0x00960009`, `0x00970009` — i.e. `(notificationId << 16) | 9`.
|
||
|
||
Recovered by locating the compiler-generated `getCommandName(commandId)` switch: 21 `lea rax,[rip+name]; ret` stubs emitted in alphabetical order at RVA `0x1b17a43 + 8k`, plus a jump table of 28 entries at VA `0x146df72f4` indexed by `commandId - 1`.
|
||
|
||
| Command | RPC | Command | RPC |
|
||
|---|---|---|---|
|
||
| `0x0001` | `fetchClientConfig` | `0x000f` | `userSettingsLoadMultiple` |
|
||
| `0x0002` | **`ping`** ✅ | `0x0010`–`0x0013` | *(unused)* |
|
||
| `0x0003` | `setClientData` | `0x0014` | `filterForProfanity` |
|
||
| `0x0004` | `localizeStrings` | `0x0015` | `fetchQosConfig` |
|
||
| `0x0005` | `getTelemetryServer` | `0x0016` | `setClientMetrics` |
|
||
| `0x0006` | `getTickerServer` | `0x0017` | `setConnectionState` |
|
||
| `0x0007` | **`preAuth`** ✅ | `0x0018` | *(unused)* |
|
||
| `0x0008` | `postAuth` | `0x0019` | `getUserOptions` |
|
||
| `0x0009` | *(unused)* | `0x001a` | `setUserOptions` |
|
||
| `0x000a` | `userSettingsLoad` | `0x001b` | `suspendUserPing` |
|
||
| `0x000b` | `userSettingsSave` | `0x001c` | `setClientState` |
|
||
| `0x000c` | `userSettingsLoadAll` | | |
|
||
| `0x000d` | *(unused)* | | |
|
||
| `0x000e` | `deleteUserSettings` | | |
|
||
|
||
**This table is self-validating**: it independently reproduces both values we observed on the wire — `ping = 0x0002` and `preAuth = 0x0007`. Confidence: high.
|
||
|
||
### Request/response type mapping (from the reflection type index)
|
||
|
||
| Command | Request | Response |
|
||
|---|---|---|
|
||
| `0x0002` ping | *(empty)* | `Blaze::Util::PingResponse` { `STIM` serverTime } |
|
||
| `0x0007` preAuth | `PreAuthRequest` | `PreAuthResponse` |
|
||
| `0x0008` postAuth | `PostAuthRequest` { `DSUI` dirtySockUserIndex: int32, `UDID` uniqueDeviceId: string } | `PostAuthResponse` |
|
||
| `0x0001` fetchClientConfig | `FetchClientConfigRequest` { `CFID` } | `FetchConfigResponse` { `CONF` map } |
|
||
| `0x0015` fetchQosConfig | *(empty)* | `QosConfigInfo` |
|
||
|
||
`Blaze::Util::PostAuthResponse` (3 members) — **the next thing we'll need**:
|
||
|
||
```
|
||
TELE telemetryServer Blaze::Util::GetTelemetryServerResponse (15 members)
|
||
ADRS address:string ANON isAnonymous:bool DISA disable:string
|
||
EDCT enableDisconnectTelemetry:bool FILT filter:string
|
||
LOC locale:uint32 MINR underage:bool NOOK noToggleOk:string
|
||
PORT port:uint32 SDLY sendDelay:uint32 SESS sessionID:string
|
||
SKEY key:string SPCT sendPercentage:uint32
|
||
STIM useServerTime:string SVNM telemetryServiceName:string
|
||
TICK tickerServer Blaze::Util::GetTickerServerResponse
|
||
ADRS address:string PORT port:uint32 SKEY key:string
|
||
UROP userOptions Blaze::Util::UserOptions
|
||
TMOP telemetryOpt:enum UID userId:int64
|
||
```
|
||
|
||
### Component ids (recovered from each component's notification dispatcher)
|
||
|
||
`Authentication=1`, `GameManager=4`, `Redirector=5`, `Util=9`, `GameReporting=28`, `UserSessions=30722`. FIFA-custom components (`CoopSeason`, `EaAccess`, `Easfc`, `FifaCups`, `SponsoredEvents`, `VProSPManagement`, `OSDKSettings`, `OSDKTournaments`, `OsdkArena`) live in the `2069/2070/2077`… range; I did not separate them individually (their dispatchers are adjacent and my scan window overlapped). `Stats`, `Clubs`, `Messaging`, `Mail`, `AssociationLists`, `GpsContentController`, `CensusData` are also linked in.
|
||
|
||
---
|
||
|
||
## 3. What the client actually *does* with the response
|
||
|
||
`ConnectionManager::onPreAuthResponse(this, PreAuthResponse* r /*rdx*/, errorCode /*r8d*/)` at **VA `0x146e1cf10`**. Signature confirmed because every `[r14+offset]` it touches matches a `PreAuthResponse` member offset from §1.
|
||
|
||
The function performs **no validation whatsoever** — on the success path it only copies fields out and then advances. So the practical requirement is: *reply with a well-formed `REPLY` frame carrying the right `msgNum`.* Nothing in the body is checked for a specific value.
|
||
|
||
Field-by-field:
|
||
|
||
| Field | What happens | Mandatory? |
|
||
|---|---|---|
|
||
| `CONF` config | whole map copied into ConnectionManager `+0x11b8` | **effectively yes** — see the tunables below |
|
||
| `CIDS` componentIds | list copied to `+0x1240`; this is the client's view of which components the server has | **yes, practically** — later components look themselves up here |
|
||
| `SVER` serverVersion | `memcpy` → `+0x1288` (512-byte buf) | no, free-form |
|
||
| `PLAT` platform | `memcpy` → `+0x1488` (512-byte buf) | no, free-form |
|
||
| `INST` serviceName | `memcpy` → `+0x1688` (512-byte buf) | no — echo the request's `SVCN` (`fifa-2017-pc`) |
|
||
| `CLID` clientId | `memcpy` → `+0x1888` (512-byte buf) | no |
|
||
| `NASP` personaNamespace | `memcpy` → `+0x1a88`, **capped at 32 bytes** | no here, but auth will care |
|
||
| `RSRC` registrationSource | `memcpy` → `+0x1a?8` | no |
|
||
| `ASRC` authenticationSource | `memcpy` → `+0x1ae8` | no |
|
||
| `PILD` legalDocGameIdentifier | `memcpy` → `+0x1b28` | no |
|
||
| `MINR` underageSupported | byte → `+0x1b6c` | no |
|
||
| `ESRC` entitlementSource | `memcpy` → `+0x1b6d` | no |
|
||
| `MAID` machineId | passed to `0x146124610` | no |
|
||
| `QOSS` qosSettings | passed to QoS manager init `0x146e1c3f0` | see below |
|
||
|
||
Then it calls `0x146e1e460` and `0x146e1c3f0` (QoS start) and returns.
|
||
|
||
### The `CONF` map keys the client reads
|
||
|
||
Read at `0x146e1d0a5`–`0x146e1d1a2`, all via `ConnectionManager` vtable getters (`+0x50` = uint32, `+0x58` = TimeValue/int64, both returning "found"):
|
||
|
||
| Key | Parsing | Behaviour if absent |
|
||
|---|---|---|
|
||
| `pingPeriod` | value ÷ 1000 (so the config value is in **microseconds** → stored as ms); if result < 1000 ms, clamped to 15000 | **defaults to 15000 ms** (`0x3a98`) |
|
||
| `defaultRequestTimeout` | ÷1000 → ms, stored `+0x278` | left unchanged |
|
||
| `connIdleTimeout` | ÷1000 → ms, stored `+0xd28` | left unchanged |
|
||
| `autoReconnectEnabled` | `!= 0` → bool `+0x11b7` | left unchanged |
|
||
| `maxReconnectAttempts` | uint32 → `+0xc5c` | left unchanged |
|
||
|
||
**None of these are strictly required** — every one has a fallback. But `pingPeriod` decides how fast the client starts hammering `Util::ping` (cmd `0x0002`), so set it deliberately.
|
||
|
||
### Turning off the QoS probes — important for an offline emulator
|
||
|
||
Separate helper at `0x146e1bbc0` reads two more keys and starts from flags `= 3` (both tests on):
|
||
|
||
| Key | Effect |
|
||
|---|---|
|
||
| `enableQosFirewallTest` | if the value string equals **`"false"`** exactly (string at VA `0x14354be74`), clears bit 0 |
|
||
| `enableQosBandwidthTest` | same → clears bit 1 |
|
||
|
||
So putting `enableQosFirewallTest=false` and `enableQosBandwidthTest=false` in `CONF` stops the client trying to reach real QoS ping servers. Combined with an **empty `LTPS` map**, the QoS manager has nothing to probe. Recommended for our first working response.
|
||
|
||
---
|
||
|
||
## 4. Practical recipe
|
||
|
||
`build_preauth_response.py` implements this and self-verifies (correct header layout, all 14 fields present in tag order, decode round-trip). It reuses `heat2.py`'s validated TDF value encoders and **overrides** its Fire2 framing.
|
||
|
||
```
|
||
reply = fire2(component=0x0009, command=0x0007,
|
||
msg_num=<echo from the request>, msg_type=REPLY(1),
|
||
payload=preauth_response(...))
|
||
```
|
||
|
||
Produced frame for `msgNum=2`: 344 bytes, header `00 00 01 48 | 00 00 | 00 09 | 00 07 | 00 00 02 | 20 | 00 | 00`. Note byte 13 = `0x20` = `REPLY << 5`.
|
||
|
||
Body sent (all fields present; strings the client only stores are left empty or placeholder):
|
||
|
||
- `CIDS` = `[1, 4, 5, 7, 9, 15, 25, 28, 30722]`
|
||
- `CONF.CONF` = `pingPeriod=20000000`, `defaultRequestTimeout=30000000`, `connIdleTimeout=90000000`, `enableQosFirewallTest=false`, `enableQosBandwidthTest=false`
|
||
- `INST` = `fifa-2017-pc` (echo of the request's `CDAT/SVCN`)
|
||
- `QOSS` = `{ BWPS:{PSA:"127.0.0.1", PSP:17502}, LNP:10, LTPS:{}, TIME:5000000 }`
|
||
- `MAID=0`, `MINR=0`, remaining strings empty / placeholder
|
||
|
||
Right after this the client will start sending `Util::ping` (`0x0009/0x0002`) on `pingPeriod`; reply with `msgType=REPLY` and a `PingResponse { STIM: serverTime }`. `ping_reply_frame()` in the same module does that.
|
||
|
||
### Residual uncertainty
|
||
|
||
- **`NASP` / `PLAT` / `SVER` values are placeholders, not reverse-engineered.** `onPreAuthResponse` doesn't validate them, but `Authentication` (component 1) almost certainly will care about `NASP`. Expect to revisit.
|
||
- **Group-inside-list/map framing is unverified.** Our capture never exercises it, and the third-party crates disagree with some Blaze versions about a `0x02` group-start marker. This is why the recommended `LTPS` is empty — it sidesteps the question. If we later need populated `LTPS`, verify that encoding first.
|
||
- The `2069/2070/2077` FIFA-custom component ids were not individually attributed to component names.
|
||
- Varint sign convention (bit `0x40` of the first byte) is unverified; nothing in `PreAuthResponse` is signed, so it doesn't bite here.
|
||
|
||
## Tooling produced
|
||
|
||
| File | Purpose |
|
||
|---|---|
|
||
| `reflect2.py` | recursive TDF type-descriptor walker (`raw` / `walk` / `byname` / `index`) |
|
||
| `build_preauth_response.py` | corrected Fire2 framer + PreAuthResponse/PingResponse builders, self-testing |
|
||
|
||
### Reflection metadata layout (for reuse)
|
||
|
||
Type descriptor, 64 bytes:
|
||
|
||
```
|
||
+0x00 u32 typeEnum +0x04 u32 nameHash
|
||
+0x08 ptr fullName +0x10 ptr shortName (interior)
|
||
+0x18 ptr auxTypeInfo +0x20 ptr runtimeInstance
|
||
+0x28 ptr shortName2 +0x30 ptr memberTable +0x38 u64 memberCount
|
||
```
|
||
|
||
Member entry, 48 bytes: `+0x00` member type descriptor · `+0x08` member name string · `+0x20` **u32 wire tag, 6-bit-packed in the top 3 bytes** · `+0x28` u64 byte offset within the C++ struct.
|
||
|
||
`typeEnum`: `2`=map `3`=list `4`=float `5`=enum `6`=string `7`=variable `9`=blob `10`=union `11`=**struct/class** `12`=ObjectType `13`=ObjectId `14`=TimeValue `15`=bool `16`=int8 `17`=uint8 `19`=uint16 `20`=int32 `21`=uint32 `22`=int64 `23`=uint64.
|
||
|
||
Containers keep their element types at `+0x28` (list elem / map key) and `+0x30` (map value).
|
||
|
||
Blaze reflection strings live around VA `0x143888000`–`0x1438a1000`; descriptors around `0x144860000`–`0x144890000`.
|