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
30 KiB
FUT loading-screen QUIET STALL — completion condition, fix, and experiment plan
Clean-room. Every claim below is backed by a VA + bytes/disasm read from the live frozen
process (pid 66672) via /proc/66672/mem, or by a log/capture line. Nothing from leaked
EA source or headers. All disassembly in this document was re-dumped and re-verified by the
synthesis pass, not copied from the reverser reports.
0. Verdict in one paragraph
The FUT loading screen is not waiting on a Blaze RPC reply, not waiting on a
server-pushed notification, and not waiting on entitlements. It is sitting in the
data-defined UI state CheckFUTRosterUpdateXML, which fires the native condition
isFUTRosterXMLAvailable and then leaves only on advance (roster XML downloaded) or
back (download failed). Neither event can ever fire, because the roster-update URL
resolves to an empty string and the fetcher takes a silent early-return that never issues
a request and never arms a callback. The URL has exactly three sources; two are ini keys
that do not exist on disk, and the third is the Blaze client-config store — which we answer
with an empty map. So the missing input is a single config string:
ROSTERUPDATE_URL, deliverable through Util::fetchClientConfig.
1. THE COMPLETION CONDITION
1.1 The state machine, read live out of FIFA's heap
FUT entry is driven by the flow checkFUTRostersFlow (data/ui/nav/checkFUTRostersFlow.nav,
path string live @0x1b8fc38). The full JSON is resident at 0x41abd000-0x41abe400. Verified
live (84 non-image copies of the literal checkFUTRostersFlow, 17 of CheckFUTRosterUpdateXML;
zero copies of either inside the image range 0x140000000-0x151359000 — i.e. these are runtime
data, not string constants).
LoadFUTDatabase → LoadFUTSquad → CheckFUTRosterUpdateXML → CheckFUTSquadBinFile
│ │
│ back ├─ "false" → EnterFUT ← GOAL
▼ └─ "true" → FUTLiveDBPopup → downloadLiveDB
FailFUTRosterXMLDownloadPopup
│ advance
▼
unloadFUTDatabaseOnFail (dead end — FUT aborts)
The blocked state, verbatim from live memory:
"name":"CheckFUTRosterUpdateXML"
,"onEnter":
[
["sendAction",["condition", "isFUTRosterXMLAvailable"]]
,["sendScreenEvent", ["ShowLoadingIcon"]]
]
, "onExit":
[
["sendScreenEvent", ["HideLoadingIcon"]]
]
,"transitions":
[
{"event":"advance" ,"targets":["CheckFUTSquadBinFile"]}
,{"event":"back" ,"targets":["FailFUTRosterXMLDownloadPopup"]}
,{"event":"evt_online_disconnected","targets":["unloadFUTDatabaseOnFail"]}
,{"event":"evt_invite_accepted" ,"targets":["unloadFUTDatabaseOnFail"]}
]
There is no timeout transition. ShowLoadingIcon on entry and HideLoadingIcon on exit
is exactly the symptom: stadium + "17" + spinner, no error, forever.
Two of the four exits (evt_online_disconnected, evt_invite_accepted) route to
unloadFUTDatabaseOnFail — they abort FUT, they do not advance it. So the only forward
exit is advance, and the only backward exit is back; both are raised by the roster-XML
download callbacks (futRosterXMLDownloadSuccess @0x143b54400 / futRosterXMLDownloadFail
@0x143b54420).
1.2 Good news downstream — the next state is already satisfied
Also read live, and not previously reported: the state after the block short-circuits
straight to EnterFUT for a fresh offline account.
"name":"CheckFUTSquadBinFile"
,"onEnter":[["sendAction",["condition", "isFUTSquadAvailable"]]]
,"transitions":
[
{"event":"true" ,"actions":[["sendScreenEvent",["SetLiveDbDownloadState","4"]]],"targets":["FUTLiveDBPopup"]}
,{"event":"false","actions":[["sendScreenEvent",["EnterFUT",""]]] ,"targets":["advance"]}
]
isFUTSquadAvailable == false (no squad bin file yet) → EnterFUT fires immediately.
So CheckFUTRosterUpdateXML is very likely the last gate in this flow.
1.3 The exact blocked input — verified disassembly
Chain, all re-dumped live this session:
(a) isFUTRosterXMLAvailable handler = 0x147d989d0 (dispatcher string-compares the
condition name against "isFUTRosterXMLAvailable" @0x143b543b8 — literal confirmed live).
Five skip predicates; if all return false:
147d98a57: c6 87 38 01 00 00 01 mov BYTE PTR [rdi+0x138],0x1 ; "downloading" flag
147d98a5e: 48 89 d9 mov rcx,rbx
147d98a61: e8 3a 43 cd ff call 0x147a6cda0 ; start roster-XML fetch
(b) 0x147a6cda0 — the "issue if not already issued" wrapper:
147a6cda5: 48 8b 59 08 mov rbx,QWORD PTR [rcx+0x8] ; this = *[obj+8] (the downloader)
147a6cda9: 83 bb a0 02 00 00 00 cmp DWORD PTR [rbx+0x2a0],0x0
147a6cdb0: 75 1d jne 0x147a6cdcf ; already issued -> return
147a6cdb6: c6 83 a4 02 00 00 00 mov BYTE PTR [rbx+0x2a4],0x0
147a6cdbd: e8 1e a3 08 00 call 0x147af70e0
147a6cdca: e9 41 a6 00 00 jmp 0x147a77410 ; -> URL resolver, this=rbx
(c) 0x147a77410 — the URL resolver. This is where it dies. Full verified path:
147a7744d: mov DWORD PTR [rbx+0x2a0],0x0 ; clear "request issued"
147a77457: test rdi,rdi / je 0x147a775c5 ; bail #1: no download manager
147a77460: cmp DWORD PTR [rbx+0x2a8],0x5
147a77467: jb 0x147a77476
147a77469: cmp BYTE PTR [rbx+0x2b9],0x1
147a77470: jne 0x147a775c5 ; bail #2: >=5 attempts and flag != 1
; --- source 1: game-ini key "FUT/ROSTERUPDATE_URL" -------------------------
147a774b5: lea rdx,[rip+...] # 0x143af1810 ; "FUT/ROSTERUPDATE_URL"
147a774bf: call 0x147763ac0 ; ini->HasKey
147a774c4: test al,al / je 0x147a774f8 ; MISS in our setup
147a774f1: call 0x145e27ff0 ; strncpy(url, val, 0x100)
; --- source 2: game-ini key "ROSTERUPDATE_URL", wrapped "https://%s" -------
147a774fd: lea rdx,[rip+...] # 0x143af1828 ; "ROSTERUPDATE_URL"
147a77507: call 0x147763ac0 ; ini->HasKey
147a7750e: je 0x147a77541 ; MISS in our setup
147a77524: lea r8, [rip+...] # 0x143af1840 ; "https://%s"
147a77538: call 0x145e24d70 ; sprintf(url, 0x100, "https://%s", val)
; --- source 3: Blaze CLIENT-CONFIG store ----------------------------------
147a77541: mov rax,QWORD PTR [rsi]
147a77544: lea r9, [rsp+0x30] ; out buffer
147a77549: lea r8, [rip+...] # 0x14354b5f0 ; default = ""
147a77550: lea rdx,[rip+...] # 0x143af1828 ; key = "ROSTERUPDATE_URL"
147a77557: mov rcx,rsi ; cfg object
147a7755a: mov DWORD PTR [rsp+0x20],0x100 ; out size
147a77562: call QWORD PTR [rax+0x30] ; cfg->getString(key, "", out, 0x100)
; --- THE BAIL --------------------------------------------------------------
147a77565: lea rcx,[rsp+0x30]
147a7756a: call 0x145e27b10 ; strlen(url)
147a77577: test rax,rax
147a7757a: 74 49 je 0x147a775c5 ; <<<<<< EMPTY URL -> SILENT RETURN
; --- the code that is being skipped ---------------------------------------
147a775a5: call QWORD PTR [r10+0x50] ; issue the HTTP download
147a775b2: call 0x147173690 ; attach job handle to [rbx+0x290]
147a775b7: mov DWORD PTR [rbx+0x2a0],0x1 ; "request issued"
147a775c1: mov BYTE PTR [rbx+0x48],0x0
147a775c5: <stack-cookie check; ret> ; bare epilogue
0x147a775c5 is the bare epilogue. Taking it means: no socket, no callback registered,
no success event, no fail event, no timeout, no popup. Exactly the observed quiet stall.
All string literals re-read live from 0x143af1810 / 0x143af1828 / 0x143af1840 and confirmed
as FUT/ROSTERUPDATE_URL, ROSTERUPDATE_URL, https://%s.
1.4 Why all three sources are empty — verified
| Source | Status | Proof |
|---|---|---|
ini FUT/ROSTERUPDATE_URL |
absent | grep -ral 'ROSTERUPDATE_URL' "/mnt/games/FIFA 17" matches only FIFA17.exe / FIFA17_Trial.exe — not default_startup.nsi, not Documents/FIFA 17/settings/ |
ini ROSTERUPDATE_URL |
absent | same grep |
| Blaze client-config store | empty | FIFA requested fetchClientConfig(CFID='OSDK_ROSTER') at RX #22 (/tmp/blaze_rx/rx_0022_0009_0001.bin decodes to {'CFID': (1,'OSDK_ROSTER')}); blaze_responder_v3b.py has no OSDK_ROSTER key in CLIENT_CONFIGS (line 511-524) → client_config_for() returns [] → EMPTY MAP |
Live confirmation that the store really lacks the key. Full-address-space scan of pid 66672
(scratchpad/scan.py), counting image (0x140000000-0x151359000) vs non-image hits:
ROSTERUPDATE_URL image=2 nonimage=0 <-- NOT in the runtime config store
FUT_RS4_BASE_URL image=1 nonimage=0
OSDK_ROSTER image=2 nonimage=3 (request-side CFID string @0x43c4a287 etc.)
CheckFUTRosterUpdateXML image=0 nonimage=17 (flow is loaded and live)
checkFUTRostersFlow image=0 nonimage=84
For contrast, every key we do serve is present in the heap store; the live config-store
region holds literal key=value strings:
0x7b5a710 OSDK_CLUBS_INCOME_SEARCH_MAX=100 (from CFID OSDK_CLIENT)
0x7b5a779 OSDK_CLUBS_MAX_SEARCH_RESULT=50
0x7b5adf0 OSDK_ANTIGRIEFING_MAX_COUNT=0 (from CFID OSDK_CORE)
0x7b5ae84 SV_SERVER_VERSION=0
1.5 RESOLVED: the config store is MERGED, not CFID-scoped
This was the single biggest open question across the reports (the reader at 0x147a77550 passes no CFID). Resolved by following the accessor live:
0x1471995b0: mov rax,[0x144b86bf8]; ret ; global service registry -> 0x43c46c70
vtable 0x143959168, slot 0x118 -> 0x147199d90
0x147199d90: mov rax,[rcx]
mov edx,0xe7ffa20f
lea edx,[rdx-0x749c3ba8] ; = 0x73636667 = 4CC 'scfg'
call QWORD PTR [rax+0x60] ; services('scfg')
mov edx,0xc34f050f
lea edx,[rdx-0x63ed98a3] ; = 0x5f616c6c = 4CC '_all'
jmp QWORD PTR [r8+0x8] ; scfg->getSection('_all')
(FIFA 17 obfuscates immediates as mov r32,A; lea r32,[r32+B]; both constants decoded and
checked arithmetically.)
The object the roster resolver reads from is the config service's _all section — an
all-sections merged view. Therefore it does not matter which CFID we put ROSTERUPDATE_URL
in. Any CFID FIFA fetches before FUT entry will do. (Cross-check: the heap store above
contains keys from OSDK_CORE and OSDK_CLIENT — two different CFIDs — and the same
vt[0x118] accessor shape is used by LoadRosterConfig (0x14723bc50) to read
ROSTER_URL/ROSTER_VER/ROSTER_CSUM.)
1.6 Corroborating live state (all re-verified this pass)
- Sockets: exactly two,
127.0.0.1:51238→4216(LSX, fd 55) and127.0.0.1:43967→42130(Blaze, fd 300). Zero SYNs, zero HTTP, in ~4 h of uptime. The fetch was never issued. grep -c CardsDLL /proc/66672/maps= 0 —CardsDLL_Win64_retail.dll(the FUT DLL, stringsLOAD_FUT_DLL@0x143aebde8,CardsDLL@0x143aebe00) is not mapped, i.e.EnterFUTnever ran. This is the definitive "FUT booted" tripwire.- Blaze link healthy and idle:
/tmp/blaze_responder.logshows only 20 s PINGs plus the 120 s CensusData re-subscribe, all answered.
1.7 What is NOT the block (ruled out, with evidence)
Not an RPC reply. Heat2 decode is tag-driven, so a 0-byte REPLY decodes as "all members at default" and the SDK completes the job with ERR_OK. Behavioural proof: TX #19, 23, 24, 25, 27, 28, 29, 32 were all 16-byte empty REPLYs, and FIFA issued the next RPC immediately after each (RX #20 → #33, all inside one second at 20:47:17), and never retried any of them. No Blaze job is pending.
Not a notification. Of the 22 Blaze components the client links, 14 share the stub
0x145bf3580 (lea rax,[0x14354b5f0]; ret, and [0x14354b5f0] == 0 → empty string) as
their getNotificationName — including every FUT/OSDK-flavoured component (Easfc, FifaCups,
OSDKSettings, OSDKTournaments, OsdkArena, SponsoredEvents, CoopSeason, VProSPManagement,
EaAccess, Mail, GpsContentController) plus Authentication and Util. There is no
FUT/OSDK "data ready" notification in this client. Independently, nothing in
checkFUTRostersFlow consumes a Blaze notification — the only exits are the two local
HTTP callbacks plus two abort events.
Not entitlements. They are broken (see §2.2) but they are not this gate: an unentitled
user takes the ENTER_FUT2_POPUP_GO_TO_STORE / FUT_ENABLE_MENU path (0x147c86d85-0x147c86df7)
and never reaches checkFUTRostersFlow at all — yet we are demonstrably inside that flow.
Also FifaOnline::NotifyEntitlementsUpdated (0x146f3f630) is broadcast on the common tail
regardless of entitlement count (gated on the message error field, which is 0), so nothing
is blocked waiting for it.
2. THE FIX — ranked and minimal
FIX 1 (THE GATE) — serve ROSTERUPDATE_URL in Util::fetchClientConfig
File: /home/alex/Documents/OpenFUT/fifa17-recon/tools/blaze_responder_v3b.py
No new TDF work is needed: fetch_config_response_fields() already emits the correct
Blaze::Util::FetchConfigResponse{ CONF : map<string,string> }, and client_config_for()
already sorted()s the pairs (Heat2 map ordering). The change is data only.
(a) Next to OSDK_TICKER = [] (line 497) add:
# --- FUT LOADING GATE ---------------------------------------------------
# CFID FIFA 17 fetches at FUT entry (RX #22, rx_0022_0009_0001.bin).
# ROSTERUPDATE_URL is the ONLY remaining source for the FUT roster-XML URL:
# the two ini keys FUT/ROSTERUPDATE_URL (@0x143af1810) and ROSTERUPDATE_URL
# (@0x143af1828) are absent on disk, so the resolver at 0x147a77410 falls
# through to cfg->getString("ROSTERUPDATE_URL", "", out, 0x100) @0x147a77562.
# An empty result makes 0x147a77577 test rax,rax / je 0x147a775c5 return
# WITHOUT issuing the request and WITHOUT arming a callback, so flow
# checkFUTRostersFlow state CheckFUTRosterUpdateXML never gets advance/back
# -> the silent loading-screen hang.
# The config store is the MERGED '_all' section (services('scfg')
# ->getSection('_all'), decoded at 0x147199d90), so any fetched CFID works;
# we put it in the CFID FIFA actually asks for at FUT entry.
# NOTE: this branch does NOT wrap the value (unlike the ini branch, which
# applies "https://%s" @0x143af1840) -> serve an ABSOLUTE url.
OSDK_ROSTER = [
("ROSTERUPDATE_URL", "http://127.0.0.1:8081/fifa17/fut/rosterupdate.xml"),
("ROSTER_URL", "http://127.0.0.1:8081/fifa17/roster/"), # literal @0x143973aa0
("ROSTER_VER", "0"), # literal @0x143973ab0
("ROSTER_CSUM", ""), # literal @0x143973ad0
]
(b) Register it in CLIENT_CONFIGS (line 511-524):
"OSDK_ROSTER": OSDK_ROSTER,
(c) Belt-and-braces (free, and it front-loads the value to login time rather than
FUT-entry time — useful because OSDK_CORE is fetched much earlier): append the same pair
to OSDK_CORE:
("ROSTERUPDATE_URL", "http://127.0.0.1:8081/fifa17/fut/rosterupdate.xml"),
LoadRosterConfig (0x14723bc50) builds its CFID with the format "OSDK_ROSTER%s"
(@0x1439740a0, site 0x14723bc88), so a suffixed variant (OSDK_ROSTERPC, OSDK_ROSTER1, …)
may also be requested. Because the store is merged, serving the key in OSDK_CORE covers
every such variant — that is the main reason to do (c).
(d) Stand up a trivial HTTP listener on 127.0.0.1:8081 answering that path. Start with
200 OK + a minimal XML body.
Observable success signals, strongest first
- A third socket.
ss -tanp | grep FIFA17shows a connection to127.0.0.1:8081. This alone proves the diagnosis — it is FIFA's first ever non-LSX/non-Blaze socket. HideLoadingIconfires and the flow leavesCheckFUTRosterUpdateXML.CardsDLL_Win64_retail.dllappears in/proc/$(pgrep -x FIFA17.exe)/maps. This is the definitive "FUT booted" signal —EnterFUTran. Per §1.2, with no squad bin file the flow goesCheckFUTSquadBinFile → (false) → EnterFUTimmediately after the advance, so signal 3 should follow signal 1 within seconds.- Failing that: the
FUT_SQUAD_DOWNLOAD_FAILpopup appears. Still a win — it proves the state machine moved and tells us the XML content is the next problem. (Be aware the fail popup dead-ends intounloadFUTDatabaseOnFail, so it aborts FUT; it is a diagnostic, not a path forward.)
Live pointer for probing the downloader after the fix — the this for the resolver is
*[obj+8] (from 0x147a6cda5 mov rbx,[rcx+0x8]), and [this+0x2a0] == 1 means the request
was issued. (My live chase of obj via *[0x144bfb910] → +0x80 reproduced the earlier
reverser's failure: it lands on 0x7c17308, a string pool containing MES_TAB_DP /
MATCHDAY_SETTING, so that pointer path is wrong. Use a breakpoint on 0x147a6cda0 instead —
see §4.)
FIX 2 (real bug, ship together, but it is NOT the gate) — entitlement group
Live-confirmed broken, re-verified this pass on pid 66672:
base = *[0x1448a3b20] = 0x43dc3e70 (online flag byte[0x1448a3ac3] = 1)
entMgr = *[base+0x2b10] = 0x43f15fd0
byte[entMgr+0x88] = 0 <-- "entitlements loaded" flag NOT set
qword[entMgr+0x90/0x98/0xa0] = 0 / 0 / 0 <-- record vector begin=end=cap: store EMPTY
Cause: the response callback EntitlementComponent::onListEntitlements (0x146f27440) keeps
an entitlement only if all three hold:
strstr(GNAM, "FIFA17PCBoxContent") != NULLORstrstr(GNAM, "FIFA16PC") != NULL(0x146f27528/call 0x145e28fa0, needles from the const array @0x144334030 ={0x1438e4d20 "FIFA17PCBoxContent", 0x1438e4d38 "FIFA16PC"}— both literals re-read live);strlen(TAG) != 0(0x146f2753e);STAT == 1(0x146f2754c cmp DWORD PTR [rbx+0xb0],0x1) —EntitlementStatus::ACTIVE.
We send GNAM = "FIFA17PC" (blaze_responder_v3b.py:106). strstr("FIFA17PC","FIFA17PCBoxContent")
is NULL (needle longer than haystack) and strstr("FIFA17PC","FIFA16PC") is NULL → zero
survivors → the serialiser emits a zero-length blob → the consumer 0x146f4da20 bails at
cmp QWORD PTR [rdx+0x18],0 / jbe 0x146f4dd9a and never executes
0x146f4dd8a mov BYTE PTR [rdi+0x88],0x1.
Change (a) — line 106:
ENTITLEMENT_GROUP = "FIFA17PCBoxContent" # was "FIFA17PC" -> matched NEITHER strstr needle
Change (b) — parameterise entitlement_fields (line 907) and emit a multi-element NLST.
Keep the existing field order (DEVI GDAY GNAM ID ISCO PID PJID PRCA PRID STAT STRC TAG TDAY TYPE UCNT VER) — it is already the ascending-tag order Heat2 requires and is proven on
the wire (TX #33.0, 152 payload bytes):
def entitlement_fields(now, group=None, tag=None, eid=1):
group = group or ENTITLEMENT_GROUP
tag = tag or ENTITLEMENT_TAG
return OrderedDict([
("DEVI", (STRING, "")),
("GDAY", (STRING, "2016-09-01T00:00:00Z")),
("GNAM", (STRING, group)), # MUST contain "FIFA17PCBoxContent" or "FIFA16PC"
("ID", (INT, eid)),
("ISCO", (INT, 0)),
("PID", (INT, PERSONA_ID)),
("PJID", (STRING, CONTENT_ID)),
("PRCA", (INT, 2)),
("PRID", (STRING, CONTENT_ID)),
("STAT", (INT, 1)), # MUST be exactly 1 (ACTIVE)
("STRC", (INT, 0)),
("TAG", (STRING, tag)), # MUST be non-empty
("TDAY", (STRING, "")),
("TYPE", (INT, 1)), # entitlementType — NOT checked client-side
("UCNT", (INT, 0)),
("VER", (INT, 1)),
])
def entitlements_response_fields():
now = int(time.time())
return OrderedDict([("NLST", (LIST, (STRUCT, [
entitlement_fields(now, "FIFA17PCBoxContent", "ONLINE_ACCESS", 1),
entitlement_fields(now, "FIFA16PC", "ONLINE_ACCESS", 2),
])))])
Hard constraints from the disassembly:
PRID/GNAM/TAGmust contain no|and no/— survivors are re-serialised as"PRID|GNAM|TAG|UCNT/"(formats%s|@0x1438fa858,%u/@0x1438fa85c) and re-parsed withstrtokon those delimiters (0x146f4db93 / 0x146f4dbd3).CONTENT_ID = "1027460"is fine.- Keep the list small — the compressor buffer is
0x1000bytes (0x146f27681 mov r8d,0x1000). - Stored records are matched later with fixed-width
strncmpon the exact group string (0x146ee9080,r8d=0x41), which is why both literals are sent verbatim.
Deliberately deferred: the CC* DLC tags (CCALLINONE, CCTEAMS1, CCTEAMS2,
CCTOURNAMENTS, CCCAREERMODE, table @0x1444049e8). 0x148198340 calls HasEntitlement
with group = NULL (xor edx,edx), which would feed NULL into the strncpy at
0x145e27ff0. That path is currently unreachable only because byte[mgr+0x88] == 0;
flipping the flag to 1 makes it live. Ship the two ONLINE_ACCESS entries first, confirm no
crash, then add the DLC tags.
Success signal (no restart needed once FIFA re-issues listEntitlements):
byte[0x43f15fd0+0x88] flips 0 → 1, and qword[mgr+0x90] != qword[mgr+0x98] with
(end-begin)/0x118 == 2. Record layout: +0x08 groupName, +0x49 entitlementTag,
+0xca productId, +0x10c useCount.
FIX 3 (DO NOT ship yet) — CensusData notification
CensusData::subscribeToCensusDataUpdates(RSUB=1) is the one outstanding subscription
(re-issued every 120 s: RX #40, 46, 52, 58, 64, 70, 76, 82, 88, 94), we have never pushed
NotifyServerCensusData (component 0x000A, notify id 0x0001), and the delivery path has a
live registered FIFA-side listener (CensusData component obj @0x79f25c0; CensusDataAPI
@0x43c70ff0; listener vector holds exactly one entry, 0x7810a58, whose vtable 0x1439742a8
sits beside the strings CensusData @0x1439742d0 and SubscribeToCensusData @0x143974320).
But checkFUTRostersFlow provably consumes no notification, and the census path is
currently stable (the 120 s cadence is healthy, not a storm). Changing it now adds a
variable to the experiment that fixes the gate. Hold this until after FIX 1 is evaluated.
If you do ship it later, the encoding is (ascending tag order, TDFL empty on purpose —
its element member is TDF type 0x07 variable TDF, which heat2.py cannot encode):
def notify_server_census_data_fields():
return OrderedDict([
("CNP", (INT, 30 * 1000000)),
("NTMT", (INT, 90 * 1000000)),
("RTMT", (INT, 300 * 1000000)),
("TDFL", (LIST, (STRUCT, []))),
])
pushed alongside the existing reply via
notification(COMP_CENSUSDATA, NOTIFY_SERVER_CENSUS_DATA, encode_tdf(...)) — both constants
already exist in the responder (lines 202, 211).
FIX 4 (DO NOT ship) — non-empty Stats / OSDKSettings / userSettings replies
Ruled out as the gate by §1.7 (no pending jobs, no retries, chain continued past every empty reply). Two ideas from the reports are worth recording but not shipping now, because each risks perturbing a currently-working login:
Util::userSettingsLoad— the real server answers a missing key with an error (UTIL_USS_RECORD_NOT_FOUND, in the Util error-enum string block @0x143895a40+), whereas we answer OK +DATA="". Note there are two keys,FirstTimeFlag(RX #30) andAchievementCache(RX #31,rx_0031_0009_000a.bin) — not a duplicate.- Stats config trio (
getStatGroupList0x03,getKeyScopesMap0x0f,getPeriodIds0x14) left empty meansSTATS_ERR_CONFIG_NOTAVAILABLEfor downstream stat calls.
Neither can produce a silent, timeout-free hang, which is what we observe.
3. ORDERING — what ships together
| Order | Fix | Ship with FIX 1? | Rationale |
|---|---|---|---|
| 1 | FIX 1 — ROSTERUPDATE_URL + local HTTP stub |
— | The gate. Ships alone or with FIX 2. |
| 2 | FIX 2 — entitlement group | YES | Independent code path; live-confirmed broken; zero interaction with the roster flow (its only shared surface is NotifyEntitlementsUpdated, which already fires today). Both fixes require the same FIFA relaunch, so bundling costs one experiment instead of two. |
| 3 | FIX 3 — CensusData push | NO | Unproven; perturbs a currently-stable path; would confound FIX 1's signal. |
| 4 | FIX 4 — non-empty Stats/OSDKSettings/userSettings | NO | Ruled out as the gate; regression risk on a working login. |
Ship FIX 1 + FIX 2 together in one relaunch. They are separable in the log: FIX 1's signal
is a new socket to :8081, FIX 2's signal is byte[entMgr+0x88] == 1. If FIX 2 causes a crash
(the group=NULL DLC path, §2.2), back out FIX 2 only and re-run FIX 1 alone.
Keep FIX 2 to the two ONLINE_ACCESS entries on the first run; add the CC* DLC tags only
after confirming no crash.
4. WHAT STILL NEEDS A LIVE EXPERIMENT
4.1 Restart requirements — important
FIX 1 requires a FIFA relaunch. fetchClientConfig(CFID='OSDK_ROSTER') is issued once,
during FUT init (RX #22). A responder-only restart cannot re-deliver it — and worse, killing
the responder drops the Blaze socket, which raises evt_online_disconnected, which
transitions CheckFUTRosterUpdateXML → unloadFUTDatabaseOnFail and aborts FUT.
FIX 2 may not require a relaunch — if FIFA re-issues listEntitlements on its own, the
new reply is consumed immediately and byte[entMgr+0x88] flips. But since FIX 1 forces a
relaunch anyway, do not spend an experiment on this.
Sequence for the combined run:
- Edit
blaze_responder_v3b.py(FIX 1 a/b/c + FIX 2 a/b). - Start the HTTP stub on
127.0.0.1:8081. - Restart the responder.
- Relaunch FIFA 17, log in, enter FUT.
- Watch, in order:
ss -tanp | grep FIFA17for a:8081socket → HTTP stub access log →grep -c CardsDLL /proc/$(pgrep -x FIFA17.exe)/maps→/tmp/blaze_responder.logfor any new RPC beyond PING / census re-subscribe.
Preserve the current frozen process (66672) until the new run reproduces the stall or passes it — it is the only known-good snapshot of the exact wait state.
4.2 Free control experiment on the current frozen process (do this FIRST)
Before relaunching, kill the Blaze responder and watch pid 66672. Per the flow JSON,
evt_online_disconnected must transition CheckFUTRosterUpdateXML → unloadFUTDatabaseOnFail
and tear FUT down. If the loading screen visibly reacts, that proves live that the flow SM
is (a) alive and (b) parked in this state — closing the last inferential gap in §1.1 without
any code change. If nothing happens, the state-machine identification is wrong and the whole
plan needs re-examination. This is the highest-value/lowest-cost probe available right now,
and it costs only a process we were going to relaunch anyway.
4.3 Open items that need a breakpoint (not just a memory read)
- Resolve the downloader
this. The pointer chase*[0x144bfb910] → +0x80is wrong (verified: yields 0x7c17308, a string pool). Break on0x147a6cda0, readrcx, thenthis = *[rcx+8], then read[this+0x2a0](0 = never issued),[this+0x2a8](attempt counter, compared against 5 at 0x147a77460) and[this+0x2b9](the retry-override flag). This turns "the bail-out branch is taken" from a strong inference into a direct observation. - The XML wire format required for
advance. The parser is reachable fromRosterXMLDownloadedSuccess@0x143af17f0 (site 0x147af03be) /RosterXMLDownloadedFail@0x143af16d8 (site 0x147afd0b2); the compared fields are.dbFUTVer/.dbFUTCRC/.dbFUTLoc(0x143af1750 / 0x143af1760 / 0x143af1770). Not yet reversed. This is the likely next gate — a 404 producesback, which dead-ends atunloadFUTDatabaseOnFail, so the XML must actually succeed. Cheapest route: serve200 OKwith a minimal document, capture what the parser rejects, iterate. Aim for an XML that reports "no update available" so the flow advances with no further download. - The five skip predicates in
isFUTRosterXMLAvailable(0x147ad5410(obj), thensess->vt[0x100]/[0xd0]/[0xe8]/[0x108]at 0x147d98a1d / 2d / 3d / 4d). Any one returning true takes the 0x147d98a6b path, which raises an event and skips the download entirely. One of these is plausibly an "already checked this session / offline" short-circuit — a zero-network way to advance the flow. Worth identifying as a fallback if the XML format proves expensive. FUT_RS4_BASE_URL(@0x1438dbe88;FutServerCallbuilds<base> + "ut/game/fifa17/", format"ut/game/%s/"@0x1438dbe58, game id"fifa17"@0x1438dac20). Confirmed live to have zero non-image copies — the UTAS layer has no base URL either. This is not the current gate (that layer has not started), but it is the next one afterEnterFUT. Since the store is merged (§1.5), pre-seeding it intoOSDK_COREalongside FIX 1(c) is free insurance — the reader sites are 0x146ec98e8 / 0x146f3b2b4.- Which group string FUT passes to
HasEntitlement. Recovered literals are"FIFA16PC"(formatted from"FIFA16%s"@0x143b03708 +"PC"@0x1438fe5fc at 0x147bb78b7) and NULL/empty (0x148198340). Sending both group literals is a hedge, not a proof. The script-callable natives (table 0x144311d10..0x144312050; slot 0x144311e80 →HasEntitlementthunk 0x146d0f620, slot 0x144311e88 → loaded-flag thunk 0x146d0f630) have data-driven callers not visible in the disassembly.
4.4 Closed by this synthesis pass — do not re-investigate
- Config store scoping — MERGED, via the
'scfg'→'_all'section lookup decoded at 0x147199d90 (§1.5). CFID choice is not load-bearing. - URL wrapping — the client-config branch (0x147a77550) does not wrap the value;
only the ini branch applies
"https://%s"(@0x143af1840). Serve an absolute URL. - Whether
LoadFUTDatabase/LoadFUTSquadcompleted — they must have. OnlyCheckFUTRosterUpdateXMLsendsShowLoadingIcon, and the spinner is up. - Whether the flow reaches
EnterFUTafter the advance — yes, immediately, viaCheckFUTSquadBinFile's"false"transition (§1.2). No squad bin file means no live-DB popup and no second download.