Files
OpenFUT/fifa17-recon/tools/pow_server.py
T
funman300 5d5198f5d1 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
2026-08-04 09:42:59 -07:00

345 lines
15 KiB
Python

#!/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")