openfut-protocol-blaze: generic Blaze protocol layer, oracle-tested

First Rust component of the Python -> Rust migration. Chosen first because
it is the lowest genuinely game-independent layer, it has an executable
oracle, and both existing Rust implementations of it are wrong.

Contents:
  * fire2   -- the proven 16-byte frame header, frame/stream splitting
  * heat2   -- tag packing, varints, all 11 TDF value types
  * message -- frame + decoded body, routed by NUMERIC component/command
  * diagnostics -- dumps for capture review

No FIFA 17 command tables, response schemas or notification IDs: this layer
knows 0x0009/0x0007 is component 9, command 7, not that it means
Util::preAuth. That mapping belongs to a game adapter, which is what lets a
future FIFA 18/23 adapter reuse this.

Parity is tested, not asserted. fixtures/generate.py drives the proven
Python responders (heat2.py, blaze_responder_v3b.py) and freezes 56 vectors
-- 31 of them real payloads from the responder's own builders, including
the 11.8 KB preAuth reply. tests/oracle_parity.rs replays every one
byte-for-byte. 54 tests green; clippy clean.

Supersedes two wrong framings, neither of which is removed yet:
  * fifa-blaze/crates/blaze-proto/frame.rs -- a 12-byte header with a u16
    length, nibble-packed type/options, an error field and a JUMBO flag.
    A documented guess at FIFA 23 predating the FIFA 17 recon.
  * heat2.py::build_fire2_frame -- packs >IHHHHB3s, msgId at [10:12] and
    msgType at [12]. Dead code, but its docstring still states that layout.

Confidence is carried in the types: TypeId::is_verified() reports which
layouts are capture-backed (int/string/blob/struct) and which the oracle
marks UNVERIFIED (list/map/union/varlist/objtype/objid/float), with a test
asserting the unverified ones stay flagged.

Cargo.lock is deliberately NOT included: it re-resolves ~240 lines against
the current registry even without this crate, so that churn is pre-existing
and does not belong in a foundation commit.

The Python backend remains the live runtime and is untouched. Nothing
consumes this crate yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-08-11 00:53:59 +00:00
parent 3153a93edf
commit a9a816e0ed
19 changed files with 2982 additions and 0 deletions
+1
View File
@@ -2,6 +2,7 @@
resolver = "2"
members = [
"openfut-core",
"openfut-protocol-blaze",
"openfut-bridge",
"openfut-launcher",
"openfut-launcher/openfut-hook",
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "openfut-protocol-blaze"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "Game-independent EA Blaze wire protocol: Fire2 framing and Heat2/TDF codec"
publish = false
# NO RUNTIME DEPENDENCIES, ON PURPOSE.
#
# This crate is a byte-level codec. Its oracle (fifa17-recon/tools/heat2.py) is
# stdlib-only, and every byte it emits has to match that oracle exactly, so
# there is nothing here for a third-party crate to do that std cannot.
#
# The obvious candidate would be the crates.io `tdf` crate, which fifa-blaze
# already depends on. It is deliberately NOT used: it targets a generic
# BlazeSDK 15.x dialect, and adopting it would silently substitute someone
# else's reading of the format for our own captured evidence. It stays useful
# as an independent cross-check, not as the implementation.
[dependencies]
[dev-dependencies]
# Fixture vectors are JSONL produced by the Python oracle; tests need to read
# them. Test-only, so it never reaches a shipped artifact.
serde_json = "1"
+103
View File
@@ -0,0 +1,103 @@
# openfut-protocol-blaze
Game-independent EA Blaze wire protocol: **Fire2** framing and the **Heat2/TDF**
codec.
This is the first Rust component of the Python → Rust migration. It was chosen
first because it is the lowest layer that is genuinely game-independent, it has
an executable oracle, and both existing Rust implementations of it are wrong.
## Scope
| In | Out |
|---|---|
| Fire2 16-byte frame header, frame/stream splitting | Component and command *name* tables |
| Heat2 tag packing, varints, the 11 value types | Notification IDs, response schemas |
| A message = frame + decoded body, routed by number | Login sequencing, session identity |
| Diagnostics dumps | Anything FIFA-17-specific |
The boundary test: *could FIFA 18 or FIFA 23 use this without importing FIFA
17's command tables?* This crate knows `0x0009/0x0007` is component 9, command
7. That it means `Util::preAuth` is a FIFA 17 fact, and belongs in a FIFA 17
adapter.
## Provenance
Ported from `fifa17-recon/tools/heat2.py` (TDF codec) and
`fifa17-recon/tools/blaze_responder_v3b.py` (Fire2 framing) — the Python
implementation that drove a retail FIFA 17 client from Origin login to an
opened FUT pack. That implementation is the project's behavioural oracle, and
this crate does not claim parity with it, it *tests* parity against it.
### Two superseded framing implementations
Both other Fire2 implementations in this repository are wrong for FIFA 17, and
this crate exists partly to replace them:
* **`fifa-blaze/crates/blaze-proto/src/frame.rs`** — a **12-byte** header with a
`u16` length, nibble-packed type/options, an `error` field, and a JUMBO-frame
escape flag. Its own doc comment says FIFA 23's variant "is UNKNOWN" and that
Fire2 was picked because it "is the most likely candidate". It predates the
FIFA 17 recon and nothing has since validated it.
* **`fifa17-recon/tools/heat2.py::build_fire2_frame`** — packs `>IHHHHB3s`,
putting a `u16 msgId` at `[10:12]` and `msgType` at `[12]`. Dead code: the
responder carries a comment telling callers not to use it, and it is not on
any live path. Its module docstring still describes the old layout.
The proven layout is 16 bytes, with a `u24 msgNum` at `[10:13]` and
`(msgType << 5) | userIndex` in byte 13. There is **no error field** (that is
Fire v1) and **no jumbo flag** (the length is already a `u32`). The
`payload_over_64kib_needs_no_jumbo_flag` test is the direct refutation.
## Confidence is not uniform
`int`, `string`, `blob` and `struct` are validated byte-exact against captured
FIFA 17 traffic. `list`, `map`, `union`, `varlist`, `objtype`, `objid` and
`float` are marked UNVERIFIED in the oracle — they are absent from every capture
we hold. `TypeId::is_verified()` reports which is which, and a test asserts the
unverified ones stay flagged, so "Rust and Python agree" can never be quietly
read as "this is how EA does it".
## Testing
```bash
cargo test -p openfut-protocol-blaze # 43 unit + 10 differential + 1 doc
./check-parity.sh # regenerate fixtures, then re-verify
```
Differential vectors live in `fixtures/`, generated by `fixtures/generate.py`
from the Python oracle. 25 of the TDF vectors and 6 of the Fire2 vectors are
`origin: "live"` — real payloads from the responder's own builders, including
the preAuth reply (~11.8 KB) that is the first RPC FIFA 17 sends.
Comparison is **byte-for-byte**. FIFA 17's deserialisers hard-freeze on an
unexpected shape, so "semantically equivalent" is not a useful category at this
layer. (It *is* the right call one layer up at UTAS/JSON — see
`fifa17-recon/tools/test_fut_contract.py`.)
Regenerate fixtures after any change to the Python oracle:
```bash
python3 fixtures/generate.py # rewrite
python3 fixtures/generate.py --check # assert committed files are current
```
## No runtime dependencies
Deliberate. This is a byte-level codec whose oracle is stdlib-only Python; there
is nothing here a third-party crate can do that `std` cannot. In particular the
crates.io `tdf` crate (already a `fifa-blaze` dependency) is **not** used: it
targets a generic BlazeSDK 15.x dialect, and adopting it would substitute
someone else's reading of the format for our own captured evidence. It remains
useful as an independent cross-check, not as the implementation.
`serde_json` is a dev-dependency only, for reading fixture files.
## Not yet done
* No TLS, no sockets, no async. Framing and codec only; transport belongs to
whatever hosts this.
* The Blaze **redirector** (HTTPS + XML `getServerInstance`) is a different
protocol and is not here.
* Nothing consumes this crate yet. The Python backend remains the live runtime,
untouched.
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Differential check: Rust vs the proven Python FIFA 17 backend.
#
# 1. assert the committed fixtures still match what the Python oracle emits
# 2. replay every fixture through the Rust implementation, byte-for-byte
#
# Read-only with respect to the running backend: the oracle is imported as a
# library, no responder is started, no port is bound, no live service is
# touched. Safe to run while the Python backend is serving a live FIFA client.
#
# Use --regen to rewrite the fixtures after an intentional oracle change.
set -euo pipefail
cd "$(dirname "$(readlink -f "$0")")"
if [[ "${1:-}" == "--regen" ]]; then
echo "==> regenerating fixtures from the Python oracle"
python3 fixtures/generate.py
else
echo "==> checking committed fixtures against the Python oracle"
python3 fixtures/generate.py --check
fi
echo "==> replaying fixtures through the Rust implementation"
cargo test -p openfut-protocol-blaze
echo
echo "PARITY OK — Rust matches the Python oracle byte-for-byte."
File diff suppressed because one or more lines are too long
+433
View File
@@ -0,0 +1,433 @@
#!/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]]
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": <type-name>, ...}. 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()
File diff suppressed because one or more lines are too long
+202
View File
@@ -0,0 +1,202 @@
//! Human-readable dumps for debugging and capture review.
//!
//! Diagnostics only: nothing here is part of the wire contract, and no output
//! format should ever be parsed back.
use crate::fire2::{Frame, Header};
use crate::heat2::{Struct, Value};
use std::fmt::Write as _;
/// Render a TDF struct as an indented tree, mirroring `heat2.py::dump`.
pub fn dump_struct(s: &Struct) -> String {
let mut out = String::new();
write_struct(s, 0, &mut out);
out
}
fn write_struct(s: &Struct, depth: usize, out: &mut String) {
for (tag, value) in s.iter() {
let pad = " ".repeat(depth);
let _ = write!(out, "{pad}{tag} ({})", value.type_id().name());
match value {
Value::Struct(inner) => {
let _ = writeln!(out, " {{");
write_struct(inner, depth + 1, out);
let _ = writeln!(out, "{pad}}}");
}
Value::List { elem, items } => {
let _ = writeln!(out, " [{} x {}]", elem.name(), items.len());
for item in items {
write_value(item, depth + 1, out);
}
}
Value::Map { key, val, entries } => {
let _ = writeln!(
out,
" {{{} -> {} x {}}}",
key.name(),
val.name(),
entries.len()
);
for (k, v) in entries {
write_value(k, depth + 1, out);
write_value(v, depth + 2, out);
}
}
other => {
let _ = writeln!(out, " = {}", scalar(other));
}
}
}
}
fn write_value(value: &Value, depth: usize, out: &mut String) {
let pad = " ".repeat(depth);
match value {
Value::Struct(inner) => {
let _ = writeln!(out, "{pad}{{");
write_struct(inner, depth + 1, out);
let _ = writeln!(out, "{pad}}}");
}
other => {
let _ = writeln!(out, "{pad}{}", scalar(other));
}
}
}
fn scalar(value: &Value) -> String {
match value {
Value::Int(v) => format!("{v}"),
Value::String(s) => format!("{s:?}"),
Value::Blob(b) => format!("<{} bytes> {}", b.len(), hex(&b[..b.len().min(32)])),
Value::VarList(v) => format!("{v:?}"),
Value::ObjType { component, ty } => format!("({component}, {ty})"),
Value::ObjId { component, ty, id } => format!("({component}, {ty}, {id})"),
Value::Float(f) => format!("{f}"),
Value::Union { key, member } => match member {
Some(m) => format!("union[{key}] {} = {}", m.0, scalar(&m.1)),
None => format!("union[{key}] unset"),
},
Value::Struct(_) | Value::List { .. } | Value::Map { .. } => String::from("..."),
}
}
/// One-line summary of a Fire2 header.
pub fn describe_header(h: &Header) -> String {
format!(
"component=0x{:04x} command=0x{:04x} {} msgNum={} userIdx={} opts=0x{:02x} \
payload={}B metadata={}B",
h.component,
h.command,
h.msg_type.name(),
h.msg_num,
h.user_index,
h.options,
h.payload_len,
h.metadata_len
)
}
/// Header summary plus a decoded body, falling back to hex when it will not
/// parse. A capture is most valuable exactly when the body is malformed, so
/// this must never fail.
pub fn describe_frame(frame: &Frame) -> String {
let mut out = describe_header(&frame.header);
out.push('\n');
if frame.payload.is_empty() {
out.push_str("(empty payload)\n");
return out;
}
match crate::heat2::decode(&frame.payload) {
Ok(body) => out.push_str(&dump_struct(&body)),
Err(e) => {
let _ = writeln!(out, "(TDF decode failed: {e})");
out.push_str(&hexdump(&frame.payload, 256));
}
}
out
}
pub fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
/// Classic offset / hex / ASCII dump, truncated to `limit` bytes.
pub fn hexdump(bytes: &[u8], limit: usize) -> String {
let shown = &bytes[..bytes.len().min(limit)];
let mut out = String::new();
for (row, chunk) in shown.chunks(16).enumerate() {
let _ = write!(out, "{:08x} ", row * 16);
for i in 0..16 {
match chunk.get(i) {
Some(b) => {
let _ = write!(out, "{b:02x} ");
}
None => out.push_str(" "),
}
if i == 7 {
out.push(' ');
}
}
out.push_str(" |");
for b in chunk {
out.push(if (0x20..0x7F).contains(b) {
*b as char
} else {
'.'
});
}
out.push_str("|\n");
}
if bytes.len() > limit {
let _ = writeln!(out, "... {} more bytes", bytes.len() - limit);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fire2::MsgType;
use crate::heat2;
#[test]
fn dumps_nested_structures() {
let s = Struct::new().with("PID", Value::Int(33068179)).with(
"CINF",
Value::Struct(Struct::new().with("ENV", Value::String("prod".into()))),
);
let text = dump_struct(&s);
assert!(text.contains("PID (int) = 33068179"), "{text}");
assert!(text.contains("ENV (string) = \"prod\""), "{text}");
}
#[test]
fn describes_a_frame_with_an_undecodable_body() {
// 0xFF is not a TDF type byte, so this must fall back to hex.
let frame = Frame::new(9, 7, 1, MsgType::Reply, vec![0xFF; 8]);
let text = describe_frame(&frame);
assert!(text.contains("decode failed"), "{text}");
assert!(text.contains("ff ff ff"), "{text}");
}
#[test]
fn describes_a_frame_with_a_good_body() {
let body = Struct::new().with("A", Value::Int(1));
let frame = Frame::new(9, 7, 1, MsgType::Reply, heat2::encode(&body));
let text = describe_frame(&frame);
assert!(text.contains("REPLY"), "{text}");
assert!(text.contains("A (int) = 1"), "{text}");
}
#[test]
fn hexdump_truncates_and_says_so() {
let text = hexdump(&[0x41; 100], 32);
assert!(text.contains("68 more bytes"), "{text}");
assert!(text.contains("AAAA"), "{text}");
}
}
+46
View File
@@ -0,0 +1,46 @@
//! Decode errors.
//!
//! Encoding cannot fail: every `Value` is representable on the wire. Decoding
//! is fallible because the input is attacker-shaped bytes from a socket.
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
/// Ran off the end of the buffer while reading `what`.
Truncated {
what: &'static str,
need: usize,
have: usize,
},
/// A type byte that is not one of the eleven Heat2 types.
UnknownType { type_byte: u8, at: usize },
/// A varint whose continuation bits never terminated within 64 bits.
VarintOverflow { at: usize },
/// A Fire2 header whose declared lengths cannot be satisfied.
ShortFrame { need: usize, have: usize },
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Truncated { what, need, have } => write!(
f,
"truncated while reading {what}: need {need} bytes, have {have}"
),
Error::UnknownType { type_byte, at } => {
write!(f, "unknown TDF type byte 0x{type_byte:02x} at offset {at}")
}
Error::VarintOverflow { at } => {
write!(f, "varint at offset {at} exceeds 64 bits")
}
Error::ShortFrame { need, have } => {
write!(f, "short Fire2 frame: need {need} bytes, have {have}")
}
}
}
}
impl std::error::Error for Error {}
pub type Result<T> = std::result::Result<T, Error>;
+416
View File
@@ -0,0 +1,416 @@
//! Fire2 framing.
//!
//! ```text
//! [0:4] u32 payload length
//! [4:6] u16 metadata length
//! [6:8] u16 component
//! [8:10] u16 command (notification id when msgType == NOTIFICATION)
//! [10:13] u24 msgNum
//! [13] u8 (msgType << 5) | (userIndex & 0x1F)
//! [14] u8 options
//! [15] u8 reserved
//!
//! wire = header(16) || metadata || payload
//! ```
//!
//! All fields big-endian. Sixteen bytes, not twelve.
//!
//! # Two wrong layouts this replaces
//!
//! This is the layout in `blaze_responder_v3b.py::fire2` — the code that
//! actually drove a retail FIFA 17 client through login. Two other
//! implementations in this repository disagree and are both wrong for FIFA 17:
//!
//! * `fifa17-recon/tools/heat2.py::build_fire2_frame` packs
//! `>IHHHHB3s`, which puts a `u16 msgId` at `[10:12]` and `msgType` at
//! `[12]`. Its own module docstring still describes that layout. The
//! responder carries a comment telling callers not to use it.
//! * `fifa-blaze/crates/blaze-proto/src/frame.rs` implements a *12-byte*
//! header with a `u16` length, nibble-packed type/options, and a JUMBO
//! flag. That was a documented guess at FIFA 23's variant, written before
//! the FIFA 17 recon; nothing has since validated it.
//!
//! There is **no error field** in a Fire2 header — that belongs to Fire v1's
//! 12-byte frame. There is also no jumbo-frame escape: the length is already a
//! full `u32`.
use crate::error::{Error, Result};
/// Header size in bytes.
pub const HEADER_LEN: usize = 16;
/// `msgType`, the top three bits of byte 13.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MsgType {
Message,
Reply,
Notification,
ErrorReply,
Ping,
PingReply,
/// 6 and 7 are representable in three bits but have never been observed.
Unknown(u8),
}
impl MsgType {
pub fn from_bits(bits: u8) -> MsgType {
match bits & 0x07 {
0 => MsgType::Message,
1 => MsgType::Reply,
2 => MsgType::Notification,
3 => MsgType::ErrorReply,
4 => MsgType::Ping,
5 => MsgType::PingReply,
other => MsgType::Unknown(other),
}
}
pub fn as_bits(self) -> u8 {
match self {
MsgType::Message => 0,
MsgType::Reply => 1,
MsgType::Notification => 2,
MsgType::ErrorReply => 3,
MsgType::Ping => 4,
MsgType::PingReply => 5,
MsgType::Unknown(v) => v & 0x07,
}
}
pub fn name(self) -> &'static str {
match self {
MsgType::Message => "MESSAGE",
MsgType::Reply => "REPLY",
MsgType::Notification => "NOTIFICATION",
MsgType::ErrorReply => "ERROR_REPLY",
MsgType::Ping => "PING",
MsgType::PingReply => "PING_REPLY",
MsgType::Unknown(_) => "UNKNOWN",
}
}
}
/// A parsed Fire2 header.
///
/// `component`/`command` are plain integers on purpose. Naming them is a
/// game-specific concern: a FIFA 17 adapter owns the tables that turn
/// `0x0009/0x0007` into `Util::preAuth`, and a future FIFA 18 or FIFA 23
/// adapter may map the same numbers differently.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
pub payload_len: u32,
pub metadata_len: u16,
pub component: u16,
pub command: u16,
/// 24-bit request correlator.
pub msg_num: u32,
pub msg_type: MsgType,
/// 5-bit local user slot.
pub user_index: u8,
pub options: u8,
pub reserved: u8,
}
impl Header {
pub fn new(component: u16, command: u16, msg_num: u32, msg_type: MsgType) -> Header {
Header {
payload_len: 0,
metadata_len: 0,
component,
command,
msg_num,
msg_type,
user_index: 0,
options: 0,
reserved: 0,
}
}
/// Total wire size of this frame: header + metadata + payload.
pub fn frame_len(&self) -> usize {
HEADER_LEN + self.metadata_len as usize + self.payload_len as usize
}
/// Parse a header from the first 16 bytes of `buf`.
pub fn parse(buf: &[u8]) -> Result<Header> {
if buf.len() < HEADER_LEN {
return Err(Error::ShortFrame {
need: HEADER_LEN,
have: buf.len(),
});
}
Ok(Header {
payload_len: u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]),
metadata_len: u16::from_be_bytes([buf[4], buf[5]]),
component: u16::from_be_bytes([buf[6], buf[7]]),
command: u16::from_be_bytes([buf[8], buf[9]]),
msg_num: ((buf[10] as u32) << 16) | ((buf[11] as u32) << 8) | buf[12] as u32,
msg_type: MsgType::from_bits(buf[13] >> 5),
user_index: buf[13] & 0x1F,
options: buf[14],
reserved: buf[15],
})
}
/// Serialise the 16 header bytes.
pub fn write(&self, out: &mut Vec<u8>) {
out.extend_from_slice(&self.payload_len.to_be_bytes());
out.extend_from_slice(&self.metadata_len.to_be_bytes());
out.extend_from_slice(&self.component.to_be_bytes());
out.extend_from_slice(&self.command.to_be_bytes());
out.push(((self.msg_num >> 16) & 0xFF) as u8);
out.push(((self.msg_num >> 8) & 0xFF) as u8);
out.push((self.msg_num & 0xFF) as u8);
out.push((self.msg_type.as_bits() << 5) | (self.user_index & 0x1F));
out.push(self.options);
out.push(self.reserved);
}
pub fn to_bytes(&self) -> [u8; HEADER_LEN] {
let mut v = Vec::with_capacity(HEADER_LEN);
self.write(&mut v);
let mut out = [0u8; HEADER_LEN];
out.copy_from_slice(&v);
out
}
}
/// A complete Fire2 frame: header plus its metadata and payload bytes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame {
pub header: Header,
pub metadata: Vec<u8>,
/// Raw payload bytes. Undecoded on purpose — the payload is Heat2/TDF, but
/// framing must stay usable for capture and diagnostics even when the body
/// does not parse.
pub payload: Vec<u8>,
}
impl Frame {
/// Build a frame, deriving the length fields from the buffers.
pub fn new(
component: u16,
command: u16,
msg_num: u32,
msg_type: MsgType,
payload: Vec<u8>,
) -> Frame {
let mut header = Header::new(component, command, msg_num, msg_type);
header.payload_len = payload.len() as u32;
Frame {
header,
metadata: Vec::new(),
payload,
}
}
pub fn with_metadata(mut self, metadata: Vec<u8>) -> Frame {
self.header.metadata_len = metadata.len() as u16;
self.metadata = metadata;
self
}
pub fn with_user_index(mut self, user_index: u8) -> Frame {
self.header.user_index = user_index & 0x1F;
self
}
pub fn with_options(mut self, options: u8) -> Frame {
self.header.options = options;
self
}
/// A reply echoes component, command, msgNum and userIndex verbatim, and
/// changes only the msgType bits.
pub fn reply_to(request: &Header, payload: Vec<u8>) -> Frame {
let mut frame = Frame::new(
request.component,
request.command,
request.msg_num,
MsgType::Reply,
payload,
);
frame.header.user_index = request.user_index;
frame
}
/// An unsolicited server push. `msgNum` is 0: notifications are not
/// correlated to any request.
pub fn notification(component: u16, notify_id: u16, payload: Vec<u8>) -> Frame {
Frame::new(component, notify_id, 0, MsgType::Notification, payload)
}
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(self.wire_len());
self.encode_into(&mut out);
out
}
pub fn encode_into(&self, out: &mut Vec<u8>) {
// Trust the buffers over any stale length in the header.
let mut header = self.header;
header.payload_len = self.payload.len() as u32;
header.metadata_len = self.metadata.len() as u16;
header.write(out);
out.extend_from_slice(&self.metadata);
out.extend_from_slice(&self.payload);
}
pub fn wire_len(&self) -> usize {
HEADER_LEN + self.metadata.len() + self.payload.len()
}
/// Parse one frame from the front of `buf`, returning it and the number of
/// bytes consumed.
pub fn parse(buf: &[u8]) -> Result<(Frame, usize)> {
let header = Header::parse(buf)?;
let total = header.frame_len();
if buf.len() < total {
return Err(Error::ShortFrame {
need: total,
have: buf.len(),
});
}
let meta_end = HEADER_LEN + header.metadata_len as usize;
Ok((
Frame {
header,
metadata: buf[HEADER_LEN..meta_end].to_vec(),
payload: buf[meta_end..total].to_vec(),
},
total,
))
}
}
/// How many bytes a frame starting at `buf` needs in total, if its header is
/// complete. `None` means the header itself has not arrived yet.
///
/// This is what a stream reader needs: Blaze frames arrive coalesced and split
/// across TCP segments, so a reader must size each frame before consuming it.
pub fn frame_size_hint(buf: &[u8]) -> Option<usize> {
Header::parse(buf).ok().map(|h| h.frame_len())
}
/// Split a buffer into as many whole frames as it contains.
///
/// Returns the frames plus the number of bytes consumed; any trailing partial
/// frame is left for the caller to retry once more bytes arrive.
pub fn parse_all(buf: &[u8]) -> Result<(Vec<Frame>, usize)> {
let mut frames = Vec::new();
let mut off = 0;
while off < buf.len() {
match Frame::parse(&buf[off..]) {
Ok((frame, used)) => {
off += used;
frames.push(frame);
}
Err(Error::ShortFrame { .. }) => break,
Err(e) => return Err(e),
}
}
Ok((frames, off))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn header_is_sixteen_bytes() {
let h = Header::new(0x0009, 0x0007, 1, MsgType::Reply);
assert_eq!(h.to_bytes().len(), 16);
}
#[test]
fn field_offsets_match_the_proven_layout() {
let mut h = Header::new(0x0009, 0x0007, 0x123456, MsgType::Reply);
h.payload_len = 0x11223344;
h.metadata_len = 0x5566;
h.user_index = 3;
h.options = 0x42;
let b = h.to_bytes();
assert_eq!(&b[0..4], &[0x11, 0x22, 0x33, 0x44]); // payload len u32
assert_eq!(&b[4..6], &[0x55, 0x66]); // metadata len u16
assert_eq!(&b[6..8], &[0x00, 0x09]); // component
assert_eq!(&b[8..10], &[0x00, 0x07]); // command
assert_eq!(&b[10..13], &[0x12, 0x34, 0x56]); // msgNum u24
assert_eq!(b[13], (1 << 5) | 3); // msgType | userIndex
assert_eq!(b[14], 0x42);
assert_eq!(b[15], 0x00);
}
#[test]
fn reply_bit_pattern_is_0x20_and_notification_is_0x40() {
let reply = Frame::new(1, 0x0A, 7, MsgType::Reply, vec![]).encode();
assert_eq!(reply[13], 0x20);
let notify = Frame::notification(0x7802, 0x0008, vec![]).encode();
assert_eq!(notify[13], 0x40);
}
#[test]
fn payload_length_exceeds_sixteen_bits_without_a_jumbo_flag() {
// The stale 12-byte implementation would have needed an escape here.
let frame = Frame::new(1, 1, 1, MsgType::Reply, vec![0x5A; 70_000]);
let bytes = frame.encode();
let parsed = Header::parse(&bytes).unwrap();
assert_eq!(parsed.payload_len, 70_000);
assert_eq!(bytes.len(), 16 + 70_000);
}
#[test]
fn round_trips_with_metadata() {
let frame = Frame::new(0x0009, 0x0007, 5, MsgType::Message, b"payload".to_vec())
.with_metadata(vec![0xde, 0xad, 0xbe, 0xef]);
let bytes = frame.encode();
let (back, used) = Frame::parse(&bytes).unwrap();
assert_eq!(used, bytes.len());
assert_eq!(back.metadata, vec![0xde, 0xad, 0xbe, 0xef]);
assert_eq!(back.payload, b"payload");
assert_eq!(back.encode(), bytes);
}
#[test]
fn reply_echoes_routing_fields() {
let req = Header {
user_index: 5,
..Header::new(0x0001, 0x000A, 0x2222, MsgType::Message)
};
let reply = Frame::reply_to(&req, vec![1, 2, 3]);
assert_eq!(reply.header.component, 0x0001);
assert_eq!(reply.header.command, 0x000A);
assert_eq!(reply.header.msg_num, 0x2222);
assert_eq!(reply.header.user_index, 5);
assert_eq!(reply.header.msg_type, MsgType::Reply);
}
#[test]
fn msg_num_is_twenty_four_bits() {
let frame = Frame::new(1, 1, 0xFFFFFF, MsgType::Reply, vec![]);
let back = Header::parse(&frame.encode()).unwrap();
assert_eq!(back.msg_num, 0xFFFFFF);
}
#[test]
fn splits_coalesced_frames_and_leaves_a_partial_tail() {
let a = Frame::new(9, 2, 1, MsgType::Reply, vec![1]).encode();
let b = Frame::new(1, 10, 2, MsgType::Reply, vec![2, 3]).encode();
let mut stream = a.clone();
stream.extend_from_slice(&b);
stream.extend_from_slice(&[0x00, 0x00]); // partial third header
let (frames, used) = parse_all(&stream).unwrap();
assert_eq!(frames.len(), 2);
assert_eq!(used, a.len() + b.len());
assert_eq!(frames[1].payload, vec![2, 3]);
}
#[test]
fn short_buffer_reports_what_it_needs() {
assert_eq!(
Header::parse(&[0u8; 8]),
Err(Error::ShortFrame { need: 16, have: 8 })
);
assert_eq!(frame_size_hint(&[0u8; 8]), None);
}
}
+334
View File
@@ -0,0 +1,334 @@
//! Heat2 decoder.
//!
//! Input is bytes off a socket, so every read is bounds-checked and every
//! failure is an `Error`, never a panic. The oracle (`heat2.py`) indexes
//! optimistically and would raise on malformed input; matching its *bytes* is
//! required, matching its *crash behaviour* is not.
use super::tag::Tag;
use super::value::{Struct, TypeId, Value, UNION_UNSET};
use super::varint;
use crate::error::{Error, Result};
/// Decode a top-level payload body (unterminated, delimited by `buf`).
pub fn decode(buf: &[u8]) -> Result<Struct> {
let (s, _) = decode_members(buf, 0, buf.len(), false)?;
Ok(s)
}
/// Read members until `end` (top level) or a `0x00` terminator (nested).
fn decode_members(
buf: &[u8],
mut i: usize,
end: usize,
terminated: bool,
) -> Result<(Struct, usize)> {
let mut fields = Vec::new();
while i < end {
if terminated && buf[i] == 0x00 {
i += 1;
break;
}
if i + 4 > end {
return Err(Error::Truncated {
what: "field header",
need: 4,
have: end - i,
});
}
let tag = Tag([buf[i], buf[i + 1], buf[i + 2]]);
let type_byte = buf[i + 3];
let ty = TypeId::from_byte(type_byte).ok_or(Error::UnknownType {
type_byte,
at: i + 3,
})?;
i += 4;
let (value, next) = decode_value(buf, i, end, ty)?;
i = next;
fields.push((tag, value));
}
Ok((Struct { fields }, i))
}
fn decode_value(buf: &[u8], i: usize, end: usize, ty: TypeId) -> Result<(Value, usize)> {
match ty {
TypeId::Int => {
let (v, i) = varint::decode(buf, i)?;
Ok((Value::Int(v), i))
}
TypeId::String => {
let (len, i) = varint::decode(buf, i)?;
let len = checked_len(len, i, end, "string")?;
let raw = &buf[i..i + len];
// The declared length includes the NUL; strip any trailing NULs so
// the value round-trips through the encoder unchanged.
let cut = raw.iter().rposition(|&b| b != 0).map_or(0, |p| p + 1);
let s = String::from_utf8_lossy(&raw[..cut]).into_owned();
Ok((Value::String(s), i + len))
}
TypeId::Blob => {
let (len, i) = varint::decode(buf, i)?;
let len = checked_len(len, i, end, "blob")?;
Ok((Value::Blob(buf[i..i + len].to_vec()), i + len))
}
TypeId::Struct => {
let (s, i) = decode_members(buf, i, end, true)?;
Ok((Value::Struct(s), i))
}
TypeId::List => {
let elem_byte = *byte_at(buf, i, end, "list element type")?;
let elem = TypeId::from_byte(elem_byte).ok_or(Error::UnknownType {
type_byte: elem_byte,
at: i,
})?;
let (count, mut i) = varint::decode(buf, i + 1)?;
let count = checked_count(count, end - i.min(end), "list")?;
let mut items = Vec::with_capacity(count.min(1024));
for _ in 0..count {
let (v, next) = decode_value(buf, i, end, elem)?;
i = next;
items.push(v);
}
Ok((Value::List { elem, items }, i))
}
TypeId::Map => {
let key_byte = *byte_at(buf, i, end, "map key type")?;
let val_byte = *byte_at(buf, i + 1, end, "map value type")?;
let key = TypeId::from_byte(key_byte).ok_or(Error::UnknownType {
type_byte: key_byte,
at: i,
})?;
let val = TypeId::from_byte(val_byte).ok_or(Error::UnknownType {
type_byte: val_byte,
at: i + 1,
})?;
let (count, mut i) = varint::decode(buf, i + 2)?;
let count = checked_count(count, end - i.min(end), "map")?;
let mut entries = Vec::with_capacity(count.min(1024));
for _ in 0..count {
let (k, next) = decode_value(buf, i, end, key)?;
let (v, next) = decode_value(buf, next, end, val)?;
i = next;
entries.push((k, v));
}
Ok((Value::Map { key, val, entries }, i))
}
TypeId::Union => {
let key = *byte_at(buf, i, end, "union discriminator")?;
let i = i + 1;
if key == UNION_UNSET {
return Ok((Value::Union { key, member: None }, i));
}
if i + 4 > end {
return Err(Error::Truncated {
what: "union member header",
need: 4,
have: end.saturating_sub(i),
});
}
let tag = Tag([buf[i], buf[i + 1], buf[i + 2]]);
let mtype_byte = buf[i + 3];
let mtype = TypeId::from_byte(mtype_byte).ok_or(Error::UnknownType {
type_byte: mtype_byte,
at: i + 3,
})?;
let (value, i) = decode_value(buf, i + 4, end, mtype)?;
Ok((
Value::Union {
key,
member: Some(Box::new((tag, value))),
},
i,
))
}
TypeId::VarList => {
let (count, mut i) = varint::decode(buf, i)?;
let count = checked_count(count, end - i.min(end), "varlist")?;
let mut items = Vec::with_capacity(count.min(1024));
for _ in 0..count {
let (v, next) = varint::decode(buf, i)?;
i = next;
items.push(v);
}
Ok((Value::VarList(items), i))
}
TypeId::ObjType => {
let (component, i) = varint::decode(buf, i)?;
let (ty, i) = varint::decode(buf, i)?;
Ok((Value::ObjType { component, ty }, i))
}
TypeId::ObjId => {
let (component, i) = varint::decode(buf, i)?;
let (ty, i) = varint::decode(buf, i)?;
let (id, i) = varint::decode(buf, i)?;
Ok((Value::ObjId { component, ty, id }, i))
}
TypeId::Float => {
if i + 4 > end {
return Err(Error::Truncated {
what: "float",
need: 4,
have: end.saturating_sub(i),
});
}
let f = f32::from_be_bytes([buf[i], buf[i + 1], buf[i + 2], buf[i + 3]]);
Ok((Value::Float(f), i + 4))
}
}
}
fn byte_at<'a>(buf: &'a [u8], i: usize, end: usize, what: &'static str) -> Result<&'a u8> {
if i >= end {
return Err(Error::Truncated {
what,
need: 1,
have: 0,
});
}
buf.get(i).ok_or(Error::Truncated {
what,
need: 1,
have: 0,
})
}
fn checked_len(len: i64, i: usize, end: usize, what: &'static str) -> Result<usize> {
let available = end.saturating_sub(i);
if len < 0 || len as u64 > available as u64 {
return Err(Error::Truncated {
what,
need: len.max(0) as usize,
have: available,
});
}
Ok(len as usize)
}
/// Reject a declared element count that cannot fit in the remaining bytes.
///
/// Without this a two-byte varint can ask for a billion elements and the
/// allocation, not the parse, becomes the failure.
fn checked_count(count: i64, remaining: usize, what: &'static str) -> Result<usize> {
if count < 0 || count as u64 > remaining as u64 {
return Err(Error::Truncated {
what,
need: count.max(0) as usize,
have: remaining,
});
}
Ok(count as usize)
}
#[cfg(test)]
mod tests {
use super::super::encode::encode;
use super::*;
fn round_trip(s: Struct) {
let bytes = encode(&s);
let back = decode(&bytes).expect("decodes");
assert_eq!(encode(&back), bytes, "re-encode must be byte-identical");
}
#[test]
fn round_trips_scalars() {
round_trip(
Struct::new()
.with("INTV", Value::Int(0x2000))
.with("STRV", Value::String("hello".into()))
.with("BLBV", Value::Blob(vec![0, 1, 2, 255]))
.with("FLTV", Value::Float(-0.25)),
);
}
#[test]
fn round_trips_nesting() {
round_trip(
Struct::new()
.with(
"OUTR",
Value::Struct(
Struct::new()
.with(
"INNR",
Value::Struct(Struct::new().with("LEAF", Value::Int(42))),
)
.with("SIBL", Value::String("s".into())),
),
)
.with("TAIL", Value::Int(9)),
);
}
#[test]
fn nested_terminator_does_not_swallow_following_members() {
let s = Struct::new()
.with(
"AAAA",
Value::Struct(Struct::new().with("X", Value::Int(1))),
)
.with("BBBB", Value::Int(7));
let back = decode(&encode(&s)).unwrap();
assert_eq!(back.len(), 2);
assert_eq!(back.get("BBBB").and_then(Value::as_int), Some(7));
}
#[test]
fn rejects_an_unknown_type_byte() {
// tag "AAAA" then type 0x0B, which is not a Heat2 type.
let mut bytes = Tag::from_label("AAAA").as_bytes().to_vec();
bytes.push(0x0B);
assert!(matches!(
decode(&bytes),
Err(Error::UnknownType {
type_byte: 0x0B,
..
})
));
}
#[test]
fn rejects_a_string_longer_than_the_buffer() {
let mut bytes = Tag::from_label("S").as_bytes().to_vec();
bytes.push(TypeId::String.as_byte());
bytes.push(0x3F); // claims 63 bytes
bytes.extend_from_slice(b"short");
assert!(matches!(decode(&bytes), Err(Error::Truncated { .. })));
}
#[test]
fn rejects_an_absurd_list_count_without_allocating() {
let mut bytes = Tag::from_label("L").as_bytes().to_vec();
bytes.push(TypeId::List.as_byte());
bytes.push(TypeId::Int.as_byte());
bytes.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0x7F]); // huge count
assert!(matches!(decode(&bytes), Err(Error::Truncated { .. })));
}
#[test]
fn rejects_a_truncated_field_header() {
let bytes = vec![0x96, 0xed]; // two bytes of a three-byte tag
assert!(decode(&bytes).is_err());
}
#[test]
fn never_panics_on_arbitrary_bytes() {
// Cheap structured fuzz: every 3-byte prefix followed by each type byte.
for type_byte in 0x00u8..=0x0C {
for pattern in [0x00u8, 0x01, 0x7F, 0x80, 0xFF] {
let bytes = vec![pattern, pattern, pattern, type_byte, pattern, pattern];
let _ = decode(&bytes); // must return, not panic
}
}
}
}
+205
View File
@@ -0,0 +1,205 @@
//! Heat2 encoder.
//!
//! Field layout is `3-byte packed tag || 1 type byte || value`.
//!
//! Two rules are easy to get wrong and both are load-bearing:
//!
//! 1. **Members serialise in ascending packed-tag order.** Not source order,
//! not alphabetical order of the label — packed-tag order. (They coincide
//! for equal-length uppercase labels, which is why a bug here hides.)
//! 2. **A nested struct is terminated by `0x00`; the top-level payload is
//! not.** The top level is delimited by the Fire2 length instead.
//!
//! Encoding is infallible: every `Value` has a wire form.
use super::tag::Tag;
use super::value::{Struct, Value, UNION_UNSET};
use super::varint;
/// Encode a top-level payload body (no trailing terminator).
pub fn encode(s: &Struct) -> Vec<u8> {
let mut out = Vec::new();
encode_into(s, &mut out);
out
}
/// Encode a top-level payload body, appending to `out`.
pub fn encode_into(s: &Struct, out: &mut Vec<u8>) {
encode_members(s, out);
}
fn encode_members(s: &Struct, out: &mut Vec<u8>) {
// Stable sort by packed tag: equal tags keep their relative order, so a
// decoded frame containing duplicates re-encodes identically.
let mut ordered: Vec<&(Tag, Value)> = s.fields.iter().collect();
ordered.sort_by_key(|(tag, _)| *tag);
for (tag, value) in ordered {
out.extend_from_slice(&tag.as_bytes());
out.push(value.type_id().as_byte());
encode_value(value, out);
}
}
fn encode_value(value: &Value, out: &mut Vec<u8>) {
match value {
Value::Int(v) => varint::encode(*v, out),
Value::String(s) => {
// Trailing NULs are stripped before framing so the length and the
// single terminator stay consistent; the length INCLUDES that NUL.
let raw = s.as_bytes();
let end = raw.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1);
let raw = &raw[..end];
varint::encode(raw.len() as i64 + 1, out);
out.extend_from_slice(raw);
out.push(0x00);
}
Value::Blob(b) => {
// Blob length EXCLUDES a terminator — there isn't one.
varint::encode(b.len() as i64, out);
out.extend_from_slice(b);
}
Value::Struct(s) => {
encode_members(s, out);
out.push(0x00);
}
Value::List { elem, items } => {
out.push(elem.as_byte());
varint::encode(items.len() as i64, out);
for it in items {
encode_value(it, out);
}
}
Value::Map { key, val, entries } => {
out.push(key.as_byte());
out.push(val.as_byte());
varint::encode(entries.len() as i64, out);
for (k, v) in entries {
encode_value(k, out);
encode_value(v, out);
}
}
Value::Union { key, member } => {
out.push(*key);
if *key != UNION_UNSET {
if let Some(m) = member {
let (tag, val) = m.as_ref();
out.extend_from_slice(&tag.as_bytes());
out.push(val.type_id().as_byte());
encode_value(val, out);
}
}
}
Value::VarList(items) => {
varint::encode(items.len() as i64, out);
for n in items {
varint::encode(*n, out);
}
}
Value::ObjType { component, ty } => {
varint::encode(*component, out);
varint::encode(*ty, out);
}
Value::ObjId { component, ty, id } => {
varint::encode(*component, out);
varint::encode(*ty, out);
varint::encode(*id, out);
}
Value::Float(f) => out.extend_from_slice(&f.to_be_bytes()),
}
}
/// Encode a value on its own, without a tag or type byte.
///
/// Useful for a nested struct that a caller frames itself; note this DOES emit
/// the `0x00` terminator for `Value::Struct`.
pub fn encode_value_only(value: &Value) -> Vec<u8> {
let mut out = Vec::new();
encode_value(value, &mut out);
out
}
/// Convenience: the type byte a value will be written with.
pub fn type_byte_of(value: &Value) -> u8 {
value.type_id().as_byte()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn members_sort_by_packed_tag_not_source_order() {
let s = Struct::new()
.with("ZZZZ", Value::Int(3))
.with("AAAA", Value::Int(1))
.with("MMMM", Value::Int(2));
let bytes = encode(&s);
// Each member is 3 tag + 1 type + 1 varint = 5 bytes; values must come
// out 1, 2, 3.
assert_eq!(bytes.len(), 15);
assert_eq!([bytes[4], bytes[9], bytes[14]], [1, 2, 3]);
}
#[test]
fn top_level_is_unterminated_but_nested_is_terminated() {
let flat = encode(&Struct::new().with("A", Value::Int(1)));
assert_eq!(
flat.last(),
Some(&1u8),
"no trailing terminator at top level"
);
let nested = encode(&Struct::new().with(
"OUTR",
Value::Struct(Struct::new().with("A", Value::Int(1))),
));
assert_eq!(
nested.last(),
Some(&0u8),
"nested struct is 0x00 terminated"
);
}
#[test]
fn empty_nested_struct_is_a_bare_terminator() {
let bytes = encode(&Struct::new().with("MTST", Value::Struct(Struct::new())));
assert_eq!(bytes.len(), 5); // 3 tag + 1 type + 1 terminator
assert_eq!(bytes[4], 0x00);
}
#[test]
fn string_length_includes_the_nul() {
let bytes = encode(&Struct::new().with("S", Value::String("ab".into())));
// tag(3) type(1) len(1)=3 'a' 'b' NUL
assert_eq!(&bytes[4..], &[0x03, b'a', b'b', 0x00]);
}
#[test]
fn empty_string_is_length_one_plus_nul() {
let bytes = encode(&Struct::new().with("S", Value::String(String::new())));
assert_eq!(&bytes[4..], &[0x01, 0x00]);
}
#[test]
fn blob_length_excludes_a_terminator() {
let bytes = encode(&Struct::new().with("B", Value::Blob(vec![1, 2, 3])));
assert_eq!(&bytes[4..], &[0x03, 1, 2, 3]);
}
#[test]
fn float_is_big_endian_f32() {
let bytes = encode(&Struct::new().with("F", Value::Float(1.5)));
assert_eq!(&bytes[4..], &1.5f32.to_be_bytes());
}
}
+27
View File
@@ -0,0 +1,27 @@
//! Heat2 TDF: EA Blaze's tagged binary serialisation.
//!
//! A field is `3-byte packed tag || 1 type byte || value`. Structs nest and are
//! `0x00` terminated; the outermost payload is delimited by the Fire2 length
//! instead.
//!
//! # Provenance
//!
//! Ported from `fifa17-recon/tools/heat2.py`, which was derived clean-room from
//! the wire bytes of our own FIFA 17 client and validated byte-exact against
//! that capture. The int/string/blob/struct layouts are proven; list, map,
//! union, varlist, objtype, objid and float are marked UNVERIFIED there and
//! that flag is preserved on [`TypeId::is_verified`].
//!
//! Byte-for-byte parity with the Python oracle is enforced by fixture tests
//! (`tests/oracle_parity.rs`) over vectors in `fixtures/tdf.jsonl`.
pub mod decode;
pub mod encode;
pub mod tag;
pub mod value;
pub mod varint;
pub use decode::decode;
pub use encode::{encode, encode_into};
pub use tag::Tag;
pub use value::{Struct, TypeId, Value, UNION_UNSET};
+101
View File
@@ -0,0 +1,101 @@
//! Heat2 field tags: four characters packed into three bytes.
//!
//! Each character contributes six bits (`(c - 0x20) & 0x3F`), concatenated
//! MSB-first. Labels shorter than four characters are space-padded, and code 0
//! decodes back to a space, so `"ENV"` survives a round trip as `"ENV"`.
//!
//! Evidence: byte-exact round trip against the FIFA 17 preAuth capture, via
//! `fifa17-recon/tools/heat2.py::encode_tag`.
use std::fmt;
/// A packed three-byte Heat2 tag.
///
/// Ordering is by packed bytes, which is exactly the member ordering Blaze
/// requires on the wire — so `sort()` on a slice of `Tag` is the wire rule.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Tag(pub [u8; 3]);
impl Tag {
/// Pack a label. Labels longer than four characters are TRUNCATED, not
/// rejected — matching the oracle. Note the hazard: `"LSTR"` and `"LSTR2"`
/// are the same wire tag, so they collide inside one struct.
pub fn from_label(label: &str) -> Tag {
let mut c = [0u8; 4];
for (i, ch) in label.chars().take(4).enumerate() {
// Non-ASCII cannot appear in a real tag; masking keeps this total
// instead of panicking on hostile input.
c[i] = ((ch as u32).wrapping_sub(0x20) & 0x3F) as u8;
}
Tag([
(c[0] << 2) | (c[1] >> 4),
((c[1] & 0x0F) << 4) | (c[2] >> 2),
((c[2] & 0x03) << 6) | c[3],
])
}
/// Unpack to a label with trailing padding stripped.
pub fn to_label(self) -> String {
let [a, b, c] = self.0;
let v = [
(a >> 2) & 0x3F,
((a & 0x03) << 4) | ((b >> 4) & 0x0F),
((b & 0x0F) << 2) | ((c >> 6) & 0x03),
c & 0x3F,
];
let s: String = v
.iter()
.map(|&x| if x == 0 { ' ' } else { (x + 0x20) as char })
.collect();
s.trim_end().to_string()
}
pub fn as_bytes(self) -> [u8; 3] {
self.0
}
}
impl fmt::Display for Tag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_label())
}
}
impl From<&str> for Tag {
fn from(s: &str) -> Tag {
Tag::from_label(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_common_labels() {
for label in ["CDAT", "CINF", "ENV", "A", "LOC", "BSDK", "PTVR"] {
assert_eq!(Tag::from_label(label).to_label(), label);
}
}
#[test]
fn packs_to_three_bytes_msb_first() {
// "ENV" pads to "ENV ": codes 0x25 0x2E 0x36 0x00.
assert_eq!(Tag::from_label("ENV").as_bytes(), [0x96, 0xed, 0x80]);
}
#[test]
fn ordering_is_packed_byte_order() {
let mut tags = [Tag::from("ZZZZ"), Tag::from("AAAA"), Tag::from("MMMM")];
tags.sort();
assert_eq!(
tags.map(|t| t.to_label()),
["AAAA".to_string(), "MMMM".to_string(), "ZZZZ".to_string()]
);
}
#[test]
fn over_long_labels_truncate_and_collide() {
assert_eq!(Tag::from("LSTR2"), Tag::from("LSTR"));
}
}
+258
View File
@@ -0,0 +1,258 @@
//! The Heat2 value model.
//!
//! Deliberately a *generic* Blaze value tree: it knows the eleven wire types
//! and nothing about any game. No FIFA 17 command IDs, field names, or response
//! schemas appear here or anywhere else in this crate — those belong to a game
//! adapter one layer up.
use super::tag::Tag;
/// The eleven Heat2 type bytes.
///
/// `Int` through `Struct` are validated against captured FIFA 17 traffic. The
/// rest are marked UNVERIFIED in the oracle (`heat2.py`): they are absent from
/// every capture we hold, and their layouts are consistent-with rather than
/// proven-against EA. `TypeId::is_verified` carries that distinction into code
/// so it cannot be lost by a reader who skips the comments.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u8)]
pub enum TypeId {
Int = 0x00,
String = 0x01,
Blob = 0x02,
Struct = 0x03,
List = 0x04,
Map = 0x05,
Union = 0x06,
VarList = 0x07,
ObjType = 0x08,
ObjId = 0x09,
Float = 0x0A,
}
/// Union discriminator meaning "no member set". UNVERIFIED.
pub const UNION_UNSET: u8 = 0x7F;
impl TypeId {
pub fn from_byte(b: u8) -> Option<TypeId> {
Some(match b {
0x00 => TypeId::Int,
0x01 => TypeId::String,
0x02 => TypeId::Blob,
0x03 => TypeId::Struct,
0x04 => TypeId::List,
0x05 => TypeId::Map,
0x06 => TypeId::Union,
0x07 => TypeId::VarList,
0x08 => TypeId::ObjType,
0x09 => TypeId::ObjId,
0x0A => TypeId::Float,
_ => return None,
})
}
pub fn as_byte(self) -> u8 {
self as u8
}
pub fn name(self) -> &'static str {
match self {
TypeId::Int => "int",
TypeId::String => "string",
TypeId::Blob => "blob",
TypeId::Struct => "struct",
TypeId::List => "list",
TypeId::Map => "map",
TypeId::Union => "union",
TypeId::VarList => "varlist",
TypeId::ObjType => "objtype",
TypeId::ObjId => "objid",
TypeId::Float => "float",
}
}
pub fn from_name(name: &str) -> Option<TypeId> {
Some(match name {
"int" => TypeId::Int,
"string" => TypeId::String,
"blob" => TypeId::Blob,
"struct" => TypeId::Struct,
"list" => TypeId::List,
"map" => TypeId::Map,
"union" => TypeId::Union,
"varlist" => TypeId::VarList,
"objtype" => TypeId::ObjType,
"objid" => TypeId::ObjId,
"float" => TypeId::Float,
_ => return None,
})
}
/// True when the layout is proven against captured FIFA 17 traffic.
///
/// A `false` here is a standing warning: the codec will round-trip such a
/// value against itself and against the Python oracle, and that still says
/// nothing about what a real Blaze server emits.
pub fn is_verified(self) -> bool {
matches!(
self,
TypeId::Int | TypeId::String | TypeId::Blob | TypeId::Struct
)
}
}
/// A decoded Heat2 value.
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Int(i64),
String(String),
Blob(Vec<u8>),
Struct(Struct),
List {
elem: TypeId,
items: Vec<Value>,
},
Map {
key: TypeId,
val: TypeId,
entries: Vec<(Value, Value)>,
},
Union {
key: u8,
member: Option<Box<(Tag, Value)>>,
},
VarList(Vec<i64>),
ObjType {
component: i64,
ty: i64,
},
ObjId {
component: i64,
ty: i64,
id: i64,
},
Float(f32),
}
impl Value {
pub fn type_id(&self) -> TypeId {
match self {
Value::Int(_) => TypeId::Int,
Value::String(_) => TypeId::String,
Value::Blob(_) => TypeId::Blob,
Value::Struct(_) => TypeId::Struct,
Value::List { .. } => TypeId::List,
Value::Map { .. } => TypeId::Map,
Value::Union { .. } => TypeId::Union,
Value::VarList(_) => TypeId::VarList,
Value::ObjType { .. } => TypeId::ObjType,
Value::ObjId { .. } => TypeId::ObjId,
Value::Float(_) => TypeId::Float,
}
}
pub fn as_int(&self) -> Option<i64> {
match self {
Value::Int(v) => Some(*v),
_ => None,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
Value::String(s) => Some(s),
_ => None,
}
}
pub fn as_struct(&self) -> Option<&Struct> {
match self {
Value::Struct(s) => Some(s),
_ => None,
}
}
}
/// An ordered set of tagged members.
///
/// A `Vec`, not a map: the wire format is a sequence, duplicate tags are
/// physically representable, and encoding has to sort by packed tag anyway.
/// Keeping the sequence means a decoded frame can be re-encoded byte-for-byte
/// even when it contains something a map would silently drop.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Struct {
pub fields: Vec<(Tag, Value)>,
}
impl Struct {
pub fn new() -> Struct {
Struct { fields: Vec::new() }
}
pub fn with(mut self, tag: &str, value: Value) -> Struct {
self.fields.push((Tag::from_label(tag), value));
self
}
pub fn push(&mut self, tag: &str, value: Value) {
self.fields.push((Tag::from_label(tag), value));
}
/// First member with this tag, if any.
pub fn get(&self, tag: &str) -> Option<&Value> {
let t = Tag::from_label(tag);
self.fields.iter().find(|(k, _)| *k == t).map(|(_, v)| v)
}
pub fn len(&self) -> usize {
self.fields.len()
}
pub fn is_empty(&self) -> bool {
self.fields.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &(Tag, Value)> {
self.fields.iter()
}
}
impl FromIterator<(Tag, Value)> for Struct {
fn from_iter<I: IntoIterator<Item = (Tag, Value)>>(iter: I) -> Struct {
Struct {
fields: iter.into_iter().collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn type_bytes_round_trip() {
for b in 0x00u8..=0x0A {
let t = TypeId::from_byte(b).expect("known type");
assert_eq!(t.as_byte(), b);
assert_eq!(TypeId::from_name(t.name()), Some(t));
}
assert_eq!(TypeId::from_byte(0x0B), None);
}
#[test]
fn only_capture_backed_types_claim_verification() {
assert!(TypeId::Int.is_verified());
assert!(TypeId::Struct.is_verified());
assert!(!TypeId::List.is_verified());
assert!(!TypeId::Float.is_verified());
}
#[test]
fn struct_lookup_finds_members() {
let s = Struct::new()
.with("PID", Value::Int(33068179))
.with("NAME", Value::String("x".into()));
assert_eq!(s.get("PID").and_then(Value::as_int), Some(33068179));
assert_eq!(s.get("NOPE"), None);
}
}
+144
View File
@@ -0,0 +1,144 @@
//! Heat2 varint.
//!
//! The first byte is the odd one: only six data bits (`0x3F`), with `0x40` as a
//! sign flag and `0x80` as "more". Every later byte is a conventional 7-bit
//! group. Groups are little-endian: byte 0 holds the low six bits, then seven
//! bits at shifts 6, 13, 20, ...
//!
//! Evidence: validated byte-exact against the FIFA 17 preAuth capture
//! (`LANG = 0x656E5553` encodes as `93 d5 f2 d6 0c`).
//!
//! The sign flag is UNVERIFIED — it never appears in any captured frame. It is
//! implemented to match the oracle so negatives round-trip, but no claim is
//! made that EA encodes negatives this way.
use crate::error::{Error, Result};
/// Append the canonical (shortest) encoding of `value`.
pub fn encode(value: i64, out: &mut Vec<u8>) {
let neg = value < 0;
// unsigned_abs, not -value: i64::MIN has no positive counterpart.
let mut v = value.unsigned_abs();
let mut first = (v & 0x3F) as u8;
v >>= 6;
if neg {
first |= 0x40;
}
if v == 0 {
out.push(first);
return;
}
out.push(first | 0x80);
while v >= 0x80 {
out.push(((v & 0x7F) as u8) | 0x80);
v >>= 7;
}
out.push(v as u8);
}
/// Decode at `pos`, returning the value and the index just past it.
pub fn decode(buf: &[u8], pos: usize) -> Result<(i64, usize)> {
let mut i = pos;
let b = *buf.get(i).ok_or(Error::Truncated {
what: "varint",
need: 1,
have: 0,
})?;
i += 1;
let mut val: u64 = (b & 0x3F) as u64;
let neg = b & 0x40 != 0;
if b & 0x80 != 0 {
let mut shift = 6u32;
loop {
let b = *buf.get(i).ok_or(Error::Truncated {
what: "varint continuation",
need: 1,
have: 0,
})?;
i += 1;
if shift >= 64 {
return Err(Error::VarintOverflow { at: pos });
}
val |= ((b & 0x7F) as u64) << shift;
shift += 7;
if b & 0x80 == 0 {
break;
}
}
}
let signed = if neg {
(val as i64).wrapping_neg()
} else {
val as i64
};
Ok((signed, i))
}
#[cfg(test)]
mod tests {
use super::*;
fn enc(v: i64) -> Vec<u8> {
let mut o = Vec::new();
encode(v, &mut o);
o
}
#[test]
fn single_byte_below_0x40() {
assert_eq!(enc(0), vec![0x00]);
assert_eq!(enc(1), vec![0x01]);
assert_eq!(enc(0x3F), vec![0x3F]);
}
#[test]
fn spills_to_a_second_group_at_0x40() {
// Six data bits in byte 0, so 0x40 is the first value that needs two.
assert_eq!(enc(0x40), vec![0x80, 0x01]);
}
#[test]
fn matches_the_captured_lang_field() {
assert_eq!(enc(0x656E5553), vec![0x93, 0xd5, 0xf2, 0xd6, 0x0c]);
}
#[test]
fn round_trips_boundaries() {
for v in [
0,
1,
0x3F,
0x40,
0x7F,
0x80,
0x1FFF,
0x2000,
0xFFFF_FFFF,
i64::MAX,
-1,
-300,
] {
let bytes = enc(v);
assert_eq!(decode(&bytes, 0).unwrap(), (v, bytes.len()), "value {v}");
}
}
#[test]
fn rejects_a_never_terminating_varint() {
let runaway = vec![0xFF; 32];
assert!(matches!(
decode(&runaway, 0),
Err(Error::VarintOverflow { .. })
));
}
#[test]
fn rejects_truncation() {
assert!(decode(&[0x80], 0).is_err());
assert!(decode(&[], 0).is_err());
}
}
+68
View File
@@ -0,0 +1,68 @@
//! # openfut-protocol-blaze
//!
//! Game-independent EA Blaze wire protocol: Fire2 framing and the Heat2/TDF
//! codec.
//!
//! ## What belongs here
//!
//! Only things that are true of Blaze itself:
//!
//! * [`fire2`] — the 16-byte frame header and frame/stream splitting
//! * [`heat2`] — tag packing, varints, and the eleven TDF value types
//! * [`message`] — a frame plus its decoded body, routed by numeric
//! component/command
//! * [`diagnostics`] — dumps for capture review
//!
//! ## What does not belong here
//!
//! Anything that would have to change for a different title: command and
//! component name tables, notification IDs, response schemas, login sequencing,
//! session identity. FIFA 17 says `0x0009/0x0007` is `Util::preAuth`; this
//! crate only knows it is component 9, command 7. A game adapter owns the rest.
//!
//! The test for a change landing in the right place: *could FIFA 18 or FIFA 23
//! use this without importing FIFA 17's command tables?* If not, it belongs in
//! an adapter.
//!
//! ## Provenance and confidence
//!
//! Ported from the Python implementation in `fifa17-recon/tools/` that drove a
//! retail FIFA 17 client from Origin login to an opened FUT pack — the
//! project's behavioural oracle. Parity is not asserted, it is tested: the
//! vectors in `fixtures/` are generated from that Python code and replayed
//! byte-for-byte by `tests/oracle_parity.rs`.
//!
//! Confidence is not uniform, and the code says so rather than leaving it in a
//! comment. Int, string, blob and struct layouts are proven against captured
//! traffic; list, map, union, varlist, objtype, objid and float are not, and
//! [`heat2::TypeId::is_verified`] reports which is which.
//!
//! ```
//! use openfut_protocol_blaze::fire2::{Frame, MsgType};
//! use openfut_protocol_blaze::heat2::{self, Struct, Value};
//!
//! let body = Struct::new()
//! .with("PID", Value::Int(33068179))
//! .with("NAME", Value::String("CAGE".into()));
//!
//! let frame = Frame::new(0x0001, 0x000A, 7, MsgType::Reply, heat2::encode(&body));
//! let wire = frame.encode();
//!
//! let (parsed, used) = Frame::parse(&wire).unwrap();
//! assert_eq!(used, wire.len());
//! assert_eq!(parsed.header.component, 0x0001);
//! assert_eq!(wire[13], 0x20); // msgType REPLY in the top 3 bits, userIndex 0
//! ```
#![forbid(unsafe_code)]
pub mod diagnostics;
pub mod error;
pub mod fire2;
pub mod heat2;
pub mod message;
pub use error::{Error, Result};
pub use fire2::{Frame, Header, MsgType};
pub use heat2::{Struct, Tag, TypeId, Value};
pub use message::Message;
+103
View File
@@ -0,0 +1,103 @@
//! A Blaze message: a Fire2 frame whose payload is decoded Heat2/TDF.
//!
//! This is the highest level this crate goes. It routes by *numbers*, never by
//! names: mapping `0x0009/0x0007` to `Util::preAuth`, or knowing which fields a
//! login reply must carry, is a per-title concern that belongs in a game
//! adapter. Keeping that out is what lets a second title reuse this layer
//! without inheriting FIFA 17's command tables.
use crate::error::Result;
use crate::fire2::{Frame, Header, MsgType};
use crate::heat2::{self, Struct};
/// A frame plus its decoded body.
#[derive(Debug, Clone, PartialEq)]
pub struct Message {
pub header: Header,
pub body: Struct,
}
impl Message {
pub fn new(
component: u16,
command: u16,
msg_num: u32,
msg_type: MsgType,
body: Struct,
) -> Message {
Message {
header: Header::new(component, command, msg_num, msg_type),
body,
}
}
/// Decode a frame's payload as TDF.
pub fn from_frame(frame: &Frame) -> Result<Message> {
Ok(Message {
header: frame.header,
body: heat2::decode(&frame.payload)?,
})
}
/// Re-frame this message.
///
/// Note this is not guaranteed byte-identical to the frame a `Message` was
/// decoded from: encoding sorts members by packed tag, so a peer that sent
/// them out of order would see its ordering normalised. For byte-exact
/// round trips (capture replay, differential tests) keep the [`Frame`] and
/// its raw payload.
pub fn to_frame(&self) -> Frame {
let payload = heat2::encode(&self.body);
let mut frame = Frame::new(
self.header.component,
self.header.command,
self.header.msg_num,
self.header.msg_type,
payload,
);
frame.header.user_index = self.header.user_index;
frame.header.options = self.header.options;
frame
}
pub fn encode(&self) -> Vec<u8> {
self.to_frame().encode()
}
/// Component and command as a pair — the key an adapter dispatches on.
pub fn route(&self) -> (u16, u16) {
(self.header.component, self.header.command)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::heat2::Value;
#[test]
fn decodes_a_frame_body_and_reframes_it() {
let body = Struct::new()
.with("PID", Value::Int(33068179))
.with("NAME", Value::String("CAGE".into()));
let msg = Message::new(0x0001, 0x000A, 7, MsgType::Reply, body.clone());
let bytes = msg.encode();
let (frame, used) = Frame::parse(&bytes).unwrap();
assert_eq!(used, bytes.len());
let back = Message::from_frame(&frame).unwrap();
assert_eq!(back.route(), (0x0001, 0x000A));
assert_eq!(back.header.msg_type, MsgType::Reply);
assert_eq!(back.body.get("NAME").and_then(Value::as_str), Some("CAGE"));
assert_eq!(back.encode(), bytes);
}
#[test]
fn an_empty_body_is_a_valid_message() {
let msg = Message::new(0x0009, 0x0002, 1, MsgType::Reply, Struct::new());
let (frame, _) = Frame::parse(&msg.encode()).unwrap();
assert_eq!(frame.header.payload_len, 0);
assert!(Message::from_frame(&frame).unwrap().body.is_empty());
}
}
@@ -0,0 +1,432 @@
//! Differential tests against the proven Python backend.
//!
//! The Python responders in `fifa17-recon/tools/` are the behavioural oracle:
//! they are what actually walked a retail FIFA 17 client from Origin login to
//! an opened FUT pack. This crate may only claim to replace them if it produces
//! *the same bytes*, and that claim has to be re-checkable without a FIFA
//! client in the loop.
//!
//! `fixtures/*.jsonl` are generated by `fixtures/generate.py` from that Python
//! code. Each vector carries both the input tree and the bytes the oracle
//! produced. These tests rebuild the tree in Rust, encode it, and require an
//! exact match.
//!
//! Comparison is **byte-for-byte**, not semantic. These are wire formats read
//! by a game binary that hard-freezes on a shape it does not expect, so
//! "equivalent" is not a category that exists here. (Semantic comparison is the
//! right call one layer up, at UTAS/JSON, where key order genuinely does not
//! matter — see `fifa17-recon/tools/test_fut_contract.py`.)
//!
//! Regenerate after any oracle change: python3 fixtures/generate.py
use openfut_protocol_blaze::fire2::{Frame, Header, MsgType};
use openfut_protocol_blaze::heat2::{self, Struct, Tag, TypeId, Value};
use serde_json::Value as J;
fn load(name: &str) -> Vec<J> {
let path = format!("{}/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("cannot read {path}: {e}\nrun: python3 fixtures/generate.py"));
text.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| serde_json::from_str(l).expect("fixture line is valid JSON"))
.collect()
}
fn unhex(s: &str) -> Vec<u8> {
assert!(s.len().is_multiple_of(2), "odd-length hex");
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex"))
.collect()
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn type_of(name: &str) -> TypeId {
TypeId::from_name(name).unwrap_or_else(|| panic!("unknown type name {name:?}"))
}
/// Rebuild a `Value` from the generator's JSON encoding.
fn to_value(j: &J) -> Value {
let t = j["t"].as_str().expect("value has a type tag");
match t {
"int" => Value::Int(j["v"].as_i64().expect("int fits i64")),
"string" => Value::String(j["v"].as_str().expect("string").to_string()),
"blob" => Value::Blob(unhex(j["v"].as_str().expect("blob hex"))),
"struct" => Value::Struct(to_struct(&j["v"])),
"list" => Value::List {
elem: type_of(j["elem"].as_str().expect("elem type")),
items: j["v"]
.as_array()
.expect("list")
.iter()
.map(to_value)
.collect(),
},
"map" => Value::Map {
key: type_of(j["key"].as_str().expect("key type")),
val: type_of(j["val"].as_str().expect("val type")),
entries: j["v"]
.as_array()
.expect("map entries")
.iter()
.map(|pair| {
let p = pair.as_array().expect("k/v pair");
(to_value(&p[0]), to_value(&p[1]))
})
.collect(),
},
"union" => Value::Union {
key: j["key"].as_u64().expect("union key") as u8,
member: match j.get("member") {
Some(J::Null) | None => None,
Some(m) => Some(Box::new((
Tag::from_label(m["tag"].as_str().expect("member tag")),
to_value(&m["value"]),
))),
},
},
"varlist" => Value::VarList(
j["v"]
.as_array()
.expect("varlist")
.iter()
.map(|n| n.as_i64().expect("varlist int"))
.collect(),
),
"objtype" => {
let a = j["v"].as_array().expect("objtype pair");
Value::ObjType {
component: a[0].as_i64().unwrap(),
ty: a[1].as_i64().unwrap(),
}
}
"objid" => {
let a = j["v"].as_array().expect("objid triple");
Value::ObjId {
component: a[0].as_i64().unwrap(),
ty: a[1].as_i64().unwrap(),
id: a[2].as_i64().unwrap(),
}
}
"float" => Value::Float(j["v"].as_f64().expect("float") as f32),
other => panic!("unhandled fixture type {other:?}"),
}
}
fn to_struct(j: &J) -> Struct {
let mut s = Struct::new();
for entry in j.as_array().expect("struct is a list of [tag, value]") {
let pair = entry.as_array().expect("[tag, value]");
s.fields.push((
Tag::from_label(pair[0].as_str().expect("tag label")),
to_value(&pair[1]),
));
}
s
}
// ───────────────────────────────── Heat2 / TDF ─────────────────────────────────
/// Encoding a fixture's tree must reproduce the oracle's bytes exactly.
#[test]
fn tdf_encode_matches_python_oracle_byte_for_byte() {
let vectors = load("tdf.jsonl");
assert!(vectors.len() >= 30, "fixture set looks truncated");
for v in &vectors {
let name = v["name"].as_str().unwrap();
let expected = unhex(v["encoded_hex"].as_str().unwrap());
let actual = heat2::encode(&to_struct(&v["fields"]));
assert_eq!(
hex(&actual),
hex(&expected),
"\n{name}: Rust encoding differs from the Python oracle"
);
}
}
/// Decoding the oracle's bytes and re-encoding must be a fixed point. This is
/// what proves the decoder agrees with the encoder on every field, not just
/// that both are self-consistent.
#[test]
fn tdf_decode_then_reencode_is_byte_identical() {
for v in load("tdf.jsonl") {
let name = v["name"].as_str().unwrap();
let bytes = unhex(v["encoded_hex"].as_str().unwrap());
let decoded =
heat2::decode(&bytes).unwrap_or_else(|e| panic!("{name}: decode failed: {e}"));
assert_eq!(
hex(&heat2::encode(&decoded)),
hex(&bytes),
"\n{name}: decode/re-encode is not a fixed point"
);
}
}
/// Sort struct members by packed tag at every depth.
///
/// The recorded tree is in the oracle's source order; the wire is in packed-tag
/// order, and that reordering applies to nested structs too. Normalising both
/// sides compares the content while ignoring the ordering the wire imposes —
/// the byte-level tests above are what pin the ordering itself.
fn normalized(value: &Value) -> Value {
match value {
Value::Struct(s) => Value::Struct(normalized_struct(s)),
Value::List { elem, items } => Value::List {
elem: *elem,
items: items.iter().map(normalized).collect(),
},
Value::Map { key, val, entries } => Value::Map {
key: *key,
val: *val,
entries: entries
.iter()
.map(|(k, v)| (normalized(k), normalized(v)))
.collect(),
},
Value::Union { key, member } => Value::Union {
key: *key,
member: member.as_ref().map(|m| Box::new((m.0, normalized(&m.1)))),
},
other => other.clone(),
}
}
fn normalized_struct(s: &Struct) -> Struct {
let mut fields: Vec<_> = s.iter().map(|(t, v)| (*t, normalized(v))).collect();
fields.sort_by_key(|(t, _)| *t);
Struct { fields }
}
/// Decoding must also reproduce the same tree the generator recorded, not just
/// bytes that happen to re-encode the same way.
#[test]
fn tdf_decode_reproduces_the_recorded_tree() {
for v in load("tdf.jsonl") {
let name = v["name"].as_str().unwrap();
let bytes = unhex(v["encoded_hex"].as_str().unwrap());
let decoded = normalized_struct(&heat2::decode(&bytes).unwrap());
let expected = normalized_struct(&to_struct(&v["fields"]));
assert_eq!(
decoded.len(),
expected.len(),
"\n{name}: field count differs"
);
for ((gt, gv), (wt, wv)) in decoded.iter().zip(expected.iter()) {
assert_eq!(gt, wt, "\n{name}: tag mismatch");
// Over-long labels truncate to the same wire tag, so compare the
// decoded value, which is what the wire actually carries.
assert_eq!(gv, wv, "\n{name}: value mismatch under tag {gt}");
}
}
}
/// The live vectors are the ones with real evidence behind them; if the
/// generator ever stops emitting them the suite would still pass while proving
/// much less.
#[test]
fn live_vectors_are_present_and_substantial() {
let vectors = load("tdf.jsonl");
let live: Vec<_> = vectors.iter().filter(|v| v["origin"] == "live").collect();
assert!(
live.len() >= 20,
"expected the responder's payload builders to be captured; got {}",
live.len()
);
// preAuth is the first RPC FIFA 17 sends and the largest proven payload;
// it is the single most valuable vector in the set.
let preauth = vectors
.iter()
.find(|v| v["name"] == "live/preauth_response")
.expect("preauth vector present");
assert!(
unhex(preauth["encoded_hex"].as_str().unwrap()).len() > 1000,
"preauth payload is suspiciously small"
);
}
/// Vectors whose layout the oracle marks UNVERIFIED must stay marked. This test
/// exists so that "Rust and Python agree" is never quietly read as "this is how
/// EA does it".
#[test]
fn unverified_layouts_remain_flagged() {
let vectors = load("tdf.jsonl");
for name in ["synthetic/lists", "synthetic/maps", "synthetic/unions"] {
let v = vectors
.iter()
.find(|v| v["name"] == name)
.unwrap_or_else(|| panic!("{name} missing"));
assert_eq!(v["verified"], false, "{name} must stay flagged UNVERIFIED");
}
assert!(!TypeId::List.is_verified());
assert!(!TypeId::Map.is_verified());
assert!(!TypeId::Union.is_verified());
}
// ─────────────────────────────────── Fire2 ────────────────────────────────────
/// Building a frame must reproduce the oracle's bytes exactly — header layout,
/// field offsets, and the msgType/userIndex packing in byte 13.
#[test]
fn fire2_encode_matches_python_oracle_byte_for_byte() {
let vectors = load("fire2.jsonl");
assert!(vectors.len() >= 15, "fixture set looks truncated");
for v in &vectors {
let name = v["name"].as_str().unwrap();
let expected = unhex(v["frame_hex"].as_str().unwrap());
let frame = Frame::new(
v["component"].as_u64().unwrap() as u16,
v["command"].as_u64().unwrap() as u16,
v["msg_num"].as_u64().unwrap() as u32,
MsgType::from_bits(v["msg_type"].as_u64().unwrap() as u8),
unhex(v["payload_hex"].as_str().unwrap()),
)
.with_metadata(unhex(v["metadata_hex"].as_str().unwrap()))
.with_user_index(v["user_index"].as_u64().unwrap() as u8)
.with_options(v["options"].as_u64().unwrap() as u8);
let actual = frame.encode();
assert_eq!(
actual.len(),
expected.len(),
"\n{name}: frame length differs"
);
assert_eq!(
hex(&actual[..16]),
hex(&expected[..16]),
"\n{name}: HEADER differs from the oracle"
);
assert_eq!(actual, expected, "\n{name}: frame body differs");
}
}
/// Parsing the oracle's frames must recover every header field.
#[test]
fn fire2_parse_recovers_every_header_field() {
for v in load("fire2.jsonl") {
let name = v["name"].as_str().unwrap();
let bytes = unhex(v["frame_hex"].as_str().unwrap());
let (frame, used) =
Frame::parse(&bytes).unwrap_or_else(|e| panic!("{name}: parse failed: {e}"));
assert_eq!(used, bytes.len(), "\n{name}: consumed length differs");
let h = &frame.header;
assert_eq!(
h.component as u64,
v["component"].as_u64().unwrap(),
"{name} component"
);
assert_eq!(
h.command as u64,
v["command"].as_u64().unwrap(),
"{name} command"
);
assert_eq!(
h.msg_num as u64,
v["msg_num"].as_u64().unwrap(),
"{name} msg_num"
);
assert_eq!(
h.msg_type.as_bits() as u64,
v["msg_type"].as_u64().unwrap(),
"{name} msg_type"
);
assert_eq!(
h.user_index as u64,
v["user_index"].as_u64().unwrap(),
"{name} user_index"
);
assert_eq!(
h.options as u64,
v["options"].as_u64().unwrap(),
"{name} options"
);
assert_eq!(
hex(&frame.payload),
v["payload_hex"].as_str().unwrap(),
"{name} payload"
);
assert_eq!(
hex(&frame.metadata),
v["metadata_hex"].as_str().unwrap(),
"{name} metadata"
);
assert_eq!(frame.encode(), bytes, "\n{name}: re-encode differs");
}
}
/// The live frames carry real TDF bodies; those must decode and re-encode
/// cleanly. This is the end-to-end check: framing and codec together, on the
/// exact bytes FIFA 17 accepted.
#[test]
fn live_frames_decode_as_tdf_and_survive_a_round_trip() {
let mut checked = 0;
for v in load("fire2.jsonl") {
if v["origin"] != "live" {
continue;
}
let name = v["name"].as_str().unwrap();
let bytes = unhex(v["frame_hex"].as_str().unwrap());
let (frame, _) = Frame::parse(&bytes).unwrap();
let body = heat2::decode(&frame.payload)
.unwrap_or_else(|e| panic!("{name}: live payload is not valid TDF: {e}"));
assert!(!body.is_empty(), "{name}: live payload decoded to nothing");
assert_eq!(
hex(&heat2::encode(&body)),
hex(&frame.payload),
"\n{name}: live payload does not survive a codec round trip"
);
checked += 1;
}
assert!(
checked >= 5,
"expected several live frames, checked {checked}"
);
}
/// A frame larger than 64 KiB must work. The superseded 12-byte implementation
/// in `fifa-blaze` carried a `u16` length plus a JUMBO escape flag; Fire2's
/// length is a plain `u32`, and the oracle emits no flag. This vector is the
/// direct refutation of that assumption.
#[test]
fn payload_over_64kib_needs_no_jumbo_flag() {
let vectors = load("fire2.jsonl");
let v = vectors
.iter()
.find(|v| v["name"] == "synthetic/large_payload")
.expect("large payload vector present");
let bytes = unhex(v["frame_hex"].as_str().unwrap());
let header = Header::parse(&bytes).unwrap();
assert!(header.payload_len > u16::MAX as u32);
assert_eq!(bytes.len(), 16 + header.payload_len as usize);
assert_eq!(header.options, 0, "no option flag is set for a large frame");
}
/// Frames arrive coalesced on a real socket; splitting them must work on the
/// oracle's actual bytes, not just synthetic ones.
#[test]
fn coalesced_live_frames_split_correctly() {
let live: Vec<Vec<u8>> = load("fire2.jsonl")
.iter()
.filter(|v| v["origin"] == "live")
.map(|v| unhex(v["frame_hex"].as_str().unwrap()))
.collect();
let stream: Vec<u8> = live.concat();
let (frames, used) = openfut_protocol_blaze::fire2::parse_all(&stream).unwrap();
assert_eq!(frames.len(), live.len());
assert_eq!(used, stream.len());
for (got, want) in frames.iter().zip(live.iter()) {
assert_eq!(&got.encode(), want);
}
}