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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
funman300
2026-08-04 09:42:59 -07:00
parent 59934b4ef0
commit 5d5198f5d1
20 changed files with 3217 additions and 144 deletions
@@ -634,3 +634,66 @@ Fix: route `/squad/list` (before the generic `/squad`) to `squad_list_body()`:
Guarded by a contract check asserting `/squad/list` returns a `squad` array and is
*not* the active-squad object. This also makes the `FUT_SQUAD_LIST=merged` workaround
(S3) unnecessary — the two responses have distinct URLs, so no merged body is needed.
## 11. The online gate is POW/EASFC, not FUT (2026-08-03)
"EA FC servers are unreachable / PRESS Q TO RE-CONNECT" is **not** the FUT/UTAS
layer, not Blaze and not Origin/LSX — all three are healthy in our live logs while
the banner shows. It is the EASFC layer in **`powdll_Win64_retail.dll`**: 1.1 MB,
**unpacked and string-rich**, so unlike Denuvo-packed FIFA17.exe it can actually be
reversed. CardsDLL contains zero hits for "unreachable"/"RE-CONNECT"/"PRESS Q";
powdll has `TXT_EASFC_RECONNECT_PROMPT`, `TXT_EASFC_SERVER_ERROR`, and the FE events
`POWService::PowReconnect` / `TriggerPleaseConnectMsg` / `PowBlazeDisconnected`.
POW is a **third HTTP API** we have never served:
| role | default host | paths |
|---|---|---|
| api | `pas.gt.easfc.ea.com:8094` | `pow/auth`, `pow/healthcheck/system/all`, `pow/v2/activity`, `pow/nucleus/entitlements`, `pow/bank/user/account`, `pow/store/...`, … (58 templates extracted) |
| content | `content.lt.easfc.ea.com:8080` | `pow/imgAssets/...`, `pow/artAssets/...` |
Neither hostname is in `/etc/hosts` or the iptables DNAT, so **every POW call dies at
DNS** — which is exactly the banner's trigger.
### Reversed (Ghidra project `/tmp/pow/powproj`, PE base 0x180000000)
| addr | role |
|---|---|
| `FUN_18005a460` | POW config init. Reads `FIFA_POW_URL`, `FIFA_POW_CONTENT_SERVER_URL`, `POW_IS_ON` via `cfg->vtbl[0x30]` = `getString(key, default, &out)`**the same merged `_all` client-config store that already delivers `ROSTERUPDATE_URL`**. Picks `http://` vs `https://` (`PTR_s_http____18010aee0` / `...aee8`). |
| `FUN_18005cb40` | health-check / reconnect handler. Issues `pow/healthcheck/system/all` through request builder `FUN_18005e780`, then sets POW state at `POWmgr[0x6ac]`: **1 = connected, 3 = disconnected** (3 raises the prompt). Also the `PowReconnect` FE-event site. |
| `FUN_18005c970` | fires `POWService::PowBlazeDisconnected` |
| `FUN_1800a8590` | fires `POWService::TriggerPleaseConnectMsg` |
| `FUN_1800ad090` | references `TXT_EASFC_RECONNECT_PROMPT` (the banner) |
**Consequence: POW can be redirected with no root and no `/etc/hosts`** — just serve
`FIFA_POW_URL` from `blaze_responder_v3b.py`.
### Shipped (all OFF by default)
* `tools/pow_server.py` — POW/EASFC server on `:8094` (api) + `:8080` (content).
`POW_MODE=log` (default) answers everything `200 {}` / assets `404` and logs the
exact method+path+headers+body to `/tmp/pow_server.log`; `POW_MODE=serve` adds
first-draft bodies for auth/healthcheck/counts. It knows all 58 extracted path
templates and **flags any path outside that set**, so the capture also tells us
where the extraction was incomplete.
* `blaze_responder_v3b.py` — `OSDK_POW` keys, merged onto **every** CFID (same
reasoning as `FUT_RS4_*`: which section powdll reads is unproven). Empty unless
`FUT_POW=1`.
* `openfut-fut.sh``pow` added to SERVERS (inert while idle: it only binds ports;
nothing points at it until `FUT_POW=1`).
* `root_arm.sh pow` / `root_arm.sh unpow` — the `/etc/hosts` fallback, opt-in
because those entries persist across reboots.
### NOT done — the response schemas
Only the REQUEST side is mapped. No powdll response parser has been walked, so every
body `pow_server.py` returns is a placeholder. **The next step is a capture run:**
```
FUT_POW=1 ./openfut-fut.sh restart # then launch FIFA, enter FUT
tail -f /tmp/pow_server.log # what does POW actually ask for?
```
Whatever appears there turns the schemas from guesswork into reversing targets —
the same route that made the squad work tractable. Instant fallback: drop `FUT_POW`
and restart.