a9a816e0ed
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>
433 lines
16 KiB
Rust
433 lines
16 KiB
Rust
//! 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);
|
|
}
|
|
}
|