fifa17-recon: FUT squad blocker solved + userInfo delivered

The client now issues PUT /squad and the hub renders coins, record and the
squad roster. Three separate root causes, all verified live.

Squad blocker (the long-standing "client never sends PUT /squad"):
  AddPlayerToSquad, GetSquads and SelectSquadById issue ZERO network requests
  (pure local model reads/mutations, FutSquadServiceImpl vtable 0x180233ff0);
  only SaveCurrentSquad writes, and it is unguarded. The client simply needed a
  populated ACTIVE squad model, which arrives via the massinfo `squad` member.
  No response of ours was ever being rejected.

userMassInfo is NOT required to be {}:
  0x180174630 is a FLAT {userInfo, squad, settings, userData} body -- the old
  "wrapper key is user" note was wrong, and the historical freeze was the
  malformed squad member, not the envelope.

clubNameChangeAllowed must be false:
  sending true advertises a club-rename flow whose UI model is never populated;
  the client shows a naming prompt and dies confirming it (ACCESS_VIOLATION
  reading 0x0 at FIFA17.exe+0x71b8651, 4/4 runs, no CardsDLL frame and no request
  in flight). Isolated by a single-variable run; guarded by a contract check.

Endpoint/schema corrections found in live traffic, invisible to static analysis:
  * GET ut/%s/squad/list is a real endpoint and must return {"squad":[...]},
    not the active-squad object (the /list suffix is appended by the caller, so
    it never appeared in the request table)
  * PUT lands on ut/%s/squad/<id>, not a bare ut/%s/squad
  * userInfo currencies are read as name/funds/finalFunds/active -- there is no
    "value" key, so coins always rendered 0
  * squad-list elements take STRING formation/squadType, not ints
  * the CardsDLL script-API thunk<->name table was off by one (AddPlayerToSquad
    is 0x18004aa70; 0x18004aff0 is GetPotentialChemistry_Club)

FUT_MASSINFO / FUT_USERINFO ladders keep every step of the bisect reproducible.
Contract suite 311 -> 358 checks. Tooling added: PyGhidra harness (Ghidra's
Java/OSGi script path is broken on this box), minidump reader, live code grabber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
funman300
2026-08-03 20:47:16 -07:00
parent 6270c37208
commit 59934b4ef0
16 changed files with 1811 additions and 94 deletions
+7 -5
View File
@@ -38,15 +38,17 @@ Offline FIFA 17 Ultimate Team runs end-to-end on our backend:
## The squad-shell (what makes it work — don't regress these)
- `userMassInfo` MUST return `{}`. Any content (userInfo AND/OR squad) desyncs the
massinfo parser (0x180174630) → infinite tokenizer spin (busy-loop freeze at
0x1801c7f1a). `utas_server.py`: `FUT_MASSINFO=empty` (default).
- ~~`userMassInfo` MUST return `{}`~~ — **superseded 2026-08-03.** `0x180174630` is fully
decompiled: a FLAT `{userInfo, squad, settings, userData}` body. The old desync is
attributed to the `squad` member, which was built before `0x18013d1f0`'s schema was
known. `utas_server.py` now defaults to `FUT_MASSINFO=full`; fall back through
`squad`/`userinfo`/`settings`/`empty` to bisect if the hub freezes at 0x1801c7f1a.
- Deliver the squad via **`GET /squad/0`** (LoadActiveSquad, deser 0x18013d1f0),
which FIFA fetches on **Squads-tab entry** (re-fetches on tab-switch, not on
editor re-open). `FUT_SQUAD_STEP` selects the squad (s2v0 = 1 real item).
- `GET /user` is NEVER called at boot — userInfo can only reach FIFA via
userMassInfo (which we can't populate without the desync). So the hub's
coins/record can't be shown until the userInfo-in-massinfo desync is solved.
userMassInfo, which is now populated (above), so the hub's coins/record and the
squad roster (`userInfo.squadList`) are delivered at boot. Pending live verify.
## Why cards render generic (definitively reversed — 3 workflows)
+22 -18
View File
@@ -78,8 +78,9 @@ minimal known-good JSON — including ack-only (`{}`) responses.
- Store `extPrice` inner keys are **`amount`/`currency`**, not `mtx`; pack identity is
**`assetId`** (0x23), not `id` (harmless SKIPs otherwise).
- `ut/v2/store` must return `{"result":"SUCCESS"}` (currently unhandled → contributes to store error).
- `GetUserMassInfo` top-level wrapper key is **`user`** (calls userInfo/squad/settings sub-parsers);
keep `{}` until every nested shape is exact (freeze-sensitive).
- ~~`GetUserMassInfo` top-level wrapper key is **`user`**~~ — **WRONG, corrected 2026-08-03.**
The body is FLAT: `userInfo`(0x370)/`squad`(0x2cd)/`settings`(0x2bf)/`userData`(0x36d) are
top-level keys. `user`(0x36c) only appears nested inside `clubUser`. Now served populated.
- No-op deserializers (bare `ret`) where `{}` always suffices: ChangeClubName, ActivateCard,
SignLoanPlayer, and most ack responses.
@@ -1171,23 +1172,26 @@ reader → infinite spin at `0x1801c7f1a` (the hub freeze).
`actives`[] (array).
- **Handled:** `utas_server.USER_GET = {"userInfo": user_info()}`.
### FutGetUserMassInfoServerResponse — CONFIDENCE: HIGH (freeze behavior) ⚠ MUST BE {} — GAP for populated
- **Deser:** `0x180174630` (freeze-sensitive per CARD_SYSTEM.md).
### FutGetUserMassInfoServerResponse — CONFIDENCE: HIGH (schema) ✅ SERVED POPULATED (live-verify)
- **Deser:** `0x180174630` (full decompile: `/tmp/ghidra_fut/massinfo.txt`).
- **HTTP:** `GET ut/%s/userMassInfo`
- **Top-level keys (this deser dispatches):**
- `user` (0x36c) — nested → calls userInfo deser `0x18013ec10`
*(NOTE: the wrapping key is `user`, not `userInfo` — relevant if ever populated)*
- `clubUser` (0x91) — nested
- `settings` → calls settings deser `0x18013c6d0`
- squad → calls LoadActiveSquad deser `0x18013d1f0`
- `transaction` (0x339), `errors` (0x10c, array), `loanPlayers` (0x19a, array),
`pileSizeClientData` (0x227), `key`(0x177)/`value`(0x377) pairs
- **CRITICAL:** any content (userInfo AND/OR squad) desyncs the massinfo parser →
infinite tokenizer spin (`0x1801c7f1a`). **Return `{}`** — proven hub-reaching.
The exact squad/squadList shape + the userInfo nested-object typing (feature /
unopenedPacks / currencies) is the open desync GAP; deliver club/squad via their
own endpoints instead.
- **Handled:** `utas_server` `FUT_MASSINFO=empty` → `{}`.
- **Shape: FLAT object, NO wrapper.** (Corrected 2026-08-03 — the earlier "wrapper key is
`user`" note was wrong; `user`(0x36c) occurs only nested inside `clubUser`. The prologue,
2×NextToken before the key loop, is identical to the proven-flat CreateUser parser
`0x18014cc60`.)
- **Top-level keys this deser dispatches:**
- `userInfo` (0x370) → userInfo deser `0x18013ec10`
- `squad` (0x2cd) → LoadActiveSquad deser `0x18013d1f0` (loads the ACTIVE squad model)
- `settings` (0x2bf) → settings deser `0x18013c6d0`
- `userData` (0x36d) → `0x180142470`
- `clubUser` (0x91), `errors` (0x10c), `loanPlayerClientData` (0x199),
`loanPlayers` (0x19a), `pileSizeClientData` (0x227)
- everything else → SKIP (`0x180135ff0`)
- **Freeze history:** the documented "any content desyncs" was recorded before the squad
schema was reversed; the prime suspect is the malformed `squad` member fed to
`0x18013d1f0`. Every `user_info()` field type-checks against `0x18013ec10`.
- **Handled:** `utas_server.massinfo()` → `{userInfo, squad, settings, userData}`;
`FUT_MASSINFO=full|squad|userinfo|settings|empty` bisects it one member per relaunch.
### FutGetSettingsServerResponse — CONFIDENCE: HIGH ✅ HANDLED
- **Deser:** `0x18013c6d0`
@@ -0,0 +1,636 @@
# 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 12 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.