# FUT Response Rebuild Plan (Ghidra-verified) Status: 2026-08-03. Plan for rebuilding the full set of `FutXServerResponse` bodies the offline backend must serve so FIFA 17 FUT works end-to-end with the existing Python system (`fifa17-recon/tools/utas_server.py` + `fut_store.py`). This plan was produced by **new Ghidra work** on `CardsDLL_Win64_retail.dll` (`/tmp/ghidra_fut` project, imports at `/tmp/fut/`), which **closed the last documented gap**: all 7 response structs missing from `ENDPOINT_MAP.md` are now reversed to field level. See `ENDPOINT_MAP.md` for the shared methodology (atom table, deserializer RECIPE, SKIP safety, type fidelity, freeze-risk). ## 1. What the Ghidra pass proved For the 7 undocumented structs, the deserializer (vtable slot +0x08) was located and decompiled. The squad family — the exact family blocking "add to squad" — is now fully known: | Struct | Deserializer | Shape | Verdict | |---|---|---|---| | `FutSquadLoadServerResponse` | `0x1801464e0` (+ sub `0x18013ad00`, squad parser `0x18013d1f0`) | full squad object (directly, no wrapper) | **Already served** — shape verified correct | | `FutSquadSaveServerResponse` | `0x180171a60` | `{"id": }` — ONLY key `id`(0x15c) parsed | **Fix**: stop echoing whole squad | | `FutSquadListServerResponse` | `0x180172140` (+ `0x180142260`, elem `0x180141fc0`) | `{"squad": [ {summary} ]}` | **Missing** — no list endpoint yet | | `FutSquadRenameServerResponse` | `0x1801642c0` (shared no-op) | `{}` | **Ack only** | | `FutSquadDeleteServerResponse` | `0x1801642c0` (shared no-op) | `{}` | **Ack only** | | `FutGrantPrizeChampionUserServerResponse` | `0x180148ec0` | `{"awardedPrizes": [...]}` | Nice-to-have | | `FutStickerBookStats2ServerResponse` | `0x180130150` | `{"stat": [...]}` | Nice-to-have | ### Full squad object schema (verified via `0x18013d1f0`, 0-risk) ``` { "personaId": , // 0x21b — must equal logged-in persona "squadName": , // 0x2d3 "squadType": , // 0x2d6 — string, converted; value 2 flags items "starRating": , // 0x2e2 "formation": , // 0x12b(299) — string, converted ("f442") "id": , // 0x15c "chemistry": , // 0x81 "captain": , // 0x69 "changed": , // 0x7e "custom": , // 0xc6 — STRING containing a JSON array of 33 ints // (re-parsed inline; must be a complete array) "actives": [ ], // 0xb — item refs, parser 0x18013fe00 "manager": [ ], // 0x1a8 — item refs "players": [ {"index":, "itemData":, "kitNumber":} ], // 0x238 "kicktakers": [ {"id":, "index":} ] // 0x178 } ``` `fut_seed._base_squad()` already matches this shape (incl. complete 33-int `custom` string, string `formation`/`squadType`). **Key finding: the squad response shape is NOT the blocker** — the client simply never issues `PUT /squad` on placement attempts (no such request ever hits the server). The rebuild therefore focuses on making every response the client *does* send exact, then re-testing add-to-squad with a fresh launch (no stale squad cache). ### Squad list element schema (via `0x180141fc0`) — types corrected 2026-08-03 ``` {"rating": , // 0x274 — scalar getter 0x1801c79d0 "chemistry": , // 0x81 — scalar getter 0x1801c79d0 "formation": , // 0x12b — STRING getter 0x1801c7aa0 -> enum conv 0x180166590 "id": , // 0x15c "squadName": , // 0x2d3 "squadType": } // 0x2d6 — STRING getter 0x1801c7aa0 -> enum conv 0x1801668e0 ``` **Correction:** `formation` and `squadType` are **strings**, not ints — `0x180141fc0` reads them with the string getter `0x1801c7aa0` and runs the same enum converters as the full-squad parser `0x18013d1f0` (lines 303-305 / 432-434 of `squadparser.txt`). Feeding ints to a string getter is exactly the type-mismatch that freezes at `0x1801c7f1a`. ### StickerBookStats2 schema (via `0x180130150`) ``` {"stat": [ {"contextId": , "contextValue": , "type": , "typeValue": } ]} ``` ### GrantPrizeChampionUser schema (via `0x180148ec0`) ``` {"awardedPrizes": [ {"awards": [], "eventId": , "rank": , "money": , "progress": } ]} ``` ## 1b. Client request-side findings (new — session 2026-08-03) Request path templates (dumped from the DLL's request table at `0x18021dfc0`, strings `0x18021e308+`): the squad family uses **only two URLs**: | Template | Name | Service methods | |---|---|---| | `ut/%s/squad` | `SQUAD` | LoadActiveSquad (GET), SaveCurrentSquad (PUT), SaveSquad (PUT), GetSquadList, GetSquads | | `ut/delete/%s/squad` | `DELETE_SQUAD` | DeleteSquad (DELETE) | | `ut/%s/squad/mode` | `SQUADMODE` | (draft/squad mode) | ~~**There is NO separate squad-list URL.**~~ **WRONG — corrected by live traffic 2026-08-03: `GET ut/%s/squad/list` exists** and is what the Squads screen calls. The request-table strings only contained `ut/%s/squad`, so the static reading missed it (the `/list` suffix is appended by the caller, not stored as a template). See §10g. > ⚠ The service-method names originally recorded here were **off by one** and are > superseded by §9a (`AddPlayerToSquad`=`FUN_18004aa70`, `SaveCurrentSquad`=`FUN_18004b5a0`, > `FUN_18004aff0` is actually `GetPotentialChemistry_Club`, and `0x18003b9d0` is a generic > script-object→handle converter, not a squad serializer). Response-factory bindings live in a file-offset table (`FutSquadRename` row @ `0x18027bb48`, `FutSquadDelete` @ `0x18027bb90`, `FutSquadLoad` @ `0x18027cb70`, `FutSquadSave` @ `0x18027bcf8`, `FutSquadList` @ `0x18027be28`) — one distinct request row per response, i.e. the client DOES distinguish Load vs List server-side somehow (query param or request-context). Not resolvable statically → capture the live request on the Squad screen. **userInfo.squadList** (atom **`0x2d4`**, in `/user`, deser `0x18013ec10`) is parsed by the **same** `FUN_180142260` as the FutSquadList response → it expects `"squadList": {"squad": [...]}`. Notably that branch does **not** write into the userInfo record at all — it fetches a singleton (`FUN_18011a830` → `vtable[0x480]` → `+0x30`) and fills **the global squad-roster model** directly. That is the model the Squad Selector reads, so this key is the roster. The current `"squadList": []` (bare array) is SKIP-safe (no desync) but leaves the client's squad-roster model EMPTY. Same for `actives` (array of ≤5 item refs). **Hypothesis: with an empty roster, "Add to squad" has no target squad, so the client never fires PUT /squad.** ## 2. Inventory: all 102 responses, grouped by what to serve Source: `/tmp/fut/all_fut_structs.txt`. Grouping reflects how far the client can get and what the offline loop actually touches. ### Tier A — already served correctly (leave as-is) `FutGetUserInfoServerResponse`(/user), `FutGetUserMassInfoServerResponse`(/userMassInfo, empty-safe), `FutUserCreditsServerResponse`(/user/credits), `FutGetPurchasedItemsServerResponse` (/club), `FutViewCardsServerResponse`(club items), `FutGetTradePileServerResponse`(/tradePile), `FutGetHubDataServerResponse`(/hub), `FutGetSettingsServerResponse`(/settings), `FutKeepAliveServerResponse`(204), `FutCreateUserServerResponse`(/user POST), `FutGetClubInfoServerResponse`, `FutGetClubUsersServerResponse`, `FutGetPhishingQuestionServerResponse`, `FutSetPhishingAnswerServerResponse`, `FutValidatePhishingAnswerServerResponse`, `FutGetTrustedConsoleListServerResponse`, `FutGetUserActionServerResponse`, `FutUpdateUserActionServerResponse`, `FutGetActiveTournamentsServerResponse`, `FutSeasonListServerResponse`, `FutUpdateCreditsServerResponse`, `FutLiveMessageUpdateServerResponse`, `FutLogoutServerResponse`, `FutResetUserServerResponse`. ### Tier B — ack-only, `{}` is safe (shared no-op or empty-tolerant parsers) `FutChangeClubNameServerResponse`, `FutActivateCardServerResponse`, `FutApplyCardServerResponse`, `FutApplyCardByResServerResponse`, `FutSignLoanPlayerServerResponse`, `FutSquadRenameServerResponse`, `FutSquadDeleteServerResponse`, `FutDiscardCardServerResponse`, `FutDiscardCardByResServerResponse`, `FutSetFavFeatureServerResponse`, `FutCreateMatchServerResponse`, `FutDestroyMatchServerResponse`, `FutResetMatchServerResponse`, `FutMatchReadyServerResponse`, `FutPlayGameServerResponse`, `FutGetCaptchaServerResponse`, `FutExchangeCaptchaServerResponse`, `FutValidateCaptchaServerResponse`, `FutGetSuggestedPricingServerResponse`, `FutGetUserActionServerResponse`, `FutUpdateFriendlySeasonServerResponse`, `FutGetDraftStatsServerResponse`, `FutGetStoryModeRewardServerResponse`, `FutUpdateSeasonServerResponse`, `FutUpdateTournamentServerResponse`, `FutTournamentQuitServerResponse`, `FutSeasonQuitServerResponse`. ### Tier C — core-loop responses that MUST be exact (the rebuild targets) | Struct | Endpoint | Required body | |---|---|---| | `FutSquadLoadServerResponse` | GET /squad | full squad object (verified OK) | | `FutSquadSaveServerResponse` | PUT /squad | `{"id": }` — **change from echo** | | `FutSquadListServerResponse` | GET /squad (same URL as load) | `{"squad": [ {summary} ]}` — see §1b, resolve list-vs-load empirically | | `FutSquadRenameServerResponse` | PUT /squad/name | `{}` | | `FutSquadDeleteServerResponse` | DELETE /squad | `{}` | | `FutPurchaseItemsServerResponse` | buy pack | existing (works) | | `FutMoveCardServerResponse` | PUT /item | existing (works) | | `FutISSearchServerResponse` | GET /transfermarket | existing (works) | | `FutISStartServerResponse` / `FutISOfferTradeServerResponse` / `FutISViewTradeServerResponse` / `FutISWatchTradeServerResponse` / `FutISWatchListServerResponse` / `FutISRemoveTradeServerResponse` / `FutISRemoveWatchServerResponse` / `FutRelistAllServerResponse` | market/trade/auction | market stubs OK (`tradePile`/`watchList` already routed) | ### Tier D — not needed for the offline loop (serve `{}`, revisit later) `FutGetDraftChoicesServerResponse`, `FutGetDraftAwardServerResponse`, `FutGetDraftCurrentStateServerResponse`, `FutPickDraftChoiceServerResponse`, `FutPickDraftAutoChoiceServerResponse`, `FutPurchaseDraftModeServerResponse`, `FutChampionsRegistrationServerResponse`, `FutGetChampionsFriendsServerResponse`, `FutGetChampionsTopXServerResponse`, `FutGrantPrizeChampionUserServerResponse`, `FutGetLBEntriesServerResponse`, `FutGetLBOptionsServerResponse`, `FutGetTowChallengeServerResponse`, `FutSetTowChallengeServerResponse`, `FutGetFriendlyHistoryDataServerResponse`, `FutGetHistoricalServerResponse`, `FutGetTournamentTeamsServerResponse`, `FutTournamentListServerResponse`, `FutTournamentLoadDataServerResponse`, `FutSeasonLoadDataServerResponse`, `FutSBCLoadCategoryDetailsServerResponse`, `FutSBCTagSetsServerResponse`, `FutSBCSetDataServerResponse`, `FutSBCSaveSquadChallengeServerResponse`, `FutSBCSubmitChallengeServerResponse`, `FutGetAvailableLoanPlayersServerResponse`, `FutLoadSetTypesServerResponse`, `FutStoreGetPackTypesServerResponse`, `FutStorePackQuantitiesServerResponse`, `FutCreatePackServerResponse`, `FutConsumablesSearchServerResponse`, `FutStickerBookSearchServerResponse`, `FutStickerBookStats2ServerResponse`, `FutStaffBonusServerResponse`, `FutUpdateSeasonServerResponse`, `FutGetAuctionCountServerResponse`, `FutGetUserData`… (non-critical: never reached before the squad save works). ## 3. Implementation order **Status 2026-08-03 (implemented, contract-green, live-verify pending):** steps 1, 2 and the populated-massinfo recommendation of §7 are in `utas_server.py`; step 3's list-vs-load split is solved statically (see below) and shipped behind `FUT_SQUAD_LIST=merged`. **List vs Load on `ut/%s/squad` — resolved without a live capture.** The two parsers are mutually SKIP-tolerant: the squad-object parser `0x18013d1f0` handles `{0xb,0x69,0x81,0xc6,0x12b,0x15c,0x163,0x16b,0x178,0x17a,0x1a8,0x21b,0x238,0x2d3,0x2d6,0x2e2,0x377}` and does **not** recognise `squad`(0x2cd); the list parser `0x180142260` recognises **only** `squad`(0x2cd). So one MERGED body — the full squad object plus a `"squad": [summary]` key — satisfies whichever response class the client instantiated. Left opt-in (`FUT_SQUAD_LIST=merged`) because `GET /squad` is boot-critical and the first live test should isolate the massinfo/squadList change. 1. **Populate `userInfo.squadList`** (highest-leverage hypothesis): serve `"squadList": {"squad": [ {id, squadName, formation, chemistry, rating, squadType} ]}` from `STORE` in `/user` so the client's squad-roster model is non-empty (`FUN_180142260`, verified). Test that `/user` still parses (no freeze) — if it freezes, fall back to populating only the FutSquadList GET path and revisit. 2. **Fix `squad_route` PUT** (`utas_server.py:240`): on save, respond `{"id": }` instead of echoing the whole body. Guarantees the only parsed key (`id`, 0x15c) is present even if the client's PUT body omits it. (Low risk, immediate.) 3. **Serve the FutSquadList shape on the GET /squad path the client uses for the roster**: capture the Squad-screen request live (fresh launch) to learn the exact query/verb, then return `{"squad": [ {summary} ]}` there (schema `0x180141fc0`). 4. **Add rename/delete ack routes** if the client calls them: `{}` bodies. 5. **Re-test add-to-squad live**: fresh FIFA launch (clears any cached s2v0 squad), open the Squad screen, place a card, confirm `PUT /squad` appears in the log. If it still doesn't, the gate is client-side (see Risks). 6. **Verify Tier C market/trade responses** against the log (they exist; confirm exact). 7. **Fill Tier D** with `{}` routes only if the log shows UNMAPPED hits — do not pre-build. 8. Port all Tier C shapes into Rust `openfut-core` behind the FIFA-17 bridge (matches repo goal). ## 4. Verification - `tools/test_fut_contract.py` (311 checks) must stay green after each change. - After the squad fixes: launch FIFA fresh, confirm squad renders with the saved item, then confirm a subsequent `PUT /squad` (if the client issues one) round-trips and persists (`STORE.save_squad`), surviving relaunch via `STORE.active_squad()`. - Live-capture the Squad-screen sequence (which GET /squad query fires for the roster) before finalizing the list-vs-load split. - Re-run the Ghidra scripts if any response changes: `/tmp/ghidra_fut/FindFut.java`, `ReadVtables.java`, `ReadMore.java`, `ReadSquadKeys.java`, `ReadSquadParser.java`. ## 5. Risks / unknowns - **The add-to-squad blocker is client-side — now proven, see §9c.** `AddPlayerToSquad`, `GetSquads` and `SelectSquadById` issue **zero** network requests; only `SaveCurrentSquad` does, and it is unguarded. So no response of ours can be "rejected" into blocking the save; the script layer simply never reaches it while the local squad-roster model is empty. `userInfo.squadList: []` (SKIP-safe, model empty) is the cause. Fixed per §3.1; if a populated roster still produces no `PUT`, the remaining target is the script layer in `FIFA17.exe` (packed — dynamic tracing required). - **List vs Load on the same URL** (`ut/%s/squad`) is not statically resolvable — the client has one request row per response (binding table `0x18027bXXX`), so they differ by query/verb that must be captured live. - `personaId` in the squad must equal the logged-in persona (`0x18014659c` check) or the SquadLoad handler takes the `vtable[0x4f0]` branch and creates a throwaway squad — keep `PERSONA_ID` in sync. - Type fidelity: never feed a scalar getter an object/array (freeze at `0x1801c7f1a`). All shapes above respect this (strings for `formation`/`squadType`/`custom`, arrays for `actives`/`manager`/`players`/`kicktakers`). `userInfo.squadList` must be an OBJECT with a `squad` array member, never a bare array. ## 6. Artifacts produced this session - `/tmp/ghidra_fut/` — Ghidra project (cardsdll.dll analyzed) + Java scripts (`FindFut.java`, `ReadVtables.java`, `ReadMore.java`, `ReadSquadKeys.java`, `ReadSquadParser.java`, `FindSquadStrings.java`, `DecompSquadSenders.java`, `DecompActiveSquad2.java`, `DecompSquadSerializer.java`, `DecompUserInfo.java`, `SquadBinding.java`, `DumpReqTable.java`, `DumpReqStrings.java`, `DumpBindings.java`, `DecompSenders.java`). - `/tmp/ghidra_fut/fut_scan.txt`, `vtables.txt`, `more.txt`, `squadkeys.txt`, `squadparser.txt`, `squad_strings.txt`, `squad_senders.txt`, `active_squad.txt`, `squad_serializer.txt`, `userinfo.txt`, `squad_binding.txt`, `reqtable.txt`, `reqstrings.txt`, `bindings.txt`, `senders.txt` — factory/vtable/deserializer/request-table dumps. - `/tmp/fut/all_fut_structs.txt` — the 102 response-struct names. - This plan + the 7 schemas above → next: fold them into `ENDPOINT_MAP.md`. ## 7. Massinfo freeze — root-cause correction (this session) Static analysis now explains (and likely resolves) the documented massinfo freeze. **Deserializer mapping (all verified in this session):** - `FUN_180174630` = **true** `FutGetUserMassInfoServerResponse` deser. Top-level keys it dispatches (flat object, no `user` wrapper): `userInfo`(0x370)→`0x18013ec10`, `squad`(0x2cd)→LoadActiveSquad deser `0x18013d1f0` (loads the ACTIVE squad model + sets personaId/flag), `settings`(0x2bf)→`0x18013c6d0`, `userData`(0x36d)→`0x180142470`, `clubUser`(0x91), `errors`(0x10c), `loanPlayerClientData`(0x199), `loanPlayers`(0x19a), `pileSizeClientData`(0x227). Unknown keys → SKIP (`FUN_180135ff0`). - The doc's "wrapper key is `user`(0x36c)" is **WRONG** — `user` appears only nested inside `clubUser`. The `userInfo` key is served directly. - `FUN_18014cc60` = separate full user-data parser (handles `login`(0x1a5)→userInfo, `squad`, `starterPack`(0x2e5), `bonusPacks`(0x5d), `userData`) — matches the doc's line-1146 format `{"login":true,"userData":{...},"squad":{},"starterPack":{},"bonusPacks":[]}`. This is a different response class, not the massinfo one. - `FUN_180146970` = FutGetUserInfo deser (`GET ut/%s/user`): body must be wrapped in exactly ONE member, name not compared → `{"userInfo": user_info()}` is correct there. **Why `user_info()` does NOT freeze the parser:** - All served fields type-match `0x18013ec10` (verified atom-by-atom this session). - `squadList: []` is SKIP-safe (`FUN_180142260` first token `[` ≠ `{` → `FUN_180135ff0` skip, exits on `}`). It does NOT spin — but leaves the client squad-roster EMPTY. - `squadList` must be `{"squad":[...]}` to populate the roster (elements parsed by `FUN_180141fc0`). **Most probable freeze cause (old evidence):** the `squad`(0x2cd) member fed to `FUN_18013d1f0` with a malformed squad object. The exact squad schema (§1) was NOT verified when the freeze was documented. With a correctly-shaped squad, massinfo can be POPULATED. **New recommendation (supersedes "massinfo MUST BE {}"):** serve a populated massinfo: ```json { "userInfo": {"...user_info() with squadList: {\"squad\":[{...summary...}]}"}, "squad": {...exact squad object (Schema §1)...}, "settings": {"configs": []}, "userData": {} } ``` This delivers the squad roster to the client AT BOOT (like EA), directly addressing the leading add-to-squad hypothesis. VERIFY LIVE before relying on it: fresh launch, watch `/tmp/utas_server.log` for the boot `/userMassInfo` request and confirm the hub loads with a populated squad roster, then test add-to-squad. ## 9. The add-to-squad blocker, solved statically (2026-08-03, session 2) New Ghidra pass (PyGhidra — see `tools/ghidra_env.py`; the Java/OSGi script path is broken on this box). The full call chain from the script API down to the wire is now reversed, and it **explains the missing `PUT /squad` mechanically**. ### 9a. Correction: the §1b service-method names were WRONG (off by one) The registrar `FUN_18004a3a0` writes 34 records of `{?, thunk, "ScriptName"}` (24-byte stride from `0x1802dfd30`). Pairing each thunk with its own record's name — not the neighbouring one — gives a different map than §1b recorded: | Script name | thunk | adapter slot | previously (wrongly) called | |---|---|---|---| | `AddPlayerToSquad` | `0x18004aa70` | +0xc8 | "LoadActiveSquad" | | `LoadActiveSquad` | `0x18004b340` | +0xc0 | — | | `SaveCurrentSquad` | `0x18004b5a0` | +0x28 | — | | `SaveSquad` | `0x18004b640` | +0x20 | — | | `RemovePlayer` | `0x18004b4a0` | +0x30 | "SaveCurrentSquad" | | `GetPotentialChemistry_Club` | `0x18004aff0` | +0xd0 | **"AddPlayerToSquad"** | | `GetSquadList` | `0x18004b180` | +0x80 | (correct) | | `GetSquads` | `0x18004b1d0` | +0x88 | (correct) | | `SelectSquadById` | `0x18004b6a0` | +0x90 | (correct) | Also: `FUN_18003b9d0` is **not** a "squad→JSON serializer" (§1b) — it is the generic script-object→native-handle converter used by many thunks. Full 34-entry map: `/tmp/ghidra_fut/fut_api_map.txt`. ### 9b. The layer stack ``` script: AddPlayerToSquad / SaveCurrentSquad / GetSquads ... thunk 0x18004a000-0x18004c000 (pull args off the script VM DAT_1802ef558) -> SquadManagerAdapter vtable 0x1801f75a8 (35 slots, name string follows) -> interface 0xe9e4f96 (COM-like registry, resolver 0x180009ce0) -> FutComponentServicesImpl::FutSquadServiceImpl vtable 0x180233ff0, size 0xf28 SaveCurrentSquad +0x50 -> 0x1801959e0 AddPlayerToSquad +0x168 -> 0x18018b940 GetSquads +0x1b8 -> 0x180192ec0 SelectSquadById +0x1c8 -> 0x180196080 ``` The service object is injected into CardsDLL by the host (`FUN_18004a9d0` stores the caller's pointer into `DAT_1802dfd18`; the concrete instance is built by `FUN_18004c180`). Sibling services: `FutAuctionServiceImpl`, `FutCardServiceImpl`, `FutStoreServiceImpl`, `FutTournamentServiceImpl`, … (all `FutComponentServicesImpl::*`). ### 9c. Why no `PUT /squad` — the actual mechanism Counting the request-dispatch call (`vtbl+0x250`, the one that enqueues an HTTP request with a completion callback) in each implementation: | Implementation | dispatches a request? | |---|---| | `SaveCurrentSquad` `0x1801959e0` | **YES — exactly one, and unconditional** | | `AddPlayerToSquad` `0x18018b940` | **NO — zero** | | `GetSquads` `0x180192ec0` | **NO — zero** | | `SelectSquadById` `0x180196080` | **NO — zero** | So: 1. **`AddPlayerToSquad` never touches the network.** It is a pure local-model mutation. With `param_2 < 0` (auto-slot) it asks the squad model for its slot array (`model->vtbl[0x270]`) and scans for the first free slot (`0x1801a8850`); if none is found it falls through `if (param_2 < 0) goto LAB_18018bd25` and returns having done nothing. Placement is then gated on `param_2 < 0x17` (23 slots). 2. **`GetSquads` and `SelectSquadById` never touch the network either** — they read the roster/squad model that must already be in memory. 3. **`SaveCurrentSquad` is the only writer, and it has no guard**: it builds the request body (player loop bounded by the model's count, ≤22) and dispatches unconditionally. If the script calls it, a `PUT` goes out — empty squad or not. **Conclusion:** the missing `PUT /squad` cannot be caused by any response we serve being *rejected* — the save path has no gate to fail. It can only be that the script/UI layer never reaches the save call, because the local squad-roster model was empty and so steps 1–2 no-op. That model is filled at boot from `userInfo.squadList` (atom `0x2d4` → `FUN_180142260` → roster singleton `0x18011a830`→`vtbl[0x480]`→`+0x30`), which we were serving as a bare `[]`. This is exactly what §3.1 / the massinfo change fix, and it upgrades the "leading hypothesis" of §5 to a mechanism supported at instruction level. It also means the remaining risk is narrow: if a populated roster still produces no `PUT`, the next target is the script layer in `FIFA17.exe` (packed → dynamic tracing), not CardsDLL. ## 10. LIVE RESULT — 2026-08-03 19:48 run (first test of populated massinfo) Boot sequence actually observed (`/tmp/utas_server.log`, marker `live-test-1`): ``` POST /ut/auth -> sid GET /settings -> {"configs":[]} GET /phishing/trusteddevice|question -> trusted / question POST /phishing/validate -> token PUT /match/reset -> {} (was UNMAPPED -> now routed) GET /userMassInfo -> POPULATED {userInfo,squad,settings,userData} PUT /v2/store/transaction/0 -> {} (body {"state":"TRANSACTIONCANCEL"}) ... then the client CRASHED ``` **Headline: the populated massinfo does NOT freeze the client.** It was served, parsed, and the client carried on to the next request. The "massinfo MUST BE `{}`" rule (CARD_SYSTEM.md, ENDPOINT_MAP.md) is now disproven *live*, not just statically — the old freeze was the malformed squad member, exactly as §7 predicted. **The crash.** The user reached the FUT club-creation prompt (club name + abbreviation), pressed Continue, and the game died. `CrashDump_2026.08.03_20.54.05.378.dmp` (`drive_c/users/steamuser/Documents/FIFA 17/Temp/`, parsed with `tools/…/dmp.py`): | field | value | |---|---| | exception | `0xc0000005` ACCESS_VIOLATION, **READ from `0x0`** | | address | `FIFA17.exe+0x71b8651` (base `0x140000000`) | | CardsDLL frames on stack | `+0x84f90` → `0x180084f90`, `+0x9ad90` → `0x18009ad90` | `0x18009ad90` is the request-completion callback thunk (the one embedded in the dispatch at `vtbl+0x250`), and `0x180084f90` is the **create-club handler**: it reports a `"PROFANITY"` error (code `0x7565`) on failure, and on success runs `strlen` over two stored string pointers (`+0x1a0` name, `+0x1d0` abbrev) **with no null check**. So the callback pair for the club-name step was live when a null string was dereferenced. This is the **first crash dump this prefix has ever produced** — the onboarding path is newly reachable because massinfo now populates `userInfo`. **Fix applied (hypothesis, needs the next live run to confirm):** diffing every atom `0x18013ec10` consumes against what `user_info()` sends showed the record's string fields are `clubName`(0x8e), `clubAbbr`(0x8d), `established`(0x110) — all sent — plus **`accountCreatedPlatformName`(0x6), which we never sent**. It is the only unsent string in the record, it is stored at `userInfo+0x42`, and the crash is a null string read in exactly that step. Now served as `"pc"` (matching the auth body's `nucleusPersonaPlatform`). Also changed: `POST /user` (CreateUser) now carries the real schema-correct squad instead of `{}` (an empty squad there would hand the freshly created club a 0-slot squad model — the §9c no-op state), and `PUT /match/reset` is routed explicitly. **That fix was WRONG.** Runs 2 and 3 crashed at the byte-identical address with `accountCreatedPlatformName` being served. Three dumps, one RVA — fully reproducible. (The field is still sent: the deser does consume it, so it is correct to include, it just was not the cause.) ### 10a. The actual faulting instruction The dump carries no code pages and FIFA17.exe is packed on disk, so the instruction was read from a LIVE process via `/proc//mem` (`tools/grab_crash_code.py`; Wine maps the PE flat at `0x140000000`, `ptrace_scope=0`): ``` 0x1471b8640 push rbx ; sub rsp,0x20 0x1471b8645 mov rbx, rcx ; this 0x1471b8648 test r9, r9 0x1471b864b je 0x1471b86ac ; arg3 == NULL is explicitly HANDLED 0x1471b864d mov rax, [r9 + 0x28] 0x1471b8651 mov r9, [rax] ; <<< FAULT: r9->field_0x28 is NULL 0x1471b8654 cmp edx, 0x80040000 ; edx = result/status code 0x1471b865c test edx, edx ; je ... ; edx == 0 -> success path ``` So the object is present (they null-check it) but its `+0x28` member was never initialised, and that member is dereferenced unguarded. Note the packer obfuscates constants (`mov r8d,0xe7ffa20f ; lea r8d,[r8+0x18005df4]` = `r8d = 3`). **Fault-time registers** (from the EXCEPTION stream's own context — the ThreadList context is the dump-writer's and is misleading): `RAX=0 RDX=0 RSI=0`, `R12=0x140000000`, `RIP=FIFA17.exe+0x71b8651`. The backtrace from the true RSP is **entirely FIFA17.exe — not one CardsDLL frame**, and **no HTTP request is issued when it happens**. So this is not a response of ours being rejected or mis-parsed; the client dies in its own UI code before reaching the network layer. ### 10b. Bisect in progress Because the crash only appeared once massinfo became populated, the member responsible is being isolated one relaunch at a time via `FUT_MASSINFO`: | value | delivers | result | |---|---|---| | `full` | userInfo + squad + settings + userData | **crash** (3/3) | | `squad` | squad only (feeds the ACTIVE squad model §9c) | ← current, untested | | `userinfo` | userInfo only (incl. squadList roster) | untested | | `settings` | settings only | untested | | `empty` | `{}` | last known hub-reaching | `squad` is tried first because it is the member that populates the squad model `AddPlayerToSquad` needs, while keeping `userInfo`/`squadList` out of the picture. **RESULT: `FUT_MASSINFO=squad` reaches the FUT hub with no crash — and the primary blocker is SOLVED.** So the crash lives in the `userInfo` member, not the squad. ### 10c. `PUT /squad` FIRES — blocker cleared (2026-08-03 20:16) With `FUT_MASSINFO=squad`, the Squads tab renders a full ACTIVE SQUAD (11 real named cards with correct ratings/positions/chemistry links) and the client then issued, of its own accord: ``` GET /ut/game/fifa17/club x3 PUT /ut/game/fifa17/squad/0 -> {"id": 0} <-- the request that never fired before GET /ut/game/fifa17/hub ``` Note the live URL is **`ut/%s/squad/`** (`/squad/0`), not a bare `ut/%s/squad`. The PUT body carries all 23 slots with 11 filled, each as an item *reference* (`{"index":0,"itemData":{"id":100000025,"dream":false},"kitNumber":11}`) — exactly the shape `Store.reconstruct_squad()` already expected. Verified round-trip: the squad persists to `fifa17_profile.json` and `GET /squad` re-serves it with all 11 real cards re-embedded, `custom` intact and 5 kicktakers. This confirms §9c end-to-end: nothing was wrong with the save path; the client simply needed a populated active squad model, which the massinfo `squad` member supplies. ### 10d. Remaining gap: the `userInfo` member Two concrete findings narrowed this from "bisect 20 fields" to "two suspects": **1. `currencies` used the wrong key — coins could never have rendered.** The userInfo currency-element parser `FUN_180138bd0` reads `name`(0x1d0), `funds`(0x134), `finalFunds`(0x124), `active`(0xa). There is **no `value` key**; the caller then `strcmp`s the name against `"coins"` / `"points"` and stores the parsed number. We were sending `{"name":"coins","value":N}`, so `value` was SKIP'd and coins parsed as 0. Now sending `name`/`funds`/`finalFunds`/`active`. (`won`(0x387) / `draw`(0xe6) / `loss`(0x1a6) were already correct — an earlier diff script wrongly flagged `won` as unhandled because its guard is `if (iVar4 != 0x387)`, an inequality the regex missed.) **2. Only two userInfo members have side effects outside the record.** Every other member fills a scalar/string slot; these two reach into the global model singleton `FUN_18011a830`: | member | atom | side effect | |---|---|---| | `squadList` | 0x2d4 | `vtbl[0x480]` → `+0x30` — fills the squad-ROSTER model | | `unopenedPacks` | 0x35e | `vtbl[0x4e0](preOrderPacks + recoveredPacks)` | Since the crash is a NULL member inside a **FIFA17.exe UI model** and userInfo had never actually reached the client before, these two newly-exercised branches are the prime suspects — and **neither is needed for coins/record**. Hence the `FUT_USERINFO` ladder, cheapest-first: | value | sends | purpose | |---|---|---| | `safe` (default) | neither | coins + record, minimum crash surface | | `packs` | +`unopenedPacks` | tests suspect 2 | | `roster` | +`squadList` | tests suspect 1, restores "MY SQUADS" | | `full` | both | end state if neither is the culprit | `FUT_MASSINFO` is back to `full`. **Instant fallback if the crash returns: `FUT_MASSINFO=squad`** — the proven-good config that still delivers the working active squad and `PUT /squad`. ### 10e. SOLVED — coins + record render (2026-08-03 20:33) **Working configuration: `FUT_MASSINFO=full` + `FUT_USERINFO=min`.** Live-confirmed: the hub shows 12,600 coins and the 0-0-0 record, no crash, no new crash dump, and the active squad is unaffected. The session exercised the store, transfer market, tradePile, watchList, a trade view, marketdata, `/squad/0`, `/user`, `/club` and `/user/credits` — all served. (Note `GET ut/%s/user` *is* reached once the hub is live, contradicting the old "never called at boot" note, which was only true of boot.) `min` sends exactly: `personaId`, `clubName`, `clubAbbr`, `established`, `accountCreatedPlatformName`, `currencies`, `won`, `draw`, `loss`. ### 10f. ROOT CAUSE ISOLATED — `clubNameChangeAllowed` must be `false` A clean single-variable experiment settles it. `live-test-4` and `live-test-6` sent the **identical userInfo field set** (both with `squadList` and `unopenedPacks` already omitted); the only difference was one bool: | `clubNameChangeAllowed`(0x8f) | club-name prompt | result | |---|---|---| | `true` | shown | **crash on confirm** — `ACCESS_VIOLATION` reading `0x0` at `FIFA17.exe+0x71b8651`, 4/4 runs | | `false` | **never appears** | hub loads, coins + record render, no crash dump | Sending `true` advertises "a club-name change is available", which pushes the client into a rename/creation flow whose UI model we never populate — hence the null `+0x28` member dereferenced in FIFA17.exe's own code, with no CardsDLL frame and no HTTP request in flight (the flow dies before it would ever call the server). Neither side-effecting member was involved; both were already absent in the crashing run. **`FUT_USERINFO=safe` is now the default** (19 userInfo fields). A contract check guards it: `clubNameChangeAllowed` may be absent, but must never be `true`. Do not set it true unless the rename flow is actually implemented server-side (`FutChangeClubNameServerResponse`, currently a Tier-B `{}` ack). ## 8. New artifacts (this session) - `/tmp/ghidra_fut/massinfo.txt` — full `0x180174630` decompile (all top-level keys). - `/tmp/ghidra_fut/settings.txt` — `0x18013c6d0` (settings/configs parser; `{"configs":[]}` OK). - `/tmp/ghidra_fut/squadlist2.txt` — `0x180142260` (squadList parser; `[]` skip-safe, `{"squad":[...]}` correct). - `/tmp/ghidra_fut/user_wrappers.txt`, `user_caller_dumps.txt` — the 3 callers of the userInfo sub-deser (`0x180146970`, `0x18014cc60`, `0x180174630`). - `fut_atoms.tsv` fully decoded for `user_info()` — only `squadList` was structurally wrong. ### Session 2 (the §9 pass) - `tools/ghidra_env.py` — PyGhidra harness (the Java/OSGi script path is broken here); `tools/ghidra_queries/*.py` — the queries that produced §9. - `/tmp/ghidra_fut/fut_api_map.txt` — all 34 script API names → thunk → adapter slot. - `/tmp/ghidra_fut/squad_service.txt` — `SquadManagerAdapter` vtable `0x1801f75a8`. - `/tmp/ghidra_fut/squad_svc.txt` — `FutSquadServiceImpl` vtable `0x180233ff0` + the `SaveCurrentSquad` / `AddPlayerToSquad` / `GetSquads` / `SelectSquadById` decompiles. - `/tmp/ghidra_fut/squad_impl.txt` — all 11 `FutComponentServicesImpl::*` service classes. ### 10g. `MY SQUADS: 0` — the squad-list URL exists after all `FUT_USERINFO=roster` did **not** crash (so `squadList` is safe to send), but the Squads screen still showed `MY SQUADS: 0`. The log explained why: ``` GET /ut/game/fifa17/squad/list <- a URL we did not know existed ``` `ut/%s/squad/list` is the real FutSquadList endpoint. The static pass concluded there was no separate list URL because the request table only holds `ut/%s/squad` — the `/list` suffix is appended by the caller rather than stored as a template, so it was invisible to a strings/table dump. Our generic `G + "/squad"` route swallowed it and returned the **active-squad object**; the list parser `0x180142260` recognises only `squad`(0x2cd) and SKIP'd every key of it, yielding an empty roster. Fix: route `/squad/list` (before the generic `/squad`) to `squad_list_body()`: | URL | response | parser | |---|---|---| | `GET ut/%s/squad/list` | `{"squad":[{summary}]}` | `0x180142260` -> elem `0x180141fc0` | | `GET ut/%s/squad[/]` | full active-squad object | `0x18013d1f0` | | `PUT ut/%s/squad/` | `{"id": }` | `0x180171a60` | Guarded by a contract check asserting `/squad/list` returns a `squad` array and is *not* the active-squad object. This also makes the `FUT_SQUAD_LIST=merged` workaround (S3) unnecessary — the two responses have distinct URLs, so no merged body is needed. ## 11. The online gate is POW/EASFC, not FUT (2026-08-03) "EA FC servers are unreachable / PRESS Q TO RE-CONNECT" is **not** the FUT/UTAS layer, not Blaze and not Origin/LSX — all three are healthy in our live logs while the banner shows. It is the EASFC layer in **`powdll_Win64_retail.dll`**: 1.1 MB, **unpacked and string-rich**, so unlike Denuvo-packed FIFA17.exe it can actually be reversed. CardsDLL contains zero hits for "unreachable"/"RE-CONNECT"/"PRESS Q"; powdll has `TXT_EASFC_RECONNECT_PROMPT`, `TXT_EASFC_SERVER_ERROR`, and the FE events `POWService::PowReconnect` / `TriggerPleaseConnectMsg` / `PowBlazeDisconnected`. POW is a **third HTTP API** we have never served: | role | default host | paths | |---|---|---| | api | `pas.gt.easfc.ea.com:8094` | `pow/auth`, `pow/healthcheck/system/all`, `pow/v2/activity`, `pow/nucleus/entitlements`, `pow/bank/user/account`, `pow/store/...`, … (58 templates extracted) | | content | `content.lt.easfc.ea.com:8080` | `pow/imgAssets/...`, `pow/artAssets/...` | Neither hostname is in `/etc/hosts` or the iptables DNAT, so **every POW call dies at DNS** — which is exactly the banner's trigger. ### Reversed (Ghidra project `/tmp/pow/powproj`, PE base 0x180000000) | addr | role | |---|---| | `FUN_18005a460` | POW config init. Reads `FIFA_POW_URL`, `FIFA_POW_CONTENT_SERVER_URL`, `POW_IS_ON` via `cfg->vtbl[0x30]` = `getString(key, default, &out)` — **the same merged `_all` client-config store that already delivers `ROSTERUPDATE_URL`**. Picks `http://` vs `https://` (`PTR_s_http____18010aee0` / `...aee8`). | | `FUN_18005cb40` | health-check / reconnect handler. Issues `pow/healthcheck/system/all` through request builder `FUN_18005e780`, then sets POW state at `POWmgr[0x6ac]`: **1 = connected, 3 = disconnected** (3 raises the prompt). Also the `PowReconnect` FE-event site. | | `FUN_18005c970` | fires `POWService::PowBlazeDisconnected` | | `FUN_1800a8590` | fires `POWService::TriggerPleaseConnectMsg` | | `FUN_1800ad090` | references `TXT_EASFC_RECONNECT_PROMPT` (the banner) | **Consequence: POW can be redirected with no root and no `/etc/hosts`** — just serve `FIFA_POW_URL` from `blaze_responder_v3b.py`. ### Shipped (all OFF by default) * `tools/pow_server.py` — POW/EASFC server on `:8094` (api) + `:8080` (content). `POW_MODE=log` (default) answers everything `200 {}` / assets `404` and logs the exact method+path+headers+body to `/tmp/pow_server.log`; `POW_MODE=serve` adds first-draft bodies for auth/healthcheck/counts. It knows all 58 extracted path templates and **flags any path outside that set**, so the capture also tells us where the extraction was incomplete. * `blaze_responder_v3b.py` — `OSDK_POW` keys, merged onto **every** CFID (same reasoning as `FUT_RS4_*`: which section powdll reads is unproven). Empty unless `FUT_POW=1`. * `openfut-fut.sh` — `pow` added to SERVERS (inert while idle: it only binds ports; nothing points at it until `FUT_POW=1`). * `root_arm.sh pow` / `root_arm.sh unpow` — the `/etc/hosts` fallback, opt-in because those entries persist across reboots. ### NOT done — the response schemas Only the REQUEST side is mapped. No powdll response parser has been walked, so every body `pow_server.py` returns is a placeholder. **The next step is a capture run:** ``` FUT_POW=1 ./openfut-fut.sh restart # then launch FIFA, enter FUT tail -f /tmp/pow_server.log # what does POW actually ask for? ``` Whatever appears there turns the schemas from guesswork into reversing targets — the same route that made the squad work tractable. Instant fallback: drop `FUT_POW` and restart.