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,202 @@
|
||||
//! Human-readable dumps for debugging and capture review.
|
||||
//!
|
||||
//! Diagnostics only: nothing here is part of the wire contract, and no output
|
||||
//! format should ever be parsed back.
|
||||
|
||||
use crate::fire2::{Frame, Header};
|
||||
use crate::heat2::{Struct, Value};
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// Render a TDF struct as an indented tree, mirroring `heat2.py::dump`.
|
||||
pub fn dump_struct(s: &Struct) -> String {
|
||||
let mut out = String::new();
|
||||
write_struct(s, 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn write_struct(s: &Struct, depth: usize, out: &mut String) {
|
||||
for (tag, value) in s.iter() {
|
||||
let pad = " ".repeat(depth);
|
||||
let _ = write!(out, "{pad}{tag} ({})", value.type_id().name());
|
||||
match value {
|
||||
Value::Struct(inner) => {
|
||||
let _ = writeln!(out, " {{");
|
||||
write_struct(inner, depth + 1, out);
|
||||
let _ = writeln!(out, "{pad}}}");
|
||||
}
|
||||
Value::List { elem, items } => {
|
||||
let _ = writeln!(out, " [{} x {}]", elem.name(), items.len());
|
||||
for item in items {
|
||||
write_value(item, depth + 1, out);
|
||||
}
|
||||
}
|
||||
Value::Map { key, val, entries } => {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {{{} -> {} x {}}}",
|
||||
key.name(),
|
||||
val.name(),
|
||||
entries.len()
|
||||
);
|
||||
for (k, v) in entries {
|
||||
write_value(k, depth + 1, out);
|
||||
write_value(v, depth + 2, out);
|
||||
}
|
||||
}
|
||||
other => {
|
||||
let _ = writeln!(out, " = {}", scalar(other));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_value(value: &Value, depth: usize, out: &mut String) {
|
||||
let pad = " ".repeat(depth);
|
||||
match value {
|
||||
Value::Struct(inner) => {
|
||||
let _ = writeln!(out, "{pad}{{");
|
||||
write_struct(inner, depth + 1, out);
|
||||
let _ = writeln!(out, "{pad}}}");
|
||||
}
|
||||
other => {
|
||||
let _ = writeln!(out, "{pad}{}", scalar(other));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Int(v) => format!("{v}"),
|
||||
Value::String(s) => format!("{s:?}"),
|
||||
Value::Blob(b) => format!("<{} bytes> {}", b.len(), hex(&b[..b.len().min(32)])),
|
||||
Value::VarList(v) => format!("{v:?}"),
|
||||
Value::ObjType { component, ty } => format!("({component}, {ty})"),
|
||||
Value::ObjId { component, ty, id } => format!("({component}, {ty}, {id})"),
|
||||
Value::Float(f) => format!("{f}"),
|
||||
Value::Union { key, member } => match member {
|
||||
Some(m) => format!("union[{key}] {} = {}", m.0, scalar(&m.1)),
|
||||
None => format!("union[{key}] unset"),
|
||||
},
|
||||
Value::Struct(_) | Value::List { .. } | Value::Map { .. } => String::from("..."),
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line summary of a Fire2 header.
|
||||
pub fn describe_header(h: &Header) -> String {
|
||||
format!(
|
||||
"component=0x{:04x} command=0x{:04x} {} msgNum={} userIdx={} opts=0x{:02x} \
|
||||
payload={}B metadata={}B",
|
||||
h.component,
|
||||
h.command,
|
||||
h.msg_type.name(),
|
||||
h.msg_num,
|
||||
h.user_index,
|
||||
h.options,
|
||||
h.payload_len,
|
||||
h.metadata_len
|
||||
)
|
||||
}
|
||||
|
||||
/// Header summary plus a decoded body, falling back to hex when it will not
|
||||
/// parse. A capture is most valuable exactly when the body is malformed, so
|
||||
/// this must never fail.
|
||||
pub fn describe_frame(frame: &Frame) -> String {
|
||||
let mut out = describe_header(&frame.header);
|
||||
out.push('\n');
|
||||
if frame.payload.is_empty() {
|
||||
out.push_str("(empty payload)\n");
|
||||
return out;
|
||||
}
|
||||
match crate::heat2::decode(&frame.payload) {
|
||||
Ok(body) => out.push_str(&dump_struct(&body)),
|
||||
Err(e) => {
|
||||
let _ = writeln!(out, "(TDF decode failed: {e})");
|
||||
out.push_str(&hexdump(&frame.payload, 256));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn hex(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
let _ = write!(s, "{b:02x}");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Classic offset / hex / ASCII dump, truncated to `limit` bytes.
|
||||
pub fn hexdump(bytes: &[u8], limit: usize) -> String {
|
||||
let shown = &bytes[..bytes.len().min(limit)];
|
||||
let mut out = String::new();
|
||||
for (row, chunk) in shown.chunks(16).enumerate() {
|
||||
let _ = write!(out, "{:08x} ", row * 16);
|
||||
for i in 0..16 {
|
||||
match chunk.get(i) {
|
||||
Some(b) => {
|
||||
let _ = write!(out, "{b:02x} ");
|
||||
}
|
||||
None => out.push_str(" "),
|
||||
}
|
||||
if i == 7 {
|
||||
out.push(' ');
|
||||
}
|
||||
}
|
||||
out.push_str(" |");
|
||||
for b in chunk {
|
||||
out.push(if (0x20..0x7F).contains(b) {
|
||||
*b as char
|
||||
} else {
|
||||
'.'
|
||||
});
|
||||
}
|
||||
out.push_str("|\n");
|
||||
}
|
||||
if bytes.len() > limit {
|
||||
let _ = writeln!(out, "... {} more bytes", bytes.len() - limit);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fire2::MsgType;
|
||||
use crate::heat2;
|
||||
|
||||
#[test]
|
||||
fn dumps_nested_structures() {
|
||||
let s = Struct::new().with("PID", Value::Int(33068179)).with(
|
||||
"CINF",
|
||||
Value::Struct(Struct::new().with("ENV", Value::String("prod".into()))),
|
||||
);
|
||||
let text = dump_struct(&s);
|
||||
assert!(text.contains("PID (int) = 33068179"), "{text}");
|
||||
assert!(text.contains("ENV (string) = \"prod\""), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describes_a_frame_with_an_undecodable_body() {
|
||||
// 0xFF is not a TDF type byte, so this must fall back to hex.
|
||||
let frame = Frame::new(9, 7, 1, MsgType::Reply, vec![0xFF; 8]);
|
||||
let text = describe_frame(&frame);
|
||||
assert!(text.contains("decode failed"), "{text}");
|
||||
assert!(text.contains("ff ff ff"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describes_a_frame_with_a_good_body() {
|
||||
let body = Struct::new().with("A", Value::Int(1));
|
||||
let frame = Frame::new(9, 7, 1, MsgType::Reply, heat2::encode(&body));
|
||||
let text = describe_frame(&frame);
|
||||
assert!(text.contains("REPLY"), "{text}");
|
||||
assert!(text.contains("A (int) = 1"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hexdump_truncates_and_says_so() {
|
||||
let text = hexdump(&[0x41; 100], 32);
|
||||
assert!(text.contains("68 more bytes"), "{text}");
|
||||
assert!(text.contains("AAAA"), "{text}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Decode errors.
|
||||
//!
|
||||
//! Encoding cannot fail: every `Value` is representable on the wire. Decoding
|
||||
//! is fallible because the input is attacker-shaped bytes from a socket.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// Ran off the end of the buffer while reading `what`.
|
||||
Truncated {
|
||||
what: &'static str,
|
||||
need: usize,
|
||||
have: usize,
|
||||
},
|
||||
/// A type byte that is not one of the eleven Heat2 types.
|
||||
UnknownType { type_byte: u8, at: usize },
|
||||
/// A varint whose continuation bits never terminated within 64 bits.
|
||||
VarintOverflow { at: usize },
|
||||
/// A Fire2 header whose declared lengths cannot be satisfied.
|
||||
ShortFrame { need: usize, have: usize },
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::Truncated { what, need, have } => write!(
|
||||
f,
|
||||
"truncated while reading {what}: need {need} bytes, have {have}"
|
||||
),
|
||||
Error::UnknownType { type_byte, at } => {
|
||||
write!(f, "unknown TDF type byte 0x{type_byte:02x} at offset {at}")
|
||||
}
|
||||
Error::VarintOverflow { at } => {
|
||||
write!(f, "varint at offset {at} exceeds 64 bits")
|
||||
}
|
||||
Error::ShortFrame { need, have } => {
|
||||
write!(f, "short Fire2 frame: need {need} bytes, have {have}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -0,0 +1,416 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
//! Heat2 decoder.
|
||||
//!
|
||||
//! Input is bytes off a socket, so every read is bounds-checked and every
|
||||
//! failure is an `Error`, never a panic. The oracle (`heat2.py`) indexes
|
||||
//! optimistically and would raise on malformed input; matching its *bytes* is
|
||||
//! required, matching its *crash behaviour* is not.
|
||||
|
||||
use super::tag::Tag;
|
||||
use super::value::{Struct, TypeId, Value, UNION_UNSET};
|
||||
use super::varint;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Decode a top-level payload body (unterminated, delimited by `buf`).
|
||||
pub fn decode(buf: &[u8]) -> Result<Struct> {
|
||||
let (s, _) = decode_members(buf, 0, buf.len(), false)?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Read members until `end` (top level) or a `0x00` terminator (nested).
|
||||
fn decode_members(
|
||||
buf: &[u8],
|
||||
mut i: usize,
|
||||
end: usize,
|
||||
terminated: bool,
|
||||
) -> Result<(Struct, usize)> {
|
||||
let mut fields = Vec::new();
|
||||
while i < end {
|
||||
if terminated && buf[i] == 0x00 {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
if i + 4 > end {
|
||||
return Err(Error::Truncated {
|
||||
what: "field header",
|
||||
need: 4,
|
||||
have: end - i,
|
||||
});
|
||||
}
|
||||
let tag = Tag([buf[i], buf[i + 1], buf[i + 2]]);
|
||||
let type_byte = buf[i + 3];
|
||||
let ty = TypeId::from_byte(type_byte).ok_or(Error::UnknownType {
|
||||
type_byte,
|
||||
at: i + 3,
|
||||
})?;
|
||||
i += 4;
|
||||
let (value, next) = decode_value(buf, i, end, ty)?;
|
||||
i = next;
|
||||
fields.push((tag, value));
|
||||
}
|
||||
Ok((Struct { fields }, i))
|
||||
}
|
||||
|
||||
fn decode_value(buf: &[u8], i: usize, end: usize, ty: TypeId) -> Result<(Value, usize)> {
|
||||
match ty {
|
||||
TypeId::Int => {
|
||||
let (v, i) = varint::decode(buf, i)?;
|
||||
Ok((Value::Int(v), i))
|
||||
}
|
||||
|
||||
TypeId::String => {
|
||||
let (len, i) = varint::decode(buf, i)?;
|
||||
let len = checked_len(len, i, end, "string")?;
|
||||
let raw = &buf[i..i + len];
|
||||
// The declared length includes the NUL; strip any trailing NULs so
|
||||
// the value round-trips through the encoder unchanged.
|
||||
let cut = raw.iter().rposition(|&b| b != 0).map_or(0, |p| p + 1);
|
||||
let s = String::from_utf8_lossy(&raw[..cut]).into_owned();
|
||||
Ok((Value::String(s), i + len))
|
||||
}
|
||||
|
||||
TypeId::Blob => {
|
||||
let (len, i) = varint::decode(buf, i)?;
|
||||
let len = checked_len(len, i, end, "blob")?;
|
||||
Ok((Value::Blob(buf[i..i + len].to_vec()), i + len))
|
||||
}
|
||||
|
||||
TypeId::Struct => {
|
||||
let (s, i) = decode_members(buf, i, end, true)?;
|
||||
Ok((Value::Struct(s), i))
|
||||
}
|
||||
|
||||
TypeId::List => {
|
||||
let elem_byte = *byte_at(buf, i, end, "list element type")?;
|
||||
let elem = TypeId::from_byte(elem_byte).ok_or(Error::UnknownType {
|
||||
type_byte: elem_byte,
|
||||
at: i,
|
||||
})?;
|
||||
let (count, mut i) = varint::decode(buf, i + 1)?;
|
||||
let count = checked_count(count, end - i.min(end), "list")?;
|
||||
let mut items = Vec::with_capacity(count.min(1024));
|
||||
for _ in 0..count {
|
||||
let (v, next) = decode_value(buf, i, end, elem)?;
|
||||
i = next;
|
||||
items.push(v);
|
||||
}
|
||||
Ok((Value::List { elem, items }, i))
|
||||
}
|
||||
|
||||
TypeId::Map => {
|
||||
let key_byte = *byte_at(buf, i, end, "map key type")?;
|
||||
let val_byte = *byte_at(buf, i + 1, end, "map value type")?;
|
||||
let key = TypeId::from_byte(key_byte).ok_or(Error::UnknownType {
|
||||
type_byte: key_byte,
|
||||
at: i,
|
||||
})?;
|
||||
let val = TypeId::from_byte(val_byte).ok_or(Error::UnknownType {
|
||||
type_byte: val_byte,
|
||||
at: i + 1,
|
||||
})?;
|
||||
let (count, mut i) = varint::decode(buf, i + 2)?;
|
||||
let count = checked_count(count, end - i.min(end), "map")?;
|
||||
let mut entries = Vec::with_capacity(count.min(1024));
|
||||
for _ in 0..count {
|
||||
let (k, next) = decode_value(buf, i, end, key)?;
|
||||
let (v, next) = decode_value(buf, next, end, val)?;
|
||||
i = next;
|
||||
entries.push((k, v));
|
||||
}
|
||||
Ok((Value::Map { key, val, entries }, i))
|
||||
}
|
||||
|
||||
TypeId::Union => {
|
||||
let key = *byte_at(buf, i, end, "union discriminator")?;
|
||||
let i = i + 1;
|
||||
if key == UNION_UNSET {
|
||||
return Ok((Value::Union { key, member: None }, i));
|
||||
}
|
||||
if i + 4 > end {
|
||||
return Err(Error::Truncated {
|
||||
what: "union member header",
|
||||
need: 4,
|
||||
have: end.saturating_sub(i),
|
||||
});
|
||||
}
|
||||
let tag = Tag([buf[i], buf[i + 1], buf[i + 2]]);
|
||||
let mtype_byte = buf[i + 3];
|
||||
let mtype = TypeId::from_byte(mtype_byte).ok_or(Error::UnknownType {
|
||||
type_byte: mtype_byte,
|
||||
at: i + 3,
|
||||
})?;
|
||||
let (value, i) = decode_value(buf, i + 4, end, mtype)?;
|
||||
Ok((
|
||||
Value::Union {
|
||||
key,
|
||||
member: Some(Box::new((tag, value))),
|
||||
},
|
||||
i,
|
||||
))
|
||||
}
|
||||
|
||||
TypeId::VarList => {
|
||||
let (count, mut i) = varint::decode(buf, i)?;
|
||||
let count = checked_count(count, end - i.min(end), "varlist")?;
|
||||
let mut items = Vec::with_capacity(count.min(1024));
|
||||
for _ in 0..count {
|
||||
let (v, next) = varint::decode(buf, i)?;
|
||||
i = next;
|
||||
items.push(v);
|
||||
}
|
||||
Ok((Value::VarList(items), i))
|
||||
}
|
||||
|
||||
TypeId::ObjType => {
|
||||
let (component, i) = varint::decode(buf, i)?;
|
||||
let (ty, i) = varint::decode(buf, i)?;
|
||||
Ok((Value::ObjType { component, ty }, i))
|
||||
}
|
||||
|
||||
TypeId::ObjId => {
|
||||
let (component, i) = varint::decode(buf, i)?;
|
||||
let (ty, i) = varint::decode(buf, i)?;
|
||||
let (id, i) = varint::decode(buf, i)?;
|
||||
Ok((Value::ObjId { component, ty, id }, i))
|
||||
}
|
||||
|
||||
TypeId::Float => {
|
||||
if i + 4 > end {
|
||||
return Err(Error::Truncated {
|
||||
what: "float",
|
||||
need: 4,
|
||||
have: end.saturating_sub(i),
|
||||
});
|
||||
}
|
||||
let f = f32::from_be_bytes([buf[i], buf[i + 1], buf[i + 2], buf[i + 3]]);
|
||||
Ok((Value::Float(f), i + 4))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn byte_at<'a>(buf: &'a [u8], i: usize, end: usize, what: &'static str) -> Result<&'a u8> {
|
||||
if i >= end {
|
||||
return Err(Error::Truncated {
|
||||
what,
|
||||
need: 1,
|
||||
have: 0,
|
||||
});
|
||||
}
|
||||
buf.get(i).ok_or(Error::Truncated {
|
||||
what,
|
||||
need: 1,
|
||||
have: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn checked_len(len: i64, i: usize, end: usize, what: &'static str) -> Result<usize> {
|
||||
let available = end.saturating_sub(i);
|
||||
if len < 0 || len as u64 > available as u64 {
|
||||
return Err(Error::Truncated {
|
||||
what,
|
||||
need: len.max(0) as usize,
|
||||
have: available,
|
||||
});
|
||||
}
|
||||
Ok(len as usize)
|
||||
}
|
||||
|
||||
/// Reject a declared element count that cannot fit in the remaining bytes.
|
||||
///
|
||||
/// Without this a two-byte varint can ask for a billion elements and the
|
||||
/// allocation, not the parse, becomes the failure.
|
||||
fn checked_count(count: i64, remaining: usize, what: &'static str) -> Result<usize> {
|
||||
if count < 0 || count as u64 > remaining as u64 {
|
||||
return Err(Error::Truncated {
|
||||
what,
|
||||
need: count.max(0) as usize,
|
||||
have: remaining,
|
||||
});
|
||||
}
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::encode::encode;
|
||||
use super::*;
|
||||
|
||||
fn round_trip(s: Struct) {
|
||||
let bytes = encode(&s);
|
||||
let back = decode(&bytes).expect("decodes");
|
||||
assert_eq!(encode(&back), bytes, "re-encode must be byte-identical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_scalars() {
|
||||
round_trip(
|
||||
Struct::new()
|
||||
.with("INTV", Value::Int(0x2000))
|
||||
.with("STRV", Value::String("hello".into()))
|
||||
.with("BLBV", Value::Blob(vec![0, 1, 2, 255]))
|
||||
.with("FLTV", Value::Float(-0.25)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_nesting() {
|
||||
round_trip(
|
||||
Struct::new()
|
||||
.with(
|
||||
"OUTR",
|
||||
Value::Struct(
|
||||
Struct::new()
|
||||
.with(
|
||||
"INNR",
|
||||
Value::Struct(Struct::new().with("LEAF", Value::Int(42))),
|
||||
)
|
||||
.with("SIBL", Value::String("s".into())),
|
||||
),
|
||||
)
|
||||
.with("TAIL", Value::Int(9)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_terminator_does_not_swallow_following_members() {
|
||||
let s = Struct::new()
|
||||
.with(
|
||||
"AAAA",
|
||||
Value::Struct(Struct::new().with("X", Value::Int(1))),
|
||||
)
|
||||
.with("BBBB", Value::Int(7));
|
||||
let back = decode(&encode(&s)).unwrap();
|
||||
assert_eq!(back.len(), 2);
|
||||
assert_eq!(back.get("BBBB").and_then(Value::as_int), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_an_unknown_type_byte() {
|
||||
// tag "AAAA" then type 0x0B, which is not a Heat2 type.
|
||||
let mut bytes = Tag::from_label("AAAA").as_bytes().to_vec();
|
||||
bytes.push(0x0B);
|
||||
assert!(matches!(
|
||||
decode(&bytes),
|
||||
Err(Error::UnknownType {
|
||||
type_byte: 0x0B,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_string_longer_than_the_buffer() {
|
||||
let mut bytes = Tag::from_label("S").as_bytes().to_vec();
|
||||
bytes.push(TypeId::String.as_byte());
|
||||
bytes.push(0x3F); // claims 63 bytes
|
||||
bytes.extend_from_slice(b"short");
|
||||
assert!(matches!(decode(&bytes), Err(Error::Truncated { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_an_absurd_list_count_without_allocating() {
|
||||
let mut bytes = Tag::from_label("L").as_bytes().to_vec();
|
||||
bytes.push(TypeId::List.as_byte());
|
||||
bytes.push(TypeId::Int.as_byte());
|
||||
bytes.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0x7F]); // huge count
|
||||
assert!(matches!(decode(&bytes), Err(Error::Truncated { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_truncated_field_header() {
|
||||
let bytes = vec![0x96, 0xed]; // two bytes of a three-byte tag
|
||||
assert!(decode(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_panics_on_arbitrary_bytes() {
|
||||
// Cheap structured fuzz: every 3-byte prefix followed by each type byte.
|
||||
for type_byte in 0x00u8..=0x0C {
|
||||
for pattern in [0x00u8, 0x01, 0x7F, 0x80, 0xFF] {
|
||||
let bytes = vec![pattern, pattern, pattern, type_byte, pattern, pattern];
|
||||
let _ = decode(&bytes); // must return, not panic
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Heat2 encoder.
|
||||
//!
|
||||
//! Field layout is `3-byte packed tag || 1 type byte || value`.
|
||||
//!
|
||||
//! Two rules are easy to get wrong and both are load-bearing:
|
||||
//!
|
||||
//! 1. **Members serialise in ascending packed-tag order.** Not source order,
|
||||
//! not alphabetical order of the label — packed-tag order. (They coincide
|
||||
//! for equal-length uppercase labels, which is why a bug here hides.)
|
||||
//! 2. **A nested struct is terminated by `0x00`; the top-level payload is
|
||||
//! not.** The top level is delimited by the Fire2 length instead.
|
||||
//!
|
||||
//! Encoding is infallible: every `Value` has a wire form.
|
||||
|
||||
use super::tag::Tag;
|
||||
use super::value::{Struct, Value, UNION_UNSET};
|
||||
use super::varint;
|
||||
|
||||
/// Encode a top-level payload body (no trailing terminator).
|
||||
pub fn encode(s: &Struct) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
encode_into(s, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Encode a top-level payload body, appending to `out`.
|
||||
pub fn encode_into(s: &Struct, out: &mut Vec<u8>) {
|
||||
encode_members(s, out);
|
||||
}
|
||||
|
||||
fn encode_members(s: &Struct, out: &mut Vec<u8>) {
|
||||
// Stable sort by packed tag: equal tags keep their relative order, so a
|
||||
// decoded frame containing duplicates re-encodes identically.
|
||||
let mut ordered: Vec<&(Tag, Value)> = s.fields.iter().collect();
|
||||
ordered.sort_by_key(|(tag, _)| *tag);
|
||||
|
||||
for (tag, value) in ordered {
|
||||
out.extend_from_slice(&tag.as_bytes());
|
||||
out.push(value.type_id().as_byte());
|
||||
encode_value(value, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_value(value: &Value, out: &mut Vec<u8>) {
|
||||
match value {
|
||||
Value::Int(v) => varint::encode(*v, out),
|
||||
|
||||
Value::String(s) => {
|
||||
// Trailing NULs are stripped before framing so the length and the
|
||||
// single terminator stay consistent; the length INCLUDES that NUL.
|
||||
let raw = s.as_bytes();
|
||||
let end = raw.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1);
|
||||
let raw = &raw[..end];
|
||||
varint::encode(raw.len() as i64 + 1, out);
|
||||
out.extend_from_slice(raw);
|
||||
out.push(0x00);
|
||||
}
|
||||
|
||||
Value::Blob(b) => {
|
||||
// Blob length EXCLUDES a terminator — there isn't one.
|
||||
varint::encode(b.len() as i64, out);
|
||||
out.extend_from_slice(b);
|
||||
}
|
||||
|
||||
Value::Struct(s) => {
|
||||
encode_members(s, out);
|
||||
out.push(0x00);
|
||||
}
|
||||
|
||||
Value::List { elem, items } => {
|
||||
out.push(elem.as_byte());
|
||||
varint::encode(items.len() as i64, out);
|
||||
for it in items {
|
||||
encode_value(it, out);
|
||||
}
|
||||
}
|
||||
|
||||
Value::Map { key, val, entries } => {
|
||||
out.push(key.as_byte());
|
||||
out.push(val.as_byte());
|
||||
varint::encode(entries.len() as i64, out);
|
||||
for (k, v) in entries {
|
||||
encode_value(k, out);
|
||||
encode_value(v, out);
|
||||
}
|
||||
}
|
||||
|
||||
Value::Union { key, member } => {
|
||||
out.push(*key);
|
||||
if *key != UNION_UNSET {
|
||||
if let Some(m) = member {
|
||||
let (tag, val) = m.as_ref();
|
||||
out.extend_from_slice(&tag.as_bytes());
|
||||
out.push(val.type_id().as_byte());
|
||||
encode_value(val, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Value::VarList(items) => {
|
||||
varint::encode(items.len() as i64, out);
|
||||
for n in items {
|
||||
varint::encode(*n, out);
|
||||
}
|
||||
}
|
||||
|
||||
Value::ObjType { component, ty } => {
|
||||
varint::encode(*component, out);
|
||||
varint::encode(*ty, out);
|
||||
}
|
||||
|
||||
Value::ObjId { component, ty, id } => {
|
||||
varint::encode(*component, out);
|
||||
varint::encode(*ty, out);
|
||||
varint::encode(*id, out);
|
||||
}
|
||||
|
||||
Value::Float(f) => out.extend_from_slice(&f.to_be_bytes()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a value on its own, without a tag or type byte.
|
||||
///
|
||||
/// Useful for a nested struct that a caller frames itself; note this DOES emit
|
||||
/// the `0x00` terminator for `Value::Struct`.
|
||||
pub fn encode_value_only(value: &Value) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
encode_value(value, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Convenience: the type byte a value will be written with.
|
||||
pub fn type_byte_of(value: &Value) -> u8 {
|
||||
value.type_id().as_byte()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn members_sort_by_packed_tag_not_source_order() {
|
||||
let s = Struct::new()
|
||||
.with("ZZZZ", Value::Int(3))
|
||||
.with("AAAA", Value::Int(1))
|
||||
.with("MMMM", Value::Int(2));
|
||||
let bytes = encode(&s);
|
||||
// Each member is 3 tag + 1 type + 1 varint = 5 bytes; values must come
|
||||
// out 1, 2, 3.
|
||||
assert_eq!(bytes.len(), 15);
|
||||
assert_eq!([bytes[4], bytes[9], bytes[14]], [1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_is_unterminated_but_nested_is_terminated() {
|
||||
let flat = encode(&Struct::new().with("A", Value::Int(1)));
|
||||
assert_eq!(
|
||||
flat.last(),
|
||||
Some(&1u8),
|
||||
"no trailing terminator at top level"
|
||||
);
|
||||
|
||||
let nested = encode(&Struct::new().with(
|
||||
"OUTR",
|
||||
Value::Struct(Struct::new().with("A", Value::Int(1))),
|
||||
));
|
||||
assert_eq!(
|
||||
nested.last(),
|
||||
Some(&0u8),
|
||||
"nested struct is 0x00 terminated"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_nested_struct_is_a_bare_terminator() {
|
||||
let bytes = encode(&Struct::new().with("MTST", Value::Struct(Struct::new())));
|
||||
assert_eq!(bytes.len(), 5); // 3 tag + 1 type + 1 terminator
|
||||
assert_eq!(bytes[4], 0x00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_length_includes_the_nul() {
|
||||
let bytes = encode(&Struct::new().with("S", Value::String("ab".into())));
|
||||
// tag(3) type(1) len(1)=3 'a' 'b' NUL
|
||||
assert_eq!(&bytes[4..], &[0x03, b'a', b'b', 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_string_is_length_one_plus_nul() {
|
||||
let bytes = encode(&Struct::new().with("S", Value::String(String::new())));
|
||||
assert_eq!(&bytes[4..], &[0x01, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_length_excludes_a_terminator() {
|
||||
let bytes = encode(&Struct::new().with("B", Value::Blob(vec![1, 2, 3])));
|
||||
assert_eq!(&bytes[4..], &[0x03, 1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_is_big_endian_f32() {
|
||||
let bytes = encode(&Struct::new().with("F", Value::Float(1.5)));
|
||||
assert_eq!(&bytes[4..], &1.5f32.to_be_bytes());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Heat2 TDF: EA Blaze's tagged binary serialisation.
|
||||
//!
|
||||
//! A field is `3-byte packed tag || 1 type byte || value`. Structs nest and are
|
||||
//! `0x00` terminated; the outermost payload is delimited by the Fire2 length
|
||||
//! instead.
|
||||
//!
|
||||
//! # Provenance
|
||||
//!
|
||||
//! Ported from `fifa17-recon/tools/heat2.py`, which was derived clean-room from
|
||||
//! the wire bytes of our own FIFA 17 client and validated byte-exact against
|
||||
//! that capture. The int/string/blob/struct layouts are proven; list, map,
|
||||
//! union, varlist, objtype, objid and float are marked UNVERIFIED there and
|
||||
//! that flag is preserved on [`TypeId::is_verified`].
|
||||
//!
|
||||
//! Byte-for-byte parity with the Python oracle is enforced by fixture tests
|
||||
//! (`tests/oracle_parity.rs`) over vectors in `fixtures/tdf.jsonl`.
|
||||
|
||||
pub mod decode;
|
||||
pub mod encode;
|
||||
pub mod tag;
|
||||
pub mod value;
|
||||
pub mod varint;
|
||||
|
||||
pub use decode::decode;
|
||||
pub use encode::{encode, encode_into};
|
||||
pub use tag::Tag;
|
||||
pub use value::{Struct, TypeId, Value, UNION_UNSET};
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Heat2 field tags: four characters packed into three bytes.
|
||||
//!
|
||||
//! Each character contributes six bits (`(c - 0x20) & 0x3F`), concatenated
|
||||
//! MSB-first. Labels shorter than four characters are space-padded, and code 0
|
||||
//! decodes back to a space, so `"ENV"` survives a round trip as `"ENV"`.
|
||||
//!
|
||||
//! Evidence: byte-exact round trip against the FIFA 17 preAuth capture, via
|
||||
//! `fifa17-recon/tools/heat2.py::encode_tag`.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// A packed three-byte Heat2 tag.
|
||||
///
|
||||
/// Ordering is by packed bytes, which is exactly the member ordering Blaze
|
||||
/// requires on the wire — so `sort()` on a slice of `Tag` is the wire rule.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct Tag(pub [u8; 3]);
|
||||
|
||||
impl Tag {
|
||||
/// Pack a label. Labels longer than four characters are TRUNCATED, not
|
||||
/// rejected — matching the oracle. Note the hazard: `"LSTR"` and `"LSTR2"`
|
||||
/// are the same wire tag, so they collide inside one struct.
|
||||
pub fn from_label(label: &str) -> Tag {
|
||||
let mut c = [0u8; 4];
|
||||
for (i, ch) in label.chars().take(4).enumerate() {
|
||||
// Non-ASCII cannot appear in a real tag; masking keeps this total
|
||||
// instead of panicking on hostile input.
|
||||
c[i] = ((ch as u32).wrapping_sub(0x20) & 0x3F) as u8;
|
||||
}
|
||||
Tag([
|
||||
(c[0] << 2) | (c[1] >> 4),
|
||||
((c[1] & 0x0F) << 4) | (c[2] >> 2),
|
||||
((c[2] & 0x03) << 6) | c[3],
|
||||
])
|
||||
}
|
||||
|
||||
/// Unpack to a label with trailing padding stripped.
|
||||
pub fn to_label(self) -> String {
|
||||
let [a, b, c] = self.0;
|
||||
let v = [
|
||||
(a >> 2) & 0x3F,
|
||||
((a & 0x03) << 4) | ((b >> 4) & 0x0F),
|
||||
((b & 0x0F) << 2) | ((c >> 6) & 0x03),
|
||||
c & 0x3F,
|
||||
];
|
||||
let s: String = v
|
||||
.iter()
|
||||
.map(|&x| if x == 0 { ' ' } else { (x + 0x20) as char })
|
||||
.collect();
|
||||
s.trim_end().to_string()
|
||||
}
|
||||
|
||||
pub fn as_bytes(self) -> [u8; 3] {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Tag {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.to_label())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Tag {
|
||||
fn from(s: &str) -> Tag {
|
||||
Tag::from_label(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trips_common_labels() {
|
||||
for label in ["CDAT", "CINF", "ENV", "A", "LOC", "BSDK", "PTVR"] {
|
||||
assert_eq!(Tag::from_label(label).to_label(), label);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packs_to_three_bytes_msb_first() {
|
||||
// "ENV" pads to "ENV ": codes 0x25 0x2E 0x36 0x00.
|
||||
assert_eq!(Tag::from_label("ENV").as_bytes(), [0x96, 0xed, 0x80]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordering_is_packed_byte_order() {
|
||||
let mut tags = [Tag::from("ZZZZ"), Tag::from("AAAA"), Tag::from("MMMM")];
|
||||
tags.sort();
|
||||
assert_eq!(
|
||||
tags.map(|t| t.to_label()),
|
||||
["AAAA".to_string(), "MMMM".to_string(), "ZZZZ".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_labels_truncate_and_collide() {
|
||||
assert_eq!(Tag::from("LSTR2"), Tag::from("LSTR"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Heat2 varint.
|
||||
//!
|
||||
//! The first byte is the odd one: only six data bits (`0x3F`), with `0x40` as a
|
||||
//! sign flag and `0x80` as "more". Every later byte is a conventional 7-bit
|
||||
//! group. Groups are little-endian: byte 0 holds the low six bits, then seven
|
||||
//! bits at shifts 6, 13, 20, ...
|
||||
//!
|
||||
//! Evidence: validated byte-exact against the FIFA 17 preAuth capture
|
||||
//! (`LANG = 0x656E5553` encodes as `93 d5 f2 d6 0c`).
|
||||
//!
|
||||
//! The sign flag is UNVERIFIED — it never appears in any captured frame. It is
|
||||
//! implemented to match the oracle so negatives round-trip, but no claim is
|
||||
//! made that EA encodes negatives this way.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Append the canonical (shortest) encoding of `value`.
|
||||
pub fn encode(value: i64, out: &mut Vec<u8>) {
|
||||
let neg = value < 0;
|
||||
// unsigned_abs, not -value: i64::MIN has no positive counterpart.
|
||||
let mut v = value.unsigned_abs();
|
||||
|
||||
let mut first = (v & 0x3F) as u8;
|
||||
v >>= 6;
|
||||
if neg {
|
||||
first |= 0x40;
|
||||
}
|
||||
if v == 0 {
|
||||
out.push(first);
|
||||
return;
|
||||
}
|
||||
out.push(first | 0x80);
|
||||
while v >= 0x80 {
|
||||
out.push(((v & 0x7F) as u8) | 0x80);
|
||||
v >>= 7;
|
||||
}
|
||||
out.push(v as u8);
|
||||
}
|
||||
|
||||
/// Decode at `pos`, returning the value and the index just past it.
|
||||
pub fn decode(buf: &[u8], pos: usize) -> Result<(i64, usize)> {
|
||||
let mut i = pos;
|
||||
let b = *buf.get(i).ok_or(Error::Truncated {
|
||||
what: "varint",
|
||||
need: 1,
|
||||
have: 0,
|
||||
})?;
|
||||
i += 1;
|
||||
|
||||
let mut val: u64 = (b & 0x3F) as u64;
|
||||
let neg = b & 0x40 != 0;
|
||||
|
||||
if b & 0x80 != 0 {
|
||||
let mut shift = 6u32;
|
||||
loop {
|
||||
let b = *buf.get(i).ok_or(Error::Truncated {
|
||||
what: "varint continuation",
|
||||
need: 1,
|
||||
have: 0,
|
||||
})?;
|
||||
i += 1;
|
||||
if shift >= 64 {
|
||||
return Err(Error::VarintOverflow { at: pos });
|
||||
}
|
||||
val |= ((b & 0x7F) as u64) << shift;
|
||||
shift += 7;
|
||||
if b & 0x80 == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let signed = if neg {
|
||||
(val as i64).wrapping_neg()
|
||||
} else {
|
||||
val as i64
|
||||
};
|
||||
Ok((signed, i))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn enc(v: i64) -> Vec<u8> {
|
||||
let mut o = Vec::new();
|
||||
encode(v, &mut o);
|
||||
o
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_byte_below_0x40() {
|
||||
assert_eq!(enc(0), vec![0x00]);
|
||||
assert_eq!(enc(1), vec![0x01]);
|
||||
assert_eq!(enc(0x3F), vec![0x3F]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spills_to_a_second_group_at_0x40() {
|
||||
// Six data bits in byte 0, so 0x40 is the first value that needs two.
|
||||
assert_eq!(enc(0x40), vec![0x80, 0x01]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_the_captured_lang_field() {
|
||||
assert_eq!(enc(0x656E5553), vec![0x93, 0xd5, 0xf2, 0xd6, 0x0c]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_boundaries() {
|
||||
for v in [
|
||||
0,
|
||||
1,
|
||||
0x3F,
|
||||
0x40,
|
||||
0x7F,
|
||||
0x80,
|
||||
0x1FFF,
|
||||
0x2000,
|
||||
0xFFFF_FFFF,
|
||||
i64::MAX,
|
||||
-1,
|
||||
-300,
|
||||
] {
|
||||
let bytes = enc(v);
|
||||
assert_eq!(decode(&bytes, 0).unwrap(), (v, bytes.len()), "value {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_never_terminating_varint() {
|
||||
let runaway = vec![0xFF; 32];
|
||||
assert!(matches!(
|
||||
decode(&runaway, 0),
|
||||
Err(Error::VarintOverflow { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncation() {
|
||||
assert!(decode(&[0x80], 0).is_err());
|
||||
assert!(decode(&[], 0).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! # openfut-protocol-blaze
|
||||
//!
|
||||
//! Game-independent EA Blaze wire protocol: Fire2 framing and the Heat2/TDF
|
||||
//! codec.
|
||||
//!
|
||||
//! ## What belongs here
|
||||
//!
|
||||
//! Only things that are true of Blaze itself:
|
||||
//!
|
||||
//! * [`fire2`] — the 16-byte frame header and frame/stream splitting
|
||||
//! * [`heat2`] — tag packing, varints, and the eleven TDF value types
|
||||
//! * [`message`] — a frame plus its decoded body, routed by numeric
|
||||
//! component/command
|
||||
//! * [`diagnostics`] — dumps for capture review
|
||||
//!
|
||||
//! ## What does not belong here
|
||||
//!
|
||||
//! Anything that would have to change for a different title: command and
|
||||
//! component name tables, notification IDs, response schemas, login sequencing,
|
||||
//! session identity. FIFA 17 says `0x0009/0x0007` is `Util::preAuth`; this
|
||||
//! crate only knows it is component 9, command 7. A game adapter owns the rest.
|
||||
//!
|
||||
//! The test for a change landing in the right place: *could FIFA 18 or FIFA 23
|
||||
//! use this without importing FIFA 17's command tables?* If not, it belongs in
|
||||
//! an adapter.
|
||||
//!
|
||||
//! ## Provenance and confidence
|
||||
//!
|
||||
//! Ported from the Python implementation in `fifa17-recon/tools/` that drove a
|
||||
//! retail FIFA 17 client from Origin login to an opened FUT pack — the
|
||||
//! project's behavioural oracle. Parity is not asserted, it is tested: the
|
||||
//! vectors in `fixtures/` are generated from that Python code and replayed
|
||||
//! byte-for-byte by `tests/oracle_parity.rs`.
|
||||
//!
|
||||
//! Confidence is not uniform, and the code says so rather than leaving it in a
|
||||
//! comment. Int, string, blob and struct layouts are proven against captured
|
||||
//! traffic; list, map, union, varlist, objtype, objid and float are not, and
|
||||
//! [`heat2::TypeId::is_verified`] reports which is which.
|
||||
//!
|
||||
//! ```
|
||||
//! use openfut_protocol_blaze::fire2::{Frame, MsgType};
|
||||
//! use openfut_protocol_blaze::heat2::{self, Struct, Value};
|
||||
//!
|
||||
//! let body = Struct::new()
|
||||
//! .with("PID", Value::Int(33068179))
|
||||
//! .with("NAME", Value::String("CAGE".into()));
|
||||
//!
|
||||
//! let frame = Frame::new(0x0001, 0x000A, 7, MsgType::Reply, heat2::encode(&body));
|
||||
//! let wire = frame.encode();
|
||||
//!
|
||||
//! let (parsed, used) = Frame::parse(&wire).unwrap();
|
||||
//! assert_eq!(used, wire.len());
|
||||
//! assert_eq!(parsed.header.component, 0x0001);
|
||||
//! assert_eq!(wire[13], 0x20); // msgType REPLY in the top 3 bits, userIndex 0
|
||||
//! ```
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod diagnostics;
|
||||
pub mod error;
|
||||
pub mod fire2;
|
||||
pub mod heat2;
|
||||
pub mod message;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use fire2::{Frame, Header, MsgType};
|
||||
pub use heat2::{Struct, Tag, TypeId, Value};
|
||||
pub use message::Message;
|
||||
@@ -0,0 +1,103 @@
|
||||
//! A Blaze message: a Fire2 frame whose payload is decoded Heat2/TDF.
|
||||
//!
|
||||
//! This is the highest level this crate goes. It routes by *numbers*, never by
|
||||
//! names: mapping `0x0009/0x0007` to `Util::preAuth`, or knowing which fields a
|
||||
//! login reply must carry, is a per-title concern that belongs in a game
|
||||
//! adapter. Keeping that out is what lets a second title reuse this layer
|
||||
//! without inheriting FIFA 17's command tables.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::fire2::{Frame, Header, MsgType};
|
||||
use crate::heat2::{self, Struct};
|
||||
|
||||
/// A frame plus its decoded body.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Message {
|
||||
pub header: Header,
|
||||
pub body: Struct,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn new(
|
||||
component: u16,
|
||||
command: u16,
|
||||
msg_num: u32,
|
||||
msg_type: MsgType,
|
||||
body: Struct,
|
||||
) -> Message {
|
||||
Message {
|
||||
header: Header::new(component, command, msg_num, msg_type),
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a frame's payload as TDF.
|
||||
pub fn from_frame(frame: &Frame) -> Result<Message> {
|
||||
Ok(Message {
|
||||
header: frame.header,
|
||||
body: heat2::decode(&frame.payload)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-frame this message.
|
||||
///
|
||||
/// Note this is not guaranteed byte-identical to the frame a `Message` was
|
||||
/// decoded from: encoding sorts members by packed tag, so a peer that sent
|
||||
/// them out of order would see its ordering normalised. For byte-exact
|
||||
/// round trips (capture replay, differential tests) keep the [`Frame`] and
|
||||
/// its raw payload.
|
||||
pub fn to_frame(&self) -> Frame {
|
||||
let payload = heat2::encode(&self.body);
|
||||
let mut frame = Frame::new(
|
||||
self.header.component,
|
||||
self.header.command,
|
||||
self.header.msg_num,
|
||||
self.header.msg_type,
|
||||
payload,
|
||||
);
|
||||
frame.header.user_index = self.header.user_index;
|
||||
frame.header.options = self.header.options;
|
||||
frame
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
self.to_frame().encode()
|
||||
}
|
||||
|
||||
/// Component and command as a pair — the key an adapter dispatches on.
|
||||
pub fn route(&self) -> (u16, u16) {
|
||||
(self.header.component, self.header.command)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::heat2::Value;
|
||||
|
||||
#[test]
|
||||
fn decodes_a_frame_body_and_reframes_it() {
|
||||
let body = Struct::new()
|
||||
.with("PID", Value::Int(33068179))
|
||||
.with("NAME", Value::String("CAGE".into()));
|
||||
let msg = Message::new(0x0001, 0x000A, 7, MsgType::Reply, body.clone());
|
||||
|
||||
let bytes = msg.encode();
|
||||
let (frame, used) = Frame::parse(&bytes).unwrap();
|
||||
assert_eq!(used, bytes.len());
|
||||
|
||||
let back = Message::from_frame(&frame).unwrap();
|
||||
assert_eq!(back.route(), (0x0001, 0x000A));
|
||||
assert_eq!(back.header.msg_type, MsgType::Reply);
|
||||
assert_eq!(back.body.get("NAME").and_then(Value::as_str), Some("CAGE"));
|
||||
assert_eq!(back.encode(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_body_is_a_valid_message() {
|
||||
let msg = Message::new(0x0009, 0x0002, 1, MsgType::Reply, Struct::new());
|
||||
let (frame, _) = Frame::parse(&msg.encode()).unwrap();
|
||||
assert_eq!(frame.header.payload_len, 0);
|
||||
assert!(Message::from_frame(&frame).unwrap().body.is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user