tests/HumanoidRetargeter.Tests/LocomotionSetTests.cs

Unit tests for the LocomotionSet detection, vmdl writer/augmenter, and pipeline plumbing. Exercises LocomotionSetDetector, VmdlWriter, VmdlAugmenter and Retargeter behavior on various name patterns, grid layouts, and augmentation collision handling.

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

namespace HumanoidRetargeter.Tests;

/// <summary>
/// Tests for <see cref="BatchOptions.DetectLocomotionSets"/>: directional-family detection
/// (<see cref="LocomotionSetDetector"/>) and the Folder + 2DBlend vmdl emission, verified
/// structurally against the shipped <c>citizen_animationlist.vmdl_prefab</c> blends.
/// </summary>
public class LocomotionSetTests
{
    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 AnimEntry Entry(string name, bool looping = true)
        => new() { Name = name, SourceFilename = $"animations/{name.ToLowerInvariant()}.dmx", Looping = looping };

    private static (List<LocomotionSetSpec> Sets, List<LocomotionSetReport> Reports) Detect(
        params string[] names)
        => LocomotionSetDetector.Detect(
            names.Select(n => Entry(n)).ToList(),
            new HashSet<string>(names, StringComparer.OrdinalIgnoreCase),
            autoSuffixCollisions: true);

    private static KvObject FindAnimationList(string vmdlText)
    {
        var rootNode = (KvObject)((KvObject)Kv3.Parse(vmdlText).Root)["rootNode"];
        return ((KvArray)rootNode["children"]).Items.OfType<KvObject>()
            .Single(o => o.GetString("_class") == "AnimationList");
    }

    private static string[][] Grid(KvObject blendNode)
        => ((KvArray)blendNode["blend_anim_list"]).Items.Cast<KvArray>()
            .Select(row => row.Items.Cast<KvString>().Select(s => s.Value).ToArray())
            .ToArray();

    // ---------------------------------------------------------------- detector

    [Fact]
    public void Detect_EightWayFamily_BuildsShippedGridLayout()
    {
        var (sets, reports) = Detect(
            "Walk_N", "Walk_NE", "Walk_E", "Walk_SE", "Walk_S", "Walk_SW", "Walk_W", "Walk_NW");

        var set = Assert.Single(sets);
        var report = Assert.Single(reports);
        Assert.True(report.Emitted);
        Assert.Equal("Walk", report.Stem);
        Assert.Equal("Walk", set.FolderName);
        Assert.Equal("Walk_2D", set.BlendName);
        Assert.Equal(8, report.Members.Count);

        // The exact shipped citizen grid: rows = move_x (-1,0,+1), cols = move_y (-1,0,+1);
        // +move_x = forward (N), +move_y = right (E). No idle in the batch, so the center
        // falls back to the forward member (noted).
        Assert.Equal(new[] { "Walk_SW", "Walk_S", "Walk_SE" }, set.BlendGrid[0]);
        Assert.Equal(new[] { "Walk_W", "Walk_N", "Walk_E" }, set.BlendGrid[1]);
        Assert.Equal(new[] { "Walk_NW", "Walk_N", "Walk_NE" }, set.BlendGrid[2]);
        Assert.Equal("Walk_N", report.CenterClipName);
        Assert.Contains(report.Notes, n => n.Contains("center"));
    }

    [Fact]
    public void Detect_CenterCell_PrefersIdleClipFromBatch()
    {
        var (sets, _) = Detect(
            "Walk_N", "Walk_E", "Walk_S", "Walk_W", "Walk_Idle");
        Assert.Equal("Walk_Idle", Assert.Single(sets).BlendGrid[1][1]);

        var (bareStem, _) = Detect("Walk_N", "Walk_E", "Walk_S", "Walk_W", "Walk");
        Assert.Equal("Walk", Assert.Single(bareStem).BlendGrid[1][1]);
    }

    [Fact]
    public void Detect_FourWayWordForms_MapToCardinals_DiagonalsFallBackToRowCardinal()
    {
        var (sets, reports) = Detect("Run_Forward", "Run_Back", "Run_Left", "Run_Right");

        var set = Assert.Single(sets);
        var report = Assert.Single(reports);
        Assert.True(report.Emitted);
        Assert.Equal("Run", report.Stem);
        Assert.Equal(
            new Dictionary<string, string>
            {
                ["N"] = "Run_Forward",
                ["E"] = "Run_Right",
                ["S"] = "Run_Back",
                ["W"] = "Run_Left",
            }, report.Members);

        // Missing diagonals reuse the row's cardinal (S row → Back, N row → Forward).
        Assert.Equal(new[] { "Run_Back", "Run_Back", "Run_Back" }, set.BlendGrid[0]);
        Assert.Equal(new[] { "Run_Left", "Run_Forward", "Run_Right" }, set.BlendGrid[1]);
        Assert.Equal(new[] { "Run_Forward", "Run_Forward", "Run_Forward" }, set.BlendGrid[2]);
        Assert.Contains(report.Notes, n => n.Contains("diagonal NE missing"));
    }

    [Fact]
    public void Detect_IncompleteFamily_EmitsNothing_ButReportsTheMissingCardinals()
    {
        // 5 of 8 — but the cardinals N and W are missing, so no blend can be built.
        var (sets, reports) = Detect("Walk_NE", "Walk_E", "Walk_SE", "Walk_S", "Walk_SW");

        Assert.Empty(sets);
        var report = Assert.Single(reports);
        Assert.False(report.Emitted);
        Assert.Equal("Walk", report.Stem);
        Assert.Contains(report.Notes, n => n.Contains("missing cardinal") && n.Contains("N, W"));
    }

    [Fact]
    public void Detect_IgnoresNoise()
    {
        // Single directional clips, additive/mirrored suffixes and undirectional names must
        // not create families.
        var (sets, reports) = Detect(
            "Turn_Left", "Jump", "Walk_N_delta", "Walk_M", "Idle");
        Assert.Empty(sets);
        Assert.Empty(reports);
    }

    [Fact]
    public void Detect_CaseInsensitive_AndCollisionSuffixesBlendNames()
    {
        var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        {
            "walk_n", "walk_e", "walk_s", "walk_w",
            "Walk", // a foreign node already owns the stem name
            "Walk_2D", // and the natural blend name
        };
        var (sets, _) = LocomotionSetDetector.Detect(
            new[] { Entry("walk_n"), Entry("walk_e"), Entry("walk_s"), Entry("walk_w") },
            used, autoSuffixCollisions: true);

        var set = Assert.Single(sets);
        Assert.Equal("walk_2", set.FolderName);
        Assert.Equal("walk_2D_2", set.BlendName);
        Assert.Equal(new[] { "walk_n", "walk_e", "walk_s", "walk_w" }, set.MemberNames);
    }

    // ---------------------------------------------------------------- ScanNames (UI dry-run)

    [Fact]
    public void ScanNames_EightWayFamily_CompleteWithEightMembers()
    {
        var families = LocomotionSetDetector.ScanNames(new[]
        {
            "Walk_N", "Walk_NE", "Walk_E", "Walk_SE", "Walk_S", "Walk_SW", "Walk_W", "Walk_NW",
        });

        Assert.Equal(new[] { ("Walk", true, 8) }, families);
    }

    [Fact]
    public void ScanNames_FourWayWordForms_CompleteWithFourMembers()
    {
        var families = LocomotionSetDetector.ScanNames(new[]
        {
            "Run_Forward", "Run_Back", "Run_Left", "Run_Right",
        });

        Assert.Equal(new[] { ("Run", true, 4) }, families);
    }

    [Fact]
    public void ScanNames_IncompleteFamily_ListedButNotComplete()
    {
        // Same shape as the detector's incomplete-family case: 5 of 8, cardinals N and W
        // missing - still reported (≥ 2 members) but not complete.
        var families = LocomotionSetDetector.ScanNames(new[]
        {
            "Walk_NE", "Walk_E", "Walk_SE", "Walk_S", "Walk_SW",
        });

        Assert.Equal(new[] { ("Walk", false, 5) }, families);
    }

    [Fact]
    public void ScanNames_IgnoresNoise()
    {
        // Single directional clips, additive/mirrored suffixes and undirectional names must
        // not create families (mirrors Detect_IgnoresNoise).
        var families = LocomotionSetDetector.ScanNames(new[]
        {
            "Turn_Left", "Jump", "Walk_N_delta", "Walk_M", "Idle",
        });

        Assert.Empty(families);
    }

    [Fact]
    public void ScanNames_MixedFamilies_KeepsFirstSeenOrderAndCasing_DuplicatesCountOnce()
    {
        var families = LocomotionSetDetector.ScanNames(new[]
        {
            "Run_S", "Walk_N", "walk_e", "WALK_s", "Walk_W",
            "Walk_Forward", // duplicate of Walk_N's direction - counted once
            "Run_N", "Run_E", // Run misses W: incomplete
            "Crouch_Left", // single member: noise
        });

        Assert.Equal(new[] { ("Run", false, 3), ("Walk", true, 4) }, families);
    }

    [Fact]
    public void ScanNames_MatchesDetectOnTheSameNames()
    {
        // The dry-run must agree with the real detector on completeness and membership.
        var names = new[]
        {
            "Walk_N", "Walk_E", "Walk_S", "Walk_W", "Walk_NE",
            "Run_Forward", "Run_Left", // incomplete
            "Jump",
        };
        var families = LocomotionSetDetector.ScanNames(names);
        var (sets, reports) = Detect(names);

        Assert.Equal(reports.Select(r => r.Stem), families.Select(f => f.Stem));
        Assert.Equal(reports.Select(r => r.Emitted), families.Select(f => f.Complete));
        Assert.Equal(reports.Select(r => r.Members.Count), families.Select(f => f.MemberCount));
        Assert.Equal(sets.Count, families.Count(f => f.Complete));
    }

    // ---------------------------------------------------------------- shipped-schema fidelity

    [Fact]
    public void Writer_2DBlendNode_MatchesShippedPrefabShapeExactly()
    {
        var prefab = Kv3.Parse(File.ReadAllText(
            SkeletonTests.FixturePath(Path.Combine("kv3", "citizen_animationlist.vmdl_prefab"))));
        var shipped = FindNodeRecursive(prefab.Root, "2DBlend", "CrouchWalk_Default_2D");

        // Document the shipped convention this feature replicates.
        Assert.Equal("move_x", shipped.GetString("row_pose_param_name"));
        Assert.Equal("move_y", shipped.GetString("col_pose_param_name"));
        var axis = new[] { -1.0, 0.0, 1.0 };
        Assert.Equal(axis, ((KvArray)shipped["row_weight_list"]).Items.Cast<KvDouble>().Select(d => d.Value));
        Assert.Equal(axis, ((KvArray)shipped["col_weight_list"]).Items.Cast<KvDouble>().Select(d => d.Value));
        var shippedGrid = Grid(shipped);
        Assert.Equal(new[] { "CrouchWalk_SW", "CrouchWalk_S", "CrouchWalk_SE" }, shippedGrid[0]);
        Assert.Equal(new[] { "CrouchWalk_W", "CrouchIdlePose_Default", "CrouchWalk_E" }, shippedGrid[1]);
        Assert.Equal(new[] { "CrouchWalk_NW", "CrouchWalk_N", "CrouchWalk_NE" }, shippedGrid[2]);

        // Our node (generated through the standalone writer, like the pipeline does):
        // identical attribute key ORDER and value KINDS.
        var entries = new[]
        {
            Entry("Walk_N"), Entry("Walk_NE"), Entry("Walk_E"), Entry("Walk_SE"),
            Entry("Walk_S"), Entry("Walk_SW"), Entry("Walk_W"), Entry("Walk_NW"),
        };
        var (sets, _) = Detect(entries.Select(e => e.Name).ToArray());
        var text = VmdlWriter.GenerateStandalone("", entries, 1.0f, "pelvis", sets);
        var ourFolder = FindNodeRecursive(Kv3.Parse(text).Root, "Folder", "Walk");
        var ours = FindNodeRecursive(Kv3.Parse(text).Root, "2DBlend", "Walk_2D");

        Assert.Equal(shipped.Keys, ours.Keys);
        foreach (var key in shipped.Keys)
        {
            Assert.True(shipped[key].GetType() == ours[key].GetType(),
                $"2DBlend attribute '{key}': shipped {shipped[key].GetType().Name}, "
                + $"ours {ours[key].GetType().Name}");
        }
        Assert.True(KvValue.DeepEquals(shipped["row_weight_list"], ours["row_weight_list"]));
        Assert.True(KvValue.DeepEquals(shipped["col_weight_list"], ours["col_weight_list"]));

        // And the shipped Folder shape (_class, name, children) is what we emit.
        var shippedFolder = FindNodeRecursive(prefab.Root, "Folder", "CrouchWalk");
        Assert.Equal(shippedFolder.Keys, ourFolder.Keys);
    }

    private static KvObject FindNodeRecursive(KvValue node, string cls, string name)
    {
        var matches = new List<KvObject>();
        Walk(node);
        return matches.Single();

        void Walk(KvValue current)
        {
            switch (current)
            {
                case KvObject o:
                    if (o.GetString("_class") == cls && o.GetString("name") == name)
                        matches.Add(o);
                    foreach (var key in o.Keys)
                        Walk(o[key]);
                    break;
                case KvArray a:
                    foreach (var item in a.Items)
                        Walk(item);
                    break;
            }
        }
    }

    // ---------------------------------------------------------------- pipeline plumbing

    private static RetargetRequest DirectionalRequest(string clipName) => new()
    {
        SourceData = Encoding.UTF8.GetBytes(WalkFixture.SyntheticWalkBvh()),
        SourceFileName = "synth_walk.bvh",
        ClipNameOverride = clipName,
        LoopingOverride = true,
    };

    [Fact]
    public void ConvertBatch_DetectsFourWaySet_GroupsFolderInStandaloneVmdl()
    {
        var requests = new[] { "Walk_N", "Walk_E", "Walk_S", "Walk_W" }
            .Select(DirectionalRequest).ToArray();
        var result = Retargeter.ConvertBatch(requests, SboxTarget.Value,
            new BatchOptions { DetectLocomotionSets = true });
        Assert.All(result.Clips, c => Assert.True(c.Success, c.Error));

        var report = Assert.Single(result.LocomotionSets);
        Assert.True(report.Emitted);
        Assert.Equal("Walk", report.FolderName);
        Assert.Equal("Walk_2D", report.BlendNodeName);

        var animList = FindAnimationList(result.StandaloneVmdl);
        var folder = Assert.Single(
            animList.ChildObjects().Where(o => o.GetString("_class") == "Folder"));
        Assert.Equal("Walk", folder.GetString("name"));

        // The folder leads with the blend and contains the four members; no member remains
        // loose at the AnimationList top level.
        var children = folder.ChildObjects().ToList();
        Assert.Equal("2DBlend", children[0].GetString("_class"));
        Assert.Equal("Walk_2D", children[0].GetString("name"));
        Assert.Equal(new[] { "Walk_N", "Walk_E", "Walk_S", "Walk_W" },
            children.Skip(1).Select(o => o.GetString("name")));
        Assert.All(children.Skip(1), o => Assert.Equal("AnimFile", o.GetString("_class")));
        Assert.DoesNotContain(animList.ChildObjects(),
            o => o.GetString("_class") == "AnimFile" && o.GetString("name")!.StartsWith("Walk_"));

        // Blend wiring: inherited citizen pose params, shipped grid layout, looping.
        var blend = children[0];
        Assert.Equal("move_x", blend.GetString("row_pose_param_name"));
        Assert.Equal("move_y", blend.GetString("col_pose_param_name"));
        Assert.True(((KvBool)blend["looping"]).Value);
        var grid = Grid(blend);
        Assert.Equal(new[] { "Walk_S", "Walk_S", "Walk_S" }, grid[0]);
        Assert.Equal(new[] { "Walk_W", "Walk_N", "Walk_E" }, grid[1]);
        Assert.Equal(new[] { "Walk_N", "Walk_N", "Walk_N" }, grid[2]);

        // The whole document still round-trips.
        var doc = Kv3.Parse(result.StandaloneVmdl);
        Assert.True(KvValue.DeepEquals(doc.Root, Kv3.Parse(Kv3.Serialize(doc)).Root));
    }

    [Fact]
    public void ConvertBatch_DefaultOff_EmitsNoBlendNodes()
    {
        var requests = new[] { "Walk_N", "Walk_E", "Walk_S", "Walk_W" }
            .Select(DirectionalRequest).ToArray();
        var result = Retargeter.ConvertBatch(requests, SboxTarget.Value);

        Assert.Empty(result.LocomotionSets);
        Assert.DoesNotContain("2DBlend", result.StandaloneVmdl);
        Assert.DoesNotContain("Folder", result.StandaloneVmdl);
    }

    [Fact]
    public void ConvertBatch_IncompleteFamily_NoNode_NoteOnReport()
    {
        var requests = new[] { "Walk_N", "Walk_E", "Walk_S" } // W missing
            .Select(DirectionalRequest).ToArray();
        var result = Retargeter.ConvertBatch(requests, SboxTarget.Value,
            new BatchOptions { DetectLocomotionSets = true });

        var report = Assert.Single(result.LocomotionSets);
        Assert.False(report.Emitted);
        Assert.Contains(report.Notes, n => n.Contains("missing cardinal") && n.Contains("W"));
        Assert.DoesNotContain("2DBlend", result.StandaloneVmdl);
    }

    // ---------------------------------------------------------------- augmenter

    private static List<AnimEntry> FourWay()
        => new() { Entry("Walk_N"), Entry("Walk_E"), Entry("Walk_S"), Entry("Walk_W") };

    private static LocomotionSetSpec FourWaySpec(string folder = "Walk", string blend = "Walk_2D")
        => new()
        {
            FolderName = folder,
            BlendName = blend,
            Looping = true,
            BlendGrid = new[]
            {
                new[] { "Walk_S", "Walk_S", "Walk_S" },
                new[] { "Walk_W", "Walk_N", "Walk_E" },
                new[] { "Walk_N", "Walk_N", "Walk_N" },
            },
            MemberNames = new[] { "Walk_N", "Walk_E", "Walk_S", "Walk_W" },
        };

    [Fact]
    public void Augment_LocomotionSet_SplicesFolder_Idempotently()
    {
        var original = File.ReadAllText(
            SkeletonTests.FixturePath(Path.Combine("kv3", "citizen_human_male.vmdl")));
        var options = new AugmentOptions
        {
            DefaultRootBone = "pelvis",
            LocomotionSets = new[] { FourWaySpec() },
            // Entries' source_filenames live under "animations/" — declare it so the
            // re-run recognizes the folder as pipeline-owned (the replaceability rule).
            DmxFolderRelative = "animations",
        };

        var once = VmdlAugmenter.Augment(original, FourWay(), out _, options);
        var animList = FindAnimationList(once);
        var folder = animList.ChildObjects()
            .Single(o => o.GetString("_class") == "Folder" && o.GetString("name") == "Walk");
        Assert.Equal(5, folder.ChildObjects().Count()); // 2DBlend + 4 members
        Assert.DoesNotContain(animList.ChildObjects(),
            o => o.GetString("_class") == "AnimFile" && o.GetString("name") == "Walk_N");

        var twice = VmdlAugmenter.Augment(once, FourWay(), out _, options);
        Assert.Equal(once, twice);
    }

    [Fact]
    public void Augment_LocomotionSet_AdoptsLooseMembersFromAPreviousRunWithoutGrouping()
    {
        // First run WITHOUT locomotion grouping leaves the members at the top level; a
        // re-run WITH grouping must move them into the folder, never duplicate them.
        var original = File.ReadAllText(
            SkeletonTests.FixturePath(Path.Combine("kv3", "citizen_human_male.vmdl")));
        var flat = VmdlAugmenter.Augment(original, FourWay(), out _,
            new AugmentOptions { DefaultRootBone = "pelvis" });
        var grouped = VmdlAugmenter.Augment(flat, FourWay(), out _, new AugmentOptions
        {
            DefaultRootBone = "pelvis",
            LocomotionSets = new[] { FourWaySpec() },
        });

        var animList = FindAnimationList(grouped);
        var walkNodes = CountNamed(animList, "Walk_N");
        Assert.Equal(1, walkNodes);
        Assert.Single(animList.ChildObjects(),
            o => o.GetString("_class") == "Folder" && o.GetString("name") == "Walk");
    }

    private static int CountNamed(KvObject node, string name)
    {
        var count = 0;
        Walk(node);
        return count;

        void Walk(KvObject current)
        {
            if (current.GetString("name") == name)
                count++;
            if (current.GetOrNull("children") is KvArray children)
            {
                foreach (var child in children.Items.OfType<KvObject>())
                    Walk(child);
            }
        }
    }

    [Fact]
    public void Augment_ForeignFolderWithSameName_Throws()
    {
        const string foreignFolderVmdl =
            "<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc30:version{8c2d7a91-9c42-4bf0-883a-5a3b1762d4f1} -->\n" +
            "{\n" +
            "\trootNode = \n" +
            "\t{\n" +
            "\t\t_class = \"RootNode\"\n" +
            "\t\tchildren = \n" +
            "\t\t[\n" +
            "\t\t\t{\n" +
            "\t\t\t\t_class = \"AnimationList\"\n" +
            "\t\t\t\tchildren = \n" +
            "\t\t\t\t[\n" +
            "\t\t\t\t\t{\n" +
            "\t\t\t\t\t\t_class = \"Folder\"\n" +
            "\t\t\t\t\t\tname = \"Walk\"\n" +
            "\t\t\t\t\t\tchildren = \n" +
            "\t\t\t\t\t\t[\n" +
            "\t\t\t\t\t\t\t{\n" +
            "\t\t\t\t\t\t\t\t_class = \"AnimFile\"\n" +
            "\t\t\t\t\t\t\t\tname = \"UserClip\"\n" +
            "\t\t\t\t\t\t\t\tsource_filename = \"animations/userclip.fbx\"\n" +
            "\t\t\t\t\t\t\t},\n" +
            "\t\t\t\t\t\t]\n" +
            "\t\t\t\t\t},\n" +
            "\t\t\t\t]\n" +
            "\t\t\t\tdefault_root_bone_name = \"pelvis\"\n" +
            "\t\t\t},\n" +
            "\t\t]\n" +
            "\t\tbase_model_name = \"\"\n" +
            "\t}\n" +
            "}\n";

        var ex = Assert.Throws<VmdlAugmentException>(() => VmdlAugmenter.Augment(
            foreignFolderVmdl, FourWay(), out _, new AugmentOptions
            {
                DefaultRootBone = "pelvis",
                LocomotionSets = new[] { FourWaySpec() },
            }));
        Assert.Contains(ex.Collisions, c => c.Contains("Walk"));
    }

    [Fact]
    public void ConvertBatch_AugmentVmdlWithForeignWalkFolder_SuffixesTheGeneratedFolder()
    {
        // The batch seeds names from the existing vmdl: a FOREIGN folder named "Walk"
        // reserves the stem, so the generated folder gets suffixed instead of replacing it.
        const string vmdlWithForeignWalk =
            "<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc30:version{8c2d7a91-9c42-4bf0-883a-5a3b1762d4f1} -->\n" +
            "{\n" +
            "\trootNode = \n" +
            "\t{\n" +
            "\t\t_class = \"RootNode\"\n" +
            "\t\tchildren = \n" +
            "\t\t[\n" +
            "\t\t\t{\n" +
            "\t\t\t\t_class = \"AnimationList\"\n" +
            "\t\t\t\tchildren = \n" +
            "\t\t\t\t[\n" +
            "\t\t\t\t\t{\n" +
            "\t\t\t\t\t\t_class = \"Folder\"\n" +
            "\t\t\t\t\t\tname = \"Walk\"\n" +
            "\t\t\t\t\t\tchildren = \n" +
            "\t\t\t\t\t\t[\n" +
            "\t\t\t\t\t\t\t{\n" +
            "\t\t\t\t\t\t\t\t_class = \"AnimFile\"\n" +
            "\t\t\t\t\t\t\t\tname = \"UserClip\"\n" +
            "\t\t\t\t\t\t\t\tsource_filename = \"animations/userclip.fbx\"\n" +
            "\t\t\t\t\t\t\t},\n" +
            "\t\t\t\t\t\t]\n" +
            "\t\t\t\t\t},\n" +
            "\t\t\t\t]\n" +
            "\t\t\t\tdefault_root_bone_name = \"pelvis\"\n" +
            "\t\t\t},\n" +
            "\t\t]\n" +
            "\t\tbase_model_name = \"\"\n" +
            "\t}\n" +
            "}\n";

        var requests = new[] { "Walk_N", "Walk_E", "Walk_S", "Walk_W" }
            .Select(DirectionalRequest).ToArray();
        var result = Retargeter.ConvertBatch(requests, SboxTarget.Value, new BatchOptions
        {
            DetectLocomotionSets = true,
            AugmentVmdlText = vmdlWithForeignWalk,
        });

        Assert.Empty(result.Errors);
        var report = Assert.Single(result.LocomotionSets);
        Assert.Equal("Walk_2", report.FolderName);

        var animList = FindAnimationList(result.AugmentedVmdl!);
        var folders = animList.ChildObjects()
            .Where(o => o.GetString("_class") == "Folder").ToList();
        Assert.Equal(2, folders.Count);
        Assert.Contains(folders, f => f.GetString("name") == "Walk");   // untouched foreign
        Assert.Contains(folders, f => f.GetString("name") == "Walk_2"); // ours
        Assert.Equal(1, CountNamed(animList, "UserClip"));
    }
}