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
+214 -10
View File
@@ -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: