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 PLATFORM = ACCOUNT.PLATFORM
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n" 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 REDIR_PORT = 42127
BLAZE_PORT = 42130 BLAZE_PORT = 42130
NUCLEUS_PORT = 42131 NUCLEUS_PORT = 42131
BLAZE_IP_STR = "127.0.0.1" BLAZE_IP_STR = _ADVERTISE
BLAZE_IP_U32 = (127 << 24) | 1 BLAZE_IP_U32 = _ip_str_to_u32(_ADVERTISE)
LOG = "/tmp/blaze_responder.log" LOG = "/tmp/blaze_responder.log"
RXDIR = "/tmp/blaze_rx" RXDIR = "/tmp/blaze_rx"
HERE = os.path.dirname(os.path.abspath(__file__)) 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. # (grid-blaze order) or after (pamplona order). Both are reported to work.
NOTIFY_BEFORE_LOGIN_REPLY = False 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() _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. # 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 # 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. # 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") POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
OSDK_ROSTER = [ OSDK_ROSTER = [
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST), ("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/" # /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 # (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). # (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 = [ FUT_RS4_MODULES = [
"AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD", "AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD",
"DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER", "DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER",
@@ -739,10 +779,12 @@ FUT_RS4_CONFIG = (
def client_config_for(cfid: str) -> list: def client_config_for(cfid: str) -> list:
"""-> sorted [(key, value)]. Unknown CFID -> [] (an EMPTY MAP, which we """Return sorted config rows for one section.
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 Unknown CFIDs still receive the shared FUT/content/POW rows because those
CardsDLL reads is unproven, so serve them everywhere).""" 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 # 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 # 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 # 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).""" has NO SVID, unlike Mirror's Edge Catalyst)."""
return OrderedDict([ return OrderedDict([
("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo ("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo
("PSA", (STRING, "127.0.0.1")), ("PSA", (STRING, _ADVERTISE)),
("PSP", (INT, 17502)), ("PSP", (INT, 17502)),
]))), ]))),
("LNP", (INT, 10)), ("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 client to have a well-formed config and then fail to connect quietly rather
than resolve a real EA hostname.""" than resolve a real EA hostname."""
tele = OrderedDict([ # GetTelemetryServerResponse (15) tele = OrderedDict([ # GetTelemetryServerResponse (15)
("ADRS", (STRING, "127.0.0.1")), ("ADRS", (STRING, _ADVERTISE)),
("ANON", (INT, 0)), ("ANON", (INT, 0)),
("DISA", (STRING, "")), ("DISA", (STRING, "")),
("EDCT", (INT, 0)), ("EDCT", (INT, 0)),
@@ -1117,7 +1159,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
("SVNM", (STRING, "telemetry-openfut")), ("SVNM", (STRING, "telemetry-openfut")),
]) ])
tick = OrderedDict([ # GetTickerServerResponse (3) tick = OrderedDict([ # GetTickerServerResponse (3)
("ADRS", (STRING, "127.0.0.1")), ("ADRS", (STRING, _ADVERTISE)),
("PORT", (INT, 8999)), ("PORT", (INT, 8999)),
("SKEY", (STRING, "")), ("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) log(" -- client locale 0x%08x captured for ALOC" % loc)
resp = preauth_response_fields(service_name=sess.service_name) resp = preauth_response_fields(service_name=sess.service_name)
payload = encode_tdf(resp) payload = encode_tdf(resp)
log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s" log(" -> PreAuthResponse (INST=%r, %d payload bytes)"
% (sess.service_name, len(payload), heat2.dump(resp))) % (sess.service_name, len(payload)))
if DUMP_FRAMES:
log(" -> PreAuthResponse TDF:\n%s" % heat2.dump(resp))
return [reply_to(hdr, payload)] return [reply_to(hdr, payload)]
if cmd == CMD_PING: 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]) n = len(resp["CONF"][1][2])
log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s" log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s"
% (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)")) % (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)"))
for k, v in resp["CONF"][1][2]: if DUMP_FRAMES:
log(" %-32s = %s" % (k, v)) for k, v in resp["CONF"][1][2]:
log(" %-32s = %s" % (k, v))
return [reply_to(hdr, encode_tdf(resp))] return [reply_to(hdr, encode_tdf(resp))]
if cmd == CMD_POSTAUTH: 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.auth_code = get_str(fields or {}, "AUTH", "")
sess.logged_in = True sess.logged_in = True
sess.login_time = int(time.time()) sess.login_time = int(time.time())
log(" == Authentication::login AUTH=%r (accepted WITHOUT Nucleus " log(" == Authentication::login AUTH=[REDACTED] "
"validation -- forged offline session)" % sess.auth_code) "(accepted as an offline OpenFUT session)")
resp = login_response_fields(sess) resp = login_response_fields(sess)
payload = encode_tdf(resp) payload = encode_tdf(resp)
log(" -> LoginResponse (%d bytes):\n%s" log(" -> LoginResponse (%d bytes)" % len(payload))
% (len(payload), heat2.dump(resp))) if DUMP_FRAMES:
log(" -> LoginResponse TDF:\n%s" % heat2.dump(resp))
notifs = build_login_notifications(sess, sess.login_time) notifs = build_login_notifications(sess, sess.login_time)
out = [] out = []
if NOTIFY_BEFORE_LOGIN_REPLY: if NOTIFY_BEFORE_LOGIN_REPLY:
@@ -1467,9 +1513,10 @@ _frame_counter = [0]
def blaze_handle(raw: socket.socket, addr) -> None: def blaze_handle(raw: socket.socket, addr) -> None:
refresh_account_identity()
log("*** BLAZE CONNECT from %s ***" % (addr,)) log("*** BLAZE CONNECT from %s ***" % (addr,))
sess = Session() sess = Session()
log(" session key minted: %s" % sess.session_key) log(" session key minted: [REDACTED]")
buf = bytearray() buf = bytearray()
raw.settimeout(300) raw.settimeout(300)
try: try:
@@ -1500,10 +1547,10 @@ def blaze_handle(raw: socket.socket, addr) -> None:
MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]), MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]),
hdr["msg_num"], hdr["user_index"], hdr["options"], hdr["msg_num"], hdr["user_index"], hdr["options"],
hdr["metadata_len"], hdr["payload_len"])) 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: if DUMP_FRAMES:
log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
if metadata:
log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
try: try:
os.makedirs(RXDIR, exist_ok=True) os.makedirs(RXDIR, exist_ok=True)
fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin" fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin"
@@ -1518,7 +1565,8 @@ def blaze_handle(raw: socket.socket, addr) -> None:
if payload: if payload:
try: try:
fields = decode_tdf(payload) 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: except Exception as e:
log("RX #%d TDF DECODE FAILED: %s" % (n, e)) log("RX #%d TDF DECODE FAILED: %s" % (n, e))
else: else:
@@ -1539,7 +1587,8 @@ def blaze_handle(raw: socket.socket, addr) -> None:
ohdr["msg_type"]), ohdr["msg_type"]),
MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]), MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]),
ohdr["msg_num"], len(out), ohdr["payload_len"])) 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: except ConnectionResetError:
log("BLAZE %s: connection reset by client" % (addr,)) log("BLAZE %s: connection reset by client" % (addr,))
except Exception as e: 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: # client can never reach accounts.ea.com. Note the exact spacing in the JSON:
# the client searches for the literal '"access_token" : "'. # 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: def nucleus_handle(raw: socket.socket, addr) -> None:
try: try:
raw.settimeout(10) raw.settimeout(10)
@@ -1649,9 +1702,9 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
head, _, rest = req.partition(b"\r\n\r\n") head, _, rest = req.partition(b"\r\n\r\n")
line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else "" line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else ""
log("NUCLEUS REQ %s: %s" % (addr, line0)) log("NUCLEUS REQ %s: %s" % (addr, line0))
if head: if head and DUMP_FRAMES:
log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace")) log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace"))
if rest: if rest and DUMP_FRAMES:
log("NUCLEUS BODY: %r" % rest[:512]) log("NUCLEUS BODY: %r" % rest[:512])
token = "OPENFUT_" + "".join( token = "OPENFUT_" + "".join(
@@ -1665,7 +1718,7 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
b"Cache-Control: no-store\r\nContent-Length: " b"Cache-Control: no-store\r\nContent-Length: "
+ str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body) + str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
raw.sendall(out) 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: except Exception as e:
log("NUCLEUS ERR %s: %s" % (addr, e)) log("NUCLEUS ERR %s: %s" % (addr, e))
finally: finally:
@@ -1717,6 +1770,10 @@ def _selftest() -> None:
sess.account_locale = 0x656E5553 sess.account_locale = 0x656E5553
now = 1469000000 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) # ---- 1. preAuth still round-trips (regression guard vs v2)
pre = preauth_response_fields() pre = preauth_response_fields()
p = _check_roundtrip("PreAuthResponse", pre) p = _check_roundtrip("PreAuthResponse", pre)
@@ -1740,9 +1797,11 @@ def _selftest() -> None:
assert items == client_config_for(cfid), cfid assert items == client_config_for(cfid), cfid
print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes" print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes"
% (cfid, len(items), len(pb))) % (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, \ 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 # ---- 3. LoginResponse
lr = login_response_fields(sess) lr = login_response_fields(sess)
+38 -1
View File
@@ -204,6 +204,7 @@ class Account:
def __init__(self, path=None): def __init__(self, path=None):
self.path = path or ACCOUNT_PATH self.path = path or ACCOUNT_PATH
self._loaded = False self._loaded = False
self._file_signature = None
self._stored = {} # what is on disk (tier 2+3 only) self._stored = {} # what is on disk (tier 2+3 only)
for f in _FIELDS: for f in _FIELDS:
setattr(self, "_" + f, None) setattr(self, "_" + f, None)
@@ -214,7 +215,8 @@ class Account:
save the first time. Never raises on a malformed file -- a broken save the first time. Never raises on a malformed file -- a broken
account file must not stop the harness booting.""" account file must not stop the harness booting."""
with _LOCK: with _LOCK:
if self._loaded and not force: signature = self._signature()
if self._loaded and not force and signature == self._file_signature:
return self return self
stored = {} stored = {}
if os.path.exists(self.path): if os.path.exists(self.path):
@@ -239,8 +241,22 @@ class Account:
% (self.path, e)) % (self.path, e))
self._stored = stored self._stored = stored
self._loaded = True self._loaded = True
self._file_signature = self._signature()
return self 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): def _migrate_from_profile(self):
"""Lift identity/club out of a pre-existing fifa17_profile.json so an """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 existing club name survives the move to this module. Read-only: the game
@@ -266,11 +282,32 @@ class Account:
return out return out
def _write(self): def _write(self):
parent = os.path.dirname(self.path)
if parent:
os.makedirs(parent, exist_ok=True)
tmp = self.path + ".tmp" tmp = self.path + ".tmp"
with open(tmp, "w") as f: with open(tmp, "w") as f:
json.dump(self._stored, f, indent=1, sort_keys=True) json.dump(self._stored, f, indent=1, sort_keys=True)
f.write("\n") f.write("\n")
os.replace(tmp, self.path) 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): def save(self):
"""Persist tiers 2+3 (only fields that differ from the built-in default, """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 import fut_cards
from fut_account import ACCOUNT # single source of truth for identity/club 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 ------------------ # ---- FUT_DISCARD_TABLE: the REAL FIFA 17 quick-sell values ------------------
# #
@@ -386,18 +398,51 @@ class Store:
p["clubName"] = ACCOUNT.club_name p["clubName"] = ACCOUNT.club_name
p["clubAbbr"] = ACCOUNT.club_abbr p["clubAbbr"] = ACCOUNT.club_abbr
p["established"] = ACCOUNT.established 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 return p
def _save(self): def _save(self):
parent = os.path.dirname(self.path)
if parent:
os.makedirs(parent, exist_ok=True)
tmp = self.path + ".tmp" tmp = self.path + ".tmp"
with open(tmp, "w") as f: with open(tmp, "w") as f:
json.dump(self._p, f, indent=1) json.dump(self._p, f, indent=1)
os.replace(tmp, self.path) 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 ------------------------------------- # ---- accessors used by utas_server -------------------------------------
def profile(self): def profile(self):
return self.load() 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): def refresh_identity(self):
"""Re-mirror ACCOUNT into the save AND persist it. """Re-mirror ACCOUNT into the save AND persist it.
+1 -1
View File
@@ -574,7 +574,7 @@ def serve(sock, addr):
def main(): def main():
s = socket.socket() s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 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) s.listen(8)
log("v2 listening on 127.0.0.1:4216 (start FIFA 17 now)") 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'}" 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") CERT = os.path.join(HERE, "redir_cert.pem")
KEY = os.path.join(HERE, "redir_key.pem") KEY = os.path.join(HERE, "redir_key.pem")
LOG = "/tmp/roster_server.log" 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. # 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' 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", []): for p in d.get("purchase", []):
check("pack has assetId (real identity)", "assetId" in p, repr(p.get("assetId"))) check("pack has assetId (real identity)", "assetId" in p, repr(p.get("assetId")))
check("pack.packContentInfo is object", is_obj(p.get("packContentInfo"))) 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)", check("store pack currencies is array (coin price)",
is_arr(p.get("currencies"))) is_arr(p.get("currencies")))
check("store pack extPrice is object", is_obj(p.get("extPrice"))) check("store pack extPrice is object", is_obj(p.get("extPrice")))
ep = p.get("extPrice", {}) ep = p.get("extPrice", {})
check("store extPrice.finalPrice is object", check("store extPrice.finalPrice is object",
is_obj(ep.get("finalPrice"))) is_obj(ep.get("finalPrice")))
else: elif p.get("unopened"):
check("owned pack omits purchase currencies", "currencies" not in p) check("owned pack omits purchase currencies", "currencies" not in p)
check("owned pack omits external purchase price", "extPrice" 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 * `destroy_match_body()` drifting from FutDestroyMatchServerResponse
(deser 0x180121b60): a non-scalar there is the freeze class at 0x1801c7f1a, (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. 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) 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"]) 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(): def main():
for t in (test_result_detection, test_reward_body, 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: try:
t() t()
except Exception as e: 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. * [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 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__))) 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) 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_cards
import fut_staff import fut_staff
from fut_account import ACCOUNT, validate_club # identity + club, single source 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 # 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 # 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; # 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 # 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. # 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") LOG = os.environ.get("FUT_LOG", "/tmp/utas_server.log")
SID = "OPENFUT-SID-0000000000000001" SID = "OPENFUT-SID-0000000000000001"
# IDENTITY NOTE: there are no PERSONA_ID / PERSONA_NAME literals in this file any # 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") 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 ------------------------------------------------------------- # ---- payloads -------------------------------------------------------------
def auth_body(h=None): def auth_body(h=None):
"""POST ut/auth. """POST ut/auth.
@@ -104,6 +196,19 @@ def auth_body(h=None):
return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()} 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(): def current_squad():
"""The squad the client should see: the persisted one (item refs re-embedded """The squad the client should see: the persisted one (item refs re-embedded
from the club) or the seed ladder squad on first run. from the club) or the seed ladder squad on first run.
@@ -1095,6 +1200,9 @@ def item_route(h):
G = r"/ut/game/[^/]+" G = r"/ut/game/[^/]+"
ROUTES = [ 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) ---- # ---- 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"/item/resource"), lambda m, h: defs_route(h)),
(re.compile(G + r"/defid"), 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/auth"), lambda m, h: (200, auth_body(h))),
(re.compile(r"^/ut/delete/auth"), lambda m, h: (200, {})), (re.compile(r"^/ut/delete/auth"), lambda m, h: (200, {})),
(re.compile(G + r"/settings"), lambda m, h: (200, SETTINGS)), (re.compile(G + r"/settings"), lambda m, h: (200, SETTINGS)),
# Device-trust ("phishing") flow. trusteddevice parser 0x18012a170 reads 4 # Device-trust ("phishing") flow. One handler owns its exact state machine,
# booleans by key-id 0x7e/0x117/0x19e/0x351; 0x351 == JSON key "trusted". # validation, persistence and redacted diagnostics; keep these above /user.
# Returning trusted=true makes FUT SKIP the security question. (re.compile(G + r"/phishing/(trusteddevice|validate|question)"),
(re.compile(G + r"/phishing/trusteddevice"), lambda m, h: (200, {"trusted": True})), lambda m, h: security_question_route(h)),
(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)), (re.compile(G + r"/user/credits"), lambda m, h: credits_route(h)),
# ---- club/squad routes (2026-08-03: squad schema 0x18013d1f0 now reversed) ---- # ---- club/squad routes (2026-08-03: squad schema 0x18013d1f0 now reversed) ----
# /user, /squad and /userMassInfo serve real data again -- the squad object # /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)]) players = len([i for i in STORE.items() if _is_player(i)])
auctions = len(STORE.listings()) auctions = len(STORE.listings())
log(" HUB: clubPlayers=%d auctionCount=%d selling=%d" % (players, auctions, auctions)) 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}} "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 ------ # ---- 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. """ut/%s/clientdata/<key> -- opaque client blob storage.
LIVE-OBSERVED: `PUT ut/game/fifa17/clientdata/userHubData` fires from the FUT 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 hub. Persist the client-owned blob so its matching GET can restore it. The PUT
server behaviour is to keep the blob and hand back exactly what was given, which acknowledgement remains the historical empty object: echoing the body was
is zero-risk by construction: we never synthesise a shape, we echo the client's exercised live with both observed values ([3,0] and [3,1]) and did not unlock
own bytes. Persisting it is also the most plausible route to the hub's offline Seasons or produce a subsequent /season request. No response schema has
"MANAGER TASKS 0/0" tile surviving a relaunch, since no FutGetObjectives class been recovered for SetTutData, so do not infer one from the request shape.
exists in the binary at all (§9) -- the tile state may simply live in this blob.
""" """
key = h.path.split("/clientdata/", 1)[-1].split("?")[0] or "default" key = h.path.split("/clientdata/", 1)[-1].split("?")[0] or "default"
if h.command in ("PUT", "POST"): if h.command in ("PUT", "POST"):
@@ -2283,10 +2403,17 @@ def season_user():
def tournament_list(): def tournament_list():
"""GET ut/%s/tournament -- FutTournamentList, deser 0x180169ef0 (MEDIUM). """GET ut/%s/tournament -- object wrapper parsed at 0x18016b220 (HIGH).
ARRAY root; rounds/prizeSet/staff/kit atoms are nested FREEZE-RISK -> omitted."""
return [{"id": 1, "difficulty": 1, "coins": 500, "rewardMultiplier": 1, The response parser recognizes only tournament(0x328), opens its ARRAY, then
"assetName": "", "eligibilityOperation": ""}] 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(): def tournament_user():
@@ -2989,14 +3116,46 @@ def destroy_match_body(result, coins, total):
return body 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): def match_route(h):
"""POST create / PUT ready / POST play / DELETE destroy(+rewards).""" """Create / ready / play / destroy(+rewards) on CardsDLL's match paths."""
try: try:
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {} body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
except Exception: except Exception:
body = {} body = {}
m = re.search(r"/match/(\d+)", h.path) 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 # 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 # index 16 = `ut/%s/match`, each appending a fixed suffix via the params object
# at slot +0x08): CREATEMATCH and PLAYGAME append nothing, MATCHREADY `/ready`, # 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 # 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 # 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. # rather than known-wrong, so this widens the gate instead of replacing it.
is_delete = (h.command == "DELETE" or "/ut/delete/" in h.path call = _match_call(h.path, h.command, body)
or (MATCH_END and h.path.split("?")[0].endswith("/match/end"))) 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. # FutDestroyMatch -- the ONLY place a match awards anything.
result, score = _match_result(body) result, score = _match_result(body)
coins = MATCH_COINS.get(result, 0) + MATCH_PARTICIPATION coins = MATCH_COINS.get(result, 0) + MATCH_PARTICIPATION
@@ -3026,15 +3186,24 @@ def match_route(h):
rec["won"], rec["draw"], rec["loss"])) rec["won"], rec["draw"], rec["loss"]))
return 200, destroy_match_body(result, coins, total) 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). # FutCreateMatch. `squad` is nested + freeze-risky -> omitted (SKIP-safe).
mid = STORE.new_item_id() mid = STORE.new_item_id()
log(" MATCH: created id=%d" % mid) log(" MATCH: created id=%d" % mid)
return 200, {"startDateTime": int(datetime.datetime.now().timestamp()), return 200, {"startDateTime": int(datetime.datetime.now().timestamp()),
"reportIdEnabled": False, "id": mid} "reportIdEnabled": False, "id": mid}
# PUT {id} = MatchReady, POST {id} = PlayGame. Both have NO deserializer at if call == "ready":
# all, so {} is a complete response; the result is claimed on destroy. # 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: if body:
log(" MATCH: %s %s body=%s" % (h.command, h.path, json.dumps(body)[:400])) log(" MATCH: %s %s body=%s" % (h.command, h.path, json.dumps(body)[:400]))
return 200, {} return 200, {}
@@ -3328,13 +3497,18 @@ def purchased_items(h):
if pack.get("ownedOnly") and not STORE.consume_unopened_pack(pid): if pack.get("ownedOnly") and not STORE.consume_unopened_pack(pid):
log(" STORE: rejected unopened pack %s; no owned instance" % pid) log(" STORE: rejected unopened pack %s; no owned instance" % pid)
return 200, {"itemData": STORE.last_pack()} 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"], items = STORE.open_pack(pack["price"], pack["count"], pack["gold"],
pack.get("tiers"), pack.get("specialChance", 0.0), pack.get("tiers"), pack.get("specialChance", 0.0),
pack.get("playersOnly", False)) pack.get("playersOnly", False))
if items is None: if items is None:
return 461, {"reason": "insufficient_coins", "credits": STORE.coins()} 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" log(" STORE: POST /purchased opened pack %s -> %d items, coins=%d"
% (pack["name"], len(items), STORE.coins())) % (pack["name"], len(items), STORE.coins()))
if PACK_AUTOCLUB: if PACK_AUTOCLUB:
@@ -3555,9 +3729,9 @@ class H(http.server.BaseHTTPRequestHandler):
n = int(self.headers.get("Content-Length", 0) or 0) n = int(self.headers.get("Content-Length", 0) or 0)
body = self.rfile.read(n) if n else b"" body = self.rfile.read(n) if n else b""
self._body = body # route fns (squad PUT) read this 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(): for k, v in self.headers.items():
log(" %s: %s" % (k, v)) log(" %s: %s" % (k, safe_header_for_log(k, v)))
if body: if body:
log(" body: %s" % body[:65536].decode("utf-8", "replace")) log(" body: %s" % body[:65536].decode("utf-8", "replace"))