Files
OpenFUT/fifa17-recon/tools/preauth_schema_reflection.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

266 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.