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

16 KiB
Raw Blame History

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 (0x8e4874CDAT, 0x8e9ba6CINF, 0x9a38f2FCCR, 0xb21924LADD), 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 0x00100x0013 (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 0x146e1d0a50x146e1d1a2, 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 0x1438880000x1438a1000; descriptors around 0x1448600000x144890000.