5d5198f5d1
Second session. FUT core loop, the EASFC/POW online layer, a central account
backend, and a lot of corrections. Everything risky is behind an env flag with
the default set to whatever was live-proven.
WORKING END TO END (live-verified this session):
* match loop -- POST/PUT/POST/DELETE ut/%s/match, rewards via FutDestroyMatch
(0x180121b60). Play a match, get coins, W/D/L updates.
* packs -- buy, cards land in the club, session survives (FUT_PACK_AUTOCLUB=1)
* quick sell -- POST ut/delete/%s/item was UNMAPPED and paid NOTHING; six cards
were destroyed for 0 coins. Now credits discardValue.
* POW/EASFC online -- the "EA FC servers unreachable" banner is powdll's layer,
a THIRD http api on :8094 nobody had served. Redirect needs no root: powdll
FUN_18005a460 reads FIFA_POW_URL from the same client-config store as
ROSTERUPDATE_URL. FUT_POW=1.
* account backend -- fut_account.py replaces 7 hardcoded copies of the persona
across 5 files; club/persona/online-profile editable via CLI.
CORRECTIONS TO ENDPOINT_MAP (all re-extracted from the deserializers):
* FutStoreGetPackTypes: id/packType/isPremium/quantity/saleType/purchaseLimit/
purchaseCount are NOT skipped no-ops -- all are parsed. extPrice inner objects
take externalPriceId(0x11a), not amount/currency.
* FutMoveCard 0x180128600 has NO skip handler (FUN_180135ff0 appears zero times,
unique among FUT deserializers) and parses only itemData -> dreamSquads.
* class -> deserializer resolution: the name literal is preceded by a 4-BYTE
HEADER and the factory LEA points at the header, so look up name_addr - 4.
Six attempts failed on this; now ghidra_env.class_deser(). Unlocked 11 SBC/
Draft schemas.
* live-only endpoints the request table never lists: ut/%s/squad/list,
ut/%s/user/club, ut/%s/club/stats/*, ut/%s/clientdata/<key>. The template
table is a floor, not a ceiling -- the log is the only ground truth.
* 163 RS4 call names exist; we served 17. All now served.
FIXED: club/stats/* was answering with the entire 28-item club inventory on every
poll (it fell through to the generic /club route).
UNSOLVED: the pack reveal's "Send to Club" (PUT ut/%s/item) kills the FUT session
whatever we answer -- {} included -- while its sibling quick-sell endpoint accepts
a bare {}. Seven hypotheses eliminated by live test, documented in
REBUILD_RESEARCH.md S14c so none get re-walked. FUT_PACK_AUTOCLUB routes around it.
Also unfixed: store tiles render "unknown" (displayGroup is parsed RECURSIVELY by
the same element parser; sending it FROZE the store, so FUT_STORE_GROUPS=1 is
default off).
Tests: test_fut_contract.py 380 (live, read-only) + test_match_rewards.py 51 (pure).
Note: fut_store.py carries some pre-existing uncommitted changes from before this
session (pack catalogue ids, pending-pile behaviour) that could not be separated
from this session's additions in the same file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
534 lines
30 KiB
Markdown
534 lines
30 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` | real finding (it genuinely has none), but `dreamSquads`-only still failed |
|
||
| POW saturating the HTTP layer (109k reqs) | same failure with POW off |
|
||
| missing `FUT_RS4_URL_<CALL>` keys | netwatch logged **ZERO** non-loopback dials; 146 were genuinely missing and are now served, but they were not the cause |
|
||
| the response body at all | **`{}` fails too** |
|
||
| the reveal screen's exit path | **Quick Sell works from the same screen** |
|
||
|
||
So the move endpoint rejects every possible response while its sibling accepts a bare
|
||
`{}`. `"error connecting"` is FIFA's GENERIC FUT-session failure text, not a network
|
||
event — do not read it literally (that inference cost two wasted attempts).
|
||
|
||
**Workaround shipped:** `FUT_PACK_AUTOCLUB=1` (default) deposits pack contents
|
||
straight into the club at open time and keeps the pending pile empty, so the client is
|
||
never offered a move. Packs are fully usable. `FUT_MOVE_BODY=empty|dreamsquads|full`
|
||
switches the response shape for future bisects without a code edit.
|
||
|
||
**Next idea, untested:** single-card `S` (Send to Club) vs batch `W` (Send All) —
|
||
if single works, it is a batch/count issue, a completely different target.
|
||
|
||
### 14d. Process note
|
||
|
||
Four of six failed hypotheses were things testable before proposing them. The two
|
||
findings that actually moved this forward were the USER's: the screenshot with the
|
||
error text, and the Quick Sell result. **Ask for the on-screen text and try the
|
||
neighbouring action FIRST** — both were cheaper than any decompile done here.
|