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>
417 lines
14 KiB
Rust
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);
|
|
}
|
|
}
|