Files
OpenFUT/fifa17-recon/tools/login_dump/COMPONENT_000A_PLAN.md
T
funman300 edab23f04a fifa17-recon: package the working offline FUT backend
Emulates FIFA 17's full online + Ultimate Team stack against an offline,
clean-room backend (no EA servers). Proven end-to-end 2026-08-01:
Origin login -> Blaze login -> device-trust -> the FUT hub.

Package:
- tools/openfut-fut.sh   one-command orchestrator (start/stop/status/restart)
- tools/root_arm.sh      idempotent host arm (sysctls, DNAT, /etc/hosts easw)
- tools/{lsx_responder_v2,blaze_responder_v3b,roster_server,utas_server,autopatch}.py
  the 5 servers (Origin LSX :4216, Blaze :42127/42130/42131, roster :8081,
  FUT/UTAS :8099) + heat2.py (Fire2/Heat2 TDF codec)
- FUT-RUNBOOK.md         runbook + gate-ladder troubleshooting
- docs/, tools/login_dump/*.md   the reverse-engineering write-ups

All findings are clean-room, from binaries we own; nothing from any leak.
The wire protocol maps 1:1 to FIFA 23.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PN5bmpDVQR1aXgefyWAt7o
2026-08-01 09:12:17 -07:00

429 lines
22 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.
# 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 14; 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('<Q',b[:8])[0],' armed jobId = 0x%x'%struct.unpack('<I',b[8:])[0])"
```
Today this prints `saved RTMT = 0 armed jobId = 0x0`. After the fix it must print
`saved RTMT = 300000000` with a **stable non-zero jobId** persisting between sends (the job now sits
on the timed heap instead of being consumed every idle).
**UI signal:** the "connecting to FUT" spinner advances / the FUT hub loads.
---
## 4. WHAT STILL NEEDS A LIVE EXPERIMENT
1. **Does FUT actually gate on census at all?** The RPC already returns `err = 0` and the response
carries no readiness flag, so the fix provably ends the *storm* but is **not proven** to unblock
FUT. It may simply reveal the next gate. Watch §3's secondary signal: if the storm stops and
**nothing new** is ever sent, the FUT gate is elsewhere and this was noise removal only.
Corollary risk: if nothing new appears, also check whether `Util::ping` resumes — it has been sent
exactly once (pre-login) in 2 h 24 m, which could mean the census loop was starving the keepalive
scheduler, or could just be SDK ping suppression while traffic flows. A permanently missing
keepalive is a latent disconnect.
2. **TimeValue unit sanity at the top end.** Microseconds is well-evidenced (`TimeValue::parse`
`*1e6`, the `/1000`→ms divide, and QosConfigInfo's baked default 5,000,000). But the delay is
passed to the scheduler as a **32-bit ms int** (`mov DWORD PTR [rsp+0x28],edx`) and no clamp or
validation was found in either `0x147e57050` or `0x146dbae10`. `(CNP+NTMT)` = 120e6 µs → 120,000
ms is far inside range, but do not scale these values up by orders of magnitude without
re-checking. If FIFA instead re-subscribes on a *far* different period than 120 s, the observed
interval directly reveals the real unit.
3. **Does the client also watchdog on `NTMT` (no notification received)?** If a resubscribe beat
appears at ~90 s rather than ~120 s, the client is timing out on missing census pushes and we need
§2.8's `NotifyServerCensusData`. That in turn is blocked on reversing the **variable-TDF (type
0x07)** wire format for a non-empty `TDFL` — an empty `TDFL` list is untested on this client.
4. **Whether FUT wants census *content*, not just the ack.** There is a whole FIFA-side layer above
Blaze CensusData — `FifaOnline::CensusDataHandler` @`0x1438fb278`, `OSDK_CensusDataAdaptor`
@`0x143993768`, `FifaOnline::GetCensusDataRequest/Response` @`0x1438f7908`/`0x1438f7930`, event
`EVENT_CENSUSDATA_UPDATE` @`0x143984650`, plus per-domain payload classes
(`GameManagerCensusData` `0x1438bc618`, `ClubsCensusData` `0x143b689a0`, `UserManagerCensusData`
`0x14388c4a0`). If any of that blocks on a populated list, the ack alone will not be enough.
5. **Whether cmds 0x0003 / 0x0004 become the next gate.** FIFA has never sent `subscribeToCensusData`
(cmd 1) — it goes straight to cmd 5. Once cmd 5 succeeds it may follow up with `getRegionCounts`
(response `Blaze::CensusData::RegionCounts`, tdfId `0x610ec2da`, 1 member `CNOU`
`map<uint32_t,string>` `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.