tests/HumanoidRetargeter.Tests/VrmInputTests.cs

Unit tests for VRM (.vrm) import and mapping behavior. Builds synthetic tiny GLB/VRM blobs, imports/inspects them via Retargeter API, and asserts that authored humanoid mappings from VRM extensions are detected, preferred over presets, and used end-to-end during conversion.

File Access
using System.Text;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Target;
using Xunit;
using HumanoidRetargeter.Core;

namespace HumanoidRetargeter.Tests;

/// <summary>
/// .vrm input tests: a VRM is a glTF 2.0 GLB container plus a VRM extension whose
/// <c>humanoid.humanBones</c> block is an EXPLICIT, authored bone map. The importer must
/// surface it as <see cref="HumanoidRetargeter.Core.Skeleton.SourceScene.AuthoredMapping"/>
/// (<see cref="MappingSource.Authored"/>, confidence 1.0) and the mapping cascade must
/// prefer it over presets/auto detection. Both extension layouts are covered: VRM 0.x
/// (<c>extensions.VRM.humanoid.humanBones</c> as an array of <c>{bone, node}</c>) and
/// VRM 1.0 (<c>extensions.VRMC_vrm.humanoid.humanBones</c> as a name-keyed object).
/// </summary>
public class VrmInputTests
{
    private static string RepoFile(params string[] parts)
    {
        var dir = new DirectoryInfo(AppContext.BaseDirectory);
        while (dir is not null)
        {
            if (File.Exists(Path.Combine(dir.FullName, "humanoid-retargeter.sbproj")))
                return Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
            dir = dir.Parent!;
        }
        throw new InvalidOperationException("Repo root (humanoid-retargeter.sbproj) not found.");
    }

    // ---------------------------------------------------------------- synthetic .vrm

    /// <summary>
    /// Tiny humanoid: deliberately OPAQUE node names (j00…j20) so no preset and no
    /// name-based auto-map could resolve it — proving the roles come from the VRM extension.
    /// Meter-scale T-pose (glTF units), Y-up. Index = position in this array.
    /// </summary>
    private static readonly (string Vrm, int Parent, float X, float Y, float Z)[] Rig =
    {
        ("hips", -1, 0f, 0.95f, 0f),
        ("spine", 0, 0f, 0.10f, 0f),
        ("chest", 1, 0f, 0.12f, 0f),
        ("neck", 2, 0f, 0.14f, 0f),
        ("head", 3, 0f, 0.06f, 0f),
        ("leftShoulder", 2, 0.06f, 0.08f, 0f),
        ("leftUpperArm", 5, 0.12f, 0f, 0f),
        ("leftLowerArm", 6, 0.26f, 0f, 0f),
        ("leftHand", 7, 0.25f, 0f, 0f),
        ("rightShoulder", 2, -0.06f, 0.08f, 0f),
        ("rightUpperArm", 9, -0.12f, 0f, 0f),
        ("rightLowerArm", 10, -0.26f, 0f, 0f),
        ("rightHand", 11, -0.25f, 0f, 0f),
        ("leftUpperLeg", 0, 0.09f, -0.05f, 0f),
        ("leftLowerLeg", 13, 0f, -0.40f, 0f),
        ("leftFoot", 14, 0f, -0.40f, 0f),
        ("leftToes", 15, 0f, -0.08f, 0.12f),
        ("rightUpperLeg", 0, -0.09f, -0.05f, 0f),
        ("rightLowerLeg", 17, 0f, -0.40f, 0f),
        ("rightFoot", 18, 0f, -0.40f, 0f),
        ("rightToes", 19, 0f, -0.08f, 0.12f),
    };

    private static string NodeName(int index) => $"j{index:00}";

    /// <summary>Builds the glTF JSON: nodes + skin + a 1 s hips-sway animation (data: URI
    /// buffers) + the requested VRM extension block.</summary>
    private static string VrmJson(int vrmVersion)
    {
        var sb = new StringBuilder();
        sb.Append("{ \"asset\": { \"version\": \"2.0\" }, \"nodes\": [");
        for (var i = 0; i < Rig.Length; i++)
        {
            var children = Enumerable.Range(0, Rig.Length)
                .Where(c => Rig[c].Parent == i).ToList();
            sb.Append(i > 0 ? ", {" : " {");
            sb.Append($" \"name\": \"{NodeName(i)}\"");
            sb.Append(string.Create(System.Globalization.CultureInfo.InvariantCulture,
                $", \"translation\": [{Rig[i].X}, {Rig[i].Y}, {Rig[i].Z}]"));
            if (children.Count > 0)
                sb.Append($", \"children\": [{string.Join(", ", children)}]");
            sb.Append(" }");
        }
        sb.Append(" ], \"skins\": [ { \"joints\": [");
        sb.Append(string.Join(", ", Enumerable.Range(0, Rig.Length)));
        sb.Append("] } ]");

        // One rotation channel on the hips (node 0): ±10° about Y over 1 s.
        var sin5 = MathF.Sin(MathF.PI / 36f);
        var cos5 = MathF.Cos(MathF.PI / 36f);
        var times = Base64Floats(0f, 1f);
        var rotations = Base64Floats(0f, -sin5, 0f, cos5, 0f, sin5, 0f, cos5);
        sb.Append(", \"animations\": [ { \"name\": \"sway\", \"channels\": [ " +
            "{ \"sampler\": 0, \"target\": { \"node\": 0, \"path\": \"rotation\" } } ], " +
            "\"samplers\": [ { \"input\": 0, \"output\": 1, \"interpolation\": \"LINEAR\" } ] } ]");
        sb.Append(", \"buffers\": [" +
            $" {{ \"byteLength\": 8, \"uri\": \"data:application/octet-stream;base64,{times}\" }}," +
            $" {{ \"byteLength\": 32, \"uri\": \"data:application/octet-stream;base64,{rotations}\" }} ]");
        sb.Append(", \"bufferViews\": [" +
            " { \"buffer\": 0, \"byteOffset\": 0, \"byteLength\": 8 }," +
            " { \"buffer\": 1, \"byteOffset\": 0, \"byteLength\": 32 } ]");
        sb.Append(", \"accessors\": [" +
            " { \"bufferView\": 0, \"componentType\": 5126, \"count\": 2, \"type\": \"SCALAR\" }," +
            " { \"bufferView\": 1, \"componentType\": 5126, \"count\": 2, \"type\": \"VEC4\" } ]");

        // ---- the VRM extension (authored humanoid bone map) ----
        if (vrmVersion == 0)
        {
            var entries = string.Join(", ", Rig.Select(
                (bone, i) => $"{{ \"bone\": \"{bone.Vrm}\", \"node\": {i} }}"));
            sb.Append($", \"extensions\": {{ \"VRM\": {{ \"exporterVersion\": \"test\", " +
                $"\"humanoid\": {{ \"humanBones\": [ {entries} ] }} }} }}");
        }
        else
        {
            var entries = string.Join(", ", Rig.Select(
                (bone, i) => $"\"{bone.Vrm}\": {{ \"node\": {i} }}"));
            sb.Append($", \"extensions\": {{ \"VRMC_vrm\": {{ \"specVersion\": \"1.0\", " +
                $"\"humanoid\": {{ \"humanBones\": {{ {entries} }} }} }} }}");
        }

        sb.Append(" }");
        return sb.ToString();
    }

    private static string Base64Floats(params float[] values)
    {
        var bytes = new byte[values.Length * 4];
        Buffer.BlockCopy(values, 0, bytes, 0, bytes.Length);
        return Convert.ToBase64String(bytes);
    }

    /// <summary>Wraps JSON into a GLB container (header + space-padded JSON chunk) — the
    /// physical layout of real .vrm files.</summary>
    private static byte[] Glb(string json)
    {
        var jsonBytes = Encoding.UTF8.GetBytes(json);
        var pad = (4 - jsonBytes.Length % 4) % 4;
        var chunkLength = jsonBytes.Length + pad;

        using var ms = new MemoryStream();
        using var bw = new BinaryWriter(ms);
        bw.Write(0x46546C67u);                  // 'glTF'
        bw.Write(2u);                           // container version
        bw.Write((uint)(12 + 8 + chunkLength)); // total length
        bw.Write((uint)chunkLength);
        bw.Write(0x4E4F534Au);                  // 'JSON'
        bw.Write(jsonBytes);
        for (var i = 0; i < pad; i++)
            bw.Write((byte)0x20);               // spaces, per spec
        bw.Flush();
        return ms.ToArray();
    }

    private static byte[] TinyVrm(int vrmVersion) => Glb(VrmJson(vrmVersion));

    // ---------------------------------------------------------------- import + mapping

    [Theory]
    [InlineData(0)]
    [InlineData(1)]
    public void ImportSource_Vrm_SurfacesAuthoredMapping(int vrmVersion)
    {
        var scene = Retargeter.ImportSource(TinyVrm(vrmVersion), "avatar.vrm");

        Assert.NotNull(scene.AuthoredMapping);
        var map = scene.AuthoredMapping!;
        Assert.Equal(MappingSource.Authored, map.Source);
        Assert.Equal(1f, map.Confidence);
        Assert.Equal("vrm", map.ProfileName);
        Assert.Equal(Rig.Length, map.RoleToBone.Count); // all 21 humanoid bones resolved

        // Every VRM bone landed on the node it names (verified through the skeleton's
        // OPAQUE bone names — nothing here is name-detectable).
        var expectedRoles = new Dictionary<string, BoneRole>
        {
            ["hips"] = BoneRole.Hips,
            ["spine"] = BoneRole.Spine0,
            ["chest"] = BoneRole.Spine1,
            ["neck"] = BoneRole.Neck,
            ["head"] = BoneRole.Head,
            ["leftShoulder"] = BoneRole.ClavicleL,
            ["leftUpperArm"] = BoneRole.UpperArmL,
            ["leftLowerArm"] = BoneRole.LowerArmL,
            ["leftHand"] = BoneRole.HandL,
            ["rightUpperLeg"] = BoneRole.UpperLegR,
            ["rightLowerLeg"] = BoneRole.LowerLegR,
            ["rightFoot"] = BoneRole.FootR,
            ["rightToes"] = BoneRole.ToeR,
        };
        foreach (var (vrmName, role) in expectedRoles)
        {
            var nodeIndex = Array.FindIndex(Rig, b => b.Vrm == vrmName);
            var bone = Assert.Contains(role, (IDictionary<BoneRole, int>)map.RoleToBone);
            Assert.Equal(NodeName(nodeIndex), scene.Skeleton[bone].Name);
        }

        Assert.Contains(map.Notes, n => n.Contains(vrmVersion == 1 ? "VRM 1.0" : "VRM 0.x"));
    }

    [Theory]
    [InlineData(0)]
    [InlineData(1)]
    public void Inspect_Vrm_ReportsAuthoredProfileAtFullConfidence(int vrmVersion)
    {
        var inspected = Retargeter.Inspect(TinyVrm(vrmVersion), "avatar.vrm");

        Assert.Equal("vrm", inspected.Mapping.ProfileName);
        Assert.Equal(MappingSource.Authored, inspected.Mapping.Source);
        Assert.Equal(1f, inspected.Mapping.Confidence);
        Assert.False(inspected.Mapping.NeedsUserDecision);
        Assert.Equal(Rig.Length, inspected.Mapping.MappedRoleCount);
        Assert.Equal(1, inspected.TakeCount); // the sway animation
    }

    [Fact]
    public void ResolveMapping_AuthoredBeatsUserPreset_ButOverrideBeatsAuthored()
    {
        var scene = Retargeter.ImportSource(TinyVrm(0), "avatar.vrm");
        var authored = scene.AuthoredMapping!;

        // Authored wins over a user preset — the lookup is never even consulted.
        var lookupCalls = 0;
        var (map, report) = Retargeter.ResolveMapping(scene.Skeleton, null,
            _ => { lookupCalls++; return new MappingResult("user", MappingSource.UserPreset); },
            authored);
        Assert.Same(authored, map);
        Assert.Equal(MappingSource.Authored, report.Source);
        Assert.Equal(0, lookupCalls);

        // An explicit override (deliberate user decision) still beats the file's own map.
        var manual = new MappingResult("manual", MappingSource.Manual) { Confidence = 1f };
        manual.RoleToBone[BoneRole.Hips] = 0;
        var (overridden, overriddenReport) = Retargeter.ResolveMapping(
            scene.Skeleton, manual, null, authored);
        Assert.Same(manual, overridden);
        Assert.Equal(MappingSource.Manual, overriddenReport.Source);
    }

    [Fact]
    public void PlainGlb_WithoutVrmExtension_IsUnchanged()
    {
        // The existing mixamo-named GLB fixture: no VRM extension → no authored mapping,
        // and the cascade still lands on the shipped preset like before.
        var bytes = File.ReadAllBytes(Path.Combine(
            AppContext.BaseDirectory, "fixtures", "gltf", "freebvh.glb"));
        var scene = Retargeter.ImportSource(bytes, "freebvh.glb");
        Assert.Null(scene.AuthoredMapping);

        var inspected = Retargeter.Inspect(bytes, "freebvh.glb");
        Assert.Equal("mixamo", inspected.Mapping.ProfileName);
        Assert.Equal(MappingSource.Preset, inspected.Mapping.Source);
    }

    // ---------------------------------------------------------------- end-to-end facade

    [Fact]
    public void Convert_Vrm_EndToEnd_UsesAuthoredMapping()
    {
        // Convert the .vrm's animation onto a target built from its own authored rig — the
        // full pipeline (import → authored mapping → solve → cleanup → DMX → vmdl) must
        // succeed even though NO name-based detection could ever map the j00…j20 skeleton.
        var scene = Retargeter.ImportSource(TinyVrm(0), "avatar.vrm");
        var spec = new RetargetTargetSpec
        {
            Rig = TargetRig.FromSkeleton(scene.Skeleton, scene.AuthoredMapping!),
            VmdlScale = 1.0f,
            BaseModelPath = "",
            DefaultRootBone = scene.Skeleton[scene.AuthoredMapping!.RoleToBone[BoneRole.Hips]].Name,
        };

        var result = Retargeter.Convert(new RetargetRequest
        {
            SourceData = TinyVrm(0),
            SourceFileName = "avatar.vrm",
        }, spec);

        Assert.True(result.Success, string.Join("; ", result.Errors));
        var clip = Assert.Single(result.Clips);
        Assert.True(clip.Success, clip.Error);
        Assert.Equal("sway", clip.ClipName);
        Assert.False(string.IsNullOrEmpty(clip.DmxContent));

        Assert.NotNull(clip.Mapping);
        Assert.Equal("vrm", clip.Mapping!.ProfileName);
        Assert.Equal(MappingSource.Authored, clip.Mapping.Source);
        Assert.Equal(1f, clip.Mapping.Confidence);
        Assert.False(clip.Mapping.NeedsUserDecision);
    }
}