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:
@@ -0,0 +1,103 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user