#!/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 # forged starter squad (clean-room) from fut_store import STORE, PACK_CATALOG, pack_by_id # persistent 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 user_info(): # Deserializer 0x18013EC10; every member optional (unknown key ids are # skipped via 0x180135FF0), so {} also parses. return { "personaId": PERSONA_ID, "clubName": "OpenFUT", "clubAbbr": "OFC", "established": "2026", "clubNameChangeAllowed": True, "currencies": [{"name": "coins", "value": 15000}, {"name": "points", "value": 0}], "won": 0, "draw": 0, "loss": 0, "divisionOffline": 10, "divisionOnline": 10, "purchased": False, "feature": {"trade": True}, "reliability": {"reliability": 100, "matchUnfinishedTime": 0}, "unopenedPacks": {"preOrderPacks": 0, "recoveredPacks": 0}, "bidTokens": {"count": 0, "updateTime": 0}, "trophies": 0, "sessionCoinsBankBalance": 0, "actives": [], "squadList": [], } # 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. USER_GET = {"userInfo": user_info()} # POST ut/game//user (CreateUser, 0x18014CC60) recognises exactly: # bonusPacks(0x5d) login(0x1a5) squad(0x2cd) starterPack(0x2e5) userData(0x36d). USER_POST = {"login": True, "userData": user_info(), "squad": {}, "starterPack": {}, "bonusPacks": []} # GET ut/game//settings (0x18013C6D0) recognises ONE key: configs (0xa2). SETTINGS = {"configs": []} # GET ut/game//userMassInfo (GetUserMassInfo, deser 0x180174630). # ANY content here (userInfo AND/OR squad) DESYNCS CardsDLL's massinfo parser -> # infinite tokenizer spin (busy-loop freeze at 0x1801c7f1a). Proven: {} reaches # the hub; {userInfo,...} and {...,squad,...} both freeze. The userInfo sub-deser # 0x18013ec10 mis-consumes some field in user_info(). So keep userMassInfo EMPTY # (hub-reaching) and deliver club/squad via their OWN endpoints (/user, /club, # /squad) whose parsers we know work. Select via env FUT_MASSINFO (empty|userinfo|full). _MI = os.environ.get("FUT_MASSINFO", "empty") if _MI == "full": MASSINFO = {"userInfo": user_info(), "squad": SQUAD, "settings": {"configs": []}, "userData": {}} elif _MI == "userinfo": MASSINFO = {"userInfo": user_info(), "settings": {"configs": []}, "userData": {}} else: MASSINFO = {} # proven hub-reaching # ---- 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]} 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: defs_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)), (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: (200, {"credits": STORE.coins()})), # ---- club/squad routes reverted to known-good {} stubs (2026-08-01) ---- # The forged squad in MASSINFO/SQUAD/CLUB HANGS CardsDLL's deserializer (hard # freeze at boot). Re-enable only after the exact shape is reversed. The forged # data still lives in fut_seed.py + MASSINFO/squad_route below (unrouted). (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)), (re.compile(G + r"/squad"), lambda m, h: squad_route(h)), (re.compile(G + r"/match/keepalive"), lambda m, h: (204, None)), (re.compile(G + r"/hub"), lambda m, h: (200, {})), # STEP 1 (wf_0bc80ab3): zero-resolve squad in MASSINFO; club stays {}. (re.compile(G + r"/userMassInfo"), lambda m, h: (200, MASSINFO)), (re.compile(G + r"/season"), 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 def squad_route(h): # GET = LoadActiveSquad, PUT = updateActiveSquad. Persist the squad the user # builds so it survives relaunches. Never return {} (empty body resets the 23 # slots, 0x18013d1f0). 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"): STORE.save_squad(sq) return 200, sq saved = STORE.active_squad() return 200, (saved if saved else SQUAD) # ---- STORE / PACKS (first-cut; iterate against the log) --------------------- def store_catalog(h): # GET store/purchasegroup/all -> the pack catalog FIFA displays. groups = [{ "id": p["id"], "packId": p["id"], "productId": p["id"], "name": p["name"], "price": {"coins": p["price"], "points": 0}, "coins": p["price"], "itemCount": p["count"], "currency": "coins", } for p in PACK_CATALOG] return 200, {"purchaseGroups": groups, "packs": groups} def store_buy(h): # PUT (v2) store/transaction. SAFE NO-OP until the real purchase-CONFIRM signal # is reversed. Observed bodies are NOT confirmed buys: # {"state":"TRANSACTIONCANCEL"} = cancel/close the store # {"packId":N} = fetch a pack's details when the store loads # Opening a pack on either wrongly spent coins. Log every body so we can spot # the real confirm body when the user makes a DELIBERATE purchase, then gate # open_pack() on exactly that. (open_pack lives in fut_store, ready to wire.) try: body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {} except Exception: body = {} log(" STORE txn body=%r (no-op; buy-confirm flow not reversed yet)" % body) return 200, {} def purchased_items(h): return 200, {"itemData": STORE.last_pack()} 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()