#!/usr/bin/env python3 """Freeze the Python Blaze responder's DISPATCH contract as replayable fixtures. The crate-level fixtures in `openfut-protocol-blaze` pin the *codec*: given a field tree, what bytes come out. This file pins the layer above: given an inbound Fire2 frame and a session, **which frames go back, in what order**. That is the whole contract of a Blaze adapter, and it is the thing a rewrite can silently get wrong in ways a codec test cannot see — a missing post-login notification, a reply where the oracle stays silent, notifications in the wrong order, session state not carried between RPCs. Every transaction is produced by calling the real `blaze_responder_v3b.dispatch()`. Session state is threaded across a scripted connection exactly as it would be on a live socket, so ordering-dependent behaviour (preAuth captures the locale; login sets the auth code that getAuthToken later returns) is captured rather than assumed. Determinism: the oracle's clock is pinned and its PRNG seeded, and the deployment-dependent addresses are set before import (the responder reads them at import time). See the sibling generator in openfut-protocol-blaze. NO SECRETS. The identity here (persona 33068179 / "CAGE") is the project's fixed synthetic offline identity. Session keys are minted from a seeded PRNG. Usage: python3 fixtures/generate.py (write) python3 fixtures/generate.py --check (verify committed files are current) """ from __future__ import annotations import json import os import random import sys from collections import OrderedDict HERE = os.path.dirname(os.path.abspath(__file__)) TOOLS = os.path.normpath(os.path.join(HERE, "..", "..", "fifa17-recon", "tools")) if not os.path.isdir(TOOLS): sys.exit("cannot find the Python oracle at %s" % TOOLS) sys.path.insert(0, TOOLS) CHECK_ONLY = "--check" in sys.argv[1:] # Internal mode: re-exec of this script with sentinel addresses, used to derive # the templated client-config table (see emit_config_table). CONFIG_TABLE_MODE = "--_config_table" in sys.argv[1:] sys.argv = [sys.argv[0]] # Sentinels substituted back into template tokens. Deliberately not IP-shaped so # a stray literal cannot be mistaken for a real address. SENTINELS = [ ("ADVERTISE-SENTINEL", "{advertise}"), ("BIND-SENTINEL", "{bind}"), ("POWCONTENT-SENTINEL", "{pow_content_host}"), ("POWHOST-SENTINEL", "{pow_host}"), ] if CONFIG_TABLE_MODE: os.environ["OPENFUT_ADVERTISE"] = "ADVERTISE-SENTINEL" os.environ["OPENFUT_BIND"] = "BIND-SENTINEL" os.environ["POW_CONTENT_HOST"] = "POWCONTENT-SENTINEL" os.environ["POW_HOST"] = "POWHOST-SENTINEL" import blaze_responder_v3b as _B # noqa: E402 out = {cfid: _B.client_config_for(cfid) for cfid in sorted(_B.CLIENT_CONFIGS)} out["__default__"] = _B.client_config_for("__no_such_section__") print(json.dumps(out)) raise SystemExit(0) # Pin deployment config BEFORE import — the responder snapshots these at import # time into module globals used by the response builders. # # Distinct, obviously-fake values on purpose: if the Rust adapter hardcoded an # address instead of reading its config, these make the failure loud rather than # accidentally matching a loopback default. ADVERTISE = "198.51.100.7" BIND = "0.0.0.0" POW_CONTENT_HOST = "198.51.100.7:8085" POW_HOST = "198.51.100.7:8094" os.environ["OPENFUT_ADVERTISE"] = ADVERTISE os.environ["OPENFUT_BIND"] = BIND os.environ["POW_CONTENT_HOST"] = POW_CONTENT_HOST os.environ["POW_HOST"] = POW_HOST import heat2 # noqa: E402 import blaze_responder_v3b as B # noqa: E402 from fut_account import ACCOUNT # noqa: E402 FIXED_NOW = 1754870400 INT, STRING, STRUCT, LIST, MAP, BLOB = ( heat2.INT, heat2.STRING, heat2.STRUCT, heat2.LIST, heat2.MAP, heat2.BLOB) RECORDS = [] # ------------------------------------------------------------------ helpers def req_frame(component, command, fields=None, msg_num=1, msg_type=None, user_index=0): """Build an inbound request frame the way the client would.""" msg_type = B.MESSAGE if msg_type is None else msg_type payload = heat2.encode_tdf(fields) if fields else b"" return B.fire2(component, command, msg_num, msg_type, payload, user_index=user_index) def tx(session, name, frame, note=""): """Run one frame through the real dispatcher and record what came back.""" hdr = B.parse_fire2_header(frame) body = frame[16 + hdr["metadata_len"]:] fields = heat2.decode_tdf(body) if body else OrderedDict() out = B.dispatch(hdr, fields, body, session["sess"]) RECORDS.append(OrderedDict(( ("kind", "tx"), ("session", session["id"]), ("name", name), ("note", note), ("request_hex", frame.hex()), ("responses", [f.hex() for f in out]), ))) return out def new_session(sid): s = {"id": sid, "sess": B.Session()} RECORDS.append(OrderedDict(( ("kind", "session"), ("id", sid), # Minted per connection by the oracle; the Rust side must be able to # inject it, because it appears in LoginResponse.SESS.KEY, the # UserAuthenticated push and PostAuthResponse.TELE.SESS and all three # must be the same string. ("session_key", s["sess"].session_key), ("account_locale", s["sess"].account_locale), ("service_name", s["sess"].service_name), ))) return s # ------------------------------------------------------------------ script def build(): RECORDS.append(OrderedDict(( ("kind", "config"), ("advertise", ADVERTISE), ("bind", BIND), ("pow_content_host", POW_CONTENT_HOST), ("pow_host", POW_HOST), ("now", FIXED_NOW), ("identity", OrderedDict(( ("persona_id", ACCOUNT.persona_id), ("persona_name", ACCOUNT.persona_name), ("user_id", ACCOUNT.user_id), ("ext_id", ACCOUNT.ext_id), ("email", ACCOUNT.email), ("namespace", ACCOUNT.NAMESPACE), ("client_platform", ACCOUNT.CLIENT_PLATFORM), ("persona_status", ACCOUNT.PERSONA_STATUS), ("user_session_type", ACCOUNT.USER_SESSION_TYPE), ("account_locale_int", ACCOUNT.account_locale_int), ("locale", ACCOUNT.locale), ("content_id", ACCOUNT.CONTENT_ID), ("entitlement_tag", ACCOUNT.ENTITLEMENT_TAG), ("entitlement_group", ACCOUNT.ENTITLEMENT_GROUP), ("title_id", ACCOUNT.TITLE_ID), ("client_id", ACCOUNT.CLIENT_ID), ("platform", ACCOUNT.PLATFORM), ("server_version", B.SERVER_VERSION), ))), ))) # ================= main connection: the real boot order ================= # # Mirrors what FIFA 17 actually does, because ordering is load-bearing: # preAuth captures the locale that later ALOC fields echo, and login sets # the auth code that getAuthToken returns afterwards. m = new_session("main") tx(m, "preauth", req_frame(B.COMP_UTIL, B.CMD_PREAUTH, OrderedDict([ ("CDAT", (STRUCT, OrderedDict([ ("IITO", (INT, 0)), ("LANG", (INT, 0x656E5553)), # 'enUS' ("SVCN", (STRING, "fifa-2017-pc")), # echoed back as INST ("TYPE", (INT, 0)), ]))), ("CINF", (STRUCT, OrderedDict([ ("BSDK", (STRING, "15.1.1.3.0")), ("CLNT", (STRING, "FIFA17")), ("ENV", (STRING, "prod")), ("LOC", (INT, 0x656E5553)), ]))), ("FCCR", (STRUCT, OrderedDict([("CFID", (STRING, "BlazeSDK"))]))), ])), "first RPC; echoes SVCN as INST and captures LANG for ALOC") tx(m, "ping", req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=2), "Util::ping -> STIM only") # Every section the responder knows, plus unknown ones. The known sections # each add their own rows on top of the shared FUT/RS4 base — OSDK_ROSTER in # particular carries the roster URL, itself a documented loading gate — so # covering only "BlazeSDK" would leave most of the table unverified. for cfid in ("BlazeSDK", "netres", "IdentityParams", "OSDK_CORE", "OSDK_CLIENT", "OSDK_NUCLEUS", "OSDK_ROSTER", "OSDK_TICKER", "OSDK_WEBOFFER", "OSDK_POW", "OSDK_ABUSE_REPORTING", "OSDK_XMS_ABUSE_REPORTING", "UTAS", "FUT", "", "TOTALLY_UNKNOWN"): tx(m, "fetch_config_%s" % (cfid or "empty"), req_frame(B.COMP_UTIL, B.CMD_FETCHCLIENTCONFIG, OrderedDict([("CFID", (STRING, cfid))]), msg_num=3), "unknown CFIDs still get the shared FUT/POW rows") tx(m, "get_auth_token_before_login", req_frame(B.COMP_AUTH, B.CMD_GETAUTHTOKEN, msg_num=4), "no auth code yet -> synthesised OPENFUT- token") tx(m, "logout_before_login", req_frame(B.COMP_AUTH, B.CMD_LOGOUT, msg_num=5), "routine LoginStateLogout (state 500), NOT a failure; empty reply") tx(m, "login", req_frame(B.COMP_AUTH, B.CMD_LOGIN, OrderedDict([ ("AUTH", (STRING, "OPENFUT-TEST-AUTHCODE")), ("EXTB", (BLOB, b"")), ("PNAM", (STRING, "")), ]), msg_num=6), "reply THEN three UserSessions pushes, in that order") tx(m, "get_auth_token_after_login", req_frame(B.COMP_AUTH, B.CMD_GETAUTHTOKEN, msg_num=7), "now echoes the login's AUTH verbatim") tx(m, "get_account", req_frame(B.COMP_AUTH, B.CMD_GETACCOUNT, msg_num=8), "the RPC behind 'Unable to retrieve account information'") tx(m, "get_persona", req_frame(B.COMP_AUTH, B.CMD_GETPERSONA, msg_num=9)) tx(m, "list_personas", req_frame(B.COMP_AUTH, B.CMD_LISTPERSONAS, msg_num=10)) for cmd, label in ((B.CMD_LISTUSERENTITLEMENTS2, "listUserEntitlements2"), (0x20, "listEntitlements"), (0x30, "listPersonaEntitlements2"), (0x27, "grantEntitlement2")): tx(m, "entitlements_%s" % label, req_frame(B.COMP_AUTH, cmd, msg_num=11), "all four aliases return the same two ONLINE_ACCESS records") tx(m, "post_auth", req_frame(B.COMP_UTIL, B.CMD_POSTAUTH, msg_num=12), "TELE/TICK/UROP; TELE.SESS must equal the login session key") tx(m, "fetch_qos_config", req_frame(B.COMP_UTIL, 0x15, msg_num=13)) tx(m, "user_settings_load", req_frame(B.COMP_UTIL, B.CMD_USERSETTINGSLOAD, msg_num=14)) tx(m, "user_settings_save", req_frame(B.COMP_UTIL, B.CMD_USERSETTINGSSAVE, msg_num=15), "accepted and discarded; empty reply") tx(m, "set_client_state", req_frame(B.COMP_UTIL, B.CMD_SETCLIENTSTATE, msg_num=16)) tx(m, "set_client_metrics", req_frame(B.COMP_UTIL, B.CMD_SETCLIENTMETRICS, msg_num=17)) tx(m, "update_network_info", req_frame(B.COMP_USERSESSIONS, B.CMD_UPDATENETWORKINFO, msg_num=18), "empty reply PLUS an unsolicited ExtendedDataUpdate push") tx(m, "get_lists", req_frame(B.COMP_ASSOCLISTS, B.CMD_GETLISTS, msg_num=19)) tx(m, "census_subscribe", req_frame(B.COMP_CENSUSDATA, B.CMD_SUBSCRIBETOCENSUSDATAUPDATES, OrderedDict([("RSUB", (INT, 1))]), msg_num=20), "non-zero TimeValues or the client storms at ~30/s and hangs the FUT load") tx(m, "logout_after_login", req_frame(B.COMP_AUTH, B.CMD_LOGOUT, msg_num=21), "session teardown after a login; still an empty reply") # ============================ fallback behaviour ======================== f = new_session("fallbacks") tx(f, "transport_ping", req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=30, msg_type=B.PING), "msgType PING -> PING_REPLY with an empty body, whatever the command") for mt, label in ((B.REPLY, "reply"), (B.NOTIFICATION, "notification"), (B.ERROR_REPLY, "error_reply"), (B.PING_REPLY, "ping_reply")): tx(f, "ignores_%s" % label, req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=31, msg_type=mt), "not a request -> NO frames at all") tx(f, "unknown_command", req_frame(B.COMP_UTIL, 0x0FFF, msg_num=32), "unimplemented RPC still gets an EMPTY reply so the client cannot hang") tx(f, "unknown_component", req_frame(0x1234, 0x0001, msg_num=33), "same fallback for an entirely unknown component") tx(f, "user_index_is_echoed", req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=34, user_index=7), "a reply echoes component/command/msgNum/userIndex verbatim") # ================= locale echo on a non-default client ================== loc = new_session("locale") tx(loc, "preauth_de_locale", req_frame(B.COMP_UTIL, B.CMD_PREAUTH, OrderedDict([ ("CDAT", (STRUCT, OrderedDict([ ("LANG", (INT, 0x64654445)), # 'deDE' ("SVCN", (STRING, "fifa-2017-pc-de")), ]))), ])), "a non-enUS client: SVCN echo AND the captured locale must both change") tx(loc, "login_with_de_locale", req_frame(B.COMP_AUTH, B.CMD_LOGIN, msg_num=41), "UserAuthenticated.ALOC must carry the captured deDE locale") # ------------------------------------------------------------------- output def emit_config_table(): """Derive the fetchClientConfig tables as address-TEMPLATED data. These are 227-243 key/value rows per CFID, almost all of them the same URL. Hand-transcribing them into Rust would be 400 lines of string literals that nobody can review and one typo can break; deriving them mechanically from the oracle removes that whole class of error and keeps them regenerable. They are reverse-engineered *data*, not logic — the same reason `openfut-core` loads its content from `data/` rather than from source. The values are templated on {advertise}/{bind}/{pow_content_host}/{pow_host} so the adapter stays configurable; baking an address in here would recreate exactly the hardcoding the client/server split removed. Correctness is not assumed: the caller substitutes real addresses back in and diffs against the oracle. See verify_config_table. """ import subprocess raw = subprocess.run( [sys.executable, os.path.abspath(__file__), "--_config_table"], capture_output=True, text=True, check=True, # Inherit nothing address-shaped; the child sets its own sentinels. env={k: v for k, v in os.environ.items() if not k.startswith(("OPENFUT_", "POW_", "FUT_"))}, ).stdout table = json.loads(raw) def templatise(value): for sentinel, token in SENTINELS: value = value.replace(sentinel, token) # Collapse whole URLs to URL-level tokens where one exists, so the Rust # side builds them in exactly one place (AdapterConfig::utas_base and # friends) instead of re-deriving the shape here. Without this the # helpers become dead code and a hardcoded address in them goes # undetected — verified by mutation testing. Longest first. for whole, token in ( ("http://{advertise}:8099/", "{utas_base}"), ("http://{bind}:42131", "{nucleus_base}"), ("http://{pow_content_host}", "{pow_content_url}"), ): if value == whole: return token return value return {cfid: [[k, templatise(v)] for k, v in rows] for cfid, rows in table.items()} def verify_config_table(table): """Substitute the real addresses back and require the oracle's exact rows. This is what makes the templated table trustworthy rather than plausible. """ subst = { "{utas_base}": "http://%s:8099/" % ADVERTISE, "{nucleus_base}": "http://%s:42131" % BIND, "{pow_content_url}": "http://%s" % POW_CONTENT_HOST, "{advertise}": ADVERTISE, "{bind}": BIND, "{pow_content_host}": POW_CONTENT_HOST, "{pow_host}": POW_HOST, } def render(v): for token, real in subst.items(): v = v.replace(token, real) return v for cfid, rows in table.items(): expected = B.client_config_for( "__no_such_section__" if cfid == "__default__" else cfid) got = [(k, render(v)) for k, v in rows] if got != [(k, v) for k, v in expected]: for (gk, gv), (ek, ev) in zip(got, expected): if (gk, gv) != (ek, ev): sys.exit("config template mismatch in %s: %r -> %r, oracle " "has %r -> %r" % (cfid, gk, gv, ek, ev)) sys.exit("config template row-count mismatch in %s: %d vs %d" % (cfid, len(got), len(expected))) print("config table verified against the oracle for %d sections" % len(table)) def frozen_clock(): import time as _time original = _time.time _time.time = lambda: float(FIXED_NOW) return original, _time def write(path, records): body = "".join(json.dumps(r, separators=(",", ":")) + "\n" for r in records) if CHECK_ONLY: if not os.path.exists(path): sys.exit("MISSING: %s has never been generated" % path) with open(path, "r", encoding="utf-8") as fh: if fh.read() != body: sys.exit("STALE: %s does not match the oracle; re-run without " "--check" % path) print("current: %s (%d records)" % (os.path.basename(path), len(records))) return with open(path, "w", encoding="utf-8") as fh: fh.write(body) print("wrote %s (%d records)" % (os.path.basename(path), len(records))) def write_json(path, obj): body = json.dumps(obj, indent=1, sort_keys=True) + "\n" if CHECK_ONLY: if not os.path.exists(path): sys.exit("MISSING: %s has never been generated" % path) with open(path, "r", encoding="utf-8") as fh: if fh.read() != body: sys.exit("STALE: %s does not match the oracle" % path) print("current: %s" % os.path.basename(path)) return with open(path, "w", encoding="utf-8") as fh: fh.write(body) print("wrote %s (%d sections)" % (os.path.basename(path), len(obj))) def main(): # The oracle logs every dispatch to stdout; useful live, pure noise here. B.log = lambda *_a, **_k: None table = emit_config_table() verify_config_table(table) write_json(os.path.join(HERE, "client_config.json"), table) random.seed(0xB1A2E) original_time, time_mod = frozen_clock() try: build() finally: time_mod.time = original_time write(os.path.join(HERE, "blaze_transactions.jsonl"), RECORDS) txs = [r for r in RECORDS if r["kind"] == "tx"] frames = sum(len(r["responses"]) for r in txs) print("%d transactions, %d response frames, %d sessions" % (len(txs), frames, len([r for r in RECORDS if r["kind"] == "session"]))) if __name__ == "__main__": main()