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
40 KiB
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_18004aff0is actuallyGetPotentialChemistry_Club, and0x18003b9d0is 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.
- Populate
userInfo.squadList(highest-leverage hypothesis): serve"squadList": {"squad": [ {id, squadName, formation, chemistry, rating, squadType} ]}fromSTOREin/userso the client's squad-roster model is non-empty (FUN_180142260, verified). Test that/userstill parses (no freeze) — if it freezes, fall back to populating only the FutSquadList GET path and revisit. - Fix
squad_routePUT (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.) - 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 (schema0x180141fc0). - Add rename/delete ack routes if the client calls them:
{}bodies. - Re-test add-to-squad live: fresh FIFA launch (clears any cached s2v0 squad), open the
Squad screen, place a card, confirm
PUT /squadappears in the log. If it still doesn't, the gate is client-side (see Risks). - Verify Tier C market/trade responses against the log (they exist; confirm exact).
- Fill Tier D with
{}routes only if the log shows UNMAPPED hits — do not pre-build. - Port all Tier C shapes into Rust
openfut-corebehind 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 viaSTORE.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,GetSquadsandSelectSquadByIdissue zero network requests; onlySaveCurrentSquaddoes, 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 noPUT, the remaining target is the script layer inFIFA17.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 table0x18027bXXX), so they differ by query/verb that must be captured live. personaIdin the squad must equal the logged-in persona (0x18014659ccheck) or the SquadLoad handler takes thevtable[0x4f0]branch and creates a throwaway squad — keepPERSONA_IDin sync.- Type fidelity: never feed a scalar getter an object/array (freeze at
0x1801c7f1a). All shapes above respect this (strings forformation/squadType/custom, arrays foractives/manager/players/kicktakers).userInfo.squadListmust be an OBJECT with asquadarray 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= trueFutGetUserMassInfoServerResponsedeser. Top-level keys it dispatches (flat object, nouserwrapper):userInfo(0x370)→0x18013ec10,squad(0x2cd)→LoadActiveSquad deser0x18013d1f0(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 —userappears only nested insideclubUser. TheuserInfokey is served directly. FUN_18014cc60= separate full user-data parser (handleslogin(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_180142260first token[≠{→FUN_180135ff0skip, exits on}). It does NOT spin — but leaves the client squad-roster EMPTY.squadListmust be{"squad":[...]}to populate the roster (elements parsed byFUN_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:
AddPlayerToSquadnever touches the network. It is a pure local-model mutation. Withparam_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 throughif (param_2 < 0) goto LAB_18018bd25and returns having done nothing. Placement is then gated onparam_2 < 0x17(23 slots).GetSquadsandSelectSquadByIdnever touch the network either — they read the roster/squad model that must already be in memory.SaveCurrentSquadis 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, aPUTgoes out — empty squad or not.
Conclusion: the missing PUT /squad cannot be caused by any response we serve
being rejected — the save path has no gate to fail. It can only be that the script/UI
layer never reaches the save call, because the local squad-roster model was empty and
so steps 1–2 no-op. That model is filled at boot from userInfo.squadList
(atom 0x2d4 → FUN_180142260 → roster singleton 0x18011a830→vtbl[0x480]→+0x30),
which we were serving as a bare [].
This is exactly what §3.1 / the massinfo change fix, and it upgrades the "leading
hypothesis" of §5 to a mechanism supported at instruction level. It also means the
remaining risk is narrow: if a populated roster still produces no PUT, the next
target is the script layer in FIFA17.exe (packed → dynamic tracing), not CardsDLL.
10. LIVE RESULT — 2026-08-03 19:48 run (first test of populated massinfo)
Boot sequence actually observed (/tmp/utas_server.log, marker live-test-1):
POST /ut/auth -> sid
GET /settings -> {"configs":[]}
GET /phishing/trusteddevice|question -> trusted / question
POST /phishing/validate -> token
PUT /match/reset -> {} (was UNMAPPED -> now routed)
GET /userMassInfo -> POPULATED {userInfo,squad,settings,userData}
PUT /v2/store/transaction/0 -> {} (body {"state":"TRANSACTIONCANCEL"})
... then the client CRASHED
Headline: the populated massinfo does NOT freeze the client. It was served, parsed,
and the client carried on to the next request. The "massinfo MUST BE {}" rule
(CARD_SYSTEM.md, ENDPOINT_MAP.md) is now disproven live, not just statically — the
old freeze was the malformed squad member, exactly as §7 predicted.
The crash. The user reached the FUT club-creation prompt (club name + abbreviation),
pressed Continue, and the game died. CrashDump_2026.08.03_20.54.05.378.dmp
(drive_c/users/steamuser/Documents/FIFA 17/Temp/, parsed with tools/…/dmp.py):
| field | value |
|---|---|
| exception | 0xc0000005 ACCESS_VIOLATION, READ from 0x0 |
| address | FIFA17.exe+0x71b8651 (base 0x140000000) |
| CardsDLL frames on stack | +0x84f90 → 0x180084f90, +0x9ad90 → 0x18009ad90 |
0x18009ad90 is the request-completion callback thunk (the one embedded in the
dispatch at vtbl+0x250), and 0x180084f90 is the create-club handler: it
reports a "PROFANITY" error (code 0x7565) on failure, and on success runs
strlen over two stored string pointers (+0x1a0 name, +0x1d0 abbrev) with no
null check. So the callback pair for the club-name step was live when a null string
was dereferenced. This is the first crash dump this prefix has ever produced — the
onboarding path is newly reachable because massinfo now populates userInfo.
Fix applied (hypothesis, needs the next live run to confirm): diffing every atom
0x18013ec10 consumes against what user_info() sends showed the record's string
fields are clubName(0x8e), clubAbbr(0x8d), established(0x110) — all sent — plus
accountCreatedPlatformName(0x6), which we never sent. It is the only unsent
string in the record, it is stored at userInfo+0x42, and the crash is a null string
read in exactly that step. Now served as "pc" (matching the auth body's
nucleusPersonaPlatform). Also changed: POST /user (CreateUser) now carries the
real schema-correct squad instead of {} (an empty squad there would hand the freshly
created club a 0-slot squad model — the §9c no-op state), and PUT /match/reset is
routed explicitly.
That fix was WRONG. Runs 2 and 3 crashed at the byte-identical address with
accountCreatedPlatformName being served. Three dumps, one RVA — fully reproducible.
(The field is still sent: the deser does consume it, so it is correct to include, it
just was not the cause.)
10a. The actual faulting instruction
The dump carries no code pages and FIFA17.exe is packed on disk, so the instruction was
read from a LIVE process via /proc/<pid>/mem (tools/grab_crash_code.py; Wine maps
the PE flat at 0x140000000, ptrace_scope=0):
0x1471b8640 push rbx ; sub rsp,0x20
0x1471b8645 mov rbx, rcx ; this
0x1471b8648 test r9, r9
0x1471b864b je 0x1471b86ac ; arg3 == NULL is explicitly HANDLED
0x1471b864d mov rax, [r9 + 0x28]
0x1471b8651 mov r9, [rax] ; <<< FAULT: r9->field_0x28 is NULL
0x1471b8654 cmp edx, 0x80040000 ; edx = result/status code
0x1471b865c test edx, edx ; je ... ; edx == 0 -> success path
So the object is present (they null-check it) but its +0x28 member was never
initialised, and that member is dereferenced unguarded. Note the packer obfuscates
constants (mov r8d,0xe7ffa20f ; lea r8d,[r8+0x18005df4] = r8d = 3).
Fault-time registers (from the EXCEPTION stream's own context — the ThreadList
context is the dump-writer's and is misleading): RAX=0 RDX=0 RSI=0,
R12=0x140000000, RIP=FIFA17.exe+0x71b8651. The backtrace from the true RSP is
entirely FIFA17.exe — not one CardsDLL frame, and no HTTP request is issued when
it happens. So this is not a response of ours being rejected or mis-parsed; the
client dies in its own UI code before reaching the network layer.
10b. Bisect in progress
Because the crash only appeared once massinfo became populated, the member responsible
is being isolated one relaunch at a time via FUT_MASSINFO:
| value | delivers | result |
|---|---|---|
full |
userInfo + squad + settings + userData | crash (3/3) |
squad |
squad only (feeds the ACTIVE squad model §9c) | ← current, untested |
userinfo |
userInfo only (incl. squadList roster) | untested |
settings |
settings only | untested |
empty |
{} |
last known hub-reaching |
squad is tried first because it is the member that populates the squad model
AddPlayerToSquad needs, while keeping userInfo/squadList out of the picture.
RESULT: FUT_MASSINFO=squad reaches the FUT hub with no crash — and the primary
blocker is SOLVED. So the crash lives in the userInfo member, not the squad.
10c. PUT /squad FIRES — blocker cleared (2026-08-03 20:16)
With FUT_MASSINFO=squad, the Squads tab renders a full ACTIVE SQUAD (11 real named
cards with correct ratings/positions/chemistry links) and the client then issued, of
its own accord:
GET /ut/game/fifa17/club x3
PUT /ut/game/fifa17/squad/0 -> {"id": 0} <-- the request that never fired before
GET /ut/game/fifa17/hub
Note the live URL is ut/%s/squad/<id> (/squad/0), not a bare ut/%s/squad.
The PUT body carries all 23 slots with 11 filled, each as an item reference
({"index":0,"itemData":{"id":100000025,"dream":false},"kitNumber":11}) — exactly the
shape Store.reconstruct_squad() already expected. Verified round-trip: the squad
persists to fifa17_profile.json and GET /squad re-serves it with all 11 real cards
re-embedded, custom intact and 5 kicktakers.
This confirms §9c end-to-end: nothing was wrong with the save path; the client simply
needed a populated active squad model, which the massinfo squad member supplies.
10d. Remaining gap: the userInfo member
Two concrete findings narrowed this from "bisect 20 fields" to "two suspects":
1. currencies used the wrong key — coins could never have rendered. The
userInfo currency-element parser FUN_180138bd0 reads
name(0x1d0), funds(0x134), finalFunds(0x124), active(0xa). There is no
value key; the caller then 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 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— full0x180174630decompile (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.tsvfully decoded foruser_info()— onlysquadListwas 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—SquadManagerAdaptervtable0x1801f75a8./tmp/ghidra_fut/squad_svc.txt—FutSquadServiceImplvtable0x180233ff0+ theSaveCurrentSquad/AddPlayerToSquad/GetSquads/SelectSquadByIddecompiles./tmp/ghidra_fut/squad_impl.txt— all 11FutComponentServicesImpl::*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 everything200 {}/ assets404and logs the exact method+path+headers+body to/tmp/pow_server.log;POW_MODE=serveadds 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_POWkeys, merged onto every CFID (same reasoning asFUT_RS4_*: which section powdll reads is unproven). Empty unlessFUT_POW=1.openfut-fut.sh—powadded to SERVERS (inert while idle: it only binds ports; nothing points at it untilFUT_POW=1).root_arm.sh pow/root_arm.sh unpow— the/etc/hostsfallback, 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.