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>
259 lines
7.0 KiB
Rust
259 lines
7.0 KiB
Rust
//! 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);
|
|
}
|
|
}
|