feat: scaffold fifa-blaze Blaze protocol emulator (Milestone 1)
Two-crate workspace: blaze-proto (Fire2 framing via tokio_util codec, tdf=0.1 for TDF decode/stringify) and server (TLS listeners for redirector + Blaze, JSONL capture with TdfStringifier, pluggable FramingVariant enum). Both listeners bind on startup and write a capture JSONL for every packet. Component/command IDs are unknown — captures reveal them. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "blaze-proto"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tdf = { version = "0.1", features = ["serde"] }
|
||||
bytes = "1"
|
||||
tokio-util = { version = "0.7", features = ["codec"] }
|
||||
thiserror = "1"
|
||||
hex = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -0,0 +1,113 @@
|
||||
//! `tokio_util` codec for Blaze packets.
|
||||
//!
|
||||
//! The `FramingVariant` enum makes the framing pluggable. FIFA 23's variant
|
||||
//! is unknown — capture determines it. Start with `FramingVariant::Fire2`.
|
||||
//!
|
||||
//! If Fire2 decode consistently fails (every packet's body length looks wrong,
|
||||
//! or the frame type nibble is never 0-3), switch to `FramingVariant::Raw`
|
||||
//! to bypass framing entirely and capture byte streams for manual analysis.
|
||||
|
||||
use bytes::{Buf, BufMut, BytesMut};
|
||||
use std::io;
|
||||
use tokio_util::codec::{Decoder, Encoder};
|
||||
|
||||
use super::{
|
||||
frame::{FireFrame, FIRE2_MIN_HEADER, FIRE2_JUMBO_EXT, PacketOptions},
|
||||
packet::Packet,
|
||||
};
|
||||
|
||||
/// Which wire framing to use.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub enum FramingVariant {
|
||||
/// Modern EA framing used by ME3, BF3, and most post-2012 titles.
|
||||
/// Most likely candidate for FIFA 23.
|
||||
#[default]
|
||||
Fire2,
|
||||
/// Bypass framing: each `poll_next` yields whatever bytes are available
|
||||
/// as a single packet with no header parsing. Useful for raw hex capture
|
||||
/// when the correct framing is unknown.
|
||||
Raw,
|
||||
}
|
||||
|
||||
/// Tracks partial-decode state between `decode()` calls.
|
||||
struct PartialFire2 {
|
||||
frame : FireFrame,
|
||||
body_len : usize,
|
||||
}
|
||||
|
||||
/// Tokio codec for encoding and decoding Blaze packets.
|
||||
#[derive(Default)]
|
||||
pub struct PacketCodec {
|
||||
pub variant: FramingVariant,
|
||||
partial: Option<PartialFire2>,
|
||||
}
|
||||
|
||||
impl PacketCodec {
|
||||
pub fn new(variant: FramingVariant) -> Self {
|
||||
PacketCodec { variant, partial: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Decoder for PacketCodec {
|
||||
type Item = Packet;
|
||||
type Error = io::Error;
|
||||
|
||||
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
|
||||
match self.variant {
|
||||
FramingVariant::Raw => {
|
||||
if src.is_empty() { return Ok(None); }
|
||||
let body = src.copy_to_bytes(src.len());
|
||||
Ok(Some(Packet::raw(body)))
|
||||
}
|
||||
|
||||
FramingVariant::Fire2 => {
|
||||
// ── Resume waiting for the body ───────────────────────────
|
||||
if let Some(ref p) = self.partial {
|
||||
if src.len() < p.body_len { return Ok(None); }
|
||||
let PartialFire2 { frame, body_len, .. } = self.partial.take().unwrap();
|
||||
let body = src.copy_to_bytes(body_len);
|
||||
return Ok(Some(Packet { frame, body }));
|
||||
}
|
||||
|
||||
// ── Need at least the minimum header ─────────────────────
|
||||
if src.len() < FIRE2_MIN_HEADER { return Ok(None); }
|
||||
|
||||
// Peek at options to see if we need a jumbo ext too.
|
||||
let opt_nibble = (src[9] >> 4) & 0xF;
|
||||
let has_jumbo = (opt_nibble & PacketOptions::JUMBO_FRAME.0) != 0;
|
||||
let header_len = FIRE2_MIN_HEADER + if has_jumbo { FIRE2_JUMBO_EXT } else { 0 };
|
||||
|
||||
if src.len() < header_len { return Ok(None); }
|
||||
|
||||
let (frame, body_len) = FireFrame::read(src);
|
||||
|
||||
// ── Now wait for the body ─────────────────────────────────
|
||||
if src.len() < body_len {
|
||||
self.partial = Some(PartialFire2 { frame, body_len });
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let body = src.copy_to_bytes(body_len);
|
||||
Ok(Some(Packet { frame, body }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Encoder<Packet> for PacketCodec {
|
||||
type Error = io::Error;
|
||||
|
||||
fn encode(&mut self, item: Packet, dst: &mut BytesMut) -> Result<(), Self::Error> {
|
||||
match self.variant {
|
||||
FramingVariant::Raw => {
|
||||
dst.put(item.body);
|
||||
}
|
||||
FramingVariant::Fire2 => {
|
||||
let body_len = item.body.len();
|
||||
item.frame.write(dst, body_len);
|
||||
dst.put(item.body);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Blaze packet frame header.
|
||||
//!
|
||||
//! Two framing variants exist ("Fire" and "Fire2"). FIFA 23's variant is UNKNOWN —
|
||||
//! the capture tool will determine which it uses. This module implements Fire2
|
||||
//! (the modern EA format, used by ME3, BF3, and most post-2012 titles) because
|
||||
//! it is the most likely candidate.
|
||||
//!
|
||||
//! Fire2 header layout (12 bytes, little-endian **big**-endian per wire):
|
||||
//! ```text
|
||||
//! [0..2] u16 body length (low 16 bits; or full length if < 65536)
|
||||
//! [2..4] u16 component id
|
||||
//! [4..6] u16 command id
|
||||
//! [6..8] u16 error code
|
||||
//! [8] u8 frame type (top nibble) | padding (low nibble)
|
||||
//! [9] u8 options (top nibble) | padding (low nibble)
|
||||
//! [10..12] u16 sequence number
|
||||
//! +[12..14] u16 high 16 bits of length — only when JUMBO_FRAME option set
|
||||
//! ```
|
||||
|
||||
use bytes::{Buf, BufMut, BytesMut};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Minimum header size for a Fire2 frame (without the optional jumbo extension).
|
||||
pub const FIRE2_MIN_HEADER: usize = 12;
|
||||
|
||||
/// Extra bytes used when JUMBO_FRAME is set (extends length to 32 bits).
|
||||
pub const FIRE2_JUMBO_EXT: usize = 2;
|
||||
|
||||
/// The type of a Blaze frame.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum FrameType {
|
||||
Request = 0x0,
|
||||
Response = 0x1,
|
||||
Notify = 0x2,
|
||||
Error = 0x3,
|
||||
}
|
||||
|
||||
impl From<u8> for FrameType {
|
||||
fn from(v: u8) -> Self {
|
||||
match v {
|
||||
0x1 => FrameType::Response,
|
||||
0x2 => FrameType::Notify,
|
||||
0x3 => FrameType::Error,
|
||||
_ => FrameType::Request,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FrameType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
FrameType::Request => write!(f, "REQUEST"),
|
||||
FrameType::Response => write!(f, "RESPONSE"),
|
||||
FrameType::Notify => write!(f, "NOTIFY"),
|
||||
FrameType::Error => write!(f, "ERROR"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Option flags for a Fire2 frame.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PacketOptions(pub u8);
|
||||
|
||||
impl PacketOptions {
|
||||
pub const NONE : Self = Self(0x0);
|
||||
/// Body length exceeds 16 bits; an extra u16 follows the header.
|
||||
pub const JUMBO_FRAME: Self = Self(0x1);
|
||||
|
||||
pub fn contains(self, flag: Self) -> bool { (self.0 & flag.0) != 0 }
|
||||
}
|
||||
|
||||
/// Fire2 packet header.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FireFrame {
|
||||
pub component : u16,
|
||||
pub command : u16,
|
||||
pub error : u16,
|
||||
pub ty : FrameType,
|
||||
pub options : PacketOptions,
|
||||
pub seq : u16,
|
||||
}
|
||||
|
||||
impl FireFrame {
|
||||
/// Build a response frame that mirrors this frame's routing fields.
|
||||
pub fn response(&self) -> Self {
|
||||
Self {
|
||||
component: self.component,
|
||||
command: self.command,
|
||||
error: 0,
|
||||
ty: FrameType::Response,
|
||||
options: PacketOptions::NONE,
|
||||
seq: self.seq,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a notify frame.
|
||||
pub fn notify(component: u16, command: u16) -> Self {
|
||||
Self { component, command, error: 0, ty: FrameType::Notify, options: PacketOptions::NONE, seq: 0 }
|
||||
}
|
||||
|
||||
/// Encode this header into `dst`, prepending `body_len` at the front.
|
||||
pub fn write(&self, dst: &mut BytesMut, body_len: usize) {
|
||||
let mut options = self.options;
|
||||
if body_len > u16::MAX as usize { options = PacketOptions(options.0 | PacketOptions::JUMBO_FRAME.0); }
|
||||
|
||||
dst.put_u16(body_len as u16);
|
||||
dst.put_u16(self.component);
|
||||
dst.put_u16(self.command);
|
||||
dst.put_u16(self.error);
|
||||
dst.put_u8((self.ty as u8) << 4);
|
||||
dst.put_u8(options.0 << 4);
|
||||
dst.put_u16(self.seq);
|
||||
|
||||
if options.contains(PacketOptions::JUMBO_FRAME) {
|
||||
dst.put_u16((body_len >> 16) as u16);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a Fire2 header from `src`, which must already have `>= FIRE2_MIN_HEADER` bytes.
|
||||
/// Returns `(frame, body_length)`. Advances `src` past the header (including jumbo ext).
|
||||
pub fn read(src: &mut BytesMut) -> (FireFrame, usize) {
|
||||
let len_lo = src.get_u16() as usize;
|
||||
let component = src.get_u16();
|
||||
let command = src.get_u16();
|
||||
let error = src.get_u16();
|
||||
let ty_byte = src.get_u8();
|
||||
let opt_byte = src.get_u8();
|
||||
let seq = src.get_u16();
|
||||
|
||||
let ty = FrameType::from(ty_byte >> 4);
|
||||
let options = PacketOptions(opt_byte >> 4);
|
||||
|
||||
let body_len = if options.contains(PacketOptions::JUMBO_FRAME) {
|
||||
let len_hi = src.get_u16() as usize;
|
||||
(len_hi << 16) | len_lo
|
||||
} else {
|
||||
len_lo
|
||||
};
|
||||
|
||||
(FireFrame { component, command, error, ty, options, seq }, body_len)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod frame;
|
||||
pub mod codec;
|
||||
pub mod packet;
|
||||
|
||||
pub use frame::{FireFrame, FrameType, PacketOptions};
|
||||
pub use codec::{PacketCodec, FramingVariant};
|
||||
pub use packet::Packet;
|
||||
@@ -0,0 +1,86 @@
|
||||
use bytes::Bytes;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::frame::{FireFrame, FrameType, PacketOptions};
|
||||
use tdf::stringify::TdfStringifier;
|
||||
use tdf::reader::TdfDeserializer;
|
||||
|
||||
/// A fully framed Blaze packet.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Packet {
|
||||
/// Decoded Fire2 header.
|
||||
pub frame: FireFrame,
|
||||
/// Raw body bytes (unparsed TDF).
|
||||
pub body: Bytes,
|
||||
}
|
||||
|
||||
impl Packet {
|
||||
/// Create a raw-mode packet (no frame header — body only).
|
||||
pub fn raw(body: Bytes) -> Self {
|
||||
Packet {
|
||||
frame: FireFrame {
|
||||
component: 0,
|
||||
command: 0,
|
||||
error: 0,
|
||||
ty: FrameType::Request,
|
||||
options: PacketOptions::NONE,
|
||||
seq: 0,
|
||||
},
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an empty response that mirrors `req`'s routing fields.
|
||||
pub fn response_empty(req: &Packet) -> Self {
|
||||
Packet { frame: req.frame.response(), body: Bytes::new() }
|
||||
}
|
||||
|
||||
/// Build a response with the given TDF body.
|
||||
pub fn response<V: tdf::TdfSerialize>(req: &Packet, value: &V) -> Self {
|
||||
Packet {
|
||||
frame: req.frame.response(),
|
||||
body: Bytes::from(tdf::serialize_vec(value)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to decode the body as TDF and return a human-readable string.
|
||||
/// On any decode error, return the raw hex.
|
||||
pub fn tdf_string(&self) -> String {
|
||||
if self.body.is_empty() {
|
||||
return String::from("(empty)");
|
||||
}
|
||||
let r = TdfDeserializer::new(&self.body);
|
||||
let (s, ok) = TdfStringifier::<String>::new_string(r);
|
||||
if ok {
|
||||
s
|
||||
} else {
|
||||
format!("(decode error) raw={}", hex::encode(&self.body))
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize the packet into a JSON-friendly struct for capture logs.
|
||||
pub fn to_capture(&self) -> CaptureRecord {
|
||||
CaptureRecord {
|
||||
component: self.frame.component,
|
||||
command: self.frame.command,
|
||||
error: self.frame.error,
|
||||
ty: self.frame.ty.to_string(),
|
||||
seq: self.frame.seq,
|
||||
body_len: self.body.len(),
|
||||
raw_hex: hex::encode(&self.body),
|
||||
tdf: self.tdf_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CaptureRecord {
|
||||
pub component: u16,
|
||||
pub command: u16,
|
||||
pub error: u16,
|
||||
pub ty: String,
|
||||
pub seq: u16,
|
||||
pub body_len: usize,
|
||||
pub raw_hex: String,
|
||||
pub tdf: String,
|
||||
}
|
||||
Reference in New Issue
Block a user