//! 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 { 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 { 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> = load("fire2.jsonl") .iter() .filter(|v| v["origin"] == "live") .map(|v| unhex(v["frame_hex"].as_str().unwrap())) .collect(); let stream: Vec = 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); } }