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