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>
335 lines
11 KiB
Rust
335 lines
11 KiB
Rust
//! 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
|
|
}
|
|
}
|
|
}
|
|
}
|