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:
@@ -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"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user