HumanoidRetargeter/Formats/Ant/AntStream.cs
#nullable enable annotations
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Numerics;
using HumanoidRetargeter.Maths;
namespace HumanoidRetargeter.Formats.Ant;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)
/// <summary>
/// Low-level reader for EA's ANT (Animation Tool) container — the <c>.cba</c> animation
/// packages shipped by EA Canada titles (verified on Fight Night Champion, PS3).
/// </summary>
/// <remarks>
/// <para>
/// The whole format is BIG-ENDIAN. Layout, established by decoding
/// <c>package_proxy_punch_ali.cba</c> against a known-good reference extraction of the same
/// file (312 of 312 channels reproduce bit-exactly on times, rotations and joint indices):
/// </para>
/// <code>
/// ANTSTM3b <u32 totalSize><u32 _> outer wrapper: 16-byte header, spans the file
/// then a TILING list of inner chunks, each <8-byte tag><u32 size><u32 _>:
/// ANTREF2b type/schema table (class and property names)
/// ANTDAT4b data — ONE CHUNK PER CLIP
///
/// clip payload (chunk + 16):
/// +96 u32 channel count
/// +116 u32 duration (ticks)
/// +120 u32 contact tick
/// +128 channel table, 8 bytes per entry, payload-relative offset in the 2nd u32
///
/// channel block at payload-relative `co`:
/// co+32 u32 descriptor-block pointer (block-relative)
/// descriptor records, 16 bytes each: (count, capacity, pad=0, payload-relative offset);
/// the LAST THREE records are times, positions and rotations
/// joint index: u16 at descBlock + 16*records + 2
/// times count x float32
/// positions count x vec4 — xyz followed by a ZERO w pad (16 bytes per key)
/// rotations count x float32 quaternion, W FIRST (wxyz)
/// </code>
/// <para><b>The vec4 position stride is not incidental.</b> A widely circulated extraction of
/// these packages read the position array with a 12-byte (vec3) stride over what is really a
/// 16-byte vec4 buffer, so every key after the first slid one float further out of alignment
/// and the last quarter of each channel was never read at all. Reading vec4 and discarding
/// <c>w</c> is what makes the data correct — the corrupted form is recognisable because every
/// 4th float of the flattened stream is exactly 0.</para>
/// </remarks>
public static class AntStream
{
/// <summary>Outer container tag; also the file's magic.</summary>
public const string StreamTag = "ANTSTM3b";
private const string DataTag = "ANTDAT4b";
private const int ChunkHeader = 16;
/// <summary>True when the bytes open with the ANT stream magic.</summary>
public static bool IsAnt(byte[] data)
=> data is not null && data.Length >= ChunkHeader && Tag(data, 0) == StreamTag;
/// <summary>One animated joint of a clip: the joint's index into the companion
/// skeleton plus its sampled local key track.</summary>
public sealed class Channel
{
public int JointIndex { get; init; }
public float[] Times { get; init; } = Array.Empty<float>();
public XForm[] Keys { get; init; } = Array.Empty<XForm>();
}
/// <summary>One clip: its key tracks plus the authored tick metadata.</summary>
public sealed class ClipData
{
public int DurationTicks { get; init; }
public int ContactTick { get; init; }
public List<Channel> Channels { get; } = new();
}
/// <summary>
/// Parses every clip chunk in an ANT package. Chunks that do not decode as clips (the
/// schema table, controller/asset data) are skipped rather than failing the import — a
/// package mixes them freely.
/// </summary>
/// <exception cref="FormatException">Thrown when the bytes are not an ANT stream.</exception>
public static List<ClipData> ParseClips(byte[] data)
{
ArgumentNullException.ThrowIfNull(data);
if (!IsAnt(data))
throw new FormatException("Not an EA ANT stream (expected the 'ANTSTM3b' magic).");
var clips = new List<ClipData>();
var offset = ChunkHeader;
while (offset + ChunkHeader <= data.Length)
{
var tag = Tag(data, offset);
if (tag is null)
break;
var size = ReadU32(data, offset + 8);
// A zero//overlong size would loop forever or read past the end: stop cleanly,
// keeping whatever parsed so far (packages are append-only chunk lists).
if (size < ChunkHeader || offset + size > data.Length)
break;
if (tag == DataTag && TryParseClip(data, offset, (int)size) is { } clip)
clips.Add(clip);
offset += (int)size;
}
return clips;
}
private static ClipData? TryParseClip(byte[] d, int chunk, int chunkSize)
{
var p = chunk + ChunkHeader;
var end = chunkSize - ChunkHeader; // payload-relative end
if (end < 136)
return null;
var channelCount = (int)ReadU32(d, p + 96);
// Non-clip data chunks land here too; their +96 word is not a plausible channel
// count, and the table would run past the payload.
if (channelCount <= 0 || channelCount > 4096 || 128 + 8 * channelCount > end)
return null;
var offsets = new int[channelCount];
for (var i = 0; i < channelCount; i++)
{
offsets[i] = (int)ReadU32(d, p + 128 + 8 * i + 4);
if (offsets[i] <= 0 || offsets[i] >= end)
return null;
}
var clip = new ClipData
{
DurationTicks = (int)ReadU32(d, p + 116),
ContactTick = (int)ReadU32(d, p + 120),
};
for (var i = 0; i < channelCount; i++)
{
var start = offsets[i];
var stop = i + 1 < channelCount ? offsets[i + 1] : end;
var channel = TryParseChannel(d, p, start, stop);
if (channel is null)
return null;
clip.Channels.Add(channel);
}
return clip;
}
private static Channel? TryParseChannel(byte[] d, int p, int start, int stop)
{
if (start + 36 > stop)
return null;
var descriptors = start + (int)ReadU32(d, p + start + 32);
if (descriptors <= start || descriptors + 16 > stop)
return null;
// (count, capacity, pad, offset) records run until one fails the shape test.
var records = new List<(int Count, int Offset)>();
for (var r = descriptors; r + 16 <= stop; r += 16)
{
var count = (int)ReadU32(d, p + r);
var capacity = (int)ReadU32(d, p + r + 4);
var pad = ReadU32(d, p + r + 8);
var offset = (int)ReadU32(d, p + r + 12);
if (pad != 0 || count != capacity || count <= 0 || count > 65536
|| offset <= start || offset > stop)
break;
records.Add((count, offset));
}
if (records.Count < 3)
return null;
var times = records[^3];
var positions = records[^2];
var rotations = records[^1];
var n = times.Count;
if (positions.Count != n || rotations.Count != n)
return null;
if (times.Offset + 4 * n > stop
|| positions.Offset + 16 * n > stop || rotations.Offset + 16 * n > stop)
return null;
var jointWord = descriptors + 16 * records.Count + 2;
if (jointWord + 2 > stop)
return null;
var t = new float[n];
var keys = new XForm[n];
for (var k = 0; k < n; k++)
{
t[k] = ReadF32(d, p + times.Offset + 4 * k);
// vec4 stride: xyz then a zero w pad. Reading this as vec3 is the corruption
// described in the class remarks.
var pos = new Vector3(
ReadF32(d, p + positions.Offset + 16 * k),
ReadF32(d, p + positions.Offset + 16 * k + 4),
ReadF32(d, p + positions.Offset + 16 * k + 8));
// Quaternion is stored W FIRST.
var w = ReadF32(d, p + rotations.Offset + 16 * k);
var rot = new Quaternion(
ReadF32(d, p + rotations.Offset + 16 * k + 4),
ReadF32(d, p + rotations.Offset + 16 * k + 8),
ReadF32(d, p + rotations.Offset + 16 * k + 12),
w);
if (!IsFinite(pos) || !IsFinite(rot))
return null;
keys[k] = new XForm(pos, MathQ.Normalize(rot));
}
return new Channel
{
JointIndex = ReadU16(d, p + jointWord),
Times = t,
Keys = keys,
};
}
// ------------------------------------------------------------------ primitives
private static string? Tag(byte[] d, int offset)
{
if (offset + 8 > d.Length)
return null;
for (var i = 0; i < 8; i++)
{
var c = d[offset + i];
if (c is < 0x30 or > 0x7a)
return null;
}
var tag = System.Text.Encoding.ASCII.GetString(d, offset, 8);
return tag.StartsWith("ANT", StringComparison.Ordinal) ? tag : null;
}
private static uint ReadU32(byte[] d, int o)
=> BinaryPrimitives.ReadUInt32BigEndian(d.AsSpan(o, 4));
private static ushort ReadU16(byte[] d, int o)
=> BinaryPrimitives.ReadUInt16BigEndian(d.AsSpan(o, 2));
private static float ReadF32(byte[] d, int o)
=> BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32BigEndian(d.AsSpan(o, 4)));
private static bool IsFinite(Vector3 v)
=> float.IsFinite(v.X) && float.IsFinite(v.Y) && float.IsFinite(v.Z);
private static bool IsFinite(Quaternion q)
=> float.IsFinite(q.X) && float.IsFinite(q.Y) && float.IsFinite(q.Z) && float.IsFinite(q.W)
&& q.LengthSquared() > 1e-8f;
}