Files
OpenFUT/openfut-protocol-blaze/src/message.rs
T
funman300 a9a816e0ed 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>
2026-08-11 00:53:59 +00:00

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());
}
}