#!/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. 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, 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" # 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 def _get(path): with urllib.request.urlopen(BASE + path, timeout=5) as r: raw = r.read() 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: _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, 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") 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 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_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: 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())