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:
funman300
2026-08-11 00:53:59 +00:00
parent 3153a93edf
commit a9a816e0ed
19 changed files with 2982 additions and 0 deletions
+258
View File
@@ -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);
}
}