Files
OpenFUT/fifa17-recon/tools/utas_server.py
T
funman300 bebe573d05 fifa17-recon: persistent FUT profile + starter pack
Add fut_store.py: a JSON-backed profile store (coins, owned club items,
saved squads, record). First run grants a starter pack -- 15000 coins +
a 10-player starter club (real assetIds; identity resolves locally
in-game per docs/CARD_SYSTEM.md).

Wire utas_server to the store: /user/credits -> persisted coins,
/club -> persisted owned items, PUT /squad -> persists the squad the
user builds so it survives relaunches. Profile save file is gitignored.

Foundation for pack-opening (store/purchasegroup + transaction) next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PN5bmpDVQR1aXgefyWAt7o
2026-08-01 20:52:48 -07:00

247 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""Minimal FIFA 17 UTAS/RS4 server (OpenFUT, clean-room).
CardsDLL resolves every RS4 endpoint to <base> + path, where <base> comes from
FUT_RS4_APIURL_<MODULE> / FUT_RS4_URL_<CALL> (blaze_responder_v3b.py) and path is
moduleTable[i] with %s -> "game/<sku>". Auth is POST <base>ut/auth; the response's
"sid" becomes the X-UT-SID header on every later call (CardsDLL @0x180126080).
Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
* NEVER 401/403 -> silent re-auth storm (3 retries) then ServerFatalError.
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
* [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
"""
import datetime, json, os, re, sys, http.server
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fut_seed import CLUB, SQUAD, USER_LIST # forged starter squad (clean-room)
from fut_store import STORE # persistent profile (coins/club/squads)
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/<sku>/user parser 0x180146970 does Parse + TWO NextToken calls
# before deserializing -> the object MUST be wrapped in one member. The member
# NAME is never compared, but the nesting level is required.
USER_GET = {"userInfo": user_info()}
# POST ut/game/<sku>/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/<sku>/settings (0x18013C6D0) recognises ONE key: configs (0xa2).
SETTINGS = {"configs": []}
# GET ut/game/<sku>/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/<sku>/item/resource, ut/<sku>/defid, and batch
# ut/<sku>/item?idList=<ids>. We serve them here (deser 0x18013fe00, same as items).
# resourceId = playerId | version<<24 ; assetId = resourceId & 0xffffff.
PLAYER_DEFS = {
# assetId: (name, rating, position, nation, leagueId, teamid, [6 attrs])
20801: ("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80]),
}
def item_def(rid):
"""Build one FUT item-definition for a requested resourceId."""
asset = rid & 0xffffff
name, rating, pos, nation, league, team, attrs = PLAYER_DEFS.get(
asset, ("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70]))
return {
"id": rid,
"resourceId": rid,
"definitionId": rid,
"assetId": asset,
"cardassetid": asset,
"commodityId": asset,
"cardsubtypeid": 0,
"cardType": 0,
"itemType": "player",
"rareflag": 1,
"rating": rating,
"preferredPosition": pos,
"nation": nation,
"leagueId": league,
"teamid": team,
"playStyle": 250,
"attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)],
"name": name,
"commonName": name,
"lastName": name,
"itemState": "free",
"untradeable": True,
}
def defs_route(h):
# Parse every integer id out of the query string (idList=a,b,c / definitionId=x
# / resourceId=x) and return a definition for each.
q = h.path.split("?", 1)[1] if "?" in h.path else ""
ids = [int(n) for n in re.findall(r"\d{3,}", q)]
if not ids:
return 200, {"itemData": []}
return 200, {"itemData": [item_def(i) for i in ids]}
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)),
(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)
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()