2 Commits

Author SHA1 Message Date
funman300 b7126b255f wip(blaze-proto): retained Blaze frame/codec refinement WIP
RETAINED PRE-EXISTING WIP (brought forward after verification, not authored
here, not production-ready). Blaze wire frame/packet layout work (Fire2
header still flagged as an unvalidated FIFA23 guess). No secrets/captures
staged (captures/ + certs/ gitignored). Preserved off detached base f4f33969f2.
2026-08-20 16:08:36 +00:00
funman300 f4f33969f2 docs(frame): flag Fire2 header as an unvalidated FIFA23 guess vs the proven layout
frame.rs implements a 12-byte Fire2 header that was a pre-FIFA17-recon guess at
FIFA 23's variant; its docstring described it plainly, and it is wrong in every
field for the proven FIFA 17 Fire2 layout (16 bytes, u32 length, u24 msgNum,
(msgType<<5)|userIndex byte, options, reserved; no error field, no jumbo). Correct
the docstring to cite openfut-protocol-blaze::fire2 as authoritative and mark the
implemented 12-byte layout as an unvalidated capture-tool guess to reconcile once
a live FIFA 23 capture exists. Code unchanged: the server is a capture tool whose
documented behaviour is 'guess, then fall back to Raw', so its framing is not
rewritten to an assumption without FIFA 23 evidence.
2026-08-16 21:29:44 +00:00
9 changed files with 256 additions and 151 deletions
+20 -7
View File
@@ -12,7 +12,7 @@ use std::io;
use tokio_util::codec::{Decoder, Encoder};
use super::{
frame::{FireFrame, FIRE2_MIN_HEADER, FIRE2_JUMBO_EXT, PacketOptions},
frame::{FireFrame, PacketOptions, FIRE2_JUMBO_EXT, FIRE2_MIN_HEADER},
packet::Packet,
};
@@ -44,7 +44,10 @@ pub struct PacketCodec {
impl PacketCodec {
pub fn new(variant: FramingVariant) -> Self {
PacketCodec { variant, partial: None }
PacketCodec {
variant,
partial: None,
}
}
}
@@ -55,7 +58,9 @@ impl Decoder for PacketCodec {
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); }
if src.is_empty() {
return Ok(None);
}
let body = src.copy_to_bytes(src.len());
Ok(Some(Packet::raw(body)))
}
@@ -63,21 +68,29 @@ impl Decoder for PacketCodec {
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();
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); }
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); }
if src.len() < header_len {
return Ok(None);
}
let (frame, body_len) = FireFrame::read(src);
+54 -11
View File
@@ -1,13 +1,35 @@
//! Blaze packet frame header.
//! Blaze packet frame header (FIFA 23 capture-tool GUESS — not validated).
//!
//! 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.
//! FIFA 23's framing is UNKNOWN; this crate's server is a capture tool that
//! decodes with a best guess and falls back to `FramingVariant::Raw` (see
//! `codec.rs`) when Fire2 decode fails. The layout implemented below is that
//! guess and it has NOT been validated against a live FIFA 23 client.
//!
//! Fire2 header layout (12 bytes, little-endian **big**-endian per wire):
//! !!! It also DISAGREES with the proven FIFA 17 Fire2 layout. !!!
//!
//! The authoritative, live-driven layout is `openfut-protocol-blaze::fire2`
//! (byte-for-byte parity-tested; it drove a retail FIFA 17 client through login).
//! It is **16 bytes**, big-endian, with a `u32` length and NO error field and NO
//! jumbo escape:
//! ```text
//! [0..2] u16 body length (low 16 bits; or full length if < 65536)
//! [0..4] u32 payload length (bytes after header + metadata)
//! [4..6] u16 metadata length
//! [6..8] u16 component
//! [8..10] u16 command
//! [10..13] u24 msgNum
//! [13] u8 (msgType << 5) | (userIndex & 0x1F)
//! [14] u8 options
//! [15] u8 reserved
//! ```
//! If the project's "FIFA 23 uses the same wire format as FIFA 17" hypothesis is
//! confirmed by a live FIFA 23 capture, this module should adopt that 16-byte
//! layout (or depend on `openfut-protocol-blaze::fire2` directly). Until there is
//! FIFA 23 evidence, the guessed 12-byte layout below is retained as-is so the
//! capture tool keeps its documented "guess, then fall back to Raw" behaviour.
//!
//! Guessed Fire2 header layout THIS MODULE IMPLEMENTS (12 bytes, big-endian; unvalidated):
//! ```text
//! [0..2] u16 body length (low 16 bits)
//! [2..4] u16 component id
//! [4..6] u16 command id
//! [6..8] u16 error code
@@ -67,7 +89,9 @@ impl PacketOptions {
/// 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 }
pub fn contains(self, flag: Self) -> bool {
(self.0 & flag.0) != 0
}
}
/// Fire2 packet header.
@@ -96,13 +120,22 @@ impl FireFrame {
/// 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 }
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); }
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);
@@ -138,6 +171,16 @@ impl FireFrame {
len_lo
};
(FireFrame { component, command, error, ty, options, seq }, body_len)
(
FireFrame {
component,
command,
error,
ty,
options,
seq,
},
body_len,
)
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
pub mod frame;
pub mod codec;
pub mod frame;
pub mod packet;
pub use codec::{FramingVariant, PacketCodec};
pub use frame::{FireFrame, FrameType, PacketOptions};
pub use codec::{PacketCodec, FramingVariant};
pub use packet::Packet;
+5 -2
View File
@@ -2,8 +2,8 @@ use bytes::Bytes;
use serde::Serialize;
use super::frame::{FireFrame, FrameType, PacketOptions};
use tdf::stringify::TdfStringifier;
use tdf::reader::TdfDeserializer;
use tdf::stringify::TdfStringifier;
/// A fully framed Blaze packet.
#[derive(Debug, Clone)]
@@ -32,7 +32,10 @@ impl Packet {
/// 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() }
Packet {
frame: req.frame.response(),
body: Bytes::new(),
}
}
/// Build a response with the given TDF body.
+41 -15
View File
@@ -3,21 +3,29 @@
//! Every packet (in and out) is appended to a JSONL file so the session can
//! be replayed with `jq` and the component/command IDs can be catalogued.
use blaze_proto::Packet;
use chrono::Utc;
use serde_json::json;
use std::{
fs::{File, OpenOptions},
io::Write,
sync::{Arc, Mutex},
};
use blaze_proto::Packet;
use chrono::Utc;
use serde_json::json;
use tracing::{info, warn};
#[derive(Clone, Copy)]
pub enum Dir { In, Out }
pub enum Dir {
In,
Out,
}
impl Dir {
fn as_str(self) -> &'static str { match self { Dir::In => "IN", Dir::Out => "OUT" } }
fn as_str(self) -> &'static str {
match self {
Dir::In => "IN",
Dir::Out => "OUT",
}
}
}
pub struct CaptureWriter {
@@ -41,7 +49,11 @@ impl CaptureWriter {
}
pub fn record(&self, pkt: &Packet, peer: &str, dir: Dir) {
let n = { let mut c = self.counter.lock().unwrap(); *c += 1; *c };
let n = {
let mut c = self.counter.lock().unwrap();
*c += 1;
*c
};
let ts = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let rec = pkt.to_capture();
@@ -67,21 +79,35 @@ impl CaptureWriter {
}
if self.pretty {
println!("\n╔══ #{n:04} {ts} {peer} {d} ══",
d = dir.as_str());
println!("║ component=0x{:04X} command=0x{:04X} {ty} seq={seq}",
rec.component, rec.command, ty = rec.ty, seq = rec.seq);
println!("\n╔══ #{n:04} {ts} {peer} {d} ══", d = dir.as_str());
println!(
"║ component=0x{:04X} command=0x{:04X} {ty} seq={seq}",
rec.component,
rec.command,
ty = rec.ty,
seq = rec.seq
);
if rec.body_len > 0 {
println!("║ body ({} bytes):\n{}",
println!(
"║ body ({} bytes):\n{}",
rec.body_len,
pkt.body.chunks(16)
pkt.body
.chunks(16)
.enumerate()
.map(|(i, chunk)| {
let h: String = chunk.iter().map(|b| format!("{b:02X}")).collect::<Vec<_>>().join(" ");
let a: String = chunk.iter().map(|&b| if b.is_ascii_graphic() { b as char } else { '.' }).collect();
let h: String = chunk
.iter()
.map(|b| format!("{b:02X}"))
.collect::<Vec<_>>()
.join(" ");
let a: String = chunk
.iter()
.map(|&b| if b.is_ascii_graphic() { b as char } else { '.' })
.collect();
format!("\n{:04X}: {h:<47} {a}", i * 16)
})
.collect::<String>());
.collect::<String>()
);
println!("║ TDF: {}", rec.tdf);
}
println!("╚══════════════════════════════════════════════════");
+1 -2
View File
@@ -45,7 +45,6 @@ impl Config {
pub fn load(path: &str) -> anyhow::Result<Self> {
let text = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("cannot read {path}: {e}"))?;
toml::from_str(&text)
.map_err(|e| anyhow::anyhow!("config parse error in {path}: {e}"))
toml::from_str(&text).map_err(|e| anyhow::anyhow!("config parse error in {path}: {e}"))
}
}
+12 -6
View File
@@ -3,8 +3,8 @@
//! All FIFA 23 component/command IDs are UNKNOWN until capture reveals them.
//! The default handler returns an empty response — never panics, never hangs.
use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc};
use blaze_proto::Packet;
use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc};
use tracing::warn;
pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
@@ -15,15 +15,19 @@ pub struct Dispatcher {
}
impl Dispatcher {
pub fn new() -> Self { Dispatcher { table: HashMap::new() } }
pub fn new() -> Self {
Dispatcher {
table: HashMap::new(),
}
}
pub fn register<F, Fut>(&mut self, component: u16, command: u16, f: F)
where
F: Fn(Packet) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Option<Packet>> + Send + 'static,
{
self.table.insert((component, command),
Arc::new(move |p| Box::pin(f(p))));
self.table
.insert((component, command), Arc::new(move |p| Box::pin(f(p))));
}
pub async fn dispatch(&self, pkt: Packet) -> Option<Packet> {
@@ -31,9 +35,11 @@ impl Dispatcher {
if let Some(h) = self.table.get(&key) {
h(pkt).await
} else {
warn!(component = format!("0x{:04X}", key.0),
warn!(
component = format!("0x{:04X}", key.0),
command = format!("0x{:04X}", key.1),
"unhandled — returning empty response");
"unhandled — returning empty response"
);
Some(Packet::response_empty(&pkt))
}
}
+20 -6
View File
@@ -33,14 +33,19 @@ async fn main() -> anyhow::Result<()> {
)
.init();
let cfg_path = std::env::args().nth(1).unwrap_or_else(|| "config.toml".into());
let cfg_path = std::env::args()
.nth(1)
.unwrap_or_else(|| "config.toml".into());
let cfg = config::Config::load(&cfg_path)?;
info!(config = cfg_path, "loaded");
let tls_cfg = tls::load_tls_config(&cfg.tls.cert, &cfg.tls.key)?;
let acceptor = TlsAcceptor::from(Arc::clone(&tls_cfg));
let capture = Arc::new(CaptureWriter::open(&cfg.capture.dir, cfg.capture.pretty_print)?);
let capture = Arc::new(CaptureWriter::open(
&cfg.capture.dir,
cfg.capture.pretty_print,
)?);
let dispatcher = Arc::new(Dispatcher::new());
let redir_addr: SocketAddr = cfg.redirector.listen.parse()?;
@@ -60,13 +65,17 @@ async fn main() -> anyhow::Result<()> {
tokio::spawn(async move {
loop {
match redir_listener.accept().await {
Err(e) => { error!(error=%e, "redirector accept error"); }
Err(e) => {
error!(error=%e, "redirector accept error");
}
Ok((tcp, peer)) => {
let acc = redir_acceptor.clone();
let cap = Arc::clone(&redir_cap);
let host = Arc::clone(&adv_host);
tokio::spawn(async move {
if let Err(e) = handle_redirector(tcp, peer, acc, cap, &host, advertise_port).await {
if let Err(e) =
handle_redirector(tcp, peer, acc, cap, &host, advertise_port).await
{
warn!(%peer, error=%e, "redirector session error");
}
});
@@ -78,7 +87,9 @@ async fn main() -> anyhow::Result<()> {
// ── Blaze loop ────────────────────────────────────────────────────────────
loop {
match blaze_listener.accept().await {
Err(e) => { error!(error=%e, "blaze accept error"); }
Err(e) => {
error!(error=%e, "blaze accept error");
}
Ok((tcp, peer)) => {
let acc = acceptor.clone();
let cap = Arc::clone(&capture);
@@ -120,7 +131,10 @@ async fn handle_redirector(
// capture will reveal it. For now we return our advertise address as a
// simple string blob so the client gets something to parse.
let body = build_redirector_body(host, port);
let resp = Packet { frame: req.frame.response(), body };
let resp = Packet {
frame: req.frame.response(),
body,
};
capture.record(&resp, &peer_str, Dir::Out);
io.send(resp).await?;
}
+8 -7
View File
@@ -1,7 +1,10 @@
use std::{fs::File, io::BufReader, sync::Arc};
use rustls::{ServerConfig, pki_types::{CertificateDer, PrivateKeyDer}};
use rustls_pemfile::{certs, private_key};
use anyhow::Context;
use rustls::{
pki_types::{CertificateDer, PrivateKeyDer},
ServerConfig,
};
use rustls_pemfile::{certs, private_key};
use std::{fs::File, io::BufReader, sync::Arc};
/// Load a TLS `ServerConfig` from PEM cert and key files.
///
@@ -13,10 +16,8 @@ pub fn load_tls_config(cert_path: &str, key_path: &str) -> anyhow::Result<Arc<Se
// Install the ring crypto provider once. Harmless if called multiple times.
let _ = rustls::crypto::ring::default_provider().install_default();
let cert_file = File::open(cert_path)
.with_context(|| format!("open cert: {cert_path}"))?;
let key_file = File::open(key_path)
.with_context(|| format!("open key: {key_path}"))?;
let cert_file = File::open(cert_path).with_context(|| format!("open cert: {cert_path}"))?;
let key_file = File::open(key_path).with_context(|| format!("open key: {key_path}"))?;
let certs: Vec<CertificateDer> = certs(&mut BufReader::new(cert_file))
.collect::<Result<_, _>>()