5d5198f5d1
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
345 lines
15 KiB
Python
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")
|