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"))