# 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": <squadId>}` — 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":   <int>,    // 0x21b — must equal logged-in persona
  "squadName":   <str>,    // 0x2d3
  "squadType":   <str>,    // 0x2d6 — string, converted; value 2 flags items
  "starRating":  <int>,    // 0x2e2
  "formation":   <str>,    // 0x12b(299) — string, converted ("f442")
  "id":          <int>,    // 0x15c
  "chemistry":   <int>,    // 0x81
  "captain":     <int>,    // 0x69
  "changed":     <int>,    // 0x7e
  "custom":      <str>,    // 0xc6 — STRING containing a JSON array of 33 ints
                           //        (re-parsed inline; must be a complete array)
  "actives":     [ <item> ],   // 0xb  — item refs, parser 0x18013fe00
  "manager":     [ <item> ],   // 0x1a8 — item refs
  "players":     [ {"index":<int>, "itemData":<item>, "kitNumber":<int>} ], // 0x238
  "kicktakers":  [ {"id":<int>, "index":<int>} ]  // 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":    <int>,   // 0x274 — scalar getter 0x1801c79d0
 "chemistry": <int>,   // 0x81  — scalar getter 0x1801c79d0
 "formation": <str>,   // 0x12b — STRING getter 0x1801c7aa0 -> enum conv 0x180166590
 "id":        <int>,   // 0x15c
 "squadName": <str>,   // 0x2d3
 "squadType": <str>}   // 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": <int>, "contextValue": <int>, "type": <int>, "typeValue": <int>} ]}
```

### GrantPrizeChampionUser schema (via `0x180148ec0`)

```
{"awardedPrizes": [ {"awards": [<prize objects>], "eventId": <int>, "rank": <int>,
                     "money": <int>, "progress": <int>} ]}
```

## 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": <squadId>}` — **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": <saved squad 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/<pid>/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/<id>`** (`/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[/<id>]` | full active-squad object | `0x18013d1f0` |
| `PUT ut/%s/squad/<id>` | `{"id": <n>}` | `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.
