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