fifa17-recon: FUT squad blocker solved + userInfo delivered

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
This commit is contained in:
funman300
2026-08-03 20:47:16 -07:00
parent 6270c37208
commit 59934b4ef0
16 changed files with 1811 additions and 94 deletions
+94 -5
View File
@@ -18,6 +18,7 @@ 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
@@ -123,11 +124,98 @@ def test_squad_boot():
check("squad slot itemData is object-or-null", it is None or is_obj(it), repr(it))
def test_massinfo_empty():
# GetUserMassInfo (deser 0x180174630) MUST stay {} -- any populated userInfo/squad
# desyncs the parser -> freeze (CARD_SYSTEM.md). Guard against accidental population.
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("userMassInfo is empty {}", d == {}, repr(d))
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():
@@ -138,7 +226,8 @@ def test_club_items():
def main():
tests = [test_credits, test_v2_store_gate, test_store_catalog, test_market_bodies,
test_auction_record_shape, test_squad_boot, test_massinfo_empty, test_club_items]
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: