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

22 KiB
Raw Blame History

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,0x1000aCENSUSDATA_ERR_PLAYER_ALREADY_SUBSCRIBED, cmp edx,0x2000aCENSUSDATA_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: cb3d6200001 (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)

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)

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:

    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()

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)

    # ----------------------------------------------------------- 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:

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.

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:

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):

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.