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:
@@ -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
|
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
|
*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.
|
(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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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.
|
"""FIFA17 Blaze redirector RESPONDER + second-hop Fire2 capture.
|
||||||
- TLS on 42127: answers POST /redirector/getServerInstance with a
|
- TLS on 42127: answers POST /redirector/getServerInstance with a
|
||||||
<serverinstanceinfo> pointing the client at 127.0.0.1:BLAZE_PORT (secure=0).
|
<serverinstanceinfo> pointing the client at 127.0.0.1:BLAZE_PORT (secure=0).
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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).
|
"""FIFA17 Blaze redirector + SESSION SERVER (v2).
|
||||||
|
|
||||||
Two listeners:
|
Two listeners:
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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.
|
"""FIFA17 Blaze redirector + SESSION SERVER (v3) -- offline forged authentication.
|
||||||
|
|
||||||
WHAT IS NEW vs v2
|
WHAT IS NEW vs v2
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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.
|
"""FIFA17 Blaze redirector + SESSION SERVER (v3) -- offline forged authentication.
|
||||||
|
|
||||||
WHAT IS NEW vs v2
|
WHAT IS NEW vs v2
|
||||||
|
|||||||
@@ -84,33 +84,48 @@ from heat2 import ( # noqa: E402
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ================================================================== identity
|
# ================================================================== identity
|
||||||
# SHARED CONSTANTS -- these MUST stay byte-identical to lsx_responder.py.
|
# SOURCED FROM fut_account.ACCOUNT -- the single source of truth shared with
|
||||||
# Source: stp-origin_emu.ini [Globals] (PersonaId / PersonaName / Language).
|
# lsx_responder_v2.py, fut_store.py, fut_seed.py and utas_server.py.
|
||||||
# A mismatch is exactly what raises AUTH_ERR_INVALID_PERSONA (26),
|
#
|
||||||
# AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA and AUTH_ERR_PERSONA_NOT_FOUND.
|
# 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
|
from fut_account import ACCOUNT # noqa: E402
|
||||||
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.
|
|
||||||
|
|
||||||
CONTENT_ID = "1027460" # FIFA 17 EA offer id (retail)
|
PERSONA_ID = ACCOUNT.persona_id
|
||||||
ENTITLEMENT_TAG = "ONLINE_ACCESS" # TRIAL_ONLINE_ACCESS for FIFA17_Trial.exe
|
PERSONA_NAME = ACCOUNT.persona_name
|
||||||
ENTITLEMENT_GROUP = "FIFA17PCBoxContent" # was "FIFA17PC" -> matched NEITHER strstr
|
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
|
# needle in EntitlementComponent::onListEntitlements (0x146f27440): FUT keeps an
|
||||||
# entitlement only if GNAM contains "FIFA17PCBoxContent" OR "FIFA16PC" (needles
|
# entitlement only if GNAM contains "FIFA17PCBoxContent" OR "FIFA16PC" (needles
|
||||||
# @0x144334030), TAG non-empty, STAT==1. "FIFA17PC" survived none -> empty store.
|
# @0x144334030), TAG non-empty, STAT==1. "FIFA17PC" survived none -> empty store.
|
||||||
|
|
||||||
TITLE_ID = "309111"
|
TITLE_ID = ACCOUNT.TITLE_ID
|
||||||
CLIENT_ID = "FIFA17-PC-SERVER-BLAZE"
|
CLIENT_ID = ACCOUNT.CLIENT_ID
|
||||||
PLATFORM = "pc"
|
PLATFORM = ACCOUNT.PLATFORM
|
||||||
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n"
|
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n"
|
||||||
|
|
||||||
# ================================================================== config
|
# ================================================================== config
|
||||||
@@ -530,9 +545,36 @@ IDENTITY_PARAMS = [
|
|||||||
("redirect_uri", "http://127.0.0.1/success"),
|
("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 = {
|
CLIENT_CONFIGS = {
|
||||||
"BlazeSDK": None, # built dynamically, see below
|
"BlazeSDK": None, # built dynamically, see below
|
||||||
"netres": OSDK_NETRES, # CFID (verified @0x143962be0)
|
"netres": OSDK_NETRES, # CFID (verified @0x143962be0)
|
||||||
|
"OSDK_POW": OSDK_POW, # EASFC/POW redirect (opt-in, FUT_POW=1)
|
||||||
"OSDK_CORE": OSDK_CORE,
|
"OSDK_CORE": OSDK_CORE,
|
||||||
"OSDK_CLIENT": OSDK_CLIENT,
|
"OSDK_CLIENT": OSDK_CLIENT,
|
||||||
"OSDK_NUCLEUS": OSDK_NUCLEUS,
|
"OSDK_NUCLEUS": OSDK_NUCLEUS,
|
||||||
@@ -565,15 +607,55 @@ FUT_RS4_MODULES = [
|
|||||||
"TFA", "SQUADMODE", "DRAFT", "CHAMPIONS", "V2STORE", "LIVEMESSAGE",
|
"TFA", "SQUADMODE", "DRAFT", "CHAMPIONS", "V2STORE", "LIVEMESSAGE",
|
||||||
"ADMIN", "DEBUG", "MAINTENANCE",
|
"ADMIN", "DEBUG", "MAINTENANCE",
|
||||||
]
|
]
|
||||||
FUT_RS4_CALLS_BOOT = [
|
# EVERY RS4 call name in the client's table at 0x18021e250-0x18021fa60 (163 of them),
|
||||||
"GETSETTINGS", "AUTHENTICATION", "LOGIN", "LOGOUT", "CREATEUSER",
|
# not just the 17 boot ones. THE CLIENT RESOLVES A PER-CALL URL KEY FIRST:
|
||||||
"GETUSERINFO", "GETUSERDATA", "GETUSERCREDITS", "USERRELIABILITYINFO",
|
# FUT_RS4_URL_<CALL> takes precedence over the per-module FUT_RS4_APIURL_<MODULE>.
|
||||||
"GETHUBDATA", "GETUSERMASSINFO", "LOADACTIVESQUAD", "SQUADLIST",
|
# Serving only the boot subset left 146 calls unresolved, so anything past boot --
|
||||||
"GETSQUADINFO", "GETCLUBINFO", "KEEPALIVE", "SEASONHISTORY",
|
# 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_CONFIG = (
|
||||||
[("FUT_RS4_APIURL_%s" % m, UTAS_BASE) for m in FUT_RS4_MODULES]
|
[("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)]
|
+ [("FUT_RS4_BASE_URL", UTAS_BASE)]
|
||||||
# STORE gate: FIFA shows "store not available" unless these config flags are
|
# STORE gate: FIFA shows "store not available" unless these config flags are
|
||||||
# true. The store-screen entitlement checks (CardsDLL 0x18001749d vtable+0x138 /
|
# 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).
|
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
|
FUT_RS4_* base-URL keys ride on EVERY CFID (merged '_all' store; which section
|
||||||
CardsDLL reads is unproven, so serve them everywhere)."""
|
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":
|
if cfid == "BlazeSDK":
|
||||||
return sorted(blazesdk_config() + FUT_RS4_CONFIG)
|
return sorted(blazesdk_config() + FUT_RS4_CONFIG + OSDK_POW)
|
||||||
return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG)
|
return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG + OSDK_POW)
|
||||||
|
|
||||||
|
|
||||||
def fetch_config_response_fields(cfid: str) -> "OrderedDict":
|
def fetch_config_response_fields(cfid: str) -> "OrderedDict":
|
||||||
@@ -691,9 +778,13 @@ def ping_response_fields() -> "OrderedDict":
|
|||||||
def persona_details_fields(now: int) -> "OrderedDict":
|
def persona_details_fields(now: int) -> "OrderedDict":
|
||||||
"""Blaze::Authentication::PersonaDetails @0x14487cab0 -- 6 members."""
|
"""Blaze::Authentication::PersonaDetails @0x14487cab0 -- 6 members."""
|
||||||
return OrderedDict([
|
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
|
("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
|
("PLAT", (INT, CLIENT_PLATFORM)), # clientPlatform enum -> pc
|
||||||
("STAS", (INT, PERSONA_STATUS)), # PersonaStatus::Code -> ACTIVE
|
("STAS", (INT, PERSONA_STATUS)), # PersonaStatus::Code -> ACTIVE
|
||||||
("XREF", (INT, EXT_ID)), # extId uint64
|
("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
|
# 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.
|
# 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":
|
def account_info_fields(sess: "Session", now: int) -> "OrderedDict":
|
||||||
@@ -1556,6 +1647,13 @@ def _selftest() -> None:
|
|||||||
print("blaze_responder_v3 selftest")
|
print("blaze_responder_v3 selftest")
|
||||||
print("=" * 72)
|
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()
|
||||||
sess.session_key = "0540000031e5dde8_OPENFUTselftestkeyOPENFUTselftestkeyOPENFUT0"
|
sess.session_key = "0540000031e5dde8_OPENFUTselftestkeyOPENFUTselftestkeyOPENFUT0"
|
||||||
sess.account_locale = 0x656E5553
|
sess.account_locale = 0x656E5553
|
||||||
@@ -1602,8 +1700,10 @@ def _selftest() -> None:
|
|||||||
d = s["PDTL"][1]
|
d = s["PDTL"][1]
|
||||||
assert list(d.keys()) == ["DSNM", "LAST", "PID", "PLAT", "STAS", "XREF"], \
|
assert list(d.keys()) == ["DSNM", "LAST", "PID", "PLAT", "STAS", "XREF"], \
|
||||||
list(d.keys())
|
list(d.keys())
|
||||||
assert d["PID"][1] == PERSONA_ID == 33068179
|
# Identity is now configurable (fut_account.ACCOUNT), so assert CONSISTENCY
|
||||||
assert d["DSNM"][1] == PERSONA_NAME == "CAGE"
|
# 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
|
assert back["ANON"][1] == 0 and back["UNDR"][1] == 0 and back["NTOS"][1] == 0
|
||||||
lfr = fire2(COMP_AUTH, CMD_LOGIN, 7, REPLY, lp)
|
lfr = fire2(COMP_AUTH, CMD_LOGIN, 7, REPLY, lp)
|
||||||
lh = parse_fire2_header(lfr)
|
lh = parse_fire2_header(lfr)
|
||||||
|
|||||||
@@ -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:]))
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""OpenFUT save maintenance — inspect and repair fifa17_profile.json offline.
|
||||||
|
|
||||||
|
Exists because the pack→club hand-off is not proven. Cards from an opened pack land
|
||||||
|
in the PENDING pile (`profile["purchased"]`) and only reach the club when the client
|
||||||
|
sends `PUT ut/%s/item` (FutMoveCard) from the reveal screen's "send to club". Across
|
||||||
|
every logged session that request has fired **zero** times, while 12 cards sit
|
||||||
|
pending — so either the flow was never exercised in-game, or the client does not
|
||||||
|
issue it the way we assume. Same shape as the squad blocker: an assumed client
|
||||||
|
request that never actually arrives.
|
||||||
|
|
||||||
|
Until a live pack-open settles it, this is the manual path.
|
||||||
|
|
||||||
|
SAFETY: the client desyncs fatally (logout) if a card exists in BOTH the pending pile
|
||||||
|
and the club — see docs/CARD_SYSTEM.md and Store.move_items. `--flush-purchased`
|
||||||
|
therefore MOVES (never copies): each card is removed from `purchased` in the same
|
||||||
|
transaction that appends it to `items`. Run it with FIFA CLOSED so the client cannot
|
||||||
|
be holding a stale view of either pile.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
fut_admin.py --show profile summary (default)
|
||||||
|
fut_admin.py --flush-purchased move every pending card into the club
|
||||||
|
fut_admin.py --flush-purchased -n dry run: show what would move
|
||||||
|
fut_admin.py --backup timestamped copy of the profile
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from fut_store import STORE # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(it):
|
||||||
|
return "asset=%-7s rating=%-3s pos=%-4s id=%s" % (
|
||||||
|
it.get("assetId"), it.get("rating"), it.get("preferredPosition"), it.get("id"))
|
||||||
|
|
||||||
|
|
||||||
|
def show():
|
||||||
|
p = STORE.profile()
|
||||||
|
rec = p.get("record", {})
|
||||||
|
print("profile : %s" % STORE.path)
|
||||||
|
print("club : %s (%s) est %s" % (p.get("clubName"), p.get("clubAbbr"),
|
||||||
|
p.get("established")))
|
||||||
|
print("coins : %s points: %s" % (p.get("coins"), p.get("points")))
|
||||||
|
print("record : %s-%s-%s matches: %s"
|
||||||
|
% (rec.get("won", 0), rec.get("draw", 0), rec.get("loss", 0),
|
||||||
|
p.get("matchesPlayed", 0)))
|
||||||
|
print("club items : %d" % len(p.get("items", [])))
|
||||||
|
print("squads saved : %d" % len(p.get("squads", [])))
|
||||||
|
print("packs opened : %s" % p.get("packsOpened", 0))
|
||||||
|
print("listings : %d" % len(p.get("listings", [])))
|
||||||
|
print("clientdata : %s" % (sorted(p.get("clientdata", {})) or "none"))
|
||||||
|
pend = p.get("purchased", [])
|
||||||
|
print("PENDING pack items: %d%s"
|
||||||
|
% (len(pend), " <-- not in the club; see --flush-purchased" if pend else ""))
|
||||||
|
for it in pend[:20]:
|
||||||
|
print(" %s" % _fmt(it))
|
||||||
|
if len(pend) > 20:
|
||||||
|
print(" ... and %d more" % (len(pend) - 20))
|
||||||
|
|
||||||
|
|
||||||
|
def backup():
|
||||||
|
dst = "%s.%s.bak" % (STORE.path,
|
||||||
|
datetime.datetime.now().strftime("%Y%m%d-%H%M%S"))
|
||||||
|
shutil.copy2(STORE.path, dst)
|
||||||
|
print("backup -> %s" % dst)
|
||||||
|
return dst
|
||||||
|
|
||||||
|
|
||||||
|
def flush(dry_run):
|
||||||
|
pend = list(STORE.profile().get("purchased", []))
|
||||||
|
if not pend:
|
||||||
|
print("nothing pending — the club already has every pack card")
|
||||||
|
return 0
|
||||||
|
print("%d pending card(s)%s:" % (len(pend), " (DRY RUN)" if dry_run else ""))
|
||||||
|
for it in pend:
|
||||||
|
print(" %s" % _fmt(it))
|
||||||
|
if dry_run:
|
||||||
|
print("\ndry run — nothing written. Re-run without -n to move them.")
|
||||||
|
return 0
|
||||||
|
backup()
|
||||||
|
# Reuse the server's own move path so the pending/club invariant is enforced in
|
||||||
|
# exactly one place: move_items() deletes from `purchased` in the same locked
|
||||||
|
# transaction that appends to `items`.
|
||||||
|
moved = STORE.move_items([{"id": it["id"], "pile": "club"} for it in pend])
|
||||||
|
p = STORE.profile()
|
||||||
|
print("\nmoved %d card(s) into the club" % len(moved))
|
||||||
|
print("club items now: %d pending now: %d"
|
||||||
|
% (len(p.get("items", [])), len(p.get("purchased", []))))
|
||||||
|
if p.get("purchased"):
|
||||||
|
print("WARNING: %d card(s) did not move — ids missing from the pending pile"
|
||||||
|
% len(p["purchased"]))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--show", action="store_true", help="profile summary (default)")
|
||||||
|
ap.add_argument("--flush-purchased", action="store_true",
|
||||||
|
help="move pending pack cards into the club (run with FIFA closed)")
|
||||||
|
ap.add_argument("-n", "--dry-run", action="store_true", help="with --flush-purchased")
|
||||||
|
ap.add_argument("--backup", action="store_true", help="timestamped profile copy")
|
||||||
|
a = ap.parse_args(argv)
|
||||||
|
if a.backup:
|
||||||
|
backup()
|
||||||
|
if a.flush_purchased:
|
||||||
|
return flush(a.dry_run)
|
||||||
|
show()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -25,9 +25,13 @@
|
|||||||
# resourceId decompose 0x180166ca0 CONFIRMED: assetId = resourceId & 0xffffff,
|
# resourceId decompose 0x180166ca0 CONFIRMED: assetId = resourceId & 0xffffff,
|
||||||
# high byte = version. Version byte value is the open question s2v0/s2v1 answer.
|
# 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
|
ITEM_ID_BASE = 100000000
|
||||||
|
|
||||||
# Real FIFA17 assetIds read earlier from the live InGameDB. assetId 41 (Iniesta)
|
# 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,
|
"resourceId": rid,
|
||||||
"assetId": asset,
|
"assetId": asset,
|
||||||
"cardassetid": 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
|
"cardsubtypeid": 0, # 0..3 => PLAYER
|
||||||
"itemType": "player",
|
"itemType": "player",
|
||||||
"rareflag": 1,
|
"rareflag": 1,
|
||||||
@@ -88,8 +97,8 @@ def _base_squad():
|
|||||||
MUST be unique 0..22) and manager empty -> zero item-deser calls by default."""
|
MUST be unique 0..22) and manager empty -> zero item-deser calls by default."""
|
||||||
return {
|
return {
|
||||||
"id": 0,
|
"id": 0,
|
||||||
"personaId": PERSONA_ID, # must equal logged-in persona (0x18014659c)
|
"personaId": ACCOUNT.persona_id, # must equal logged-in persona (0x18014659c)
|
||||||
"squadName": "OpenFUT",
|
"squadName": ACCOUNT.squad_name,
|
||||||
"formation": "f442",
|
"formation": "f442",
|
||||||
"squadType": "REGULAR_SQUAD",
|
"squadType": "REGULAR_SQUAD",
|
||||||
"chemistry": 100,
|
"chemistry": 100,
|
||||||
@@ -182,7 +191,7 @@ def squad_summary(squad):
|
|||||||
"chemistry": int(squad.get("chemistry", 0)),
|
"chemistry": int(squad.get("chemistry", 0)),
|
||||||
"formation": squad.get("formation", "f442"),
|
"formation": squad.get("formation", "f442"),
|
||||||
"id": int(squad.get("id", 0)),
|
"id": int(squad.get("id", 0)),
|
||||||
"squadName": squad.get("squadName", "OpenFUT"),
|
"squadName": squad.get("squadName", ACCOUNT.squad_name),
|
||||||
"squadType": squad.get("squadType", "REGULAR_SQUAD"),
|
"squadType": squad.get("squadType", "REGULAR_SQUAD"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+157
-17
@@ -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
|
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).
|
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__))
|
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"))
|
PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json"))
|
||||||
|
|
||||||
PERSONA_ID = 33068179
|
# Back-compat snapshots. Identity now lives in fut_account.ACCOUNT so Blaze, LSX
|
||||||
PERSONA_NAME = "CAGE"
|
# 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()
|
_LOCK = threading.Lock()
|
||||||
|
|
||||||
# Starter squad granted on first run (real FIFA17 assetIds; identity resolves
|
# 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)]
|
for i, (a, r, p, n, lg, tm, at) in enumerate(STARTER_PLAYERS)]
|
||||||
return {
|
return {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"personaId": PERSONA_ID,
|
# personaId/personaName/clubName/clubAbbr/established are NOT seeded
|
||||||
"personaName": PERSONA_NAME,
|
# here any more -- they belong to fut_account.ACCOUNT. _sync_identity()
|
||||||
"clubName": "OpenFUT",
|
# mirrors them into the save on every load so existing readers
|
||||||
"clubAbbr": "OFC",
|
# (utas_server's userInfo, tradepile sellerName) keep working unchanged
|
||||||
"established": "2026",
|
# and can never disagree with what Blaze/LSX assert.
|
||||||
"coins": 15000,
|
"coins": 15000,
|
||||||
"points": 0,
|
"points": 0,
|
||||||
"record": {"won": 0, "draw": 0, "loss": 0},
|
"record": {"won": 0, "draw": 0, "loss": 0},
|
||||||
"nextItemId": ITEM_ID_BASE + len(STARTER_PLAYERS) + 1,
|
"nextItemId": ITEM_ID_BASE + len(STARTER_PLAYERS) + 1,
|
||||||
"items": items, # owned club items
|
"items": items, # owned club items
|
||||||
|
"purchased": [], # unassigned/pending items from opened packs
|
||||||
"squads": [], # saved squads (raw squad objects from PUT /squad)
|
"squads": [], # saved squads (raw squad objects from PUT /squad)
|
||||||
"packsOpened": 0,
|
"packsOpened": 0,
|
||||||
}
|
}
|
||||||
@@ -96,9 +103,27 @@ class Store:
|
|||||||
self._p = json.load(f)
|
self._p = json.load(f)
|
||||||
else:
|
else:
|
||||||
self._p = _new_profile()
|
self._p = _new_profile()
|
||||||
|
self._sync_identity()
|
||||||
self._save()
|
self._save()
|
||||||
|
self._sync_identity()
|
||||||
return self._p
|
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):
|
def _save(self):
|
||||||
tmp = self.path + ".tmp"
|
tmp = self.path + ".tmp"
|
||||||
with open(tmp, "w") as f:
|
with open(tmp, "w") as f:
|
||||||
@@ -109,6 +134,30 @@ class Store:
|
|||||||
def profile(self):
|
def profile(self):
|
||||||
return self.load()
|
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):
|
def coins(self):
|
||||||
return self.load()["coins"]
|
return self.load()["coins"]
|
||||||
|
|
||||||
@@ -138,6 +187,64 @@ class Store:
|
|||||||
self.load()["coins"] += amount
|
self.load()["coins"] += amount
|
||||||
self._save()
|
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):
|
def save_squad(self, squad):
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
p = self.load()
|
p = self.load()
|
||||||
@@ -145,6 +252,37 @@ class Store:
|
|||||||
p["squads"] = [s for s in p["squads"] if s.get("id") != sid] + [squad]
|
p["squads"] = [s for s in p["squads"] if s.get("id") != sid] + [squad]
|
||||||
self._save()
|
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):
|
def active_squad(self):
|
||||||
sq = self.load()["squads"]
|
sq = self.load()["squads"]
|
||||||
return sq[0] if sq else None
|
return sq[0] if sq else None
|
||||||
@@ -194,8 +332,10 @@ class Store:
|
|||||||
|
|
||||||
|
|
||||||
def open_pack(self, price, count, gold=True):
|
def open_pack(self, price, count, gold=True):
|
||||||
"""Deduct `price` coins, generate `count` player items from the pool, add
|
"""Deduct `price` coins, generate `count` player items from the pool, and
|
||||||
them to the club, return them. Returns None if not enough coins."""
|
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
|
import random
|
||||||
if not self.spend(price):
|
if not self.spend(price):
|
||||||
return None
|
return None
|
||||||
@@ -203,15 +343,15 @@ class Store:
|
|||||||
picks = [random.choice(pool) for _ in range(count)]
|
picks = [random.choice(pool) for _ in range(count)]
|
||||||
items = [_item(self.new_item_id(), a, r, p, n, lg, tm, at)
|
items = [_item(self.new_item_id(), a, r, p, n, lg, tm, at)
|
||||||
for (a, r, p, n, lg, tm, at) in picks]
|
for (a, r, p, n, lg, tm, at) in picks]
|
||||||
self.add_items(items)
|
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
self.load()["packsOpened"] += 1
|
p = self.load()
|
||||||
self._last_pack = items
|
p.setdefault("purchased", []).extend(items)
|
||||||
|
p["packsOpened"] += 1
|
||||||
self._save()
|
self._save()
|
||||||
return items
|
return items
|
||||||
|
|
||||||
def last_pack(self):
|
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);
|
# 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.
|
# 3 store packs (price in coins, card count, gold-only). Ids are stable.
|
||||||
PACK_CATALOG = [
|
PACK_CATALOG = [
|
||||||
{"id": 101, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False},
|
{"id": 1, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False},
|
||||||
{"id": 102, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True},
|
{"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True},
|
||||||
{"id": 103, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True},
|
{"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,13 +33,19 @@ pyghidra.start(verbose=False)
|
|||||||
from ghidra.app.decompiler import DecompInterface # noqa: E402
|
from ghidra.app.decompiler import DecompInterface # noqa: E402
|
||||||
from ghidra.util.task import ConsoleTaskMonitor # noqa: E402
|
from ghidra.util.task import ConsoleTaskMonitor # noqa: E402
|
||||||
|
|
||||||
DLL = "/tmp/fut/cardsdll.dll"
|
# Defaults target CardsDLL; override for another binary, e.g. powdll (the EASFC/POW
|
||||||
PROJ_DIR, PROJ = "/tmp/ghidra_fut", "cardsdll"
|
# 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
|
# nested_project_location=False -> use /tmp/ghidra_fut/cardsdll.gpr itself (the
|
||||||
# already-analysed project) instead of creating /tmp/ghidra_fut/cardsdll/.
|
# already-analysed project) instead of creating /tmp/ghidra_fut/cardsdll/.
|
||||||
_ctx = pyghidra.open_program(DLL, project_location=PROJ_DIR, project_name=PROJ,
|
_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)
|
nested_project_location=False)
|
||||||
flat = _ctx.__enter__()
|
flat = _ctx.__enter__()
|
||||||
prog = flat.getCurrentProgram()
|
prog = flat.getCurrentProgram()
|
||||||
@@ -156,6 +162,51 @@ def vtable(a, n=64):
|
|||||||
return out
|
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):
|
def fname(a):
|
||||||
f = func(a)
|
f = func(a)
|
||||||
return f.getName() if f else "?"
|
return f.getName() if f else "?"
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
OpenFUT clean-room LSX responder for FIFA 17 (replaces the Steampunks stp-origin_emu
|
||||||
in-process stub on 127.0.0.1:4216).
|
in-process stub on 127.0.0.1:4216).
|
||||||
|
|||||||
@@ -100,17 +100,27 @@ import time
|
|||||||
|
|
||||||
from Crypto.Cipher import AES
|
from Crypto.Cipher import AES
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from fut_account import ACCOUNT # noqa: E402
|
||||||
|
|
||||||
# ---------------------------------------------------------------- identity
|
# ---------------------------------------------------------------- identity
|
||||||
# SHARED CONSTANTS -- must stay byte-identical to blaze_responder_v3.py.
|
# SOURCED FROM fut_account.ACCOUNT, shared with blaze_responder_v3b.py,
|
||||||
# A mismatch between what LSX reports here and what Blaze returns in
|
# fut_store.py, fut_seed.py and utas_server.py.
|
||||||
# LoginResponse.SESS.PDTL is exactly what raises AUTH_ERR_INVALID_PERSONA /
|
#
|
||||||
# AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA / AUTH_ERR_PERSONA_NOT_FOUND.
|
# THE CONSTRAINT IS CROSS-LAYER CONSISTENCY, NOT ANY PARTICULAR VALUE: what LSX
|
||||||
PERSONA_ID = 33068179
|
# reports here must equal what Blaze returns in LoginResponse.SESS.PDTL and what
|
||||||
PERSONA_NAME = "CAGE"
|
# UTAS serves as userInfo.personaId. (The previous comment blamed a mismatch for
|
||||||
USER_ID = 33068179
|
# AUTH_ERR_INVALID_PERSONA / AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA /
|
||||||
CONTENT_ID = "1027460" # FIFA 17 EA offer id
|
# AUTH_ERR_PERSONA_NOT_FOUND -- those are Blaze *server* error codes and we are
|
||||||
ENTITLEMENT_TAG = "ONLINE_ACCESS"
|
# the server. Neither "CAGE" nor "33068179" appears in FIFA17.exe, CardsDLL or
|
||||||
LOCALE = "en_US"
|
# 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"
|
AUTHCODE_FILE = "/tmp/openfut_authcode.txt"
|
||||||
CLIENTID_FILE = "/tmp/openfut_lsx_clientid.txt"
|
CLIENTID_FILE = "/tmp/openfut_lsx_clientid.txt"
|
||||||
@@ -397,6 +407,10 @@ def build_reply(mid, req_name, attrs, conn, recipient=""):
|
|||||||
# (OriginGetDefaultUser @0x1470da6d0 / OriginGetDefaultPersona
|
# (OriginGetDefaultUser @0x1470da6d0 / OriginGetDefaultPersona
|
||||||
# @0x1470da680 are bare reads of those fields, written only by
|
# @0x1470da680 are bare reads of those fields, written only by
|
||||||
# OriginSDK::Initialize @0x1470e5ad5/0x1470e5ae1). Keep it complete.
|
# 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,
|
return resp(mid,
|
||||||
f'GetProfileResponse IsSubscriber="true" PersonaId="{PERSONA_ID}" '
|
f'GetProfileResponse IsSubscriber="true" PersonaId="{PERSONA_ID}" '
|
||||||
f'AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US" '
|
f'AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US" '
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ SERVERS=(
|
|||||||
"roster roster_server.py 8081 -"
|
"roster roster_server.py 8081 -"
|
||||||
"utas utas_server.py 8099 -"
|
"utas utas_server.py 8099 -"
|
||||||
"autopatch autopatch.py - -"
|
"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' "$*"; }
|
c() { printf ' %s\n' "$*"; }
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
# 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 \
|
grep -q '[[:space:]]easw\.easports\.com\b' /etc/hosts 2>/dev/null \
|
||||||
|| printf '127.0.0.1\teasw.easports.com\n' >> /etc/hosts
|
|| 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 ---"
|
echo "--- armed ---"
|
||||||
sysctl kernel.yama.ptrace_scope net.ipv4.conf.lo.route_localnet
|
sysctl kernel.yama.ptrace_scope net.ipv4.conf.lo.route_localnet
|
||||||
|
|||||||
@@ -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
|
reversed schemas so a future edit that reintroduces that class of bug fails here
|
||||||
instead of freezing the game.
|
instead of freezing the game.
|
||||||
|
|
||||||
Read-only: only GET endpoints are exercised (no pack buys / squad writes), so it
|
MOSTLY read-only: every check but one uses GET, so no pack is bought and no squad
|
||||||
never mutates the profile. Run: python3 tools/test_fut_contract.py
|
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).
|
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"
|
BASE = "http://127.0.0.1:8099"
|
||||||
G = "/ut/game/fifa17"
|
G = "/ut/game/fifa17"
|
||||||
V2 = "/ut/v2/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 = []
|
_fail = []
|
||||||
_pass = 0
|
_pass = 0
|
||||||
@@ -30,6 +41,23 @@ def _get(path):
|
|||||||
return json.loads(raw) if raw else {}
|
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=""):
|
def check(name, cond, detail=""):
|
||||||
global _pass
|
global _pass
|
||||||
if cond:
|
if cond:
|
||||||
@@ -151,11 +179,26 @@ def test_squad_list_shape():
|
|||||||
check("userInfo coins uses funds not value", "value" not in coins, repr(coins))
|
check("userInfo coins uses funds not value", "value" not in coins, repr(coins))
|
||||||
for k in ("won", "draw", "loss"):
|
for k in ("won", "draw", "loss"):
|
||||||
check(f"userInfo.{k} is number", is_num(ui.get(k)), repr(ui.get(k)))
|
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
|
# REGRESSION GUARD, KEPT (not deleted -- the recon evidence does NOT show the
|
||||||
# 2026-08-03 create-club crash (identical field set, only this bool flipped, 4/4
|
# new rename endpoint makes this safe; it shows the opposite: the crash chain
|
||||||
# crash vs no crash). It may be absent, but it must never be true.
|
# runs entirely inside FIFA17.exe and never reaches our response).
|
||||||
check("clubNameChangeAllowed is not true", ui.get("clubNameChangeAllowed") is not True,
|
# clubNameChangeAllowed=true is the isolated root cause of the 2026-08-03
|
||||||
repr(ui.get("clubNameChangeAllowed")))
|
# 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
|
# squadList is OPTIONAL (FUT_USERINFO ladder) -- but if present it must be an
|
||||||
# object with a squad array, never a bare array.
|
# object with a squad array, never a bare array.
|
||||||
sl = ui.get("squadList")
|
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"))))
|
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():
|
def main():
|
||||||
tests = [test_credits, test_v2_store_gate, test_store_catalog, test_market_bodies,
|
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_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:
|
try:
|
||||||
_get(G + "/user/credits")
|
_get(G + "/user/credits")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -16,12 +16,19 @@ import datetime, json, os, re, sys, http.server
|
|||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
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_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_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)
|
ADDR = ("127.0.0.1", 8099)
|
||||||
LOG = "/tmp/utas_server.log"
|
LOG = "/tmp/utas_server.log"
|
||||||
SID = "OPENFUT-SID-0000000000000001"
|
SID = "OPENFUT-SID-0000000000000001"
|
||||||
PERSONA_ID = 33068179 # blaze LoginResponse SESS.BUID / PDTL.PID
|
# IDENTITY NOTE: there are no PERSONA_ID / PERSONA_NAME literals in this file any
|
||||||
PERSONA_NAME = "CAGE" # PDTL.DSNM
|
# 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.
|
# Flip to True once you want to exercise the create-club path instead.
|
||||||
NEW_USER = False
|
NEW_USER = False
|
||||||
|
|
||||||
@@ -38,9 +45,45 @@ def log(m):
|
|||||||
|
|
||||||
|
|
||||||
# ---- payloads -------------------------------------------------------------
|
# ---- payloads -------------------------------------------------------------
|
||||||
def auth_body():
|
def auth_body(h=None):
|
||||||
# Only "sid" is load-bearing (parser 0x1801a2880 -> ServerCall+0x1e8).
|
"""POST ut/auth.
|
||||||
# serverTime/lastOnlineTime are atoi'd at char offsets 0,5,8,11,14,17.
|
|
||||||
|
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()}
|
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
|
"""The squad the client should see: the persisted one (item refs re-embedded
|
||||||
from the club) or the seed ladder squad on first run.
|
from the club) or the seed ladder squad on first run.
|
||||||
|
|
||||||
personaId is FORCED to PERSONA_ID: SquadLoad compares it against the logged-in
|
personaId is FORCED to ACCOUNT.persona_id: SquadLoad compares it against the
|
||||||
persona at 0x18014659c and, on mismatch, takes the vtable[0x4f0] branch and
|
logged-in persona at 0x18014659c and, on mismatch, takes the vtable[0x4f0]
|
||||||
builds a throwaway squad instead of adopting ours.
|
branch and builds a throwaway squad instead of adopting ours.
|
||||||
"""
|
"""
|
||||||
saved = STORE.active_squad()
|
saved = STORE.active_squad()
|
||||||
# Overlay the saved squad on the seed envelope: FIFA's PUT body carries only
|
# Overlay the saved squad on the seed envelope: FIFA's PUT body carries only
|
||||||
@@ -59,7 +102,7 @@ def current_squad():
|
|||||||
sq = dict(SQUAD)
|
sq = dict(SQUAD)
|
||||||
if saved:
|
if saved:
|
||||||
sq.update(STORE.reconstruct_squad(saved))
|
sq.update(STORE.reconstruct_squad(saved))
|
||||||
sq["personaId"] = PERSONA_ID
|
sq["personaId"] = ACCOUNT.persona_id
|
||||||
sq.setdefault("id", 0)
|
sq.setdefault("id", 0)
|
||||||
return sq
|
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
|
# a naming flow whose UI model we never populate. Neither side-effecting member
|
||||||
# (squadList / unopenedPacks) was involved -- both were already omitted in the
|
# (squadList / unopenedPacks) was involved -- both were already omitted in the
|
||||||
# crashing run. Keep this false unless the rename flow is actually implemented.
|
# 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")
|
_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():
|
def user_info():
|
||||||
# Deserializer 0x18013EC10; every member optional (unknown key ids are
|
# Deserializer 0x18013EC10; every member optional (unknown key ids are
|
||||||
@@ -105,15 +207,23 @@ def user_info():
|
|||||||
p = STORE.profile()
|
p = STORE.profile()
|
||||||
rec = p.get("record", {})
|
rec = p.get("record", {})
|
||||||
info = {
|
info = {
|
||||||
"personaId": PERSONA_ID,
|
# Identity/club come from ACCOUNT, never from the save: the save is only a
|
||||||
"clubName": p.get("clubName", "OpenFUT"), "clubAbbr": p.get("clubAbbr", "OFC"),
|
# mirror (fut_store._sync_identity) and must not be able to disagree with
|
||||||
"established": p.get("established", "2026"),
|
# 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
|
# accountCreatedPlatformName(0x6), stored at userInfo+0x42: the only string
|
||||||
# 0x18013ec10 consumes that we used to omit (clubAbbr 0x8d / clubName 0x8e /
|
# 0x18013ec10 consumes that we used to omit (clubAbbr 0x8d / clubName 0x8e /
|
||||||
# established 0x110 were already covered). Sending it is correct, but note
|
# 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
|
# it was NOT the cause of the create-club crash -- that was still identical
|
||||||
# with this field present. Value matches auth's nucleusPersonaPlatform.
|
# with this field present. Value MUST match the auth body's
|
||||||
"accountCreatedPlatformName": "pc",
|
# nucleusPersonaPlatform, which is why it reads the locked wire constant.
|
||||||
|
"accountCreatedPlatformName": ACCOUNT.PLATFORM,
|
||||||
# The userInfo currency-element parser FUN_180138bd0 reads name(0x1d0),
|
# The userInfo currency-element parser FUN_180138bd0 reads name(0x1d0),
|
||||||
# funds(0x134), finalFunds(0x124), active(0xa) -- there is NO "value" key,
|
# 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
|
# 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.
|
# Everything below is optional for coins/record. `min` stops here.
|
||||||
if _UI != "min":
|
if _UI != "min":
|
||||||
info.update({
|
info.update({
|
||||||
# clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62. MUST stay False:
|
# clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62. FALSE unless
|
||||||
# True is the isolated root cause of the create-club crash (see _UI).
|
# FUT_CLUB_RENAME=1: True is the isolated root cause of the 2026-08-03
|
||||||
"clubNameChangeAllowed": False,
|
# 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,
|
"divisionOffline": 10, "divisionOnline": 10,
|
||||||
"purchased": False, # 0x262 -> bool at +0x68
|
"purchased": False, # 0x262 -> bool at +0x68
|
||||||
"feature": {"trade": True},
|
"feature": {"trade": True},
|
||||||
@@ -167,7 +279,14 @@ def user_get():
|
|||||||
|
|
||||||
# POST ut/game/<sku>/user (CreateUser, 0x18014CC60) recognises exactly:
|
# POST ut/game/<sku>/user (CreateUser, 0x18014CC60) recognises exactly:
|
||||||
# bonusPacks(0x5d) login(0x1a5) squad(0x2cd) starterPack(0x2e5) userData(0x36d).
|
# 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
|
# squad(0x2cd) goes to the SAME LoadActiveSquad parser as everywhere else, so
|
||||||
# serve the real schema-correct squad rather than {} -- a create-club response
|
# 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
|
# 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": []}
|
"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).
|
# GET ut/game/<sku>/settings (0x18013C6D0) recognises ONE key: configs (0xa2).
|
||||||
SETTINGS = {"configs": []}
|
SETTINGS = {"configs": []}
|
||||||
|
|
||||||
@@ -224,10 +508,17 @@ def massinfo():
|
|||||||
return {"userInfo": user_info()}
|
return {"userInfo": user_info()}
|
||||||
if _MI == "settings":
|
if _MI == "settings":
|
||||||
return {"settings": SETTINGS}
|
return {"settings": SETTINGS}
|
||||||
return {"userInfo": user_info(), # squadList -> roster singleton
|
body = {"userInfo": user_info(), # squadList -> roster singleton
|
||||||
"squad": current_squad(), # personaId == PERSONA_ID
|
"squad": current_squad(), # personaId == ACCOUNT.persona_id
|
||||||
"settings": SETTINGS,
|
"settings": SETTINGS,
|
||||||
"userData": {}}
|
"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) -------------------------------
|
# ---- FUT item-definition serving (wf_e41070d8) -------------------------------
|
||||||
# The card view-model 0x1800d7920 renders identity/rating/face from a RESOLVED
|
# 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]}
|
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):
|
def item_route(h):
|
||||||
# PUT ut/game/fifa17/item = FutMoveCard (move item to a pile, e.g. the reveal
|
# 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,
|
# screen's "keep/assign" -> {"itemData":[{"id":..,"pile":"club","swap":0,
|
||||||
@@ -300,11 +665,33 @@ def item_route(h):
|
|||||||
moved = STORE.move_items(req)
|
moved = STORE.move_items(req)
|
||||||
if moved:
|
if moved:
|
||||||
log(" ITEM: moved %d item(s) to pile(s)" % len(moved))
|
log(" ITEM: moved %d item(s) to pile(s)" % len(moved))
|
||||||
# Same shape as the proven-parseable GET itemData (no extra keys):
|
# ROOT CAUSE of the "can't send cards to club -> kicked to the main
|
||||||
# adding an unexpected key (e.g. chemistry) desynced the parser
|
# menu" logout, found 2026-08-04 by decompiling the deserializer:
|
||||||
# and made FIFA report "failed to send to club" then log out.
|
#
|
||||||
return 200, {"itemData": moved}
|
# FutMoveCard 0x180128600 HAS NO SKIP HANDLER. Every other FUT
|
||||||
return 200, {"itemData": []}
|
# 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)
|
return defs_route(h)
|
||||||
|
|
||||||
|
|
||||||
@@ -323,7 +710,9 @@ ROUTES = [
|
|||||||
# store §2.) Bare /store only -- purchasegroup/transaction matched above.
|
# store §2.) Bare /store only -- purchasegroup/transaction matched above.
|
||||||
(re.compile(r"/store(\?|$)"), lambda m, h: (200, {"result": "SUCCESS"})),
|
(re.compile(r"/store(\?|$)"), lambda m, h: (200, {"result": "SUCCESS"})),
|
||||||
(re.compile(r"/purchased"), lambda m, h: purchased_items(h)),
|
(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(r"^/ut/delete/auth"), lambda m, h: (200, {})),
|
||||||
(re.compile(G + r"/settings"), lambda m, h: (200, SETTINGS)),
|
(re.compile(G + r"/settings"), lambda m, h: (200, SETTINGS)),
|
||||||
# Device-trust ("phishing") flow. trusteddevice parser 0x18012a170 reads 4
|
# 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"/phishing/question"), lambda m, h: (200, {"question": 0, "answer": "", "attempts": 5})),
|
||||||
(re.compile(G + r"/user/credits"), lambda m, h: credits_route(h)),
|
(re.compile(G + r"/user/credits"), lambda m, h: credits_route(h)),
|
||||||
# ---- club/squad routes (2026-08-03: squad schema 0x18013d1f0 now reversed) ----
|
# ---- 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
|
# /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.
|
# 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)),
|
(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`
|
# 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
|
# -- 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
|
# 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.
|
# -- routed explicitly so it stops showing up as an unmapped hit in the log.
|
||||||
(re.compile(G + r"/match/reset"), lambda m, h: (200, {})),
|
(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, {})),
|
(re.compile(G + r"/hub"), lambda m, h: (200, {})),
|
||||||
# Populated massinfo (see massinfo() above): userInfo + squad + settings.
|
# Populated massinfo (see massinfo() above): userInfo + squad + settings.
|
||||||
(re.compile(G + r"/userMassInfo"), lambda m, h: (200, massinfo())),
|
(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 §)
|
# ---- transfer market / auction house (empty-but-valid; ENDPOINT_MAP market §)
|
||||||
# tradePile MUST precede /trade ("/tradePile" contains the "/trade" prefix).
|
# tradePile MUST precede /trade ("/tradePile" contains the "/trade" prefix).
|
||||||
(re.compile(G + r"/tradePile"), lambda m, h: tradepile_route(h)),
|
(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.
|
# /auctionhouse (was UNMAPPED -> {} => empty market). Serve the same listings.
|
||||||
(re.compile(G + r"/transfermarket"), lambda m, h: auctionhouse_route(h)),
|
(re.compile(G + r"/transfermarket"), lambda m, h: auctionhouse_route(h)),
|
||||||
(re.compile(G + r"/marketdata"), lambda m, h: (200, {"minPrice": 150, "maxPrice": 15000})),
|
(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/[^/]+/trade"), lambda m, h: delete_trade_route(h)),
|
||||||
(re.compile(r"/ut/delete/game/[^/]+/watchList"), lambda m, h: (200, {})),
|
(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):
|
def user_route(h):
|
||||||
if h.command == "POST":
|
if h.command == "POST":
|
||||||
return 200, user_post()
|
return 200, user_post(h)
|
||||||
if NEW_USER:
|
if NEW_USER:
|
||||||
# accepted, non-fatal: 0x18007AF20 treats 404 as the new-user branch
|
# accepted, non-fatal: 0x18007AF20 treats 404 as the new-user branch
|
||||||
return 404, {}
|
return 404, {}
|
||||||
@@ -393,6 +845,208 @@ def user_route(h):
|
|||||||
_SQUAD_LIST_MODE = os.environ.get("FUT_SQUAD_LIST", "off")
|
_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):
|
def squad_route(h):
|
||||||
# PUT = SaveCurrentSquad. FutSquadSaveServerResponse deser 0x180171a60 parses
|
# PUT = SaveCurrentSquad. FutSquadSaveServerResponse deser 0x180171a60 parses
|
||||||
# exactly ONE key, id(0x15c) -> reply {"id": <squadId>}, NOT an echo of the
|
# 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) ---------------------
|
# ---- STORE / PACKS (first-cut; iterate against the log) ---------------------
|
||||||
def store_catalog(h):
|
def _pack_body(p, idx):
|
||||||
packs = []
|
"""One entry of FutStoreGetPackTypes.purchase (element deser 0x18013af30).
|
||||||
idx = 1
|
|
||||||
for p in PACK_CATALOG:
|
THIS IS THE ORIGINAL, KNOWN-GOOD BODY -- restored 2026-08-04 after my "field
|
||||||
gold = p["gold"]
|
corrections" broke pack BUYING live. It renders pack tiles as "unknown" (see
|
||||||
mtx = max(1, p["price"] // 100)
|
FUT_STORE_FIELDS below) but packs are purchasable, which matters more.
|
||||||
packs.append({
|
|
||||||
"assetId": p["id"],
|
The corrections were derived from re-reading the deserializer and are probably
|
||||||
"id": p["id"],
|
right about what is PARSED -- but "parsed" is not "safe to change", and I
|
||||||
"packType": "GOLD" if gold else "BRONZE",
|
swapped a working body for an unverified one with no way to test it offline.
|
||||||
"description": p["name"],
|
They now live behind FUT_STORE_FIELDS=1.
|
||||||
"state": "active",
|
"""
|
||||||
"saleType": "promo",
|
gold = p["gold"]
|
||||||
"limitType": "NONE",
|
mtx = max(1, p["price"] // 100)
|
||||||
"quantity": 0,
|
body = {
|
||||||
"purchaseLimit": 0,
|
"assetId": p["id"],
|
||||||
"purchaseCount": 0,
|
"id": p["id"],
|
||||||
"isPremium": False,
|
"packType": "GOLD" if gold else "BRONZE",
|
||||||
"sortPriority": idx,
|
"description": p["name"],
|
||||||
"currencies": [{"name": "coins", "funds": p["price"], "finalFunds": p["price"]}],
|
"state": "active",
|
||||||
"extPrice": {"finalPrice": {"amount": mtx, "currency": "mtx"},
|
"saleType": "promo",
|
||||||
"originalPrice": {"amount": mtx, "currency": "mtx"}},
|
"limitType": "NONE",
|
||||||
"packContentInfo": {
|
"quantity": 0,
|
||||||
"bronzeQuantity": 0 if gold else p["count"],
|
"purchaseLimit": 0,
|
||||||
"silverQuantity": 0,
|
"purchaseCount": 0,
|
||||||
"goldQuantity": p["count"] if gold else 0,
|
"isPremium": False,
|
||||||
"rareQuantity": p["count"] if gold else 0,
|
"sortPriority": idx,
|
||||||
"itemQuantity": p["count"],
|
"currencies": [{"name": "coins", "funds": p["price"], "finalFunds": p["price"]}],
|
||||||
"unopened": False,
|
"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}
|
return 200, {"purchase": packs, "timestamp": 1596326400}
|
||||||
|
|
||||||
|
|
||||||
@@ -506,6 +1201,11 @@ def purchased_items(h):
|
|||||||
return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
|
return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
|
||||||
log(" STORE: POST /purchased opened pack %s -> %d items, coins=%d"
|
log(" STORE: POST /purchased opened pack %s -> %d items, coins=%d"
|
||||||
% (pack["name"], len(items), STORE.coins()))
|
% (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, {
|
return 200, {
|
||||||
"packId": pid,
|
"packId": pid,
|
||||||
"firstPartyStoreId": 0,
|
"firstPartyStoreId": 0,
|
||||||
@@ -659,7 +1359,7 @@ def tradepile_route(h):
|
|||||||
# The user's OWN sale pile: build a validated auction record per active listing
|
# 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).
|
# from the owned club item + its list prices. Freeze-safe (same record shape).
|
||||||
by_id = {it["id"]: it for it in STORE.items()}
|
by_id = {it["id"]: it for it in STORE.items()}
|
||||||
seller = STORE.profile().get("personaName", "OpenFUT")
|
seller = ACCOUNT.persona_name
|
||||||
recs = []
|
recs = []
|
||||||
for l in STORE.listings():
|
for l in STORE.listings():
|
||||||
it = by_id.get(l["itemId"])
|
it = by_id.get(l["itemId"])
|
||||||
|
|||||||
Reference in New Issue
Block a user