Files
OpenFUT/openfut-adapter-fifa17/fixtures/generate.py
T
funman300 cf961603fe openfut-adapter-fifa17: FIFA 17 Blaze adapter, oracle-tested
The second migration step: the layer above the codec, deciding WHAT to say
rather than how to encode it. Sits on openfut-protocol-blaze and supplies
what that crate deliberately refuses to know.

  blaze/ids.rs           component/command/notification tables
  blaze/config.rs        injectable identity + endpoints, nothing hardcoded
  blaze/session.rs       per-connection state
  blaze/client_config.rs the fetchClientConfig tables
  blaze/responses.rs     16 Blaze::* response bodies
  blaze/dispatch.rs      (component, command) -> Vec<Frame>

Parity is tested, not asserted. fixtures/generate.py drives the real
blaze_responder_v3b.dispatch() and records 49 request->response(s)
transactions, replayed in order against a shared session per connection so
ordering-dependent behaviour is exercised: preAuth captures the locale later
ALOC fields echo, login sets the auth code getAuthToken returns. Comparison
is byte-for-byte including frame count and order.

98 tests green across both crates; clippy clean.

MUTATION TESTED, and it found a real defect in this commit's own design.
Swapping two post-login notifications and flipping one enum inside
AccountInfo both turned the suite red as intended. Hardcoding an address in
utas_base() did NOT -- the config templating substituted raw hosts directly,
making those helpers dead code that merely looked load-bearing. The table now
templates on URL-level tokens ({utas_base}, {nucleus_base},
{pow_content_url}) so they are the single place a URL shape is defined, and
the mutation is caught.

The client config table (227-243 rows per CFID) is generated from the oracle
rather than transcribed: it is reverse-engineered data, not logic, and 400
hand-copied string literals would add a typo class no reviewer can catch. The
generator substitutes real addresses back in and diffs against the oracle for
every section before writing, so the templating is verified rather than
assumed.

Reproduces one known defect deliberately: nucleusConnect is built from BIND,
not advertise, so the live split deployment tells a client on another machine
to reach Nucleus at http://0.0.0.0:42131. Confirmed against the running
container. Reproduced because it is what the only proven-working config does;
fixing it needs live validation and is a separate change. It also implies the
Nucleus stub is not reached in the current remote flow.

Blaze carries no FUT domain state -- no coins, packs, clubs or squads on this
wire -- so Session stays a session key, locale, service name, auth code and a
flag. That boundary will need defending when UTAS is migrated.

Not wired into anything. The crate answers frames; it opens no socket and
owns no runtime. The Python backend remains the live service and the oracle,
and is unmodified (contract suite still green).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 01:18:29 +00:00

461 lines
19 KiB
Python

#!/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-<key[:16]> 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()