59934b4ef0
The client now issues PUT /squad and the hub renders coins, record and the
squad roster. Three separate root causes, all verified live.
Squad blocker (the long-standing "client never sends PUT /squad"):
AddPlayerToSquad, GetSquads and SelectSquadById issue ZERO network requests
(pure local model reads/mutations, FutSquadServiceImpl vtable 0x180233ff0);
only SaveCurrentSquad writes, and it is unguarded. The client simply needed a
populated ACTIVE squad model, which arrives via the massinfo `squad` member.
No response of ours was ever being rejected.
userMassInfo is NOT required to be {}:
0x180174630 is a FLAT {userInfo, squad, settings, userData} body -- the old
"wrapper key is user" note was wrong, and the historical freeze was the
malformed squad member, not the envelope.
clubNameChangeAllowed must be false:
sending true advertises a club-rename flow whose UI model is never populated;
the client shows a naming prompt and dies confirming it (ACCESS_VIOLATION
reading 0x0 at FIFA17.exe+0x71b8651, 4/4 runs, no CardsDLL frame and no request
in flight). Isolated by a single-variable run; guarded by a contract check.
Endpoint/schema corrections found in live traffic, invisible to static analysis:
* GET ut/%s/squad/list is a real endpoint and must return {"squad":[...]},
not the active-squad object (the /list suffix is appended by the caller, so
it never appeared in the request table)
* PUT lands on ut/%s/squad/<id>, not a bare ut/%s/squad
* userInfo currencies are read as name/funds/finalFunds/active -- there is no
"value" key, so coins always rendered 0
* squad-list elements take STRING formation/squadType, not ints
* the CardsDLL script-API thunk<->name table was off by one (AddPlayerToSquad
is 0x18004aa70; 0x18004aff0 is GetPotentialChemistry_Club)
FUT_MASSINFO / FUT_USERINFO ladders keep every step of the bisect reproducible.
Contract suite 311 -> 358 checks. Tooling added: PyGhidra harness (Ghidra's
Java/OSGi script path is broken on this box), minidump reader, live code grabber.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
249 lines
13 KiB
Python
249 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Contract / freeze-safety regression tests for the FIFA 17 FUT backend.
|
|
|
|
Hits the LIVE utas_server (default http://127.0.0.1:8099) and asserts each
|
|
response matches the shape reversed from CardsDLL (see docs/ENDPOINT_MAP.md).
|
|
The point is freeze-safety: FIFA's SAX deserializers hard-freeze (busy-loop at
|
|
0x1801c7f1a) if a field that must be an array/object arrives as a scalar. These
|
|
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
|
|
Exit 0 = all pass. No pytest dependency (stdlib only).
|
|
"""
|
|
import json, sys, urllib.request
|
|
|
|
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
|
|
|
|
_fail = []
|
|
_pass = 0
|
|
|
|
|
|
def _get(path):
|
|
with urllib.request.urlopen(BASE + path, timeout=5) as r:
|
|
raw = r.read()
|
|
return json.loads(raw) if raw else {}
|
|
|
|
|
|
def check(name, cond, detail=""):
|
|
global _pass
|
|
if cond:
|
|
_pass += 1
|
|
else:
|
|
_fail.append(f"{name}: {detail}")
|
|
|
|
|
|
def is_arr(x): return isinstance(x, list)
|
|
def is_obj(x): return isinstance(x, dict)
|
|
def is_num(x): return isinstance(x, (int, float)) and not isinstance(x, bool)
|
|
def is_str(x): return isinstance(x, str)
|
|
|
|
|
|
def test_credits():
|
|
d = _get(G + "/user/credits")
|
|
# coin counter binds to currencies[name==coins].funds (deser 0x180122c50), NOT "credits"
|
|
check("credits.currencies is array", is_arr(d.get("currencies")), repr(d.get("currencies")))
|
|
coins = next((c for c in d.get("currencies", []) if c.get("name") == "coins"), None)
|
|
check("credits has coins currency", coins is not None)
|
|
if coins:
|
|
check("coins.funds is number", is_num(coins.get("funds")), repr(coins.get("funds")))
|
|
|
|
|
|
def test_v2_store_gate():
|
|
d = _get(V2 + "/store")
|
|
# FutStorePackQuantities eligibility gate (deser 0x1801758c0): result must be SUCCESS
|
|
check("v2/store result == SUCCESS", d.get("result") == "SUCCESS", repr(d))
|
|
|
|
|
|
def test_store_catalog():
|
|
d = _get(G + "/store/purchasegroup/all")
|
|
# FutStoreGetPackTypes (deser 0x1801234e0): root key "purchase" MUST be an array
|
|
check("catalog.purchase is array", is_arr(d.get("purchase")), repr(type(d.get("purchase"))))
|
|
for p in d.get("purchase", []):
|
|
check("pack has assetId (real identity)", "assetId" in p, repr(p.get("assetId")))
|
|
check("pack.currencies is array (coin price)", is_arr(p.get("currencies")))
|
|
check("pack.packContentInfo is object", is_obj(p.get("packContentInfo")))
|
|
check("pack.extPrice is object", is_obj(p.get("extPrice")))
|
|
ep = p.get("extPrice", {})
|
|
check("extPrice.finalPrice is object", is_obj(ep.get("finalPrice")))
|
|
|
|
|
|
def test_market_bodies():
|
|
# Shared IS-list body (deser 0x18013e7f0): auctionInfo MUST be array, credits number
|
|
for path in [G + "/auctionhouse?type=player&start=0&num=21",
|
|
G + "/tradePile", G + "/watchList", G + "/trade/123"]:
|
|
d = _get(path)
|
|
check(f"{path} auctionInfo is array", is_arr(d.get("auctionInfo")), repr(d.get("auctionInfo")))
|
|
check(f"{path} credits is number", is_num(d.get("credits")), repr(d.get("credits")))
|
|
dup = _get(G + "/auctionhouse?type=player").get("duplicateItemIdList")
|
|
check("auctionhouse duplicateItemIdList is array", is_arr(dup), repr(dup))
|
|
md = _get(G + "/marketdata?defId=1")
|
|
check("marketdata minPrice is number", is_num(md.get("minPrice")))
|
|
check("marketdata maxPrice is number", is_num(md.get("maxPrice")))
|
|
|
|
|
|
def test_auction_record_shape():
|
|
# Every populated auction record MUST match the reversed schema (deser
|
|
# 0x18013e410) field-for-field, or the market screen freezes. This proves the
|
|
# sample listings are freeze-safe OFFLINE, before the game ever parses them.
|
|
d = _get(G + "/auctionhouse?type=player&start=0&num=21")
|
|
recs = d.get("auctionInfo", [])
|
|
check("auctionhouse returns >=1 listing (or FUT_MARKET=empty)", is_arr(recs))
|
|
numeric = ["tradeId", "buyNowPrice", "startingBid", "currentBid", "expires",
|
|
"sellerEstablished", "coinsProcessed"]
|
|
strings = ["tradeState", "bidState", "sellerName"]
|
|
for r in recs:
|
|
check("record.itemData is object", is_obj(r.get("itemData")), repr(type(r.get("itemData"))))
|
|
check("record.watched is bool", isinstance(r.get("watched"), bool), repr(r.get("watched")))
|
|
for k in numeric:
|
|
check(f"record.{k} is number", is_num(r.get(k)), repr(r.get(k)))
|
|
for k in strings:
|
|
check(f"record.{k} is string", is_str(r.get(k)), repr(r.get(k)))
|
|
# itemData must itself be a valid card object (reuses club/squad parser)
|
|
it = r.get("itemData", {})
|
|
check("record.itemData.attributeList is array", is_arr(it.get("attributeList")))
|
|
check("record.itemData.resourceId is number", is_num(it.get("resourceId")))
|
|
|
|
|
|
def test_squad_boot():
|
|
# LoadActiveSquad (deser 0x18013d1f0): players MUST be array; empty body would reset
|
|
# the 23 slots. formation is a string. This is the boot-critical path.
|
|
d = _get(G + "/squad/0")
|
|
check("squad.players is array", is_arr(d.get("players")), repr(type(d.get("players"))))
|
|
check("squad.formation is string", is_str(d.get("formation")), repr(d.get("formation")))
|
|
for pl in d.get("players", []):
|
|
# Empty bench/reserve slots legitimately carry itemData=null (proven-safe,
|
|
# matches the working in-game squad). Only a PRESENT itemData must be an
|
|
# object -- a scalar there would desync the reader.
|
|
it = pl.get("itemData")
|
|
check("squad slot itemData is object-or-null", it is None or is_obj(it), repr(it))
|
|
|
|
|
|
def test_squad_list_shape():
|
|
# userInfo.squadList (atom 0x2d4) goes through FUN_180142260 -- the same parser as
|
|
# the FutSquadList response -- so it must be an OBJECT with a "squad" ARRAY, never
|
|
# a bare array. Each element (0x180141fc0): rating/chemistry/id INT,
|
|
# formation/squadName/squadType STRING (string getter 0x1801c7aa0 + enum conv).
|
|
d = _get(G + "/user")
|
|
ui = d.get("userInfo", {})
|
|
check("user.userInfo is object", is_obj(ui), repr(type(ui)))
|
|
# Every STRING atom 0x18013ec10 consumes must be present and non-empty: a null
|
|
# string pointer in this record is what the 2026-08-03 create-club crash read.
|
|
for k in ("clubName", "clubAbbr", "established", "accountCreatedPlatformName"):
|
|
check(f"userInfo.{k} is non-empty string", is_str(ui.get(k)) and ui.get(k), repr(ui.get(k)))
|
|
# actives is optional (omitted by FUT_USERINFO=min); when present it must be an
|
|
# array of at most 5 item refs (0x18013ec10 stops storing past index 4).
|
|
act = ui.get("actives")
|
|
check("userInfo.actives absent or array", act is None or is_arr(act), repr(act))
|
|
check("userInfo.actives <= 5", len(act or []) <= 5)
|
|
# coins/record are what the hub renders: currencies elements are read by
|
|
# FUN_180138bd0 as name/funds/finalFunds/active -- "value" is NOT a key it knows.
|
|
coins = next((c for c in ui.get("currencies", []) if c.get("name") == "coins"), None)
|
|
check("userInfo has coins currency", coins is not None, repr(ui.get("currencies")))
|
|
if coins:
|
|
check("userInfo coins.funds is number", is_num(coins.get("funds")), repr(coins))
|
|
check("userInfo coins.finalFunds is number", is_num(coins.get("finalFunds")), repr(coins))
|
|
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")))
|
|
# 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")
|
|
check("userInfo.squadList absent or object", sl is None or is_obj(sl), repr(sl))
|
|
if is_obj(sl):
|
|
check("squadList.squad is array", is_arr(sl.get("squad")), repr(sl.get("squad")))
|
|
for e in sl.get("squad", []):
|
|
check("squadList elem is object", is_obj(e), repr(e))
|
|
if not is_obj(e):
|
|
continue
|
|
for k in ("rating", "chemistry", "id"):
|
|
check(f"squadList.{k} is number", is_num(e.get(k)), repr(e.get(k)))
|
|
for k in ("formation", "squadName", "squadType"):
|
|
check(f"squadList.{k} is string", is_str(e.get(k)), repr(e.get(k)))
|
|
|
|
|
|
def test_squad_list_endpoint():
|
|
# GET ut/%s/squad/list is the real FutSquadList URL (live-observed 2026-08-03).
|
|
# Its parser 0x180142260 recognises ONLY squad(0x2cd), so the body MUST be
|
|
# {"squad":[...]}; returning the active-squad object here yields "MY SQUADS: 0".
|
|
d = _get(G + "/squad/list")
|
|
check("squad/list is object", is_obj(d), repr(type(d)))
|
|
check("squad/list has squad array", is_arr(d.get("squad")), repr(d)[:120])
|
|
check("squad/list is NOT the active-squad object", "players" not in d, repr(list(d)))
|
|
for e in d.get("squad", []):
|
|
for k in ("rating", "chemistry", "id"):
|
|
check(f"squad/list elem {k} is number", is_num(e.get(k)), repr(e.get(k)))
|
|
for k in ("formation", "squadName", "squadType"):
|
|
check(f"squad/list elem {k} is string", is_str(e.get(k)), repr(e.get(k)))
|
|
|
|
|
|
def test_massinfo_shape():
|
|
# GetUserMassInfo (deser 0x180174630) is a FLAT object -- no "user" wrapper.
|
|
# Populated as of 2026-08-03 (see FUT_RESPONSE_REBUILD_PLAN.md S7): userInfo,
|
|
# squad, settings, userData. Every member must keep its reversed type or the
|
|
# SAX reader desyncs -> busy-loop freeze at 0x1801c7f1a.
|
|
d = _get(G + "/userMassInfo")
|
|
check("massinfo is object", is_obj(d), repr(type(d)))
|
|
check("massinfo has no 'user' wrapper", "user" not in d, repr(list(d)))
|
|
if not d:
|
|
return # FUT_MASSINFO=empty bisect mode
|
|
for k in ("userInfo", "squad", "settings", "userData"):
|
|
if k in d:
|
|
check(f"massinfo.{k} is object", is_obj(d[k]), repr(type(d.get(k))))
|
|
sq = d.get("squad")
|
|
if is_obj(sq):
|
|
# squad(0x2cd) -> LoadActiveSquad parser 0x18013d1f0, same schema as GET /squad
|
|
check("massinfo.squad.players is array", is_arr(sq.get("players")), repr(type(sq.get("players"))))
|
|
check("massinfo.squad.formation is string", is_str(sq.get("formation")), repr(sq.get("formation")))
|
|
check("massinfo.squad.squadType is string", is_str(sq.get("squadType")), repr(sq.get("squadType")))
|
|
check("massinfo.squad.custom is string", is_str(sq.get("custom")), repr(type(sq.get("custom"))))
|
|
check("massinfo.squad.actives is array", is_arr(sq.get("actives")), repr(type(sq.get("actives"))))
|
|
check("massinfo.squad.manager is array", is_arr(sq.get("manager")), repr(type(sq.get("manager"))))
|
|
check("massinfo.squad.kicktakers is array", is_arr(sq.get("kicktakers")), repr(type(sq.get("kicktakers"))))
|
|
# personaId MUST equal the logged-in persona (0x18014659c) or SquadLoad
|
|
# discards our squad and builds a throwaway one.
|
|
check("massinfo.squad.personaId == PERSONA_ID", sq.get("personaId") == PERSONA_ID, repr(sq.get("personaId")))
|
|
if is_obj(d.get("settings")):
|
|
check("massinfo.settings.configs is array", is_arr(d["settings"].get("configs")),
|
|
repr(type(d["settings"].get("configs"))))
|
|
|
|
|
|
def test_club_items():
|
|
# /club serves {"itemData":[...]} (SKIP'd by GetClubInfo, but must stay array-safe)
|
|
d = _get(G + "/club?type=player&count=5")
|
|
check("club.itemData is array", is_arr(d.get("itemData")), repr(type(d.get("itemData"))))
|
|
|
|
|
|
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]
|
|
try:
|
|
_get(G + "/user/credits")
|
|
except Exception as e:
|
|
print(f"SERVER NOT REACHABLE at {BASE}: {e}\nStart it: python3 tools/utas_server.py")
|
|
return 2
|
|
for t in tests:
|
|
try:
|
|
t()
|
|
except Exception as e:
|
|
_fail.append(f"{t.__name__} raised {type(e).__name__}: {e}")
|
|
print(f"\n{_pass} checks passed, {len(_fail)} failed")
|
|
for f in _fail:
|
|
print(" FAIL:", f)
|
|
return 0 if not _fail else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|