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>
206 lines
6.4 KiB
Rust
206 lines
6.4 KiB
Rust
//! 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());
|
|
}
|
|
}
|