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