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
+92 -33
View File
@@ -128,14 +128,52 @@ CLIENT_ID = ACCOUNT.CLIENT_ID
PLATFORM = ACCOUNT.PLATFORM
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n"
# ================================================================== config
HOST = "127.0.0.1"
def refresh_account_identity():
"""Refresh launcher-selected identity before constructing a Blaze session.
The account sync endpoint runs in the separate UTAS process and atomically
replaces the shared active-account file. Blaze snapshots these aliases for
its response builders, so refresh them once at each new TCP session.
"""
global PERSONA_ID, PERSONA_NAME, USER_ID, EXT_ID, EMAIL, ACCOUNT_LOCALE_FALLBACK
ACCOUNT.load(force=True)
PERSONA_ID = ACCOUNT.persona_id
PERSONA_NAME = ACCOUNT.persona_name
USER_ID = ACCOUNT.user_id
EXT_ID = ACCOUNT.ext_id
EMAIL = ACCOUNT.email
ACCOUNT_LOCALE_FALLBACK = ACCOUNT.account_locale_int
# ================================================================== config
#
# Client/server split support (OpenFUT dev-container): two env vars, both
# defaulting to loopback so the original all-on-localhost flow is byte-identical.
# OPENFUT_BIND — the address the listeners bind (0.0.0.0 in a container).
# OPENFUT_ADVERTISE — the address this server hands back to the client for the
# NEXT hop (Blaze host, roster/UTAS/telemetry/QoS URLs). On
# 105-local this is 127.0.0.1; on the 120 server it is the
# server's LAN IP so the game dials 120 directly after the
# first (hook/DNAT-redirected) contact.
import os as _os_cfg
_ADVERTISE = _os_cfg.environ.get("OPENFUT_ADVERTISE", "127.0.0.1")
_BIND = _os_cfg.environ.get("OPENFUT_BIND", "127.0.0.1")
def _ip_str_to_u32(ip):
"""Dotted-quad -> big-endian u32 (matches the original (127<<24)|1 layout).
Falls back to loopback if the advertise value isn't a bare IPv4 literal."""
try:
a, b, c, d = (int(x) for x in ip.split("."))
return (a << 24) | (b << 16) | (c << 8) | d
except Exception:
return (127 << 24) | 1
HOST = _BIND
REDIR_PORT = 42127
BLAZE_PORT = 42130
NUCLEUS_PORT = 42131
BLAZE_IP_STR = "127.0.0.1"
BLAZE_IP_U32 = (127 << 24) | 1
BLAZE_IP_STR = _ADVERTISE
BLAZE_IP_U32 = _ip_str_to_u32(_ADVERTISE)
LOG = "/tmp/blaze_responder.log"
RXDIR = "/tmp/blaze_rx"
HERE = os.path.dirname(os.path.abspath(__file__))
@@ -157,7 +195,9 @@ REPLY_EMPTY_TO_UNKNOWN = True
# (grid-blaze order) or after (pamplona order). Both are reported to work.
NOTIFY_BEFORE_LOGIN_REPLY = False
DUMP_FRAMES = True
# Raw Fire2 frames and decoded TDF can contain auth/session material. Keep the
# reverse-engineering capture path, but require an explicit opt-in for it.
DUMP_FRAMES = os.environ.get("OPENFUT_BLAZE_DUMP_FRAMES") == "1"
_log_lock = threading.Lock()
@@ -525,7 +565,7 @@ OSDK_TICKER = []
# branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url.
# Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject
# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK.
ROSTER_HOST = "127.0.0.1:8081"
ROSTER_HOST = "%s:8081" % _ADVERTISE
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
OSDK_ROSTER = [
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
@@ -603,7 +643,7 @@ CLIENT_CONFIGS = {
# /etc/hosts easw.easports.com->127.0.0.1 redirect. MUST be exactly "http://127.0.0.1:8099/"
# (scheme + trailing slash mandatory on the auth path). Do NOT serve FUT_TARGET_PORT
# (bug @0x1801808e8 reads FUT_MAX_HOPS instead) nor FUT/MODULE_BASEURL_* (dead code).
UTAS_BASE = "http://127.0.0.1:8099/"
UTAS_BASE = "http://%s:8099/" % _ADVERTISE
FUT_RS4_MODULES = [
"AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD",
"DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER",
@@ -739,10 +779,12 @@ FUT_RS4_CONFIG = (
def client_config_for(cfid: str) -> list:
"""-> sorted [(key, value)]. Unknown CFID -> [] (an EMPTY MAP, which we
still wrap in a present CONF field -- never an empty frame).
FUT_RS4_* base-URL keys ride on EVERY CFID (merged '_all' store; which section
CardsDLL reads is unproven, so serve them everywhere)."""
"""Return sorted config rows for one section.
Unknown CFIDs still receive the shared FUT/content/POW rows because those
consumers read the merged ``_all`` store and the contributing section is
unproven. The response always carries a present CONF field.
"""
# OSDK_POW rides on EVERY CFID for the same reason FUT_RS4_* does: powdll's
# FUN_18005a460 reads FIFA_POW_URL out of the merged '_all' store, and which
# section it happens to read is unproven. Empty list when FUT_POW is unset, so
@@ -774,7 +816,7 @@ def qos_config() -> "OrderedDict":
has NO SVID, unlike Mirror's Edge Catalyst)."""
return OrderedDict([
("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo
("PSA", (STRING, "127.0.0.1")),
("PSA", (STRING, _ADVERTISE)),
("PSP", (INT, 17502)),
]))),
("LNP", (INT, 10)),
@@ -1100,7 +1142,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
client to have a well-formed config and then fail to connect quietly rather
than resolve a real EA hostname."""
tele = OrderedDict([ # GetTelemetryServerResponse (15)
("ADRS", (STRING, "127.0.0.1")),
("ADRS", (STRING, _ADVERTISE)),
("ANON", (INT, 0)),
("DISA", (STRING, "")),
("EDCT", (INT, 0)),
@@ -1117,7 +1159,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
("SVNM", (STRING, "telemetry-openfut")),
])
tick = OrderedDict([ # GetTickerServerResponse (3)
("ADRS", (STRING, "127.0.0.1")),
("ADRS", (STRING, _ADVERTISE)),
("PORT", (INT, 8999)),
("SKEY", (STRING, "")),
])
@@ -1261,8 +1303,10 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
log(" -- client locale 0x%08x captured for ALOC" % loc)
resp = preauth_response_fields(service_name=sess.service_name)
payload = encode_tdf(resp)
log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s"
% (sess.service_name, len(payload), heat2.dump(resp)))
log(" -> PreAuthResponse (INST=%r, %d payload bytes)"
% (sess.service_name, len(payload)))
if DUMP_FRAMES:
log(" -> PreAuthResponse TDF:\n%s" % heat2.dump(resp))
return [reply_to(hdr, payload)]
if cmd == CMD_PING:
@@ -1276,8 +1320,9 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
n = len(resp["CONF"][1][2])
log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s"
% (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)"))
for k, v in resp["CONF"][1][2]:
log(" %-32s = %s" % (k, v))
if DUMP_FRAMES:
for k, v in resp["CONF"][1][2]:
log(" %-32s = %s" % (k, v))
return [reply_to(hdr, encode_tdf(resp))]
if cmd == CMD_POSTAUTH:
@@ -1311,12 +1356,13 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
sess.auth_code = get_str(fields or {}, "AUTH", "")
sess.logged_in = True
sess.login_time = int(time.time())
log(" == Authentication::login AUTH=%r (accepted WITHOUT Nucleus "
"validation -- forged offline session)" % sess.auth_code)
log(" == Authentication::login AUTH=[REDACTED] "
"(accepted as an offline OpenFUT session)")
resp = login_response_fields(sess)
payload = encode_tdf(resp)
log(" -> LoginResponse (%d bytes):\n%s"
% (len(payload), heat2.dump(resp)))
log(" -> LoginResponse (%d bytes)" % len(payload))
if DUMP_FRAMES:
log(" -> LoginResponse TDF:\n%s" % heat2.dump(resp))
notifs = build_login_notifications(sess, sess.login_time)
out = []
if NOTIFY_BEFORE_LOGIN_REPLY:
@@ -1467,9 +1513,10 @@ _frame_counter = [0]
def blaze_handle(raw: socket.socket, addr) -> None:
refresh_account_identity()
log("*** BLAZE CONNECT from %s ***" % (addr,))
sess = Session()
log(" session key minted: %s" % sess.session_key)
log(" session key minted: [REDACTED]")
buf = bytearray()
raw.settimeout(300)
try:
@@ -1500,10 +1547,10 @@ def blaze_handle(raw: socket.socket, addr) -> None:
MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]),
hdr["msg_num"], hdr["user_index"], hdr["options"],
hdr["metadata_len"], hdr["payload_len"]))
log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
if metadata:
log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
if DUMP_FRAMES:
log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
if metadata:
log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
try:
os.makedirs(RXDIR, exist_ok=True)
fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin"
@@ -1518,7 +1565,8 @@ def blaze_handle(raw: socket.socket, addr) -> None:
if payload:
try:
fields = decode_tdf(payload)
log("RX #%d TDF:\n%s" % (n, heat2.dump(fields)))
if DUMP_FRAMES:
log("RX #%d TDF:\n%s" % (n, heat2.dump(fields)))
except Exception as e:
log("RX #%d TDF DECODE FAILED: %s" % (n, e))
else:
@@ -1539,7 +1587,8 @@ def blaze_handle(raw: socket.socket, addr) -> None:
ohdr["msg_type"]),
MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]),
ohdr["msg_num"], len(out), ohdr["payload_len"]))
log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024)))
if DUMP_FRAMES:
log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024)))
except ConnectionResetError:
log("BLAZE %s: connection reset by client" % (addr,))
except Exception as e:
@@ -1637,6 +1686,10 @@ def redir_handle(raw: socket.socket, addr) -> None:
# client can never reach accounts.ea.com. Note the exact spacing in the JSON:
# the client searches for the literal '"access_token" : "'.
def nucleus_sent_log(addr, size):
return "NUCLEUS SENT %s %dB access_token=[REDACTED]" % (addr, size)
def nucleus_handle(raw: socket.socket, addr) -> None:
try:
raw.settimeout(10)
@@ -1649,9 +1702,9 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
head, _, rest = req.partition(b"\r\n\r\n")
line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else ""
log("NUCLEUS REQ %s: %s" % (addr, line0))
if head:
if head and DUMP_FRAMES:
log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace"))
if rest:
if rest and DUMP_FRAMES:
log("NUCLEUS BODY: %r" % rest[:512])
token = "OPENFUT_" + "".join(
@@ -1665,7 +1718,7 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
b"Cache-Control: no-store\r\nContent-Length: "
+ str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
raw.sendall(out)
log("NUCLEUS SENT %s %dB access_token=%s" % (addr, len(out), token))
log(nucleus_sent_log(addr, len(out)))
except Exception as e:
log("NUCLEUS ERR %s: %s" % (addr, e))
finally:
@@ -1717,6 +1770,10 @@ def _selftest() -> None:
sess.account_locale = 0x656E5553
now = 1469000000
nucleus_summary = nucleus_sent_log(("127.0.0.1", 1234), 380)
assert "[REDACTED]" in nucleus_summary
assert "OPENFUT_selftest_secret" not in nucleus_summary
# ---- 1. preAuth still round-trips (regression guard vs v2)
pre = preauth_response_fields()
p = _check_roundtrip("PreAuthResponse", pre)
@@ -1740,9 +1797,11 @@ def _selftest() -> None:
assert items == client_config_for(cfid), cfid
print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes"
% (cfid, len(items), len(pb)))
assert client_config_for("TOTALLY_UNKNOWN") == [], "unknown CFID must be []"
shared = sorted(FUT_RS4_CONFIG + FUT_CONTENT_CONFIG + OSDK_POW)
assert client_config_for("TOTALLY_UNKNOWN") == shared, \
"unknown CFID must carry only the shared merged-store rows"
assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \
"unknown CFID must still carry a CONF field (empty map, not empty frame)"
"unknown CFID must still carry a CONF field"
# ---- 3. LoginResponse
lr = login_response_fields(sess)
+38 -1
View File
@@ -204,6 +204,7 @@ class Account:
def __init__(self, path=None):
self.path = path or ACCOUNT_PATH
self._loaded = False
self._file_signature = None
self._stored = {} # what is on disk (tier 2+3 only)
for f in _FIELDS:
setattr(self, "_" + f, None)
@@ -214,7 +215,8 @@ class Account:
save the first time. Never raises on a malformed file -- a broken
account file must not stop the harness booting."""
with _LOCK:
if self._loaded and not force:
signature = self._signature()
if self._loaded and not force and signature == self._file_signature:
return self
stored = {}
if os.path.exists(self.path):
@@ -239,8 +241,22 @@ class Account:
% (self.path, e))
self._stored = stored
self._loaded = True
self._file_signature = self._signature()
return self
def _signature(self):
"""Identity of the active-account file across atomic replacements.
The launcher can select an account while Blaze/POW are already running
in separate processes. inode + mtime + size lets every process notice
the replacement on its next property read without restarting Docker.
"""
try:
st = os.stat(self.path)
return st.st_dev, st.st_ino, st.st_mtime_ns, st.st_size
except OSError:
return None
def _migrate_from_profile(self):
"""Lift identity/club out of a pre-existing fifa17_profile.json so an
existing club name survives the move to this module. Read-only: the game
@@ -266,11 +282,32 @@ class Account:
return out
def _write(self):
parent = os.path.dirname(self.path)
if parent:
os.makedirs(parent, exist_ok=True)
tmp = self.path + ".tmp"
with open(tmp, "w") as f:
json.dump(self._stored, f, indent=1, sort_keys=True)
f.write("\n")
os.replace(tmp, self.path)
self._file_signature = self._signature()
def replace(self, values):
"""Atomically replace the active identity with validated persisted values."""
with _LOCK:
clean = {k: v for k, v in values.items() if k in _FIELDS and v is not None}
if "persona_id" not in clean or "persona_name" not in clean:
raise ValueError("persona_id and persona_name are required")
clean["persona_id"] = int(clean["persona_id"])
clean["persona_name"] = str(clean["persona_name"]).strip()
if clean["persona_id"] <= 0 or not clean["persona_name"]:
raise ValueError("persona_id must be positive and persona_name must not be empty")
self._stored = clean
for field in _FIELDS:
setattr(self, "_" + field, None)
self._loaded = True
self._write()
return self
def save(self):
"""Persist tiers 2+3 (only fields that differ from the built-in default,
+46 -1
View File
@@ -17,7 +17,19 @@ sys.path.insert(0, HERE)
import fut_cards
from fut_account import ACCOUNT # single source of truth for identity/club
PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json"))
PROFILE_ROOT = os.environ.get("FUT_PROFILE_ROOT", "")
def profile_path_for(persona_id):
explicit = os.environ.get("FUT_PROFILE")
if explicit:
return explicit
if PROFILE_ROOT:
return os.path.join(PROFILE_ROOT, str(int(persona_id)), "fifa17_profile.json")
return os.path.join(HERE, "fifa17_profile.json")
PROFILE_PATH = profile_path_for(ACCOUNT.persona_id)
# ---- FUT_DISCARD_TABLE: the REAL FIFA 17 quick-sell values ------------------
#
@@ -386,18 +398,51 @@ class Store:
p["clubName"] = ACCOUNT.club_name
p["clubAbbr"] = ACCOUNT.club_abbr
p["established"] = ACCOUNT.established
# EA/EASFC account-bar state belongs to the same persona as the FUT
# save, but remains a distinct balance from FUT coins.
p["powLevel"] = ACCOUNT.pow_level
p["powExp"] = ACCOUNT.pow_exp
p["powExpMax"] = ACCOUNT.pow_exp_max
p["powFunds"] = ACCOUNT.pow_funds
p["powFundsCap"] = ACCOUNT.pow_funds_cap
return p
def _save(self):
parent = os.path.dirname(self.path)
if parent:
os.makedirs(parent, exist_ok=True)
tmp = self.path + ".tmp"
with open(tmp, "w") as f:
json.dump(self._p, f, indent=1)
os.replace(tmp, self.path)
def select_account(self, persona_id):
"""Switch the single active session to its isolated persistent FUT save."""
with _LOCK:
self.path = profile_path_for(persona_id)
self._p = None
return self.load()
# ---- accessors used by utas_server -------------------------------------
def profile(self):
return self.load()
def ensure_security_question(self):
"""Persist OpenFUT's account-scoped compatibility state for the FUT gate.
FIFA 17 transforms any entered answer before sending it. OpenFUT does not
need that value to emulate a retired service, so neither the clear text nor
the transformed value is stored. The only durable fact is that this
OpenFUT profile has an initialized, verified compatibility record.
"""
expected = {"version": 1, "verified": True}
with _LOCK:
p = self.load()
if p.get("securityQuestion") != expected:
p["securityQuestion"] = dict(expected)
self._save()
return dict(p["securityQuestion"])
def refresh_identity(self):
"""Re-mirror ACCOUNT into the save AND persist it.
+1 -1
View File
@@ -574,7 +574,7 @@ def serve(sock, addr):
def main():
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 4216))
s.bind((os.environ.get("OPENFUT_BIND", "127.0.0.1"), 4216))
s.listen(8)
log("v2 listening on 127.0.0.1:4216 (start FIFA 17 now)")
log(f"login-state event push: {'ENABLED' if EVENTS_ENABLED else 'DISABLED'}"
+1 -1
View File
@@ -20,7 +20,7 @@ HERE = os.path.dirname(os.path.abspath(__file__))
CERT = os.path.join(HERE, "redir_cert.pem")
KEY = os.path.join(HERE, "redir_key.pem")
LOG = "/tmp/roster_server.log"
ADDR = ("127.0.0.1", 8081)
ADDR = (os.environ.get("OPENFUT_BIND", "127.0.0.1"), 8081)
# Minimal "no update available" roster body. Unknown-format -> iterate from the log.
ROSTER_XML = b'<?xml version="1.0" encoding="utf-8"?>\n<rosterupdate version="0"/>\n'
+6 -2
View File
@@ -105,14 +105,18 @@ def test_store_catalog():
for p in d.get("purchase", []):
check("pack has assetId (real identity)", "assetId" in p, repr(p.get("assetId")))
check("pack.packContentInfo is object", is_obj(p.get("packContentInfo")))
if not p.get("unopened"):
# The inactive zero-item sentinel keeps FIFA's hardcoded `mypacks`
# navigation destination resolvable when no owned packs remain. It is
# intentionally neither owned nor purchasable and therefore has no
# pricing. Validate prices only for active store packs.
if p.get("state") == "active" and not p.get("unopened"):
check("store pack currencies is array (coin price)",
is_arr(p.get("currencies")))
check("store pack extPrice is object", is_obj(p.get("extPrice")))
ep = p.get("extPrice", {})
check("store extPrice.finalPrice is object",
is_obj(ep.get("finalPrice")))
else:
elif p.get("unopened"):
check("owned pack omits purchase currencies", "currencies" not in p)
check("owned pack omits external purchase price", "extPrice" not in p)
+31 -1
View File
@@ -11,6 +11,8 @@ Guards the two things that would silently break the loop:
* `destroy_match_body()` drifting from FutDestroyMatchServerResponse
(deser 0x180121b60): a non-scalar there is the freeze class at 0x1801c7f1a,
and a renamed key is silently SKIP'd, i.e. the reward vanishes with no error.
* the shared base `/match` path distinguishing CREATEMATCH from PLAYGAME by
the body-level matchId that CardsDLL serializes for subsequent operations
Run: python3 tools/test_match_rewards.py (exit 0 = pass)
"""
@@ -152,9 +154,37 @@ def test_payout_table():
check("draw pays >= loss", U.MATCH_COINS["draw"] >= U.MATCH_COINS["loss"])
def test_match_call_classification():
"""CREATEMATCH and PLAYGAME share a path; only the latter has a matchId."""
cases = (
("/ut/game/fifa17/match", "POST", {}, "create"),
("/ut/game/fifa17/match", "POST", {"matchId": 1234}, "play"),
("/ut/game/fifa17/match/ready", "POST", {"matchId": 1234}, "ready"),
("/ut/game/fifa17/match/end", "POST", {"matchId": 1234}, "end"),
("/ut/game/fifa17/match/reset", "PUT", {"matchId": 1234}, "reset"),
("/ut/game/fifa17/match/keepalive", "POST", {"matchId": 1234}, "keepalive"),
)
for path, method, body, want in cases:
got = U._match_call(path, method, body)
check("%s %s -> %s" % (method, path, want), got == want, "got %s" % got)
def test_match_ready_body():
"""FutMatchReadyServerResponse parses these two scalar identifiers."""
body = U.match_ready_body(1234, 33068179)
check("ready echoes matchId", body.get("matchId") == 1234, repr(body))
check("ready has opponentPersonaId", body.get("opponentPersonaId") == 33068179,
repr(body))
check("ready IDs are scalar ints",
all(isinstance(v, int) and not isinstance(v, bool) for v in body.values()),
repr(body))
check("ready omits unproven nested items", "items" not in body, repr(body))
def main():
for t in (test_result_detection, test_reward_body,
test_end_reason_is_authoritative, test_payout_table):
test_end_reason_is_authoritative, test_payout_table,
test_match_call_classification, test_match_ready_body):
try:
t()
except Exception as e:
+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"))