Files
OpenFUT/fifa17-recon/docs/FUT_RESPONSE_REBUILD_PLAN.md
T
funman300 5d5198f5d1 fifa17-recon: match rewards, POW online layer, account backend, quick sell
Second session. FUT core loop, the EASFC/POW online layer, a central account
backend, and a lot of corrections. Everything risky is behind an env flag with
the default set to whatever was live-proven.

WORKING END TO END (live-verified this session):
  * match loop -- POST/PUT/POST/DELETE ut/%s/match, rewards via FutDestroyMatch
    (0x180121b60). Play a match, get coins, W/D/L updates.
  * packs -- buy, cards land in the club, session survives (FUT_PACK_AUTOCLUB=1)
  * quick sell -- POST ut/delete/%s/item was UNMAPPED and paid NOTHING; six cards
    were destroyed for 0 coins. Now credits discardValue.
  * POW/EASFC online -- the "EA FC servers unreachable" banner is powdll's layer,
    a THIRD http api on :8094 nobody had served. Redirect needs no root: powdll
    FUN_18005a460 reads FIFA_POW_URL from the same client-config store as
    ROSTERUPDATE_URL. FUT_POW=1.
  * account backend -- fut_account.py replaces 7 hardcoded copies of the persona
    across 5 files; club/persona/online-profile editable via CLI.

CORRECTIONS TO ENDPOINT_MAP (all re-extracted from the deserializers):
  * FutStoreGetPackTypes: id/packType/isPremium/quantity/saleType/purchaseLimit/
    purchaseCount are NOT skipped no-ops -- all are parsed. extPrice inner objects
    take externalPriceId(0x11a), not amount/currency.
  * FutMoveCard 0x180128600 has NO skip handler (FUN_180135ff0 appears zero times,
    unique among FUT deserializers) and parses only itemData -> dreamSquads.
  * class -> deserializer resolution: the name literal is preceded by a 4-BYTE
    HEADER and the factory LEA points at the header, so look up name_addr - 4.
    Six attempts failed on this; now ghidra_env.class_deser(). Unlocked 11 SBC/
    Draft schemas.
  * live-only endpoints the request table never lists: ut/%s/squad/list,
    ut/%s/user/club, ut/%s/club/stats/*, ut/%s/clientdata/<key>. The template
    table is a floor, not a ceiling -- the log is the only ground truth.
  * 163 RS4 call names exist; we served 17. All now served.

FIXED: club/stats/* was answering with the entire 28-item club inventory on every
poll (it fell through to the generic /club route).

UNSOLVED: the pack reveal's "Send to Club" (PUT ut/%s/item) kills the FUT session
whatever we answer -- {} included -- while its sibling quick-sell endpoint accepts
a bare {}. Seven hypotheses eliminated by live test, documented in
REBUILD_RESEARCH.md S14c so none get re-walked. FUT_PACK_AUTOCLUB routes around it.
Also unfixed: store tiles render "unknown" (displayGroup is parsed RECURSIVELY by
the same element parser; sending it FROZE the store, so FUT_STORE_GROUPS=1 is
default off).

Tests: test_fut_contract.py 380 (live, read-only) + test_match_rewards.py 51 (pure).

Note: fut_store.py carries some pre-existing uncommitted changes from before this
session (pack catalogue ids, pending-pile behaviour) that could not be separated
from this session's additions in the same file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 09:42:59 -07:00

700 lines
40 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.
## 11. The online gate is POW/EASFC, not FUT (2026-08-03)
"EA FC servers are unreachable / PRESS Q TO RE-CONNECT" is **not** the FUT/UTAS
layer, not Blaze and not Origin/LSX — all three are healthy in our live logs while
the banner shows. It is the EASFC layer in **`powdll_Win64_retail.dll`**: 1.1 MB,
**unpacked and string-rich**, so unlike Denuvo-packed FIFA17.exe it can actually be
reversed. CardsDLL contains zero hits for "unreachable"/"RE-CONNECT"/"PRESS Q";
powdll has `TXT_EASFC_RECONNECT_PROMPT`, `TXT_EASFC_SERVER_ERROR`, and the FE events
`POWService::PowReconnect` / `TriggerPleaseConnectMsg` / `PowBlazeDisconnected`.
POW is a **third HTTP API** we have never served:
| role | default host | paths |
|---|---|---|
| api | `pas.gt.easfc.ea.com:8094` | `pow/auth`, `pow/healthcheck/system/all`, `pow/v2/activity`, `pow/nucleus/entitlements`, `pow/bank/user/account`, `pow/store/...`, … (58 templates extracted) |
| content | `content.lt.easfc.ea.com:8080` | `pow/imgAssets/...`, `pow/artAssets/...` |
Neither hostname is in `/etc/hosts` or the iptables DNAT, so **every POW call dies at
DNS** — which is exactly the banner's trigger.
### Reversed (Ghidra project `/tmp/pow/powproj`, PE base 0x180000000)
| addr | role |
|---|---|
| `FUN_18005a460` | POW config init. Reads `FIFA_POW_URL`, `FIFA_POW_CONTENT_SERVER_URL`, `POW_IS_ON` via `cfg->vtbl[0x30]` = `getString(key, default, &out)`**the same merged `_all` client-config store that already delivers `ROSTERUPDATE_URL`**. Picks `http://` vs `https://` (`PTR_s_http____18010aee0` / `...aee8`). |
| `FUN_18005cb40` | health-check / reconnect handler. Issues `pow/healthcheck/system/all` through request builder `FUN_18005e780`, then sets POW state at `POWmgr[0x6ac]`: **1 = connected, 3 = disconnected** (3 raises the prompt). Also the `PowReconnect` FE-event site. |
| `FUN_18005c970` | fires `POWService::PowBlazeDisconnected` |
| `FUN_1800a8590` | fires `POWService::TriggerPleaseConnectMsg` |
| `FUN_1800ad090` | references `TXT_EASFC_RECONNECT_PROMPT` (the banner) |
**Consequence: POW can be redirected with no root and no `/etc/hosts`** — just serve
`FIFA_POW_URL` from `blaze_responder_v3b.py`.
### Shipped (all OFF by default)
* `tools/pow_server.py` — POW/EASFC server on `:8094` (api) + `:8080` (content).
`POW_MODE=log` (default) answers everything `200 {}` / assets `404` and logs the
exact method+path+headers+body to `/tmp/pow_server.log`; `POW_MODE=serve` adds
first-draft bodies for auth/healthcheck/counts. It knows all 58 extracted path
templates and **flags any path outside that set**, so the capture also tells us
where the extraction was incomplete.
* `blaze_responder_v3b.py` — `OSDK_POW` keys, merged onto **every** CFID (same
reasoning as `FUT_RS4_*`: which section powdll reads is unproven). Empty unless
`FUT_POW=1`.
* `openfut-fut.sh``pow` added to SERVERS (inert while idle: it only binds ports;
nothing points at it until `FUT_POW=1`).
* `root_arm.sh pow` / `root_arm.sh unpow` — the `/etc/hosts` fallback, opt-in
because those entries persist across reboots.
### NOT done — the response schemas
Only the REQUEST side is mapped. No powdll response parser has been walked, so every
body `pow_server.py` returns is a placeholder. **The next step is a capture run:**
```
FUT_POW=1 ./openfut-fut.sh restart # then launch FIFA, enter FUT
tail -f /tmp/pow_server.log # what does POW actually ask for?
```
Whatever appears there turns the schemas from guesswork into reversing targets —
the same route that made the squad work tractable. Instant fallback: drop `FUT_POW`
and restart.