# FUT / UTAS (RS4) CONNECT PLAN — FIFA 17, clean-room

Synthesis of four independent reversals of `CardsDLL_Win64_retail.dll` (PE base `0x180000000`,
live-mapped at `0x6ffffc140000`, slide `0x6FFE7C140000`) plus live `/proc/932452/mem` reads and
`/tmp/blaze_responder.log` / `/tmp/roster_server.log`.
Scope: get FUT to **open a socket to us, authenticate, and stop showing "error connecting to
FIFA 17 Ultimate Team"**. Not full playable FUT.

**Headline correction to BRIEF8.** The brief's hypothesis ("we serve the base-URL keys EMPTY, so
CardsDLL can't build a URL") is **wrong**. The URL is built fine. CardsDLL falls back to a
**hardcoded default base URL** and that host is dead:

```
default base   = "http://easw.easports.com:8099/"      .rdata file 0x21d680 = VA 0x18021e280
default ptr    = VA 0x1802caa08  ->  live 0x6ffffc35e280 -> same string
live descriptors: Counter({(b'http://easw.easports.com:8099/', ks=0): 126})   # ALL 126 entries
$ getent hosts easw.easports.com  ->  rc=2 (NXDOMAIN), no /etc/hosts entry
```

So resolution succeeds, DNS fails, `connect()` is never reached, no SYN — exactly the observed
symptom. The in-process telemetry ring confirms the flow ran and died with no HTTP status at all:
`{"type":"utas","status":"start_flow"}` then `{"type":"utas","status":"error","status_code":"0"}`.

This changes the fix from "make a URL exist" to "point the URL at us" — and it opens a **zero-config
fallback** (§2.0): just make `easw.easports.com` resolve to `127.0.0.1` and listen on `:8099`.

---

## 1. THE URL + AUTH CHAIN

### 1.1 Two tables drive everything

**Module (group) table — 48 entries, VA `0x18021df80`, stride 0x10 = `{char* pathTemplate; char* NAME}`**
(dumped from the file; entries 45–47 have a NULL path):

| idx | path template | NAME | idx | path template | NAME |
|---|---|---|---|---|---|
|0|`ut/%s/auctionhouse`|AUCTIONHOUSE|24|`ut/%s/season/%%s/reset`|SEASONRESET|
|1|`ut/%s/clubUser`|CLUB_USER|25|`ut/%s/season/friendly`|FRIENDLYSEASON|
|2|`ut/%s/user/list`|CLUB_INFO|26|`ut/%s/purchased`|PURCHASED|
|3|`ut/%s/club`|CLUB|27|`ut/%s/store`|STORE|
|4|`ut/%s/defid`|DREAM|28|`ut/%s/watchList`|WATCHLIST|
|5|`ut/%s/squad`|SQUAD|29|`ut/delete/%s/watchList`|DELETEWATCHLIST|
|6|`ut/delete/%s/squad`|DELETE_SQUAD|30|`ut/%s/tradePile`|TRADEPILE|
|7|`ut/%s/leaderboards/options`|LBOPTIONS|31|`ut/%s/trade`|TRADE|
|8|`ut/%s/leaderboards`|LBDEFAULT|32|`ut/delete/%s/trade`|DELETETRADE|
|9|`ut/%s/activeMessage`|PAFPRACTICE|33|`ut/%s/marketdata`|MARKETDATA|
|10|`ut/%s`|**UT**|34|`ut/%s/clientdata`|CLIENTDATA|
|11|`ut/%s/user`|**USER**|35|`ut/auth`|**AUTH**|
|12|`ut/delete/%s/user`|DELETEUSER|36|`ut/delete/auth`|DELETE_AUTH|
|13|`ut/%s/item`|ITEMS|37|`ut/%s/phishing`|PHISHING|
|14|`ut/%s/item/resource`|ITEMS_BY_RES|38|`ut/%s/captcha`|CAPTCHA|
|15|`ut/delete/%s/item`|DELETEITEMS|39|`ut/%s/tfa`|TFA|
|16|`ut/%s/match`|MATCH|40|`ut/%s/squad/mode`|SQUADMODE|
|17|`ut/%s/sbs`|SBC|41|`ut/%s/draft/mode`|DRAFT|
|18|`ut/%s/tournament`|TOURNAMENT|42|`ut/%s/champion`|CHAMPIONS|
|19|`ut/%s/tournament/user`|TOURNAMENTUSER|43|`ut/v2/%s/store`|V2STORE|
|20|`ut/delete/%s/tournament/user`|TOURNAMENTQUIT|44|`ut/%s/livemessage`|LIVEMESSAGE|
|21|`ut/%s/season`|SEASON|45|*(null)*|ADMIN|
|22|`ut/%s/season/user`|SEASONUSER|46|*(null)*|DEBUG|
|23|`ut/%s/season/%%s/user`|SEASONUSER_ALTER|47|*(null)*|MAINTENANCE|

**Call (endpoint) descriptor table — 125 live entries + terminator, VA `0x1802caa20`, stride 0x30:**

```
+0x00 char*  callName        e.g. "GetSettings"
+0x08 u64    moduleIdx       index into the 48-entry table above
+0x10 char*  CALL_TAG        e.g. "GETSETTINGS"       (uppercase, used in the config key)
+0x18 char*  baseUrl         <-- what resolve() writes; live = the easw default for all 126
+0x20 u32    preAuthFlag     1 for GetSettings + the market/IS* calls, 0 otherwise
+0x21 u8     killSwitch      live = 0 for all 126
+0x28 fnptr  setKillSwitch   one thunk per call, e.g. 0x180124020 writes 0x1802cae31
```

Verified entries relevant to boot: `21 GetSettings mod=10 UT flag=1`, `22 Authentication mod=35 AUTH`,
`23 Login mod=11 USER`, `24 Logout mod=36 DELETE_AUTH`, `27 CreateUser mod=11 USER`,
`28 GetUserInfo mod=11 USER`, `35 GetUserCredits mod=11`, `36 GetUserData mod=11`,
`14 LoadActiveSquad mod=5 SQUAD`, `54 KeepAlive mod=16 MATCH`, `100 GetHubData mod=10 UT`,
`101 GetUserMassInfo mod=10 UT`, `5 GetClubInfo mod=2 CLUB_INFO`.

### 1.2 Base-URL resolution — `RS4::ServerSettings::resolve` @ `0x180124270`

Runs **once, at EnterFUT**, after all `fetchClientConfig` traffic (blaze log: whole CFID sweep at
21:58:11, resolver object at `0x1802e6408` already populated). Three passes, **last non-empty wins**:

1. **Hardcoded default** — `mov r14,[rip+0x1a672c] # 0x1802caa08` @ `0x1801242d5`, splashed into every
   descriptor's `+0x18` by the loop @ `0x180124350`.
2. **`FUT_RS4_APIURL_<MODULE_NAME>`** — loop @ `0x180124390`, `edi < 0x30` (48), `rbx` walks the module
   table's NAME field, key format string `0x18021fa70`.
3. **`FUT_RS4_URL_<CALL_TAG>`** — loop @ `0x180124440`, `r12 = 0x87`, `rbx` walks descriptor `+0x10`,
   key format string `0x18021faa0`.

Each pass requires **HasKey (`vt+0xe8`) == true AND GetString (`vt+0x30`) non-empty**
(`lea r8,[rip+0xc58bb] # 0x1801e9caf` is the default = a NUL byte; `cmp BYTE PTR [rbp-0x80],0x0; je skip`
@ `0x1801243ff`). Out buffer 0x200 bytes.

**`FUT/MODULE_BASEURL_%s` (`0x18021fa58`) and `FUT/SINGLE_BASEURL_%s` (`0x18021fa88`) are DEAD CODE**
in this build — independently confirmed by three reversers and re-verified here: both are `snprintf`'d
into `[rbp+0x180]` (`0x18012439f`, `0x18012444f`) and that buffer is **never** passed to `HasKey`/
`GetString`; the lookups take `lea rdx,[rsp+0x40]`, which holds the `FUT_RS4_*` form. Serving them is
harmless but useless.

**`FUT_TARGET_HOSTNAME` / `FUT_TARGET_PORT` are NOT the API base** — they belong to a separate
ICMP/traceroute latency probe (@`0x180180876`, siblings `FIFA_FUT_ALLOW_PING`, `FUT_DNS_TIMEOUT`,
`FUT_PING_TIMEOUT`, `FUT_MAX_HOPS`, `FUT_PUMP_RATE_MILLSEC`). **Do not serve `FUT_TARGET_PORT`**: a
copy/paste bug @`0x1801808e8` makes its `GetInt` read the key `FUT_MAX_HOPS` instead, so its presence
poisons the port with the hop count.

### 1.3 Value format and URL assembly (@ `0x180177a89`–`0x180177b62`, re-verified)

```
scan value for ':'                        cmp al,0x3a           @0x180177aa0
  bytes[colon+1] == '/' && [colon+2] == '/'  -> strcpy_s verbatim   @0x180177acf
  else                                        -> sprintf("http://%s")  fmt @0x18022dc38
force trailing '/'   mov BYTE PTR [rbp+rax-0x60],0x2f            @0x180177b28
strcat_s(path)                                                    @0x180177b62
buffer 0x200 everywhere
```

So `http://127.0.0.1:8099/`, `http://127.0.0.1:8099` and `127.0.0.1:8099` all end up identical.
**Scheme: plain HTTP is native** (EA's own default is `http://…:8099/`); there is no scheme rewrite.
HTTPS would also be accepted verbatim (ProtoSSL verify is patched) but adds a variable — use http.

### 1.4 The platform token — `game/fifa17`

`BuildEndpointPath` @ `0x180123da0`:

```
movsxd rdi,[r14+rcx*8+0x2caa28]      ; moduleIdx from the descriptor
call   QWORD PTR [rax+0x250]         ; engine/config service -> const char* token
lea    r8,[rip+0xfbc7e] # 0x18021fac8 ; "game/%s"   -> snprintf into a 0x40 buffer
mov    r8,[r14+r8*8+0x21df80]        ; moduleTable[moduleIdx].pathTemplate
snprintf(out, len, pathTemplate, "game/<token>")
```

The `%s` in `FUT_RS4_APIURL_%s` / `FUT_RS4_URL_%s` is a **module/call NAME, not a platform token** —
that was the brief's second wrong assumption. The only path-level token is this one, and the evidence
says `fifa17`: live FIFA17.exe `.rdata` `0x1438dac20 = b'fifa17\0\0 2017\0'`, adjacent
`0x1438dbe58 = 'ut/game/%s/'`, `0x1438dbe88 = 'FUT_RS4_BASE_URL'`; process env `GAMEID=fifa17`;
our own roster server is already serving `/fifa17/fut/rosterupdate.xml`. **Not proven** (the getter
lives in the encrypted exe) — mitigate by wildcarding the segment. Final shapes:

```
ut/auth                       (AUTH  — no platform segment at all)
ut/delete/auth                (LOGOUT)
ut/game/fifa17                (UT      -> + "/settings", "/hub", "/userMassInfo")
ut/game/fifa17/user           (USER    -> + "/credits", …)
ut/game/fifa17/squad          (SQUAD   -> + "/active?active=true" | "/active/user/<personaId>")
ut/game/fifa17/user/list      (CLUB_INFO -> + "?personaIdList=…")
ut/game/fifa17/match          (MATCH   -> + "/keepalive")
```

**In the JSON body the platform is the literal `"pc"`** (VA `0x18021fe9c`, the only `\0pc\0` in the
whole DLL) with `"sku":"FFA17PCC"` (VA `0x18021fe90`). No runtime platform formatting exists.

### 1.5 The auth handshake

**Request** — module 35 `ut/auth`, JSON body ⇒ POST (the literal method string is set through a
ProtoHttp enum, not a `"POST"` literal — treat method as unproven and accept anything).

Headers (template @ `0x180220150`, file 0x21f550): `Accept: application/json` + `Content-Type:
application/json` + **`Hash: %s` only when POW is on** (feature flag `"POW"` @ `0x180235ce0`, read via
config `vt+0x50`, cached to `0x1802ef5d0/…d8` — we simply never serve the key, so no POW).
**No `X-UT-SID` on this first request** (there is no sid yet).

Body, built by `0x180125900` in this exact key order, then `0x1801a26d0` appends the identification:

```json
{"isReadOnly":false,"priorityLevel":6,"sku":"FFA17PCC","nucleusPersonaPlatform":"pc",
 "clientVersion":3,"nuc":33068179,"nucleusPersonaId":33068179,
 "nucleusPersonaDisplayName":"CAGE","locale":"en-us","regionCode":"…",
 "deviceId":"…","macAddress":"…",
 "method":"authcode","identification":{"authCode":"OPENFUT-000…"}}
```

Key literals verified: `isReadOnly`0x1802201c0, `priorityLevel`0x1802201d0 (from global 0x1802cc528,
live 6), `sku`0x1802201e0, `nucleusPersonaPlatform`0x1802201e8, `clientVersion`0x180220200 (immediate 3),
`nuc`0x180220210, `nucleusPersonaId`0x180220218, `nucleusPersonaDisplayName`0x180220230 (fallback
literal `"mememe"`0x18022024c), `locale`0x180220254, `regionCode`0x1802201b0, `deviceId`0x180220260,
`macAddress`0x180220270, `method`0x18023641c = `authcode`0x180236428, `identification`0x180236438,
`authCode`0x180236448. The optional `"fat"`0x180236454 is **never** emitted (its gate `vt+0xb0` =
`0x1800cd820` = `xor al,al; ret`), and the derived-extra-fields hook `vt+0xa0` = `0x180122420` = `ret`.
Persona values come from FIFA17.exe via import thunks — with our Blaze LoginResponse they will be
`33068179` / `"CAGE"` (blaze log SESS.BUID / PDTL.DSNM).

**Response parser** @ `0x1801a2880` — a rapidjson member walk that recognises **exactly three keys**:

| key | VA | action |
|---|---|---|
|`sid`|0x180236458|`strlen` + string-assign into **ServerCall+0x1e8** (`0x1801a2ae8`) |
|`serverTime`|0x180236460|six `atoi` at char offsets 0,5,8,11,14,17 → `+0x208/+0x210` |
|`lastOnlineTime`|0x180236470|same → `+0x218/+0x220` |

Everything else is skipped. Return value is `setne` on the document terminating cleanly — **there is no
status-code check in the parser**, but the layer above requires a parseable body (see 1.7).
Datetime shape is `YYYY-MM-DD HH:MM:SS` (a `T` separator parses identically — `atoi` at fixed offsets).

**`sid` is the whole ballgame.** Header builder `0x180126080` (vtable slot 0x110) copies template
`0x180220100` = `"Accept: application/json\r\nContent-Type: application/json\r\nX-UT-SID: %s\r\n"`
(file 0x21f500 — one string; `strings(1)` split it, which is why the brief lists `X-UT-SID: %s` alone),
fills `%s` from `[this+0x1e8]` (`0x18012611b`) and appends it with fourCC `'apnd'` (`0x61706e64`)
via `[this+0x18]->vt[0x70]` (`0x180126231`). A getter for the same field is slot 0x108 = `0x180125520`.
The field is an eastl::string initialised empty in the ctor `0x1801a2210`.

**401/403 handling** @ `0x1801a33a0` / `0x1801a3447` — on 401 or 403 for a normal API call the client
**silently re-auths** (max 3, counter `[rcx+0x1e0]`); a 401 on `ut/auth` itself **truncates the stored
sid** and re-auths (max 2, counter `[rbx+0x350]`). Never answer 401/403 or we burn retries and then error.

`X-UT-VALIDATE: true` (VA `0x18022b398`) exists in the DLL but is not part of this path.

### 1.6 What the brief's other strings actually are (all four reversers agree)

* `%s/pow/mm/` (0x18021fab0) + `/fut/` (0x18021fabc) — a **content/CDN base** built at `0x18012450e`
  from a *different* vtable slot (`vt+0x3f8`); live it is EMPTY (`obj+0x30 = 0`, `obj+0x38 = ""`) and
  the code skips the whole block when empty. Production shape (leftover in memory):
  `http://content.lt.easfc.ea.com:8080//fifa/fltOnlineAssets/2013/pow/mm/…`. Not the auth flow.
* `TRANSACTION_STATE_WAIT_FOR_AUTH_CODE` (0x1ef360) — the **FIFA-Points store purchase** state machine
  (contiguous enum with `_WAIT_FOR_CART`, `_WAIT_FOR_CHECKOUT`, `_WAIT_FOR_CONSUME_SERVER`).
* `/user/hub`, `/user/registration` — **FUT Champions** suffixes (module CHAMPIONS →
  `ut/game/fifa17/champion/user/hub`), not initial load.
* `/active/user/%lld`, `/active?active=true` — the **active-squad** loader (`0x18014DAD0`,
  `RS4:FutSquadLoadServerResponse`).
* `http-rs4` (0x18022b4a8) — a **telemetry tag** passed next to `fut-rs4-server` (0x18022b4b8), not a scheme.
* There is a **second, exe-side FUT client** (`FutServerCall`, `FifaFutServiceImplementation`) with a
  single key **`FUT_RS4_BASE_URL`** (no `%s`), path `ut/game/%s/`, sub-paths `users/club?sku=`,
  `utStats?sku=`, `user/accountinfo`, and a *different* auth header:
  `Easw-Session-Data-Nucleus-Id: %lld` (no X-UT-SID). Serve that key too.

### 1.7 Generic response pipeline and where "error connecting" is raised

`OnResponse` @ `0x18016D230`:

```
HTTP 204                -> skip parsing entirely (accepted)
empty body              -> err = 0x63
JSON parse failure      -> err = 0x3E6
else                    -> err = call->vt[0x60](resp, body)     ; per-class error extractor
epilogue: [resp+0x1c] = err        [resp+0x20] = httpStatus
```

`[resp+0x1c] == 0` is the universal success test.

The popup itself: **`FUT::WebServiceImpl` @ `0x180122440`** fires
`FireEvent(target="_global" (0x1801ed8a8), event="ServerFatalError" (0x1801ed890),
param="OSDK_LOST_CON_TO_EA" (0x18021dae0))` at `0x1801224d0` (alternate param
`"Unknown_FCC_Error"` 0x1801ed878 at `0x180122503`) — all four VAs verified by direct byte read.
Gate @ `0x1801224a7`: `cmp [rsp+0x58],0x2 / cmp eax,0x3` else fire.
It is **not** `FUTOnlineGameModeBase` (that string is a debug formatter, one xref, raises nothing).

The surrounding flow is a front-end `.nav` state machine, dispatched by string on event `0x3D`
(`0x180019BA0`, field `"StrParam"` 0x1801ed788): `beginFUTLogin` → `retrieveUserData` →
`CheckRewards` → `loginComplete` → `FinalizeLogin` → telemetry `('GAME','ACTN','MODE',"enter_hub")`
→ transition into the FUT hub. Live strings in the process confirm the flow files:
`loading nav/fut/futFlow.nav`, `/fut/futLogInFlow.nav`, `/fut/futGameHubFlow.nav`.
**A 404 on the first user lookup is an accepted, non-fatal answer** (`0x18007AF20`:
`[rdx+0x1c]==0 ? ok : [rdx+0x20]==0x194 ? ok : ServerFatalError`) — it is the new-user branch
that routes to createClub.

---

## 2. THE FIX (minimal)

### 2.0 Path B first — the zero-config, zero-relaunch experiment

Because every descriptor **already** holds `http://easw.easports.com:8099/`, the cheapest possible test
needs no responder change at all:

```bash
echo "127.0.0.1  easw.easports.com" | sudo tee -a /etc/hosts
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/utas_server.py    # binds 127.0.0.1:8099
# re-enter Ultimate Team (a game restart is safest — the resolver may have negatively cached)
```

Do this **in addition** to Path A below; it costs nothing and it removes the config store from the
equation (we have not proven CardsDLL reads the same merged `_all` section the exe uses).

### 2.1 (a) `blaze_responder_v3b.py` — fetchClientConfig additions

Insert after the `OSDK_ROSTER` block (~line 520) and change `client_config_for`:

```python
# --- FUT / UTAS (RS4) BASE URL --------------------------------------------
# CardsDLL RS4::ServerSettings::resolve @0x180124270 writes each of the 125
# endpoint descriptors' baseUrl (VA 0x1802caa20 + i*0x30 + 0x18) in 3 passes,
# last non-empty wins:
#   1. hardcoded default @0x1802caa08 = "http://easw.easports.com:8099/"  (DEAD HOST)
#   2. cfg["FUT_RS4_APIURL_<MODULE_NAME>"]   key fmt @0x18021fa70
#   3. cfg["FUT_RS4_URL_<CALL_TAG>"]         key fmt @0x18021faa0
# A key only counts if HasKey(vt+0xe8) AND GetString(vt+0x30) is non-empty.
# The value is used verbatim when it contains "://" (@0x180177ab3), a trailing
# '/' is force-appended (@0x180177b28), then the endpoint path is strcat'd.
# FUT/MODULE_BASEURL_%s and FUT/SINGLE_BASEURL_%s are formatted but NEVER looked
# up (dead code). FUT_TARGET_PORT must NOT be served: bug @0x1801808e8 makes its
# GetInt read the key FUT_MAX_HOPS instead.
UTAS_BASE = "http://127.0.0.1:8099/"

FUT_RS4_MODULES = [
    "AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD",
    "DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER",
    "DELETEUSER", "ITEMS", "ITEMS_BY_RES", "DELETEITEMS", "MATCH", "SBC",
    "TOURNAMENT", "TOURNAMENTUSER", "TOURNAMENTQUIT", "SEASON", "SEASONUSER",
    "SEASONUSER_ALTER", "SEASONRESET", "FRIENDLYSEASON", "PURCHASED", "STORE",
    "WATCHLIST", "DELETEWATCHLIST", "TRADEPILE", "TRADE", "DELETETRADE",
    "MARKETDATA", "CLIENTDATA", "AUTH", "DELETE_AUTH", "PHISHING", "CAPTCHA",
    "TFA", "SQUADMODE", "DRAFT", "CHAMPIONS", "V2STORE", "LIVEMESSAGE",
    "ADMIN", "DEBUG", "MAINTENANCE",
]                                            # 48, VA 0x18021df80 NAME field

# Per-call overrides (pass 3, runs last and wins). Not required if pass 2 lands,
# but free insurance for the boot set.
FUT_RS4_CALLS_BOOT = [
    "GETSETTINGS", "AUTHENTICATION", "LOGIN", "LOGOUT", "CREATEUSER",
    "GETUSERINFO", "GETUSERDATA", "GETUSERCREDITS", "USERRELIABILITYINFO",
    "GETHUBDATA", "GETUSERMASSINFO", "LOADACTIVESQUAD", "SQUADLIST",
    "GETSQUADINFO", "GETCLUBINFO", "KEEPALIVE", "SEASONHISTORY",
]

FUT_RS4_CONFIG = (
    [("FUT_RS4_APIURL_%s" % m, UTAS_BASE) for m in FUT_RS4_MODULES]
    + [("FUT_RS4_URL_%s" % c, UTAS_BASE) for c in FUT_RS4_CALLS_BOOT]
    + [("FUT_RS4_BASE_URL", UTAS_BASE)]     # exe-side FutServerCall @0x1438dbe88
)
```

```python
def client_config_for(cfid: str) -> list:
    """-> sorted [(key, value)].  Unknown CFID -> [] ..."""
    if cfid == "BlazeSDK":
        return sorted(blazesdk_config() + FUT_RS4_CONFIG)
    # FUT_RS4_* keys ride on EVERY CFID: we have not proven which section
    # CardsDLL's accessor (engineTable[0x170] -> vt+0x118) reads.
    return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG)
```

Timing is safe: the whole CFID sweep (OSDK_CORE … OSDK_ROSTER) completes at login
(`/tmp/blaze_responder.log` 21:58:11, lines 909-1338), long before EnterFUT runs the resolver.

**Do not add:** `FUT_TARGET_PORT` (poisons the port via the `FUT_MAX_HOPS` bug),
`FUT/MODULE_BASEURL_*`, `FUT/SINGLE_BASEURL_*` (dead), `POW` (leave absent so no `Hash:` header /
proof-of-work round-trip is required).

### 2.2 (b) Minimal UTAS server — `fifa17-recon/tools/utas_server.py`

Plain HTTP on `127.0.0.1:8099`. Rules distilled from §1.7:

* **Never 401/403** (triggers the silent re-auth storm).
* Every body must be **parseable JSON** (parse failure ⇒ err `0x3E6` ⇒ ServerFatalError).
* `204` with no body is explicitly accepted and skips parsing — good for fire-and-forget calls.
* `404` is accepted **only** on the first user lookup (new-user branch).
* Accept any HTTP method on any route (the method enum was not decoded to a literal).
* SKU segment is wildcarded (`/ut/game/<anything>/…`).

```python
#!/usr/bin/env python3
"""Minimal FIFA 17 UTAS/RS4 server (OpenFUT, clean-room).

CardsDLL resolves every RS4 endpoint to <base> + path, where <base> comes from
FUT_RS4_APIURL_<MODULE> / FUT_RS4_URL_<CALL> (blaze_responder_v3b.py) and path is
moduleTable[i] with %s -> "game/<sku>".  Auth is POST <base>ut/auth; the response's
"sid" becomes the X-UT-SID header on every later call (CardsDLL @0x180126080).

Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
  * NEVER 401/403  -> silent re-auth storm (3 retries) then ServerFatalError.
  * body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
  * [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
"""
import datetime, json, os, re, http.server

ADDR = ("127.0.0.1", 8099)
LOG = "/tmp/utas_server.log"
SID = "OPENFUT-SID-0000000000000001"
PERSONA_ID = 33068179          # blaze LoginResponse SESS.BUID / PDTL.PID
PERSONA_NAME = "CAGE"          # PDTL.DSNM
# Flip to True once you want to exercise the create-club path instead.
NEW_USER = False


def now():
    return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def log(m):
    line = "[%s] %s" % (datetime.datetime.now().strftime("%H:%M:%S"), m)
    print(line, flush=True)
    with open(LOG, "a") as f:
        f.write(line + "\n")


# ---- payloads -------------------------------------------------------------
def auth_body():
    # Only "sid" is load-bearing (parser 0x1801a2880 -> ServerCall+0x1e8).
    # serverTime/lastOnlineTime are atoi'd at char offsets 0,5,8,11,14,17.
    return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}


def user_info():
    # Deserializer 0x18013EC10; every member optional (unknown key ids are
    # skipped via 0x180135FF0), so {} also parses.
    return {
        "personaId": PERSONA_ID,
        "clubName": "OpenFUT", "clubAbbr": "OFC", "established": "2026",
        "clubNameChangeAllowed": True,
        "currencies": [{"name": "coins", "value": 15000},
                       {"name": "points", "value": 0}],
        "won": 0, "draw": 0, "loss": 0,
        "divisionOffline": 10, "divisionOnline": 10,
        "purchased": False,
        "feature": {"trade": True},
        "reliability": {"reliability": 100, "matchUnfinishedTime": 0},
        "unopenedPacks": {"preOrderPacks": 0, "recoveredPacks": 0},
        "bidTokens": {"count": 0, "updateTime": 0},
        "trophies": 0, "sessionCoinsBankBalance": 0,
        "actives": [], "squadList": [],
    }


# GET ut/game/<sku>/user parser 0x180146970 does Parse + TWO NextToken calls
# before deserializing -> the object MUST be wrapped in one member.  The member
# NAME is never compared, but the nesting level is required.
USER_GET = {"userInfo": user_info()}
# POST ut/game/<sku>/user (CreateUser, 0x18014CC60) recognises exactly:
# bonusPacks(0x5d) login(0x1a5) squad(0x2cd) starterPack(0x2e5) userData(0x36d).
USER_POST = {"login": True, "userData": user_info(),
             "squad": {}, "starterPack": {}, "bonusPacks": []}
# GET ut/game/<sku>/settings (0x18013C6D0) recognises ONE key: configs (0xa2).
SETTINGS = {"configs": []}

G = r"/ut/game/[^/]+"
ROUTES = [
    (re.compile(r"^/ut/auth"),                       lambda m, h: (200, auth_body())),
    (re.compile(r"^/ut/delete/auth"),                lambda m, h: (200, {})),
    (re.compile(G + r"/settings"),                   lambda m, h: (200, SETTINGS)),
    (re.compile(G + r"/user/credits"),               lambda m, h: (200, {"credits": 15000})),
    (re.compile(G + r"/user/list"),                  lambda m, h: (200, {})),
    (re.compile(G + r"/user/accountinfo"),           lambda m, h: (200, {})),
    (re.compile(G + r"/user$|" + G + r"/user\?"),    lambda m, h: user_route(h)),
    (re.compile(G + r"/squad"),                      lambda m, h: (200, {})),
    (re.compile(G + r"/match/keepalive"),            lambda m, h: (204, None)),
    (re.compile(G + r"/hub"),                        lambda m, h: (200, {})),
    (re.compile(G + r"/userMassInfo"),               lambda m, h: (200, {})),
    (re.compile(G + r"/season"),                     lambda m, h: (200, {})),
    (re.compile(G + r"/club"),                       lambda m, h: (200, {})),
]


def user_route(h):
    if h.command == "POST":
        return 200, USER_POST
    if NEW_USER:
        # accepted, non-fatal: 0x18007AF20 treats 404 as the new-user branch
        return 404, {}
    return 200, USER_GET


class H(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def _handle(self):
        n = int(self.headers.get("Content-Length", 0) or 0)
        body = self.rfile.read(n) if n else b""
        log("%s %s" % (self.command, self.path))
        for k, v in self.headers.items():
            log("    %s: %s" % (k, v))
        if body:
            log("    body: %s" % body[:1200].decode("utf-8", "replace"))

        code, payload = 200, {}
        for rx, fn in ROUTES:
            if rx.search(self.path):
                code, payload = fn(rx, self)
                break
        else:
            log("    !! UNMAPPED PATH -> catch-all 200 {}")

        raw = b"" if payload is None else json.dumps(payload).encode()
        self.send_response(code)
        if raw:
            self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(raw)))
        self.end_headers()
        if self.command != "HEAD" and raw:
            self.wfile.write(raw)
        log("  -> %d %s" % (code, raw[:200].decode() if raw else "(no body)"))

    do_GET = do_POST = do_PUT = do_DELETE = do_HEAD = do_PATCH = _handle

    def log_message(self, *a):
        pass


if __name__ == "__main__":
    open(LOG, "a").close()
    log("=== utas_server http://%s:%d ===" % ADDR)
    http.server.ThreadingHTTPServer(ADDR, H).serve_forever()
```

Exact wire responses for the two that matter:

```
POST /ut/auth
  HTTP/1.1 200 OK
  Content-Type: application/json
  {"protocol":1,"sid":"OPENFUT-SID-0000000000000001",
   "serverTime":"2026-07-31 22:40:00","lastOnlineTime":"2026-07-31 22:40:00"}

GET /ut/game/fifa17/settings
  HTTP/1.1 200 OK
  Content-Type: application/json
  {"configs":[]}
```

Every subsequent request must be answered 2xx and must carry (from the client)
`X-UT-SID: OPENFUT-SID-0000000000000001`.

### 2.3 Fastest validation with no relaunch (live poke)

CardsDLL live base `0x6ffffc140000` (slide `0x6FFE7C140000`). Write a pointer to your own
NUL-terminated URL string into every descriptor's baseUrl field:

```
for i in 0..125:  *(u64*)(0x1802caa20 + i*0x30 + 0x18 + slide) = <ptr to "http://127.0.0.1:8099/">
```

then re-enter Ultimate Team. `/proc/PID/mem` writes work under `ptrace_scope=1` (already proven by
`openfut-poke`). This bypasses both the responder and the DNS question in one step.

---

## 3. OBSERVABLE SUCCESS SIGNALS (strongest first)

1. **A 4th FIFA socket** — `ss -tnp | grep FIFA17` shows `SYN-SENT`/`ESTAB` to `127.0.0.1:8099`
   (today there is none: three Blaze/roster sockets only). This alone proves URL resolution +
   `connect()`, i.e. the whole of §1.2–1.4 landed.
2. **`/tmp/utas_server.log` shows a real request line** with the literal SKU:
   `GET /ut/game/fifa17/settings` (or `POST /ut/auth`) plus headers
   `Accept: application/json`, `Content-Type: application/json`, `User-Agent: ProtoHttp …`.
   This is also how we *read* the SKU instead of guessing it.
3. **`POST /ut/auth`** with a body containing `"method":"authcode"` and our
   `"identification":{"authCode":"OPENFUT-000…"}` — proves the Blaze authCode reached CardsDLL.
4. **Subsequent requests carry `X-UT-SID: OPENFUT-SID-0000000000000001`** — proves the sid was
   stored at `ServerCall+0x1e8` and re-emitted by `0x180126080`; i.e. CardsDLL considers itself
   authenticated. If a later request arrives with an *empty* `X-UT-SID:`, the sid is per-call and we
   have another site to find.
5. **No re-auth storm** — the same `POST /ut/auth` must not repeat 2–3 times in a row (that pattern
   means we answered 401/403 somewhere).
6. **In-game**: the "error connecting to FIFA 17 Ultimate Team" popup does not appear; the nav flow
   advances `beginFUTLogin → retrieveUserData → loginComplete → FinalizeLogin` and emits the
   telemetry `('GAME','ACTN','MODE',"enter_hub")`, landing on the FUT hub (or the starter-squad
   screen if we answer 404 / `NEW_USER=True`).
7. **Live re-reads** (no relaunch needed):
   * descriptor baseUrl at `0x1802caa20 + i*0x30 + 0x18 + slide` now reads `http://127.0.0.1:8099/`
     instead of `http://easw.easports.com:8099/` → the config keys were consumed;
   * `grep` process memory for `ut/game/` → currently **zero** hits; any hit proves
     `BuildEndpointPath` @`0x180123da0` ran;
   * the telemetry ring should now read `{"type":"utas","status":"start_flow"}` **without** the
     following `{"type":"utas","status":"error","status_code":"0"}`.

---

## 4. WHAT STILL NEEDS A LIVE EXPERIMENT

| # | Unknown | Why it matters | Cheapest resolution |
|---|---|---|---|
|1|**The SKU token** returned by `engine->vt[0x250]` (feeds `game/%s`). `fifa17` is inferred from FIFA17.exe `0x1438dac20` + `GAMEID=fifa17`, never read directly (the getter lives in the encrypted exe).|Wrong token ⇒ our routes 404.|Wildcarded in the server (`/ut/game/[^/]+/…`); the first log line tells us the truth.|
|2|**Which config section CardsDLL reads** (`engineTable[0x170] → vt+0x118`, HasKey `+0xe8` / GetString `+0x30`) vs the exe's merged `_all` section (`getSection @0x14719e050`).|If it is a different store, the keys never land.|Already mitigated: keys attached to every CFID + the `/etc/hosts` Path B fallback.|
|3|**Report 3's finding that `ut/game/` has zero hits in 1.87 GB of process memory**, i.e. no RS4 path was ever built — which would put the block *upstream* of URL resolution (the nav flow never firing `beginFUTLogin`).|If true, config keys alone will not produce a socket.|Discriminator: after the fix, if the descriptors show our URL but still no SYN, instrument `0x180018F50` (`beginFUTLogin`) / `0x180122440` (the fatal-error site). Counter-evidence: the `utas start_flow`/`error status_code 0` telemetry pair says the flow *did* run and got no HTTP status — consistent with a DNS failure after the path was built into a since-freed heap buffer.|
|4|**HTTP method** for each call (the ProtoHttp method enum was not decoded to a literal `POST`/`GET`).|None if we accept all methods.|Server accepts every verb; log records the truth.|
|5|**Boot order** of the initial-load calls. `GetSettings` is the only non-market call with `preAuthFlag=1` (descriptor `+0x20`), which strongly implies it is first, but the driver (`CardsAdaptor vt[0x758]/[0x768]`) lives outside CardsDLL.|Determines which response must be right first.|Read the access log.|
|6|**Killswitch semantics** — `desc+0x21` is live 0 for all 126 (verified). If 0 means "disabled" rather than "not killed", `{"configs":[]}` would starve every call.|Could block everything after GetSettings.|If GetSettings returns and nothing else is ever requested, populate `configs` from the setter thunks (e.g. `0x180124020` → `0x1802cae31` = GetSettings' own byte) and the literal `FUT_INSTRUCTIONS_KILLSWITCH` (`0x180202FB0`).|
|7|**`Hash: %s`** value (auth-only header, from `svc->vt[0x420]`, hex digest — alphabet `0123456789abcdef` @0x21f598) and the **`POW`** feature flag.|Only if EA-side validation were mirrored; we control the flag.|Leave `POW` unserved; if a `Hash:` header shows up anyway, ignore it server-side.|
|8|**The per-class error extractor `call->vt[0x60]`** (`0x18016D47B`) that turns a parsed body into `[resp+0x1c]`. Key ids located: `code`(0x92), `reason`(0x279), `debug`(0xcb), `string`(0x2f6), `errorState`(0x10d); the function itself was not reversed.|If a 200 with a body still yields ServerFatalError, this is the next target — most likely our body contains a `code` member it reads as an error.|Keep bodies minimal (no `code`/`reason`/`debug` keys) — the supplied payloads already avoid them.|
|9|**The content/CDN base** (`vt+0x3f8` → `<X>/pow/mm/`, `<X>/fut/`), live EMPTY.|May cause a *secondary* failure after the API connects (LiveMessage / hub assets).|If a post-auth failure mentions pow/mm, add a config value shaped like `http://127.0.0.1:8099//fifa/fltOnlineAssets/2013`.|
|10|**The exe-side `FutServerCall`** (`user/accountinfo`, header `Easw-Session-Data-Nucleus-Id: %lld`, no X-UT-SID) — its response shape (`userAccountInfo`, `personas`, `userClubList`, `clubName`, `established`, `assetId`, `returningUser`) is read from live strings, not from a reversed parser.|A second, independent client that may error separately.|Route already stubbed to `200 {}`; iterate from its log line.|

### The next gate after this one

Assuming we clear auth, the flow reaches `retrieveUserData` → `FinalizeLogin`. Two outcomes:

* **Existing-club path** (`NEW_USER=False`): the hub loads against `{"userInfo":{…}}` with an **empty
  club and empty squad** (`"actives":[]`, `"squadList":[]`). The hub will render, but the moment the
  UI asks for real content — `LoadActiveSquad`, `SquadList`, `ViewCards` / `ut/game/fifa17/item`,
  `GetUserMassInfo` — it needs an actual item set. Empty arrays should be *structurally* valid
  (every member is optional, unknown key ids are skipped) but will likely surface as an empty club
  screen or a "squad required" wall.
* **New-user path** (`NEW_USER=True`, or a 404 on the first GET): `GotoStarterSquad` /
  `SetNewUserContextData` → `POST ut/game/fifa17/user` (CreateUser) with
  `{"login":true,"userData":{…},"starterPack":{…},"squad":{…},"bonusPacks":[]}` — that is where a
  **real starter pack + a legal 11-player squad** must be produced.

Either way, the next gate is **content, not protocol** — which is exactly `openfut-core`'s job
(`data/cards/`, `data/packs/`, the squad/club models). The clean handoff is: `utas_server.py` stays a
thin router that proxies `/ut/game/*/…` to `openfut-core` on `:8080`, keeping only `ut/auth` and the
X-UT-SID session locally. The item-JSON schema for that work is already recoverable — CardsDLL parses
by **hashed key ids** (FNV-1a `0x811c9dc5` @ `0x180180D00`, red-black map @ `0x1802E6598`, unknown ⇒
id `0x38C` and silently skipped) against a **907-entry key dictionary at VA `0x1802D2760`** (file
`0x2D0D60`, alphabetical: index 6 = `accountCreatedPlatformName` … `0x38a` = `yellowCards`). That
table is the complete FUT JSON vocabulary and should be dumped into `openfut-core` as the schema
reference for the content phase.

---

## Appendix A — disagreements between the four reversals, resolved

| Claim | Verdict |
|---|---|
|"Config keys are served empty ⇒ no URL" (report 2)|**Rejected.** Live: all 126 descriptors hold the easw default; DNS NXDOMAIN is the failure. Confirmed independently by reports 1, 3, 4 and re-verified here.|
|Descriptor table at `0x1802caa28` +0x10 (r1) vs `0x1802caa20` +0x18 (r3/r4)|**Same address** (`0x2caa38`). Normalised to base `0x1802caa20`, stride `0x30`, baseUrl at `+0x18`.|
|`FUT/MODULE_BASEURL_%s` live vs dead|**Dead** — verified in disasm here: formatted into `[rbp+0x180]`, lookups take `[rsp+0x40]`.|
|`%s` = platform token (brief, report 2) vs module/call NAME (r1, r3, r4)|**Module/call NAME.** The platform token appears only in `game/%s`.|
|`serverTime` `"YYYY-MM-DD HH:MM:SS"` (r2/r4) vs `"YYYY-MM-DDTHH:MM:SS"` (r3)|**Both parse** — six `atoi` at fixed char offsets 0,5,8,11,14,17. Use the space form.|
|Port 8082 (r3) vs 8099 (r1, r4)|**8099**, so the `easw.easports.com → 127.0.0.1` hosts trick works with zero config.|
|`/user/hub`, `/user/registration`, `/active/user/%lld`, `%s/pow/mm/` as auth/initial-load (brief)|**All misattributed** — FUT Champions, active-squad loader, and the FIFA-Points store respectively.|
|125 vs 126 endpoint descriptors|**125 real** (idx 0–124) + one terminator at idx 125 (`moduleIdx = 48`, empty name).|

## Appendix B — the 125 call tags (for `FUT_RS4_URL_<TAG>` if ever needed)

ISSEARCH, ISOFFERTRADE, ISSTART, RELISTALL, GETCLUBUSERS, GETCLUBINFO, CLUBSEARCH, CLUBSTATS,
STAFFSTATS, CONSUMABLESSEARCH, DREAMSQUADSEARCH, GETSQUADINFO, UPDATESQUADNAME, RETRIEVESQUAD,
LOADACTIVESQUAD, DELETESQUAD, SAVESQUAD, SQUADLIST, GETLBOPTIONS, GETLBENTRIES, GETLBENTRYDATA,
GETSETTINGS, AUTHENTICATION, LOGIN, LOGOUT, RESETUSER, USERRELIABILITYINFO, CREATEUSER, GETUSERINFO,
SETUSERINFO, GETHISTORICAL, SETTUTDATA, GETTOWDATA, SETTOWDATA, SETFAVDATA, GETUSERCREDITS,
GETUSERDATA, VIEWCARDS, ASSIGNCARD, APPLYCARD, APPLYCARDBYRES, ACTIVATECARD, CONSUMECARD, DISCARDCARD,
DISCARDCARDBYRES, DISCARDACARD, MOVECARD, MOVECARDBYRES, SWAPCARD, CREATEMATCH, MATCHREADY,
DESTROYMATCH, PLAYGAME, RESETMATCH, KEEPALIVE, LOADCATEGORYDETAILS, LOADSETCHALLENGES, STARTCHALLENGE,
LOADSQUADCHALLENGE, SAVESQUADCHALLENGE, SUBMITCHALLENGE, TAGSETS, SETSBCDATA, TOURNAMENTLIST,
TOURNAMENTTEAMS, GETACTIVETOURNAMENTS, UPDATETOURNAMENT, TOURNAMENTLOADDATA, TOURNAMENTQUIT,
SEASONLIST, SEASONUPDATE, SEASONLOADDATA, SEASONQUIT, SEASONHISTORY, PURCHASEDITEMS, PURCHASEPACK,
PURCHASEITEMS, STOREPACKTYPES, STOREPACKQUANTITIES, ISREMOVEWATCH, ISWATCHTRADE, ISWATCHLIST,
ISVIEWTRADE, GETTRADEPILE, GETAUCTIONCOUNT, ISREMOVETRADE, GETSUGGESTEDPRICING, CHANGECLUBNAME,
GETPHISHINGQUESTION, SETPHISHINGANSWER, VALIDATEPHISHINGANSWER, GETTRUSTEDCONSOLELIST, GETCAPTCHA,
EXCHANGECAPTCHA, VALIDATECAPTCHA, VALIDATETFA, UPDATEUSERACTION, GETUSERACTION,
GETMANAGERQUESTREWARD, SETMANAGERQUESTCOMPLETE, GETHUBDATA, GETUSERMASSINFO, FIFAPOINTSTRANSFER,
FRIENDLYSEASONUPDATE, FRIENDLYSEASONLOAD, FRIENDLYSEASONHISTORY, GETAVAILABLELOANPLAYERS,
SIGNLOANPLAYER, GETCHEMISTRYATTR, GETDRAFTCURRENTSTATE, GETDRAFTCHOICES, GETDRAFTSTATS, GETDRAFTAWARD,
PURCHASEDRAFTMODE, PICKDRAFTCHOICE, PICKDRAFTAUTOCHOICE, GETSTORYMODEREWARD, CHAMPIONSHUB,
CHAMPIONSTOPX, CHAMPIONSRANK, CHAMPIONSFRIENDS, GRANTPRIZECHAMPIONUSER, REGISTERCHAMPIONSLEAGUE,
GetChampionsUserCountry, LIVEMESSAGE

*(Note: tag 124 is spelled `GetChampionsUserCountry` in mixed case in the binary — the key would be
`FUT_RS4_URL_GetChampionsUserCountry`, byte-for-byte.)*
