HumanoidRetargeter/Formats/Ant/AntSkeletonJson.cs
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.Text.Json;
namespace HumanoidRetargeter.Formats.Ant;
/// <summary>
/// Reads the companion JOINT TABLE that an ANT animation package needs but does not carry.
/// </summary>
/// <remarks>
/// <para>
/// An ANT clip binds its channels to joints by INDEX, never by name, and the joint table
/// lives in a separate rig package (<c>package_rig.cba</c>) — only 3 of Fight Night
/// Champion's 489 packages contain one at all. So, exactly like the RenderWare importer
/// needing the model <c>.dff</c> beside a <c>.anm</c>, this importer takes the joint table
/// as companion bytes (<see cref="RetargetRequest.SkeletonData"/>).
/// </para>
/// <para>
/// The accepted shape is the simple joint dump every ANT rig extractor emits — an ordered
/// array where the array position IS the joint index the clips reference. The extractor's
/// metadata wrapper <c>{ "name": "skeleton_boxer", "joints": [...] }</c> is accepted too:
/// </para>
/// <code>
/// [ { "name": "Reference", "parent": -1 },
/// { "name": "AITrajectory", "parent": 0 },
/// { "name": "Hips", "parent": 1 }, ... ]
/// </code>
/// <para>
/// Note what is NOT here: a bind pose. ANT's <c>SkeletonAsset</c> genuinely stores only
/// <c>JointName</c> and <c>ParentIndex</c> per joint, so no extractor can supply rest
/// transforms from the animation packages — see <see cref="AntImporter"/> for how the rest
/// pose is recovered instead.
/// </para>
/// </remarks>
public static class AntSkeletonJson
{
/// <summary>One joint of the companion table: name plus parent index (-1 = root).</summary>
public readonly record struct Joint(string Name, int Parent);
/// <summary>True when the bytes look like a joint-table JSON document.</summary>
public static bool Looks(byte[] data)
{
if (data is null)
return false;
for (var i = 0; i < data.Length && i < 64; i++)
{
if (data[i] is (byte)'[' or (byte)'{')
return true;
if (data[i] is not ((byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n' or 0xEF or 0xBB or 0xBF))
return false;
}
return false;
}
/// <exception cref="FormatException">Thrown when the document is not a readable joint
/// table, an entry lacks a name, or a parent index is out of range.</exception>
public static List<Joint> Parse(byte[] data)
{
ArgumentNullException.ThrowIfNull(data);
JsonDocument doc;
try
{
doc = JsonDocument.Parse(data);
}
catch (JsonException e)
{
throw new FormatException($"Companion joint table is not valid JSON: {e.Message}", e);
}
using (doc)
{
var jointArray = doc.RootElement;
if (jointArray.ValueKind == JsonValueKind.Object
&& jointArray.TryGetProperty("joints", out var wrappedJoints))
jointArray = wrappedJoints;
if (jointArray.ValueKind != JsonValueKind.Array)
throw new FormatException(
"Companion joint table must be a JSON ARRAY of {\"name\", \"parent\"} "
+ "objects in joint-index order, or an object containing that array as "
+ "its \"joints\" property.");
var joints = new List<Joint>();
foreach (var element in jointArray.EnumerateArray())
{
if (element.ValueKind != JsonValueKind.Object
|| !element.TryGetProperty("name", out var name)
|| name.ValueKind != JsonValueKind.String)
{
throw new FormatException(
$"Joint {joints.Count} has no string \"name\" property.");
}
var parent = -1;
if (element.TryGetProperty("parent", out var p) && p.ValueKind == JsonValueKind.Number)
p.TryGetInt32(out parent);
var text = name.GetString();
if (string.IsNullOrEmpty(text))
throw new FormatException($"Joint {joints.Count} has an empty name.");
joints.Add(new Joint(text, parent));
}
if (joints.Count == 0)
throw new FormatException("Companion joint table is empty.");
for (var i = 0; i < joints.Count; i++)
{
var parent = joints[i].Parent;
if (parent >= joints.Count || parent == i)
throw new FormatException(
$"Joint {i} ('{joints[i].Name}') has an invalid parent index {parent}.");
}
return joints;
}
}
}