#!/usr/bin/env python3 """Minimal FIFA 17 UTAS/RS4 server (OpenFUT, clean-room). CardsDLL resolves every RS4 endpoint to + path, where comes from FUT_RS4_APIURL_ / FUT_RS4_URL_ (blaze_responder_v3b.py) and path is moduleTable[i] with %s -> "game/". Auth is POST 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 from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit 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 from fut_accounts import activate as activate_account # 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 = (os.environ.get("OPENFUT_BIND", "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") def safe_request_path(path): """Redact legacy phishing answers before ordinary request logging.""" parts = urlsplit(path) query = [] for key, value in parse_qs(parts.query, keep_blank_values=True).items(): query.extend((key, "[REDACTED]" if key.lower() == "answer" else item) for item in value) return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)) _SECRET_HEADERS = { "authorization", "cookie", "set-cookie", "x-ut-sid", "x-pow-sid", } def safe_header_for_log(name, value): """Return a diagnostic-safe HTTP header value.""" if name.lower() in _SECRET_HEADERS: return "[REDACTED]" return value _PHISHING_HEX32 = re.compile(r"^[0-9a-fA-F]{32}$") def security_question_route(h): """Emulate FIFA 17's retired FUT phishing/security-question service. Clean-room CardsDLL evidence: GET /question?deviceId=%s parses question/attempts/recoverAttempts. POST /validate?deviceId=%s&answer=%s parses no response fields. /trusteddevice parses changed/exists/locked/trusted booleans. The answer is an opaque client-transformed 32-hex value. Successful legacy set/validate calls have empty response contracts, so OpenFUT acknowledges a well-formed value without retaining or comparing it. Account selection has already initialized the server-owned verified compatibility state. """ if h.headers.get("X-UT-SID") != SID: log("[FUT] security-question request has no matching OpenFUT session") return 400, {"reason": "invalid_session"} parts = urlsplit(h.path) action = parts.path.rstrip("/").rsplit("/", 1)[-1] params = parse_qs(parts.query, keep_blank_values=True) device_id = params.get("deviceId", [""])[0] if not _PHISHING_HEX32.fullmatch(device_id): log("[FUT] malformed security-question device identifier") return 400, {"reason": "malformed_request"} state = STORE.ensure_security_question() log("[FUT] security-question %s request" % action) log("[FUT] profile security state: %s" % ("initialized" if state.get("verified") else "not initialized")) if action == "trusteddevice": if h.command != "GET": return 405, {"reason": "method_not_allowed"} log("[FUT] returning verified trusted-device response") return 200, { "changed": False, "exists": True, "locked": False, "trusted": True, } if action == "question" and h.command == "GET": return 200, {"question": 0, "attempts": 5, "recoverAttempts": 0} if action == "question" and h.command in ("POST", "PUT"): answer = params.get("answer", [""])[0] question = params.get("question", [""])[0] if not question.isdigit() or not _PHISHING_HEX32.fullmatch(answer): log("[FUT] malformed security-question setup request") return 400, {"reason": "malformed_request"} log("[FUT] security-question compatibility setup completed") return 200, {} if action == "validate" and h.command == "POST": answer = params.get("answer", [""])[0] if not _PHISHING_HEX32.fullmatch(answer): log("[FUT] malformed security-question validation request") return 400, {"reason": "malformed_request"} log("[FUT] security-question accepted") return 200, {} return 405, {"reason": "method_not_allowed"} # ---- 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 account_sync_route(h): """Launcher-only active-profile selection, before LSX/Blaze login starts.""" try: body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {} account = activate_account(body) except (ValueError, TypeError) as error: return 400, {"error": str(error)} log(" ACCOUNT: selected %s/%r profile=%s coins=%s unopened=%s" % (account["personaId"], account["personaName"], account["profilePath"], account["coins"], account["unopenedPacks"])) return 200, {"account": account, "status": "OK"} 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//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//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//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//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//item/resource, ut//defid, and batch # ut//item?idList=. 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": ""}. 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/ -- 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 = [ # Launcher control-plane endpoint. It is intentionally outside /ut so FIFA # never calls it; launch is blocked unless this succeeds first. (re.compile(r"^/openfut/account/sync$"), lambda m, h: account_sync_route(h)), # ---- 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/ -- 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 /), 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. One handler owns its exact state machine, # validation, persistence and redacted diagnostics; keep these above /user. (re.compile(G + r"/phishing/(trusteddevice|validate|question)"), lambda m, h: security_question_route(h)), (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/ # 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/ -> 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/ lists the LEAGUES in that nation (case 3, keyed by LEAGUE_ID) and league/ 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:" 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/ -- 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/, league/, # team/. 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/ renders a list of leagues keyed by LEAGUE_ID; league/ 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="-". _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@' 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 '-' or 'auto:-:'" % 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/ -- opaque client blob storage. LIVE-OBSERVED: `PUT ut/game/fifa17/clientdata/userHubData` fires from the FUT hub. Persist the client-owned blob so its matching GET can restore it. The PUT acknowledgement remains the historical empty object: echoing the body was exercised live with both observed values ([3,0] and [3,1]) and did not unlock offline Seasons or produce a subsequent /season request. No response schema has been recovered for SetTutData, so do not infer one from the request shape. """ 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 -- object wrapper parsed at 0x18016b220 (HIGH). The response parser recognizes only tournament(0x328), opens its ARRAY, then invokes the element parser at 0x180169ef0. A bare array populates nothing. rounds(0x292) and elgReq(0xf7) are nested ARRAY loops and remain omitted. The wrapper/root shape is recovered; element semantics remain live-unverified. """ return {"tournament": [ {"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":}). 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_call(path, method, body): """Classify one of CardsDLL's six match calls. The RPC descriptor block gives READY/END/RESET/KEEPALIVE explicit suffixes. CREATEMATCH and PLAYGAME both use the bare ``ut/%s/match`` path; CardsDLL serializes atom ``matchId`` as an integer for operations on an existing match, which is the discriminator for PLAYGAME. HTTP verbs are intentionally not used for that pair because method selection lives outside CardsDLL. """ clean = path.split("?", 1)[0].rstrip("/") for suffix, call in (("/ready", "ready"), ("/end", "end"), ("/reset", "reset"), ("/keepalive", "keepalive")): if clean.endswith(suffix): return call if method == "DELETE" or "/ut/delete/" in clean: return "end" if isinstance(body, dict) and isinstance(body.get("matchId"), int): return "play" return "create" def match_ready_body(match_id, opponent_persona_id): """Minimal FutMatchReadyServerResponse (CardsDLL parser 0x1801205d0). The parser has scalar ``matchId`` and ``opponentPersonaId`` members plus a nested ``items`` member. The latter remains omitted until its opponent-squad item contract is recovered; unrecognized/absent members are skip-safe. """ return {"matchId": int(match_id), "opponentPersonaId": int(opponent_persona_id)} def match_route(h): """Create / ready / play / destroy(+rewards) on CardsDLL's match paths.""" 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) url_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. call = _match_call(h.path, h.command, body) body_match_id = body.get("matchId") if isinstance(body, dict) else None match_id = body_match_id if isinstance(body_match_id, int) else url_match_id if call == "end": # 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 call == "create": # 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} if call == "ready": # FutMatchReadyServerResponse has two scalar IDs and an optional nested # item list. Preserve an explicit opponent supplied by the request. For # offline AI the value is not yet live-confirmed; zero is deliberately a # TODO/CONFIRM neutral placeholder, never the selected user's persona. opponent_id = body.get("opponentPersonaId", 0) if isinstance(body, dict) else 0 if not isinstance(opponent_id, int): opponent_id = 0 return 200, match_ready_body(match_id or 0, opponent_id) # FutPlayGameServerResponse has no parsed fields. The result is claimed on end. 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": }, 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()} 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()} # FIFA always returns to its hard-coded `mypacks` group after the reveal, # including for an ordinary coin-purchased pack. Keep one owned-shaped # catalogue copy alive until the next hub request; otherwise that group # contains only the inactive sentinel and FIFA shows "The pack you've # selected is currently not available" after a successful opening. if pid not in _OPENED_PACK_GRACE: _OPENED_PACK_GRACE.append(pid) 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 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()