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>
203 lines
6.4 KiB
Rust
203 lines
6.4 KiB
Rust
//! Human-readable dumps for debugging and capture review.
|
|
//!
|
|
//! Diagnostics only: nothing here is part of the wire contract, and no output
|
|
//! format should ever be parsed back.
|
|
|
|
use crate::fire2::{Frame, Header};
|
|
use crate::heat2::{Struct, Value};
|
|
use std::fmt::Write as _;
|
|
|
|
/// Render a TDF struct as an indented tree, mirroring `heat2.py::dump`.
|
|
pub fn dump_struct(s: &Struct) -> String {
|
|
let mut out = String::new();
|
|
write_struct(s, 0, &mut out);
|
|
out
|
|
}
|
|
|
|
fn write_struct(s: &Struct, depth: usize, out: &mut String) {
|
|
for (tag, value) in s.iter() {
|
|
let pad = " ".repeat(depth);
|
|
let _ = write!(out, "{pad}{tag} ({})", value.type_id().name());
|
|
match value {
|
|
Value::Struct(inner) => {
|
|
let _ = writeln!(out, " {{");
|
|
write_struct(inner, depth + 1, out);
|
|
let _ = writeln!(out, "{pad}}}");
|
|
}
|
|
Value::List { elem, items } => {
|
|
let _ = writeln!(out, " [{} x {}]", elem.name(), items.len());
|
|
for item in items {
|
|
write_value(item, depth + 1, out);
|
|
}
|
|
}
|
|
Value::Map { key, val, entries } => {
|
|
let _ = writeln!(
|
|
out,
|
|
" {{{} -> {} x {}}}",
|
|
key.name(),
|
|
val.name(),
|
|
entries.len()
|
|
);
|
|
for (k, v) in entries {
|
|
write_value(k, depth + 1, out);
|
|
write_value(v, depth + 2, out);
|
|
}
|
|
}
|
|
other => {
|
|
let _ = writeln!(out, " = {}", scalar(other));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn write_value(value: &Value, depth: usize, out: &mut String) {
|
|
let pad = " ".repeat(depth);
|
|
match value {
|
|
Value::Struct(inner) => {
|
|
let _ = writeln!(out, "{pad}{{");
|
|
write_struct(inner, depth + 1, out);
|
|
let _ = writeln!(out, "{pad}}}");
|
|
}
|
|
other => {
|
|
let _ = writeln!(out, "{pad}{}", scalar(other));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn scalar(value: &Value) -> String {
|
|
match value {
|
|
Value::Int(v) => format!("{v}"),
|
|
Value::String(s) => format!("{s:?}"),
|
|
Value::Blob(b) => format!("<{} bytes> {}", b.len(), hex(&b[..b.len().min(32)])),
|
|
Value::VarList(v) => format!("{v:?}"),
|
|
Value::ObjType { component, ty } => format!("({component}, {ty})"),
|
|
Value::ObjId { component, ty, id } => format!("({component}, {ty}, {id})"),
|
|
Value::Float(f) => format!("{f}"),
|
|
Value::Union { key, member } => match member {
|
|
Some(m) => format!("union[{key}] {} = {}", m.0, scalar(&m.1)),
|
|
None => format!("union[{key}] unset"),
|
|
},
|
|
Value::Struct(_) | Value::List { .. } | Value::Map { .. } => String::from("..."),
|
|
}
|
|
}
|
|
|
|
/// One-line summary of a Fire2 header.
|
|
pub fn describe_header(h: &Header) -> String {
|
|
format!(
|
|
"component=0x{:04x} command=0x{:04x} {} msgNum={} userIdx={} opts=0x{:02x} \
|
|
payload={}B metadata={}B",
|
|
h.component,
|
|
h.command,
|
|
h.msg_type.name(),
|
|
h.msg_num,
|
|
h.user_index,
|
|
h.options,
|
|
h.payload_len,
|
|
h.metadata_len
|
|
)
|
|
}
|
|
|
|
/// Header summary plus a decoded body, falling back to hex when it will not
|
|
/// parse. A capture is most valuable exactly when the body is malformed, so
|
|
/// this must never fail.
|
|
pub fn describe_frame(frame: &Frame) -> String {
|
|
let mut out = describe_header(&frame.header);
|
|
out.push('\n');
|
|
if frame.payload.is_empty() {
|
|
out.push_str("(empty payload)\n");
|
|
return out;
|
|
}
|
|
match crate::heat2::decode(&frame.payload) {
|
|
Ok(body) => out.push_str(&dump_struct(&body)),
|
|
Err(e) => {
|
|
let _ = writeln!(out, "(TDF decode failed: {e})");
|
|
out.push_str(&hexdump(&frame.payload, 256));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn hex(bytes: &[u8]) -> String {
|
|
let mut s = String::with_capacity(bytes.len() * 2);
|
|
for b in bytes {
|
|
let _ = write!(s, "{b:02x}");
|
|
}
|
|
s
|
|
}
|
|
|
|
/// Classic offset / hex / ASCII dump, truncated to `limit` bytes.
|
|
pub fn hexdump(bytes: &[u8], limit: usize) -> String {
|
|
let shown = &bytes[..bytes.len().min(limit)];
|
|
let mut out = String::new();
|
|
for (row, chunk) in shown.chunks(16).enumerate() {
|
|
let _ = write!(out, "{:08x} ", row * 16);
|
|
for i in 0..16 {
|
|
match chunk.get(i) {
|
|
Some(b) => {
|
|
let _ = write!(out, "{b:02x} ");
|
|
}
|
|
None => out.push_str(" "),
|
|
}
|
|
if i == 7 {
|
|
out.push(' ');
|
|
}
|
|
}
|
|
out.push_str(" |");
|
|
for b in chunk {
|
|
out.push(if (0x20..0x7F).contains(b) {
|
|
*b as char
|
|
} else {
|
|
'.'
|
|
});
|
|
}
|
|
out.push_str("|\n");
|
|
}
|
|
if bytes.len() > limit {
|
|
let _ = writeln!(out, "... {} more bytes", bytes.len() - limit);
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::fire2::MsgType;
|
|
use crate::heat2;
|
|
|
|
#[test]
|
|
fn dumps_nested_structures() {
|
|
let s = Struct::new().with("PID", Value::Int(33068179)).with(
|
|
"CINF",
|
|
Value::Struct(Struct::new().with("ENV", Value::String("prod".into()))),
|
|
);
|
|
let text = dump_struct(&s);
|
|
assert!(text.contains("PID (int) = 33068179"), "{text}");
|
|
assert!(text.contains("ENV (string) = \"prod\""), "{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn describes_a_frame_with_an_undecodable_body() {
|
|
// 0xFF is not a TDF type byte, so this must fall back to hex.
|
|
let frame = Frame::new(9, 7, 1, MsgType::Reply, vec![0xFF; 8]);
|
|
let text = describe_frame(&frame);
|
|
assert!(text.contains("decode failed"), "{text}");
|
|
assert!(text.contains("ff ff ff"), "{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn describes_a_frame_with_a_good_body() {
|
|
let body = Struct::new().with("A", Value::Int(1));
|
|
let frame = Frame::new(9, 7, 1, MsgType::Reply, heat2::encode(&body));
|
|
let text = describe_frame(&frame);
|
|
assert!(text.contains("REPLY"), "{text}");
|
|
assert!(text.contains("A (int) = 1"), "{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn hexdump_truncates_and_says_so() {
|
|
let text = hexdump(&[0x41; 100], 32);
|
|
assert!(text.contains("68 more bytes"), "{text}");
|
|
assert!(text.contains("AAAA"), "{text}");
|
|
}
|
|
}
|