Files
OpenFUT/openfut-protocol-blaze/src/fire2.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

417 lines
14 KiB
Rust

//! Fire2 framing.
//!
//! ```text
//! [0:4] u32 payload length
//! [4:6] u16 metadata length
//! [6:8] u16 component
//! [8:10] u16 command (notification id when msgType == NOTIFICATION)
//! [10:13] u24 msgNum
//! [13] u8 (msgType << 5) | (userIndex & 0x1F)
//! [14] u8 options
//! [15] u8 reserved
//!
//! wire = header(16) || metadata || payload
//! ```
//!
//! All fields big-endian. Sixteen bytes, not twelve.
//!
//! # Two wrong layouts this replaces
//!
//! This is the layout in `blaze_responder_v3b.py::fire2` — the code that
//! actually drove a retail FIFA 17 client through login. Two other
//! implementations in this repository disagree and are both wrong for FIFA 17:
//!
//! * `fifa17-recon/tools/heat2.py::build_fire2_frame` packs
//! `>IHHHHB3s`, which puts a `u16 msgId` at `[10:12]` and `msgType` at
//! `[12]`. Its own module docstring still describes that layout. The
//! responder carries a comment telling callers not to use it.
//! * `fifa-blaze/crates/blaze-proto/src/frame.rs` implements a *12-byte*
//! header with a `u16` length, nibble-packed type/options, and a JUMBO
//! flag. That was a documented guess at FIFA 23's variant, written before
//! the FIFA 17 recon; nothing has since validated it.
//!
//! There is **no error field** in a Fire2 header — that belongs to Fire v1's
//! 12-byte frame. There is also no jumbo-frame escape: the length is already a
//! full `u32`.
use crate::error::{Error, Result};
/// Header size in bytes.
pub const HEADER_LEN: usize = 16;
/// `msgType`, the top three bits of byte 13.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MsgType {
Message,
Reply,
Notification,
ErrorReply,
Ping,
PingReply,
/// 6 and 7 are representable in three bits but have never been observed.
Unknown(u8),
}
impl MsgType {
pub fn from_bits(bits: u8) -> MsgType {
match bits & 0x07 {
0 => MsgType::Message,
1 => MsgType::Reply,
2 => MsgType::Notification,
3 => MsgType::ErrorReply,
4 => MsgType::Ping,
5 => MsgType::PingReply,
other => MsgType::Unknown(other),
}
}
pub fn as_bits(self) -> u8 {
match self {
MsgType::Message => 0,
MsgType::Reply => 1,
MsgType::Notification => 2,
MsgType::ErrorReply => 3,
MsgType::Ping => 4,
MsgType::PingReply => 5,
MsgType::Unknown(v) => v & 0x07,
}
}
pub fn name(self) -> &'static str {
match self {
MsgType::Message => "MESSAGE",
MsgType::Reply => "REPLY",
MsgType::Notification => "NOTIFICATION",
MsgType::ErrorReply => "ERROR_REPLY",
MsgType::Ping => "PING",
MsgType::PingReply => "PING_REPLY",
MsgType::Unknown(_) => "UNKNOWN",
}
}
}
/// A parsed Fire2 header.
///
/// `component`/`command` are plain integers on purpose. Naming them is a
/// game-specific concern: a FIFA 17 adapter owns the tables that turn
/// `0x0009/0x0007` into `Util::preAuth`, and a future FIFA 18 or FIFA 23
/// adapter may map the same numbers differently.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
pub payload_len: u32,
pub metadata_len: u16,
pub component: u16,
pub command: u16,
/// 24-bit request correlator.
pub msg_num: u32,
pub msg_type: MsgType,
/// 5-bit local user slot.
pub user_index: u8,
pub options: u8,
pub reserved: u8,
}
impl Header {
pub fn new(component: u16, command: u16, msg_num: u32, msg_type: MsgType) -> Header {
Header {
payload_len: 0,
metadata_len: 0,
component,
command,
msg_num,
msg_type,
user_index: 0,
options: 0,
reserved: 0,
}
}
/// Total wire size of this frame: header + metadata + payload.
pub fn frame_len(&self) -> usize {
HEADER_LEN + self.metadata_len as usize + self.payload_len as usize
}
/// Parse a header from the first 16 bytes of `buf`.
pub fn parse(buf: &[u8]) -> Result<Header> {
if buf.len() < HEADER_LEN {
return Err(Error::ShortFrame {
need: HEADER_LEN,
have: buf.len(),
});
}
Ok(Header {
payload_len: u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]),
metadata_len: u16::from_be_bytes([buf[4], buf[5]]),
component: u16::from_be_bytes([buf[6], buf[7]]),
command: u16::from_be_bytes([buf[8], buf[9]]),
msg_num: ((buf[10] as u32) << 16) | ((buf[11] as u32) << 8) | buf[12] as u32,
msg_type: MsgType::from_bits(buf[13] >> 5),
user_index: buf[13] & 0x1F,
options: buf[14],
reserved: buf[15],
})
}
/// Serialise the 16 header bytes.
pub fn write(&self, out: &mut Vec<u8>) {
out.extend_from_slice(&self.payload_len.to_be_bytes());
out.extend_from_slice(&self.metadata_len.to_be_bytes());
out.extend_from_slice(&self.component.to_be_bytes());
out.extend_from_slice(&self.command.to_be_bytes());
out.push(((self.msg_num >> 16) & 0xFF) as u8);
out.push(((self.msg_num >> 8) & 0xFF) as u8);
out.push((self.msg_num & 0xFF) as u8);
out.push((self.msg_type.as_bits() << 5) | (self.user_index & 0x1F));
out.push(self.options);
out.push(self.reserved);
}
pub fn to_bytes(&self) -> [u8; HEADER_LEN] {
let mut v = Vec::with_capacity(HEADER_LEN);
self.write(&mut v);
let mut out = [0u8; HEADER_LEN];
out.copy_from_slice(&v);
out
}
}
/// A complete Fire2 frame: header plus its metadata and payload bytes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame {
pub header: Header,
pub metadata: Vec<u8>,
/// Raw payload bytes. Undecoded on purpose — the payload is Heat2/TDF, but
/// framing must stay usable for capture and diagnostics even when the body
/// does not parse.
pub payload: Vec<u8>,
}
impl Frame {
/// Build a frame, deriving the length fields from the buffers.
pub fn new(
component: u16,
command: u16,
msg_num: u32,
msg_type: MsgType,
payload: Vec<u8>,
) -> Frame {
let mut header = Header::new(component, command, msg_num, msg_type);
header.payload_len = payload.len() as u32;
Frame {
header,
metadata: Vec::new(),
payload,
}
}
pub fn with_metadata(mut self, metadata: Vec<u8>) -> Frame {
self.header.metadata_len = metadata.len() as u16;
self.metadata = metadata;
self
}
pub fn with_user_index(mut self, user_index: u8) -> Frame {
self.header.user_index = user_index & 0x1F;
self
}
pub fn with_options(mut self, options: u8) -> Frame {
self.header.options = options;
self
}
/// A reply echoes component, command, msgNum and userIndex verbatim, and
/// changes only the msgType bits.
pub fn reply_to(request: &Header, payload: Vec<u8>) -> Frame {
let mut frame = Frame::new(
request.component,
request.command,
request.msg_num,
MsgType::Reply,
payload,
);
frame.header.user_index = request.user_index;
frame
}
/// An unsolicited server push. `msgNum` is 0: notifications are not
/// correlated to any request.
pub fn notification(component: u16, notify_id: u16, payload: Vec<u8>) -> Frame {
Frame::new(component, notify_id, 0, MsgType::Notification, payload)
}
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(self.wire_len());
self.encode_into(&mut out);
out
}
pub fn encode_into(&self, out: &mut Vec<u8>) {
// Trust the buffers over any stale length in the header.
let mut header = self.header;
header.payload_len = self.payload.len() as u32;
header.metadata_len = self.metadata.len() as u16;
header.write(out);
out.extend_from_slice(&self.metadata);
out.extend_from_slice(&self.payload);
}
pub fn wire_len(&self) -> usize {
HEADER_LEN + self.metadata.len() + self.payload.len()
}
/// Parse one frame from the front of `buf`, returning it and the number of
/// bytes consumed.
pub fn parse(buf: &[u8]) -> Result<(Frame, usize)> {
let header = Header::parse(buf)?;
let total = header.frame_len();
if buf.len() < total {
return Err(Error::ShortFrame {
need: total,
have: buf.len(),
});
}
let meta_end = HEADER_LEN + header.metadata_len as usize;
Ok((
Frame {
header,
metadata: buf[HEADER_LEN..meta_end].to_vec(),
payload: buf[meta_end..total].to_vec(),
},
total,
))
}
}
/// How many bytes a frame starting at `buf` needs in total, if its header is
/// complete. `None` means the header itself has not arrived yet.
///
/// This is what a stream reader needs: Blaze frames arrive coalesced and split
/// across TCP segments, so a reader must size each frame before consuming it.
pub fn frame_size_hint(buf: &[u8]) -> Option<usize> {
Header::parse(buf).ok().map(|h| h.frame_len())
}
/// Split a buffer into as many whole frames as it contains.
///
/// Returns the frames plus the number of bytes consumed; any trailing partial
/// frame is left for the caller to retry once more bytes arrive.
pub fn parse_all(buf: &[u8]) -> Result<(Vec<Frame>, usize)> {
let mut frames = Vec::new();
let mut off = 0;
while off < buf.len() {
match Frame::parse(&buf[off..]) {
Ok((frame, used)) => {
off += used;
frames.push(frame);
}
Err(Error::ShortFrame { .. }) => break,
Err(e) => return Err(e),
}
}
Ok((frames, off))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn header_is_sixteen_bytes() {
let h = Header::new(0x0009, 0x0007, 1, MsgType::Reply);
assert_eq!(h.to_bytes().len(), 16);
}
#[test]
fn field_offsets_match_the_proven_layout() {
let mut h = Header::new(0x0009, 0x0007, 0x123456, MsgType::Reply);
h.payload_len = 0x11223344;
h.metadata_len = 0x5566;
h.user_index = 3;
h.options = 0x42;
let b = h.to_bytes();
assert_eq!(&b[0..4], &[0x11, 0x22, 0x33, 0x44]); // payload len u32
assert_eq!(&b[4..6], &[0x55, 0x66]); // metadata len u16
assert_eq!(&b[6..8], &[0x00, 0x09]); // component
assert_eq!(&b[8..10], &[0x00, 0x07]); // command
assert_eq!(&b[10..13], &[0x12, 0x34, 0x56]); // msgNum u24
assert_eq!(b[13], (1 << 5) | 3); // msgType | userIndex
assert_eq!(b[14], 0x42);
assert_eq!(b[15], 0x00);
}
#[test]
fn reply_bit_pattern_is_0x20_and_notification_is_0x40() {
let reply = Frame::new(1, 0x0A, 7, MsgType::Reply, vec![]).encode();
assert_eq!(reply[13], 0x20);
let notify = Frame::notification(0x7802, 0x0008, vec![]).encode();
assert_eq!(notify[13], 0x40);
}
#[test]
fn payload_length_exceeds_sixteen_bits_without_a_jumbo_flag() {
// The stale 12-byte implementation would have needed an escape here.
let frame = Frame::new(1, 1, 1, MsgType::Reply, vec![0x5A; 70_000]);
let bytes = frame.encode();
let parsed = Header::parse(&bytes).unwrap();
assert_eq!(parsed.payload_len, 70_000);
assert_eq!(bytes.len(), 16 + 70_000);
}
#[test]
fn round_trips_with_metadata() {
let frame = Frame::new(0x0009, 0x0007, 5, MsgType::Message, b"payload".to_vec())
.with_metadata(vec![0xde, 0xad, 0xbe, 0xef]);
let bytes = frame.encode();
let (back, used) = Frame::parse(&bytes).unwrap();
assert_eq!(used, bytes.len());
assert_eq!(back.metadata, vec![0xde, 0xad, 0xbe, 0xef]);
assert_eq!(back.payload, b"payload");
assert_eq!(back.encode(), bytes);
}
#[test]
fn reply_echoes_routing_fields() {
let req = Header {
user_index: 5,
..Header::new(0x0001, 0x000A, 0x2222, MsgType::Message)
};
let reply = Frame::reply_to(&req, vec![1, 2, 3]);
assert_eq!(reply.header.component, 0x0001);
assert_eq!(reply.header.command, 0x000A);
assert_eq!(reply.header.msg_num, 0x2222);
assert_eq!(reply.header.user_index, 5);
assert_eq!(reply.header.msg_type, MsgType::Reply);
}
#[test]
fn msg_num_is_twenty_four_bits() {
let frame = Frame::new(1, 1, 0xFFFFFF, MsgType::Reply, vec![]);
let back = Header::parse(&frame.encode()).unwrap();
assert_eq!(back.msg_num, 0xFFFFFF);
}
#[test]
fn splits_coalesced_frames_and_leaves_a_partial_tail() {
let a = Frame::new(9, 2, 1, MsgType::Reply, vec![1]).encode();
let b = Frame::new(1, 10, 2, MsgType::Reply, vec![2, 3]).encode();
let mut stream = a.clone();
stream.extend_from_slice(&b);
stream.extend_from_slice(&[0x00, 0x00]); // partial third header
let (frames, used) = parse_all(&stream).unwrap();
assert_eq!(frames.len(), 2);
assert_eq!(used, a.len() + b.len());
assert_eq!(frames[1].payload, vec![2, 3]);
}
#[test]
fn short_buffer_reports_what_it_needs() {
assert_eq!(
Header::parse(&[0u8; 8]),
Err(Error::ShortFrame { need: 16, have: 8 })
);
assert_eq!(frame_size_hint(&[0u8; 8]), None);
}
}