Files
OpenFUT/fifa17-recon/tools/utas_server.py
T
funman300 a93a8bcdd7 fifa17-recon: decouple contract suite from the Python implementation
test_fut_contract.py no longer imports ACCOUNT from fut_account. The expected
persona comes from FUT_TEST_PERSONA_ID and the target from FUT_TEST_BASE, so the
suite now imports nothing but stdlib and talks to a server at a URL.

That is what lets these 380 checks certify ANY implementation of the reversed
spec, a future Rust openfut-core included, without replaying the reverse
engineering. The original reason for reading ACCOUNT still holds and is preserved
in the comment: suite and server must not each hold a private copy of the
constant, or the identity-consistency checks would only prove two copies matched.

Also adds pileSizeClientData(0x227) behind FUT_PILESIZES (default off). A probe
run with 16 uniquely-valued entries did NOT move the MY CLUB counter, so that
member is eliminated as its source; the code is kept for the record and flagged
off.

Docs: OPENFUT_PROJECT_REPORT.md and OPENFUT_HANDOFF.md. The report now separates
"built but untested" from "never requested by the client" -- the server log records
User-Agent, and splitting real client traffic (ProtoHttp) from this project's own
probes shows /season, /tournament, /champion, /match, /clubUser and /user/list are
at ZERO client requests. /clubUser (0 client, 93 probe) and /user/list (0 client,
180 probe) are the starkest: work was done on both assuming the client wanted them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 10:31:37 -07:00

1476 lines
76 KiB
Python
Executable File

#!/usr/bin/env python3
"""Minimal FIFA 17 UTAS/RS4 server (OpenFUT, clean-room).
CardsDLL resolves every RS4 endpoint to <base> + path, where <base> comes from
FUT_RS4_APIURL_<MODULE> / FUT_RS4_URL_<CALL> (blaze_responder_v3b.py) and path is
moduleTable[i] with %s -> "game/<sku>". Auth is POST <base>ut/auth; the response's
"sid" becomes the X-UT-SID header on every later call (CardsDLL @0x180126080).
Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
* NEVER 401/403 -> silent re-auth storm (3 retries) then ServerFatalError.
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
* [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
"""
import datetime, json, os, re, sys, http.server
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fut_seed import CLUB, SQUAD, USER_LIST, squad_summary # forged starter squad (clean-room)
from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item # profile + packs
from fut_account import ACCOUNT, validate_club # identity + club, single source
ADDR = ("127.0.0.1", 8099)
LOG = "/tmp/utas_server.log"
SID = "OPENFUT-SID-0000000000000001"
# IDENTITY NOTE: there are no PERSONA_ID / PERSONA_NAME literals in this file any
# more. They lived here, in fut_store.py, fut_seed.py, blaze_responder_v3b.py and
# lsx_responder_v2.py -- five files, seven copies of the same two values. The stack
# only works while Blaze SESS.PDTL, LSX GetProfileResponse and the UTAS
# userInfo/squad bodies all assert the SAME persona, so every one of them now reads
# fut_account.ACCOUNT. Read ACCOUNT.persona_id LIVE at call time (never snapshot it
# into a module constant) -- POST /ut/auth can adopt a different persona from the
# client's own body at runtime, and a snapshot would silently keep the old value.
# Flip to True once you want to exercise the create-club path instead.
NEW_USER = False
def now():
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def log(m):
line = "[%s] %s" % (datetime.datetime.now().strftime("%H:%M:%S"), m)
print(line, flush=True)
with open(LOG, "a") as f:
f.write(line + "\n")
# ---- payloads -------------------------------------------------------------
def auth_body(h=None):
"""POST ut/auth.
RESPONSE: only "sid" is load-bearing (parser 0x1801a2880 -> ServerCall+0x1e8).
serverTime/lastOnlineTime are atoi'd at char offsets 0,5,8,11,14,17.
REQUEST: the client TELLS us who it is and we used to throw that away. The
body is built by CardsDLL FUN_180125900 and was live-logged byte-identical on
three separate runs:
{"sku":"FFA17PCC","nucleusPersonaPlatform":"pc","nuc":33068179,
"nucleusPersonaId":33068179,"nucleusPersonaDisplayName":"CAGE",
"locale":"en-US","regionCode":"US",...}
Adopting it makes the squad personaId comparison at 0x18014659c correct BY
CONSTRUCTION instead of by matching literals: the squad parser 0x18013d1f0
stores personaId (atom 0x21b) at squad+0x38 and compares it against
FUN_18011a830()->vtbl[0x908]; on mismatch it silently builds a THROWAWAY squad
rather than erroring, so a drifted id looks like "my squad reset itself".
RECONCILIATION RULE: the wire is truth, fut_account.json is a cache. Adopt and
WARN, never refuse -- a mismatch is the NORMAL state on the first boot after a
rename. FUT_ADOPT_AUTH=0 disables adoption (documented escape hatch).
"""
if h is not None:
try:
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else None
except Exception:
body = None
if isinstance(body, dict):
before = (ACCOUNT.persona_id, ACCOUNT.persona_name)
try:
if ACCOUNT.adopt_from_auth(body):
log(" AUTH: adopted persona %s/%r (was %s/%r)"
% (ACCOUNT.persona_id, ACCOUNT.persona_name, before[0], before[1]))
# Keep the game save's identity mirror in step with ACCOUNT so
# tradepile/club readers cannot lag a session behind.
STORE.refresh_identity()
except Exception as e: # adoption must never break auth
log(" AUTH: adopt failed (%s: %s) -- keeping %s/%r"
% (type(e).__name__, e, before[0], before[1]))
return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}
def current_squad():
"""The squad the client should see: the persisted one (item refs re-embedded
from the club) or the seed ladder squad on first run.
personaId is FORCED to ACCOUNT.persona_id: SquadLoad compares it against the
logged-in persona at 0x18014659c and, on mismatch, takes the vtable[0x4f0]
branch and builds a throwaway squad instead of adopting ours.
"""
saved = STORE.active_squad()
# Overlay the saved squad on the seed envelope: FIFA's PUT body carries only
# what it changed, so this keeps a valid custom(0xc6) 33-int string, kicktakers
# and squadName present on reload instead of silently dropping them.
sq = dict(SQUAD)
if saved:
sq.update(STORE.reconstruct_squad(saved))
sq["personaId"] = ACCOUNT.persona_id
sq.setdefault("id", 0)
return sq
def squad_list_body(squad=None):
"""FutSquadListServerResponse body (deser 0x180172140 -> value parser
0x180142260 -> element 0x180141fc0). ONE key, "squad"(0x2cd), holding an ARRAY
of squad summaries. This is the same parser userInfo.squadList goes through."""
return {"squad": [squad_summary(squad or current_squad())]}
# Exactly TWO branches of the userInfo deser 0x18013ec10 have side effects OUTSIDE
# the userInfo record -- they reach into the global model singleton FUN_18011a830:
# squadList(0x2d4) -> vtbl[0x480] -> +0x30, fills the squad-ROSTER model
# unopenedPacks(0x35e) -> vtbl[0x4e0](preOrderPacks + recoveredPacks)
# Every other member just fills a scalar/string slot in the record. The 2026-08-03
# crash is a NULL member inside a FIFA17.exe UI model, and userInfo had never
# actually reached the client before (massinfo was {} and /user is not called at
# boot) -- so these two newly-exercised branches are the prime suspects, and both
# are optional for coins/record. Ladder, cheapest-first:
# min = ONLY what coins/record need | safe = + the rest of the scalars
# roster (default) = +squadList (MY SQUADS) | packs = +unopenedPacks | full = both
# `roster` is live-confirmed working (hub + coins + record + MY SQUADS: 1). Only
# `unopenedPacks` remains untested, hence `full` is not the default.
#
# ROOT CAUSE, ISOLATED LIVE 2026-08-03 -- clubNameChangeAllowed(0x8f) must be FALSE.
# Two runs sent the IDENTICAL field set and differed only in that one bool:
# true -> client shows the FUT club-name prompt, then dies confirming it
# (ACCESS_VIOLATION reading 0x0 at FIFA17.exe+0x71b8651, 4/4 runs)
# false -> no prompt at all, hub loads, coins + record render
# Sending true advertises "a club-name change is available", pushing the client into
# a naming flow whose UI model we never populate. Neither side-effecting member
# (squadList / unopenedPacks) was involved -- both were already omitted in the
# crashing run. Keep this false unless the rename flow is actually implemented.
#
# 2026-08-03, LATER: the rename endpoint IS implemented now (club_rename_route
# below) and it is still NOT enough to make this bool safe -- see FUT_CLUB_RENAME.
_UI = os.environ.get("FUT_USERINFO", "roster")
# ---- FUT_CLUB_RENAME: the in-game rename experiment (DEFAULT OFF) -----------
# clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62.
#
# DEFAULT IS FALSE AND THAT IS THE PROVEN-GOOD BEHAVIOUR. Setting FUT_CLUB_RENAME=1
# is a ONE-SHOT EXPERIMENT, not a feature, and the expected outcome is still the
# 2026-08-03 crash. Do NOT read "we now serve a correct rename endpoint" as "the
# flag is safe": nothing the server sends is consumed anywhere in the crash path.
#
# The traced chain, all of it CLIENT-side after the bool:
# userInfo+0x62 -> FUN_18001a7a0: if rec[0x62]==1 { target="changeClubName";
# state=0x3f } else { target=NULL; state=2 }
# state 0x3f -> FIFA17.exe front-end flow manager -> exe-side naming screen
# on confirm -> FUT-manager vtbl+0xa70 (name validation / profanity, component
# GUID 0xed84b11, fetched by FUN_180009c80)
# ONLY THEN -> CardsDLL builds the request (FUN_1800824b0) and sends it via
# vtbl+0x5d0 <-- the first point our endpoint could possibly
# matter, and the crash happens BEFORE it.
# That matches the observed signature exactly: ACCESS_VIOLATION reading 0x0 at
# FIFA17.exe+0x71b8651, 4/4 runs, no CardsDLL frame on the stack, no HTTP request
# in flight. The crash site itself could NOT be identified statically -- FIFA17.exe
# is Denuvo-packed, RVA 0x71b8651 lands in the encrypted .data blob (file offset
# 0x3499e51, high entropy), and the reported bytes `49 8B 41 28 4C 8B 08` occur
# ZERO times in the whole 224 MB file. The leading theory (vtbl+0xa70 is an EA
# text-filter service that is NULL offline) is a HYPOTHESIS, not a finding.
#
# THE SAFE WAY TO RENAME YOUR CLUB IS OFFLINE, and it works today:
# python3 tools/fut_account.py --club-name 'Real OpenFUT' --club-abbr ROF
# ./openfut-fut.sh restart
# INSTANT FALLBACK if you do try the flag: `unset FUT_CLUB_RENAME`, restart, the
# hub is immediately back. Fixing it for real needs a live /proc/PID/mem dump
# around 0x1471b8651 (Wine maps the PE flat at 0x140000000) -- a separate task.
_CLUB_RENAME = os.environ.get("FUT_CLUB_RENAME") == "1"
# ---- FUT_CLUB_IDENTITY ladder ----------------------------------------------
# Which club/gamertag-identity bodies we serve. Same convention as FUT_MASSINFO /
# FUT_USERINFO: each rung is one more change class, so a live regression bisects
# in one step.
# off -> DEFAULT: pre-2026-08-03 behaviour, /clubUser and /user/list stub out
# route -> /clubUser serves {"user":[...]} and /user/list serves the club-info
# body.
# massinfo -> ALSO injects clubUser into the userMassInfo body. massinfo is
# boot-critical and adding a member to it is exactly the change class
# behind the last two live regressions; FUT_MASSINFO=squad remains the
# instant known-good fallback.
#
# WHY `off` IS THE DEFAULT (corrected after review, 2026-08-03): this ladder was
# first shipped defaulting to `route` on the argument that these are "NEW ROUTES, so
# nothing that works today changes shape". The live log refutes the premise --
# EVERY request to /clubUser (22) and /user/list (39) in /tmp/utas_server.log is
# dated 21:xx, i.e. the contract suite hitting them. The real client, across five
# full sessions between 17:00 and 20:45, requested NEITHER. So populating them buys
# nothing observable and hands the client two response bodies it has never parsed --
# the exact change class that produced today's two live regressions. Turn a rung on
# only when the log shows the client actually asking.
_CLUB_ID = os.environ.get("FUT_CLUB_IDENTITY", "off")
def user_info():
# Deserializer 0x18013EC10; every member optional (unknown key ids are
# skipped via 0x180135FF0), so {} also parses.
# Now that massinfo is populated, this record actually reaches the hub -- read
# the live profile instead of the old hardcoded placeholders.
p = STORE.profile()
rec = p.get("record", {})
info = {
# Identity/club come from ACCOUNT, never from the save: the save is only a
# mirror (fut_store._sync_identity) and must not be able to disagree with
# what Blaze PDTL / LSX GetProfileResponse assert for the same session.
"personaId": ACCOUNT.persona_id,
"clubName": ACCOUNT.club_name, "clubAbbr": ACCOUNT.club_abbr,
# established(0x110) is a STRING of digits: deser 0x18013ec10 case 0x110
# takes the STRING getter then strtol base 10 into rec+0x64. An int on the
# wire here is the scalar/string mismatch class that busy-loops the SAX
# reader at 0x1801c7f1a. ACCOUNT.established is str-typed for this reason.
"established": ACCOUNT.established,
# accountCreatedPlatformName(0x6), stored at userInfo+0x42: the only string
# 0x18013ec10 consumes that we used to omit (clubAbbr 0x8d / clubName 0x8e /
# established 0x110 were already covered). Sending it is correct, but note
# it was NOT the cause of the create-club crash -- that was still identical
# with this field present. Value MUST match the auth body's
# nucleusPersonaPlatform, which is why it reads the locked wire constant.
"accountCreatedPlatformName": ACCOUNT.PLATFORM,
# The userInfo currency-element parser FUN_180138bd0 reads name(0x1d0),
# funds(0x134), finalFunds(0x124), active(0xa) -- there is NO "value" key,
# so the old {"name","value"} pairs were parsed as 0 and the hub showed 0
# coins. The caller then matches name against "coins"/"points" literally.
"currencies": [
{"name": "coins", "funds": STORE.coins(),
"finalFunds": STORE.coins(), "active": True},
{"name": "points", "funds": p.get("points", 0),
"finalFunds": p.get("points", 0), "active": True},
],
# record: won(0x387) draw(0xe6) loss(0x1a6), all scalar slots in the record
"won": rec.get("won", 0), "draw": rec.get("draw", 0), "loss": rec.get("loss", 0),
}
# Everything below is optional for coins/record. `min` stops here.
if _UI != "min":
info.update({
# clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62. FALSE unless
# FUT_CLUB_RENAME=1: True is the isolated root cause of the 2026-08-03
# create-club crash and serving the rename endpoint does NOT fix it
# (the crash is upstream of the network). See the _CLUB_RENAME block.
"clubNameChangeAllowed": _CLUB_RENAME,
"divisionOffline": 10, "divisionOnline": 10,
"purchased": False, # 0x262 -> bool at +0x68
"feature": {"trade": True},
"reliability": {"reliability": 100, "matchUnfinishedTime": 0},
"bidTokens": {"count": 0, "updateTime": 0},
"trophies": 0, "sessionCoinsBankBalance": 0,
# actives(0xb): array of <=5 item refs (item parser 0x18013fe00). The seed
# ladder squad has none yet -> empty array (arrays never desync).
"actives": (current_squad().get("actives") or [])[:5],
})
# ---- the two side-effecting members, off by default (see _UI above) --------
if _UI in ("packs", "full"):
# unopenedPacks(0x35e): after parsing preOrderPacks(0x24b)+recoveredPacks
# (0x27b) the deser calls singleton->vtbl[0x4e0](preOrder + recovered).
info["unopenedPacks"] = {"preOrderPacks": 0, "recoveredPacks": 0}
if _UI in ("roster", "full"):
# squadList(0x2d4) -> FUN_180142260 on singleton->vtbl[0x480]+0x30, i.e. it
# fills the global squad-ROSTER model ("MY SQUADS" on the Squads screen).
# Must be an OBJECT {"squad":[...]}; a bare [] is SKIP-safe but leaves the
# roster empty. NOT required for the ACTIVE squad -- that comes from the
# massinfo `squad` member, which is already proven working.
info["squadList"] = squad_list_body()
return info
# GET ut/game/<sku>/user parser 0x180146970 does Parse + TWO NextToken calls
# before deserializing -> the object MUST be wrapped in one member. The member
# NAME is never compared, but the nesting level is required.
def user_get():
return {"userInfo": user_info()}
# POST ut/game/<sku>/user (CreateUser, 0x18014CC60) recognises exactly:
# bonusPacks(0x5d) login(0x1a5) squad(0x2cd) starterPack(0x2e5) userData(0x36d).
def user_post(h=None):
# CREATE-CLUB VARIANT: CardsDLL builder FUN_18014ca00 sends
# {useFut1Data:false, clubName, clubAbbr, purchased:false} on this same URL.
# Adopt the name the user typed so a create-club round-trips instead of the
# client seeing its own choice silently replaced by ours. Same validation and
# the same never-4xx rule as club_rename_route().
if h is not None:
_adopt_club_from_body(h, "CREATE-CLUB")
# squad(0x2cd) goes to the SAME LoadActiveSquad parser as everywhere else, so
# serve the real schema-correct squad rather than {} -- a create-club response
# carrying an empty squad leaves the client with a 0-slot squad model, which is
# precisely the state that makes AddPlayerToSquad no-op (see REBUILD_PLAN S9c).
return {"login": True, "userData": user_info(),
"squad": current_squad(), "starterPack": {}, "bonusPacks": []}
# ---- CLUB IDENTITY: the gamertag-carrying bodies ---------------------------
def club_user_body():
"""GET ut/%s/clubUser -- FutGetClubUsersServerResponse (deser 0x180145c00).
THE BUG THIS FIXES: `/ut/game/fifa17/clubUser` used to be swallowed by the
generic `(G + r"/club")` route (verified by running that compiled regex
against the real path) and answered {"itemData":[...club items...]}. The
GetClubUsers deser recognises ONLY `user`(0x36c) and SKIPs itemData, so the
club-user model was empty BY CONSTRUCTION -- there was no network-supplied
gamertag anywhere in FUT. docs/ENDPOINT_MAP.md row 2 recorded this as a GAP
but described the old response as `{}`; it was actually the itemData body.
SCHEMA: {"user":[element]}. Three scalars, all freeze-safe:
persona (0x21a) STRING, bounded-copied to 32 chars -> FUN_180008120(dst,s,0x21)
personaId(0x21b) INT64
public (0x25f) BOOL
None of them is an array/object slot, so the scalar-vs-container freeze class
(busy-loop at 0x1801c7f1a) does not apply to this body at all.
NOTE the element-parser address is recorded twice and inconsistently
(0x180145480 in ENDPOINT_MAP row 2, 0x180138b00 in the display recon). Both
derivations agree on the top-level key and on these three members, so it is a
naming/wrapper question rather than a schema one -- carried as a caveat.
"""
return {"user": [{
"persona": ACCOUNT.persona_name[:32],
"personaId": ACCOUNT.persona_id,
"public": True,
}]}
def club_info_body():
"""GET ut/%s/user/list -- the club-identity record list.
DELIBERATELY NO `name` KEY: record+0x08 is filled by the merge FUN_18011e7c0,
which matches on personaId and copies clubUser.persona in. That is exactly why
personaId MUST be byte-identical here and in club_user_body() -- if they
disagree the merge finds nothing and the name stays empty.
OMITS squadList(0x2d4) ON PURPOSE: it is routed to FUN_180142260, and a bare
array/scalar there is the 0x1801c7f1a busy-loop class. userInfo already carries
the squadList via the FUT_USERINFO ladder, so there is nothing to gain here.
"""
return {"user": [{
"personaId": ACCOUNT.persona_id,
"clubName": ACCOUNT.club_name, # 0x8e
"clubAbbr": ACCOUNT.club_abbr, # 0x8d
"established": ACCOUNT.established, # 0x110 -- STRING of digits
}]}
def club_identity_route(kind):
"""FUT_CLUB_IDENTITY=off restores the pre-fix stubs for a one-step bisect."""
if _CLUB_ID == "off":
return 200, {}
return 200, (club_user_body() if kind == "clubUser" else club_info_body())
# ---- ACCOUNTINFO (unproven; OFF by default) --------------------------------
# GET ut/%s/user/accountinfo is requested ~9x per session and we answer {}.
# It STAYS {} by default and that is a deliberate refusal, not an oversight: its
# parser is FutGetUserAccountInfoServerCallConfig, which lives inside the
# Denuvo-packed FIFA17.exe, so it CANNOT be reversed statically. Any key we invent
# has an unknown expected TYPE, and a scalar where a container is expected is
# precisely the freeze class this whole codebase is organised around avoiding.
# FUT_ACCOUNTINFO=1 serves a guessed body for a single deliberate experiment. Every
# key in it is a real atom from docs/fut_atoms.tsv and every value is a scalar --
# that bounds the risk, it does not eliminate it. Treat a freeze after enabling
# this as expected, and unset it.
_ACCOUNTINFO = os.environ.get("FUT_ACCOUNTINFO") == "1"
def accountinfo_body():
if not _ACCOUNTINFO:
return {}
return {
"userId": ACCOUNT.user_id, # 0x36f
"personaId": ACCOUNT.persona_id, # 0x21b
"persona": ACCOUNT.persona_name, # 0x21a
"name": ACCOUNT.persona_name, # 0x1d0
"email": ACCOUNT.email, # 0xf8
"country": ACCOUNT.country, # 0xbd
}
# ---- CLUB RENAME -----------------------------------------------------------
def _adopt_club_from_body(h, tag):
"""Parse {clubName(0x8e), clubAbbr(0x8d)} out of a request body, validate it
against the client's OWN limits, persist it, and mirror it into the save.
Returns True if the club changed. NEVER raises and NEVER makes the caller
answer 4xx -- see club_rename_route() for why that rule is load-bearing.
"""
try:
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
except Exception:
body = {}
if not isinstance(body, dict):
return False
name = body.get("clubName")
abbr = body.get("clubAbbr")
if name is None and abbr is None:
return False
name = ACCOUNT.club_name if name is None else name
abbr = ACCOUNT.club_abbr if abbr is None else abbr
if (name, abbr) == (ACCOUNT.club_name, ACCOUNT.club_abbr):
return False
try:
validate_club(name, abbr)
except ValueError as e:
# Rejected: keep the old club, keep serving 200. The client is told the
# transport succeeded and simply keeps rendering whatever userInfo says.
log(" %s: REJECTED %r/%r -- %s" % (tag, name, abbr, e))
return False
old = (ACCOUNT.club_name, ACCOUNT.club_abbr)
try:
ACCOUNT.set_club(name, abbr)
ACCOUNT.save() # -> tools/fut_account.json
STORE.refresh_identity() # -> mirror into fifa17_profile.json
except Exception as e:
log(" %s: FAILED to persist %r/%r (%s: %s)" % (tag, name, abbr, type(e).__name__, e))
return False
log(" %s: club %r/%r -> %r/%r (persisted)"
% (tag, old[0], old[1], ACCOUNT.club_name, ACCOUNT.club_abbr))
return True
def club_rename_route(h):
"""PUT the club rename -- FutChangeClubNameServerResponse.
RESPONSE IS `{}`, AND THAT IS COMPLETE, NOT A STUB. The struct has ZERO atoms:
vtable 0x18022cb58 slot +0x08 is 0x1801642c0, whose entire body is `return 1`
(a shared no-op deserializer also used by ActivateCard and SignLoanPlayer).
The HTTP body is fully ignored. The ONLY thing read is the transport result
code at result+0x1c (handler FUN_1800829c0):
0 -> SUCCESS (client then copies the name into userInfo rec+0x20 and
the abbr into rec+0x3e via FUN_180007f80(rec+0x3e,4,...))
0x1f -> PROFANITY
else -> FAILED
So: HTTP 200, no FUT error code, EVER.
NEVER 4xx, even on a rejected name. CardsDLL's failure reporter FUN_18016cca0
explicitly SKIPS the whole 'R4ER: DISCONNECTED' telemetry path when the status
is 200 -- answering 4xx is how a bad rename turns into a disconnect.
TWO URLs, BOTH ROUTED. docs/ENDPOINT_MAP.md row 3 derives PUT `ut/%s/club`.
The rename recon derives `ut/%s/user` (request row 0x1802cba70, urlIdx 0xb ->
template @0x18021e030) plus a per-response-class literal suffix "/club"
(appender 0x18014c740: MOV RCX,RDX; LEA RDX,[0x180225124 "/club"]; JMP
0x180008020) = `ut/game/fifa17/user/club`, and it validated that same urlIdx
column against three independently-known live URLs. Since the response is a
zero-atom ack, being wrong about which costs nothing -- so both are served and
the live log settles it.
REQUEST BODY: builder FUN_18014c590 emits exactly clubName + clubAbbr, nothing
else.
"""
if h.command in ("PUT", "POST"):
_adopt_club_from_body(h, "RENAME")
return 200, {}
# GET on the rename URL is not a known endpoint; a parseable {} is the
# cheapest correct answer (unknown keys are SKIP'd everywhere in FUT).
return 200, {}
# GET ut/game/<sku>/settings (0x18013C6D0) recognises ONE key: configs (0xa2).
SETTINGS = {"configs": []}
# GET ut/game/<sku>/userMassInfo (GetUserMassInfo, deser FUN_180174630).
#
# CORRECTED 2026-08-03 (supersedes the old "MUST BE {}" note). Full decompile of
# 0x180174630 (/tmp/ghidra_fut/massinfo.txt): the body is a FLAT object whose
# top-level keys dispatch to
# userInfo(0x370) -> 0x18013ec10 squad(0x2cd) -> 0x18013d1f0
# settings(0x2bf) -> 0x18013c6d0 userData(0x36d) -> 0x180142470
# clubUser(0x91), errors(0x10c), loanPlayers(0x19a), loanPlayerClientData(0x199),
# pileSizeClientData(0x227); everything else SKIP'd via 0x180135ff0.
# There is NO "user" wrapper (the old note was wrong -- "user" only appears nested
# inside clubUser). The prologue (2 x NextToken before the key loop) is identical
# to 0x18014cc60, the proven-flat CreateUser parser.
#
# The historical freeze is now attributed to the SQUAD member: it was fed a squad
# object built before the exact schema was known (0x18013d1f0 was only reversed on
# 2026-08-03). Every field of user_info() type-checks against 0x18013ec10, and the
# one structurally wrong field -- squadList as a bare array -- is SKIP-safe rather
# than spin-inducing. So massinfo is now served POPULATED (like EA does), which is
# what delivers the squad roster to the client at boot.
#
# LIVE STATUS 2026-08-03: `full` PARSES fine (no freeze -- the client processed it
# and issued the next request), but the client then enters FUT club-creation and
# dies confirming the club name: reproducible ACCESS_VIOLATION reading 0x0 at
# FIFA17.exe+0x71b8651 (3/3 runs). That crash site is
# mov rax,[r9+0x28] ; mov r9,[rax] <- r9->field_28 is NULL, unguarded
# in FIFA17.exe's own UI code -- no CardsDLL frame on the stack and NO request is
# sent when it happens, so it is not a response being rejected. Bisecting which
# massinfo member triggers it, one relaunch per value:
# BISECT RESULT: `squad` alone reaches the hub AND made the client issue
# PUT /squad/0 (blocker solved) -- so the crash is in the `userInfo` member.
# `full` is restored now that userInfo omits its two side-effecting members by
# default (see _UI): that is what puts coins/record back on the hub.
# INSTANT FALLBACK if the crash returns: FUT_MASSINFO=squad (known-good).
_MI = os.environ.get("FUT_MASSINFO", "full")
# ---- pileSizeClientData: the MY CLUB counter --------------------------------
# LIVE EVIDENCE (2026-08-04): the user opened MY CLUB, the client fetched GET /club
# and DISPLAYED all 99 players -- and the MY CLUB counter still read 0. So that
# counter is NOT derived from the item list; it is a PILE SIZE, delivered
# separately. massinfo's pileSizeClientData(0x227) is that member and we have never
# sent it. Parser 0x18013adb0: {"entries":[{"key":<int>,"value":<int>}]} -- key and
# value BOTH read with the int getter 0x1801c79d0, and the parser IS skip-safe.
#
# The pile-id enum is not recoverable from the strings (the "club"/"tradepile"
# literals are just atom names in the alphabetical key table). So rather than guess:
#
# FUT_PILESIZES=probe -> emit one entry per candidate key 0..15 with a UNIQUE
# recognisable value (100+key). Whatever number MY CLUB then displays names the
# club pile's key: 103 means key 3. One launch identifies the enum.
# FUT_PILESIZES=1 -> emit the REAL counts once PILE_KEY_CLUB below is known.
#
# Default OFF: this adds a member to boot-critical massinfo. It is a documented
# member of that parser and carries only ints, so the risk is low -- but "low" is
# what I said about displayGroup before it froze the store, so it ships behind a flag.
_PILESIZES = os.environ.get("FUT_PILESIZES", "")
PILE_KEY_CLUB = int(os.environ.get("FUT_PILE_KEY_CLUB", "-1")) # set once probed
def pile_size_body():
"""massinfo.pileSizeClientData -- see the note above."""
if _PILESIZES == "probe":
return {"entries": [{"key": k, "value": 100 + k} for k in range(16)]}
counts = {
"club": len(STORE.items()),
"purchased": len(STORE.purchased()),
"tradepile": len(STORE.listings()),
}
if PILE_KEY_CLUB >= 0:
return {"entries": [{"key": PILE_KEY_CLUB, "value": counts["club"]}]}
# No verified key yet -> announce the club count on every candidate key. Crude,
# but every value is truthful, so no pile can be told a wrong number.
return {"entries": [{"key": k, "value": counts["club"]} for k in range(16)]}
def massinfo():
if _MI == "empty":
return {} # old known-hub-reaching body
if _MI == "squad":
return {"squad": current_squad()}
if _MI == "userinfo":
return {"userInfo": user_info()}
if _MI == "settings":
return {"settings": SETTINGS}
body = {"userInfo": user_info(), # squadList -> roster singleton
"squad": current_squad(), # personaId == ACCOUNT.persona_id
"settings": SETTINGS,
"userData": {}}
if _CLUB_ID == "massinfo":
# clubUser(0x91) IS a recognised massinfo key (deser 0x180174630), so this
# is schema-legal -- it is opt-in only because massinfo is the boot-critical
# body and adding a member to it is the change class behind the last two
# live regressions. Instant fallback: FUT_MASSINFO=squad.
body["clubUser"] = club_user_body()
if _PILESIZES:
# pileSizeClientData(0x227) -> parser 0x18013adb0, int-only, skip-safe.
# This is what the MY CLUB counter reads (see pile_size_body above).
body["pileSizeClientData"] = pile_size_body()
return body
# ---- FUT item-definition serving (wf_e41070d8) -------------------------------
# The card view-model 0x1800d7920 renders identity/rating/face from a RESOLVED
# record at item+0x10, filled by looking the resourceId up in the FUT item-def
# store. That store is network-filled; empty offline => generic cards. FIFA
# fetches definitions from ut/<sku>/item/resource, ut/<sku>/defid, and batch
# ut/<sku>/item?idList=<ids>. We serve them here (deser 0x18013fe00, same as items).
# resourceId = playerId | version<<24 ; assetId = resourceId & 0xffffff.
PLAYER_DEFS = {
# assetId: (name, rating, position, nation, leagueId, teamid, [6 attrs])
20801: ("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80]),
}
def item_def(rid):
"""Build one FUT item-definition for a requested resourceId."""
asset = rid & 0xffffff
name, rating, pos, nation, league, team, attrs = PLAYER_DEFS.get(
asset, ("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70]))
return {
"id": rid,
"resourceId": rid,
"definitionId": rid,
"assetId": asset,
"cardassetid": asset,
"commodityId": asset,
"cardsubtypeid": 0,
"cardType": 0,
"itemType": "player",
"rareflag": 1,
"rating": rating,
"preferredPosition": pos,
"nation": nation,
"leagueId": league,
"teamid": team,
"playStyle": 250,
"attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)],
"name": name,
"commonName": name,
"lastName": name,
"itemState": "free",
"untradeable": True,
}
def defs_route(h):
# Parse every integer id out of the query string (idList=a,b,c / definitionId=x
# / resourceId=x) and return a definition for each.
q = h.path.split("?", 1)[1] if "?" in h.path else ""
ids = [int(n) for n in re.findall(r"\d{3,}", q)]
if not ids:
return 200, {"itemData": []}
return 200, {"itemData": [item_def(i) for i in ids]}
# ---- pack reveal 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,
# "tradeId":0}]}). Response echoes the FULL updated card-item(s) + chemistry
# bool (ENDPOINT_MAP item rows 6/7/10/11; itemData via deser 0x18013fe00).
# Returning [] here makes FIFA think the move failed -> kicks to main menu.
# GET stays defs_route (ViewCards / ConsumablesSearch / loan lists).
if h.command == "PUT":
try:
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
except Exception:
body = {}
req = body.get("itemData")
if isinstance(req, list):
moved = STORE.move_items(req)
if moved:
log(" ITEM: moved %d item(s) to pile(s)" % len(moved))
# 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)
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())),
(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, {})),
# 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. 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(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 -- 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
# 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. 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,
})
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}
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"])
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"])
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()