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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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 1–2 no-op. That model is filled at boot from `userInfo.squadList`
|
||||
(atom `0x2d4` → `FUN_180142260` → roster singleton `0x18011a830`→`vtbl[0x480]`→`+0x30`),
|
||||
which we were serving as a bare `[]`.
|
||||
|
||||
This is exactly what §3.1 / the massinfo change fix, and it upgrades the "leading
|
||||
hypothesis" of §5 to a mechanism supported at instruction level. It also means the
|
||||
remaining risk is narrow: if a populated roster still produces no `PUT`, the next
|
||||
target is the script layer in `FIFA17.exe` (packed → dynamic tracing), not CardsDLL.
|
||||
|
||||
## 10. LIVE RESULT — 2026-08-03 19:48 run (first test of populated massinfo)
|
||||
|
||||
Boot sequence actually observed (`/tmp/utas_server.log`, marker `live-test-1`):
|
||||
|
||||
```
|
||||
POST /ut/auth -> sid
|
||||
GET /settings -> {"configs":[]}
|
||||
GET /phishing/trusteddevice|question -> trusted / question
|
||||
POST /phishing/validate -> token
|
||||
PUT /match/reset -> {} (was UNMAPPED -> now routed)
|
||||
GET /userMassInfo -> POPULATED {userInfo,squad,settings,userData}
|
||||
PUT /v2/store/transaction/0 -> {} (body {"state":"TRANSACTIONCANCEL"})
|
||||
... then the client CRASHED
|
||||
```
|
||||
|
||||
**Headline: the populated massinfo does NOT freeze the client.** It was served, parsed,
|
||||
and the client carried on to the next request. The "massinfo MUST BE `{}`" rule
|
||||
(CARD_SYSTEM.md, ENDPOINT_MAP.md) is now disproven *live*, not just statically — the
|
||||
old freeze was the malformed squad member, exactly as §7 predicted.
|
||||
|
||||
**The crash.** The user reached the FUT club-creation prompt (club name + abbreviation),
|
||||
pressed Continue, and the game died. `CrashDump_2026.08.03_20.54.05.378.dmp`
|
||||
(`drive_c/users/steamuser/Documents/FIFA 17/Temp/`, parsed with `tools/…/dmp.py`):
|
||||
|
||||
| field | value |
|
||||
|---|---|
|
||||
| exception | `0xc0000005` ACCESS_VIOLATION, **READ from `0x0`** |
|
||||
| address | `FIFA17.exe+0x71b8651` (base `0x140000000`) |
|
||||
| CardsDLL frames on stack | `+0x84f90` → `0x180084f90`, `+0x9ad90` → `0x18009ad90` |
|
||||
|
||||
`0x18009ad90` is the request-completion callback thunk (the one embedded in the
|
||||
dispatch at `vtbl+0x250`), and `0x180084f90` is the **create-club handler**: it
|
||||
reports a `"PROFANITY"` error (code `0x7565`) on failure, and on success runs
|
||||
`strlen` over two stored string pointers (`+0x1a0` name, `+0x1d0` abbrev) **with no
|
||||
null check**. So the callback pair for the club-name step was live when a null string
|
||||
was dereferenced. This is the **first crash dump this prefix has ever produced** — the
|
||||
onboarding path is newly reachable because massinfo now populates `userInfo`.
|
||||
|
||||
**Fix applied (hypothesis, needs the next live run to confirm):** diffing every atom
|
||||
`0x18013ec10` consumes against what `user_info()` sends showed the record's string
|
||||
fields are `clubName`(0x8e), `clubAbbr`(0x8d), `established`(0x110) — all sent — plus
|
||||
**`accountCreatedPlatformName`(0x6), which we never sent**. It is the only unsent
|
||||
string in the record, it is stored at `userInfo+0x42`, and the crash is a null string
|
||||
read in exactly that step. Now served as `"pc"` (matching the auth body's
|
||||
`nucleusPersonaPlatform`). Also changed: `POST /user` (CreateUser) now carries the
|
||||
real schema-correct squad instead of `{}` (an empty squad there would hand the freshly
|
||||
created club a 0-slot squad model — the §9c no-op state), and `PUT /match/reset` is
|
||||
routed explicitly.
|
||||
|
||||
**That fix was WRONG.** Runs 2 and 3 crashed at the byte-identical address with
|
||||
`accountCreatedPlatformName` being served. Three dumps, one RVA — fully reproducible.
|
||||
(The field is still sent: the deser does consume it, so it is correct to include, it
|
||||
just was not the cause.)
|
||||
|
||||
### 10a. The actual faulting instruction
|
||||
|
||||
The dump carries no code pages and FIFA17.exe is packed on disk, so the instruction was
|
||||
read from a LIVE process via `/proc/<pid>/mem` (`tools/grab_crash_code.py`; Wine maps
|
||||
the PE flat at `0x140000000`, `ptrace_scope=0`):
|
||||
|
||||
```
|
||||
0x1471b8640 push rbx ; sub rsp,0x20
|
||||
0x1471b8645 mov rbx, rcx ; this
|
||||
0x1471b8648 test r9, r9
|
||||
0x1471b864b je 0x1471b86ac ; arg3 == NULL is explicitly HANDLED
|
||||
0x1471b864d mov rax, [r9 + 0x28]
|
||||
0x1471b8651 mov r9, [rax] ; <<< FAULT: r9->field_0x28 is NULL
|
||||
0x1471b8654 cmp edx, 0x80040000 ; edx = result/status code
|
||||
0x1471b865c test edx, edx ; je ... ; edx == 0 -> success path
|
||||
```
|
||||
|
||||
So the object is present (they null-check it) but its `+0x28` member was never
|
||||
initialised, and that member is dereferenced unguarded. Note the packer obfuscates
|
||||
constants (`mov r8d,0xe7ffa20f ; lea r8d,[r8+0x18005df4]` = `r8d = 3`).
|
||||
|
||||
**Fault-time registers** (from the EXCEPTION stream's own context — the ThreadList
|
||||
context is the dump-writer's and is misleading): `RAX=0 RDX=0 RSI=0`,
|
||||
`R12=0x140000000`, `RIP=FIFA17.exe+0x71b8651`. The backtrace from the true RSP is
|
||||
**entirely FIFA17.exe — not one CardsDLL frame**, and **no HTTP request is issued when
|
||||
it happens**. So this is not a response of ours being rejected or mis-parsed; the
|
||||
client dies in its own UI code before reaching the network layer.
|
||||
|
||||
### 10b. Bisect in progress
|
||||
|
||||
Because the crash only appeared once massinfo became populated, the member responsible
|
||||
is being isolated one relaunch at a time via `FUT_MASSINFO`:
|
||||
|
||||
| value | delivers | result |
|
||||
|---|---|---|
|
||||
| `full` | userInfo + squad + settings + userData | **crash** (3/3) |
|
||||
| `squad` | squad only (feeds the ACTIVE squad model §9c) | ← current, untested |
|
||||
| `userinfo` | userInfo only (incl. squadList roster) | untested |
|
||||
| `settings` | settings only | untested |
|
||||
| `empty` | `{}` | last known hub-reaching |
|
||||
|
||||
`squad` is tried first because it is the member that populates the squad model
|
||||
`AddPlayerToSquad` needs, while keeping `userInfo`/`squadList` out of the picture.
|
||||
|
||||
**RESULT: `FUT_MASSINFO=squad` reaches the FUT hub with no crash — and the primary
|
||||
blocker is SOLVED.** So the crash lives in the `userInfo` member, not the squad.
|
||||
|
||||
### 10c. `PUT /squad` FIRES — blocker cleared (2026-08-03 20:16)
|
||||
|
||||
With `FUT_MASSINFO=squad`, the Squads tab renders a full ACTIVE SQUAD (11 real named
|
||||
cards with correct ratings/positions/chemistry links) and the client then issued, of
|
||||
its own accord:
|
||||
|
||||
```
|
||||
GET /ut/game/fifa17/club x3
|
||||
PUT /ut/game/fifa17/squad/0 -> {"id": 0} <-- the request that never fired before
|
||||
GET /ut/game/fifa17/hub
|
||||
```
|
||||
|
||||
Note the live URL is **`ut/%s/squad/<id>`** (`/squad/0`), not a bare `ut/%s/squad`.
|
||||
The PUT body carries all 23 slots with 11 filled, each as an item *reference*
|
||||
(`{"index":0,"itemData":{"id":100000025,"dream":false},"kitNumber":11}`) — exactly the
|
||||
shape `Store.reconstruct_squad()` already expected. Verified round-trip: the squad
|
||||
persists to `fifa17_profile.json` and `GET /squad` re-serves it with all 11 real cards
|
||||
re-embedded, `custom` intact and 5 kicktakers.
|
||||
|
||||
This confirms §9c end-to-end: nothing was wrong with the save path; the client simply
|
||||
needed a populated active squad model, which the massinfo `squad` member supplies.
|
||||
|
||||
### 10d. Remaining gap: the `userInfo` member
|
||||
|
||||
Two concrete findings narrowed this from "bisect 20 fields" to "two suspects":
|
||||
|
||||
**1. `currencies` used the wrong key — coins could never have rendered.** The
|
||||
userInfo currency-element parser `FUN_180138bd0` reads
|
||||
`name`(0x1d0), `funds`(0x134), `finalFunds`(0x124), `active`(0xa). There is **no
|
||||
`value` key**; the caller then `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.
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
@@ -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()}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user