0968cd351b
Deser 0x180147070 (FutGetDraftCurrentStateServerResponse) discards tokens until it sees START_ARRAY. Handed a top-level OBJECT it never reaches its exit condition and spins in the inner `while (tok != END_OBJECT)` loop while the tokenizer returns EOF forever. Process alive, no crash dump, no dialog: exactly the signature observed live on 2026-08-03, when our generic /squad route answered this endpoint with a full active-squad object. The suffix composition `ut/%s/squad/mode` + `/draft/state?mode=...` makes the URL invisible to the request-template table, which is why /squad swallowed it. Fourth time a suffix endpoint has been invisible to that table, second time the generic /squad route has eaten one (/squad/list was the first). Verified at instruction level rather than by regex: 7 top-level atoms (4 int-getter calls, 3 string-getter, 0 bool, 2 skip) across the whole body [0x180147070, 0x1801475a3]. FUN_180135ff0 IS present, twice, so unknown keys are inert. NO ATOM COLLIDES with the squad object we were serving, which means the hang was purely the container level and not a per-field type desync. entranceCriteria(0x108) is now known to be an object of three int keys COINS/DRAFT_TOKEN/POINTS. It is OMITTED anyway: knowing a shape is not a reason to send it. A second agent independently simulated this exact body through the deserializer line by line and got a clean exit in 16 token reads, and separately refuted four claims in the first agent's report (a census undercount, a wrong .rdata address where 0x18021e7f4 is 'TFA' not the squad template, an incorrect stateParam2 typing argument, and a dangerous aside about a second array-root envelope). The body survived all of it. DEFAULT ON, a deliberate exception to "default to the live-proven value": the live-proven value here HANGS THE GAME, and there is no working screen to protect because Draft cannot be entered at all today. FUT_DRAFT_STATE=0 restores the old routing. 392 + 51 checks green; /squad/0 and /squad/list verified unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
1603 lines
83 KiB
Python
Executable File
1603 lines
83 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Minimal FIFA 17 UTAS/RS4 server (OpenFUT, clean-room).
|
|
|
|
CardsDLL resolves every RS4 endpoint to <base> + path, where <base> comes from
|
|
FUT_RS4_APIURL_<MODULE> / FUT_RS4_URL_<CALL> (blaze_responder_v3b.py) and path is
|
|
moduleTable[i] with %s -> "game/<sku>". Auth is POST <base>ut/auth; the response's
|
|
"sid" becomes the X-UT-SID header on every later call (CardsDLL @0x180126080).
|
|
|
|
Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
|
|
* NEVER 401/403 -> silent re-auth storm (3 retries) then ServerFatalError.
|
|
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
|
|
* [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
|
|
"""
|
|
import datetime, json, os, re, sys, http.server
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from fut_seed import CLUB, SQUAD, USER_LIST, squad_summary # forged starter squad (clean-room)
|
|
from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item # profile + packs
|
|
from fut_account import ACCOUNT, validate_club # identity + club, single source
|
|
|
|
ADDR = ("127.0.0.1", 8099)
|
|
LOG = "/tmp/utas_server.log"
|
|
SID = "OPENFUT-SID-0000000000000001"
|
|
# IDENTITY NOTE: there are no PERSONA_ID / PERSONA_NAME literals in this file any
|
|
# more. They lived here, in fut_store.py, fut_seed.py, blaze_responder_v3b.py and
|
|
# lsx_responder_v2.py -- five files, seven copies of the same two values. The stack
|
|
# only works while Blaze SESS.PDTL, LSX GetProfileResponse and the UTAS
|
|
# userInfo/squad bodies all assert the SAME persona, so every one of them now reads
|
|
# fut_account.ACCOUNT. Read ACCOUNT.persona_id LIVE at call time (never snapshot it
|
|
# into a module constant) -- POST /ut/auth can adopt a different persona from the
|
|
# client's own body at runtime, and a snapshot would silently keep the old value.
|
|
# Flip to True once you want to exercise the create-club path instead.
|
|
NEW_USER = False
|
|
|
|
|
|
def now():
|
|
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
|
def log(m):
|
|
line = "[%s] %s" % (datetime.datetime.now().strftime("%H:%M:%S"), m)
|
|
print(line, flush=True)
|
|
with open(LOG, "a") as f:
|
|
f.write(line + "\n")
|
|
|
|
|
|
# ---- payloads -------------------------------------------------------------
|
|
def auth_body(h=None):
|
|
"""POST ut/auth.
|
|
|
|
RESPONSE: only "sid" is load-bearing (parser 0x1801a2880 -> ServerCall+0x1e8).
|
|
serverTime/lastOnlineTime are atoi'd at char offsets 0,5,8,11,14,17.
|
|
|
|
REQUEST: the client TELLS us who it is and we used to throw that away. The
|
|
body is built by CardsDLL FUN_180125900 and was live-logged byte-identical on
|
|
three separate runs:
|
|
{"sku":"FFA17PCC","nucleusPersonaPlatform":"pc","nuc":33068179,
|
|
"nucleusPersonaId":33068179,"nucleusPersonaDisplayName":"CAGE",
|
|
"locale":"en-US","regionCode":"US",...}
|
|
Adopting it makes the squad personaId comparison at 0x18014659c correct BY
|
|
CONSTRUCTION instead of by matching literals: the squad parser 0x18013d1f0
|
|
stores personaId (atom 0x21b) at squad+0x38 and compares it against
|
|
FUN_18011a830()->vtbl[0x908]; on mismatch it silently builds a THROWAWAY squad
|
|
rather than erroring, so a drifted id looks like "my squad reset itself".
|
|
|
|
RECONCILIATION RULE: the wire is truth, fut_account.json is a cache. Adopt and
|
|
WARN, never refuse -- a mismatch is the NORMAL state on the first boot after a
|
|
rename. FUT_ADOPT_AUTH=0 disables adoption (documented escape hatch).
|
|
"""
|
|
if h is not None:
|
|
try:
|
|
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else None
|
|
except Exception:
|
|
body = None
|
|
if isinstance(body, dict):
|
|
before = (ACCOUNT.persona_id, ACCOUNT.persona_name)
|
|
try:
|
|
if ACCOUNT.adopt_from_auth(body):
|
|
log(" AUTH: adopted persona %s/%r (was %s/%r)"
|
|
% (ACCOUNT.persona_id, ACCOUNT.persona_name, before[0], before[1]))
|
|
# Keep the game save's identity mirror in step with ACCOUNT so
|
|
# tradepile/club readers cannot lag a session behind.
|
|
STORE.refresh_identity()
|
|
except Exception as e: # adoption must never break auth
|
|
log(" AUTH: adopt failed (%s: %s) -- keeping %s/%r"
|
|
% (type(e).__name__, e, before[0], before[1]))
|
|
return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}
|
|
|
|
|
|
def current_squad():
|
|
"""The squad the client should see: the persisted one (item refs re-embedded
|
|
from the club) or the seed ladder squad on first run.
|
|
|
|
personaId is FORCED to ACCOUNT.persona_id: SquadLoad compares it against the
|
|
logged-in persona at 0x18014659c and, on mismatch, takes the vtable[0x4f0]
|
|
branch and builds a throwaway squad instead of adopting ours.
|
|
"""
|
|
saved = STORE.active_squad()
|
|
# Overlay the saved squad on the seed envelope: FIFA's PUT body carries only
|
|
# what it changed, so this keeps a valid custom(0xc6) 33-int string, kicktakers
|
|
# and squadName present on reload instead of silently dropping them.
|
|
sq = dict(SQUAD)
|
|
if saved:
|
|
sq.update(STORE.reconstruct_squad(saved))
|
|
sq["personaId"] = ACCOUNT.persona_id
|
|
sq.setdefault("id", 0)
|
|
return sq
|
|
|
|
|
|
def squad_list_body(squad=None):
|
|
"""FutSquadListServerResponse body (deser 0x180172140 -> value parser
|
|
0x180142260 -> element 0x180141fc0). ONE key, "squad"(0x2cd), holding an ARRAY
|
|
of squad summaries. This is the same parser userInfo.squadList goes through."""
|
|
return {"squad": [squad_summary(squad or current_squad())]}
|
|
|
|
|
|
# Exactly TWO branches of the userInfo deser 0x18013ec10 have side effects OUTSIDE
|
|
# the userInfo record -- they reach into the global model singleton FUN_18011a830:
|
|
# squadList(0x2d4) -> vtbl[0x480] -> +0x30, fills the squad-ROSTER model
|
|
# unopenedPacks(0x35e) -> vtbl[0x4e0](preOrderPacks + recoveredPacks)
|
|
# Every other member just fills a scalar/string slot in the record. The 2026-08-03
|
|
# crash is a NULL member inside a FIFA17.exe UI model, and userInfo had never
|
|
# actually reached the client before (massinfo was {} and /user is not called at
|
|
# boot) -- so these two newly-exercised branches are the prime suspects, and both
|
|
# are optional for coins/record. Ladder, cheapest-first:
|
|
# min = ONLY what coins/record need | safe = + the rest of the scalars
|
|
# roster (default) = +squadList (MY SQUADS) | packs = +unopenedPacks | full = both
|
|
# `roster` is live-confirmed working (hub + coins + record + MY SQUADS: 1). Only
|
|
# `unopenedPacks` remains untested, hence `full` is not the default.
|
|
#
|
|
# ROOT CAUSE, ISOLATED LIVE 2026-08-03 -- clubNameChangeAllowed(0x8f) must be FALSE.
|
|
# Two runs sent the IDENTICAL field set and differed only in that one bool:
|
|
# true -> client shows the FUT club-name prompt, then dies confirming it
|
|
# (ACCESS_VIOLATION reading 0x0 at FIFA17.exe+0x71b8651, 4/4 runs)
|
|
# false -> no prompt at all, hub loads, coins + record render
|
|
# Sending true advertises "a club-name change is available", pushing the client into
|
|
# a naming flow whose UI model we never populate. Neither side-effecting member
|
|
# (squadList / unopenedPacks) was involved -- both were already omitted in the
|
|
# crashing run. Keep this false unless the rename flow is actually implemented.
|
|
#
|
|
# 2026-08-03, LATER: the rename endpoint IS implemented now (club_rename_route
|
|
# below) and it is still NOT enough to make this bool safe -- see FUT_CLUB_RENAME.
|
|
_UI = os.environ.get("FUT_USERINFO", "roster")
|
|
|
|
# ---- FUT_CLUB_RENAME: the in-game rename experiment (DEFAULT OFF) -----------
|
|
# clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62.
|
|
#
|
|
# DEFAULT IS FALSE AND THAT IS THE PROVEN-GOOD BEHAVIOUR. Setting FUT_CLUB_RENAME=1
|
|
# is a ONE-SHOT EXPERIMENT, not a feature, and the expected outcome is still the
|
|
# 2026-08-03 crash. Do NOT read "we now serve a correct rename endpoint" as "the
|
|
# flag is safe": nothing the server sends is consumed anywhere in the crash path.
|
|
#
|
|
# The traced chain, all of it CLIENT-side after the bool:
|
|
# userInfo+0x62 -> FUN_18001a7a0: if rec[0x62]==1 { target="changeClubName";
|
|
# state=0x3f } else { target=NULL; state=2 }
|
|
# state 0x3f -> FIFA17.exe front-end flow manager -> exe-side naming screen
|
|
# on confirm -> FUT-manager vtbl+0xa70 (name validation / profanity, component
|
|
# GUID 0xed84b11, fetched by FUN_180009c80)
|
|
# ONLY THEN -> CardsDLL builds the request (FUN_1800824b0) and sends it via
|
|
# vtbl+0x5d0 <-- the first point our endpoint could possibly
|
|
# matter, and the crash happens BEFORE it.
|
|
# That matches the observed signature exactly: ACCESS_VIOLATION reading 0x0 at
|
|
# FIFA17.exe+0x71b8651, 4/4 runs, no CardsDLL frame on the stack, no HTTP request
|
|
# in flight. The crash site itself could NOT be identified statically -- FIFA17.exe
|
|
# is Denuvo-packed, RVA 0x71b8651 lands in the encrypted .data blob (file offset
|
|
# 0x3499e51, high entropy), and the reported bytes `49 8B 41 28 4C 8B 08` occur
|
|
# ZERO times in the whole 224 MB file. The leading theory (vtbl+0xa70 is an EA
|
|
# text-filter service that is NULL offline) is a HYPOTHESIS, not a finding.
|
|
#
|
|
# THE SAFE WAY TO RENAME YOUR CLUB IS OFFLINE, and it works today:
|
|
# python3 tools/fut_account.py --club-name 'Real OpenFUT' --club-abbr ROF
|
|
# ./openfut-fut.sh restart
|
|
# INSTANT FALLBACK if you do try the flag: `unset FUT_CLUB_RENAME`, restart, the
|
|
# hub is immediately back. Fixing it for real needs a live /proc/PID/mem dump
|
|
# around 0x1471b8651 (Wine maps the PE flat at 0x140000000) -- a separate task.
|
|
_CLUB_RENAME = os.environ.get("FUT_CLUB_RENAME") == "1"
|
|
|
|
# ---- FUT_CLUB_IDENTITY ladder ----------------------------------------------
|
|
# Which club/gamertag-identity bodies we serve. Same convention as FUT_MASSINFO /
|
|
# FUT_USERINFO: each rung is one more change class, so a live regression bisects
|
|
# in one step.
|
|
# off -> DEFAULT: pre-2026-08-03 behaviour, /clubUser and /user/list stub out
|
|
# route -> /clubUser serves {"user":[...]} and /user/list serves the club-info
|
|
# body.
|
|
# massinfo -> ALSO injects clubUser into the userMassInfo body. massinfo is
|
|
# boot-critical and adding a member to it is exactly the change class
|
|
# behind the last two live regressions; FUT_MASSINFO=squad remains the
|
|
# instant known-good fallback.
|
|
#
|
|
# WHY `off` IS THE DEFAULT (corrected after review, 2026-08-03): this ladder was
|
|
# first shipped defaulting to `route` on the argument that these are "NEW ROUTES, so
|
|
# nothing that works today changes shape". The live log refutes the premise --
|
|
# EVERY request to /clubUser (22) and /user/list (39) in /tmp/utas_server.log is
|
|
# dated 21:xx, i.e. the contract suite hitting them. The real client, across five
|
|
# full sessions between 17:00 and 20:45, requested NEITHER. So populating them buys
|
|
# nothing observable and hands the client two response bodies it has never parsed --
|
|
# the exact change class that produced today's two live regressions. Turn a rung on
|
|
# only when the log shows the client actually asking.
|
|
_CLUB_ID = os.environ.get("FUT_CLUB_IDENTITY", "off")
|
|
|
|
|
|
def user_info():
|
|
# Deserializer 0x18013EC10; every member optional (unknown key ids are
|
|
# skipped via 0x180135FF0), so {} also parses.
|
|
# Now that massinfo is populated, this record actually reaches the hub -- read
|
|
# the live profile instead of the old hardcoded placeholders.
|
|
p = STORE.profile()
|
|
rec = p.get("record", {})
|
|
info = {
|
|
# Identity/club come from ACCOUNT, never from the save: the save is only a
|
|
# mirror (fut_store._sync_identity) and must not be able to disagree with
|
|
# what Blaze PDTL / LSX GetProfileResponse assert for the same session.
|
|
"personaId": ACCOUNT.persona_id,
|
|
"clubName": ACCOUNT.club_name, "clubAbbr": ACCOUNT.club_abbr,
|
|
# established(0x110) is a STRING of digits: deser 0x18013ec10 case 0x110
|
|
# takes the STRING getter then strtol base 10 into rec+0x64. An int on the
|
|
# wire here is the scalar/string mismatch class that busy-loops the SAX
|
|
# reader at 0x1801c7f1a. ACCOUNT.established is str-typed for this reason.
|
|
"established": ACCOUNT.established,
|
|
# accountCreatedPlatformName(0x6), stored at userInfo+0x42: the only string
|
|
# 0x18013ec10 consumes that we used to omit (clubAbbr 0x8d / clubName 0x8e /
|
|
# established 0x110 were already covered). Sending it is correct, but note
|
|
# it was NOT the cause of the create-club crash -- that was still identical
|
|
# with this field present. Value MUST match the auth body's
|
|
# nucleusPersonaPlatform, which is why it reads the locked wire constant.
|
|
"accountCreatedPlatformName": ACCOUNT.PLATFORM,
|
|
# The userInfo currency-element parser FUN_180138bd0 reads name(0x1d0),
|
|
# funds(0x134), finalFunds(0x124), active(0xa) -- there is NO "value" key,
|
|
# so the old {"name","value"} pairs were parsed as 0 and the hub showed 0
|
|
# coins. The caller then matches name against "coins"/"points" literally.
|
|
"currencies": [
|
|
{"name": "coins", "funds": STORE.coins(),
|
|
"finalFunds": STORE.coins(), "active": True},
|
|
{"name": "points", "funds": p.get("points", 0),
|
|
"finalFunds": p.get("points", 0), "active": True},
|
|
],
|
|
# record: won(0x387) draw(0xe6) loss(0x1a6), all scalar slots in the record
|
|
"won": rec.get("won", 0), "draw": rec.get("draw", 0), "loss": rec.get("loss", 0),
|
|
}
|
|
# Everything below is optional for coins/record. `min` stops here.
|
|
if _UI != "min":
|
|
info.update({
|
|
# clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62. FALSE unless
|
|
# FUT_CLUB_RENAME=1: True is the isolated root cause of the 2026-08-03
|
|
# create-club crash and serving the rename endpoint does NOT fix it
|
|
# (the crash is upstream of the network). See the _CLUB_RENAME block.
|
|
"clubNameChangeAllowed": _CLUB_RENAME,
|
|
"divisionOffline": 10, "divisionOnline": 10,
|
|
"purchased": False, # 0x262 -> bool at +0x68
|
|
"feature": {"trade": True},
|
|
"reliability": {"reliability": 100, "matchUnfinishedTime": 0},
|
|
"bidTokens": {"count": 0, "updateTime": 0},
|
|
"trophies": 0, "sessionCoinsBankBalance": 0,
|
|
# actives(0xb): array of <=5 item refs (item parser 0x18013fe00). The seed
|
|
# ladder squad has none yet -> empty array (arrays never desync).
|
|
"actives": (current_squad().get("actives") or [])[:5],
|
|
})
|
|
# ---- the two side-effecting members, off by default (see _UI above) --------
|
|
if _UI in ("packs", "full"):
|
|
# unopenedPacks(0x35e): after parsing preOrderPacks(0x24b)+recoveredPacks
|
|
# (0x27b) the deser calls singleton->vtbl[0x4e0](preOrder + recovered).
|
|
info["unopenedPacks"] = {"preOrderPacks": 0, "recoveredPacks": 0}
|
|
if _UI in ("roster", "full"):
|
|
# squadList(0x2d4) -> FUN_180142260 on singleton->vtbl[0x480]+0x30, i.e. it
|
|
# fills the global squad-ROSTER model ("MY SQUADS" on the Squads screen).
|
|
# Must be an OBJECT {"squad":[...]}; a bare [] is SKIP-safe but leaves the
|
|
# roster empty. NOT required for the ACTIVE squad -- that comes from the
|
|
# massinfo `squad` member, which is already proven working.
|
|
info["squadList"] = squad_list_body()
|
|
return info
|
|
|
|
|
|
# GET ut/game/<sku>/user parser 0x180146970 does Parse + TWO NextToken calls
|
|
# before deserializing -> the object MUST be wrapped in one member. The member
|
|
# NAME is never compared, but the nesting level is required.
|
|
def user_get():
|
|
return {"userInfo": user_info()}
|
|
|
|
|
|
# POST ut/game/<sku>/user (CreateUser, 0x18014CC60) recognises exactly:
|
|
# bonusPacks(0x5d) login(0x1a5) squad(0x2cd) starterPack(0x2e5) userData(0x36d).
|
|
def user_post(h=None):
|
|
# CREATE-CLUB VARIANT: CardsDLL builder FUN_18014ca00 sends
|
|
# {useFut1Data:false, clubName, clubAbbr, purchased:false} on this same URL.
|
|
# Adopt the name the user typed so a create-club round-trips instead of the
|
|
# client seeing its own choice silently replaced by ours. Same validation and
|
|
# the same never-4xx rule as club_rename_route().
|
|
if h is not None:
|
|
_adopt_club_from_body(h, "CREATE-CLUB")
|
|
# squad(0x2cd) goes to the SAME LoadActiveSquad parser as everywhere else, so
|
|
# serve the real schema-correct squad rather than {} -- a create-club response
|
|
# carrying an empty squad leaves the client with a 0-slot squad model, which is
|
|
# precisely the state that makes AddPlayerToSquad no-op (see REBUILD_PLAN S9c).
|
|
return {"login": True, "userData": user_info(),
|
|
"squad": current_squad(), "starterPack": {}, "bonusPacks": []}
|
|
|
|
|
|
# ---- CLUB IDENTITY: the gamertag-carrying bodies ---------------------------
|
|
def club_user_body():
|
|
"""GET ut/%s/clubUser -- FutGetClubUsersServerResponse (deser 0x180145c00).
|
|
|
|
THE BUG THIS FIXES: `/ut/game/fifa17/clubUser` used to be swallowed by the
|
|
generic `(G + r"/club")` route (verified by running that compiled regex
|
|
against the real path) and answered {"itemData":[...club items...]}. The
|
|
GetClubUsers deser recognises ONLY `user`(0x36c) and SKIPs itemData, so the
|
|
club-user model was empty BY CONSTRUCTION -- there was no network-supplied
|
|
gamertag anywhere in FUT. docs/ENDPOINT_MAP.md row 2 recorded this as a GAP
|
|
but described the old response as `{}`; it was actually the itemData body.
|
|
|
|
SCHEMA: {"user":[element]}. Three scalars, all freeze-safe:
|
|
persona (0x21a) STRING, bounded-copied to 32 chars -> FUN_180008120(dst,s,0x21)
|
|
personaId(0x21b) INT64
|
|
public (0x25f) BOOL
|
|
None of them is an array/object slot, so the scalar-vs-container freeze class
|
|
(busy-loop at 0x1801c7f1a) does not apply to this body at all.
|
|
|
|
NOTE the element-parser address is recorded twice and inconsistently
|
|
(0x180145480 in ENDPOINT_MAP row 2, 0x180138b00 in the display recon). Both
|
|
derivations agree on the top-level key and on these three members, so it is a
|
|
naming/wrapper question rather than a schema one -- carried as a caveat.
|
|
"""
|
|
return {"user": [{
|
|
"persona": ACCOUNT.persona_name[:32],
|
|
"personaId": ACCOUNT.persona_id,
|
|
"public": True,
|
|
}]}
|
|
|
|
|
|
def club_info_body():
|
|
"""GET ut/%s/user/list -- the club-identity record list.
|
|
|
|
DELIBERATELY NO `name` KEY: record+0x08 is filled by the merge FUN_18011e7c0,
|
|
which matches on personaId and copies clubUser.persona in. That is exactly why
|
|
personaId MUST be byte-identical here and in club_user_body() -- if they
|
|
disagree the merge finds nothing and the name stays empty.
|
|
|
|
OMITS squadList(0x2d4) ON PURPOSE: it is routed to FUN_180142260, and a bare
|
|
array/scalar there is the 0x1801c7f1a busy-loop class. userInfo already carries
|
|
the squadList via the FUT_USERINFO ladder, so there is nothing to gain here.
|
|
"""
|
|
return {"user": [{
|
|
"personaId": ACCOUNT.persona_id,
|
|
"clubName": ACCOUNT.club_name, # 0x8e
|
|
"clubAbbr": ACCOUNT.club_abbr, # 0x8d
|
|
"established": ACCOUNT.established, # 0x110 -- STRING of digits
|
|
}]}
|
|
|
|
|
|
def club_identity_route(kind):
|
|
"""FUT_CLUB_IDENTITY=off restores the pre-fix stubs for a one-step bisect."""
|
|
if _CLUB_ID == "off":
|
|
return 200, {}
|
|
return 200, (club_user_body() if kind == "clubUser" else club_info_body())
|
|
|
|
|
|
# ---- ACCOUNTINFO (unproven; OFF by default) --------------------------------
|
|
# GET ut/%s/user/accountinfo is requested ~9x per session and we answer {}.
|
|
# It STAYS {} by default and that is a deliberate refusal, not an oversight: its
|
|
# parser is FutGetUserAccountInfoServerCallConfig, which lives inside the
|
|
# Denuvo-packed FIFA17.exe, so it CANNOT be reversed statically. Any key we invent
|
|
# has an unknown expected TYPE, and a scalar where a container is expected is
|
|
# precisely the freeze class this whole codebase is organised around avoiding.
|
|
# FUT_ACCOUNTINFO=1 serves a guessed body for a single deliberate experiment. Every
|
|
# key in it is a real atom from docs/fut_atoms.tsv and every value is a scalar --
|
|
# that bounds the risk, it does not eliminate it. Treat a freeze after enabling
|
|
# this as expected, and unset it.
|
|
_ACCOUNTINFO = os.environ.get("FUT_ACCOUNTINFO") == "1"
|
|
|
|
|
|
def accountinfo_body():
|
|
if not _ACCOUNTINFO:
|
|
return {}
|
|
return {
|
|
"userId": ACCOUNT.user_id, # 0x36f
|
|
"personaId": ACCOUNT.persona_id, # 0x21b
|
|
"persona": ACCOUNT.persona_name, # 0x21a
|
|
"name": ACCOUNT.persona_name, # 0x1d0
|
|
"email": ACCOUNT.email, # 0xf8
|
|
"country": ACCOUNT.country, # 0xbd
|
|
}
|
|
|
|
|
|
# ---- CLUB RENAME -----------------------------------------------------------
|
|
def _adopt_club_from_body(h, tag):
|
|
"""Parse {clubName(0x8e), clubAbbr(0x8d)} out of a request body, validate it
|
|
against the client's OWN limits, persist it, and mirror it into the save.
|
|
|
|
Returns True if the club changed. NEVER raises and NEVER makes the caller
|
|
answer 4xx -- see club_rename_route() for why that rule is load-bearing.
|
|
"""
|
|
try:
|
|
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
|
|
except Exception:
|
|
body = {}
|
|
if not isinstance(body, dict):
|
|
return False
|
|
name = body.get("clubName")
|
|
abbr = body.get("clubAbbr")
|
|
if name is None and abbr is None:
|
|
return False
|
|
name = ACCOUNT.club_name if name is None else name
|
|
abbr = ACCOUNT.club_abbr if abbr is None else abbr
|
|
if (name, abbr) == (ACCOUNT.club_name, ACCOUNT.club_abbr):
|
|
return False
|
|
try:
|
|
validate_club(name, abbr)
|
|
except ValueError as e:
|
|
# Rejected: keep the old club, keep serving 200. The client is told the
|
|
# transport succeeded and simply keeps rendering whatever userInfo says.
|
|
log(" %s: REJECTED %r/%r -- %s" % (tag, name, abbr, e))
|
|
return False
|
|
old = (ACCOUNT.club_name, ACCOUNT.club_abbr)
|
|
try:
|
|
ACCOUNT.set_club(name, abbr)
|
|
ACCOUNT.save() # -> tools/fut_account.json
|
|
STORE.refresh_identity() # -> mirror into fifa17_profile.json
|
|
except Exception as e:
|
|
log(" %s: FAILED to persist %r/%r (%s: %s)" % (tag, name, abbr, type(e).__name__, e))
|
|
return False
|
|
log(" %s: club %r/%r -> %r/%r (persisted)"
|
|
% (tag, old[0], old[1], ACCOUNT.club_name, ACCOUNT.club_abbr))
|
|
return True
|
|
|
|
|
|
def club_rename_route(h):
|
|
"""PUT the club rename -- FutChangeClubNameServerResponse.
|
|
|
|
RESPONSE IS `{}`, AND THAT IS COMPLETE, NOT A STUB. The struct has ZERO atoms:
|
|
vtable 0x18022cb58 slot +0x08 is 0x1801642c0, whose entire body is `return 1`
|
|
(a shared no-op deserializer also used by ActivateCard and SignLoanPlayer).
|
|
The HTTP body is fully ignored. The ONLY thing read is the transport result
|
|
code at result+0x1c (handler FUN_1800829c0):
|
|
0 -> SUCCESS (client then copies the name into userInfo rec+0x20 and
|
|
the abbr into rec+0x3e via FUN_180007f80(rec+0x3e,4,...))
|
|
0x1f -> PROFANITY
|
|
else -> FAILED
|
|
So: HTTP 200, no FUT error code, EVER.
|
|
|
|
NEVER 4xx, even on a rejected name. CardsDLL's failure reporter FUN_18016cca0
|
|
explicitly SKIPS the whole 'R4ER: DISCONNECTED' telemetry path when the status
|
|
is 200 -- answering 4xx is how a bad rename turns into a disconnect.
|
|
|
|
TWO URLs, BOTH ROUTED. docs/ENDPOINT_MAP.md row 3 derives PUT `ut/%s/club`.
|
|
The rename recon derives `ut/%s/user` (request row 0x1802cba70, urlIdx 0xb ->
|
|
template @0x18021e030) plus a per-response-class literal suffix "/club"
|
|
(appender 0x18014c740: MOV RCX,RDX; LEA RDX,[0x180225124 "/club"]; JMP
|
|
0x180008020) = `ut/game/fifa17/user/club`, and it validated that same urlIdx
|
|
column against three independently-known live URLs. Since the response is a
|
|
zero-atom ack, being wrong about which costs nothing -- so both are served and
|
|
the live log settles it.
|
|
|
|
REQUEST BODY: builder FUN_18014c590 emits exactly clubName + clubAbbr, nothing
|
|
else.
|
|
"""
|
|
if h.command in ("PUT", "POST"):
|
|
_adopt_club_from_body(h, "RENAME")
|
|
return 200, {}
|
|
# GET on the rename URL is not a known endpoint; a parseable {} is the
|
|
# cheapest correct answer (unknown keys are SKIP'd everywhere in FUT).
|
|
return 200, {}
|
|
|
|
|
|
# GET ut/game/<sku>/settings (0x18013C6D0) recognises ONE key: configs (0xa2).
|
|
SETTINGS = {"configs": []}
|
|
|
|
# GET ut/game/<sku>/userMassInfo (GetUserMassInfo, deser FUN_180174630).
|
|
#
|
|
# CORRECTED 2026-08-03 (supersedes the old "MUST BE {}" note). Full decompile of
|
|
# 0x180174630 (/tmp/ghidra_fut/massinfo.txt): the body is a FLAT object whose
|
|
# top-level keys dispatch to
|
|
# userInfo(0x370) -> 0x18013ec10 squad(0x2cd) -> 0x18013d1f0
|
|
# settings(0x2bf) -> 0x18013c6d0 userData(0x36d) -> 0x180142470
|
|
# clubUser(0x91), errors(0x10c), loanPlayers(0x19a), loanPlayerClientData(0x199),
|
|
# pileSizeClientData(0x227); everything else SKIP'd via 0x180135ff0.
|
|
# There is NO "user" wrapper (the old note was wrong -- "user" only appears nested
|
|
# inside clubUser). The prologue (2 x NextToken before the key loop) is identical
|
|
# to 0x18014cc60, the proven-flat CreateUser parser.
|
|
#
|
|
# The historical freeze is now attributed to the SQUAD member: it was fed a squad
|
|
# object built before the exact schema was known (0x18013d1f0 was only reversed on
|
|
# 2026-08-03). Every field of user_info() type-checks against 0x18013ec10, and the
|
|
# one structurally wrong field -- squadList as a bare array -- is SKIP-safe rather
|
|
# than spin-inducing. So massinfo is now served POPULATED (like EA does), which is
|
|
# what delivers the squad roster to the client at boot.
|
|
#
|
|
# LIVE STATUS 2026-08-03: `full` PARSES fine (no freeze -- the client processed it
|
|
# and issued the next request), but the client then enters FUT club-creation and
|
|
# dies confirming the club name: reproducible ACCESS_VIOLATION reading 0x0 at
|
|
# FIFA17.exe+0x71b8651 (3/3 runs). That crash site is
|
|
# mov rax,[r9+0x28] ; mov r9,[rax] <- r9->field_28 is NULL, unguarded
|
|
# in FIFA17.exe's own UI code -- no CardsDLL frame on the stack and NO request is
|
|
# sent when it happens, so it is not a response being rejected. Bisecting which
|
|
# massinfo member triggers it, one relaunch per value:
|
|
# BISECT RESULT: `squad` alone reaches the hub AND made the client issue
|
|
# PUT /squad/0 (blocker solved) -- so the crash is in the `userInfo` member.
|
|
# `full` is restored now that userInfo omits its two side-effecting members by
|
|
# default (see _UI): that is what puts coins/record back on the hub.
|
|
# INSTANT FALLBACK if the crash returns: FUT_MASSINFO=squad (known-good).
|
|
_MI = os.environ.get("FUT_MASSINFO", "full")
|
|
|
|
|
|
# ---- pileSizeClientData: the MY CLUB counter --------------------------------
|
|
# LIVE EVIDENCE (2026-08-04): the user opened MY CLUB, the client fetched GET /club
|
|
# and DISPLAYED all 99 players -- and the MY CLUB counter still read 0. So that
|
|
# counter is NOT derived from the item list; it is a PILE SIZE, delivered
|
|
# separately. massinfo's pileSizeClientData(0x227) is that member and we have never
|
|
# sent it. Parser 0x18013adb0: {"entries":[{"key":<int>,"value":<int>}]} -- key and
|
|
# value BOTH read with the int getter 0x1801c79d0, and the parser IS skip-safe.
|
|
#
|
|
# The pile-id enum is not recoverable from the strings (the "club"/"tradepile"
|
|
# literals are just atom names in the alphabetical key table). So rather than guess:
|
|
#
|
|
# FUT_PILESIZES=probe -> emit one entry per candidate key 0..15 with a UNIQUE
|
|
# recognisable value (100+key). Whatever number MY CLUB then displays names the
|
|
# club pile's key: 103 means key 3. One launch identifies the enum.
|
|
# FUT_PILESIZES=1 -> emit the REAL counts once PILE_KEY_CLUB below is known.
|
|
#
|
|
# Default OFF: this adds a member to boot-critical massinfo. It is a documented
|
|
# member of that parser and carries only ints, so the risk is low -- but "low" is
|
|
# what I said about displayGroup before it froze the store, so it ships behind a flag.
|
|
_PILESIZES = os.environ.get("FUT_PILESIZES", "")
|
|
PILE_KEY_CLUB = int(os.environ.get("FUT_PILE_KEY_CLUB", "-1")) # set once probed
|
|
|
|
|
|
def pile_size_body():
|
|
"""massinfo.pileSizeClientData -- see the note above."""
|
|
if _PILESIZES == "probe":
|
|
return {"entries": [{"key": k, "value": 100 + k} for k in range(16)]}
|
|
counts = {
|
|
"club": len(STORE.items()),
|
|
"purchased": len(STORE.purchased()),
|
|
"tradepile": len(STORE.listings()),
|
|
}
|
|
if PILE_KEY_CLUB >= 0:
|
|
return {"entries": [{"key": PILE_KEY_CLUB, "value": counts["club"]}]}
|
|
# No verified key yet -> announce the club count on every candidate key. Crude,
|
|
# but every value is truthful, so no pile can be told a wrong number.
|
|
return {"entries": [{"key": k, "value": counts["club"]} for k in range(16)]}
|
|
|
|
|
|
def massinfo():
|
|
if _MI == "empty":
|
|
return {} # old known-hub-reaching body
|
|
if _MI == "squad":
|
|
return {"squad": current_squad()}
|
|
if _MI == "userinfo":
|
|
return {"userInfo": user_info()}
|
|
if _MI == "settings":
|
|
return {"settings": SETTINGS}
|
|
body = {"userInfo": user_info(), # squadList -> roster singleton
|
|
"squad": current_squad(), # personaId == ACCOUNT.persona_id
|
|
"settings": SETTINGS,
|
|
"userData": {}}
|
|
if _CLUB_ID == "massinfo":
|
|
# clubUser(0x91) IS a recognised massinfo key (deser 0x180174630), so this
|
|
# is schema-legal -- it is opt-in only because massinfo is the boot-critical
|
|
# body and adding a member to it is the change class behind the last two
|
|
# live regressions. Instant fallback: FUT_MASSINFO=squad.
|
|
body["clubUser"] = club_user_body()
|
|
if _PILESIZES:
|
|
# pileSizeClientData(0x227) -> parser 0x18013adb0, int-only, skip-safe.
|
|
# This is what the MY CLUB counter reads (see pile_size_body above).
|
|
body["pileSizeClientData"] = pile_size_body()
|
|
return body
|
|
|
|
# ---- FUT item-definition serving (wf_e41070d8) -------------------------------
|
|
# The card view-model 0x1800d7920 renders identity/rating/face from a RESOLVED
|
|
# record at item+0x10, filled by looking the resourceId up in the FUT item-def
|
|
# store. That store is network-filled; empty offline => generic cards. FIFA
|
|
# fetches definitions from ut/<sku>/item/resource, ut/<sku>/defid, and batch
|
|
# ut/<sku>/item?idList=<ids>. We serve them here (deser 0x18013fe00, same as items).
|
|
# resourceId = playerId | version<<24 ; assetId = resourceId & 0xffffff.
|
|
PLAYER_DEFS = {
|
|
# assetId: (name, rating, position, nation, leagueId, teamid, [6 attrs])
|
|
20801: ("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80]),
|
|
}
|
|
|
|
|
|
def item_def(rid):
|
|
"""Build one FUT item-definition for a requested resourceId."""
|
|
asset = rid & 0xffffff
|
|
name, rating, pos, nation, league, team, attrs = PLAYER_DEFS.get(
|
|
asset, ("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70]))
|
|
return {
|
|
"id": rid,
|
|
"resourceId": rid,
|
|
"definitionId": rid,
|
|
"assetId": asset,
|
|
"cardassetid": asset,
|
|
"commodityId": asset,
|
|
"cardsubtypeid": 0,
|
|
"cardType": 0,
|
|
"itemType": "player",
|
|
"rareflag": 1,
|
|
"rating": rating,
|
|
"preferredPosition": pos,
|
|
"nation": nation,
|
|
"leagueId": league,
|
|
"teamid": team,
|
|
"playStyle": 250,
|
|
"attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)],
|
|
"name": name,
|
|
"commonName": name,
|
|
"lastName": name,
|
|
"itemState": "free",
|
|
"untradeable": True,
|
|
}
|
|
|
|
|
|
def defs_route(h):
|
|
# Parse every integer id out of the query string (idList=a,b,c / definitionId=x
|
|
# / resourceId=x) and return a definition for each.
|
|
q = h.path.split("?", 1)[1] if "?" in h.path else ""
|
|
ids = [int(n) for n in re.findall(r"\d{3,}", q)]
|
|
if not ids:
|
|
return 200, {"itemData": []}
|
|
return 200, {"itemData": [item_def(i) for i in ids]}
|
|
|
|
|
|
# ---- pack reveal: SOLVED 2026-08-04 -------------------------------------------
|
|
# The reveal hand-off worked live: five cards, Send to Club, session survived, cards
|
|
# persisted, no ut/delete/auth. See MOVE_BODY below for what actually fixed it.
|
|
#
|
|
# The workaround this block used to describe (deposit pack contents straight into
|
|
# the club at open time, keep the pending pile empty) is now DEFAULT OFF. It was
|
|
# always a cost, not a fix: with the pending pile empty the client has nothing to
|
|
# assign, so the reveal screen shows no cards AND the move request is never sent.
|
|
# That second effect made the real bug untestable -- the first live attempt at the
|
|
# correct response shape produced no PUT /item at all because autoclub had already
|
|
# emptied the pile. Leave this off unless the move path regresses.
|
|
PACK_AUTOCLUB = os.environ.get("FUT_PACK_AUTOCLUB", "0") == "1"
|
|
|
|
# FUT_MOVE_BODY -- what PUT ut/%s/item answers.
|
|
# ack (default) -> {"itemData":[{"id":N,"pile":"club","success":true}, ...]}
|
|
# LIVE-PROVEN 2026-08-04. Five cards sent to club, session
|
|
# survived, cards persisted, NO ut/delete/auth logout.
|
|
# empty -> {} known-broken
|
|
# full -> echo the moved card objects known-broken
|
|
# dreamsquads -> {"itemData":[{"dreamSquads":[]} x N]} known-broken
|
|
#
|
|
# WHY. 0x180128600 does not parse an acknowledgement, it builds per-item VERDICT
|
|
# records, and the completion handler raises EVENT_CARDS_MOVE_CARD_FAILURE when the
|
|
# record vector is EMPTY or when record+0x0c != 1. success(0x2fa) is initialised to
|
|
# '\0' per element. So {} and every echo shape reported the move as FAILED -- the
|
|
# session died because we told it to.
|
|
#
|
|
# The request the client actually sends (captured live, first time ever):
|
|
# {"itemData":[{"id":100000125,"pile":"club","swap":0,"tradeId":0}, ...]}
|
|
# so `swap` and `tradeId` accompany id/pile. We ignore both; the move succeeded
|
|
# without honouring them.
|
|
#
|
|
# HISTORY, kept because the wrong version of it cost seven attempts. This file used
|
|
# to claim 0x180128600 had NO skip handler and parsed only itemData -> dreamSquads.
|
|
# Both false: two skip-handler sites, seven atoms. The claim came from searching a
|
|
# TRUNCATED decompile (src[:4000] of 6193 chars). It implied "the body cannot be the
|
|
# problem", which sent the investigation after client-side state. It was the body.
|
|
# See REBUILD_RESEARCH.md S16.
|
|
#
|
|
# Also retracted: the argument that a bare {} was "demonstrably acceptable" because
|
|
# Quick Sell survives one. Quick sell's callbacks read only the transport code and
|
|
# never touch the body, so its tolerance said nothing about this endpoint.
|
|
#
|
|
# Still true from that round: the netwatch recorded ZERO non-loopback connections
|
|
# during a failure, so "error connecting to FIFA 17 Ultimate Team" is FIFA's generic
|
|
# FUT-session failure text and must never be read as a network event.
|
|
MOVE_BODY = os.environ.get("FUT_MOVE_BODY", "ack")
|
|
|
|
# FUT_STORE_GROUPS: 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 _move_ack(req, moved):
|
|
"""FutMoveCard per-item verdict records (deser 0x180128600).
|
|
|
|
Built from the REQUEST, not from `moved`: under FUT_PACK_AUTOCLUB the pack's
|
|
cards are already in the club before the client asks to move them, so
|
|
STORE.move_items() legitimately returns nothing and a `moved`-derived body
|
|
would be empty -- which is the one shape guaranteed to raise
|
|
EVENT_CARDS_MOVE_CARD_FAILURE.
|
|
|
|
success is asserted ONLY for ids we can actually account for: either this call
|
|
moved them, or they are already sitting in the club. Anything else gets
|
|
success:false, which is the truthful verdict -- claiming success for an id the
|
|
store has never seen would desync the client's model against ours, and that is
|
|
a worse failure than an honest per-item false.
|
|
|
|
`reason`(0x279) is omitted deliberately. The only string known to map to a
|
|
specific code is "Destination Full" (0xf) and that is not what happened here;
|
|
the key is skip-safe, so sending nothing is better than sending a wrong reason.
|
|
"""
|
|
club_ids = {it.get("id") for it in STORE.items()}
|
|
moved_ids = {it.get("id") for it in moved}
|
|
out = []
|
|
for r in req:
|
|
if not isinstance(r, dict):
|
|
continue
|
|
iid = r.get("id")
|
|
ok = iid in moved_ids or iid in club_ids
|
|
out.append({
|
|
"id": iid, # INT getter -- send a number
|
|
"pile": r.get("pile", "club"), # STRING -> enum 0x180142650
|
|
"success": bool(ok), # BOOL, record+0x0c, must be 1
|
|
})
|
|
if out and not all(x["success"] for x in out):
|
|
log(" ITEM: %d/%d verdicts are success=false (ids not accounted for)"
|
|
% (sum(1 for x in out if not x["success"]), len(out)))
|
|
return out
|
|
|
|
|
|
def item_route(h):
|
|
# PUT ut/game/fifa17/item = FutMoveCard (move item to a pile, e.g. the reveal
|
|
# screen's "keep/assign" -> {"itemData":[{"id":..,"pile":"club","swap":0,
|
|
# "tradeId":0}]}). Response echoes the FULL updated card-item(s) + chemistry
|
|
# bool (ENDPOINT_MAP item rows 6/7/10/11; itemData via deser 0x18013fe00).
|
|
# Returning [] here makes FIFA think the move failed -> kicks to main menu.
|
|
# GET stays defs_route (ViewCards / ConsumablesSearch / loan lists).
|
|
if h.command == "PUT":
|
|
try:
|
|
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
|
|
except Exception:
|
|
body = {}
|
|
req = body.get("itemData")
|
|
if isinstance(req, list):
|
|
moved = STORE.move_items(req)
|
|
# ack is answered BEFORE the `if moved:` gate on purpose. Under
|
|
# FUT_PACK_AUTOCLUB=1 (the default) a pack's cards are already in the
|
|
# club by the time the reveal screen asks to move them, so move_items()
|
|
# legitimately returns nothing -- and gating the ack on `moved` would
|
|
# emit a ZERO-RECORD itemData, which is precisely the shape that raises
|
|
# EVENT_CARDS_MOVE_CARD_FAILURE. The verdict has to come from the
|
|
# REQUEST. (Caught in review before it ever ran; the earlier placement
|
|
# would have made ack mode fail in exactly the configuration it is meant
|
|
# to fix, and the bug would have looked like "ack does not work".)
|
|
if MOVE_BODY == "ack":
|
|
if moved:
|
|
log(" ITEM: moved %d item(s) to pile(s)" % len(moved))
|
|
return 200, {"itemData": _move_ack(req, moved)}
|
|
if moved:
|
|
log(" ITEM: moved %d item(s) to pile(s)" % len(moved))
|
|
# RETRACTED 2026-08-04. This block previously claimed:
|
|
# "FutMoveCard 0x180128600 HAS NO SKIP HANDLER ... calls it ZERO
|
|
# times ... parses exactly two atoms, itemData and dreamSquads"
|
|
# That was WRONG and it sent the investigation after client-side
|
|
# state for seven attempts. Cause of the error: the decompile was
|
|
# truncated to 4000 chars before being searched, and the function is
|
|
# 6193 chars -- BOTH FUN_180135ff0 call sites (offsets 5006 and 6080)
|
|
# and four of the seven atoms lie past the cut. Never conclude an
|
|
# absence from a truncated decompile.
|
|
#
|
|
# VERIFIED SHAPE (full decompile, getter read from the first call
|
|
# after each atom compare):
|
|
# itemData(0x16b) array of PER-ITEM VERDICT RECORDS, 0x18 bytes each
|
|
# id(0x15c) INT 0x1801c79d0 -> record+0x00
|
|
# pile(0x226) STRING 0x1801c7aa0 -> enum via 0x180142650
|
|
# club=7 purchased=6 trade=5
|
|
# success(0x2fa) BOOL 0x1801c7620 -> record+0x0c
|
|
# reason(0x279) STRING 0x1801c7aa0 -> "Destination Full" = 0xf
|
|
# dreamSquads(0xe9) INT array
|
|
# anything else -> FUN_180135ff0 (the skip handler, twice)
|
|
#
|
|
# This is NOT an ack endpoint. The completion handler raises
|
|
# EVENT_CARDS_MOVE_CARD_FAILURE when the record vector is EMPTY or
|
|
# when record+0x0c != 1, and `success` is initialised to '\0' at the
|
|
# top of every element loop. So every body we have ever returned --
|
|
# full cards, +chemistry, dreamsquads-only, and `empty` ({}, which
|
|
# produces zero records and fails the first guard outright) -- has
|
|
# reported the move as FAILED. Quick sell survives an identical {}
|
|
# because its callbacks check only the transport code and ignore the
|
|
# body: that is the whole asymmetry, and it was on the wire after all.
|
|
#
|
|
# `ack` is the corrected shape. NOT the default yet: it is untested
|
|
# live, and the necessary condition being identified is not proof of
|
|
# sufficiency. One launch with FUT_MOVE_BODY=ack settles it.
|
|
if MOVE_BODY == "full":
|
|
return 200, {"itemData": moved}
|
|
if MOVE_BODY == "dreamsquads":
|
|
return 200, {"itemData": [{"dreamSquads": []} for _ in moved]}
|
|
return 200, {}
|
|
# Nothing matched (ids not in the pending pile). Answer in the SAME shape as
|
|
# a successful move so the client cannot tell the two apart structurally.
|
|
return 200, ({} if MOVE_BODY == "empty" else {"itemData": []})
|
|
return defs_route(h)
|
|
|
|
|
|
G = r"/ut/game/[^/]+"
|
|
ROUTES = [
|
|
# ---- FUT item-definition endpoints (must precede generic /item, /user) ----
|
|
(re.compile(G + r"/item/resource"), lambda m, h: defs_route(h)),
|
|
(re.compile(G + r"/defid"), lambda m, h: defs_route(h)),
|
|
(re.compile(G + r"/item(\?|$)"), lambda m, h: item_route(h)),
|
|
# ---- store / packs (match regardless of /ut/game vs /ut/v2/game prefix) ----
|
|
(re.compile(r"/store/purchasegroup"), lambda m, h: store_catalog(h)),
|
|
(re.compile(r"/store/transaction"), lambda m, h: store_buy(h)),
|
|
# ut/v2/game/fifa17/store = FutStorePackQuantities ELIGIBILITY GATE, not a
|
|
# quantity list. deser 0x1801758c0 reads one key "result" (atom 0x288); the
|
|
# store screen shows "not available" unless this is SUCCESS. (ENDPOINT_MAP
|
|
# store §2.) Bare /store only -- purchasegroup/transaction matched above.
|
|
(re.compile(r"/store(\?|$)"), lambda m, h: (200, {"result": "SUCCESS"})),
|
|
(re.compile(r"/purchased"), lambda m, h: purchased_items(h)),
|
|
# POST ut/auth: the response is unchanged; the REQUEST body is now read so
|
|
# ACCOUNT can adopt the persona the client itself asserts (see auth_body).
|
|
(re.compile(r"^/ut/auth"), lambda m, h: (200, auth_body(h))),
|
|
(re.compile(r"^/ut/delete/auth"), lambda m, h: (200, {})),
|
|
(re.compile(G + r"/settings"), lambda m, h: (200, SETTINGS)),
|
|
# Device-trust ("phishing") flow. trusteddevice parser 0x18012a170 reads 4
|
|
# booleans by key-id 0x7e/0x117/0x19e/0x351; 0x351 == JSON key "trusted".
|
|
# Returning trusted=true makes FUT SKIP the security question.
|
|
(re.compile(G + r"/phishing/trusteddevice"), lambda m, h: (200, {"trusted": True})),
|
|
(re.compile(G + r"/phishing/validate"), lambda m, h: (200, {"token": "OPENFUT-TRUST-0000000000000000"})),
|
|
(re.compile(G + r"/phishing/question"), lambda m, h: (200, {"question": 0, "answer": "", "attempts": 5})),
|
|
(re.compile(G + r"/user/credits"), lambda m, h: credits_route(h)),
|
|
# ---- club/squad routes (2026-08-03: squad schema 0x18013d1f0 now reversed) ----
|
|
# /user, /squad and /userMassInfo serve real data again -- the squad object
|
|
# matches the verified schema, so the 2026-08-01 revert-to-{} no longer applies.
|
|
#
|
|
# ORDER MATTERS TWICE HERE:
|
|
# * /clubUser MUST precede the generic /club route at the bottom of this table,
|
|
# which was silently swallowing it and answering with the club ITEM list.
|
|
# * /user/club MUST precede /user/list and /user$ so the rename URL is not
|
|
# absorbed by a neighbour. (It would otherwise fall through to /club, which
|
|
# now also dispatches renames -- but relying on that is a trap for the next
|
|
# edit of this table.)
|
|
(re.compile(G + r"/clubUser"), lambda m, h: club_identity_route("clubUser")),
|
|
(re.compile(G + r"/user/club"), lambda m, h: club_rename_route(h)),
|
|
(re.compile(G + r"/user/list"), lambda m, h: club_identity_route("userList")),
|
|
(re.compile(G + r"/user/accountinfo"), lambda m, h: (200, accountinfo_body())),
|
|
(re.compile(G + r"/user$|" + G + r"/user\?"), lambda m, h: user_route(h)),
|
|
# LIVE GROUND TRUTH 2026-08-03: FutSquadList has its OWN URL, `ut/%s/squad/list`
|
|
# -- the request-table strings only showed ut/%s/squad, so the static conclusion
|
|
# "there is NO separate squad-list URL" (REBUILD_PLAN S1b) was WRONG. This must
|
|
# precede the generic /squad route, which was swallowing it and returning the
|
|
# full active-squad object; the list parser 0x180142260 recognises ONLY
|
|
# squad(0x2cd) and skipped every one of those keys -> "MY SQUADS: 0".
|
|
(re.compile(G + r"/squad/list"), lambda m, h: (200, squad_list_body())),
|
|
# Same bug as /squad/list, second instance: `ut/%s/squad/mode` + `/draft/state`
|
|
# is composed by appending a suffix, so it is invisible to the request-template
|
|
# table, and the generic /squad route below was swallowing it. MUST precede it.
|
|
(re.compile(G + r"/squad/mode/draft/state"), lambda m, h: draft_state_route(h)),
|
|
(re.compile(G + r"/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": []}
|
|
|
|
|
|
# ---- Draft current state -----------------------------------------------------
|
|
# GET ut/%s/squad/mode/draft/state?mode=ONLINE|SINGLE_PLAYER
|
|
# -> FutGetDraftCurrentStateServerResponse, deser 0x180147070.
|
|
#
|
|
# THE ROOT CONTAINER IS A JSON ARRAY, not an object. That single fact is the whole
|
|
# finding. The deserializer's prologue discards tokens until it sees START_ARRAY;
|
|
# handed a top-level object it never reaches its exit condition and spins in the
|
|
# inner `while (tok != END_OBJECT)` loop with the tokenizer returning EOF forever.
|
|
# Process alive, no crash dump, no error dialog: exactly the signature observed live
|
|
# on 2026-08-03 when our generic /squad route answered this with a full active-squad
|
|
# object (23 slots, nested itemData, the 33-int `custom` string).
|
|
#
|
|
# Verified, not assumed:
|
|
# * 7 top-level atoms, counted at instruction level (4 int-getter calls, 3
|
|
# string-getter calls, 0 bool, 2 skip) over the whole body [0x180147070,
|
|
# 0x1801475a3]. FUN_180135ff0 IS present (2 sites), so unknown keys are inert.
|
|
# * NO ATOM COLLIDES with the squad object we were serving. The freeze was purely
|
|
# the container level, not a per-field type desync.
|
|
# * roundsInfo[] element parser FUN_180146eb0 (2477 chars, read in full): 7 scalar
|
|
# atoms, all safe. Empty array is safest and is what we send.
|
|
# * entranceCriteria(0x108) is an object of three int keys COINS/DRAFT_TOKEN/
|
|
# POINTS. OMITTED here: knowing a shape is not a reason to send it.
|
|
# * squadState(0x2d5) is a string enum. "DRAFTSQUAD_ON" from the old notes is NOT
|
|
# an accepted spelling.
|
|
#
|
|
# A reviewer independently simulated this exact body through the deserializer line by
|
|
# line: 16 token reads, clean exit, nothing left over.
|
|
#
|
|
# DEFAULT ON, which is a deliberate exception to "default to the live-proven value".
|
|
# The live-proven value here HANGS THE GAME. There is no working screen to protect:
|
|
# Draft cannot be entered at all today. FUT_DRAFT_STATE=0 restores the old routing if
|
|
# this turns out to be wrong.
|
|
DRAFT_STATE = os.environ.get("FUT_DRAFT_STATE", "1") == "1"
|
|
|
|
|
|
def draft_state_route(h):
|
|
if not DRAFT_STATE:
|
|
return squad_route(h)
|
|
return 200, [{
|
|
"squadState": "INVALID", # 0x2d5 STRING enum
|
|
"stateParam1": "INVALID", # STRING
|
|
"stateParam2": "0", # STRING (the int getter also accepts it)
|
|
"gamesWonCurrentMatch": 0, # INT
|
|
"roundsInfo": [], # array of the 7-scalar element; empty is safe
|
|
# entranceCriteria: OMITTED. Shape known, not needed, skip-safe.
|
|
}]
|
|
|
|
|
|
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()
|