8cba70dc90
- Add 8 files present in docker/fifa17-python/tools but missing from the top-level tree: fut_accounts.py + 7 test_*.py contracts (all committed in the server's docker tree; byte-identical to the running image). - Preserve newer responder work already matching the running container: utas_server.py (offlineSeason), lsx_responder_v2.py (OPENFUT_BIND), blaze_responder_v3b.py, autopatch.py, pow_server.py, fut_store.py, test_fut_contract.py, fifa17-hook-m1.sh. - Add 30 newer ghidra_queries (draft purchase/state, SBC 9-26, runtime registries). Local tree is now a strict superset of B with all shared files byte-identical.
3592 lines
189 KiB
Python
Executable File
3592 lines
189 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 copy, datetime, json, os, random, 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, player_item
|
|
import fut_cards
|
|
import fut_staff
|
|
from fut_account import ACCOUNT, validate_club # identity + club, single source
|
|
|
|
# FUT_PORT exists so a second, THROWAWAY instance can be started without touching the
|
|
# one the live client is talking to. Research agents kept bouncing the live server
|
|
# because the only way to exercise a route was to restart the only server there was;
|
|
# with this plus FUT_PROFILE (a copy of the save) and FUT_TEST_BASE, a test run is
|
|
# fully isolated. The default stays 8099: that is the port the hook redirects to.
|
|
ADDR = ("127.0.0.1", int(os.environ.get("FUT_PORT", "8099")))
|
|
LOG = os.environ.get("FUT_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
|
|
|
|
# An owned pack is consumed persistently when opened, but FIFA's reveal controller
|
|
# still returns to the My Packs group after all items are assigned/sold. Keep the
|
|
# just-opened catalogue record visible until the next hub request so that group is
|
|
# not deleted underneath a live UI controller.
|
|
_OPENED_PACK_GRACE = []
|
|
|
|
|
|
def visible_unopened_packs():
|
|
return STORE.unopened_packs() + list(_OPENED_PACK_GRACE)
|
|
|
|
|
|
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_TRADING: stop banning our own trading.
|
|
#
|
|
# userInfo.feature (atom 0x11c) is a RESTRICTION map, not a grant. Sending
|
|
# feature={"trade": true} marks TRADE RESTRICTED. Verified at the instruction level
|
|
# 2026-08-06 (q_feature_trade.py): FUN_18013ec10 parses feature/trade into
|
|
# userInfo+0x17c, and at the massinfo top-level END_OBJECT the client runs
|
|
# 0x180174f10 cmp byte [rsi+0x17c], 0
|
|
# 0x180174f17 jz 0x180174f20 ; not restricted -> skip
|
|
# 0x180174f19 mov dword [rsi+0x50], 0 ; restricted -> zero the trade field
|
|
# which feeds applier 0x18011dc91 -> IS_TRADING_ENABLED (model+0x1fd2e) = 0. It runs
|
|
# LAST and unconditionally, which is why the gate byte read 0 all day no matter what
|
|
# /settings or the Blaze client-config store sent. We were disabling trading ourselves.
|
|
#
|
|
# With the flag on we send feature={} (no trade key -> +0x17c stays 0 -> the jz skips
|
|
# the zeroing -> the gate keeps its constructor default of 1). Empty object is
|
|
# type-safe: feature is an OBJECT and {} parses with no members.
|
|
#
|
|
# Default OFF for one relaunch only: this is on the critical path into FUT and has
|
|
# never been in front of the game. Verify by reading model+0x1fd2e (should become 1)
|
|
# and by checking the per-card "Place on Transfer List" entry is no longer greyed.
|
|
TRADING = os.environ.get("FUT_TRADING", "0") == "1"
|
|
|
|
# ---- 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
|
|
# {"trade": true} = trade RESTRICTED (see FUT_TRADING note up top). {} lifts
|
|
# the restriction. Default keeps the historical value until one live test.
|
|
"feature": ({} if TRADING else {"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) --------
|
|
# Ownership counters must reflect persistent inventory only. The catalogue
|
|
# grace row prevents StoreFront from deleting a group beneath its live reveal
|
|
# controller, but the pack was already consumed and must not remain in FIFA's
|
|
# cached unopened-pack count.
|
|
unopened_count = len(STORE.unopened_packs())
|
|
if unopened_count or _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": unopened_count}
|
|
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).
|
|
#
|
|
# This body is FLAT and that is correct. Every key here is dispatched, including
|
|
# the first one. Do not "fix" it by wrapping it in an envelope key: CreateUser's
|
|
# ladder has arms for exactly these five atoms (login 0x1a5, userData 0x36d,
|
|
# squad 0x2cd, starterPack 0x2e5, bonusPacks 0x5d) and nothing else, so a wrapper
|
|
# name would hash to an atom with no arm and the whole object would be skipped.
|
|
#
|
|
# Why this note exists: an earlier pass on 2026-08-05 claimed the opposite, that
|
|
# the three tokenizer calls before the key loop consume `{`, the first field name
|
|
# and its value, so the first key was silently eaten. That was WRONG. The first
|
|
# call to FUN_1801c7f10 returns token 7 and consumes NO input (once-only branch
|
|
# guarded by the flag at parser+0xda), so the three tokens are BOF, `{`, and the
|
|
# FIRST FIELD NAME. The loop dispatches from that first key onward. The `== 10`
|
|
# test on the third token is just the empty-object early-out for `{}`.
|
|
# Refuted live rather than on paper: GET /hub is the same flat shape, and its
|
|
# first key clubPlayers read back as 205 out of the running client at
|
|
# model+0x1fd70+0x3c, i.e. it reached its arm. Key order here is NOT load-bearing.
|
|
# Token enum, from FUN_1801c67a0: 7=BOF 9=START_OBJECT 10=END_OBJECT 11=FIELD_NAME
|
|
# 12=START_ARRAY 13=END_ARRAY. See docs/plan-2026-08-05-pack-opening.md section 2
|
|
# and tools/ghidra_queries/q_envelope_{1,2,3}.py + q_hub_{1,2,3}.py.
|
|
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),
|
|
# an array of {type(0x354), value(0x377)}. The flag is not the JSON key: the
|
|
# client hashes the STRING VALUE of `type` through the atom hasher FUN_180180d00
|
|
# and switches on it, 42 arms wide. See ENDPOINT_MAP "FutGetSettingsServerResponse".
|
|
#
|
|
# WHY THIS IS NOT `{"configs": []}` ANY MORE. The applier FUN_18011dc50 is the only
|
|
# writer of the IS_* UI gate bytes and every line is `byte = (field == 1)`. The
|
|
# FutDataManagerImpl ctor never touches those bytes. So a flag we do not send is a
|
|
# gate that is never opened, and IS_FRIENDLY_SEASON_ENABLED / IS_DRAFT_MODE_ENABLED
|
|
# have never been sent by anything. That is a mechanism for the standing bug where
|
|
# Seasons refuses while making ZERO requests to any of the four servers.
|
|
#
|
|
# FREEZE SAFETY. `value`'s getter 0x1801c79d0 takes int/float/bool/string and
|
|
# coerces to int64, so a scalar cannot desync the token reader here. Ints are used
|
|
# below. Never put an object or an array in `value`.
|
|
#
|
|
# THE STORE FLAGS ARE RE-ASSERTED DELIBERATELY. storeEnabled/coinEnabled/... reach
|
|
# the client today through the BLAZE config store, not through here, and the store
|
|
# screen is live-proven working. Once a populated configs array makes the applier
|
|
# run, it writes EVERY gate byte from this struct, so omitting them could turn the
|
|
# working store off. Sending them as 1 pins them to the state they are already in.
|
|
#
|
|
# DEFAULT IS `off`, deliberately. Populating the array is what makes the applier
|
|
# run at all, and it then writes EVERY gate byte from this struct, including the
|
|
# ones behind screens that work today. The house rule is that a flag defaults to
|
|
# the live-proven value and nothing here has been in front of the game yet. Flip
|
|
# it for the test: `FUT_SETTINGS=gates ./openfut-fut.sh start` (the orchestrator
|
|
# runs the servers under its own environment, so an export is enough).
|
|
_SETTINGS_MODE = os.environ.get("FUT_SETTINGS", "off")
|
|
|
|
# Flags that are already live-proven ON via the Blaze store. Re-asserted so the
|
|
# applier cannot regress a working screen. Keep in sync with FUT_RS4_CONFIG.
|
|
_SETTINGS_KEEP = (
|
|
"storeEnabled", "storeEnabled_JP", "coinEnabled", "coinEnabled_JP",
|
|
"cardPackStoreEnabled", "cardPackStoreEnabled_JP", "pointsPackStoreEnabled",
|
|
"tradingEnabled",
|
|
)
|
|
|
|
# The gates nothing has ever populated. These are the point of the exercise.
|
|
_SETTINGS_GATES = (
|
|
"friendlySeasonsEnabled", # [0x16] -> 0x1fd3a -> IS_FRIENDLY_SEASON_ENABLED
|
|
"enableDraftMode", # [0x17] -> 0x1fd3d -> IS_DRAFT_MODE_ENABLED
|
|
"enableSinglePlayerDraftMode", # [0x18] -> 0x1fd3e (shares its arm with
|
|
"enableOfflineDraftMode", # enableOfflineDraftMode)
|
|
"tournamentQuitEnabled", # [0x20] -> 0x1fd3b -> IS_TOURNAMENT_QUIT_ENABLED
|
|
)
|
|
|
|
# NOT sent, and each for a reason:
|
|
# enableObjectives / enableObjectivesAsManagerTasks -- their shared arm can only
|
|
# CLEAR the field (`if (value == 0) field = 0`), so 1 is a no-op and 0 would
|
|
# switch objectives OFF. Nothing to gain, something to lose.
|
|
# clientKeepAliveResetTimeoutSec / getOperationTimeoutSec -- these do not set a
|
|
# field, they reprogram client timers with value*1000.
|
|
# itemDbVersion / checkServerDbVersion -- checkServerDbVersion makes the client
|
|
# go read a server_db_version config; leave the DB-version path alone.
|
|
# enableSquadBuildingSetsFeature -- a real atom with NO arm in this switch, so
|
|
# it does nothing here whatever we send.
|
|
|
|
# The positive control. maximumTradePileSize lands in field [0] and feeds
|
|
# FUN_18011f380, and transfer-list capacity is READABLE IN GAME. Without it a null
|
|
# result is ambiguous between "the flags did not help" and "the configs array never
|
|
# reached the consumer". With it, those two look different.
|
|
# 77 on purpose: it has to be a number FUT would never pick by itself. 100 is the
|
|
# stock-looking transfer-list size, so reading "x/100" in game would prove nothing.
|
|
# The profile holds 0 listings, so a small cap cannot strand anything.
|
|
_SETTINGS_PROBE = (("maximumTradePileSize", 77),)
|
|
|
|
|
|
def _settings_body():
|
|
"""off -> the historical {"configs": []} baseline.
|
|
keep -> re-assert only the already-working flags, plus the control. Isolates
|
|
"does populating configs at all change anything" from the new gates.
|
|
gates-> keep, plus the gates nothing has ever sent. The actual experiment."""
|
|
if _SETTINGS_MODE == "off":
|
|
return {"configs": []}
|
|
rows = [{"type": k, "value": 1} for k in _SETTINGS_KEEP]
|
|
if _SETTINGS_MODE == "gates":
|
|
rows += [{"type": k, "value": 1} for k in _SETTINGS_GATES]
|
|
rows += [{"type": k, "value": v} for k, v in _SETTINGS_PROBE]
|
|
return {"configs": rows}
|
|
|
|
|
|
SETTINGS = _settings_body()
|
|
|
|
# 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 TRANSFER LIST + WATCH LIST CAPACITIES -----------
|
|
# CORRECTED 2026-08-06 (q_pilesize_keys.py). The old "MY CLUB counter" theory here
|
|
# was WRONG. Parser FUN_18013adb0 has EXACTLY two storing arms and no default:
|
|
# key(0x177)==2 -> value -> param_2+0x8 -> model+0x1fd1c = TRADE_PILE_SIZE
|
|
# key(0x177)==4 -> value -> param_2+0xc -> model+0x1fd20 = watch-list size
|
|
# every other key hits the SKIP handler. So this member is the transfer-list and
|
|
# watch-list CAPACITIES, not counts and not the club. The real MY CLUB counter is
|
|
# the /hub clubPlayers field (model+0x1fd70+0x3c), which we already serve.
|
|
#
|
|
# This is THE fix for the red "TRANSFER LIST 0/0" and the "TRANSFER LIST FULL"
|
|
# refusal on Place on Transfer List: with this member absent, model+0x1fd1c stays at
|
|
# its constructor default of 0, so the list has zero capacity and nothing can be
|
|
# listed even though trading is now enabled. Confirmed live: byte read 0, client
|
|
# said FULL.
|
|
#
|
|
# key and value both pass through FUN_1800d7b30 (test rcx,rcx / jle -> 0), so values
|
|
# must be POSITIVE; -1 does not mean unlimited. 100/50 are the stock FIFA 17
|
|
# convention (nothing in the binary carries a default; the ctor zeroes both).
|
|
#
|
|
# Freeze risk: LOW. Documented int-only member of the boot-critical massinfo parser,
|
|
# skip-safe on unrecognised fields. Instant fallback: FUT_MASSINFO=squad.
|
|
# Default OFF for one live test; this adds a member to boot-critical massinfo.
|
|
_PILESIZES = os.environ.get("FUT_PILESIZES", "0") == "1"
|
|
PILE_KEY_TRADEPILE = 2
|
|
PILE_KEY_WATCHLIST = 4
|
|
|
|
|
|
def marketdata_route(h):
|
|
"""GET marketdata/pricelimits?defId=a,b,c -- FutGetSuggestedPricing (deser
|
|
0x180163ee0). The response is a BARE TOP-LEVEL ARRAY, one element per requested
|
|
defId, each {defId, minPrice, maxPrice}, all scalar ints.
|
|
|
|
FROZE THE CLIENT 2026-08-06: we returned an OBJECT {"minPrice","maxPrice"} where
|
|
the deser's root loop reads an ARRAY (while tok != 0xd). Object-where-array is the
|
|
type-desync busy loop at 0x1801c7f1a. Confirmed live: listing a card at the price
|
|
screen pinned a core. Element atoms verified: defId 0xcf, maxPrice 0x1c2, minPrice
|
|
0x1ca, all read via the INT getter 0x1801c79d0, so int values are type-correct.
|
|
The container was the whole bug.
|
|
|
|
defId can be a comma-separated list. Echo each so the client can match the band to
|
|
the item it asked about. Bands are a placeholder (150..15000); real per-item
|
|
pricing is a later refinement, not a freeze concern.
|
|
"""
|
|
from urllib.parse import urlparse, parse_qs
|
|
parsed = urlparse(h.path)
|
|
# ONLY /marketdata/pricelimits is the bare-array GetSuggestedPricing. Plain
|
|
# /marketdata?defId=N is a DIFFERENT endpoint (price comparison) that takes an
|
|
# OBJECT: it was served {minPrice,maxPrice} in the frozen session and did NOT
|
|
# freeze, so it wants an object, not the array. Returning the array for it would
|
|
# be the same object-vs-array desync in reverse. Keep them distinct.
|
|
if not parsed.path.endswith("/pricelimits"):
|
|
return 200, {"minPrice": 150, "maxPrice": 15000}
|
|
q = parse_qs(parsed.query)
|
|
raw = q.get("defId", [""])[0]
|
|
ids = [int(x) for x in raw.split(",") if x.strip().isdigit()]
|
|
return 200, [{"defId": d, "minPrice": 150, "maxPrice": 15000} for d in ids]
|
|
|
|
|
|
def pile_size_body():
|
|
"""massinfo.pileSizeClientData -- transfer-list and watch-list CAPACITIES."""
|
|
return {"entries": [
|
|
{"key": PILE_KEY_TRADEPILE, "value": 100},
|
|
{"key": PILE_KEY_WATCHLIST, "value": 50},
|
|
]}
|
|
|
|
|
|
def massinfo():
|
|
if _MI == "empty":
|
|
return {} # old known-hub-reaching body
|
|
if _MI == "squad":
|
|
return {"squad": current_squad()}
|
|
if _MI == "userinfo":
|
|
return {"userInfo": user_info()}
|
|
if _MI == "settings":
|
|
return {"settings": SETTINGS}
|
|
body = {"userInfo": user_info(), # squadList -> roster singleton
|
|
"squad": current_squad(), # personaId == ACCOUNT.persona_id
|
|
"settings": SETTINGS,
|
|
"userData": {}}
|
|
if _CLUB_ID == "massinfo":
|
|
# clubUser(0x91) IS a recognised massinfo key (deser 0x180174630), so this
|
|
# is schema-legal -- it is opt-in only because massinfo is the boot-critical
|
|
# body and adding a member to it is the change class behind the last two
|
|
# live regressions. Instant fallback: FUT_MASSINFO=squad.
|
|
body["clubUser"] = club_user_body()
|
|
if _PILESIZES:
|
|
# pileSizeClientData(0x227) -> parser 0x18013adb0, int-only, skip-safe.
|
|
# This is what the MY CLUB counter reads (see pile_size_body above).
|
|
body["pileSizeClientData"] = pile_size_body()
|
|
return body
|
|
|
|
# ---- FUT item-definition serving (wf_e41070d8) -------------------------------
|
|
# The card view-model 0x1800d7920 renders identity/rating/face from a RESOLVED
|
|
# record at item+0x10, filled by looking the resourceId up in the FUT item-def
|
|
# store. That store is network-filled; empty offline => generic cards. FIFA
|
|
# fetches definitions from ut/<sku>/item/resource, ut/<sku>/defid, and batch
|
|
# ut/<sku>/item?idList=<ids>. We serve them here (deser 0x18013fe00, same as items).
|
|
# resourceId = playerId | version<<24 ; assetId = resourceId & 0xffffff.
|
|
PLAYER_DEFS = {
|
|
# assetId: (name, rating, position, nation, leagueId, teamid, [6 attrs])
|
|
20801: ("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80]),
|
|
}
|
|
|
|
|
|
def item_def(rid):
|
|
"""Build one FUT item-definition for a requested resourceId."""
|
|
if CONSUMABLES:
|
|
# A consumable's definition is NOT a player's. Answering a consumable
|
|
# resourceId with cardsubtypeid 0 makes it cardtype 0 -- no merge arm, no
|
|
# miss-fill -- i.e. plausible-looking garbage. Same trap as fut_store._item():
|
|
# this route also hardcoded cardsubtypeid 0 / rareflag 1, and rareflag 1 on
|
|
# subtype 219 renders Player Fitness as Squad Fitness (FUN_1801bfac0 case 5).
|
|
# Gated so the player definition path is untouched by default.
|
|
import fut_consumables
|
|
d = fut_consumables.def_for(rid)
|
|
if d is not None:
|
|
return d
|
|
asset = rid & 0xffffff
|
|
name, rating, pos, nation, league, team, attrs = PLAYER_DEFS.get(
|
|
asset, ("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70]))
|
|
return {
|
|
"id": rid,
|
|
"resourceId": rid,
|
|
"definitionId": rid,
|
|
"assetId": asset,
|
|
"cardassetid": asset,
|
|
"commodityId": asset,
|
|
"cardsubtypeid": 0,
|
|
"cardType": 0,
|
|
"itemType": "player",
|
|
"rareflag": 1,
|
|
"rating": rating,
|
|
"preferredPosition": pos,
|
|
"nation": nation,
|
|
"leagueId": league,
|
|
"teamid": team,
|
|
"playStyle": 250,
|
|
"attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)],
|
|
"name": name,
|
|
"commonName": name,
|
|
"lastName": name,
|
|
"itemState": "free",
|
|
"untradeable": True,
|
|
}
|
|
|
|
|
|
def defs_route(h):
|
|
# Parse every integer id out of the query string (idList=a,b,c / definitionId=x
|
|
# / resourceId=x) and return a definition for each.
|
|
q = h.path.split("?", 1)[1] if "?" in h.path else ""
|
|
ids = [int(n) for n in re.findall(r"\d{3,}", q)]
|
|
if not ids:
|
|
return 200, {"itemData": []}
|
|
return 200, {"itemData": [item_def(i) for i in ids]}
|
|
|
|
|
|
# ---- pack reveal: SOLVED 2026-08-04 -------------------------------------------
|
|
# The reveal hand-off worked live: five cards, Send to Club, session survived, cards
|
|
# persisted, no ut/delete/auth. See MOVE_BODY below for what actually fixed it.
|
|
#
|
|
# The workaround this block used to describe (deposit pack contents straight into
|
|
# the club at open time, keep the pending pile empty) is now DEFAULT OFF. It was
|
|
# always a cost, not a fix: with the pending pile empty the client has nothing to
|
|
# assign, so the reveal screen shows no cards AND the move request is never sent.
|
|
# That second effect made the real bug untestable -- the first live attempt at the
|
|
# correct response shape produced no PUT /item at all because autoclub had already
|
|
# emptied the pile. Leave this off unless the move path regresses.
|
|
PACK_AUTOCLUB = os.environ.get("FUT_PACK_AUTOCLUB", "0") == "1"
|
|
|
|
# FUT_MOVE_BODY -- what PUT ut/%s/item answers.
|
|
# ack (default) -> {"itemData":[{"id":N,"pile":"club","success":true}, ...]}
|
|
# LIVE-PROVEN 2026-08-04. Five cards sent to club, session
|
|
# survived, cards persisted, NO ut/delete/auth logout.
|
|
# empty -> {} known-broken
|
|
# full -> echo the moved card objects known-broken
|
|
# dreamsquads -> {"itemData":[{"dreamSquads":[]} x N]} known-broken
|
|
#
|
|
# WHY. 0x180128600 does not parse an acknowledgement, it builds per-item VERDICT
|
|
# records, and the completion handler raises EVENT_CARDS_MOVE_CARD_FAILURE when the
|
|
# record vector is EMPTY or when record+0x0c != 1. success(0x2fa) is initialised to
|
|
# '\0' per element. So {} and every echo shape reported the move as FAILED -- the
|
|
# session died because we told it to.
|
|
#
|
|
# The request the client actually sends (captured live, first time ever):
|
|
# {"itemData":[{"id":100000125,"pile":"club","swap":0,"tradeId":0}, ...]}
|
|
# so `swap` and `tradeId` accompany id/pile. We ignore both; the move succeeded
|
|
# without honouring them.
|
|
#
|
|
# HISTORY, kept because the wrong version of it cost seven attempts. This file used
|
|
# to claim 0x180128600 had NO skip handler and parsed only itemData -> dreamSquads.
|
|
# Both false: two skip-handler sites, seven atoms. The claim came from searching a
|
|
# TRUNCATED decompile (src[:4000] of 6193 chars). It implied "the body cannot be the
|
|
# problem", which sent the investigation after client-side state. It was the body.
|
|
# See REBUILD_RESEARCH.md S16.
|
|
#
|
|
# Also retracted: the argument that a bare {} was "demonstrably acceptable" because
|
|
# Quick Sell survives one. Quick sell's callbacks read only the transport code and
|
|
# never touch the body, so its tolerance said nothing about this endpoint.
|
|
#
|
|
# Still true from that round: the netwatch recorded ZERO non-loopback connections
|
|
# during a failure, so "error connecting to FIFA 17 Ultimate Team" is FIFA's generic
|
|
# FUT-session failure text and must never be read as a network event.
|
|
MOVE_BODY = os.environ.get("FUT_MOVE_BODY", "ack")
|
|
|
|
# FUT_STORE_GROUPS and FUT_STORE_FIELDS ARE GONE. Both were kept around as "maybe
|
|
# they were nearly right" experiments; the 2026-08-04 read of 0x18013af30 (19,279
|
|
# chars, read in full by two independent agents) showed both were wrong in ways that
|
|
# make them unsafe to keep even switched off:
|
|
# STORE_GROUPS sent displayGroup as an ARRAY. It is a flat OBJECT of two members.
|
|
# STORE_FIELDS sent actionType(0x8) and firstPartyStoreId(0x127) as INTEGERS. Both
|
|
# take the STRING getter 0x1801c7aa0.
|
|
# Each is the type-desync freeze, so leaving them in the file as togglable options was
|
|
# leaving two loaded guns on the table. The fix that replaces them is below.
|
|
#
|
|
# FUT_STORE_DISPLAYGROUP: send the ONE key that actually names a tile,
|
|
# displayGroup(0xd9) as {"value": "<pack name>"}. DEFAULT ON since 2026-08-04: it was
|
|
# shipped off because the parser-side proof did not cover whether the key switches
|
|
# FIFA17.exe to a different (packed, unreadable) tile render path. It was then run live
|
|
# with FUT_STORE_DISPLAYGROUP=1 and the store tiles rendered their real names instead of
|
|
# "unknown", so the live-proven value is now on. See _pack_body.
|
|
STORE_DISPLAYGROUP = os.environ.get("FUT_STORE_DISPLAYGROUP", "1") == "1"
|
|
|
|
# FUT_STORE_GROUPID: give each pack a DISTINCT displayGroupAssetId so the grouped
|
|
# layout that FUT_STORE_DISPLAYGROUP switched on has something to separate packs by.
|
|
# Default off. See the long note at the send site in _pack_body.
|
|
STORE_GROUPID = os.environ.get("FUT_STORE_GROUPID", "0") == "1"
|
|
|
|
|
|
# FUT_QUICKSELL: serve the SINGLE-CARD quick sell, which we have never served.
|
|
#
|
|
# CAPTURED LIVE 2026-08-05 19:39:52. The client sends:
|
|
# DELETE /ut/game/fifa17/item/100000240 (no body, id in the URL)
|
|
# ENDPOINT_MAP documented the path as `ut/delete/game/%s/item`, and ROUTES was built
|
|
# from the doc, so the regex at the bottom of this file has never matched a real quick
|
|
# sell. Every quick sell to date fell through to the catch-all. quick_sell_route()
|
|
# below, and STORE.quick_sell() behind it, have therefore never once been called.
|
|
#
|
|
# WHAT THE EMPTY RESPONSE DID. Answering {} does not merely skip the credit. The
|
|
# client takes its coin balance from this response, so with totalCredits absent it
|
|
# rendered an uninitialised value: a real session showed 1,133,686,384 coins against a
|
|
# true balance of 9,889,600. It is only a display artifact, corrected by the next
|
|
# GET /user/credits, and the save was never touched. But it means this response is
|
|
# BALANCE-BEARING and cannot be stubbed.
|
|
#
|
|
# WHAT IS STILL UNKNOWN, and what the next live run settles. We do not know whether
|
|
# the client ASSIGNS totalCredits as the new balance or ADDS it as a delta. One garbage
|
|
# sample cannot distinguish them. We send the NEW BALANCE, which is the natural reading
|
|
# of the field name, and the run is self-diagnosing:
|
|
# balance shows old + value -> assign. Correct, keep it.
|
|
# balance shows roughly double -> delta. Send (value) instead of (new balance).
|
|
# The coin figures are large enough that doubling is unmistakable.
|
|
#
|
|
# The credit itself is STORE.quick_sell()'s rating-based fallback, which is an invented
|
|
# number, not FUT's real discard table. That table is still UNKNOWN. Flagged here so
|
|
# nobody mistakes it for a reversed value.
|
|
# DEFAULT ON since 2026-08-05: live-proven. Two quick sells fired through this
|
|
# handler in one session, each credited 150 and removed the card, and the coin
|
|
# arithmetic reconciled exactly against the pack purchases either side of them.
|
|
# The previous behaviour (unmapped -> {}) is strictly worse: it credited nothing
|
|
# and left an uninitialised balance on screen.
|
|
QUICKSELL = os.environ.get("FUT_QUICKSELL", "1") == "1"
|
|
|
|
|
|
def quick_sell_url_route(h):
|
|
"""DELETE ut/%s/item/<id> -- single-card Quick Sell, the real wire form.
|
|
|
|
Default OFF returns exactly what the catch-all returned before, so the baseline
|
|
the client has always seen is unchanged until this has been in front of the game.
|
|
"""
|
|
m = re.search(r"/item/(\d+)", h.path)
|
|
if h.command != "DELETE" or not m:
|
|
return 200, {}
|
|
if not QUICKSELL:
|
|
log(" QUICKSELL: id=%s seen, handler DISABLED (FUT_QUICKSELL=0), "
|
|
"answering {} as before" % m.group(1))
|
|
return 200, {}
|
|
iid = int(m.group(1))
|
|
sold, coins = STORE.quick_sell([iid])
|
|
if not sold:
|
|
# Do not claim to have sold a card we cannot account for. An invented verdict
|
|
# desyncs the client's model against ours, which is worse than an honest miss.
|
|
log(" QUICKSELL: id=%d NOT FOUND in either pile, no credit" % iid)
|
|
return 200, {"items": [], "totalCredits": STORE.coins()}
|
|
total = STORE.coins()
|
|
log(" QUICKSELL: sold id=%d for %d coins -> balance %d" % (iid, coins, total))
|
|
# FutDiscardCardServerResponse: items is an array of OBJECTS and there is no
|
|
# top-level id. Bare ints here would be a type desync, i.e. a freeze.
|
|
return 200, {"items": [{"id": iid}], "totalCredits": total}
|
|
|
|
|
|
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)]
|
|
# Live FIFA 17 bulk serializer FUN_180126f40 emits the singular atom
|
|
# `itemId` containing an array of int64 handles. Keep itemIds as a tolerant
|
|
# alias for old replay fixtures, but never rely on it for the retail client.
|
|
if not ids and isinstance(body.get("itemId"), list):
|
|
ids = body["itemId"]
|
|
if not ids and isinstance(body.get("itemIds"), list):
|
|
ids = body["itemIds"]
|
|
ids = [int(i) for i in ids if isinstance(i, int) and i > 0]
|
|
sellable_before = {it.get("id") for it in STORE.purchased() + STORE.items()}
|
|
sold, coins = STORE.quick_sell(ids)
|
|
if sold:
|
|
log(" QUICKSELL: sold %d card(s) for %d coins (total %d)"
|
|
% (sold, coins, STORE.coins()))
|
|
else:
|
|
log(" QUICKSELL: no requested ids were found; balance unchanged at %d"
|
|
% STORE.coins())
|
|
# FutDiscardCardServerResponse. `totalCredits` is the absolute post-sale
|
|
# wallet balance, not the sale delta. Only echo accounted-for IDs; duplicate
|
|
# or stale request handles must not be removed from the client model twice.
|
|
sold_ids = list(dict.fromkeys(iid for iid in ids if iid in sellable_before))
|
|
return 200, {
|
|
"items": [{"id": iid} for iid in sold_ids],
|
|
"totalCredits": STORE.coins(),
|
|
}
|
|
|
|
|
|
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)),
|
|
# DELETE ut/%s/item/<id> -- single-card Quick Sell. Captured live 2026-08-05.
|
|
# Disjoint from the /item(\?|$) move route below (that one cannot match a path
|
|
# with a trailing /<id>), but kept above it so the item routes read in one block.
|
|
(re.compile(G + r"/item/\d+"), lambda m, h: quick_sell_url_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)),
|
|
# Live-composed Draft URL, likewise invisible in the static request templates.
|
|
# It must precede generic /squad or squad_route answers {"id":0}, leaving the
|
|
# formation carousel empty even though FORMATION_DRAFT was accepted.
|
|
(re.compile(G + r"/squad/mode/\d+/draft/choices/formation"),
|
|
lambda m, h: draft_formation_choices_route(h)),
|
|
(re.compile(G + r"/squad/mode/\d+/draft/choices/captain"),
|
|
lambda m, h: draft_captain_choices_route(h)),
|
|
(re.compile(G + r"/squad/mode/\d+/draft/choices/player"),
|
|
lambda m, h: draft_player_choices_route(h)),
|
|
(re.compile(G + r"/squad/mode/\d+/draft/choices/manager"),
|
|
lambda m, h: draft_manager_choices_route(h)),
|
|
(re.compile(G + r"/squad/mode/\d+/draft/choose"),
|
|
lambda m, h: draft_choose_route(h)),
|
|
(re.compile(G + r"/purchase/mode/\d+/draft"), lambda m, h: draft_purchase_route(h)),
|
|
(re.compile(G + r"/squad"), lambda m, h: squad_route(h)),
|
|
(re.compile(G + r"/match/keepalive"), lambda m, h: (204, None)),
|
|
# PUT ut/%s/match/reset = FutResetMatch. Seen live at boot (2026-08-03) as an
|
|
# UNMAPPED catch-all; it is a Tier-B ack (shared no-op deser), so {} is correct
|
|
# -- routed explicitly so it stops showing up as an unmapped hit in the log.
|
|
(re.compile(G + r"/match/reset"), lambda m, h: (200, {})),
|
|
# THE CORE LOOP. ut/%s/match was the biggest hole in the API surface: we only
|
|
# answered keepalive/reset, so the base endpoint fell through to the catch-all
|
|
# {} and a finished match awarded NOTHING. Must precede any generic route.
|
|
# ut/delete/%s/match/{id} is the DELETE form (UTAS tunnels DELETE through a
|
|
# /ut/delete/ path prefix, same as trade/watchList/squad).
|
|
(re.compile(r"/ut/delete/game/[^/]+/match"), lambda m, h: match_route(h)),
|
|
(re.compile(G + r"/match"), lambda m, h: match_route(h)),
|
|
(re.compile(G + r"/hub"), lambda m, h: (200, hub_data())),
|
|
# Populated massinfo (see massinfo() above): userInfo + squad + settings.
|
|
(re.compile(G + r"/userMassInfo"), lambda m, h: (200, massinfo())),
|
|
# ---- game modes (FUT_MODES=1; default keeps the proven {} everywhere) ----
|
|
# Order matters: the more specific season/tournament sub-paths must precede
|
|
# the bare ones, and /leaderboards/options precedes /leaderboards.
|
|
(re.compile(G + r"/season/\d+/reset"), lambda m, h: (200, {"reset": True} if _MODES else {})),
|
|
(re.compile(G + r"/season/user"), lambda m, h: (200, season_user() if (_MODES and h.command == "GET") else {})),
|
|
(re.compile(G + r"/season/friendly"), lambda m, h: (200, {})),
|
|
(re.compile(G + r"/season"), lambda m, h: (200, season_list() if (_MODES and h.command == "GET") else {})),
|
|
(re.compile(r"/ut/delete/game/[^/]+/tournament"), lambda m, h: (200, {})),
|
|
(re.compile(G + r"/tournament/user"), lambda m, h: (200, tournament_user() if (_MODES and h.command == "GET") else {})),
|
|
(re.compile(G + r"/tournament"), lambda m, h: (200, tournament_list() if (_MODES and h.command == "GET") else {})),
|
|
(re.compile(G + r"/leaderboards"), lambda m, h: leaderboard_route(h) if _MODES else (200, {})),
|
|
(re.compile(G + r"/champion"), lambda m, h: champion_route(h) if _MODES else (200, {})),
|
|
# ---- Squad Building Challenges (SBC). Path family is `sbs/*` (not `sbc`).
|
|
# Baseline had NO sbs routes -> GET sbs/sets fell through to the catch-all {} and
|
|
# the client threw "problem communicating with the FUT servers" (empty set list,
|
|
# not a parse fault: the response root is object + skip-tolerant, so {} parses but
|
|
# carries no data). See sbc_sets_route for the reversed shape and the freeze rules.
|
|
# Order matters (first-match-wins, rx.search is unanchored): the more specific
|
|
# sbs paths MUST precede the generic /sbs/sets below, which would otherwise
|
|
# substring-match /sbs/sets/tag. squad save/load precedes start/submit; start vs
|
|
# submit split by body/method inside sbc_challenge_route.
|
|
(re.compile(G + r"/sbs/sets/tag/?$"), lambda m, h: sbc_tag_route(h) if _SBC else (200, {})),
|
|
(re.compile(G + r"/sbs/setId/\d+/challenges"), lambda m, h: sbc_challenges_route(h) if _SBC else (200, {})),
|
|
(re.compile(G + r"/sbs/challenge/\d+/squad/?$"), lambda m, h: sbc_squad_route(h) if _SBC else (200, {})),
|
|
(re.compile(G + r"/sbs/challenge/\d+"), lambda m, h: sbc_challenge_route(h) if _SBC else (200, {})),
|
|
(re.compile(G + r"/sbs/sets"), lambda m, h: sbc_sets_route(h) if _SBC 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).
|
|
# /tradePile/counts (GetAuctionCount) MUST precede /tradePile: the latter's regex
|
|
# also matches the /counts path, and the two responses are different shapes.
|
|
# CASE-INSENSITIVE (live 2026-08-06): the FUT-hub Transfer List TILE polls the
|
|
# LOWERCASE `tradepile`/`tradepile/counts`, while the Transfer List SCREEN uses
|
|
# camelCase `tradePile`. Case-sensitive routes matched only the screen, so the
|
|
# tile fell through to /trade (which contains "trade") and got a shape the counts
|
|
# deser skips -> the tile read "Selling: 0" while a card was actively listed.
|
|
(re.compile(G + r"/tradePile/counts", re.I), lambda m, h: auction_counts_route(h)),
|
|
(re.compile(G + r"/tradePile", re.I), 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: marketdata_route(h)),
|
|
# QUICK SELL. Live-observed 2026-08-04: the reveal screen's "Quick Sell All"
|
|
# sends POST ut/delete/%s/item -- it was UNMAPPED (catch-all {}), which the
|
|
# client ACCEPTS (no error, session survives) but which paid 0 coins: the user
|
|
# sold 6 cards for nothing. Credit discardValue per card and remove them.
|
|
(re.compile(r"/ut/delete/game/[^/]+/item"), lambda m, h: quick_sell_route(h)),
|
|
(re.compile(r"/ut/delete/game/[^/]+/trade"), lambda m, h: delete_trade_route(h)),
|
|
(re.compile(r"/ut/delete/game/[^/]+/watchList"), lambda m, h: (200, {})),
|
|
# Generic /club, LAST on purpose (it is a prefix of /clubUser).
|
|
# PUT -> ChangeClubName, per docs/ENDPOINT_MAP.md row 3 (the other of the two
|
|
# competing URL derivations; see club_rename_route).
|
|
# GET -> the club item list, unchanged. NOT switched to GetClubInfo's `user`
|
|
# shape: its element parser 0x18012c990 is only PARTIALLY decoded, and
|
|
# the rendered club cards come through /item (ViewCards) anyway.
|
|
# LIVE-OBSERVED, UNDOCUMENTED (found 2026-08-04 by replaying every path in
|
|
# /tmp/utas_server.log): the client really fetches ut/%s/club/stats/{consumables,
|
|
# staff,year} on the MY CLUB screen. They are suffix endpoints the request table
|
|
# never lists -- the same trap as /squad/list. They were being swallowed by the
|
|
# generic /club route, which answers with the FULL 28-item club list where the
|
|
# client asked for STATS: wrong shape, and re-sent on every poll.
|
|
(re.compile(G + r"/club/stats"), lambda m, h: club_stats_route(h)),
|
|
# MUST precede the generic /club: the client asks GET club/consumables/<category>
|
|
# and that path is a /club prefix, so without this line it is answered with the
|
|
# player list. LIVE 2026-08-05: it was, and the consumables screen was handed
|
|
# Cristiano Ronaldo when it asked for training cards.
|
|
(re.compile(G + r"/club/consumables"), lambda m, h: club_consumables_route(h)),
|
|
(re.compile(G + r"/club"), lambda m, h: club_route(h)),
|
|
]
|
|
|
|
|
|
# ---- GET ut/%s/hub : THE MY CLUB TILE COUNTER --------------------------------
|
|
# FutGetHubDataServerResponse. Body parser FUN_180139610 (14,855 chars, censused in
|
|
# full: 18 atom comparisons, none missed). Root container is a FLAT OBJECT, verified
|
|
# from the prologue by tokenizer-call calibration against the live-proven massinfo
|
|
# parser rather than assumed.
|
|
#
|
|
# THE CHAIN, end to end, re-derived independently by two agents (one via Ghidra, one
|
|
# via raw PE plus capstone with no decompiler) and checked by two reviewers:
|
|
#
|
|
# clubPlayers (atom 0x90) --INT getter 0x1801c79d0--> clamp FUN_1800d7b30 (<=0 -> 0)
|
|
# -> stored at R+0x3c, where R = FUT data-manager slot +0x1f8 (FUN_18011a810 is
|
|
# literally `lea rax,[rcx+0x1fd70]; ret`)
|
|
# -> read by FUN_1800b0250 and published as TEXT0 of TILE_ID 0x210
|
|
# -> captions FUT_GH_TOTAL_PLAYERS_0 / _1 at 0x18020a0f8 / 0x18020a110
|
|
# auctionCount (atom 0x33) -> R+0x38 -> TEXT0 of TILE_ID 0x1b0, the TRANSFERS tile
|
|
#
|
|
# `FUN_180139610` is the ONLY writer of +0x3c anywhere in the image (one write in
|
|
# 14,855 chars, guarded by `if (iVar6 != 0x90)`). So this is not a candidate, it is the
|
|
# field.
|
|
#
|
|
# WHY IT TOOK SO LONG, recorded because the error is instructive. The hunt went to
|
|
# /club/stats and stayed there for a day, and REBUILD_RESEARCH S19 concluded the
|
|
# counter was "not server-fixable" with a mechanism that was internally correct and
|
|
# entirely beside the point: the tile never read the club-stat store. Two things
|
|
# reinforced the wrong path. ENDPOINT_MAP said this response "uses C++ reflection /
|
|
# vtable dispatch, NOT an inline atom ladder, no static field ladder to read" and
|
|
# marked it a GAP, which is wrong: there IS an inline ladder, one indirection away.
|
|
# And the eight-row MY CLUB panel was assumed to be FUN_180043b90 case 1, which
|
|
# publishes six keys; it is actually FUN_180094ce0, a different provider using a
|
|
# different string family (FUT_MYCLUB_*), which reads neither the mode tag nor any
|
|
# type id we were sending. Two providers, and we were reading the wrong one.
|
|
#
|
|
# auctionCount is deliberately included as a FREE CONTROL: it lands in a different
|
|
# field (+0x38) and a different tile, so if the MY CLUB tile moves and TRANSFERS does
|
|
# not, the delivery is fine and something is specific to +0x3c.
|
|
#
|
|
# DEFAULT ON. Freeze risk is genuinely low rather than merely believed low: the body
|
|
# is a flat object of two integers, both read with the INT getter, so there is no
|
|
# array, no nested object, and no type-desync surface. FUT_HUBDATA=0 restores {}.
|
|
HUBDATA = os.environ.get("FUT_HUBDATA", "1") == "1"
|
|
|
|
|
|
def _is_player(it):
|
|
"""The CLIENT's own definition of a footballer, and the only one worth using.
|
|
|
|
FUN_1800d8330 maps cardsubtypeid 0..3 -> cardtype 1 (players); 4 is a manager,
|
|
5/6/7/8 the four coach families, 51..341 the consumables. Three counters used to
|
|
ask `itemType == "player"` instead -- the hub's clubPlayers tile, the MY CLUB
|
|
per-nation/league/team buckets and the global stat set. That string is INERT on
|
|
the wire (atom 0x173 is parsed into a stack std::string in FUN_18013fe00 and
|
|
freed; it never reaches the record), so keying our own screens on it made their
|
|
correctness depend on a field the client ignores. Provable no-op on the current
|
|
save: all 194 items are cardsubtypeid 0 and itemType "player"."""
|
|
return it.get("cardsubtypeid", 0) in (0, 1, 2, 3)
|
|
|
|
|
|
def hub_data():
|
|
"""GET ut/%s/hub -- the FUT hub tile counters.
|
|
|
|
The hub parser FUN_180139610 reads 18 atoms; two of them (auctionCount 0x33,
|
|
clubPlayers 0x90) we already serve. The FUT-hub 'TRANSFER LIST' tile
|
|
(items / Selling / Sold) is fed by a THIRD atom we were omitting: tradePile
|
|
(0x333), a nested object parsed by sub-deser 0x18013ead0. That sub-parser reads
|
|
count(0xbc), notification(0x1da), selling(0x2b8), sold(0x2c9) -- the same atom
|
|
scheme as GetAuctionCount (/tradePile/counts) -- each a SCALAR INT via the int
|
|
getter 0x1801c79d0 (5 int reads, one SKIP, an object field loop; no array, no
|
|
nested object => no type-desync surface). Confirmed 2026-08-06 straight from the
|
|
on-disk CardsDLL via objdump (scratchpad/hub_ladder.py).
|
|
LIVE SYMPTOM this fixes: a card was actively listed (auctionCount 1, Listed Items
|
|
showed it) yet the TRANSFER LIST tile read '0 items / Selling 0' -- the tile reads
|
|
hub.tradePile, not /tradePile/counts (which the tile never re-polls). All active
|
|
listings are 'selling'; none are 'sold'. count == selling == number of listings."""
|
|
if _OPENED_PACK_GRACE:
|
|
log(" STORE: retiring %d opened-pack grace entry at hub"
|
|
% len(_OPENED_PACK_GRACE))
|
|
_OPENED_PACK_GRACE.clear()
|
|
if not HUBDATA:
|
|
return {}
|
|
players = len([i for i in STORE.items() if _is_player(i)])
|
|
auctions = len(STORE.listings())
|
|
log(" HUB: clubPlayers=%d auctionCount=%d selling=%d" % (players, auctions, auctions))
|
|
return {"clubPlayers": players, "auctionCount": auctions,
|
|
"tradePile": {"count": auctions, "selling": auctions, "sold": 0}}
|
|
|
|
|
|
# ---- club stats: the CLUB STATS panel, and probably the MY CLUB tile too ------
|
|
# GET ut/%s/club/stats/<mode> -> FutStickerBookStats2ServerResponse, deser
|
|
# 0x180130150 (7,870 chars, read end to end). Wire schema, fully verified:
|
|
#
|
|
# {"stat":[{"contextId":int, "contextValue":int, "type":str, "typeValue":int}]}
|
|
# contextId(0xb6) INT 0x1801c79d0
|
|
# contextValue(0xb7) INT 0x1801c79d0
|
|
# type(0x354) STRING 0x1801c7aa0 -> copied into a 0x30-byte buffer
|
|
# typeValue(0x355) INT 0x1801c79d0
|
|
# Unknown keys route to FUN_180135ff0 at BOTH nesting levels, so extras are inert.
|
|
#
|
|
# FIVE THINGS THAT DECIDE WHETHER THIS WORKS, all learned the hard way:
|
|
#
|
|
# 1. EVERY RESPONSE WIPES THE WHOLE MAP FIRST. Nothing accumulates. So a good body on
|
|
# one mode followed by a thin body on another ERASES the first, and the ordering of
|
|
# the client's requests would decide what survives. The fix is to serve the SAME
|
|
# COMPLETE SET for every Stats2 mode: then whichever request lands last leaves the
|
|
# map correct and ordering stops mattering. (One investigator reported this factory
|
|
# does NOT wipe; a reviewer re-read it and refuted that. The wipe is real.)
|
|
# 2. /club/stats/staff IS A DIFFERENT CLASS. It is FutStaffBonus, shape
|
|
# {"bonus":[{"type":str,"value":int}]}, NOT Stats2. Sending a {"stat":[...]} body
|
|
# there is harmless but does nothing. Its type strings are not decoded, so it keeps
|
|
# {} -- which is safe, because that parser's top-level loop exits immediately on
|
|
# END_OBJECT. It also means staff does NOT wipe the Stats2 map.
|
|
# 3. ELEMENT-LOCAL VARIABLES ARE NOT RESET BETWEEN ELEMENTS. The clears happen once
|
|
# before the array loop, not inside it, so omitting a key in element N silently
|
|
# inherits element N-1's value. ALWAYS EMIT ALL FOUR KEYS IN EVERY ELEMENT.
|
|
# 4. The storage key is contextValue ALONE. contextId is only a guard: contextId == 1
|
|
# or 5 <= contextId <= 9 forces contextValue to 0, which is the global bucket the
|
|
# +0x800 getter reads. We use contextId 1 throughout to land everything there.
|
|
# 5. Three ids the panel READS can never be SET from here: 0x3d CONTRACTS,
|
|
# 0x3e TRAINING, 0x40 FITNESS. No type string produces them.
|
|
#
|
|
# type string -> internal id -> the on-screen row it moves (FUN_18012fd40 -> the club
|
|
# stats provider FUN_180043b90):
|
|
# players 1 PLAYERS rarePlayers 5 staff 0xa STAFF_EMPLOYED
|
|
# stadia 0x14 STADIA_OWNED balls 0x1e BALLS_EARNED kits 0x28 KITS_AVAILABLE
|
|
# badges 0x2d BADGES trophies 0x32 TROPHIES_WON
|
|
#
|
|
# WHY THIS IS NOW ALSO THE HUB-TILE CANDIDATE. The investigation concluded the MY CLUB
|
|
# tile does not read this store, but flagged that negative as BOUNDED: the interface
|
|
# comes through a QueryInterface adapter, so the vtable is assembled at runtime and
|
|
# cannot be read statically. Live evidence on 2026-08-04 points the other way. The hub
|
|
# tile reads "0 TOTAL PLAYERS" and the CLUB STATS panel reads "Players 0", the same
|
|
# quantity, both zero, while we answer {}. And FUT_CLUB_PAGE ruled out the alternative:
|
|
# we served 114 items to /club and the tile still said 0, so it is not a count of the
|
|
# list. Strong inference, not proof. This flag is the test.
|
|
#
|
|
# The test is unusually clean because the club holds 114 items and ALL of them are
|
|
# players: every other row is an honest zero. So if this works, exactly two numbers
|
|
# move (Players and Rare Players, 0 -> 114) and nothing else changes.
|
|
# DEFAULT ON since 2026-08-04: LIVE-PROVEN. With this serving, the MY CLUB ->
|
|
# ENGLAND -> Premier League row went from 0 to 17, the first non-zero number ever
|
|
# rendered on that screen. Nothing froze and no other screen changed. The global
|
|
# bucket rows ride along and are harmless; they are what the MY CLUB summary and
|
|
# hub tile WOULD read if anything ever selected those cases (see REBUILD_RESEARCH
|
|
# S19, still open). FUT_CLUBSTATS=0 reverts to the old {}.
|
|
CLUBSTATS = os.environ.get("FUT_CLUBSTATS", "1") == "1"
|
|
|
|
|
|
def _counts_for(players):
|
|
"""The per-collection counts, from a list of player items."""
|
|
# Gold/silver/bronze is FIFA's rating convention (75+/65-74/under), NOT read out of
|
|
# the binary. If a tile ever disagrees, this is the line to doubt.
|
|
return [
|
|
("players", len(players)),
|
|
("playersGold", len([i for i in players if (i.get("rating") or 0) >= 75])),
|
|
("playersSilver", len([i for i in players if 65 <= (i.get("rating") or 0) < 75])),
|
|
("playersBronze", len([i for i in players if 0 < (i.get("rating") or 0) < 65])),
|
|
("rarePlayers", len([i for i in players if i.get("rareflag")])),
|
|
]
|
|
|
|
|
|
def _club_stat_context(kind):
|
|
"""Per-context rows, keyed the way the READER actually looks them up.
|
|
|
|
Read out of the club-stats provider FUN_180043b90, not guessed. The per-context
|
|
getter is `(+0x7f8)(store, contextValue, typeId)`, and the crucial detail is where
|
|
contextValue comes from: THE UI ROW, not the URL.
|
|
|
|
case 3: uVar7 = (**(param_2 + 0x18))(param_2, row, "LEAGUE_ID")
|
|
bronze = (+0x7f8)(store, uVar7, 2)
|
|
silver = (+0x7f8)(store, uVar7, 3)
|
|
gold = (+0x7f8)(store, uVar7, 4)
|
|
publish "PLAYERS_EMPLOYED", gold + silver + bronze
|
|
rare = (+0x7f8)(store, uVar7, 5)
|
|
kits = (+0x7f8)(store, uVar7, 0x28)
|
|
badges = (+0x7f8)(store, uVar7, 0x2d)
|
|
case 4: keyed by "TEAM_ID"; reads 1 (players), 0x28 (kits), 0x2e (badgeDBid)
|
|
|
|
THREE CONSEQUENCES, all of which my first attempt got wrong:
|
|
|
|
1. One response must carry a bucket for EVERY ROW the screen will show, because
|
|
the reader iterates rows and looks up each row's own id. Keying everything to
|
|
the id in the URL, which is what I did first, fills exactly one bucket that the
|
|
screen never asks for.
|
|
2. `PLAYERS_EMPLOYED` is COMPUTED as gold + silver + bronze. It is never read from
|
|
the store in the per-context cases, so sending `players` (type id 1) does
|
|
nothing there. The tier counts are mandatory, not decoration.
|
|
3. The screens NEST: country/<id> lists the LEAGUES in that nation (case 3, keyed
|
|
by LEAGUE_ID) and league/<id> lists the TEAMS (case 4, keyed by TEAM_ID). That
|
|
matches the live navigation exactly: selecting ENGLAND produced a Premier
|
|
League / Championship / League One / League Two list.
|
|
|
|
Because every response WIPES the whole map, each response only needs the buckets
|
|
for its own screen. That is also what keeps nation ids and league ids from
|
|
colliding: the storage key is contextValue alone, so nation 14 and league 14 would
|
|
otherwise share a bucket. One kind per response, no collision.
|
|
|
|
contextId 3 is used because it is OUTSIDE the guard (contextId == 1, or 5..9,
|
|
force contextValue to 0) and therefore preserves contextValue. `TODO/CONFIRM`
|
|
whether contextId carries further meaning; nothing read so far gives it one.
|
|
"""
|
|
players = [i for i in STORE.items() if _is_player(i)]
|
|
# kind -> which id the SCREEN's rows are keyed by.
|
|
# "" the MY CLUB tab strip itself: its nation tiles and the eight-row
|
|
# panel FUN_180094ce0, which computes PLAYERS_EMPLOYED as
|
|
# +0x7f8(nationId, 4) + (nationId, 3) + (nationId, 2). Per NATION.
|
|
# country the leagues inside a nation (case 3, keyed by LEAGUE_ID)
|
|
# league the teams inside a league (case 4, keyed by TEAM_ID)
|
|
field = {"": "nation", "country": "leagueId", "league": "teamid"}.get(kind)
|
|
if not field:
|
|
return [], 0
|
|
ctxs = sorted({i.get(field) for i in players if i.get(field) is not None})
|
|
rows = []
|
|
for ctx in ctxs:
|
|
sel = [i for i in players if i.get(field) == ctx]
|
|
if field == "teamid":
|
|
counts = [("players", len(sel)), ("kits", 0), ("badgeDBid", 0)]
|
|
else:
|
|
counts = [c for c in _counts_for(sel) if c[0] != "players"]
|
|
counts += [("kits", 0), ("badges", 0)]
|
|
rows += [{"contextId": 3, "contextValue": int(ctx), "type": t, "typeValue": int(v)}
|
|
for t, v in counts]
|
|
return rows, len(ctxs)
|
|
|
|
|
|
|
|
# FUT_CONSUM_STATS: answer the CONSUMABLES panel with CONSUMABLE counts.
|
|
#
|
|
# LIVE 2026-08-05, and this is the whole reason the tab was empty. The client asks
|
|
# GET club/stats/consumables 41 times a session and we answered it with the PLAYER
|
|
# stat set (players 205, playersGold 189 ...). The panel reads a different set of
|
|
# names entirely, so it was told about footballers when it asked about contracts.
|
|
# The player screenshot is unambiguous: seven categories -- Training, Contracts,
|
|
# Fitness, Healing, Chemistry Style, Manager League, Position Modifier -- every one
|
|
# reading 0, on a tab that is present and selectable.
|
|
#
|
|
# The vocabulary is not a string table: FUN_18012fd40 looks the `type` string up in
|
|
# the ATOM table and switches on 40 atom ids, default `return 0`. An unrecognised
|
|
# name is therefore INERT, not fatal, which is what makes appending these rows safe.
|
|
#
|
|
# COUNT THE SHELF, NOT THE STORE. The consumables we serve are a synthetic overlay
|
|
# and are never granted into the save, so STORE.items() holds none of them and
|
|
# counting it yields fourteen zeros -- which looks exactly like failure on screen and
|
|
# would make the experiment unreadable.
|
|
#
|
|
# Default ON: answering the consumables panel with player counts is wrong by
|
|
# inspection, not a judgement call. Set FUT_CONSUM_STATS=0 to go back.
|
|
CONSUM_STATS = os.environ.get("FUT_CONSUM_STATS", "1") == "1"
|
|
# Serve consumables as TRADEABLE by default; see _consumable_stacks for why.
|
|
CONSUM_UNTRADEABLE = os.environ.get("FUT_CONSUM_UNTRADEABLE", "0") == "1"
|
|
|
|
# FUT_CLUBITEMS: balls, stadia, badges, kits and league logos.
|
|
#
|
|
# Counts FIRST, deliberately. The consumables round proved that the client does not
|
|
# request an item list until club/stats reports a non-zero count for that family, and
|
|
# the CLUB tab reads exactly these ids: 0x1e balls, 0x28 kits, 0x14 stadia. So arming
|
|
# the counters is what makes the client name the item route it uses -- which is the
|
|
# one thing no amount of static reading has produced for this family, because
|
|
# cardtype 9 has no arm in the merge at all.
|
|
#
|
|
# Default OFF: nothing here has ever been requested by the client, so unlike the
|
|
# consumables stat fix this is not correcting a demonstrably wrong answer.
|
|
# "1" serves each family on its own type= arm and serves NOTHING for equippables.
|
|
# "probe:<family>" serves one item per candidate cardsubtypeid for that family only.
|
|
_CLUBITEMS_MODE = os.environ.get("FUT_CLUBITEMS", "")
|
|
CLUBITEMS = bool(_CLUBITEMS_MODE) and _CLUBITEMS_MODE != "0"
|
|
|
|
|
|
def _consumable_stat_rows():
|
|
"""[(stat name, count)] for the consumables panel, counted from the shelf."""
|
|
if not (CONSUM_STATS and CONSUMABLES):
|
|
return []
|
|
try:
|
|
import fut_consumables
|
|
import fut_club_stats
|
|
except Exception as e: # never break the panel over this
|
|
log(" CLUBSTATS: consumable rows unavailable (%s)" % e)
|
|
return []
|
|
shelf = fut_consumables.starter_consumables(fut_consumables.CONSUMABLE_ID_BASE)
|
|
g = fut_club_stats.global_counts(shelf)
|
|
rows = [(fut_club_stats.VOCAB[sid], v) for sid, v in sorted(g.items())
|
|
if sid >= 0x3C and sid in fut_club_stats.VOCAB]
|
|
log(" CLUBSTATS: %d consumable row(s) from a shelf of %d" % (len(rows), len(shelf)))
|
|
return rows
|
|
|
|
|
|
def _club_stat_set():
|
|
"""The complete global stat set, computed from what the club actually holds."""
|
|
items = STORE.items()
|
|
players = [i for i in items if _is_player(i)]
|
|
rare = [i for i in players if i.get("rareflag")]
|
|
# Gold/silver/bronze is FIFA's rating convention (75+/65-74/below), NOT something
|
|
# read out of the binary. Marked as a convention because it is one; if a tile ever
|
|
# disagrees, this is the line to doubt.
|
|
gold = [i for i in players if (i.get("rating") or 0) >= 75]
|
|
silver = [i for i in players if 65 <= (i.get("rating") or 0) < 75]
|
|
bronze = [i for i in players if 0 < (i.get("rating") or 0) < 65]
|
|
# Staff the club is CURRENTLY BEING SERVED (the FUT_COACHES / FUT_MANAGERS
|
|
# overlay). All zero unless a flag is set. This is the free second oracle for the
|
|
# staff round: the eight-row panel's STAFF_EMPLOYED number moves without the merge
|
|
# being involved at all, so "our club really holds N staff" stays separable from
|
|
# "the client resolved the card". Keyed by cardsubtypeid: 4 manager, 5 head coach,
|
|
# 6 GK coach, 7 physio, 8 fitness coach.
|
|
_staff = _staff_overlay_counts()
|
|
counts = [
|
|
("players", len(players)),
|
|
("playersGold", len(gold)),
|
|
("playersSilver", len(silver)),
|
|
("playersBronze", len(bronze)),
|
|
("rarePlayers", len(rare)),
|
|
# Honest zeros: this club holds no non-player items of any kind... unless a
|
|
# staff overlay is armed, in which case _staff below is what it holds.
|
|
("staff", sum(_staff.values())),
|
|
("stadia", 0), # 0x14, read directly by STADIA_OWNED
|
|
("balls", 0), # 0x1e, read directly by BALLS_EARNED
|
|
("kits", 0),
|
|
("badges", 0),
|
|
("trophies", 0),
|
|
# The eight-row panel FUN_180094ce0 does NOT read staff(0xa) or trophies(0x32).
|
|
# It SUMS the sub-types: STAFF_EMPLOYED = +0x800 over 0xb..0xf, and
|
|
# TROPHIES_WON = +0x800 over 0x33..0x38. Sending the parent ids alone can
|
|
# never move those two rows. All zero today because the club owns no staff and
|
|
# has won nothing, but the mapping is what matters when it does.
|
|
("staffManager", _staff[4]), # 0xb
|
|
("staffHeadCoach", _staff[5]), # 0xc
|
|
("staffGKCoach", _staff[6]), # 0xd
|
|
("staffPhysio", _staff[7]), # 0xe
|
|
("staffFitnessCoach", _staff[8]), # 0xf
|
|
("trophiesOffline", 0), # 0x33
|
|
("trophiesOnline", 0), # 0x34
|
|
("trophiesFeaturedOffline", 0), # 0x35
|
|
("trophiesFeaturedOnline", 0), # 0x36
|
|
("trophiesSeasonOffline", 0), # 0x37
|
|
]
|
|
counts += _consumable_stat_rows()
|
|
if CLUBITEMS:
|
|
import fut_clubitems
|
|
ci = fut_clubitems.counts()
|
|
# REPLACE the honest zeros above rather than appending a second row per name:
|
|
# the deserializer writes store[contextValue][statId] = typeValue, so a later
|
|
# row silently wins and two rows for one id is a coin toss.
|
|
have = dict(ci)
|
|
counts = [(t, have.get(t, v)) for t, v in counts]
|
|
for t, v in ci:
|
|
if t not in [c[0] for c in counts]:
|
|
counts.append((t, v))
|
|
log(" CLUBITEMS: %s" % ", ".join("%s=%d" % kv for kv in ci))
|
|
# All four keys in every element -- see note 3 above.
|
|
return [{"contextId": 1, "contextValue": 0, "type": t, "typeValue": int(v)}
|
|
for t, v in counts]
|
|
|
|
|
|
# GET ut/%s/club/consumables/<category> -- the consumables ITEM list.
|
|
#
|
|
# FOUND LIVE 2026-08-05, and only because the counts were fixed first. The client
|
|
# does not ask for consumables through club?type=; it asks here, and it asks ONLY
|
|
# once club/stats/consumables reports a non-zero count. So the counter was the gate
|
|
# and this route is the door. Before this line existed the path fell through to the
|
|
# generic /club prefix and the consumables screen was answered with the 194-card
|
|
# player list.
|
|
#
|
|
# The segment names come from the UI group table at 0x180203260 (codes 0x00 training,
|
|
# 0x01 contracts, 0x04 fitness, 0x03 healing, 0x17 playStyle, 0x18
|
|
# managerLeagueModifier, 0x11 position). "training" and "contracts" are CONFIRMED on
|
|
# the wire; the other five are from that table and are matched case-insensitively,
|
|
# with the singular "contract" accepted because the client has used both spellings.
|
|
#
|
|
# The category numbers are fut_consumables' own, and they line up with the panel
|
|
# exactly: 0 -> Training 42, 2+3 -> Contracts 13, 4 -> Healing 21, 5 -> Fitness 6,
|
|
# 8 -> Position Modifier 20, 9 -> Chemistry Style 24. That correspondence is what
|
|
# makes an empty list here distinguishable from a wrong mapping.
|
|
CLUB_CONSUMABLE_CATS = {
|
|
"training": {0},
|
|
"contracts": {2, 3},
|
|
"contract": {2, 3},
|
|
"fitness": {5},
|
|
"healing": {4},
|
|
"position": {8},
|
|
"playstyle": {9},
|
|
"managerleaguemodifier": {10},
|
|
}
|
|
|
|
|
|
_DATA_DIR_TABLES = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
"..", "data", "tables")
|
|
_CARDASSET_BY_RESOURCE = None
|
|
|
|
|
|
def _cardasset_map():
|
|
"""carddbid -> cardassetid, from the game's own fcc_* tables.
|
|
|
|
THE GREEN "NOT FOUND" BOX. A consumable card draws its art from cardassetid, and
|
|
that is a SMALL art id in the fcc tables (3 training, 7 contract, 10 healing,
|
|
45 misc), NOT the carddbid. fut_store._item copies the resourceId into
|
|
cardassetid, which is right for players and wrong here: the client looked for art
|
|
id 5003001, found none, and fell back to
|
|
external/ion_fut/artAssets/.../notfound.swf -- the green placeholder a player
|
|
photographed on 2026-08-05.
|
|
|
|
Built from data/tables/fcc_*.json, which were dumped read-only from the client's
|
|
own database, so these are the game's ids and not a guess.
|
|
"""
|
|
global _CARDASSET_BY_RESOURCE
|
|
if _CARDASSET_BY_RESOURCE is None:
|
|
import glob
|
|
m = {}
|
|
for f in glob.glob(os.path.join(_DATA_DIR_TABLES, "fcc_*.json")):
|
|
try:
|
|
rows = json.load(open(f)).get("rows") or []
|
|
except Exception:
|
|
continue
|
|
for r in rows:
|
|
if "carddbid" in r and "cardassetid" in r:
|
|
m[r["carddbid"]] = r["cardassetid"]
|
|
_CARDASSET_BY_RESOURCE = m
|
|
log(" CONSUMABLES: %d carddbid->cardassetid art mappings loaded" % len(m))
|
|
return _CARDASSET_BY_RESOURCE
|
|
|
|
|
|
def _consumable_stacks(items):
|
|
"""Wrap consumables as the STACK records this response class actually reads.
|
|
|
|
RESOLVED LIVE 2026-08-05 the hard way: we served bare items here, the client
|
|
took the response and inserted NOTHING (the card map held only the 11 squad
|
|
players), and the screen stayed empty with no error anywhere.
|
|
|
|
FutConsumablesSearchServerResponse (RS4 literal 0x1802222f8, factory
|
|
0x180130a10, vtable 0x180222200, deserializer +0x08 = 0x180130d10, 6873 chars)
|
|
takes itemData(0x16b) at the root like the club list, but its ELEMENT is not an
|
|
item. It is a five-atom wrapper and only ONE of those five carries the item:
|
|
|
|
0xbc count int
|
|
0xd7 discardValue int
|
|
0x16a item -> FUN_18013fe00, the item parser itself
|
|
0x287 resourceId int
|
|
0x362 untradeableCount int
|
|
|
|
Everything else falls to the value-skip handler, which is why a bare item was
|
|
accepted and silently did nothing. That is also why FUT draws consumables as one
|
|
stack with a quantity rather than N separate cards.
|
|
|
|
Identical consumables are therefore COLLAPSED by resourceId and counted.
|
|
"""
|
|
stacks = {}
|
|
for it in items:
|
|
rid = it.get("resourceId")
|
|
s = stacks.get(rid)
|
|
if s is None:
|
|
# untradeable is forced OFF on the copy we serve. The deserializer sets a
|
|
# flag from (untradeableCount < count), so untradeableCount == count means
|
|
# "every copy is untradeable" and the card draws the untradeable badge --
|
|
# the green tag a player asked about on 2026-08-05. In FIFA 17 a
|
|
# pack-opened consumable is normally tradeable, so the badge was our own
|
|
# data showing through, not the client being wrong. FUT_CONSUM_UNTRADEABLE=1
|
|
# restores the old behaviour.
|
|
item = dict(it)
|
|
if not CONSUM_UNTRADEABLE:
|
|
item["untradeable"] = False
|
|
art = _cardasset_map().get(rid)
|
|
if art is not None:
|
|
item["cardassetid"] = art
|
|
s = stacks[rid] = {"count": 0, "discardValue": 0, "item": item,
|
|
"resourceId": rid, "untradeableCount": 0}
|
|
s["count"] += 1
|
|
if CONSUM_UNTRADEABLE and it.get("untradeable"):
|
|
s["untradeableCount"] += 1
|
|
return list(stacks.values())
|
|
|
|
|
|
def club_consumables_route(h):
|
|
seg = h.path.split("/club/consumables", 1)[-1].split("?")[0].strip("/").lower()
|
|
if not CONSUMABLES:
|
|
# Flag off: the club genuinely holds none. An empty itemData is the honest
|
|
# answer and is the same shape the client already accepts elsewhere.
|
|
return 200, {"itemData": []}
|
|
import fut_consumables
|
|
shelf = fut_consumables.starter_consumables(fut_consumables.CONSUMABLE_ID_BASE)
|
|
cats = CLUB_CONSUMABLE_CATS.get(seg)
|
|
if cats is None:
|
|
# An unknown segment: serve the whole shelf rather than nothing, so a
|
|
# spelling we have not seen still shows cards instead of an empty screen,
|
|
# and log it loudly because it is a new wire fact worth acting on.
|
|
log(" CONSUMABLES: UNKNOWN category %r -- serving the whole shelf. Add it "
|
|
"to CLUB_CONSUMABLE_CATS." % seg)
|
|
items = shelf
|
|
else:
|
|
items = [i for i in shelf
|
|
if fut_consumables.BY_SUBTYPE[i["cardsubtypeid"]]["category"] in cats]
|
|
log(" CONSUMABLES: %s -> %d item(s)" % (seg or "(none)", len(items)))
|
|
stacks = _consumable_stacks(items)
|
|
log(" CONSUMABLES: %d item(s) collapsed into %d stack(s)" % (len(items), len(stacks)))
|
|
return 200, {"itemData": stacks}
|
|
|
|
|
|
def club_stats_route(h):
|
|
mode = h.path.split("/club/stats/", 1)[-1].split("?")[0] if "/club/stats/" in h.path else ""
|
|
if not CLUBSTATS:
|
|
return 200, {}
|
|
if mode.startswith("staff"):
|
|
# FutStaffBonus, a different class with a different shape. {} is safe and
|
|
# deliberately does not disturb the Stats2 map.
|
|
return 200, {}
|
|
stats = _club_stat_set()
|
|
# Per-context modes carry an id in the URL: country/<nation>, league/<id>,
|
|
# team/<id>. Those tabs read a bucket keyed by that id, so the global rows above
|
|
# are invisible to them. Both sets go in the SAME response because every response
|
|
# wipes the whole map first, so anything left out of this body is erased.
|
|
parts = mode.split("/")
|
|
if len(parts) >= 2 and parts[1].isdigit():
|
|
# The id in the URL says which screen we are on, NOT which bucket to fill.
|
|
# country/<n> renders a list of leagues keyed by LEAGUE_ID; league/<n> renders
|
|
# a list of teams keyed by TEAM_ID. So fill every bucket that screen can show.
|
|
ctx_rows, n = _club_stat_context(parts[0])
|
|
stats = stats + ctx_rows
|
|
log(" CLUBSTATS: %s -> %d rows (global players=%d, %d %s buckets)"
|
|
% (mode, len(stats), stats[0]["typeValue"], n,
|
|
{"country": "league", "league": "team"}.get(parts[0], parts[0])))
|
|
return 200, {"stat": stats}
|
|
# No id in the URL: this is the MY CLUB tab strip (year / consumables / club /
|
|
# newcards). Its nation tiles and its eight-row panel read PER-NATION buckets.
|
|
# LIVE 2026-08-04: the ENGLAND tile read 0 while its own Premier League row read
|
|
# 17, which is this bug exactly one level up -- the leagues were keyed and the
|
|
# nations were not.
|
|
ctx_rows, n = _club_stat_context("")
|
|
stats = stats + ctx_rows
|
|
log(" CLUBSTATS: %s -> %d rows (global players=%d, %d nation buckets)"
|
|
% (mode or "(none)", len(stats), stats[0]["typeValue"], n))
|
|
return 200, {"stat": stats}
|
|
|
|
|
|
# FUT_CLUB_PAGE -- an EXPERIMENT, not a fix, aimed at the MY CLUB hub counter.
|
|
#
|
|
# The counter's renderer is NOT in cardsdll.dll. There is no two-number formatter of
|
|
# any spelling in the binary, no tab-strip layout and no display label; the tab entry
|
|
# itself carries NAME/UUID/CARD_ID/CARD_TYPE/TAB_INDEX/LEVEL/IS_CLUB and panel-wide
|
|
# numTabs/numCards, and nothing else. The composition happens in FIFA17.exe or the
|
|
# Scaleform assets, neither of which is readable. Separately, FutStickerBookSearch
|
|
# (the GET /club?... parser) has no count or total atom at all: it reads itemData and
|
|
# nothing else. So there is no field we can send that IS the number.
|
|
#
|
|
# What remains testable: if the counter is a Flash-side count over whatever the club
|
|
# list returned, then changing the LENGTH of that array moves it. That is a pure
|
|
# volume change with no new key and no type change, which makes it the cheapest
|
|
# possible discriminator.
|
|
#
|
|
# The client asks with count=11 (and count=34 when filtering by position). We already
|
|
# ignore the query string entirely and return everything, so this flag deliberately
|
|
# does the opposite of paging: it is here to make the returned length UNAMBIGUOUS in
|
|
# the log so the on-screen number can be compared against it.
|
|
#
|
|
# A NULL RESULT IS THE VALUABLE ONE. If the counter does not move, the "counter is a
|
|
# count of what we sent" hypothesis is dead, and with the binary evidence above that
|
|
# leaves no server-side lever at all for this symptom -- which would mean the correct
|
|
# outcome is to prove it and stop, not to keep generating candidate bodies.
|
|
#
|
|
# Risk: paging behaviour on the client side is unknown, so an unexpected length could
|
|
# make the club screen misbehave, and then the counter reading is worthless rather
|
|
# than informative. Default OFF.
|
|
CLUB_PAGE = os.environ.get("FUT_CLUB_PAGE") == "1"
|
|
|
|
|
|
# ---- FUT_ID_SWEEP: use the game itself as the player-database oracle --------
|
|
#
|
|
# Card identity is never taken from the wire. Every item we serve is registered
|
|
# into the client's CardsDb map, and just before that the client merges in its OWN
|
|
# local `players` table keyed on `resourceId & 0xffffff`. So serving a RANGE of
|
|
# candidate playerids and then reading the map back (tools/card_identity_probe.py)
|
|
# classifies the whole range in one pass. That is the entire database-extraction
|
|
# problem solved from the outside, without unpacking anything: dbdata.dll turned
|
|
# out to be an anti-tamper decoy, and the real table only exists inside the running
|
|
# game.
|
|
#
|
|
# Three outcomes per id, all distinguishable in the record (proven live 2026-08-04):
|
|
# HIT real name at +0xb8/+0xc8; teamid/nation/leagueId filled from the
|
|
# DB because we send them as ZERO (the merge only fills zeros).
|
|
# NAMELESS ROW our sentinel rating survives but the name is the DB's default
|
|
# row, "Jamal Blackman". The row exists and is empty.
|
|
# TRUE MISS the merge's miss fill: rating 0x32, teamid 0x78d, nation 0xe.
|
|
# SENTINEL_RATING is deliberately NOT 50, so a miss can never be mistaken for it.
|
|
#
|
|
# The sweep is SYNTHETIC: nothing is written to the save, so clearing the flag
|
|
# restores the real club exactly. Format: FUT_ID_SWEEP="<lo>-<hi>".
|
|
_ID_SWEEP = os.environ.get("FUT_ID_SWEEP", "")
|
|
SWEEP_FILE = os.environ.get("FUT_ID_SWEEP_FILE", "/tmp/fut_id_sweep")
|
|
SWEEP_ID_BASE = 900000000 # item ids, distinct from the save's 100000000+
|
|
SWEEP_SENTINEL_RATING = 7
|
|
|
|
|
|
def sweep_window():
|
|
"""The active sweep window: the control FILE if it has content, else the env.
|
|
|
|
Read per REQUEST, on purpose. A full database sweep is many windows, and
|
|
restarting this server to re-aim it is exactly the action that once produced
|
|
"error connecting to FIFA 17 Ultimate Team" mid-session -- a plain connection
|
|
refusal, misread for weeks as a protocol bug. Writing a range into the file
|
|
re-aims the sweep with the client still live; emptying the file restores the
|
|
real club on the very next fetch.
|
|
"""
|
|
try:
|
|
with open(SWEEP_FILE) as f:
|
|
s = f.read().strip()
|
|
if s:
|
|
return s
|
|
except (IOError, OSError):
|
|
pass
|
|
return _ID_SWEEP
|
|
|
|
|
|
# Auto-advance state. The client PAGES the club -- one visit produced seven
|
|
# GET /club?...start=50&count=11 fetches -- so an auto window can hand out a new
|
|
# chunk on every fetch and cover ~7 chunks per visit instead of one. Item ids are
|
|
# derived from the candidate's OFFSET IN THE WHOLE RANGE, not from its index in
|
|
# the chunk, so chunks never collide in the map and results ACCUMULATE across
|
|
# fetches; one probe at the end reads them all.
|
|
_SWEEP_SPEC = None
|
|
_SWEEP_POS = 0
|
|
_SWEEP_SUBTYPE = 0 # 0..3 = player; see _parse_window for the other tables
|
|
# The five non-player card tables, as dispatched by FUN_1800d8330:
|
|
# 4 -> managercards 5 -> headcoachcards 6 -> gkcoachcards
|
|
# 7 -> physiocards 8 -> fitnesscoachcards
|
|
_STAFF_SUBTYPES = [4, 5, 6, 7, 8]
|
|
|
|
|
|
def _parse_window(win):
|
|
"""'lo-hi' or 'auto:lo-hi:step' -> (lo, hi, step or None). None on garbage.
|
|
|
|
An optional 't<subtype>@' prefix sweeps a NON-PLAYER card table. The merge
|
|
FUN_180141660 dispatches on record+0x4c, which FUN_1800d8330 derives from
|
|
cardsubtypeid alone, and each branch queries a different table by
|
|
carddbid = record+0x18 (the same field players use for playerid):
|
|
|
|
0..3 -> 1 players 5 -> 3 headcoachcards
|
|
4 -> 2 manager 8 -> 4 fitnesscoachcards
|
|
6 -> 10 gkcoachcards 7 -> 5 physiocards
|
|
9..b -> 7 (unidentified) absent -> 0x156 -> 0, NO merge at all
|
|
|
|
So 't5@auto:1-20000:5000' sweeps head coaches exactly the way the default
|
|
sweeps players. Nothing about the non-player branches is live-proven yet;
|
|
they are read out of the binary.
|
|
"""
|
|
global _SWEEP_SUBTYPE
|
|
_SWEEP_SUBTYPE = 0
|
|
if win.startswith("t") and "@" in win:
|
|
head, win = win.split("@", 1)
|
|
if head == "t*":
|
|
_SWEEP_SUBTYPE = -1 # fan every staff table across the range
|
|
else:
|
|
try:
|
|
_SWEEP_SUBTYPE = int(head[1:], 0)
|
|
except ValueError:
|
|
return None
|
|
auto = win.startswith("auto:")
|
|
step = None
|
|
if auto:
|
|
parts = win[5:].split(":")
|
|
rng = parts[0]
|
|
if len(parts) > 1:
|
|
step = int(parts[1], 0)
|
|
else:
|
|
rng = win
|
|
try:
|
|
lo, hi = (int(x, 0) for x in rng.split("-", 1))
|
|
except Exception:
|
|
return None
|
|
if hi < lo:
|
|
lo, hi = hi, lo
|
|
if auto and not step:
|
|
step = 5000
|
|
return lo, hi, step
|
|
|
|
|
|
def sweep_items():
|
|
"""Synthetic club contents for the current sweep window.
|
|
|
|
5000 candidates per response is live-proven. 20000 was served fine and then
|
|
silently NOT ingested -- the map did not change at all -- so there is a
|
|
ceiling between the two. Auto chunks therefore default to 5000, and a chunk
|
|
that is not ingested costs one fetch, not the sweep.
|
|
"""
|
|
global _SWEEP_SPEC, _SWEEP_POS
|
|
win = sweep_window()
|
|
parsed = _parse_window(win)
|
|
if not parsed:
|
|
log(" SWEEP: bad window %r, want '<lo>-<hi>' or 'auto:<lo>-<hi>:<step>'" % win)
|
|
return []
|
|
lo, hi, step = parsed
|
|
|
|
if win != _SWEEP_SPEC: # re-aimed: restart the walk
|
|
_SWEEP_SPEC, _SWEEP_POS = win, 0
|
|
|
|
if step:
|
|
start = lo + _SWEEP_POS
|
|
if start > hi:
|
|
log(" SWEEP: range %d..%d EXHAUSTED -- probe now, then re-aim" % (lo, hi))
|
|
return []
|
|
end = min(start + step - 1, hi)
|
|
_SWEEP_POS += step
|
|
else:
|
|
start, end = lo, hi
|
|
|
|
# 't*@' fans EVERY staff subtype across the range at once, so one staff-tab
|
|
# load tests managercards / headcoachcards / fitnesscoachcards / physiocards /
|
|
# gkcoachcards together instead of costing five separate visits.
|
|
subs = _STAFF_SUBTYPES if _SWEEP_SUBTYPE == -1 else [_SWEEP_SUBTYPE]
|
|
|
|
# Only cardsubtypeid ever changes. itemType stays "player" because the merge
|
|
# dispatches on the subtype alone and the wire shape of a real staff item has
|
|
# never been observed -- inventing one is the change class that freezes this
|
|
# client.
|
|
out = []
|
|
for pid in range(start, end + 1):
|
|
for si, sub in enumerate(subs):
|
|
it = _item(SWEEP_ID_BASE + (pid - lo) * len(subs) + si, pid,
|
|
SWEEP_SENTINEL_RATING, "ST", 0, 0, 0, [1, 1, 1, 1, 1, 1])
|
|
if sub:
|
|
it["cardsubtypeid"] = sub
|
|
out.append(it)
|
|
log(" SWEEP: serving %d item(s) for id(s) %d..%d%s subtype(s)=%s "
|
|
"[synthetic, nothing saved]"
|
|
% (len(out), start, end,
|
|
(" (auto, %d..%d done)" % (lo, end)) if step else "",
|
|
",".join(str(s) for s in subs)))
|
|
return out
|
|
|
|
|
|
# ---- FUT_CONSUMABLES / FUT_COACHES / FUT_MANAGERS: the non-player families -----
|
|
#
|
|
# Three whole card families are now derivable offline (docs/plan-2026-08-04-card-
|
|
# families.md and the 2026-08-05 round): consumables need no id space at all, and
|
|
# staff ids came out of the 149 tables dumped read-only from the running client into
|
|
# data/tables/. Each ships behind its own flag, DEFAULT OFF.
|
|
#
|
|
# THEY ARE SERVED AS AN OVERLAY, NOT GRANTED INTO THE SAVE. That is deliberate:
|
|
# * clearing the flag restores the real club exactly, on the very next fetch, with
|
|
# no un-granting and no edit to a save that a live client is holding open;
|
|
# * Store.add_items() uses `setdefault("id", ...)`, so items that arrive with an id
|
|
# already set do NOT advance nextItemId -- granting these would eventually collide
|
|
# two id spaces. Overlay ids come from 9.4e8/9.5e8, clear of the save's 1e8, the
|
|
# sweep's 9e8 and each other.
|
|
# The cost is that overlay cards cannot be quick-sold or moved (they are not in the
|
|
# save), and the MY CLUB / clubPlayers counters do not see them. The staff COUNTERS
|
|
# are set from the overlay below, which is deliberate and is the second, independent
|
|
# oracle: "our club reports N staff" is a different signal from "the client resolved
|
|
# the card".
|
|
#
|
|
# EACH FLAG IS TWO-VALUED because the tab-to-?type= binding is UNOBSERVED. Only
|
|
# type=player, type=manager and type=custom have ever come from this client, so which
|
|
# arm the consumables and staff screens ask for is a guess:
|
|
# FUT_CONSUMABLES=1 serve on type=contract|training|healing|development
|
|
# FUT_CONSUMABLES=all ... and on an untyped club fetch with no team=/league=
|
|
# FUT_COACHES=1 serve on type=headcoach|gkcoach|physio|fitnesscoach|staff
|
|
# FUT_COACHES=all ... and on type=manager, the ONE staff request ever observed
|
|
# FUT_MANAGERS=1 serve on type=manager|staff
|
|
# Every club fetch logs the ?type= it was asked for while any of the three is set, so
|
|
# a null result tells the human WHICH arm to aim at instead of nothing at all.
|
|
def _family_flag(name):
|
|
""""" / "0" / "off" all mean OFF. Without this, FUT_CONSUMABLES=0 would be a
|
|
non-empty string and would quietly turn the family ON -- the opposite of what
|
|
anyone typing it means."""
|
|
v = os.environ.get(name, "").strip().lower()
|
|
return "" if v in ("", "0", "off", "no", "false") else v
|
|
|
|
|
|
CONSUMABLES = _family_flag("FUT_CONSUMABLES")
|
|
COACHES = _family_flag("FUT_COACHES")
|
|
MANAGERS = _family_flag("FUT_MANAGERS")
|
|
FAMILIES_ON = bool(CONSUMABLES or COACHES or MANAGERS)
|
|
|
|
MANAGER_TYPES = ("manager", "staff")
|
|
|
|
|
|
def _family_overlay(kind, has_drilldown):
|
|
"""The non-player items to serve for this ?type=, or []. Never touches the save."""
|
|
out = []
|
|
if CONSUMABLES:
|
|
import fut_consumables
|
|
if kind in fut_consumables.TYPE_CATEGORIES:
|
|
out += fut_consumables.items_for_type(kind)
|
|
elif CONSUMABLES == "all" and not kind and not has_drilldown:
|
|
out += fut_consumables.starter_consumables(fut_consumables.CONSUMABLE_ID_BASE)
|
|
if COACHES:
|
|
import fut_coaches
|
|
if kind in fut_coaches.CLUB_TYPES:
|
|
out += fut_coaches.items_for_type(kind)
|
|
elif COACHES == "all" and kind == "manager":
|
|
# type=manager is the ONE staff request ever observed on the wire (STAFF
|
|
# tab, 2026-08-04). If the tab only ever asks under that name, this is the
|
|
# only arm that can put a coach on screen.
|
|
out += fut_coaches.starter_coaches(fut_coaches.COACH_ID_BASE)
|
|
if MANAGERS:
|
|
import fut_staff
|
|
if kind in MANAGER_TYPES:
|
|
out += [fut_staff.manager_item(fut_staff.OVERLAY_ID_BASE + i, c)
|
|
for i, c in enumerate(fut_staff.STARTER_MANAGERS)]
|
|
return out
|
|
|
|
|
|
def _staff_overlay_counts():
|
|
"""(staffManager, headCoach, gkCoach, physio, fitnessCoach) held by the overlay.
|
|
|
|
FUN_180094ce0's STAFF_EMPLOYED row is the +0x800 SUM over stat ids 0xb..0xf, so the
|
|
parent id 0xa can never move it -- the five sub-types are what count. Zero unless a
|
|
staff flag is set, so the default panel is unchanged."""
|
|
n = {4: 0, 5: 0, 6: 0, 7: 0, 8: 0}
|
|
if MANAGERS:
|
|
import fut_staff
|
|
n[4] = len(fut_staff.STARTER_MANAGERS)
|
|
if COACHES:
|
|
import fut_coaches
|
|
for it in fut_coaches.starter_coaches(fut_coaches.COACH_ID_BASE):
|
|
n[it["cardsubtypeid"]] = n.get(it["cardsubtypeid"], 0) + 1
|
|
return n
|
|
|
|
|
|
def club_route(h):
|
|
# PUT only -- ENDPOINT_MAP row 3 gives ChangeClubName as PUT. Every other
|
|
# method keeps the exact body this route served before, so the rename support
|
|
# cannot change the behaviour of anything that already worked.
|
|
if h.command == "PUT":
|
|
return club_rename_route(h)
|
|
if sweep_window():
|
|
return 200, {"itemData": sweep_items()}
|
|
items = STORE.items()
|
|
|
|
# HONOUR ?type=. This route ignored it, so the STAFF tab -- which asks for
|
|
# type=manager, observed live 2026-08-04 -- was answered with the player list
|
|
# and displayed footballers as coaching staff.
|
|
#
|
|
# Filtering is deliberately NARROW. `player` and `manager` are the two values
|
|
# actually observed; `custom` (the by-league and by-team drill-downs) and a
|
|
# missing type keep exactly the behaviour that is already live-proven on
|
|
# screen, because the drill-down counts were only just fixed and this must not
|
|
# disturb them. An unrecognised type is treated like manager -- filtered, not
|
|
# unfiltered -- since answering an unknown question with the whole player list
|
|
# is what produced this bug in the first place.
|
|
q = {}
|
|
if "?" in h.path:
|
|
for part in h.path.split("?", 1)[1].split("&"):
|
|
if "=" in part:
|
|
k, v = part.split("=", 1)
|
|
q[k] = v
|
|
kind = q.get("type", "")
|
|
|
|
# HONOUR ?team= AND ?league=. These are the CLUB DRILL-DOWNS: clicking Chelsea in
|
|
# the club panel issues team=5, clicking the Premier League issues league=13.
|
|
# They were ignored, so every drill-down was answered with the ENTIRE club and
|
|
# Cristiano Ronaldo showed up under Chelsea, Arsenal and everyone else. Reported
|
|
# live 2026-08-05. The counts on the stats panel were right all along; it was
|
|
# only the item list that was unfiltered.
|
|
has_drilldown = False
|
|
for param, field in (("team", "teamid"), ("league", "leagueId")):
|
|
raw = q.get(param)
|
|
if raw is None:
|
|
continue
|
|
try:
|
|
want = int(raw)
|
|
except ValueError:
|
|
continue
|
|
has_drilldown = True
|
|
items = [i for i in items if i.get(field) == want]
|
|
log(" CLUB: %s=%d -> %d item(s)" % (param, want, len(items)))
|
|
|
|
# CLUB ITEMS. Observed live 2026-08-05, and the names are SINGULAR: the client
|
|
# asks type=stadium, type=ball and type=equippables (count=11 for the last,
|
|
# count=200 for the other two). Those were served from STORE.items(), which holds
|
|
# no club items because the shelf is synthetic, so they correctly came back empty.
|
|
# equippables is the combined view behind the club-customisation screen.
|
|
if CLUBITEMS and kind in ("stadium", "ball", "badge", "kit", "leaguelogos",
|
|
"equippables"):
|
|
import fut_clubitems
|
|
fam = {"stadium": "stadia", "ball": "balls", "badge": "badges",
|
|
"kit": "kits", "leaguelogos": "leaguelogos"}.get(kind)
|
|
if _CLUBITEMS_MODE.startswith("probe:"):
|
|
want = _CLUBITEMS_MODE.split(":", 1)[1]
|
|
got = fut_clubitems.probe_shelf(want) if fam == want else []
|
|
log(" CLUBITEMS: PROBE %s on type=%s -> %d item(s), one per candidate "
|
|
"subtype" % (want, kind, len(got)))
|
|
return 200, {"itemData": got}
|
|
if fam is None:
|
|
# equippables is the COMBINED view and it is what crashed the client on
|
|
# 2026-08-05: 30 items across five unverified subtypes in one response.
|
|
# Until the subtypes are confirmed one family at a time, answer it empty.
|
|
# An empty itemData is a shape the client already accepts everywhere.
|
|
log(" CLUBITEMS: type=equippables -> [] (combined view withheld until "
|
|
"the subtypes are verified; it crashed the client once)")
|
|
return 200, {"itemData": []}
|
|
got = fut_clubitems.shelf(families={fam}).get(fam, [])
|
|
log(" CLUBITEMS: type=%s -> %d item(s)" % (kind, len(got)))
|
|
return 200, {"itemData": got}
|
|
|
|
if kind and kind not in ("player", "custom"):
|
|
# cardsubtypeid 0..3 is a player (FUN_1800d8330); everything else is
|
|
# staff or a manager. The save holds no non-player items (the families
|
|
# below are served as an overlay, not granted), so this is [] today --
|
|
# an empty item list, which is the same shape the parser already accepts.
|
|
items = [i for i in items if i.get("cardsubtypeid", 0) not in (0, 1, 2, 3)]
|
|
log(" CLUB: type=%s -> %d item(s) (players filtered out)" % (kind, len(items)))
|
|
else:
|
|
# THE MIRROR FILTER. This branch -- type=player, type=custom, and an untyped
|
|
# fetch -- used to filter NOTHING, so the moment the club held a non-player
|
|
# item it would be served straight into the players tab and into the by-league
|
|
# / by-team drill-downs. A manager carries nation, leagueId and teamid, so he
|
|
# would have appeared as a footballer in exactly the MY CLUB rows that were
|
|
# only just made non-zero. Provable no-op today: all 194 items in the live save
|
|
# are cardsubtypeid 0, so this list is unchanged (verified 2026-08-05).
|
|
items = [i for i in items if i.get("cardsubtypeid", 0) in (0, 1, 2, 3)]
|
|
if CLUB_PAGE:
|
|
log(" CLUB: returning %d item(s) [FUT_CLUB_PAGE experiment -- compare "
|
|
"this number against the MY CLUB counter on screen]" % len(items))
|
|
|
|
if FAMILIES_ON:
|
|
# The tab-to-?type= binding is unobserved, so LOG EVERY ARM ASKED FOR. If a
|
|
# family tab comes back empty this line is what says whether the request even
|
|
# reached us and under which name -- the difference between "aim at another
|
|
# arm" and "nothing was asked".
|
|
overlay = _family_overlay(kind, has_drilldown)
|
|
log(" CLUB: family overlay armed (consumables=%r coaches=%r managers=%r); "
|
|
"type=%r drilldown=%s -> +%d item(s)"
|
|
% (CONSUMABLES, COACHES, MANAGERS, kind, has_drilldown, len(overlay)))
|
|
items = items + overlay
|
|
return 200, {"itemData": items}
|
|
|
|
|
|
def user_route(h):
|
|
if h.command == "POST":
|
|
return 200, user_post(h)
|
|
if NEW_USER:
|
|
# accepted, non-fatal: 0x18007AF20 treats 404 as the new-user branch
|
|
return 404, {}
|
|
return 200, user_get()
|
|
|
|
|
|
# ut/%s/squad carries the WHOLE squad family (request table 0x18021dfc0): GET =
|
|
# LoadActiveSquad AND GetSquadList/GetSquads, PUT = SaveCurrentSquad/SaveSquad.
|
|
# Load-vs-List is not resolvable statically (one binding row each, 0x18027cb70 /
|
|
# 0x18027be28) -- but the two parsers are mutually SKIP-tolerant: the squad-object
|
|
# parser 0x18013d1f0 does not recognise "squad"(0x2cd), and the list parser
|
|
# 0x180142260 recognises ONLY "squad". So a MERGED body satisfies both. Kept behind
|
|
# FUT_SQUAD_LIST=merged for now (GET /squad is boot-critical; default keeps the
|
|
# proven plain-squad body so a live test isolates one change at a time).
|
|
_SQUAD_LIST_MODE = os.environ.get("FUT_SQUAD_LIST", "off")
|
|
|
|
|
|
def clientdata_route(h):
|
|
"""ut/%s/clientdata/<key> -- opaque client blob storage.
|
|
|
|
LIVE-OBSERVED: `PUT ut/game/fifa17/clientdata/userHubData` fires from the FUT
|
|
hub (20:40 session). The client is storing its own hub state -- so the correct
|
|
server behaviour is to keep the blob and hand back exactly what was given, which
|
|
is zero-risk by construction: we never synthesise a shape, we echo the client's
|
|
own bytes. Persisting it is also the most plausible route to the hub's
|
|
"MANAGER TASKS 0/0" tile surviving a relaunch, since no FutGetObjectives class
|
|
exists in the binary at all (§9) -- the tile state may simply live in this blob.
|
|
"""
|
|
key = h.path.split("/clientdata/", 1)[-1].split("?")[0] or "default"
|
|
if h.command in ("PUT", "POST"):
|
|
try:
|
|
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
|
|
except Exception:
|
|
body = None
|
|
if body is not None:
|
|
STORE.set_clientdata(key, body)
|
|
log(" CLIENTDATA: stored %r (%d bytes)" % (key, len(h._body)))
|
|
return 200, {}
|
|
return 200, STORE.get_clientdata(key)
|
|
|
|
|
|
# ---- GAME MODES: seasons / tournaments / leaderboards / champions ------------
|
|
# These were 15 of the 45 templates in the URL table with NO route at all -- they
|
|
# fell through to the catch-all {}. Schemas below come from ENDPOINT_MAP.md (the
|
|
# same source that already had the match loop right); confidence per endpoint is
|
|
# noted inline.
|
|
#
|
|
# DEFAULT OFF. Every body here is documented-but-never-live-tested, and today's two
|
|
# regressions were both "serve a new body the client has never parsed". Notably
|
|
# FutSeasonList wants an ARRAY root where we currently send {} on a boot-adjacent
|
|
# path -- exactly the shape class that freezes at 0x1801c7f1a if wrong. Turn on
|
|
# with FUT_MODES=1 when you can watch a launch; `unset FUT_MODES` is the fallback.
|
|
_MODES = os.environ.get("FUT_MODES") == "1"
|
|
|
|
|
|
def season_list():
|
|
"""GET ut/%s/season -- FutSeasonListServerResponse, deser 0x1801683f0.
|
|
|
|
CORRECTED 2026-08-04. This function and its docstring were BOTH wrong, in the
|
|
same way, and the error is instructive:
|
|
|
|
* The deserializer is 0x1801683f0, not 0x180167740. 0x180167740 is the
|
|
per-ELEMENT parser.
|
|
* The root is therefore an OBJECT, not an array. 0x1801683f0 runs a key loop
|
|
and matches exactly ONE atom, seasons(0x2ad); only inside that does an array
|
|
open. Someone read the element parser, saw its key set, and served those keys
|
|
at the document root. A bare array populates nothing at all.
|
|
|
|
Three of the keys previously served here (eligibilityKey, eligibilitySlot,
|
|
eligibilityValue) are inner members of elgReq and are completely inert at element
|
|
level, so even the element shape was wrong.
|
|
|
|
Ordering matters inside an element: `type` MUST precede `divisionId`, because the
|
|
divisionId branch reads the already-parsed type field at elem+0x1b4.
|
|
|
|
Still omitted, and now for a stated reason: prizeSet(0x253), elgReq(0xf7) and
|
|
matches(0x1b8) are all `while (tok != 0xd)` ARRAY loops. A scalar in any of them
|
|
is the 0x1801c7f1a spin.
|
|
|
|
NOT SERVED BY DEFAULT and there is no point serving it yet: across 486 real
|
|
client requests (User-Agent ProtoHttp, roughly 30 boots) the game has NEVER asked
|
|
for /season. Every /season line in our log is our own curl or urllib. A body here
|
|
changes nothing observable, so this is correctness-in-waiting, not a fix.
|
|
Semantic hazard for whenever it does ship: omitting untilEndSeconds makes the
|
|
season's end timestamp equal to now.
|
|
"""
|
|
return {"seasons": [{"type": "OFFLINE", "id": 1, "divisionId": 10}]}
|
|
|
|
|
|
def season_user():
|
|
"""GET ut/%s/season/user -- FutSeasonLoadData, deser 0x180131450 (HIGH,
|
|
switch fully traced at 0x18013153c). `data`(201) is an opaque interned blob
|
|
string; empty is valid. friendlySeasonHistory is nested -> omitted."""
|
|
return {"seasonId": 1, "divisionId": 10, "round": 1, "userPoints": 0,
|
|
"dataVersion": "1", "data": ""}
|
|
|
|
|
|
def tournament_list():
|
|
"""GET ut/%s/tournament -- FutTournamentList, deser 0x180169ef0 (MEDIUM).
|
|
ARRAY root; rounds/prizeSet/staff/kit atoms are nested FREEZE-RISK -> omitted."""
|
|
return [{"id": 1, "difficulty": 1, "coins": 500, "rewardMultiplier": 1,
|
|
"assetName": "", "eligibilityOperation": ""}]
|
|
|
|
|
|
def tournament_user():
|
|
"""GET ut/%s/tournament/user -- FutTournamentLoadData 0x180147cb0 (MEDIUM).
|
|
Mirrors the season shape; tournamentData(810) is the same interned blob."""
|
|
return {"round": 1, "dataVersion": "1", "tournamentData": ""}
|
|
|
|
|
|
def leaderboard_route(h):
|
|
"""GET ut/%s/leaderboards -- FutGetLBEntries 0x180144c8d (MEDIUM).
|
|
/options -- FutGetLBOptions 0x18014351c. Empty entries list is the safe body:
|
|
an empty array cannot desync the reader."""
|
|
if "/options" in h.path:
|
|
return 200, {"category": 0, "id": 0, "period": 0, "view": 0, "url": ""}
|
|
return 200, {"entries": []}
|
|
|
|
|
|
# ---- Squad Building Challenges (SBC) -----------------------------------------
|
|
# GET ut/%s/sbs/sets is the FIRST call the SBC hub makes. Two response classes both
|
|
# bind to sbs/sets:
|
|
# FutSBCSetDataServerResponse deser 0x18016fe90 -- OBJECT root, parses ONLY
|
|
# `reset`(0x283 bool); every other key is
|
|
# value-SKIP'd. Carries NO set list. This is
|
|
# why {} produced "problem communicating": the
|
|
# body parsed fine but held no data.
|
|
# FutSBCLoadCategoryDetailsServerResponse deser 0x18017ac08 -- OBJECT root, parses
|
|
# categoryId(0x73 int), name(0x1d0 str),
|
|
# priority(0x250 int), sets(0x2be ARRAY). Each
|
|
# set element (parser 0x18017ad60) reads
|
|
# categoryId(0x73), name(0x1d0), description
|
|
# (0xd1), priority(0x250), challengesCount
|
|
# (0x78), challengesCompletedCount(0x77),
|
|
# awards(0x47 ARRAY). This is the one that
|
|
# renders the list.
|
|
# Both parsers are object-root and skip-tolerant, so a MERGED body satisfies whichever
|
|
# class the client instantiates for a given sbs/sets request with zero freeze risk.
|
|
# FREEZE-CRITICAL: `sets` and every element's `awards` MUST be JSON arrays if present
|
|
# (a scalar/object there desyncs the array reader into the 0x1801c7f1a busy loop).
|
|
# Omitting them defaults to empty (safe). Reversed 2026-08-06 from the on-disk
|
|
# CardsDLL (control-checked against FutLoadSetChallengesResponse 0x18017bbbb).
|
|
#
|
|
# DEFAULT ON: the current behaviour is a hard "problem communicating" modal, so there
|
|
# is no working state to protect. FUT_SBC=0 restores the catch-all {}.
|
|
_SBC = os.environ.get("FUT_SBC", "1") == "1"
|
|
|
|
# Moddable SBC content. Each category holds its sets; each set optionally holds a
|
|
# `challenges` list served by the sbs/setId/{id}/challenges route (that key is an
|
|
# internal detail, stripped before it goes on the sbs/sets wire). Keep `sets`,
|
|
# `awards`, and each set's `challenges` as lists. setId ties a set to its challenge
|
|
# records so the two calls stay consistent.
|
|
SBC_CATEGORIES = [
|
|
{
|
|
"categoryId": 1, "name": "Foundations", "priority": 1,
|
|
"sets": [
|
|
{"setId": 1, "categoryId": 1, "name": "Bronze Challenge",
|
|
"description": "Submit an 11-player squad.", "priority": 1,
|
|
"challengesCount": 1, "challengesCompletedCount": 0, "awards": [],
|
|
"hidden": False, "endTime": 4102444800,
|
|
"challenges": [
|
|
{"challengeId": 101, "name": "League Basics",
|
|
"description": "Submit an 11-player squad.",
|
|
"formation": "f442", "type": "OPEN_CHALLENGE", "status": "OPEN"},
|
|
]},
|
|
{"setId": 2, "categoryId": 1, "name": "Simple Start",
|
|
"description": "Get started with your first SBC.", "priority": 2,
|
|
"challengesCount": 1, "challengesCompletedCount": 0, "awards": [],
|
|
"hidden": False, "endTime": 4102444800,
|
|
"challenges": [
|
|
{"challengeId": 201, "name": "First Steps",
|
|
"description": "Get started with your first SBC.",
|
|
"formation": "f442", "type": "OPEN_CHALLENGE", "status": "OPEN"},
|
|
]},
|
|
],
|
|
},
|
|
]
|
|
|
|
# Non-container / non-key scalars every challenge record carries, with freeze-safe
|
|
# defaults. FutLoadSetChallengesResponse per-record parser 0x18017bb50: ints
|
|
# challengeId/setId/categoryId/index/endTime/trophyId/timesCompleted; strings
|
|
# type/name/description/challengeImageId/status/formation (formation MUST be a string
|
|
# -- int desyncs the string mapper 0x180166590); bool repeatable; ARRAYS awards +
|
|
# elgReq (a scalar there hits the type-desync busy-loop 0x1801c7f1a).
|
|
_SBC_CHALLENGE_DEFAULTS = {
|
|
"categoryId": 0, "index": 0, "type": "OPEN_CHALLENGE",
|
|
"name": "SBC Challenge", "description": "Submit a squad.",
|
|
"challengeImageId": "", "formation": "f442", "endTime": 0,
|
|
"repeatable": False, "trophyId": 0, "status": "OPEN",
|
|
"timesCompleted": 0, "awards": [], "elgReq": [],
|
|
}
|
|
|
|
|
|
def _sbc_sets_payload():
|
|
"""Categories for FutSBCLoadCategoryDetailsServerResponse, internal keys removed.
|
|
|
|
Strips the internal `challenges` list from each set: the sbs/sets set-element
|
|
parser 0x18017ad60 only reads categoryId/name/description/priority/
|
|
challengesCount/challengesCompletedCount/awards, and we keep the wire minimal
|
|
and identical to the proven success body rather than lean on value-SKIP."""
|
|
cats = []
|
|
for cat in SBC_CATEGORIES:
|
|
sets = [{k: v for k, v in s.items() if k != "challenges"}
|
|
for s in cat.get("sets", [])]
|
|
c = {k: v for k, v in cat.items() if k != "sets"}
|
|
c["sets"] = sets
|
|
cats.append(c)
|
|
return cats
|
|
|
|
|
|
def _sbc_find_set(set_id):
|
|
"""Return (category, set) for setId, or (None, None) -- keeps setIds consistent
|
|
between sbs/sets and sbs/setId/{id}/challenges."""
|
|
for cat in SBC_CATEGORIES:
|
|
for s in cat.get("sets", []):
|
|
if s.get("setId") == set_id:
|
|
return cat, s
|
|
return None, None
|
|
|
|
|
|
def _sbc_challenge_record(set_id, cat, s, idx, ch):
|
|
"""Build one freeze-safe FutLoadSetChallengesResponse record. awards/elgReq are
|
|
forced to lists; overrides from the moddable `challenges` entry win over the
|
|
defaults, but only for the recognised scalar/array fields."""
|
|
rec = dict(_SBC_CHALLENGE_DEFAULTS)
|
|
rec["challengeId"] = ch.get("challengeId", set_id * 100 + idx + 1)
|
|
rec["setId"] = set_id
|
|
rec["categoryId"] = (cat or {}).get("categoryId", rec["categoryId"])
|
|
rec["index"] = idx
|
|
for k, v in ch.items():
|
|
if k in _SBC_CHALLENGE_DEFAULTS:
|
|
rec[k] = v
|
|
rec["awards"] = list(rec.get("awards") or [])
|
|
rec["elgReq"] = list(rec.get("elgReq") or [])
|
|
return rec
|
|
|
|
|
|
def sbc_sets_route(h):
|
|
"""GET ut/%s/sbs/sets -- the SBC category/set list.
|
|
|
|
Deser: FutSBCLoadCategoryDetailsServerResponse body 0x18017b2b0, dispatch
|
|
0x18017b64c. The root is a JSON OBJECT (END_OBJECT cmp eax,0xa @0x18017b66a);
|
|
the SOLE recognised top-level key is atom 0x6f = "categories", read as an ARRAY
|
|
(discriminator 0x18017b697; END_ARRAY cmp eax,0xd @0x18017b6b7). GATE (by
|
|
elimination, no scalar/status/reset is parsed at top level): the categories
|
|
array must be NON-EMPTY, else the client shows the "problem communicating" modal
|
|
(no freeze -- object-root accepts {} cleanly). Earlier single-object /
|
|
merged-with-reset bodies failed because every top-level key other than
|
|
"categories" is value-SKIP'd -> empty categories -> the modal.
|
|
FREEZE-CRITICAL: categories[], each category's sets[], and each set's awards[]
|
|
MUST be JSON arrays; each category is an object. Reversed from CardsDLL."""
|
|
if h.command != "GET":
|
|
return 200, {}
|
|
cats = _sbc_sets_payload()
|
|
log(" SBC: sbs/sets -> %d categor(y/ies), %d set(s) total"
|
|
% (len(cats), sum(len(c.get("sets", [])) for c in cats)))
|
|
return 200, {"categories": cats}
|
|
|
|
|
|
def sbc_challenges_route(h):
|
|
"""GET ut/%s/sbs/setId/{setId}/challenges -- FutLoadSetChallengesResponse.
|
|
|
|
Top-level deser 0x18017c4e0 is OBJECT-root (END_OBJECT cmp eax,0xa @0x18017c8a7);
|
|
the SOLE recognised key is atom 0x76 = "challenges", read as an ARRAY (END_ARRAY
|
|
cmp eax,0xd @0x18017c8e7; per-record deser 0x18017bb50). Return an OBJECT
|
|
{"challenges":[...]}, never a bare array (that would desync the object-root).
|
|
Records are wired to the set's own challenges (or synthesised to challengesCount)
|
|
so challengeIds/setIds stay consistent with sbs/sets.
|
|
FREEZE-CRITICAL: challenges[], and per-record awards[] (0x47) + elgReq[] (0xf7)
|
|
are arrays; formation is a string."""
|
|
m = re.search(r"/setId/(\d+)/challenges", h.path)
|
|
set_id = int(m.group(1)) if m else 0
|
|
cat, s = _sbc_find_set(set_id)
|
|
records = []
|
|
if s is not None:
|
|
chs = s.get("challenges")
|
|
if not chs:
|
|
chs = [{} for _ in range(max(1, int(s.get("challengesCount", 1))))]
|
|
for idx, ch in enumerate(chs):
|
|
records.append(_sbc_challenge_record(set_id, cat, s, idx, ch))
|
|
log(" SBC: challenges setId=%d -> %d challenge(s)" % (set_id, len(records)))
|
|
return 200, {"challenges": records}
|
|
|
|
|
|
def sbc_start_route(h):
|
|
"""POST ut/%s/sbs/challenge/{id} with EMPTY body -- START a challenge ->
|
|
FutSBCStartChallengeResponse (deser 0x180155949, OBJECT-root; {} freeze-safe).
|
|
|
|
FREEZE-CRITICAL: "squad" (atom 0x2cd) MUST be a JSON OBJECT -- its value is
|
|
delegated to the OBJECT-root squad deser 0x18013d1f0; an array there desyncs.
|
|
"playerRequirements" (0x237) MUST be a JSON ARRAY (END_ARRAY cmp eax,0xd
|
|
@0x1801559e7). No non-empty gate; empty {} / [] are accepted."""
|
|
m = re.search(r"/challenge/(\d+)", h.path)
|
|
cid = int(m.group(1)) if m else 0
|
|
log(" SBC: start challenge id=%d" % cid)
|
|
return 200, {"challengeId": cid, "squad": {}, "playerRequirements": []}
|
|
|
|
|
|
def sbc_submit_route(h):
|
|
"""POST/PUT ut/%s/sbs/challenge/{id} with a BODY -- SUBMIT a challenge ->
|
|
FutSBCSubmitChallengeServerResponse (deser 0x180161b00, dispatch 0x180161bda,
|
|
OBJECT-root; {} freeze-safe).
|
|
|
|
FREEZE-CRITICAL: grantedChallengeAwards (atom 0x14a) and grantedSetAwards
|
|
(atom 0x14b) MUST each be a JSON ARRAY (END_ARRAY cmp eax,0xd), never a
|
|
scalar/object; empty [] is safe. Scalars challengeId/setId/credits/
|
|
preOrderPacks/recoveredPacks are ints."""
|
|
m = re.search(r"/challenge/(\d+)", h.path)
|
|
cid = int(m.group(1)) if m else 0
|
|
log(" SBC: submit challenge id=%d" % cid)
|
|
return 200, {"challengeId": cid, "setId": 0, "credits": 0,
|
|
"preOrderPacks": 0, "recoveredPacks": 0,
|
|
"grantedChallengeAwards": [], "grantedSetAwards": []}
|
|
|
|
|
|
def sbc_challenge_route(h):
|
|
"""Dispatch the shared path ut/%s/sbs/challenge/{id}: START vs SUBMIT.
|
|
|
|
Both builders format the identical "/challenge/%d" (str 0x180227300). The proven
|
|
discriminator is the REQUEST BODY -- START emits an empty body (POST), SUBMIT a
|
|
populated JSON body (PUT/POST). We route to SUBMIT on a PUT or any non-empty
|
|
body, else START (covers both the body-presence and method discriminators)."""
|
|
if h.command == "PUT" or getattr(h, "_body", b""):
|
|
return sbc_submit_route(h)
|
|
return sbc_start_route(h)
|
|
|
|
|
|
def sbc_squad_route(h):
|
|
"""ut/%s/sbs/challenge/{id}/squad -- method-discriminated save/load.
|
|
|
|
PUT -> FutSBCSaveSquadChallengeServerResponse (deser 0x18017cff0; parses only
|
|
id 0x15c -> return {"id":<id>}). GET -> FutLoadSetTypesServerResponse (deser
|
|
0x180154990; reads squad 0x2cd + playerRequirements 0x237). Both roots are JSON
|
|
objects. NOTE: unlike START, here "squad" (0x2cd) is parsed as an ARRAY (callback
|
|
0x18013d1f0, state [rbx+0xb8]=3), and "playerRequirements" (0x237) is an ARRAY
|
|
(END_ARRAY cmp eax,0xd @0x180154e07). The unified body is freeze-safe under both
|
|
verbs: PUT reads id and value-SKIPs the arrays; GET reads the arrays and
|
|
value-SKIPs id."""
|
|
m = re.search(r"/challenge/(\d+)/squad", h.path)
|
|
cid = int(m.group(1)) if m else 0
|
|
if h.command == "PUT":
|
|
log(" SBC: save squad challenge id=%d" % cid)
|
|
return 200, {"id": cid}
|
|
log(" SBC: load squad challenge id=%d" % cid)
|
|
return 200, {"id": cid, "squad": [], "playerRequirements": []}
|
|
|
|
|
|
def sbc_tag_route(h):
|
|
"""POST/PUT ut/%s/sbs/sets/tag -- FutSBCTagSetsServerResponse (deser 0x1801542f0,
|
|
OBJECT-root, parses NO fields: every key value-SKIP'd, always returns success).
|
|
Pure ack. The one hard rule is the container: the root MUST be a JSON object --
|
|
{} is fully accepted; an array/scalar root would busy-loop at 0x1801c7f1a."""
|
|
log(" SBC: sets/tag ack")
|
|
return 200, {}
|
|
|
|
|
|
# ---- 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"
|
|
# Modes that successfully passed the entry-purchase response in this server process.
|
|
# Keep this volatile until the complete draft lifecycle (including abandon/rewards)
|
|
# is implemented; persisting a half-built draft would make recovery harder.
|
|
_DRAFT_SESSIONS = {}
|
|
|
|
|
|
def _draft_squad(session):
|
|
"""A Draft-owned squad model, separate from STORE's regular active squad."""
|
|
squad = copy.deepcopy(SQUAD)
|
|
squad.update({
|
|
"id": 0,
|
|
"personaId": ACCOUNT.persona_id,
|
|
"squadName": "My Draft",
|
|
"formation": session.get("formation", "f442"),
|
|
"squadType": "DRAFT_SQUAD",
|
|
"chemistry": 0,
|
|
"starRating": 0,
|
|
"captain": 0,
|
|
"manager": ([{
|
|
"id": session["manager"].get("id", 0),
|
|
"itemData": copy.deepcopy(session["manager"]),
|
|
"dream": False,
|
|
}] if session.get("manager") else []),
|
|
})
|
|
# SQUAD is currently the empty, schema-proven seed, but explicitly stripping
|
|
# itemData prevents a future seed-mode change from leaking the regular XI here.
|
|
selected = session.get("selected", {})
|
|
squad["players"] = []
|
|
for index in range(23):
|
|
player = {"index": index, "kitNumber": 0}
|
|
if index in selected:
|
|
player["itemData"] = copy.deepcopy(selected[index])
|
|
squad["players"].append(player)
|
|
captain_slot = session.get("captain_slot")
|
|
if captain_slot in selected:
|
|
squad["captain"] = selected[captain_slot].get("id", 0)
|
|
return squad
|
|
|
|
|
|
def draft_state_route(h):
|
|
if not DRAFT_STATE:
|
|
return squad_route(h)
|
|
m = re.search(r"[?&]mode=([^&]+)", h.path)
|
|
mode = m.group(1) if m else ""
|
|
session = _DRAFT_SESSIONS.get(mode)
|
|
purchased = session is not None
|
|
return 200, [{
|
|
# Atom-table-backed enum consumed by FUN_180147070. After a successful
|
|
# entry purchase FIFA's next legitimate stage is formation selection.
|
|
"squadState": session.get("stage", "FORMATION_DRAFT") if purchased else "INVALID",
|
|
"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
|
|
**({"squad": _draft_squad(session)} if purchased else {}),
|
|
# entranceCriteria: OMITTED. Shape known, not needed, skip-safe.
|
|
}]
|
|
|
|
|
|
def draft_formation_choices_route(h):
|
|
"""Return the first Draft round: a formation carousel.
|
|
|
|
FutGetDraftChoicesServerResponse (FUN_18014f2d0) consumes an object root with
|
|
choices[] records. Formation records use only index + formation; itemData is
|
|
reserved for later player/manager rounds.
|
|
"""
|
|
log(" DRAFT: serving formation choices")
|
|
return 200, {
|
|
"positionid": 0,
|
|
"tier": 1,
|
|
"choices": [
|
|
{"index": 0, "formation": "f442"},
|
|
{"index": 1, "formation": "f433"},
|
|
],
|
|
}
|
|
|
|
|
|
def draft_captain_choices_route(h):
|
|
"""Offer five known-good player cards for the captain round."""
|
|
candidates = [player for player in fut_cards.POOL if player[1] >= 84]
|
|
cards = [player_item(800000000 + index, player, special=random.random() < 0.20)
|
|
for index, player in enumerate(random.sample(candidates, 5))]
|
|
m = re.search(r"/squad/mode/(\d+)/draft/", h.path)
|
|
mode = "SINGLE_PLAYER" if (m and m.group(1) == "1") else "ONLINE"
|
|
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "CAPTAIN_DRAFT"})
|
|
session["pending_choices"] = cards
|
|
log(" DRAFT: serving %d captain choices" % len(cards))
|
|
return 200, {
|
|
"positionid": 0,
|
|
"tier": 1,
|
|
"choices": [
|
|
{"index": index, "itemData": card}
|
|
for index, card in enumerate(cards)
|
|
],
|
|
}
|
|
|
|
|
|
def draft_player_choices_route(h):
|
|
"""Offer a player round for the slot requested by the Draft UI."""
|
|
try:
|
|
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
|
|
except Exception:
|
|
body = {}
|
|
position_id = int(body.get("positionId", body.get("positionid", 0)))
|
|
m = re.search(r"/squad/mode/(\d+)/draft/", h.path)
|
|
mode = "SINGLE_PLAYER" if (m and m.group(1) == "1") else "ONLINE"
|
|
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "PLAYER_DRAFT"})
|
|
selected_assets = {
|
|
card.get("assetId") for card in session.get("selected", {}).values()
|
|
}
|
|
|
|
|
|
def draft_manager_choices_route(h):
|
|
"""Offer five verified FIFA 17 managercards for the final Draft round."""
|
|
m = re.search(r"/squad/mode/(\d+)/draft/", h.path)
|
|
mode = "SINGLE_PLAYER" if (m and m.group(1) == "1") else "ONLINE"
|
|
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "MANAGER_DRAFT"})
|
|
manager_ids = random.sample(fut_staff.STARTER_MANAGERS, 5)
|
|
cards = [fut_staff.manager_item(800200000 + index, carddbid)
|
|
for index, carddbid in enumerate(manager_ids)]
|
|
session["stage"] = "MANAGER_DRAFT"
|
|
session["pending_choices"] = cards
|
|
session["pending_position"] = 23
|
|
log(" DRAFT: serving %d manager choices" % len(cards))
|
|
return 200, {
|
|
"positionid": 23,
|
|
"tier": 1,
|
|
"choices": [
|
|
{"index": index, "itemData": card}
|
|
for index, card in enumerate(cards)
|
|
],
|
|
}
|
|
# Prefer the requested slot's broad position family. If FIFA sends only a
|
|
# numeric squad slot (as observed), the client still enforces chemistry/fit;
|
|
# offering varied high-quality players is safer than inventing a slot map for
|
|
# every formation. Five unique assets are guaranteed per carousel.
|
|
candidates = [player for player in fut_cards.POOL
|
|
if player[1] >= 75 and player[0] not in selected_assets]
|
|
picks = random.sample(candidates, 5)
|
|
draft_seq = session.get("draft_item_seq", 0)
|
|
cards = [player_item(800100000 + draft_seq + index, player,
|
|
special=random.random() < 0.12)
|
|
for index, player in enumerate(picks)]
|
|
session["draft_item_seq"] = draft_seq + len(cards)
|
|
session["pending_choices"] = cards
|
|
session["pending_position"] = position_id
|
|
log(" DRAFT: serving %d player choices for slot %d"
|
|
% (len(cards), position_id))
|
|
return 200, {
|
|
"positionid": position_id,
|
|
"tier": 1,
|
|
"choices": [
|
|
{"index": index, "itemData": card}
|
|
for index, card in enumerate(cards)
|
|
],
|
|
}
|
|
|
|
|
|
def draft_choose_route(h):
|
|
"""Acknowledge a pick and advance the volatile Draft state machine."""
|
|
try:
|
|
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
|
|
except Exception:
|
|
body = {}
|
|
m = re.search(r"/squad/mode/(\d+)/draft/choose", h.path)
|
|
mode_id = int(m.group(1)) if m else 0
|
|
mode = "SINGLE_PLAYER" if mode_id == 1 else "ONLINE"
|
|
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "FORMATION_DRAFT"})
|
|
if session.get("stage") == "FORMATION_DRAFT":
|
|
formations = ("f442", "f433")
|
|
choice = body.get("choiceIndex", 0)
|
|
session["formation"] = formations[choice] if choice in range(len(formations)) else "f442"
|
|
session["stage"] = "CAPTAIN_DRAFT"
|
|
log(" DRAFT: chose formation %s; advancing to captain"
|
|
% session["formation"])
|
|
elif session.get("stage") in ("CAPTAIN_DRAFT", "PLAYER_DRAFT", "MANAGER_DRAFT"):
|
|
choice_index = int(body.get("choiceIndex", 0))
|
|
position_id = int(body.get("positionId", session.get("pending_position", 0)))
|
|
choices = session.get("pending_choices", [])
|
|
if 0 <= choice_index < len(choices):
|
|
session.setdefault("selected", {})[position_id] = copy.deepcopy(
|
|
choices[choice_index]
|
|
)
|
|
if session.get("stage") == "CAPTAIN_DRAFT":
|
|
session["captain_slot"] = position_id
|
|
session["stage"] = "PLAYER_DRAFT"
|
|
log(" DRAFT: chose captain for slot %d; advancing to players"
|
|
% position_id)
|
|
elif session.get("stage") == "MANAGER_DRAFT":
|
|
session["manager"] = copy.deepcopy(choices[choice_index])
|
|
session["stage"] = "COMPLETED_DRAFT"
|
|
log(" DRAFT: chose manager; draft squad is complete")
|
|
else:
|
|
log(" DRAFT: chose player for slot %d" % position_id)
|
|
else:
|
|
log(" DRAFT: rejected out-of-range choice %d at slot %d"
|
|
% (choice_index, position_id))
|
|
else:
|
|
log(" DRAFT: acknowledged pick at stage %s body=%s"
|
|
% (session.get("stage"), json.dumps(body)))
|
|
# FutPickDraftChoiceServerResponse uses the generic no-field response parser.
|
|
return 200, {}
|
|
|
|
|
|
# ---- Draft entry purchase ----------------------------------------------------
|
|
# POST ut/%s/purchase/mode/{mode}/draft body {"currency":"COINS","usePreOrder":0}
|
|
# -> FutPurchaseDraftModeServerResponse. The path component is the draft mode
|
|
# (1 for SINGLE_PLAYER), not the entry price.
|
|
#
|
|
# LIVE 2026-08-04: this endpoint was UNMAPPED, answered {} by the catch-all, and the
|
|
# client CRASHED immediately after. Sequence, from the log:
|
|
# GET /squad/mode/draft/state?mode=ONLINE -> our array body, screen RENDERED
|
|
# GET /user/credits -> 7200
|
|
# GET /store/purchasegroup/all -> the entry-fee screen
|
|
# POST /purchase/mode/0/draft -> {} then the crash
|
|
# So the draft-state fix worked and simply advanced the failure to the next
|
|
# unimplemented call, which is the outcome a correct fix is supposed to have.
|
|
#
|
|
# WHICH ENVELOPE. ENDPOINT_MAP flags a "response-variant ambiguity" here: two
|
|
# structures reference the class name. Live behaviour plus the request vtable resolves
|
|
# the POST to the second variant:
|
|
# 0x18014c260 (vtable 0x180224ef8, factory 0x18014c090) 3188 chars, OBJECT root
|
|
# (prologue tests != 10 = END_OBJECT), 1 skip-handler call, and seven
|
|
# scalar ints. This is a distinct response using the same class name.
|
|
# 0x180150310 (vtable 0x1802262f0, factory 0x180150260) 1836 chars, ARRAY root
|
|
# (loops until 0xd = END_ARRAY), ZERO skip handlers. Each element is
|
|
# parsed by FUN_180138bd0 as name/funds/finalFunds, then name is compared
|
|
# with "COINS", "POINTS", and "DRAFT_TOKEN". Request vtable
|
|
# 0x180226300 selects factory 0x180150260 for the live purchase POST.
|
|
#
|
|
# LIVE 2026-08-07: returning the seven-int object made FIFA consume the POST (HTTP
|
|
# 200), issue no follow-up request, and spin at high CPU. That is the array parser's
|
|
# exact EOF-loop signature. The response below therefore uses the required array root.
|
|
# No coins are deducted yet: the emulator has not served a verified entrance price,
|
|
# and the path's mode id must not be mistaken for a price.
|
|
#
|
|
# DEFAULT ON for the same reason as FUT_DRAFT_STATE: the current behaviour is a
|
|
# confirmed crash, so there is no working state being protected.
|
|
DRAFT_PURCHASE = os.environ.get("FUT_DRAFT_PURCHASE", "1") == "1"
|
|
|
|
|
|
def draft_purchase_route(h):
|
|
if not DRAFT_PURCHASE:
|
|
return 200, {}
|
|
# NB: the route table hands handlers the compiled PATTERN, not a match object
|
|
# (the dispatcher calls fn(rx, self)), so re-extract from the path rather than
|
|
# calling .group() on the first argument. Doing that raised AttributeError,
|
|
# which killed the connection outright -- strictly worse than the {} it replaced.
|
|
m = re.search(r"/purchase/mode/(\d+)/draft", h.path)
|
|
mode = int(m.group(1)) if m else 0
|
|
mode_name = "SINGLE_PLAYER" if mode == 1 else "ONLINE"
|
|
_DRAFT_SESSIONS[mode_name] = {"stage": "FORMATION_DRAFT", "formation": "f442"}
|
|
coins = STORE.coins()
|
|
points = STORE.profile().get("points", 0)
|
|
log(" DRAFT: purchase entry, mode=%d; returning array-root currency result "
|
|
"(entry fee not deducted until entrance criteria are verified)" % mode)
|
|
return 200, [
|
|
{"name": "COINS", "funds": coins, "finalFunds": coins},
|
|
{"name": "POINTS", "funds": points, "finalFunds": points},
|
|
{"name": "DRAFT_TOKEN", "funds": 0, "finalFunds": 0},
|
|
]
|
|
|
|
|
|
def champion_route(h):
|
|
"""ut/%s/champion -- registration 0x18014980d (ack, no atoms),
|
|
topX 0x18014a09d ({"entries":[]}), friends 0x18014b7ad ({} safe)."""
|
|
if h.command == "POST":
|
|
return 200, {}
|
|
return 200, {"entries": []}
|
|
|
|
|
|
# ---- THE CORE LOOP: match lifecycle + rewards --------------------------------
|
|
# Schemas from ENDPOINT_MAP.md (all CONFIDENCE: HIGH, reversed earlier):
|
|
# POST ut/%s/match FutCreateMatch 0x180120380
|
|
# startDateTime(740,int) reportIdEnabled(641,bool)
|
|
# squad(717,nested -- FREEZE-RISK, omit: SKIP-safe)
|
|
# PUT ut/%s/match/{id} FutMatchReady no deserializer at all -> {}
|
|
# POST ut/%s/match/{id} FutPlayGame no deserializer at all -> {}
|
|
# (the client SENDS the result here; body ignored)
|
|
# DELETE ut/%s/match/{id} FutDestroyMatch 0x180121b60 <-- THE REWARDS
|
|
# allCoins(20)@0x28 matchCoins(436)@0x2c tournamentCoins(809)@0x30
|
|
# teamOfTournamentWinner(776,bool)@0x34 seasonCoins(670)@0x38 coins(149)@0x3c
|
|
# participationAward(529)@0x44 boostConis(96)@0x48 [EA's typo, exact key]
|
|
# qualifiedChampionEventId(617)@0xb0
|
|
# gameModeAward(310) / matchCoinMultipliers(437) / userData(877) are NESTED and
|
|
# SKIP-safe -- omitted deliberately (userData is a documented freeze-risk: it
|
|
# must be an object if present, so the safe move is not to send it).
|
|
# Every field we DO send is a top-level scalar -> zero freeze risk.
|
|
#
|
|
# Reward amounts are ours to choose (the server decides payouts). Defaults are
|
|
# FUT-ish and env-tunable; they are NOT reversed values and are not claimed to be.
|
|
MATCH_COINS = {
|
|
"won": int(os.environ.get("FUT_MATCH_COINS_WIN", "400")),
|
|
"draw": int(os.environ.get("FUT_MATCH_COINS_DRAW", "200")),
|
|
"loss": int(os.environ.get("FUT_MATCH_COINS_LOSS", "100")),
|
|
}
|
|
MATCH_PARTICIPATION = int(os.environ.get("FUT_MATCH_PARTICIPATION", "0"))
|
|
|
|
# FUT_MATCH_END -- the 2026-08-04 correction of the whole match tail: route /match/end
|
|
# as DestroyMatch regardless of verb, and move `coins` inside gameModeAward where the
|
|
# deserializer actually reads it. DEFAULT ON, and like FUT_DRAFT_STATE this is a
|
|
# reasoned exception to "default to the live-proven value": nothing here is
|
|
# live-proven, because no match has ever been played. The old behaviour is not a
|
|
# working screen being protected, it is a path that provably could not fire (it
|
|
# required a verb and a /match/{id} URL the client does not use). Set to 0 to revert.
|
|
MATCH_END = os.environ.get("FUT_MATCH_END", "1") == "1"
|
|
|
|
|
|
# The DestroyMatch REQUEST, reversed 2026-08-04 from the serializer rather than
|
|
# guessed from the response side. This replaces the spelling-probe below as the
|
|
# PRIMARY path; the probe stays as a fallback because request-side static findings
|
|
# are a floor, not a ceiling (PUT /item's swap/tradeId were in no static listing).
|
|
#
|
|
# endReason (atom 260) -- STRING enum, and it is the AUTHORITATIVE result signal.
|
|
# Nine values: WIN DRAW LOSS DNF QUIT NO_CONTEST DNF_WIN DNF_DRAW DNF_LOSS.
|
|
# A score comparison is NOT how the client reports the outcome.
|
|
# myMatchStats / opponentMatchStats -- literal-keyed objects, 15 int fields each,
|
|
# first of which is `goals`. OMITTED BY THE CLIENT when endReason is DNF or QUIT,
|
|
# so nothing may require them.
|
|
_END_REASON = {
|
|
"WIN": "won", "DNF_WIN": "won",
|
|
"DRAW": "draw", "DNF_DRAW": "draw", "NO_CONTEST": "draw",
|
|
"LOSS": "loss", "DNF_LOSS": "loss", "DNF": "loss", "QUIT": "loss",
|
|
}
|
|
|
|
|
|
def _match_result(body):
|
|
"""Work out win/draw/loss from whatever the client posted.
|
|
|
|
Primary: endReason, the string enum the serializer actually writes. Fallback:
|
|
the old spelling probe, then a draw, which is the neutral outcome -- it credits
|
|
coins and advances the record without inventing a win. Every body is logged, so
|
|
the first live match still tells us if the static read was incomplete."""
|
|
if not isinstance(body, dict):
|
|
return "draw", None
|
|
|
|
reason = body.get("endReason")
|
|
if isinstance(reason, str) and reason.upper() in _END_REASON:
|
|
mine = body.get("myMatchStats") or {}
|
|
theirs = body.get("opponentMatchStats") or {}
|
|
score = None
|
|
if isinstance(mine, dict) and isinstance(theirs, dict):
|
|
a, b = mine.get("goals"), theirs.get("goals")
|
|
if isinstance(a, int) and isinstance(b, int):
|
|
score = (a, b)
|
|
return _END_REASON[reason.upper()], score
|
|
# a nested match/stats object is as likely as a flat one
|
|
for key in ("match", "matchStats", "stats", "result", "gameResult"):
|
|
inner = body.get(key)
|
|
if isinstance(inner, dict):
|
|
r, s = _match_result(inner)
|
|
if s is not None:
|
|
return r, s
|
|
for us, them in (("goals", "opponentGoals"), ("score", "opponentScore"),
|
|
("userGoals", "opponentGoals"), ("homeGoals", "awayGoals"),
|
|
("ourScore", "theirScore")):
|
|
a, b = body.get(us), body.get(them)
|
|
if isinstance(a, int) and isinstance(b, int):
|
|
return ("won" if a > b else "loss" if a < b else "draw"), (a, b)
|
|
# explicit textual result
|
|
r = body.get("result") or body.get("outcome")
|
|
if isinstance(r, str):
|
|
rl = r.lower()
|
|
for k, v in (("win", "won"), ("won", "won"), ("loss", "loss"),
|
|
("lose", "loss"), ("defeat", "loss"), ("draw", "draw"),
|
|
("tie", "draw")):
|
|
if k in rl:
|
|
return v, None
|
|
return "draw", None
|
|
|
|
|
|
def destroy_match_body(result, coins, total):
|
|
"""FutDestroyMatchServerResponse (deser 0x180121b60) -- PURE, no state.
|
|
|
|
Split out of match_route so it can be unit-tested: the match loop mutates
|
|
(credits coins, bumps W/D/L), so it cannot live in the read-only HTTP contract
|
|
suite. See tools/test_match_rewards.py. Every field is a top-level scalar; the
|
|
nested members matchCoinMultipliers(437)/userData(877) are SKIP-safe and
|
|
deliberately omitted (userData is a documented freeze-risk).
|
|
|
|
THREE CORRECTIONS from the 2026-08-04 pass over deser 0x180121b60, all of which
|
|
were shipping wrong before:
|
|
|
|
1. `coins` (atom 149) is NOT a top-level key of this response. It is read ONLY
|
|
inside the `gameModeAward` object. The top-level "coins" we were sending was
|
|
silently skipped and never reached the client, which means the one field most
|
|
obviously named "the reward" was the one field going nowhere.
|
|
2. `qualifiedChampionEventId` (atom 0x269) HAS A SIDE EFFECT. Its branch does not
|
|
just store the int, it calls through a manager vtable afterwards. Sending it
|
|
as a habitual zero pokes champion-event machinery for no benefit. Removed.
|
|
3. NEVER emit `bidTokens` (atom 89) inside gameModeAward. That atom is explicitly
|
|
matched there and then handled by NOTHING: not read, not routed to the skip
|
|
handler. Its value token is left unconsumed in the stream, which is the exact
|
|
precondition for the type-desync spin. It is a freeze trap wearing the costume
|
|
of an ordinary field.
|
|
|
|
FUT_MATCH_END=0 restores the previous body if the new one misbehaves live."""
|
|
body = {
|
|
"allCoins": int(total),
|
|
"matchCoins": int(MATCH_COINS.get(result, 0)),
|
|
"seasonCoins": 0,
|
|
"tournamentCoins": 0,
|
|
"boostConis": 0, # EA's spelling, atom 96
|
|
"participationAward": int(MATCH_PARTICIPATION),
|
|
"teamOfTournamentWinner": False,
|
|
}
|
|
if MATCH_END:
|
|
# `coins` lives here and nowhere else. No bidTokens, ever.
|
|
body["gameModeAward"] = {"coins": int(coins)}
|
|
else:
|
|
body["coins"] = int(coins)
|
|
body["qualifiedChampionEventId"] = 0
|
|
return body
|
|
|
|
|
|
def match_route(h):
|
|
"""POST create / PUT ready / POST play / DELETE destroy(+rewards)."""
|
|
try:
|
|
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
|
|
except Exception:
|
|
body = {}
|
|
m = re.search(r"/match/(\d+)", h.path)
|
|
match_id = int(m.group(1)) if m else None
|
|
# THE REAL URLS, from the RPC descriptor block (rows 49-54, all using template
|
|
# index 16 = `ut/%s/match`, each appending a fixed suffix via the params object
|
|
# at slot +0x08): CREATEMATCH and PLAYGAME append nothing, MATCHREADY `/ready`,
|
|
# DESTROYMATCH `/end`, RESETMATCH `/reset`, KEEPALIVE `/keepalive`.
|
|
#
|
|
# THERE IS NO /match/{id} URL ANYWHERE. The id travels in the body. So the old
|
|
# `re.search(r"/match/(\d+)")` could never match a real request, and the reward
|
|
# path was gated on DELETE-or-/ut/delete/ which the client also never sends --
|
|
# it would have fired on nothing. match_id is retained only for hand probes.
|
|
#
|
|
# The HTTP VERB for each call cannot be determined statically: the strings "PUT"
|
|
# and "DELETE" do not exist anywhere in cardsdll.dll (0 hits each), so verb
|
|
# selection happens in the HTTP layer outside this DLL. Hence: match on the PATH
|
|
# and accept any verb. A reviewer specifically flagged the claim "the reward path
|
|
# can never fire" as overreach on exactly this point, since the verb is unknown
|
|
# rather than known-wrong, so this widens the gate instead of replacing it.
|
|
is_delete = (h.command == "DELETE" or "/ut/delete/" in h.path
|
|
or (MATCH_END and h.path.split("?")[0].endswith("/match/end")))
|
|
|
|
if is_delete:
|
|
# FutDestroyMatch -- the ONLY place a match awards anything.
|
|
result, score = _match_result(body)
|
|
coins = MATCH_COINS.get(result, 0) + MATCH_PARTICIPATION
|
|
rec, total = STORE.record_match(result, coins)
|
|
log(" MATCH: %s%s -> +%d coins (total %d) record %d-%d-%d"
|
|
% (result, (" %d-%d" % score) if score else "", coins, total,
|
|
rec["won"], rec["draw"], rec["loss"]))
|
|
return 200, destroy_match_body(result, coins, total)
|
|
|
|
if h.command == "POST" and match_id is None:
|
|
# FutCreateMatch. `squad` is nested + freeze-risky -> omitted (SKIP-safe).
|
|
mid = STORE.new_item_id()
|
|
log(" MATCH: created id=%d" % mid)
|
|
return 200, {"startDateTime": int(datetime.datetime.now().timestamp()),
|
|
"reportIdEnabled": False, "id": mid}
|
|
|
|
# PUT {id} = MatchReady, POST {id} = PlayGame. Both have NO deserializer at
|
|
# all, so {} is a complete response; the result is claimed on destroy.
|
|
if body:
|
|
log(" MATCH: %s %s body=%s" % (h.command, h.path, json.dumps(body)[:400]))
|
|
return 200, {}
|
|
|
|
|
|
def squad_route(h):
|
|
# PUT = SaveCurrentSquad. FutSquadSaveServerResponse deser 0x180171a60 parses
|
|
# exactly ONE key, id(0x15c) -> reply {"id": <squadId>}, NOT an echo of the
|
|
# squad (the echo's extra keys were merely SKIP'd, but the id was only present
|
|
# by luck of the client's own body).
|
|
if h.command == "PUT":
|
|
try:
|
|
sq = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else None
|
|
except Exception:
|
|
sq = None
|
|
if isinstance(sq, dict) and sq.get("players"):
|
|
sq.setdefault("id", 0)
|
|
STORE.save_squad(sq)
|
|
log(" SQUAD: saved squad id=%s (%d slots)" % (sq["id"], len(sq["players"])))
|
|
return 200, {"id": sq["id"]}
|
|
# Malformed/empty PUT: still answer with the id we hold, never {} -- the
|
|
# save handler needs the key and an empty body reads as squadId 0.
|
|
return 200, {"id": current_squad().get("id", 0)}
|
|
|
|
# GET = LoadActiveSquad: the full squad object, directly (no wrapper). Never
|
|
# return {} -- an empty body resets the 23 slots (0x18013d1f0).
|
|
sq = current_squad()
|
|
if _SQUAD_LIST_MODE == "merged":
|
|
sq = dict(sq, **squad_list_body(sq))
|
|
return 200, sq
|
|
|
|
|
|
# ---- STORE / PACKS (first-cut; iterate against the log) ---------------------
|
|
# FUT_PRICE_PROBE: step 1 of the pack live test, and the control the rest of it rests
|
|
# on. Sends `funds` and `finalFunds` as different numbers for the Gold Pack alone, so
|
|
# the tile reveals which key the client renders and, more importantly, whether the
|
|
# store body reaches the tile at all.
|
|
#
|
|
# OFF BY DEFAULT and it must go back off after the test: this puts a price on a real
|
|
# tile that the buy path does not charge. The pack still DEBITS p["price"] (5000), so
|
|
# a purchase made with this on leaves the tile and the wallet disagreeing by 679 coins.
|
|
# That is harmless for one run and confusing forever if it is left on.
|
|
#
|
|
# Enable for the test with: FUT_PRICE_PROBE=1 ./openfut-fut.sh restart
|
|
_PRICE_PROBE = os.environ.get("FUT_PRICE_PROBE", "0") == "1"
|
|
# Which pack carries the probe. Env-driven because WHICH TILE IS REACHABLE is not
|
|
# something we control: observed live 2026-08-05, all three packs collapse into a single
|
|
# display group (we send displayGroup but never displayGroupAssetId 0xda, so they all
|
|
# share group 0), and drilling into any group renders one representative, Premium Gold.
|
|
# A probe on an unreachable tile answers nothing, so this has to be movable.
|
|
_PROBE_PACK_ID = int(os.environ.get("FUT_PRICE_PROBE_PACK", "5"))
|
|
_PROBE_FINAL_FUNDS = 4321 # not a round number FUT could plausibly have chosen itself
|
|
|
|
|
|
def _probe_final_funds(p):
|
|
"""finalFunds for one pack tile. Identical to funds unless the probe is armed."""
|
|
if _PRICE_PROBE and p.get("id") == _PROBE_PACK_ID:
|
|
return _PROBE_FINAL_FUNDS
|
|
return p["price"]
|
|
|
|
|
|
def _pack_body(p, idx, owned=False):
|
|
"""One entry of FutStoreGetPackTypes.purchase (element deser 0x18013af30).
|
|
|
|
THIS IS THE ORIGINAL, KNOWN-GOOD BODY -- restored 2026-08-04 after my "field
|
|
corrections" broke pack BUYING live, and now confirmed to be the right call for a
|
|
reason nobody had at the time.
|
|
|
|
THE FUT_STORE_FIELDS BLOCK WAS A FREEZE, NOT A REGRESSION. It has been DELETED.
|
|
It sent `"actionType": 0` and `"firstPartyStoreId": 0` as JSON integers, and both
|
|
atoms (0x8 and 0x127) are read with the STRING getter 0x1801c7aa0. That is the
|
|
exact type-desync class this whole project exists to avoid: a scalar of the wrong
|
|
token type where the parser calls a typed getter, which is what spins the reader
|
|
at 0x1801c7f1a. So "the corrections stopped packs opening" was not bad luck or an
|
|
unrelated field; two of them were the documented freeze mechanism, shipped by a
|
|
change whose own comment said it was correct about what the parser reads.
|
|
|
|
"Parsed" is not "safe to change", and knowing WHICH atoms a parser reads tells you
|
|
nothing about which TYPES it demands. Read the getter, every time.
|
|
|
|
Two of the deleted block's other fields were no-ops anyway: `useDefaultImage`
|
|
(0x36a) stores inverted, so True set it false, and `visible` (0x37d) never reads
|
|
its value at all (`local_130 = 1` unconditionally).
|
|
"""
|
|
gold = p["gold"]
|
|
mtx = max(1, p["price"] // 100)
|
|
body = {
|
|
"assetId": p["id"],
|
|
"id": p["id"],
|
|
"packType": "GOLD" if gold else "BRONZE",
|
|
"description": p["name"],
|
|
"state": "active",
|
|
"saleType": "promo",
|
|
"limitType": "NONE",
|
|
"quantity": 0,
|
|
"purchaseLimit": 0,
|
|
"purchaseCount": 0,
|
|
"isPremium": False,
|
|
"sortPriority": idx,
|
|
# FUT_PRICE_PROBE: the step-1 CONTROL of the 2026-08-05 pack live test.
|
|
# `funds` and `finalFunds` are sent as DIFFERENT numbers for one pack only, so
|
|
# the tile tells us which of the two the client renders, and whether the store
|
|
# body reaches the tile at all. Without this a silent Quick Sell in step 4 is
|
|
# ambiguous between "Quick Sell sends nothing" and "our store response never
|
|
# arrived". Default OFF: it is a wrong price on a real tile, so it must not
|
|
# linger past the test. See docs/plan-2026-08-05-pack-opening.md section 7.
|
|
# finalFunds 4321 is deliberately not a round number FUT could have chosen.
|
|
"currencies": [{"name": "coins", "funds": p["price"],
|
|
"finalFunds": _probe_final_funds(p)}],
|
|
"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"],
|
|
},
|
|
# This is a top-level BOOL in the 0x158-byte pack record. Nesting it in
|
|
# packContentInfo (the old code) is skip-safe but completely inert.
|
|
"unopened": bool(owned),
|
|
}
|
|
if owned:
|
|
# Reward packs are opened with usePreOrder=1 and have no purchase path.
|
|
# Leaving zero-value coin/mtx objects attached makes the My Packs tile
|
|
# format an unavailable payment label as the literal "undefined".
|
|
body.pop("currencies", None)
|
|
body.pop("extPrice", None)
|
|
if owned:
|
|
body["displayGroup"] = {"value": "mypacks", "priority": idx}
|
|
elif STORE_DISPLAYGROUP:
|
|
# THE "unknown" FIX. displayGroup(0xd9) is parsed INLINE as a FLAT OBJECT --
|
|
# it is NOT recursive, the case-0xd9 body never re-enters 0x18013af30, so the
|
|
# recursion the old notes assumed does not exist and there was never anything
|
|
# to terminate. Exactly two members live inside it: value(0x377, STRING) and
|
|
# priority(0x250, INT).
|
|
#
|
|
# `value` writes record offset +0x00, which is the SAME slot whose constructor
|
|
# default is the literal "unknown" (the only such literal in cardsdll.dll,
|
|
# 0x180223108, written by FUN_180133f60). That is why the tiles read "unknown":
|
|
# not a missing translation, just a field nobody ever sent.
|
|
#
|
|
# ONE KEY, distinct per pack so grouping stays 1:1 and the current one-tile-
|
|
# per-pack layout is preserved. priority(0x250), displayGroupAssetId(0xda) and
|
|
# displayGroupUseDefaultImage(0xdb) are all OMITTED: each is a second variable,
|
|
# and 0xdb stores inverted so the old True was always a no-op.
|
|
#
|
|
# Parser-side this is provably balanced: the store root 0x1801234e0 consumes
|
|
# each element's START_OBJECT, so case 0xd9 dispatches with the value token
|
|
# current, and {"value": "..."} consumes FIELD_NAME, string scalar,
|
|
# END_OBJECT. Nothing left over, nothing over-consumed.
|
|
#
|
|
# WHAT THE PROOF DOES NOT COVER, and it is the real risk: this may be the first
|
|
# field we have ever sent that selects a RENDER PATH rather than a value.
|
|
# Going from no-displayGroup to a-displayGroup could switch FIFA17.exe from an
|
|
# ungrouped-tile layout to a group-tile layout, and that code is packed. If the
|
|
# tiles collapse into one, or render blank because `value` is a loc key rather
|
|
# than a caption, the store goes from ugly-but-working to unusable. Default OFF
|
|
# for exactly that reason, and the live test buys a pack to prove the buy path
|
|
# still works.
|
|
# FIFA 17's StoreFront does not treat this value as an arbitrary caption.
|
|
# It resolves exactly six hard-coded category tokens: mypacks, points,
|
|
# bronze, silver, gold and special (FUN_180014580/FUN_180014df0). Pack
|
|
# titles here create unsupported pseudo-categories and make GOTO_STORE_MYPACK
|
|
# initially land on the all-groups screen. Keep ordinary packs in the
|
|
# client's canonical categories; `description` remains the per-pack title.
|
|
if p.get("specialChance", 0.0) >= 1.0:
|
|
category = "special"
|
|
elif gold:
|
|
category = "gold"
|
|
else:
|
|
category = "bronze"
|
|
body["displayGroup"] = {"value": category}
|
|
# FUT_STORE_GROUPID. The risk flagged above ACTUALLY HAPPENED, live 2026-08-05:
|
|
# sending displayGroup did switch the store to a grouped render path, all three
|
|
# packs collapsed into ONE group, and drilling into any of the three group tiles
|
|
# rendered the same single Premium Gold pack. Two of three packs became
|
|
# unbuyable. Cosmetic tile names were bought with two thirds of the store.
|
|
#
|
|
# displayGroupAssetId (0xda) is the obvious thing to group BY, and it is real:
|
|
# case 0xda in 0x18013af30 calls the INT getter 0x1801c79d0 and lands in the
|
|
# 0x158-byte pack record at +0x30 (the record is copy-constructed out of the
|
|
# stack frame at the tail of the deser, via FUN_1801340e0 / FUN_180132180).
|
|
# Omitting it presumably leaves every pack on the same default, hence one group.
|
|
#
|
|
# This is a hypothesis with a mechanism, not a proven fix. The consumer that
|
|
# builds group membership was NOT located: it is reached from the packed
|
|
# FIFA17.exe side and chasing it costs far more than the live test does.
|
|
# Type fidelity is not the risk here (a scalar into an INT getter is the safe
|
|
# direction; the freeze that started all this came from sending displayGroup as
|
|
# an ARRAY where a flat object was expected), so the cheap experiment is sound.
|
|
#
|
|
# Default OFF until a launch shows three separately buyable tiles.
|
|
# If it does NOT work, the correct fallback is FUT_STORE_DISPLAYGROUP=0, which
|
|
# restores the ungrouped layout: tiles read "unknown" but all three are buyable.
|
|
# Ugly and working beats pretty and unbuyable.
|
|
if STORE_GROUPID:
|
|
body["displayGroupAssetId"] = p["id"]
|
|
return body
|
|
|
|
|
|
def store_catalog(h):
|
|
"""GET ut/%s/store/purchasegroup/... -- FutStoreGetPackTypes (root 0x1801234e0).
|
|
|
|
The "unknown" tiles are addressed by FUT_STORE_DISPLAYGROUP (see _pack_body).
|
|
|
|
THE FUT_STORE_GROUPS BLOCK IS DELETED, and its failure is now fully explained. It
|
|
sent displayGroup as an ARRAY of pack-shaped objects, on the belief that the key
|
|
was parsed recursively by this same element parser. It is not: it is a flat object
|
|
with two members. An array there desyncs the reader, the parser then runs off the
|
|
end of the document, and the tokenizer returns the same token forever with nothing
|
|
consumed -- an infinite loop inside FUN_1801c7f10, whose body contains the spin PC
|
|
0x1801c7f1a that was observed live. Not a mystery freeze; a traced one.
|
|
"""
|
|
normal = [p for p in PACK_CATALOG if not p.get("ownedOnly")]
|
|
packs = [_pack_body(p, idx) for idx, p in enumerate(normal, start=1)]
|
|
owned_ids = visible_unopened_packs()
|
|
for idx, pack_id in enumerate(owned_ids, start=1):
|
|
owned = pack_by_id(pack_id)
|
|
if owned:
|
|
packs.append(_pack_body(owned, idx, owned=True))
|
|
if not owned_ids:
|
|
# GOTO_STORE_MYPACK resolves the hard-coded `mypacks` group before it
|
|
# renders rows. If the group is absent FIFA falls back to Bronze and
|
|
# shows the empty-category dialog over the wrong tab. Retain an inactive
|
|
# zero-item sentinel so the destination resolves, while state != active
|
|
# keeps it out of the visible row list. Its id is deliberately absent
|
|
# from PACK_CATALOG, so both purchase/open handlers reject it as well.
|
|
sentinel = {
|
|
"id": 65534,
|
|
"name": "",
|
|
"price": 0,
|
|
"count": 0,
|
|
"gold": True,
|
|
"specialChance": 0.0,
|
|
}
|
|
empty = _pack_body(sentinel, 1, owned=True)
|
|
empty["state"] = "inactive"
|
|
empty["unopened"] = False
|
|
packs.append(empty)
|
|
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 or pack.get("ownedOnly"):
|
|
return 200, {}
|
|
items = STORE.open_pack(pack["price"], pack["count"], pack["gold"],
|
|
pack.get("tiers"), pack.get("specialChance", 0.0),
|
|
pack.get("playersOnly", False))
|
|
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()}
|
|
if pack.get("ownedOnly") and not STORE.consume_unopened_pack(pid):
|
|
log(" STORE: rejected unopened pack %s; no owned instance" % pid)
|
|
return 200, {"itemData": STORE.last_pack()}
|
|
if pack.get("ownedOnly"):
|
|
_OPENED_PACK_GRACE.append(pid)
|
|
items = STORE.open_pack(pack["price"], pack["count"], pack["gold"],
|
|
pack.get("tiers"), pack.get("specialChance", 0.0),
|
|
pack.get("playersOnly", False))
|
|
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()
|
|
body = {
|
|
"credits": c,
|
|
"currencies": [
|
|
{"name": "coins", "funds": c, "finalFunds": c},
|
|
{"name": "points", "funds": 0, "finalFunds": 0},
|
|
],
|
|
}
|
|
# Do not count _OPENED_PACK_GRACE here: it is a UI-lifetime catalogue shim,
|
|
# not an owned pack. Reporting it would leave the My Packs badge stuck at 1.
|
|
unopened_count = len(STORE.unopened_packs())
|
|
if unopened_count:
|
|
body["unopenedPacks"] = {"preOrderPacks": 0,
|
|
"recoveredPacks": unopened_count}
|
|
return 200, body
|
|
|
|
|
|
# ---- 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}
|
|
|
|
|
|
def auction_counts_route(h):
|
|
"""GET tradePile/counts -- FutGetAuctionCount (deser 0x180163770). Distinct from
|
|
/tradePile: this is the auction TALLY, not the listing list. Until now it fell
|
|
through to tradepile_route and got {auctionInfo,...}, which the counts deser skips,
|
|
leaving every count at its constructor default. Survivable but wrong.
|
|
|
|
All five fields are scalar ints (count 0xbc, maxAuctionsAllowed 0x1bf, offered
|
|
0x1e5, selling 0x2b8, sold 0x2c9), so there is no container-type freeze risk.
|
|
They are the only inputs to IS_MAX_AUCTIONS (FUN_1800377c0 = !(max<0 || cur<max));
|
|
maxAuctionsAllowed 100 with selling < 100 keeps the cap open.
|
|
"""
|
|
n = len(STORE.listings())
|
|
return 200, {"count": n, "maxAuctionsAllowed": 100,
|
|
"offered": 0, "selling": n, "sold": 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()
|