tests/HumanoidRetargeter.Tests/Formats/RenderwareTests.cs

Unit tests for RenderWare format import and retargeting. Validates parsing of .dff, .anm, .an5 fixtures, skeleton structure, frame data, mapping detection, conversion to a target rig, and an optional corpus sweep via environment variable.

File Access
using System.Numerics;
using HumanoidRetargeter.Core.Formats.Renderware;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Target;
using Xunit;
using HumanoidRetargeter.Core;

namespace HumanoidRetargeter.Tests.Formats;

// =============================================================================================
// GROUND TRUTH: real FreeStyle 2 (FSB2) game data, extracted by dev/tools/renderware/
// rw_explore.py from the user's install:
//   fixtures/renderware/f_upper_b01.dff       character.pak: Character\body\body_upper\dff\
//                                             f_upper_b01.dff — 72 frames, 71 HAnim nodes,
//                                             3ds Max Biped names in RpUserData (0x11F)
//   fixtures/renderware/7FEA6AEBBEB5C5F3.an5  smallest character motion bank (2 takes)
//   fixtures/renderware/FE450CFBB1F2A8CA.an5  small bank (5 takes)
//   fixtures/renderware/BACK0098_CATCH.anm    item-prop clip, 20-node rig, 553 keyframes
//   fixtures/renderware/BACK0098_IDLE_1.anm   item-prop clip, 40 keyframes
//   fixtures/renderware/TWN_ABACK0016_FIX.anm tiny item clip (6 keyframes)
//
// Format structure was mapped empirically over the ENTIRE corpus (dev/tools/renderware/
// rw_explore.py sweep: 275/275 .anm + 307/307 .an5 parse clean; every .an5 take = one
// 3-node full-TRS anim + one 71-node rotation-only anim + a marker table). The rig's rest
// FK was verified plausible (pelvis (0, 98.7, 2.8), head top ~184 cm, feet at ground,
// toes +Z) and the (nodeId, name, parent) list is byte-identical across 5 characters
// sampled from character.pak.
// =============================================================================================
public class RenderwareTests
{
    private static byte[] Fixture(string name)
        => File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "fixtures", "renderware", name));

    /// <summary>Resolves a repo-relative path by walking up to the .sbproj directory.</summary>
    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 RwDffSkeletonData Dff() => RwDffSkeleton.Parse(Fixture("f_upper_b01.dff"));

    private static SourceScene ImportBank(string name = "7FEA6AEBBEB5C5F3.an5")
        => RwAnmImporter.Import(Fixture(name), Fixture("f_upper_b01.dff"),
            new RwAnmImportOptions { ClipNameBase = Path.GetFileNameWithoutExtension(name) });

    // ================================================================ dff skeleton

    [Fact]
    public void Dff_Parses_71Nodes_72Frames()
    {
        var dff = Dff();
        Assert.Equal(71, dff.Nodes.Count);
        Assert.Equal(72, dff.FrameCount);
    }

    [Fact]
    public void Dff_NodeOrder_And_Names_MatchGameData()
    {
        var dff = Dff();
        // Node order = HAnim node table order = animation keyframe order.
        Assert.Equal(2000, dff.Nodes[0].NodeId);
        Assert.Equal("Dummy01", dff.Nodes[0].Name);
        Assert.Equal(2001, dff.Nodes[1].NodeId);
        Assert.Equal("Ball_Point", dff.Nodes[1].Name);
        Assert.Equal(1000, dff.Nodes[2].NodeId);
        Assert.Equal("Bip01", dff.Nodes[2].Name);
        Assert.Equal(1045, dff.Nodes[3].NodeId);
        Assert.Equal("Bip01 Pelvis", dff.Nodes[3].Name);

        // All names unique + non-empty (Skeleton.Create requirement).
        var names = dff.Nodes.Select(n => n.Name).ToList();
        Assert.Equal(names.Count, names.Distinct(StringComparer.Ordinal).Count());
        Assert.DoesNotContain(names, string.IsNullOrEmpty);
    }

    [Fact]
    public void Dff_Parents_FollowFrameHierarchy()
    {
        var dff = Dff();
        var byName = dff.Nodes.ToDictionary(n => n.Name);
        string ParentOf(string name)
        {
            var p = byName[name].ParentIndex;
            return p < 0 ? "<root>" : dff.Nodes[p].Name;
        }

        Assert.Equal("<root>", ParentOf("Dummy01"));
        Assert.Equal("Dummy01", ParentOf("Ball_Point"));
        Assert.Equal("Dummy01", ParentOf("Bip01"));
        Assert.Equal("Bip01", ParentOf("Bip01 Pelvis"));
        Assert.Equal("Bip01 Pelvis", ParentOf("Bip01 L Thigh"));
        Assert.Equal("Bip01 L Thigh", ParentOf("Bip01 L Calf"));
        Assert.Equal("Bip01 L Calf", ParentOf("Bip01 L Foot"));
        Assert.Equal("Bip01 L Foot", ParentOf("Bip01 L Toe0"));
        Assert.Equal("Bip01 Spine3", ParentOf("Bip01 Neck"));
        Assert.Equal("Bip01 Neck", ParentOf("Bip01 L Clavicle"));
        Assert.Equal("Bip01 L Clavicle", ParentOf("Bip01 L UpperArm"));
        Assert.Equal("Bip01 L UpperArm", ParentOf("Bip01 L Forearm"));
        Assert.Equal("Bip01 L Forearm", ParentOf("Bip01 L Hand"));

        // Parent-first ordering (what positional keyframe assignment relies on).
        for (var n = 0; n < dff.Nodes.Count; n++)
            Assert.True(dff.Nodes[n].ParentIndex < n);
    }

    [Fact]
    public void Dff_RestPose_IsPlausibleHuman_YUpCm()
    {
        var dff = Dff();
        var world = new XForm[dff.Nodes.Count];
        for (var n = 0; n < dff.Nodes.Count; n++)
        {
            world[n] = dff.Nodes[n].ParentIndex < 0
                ? dff.Nodes[n].RestLocal
                : XForm.Compose(world[dff.Nodes[n].ParentIndex], dff.Nodes[n].RestLocal);
        }
        var byName = dff.Nodes.Select((n, i) => (n.Name, i)).ToDictionary(t => t.Name, t => t.i);

        // Pelvis ≈ (0, 98.7, 2.8) — Y-up, centimeter scale (this pins the row-vector
        // 3x3 → quaternion conversion: a transposed read scrambles every world position).
        TestUtil.AssertVectorEqual(new Vector3(0f, 98.73f, 2.80f), world[byName["Bip01"]].Pos, 0.05f);
        // Feet at ground level, head top ~184 cm.
        Assert.InRange(world[byName["Bip01 L Toe0"]].Pos.Y, -2f, 3f);
        Assert.InRange(world[byName["Bip01 R Toe0"]].Pos.Y, -2f, 3f);
        Assert.InRange(world[byName["Bip01 HeadNub"]].Pos.Y, 175f, 195f);
        // Toes forward = +Z; left side = +X ("Bip01 L Thigh" verified against the game's
        // own authored names).
        Assert.True(world[byName["Bip01 L Toe0"]].Pos.Z > world[byName["Bip01 L Foot"]].Pos.Z);
        Assert.True(world[byName["Bip01 L Thigh"]].Pos.X > 0f);
        Assert.True(world[byName["Bip01 R Thigh"]].Pos.X < 0f);
    }

    [Fact]
    public void Dff_PeekNodeCount_71_AndNullOnGarbage()
    {
        Assert.Equal(71, RwDffSkeleton.PeekNodeCount(Fixture("f_upper_b01.dff")));
        Assert.Null(RwDffSkeleton.PeekNodeCount(new byte[] { 1, 2, 3 }));
        Assert.Null(RwDffSkeleton.PeekNodeCount(Fixture("BACK0098_CATCH.anm")));
    }

    // ================================================================ anm parse level

    [Fact]
    public void Anm_PeekNodeCount_MatchesItemRig()
    {
        // BACK0098 is an item-prop clip: 20-node rig (t=0 keyframe block), 553 keyframes.
        Assert.Equal(20, RwAnmImporter.PeekNodeCount(Fixture("BACK0098_CATCH.anm")));
        Assert.Equal(1, RwAnmImporter.PeekTakeCount(Fixture("BACK0098_CATCH.anm")));
        Assert.Equal(1, RwAnmImporter.PeekTakeCount(Fixture("BACK0098_IDLE_1.anm")));
        Assert.Equal(1, RwAnmImporter.PeekTakeCount(Fixture("TWN_ABACK0016_FIX.anm")));
        Assert.Null(RwAnmImporter.PeekNodeCount(Fixture("f_upper_b01.dff")));
    }

    [Fact]
    public void Anm_Sniff_RecognizesRenderwareStreams()
    {
        Assert.True(RwAnmImporter.LooksLikeRenderwareAnim(Fixture("BACK0098_CATCH.anm")));
        Assert.True(RwAnmImporter.LooksLikeRenderwareAnim(Fixture("7FEA6AEBBEB5C5F3.an5")));
        Assert.False(RwAnmImporter.LooksLikeRenderwareAnim(Fixture("f_upper_b01.dff")));
        Assert.False(RwAnmImporter.LooksLikeRenderwareAnim(new byte[] { 0, 1, 2, 3 }));
    }

    [Fact]
    public void Anm_MissingSkeleton_ThrowsInstructiveError()
    {
        var e = Assert.Throws<FormatException>(
            () => RwAnmImporter.Import(Fixture("BACK0098_CATCH.anm"), skeletonData: null));
        Assert.Contains(".dff", e.Message);
        Assert.Contains("next to the animation", e.Message);
    }

    [Fact]
    public void Anm_ImportsAgainstMatchingSkeleton_ViaFacadeExtension()
    {
        // Facade route: extension .anm → RenderWare importer; the 20-node item clip is
        // structurally importable against the 71-node rig (RenderWare anims may drive a
        // node subset — FSB2's own 3-node TRS anims do), though resolution in the editor
        // only pairs files whose node counts match.
        var scene = Retargeter.ImportSource(
            Fixture("BACK0098_CATCH.anm"), "BACK0098_CATCH.anm", skeletonData: Fixture("f_upper_b01.dff"));
        var clip = Assert.Single(scene.Clips);
        Assert.Equal("BACK0098_CATCH", clip.Name);
        Assert.Equal(71, scene.Skeleton.Count);
        Assert.True(clip.FrameCount >= 2);
        Assert.Equal(1f, scene.UnitScaleCm); // FSB2 is authored in centimeters
    }

    // ================================================================ an5 banks

    [Fact]
    public void An5_SplitsIntoTakes_NamedByBase()
    {
        var scene = ImportBank();
        Assert.Equal(3, scene.Clips.Count);
        Assert.Equal("7FEA6AEBBEB5C5F3_1", scene.Clips[0].Name);
        Assert.Equal("7FEA6AEBBEB5C5F3_2", scene.Clips[1].Name);
        Assert.Equal("7FEA6AEBBEB5C5F3_3", scene.Clips[2].Name);
        // A single-take bank keeps the plain base name (no _1 suffix).
        Assert.Equal(1, RwAnmImporter.PeekTakeCount(Fixture("FE450CFBB1F2A8CA.an5")));
        var single = RwAnmImporter.Import(
            Fixture("FE450CFBB1F2A8CA.an5"), Fixture("f_upper_b01.dff"),
            new RwAnmImportOptions { ClipNameBase = "FE450CFBB1F2A8CA" });
        Assert.Equal("FE450CFBB1F2A8CA", Assert.Single(single.Clips).Name);
        Assert.Equal(71, RwAnmImporter.PeekNodeCount(Fixture("7FEA6AEBBEB5C5F3.an5")));
    }

    [Fact]
    public void An5_Frames_AreCompleteAndAnimated()
    {
        var scene = ImportBank();
        foreach (var clip in scene.Clips)
        {
            Assert.True(clip.FrameCount >= 2, $"{clip.Name}: expected sampled frames");
            foreach (var frame in clip.Frames)
            {
                Assert.Equal(scene.Skeleton.Count, frame.Length);
                foreach (var x in frame)
                {
                    Assert.True(float.IsFinite(x.Pos.X) && float.IsFinite(x.Pos.Y) && float.IsFinite(x.Pos.Z));
                    Assert.True(MathF.Abs(x.Rot.Length() - 1f) < 1e-3f, "rotations must be normalized");
                }
            }

            // The body actually moves: some bone's rotation changes between first/last frame.
            var moved = false;
            for (var b = 0; b < scene.Skeleton.Count && !moved; b++)
            {
                var a = clip.Frames[0][b].Rot;
                var z = clip.Frames[clip.FrameCount - 1][b].Rot;
                moved = MathF.Abs(MathF.Abs(Quaternion.Dot(a, z)) - 1f) > 1e-4f;
            }
            Assert.True(moved, $"{clip.Name}: no bone rotation changed over the clip");
        }
    }

    [Fact]
    public void An5_RotationOnlyBones_HoldRestTranslation()
    {
        // FSB2 takes translate only Dummy01 / Ball_Point / Bip01 (the 3-node TRS anim);
        // every other bone must carry its .dff rest translation on every frame (bone
        // lengths come from the model, exactly like the runtime plays these).
        var dff = Dff();
        var scene = ImportBank();
        var clip = scene.Clips[0];
        var translated = new HashSet<string> { "Dummy01", "Ball_Point", "Bip01" };
        foreach (var node in dff.Nodes)
        {
            if (translated.Contains(node.Name))
                continue;
            var bone = scene.Skeleton.IndexOf(node.Name);
            foreach (var frame in clip.Frames)
                TestUtil.AssertVectorEqual(node.RestLocal.Pos, frame[bone].Pos, 1e-4f);
        }

        // ... and the hips DO translate (crouch/jump variation over the bank's takes).
        var hips = scene.Skeleton.IndexOf("Bip01");
        var ys = clip.Frames.Select(f => f[hips].Pos.Y).ToList();
        Assert.True(ys.Max() - ys.Min() > 0.5f, "hips should move vertically in a motion take");
    }

    [Fact]
    public void An5_Mapping_DetectsBipedPreset()
    {
        var scene = ImportBank();
        var (map, report) = Retargeter.ResolveMapping(scene.Skeleton);

        Assert.Equal("biped", map.ProfileName);
        Assert.Equal(MappingSource.Preset, map.Source);
        Assert.True(map.Confidence >= ProfileDetector.DetectionThreshold,
            $"confidence {map.Confidence} below detection threshold");
        Assert.False(report.NeedsUserDecision);

        string BoneOf(BoneRole role) => scene.Skeleton[map.RoleToBone[role]].Name;
        Assert.Equal("Bip01 Pelvis", BoneOf(BoneRole.Hips));
        Assert.Equal("Bip01 L Thigh", BoneOf(BoneRole.UpperLegL));
        Assert.Equal("Bip01 R Calf", BoneOf(BoneRole.LowerLegR));
        Assert.Equal("Bip01 L Foot", BoneOf(BoneRole.FootL));
        Assert.Equal("Bip01 R Toe0", BoneOf(BoneRole.ToeR));
        Assert.Equal("Bip01 L UpperArm", BoneOf(BoneRole.UpperArmL));
        Assert.Equal("Bip01 R Forearm", BoneOf(BoneRole.LowerArmR));
        Assert.Equal("Bip01 L Hand", BoneOf(BoneRole.HandL));
        Assert.Equal("Bip01 Head", BoneOf(BoneRole.Head));

        // Props/helpers must stay unmapped (ball, root dummy, twist/chest helpers, nubs).
        var mapped = map.RoleToBone.Values.Select(i => scene.Skeleton[i].Name).ToHashSet();
        Assert.DoesNotContain("Ball_Point", mapped);
        Assert.DoesNotContain("Dummy01", mapped);
        Assert.DoesNotContain("LFT_Bone01", mapped);
        Assert.DoesNotContain("RHT_Bone01", mapped);
        Assert.DoesNotContain("Bip01 L Toe0Nub", mapped);
    }

    // ================================================================ end to end

    [Fact]
    public void An5_Convert_ToCitizen_ProducesMovingDmx()
    {
        var target = RetargetTargetSpec.SboxCitizen(
            File.ReadAllText(RepoFile("Assets", "data", "humanoid_retargeter", "target_rig_sbox_citizen.json")));
        var result = Retargeter.Convert(new RetargetRequest
        {
            SourceData = Fixture("7FEA6AEBBEB5C5F3.an5"),
            SourceFileName = "7FEA6AEBBEB5C5F3.an5",
            SkeletonData = Fixture("f_upper_b01.dff"),
            TakeIndex = 0,
        }, target);

        Assert.True(result.Success, string.Join("; ", result.Errors));
        var clip = Assert.Single(result.Clips);
        Assert.True(clip.Success, clip.Error);
        Assert.Equal("7FEA6AEBBEB5C5F3_1", clip.ClipName);
        Assert.NotNull(clip.Mapping);
        Assert.Equal("biped", clip.Mapping!.ProfileName);
        Assert.Contains("pelvis_p", clip.DmxContent);
        Assert.Contains("pelvis_o", clip.DmxContent);
        Assert.Contains("hand_L_p", clip.DmxContent);

        // Solved target frames actually move.
        Assert.NotNull(clip.SolvedFrames);
        var frames = clip.SolvedFrames!;
        Assert.True(frames.Count >= 2);
        var pelvis = target.Rig.Skeleton.IndexOf("pelvis");
        var moved = false;
        for (var f = 1; f < frames.Count && !moved; f++)
            moved = (frames[f][pelvis].Pos - frames[0][pelvis].Pos).Length() > 0.5f
                || MathF.Abs(MathF.Abs(Quaternion.Dot(frames[f][pelvis].Rot, frames[0][pelvis].Rot)) - 1f) > 1e-4f;
        Assert.True(moved, "pelvis never moved in the solved clip");
    }

    // ================================================================ corpus sweep (opt-in)

    /// <summary>
    /// Full-corpus parse sweep with the C# parser. Runs only when HR_RENDERWARE_SWEEP
    /// points at a folder tree containing .anm/.an5 files (the full FSB2 sweep takes tens
    /// of seconds — see dev/tools/renderware/rw_explore.py for the standalone tool);
    /// otherwise it sweeps just the bundled fixtures.
    /// </summary>
    [Fact]
    public void Sweep_AllRenderwareFiles_Parse()
    {
        var root = Environment.GetEnvironmentVariable("HR_RENDERWARE_SWEEP");
        if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
            root = Path.Combine(AppContext.BaseDirectory, "fixtures", "renderware");

        var failures = new List<string>();
        var count = 0;
        foreach (var file in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories))
        {
            var ext = Path.GetExtension(file).ToLowerInvariant();
            if (ext is not (".anm" or ".an5"))
                continue;
            count++;
            var bytes = File.ReadAllBytes(file);
            if (RwAnmImporter.PeekNodeCount(bytes) is null || RwAnmImporter.PeekTakeCount(bytes) == 0)
                failures.Add(file);
        }

        Assert.True(count > 0, $"no .anm/.an5 files under {root}");
        Assert.True(failures.Count == 0,
            $"{failures.Count}/{count} files failed to parse:\n" + string.Join("\n", failures.Take(20)));
    }
}