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
+134 -34
View File
@@ -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)