diff --git a/fifa17-recon/tools/blaze_responder_v3b.py b/fifa17-recon/tools/blaze_responder_v3b.py
index 610a6f0..65d9f74 100644
--- a/fifa17-recon/tools/blaze_responder_v3b.py
+++ b/fifa17-recon/tools/blaze_responder_v3b.py
@@ -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)
diff --git a/fifa17-recon/tools/fut_account.py b/fifa17-recon/tools/fut_account.py
index 0549667..67daef7 100644
--- a/fifa17-recon/tools/fut_account.py
+++ b/fifa17-recon/tools/fut_account.py
@@ -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,
diff --git a/fifa17-recon/tools/fut_store.py b/fifa17-recon/tools/fut_store.py
index 5437603..1a1c8d2 100644
--- a/fifa17-recon/tools/fut_store.py
+++ b/fifa17-recon/tools/fut_store.py
@@ -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.
diff --git a/fifa17-recon/tools/lsx_responder_v2.py b/fifa17-recon/tools/lsx_responder_v2.py
index 1e8be4f..addd83d 100755
--- a/fifa17-recon/tools/lsx_responder_v2.py
+++ b/fifa17-recon/tools/lsx_responder_v2.py
@@ -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'}"
diff --git a/fifa17-recon/tools/roster_server.py b/fifa17-recon/tools/roster_server.py
index 4e33902..9d446cd 100644
--- a/fifa17-recon/tools/roster_server.py
+++ b/fifa17-recon/tools/roster_server.py
@@ -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'\n\n'
diff --git a/fifa17-recon/tools/test_fut_contract.py b/fifa17-recon/tools/test_fut_contract.py
index ce8a050..f10d925 100644
--- a/fifa17-recon/tools/test_fut_contract.py
+++ b/fifa17-recon/tools/test_fut_contract.py
@@ -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)
diff --git a/fifa17-recon/tools/test_match_rewards.py b/fifa17-recon/tools/test_match_rewards.py
index aa7d368..7d899d7 100644
--- a/fifa17-recon/tools/test_match_rewards.py
+++ b/fifa17-recon/tools/test_match_rewards.py
@@ -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:
diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py
index 163298c..9140588 100755
--- a/fifa17-recon/tools/utas_server.py
+++ b/fifa17-recon/tools/utas_server.py
@@ -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/ -- 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"))