MY CLUB -> ENGLAND -> Premier League now reads 17. First non-zero number ever rendered
on that screen. Nothing froze, no other screen changed, 392 + 61 checks green with the
flag defaulted on and no env override.
S19 concluded "the MY CLUB counter is not server-fixable". That was too broadly scoped.
The nation and league drill-downs ARE server-fixable and are now fixed. S20 records the
corrected scope.
What made it work, from FUN_180043b90 case 3:
uVar7 = (**(param_2 + 0x18))(param_2, row, "LEAGUE_ID") THE UI ROW'S OWN ID
bronze/silver/gold = (+0x7f8)(store, uVar7, 2 / 3 / 4)
publish("PLAYERS_EMPLOYED", gold + silver + bronze) COMPUTED, never read
rare/kits/badges = (+0x7f8)(store, uVar7, 5 / 0x28 / 0x2d)
Still open and now correctly scoped: the hub tile's "0 TOTAL PLAYERS" and the MY CLUB
summary rows read the GLOBAL bucket via +0x800 in cases 1 and 5. We serve those rows.
The unchanged question is what SELECTS those cases, since the mode tag is copied from
the completed request and the client requests year, consumables, staff, country/<id>
and league/<id> but never club.
The method note, which is the durable part: two rounds of reasoning about this endpoint
produced two wrong bodies; twenty lines of the consumer produced the right one. Reading
the PARSER tells you what is accepted. Only reading the CONSUMER tells you what is used.
That question was answerable from the start and went unasked until live screenshots
forced it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
50 KiB
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)
ut/%s/match— create/destroy and the reward payload. Biggest core-loop hole; without it matches award nothing. Needs the response schema.- 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: vtable0x18022c560, name0x18022c61c), with a descriptor block before it containing 32-bit RVAs and the0x19930522signature. 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. - POW envelope — settled by the armed probe on the next launch.
- 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, replacingfut_store.PACK_POOL's 18 hand-entered players. - Objectives / Manager Tasks — the hub tile reads
0/0; noFutGetObjectivesRTTI string exists, so the feed is something else (possiblyclientdataor 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
- POW envelope — blocks LVL/credits; the armed probe settles it in one launch.
- 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. - SBC / Draft — the only families with NO documented schema.
FutSBC*andFutGetDraft*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, gotcands=0because Ghidra creates no references from those LEAs: the name strings are not defined data). Next idea: define the strings as data first (or scan.textfor the RIP-relative LEA encoding directly), then re-run the factory walk. - dbdata.dll — imported at
/tmp/fifadb/dbproj, untouched: real player names/ ratings to replace the 18 hand-enteredPACK_POOLentries.
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 nestedmatch/statsbodies, textualWIN/defeat/tie, and the two fallback cases ({}andNone→ 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 at0x1801c7f1a),allCoinsis the NEW balance, the nesteduserData/gameModeAward/matchCoinMultipliersstay omitted, and — load-bearing — the key is EA's misspelledboostConis(atom 96), notboostCoins. 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/purchaseCountare skipped no-ops. They are all parsed (0x15c, 0x20f, 0x176, 0x26b, 0x298, 0x265, 0x261). - It claims
extPrice.finalPrice/originalPricetakeamount/currency. The inner parsers0x180139070/0x18013aae0readexternalPriceId(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 |
RETRACTED, FALSE -- it has TWO skip handlers, see S16 |
| 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.
15. SURPRISE FOUR: ut/%s/squad/mode/draft/state (2026-08-04, observation session)
Recorded immediately per the session checklist. A live client request to a URL that is not in the 45-template table.
[10:35:28] GET /ut/game/fifa17/squad/mode/draft/state
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows) <- the real client
[10:35:28] -> 200 {"id":0,"personaId":...,"squadName":"OpenFUT","formation":"f442",...}
The template table holds ut/%s/squad/mode (SQUADMODE) and ut/%s/draft/mode (DRAFT)
as separate entries. The real endpoint is a suffixed composition of neither:
squad/mode + /draft/state. This is the fourth time a suffix endpoint has been
invisible to the static table (squad/list, user/club, club/stats/*, now this).
What we answered, and why it is wrong
Our generic G + r"/squad" route matched first and returned the full active-squad
object (23 slots, nested itemData, the 33-int custom string) to an endpoint asking for
DRAFT STATE. Identical bug class to club/stats/*, which was found the same way and
returned the entire club inventory to a stats endpoint.
Per §9, FutGetDraftCurrentState (deser 0x180147070) parses roundsInfo(0x293) plus a
state enum: CAPTAIN_DRAFT, FORMATION_DRAFT, PLAYER_DRAFT, MANAGER_DRAFT,
COMPLETED_DRAFT, READY_FOR_MATCH, READY_FOR_REWARDS, PICK_DIFFICULTY, INVALID.
Feeding that parser a squad object is a textbook type-mismatch freeze candidate.
Status
PROVEN: the client requested this URL; we answered with a squad object; the game
became unresponsive immediately afterwards. FIFA17.exe was still running with no
crash dump written, so this was a hang, not an access violation.
NOT PROVEN: that the wrong body caused the hang. It is the obvious candidate and the timing is exact, but no measurement has isolated it. Do not record this as the cause until something confirms it.
NOT FIXED. Observed during an assessment-only session; recorded and left alone deliberately.
Also observed this session
Single Player -> Seasons raises "There was a problem communicating with the FIFA
Ultimate Team servers" with ZERO requests to any layer (UTAS, Blaze and POW logs all
show nothing but unrelated pings and one CensusData::subscribeToCensusDataUpdates).
Nothing failed because nothing was asked. This is the third time FIFA's error text has
described a network problem that did not happen; the message is a generic client-side
refusal. POW/EASFC was OFF this session and is the leading untested hypothesis for the
gate, disconfirmable in one launch with FUT_POW=1.
16. RETRACTION: FutMoveCard's "no skip handler" claim was FALSE
Retracted 2026-08-04. Sections 14c and the utas_server.item_route comment
claimed:
FutMoveCard
0x180128600HAS NO SKIP HANDLER. Every other FUT deserializer routes an unrecognised key toFUN_180135ff0; this one calls it ZERO times. It parses exactly two atoms,itemDataanddreamSquads.
Every part of that is wrong. Verified on a full decompile:
FUN_180135ff0 call sites : 2 (offsets 5006 and 6080)
atoms parsed : 7 active, dreamSquads, id, itemData, pile, reason, success
Cause of the error: the decompile was written out as src[:4000] and then
searched. The function is 6193 characters. Both skip-handler call sites and four
of the seven atoms lie beyond the cut. An absence was reported from a truncated
listing.
Lesson, and it has now cost twice: this is the same failure mode as the
Memory.getBytes bytearray scan that silently returned zero hits. Never conclude an
absence from a truncated or unverified-length extraction. Print the length, or
assert the region searched covers the whole function.
Cost: the false claim implied "any extra key desyncs this parser", which pointed the whole investigation at client-side state for seven attempts. The runbook premise "the deciding factor is client-side state, not the wire" derived from it and was also wrong.
The verified schema
PUT ut/%s/item is not an ack endpoint. It returns per-item VERDICT records:
{"itemData":[{"id": 100000123, "pile": "club", "success": true}]}
| atom | key | getter | destination |
|---|---|---|---|
| 0x15c | id |
INT 0x1801c79d0 |
record+0x00 |
| 0x226 | pile |
STRING 0x1801c7aa0 -> enum 0x180142650 (club=7, purchased=6, trade=5) |
record+0x08 |
| 0x2fa | success |
BOOL 0x1801c7620 |
record+0x0c |
| 0x279 | reason |
STRING; "Destination Full" maps to 0xf |
record+0x10 |
| 0xe9 | dreamSquads |
INT array | |
| else | FUN_180135ff0 (skip) |
The completion handler raises EVENT_CARDS_MOVE_CARD_FAILURE when the record vector
is empty or when record+0x0c != 1, and success is initialised to '\0' at
the top of every element loop. So every body ever returned reported failure:
full cards, +chemistry, dreamSquads-only, and {} (zero records, fails the first
guard outright).
Quick sell survives an identical {} because its callbacks check only the transport
error code and never read the body. That is the entire asymmetry, and it was on the
wire the whole time.
Status: necessary condition identified, sufficiency UNTESTED. Staged behind
FUT_MOVE_BODY=ack, default still empty. One launch settles it.
17. SOLVED: "Send to Club" (2026-08-04, one launch)
FUT_MOVE_BODY=ack, FUT_PACK_AUTOCLUB=0. Bought a bronze pack, opened it, chose
Send to Club. The session survived. The five cards persisted into the club pile
and the client carried on to the hub and then into MY CLUB.
11:15:42 POST /purchased/items pack bought, 8400 -> 8000
11:15:43 GET /purchased/items 5 items in the pending pile
11:15:48 PUT /item
req {"itemData":[{"id":100000125,"pile":"club","swap":0,"tradeId":0}, ... x5]}
res {"itemData":[{"id":100000125,"pile":"club","success":true}, ... x5]}
11:15:49 GET /user/credits session alive
11:15:51 GET /hub no error dialog
11:16:05 GET /club/stats/{staff,year,consumables}
11:16:06 GET /club?year=2017&type=player&count=11&... MY CLUB opened
No ut/delete/auth. Every one of the seven previous attempts logged that logout
within a second or two. This run has none. Profile on disk afterwards: 109 items, 86
in the club pile, all five new ids present.
What this settles
The failure was always the response body. The client was told, by every body this
project ever returned including a bare {}, that the move had failed, and it
ended the FUT session because that is what EVENT_CARDS_MOVE_CARD_FAILURE does. The
"deciding factor is client-side state, not the wire" premise recorded in §14c was
wrong, and §16 explains exactly which truncated decompile produced it.
Both defaults are flipped in utas_server.py: FUT_MOVE_BODY=ack, and
FUT_PACK_AUTOCLUB now defaults off.
The request shape
{"itemData":[{"id":100000125,"pile":"club","swap":0,"tradeId":0}, ...]}
swap and tradeId accompany id and pile. We ignore both and the move succeeded,
so neither is load-bearing for a pending-pile-to-club move. TODO/CONFIRM what swap
means for a squad-slot exchange, where it plausibly is.
Correction to the first version of this section, which called this shape "captured
for the first time" because "the client had never successfully reached this path". That
is wrong. futlog.py over the full history shows nine client PUT /item requests,
eight of them before today, all carrying swap and tradeId:
08:45:11 08:51:13 08:55:21 08:58:19 09:10:52 09:15:26 09:22:31 09:37:12 11:15:48
the seven failed attempts the fix
The request was on the wire and in the log the entire time. What was new today was not the capture, it was reading it. That is the same class of error as the truncated decompile in §16: evidence already collected, not looked at.
Process note, and it is not a small one
The first attempt at this test produced no PUT /item at all. FUT_PACK_AUTOCLUB=1
had already emptied the pending pile at purchase time, so the reveal screen had
nothing to assign and the client never issued the request. The workaround for the bug
was hiding the bug: it removed the only path that exercises the broken endpoint.
The lesson generalises. A workaround that suppresses a request suppresses the evidence too. Before testing a fix, check that the configuration still lets the client make the call the fix is for.
Two self-inflicted incidents in the same session, both worth recording:
- The server was restarted to inject the flag while FIFA was already running, and
the user walked into the FUT hub during the roughly 30-second window with nothing on
:8099. That produced the exact "error connecting to FIFA 17 Ultimate Team" dialog
this project has spent weeks chasing, from a plain connection refusal. Restart only
when the client is at the main menu, and check the log for
ProtoHttprequests before attributing a failure to a response. pgrep -f/pkill -fmatched the running shell twice, because the command that ran the pattern also contained the literal script name in a later clause. It killed the invoking shell before it reached the restart. Split the kill and the start into separate commands, or obfuscate every occurrence.
18. Log-archaeology findings (2026-08-04, futlog.py)
Filtering the full 3,044-request log to real client traffic (486 requests, User-Agent
ProtoHttp) surfaced three things that were sitting in evidence already collected.
18a. GET /club is only ever the SEARCH form, and itemData is right for it
The client has requested /club six times, every one of them with a query string:
/club?year=2017&type=player&count=34&position=ST&level=any&nation=-1&league=-1&team=-1&sort=desc
/club?year=2017&type=player&count=34&position=RB&level=any&...
/club?year=2017&type=player&count=34&level=any&...
/club?year=2017&type=player&count=11&level=any&sort=desc (x3)
The bare path /club has never been requested. Zero times.
This contradicts ENDPOINT_MAP.md, which states that GET ut/%s/club resolves to
FutGetClubInfoServerResponse, that its only recognised member is user(0x36c) as an
array of user-records, and therefore that our {"itemData":[...]} is skipped and the
club list must be empty. The club list is not empty: the user opened MY CLUB and
every card rendered. So either the query form dispatches to a different response class,
or the row is wrong. TODO/CONFIRM which.
Two consequences worth chasing:
- We ignore the query string entirely and return all 109 items to a request that
asked for
count=11with position and sort filters. That happens to work for rendering, but a search response that carries a result total is exactly the sort of place the MY CLUB counter would read from, and ours carries no count of any kind. position=STandposition=RBappear only in the 20:15 pair, which is squad-slot filtering. The client is using this endpoint as a player picker, not just a list.
18b. The move request had been captured eight times before it was read
See the correction in §17. PUT /item appears nine times in the log, eight of them
during the failed attempts, every one carrying swap and tradeId. The shape was never
missing; nobody looked.
18c. Endpoints the client wanted that fell through to the catch-all
futlog.py -s --unmapped over the whole history:
/leaderboards/options /match/reset
/clientdata/userHubData /delete/game/fifa17/item
All four are now routed. The value here is the method: the unmapped view is a standing detector for the suffix endpoints the binary's URL template table cannot show, which have now caught this project four separate times. Run it after every session.
18d. A hypothesis for Seasons: the deciding fetch happens minutes earlier
/leaderboards/options is the ONLY mode-related endpoint the real client has ever
requested. It has been requested seven times, and we answer {} because FUT_MODES is
off. What follows each request:
20:41:02 -> 135s -> /user/accountinfo, then a fresh /ut/auth
22:27:40 -> gap -> /user/accountinfo, then a fresh /ut/auth
09:51:07 -> 1s -> /club/stats/staff (normal play continued)
09:55:21 -> 126s -> /user/accountinfo, then a fresh /ut/auth
09:57:47 -> 2127s -> /hub
11:12:32 -> 12s -> /clientdata/userHubData (normal play continued)
11:16:33 -> 274s -> /hub
A following /ut/auth means the FUT session ended and the client logged in again, which
is the exact signature of hitting an error, being returned to the main menu, and going
back into FUT. Three of the seven have it.
The hypothesis. The client fetches mode/leaderboard options when it enters the play area, caches the result, and later refuses Seasons based on that cached EMPTY body, issuing no further request. That would explain the otherwise strange observation that the failure produces zero requests at any layer: the deciding data was fetched two minutes earlier and we returned nothing in it.
This is a hypothesis, not a finding. What is factual: the correlation above, and that the one mode endpoint the client asks for is the one we stub.
Why it is worth testing first. It is cheap. leaderboard_route() already implements
an options body ({"category":0,"id":0,"period":0,"view":0,"url":""}) behind
FUT_MODES=1, so the test is one flag and one menu selection.
The conditional risk, stated in advance. FUT_MODES=1 also enables /season, whose
array-root shape was flagged as a freeze candidate. That risk cannot fire while the
client never asks for /season. But if this hypothesis is RIGHT, then a populated
options body is exactly what would make the client proceed and ask for /season for the
first time. So success on the first step arms the second risk. Expect it, watch for the
busy-loop freeze rather than an error dialog, and be ready to answer /season
minimally rather than fully.
19. The MY CLUB counter: why it is not server-fixable (2026-08-04, measured)
Three experiments in one session, and the answer is a negative with a mechanism behind it rather than another candidate body.
What was tried
| flag | what it changed | result |
|---|---|---|
FUT_CLUB_PAGE |
served 114 items to GET /club?...count=11 |
tile still 0 TOTAL PLAYERS |
FUT_CLUBSTATS |
full stat set on every Stats2 mode, players=114 |
panel still Players 0 |
The bodies demonstrably went out. The log shows four CLUBSTATS: ... 11 stat rows (players=114) responses at 14:20:19 and 14:20:23, and the panel was re-entered after.
The mechanism
FUN_18012fbe0 store+0x78 = request+0xc4 the mode tag is copied out of the
store+0x7c = request+0xc8 REQUEST that just completed
store+0x80 = request+0xcc
FUN_180043b90 switch (store+0x78)
case 1 +0x800(1)->PLAYERS_EMPLOYED +0x800(0x1e)->BALLS_EARNED
+0x800(0x28)->KITS_AVAILABLE +0x800(0x14)->STADIA_OWNED
+0x800(10)->STAFF_EMPLOYED +0x800(0x32)->TROPHIES_WON
case 3 per-nation: BRONZE/SILVER/GOLD/RARE_PLAYERS_EMPLOYED, KITS, BADGES
case 6 CARDS_NO_TRAINING_*, CARDS_NO_CONTRACT_*, CARDS_NO_FITNESS_*
The MY CLUB summary is case 1, which requires mode 1 (club). The real client
requests /club/stats/staff, /club/stats/year and /club/stats/consumables, and
never /club/stats/club, so the mode tag settles at 6 and case 1 is never
selected. Our values are stored correctly (contextId 1 forces contextValue 0, the
global bucket the +0x800 getter reads) and case 1 reads exactly the six ids we set.
Nothing ever asks for them.
The mode is chosen client-side from the request URL. No response body can change it. That is why this is not a shape problem: there is no body that fixes it.
Two independent corroborations
- Case 6 reads ids
0x3dCONTRACTS,0x3eTRAINING and0x40FITNESS, which are exactly the three ids the type-string map (FUN_18012fd40) cannot produce. The consumables view is unsettable from this endpoint by construction, which is a strange property unless the mode split is real. FUT_CLUB_PAGEeliminated the only other candidate: the tile is not a count of the list we return.
Status
FUT_CLUBSTATS stays implemented and default off. The body is correct against the
verified schema and would populate the moment a club-mode request occurred. Keeping
it costs nothing and throwing it away would lose the schema work.
FUT_CLUB_PAGE stays default off; it answered its question.
What would still change this
One thing, and it is not another body: find what makes the client issue
/club/stats/club. It is a request the client knows how to build (the URL builder
FUN_18012f4f0 has a club(0x87) mode), so something reaches it. That decision is in
the front-end layer, same as Seasons. Until then, treat the MY CLUB counter as not
server-fixable and stop generating candidate bodies for it.
An earlier note in this file argued the counter was server-supplied by
/club/stats/<mode>, and the live-evidence argument that the hub tile and the panel
read the same store (both said "players", both zero) was mine and was wrong in its
conclusion. The store is shared; the selection is not.
20. Club stats ARE server-fixable (2026-08-04, live-proven)
§19 concluded the MY CLUB counter was not server-fixable. That conclusion was too broadly scoped. The nation and league drill-down screens are fixable, and are now fixed and live-proven: the MY CLUB -> ENGLAND -> Premier League row reads 17, the first non-zero number ever rendered on that screen.
What made it work
The per-context getter is (+0x7f8)(store, contextValue, typeId), and the whole thing
turns on where contextValue comes from. From FUN_180043b90 case 3:
uVar7 = (**(param_2 + 0x18))(param_2, row, "LEAGUE_ID"); // THE UI ROW'S OWN ID
bronze = (+0x7f8)(store, uVar7, 2);
silver = (+0x7f8)(store, uVar7, 3);
gold = (+0x7f8)(store, uVar7, 4);
publish("PLAYERS_EMPLOYED", gold + silver + bronze); // COMPUTED, never read
rare = (+0x7f8)(store, uVar7, 5);
kits = (+0x7f8)(store, uVar7, 0x28);
badges = (+0x7f8)(store, uVar7, 0x2d);
Three rules follow, and two wrong bodies were shipped before they were understood:
- A response must carry a bucket per SCREEN ROW, because the reader iterates the rows and looks up each row's own id. Keying every row to the id in the URL fills one bucket the screen never asks for. That was attempt two.
PLAYERS_EMPLOYEDis computed as gold + silver + bronze in the per-context cases. Sendingplayers(type id 1) does nothing there. The tier counts are mandatory.- The screens nest:
country/<id>lists the LEAGUES in that nation (case 3, keyed byLEAGUE_ID);league/<id>lists the TEAMS (case 4, keyed byTEAM_ID, reading ids 1, 0x28, 0x2e).
The guard still matters: contextId == 1 or 5 <= contextId <= 9 forces contextValue
to 0, the global bucket. Per-context rows use contextId 3 purely to sit outside it.
TODO/CONFIRM whether contextId means anything more.
Because every response wipes the whole map, each response carries only its own screen's
buckets. That also dodges a real collision: the storage key is contextValue alone, so
nation 14 and league 14 would otherwise share a bucket.
FUT_CLUBSTATS is now default ON, the live-proven value.
What is still open, and correctly scoped this time
The hub tile's 0 TOTAL PLAYERS and the MY CLUB summary panel rows are unchanged.
They read the GLOBAL bucket through +0x800 in cases 1 and 5, and we do serve those
rows, so the remaining question is unchanged from §19: what selects those cases. The
mode tag is copied from the completed request, and the client requests year,
consumables, staff, country/<id> and league/<id> but never club.
The method note, which is the durable part
Two rounds of reasoning about this endpoint produced two wrong bodies. Twenty lines of the consumer produced the right one. The question "what does the reader actually look up" was answerable from the start and was not asked until live screenshots forced it. Reading the parser tells you what is accepted; only reading the CONSUMER tells you what is used.