diff --git a/fifa17-recon/docs/FUT_RESPONSE_REBUILD_PLAN.md b/fifa17-recon/docs/FUT_RESPONSE_REBUILD_PLAN.md index 9d0aadb..28a54ed 100644 --- a/fifa17-recon/docs/FUT_RESPONSE_REBUILD_PLAN.md +++ b/fifa17-recon/docs/FUT_RESPONSE_REBUILD_PLAN.md @@ -634,3 +634,66 @@ Fix: route `/squad/list` (before the generic `/squad`) to `squad_list_body()`: Guarded by a contract check asserting `/squad/list` returns a `squad` array and is *not* the active-squad object. This also makes the `FUT_SQUAD_LIST=merged` workaround (S3) unnecessary — the two responses have distinct URLs, so no merged body is needed. + +## 11. The online gate is POW/EASFC, not FUT (2026-08-03) + +"EA FC servers are unreachable / PRESS Q TO RE-CONNECT" is **not** the FUT/UTAS +layer, not Blaze and not Origin/LSX — all three are healthy in our live logs while +the banner shows. It is the EASFC layer in **`powdll_Win64_retail.dll`**: 1.1 MB, +**unpacked and string-rich**, so unlike Denuvo-packed FIFA17.exe it can actually be +reversed. CardsDLL contains zero hits for "unreachable"/"RE-CONNECT"/"PRESS Q"; +powdll has `TXT_EASFC_RECONNECT_PROMPT`, `TXT_EASFC_SERVER_ERROR`, and the FE events +`POWService::PowReconnect` / `TriggerPleaseConnectMsg` / `PowBlazeDisconnected`. + +POW is a **third HTTP API** we have never served: + +| role | default host | paths | +|---|---|---| +| api | `pas.gt.easfc.ea.com:8094` | `pow/auth`, `pow/healthcheck/system/all`, `pow/v2/activity`, `pow/nucleus/entitlements`, `pow/bank/user/account`, `pow/store/...`, … (58 templates extracted) | +| content | `content.lt.easfc.ea.com:8080` | `pow/imgAssets/...`, `pow/artAssets/...` | + +Neither hostname is in `/etc/hosts` or the iptables DNAT, so **every POW call dies at +DNS** — which is exactly the banner's trigger. + +### Reversed (Ghidra project `/tmp/pow/powproj`, PE base 0x180000000) + +| addr | role | +|---|---| +| `FUN_18005a460` | POW config init. Reads `FIFA_POW_URL`, `FIFA_POW_CONTENT_SERVER_URL`, `POW_IS_ON` via `cfg->vtbl[0x30]` = `getString(key, default, &out)` — **the same merged `_all` client-config store that already delivers `ROSTERUPDATE_URL`**. Picks `http://` vs `https://` (`PTR_s_http____18010aee0` / `...aee8`). | +| `FUN_18005cb40` | health-check / reconnect handler. Issues `pow/healthcheck/system/all` through request builder `FUN_18005e780`, then sets POW state at `POWmgr[0x6ac]`: **1 = connected, 3 = disconnected** (3 raises the prompt). Also the `PowReconnect` FE-event site. | +| `FUN_18005c970` | fires `POWService::PowBlazeDisconnected` | +| `FUN_1800a8590` | fires `POWService::TriggerPleaseConnectMsg` | +| `FUN_1800ad090` | references `TXT_EASFC_RECONNECT_PROMPT` (the banner) | + +**Consequence: POW can be redirected with no root and no `/etc/hosts`** — just serve +`FIFA_POW_URL` from `blaze_responder_v3b.py`. + +### Shipped (all OFF by default) + +* `tools/pow_server.py` — POW/EASFC server on `:8094` (api) + `:8080` (content). + `POW_MODE=log` (default) answers everything `200 {}` / assets `404` and logs the + exact method+path+headers+body to `/tmp/pow_server.log`; `POW_MODE=serve` adds + first-draft bodies for auth/healthcheck/counts. It knows all 58 extracted path + templates and **flags any path outside that set**, so the capture also tells us + where the extraction was incomplete. +* `blaze_responder_v3b.py` — `OSDK_POW` keys, merged onto **every** CFID (same + reasoning as `FUT_RS4_*`: which section powdll reads is unproven). Empty unless + `FUT_POW=1`. +* `openfut-fut.sh` — `pow` added to SERVERS (inert while idle: it only binds ports; + nothing points at it until `FUT_POW=1`). +* `root_arm.sh pow` / `root_arm.sh unpow` — the `/etc/hosts` fallback, opt-in + because those entries persist across reboots. + +### NOT done — the response schemas + +Only the REQUEST side is mapped. No powdll response parser has been walked, so every +body `pow_server.py` returns is a placeholder. **The next step is a capture run:** + +``` +FUT_POW=1 ./openfut-fut.sh restart # then launch FIFA, enter FUT +tail -f /tmp/pow_server.log # what does POW actually ask for? +``` + +Whatever appears there turns the schemas from guesswork into reversing targets — +the same route that made the squad work tractable. Instant fallback: drop `FUT_POW` +and restart. diff --git a/fifa17-recon/docs/REBUILD_RESEARCH.md b/fifa17-recon/docs/REBUILD_RESEARCH.md new file mode 100644 index 0000000..fe3dbd4 --- /dev/null +++ b/fifa17-recon/docs/REBUILD_RESEARCH.md @@ -0,0 +1,533 @@ +# 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/` (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//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/` 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_` 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. diff --git a/fifa17-recon/tools/blaze_responder.py b/fifa17-recon/tools/blaze_responder.py index e44b490..814953e 100644 --- a/fifa17-recon/tools/blaze_responder.py +++ b/fifa17-recon/tools/blaze_responder.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# FROZEN: not started by openfut-fut.sh (see its SERVERS array); identity is NOT +# sourced from fut_account.py here. The live pair is lsx_responder_v2.py + +# blaze_responder_v3b.py -- edit those. Kept for reference/bisecting only. """FIFA17 Blaze redirector RESPONDER + second-hop Fire2 capture. - TLS on 42127: answers POST /redirector/getServerInstance with a pointing the client at 127.0.0.1:BLAZE_PORT (secure=0). diff --git a/fifa17-recon/tools/blaze_responder_v2.py b/fifa17-recon/tools/blaze_responder_v2.py index 004e91b..03ba591 100644 --- a/fifa17-recon/tools/blaze_responder_v2.py +++ b/fifa17-recon/tools/blaze_responder_v2.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# FROZEN: not started by openfut-fut.sh (see its SERVERS array); identity is NOT +# sourced from fut_account.py here. The live pair is lsx_responder_v2.py + +# blaze_responder_v3b.py -- edit those. Kept for reference/bisecting only. """FIFA17 Blaze redirector + SESSION SERVER (v2). Two listeners: diff --git a/fifa17-recon/tools/blaze_responder_v3.py b/fifa17-recon/tools/blaze_responder_v3.py index 75b3b5f..2fecff0 100644 --- a/fifa17-recon/tools/blaze_responder_v3.py +++ b/fifa17-recon/tools/blaze_responder_v3.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# FROZEN: not started by openfut-fut.sh (see its SERVERS array); identity is NOT +# sourced from fut_account.py here. The live pair is lsx_responder_v2.py + +# blaze_responder_v3b.py -- edit those. Kept for reference/bisecting only. """FIFA17 Blaze redirector + SESSION SERVER (v3) -- offline forged authentication. WHAT IS NEW vs v2 diff --git a/fifa17-recon/tools/blaze_responder_v3_patched.py b/fifa17-recon/tools/blaze_responder_v3_patched.py index 6a2df6f..dcd0e5e 100644 --- a/fifa17-recon/tools/blaze_responder_v3_patched.py +++ b/fifa17-recon/tools/blaze_responder_v3_patched.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# FROZEN: not started by openfut-fut.sh (see its SERVERS array); identity is NOT +# sourced from fut_account.py here. The live pair is lsx_responder_v2.py + +# blaze_responder_v3b.py -- edit those. Kept for reference/bisecting only. """FIFA17 Blaze redirector + SESSION SERVER (v3) -- offline forged authentication. WHAT IS NEW vs v2 diff --git a/fifa17-recon/tools/blaze_responder_v3b.py b/fifa17-recon/tools/blaze_responder_v3b.py index 7f05421..be36410 100644 --- a/fifa17-recon/tools/blaze_responder_v3b.py +++ b/fifa17-recon/tools/blaze_responder_v3b.py @@ -84,33 +84,48 @@ from heat2 import ( # noqa: E402 ) # ================================================================== identity -# SHARED CONSTANTS -- these MUST stay byte-identical to lsx_responder.py. -# Source: stp-origin_emu.ini [Globals] (PersonaId / PersonaName / Language). -# A mismatch is exactly what raises AUTH_ERR_INVALID_PERSONA (26), -# AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA and AUTH_ERR_PERSONA_NOT_FOUND. +# SOURCED FROM fut_account.ACCOUNT -- the single source of truth shared with +# lsx_responder_v2.py, fut_store.py, fut_seed.py and utas_server.py. +# +# THE CONSTRAINT IS CROSS-LAYER CONSISTENCY, NOT ANY PARTICULAR VALUE. +# Whatever Blaze asserts here (LoginResponse.SESS.PDTL) must equal what LSX +# asserts (GetProfileResponse) and what UTAS serves (userInfo / squad.personaId). +# Reading them all from one module is what guarantees that. +# +# CORRECTION to the comment this replaces: it claimed these came from +# stp-origin_emu.ini [Globals] and that a mismatch raises AUTH_ERR_INVALID_PERSONA +# (26) / AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA / AUTH_ERR_PERSONA_NOT_FOUND. That +# justification is wrong twice over: those are Blaze *server* error codes and WE +# are the server, and a byte-scan found "CAGE" and "33068179" ZERO times in +# FIFA17.exe, CardsDLL, dbdata.dll and _fifa17.exe. 33068179 appears only inside +# stp-origin_emu.dll, as that emu's own ini default. The client does not demand +# these values -- they are what the currently-working stack asserts, which is +# why they stay the defaults in fut_account.py. -PERSONA_ID = 33068179 -PERSONA_NAME = "CAGE" -USER_ID = 33068179 # blazeId / userId; same value keeps BUID==UID==PID -EXT_ID = 33068179 # XREF externalId -EMAIL = "cage@openfut.local" -PERSONA_NAMESPACE = "cem_ea_id" # must equal PreAuthResponse.NASP -CLIENT_PLATFORM = 4 # Blaze::ClientPlatformType -> pc -PERSONA_STATUS = 2 # PersonaStatus::Code -> ACTIVE (verified live: table 0x14487ad20, ACTIVE==2) -USER_SESSION_TYPE = 0 # Blaze::UserSessionType -> normal/console user -ACCOUNT_LOCALE_FALLBACK = 0x656E5553 # 'enUS'; overwritten by the client's own - # PreAuthRequest LANG/LOC when we see it. +from fut_account import ACCOUNT # noqa: E402 -CONTENT_ID = "1027460" # FIFA 17 EA offer id (retail) -ENTITLEMENT_TAG = "ONLINE_ACCESS" # TRIAL_ONLINE_ACCESS for FIFA17_Trial.exe -ENTITLEMENT_GROUP = "FIFA17PCBoxContent" # was "FIFA17PC" -> matched NEITHER strstr +PERSONA_ID = ACCOUNT.persona_id +PERSONA_NAME = ACCOUNT.persona_name +USER_ID = ACCOUNT.user_id # blazeId / userId; derived, keeps BUID==UID==PID +EXT_ID = ACCOUNT.ext_id # XREF externalId; derived from persona_id +EMAIL = ACCOUNT.email +PERSONA_NAMESPACE = ACCOUNT.NAMESPACE # must equal PreAuthResponse.NASP +CLIENT_PLATFORM = ACCOUNT.CLIENT_PLATFORM # Blaze::ClientPlatformType -> pc +PERSONA_STATUS = ACCOUNT.PERSONA_STATUS # PersonaStatus::Code -> ACTIVE (live: table 0x14487ad20) +USER_SESSION_TYPE = ACCOUNT.USER_SESSION_TYPE # Blaze::UserSessionType -> normal user +ACCOUNT_LOCALE_FALLBACK = ACCOUNT.account_locale_int # 'enUS'; overwritten by the + # client's own PreAuthRequest LANG/LOC. + +CONTENT_ID = ACCOUNT.CONTENT_ID # FIFA 17 EA offer id (retail) +ENTITLEMENT_TAG = ACCOUNT.ENTITLEMENT_TAG # TRIAL_ONLINE_ACCESS for FIFA17_Trial.exe +ENTITLEMENT_GROUP = ACCOUNT.ENTITLEMENT_GROUP # was "FIFA17PC" -> matched NEITHER strstr # needle in EntitlementComponent::onListEntitlements (0x146f27440): FUT keeps an # entitlement only if GNAM contains "FIFA17PCBoxContent" OR "FIFA16PC" (needles # @0x144334030), TAG non-empty, STAT==1. "FIFA17PC" survived none -> empty store. -TITLE_ID = "309111" -CLIENT_ID = "FIFA17-PC-SERVER-BLAZE" -PLATFORM = "pc" +TITLE_ID = ACCOUNT.TITLE_ID +CLIENT_ID = ACCOUNT.CLIENT_ID +PLATFORM = ACCOUNT.PLATFORM SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n" # ================================================================== config @@ -530,9 +545,36 @@ IDENTITY_PARAMS = [ ("redirect_uri", "http://127.0.0.1/success"), ] +# --- POW / EASFC redirect (the "EA FC servers unreachable" gate) -------------- +# The reconnect banner comes from the EASFC layer in powdll_Win64_retail.dll, a +# THIRD HTTP API (default host pas.gt.easfc.ea.com:8094) that nothing has ever +# served. powdll FUN_18005a460 reads its base URLs out of THIS store -- the merged +# '_all' section, same path that already delivers ROSTERUPDATE_URL -- via +# cfg->vtbl[0x30] = getString(key, default, &out), and picks an http:// vs https:// +# prefix (PTR_s_http____18010aee0 / PTR_s_https____18010aee8). So pointing POW at +# our own server needs NO /etc/hosts entry and NO root: just answer these keys. +# POW_IS_ON (read by the same function, getBool, default TRUE) is the kill switch. +# +# DEFAULT IS OFF. Serving these keys sends the client somewhere it has never been +# and pow_server.py cannot yet answer POW properly (the response schemas are not +# reversed -- only the 58 request paths are). Enable for a CAPTURE run with +# FUT_POW=1, which is what turns the schemas into reversing targets: +# FUT_POW=1 ./openfut-fut.sh restart +# and read /tmp/pow_server.log. FUT_POW=off is the instant fallback. +POW_HOST = os.environ.get("POW_HOST", "127.0.0.1:8094") +POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080") +_POW_ON = os.environ.get("FUT_POW", "").lower() in ("1", "true", "on", "yes") +OSDK_POW = [ + ("FIFA_POW_URL", "http://%s/" % POW_HOST), + ("FIFA_POW_CONTENT_SERVER_URL", "http://%s/" % POW_CONTENT_HOST), + ("FIFA_POW_NUCLEUS_PROXY_URL", "http://%s/" % POW_HOST), + ("POW_IS_ON", "1"), +] if _POW_ON else [] + CLIENT_CONFIGS = { "BlazeSDK": None, # built dynamically, see below "netres": OSDK_NETRES, # CFID (verified @0x143962be0) + "OSDK_POW": OSDK_POW, # EASFC/POW redirect (opt-in, FUT_POW=1) "OSDK_CORE": OSDK_CORE, "OSDK_CLIENT": OSDK_CLIENT, "OSDK_NUCLEUS": OSDK_NUCLEUS, @@ -565,15 +607,55 @@ FUT_RS4_MODULES = [ "TFA", "SQUADMODE", "DRAFT", "CHAMPIONS", "V2STORE", "LIVEMESSAGE", "ADMIN", "DEBUG", "MAINTENANCE", ] -FUT_RS4_CALLS_BOOT = [ - "GETSETTINGS", "AUTHENTICATION", "LOGIN", "LOGOUT", "CREATEUSER", - "GETUSERINFO", "GETUSERDATA", "GETUSERCREDITS", "USERRELIABILITYINFO", - "GETHUBDATA", "GETUSERMASSINFO", "LOADACTIVESQUAD", "SQUADLIST", - "GETSQUADINFO", "GETCLUBINFO", "KEEPALIVE", "SEASONHISTORY", +# EVERY RS4 call name in the client's table at 0x18021e250-0x18021fa60 (163 of them), +# not just the 17 boot ones. THE CLIENT RESOLVES A PER-CALL URL KEY FIRST: +# FUT_RS4_URL_ takes precedence over the per-module FUT_RS4_APIURL_. +# Serving only the boot subset left 146 calls unresolved, so anything past boot -- +# VIEWCARDS, ASSIGNCARD, CLUBSTATS, GETCLUBUSERS, MOVECARD's follow-ups -- fell back +# to a default (real EA) host, failed at the transport, and the client raised +# "We are sorry but there has been an error connecting to FIFA 17 Ultimate Team." +# That is the pack "Send to Club" kick: the move itself succeeded, the FOLLOW-UP +# request never reached us. Live-diagnosed 2026-08-04 from the on-screen error text +# plus GET /club never once appearing in the log. +FUT_RS4_CALLS = [ + "AUCTIONHOUSE", "CLUB_USER", "DREAM", "SQUAD", "DELETE_SQUAD", "LBOPTIONS", + "LBDEFAULT", "PAFPRACTICE", "USER", "DELETEUSER", "ITEMS", "ITEMS_BY_RES", + "DELETEITEMS", "TOURNAMENT", "TOURNAMENTUSER", "TOURNAMENTQUIT", "SEASONUSER", + "SEASONUSER_ALTER", "SEASONRESET", "FRIENDLYSEASON", "PURCHASED", "STORE", "WATCHLIST", + "DELETEWATCHLIST", "TRADEPILE", "TRADE", "DELETETRADE", "MARKETDATA", "CLIENTDATA", + "AUTH", "DELETE_AUTH", "PHISHING", "CAPTCHA", "SQUADMODE", "DRAFT", "CHAMPIONS", + "V2STORE", "LIVEMESSAGE", "ADMIN", "DEBUG", "MAINTENANCE", "ISSEARCH", "ISOFFERTRADE", + "ISSTART", "RELISTALL", "GETCLUBUSERS", "GETCLUBINFO", "CLUBSEARCH", "CLUBSTATS", + "STAFFSTATS", "CONSUMABLESSEARCH", "DREAMSQUADSEARCH", "GETSQUADINFO", + "UPDATESQUADNAME", "RETRIEVESQUAD", "LOADACTIVESQUAD", "DELETESQUAD", "SAVESQUAD", + "SQUADLIST", "GETLBOPTIONS", "GETLBENTRIES", "GETLBENTRYDATA", "GETSETTINGS", + "AUTHENTICATION", "LOGIN", "LOGOUT", "RESETUSER", "USERRELIABILITYINFO", "CREATEUSER", + "GETUSERINFO", "SETUSERINFO", "GETHISTORICAL", "SETTUTDATA", "GETTOWDATA", + "SETTOWDATA", "SETFAVDATA", "GETUSERCREDITS", "GETUSERDATA", "VIEWCARDS", "ASSIGNCARD", + "APPLYCARD", "APPLYCARDBYRES", "ACTIVATECARD", "CONSUMECARD", "DISCARDCARD", + "DISCARDCARDBYRES", "DISCARDACARD", "MOVECARD", "MOVECARDBYRES", "SWAPCARD", + "CREATEMATCH", "MATCHREADY", "DESTROYMATCH", "PLAYGAME", "RESETMATCH", "KEEPALIVE", + "LOADCATEGORYDETAILS", "LOADSETCHALLENGES", "STARTCHALLENGE", "LOADSQUADCHALLENGE", + "SAVESQUADCHALLENGE", "SUBMITCHALLENGE", "TAGSETS", "SETSBCDATA", "TOURNAMENTLIST", + "TOURNAMENTTEAMS", "GETACTIVETOURNAMENTS", "UPDATETOURNAMENT", "TOURNAMENTLOADDATA", + "SEASONLIST", "SEASONUPDATE", "SEASONLOADDATA", "SEASONQUIT", "SEASONHISTORY", + "PURCHASEDITEMS", "PURCHASEPACK", "PURCHASEITEMS", "STOREPACKTYPES", + "STOREPACKQUANTITIES", "ISREMOVEWATCH", "ISWATCHTRADE", "ISWATCHLIST", "ISVIEWTRADE", + "GETTRADEPILE", "GETAUCTIONCOUNT", "ISREMOVETRADE", "GETSUGGESTEDPRICING", + "CHANGECLUBNAME", "GETPHISHINGQUESTION", "SETPHISHINGANSWER", "VALIDATEPHISHINGANSWER", + "GETTRUSTEDCONSOLELIST", "GETCAPTCHA", "EXCHANGECAPTCHA", "VALIDATECAPTCHA", + "VALIDATETFA", "UPDATEUSERACTION", "GETUSERACTION", "GETMANAGERQUESTREWARD", + "SETMANAGERQUESTCOMPLETE", "GETHUBDATA", "GETUSERMASSINFO", "FIFAPOINTSTRANSFER", + "FRIENDLYSEASONUPDATE", "FRIENDLYSEASONLOAD", "FRIENDLYSEASONHISTORY", + "GETAVAILABLELOANPLAYERS", "SIGNLOANPLAYER", "GETCHEMISTRYATTR", + "GETDRAFTCURRENTSTATE", "GETDRAFTCHOICES", "GETDRAFTSTATS", "GETDRAFTAWARD", + "PURCHASEDRAFTMODE", "PICKDRAFTCHOICE", "PICKDRAFTAUTOCHOICE", "GETSTORYMODEREWARD", + "CHAMPIONSHUB", "CHAMPIONSTOPX", "CHAMPIONSRANK", "CHAMPIONSFRIENDS", + "GRANTPRIZECHAMPIONUSER", "REGISTERCHAMPIONSLEAGUE" ] FUT_RS4_CONFIG = ( [("FUT_RS4_APIURL_%s" % m, UTAS_BASE) for m in FUT_RS4_MODULES] - + [("FUT_RS4_URL_%s" % c, UTAS_BASE) for c in FUT_RS4_CALLS_BOOT] + + [("FUT_RS4_URL_%s" % c, UTAS_BASE) for c in FUT_RS4_CALLS] + [("FUT_RS4_BASE_URL", UTAS_BASE)] # STORE gate: FIFA shows "store not available" unless these config flags are # true. The store-screen entitlement checks (CardsDLL 0x18001749d vtable+0x138 / @@ -604,9 +686,14 @@ def client_config_for(cfid: str) -> list: still wrap in a present CONF field -- never an empty frame). FUT_RS4_* base-URL keys ride on EVERY CFID (merged '_all' store; which section CardsDLL reads is unproven, so serve them everywhere).""" + # OSDK_POW rides on EVERY CFID for the same reason FUT_RS4_* does: powdll's + # FUN_18005a460 reads FIFA_POW_URL out of the merged '_all' store, and which + # section it happens to read is unproven. Empty list when FUT_POW is unset, so + # this is a no-op by default. (Putting the keys ONLY under a hypothetical + # "OSDK_POW" CFID would be dead code -- nothing is known to request that name.) if cfid == "BlazeSDK": - return sorted(blazesdk_config() + FUT_RS4_CONFIG) - return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG) + return sorted(blazesdk_config() + FUT_RS4_CONFIG + OSDK_POW) + return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG + OSDK_POW) def fetch_config_response_fields(cfid: str) -> "OrderedDict": @@ -691,9 +778,13 @@ def ping_response_fields() -> "OrderedDict": def persona_details_fields(now: int) -> "OrderedDict": """Blaze::Authentication::PersonaDetails @0x14487cab0 -- 6 members.""" return OrderedDict([ - ("DSNM", (STRING, PERSONA_NAME)), # displayName MUST be "CAGE" + # DSNM/PID: no particular value is demanded by the client (see the + # identity block at the top). What IS required is that they equal LSX + # GetProfileResponse Persona/PersonaId and UTAS userInfo.personaId -- + # hence fut_account.ACCOUNT. + ("DSNM", (STRING, PERSONA_NAME)), # displayName == LSX Persona ("LAST", (INT, now)), # lastAuthenticated uint32 - ("PID", (INT, PERSONA_ID)), # personaId int64 MUST be 33068179 + ("PID", (INT, PERSONA_ID)), # personaId int64 == LSX PersonaId ("PLAT", (INT, CLIENT_PLATFORM)), # clientPlatform enum -> pc ("STAS", (INT, PERSONA_STATUS)), # PersonaStatus::Code -> ACTIVE ("XREF", (INT, EXT_ID)), # extId uint64 @@ -748,7 +839,7 @@ def login_response_fields(sess: Session) -> "OrderedDict": # LN MAIL PML RC STAS STAT TPOT UDU UID. IDENTITY: MAIL/UID/ASRC MUST byte-match # LoginResponse.SESS.MAIL/UID and PreAuthResponse.NASP. Empty request. -ACCOUNT_LOCALE_STR = "en_US" # AccountInfo.LN (language) +ACCOUNT_LOCALE_STR = ACCOUNT.locale # AccountInfo.LN (language); "en_US" default def account_info_fields(sess: "Session", now: int) -> "OrderedDict": @@ -1556,6 +1647,13 @@ def _selftest() -> None: print("blaze_responder_v3 selftest") print("=" * 72) + # ---- 0. identity is sourced from the shared account, not local literals + assert PERSONA_ID == ACCOUNT.persona_id and PERSONA_NAME == ACCOUNT.persona_name + assert USER_ID == EXT_ID == ACCOUNT.persona_id, "BUID/UID/XREF are derived" + assert PERSONA_NAMESPACE == ACCOUNT.NAMESPACE == "cem_ea_id" + assert EMAIL == ACCOUNT.email and ACCOUNT_LOCALE_STR == ACCOUNT.locale + print("[ok] identity from fut_account %r" % (ACCOUNT,)) + sess = Session() sess.session_key = "0540000031e5dde8_OPENFUTselftestkeyOPENFUTselftestkeyOPENFUT0" sess.account_locale = 0x656E5553 @@ -1602,8 +1700,10 @@ def _selftest() -> None: d = s["PDTL"][1] assert list(d.keys()) == ["DSNM", "LAST", "PID", "PLAT", "STAS", "XREF"], \ list(d.keys()) - assert d["PID"][1] == PERSONA_ID == 33068179 - assert d["DSNM"][1] == PERSONA_NAME == "CAGE" + # Identity is now configurable (fut_account.ACCOUNT), so assert CONSISTENCY + # with the shared account rather than the old hardcoded 33068179/"CAGE". + assert d["PID"][1] == PERSONA_ID == ACCOUNT.persona_id + assert d["DSNM"][1] == PERSONA_NAME == ACCOUNT.persona_name assert back["ANON"][1] == 0 and back["UNDR"][1] == 0 and back["NTOS"][1] == 0 lfr = fire2(COMP_AUTH, CMD_LOGIN, 7, REPLY, lp) lh = parse_fire2_header(lfr) diff --git a/fifa17-recon/tools/fut_account.py b/fifa17-recon/tools/fut_account.py new file mode 100644 index 0000000..0549667 --- /dev/null +++ b/fifa17-recon/tools/fut_account.py @@ -0,0 +1,652 @@ +#!/usr/bin/env python3 +"""Central ACCOUNT config for the FIFA 17 offline stack (OpenFUT, clean-room). + +ONE source of truth for the identity every layer has to agree on. Before this +module the same persona id / display name / namespace literals were copy-pasted +into blaze_responder_v3b.py, lsx_responder_v2.py, fut_store.py, fut_seed.py and +utas_server.py -- five files, seven copies. The stack only works while all of +them agree, so the copies were a silent drift surface. + + THE REAL CONSTRAINT IS CROSS-LAYER CONSISTENCY, NOT ANY PARTICULAR VALUE. + Blaze LoginResponse.SESS.PDTL, LSX GetProfileResponse and the UTAS + userInfo/squad bodies must all assert the SAME persona. That is why they + now all read this module instead of their own literal. + + (The older comments in blaze/lsx claimed DSNM "MUST be CAGE" and PID "MUST + be 33068179", justified by AUTH_ERR_INVALID_PERSONA. That justification is + wrong on two counts: those are Blaze *server* error codes and we are the + server, and neither literal appears anywhere in FIFA17.exe / CardsDLL / + dbdata.dll. 33068179 occurs only inside stp-origin_emu.dll, as that emu's + own ini default. The values are kept as DEFAULTS because they are what the + currently-working stack asserts -- not because the client demands them.) + +THREE TIERS + 1. LOCKED wire constants -- module-level, no env, never persisted. These are + baked into the binaries or into EA's own catalogue; changing them is a + protocol change, not a preference. + 2. IDENTITY -- persona id / display name / email / locale. Env-overridable, + persisted. + 3. CLUB -- club name / abbreviation / established year / squad name. + Env-overridable, persisted. This tier is the offline, crash-free way to + name your club (the in-game rename path is a separate, gated experiment). + +PRECEDENCE for tiers 2 and 3: env var > fut_account.json > built-in default. + +PERSISTENCE + Its own file, tools/fut_account.json (override with FUT_ACCOUNT_PATH), NOT the + game save. Two reasons: the account must survive deleting fifa17_profile.json + to reset progress, and blaze/lsx must be able to import this module without + dragging in the profile store. On first load, if fut_account.json is absent + and fifa17_profile.json exists, the identity/club values are MIGRATED out of + it so an existing club name is never lost. + +CLI (this is the safe club-rename path -- offline, no client involvement): + python3 tools/fut_account.py --show + python3 tools/fut_account.py --club-name 'Real OpenFUT' --club-abbr ROF +Then restart the harness. See --help. +""" +import json +import os +import sys +import threading + +HERE = os.path.dirname(os.path.abspath(__file__)) +ACCOUNT_PATH = os.environ.get("FUT_ACCOUNT_PATH", os.path.join(HERE, "fut_account.json")) +# Legacy home of these values; read once for migration, never written by us. +LEGACY_PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json")) + +_LOCK = threading.RLock() + +# ====================================================================== tier 1 +# LOCKED WIRE CONSTANTS. No env override on purpose: these are not preferences. +# Each carries its provenance -- do not "clean up" a value without re-deriving it. + +NAMESPACE = "cem_ea_id" +"""Persona namespace. Baked into FIFA17.exe (file offset 0x36b748) in the +BlazeSDK platform->namespace default table; exactly one occurrence. Must equal +PreAuthResponse.NASP and every later NASP/NSNM/ASRC we emit.""" + +PLATFORM = "pc" +"""Wire platform string. The client sends nucleusPersonaPlatform="pc" in its own +POST /ut/auth body -- we echo it, we do not choose it.""" + +CLIENT_PLATFORM = 4 +"""Blaze::ClientPlatformType enum value for pc.""" + +SKU = "FFA17PCC" +"""CardsDLL FUN_180125900 literal @0x1802201e0; also the "game/" URL segment.""" + +TITLE_ID = "309111" +CLIENT_ID = "FIFA17-PC-SERVER-BLAZE" +CONTENT_ID = "1027460" +"""FIFA 17 EA offer id (retail).""" + +ENTITLEMENT_TAG = "ONLINE_ACCESS" +"""TRIAL_ONLINE_ACCESS for FIFA17_Trial.exe.""" + +ENTITLEMENT_GROUP = "FIFA17PCBoxContent" +"""strstr needle @0x144334030. EntitlementComponent::onListEntitlements +(0x146f27440) keeps an entitlement only if GNAM contains "FIFA17PCBoxContent" or +"FIFA16PC", TAG is non-empty and STAT==1. Plain "FIFA17PC" matched neither and +produced an empty store.""" + +PERSONA_STATUS = 2 +"""PersonaStatus::Code ACTIVE (verified live: table 0x14487ad20).""" + +USER_SESSION_TYPE = 0 +"""Blaze::UserSessionType -> normal/console user.""" + +_LOCKED = ("NAMESPACE", "PLATFORM", "CLIENT_PLATFORM", "SKU", "TITLE_ID", + "CLIENT_ID", "CONTENT_ID", "ENTITLEMENT_TAG", "ENTITLEMENT_GROUP", + "PERSONA_STATUS", "USER_SESSION_TYPE") + +# ================================================================ tiers 2 + 3 +# field -> (env var, default). Only these keys are ever persisted. +_FIELDS = { + # tier 2: identity + "persona_id": ("FUT_PERSONA_ID", 33068179), + "persona_name": ("FUT_PERSONA_NAME", "CAGE"), + "email": ("FUT_ACCOUNT_EMAIL", None), # None -> derived from persona_name + "locale": ("FUT_LOCALE", "en_US"), + "country": ("FUT_COUNTRY", "US"), + "currency": ("FUT_CURRENCY", "USD"), + # tier 3: club + "club_name": ("FUT_CLUB_NAME", "OpenFUT"), + "club_abbr": ("FUT_CLUB_ABBR", "OFC"), + "established": ("FUT_ESTABLISHED", "2026"), + "squad_name": ("FUT_SQUAD_NAME", "OpenFUT"), + # tier 4: the ONLINE (EASFC/POW) profile -- what the top-right hub bar shows. + # Served by pow_server.py; key names below are the literal strings powdll's + # parsers compare against (see pow_server.py for addresses), so these map 1:1 + # onto the wire: + # pow_level -> "level", pow_exp -> "exp" (widget renders exp/expMax) + # pow_funds -> EASFC credits, the coin counter next to the cart + "pow_level": ("FUT_POW_LEVEL", 1), + "pow_exp": ("FUT_POW_EXP", 0), + "pow_exp_max": ("FUT_POW_EXP_MAX", 1000), # currLevelExpMax + "pow_funds": ("FUT_POW_FUNDS", 0), # EASFC credit balance + "pow_funds_cap": ("FUT_POW_FUNDS_CAP", 100000), +} +_INT_FIELDS = ("persona_id", "pow_level", "pow_exp", "pow_exp_max", + "pow_funds", "pow_funds_cap") + +# Club-name limits, reversed from CardsDLL: +# * clubAbbr 1..3 -- the client's own write-back after a successful rename is +# FUN_180007f80(rec+0x3e, 4, "%s", abbr), a FOUR-BYTE buffer (handler +# FUN_1800829c0), so 4+ chars truncate. +# * clubName 5..15 -- view-model builder FUN_180082c30 carries +# name_min_length=5, name_max_length=0xf, abbr_max_length=3. The userInfo +# write-back buffer at rec+0x20 is 30 bytes, so 15 is the binding constraint. +CLUB_NAME_MIN = 5 +CLUB_NAME_MAX = 15 +CLUB_ABBR_MIN = 1 +CLUB_ABBR_MAX = 3 + +# CardsDLL FUN_180125900 uses this literal as the display name when the OSDK +# online-user object is NULL. Seeing it means "the client has no identity", not +# "the user is called mememe" -- never adopt it. +NULL_IDENTITY_NAME = "mememe" + + +def validate_club(name, abbr, established=None): + """Validate club identity against the client's own limits. + + Returns (name, abbr) -- or (name, abbr, established) when `established` is + passed. Raises ValueError with a message naming the reversed constraint. + """ + if not isinstance(name, str): + raise ValueError("clubName must be a string, got %r" % type(name).__name__) + if not isinstance(abbr, str): + raise ValueError("clubAbbr must be a string, got %r" % type(abbr).__name__) + name = name.strip() + abbr = abbr.strip() + if not (CLUB_NAME_MIN <= len(name) <= CLUB_NAME_MAX): + raise ValueError( + "clubName %r is %d chars; must be %d..%d (view-model FUN_180082c30 " + "name_min_length=5 name_max_length=0xf)" + % (name, len(name), CLUB_NAME_MIN, CLUB_NAME_MAX)) + if not (CLUB_ABBR_MIN <= len(abbr) <= CLUB_ABBR_MAX): + raise ValueError( + "clubAbbr %r is %d chars; must be %d..%d (write-back buffer at " + "userInfo+0x3e is 4 bytes -> FUN_180007f80(dst,4,\"%%s\",abbr))" + % (abbr, len(abbr), CLUB_ABBR_MIN, CLUB_ABBR_MAX)) + if established is None: + return name, abbr + est = established + if isinstance(est, int) and not isinstance(est, bool): + est = str(est) + # userInfo deser 0x18013ec10 case 0x110 takes the STRING getter then strtol + # base 10 into rec+0x64. An int on the wire here is the scalar/string type + # mismatch class that busy-loops the SAX reader at 0x1801c7f1a. + if not isinstance(est, str) or not est.isdigit(): + raise ValueError("established must be a STRING of digits (deser " + "0x18013ec10 case 0x110 -> strtol base 10), got %r" + % (established,)) + return name, abbr, est + + +class Account: + """Mutable singleton; see module docstring for the tier/precedence rules.""" + + # tier 1 re-exported as attributes so call sites can just use ACCOUNT.X + NAMESPACE = NAMESPACE + PLATFORM = PLATFORM + CLIENT_PLATFORM = CLIENT_PLATFORM + SKU = SKU + TITLE_ID = TITLE_ID + CLIENT_ID = CLIENT_ID + CONTENT_ID = CONTENT_ID + ENTITLEMENT_TAG = ENTITLEMENT_TAG + ENTITLEMENT_GROUP = ENTITLEMENT_GROUP + PERSONA_STATUS = PERSONA_STATUS + USER_SESSION_TYPE = USER_SESSION_TYPE + + def __init__(self, path=None): + self.path = path or ACCOUNT_PATH + self._loaded = False + self._stored = {} # what is on disk (tier 2+3 only) + for f in _FIELDS: + setattr(self, "_" + f, None) + + # ------------------------------------------------------------ persistence + def load(self, force=False): + """Idempotent. Reads fut_account.json, migrating from the legacy game + save the first time. Never raises on a malformed file -- a broken + account file must not stop the harness booting.""" + with _LOCK: + if self._loaded and not force: + return self + stored = {} + if os.path.exists(self.path): + try: + with open(self.path) as f: + raw = json.load(f) + if isinstance(raw, dict): + stored = {k: v for k, v in raw.items() if k in _FIELDS} + except (OSError, ValueError) as e: + sys.stderr.write("[account] WARN: ignoring unreadable %s (%s)\n" + % (self.path, e)) + else: + stored = self._migrate_from_profile() + if stored: + self._stored = stored + try: + self._write() + except OSError as e: + # A read-only tools/ must not stop a server booting; the + # migrated values still apply for this process. + sys.stderr.write("[account] WARN: could not write %s (%s)\n" + % (self.path, e)) + self._stored = stored + self._loaded = True + return self + + def _migrate_from_profile(self): + """Lift identity/club out of a pre-existing fifa17_profile.json so an + existing club name survives the move to this module. Read-only: the game + save is never modified, and its copies stay there harmlessly.""" + if not os.path.exists(LEGACY_PROFILE_PATH): + return {} + try: + with open(LEGACY_PROFILE_PATH) as f: + p = json.load(f) + except (OSError, ValueError): + return {} + if not isinstance(p, dict): + return {} + out = {} + for src, dst in (("personaId", "persona_id"), ("personaName", "persona_name"), + ("clubName", "club_name"), ("clubAbbr", "club_abbr"), + ("established", "established")): + if p.get(src) not in (None, ""): + out[dst] = p[src] + if out: + sys.stderr.write("[account] migrated %s from %s\n" + % (",".join(sorted(out)), os.path.basename(LEGACY_PROFILE_PATH))) + return out + + def _write(self): + tmp = self.path + ".tmp" + with open(tmp, "w") as f: + json.dump(self._stored, f, indent=1, sort_keys=True) + f.write("\n") + os.replace(tmp, self.path) + + def save(self): + """Persist tiers 2+3 (only fields that differ from the built-in default, + plus anything already stored). Tier 1 is never written.""" + with _LOCK: + self.load() + for f in _FIELDS: + v = getattr(self, "_" + f) + if v is not None: + self._stored[f] = v + self._write() + return self + + # ------------------------------------------------------------ field access + def _get(self, field): + self.load() + env, default = _FIELDS[field] + v = getattr(self, "_" + field) + if v is None: + v = os.environ.get(env) + if v is None: + v = self._stored.get(field) + if v is None: + v = default + if field in _INT_FIELDS and v is not None: + v = int(v) + return v + + def _set(self, field, value): + with _LOCK: + self.load() + if field in _INT_FIELDS: + value = int(value) + setattr(self, "_" + field, value) + + # tier 2 --------------------------------------------------------------- + @property + def persona_id(self): + """Blaze SESS.BUID / SESS.UID / PDTL.PID, LSX PersonaId/UserId, UTAS + userInfo.personaId and squad.personaId. UNVERIFIED KNOB: REPACK_INTEL + Section 2.2 records the repack's decrypted .dlf license carrying + 33068179 (consumed by dbdata.dll!getTableData). No .dlf + exists on disk any more, so changing this cannot be re-checked + statically -- treat it as a deliberate single-variable experiment.""" + return self._get("persona_id") + + @persona_id.setter + def persona_id(self, v): + self._set("persona_id", v) + + @property + def persona_name(self): + """Blaze PDTL.DSNM / LSX GetProfileResponse Persona / UTAS sellerName.""" + return self._get("persona_name") + + @persona_name.setter + def persona_name(self, v): + v = str(v).strip() + if not v: + raise ValueError("persona_name must not be empty") + self._set("persona_name", v) + + @property + def email(self): + """Blaze SESS.MAIL / AccountInfo.MAIL. Derived from persona_name when unset.""" + v = self._get("email") + return v if v else "%s@openfut.local" % self.persona_name.lower() + + @email.setter + def email(self, v): + self._set("email", v) + + @property + def locale(self): + return self._get("locale") + + @locale.setter + def locale(self, v): + self._set("locale", v) + + @property + def country(self): + return self._get("country") + + @property + def currency(self): + return self._get("currency") + + # DERIVED, read-only. Deliberately NOT independent knobs: the client sends + # both `nuc` and `nucleusPersonaId` and both came out equal, so the + # getter->field mapping is undetermined. Do not split them until a live test + # proves Blaze USER_ID/EXT_ID may legitimately differ from PERSONA_ID. + @property + def user_id(self): + """Blaze blazeId / userId (SESS.BUID, SESS.UID, AccountInfo.UID).""" + return self.persona_id + + @property + def ext_id(self): + """Blaze XREF / EXID externalId.""" + return self.persona_id + + @property + def locale_dash(self): + """"en-US" form, as the client sends it in POST /ut/auth.""" + return self.locale.replace("_", "-") + + @property + def account_locale_int(self): + """Packed 4-char locale for Blaze AccountInfo; 'enUS' == 0x656E5553. + Overwritten per-session by the client's own PreAuthRequest LANG/LOC.""" + s = (self.locale.replace("_", "") + "\0\0\0\0")[:4] + return int.from_bytes(s.encode("latin-1"), "big") + + # tier 3 --------------------------------------------------------------- + @property + def club_name(self): + return self._get("club_name") + + @club_name.setter + def club_name(self, v): + name, _ = validate_club(v, self.club_abbr) + self._set("club_name", name) + + @property + def club_abbr(self): + return self._get("club_abbr") + + @club_abbr.setter + def club_abbr(self, v): + _, abbr = validate_club(self.club_name, v) + self._set("club_abbr", abbr) + + @property + def established(self): + """STRING of digits -- see validate_club().""" + return str(self._get("established")) + + @established.setter + def established(self, v): + _, _, est = validate_club(self.club_name, self.club_abbr, v) + self._set("established", est) + + @property + def squad_name(self): + return self._get("squad_name") + + @squad_name.setter + def squad_name(self, v): + self._set("squad_name", v) + + def set_club(self, name=None, abbr=None, established=None): + """Atomic validated club update. Raises ValueError before mutating + anything, so a rejected rename leaves the account untouched.""" + with _LOCK: + n = self.club_name if name is None else name + a = self.club_abbr if abbr is None else abbr + e = self.established if established is None else established + n, a, e = validate_club(n, a, e) + self._set("club_name", n) + self._set("club_abbr", a) + self._set("established", e) + return n, a, e + + # ------------------------------------------------- tier 4: online profile + @property + def pow_level(self): + return self._get("pow_level") + + @property + def pow_exp(self): + return self._get("pow_exp") + + @property + def pow_exp_max(self): + return self._get("pow_exp_max") + + @property + def pow_funds(self): + return self._get("pow_funds") + + @property + def pow_funds_cap(self): + return self._get("pow_funds_cap") + + def set_online_profile(self, level=None, exp=None, exp_max=None, + funds=None, funds_cap=None): + """Atomic validated update of the EASFC/POW profile (the top-right hub + bar: LVL x, the exp bar, and the credit counter). + + Validation is deliberately light -- unlike the club fields there is no + reversed length/range check to cite, so we only enforce what is + structurally required: non-negative ints, and exp <= exp_max so the + widget cannot render a bar past 100%.""" + with _LOCK: + lv = self.pow_level if level is None else int(level) + xp = self.pow_exp if exp is None else int(exp) + xm = self.pow_exp_max if exp_max is None else int(exp_max) + fu = self.pow_funds if funds is None else int(funds) + fc = self.pow_funds_cap if funds_cap is None else int(funds_cap) + if min(lv, xp, xm, fu, fc) < 0: + raise ValueError("online-profile values must be >= 0") + if lv < 1: + raise ValueError("pow_level must be >= 1") + if xm < 1: + raise ValueError("pow_exp_max must be >= 1") + if xp > xm: + raise ValueError("pow_exp (%d) exceeds pow_exp_max (%d)" % (xp, xm)) + if fu > fc: + raise ValueError("pow_funds (%d) exceeds pow_funds_cap (%d)" % (fu, fc)) + for k, v in (("pow_level", lv), ("pow_exp", xp), ("pow_exp_max", xm), + ("pow_funds", fu), ("pow_funds_cap", fc)): + self._set(k, v) + return lv, xp, xm, fu, fc + + # ------------------------------------------------------------- adoption + def adopt_from_auth(self, body): + """Adopt the identity the client itself asserts in POST /ut/auth. + + Live-observed body (three byte-identical runs; builder CardsDLL + FUN_180125900): + {"sku":"FFA17PCC","nucleusPersonaPlatform":"pc","nuc":33068179, + "nucleusPersonaId":33068179,"nucleusPersonaDisplayName":"CAGE", + "locale":"en-US","regionCode":"US",...} + + RECONCILIATION RULE: the wire is truth, the stored JSON is a cache. + Adopt-and-overwrite with a WARN; never refuse -- a mismatch is the + NORMAL state on the first boot after a rename. Returns True if anything + changed. FUT_ADOPT_AUTH=0 disables adoption entirely. + + Why it matters: the squad parser 0x18013d1f0 stores personaId (atom + 0x21b) at squad+0x38 and compares it against + FUN_18011a830()->vtbl[0x908]; on mismatch it silently builds a throwaway + squad instead of erroring. Same comparison in FUN_1801464e0 for squad + summaries. Adopting makes that comparison correct by construction. + """ + if os.environ.get("FUT_ADOPT_AUTH") == "0": + return False + if not isinstance(body, dict): + return False + changed = [] + pid = body.get("nucleusPersonaId", body.get("nuc")) + if isinstance(pid, (int, str)) and not isinstance(pid, bool): + try: + pid = int(pid) + except (TypeError, ValueError): + pid = None + if pid and pid != self.persona_id: + changed.append("persona %d -> %d" % (self.persona_id, pid)) + self._set("persona_id", pid) + name = body.get("nucleusPersonaDisplayName") + if isinstance(name, str): + name = name.strip() + if name and name != NULL_IDENTITY_NAME and name != self.persona_name: + changed.append("name %r -> %r" % (self.persona_name, name)) + self._set("persona_name", name) + loc = body.get("locale") + if isinstance(loc, str) and loc: + loc = loc.replace("-", "_") + if loc != self.locale: + changed.append("locale %r -> %r" % (self.locale, loc)) + self._set("locale", loc) + if changed: + sys.stderr.write("[account] WARN: adopted from /ut/auth: %s\n" + % "; ".join(changed)) + self.save() + return bool(changed) + + # ------------------------------------------------------------------ misc + def as_dict(self): + """Effective tier 2+3 values plus the derived ones (for --show / logs).""" + d = {f: getattr(self, f) for f in _FIELDS} + d["email"] = self.email # resolve the derived default + d.update(user_id=self.user_id, ext_id=self.ext_id, + locale_dash=self.locale_dash, + account_locale_int=self.account_locale_int) + return d + + def locked(self): + return {k: globals()[k] for k in _LOCKED} + + def __repr__(self): + return ("" + % (self.persona_id, self.persona_name, self.club_name, + self.club_abbr, self.established, self.NAMESPACE)) + + +ACCOUNT = Account() + +# Back-compat aliases so the old module-level names keep resolving where they +# are still imported. Prefer ACCOUNT. in new code -- these are snapshots +# taken at import time and will NOT reflect a later adopt_from_auth(). +PERSONA_ID = ACCOUNT.persona_id +PERSONA_NAME = ACCOUNT.persona_name + + +# ===================================================================== CLI +def _main(argv): + import argparse + ap = argparse.ArgumentParser( + prog="fut_account.py", + description="Inspect / edit the OpenFUT FIFA 17 account identity. " + "Editing the club here is the SAFE rename path: it is " + "offline, validated against the client's own limits, and " + "never involves the in-game rename flow. Restart the " + "harness after changing anything.") + ap.add_argument("--show", action="store_true", help="print the account and exit") + ap.add_argument("--club-name", help="club name (%d..%d chars)" % (CLUB_NAME_MIN, CLUB_NAME_MAX)) + ap.add_argument("--club-abbr", help="club abbreviation (%d..%d chars)" % (CLUB_ABBR_MIN, CLUB_ABBR_MAX)) + ap.add_argument("--established", help="founding year, digits only") + ap.add_argument("--squad-name", help="default squad name") + ap.add_argument("--persona-name", help="display name (Blaze DSNM / LSX Persona)") + ap.add_argument("--persona-id", type=int, help="persona id (UNVERIFIED knob; see docstring)") + ap.add_argument("--email", help="account email (Blaze MAIL)") + # tier 4: the online (EASFC/POW) profile shown in the top-right hub bar + ap.add_argument("--pow-level", type=int, help="online profile level (LVL)") + ap.add_argument("--pow-exp", type=int, help="online profile XP into the current level") + ap.add_argument("--pow-exp-max", type=int, help="XP needed for the next level") + ap.add_argument("--pow-funds", type=int, help="EASFC credits (the coin counter)") + ap.add_argument("--pow-funds-cap", type=int, help="EASFC credit cap") + ap.add_argument("--json", action="store_true", help="machine-readable output") + a = ap.parse_args(argv) + + ACCOUNT.load() + dirty = False + try: + if a.club_name or a.club_abbr or a.established: + ACCOUNT.set_club(a.club_name, a.club_abbr, a.established) + dirty = True + if a.persona_name: + ACCOUNT.persona_name = a.persona_name + dirty = True + if a.persona_id: + ACCOUNT.persona_id = a.persona_id + dirty = True + if a.email: + ACCOUNT.email = a.email + dirty = True + if a.squad_name: + ACCOUNT.squad_name = a.squad_name + dirty = True + if any(v is not None for v in (a.pow_level, a.pow_exp, a.pow_exp_max, + a.pow_funds, a.pow_funds_cap)): + ACCOUNT.set_online_profile(a.pow_level, a.pow_exp, a.pow_exp_max, + a.pow_funds, a.pow_funds_cap) + dirty = True + except ValueError as e: + sys.stderr.write("error: %s\n" % e) + return 2 + if dirty: + ACCOUNT.save() + print("saved %s" % ACCOUNT.path) + + if a.json: + print(json.dumps({"account": ACCOUNT.as_dict(), "locked": ACCOUNT.locked()}, + indent=1, sort_keys=True)) + else: + d = ACCOUNT.as_dict() + print("account file : %s" % ACCOUNT.path) + print("-- identity (env-overridable, persisted) --") + for k in ("persona_id", "persona_name", "email", "locale", "country", "currency"): + print(" %-14s %s" % (k, d[k])) + print("-- club --") + for k in ("club_name", "club_abbr", "established", "squad_name"): + print(" %-14s %s" % (k, d[k])) + print("-- derived --") + for k in ("user_id", "ext_id", "locale_dash"): + print(" %-14s %s" % (k, d[k])) + print(" %-14s 0x%08x" % ("account_locale", d["account_locale_int"])) + print("-- locked wire constants (not settable) --") + for k, v in sorted(ACCOUNT.locked().items()): + print(" %-18s %s" % (k, v)) + if dirty: + print("\nrestart the harness for this to take effect: ./openfut-fut.sh restart") + return 0 + + +if __name__ == "__main__": + sys.exit(_main(sys.argv[1:])) diff --git a/fifa17-recon/tools/fut_admin.py b/fifa17-recon/tools/fut_admin.py new file mode 100644 index 0000000..595d639 --- /dev/null +++ b/fifa17-recon/tools/fut_admin.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""OpenFUT save maintenance — inspect and repair fifa17_profile.json offline. + +Exists because the pack→club hand-off is not proven. Cards from an opened pack land +in the PENDING pile (`profile["purchased"]`) and only reach the club when the client +sends `PUT ut/%s/item` (FutMoveCard) from the reveal screen's "send to club". Across +every logged session that request has fired **zero** times, while 12 cards sit +pending — so either the flow was never exercised in-game, or the client does not +issue it the way we assume. Same shape as the squad blocker: an assumed client +request that never actually arrives. + +Until a live pack-open settles it, this is the manual path. + +SAFETY: the client desyncs fatally (logout) if a card exists in BOTH the pending pile +and the club — see docs/CARD_SYSTEM.md and Store.move_items. `--flush-purchased` +therefore MOVES (never copies): each card is removed from `purchased` in the same +transaction that appends it to `items`. Run it with FIFA CLOSED so the client cannot +be holding a stale view of either pile. + +Usage: + fut_admin.py --show profile summary (default) + fut_admin.py --flush-purchased move every pending card into the club + fut_admin.py --flush-purchased -n dry run: show what would move + fut_admin.py --backup timestamped copy of the profile +""" +import argparse +import datetime +import json +import os +import shutil +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from fut_store import STORE # noqa: E402 + + +def _fmt(it): + return "asset=%-7s rating=%-3s pos=%-4s id=%s" % ( + it.get("assetId"), it.get("rating"), it.get("preferredPosition"), it.get("id")) + + +def show(): + p = STORE.profile() + rec = p.get("record", {}) + print("profile : %s" % STORE.path) + print("club : %s (%s) est %s" % (p.get("clubName"), p.get("clubAbbr"), + p.get("established"))) + print("coins : %s points: %s" % (p.get("coins"), p.get("points"))) + print("record : %s-%s-%s matches: %s" + % (rec.get("won", 0), rec.get("draw", 0), rec.get("loss", 0), + p.get("matchesPlayed", 0))) + print("club items : %d" % len(p.get("items", []))) + print("squads saved : %d" % len(p.get("squads", []))) + print("packs opened : %s" % p.get("packsOpened", 0)) + print("listings : %d" % len(p.get("listings", []))) + print("clientdata : %s" % (sorted(p.get("clientdata", {})) or "none")) + pend = p.get("purchased", []) + print("PENDING pack items: %d%s" + % (len(pend), " <-- not in the club; see --flush-purchased" if pend else "")) + for it in pend[:20]: + print(" %s" % _fmt(it)) + if len(pend) > 20: + print(" ... and %d more" % (len(pend) - 20)) + + +def backup(): + dst = "%s.%s.bak" % (STORE.path, + datetime.datetime.now().strftime("%Y%m%d-%H%M%S")) + shutil.copy2(STORE.path, dst) + print("backup -> %s" % dst) + return dst + + +def flush(dry_run): + pend = list(STORE.profile().get("purchased", [])) + if not pend: + print("nothing pending — the club already has every pack card") + return 0 + print("%d pending card(s)%s:" % (len(pend), " (DRY RUN)" if dry_run else "")) + for it in pend: + print(" %s" % _fmt(it)) + if dry_run: + print("\ndry run — nothing written. Re-run without -n to move them.") + return 0 + backup() + # Reuse the server's own move path so the pending/club invariant is enforced in + # exactly one place: move_items() deletes from `purchased` in the same locked + # transaction that appends to `items`. + moved = STORE.move_items([{"id": it["id"], "pile": "club"} for it in pend]) + p = STORE.profile() + print("\nmoved %d card(s) into the club" % len(moved)) + print("club items now: %d pending now: %d" + % (len(p.get("items", [])), len(p.get("purchased", [])))) + if p.get("purchased"): + print("WARNING: %d card(s) did not move — ids missing from the pending pile" + % len(p["purchased"])) + return 0 + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--show", action="store_true", help="profile summary (default)") + ap.add_argument("--flush-purchased", action="store_true", + help="move pending pack cards into the club (run with FIFA closed)") + ap.add_argument("-n", "--dry-run", action="store_true", help="with --flush-purchased") + ap.add_argument("--backup", action="store_true", help="timestamped profile copy") + a = ap.parse_args(argv) + if a.backup: + backup() + if a.flush_purchased: + return flush(a.dry_run) + show() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/fifa17-recon/tools/fut_seed.py b/fifa17-recon/tools/fut_seed.py index 2d8d1dd..f5b4d7f 100644 --- a/fifa17-recon/tools/fut_seed.py +++ b/fifa17-recon/tools/fut_seed.py @@ -25,9 +25,13 @@ # resourceId decompose 0x180166ca0 CONFIRMED: assetId = resourceId & 0xffffff, # high byte = version. Version byte value is the open question s2v0/s2v1 answer. # --------------------------------------------------------------------------- -import os +import os, sys -PERSONA_ID = 33068179 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from fut_account import ACCOUNT # single source of truth for identity + +# Back-compat snapshot; prefer ACCOUNT.persona_id in new code. +PERSONA_ID = ACCOUNT.persona_id ITEM_ID_BASE = 100000000 # Real FIFA17 assetIds read earlier from the live InGameDB. assetId 41 (Iniesta) @@ -59,7 +63,12 @@ def player_item(asset, rating, pos, version=0x00, nation=38, team=243, league=53 "resourceId": rid, "assetId": asset, "cardassetid": asset, - "definitionId": rid, # some FUT APIs key on definitionId + # definitionId is INERT in FIFA 17: it is not in the atom table at all, so + # the client's key hash never matches and it routes straight to the value-SKIP + # handler 0x180135ff0 -- same class as the itemDbVersion/checkServerDbVersion + # keys proven phantom in blaze_responder. Kept (harmless, and other FIFA + # versions do use it) but it is NOT read here; the live key is resourceId. + "definitionId": rid, "cardsubtypeid": 0, # 0..3 => PLAYER "itemType": "player", "rareflag": 1, @@ -88,8 +97,8 @@ def _base_squad(): MUST be unique 0..22) and manager empty -> zero item-deser calls by default.""" return { "id": 0, - "personaId": PERSONA_ID, # must equal logged-in persona (0x18014659c) - "squadName": "OpenFUT", + "personaId": ACCOUNT.persona_id, # must equal logged-in persona (0x18014659c) + "squadName": ACCOUNT.squad_name, "formation": "f442", "squadType": "REGULAR_SQUAD", "chemistry": 100, @@ -182,7 +191,7 @@ def squad_summary(squad): "chemistry": int(squad.get("chemistry", 0)), "formation": squad.get("formation", "f442"), "id": int(squad.get("id", 0)), - "squadName": squad.get("squadName", "OpenFUT"), + "squadName": squad.get("squadName", ACCOUNT.squad_name), "squadType": squad.get("squadType", "REGULAR_SQUAD"), } diff --git a/fifa17-recon/tools/fut_store.py b/fifa17-recon/tools/fut_store.py index 63a6e2b..7f88ccf 100644 --- a/fifa17-recon/tools/fut_store.py +++ b/fifa17-recon/tools/fut_store.py @@ -10,13 +10,19 @@ Item shape mirrors what /club and /squad already serve (fut_seed.player_item): resourceId/assetId + attrs; identity (name/photo/club/nation) resolves locally in FIFA from dbdata.dll on the club-search/add path (see docs/CARD_SYSTEM.md). """ -import json, os, threading +import json, os, sys, threading HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +from fut_account import ACCOUNT # single source of truth for identity/club + PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json")) -PERSONA_ID = 33068179 -PERSONA_NAME = "CAGE" +# Back-compat snapshots. Identity now lives in fut_account.ACCOUNT so Blaze, LSX +# and UTAS cannot drift apart; prefer ACCOUNT. in new code. These are +# import-time snapshots and will NOT reflect a later adopt_from_auth(). +PERSONA_ID = ACCOUNT.persona_id +PERSONA_NAME = ACCOUNT.persona_name _LOCK = threading.Lock() # Starter squad granted on first run (real FIFA17 assetIds; identity resolves @@ -68,16 +74,17 @@ def _new_profile(): for i, (a, r, p, n, lg, tm, at) in enumerate(STARTER_PLAYERS)] return { "version": 1, - "personaId": PERSONA_ID, - "personaName": PERSONA_NAME, - "clubName": "OpenFUT", - "clubAbbr": "OFC", - "established": "2026", + # personaId/personaName/clubName/clubAbbr/established are NOT seeded + # here any more -- they belong to fut_account.ACCOUNT. _sync_identity() + # mirrors them into the save on every load so existing readers + # (utas_server's userInfo, tradepile sellerName) keep working unchanged + # and can never disagree with what Blaze/LSX assert. "coins": 15000, "points": 0, "record": {"won": 0, "draw": 0, "loss": 0}, "nextItemId": ITEM_ID_BASE + len(STARTER_PLAYERS) + 1, "items": items, # owned club items + "purchased": [], # unassigned/pending items from opened packs "squads": [], # saved squads (raw squad objects from PUT /squad) "packsOpened": 0, } @@ -96,9 +103,27 @@ class Store: self._p = json.load(f) else: self._p = _new_profile() + self._sync_identity() self._save() + self._sync_identity() return self._p + def _sync_identity(self): + """Mirror ACCOUNT's identity/club into the in-memory save. + + The save file used to OWN these five keys; they now live in + fut_account.json (which is where ACCOUNT migrated them from on first + run, so this is a no-op for an existing profile). Mirroring rather than + deleting keeps every current reader working without an edit, and makes + drift between the save and the wire impossible by construction.""" + p = self._p + p["personaId"] = ACCOUNT.persona_id + p["personaName"] = ACCOUNT.persona_name + p["clubName"] = ACCOUNT.club_name + p["clubAbbr"] = ACCOUNT.club_abbr + p["established"] = ACCOUNT.established + return p + def _save(self): tmp = self.path + ".tmp" with open(tmp, "w") as f: @@ -109,6 +134,30 @@ class Store: def profile(self): return self.load() + def refresh_identity(self): + """Re-mirror ACCOUNT into the save AND persist it. + + Call this after anything mutates ACCOUNT at runtime (utas_server's club + rename, or the /ut/auth persona adoption) so the save cannot lag a session + behind the wire. Identity itself is owned by fut_account.json -- this only + keeps the save's copy honest.""" + with _LOCK: + self.load() + self._sync_identity() + self._save() + return self._p + + def profile_identity(self): + """Identity/club as served to the client. Sourced from ACCOUNT, never + from the save -- use this instead of profile().get("clubName").""" + return { + "personaId": ACCOUNT.persona_id, + "personaName": ACCOUNT.persona_name, + "clubName": ACCOUNT.club_name, + "clubAbbr": ACCOUNT.club_abbr, + "established": ACCOUNT.established, + } + def coins(self): return self.load()["coins"] @@ -138,6 +187,64 @@ class Store: self.load()["coins"] += amount self._save() + def quick_sell(self, ids): + """Remove cards (from either pile) and credit their discard value. + -> (count_sold, coins_credited). discardValue is 0 on our seeded cards, so + fall back to a rating-based figure rather than paying nothing.""" + def value(it): + dv = it.get("discardValue") or 0 + if dv: + return int(dv) + r = it.get("rating") or 0 + return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50 + with _LOCK: + p = self.load() + want = {i for i in ids if i is not None} + total = 0 + sold = 0 + for pile in ("purchased", "items"): + keep = [] + for it in p.get(pile, []): + if it.get("id") in want: + total += value(it) + sold += 1 + else: + keep.append(it) + p[pile] = keep + if sold: + p["coins"] = p.get("coins", 0) + total + self._save() + return sold, total + + def set_clientdata(self, key, value): + """Persist an opaque client blob (ut/%s/clientdata/). We never + interpret it -- the client wrote it, the client reads it back.""" + with _LOCK: + p = self.load() + p.setdefault("clientdata", {})[key] = value + self._save() + + def get_clientdata(self, key): + return self.load().get("clientdata", {}).get(key, {}) + + def record_match(self, result, coins): + """Commit a finished match: bump the W/D/L record and credit coins. + + `result` is "won" | "draw" | "loss". Returns the new (record, coins) so the + caller can build FutDestroyMatchServerResponse without a second read -- + allCoins must be the balance AFTER crediting, and reading it separately + would race another mutation.""" + with _LOCK: + p = self.load() + rec = p.setdefault("record", {"won": 0, "draw": 0, "loss": 0}) + if result in rec: + rec[result] += 1 + p["coins"] = p.get("coins", 0) + max(0, int(coins)) + p.setdefault("matchesPlayed", 0) + p["matchesPlayed"] += 1 + self._save() + return dict(rec), p["coins"] + def save_squad(self, squad): with _LOCK: p = self.load() @@ -145,6 +252,37 @@ class Store: p["squads"] = [s for s in p["squads"] if s.get("id") != sid] + [squad] self._save() + def move_items(self, requests): + """FutMoveCard: transfer item(s) from the pending/purchased pile into their + target pile (FIFO's model), persist, return the moved cards. A purchased + card must NOT exist in both the purchased pile and the club, or the client + desyncs -> fatal logout. Cards live in profile["purchased"] until moved.""" + with _LOCK: + p = self.load() + pending = p.setdefault("purchased", []) + by_id = {it["id"]: it for it in pending} + moved = [] + for r in requests: + it = by_id.get(r.get("id")) + if it is None: + continue + pile = r.get("pile", it.get("pile", "club")) + it["pile"] = pile + if pile == "club": + it["itemState"] = "free" + p.setdefault("items", []).append(it) + moved.append(it) + if moved: + moved_ids = {it["id"] for it in moved} + p["purchased"] = [x for x in p["purchased"] if x["id"] not in moved_ids] + self._save() + return moved + + def purchased(self): + """Items still held in the purchased/unassigned pile (returned by + GET /purchased/items); they move to the club via FutMoveCard (PUT /item).""" + return self.load().get("purchased", []) + def active_squad(self): sq = self.load()["squads"] return sq[0] if sq else None @@ -194,8 +332,10 @@ class Store: def open_pack(self, price, count, gold=True): - """Deduct `price` coins, generate `count` player items from the pool, add - them to the club, return them. Returns None if not enough coins.""" + """Deduct `price` coins, generate `count` player items from the pool, and + place them in the PENDING purchased pile (unassigned). They are NOT owned + club items until moved there via FutMoveCard (PUT /item). Returns None if + not enough coins.""" import random if not self.spend(price): return None @@ -203,15 +343,15 @@ class Store: picks = [random.choice(pool) for _ in range(count)] items = [_item(self.new_item_id(), a, r, p, n, lg, tm, at) for (a, r, p, n, lg, tm, at) in picks] - self.add_items(items) with _LOCK: - self.load()["packsOpened"] += 1 - self._last_pack = items + p = self.load() + p.setdefault("purchased", []).extend(items) + p["packsOpened"] += 1 self._save() return items def last_pack(self): - return getattr(self, "_last_pack", []) + return self.load().get("purchased", []) # Card pool for packs. TODO: replace with a full dbdata.dll extract (~18k players); @@ -229,9 +369,9 @@ PACK_POOL = STARTER_PLAYERS + [ # 3 store packs (price in coins, card count, gold-only). Ids are stable. PACK_CATALOG = [ - {"id": 101, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False}, - {"id": 102, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True}, - {"id": 103, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True}, + {"id": 1, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False}, + {"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True}, + {"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True}, ] diff --git a/fifa17-recon/tools/ghidra_env.py b/fifa17-recon/tools/ghidra_env.py index 37ff04b..f7946b6 100644 --- a/fifa17-recon/tools/ghidra_env.py +++ b/fifa17-recon/tools/ghidra_env.py @@ -33,13 +33,19 @@ pyghidra.start(verbose=False) from ghidra.app.decompiler import DecompInterface # noqa: E402 from ghidra.util.task import ConsoleTaskMonitor # noqa: E402 -DLL = "/tmp/fut/cardsdll.dll" -PROJ_DIR, PROJ = "/tmp/ghidra_fut", "cardsdll" +# Defaults target CardsDLL; override for another binary, e.g. powdll (the EASFC/POW +# layer, which is UNPACKED unlike FIFA17.exe): +# GHIDRA_DLL=/tmp/pow/powdll_Win64_retail.dll GHIDRA_PROJ_DIR=/tmp/pow \ +# GHIDRA_PROJ=powproj ghidra_env.py +DLL = os.environ.get("GHIDRA_DLL", "/tmp/fut/cardsdll.dll") +PROJ_DIR = os.environ.get("GHIDRA_PROJ_DIR", "/tmp/ghidra_fut") +PROJ = os.environ.get("GHIDRA_PROJ", "cardsdll") +PROG = os.environ.get("GHIDRA_PROG", os.path.basename(DLL)) # nested_project_location=False -> use /tmp/ghidra_fut/cardsdll.gpr itself (the # already-analysed project) instead of creating /tmp/ghidra_fut/cardsdll/. _ctx = pyghidra.open_program(DLL, project_location=PROJ_DIR, project_name=PROJ, - analyze=False, program_name="cardsdll.dll", + analyze=False, program_name=PROG, nested_project_location=False) flat = _ctx.__enter__() prog = flat.getCurrentProgram() @@ -156,6 +162,51 @@ def vtable(a, n=64): return out +def class_deser(cls): + """FutXServerResponse class name -> [(deserializer, vtable, factory), ...]. + + THE -4 RULE. 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. + So the reference to look up is `name_addr - 4`. Six attempts at class->deser + resolution failed before this was noticed -- four of them returned zero + candidates and were nearly written up as "the class has no deserializer". + Ghidra does create the reference, so no manual instruction decoding is needed. + + From the factory, the object's vtable is the .rdata address it references whose + first two qwords are functions; the deserializer is vtable slot +0x08. + + Verified against known-good controls: FutSquadSave -> 0x180171a60, + FutSquadList -> 0x180172140, FutCreateMatch -> 0x180120380 (3/3 correct when it + resolves). It DOES produce false negatives -- FutDestroyMatch and + FutSeasonLoadData return nothing despite having known deserializers -- so treat + an empty result as "unknown", never as "no deserializer exists". Always include + a control with a known answer in any batch. + """ + res = [] + for a in find_all(cls.encode() + b"\x00"): + for frm, typ, fn, ent in xrefs_to(a - 4): + if not ent: + continue + f = func(ent) + if f is None: + continue + for ad in f.getBody().getAddresses(True): + ins = listing.getInstructionAt(ad) + if ins is None: + continue + for r in ins.getReferencesFrom(): + t = int(r.getToAddress().getOffset()) + if not (0x1801E5000 <= t <= 0x1802891FF): + continue + try: + v0, v1 = qword(t), qword(t + 8) + except Exception: + continue + if (fm.getFunctionAt(addr(v0)) and fm.getFunctionAt(addr(v1))): + res.append((v1, t, ent)) + return res + + def fname(a): f = func(a) return f.getName() if f else "?" diff --git a/fifa17-recon/tools/lsx_responder.py b/fifa17-recon/tools/lsx_responder.py index eed59d2..821466d 100644 --- a/fifa17-recon/tools/lsx_responder.py +++ b/fifa17-recon/tools/lsx_responder.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# FROZEN: not started by openfut-fut.sh (see its SERVERS array); identity is NOT +# sourced from fut_account.py here. The live pair is lsx_responder_v2.py + +# blaze_responder_v3b.py -- edit those. Kept for reference/bisecting only. """ OpenFUT clean-room LSX responder for FIFA 17 (replaces the Steampunks stp-origin_emu in-process stub on 127.0.0.1:4216). diff --git a/fifa17-recon/tools/lsx_responder_v2.py b/fifa17-recon/tools/lsx_responder_v2.py index 273e4bb..64d8390 100644 --- a/fifa17-recon/tools/lsx_responder_v2.py +++ b/fifa17-recon/tools/lsx_responder_v2.py @@ -100,17 +100,27 @@ import time from Crypto.Cipher import AES +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from fut_account import ACCOUNT # noqa: E402 + # ---------------------------------------------------------------- identity -# SHARED CONSTANTS -- must stay byte-identical to blaze_responder_v3.py. -# A mismatch between what LSX reports here and what Blaze returns in -# LoginResponse.SESS.PDTL is exactly what raises AUTH_ERR_INVALID_PERSONA / -# AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA / AUTH_ERR_PERSONA_NOT_FOUND. -PERSONA_ID = 33068179 -PERSONA_NAME = "CAGE" -USER_ID = 33068179 -CONTENT_ID = "1027460" # FIFA 17 EA offer id -ENTITLEMENT_TAG = "ONLINE_ACCESS" -LOCALE = "en_US" +# SOURCED FROM fut_account.ACCOUNT, shared with blaze_responder_v3b.py, +# fut_store.py, fut_seed.py and utas_server.py. +# +# THE CONSTRAINT IS CROSS-LAYER CONSISTENCY, NOT ANY PARTICULAR VALUE: what LSX +# reports here must equal what Blaze returns in LoginResponse.SESS.PDTL and what +# UTAS serves as userInfo.personaId. (The previous comment blamed a mismatch for +# AUTH_ERR_INVALID_PERSONA / AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA / +# AUTH_ERR_PERSONA_NOT_FOUND -- those are Blaze *server* error codes and we are +# the server. Neither "CAGE" nor "33068179" appears in FIFA17.exe, CardsDLL or +# dbdata.dll; 33068179 lives only in stp-origin_emu.dll's own ini default. They +# stay the defaults because they are what the working stack asserts.) +PERSONA_ID = ACCOUNT.persona_id +PERSONA_NAME = ACCOUNT.persona_name +USER_ID = ACCOUNT.user_id # derived from persona_id +CONTENT_ID = ACCOUNT.CONTENT_ID # FIFA 17 EA offer id +ENTITLEMENT_TAG = ACCOUNT.ENTITLEMENT_TAG +LOCALE = ACCOUNT.locale AUTHCODE_FILE = "/tmp/openfut_authcode.txt" CLIENTID_FILE = "/tmp/openfut_lsx_clientid.txt" @@ -397,6 +407,10 @@ def build_reply(mid, req_name, attrs, conn, recipient=""): # (OriginGetDefaultUser @0x1470da6d0 / OriginGetDefaultPersona # @0x1470da680 are bare reads of those fields, written only by # OriginSDK::Initialize @0x1470e5ad5/0x1470e5ae1). Keep it complete. + # ONLY PersonaId/UserId/Persona are substituted from ACCOUNT; the rest + # of this template (Country/CommerceCountry/GeoCountry/CommerceCurrency/ + # AvatarId/IsSubscriber/IsUnderAge) is byte-exact per REPACK_INTEL 1.4 + # and is latched into OriginSDK[+0x3a0]/[+0x3a8] -- leave it verbatim. return resp(mid, f'GetProfileResponse IsSubscriber="true" PersonaId="{PERSONA_ID}" ' f'AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US" ' diff --git a/fifa17-recon/tools/openfut-fut.sh b/fifa17-recon/tools/openfut-fut.sh index ca62486..829bd39 100755 --- a/fifa17-recon/tools/openfut-fut.sh +++ b/fifa17-recon/tools/openfut-fut.sh @@ -22,6 +22,10 @@ SERVERS=( "roster roster_server.py 8081 -" "utas utas_server.py 8099 -" "autopatch autopatch.py - -" + # POW/EASFC — the "EA FC servers unreachable" layer. Harmless when idle: it just + # binds 8094/8080 and nothing points at it unless FUT_POW=1 makes blaze serve the + # FIFA_POW_URL redirect keys. See pow_server.py for the powdll evidence. + "pow pow_server.py 8094,8080 -" ) c() { printf ' %s\n' "$*"; } diff --git a/fifa17-recon/tools/pow_server.py b/fifa17-recon/tools/pow_server.py new file mode 100644 index 0000000..3b7cd59 --- /dev/null +++ b/fifa17-recon/tools/pow_server.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +"""OpenFUT — POW / EASFC server for FIFA 17 (clean-room). + +WHY THIS EXISTS +--------------- +The FUT hub's "EA FC servers are unreachable / PRESS Q TO RE-CONNECT" banner is +NOT the FUT/UTAS layer, NOT Blaze and NOT Origin/LSX -- all three are healthy in +our live logs while the banner is showing. It is the EASFC layer, implemented in +`powdll_Win64_retail.dll` (1.1 MB, UNPACKED and string-rich -- unlike the Denuvo +-packed FIFA17.exe, this one can actually be reversed). + +POW is a THIRD HTTP API, alongside Blaze and UTAS, that we have never served: + api pas.gt.easfc.ea.com:8094 paths `pow/...` + content content.lt.easfc.ea.com:8080 paths `pow/imgAssets/...`, artAssets, ... +Neither hostname is in /etc/hosts nor in the iptables DNAT, so every POW call dies +at DNS resolution and the client raises the reconnect prompt. + +REVERSED FROM powdll (PE base 0x180000000, Ghidra project /tmp/pow/powproj): + * FUN_18005a460 -- POW config init. Reads, through the SAME client-config store + that already feeds us ROSTERUPDATE_URL (cfg->vtbl[0x30] = getString with a + default): "FIFA_POW_URL", "FIFA_POW_CONTENT_SERVER_URL", and "POW_IS_ON". + It picks an http:// vs https:// prefix (PTR_s_http____18010aee0 / + PTR_s_https____18010aee8). So POW can be redirected purely by serving those + keys from blaze_responder_v3b.py -- no /etc/hosts and no root required. + * FUN_18005cb40 -- the health-check / reconnect handler. Issues + `pow/healthcheck/system/all` via the request builder FUN_18005e780, then sets + the POW connection state at POWmgr[0x6ac]: + 1 = connected/online 3 = disconnected (raises the prompt) + It is also the site that fires the `POWService::PowReconnect` FE event. + * FUN_18005c970 fires POWService::PowBlazeDisconnected, + FUN_1800a8590 fires POWService::TriggerPleaseConnectMsg, + FUN_1800ad090 references TXT_EASFC_RECONNECT_PROMPT (the banner string). + +STATUS: the REQUEST side is mapped (58 `pow/...` path templates extracted from the +binary, see PATHS below). The RESPONSE schemas are NOT yet reversed -- powdll's +parsers have not been walked. So this server's job right now is to be a faithful, +loud LOGGER: bind the ports, answer every request in a way that cannot wedge the +client, and write the exact method/path/headers/body of everything POW asks for to +/tmp/pow_server.log. That capture is what turns the response schemas from guesswork +into reversing targets, exactly as the UTAS log did for the squad work. + +MODES (POW_MODE): + log (default) every request -> 200 {} (assets -> 404), everything logged. + Nothing is asserted about our capabilities; safest first run. + serve additionally answers the handful of paths whose shape we can + infer (auth/healthcheck/counts) with minimal plausible bodies. + Use this only AFTER a capture run, and expect to iterate. + +Ports: POW_ADDR (default 127.0.0.1:8094), POW_CONTENT_ADDR (default 127.0.0.1:8080). +""" +import datetime, json, os, re, sys, threading, http.server + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +try: + from fut_account import ACCOUNT # username/persona, single source +except Exception: # keep the logger usable standalone + ACCOUNT = None + +LOG = os.environ.get("POW_LOG", "/tmp/pow_server.log") +# Default flipped to `serve` once the schemas were recovered from powdll: `log` +# answers every list with {}, which makes the catalogue pager spin forever (983 +# requests in 84s, live-captured). POW_MODE=log is still available for a fresh +# capture run. +MODE = os.environ.get("POW_MODE", "serve") +API_ADDR = os.environ.get("POW_ADDR", "127.0.0.1:8094") +CONTENT_ADDR = os.environ.get("POW_CONTENT_ADDR", "127.0.0.1:8080") + + +def _split(hostport, default_port): + host, _, port = hostport.partition(":") + return (host or "127.0.0.1", int(port or default_port)) + + +def log(m): + line = "[%s] %s" % (datetime.datetime.now().strftime("%H:%M:%S"), m) + print(line, flush=True) + try: + with open(LOG, "a") as f: + f.write(line + "\n") + except Exception: + pass + + +# Every `pow/...` path template found in powdll_Win64_retail.dll. Kept verbatim so +# the log can flag an incoming path that is NOT in this list (i.e. our extraction +# missed something) rather than silently lumping it in with the known set. +PATHS = [ + "pow/auth", "pow/healthcheck/system/all", "pow/nucleus/entitlements", + "pow/v2/activity", "pow/activity/count", "pow/bank/user/account", + "pow/bank/currency/%s/cap/info", "pow/chal/user/prog", "pow/communication/all", + "pow/communication/all/countUnread", "pow/communication/count", + "pow/communication/type/%s", "pow/communication/attributes/type/%s", + "pow/components/EASFCWidget", "pow/gamechange/gamechangetype/%s", + "pow/inventory/item", "pow/inventory/item/list", + "pow/lvl/user/tiergp/%s/tiertp/%s", "pow/lvl/weight/tiergp/%s/tiertp/%s", + "pow/message", "pow/mm", "pow/mm/game/%s/message/list", + "pow/news/count/unread", "pow/news/opt", "pow/news/user", + "pow/pfyc/user", "pow/pfyc/user/club", "pow/pfyc/user/prefs/shareinfo", + "pow/store/game/%s/catalog/list", "pow/store/game/%s/catalog/%d/item/list", + "pow/store/gift/list", "pow/user/friends", + "pow/users/info/tiergp/%s/tiertp/%s", +] +_KNOWN = [re.compile("^/?" + re.escape(p).replace(r"\%s", "[^/]+").replace(r"\%d", r"\d+") + .replace(r"\%lld", r"\d+") + "$") for p in PATHS] + + +# Asset roots are PREFIXES in the binary ("pow/imgAssets/", plus %d-templated file +# names), so match them by prefix rather than exact template or every art fetch +# trips the unknown-path flag. +_ASSET_PREFIXES = ("pow/imgAssets/", "pow/artAssets/", "pow/facebook/", + "pow/cacheresponse/") + + +def is_known_path(path): + p = path.split("?", 1)[0] + if p.lstrip("/").startswith(_ASSET_PREFIXES): + return True + return any(rx.match(p) for rx in _KNOWN) + + +def _username(): + if ACCOUNT is not None: + return ACCOUNT.persona_name + return os.environ.get("POW_USERNAME", "CAGE") + + +def _persona_id(): + if ACCOUNT is not None: + return ACCOUNT.persona_id + return int(os.environ.get("POW_PERSONA_ID", "33068179")) + + +# ---- response schemas, recovered from powdll --------------------------------- +# Every key below is a LITERAL STRING in powdll_Win64_retail.dll, i.e. a name the +# client's parser actually compares against. Addresses are the literal's location. +# +# level parser FUN_180094700 groups exactly these seven: +# level(0x1800c9862) exp(0x1800e1974) currLevelExpMin(0x1800e1978) +# currLevelExpMax(0x1800e1988) isMaxLevel(0x1800e1998) dailyXpCap(0x1800e1bb0) +# currency(0x1800c96e0) +# paging: itemsTotal(0x1800e18c0) numItems(0x1800e17d8) totalCount(0x1800c9218) +# bank (contiguous field-name table, i.e. a reflection-style schema): +# currencies(0x1800c96b8) currency(0x1800c96e0) currencyName(0x1800c96f0) +# funds(0x1800c98f0) fundsBalance(0x1800c98f8) fundsCap(0x1800c9908) +# fundsCapInfo(0x1800c9918) fundsEarned(0x1800c9928) +# accountBalance(0x1800c92e0) balance(0x1800ccb28) numCurrency(0x1800e1808) +# pow_funds(0x1800c7f30) -- the currency NAME the client asks for by +# `pow/bank/currency/pow_funds/cap/info` (live-captured). +# +# DELIBERATELY NOT INVENTED: personaId / personaName / userId / sessionId / +# displayName / personaList do NOT exist as literals anywhere in powdll, so an +# auth response carrying them would be parsed as nothing. An earlier draft of this +# file asserted exactly those keys -- it was wrong and is corrected here. +POW_CURRENCY = "pow_funds" + + +# ---- ENVELOPE PROBE ---------------------------------------------------------- +# The field NAMES are certain (literals in powdll). The top-level ENVELOPE is not: +# serving the level record at the JSON root was live-tested and IGNORED -- the hub +# still read "LVL: 0/0". The wrapper is not statically recoverable so far: the name +# tables (FUN_180094700 etc.) are plain `return names[idx]` helpers with no schema +# descriptor attached, and their only other xrefs are .pdata unwind entries. +# +# So probe empirically, but in ONE launch instead of one-per-candidate: emit the +# record at the root AND under every plausible wrapper key at once. A reflection +# parser ignores members it has no field for (the same SKIP behaviour CardsDLL's +# deserializers use), so the extra copies are inert -- whichever wrapper the client +# looks for, it finds. Wrapper candidates are the envelope-ish literals that exist +# in powdll: data(0x1800c818c) result(0x1800ce3fc) items(0x1800c9e28) +# content(0x1800c9378) status(0x1800dd2c0) success(0x1800daed8) message(0x1800cef28). +# +# Set POW_ENVELOPE=root to serve ONLY the bare record (no probe copies) once the +# right wrapper is known. +_ENVELOPE = os.environ.get("POW_ENVELOPE", "probe") + + +def _wrap(record, list_key="items"): + """Root record + probe copies under each candidate wrapper.""" + if _ENVELOPE == "root": + return dict(record) + body = dict(record) + for k in ("data", "result", "content"): + body[k] = dict(record) + body[list_key] = [dict(record)] + body["numItems"] = 1 + body["itemsTotal"] = 1 + body["totalCount"] = 1 + body["status"] = "OK" + body["success"] = True + return body + + +def level_record(): + """The seven fields powdll's level name table (FUN_180094700) enumerates.""" + a = ACCOUNT + return { + "level": a.pow_level if a else 1, + "exp": a.pow_exp if a else 0, + "currLevelExpMin": 0, + "currLevelExpMax": a.pow_exp_max if a else 1000, + "isMaxLevel": False, + "dailyXpCap": 0, + "currency": POW_CURRENCY, + } + + +def level_body(): + """pow/lvl/user/tiergp/%s/tiertp/%s -> the hub's "LVL: x/y" widget.""" + return _wrap(level_record(), list_key="levels") + + +def bank_body(): + """pow/bank/user/account -> the EASFC credit counter next to the cart.""" + a = ACCOUNT + funds = a.pow_funds if a else 0 + cap = a.pow_funds_cap if a else 100000 + entry = { + "currencyName": POW_CURRENCY, + "currency": POW_CURRENCY, + "funds": funds, + "fundsBalance": funds, + "fundsEarned": 0, + "fundsCap": cap, + "balance": funds, + "accountBalance": funds, + } + body = _wrap(entry, list_key="currencies") + body["numCurrency"] = 1 + return body + + +def _empty_page(): + """Any paginated list. The count fields are what TERMINATE the pager. + + Not cosmetic: with a bare {} the catalogue pager never learns the result count + and re-requests offset=0&count=49 forever -- 983 identical requests in 84s on + the first live capture, still 432 with only itemsTotal/numItems set. So emit + the FULL count vocabulary that powdll's list envelope reader FUN_180094560 + enumerates (numItems 0x1800e17d8, numOwnedItems 0x1800e17e8, numLockedItems + 0x1800e17f8, numCurrency 0x1800e1808) plus the totals the catalog-item reader + FUN_1800945c0 knows (itemCount 0x1800e18b0, itemsTotal 0x1800e18c0, + itemsOwned 0x1800e18d0), and an empty array under every plausible list key.""" + body = { + "numItems": 0, "numOwnedItems": 0, "numLockedItems": 0, "numCurrency": 0, + "itemCount": 0, "itemsTotal": 0, "itemsOwned": 0, + "totalCount": 0, "count": 0, "offset": 0, + "status": "OK", "success": True, + } + for k in ("items", "list", "data", "result", "content", "catalogs", + "currencies", "entries"): + body[k] = [] + return body + + +def serve_body(path, method): + """MODE=serve. Bodies built only from keys verified present in powdll (above). + Returns None to fall through to {}.""" + p = path.split("?", 1)[0].lstrip("/") + if p == "pow/healthcheck/system/all": + # FUN_18005cb40 issues this first, then sets POWmgr[0x6ac] 1=connected / + # 3=disconnected. Live: the client went ONLINE with a bare {} here, so the + # state is driven by transport success, not by this body. Keep it minimal. + return {} + if p.startswith("pow/lvl/"): # user + weight both parse here + return level_body() + if p == "pow/bank/user/account": + return bank_body() + if p.startswith("pow/bank/currency/") and p.endswith("/cap/info"): + a = ACCOUNT + return {"currencyName": POW_CURRENCY, + "fundsCap": (a.pow_funds_cap if a else 100000), + "fundsEarnedInPeriod": 0} + if p.endswith("/count") or p.endswith("/countUnread"): + return {"count": 0, "totalCount": 0} + # Everything list-shaped gets a terminating page. Catalogue, inventory, gifts, + # friends, activity, messages, news -- all were captured live and all page. + if ("/list" in p or p in ("pow/v2/activity", "pow/user/friends", "pow/message", + "pow/mm", "pow/communication/all", "pow/news/user", + "pow/nucleus/entitlements", "pow/inventory/item")): + return _empty_page() + return None + + +class _Handler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + kind = "api" + + def _handle(self): + n = int(self.headers.get("Content-Length", 0) or 0) + body = self.rfile.read(n) if n else b"" + tag = "" if is_known_path(self.path) else " !! PATH NOT IN THE EXTRACTED TEMPLATE SET" + log("%s %s %s%s" % (self.kind.upper(), self.command, self.path, tag)) + for k, v in self.headers.items(): + log(" %s: %s" % (k, v)) + if body: + log(" body: %s" % body[:65536].decode("utf-8", "replace")) + + if self.kind == "content": + # Art assets (.dds/.png). We have none; 404 is the honest answer and is + # what a missing-asset CDN would return. Logged so we learn what art the + # client wants before deciding to synthesise any. + self.send_response(404) + self.send_header("Content-Length", "0") + self.end_headers() + log(" -> 404 (no asset)") + return + + payload = serve_body(self.path, self.command) if MODE == "serve" else None + raw = json.dumps(payload if payload is not None else {}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(raw) + log(" -> 200 %s" % raw[:400].decode()) + + do_GET = do_POST = do_PUT = do_DELETE = do_HEAD = do_PATCH = _handle + + def log_message(self, *a): + pass + + +class _ContentHandler(_Handler): + kind = "content" + + +def _serve(addr, handler, label): + host, port = addr + srv = http.server.ThreadingHTTPServer((host, port), handler) + log("=== pow %s listening on http://%s:%d ===" % (label, host, port)) + srv.serve_forever() + + +if __name__ == "__main__": + open(LOG, "a").close() + api = _split(API_ADDR, 8094) + content = _split(CONTENT_ADDR, 8080) + log("=== pow_server MODE=%s api=%s:%d content=%s:%d user=%r ===" + % (MODE, api[0], api[1], content[0], content[1], _username())) + t = threading.Thread(target=_serve, args=(content, _ContentHandler, "content"), + daemon=True) + t.start() + _serve(api, _Handler, "api") diff --git a/fifa17-recon/tools/root_arm.sh b/fifa17-recon/tools/root_arm.sh index 7d8780a..35c56f2 100755 --- a/fifa17-recon/tools/root_arm.sh +++ b/fifa17-recon/tools/root_arm.sh @@ -14,6 +14,21 @@ iptables -t nat -C OUTPUT -p tcp -d 159.153.51.20 -j DNAT --to-destination 127.0 # 4) point FUT's dead hardcoded UTAS host (easw.easports.com:8099) at our utas_server grep -q '[[:space:]]easw\.easports\.com\b' /etc/hosts 2>/dev/null \ || printf '127.0.0.1\teasw.easports.com\n' >> /etc/hosts +# 5) POW/EASFC hosts -- ONLY with `root_arm.sh pow`. The preferred redirect is the +# FIFA_POW_URL client-config key (FUT_POW=1, no root needed); these /etc/hosts +# entries are the fallback for if the client ignores that key. Kept opt-in +# because they persist across reboots and silently change where FIFA's EASFC +# traffic goes. Remove with: root_arm.sh unpow +if [ "${1:-}" = "pow" ]; then + for h in pas.gt.easfc.ea.com content.lt.easfc.ea.com; do + grep -q "[[:space:]]$h\b" /etc/hosts 2>/dev/null \ + || printf '127.0.0.1\t%s\n' "$h" >> /etc/hosts + done + echo " POW hosts -> 127.0.0.1" +elif [ "${1:-}" = "unpow" ]; then + sed -i '/pas\.gt\.easfc\.ea\.com/d;/content\.lt\.easfc\.ea\.com/d' /etc/hosts + echo " POW hosts removed" +fi echo "--- armed ---" sysctl kernel.yama.ptrace_scope net.ipv4.conf.lo.route_localnet diff --git a/fifa17-recon/tools/test_fut_contract.py b/fifa17-recon/tools/test_fut_contract.py index 3d18600..e4e3bcf 100644 --- a/fifa17-recon/tools/test_fut_contract.py +++ b/fifa17-recon/tools/test_fut_contract.py @@ -9,16 +9,27 @@ tests encode "must be array" / "must be object" / "must be number" per the reversed schemas so a future edit that reintroduces that class of bug fails here instead of freezing the game. -Read-only: only GET endpoints are exercised (no pack buys / squad writes), so it -never mutates the profile. Run: python3 tools/test_fut_contract.py +MOSTLY read-only: every check but one uses GET, so no pack is bought and no squad +is written. THE ONE EXCEPTION is test_club_rename_roundtrip, which PUTs a club +name to exercise the rename endpoint and RESTORES the original in a finally block. +(The docstring used to promise strictly read-only; that promise is now this +paragraph instead of a lie.) + +Run: python3 tools/test_fut_contract.py Exit 0 = all pass. No pytest dependency (stdlib only). """ -import json, sys, urllib.request +import json, os, sys, urllib.error, urllib.request + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from fut_account import ACCOUNT # the identity every layer must agree on BASE = "http://127.0.0.1:8099" G = "/ut/game/fifa17" V2 = "/ut/v2/game/fifa17" -PERSONA_ID = 33068179 # blaze LoginResponse SESS.BUID / PDTL.PID +# No PERSONA_ID literal here any more. This suite and the server MUST read the +# same source or the "identity is consistent" checks below would only be proving +# that two copies of a constant were copied correctly. +PERSONA_ID = ACCOUNT.persona_id _fail = [] _pass = 0 @@ -30,6 +41,23 @@ def _get(path): return json.loads(raw) if raw else {} +def _req(method, path, body=None): + """Returns (status, parsed-body). Never raises on 4xx/5xx -- the status itself + is a thing under test (FUT's rule is NEVER 4xx; see club_rename_route).""" + data = json.dumps(body).encode() if body is not None else None + rq = urllib.request.Request(BASE + path, data=data, method=method, + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(rq, timeout=5) as r: + raw, code = r.read(), r.status + except urllib.error.HTTPError as e: + raw, code = e.read(), e.code + try: + return code, (json.loads(raw) if raw else {}) + except ValueError: + return code, None # unparseable body -> caller fails the check + + def check(name, cond, detail=""): global _pass if cond: @@ -151,11 +179,26 @@ def test_squad_list_shape(): check("userInfo coins uses funds not value", "value" not in coins, repr(coins)) for k in ("won", "draw", "loss"): check(f"userInfo.{k} is number", is_num(ui.get(k)), repr(ui.get(k))) - # REGRESSION GUARD: clubNameChangeAllowed=true is the isolated root cause of the - # 2026-08-03 create-club crash (identical field set, only this bool flipped, 4/4 - # crash vs no crash). It may be absent, but it must never be true. - check("clubNameChangeAllowed is not true", ui.get("clubNameChangeAllowed") is not True, - repr(ui.get("clubNameChangeAllowed"))) + # REGRESSION GUARD, KEPT (not deleted -- the recon evidence does NOT show the + # new rename endpoint makes this safe; it shows the opposite: the crash chain + # runs entirely inside FIFA17.exe and never reaches our response). + # clubNameChangeAllowed=true is the isolated root cause of the 2026-08-03 + # create-club crash (identical field set, only this bool flipped, 4/4 crash vs + # no crash). It may be absent, but it must never be true UNLESS the operator + # deliberately opted in with FUT_CLUB_RENAME=1 -- i.e. the guard now asserts + # the SAFE DEFAULT rather than blocking the opt-in experiment. + # The opt-in is keyed on a DISTINCT, test-only variable, NOT on FUT_CLUB_RENAME. + # Keying it on the same var the server reads means one exported FUT_CLUB_RENAME=1 + # arms the crashing config AND silently disables the check that would catch it -- + # the guard has to fail loudly in exactly that case, which is the whole point of + # having it. So: assert the safe default unless a human explicitly says "I am + # testing the rename experiment right now". + if os.environ.get("FUT_TEST_ALLOW_RENAME") == "1": + check("clubNameChangeAllowed is true under FUT_CLUB_RENAME=1", + ui.get("clubNameChangeAllowed") is True, repr(ui.get("clubNameChangeAllowed"))) + else: + check("clubNameChangeAllowed is not true (default)", + ui.get("clubNameChangeAllowed") is not True, repr(ui.get("clubNameChangeAllowed"))) # squadList is OPTIONAL (FUT_USERINFO ladder) -- but if present it must be an # object with a squad array, never a bare array. sl = ui.get("squadList") @@ -224,10 +267,171 @@ def test_club_items(): check("club.itemData is array", is_arr(d.get("itemData")), repr(type(d.get("itemData")))) +def test_identity_consistency(): + """personaId must be IDENTICAL everywhere it is asserted. + + This is the single check that would have caught any drift the old + seven-copies-of-a-literal layout could produce. The squad parser 0x18013d1f0 + compares squad.personaId against the logged-in persona at 0x18014659c and, on + mismatch, silently builds a THROWAWAY squad (same comparison in 0x1801464e0 + for summaries) -- so drift does not error, it just quietly loses your squad. + The merge FUN_18011e7c0 likewise matches clubUser records to club records on + personaId, so a mismatch there silently loses the gamertag. + """ + seen = {} + seen["userInfo.personaId"] = _get(G + "/user").get("userInfo", {}).get("personaId") + mi = _get(G + "/userMassInfo") + if "userInfo" in mi: + seen["massinfo.userInfo.personaId"] = mi["userInfo"].get("personaId") + if "squad" in mi: + seen["massinfo.squad.personaId"] = mi["squad"].get("personaId") + seen["squad.personaId"] = _get(G + "/squad/0").get("personaId") + cu = _get(G + "/clubUser").get("user") or [] + if cu: + seen["clubUser.personaId"] = cu[0].get("personaId") + ul = _get(G + "/user/list?personaIdList=%d" % PERSONA_ID).get("user") or [] + if ul: + seen["user/list.personaId"] = ul[0].get("personaId") + for where, v in seen.items(): + check("%s == ACCOUNT.persona_id" % where, v == PERSONA_ID, + "%r != %r" % (v, PERSONA_ID)) + check("personaId asserted in >=4 places", len(seen) >= 4, repr(sorted(seen))) + + +def test_club_user_shape(): + """GET /clubUser -- FutGetClubUsers (deser 0x180145c00), key `user`(0x36c). + + REGRESSION THIS PINS: /clubUser used to be swallowed by the generic /club + route and answered {"itemData":[...]}, which GetClubUsers SKIPs entirely -- + so the club-user (gamertag) model was empty by construction. Assert we are + NOT serving the itemData body. + """ + d = _get(G + "/clubUser") + check("clubUser is object", is_obj(d), repr(type(d))) + if d == {}: + return # FUT_CLUB_IDENTITY=off bisect rung + check("clubUser is NOT the itemData body", "itemData" not in d, repr(list(d))) + users = d.get("user") + check("clubUser.user is array", is_arr(users), repr(users)) + for e in users or []: + check("clubUser elem is object", is_obj(e), repr(e)) + if not is_obj(e): + continue + # persona(0x21a) STRING, bounded copy FUN_180008120(dst,s,0x21) -> 32 chars + p = e.get("persona") + check("clubUser.persona is non-empty string", is_str(p) and p, repr(p)) + check("clubUser.persona <= 32 chars", is_str(p) and len(p) <= 32, repr(p)) + check("clubUser.personaId is number", is_num(e.get("personaId")), repr(e.get("personaId"))) + check("clubUser.public is bool", isinstance(e.get("public"), bool), repr(e.get("public"))) + + +def test_club_info_shape(): + """GET /user/list -- club-identity records. + + established MUST be a STRING of digits: userInfo deser 0x18013ec10 case 0x110 + uses the STRING getter then strtol base 10. squadList(0x2d4) must be ABSENT or + an object with a squad array -- a bare array/scalar there goes to FUN_180142260 + and is the 0x1801c7f1a busy-loop class. + """ + d = _get(G + "/user/list?personaIdList=%d" % PERSONA_ID) + check("user/list is object", is_obj(d), repr(type(d))) + if d == {}: + return # FUT_CLUB_IDENTITY=off bisect rung + users = d.get("user") + check("user/list.user is array", is_arr(users), repr(users)) + for e in users or []: + check("user/list elem is object", is_obj(e), repr(e)) + if not is_obj(e): + continue + check("user/list.personaId is number", is_num(e.get("personaId")), repr(e.get("personaId"))) + for k in ("clubName", "clubAbbr"): + check(f"user/list.{k} is non-empty string", is_str(e.get(k)) and e.get(k), repr(e.get(k))) + est = e.get("established") + check("user/list.established is string", is_str(est), repr(est)) + check("user/list.established is digits", is_str(est) and est.isdigit(), repr(est)) + sl = e.get("squadList") + check("user/list.squadList absent or object", sl is None or is_obj(sl), repr(sl)) + if is_obj(sl): + check("user/list.squadList.squad is array", is_arr(sl.get("squad")), repr(sl)) + + +def test_accountinfo_shape(): + # GET /user/accountinfo: {} by default and that is DELIBERATE -- its parser + # (FutGetUserAccountInfoServerCallConfig) is inside the Denuvo-packed + # FIFA17.exe and cannot be reversed, so key TYPES are unknown and any invented + # container is a freeze candidate. Under FUT_ACCOUNTINFO=1 every value must + # still be a scalar; nothing here may be an array or object. + d = _get(G + "/user/accountinfo") + check("accountinfo is object", is_obj(d), repr(type(d))) + for k, v in (d or {}).items(): + check(f"accountinfo.{k} is scalar (no guessed containers)", + not isinstance(v, (list, dict)), repr(v)) + + +def test_club_rename_roundtrip(): + """PUT the ChangeClubName endpoint(s) and prove the name persists. + + THE ONLY MUTATING TEST IN THIS FILE -- it restores the original club in a + finally block. + + FutChangeClubNameServerResponse has ZERO atoms (vtable 0x18022cb58 slot +0x08 + = 0x1801642c0, body `return 1`), so the response body is fully ignored and {} + is complete. What is actually under test: + * HTTP 200, NEVER 4xx -- CardsDLL's failure reporter FUN_18016cca0 skips the + 'R4ER: DISCONNECTED' telemetry path only while status==200, so answering + 4xx is how a rejected name becomes a disconnect. + * the new name is reflected in userInfo (write-back parity with the client's + own FUN_1800829c0 -> rec+0x20 / rec+0x3e). + * BOTH competing URL derivations are routed (ENDPOINT_MAP row 3 says PUT + ut/%s/club; the recon says ut/%s/user + "/club" suffix appender 0x18014c740). + * an over-long abbr is REJECTED, not echoed: the client's write-back buffer + at userInfo+0x3e is 4 bytes -> FUN_180007f80(dst,4,"%s",abbr). + """ + orig = _get(G + "/user").get("userInfo", {}) + o_name, o_abbr = orig.get("clubName"), orig.get("clubAbbr") + check("rename precondition: original club readable", + is_str(o_name) and is_str(o_abbr), repr((o_name, o_abbr))) + if not (is_str(o_name) and is_str(o_abbr)): + return + try: + for path in (G + "/user/club", G + "/club"): + code, body = _req("PUT", path, {"clubName": "TestClub", "clubAbbr": "TST"}) + check(f"PUT {path} -> 200 (never 4xx)", code == 200, repr(code)) + check(f"PUT {path} body is parseable object", is_obj(body), repr(body)) + ui = _get(G + "/user").get("userInfo", {}) + check(f"PUT {path} applied clubName", ui.get("clubName") == "TestClub", repr(ui.get("clubName"))) + check(f"PUT {path} applied clubAbbr", ui.get("clubAbbr") == "TST", repr(ui.get("clubAbbr"))) + # user/list must follow the same source of truth, or the merge + # FUN_18011e7c0 would show a stale club next to a fresh one. + ul = (_get(G + "/user/list?personaIdList=%d" % PERSONA_ID).get("user") or [{}])[0] + if ul: + check(f"PUT {path} reflected in user/list", + ul.get("clubName") in ("TestClub", None), repr(ul.get("clubName"))) + # restore between the two URLs so each is tested from a known state + _req("PUT", G + "/user/club", {"clubName": o_name, "clubAbbr": o_abbr}) + # over-long abbr: rejected (4 bytes incl. NUL at userInfo+0x3e), never echoed + code, _ = _req("PUT", G + "/user/club", {"clubName": "BadAbbrClub", "clubAbbr": "TOOLONG"}) + check("over-long abbr still answers 200", code == 200, repr(code)) + ui = _get(G + "/user").get("userInfo", {}) + check("over-long abbr not echoed", ui.get("clubAbbr") != "TOOLONG", repr(ui.get("clubAbbr"))) + check("over-long abbr <= 3 chars", len(ui.get("clubAbbr") or "") <= 3, repr(ui.get("clubAbbr"))) + # too-short name (view-model FUN_180082c30 name_min_length=5) likewise + _req("PUT", G + "/user/club", {"clubName": "Ab", "clubAbbr": "AB"}) + ui = _get(G + "/user").get("userInfo", {}) + check("too-short name rejected", ui.get("clubName") != "Ab", repr(ui.get("clubName"))) + finally: + _req("PUT", G + "/user/club", {"clubName": o_name, "clubAbbr": o_abbr}) + ui = _get(G + "/user").get("userInfo", {}) + check("original club restored", (ui.get("clubName"), ui.get("clubAbbr")) == (o_name, o_abbr), + repr((ui.get("clubName"), ui.get("clubAbbr")))) + + def main(): tests = [test_credits, test_v2_store_gate, test_store_catalog, test_market_bodies, test_auction_record_shape, test_squad_boot, test_squad_list_shape, - test_squad_list_endpoint, test_massinfo_shape, test_club_items] + test_squad_list_endpoint, test_massinfo_shape, test_club_items, + test_identity_consistency, test_club_user_shape, test_club_info_shape, + test_accountinfo_shape, test_club_rename_roundtrip] try: _get(G + "/user/credits") except Exception as e: diff --git a/fifa17-recon/tools/test_match_rewards.py b/fifa17-recon/tools/test_match_rewards.py new file mode 100644 index 0000000..8c50db6 --- /dev/null +++ b/fifa17-recon/tools/test_match_rewards.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Unit tests for the FUT match core loop — PURE, no server, no state, no profile. + +Why separate from test_fut_contract.py: that suite is read-only by design (it hits +a live server and must never mutate the save), but the match loop credits coins and +bumps the W/D/L record. So the two pure pieces — result detection and the reward +body — are tested here instead of making the HTTP suite stateful. + +Guards the two things that would silently break the loop: + * `_match_result()` mis-reading a scoreline (wrong result -> wrong payout) + * `destroy_match_body()` drifting from FutDestroyMatchServerResponse + (deser 0x180121b60): a non-scalar there is the freeze class at 0x1801c7f1a, + and a renamed key is silently SKIP'd, i.e. the reward vanishes with no error. + +Run: python3 tools/test_match_rewards.py (exit 0 = pass) +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +os.environ.setdefault("FUT_PROFILE", "/tmp/openfut_unittest_profile.json") + +import utas_server as U # noqa: E402 + +_fail = [] +_pass = 0 + + +def check(name, cond, detail=""): + global _pass + if cond: + _pass += 1 + else: + _fail.append("%s: %s" % (name, detail)) + + +# ---- _match_result: scoreline -> outcome ------------------------------------ +def test_result_detection(): + cases = [ + ({"goals": 3, "opponentGoals": 1}, "won"), + ({"goals": 0, "opponentGoals": 2}, "loss"), + ({"goals": 1, "opponentGoals": 1}, "draw"), + ({"score": 2, "opponentScore": 0}, "won"), + ({"homeGoals": 0, "awayGoals": 4}, "loss"), + ({"match": {"goals": 5, "opponentGoals": 0}}, "won"), # nested + ({"stats": {"score": 0, "opponentScore": 3}}, "loss"), # nested + ({"result": "WIN"}, "won"), + ({"outcome": "defeat"}, "loss"), + ({"result": "tie"}, "draw"), + ({}, "draw"), # unknown -> neutral fallback + (None, "draw"), # malformed body -> neutral fallback + ({"goals": "2", "opponentGoals": 1}, "draw"), # non-int -> no guess + ] + for body, expect in cases: + got, _ = U._match_result(body) + check("result %r -> %s" % (body, expect), got == expect, "got %s" % got) + + # a 0-0 draw must not be mistaken for "no data" + r, s = U._match_result({"goals": 0, "opponentGoals": 0}) + check("0-0 is a draw with a score", r == "draw" and s == (0, 0), "%s %s" % (r, s)) + + +# ---- destroy_match_body: the reward record ---------------------------------- +REQUIRED_INT = ("coins", "allCoins", "matchCoins", "seasonCoins", "tournamentCoins", + "boostConis", "participationAward", "qualifiedChampionEventId") + + +def test_reward_body(): + b = U.destroy_match_body("won", 400, 13000) + for k in REQUIRED_INT: + check("reward.%s present" % k, k in b) + check("reward.%s is int (scalar, not nested)" % k, + isinstance(b.get(k), int) and not isinstance(b.get(k), bool), repr(b.get(k))) + check("reward.teamOfTournamentWinner is bool", + isinstance(b.get("teamOfTournamentWinner"), bool), repr(b.get("teamOfTournamentWinner"))) + check("coins echoes the credited amount", b["coins"] == 400, repr(b["coins"])) + check("allCoins is the NEW balance", b["allCoins"] == 13000, repr(b["allCoins"])) + # EA's typo is load-bearing: the atom is 96 == "boostConis", not "boostCoins". + check("key is EA's misspelled boostConis", "boostConis" in b and "boostCoins" not in b, + repr(sorted(b))) + # nested members must stay OUT (all SKIP-safe; userData is a freeze-risk) + for k in ("userData", "gameModeAward", "matchCoinMultipliers"): + check("reward omits nested %s" % k, k not in b) + # nothing non-scalar may sneak in + for k, v in b.items(): + check("reward.%s is scalar" % k, isinstance(v, (int, bool, str)), repr(v)) + + +def test_payout_table(): + for res in ("won", "draw", "loss"): + b = U.destroy_match_body(res, U.MATCH_COINS[res], 0) + check("matchCoins matches the %s payout" % res, + b["matchCoins"] == U.MATCH_COINS[res], repr(b["matchCoins"])) + check("win pays >= draw", U.MATCH_COINS["won"] >= U.MATCH_COINS["draw"]) + check("draw pays >= loss", U.MATCH_COINS["draw"] >= U.MATCH_COINS["loss"]) + + +def main(): + for t in (test_result_detection, test_reward_body, test_payout_table): + try: + t() + except Exception as e: + _fail.append("%s raised %s: %s" % (t.__name__, type(e).__name__, e)) + print("\n%d checks passed, %d failed" % (_pass, len(_fail))) + for f in _fail: + print(" FAIL:", f) + return 0 if not _fail else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index 0972ce1..0f84473 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -16,12 +16,19 @@ import datetime, json, os, re, sys, http.server sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from fut_seed import CLUB, SQUAD, USER_LIST, squad_summary # forged starter squad (clean-room) from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item # profile + packs +from fut_account import ACCOUNT, validate_club # identity + club, single source ADDR = ("127.0.0.1", 8099) LOG = "/tmp/utas_server.log" SID = "OPENFUT-SID-0000000000000001" -PERSONA_ID = 33068179 # blaze LoginResponse SESS.BUID / PDTL.PID -PERSONA_NAME = "CAGE" # PDTL.DSNM +# IDENTITY NOTE: there are no PERSONA_ID / PERSONA_NAME literals in this file any +# more. They lived here, in fut_store.py, fut_seed.py, blaze_responder_v3b.py and +# lsx_responder_v2.py -- five files, seven copies of the same two values. The stack +# only works while Blaze SESS.PDTL, LSX GetProfileResponse and the UTAS +# userInfo/squad bodies all assert the SAME persona, so every one of them now reads +# fut_account.ACCOUNT. Read ACCOUNT.persona_id LIVE at call time (never snapshot it +# into a module constant) -- POST /ut/auth can adopt a different persona from the +# client's own body at runtime, and a snapshot would silently keep the old value. # Flip to True once you want to exercise the create-club path instead. NEW_USER = False @@ -38,9 +45,45 @@ def log(m): # ---- payloads ------------------------------------------------------------- -def auth_body(): - # Only "sid" is load-bearing (parser 0x1801a2880 -> ServerCall+0x1e8). - # serverTime/lastOnlineTime are atoi'd at char offsets 0,5,8,11,14,17. +def auth_body(h=None): + """POST ut/auth. + + RESPONSE: only "sid" is load-bearing (parser 0x1801a2880 -> ServerCall+0x1e8). + serverTime/lastOnlineTime are atoi'd at char offsets 0,5,8,11,14,17. + + REQUEST: the client TELLS us who it is and we used to throw that away. The + body is built by CardsDLL FUN_180125900 and was live-logged byte-identical on + three separate runs: + {"sku":"FFA17PCC","nucleusPersonaPlatform":"pc","nuc":33068179, + "nucleusPersonaId":33068179,"nucleusPersonaDisplayName":"CAGE", + "locale":"en-US","regionCode":"US",...} + Adopting it makes the squad personaId comparison at 0x18014659c correct BY + CONSTRUCTION instead of by matching literals: the squad parser 0x18013d1f0 + stores personaId (atom 0x21b) at squad+0x38 and compares it against + FUN_18011a830()->vtbl[0x908]; on mismatch it silently builds a THROWAWAY squad + rather than erroring, so a drifted id looks like "my squad reset itself". + + RECONCILIATION RULE: the wire is truth, fut_account.json is a cache. Adopt and + WARN, never refuse -- a mismatch is the NORMAL state on the first boot after a + rename. FUT_ADOPT_AUTH=0 disables adoption (documented escape hatch). + """ + if h is not None: + try: + body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else None + except Exception: + body = None + if isinstance(body, dict): + before = (ACCOUNT.persona_id, ACCOUNT.persona_name) + try: + if ACCOUNT.adopt_from_auth(body): + log(" AUTH: adopted persona %s/%r (was %s/%r)" + % (ACCOUNT.persona_id, ACCOUNT.persona_name, before[0], before[1])) + # Keep the game save's identity mirror in step with ACCOUNT so + # tradepile/club readers cannot lag a session behind. + STORE.refresh_identity() + except Exception as e: # adoption must never break auth + log(" AUTH: adopt failed (%s: %s) -- keeping %s/%r" + % (type(e).__name__, e, before[0], before[1])) return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()} @@ -48,9 +91,9 @@ def current_squad(): """The squad the client should see: the persisted one (item refs re-embedded from the club) or the seed ladder squad on first run. - personaId is FORCED to PERSONA_ID: SquadLoad compares it against the logged-in - persona at 0x18014659c and, on mismatch, takes the vtable[0x4f0] branch and - builds a throwaway squad instead of adopting ours. + personaId is FORCED to ACCOUNT.persona_id: SquadLoad compares it against the + logged-in persona at 0x18014659c and, on mismatch, takes the vtable[0x4f0] + branch and builds a throwaway squad instead of adopting ours. """ saved = STORE.active_squad() # Overlay the saved squad on the seed envelope: FIFA's PUT body carries only @@ -59,7 +102,7 @@ def current_squad(): sq = dict(SQUAD) if saved: sq.update(STORE.reconstruct_squad(saved)) - sq["personaId"] = PERSONA_ID + sq["personaId"] = ACCOUNT.persona_id sq.setdefault("id", 0) return sq @@ -94,8 +137,67 @@ def squad_list_body(squad=None): # a naming flow whose UI model we never populate. Neither side-effecting member # (squadList / unopenedPacks) was involved -- both were already omitted in the # crashing run. Keep this false unless the rename flow is actually implemented. +# +# 2026-08-03, LATER: the rename endpoint IS implemented now (club_rename_route +# below) and it is still NOT enough to make this bool safe -- see FUT_CLUB_RENAME. _UI = os.environ.get("FUT_USERINFO", "roster") +# ---- FUT_CLUB_RENAME: the in-game rename experiment (DEFAULT OFF) ----------- +# clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62. +# +# DEFAULT IS FALSE AND THAT IS THE PROVEN-GOOD BEHAVIOUR. Setting FUT_CLUB_RENAME=1 +# is a ONE-SHOT EXPERIMENT, not a feature, and the expected outcome is still the +# 2026-08-03 crash. Do NOT read "we now serve a correct rename endpoint" as "the +# flag is safe": nothing the server sends is consumed anywhere in the crash path. +# +# The traced chain, all of it CLIENT-side after the bool: +# userInfo+0x62 -> FUN_18001a7a0: if rec[0x62]==1 { target="changeClubName"; +# state=0x3f } else { target=NULL; state=2 } +# state 0x3f -> FIFA17.exe front-end flow manager -> exe-side naming screen +# on confirm -> FUT-manager vtbl+0xa70 (name validation / profanity, component +# GUID 0xed84b11, fetched by FUN_180009c80) +# ONLY THEN -> CardsDLL builds the request (FUN_1800824b0) and sends it via +# vtbl+0x5d0 <-- the first point our endpoint could possibly +# matter, and the crash happens BEFORE it. +# That matches the observed signature exactly: ACCESS_VIOLATION reading 0x0 at +# FIFA17.exe+0x71b8651, 4/4 runs, no CardsDLL frame on the stack, no HTTP request +# in flight. The crash site itself could NOT be identified statically -- FIFA17.exe +# is Denuvo-packed, RVA 0x71b8651 lands in the encrypted .data blob (file offset +# 0x3499e51, high entropy), and the reported bytes `49 8B 41 28 4C 8B 08` occur +# ZERO times in the whole 224 MB file. The leading theory (vtbl+0xa70 is an EA +# text-filter service that is NULL offline) is a HYPOTHESIS, not a finding. +# +# THE SAFE WAY TO RENAME YOUR CLUB IS OFFLINE, and it works today: +# python3 tools/fut_account.py --club-name 'Real OpenFUT' --club-abbr ROF +# ./openfut-fut.sh restart +# INSTANT FALLBACK if you do try the flag: `unset FUT_CLUB_RENAME`, restart, the +# hub is immediately back. Fixing it for real needs a live /proc/PID/mem dump +# around 0x1471b8651 (Wine maps the PE flat at 0x140000000) -- a separate task. +_CLUB_RENAME = os.environ.get("FUT_CLUB_RENAME") == "1" + +# ---- FUT_CLUB_IDENTITY ladder ---------------------------------------------- +# Which club/gamertag-identity bodies we serve. Same convention as FUT_MASSINFO / +# FUT_USERINFO: each rung is one more change class, so a live regression bisects +# in one step. +# off -> DEFAULT: pre-2026-08-03 behaviour, /clubUser and /user/list stub out +# route -> /clubUser serves {"user":[...]} and /user/list serves the club-info +# body. +# massinfo -> ALSO injects clubUser into the userMassInfo body. massinfo is +# boot-critical and adding a member to it is exactly the change class +# behind the last two live regressions; FUT_MASSINFO=squad remains the +# instant known-good fallback. +# +# WHY `off` IS THE DEFAULT (corrected after review, 2026-08-03): this ladder was +# first shipped defaulting to `route` on the argument that these are "NEW ROUTES, so +# nothing that works today changes shape". The live log refutes the premise -- +# EVERY request to /clubUser (22) and /user/list (39) in /tmp/utas_server.log is +# dated 21:xx, i.e. the contract suite hitting them. The real client, across five +# full sessions between 17:00 and 20:45, requested NEITHER. So populating them buys +# nothing observable and hands the client two response bodies it has never parsed -- +# the exact change class that produced today's two live regressions. Turn a rung on +# only when the log shows the client actually asking. +_CLUB_ID = os.environ.get("FUT_CLUB_IDENTITY", "off") + def user_info(): # Deserializer 0x18013EC10; every member optional (unknown key ids are @@ -105,15 +207,23 @@ def user_info(): p = STORE.profile() rec = p.get("record", {}) info = { - "personaId": PERSONA_ID, - "clubName": p.get("clubName", "OpenFUT"), "clubAbbr": p.get("clubAbbr", "OFC"), - "established": p.get("established", "2026"), + # Identity/club come from ACCOUNT, never from the save: the save is only a + # mirror (fut_store._sync_identity) and must not be able to disagree with + # what Blaze PDTL / LSX GetProfileResponse assert for the same session. + "personaId": ACCOUNT.persona_id, + "clubName": ACCOUNT.club_name, "clubAbbr": ACCOUNT.club_abbr, + # established(0x110) is a STRING of digits: deser 0x18013ec10 case 0x110 + # takes the STRING getter then strtol base 10 into rec+0x64. An int on the + # wire here is the scalar/string mismatch class that busy-loops the SAX + # reader at 0x1801c7f1a. ACCOUNT.established is str-typed for this reason. + "established": ACCOUNT.established, # accountCreatedPlatformName(0x6), stored at userInfo+0x42: the only string # 0x18013ec10 consumes that we used to omit (clubAbbr 0x8d / clubName 0x8e / # established 0x110 were already covered). Sending it is correct, but note # it was NOT the cause of the create-club crash -- that was still identical - # with this field present. Value matches auth's nucleusPersonaPlatform. - "accountCreatedPlatformName": "pc", + # with this field present. Value MUST match the auth body's + # nucleusPersonaPlatform, which is why it reads the locked wire constant. + "accountCreatedPlatformName": ACCOUNT.PLATFORM, # The userInfo currency-element parser FUN_180138bd0 reads name(0x1d0), # funds(0x134), finalFunds(0x124), active(0xa) -- there is NO "value" key, # so the old {"name","value"} pairs were parsed as 0 and the hub showed 0 @@ -130,9 +240,11 @@ def user_info(): # Everything below is optional for coins/record. `min` stops here. if _UI != "min": info.update({ - # clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62. MUST stay False: - # True is the isolated root cause of the create-club crash (see _UI). - "clubNameChangeAllowed": False, + # clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62. FALSE unless + # FUT_CLUB_RENAME=1: True is the isolated root cause of the 2026-08-03 + # create-club crash and serving the rename endpoint does NOT fix it + # (the crash is upstream of the network). See the _CLUB_RENAME block. + "clubNameChangeAllowed": _CLUB_RENAME, "divisionOffline": 10, "divisionOnline": 10, "purchased": False, # 0x262 -> bool at +0x68 "feature": {"trade": True}, @@ -167,7 +279,14 @@ def user_get(): # POST ut/game//user (CreateUser, 0x18014CC60) recognises exactly: # bonusPacks(0x5d) login(0x1a5) squad(0x2cd) starterPack(0x2e5) userData(0x36d). -def user_post(): +def user_post(h=None): + # CREATE-CLUB VARIANT: CardsDLL builder FUN_18014ca00 sends + # {useFut1Data:false, clubName, clubAbbr, purchased:false} on this same URL. + # Adopt the name the user typed so a create-club round-trips instead of the + # client seeing its own choice silently replaced by ours. Same validation and + # the same never-4xx rule as club_rename_route(). + if h is not None: + _adopt_club_from_body(h, "CREATE-CLUB") # squad(0x2cd) goes to the SAME LoadActiveSquad parser as everywhere else, so # serve the real schema-correct squad rather than {} -- a create-club response # carrying an empty squad leaves the client with a 0-slot squad model, which is @@ -176,6 +295,171 @@ def user_post(): "squad": current_squad(), "starterPack": {}, "bonusPacks": []} +# ---- CLUB IDENTITY: the gamertag-carrying bodies --------------------------- +def club_user_body(): + """GET ut/%s/clubUser -- FutGetClubUsersServerResponse (deser 0x180145c00). + + THE BUG THIS FIXES: `/ut/game/fifa17/clubUser` used to be swallowed by the + generic `(G + r"/club")` route (verified by running that compiled regex + against the real path) and answered {"itemData":[...club items...]}. The + GetClubUsers deser recognises ONLY `user`(0x36c) and SKIPs itemData, so the + club-user model was empty BY CONSTRUCTION -- there was no network-supplied + gamertag anywhere in FUT. docs/ENDPOINT_MAP.md row 2 recorded this as a GAP + but described the old response as `{}`; it was actually the itemData body. + + SCHEMA: {"user":[element]}. Three scalars, all freeze-safe: + persona (0x21a) STRING, bounded-copied to 32 chars -> FUN_180008120(dst,s,0x21) + personaId(0x21b) INT64 + public (0x25f) BOOL + None of them is an array/object slot, so the scalar-vs-container freeze class + (busy-loop at 0x1801c7f1a) does not apply to this body at all. + + NOTE the element-parser address is recorded twice and inconsistently + (0x180145480 in ENDPOINT_MAP row 2, 0x180138b00 in the display recon). Both + derivations agree on the top-level key and on these three members, so it is a + naming/wrapper question rather than a schema one -- carried as a caveat. + """ + return {"user": [{ + "persona": ACCOUNT.persona_name[:32], + "personaId": ACCOUNT.persona_id, + "public": True, + }]} + + +def club_info_body(): + """GET ut/%s/user/list -- the club-identity record list. + + DELIBERATELY NO `name` KEY: record+0x08 is filled by the merge FUN_18011e7c0, + which matches on personaId and copies clubUser.persona in. That is exactly why + personaId MUST be byte-identical here and in club_user_body() -- if they + disagree the merge finds nothing and the name stays empty. + + OMITS squadList(0x2d4) ON PURPOSE: it is routed to FUN_180142260, and a bare + array/scalar there is the 0x1801c7f1a busy-loop class. userInfo already carries + the squadList via the FUT_USERINFO ladder, so there is nothing to gain here. + """ + return {"user": [{ + "personaId": ACCOUNT.persona_id, + "clubName": ACCOUNT.club_name, # 0x8e + "clubAbbr": ACCOUNT.club_abbr, # 0x8d + "established": ACCOUNT.established, # 0x110 -- STRING of digits + }]} + + +def club_identity_route(kind): + """FUT_CLUB_IDENTITY=off restores the pre-fix stubs for a one-step bisect.""" + if _CLUB_ID == "off": + return 200, {} + return 200, (club_user_body() if kind == "clubUser" else club_info_body()) + + +# ---- ACCOUNTINFO (unproven; OFF by default) -------------------------------- +# GET ut/%s/user/accountinfo is requested ~9x per session and we answer {}. +# It STAYS {} by default and that is a deliberate refusal, not an oversight: its +# parser is FutGetUserAccountInfoServerCallConfig, which lives inside the +# Denuvo-packed FIFA17.exe, so it CANNOT be reversed statically. Any key we invent +# has an unknown expected TYPE, and a scalar where a container is expected is +# precisely the freeze class this whole codebase is organised around avoiding. +# FUT_ACCOUNTINFO=1 serves a guessed body for a single deliberate experiment. Every +# key in it is a real atom from docs/fut_atoms.tsv and every value is a scalar -- +# that bounds the risk, it does not eliminate it. Treat a freeze after enabling +# this as expected, and unset it. +_ACCOUNTINFO = os.environ.get("FUT_ACCOUNTINFO") == "1" + + +def accountinfo_body(): + if not _ACCOUNTINFO: + return {} + return { + "userId": ACCOUNT.user_id, # 0x36f + "personaId": ACCOUNT.persona_id, # 0x21b + "persona": ACCOUNT.persona_name, # 0x21a + "name": ACCOUNT.persona_name, # 0x1d0 + "email": ACCOUNT.email, # 0xf8 + "country": ACCOUNT.country, # 0xbd + } + + +# ---- CLUB RENAME ----------------------------------------------------------- +def _adopt_club_from_body(h, tag): + """Parse {clubName(0x8e), clubAbbr(0x8d)} out of a request body, validate it + against the client's OWN limits, persist it, and mirror it into the save. + + Returns True if the club changed. NEVER raises and NEVER makes the caller + answer 4xx -- see club_rename_route() for why that rule is load-bearing. + """ + try: + body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {} + except Exception: + body = {} + if not isinstance(body, dict): + return False + name = body.get("clubName") + abbr = body.get("clubAbbr") + if name is None and abbr is None: + return False + name = ACCOUNT.club_name if name is None else name + abbr = ACCOUNT.club_abbr if abbr is None else abbr + if (name, abbr) == (ACCOUNT.club_name, ACCOUNT.club_abbr): + return False + try: + validate_club(name, abbr) + except ValueError as e: + # Rejected: keep the old club, keep serving 200. The client is told the + # transport succeeded and simply keeps rendering whatever userInfo says. + log(" %s: REJECTED %r/%r -- %s" % (tag, name, abbr, e)) + return False + old = (ACCOUNT.club_name, ACCOUNT.club_abbr) + try: + ACCOUNT.set_club(name, abbr) + ACCOUNT.save() # -> tools/fut_account.json + STORE.refresh_identity() # -> mirror into fifa17_profile.json + except Exception as e: + log(" %s: FAILED to persist %r/%r (%s: %s)" % (tag, name, abbr, type(e).__name__, e)) + return False + log(" %s: club %r/%r -> %r/%r (persisted)" + % (tag, old[0], old[1], ACCOUNT.club_name, ACCOUNT.club_abbr)) + return True + + +def club_rename_route(h): + """PUT the club rename -- FutChangeClubNameServerResponse. + + RESPONSE IS `{}`, AND THAT IS COMPLETE, NOT A STUB. The struct has ZERO atoms: + vtable 0x18022cb58 slot +0x08 is 0x1801642c0, whose entire body is `return 1` + (a shared no-op deserializer also used by ActivateCard and SignLoanPlayer). + The HTTP body is fully ignored. The ONLY thing read is the transport result + code at result+0x1c (handler FUN_1800829c0): + 0 -> SUCCESS (client then copies the name into userInfo rec+0x20 and + the abbr into rec+0x3e via FUN_180007f80(rec+0x3e,4,...)) + 0x1f -> PROFANITY + else -> FAILED + So: HTTP 200, no FUT error code, EVER. + + NEVER 4xx, even on a rejected name. CardsDLL's failure reporter FUN_18016cca0 + explicitly SKIPS the whole 'R4ER: DISCONNECTED' telemetry path when the status + is 200 -- answering 4xx is how a bad rename turns into a disconnect. + + TWO URLs, BOTH ROUTED. docs/ENDPOINT_MAP.md row 3 derives PUT `ut/%s/club`. + The rename recon derives `ut/%s/user` (request row 0x1802cba70, urlIdx 0xb -> + template @0x18021e030) plus a per-response-class literal suffix "/club" + (appender 0x18014c740: MOV RCX,RDX; LEA RDX,[0x180225124 "/club"]; JMP + 0x180008020) = `ut/game/fifa17/user/club`, and it validated that same urlIdx + column against three independently-known live URLs. Since the response is a + zero-atom ack, being wrong about which costs nothing -- so both are served and + the live log settles it. + + REQUEST BODY: builder FUN_18014c590 emits exactly clubName + clubAbbr, nothing + else. + """ + if h.command in ("PUT", "POST"): + _adopt_club_from_body(h, "RENAME") + return 200, {} + # GET on the rename URL is not a known endpoint; a parseable {} is the + # cheapest correct answer (unknown keys are SKIP'd everywhere in FUT). + return 200, {} + + # GET ut/game//settings (0x18013C6D0) recognises ONE key: configs (0xa2). SETTINGS = {"configs": []} @@ -224,10 +508,17 @@ def massinfo(): return {"userInfo": user_info()} if _MI == "settings": return {"settings": SETTINGS} - return {"userInfo": user_info(), # squadList -> roster singleton - "squad": current_squad(), # personaId == PERSONA_ID + body = {"userInfo": user_info(), # squadList -> roster singleton + "squad": current_squad(), # personaId == ACCOUNT.persona_id "settings": SETTINGS, "userData": {}} + if _CLUB_ID == "massinfo": + # clubUser(0x91) IS a recognised massinfo key (deser 0x180174630), so this + # is schema-legal -- it is opt-in only because massinfo is the boot-critical + # body and adding a member to it is the change class behind the last two + # live regressions. Instant fallback: FUT_MASSINFO=squad. + body["clubUser"] = club_user_body() + return body # ---- FUT item-definition serving (wf_e41070d8) ------------------------------- # The card view-model 0x1800d7920 renders identity/rating/face from a RESOLVED @@ -283,6 +574,80 @@ def defs_route(h): return 200, {"itemData": [item_def(i) for i in ids]} +# ---- pack reveal workaround --------------------------------------------------- +# THE REVEAL HAND-OFF IS UNSOLVED. Live 2026-08-04, five packs, three different +# response shapes for FutMoveCard (full card objects / +chemistry / dreamSquads-only): +# every time the client moved the cards, then POSTed ut/delete/auth ~1s later and +# dropped to the main menu. No crash dump; Blaze keeps pinging afterwards, so the +# game is alive and it is the FUT SESSION that ends. The cards always arrive +# server-side -- only the acknowledgement is rejected. +# +# What is known: FutMoveCard's deserializer 0x180128600 contains NO skip handler +# (FUN_180135ff0 appears zero times, unique among FUT deserializers) and parses only +# itemData(0x16b) -> element -> dreamSquads(0xe9). Sending exactly that still failed, +# so the trigger is elsewhere and remains unidentified. +# +# WORKAROUND (default ON, FUT_PACK_AUTOCLUB=0 disables): deposit pack contents +# STRAIGHT into the club at open time and keep the pending pile empty, so the client +# is never offered a move to make and never sends the request that kills the session. +# Cost: the reveal screen shows no cards to assign. Benefit: packs are usable and the +# cards are in the club, which is the point of buying one. Turn this off when the +# real hand-off is understood. +PACK_AUTOCLUB = os.environ.get("FUT_PACK_AUTOCLUB", "1") == "1" + +# FUT_MOVE_BODY -- what PUT ut/%s/item answers. Made switchable so the shape can be +# bisected in one relaunch each instead of a code edit per attempt. +# empty (default) -> {} full -> echo the moved card objects +# dreamsquads -> {"itemData":[{"dreamSquads":[]} x N]} +# +# WHY `empty` IS NOW THE DEFAULT (live 2026-08-04, after six failed attempts): +# Quick Sell All hits the SIBLING endpoint POST ut/delete/%s/item, which was +# UNMAPPED and therefore answered with a bare {} -- and it WORKED: no error, session +# intact. Meanwhile every crafted body on PUT ut/%s/item was fatal. So a bare {} is +# demonstrably acceptable to this screen, and the inherited claim that "returning [] +# makes FIFA think the move failed -> kicks to main menu" is unproven and probably +# another misdiagnosis in the same lineage as the chemistry one. +# Also disproven this round: the netwatch recorded ZERO non-loopback connections, so +# "error connecting to FIFA 17 Ultimate Team" is FIFA's generic FUT-session failure +# text, not a real network failure -- and the 146 missing FUT_RS4_URL_ keys +# (now served, and genuinely missing) were not the cause either. +MOVE_BODY = os.environ.get("FUT_MOVE_BODY", "empty") + +# FUT_STORE_GROUPS: send displayGroup(0xd9) in the pack catalogue. DEFAULT OFF -- +# it FROZE the store screen live on 2026-08-04 (recursive nested array through the +# same element parser 0x18013af30 -> busy-loop at 0x1801c7f1a). +STORE_GROUPS = os.environ.get("FUT_STORE_GROUPS") == "1" +# FUT_STORE_FIELDS: the re-extracted pack fields. DEFAULT OFF -- enabling them +# stopped packs opening live on 2026-08-04. +STORE_FIELDS = os.environ.get("FUT_STORE_FIELDS") == "1" + + +def quick_sell_route(h): + """POST ut/delete/%s/item -- Quick Sell (the reveal screen's 'Quick Sell All'). + + Discovered live 2026-08-04 as an UNMAPPED path. The bare {} it was getting is + ACCEPTED by the client (unlike the move path), but nothing was credited, so a + quick sell destroyed the cards for 0 coins. + + Coin value: FUT quick-sell pays the card's discardValue. Ours are seeded 0, so + fall back to a rating-based figure in the same spirit as the market pricing + heuristic -- an invented number, but a sane one, and better than zero. The client + re-reads the balance from GET /user/credits straight after (observed), so the + response body itself only has to be accepted.""" + try: + body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {} + except Exception: + body = {} + ids = [it.get("id") for it in (body.get("itemData") or []) if isinstance(it, dict)] + if not ids and isinstance(body.get("itemIds"), list): + ids = body["itemIds"] + sold, coins = STORE.quick_sell(ids) + if sold: + log(" QUICKSELL: sold %d card(s) for %d coins (total %d)" + % (sold, coins, STORE.coins())) + return 200, {} + + def item_route(h): # PUT ut/game/fifa17/item = FutMoveCard (move item to a pile, e.g. the reveal # screen's "keep/assign" -> {"itemData":[{"id":..,"pile":"club","swap":0, @@ -300,11 +665,33 @@ def item_route(h): moved = STORE.move_items(req) if moved: log(" ITEM: moved %d item(s) to pile(s)" % len(moved)) - # Same shape as the proven-parseable GET itemData (no extra keys): - # adding an unexpected key (e.g. chemistry) desynced the parser - # and made FIFA report "failed to send to club" then log out. - return 200, {"itemData": moved} - return 200, {"itemData": []} + # ROOT CAUSE of the "can't send cards to club -> kicked to the main + # menu" logout, found 2026-08-04 by decompiling the deserializer: + # + # FutMoveCard 0x180128600 HAS NO SKIP HANDLER. Every other FUT + # deserializer routes an unrecognised key to FUN_180135ff0 (the + # value-SKIP handler); this one calls it ZERO times. It parses + # exactly two atoms -- itemData(0x16b) as an array, and inside each + # element dreamSquads(0xe9) as an int array -- and an unknown key + # leaves its VALUE unconsumed, so the next loop iteration reads that + # value as a key and the reader desyncs. + # + # We were echoing the FULL card object: ~20 keys each, including a + # nested attributeList. Every one of them is unknown to this parser. + # That also explains the two earlier misdiagnoses -- ANY extra key + # breaks it, so `chemistry` looked causal when it was added, and + # removing it changed nothing because 20 other keys remained. + # + # Shape selected by FUT_MOVE_BODY (see above) so it can be bisected + # live without a code edit. + if MOVE_BODY == "full": + return 200, {"itemData": moved} + if MOVE_BODY == "dreamsquads": + return 200, {"itemData": [{"dreamSquads": []} for _ in moved]} + return 200, {} + # Nothing matched (ids not in the pending pile). Answer in the SAME shape as + # a successful move so the client cannot tell the two apart structurally. + return 200, ({} if MOVE_BODY == "empty" else {"itemData": []}) return defs_route(h) @@ -323,7 +710,9 @@ ROUTES = [ # store §2.) Bare /store only -- purchasegroup/transaction matched above. (re.compile(r"/store(\?|$)"), lambda m, h: (200, {"result": "SUCCESS"})), (re.compile(r"/purchased"), lambda m, h: purchased_items(h)), - (re.compile(r"^/ut/auth"), lambda m, h: (200, auth_body())), + # POST ut/auth: the response is unchanged; the REQUEST body is now read so + # ACCOUNT can adopt the persona the client itself asserts (see auth_body). + (re.compile(r"^/ut/auth"), lambda m, h: (200, auth_body(h))), (re.compile(r"^/ut/delete/auth"), lambda m, h: (200, {})), (re.compile(G + r"/settings"), lambda m, h: (200, SETTINGS)), # Device-trust ("phishing") flow. trusteddevice parser 0x18012a170 reads 4 @@ -334,11 +723,20 @@ ROUTES = [ (re.compile(G + r"/phishing/question"), lambda m, h: (200, {"question": 0, "answer": "", "attempts": 5})), (re.compile(G + r"/user/credits"), lambda m, h: credits_route(h)), # ---- club/squad routes (2026-08-03: squad schema 0x18013d1f0 now reversed) ---- - # /user/list + /user/accountinfo stay {} (nothing in them is load-bearing yet). # /user, /squad and /userMassInfo serve real data again -- the squad object # matches the verified schema, so the 2026-08-01 revert-to-{} no longer applies. - (re.compile(G + r"/user/list"), lambda m, h: (200, {})), - (re.compile(G + r"/user/accountinfo"), lambda m, h: (200, {})), + # + # ORDER MATTERS TWICE HERE: + # * /clubUser MUST precede the generic /club route at the bottom of this table, + # which was silently swallowing it and answering with the club ITEM list. + # * /user/club MUST precede /user/list and /user$ so the rename URL is not + # absorbed by a neighbour. (It would otherwise fall through to /club, which + # now also dispatches renames -- but relying on that is a trap for the next + # edit of this table.) + (re.compile(G + r"/clubUser"), lambda m, h: club_identity_route("clubUser")), + (re.compile(G + r"/user/club"), lambda m, h: club_rename_route(h)), + (re.compile(G + r"/user/list"), lambda m, h: club_identity_route("userList")), + (re.compile(G + r"/user/accountinfo"), lambda m, h: (200, accountinfo_body())), (re.compile(G + r"/user$|" + G + r"/user\?"), lambda m, h: user_route(h)), # LIVE GROUND TRUTH 2026-08-03: FutSquadList has its OWN URL, `ut/%s/squad/list` # -- the request-table strings only showed ut/%s/squad, so the static conclusion @@ -353,10 +751,36 @@ ROUTES = [ # UNMAPPED catch-all; it is a Tier-B ack (shared no-op deser), so {} is correct # -- routed explicitly so it stops showing up as an unmapped hit in the log. (re.compile(G + r"/match/reset"), lambda m, h: (200, {})), + # THE CORE LOOP. ut/%s/match was the biggest hole in the API surface: we only + # answered keepalive/reset, so the base endpoint fell through to the catch-all + # {} and a finished match awarded NOTHING. Must precede any generic route. + # ut/delete/%s/match/{id} is the DELETE form (UTAS tunnels DELETE through a + # /ut/delete/ path prefix, same as trade/watchList/squad). + (re.compile(r"/ut/delete/game/[^/]+/match"), lambda m, h: match_route(h)), + (re.compile(G + r"/match"), lambda m, h: match_route(h)), (re.compile(G + r"/hub"), lambda m, h: (200, {})), # Populated massinfo (see massinfo() above): userInfo + squad + settings. (re.compile(G + r"/userMassInfo"), lambda m, h: (200, massinfo())), - (re.compile(G + r"/season"), lambda m, h: (200, {})), + # ---- game modes (FUT_MODES=1; default keeps the proven {} everywhere) ---- + # Order matters: the more specific season/tournament sub-paths must precede + # the bare ones, and /leaderboards/options precedes /leaderboards. + (re.compile(G + r"/season/\d+/reset"), lambda m, h: (200, {"reset": True} if _MODES else {})), + (re.compile(G + r"/season/user"), lambda m, h: (200, season_user() if (_MODES and h.command == "GET") else {})), + (re.compile(G + r"/season/friendly"), lambda m, h: (200, {})), + (re.compile(G + r"/season"), lambda m, h: (200, season_list() if (_MODES and h.command == "GET") else {})), + (re.compile(r"/ut/delete/game/[^/]+/tournament"), lambda m, h: (200, {})), + (re.compile(G + r"/tournament/user"), lambda m, h: (200, tournament_user() if (_MODES and h.command == "GET") else {})), + (re.compile(G + r"/tournament"), lambda m, h: (200, tournament_list() if (_MODES and h.command == "GET") else {})), + (re.compile(G + r"/leaderboards"), lambda m, h: leaderboard_route(h) if _MODES else (200, {})), + (re.compile(G + r"/champion"), lambda m, h: champion_route(h) if _MODES else (200, {})), + # FutGetCaptcha 0x18014e78d: encodedImg(str b64) sequence(int) sizeBeforeEncode(int). + # Served always -- an empty captcha is strictly better than the catch-all {}, + # and all three fields are scalars (no freeze risk). + (re.compile(G + r"/captcha"), lambda m, h: (200, {"encodedImg": "", "sequence": 0, "sizeBeforeEncode": 0})), + (re.compile(G + r"/tfa"), lambda m, h: (200, {})), + (re.compile(G + r"/clientdata"), lambda m, h: clientdata_route(h)), + (re.compile(G + r"/livemessage"), lambda m, h: (200, {})), + (re.compile(G + r"/activeMessage"), lambda m, h: (200, {})), # ---- transfer market / auction house (empty-but-valid; ENDPOINT_MAP market §) # tradePile MUST precede /trade ("/tradePile" contains the "/trade" prefix). (re.compile(G + r"/tradePile"), lambda m, h: tradepile_route(h)), @@ -367,15 +791,43 @@ ROUTES = [ # /auctionhouse (was UNMAPPED -> {} => empty market). Serve the same listings. (re.compile(G + r"/transfermarket"), lambda m, h: auctionhouse_route(h)), (re.compile(G + r"/marketdata"), lambda m, h: (200, {"minPrice": 150, "maxPrice": 15000})), + # QUICK SELL. Live-observed 2026-08-04: the reveal screen's "Quick Sell All" + # sends POST ut/delete/%s/item -- it was UNMAPPED (catch-all {}), which the + # client ACCEPTS (no error, session survives) but which paid 0 coins: the user + # sold 6 cards for nothing. Credit discardValue per card and remove them. + (re.compile(r"/ut/delete/game/[^/]+/item"), lambda m, h: quick_sell_route(h)), (re.compile(r"/ut/delete/game/[^/]+/trade"), lambda m, h: delete_trade_route(h)), (re.compile(r"/ut/delete/game/[^/]+/watchList"), lambda m, h: (200, {})), - (re.compile(G + r"/club"), lambda m, h: (200, {"itemData": STORE.items()})), + # Generic /club, LAST on purpose (it is a prefix of /clubUser). + # PUT -> ChangeClubName, per docs/ENDPOINT_MAP.md row 3 (the other of the two + # competing URL derivations; see club_rename_route). + # GET -> the club item list, unchanged. NOT switched to GetClubInfo's `user` + # shape: its element parser 0x18012c990 is only PARTIALLY decoded, and + # the rendered club cards come through /item (ViewCards) anyway. + # LIVE-OBSERVED, UNDOCUMENTED (found 2026-08-04 by replaying every path in + # /tmp/utas_server.log): the client really fetches ut/%s/club/stats/{consumables, + # staff,year} on the MY CLUB screen. They are suffix endpoints the request table + # never lists -- the same trap as /squad/list. They were being swallowed by the + # generic /club route, which answers with the FULL 28-item club list where the + # client asked for STATS: wrong shape, and re-sent on every poll. No schema is + # documented for them, so serve the proven-safe {} and let a capture refine it. + (re.compile(G + r"/club/stats"), lambda m, h: (200, {})), + (re.compile(G + r"/club"), lambda m, h: club_route(h)), ] +def club_route(h): + # PUT only -- ENDPOINT_MAP row 3 gives ChangeClubName as PUT. Every other + # method keeps the exact body this route served before, so the rename support + # cannot change the behaviour of anything that already worked. + if h.command == "PUT": + return club_rename_route(h) + return 200, {"itemData": STORE.items()} + + def user_route(h): if h.command == "POST": - return 200, user_post() + return 200, user_post(h) if NEW_USER: # accepted, non-fatal: 0x18007AF20 treats 404 as the new-user branch return 404, {} @@ -393,6 +845,208 @@ def user_route(h): _SQUAD_LIST_MODE = os.environ.get("FUT_SQUAD_LIST", "off") +def clientdata_route(h): + """ut/%s/clientdata/ -- opaque client blob storage. + + LIVE-OBSERVED: `PUT ut/game/fifa17/clientdata/userHubData` fires from the FUT + hub (20:40 session). The client is storing its own hub state -- so the correct + server behaviour is to keep the blob and hand back exactly what was given, which + is zero-risk by construction: we never synthesise a shape, we echo the client's + own bytes. Persisting it is also the most plausible route to the hub's + "MANAGER TASKS 0/0" tile surviving a relaunch, since no FutGetObjectives class + exists in the binary at all (§9) -- the tile state may simply live in this blob. + """ + key = h.path.split("/clientdata/", 1)[-1].split("?")[0] or "default" + if h.command in ("PUT", "POST"): + try: + body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {} + except Exception: + body = None + if body is not None: + STORE.set_clientdata(key, body) + log(" CLIENTDATA: stored %r (%d bytes)" % (key, len(h._body))) + return 200, {} + return 200, STORE.get_clientdata(key) + + +# ---- GAME MODES: seasons / tournaments / leaderboards / champions ------------ +# These were 15 of the 45 templates in the URL table with NO route at all -- they +# fell through to the catch-all {}. Schemas below come from ENDPOINT_MAP.md (the +# same source that already had the match loop right); confidence per endpoint is +# noted inline. +# +# DEFAULT OFF. Every body here is documented-but-never-live-tested, and today's two +# regressions were both "serve a new body the client has never parsed". Notably +# FutSeasonList wants an ARRAY root where we currently send {} on a boot-adjacent +# path -- exactly the shape class that freezes at 0x1801c7f1a if wrong. Turn on +# with FUT_MODES=1 when you can watch a launch; `unset FUT_MODES` is the fallback. +_MODES = os.environ.get("FUT_MODES") == "1" + + +def season_list(): + """GET ut/%s/season -- FutSeasonList, deser 0x180167740 (HIGH). + ARRAY root of season descriptors. prizeSet(595)/elgReq(247) are nested and + FREEZE-RISK, so both are omitted (SKIP-safe).""" + return [{"id": 1, "divisionId": 10, "eligibilityKey": 0, "eligibilitySlot": 0, + "eligibilityValue": 0, "elgOperation": ""}] + + +def season_user(): + """GET ut/%s/season/user -- FutSeasonLoadData, deser 0x180131450 (HIGH, + switch fully traced at 0x18013153c). `data`(201) is an opaque interned blob + string; empty is valid. friendlySeasonHistory is nested -> omitted.""" + return {"seasonId": 1, "divisionId": 10, "round": 1, "userPoints": 0, + "dataVersion": "1", "data": ""} + + +def tournament_list(): + """GET ut/%s/tournament -- FutTournamentList, deser 0x180169ef0 (MEDIUM). + ARRAY root; rounds/prizeSet/staff/kit atoms are nested FREEZE-RISK -> omitted.""" + return [{"id": 1, "difficulty": 1, "coins": 500, "rewardMultiplier": 1, + "assetName": "", "eligibilityOperation": ""}] + + +def tournament_user(): + """GET ut/%s/tournament/user -- FutTournamentLoadData 0x180147cb0 (MEDIUM). + Mirrors the season shape; tournamentData(810) is the same interned blob.""" + return {"round": 1, "dataVersion": "1", "tournamentData": ""} + + +def leaderboard_route(h): + """GET ut/%s/leaderboards -- FutGetLBEntries 0x180144c8d (MEDIUM). + /options -- FutGetLBOptions 0x18014351c. Empty entries list is the safe body: + an empty array cannot desync the reader.""" + if "/options" in h.path: + return 200, {"category": 0, "id": 0, "period": 0, "view": 0, "url": ""} + return 200, {"entries": []} + + +def champion_route(h): + """ut/%s/champion -- registration 0x18014980d (ack, no atoms), + topX 0x18014a09d ({"entries":[]}), friends 0x18014b7ad ({} safe).""" + if h.command == "POST": + return 200, {} + return 200, {"entries": []} + + +# ---- THE CORE LOOP: match lifecycle + rewards -------------------------------- +# Schemas from ENDPOINT_MAP.md (all CONFIDENCE: HIGH, reversed earlier): +# POST ut/%s/match FutCreateMatch 0x180120380 +# startDateTime(740,int) reportIdEnabled(641,bool) +# squad(717,nested -- FREEZE-RISK, omit: SKIP-safe) +# PUT ut/%s/match/{id} FutMatchReady no deserializer at all -> {} +# POST ut/%s/match/{id} FutPlayGame no deserializer at all -> {} +# (the client SENDS the result here; body ignored) +# DELETE ut/%s/match/{id} FutDestroyMatch 0x180121b60 <-- THE REWARDS +# allCoins(20)@0x28 matchCoins(436)@0x2c tournamentCoins(809)@0x30 +# teamOfTournamentWinner(776,bool)@0x34 seasonCoins(670)@0x38 coins(149)@0x3c +# participationAward(529)@0x44 boostConis(96)@0x48 [EA's typo, exact key] +# qualifiedChampionEventId(617)@0xb0 +# gameModeAward(310) / matchCoinMultipliers(437) / userData(877) are NESTED and +# SKIP-safe -- omitted deliberately (userData is a documented freeze-risk: it +# must be an object if present, so the safe move is not to send it). +# Every field we DO send is a top-level scalar -> zero freeze risk. +# +# Reward amounts are ours to choose (the server decides payouts). Defaults are +# FUT-ish and env-tunable; they are NOT reversed values and are not claimed to be. +MATCH_COINS = { + "won": int(os.environ.get("FUT_MATCH_COINS_WIN", "400")), + "draw": int(os.environ.get("FUT_MATCH_COINS_DRAW", "200")), + "loss": int(os.environ.get("FUT_MATCH_COINS_LOSS", "100")), +} +MATCH_PARTICIPATION = int(os.environ.get("FUT_MATCH_PARTICIPATION", "0")) + + +def _match_result(body): + """Work out win/draw/loss from whatever the client posted. + + The PlayGame/DestroyMatch request shape is NOT reversed -- the response side is + (that is what we serve), but nobody has captured the request yet. So probe the + plausible spellings and fall back to a draw, which is the neutral outcome: it + still credits coins and advances the record without inventing a win. Every body + is logged, so the first live match tells us the real shape.""" + if not isinstance(body, dict): + return "draw", None + # a nested match/stats object is as likely as a flat one + for key in ("match", "matchStats", "stats", "result", "gameResult"): + inner = body.get(key) + if isinstance(inner, dict): + r, s = _match_result(inner) + if s is not None: + return r, s + for us, them in (("goals", "opponentGoals"), ("score", "opponentScore"), + ("userGoals", "opponentGoals"), ("homeGoals", "awayGoals"), + ("ourScore", "theirScore")): + a, b = body.get(us), body.get(them) + if isinstance(a, int) and isinstance(b, int): + return ("won" if a > b else "loss" if a < b else "draw"), (a, b) + # explicit textual result + r = body.get("result") or body.get("outcome") + if isinstance(r, str): + rl = r.lower() + for k, v in (("win", "won"), ("won", "won"), ("loss", "loss"), + ("lose", "loss"), ("defeat", "loss"), ("draw", "draw"), + ("tie", "draw")): + if k in rl: + return v, None + return "draw", None + + +def destroy_match_body(result, coins, total): + """FutDestroyMatchServerResponse (deser 0x180121b60) -- PURE, no state. + + Split out of match_route so it can be unit-tested: the match loop mutates + (credits coins, bumps W/D/L), so it cannot live in the read-only HTTP contract + suite. See tools/test_match_rewards.py. Every field is a top-level scalar; the + nested members gameModeAward(310)/matchCoinMultipliers(437)/userData(877) are + SKIP-safe and deliberately omitted (userData is a documented freeze-risk).""" + return { + "coins": int(coins), + "allCoins": int(total), + "matchCoins": int(MATCH_COINS.get(result, 0)), + "seasonCoins": 0, + "tournamentCoins": 0, + "boostConis": 0, # EA's spelling, atom 96 + "participationAward": int(MATCH_PARTICIPATION), + "qualifiedChampionEventId": 0, + "teamOfTournamentWinner": False, + } + + +def match_route(h): + """POST create / PUT ready / POST play / DELETE destroy(+rewards).""" + try: + body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {} + except Exception: + body = {} + m = re.search(r"/match/(\d+)", h.path) + match_id = int(m.group(1)) if m else None + is_delete = h.command == "DELETE" or "/ut/delete/" in h.path + + if is_delete: + # FutDestroyMatch -- the ONLY place a match awards anything. + result, score = _match_result(body) + coins = MATCH_COINS.get(result, 0) + MATCH_PARTICIPATION + rec, total = STORE.record_match(result, coins) + log(" MATCH: %s%s -> +%d coins (total %d) record %d-%d-%d" + % (result, (" %d-%d" % score) if score else "", coins, total, + rec["won"], rec["draw"], rec["loss"])) + return 200, destroy_match_body(result, coins, total) + + if h.command == "POST" and match_id is None: + # FutCreateMatch. `squad` is nested + freeze-risky -> omitted (SKIP-safe). + mid = STORE.new_item_id() + log(" MATCH: created id=%d" % mid) + return 200, {"startDateTime": int(datetime.datetime.now().timestamp()), + "reportIdEnabled": False, "id": mid} + + # PUT {id} = MatchReady, POST {id} = PlayGame. Both have NO deserializer at + # all, so {} is a complete response; the result is claimed on destroy. + if body: + log(" MATCH: %s %s body=%s" % (h.command, h.path, json.dumps(body)[:400])) + return 200, {} + + def squad_route(h): # PUT = SaveCurrentSquad. FutSquadSaveServerResponse deser 0x180171a60 parses # exactly ONE key, id(0x15c) -> reply {"id": }, NOT an echo of the @@ -421,38 +1075,79 @@ def squad_route(h): # ---- STORE / PACKS (first-cut; iterate against the log) --------------------- -def store_catalog(h): - packs = [] - idx = 1 - for p in PACK_CATALOG: - gold = p["gold"] - mtx = max(1, p["price"] // 100) - packs.append({ - "assetId": p["id"], - "id": p["id"], - "packType": "GOLD" if gold else "BRONZE", - "description": p["name"], - "state": "active", - "saleType": "promo", - "limitType": "NONE", - "quantity": 0, - "purchaseLimit": 0, - "purchaseCount": 0, - "isPremium": False, - "sortPriority": idx, - "currencies": [{"name": "coins", "funds": p["price"], "finalFunds": p["price"]}], - "extPrice": {"finalPrice": {"amount": mtx, "currency": "mtx"}, - "originalPrice": {"amount": mtx, "currency": "mtx"}}, - "packContentInfo": { - "bronzeQuantity": 0 if gold else p["count"], - "silverQuantity": 0, - "goldQuantity": p["count"] if gold else 0, - "rareQuantity": p["count"] if gold else 0, - "itemQuantity": p["count"], - "unopened": False, - }, +def _pack_body(p, idx): + """One entry of FutStoreGetPackTypes.purchase (element deser 0x18013af30). + + THIS IS THE ORIGINAL, KNOWN-GOOD BODY -- restored 2026-08-04 after my "field + corrections" broke pack BUYING live. It renders pack tiles as "unknown" (see + FUT_STORE_FIELDS below) but packs are purchasable, which matters more. + + The corrections were derived from re-reading the deserializer and are probably + right about what is PARSED -- but "parsed" is not "safe to change", and I + swapped a working body for an unverified one with no way to test it offline. + They now live behind FUT_STORE_FIELDS=1. + """ + gold = p["gold"] + mtx = max(1, p["price"] // 100) + body = { + "assetId": p["id"], + "id": p["id"], + "packType": "GOLD" if gold else "BRONZE", + "description": p["name"], + "state": "active", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": False, + "sortPriority": idx, + "currencies": [{"name": "coins", "funds": p["price"], "finalFunds": p["price"]}], + "extPrice": {"finalPrice": {"amount": mtx, "currency": "mtx"}, + "originalPrice": {"amount": mtx, "currency": "mtx"}}, + "packContentInfo": { + "bronzeQuantity": 0 if gold else p["count"], + "silverQuantity": 0, + "goldQuantity": p["count"] if gold else 0, + "rareQuantity": p["count"] if gold else 0, + "itemQuantity": p["count"], + "unopened": False, + }, + } + if STORE_FIELDS: + # Re-extracted from 0x18013af30 (correct about what the parser READS, but + # live-untested and NOT proven safe -- the last attempt stopped packs from + # opening at all). extPrice inner objects take externalPriceId(0x11a)+active, + # not amount/currency. + body.update({ + "dealType": "", "actionType": 0, "bonus": 0, "points": 0, + "value": p["price"], "priority": idx, "firstPartyStoreId": 0, + "useDefaultImage": True, "start": 0, "end": 0, }) - idx += 1 + body["currencies"][0]["active"] = True + body["extPrice"] = {"finalPrice": {"externalPriceId": p["id"], "active": True}, + "originalPrice": {"externalPriceId": p["id"], "active": True}} + return body + + +def store_catalog(h): + """GET ut/%s/store/purchasegroup/... -- FutStoreGetPackTypes (root 0x1801234e0). + + The "unknown" tiles are STILL unfixed. displayGroup(0xd9) is the likely answer -- + the store renders display GROUPS and that key is parsed recursively by the same + element parser -- but sending it FROZE the store screen (busy-loop 0x1801c7f1a), + so it is behind FUT_STORE_GROUPS=1, default OFF. + """ + packs = [] + for idx, p in enumerate(PACK_CATALOG, start=1): + entry = _pack_body(p, idx) + if STORE_GROUPS: + group = _pack_body(p, idx) + group.pop("displayGroup", None) + entry["displayGroup"] = [group] # RECURSIVE -- froze the store + entry["displayGroupAssetId"] = p["id"] + entry["displayGroupUseDefaultImage"] = True + packs.append(entry) return 200, {"purchase": packs, "timestamp": 1596326400} @@ -506,6 +1201,11 @@ def purchased_items(h): return 461, {"reason": "insufficient_coins", "credits": STORE.coins()} log(" STORE: POST /purchased opened pack %s -> %d items, coins=%d" % (pack["name"], len(items), STORE.coins())) + if PACK_AUTOCLUB: + # Move straight to the club so the reveal never offers a hand-off. + moved = STORE.move_items([{"id": it["id"], "pile": "club"} for it in items]) + log(" STORE: auto-club deposited %d card(s) (reveal hand-off bypassed)" + % len(moved)) return 200, { "packId": pid, "firstPartyStoreId": 0, @@ -659,7 +1359,7 @@ def tradepile_route(h): # The user's OWN sale pile: build a validated auction record per active listing # from the owned club item + its list prices. Freeze-safe (same record shape). by_id = {it["id"]: it for it in STORE.items()} - seller = STORE.profile().get("personaName", "OpenFUT") + seller = ACCOUNT.persona_name recs = [] for l in STORE.listings(): it = by_id.get(l["itemId"])