629813b580
Add auctionhouse/trade/tradePile/watchList/marketdata routes per ENDPOINT_MAP
market §. All share the reversed IS-list body {auctionInfo:[], credits, total,
duplicateItemIdList} (shared deser 0x18013e7f0). Served EMPTY (no live listings
yet) -- empty arrays never desync the SAX reader, so freeze-safe; unlocks the
market screens vs the prior catch-all {}. GET auctionhouse merges the
FutGetAuctionCount ints (extra keys skip). POST auctionhouse = FutISStart new
tradeId; PUT relist / watch add-remove / delete = acks. tradePile precedes
/trade (prefix collision). Populating real auctions deferred to in-game test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PN5bmpDVQR1aXgefyWAt7o
398 lines
18 KiB
Python
Executable File
398 lines
18 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, 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/<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)),
|
|
# ---- 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 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, {})),
|
|
# ---- 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)),
|
|
(re.compile(G + r"/marketdata"), lambda m, h: (200, {"minPrice": 150, "maxPrice": 15000})),
|
|
(re.compile(r"/ut/delete/game/[^/]+/trade"), lambda m, h: (200, {})),
|
|
(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
|
|
|
|
|
|
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, (STORE.reconstruct_squad(saved) if saved else SQUAD)
|
|
|
|
|
|
# ---- STORE / PACKS (first-cut; iterate against the log) ---------------------
|
|
def store_catalog(h):
|
|
# GET store/purchasegroup/all. Root key MUST be "purchase" (atom 608, array);
|
|
# pack identity is "id" (int16, NOT packId); price is a "currencies" array of
|
|
# {name,funds,finalFunds}; display name is "description". (wf a245577b —
|
|
# {purchaseGroups:...} + packId/price were all unknown atoms => empty => "not
|
|
# available".) quantity:0 => unlimited.
|
|
# Parse format is verified correct ("purchase" array, per-pack 0x18013af30).
|
|
# The store still rejected minimal packs -> a pack must be COMPLETE to count as
|
|
# valid: content info + BOTH a coins price (currencies) and a FIFA-Points price
|
|
# (extPrice {finalPrice,originalPrice} -> {"mtx":N}).
|
|
packs = []
|
|
for p in PACK_CATALOG:
|
|
gold = p["gold"]
|
|
mtx = max(1, p["price"] // 100)
|
|
packs.append({
|
|
# assetId (atom 0x23) is the REAL pack identity the deser 0x18013af30
|
|
# reads (ENDPOINT_MAP store §). id/packType/quantity/saleType/isPremium
|
|
# are all unknown atoms -> SKIP (harmless no-ops, kept for readability).
|
|
"assetId": p["id"],
|
|
"id": p["id"],
|
|
"packType": "GOLD" if gold else "BRONZE",
|
|
"description": p["name"],
|
|
"quantity": 0,
|
|
"purchaseLimit": 0,
|
|
"purchaseCount": 0,
|
|
"isPremium": False,
|
|
"saleType": "PERMANENT",
|
|
"sortPriority": p["id"] - 100,
|
|
"currencies": [{"name": "coins", "funds": p["price"], "finalFunds": p["price"]}],
|
|
# extPrice inner keys are amount(0x1b)/currency(0xc4), NOT mtx (skipped).
|
|
"extPrice": {"finalPrice": {"amount": mtx, "currency": "fifapoints"},
|
|
"originalPrice": {"amount": mtx, "currency": "fifapoints"}},
|
|
"packContentInfo": {
|
|
"bronzeQuantity": 0 if gold else p["count"],
|
|
"silverQuantity": 0,
|
|
"goldQuantity": p["count"] if gold else 0,
|
|
"rareQuantity": 1 if gold else 0,
|
|
"itemQuantity": p["count"],
|
|
},
|
|
})
|
|
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):
|
|
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.
|
|
def _market_body():
|
|
return {"auctionInfo": [], "credits": STORE.coins(), "total": 0,
|
|
"duplicateItemIdList": []}
|
|
|
|
|
|
def auctionhouse_route(h):
|
|
# GET = search (empty results) 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":
|
|
return 200, {"id": STORE.new_item_id()}
|
|
if h.command == "PUT":
|
|
return 200, {}
|
|
body = _market_body()
|
|
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 / POST place bid -> {auctionInfo:[record], credits}
|
|
return 200, {"auctionInfo": [], "credits": STORE.coins()}
|
|
|
|
|
|
def tradepile_route(h):
|
|
b = _market_body(); b.pop("duplicateItemIdList", None)
|
|
return 200, b # {auctionInfo, credits, total}
|
|
|
|
|
|
def watchlist_route(h):
|
|
if h.command in ("PUT", "POST", "DELETE"):
|
|
return 200, {} # add/remove watch -> ack
|
|
b = _market_body(); b.pop("duplicateItemIdList", None)
|
|
return 200, b
|
|
|
|
|
|
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()
|