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:
@@ -14,7 +14,7 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
|
||||
import datetime, json, os, re, sys, http.server
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from fut_seed import CLUB, SQUAD, USER_LIST # forged starter squad (clean-room)
|
||||
from fut_seed import CLUB, SQUAD, USER_LIST, squad_summary # forged starter squad (clean-room)
|
||||
from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item # profile + packs
|
||||
|
||||
ADDR = ("127.0.0.1", 8099)
|
||||
@@ -44,52 +44,190 @@ def auth_body():
|
||||
return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}
|
||||
|
||||
|
||||
def current_squad():
|
||||
"""The squad the client should see: the persisted one (item refs re-embedded
|
||||
from the club) or the seed ladder squad on first run.
|
||||
|
||||
personaId is FORCED to PERSONA_ID: SquadLoad compares it against the logged-in
|
||||
persona at 0x18014659c and, on mismatch, takes the vtable[0x4f0] branch and
|
||||
builds a throwaway squad instead of adopting ours.
|
||||
"""
|
||||
saved = STORE.active_squad()
|
||||
# Overlay the saved squad on the seed envelope: FIFA's PUT body carries only
|
||||
# what it changed, so this keeps a valid custom(0xc6) 33-int string, kicktakers
|
||||
# and squadName present on reload instead of silently dropping them.
|
||||
sq = dict(SQUAD)
|
||||
if saved:
|
||||
sq.update(STORE.reconstruct_squad(saved))
|
||||
sq["personaId"] = PERSONA_ID
|
||||
sq.setdefault("id", 0)
|
||||
return sq
|
||||
|
||||
|
||||
def squad_list_body(squad=None):
|
||||
"""FutSquadListServerResponse body (deser 0x180172140 -> value parser
|
||||
0x180142260 -> element 0x180141fc0). ONE key, "squad"(0x2cd), holding an ARRAY
|
||||
of squad summaries. This is the same parser userInfo.squadList goes through."""
|
||||
return {"squad": [squad_summary(squad or current_squad())]}
|
||||
|
||||
|
||||
# Exactly TWO branches of the userInfo deser 0x18013ec10 have side effects OUTSIDE
|
||||
# the userInfo record -- they reach into the global model singleton FUN_18011a830:
|
||||
# squadList(0x2d4) -> vtbl[0x480] -> +0x30, fills the squad-ROSTER model
|
||||
# unopenedPacks(0x35e) -> vtbl[0x4e0](preOrderPacks + recoveredPacks)
|
||||
# Every other member just fills a scalar/string slot in the record. The 2026-08-03
|
||||
# crash is a NULL member inside a FIFA17.exe UI model, and userInfo had never
|
||||
# actually reached the client before (massinfo was {} and /user is not called at
|
||||
# boot) -- so these two newly-exercised branches are the prime suspects, and both
|
||||
# are optional for coins/record. Ladder, cheapest-first:
|
||||
# min = ONLY what coins/record need | safe = + the rest of the scalars
|
||||
# roster (default) = +squadList (MY SQUADS) | packs = +unopenedPacks | full = both
|
||||
# `roster` is live-confirmed working (hub + coins + record + MY SQUADS: 1). Only
|
||||
# `unopenedPacks` remains untested, hence `full` is not the default.
|
||||
#
|
||||
# ROOT CAUSE, ISOLATED LIVE 2026-08-03 -- clubNameChangeAllowed(0x8f) must be FALSE.
|
||||
# Two runs sent the IDENTICAL field set and differed only in that one bool:
|
||||
# true -> client shows the FUT club-name prompt, then dies confirming it
|
||||
# (ACCESS_VIOLATION reading 0x0 at FIFA17.exe+0x71b8651, 4/4 runs)
|
||||
# false -> no prompt at all, hub loads, coins + record render
|
||||
# Sending true advertises "a club-name change is available", pushing the client into
|
||||
# a naming flow whose UI model we never populate. Neither side-effecting member
|
||||
# (squadList / unopenedPacks) was involved -- both were already omitted in the
|
||||
# crashing run. Keep this false unless the rename flow is actually implemented.
|
||||
_UI = os.environ.get("FUT_USERINFO", "roster")
|
||||
|
||||
|
||||
def user_info():
|
||||
# Deserializer 0x18013EC10; every member optional (unknown key ids are
|
||||
# skipped via 0x180135FF0), so {} also parses.
|
||||
return {
|
||||
# Now that massinfo is populated, this record actually reaches the hub -- read
|
||||
# the live profile instead of the old hardcoded placeholders.
|
||||
p = STORE.profile()
|
||||
rec = p.get("record", {})
|
||||
info = {
|
||||
"personaId": PERSONA_ID,
|
||||
"clubName": "OpenFUT", "clubAbbr": "OFC", "established": "2026",
|
||||
"clubNameChangeAllowed": True,
|
||||
"currencies": [{"name": "coins", "value": 15000},
|
||||
{"name": "points", "value": 0}],
|
||||
"won": 0, "draw": 0, "loss": 0,
|
||||
"divisionOffline": 10, "divisionOnline": 10,
|
||||
"purchased": False,
|
||||
"feature": {"trade": True},
|
||||
"reliability": {"reliability": 100, "matchUnfinishedTime": 0},
|
||||
"unopenedPacks": {"preOrderPacks": 0, "recoveredPacks": 0},
|
||||
"bidTokens": {"count": 0, "updateTime": 0},
|
||||
"trophies": 0, "sessionCoinsBankBalance": 0,
|
||||
"actives": [], "squadList": [],
|
||||
"clubName": p.get("clubName", "OpenFUT"), "clubAbbr": p.get("clubAbbr", "OFC"),
|
||||
"established": p.get("established", "2026"),
|
||||
# accountCreatedPlatformName(0x6), stored at userInfo+0x42: the only string
|
||||
# 0x18013ec10 consumes that we used to omit (clubAbbr 0x8d / clubName 0x8e /
|
||||
# established 0x110 were already covered). Sending it is correct, but note
|
||||
# it was NOT the cause of the create-club crash -- that was still identical
|
||||
# with this field present. Value matches auth's nucleusPersonaPlatform.
|
||||
"accountCreatedPlatformName": "pc",
|
||||
# The userInfo currency-element parser FUN_180138bd0 reads name(0x1d0),
|
||||
# funds(0x134), finalFunds(0x124), active(0xa) -- there is NO "value" key,
|
||||
# so the old {"name","value"} pairs were parsed as 0 and the hub showed 0
|
||||
# coins. The caller then matches name against "coins"/"points" literally.
|
||||
"currencies": [
|
||||
{"name": "coins", "funds": STORE.coins(),
|
||||
"finalFunds": STORE.coins(), "active": True},
|
||||
{"name": "points", "funds": p.get("points", 0),
|
||||
"finalFunds": p.get("points", 0), "active": True},
|
||||
],
|
||||
# record: won(0x387) draw(0xe6) loss(0x1a6), all scalar slots in the record
|
||||
"won": rec.get("won", 0), "draw": rec.get("draw", 0), "loss": rec.get("loss", 0),
|
||||
}
|
||||
# Everything below is optional for coins/record. `min` stops here.
|
||||
if _UI != "min":
|
||||
info.update({
|
||||
# clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62. MUST stay False:
|
||||
# True is the isolated root cause of the create-club crash (see _UI).
|
||||
"clubNameChangeAllowed": False,
|
||||
"divisionOffline": 10, "divisionOnline": 10,
|
||||
"purchased": False, # 0x262 -> bool at +0x68
|
||||
"feature": {"trade": True},
|
||||
"reliability": {"reliability": 100, "matchUnfinishedTime": 0},
|
||||
"bidTokens": {"count": 0, "updateTime": 0},
|
||||
"trophies": 0, "sessionCoinsBankBalance": 0,
|
||||
# actives(0xb): array of <=5 item refs (item parser 0x18013fe00). The seed
|
||||
# ladder squad has none yet -> empty array (arrays never desync).
|
||||
"actives": (current_squad().get("actives") or [])[:5],
|
||||
})
|
||||
# ---- the two side-effecting members, off by default (see _UI above) --------
|
||||
if _UI in ("packs", "full"):
|
||||
# unopenedPacks(0x35e): after parsing preOrderPacks(0x24b)+recoveredPacks
|
||||
# (0x27b) the deser calls singleton->vtbl[0x4e0](preOrder + recovered).
|
||||
info["unopenedPacks"] = {"preOrderPacks": 0, "recoveredPacks": 0}
|
||||
if _UI in ("roster", "full"):
|
||||
# squadList(0x2d4) -> FUN_180142260 on singleton->vtbl[0x480]+0x30, i.e. it
|
||||
# fills the global squad-ROSTER model ("MY SQUADS" on the Squads screen).
|
||||
# Must be an OBJECT {"squad":[...]}; a bare [] is SKIP-safe but leaves the
|
||||
# roster empty. NOT required for the ACTIVE squad -- that comes from the
|
||||
# massinfo `squad` member, which is already proven working.
|
||||
info["squadList"] = squad_list_body()
|
||||
return info
|
||||
|
||||
|
||||
# GET ut/game/<sku>/user parser 0x180146970 does Parse + TWO NextToken calls
|
||||
# before deserializing -> the object MUST be wrapped in one member. The member
|
||||
# NAME is never compared, but the nesting level is required.
|
||||
USER_GET = {"userInfo": user_info()}
|
||||
def user_get():
|
||||
return {"userInfo": user_info()}
|
||||
|
||||
|
||||
# POST ut/game/<sku>/user (CreateUser, 0x18014CC60) recognises exactly:
|
||||
# bonusPacks(0x5d) login(0x1a5) squad(0x2cd) starterPack(0x2e5) userData(0x36d).
|
||||
USER_POST = {"login": True, "userData": user_info(),
|
||||
"squad": {}, "starterPack": {}, "bonusPacks": []}
|
||||
def user_post():
|
||||
# squad(0x2cd) goes to the SAME LoadActiveSquad parser as everywhere else, so
|
||||
# serve the real schema-correct squad rather than {} -- a create-club response
|
||||
# carrying an empty squad leaves the client with a 0-slot squad model, which is
|
||||
# precisely the state that makes AddPlayerToSquad no-op (see REBUILD_PLAN S9c).
|
||||
return {"login": True, "userData": user_info(),
|
||||
"squad": current_squad(), "starterPack": {}, "bonusPacks": []}
|
||||
|
||||
|
||||
# GET ut/game/<sku>/settings (0x18013C6D0) recognises ONE key: configs (0xa2).
|
||||
SETTINGS = {"configs": []}
|
||||
# GET ut/game/<sku>/userMassInfo (GetUserMassInfo, deser 0x180174630).
|
||||
# ANY content here (userInfo AND/OR squad) DESYNCS CardsDLL's massinfo parser ->
|
||||
# infinite tokenizer spin (busy-loop freeze at 0x1801c7f1a). Proven: {} reaches
|
||||
# the hub; {userInfo,...} and {...,squad,...} both freeze. The userInfo sub-deser
|
||||
# 0x18013ec10 mis-consumes some field in user_info(). So keep userMassInfo EMPTY
|
||||
# (hub-reaching) and deliver club/squad via their OWN endpoints (/user, /club,
|
||||
# /squad) whose parsers we know work. Select via env FUT_MASSINFO (empty|userinfo|full).
|
||||
_MI = os.environ.get("FUT_MASSINFO", "empty")
|
||||
if _MI == "full":
|
||||
MASSINFO = {"userInfo": user_info(), "squad": SQUAD,
|
||||
"settings": {"configs": []}, "userData": {}}
|
||||
elif _MI == "userinfo":
|
||||
MASSINFO = {"userInfo": user_info(), "settings": {"configs": []}, "userData": {}}
|
||||
else:
|
||||
MASSINFO = {} # proven hub-reaching
|
||||
|
||||
# GET ut/game/<sku>/userMassInfo (GetUserMassInfo, deser FUN_180174630).
|
||||
#
|
||||
# CORRECTED 2026-08-03 (supersedes the old "MUST BE {}" note). Full decompile of
|
||||
# 0x180174630 (/tmp/ghidra_fut/massinfo.txt): the body is a FLAT object whose
|
||||
# top-level keys dispatch to
|
||||
# userInfo(0x370) -> 0x18013ec10 squad(0x2cd) -> 0x18013d1f0
|
||||
# settings(0x2bf) -> 0x18013c6d0 userData(0x36d) -> 0x180142470
|
||||
# clubUser(0x91), errors(0x10c), loanPlayers(0x19a), loanPlayerClientData(0x199),
|
||||
# pileSizeClientData(0x227); everything else SKIP'd via 0x180135ff0.
|
||||
# There is NO "user" wrapper (the old note was wrong -- "user" only appears nested
|
||||
# inside clubUser). The prologue (2 x NextToken before the key loop) is identical
|
||||
# to 0x18014cc60, the proven-flat CreateUser parser.
|
||||
#
|
||||
# The historical freeze is now attributed to the SQUAD member: it was fed a squad
|
||||
# object built before the exact schema was known (0x18013d1f0 was only reversed on
|
||||
# 2026-08-03). Every field of user_info() type-checks against 0x18013ec10, and the
|
||||
# one structurally wrong field -- squadList as a bare array -- is SKIP-safe rather
|
||||
# than spin-inducing. So massinfo is now served POPULATED (like EA does), which is
|
||||
# what delivers the squad roster to the client at boot.
|
||||
#
|
||||
# LIVE STATUS 2026-08-03: `full` PARSES fine (no freeze -- the client processed it
|
||||
# and issued the next request), but the client then enters FUT club-creation and
|
||||
# dies confirming the club name: reproducible ACCESS_VIOLATION reading 0x0 at
|
||||
# FIFA17.exe+0x71b8651 (3/3 runs). That crash site is
|
||||
# mov rax,[r9+0x28] ; mov r9,[rax] <- r9->field_28 is NULL, unguarded
|
||||
# in FIFA17.exe's own UI code -- no CardsDLL frame on the stack and NO request is
|
||||
# sent when it happens, so it is not a response being rejected. Bisecting which
|
||||
# massinfo member triggers it, one relaunch per value:
|
||||
# BISECT RESULT: `squad` alone reaches the hub AND made the client issue
|
||||
# PUT /squad/0 (blocker solved) -- so the crash is in the `userInfo` member.
|
||||
# `full` is restored now that userInfo omits its two side-effecting members by
|
||||
# default (see _UI): that is what puts coins/record back on the hub.
|
||||
# INSTANT FALLBACK if the crash returns: FUT_MASSINFO=squad (known-good).
|
||||
_MI = os.environ.get("FUT_MASSINFO", "full")
|
||||
|
||||
|
||||
def massinfo():
|
||||
if _MI == "empty":
|
||||
return {} # old known-hub-reaching body
|
||||
if _MI == "squad":
|
||||
return {"squad": current_squad()}
|
||||
if _MI == "userinfo":
|
||||
return {"userInfo": user_info()}
|
||||
if _MI == "settings":
|
||||
return {"settings": SETTINGS}
|
||||
return {"userInfo": user_info(), # squadList -> roster singleton
|
||||
"squad": current_squad(), # personaId == PERSONA_ID
|
||||
"settings": SETTINGS,
|
||||
"userData": {}}
|
||||
|
||||
# ---- FUT item-definition serving (wf_e41070d8) -------------------------------
|
||||
# The card view-model 0x1800d7920 renders identity/rating/face from a RESOLVED
|
||||
@@ -145,12 +283,37 @@ def defs_route(h):
|
||||
return 200, {"itemData": [item_def(i) for i in ids]}
|
||||
|
||||
|
||||
def item_route(h):
|
||||
# PUT ut/game/fifa17/item = FutMoveCard (move item to a pile, e.g. the reveal
|
||||
# screen's "keep/assign" -> {"itemData":[{"id":..,"pile":"club","swap":0,
|
||||
# "tradeId":0}]}). Response echoes the FULL updated card-item(s) + chemistry
|
||||
# bool (ENDPOINT_MAP item rows 6/7/10/11; itemData via deser 0x18013fe00).
|
||||
# Returning [] here makes FIFA think the move failed -> kicks to main menu.
|
||||
# GET stays defs_route (ViewCards / ConsumablesSearch / loan lists).
|
||||
if h.command == "PUT":
|
||||
try:
|
||||
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
|
||||
except Exception:
|
||||
body = {}
|
||||
req = body.get("itemData")
|
||||
if isinstance(req, list):
|
||||
moved = STORE.move_items(req)
|
||||
if moved:
|
||||
log(" ITEM: moved %d item(s) to pile(s)" % len(moved))
|
||||
# Same shape as the proven-parseable GET itemData (no extra keys):
|
||||
# adding an unexpected key (e.g. chemistry) desynced the parser
|
||||
# and made FIFA report "failed to send to club" then log out.
|
||||
return 200, {"itemData": moved}
|
||||
return 200, {"itemData": []}
|
||||
return defs_route(h)
|
||||
|
||||
|
||||
G = r"/ut/game/[^/]+"
|
||||
ROUTES = [
|
||||
# ---- FUT item-definition endpoints (must precede generic /item, /user) ----
|
||||
(re.compile(G + r"/item/resource"), lambda m, h: defs_route(h)),
|
||||
(re.compile(G + r"/defid"), lambda m, h: defs_route(h)),
|
||||
(re.compile(G + r"/item(\?|$)"), lambda m, h: defs_route(h)),
|
||||
(re.compile(G + r"/item(\?|$)"), lambda m, h: item_route(h)),
|
||||
# ---- store / packs (match regardless of /ut/game vs /ut/v2/game prefix) ----
|
||||
(re.compile(r"/store/purchasegroup"), lambda m, h: store_catalog(h)),
|
||||
(re.compile(r"/store/transaction"), lambda m, h: store_buy(h)),
|
||||
@@ -170,18 +333,29 @@ ROUTES = [
|
||||
(re.compile(G + r"/phishing/validate"), lambda m, h: (200, {"token": "OPENFUT-TRUST-0000000000000000"})),
|
||||
(re.compile(G + r"/phishing/question"), lambda m, h: (200, {"question": 0, "answer": "", "attempts": 5})),
|
||||
(re.compile(G + r"/user/credits"), lambda m, h: credits_route(h)),
|
||||
# ---- club/squad routes reverted to known-good {} stubs (2026-08-01) ----
|
||||
# The forged squad in MASSINFO/SQUAD/CLUB HANGS CardsDLL's deserializer (hard
|
||||
# freeze at boot). Re-enable only after the exact shape is reversed. The forged
|
||||
# data still lives in fut_seed.py + MASSINFO/squad_route below (unrouted).
|
||||
# ---- club/squad routes (2026-08-03: squad schema 0x18013d1f0 now reversed) ----
|
||||
# /user/list + /user/accountinfo stay {} (nothing in them is load-bearing yet).
|
||||
# /user, /squad and /userMassInfo serve real data again -- the squad object
|
||||
# matches the verified schema, so the 2026-08-01 revert-to-{} no longer applies.
|
||||
(re.compile(G + r"/user/list"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/user/accountinfo"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/user$|" + G + r"/user\?"), lambda m, h: user_route(h)),
|
||||
# LIVE GROUND TRUTH 2026-08-03: FutSquadList has its OWN URL, `ut/%s/squad/list`
|
||||
# -- the request-table strings only showed ut/%s/squad, so the static conclusion
|
||||
# "there is NO separate squad-list URL" (REBUILD_PLAN S1b) was WRONG. This must
|
||||
# precede the generic /squad route, which was swallowing it and returning the
|
||||
# full active-squad object; the list parser 0x180142260 recognises ONLY
|
||||
# squad(0x2cd) and skipped every one of those keys -> "MY SQUADS: 0".
|
||||
(re.compile(G + r"/squad/list"), lambda m, h: (200, squad_list_body())),
|
||||
(re.compile(G + r"/squad"), lambda m, h: squad_route(h)),
|
||||
(re.compile(G + r"/match/keepalive"), lambda m, h: (204, None)),
|
||||
# PUT ut/%s/match/reset = FutResetMatch. Seen live at boot (2026-08-03) as an
|
||||
# UNMAPPED catch-all; it is a Tier-B ack (shared no-op deser), so {} is correct
|
||||
# -- routed explicitly so it stops showing up as an unmapped hit in the log.
|
||||
(re.compile(G + r"/match/reset"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/hub"), lambda m, h: (200, {})),
|
||||
# STEP 1 (wf_0bc80ab3): zero-resolve squad in MASSINFO; club stays {}.
|
||||
(re.compile(G + r"/userMassInfo"), lambda m, h: (200, MASSINFO)),
|
||||
# Populated massinfo (see massinfo() above): userInfo + squad + settings.
|
||||
(re.compile(G + r"/userMassInfo"), lambda m, h: (200, massinfo())),
|
||||
(re.compile(G + r"/season"), lambda m, h: (200, {})),
|
||||
# ---- transfer market / auction house (empty-but-valid; ENDPOINT_MAP market §)
|
||||
# tradePile MUST precede /trade ("/tradePile" contains the "/trade" prefix).
|
||||
@@ -201,70 +375,84 @@ ROUTES = [
|
||||
|
||||
def user_route(h):
|
||||
if h.command == "POST":
|
||||
return 200, USER_POST
|
||||
return 200, user_post()
|
||||
if NEW_USER:
|
||||
# accepted, non-fatal: 0x18007AF20 treats 404 as the new-user branch
|
||||
return 404, {}
|
||||
return 200, USER_GET
|
||||
return 200, user_get()
|
||||
|
||||
|
||||
# ut/%s/squad carries the WHOLE squad family (request table 0x18021dfc0): GET =
|
||||
# LoadActiveSquad AND GetSquadList/GetSquads, PUT = SaveCurrentSquad/SaveSquad.
|
||||
# Load-vs-List is not resolvable statically (one binding row each, 0x18027cb70 /
|
||||
# 0x18027be28) -- but the two parsers are mutually SKIP-tolerant: the squad-object
|
||||
# parser 0x18013d1f0 does not recognise "squad"(0x2cd), and the list parser
|
||||
# 0x180142260 recognises ONLY "squad". So a MERGED body satisfies both. Kept behind
|
||||
# FUT_SQUAD_LIST=merged for now (GET /squad is boot-critical; default keeps the
|
||||
# proven plain-squad body so a live test isolates one change at a time).
|
||||
_SQUAD_LIST_MODE = os.environ.get("FUT_SQUAD_LIST", "off")
|
||||
|
||||
|
||||
def squad_route(h):
|
||||
# GET = LoadActiveSquad, PUT = updateActiveSquad. Persist the squad the user
|
||||
# builds so it survives relaunches. Never return {} (empty body resets the 23
|
||||
# slots, 0x18013d1f0).
|
||||
# PUT = SaveCurrentSquad. FutSquadSaveServerResponse deser 0x180171a60 parses
|
||||
# exactly ONE key, id(0x15c) -> reply {"id": <squadId>}, NOT an echo of the
|
||||
# squad (the echo's extra keys were merely SKIP'd, but the id was only present
|
||||
# by luck of the client's own body).
|
||||
if h.command == "PUT":
|
||||
try:
|
||||
sq = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else None
|
||||
except Exception:
|
||||
sq = None
|
||||
if isinstance(sq, dict) and sq.get("players"):
|
||||
sq.setdefault("id", 0)
|
||||
STORE.save_squad(sq)
|
||||
return 200, sq
|
||||
saved = STORE.active_squad()
|
||||
return 200, (STORE.reconstruct_squad(saved) if saved else SQUAD)
|
||||
log(" SQUAD: saved squad id=%s (%d slots)" % (sq["id"], len(sq["players"])))
|
||||
return 200, {"id": sq["id"]}
|
||||
# Malformed/empty PUT: still answer with the id we hold, never {} -- the
|
||||
# save handler needs the key and an empty body reads as squadId 0.
|
||||
return 200, {"id": current_squad().get("id", 0)}
|
||||
|
||||
# GET = LoadActiveSquad: the full squad object, directly (no wrapper). Never
|
||||
# return {} -- an empty body resets the 23 slots (0x18013d1f0).
|
||||
sq = current_squad()
|
||||
if _SQUAD_LIST_MODE == "merged":
|
||||
sq = dict(sq, **squad_list_body(sq))
|
||||
return 200, sq
|
||||
|
||||
|
||||
# ---- STORE / PACKS (first-cut; iterate against the log) ---------------------
|
||||
def store_catalog(h):
|
||||
# GET store/purchasegroup/all. Root key MUST be "purchase" (atom 608, array);
|
||||
# pack identity is "id" (int16, NOT packId); price is a "currencies" array of
|
||||
# {name,funds,finalFunds}; display name is "description". (wf a245577b —
|
||||
# {purchaseGroups:...} + packId/price were all unknown atoms => empty => "not
|
||||
# available".) quantity:0 => unlimited.
|
||||
# Parse format is verified correct ("purchase" array, per-pack 0x18013af30).
|
||||
# The store still rejected minimal packs -> a pack must be COMPLETE to count as
|
||||
# valid: content info + BOTH a coins price (currencies) and a FIFA-Points price
|
||||
# (extPrice {finalPrice,originalPrice} -> {"mtx":N}).
|
||||
packs = []
|
||||
idx = 1
|
||||
for p in PACK_CATALOG:
|
||||
gold = p["gold"]
|
||||
mtx = max(1, p["price"] // 100)
|
||||
packs.append({
|
||||
# assetId (atom 0x23) is the REAL pack identity the deser 0x18013af30
|
||||
# reads (ENDPOINT_MAP store §). id/packType/quantity/saleType/isPremium
|
||||
# are all unknown atoms -> SKIP (harmless no-ops, kept for readability).
|
||||
"assetId": p["id"],
|
||||
"id": p["id"],
|
||||
"packType": "GOLD" if gold else "BRONZE",
|
||||
"description": p["name"],
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": False,
|
||||
"saleType": "PERMANENT",
|
||||
"sortPriority": p["id"] - 100,
|
||||
"sortPriority": idx,
|
||||
"currencies": [{"name": "coins", "funds": p["price"], "finalFunds": p["price"]}],
|
||||
# extPrice inner keys are amount(0x1b)/currency(0xc4), NOT mtx (skipped).
|
||||
"extPrice": {"finalPrice": {"amount": mtx, "currency": "fifapoints"},
|
||||
"originalPrice": {"amount": mtx, "currency": "fifapoints"}},
|
||||
"extPrice": {"finalPrice": {"amount": mtx, "currency": "mtx"},
|
||||
"originalPrice": {"amount": mtx, "currency": "mtx"}},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0 if gold else p["count"],
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": p["count"] if gold else 0,
|
||||
"rareQuantity": 1 if gold else 0,
|
||||
"rareQuantity": p["count"] if gold else 0,
|
||||
"itemQuantity": p["count"],
|
||||
"unopened": False,
|
||||
},
|
||||
})
|
||||
idx += 1
|
||||
return 200, {"purchase": packs, "timestamp": 1596326400}
|
||||
|
||||
|
||||
@@ -297,6 +485,34 @@ def store_buy(h):
|
||||
|
||||
|
||||
def purchased_items(h):
|
||||
# POST = FutPurchaseItemsServerResponse: the BUY itself. Live ground truth: FIFA
|
||||
# sends {"packId":1,"useCredits":1,"usePreOrder":0,"currency":"COINS"} then
|
||||
# immediately polls GET /purchased/items for the awarded cards. Open the pack
|
||||
# here (debit coins, award items). Response mirrors CardsDLL's serializer
|
||||
# (0x180126900): packId, firstPartyStoreId, groupName, productId,
|
||||
# purchasePackType -- unknown keys are skipped, so extra fields are harmless.
|
||||
# GET = FutGetPurchasedItemsServerResponse: the awarded items from the last buy.
|
||||
if h.command == "POST":
|
||||
try:
|
||||
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
|
||||
except Exception:
|
||||
body = {}
|
||||
pid = body.get("packId")
|
||||
pack = pack_by_id(pid) if isinstance(pid, int) else None
|
||||
if pack is None:
|
||||
return 200, {"itemData": STORE.last_pack()}
|
||||
items = STORE.open_pack(pack["price"], pack["count"], pack["gold"])
|
||||
if items is None:
|
||||
return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
|
||||
log(" STORE: POST /purchased opened pack %s -> %d items, coins=%d"
|
||||
% (pack["name"], len(items), STORE.coins()))
|
||||
return 200, {
|
||||
"packId": pid,
|
||||
"firstPartyStoreId": 0,
|
||||
"groupName": "fifa17",
|
||||
"productId": str(pack["id"]),
|
||||
"purchasePackType": "GOLD" if pack["gold"] else "BRONZE",
|
||||
}
|
||||
return 200, {"itemData": STORE.last_pack()}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user