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