edab23f04a
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
429 lines
22 KiB
Markdown
429 lines
22 KiB
Markdown
# 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('<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.
|