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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user