Files
OpenFUT/fifa17-recon/tools/login_dump/FUT_UTAS_PLAN.md
T
funman300 edab23f04a fifa17-recon: package the working offline FUT backend
Emulates FIFA 17's full online + Ultimate Team stack against an offline,
clean-room backend (no EA servers). Proven end-to-end 2026-08-01:
Origin login -> Blaze login -> device-trust -> the FUT hub.

Package:
- tools/openfut-fut.sh   one-command orchestrator (start/stop/status/restart)
- tools/root_arm.sh      idempotent host arm (sysctls, DNAT, /etc/hosts easw)
- tools/{lsx_responder_v2,blaze_responder_v3b,roster_server,utas_server,autopatch}.py
  the 5 servers (Origin LSX :4216, Blaze :42127/42130/42131, roster :8081,
  FUT/UTAS :8099) + heat2.py (Fire2/Heat2 TDF codec)
- FUT-RUNBOOK.md         runbook + gate-ladder troubleshooting
- docs/, tools/login_dump/*.md   the reverse-engineering write-ups

All findings are clean-room, from binaries we own; nothing from any leak.
The wire protocol maps 1:1 to FIFA 23.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PN5bmpDVQR1aXgefyWAt7o
2026-08-01 09:12:17 -07:00

37 KiB
Raw Blame History

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 4547 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 defaultmov 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 (@ 0x180177a890x180177b62, 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:

{"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: isReadOnly0x1802201c0, priorityLevel0x1802201d0 (from global 0x1802cc528, live 6), sku0x1802201e0, nucleusPersonaPlatform0x1802201e8, clientVersion0x180220200 (immediate 3), nuc0x180220210, nucleusPersonaId0x180220218, nucleusPersonaDisplayName0x180220230 (fallback literal "mememe"0x18022024c), locale0x180220254, regionCode0x1802201b0, deviceId0x180220260, macAddress0x180220270, method0x18023641c = authcode0x180236428, identification0x180236438, authCode0x180236448. 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/registrationFUT 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): beginFUTLoginretrieveUserDataCheckRewardsloginCompleteFinalizeLogin → 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:

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:

# --- 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
)
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>/…).
#!/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 socketss -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.21.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 23 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 semanticsdesc+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. 0x1801240200x1802cae31 = 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 retrieveUserDataFinalizeLogin. 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 / SetNewUserContextDataPOST 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 = accountCreatedPlatformName0x38a = 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 0124) + 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.)