Files
OpenFUT/fifa17-recon/tools/utas_server.py
T
funman300 4c5cc3ab4b fifa17-recon: THE MY CLUB COUNTER -- it was clubPlayers in GET /hub all along
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
2026-08-04 15:29:59 -07:00

2089 lines
110 KiB
Python
Executable File

#!/usr/bin/env python3
"""Minimal FIFA 17 UTAS/RS4 server (OpenFUT, clean-room).
CardsDLL resolves every RS4 endpoint to <base> + path, where <base> comes from
FUT_RS4_APIURL_<MODULE> / FUT_RS4_URL_<CALL> (blaze_responder_v3b.py) and path is
moduleTable[i] with %s -> "game/<sku>". Auth is POST <base>ut/auth; the response's
"sid" becomes the X-UT-SID header on every later call (CardsDLL @0x180126080).
Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
* NEVER 401/403 -> silent re-auth storm (3 retries) then ServerFatalError.
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
* [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
"""
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"
# 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
def now():
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def log(m):
line = "[%s] %s" % (datetime.datetime.now().strftime("%H:%M:%S"), m)
print(line, flush=True)
with open(LOG, "a") as f:
f.write(line + "\n")
# ---- payloads -------------------------------------------------------------
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()}
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 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
# 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"] = ACCOUNT.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.
#
# 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
# skipped via 0x180135FF0), so {} also parses.
# 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 = {
# 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 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
# 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. 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},
"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.
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).
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
# precisely the state that makes AddPlayerToSquad no-op (see REBUILD_PLAN S9c).
return {"login": True, "userData": user_info(),
"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": []}
# 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")
# ---- pileSizeClientData: the MY CLUB counter --------------------------------
# LIVE EVIDENCE (2026-08-04): the user opened MY CLUB, the client fetched GET /club
# and DISPLAYED all 99 players -- and the MY CLUB counter still read 0. So that
# counter is NOT derived from the item list; it is a PILE SIZE, delivered
# separately. massinfo's pileSizeClientData(0x227) is that member and we have never
# sent it. Parser 0x18013adb0: {"entries":[{"key":<int>,"value":<int>}]} -- key and
# value BOTH read with the int getter 0x1801c79d0, and the parser IS skip-safe.
#
# The pile-id enum is not recoverable from the strings (the "club"/"tradepile"
# literals are just atom names in the alphabetical key table). So rather than guess:
#
# FUT_PILESIZES=probe -> emit one entry per candidate key 0..15 with a UNIQUE
# recognisable value (100+key). Whatever number MY CLUB then displays names the
# club pile's key: 103 means key 3. One launch identifies the enum.
# FUT_PILESIZES=1 -> emit the REAL counts once PILE_KEY_CLUB below is known.
#
# Default OFF: this adds a member to boot-critical massinfo. It is a documented
# member of that parser and carries only ints, so the risk is low -- but "low" is
# what I said about displayGroup before it froze the store, so it ships behind a flag.
_PILESIZES = os.environ.get("FUT_PILESIZES", "")
PILE_KEY_CLUB = int(os.environ.get("FUT_PILE_KEY_CLUB", "-1")) # set once probed
def pile_size_body():
"""massinfo.pileSizeClientData -- see the note above."""
if _PILESIZES == "probe":
return {"entries": [{"key": k, "value": 100 + k} for k in range(16)]}
counts = {
"club": len(STORE.items()),
"purchased": len(STORE.purchased()),
"tradepile": len(STORE.listings()),
}
if PILE_KEY_CLUB >= 0:
return {"entries": [{"key": PILE_KEY_CLUB, "value": counts["club"]}]}
# No verified key yet -> announce the club count on every candidate key. Crude,
# but every value is truthful, so no pile can be told a wrong number.
return {"entries": [{"key": k, "value": counts["club"]} for k in range(16)]}
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}
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()
if _PILESIZES:
# pileSizeClientData(0x227) -> parser 0x18013adb0, int-only, skip-safe.
# This is what the MY CLUB counter reads (see pile_size_body above).
body["pileSizeClientData"] = pile_size_body()
return body
# ---- FUT item-definition serving (wf_e41070d8) -------------------------------
# The card view-model 0x1800d7920 renders identity/rating/face from a RESOLVED
# record at item+0x10, filled by looking the resourceId up in the FUT item-def
# store. That store is network-filled; empty offline => generic cards. FIFA
# fetches definitions from ut/<sku>/item/resource, ut/<sku>/defid, and batch
# ut/<sku>/item?idList=<ids>. We serve them here (deser 0x18013fe00, same as items).
# resourceId = playerId | version<<24 ; assetId = resourceId & 0xffffff.
PLAYER_DEFS = {
# assetId: (name, rating, position, nation, leagueId, teamid, [6 attrs])
20801: ("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80]),
}
def item_def(rid):
"""Build one FUT item-definition for a requested resourceId."""
asset = rid & 0xffffff
name, rating, pos, nation, league, team, attrs = PLAYER_DEFS.get(
asset, ("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70]))
return {
"id": rid,
"resourceId": rid,
"definitionId": rid,
"assetId": asset,
"cardassetid": asset,
"commodityId": asset,
"cardsubtypeid": 0,
"cardType": 0,
"itemType": "player",
"rareflag": 1,
"rating": rating,
"preferredPosition": pos,
"nation": nation,
"leagueId": league,
"teamid": team,
"playStyle": 250,
"attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)],
"name": name,
"commonName": name,
"lastName": name,
"itemState": "free",
"untradeable": True,
}
def defs_route(h):
# Parse every integer id out of the query string (idList=a,b,c / definitionId=x
# / resourceId=x) and return a definition for each.
q = h.path.split("?", 1)[1] if "?" in h.path else ""
ids = [int(n) for n in re.findall(r"\d{3,}", q)]
if not ids:
return 200, {"itemData": []}
return 200, {"itemData": [item_def(i) for i in ids]}
# ---- pack reveal: SOLVED 2026-08-04 -------------------------------------------
# The reveal hand-off worked live: five cards, Send to Club, session survived, cards
# persisted, no ut/delete/auth. See MOVE_BODY below for what actually fixed it.
#
# The workaround this block used to describe (deposit pack contents straight into
# the club at open time, keep the pending pile empty) is now DEFAULT OFF. It was
# always a cost, not a fix: with the pending pile empty the client has nothing to
# assign, so the reveal screen shows no cards AND the move request is never sent.
# That second effect made the real bug untestable -- the first live attempt at the
# correct response shape produced no PUT /item at all because autoclub had already
# emptied the pile. Leave this off unless the move path regresses.
PACK_AUTOCLUB = os.environ.get("FUT_PACK_AUTOCLUB", "0") == "1"
# FUT_MOVE_BODY -- what PUT ut/%s/item answers.
# ack (default) -> {"itemData":[{"id":N,"pile":"club","success":true}, ...]}
# LIVE-PROVEN 2026-08-04. Five cards sent to club, session
# survived, cards persisted, NO ut/delete/auth logout.
# empty -> {} known-broken
# full -> echo the moved card objects known-broken
# dreamsquads -> {"itemData":[{"dreamSquads":[]} x N]} known-broken
#
# WHY. 0x180128600 does not parse an acknowledgement, it builds per-item VERDICT
# records, and the completion handler raises EVENT_CARDS_MOVE_CARD_FAILURE when the
# record vector is EMPTY or when record+0x0c != 1. success(0x2fa) is initialised to
# '\0' per element. So {} and every echo shape reported the move as FAILED -- the
# session died because we told it to.
#
# The request the client actually sends (captured live, first time ever):
# {"itemData":[{"id":100000125,"pile":"club","swap":0,"tradeId":0}, ...]}
# so `swap` and `tradeId` accompany id/pile. We ignore both; the move succeeded
# without honouring them.
#
# HISTORY, kept because the wrong version of it cost seven attempts. This file used
# to claim 0x180128600 had NO skip handler and parsed only itemData -> dreamSquads.
# Both false: two skip-handler sites, seven atoms. The claim came from searching a
# TRUNCATED decompile (src[:4000] of 6193 chars). It implied "the body cannot be the
# problem", which sent the investigation after client-side state. It was the body.
# See REBUILD_RESEARCH.md S16.
#
# Also retracted: the argument that a bare {} was "demonstrably acceptable" because
# Quick Sell survives one. Quick sell's callbacks read only the transport code and
# never touch the body, so its tolerance said nothing about this endpoint.
#
# Still true from that round: the netwatch recorded ZERO non-loopback connections
# during a failure, so "error connecting to FIFA 17 Ultimate Team" is FIFA's generic
# FUT-session failure text and must never be read as a network event.
MOVE_BODY = os.environ.get("FUT_MOVE_BODY", "ack")
# FUT_STORE_GROUPS and FUT_STORE_FIELDS ARE GONE. Both were kept around as "maybe
# they were nearly right" experiments; the 2026-08-04 read of 0x18013af30 (19,279
# chars, read in full by two independent agents) showed both were wrong in ways that
# make them unsafe to keep even switched off:
# STORE_GROUPS sent displayGroup as an ARRAY. It is a flat OBJECT of two members.
# STORE_FIELDS sent actionType(0x8) and firstPartyStoreId(0x127) as INTEGERS. Both
# take the STRING getter 0x1801c7aa0.
# Each is the type-desync freeze, so leaving them in the file as togglable options was
# leaving two loaded guns on the table. The fix that replaces them is below.
#
# FUT_STORE_DISPLAYGROUP: send the ONE key that actually names a tile,
# displayGroup(0xd9) as {"value": "<pack name>"}. DEFAULT OFF: the parser-side proof
# is airtight but does not cover whether the presence of this key switches FIFA17.exe
# to a different (packed, unreadable) tile render path. See _pack_body.
STORE_DISPLAYGROUP = os.environ.get("FUT_STORE_DISPLAYGROUP") == "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 _move_ack(req, moved):
"""FutMoveCard per-item verdict records (deser 0x180128600).
Built from the REQUEST, not from `moved`: under FUT_PACK_AUTOCLUB the pack's
cards are already in the club before the client asks to move them, so
STORE.move_items() legitimately returns nothing and a `moved`-derived body
would be empty -- which is the one shape guaranteed to raise
EVENT_CARDS_MOVE_CARD_FAILURE.
success is asserted ONLY for ids we can actually account for: either this call
moved them, or they are already sitting in the club. Anything else gets
success:false, which is the truthful verdict -- claiming success for an id the
store has never seen would desync the client's model against ours, and that is
a worse failure than an honest per-item false.
`reason`(0x279) is omitted deliberately. The only string known to map to a
specific code is "Destination Full" (0xf) and that is not what happened here;
the key is skip-safe, so sending nothing is better than sending a wrong reason.
"""
club_ids = {it.get("id") for it in STORE.items()}
moved_ids = {it.get("id") for it in moved}
out = []
for r in req:
if not isinstance(r, dict):
continue
iid = r.get("id")
ok = iid in moved_ids or iid in club_ids
out.append({
"id": iid, # INT getter -- send a number
"pile": r.get("pile", "club"), # STRING -> enum 0x180142650
"success": bool(ok), # BOOL, record+0x0c, must be 1
})
if out and not all(x["success"] for x in out):
log(" ITEM: %d/%d verdicts are success=false (ids not accounted for)"
% (sum(1 for x in out if not x["success"]), len(out)))
return out
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)
# ack is answered BEFORE the `if moved:` gate on purpose. Under
# FUT_PACK_AUTOCLUB=1 (the default) a pack's cards are already in the
# club by the time the reveal screen asks to move them, so move_items()
# legitimately returns nothing -- and gating the ack on `moved` would
# emit a ZERO-RECORD itemData, which is precisely the shape that raises
# EVENT_CARDS_MOVE_CARD_FAILURE. The verdict has to come from the
# REQUEST. (Caught in review before it ever ran; the earlier placement
# would have made ack mode fail in exactly the configuration it is meant
# to fix, and the bug would have looked like "ack does not work".)
if MOVE_BODY == "ack":
if moved:
log(" ITEM: moved %d item(s) to pile(s)" % len(moved))
return 200, {"itemData": _move_ack(req, moved)}
if moved:
log(" ITEM: moved %d item(s) to pile(s)" % len(moved))
# RETRACTED 2026-08-04. This block previously claimed:
# "FutMoveCard 0x180128600 HAS NO SKIP HANDLER ... calls it ZERO
# times ... parses exactly two atoms, itemData and dreamSquads"
# That was WRONG and it sent the investigation after client-side
# state for seven attempts. Cause of the error: the decompile was
# truncated to 4000 chars before being searched, and the function is
# 6193 chars -- BOTH FUN_180135ff0 call sites (offsets 5006 and 6080)
# and four of the seven atoms lie past the cut. Never conclude an
# absence from a truncated decompile.
#
# VERIFIED SHAPE (full decompile, getter read from the first call
# after each atom compare):
# itemData(0x16b) array of PER-ITEM VERDICT RECORDS, 0x18 bytes each
# id(0x15c) INT 0x1801c79d0 -> record+0x00
# pile(0x226) STRING 0x1801c7aa0 -> enum via 0x180142650
# club=7 purchased=6 trade=5
# success(0x2fa) BOOL 0x1801c7620 -> record+0x0c
# reason(0x279) STRING 0x1801c7aa0 -> "Destination Full" = 0xf
# dreamSquads(0xe9) INT array
# anything else -> FUN_180135ff0 (the skip handler, twice)
#
# This is NOT an ack endpoint. The completion handler raises
# EVENT_CARDS_MOVE_CARD_FAILURE when the record vector is EMPTY or
# when record+0x0c != 1, and `success` is initialised to '\0' at the
# top of every element loop. So every body we have ever returned --
# full cards, +chemistry, dreamsquads-only, and `empty` ({}, which
# produces zero records and fails the first guard outright) -- has
# reported the move as FAILED. Quick sell survives an identical {}
# because its callbacks check only the transport code and ignore the
# body: that is the whole asymmetry, and it was on the wire after all.
#
# `ack` is the corrected shape. NOT the default yet: it is untested
# live, and the necessary condition being identified is not proof of
# sufficiency. One launch with FUT_MOVE_BODY=ack settles it.
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)
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: 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)),
# ut/v2/game/fifa17/store = FutStorePackQuantities ELIGIBILITY GATE, not a
# quantity list. deser 0x1801758c0 reads one key "result" (atom 0x288); the
# store screen shows "not available" unless this is SUCCESS. (ENDPOINT_MAP
# 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)),
# 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
# booleans by key-id 0x7e/0x117/0x19e/0x351; 0x351 == JSON key "trusted".
# Returning trusted=true makes FUT SKIP the security question.
(re.compile(G + r"/phishing/trusteddevice"), lambda m, h: (200, {"trusted": True})),
(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 (2026-08-03: squad schema 0x18013d1f0 now reversed) ----
# /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.
#
# 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
# "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())),
# Same bug as /squad/list, second instance: `ut/%s/squad/mode` + `/draft/state`
# is composed by appending a suffix, so it is invisible to the request-template
# table, and the generic /squad route below was swallowing it. MUST precede it.
(re.compile(G + r"/squad/mode/draft/state"), lambda m, h: draft_state_route(h)),
(re.compile(G + r"/purchase/mode/\d+/draft"), lambda m, h: draft_purchase_route(h)),
(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, {})),
# 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, hub_data())),
# Populated massinfo (see massinfo() above): userInfo + squad + settings.
(re.compile(G + r"/userMassInfo"), lambda m, h: (200, massinfo())),
# ---- 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)),
(re.compile(G + r"/trade"), lambda m, h: trade_route(h)),
(re.compile(G + r"/watchList"), lambda m, h: watchlist_route(h)),
(re.compile(G + r"/auctionhouse"), lambda m, h: auctionhouse_route(h)),
# LIVE GROUND TRUTH: FIFA's market SEARCH hits /transfermarket (one word), not
# /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, {})),
# 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.
(re.compile(G + r"/club/stats"), lambda m, h: club_stats_route(h)),
(re.compile(G + r"/club"), lambda m, h: club_route(h)),
]
# ---- GET ut/%s/hub : THE MY CLUB TILE COUNTER --------------------------------
# FutGetHubDataServerResponse. Body parser FUN_180139610 (14,855 chars, censused in
# full: 18 atom comparisons, none missed). Root container is a FLAT OBJECT, verified
# from the prologue by tokenizer-call calibration against the live-proven massinfo
# parser rather than assumed.
#
# THE CHAIN, end to end, re-derived independently by two agents (one via Ghidra, one
# via raw PE plus capstone with no decompiler) and checked by two reviewers:
#
# clubPlayers (atom 0x90) --INT getter 0x1801c79d0--> clamp FUN_1800d7b30 (<=0 -> 0)
# -> stored at R+0x3c, where R = FUT data-manager slot +0x1f8 (FUN_18011a810 is
# literally `lea rax,[rcx+0x1fd70]; ret`)
# -> read by FUN_1800b0250 and published as TEXT0 of TILE_ID 0x210
# -> captions FUT_GH_TOTAL_PLAYERS_0 / _1 at 0x18020a0f8 / 0x18020a110
# auctionCount (atom 0x33) -> R+0x38 -> TEXT0 of TILE_ID 0x1b0, the TRANSFERS tile
#
# `FUN_180139610` is the ONLY writer of +0x3c anywhere in the image (one write in
# 14,855 chars, guarded by `if (iVar6 != 0x90)`). So this is not a candidate, it is the
# field.
#
# WHY IT TOOK SO LONG, recorded because the error is instructive. The hunt went to
# /club/stats and stayed there for a day, and REBUILD_RESEARCH S19 concluded the
# counter was "not server-fixable" with a mechanism that was internally correct and
# entirely beside the point: the tile never read the club-stat store. Two things
# reinforced the wrong path. 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, which is wrong: there IS an inline ladder, one indirection away.
# And the eight-row MY CLUB panel was assumed to be FUN_180043b90 case 1, which
# publishes six keys; it is actually FUN_180094ce0, a different provider using a
# different string family (FUT_MYCLUB_*), which reads neither the mode tag nor any
# type id we were sending. Two providers, and we were reading the wrong one.
#
# auctionCount is deliberately included as a FREE CONTROL: it lands in a different
# field (+0x38) and a different tile, so if the MY CLUB tile moves and TRANSFERS does
# not, the delivery is fine and something is specific to +0x3c.
#
# DEFAULT ON. Freeze risk is genuinely low rather than merely believed low: the body
# is 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 {}.
HUBDATA = os.environ.get("FUT_HUBDATA", "1") == "1"
def hub_data():
"""GET ut/%s/hub -- the FUT hub tile counters."""
if not HUBDATA:
return {}
players = len([i for i in STORE.items() if i.get("itemType") == "player"])
auctions = len(STORE.listings())
log(" HUB: clubPlayers=%d auctionCount=%d" % (players, auctions))
return {"clubPlayers": players, "auctionCount": auctions}
# ---- club stats: the CLUB STATS panel, and probably the MY CLUB tile too ------
# GET ut/%s/club/stats/<mode> -> FutStickerBookStats2ServerResponse, deser
# 0x180130150 (7,870 chars, read end to end). Wire schema, fully verified:
#
# {"stat":[{"contextId":int, "contextValue":int, "type":str, "typeValue":int}]}
# contextId(0xb6) INT 0x1801c79d0
# contextValue(0xb7) INT 0x1801c79d0
# type(0x354) STRING 0x1801c7aa0 -> copied into a 0x30-byte buffer
# typeValue(0x355) INT 0x1801c79d0
# Unknown keys route to FUN_180135ff0 at BOTH nesting levels, so extras are inert.
#
# FIVE THINGS THAT DECIDE WHETHER THIS WORKS, all learned the hard way:
#
# 1. EVERY RESPONSE WIPES THE WHOLE MAP FIRST. Nothing accumulates. So a good body on
# one mode followed by a thin body on another ERASES the first, and the ordering of
# the client's requests would decide what survives. The fix is to serve the SAME
# COMPLETE SET for every Stats2 mode: then whichever request lands last leaves the
# map correct and ordering stops mattering. (One investigator reported this factory
# does NOT wipe; a reviewer re-read it and refuted that. The wipe is real.)
# 2. /club/stats/staff IS A DIFFERENT CLASS. It is FutStaffBonus, shape
# {"bonus":[{"type":str,"value":int}]}, NOT Stats2. Sending a {"stat":[...]} body
# there is harmless but does nothing. Its type strings are not decoded, so it keeps
# {} -- which is safe, because that parser's top-level loop exits immediately on
# END_OBJECT. It also means staff does NOT wipe the Stats2 map.
# 3. ELEMENT-LOCAL VARIABLES ARE NOT RESET BETWEEN ELEMENTS. The clears happen once
# before the array loop, not inside it, so omitting a key in element N silently
# inherits element N-1's value. ALWAYS EMIT ALL FOUR KEYS IN EVERY ELEMENT.
# 4. The storage key is contextValue ALONE. contextId is only a guard: contextId == 1
# or 5 <= contextId <= 9 forces contextValue to 0, which is the global bucket the
# +0x800 getter reads. We use contextId 1 throughout to land everything there.
# 5. Three ids the panel READS can never be SET from here: 0x3d CONTRACTS,
# 0x3e TRAINING, 0x40 FITNESS. No type string produces them.
#
# type string -> internal id -> the on-screen row it moves (FUN_18012fd40 -> the club
# stats provider FUN_180043b90):
# players 1 PLAYERS rarePlayers 5 staff 0xa STAFF_EMPLOYED
# stadia 0x14 STADIA_OWNED balls 0x1e BALLS_EARNED kits 0x28 KITS_AVAILABLE
# badges 0x2d BADGES trophies 0x32 TROPHIES_WON
#
# WHY THIS IS NOW ALSO THE HUB-TILE CANDIDATE. The investigation concluded the MY CLUB
# tile does not read this store, but flagged that negative as BOUNDED: the interface
# comes through a QueryInterface adapter, so the vtable is assembled at runtime and
# cannot be read statically. Live evidence on 2026-08-04 points the other way. The hub
# tile reads "0 TOTAL PLAYERS" and the CLUB STATS panel reads "Players 0", the same
# quantity, both zero, while we answer {}. And FUT_CLUB_PAGE ruled out the alternative:
# we served 114 items to /club and the tile still said 0, so it is not a count of the
# list. Strong inference, not proof. This flag is the test.
#
# The test is unusually clean because the club holds 114 items and ALL of them are
# players: every other row is an honest zero. So if this works, exactly two numbers
# move (Players and Rare Players, 0 -> 114) and nothing else changes.
# DEFAULT ON since 2026-08-04: LIVE-PROVEN. With this serving, the MY CLUB ->
# ENGLAND -> Premier League row went from 0 to 17, the first non-zero number ever
# rendered on that screen. Nothing froze and no other screen changed. The global
# bucket rows ride along and are harmless; they are what the MY CLUB summary and
# hub tile WOULD read if anything ever selected those cases (see REBUILD_RESEARCH
# S19, still open). FUT_CLUBSTATS=0 reverts to the old {}.
CLUBSTATS = os.environ.get("FUT_CLUBSTATS", "1") == "1"
def _counts_for(players):
"""The per-collection counts, from a list of player items."""
# Gold/silver/bronze is FIFA's rating convention (75+/65-74/under), NOT read out of
# the binary. If a tile ever disagrees, this is the line to doubt.
return [
("players", len(players)),
("playersGold", len([i for i in players if (i.get("rating") or 0) >= 75])),
("playersSilver", len([i for i in players if 65 <= (i.get("rating") or 0) < 75])),
("playersBronze", len([i for i in players if 0 < (i.get("rating") or 0) < 65])),
("rarePlayers", len([i for i in players if i.get("rareflag")])),
]
def _club_stat_context(kind):
"""Per-context rows, keyed the way the READER actually looks them up.
Read out of the club-stats provider FUN_180043b90, not guessed. The per-context
getter is `(+0x7f8)(store, contextValue, typeId)`, and the crucial detail is where
contextValue comes from: THE UI ROW, not the URL.
case 3: uVar7 = (**(param_2 + 0x18))(param_2, row, "LEAGUE_ID")
bronze = (+0x7f8)(store, uVar7, 2)
silver = (+0x7f8)(store, uVar7, 3)
gold = (+0x7f8)(store, uVar7, 4)
publish "PLAYERS_EMPLOYED", gold + silver + bronze
rare = (+0x7f8)(store, uVar7, 5)
kits = (+0x7f8)(store, uVar7, 0x28)
badges = (+0x7f8)(store, uVar7, 0x2d)
case 4: keyed by "TEAM_ID"; reads 1 (players), 0x28 (kits), 0x2e (badgeDBid)
THREE CONSEQUENCES, all of which my first attempt got wrong:
1. One response must carry a bucket for EVERY ROW the screen will show, because
the reader iterates rows and looks up each row's own id. Keying everything to
the id in the URL, which is what I did first, fills exactly one bucket that the
screen never asks for.
2. `PLAYERS_EMPLOYED` is COMPUTED as gold + silver + bronze. It is never read from
the store in the per-context cases, so sending `players` (type id 1) does
nothing there. The tier counts are mandatory, not decoration.
3. The screens NEST: country/<id> lists the LEAGUES in that nation (case 3, keyed
by LEAGUE_ID) and league/<id> lists the TEAMS (case 4, keyed by TEAM_ID). That
matches the live navigation exactly: selecting ENGLAND produced a Premier
League / Championship / League One / League Two list.
Because every response WIPES the whole map, each response only needs the buckets
for its own screen. That is also what keeps nation ids and league ids from
colliding: the storage key is contextValue alone, so nation 14 and league 14 would
otherwise share a bucket. One kind per response, no collision.
contextId 3 is used because it is OUTSIDE the guard (contextId == 1, or 5..9,
force contextValue to 0) and therefore preserves contextValue. `TODO/CONFIRM`
whether contextId carries further meaning; nothing read so far gives it one.
"""
players = [i for i in STORE.items() if i.get("itemType") == "player"]
field = {"country": "leagueId", "league": "teamid"}.get(kind)
if not field:
return [], 0
ctxs = sorted({i.get(field) for i in players if i.get(field) is not None})
rows = []
for ctx in ctxs:
sel = [i for i in players if i.get(field) == ctx]
if field == "teamid":
counts = [("players", len(sel)), ("kits", 0), ("badgeDBid", 0)]
else:
counts = [c for c in _counts_for(sel) if c[0] != "players"]
counts += [("kits", 0), ("badges", 0)]
rows += [{"contextId": 3, "contextValue": int(ctx), "type": t, "typeValue": int(v)}
for t, v in counts]
return rows, len(ctxs)
def _club_stat_set():
"""The complete global stat set, computed from what the club actually holds."""
items = STORE.items()
players = [i for i in items if i.get("itemType") == "player"]
rare = [i for i in players if i.get("rareflag")]
# Gold/silver/bronze is FIFA's rating convention (75+/65-74/below), NOT something
# read out of the binary. Marked as a convention because it is one; if a tile ever
# disagrees, this is the line to doubt.
gold = [i for i in players if (i.get("rating") or 0) >= 75]
silver = [i for i in players if 65 <= (i.get("rating") or 0) < 75]
bronze = [i for i in players if 0 < (i.get("rating") or 0) < 65]
counts = [
("players", len(players)),
("playersGold", len(gold)),
("playersSilver", len(silver)),
("playersBronze", len(bronze)),
("rarePlayers", len(rare)),
# Honest zeros: this club holds no non-player items of any kind.
("staff", 0),
("stadia", 0),
("balls", 0),
("kits", 0),
("badges", 0),
("trophies", 0),
]
# All four keys in every element -- see note 3 above.
return [{"contextId": 1, "contextValue": 0, "type": t, "typeValue": int(v)}
for t, v in counts]
def club_stats_route(h):
mode = h.path.split("/club/stats/", 1)[-1].split("?")[0] if "/club/stats/" in h.path else ""
if not CLUBSTATS:
return 200, {}
if mode.startswith("staff"):
# FutStaffBonus, a different class with a different shape. {} is safe and
# deliberately does not disturb the Stats2 map.
return 200, {}
stats = _club_stat_set()
# Per-context modes carry an id in the URL: country/<nation>, league/<id>,
# team/<id>. Those tabs read a bucket keyed by that id, so the global rows above
# are invisible to them. Both sets go in the SAME response because every response
# wipes the whole map first, so anything left out of this body is erased.
parts = mode.split("/")
if len(parts) >= 2 and parts[1].isdigit():
# The id in the URL says which screen we are on, NOT which bucket to fill.
# country/<n> renders a list of leagues keyed by LEAGUE_ID; league/<n> renders
# a list of teams keyed by TEAM_ID. So fill every bucket that screen can show.
ctx_rows, n = _club_stat_context(parts[0])
stats = stats + ctx_rows
log(" CLUBSTATS: %s -> %d rows (global players=%d, %d %s buckets)"
% (mode, len(stats), stats[0]["typeValue"], n,
{"country": "league", "league": "team"}.get(parts[0], parts[0])))
return 200, {"stat": stats}
log(" CLUBSTATS: %s -> %d stat rows (players=%d)"
% (mode or "(none)", len(stats), stats[0]["typeValue"]))
return 200, {"stat": stats}
# FUT_CLUB_PAGE -- an EXPERIMENT, not a fix, aimed at the MY CLUB hub counter.
#
# The counter's renderer is NOT in cardsdll.dll. There is no two-number formatter of
# any spelling in the binary, no tab-strip layout and no display label; the tab entry
# itself carries NAME/UUID/CARD_ID/CARD_TYPE/TAB_INDEX/LEVEL/IS_CLUB and panel-wide
# numTabs/numCards, and nothing else. The composition happens in FIFA17.exe or the
# Scaleform assets, neither of which is readable. Separately, FutStickerBookSearch
# (the GET /club?... parser) has no count or total atom at all: it reads itemData and
# nothing else. So there is no field we can send that IS the number.
#
# What remains testable: if the counter is a Flash-side count over whatever the club
# list returned, then changing the LENGTH of that array moves it. That is a pure
# volume change with no new key and no type change, which makes it the cheapest
# possible discriminator.
#
# The client asks with count=11 (and count=34 when filtering by position). We already
# ignore the query string entirely and return everything, so this flag deliberately
# does the opposite of paging: it is here to make the returned length UNAMBIGUOUS in
# the log so the on-screen number can be compared against it.
#
# A NULL RESULT IS THE VALUABLE ONE. If the counter does not move, the "counter is a
# count of what we sent" hypothesis is dead, and with the binary evidence above that
# leaves no server-side lever at all for this symptom -- which would mean the correct
# outcome is to prove it and stop, not to keep generating candidate bodies.
#
# Risk: paging behaviour on the client side is unknown, so an unexpected length could
# make the club screen misbehave, and then the counter reading is worthless rather
# than informative. Default OFF.
CLUB_PAGE = os.environ.get("FUT_CLUB_PAGE") == "1"
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)
items = STORE.items()
if CLUB_PAGE:
log(" CLUB: returning %d item(s) [FUT_CLUB_PAGE experiment -- compare this "
"number against the MY CLUB counter on screen]" % len(items))
return 200, {"itemData": items}
def user_route(h):
if h.command == "POST":
return 200, user_post(h)
if NEW_USER:
# accepted, non-fatal: 0x18007AF20 treats 404 as the new-user branch
return 404, {}
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 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 -- FutSeasonListServerResponse, deser 0x1801683f0.
CORRECTED 2026-08-04. This function and its docstring were BOTH wrong, in the
same way, and the error is instructive:
* The deserializer is 0x1801683f0, not 0x180167740. 0x180167740 is the
per-ELEMENT parser.
* The root is therefore an OBJECT, not an array. 0x1801683f0 runs a key loop
and matches exactly ONE atom, seasons(0x2ad); only inside that does an array
open. Someone read the element parser, saw its key set, and served those keys
at the document root. A bare array populates nothing at all.
Three of the keys previously served here (eligibilityKey, eligibilitySlot,
eligibilityValue) are inner members of elgReq and are completely inert at element
level, so even the element shape was wrong.
Ordering matters inside an element: `type` MUST precede `divisionId`, because the
divisionId branch reads the already-parsed type field at elem+0x1b4.
Still omitted, and now for a stated reason: prizeSet(0x253), elgReq(0xf7) and
matches(0x1b8) are all `while (tok != 0xd)` ARRAY loops. A scalar in any of them
is the 0x1801c7f1a spin.
NOT SERVED BY DEFAULT and there is no point serving it yet: across 486 real
client requests (User-Agent ProtoHttp, roughly 30 boots) the game has NEVER asked
for /season. Every /season line in our log is our own curl or urllib. A body here
changes nothing observable, so this is correctness-in-waiting, not a fix.
Semantic hazard for whenever it does ship: omitting untilEndSeconds makes the
season's end timestamp equal to now.
"""
return {"seasons": [{"type": "OFFLINE", "id": 1, "divisionId": 10}]}
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": []}
# ---- Draft current state -----------------------------------------------------
# GET ut/%s/squad/mode/draft/state?mode=ONLINE|SINGLE_PLAYER
# -> FutGetDraftCurrentStateServerResponse, deser 0x180147070.
#
# THE ROOT CONTAINER IS A JSON ARRAY, not an object. That single fact is the whole
# finding. The deserializer's prologue discards tokens until it sees START_ARRAY;
# handed a top-level object it never reaches its exit condition and spins in the
# inner `while (tok != END_OBJECT)` loop with the tokenizer returning EOF forever.
# Process alive, no crash dump, no error dialog: exactly the signature observed live
# on 2026-08-03 when our generic /squad route answered this with a full active-squad
# object (23 slots, nested itemData, the 33-int `custom` string).
#
# Verified, not assumed:
# * 7 top-level atoms, counted at instruction level (4 int-getter calls, 3
# string-getter calls, 0 bool, 2 skip) over the whole body [0x180147070,
# 0x1801475a3]. FUN_180135ff0 IS present (2 sites), so unknown keys are inert.
# * NO ATOM COLLIDES with the squad object we were serving. The freeze was purely
# the container level, not a per-field type desync.
# * roundsInfo[] element parser FUN_180146eb0 (2477 chars, read in full): 7 scalar
# atoms, all safe. Empty array is safest and is what we send.
# * entranceCriteria(0x108) is an object of three int keys COINS/DRAFT_TOKEN/
# POINTS. OMITTED here: knowing a shape is not a reason to send it.
# * squadState(0x2d5) is a string enum. "DRAFTSQUAD_ON" from the old notes is NOT
# an accepted spelling.
#
# A reviewer independently simulated this exact body through the deserializer line by
# line: 16 token reads, clean exit, nothing left over.
#
# DEFAULT ON, which is a deliberate exception to "default to the live-proven value".
# The live-proven value here HANGS THE GAME. There is no working screen to protect:
# Draft cannot be entered at all today. FUT_DRAFT_STATE=0 restores the old routing if
# this turns out to be wrong.
DRAFT_STATE = os.environ.get("FUT_DRAFT_STATE", "1") == "1"
def draft_state_route(h):
if not DRAFT_STATE:
return squad_route(h)
return 200, [{
"squadState": "INVALID", # 0x2d5 STRING enum
"stateParam1": "INVALID", # STRING
"stateParam2": "0", # STRING (the int getter also accepts it)
"gamesWonCurrentMatch": 0, # INT
"roundsInfo": [], # array of the 7-scalar element; empty is safe
# entranceCriteria: OMITTED. Shape known, not needed, skip-safe.
}]
# ---- Draft entry purchase ----------------------------------------------------
# POST ut/%s/purchase/mode/{price}/draft body {"currency":"COINS","usePreOrder":0}
# -> FutPurchaseDraftModeServerResponse. "Buys" entry into draft mode and returns
# the fresh draft session summary.
#
# LIVE 2026-08-04: this endpoint was UNMAPPED, answered {} by the catch-all, and the
# client CRASHED immediately after. Sequence, from the log:
# GET /squad/mode/draft/state?mode=ONLINE -> our array body, screen RENDERED
# GET /user/credits -> 7200
# GET /store/purchasegroup/all -> the entry-fee screen
# POST /purchase/mode/0/draft -> {} then the crash
# So the draft-state fix worked and simply advanced the failure to the next
# unimplemented call, which is the outcome a correct fix is supposed to have.
#
# WHICH ENVELOPE. ENDPOINT_MAP flags a "response-variant ambiguity" here: two
# structures reference the class name. Resolved this session, and the doc's note about
# the second one is wrong:
# 0x18014c260 (vtable 0x180224ef8, factory 0x18014c090) 3188 chars, OBJECT root
# (prologue tests != 10 = END_OBJECT), 1 skip-handler call, and exactly
# the seven scalar ints below. THIS IS THE RESPONSE PARSER.
# 0x180150310 (vtable 0x1802262f0, factory 0x180150260) 1836 chars, ARRAY root
# (loops until 0xd = END_ARRAY), ZERO skip handlers -- and it is not a
# response root at all. It parses ENTRANCE CRITERIA: each element's
# name is strcmp'd against the literals "COINS", "POINTS" and
# "DRAFT_TOKEN" and stored at +0x28/+0x2c/+0x30. It shares the name
# string because it is the fee sub-object, not an alternate envelope.
#
# THE CRASH ITSELF DISCRIMINATES, which is worth recording as a technique. An
# object-root parser handed {} parses benignly and leaves defaults; an array-root
# parser handed {} desyncs and HANGS, which is exactly what draft/state did before it
# was fixed. We observed a CRASH, not a hang, so the object-root parser is what ran,
# and the failure is downstream of an empty-but-valid parse. That is consistent with
# 0x18014c260 and inconsistent with 0x180150310.
#
# All seven members are scalar ints and the skip handler is present, so unknown keys
# are inert and there is no freeze surface here.
#
# NOT DEDUCTED FROM COINS. The client posts {"currency":"COINS"} with the price in the
# URL, and the price it sent was 0 because we omit entranceCriteria from draft/state,
# so there is no fee to charge yet. Charging a guessed amount would be inventing an
# economy rule; when entranceCriteria is served the price becomes real and this is the
# place to take it.
#
# DEFAULT ON for the same reason as FUT_DRAFT_STATE: the current behaviour is a
# confirmed crash, so there is no working state being protected.
DRAFT_PURCHASE = os.environ.get("FUT_DRAFT_PURCHASE", "1") == "1"
def draft_purchase_route(h):
if not DRAFT_PURCHASE:
return 200, {}
# NB: the route table hands handlers the compiled PATTERN, not a match object
# (the dispatcher calls fn(rx, self)), so re-extract from the path rather than
# calling .group() on the first argument. Doing that raised AttributeError,
# which killed the connection outright -- strictly worse than the {} it replaced.
m = re.search(r"/purchase/mode/(\d+)/draft", h.path)
price = int(m.group(1)) if m else 0
log(" DRAFT: purchase entry, price=%d (not deducted -- no entranceCriteria "
"served yet, so the client posted its own price)" % price)
return 200, {
"championEventId": 0,
"expectedTierLevel": 1,
"gamesPlayed": 0,
"gamesRemaining": 4, # a draft run is 4 rounds
"rank": 0,
"score": 0,
"tierLevel": 1,
}
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"))
# FUT_MATCH_END -- the 2026-08-04 correction of the whole match tail: route /match/end
# as DestroyMatch regardless of verb, and move `coins` inside gameModeAward where the
# deserializer actually reads it. DEFAULT ON, and like FUT_DRAFT_STATE this is a
# reasoned exception to "default to the live-proven value": nothing here is
# live-proven, because no match has ever been played. The old behaviour is not a
# working screen being protected, it is a path that provably could not fire (it
# required a verb and a /match/{id} URL the client does not use). Set to 0 to revert.
MATCH_END = os.environ.get("FUT_MATCH_END", "1") == "1"
# The DestroyMatch REQUEST, reversed 2026-08-04 from the serializer rather than
# guessed from the response side. This replaces the spelling-probe below as the
# PRIMARY path; the probe stays as a fallback because request-side static findings
# are a floor, not a ceiling (PUT /item's swap/tradeId were in no static listing).
#
# endReason (atom 260) -- STRING enum, and it is the AUTHORITATIVE result signal.
# Nine values: WIN DRAW LOSS DNF QUIT NO_CONTEST DNF_WIN DNF_DRAW DNF_LOSS.
# A score comparison is NOT how the client reports the outcome.
# myMatchStats / opponentMatchStats -- literal-keyed objects, 15 int fields each,
# first of which is `goals`. OMITTED BY THE CLIENT when endReason is DNF or QUIT,
# so nothing may require them.
_END_REASON = {
"WIN": "won", "DNF_WIN": "won",
"DRAW": "draw", "DNF_DRAW": "draw", "NO_CONTEST": "draw",
"LOSS": "loss", "DNF_LOSS": "loss", "DNF": "loss", "QUIT": "loss",
}
def _match_result(body):
"""Work out win/draw/loss from whatever the client posted.
Primary: endReason, the string enum the serializer actually writes. Fallback:
the old spelling probe, then a draw, which is the neutral outcome -- it credits
coins and advances the record without inventing a win. Every body is logged, so
the first live match still tells us if the static read was incomplete."""
if not isinstance(body, dict):
return "draw", None
reason = body.get("endReason")
if isinstance(reason, str) and reason.upper() in _END_REASON:
mine = body.get("myMatchStats") or {}
theirs = body.get("opponentMatchStats") or {}
score = None
if isinstance(mine, dict) and isinstance(theirs, dict):
a, b = mine.get("goals"), theirs.get("goals")
if isinstance(a, int) and isinstance(b, int):
score = (a, b)
return _END_REASON[reason.upper()], score
# 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 matchCoinMultipliers(437)/userData(877) are SKIP-safe and
deliberately omitted (userData is a documented freeze-risk).
THREE CORRECTIONS from the 2026-08-04 pass over deser 0x180121b60, all of which
were shipping wrong before:
1. `coins` (atom 149) is NOT a top-level key of this response. It is read ONLY
inside the `gameModeAward` object. The top-level "coins" we were sending was
silently skipped and never reached the client, which means the one field most
obviously named "the reward" was the one field going nowhere.
2. `qualifiedChampionEventId` (atom 0x269) HAS A SIDE EFFECT. Its branch does not
just store the int, it calls through a manager vtable afterwards. Sending it
as a habitual zero pokes champion-event machinery for no benefit. Removed.
3. NEVER emit `bidTokens` (atom 89) inside gameModeAward. That atom is explicitly
matched there and then handled by NOTHING: not read, not routed to the skip
handler. Its value token is left unconsumed in the stream, which is the exact
precondition for the type-desync spin. It is a freeze trap wearing the costume
of an ordinary field.
FUT_MATCH_END=0 restores the previous body if the new one misbehaves live."""
body = {
"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),
"teamOfTournamentWinner": False,
}
if MATCH_END:
# `coins` lives here and nowhere else. No bidTokens, ever.
body["gameModeAward"] = {"coins": int(coins)}
else:
body["coins"] = int(coins)
body["qualifiedChampionEventId"] = 0
return body
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
# THE REAL URLS, from the RPC descriptor block (rows 49-54, all using template
# index 16 = `ut/%s/match`, each appending a fixed suffix via the params object
# at slot +0x08): CREATEMATCH and PLAYGAME append nothing, MATCHREADY `/ready`,
# DESTROYMATCH `/end`, RESETMATCH `/reset`, KEEPALIVE `/keepalive`.
#
# THERE IS NO /match/{id} URL ANYWHERE. The id travels in the body. So the old
# `re.search(r"/match/(\d+)")` could never match a real request, and the reward
# path was gated on DELETE-or-/ut/delete/ which the client also never sends --
# it would have fired on nothing. match_id is retained only for hand probes.
#
# The HTTP VERB for each call cannot be determined statically: the strings "PUT"
# and "DELETE" do not exist anywhere in cardsdll.dll (0 hits each), so verb
# selection happens in the HTTP layer outside this DLL. Hence: match on the PATH
# and accept any verb. A reviewer specifically flagged the claim "the reward path
# can never fire" as overreach on exactly this point, since the verb is unknown
# rather than known-wrong, so this widens the gate instead of replacing it.
is_delete = (h.command == "DELETE" or "/ut/delete/" in h.path
or (MATCH_END and h.path.split("?")[0].endswith("/match/end")))
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
# 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)
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 _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, and now confirmed to be the right call for a
reason nobody had at the time.
THE FUT_STORE_FIELDS BLOCK WAS A FREEZE, NOT A REGRESSION. It has been DELETED.
It sent `"actionType": 0` and `"firstPartyStoreId": 0` as JSON integers, and both
atoms (0x8 and 0x127) are read with the STRING getter 0x1801c7aa0. That is the
exact type-desync class this whole project exists to avoid: a scalar of the wrong
token type where the parser calls a typed getter, which is what spins the reader
at 0x1801c7f1a. So "the corrections stopped packs opening" was not bad luck or an
unrelated field; two of them were the documented freeze mechanism, shipped by a
change whose own comment said it was correct about what the parser reads.
"Parsed" is not "safe to change", and knowing WHICH atoms a parser reads tells you
nothing about which TYPES it demands. Read the getter, every time.
Two of the deleted block's other fields were no-ops anyway: `useDefaultImage`
(0x36a) stores inverted, so True set it false, and `visible` (0x37d) never reads
its value at all (`local_130 = 1` unconditionally).
"""
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_DISPLAYGROUP:
# THE "unknown" FIX. displayGroup(0xd9) is parsed INLINE as a FLAT OBJECT --
# it is NOT recursive, the case-0xd9 body never re-enters 0x18013af30, so the
# recursion the old notes assumed does not exist and there was never anything
# to terminate. Exactly two members live inside it: value(0x377, STRING) and
# priority(0x250, INT).
#
# `value` writes record offset +0x00, which is the SAME slot whose constructor
# default is the literal "unknown" (the only such literal in cardsdll.dll,
# 0x180223108, written by FUN_180133f60). That is why the tiles read "unknown":
# not a missing translation, just a field nobody ever sent.
#
# ONE KEY, distinct per pack so grouping stays 1:1 and the current one-tile-
# per-pack layout is preserved. priority(0x250), displayGroupAssetId(0xda) and
# displayGroupUseDefaultImage(0xdb) are all OMITTED: each is a second variable,
# and 0xdb stores inverted so the old True was always a no-op.
#
# Parser-side this is provably balanced: the store root 0x1801234e0 consumes
# each element's START_OBJECT, so case 0xd9 dispatches with the value token
# current, and {"value": "..."} consumes FIELD_NAME, string scalar,
# END_OBJECT. Nothing left over, nothing over-consumed.
#
# WHAT THE PROOF DOES NOT COVER, and it is the real risk: this may be the first
# field we have ever sent that selects a RENDER PATH rather than a value.
# Going from no-displayGroup to a-displayGroup could switch FIFA17.exe from an
# ungrouped-tile layout to a group-tile layout, and that code is packed. If the
# tiles collapse into one, or render blank because `value` is a loc key rather
# than a caption, the store goes from ugly-but-working to unusable. Default OFF
# for exactly that reason, and the live test buys a pack to prove the buy path
# still works.
body["displayGroup"] = {"value": p["name"]}
return body
def store_catalog(h):
"""GET ut/%s/store/purchasegroup/... -- FutStoreGetPackTypes (root 0x1801234e0).
The "unknown" tiles are addressed by FUT_STORE_DISPLAYGROUP (see _pack_body).
THE FUT_STORE_GROUPS BLOCK IS DELETED, and its failure is now fully explained. It
sent displayGroup as an ARRAY of pack-shaped objects, on the belief that the key
was parsed recursively by this same element parser. It is not: it is a flat object
with two members. An array there desyncs the reader, the parser then runs off the
end of the document, and the tokenizer returns the same token forever with nothing
consumed -- an infinite loop inside FUN_1801c7f10, whose body contains the spin PC
0x1801c7f1a that was observed live. Not a mystery freeze; a traced one.
"""
packs = [_pack_body(p, idx) for idx, p in enumerate(PACK_CATALOG, start=1)]
return 200, {"purchase": packs, "timestamp": 1596326400}
def store_buy(h):
# PUT (v2) store/transaction. The BUY is the create step: body carries "packId"
# (atom 0x20b, TRANSACTIONCREATED) and state != TRANSACTIONCANCEL (wf a245577b /
# wf_76fcf89b). Only THEN open a pack. Cancel/other -> no-op {} (fixes the
# phantom-buy). Reveal = {"createPackResponse":{itemList,numberItems,
# purchasedPackId,duplicateItemIdList}} (FutCreatePackServerResponse 0x180162880).
try:
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
except Exception:
body = {}
pid = body.get("packId")
if body.get("state") == "TRANSACTIONCANCEL" or not isinstance(pid, int):
return 200, {} # not a confirmed buy
pack = pack_by_id(pid)
if not pack:
return 200, {}
items = STORE.open_pack(pack["price"], pack["count"], pack["gold"],
pack.get("tiers"))
if items is None:
return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
log(" STORE: opened pack %s -> %d items, coins=%d" % (pack["name"], len(items), STORE.coins()))
return 200, {"createPackResponse": {
"itemList": items,
"numberItems": len(items),
"purchasedPackId": pid,
"duplicateItemIdList": [],
}}
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"],
pack.get("tiers"))
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()))
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,
"groupName": "fifa17",
"productId": str(pack["id"]),
"purchasePackType": "GOLD" if pack["gold"] else "BRONZE",
}
return 200, {"itemData": STORE.last_pack()}
def credits_route(h):
# The FUT hub coin counter binds to currencies[].funds (deser 0x180122c50,
# atom "currencies" 0xc5), NOT a "credits" key -- wf_76fcf89b.
c = STORE.coins()
return 200, {
"credits": c,
"currencies": [
{"name": "coins", "funds": c, "finalFunds": c},
{"name": "points", "funds": 0, "finalFunds": 0},
],
}
# ---- TRANSFER MARKET / AUCTION HOUSE (ENDPOINT_MAP market §) ----------------
# Every list response shares one body: {auctionInfo:[record], credits, total,
# duplicateItemIdList} (shared deser 0x18013e7f0). auctionInfo + dupIdList MUST be
# ARRAYS and each record.itemData MUST be a card OBJECT, or the SAX reader desyncs
# -> busy-loop freeze at 0x1801c7f1a. We serve an EMPTY-but-valid market (no live
# listings yet): empty arrays never desync, so this is freeze-safe. Populating real
# auctions needs an in-game test pass. Extra keys are SKIP'd, so one merged body
# safely satisfies both the search parser and the auction-count parser.
# Sample auction listings (real players from the pack pool) so the market is
# browsable/buyable. Each record follows the reversed auction schema (deser
# 0x18013e410) EXACTLY; itemData reuses fut_store._item -- the same proven-safe
# card shape that renders club/squad cards (parser 0x18013fe00). All record
# fields are HIGH-confidence reversed scalars, so freeze risk is low. Toggle with
# FUT_MARKET=empty. Price heuristic: rating-based buy-now, ~66% starting bid.
_MARKET_MODE = os.environ.get("FUT_MARKET", "sample")
_TRADE_ID_BASE = 900000000
def _price_for(rating):
if rating >= 90: return 25000
if rating >= 85: return 8000
if rating >= 80: return 2500
if rating >= 75: return 900
return 400
def _auction_record(i, defn):
asset, rating, pos, nation, league, team, attrs = defn
buy = _price_for(rating)
card = _item(_TRADE_ID_BASE + 100000 + i, asset, rating, pos, nation, league, team, attrs)
card["untradeable"] = False # market cards are tradeable
card["itemState"] = "forSale"
return {
"tradeId": _TRADE_ID_BASE + i,
"itemData": card, # OBJECT (0x18013fe00) -- freeze-safe
"tradeState": "active", # enum string
"buyNowPrice": buy,
"startingBid": max(150, (buy * 2) // 3),
"currentBid": 0,
"bidState": "none", # enum string
"expires": 3600, # SECONDS remaining (not epoch)
"sellerName": "EASFC",
"sellerEstablished": 1,
"watched": False,
"coinsProcessed": 0,
}
def _market_auctions(limit=21):
if _MARKET_MODE == "empty":
return []
return [_auction_record(i, PACK_POOL[i % len(PACK_POOL)])
for i in range(min(limit, len(PACK_POOL)))]
def _auction_by_tradeid(tid):
# tradeId space is _TRADE_ID_BASE + index into PACK_POOL -> reconstruct the auction.
i = tid - _TRADE_ID_BASE
if 0 <= i < len(PACK_POOL):
return _auction_record(i, PACK_POOL[i])
return None
def _market_body(auctions):
return {"auctionInfo": auctions, "credits": STORE.coins(),
"total": len(auctions), "duplicateItemIdList": []}
def auctionhouse_route(h):
# GET = search (sample listings) OR count -> merged body (extra keys skip)
# POST = FutISStart (list item for sale) -> {"id": new tradeId}
# PUT = .../relist (relist all expired) -> ack {}
if h.command == "POST":
# FutISStart: list an owned club item for sale -> {"id": tradeId}
try:
b = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
except Exception:
b = {}
item_id = (b.get("itemData") or {}).get("id") or b.get("itemId")
if item_id:
tid = STORE.list_for_sale(item_id, b.get("startingBid", 150), b.get("buyNowPrice", 0))
log(" MARKET: listed item %s -> tradeId %d" % (item_id, tid))
return 200, {"id": tid}
return 200, {"id": STORE.new_item_id()}
if h.command == "PUT":
return 200, {}
body = _market_body(_market_auctions())
body.update({"count": 0, "maxAuctionsAllowed": 100,
"offered": 0, "selling": 0, "sold": 0}) # FutGetAuctionCount ints
return 200, body
def trade_route(h):
# GET view one auction -> {auctionInfo:[record], credits}
# POST/PUT place bid / buy-now -> stateful: on buy-now (bid >= buyNowPrice) deduct
# coins, grant the won card to the club, echo the CLOSED auction. Reuses the
# validated auction-record shape (0x18013e410) so it's freeze-safe; whether FIFA
# surfaces the won item post-buy is functional (needs live test). FutISOfferTrade.
m = re.search(r"/trade/(\d+)", h.path)
tid = int(m.group(1)) if m else -1
rec = _auction_by_tradeid(tid)
if h.command in ("POST", "PUT"):
try:
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
except Exception:
body = {}
if rec is None:
return 200, {"auctionInfo": [], "credits": STORE.coins()}
bid = body.get("bid") or rec["buyNowPrice"]
if bid >= rec["buyNowPrice"]: # BUY NOW
if not STORE.spend(rec["buyNowPrice"]):
return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
won = dict(rec["itemData"]); won.pop("id", None)
won["itemState"] = "free" # unassigned/won
granted = STORE.add_items([won])[0]
rec = dict(rec)
rec.update({"tradeState": "closed", "bidState": "highest",
"currentBid": rec["buyNowPrice"], "itemData": granted})
log(" MARKET: bought tradeId %d for %d, coins=%d, card->club"
% (tid, rec["currentBid"], STORE.coins()))
else: # simple bid (we're sole bidder)
rec = dict(rec); rec.update({"currentBid": bid, "bidState": "highest"})
return 200, {"auctionInfo": [rec], "credits": STORE.coins()}
return 200, {"auctionInfo": [rec] if rec else [], "credits": STORE.coins()}
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 = ACCOUNT.persona_name
recs = []
for l in STORE.listings():
it = by_id.get(l["itemId"])
if not it:
continue
card = dict(it); card["itemState"] = "listFS"
recs.append({
"tradeId": l["tradeId"], "itemData": card, "tradeState": "active",
"buyNowPrice": l.get("buyNowPrice", 0), "startingBid": l.get("startingBid", 150),
"currentBid": 0, "bidState": "none", "expires": 3600,
"sellerName": seller, "sellerEstablished": 1, "watched": False, "coinsProcessed": 0,
})
return 200, {"auctionInfo": recs, "credits": STORE.coins(), "total": len(recs)}
def delete_trade_route(h):
# DELETE ut/delete/game/fifa17/trade/{id} -- remove a listing from the sale pile.
m = re.search(r"/trade/(\d+)", h.path)
if m:
STORE.remove_listing(int(m.group(1)))
return 200, {}
def watchlist_route(h):
if h.command in ("PUT", "POST", "DELETE"):
return 200, {} # add/remove watch -> ack
return 200, {"auctionInfo": [], "credits": STORE.coins(), "total": 0}
class H(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def _handle(self):
n = int(self.headers.get("Content-Length", 0) or 0)
body = self.rfile.read(n) if n else b""
self._body = body # route fns (squad PUT) read this
log("%s %s" % (self.command, self.path))
for k, v in self.headers.items():
log(" %s: %s" % (k, v))
if body:
log(" body: %s" % body[:65536].decode("utf-8", "replace"))
code, payload = 200, {}
for rx, fn in ROUTES:
if rx.search(self.path):
code, payload = fn(rx, self)
break
else:
log(" !! UNMAPPED PATH -> catch-all 200 {}")
raw = b"" if payload is None else json.dumps(payload).encode()
self.send_response(code)
if raw:
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
if self.command != "HEAD" and raw:
self.wfile.write(raw)
log(" -> %d %s" % (code, raw[:200].decode() if raw else "(no body)"))
do_GET = do_POST = do_PUT = do_DELETE = do_HEAD = do_PATCH = _handle
def log_message(self, *a):
pass
if __name__ == "__main__":
open(LOG, "a").close()
log("=== utas_server http://%s:%d ===" % ADDR)
http.server.ThreadingHTTPServer(ADDR, H).serve_forever()