# 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/` (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//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/` 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_` 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.