Files
OpenFUT/fifa17-recon/docs/FUT_RESPONSE_REBUILD_PLAN.md
T
funman300 59934b4ef0 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
2026-08-03 20:47:16 -07:00

637 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.