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