tests/HumanoidRetargeter.Tests/FootstepEventTests.cs

Unit tests for the HumanoidRetargeter footstep event generation and vmdl output. Tests validate detection of foot plant-starts, event data shape, kv3 round-trip, augmenting existing vmdl text, and behavior when target rig lacks leg chains.

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

namespace HumanoidRetargeter.Tests;

/// <summary>
/// Facade tests for <see cref="RetargetRequest.GenerateFootstepEvents"/>: plant-start
/// detection on the solved target clip → AE_FOOTSTEP AnimEvent nodes in the vmdl, shaped
/// exactly like the shipped citizen data.
/// </summary>
public class FootstepEventTests
{
    private static string FixturePath(params string[] parts)
        => Path.Combine(new[] { AppContext.BaseDirectory, "fixtures" }.Concat(parts).ToArray());

    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.");
    }

    private static readonly Lazy<RetargetTargetSpec> SboxTarget = new(()
        => RetargetTargetSpec.SboxDefault(
            File.ReadAllText(RepoFile("Assets", "data", "humanoid_retargeter", "target_rig_sbox.json"))));

    private static RetargetRequest WalkRequest(bool footsteps = true) => new()
    {
        SourceData = Encoding.UTF8.GetBytes(WalkFixture.SyntheticWalkBvh()),
        SourceFileName = "synth_walk.bvh",
        GenerateFootstepEvents = footsteps,
    };

    // ---------------------------------------------------------------- detection

    [Fact]
    public void Convert_SyntheticWalk_EmitsEventsAtKnownPlantStarts()
    {
        var result = Retargeter.Convert(WalkRequest(), SboxTarget.Value);
        var clip = Assert.Single(result.Clips);
        Assert.True(clip.Success, clip.Error);

        var left = clip.FootstepEvents.Where(e => e.Foot == "0").ToList();
        var right = clip.FootstepEvents.Where(e => e.Foot == "1").ToList();

        // One touchdown per foot, at the authored plant-start frames (±3 frames of
        // solve/cleanup jitter allowed at plant boundaries).
        var leftEvent = Assert.Single(left);
        Assert.InRange(leftEvent.Frame,
            WalkFixture.LeftTouchdownFrame - 3, WalkFixture.LeftTouchdownFrame + 3);
        var rightEvent = Assert.Single(right);
        Assert.InRange(rightEvent.Frame,
            WalkFixture.RightTouchdownFrame - 3, WalkFixture.RightTouchdownFrame + 3);

        // The right foot is ALREADY planted at clip start: its frame-0 plant has no
        // touchdown, so no event fires there.
        Assert.DoesNotContain(clip.FootstepEvents, e => e.Frame == 0);

        // Shipped-data event shape on every event.
        Assert.All(clip.FootstepEvents, e =>
        {
            Assert.Equal("AE_FOOTSTEP", e.EventClass);
            Assert.Equal(e.Foot == "0" ? "foot_L" : "foot_R", e.Attachment);
            Assert.Equal(0.7, e.Volume!.Value, 10);
        });

        // Merged list is frame-ordered.
        Assert.Equal(clip.FootstepEvents.OrderBy(e => e.Frame).Select(e => e.Frame),
            clip.FootstepEvents.Select(e => e.Frame));
    }

    [Fact]
    public void Convert_DefaultOff_ProducesNoEvents()
    {
        var result = Retargeter.Convert(WalkRequest(footsteps: false), SboxTarget.Value);
        var clip = Assert.Single(result.Clips);
        Assert.True(clip.Success, clip.Error);
        Assert.Empty(clip.FootstepEvents);
        Assert.DoesNotContain("AnimEvent", result.StandaloneVmdl);
    }

    // ---------------------------------------------------------------- vmdl node shape

    [Fact]
    public void Convert_WithEvents_VmdlCarriesShippedShapeAnimEventNodes()
    {
        var result = Retargeter.Convert(WalkRequest(), SboxTarget.Value);
        var clip = Assert.Single(result.Clips);
        Assert.True(clip.Success, clip.Error);
        Assert.NotEmpty(clip.FootstepEvents);

        var eventNodes = AnimEventNodes(result.StandaloneVmdl, clip.ClipName);
        Assert.Equal(clip.FootstepEvents.Count, eventNodes.Count);

        for (var i = 0; i < eventNodes.Count; i++)
        {
            var node = eventNodes[i];
            var expected = clip.FootstepEvents[i];

            Assert.Equal("AnimEvent", node.GetString("_class"));
            Assert.Equal("AE_FOOTSTEP", node.GetString("event_class"));
            Assert.Equal(expected.Frame, Assert.IsType<KvLong>(node["event_frame"]).Value);

            // event_keys: Attachment/Foot as STRINGS, Volume as a double — exactly like the
            // 28 shipped events in citizen_animationlist.vmdl_prefab.
            var keys = Assert.IsType<KvObject>(node["event_keys"]);
            Assert.Equal(expected.Attachment, keys.GetString("Attachment"));
            Assert.Equal(expected.Foot, keys.GetString("Foot"));
            Assert.Equal(0.7, Assert.IsType<KvDouble>(keys["Volume"]).Value, 10);
        }
    }

    [Fact]
    public void Convert_WithEvents_Kv3RoundTripsIntact()
    {
        var result = Retargeter.Convert(WalkRequest(), SboxTarget.Value);
        var clip = Assert.Single(result.Clips);
        Assert.True(clip.Success, clip.Error);

        // Parse → serialize → parse: semantically identical, and the writer is a fixed
        // point (third generation byte-identical).
        var first = Kv3.Parse(result.StandaloneVmdl);
        var serialized = Kv3.Serialize(first);
        var second = Kv3.Parse(serialized);
        Assert.True(KvValue.DeepEquals(first.Root, second.Root));
        Assert.Equal(serialized, Kv3.Serialize(second));

        // The round-tripped text still carries the events.
        Assert.Equal(clip.FootstepEvents.Count, AnimEventNodes(serialized, clip.ClipName).Count);
    }

    [Fact]
    public void ConvertBatch_Augment_SplicesEventsIntoExistingVmdl()
    {
        var original = File.ReadAllText(FixturePath("kv3", "citizen_human_male.vmdl"));
        var result = Retargeter.ConvertBatch(
            new[] { WalkRequest() }, SboxTarget.Value,
            new BatchOptions { AugmentVmdlText = original });

        Assert.NotNull(result.AugmentedVmdl);
        var clip = Assert.Single(result.Clips);
        Assert.True(clip.Success, clip.Error);
        Assert.NotEmpty(clip.FootstepEvents);

        var eventNodes = AnimEventNodes(result.AugmentedVmdl!, clip.ClipName);
        Assert.Equal(clip.FootstepEvents.Count, eventNodes.Count);
        Assert.All(eventNodes, n => Assert.Equal("AE_FOOTSTEP", n.GetString("event_class")));
    }

    [Fact]
    public void Convert_TargetWithoutLegChains_SkipsWithNote()
    {
        // A target rig with incomplete leg chains (no Foot/Toe roles): events must be
        // skipped with a report note, not fail the clip.
        var scene = Retargeter.ImportSource(
            Encoding.UTF8.GetBytes(WalkFixture.SyntheticWalkBvh()), "synth_walk.bvh");
        var (full, _) = Retargeter.ResolveMapping(scene.Skeleton);
        var footless = new HumanoidRetargeter.Core.Mapping.MappingResult("footless", full.Source)
        {
            Confidence = full.Confidence,
        };
        foreach (var (role, bone) in full.RoleToBone)
        {
            if (!role.ToString().StartsWith("Foot") && !role.ToString().StartsWith("Toe"))
                footless.RoleToBone[role] = bone;
        }
        var spec = new RetargetTargetSpec
        {
            Rig = TargetRig.FromSkeleton(scene.Skeleton, footless),
            VmdlScale = 1.0f,
            DefaultRootBone = "mixamorig:Hips",
        };

        var result = Retargeter.Convert(WalkRequest(), spec);
        var clip = Assert.Single(result.Clips);
        Assert.True(clip.Success, clip.Error);
        Assert.Empty(clip.FootstepEvents);
        Assert.Contains(clip.Mapping!.Notes, n => n.Contains("Footstep events skipped"));
    }

    // ---------------------------------------------------------------- helpers

    /// <summary>The AnimEvent children of the named AnimFile node in a vmdl text.</summary>
    internal static List<KvObject> AnimEventNodes(string vmdlText, string clipName)
    {
        var rootNode = (KvObject)((KvObject)Kv3.Parse(vmdlText).Root)["rootNode"];
        var animList = ((KvArray)rootNode["children"]).Items.OfType<KvObject>()
            .Single(o => o.GetString("_class") == "AnimationList");
        var animFile = ((KvArray)animList["children"]).Items.OfType<KvObject>()
            .Single(o => o.GetString("_class") == "AnimFile" && o.GetString("name") == clipName);
        if (animFile.GetOrNull("children") is not KvArray children)
            return new List<KvObject>();
        return children.Items.OfType<KvObject>()
            .Where(o => o.GetString("_class") == "AnimEvent")
            .ToList();
    }
}