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