Files
OpenFUT/openfut-protocol-blaze/tests/oracle_parity.rs
T
funman300 a9a816e0ed 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>
2026-08-11 00:53:59 +00:00

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);
}
}