#!/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 datetime, json, os, re, sys, http.server sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from fut_seed import CLUB, SQUAD, USER_LIST, squad_summary # forged starter squad (clean-room) from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item # profile + packs ADDR = ("127.0.0.1", 8099) LOG = "/tmp/utas_server.log" SID = "OPENFUT-SID-0000000000000001" PERSONA_ID = 33068179 # blaze LoginResponse SESS.BUID / PDTL.PID PERSONA_NAME = "CAGE" # PDTL.DSNM # Flip to True once you want to exercise the create-club path instead. NEW_USER = False def now(): return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") def log(m): line = "[%s] %s" % (datetime.datetime.now().strftime("%H:%M:%S"), m) print(line, flush=True) with open(LOG, "a") as f: f.write(line + "\n") # ---- payloads ------------------------------------------------------------- def auth_body(): # Only "sid" is load-bearing (parser 0x1801a2880 -> ServerCall+0x1e8). # serverTime/lastOnlineTime are atoi'd at char offsets 0,5,8,11,14,17. return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()} def current_squad(): """The squad the client should see: the persisted one (item refs re-embedded from the club) or the seed ladder squad on first run. personaId is FORCED to PERSONA_ID: SquadLoad compares it against the logged-in persona at 0x18014659c and, on mismatch, takes the vtable[0x4f0] branch and builds a throwaway squad instead of adopting ours. """ saved = STORE.active_squad() # Overlay the saved squad on the seed envelope: FIFA's PUT body carries only # what it changed, so this keeps a valid custom(0xc6) 33-int string, kicktakers # and squadName present on reload instead of silently dropping them. sq = dict(SQUAD) if saved: sq.update(STORE.reconstruct_squad(saved)) sq["personaId"] = PERSONA_ID sq.setdefault("id", 0) return sq def squad_list_body(squad=None): """FutSquadListServerResponse body (deser 0x180172140 -> value parser 0x180142260 -> element 0x180141fc0). ONE key, "squad"(0x2cd), holding an ARRAY of squad summaries. This is the same parser userInfo.squadList goes through.""" return {"squad": [squad_summary(squad or current_squad())]} # Exactly TWO branches of the userInfo deser 0x18013ec10 have side effects OUTSIDE # the userInfo record -- they reach into the global model singleton FUN_18011a830: # squadList(0x2d4) -> vtbl[0x480] -> +0x30, fills the squad-ROSTER model # unopenedPacks(0x35e) -> vtbl[0x4e0](preOrderPacks + recoveredPacks) # Every other member just fills a scalar/string slot in the record. The 2026-08-03 # crash is a NULL member inside a FIFA17.exe UI model, and userInfo had never # actually reached the client before (massinfo was {} and /user is not called at # boot) -- so these two newly-exercised branches are the prime suspects, and both # are optional for coins/record. Ladder, cheapest-first: # min = ONLY what coins/record need | safe = + the rest of the scalars # roster (default) = +squadList (MY SQUADS) | packs = +unopenedPacks | full = both # `roster` is live-confirmed working (hub + coins + record + MY SQUADS: 1). Only # `unopenedPacks` remains untested, hence `full` is not the default. # # ROOT CAUSE, ISOLATED LIVE 2026-08-03 -- clubNameChangeAllowed(0x8f) must be FALSE. # Two runs sent the IDENTICAL field set and differed only in that one bool: # true -> client shows the FUT club-name prompt, then dies confirming it # (ACCESS_VIOLATION reading 0x0 at FIFA17.exe+0x71b8651, 4/4 runs) # false -> no prompt at all, hub loads, coins + record render # Sending true advertises "a club-name change is available", pushing the client into # a naming flow whose UI model we never populate. Neither side-effecting member # (squadList / unopenedPacks) was involved -- both were already omitted in the # crashing run. Keep this false unless the rename flow is actually implemented. _UI = os.environ.get("FUT_USERINFO", "roster") def user_info(): # Deserializer 0x18013EC10; every member optional (unknown key ids are # skipped via 0x180135FF0), so {} also parses. # Now that massinfo is populated, this record actually reaches the hub -- read # the live profile instead of the old hardcoded placeholders. p = STORE.profile() rec = p.get("record", {}) info = { "personaId": PERSONA_ID, "clubName": p.get("clubName", "OpenFUT"), "clubAbbr": p.get("clubAbbr", "OFC"), "established": p.get("established", "2026"), # accountCreatedPlatformName(0x6), stored at userInfo+0x42: the only string # 0x18013ec10 consumes that we used to omit (clubAbbr 0x8d / clubName 0x8e / # established 0x110 were already covered). Sending it is correct, but note # it was NOT the cause of the create-club crash -- that was still identical # with this field present. Value matches auth's nucleusPersonaPlatform. "accountCreatedPlatformName": "pc", # The userInfo currency-element parser FUN_180138bd0 reads name(0x1d0), # funds(0x134), finalFunds(0x124), active(0xa) -- there is NO "value" key, # so the old {"name","value"} pairs were parsed as 0 and the hub showed 0 # coins. The caller then matches name against "coins"/"points" literally. "currencies": [ {"name": "coins", "funds": STORE.coins(), "finalFunds": STORE.coins(), "active": True}, {"name": "points", "funds": p.get("points", 0), "finalFunds": p.get("points", 0), "active": True}, ], # record: won(0x387) draw(0xe6) loss(0x1a6), all scalar slots in the record "won": rec.get("won", 0), "draw": rec.get("draw", 0), "loss": rec.get("loss", 0), } # Everything below is optional for coins/record. `min` stops here. if _UI != "min": info.update({ # clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62. MUST stay False: # True is the isolated root cause of the create-club crash (see _UI). "clubNameChangeAllowed": False, "divisionOffline": 10, "divisionOnline": 10, "purchased": False, # 0x262 -> bool at +0x68 "feature": {"trade": True}, "reliability": {"reliability": 100, "matchUnfinishedTime": 0}, "bidTokens": {"count": 0, "updateTime": 0}, "trophies": 0, "sessionCoinsBankBalance": 0, # actives(0xb): array of <=5 item refs (item parser 0x18013fe00). The seed # ladder squad has none yet -> empty array (arrays never desync). "actives": (current_squad().get("actives") or [])[:5], }) # ---- the two side-effecting members, off by default (see _UI above) -------- if _UI in ("packs", "full"): # unopenedPacks(0x35e): after parsing preOrderPacks(0x24b)+recoveredPacks # (0x27b) the deser calls singleton->vtbl[0x4e0](preOrder + recovered). info["unopenedPacks"] = {"preOrderPacks": 0, "recoveredPacks": 0} if _UI in ("roster", "full"): # squadList(0x2d4) -> FUN_180142260 on singleton->vtbl[0x480]+0x30, i.e. it # fills the global squad-ROSTER model ("MY SQUADS" on the Squads screen). # Must be an OBJECT {"squad":[...]}; a bare [] is SKIP-safe but leaves the # roster empty. NOT required for the ACTIVE squad -- that comes from the # massinfo `squad` member, which is already proven working. info["squadList"] = squad_list_body() return info # GET ut/game//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(): # squad(0x2cd) goes to the SAME LoadActiveSquad parser as everywhere else, so # serve the real schema-correct squad rather than {} -- a create-club response # carrying an empty squad leaves the client with a 0-slot squad model, which is # precisely the state that makes AddPlayerToSquad no-op (see REBUILD_PLAN S9c). return {"login": True, "userData": user_info(), "squad": current_squad(), "starterPack": {}, "bonusPacks": []} # GET ut/game//settings (0x18013C6D0) recognises ONE key: configs (0xa2). SETTINGS = {"configs": []} # 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") def massinfo(): if _MI == "empty": return {} # old known-hub-reaching body if _MI == "squad": return {"squad": current_squad()} if _MI == "userinfo": return {"userInfo": user_info()} if _MI == "settings": return {"settings": SETTINGS} return {"userInfo": user_info(), # squadList -> roster singleton "squad": current_squad(), # personaId == PERSONA_ID "settings": SETTINGS, "userData": {}} # ---- FUT item-definition serving (wf_e41070d8) ------------------------------- # The card view-model 0x1800d7920 renders identity/rating/face from a RESOLVED # record at item+0x10, filled by looking the resourceId up in the FUT item-def # store. That store is network-filled; empty offline => generic cards. FIFA # fetches definitions from ut//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.""" asset = rid & 0xffffff name, rating, pos, nation, league, team, attrs = PLAYER_DEFS.get( asset, ("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70])) return { "id": rid, "resourceId": rid, "definitionId": rid, "assetId": asset, "cardassetid": asset, "commodityId": asset, "cardsubtypeid": 0, "cardType": 0, "itemType": "player", "rareflag": 1, "rating": rating, "preferredPosition": pos, "nation": nation, "leagueId": league, "teamid": team, "playStyle": 250, "attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)], "name": name, "commonName": name, "lastName": name, "itemState": "free", "untradeable": True, } def defs_route(h): # Parse every integer id out of the query string (idList=a,b,c / definitionId=x # / resourceId=x) and return a definition for each. q = h.path.split("?", 1)[1] if "?" in h.path else "" ids = [int(n) for n in re.findall(r"\d{3,}", q)] if not ids: return 200, {"itemData": []} return 200, {"itemData": [item_def(i) for i in ids]} def item_route(h): # PUT ut/game/fifa17/item = FutMoveCard (move item to a pile, e.g. the reveal # screen's "keep/assign" -> {"itemData":[{"id":..,"pile":"club","swap":0, # "tradeId":0}]}). Response echoes the FULL updated card-item(s) + chemistry # bool (ENDPOINT_MAP item rows 6/7/10/11; itemData via deser 0x18013fe00). # Returning [] here makes FIFA think the move failed -> kicks to main menu. # GET stays defs_route (ViewCards / ConsumablesSearch / loan lists). if h.command == "PUT": try: body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {} except Exception: body = {} req = body.get("itemData") if isinstance(req, list): moved = STORE.move_items(req) if moved: log(" ITEM: moved %d item(s) to pile(s)" % len(moved)) # Same shape as the proven-parseable GET itemData (no extra keys): # adding an unexpected key (e.g. chemistry) desynced the parser # and made FIFA report "failed to send to club" then log out. return 200, {"itemData": moved} return 200, {"itemData": []} return defs_route(h) G = r"/ut/game/[^/]+" ROUTES = [ # ---- FUT item-definition endpoints (must precede generic /item, /user) ---- (re.compile(G + r"/item/resource"), lambda m, h: defs_route(h)), (re.compile(G + r"/defid"), lambda m, h: defs_route(h)), (re.compile(G + r"/item(\?|$)"), lambda m, h: item_route(h)), # ---- store / packs (match regardless of /ut/game vs /ut/v2/game prefix) ---- (re.compile(r"/store/purchasegroup"), lambda m, h: store_catalog(h)), (re.compile(r"/store/transaction"), lambda m, h: store_buy(h)), # ut/v2/game/fifa17/store = FutStorePackQuantities ELIGIBILITY GATE, not a # quantity list. deser 0x1801758c0 reads one key "result" (atom 0x288); the # store screen shows "not available" unless this is SUCCESS. (ENDPOINT_MAP # store §2.) Bare /store only -- purchasegroup/transaction matched above. (re.compile(r"/store(\?|$)"), lambda m, h: (200, {"result": "SUCCESS"})), (re.compile(r"/purchased"), lambda m, h: purchased_items(h)), (re.compile(r"^/ut/auth"), lambda m, h: (200, auth_body())), (re.compile(r"^/ut/delete/auth"), lambda m, h: (200, {})), (re.compile(G + r"/settings"), lambda m, h: (200, SETTINGS)), # Device-trust ("phishing") flow. trusteddevice parser 0x18012a170 reads 4 # booleans by key-id 0x7e/0x117/0x19e/0x351; 0x351 == JSON key "trusted". # Returning trusted=true makes FUT SKIP the security question. (re.compile(G + r"/phishing/trusteddevice"), lambda m, h: (200, {"trusted": True})), (re.compile(G + r"/phishing/validate"), lambda m, h: (200, {"token": "OPENFUT-TRUST-0000000000000000"})), (re.compile(G + r"/phishing/question"), lambda m, h: (200, {"question": 0, "answer": "", "attempts": 5})), (re.compile(G + r"/user/credits"), lambda m, h: credits_route(h)), # ---- club/squad routes (2026-08-03: squad schema 0x18013d1f0 now reversed) ---- # /user/list + /user/accountinfo stay {} (nothing in them is load-bearing yet). # /user, /squad and /userMassInfo serve real data again -- the squad object # matches the verified schema, so the 2026-08-01 revert-to-{} no longer applies. (re.compile(G + r"/user/list"), lambda m, h: (200, {})), (re.compile(G + r"/user/accountinfo"), lambda m, h: (200, {})), (re.compile(G + r"/user$|" + G + r"/user\?"), lambda m, h: user_route(h)), # LIVE GROUND TRUTH 2026-08-03: FutSquadList has its OWN URL, `ut/%s/squad/list` # -- the request-table strings only showed ut/%s/squad, so the static conclusion # "there is NO separate squad-list URL" (REBUILD_PLAN S1b) was WRONG. This must # precede the generic /squad route, which was swallowing it and returning the # full active-squad object; the list parser 0x180142260 recognises ONLY # squad(0x2cd) and skipped every one of those keys -> "MY SQUADS: 0". (re.compile(G + r"/squad/list"), lambda m, h: (200, squad_list_body())), (re.compile(G + r"/squad"), lambda m, h: squad_route(h)), (re.compile(G + r"/match/keepalive"), lambda m, h: (204, None)), # PUT ut/%s/match/reset = FutResetMatch. Seen live at boot (2026-08-03) as an # UNMAPPED catch-all; it is a Tier-B ack (shared no-op deser), so {} is correct # -- routed explicitly so it stops showing up as an unmapped hit in the log. (re.compile(G + r"/match/reset"), lambda m, h: (200, {})), (re.compile(G + r"/hub"), lambda m, h: (200, {})), # Populated massinfo (see massinfo() above): userInfo + squad + settings. (re.compile(G + r"/userMassInfo"), lambda m, h: (200, massinfo())), (re.compile(G + r"/season"), lambda m, h: (200, {})), # ---- transfer market / auction house (empty-but-valid; ENDPOINT_MAP market §) # tradePile MUST precede /trade ("/tradePile" contains the "/trade" prefix). (re.compile(G + r"/tradePile"), lambda m, h: tradepile_route(h)), (re.compile(G + r"/trade"), lambda m, h: trade_route(h)), (re.compile(G + r"/watchList"), lambda m, h: watchlist_route(h)), (re.compile(G + r"/auctionhouse"), lambda m, h: auctionhouse_route(h)), # LIVE GROUND TRUTH: FIFA's market SEARCH hits /transfermarket (one word), not # /auctionhouse (was UNMAPPED -> {} => empty market). Serve the same listings. (re.compile(G + r"/transfermarket"), lambda m, h: auctionhouse_route(h)), (re.compile(G + r"/marketdata"), lambda m, h: (200, {"minPrice": 150, "maxPrice": 15000})), (re.compile(r"/ut/delete/game/[^/]+/trade"), lambda m, h: delete_trade_route(h)), (re.compile(r"/ut/delete/game/[^/]+/watchList"), lambda m, h: (200, {})), (re.compile(G + r"/club"), lambda m, h: (200, {"itemData": STORE.items()})), ] def user_route(h): if h.command == "POST": return 200, user_post() if NEW_USER: # accepted, non-fatal: 0x18007AF20 treats 404 as the new-user branch return 404, {} return 200, user_get() # ut/%s/squad carries the WHOLE squad family (request table 0x18021dfc0): GET = # LoadActiveSquad AND GetSquadList/GetSquads, PUT = SaveCurrentSquad/SaveSquad. # Load-vs-List is not resolvable statically (one binding row each, 0x18027cb70 / # 0x18027be28) -- but the two parsers are mutually SKIP-tolerant: the squad-object # parser 0x18013d1f0 does not recognise "squad"(0x2cd), and the list parser # 0x180142260 recognises ONLY "squad". So a MERGED body satisfies both. Kept behind # FUT_SQUAD_LIST=merged for now (GET /squad is boot-critical; default keeps the # proven plain-squad body so a live test isolates one change at a time). _SQUAD_LIST_MODE = os.environ.get("FUT_SQUAD_LIST", "off") def squad_route(h): # PUT = SaveCurrentSquad. FutSquadSaveServerResponse deser 0x180171a60 parses # exactly ONE key, id(0x15c) -> reply {"id": }, NOT an echo of the # squad (the echo's extra keys were merely SKIP'd, but the id was only present # by luck of the client's own body). if h.command == "PUT": try: sq = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else None except Exception: sq = None if isinstance(sq, dict) and sq.get("players"): sq.setdefault("id", 0) STORE.save_squad(sq) log(" SQUAD: saved squad id=%s (%d slots)" % (sq["id"], len(sq["players"]))) return 200, {"id": sq["id"]} # Malformed/empty PUT: still answer with the id we hold, never {} -- the # save handler needs the key and an empty body reads as squadId 0. return 200, {"id": current_squad().get("id", 0)} # GET = LoadActiveSquad: the full squad object, directly (no wrapper). Never # return {} -- an empty body resets the 23 slots (0x18013d1f0). sq = current_squad() if _SQUAD_LIST_MODE == "merged": sq = dict(sq, **squad_list_body(sq)) return 200, sq # ---- STORE / PACKS (first-cut; iterate against the log) --------------------- def store_catalog(h): packs = [] idx = 1 for p in PACK_CATALOG: gold = p["gold"] mtx = max(1, p["price"] // 100) packs.append({ "assetId": p["id"], "id": p["id"], "packType": "GOLD" if gold else "BRONZE", "description": p["name"], "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": False, "sortPriority": idx, "currencies": [{"name": "coins", "funds": p["price"], "finalFunds": p["price"]}], "extPrice": {"finalPrice": {"amount": mtx, "currency": "mtx"}, "originalPrice": {"amount": mtx, "currency": "mtx"}}, "packContentInfo": { "bronzeQuantity": 0 if gold else p["count"], "silverQuantity": 0, "goldQuantity": p["count"] if gold else 0, "rareQuantity": p["count"] if gold else 0, "itemQuantity": p["count"], "unopened": False, }, }) idx += 1 return 200, {"purchase": packs, "timestamp": 1596326400} def store_buy(h): # PUT (v2) store/transaction. The BUY is the create step: body carries "packId" # (atom 0x20b, TRANSACTIONCREATED) and state != TRANSACTIONCANCEL (wf a245577b / # wf_76fcf89b). Only THEN open a pack. Cancel/other -> no-op {} (fixes the # phantom-buy). Reveal = {"createPackResponse":{itemList,numberItems, # purchasedPackId,duplicateItemIdList}} (FutCreatePackServerResponse 0x180162880). try: body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {} except Exception: body = {} pid = body.get("packId") if body.get("state") == "TRANSACTIONCANCEL" or not isinstance(pid, int): return 200, {} # not a confirmed buy pack = pack_by_id(pid) if not pack: return 200, {} items = STORE.open_pack(pack["price"], pack["count"], pack["gold"]) if items is None: return 461, {"reason": "insufficient_coins", "credits": STORE.coins()} log(" STORE: opened pack %s -> %d items, coins=%d" % (pack["name"], len(items), STORE.coins())) return 200, {"createPackResponse": { "itemList": items, "numberItems": len(items), "purchasedPackId": pid, "duplicateItemIdList": [], }} def purchased_items(h): # POST = FutPurchaseItemsServerResponse: the BUY itself. Live ground truth: FIFA # sends {"packId":1,"useCredits":1,"usePreOrder":0,"currency":"COINS"} then # immediately polls GET /purchased/items for the awarded cards. Open the pack # here (debit coins, award items). Response mirrors CardsDLL's serializer # (0x180126900): packId, firstPartyStoreId, groupName, productId, # purchasePackType -- unknown keys are skipped, so extra fields are harmless. # GET = FutGetPurchasedItemsServerResponse: the awarded items from the last buy. if h.command == "POST": try: body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {} except Exception: body = {} pid = body.get("packId") pack = pack_by_id(pid) if isinstance(pid, int) else None if pack is None: return 200, {"itemData": STORE.last_pack()} items = STORE.open_pack(pack["price"], pack["count"], pack["gold"]) if items is None: return 461, {"reason": "insufficient_coins", "credits": STORE.coins()} log(" STORE: POST /purchased opened pack %s -> %d items, coins=%d" % (pack["name"], len(items), STORE.coins())) return 200, { "packId": pid, "firstPartyStoreId": 0, "groupName": "fifa17", "productId": str(pack["id"]), "purchasePackType": "GOLD" if pack["gold"] else "BRONZE", } return 200, {"itemData": STORE.last_pack()} def credits_route(h): # The FUT hub coin counter binds to currencies[].funds (deser 0x180122c50, # atom "currencies" 0xc5), NOT a "credits" key -- wf_76fcf89b. c = STORE.coins() return 200, { "credits": c, "currencies": [ {"name": "coins", "funds": c, "finalFunds": c}, {"name": "points", "funds": 0, "finalFunds": 0}, ], } # ---- TRANSFER MARKET / AUCTION HOUSE (ENDPOINT_MAP market §) ---------------- # Every list response shares one body: {auctionInfo:[record], credits, total, # duplicateItemIdList} (shared deser 0x18013e7f0). auctionInfo + dupIdList MUST be # ARRAYS and each record.itemData MUST be a card OBJECT, or the SAX reader desyncs # -> busy-loop freeze at 0x1801c7f1a. We serve an EMPTY-but-valid market (no live # listings yet): empty arrays never desync, so this is freeze-safe. Populating real # auctions needs an in-game test pass. Extra keys are SKIP'd, so one merged body # safely satisfies both the search parser and the auction-count parser. # Sample auction listings (real players from the pack pool) so the market is # browsable/buyable. Each record follows the reversed auction schema (deser # 0x18013e410) EXACTLY; itemData reuses fut_store._item -- the same proven-safe # card shape that renders club/squad cards (parser 0x18013fe00). All record # fields are HIGH-confidence reversed scalars, so freeze risk is low. Toggle with # FUT_MARKET=empty. Price heuristic: rating-based buy-now, ~66% starting bid. _MARKET_MODE = os.environ.get("FUT_MARKET", "sample") _TRADE_ID_BASE = 900000000 def _price_for(rating): if rating >= 90: return 25000 if rating >= 85: return 8000 if rating >= 80: return 2500 if rating >= 75: return 900 return 400 def _auction_record(i, defn): asset, rating, pos, nation, league, team, attrs = defn buy = _price_for(rating) card = _item(_TRADE_ID_BASE + 100000 + i, asset, rating, pos, nation, league, team, attrs) card["untradeable"] = False # market cards are tradeable card["itemState"] = "forSale" return { "tradeId": _TRADE_ID_BASE + i, "itemData": card, # OBJECT (0x18013fe00) -- freeze-safe "tradeState": "active", # enum string "buyNowPrice": buy, "startingBid": max(150, (buy * 2) // 3), "currentBid": 0, "bidState": "none", # enum string "expires": 3600, # SECONDS remaining (not epoch) "sellerName": "EASFC", "sellerEstablished": 1, "watched": False, "coinsProcessed": 0, } def _market_auctions(limit=21): if _MARKET_MODE == "empty": return [] return [_auction_record(i, PACK_POOL[i % len(PACK_POOL)]) for i in range(min(limit, len(PACK_POOL)))] def _auction_by_tradeid(tid): # tradeId space is _TRADE_ID_BASE + index into PACK_POOL -> reconstruct the auction. i = tid - _TRADE_ID_BASE if 0 <= i < len(PACK_POOL): return _auction_record(i, PACK_POOL[i]) return None def _market_body(auctions): return {"auctionInfo": auctions, "credits": STORE.coins(), "total": len(auctions), "duplicateItemIdList": []} def auctionhouse_route(h): # GET = search (sample listings) OR count -> merged body (extra keys skip) # POST = FutISStart (list item for sale) -> {"id": new tradeId} # PUT = .../relist (relist all expired) -> ack {} if h.command == "POST": # FutISStart: list an owned club item for sale -> {"id": tradeId} try: b = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {} except Exception: b = {} item_id = (b.get("itemData") or {}).get("id") or b.get("itemId") if item_id: tid = STORE.list_for_sale(item_id, b.get("startingBid", 150), b.get("buyNowPrice", 0)) log(" MARKET: listed item %s -> tradeId %d" % (item_id, tid)) return 200, {"id": tid} return 200, {"id": STORE.new_item_id()} if h.command == "PUT": return 200, {} body = _market_body(_market_auctions()) body.update({"count": 0, "maxAuctionsAllowed": 100, "offered": 0, "selling": 0, "sold": 0}) # FutGetAuctionCount ints return 200, body def trade_route(h): # GET view one auction -> {auctionInfo:[record], credits} # POST/PUT place bid / buy-now -> stateful: on buy-now (bid >= buyNowPrice) deduct # coins, grant the won card to the club, echo the CLOSED auction. Reuses the # validated auction-record shape (0x18013e410) so it's freeze-safe; whether FIFA # surfaces the won item post-buy is functional (needs live test). FutISOfferTrade. m = re.search(r"/trade/(\d+)", h.path) tid = int(m.group(1)) if m else -1 rec = _auction_by_tradeid(tid) if h.command in ("POST", "PUT"): try: body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {} except Exception: body = {} if rec is None: return 200, {"auctionInfo": [], "credits": STORE.coins()} bid = body.get("bid") or rec["buyNowPrice"] if bid >= rec["buyNowPrice"]: # BUY NOW if not STORE.spend(rec["buyNowPrice"]): return 461, {"reason": "insufficient_coins", "credits": STORE.coins()} won = dict(rec["itemData"]); won.pop("id", None) won["itemState"] = "free" # unassigned/won granted = STORE.add_items([won])[0] rec = dict(rec) rec.update({"tradeState": "closed", "bidState": "highest", "currentBid": rec["buyNowPrice"], "itemData": granted}) log(" MARKET: bought tradeId %d for %d, coins=%d, card->club" % (tid, rec["currentBid"], STORE.coins())) else: # simple bid (we're sole bidder) rec = dict(rec); rec.update({"currentBid": bid, "bidState": "highest"}) return 200, {"auctionInfo": [rec], "credits": STORE.coins()} return 200, {"auctionInfo": [rec] if rec else [], "credits": STORE.coins()} def tradepile_route(h): # The user's OWN sale pile: build a validated auction record per active listing # from the owned club item + its list prices. Freeze-safe (same record shape). by_id = {it["id"]: it for it in STORE.items()} seller = STORE.profile().get("personaName", "OpenFUT") recs = [] for l in STORE.listings(): it = by_id.get(l["itemId"]) if not it: continue card = dict(it); card["itemState"] = "listFS" recs.append({ "tradeId": l["tradeId"], "itemData": card, "tradeState": "active", "buyNowPrice": l.get("buyNowPrice", 0), "startingBid": l.get("startingBid", 150), "currentBid": 0, "bidState": "none", "expires": 3600, "sellerName": seller, "sellerEstablished": 1, "watched": False, "coinsProcessed": 0, }) return 200, {"auctionInfo": recs, "credits": STORE.coins(), "total": len(recs)} def delete_trade_route(h): # DELETE ut/delete/game/fifa17/trade/{id} -- remove a listing from the sale pile. m = re.search(r"/trade/(\d+)", h.path) if m: STORE.remove_listing(int(m.group(1))) return 200, {} def watchlist_route(h): if h.command in ("PUT", "POST", "DELETE"): return 200, {} # add/remove watch -> ack return 200, {"auctionInfo": [], "credits": STORE.coins(), "total": 0} class H(http.server.BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def _handle(self): n = int(self.headers.get("Content-Length", 0) or 0) body = self.rfile.read(n) if n else b"" self._body = body # route fns (squad PUT) read this log("%s %s" % (self.command, self.path)) for k, v in self.headers.items(): log(" %s: %s" % (k, v)) if body: log(" body: %s" % body[:65536].decode("utf-8", "replace")) code, payload = 200, {} for rx, fn in ROUTES: if rx.search(self.path): code, payload = fn(rx, self) break else: log(" !! UNMAPPED PATH -> catch-all 200 {}") raw = b"" if payload is None else json.dumps(payload).encode() self.send_response(code) if raw: self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(raw))) self.end_headers() if self.command != "HEAD" and raw: self.wfile.write(raw) log(" -> %d %s" % (code, raw[:200].decode() if raw else "(no body)")) do_GET = do_POST = do_PUT = do_DELETE = do_HEAD = do_PATCH = _handle def log_message(self, *a): pass if __name__ == "__main__": open(LOG, "a").close() log("=== utas_server http://%s:%d ===" % ADDR) http.server.ThreadingHTTPServer(ADDR, H).serve_forever()