Files
OpenFUT/fifa17-recon/tools/login_dump/FUT_LOADING_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

585 lines
30 KiB
Markdown

# 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:
```json
"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.
```json
"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) and `127.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,
strings `LOAD_FUT_DLL` @0x143aebde8, `CardsDLL` @0x143aebe00) is **not mapped**, i.e.
`EnterFUT` never ran. This is the definitive "FUT booted" tripwire.
* Blaze link healthy and idle: `/tmp/blaze_responder.log` shows 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:
```python
# --- 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):
```python
"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`:
```python
("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
1. **A third socket.** `ss -tanp | grep FIFA17` shows a connection to `127.0.0.1:8081`.
This alone proves the diagnosis — it is FIFA's **first ever** non-LSX/non-Blaze socket.
2. **`HideLoadingIcon`** fires and the flow leaves `CheckFUTRosterUpdateXML`.
3. **`CardsDLL_Win64_retail.dll` appears in `/proc/$(pgrep -x FIFA17.exe)/maps`.** This is
the definitive "FUT booted" signal — `EnterFUT` ran. Per §1.2, with no squad bin file the
flow goes `CheckFUTSquadBinFile → (false) → EnterFUT` **immediately** after the advance,
so signal 3 should follow signal 1 within seconds.
4. Failing that: the `FUT_SQUAD_DOWNLOAD_FAIL` popup 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 into `unloadFUTDatabaseOnFail`, 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") != NULL` **OR** `strstr(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:
```python
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):
```python
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`/`TAG` must contain **no `|` and no `/`** — survivors are re-serialised as
`"PRID|GNAM|TAG|UCNT/"` (formats `%s|` @0x1438fa858, `%u/` @0x1438fa85c) and re-parsed with
`strtok` on those delimiters (0x146f4db93 / 0x146f4dbd3). `CONTENT_ID = "1027460"` is fine.
* Keep the list small — the compressor buffer is `0x1000` bytes (`0x146f27681 mov r8d,0x1000`).
* Stored records are matched later with fixed-width `strncmp` on 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):
```python
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) and
**`AchievementCache`** (RX #31, `rx_0031_0009_000a.bin`) — not a duplicate.
* Stats config trio (`getStatGroupList` 0x03, `getKeyScopesMap` 0x0f, `getPeriodIds` 0x14)
left empty means `STATS_ERR_CONFIG_NOTAVAILABLE` for 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:**
1. Edit `blaze_responder_v3b.py` (FIX 1 a/b/c + FIX 2 a/b).
2. Start the HTTP stub on `127.0.0.1:8081`.
3. Restart the responder.
4. Relaunch FIFA 17, log in, enter FUT.
5. Watch, in order: `ss -tanp | grep FIFA17` for a `:8081` socket → HTTP stub access log →
`grep -c CardsDLL /proc/$(pgrep -x FIFA17.exe)/maps``/tmp/blaze_responder.log` for 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)
1. **Resolve the downloader `this`.** The pointer chase `*[0x144bfb910] → +0x80` is wrong
(verified: yields 0x7c17308, a string pool). Break on `0x147a6cda0`, read `rcx`, then
`this = *[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.
2. **The XML wire format required for `advance`.** The parser is reachable from
`RosterXMLDownloadedSuccess` @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 produces `back`, which dead-ends at `unloadFUTDatabaseOnFail`,
so the XML must actually *succeed*. Cheapest route: serve `200 OK` with 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.
3. **The five skip predicates** in `isFUTRosterXMLAvailable` (`0x147ad5410(obj)`, then
`sess->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.
4. **`FUT_RS4_BASE_URL`** (@0x1438dbe88; `FutServerCall` builds `<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 after `EnterFUT`.
Since the store is merged (§1.5), pre-seeding it into `OSDK_CORE` alongside FIX 1(c) is
free insurance — the reader sites are 0x146ec98e8 / 0x146f3b2b4.
5. **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 → `HasEntitlement`
thunk 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` / `LoadFUTSquad` completed** — they must have. Only
`CheckFUTRosterUpdateXML` sends `ShowLoadingIcon`, and the spinner is up.
* **Whether the flow reaches `EnterFUT` after the advance** — yes, immediately, via
`CheckFUTSquadBinFile`'s `"false"` transition (§1.2). No squad bin file means no live-DB
popup and no second download.