fifa17-recon: FUT squad blocker solved + userInfo delivered

The client now issues PUT /squad and the hub renders coins, record and the
squad roster. Three separate root causes, all verified live.

Squad blocker (the long-standing "client never sends PUT /squad"):
  AddPlayerToSquad, GetSquads and SelectSquadById issue ZERO network requests
  (pure local model reads/mutations, FutSquadServiceImpl vtable 0x180233ff0);
  only SaveCurrentSquad writes, and it is unguarded. The client simply needed a
  populated ACTIVE squad model, which arrives via the massinfo `squad` member.
  No response of ours was ever being rejected.

userMassInfo is NOT required to be {}:
  0x180174630 is a FLAT {userInfo, squad, settings, userData} body -- the old
  "wrapper key is user" note was wrong, and the historical freeze was the
  malformed squad member, not the envelope.

clubNameChangeAllowed must be false:
  sending true advertises a club-rename flow whose UI model is never populated;
  the client shows a naming prompt and dies confirming it (ACCESS_VIOLATION
  reading 0x0 at FIFA17.exe+0x71b8651, 4/4 runs, no CardsDLL frame and no request
  in flight). Isolated by a single-variable run; guarded by a contract check.

Endpoint/schema corrections found in live traffic, invisible to static analysis:
  * GET ut/%s/squad/list is a real endpoint and must return {"squad":[...]},
    not the active-squad object (the /list suffix is appended by the caller, so
    it never appeared in the request table)
  * PUT lands on ut/%s/squad/<id>, not a bare ut/%s/squad
  * userInfo currencies are read as name/funds/finalFunds/active -- there is no
    "value" key, so coins always rendered 0
  * squad-list elements take STRING formation/squadType, not ints
  * the CardsDLL script-API thunk<->name table was off by one (AddPlayerToSquad
    is 0x18004aa70; 0x18004aff0 is GetPotentialChemistry_Club)

FUT_MASSINFO / FUT_USERINFO ladders keep every step of the bisect reproducible.
Contract suite 311 -> 358 checks. Tooling added: PyGhidra harness (Ghidra's
Java/OSGi script path is broken on this box), minidump reader, live code grabber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
funman300
2026-08-03 20:47:16 -07:00
parent 6270c37208
commit 59934b4ef0
16 changed files with 1811 additions and 94 deletions
+7 -5
View File
@@ -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)
+22 -18
View File
@@ -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`
@@ -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": <squadId>}` — ONLY key `id`(0x15c) parsed | **Fix**: stop echoing whole squad |
| `FutSquadListServerResponse` | `0x180172140` (+ `0x180142260`, elem `0x180141fc0`) | `{"squad": [ {summary} ]}` | **Missing** — no list endpoint yet |
| `FutSquadRenameServerResponse` | `0x1801642c0` (shared no-op) | `{}` | **Ack only** |
| `FutSquadDeleteServerResponse` | `0x1801642c0` (shared no-op) | `{}` | **Ack only** |
| `FutGrantPrizeChampionUserServerResponse` | `0x180148ec0` | `{"awardedPrizes": [...]}` | Nice-to-have |
| `FutStickerBookStats2ServerResponse` | `0x180130150` | `{"stat": [...]}` | Nice-to-have |
### Full squad object schema (verified via `0x18013d1f0`, 0-risk)
```
{
"personaId": <int>, // 0x21b — must equal logged-in persona
"squadName": <str>, // 0x2d3
"squadType": <str>, // 0x2d6 — string, converted; value 2 flags items
"starRating": <int>, // 0x2e2
"formation": <str>, // 0x12b(299) — string, converted ("f442")
"id": <int>, // 0x15c
"chemistry": <int>, // 0x81
"captain": <int>, // 0x69
"changed": <int>, // 0x7e
"custom": <str>, // 0xc6 — STRING containing a JSON array of 33 ints
// (re-parsed inline; must be a complete array)
"actives": [ <item> ], // 0xb — item refs, parser 0x18013fe00
"manager": [ <item> ], // 0x1a8 — item refs
"players": [ {"index":<int>, "itemData":<item>, "kitNumber":<int>} ], // 0x238
"kicktakers": [ {"id":<int>, "index":<int>} ] // 0x178
}
```
`fut_seed._base_squad()` already matches this shape (incl. complete 33-int `custom` string,
string `formation`/`squadType`). **Key finding: the squad response shape is NOT the blocker**
the client simply never issues `PUT /squad` on placement attempts (no such request ever hits
the server). The rebuild therefore focuses on making every response the client *does* send
exact, then re-testing add-to-squad with a fresh launch (no stale squad cache).
### Squad list element schema (via `0x180141fc0`) — types corrected 2026-08-03
```
{"rating": <int>, // 0x274 — scalar getter 0x1801c79d0
"chemistry": <int>, // 0x81 — scalar getter 0x1801c79d0
"formation": <str>, // 0x12b — STRING getter 0x1801c7aa0 -> enum conv 0x180166590
"id": <int>, // 0x15c
"squadName": <str>, // 0x2d3
"squadType": <str>} // 0x2d6 — STRING getter 0x1801c7aa0 -> enum conv 0x1801668e0
```
**Correction:** `formation` and `squadType` are **strings**, not ints — `0x180141fc0` reads
them with the string getter `0x1801c7aa0` and runs the same enum converters as the
full-squad parser `0x18013d1f0` (lines 303-305 / 432-434 of `squadparser.txt`). Feeding
ints to a string getter is exactly the type-mismatch that freezes at `0x1801c7f1a`.
### StickerBookStats2 schema (via `0x180130150`)
```
{"stat": [ {"contextId": <int>, "contextValue": <int>, "type": <int>, "typeValue": <int>} ]}
```
### GrantPrizeChampionUser schema (via `0x180148ec0`)
```
{"awardedPrizes": [ {"awards": [<prize objects>], "eventId": <int>, "rank": <int>,
"money": <int>, "progress": <int>} ]}
```
## 1b. Client request-side findings (new — session 2026-08-03)
Request path templates (dumped from the DLL's request table at `0x18021dfc0`, strings
`0x18021e308+`): the squad family uses **only two URLs**:
| Template | Name | Service methods |
|---|---|---|
| `ut/%s/squad` | `SQUAD` | LoadActiveSquad (GET), SaveCurrentSquad (PUT), SaveSquad (PUT), GetSquadList, GetSquads |
| `ut/delete/%s/squad` | `DELETE_SQUAD` | DeleteSquad (DELETE) |
| `ut/%s/squad/mode` | `SQUADMODE` | (draft/squad mode) |
~~**There is NO separate squad-list URL.**~~ **WRONG — corrected by live traffic
2026-08-03: `GET ut/%s/squad/list` exists** and is what the Squads screen calls. The
request-table strings only contained `ut/%s/squad`, so the static reading missed it
(the `/list` suffix is appended by the caller, not stored as a template). See §10g.
> ⚠ The service-method names originally recorded here were **off by one** and are
> superseded by §9a (`AddPlayerToSquad`=`FUN_18004aa70`, `SaveCurrentSquad`=`FUN_18004b5a0`,
> `FUN_18004aff0` is actually `GetPotentialChemistry_Club`, and `0x18003b9d0` is a generic
> script-object→handle converter, not a squad serializer).
Response-factory bindings live in a file-offset table (`FutSquadRename` row @
`0x18027bb48`, `FutSquadDelete` @ `0x18027bb90`, `FutSquadLoad` @ `0x18027cb70`,
`FutSquadSave` @ `0x18027bcf8`, `FutSquadList` @ `0x18027be28`) — one distinct request row
per response, i.e. the client DOES distinguish Load vs List server-side somehow (query param
or request-context). Not resolvable statically → capture the live request on the Squad screen.
**userInfo.squadList** (atom **`0x2d4`**, in `/user`, deser `0x18013ec10`) is parsed by the
**same** `FUN_180142260` as the FutSquadList response → it expects `"squadList": {"squad": [...]}`.
Notably that branch does **not** write into the userInfo record at all — it fetches a
singleton (`FUN_18011a830``vtable[0x480]``+0x30`) and fills **the global squad-roster
model** directly. That is the model the Squad Selector reads, so this key is the roster.
The current `"squadList": []` (bare array) is SKIP-safe (no desync) but leaves the client's
squad-roster model EMPTY. Same for `actives` (array of ≤5 item refs). **Hypothesis: with an
empty roster, "Add to squad" has no target squad, so the client never fires PUT /squad.**
## 2. Inventory: all 102 responses, grouped by what to serve
Source: `/tmp/fut/all_fut_structs.txt`. Grouping reflects how far the client can get and
what the offline loop actually touches.
### Tier A — already served correctly (leave as-is)
`FutGetUserInfoServerResponse`(/user), `FutGetUserMassInfoServerResponse`(/userMassInfo,
empty-safe), `FutUserCreditsServerResponse`(/user/credits), `FutGetPurchasedItemsServerResponse`
(/club), `FutViewCardsServerResponse`(club items), `FutGetTradePileServerResponse`(/tradePile),
`FutGetHubDataServerResponse`(/hub), `FutGetSettingsServerResponse`(/settings),
`FutKeepAliveServerResponse`(204), `FutCreateUserServerResponse`(/user POST),
`FutGetClubInfoServerResponse`, `FutGetClubUsersServerResponse`, `FutGetPhishingQuestionServerResponse`,
`FutSetPhishingAnswerServerResponse`, `FutValidatePhishingAnswerServerResponse`,
`FutGetTrustedConsoleListServerResponse`, `FutGetUserActionServerResponse`,
`FutUpdateUserActionServerResponse`, `FutGetActiveTournamentsServerResponse`,
`FutSeasonListServerResponse`, `FutUpdateCreditsServerResponse`, `FutLiveMessageUpdateServerResponse`,
`FutLogoutServerResponse`, `FutResetUserServerResponse`.
### Tier B — ack-only, `{}` is safe (shared no-op or empty-tolerant parsers)
`FutChangeClubNameServerResponse`, `FutActivateCardServerResponse`, `FutApplyCardServerResponse`,
`FutApplyCardByResServerResponse`, `FutSignLoanPlayerServerResponse`,
`FutSquadRenameServerResponse`, `FutSquadDeleteServerResponse`, `FutDiscardCardServerResponse`,
`FutDiscardCardByResServerResponse`, `FutSetFavFeatureServerResponse`,
`FutCreateMatchServerResponse`, `FutDestroyMatchServerResponse`, `FutResetMatchServerResponse`,
`FutMatchReadyServerResponse`, `FutPlayGameServerResponse`, `FutGetCaptchaServerResponse`,
`FutExchangeCaptchaServerResponse`, `FutValidateCaptchaServerResponse`,
`FutGetSuggestedPricingServerResponse`, `FutGetUserActionServerResponse`,
`FutUpdateFriendlySeasonServerResponse`, `FutGetDraftStatsServerResponse`,
`FutGetStoryModeRewardServerResponse`, `FutUpdateSeasonServerResponse`,
`FutUpdateTournamentServerResponse`, `FutTournamentQuitServerResponse`,
`FutSeasonQuitServerResponse`.
### Tier C — core-loop responses that MUST be exact (the rebuild targets)
| Struct | Endpoint | Required body |
|---|---|---|
| `FutSquadLoadServerResponse` | GET /squad | full squad object (verified OK) |
| `FutSquadSaveServerResponse` | PUT /squad | `{"id": <squadId>}`**change from echo** |
| `FutSquadListServerResponse` | GET /squad (same URL as load) | `{"squad": [ {summary} ]}` — see §1b, resolve list-vs-load empirically |
| `FutSquadRenameServerResponse` | PUT /squad/name | `{}` |
| `FutSquadDeleteServerResponse` | DELETE /squad | `{}` |
| `FutPurchaseItemsServerResponse` | buy pack | existing (works) |
| `FutMoveCardServerResponse` | PUT /item | existing (works) |
| `FutISSearchServerResponse` | GET /transfermarket | existing (works) |
| `FutISStartServerResponse` / `FutISOfferTradeServerResponse` / `FutISViewTradeServerResponse` / `FutISWatchTradeServerResponse` / `FutISWatchListServerResponse` / `FutISRemoveTradeServerResponse` / `FutISRemoveWatchServerResponse` / `FutRelistAllServerResponse` | market/trade/auction | market stubs OK (`tradePile`/`watchList` already routed) |
### Tier D — not needed for the offline loop (serve `{}`, revisit later)
`FutGetDraftChoicesServerResponse`, `FutGetDraftAwardServerResponse`,
`FutGetDraftCurrentStateServerResponse`, `FutPickDraftChoiceServerResponse`,
`FutPickDraftAutoChoiceServerResponse`, `FutPurchaseDraftModeServerResponse`,
`FutChampionsRegistrationServerResponse`, `FutGetChampionsFriendsServerResponse`,
`FutGetChampionsTopXServerResponse`, `FutGrantPrizeChampionUserServerResponse`,
`FutGetLBEntriesServerResponse`, `FutGetLBOptionsServerResponse`,
`FutGetTowChallengeServerResponse`, `FutSetTowChallengeServerResponse`,
`FutGetFriendlyHistoryDataServerResponse`, `FutGetHistoricalServerResponse`,
`FutGetTournamentTeamsServerResponse`, `FutTournamentListServerResponse`,
`FutTournamentLoadDataServerResponse`, `FutSeasonLoadDataServerResponse`,
`FutSBCLoadCategoryDetailsServerResponse`, `FutSBCTagSetsServerResponse`,
`FutSBCSetDataServerResponse`, `FutSBCSaveSquadChallengeServerResponse`,
`FutSBCSubmitChallengeServerResponse`, `FutGetAvailableLoanPlayersServerResponse`,
`FutLoadSetTypesServerResponse`, `FutStoreGetPackTypesServerResponse`,
`FutStorePackQuantitiesServerResponse`, `FutCreatePackServerResponse`,
`FutConsumablesSearchServerResponse`, `FutStickerBookSearchServerResponse`,
`FutStickerBookStats2ServerResponse`, `FutStaffBonusServerResponse`,
`FutUpdateSeasonServerResponse`, `FutGetAuctionCountServerResponse`, `FutGetUserData`
(non-critical: never reached before the squad save works).
## 3. Implementation order
**Status 2026-08-03 (implemented, contract-green, live-verify pending):** steps 1, 2 and the
populated-massinfo recommendation of §7 are in `utas_server.py`; step 3's list-vs-load split
is solved statically (see below) and shipped behind `FUT_SQUAD_LIST=merged`.
**List vs Load on `ut/%s/squad` — resolved without a live capture.** The two parsers are
mutually SKIP-tolerant: the squad-object parser `0x18013d1f0` handles
`{0xb,0x69,0x81,0xc6,0x12b,0x15c,0x163,0x16b,0x178,0x17a,0x1a8,0x21b,0x238,0x2d3,0x2d6,0x2e2,0x377}`
and does **not** recognise `squad`(0x2cd); the list parser `0x180142260` recognises **only**
`squad`(0x2cd). So one MERGED body — the full squad object plus a `"squad": [summary]` key —
satisfies whichever response class the client instantiated. Left opt-in
(`FUT_SQUAD_LIST=merged`) because `GET /squad` is boot-critical and the first live test
should isolate the massinfo/squadList change.
1. **Populate `userInfo.squadList`** (highest-leverage hypothesis): serve
`"squadList": {"squad": [ {id, squadName, formation, chemistry, rating, squadType} ]}` from
`STORE` in `/user` so the client's squad-roster model is non-empty (`FUN_180142260`, verified).
Test that `/user` still parses (no freeze) — if it freezes, fall back to populating only the
FutSquadList GET path and revisit.
2. **Fix `squad_route` PUT** (`utas_server.py:240`): on save, respond `{"id": <saved squad id>}`
instead of echoing the whole body. Guarantees the only parsed key (`id`, 0x15c) is present
even if the client's PUT body omits it. (Low risk, immediate.)
3. **Serve the FutSquadList shape on the GET /squad path the client uses for the roster**:
capture the Squad-screen request live (fresh launch) to learn the exact query/verb, then
return `{"squad": [ {summary} ]}` there (schema `0x180141fc0`).
4. **Add rename/delete ack routes** if the client calls them: `{}` bodies.
5. **Re-test add-to-squad live**: fresh FIFA launch (clears any cached s2v0 squad), open the
Squad screen, place a card, confirm `PUT /squad` appears in the log. If it still doesn't,
the gate is client-side (see Risks).
6. **Verify Tier C market/trade responses** against the log (they exist; confirm exact).
7. **Fill Tier D** with `{}` routes only if the log shows UNMAPPED hits — do not pre-build.
8. Port all Tier C shapes into Rust `openfut-core` behind the FIFA-17 bridge (matches repo goal).
## 4. Verification
- `tools/test_fut_contract.py` (311 checks) must stay green after each change.
- After the squad fixes: launch FIFA fresh, confirm squad renders with the saved item, then
confirm a subsequent `PUT /squad` (if the client issues one) round-trips and persists
(`STORE.save_squad`), surviving relaunch via `STORE.active_squad()`.
- Live-capture the Squad-screen sequence (which GET /squad query fires for the roster) before
finalizing the list-vs-load split.
- Re-run the Ghidra scripts if any response changes: `/tmp/ghidra_fut/FindFut.java`,
`ReadVtables.java`, `ReadMore.java`, `ReadSquadKeys.java`, `ReadSquadParser.java`.
## 5. Risks / unknowns
- **The add-to-squad blocker is client-side — now proven, see §9c.** `AddPlayerToSquad`,
`GetSquads` and `SelectSquadById` issue **zero** network requests; only `SaveCurrentSquad`
does, and it is unguarded. So no response of ours can be "rejected" into blocking the save;
the script layer simply never reaches it while the local squad-roster model is empty.
`userInfo.squadList: []` (SKIP-safe, model empty) is the cause. Fixed per §3.1; if a
populated roster still produces no `PUT`, the remaining target is the script layer in
`FIFA17.exe` (packed — dynamic tracing required).
- **List vs Load on the same URL** (`ut/%s/squad`) is not statically resolvable — the client
has one request row per response (binding table `0x18027bXXX`), so they differ by query/verb
that must be captured live.
- `personaId` in the squad must equal the logged-in persona (`0x18014659c` check) or the
SquadLoad handler takes the `vtable[0x4f0]` branch and creates a throwaway squad — keep
`PERSONA_ID` in sync.
- Type fidelity: never feed a scalar getter an object/array (freeze at `0x1801c7f1a`). All
shapes above respect this (strings for `formation`/`squadType`/`custom`, arrays for
`actives`/`manager`/`players`/`kicktakers`). `userInfo.squadList` must be an OBJECT with a
`squad` array member, never a bare array.
## 6. Artifacts produced this session
- `/tmp/ghidra_fut/` — Ghidra project (cardsdll.dll analyzed) + Java scripts (`FindFut.java`,
`ReadVtables.java`, `ReadMore.java`, `ReadSquadKeys.java`, `ReadSquadParser.java`,
`FindSquadStrings.java`, `DecompSquadSenders.java`, `DecompActiveSquad2.java`,
`DecompSquadSerializer.java`, `DecompUserInfo.java`, `SquadBinding.java`, `DumpReqTable.java`,
`DumpReqStrings.java`, `DumpBindings.java`, `DecompSenders.java`).
- `/tmp/ghidra_fut/fut_scan.txt`, `vtables.txt`, `more.txt`, `squadkeys.txt`, `squadparser.txt`,
`squad_strings.txt`, `squad_senders.txt`, `active_squad.txt`, `squad_serializer.txt`,
`userinfo.txt`, `squad_binding.txt`, `reqtable.txt`, `reqstrings.txt`, `bindings.txt`,
`senders.txt` — factory/vtable/deserializer/request-table dumps.
- `/tmp/fut/all_fut_structs.txt` — the 102 response-struct names.
- This plan + the 7 schemas above → next: fold them into `ENDPOINT_MAP.md`.
## 7. Massinfo freeze — root-cause correction (this session)
Static analysis now explains (and likely resolves) the documented massinfo freeze.
**Deserializer mapping (all verified in this session):**
- `FUN_180174630` = **true** `FutGetUserMassInfoServerResponse` deser. Top-level keys it
dispatches (flat object, no `user` wrapper): `userInfo`(0x370)→`0x18013ec10`,
`squad`(0x2cd)→LoadActiveSquad deser `0x18013d1f0` (loads the ACTIVE squad model +
sets personaId/flag), `settings`(0x2bf)→`0x18013c6d0`, `userData`(0x36d)→`0x180142470`,
`clubUser`(0x91), `errors`(0x10c), `loanPlayerClientData`(0x199),
`loanPlayers`(0x19a), `pileSizeClientData`(0x227). Unknown keys → SKIP (`FUN_180135ff0`).
- The doc's "wrapper key is `user`(0x36c)" is **WRONG**`user` appears only nested inside
`clubUser`. The `userInfo` key is served directly.
- `FUN_18014cc60` = separate full user-data parser (handles `login`(0x1a5)→userInfo,
`squad`, `starterPack`(0x2e5), `bonusPacks`(0x5d), `userData`) — matches the doc's
line-1146 format `{"login":true,"userData":{...},"squad":{},"starterPack":{},"bonusPacks":[]}`.
This is a different response class, not the massinfo one.
- `FUN_180146970` = FutGetUserInfo deser (`GET ut/%s/user`): body must be wrapped in exactly
ONE member, name not compared → `{"userInfo": user_info()}` is correct there.
**Why `user_info()` does NOT freeze the parser:**
- All served fields type-match `0x18013ec10` (verified atom-by-atom this session).
- `squadList: []` is SKIP-safe (`FUN_180142260` first token `[``{``FUN_180135ff0` skip,
exits on `}`). It does NOT spin — but leaves the client squad-roster EMPTY.
- `squadList` must be `{"squad":[...]}` to populate the roster (elements parsed by
`FUN_180141fc0`).
**Most probable freeze cause (old evidence):** the `squad`(0x2cd) member fed to
`FUN_18013d1f0` with a malformed squad object. The exact squad schema (§1) was NOT verified
when the freeze was documented. With a correctly-shaped squad, massinfo can be POPULATED.
**New recommendation (supersedes "massinfo MUST BE {}"):** serve a populated massinfo:
```json
{
"userInfo": {"...user_info() with squadList: {\"squad\":[{...summary...}]}"},
"squad": {...exact squad object (Schema §1)...},
"settings": {"configs": []},
"userData": {}
}
```
This delivers the squad roster to the client AT BOOT (like EA), directly addressing the
leading add-to-squad hypothesis. VERIFY LIVE before relying on it: fresh launch, watch
`/tmp/utas_server.log` for the boot `/userMassInfo` request and confirm the hub loads with
a populated squad roster, then test add-to-squad.
## 9. The add-to-squad blocker, solved statically (2026-08-03, session 2)
New Ghidra pass (PyGhidra — see `tools/ghidra_env.py`; the Java/OSGi script path is
broken on this box). The full call chain from the script API down to the wire is now
reversed, and it **explains the missing `PUT /squad` mechanically**.
### 9a. Correction: the §1b service-method names were WRONG (off by one)
The registrar `FUN_18004a3a0` writes 34 records of `{?, thunk, "ScriptName"}` (24-byte
stride from `0x1802dfd30`). Pairing each thunk with its own record's name — not the
neighbouring one — gives a different map than §1b recorded:
| Script name | thunk | adapter slot | previously (wrongly) called |
|---|---|---|---|
| `AddPlayerToSquad` | `0x18004aa70` | +0xc8 | "LoadActiveSquad" |
| `LoadActiveSquad` | `0x18004b340` | +0xc0 | — |
| `SaveCurrentSquad` | `0x18004b5a0` | +0x28 | — |
| `SaveSquad` | `0x18004b640` | +0x20 | — |
| `RemovePlayer` | `0x18004b4a0` | +0x30 | "SaveCurrentSquad" |
| `GetPotentialChemistry_Club` | `0x18004aff0` | +0xd0 | **"AddPlayerToSquad"** |
| `GetSquadList` | `0x18004b180` | +0x80 | (correct) |
| `GetSquads` | `0x18004b1d0` | +0x88 | (correct) |
| `SelectSquadById` | `0x18004b6a0` | +0x90 | (correct) |
Also: `FUN_18003b9d0` is **not** a "squad→JSON serializer" (§1b) — it is the generic
script-object→native-handle converter used by many thunks. Full 34-entry map:
`/tmp/ghidra_fut/fut_api_map.txt`.
### 9b. The layer stack
```
script: AddPlayerToSquad / SaveCurrentSquad / GetSquads ...
thunk 0x18004a000-0x18004c000 (pull args off the script VM DAT_1802ef558)
-> SquadManagerAdapter vtable 0x1801f75a8 (35 slots, name string follows)
-> interface 0xe9e4f96 (COM-like registry, resolver 0x180009ce0)
-> FutComponentServicesImpl::FutSquadServiceImpl vtable 0x180233ff0, size 0xf28
SaveCurrentSquad +0x50 -> 0x1801959e0
AddPlayerToSquad +0x168 -> 0x18018b940
GetSquads +0x1b8 -> 0x180192ec0
SelectSquadById +0x1c8 -> 0x180196080
```
The service object is injected into CardsDLL by the host (`FUN_18004a9d0` stores the
caller's pointer into `DAT_1802dfd18`; the concrete instance is built by
`FUN_18004c180`). Sibling services: `FutAuctionServiceImpl`, `FutCardServiceImpl`,
`FutStoreServiceImpl`, `FutTournamentServiceImpl`, … (all `FutComponentServicesImpl::*`).
### 9c. Why no `PUT /squad` — the actual mechanism
Counting the request-dispatch call (`vtbl+0x250`, the one that enqueues an HTTP
request with a completion callback) in each implementation:
| Implementation | dispatches a request? |
|---|---|
| `SaveCurrentSquad` `0x1801959e0` | **YES — exactly one, and unconditional** |
| `AddPlayerToSquad` `0x18018b940` | **NO — zero** |
| `GetSquads` `0x180192ec0` | **NO — zero** |
| `SelectSquadById` `0x180196080` | **NO — zero** |
So:
1. **`AddPlayerToSquad` never touches the network.** It is a pure local-model
mutation. With `param_2 < 0` (auto-slot) it asks the squad model for its slot
array (`model->vtbl[0x270]`) and scans for the first free slot (`0x1801a8850`);
if none is found it falls through `if (param_2 < 0) goto LAB_18018bd25` and
returns having done nothing. Placement is then gated on `param_2 < 0x17` (23 slots).
2. **`GetSquads` and `SelectSquadById` never touch the network either** — they read
the roster/squad model that must already be in memory.
3. **`SaveCurrentSquad` is the only writer, and it has no guard**: it builds the
request body (player loop bounded by the model's count, ≤22) and dispatches
unconditionally. If the script calls it, a `PUT` goes out — empty squad or not.
**Conclusion:** the missing `PUT /squad` cannot be caused by any response we serve
being *rejected* — the save path has no gate to fail. It can only be that the script/UI
layer never reaches the save call, because the local squad-roster model was empty and
so steps 12 no-op. That model is filled at boot from `userInfo.squadList`
(atom `0x2d4``FUN_180142260` → roster singleton `0x18011a830``vtbl[0x480]``+0x30`),
which we were serving as a bare `[]`.
This is exactly what §3.1 / the massinfo change fix, and it upgrades the "leading
hypothesis" of §5 to a mechanism supported at instruction level. It also means the
remaining risk is narrow: if a populated roster still produces no `PUT`, the next
target is the script layer in `FIFA17.exe` (packed → dynamic tracing), not CardsDLL.
## 10. LIVE RESULT — 2026-08-03 19:48 run (first test of populated massinfo)
Boot sequence actually observed (`/tmp/utas_server.log`, marker `live-test-1`):
```
POST /ut/auth -> sid
GET /settings -> {"configs":[]}
GET /phishing/trusteddevice|question -> trusted / question
POST /phishing/validate -> token
PUT /match/reset -> {} (was UNMAPPED -> now routed)
GET /userMassInfo -> POPULATED {userInfo,squad,settings,userData}
PUT /v2/store/transaction/0 -> {} (body {"state":"TRANSACTIONCANCEL"})
... then the client CRASHED
```
**Headline: the populated massinfo does NOT freeze the client.** It was served, parsed,
and the client carried on to the next request. The "massinfo MUST BE `{}`" rule
(CARD_SYSTEM.md, ENDPOINT_MAP.md) is now disproven *live*, not just statically — the
old freeze was the malformed squad member, exactly as §7 predicted.
**The crash.** The user reached the FUT club-creation prompt (club name + abbreviation),
pressed Continue, and the game died. `CrashDump_2026.08.03_20.54.05.378.dmp`
(`drive_c/users/steamuser/Documents/FIFA 17/Temp/`, parsed with `tools/…/dmp.py`):
| field | value |
|---|---|
| exception | `0xc0000005` ACCESS_VIOLATION, **READ from `0x0`** |
| address | `FIFA17.exe+0x71b8651` (base `0x140000000`) |
| CardsDLL frames on stack | `+0x84f90``0x180084f90`, `+0x9ad90``0x18009ad90` |
`0x18009ad90` is the request-completion callback thunk (the one embedded in the
dispatch at `vtbl+0x250`), and `0x180084f90` is the **create-club handler**: it
reports a `"PROFANITY"` error (code `0x7565`) on failure, and on success runs
`strlen` over two stored string pointers (`+0x1a0` name, `+0x1d0` abbrev) **with no
null check**. So the callback pair for the club-name step was live when a null string
was dereferenced. This is the **first crash dump this prefix has ever produced** — the
onboarding path is newly reachable because massinfo now populates `userInfo`.
**Fix applied (hypothesis, needs the next live run to confirm):** diffing every atom
`0x18013ec10` consumes against what `user_info()` sends showed the record's string
fields are `clubName`(0x8e), `clubAbbr`(0x8d), `established`(0x110) — all sent — plus
**`accountCreatedPlatformName`(0x6), which we never sent**. It is the only unsent
string in the record, it is stored at `userInfo+0x42`, and the crash is a null string
read in exactly that step. Now served as `"pc"` (matching the auth body's
`nucleusPersonaPlatform`). Also changed: `POST /user` (CreateUser) now carries the
real schema-correct squad instead of `{}` (an empty squad there would hand the freshly
created club a 0-slot squad model — the §9c no-op state), and `PUT /match/reset` is
routed explicitly.
**That fix was WRONG.** Runs 2 and 3 crashed at the byte-identical address with
`accountCreatedPlatformName` being served. Three dumps, one RVA — fully reproducible.
(The field is still sent: the deser does consume it, so it is correct to include, it
just was not the cause.)
### 10a. The actual faulting instruction
The dump carries no code pages and FIFA17.exe is packed on disk, so the instruction was
read from a LIVE process via `/proc/<pid>/mem` (`tools/grab_crash_code.py`; Wine maps
the PE flat at `0x140000000`, `ptrace_scope=0`):
```
0x1471b8640 push rbx ; sub rsp,0x20
0x1471b8645 mov rbx, rcx ; this
0x1471b8648 test r9, r9
0x1471b864b je 0x1471b86ac ; arg3 == NULL is explicitly HANDLED
0x1471b864d mov rax, [r9 + 0x28]
0x1471b8651 mov r9, [rax] ; <<< FAULT: r9->field_0x28 is NULL
0x1471b8654 cmp edx, 0x80040000 ; edx = result/status code
0x1471b865c test edx, edx ; je ... ; edx == 0 -> success path
```
So the object is present (they null-check it) but its `+0x28` member was never
initialised, and that member is dereferenced unguarded. Note the packer obfuscates
constants (`mov r8d,0xe7ffa20f ; lea r8d,[r8+0x18005df4]` = `r8d = 3`).
**Fault-time registers** (from the EXCEPTION stream's own context — the ThreadList
context is the dump-writer's and is misleading): `RAX=0 RDX=0 RSI=0`,
`R12=0x140000000`, `RIP=FIFA17.exe+0x71b8651`. The backtrace from the true RSP is
**entirely FIFA17.exe — not one CardsDLL frame**, and **no HTTP request is issued when
it happens**. So this is not a response of ours being rejected or mis-parsed; the
client dies in its own UI code before reaching the network layer.
### 10b. Bisect in progress
Because the crash only appeared once massinfo became populated, the member responsible
is being isolated one relaunch at a time via `FUT_MASSINFO`:
| value | delivers | result |
|---|---|---|
| `full` | userInfo + squad + settings + userData | **crash** (3/3) |
| `squad` | squad only (feeds the ACTIVE squad model §9c) | ← current, untested |
| `userinfo` | userInfo only (incl. squadList roster) | untested |
| `settings` | settings only | untested |
| `empty` | `{}` | last known hub-reaching |
`squad` is tried first because it is the member that populates the squad model
`AddPlayerToSquad` needs, while keeping `userInfo`/`squadList` out of the picture.
**RESULT: `FUT_MASSINFO=squad` reaches the FUT hub with no crash — and the primary
blocker is SOLVED.** So the crash lives in the `userInfo` member, not the squad.
### 10c. `PUT /squad` FIRES — blocker cleared (2026-08-03 20:16)
With `FUT_MASSINFO=squad`, the Squads tab renders a full ACTIVE SQUAD (11 real named
cards with correct ratings/positions/chemistry links) and the client then issued, of
its own accord:
```
GET /ut/game/fifa17/club x3
PUT /ut/game/fifa17/squad/0 -> {"id": 0} <-- the request that never fired before
GET /ut/game/fifa17/hub
```
Note the live URL is **`ut/%s/squad/<id>`** (`/squad/0`), not a bare `ut/%s/squad`.
The PUT body carries all 23 slots with 11 filled, each as an item *reference*
(`{"index":0,"itemData":{"id":100000025,"dream":false},"kitNumber":11}`) — exactly the
shape `Store.reconstruct_squad()` already expected. Verified round-trip: the squad
persists to `fifa17_profile.json` and `GET /squad` re-serves it with all 11 real cards
re-embedded, `custom` intact and 5 kicktakers.
This confirms §9c end-to-end: nothing was wrong with the save path; the client simply
needed a populated active squad model, which the massinfo `squad` member supplies.
### 10d. Remaining gap: the `userInfo` member
Two concrete findings narrowed this from "bisect 20 fields" to "two suspects":
**1. `currencies` used the wrong key — coins could never have rendered.** The
userInfo currency-element parser `FUN_180138bd0` reads
`name`(0x1d0), `funds`(0x134), `finalFunds`(0x124), `active`(0xa). There is **no
`value` key**; the caller then `strcmp`s the name against `"coins"` / `"points"` and
stores the parsed number. We were sending `{"name":"coins","value":N}`, so `value`
was SKIP'd and coins parsed as 0. Now sending `name`/`funds`/`finalFunds`/`active`.
(`won`(0x387) / `draw`(0xe6) / `loss`(0x1a6) were already correct — an earlier diff
script wrongly flagged `won` as unhandled because its guard is `if (iVar4 != 0x387)`,
an inequality the regex missed.)
**2. Only two userInfo members have side effects outside the record.** Every other
member fills a scalar/string slot; these two reach into the global model singleton
`FUN_18011a830`:
| member | atom | side effect |
|---|---|---|
| `squadList` | 0x2d4 | `vtbl[0x480]``+0x30` — fills the squad-ROSTER model |
| `unopenedPacks` | 0x35e | `vtbl[0x4e0](preOrderPacks + recoveredPacks)` |
Since the crash is a NULL member inside a **FIFA17.exe UI model** and userInfo had
never actually reached the client before, these two newly-exercised branches are the
prime suspects — and **neither is needed for coins/record**. Hence the `FUT_USERINFO`
ladder, cheapest-first:
| value | sends | purpose |
|---|---|---|
| `safe` (default) | neither | coins + record, minimum crash surface |
| `packs` | +`unopenedPacks` | tests suspect 2 |
| `roster` | +`squadList` | tests suspect 1, restores "MY SQUADS" |
| `full` | both | end state if neither is the culprit |
`FUT_MASSINFO` is back to `full`. **Instant fallback if the crash returns:
`FUT_MASSINFO=squad`** — the proven-good config that still delivers the working
active squad and `PUT /squad`.
### 10e. SOLVED — coins + record render (2026-08-03 20:33)
**Working configuration: `FUT_MASSINFO=full` + `FUT_USERINFO=min`.** Live-confirmed:
the hub shows 12,600 coins and the 0-0-0 record, no crash, no new crash dump, and the
active squad is unaffected. The session exercised the store, transfer market,
tradePile, watchList, a trade view, marketdata, `/squad/0`, `/user`, `/club` and
`/user/credits` — all served. (Note `GET ut/%s/user` *is* reached once the hub is
live, contradicting the old "never called at boot" note, which was only true of boot.)
`min` sends exactly: `personaId`, `clubName`, `clubAbbr`, `established`,
`accountCreatedPlatformName`, `currencies`, `won`, `draw`, `loss`.
### 10f. ROOT CAUSE ISOLATED — `clubNameChangeAllowed` must be `false`
A clean single-variable experiment settles it. `live-test-4` and `live-test-6` sent
the **identical userInfo field set** (both with `squadList` and `unopenedPacks`
already omitted); the only difference was one bool:
| `clubNameChangeAllowed`(0x8f) | club-name prompt | result |
|---|---|---|
| `true` | shown | **crash on confirm**`ACCESS_VIOLATION` reading `0x0` at `FIFA17.exe+0x71b8651`, 4/4 runs |
| `false` | **never appears** | hub loads, coins + record render, no crash dump |
Sending `true` advertises "a club-name change is available", which pushes the client
into a rename/creation flow whose UI model we never populate — hence the null `+0x28`
member dereferenced in FIFA17.exe's own code, with no CardsDLL frame and no HTTP
request in flight (the flow dies before it would ever call the server). Neither
side-effecting member was involved; both were already absent in the crashing run.
**`FUT_USERINFO=safe` is now the default** (19 userInfo fields). A contract check
guards it: `clubNameChangeAllowed` may be absent, but must never be `true`. Do not
set it true unless the rename flow is actually implemented server-side
(`FutChangeClubNameServerResponse`, currently a Tier-B `{}` ack).
## 8. New artifacts (this session)
- `/tmp/ghidra_fut/massinfo.txt` — full `0x180174630` decompile (all top-level keys).
- `/tmp/ghidra_fut/settings.txt``0x18013c6d0` (settings/configs parser; `{"configs":[]}` OK).
- `/tmp/ghidra_fut/squadlist2.txt``0x180142260` (squadList parser; `[]` skip-safe,
`{"squad":[...]}` correct).
- `/tmp/ghidra_fut/user_wrappers.txt`, `user_caller_dumps.txt` — the 3 callers of the userInfo
sub-deser (`0x180146970`, `0x18014cc60`, `0x180174630`).
- `fut_atoms.tsv` fully decoded for `user_info()` — only `squadList` was structurally wrong.
### Session 2 (the §9 pass)
- `tools/ghidra_env.py` — PyGhidra harness (the Java/OSGi script path is broken here);
`tools/ghidra_queries/*.py` — the queries that produced §9.
- `/tmp/ghidra_fut/fut_api_map.txt` — all 34 script API names → thunk → adapter slot.
- `/tmp/ghidra_fut/squad_service.txt``SquadManagerAdapter` vtable `0x1801f75a8`.
- `/tmp/ghidra_fut/squad_svc.txt``FutSquadServiceImpl` vtable `0x180233ff0` + the
`SaveCurrentSquad` / `AddPlayerToSquad` / `GetSquads` / `SelectSquadById` decompiles.
- `/tmp/ghidra_fut/squad_impl.txt` — all 11 `FutComponentServicesImpl::*` service classes.
### 10g. `MY SQUADS: 0` — the squad-list URL exists after all
`FUT_USERINFO=roster` did **not** crash (so `squadList` is safe to send), but the
Squads screen still showed `MY SQUADS: 0`. The log explained why:
```
GET /ut/game/fifa17/squad/list <- a URL we did not know existed
```
`ut/%s/squad/list` is the real FutSquadList endpoint. The static pass concluded there
was no separate list URL because the request table only holds `ut/%s/squad` — the
`/list` suffix is appended by the caller rather than stored as a template, so it was
invisible to a strings/table dump. Our generic `G + "/squad"` route swallowed it and
returned the **active-squad object**; the list parser `0x180142260` recognises only
`squad`(0x2cd) and SKIP'd every key of it, yielding an empty roster.
Fix: route `/squad/list` (before the generic `/squad`) to `squad_list_body()`:
| URL | response | parser |
|---|---|---|
| `GET ut/%s/squad/list` | `{"squad":[{summary}]}` | `0x180142260` -> elem `0x180141fc0` |
| `GET ut/%s/squad[/<id>]` | full active-squad object | `0x18013d1f0` |
| `PUT ut/%s/squad/<id>` | `{"id": <n>}` | `0x180171a60` |
Guarded by a contract check asserting `/squad/list` returns a `squad` array and is
*not* the active-squad object. This also makes the `FUT_SQUAD_LIST=merged` workaround
(S3) unnecessary — the two responses have distinct URLs, so no merged body is needed.
+71
View File
@@ -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 <module>+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("<IiI", d, 4)[0], *struct.unpack_from("<II", d, 8)
nstreams, dirrva = struct.unpack_from("<II", d, 8)
streams = {}
for i in range(nstreams):
st, size, rva = struct.unpack_from("<III", d, dirrva + i * 12)
streams.setdefault(st, []).append((size, rva))
print("streams:", sorted(streams))
def mstring(rva):
(ln,) = struct.unpack_from("<I", d, rva)
return d[rva + 4: rva + 4 + ln].decode("utf-16-le", "replace")
mods = []
if 4 in streams:
size, rva = streams[4][0]
(n,) = struct.unpack_from("<I", d, rva)
off = rva + 4
for i in range(n):
base, sz, csum, ts, nrva = struct.unpack_from("<QIIII", d, off)
mods.append((base, sz, mstring(nrva)))
off += 108
print("modules:", len(mods))
exc_addr = None
if 6 in streams:
size, rva = streams[6][0]
tid, _pad = struct.unpack_from("<II", d, rva)
code, flags, recptr, addr, nparams = struct.unpack_from("<IIQQI", d, rva + 8)
exc_addr = addr
NAMES = {0xC0000005: "ACCESS_VIOLATION", 0xC000001D: "ILLEGAL_INSTRUCTION",
0xC0000094: "INT_DIVIDE_BY_ZERO", 0xC0000096: "PRIV_INSTRUCTION",
0x80000003: "BREAKPOINT", 0xC00000FD: "STACK_OVERFLOW",
0xC0000374: "HEAP_CORRUPTION", 0xC0000135: "DLL_NOT_FOUND"}
print("\n=== EXCEPTION ===")
print(" thread : %#x" % tid)
print(" code : %#010x %s" % (code, NAMES.get(code, "?")))
print(" address : %#018x" % addr)
params = [struct.unpack_from("<Q", d, rva + 8 + 32 + i * 8)[0] for i in range(min(nparams, 15))]
print(" params : %s" % [hex(x) for x in params])
if code == 0xC0000005 and len(params) >= 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))
+87
View File
@@ -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("<II", d, 8)
streams = {}
for i in range(nstreams):
st, size, rva = struct.unpack_from("<III", d, dirrva + i * 12)
streams.setdefault(st, []).append((size, rva))
def mstring(rva):
(ln,) = struct.unpack_from("<I", d, rva)
return d[rva + 4: rva + 4 + ln].decode("utf-16-le", "replace")
mods = []
size, rva = streams[4][0]
(n,) = struct.unpack_from("<I", d, rva)
off = rva + 4
for i in range(n):
base, sz, csum, ts, nrva = struct.unpack_from("<QIIII", d, off)
mods.append((base, sz, mstring(nrva).split("\\")[-1]))
off += 108
mods.sort()
# faulting thread + exception address
size, rva = streams[6][0]
tid, _ = struct.unpack_from("<II", d, rva)
code, flags, recptr, exc_addr, nparams = struct.unpack_from("<IIQQI", d, rva + 8)
def whose(a):
for base, sz, name in mods:
if base <= a < base + sz:
return name, a - base
return None, 0
# thread list
size, rva = streams[3][0]
(nthreads,) = struct.unpack_from("<I", d, rva)
off = rva + 4
target = None
for i in range(nthreads):
t_id, susp, pcls, prio, teb, stk_start, stk_size, stk_rva, ctx_size, ctx_rva = \
struct.unpack_from("<IIIIQQIIII", d, off)
if t_id == tid:
target = (stk_start, stk_size, stk_rva, ctx_size, ctx_rva)
off += 48
print("faulting thread %#x exception at %s+%#x" % (tid, *whose(exc_addr)))
if not target:
print("no stack captured for the faulting thread"); sys.exit(0)
stk_start, stk_size, stk_rva, ctx_size, ctx_rva = target
print("stack %#x..%#x (%d bytes)\n" % (stk_start, stk_start + stk_size, stk_size))
# CONTEXT_AMD64: Rsp at offset 0x98, Rip at 0xF8 (RUNTIME layout)
if ctx_size >= 0x100:
rsp = struct.unpack_from("<Q", d, ctx_rva + 0x98)[0]
rip = struct.unpack_from("<Q", d, ctx_rva + 0xF8)[0]
print("RSP=%#x RIP=%#x (%s+%#x)\n" % (rsp, rip, *whose(rip)))
else:
rsp = stk_start
print("=== plausible return addresses (innermost first) ===")
seen, out = set(), []
start = max(rsp - stk_start, 0)
for o in range(int(start), stk_size - 8, 8):
(v,) = struct.unpack_from("<Q", d, stk_rva + o)
name, off2 = whose(v)
if name is None:
continue
key = (name, off2)
if key in seen:
continue
seen.add(key)
out.append((stk_start + o, name, off2))
for i, (sa, name, off2) in enumerate(out[:45]):
tag = ""
if "cards" in name.lower():
tag = " <-- CardsDLL ghidra %#x" % (0x180000000 + off2)
print(" [%2d] %#x %s+%#x%s" % (i, sa, name, off2, tag))
+35
View File
@@ -152,6 +152,41 @@ def make_squad(step="s1"):
return squad, {"itemData": club_items}
def squad_rating(squad):
"""Squad rating = mean of the rated players actually placed (0 when the squad
is empty, e.g. the s1 zero-resolve ladder step)."""
ratings = [(pl.get("itemData") or {}).get("rating", 0)
for pl in squad.get("players", [])]
ratings = [r for r in ratings if isinstance(r, int) and r > 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 "<int>" 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)
+55
View File
@@ -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/<id> (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])
+185
View File
@@ -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 <query.py> -- runs <query.py> 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 <query.py>
Two traps this file already works around:
* open_program(..., nested_project_location=False) -- otherwise pyghidra creates
a NEW empty project at <loc>/<name>/ 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)
@@ -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")
@@ -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 <end of vtable>" % (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")
@@ -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: <lhs> = FUN_xxxxxxxxx; <lhs> = "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))
@@ -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")
+71
View File
@@ -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/<pid>/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)
+94 -5
View File
@@ -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:
+58
View File
@@ -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))
+282 -66
View File
@@ -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/<sku>/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/<sku>/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/<sku>/settings (0x18013C6D0) recognises ONE key: configs (0xa2).
SETTINGS = {"configs": []}
# GET ut/game/<sku>/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/<sku>/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": <squadId>}, 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()}