#!/usr/bin/env python3 """Generate protocol fixtures from the PROVEN Python FIFA 17 backend. WHY THIS EXISTS --------------- The Python backend in `fifa17-recon/tools/` is the behavioural oracle: it is the implementation that actually walked a retail FIFA 17 client from Origin login to an opened FUT pack. Nothing in Rust may claim parity with it by assertion; parity has to be *replayable*. The `captures/` directory that earlier findings cite is gitignored (`captures/`, `*.bin`), so the original wire captures are not recoverable from Git. The next best oracle is the Python code that produced the bytes the client accepted: `heat2.encode_tdf` (Heat2/TDF codec) and `blaze_responder_v3b.fire2` (Fire2 framing). This script freezes their output into committed, machine-testable vectors so the Rust port can be diffed byte-for-byte, forever, without a running FIFA client. WHAT IS AND IS NOT AN ORACLE HERE --------------------------------- * "live" vectors are built from the responder's real payload builders — the exact field trees FIFA 17 accepted in the working run. Highest confidence. * "synthetic" vectors exercise codec edge cases (varint boundaries, tag padding, empty string, nesting, every type byte). They prove the Rust codec matches the Python codec; they do NOT prove either matches EA, because the complex types (list/map/union/varlist/objtype/objid/float) are flagged UNVERIFIED in heat2.py's own docstring. Vectors carry that flag through so a future reader cannot mistake self-consistency for wire truth. NO SECRETS. The FIFA 17 identity in these fixtures (persona 33068179 / "CAGE") is the project's fixed synthetic offline identity, not a credential. Session keys are generated from a seeded PRNG below so output is deterministic and carries no entropy from any real session. Usage: python3 fixtures/generate.py (writes tdf.jsonl + fire2.jsonl) 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) # Read our own flags BEFORE clearing argv. The responders inspect sys.argv at # import time (blaze_responder_v3b honours --selftest), so it has to be emptied # for them — but doing that first would silently swallow --check and turn the # staleness guard into a no-op that always rewrites and always passes. CHECK_ONLY = "--check" in sys.argv[1:] sys.argv = [sys.argv[0]] # Pin the deployment-dependent config BEFORE importing the responder, which # reads these at import time. Several live payloads embed the advertised # address (Blaze redirect target, RS4/POW/roster URLs), so without this the # fixture bytes would depend on whoever's shell ran the generator and `--check` # would go red on a machine that happens to export OPENFUT_ADVERTISE. # # 127.0.0.1 is a placeholder chosen so no real LAN address is ever baked into a # committed fixture. It is NOT a statement about how the backend is deployed — # remote mode requires an explicit advertised address and has no loopback # fallback (see the vault's Architecture note on OPENFUT_BIND vs # OPENFUT_ADVERTISE). os.environ["OPENFUT_ADVERTISE"] = "127.0.0.1" os.environ["OPENFUT_BIND"] = "127.0.0.1" import heat2 # noqa: E402 import blaze_responder_v3b as B # noqa: E402 TYPE_NAME = { heat2.INT: "int", heat2.STRING: "string", heat2.BLOB: "blob", heat2.STRUCT: "struct", heat2.LIST: "list", heat2.MAP: "map", heat2.UNION: "union", heat2.VARLIST: "varlist", heat2.OBJTYPE: "objtype", heat2.OBJID: "objid", heat2.FLOAT: "float", } # ---------------------------------------------------------------- value model # # A TDF value is serialised to JSON as {"t": , ...}. The shape per # type mirrors heat2.py's Python representation exactly so the Rust side can # rebuild an identical tree and re-encode it. def val_to_json(typ: int, value): t = TYPE_NAME[typ] if typ == heat2.INT: return {"t": t, "v": int(value)} if typ == heat2.STRING: s = value.decode("utf-8") if isinstance(value, (bytes, bytearray)) else value return {"t": t, "v": s} if typ == heat2.BLOB: return {"t": t, "v": bytes(value).hex()} if typ == heat2.STRUCT: return {"t": t, "v": fields_to_json(value)} if typ == heat2.LIST: etype, items = value return {"t": t, "elem": TYPE_NAME[etype], "v": [val_to_json(etype, it) for it in items]} if typ == heat2.MAP: ktype, vtype, items = value return {"t": t, "key": TYPE_NAME[ktype], "val": TYPE_NAME[vtype], "v": [[val_to_json(ktype, k), val_to_json(vtype, v)] for k, v in items]} if typ == heat2.UNION: key, member = value if member is None: return {"t": t, "key": key, "member": None} mtag, mtype, mval = member return {"t": t, "key": key, "member": {"tag": mtag, "value": val_to_json(mtype, mval)}} if typ == heat2.VARLIST: return {"t": t, "v": [int(n) for n in value]} if typ == heat2.OBJTYPE: return {"t": t, "v": [int(value[0]), int(value[1])]} if typ == heat2.OBJID: return {"t": t, "v": [int(value[0]), int(value[1]), int(value[2])]} if typ == heat2.FLOAT: return {"t": t, "v": float(value)} raise ValueError("unhandled TDF type 0x%02x" % typ) def fields_to_json(fields): """Ordered [[tag, value], ...]. Order is informational: both encoders sort by packed tag, and the fixture's encoded_hex is what actually binds.""" items = list(fields.items()) if isinstance(fields, dict) else list(fields) return [[tag, val_to_json(typ, val)] for tag, (typ, val) in items] # ---------------------------------------------------------------- collection TDF_VECTORS = [] FIRE2_VECTORS = [] def tdf_case(name, fields, *, origin, verified, note=""): """Encode with the oracle, prove it round-trips, and record the vector.""" encoded = heat2.encode_tdf(fields) decoded = heat2.decode_tdf(encoded) reencoded = heat2.encode_tdf(decoded) if reencoded != encoded: raise AssertionError("oracle does not round-trip: %s" % name) TDF_VECTORS.append(OrderedDict(( ("name", name), ("origin", origin), # "live" | "synthetic" ("verified", verified), # False => heat2.py marks the layout UNVERIFIED ("note", note), ("fields", fields_to_json(fields)), ("encoded_hex", encoded.hex()), ))) def fire2_case(name, *, component, command, msg_num, msg_type, payload, metadata=b"", user_index=0, options=0, origin, note=""): frame = B.fire2(component, command, msg_num, msg_type, payload, metadata=metadata, user_index=user_index, options=options) hdr = B.parse_fire2_header(frame) assert hdr["component"] == component and hdr["command"] == command assert hdr["msg_num"] == msg_num and hdr["msg_type"] == msg_type assert hdr["user_index"] == user_index and hdr["options"] == options assert hdr["payload_len"] == len(payload) assert hdr["metadata_len"] == len(metadata) FIRE2_VECTORS.append(OrderedDict(( ("name", name), ("origin", origin), ("note", note), ("component", component), ("command", command), ("msg_num", msg_num), ("msg_type", msg_type), ("user_index", user_index), ("options", options), ("metadata_hex", metadata.hex()), ("payload_hex", payload.hex()), ("frame_hex", frame.hex()), ))) # ---------------------------------------------------------------- live vectors # # These field trees come from the responder's own builders — the payloads the # retail client accepted during the working end-to-end run. # A fixed epoch (2025-08-11T00:00:00Z) so regeneration is reproducible and # `--check` is meaningful. FIXED_NOW = 1754870400 def collect_live(): random.seed(0xF17A) # deterministic forged session key sess = B.Session() now = FIXED_NOW builders = [ ("preauth_response", lambda: B.preauth_response_fields()), ("ping_response", lambda: B.ping_response_fields()), ("qos_config", lambda: B.qos_config()), ("census_subscribe_response", lambda: B.subscribe_census_data_updates_response_fields()), ("persona_details", lambda: B.persona_details_fields(now)), ("user_login_info", lambda: B.user_login_info_fields(sess, now)), ("login_response", lambda: B.login_response_fields(sess)), ("account_info", lambda: B.account_info_fields(sess, now)), ("persona_info", lambda: B.persona_info_fields(now)), ("get_persona_response", lambda: B.get_persona_response_fields(now)), ("list_personas_response", lambda: B.list_personas_response_fields(now)), ("user_session_login_info", lambda: B.user_session_login_info_fields(sess, now)), ("network_qos_data", lambda: B.network_qos_data_fields()), ("user_session_extended_data", lambda: B.user_session_extended_data_fields()), ("user_session_extended_data_update", lambda: B.user_session_extended_data_update_fields()), ("user_identification", lambda: B.user_identification_fields(sess)), ("user_data", lambda: B.user_data_fields(sess)), ("post_auth_response", lambda: B.post_auth_response_fields(sess)), ("entitlements_response", lambda: B.entitlements_response_fields()), ("get_auth_token_response", lambda: B.get_auth_token_response_fields(sess)), ("user_settings_response", lambda: B.user_settings_response_fields()), ("get_lists_response", lambda: B.get_lists_response_fields()), ] for name, fn in builders: fields = fn() if not isinstance(fields, (dict, OrderedDict)): raise AssertionError("%s did not return a TDF struct" % name) tdf_case("live/" + name, fields, origin="live", verified=True, note="payload builder from blaze_responder_v3b.py") for cfid in ("BlazeSDK", "UTAS", "FUT"): try: fields = B.fetch_config_response_fields(cfid) except Exception: continue tdf_case("live/fetch_config_%s" % cfid, fields, origin="live", verified=True, note="fetchClientConfig CFID=%s" % cfid) # --- framing: the real login burst, exactly as the client received it. login_payload = heat2.encode_tdf(B.login_response_fields(sess)) fire2_case("live/login_reply", component=B.COMP_AUTH, command=B.CMD_LOGIN, msg_num=7, msg_type=B.REPLY, payload=login_payload, origin="live", note="Authentication::login REPLY") for tag, frame in B.build_login_notifications(sess, now): hdr = B.parse_fire2_header(frame) body = frame[16 + hdr["metadata_len"]:] fire2_case("live/notify_%s" % str(tag).lower().replace(" ", "_"), component=hdr["component"], command=hdr["command"], msg_num=hdr["msg_num"], msg_type=hdr["msg_type"], payload=body, user_index=hdr["user_index"], options=hdr["options"], origin="live", note="post-login server push") fire2_case("live/preauth_reply", component=B.COMP_UTIL, command=B.CMD_PREAUTH, msg_num=1, msg_type=B.REPLY, payload=heat2.encode_tdf(B.preauth_response_fields()), origin="live", note="Util::preAuth REPLY, the first RPC") fire2_case("live/ping_reply", component=B.COMP_UTIL, command=B.CMD_PING, msg_num=2, msg_type=B.REPLY, payload=heat2.encode_tdf(B.ping_response_fields()), origin="live", note="Util::ping REPLY") # ------------------------------------------------------------ synthetic vectors def collect_synthetic(): I, S, BL, ST = heat2.INT, heat2.STRING, heat2.BLOB, heat2.STRUCT L, M, U = heat2.LIST, heat2.MAP, heat2.UNION VL, OT, OI, F = heat2.VARLIST, heat2.OBJTYPE, heat2.OBJID, heat2.FLOAT def case(name, fields, verified=True, note=""): tdf_case("synthetic/" + name, fields, origin="synthetic", verified=verified, note=note) # Varint boundaries. The first byte carries only 6 data bits, so 0x3F/0x40 # is the one-to-two byte edge; the rest are the 7-bit group edges. case("varint_boundaries", OrderedDict([ ("V000", (I, 0)), ("V001", (I, 1)), ("V03F", (I, 0x3F)), ("V040", (I, 0x40)), ("V07F", (I, 0x7F)), ("V080", (I, 0x80)), ("V1FF", (I, 0x1FFF)), ("V200", (I, 0x2000)), ("VBIG", (I, 0xFFFFFFFF)), ("VMAX", (I, 0x7FFFFFFFFFFFFFFF)), ]), note="6-bit first group then 7-bit groups") case("varint_negative", OrderedDict([("NEG1", (I, -1)), ("NEGB", (I, -300))]), verified=False, note="sign bit 0x40 is UNVERIFIED in heat2.py; never seen on the wire") # Tag packing: 6-bit chars, space padded to 4. Short tags and the full # 4-char case must both survive a round trip. case("tag_padding", OrderedDict([ ("A", (I, 1)), ("AB", (I, 2)), ("ABC", (I, 3)), ("ABCD", (I, 4)), ("ENV", (S, "prod")), ("Z", (I, 26)), ]), note="trailing-space padding must strip on decode") case("tag_ordering", OrderedDict([ ("ZZZZ", (I, 3)), ("AAAA", (I, 1)), ("MMMM", (I, 2)), ]), note="members must serialise in ascending packed-tag order") case("strings", OrderedDict([ ("EMPT", (S, "")), ("ONEC", (S, "x")), ("LONG", (S, "x" * 200)), ("UTF8", (S, "Grün-Weiß Ünïcode")), ]), note="length varint INCLUDES the NUL terminator") case("blobs", OrderedDict([ ("BEMP", (BL, b"")), ("BONE", (BL, b"\x00")), ("BFUL", (BL, bytes(range(256)))), ]), note="blob length varint EXCLUDES any terminator") case("nested_structs", OrderedDict([ ("OUTR", (ST, OrderedDict([ ("INNR", (ST, OrderedDict([ ("DEEP", (ST, OrderedDict([("LEAF", (I, 42))]))), ("SIBL", (S, "sibling")), ]))), ("AFTR", (I, 7)), ]))), ("TAIL", (I, 9)), ]), note="nested structs are 0x00 terminated; the top level is not") case("empty_struct", OrderedDict([("MTST", (ST, OrderedDict()))]), note="an empty nested struct is a bare 0x00") case("lists", OrderedDict([ ("LINT", (L, (I, [1, 2, 3, 0x40, 0x2000]))), ("LSTR", (L, (S, ["a", "bb", ""]))), ("LEMP", (L, (I, []))), ("LST2", (L, (ST, [OrderedDict([("A", (I, 1))]), OrderedDict([("B", (I, 2))])]))), ]), verified=False, note="list layout UNVERIFIED (absent from the capture)") # A tag is exactly 4 packed 6-bit chars. Longer labels are TRUNCATED, not # rejected, so "LSTR2" and "LSTR" are the same wire tag and one silently # overwrites the other in a struct. Byte-level round-trip still holds. case("tag_truncation", OrderedDict([("LONGTAG", (I, 1))]), note="labels over 4 chars truncate silently — a collision hazard") case("maps", OrderedDict([ ("MSI", (M, (S, I, [("one", 1), ("two", 2)]))), ("MII", (M, (I, I, [(1, 10), (2, 20)]))), ("MEMP", (M, (S, I, []))), ]), verified=False, note="map layout UNVERIFIED") case("unions", OrderedDict([ ("USET", (U, (0, ("VALU", I, 1234)))), ("UUNS", (U, (heat2.UNION_UNSET, None))), ]), verified=False, note="union layout UNVERIFIED") case("scalar_misc", OrderedDict([ ("VLST", (VL, [1, 0x40, 0x2000])), ("OTYP", (OT, (4, 1))), ("OIDD", (OI, (4, 1, 33068179))), ("FLTP", (F, 1.5)), ("FLTN", (F, -0.25)), ("FLTZ", (F, 0.0)), ]), verified=False, note="varintlist/objtype/objid/float layouts UNVERIFIED; float is >f") # Framing edge cases. fire2_case("synthetic/empty_payload", component=0x0009, command=0x0002, msg_num=0, msg_type=B.MESSAGE, payload=b"", origin="synthetic", note="a zero-length body is legal (ping)") fire2_case("synthetic/msgnum_24bit", component=0x0001, command=0x000A, msg_num=0xFFFFFF, msg_type=B.REPLY, payload=b"\x01\x02", origin="synthetic", note="msgNum is a 24-bit field at [10:13]") fire2_case("synthetic/user_index_max", component=0x7802, command=0x0008, msg_num=1, msg_type=B.NOTIFICATION, payload=b"\xaa", user_index=0x1F, origin="synthetic", note="byte[13] = (msgType<<5) | (userIndex & 0x1F)") fire2_case("synthetic/with_metadata", component=0x0009, command=0x0007, msg_num=5, msg_type=B.MESSAGE, payload=b"payload", metadata=b"\xde\xad\xbe\xef", origin="synthetic", note="wire = header(16) || metadata || payload") fire2_case("synthetic/options_byte", component=0x0009, command=0x0007, msg_num=5, msg_type=B.MESSAGE, payload=b"x", options=0x42, origin="synthetic", note="byte[14] is the options byte") for mt, label in ((B.MESSAGE, "message"), (B.REPLY, "reply"), (B.NOTIFICATION, "notification"), (B.ERROR_REPLY, "error_reply"), (B.PING, "ping"), (B.PING_REPLY, "ping_reply")): fire2_case("synthetic/msgtype_%s" % label, component=0x0009, command=0x0001, msg_num=3, msg_type=mt, payload=b"\x00", origin="synthetic", note="msgType %d in the top 3 bits of byte[13]" % mt) fire2_case("synthetic/large_payload", component=0x0001, command=0x000A, msg_num=1, msg_type=B.REPLY, payload=bytes(70000 * [0x5A]), origin="synthetic", note="payloadLen is a full u32 — Fire2 has no jumbo-flag escape") # ---------------------------------------------------------------- output def write(path, vectors): body = "".join(json.dumps(v, separators=(",", ":")) + "\n" for v in vectors) 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 vectors)" % (os.path.basename(path), len(vectors))) return with open(path, "w", encoding="utf-8") as fh: fh.write(body) print("wrote %s (%d vectors)" % (os.path.basename(path), len(vectors))) def frozen_clock(): """Pin the oracle's clock for the duration of sampling. Some builders take a `now` argument; others (`ping_response_fields`, `login_response_fields`) call `int(time.time())` internally and ignore it. Left alone, every regeneration produces different bytes for those vectors, which makes `--check` permanently red and the committed fixtures unreproducible. This patches the clock the oracle reads, not the oracle's logic — the encoding path under test is untouched. Restored in a finally block so an imported responder is never left with a frozen clock. """ import time as _time original = _time.time _time.time = lambda: float(FIXED_NOW) return original, _time def main(): original_time, time_mod = frozen_clock() try: collect_live() finally: time_mod.time = original_time collect_synthetic() write(os.path.join(HERE, "tdf.jsonl"), TDF_VECTORS) write(os.path.join(HERE, "fire2.jsonl"), FIRE2_VECTORS) live = sum(1 for v in TDF_VECTORS if v["origin"] == "live") print("TDF: %d vectors (%d live, %d synthetic); Fire2: %d vectors" % (len(TDF_VECTORS), live, len(TDF_VECTORS) - live, len(FIRE2_VECTORS))) if __name__ == "__main__": main()