Files
OpenFUT/openfut-protocol-blaze/src/diagnostics.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

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