AutoRig/Formats/Fbx/FbxWriter.cs

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.

File Access
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 &lt; 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 );
    }
}