From 59934b4ef0214dde8456aced52e2c6b4253aac2e Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 3 Aug 2026 20:47:16 -0700 Subject: [PATCH] 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/, 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) Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW --- fifa17-recon/docs/CARD_SYSTEM.md | 12 +- fifa17-recon/docs/ENDPOINT_MAP.md | 40 +- .../docs/FUT_RESPONSE_REBUILD_PLAN.md | 636 ++++++++++++++++++ fifa17-recon/tools/dmp.py | 71 ++ fifa17-recon/tools/dmp_stack.py | 87 +++ fifa17-recon/tools/fut_seed.py | 35 + fifa17-recon/tools/futlog.py | 55 ++ fifa17-recon/tools/ghidra_env.py | 185 +++++ fifa17-recon/tools/ghidra_queries/q16_impl.py | 65 ++ .../tools/ghidra_queries/q17_final.py | 36 + fifa17-recon/tools/ghidra_queries/q7_map.py | 43 ++ .../tools/ghidra_queries/q9_vtable.py | 64 ++ fifa17-recon/tools/grab_crash_code.py | 71 ++ fifa17-recon/tools/test_fut_contract.py | 99 ++- fifa17-recon/tools/uidiff.py | 58 ++ fifa17-recon/tools/utas_server.py | 348 ++++++++-- 16 files changed, 1811 insertions(+), 94 deletions(-) create mode 100644 fifa17-recon/docs/FUT_RESPONSE_REBUILD_PLAN.md create mode 100644 fifa17-recon/tools/dmp.py create mode 100644 fifa17-recon/tools/dmp_stack.py create mode 100644 fifa17-recon/tools/futlog.py create mode 100644 fifa17-recon/tools/ghidra_env.py create mode 100644 fifa17-recon/tools/ghidra_queries/q16_impl.py create mode 100644 fifa17-recon/tools/ghidra_queries/q17_final.py create mode 100644 fifa17-recon/tools/ghidra_queries/q7_map.py create mode 100644 fifa17-recon/tools/ghidra_queries/q9_vtable.py create mode 100644 fifa17-recon/tools/grab_crash_code.py create mode 100644 fifa17-recon/tools/uidiff.py diff --git a/fifa17-recon/docs/CARD_SYSTEM.md b/fifa17-recon/docs/CARD_SYSTEM.md index 4a415c6..49c4cd8 100644 --- a/fifa17-recon/docs/CARD_SYSTEM.md +++ b/fifa17-recon/docs/CARD_SYSTEM.md @@ -38,15 +38,17 @@ Offline FIFA 17 Ultimate Team runs end-to-end on our backend: ## The squad-shell (what makes it work — don't regress these) -- `userMassInfo` MUST return `{}`. Any content (userInfo AND/OR squad) desyncs the - massinfo parser (0x180174630) → infinite tokenizer spin (busy-loop freeze at - 0x1801c7f1a). `utas_server.py`: `FUT_MASSINFO=empty` (default). +- ~~`userMassInfo` MUST return `{}`~~ — **superseded 2026-08-03.** `0x180174630` is fully + decompiled: a FLAT `{userInfo, squad, settings, userData}` body. The old desync is + attributed to the `squad` member, which was built before `0x18013d1f0`'s schema was + known. `utas_server.py` now defaults to `FUT_MASSINFO=full`; fall back through + `squad`/`userinfo`/`settings`/`empty` to bisect if the hub freezes at 0x1801c7f1a. - Deliver the squad via **`GET /squad/0`** (LoadActiveSquad, deser 0x18013d1f0), which FIFA fetches on **Squads-tab entry** (re-fetches on tab-switch, not on editor re-open). `FUT_SQUAD_STEP` selects the squad (s2v0 = 1 real item). - `GET /user` is NEVER called at boot — userInfo can only reach FIFA via - userMassInfo (which we can't populate without the desync). So the hub's - coins/record can't be shown until the userInfo-in-massinfo desync is solved. + userMassInfo, which is now populated (above), so the hub's coins/record and the + squad roster (`userInfo.squadList`) are delivered at boot. Pending live verify. ## Why cards render generic (definitively reversed — 3 workflows) diff --git a/fifa17-recon/docs/ENDPOINT_MAP.md b/fifa17-recon/docs/ENDPOINT_MAP.md index 8c78904..5232512 100644 --- a/fifa17-recon/docs/ENDPOINT_MAP.md +++ b/fifa17-recon/docs/ENDPOINT_MAP.md @@ -78,8 +78,9 @@ minimal known-good JSON — including ack-only (`{}`) responses. - Store `extPrice` inner keys are **`amount`/`currency`**, not `mtx`; pack identity is **`assetId`** (0x23), not `id` (harmless SKIPs otherwise). - `ut/v2/store` must return `{"result":"SUCCESS"}` (currently unhandled → contributes to store error). -- `GetUserMassInfo` top-level wrapper key is **`user`** (calls userInfo/squad/settings sub-parsers); - keep `{}` until every nested shape is exact (freeze-sensitive). +- ~~`GetUserMassInfo` top-level wrapper key is **`user`**~~ — **WRONG, corrected 2026-08-03.** + The body is FLAT: `userInfo`(0x370)/`squad`(0x2cd)/`settings`(0x2bf)/`userData`(0x36d) are + top-level keys. `user`(0x36c) only appears nested inside `clubUser`. Now served populated. - No-op deserializers (bare `ret`) where `{}` always suffices: ChangeClubName, ActivateCard, SignLoanPlayer, and most ack responses. @@ -1171,23 +1172,26 @@ reader → infinite spin at `0x1801c7f1a` (the hub freeze). `actives`[] (array). - **Handled:** `utas_server.USER_GET = {"userInfo": user_info()}`. -### FutGetUserMassInfoServerResponse — CONFIDENCE: HIGH (freeze behavior) ⚠ MUST BE {} — GAP for populated -- **Deser:** `0x180174630` (freeze-sensitive per CARD_SYSTEM.md). +### FutGetUserMassInfoServerResponse — CONFIDENCE: HIGH (schema) ✅ SERVED POPULATED (live-verify) +- **Deser:** `0x180174630` (full decompile: `/tmp/ghidra_fut/massinfo.txt`). - **HTTP:** `GET ut/%s/userMassInfo` -- **Top-level keys (this deser dispatches):** - - `user` (0x36c) — nested → calls userInfo deser `0x18013ec10` - *(NOTE: the wrapping key is `user`, not `userInfo` — relevant if ever populated)* - - `clubUser` (0x91) — nested - - `settings` → calls settings deser `0x18013c6d0` - - squad → calls LoadActiveSquad deser `0x18013d1f0` - - `transaction` (0x339), `errors` (0x10c, array), `loanPlayers` (0x19a, array), - `pileSizeClientData` (0x227), `key`(0x177)/`value`(0x377) pairs -- **CRITICAL:** any content (userInfo AND/OR squad) desyncs the massinfo parser → - infinite tokenizer spin (`0x1801c7f1a`). **Return `{}`** — proven hub-reaching. - The exact squad/squadList shape + the userInfo nested-object typing (feature / - unopenedPacks / currencies) is the open desync GAP; deliver club/squad via their - own endpoints instead. -- **Handled:** `utas_server` `FUT_MASSINFO=empty` → `{}`. +- **Shape: FLAT object, NO wrapper.** (Corrected 2026-08-03 — the earlier "wrapper key is + `user`" note was wrong; `user`(0x36c) occurs only nested inside `clubUser`. The prologue, + 2×NextToken before the key loop, is identical to the proven-flat CreateUser parser + `0x18014cc60`.) +- **Top-level keys this deser dispatches:** + - `userInfo` (0x370) → userInfo deser `0x18013ec10` + - `squad` (0x2cd) → LoadActiveSquad deser `0x18013d1f0` (loads the ACTIVE squad model) + - `settings` (0x2bf) → settings deser `0x18013c6d0` + - `userData` (0x36d) → `0x180142470` + - `clubUser` (0x91), `errors` (0x10c), `loanPlayerClientData` (0x199), + `loanPlayers` (0x19a), `pileSizeClientData` (0x227) + - everything else → SKIP (`0x180135ff0`) +- **Freeze history:** the documented "any content desyncs" was recorded before the squad + schema was reversed; the prime suspect is the malformed `squad` member fed to + `0x18013d1f0`. Every `user_info()` field type-checks against `0x18013ec10`. +- **Handled:** `utas_server.massinfo()` → `{userInfo, squad, settings, userData}`; + `FUT_MASSINFO=full|squad|userinfo|settings|empty` bisects it one member per relaunch. ### FutGetSettingsServerResponse — CONFIDENCE: HIGH ✅ HANDLED - **Deser:** `0x18013c6d0` diff --git a/fifa17-recon/docs/FUT_RESPONSE_REBUILD_PLAN.md b/fifa17-recon/docs/FUT_RESPONSE_REBUILD_PLAN.md new file mode 100644 index 0000000..9d0aadb --- /dev/null +++ b/fifa17-recon/docs/FUT_RESPONSE_REBUILD_PLAN.md @@ -0,0 +1,636 @@ +# 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": }` — 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": , // 0x21b — must equal logged-in persona + "squadName": , // 0x2d3 + "squadType": , // 0x2d6 — string, converted; value 2 flags items + "starRating": , // 0x2e2 + "formation": , // 0x12b(299) — string, converted ("f442") + "id": , // 0x15c + "chemistry": , // 0x81 + "captain": , // 0x69 + "changed": , // 0x7e + "custom": , // 0xc6 — STRING containing a JSON array of 33 ints + // (re-parsed inline; must be a complete array) + "actives": [ ], // 0xb — item refs, parser 0x18013fe00 + "manager": [ ], // 0x1a8 — item refs + "players": [ {"index":, "itemData":, "kitNumber":} ], // 0x238 + "kicktakers": [ {"id":, "index":} ] // 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": , // 0x274 — scalar getter 0x1801c79d0 + "chemistry": , // 0x81 — scalar getter 0x1801c79d0 + "formation": , // 0x12b — STRING getter 0x1801c7aa0 -> enum conv 0x180166590 + "id": , // 0x15c + "squadName": , // 0x2d3 + "squadType": } // 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": , "contextValue": , "type": , "typeValue": } ]} +``` + +### GrantPrizeChampionUser schema (via `0x180148ec0`) + +``` +{"awardedPrizes": [ {"awards": [], "eventId": , "rank": , + "money": , "progress": } ]} +``` + +## 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": }` — **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": }` + instead of echoing the whole body. Guarantees the only parsed key (`id`, 0x15c) is present + even if the client's PUT body omits it. (Low risk, immediate.) +3. **Serve the FutSquadList shape on the GET /squad path the client uses for the roster**: + capture the Squad-screen request live (fresh launch) to learn the exact query/verb, then + return `{"squad": [ {summary} ]}` there (schema `0x180141fc0`). +4. **Add rename/delete ack routes** if the client calls them: `{}` bodies. +5. **Re-test add-to-squad live**: fresh FIFA launch (clears any cached s2v0 squad), open the + Squad screen, place a card, confirm `PUT /squad` appears in the log. If it still doesn't, + the gate is client-side (see Risks). +6. **Verify Tier C market/trade responses** against the log (they exist; confirm exact). +7. **Fill Tier D** with `{}` routes only if the log shows UNMAPPED hits — do not pre-build. +8. Port all Tier C shapes into Rust `openfut-core` behind the FIFA-17 bridge (matches repo goal). + +## 4. Verification + +- `tools/test_fut_contract.py` (311 checks) must stay green after each change. +- After the squad fixes: launch FIFA fresh, confirm squad renders with the saved item, then + confirm a subsequent `PUT /squad` (if the client issues one) round-trips and persists + (`STORE.save_squad`), surviving relaunch via `STORE.active_squad()`. +- Live-capture the Squad-screen sequence (which GET /squad query fires for the roster) before + finalizing the list-vs-load split. +- Re-run the Ghidra scripts if any response changes: `/tmp/ghidra_fut/FindFut.java`, + `ReadVtables.java`, `ReadMore.java`, `ReadSquadKeys.java`, `ReadSquadParser.java`. + +## 5. Risks / unknowns + +- **The add-to-squad blocker is client-side — now proven, see §9c.** `AddPlayerToSquad`, + `GetSquads` and `SelectSquadById` issue **zero** network requests; only `SaveCurrentSquad` + does, and it is unguarded. So no response of ours can be "rejected" into blocking the save; + the script layer simply never reaches it while the local squad-roster model is empty. + `userInfo.squadList: []` (SKIP-safe, model empty) is the cause. Fixed per §3.1; if a + populated roster still produces no `PUT`, the remaining target is the script layer in + `FIFA17.exe` (packed — dynamic tracing required). +- **List vs Load on the same URL** (`ut/%s/squad`) is not statically resolvable — the client + has one request row per response (binding table `0x18027bXXX`), so they differ by query/verb + that must be captured live. +- `personaId` in the squad must equal the logged-in persona (`0x18014659c` check) or the + SquadLoad handler takes the `vtable[0x4f0]` branch and creates a throwaway squad — keep + `PERSONA_ID` in sync. +- Type fidelity: never feed a scalar getter an object/array (freeze at `0x1801c7f1a`). All + shapes above respect this (strings for `formation`/`squadType`/`custom`, arrays for + `actives`/`manager`/`players`/`kicktakers`). `userInfo.squadList` must be an OBJECT with a + `squad` array member, never a bare array. + +## 6. Artifacts produced this session + +- `/tmp/ghidra_fut/` — Ghidra project (cardsdll.dll analyzed) + Java scripts (`FindFut.java`, + `ReadVtables.java`, `ReadMore.java`, `ReadSquadKeys.java`, `ReadSquadParser.java`, + `FindSquadStrings.java`, `DecompSquadSenders.java`, `DecompActiveSquad2.java`, + `DecompSquadSerializer.java`, `DecompUserInfo.java`, `SquadBinding.java`, `DumpReqTable.java`, + `DumpReqStrings.java`, `DumpBindings.java`, `DecompSenders.java`). +- `/tmp/ghidra_fut/fut_scan.txt`, `vtables.txt`, `more.txt`, `squadkeys.txt`, `squadparser.txt`, + `squad_strings.txt`, `squad_senders.txt`, `active_squad.txt`, `squad_serializer.txt`, + `userinfo.txt`, `squad_binding.txt`, `reqtable.txt`, `reqstrings.txt`, `bindings.txt`, + `senders.txt` — factory/vtable/deserializer/request-table dumps. +- `/tmp/fut/all_fut_structs.txt` — the 102 response-struct names. +- This plan + the 7 schemas above → next: fold them into `ENDPOINT_MAP.md`. + +## 7. Massinfo freeze — root-cause correction (this session) + +Static analysis now explains (and likely resolves) the documented massinfo freeze. + +**Deserializer mapping (all verified in this session):** +- `FUN_180174630` = **true** `FutGetUserMassInfoServerResponse` deser. Top-level keys it + dispatches (flat object, no `user` wrapper): `userInfo`(0x370)→`0x18013ec10`, + `squad`(0x2cd)→LoadActiveSquad deser `0x18013d1f0` (loads the ACTIVE squad model + + sets personaId/flag), `settings`(0x2bf)→`0x18013c6d0`, `userData`(0x36d)→`0x180142470`, + `clubUser`(0x91), `errors`(0x10c), `loanPlayerClientData`(0x199), + `loanPlayers`(0x19a), `pileSizeClientData`(0x227). Unknown keys → SKIP (`FUN_180135ff0`). +- The doc's "wrapper key is `user`(0x36c)" is **WRONG** — `user` appears only nested inside + `clubUser`. The `userInfo` key is served directly. +- `FUN_18014cc60` = separate full user-data parser (handles `login`(0x1a5)→userInfo, + `squad`, `starterPack`(0x2e5), `bonusPacks`(0x5d), `userData`) — matches the doc's + line-1146 format `{"login":true,"userData":{...},"squad":{},"starterPack":{},"bonusPacks":[]}`. + This is a different response class, not the massinfo one. +- `FUN_180146970` = FutGetUserInfo deser (`GET ut/%s/user`): body must be wrapped in exactly + ONE member, name not compared → `{"userInfo": user_info()}` is correct there. + +**Why `user_info()` does NOT freeze the parser:** +- All served fields type-match `0x18013ec10` (verified atom-by-atom this session). +- `squadList: []` is SKIP-safe (`FUN_180142260` first token `[` ≠ `{` → `FUN_180135ff0` skip, + exits on `}`). It does NOT spin — but leaves the client squad-roster EMPTY. +- `squadList` must be `{"squad":[...]}` to populate the roster (elements parsed by + `FUN_180141fc0`). + +**Most probable freeze cause (old evidence):** the `squad`(0x2cd) member fed to +`FUN_18013d1f0` with a malformed squad object. The exact squad schema (§1) was NOT verified +when the freeze was documented. With a correctly-shaped squad, massinfo can be POPULATED. + +**New recommendation (supersedes "massinfo MUST BE {}"):** serve a populated massinfo: +```json +{ + "userInfo": {"...user_info() with squadList: {\"squad\":[{...summary...}]}"}, + "squad": {...exact squad object (Schema §1)...}, + "settings": {"configs": []}, + "userData": {} +} +``` +This delivers the squad roster to the client AT BOOT (like EA), directly addressing the +leading add-to-squad hypothesis. VERIFY LIVE before relying on it: fresh launch, watch +`/tmp/utas_server.log` for the boot `/userMassInfo` request and confirm the hub loads with +a populated squad roster, then test add-to-squad. + +## 9. The add-to-squad blocker, solved statically (2026-08-03, session 2) + +New Ghidra pass (PyGhidra — see `tools/ghidra_env.py`; the Java/OSGi script path is +broken on this box). The full call chain from the script API down to the wire is now +reversed, and it **explains the missing `PUT /squad` mechanically**. + +### 9a. Correction: the §1b service-method names were WRONG (off by one) + +The registrar `FUN_18004a3a0` writes 34 records of `{?, thunk, "ScriptName"}` (24-byte +stride from `0x1802dfd30`). Pairing each thunk with its own record's name — not the +neighbouring one — gives a different map than §1b recorded: + +| Script name | thunk | adapter slot | previously (wrongly) called | +|---|---|---|---| +| `AddPlayerToSquad` | `0x18004aa70` | +0xc8 | "LoadActiveSquad" | +| `LoadActiveSquad` | `0x18004b340` | +0xc0 | — | +| `SaveCurrentSquad` | `0x18004b5a0` | +0x28 | — | +| `SaveSquad` | `0x18004b640` | +0x20 | — | +| `RemovePlayer` | `0x18004b4a0` | +0x30 | "SaveCurrentSquad" | +| `GetPotentialChemistry_Club` | `0x18004aff0` | +0xd0 | **"AddPlayerToSquad"** | +| `GetSquadList` | `0x18004b180` | +0x80 | (correct) | +| `GetSquads` | `0x18004b1d0` | +0x88 | (correct) | +| `SelectSquadById` | `0x18004b6a0` | +0x90 | (correct) | + +Also: `FUN_18003b9d0` is **not** a "squad→JSON serializer" (§1b) — it is the generic +script-object→native-handle converter used by many thunks. Full 34-entry map: +`/tmp/ghidra_fut/fut_api_map.txt`. + +### 9b. The layer stack + +``` +script: AddPlayerToSquad / SaveCurrentSquad / GetSquads ... + thunk 0x18004a000-0x18004c000 (pull args off the script VM DAT_1802ef558) + -> SquadManagerAdapter vtable 0x1801f75a8 (35 slots, name string follows) + -> interface 0xe9e4f96 (COM-like registry, resolver 0x180009ce0) + -> FutComponentServicesImpl::FutSquadServiceImpl vtable 0x180233ff0, size 0xf28 + SaveCurrentSquad +0x50 -> 0x1801959e0 + AddPlayerToSquad +0x168 -> 0x18018b940 + GetSquads +0x1b8 -> 0x180192ec0 + SelectSquadById +0x1c8 -> 0x180196080 +``` +The service object is injected into CardsDLL by the host (`FUN_18004a9d0` stores the +caller's pointer into `DAT_1802dfd18`; the concrete instance is built by +`FUN_18004c180`). Sibling services: `FutAuctionServiceImpl`, `FutCardServiceImpl`, +`FutStoreServiceImpl`, `FutTournamentServiceImpl`, … (all `FutComponentServicesImpl::*`). + +### 9c. Why no `PUT /squad` — the actual mechanism + +Counting the request-dispatch call (`vtbl+0x250`, the one that enqueues an HTTP +request with a completion callback) in each implementation: + +| Implementation | dispatches a request? | +|---|---| +| `SaveCurrentSquad` `0x1801959e0` | **YES — exactly one, and unconditional** | +| `AddPlayerToSquad` `0x18018b940` | **NO — zero** | +| `GetSquads` `0x180192ec0` | **NO — zero** | +| `SelectSquadById` `0x180196080` | **NO — zero** | + +So: + +1. **`AddPlayerToSquad` never touches the network.** It is a pure local-model + mutation. With `param_2 < 0` (auto-slot) it asks the squad model for its slot + array (`model->vtbl[0x270]`) and scans for the first free slot (`0x1801a8850`); + if none is found it falls through `if (param_2 < 0) goto LAB_18018bd25` and + returns having done nothing. Placement is then gated on `param_2 < 0x17` (23 slots). +2. **`GetSquads` and `SelectSquadById` never touch the network either** — they read + the roster/squad model that must already be in memory. +3. **`SaveCurrentSquad` is the only writer, and it has no guard**: it builds the + request body (player loop bounded by the model's count, ≤22) and dispatches + unconditionally. If the script calls it, a `PUT` goes out — empty squad or not. + +**Conclusion:** the missing `PUT /squad` cannot be caused by any response we serve +being *rejected* — the save path has no gate to fail. It can only be that the script/UI +layer never reaches the save call, because the local squad-roster model was empty and +so steps 1–2 no-op. That model is filled at boot from `userInfo.squadList` +(atom `0x2d4` → `FUN_180142260` → roster singleton `0x18011a830`→`vtbl[0x480]`→`+0x30`), +which we were serving as a bare `[]`. + +This is exactly what §3.1 / the massinfo change fix, and it upgrades the "leading +hypothesis" of §5 to a mechanism supported at instruction level. It also means the +remaining risk is narrow: if a populated roster still produces no `PUT`, the next +target is the script layer in `FIFA17.exe` (packed → dynamic tracing), not CardsDLL. + +## 10. LIVE RESULT — 2026-08-03 19:48 run (first test of populated massinfo) + +Boot sequence actually observed (`/tmp/utas_server.log`, marker `live-test-1`): + +``` +POST /ut/auth -> sid +GET /settings -> {"configs":[]} +GET /phishing/trusteddevice|question -> trusted / question +POST /phishing/validate -> token +PUT /match/reset -> {} (was UNMAPPED -> now routed) +GET /userMassInfo -> POPULATED {userInfo,squad,settings,userData} +PUT /v2/store/transaction/0 -> {} (body {"state":"TRANSACTIONCANCEL"}) + ... then the client CRASHED +``` + +**Headline: the populated massinfo does NOT freeze the client.** It was served, parsed, +and the client carried on to the next request. The "massinfo MUST BE `{}`" rule +(CARD_SYSTEM.md, ENDPOINT_MAP.md) is now disproven *live*, not just statically — the +old freeze was the malformed squad member, exactly as §7 predicted. + +**The crash.** The user reached the FUT club-creation prompt (club name + abbreviation), +pressed Continue, and the game died. `CrashDump_2026.08.03_20.54.05.378.dmp` +(`drive_c/users/steamuser/Documents/FIFA 17/Temp/`, parsed with `tools/…/dmp.py`): + +| field | value | +|---|---| +| exception | `0xc0000005` ACCESS_VIOLATION, **READ from `0x0`** | +| address | `FIFA17.exe+0x71b8651` (base `0x140000000`) | +| CardsDLL frames on stack | `+0x84f90` → `0x180084f90`, `+0x9ad90` → `0x18009ad90` | + +`0x18009ad90` is the request-completion callback thunk (the one embedded in the +dispatch at `vtbl+0x250`), and `0x180084f90` is the **create-club handler**: it +reports a `"PROFANITY"` error (code `0x7565`) on failure, and on success runs +`strlen` over two stored string pointers (`+0x1a0` name, `+0x1d0` abbrev) **with no +null check**. So the callback pair for the club-name step was live when a null string +was dereferenced. This is the **first crash dump this prefix has ever produced** — the +onboarding path is newly reachable because massinfo now populates `userInfo`. + +**Fix applied (hypothesis, needs the next live run to confirm):** diffing every atom +`0x18013ec10` consumes against what `user_info()` sends showed the record's string +fields are `clubName`(0x8e), `clubAbbr`(0x8d), `established`(0x110) — all sent — plus +**`accountCreatedPlatformName`(0x6), which we never sent**. It is the only unsent +string in the record, it is stored at `userInfo+0x42`, and the crash is a null string +read in exactly that step. Now served as `"pc"` (matching the auth body's +`nucleusPersonaPlatform`). Also changed: `POST /user` (CreateUser) now carries the +real schema-correct squad instead of `{}` (an empty squad there would hand the freshly +created club a 0-slot squad model — the §9c no-op state), and `PUT /match/reset` is +routed explicitly. + +**That fix was WRONG.** Runs 2 and 3 crashed at the byte-identical address with +`accountCreatedPlatformName` being served. Three dumps, one RVA — fully reproducible. +(The field is still sent: the deser does consume it, so it is correct to include, it +just was not the cause.) + +### 10a. The actual faulting instruction + +The dump carries no code pages and FIFA17.exe is packed on disk, so the instruction was +read from a LIVE process via `/proc//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/`** (`/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[/]` | full active-squad object | `0x18013d1f0` | +| `PUT ut/%s/squad/` | `{"id": }` | `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. diff --git a/fifa17-recon/tools/dmp.py b/fifa17-recon/tools/dmp.py new file mode 100644 index 0000000..3d11281 --- /dev/null +++ b/fifa17-recon/tools/dmp.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Minimal minidump reader: exception code/address + module list, so a FIFA 17 +CrashDump can be mapped to +RVA (and thence to a Ghidra address).""" +import struct, sys + +p = sys.argv[1] +d = open(p, "rb").read() +assert d[:4] == b"MDMP", "not a minidump: %r" % d[:4] +ver, nstreams, dirrva = struct.unpack_from("= 2: + print(" -> %s at %#x" % ({0: "READ from", 1: "WRITE to", 8: "EXECUTE at"}.get(params[0], "access"), + params[1])) + +if exc_addr is not None: + hit = [m for m in mods if m[0] <= exc_addr < m[0] + m[1]] + print("\n=== FAULTING MODULE ===") + if hit: + base, sz, name = hit[0] + short = name.split("\\")[-1] + print(" %s base=%#x size=%#x" % (short, base, sz)) + print(" RVA = %#x" % (exc_addr - base)) + print(" ghidra (PE base 0x180000000) = %#x" % (0x180000000 + (exc_addr - base))) + else: + print(" address %#x is in NO loaded module (bad indirect call / corrupt ptr)" % exc_addr) + +print("\n=== modules of interest ===") +for base, sz, name in mods: + s = name.split("\\")[-1].lower() + if any(k in s for k in ("cards", "fifa", "dbdata", "core", "game")): + print(" %-28s base=%#014x size=%#x" % (name.split("\\")[-1], base, sz)) diff --git a/fifa17-recon/tools/dmp_stack.py b/fifa17-recon/tools/dmp_stack.py new file mode 100644 index 0000000..a14c27a --- /dev/null +++ b/fifa17-recon/tools/dmp_stack.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Pseudo-backtrace from a minidump: walk the faulting thread's stack and report +every qword that points into a loaded module's code (i.e. plausible return +addresses), innermost first.""" +import struct, sys + +p = sys.argv[1] +d = open(p, "rb").read() +nstreams, dirrva = struct.unpack_from("= 0x100: + rsp = struct.unpack_from(" 0] + return sum(ratings) // len(ratings) if ratings else 0 + + +def squad_summary(squad): + """One FutSquadList element (deser 0x180141fc0, verified 2026-08-03). + + Exactly six atoms are recognised; everything else is SKIP'd: + rating 0x274 int (scalar getter 0x1801c79d0) + chemistry 0x81 int (scalar getter 0x1801c79d0) + formation 0x12b STRING (string getter 0x1801c7aa0 -> enum conv 0x180166590) + id 0x15c int + squadName 0x2d3 STRING + squadType 0x2d6 STRING (string getter 0x1801c7aa0 -> enum conv 0x1801668e0) + + NOTE: formation/squadType are STRINGS here, exactly as in the full-squad parser + 0x18013d1f0 -- they go through the same 0x1801c7aa0 + converter pair. (The + rebuild plan's "" for those two was wrong; feeding ints to a string getter + is the classic type-mismatch freeze at 0x1801c7f1a.) + """ + return { + "rating": squad_rating(squad), + "chemistry": int(squad.get("chemistry", 0)), + "formation": squad.get("formation", "f442"), + "id": int(squad.get("id", 0)), + "squadName": squad.get("squadName", "OpenFUT"), + "squadType": squad.get("squadType", "REGULAR_SQUAD"), + } + + # Selected at import time from the environment (default s1 = the zero-resolve test). STEP = os.environ.get("FUT_SQUAD_STEP", "s1") SQUAD, CLUB = make_squad(STEP) diff --git a/fifa17-recon/tools/futlog.py b/fifa17-recon/tools/futlog.py new file mode 100644 index 0000000..009c4b5 --- /dev/null +++ b/fifa17-recon/tools/futlog.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Summarise /tmp/utas_server.log since the last MARKER line: one row per request +(verb, path, status), plus the squad/massinfo detail lines. Keeps the transcript +small instead of dumping the raw log.""" +import re, sys, collections + +LOG = "/tmp/utas_server.log" +raw = open(LOG, errors="replace").read().splitlines() +# start after the last marker (or the last server banner) +start = 0 +for i, l in enumerate(raw): + if "=== MARKER" in l or "=== utas_server" in l: + start = i +lines = raw[start:] + +REQ = re.compile(r"^\[[\d:]+\] (GET|PUT|POST|DELETE|HEAD|PATCH) (\S+)") +RESP = re.compile(r"^\[[\d:]+\] -> (\d+) (.*)") +rows, counts, pending = [], collections.Counter(), None +notes = [] +for l in lines: + m = REQ.match(l) + if m: + pending = (m.group(1), m.group(2).split("?")[0], m.group(2)) + continue + m = RESP.match(l) + if m and pending: + rows.append((pending[0], pending[1], m.group(1), len(m.group(2)))) + counts[(pending[0], pending[1])] += 1 + pending = None + continue + if "UNMAPPED" in l or "SQUAD:" in l or "STORE:" in l or "MARKET:" in l or "ITEM:" in l: + notes.append(l.strip()) + +print(f"{len(rows)} requests since marker\n") +print("verb path n") +for (v, p), n in counts.most_common(): + print(f"{v:6} {p:48} {n}") + +squad = [r for r in rows if "/squad" in r[1]] +print(f"\n--- squad family ({len(squad)}) ---") +for r in squad: + print(" ", r[0], r[1], "->", r[2], f"({r[3]}b)") +# the live URL is /squad/ (e.g. /squad/0), not a bare /squad -- match on the +# segment, not endswith, or the headline result reads as a false negative. +print("\nPUT /squad seen:", any(r[0] == "PUT" and "/squad" in r[1] for r in rows)) +mi = [r for r in rows if "userMassInfo" in r[1]] +print("userMassInfo requests:", len(mi), [r[2] for r in mi]) +if notes: + print("\n--- notes ---") + for n in notes[-25:]: + print(" ", n) +last = rows[-6:] +print("\n--- last 6 requests (where it stopped) ---") +for r in last: + print(" ", r[0], r[1], "->", r[2]) diff --git a/fifa17-recon/tools/ghidra_env.py b/fifa17-recon/tools/ghidra_env.py new file mode 100644 index 0000000..37ff04b --- /dev/null +++ b/fifa17-recon/tools/ghidra_env.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""OpenFUT Ghidra helper: opens the analysed cardsdll.dll program once and exposes +decompile / xref / vtable helpers, so each RE question is a small python file +instead of a JVM restart + OSGi compile. + +Usage: ghidra_env.py -- runs with the helpers in scope + (see tools/ghidra_queries/ for worked examples) + +WHY PYGHIDRA: this box's Ghidra 12.1.2 cannot compile .java scripts at all -- +analyzeHeadless -postScript Foo.java dies with "Failed to get OSGi bundle +containing script" for EVERY script, including ones that ran before (it is the +in-process OSGi/javac path that is broken, not the scripts). PyGhidra bypasses it. +Setup once: + python3 -m venv gvenv + gvenv/bin/pip install --no-index \ + --find-links /opt/ghidra/Ghidra/Features/PyGhidra/pypkg/dist pyghidra + gvenv/bin/python ghidra_env.py + +Two traps this file already works around: + * open_program(..., nested_project_location=False) -- otherwise pyghidra creates + a NEW empty project at // and re-imports (losing the analysis). + * os._exit(0) at the end -- JVM teardown under jpype deadlocks forever. + * read_bytes() uses a Java byte[]; passing a Python bytearray to Memory.getBytes + silently reads NOTHING and every scan comes back with 0 hits. +""" +import os, sys + +os.environ.setdefault("GHIDRA_INSTALL_DIR", "/opt/ghidra") +import pyghidra + +pyghidra.start(verbose=False) + +from ghidra.app.decompiler import DecompInterface # noqa: E402 +from ghidra.util.task import ConsoleTaskMonitor # noqa: E402 + +DLL = "/tmp/fut/cardsdll.dll" +PROJ_DIR, PROJ = "/tmp/ghidra_fut", "cardsdll" + +# nested_project_location=False -> use /tmp/ghidra_fut/cardsdll.gpr itself (the +# already-analysed project) instead of creating /tmp/ghidra_fut/cardsdll/. +_ctx = pyghidra.open_program(DLL, project_location=PROJ_DIR, project_name=PROJ, + analyze=False, program_name="cardsdll.dll", + nested_project_location=False) +flat = _ctx.__enter__() +prog = flat.getCurrentProgram() +mon = ConsoleTaskMonitor() +fm = prog.getFunctionManager() +listing = prog.getListing() +mem = prog.getMemory() +refs = prog.getReferenceManager() + +_dec = DecompInterface() +_dec.openProgram(prog) + + +def addr(a): + return prog.getAddressFactory().getDefaultAddressSpace().getAddress(int(a)) + + +def func(a): + return fm.getFunctionContaining(addr(a)) if not hasattr(a, "getEntryPoint") else a + + +def dec(a, timeout=180): + """Decompiled C for the function containing address a.""" + f = func(a) + if f is None: + return "// no function at %#x" % int(a) + r = _dec.decompileFunction(f, timeout, mon) + if r is None or not r.decompileCompleted(): + return "// decompile failed for %s" % f.getName() + return str(r.getDecompiledFunction().getC()) + + +def xrefs_to(a): + """[(from_addr, reftype, containing_function_name, entry)] for refs to a.""" + out = [] + it = refs.getReferencesTo(addr(a)) + while it.hasNext(): + r = it.next() + f = fm.getFunctionContaining(r.getFromAddress()) + out.append((int(r.getFromAddress().getOffset()), str(r.getReferenceType()), + f.getName() if f else "?", + int(f.getEntryPoint().getOffset()) if f else 0)) + return out + + +def qword(a): + return mem.getLong(addr(a)) & 0xFFFFFFFFFFFFFFFF + + +def dword(a): + return mem.getInt(addr(a)) & 0xFFFFFFFF + + +import jpype # noqa: E402 +_JBYTE = jpype.JArray(jpype.JByte) + + +def read_bytes(a, n): + """Bulk read n bytes at a. MUST use a Java byte[] -- passing a Python + bytearray to Memory.getBytes silently reads nothing (this bug quietly + zeroed several earlier scans).""" + buf = _JBYTE(int(n)) + got = mem.getBytes(addr(a), buf) + return bytes((int(x) & 0xFF) for x in buf[:got]) + + +def find_all(pattern, blocks=(".text", ".rdata", ".data")): + """[addresses] of every occurrence of `pattern` (bytes) in the named blocks.""" + hits = [] + for b in mem.getBlocks(): + if b.getName() not in blocks or not b.isInitialized(): + continue + s = int(b.getStart().getOffset()) + size = int(b.getEnd().getOffset()) - s + 1 + off = 0 + chunk = 1 << 20 + while off < size: + ln = min(chunk, size - off) + try: + data = read_bytes(s + off, ln) + except Exception: + off += ln + continue + i = data.find(pattern) + while i != -1: + hits.append(s + off + i) + i = data.find(pattern, i + 1) + off += ln - (len(pattern) - 1) if ln == chunk else ln + return hits + + +def rd_str(a, maxlen=200): + b = bytearray() + p = int(a) + for _ in range(maxlen): + c = mem.getByte(addr(p)) & 0xFF + if c == 0: + break + b.append(c) + p += 1 + return b.decode("utf-8", "replace") + + +def vtable(a, n=64): + """[(slot_offset, target_addr, function_name)] reading n qwords at a.""" + out = [] + for i in range(n): + try: + t = qword(int(a) + i * 8) + except Exception: + break + f = fm.getFunctionAt(addr(t)) if 0x180000000 <= t < 0x181000000 else None + out.append((i * 8, t, f.getName() if f else "")) + return out + + +def fname(a): + f = func(a) + return f.getName() if f else "?" + + +def callees(a): + f = func(a) + return sorted({(int(c.getEntryPoint().getOffset()), c.getName()) + for c in f.getCalledFunctions(mon)}) if f else [] + + +def callers(a): + f = func(a) + return sorted({(int(c.getEntryPoint().getOffset()), c.getName()) + for c in f.getCallingFunctions(mon)}) if f else [] + + +if __name__ == "__main__": + if len(sys.argv) > 1: + g = dict(globals()) + g["__name__"] = "__main__" + exec(open(sys.argv[1]).read(), g) + else: + print("loaded:", prog.getName(), fm.getFunctionCount(), "functions") + sys.stdout.flush() + # JVM teardown deadlocks under jpype here -- skip it, all output is flushed. + os._exit(0) diff --git a/fifa17-recon/tools/ghidra_queries/q16_impl.py b/fifa17-recon/tools/ghidra_queries/q16_impl.py new file mode 100644 index 0000000..e5a2ba7 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q16_impl.py @@ -0,0 +1,65 @@ +# Q16: FutComponentServicesImpl::FutSquadServiceImpl -- get its ctor + vtable and +# decompile SaveCurrentSquad(+0x50) / AddPlayerToSquad(+0x168) for real. +out = open("/tmp/ghidra_fut/squad_impl.txt", "w") +P = lambda *a: print(*a, file=out) + +P("=== all FutComponentServicesImpl::* service classes ===") +for a in find_all(b"FutComponentServicesImpl::", blocks=(".rdata", ".data")): + P(" %#x %s" % (a, rd_str(a, 90))) + +P("\n=== ctor/factory FUN_180189be0 ===") +P(dec(0x180189be0)[:3000]) + +NAME = None +for a in find_all(b"FutComponentServicesImpl::FutSquadServiceImpl", blocks=(".rdata", ".data")): + NAME = a +P("\nclass-name string @ %#x; xrefs:" % NAME) +for frm, typ, fn, ent in xrefs_to(NAME): + P(" %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + +# The ctor writes the vtable into the object. Find vtables whose slot count is +# large and that live near other Fut service vtables; verify by checking that +# +0x50 / +0x168 / +0x198 / +0x1b8 / +0x1c8 are all real functions. +P("\n=== candidate FutSquadServiceImpl vtables (>=58 slots) ===") +TXT_LO, TXT_HI = 0x180001000, 0x1801e4fff + + +def is_fn(v): + return TXT_LO <= v <= TXT_HI and fm.getFunctionAt(addr(v)) is not None + + +p, cur, runs = 0x1801e5000, None, [] +while p < 0x2891f0 + 0x180000000: + try: + v = qword(p) + except Exception: + v = 0 + if is_fn(v): + if cur is None: + cur = [p, 0] + cur[1] += 1 + else: + if cur and cur[1] >= 58: + runs.append(tuple(cur)) + cur = None + p += 8 +if cur and cur[1] >= 58: + runs.append(tuple(cur)) + +for s, n in runs: + nm = rd_str(s + n * 8, 60) + P(" vtable %#x %d slots trailing=%r" % (s, n, nm[:45])) + +P("\n=== slot decompiles for every candidate ===") +for s, n in runs: + nm = rd_str(s + n * 8, 60) + P("\n##### vtable %#x (%d slots, %r) #####" % (s, n, nm[:40])) + for off, tag in ((0x50, "SaveCurrentSquad"), (0x168, "AddPlayerToSquad")): + if off // 8 >= n: + continue + t = qword(s + off) + f = fm.getFunctionAt(addr(t)) + P("--- +%#05x %s -> %#x %s ---" % (off, tag, t, f.getName() if f else "")) + P(dec(t)[:3000]) +out.close() +print("wrote squad_impl.txt") diff --git a/fifa17-recon/tools/ghidra_queries/q17_final.py b/fifa17-recon/tools/ghidra_queries/q17_final.py new file mode 100644 index 0000000..61ac742 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q17_final.py @@ -0,0 +1,36 @@ +# Q17: FutSquadServiceImpl vtable = 0x180233ff0 (written by ctor FUN_180189be0). +# Dump it and decompile the placement/save slots. +out = open("/tmp/ghidra_fut/squad_svc.txt", "w") +P = lambda *a: print(*a, file=out) + +VT = 0x180233FF0 +SL = {0x50: "SaveCurrentSquad", 0xb8: "pre-save getter", 0x168: "AddPlayerToSquad", + 0x198: "GetSquadList", 0x1b8: "GetSquads", 0x1c8: "SelectSquadById"} + +P("=== FutSquadServiceImpl vtable @ %#x ===" % VT) +n = 0x60 +for i in range(n): + a = VT + i * 8 + try: + t = qword(a) + except Exception: + break + f = fm.getFunctionContaining(addr(t)) if 0x180001000 <= t <= 0x1801e4fff else None + if f is None and not (0x180001000 <= t <= 0x1801e4fff): + P(" +%#05x %#x " % (i * 8, t)) + break + P(" +%#05x -> %#x %-18s %s" % (i * 8, t, f.getName() if f else "(no fn)", + SL.get(i * 8, ""))) + +P("\n=== decompiles ===") +for off in (0x50, 0xb8, 0x168, 0x198, 0x1b8, 0x1c8): + try: + t = qword(VT + off) + except Exception: + continue + f = fm.getFunctionContaining(addr(t)) + P("\n########## +%#05x %s -> %#x %s ##########" + % (off, SL.get(off, ""), t, f.getName() if f else "")) + P(dec(t)) +out.close() +print("wrote squad_svc.txt") diff --git a/fifa17-recon/tools/ghidra_queries/q7_map.py b/fifa17-recon/tools/ghidra_queries/q7_map.py new file mode 100644 index 0000000..9ebd4d2 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q7_map.py @@ -0,0 +1,43 @@ +# Q7: build the definitive FUT script-API map: script name -> thunk -> service +# vtable slot, by parsing the 24-byte {?, fnptr, name} records the registrar +# FUN_18004a3a0 writes, then joining with the slot each thunk calls. +import re +out = open("/tmp/ghidra_fut/fut_api_map.txt", "w") +P = lambda *a: print(*a, file=out) + +c = dec(0x18004a3a0, 300) +# records appear as: = FUN_xxxxxxxxx; = "Name"; +pairs = re.findall(r'=\s*(FUN_[0-9a-f]+);\s*\n\s*\w+\s*=\s*"([^"]+)"', c) +P("=== registrar records: %d ===" % len(pairs)) + +SLOT = re.compile(r"\*\(code \*\*\)\(lVar\d+ \+ (0x[0-9a-f]+|\d+)\)") +rows = [] +for fnname, script in pairs: + ent = int(fnname.replace("FUN_", ""), 16) + body = dec(ent, 60) + slots = sorted(set(int(s, 16) if s.startswith("0x") else int(s) + for s in SLOT.findall(body))) + nargs = len(re.findall(r"FUN_18019fb[45]0\(", body)) + ret = "FUN_18019fc00(" in body or "FUN_18019fbf0(" in body + rows.append((slots[0] if slots else -1, script, ent, nargs, ret, body)) + +P("\n%-34s %-8s %-14s %s" % ("script name", "slot", "thunk", "args ret")) +for slot, script, ent, nargs, ret, _ in sorted(rows): + P("%-34s %-8s %#-14x %d %s" % (script, hex(slot) if slot >= 0 else "-", ent, + nargs, "Y" if ret else "")) + +P("\n=== thunks with NO service call (pure local/query) ===") +for slot, script, ent, nargs, ret, body in sorted(rows): + if slot < 0: + P("--- %s @ %#x ---" % (script, ent)) + P(body) + +WANT = ("AddPlayer", "SaveCurrentSquad", "SaveSquad", "RemovePlayer", + "GetCurrentSquadID", "GetCurrentSquadData", "SetPlayer", "SwapPlayer") +P("\n=== decompiles of the placement/save path ===") +for slot, script, ent, nargs, ret, body in sorted(rows): + if any(w.lower() in script.lower() for w in WANT): + P("--- %s slot=%s @ %#x ---" % (script, hex(slot) if slot >= 0 else "-", ent)) + P(body) +out.close() +print("wrote fut_api_map.txt; records:", len(pairs)) diff --git a/fifa17-recon/tools/ghidra_queries/q9_vtable.py b/fifa17-recon/tools/ghidra_queries/q9_vtable.py new file mode 100644 index 0000000..69d04bf --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q9_vtable.py @@ -0,0 +1,64 @@ +# Q9: FUN_18004c180 builds the squad-service object registered into DAT_1802dfd18. +# Recover its vtable, then decompile AddPlayerToSquad(+0xc8) and SaveCurrentSquad +# (+0x28) -- the two slots that decide whether PUT /squad is ever issued. +out = open("/tmp/ghidra_fut/squad_service.txt", "w") +P = lambda *a: print(*a, file=out) + +P("=== factory FUN_18004c180 ===") +P(dec(0x18004c180)) + +# find vtable pointers written by the factory +f = func(0x18004c180) +cands = {} +for a in f.getBody().getAddresses(True): + ins = listing.getInstructionAt(a) + if ins is None: + continue + for r in ins.getReferencesFrom(): + t = int(r.getToAddress().getOffset()) + if 0x1801e5000 <= t <= 0x1802891ff: + cands[t] = cands.get(t, 0) + 1 + +P("\n=== vtable candidates referenced by the factory ===") +best = None +for t, n in sorted(cands.items()): + try: + fns = [fm.getFunctionAt(addr(qword(t + i * 8))) for i in range(6)] + except Exception: + continue + nf = sum(1 for x in fns if x) + if nf >= 5: + P(" %#x (%d/6 fnptrs, %d refs)" % (t, nf, n)) + if best is None: + best = t + +SLOTS = {0x20: "SaveSquad", 0x28: "SaveCurrentSquad", 0x30: "RemovePlayer", + 0x40: "GetCurrentSquadID?", 0x48: "GetCurrentSquadChemistry", + 0x58: "GetCurrentSquadData", 0x80: "GetSquadList", 0x88: "GetSquads", + 0x90: "SelectSquadById", 0xa8: "IsCardInSquad", 0xb8: "GetSquadLineup", + 0xc0: "LoadActiveSquad", 0xc8: "AddPlayerToSquad"} + +for t in sorted(cands): + try: + fns = [fm.getFunctionAt(addr(qword(t + i * 8))) for i in range(6)] + except Exception: + continue + if sum(1 for x in fns if x) < 5: + continue + P("\n=== VTABLE %#x ===" % t) + for off, tgt, name in vtable(t, 40): + tag = SLOTS.get(off, "") + P(" +%#04x -> %#x %-16s %s" % (off, tgt, name, tag)) + P("\n--- key slot decompiles ---") + for off in (0x28, 0xc8, 0x88, 0x80, 0x90, 0xc0): + try: + tgt = qword(t + off) + except Exception: + continue + if not fm.getFunctionAt(addr(tgt)): + continue + P("### +%#04x %s -> %#x" % (off, SLOTS.get(off, ""), tgt)) + P(dec(tgt)) + break +out.close() +print("wrote squad_service.txt") diff --git a/fifa17-recon/tools/grab_crash_code.py b/fifa17-recon/tools/grab_crash_code.py new file mode 100644 index 0000000..9e5e6d7 --- /dev/null +++ b/fifa17-recon/tools/grab_crash_code.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Wait for FIFA17.exe, then dump + disassemble the unpacked code around a VA. + +FIFA17.exe is packed on disk but Wine maps it flat at 0x140000000 and it unpacks +at load, so the only way to read the real instructions is from a LIVE process +(/proc//mem, needs ptrace_scope=0 -- openfut-fut.sh's root_arm does that). +The game does NOT need to be at the crash point; the code is mapped as soon as +the module is up. + +Usage: grab_crash_code.py [va_hex] [nbytes_before] [nbytes_after] +Default VA is the 2026-08-03 create-club crash site FIFA17.exe+0x71b8651. +""" +import glob, os, sys, time + +VA = int(sys.argv[1], 16) if len(sys.argv) > 1 else 0x1471B8651 +BEFORE = int(sys.argv[2]) if len(sys.argv) > 2 else 0xC0 +AFTER = int(sys.argv[3]) if len(sys.argv) > 3 else 0x60 +OUT = "/tmp/crash_code.txt" + + +def find_pid(): + for d in glob.glob("/proc/[0-9]*"): + try: + if open(d + "/comm").read().strip() == "FIFA17.exe": + return int(d.split("/")[-1]) + except Exception: + pass + return None + + +print("waiting for FIFA17.exe (launch the game; no need to reach the crash)...", + flush=True) +pid = None +while pid is None: + pid = find_pid() + if pid is None: + time.sleep(2) +print("pid=%d, reading %#x" % (pid, VA), flush=True) + +# give the unpacker a moment after process start +time.sleep(5) +start = VA - BEFORE +with open("/proc/%d/mem" % pid, "rb") as f: + f.seek(start) + data = f.read(BEFORE + AFTER) + +lines = ["pid=%d window %#x..%#x (%d bytes)" % (pid, start, start + len(data), len(data)), + "raw: " + data.hex()] +try: + import capstone + md = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64) + md.detail = False + # align: disassemble from several offsets, keep the run that lands exactly on VA + best = None + for skip in range(0, 16): + ins = list(md.disasm(data[skip:], start + skip)) + if any(i.address == VA for i in ins): + if best is None or len(ins) > len(best[1]): + best = (skip, ins) + if best: + for i in best[1]: + mark = " <<<<< FAULT (read from 0x0)" if i.address == VA else "" + lines.append(" %#x %-10s %s%s" % (i.address, i.mnemonic, i.op_str, mark)) + else: + lines.append("could not align a disassembly onto the fault VA") +except ImportError: + lines.append("(capstone not installed; raw bytes above)") + +open(OUT, "w").write("\n".join(lines) + "\n") +print("\n".join(lines)) +print("\nwrote " + OUT) diff --git a/fifa17-recon/tools/test_fut_contract.py b/fifa17-recon/tools/test_fut_contract.py index ada235c..3d18600 100644 --- a/fifa17-recon/tools/test_fut_contract.py +++ b/fifa17-recon/tools/test_fut_contract.py @@ -18,6 +18,7 @@ import json, sys, urllib.request BASE = "http://127.0.0.1:8099" G = "/ut/game/fifa17" V2 = "/ut/v2/game/fifa17" +PERSONA_ID = 33068179 # blaze LoginResponse SESS.BUID / PDTL.PID _fail = [] _pass = 0 @@ -123,11 +124,98 @@ def test_squad_boot(): check("squad slot itemData is object-or-null", it is None or is_obj(it), repr(it)) -def test_massinfo_empty(): - # GetUserMassInfo (deser 0x180174630) MUST stay {} -- any populated userInfo/squad - # desyncs the parser -> freeze (CARD_SYSTEM.md). Guard against accidental population. +def test_squad_list_shape(): + # userInfo.squadList (atom 0x2d4) goes through FUN_180142260 -- the same parser as + # the FutSquadList response -- so it must be an OBJECT with a "squad" ARRAY, never + # a bare array. Each element (0x180141fc0): rating/chemistry/id INT, + # formation/squadName/squadType STRING (string getter 0x1801c7aa0 + enum conv). + d = _get(G + "/user") + ui = d.get("userInfo", {}) + check("user.userInfo is object", is_obj(ui), repr(type(ui))) + # Every STRING atom 0x18013ec10 consumes must be present and non-empty: a null + # string pointer in this record is what the 2026-08-03 create-club crash read. + for k in ("clubName", "clubAbbr", "established", "accountCreatedPlatformName"): + check(f"userInfo.{k} is non-empty string", is_str(ui.get(k)) and ui.get(k), repr(ui.get(k))) + # actives is optional (omitted by FUT_USERINFO=min); when present it must be an + # array of at most 5 item refs (0x18013ec10 stops storing past index 4). + act = ui.get("actives") + check("userInfo.actives absent or array", act is None or is_arr(act), repr(act)) + check("userInfo.actives <= 5", len(act or []) <= 5) + # coins/record are what the hub renders: currencies elements are read by + # FUN_180138bd0 as name/funds/finalFunds/active -- "value" is NOT a key it knows. + coins = next((c for c in ui.get("currencies", []) if c.get("name") == "coins"), None) + check("userInfo has coins currency", coins is not None, repr(ui.get("currencies"))) + if coins: + check("userInfo coins.funds is number", is_num(coins.get("funds")), repr(coins)) + check("userInfo coins.finalFunds is number", is_num(coins.get("finalFunds")), repr(coins)) + check("userInfo coins uses funds not value", "value" not in coins, repr(coins)) + for k in ("won", "draw", "loss"): + check(f"userInfo.{k} is number", is_num(ui.get(k)), repr(ui.get(k))) + # REGRESSION GUARD: clubNameChangeAllowed=true is the isolated root cause of the + # 2026-08-03 create-club crash (identical field set, only this bool flipped, 4/4 + # crash vs no crash). It may be absent, but it must never be true. + check("clubNameChangeAllowed is not true", ui.get("clubNameChangeAllowed") is not True, + repr(ui.get("clubNameChangeAllowed"))) + # squadList is OPTIONAL (FUT_USERINFO ladder) -- but if present it must be an + # object with a squad array, never a bare array. + sl = ui.get("squadList") + check("userInfo.squadList absent or object", sl is None or is_obj(sl), repr(sl)) + if is_obj(sl): + check("squadList.squad is array", is_arr(sl.get("squad")), repr(sl.get("squad"))) + for e in sl.get("squad", []): + check("squadList elem is object", is_obj(e), repr(e)) + if not is_obj(e): + continue + for k in ("rating", "chemistry", "id"): + check(f"squadList.{k} is number", is_num(e.get(k)), repr(e.get(k))) + for k in ("formation", "squadName", "squadType"): + check(f"squadList.{k} is string", is_str(e.get(k)), repr(e.get(k))) + + +def test_squad_list_endpoint(): + # GET ut/%s/squad/list is the real FutSquadList URL (live-observed 2026-08-03). + # Its parser 0x180142260 recognises ONLY squad(0x2cd), so the body MUST be + # {"squad":[...]}; returning the active-squad object here yields "MY SQUADS: 0". + d = _get(G + "/squad/list") + check("squad/list is object", is_obj(d), repr(type(d))) + check("squad/list has squad array", is_arr(d.get("squad")), repr(d)[:120]) + check("squad/list is NOT the active-squad object", "players" not in d, repr(list(d))) + for e in d.get("squad", []): + for k in ("rating", "chemistry", "id"): + check(f"squad/list elem {k} is number", is_num(e.get(k)), repr(e.get(k))) + for k in ("formation", "squadName", "squadType"): + check(f"squad/list elem {k} is string", is_str(e.get(k)), repr(e.get(k))) + + +def test_massinfo_shape(): + # GetUserMassInfo (deser 0x180174630) is a FLAT object -- no "user" wrapper. + # Populated as of 2026-08-03 (see FUT_RESPONSE_REBUILD_PLAN.md S7): userInfo, + # squad, settings, userData. Every member must keep its reversed type or the + # SAX reader desyncs -> busy-loop freeze at 0x1801c7f1a. d = _get(G + "/userMassInfo") - check("userMassInfo is empty {}", d == {}, repr(d)) + check("massinfo is object", is_obj(d), repr(type(d))) + check("massinfo has no 'user' wrapper", "user" not in d, repr(list(d))) + if not d: + return # FUT_MASSINFO=empty bisect mode + for k in ("userInfo", "squad", "settings", "userData"): + if k in d: + check(f"massinfo.{k} is object", is_obj(d[k]), repr(type(d.get(k)))) + sq = d.get("squad") + if is_obj(sq): + # squad(0x2cd) -> LoadActiveSquad parser 0x18013d1f0, same schema as GET /squad + check("massinfo.squad.players is array", is_arr(sq.get("players")), repr(type(sq.get("players")))) + check("massinfo.squad.formation is string", is_str(sq.get("formation")), repr(sq.get("formation"))) + check("massinfo.squad.squadType is string", is_str(sq.get("squadType")), repr(sq.get("squadType"))) + check("massinfo.squad.custom is string", is_str(sq.get("custom")), repr(type(sq.get("custom")))) + check("massinfo.squad.actives is array", is_arr(sq.get("actives")), repr(type(sq.get("actives")))) + check("massinfo.squad.manager is array", is_arr(sq.get("manager")), repr(type(sq.get("manager")))) + check("massinfo.squad.kicktakers is array", is_arr(sq.get("kicktakers")), repr(type(sq.get("kicktakers")))) + # personaId MUST equal the logged-in persona (0x18014659c) or SquadLoad + # discards our squad and builds a throwaway one. + check("massinfo.squad.personaId == PERSONA_ID", sq.get("personaId") == PERSONA_ID, repr(sq.get("personaId"))) + if is_obj(d.get("settings")): + check("massinfo.settings.configs is array", is_arr(d["settings"].get("configs")), + repr(type(d["settings"].get("configs")))) def test_club_items(): @@ -138,7 +226,8 @@ def test_club_items(): def main(): tests = [test_credits, test_v2_store_gate, test_store_catalog, test_market_bodies, - test_auction_record_shape, test_squad_boot, test_massinfo_empty, test_club_items] + test_auction_record_shape, test_squad_boot, test_squad_list_shape, + test_squad_list_endpoint, test_massinfo_shape, test_club_items] try: _get(G + "/user/credits") except Exception as e: diff --git a/fifa17-recon/tools/uidiff.py b/fifa17-recon/tools/uidiff.py new file mode 100644 index 0000000..ebf5be6 --- /dev/null +++ b/fifa17-recon/tools/uidiff.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Diff the atoms the userInfo deserializer (0x18013ec10) can consume against the +keys utas_server.user_info() actually sends, and flag STRING-typed fields we omit +(a NULL string pointer is exactly the crash class seen in FUN_180084f90).""" +import re, sys, os + +sys.path.insert(0, "/home/alex/Documents/OpenFUT/fifa17-recon/tools") +os.environ.setdefault("FUT_PROFILE", "/tmp/claude-1000/-home-alex-Documents-OpenFUT/" + "4cf26d25-8cee-4db3-ad9e-9fd1838020eb/scratchpad/diffprof.json") + +atoms = {} +for line in open("/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv"): + p = line.rstrip("\n").split("\t") + if len(p) >= 3: + atoms[int(p[0])] = p[2] + +src = open("/tmp/ghidra_fut/userinfo.txt").read() + +# Every comparison against the key-id variable, plus switch cases. +ids = set() +for m in re.finditer(r"iVar\d+ == (0x[0-9a-f]+|\d+)", src): + ids.add(int(m.group(1), 0)) +for m in re.finditer(r"case (0x[0-9a-f]+|\d+):", src): + ids.add(int(m.group(1), 0)) +for m in re.finditer(r"caseD_([0-9a-f]+)", src): + ids.add(int(m.group(1), 16)) + +# getter used per id -> type. 0x1801c7aa0 = STRING, 0x1801c79d0 = int, +# 0x1801c7a40/0x1801c7b40 = bool/other scalars. +GET = {"1801c7aa0": "str", "1801c79d0": "int"} +typed = {} +for m in re.finditer(r"(?:iVar\d+ == |case )(0x[0-9a-f]+|\d+)\)?:?\s*\{?\s*\n((?:.*\n){0,6})", src): + try: + i = int(m.group(1), 0) + except ValueError: + continue + blk = m.group(2) + for g, t in GET.items(): + if g in blk: + typed[i] = t + break + +from utas_server import user_info # noqa: E402 +sent = set(user_info().keys()) + +known = {i: atoms.get(i, "?") for i in sorted(ids) if i in atoms} +print("userInfo deser consumes %d named atoms; user_info() sends %d keys\n" + % (len(known), len(sent))) + +missing = [(i, n, typed.get(i, "")) for i, n in known.items() if n not in sent] +extra = sorted(sent - set(known.values())) + +print("=== parsed by the client but NOT sent by us (%d) ===" % len(missing)) +for i, n, t in sorted(missing, key=lambda x: (x[2] != "str", x[1])): + print(" %-32s atom %#-6x %s" % (n, i, ("<-- STRING" if t == "str" else t))) + +print("\n=== we send but the deser does not name (harmless SKIPs) ===") +print(" " + ", ".join(extra)) diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index ba62326..0972ce1 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -14,7 +14,7 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0): import datetime, json, os, re, sys, http.server sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from fut_seed import CLUB, SQUAD, USER_LIST # forged starter squad (clean-room) +from fut_seed import CLUB, SQUAD, USER_LIST, squad_summary # forged starter squad (clean-room) from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item # profile + packs ADDR = ("127.0.0.1", 8099) @@ -44,52 +44,190 @@ def auth_body(): return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()} +def current_squad(): + """The squad the client should see: the persisted one (item refs re-embedded + from the club) or the seed ladder squad on first run. + + personaId is FORCED to PERSONA_ID: SquadLoad compares it against the logged-in + persona at 0x18014659c and, on mismatch, takes the vtable[0x4f0] branch and + builds a throwaway squad instead of adopting ours. + """ + saved = STORE.active_squad() + # Overlay the saved squad on the seed envelope: FIFA's PUT body carries only + # what it changed, so this keeps a valid custom(0xc6) 33-int string, kicktakers + # and squadName present on reload instead of silently dropping them. + sq = dict(SQUAD) + if saved: + sq.update(STORE.reconstruct_squad(saved)) + sq["personaId"] = PERSONA_ID + sq.setdefault("id", 0) + return sq + + +def squad_list_body(squad=None): + """FutSquadListServerResponse body (deser 0x180172140 -> value parser + 0x180142260 -> element 0x180141fc0). ONE key, "squad"(0x2cd), holding an ARRAY + of squad summaries. This is the same parser userInfo.squadList goes through.""" + return {"squad": [squad_summary(squad or current_squad())]} + + +# Exactly TWO branches of the userInfo deser 0x18013ec10 have side effects OUTSIDE +# the userInfo record -- they reach into the global model singleton FUN_18011a830: +# squadList(0x2d4) -> vtbl[0x480] -> +0x30, fills the squad-ROSTER model +# unopenedPacks(0x35e) -> vtbl[0x4e0](preOrderPacks + recoveredPacks) +# Every other member just fills a scalar/string slot in the record. The 2026-08-03 +# crash is a NULL member inside a FIFA17.exe UI model, and userInfo had never +# actually reached the client before (massinfo was {} and /user is not called at +# boot) -- so these two newly-exercised branches are the prime suspects, and both +# are optional for coins/record. Ladder, cheapest-first: +# min = ONLY what coins/record need | safe = + the rest of the scalars +# roster (default) = +squadList (MY SQUADS) | packs = +unopenedPacks | full = both +# `roster` is live-confirmed working (hub + coins + record + MY SQUADS: 1). Only +# `unopenedPacks` remains untested, hence `full` is not the default. +# +# ROOT CAUSE, ISOLATED LIVE 2026-08-03 -- clubNameChangeAllowed(0x8f) must be FALSE. +# Two runs sent the IDENTICAL field set and differed only in that one bool: +# true -> client shows the FUT club-name prompt, then dies confirming it +# (ACCESS_VIOLATION reading 0x0 at FIFA17.exe+0x71b8651, 4/4 runs) +# false -> no prompt at all, hub loads, coins + record render +# Sending true advertises "a club-name change is available", pushing the client into +# a naming flow whose UI model we never populate. Neither side-effecting member +# (squadList / unopenedPacks) was involved -- both were already omitted in the +# crashing run. Keep this false unless the rename flow is actually implemented. +_UI = os.environ.get("FUT_USERINFO", "roster") + + def user_info(): # Deserializer 0x18013EC10; every member optional (unknown key ids are # skipped via 0x180135FF0), so {} also parses. - return { + # Now that massinfo is populated, this record actually reaches the hub -- read + # the live profile instead of the old hardcoded placeholders. + p = STORE.profile() + rec = p.get("record", {}) + info = { "personaId": PERSONA_ID, - "clubName": "OpenFUT", "clubAbbr": "OFC", "established": "2026", - "clubNameChangeAllowed": True, - "currencies": [{"name": "coins", "value": 15000}, - {"name": "points", "value": 0}], - "won": 0, "draw": 0, "loss": 0, - "divisionOffline": 10, "divisionOnline": 10, - "purchased": False, - "feature": {"trade": True}, - "reliability": {"reliability": 100, "matchUnfinishedTime": 0}, - "unopenedPacks": {"preOrderPacks": 0, "recoveredPacks": 0}, - "bidTokens": {"count": 0, "updateTime": 0}, - "trophies": 0, "sessionCoinsBankBalance": 0, - "actives": [], "squadList": [], + "clubName": p.get("clubName", "OpenFUT"), "clubAbbr": p.get("clubAbbr", "OFC"), + "established": p.get("established", "2026"), + # accountCreatedPlatformName(0x6), stored at userInfo+0x42: the only string + # 0x18013ec10 consumes that we used to omit (clubAbbr 0x8d / clubName 0x8e / + # established 0x110 were already covered). Sending it is correct, but note + # it was NOT the cause of the create-club crash -- that was still identical + # with this field present. Value matches auth's nucleusPersonaPlatform. + "accountCreatedPlatformName": "pc", + # The userInfo currency-element parser FUN_180138bd0 reads name(0x1d0), + # funds(0x134), finalFunds(0x124), active(0xa) -- there is NO "value" key, + # so the old {"name","value"} pairs were parsed as 0 and the hub showed 0 + # coins. The caller then matches name against "coins"/"points" literally. + "currencies": [ + {"name": "coins", "funds": STORE.coins(), + "finalFunds": STORE.coins(), "active": True}, + {"name": "points", "funds": p.get("points", 0), + "finalFunds": p.get("points", 0), "active": True}, + ], + # record: won(0x387) draw(0xe6) loss(0x1a6), all scalar slots in the record + "won": rec.get("won", 0), "draw": rec.get("draw", 0), "loss": rec.get("loss", 0), } + # Everything below is optional for coins/record. `min` stops here. + if _UI != "min": + info.update({ + # clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62. MUST stay False: + # True is the isolated root cause of the create-club crash (see _UI). + "clubNameChangeAllowed": False, + "divisionOffline": 10, "divisionOnline": 10, + "purchased": False, # 0x262 -> bool at +0x68 + "feature": {"trade": True}, + "reliability": {"reliability": 100, "matchUnfinishedTime": 0}, + "bidTokens": {"count": 0, "updateTime": 0}, + "trophies": 0, "sessionCoinsBankBalance": 0, + # actives(0xb): array of <=5 item refs (item parser 0x18013fe00). The seed + # ladder squad has none yet -> empty array (arrays never desync). + "actives": (current_squad().get("actives") or [])[:5], + }) + # ---- the two side-effecting members, off by default (see _UI above) -------- + if _UI in ("packs", "full"): + # unopenedPacks(0x35e): after parsing preOrderPacks(0x24b)+recoveredPacks + # (0x27b) the deser calls singleton->vtbl[0x4e0](preOrder + recovered). + info["unopenedPacks"] = {"preOrderPacks": 0, "recoveredPacks": 0} + if _UI in ("roster", "full"): + # squadList(0x2d4) -> FUN_180142260 on singleton->vtbl[0x480]+0x30, i.e. it + # fills the global squad-ROSTER model ("MY SQUADS" on the Squads screen). + # Must be an OBJECT {"squad":[...]}; a bare [] is SKIP-safe but leaves the + # roster empty. NOT required for the ACTIVE squad -- that comes from the + # massinfo `squad` member, which is already proven working. + info["squadList"] = squad_list_body() + return info # GET ut/game//user parser 0x180146970 does Parse + TWO NextToken calls # before deserializing -> the object MUST be wrapped in one member. The member # NAME is never compared, but the nesting level is required. -USER_GET = {"userInfo": user_info()} +def user_get(): + return {"userInfo": user_info()} + + # POST ut/game//user (CreateUser, 0x18014CC60) recognises exactly: # bonusPacks(0x5d) login(0x1a5) squad(0x2cd) starterPack(0x2e5) userData(0x36d). -USER_POST = {"login": True, "userData": user_info(), - "squad": {}, "starterPack": {}, "bonusPacks": []} +def user_post(): + # squad(0x2cd) goes to the SAME LoadActiveSquad parser as everywhere else, so + # serve the real schema-correct squad rather than {} -- a create-club response + # carrying an empty squad leaves the client with a 0-slot squad model, which is + # precisely the state that makes AddPlayerToSquad no-op (see REBUILD_PLAN S9c). + return {"login": True, "userData": user_info(), + "squad": current_squad(), "starterPack": {}, "bonusPacks": []} + + # GET ut/game//settings (0x18013C6D0) recognises ONE key: configs (0xa2). SETTINGS = {"configs": []} -# GET ut/game//userMassInfo (GetUserMassInfo, deser 0x180174630). -# ANY content here (userInfo AND/OR squad) DESYNCS CardsDLL's massinfo parser -> -# infinite tokenizer spin (busy-loop freeze at 0x1801c7f1a). Proven: {} reaches -# the hub; {userInfo,...} and {...,squad,...} both freeze. The userInfo sub-deser -# 0x18013ec10 mis-consumes some field in user_info(). So keep userMassInfo EMPTY -# (hub-reaching) and deliver club/squad via their OWN endpoints (/user, /club, -# /squad) whose parsers we know work. Select via env FUT_MASSINFO (empty|userinfo|full). -_MI = os.environ.get("FUT_MASSINFO", "empty") -if _MI == "full": - MASSINFO = {"userInfo": user_info(), "squad": SQUAD, - "settings": {"configs": []}, "userData": {}} -elif _MI == "userinfo": - MASSINFO = {"userInfo": user_info(), "settings": {"configs": []}, "userData": {}} -else: - MASSINFO = {} # proven hub-reaching + +# GET ut/game//userMassInfo (GetUserMassInfo, deser FUN_180174630). +# +# CORRECTED 2026-08-03 (supersedes the old "MUST BE {}" note). Full decompile of +# 0x180174630 (/tmp/ghidra_fut/massinfo.txt): the body is a FLAT object whose +# top-level keys dispatch to +# userInfo(0x370) -> 0x18013ec10 squad(0x2cd) -> 0x18013d1f0 +# settings(0x2bf) -> 0x18013c6d0 userData(0x36d) -> 0x180142470 +# clubUser(0x91), errors(0x10c), loanPlayers(0x19a), loanPlayerClientData(0x199), +# pileSizeClientData(0x227); everything else SKIP'd via 0x180135ff0. +# There is NO "user" wrapper (the old note was wrong -- "user" only appears nested +# inside clubUser). The prologue (2 x NextToken before the key loop) is identical +# to 0x18014cc60, the proven-flat CreateUser parser. +# +# The historical freeze is now attributed to the SQUAD member: it was fed a squad +# object built before the exact schema was known (0x18013d1f0 was only reversed on +# 2026-08-03). Every field of user_info() type-checks against 0x18013ec10, and the +# one structurally wrong field -- squadList as a bare array -- is SKIP-safe rather +# than spin-inducing. So massinfo is now served POPULATED (like EA does), which is +# what delivers the squad roster to the client at boot. +# +# LIVE STATUS 2026-08-03: `full` PARSES fine (no freeze -- the client processed it +# and issued the next request), but the client then enters FUT club-creation and +# dies confirming the club name: reproducible ACCESS_VIOLATION reading 0x0 at +# FIFA17.exe+0x71b8651 (3/3 runs). That crash site is +# mov rax,[r9+0x28] ; mov r9,[rax] <- r9->field_28 is NULL, unguarded +# in FIFA17.exe's own UI code -- no CardsDLL frame on the stack and NO request is +# sent when it happens, so it is not a response being rejected. Bisecting which +# massinfo member triggers it, one relaunch per value: +# BISECT RESULT: `squad` alone reaches the hub AND made the client issue +# PUT /squad/0 (blocker solved) -- so the crash is in the `userInfo` member. +# `full` is restored now that userInfo omits its two side-effecting members by +# default (see _UI): that is what puts coins/record back on the hub. +# INSTANT FALLBACK if the crash returns: FUT_MASSINFO=squad (known-good). +_MI = os.environ.get("FUT_MASSINFO", "full") + + +def massinfo(): + if _MI == "empty": + return {} # old known-hub-reaching body + if _MI == "squad": + return {"squad": current_squad()} + if _MI == "userinfo": + return {"userInfo": user_info()} + if _MI == "settings": + return {"settings": SETTINGS} + return {"userInfo": user_info(), # squadList -> roster singleton + "squad": current_squad(), # personaId == PERSONA_ID + "settings": SETTINGS, + "userData": {}} # ---- FUT item-definition serving (wf_e41070d8) ------------------------------- # The card view-model 0x1800d7920 renders identity/rating/face from a RESOLVED @@ -145,12 +283,37 @@ def defs_route(h): return 200, {"itemData": [item_def(i) for i in ids]} +def item_route(h): + # PUT ut/game/fifa17/item = FutMoveCard (move item to a pile, e.g. the reveal + # screen's "keep/assign" -> {"itemData":[{"id":..,"pile":"club","swap":0, + # "tradeId":0}]}). Response echoes the FULL updated card-item(s) + chemistry + # bool (ENDPOINT_MAP item rows 6/7/10/11; itemData via deser 0x18013fe00). + # Returning [] here makes FIFA think the move failed -> kicks to main menu. + # GET stays defs_route (ViewCards / ConsumablesSearch / loan lists). + if h.command == "PUT": + try: + body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {} + except Exception: + body = {} + req = body.get("itemData") + if isinstance(req, list): + moved = STORE.move_items(req) + if moved: + log(" ITEM: moved %d item(s) to pile(s)" % len(moved)) + # Same shape as the proven-parseable GET itemData (no extra keys): + # adding an unexpected key (e.g. chemistry) desynced the parser + # and made FIFA report "failed to send to club" then log out. + return 200, {"itemData": moved} + return 200, {"itemData": []} + return defs_route(h) + + G = r"/ut/game/[^/]+" ROUTES = [ # ---- FUT item-definition endpoints (must precede generic /item, /user) ---- (re.compile(G + r"/item/resource"), lambda m, h: defs_route(h)), (re.compile(G + r"/defid"), lambda m, h: defs_route(h)), - (re.compile(G + r"/item(\?|$)"), lambda m, h: defs_route(h)), + (re.compile(G + r"/item(\?|$)"), lambda m, h: item_route(h)), # ---- store / packs (match regardless of /ut/game vs /ut/v2/game prefix) ---- (re.compile(r"/store/purchasegroup"), lambda m, h: store_catalog(h)), (re.compile(r"/store/transaction"), lambda m, h: store_buy(h)), @@ -170,18 +333,29 @@ ROUTES = [ (re.compile(G + r"/phishing/validate"), lambda m, h: (200, {"token": "OPENFUT-TRUST-0000000000000000"})), (re.compile(G + r"/phishing/question"), lambda m, h: (200, {"question": 0, "answer": "", "attempts": 5})), (re.compile(G + r"/user/credits"), lambda m, h: credits_route(h)), - # ---- club/squad routes reverted to known-good {} stubs (2026-08-01) ---- - # The forged squad in MASSINFO/SQUAD/CLUB HANGS CardsDLL's deserializer (hard - # freeze at boot). Re-enable only after the exact shape is reversed. The forged - # data still lives in fut_seed.py + MASSINFO/squad_route below (unrouted). + # ---- club/squad routes (2026-08-03: squad schema 0x18013d1f0 now reversed) ---- + # /user/list + /user/accountinfo stay {} (nothing in them is load-bearing yet). + # /user, /squad and /userMassInfo serve real data again -- the squad object + # matches the verified schema, so the 2026-08-01 revert-to-{} no longer applies. (re.compile(G + r"/user/list"), lambda m, h: (200, {})), (re.compile(G + r"/user/accountinfo"), lambda m, h: (200, {})), (re.compile(G + r"/user$|" + G + r"/user\?"), lambda m, h: user_route(h)), + # LIVE GROUND TRUTH 2026-08-03: FutSquadList has its OWN URL, `ut/%s/squad/list` + # -- the request-table strings only showed ut/%s/squad, so the static conclusion + # "there is NO separate squad-list URL" (REBUILD_PLAN S1b) was WRONG. This must + # precede the generic /squad route, which was swallowing it and returning the + # full active-squad object; the list parser 0x180142260 recognises ONLY + # squad(0x2cd) and skipped every one of those keys -> "MY SQUADS: 0". + (re.compile(G + r"/squad/list"), lambda m, h: (200, squad_list_body())), (re.compile(G + r"/squad"), lambda m, h: squad_route(h)), (re.compile(G + r"/match/keepalive"), lambda m, h: (204, None)), + # PUT ut/%s/match/reset = FutResetMatch. Seen live at boot (2026-08-03) as an + # UNMAPPED catch-all; it is a Tier-B ack (shared no-op deser), so {} is correct + # -- routed explicitly so it stops showing up as an unmapped hit in the log. + (re.compile(G + r"/match/reset"), lambda m, h: (200, {})), (re.compile(G + r"/hub"), lambda m, h: (200, {})), - # STEP 1 (wf_0bc80ab3): zero-resolve squad in MASSINFO; club stays {}. - (re.compile(G + r"/userMassInfo"), lambda m, h: (200, MASSINFO)), + # Populated massinfo (see massinfo() above): userInfo + squad + settings. + (re.compile(G + r"/userMassInfo"), lambda m, h: (200, massinfo())), (re.compile(G + r"/season"), lambda m, h: (200, {})), # ---- transfer market / auction house (empty-but-valid; ENDPOINT_MAP market §) # tradePile MUST precede /trade ("/tradePile" contains the "/trade" prefix). @@ -201,70 +375,84 @@ ROUTES = [ def user_route(h): if h.command == "POST": - return 200, USER_POST + return 200, user_post() if NEW_USER: # accepted, non-fatal: 0x18007AF20 treats 404 as the new-user branch return 404, {} - return 200, USER_GET + return 200, user_get() + + +# ut/%s/squad carries the WHOLE squad family (request table 0x18021dfc0): GET = +# LoadActiveSquad AND GetSquadList/GetSquads, PUT = SaveCurrentSquad/SaveSquad. +# Load-vs-List is not resolvable statically (one binding row each, 0x18027cb70 / +# 0x18027be28) -- but the two parsers are mutually SKIP-tolerant: the squad-object +# parser 0x18013d1f0 does not recognise "squad"(0x2cd), and the list parser +# 0x180142260 recognises ONLY "squad". So a MERGED body satisfies both. Kept behind +# FUT_SQUAD_LIST=merged for now (GET /squad is boot-critical; default keeps the +# proven plain-squad body so a live test isolates one change at a time). +_SQUAD_LIST_MODE = os.environ.get("FUT_SQUAD_LIST", "off") def squad_route(h): - # GET = LoadActiveSquad, PUT = updateActiveSquad. Persist the squad the user - # builds so it survives relaunches. Never return {} (empty body resets the 23 - # slots, 0x18013d1f0). + # PUT = SaveCurrentSquad. FutSquadSaveServerResponse deser 0x180171a60 parses + # exactly ONE key, id(0x15c) -> reply {"id": }, NOT an echo of the + # squad (the echo's extra keys were merely SKIP'd, but the id was only present + # by luck of the client's own body). if h.command == "PUT": try: sq = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else None except Exception: sq = None if isinstance(sq, dict) and sq.get("players"): + sq.setdefault("id", 0) STORE.save_squad(sq) - return 200, sq - saved = STORE.active_squad() - return 200, (STORE.reconstruct_squad(saved) if saved else SQUAD) + log(" SQUAD: saved squad id=%s (%d slots)" % (sq["id"], len(sq["players"]))) + return 200, {"id": sq["id"]} + # Malformed/empty PUT: still answer with the id we hold, never {} -- the + # save handler needs the key and an empty body reads as squadId 0. + return 200, {"id": current_squad().get("id", 0)} + + # GET = LoadActiveSquad: the full squad object, directly (no wrapper). Never + # return {} -- an empty body resets the 23 slots (0x18013d1f0). + sq = current_squad() + if _SQUAD_LIST_MODE == "merged": + sq = dict(sq, **squad_list_body(sq)) + return 200, sq # ---- STORE / PACKS (first-cut; iterate against the log) --------------------- def store_catalog(h): - # GET store/purchasegroup/all. Root key MUST be "purchase" (atom 608, array); - # pack identity is "id" (int16, NOT packId); price is a "currencies" array of - # {name,funds,finalFunds}; display name is "description". (wf a245577b — - # {purchaseGroups:...} + packId/price were all unknown atoms => empty => "not - # available".) quantity:0 => unlimited. - # Parse format is verified correct ("purchase" array, per-pack 0x18013af30). - # The store still rejected minimal packs -> a pack must be COMPLETE to count as - # valid: content info + BOTH a coins price (currencies) and a FIFA-Points price - # (extPrice {finalPrice,originalPrice} -> {"mtx":N}). packs = [] + idx = 1 for p in PACK_CATALOG: gold = p["gold"] mtx = max(1, p["price"] // 100) packs.append({ - # assetId (atom 0x23) is the REAL pack identity the deser 0x18013af30 - # reads (ENDPOINT_MAP store §). id/packType/quantity/saleType/isPremium - # are all unknown atoms -> SKIP (harmless no-ops, kept for readability). "assetId": p["id"], "id": p["id"], "packType": "GOLD" if gold else "BRONZE", "description": p["name"], + "state": "active", + "saleType": "promo", + "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": False, - "saleType": "PERMANENT", - "sortPriority": p["id"] - 100, + "sortPriority": idx, "currencies": [{"name": "coins", "funds": p["price"], "finalFunds": p["price"]}], - # extPrice inner keys are amount(0x1b)/currency(0xc4), NOT mtx (skipped). - "extPrice": {"finalPrice": {"amount": mtx, "currency": "fifapoints"}, - "originalPrice": {"amount": mtx, "currency": "fifapoints"}}, + "extPrice": {"finalPrice": {"amount": mtx, "currency": "mtx"}, + "originalPrice": {"amount": mtx, "currency": "mtx"}}, "packContentInfo": { "bronzeQuantity": 0 if gold else p["count"], "silverQuantity": 0, "goldQuantity": p["count"] if gold else 0, - "rareQuantity": 1 if gold else 0, + "rareQuantity": p["count"] if gold else 0, "itemQuantity": p["count"], + "unopened": False, }, }) + idx += 1 return 200, {"purchase": packs, "timestamp": 1596326400} @@ -297,6 +485,34 @@ def store_buy(h): def purchased_items(h): + # POST = FutPurchaseItemsServerResponse: the BUY itself. Live ground truth: FIFA + # sends {"packId":1,"useCredits":1,"usePreOrder":0,"currency":"COINS"} then + # immediately polls GET /purchased/items for the awarded cards. Open the pack + # here (debit coins, award items). Response mirrors CardsDLL's serializer + # (0x180126900): packId, firstPartyStoreId, groupName, productId, + # purchasePackType -- unknown keys are skipped, so extra fields are harmless. + # GET = FutGetPurchasedItemsServerResponse: the awarded items from the last buy. + if h.command == "POST": + try: + body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {} + except Exception: + body = {} + pid = body.get("packId") + pack = pack_by_id(pid) if isinstance(pid, int) else None + if pack is None: + return 200, {"itemData": STORE.last_pack()} + items = STORE.open_pack(pack["price"], pack["count"], pack["gold"]) + if items is None: + return 461, {"reason": "insufficient_coins", "credits": STORE.coins()} + log(" STORE: POST /purchased opened pack %s -> %d items, coins=%d" + % (pack["name"], len(items), STORE.coins())) + return 200, { + "packId": pid, + "firstPartyStoreId": 0, + "groupName": "fifa17", + "productId": str(pack["id"]), + "purchasePackType": "GOLD" if pack["gold"] else "BRONZE", + } return 200, {"itemData": STORE.last_pack()}