# Component 0x000A / cmd 0x0005 — CensusData::subscribeToCensusDataUpdates **Status:** RPC contract fully resolved (reflection + live disasm + live heap read). Fix is a ~15-line handler in `blaze_responder_v3b.py`. Not yet run against the game. **Clean-room:** every claim below is from decrypted `FIFA17.exe` / `/proc/66672/mem`, `/tmp/blaze_rx/`, and `/tmp/blaze_responder.log`. No EA source or headers. **Independently re-verified for this document** (live pid 66672, not just quoted from the reports): component-id arithmetic at `0x147e639cd`, vtable `0x143b72330`, both TDF class descriptors + member tables at `0x144c11d70` / `0x144c11dc0`, the reply handler `0x147e57050`, the resend functor `0x147e57380`, the scheduler zero-delay branch `0x146dbae7c`, the live API object at `0x43c70ff0`, and the exact wire bytes through `heat2.py`. --- ## 1. THE RPC CONTRACT ### 1.1 Component 0x000A = `Blaze::CensusData` ("CensusDataComponent") The component constructor at `0x147e63970` materialises the id with FIFA17's usual split-immediate obfuscation, then installs the component vtable: ``` 147e639cd: b9 0f a2 ff e7 mov ecx,0xe7ffa20f 147e639d2: 8d 89 fb 5d 00 18 lea ecx,[rcx+0x18005dfb] ; 0xe7ffa20f+0x18005dfb = 0x1_0000000A 147e639d8: 66 89 48 10 mov WORD PTR [rax+0x10],cx ; componentId = 0x000A 147e639dc: 48 8d 0d 4d e9 d0 fb lea rcx,[rip+...] ; # 0x143b72330 (component vtable) ``` Live read of vtable `0x143b72330` and its strings: | slot | VA | returns | |---|---|---| | +0x08 | `0x147e641d0` | `getComponentName` → `"CensusDataComponent"` @`0x143b72368` | | +0x10 | `0x147e64170` | `getCommandName` | | +0x18 | `0x147e645e0` | `getNotificationName` | | +0x20 | `0x147e64250` | `getErrorName` | | +0x30 | `0x147e64710` | notification dispatcher | Allocation tag `"CENSUSDATAInstance"` @`0x143b72380`. Cross-check from `getErrorName` @`0x147e64250`: `cmp edx,0x1000a` → `CENSUSDATA_ERR_PLAYER_ALREADY_SUBSCRIBED`, `cmp edx,0x2000a` → `CENSUSDATA_ERR_PLAYER_NOT_SUBSCRIBED`. Blaze error = `(index<<16)|componentId` ⇒ componentId `0x000A`. Not an assumption from framework lore — read out of FIFA's own registry. ### 1.2 Command 0x0005 = `subscribeToCensusDataUpdates` `getCommandName` @`0x147e64170` is a `movzx ecx,dx` + `dec/je` chain. Full CensusData command table: | cmd | name | |---|---| | 0x0001 | `subscribeToCensusData` (`0x143b723c0`) | | 0x0002 | `unsubscribeFromCensusData` (`0x143b723f8`) | | 0x0003 | `getRegionCounts` (`0x143b723b0`) | | 0x0004 | `getLatestCensusData` (`0x143b72398`) | | **0x0005** | **`subscribeToCensusDataUpdates`** (`0x143b723d8`) | Notification id `0x0001` = `NotifyServerCensusData` (`0x143b72228`, from `getNotificationName` @`0x147e645e0`). FIFA never sends cmd 1–4; it goes straight to cmd 5. ### 1.3 Request TDF — `Blaze::CensusData::SubscribeToCensusDataUpdatesRequest` Class descriptor `0x144c11d70`, tdfId `0x2975a3d9`, member table `0x144c11bc0`, 1 member (live dump): | member | tag (dword) | wire tag | off | decl type | |---|---|---|---|---| | `resubscribe` | `0xcb3d6200` | `cb 3d 62` = **`RSUB`** | +0x10 | `0x0f` = `bool` | Wire (`/tmp/blaze_rx/rx_0017_000a_0005.bin`, 21 B = 16 hdr + 5 payload): `cb 3d 62 | 00 | 00` → tag `RSUB`, Heat2 wire type `0x00` (INT), varint `0`. Every later capture: `cb 3d 62 00 **01**` → `RSUB=1`. Note the wire type byte carries the **generic Heat2 scalar type**, not the declared C++ type — a declared `bool` goes out as Heat2 INT. Same rule applies to our reply's `TimeValue` members. ### 1.4 Response TDF — `Blaze::CensusData::SubscribeToCensusDataUpdatesResponse` Class descriptor `0x144c11dc0` (header qword `0b000000 a3286174` ⇒ tdfId **`0x746128a3`**), member table `0x144c11bf0`, count 3. Live dump, including the reflection default column: | member | tag (dword) | wire tag | off | type | default | |---|---|---|---|---|---| | `censusNotificationPeriod` | `0x8eec0000` | `8e ec 00` = **`CNP `** | +0x10 | `0x0e` `TimeValue` | **0** | | `notificationTimeout` | `0xbb4b7400` | `bb 4b 74` = **`NTMT`** | +0x18 | `0x0e` `TimeValue` | **0** | | `resubscribeTimeout` | `0xcb4b7400` | `cb 4b 74` = **`RTMT`** | +0x20 | `0x0e` `TimeValue` | **0** | That is the **entire** response: three int64 time values. **No status flag, no subscription id, no list, no count.** This categorically rules out "poll until a ready flag appears" — there is nothing in the reply that could carry readiness. `TimeValue` is **microseconds**: `TimeValue::parse` @`0x1479b2d50` builds its internal value as `days*365` then `*60 *60 *1000 *1000` (`imul rcx,rax,0x16d` / `imul rdx,rcx,0x3c` / `imul rcx,rdx,0x3c` / `imul rdx,rcx,0x3e8` / `imul rax,rdx,0x3e8`) = seconds·1e6. Wire cross-check: `Blaze::QosConfigInfo.timeout` (`'TIME'`) uses the **same** typedesc `0x14486d7b8` and its reflection default is `0x004C4B40` = 5,000,000 — and we already send `("TIME", (INT, 5000000))` in `qos_config()` and FIFA accepted it. So `TimeValue` on the wire = Heat2 INT varint of microseconds. ### 1.5 Why the empty reply hangs FIFA — the exact mechanism Our 16-byte empty REPLY is **not** rejected and is **not** a decode error. It is a *successful* zero-member struct, so the response object is left default-constructed with `CNP = NTMT = RTMT = 0`. The reply callback is `0x147e57050`. Verified disassembly (live): ``` 147e57055: f7 81 68 01 00 00 ff ff ff f7 test DWORD PTR [rcx+0x168],0xf7ffffff ; job already armed? 147e57062: 0f 85 9f 00 00 00 jne 0x147e57107 ; ...then bail 147e57075: 45 85 c0 test r8d,r8d ; r8d = BlazeError 147e57078: 75 2e jne 0x147e570a8 ; error path ; --- success path (err == 0) --- 147e5707a: 4c 8b 42 18 mov r8,QWORD PTR [rdx+0x18] ; NTMT 147e5707e: 48 8b 42 20 mov rax,QWORD PTR [rdx+0x20] ; RTMT 147e57082: 4c 03 42 10 add r8,QWORD PTR [rdx+0x10] ; += CNP 147e57086: 48 89 81 60 01 00 00 mov QWORD PTR [rcx+0x160],rax ; stash RTMT for the error path 147e5708d: 48 8d 05 ec 02 00 00 lea rax,[rip+0x2ec] ; # 0x147e57380 = resend functor 147e57099: 48 b8 cf f7 53 e3 a5 9b c4 20 movabs rax,0x20c49ba5e353f7cf ; magic /1000 147e570a3: 49 f7 e8 imul r8 ; --- error path (err != 0) --- 147e570be: 48 f7 a9 60 01 00 00 imul QWORD PTR [rcx+0x160] ; saved RTMT /1000 ; --- common tail --- 147e570ce: 48 c1 fa 07 sar rdx,0x7 ; => delay in MILLISECONDS 147e570ec: 89 54 24 28 mov DWORD PTR [rsp+0x28],edx ; delay arg (32-bit ms) 147e570fa: e8 e1 ee ff ff call 0x147e55fe0 ; alloc FunctorJob + schedule 147e570ff: 8b 08 mov ecx,DWORD PTR [rax] 147e57101: 89 8b 68 01 00 00 mov DWORD PTR [rbx+0x168],ecx ; store armed job id ``` So: **`delay_ms = (CNP + NTMT) / 1000`** on success; `RTMT / 1000` only on the error path. With an empty reply that is `(0 + 0)/1000 = 0`. A **zero** delay is special-cased into the *immediate/ready* job list rather than the timed heap — scheduler `0x146dbae10`: ``` 146dbae78: 8b 74 24 58 mov esi,DWORD PTR [rsp+0x58] ; delay ms 146dbae7c: 85 f6 test esi,esi 146dbae7e: 75 14 jne 0x146dbae94 ; nonzero -> now + delay on the timed heap 146dbae80: 49 8d 48 08 lea rcx,[r8+0x8] 146dbae84: 48 8d 57 18 lea rdx,[rdi+0x18] ; zero -> ready list 146dbae88: 40 38 77 48 cmp BYTE PTR [rdi+0x48],sil ; (mid-pump? -> next-idle list [rdi+0x38]) 146dbae8e: 48 8d 57 38 lea rdx,[rdi+0x38] ``` The job that fires is `0x147e57380`, which clears the job id, constructs a fresh request and sets **`RSUB = 1`** before re-sending through the cmd-5 proxy: ``` 147e5739e: 89 b9 68 01 00 00 mov DWORD PTR [rcx+0x168],edi ; clear armed job id 147e573bf: e8 fc bf 00 00 call 0x147e633c0 ; request ctor 147e573c5: c6 84 24 80 00 00 00 01 mov BYTE PTR [rsp+0x80],0x1 ; request+0x10 = RSUB = 1 147e573cd: 48 8d 05 7c fc ff ff lea rax,[rip-0x384] ; # 0x147e57050 = reply cb ``` **Conclusion: this is a self-re-arming subscription-refresh timer with a zero interval, not an error-retry storm.** Three independent confirmations: 1. **Wire evidence** — payload histogram over `/tmp/blaze_rx/rx_*_000a_0005.bin`: `cb3d620000` → **1** (the single initial subscribe), `cb3d620001` → all the rest. Only `0x147e57380` writes `RSUB=1`, so the timer functor is provably the sole driver. 2. **Cadence** — `/tmp/blaze_responder.log` per-second RX histogram is a flat **30/s** from `[17:36:53]` to `[20:01:15]` (2 h 24 m and counting; min 25, max 32). That is the game idle-tick rate, i.e. exactly one re-send per BlazeHub pump — mechanically impossible for a socket-speed error retry, and no backoff, no teardown. 3. **Live state** — API object `0x43c70ff0` (vtable `0x143b6d6b0`, hub `+0x08` = `0x43c47330`, component proxy `+0x20` = `0x079f25c0`) read out of `/proc/66672/mem` right now: `+0x160` (saved RTMT) = **0**, `+0x168` (armed job id) = 0. And a gdb break on `0x147e57050` showed `err = 0x0` with `resp+0x10 = resp+0x18 = resp+0x20 = 0` — the reply is accepted as SUCCESS. **The fix is therefore: make `CNP + NTMT` ≥ 1000 µs so the job goes on the timed heap.** --- ## 2. THE FIX File: `/home/alex/Documents/OpenFUT/fifa17-recon/tools/blaze_responder_v3b.py` ### 2.1 Constants — after `COMP_USERSESSIONS` (line ~201) ```python COMP_CENSUSDATA = 0x000A # id built at 0x147e639cd-0x147e639d8; # "CensusDataComponent" @0x143b72368 # ---- CensusData (0x000A) command table, from getCommandName @0x147e64170. CMD_SUBSCRIBETOCENSUSDATAUPDATES = 0x0005 CENSUSDATA_CMDS = { 0x01: "subscribeToCensusData", 0x02: "unsubscribeFromCensusData", 0x03: "getRegionCounts", 0x04: "getLatestCensusData", 0x05: "subscribeToCensusDataUpdates", } NOTIFY_SERVER_CENSUS_DATA = 0x0001 # getNotificationName @0x147e645e0 ``` ### 2.2 Logging names — `COMP_NAMES` (line 277) and `rpc_name()` (line 285) ```python COMP_NAMES = { ... COMP_CENSUSDATA: "CensusData", 0x000B: "Clubs", # id at 0x147e38b5e/0x147e38b71 0x081C: "SponsoredEvents", # cmd table 0x146f89130 0x08C9: "OSDKSettings", # cmd table 0x14725f9a0 } ``` and inside `rpc_name()`, next to the existing `COMP_UTIL` / `COMP_AUTH` arms: ```python elif component == COMP_CENSUSDATA: cmd = CENSUSDATA_CMDS.get(command, "cmd:0x%04x" % command) ``` (Log cosmetics only — no behaviour change, but it makes the success signal in §3 readable.) ### 2.3 Response builder — next to `qos_config()` / `preauth_response_fields()` ```python def subscribe_census_data_updates_response_fields() -> "OrderedDict": """Blaze::CensusData::SubscribeToCensusDataUpdatesResponse classinfo @0x144c11dc0, tdfId 0x746128a3, member table @0x144c11bf0, 3 members, ALL TDF type 0x0e (TimeValue) -> Heat2 INT varint of MICROSECONDS. The client's reply callback 0x147e57050 computes delay_ms = (CNP + NTMT) / 1000 (magic-divide 0x20C49BA5E353F7CF, sar 7) and arms a FunctorJob (0x147e57380) that re-sends this RPC with RSUB=1. All three members default to 0 in reflection, so our old EMPTY reply gave delay_ms = 0, which 0x146dbae7c pushes onto the *ready* job list -> one re-subscribe per idle tick = the observed 30/s storm. 30s + 90s => the client re-subscribes every 120s instead of every 33ms. Tags ascend (8eec00 < bb4b74 < cb4b74), which is also correct Heat2 order.""" return OrderedDict([ ("CNP", (INT, 30 * 1000000)), # censusNotificationPeriod ("NTMT", (INT, 90 * 1000000)), # notificationTimeout ("RTMT", (INT, 300 * 1000000)), # resubscribeTimeout (error path only) ]) ``` `"CNP"` encodes to `8e ec 00` — identical to the 4-char `"CNP "` — verified via `heat2.encode_tag`, so the 3-char spelling is safe. ### 2.4 Dispatch arm — in `dispatch()`, before the `REPLY_EMPTY_TO_UNKNOWN` fallback (~line 1166) ```python # ----------------------------------------------------------- CensusData if comp == COMP_CENSUSDATA and cmd == CMD_SUBSCRIBETOCENSUSDATAUPDATES: rsub = find_nested_int(fields, "RSUB") if fields is not None else None log(" -> subscribeToCensusDataUpdates(RSUB=%s): " "SubscribeToCensusDataUpdatesResponse{CNP=30s, NTMT=90s, RTMT=300s} " "-> client re-subscribes in (CNP+NTMT)/1000 = 120000 ms" % rsub) return [reply_to(hdr, encode_tdf( subscribe_census_data_updates_response_fields()))] ``` ### 2.5 Exact bytes on the wire (round-tripped through this repo's `heat2.py` + `reply_to`) Payload — 25 bytes: ``` 8e ec 00 00 80 8e ce 1c CNP tag 8eec00, type 00 (INT), varint 30000000 bb 4b 74 00 80 aa ea 55 NTMT tag bb4b74, type 00 (INT), varint 90000000 cb 4b 74 00 80 8c 8d 9e 02 RTMT tag cb4b74, type 00 (INT), varint 300000000 ``` Full 41-byte REPLY frame (shown for msgNum 0x10, the first census request): ``` 00 00 00 19 00 00 00 0a 00 05 00 00 10 20 00 00 8e ec 00 00 80 8e ce 1c bb 4b 74 00 80 aa ea 55 cb 4b 74 00 80 8c 8d 9e 02 ``` (size `0x19` = 25, component `0x000a`, command `0x0005`, byte[13] `0x20` = REPLY + userIndex 0 — produced by the existing `reply_to(hdr, payload)`, no header changes needed.) ### 2.6 CIDS / component list — **do NOT change** `COMPONENT_IDS` (line 528) feeds only `CIDS` in `preauth_response_fields`, and `dispatch()` never reads it. CIDS demonstrably does not gate anything: FIFA already sends **four** components that are absent from our advertised CIDS `[1,4,5,7,9,15,25,28,30722]` — `0x000A`, `0x000B`, `0x081C`, `0x08C9`. Adding `0x000A` would change nothing we can observe, and could only add risk (FIFA might instantiate *more* components and issue new unhandled RPCs), which would break the one-variable discipline that cracked every previous gate. Leave it alone for this experiment. ### 2.7 Adjacent components 0x000B / 0x08C9 / 0x081C — names only, no handlers These are each called **exactly once**, get a 16-byte empty REPLY, and are **never retried** — so none of them is currently a hang, and none has a reversed response schema yet. Adding invented reply bodies would inject a second variable into the experiment. Add the `COMP_NAMES` entries from §2.2 (pure logging) and stop there. For reference when one of them becomes the next gate — all names read from FIFA's own command tables: | comp | cmd | name | cmd-table VA | |---|---|---|---| | 0x000B Clubs | 0x0A28 | `getClubsComponentSettings` | `0x147e40b00` (`0x143b69098`) | | 0x000B Clubs | 0x0640 | `getInvitations` | `0x147e40b00` (`0x143b690f0`) | | 0x08C9 OSDKSettings | 0x0001 | `fetchSettings` | `0x14725f9a0` | | 0x08C9 OSDKSettings | 0x0002 | `fetchSettingsGroups` | `0x14725f9a0` | | 0x081C SponsoredEvents | 0x0003 | `getEventsURL` | `0x146f89130` | | 0x0007 Stats | 0x0003 / 0x000F / 0x0014 | `getStatGroupList` / `getKeyScopesMap` / `getPeriodIds` | `0x147e29040` | | 0x0009 Util | 0x000C | `userSettingsLoadAll` | `0x146df6dc0` | | 0x000F Messaging | 0x0002 | `fetchMessages` (×3) | `0x147e49780` | ### 2.8 Optional step 2 — only if a residual ~120 s beat is not enough A real server pushes census content every `CNP`; we never will, so FIFA will keep re-subscribing every 120 s. That is the correct steady state, not a bug. If FUT turns out to need actual content: ```python notification(COMP_CENSUSDATA, NOTIFY_SERVER_CENSUS_DATA, encode_tdf(OrderedDict([ ("CNP", (INT, 30 * 1000000)), ("NTMT", (INT, 90 * 1000000)), ("RTMT", (INT, 300 * 1000000)), ("TDFL", (LIST, (STRUCT, []))), # censusDataList -- EMPTY ]))) ``` `Blaze::CensusData::NotifyServerCensusData`, tdfId `0x5b08e70d`, classinfo `0x144c11cd0`, member table `0x144c11ad0`, 4 members: `TDFL` @+0x10 (`0xd249ac00`, list of `NotifyServerCensusDataItem`), `CNP` @+0x58, `NTMT` @+0x60, `RTMT` @+0x68. Keep `TDFL` empty — its single element member `tdf` (`0xd2498000`, typedesc `0x14486d860`) is TDF type **`0x07` = variable TDF**, a wire format `heat2.py` does not implement. Do not attempt it until the variable-TDF encoding is reversed. --- ## 3. OBSERVABLE SUCCESS SIGNAL **Primary (log, immediate):** the 30/s storm collapses. ```bash grep -oE '^\[[0-9:]+\] RX #[0-9]+ CensusData' /tmp/blaze_responder.log \ | grep -oE '^\[[0-9:]+\]' | uniq -c | tail ``` Per-second count must go from a flat **30** to **0**, with at most one `CensusData::subscribeToCensusDataUpdates` roughly every **120 s** (`RSUB=1`). Anything in between (e.g. a 1/s or 10/s beat) means the reply decoded but with different values than intended — see §4. **Secondary (the real prize):** a brand-new RPC on a component other than `0x000A`, at a msgNum higher than the last census msgNum. Right now, from RX #43 onward for 2 h 24 m, the socket carries **nothing but census** — not even `Util::ping`. Any new line here is forward progress: ```bash tail -f /tmp/blaze_responder.log | grep -P 'RX #\d+ ' | grep -v CensusData ``` Expected candidates: `GameManager (0x0004)` (never contacted at all so far), `Util::fetchQosConfig (9/0x15)`, `Stats`, `GameReporting (0x001C)`, or FUT/UTAS HTTP traffic. **Direct in-process confirmation (proves the fields landed, not just that the spam stopped):** ``` gdb -p 66672 hbreak *0x147e57050 # at the hit: p/d *(long long*)($rdx+0x10) # CNP -> must be 30000000, was 0 p/d *(long long*)($rdx+0x18) # NTMT -> must be 90000000, was 0 p/d *(long long*)($rdx+0x20) # RTMT -> must be 300000000, was 0 p/x $r8d # BlazeError -> 0 ``` Or without gdb, straight from the live heap (the API object we already located): ```bash python3 -c " import struct;f=open('/proc/66672/mem','rb',0);f.seek(0x43c70ff0+0x160);b=f.read(12) print('saved RTMT =',struct.unpack('` `numOfUsersByRegion`, classinfo `0x144c11d20`) or `getLatestCensusData`. RegionCounts is cheap to stub if it appears; `getLatestCensusData` is not (variable TDFs again). 6. **Which of the nine empty-replied RPCs bites next** is not determinable from this log — none was retried even once, so there is no retry signature to read. A-priori favourites are `OSDKSettings::fetchSettings` / `fetchSettingsGroups` (an OSDK-layer gate FIFA calls exactly twice, matching that component's entire 2-command surface) and `Clubs::getClubsComponentSettings`. Three *non*-empty but stubbed replies are also live risks once census unblocks: `AssociationLists::getLists` returns `LMAP:[]` though FIFA explicitly asked for `OSDKPreferredPlayerList` and `OSDKAvoidPlayerList`; `Util::fetchClientConfig` returns an empty map for `OSDK_TICKER` / `OSDK_ARENA` / `OSDK_ROSTER`; `Util::userSettingsLoad` returns empty `DATA` for `FirstTimeFlag` and `AchievementCache`. 7. **Restart discipline.** The fix changes only the reply to an RPC FIFA re-sends every idle tick, so in principle restarting the responder mid-session is enough — the very next census request would get the new reply. Whether the live client tolerates the dropped TCP connection is untested; a full relaunch is the safe path, and it also gives a clean log to measure the cadence against.