2f8a6512db
The tab was empty because we answered the wrong question. The client asks GET club/stats/consumables 41 times a session; we replied with the PLAYER stat set (players 205, playersGold 189 ...), which that panel does not read. Confirmed on screen: seven categories present and selectable, every one reading 0. Now appends 14 consumables* rows counted from the SHELF. The shelf, not STORE.items(): the consumables we serve are a synthetic overlay never granted into the save, so counting the store gives fourteen zeros, which on screen is byte-identical to failure and would have made the experiment unreadable. Counts match the independently derived expectation exactly: 126 total, 21 healing, 7 player contracts, 21 player training, 3 player fitness, 20 position, 21 GK training, 6 manager contracts, 19 playstyle. Safe by construction: the vocabulary is an ATOM switch (FUN_18012fd40, 40 arms, default return 0), so an unrecognised name is inert rather than fatal, and the rows are APPENDED -- the player, nation and league rows that drive the working screens are untouched. Zero rows unless FUT_CONSUMABLES is armed. Default ON because answering the consumables panel with player counts is wrong by inspection rather than a judgement call. FUT_CONSUM_STATS=0 reverts. 439 + 414 checks green. The open question this sets up: whether a non-zero count makes the client request an item list at all. If it does, the log names the route and /consumables/%s is settled for free. If the numbers move and no request follows, the panel renders from counts alone and the 126-item shelf was never needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2516 lines
132 KiB
Python
Executable File
2516 lines
132 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."""
|
|
if CONSUMABLES:
|
|
# A consumable's definition is NOT a player's. Answering a consumable
|
|
# resourceId with cardsubtypeid 0 makes it cardtype 0 -- no merge arm, no
|
|
# miss-fill -- i.e. plausible-looking garbage. Same trap as fut_store._item():
|
|
# this route also hardcoded cardsubtypeid 0 / rareflag 1, and rareflag 1 on
|
|
# subtype 219 renders Player Fitness as Squad Fitness (FUN_1801bfac0 case 5).
|
|
# Gated so the player definition path is untouched by default.
|
|
import fut_consumables
|
|
d = fut_consumables.def_for(rid)
|
|
if d is not None:
|
|
return d
|
|
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 ON since 2026-08-04: it was
|
|
# shipped off because the parser-side proof did not cover whether the key switches
|
|
# FIFA17.exe to a different (packed, unreadable) tile render path. It was then run live
|
|
# with FUT_STORE_DISPLAYGROUP=1 and the store tiles rendered their real names instead of
|
|
# "unknown", so the live-proven value is now on. See _pack_body.
|
|
STORE_DISPLAYGROUP = os.environ.get("FUT_STORE_DISPLAYGROUP", "1") == "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 _is_player(it):
|
|
"""The CLIENT's own definition of a footballer, and the only one worth using.
|
|
|
|
FUN_1800d8330 maps cardsubtypeid 0..3 -> cardtype 1 (players); 4 is a manager,
|
|
5/6/7/8 the four coach families, 51..341 the consumables. Three counters used to
|
|
ask `itemType == "player"` instead -- the hub's clubPlayers tile, the MY CLUB
|
|
per-nation/league/team buckets and the global stat set. That string is INERT on
|
|
the wire (atom 0x173 is parsed into a stack std::string in FUN_18013fe00 and
|
|
freed; it never reaches the record), so keying our own screens on it made their
|
|
correctness depend on a field the client ignores. Provable no-op on the current
|
|
save: all 194 items are cardsubtypeid 0 and itemType "player"."""
|
|
return it.get("cardsubtypeid", 0) in (0, 1, 2, 3)
|
|
|
|
|
|
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 _is_player(i)])
|
|
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 _is_player(i)]
|
|
# kind -> which id the SCREEN's rows are keyed by.
|
|
# "" the MY CLUB tab strip itself: its nation tiles and the eight-row
|
|
# panel FUN_180094ce0, which computes PLAYERS_EMPLOYED as
|
|
# +0x7f8(nationId, 4) + (nationId, 3) + (nationId, 2). Per NATION.
|
|
# country the leagues inside a nation (case 3, keyed by LEAGUE_ID)
|
|
# league the teams inside a league (case 4, keyed by TEAM_ID)
|
|
field = {"": "nation", "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)
|
|
|
|
|
|
|
|
# FUT_CONSUM_STATS: answer the CONSUMABLES panel with CONSUMABLE counts.
|
|
#
|
|
# LIVE 2026-08-05, and this is the whole reason the tab was empty. The client asks
|
|
# GET club/stats/consumables 41 times a session and we answered it with the PLAYER
|
|
# stat set (players 205, playersGold 189 ...). The panel reads a different set of
|
|
# names entirely, so it was told about footballers when it asked about contracts.
|
|
# The player screenshot is unambiguous: seven categories -- Training, Contracts,
|
|
# Fitness, Healing, Chemistry Style, Manager League, Position Modifier -- every one
|
|
# reading 0, on a tab that is present and selectable.
|
|
#
|
|
# The vocabulary is not a string table: FUN_18012fd40 looks the `type` string up in
|
|
# the ATOM table and switches on 40 atom ids, default `return 0`. An unrecognised
|
|
# name is therefore INERT, not fatal, which is what makes appending these rows safe.
|
|
#
|
|
# COUNT THE SHELF, NOT THE STORE. The consumables we serve are a synthetic overlay
|
|
# and are never granted into the save, so STORE.items() holds none of them and
|
|
# counting it yields fourteen zeros -- which looks exactly like failure on screen and
|
|
# would make the experiment unreadable.
|
|
#
|
|
# Default ON: answering the consumables panel with player counts is wrong by
|
|
# inspection, not a judgement call. Set FUT_CONSUM_STATS=0 to go back.
|
|
CONSUM_STATS = os.environ.get("FUT_CONSUM_STATS", "1") == "1"
|
|
|
|
|
|
def _consumable_stat_rows():
|
|
"""[(stat name, count)] for the consumables panel, counted from the shelf."""
|
|
if not (CONSUM_STATS and CONSUMABLES):
|
|
return []
|
|
try:
|
|
import fut_consumables
|
|
import fut_club_stats
|
|
except Exception as e: # never break the panel over this
|
|
log(" CLUBSTATS: consumable rows unavailable (%s)" % e)
|
|
return []
|
|
shelf = fut_consumables.starter_consumables(fut_consumables.CONSUMABLE_ID_BASE)
|
|
g = fut_club_stats.global_counts(shelf)
|
|
rows = [(fut_club_stats.VOCAB[sid], v) for sid, v in sorted(g.items())
|
|
if sid >= 0x3C and sid in fut_club_stats.VOCAB]
|
|
log(" CLUBSTATS: %d consumable row(s) from a shelf of %d" % (len(rows), len(shelf)))
|
|
return rows
|
|
|
|
|
|
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 _is_player(i)]
|
|
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]
|
|
# Staff the club is CURRENTLY BEING SERVED (the FUT_COACHES / FUT_MANAGERS
|
|
# overlay). All zero unless a flag is set. This is the free second oracle for the
|
|
# staff round: the eight-row panel's STAFF_EMPLOYED number moves without the merge
|
|
# being involved at all, so "our club really holds N staff" stays separable from
|
|
# "the client resolved the card". Keyed by cardsubtypeid: 4 manager, 5 head coach,
|
|
# 6 GK coach, 7 physio, 8 fitness coach.
|
|
_staff = _staff_overlay_counts()
|
|
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... unless a
|
|
# staff overlay is armed, in which case _staff below is what it holds.
|
|
("staff", sum(_staff.values())),
|
|
("stadia", 0), # 0x14, read directly by STADIA_OWNED
|
|
("balls", 0), # 0x1e, read directly by BALLS_EARNED
|
|
("kits", 0),
|
|
("badges", 0),
|
|
("trophies", 0),
|
|
# The eight-row panel FUN_180094ce0 does NOT read staff(0xa) or trophies(0x32).
|
|
# It SUMS the sub-types: STAFF_EMPLOYED = +0x800 over 0xb..0xf, and
|
|
# TROPHIES_WON = +0x800 over 0x33..0x38. Sending the parent ids alone can
|
|
# never move those two rows. All zero today because the club owns no staff and
|
|
# has won nothing, but the mapping is what matters when it does.
|
|
("staffManager", _staff[4]), # 0xb
|
|
("staffHeadCoach", _staff[5]), # 0xc
|
|
("staffGKCoach", _staff[6]), # 0xd
|
|
("staffPhysio", _staff[7]), # 0xe
|
|
("staffFitnessCoach", _staff[8]), # 0xf
|
|
("trophiesOffline", 0), # 0x33
|
|
("trophiesOnline", 0), # 0x34
|
|
("trophiesFeaturedOffline", 0), # 0x35
|
|
("trophiesFeaturedOnline", 0), # 0x36
|
|
("trophiesSeasonOffline", 0), # 0x37
|
|
]
|
|
counts += _consumable_stat_rows()
|
|
# 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}
|
|
# No id in the URL: this is the MY CLUB tab strip (year / consumables / club /
|
|
# newcards). Its nation tiles and its eight-row panel read PER-NATION buckets.
|
|
# LIVE 2026-08-04: the ENGLAND tile read 0 while its own Premier League row read
|
|
# 17, which is this bug exactly one level up -- the leagues were keyed and the
|
|
# nations were not.
|
|
ctx_rows, n = _club_stat_context("")
|
|
stats = stats + ctx_rows
|
|
log(" CLUBSTATS: %s -> %d rows (global players=%d, %d nation buckets)"
|
|
% (mode or "(none)", len(stats), stats[0]["typeValue"], n))
|
|
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"
|
|
|
|
|
|
# ---- FUT_ID_SWEEP: use the game itself as the player-database oracle --------
|
|
#
|
|
# Card identity is never taken from the wire. Every item we serve is registered
|
|
# into the client's CardsDb map, and just before that the client merges in its OWN
|
|
# local `players` table keyed on `resourceId & 0xffffff`. So serving a RANGE of
|
|
# candidate playerids and then reading the map back (tools/card_identity_probe.py)
|
|
# classifies the whole range in one pass. That is the entire database-extraction
|
|
# problem solved from the outside, without unpacking anything: dbdata.dll turned
|
|
# out to be an anti-tamper decoy, and the real table only exists inside the running
|
|
# game.
|
|
#
|
|
# Three outcomes per id, all distinguishable in the record (proven live 2026-08-04):
|
|
# HIT real name at +0xb8/+0xc8; teamid/nation/leagueId filled from the
|
|
# DB because we send them as ZERO (the merge only fills zeros).
|
|
# NAMELESS ROW our sentinel rating survives but the name is the DB's default
|
|
# row, "Jamal Blackman". The row exists and is empty.
|
|
# TRUE MISS the merge's miss fill: rating 0x32, teamid 0x78d, nation 0xe.
|
|
# SENTINEL_RATING is deliberately NOT 50, so a miss can never be mistaken for it.
|
|
#
|
|
# The sweep is SYNTHETIC: nothing is written to the save, so clearing the flag
|
|
# restores the real club exactly. Format: FUT_ID_SWEEP="<lo>-<hi>".
|
|
_ID_SWEEP = os.environ.get("FUT_ID_SWEEP", "")
|
|
SWEEP_FILE = os.environ.get("FUT_ID_SWEEP_FILE", "/tmp/fut_id_sweep")
|
|
SWEEP_ID_BASE = 900000000 # item ids, distinct from the save's 100000000+
|
|
SWEEP_SENTINEL_RATING = 7
|
|
|
|
|
|
def sweep_window():
|
|
"""The active sweep window: the control FILE if it has content, else the env.
|
|
|
|
Read per REQUEST, on purpose. A full database sweep is many windows, and
|
|
restarting this server to re-aim it is exactly the action that once produced
|
|
"error connecting to FIFA 17 Ultimate Team" mid-session -- a plain connection
|
|
refusal, misread for weeks as a protocol bug. Writing a range into the file
|
|
re-aims the sweep with the client still live; emptying the file restores the
|
|
real club on the very next fetch.
|
|
"""
|
|
try:
|
|
with open(SWEEP_FILE) as f:
|
|
s = f.read().strip()
|
|
if s:
|
|
return s
|
|
except (IOError, OSError):
|
|
pass
|
|
return _ID_SWEEP
|
|
|
|
|
|
# Auto-advance state. The client PAGES the club -- one visit produced seven
|
|
# GET /club?...start=50&count=11 fetches -- so an auto window can hand out a new
|
|
# chunk on every fetch and cover ~7 chunks per visit instead of one. Item ids are
|
|
# derived from the candidate's OFFSET IN THE WHOLE RANGE, not from its index in
|
|
# the chunk, so chunks never collide in the map and results ACCUMULATE across
|
|
# fetches; one probe at the end reads them all.
|
|
_SWEEP_SPEC = None
|
|
_SWEEP_POS = 0
|
|
_SWEEP_SUBTYPE = 0 # 0..3 = player; see _parse_window for the other tables
|
|
# The five non-player card tables, as dispatched by FUN_1800d8330:
|
|
# 4 -> managercards 5 -> headcoachcards 6 -> gkcoachcards
|
|
# 7 -> physiocards 8 -> fitnesscoachcards
|
|
_STAFF_SUBTYPES = [4, 5, 6, 7, 8]
|
|
|
|
|
|
def _parse_window(win):
|
|
"""'lo-hi' or 'auto:lo-hi:step' -> (lo, hi, step or None). None on garbage.
|
|
|
|
An optional 't<subtype>@' prefix sweeps a NON-PLAYER card table. The merge
|
|
FUN_180141660 dispatches on record+0x4c, which FUN_1800d8330 derives from
|
|
cardsubtypeid alone, and each branch queries a different table by
|
|
carddbid = record+0x18 (the same field players use for playerid):
|
|
|
|
0..3 -> 1 players 5 -> 3 headcoachcards
|
|
4 -> 2 manager 8 -> 4 fitnesscoachcards
|
|
6 -> 10 gkcoachcards 7 -> 5 physiocards
|
|
9..b -> 7 (unidentified) absent -> 0x156 -> 0, NO merge at all
|
|
|
|
So 't5@auto:1-20000:5000' sweeps head coaches exactly the way the default
|
|
sweeps players. Nothing about the non-player branches is live-proven yet;
|
|
they are read out of the binary.
|
|
"""
|
|
global _SWEEP_SUBTYPE
|
|
_SWEEP_SUBTYPE = 0
|
|
if win.startswith("t") and "@" in win:
|
|
head, win = win.split("@", 1)
|
|
if head == "t*":
|
|
_SWEEP_SUBTYPE = -1 # fan every staff table across the range
|
|
else:
|
|
try:
|
|
_SWEEP_SUBTYPE = int(head[1:], 0)
|
|
except ValueError:
|
|
return None
|
|
auto = win.startswith("auto:")
|
|
step = None
|
|
if auto:
|
|
parts = win[5:].split(":")
|
|
rng = parts[0]
|
|
if len(parts) > 1:
|
|
step = int(parts[1], 0)
|
|
else:
|
|
rng = win
|
|
try:
|
|
lo, hi = (int(x, 0) for x in rng.split("-", 1))
|
|
except Exception:
|
|
return None
|
|
if hi < lo:
|
|
lo, hi = hi, lo
|
|
if auto and not step:
|
|
step = 5000
|
|
return lo, hi, step
|
|
|
|
|
|
def sweep_items():
|
|
"""Synthetic club contents for the current sweep window.
|
|
|
|
5000 candidates per response is live-proven. 20000 was served fine and then
|
|
silently NOT ingested -- the map did not change at all -- so there is a
|
|
ceiling between the two. Auto chunks therefore default to 5000, and a chunk
|
|
that is not ingested costs one fetch, not the sweep.
|
|
"""
|
|
global _SWEEP_SPEC, _SWEEP_POS
|
|
win = sweep_window()
|
|
parsed = _parse_window(win)
|
|
if not parsed:
|
|
log(" SWEEP: bad window %r, want '<lo>-<hi>' or 'auto:<lo>-<hi>:<step>'" % win)
|
|
return []
|
|
lo, hi, step = parsed
|
|
|
|
if win != _SWEEP_SPEC: # re-aimed: restart the walk
|
|
_SWEEP_SPEC, _SWEEP_POS = win, 0
|
|
|
|
if step:
|
|
start = lo + _SWEEP_POS
|
|
if start > hi:
|
|
log(" SWEEP: range %d..%d EXHAUSTED -- probe now, then re-aim" % (lo, hi))
|
|
return []
|
|
end = min(start + step - 1, hi)
|
|
_SWEEP_POS += step
|
|
else:
|
|
start, end = lo, hi
|
|
|
|
# 't*@' fans EVERY staff subtype across the range at once, so one staff-tab
|
|
# load tests managercards / headcoachcards / fitnesscoachcards / physiocards /
|
|
# gkcoachcards together instead of costing five separate visits.
|
|
subs = _STAFF_SUBTYPES if _SWEEP_SUBTYPE == -1 else [_SWEEP_SUBTYPE]
|
|
|
|
# Only cardsubtypeid ever changes. itemType stays "player" because the merge
|
|
# dispatches on the subtype alone and the wire shape of a real staff item has
|
|
# never been observed -- inventing one is the change class that freezes this
|
|
# client.
|
|
out = []
|
|
for pid in range(start, end + 1):
|
|
for si, sub in enumerate(subs):
|
|
it = _item(SWEEP_ID_BASE + (pid - lo) * len(subs) + si, pid,
|
|
SWEEP_SENTINEL_RATING, "ST", 0, 0, 0, [1, 1, 1, 1, 1, 1])
|
|
if sub:
|
|
it["cardsubtypeid"] = sub
|
|
out.append(it)
|
|
log(" SWEEP: serving %d item(s) for id(s) %d..%d%s subtype(s)=%s "
|
|
"[synthetic, nothing saved]"
|
|
% (len(out), start, end,
|
|
(" (auto, %d..%d done)" % (lo, end)) if step else "",
|
|
",".join(str(s) for s in subs)))
|
|
return out
|
|
|
|
|
|
# ---- FUT_CONSUMABLES / FUT_COACHES / FUT_MANAGERS: the non-player families -----
|
|
#
|
|
# Three whole card families are now derivable offline (docs/plan-2026-08-04-card-
|
|
# families.md and the 2026-08-05 round): consumables need no id space at all, and
|
|
# staff ids came out of the 149 tables dumped read-only from the running client into
|
|
# data/tables/. Each ships behind its own flag, DEFAULT OFF.
|
|
#
|
|
# THEY ARE SERVED AS AN OVERLAY, NOT GRANTED INTO THE SAVE. That is deliberate:
|
|
# * clearing the flag restores the real club exactly, on the very next fetch, with
|
|
# no un-granting and no edit to a save that a live client is holding open;
|
|
# * Store.add_items() uses `setdefault("id", ...)`, so items that arrive with an id
|
|
# already set do NOT advance nextItemId -- granting these would eventually collide
|
|
# two id spaces. Overlay ids come from 9.4e8/9.5e8, clear of the save's 1e8, the
|
|
# sweep's 9e8 and each other.
|
|
# The cost is that overlay cards cannot be quick-sold or moved (they are not in the
|
|
# save), and the MY CLUB / clubPlayers counters do not see them. The staff COUNTERS
|
|
# are set from the overlay below, which is deliberate and is the second, independent
|
|
# oracle: "our club reports N staff" is a different signal from "the client resolved
|
|
# the card".
|
|
#
|
|
# EACH FLAG IS TWO-VALUED because the tab-to-?type= binding is UNOBSERVED. Only
|
|
# type=player, type=manager and type=custom have ever come from this client, so which
|
|
# arm the consumables and staff screens ask for is a guess:
|
|
# FUT_CONSUMABLES=1 serve on type=contract|training|healing|development
|
|
# FUT_CONSUMABLES=all ... and on an untyped club fetch with no team=/league=
|
|
# FUT_COACHES=1 serve on type=headcoach|gkcoach|physio|fitnesscoach|staff
|
|
# FUT_COACHES=all ... and on type=manager, the ONE staff request ever observed
|
|
# FUT_MANAGERS=1 serve on type=manager|staff
|
|
# Every club fetch logs the ?type= it was asked for while any of the three is set, so
|
|
# a null result tells the human WHICH arm to aim at instead of nothing at all.
|
|
def _family_flag(name):
|
|
""""" / "0" / "off" all mean OFF. Without this, FUT_CONSUMABLES=0 would be a
|
|
non-empty string and would quietly turn the family ON -- the opposite of what
|
|
anyone typing it means."""
|
|
v = os.environ.get(name, "").strip().lower()
|
|
return "" if v in ("", "0", "off", "no", "false") else v
|
|
|
|
|
|
CONSUMABLES = _family_flag("FUT_CONSUMABLES")
|
|
COACHES = _family_flag("FUT_COACHES")
|
|
MANAGERS = _family_flag("FUT_MANAGERS")
|
|
FAMILIES_ON = bool(CONSUMABLES or COACHES or MANAGERS)
|
|
|
|
MANAGER_TYPES = ("manager", "staff")
|
|
|
|
|
|
def _family_overlay(kind, has_drilldown):
|
|
"""The non-player items to serve for this ?type=, or []. Never touches the save."""
|
|
out = []
|
|
if CONSUMABLES:
|
|
import fut_consumables
|
|
if kind in fut_consumables.TYPE_CATEGORIES:
|
|
out += fut_consumables.items_for_type(kind)
|
|
elif CONSUMABLES == "all" and not kind and not has_drilldown:
|
|
out += fut_consumables.starter_consumables(fut_consumables.CONSUMABLE_ID_BASE)
|
|
if COACHES:
|
|
import fut_coaches
|
|
if kind in fut_coaches.CLUB_TYPES:
|
|
out += fut_coaches.items_for_type(kind)
|
|
elif COACHES == "all" and kind == "manager":
|
|
# type=manager is the ONE staff request ever observed on the wire (STAFF
|
|
# tab, 2026-08-04). If the tab only ever asks under that name, this is the
|
|
# only arm that can put a coach on screen.
|
|
out += fut_coaches.starter_coaches(fut_coaches.COACH_ID_BASE)
|
|
if MANAGERS:
|
|
import fut_staff
|
|
if kind in MANAGER_TYPES:
|
|
out += [fut_staff.manager_item(fut_staff.OVERLAY_ID_BASE + i, c)
|
|
for i, c in enumerate(fut_staff.STARTER_MANAGERS)]
|
|
return out
|
|
|
|
|
|
def _staff_overlay_counts():
|
|
"""(staffManager, headCoach, gkCoach, physio, fitnessCoach) held by the overlay.
|
|
|
|
FUN_180094ce0's STAFF_EMPLOYED row is the +0x800 SUM over stat ids 0xb..0xf, so the
|
|
parent id 0xa can never move it -- the five sub-types are what count. Zero unless a
|
|
staff flag is set, so the default panel is unchanged."""
|
|
n = {4: 0, 5: 0, 6: 0, 7: 0, 8: 0}
|
|
if MANAGERS:
|
|
import fut_staff
|
|
n[4] = len(fut_staff.STARTER_MANAGERS)
|
|
if COACHES:
|
|
import fut_coaches
|
|
for it in fut_coaches.starter_coaches(fut_coaches.COACH_ID_BASE):
|
|
n[it["cardsubtypeid"]] = n.get(it["cardsubtypeid"], 0) + 1
|
|
return n
|
|
|
|
|
|
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)
|
|
if sweep_window():
|
|
return 200, {"itemData": sweep_items()}
|
|
items = STORE.items()
|
|
|
|
# HONOUR ?type=. This route ignored it, so the STAFF tab -- which asks for
|
|
# type=manager, observed live 2026-08-04 -- was answered with the player list
|
|
# and displayed footballers as coaching staff.
|
|
#
|
|
# Filtering is deliberately NARROW. `player` and `manager` are the two values
|
|
# actually observed; `custom` (the by-league and by-team drill-downs) and a
|
|
# missing type keep exactly the behaviour that is already live-proven on
|
|
# screen, because the drill-down counts were only just fixed and this must not
|
|
# disturb them. An unrecognised type is treated like manager -- filtered, not
|
|
# unfiltered -- since answering an unknown question with the whole player list
|
|
# is what produced this bug in the first place.
|
|
q = {}
|
|
if "?" in h.path:
|
|
for part in h.path.split("?", 1)[1].split("&"):
|
|
if "=" in part:
|
|
k, v = part.split("=", 1)
|
|
q[k] = v
|
|
kind = q.get("type", "")
|
|
|
|
# HONOUR ?team= AND ?league=. These are the CLUB DRILL-DOWNS: clicking Chelsea in
|
|
# the club panel issues team=5, clicking the Premier League issues league=13.
|
|
# They were ignored, so every drill-down was answered with the ENTIRE club and
|
|
# Cristiano Ronaldo showed up under Chelsea, Arsenal and everyone else. Reported
|
|
# live 2026-08-05. The counts on the stats panel were right all along; it was
|
|
# only the item list that was unfiltered.
|
|
has_drilldown = False
|
|
for param, field in (("team", "teamid"), ("league", "leagueId")):
|
|
raw = q.get(param)
|
|
if raw is None:
|
|
continue
|
|
try:
|
|
want = int(raw)
|
|
except ValueError:
|
|
continue
|
|
has_drilldown = True
|
|
items = [i for i in items if i.get(field) == want]
|
|
log(" CLUB: %s=%d -> %d item(s)" % (param, want, len(items)))
|
|
|
|
if kind and kind not in ("player", "custom"):
|
|
# cardsubtypeid 0..3 is a player (FUN_1800d8330); everything else is
|
|
# staff or a manager. The save holds no non-player items (the families
|
|
# below are served as an overlay, not granted), so this is [] today --
|
|
# an empty item list, which is the same shape the parser already accepts.
|
|
items = [i for i in items if i.get("cardsubtypeid", 0) not in (0, 1, 2, 3)]
|
|
log(" CLUB: type=%s -> %d item(s) (players filtered out)" % (kind, len(items)))
|
|
else:
|
|
# THE MIRROR FILTER. This branch -- type=player, type=custom, and an untyped
|
|
# fetch -- used to filter NOTHING, so the moment the club held a non-player
|
|
# item it would be served straight into the players tab and into the by-league
|
|
# / by-team drill-downs. A manager carries nation, leagueId and teamid, so he
|
|
# would have appeared as a footballer in exactly the MY CLUB rows that were
|
|
# only just made non-zero. Provable no-op today: all 194 items in the live save
|
|
# are cardsubtypeid 0, so this list is unchanged (verified 2026-08-05).
|
|
items = [i for i in items if i.get("cardsubtypeid", 0) in (0, 1, 2, 3)]
|
|
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))
|
|
|
|
if FAMILIES_ON:
|
|
# The tab-to-?type= binding is unobserved, so LOG EVERY ARM ASKED FOR. If a
|
|
# family tab comes back empty this line is what says whether the request even
|
|
# reached us and under which name -- the difference between "aim at another
|
|
# arm" and "nothing was asked".
|
|
overlay = _family_overlay(kind, has_drilldown)
|
|
log(" CLUB: family overlay armed (consumables=%r coaches=%r managers=%r); "
|
|
"type=%r drilldown=%s -> +%d item(s)"
|
|
% (CONSUMABLES, COACHES, MANAGERS, kind, has_drilldown, len(overlay)))
|
|
items = items + overlay
|
|
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()
|