4c5cc3ab4b
The tile's big number is `clubPlayers` (atom 0x90) in the body of GET ut/%s/hub, a
route we have answered with {} for the life of the project.
The chain, re-derived independently by two agents (one via Ghidra, one via raw PE plus
capstone with no decompiler) and checked by two reviewers:
clubPlayers(0x90) --INT getter 0x1801c79d0--> clamp FUN_1800d7b30 (<=0 becomes 0)
-> R+0x3c, where R = FUT data-manager slot +0x1f8 (FUN_18011a810 is literally
`lea rax,[rcx+0x1fd70]; ret`)
-> read by FUN_1800b0250, published as TEXT0 of TILE_ID 0x210
-> captions FUT_GH_TOTAL_PLAYERS_0/_1 at 0x18020a0f8 / 0x18020a110
auctionCount(0x33) -> R+0x38 -> TEXT0 of TILE_ID 0x1b0, the TRANSFERS tile
FUN_180139610 (the hub body parser, 14855 chars, censused in full: 18 atoms, none
missed) is the ONLY writer of +0x3c anywhere in the image, one write, guarded by
`if (iVar6 != 0x90)`. This is not a candidate, it is the field.
I SPENT A DAY ON THE WRONG SURFACE AND WROTE THE WRONG CONCLUSION. REBUILD_RESEARCH
S19 declared the counter "not server-fixable" with a mechanism that was internally
correct and completely beside the point: the tile never read the club-stat store.
Two things reinforced the error and both are now fixed in the docs:
* ENDPOINT_MAP said this response "uses C++ reflection / vtable dispatch, NOT an
inline atom ladder -- no static field ladder to read" and marked it a GAP. False.
There is an inline ladder, one indirection away.
* The eight-row MY CLUB panel was assumed to be FUN_180043b90 case 1, which
publishes six keys, and I treated the six-versus-eight mismatch as a puzzle rather
than as evidence. It is a DIFFERENT provider, FUN_180094ce0, using a different
string family (FUT_MYCLUB_*), reading neither the mode tag nor any type id we were
sending. Two providers; we were reading the wrong one.
That is the third negative claim of this shape to fail today, after "this deserializer
has no skip handler" and "the factory does not wipe the stat map".
auctionCount is included as a FREE CONTROL: different field, different tile, so if MY
CLUB moves and TRANSFERS does not, delivery is fine and something is specific to +0x3c.
Default ON. Freeze risk is low by construction rather than by belief: a flat object of
two integers, both read with the INT getter, so there is no array, no nested object and
no type-desync surface. FUT_HUBDATA=0 restores {}.
Contract guard added, and verified to bite rather than merely pass:
default 439 checks, 0 failed
FUT_HUBDATA=0 435 checks, 3 FAILED (clubPlayers missing / not a number)
A regression here would otherwise be silent: still 200, still valid JSON, tile quietly
back to 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
538 lines
28 KiB
Python
538 lines
28 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.
|
|
|
|
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
|
|
|
|
BASE = os.environ.get("FUT_TEST_BASE", "http://127.0.0.1:8099")
|
|
G = "/ut/game/fifa17"
|
|
V2 = "/ut/v2/game/fifa17"
|
|
|
|
# IMPLEMENTATION-INDEPENDENT BY CONSTRUCTION.
|
|
# This suite talks to a server at a URL and imports NOTHING from the server's own
|
|
# code. That is what lets it verify ANY implementation of the reversed spec -- a
|
|
# future Rust openfut-core included -- without replaying the reverse engineering.
|
|
#
|
|
# It used to do `from fut_account import ACCOUNT` for the persona, which was a
|
|
# Python import against the Python implementation and quietly made the suite
|
|
# unable to certify a non-Python server. The expected persona now comes from the
|
|
# environment, defaulting to the value every layer has agreed on all along.
|
|
#
|
|
# The original reason for reading ACCOUNT still stands and is preserved: the suite
|
|
# and the server must not each hold their own copy of the constant, or the
|
|
# "identity is consistent" checks would only prove that two copies were copied
|
|
# correctly. Point FUT_TEST_PERSONA_ID at whatever the server under test is
|
|
# configured with; the default matches the shipped default.
|
|
PERSONA_ID = int(os.environ.get("FUT_TEST_PERSONA_ID", "33068179"))
|
|
|
|
_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 test_move_verdict_shape():
|
|
"""PUT /item must return per-item VERDICT records. This guards the fix for the
|
|
project's longest-lived bug.
|
|
|
|
FutMoveCard's deserializer does not parse an acknowledgement, it builds a vector
|
|
of verdict records, and the completion handler raises
|
|
EVENT_CARDS_MOVE_CARD_FAILURE when that vector is EMPTY or when a record's
|
|
success byte (record+0x0c) is not 1. For seven attempts this endpoint answered
|
|
{}, an echo of the moved cards, or a dreamSquads stub, and every one of them told
|
|
the client the move had failed, so the client killed the FUT session. It looked
|
|
like a client-state bug for weeks because the HTTP status was always 200.
|
|
|
|
That failure mode is silent at the transport layer, which is exactly why it needs
|
|
a contract test: nothing else in this suite would notice a regression to {}.
|
|
|
|
NON-MUTATING. It asks to move ids that cannot exist, so no item changes pile. The
|
|
verdicts therefore come back success=false, which is the honest answer and is not
|
|
what is being asserted here. What is asserted is the SHAPE and the RECORD COUNT,
|
|
because an empty vector fails the client just as hard as a wrong flag.
|
|
"""
|
|
ghosts = [{"id": 999000001, "pile": "club", "swap": 0, "tradeId": 0},
|
|
{"id": 999000002, "pile": "club", "swap": 0, "tradeId": 0}]
|
|
code, body = _req("PUT", G + "/item", {"itemData": ghosts})
|
|
check("PUT /item -> 200", code == 200, repr(code))
|
|
check("PUT /item body is an object", is_obj(body), repr(body))
|
|
recs = body.get("itemData") if is_obj(body) else None
|
|
check("PUT /item returns itemData array (NOT {} -- {} reads as move-failed)",
|
|
is_arr(recs), repr(body)[:120])
|
|
if not is_arr(recs):
|
|
return
|
|
check("PUT /item returns one record per requested item",
|
|
len(recs) == len(ghosts), "%d records for %d items" % (len(recs), len(ghosts)))
|
|
for i, r in enumerate(recs):
|
|
check("record[%d] is an object" % i, is_obj(r), repr(r))
|
|
if not is_obj(r):
|
|
continue
|
|
# id(0x15c) INT via 0x1801c79d0 -- a string here desyncs the reader
|
|
check("record[%d].id is a number" % i, is_num(r.get("id")), repr(r.get("id")))
|
|
# pile(0x226) STRING via 0x1801c7aa0 -> enum 0x180142650
|
|
check("record[%d].pile is a string" % i, is_str(r.get("pile")), repr(r.get("pile")))
|
|
# success(0x2fa) BOOL via 0x1801c7620 -> record+0x0c
|
|
check("record[%d].success is a bool" % i, isinstance(r.get("success"), bool),
|
|
repr(r.get("success")))
|
|
|
|
|
|
def test_hub_counters():
|
|
"""GET /hub must carry the tile counters as INTEGERS.
|
|
|
|
clubPlayers (atom 0x90) is the MY CLUB tile's big number: the hub body parser
|
|
FUN_180139610 is the ONLY writer of the field it lands in (R+0x3c, read by
|
|
FUN_1800b0250 as TEXT0 of TILE_ID 0x210). auctionCount (0x33) feeds the TRANSFERS
|
|
tile the same way via R+0x38.
|
|
|
|
This route answered {} for the life of the project, which is why the tile read 0,
|
|
and a day was spent looking at /club/stats instead. A regression to {} would be
|
|
silent: still 200, still valid JSON, tile quietly back to zero.
|
|
|
|
Both are read with the INT getter 0x1801c79d0, so a string here would desync.
|
|
"""
|
|
d = _get(G + "/hub")
|
|
check("hub body is an object", is_obj(d), repr(d))
|
|
if not is_obj(d):
|
|
return
|
|
check("hub.clubPlayers present (the MY CLUB tile counter)", "clubPlayers" in d, repr(sorted(d)))
|
|
check("hub.clubPlayers is a number, not a string", is_num(d.get("clubPlayers")),
|
|
repr(d.get("clubPlayers")))
|
|
check("hub.auctionCount is a number", is_num(d.get("auctionCount")),
|
|
repr(d.get("auctionCount")))
|
|
# the clamp FUN_1800d7b30 turns <=0 into 0, so a negative would silently read as 0
|
|
if is_num(d.get("clubPlayers")):
|
|
check("hub.clubPlayers is not negative (clamped to 0 by the client)",
|
|
d["clubPlayers"] >= 0, repr(d["clubPlayers"]))
|
|
|
|
|
|
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,
|
|
test_move_verdict_shape, test_hub_counters]
|
|
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())
|