59934b4ef0
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
637 lines
36 KiB
Markdown
637 lines
36 KiB
Markdown
# 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.
|