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
16 KiB
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:
- 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. - Disassembly of code in the binary we own (
ConnectionManager::onPreAuthResponseand the Util component'sgetCommandNameswitch). - 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=falseINST=fifa-2017-pc(echo of the request'sCDAT/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/SVERvalues are placeholders, not reverse-engineered.onPreAuthResponsedoesn't validate them, butAuthentication(component 1) almost certainly will care aboutNASP. 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
0x02group-start marker. This is why the recommendedLTPSis empty — it sidesteps the question. If we later need populatedLTPS, verify that encoding first. - The
2069/2070/2077FIFA-custom component ids were not individually attributed to component names. - Varint sign convention (bit
0x40of the first byte) is unverified; nothing inPreAuthResponseis 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.