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