openfut-protocol-blaze: generic Blaze protocol layer, oracle-tested

First Rust component of the Python -> Rust migration. Chosen first because
it is the lowest genuinely game-independent layer, it has an executable
oracle, and both existing Rust implementations of it are wrong.

Contents:
  * fire2   -- the proven 16-byte frame header, frame/stream splitting
  * heat2   -- tag packing, varints, all 11 TDF value types
  * message -- frame + decoded body, routed by NUMERIC component/command
  * diagnostics -- dumps for capture review

No FIFA 17 command tables, response schemas or notification IDs: this layer
knows 0x0009/0x0007 is component 9, command 7, not that it means
Util::preAuth. That mapping belongs to a game adapter, which is what lets a
future FIFA 18/23 adapter reuse this.

Parity is tested, not asserted. fixtures/generate.py drives the proven
Python responders (heat2.py, blaze_responder_v3b.py) and freezes 56 vectors
-- 31 of them real payloads from the responder's own builders, including
the 11.8 KB preAuth reply. tests/oracle_parity.rs replays every one
byte-for-byte. 54 tests green; clippy clean.

Supersedes two wrong framings, neither of which is removed yet:
  * fifa-blaze/crates/blaze-proto/frame.rs -- a 12-byte header with a u16
    length, nibble-packed type/options, an error field and a JUMBO flag.
    A documented guess at FIFA 23 predating the FIFA 17 recon.
  * heat2.py::build_fire2_frame -- packs >IHHHHB3s, msgId at [10:12] and
    msgType at [12]. Dead code, but its docstring still states that layout.

Confidence is carried in the types: TypeId::is_verified() reports which
layouts are capture-backed (int/string/blob/struct) and which the oracle
marks UNVERIFIED (list/map/union/varlist/objtype/objid/float), with a test
asserting the unverified ones stay flagged.

Cargo.lock is deliberately NOT included: it re-resolves ~240 lines against
the current registry even without this crate, so that churn is pre-existing
and does not belong in a foundation commit.

The Python backend remains the live runtime and is untouched. Nothing
consumes this crate yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-08-11 00:53:59 +00:00
parent 3153a93edf
commit a9a816e0ed
19 changed files with 2982 additions and 0 deletions
+334
View File
@@ -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
}
}
}
}
+205
View File
@@ -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());
}
}
+27
View File
@@ -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};
+101
View File
@@ -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"));
}
}
+258
View File
@@ -0,0 +1,258 @@
//! The Heat2 value model.
//!
//! Deliberately a *generic* Blaze value tree: it knows the eleven wire types
//! and nothing about any game. No FIFA 17 command IDs, field names, or response
//! schemas appear here or anywhere else in this crate — those belong to a game
//! adapter one layer up.
use super::tag::Tag;
/// The eleven Heat2 type bytes.
///
/// `Int` through `Struct` are validated against captured FIFA 17 traffic. The
/// rest are marked UNVERIFIED in the oracle (`heat2.py`): they are absent from
/// every capture we hold, and their layouts are consistent-with rather than
/// proven-against EA. `TypeId::is_verified` carries that distinction into code
/// so it cannot be lost by a reader who skips the comments.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u8)]
pub enum TypeId {
Int = 0x00,
String = 0x01,
Blob = 0x02,
Struct = 0x03,
List = 0x04,
Map = 0x05,
Union = 0x06,
VarList = 0x07,
ObjType = 0x08,
ObjId = 0x09,
Float = 0x0A,
}
/// Union discriminator meaning "no member set". UNVERIFIED.
pub const UNION_UNSET: u8 = 0x7F;
impl TypeId {
pub fn from_byte(b: u8) -> Option<TypeId> {
Some(match b {
0x00 => TypeId::Int,
0x01 => TypeId::String,
0x02 => TypeId::Blob,
0x03 => TypeId::Struct,
0x04 => TypeId::List,
0x05 => TypeId::Map,
0x06 => TypeId::Union,
0x07 => TypeId::VarList,
0x08 => TypeId::ObjType,
0x09 => TypeId::ObjId,
0x0A => TypeId::Float,
_ => return None,
})
}
pub fn as_byte(self) -> u8 {
self as u8
}
pub fn name(self) -> &'static str {
match self {
TypeId::Int => "int",
TypeId::String => "string",
TypeId::Blob => "blob",
TypeId::Struct => "struct",
TypeId::List => "list",
TypeId::Map => "map",
TypeId::Union => "union",
TypeId::VarList => "varlist",
TypeId::ObjType => "objtype",
TypeId::ObjId => "objid",
TypeId::Float => "float",
}
}
pub fn from_name(name: &str) -> Option<TypeId> {
Some(match name {
"int" => TypeId::Int,
"string" => TypeId::String,
"blob" => TypeId::Blob,
"struct" => TypeId::Struct,
"list" => TypeId::List,
"map" => TypeId::Map,
"union" => TypeId::Union,
"varlist" => TypeId::VarList,
"objtype" => TypeId::ObjType,
"objid" => TypeId::ObjId,
"float" => TypeId::Float,
_ => return None,
})
}
/// True when the layout is proven against captured FIFA 17 traffic.
///
/// A `false` here is a standing warning: the codec will round-trip such a
/// value against itself and against the Python oracle, and that still says
/// nothing about what a real Blaze server emits.
pub fn is_verified(self) -> bool {
matches!(
self,
TypeId::Int | TypeId::String | TypeId::Blob | TypeId::Struct
)
}
}
/// A decoded Heat2 value.
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Int(i64),
String(String),
Blob(Vec<u8>),
Struct(Struct),
List {
elem: TypeId,
items: Vec<Value>,
},
Map {
key: TypeId,
val: TypeId,
entries: Vec<(Value, Value)>,
},
Union {
key: u8,
member: Option<Box<(Tag, Value)>>,
},
VarList(Vec<i64>),
ObjType {
component: i64,
ty: i64,
},
ObjId {
component: i64,
ty: i64,
id: i64,
},
Float(f32),
}
impl Value {
pub fn type_id(&self) -> TypeId {
match self {
Value::Int(_) => TypeId::Int,
Value::String(_) => TypeId::String,
Value::Blob(_) => TypeId::Blob,
Value::Struct(_) => TypeId::Struct,
Value::List { .. } => TypeId::List,
Value::Map { .. } => TypeId::Map,
Value::Union { .. } => TypeId::Union,
Value::VarList(_) => TypeId::VarList,
Value::ObjType { .. } => TypeId::ObjType,
Value::ObjId { .. } => TypeId::ObjId,
Value::Float(_) => TypeId::Float,
}
}
pub fn as_int(&self) -> Option<i64> {
match self {
Value::Int(v) => Some(*v),
_ => None,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
Value::String(s) => Some(s),
_ => None,
}
}
pub fn as_struct(&self) -> Option<&Struct> {
match self {
Value::Struct(s) => Some(s),
_ => None,
}
}
}
/// An ordered set of tagged members.
///
/// A `Vec`, not a map: the wire format is a sequence, duplicate tags are
/// physically representable, and encoding has to sort by packed tag anyway.
/// Keeping the sequence means a decoded frame can be re-encoded byte-for-byte
/// even when it contains something a map would silently drop.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Struct {
pub fields: Vec<(Tag, Value)>,
}
impl Struct {
pub fn new() -> Struct {
Struct { fields: Vec::new() }
}
pub fn with(mut self, tag: &str, value: Value) -> Struct {
self.fields.push((Tag::from_label(tag), value));
self
}
pub fn push(&mut self, tag: &str, value: Value) {
self.fields.push((Tag::from_label(tag), value));
}
/// First member with this tag, if any.
pub fn get(&self, tag: &str) -> Option<&Value> {
let t = Tag::from_label(tag);
self.fields.iter().find(|(k, _)| *k == t).map(|(_, v)| v)
}
pub fn len(&self) -> usize {
self.fields.len()
}
pub fn is_empty(&self) -> bool {
self.fields.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &(Tag, Value)> {
self.fields.iter()
}
}
impl FromIterator<(Tag, Value)> for Struct {
fn from_iter<I: IntoIterator<Item = (Tag, Value)>>(iter: I) -> Struct {
Struct {
fields: iter.into_iter().collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn type_bytes_round_trip() {
for b in 0x00u8..=0x0A {
let t = TypeId::from_byte(b).expect("known type");
assert_eq!(t.as_byte(), b);
assert_eq!(TypeId::from_name(t.name()), Some(t));
}
assert_eq!(TypeId::from_byte(0x0B), None);
}
#[test]
fn only_capture_backed_types_claim_verification() {
assert!(TypeId::Int.is_verified());
assert!(TypeId::Struct.is_verified());
assert!(!TypeId::List.is_verified());
assert!(!TypeId::Float.is_verified());
}
#[test]
fn struct_lookup_finds_members() {
let s = Struct::new()
.with("PID", Value::Int(33068179))
.with("NAME", Value::String("x".into()));
assert_eq!(s.get("PID").and_then(Value::as_int), Some(33068179));
assert_eq!(s.get("NOPE"), None);
}
}
+144
View File
@@ -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());
}
}