fifa17-recon: take running-backend versions of 8 runtime files (direction fix)

The earlier reconcile committed the local working-tree versions of these
files, which are OLDER than the deployed backend. The running container (C)
is byte-identical to docker/fifa17-python/tools (B) and is a strict superset:
it adds profile_path_for/select_account/ensure_security_question (fut_store),
safe_header_for_log/safe_request_path/security_question_route (utas_server),
account_sync_route/_match_call/match_ready_body, plus POW balance fields and
match lifecycle support, with zero unique local functions lost.

Reconciled tree is now a strict superset of B with every shared file
byte-identical; verified via md5 map (0 missing, 0 differing).
This commit is contained in:
funman300
2026-08-10 17:12:27 -07:00
parent 695421cfd4
commit 83539e33ec
8 changed files with 419 additions and 70 deletions
+204 -30
View File
@@ -12,6 +12,7 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
* [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)
@@ -19,13 +20,14 @@ from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item, player_
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 = ("127.0.0.1", int(os.environ.get("FUT_PORT", "8099")))
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
@@ -61,6 +63,96 @@ def log(m):
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.
@@ -104,6 +196,19 @@ def auth_body(h=None):
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.
@@ -1095,6 +1200,9 @@ def item_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)),
@@ -1117,12 +1225,10 @@ ROUTES = [
(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. 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})),
# 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
@@ -1343,8 +1449,23 @@ def hub_data():
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,
body = {"clubPlayers": players, "auctionCount": auctions,
"tradePile": {"count": auctions, "selling": auctions, "sold": 0}}
if _MODES:
# GetHubData's parser 0x180139610 recognises offlineSeason (atom 0x1ec)
# and passes it to 0x18013c3a0. The nested scalar fields are STRING
# getters, despite representing numbers. Omitting the object leaves the
# offline-season summary invalid and the UI aborts before requesting
# /season. The initial division matches season_list()/season_user(); the
# ten-game length is a live-test hypothesis, isolated behind FUT_MODES.
body["offlineSeason"] = {
"divisionId": "10",
"gamesPlayed": "0",
"points": "0",
"totalGames": "10",
"progressDataVersion": "0",
}
return body
# ---- club stats: the CLUB STATS panel, and probably the MY CLUB tile too ------
@@ -2206,12 +2327,11 @@ def clientdata_route(h):
"""ut/%s/clientdata/<key> -- opaque client blob storage.
LIVE-OBSERVED: `PUT ut/game/fifa17/clientdata/userHubData` fires from the FUT
hub (20:40 session). The client is storing its own hub state -- so the correct
server behaviour is to keep the blob and hand back exactly what was given, which
is zero-risk by construction: we never synthesise a shape, we echo the client's
own bytes. Persisting it is also the most plausible route to the hub's
"MANAGER TASKS 0/0" tile surviving a relaunch, since no FutGetObjectives class
exists in the binary at all (§9) -- the tile state may simply live in this blob.
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"):
@@ -2283,10 +2403,17 @@ def season_user():
def tournament_list():
"""GET ut/%s/tournament -- FutTournamentList, deser 0x180169ef0 (MEDIUM).
ARRAY root; rounds/prizeSet/staff/kit atoms are nested FREEZE-RISK -> omitted."""
return [{"id": 1, "difficulty": 1, "coins": 500, "rewardMultiplier": 1,
"assetName": "", "eligibilityOperation": ""}]
"""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():
@@ -2989,14 +3116,46 @@ def destroy_match_body(result, coins, total):
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):
"""POST create / PUT ready / POST play / DELETE destroy(+rewards)."""
"""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)
match_id = int(m.group(1)) if m else None
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`,
@@ -3013,10 +3172,11 @@ def match_route(h):
# 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.
is_delete = (h.command == "DELETE" or "/ut/delete/" in h.path
or (MATCH_END and h.path.split("?")[0].endswith("/match/end")))
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 is_delete:
if call == "end":
# FutDestroyMatch -- the ONLY place a match awards anything.
result, score = _match_result(body)
coins = MATCH_COINS.get(result, 0) + MATCH_PARTICIPATION
@@ -3026,15 +3186,24 @@ def match_route(h):
rec["won"], rec["draw"], rec["loss"]))
return 200, destroy_match_body(result, coins, total)
if h.command == "POST" and match_id is None:
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}
# PUT {id} = MatchReady, POST {id} = PlayGame. Both have NO deserializer at
# all, so {} is a complete response; the result is claimed on destroy.
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, {}
@@ -3328,13 +3497,18 @@ def purchased_items(h):
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()}
if pack.get("ownedOnly"):
_OPENED_PACK_GRACE.append(pid)
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:
@@ -3555,9 +3729,9 @@ class H(http.server.BaseHTTPRequestHandler):
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))
log("%s %s" % (self.command, safe_request_path(self.path)))
for k, v in self.headers.items():
log(" %s: %s" % (k, v))
log(" %s: %s" % (k, safe_header_for_log(k, v)))
if body:
log(" body: %s" % body[:65536].decode("utf-8", "replace"))