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
This commit is contained in:
funman300
2026-08-01 09:12:17 -07:00
parent 1fb664710a
commit edab23f04a
26 changed files with 7909 additions and 0 deletions
@@ -0,0 +1,496 @@
# QOS_CONNECT_PLAN — "Unable to connect to the EA servers"
**Date:** 2026-07-31 · **Target:** FIFA17.exe (decrypted, VMA==runtime VA), live pid 50676
**Sources:** decrypted image + `/proc/50676/mem` + `/tmp/blaze_responder.log` + `/tmp/lsx.log` +
`blaze_responder_v3b.py`. **Clean-room: no leaked EA source was consulted.**
---
## 0. Verdict up front
> **The QoS hypothesis in BRIEF5 is WRONG. The empty `LTPS` and the unserved `127.0.0.1:17502` are
> both harmless and were never even reached. The bug is a *silent type error in our `CONF` map*:
> three of the values we send are TimeValue-typed and FIFA's duration parser rejects bare integers,
> so `pingPeriod`, `defaultRequestTimeout` and `connIdleTimeout` all land as `0`.
> The one-line class of fix is `"30000000"` → `"30s"`.**
BRIEF5's premise is also factually wrong and should be retired: **session 1 did not stay up for
~2.5 minutes of pings.** It died at `16:38:07`, one second after the login burst, and the whole log
contains exactly one `Util::ping` ever. The "2.5 minutes" is dead air between two failed connections.
Three of the four independent reverser passes converged on this cause. Everything load-bearing below
was **re-verified from scratch** for this synthesis (live reads + raw bytes re-disassembled), and two
new pieces of proof were added that no individual report had — see §1.5 and §1.6.
---
## 1. THE CAUSE — end to end
### 1.1 What we actually send
`blaze_responder_v3b.py:400-406`, inside `PreAuthResponse.CONF` (a `map<string,string>`):
```python
("autoReconnectEnabled", "1"), # OK — read via atoi
("connIdleTimeout", "90000000"), # BROKEN — TimeValue
("defaultRequestTimeout", "30000000"), # BROKEN — TimeValue
("maxReconnectAttempts", "5"), # OK — read via atoi
("pingPeriod", "20000000"), # BROKEN — TimeValue
```
We wrote microseconds as bare integers. FIFA reads these five keys through **two different vtable
getters**, and only one of them accepts a bare integer.
### 1.2 The two getters
`ConnectionManager` vtable (live vptr `0x1438a0850`):
| Slot | Function | Semantics |
|---|---|---|
| `vt+0x48` | `0x146e1bb80`-adjacent | raw `getConfigString` |
| `vt+0x50` | `0x146e1bb80` | **uint32** — string → `atoi` (import `0x148e21ad8`). **Works.** |
| `vt+0x58` | `0x146e1bda0` | **TimeValue** — string → duration parser `0x1479b2d50`. **Broken for us.** |
`0x146e1bda0` re-disassembled live (raw bytes read out of pid 50676 for this document):
```
146e1bda0: 53 push rbx
146e1bda1: 4883ec20 sub rsp,0x20
146e1bda5: 488b01 mov rax,[rcx]
146e1bda8: 4c89c3 mov rbx,r8
146e1bdab: 4c8d442430 lea r8,[rsp+0x30]
146e1bdb0: ff5048 call [rax+0x48] ; getConfigString
146e1bdb3: 84c0 test al,al
146e1bdb5: 7506 jne 0x146e1bdbd
146e1bdb7: 4883c420 5b c3 add rsp,0x20; pop rbx; ret ; key absent -> false
146e1bdbd: 488b542430 mov rdx,[rsp+0x30]
146e1bdc2: 4889d9 mov rcx,rbx
146e1bdc5: e8866fb900 call 0x1479b2d50 ; TimeValue::parse <-- return value
146e1bdca: b001 mov al,0x1 ; <-- CLOBBERED, UNCONDITIONAL "success"
146e1bdcc: 4883c420 5b c3 add rsp,0x20; pop rbx; ret
```
Line `146e1bdca: b0 01` is the whole problem: **the parser's boolean result is thrown away.** The
caller is told the value was applied. It was not.
### 1.3 The parser rejects us
`0x1479b2d50` accumulates digits, then dispatches on the byte that terminates the run. Raw bytes at
`0x1479b2db4`, read live:
```
1479b2db4: 80f964 7435 cmp cl,'d' ; je
1479b2db9: 80f968 742b cmp cl,'h' ; je
1479b2dbe: 80f96d 7414 cmp cl,'m' ; je (+ 's' lookahead -> ms)
1479b2dc3: 80f973 740a cmp cl,'s' ; je
1479b2dc8: 80f979 7535 cmp cl,'y' ; jne 0x1479b2e02
...
1479b2e02: 30c0 xor al,al ; return false
1479b2e04: eb50 jmp 0x1479b2e56 ; SKIPS the store at 0x1479b2e46
```
Accepted grammar: optional `-`, then `:`-separated `<digits><unit>` with unit ∈ `{y, d, h, m, ms, s}`.
A **NUL terminator with no unit** — i.e. exactly `"20000000"` — falls straight to `0x1479b2e02`,
returns false, and **never executes `mov [r14],rax`**. The caller pre-zeroes each out-slot
(`0x146e1d0af`, `0x146e1d10c`, `0x146e1d143`, all `mov QWORD PTR [rbp+N],rdi` with `rdi=0`), so the
value the SDK ends up applying is **0**.
### 1.4 Live proof that all three landed as zero
Read out of pid 50676 for this document. `ConnectionManager = 0x43c47ff0` (vptr `0x1438a0850` ✓,
`CM+0x1288 = "Blaze 15.1.1.3.0 (OpenFUT)\n"` — our own `SVER`, so this is the object our reply
configured):
| Field | Offset | Written at | **Live value** | What we intended |
|---|---|---|---|---|
| `pingPeriod` | `CM+0xd1c` | `0x146e1d0c9` / `0x146e1d0f5` | **15000** | 20000 |
| `defaultRequestTimeout` | `CM+0x278` | `0x146e1d12c` | **0** | 30000 |
| `connIdleTimeout` | `CM+0xd28` | `0x146e1d164` | **0** | 90000 |
| `maxReconnectAttempts` | `CM+0xc5c` | `0x146e1d1b6` | 5 ✓ | 5 |
| `autoReconnectEnabled` | `CM+0x11b7` | `0x146e1d188` | 1 ✓ | 1 |
The split is exactly along the getter boundary: **every `atoi` key took, every TimeValue key was
dropped.** That is not a coincidence, it is the signature.
The `15000` is *not* a "key missing" default — it is the clamp. Bytes at `0x146e1d0a5`, live:
```
146e1d0a5: 488d1584 39a8fc lea rdx,[rip-0x357867c] ; -> 0x1438a0a30 = "pingPeriod"
146e1d0ac: 4889d9 mov rcx,rbx
146e1d0af: 48897d07 mov [rbp+7],rdi ; pre-zero out-slot
146e1d0b3: ff5058 call [rax+0x58] ; getConfigTimeValue -> always true
146e1d0b6: 48be cff753e3a59bc420 movabs rsi,0x20c49ba5e353f7cf ; /1000 magic
146e1d0c0: 84c0 test al,al
146e1d0c2: 750d jne 0x146e1d0d1
146e1d0c4: b8983a0000 mov eax,0x3a98 ; 15000 = key-absent default
146e1d0c9: 89831c0d0000 mov [rbx+0xd1c],eax
```
`al` is always 1, so we take the `jne` — the parsed value (0) is divided by 1000 → 0, then the
`cmovl` at `0x146e1d0f2` clamps anything below 1000 up to `0x3a98`. Reaching 15000 *through the
clamp* is positive proof the parse produced 0. The `lea` also confirms the key name byte-for-byte:
`0x1438a0a30` is exactly the address our own source comment on line 406 cites.
### 1.5 NEW PROOF — session 2's response callback provably never ran
`onPreAuthResponse` (`0x146e1cf10`) has **no branch on its success path that can skip
`0x146e1d1cb: call 0x146e1e460` (sendPing)**. So "did a ping happen" is a perfect oracle for "did the
callback run". The log shows exactly one ping ever (`RX #3984`, 16:38:06, conn 43427) and none in
session 2.
`onPingResponse` (`0x146e1d290`) stamps `CM+0x11a0 = response STIM` and `CM+0x11a4 = local tick`.
Live values, decoded for this document:
- `CM+0x11a0 = 1785541086`**`2026-07-31 16:38:06`**, and our responder builds `STIM` as
`int(time.time())` (`blaze_responder_v3b.py:564`) — this is literally the timestamp *we* generated
and sent in session 1.
- `CM+0x11a4 = 14437471 ms`. This is a system-monotonic tick, not process-relative. Against
`/proc/uptime = 16638.9 s` at wallclock `17:14:48`, it back-solves to **`16:38:06`**.
Two independent clocks, both landing on **16:38:06**, not 16:40:34. The preAuth response callback
last completed in session 1 and **did not run at all in session 2** — even though our reply was
byte-identical (`cmp -l rx_3983… rx_4009…` differs in one byte, offset 13 = msgNum) and was fully
received (the raw 772-byte payload is still sitting in the recv buffer at `0x43c4e2xx-0x43c4e373`).
**The teardown therefore precedes the dispatch of our reply. Nothing about the reply's *content* is
being rejected.** That kills the "QoS field validation" theory outright — the QoS fields are parsed
*inside* the callback that never ran.
### 1.6 NEW PROOF — why session 1 survived preAuth and session 2 did not
This is the asymmetry BRIEF5 asked about, and it falls straight out of §1.4.
`ConnectionManager::ctor` at `0x146e187ea`:
```
146e187ea: 488b5608 mov rdx,[rsi+0x8] ; BlazeHub
146e187ee: 8b8244050000 mov eax,[rdx+0x544]
146e187f4: 89867802 0000 mov [rsi+0x278],eax ; defaultRequestTimeout = hub+0x544
```
Live `hub = 0x43c47330`, `hub+0x544 = 10000`. So a freshly-constructed ConnectionManager has a
**healthy 10000 ms request timeout** — and `CM+0x278` is only ever overwritten **inside the preAuth
*response* callback** (`0x146e1d12c`).
| | `CM+0x278` while its own preAuth RPC was outstanding | Outcome |
|---|---|---|
| conn A (43427, 16:38:06) | **10000 ms** (ctor default, not yet clobbered) | preAuth callback ran → ping sent → login burst |
| conn C (35037, 16:40:34) | **0** (clobbered by conn A's callback, persists for the CM's lifetime) | preAuth job expires before the reply is dispatched → teardown |
**Our own preAuth reply poisons the timeout that the *next* preAuth needs.** Session 1 worked *because
it ran before our config was applied*. That is why the identical bytes produce opposite outcomes, and
why no amount of varying the QOSS content would ever have helped.
The same zero explains the rest of the log: with `connIdleTimeout = 0`, **every** connection dies at
its first idle moment — conn A right after the ping reply, conn B (`43131`) right after `TX #4008.0`,
the last frame of the login burst, conn C right after our preAuth reply. Conn B's 24 RPCs all
succeeded because localhost replies land inside the same SDK tick they were issued on, so the expiry
sweep never saw them outstanding.
Conn B also explains itself: `onSocketConnected` (`0x146e1cb60`) gates preAuth on a flag —
```
146e1cbed: 4038bb3c0b0000 cmp BYTE PTR [rbx+0xb3c],dil ; dil = 0 (errorCode)
146e1cbf4: 7507 jne 0x146e1cbfd
146e1cbf6: e8f2140000 call 0x146e1e0f0 ; sendPreAuthRequest
146e1cbfb: eb0c jmp 0x146e1cc09
146e1cbfd: c68380110000 01 mov BYTE PTR [rbx+0x1180],1
146e1cc04: e854180000 call 0x146e1e460 ; sendPing only
```
Conn B skipped preAuth (its first frame is `fetchClientConfig msgNum=2`) → `CM+0xb3c` was non-zero →
conn B was an **SDK auto-reconnect** (`autoReconnectEnabled=1`, `maxReconnectAttempts=5`, both live
✓). `CM+0xb3c` reads **0** now, so the flag is cleared on full teardown — consistent with conn C
re-sending preAuth. And `msgNum` is one monotonic counter `0…26` across all three sockets, with live
`CM+0xc50 = 26` ✓ — **there is only one ConnectionManager**, so "session 2 is a different kind of
connection" is false.
### 1.7 The QoS path is exonerated, with disasm
`QosManager::startLatencyProbes` `0x146e1dae0`, re-disassembled live:
```
146e1dae0: 53 push rbx
146e1dae1: 4883ec20 sub rsp,0x20
146e1dae5: 488b8120020000 mov rax,[rcx+0x220] ; LTPS end
146e1daec: 4889cb mov rbx,rcx
146e1daef: 48398118020000 cmp [rcx+0x218],rax ; LTPS begin
146e1daf6: 7508 jne 0x146e1db00
146e1daf8: 30c0 xor al,al ; EMPTY -> false, benign, no side effects
146e1dafa: 4883c420 5b c3 add rsp,0x20; pop rbx; ret
```
Its caller `QosManager::initialize` `0x146e1c3f0` has a dedicated no-probes-started branch:
```
146e1c5aa: e831150000 call 0x146e1dae0
146e1c5af: 84c0 test al,al
146e1c5b1: 7508 jne 0x146e1c5bb
146e1c5b3: 4889f9 mov rcx,rdi
146e1c5b6: e875d5ffff call 0x146e19b30 ; normal QoS-completion fn
146e1c5bb: c6471001 mov BYTE PTR [rdi+0x10],1 ; initialized = true
```
Live `QosManager = CM+0x1db0 = 0x43c49da0` (vptr `0x1438a07c8` ✓) proves that exact path ran to
completion:
| Field | Live | Meaning |
|---|---|---|
| `Q+0x10` | **1** | `initialize()` reached its final store — completed |
| `Q+0x20` | 0 | both QoS tests off — our `enableQos*Test=false` parsed fine |
| `Q+0x218` / `Q+0x220` | 0 / 0 | LTPS empty → probe loop `0x146e1c4bb..0x146e1c4f0` never ran |
| `Q+0x18` | 0 | no DirtySDK `QosApi` object exists |
| `Q+0x1c8` / `Q+0x1f8` / `Q+0x250` | 10 / 17502 / 5000000 | our `LNP` / `BWPS.PSP` / `TIME` arrived intact |
| `hub+0x53f` | 1 | QoS is *enabled*; the early-out at `0x146e1c44b` was **not** taken |
| `CM+0x1f60` | 1 | SDK's own "QoS ready" flag is set |
So QoS ran, with QoS enabled, with an empty ping-site list, and **reported ready**.
**Nothing ever contacted 17502.** Verified for this document: no `445e` in `/proc/net/{tcp,udp,tcp6,udp6}`,
and `ss -lntup` shows our responder listening only on 42127 / 42130 / 42131. The `BWPS` port is merely
the `QosApi` *create* argument (`0x146e1975a: movzx r8d,WORD PTR [rbx+0x1f8]`), and the real probe
transport is HTTPS (`https://%s:%u/qos/qos` @ `0x143aa75c0`) driven **only** by `LTPS` entries, of
which there are none.
`PinQosError_ReferenceEvent` @`0x1439de5c8` has zero code xrefs (it sits in a telemetry name table
next to `CM_WonTournament`, `PS3TOSAccepted`, …) and `POW:sConnectionManager` @`0x143995b88` is an
allocation tag in the **powdll loader** string block. Both anchors in BRIEF5 are red herrings.
### 1.8 The on-screen message corroborates all of the above
The popup text is loc key `OSDK_LOST_CON_TO_EA` @`0x14395c6c8` (resolved English on the heap at
`0x7b438e0`). Selection site `0x1471aa220`, bytes re-read live at `0x1471aa286`:
```
1471aa286: 83ba0c02000001 cmp DWORD PTR [rdx+0x20c],1
1471aa28d: 488d0534247bfc lea rax,[rip-0x384dbcc] ; 0x14395c6c8 OSDK_LOST_CON_TO_EA
1471aa294: 4c8d1d157d7afc lea r11,[rip-0x38582eb] ; 0x143951fb0 OSDK_A_R30
1471aa29b: 4c0f44d8 cmove r11,rax
```
Caller `0x1471a1dda` passes `rdx = cnncMgr+0x4c4`, so the tested field is `cnncMgr+0x6d0` — the
**"an EA request is currently in flight" busy flag** (set to 1 immediately before each async Blaze op
at `0x1471b50bc`, `…5115`, `…5202`, `…5282`, `…5322`, `…5cb1`, `0x1471b6578`; cleared on completion at
`0x14717d7bb`, `0x147190d06`, `0x1471b4fa4`, `0x1471b501e`, `0x1471b54a8`, `0x1471b5c37`, `0x1471b65c6`,
`0x1471d5486`, `0x1471d6919`).
So the message means, literally: **"the socket died while an RPC was outstanding."** Not "QoS
validation failed", and not "login timed out" — the 30 s login timeout path
(`LoginStateVersionCheck::Tick` `0x1471b4ee0`, `lea eax,[rdx+0x7530]`) resolves to table index 0 and
yields `OSDK_A_R30`, a *different* on-screen string. The alternative state-machine selector also did
not fire: the live `LoginStateLogin` object (`0x43d189d8`, vtable `0x14395c180`) still has
`+0x7c errCode = 0` and `+0x80 = 0x143951fb0` (the ctor default), i.e. no error was ever latched.
The popup is therefore a pure transport-disconnect notice, and it is *exactly* what a request that
expires under a 0 ms timeout would produce.
### 1.9 Causal chain, one paragraph
We send `defaultRequestTimeout="30000000"` in `PreAuthResponse.CONF`. FIFA reads it with the TimeValue
getter `0x146e1bda0`, whose parser `0x1479b2d50` requires a unit suffix and returns false on a bare
integer; the getter discards that false (`146e1bdca: b0 01`) and the SDK applies the pre-zeroed
out-slot, i.e. **0**, to `CM+0x278` — overwriting the ctor's healthy 10000 ms — plus `CM+0xd28 = 0`
(idle timeout) and `CM+0xd1c` clamped to 15000. This happens *inside session 1's preAuth response
callback*, so session 1's own preAuth was still protected by 10000 ms and completed normally. From
that instant on, every connection on this ConnectionManager dies at its first idle moment, and any RPC
that is not answered within the issuing tick is expired immediately. When the user goes online at
16:40:34, conn C issues `Util::preAuth` under a 0 ms timeout; the job is expired before our reply is
dispatched, the callback never runs (proved by the 16:38:06 ping tick), the SDK tears the socket down
with `cnncMgr+0x6d0 == 1`, and `0x1471aa220` selects `OSDK_LOST_CON_TO_EA`: **"Unable to connect to
the EA servers at this time."**
---
## 2. THE FIX — ranked and minimal
### FIX 1 — REQUIRED, proven, 3 lines, no new listener or thread
`blaze_responder_v3b.py:401-406`. Change bare microsecond integers to unit-suffixed duration strings:
```python
("connIdleTimeout", "90s"), # was "90000000" -> parsed to 0
("defaultRequestTimeout", "30s"), # was "30000000" -> parsed to 0
("pingPeriod", "20s"), # was "20000000" -> clamped to 15000
```
`"90000ms"` / `"30000ms"` / `"20000ms"` are equally valid. Leave `maxReconnectAttempts` and
`autoReconnectEnabled` as plain integers — they go through the `atoi` getter and already work.
Grammar accepted by `0x1479b2d50`: optional leading `-`, then `:`-separated `<digits><unit>` with
unit ∈ `{y, d, h, m, ms, s}`; combined as `((((y*365+d)*24+h)*60+m)*60+s)*1000+ms`, then ×1000 → µs.
Corroborating precedent from the shipped image: the default for `QosConfigInfo.timeout` is the literal
string `"5s"` @ `~0x14388cc90`.
While editing, add a comment recording the getter split so this class of bug cannot recur:
```python
# CONF value TYPES matter. Keys read via ConnMgr vt+0x50 (0x146e1bb80) go through
# atoi -> plain integers are fine. Keys read via vt+0x58 (0x146e1bda0) go through
# TimeValue::parse (0x1479b2d50), which REQUIRES a unit suffix (y/d/h/m/ms/s) and
# silently yields 0 for a bare integer -- 0x146e1bdca discards the parser's bool.
```
**Non-interactive verification (no relaunch needed to check the *parse*, only a reconnect):**
read the live ConnectionManager and assert the three fields flipped.
```bash
PID=$(pgrep -x FIFA17.exe)
python3 - <<'EOF'
import struct
PID=<pid>
f=open("/proc/%d/mem"%PID,"rb")
def u32(va): f.seek(va); return struct.unpack("<I",f.read(4))[0]
CM=0x43c47ff0 # re-locate: scan rw memory for qword == 0x1438a0850
print("pingPeriod CM+0xd1c =", u32(CM+0xd1c), "(want 20000, currently 15000)")
print("defReqTO CM+0x278 =", u32(CM+0x278), "(want 30000, currently 0)")
print("connIdleTO CM+0xd28 =", u32(CM+0xd28), "(want 90000, currently 0)")
EOF
```
> Note `CM=0x43c47ff0` is this process instance's address. After a relaunch, re-locate it by scanning
> rw regions for the 8-byte vptr `0x1438a0850` (exactly one hit), or for the string
> `Blaze 15.1.1.3.0 (OpenFUT)` and subtracting `0x1288`.
**Observable success signal (the one that matters):** on the go-online connection, after our
`Util::preAuth` reply the client emits **`Util::ping` (component `0x0009`, cmd `0x0002`)** instead of
`closed`, and proceeds to **`Authentication::login` (`0x0001`/`0x000a`)** on that same socket. A
secondary signal: the boot connection stops dying ~1 s after the login burst and starts emitting a
ping every ~20 s.
### FIX 2 — cheap, low risk, not the cause: close the CIDS gap
`blaze_responder_v3b.py:521-525`. We advertise `CIDS = (1, 4, 5, 7, 9, 15, 25, 28, 30722)`, but the
session-1 log shows the client talking to three components we never listed:
| Component | Seen in log | Frame |
|---|---|---|
| `0x000a` (10) | `RX #3999 cmd:0x0005`, `RX #4008 cmd:0x0002` | UserSessions-adjacent |
| `0x000b` (11) | `RX #4001 cmd:0x0a28` | — |
| `0x08c9` (2249) | `RX #4005 cmd:0x0001` | — |
Add `10, 11, 0x08c9` to `COMPONENT_IDS`. Not the cause (the client sent to them regardless and we
replied), but it is free and it removes a known divergence from a real server. Do this **after**
FIX 1 lands and only if FIX 1 alone does not clear the gate, so the two changes stay separable.
### FIX 3 — DO NOT DO YET. Populating `LTPS` (and serving :17502)
**Explicitly deferred, and it is the one item that would need a new listener and thread.**
Reasons to leave `qos_config()` (`blaze_responder_v3b.py:527-538`) exactly as it is:
1. The empty-LTPS path is a *first-class, benign* branch (`0x146e1daf8: xor al,al`) and live memory
proves it completed with QoS enabled (`Q+0x10=1`, `hub+0x53f=1`, `CM+0x1f60=1`). It blocks nothing.
2. The QoS manager is initialized at `0x146e1d214`, **after** the `sendPing` at `0x146e1d1cb`, inside
a callback that session 2 provably never entered (§1.5). It is downstream of the failure.
3. Populating `LTPS` **creates** work: the per-site loop `0x146e1c4bb..0x146e1c4f0` would then run and
fire real HTTPS `GET https://127.0.0.1:17502/qos/qos` requests. Serving those needs a **new TLS
listener + thread** in the responder, with a cert the client accepts. We would be trading a
non-problem for a real one.
4. The Heat2 framing for `map<string, STRUCT>` is flagged **UNVERIFIED** in `heat2.py:204` (and the
`STRUCT` value path in `_enc_value` likewise). A malformed TDF here would fail the client's decode
of the *whole* PreAuthResponse — a strictly worse failure than the current one.
Only revisit if, **after FIX 1**, the game reaches login but then stalls on a ping-site-dependent
feature. In that case the anchors are already mapped: `QosConfigInfo` copied to `QosManager+0x1b8`;
`+0x1c8`=LNP, `+0x1d0`=BWPS (`QosPingSiteInfo` vtable `0x143889ba0`), `+0x200`=LTPS map (vtable
`0x143889c48`, begin `+0x218` / end `+0x220`, **element stride 0x20**, int32 latency at `element+0x18`
— stride and latency offset independently confirmed by the OSDK helper `0x1471c0c90`, which loops
`add rax,0x20` writing `mov DWORD PTR [rax+0x18],0x3e8`). The minimal payload would be:
```python
("LTPS", (MAP, (STRING, STRUCT, [
("eu-west", OrderedDict([("PSA", (STRING, "127.0.0.1")), ("PSP", (INT, 17502))])),
]))),
("LNP", (INT, 1)), # drop from 10 -- one probe round, not ten
```
and it must ship **together with** an HTTPS listener on `127.0.0.1:17502` answering `/qos/qos`.
The OSDK side degrades gracefully in the meantime: `GetPingSiteAliasList` impl `0x1472d1780`
`0x14728c4c0` explicitly returns an empty script array when the container is NULL or its count
(`[container+0x28]`) is 0, so an empty ping-site list yields `""` / `[]` rather than an error.
### FIX 4 — instrumentation to buy, cheaply, what the next iteration will need
Independent of the above, and worth landing with FIX 1:
- **Sub-second timestamps** in the responder log. Current second-resolution timestamps cannot resolve
the RPC round trip, which is the whole question for a timeout bug. (Measured for this document: the
pure-CPU cost of `heat2.dump` + `hexdump` on the 772-byte reply is only **0.08 ms/iter**, and
`raw.sendall(out)` at line 1252 already runs *before* the TX logging at 1254-1260 — so responder
latency is almost certainly not a contributing factor. Timestamps would confirm that outright.)
- **Log the redirector request body.** `blaze_responder_v3b.py:1338` logs only the request line and
`build_redirect_response()` (~:1274) ignores the request entirely, so the responder cannot actually
substantiate "session 2 asked for the SAME service" — that was established from the byte-identical
preAuth requests, not from the redirector.
- **Log FIN vs RST and bytes-read-before-close** on the Blaze socket. The recv-loop EOF at
`blaze_responder_v3b.py:1195-1270` currently reports a bare `closed` for every case.
- **Timestamp `/tmp/lsx.log`.** The four `GetAuthCode` issuances (ids 25-28) cannot currently be
aligned with the Blaze log.
---
## 3. What still needs a live experiment
Static RE has taken this as far as it can go. Everything below needs the game.
### 3.1 The decisive one — apply FIX 1, then re-enter online (no relaunch strictly required, but cleaner)
1. Edit the three CONF lines, restart **the responder only**.
2. Relaunch FIFA17 (cleanest: guarantees a fresh ConnectionManager with `CM+0x278 = 10000` from the
ctor and `CM+0xb3c = 0`).
3. Immediately after the boot login, read live `CM+0xd1c` / `CM+0x278` / `CM+0xd28`. **Gate:** they
must read `20000 / 30000 / 90000`. If they do not, the fix did not apply and nothing else matters.
4. Then have the user go online. **Gate:** the log must show `Util::ping` after our preAuth reply on
that connection, then `Authentication::login`.
Two clean outcomes, both informative:
- **Connection survives → FIX 1 was the whole bug.** Proceed to whatever the next gate is (likely
the constant `AuthCode` `OPENFUT-0000…` we return on every LSX `GetAuthCode`).
- **Connection still dies → the zero timeouts were real but not sufficient.** Go to §3.2.
### 3.2 If it still dies — read the error code the SDK hands the callback
This is the single highest-value remaining measurement and it is small. Breakpoint (or
single-step-and-read) at `0x146e1cf10` (`onPreAuthResponse` entry) and capture **`r8d`**, or at the
branch `0x146e1cf33` (`test r8d,r8d` / `je 0x146e1cf82`). That one integer separates:
- **callback entered with a non-zero errorCode** → the SDK generated the failure itself (timeout,
transport) — chase the failure dispatcher `0x146e18170` (callers `0x146e1990c`, `0x146e1c1c2`,
`0x146e1cf77`, `0x146e1d2de`) and the connect-failure handler `0x146e1cc70`;
- **callback never entered at all** → the socket was torn down before dispatch, i.e. exactly the
timeout-expiry story, and the remaining question is *who* called `disconnect`.
Pair it with a live read of `LoginStateLogin` (`0x43d189d8`, vtable `0x14395c180`) **at the moment of
failure**: if `+0x80` becomes `0x14395c6c8` the state-machine selector fired and `+0x260` names the
outstanding async op (states `{3,4,6,10,12,13,14,15,23,25}` map to `OSDK_LOST_CON_TO_EA`); if `+0x80`
stays `0x143951fb0` with `+0x7c == 0`, it was the transport-disconnect popup at `0x1471aa220`.
### 3.3 Open items that only dynamic work can close
| # | Question | Why it is open | How to close it |
|---|---|---|---|
| 1 | **Who reads `CM+0xd28` and `CM+0x278`?** A disp32 scan for `0x00000d28` / `0x00000278` across `0x144ed3000-0x14a000000` finds **only the write sites** (`0x146e1d163`, `0x146e1d12b`). | Readers are inside VM-mutated functions. So "0 ⇒ close immediately" is inferred from behaviour (all three connections die at first idle), **not** proven by disassembly. | FIX 1 is itself the experiment. If the connections stop dying, the inference was right. |
| 2 | **What writes `CM+0xb3c`** (the preAuth-skip flag)? Byte-pattern scans for `c6 8x 3c 0b 00 00` over `0x144ed3000-0x149000000` find no writer; only the read at `0x146e1cbed`. | Set from a virtualized function. | Watchpoint on `CM+0xb3c`. Knowing it would let us distinguish reconnect from fresh connect — and possibly force *every* connection onto the working preAuth-skipping path. |
| 3 | **Two functions on the QoS completion path are VM stubs**: `0x146e19b30` (`push rcx; lea rcx,[0x148775779]; jmp 0x14e02d517`) and the QoS-retrieved callback `0x146e1c5e0` (`push rcx; lea rcx,[0x146fdea3d]; jmp 0x149875d6c`). Same for the writer of `CM+0x1f60` (only the read at `0x146e1c3c2` exists). | Denuvo/VMProtect-style. Not statically decompilable. | Session 1 provably ran both to completion, so they are not fatal. Only worth tracing if FIX 3 ever becomes necessary. |
| 4 | **Does the client actually probe `:17502` once LTPS is populated?** | Never tested — LTPS has always been empty; nothing has ever opened that port (`/proc/net/*` clean). | `tcpdump -i lo port 17502` during the first attempt with a populated LTPS. Only relevant under FIX 3. |
| 5 | **What is disconnect `reason == 2`?** The dispatcher `0x14718dda0` tail-jumps to `0x1471aa220` passing its own arg; reasons 3/4/5 additionally emit a `NETW`/`LoginError` telemetry event (string `0x14395c840`). The producer of the enum was not walked. | Static chase not completed. | Backtrace from `0x14718dda0` at the moment of failure. Almost certainly "connection to Blaze lost". |
| 6 | **Blaze error codes `0x000B0001` and `0x000C0001`** are the only two special-cased in `OnBlazeError` `0x1471d53e0` (`cmp edx,0xb0001; je` / `cmp edx,0xc0001; je`) — i.e. the only two failures FIFA treats as recoverable. Not decoded. | Would tell us which failures we can safely provoke. Component `0x000b` is live in our traffic (`RX #4001 cmd:0x0a28`). | Decode against the component table; low priority. |
| 7 | **The constant `AuthCode`.** `/tmp/lsx.log` shows four `GetAuthCode` issuances (ids 25-28) and we return the same `OPENFUT-0000…` every time. | Untested — we have never survived past preAuth on the online connection. | This is the most likely **next** gate once FIX 1 lands. Have it in mind, do not pre-emptively change it. |
---
## 4. One-line summary for the commit message
```
blaze: send CONF durations as unit-suffixed strings ("30s"), not bare microseconds
FIFA17's TimeValue getter (ConnMgr vt+0x58, 0x146e1bda0) discards the parse
result from 0x1479b2d50, which rejects unit-less integers. pingPeriod,
defaultRequestTimeout and connIdleTimeout were all landing as 0 (live:
CM+0xd1c=15000 via clamp, CM+0x278=0, CM+0xd28=0), zeroing the request/idle
timeouts for the whole ConnectionManager lifetime and tearing down every
connection at its first idle moment -- including the go-online preAuth, whose
response callback never ran (proved by CM+0x11a4 still holding session 1's
16:38:06 tick). Not a QoS/ping-site issue: LTPS-empty is a benign first-class
branch and nothing ever contacted :17502.
```