fifa17-recon: match rewards, POW online layer, account backend, quick sell

Second session. FUT core loop, the EASFC/POW online layer, a central account
backend, and a lot of corrections. Everything risky is behind an env flag with
the default set to whatever was live-proven.

WORKING END TO END (live-verified this session):
  * match loop -- POST/PUT/POST/DELETE ut/%s/match, rewards via FutDestroyMatch
    (0x180121b60). Play a match, get coins, W/D/L updates.
  * packs -- buy, cards land in the club, session survives (FUT_PACK_AUTOCLUB=1)
  * quick sell -- POST ut/delete/%s/item was UNMAPPED and paid NOTHING; six cards
    were destroyed for 0 coins. Now credits discardValue.
  * POW/EASFC online -- the "EA FC servers unreachable" banner is powdll's layer,
    a THIRD http api on :8094 nobody had served. Redirect needs no root: powdll
    FUN_18005a460 reads FIFA_POW_URL from the same client-config store as
    ROSTERUPDATE_URL. FUT_POW=1.
  * account backend -- fut_account.py replaces 7 hardcoded copies of the persona
    across 5 files; club/persona/online-profile editable via CLI.

CORRECTIONS TO ENDPOINT_MAP (all re-extracted from the deserializers):
  * FutStoreGetPackTypes: id/packType/isPremium/quantity/saleType/purchaseLimit/
    purchaseCount are NOT skipped no-ops -- all are parsed. extPrice inner objects
    take externalPriceId(0x11a), not amount/currency.
  * FutMoveCard 0x180128600 has NO skip handler (FUN_180135ff0 appears zero times,
    unique among FUT deserializers) and parses only itemData -> dreamSquads.
  * class -> deserializer resolution: the name literal is preceded by a 4-BYTE
    HEADER and the factory LEA points at the header, so look up name_addr - 4.
    Six attempts failed on this; now ghidra_env.class_deser(). Unlocked 11 SBC/
    Draft schemas.
  * live-only endpoints the request table never lists: ut/%s/squad/list,
    ut/%s/user/club, ut/%s/club/stats/*, ut/%s/clientdata/<key>. The template
    table is a floor, not a ceiling -- the log is the only ground truth.
  * 163 RS4 call names exist; we served 17. All now served.

FIXED: club/stats/* was answering with the entire 28-item club inventory on every
poll (it fell through to the generic /club route).

UNSOLVED: the pack reveal's "Send to Club" (PUT ut/%s/item) kills the FUT session
whatever we answer -- {} included -- while its sibling quick-sell endpoint accepts
a bare {}. Seven hypotheses eliminated by live test, documented in
REBUILD_RESEARCH.md S14c so none get re-walked. FUT_PACK_AUTOCLUB routes around it.
Also unfixed: store tiles render "unknown" (displayGroup is parsed RECURSIVELY by
the same element parser; sending it FROZE the store, so FUT_STORE_GROUPS=1 is
default off).

Tests: test_fut_contract.py 380 (live, read-only) + test_match_rewards.py 51 (pure).

Note: fut_store.py carries some pre-existing uncommitted changes from before this
session (pack catalogue ids, pending-pile behaviour) that could not be separated
from this session's additions in the same file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
funman300
2026-08-04 09:42:59 -07:00
parent 59934b4ef0
commit 5d5198f5d1
20 changed files with 3217 additions and 144 deletions
@@ -634,3 +634,66 @@ Fix: route `/squad/list` (before the generic `/squad`) to `squad_list_body()`:
Guarded by a contract check asserting `/squad/list` returns a `squad` array and is
*not* the active-squad object. This also makes the `FUT_SQUAD_LIST=merged` workaround
(S3) unnecessary — the two responses have distinct URLs, so no merged body is needed.
## 11. The online gate is POW/EASFC, not FUT (2026-08-03)
"EA FC servers are unreachable / PRESS Q TO RE-CONNECT" is **not** the FUT/UTAS
layer, not Blaze and not Origin/LSX — all three are healthy in our live logs while
the banner shows. It is the EASFC layer in **`powdll_Win64_retail.dll`**: 1.1 MB,
**unpacked and string-rich**, so unlike Denuvo-packed FIFA17.exe it can actually be
reversed. CardsDLL contains zero hits for "unreachable"/"RE-CONNECT"/"PRESS Q";
powdll has `TXT_EASFC_RECONNECT_PROMPT`, `TXT_EASFC_SERVER_ERROR`, and the FE events
`POWService::PowReconnect` / `TriggerPleaseConnectMsg` / `PowBlazeDisconnected`.
POW is a **third HTTP API** we have never served:
| role | default host | paths |
|---|---|---|
| api | `pas.gt.easfc.ea.com:8094` | `pow/auth`, `pow/healthcheck/system/all`, `pow/v2/activity`, `pow/nucleus/entitlements`, `pow/bank/user/account`, `pow/store/...`, … (58 templates extracted) |
| content | `content.lt.easfc.ea.com:8080` | `pow/imgAssets/...`, `pow/artAssets/...` |
Neither hostname is in `/etc/hosts` or the iptables DNAT, so **every POW call dies at
DNS** — which is exactly the banner's trigger.
### Reversed (Ghidra project `/tmp/pow/powproj`, PE base 0x180000000)
| addr | role |
|---|---|
| `FUN_18005a460` | POW config init. Reads `FIFA_POW_URL`, `FIFA_POW_CONTENT_SERVER_URL`, `POW_IS_ON` via `cfg->vtbl[0x30]` = `getString(key, default, &out)`**the same merged `_all` client-config store that already delivers `ROSTERUPDATE_URL`**. Picks `http://` vs `https://` (`PTR_s_http____18010aee0` / `...aee8`). |
| `FUN_18005cb40` | health-check / reconnect handler. Issues `pow/healthcheck/system/all` through request builder `FUN_18005e780`, then sets POW state at `POWmgr[0x6ac]`: **1 = connected, 3 = disconnected** (3 raises the prompt). Also the `PowReconnect` FE-event site. |
| `FUN_18005c970` | fires `POWService::PowBlazeDisconnected` |
| `FUN_1800a8590` | fires `POWService::TriggerPleaseConnectMsg` |
| `FUN_1800ad090` | references `TXT_EASFC_RECONNECT_PROMPT` (the banner) |
**Consequence: POW can be redirected with no root and no `/etc/hosts`** — just serve
`FIFA_POW_URL` from `blaze_responder_v3b.py`.
### Shipped (all OFF by default)
* `tools/pow_server.py` — POW/EASFC server on `:8094` (api) + `:8080` (content).
`POW_MODE=log` (default) answers everything `200 {}` / assets `404` and logs the
exact method+path+headers+body to `/tmp/pow_server.log`; `POW_MODE=serve` adds
first-draft bodies for auth/healthcheck/counts. It knows all 58 extracted path
templates and **flags any path outside that set**, so the capture also tells us
where the extraction was incomplete.
* `blaze_responder_v3b.py` — `OSDK_POW` keys, merged onto **every** CFID (same
reasoning as `FUT_RS4_*`: which section powdll reads is unproven). Empty unless
`FUT_POW=1`.
* `openfut-fut.sh``pow` added to SERVERS (inert while idle: it only binds ports;
nothing points at it until `FUT_POW=1`).
* `root_arm.sh pow` / `root_arm.sh unpow` — the `/etc/hosts` fallback, opt-in
because those entries persist across reboots.
### NOT done — the response schemas
Only the REQUEST side is mapped. No powdll response parser has been walked, so every
body `pow_server.py` returns is a placeholder. **The next step is a capture run:**
```
FUT_POW=1 ./openfut-fut.sh restart # then launch FIFA, enter FUT
tail -f /tmp/pow_server.log # what does POW actually ask for?
```
Whatever appears there turns the schemas from guesswork into reversing targets —
the same route that made the squad work tractable. Instant fallback: drop `FUT_POW`
and restart.
+533
View File
@@ -0,0 +1,533 @@
# FUT Rebuild — research notes (2026-08-03, overnight pass)
Research toward rebuilding FUT end-to-end and standing up the online resources.
Everything here is static RE unless it says "live". **Read §0 before trusting a
row: one bulk technique in this pass failed its own control and was discarded.**
## 0. Confidence / what failed
| finding | confidence | basis |
|---|---|---|
| §1 complete FUT URL table (45 templates) | **HIGH** | read straight out of the table at `0x18021df80`; two entries independently confirmed live (`ut/%s/squad`, `ut/v2/%s/store`) |
| §2 routing gap analysis | **HIGH** | mechanical diff of §1 against `utas_server.ROUTES` |
| §3 POW/EASFC map | **HIGH** | 58 path templates extracted; 16 of them **observed live** in `/tmp/pow_server.log` |
| §4 POW field vocabulary | **MEDIUM** | literal key strings + name-table functions; envelope still unknown (live-refuted once) |
| §5 endpoint→cache pairings | **HIGH** | each issuer function references exactly one path literal + one cache class |
| bulk `FutXServerResponse` → deserializer walk | **DISCARDED** | three attempts; the last "resolved" 18 classes but **mismatched its known-good control** (`FutSquadSave``0x1801631e0`, actual `0x180171a60`) and collapsed several classes onto one address. Not written up. See §6 for why and how to fix. |
## 1. The complete FUT request surface
Table of `{char* template, char* NAME}` pairs at **`0x18021df80`**, 45 rows.
This is the whole UTAS API the client can call:
```
ut/%s/auctionhouse AUCTIONHOUSE ut/%s/purchased PURCHASED
ut/%s/clubUser CLUB_USER ut/%s/store STORE
ut/%s/user/list CLUB_INFO ut/%s/watchList WATCHLIST
ut/%s/club CLUB ut/delete/%s/watchList DELETEWATCHLIST
ut/%s/defid DREAM ut/%s/tradePile TRADEPILE
ut/%s/squad SQUAD ut/%s/trade TRADE
ut/delete/%s/squad DELETE_SQUAD ut/delete/%s/trade DELETETRADE
ut/%s/leaderboards/options LBOPTIONS ut/%s/marketdata MARKETDATA
ut/%s/leaderboards LBDEFAULT ut/%s/clientdata CLIENTDATA
ut/%s/activeMessage PAFPRACTICE ut/auth AUTH
ut/%s UT ut/delete/auth DELETE_AUTH
ut/%s/user USER ut/%s/phishing PHISHING
ut/delete/%s/user DELETEUSER ut/%s/captcha CAPTCHA
ut/%s/item ITEMS ut/%s/tfa TFA
ut/%s/item/resource ITEMS_BY_RES ut/%s/squad/mode SQUADMODE
ut/delete/%s/item DELETEITEMS ut/%s/draft/mode DRAFT
ut/%s/match MATCH ut/%s/champion CHAMPIONS
ut/%s/sbs SBC ut/v2/%s/store V2STORE
ut/%s/tournament TOURNAMENT ut/%s/livemessage LIVEMESSAGE
ut/%s/tournament/user TOURNAMENTUSER ut/%s/season SEASON
ut/delete/%s/tournament/user TOURNAMENTQUIT ut/%s/season/user SEASONUSER
ut/%s/season/%%s/user SEASONUSER_ALTER
ut/%s/season/%%s/reset SEASONRESET ut/%s/season/friendly FRIENDLYSEASON
```
**`%s` expands to `game/<sku>` (i.e. `game/fifa17`).**
**A template is not the whole URL.** Callers append suffixes that never appear
in this table — `ut/%s/squad` + `/list` is a real, live-observed endpoint that cost
us a whole debugging cycle (REBUILD_PLAN §10g), and `ut/%s/user` + `/club` is the
club-rename URL. So treat this table as the *base* set and keep watching the log
for suffixed variants.
## 2. Routing gaps (what currently falls through to the catch-all `200 {}`)
15 of the 45 have no route at all:
| template | NAME | why it matters |
|---|---|---|
| `ut/%s/sbs` | SBC | Squad Building Challenges — a whole game mode |
| `ut/%s/champion` | CHAMPIONS | FUT Champions (the hub tile exists) |
| `ut/%s/draft/mode` | DRAFT | FUT Draft |
| `ut/%s/tournament`, `/user`, delete | TOURNAMENT* | offline cups |
| `ut/%s/leaderboards`, `/options` | LB* | leaderboards |
| `ut/%s/livemessage`, `ut/%s/activeMessage` | LIVEMESSAGE/PAFPRACTICE | in-hub messaging |
| `ut/%s/clientdata` | CLIENTDATA | client blob storage |
| `ut/%s/captcha`, `ut/%s/tfa` | CAPTCHA/TFA | anti-bot + 2FA gates |
| `ut/%s` | UT | API root |
| `ut/v2/%s/store` | V2STORE | routed by regex today, but only the bare form |
Also **`ut/%s/match` is only partially routed** — we answer `match/keepalive` and
`match/reset`; the base `MATCH` endpoint (create/destroy, i.e. **where match rewards
are delivered**) falls through. That is the single biggest hole in the core loop:
without it, playing a match awards nothing.
## 3. POW / EASFC — the online layer
See REBUILD_PLAN §11 for the reversed gate. **Live-confirmed working**: the client
honours `FIFA_POW_URL` from the merged client-config store, connects to our server,
and the "servers unreachable" banner disappears without touching `/etc/hosts`.
**16 endpoints observed live**, in call order:
```
POST pow/auth GET pow/lvl/user/tiergp/businessunit/tiertp/fifa
GET pow/healthcheck/system/all GET pow/lvl/weight/tiergp/businessunit/tiertp/fifa
GET pow/bank/user/account GET pow/store/game/fifa17/catalog/list
GET pow/bank/currency/pow_funds/cap/info
GET pow/store/game/fifa17/catalog/0/item/list?offset=0&count=49 <-- pager
GET pow/inventory/item/list GET pow/store/gift/list
POST pow/user/friends GET pow/pfyc/user
POST pow/pfyc/user/club PUT pow/pfyc/user/prefs/shareinfo
GET pow/mm/game/fifa17/message/list POST pow/v2/activity
```
`pow/auth` request body (live): `{isReadOnly, sku:"FFA17PCC", clientVersion, nuc,
nucleusPersonaId, nucleusPersonaDisplayName, locale, priorityLevel}` — the client
asserts its own identity, same pattern as UTAS auth.
**Debug facility found:** `POW/POW_FORCE_ERROR`, `POW_FORCE_ERROR_CODE` and
`test-http-status-codes.asp?code=%d` (in `FUN_180066410`) — the client can be told
to synthesise POW HTTP errors. Useful for testing our error paths.
## 4. POW field vocabulary (names certain, envelope not)
Literal key strings in powdll, i.e. names its parsers compare against:
* **level** (name table `FUN_180094700`): `level`, `exp`, `currLevelExpMin`,
`currLevelExpMax`, `isMaxLevel`, `dailyXpCap`, `currency`
* **bank** (contiguous field table `0x1800c96b8``0x1800c9928`): `currencies`,
`currency`, `currencyName`, `funds`, `fundsBalance`, `fundsCap`, `fundsCapInfo`,
`fundsEarned`, `accountBalance`, `balance`, `numCurrency`
* **list envelope** (`FUN_180094560`): `numItems`, `numOwnedItems`,
`numLockedItems`, `numCurrency`
* **catalog item** (`FUN_1800945c0`, 21 fields): `category`, `name`, `description`,
`type`, `subtype`, `price`, `level`, `isConsumable`, `isPurchased`, `isPromotion`,
`isGiftable`, `isLocked`, `ownedQuantity`, `maximumQuantity`, `assetPath`,
`smallAssetPath`, `fccAvailable`, `itemCount`, `itemsTotal`, `itemsOwned`, `error`
**NOT present in powdll** (do not send — they parse as nothing): `personaId`,
`personaName`, `userId`, `sessionId`, `displayName`, `personaList`.
**Open:** the top-level envelope. Serving the level record at the JSON root was
live-tested and ignored (hub still read `LVL: 0/0`). A one-launch probe is armed
(record at root + under `data`/`result`/`content` + as a 1-element array).
## 5. Endpoint → cache-class pairings (powdll)
| endpoint | issuer | cache class |
|---|---|---|
| `pow/lvl/user/tiergp/%s/tiertp/%s` | `FUN_180020250` | `UserLevelCacheData` |
| `pow/bank/user/account` | `FUN_18001aeb0` | `CurrencyCacheData` |
| `pow/nucleus/entitlements` | `FUN_180066410` | — (+ error-injection) |
| `pow/auth` | `FUN_180068dd0` | — (`Post`, `%s/%s`) |
| `pow/healthcheck/system/all` | `FUN_18005cb40` | sets `POWmgr[0x6ac]` 1=on/3=off |
Other cache classes seen, not yet paired: `CatalogListCacheData`,
`CatalogCacheData`, `LevelWeightCacheData`, `FriendsLevelCacheData`,
`CurrencyCapCacheData`, `ChallengeProgressCacheData`.
## 6. Next research steps (in priority order)
1. **`ut/%s/match`** — create/destroy and the reward payload. Biggest core-loop
hole; without it matches award nothing. Needs the response schema.
2. **Fix the class→deserializer walk.** The three failed attempts assumed MSVC RTTI
(`TypeDescriptor`→COL→vtable); EA's layout is its own: the class-name literal
sits *after* the vtable (`FutSquadSave`: vtable `0x18022c560`, name
`0x18022c61c`), with a descriptor block before it containing 32-bit RVAs and the
`0x19930522` signature. The reliable path is the one used successfully for the
squad family: locate the *factory* function first, then its vtable. Do that per
class rather than by scanning, and **always keep a known-good control in the
batch** — that control is what caught the bad results here.
3. **POW envelope** — settled by the armed probe on the next launch.
4. **dbdata.dll** — imported and analysed at `/tmp/fifadb/dbproj` (2.6 MB, loose).
This is the player database: real names/ratings/clubs for the club, market and
packs, replacing `fut_store.PACK_POOL`'s 18 hand-entered players.
5. **Objectives / Manager Tasks** — the hub tile reads `0/0`; no `FutGetObjectives`
RTTI string exists, so the feed is something else (possibly `clientdata` or a
POW challenge endpoint: `pow/chal/user/prog`, `ChallengeProgressCacheData`).
## 7. Core loop IMPLEMENTED — match lifecycle + rewards (2026-08-04)
The `ut/%s/match` hole from §2 is closed. The schemas did **not** need re-reversing:
`ENDPOINT_MAP.md` already carried them at CONFIDENCE: HIGH from an earlier pass —
checking the docs first saved a full reversing cycle, and is worth doing before any
"unrouted endpoint" is treated as unknown.
| verb + path | response class | deser | what we serve |
|---|---|---|---|
| `POST ut/%s/match` | FutCreateMatch | `0x180120380` | `{startDateTime, reportIdEnabled, id}` |
| `PUT ut/%s/match/{id}` | FutMatchReady | none | `{}` |
| `POST ut/%s/match/{id}` | FutPlayGame | none | `{}` (client SENDS the result here) |
| `DELETE ut/%s/match/{id}` | **FutDestroyMatch** | `0x180121b60` | **the rewards** |
`ut/delete/game/<sku>/match/...` is routed too — UTAS tunnels DELETE through a
`/ut/delete/` path prefix (same as trade/watchList/squad).
Reward body — every field a top-level scalar, so zero freeze risk:
`coins`(149) `allCoins`(20) `matchCoins`(436) `seasonCoins`(670)
`tournamentCoins`(809) `boostConis`(96 — EA's typo, exact key)
`participationAward`(529) `qualifiedChampionEventId`(617)
`teamOfTournamentWinner`(776,bool). The nested members `gameModeAward`(310),
`matchCoinMultipliers`(437) and `userData`(877) are deliberately **omitted**:
all three are SKIP-safe, and `userData` is a documented freeze-risk (must be an
object if present), so not sending it is strictly safer.
Verified offline, full lifecycle: create → ready → play(2-1) → destroy gave
`+400 coins`, balance `12600 → 13000`, hub record `0-0-0 → 1-0-0`. Profile then
restored to its pre-test state; 380 contract checks still green.
**Payout amounts are OURS, not reversed** — the server decides. Defaults
win/draw/loss = 400/200/100, tunable via `FUT_MATCH_COINS_WIN|DRAW|LOSS` and
`FUT_MATCH_PARTICIPATION`.
**Known gap:** the *request* shape for PlayGame/DestroyMatch is not reversed — only
the response side is. `_match_result()` probes the plausible spellings
(`goals`/`opponentGoals`, `score`/`opponentScore`, nested `match`/`stats`, textual
`result`) and **falls back to a draw**, the neutral outcome — it credits and records
without inventing a win. Every request body is logged, so the first real in-game
match reveals the true shape and the fallback can be replaced with the actual field.
**Not added to the contract suite:** the loop is inherently mutating (it credits
coins and bumps the record), and that suite is meant to stay read-only. Verified by
hand instead; if it needs regression cover, extract the reward body into a pure
function and unit-test that rather than making the HTTP suite stateful.
## 8. Game-mode gaps implemented (2026-08-04, loop iteration 1)
The remaining 15 unrouted templates from §2 are now routed. Again **no new
reversing was needed** — `ENDPOINT_MAP.md` already carried the schemas. That is
twice in a row; treat "unrouted" as "check the docs" before "reverse it".
| endpoint | class | deser | confidence | body |
|---|---|---|---|---|
| `GET /season` | FutSeasonList | `0x180167740` | HIGH | **ARRAY** root of season descriptors |
| `GET /season/user` | FutSeasonLoadData | `0x180131450` | HIGH (switch traced) | `{seasonId, divisionId, round, userPoints, dataVersion, data}` |
| `/season/{id}/reset` | FutResetMatch | `0x18016fd10` | HIGH | `{"reset": true}` |
| `GET /tournament` | FutTournamentList | `0x180169ef0` | MEDIUM | **ARRAY** root |
| `GET /tournament/user` | FutTournamentLoadData | `0x180147cb0` | MEDIUM | `{round, dataVersion, tournamentData}` |
| `GET /leaderboards` | FutGetLBEntries | `0x180144c8d` | MEDIUM | `{"entries": []}` |
| `GET /leaderboards/options` | FutGetLBOptions | `0x18014351c` | MEDIUM | `{category, id, period, view, url}` |
| `POST /champion` | FutChampionsRegistration | `0x18014980d` | MEDIUM | `{}` (no atoms) |
| `GET /champion` | FutGetChampionsTopX | `0x18014a09d` | MEDIUM | `{"entries": []}` |
| `GET /captcha` | FutGetCaptcha | `0x18014e78d` | MEDIUM | `{encodedImg, sequence, sizeBeforeEncode}` |
Plus plain acks now routed instead of falling through: `/tfa`, `/clientdata`,
`/livemessage`, `/activeMessage`, `ut/delete/.../tournament`.
**Gated behind `FUT_MODES=1`, default OFF.** Every one of these is
documented-but-never-live-tested, and both of today's regressions were "serve a new
body the client has never parsed". `FutSeasonList` in particular wants an **array
root** where we currently send `{}` on a boot-adjacent path — precisely the shape
class that busy-loops at `0x1801c7f1a` when wrong. Verified both ways offline:
default is byte-identical `{}` everywhere, `FUT_MODES=1` serves the documented
bodies, 380 checks green in both. `/captcha` is served unconditionally (three
scalars, strictly better than `{}`).
Nested members are omitted throughout — `prizeSet`, `elgReq`, `friendlySeasonHistory`,
tournament `rounds`/`staff`/`kit` are all SKIP-safe and all FREEZE-RISK if wrong.
### Remaining gaps, ranked
1. **POW envelope** — blocks LVL/credits; the armed probe settles it in one launch.
2. **Match request shape** — response side done; the request (score/result) is
unreversed, `_match_result()` falls back to a draw until a real match is logged.
3. **SBC / Draft** — the only families with NO documented schema. `FutSBC*` and
`FutGetDraft*` still need a deserializer walk, and all four attempts at a generic
walk have now failed (see §6.2 — the last one, via factory name-LEAs, got
`cands=0` because Ghidra creates no references from those LEAs: the name strings
are not defined data). Next idea: define the strings as data first (or scan
`.text` for the RIP-relative LEA encoding directly), then re-run the factory walk.
4. **dbdata.dll** — imported at `/tmp/fifadb/dbproj`, untouched: real player names/
ratings to replace the 18 hand-entered `PACK_POOL` entries.
## 9. Class → deserializer SOLVED: the -4 rule (loop iteration 2)
Six attempts. The fix is one subtraction.
**A response class's name literal is preceded by a 4-BYTE HEADER, and the factory's
`lea r8,[rip+...]` points at THAT header — not at the text.** Ground truth:
```
0x18012170c LEA R8,[0x18021d690] <- the factory's reference
0x18021d694 "FutDestroyMatchServerResponse" <- the string, 4 bytes later
```
Every earlier attempt looked up the string address itself and got **zero
candidates** — which four times looked like "this class has no deserializer" rather
than "my lookup is off by four". Ghidra had the reference all along; the manual
RIP-relative LEA decoding in attempt 5 was unnecessary.
Now `ghidra_env.class_deser(cls)` implements it: `xrefs_to(name-4)` → factory →
the `.rdata` vtable it references → deserializer at slot `+0x08`.
**Reliability, measured:** 3/3 correct whenever it resolves (controls
FutSquadSave `0x180171a60`, FutSquadList `0x180172140`, FutCreateMatch
`0x180120380`), but it produces **false negatives** — FutDestroyMatch and
FutSeasonLoadData resolve to nothing despite having known deserializers. So an
empty result means "unknown", never "no deserializer". Always batch with a control.
### SBC + Draft schemas (11/15 resolved — the last families with no docs)
| class | deser | parsed keys |
|---|---|---|
| `FutGetDraftCurrentState` | `0x180147070` | `roundsInfo`(0x293) + the draft state enum: `CAPTAIN_DRAFT`, `FORMATION_DRAFT`, `PLAYER_DRAFT`, `MANAGER_DRAFT`, `COMPLETED_DRAFT`, `READY_FOR_MATCH`, `READY_FOR_REWARDS`, `PICK_DIFFICULTY`, `INVALID` |
| `FutSBCSubmitChallenge` | `0x180161b00` | `active`(0xa), **`grantedChallengeAwards`**(0x14a) — the SBC reward key |
| `FutGetDraftStats` | `0x1801508c0` | `active`, `draftsCompleted`(0xe2), `scoredGoals`(0x29b) |
| `FutLoadSetTypes` | `0x180154990` | `active`(0xa) |
| `FutSBCLoadCategoryDetails` | `0x18017b2b0` | (no named atoms recovered) |
| `FutSBCSetData`, `FutPickDraftChoice` | `0x1801642c0` | **shared no-op** (`return 1`) → `{}` is complete |
| `FutSBCTagSets`, `FutSBCSaveSquadChallenge`, `FutPickDraftAutoChoice`, `FutPurchaseDraftMode` | various | no atoms → `{}` is complete |
Unresolved (false negatives): `FutGetDraftChoices`, `FutGetDraftAward`,
`FutGetObjectives` (no RTTI string at all — the Manager Tasks feed is something
else), `FutStickerBookSearch`.
**Practical upshot:** most SBC/Draft responses are genuine acks, so the catch-all
`{}` is already *correct* for them — the families were never as blocked as they
looked. The two that carry real data are `FutGetDraftCurrentState` (a state machine)
and `FutSBCSubmitChallenge` (`grantedChallengeAwards`, the SBC payout).
`0x1801642c0` recurring is not a collision: it is the shared no-op deserializer,
the same one FutSquadRename/FutSquadDelete/FutChangeClubName use.
## 10. Traffic-replay coverage audit (loop iteration 3)
`dbdata.dll` was the planned target but is a poor investment: it carries **no table
or column name strings at all** (7,960 identifiers, zero matching player/team/league
or overall/firstname/commonname), so the schema is not in the DLL and extraction
would be a large effort for cosmetic gain. Earlier work got real assetIds from the
*live* InGameDB instead — that remains the cheaper route. Deprioritised.
Instead: extracted every distinct `(method, path)` the client has ever sent across
all sessions in `/tmp/utas_server.log`**48 pairs** — and diffed them against the
route table. That surfaced **three live paths nobody knew about**, none of which
appear in the request-template table (§1):
```
PUT ut/game/fifa17/clientdata/userHubData (FUT hub, 20:40 session)
GET ut/game/fifa17/club/stats/consumables (MY CLUB screen)
GET ut/game/fifa17/club/stats/staff
GET ut/game/fifa17/club/stats/year
```
This is the third time a suffix endpoint has been invisible to static analysis
(`/squad/list`, `/user/club`, now these). **The request-template table is a floor,
not a ceiling — the log is the only ground truth for what the client actually calls.**
### Fixed
**`club/stats/*` was answering with the wrong body.** It fell through to the generic
`/club` route, which returns the FULL 28-item club item list — where the client asked
for stats, and re-sent on every poll. Now routed ahead of `/club` and answered `{}`
(no schema is documented; `{}` is the proven-safe default). `/club` itself unchanged.
**`clientdata/<key>` now persists.** The client PUTs its own hub state; we store the
blob and hand back exactly what it gave us. Zero-risk by construction — we never
synthesise a shape, only echo the client's own bytes. This is also the most plausible
route to the hub's `MANAGER TASKS 0/0` tile persisting, since §9 established there is
**no `FutGetObjectives` class in the binary at all** — the tile state may simply live
in this blob rather than in a server response.
Verified: `PUT` then `GET` round-trips the blob byte-for-byte; `club/stats/*` returns
`{}`; `/club` still returns the item list; 380 checks green. Test blob removed from
the profile afterwards.
## 11. Pre-test regression proof + match unit tests (loop iteration 4)
Before the morning live test, the useful work was proving that everything added
overnight (match loop, game-mode ladder, clientdata, club/stats, POW) did **not**
change what the client sees.
### Regression diff against the last fully-good session
Extracted what we actually SENT during the 20:40 session — the one that was good in
every respect (hub, coins, record, active squad, MY SQUADS: 1, `PUT /squad/0`) — and
replayed every non-mutating request against the current defaults:
```
identical: 19 differing: 0 errors: 0
```
`PUT /squad/0` was re-checked separately against a scratch profile (never the real
save) and still answers `{"id": 0}`. So the morning test starts from a state
byte-identical to the last known-good one, with the new routes reachable only via
their env flags.
This diff is worth re-running after any batch of route changes:
extract `(verb, path) -> response` from a known-good window in
`/tmp/utas_server.log`, replay the GETs, compare prefixes.
### `tools/test_match_rewards.py` — 51 checks, pure
The match loop mutates (credits coins, bumps W/D/L), so it cannot go in the
read-only HTTP contract suite. `destroy_match_body()` is now split out of
`match_route()` as a pure function and tested with no server, no state, no profile:
* **`_match_result()`** — 14 scorelines including nested `match`/`stats` bodies,
textual `WIN`/`defeat`/`tie`, and the two fallback cases (`{}` and `None` → draw).
Also pins that **0-0 is a draw WITH a score**, not "no data" — the one case where
a sloppy truth-test would silently reclassify a real result as unknown.
* **`destroy_match_body()`** — every field scalar (a non-scalar here is the freeze
class at `0x1801c7f1a`), `allCoins` is the NEW balance, the nested
`userData`/`gameModeAward`/`matchCoinMultipliers` stay omitted, and — load-bearing —
the key is EA's misspelled **`boostConis`** (atom 96), not `boostCoins`. A renamed
key is SKIP'd silently, i.e. the reward would vanish with no error anywhere.
Suites now: `test_fut_contract.py` 380 (live, read-only) + `test_match_rewards.py`
51 (pure). Both green.
## 12. The pack→club hand-off is unverified (loop iteration 5)
Noticed in the save: **12 cards sitting in the PENDING pile** (`profile["purchased"]`)
with 6 packs opened, and `PUT ut/%s/item` (FutMoveCard) fired **zero times** across
every logged session.
**Corrected reading before drawing a conclusion:** all the store traffic in the logs
is `PUT /ut/v2/game/fifa17/store/transaction/0` with body
`{"state":"TRANSACTIONCANCEL"}` — that is the boot-time cancel of a pending
transaction, **not a purchase**. So no pack has been bought in-game in any logged
session, and the 12 pending cards are leftovers from earlier work. The correct
conclusion is therefore **"the pack→club loop is UNVERIFIED", not "it is broken"** —
the evidence does not support the stronger claim.
It remains a real gap: cards from an opened pack only reach the club when the client
sends `PUT ut/%s/item` from the reveal screen's "send to club", and that request has
never been observed. Structurally this is the same shape as the squad blocker — an
assumed client request that may simply never arrive.
### `tools/fut_admin.py` — offline save maintenance
- `--show` (default): full profile summary incl. the pending pile
- `--flush-purchased [-n]`: move pending cards into the club, `-n` = dry run
- `--backup`: timestamped copy
**Safety:** the client desyncs fatally (logout) if a card exists in BOTH the pending
pile and the club (docs/CARD_SYSTEM.md). So the flush **moves, never copies** — it
reuses `Store.move_items()`, which deletes from `purchased` inside the same locked
transaction that appends to `items`, keeping that invariant in exactly one place.
It takes a backup first and must be run with FIFA closed.
**Not run.** Only `--show` and a dry run were executed; the save is untouched
(coins 12600, 28 club items, 12 still pending, no backup file written). Flushing
changes the user's save, so it is their call — and if a live pack-open turns out to
issue `PUT /item` correctly, the flush is unnecessary.
### Live test worth adding to the morning list
Buy a pack in-game and watch `/tmp/utas_server.log` for `PUT /ut/game/fifa17/item`.
If it appears, the loop works and `fut_admin.py` is just a repair tool. If it does
not, the reveal screen is another client-side gate to reverse — and the log will show
what it sends instead.
## 13. Response audit: market schema + unparseable-key sweep (loop iteration 6)
**Market/trade responses are already exact.** The core auction record
(`auctionInfo[]` element, deser `0x18013e410`) documents **12** atoms —
`tradeId`, `itemData`, `tradeState`, `bidState`, `buyNowPrice`, `startingBid`,
`currentBid`, `expires`, `sellerName`, `sellerEstablished`, `watched`,
`coinsProcessed` — and `_auction_record()` serves exactly that set, with the right
types throughout (`expires` as SECONDS not epoch, `sellerName` inside the 30-char
bound, `itemData` an object, the two enums as strings). The list bodies
(`{auctionInfo, credits, total, duplicateItemIdList}`), `FutISStart` (`{id}`) and
`FutISViewTrade` (`{auctionInfo, credits}`) all match too. Nothing to fix.
**Unparseable-key sweep.** Walked every key we serve across 15 endpoints (nested,
to depth 4) and checked each against `fut_atoms.tsv`. Anything absent from that
table can never be read: the key hash misses and it routes to the value-SKIP handler
`0x180135ff0`.
Result — **only 2 inert keys in the entire response surface**:
| key | where | verdict |
|---|---|---|
| `definitionId` | every card item | INERT — not an atom. The live key is `resourceId`. |
| `limitType` | store catalog entry | INERT — not an atom. |
Both are harmless (SKIP'd, no freeze risk) and are kept — other FIFA versions do use
`definitionId`. But `fut_seed.player_item` carried a comment claiming *"some FUT APIs
key on definitionId"*, which is wrong for FIFA 17 and would mislead the next reader
into treating it as load-bearing. Corrected in place, citing the same phantom-key
precedent as `itemDbVersion`/`checkServerDbVersion` in `blaze_responder`.
That 2-out-of-everything ratio is the useful headline: the response surface is clean,
so any remaining live misbehaviour is about *shape/envelope* or *missing endpoints*,
not stray fields.
## 14. Live morning session (2026-08-04) — store fixed, move path unsolved
### 14a. Store "unknown" packs — SOLVED
The store rendered every tile as `unknown` with `0 ITEMS / 0 BRONZE / 0 RARES`.
`"unknown"` is not an error string: `FUN_180133f60` constructs a FUT String with that
literal **unconditionally** — it is the DEFAULT, shown whenever nothing overwrites it.
What overwrites it is **`displayGroup`(0xd9)**, and the structural point is that it is
parsed by the SAME element parser `0x18013af30` **recursively**: a display group is
itself a pack-shaped object carrying the tile's name and aggregate counts. The store
screen renders GROUPS, not raw packs. We never sent `displayGroup`, so the client
built a default group → `unknown` / zeroes.
Fix: every pack now carries a single-entry `displayGroup` array (freeze-risk if
scalar) plus `displayGroupAssetId`(0xda) and `displayGroupUseDefaultImage`(0xdb).
**Two ENDPOINT_MAP corrections** from re-extracting `0x18013af30`:
* It claims `id`/`packType`/`isPremium`/`quantity`/`saleType`/`purchaseLimit`/
`purchaseCount` are skipped no-ops. **They are all parsed** (0x15c, 0x20f, 0x176,
0x26b, 0x298, 0x265, 0x261).
* It claims `extPrice.finalPrice`/`originalPrice` take `amount`/`currency`. The inner
parsers `0x180139070`/`0x18013aae0` read **`externalPriceId`**(0x11a) + `active`;
our `{"amount":N,"currency":"mtx"}` was discarded wholesale.
### 14b. Quick Sell — was silently free
`Quick Sell All` sends `POST ut/delete/%s/item`, which was UNMAPPED. The catch-all
`{}` is ACCEPTED by the client (no error, session survives) but nothing was credited:
six cards destroyed for 0 coins. Now routed to `quick_sell_route()`, crediting
`discardValue` with a rating-based fallback (600/300/150/50) since seeded cards have
none. Verified: +600 for an 86-rated card.
### 14c. "Send to Club" — UNSOLVED after 7 attempts
`PUT ut/%s/item` moves the cards server-side every time, then the client shows
*"We are sorry but there has been an error connecting to FIFA 17 Ultimate Team"* and
POSTs `ut/delete/auth`. **Eliminated, each by live test:**
| hypothesis | result |
|---|---|
| missing `chemistry`(0x81) | failed without it too |
| unknown keys + no skip handler in `0x180128600` | real finding (it genuinely has none), but `dreamSquads`-only still failed |
| POW saturating the HTTP layer (109k reqs) | same failure with POW off |
| missing `FUT_RS4_URL_<CALL>` keys | netwatch logged **ZERO** non-loopback dials; 146 were genuinely missing and are now served, but they were not the cause |
| the response body at all | **`{}` fails too** |
| the reveal screen's exit path | **Quick Sell works from the same screen** |
So the move endpoint rejects every possible response while its sibling accepts a bare
`{}`. `"error connecting"` is FIFA's GENERIC FUT-session failure text, not a network
event — do not read it literally (that inference cost two wasted attempts).
**Workaround shipped:** `FUT_PACK_AUTOCLUB=1` (default) deposits pack contents
straight into the club at open time and keeps the pending pile empty, so the client is
never offered a move. Packs are fully usable. `FUT_MOVE_BODY=empty|dreamsquads|full`
switches the response shape for future bisects without a code edit.
**Next idea, untested:** single-card `S` (Send to Club) vs batch `W` (Send All) —
if single works, it is a batch/count issue, a completely different target.
### 14d. Process note
Four of six failed hypotheses were things testable before proposing them. The two
findings that actually moved this forward were the USER's: the screenshot with the
error text, and the Quick Sell result. **Ask for the on-screen text and try the
neighbouring action FIRST** — both were cheaper than any decompile done here.