59934b4ef0
The client now issues PUT /squad and the hub renders coins, record and the
squad roster. Three separate root causes, all verified live.
Squad blocker (the long-standing "client never sends PUT /squad"):
AddPlayerToSquad, GetSquads and SelectSquadById issue ZERO network requests
(pure local model reads/mutations, FutSquadServiceImpl vtable 0x180233ff0);
only SaveCurrentSquad writes, and it is unguarded. The client simply needed a
populated ACTIVE squad model, which arrives via the massinfo `squad` member.
No response of ours was ever being rejected.
userMassInfo is NOT required to be {}:
0x180174630 is a FLAT {userInfo, squad, settings, userData} body -- the old
"wrapper key is user" note was wrong, and the historical freeze was the
malformed squad member, not the envelope.
clubNameChangeAllowed must be false:
sending true advertises a club-rename flow whose UI model is never populated;
the client shows a naming prompt and dies confirming it (ACCESS_VIOLATION
reading 0x0 at FIFA17.exe+0x71b8651, 4/4 runs, no CardsDLL frame and no request
in flight). Isolated by a single-variable run; guarded by a contract check.
Endpoint/schema corrections found in live traffic, invisible to static analysis:
* GET ut/%s/squad/list is a real endpoint and must return {"squad":[...]},
not the active-squad object (the /list suffix is appended by the caller, so
it never appeared in the request table)
* PUT lands on ut/%s/squad/<id>, not a bare ut/%s/squad
* userInfo currencies are read as name/funds/finalFunds/active -- there is no
"value" key, so coins always rendered 0
* squad-list elements take STRING formation/squadType, not ints
* the CardsDLL script-API thunk<->name table was off by one (AddPlayerToSquad
is 0x18004aa70; 0x18004aff0 is GetPotentialChemistry_Club)
FUT_MASSINFO / FUT_USERINFO ladders keep every step of the bisect reproducible.
Contract suite 311 -> 358 checks. Tooling added: PyGhidra harness (Ghidra's
Java/OSGi script path is broken on this box), minidump reader, live code grabber.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
733 lines
36 KiB
Python
Executable File
733 lines
36 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
|
|
|
|
ADDR = ("127.0.0.1", 8099)
|
|
LOG = "/tmp/utas_server.log"
|
|
SID = "OPENFUT-SID-0000000000000001"
|
|
PERSONA_ID = 33068179 # blaze LoginResponse SESS.BUID / PDTL.PID
|
|
PERSONA_NAME = "CAGE" # PDTL.DSNM
|
|
# 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():
|
|
# Only "sid" is load-bearing (parser 0x1801a2880 -> ServerCall+0x1e8).
|
|
# serverTime/lastOnlineTime are atoi'd at char offsets 0,5,8,11,14,17.
|
|
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 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"] = 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.
|
|
_UI = os.environ.get("FUT_USERINFO", "roster")
|
|
|
|
|
|
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 = {
|
|
"personaId": PERSONA_ID,
|
|
"clubName": p.get("clubName", "OpenFUT"), "clubAbbr": p.get("clubAbbr", "OFC"),
|
|
"established": p.get("established", "2026"),
|
|
# accountCreatedPlatformName(0x6), stored at userInfo+0x42: the only string
|
|
# 0x18013ec10 consumes that we used to omit (clubAbbr 0x8d / clubName 0x8e /
|
|
# established 0x110 were already covered). Sending it is correct, but note
|
|
# it was NOT the cause of the create-club crash -- that was still identical
|
|
# with this field present. Value matches auth's nucleusPersonaPlatform.
|
|
"accountCreatedPlatformName": "pc",
|
|
# 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. MUST stay False:
|
|
# True is the isolated root cause of the create-club crash (see _UI).
|
|
"clubNameChangeAllowed": False,
|
|
"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():
|
|
# 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": []}
|
|
|
|
|
|
# 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")
|
|
|
|
|
|
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}
|
|
return {"userInfo": user_info(), # squadList -> roster singleton
|
|
"squad": current_squad(), # personaId == PERSONA_ID
|
|
"settings": SETTINGS,
|
|
"userData": {}}
|
|
|
|
# ---- 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]}
|
|
|
|
|
|
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))
|
|
# Same shape as the proven-parseable GET itemData (no extra keys):
|
|
# adding an unexpected key (e.g. chemistry) desynced the parser
|
|
# and made FIFA report "failed to send to club" then log out.
|
|
return 200, {"itemData": moved}
|
|
return 200, {"itemData": []}
|
|
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)),
|
|
(re.compile(r"^/ut/auth"), lambda m, h: (200, auth_body())),
|
|
(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/list + /user/accountinfo stay {} (nothing in them is load-bearing yet).
|
|
# /user, /squad and /userMassInfo serve real data again -- the squad object
|
|
# matches the verified schema, so the 2026-08-01 revert-to-{} no longer applies.
|
|
(re.compile(G + r"/user/list"), lambda m, h: (200, {})),
|
|
(re.compile(G + r"/user/accountinfo"), lambda m, h: (200, {})),
|
|
(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, {})),
|
|
(re.compile(G + r"/hub"), lambda m, h: (200, {})),
|
|
# Populated massinfo (see massinfo() above): userInfo + squad + settings.
|
|
(re.compile(G + r"/userMassInfo"), lambda m, h: (200, massinfo())),
|
|
(re.compile(G + r"/season"), lambda m, h: (200, {})),
|
|
# ---- 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})),
|
|
(re.compile(r"/ut/delete/game/[^/]+/trade"), lambda m, h: delete_trade_route(h)),
|
|
(re.compile(r"/ut/delete/game/[^/]+/watchList"), lambda m, h: (200, {})),
|
|
(re.compile(G + r"/club"), lambda m, h: (200, {"itemData": STORE.items()})),
|
|
]
|
|
|
|
|
|
def user_route(h):
|
|
if h.command == "POST":
|
|
return 200, user_post()
|
|
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 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 store_catalog(h):
|
|
packs = []
|
|
idx = 1
|
|
for p in PACK_CATALOG:
|
|
gold = p["gold"]
|
|
mtx = max(1, p["price"] // 100)
|
|
packs.append({
|
|
"assetId": p["id"],
|
|
"id": p["id"],
|
|
"packType": "GOLD" if gold else "BRONZE",
|
|
"description": p["name"],
|
|
"state": "active",
|
|
"saleType": "promo",
|
|
"limitType": "NONE",
|
|
"quantity": 0,
|
|
"purchaseLimit": 0,
|
|
"purchaseCount": 0,
|
|
"isPremium": False,
|
|
"sortPriority": idx,
|
|
"currencies": [{"name": "coins", "funds": p["price"], "finalFunds": p["price"]}],
|
|
"extPrice": {"finalPrice": {"amount": mtx, "currency": "mtx"},
|
|
"originalPrice": {"amount": mtx, "currency": "mtx"}},
|
|
"packContentInfo": {
|
|
"bronzeQuantity": 0 if gold else p["count"],
|
|
"silverQuantity": 0,
|
|
"goldQuantity": p["count"] if gold else 0,
|
|
"rareQuantity": p["count"] if gold else 0,
|
|
"itemQuantity": p["count"],
|
|
"unopened": False,
|
|
},
|
|
})
|
|
idx += 1
|
|
return 200, {"purchase": packs, "timestamp": 1596326400}
|
|
|
|
|
|
def store_buy(h):
|
|
# PUT (v2) store/transaction. The BUY is the create step: body carries "packId"
|
|
# (atom 0x20b, TRANSACTIONCREATED) and state != TRANSACTIONCANCEL (wf a245577b /
|
|
# wf_76fcf89b). Only THEN open a pack. Cancel/other -> no-op {} (fixes the
|
|
# phantom-buy). Reveal = {"createPackResponse":{itemList,numberItems,
|
|
# purchasedPackId,duplicateItemIdList}} (FutCreatePackServerResponse 0x180162880).
|
|
try:
|
|
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
|
|
except Exception:
|
|
body = {}
|
|
pid = body.get("packId")
|
|
if body.get("state") == "TRANSACTIONCANCEL" or not isinstance(pid, int):
|
|
return 200, {} # not a confirmed buy
|
|
pack = pack_by_id(pid)
|
|
if not pack:
|
|
return 200, {}
|
|
items = STORE.open_pack(pack["price"], pack["count"], pack["gold"])
|
|
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()))
|
|
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 = STORE.profile().get("personaName", "OpenFUT")
|
|
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()
|