fifa17-recon: match rewards, POW online layer, account backend, quick sell

Second session. FUT core loop, the EASFC/POW online layer, a central account
backend, and a lot of corrections. Everything risky is behind an env flag with
the default set to whatever was live-proven.

WORKING END TO END (live-verified this session):
  * match loop -- POST/PUT/POST/DELETE ut/%s/match, rewards via FutDestroyMatch
    (0x180121b60). Play a match, get coins, W/D/L updates.
  * packs -- buy, cards land in the club, session survives (FUT_PACK_AUTOCLUB=1)
  * quick sell -- POST ut/delete/%s/item was UNMAPPED and paid NOTHING; six cards
    were destroyed for 0 coins. Now credits discardValue.
  * POW/EASFC online -- the "EA FC servers unreachable" banner is powdll's layer,
    a THIRD http api on :8094 nobody had served. Redirect needs no root: powdll
    FUN_18005a460 reads FIFA_POW_URL from the same client-config store as
    ROSTERUPDATE_URL. FUT_POW=1.
  * account backend -- fut_account.py replaces 7 hardcoded copies of the persona
    across 5 files; club/persona/online-profile editable via CLI.

CORRECTIONS TO ENDPOINT_MAP (all re-extracted from the deserializers):
  * FutStoreGetPackTypes: id/packType/isPremium/quantity/saleType/purchaseLimit/
    purchaseCount are NOT skipped no-ops -- all are parsed. extPrice inner objects
    take externalPriceId(0x11a), not amount/currency.
  * FutMoveCard 0x180128600 has NO skip handler (FUN_180135ff0 appears zero times,
    unique among FUT deserializers) and parses only itemData -> dreamSquads.
  * class -> deserializer resolution: the name literal is preceded by a 4-BYTE
    HEADER and the factory LEA points at the header, so look up name_addr - 4.
    Six attempts failed on this; now ghidra_env.class_deser(). Unlocked 11 SBC/
    Draft schemas.
  * live-only endpoints the request table never lists: ut/%s/squad/list,
    ut/%s/user/club, ut/%s/club/stats/*, ut/%s/clientdata/<key>. The template
    table is a floor, not a ceiling -- the log is the only ground truth.
  * 163 RS4 call names exist; we served 17. All now served.

FIXED: club/stats/* was answering with the entire 28-item club inventory on every
poll (it fell through to the generic /club route).

UNSOLVED: the pack reveal's "Send to Club" (PUT ut/%s/item) kills the FUT session
whatever we answer -- {} included -- while its sibling quick-sell endpoint accepts
a bare {}. Seven hypotheses eliminated by live test, documented in
REBUILD_RESEARCH.md S14c so none get re-walked. FUT_PACK_AUTOCLUB routes around it.
Also unfixed: store tiles render "unknown" (displayGroup is parsed RECURSIVELY by
the same element parser; sending it FROZE the store, so FUT_STORE_GROUPS=1 is
default off).

Tests: test_fut_contract.py 380 (live, read-only) + test_match_rewards.py 51 (pure).

Note: fut_store.py carries some pre-existing uncommitted changes from before this
session (pack catalogue ids, pending-pile behaviour) that could not be separated
from this session's additions in the same file.

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-04 09:42:59 -07:00
parent 59934b4ef0
commit 5d5198f5d1
20 changed files with 3217 additions and 144 deletions
+764 -64
View File
@@ -16,12 +16,19 @@ 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, squad_summary # forged starter squad (clean-room)
from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item # profile + packs
from fut_account import ACCOUNT, validate_club # identity + club, single source
ADDR = ("127.0.0.1", 8099)
LOG = "/tmp/utas_server.log"
SID = "OPENFUT-SID-0000000000000001"
PERSONA_ID = 33068179 # blaze LoginResponse SESS.BUID / PDTL.PID
PERSONA_NAME = "CAGE" # PDTL.DSNM
# IDENTITY NOTE: there are no PERSONA_ID / PERSONA_NAME literals in this file any
# more. They lived here, in fut_store.py, fut_seed.py, blaze_responder_v3b.py and
# lsx_responder_v2.py -- five files, seven copies of the same two values. The stack
# only works while Blaze SESS.PDTL, LSX GetProfileResponse and the UTAS
# userInfo/squad bodies all assert the SAME persona, so every one of them now reads
# fut_account.ACCOUNT. Read ACCOUNT.persona_id LIVE at call time (never snapshot it
# into a module constant) -- POST /ut/auth can adopt a different persona from the
# client's own body at runtime, and a snapshot would silently keep the old value.
# Flip to True once you want to exercise the create-club path instead.
NEW_USER = False
@@ -38,9 +45,45 @@ def log(m):
# ---- payloads -------------------------------------------------------------
def auth_body():
# Only "sid" is load-bearing (parser 0x1801a2880 -> ServerCall+0x1e8).
# serverTime/lastOnlineTime are atoi'd at char offsets 0,5,8,11,14,17.
def auth_body(h=None):
"""POST ut/auth.
RESPONSE: only "sid" is load-bearing (parser 0x1801a2880 -> ServerCall+0x1e8).
serverTime/lastOnlineTime are atoi'd at char offsets 0,5,8,11,14,17.
REQUEST: the client TELLS us who it is and we used to throw that away. The
body is built by CardsDLL FUN_180125900 and was live-logged byte-identical on
three separate runs:
{"sku":"FFA17PCC","nucleusPersonaPlatform":"pc","nuc":33068179,
"nucleusPersonaId":33068179,"nucleusPersonaDisplayName":"CAGE",
"locale":"en-US","regionCode":"US",...}
Adopting it makes the squad personaId comparison at 0x18014659c correct BY
CONSTRUCTION instead of by matching literals: the squad parser 0x18013d1f0
stores personaId (atom 0x21b) at squad+0x38 and compares it against
FUN_18011a830()->vtbl[0x908]; on mismatch it silently builds a THROWAWAY squad
rather than erroring, so a drifted id looks like "my squad reset itself".
RECONCILIATION RULE: the wire is truth, fut_account.json is a cache. Adopt and
WARN, never refuse -- a mismatch is the NORMAL state on the first boot after a
rename. FUT_ADOPT_AUTH=0 disables adoption (documented escape hatch).
"""
if h is not None:
try:
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else None
except Exception:
body = None
if isinstance(body, dict):
before = (ACCOUNT.persona_id, ACCOUNT.persona_name)
try:
if ACCOUNT.adopt_from_auth(body):
log(" AUTH: adopted persona %s/%r (was %s/%r)"
% (ACCOUNT.persona_id, ACCOUNT.persona_name, before[0], before[1]))
# Keep the game save's identity mirror in step with ACCOUNT so
# tradepile/club readers cannot lag a session behind.
STORE.refresh_identity()
except Exception as e: # adoption must never break auth
log(" AUTH: adopt failed (%s: %s) -- keeping %s/%r"
% (type(e).__name__, e, before[0], before[1]))
return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}
@@ -48,9 +91,9 @@ 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.
personaId is FORCED to ACCOUNT.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
@@ -59,7 +102,7 @@ def current_squad():
sq = dict(SQUAD)
if saved:
sq.update(STORE.reconstruct_squad(saved))
sq["personaId"] = PERSONA_ID
sq["personaId"] = ACCOUNT.persona_id
sq.setdefault("id", 0)
return sq
@@ -94,8 +137,67 @@ def squad_list_body(squad=None):
# 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.
#
# 2026-08-03, LATER: the rename endpoint IS implemented now (club_rename_route
# below) and it is still NOT enough to make this bool safe -- see FUT_CLUB_RENAME.
_UI = os.environ.get("FUT_USERINFO", "roster")
# ---- FUT_CLUB_RENAME: the in-game rename experiment (DEFAULT OFF) -----------
# clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62.
#
# DEFAULT IS FALSE AND THAT IS THE PROVEN-GOOD BEHAVIOUR. Setting FUT_CLUB_RENAME=1
# is a ONE-SHOT EXPERIMENT, not a feature, and the expected outcome is still the
# 2026-08-03 crash. Do NOT read "we now serve a correct rename endpoint" as "the
# flag is safe": nothing the server sends is consumed anywhere in the crash path.
#
# The traced chain, all of it CLIENT-side after the bool:
# userInfo+0x62 -> FUN_18001a7a0: if rec[0x62]==1 { target="changeClubName";
# state=0x3f } else { target=NULL; state=2 }
# state 0x3f -> FIFA17.exe front-end flow manager -> exe-side naming screen
# on confirm -> FUT-manager vtbl+0xa70 (name validation / profanity, component
# GUID 0xed84b11, fetched by FUN_180009c80)
# ONLY THEN -> CardsDLL builds the request (FUN_1800824b0) and sends it via
# vtbl+0x5d0 <-- the first point our endpoint could possibly
# matter, and the crash happens BEFORE it.
# That matches the observed signature exactly: ACCESS_VIOLATION reading 0x0 at
# FIFA17.exe+0x71b8651, 4/4 runs, no CardsDLL frame on the stack, no HTTP request
# in flight. The crash site itself could NOT be identified statically -- FIFA17.exe
# is Denuvo-packed, RVA 0x71b8651 lands in the encrypted .data blob (file offset
# 0x3499e51, high entropy), and the reported bytes `49 8B 41 28 4C 8B 08` occur
# ZERO times in the whole 224 MB file. The leading theory (vtbl+0xa70 is an EA
# text-filter service that is NULL offline) is a HYPOTHESIS, not a finding.
#
# THE SAFE WAY TO RENAME YOUR CLUB IS OFFLINE, and it works today:
# python3 tools/fut_account.py --club-name 'Real OpenFUT' --club-abbr ROF
# ./openfut-fut.sh restart
# INSTANT FALLBACK if you do try the flag: `unset FUT_CLUB_RENAME`, restart, the
# hub is immediately back. Fixing it for real needs a live /proc/PID/mem dump
# around 0x1471b8651 (Wine maps the PE flat at 0x140000000) -- a separate task.
_CLUB_RENAME = os.environ.get("FUT_CLUB_RENAME") == "1"
# ---- FUT_CLUB_IDENTITY ladder ----------------------------------------------
# Which club/gamertag-identity bodies we serve. Same convention as FUT_MASSINFO /
# FUT_USERINFO: each rung is one more change class, so a live regression bisects
# in one step.
# off -> DEFAULT: pre-2026-08-03 behaviour, /clubUser and /user/list stub out
# route -> /clubUser serves {"user":[...]} and /user/list serves the club-info
# body.
# massinfo -> ALSO injects clubUser into the userMassInfo body. massinfo is
# boot-critical and adding a member to it is exactly the change class
# behind the last two live regressions; FUT_MASSINFO=squad remains the
# instant known-good fallback.
#
# WHY `off` IS THE DEFAULT (corrected after review, 2026-08-03): this ladder was
# first shipped defaulting to `route` on the argument that these are "NEW ROUTES, so
# nothing that works today changes shape". The live log refutes the premise --
# EVERY request to /clubUser (22) and /user/list (39) in /tmp/utas_server.log is
# dated 21:xx, i.e. the contract suite hitting them. The real client, across five
# full sessions between 17:00 and 20:45, requested NEITHER. So populating them buys
# nothing observable and hands the client two response bodies it has never parsed --
# the exact change class that produced today's two live regressions. Turn a rung on
# only when the log shows the client actually asking.
_CLUB_ID = os.environ.get("FUT_CLUB_IDENTITY", "off")
def user_info():
# Deserializer 0x18013EC10; every member optional (unknown key ids are
@@ -105,15 +207,23 @@ def user_info():
p = STORE.profile()
rec = p.get("record", {})
info = {
"personaId": PERSONA_ID,
"clubName": p.get("clubName", "OpenFUT"), "clubAbbr": p.get("clubAbbr", "OFC"),
"established": p.get("established", "2026"),
# Identity/club come from ACCOUNT, never from the save: the save is only a
# mirror (fut_store._sync_identity) and must not be able to disagree with
# what Blaze PDTL / LSX GetProfileResponse assert for the same session.
"personaId": ACCOUNT.persona_id,
"clubName": ACCOUNT.club_name, "clubAbbr": ACCOUNT.club_abbr,
# established(0x110) is a STRING of digits: deser 0x18013ec10 case 0x110
# takes the STRING getter then strtol base 10 into rec+0x64. An int on the
# wire here is the scalar/string mismatch class that busy-loops the SAX
# reader at 0x1801c7f1a. ACCOUNT.established is str-typed for this reason.
"established": ACCOUNT.established,
# 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",
# with this field present. Value MUST match the auth body's
# nucleusPersonaPlatform, which is why it reads the locked wire constant.
"accountCreatedPlatformName": ACCOUNT.PLATFORM,
# 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
@@ -130,9 +240,11 @@ def user_info():
# 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,
# clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62. FALSE unless
# FUT_CLUB_RENAME=1: True is the isolated root cause of the 2026-08-03
# create-club crash and serving the rename endpoint does NOT fix it
# (the crash is upstream of the network). See the _CLUB_RENAME block.
"clubNameChangeAllowed": _CLUB_RENAME,
"divisionOffline": 10, "divisionOnline": 10,
"purchased": False, # 0x262 -> bool at +0x68
"feature": {"trade": True},
@@ -167,7 +279,14 @@ def user_get():
# POST ut/game/<sku>/user (CreateUser, 0x18014CC60) recognises exactly:
# bonusPacks(0x5d) login(0x1a5) squad(0x2cd) starterPack(0x2e5) userData(0x36d).
def user_post():
def user_post(h=None):
# CREATE-CLUB VARIANT: CardsDLL builder FUN_18014ca00 sends
# {useFut1Data:false, clubName, clubAbbr, purchased:false} on this same URL.
# Adopt the name the user typed so a create-club round-trips instead of the
# client seeing its own choice silently replaced by ours. Same validation and
# the same never-4xx rule as club_rename_route().
if h is not None:
_adopt_club_from_body(h, "CREATE-CLUB")
# 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
@@ -176,6 +295,171 @@ def user_post():
"squad": current_squad(), "starterPack": {}, "bonusPacks": []}
# ---- CLUB IDENTITY: the gamertag-carrying bodies ---------------------------
def club_user_body():
"""GET ut/%s/clubUser -- FutGetClubUsersServerResponse (deser 0x180145c00).
THE BUG THIS FIXES: `/ut/game/fifa17/clubUser` used to be swallowed by the
generic `(G + r"/club")` route (verified by running that compiled regex
against the real path) and answered {"itemData":[...club items...]}. The
GetClubUsers deser recognises ONLY `user`(0x36c) and SKIPs itemData, so the
club-user model was empty BY CONSTRUCTION -- there was no network-supplied
gamertag anywhere in FUT. docs/ENDPOINT_MAP.md row 2 recorded this as a GAP
but described the old response as `{}`; it was actually the itemData body.
SCHEMA: {"user":[element]}. Three scalars, all freeze-safe:
persona (0x21a) STRING, bounded-copied to 32 chars -> FUN_180008120(dst,s,0x21)
personaId(0x21b) INT64
public (0x25f) BOOL
None of them is an array/object slot, so the scalar-vs-container freeze class
(busy-loop at 0x1801c7f1a) does not apply to this body at all.
NOTE the element-parser address is recorded twice and inconsistently
(0x180145480 in ENDPOINT_MAP row 2, 0x180138b00 in the display recon). Both
derivations agree on the top-level key and on these three members, so it is a
naming/wrapper question rather than a schema one -- carried as a caveat.
"""
return {"user": [{
"persona": ACCOUNT.persona_name[:32],
"personaId": ACCOUNT.persona_id,
"public": True,
}]}
def club_info_body():
"""GET ut/%s/user/list -- the club-identity record list.
DELIBERATELY NO `name` KEY: record+0x08 is filled by the merge FUN_18011e7c0,
which matches on personaId and copies clubUser.persona in. That is exactly why
personaId MUST be byte-identical here and in club_user_body() -- if they
disagree the merge finds nothing and the name stays empty.
OMITS squadList(0x2d4) ON PURPOSE: it is routed to FUN_180142260, and a bare
array/scalar there is the 0x1801c7f1a busy-loop class. userInfo already carries
the squadList via the FUT_USERINFO ladder, so there is nothing to gain here.
"""
return {"user": [{
"personaId": ACCOUNT.persona_id,
"clubName": ACCOUNT.club_name, # 0x8e
"clubAbbr": ACCOUNT.club_abbr, # 0x8d
"established": ACCOUNT.established, # 0x110 -- STRING of digits
}]}
def club_identity_route(kind):
"""FUT_CLUB_IDENTITY=off restores the pre-fix stubs for a one-step bisect."""
if _CLUB_ID == "off":
return 200, {}
return 200, (club_user_body() if kind == "clubUser" else club_info_body())
# ---- ACCOUNTINFO (unproven; OFF by default) --------------------------------
# GET ut/%s/user/accountinfo is requested ~9x per session and we answer {}.
# It STAYS {} by default and that is a deliberate refusal, not an oversight: its
# parser is FutGetUserAccountInfoServerCallConfig, which lives inside the
# Denuvo-packed FIFA17.exe, so it CANNOT be reversed statically. Any key we invent
# has an unknown expected TYPE, and a scalar where a container is expected is
# precisely the freeze class this whole codebase is organised around avoiding.
# FUT_ACCOUNTINFO=1 serves a guessed body for a single deliberate experiment. Every
# key in it is a real atom from docs/fut_atoms.tsv and every value is a scalar --
# that bounds the risk, it does not eliminate it. Treat a freeze after enabling
# this as expected, and unset it.
_ACCOUNTINFO = os.environ.get("FUT_ACCOUNTINFO") == "1"
def accountinfo_body():
if not _ACCOUNTINFO:
return {}
return {
"userId": ACCOUNT.user_id, # 0x36f
"personaId": ACCOUNT.persona_id, # 0x21b
"persona": ACCOUNT.persona_name, # 0x21a
"name": ACCOUNT.persona_name, # 0x1d0
"email": ACCOUNT.email, # 0xf8
"country": ACCOUNT.country, # 0xbd
}
# ---- CLUB RENAME -----------------------------------------------------------
def _adopt_club_from_body(h, tag):
"""Parse {clubName(0x8e), clubAbbr(0x8d)} out of a request body, validate it
against the client's OWN limits, persist it, and mirror it into the save.
Returns True if the club changed. NEVER raises and NEVER makes the caller
answer 4xx -- see club_rename_route() for why that rule is load-bearing.
"""
try:
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
except Exception:
body = {}
if not isinstance(body, dict):
return False
name = body.get("clubName")
abbr = body.get("clubAbbr")
if name is None and abbr is None:
return False
name = ACCOUNT.club_name if name is None else name
abbr = ACCOUNT.club_abbr if abbr is None else abbr
if (name, abbr) == (ACCOUNT.club_name, ACCOUNT.club_abbr):
return False
try:
validate_club(name, abbr)
except ValueError as e:
# Rejected: keep the old club, keep serving 200. The client is told the
# transport succeeded and simply keeps rendering whatever userInfo says.
log(" %s: REJECTED %r/%r -- %s" % (tag, name, abbr, e))
return False
old = (ACCOUNT.club_name, ACCOUNT.club_abbr)
try:
ACCOUNT.set_club(name, abbr)
ACCOUNT.save() # -> tools/fut_account.json
STORE.refresh_identity() # -> mirror into fifa17_profile.json
except Exception as e:
log(" %s: FAILED to persist %r/%r (%s: %s)" % (tag, name, abbr, type(e).__name__, e))
return False
log(" %s: club %r/%r -> %r/%r (persisted)"
% (tag, old[0], old[1], ACCOUNT.club_name, ACCOUNT.club_abbr))
return True
def club_rename_route(h):
"""PUT the club rename -- FutChangeClubNameServerResponse.
RESPONSE IS `{}`, AND THAT IS COMPLETE, NOT A STUB. The struct has ZERO atoms:
vtable 0x18022cb58 slot +0x08 is 0x1801642c0, whose entire body is `return 1`
(a shared no-op deserializer also used by ActivateCard and SignLoanPlayer).
The HTTP body is fully ignored. The ONLY thing read is the transport result
code at result+0x1c (handler FUN_1800829c0):
0 -> SUCCESS (client then copies the name into userInfo rec+0x20 and
the abbr into rec+0x3e via FUN_180007f80(rec+0x3e,4,...))
0x1f -> PROFANITY
else -> FAILED
So: HTTP 200, no FUT error code, EVER.
NEVER 4xx, even on a rejected name. CardsDLL's failure reporter FUN_18016cca0
explicitly SKIPS the whole 'R4ER: DISCONNECTED' telemetry path when the status
is 200 -- answering 4xx is how a bad rename turns into a disconnect.
TWO URLs, BOTH ROUTED. docs/ENDPOINT_MAP.md row 3 derives PUT `ut/%s/club`.
The rename recon derives `ut/%s/user` (request row 0x1802cba70, urlIdx 0xb ->
template @0x18021e030) plus a per-response-class literal suffix "/club"
(appender 0x18014c740: MOV RCX,RDX; LEA RDX,[0x180225124 "/club"]; JMP
0x180008020) = `ut/game/fifa17/user/club`, and it validated that same urlIdx
column against three independently-known live URLs. Since the response is a
zero-atom ack, being wrong about which costs nothing -- so both are served and
the live log settles it.
REQUEST BODY: builder FUN_18014c590 emits exactly clubName + clubAbbr, nothing
else.
"""
if h.command in ("PUT", "POST"):
_adopt_club_from_body(h, "RENAME")
return 200, {}
# GET on the rename URL is not a known endpoint; a parseable {} is the
# cheapest correct answer (unknown keys are SKIP'd everywhere in FUT).
return 200, {}
# GET ut/game/<sku>/settings (0x18013C6D0) recognises ONE key: configs (0xa2).
SETTINGS = {"configs": []}
@@ -224,10 +508,17 @@ def massinfo():
return {"userInfo": user_info()}
if _MI == "settings":
return {"settings": SETTINGS}
return {"userInfo": user_info(), # squadList -> roster singleton
"squad": current_squad(), # personaId == PERSONA_ID
body = {"userInfo": user_info(), # squadList -> roster singleton
"squad": current_squad(), # personaId == ACCOUNT.persona_id
"settings": SETTINGS,
"userData": {}}
if _CLUB_ID == "massinfo":
# clubUser(0x91) IS a recognised massinfo key (deser 0x180174630), so this
# is schema-legal -- it is opt-in only because massinfo is the boot-critical
# body and adding a member to it is the change class behind the last two
# live regressions. Instant fallback: FUT_MASSINFO=squad.
body["clubUser"] = club_user_body()
return body
# ---- FUT item-definition serving (wf_e41070d8) -------------------------------
# The card view-model 0x1800d7920 renders identity/rating/face from a RESOLVED
@@ -283,6 +574,80 @@ def defs_route(h):
return 200, {"itemData": [item_def(i) for i in ids]}
# ---- pack reveal workaround ---------------------------------------------------
# THE REVEAL HAND-OFF IS UNSOLVED. Live 2026-08-04, five packs, three different
# response shapes for FutMoveCard (full card objects / +chemistry / dreamSquads-only):
# every time the client moved the cards, then POSTed ut/delete/auth ~1s later and
# dropped to the main menu. No crash dump; Blaze keeps pinging afterwards, so the
# game is alive and it is the FUT SESSION that ends. The cards always arrive
# server-side -- only the acknowledgement is rejected.
#
# What is known: FutMoveCard's deserializer 0x180128600 contains NO skip handler
# (FUN_180135ff0 appears zero times, unique among FUT deserializers) and parses only
# itemData(0x16b) -> element -> dreamSquads(0xe9). Sending exactly that still failed,
# so the trigger is elsewhere and remains unidentified.
#
# WORKAROUND (default ON, FUT_PACK_AUTOCLUB=0 disables): deposit pack contents
# STRAIGHT into the club at open time and keep the pending pile empty, so the client
# is never offered a move to make and never sends the request that kills the session.
# Cost: the reveal screen shows no cards to assign. Benefit: packs are usable and the
# cards are in the club, which is the point of buying one. Turn this off when the
# real hand-off is understood.
PACK_AUTOCLUB = os.environ.get("FUT_PACK_AUTOCLUB", "1") == "1"
# FUT_MOVE_BODY -- what PUT ut/%s/item answers. Made switchable so the shape can be
# bisected in one relaunch each instead of a code edit per attempt.
# empty (default) -> {} full -> echo the moved card objects
# dreamsquads -> {"itemData":[{"dreamSquads":[]} x N]}
#
# WHY `empty` IS NOW THE DEFAULT (live 2026-08-04, after six failed attempts):
# Quick Sell All hits the SIBLING endpoint POST ut/delete/%s/item, which was
# UNMAPPED and therefore answered with a bare {} -- and it WORKED: no error, session
# intact. Meanwhile every crafted body on PUT ut/%s/item was fatal. So a bare {} is
# demonstrably acceptable to this screen, and the inherited claim that "returning []
# makes FIFA think the move failed -> kicks to main menu" is unproven and probably
# another misdiagnosis in the same lineage as the chemistry one.
# Also disproven this round: the netwatch recorded ZERO non-loopback connections, so
# "error connecting to FIFA 17 Ultimate Team" is FIFA's generic FUT-session failure
# text, not a real network failure -- and the 146 missing FUT_RS4_URL_<CALL> keys
# (now served, and genuinely missing) were not the cause either.
MOVE_BODY = os.environ.get("FUT_MOVE_BODY", "empty")
# FUT_STORE_GROUPS: send displayGroup(0xd9) in the pack catalogue. DEFAULT OFF --
# it FROZE the store screen live on 2026-08-04 (recursive nested array through the
# same element parser 0x18013af30 -> busy-loop at 0x1801c7f1a).
STORE_GROUPS = os.environ.get("FUT_STORE_GROUPS") == "1"
# FUT_STORE_FIELDS: the re-extracted pack fields. DEFAULT OFF -- enabling them
# stopped packs opening live on 2026-08-04.
STORE_FIELDS = os.environ.get("FUT_STORE_FIELDS") == "1"
def quick_sell_route(h):
"""POST ut/delete/%s/item -- Quick Sell (the reveal screen's 'Quick Sell All').
Discovered live 2026-08-04 as an UNMAPPED path. The bare {} it was getting is
ACCEPTED by the client (unlike the move path), but nothing was credited, so a
quick sell destroyed the cards for 0 coins.
Coin value: FUT quick-sell pays the card's discardValue. Ours are seeded 0, so
fall back to a rating-based figure in the same spirit as the market pricing
heuristic -- an invented number, but a sane one, and better than zero. The client
re-reads the balance from GET /user/credits straight after (observed), so the
response body itself only has to be accepted."""
try:
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
except Exception:
body = {}
ids = [it.get("id") for it in (body.get("itemData") or []) if isinstance(it, dict)]
if not ids and isinstance(body.get("itemIds"), list):
ids = body["itemIds"]
sold, coins = STORE.quick_sell(ids)
if sold:
log(" QUICKSELL: sold %d card(s) for %d coins (total %d)"
% (sold, coins, STORE.coins()))
return 200, {}
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,
@@ -300,11 +665,33 @@ def item_route(h):
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": []}
# ROOT CAUSE of the "can't send cards to club -> kicked to the main
# menu" logout, found 2026-08-04 by decompiling the deserializer:
#
# FutMoveCard 0x180128600 HAS NO SKIP HANDLER. Every other FUT
# deserializer routes an unrecognised key to FUN_180135ff0 (the
# value-SKIP handler); this one calls it ZERO times. It parses
# exactly two atoms -- itemData(0x16b) as an array, and inside each
# element dreamSquads(0xe9) as an int array -- and an unknown key
# leaves its VALUE unconsumed, so the next loop iteration reads that
# value as a key and the reader desyncs.
#
# We were echoing the FULL card object: ~20 keys each, including a
# nested attributeList. Every one of them is unknown to this parser.
# That also explains the two earlier misdiagnoses -- ANY extra key
# breaks it, so `chemistry` looked causal when it was added, and
# removing it changed nothing because 20 other keys remained.
#
# Shape selected by FUT_MOVE_BODY (see above) so it can be bisected
# live without a code edit.
if MOVE_BODY == "full":
return 200, {"itemData": moved}
if MOVE_BODY == "dreamsquads":
return 200, {"itemData": [{"dreamSquads": []} for _ in moved]}
return 200, {}
# Nothing matched (ids not in the pending pile). Answer in the SAME shape as
# a successful move so the client cannot tell the two apart structurally.
return 200, ({} if MOVE_BODY == "empty" else {"itemData": []})
return defs_route(h)
@@ -323,7 +710,9 @@ ROUTES = [
# store §2.) Bare /store only -- purchasegroup/transaction matched above.
(re.compile(r"/store(\?|$)"), lambda m, h: (200, {"result": "SUCCESS"})),
(re.compile(r"/purchased"), lambda m, h: purchased_items(h)),
(re.compile(r"^/ut/auth"), lambda m, h: (200, auth_body())),
# POST ut/auth: the response is unchanged; the REQUEST body is now read so
# ACCOUNT can adopt the persona the client itself asserts (see auth_body).
(re.compile(r"^/ut/auth"), lambda m, h: (200, auth_body(h))),
(re.compile(r"^/ut/delete/auth"), lambda m, h: (200, {})),
(re.compile(G + r"/settings"), lambda m, h: (200, SETTINGS)),
# Device-trust ("phishing") flow. trusteddevice parser 0x18012a170 reads 4
@@ -334,11 +723,20 @@ ROUTES = [
(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 (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, {})),
#
# ORDER MATTERS TWICE HERE:
# * /clubUser MUST precede the generic /club route at the bottom of this table,
# which was silently swallowing it and answering with the club ITEM list.
# * /user/club MUST precede /user/list and /user$ so the rename URL is not
# absorbed by a neighbour. (It would otherwise fall through to /club, which
# now also dispatches renames -- but relying on that is a trap for the next
# edit of this table.)
(re.compile(G + r"/clubUser"), lambda m, h: club_identity_route("clubUser")),
(re.compile(G + r"/user/club"), lambda m, h: club_rename_route(h)),
(re.compile(G + r"/user/list"), lambda m, h: club_identity_route("userList")),
(re.compile(G + r"/user/accountinfo"), lambda m, h: (200, accountinfo_body())),
(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
@@ -353,10 +751,36 @@ ROUTES = [
# 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, {})),
# THE CORE LOOP. ut/%s/match was the biggest hole in the API surface: we only
# answered keepalive/reset, so the base endpoint fell through to the catch-all
# {} and a finished match awarded NOTHING. Must precede any generic route.
# ut/delete/%s/match/{id} is the DELETE form (UTAS tunnels DELETE through a
# /ut/delete/ path prefix, same as trade/watchList/squad).
(re.compile(r"/ut/delete/game/[^/]+/match"), lambda m, h: match_route(h)),
(re.compile(G + r"/match"), lambda m, h: match_route(h)),
(re.compile(G + r"/hub"), lambda m, h: (200, {})),
# 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, {})),
# ---- game modes (FUT_MODES=1; default keeps the proven {} everywhere) ----
# Order matters: the more specific season/tournament sub-paths must precede
# the bare ones, and /leaderboards/options precedes /leaderboards.
(re.compile(G + r"/season/\d+/reset"), lambda m, h: (200, {"reset": True} if _MODES else {})),
(re.compile(G + r"/season/user"), lambda m, h: (200, season_user() if (_MODES and h.command == "GET") else {})),
(re.compile(G + r"/season/friendly"), lambda m, h: (200, {})),
(re.compile(G + r"/season"), lambda m, h: (200, season_list() if (_MODES and h.command == "GET") else {})),
(re.compile(r"/ut/delete/game/[^/]+/tournament"), lambda m, h: (200, {})),
(re.compile(G + r"/tournament/user"), lambda m, h: (200, tournament_user() if (_MODES and h.command == "GET") else {})),
(re.compile(G + r"/tournament"), lambda m, h: (200, tournament_list() if (_MODES and h.command == "GET") else {})),
(re.compile(G + r"/leaderboards"), lambda m, h: leaderboard_route(h) if _MODES else (200, {})),
(re.compile(G + r"/champion"), lambda m, h: champion_route(h) if _MODES else (200, {})),
# FutGetCaptcha 0x18014e78d: encodedImg(str b64) sequence(int) sizeBeforeEncode(int).
# Served always -- an empty captcha is strictly better than the catch-all {},
# and all three fields are scalars (no freeze risk).
(re.compile(G + r"/captcha"), lambda m, h: (200, {"encodedImg": "", "sequence": 0, "sizeBeforeEncode": 0})),
(re.compile(G + r"/tfa"), lambda m, h: (200, {})),
(re.compile(G + r"/clientdata"), lambda m, h: clientdata_route(h)),
(re.compile(G + r"/livemessage"), lambda m, h: (200, {})),
(re.compile(G + r"/activeMessage"), lambda m, h: (200, {})),
# ---- transfer market / auction house (empty-but-valid; ENDPOINT_MAP market §)
# tradePile MUST precede /trade ("/tradePile" contains the "/trade" prefix).
(re.compile(G + r"/tradePile"), lambda m, h: tradepile_route(h)),
@@ -367,15 +791,43 @@ ROUTES = [
# /auctionhouse (was UNMAPPED -> {} => empty market). Serve the same listings.
(re.compile(G + r"/transfermarket"), lambda m, h: auctionhouse_route(h)),
(re.compile(G + r"/marketdata"), lambda m, h: (200, {"minPrice": 150, "maxPrice": 15000})),
# QUICK SELL. Live-observed 2026-08-04: the reveal screen's "Quick Sell All"
# sends POST ut/delete/%s/item -- it was UNMAPPED (catch-all {}), which the
# client ACCEPTS (no error, session survives) but which paid 0 coins: the user
# sold 6 cards for nothing. Credit discardValue per card and remove them.
(re.compile(r"/ut/delete/game/[^/]+/item"), lambda m, h: quick_sell_route(h)),
(re.compile(r"/ut/delete/game/[^/]+/trade"), lambda m, h: delete_trade_route(h)),
(re.compile(r"/ut/delete/game/[^/]+/watchList"), lambda m, h: (200, {})),
(re.compile(G + r"/club"), lambda m, h: (200, {"itemData": STORE.items()})),
# Generic /club, LAST on purpose (it is a prefix of /clubUser).
# PUT -> ChangeClubName, per docs/ENDPOINT_MAP.md row 3 (the other of the two
# competing URL derivations; see club_rename_route).
# GET -> the club item list, unchanged. NOT switched to GetClubInfo's `user`
# shape: its element parser 0x18012c990 is only PARTIALLY decoded, and
# the rendered club cards come through /item (ViewCards) anyway.
# LIVE-OBSERVED, UNDOCUMENTED (found 2026-08-04 by replaying every path in
# /tmp/utas_server.log): the client really fetches ut/%s/club/stats/{consumables,
# staff,year} on the MY CLUB screen. They are suffix endpoints the request table
# never lists -- the same trap as /squad/list. They were being swallowed by the
# generic /club route, which answers with the FULL 28-item club list where the
# client asked for STATS: wrong shape, and re-sent on every poll. No schema is
# documented for them, so serve the proven-safe {} and let a capture refine it.
(re.compile(G + r"/club/stats"), lambda m, h: (200, {})),
(re.compile(G + r"/club"), lambda m, h: club_route(h)),
]
def club_route(h):
# PUT only -- ENDPOINT_MAP row 3 gives ChangeClubName as PUT. Every other
# method keeps the exact body this route served before, so the rename support
# cannot change the behaviour of anything that already worked.
if h.command == "PUT":
return club_rename_route(h)
return 200, {"itemData": STORE.items()}
def user_route(h):
if h.command == "POST":
return 200, user_post()
return 200, user_post(h)
if NEW_USER:
# accepted, non-fatal: 0x18007AF20 treats 404 as the new-user branch
return 404, {}
@@ -393,6 +845,208 @@ def user_route(h):
_SQUAD_LIST_MODE = os.environ.get("FUT_SQUAD_LIST", "off")
def clientdata_route(h):
"""ut/%s/clientdata/<key> -- opaque client blob storage.
LIVE-OBSERVED: `PUT ut/game/fifa17/clientdata/userHubData` fires from the FUT
hub (20:40 session). The client is storing its own hub state -- so the correct
server behaviour is to keep the blob and hand back exactly what was given, which
is zero-risk by construction: we never synthesise a shape, we echo the client's
own bytes. Persisting it is also the most plausible route to the hub's
"MANAGER TASKS 0/0" tile surviving a relaunch, since no FutGetObjectives class
exists in the binary at all (§9) -- the tile state may simply live in this blob.
"""
key = h.path.split("/clientdata/", 1)[-1].split("?")[0] or "default"
if h.command in ("PUT", "POST"):
try:
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
except Exception:
body = None
if body is not None:
STORE.set_clientdata(key, body)
log(" CLIENTDATA: stored %r (%d bytes)" % (key, len(h._body)))
return 200, {}
return 200, STORE.get_clientdata(key)
# ---- GAME MODES: seasons / tournaments / leaderboards / champions ------------
# These were 15 of the 45 templates in the URL table with NO route at all -- they
# fell through to the catch-all {}. Schemas below come from ENDPOINT_MAP.md (the
# same source that already had the match loop right); confidence per endpoint is
# noted inline.
#
# DEFAULT OFF. Every body here is documented-but-never-live-tested, and today's two
# regressions were both "serve a new body the client has never parsed". Notably
# FutSeasonList wants an ARRAY root where we currently send {} on a boot-adjacent
# path -- exactly the shape class that freezes at 0x1801c7f1a if wrong. Turn on
# with FUT_MODES=1 when you can watch a launch; `unset FUT_MODES` is the fallback.
_MODES = os.environ.get("FUT_MODES") == "1"
def season_list():
"""GET ut/%s/season -- FutSeasonList, deser 0x180167740 (HIGH).
ARRAY root of season descriptors. prizeSet(595)/elgReq(247) are nested and
FREEZE-RISK, so both are omitted (SKIP-safe)."""
return [{"id": 1, "divisionId": 10, "eligibilityKey": 0, "eligibilitySlot": 0,
"eligibilityValue": 0, "elgOperation": ""}]
def season_user():
"""GET ut/%s/season/user -- FutSeasonLoadData, deser 0x180131450 (HIGH,
switch fully traced at 0x18013153c). `data`(201) is an opaque interned blob
string; empty is valid. friendlySeasonHistory is nested -> omitted."""
return {"seasonId": 1, "divisionId": 10, "round": 1, "userPoints": 0,
"dataVersion": "1", "data": ""}
def tournament_list():
"""GET ut/%s/tournament -- FutTournamentList, deser 0x180169ef0 (MEDIUM).
ARRAY root; rounds/prizeSet/staff/kit atoms are nested FREEZE-RISK -> omitted."""
return [{"id": 1, "difficulty": 1, "coins": 500, "rewardMultiplier": 1,
"assetName": "", "eligibilityOperation": ""}]
def tournament_user():
"""GET ut/%s/tournament/user -- FutTournamentLoadData 0x180147cb0 (MEDIUM).
Mirrors the season shape; tournamentData(810) is the same interned blob."""
return {"round": 1, "dataVersion": "1", "tournamentData": ""}
def leaderboard_route(h):
"""GET ut/%s/leaderboards -- FutGetLBEntries 0x180144c8d (MEDIUM).
/options -- FutGetLBOptions 0x18014351c. Empty entries list is the safe body:
an empty array cannot desync the reader."""
if "/options" in h.path:
return 200, {"category": 0, "id": 0, "period": 0, "view": 0, "url": ""}
return 200, {"entries": []}
def champion_route(h):
"""ut/%s/champion -- registration 0x18014980d (ack, no atoms),
topX 0x18014a09d ({"entries":[]}), friends 0x18014b7ad ({} safe)."""
if h.command == "POST":
return 200, {}
return 200, {"entries": []}
# ---- THE CORE LOOP: match lifecycle + rewards --------------------------------
# Schemas from ENDPOINT_MAP.md (all CONFIDENCE: HIGH, reversed earlier):
# POST ut/%s/match FutCreateMatch 0x180120380
# startDateTime(740,int) reportIdEnabled(641,bool)
# squad(717,nested -- FREEZE-RISK, omit: SKIP-safe)
# PUT ut/%s/match/{id} FutMatchReady no deserializer at all -> {}
# POST ut/%s/match/{id} FutPlayGame no deserializer at all -> {}
# (the client SENDS the result here; body ignored)
# DELETE ut/%s/match/{id} FutDestroyMatch 0x180121b60 <-- THE REWARDS
# allCoins(20)@0x28 matchCoins(436)@0x2c tournamentCoins(809)@0x30
# teamOfTournamentWinner(776,bool)@0x34 seasonCoins(670)@0x38 coins(149)@0x3c
# participationAward(529)@0x44 boostConis(96)@0x48 [EA's typo, exact key]
# qualifiedChampionEventId(617)@0xb0
# gameModeAward(310) / matchCoinMultipliers(437) / userData(877) are NESTED and
# SKIP-safe -- omitted deliberately (userData is a documented freeze-risk: it
# must be an object if present, so the safe move is not to send it).
# Every field we DO send is a top-level scalar -> zero freeze risk.
#
# Reward amounts are ours to choose (the server decides payouts). Defaults are
# FUT-ish and env-tunable; they are NOT reversed values and are not claimed to be.
MATCH_COINS = {
"won": int(os.environ.get("FUT_MATCH_COINS_WIN", "400")),
"draw": int(os.environ.get("FUT_MATCH_COINS_DRAW", "200")),
"loss": int(os.environ.get("FUT_MATCH_COINS_LOSS", "100")),
}
MATCH_PARTICIPATION = int(os.environ.get("FUT_MATCH_PARTICIPATION", "0"))
def _match_result(body):
"""Work out win/draw/loss from whatever the client posted.
The PlayGame/DestroyMatch request shape is NOT reversed -- the response side is
(that is what we serve), but nobody has captured the request yet. So probe the
plausible spellings and fall back to a draw, which is the neutral outcome: it
still credits coins and advances the record without inventing a win. Every body
is logged, so the first live match tells us the real shape."""
if not isinstance(body, dict):
return "draw", None
# a nested match/stats object is as likely as a flat one
for key in ("match", "matchStats", "stats", "result", "gameResult"):
inner = body.get(key)
if isinstance(inner, dict):
r, s = _match_result(inner)
if s is not None:
return r, s
for us, them in (("goals", "opponentGoals"), ("score", "opponentScore"),
("userGoals", "opponentGoals"), ("homeGoals", "awayGoals"),
("ourScore", "theirScore")):
a, b = body.get(us), body.get(them)
if isinstance(a, int) and isinstance(b, int):
return ("won" if a > b else "loss" if a < b else "draw"), (a, b)
# explicit textual result
r = body.get("result") or body.get("outcome")
if isinstance(r, str):
rl = r.lower()
for k, v in (("win", "won"), ("won", "won"), ("loss", "loss"),
("lose", "loss"), ("defeat", "loss"), ("draw", "draw"),
("tie", "draw")):
if k in rl:
return v, None
return "draw", None
def destroy_match_body(result, coins, total):
"""FutDestroyMatchServerResponse (deser 0x180121b60) -- PURE, no state.
Split out of match_route so it can be unit-tested: the match loop mutates
(credits coins, bumps W/D/L), so it cannot live in the read-only HTTP contract
suite. See tools/test_match_rewards.py. Every field is a top-level scalar; the
nested members gameModeAward(310)/matchCoinMultipliers(437)/userData(877) are
SKIP-safe and deliberately omitted (userData is a documented freeze-risk)."""
return {
"coins": int(coins),
"allCoins": int(total),
"matchCoins": int(MATCH_COINS.get(result, 0)),
"seasonCoins": 0,
"tournamentCoins": 0,
"boostConis": 0, # EA's spelling, atom 96
"participationAward": int(MATCH_PARTICIPATION),
"qualifiedChampionEventId": 0,
"teamOfTournamentWinner": False,
}
def match_route(h):
"""POST create / PUT ready / POST play / DELETE destroy(+rewards)."""
try:
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
except Exception:
body = {}
m = re.search(r"/match/(\d+)", h.path)
match_id = int(m.group(1)) if m else None
is_delete = h.command == "DELETE" or "/ut/delete/" in h.path
if is_delete:
# FutDestroyMatch -- the ONLY place a match awards anything.
result, score = _match_result(body)
coins = MATCH_COINS.get(result, 0) + MATCH_PARTICIPATION
rec, total = STORE.record_match(result, coins)
log(" MATCH: %s%s -> +%d coins (total %d) record %d-%d-%d"
% (result, (" %d-%d" % score) if score else "", coins, total,
rec["won"], rec["draw"], rec["loss"]))
return 200, destroy_match_body(result, coins, total)
if h.command == "POST" and match_id is None:
# FutCreateMatch. `squad` is nested + freeze-risky -> omitted (SKIP-safe).
mid = STORE.new_item_id()
log(" MATCH: created id=%d" % mid)
return 200, {"startDateTime": int(datetime.datetime.now().timestamp()),
"reportIdEnabled": False, "id": mid}
# PUT {id} = MatchReady, POST {id} = PlayGame. Both have NO deserializer at
# all, so {} is a complete response; the result is claimed on destroy.
if body:
log(" MATCH: %s %s body=%s" % (h.command, h.path, json.dumps(body)[:400]))
return 200, {}
def squad_route(h):
# PUT = SaveCurrentSquad. FutSquadSaveServerResponse deser 0x180171a60 parses
# exactly ONE key, id(0x15c) -> reply {"id": <squadId>}, NOT an echo of the
@@ -421,38 +1075,79 @@ def squad_route(h):
# ---- STORE / PACKS (first-cut; iterate against the log) ---------------------
def store_catalog(h):
packs = []
idx = 1
for p in PACK_CATALOG:
gold = p["gold"]
mtx = max(1, p["price"] // 100)
packs.append({
"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,
"sortPriority": idx,
"currencies": [{"name": "coins", "funds": p["price"], "finalFunds": p["price"]}],
"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": p["count"] if gold else 0,
"itemQuantity": p["count"],
"unopened": False,
},
def _pack_body(p, idx):
"""One entry of FutStoreGetPackTypes.purchase (element deser 0x18013af30).
THIS IS THE ORIGINAL, KNOWN-GOOD BODY -- restored 2026-08-04 after my "field
corrections" broke pack BUYING live. It renders pack tiles as "unknown" (see
FUT_STORE_FIELDS below) but packs are purchasable, which matters more.
The corrections were derived from re-reading the deserializer and are probably
right about what is PARSED -- but "parsed" is not "safe to change", and I
swapped a working body for an unverified one with no way to test it offline.
They now live behind FUT_STORE_FIELDS=1.
"""
gold = p["gold"]
mtx = max(1, p["price"] // 100)
body = {
"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,
"sortPriority": idx,
"currencies": [{"name": "coins", "funds": p["price"], "finalFunds": p["price"]}],
"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": p["count"] if gold else 0,
"itemQuantity": p["count"],
"unopened": False,
},
}
if STORE_FIELDS:
# Re-extracted from 0x18013af30 (correct about what the parser READS, but
# live-untested and NOT proven safe -- the last attempt stopped packs from
# opening at all). extPrice inner objects take externalPriceId(0x11a)+active,
# not amount/currency.
body.update({
"dealType": "", "actionType": 0, "bonus": 0, "points": 0,
"value": p["price"], "priority": idx, "firstPartyStoreId": 0,
"useDefaultImage": True, "start": 0, "end": 0,
})
idx += 1
body["currencies"][0]["active"] = True
body["extPrice"] = {"finalPrice": {"externalPriceId": p["id"], "active": True},
"originalPrice": {"externalPriceId": p["id"], "active": True}}
return body
def store_catalog(h):
"""GET ut/%s/store/purchasegroup/... -- FutStoreGetPackTypes (root 0x1801234e0).
The "unknown" tiles are STILL unfixed. displayGroup(0xd9) is the likely answer --
the store renders display GROUPS and that key is parsed recursively by the same
element parser -- but sending it FROZE the store screen (busy-loop 0x1801c7f1a),
so it is behind FUT_STORE_GROUPS=1, default OFF.
"""
packs = []
for idx, p in enumerate(PACK_CATALOG, start=1):
entry = _pack_body(p, idx)
if STORE_GROUPS:
group = _pack_body(p, idx)
group.pop("displayGroup", None)
entry["displayGroup"] = [group] # RECURSIVE -- froze the store
entry["displayGroupAssetId"] = p["id"]
entry["displayGroupUseDefaultImage"] = True
packs.append(entry)
return 200, {"purchase": packs, "timestamp": 1596326400}
@@ -506,6 +1201,11 @@ def purchased_items(h):
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()))
if PACK_AUTOCLUB:
# Move straight to the club so the reveal never offers a hand-off.
moved = STORE.move_items([{"id": it["id"], "pile": "club"} for it in items])
log(" STORE: auto-club deposited %d card(s) (reveal hand-off bypassed)"
% len(moved))
return 200, {
"packId": pid,
"firstPartyStoreId": 0,
@@ -659,7 +1359,7 @@ def tradepile_route(h):
# The user's OWN sale pile: build a validated auction record per active listing
# from the owned club item + its list prices. Freeze-safe (same record shape).
by_id = {it["id"]: it for it in STORE.items()}
seller = STORE.profile().get("personaName", "OpenFUT")
seller = ACCOUNT.persona_name
recs = []
for l in STORE.listings():
it = by_id.get(l["itemId"])