f488793b34
RETRACTION. This repo claimed, in REBUILD_RESEARCH S14c and in utas_server's
item_route comment, that FutMoveCard 0x180128600 "HAS NO SKIP HANDLER
(FUN_180135ff0 appears zero times, unique among FUT deserializers)" and "parses
only itemData -> dreamSquads". Every part of that is false. Full decompile:
FUN_180135ff0 call sites : 2 (offsets 5006, 6080)
atoms parsed : 7 active dreamSquads id itemData pile reason success
Cause: the decompile was written out as src[:4000] and then searched. The function
is 6193 chars, so BOTH skip-handler call sites and four of the seven atoms lay past
the cut. An absence was reported from a truncated listing -- the same failure mode
as the Memory.getBytes bytearray scan that silently returned zero hits. Never
conclude an absence without asserting the searched region covers the function.
Cost: the false claim implied "any extra key desyncs this parser", which sent the
investigation after client-side state for seven attempts, and the derived premise
"the deciding factor is client-side state, not the wire" was wrong too.
VERIFIED SHAPE. PUT ut/%s/item is not an ack endpoint; it returns per-item VERDICT
records:
id(0x15c) INT 0x1801c79d0 -> record+0x00
pile(0x226) STRING 0x1801c7aa0 -> enum 0x180142650 (club=7 purchased=6 trade=5)
success(0x2fa) BOOL 0x1801c7620 -> record+0x0c
reason(0x279) STRING -> "Destination Full" = 0xf
dreamSquads(0xe9) INT array; else -> FUN_180135ff0 (skip)
The completion handler raises EVENT_CARDS_MOVE_CARD_FAILURE when the record vector
is EMPTY or record+0x0c != 1, and success is initialised to '\0' per element. So
every body ever returned reported the move as FAILED, {} included. Quick sell
survives an identical {} because its callbacks read only the transport code and
ignore the body -- that is the whole asymmetry, and it was on the wire after all.
STAGED, NOT DEFAULTED. FUT_MOVE_BODY=ack emits the correct shape; the default stays
`empty` because sufficiency is untested. One launch settles it.
The ack is answered BEFORE the `if moved:` gate: under FUT_PACK_AUTOCLUB=1 the
cards are already in the club when the reveal asks to move them, so move_items()
returns nothing and a moved-derived body would be zero-record -- failing in exactly
the configuration ack exists to fix. Caught in review before it ran. success is
asserted only for ids that were moved now or are already in the club; anything else
gets an honest success:false rather than an invented verdict.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
646 lines
35 KiB
Markdown
646 lines
35 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.
|