fifa17-recon: match rewards, POW online layer, account backend, quick sell

Second session. FUT core loop, the EASFC/POW online layer, a central account
backend, and a lot of corrections. Everything risky is behind an env flag with
the default set to whatever was live-proven.

WORKING END TO END (live-verified this session):
  * match loop -- POST/PUT/POST/DELETE ut/%s/match, rewards via FutDestroyMatch
    (0x180121b60). Play a match, get coins, W/D/L updates.
  * packs -- buy, cards land in the club, session survives (FUT_PACK_AUTOCLUB=1)
  * quick sell -- POST ut/delete/%s/item was UNMAPPED and paid NOTHING; six cards
    were destroyed for 0 coins. Now credits discardValue.
  * POW/EASFC online -- the "EA FC servers unreachable" banner is powdll's layer,
    a THIRD http api on :8094 nobody had served. Redirect needs no root: powdll
    FUN_18005a460 reads FIFA_POW_URL from the same client-config store as
    ROSTERUPDATE_URL. FUT_POW=1.
  * account backend -- fut_account.py replaces 7 hardcoded copies of the persona
    across 5 files; club/persona/online-profile editable via CLI.

CORRECTIONS TO ENDPOINT_MAP (all re-extracted from the deserializers):
  * FutStoreGetPackTypes: id/packType/isPremium/quantity/saleType/purchaseLimit/
    purchaseCount are NOT skipped no-ops -- all are parsed. extPrice inner objects
    take externalPriceId(0x11a), not amount/currency.
  * FutMoveCard 0x180128600 has NO skip handler (FUN_180135ff0 appears zero times,
    unique among FUT deserializers) and parses only itemData -> dreamSquads.
  * class -> deserializer resolution: the name literal is preceded by a 4-BYTE
    HEADER and the factory LEA points at the header, so look up name_addr - 4.
    Six attempts failed on this; now ghidra_env.class_deser(). Unlocked 11 SBC/
    Draft schemas.
  * live-only endpoints the request table never lists: ut/%s/squad/list,
    ut/%s/user/club, ut/%s/club/stats/*, ut/%s/clientdata/<key>. The template
    table is a floor, not a ceiling -- the log is the only ground truth.
  * 163 RS4 call names exist; we served 17. All now served.

FIXED: club/stats/* was answering with the entire 28-item club inventory on every
poll (it fell through to the generic /club route).

UNSOLVED: the pack reveal's "Send to Club" (PUT ut/%s/item) kills the FUT session
whatever we answer -- {} included -- while its sibling quick-sell endpoint accepts
a bare {}. Seven hypotheses eliminated by live test, documented in
REBUILD_RESEARCH.md S14c so none get re-walked. FUT_PACK_AUTOCLUB routes around it.
Also unfixed: store tiles render "unknown" (displayGroup is parsed RECURSIVELY by
the same element parser; sending it FROZE the store, so FUT_STORE_GROUPS=1 is
default off).

Tests: test_fut_contract.py 380 (live, read-only) + test_match_rewards.py 51 (pure).

Note: fut_store.py carries some pre-existing uncommitted changes from before this
session (pack catalogue ids, pending-pile behaviour) that could not be separated
from this session's additions in the same file.

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