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

36 KiB
Raw Blame History

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_18011a830vtable[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 WRONGuser 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:

{
  "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 0x2d4FUN_180142260 → roster singleton 0x18011a830vtbl[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 +0x84f900x180084f90, +0x9ad900x18009ad90

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 strcmps 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 confirmACCESS_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.txt0x18013c6d0 (settings/configs parser; {"configs":[]} OK).
  • /tmp/ghidra_fut/squadlist2.txt0x180142260 (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.txtSquadManagerAdapter vtable 0x1801f75a8.
  • /tmp/ghidra_fut/squad_svc.txtFutSquadServiceImpl 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.