Binary FBX writer for the AutoRig tool. Serializes an FbxNode tree to the FBX binary format with u32-style headers (versions 7000-7499), writing node headers, properties (scalars, arrays, strings, blobs), nested children, and the FBX footer.
using System.Buffers.Binary;
using System.Text;
namespace AutoRig.Formats.Fbx;
/// <summary>
/// Binary FBX writer — the inverse of <see cref="FbxTokenizer"/>'s binary path.
/// Writes the u32-header record layout (versions < 7500), arrays uncompressed.
/// The 160-byte footer is zero-filled (readers, including the FBX SDK and our own
/// tokenizer, do not validate it).
/// </summary>
public static class FbxWriter
{
static readonly byte[] Magic = "Kaydara FBX Binary \0\x1a\0"u8.ToArray();
/// <exception cref="FormatException">Version >= 7500 (wide headers unsupported) or
/// a property of an unsupported CLR type.</exception>
public static byte[] Write( FbxNode root, uint version = 7400 )
{
ArgumentNullException.ThrowIfNull( root );
if ( version is < 7000 or >= 7500 )
throw new FormatException(
$"FbxWriter supports versions 7000-7499 (u32 record headers); got {version}." );
using var stream = new MemoryStream();
stream.Write( Magic );
Span<byte> u32 = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian( u32, version );
stream.Write( u32 );
foreach ( var child in root.Children )
WriteNode( stream, child );
WriteNullRecord( stream );
// Footer: 16-byte footer id, 4 zero bytes, zero-pad to a 16-byte boundary
// (a full 16 when already aligned), version echo, 120 zero bytes, terminal
// magic. The FBX SDK validates this block; a zeroed footer makes it reject
// the whole file (imports as empty).
stream.Write( FooterId );
stream.Write( new byte[4] );
var padding = (int)(16 - stream.Position % 16);
stream.Write( new byte[padding] );
BinaryPrimitives.WriteUInt32LittleEndian( u32, version );
stream.Write( u32 );
stream.Write( new byte[120] );
stream.Write( FooterMagic );
return stream.ToArray();
}
static readonly byte[] FooterId =
{
0xFA, 0xBC, 0xAB, 0x09, 0xD0, 0xC8, 0xD4, 0x66,
0xB1, 0x76, 0xFB, 0x83, 0x1C, 0xF7, 0x26, 0x7E,
};
static readonly byte[] FooterMagic =
{
0xF8, 0x5A, 0x8C, 0x6A, 0xDE, 0xF5, 0xD9, 0x7E,
0xEC, 0xE9, 0x0C, 0xE3, 0x75, 0x8F, 0x29, 0x0B,
};
static void WriteNullRecord( MemoryStream stream ) => stream.Write( new byte[13] );
static void WriteNode( MemoryStream stream, FbxNode node )
{
var headerAt = stream.Position;
stream.Write( new byte[13] ); // placeholder: endOffset, numProps, propListLen, nameLen
var nameBytes = Encoding.ASCII.GetBytes( node.Name );
if ( nameBytes.Length > byte.MaxValue )
throw new FormatException( $"FBX node name too long: '{node.Name}'." );
stream.Write( nameBytes );
var propsAt = stream.Position;
foreach ( var property in node.Properties )
WriteProperty( stream, node.Name, property );
var propsLength = stream.Position - propsAt;
// Sentinel rule (matches Blender's encoder): a NULL record terminates the
// nested list when the node has children, and ALSO when it is completely
// empty (no properties, no children) - `References:` style nodes.
if ( node.Children.Count > 0 || node.Properties.Count == 0 )
{
foreach ( var child in node.Children )
WriteNode( stream, child );
WriteNullRecord( stream );
}
var end = stream.Position;
stream.Position = headerAt;
Span<byte> header = stackalloc byte[13];
BinaryPrimitives.WriteUInt32LittleEndian( header[..4], checked((uint)end) );
BinaryPrimitives.WriteUInt32LittleEndian( header[4..8], (uint)node.Properties.Count );
BinaryPrimitives.WriteUInt32LittleEndian( header[8..12], checked((uint)propsLength) );
header[12] = (byte)nameBytes.Length;
stream.Write( header );
stream.Position = end;
}
static void WriteProperty( MemoryStream stream, string owner, object value )
{
Span<byte> scratch = stackalloc byte[8];
switch ( value )
{
case short s:
stream.WriteByte( (byte)'Y' );
BinaryPrimitives.WriteInt16LittleEndian( scratch[..2], s );
stream.Write( scratch[..2] );
break;
case bool b:
stream.WriteByte( (byte)'C' );
stream.WriteByte( b ? (byte)1 : (byte)0 );
break;
case int i:
stream.WriteByte( (byte)'I' );
BinaryPrimitives.WriteInt32LittleEndian( scratch[..4], i );
stream.Write( scratch[..4] );
break;
case float f:
stream.WriteByte( (byte)'F' );
BinaryPrimitives.WriteSingleLittleEndian( scratch[..4], f );
stream.Write( scratch[..4] );
break;
case double d:
stream.WriteByte( (byte)'D' );
BinaryPrimitives.WriteDoubleLittleEndian( scratch, d );
stream.Write( scratch );
break;
case long l:
stream.WriteByte( (byte)'L' );
BinaryPrimitives.WriteInt64LittleEndian( scratch, l );
stream.Write( scratch );
break;
case float[] floats:
WriteArrayHeader( stream, 'f', floats.Length, floats.Length * 4 );
foreach ( var x in floats )
{
BinaryPrimitives.WriteSingleLittleEndian( scratch[..4], x );
stream.Write( scratch[..4] );
}
break;
case double[] doubles:
WriteArrayHeader( stream, 'd', doubles.Length, doubles.Length * 8 );
foreach ( var x in doubles )
{
BinaryPrimitives.WriteDoubleLittleEndian( scratch, x );
stream.Write( scratch );
}
break;
case long[] longs:
WriteArrayHeader( stream, 'l', longs.Length, longs.Length * 8 );
foreach ( var x in longs )
{
BinaryPrimitives.WriteInt64LittleEndian( scratch, x );
stream.Write( scratch );
}
break;
case int[] ints:
WriteArrayHeader( stream, 'i', ints.Length, ints.Length * 4 );
foreach ( var x in ints )
{
BinaryPrimitives.WriteInt32LittleEndian( scratch[..4], x );
stream.Write( scratch[..4] );
}
break;
case string text:
{
stream.WriteByte( (byte)'S' );
var bytes = Encoding.UTF8.GetBytes( text );
BinaryPrimitives.WriteUInt32LittleEndian( scratch[..4], (uint)bytes.Length );
stream.Write( scratch[..4] );
stream.Write( bytes );
break;
}
case byte[] blob:
stream.WriteByte( (byte)'R' );
BinaryPrimitives.WriteUInt32LittleEndian( scratch[..4], (uint)blob.Length );
stream.Write( scratch[..4] );
stream.Write( blob );
break;
default:
throw new FormatException(
$"FBX node '{owner}': cannot write property of type {value?.GetType().Name ?? "null"}." );
}
}
static void WriteArrayHeader( MemoryStream stream, char code, int count, int byteLength )
{
Span<byte> header = stackalloc byte[13];
header[0] = (byte)code;
BinaryPrimitives.WriteUInt32LittleEndian( header[1..5], (uint)count );
BinaryPrimitives.WriteUInt32LittleEndian( header[5..9], 0 ); // encoding 0 = raw
BinaryPrimitives.WriteUInt32LittleEndian( header[9..13], (uint)byteLength );
stream.Write( header );
}
}