Files
OpenFUT/fifa17-recon/docs/REBUILD_RESEARCH.md
T
funman300 5d5198f5d1 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
2026-08-04 09:42:59 -07:00

30 KiB
Raw Blame History

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 (FutSquadSave0x1801631e0, 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 0x1800c96b80x1800c9928): 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 neededENDPOINT_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.log48 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.