Search the source of every open source package.
264 results
using Sandbox;
[TestClass]
public partial class LibraryTests
{
[TestMethod]
public void SceneTest()
{
var scene = new Scene();
using ( scene.Push() )
{
var go = new GameObject();
Assert.AreEqual( 1, scene.Directory.GameObjectCount );
}
}
}
global using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class TestInit
{
public static Sandbox.TestAppSystem AppSystem;
[AssemblyInitialize]
public static void AssemblyInitialize( TestContext context )
{
AppSystem = new Sandbox.TestAppSystem();
AppSystem.Init();
}
[AssemblyCleanup]
public static void AssemblyCleanup()
{
AppSystem.Shutdown();
}
}
global using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class TestInit
{
public static Sandbox.TestAppSystem AppSystem;
[AssemblyInitialize]
public static void AssemblyInitialize( TestContext context )
{
AppSystem = new Sandbox.TestAppSystem();
AppSystem.Init();
}
[AssemblyCleanup]
public static void AssemblyCleanup()
{
AppSystem.Shutdown();
}
}
using Sandbox;
[TestClass]
public partial class LibraryTests
{
[TestMethod]
public void SceneTest()
{
var scene = new Scene();
using ( scene.Push() )
{
var go = new GameObject();
Assert.AreEqual( 1, scene.Directory.GameObjectCount );
}
}
}
using Hexagon.V2.Application;
namespace Hexagon.V2.Tests.Application;
[TestClass]
public sealed class CharacterNameSkeletonTests
{
[TestMethod]
public void WithinScriptLookAlikesCollapseOntoTheNameTheyImitate()
{
var alice = CharacterNameSkeleton.Of( "Alice" );
// Digit-for-letter and the i/l/1 shape collision.
Assert.AreEqual( alice, CharacterNameSkeleton.Of( "A1ice" ) );
Assert.AreEqual( alice, CharacterNameSkeleton.Of( "Alice" ) );
Assert.AreEqual( alice, CharacterNameSkeleton.Of( "AIice" ) );
Assert.AreEqual( alice, CharacterNameSkeleton.Of( "4lice" ) );
// Separators and punctuation carry no identity.
Assert.AreEqual( alice, CharacterNameSkeleton.Of( "A l i c e" ) );
Assert.AreEqual( alice, CharacterNameSkeleton.Of( "A-l-i-c-e" ) );
// Accents are decoration, not identity.
Assert.AreEqual( CharacterNameSkeleton.Of( "Jose" ), CharacterNameSkeleton.Of( "Jos\u00E9" ) );
// The classic digraph pair.
Assert.AreEqual( CharacterNameSkeleton.Of( "Bemard" ), CharacterNameSkeleton.Of( "Bernard" ) );
Assert.AreEqual( CharacterNameSkeleton.Of( "Walker" ), CharacterNameSkeleton.Of( "VValker" ) );
}
[TestMethod]
public void WholeNameWrittenInAnotherScriptCollapsesOntoTheLatinNameItImitates()
{
// The script profile permits these - they are single-script names. Only the skeleton
// sees that they are drawn to read as "Alice" and "Petro".
// Cyrillic es-o-o-er-ie-ghe, which draws "Cooper" without one Latin letter in it.
Assert.AreEqual(
CharacterNameSkeleton.Of( "Cooper" ),
CharacterNameSkeleton.Of( "\u0441\u043E\u043E\u0440\u0435\u0433" ) );
// Greek rho-omicron-rho-omicron-nu, which draws "Popov".
Assert.AreEqual(
CharacterNameSkeleton.Of( "Popov" ),
CharacterNameSkeleton.Of( "\u03C1\u03BF\u03C1\u03BF\u03BD" ) );
}
[TestMethod]
public void FoldingFollowsUnicodeRatherThanLocalIntuition()
{
// A documented residual, asserted so a table bump surfaces it rather than hiding it:
// UTS #39 judges Cyrillic em confusable with a turned w, NOT with Latin m, so a Cyrillic
// name using it does not collapse onto the Latin name it arguably resembles. The script
// profile still blocks the mixed-script form, which is the reachable attack.
Assert.AreNotEqual(
CharacterNameSkeleton.Of( "Maxwell" ),
CharacterNameSkeleton.Of( "\u043C\u0430\u0445\u051D\u0435\u0456\u0456" ) );
// Latin i and l stay distinct - the standard folds the digit 1, capital I and the bar
// onto l, but never merges lowercase i with it.
Assert.AreEqual( CharacterNameSkeleton.Of( "Alice" ), CharacterNameSkeleton.Of( "A1ice" ) );
Assert.AreNotEqual( CharacterNameSkeleton.Of( "Bili" ), CharacterNameSkeleton.Of( "Bill" ) );
}
[TestMethod]
public void DistinctNamesKeepDistinctSkeletons()
{
Assert.AreNotEqual( CharacterNameSkeleton.Of( "Alice" ), CharacterNameSkeleton.Of( "Alicia" ) );
Assert.AreNotEqual( CharacterNameSkeleton.Of( "Alice" ), CharacterNameSkeleton.Of( "Alison" ) );
Assert.AreNotEqual( CharacterNameSkeleton.Of( "Barney" ), CharacterNameSkeleton.Of( "Gordon" ) );
Assert.AreNotEqual( CharacterNameSkeleton.Of( "Judith Mossman" ), CharacterNameSkeleton.Of( "Eli Vance" ) );
// A name in a script with no Latin look-alikes is left alone rather than flattened.
Assert.AreNotEqual(
CharacterNameSkeleton.Of( "\u0410\u043B\u0438\u0441\u0430" ), // Cyrillic "Alisa"
CharacterNameSkeleton.Of( "\u0411\u043E\u0440\u0438\u0441" ) ); // Cyrillic "Boris"
}
[TestMethod]
public void NameWithNoIdentityBearingCharactersHasAnEmptySkeleton()
{
Assert.AreEqual( string.Empty, CharacterNameSkeleton.Of( "..." ) );
Assert.AreEqual( string.Empty, CharacterNameSkeleton.Of( "---" ) );
Assert.AreEqual( string.Empty, CharacterNameSkeleton.Of( string.Empty ) );
}
}using System.Text.Json;
using Hexagon.V2.Application;
using Hexagon.V2.Domain;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Hexagon.V2.Tests.Application;
[TestClass]
public sealed class CurrencyAndCharacterRulesTests
{
[TestMethod]
public void CurrencyRejectsUnderflowAndOverflow()
{
var character = CreateCharacter( 10 );
Assert.IsTrue( CurrencyService.Debit( character, 11 ).Failed );
Assert.IsTrue( CurrencyService.Credit( character with { Balance = long.MaxValue }, 1 ).Failed );
}
[TestMethod]
public void SlotAllocatorUsesLowestGap()
{
var characters = new[] { CreateCharacter( 0 ) with { Slot = 0 }, CreateCharacter( 0 ) with { Slot = 2 } };
Assert.AreEqual( 1, CharacterRules.FindLowestFreeSlot( characters ) );
}
[TestMethod]
public void SlotAllocatorReturnsMinusOneWhenEverySlotIsOccupied()
{
var characters = Enumerable.Range( 0, CharacterRules.MaximumSlots )
.Select( slot => CreateCharacter( 0 ) with { Slot = slot } )
.ToArray();
Assert.AreEqual( -1, CharacterRules.FindLowestFreeSlot( characters ) );
var oneFree = characters.Where( character => character.Slot != CharacterRules.MaximumSlots - 1 ).ToArray();
Assert.AreEqual( CharacterRules.MaximumSlots - 1, CharacterRules.FindLowestFreeSlot( oneFree ) );
}
[TestMethod]
public void CreationRejectsUnregisteredFields()
{
var request = new CharacterCreationRequest
{
Name = "Valid Name",
Description = "A sufficiently long character description.",
Model = new DefinitionId( "test.model" ),
Faction = new FactionId( "test.faction" ),
Fields = new Dictionary<string, CreationValue> { ["money"] = CreationValue.Integer( long.MaxValue ) }
};
Assert.IsTrue( CharacterRules.ValidateCreationRequest( request, new HashSet<string>() ).Failed );
}
[TestMethod]
public void IdentityTextRejectsControlAndFormatCharacters()
{
// A name reaches other players verbatim through recognition records and nameplates, so
// spoofing payloads must not survive the creation boundary. These are written as escapes
// on purpose: as literals they are invisible in an editor and lost by copy/paste.
foreach ( var name in new[]
{
"Bob\nCombine Overwatch", // U+000A interior line break
"Bob\u0007Smith", // C0 control
"Bob\u202Eelbat", // right-to-left override
"Bo\u200Bb Smith", // zero-width space
"Bob\u200DSmith", // zero-width joiner
"Bob\u2066Smith\u2069", // bidirectional isolates
"Bob\uFEFFSmith" // zero-width no-break space
} )
{
Assert.IsTrue(
CharacterRules.NormalizeName( name ).Failed,
$"Name '{name.Replace( "\n", "\\n" )}' should have been rejected." );
Assert.IsTrue( CharacterRules.ValidateCreationRequest( Request( name: name ), NoFields ).Failed );
}
// Zero-width characters are not whitespace, so they survive Trim: a length check
// alone can never catch a name built entirely from them.
Assert.IsTrue( CharacterRules.NormalizeName( "\u200B\u200B\u200B\u200B" ).Failed );
}
[TestMethod]
public void DescriptionKeepsLineBreaksButRejectsOtherControlCharacters()
{
var prose = "A tall citizen in a worn jacket.\nHe keeps his hands in his pockets.";
Assert.AreEqual( prose, CharacterRules.NormalizeDescription( prose ).Value );
Assert.IsTrue( CharacterRules.NormalizeDescription( "A citizen\u202Ewith a reversed tail." ).Failed );
Assert.IsTrue( CharacterRules.NormalizeDescription( "A citizen with a\u0000 null byte." ).Failed );
}
[TestMethod]
public void IdentityTextIsCanonicalizedSoValidationAndStorageAgree()
{
// Combining and precomposed acutes render identically, so they must not be
// storable as two distinct names.
var decomposed = CharacterRules.NormalizeName( " Ame\u0301lie " );
Assert.IsTrue( decomposed.Succeeded );
Assert.AreEqual( "Am\u00E9lie", decomposed.Value );
Assert.AreEqual( decomposed.Value, CharacterRules.NormalizeName( "Am\u00E9lie" ).Value );
// An unpaired surrogate is rejected rather than thrown out of Normalize.
Assert.IsTrue( CharacterRules.NormalizeName( "Bob\uD800Smith" ).Failed );
// Ordinary names still pass.
Assert.IsTrue( CharacterRules.NormalizeName( "Valid Name" ).Succeeded );
Assert.IsTrue( CharacterRules.NormalizeName( "Baßmann-O'Neill" ).Succeeded );
Assert.IsTrue( CharacterRules.ValidateCreationRequest( Request(), NoFields ).Succeeded );
}
[TestMethod]
public void NameRejectsMixedScriptImpersonation()
{
// Each of these renders as ordinary Latin text but smuggles one letter from another
// script, which is exactly how a name is made to look like someone else's.
Assert.IsTrue( CharacterRules.NormalizeName( "\u0410lice" ).Failed ); // Cyrillic A
Assert.IsTrue( CharacterRules.NormalizeName( "Alic\u0435" ).Failed ); // Cyrillic e
Assert.IsTrue( CharacterRules.NormalizeName( "\u03A1eter" ).Failed ); // Greek Rho
Assert.IsTrue( CharacterRules.NormalizeName( "\u13AAlice" ).Failed ); // Cherokee
Assert.IsTrue( CharacterRules.ValidateCreationRequest( Request( name: "\u0410lice" ), NoFields ).Failed );
// A wholly Cyrillic or Greek name is a real name, not an impersonation.
Assert.IsTrue( CharacterRules.NormalizeName( "Алиса" ).Succeeded );
Assert.IsTrue( CharacterRules.NormalizeName( "Γεωργος" ).Succeeded );
}
[TestMethod]
public void NameAllowsTheScriptCombinationsRealNamesNeed()
{
// Japanese mixes Han, Hiragana and Katakana by nature, and may carry Latin.
Assert.IsTrue( CharacterRules.NormalizeName( "田中 ひろし" ).Succeeded );
Assert.IsTrue( CharacterRules.NormalizeName( "タナカ Tanaka" ).Succeeded );
// Korean mixes Hangul with Latin.
Assert.IsTrue( CharacterRules.NormalizeName( "김철수 Kim" ).Succeeded );
// Digits, spaces and punctuation are script-neutral and never constrain a name.
Assert.IsTrue( CharacterRules.NormalizeName( "Dr. Judith Mossman II" ).Succeeded );
// Greek alongside Latin is not a combination any real name needs.
Assert.IsTrue( CharacterRules.NormalizeName( "Alice Γεω" ).Failed );
}
[TestMethod]
public void NameFoldsCompatibilityLookAlikesOntoWhatTheyImitate()
{
// Fullwidth Latin is Latin script, so mixed-script detection cannot see it. NFKC folding
// is what stops it being a second, visually identical spelling of an existing name.
var fullwidth = CharacterRules.NormalizeName( "\uFF21lice" );
Assert.IsTrue( fullwidth.Succeeded );
Assert.AreEqual( "Alice", fullwidth.Value );
// A description is prose, not identity: it keeps compatibility characters and may mix
// scripts freely. Only its control- and format-character rules apply.
Assert.IsTrue( CharacterRules.NormalizeDescription(
"A citizen who mutters Алиса under their breath." ).Succeeded );
Assert.IsTrue( CharacterRules.NormalizeDescription(
"A citizen wearing a \uFF21-series jumpsuit, collar upturned." ).Succeeded );
}
[TestMethod]
public void TwoUnenumeratedScriptsAreStillTwoScripts()
{
// Neither Coptic nor Runic is in the named script table. They must not share one "other"
// bucket, or a name could mix two writing systems the profile never actually looked at.
Assert.IsTrue( CharacterRules.NormalizeName( "\u2C81\u2C83\u2C85" ).Succeeded ); // Coptic alone
Assert.IsTrue( CharacterRules.NormalizeName( "\u16A0\u16A2\u16A6" ).Succeeded ); // Runic alone
Assert.IsTrue( CharacterRules.NormalizeName( "\u2C81\u2C83\u16A0" ).Failed ); // the two mixed
Assert.IsTrue( CharacterRules.NormalizeName( "\u2C81\u2C83Ab" ).Failed ); // one with Latin
}
private static IReadOnlySet<string> NoFields => new HashSet<string>();
private static CharacterCreationRequest Request( string name = "Valid Name" ) =>
new()
{
Name = name,
Description = "A sufficiently long character description.",
Model = new DefinitionId( "test.model" ),
Faction = new FactionId( "test.faction" ),
Fields = new Dictionary<string, CreationValue>()
};
private static CharacterRecord CreateCharacter( long balance )
{
using var document = JsonDocument.Parse( "{}" );
return new CharacterRecord
{
Id = CharacterId.New(),
AccountId = new AccountId( 1 ),
Slot = 0,
Name = "Test",
Description = "A sufficiently long description.",
Model = new DefinitionId( "test.model" ),
Faction = new FactionId( "test.faction" ),
Balance = balance,
CreatedAt = DateTimeOffset.UtcNow,
LastPlayedAt = DateTimeOffset.UtcNow,
SchemaState = new TypedPayload
{
TypeId = new PersistedTypeId( "test.character" ),
TypeVersion = 1,
Data = document.RootElement.Clone()
}
};
}
}
#nullable enable
using Hexagon.V2.Client;
using Hexagon.V2.Domain;
using Hexagon.V2.Kernel;
using Hexagon.V2.Networking;
namespace Hexagon.V2.Tests.Client;
[TestClass]
public sealed class ClientControllerTests
{
[TestMethod]
public async Task ControllerMapsEveryUiIntentToAnExplicitCommand()
{
var transport = new RecordingTransport();
var controller = new HexClientController(transport);
var characterId = CharacterId.New();
var sourceId = InventoryId.New();
var targetId = InventoryId.New();
var itemId = ItemId.New();
var actionId = new ActionId("use");
var actionInstance = Guid.NewGuid();
var actionArguments = new Dictionary<string, SnapshotValue>( StringComparer.Ordinal )
{
["amount"] = SnapshotValue.Integer( 3 )
};
var creation = new CharacterCreationInput(
"Alyx", "Description", new DefinitionId("citizen_model"),
new FactionId("citizen"), null);
await controller.RequestCharactersAsync();
await controller.CreateCharacterAsync(creation);
await controller.LoadCharacterAsync(characterId);
await controller.DeleteCharacterAsync(characterId);
await controller.UnloadCharacterAsync();
await controller.MoveItemAsync(sourceId, targetId, itemId, 2, 3);
await controller.RunItemActionAsync(sourceId, itemId, actionId, actionArguments);
actionArguments["amount"] = SnapshotValue.Integer( 99 );
await controller.DropItemAsync(sourceId, itemId);
await controller.PickUpItemAsync(itemId, targetId);
await controller.SendChatAsync("ic", "Hello");
await controller.CancelActionAsync(actionInstance);
Assert.HasCount(11, transport.Commands);
Assert.IsInstanceOfType<RequestCharacterListCommand>(transport.Commands[0]);
Assert.AreSame(creation, ((CreateCharacterCommand)transport.Commands[1]).Input);
Assert.AreEqual(characterId, ((LoadCharacterCommand)transport.Commands[2]).CharacterId);
Assert.IsInstanceOfType<DeleteCharacterCommand>(transport.Commands[3]);
Assert.IsInstanceOfType<UnloadCharacterCommand>(transport.Commands[4]);
Assert.AreEqual((2, 3),
(((MoveInventoryItemCommand)transport.Commands[5]).X,
((MoveInventoryItemCommand)transport.Commands[5]).Y));
Assert.AreEqual(actionId, ((RunItemActionCommand)transport.Commands[6]).ActionId);
Assert.AreEqual( 3L, ((RunItemActionCommand)transport.Commands[6]).Arguments["amount"].IntegerValue );
Assert.IsInstanceOfType<DropItemCommand>(transport.Commands[7]);
Assert.IsInstanceOfType<PickUpItemCommand>(transport.Commands[8]);
Assert.AreEqual("Hello", ((SendChatCommand)transport.Commands[9]).Text);
Assert.AreEqual(actionInstance, ((CancelActionCommand)transport.Commands[10]).InstanceId);
}
[TestMethod]
public async Task TransportFailureAndCancellationTokenArePropagated()
{
var expected = OperationResult.Failure(ErrorCode.Unauthorized, "Denied.");
var transport = new RecordingTransport(expected);
var controller = new HexClientController(transport);
using var source = new CancellationTokenSource();
var result = await controller.SendChatAsync("ic", "Hello", source.Token);
Assert.AreEqual(ErrorCode.Unauthorized, result.Error!.Code);
Assert.AreEqual(source.Token, transport.LastCancellationToken);
}
private sealed class RecordingTransport : IClientCommandTransport
{
private readonly OperationResult _result;
public RecordingTransport(OperationResult? result = null) =>
_result = result ?? OperationResult.Success();
public List<ClientCommand> Commands { get; } = new();
public CancellationToken LastCancellationToken { get; private set; }
public ValueTask<OperationResult> SendAsync(
ClientCommand command,
CancellationToken cancellationToken = default)
{
Commands.Add(command);
LastCancellationToken = cancellationToken;
return ValueTask.FromResult(_result);
}
}
}
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
[assembly: Parallelize( Scope = ExecutionScope.MethodLevel )]
global using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class TestInit
{
[AssemblyInitialize]
public static void ClassInitialize( TestContext context )
{
Sandbox.Application.InitUnitTest();
}
}
using System.Numerics;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Target;
using HumanoidRetargeter.Tests.Skeleton;
using Xunit;
using SkeletonModel = HumanoidRetargeter.Core.Skeleton.Skeleton;
namespace HumanoidRetargeter.Tests.Target;
public class SmartPortAdditiveIkTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public void RecoilDoesNotInventGoalTranslationFromDifferentArmBindPoses(bool authoredGoalMotion)
{
var original = TargetRig.Load(TargetRigGenerator.Generate(File.ReadAllText(SkeletonTests.FixturePath("rig_human_male.json")))).Skeleton;
const string goal = "support_goal";
var definitions = original.Bones.Select(b => new BoneDefinition(b.Name,
b.ParentIndex < 0 ? null : original[b.ParentIndex].Name, b.RestLocal)).ToList();
definitions.Add(new(goal, "hand_R", XForm.ToLocal(original.RestWorld[original.IndexOf("hand_R")],
original.RestWorld[original.IndexOf("hand_L")])));
var source = SkeletonModel.Create(definitions);
var target = SkeletonModel.Create(source.Bones.Select(b => new BoneDefinition(b.Name,
b.ParentIndex < 0 ? null : source[b.ParentIndex].Name,
new XForm(b.RestLocal.Pos * .8f, b.RestLocal.Rot *
(b.Name.StartsWith("arm_upper_") ? Quaternion.CreateFromAxisAngle(Vector3.UnitY, .6f) : Quaternion.Identity)))).ToArray());
var rig = new SmartPortRig(source, target, new Dictionary<string, string> { [goal] = "hand_L" });
var ordinary = new SmartPortRig(source, target);
var pose = Enumerable.Repeat(XForm.Identity, source.Count).ToArray();
pose[source.IndexOf("arm_upper_R")].Rot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, .2f);
pose[source.IndexOf("arm_lower_L")].Rot = Quaternion.CreateFromAxisAngle(Vector3.UnitX, -.3f);
pose[source.IndexOf(goal)] = new XForm(authoredGoalMotion ? new Vector3(.2f, -.1f, .3f) : Vector3.Zero,
Quaternion.CreateFromAxisAngle(Vector3.UnitY, .1f));
var actual = rig.Transfer(pose, true)[rig.Target.IndexOf(goal)];
var expected = ordinary.Transfer(pose, true)[ordinary.Target.IndexOf(goal)];
// Additive goals are local deltas on the already fitted grip, not absolute
// effector positions computed against a reconstructed rest-pose animation.
Assert.True(Vector3.Distance(expected.Pos, actual.Pos) < .0001f, $"Expected {expected.Pos}, got {actual.Pos}");
Assert.True(MathQ.AngleBetween(expected.Rot, actual.Rot) < .001f);
if (!authoredGoalMotion) Assert.True(actual.Pos.Length() < .0001f);
else Assert.True(actual.Pos.Length() > .1f); // Preserve authored motion; do not freeze the goal.
}
}
using System.Numerics;
using HumanoidRetargeter.Core.Cleanup;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Target;
using HumanoidRetargeter.Tests.Skeleton;
using Xunit;
using SkeletonModel = HumanoidRetargeter.Core.Skeleton.Skeleton;
namespace HumanoidRetargeter.Tests.Target;
public class SmartPortGripReachTests
{
[Fact]
public void SharedGripFitsShorterArmsWithoutStretching()
{
var original = TargetRig.Load(TargetRigGenerator.Generate(File.ReadAllText(SkeletonTests.FixturePath("rig_human_male.json")))).Skeleton;
var definitions = original.Bones.Select(b => new BoneDefinition(b.Name,
b.ParentIndex < 0 ? null : original[b.ParentIndex].Name, b.RestLocal)).ToList();
foreach (var side in new[] { "L", "R" }) definitions.Add(new("grip_" + side, "hand_" + side, XForm.Identity));
definitions.Add(new("support_goal", "hand_R", XForm.Identity));
var source = SkeletonModel.Create(definitions);
var target = SkeletonModel.Create(original.Bones.Select(b => new BoneDefinition(b.Name,
b.ParentIndex < 0 ? null : original[b.ParentIndex].Name,
new XForm(b.RestLocal.Pos * (b.Name is "arm_lower_L" or "hand_L" ? .55f : 1), b.RestLocal.Rot))).ToArray());
var pose = source.Bones.Select(b => b.RestLocal).ToArray();
var left = source.IndexOf("arm_upper_L"); var right = source.IndexOf("arm_upper_R");
var center = (source.RestWorld[left].Pos + source.RestWorld[right].Pos) * .5f;
var up = Vector3.Normalize(source.RestWorld[source.IndexOf("head")].Pos - source.RestWorld[source.IndexOf("pelvis")].Pos);
var lateral = Vector3.Normalize(source.RestWorld[right].Pos - source.RestWorld[left].Pos);
var forward = Vector3.Normalize(Vector3.Cross(up, lateral));
var reach = source[source.IndexOf("arm_lower_R")].RestLocal.Pos.Length() + source[source.IndexOf("hand_R")].RestLocal.Pos.Length();
foreach (var side in new[] { "L", "R" })
{
var chain = new LimbChain { Upper = source.IndexOf("arm_upper_" + side), Lower = source.IndexOf("arm_lower_" + side), End = source.IndexOf("hand_" + side) };
var goal = center + forward * reach * (side == "L" ? .75f : .5f) + lateral * (side == "L" ? -4 : 4) - up * reach * .1f;
EffectorIk.ApplyGoals(new() { pose }, source, chain, new[] { goal }, lateral, soften: 0);
}
var input = new Pose(pose).ToWorld(source);
pose[source.IndexOf("support_goal")] = XForm.ToLocal(input[source.IndexOf("hand_R")], input[source.IndexOf("hand_L")]);
var targets = new Dictionary<string, string> { ["support_goal"] = "hand_L" };
var rig = new SmartPortRig(source, target, targets, new[] { "grip_L", "grip_R" });
var result = rig.Transfer(pose);
var world = new Pose(result).ToWorld(rig.Target);
var error = Vector3.Distance(world[rig.Target.IndexOf("hand_L")].Pos, world[rig.Target.IndexOf("support_goal")].Pos);
Assert.True(error < .01f, $"Support hand missed its goal by {error}");
foreach (var side in new[] { "L", "R" })
foreach (var name in new[] { "arm_upper_", "arm_lower_", "hand_" })
{
var index = rig.Target.IndexOf(name + side);
Assert.True(Vector3.Distance(rig.Target[index].RestLocal.Pos, result[index].Pos) < .0001f);
}
var unfitted = new SmartPortRig(source, target, targets);
var before = new Pose(unfitted.Transfer(pose)).ToWorld(unfitted.Target);
Assert.True(Vector3.Distance(before[unfitted.Target.IndexOf("hand_L")].Pos, before[unfitted.Target.IndexOf("support_goal")].Pos) > .1f);
Assert.All(rig.Transfer(Enumerable.Repeat(XForm.Identity, source.Count).ToArray(), true), delta =>
{
Assert.True(delta.Pos.Length() < .0001f);
Assert.True(MathQ.AngleBetween(delta.Rot, Quaternion.Identity) < .001f);
});
}
}
using System.Numerics;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Target;
using HumanoidRetargeter.Tests.Skeleton;
using Xunit;
using SkeletonModel = HumanoidRetargeter.Core.Skeleton.Skeleton;
namespace HumanoidRetargeter.Tests.Target;
public class SmartPortHandSocketTests
{
[Theory]
[InlineData(0)]
[InlineData(2)]
public void CopiedSocketFitsTheTargetHandNotItsLegProportions(int helperDepth)
{
var original = TargetRig.Load(TargetRigGenerator.Generate(File.ReadAllText(SkeletonTests.FixturePath("rig_human_male.json")))).Skeleton;
var names = new[] { "finger_index_0_R", "finger_middle_0_R", "finger_ring_0_R" };
Vector3 Center(SkeletonModel sk, XForm[] world) => names.Select(n => world[sk.IndexOf(n)].Pos).Aggregate(Vector3.Zero, (a,b) => a+b) / names.Length;
const string socket = "new_hand_socket";
var bind = original.RestWorld.ToArray();
var hand = original.IndexOf("hand_R");
var definitions = original.Bones.Select(b => new BoneDefinition(b.Name,
b.ParentIndex < 0 ? null : original[b.ParentIndex].Name, b.RestLocal)).ToList();
definitions.Add(new(socket, "hand_R", new XForm(bind[hand].Inverse().TransformPoint(Center(original, bind)), Quaternion.Identity)));
var attachment = socket;
for (var i = 0; i < helperDepth; i++)
{
var child = "nested_socket_" + i;
definitions.Add(new(child, attachment, XForm.Identity));
attachment = child;
}
var source = SkeletonModel.Create(definitions);
var target = SkeletonModel.Create(original.Bones.Select(b => new BoneDefinition(b.Name,
b.ParentIndex < 0 ? null : original[b.ParentIndex].Name,
new XForm(b.RestLocal.Pos * (b.Name.StartsWith("finger_") ? .5f : 1f), b.RestLocal.Rot))).ToArray());
var rig = new SmartPortRig(source, target, attachmentBones: new[] { attachment, socket });
var pose = source.Bones.Select(b => b.RestLocal).ToArray();
var result = rig.Transfer(pose);
var world = new Pose(result).ToWorld(rig.Target);
var unfitted = new SmartPortRig(source, target);
var before = new Pose(unfitted.Transfer(pose)).ToWorld(unfitted.Target);
Assert.True(Vector3.Distance(Center(unfitted.Target, before), before[unfitted.Target.IndexOf(socket)].Pos) > .1f);
Assert.True(Vector3.Distance(Center(rig.Target, world), world[rig.Target.IndexOf(socket)].Pos) < .001f,
$"Expected {Center(rig.Target, world)}, got {world[rig.Target.IndexOf(socket)].Pos}");
Assert.True(Vector3.Distance(Center(rig.Target, world), world[rig.Target.IndexOf(attachment)].Pos) < .001f);
foreach (var bone in target.Bones)
Assert.Equal(bone.RestLocal, rig.Target[rig.Target.IndexOf(bone.Name)].RestLocal);
Assert.All(rig.Transfer(Enumerable.Repeat(XForm.Identity, source.Count).ToArray(), true), delta =>
{
Assert.True(delta.Pos.Length() < .0001f);
Assert.True(MathQ.AngleBetween(delta.Rot, Quaternion.Identity) < .001f);
});
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ExistingFittedSocketIsNotReplacedByHandGeometry(bool nestedSource)
{
var original = TargetRig.Load(TargetRigGenerator.Generate(File.ReadAllText(SkeletonTests.FixturePath("rig_human_male.json")))).Skeleton;
var definitions = original.Bones.Select(b => new BoneDefinition(b.Name,
nestedSource && b.Name == "hold_R" ? "weapon_pivot" : b.ParentIndex < 0 ? null : original[b.ParentIndex].Name, b.RestLocal)).ToList();
if (nestedSource) definitions.Add(new("weapon_pivot", "hand_R", XForm.Identity));
var source = SkeletonModel.Create(definitions);
var target = SkeletonModel.Create(original.Bones.Select(b => new BoneDefinition(b.Name,
b.ParentIndex < 0 ? null : original[b.ParentIndex].Name,
new XForm(b.RestLocal.Pos * (b.Name.StartsWith("finger_") ? .5f : 1f)
+ (b.Name == "hold_R" ? new Vector3(.2f, .1f, .3f) : Vector3.Zero), b.RestLocal.Rot))).ToArray());
var fitted = new SmartPortRig(source, target, attachmentBones: new[] { "hold_R" });
var ordinary = new SmartPortRig(source, target);
var pose = source.Bones.Select(b => b.RestLocal).ToArray();
Assert.Equal(ordinary.Transfer(pose), fitted.Transfer(pose));
}
/// <summary>A target with a half-size hand whose hold_R sits outside the hand entirely.</summary>
static (SkeletonModel Source, SkeletonModel Target) OffHandSocket()
{
var original = TargetRig.Load(TargetRigGenerator.Generate(File.ReadAllText(SkeletonTests.FixturePath("rig_human_male.json")))).Skeleton;
var small = SkeletonModel.Create(original.Bones.Select(b => new BoneDefinition(b.Name,
b.ParentIndex < 0 ? null : original[b.ParentIndex].Name,
new XForm(b.RestLocal.Pos * (b.Name.StartsWith("finger_") ? .5f : 1f), b.RestLocal.Rot))).ToArray());
var hand = small.IndexOf("hand_R");
var reach = small.Bones.Where(b => b.Name.StartsWith("finger_") && b.Name.EndsWith("_R"))
.Max(b => Vector3.Distance(small.RestWorld[b.Index].Pos, small.RestWorld[hand].Pos));
// Well past the fingertips: a weapon on it would hang in the air.
var place = small.RestWorld[hand].Pos + Vector3.UnitZ * reach * 2.2f;
var target = SkeletonModel.Create(small.Bones.Select(b => new BoneDefinition(b.Name, b.ParentIndex < 0 ? null : small[b.ParentIndex].Name,
b.Name == "hold_R" ? new XForm(small.RestWorld[hand].Inverse().TransformPoint(place), b.RestLocal.Rot) : b.RestLocal)).ToArray());
return (original, target);
}
static Vector3 Knuckles(SkeletonModel sk, XForm[] world) => new[] { "finger_index_0_R", "finger_middle_0_R", "finger_ring_0_R" }
.Select(n => world[sk.IndexOf(n)].Pos).Aggregate(Vector3.Zero, (a, b) => a + b) / 3f;
[Fact]
public void ExistingSocketOutsideTheHandIsFittedToTheKnuckles()
{
var (source, target) = OffHandSocket();
var rig = new SmartPortRig(source, target, attachmentBones: new[] { "hold_R" });
var unfitted = new SmartPortRig(source, target);
var pose = source.Bones.Select(b => b.RestLocal).ToArray();
var world = new Pose(rig.Transfer(pose)).ToWorld(rig.Target);
var before = new Pose(unfitted.Transfer(pose)).ToWorld(unfitted.Target);
var socket = rig.Target.IndexOf("hold_R");
var sourceGrip = source.RestWorld[source.IndexOf("hold_R")].Pos - Knuckles(source, source.RestWorld.ToArray());
// Where the source's grip lands on this hand: the knuckle centre plus the source offset.
var expected = Knuckles(rig.Target, world) + sourceGrip * rig.MotionScale;
Assert.True(Vector3.Distance(before[socket].Pos, expected) > 1f, "the socket must start off the grip");
Assert.True(Vector3.Distance(world[socket].Pos, expected) < .001f, $"Expected {expected}, got {world[socket].Pos}");
// Rotation stays as animated; the bind and every other bone are untouched.
Assert.True(MathQ.AngleBetween(world[socket].Rot, before[socket].Rot) < .001f);
foreach (var bone in target.Bones)
Assert.Equal(bone.RestLocal, rig.Target[rig.Target.IndexOf(bone.Name)].RestLocal);
var result = rig.Transfer(pose);
var plain = unfitted.Transfer(pose);
for (var i = 0; i < result.Length; i++)
if (i != socket) Assert.Equal(plain[i], result[i]);
// Additive clips: a zero delta stays zero.
Assert.All(rig.Transfer(Enumerable.Repeat(XForm.Identity, source.Count).ToArray(), true), delta =>
{
Assert.True(delta.Pos.Length() < .0001f);
Assert.True(MathQ.AngleBetween(delta.Rot, Quaternion.Identity) < .001f);
});
}
[Fact]
public void FittedExistingSocketFollowsTheGripWhileTheHandAnimates()
{
var (source, target) = OffHandSocket();
var rig = new SmartPortRig(source, target, attachmentBones: new[] { "hold_R" });
// Bend the wrist: the grip moves with the hand, the socket must stay on it.
var hand = source.IndexOf("hand_R");
var pose = source.Bones.Select(b => b.RestLocal).ToArray();
pose[hand] = new XForm(pose[hand].Pos, Quaternion.Normalize(pose[hand].Rot * Quaternion.CreateFromAxisAngle(Vector3.UnitZ, .6f)));
var sourceWorld = new Pose(pose).ToWorld(source);
var sourceGrip = sourceWorld[source.IndexOf("hold_R")].Pos - Knuckles(source, sourceWorld);
var world = new Pose(rig.Transfer(pose)).ToWorld(rig.Target);
var expected = Knuckles(rig.Target, world) + sourceGrip * rig.MotionScale;
Assert.True(Vector3.Distance(world[rig.Target.IndexOf("hold_R")].Pos, expected) < .001f,
$"Expected {expected}, got {world[rig.Target.IndexOf("hold_R")].Pos}");
}
}
using System.Numerics;
using HumanoidRetargeter.Core.Dl;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Solve;
using Xunit;
namespace HumanoidRetargeter.Tests.Dl;
/// <summary>
/// Quality gate for the DL solver (Milestone 10): on the spike's fixture clip, the SAME
/// port's role trajectories must agree with the verified geometric solver's — the spike
/// measured a mean role cosine of 0.937 against the geometric dump; the gate sits below it
/// (mean ≥ 0.85 across body roles, feet/head ≥ 0.9 individually, hands are the known-weak
/// spot of the pretrained checkpoint).
/// </summary>
public class DlSolverTests
{
private static readonly Lazy<byte[]> WeightBytes = new(() => File.ReadAllBytes(
DlFixtures.RepoFile("Assets", "data", "humanoid_retargeter", "dl", "same_v1.weights")));
private static readonly string[] BodyRoleBones =
{
"head", "hand_L", "hand_R", "ankle_L", "ankle_R",
"ball_L", "ball_R", "arm_lower_L", "arm_lower_R",
"leg_lower_L", "leg_lower_R",
};
private static readonly string[] StrictBones = { "head", "ankle_L", "ankle_R", "ball_L", "ball_R" };
private static (Clip Dl, Clip Geo, SourceScene Scene) Solve()
{
var scene = DlFixtures.FixtureBvh();
var map = ProfileDetector.Detect(scene.Skeleton) is { } detected
? detected.Result
: throw new InvalidOperationException("fixture should detect as mixamo");
var rig = DlFixtures.SboxRig.Value;
var dl = new DlSolver(WeightBytes.Value).Solve(scene, map, rig, new SolveOptions());
var geo = new GeometricSolver().Solve(scene, map, rig, new SolveOptions());
return (dl, geo, scene);
}
/// <summary>One shared solve pair for this class and the deriver tests.</summary>
internal static readonly Lazy<(Clip Dl, Clip Geo, SourceScene Scene)> Solved = new(Solve);
[Fact]
public void SolveIsFiniteAndFrameAligned()
{
var (dl, _, scene) = Solved.Value;
var clip = scene.Clips[0];
Assert.Equal(clip.FrameCount, dl.FrameCount);
Assert.Equal(clip.Fps, dl.Fps);
foreach (var frame in dl.Frames)
{
Assert.Equal(DlFixtures.SboxRig.Value.Skeleton.Count, frame.Length);
foreach (var xf in frame)
{
Assert.True(float.IsFinite(xf.Pos.X) && float.IsFinite(xf.Pos.Y) && float.IsFinite(xf.Pos.Z));
Assert.True(float.IsFinite(xf.Rot.X) && float.IsFinite(xf.Rot.Y)
&& float.IsFinite(xf.Rot.Z) && float.IsFinite(xf.Rot.W));
}
}
}
[Fact]
public void SolveIsDeterministic()
{
var (dl, _, scene) = Solved.Value;
var map = ProfileDetector.Detect(scene.Skeleton)!.Value.Result;
var again = new DlSolver(WeightBytes.Value).Solve(
scene, map, DlFixtures.SboxRig.Value, new SolveOptions());
for (var f = 0; f < dl.FrameCount; f++)
{
for (var b = 0; b < dl.Frames[f].Length; b++)
Assert.Equal(dl.Frames[f][b], again.Frames[f][b]);
}
}
[Fact]
public void QualityGate_RoleTrajectoriesAgreeWithGeometricSolver()
{
var (dl, geo, _) = Solved.Value;
var rig = DlFixtures.SboxRig.Value;
var skeleton = rig.Skeleton;
var frames = Math.Min(dl.FrameCount, geo.FrameCount);
var pelvis = skeleton.IndexOf("pelvis");
var head = skeleton.IndexOf("head");
Assert.True(pelvis >= 0 && head >= 0);
// World positions per frame for both solves.
var dlPos = WorldPositions(skeleton, dl, frames);
var geoPos = WorldPositions(skeleton, geo, frames);
// Per-side scale proxy (mean pelvis→head distance) for scale-invariant comparison.
var dlScale = MeanDistance(dlPos, pelvis, head, frames);
var geoScale = MeanDistance(geoPos, pelvis, head, frames);
Assert.True(dlScale > 1f && geoScale > 1f);
var report = new System.Text.StringBuilder();
var cosines = new Dictionary<string, float>();
foreach (var bone in BodyRoleBones)
{
var index = skeleton.IndexOf(bone);
Assert.True(index >= 0, $"rig has no bone '{bone}'");
var sum = 0f;
for (var f = 0; f < frames; f++)
{
var a = (dlPos[f][index] - dlPos[f][pelvis]) / dlScale;
var b = (geoPos[f][index] - geoPos[f][pelvis]) / geoScale;
var denom = a.Length() * b.Length() + 1e-8f;
sum += Vector3.Dot(a, b) / denom;
}
var cos = sum / frames;
cosines[bone] = cos;
report.AppendLine($"{bone}: {cos:0.000}");
}
var mean = cosines.Values.Average();
Assert.True(mean >= 0.85f,
$"mean role-trajectory cosine {mean:0.000} < 0.85 (spike measured 0.937)\n{report}");
foreach (var bone in StrictBones)
{
Assert.True(cosines[bone] >= 0.9f,
$"{bone} trajectory cosine {cosines[bone]:0.000} < 0.90\n{report}");
}
}
private static Vector3[][] WorldPositions(
HumanoidRetargeter.Core.Skeleton.Skeleton skeleton, Clip clip, int frames)
{
var result = new Vector3[frames][];
for (var f = 0; f < frames; f++)
{
var world = new Pose(clip.Frames[f]).ToWorld(skeleton);
result[f] = new Vector3[skeleton.Count];
for (var b = 0; b < skeleton.Count; b++)
result[f][b] = world[b].Pos;
}
return result;
}
private static float MeanDistance(Vector3[][] positions, int a, int b, int frames)
{
var sum = 0f;
for (var f = 0; f < frames; f++)
sum += Vector3.Distance(positions[f][a], positions[f][b]);
return sum / frames;
}
}
using System.Globalization;
using System.Numerics;
using System.Text;
using HumanoidRetargeter.Core.Formats.Bvh;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using Xunit;
namespace HumanoidRetargeter.Tests.Formats;
/// <summary>
/// The BVH importer's calibration rest-frame trim (user report: 47_01.bvh — "starts either
/// ends or starts at t-pose … when i move the preview slider to 0 then there's a t-pose").
/// Mocap conversions (CMU asf/amc exports among them) prepend a skeleton-calibration
/// segment: the rest pose itself, hard-cut — or blend-ramped over 2–3 frames
/// (makehuman-retarget exports) — into the real motion. Measured on the repro corpus:
/// calibration frames/ramps sit ≤ 24° (max joint rotation) from the identity-rotation bind
/// with a 25–177° discontinuity into the motion; every real clip edge sits ≥ 80° from the
/// bind with ≤ 8° edge continuity. The importer drops such a segment (≤ 4 frames) per clip
/// end, and ONLY when its frames are rest-like AND the clip beyond is not AND the segment's
/// exit discontinuity is large both absolutely and against the clip's median inter-frame
/// delta — a clip legitimately idling near rest must never lose its first frame.
/// </summary>
public class BvhCalibrationTrimTests
{
private const float Rad2Deg = 180f / MathF.PI;
// ---------------------------------------------------------------- synthetic fixtures
/// <summary>
/// Two-joint rig (30 native fps = the import grid, so native frames map 1:1 onto clip
/// frames) whose Head rotation per native frame is caller-chosen. Offsets keep the rest
/// height above the meters heuristic threshold.
/// </summary>
private static byte[] BvhWithHeadAngles(params float[] headZDegPerFrame)
{
var sb = new StringBuilder();
sb.AppendLine("HIERARCHY");
sb.AppendLine("ROOT Hips");
sb.AppendLine("{");
sb.AppendLine(" OFFSET 0 0 0");
sb.AppendLine(" CHANNELS 6 Xposition Yposition Zposition Zrotation Yrotation Xrotation");
sb.AppendLine(" JOINT Head");
sb.AppendLine(" {");
sb.AppendLine(" OFFSET 0 70 0");
sb.AppendLine(" CHANNELS 3 Zrotation Yrotation Xrotation");
sb.AppendLine(" End Site");
sb.AppendLine(" {");
sb.AppendLine(" OFFSET 0 10 0");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine("}");
sb.AppendLine("MOTION");
sb.AppendLine($"Frames: {headZDegPerFrame.Length}");
sb.AppendLine("Frame Time: 0.0333333");
foreach (var z in headZDegPerFrame)
{
sb.AppendLine(string.Create(
CultureInfo.InvariantCulture, $"0 90 0 0 0 0 {z} 0 0"));
}
return Encoding.ASCII.GetBytes(sb.ToString());
}
private static float HeadAngleDeg(SourceScene scene, int frame)
{
var head = scene.Skeleton.IndexOf("Head");
return MathQ.AngleBetween(
Quaternion.Identity, scene.Clips[0].Frames[frame][head].Rot) * Rad2Deg;
}
/// <summary>A held pose at 70° with ~1°/frame drift; interior deltas ≈ 1°.</summary>
private static float[] HeldPose(int frames, float startDeg = 70f)
{
var values = new float[frames];
for (var f = 0; f < frames; f++)
values[f] = startDeg + f;
return values;
}
// ---------------------------------------------------------------- trims
[Fact]
public void LeadingCalibrationRestFrame_IsTrimmed()
{
var angles = new float[10];
HeldPose(9).CopyTo(angles, 1); // frame 0 stays 0 = the rest pose; 1..9 held at ~70°
var scene = BvhImporter.Import(BvhWithHeadAngles(angles));
var clip = Assert.Single(scene.Clips);
Assert.Equal(9, clip.FrameCount); // 10 native − 1 calibration frame
Assert.InRange(HeadAngleDeg(scene, 0), 65f, 75f); // clip now opens on the real pose
}
[Fact]
public void TrailingCalibrationRestFrame_IsTrimmed()
{
var angles = new float[10];
HeldPose(9).CopyTo(angles, 0); // frames 0..8 held at ~70°; frame 9 back to rest
var scene = BvhImporter.Import(BvhWithHeadAngles(angles));
var clip = Assert.Single(scene.Clips);
Assert.Equal(9, clip.FrameCount);
Assert.InRange(HeadAngleDeg(scene, clip.FrameCount - 1), 65f, 85f); // ends on the pose
}
[Fact]
public void CalibrationFramesAtBothEnds_AreBothTrimmed()
{
var angles = new float[11];
HeldPose(9).CopyTo(angles, 1); // frame 0 AND frame 10 at rest, 1..9 held
var scene = BvhImporter.Import(BvhWithHeadAngles(angles));
var clip = Assert.Single(scene.Clips);
Assert.Equal(9, clip.FrameCount);
Assert.InRange(HeadAngleDeg(scene, 0), 65f, 75f);
Assert.InRange(HeadAngleDeg(scene, clip.FrameCount - 1), 65f, 85f);
}
/// <summary>A 2-frame rest→motion blend ramp (measured on a makehuman-retarget export:
/// rest 10°, ramp 24°, then motion ≥ 49°) is a calibration segment too.</summary>
[Fact]
public void CalibrationBlendRamp_IsTrimmed()
{
var angles = new float[12];
angles[1] = 12f; // frame 0 at rest, frame 1 mid-blend — both rest-like
HeldPose(10).CopyTo(angles, 2); // frames 2..11 held at ~70°
var scene = BvhImporter.Import(BvhWithHeadAngles(angles));
var clip = Assert.Single(scene.Clips);
Assert.Equal(10, clip.FrameCount); // both ramp frames trimmed
Assert.InRange(HeadAngleDeg(scene, 0), 65f, 75f);
}
// ---------------------------------------------------------------- must-NOT-trim guards
/// <summary>An idle that legitimately STARTS at the rest pose ramps continuously into
/// motion — rest-similarity alone must never cost it its first frame.</summary>
[Fact]
public void IdleStartingAtRest_KeepsItsFirstFrame()
{
var angles = new float[20];
for (var f = 0; f < angles.Length; f++)
angles[f] = f * 3f; // 3°/frame ramp from exact rest
var scene = BvhImporter.Import(BvhWithHeadAngles(angles));
var clip = Assert.Single(scene.Clips);
Assert.Equal(20, clip.FrameCount);
Assert.InRange(HeadAngleDeg(scene, 0), 0f, 0.01f);
}
/// <summary>A clip resting throughout (e.g. a bind-pose export) has nothing to trim:
/// the neighbor frame is rest-like too.</summary>
[Fact]
public void AllRestClip_IsNotTrimmed()
{
var scene = BvhImporter.Import(BvhWithHeadAngles(new float[8]));
var clip = Assert.Single(scene.Clips);
Assert.Equal(8, clip.FrameCount);
}
/// <summary>A hard cut between two NON-rest poses (action edit) is not a calibration
/// frame — the edge must be rest-like for the trim to engage.</summary>
[Fact]
public void HardCutBetweenRealPoses_IsNotTrimmed()
{
var angles = HeldPose(10, startDeg: 120f);
for (var f = 1; f < angles.Length; f++)
angles[f] = 60f + f; // frame 0 at 120°, cut to ~60° held
var scene = BvhImporter.Import(BvhWithHeadAngles(angles));
var clip = Assert.Single(scene.Clips);
Assert.Equal(10, clip.FrameCount);
}
/// <summary>Two-frame clips are never trimmed (no interior to judge typical motion by).</summary>
[Fact]
public void TwoFrameClip_IsNotTrimmed()
{
var scene = BvhImporter.Import(BvhWithHeadAngles(0f, 70f));
Assert.Equal(2, Assert.Single(scene.Clips).FrameCount);
}
// ---------------------------------------------------------------- real repro file
/// <summary>47_01.bvh (CMU mocap, the user repro): native frame 0 is a calibration
/// T-pose 21° from the bind with an 89.6° cut into frame 1 (measured); the imported
/// clip must open on the real motion instead. Silently green when the local corpus
/// file is absent.</summary>
[Fact]
public void Cmu4701_CalibrationTPose_DoesNotReachTheClip()
{
var path = TestUtil.RepoFile("dev", "corpus", "todo", "47_01.bvh");
if (!File.Exists(path))
return;
var scene = BvhImporter.Import(File.ReadAllBytes(path));
var clip = Assert.Single(scene.Clips);
// 1320 native frames at 120 fps → 1319 after the trim → 330 on the 30 fps grid.
Assert.Equal(330, clip.FrameCount);
// Frame 0 is now a real pose, far from the identity-rotation bind …
float MaxJointAngleDeg(int frame)
{
var max = 0f;
foreach (var x in clip.Frames[frame])
max = MathF.Max(max, MathQ.AngleBetween(Quaternion.Identity, x.Rot) * Rad2Deg);
return max;
}
Assert.True(MaxJointAngleDeg(0) > 60f,
$"clip still opens rest-like ({MaxJointAngleDeg(0):F1}° from bind) — calibration frame not trimmed");
// … and continuous into its neighbor (the old frame 0 cut 89.6° deep).
var maxDelta = 0f;
for (var i = 0; i < scene.Skeleton.Count; i++)
{
maxDelta = MathF.Max(maxDelta, MathQ.AngleBetween(
clip.Frames[0][i].Rot, clip.Frames[1][i].Rot) * Rad2Deg);
}
Assert.True(maxDelta < 30f, $"frame 0→1 still discontinuous ({maxDelta:F1}°)");
}
}
using System.Numerics;
using System.Text.Json;
using HumanoidRetargeter.Core.Formats.Fbx;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using Xunit;
using Skel = HumanoidRetargeter.Core.Skeleton.Skeleton;
namespace HumanoidRetargeter.Tests.Formats;
// =============================================================================================
// UNIT / SPACE RESOLUTION (determined empirically from research/extract_anim.py + the JSONs):
//
// The ground-truth JSONs (fixtures/anim_truth/*.json) were produced by Blender's FBX importer
// (automatic_bone_orientation=False) and mix TWO spaces:
//
// 1. `bones[]` rest data comes from `armature.data.bones[*].matrix_local`, which is in
// ARMATURE space. Blender's FBX importer does not bake the axis/unit conversion into the
// armature data — it stores it on the object (rotation_euler.x = +90 deg, scale = 0.01 *
// UnitScaleFactor). Armature space therefore equals the FBX file's native space and units
// (Y-up, centimeters for these fixtures, UnitScaleFactor = 1). Rest values compare to our
// imported skeleton DIRECTLY, no conversion.
//
// 2. `frames[]` come from `obj.matrix_world @ pose_bone.matrix`, which is in BLENDER WORLD
// space (Z-up, meters, the 0.01 object scale applied). To compare a truth world transform
// against our FBX-space result: p_fbx = Rx(-90deg) * wp / object_scale,
// q_fbx = Rx(-90deg) * wr (column-vector quaternion composition, parent on the left).
//
// Frame alignment: Blender places the first FBX key at action frame_start, and the truth was
// sampled on an fps grid from there; our importer samples from the earliest curve key on the
// same grid, so truth frame i == our clip frame i.
//
// Note: "Lead Jab.fbx" is NOT a Mixamo rig — it is a citizen-skeleton clip (84 s&box bones,
// pelvis/spine_0/...), which conveniently exercises a second rig family.
//
// TOLERANCES: the task gates were 0.5% of skeleton height / 1.0 deg (rest) and 0.7% / 1.0 deg
// (animation). Observed residuals are float-precision noise — max 0.0002 cm / 0.0004 deg over
// every bone of both rigs at all sampled frames — so the tolerances below are tightened to
// 0.25 cm / 0.25 deg absolute (~1000x above the observed residual, ~4x tighter than the gate).
// =============================================================================================
public class FbxImporterTests
{
private const float KDegPerRad = 180f / MathF.PI;
private const float PosTolCm = 0.25f;
private const float RotTolDeg = 0.25f;
private static byte[] Fixture(string name)
=> File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "fixtures", "fbx", name));
private static SourceScene ImportFixture(string name)
=> FbxImporter.Import(Fixture(name), new FbxImportOptions());
// ---- ground truth -----------------------------------------------------------------
private sealed record TruthBone(
string Name, string? Parent, Vector3 RestWorldPos, Quaternion RestWorldRot);
private sealed record TruthFrameBone(Vector3 WorldPos, Quaternion WorldRot);
private sealed class Truth
{
public required float SampleFps;
public required int NSamples;
public required float ObjectScale;
public required List<TruthBone> Bones;
public required List<TruthFrameBone[]> Frames;
/// <summary>Rest skeleton height (max-min world Y, FBX Y-up) in native units (cm).</summary>
public float Height()
{
float min = float.MaxValue, max = float.MinValue;
foreach (var b in Bones)
{
min = MathF.Min(min, b.RestWorldPos.Y);
max = MathF.Max(max, b.RestWorldPos.Y);
}
return max - min;
}
}
private static Truth LoadTruth(string name)
{
var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "anim_truth", name);
using var doc = JsonDocument.Parse(File.ReadAllBytes(path));
var r = doc.RootElement;
static Vector3 V3(JsonElement e) => new(
e[0].GetSingle(), e[1].GetSingle(), e[2].GetSingle());
static Quaternion Q(JsonElement e) => new(
e[0].GetSingle(), e[1].GetSingle(), e[2].GetSingle(), e[3].GetSingle());
var bones = new List<TruthBone>();
foreach (var b in r.GetProperty("bones").EnumerateArray())
{
bones.Add(new TruthBone(
b.GetProperty("name").GetString()!,
b.GetProperty("parent").ValueKind == JsonValueKind.Null
? null : b.GetProperty("parent").GetString(),
V3(b.GetProperty("rest_world_pos")),
Q(b.GetProperty("rest_world_rot_xyzw"))));
}
var frames = new List<TruthFrameBone[]>();
foreach (var f in r.GetProperty("frames").EnumerateArray())
{
var fb = new TruthFrameBone[bones.Count];
int i = 0;
foreach (var e in f.EnumerateArray())
fb[i++] = new TruthFrameBone(V3(e.GetProperty("wp")), Q(e.GetProperty("wr")));
frames.Add(fb);
}
return new Truth
{
SampleFps = r.GetProperty("sample_fps").GetSingle(),
NSamples = r.GetProperty("n_samples").GetInt32(),
ObjectScale = r.GetProperty("object_scale")[0].GetSingle(),
Bones = bones,
Frames = frames,
};
}
// Blender world -> FBX native space: undo object rotation (X +90 deg) and object scale.
private static readonly Quaternion BlenderToFbx =
Quaternion.CreateFromAxisAngle(Vector3.UnitX, -MathF.PI / 2f);
private static Vector3 FramePosToFbx(Vector3 wp, float objectScale)
=> Vector3.Transform(wp, BlenderToFbx) / objectScale;
private static Quaternion FrameRotToFbx(Quaternion wr)
=> BlenderToFbx * wr;
// ---- comparison helper -------------------------------------------------------------
/// <summary>
/// Compares world transforms of all common bones; fails with a per-bone diff dump of the
/// worst offenders. Returns the number of common bones compared.
/// </summary>
private static int AssertWorldsMatch(
Skel skeleton,
IReadOnlyList<XForm> worlds,
IEnumerable<(string Name, Vector3 Pos, Quaternion Rot)> truthWorlds,
float posTolCm,
float rotTolDeg,
string context)
{
var failures = new List<(string Bone, float PosErr, float RotErrDeg)>();
int common = 0;
float maxPos = 0f, maxRot = 0f;
foreach (var (name, tPos, tRot) in truthWorlds)
{
int idx = skeleton.IndexOf(name);
if (idx < 0)
continue;
common++;
float posErr = Vector3.Distance(worlds[idx].Pos, tPos);
float rotErr = MathQ.AngleBetween(worlds[idx].Rot, tRot) * KDegPerRad;
maxPos = MathF.Max(maxPos, posErr);
maxRot = MathF.Max(maxRot, rotErr);
if (posErr > posTolCm || rotErr > rotTolDeg)
failures.Add((name, posErr, rotErr));
}
if (failures.Count > 0)
{
var worst = failures
.OrderByDescending(f => MathF.Max(f.PosErr / posTolCm, f.RotErrDeg / rotTolDeg))
.Take(12)
.Select(f => $" {f.Bone}: posErr={f.PosErr:F3} cm (tol {posTolCm:F3}), rotErr={f.RotErrDeg:F3} deg (tol {rotTolDeg:F2})");
Assert.Fail(
$"{context}: {failures.Count}/{common} bones out of tolerance " +
$"(max posErr={maxPos:F3} cm, max rotErr={maxRot:F3} deg):\n" +
string.Join("\n", worst));
}
return common;
}
// ---- 1. Zombie Crawl: skeleton + clip basics ----------------------------------------
[Fact]
public void ZombieCrawl_SkeletonAndClipBasics()
{
var scene = ImportFixture("Zombie Crawl.fbx");
// Spot list of 10 well-known Mixamo bones (this export uses the "mixamorig1:" namespace).
string[] expected =
{
"mixamorig1:Hips", "mixamorig1:Spine", "mixamorig1:Spine1", "mixamorig1:Spine2",
"mixamorig1:Neck", "mixamorig1:Head", "mixamorig1:LeftUpLeg",
"mixamorig1:LeftHandIndex1", "mixamorig1:RightFoot", "mixamorig1:LeftShoulder",
};
foreach (var name in expected)
Assert.True(scene.Skeleton.IndexOf(name) >= 0, $"missing bone {name}");
Assert.Single(scene.Clips);
var clip = scene.Clips[0];
Assert.Equal(30f, clip.Fps);
Assert.InRange(clip.FrameCount, 154, 156);
Assert.Equal(1f, scene.UnitScaleCm, 3);
Assert.Equal(1, scene.UpAxis); // Y-up source, axes recorded but NOT converted
Assert.Equal(1, scene.UpAxisSign);
}
// ---- 2/3. Zombie Crawl vs Blender ground truth --------------------------------------
[Fact]
public void ZombieCrawl_RestPose_MatchesBlender()
{
var scene = ImportFixture("Zombie Crawl.fbx");
var truth = LoadTruth("zombie_crawl.json");
int common = AssertWorldsMatch(
scene.Skeleton,
scene.Skeleton.RestWorld,
truth.Bones.Select(b => (b.Name, b.RestWorldPos, b.RestWorldRot)),
PosTolCm, RotTolDeg, "rest pose");
Assert.True(common >= 60, $"only {common} common bones (truth has {truth.Bones.Count})");
}
[Fact]
public void ZombieCrawl_Animation_MatchesBlender()
{
var scene = ImportFixture("Zombie Crawl.fbx");
var truth = LoadTruth("zombie_crawl.json");
var clip = scene.Clips[0];
Assert.InRange(clip.FrameCount, truth.NSamples - 1, truth.NSamples + 1);
foreach (int f in new[] { 0, 38, 77, 116, 154 })
{
var worlds = new Pose(clip.Frames[Math.Min(f, clip.FrameCount - 1)]).ToWorld(scene.Skeleton);
var truthFrame = truth.Frames[f];
int common = AssertWorldsMatch(
scene.Skeleton,
worlds,
truth.Bones.Select((b, i) => (
b.Name,
FramePosToFbx(truthFrame[i].WorldPos, truth.ObjectScale),
FrameRotToFbx(truthFrame[i].WorldRot))),
PosTolCm, RotTolDeg, $"frame {f}");
Assert.True(common >= 60, $"frame {f}: only {common} common bones");
}
}
// ---- 4. Lead Jab (citizen-skeleton clip) vs Blender ground truth ---------------------
[Fact]
public void LeadJab_RestPose_MatchesBlender()
{
var scene = ImportFixture("Lead Jab.fbx");
var truth = LoadTruth("lead_jab.json");
int common = AssertWorldsMatch(
scene.Skeleton,
scene.Skeleton.RestWorld,
truth.Bones.Select(b => (b.Name, b.RestWorldPos, b.RestWorldRot)),
PosTolCm, RotTolDeg, "rest pose");
Assert.True(common >= 75, $"only {common} common bones (truth has {truth.Bones.Count})");
}
[Fact]
public void LeadJab_Animation_MatchesBlender()
{
var scene = ImportFixture("Lead Jab.fbx");
var truth = LoadTruth("lead_jab.json");
Assert.NotEmpty(scene.Clips);
var clip = scene.Clips[0];
Assert.InRange(clip.FrameCount, truth.NSamples - 1, truth.NSamples + 1);
foreach (int f in new[] { 0, 27, 54 })
{
var worlds = new Pose(clip.Frames[Math.Min(f, clip.FrameCount - 1)]).ToWorld(scene.Skeleton);
var truthFrame = truth.Frames[f];
int common = AssertWorldsMatch(
scene.Skeleton,
worlds,
truth.Bones.Select((b, i) => (
b.Name,
FramePosToFbx(truthFrame[i].WorldPos, truth.ObjectScale),
FrameRotToFbx(truthFrame[i].WorldRot))),
PosTolCm, RotTolDeg, $"frame {f}");
Assert.True(common >= 75, $"frame {f}: only {common} common bones");
}
}
// ---- 5. Edge fixtures: parse without throwing, non-empty skeleton --------------------
[Theory]
[InlineData("prod_fix7400.fbx")]
[InlineData("mut_strip-prerotation.fbx")]
[InlineData("gen_inherit.fbx")]
public void EdgeFixture_ImportsWithNonEmptySkeleton(string name)
{
var scene = ImportFixture(name);
Assert.True(scene.Skeleton.Count > 0, "skeleton is empty");
}
// ---- 6. ActorCore -------------------------------------------------------------------
[Fact]
public void ActorCore_CatwalkLoop_ImportsFiniteSkeletonAndClip()
{
var scene = ImportFixture("catwalk-loop-378982.fbx");
Assert.True(scene.Skeleton.IndexOf("CC_Base_Hip") >= 0, "missing CC_Base_Hip");
Assert.True(scene.Skeleton.IndexOf("CC_Base_L_Thigh") >= 0, "missing CC_Base_L_Thigh");
Assert.NotEmpty(scene.Clips);
Assert.True(scene.Clips[0].FrameCount > 1, "clip has <= 1 frame");
static void AssertFinite(XForm x, string what)
{
Assert.True(
float.IsFinite(x.Pos.X) && float.IsFinite(x.Pos.Y) && float.IsFinite(x.Pos.Z) &&
float.IsFinite(x.Rot.X) && float.IsFinite(x.Rot.Y) && float.IsFinite(x.Rot.Z) &&
float.IsFinite(x.Rot.W),
$"non-finite transform in {what}: {x}");
}
for (int i = 0; i < scene.Skeleton.Count; i++)
{
AssertFinite(scene.Skeleton[i].RestLocal, $"rest local {scene.Skeleton[i].Name}");
AssertFinite(scene.Skeleton.RestWorld[i], $"rest world {scene.Skeleton[i].Name}");
}
foreach (var clip in scene.Clips)
for (int f = 0; f < clip.FrameCount; f++)
foreach (var x in clip.Frames[f])
AssertFinite(x, $"clip {clip.Name} frame {f}");
}
// ---- 7. UE Mannequin: static translation channels override bind rest geometry --------
//
// The UE animation FBX files carry rest data (BindPose/Lcl defaults) whose foot->ball
// local offset disagrees with the clip's STATIC ball translation channels by ~7.9 deg on
// both feet, while every other bone pair agrees to 0.000 deg (dev/verification/RESULTS.md
// root-cause analysis). The animation plays the static channel value, so the rest must use
// it — otherwise the canonical foot->ball direction is constantly ~7.9 deg off in output.
[Theory]
[InlineData("foot_l", "ball_l")]
[InlineData("foot_r", "ball_r")]
public void UeMannequin_StaticTranslationChannel_OverridesBindRest(string foot, string ball)
{
var scene = ImportFixture("ThirdPersonWalk.FBX");
var skel = scene.Skeleton;
int ballIdx = skel.IndexOf(ball);
Assert.True(ballIdx >= 0, $"missing bone {ball}");
Assert.Equal(skel.IndexOf(foot), skel[ballIdx].ParentIndex);
Assert.NotEmpty(scene.Clips);
var frame0 = scene.Clips[0].Frames[0];
// The ball's translation channels are static, so its sampled frame-0 local translation
// IS the channel value: the rest local must match it in direction (foot->ball, both
// vectors live in the foot frame; ~7.94 deg apart before the fix) and in value.
var restPos = skel[ballIdx].RestLocal.Pos;
var animPos = frame0[ballIdx].Pos;
float angleDeg = MathF.Acos(Math.Clamp(
Vector3.Dot(Vector3.Normalize(restPos), Vector3.Normalize(animPos)),
-1f, 1f)) * KDegPerRad;
Assert.True(angleDeg < 0.5f,
$"rest foot->ball direction is {angleDeg:F3} deg away from the animation's static geometry");
Assert.True(Vector3.Distance(restPos, animPos) < 0.01f,
$"rest local translation {restPos} != static channel value {animPos}");
}
[Fact]
public void UeMannequin_SingleStack_ProducesNoImportNotes()
{
// One animation stack: there is nothing to disagree with, so the multi-stack
// static-translation detection must stay silent.
var scene = ImportFixture("ThirdPersonWalk.FBX");
Assert.Empty(scene.Notes);
}
[Theory]
[InlineData("foot_l", "ball_l")]
[InlineData("foot_r", "ball_r")]
public void UeMannequin_StaticOverride_EqualsFrame0SampledLocalTranslation(string foot, string ball)
{
// The invariant the override formula must satisfy regardless of ancestor scale:
// a bone whose translation channels are static plays exactly one local translation,
// so the rest local must equal the frame-0 sampled local translation.
var scene = ImportFixture("ThirdPersonWalk.FBX");
var skel = scene.Skeleton;
int ballIdx = skel.IndexOf(ball);
Assert.True(ballIdx >= 0, $"missing bone {ball}");
Assert.Equal(skel.IndexOf(foot), skel[ballIdx].ParentIndex);
var restPos = skel[ballIdx].RestLocal.Pos;
var frame0Pos = scene.Clips[0].Frames[0][ballIdx].Pos;
Assert.True(Vector3.Distance(restPos, frame0Pos) < 0.005f,
$"rest local {restPos} != frame-0 sampled local {frame0Pos}");
}
[Fact]
public void UeMannequin_VaryingTranslationChannels_DoNotTouchRest()
{
var scene = ImportFixture("ThirdPersonWalk.FBX");
var skel = scene.Skeleton;
int pelvis = skel.IndexOf("pelvis");
Assert.True(pelvis >= 0, "missing pelvis");
// The pelvis carries the walk trajectory (VARYING translation channels): its rest must
// stay bind-derived — pinned at the UE Mannequin bind height (Z-up, cm) — and must NOT
// equal the mid-stride frame-0 trajectory sample.
Assert.InRange(skel.RestWorld[pelvis].Pos.Z, 96.7f, 96.8f);
var frame0 = scene.Clips[0].Frames[0];
Assert.NotEqual(frame0[pelvis].Pos, skel[pelvis].RestLocal.Pos);
}
}
using HumanoidRetargeter.Core.Formats;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using Xunit;
namespace HumanoidRetargeter.Tests.Formats;
/// <summary>
/// Unity <c>.fbx.meta</c> sidecar support: the <see cref="UnityMeta.ParseClipAnimations"/>
/// line parser over the clipAnimations YAML subset, and the <see cref="UnityMeta.Slice"/>
/// native-frame → resampled-grid range mapping. The fixture
/// (fixtures/unity/synthetic_stance.fbx.meta) is SYNTHETIC, replicating the structure Unity
/// 2020+ writes (ModelImporter serializedVersion 20300, clipAnimations serializedVersion 16).
/// </summary>
public class UnityMetaTests
{
private static string FixturePath(params string[] parts)
=> Path.Combine(new[] { AppContext.BaseDirectory, "fixtures" }.Concat(parts).ToArray());
private static string FixtureText()
=> File.ReadAllText(FixturePath("unity", "synthetic_stance.fbx.meta"));
// ---------------------------------------------------------------- parser
[Fact]
public void Parse_SyntheticUnityMeta_YieldsBothDefinitions()
{
var defs = UnityMeta.ParseClipAnimations(FixtureText());
Assert.Equal(2, defs.Count);
Assert.Equal("Idle", defs[0].Name);
Assert.Equal("root|Animation", defs[0].TakeName);
Assert.Equal(0f, defs[0].FirstFrame);
Assert.Equal(20f, defs[0].LastFrame);
Assert.True(defs[0].Loop); // loopTime: 1 (NOT the unrelated "loop: 0" field)
// Quoted name unwrapped; fractional frames parsed.
Assert.Equal("Idle Short", defs[1].Name);
Assert.Equal("root|Animation", defs[1].TakeName);
Assert.Equal(5.5f, defs[1].FirstFrame);
Assert.Equal(6.5f, defs[1].LastFrame);
Assert.False(defs[1].Loop); // loopTime: 0
}
[Fact]
public void Parse_NestedEventListEntries_DoNotBleedIntoDefinitions()
{
// The first item carries an events list whose entries are themselves "- key: value"
// lines at deeper indentation — they must neither start new definitions nor
// overwrite the item's own fields.
var defs = UnityMeta.ParseClipAnimations(FixtureText());
Assert.Equal(2, defs.Count);
Assert.Equal("Idle", defs[0].Name);
}
[Fact]
public void Parse_InlineEmptyList_YieldsNoDefinitions()
=> Assert.Empty(UnityMeta.ParseClipAnimations(
"ModelImporter:\n animations:\n clipAnimations: []\n isReadable: 0\n"));
[Fact]
public void Parse_NoClipAnimationsKey_YieldsNoDefinitions()
=> Assert.Empty(UnityMeta.ParseClipAnimations(
"fileFormatVersion: 2\nguid: abc\nTextureImporter:\n mipmaps: 1\n"));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("complete garbage \0 {{{{ ---")]
[InlineData("clipAnimations:")]
[InlineData("clipAnimations:\n- name: x")] // dash at indent 0 < key indent is malformed → tolerated
public void Parse_DegenerateInput_NeverThrows(string? text)
{
var defs = UnityMeta.ParseClipAnimations(text);
Assert.NotNull(defs);
}
[Fact]
public void Parse_MissingFields_KeepDefaults()
{
var defs = UnityMeta.ParseClipAnimations(
" clipAnimations:\n - name: OnlyName\n");
var def = Assert.Single(defs);
Assert.Equal("OnlyName", def.Name);
Assert.Equal("", def.TakeName);
Assert.Equal(0f, def.FirstFrame);
Assert.True(float.IsPositiveInfinity(def.LastFrame)); // "to the end of the take"
Assert.False(def.Loop);
}
[Fact]
public void Parse_ListEndsAtSiblingKey()
{
var defs = UnityMeta.ParseClipAnimations(
" clipAnimations:\n - name: A\n lastFrame: 3\n isReadable: 0\n - name: NotAClip\n");
var def = Assert.Single(defs);
Assert.Equal("A", def.Name);
}
[Fact]
public void Parse_NoiseOnlyItems_AreDropped()
{
var defs = UnityMeta.ParseClipAnimations(
" clipAnimations:\n - serializedVersion: 16\n wrapMode: 0\n - name: Real\n lastFrame: 2\n");
var def = Assert.Single(defs);
Assert.Equal("Real", def.Name);
}
// ---------------------------------------------------------------- slicing math
/// <summary>Clip whose frame f carries Pos.X = f on its single bone — slice contents
/// are then directly checkable.</summary>
private static Clip IndexClip(int frameCount, float fps, float nativeFps)
{
var frames = new List<XForm[]>(frameCount);
for (var f = 0; f < frameCount; f++)
frames.Add(new[] { new XForm(new System.Numerics.Vector3(f, 0, 0), System.Numerics.Quaternion.Identity) });
return new Clip("take", fps, looping: false, frames, nativeFps);
}
private static ExternalClipDef Def(float first, float last, bool loop = false, string name = "part")
=> new() { Name = name, FirstFrame = first, LastFrame = last, Loop = loop };
[Fact]
public void Slice_SameNativeAndSampleRate_UsesFrameIndicesDirectly()
{
var source = IndexClip(44, fps: 30f, nativeFps: 30f);
var full = UnityMeta.Slice(source, Def(0, 43));
Assert.Equal(44, full.FrameCount);
var pose = UnityMeta.Slice(source, Def(0, 1));
Assert.Equal(2, pose.FrameCount);
Assert.Equal(0f, pose.Frames[0][0].Pos.X);
Assert.Equal(1f, pose.Frames[1][0].Pos.X);
}
[Fact]
public void Slice_RescalesNativeFramesOntoSampleGrid()
{
// 24 fps native take of 54 native intervals resampled at 30 fps:
// round(54/24*30)+1 = 69 samples. Native frame n maps to sample round(n*30/24).
var source = IndexClip(69, fps: 30f, nativeFps: 24f);
var sliced = UnityMeta.Slice(source, Def(8, 43));
Assert.Equal(10f, sliced.Frames[0][0].Pos.X); // round(8 * 1.25) = 10
Assert.Equal(54f, sliced.Frames[^1][0].Pos.X); // round(43 * 1.25) = 54 (53.75 rounds up)
Assert.Equal(45, sliced.FrameCount);
}
[Fact]
public void Slice_ClampsRangeIntoClip()
{
var source = IndexClip(44, fps: 30f, nativeFps: 30f);
var past = UnityMeta.Slice(source, Def(100, 999));
Assert.Equal(1, past.FrameCount);
Assert.Equal(43f, past.Frames[0][0].Pos.X);
var overrun = UnityMeta.Slice(source, Def(40, 999));
Assert.Equal(4, overrun.FrameCount);
Assert.Equal(43f, overrun.Frames[^1][0].Pos.X);
}
[Fact]
public void Slice_SingleFrameRange_YieldsOneFrame()
{
var source = IndexClip(44, fps: 30f, nativeFps: 30f);
var sliced = UnityMeta.Slice(source, Def(5, 5));
Assert.Equal(1, sliced.FrameCount);
Assert.Equal(5f, sliced.Frames[0][0].Pos.X);
}
[Fact]
public void Slice_InvertedRange_CollapsesToFirstFrame()
{
var source = IndexClip(44, fps: 30f, nativeFps: 30f);
var sliced = UnityMeta.Slice(source, Def(10, 2));
Assert.Equal(1, sliced.FrameCount);
Assert.Equal(10f, sliced.Frames[0][0].Pos.X);
}
[Fact]
public void Slice_MissingLastFrame_RunsToTheEnd()
{
var source = IndexClip(44, fps: 30f, nativeFps: 30f);
var sliced = UnityMeta.Slice(source, new ExternalClipDef { Name = "tail", FirstFrame = 30 });
Assert.Equal(14, sliced.FrameCount);
Assert.Equal(43f, sliced.Frames[^1][0].Pos.X);
}
[Fact]
public void Slice_CarriesNameLoopAndRates()
{
var source = IndexClip(44, fps: 30f, nativeFps: 24f);
var sliced = UnityMeta.Slice(source, Def(0, 5, loop: true, name: "walk cycle"));
Assert.Equal("walk cycle", sliced.Name); // sanitization happens pipeline-side
Assert.True(sliced.Looping);
Assert.Equal(30f, sliced.Fps);
Assert.Equal(24f, sliced.NativeFps);
var named = UnityMeta.Slice(source, Def(0, 5), "explicit");
Assert.Equal("explicit", named.Name);
}
}
using HumanoidRetargeter.Core.Mapping;
using Xunit;
namespace HumanoidRetargeter.Tests.Mapping;
public class ProfileTests
{
private static readonly BoneRole[] FingerProxMidDistBothSides = BuildFingerRoles();
private static BoneRole[] BuildFingerRoles()
{
var fingers = new[] { "Thumb", "Index", "Middle", "Ring", "Pinky" };
var segments = new[] { "Prox", "Mid", "Dist" };
var sides = new[] { "L", "R" };
return (
from finger in fingers
from segment in segments
from side in sides
select Enum.Parse<BoneRole>(finger + segment + side)).ToArray();
}
// ---------------------------------------------------------------- detection: mixamo
[Fact]
public void Detect_ZombieCrawl_PicksMixamoWithHighConfidence()
{
var skeleton = MappingFixtures.LoadZombieCrawl();
var detected = ProfileDetector.Detect(skeleton);
Assert.NotNull(detected);
var (profile, result) = detected.Value;
Assert.Equal("mixamo", profile.Name);
Assert.Equal("mixamo", result.ProfileName);
Assert.Equal(MappingSource.Preset, result.Source);
Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
Assert.True(result.RoleToBone.Count >= 20, $"Only {result.RoleToBone.Count} roles mapped");
AssertRole(skeleton, result, BoneRole.Hips, "mixamorig1:Hips");
AssertRole(skeleton, result, BoneRole.Spine0, "mixamorig1:Spine");
AssertRole(skeleton, result, BoneRole.Spine1, "mixamorig1:Spine1");
AssertRole(skeleton, result, BoneRole.Spine2, "mixamorig1:Spine2");
AssertRole(skeleton, result, BoneRole.Neck, "mixamorig1:Neck");
AssertRole(skeleton, result, BoneRole.Head, "mixamorig1:Head");
AssertRole(skeleton, result, BoneRole.ClavicleL, "mixamorig1:LeftShoulder");
AssertRole(skeleton, result, BoneRole.UpperArmL, "mixamorig1:LeftArm");
AssertRole(skeleton, result, BoneRole.LowerArmL, "mixamorig1:LeftForeArm");
AssertRole(skeleton, result, BoneRole.HandL, "mixamorig1:LeftHand");
AssertRole(skeleton, result, BoneRole.IndexProxL, "mixamorig1:LeftHandIndex1");
AssertRole(skeleton, result, BoneRole.MiddleDistR, "mixamorig1:RightHandMiddle3");
AssertRole(skeleton, result, BoneRole.UpperLegL, "mixamorig1:LeftUpLeg");
AssertRole(skeleton, result, BoneRole.LowerLegR, "mixamorig1:RightLeg");
AssertRole(skeleton, result, BoneRole.FootL, "mixamorig1:LeftFoot");
AssertRole(skeleton, result, BoneRole.ToeR, "mixamorig1:RightToeBase");
// All 5 finger chains, both sides, all three phalanges.
foreach (var role in FingerProxMidDistBothSides)
Assert.True(result.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");
}
// ---------------------------------------------------------------- detection: actorcore
[Fact]
public void Detect_ActorCore_PicksActorCorePreset()
{
var skeleton = MappingFixtures.LoadActorCore();
var detected = ProfileDetector.Detect(skeleton);
Assert.NotNull(detected);
var (profile, result) = detected.Value;
Assert.Equal("actorcore_cc", profile.Name);
Assert.Equal(MappingSource.Preset, result.Source);
Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
Assert.True(result.RoleToBone.Count >= 20, $"Only {result.RoleToBone.Count} roles mapped");
// Empirical hips resolution: CC_Base_Hip is the parent of both CC_Base_Pelvis (leg
// branch) and CC_Base_Waist (spine branch) — it is the LCA of legs+spine and the
// translating root, so it carries the Hips role; CC_Base_Pelvis stays unmapped.
AssertRole(skeleton, result, BoneRole.Hips, "CC_Base_Hip");
AssertRole(skeleton, result, BoneRole.Spine0, "CC_Base_Waist");
AssertRole(skeleton, result, BoneRole.Spine1, "CC_Base_Spine01");
AssertRole(skeleton, result, BoneRole.Spine2, "CC_Base_Spine02");
AssertRole(skeleton, result, BoneRole.Neck, "CC_Base_NeckTwist01");
AssertRole(skeleton, result, BoneRole.Head, "CC_Base_Head");
AssertRole(skeleton, result, BoneRole.ClavicleR, "CC_Base_R_Clavicle");
AssertRole(skeleton, result, BoneRole.UpperArmL, "CC_Base_L_Upperarm");
AssertRole(skeleton, result, BoneRole.LowerArmR, "CC_Base_R_Forearm");
AssertRole(skeleton, result, BoneRole.HandL, "CC_Base_L_Hand");
AssertRole(skeleton, result, BoneRole.ThumbProxL, "CC_Base_L_Thumb1");
AssertRole(skeleton, result, BoneRole.MiddleMidR, "CC_Base_R_Mid2");
AssertRole(skeleton, result, BoneRole.PinkyDistL, "CC_Base_L_Pinky3");
AssertRole(skeleton, result, BoneRole.UpperLegL, "CC_Base_L_Thigh");
AssertRole(skeleton, result, BoneRole.LowerLegR, "CC_Base_R_Calf");
AssertRole(skeleton, result, BoneRole.FootL, "CC_Base_L_Foot");
AssertRole(skeleton, result, BoneRole.ToeL, "CC_Base_L_ToeBase");
foreach (var role in FingerProxMidDistBothSides)
Assert.True(result.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");
// ShareBone helpers and limb twist bones must never be mapped. The single allowed
// "Twist" name is CC_Base_NeckTwist01, which IS the rig's neck bone.
foreach (var (role, boneIndex) in result.RoleToBone)
{
var name = skeleton[boneIndex].Name;
Assert.False(name.Contains("ShareBone"), $"{role} mapped to share bone {name}");
if (name.Contains("Twist"))
Assert.True(role == BoneRole.Neck && name == "CC_Base_NeckTwist01",
$"{role} mapped to twist bone {name}");
}
}
// ---------------------------------------------------------------- detection: UE
[Fact]
public void Detect_SyntheticUeMannequin_PicksUePreset()
{
var skeleton = MappingFixtures.BuildUeMannequin();
var detected = ProfileDetector.Detect(skeleton);
Assert.NotNull(detected);
var (profile, result) = detected.Value;
Assert.Equal("ue_mannequin", profile.Name);
Assert.True(result.Confidence >= 0.8f, $"Confidence {result.Confidence} < 0.8");
AssertRole(skeleton, result, BoneRole.Hips, "pelvis");
AssertRole(skeleton, result, BoneRole.Spine0, "spine_01");
AssertRole(skeleton, result, BoneRole.Spine4, "spine_05");
AssertRole(skeleton, result, BoneRole.Neck, "neck_01");
AssertRole(skeleton, result, BoneRole.Head, "head");
AssertRole(skeleton, result, BoneRole.ClavicleL, "clavicle_l");
AssertRole(skeleton, result, BoneRole.UpperArmR, "upperarm_r");
AssertRole(skeleton, result, BoneRole.LowerArmL, "lowerarm_l");
AssertRole(skeleton, result, BoneRole.HandR, "hand_r");
AssertRole(skeleton, result, BoneRole.ThumbProxL, "thumb_01_l");
AssertRole(skeleton, result, BoneRole.IndexMetaL, "index_metacarpal_l");
AssertRole(skeleton, result, BoneRole.RingDistR, "ring_03_r");
AssertRole(skeleton, result, BoneRole.UpperLegL, "thigh_l");
AssertRole(skeleton, result, BoneRole.LowerLegR, "calf_r");
AssertRole(skeleton, result, BoneRole.FootL, "foot_l");
AssertRole(skeleton, result, BoneRole.ToeL, "ball_l");
// *_twist_* bones are never mapped.
foreach (var (role, boneIndex) in result.RoleToBone)
Assert.DoesNotContain("_twist_", skeleton[boneIndex].Name);
}
// ---------------------------------------------------------------- detection: rokoko/bvh
[Fact]
public void Detect_SyntheticRokokoBvhNames_PicksRokokoPreset()
{
var bones = new List<(string, string?, System.Numerics.Vector3)>
{
("Hips", null, new(0, 98, 0)),
("Spine", "Hips", new(0, 108, 0)),
("Spine1", "Spine", new(0, 120, 0)),
("Spine2", "Spine1", new(0, 132, 0)),
("Spine3", "Spine2", new(0, 142, 0)),
("Neck", "Spine3", new(0, 150, 0)),
("Head", "Neck", new(0, 158, 0)),
};
foreach (var (side, x) in new[] { ("Left", 1f), ("Right", -1f) })
{
bones.Add(($"{side}Shoulder", "Spine3", new(5 * x, 146, 0)));
bones.Add(($"{side}Arm", $"{side}Shoulder", new(18 * x, 144, 0)));
bones.Add(($"{side}ForeArm", $"{side}Arm", new(44 * x, 144, 0)));
bones.Add(($"{side}Hand", $"{side}ForeArm", new(66 * x, 144, 0)));
bones.Add(($"{side}UpLeg", "Hips", new(9 * x, 92, 0)));
bones.Add(($"{side}Leg", $"{side}UpLeg", new(9 * x, 50, 0)));
bones.Add(($"{side}Foot", $"{side}Leg", new(9 * x, 9, 0)));
bones.Add(($"{side}Toe", $"{side}Foot", new(9 * x, 2, 12)));
}
var skeleton = MappingFixtures.FromWorldPositions(bones);
var detected = ProfileDetector.Detect(skeleton);
Assert.NotNull(detected);
var (profile, result) = detected.Value;
Assert.Equal("rokoko_bvh", profile.Name);
AssertRole(skeleton, result, BoneRole.Hips, "Hips");
AssertRole(skeleton, result, BoneRole.Spine0, "Spine");
AssertRole(skeleton, result, BoneRole.Spine3, "Spine3");
AssertRole(skeleton, result, BoneRole.UpperArmL, "LeftArm");
AssertRole(skeleton, result, BoneRole.LowerLegR, "RightLeg");
AssertRole(skeleton, result, BoneRole.ToeL, "LeftToe");
}
// ---------------------------------------------------------------- scoring honesty
[Fact]
public void Score_GenericCoreNamesOnly_NoPresetReachesThreshold()
{
// Only the generic center names (Hips/Spine1/Spine2/Neck/Head) match any preset;
// every limb is named outside all alias families. Rich center matches alone must
// never carry a preset over the detection threshold.
var bones = new List<(string, string?, System.Numerics.Vector3)>
{
("Hips", null, new(0, 98, 0)),
("Spine1", "Hips", new(0, 110, 0)),
("Spine2", "Spine1", new(0, 124, 0)),
("Neck", "Spine2", new(0, 148, 0)),
("Head", "Neck", new(0, 156, 0)),
};
foreach (var (side, x) in new[] { ("A", 1f), ("B", -1f) })
{
bones.Add(($"limb_{side}_1", "Spine2", new(18 * x, 144, 0)));
bones.Add(($"limb_{side}_2", $"limb_{side}_1", new(44 * x, 144, 0)));
bones.Add(($"limb_{side}_3", $"limb_{side}_2", new(66 * x, 144, 0)));
bones.Add(($"limb_{side}_4", "Hips", new(9 * x, 92, 0)));
bones.Add(($"limb_{side}_5", $"limb_{side}_4", new(9 * x, 50, 0)));
bones.Add(($"limb_{side}_6", $"limb_{side}_5", new(9 * x, 9, 0)));
}
var skeleton = MappingFixtures.FromWorldPositions(bones);
foreach (var profile in ProfileLibrary.All)
{
var score = ProfileDetector.Score(profile, skeleton);
Assert.True(score < ProfileDetector.DetectionThreshold,
$"{profile.Name} scored {score} on a rig with no matching limb names");
}
}
// ---------------------------------------------------------------- detection: negative
[Fact]
public void Detect_UnknownNames_ReturnsNull()
{
var skeleton = MappingFixtures.RenameAll(MappingFixtures.LoadZombieCrawl(), i => $"bone_{i:000}");
Assert.Null(ProfileDetector.Detect(skeleton));
}
[Fact]
public void Score_MixamoProfileOnZombieCrawl_BeatsOtherPresets()
{
var skeleton = MappingFixtures.LoadZombieCrawl();
var mixamo = ProfileDetector.Score(ProfileLibrary.Mixamo, skeleton);
Assert.True(mixamo >= 0.9f);
Assert.True(mixamo > ProfileDetector.Score(ProfileLibrary.ActorCoreCc, skeleton));
Assert.True(mixamo > ProfileDetector.Score(ProfileLibrary.UeMannequin, skeleton));
Assert.True(mixamo > ProfileDetector.Score(ProfileLibrary.RokokoBvh, skeleton));
}
// ---------------------------------------------------------------- json (de)serialization
[Fact]
public void Profile_JsonRoundTrips()
{
foreach (var profile in ProfileLibrary.All)
{
var json = profile.ToJson();
var restored = Profile.FromJson(json);
Assert.Equal(profile.Name, restored.Name);
Assert.Equal(profile.NamespacePatterns, restored.NamespacePatterns);
Assert.Equal(profile.Aliases.Count, restored.Aliases.Count);
foreach (var (role, aliases) in profile.Aliases)
Assert.Equal(aliases, restored.Aliases[role]);
}
}
[Fact]
public void Profile_UserPresetShape_V1RoundTrips()
{
// The exact document shape the editor's UserPresets.Save produces: name
// "user_<sig8>", no namespace patterns, ONE alias per role - the literal source
// bone name. This is the v1 user-preset contract; FromJson must keep reading it.
var skeleton = MappingFixtures.LoadZombieCrawl();
var aliases = new Dictionary<BoneRole, string[]>
{
[BoneRole.Hips] = new[] { "mixamorig1:Hips" },
[BoneRole.Spine0] = new[] { "mixamorig1:Spine" },
[BoneRole.Head] = new[] { "mixamorig1:Head" },
[BoneRole.HandL] = new[] { "mixamorig1:LeftHand" },
[BoneRole.PinkyDistR] = new[] { "mixamorig1:RightHandPinky3" },
};
var profile = new Profile("user_a1b2c3d4", Array.Empty<string>(), aliases);
var json = profile.ToJson();
Assert.Contains("\"v\": 1", json);
var restored = Profile.FromJson(json);
Assert.Equal(profile.Name, restored.Name);
Assert.Empty(restored.NamespacePatterns);
Assert.Equal(aliases.Count, restored.Aliases.Count);
foreach (var (role, names) in aliases)
Assert.Equal(names, restored.Aliases[role]);
// And the restored preset actually resolves on the rig it was saved from.
var applied = ProfileDetector.Apply(restored, skeleton);
foreach (var (role, names) in aliases)
Assert.Equal(names[0], skeleton[applied.RoleToBone[role]].Name);
}
[Fact]
public void PresetAssetsFolder_HasNoOrphanProfiles()
{
// Every committed (non-user) profile JSON must correspond to a shipped preset -
// an orphan file would silently diverge from ProfileLibrary.
var dir = FindRepoFile(Path.Combine("Assets", "data", "humanoid_retargeter", "profiles"));
Assert.True(Directory.Exists(dir), $"profiles folder missing: {dir}");
var known = ProfileLibrary.All.Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
var orphans = Directory.GetFiles(dir, "*.json", SearchOption.TopDirectoryOnly)
.Select(Path.GetFileNameWithoutExtension)
.Where(name => !known.Contains(name!))
.ToList();
Assert.True(orphans.Count == 0,
$"Orphan profile JSONs without a ProfileLibrary preset: {string.Join(", ", orphans)}");
}
[Fact]
public void Profile_FromJson_RejectsUnknownSchemaVersion()
{
var json = """{"v": 99, "name": "x", "namespace_patterns": [], "aliases": {}}""";
Assert.Throws<ArgumentException>(() => Profile.FromJson(json));
}
[Fact]
public void Profile_FromJson_RejectsUnknownRole()
{
var json = """{"v": 1, "name": "x", "namespace_patterns": [], "aliases": {"NotARole": ["a"]}}""";
Assert.Throws<ArgumentException>(() => Profile.FromJson(json));
}
// ---------------------------------------------------------------- preset asset regeneration
[Fact]
public void PresetJsons_MatchCommittedAssets()
{
foreach (var profile in ProfileLibrary.All)
{
var generated = profile.ToJson();
var assetPath = FindRepoFile(Path.Combine(
"Assets", "data", "humanoid_retargeter", "profiles", profile.Name + ".json"));
if (!File.Exists(assetPath))
{
// Regenerate-and-diff pattern: first run writes the committed artifact.
Directory.CreateDirectory(Path.GetDirectoryName(assetPath)!);
File.WriteAllText(assetPath, generated);
}
var committed = File.ReadAllText(assetPath);
Assert.Equal(Normalize(committed), Normalize(generated));
}
}
[Fact]
public void PresetLibrary_HasExpectedProfiles()
{
Assert.Equal(
new[]
{
"sbox", "mixamo", "actorcore_cc", "ue_mannequin", "xsens_mvn", "perception_neuron",
"fight_night", "rokoko_bvh", "smpl_x", "smpl", "soma_bvh", "classic_bvh",
"valvebiped", "biped", "daz_genesis", "daz_poser",
"rigify", "vrm", "auto_rig_pro", "advanced_skeleton",
},
ProfileLibrary.All.Select(p => p.Name).ToArray());
}
// ---------------------------------------------------------------- detection: fight night
/// <summary>
/// The EA Fight Night boxer rig uses MotionBuilder/HumanIK joint names, so mixamo and
/// perception_neuron both score respectably on it — the dedicated preset must win
/// outright by covering the fourth spine bone, the toes AND declaring only the finger
/// roles a gloved hand actually has.
/// </summary>
[Fact]
public void Detect_SyntheticFightNight_PicksFightNightPreset()
{
var skeleton = MappingFixtures.BuildFightNight();
var detected = ProfileDetector.Detect(skeleton);
Assert.NotNull(detected);
var (profile, result) = detected.Value;
Assert.Equal("fight_night", profile.Name);
Assert.True(result.Confidence >= 0.99f, $"Confidence {result.Confidence} < 0.99");
// It must beat the HumanIK-family presets that also match these names.
var fightNight = ProfileDetector.Score(ProfileLibrary.FightNight, skeleton);
Assert.True(fightNight > ProfileDetector.Score(ProfileLibrary.Mixamo, skeleton),
"fight_night must outscore mixamo on a boxer rig");
Assert.True(fightNight > ProfileDetector.Score(ProfileLibrary.PerceptionNeuron, skeleton),
"fight_night must outscore perception_neuron on a boxer rig");
AssertRole(skeleton, result, BoneRole.Hips, "Hips");
AssertRole(skeleton, result, BoneRole.Spine0, "Spine");
AssertRole(skeleton, result, BoneRole.Spine3, "Spine3");
Assert.False(result.RoleToBone.ContainsKey(BoneRole.Spine4), "Spine4 role double-mapped");
// Neck→Neck1→Head: the second neck bone stays unmapped.
AssertRole(skeleton, result, BoneRole.Neck, "Neck");
AssertRole(skeleton, result, BoneRole.Head, "Head");
AssertRole(skeleton, result, BoneRole.ClavicleL, "LeftShoulder");
AssertRole(skeleton, result, BoneRole.UpperArmR, "RightArm");
AssertRole(skeleton, result, BoneRole.LowerArmL, "LeftForeArm");
AssertRole(skeleton, result, BoneRole.HandL, "LeftHand");
AssertRole(skeleton, result, BoneRole.UpperLegL, "LeftUpLeg");
AssertRole(skeleton, result, BoneRole.LowerLegR, "RightLeg");
AssertRole(skeleton, result, BoneRole.FootL, "LeftFoot");
AssertRole(skeleton, result, BoneRole.ToeR, "RightToeBase");
// Gloved hand: two thumb segments, and the middle finger's InHand metacarpal maps
// to the Meta role (NOT a phalanx — that is the SOMA finger bug).
AssertRole(skeleton, result, BoneRole.ThumbProxL, "LeftHandThumb1");
AssertRole(skeleton, result, BoneRole.ThumbMidL, "LeftHandThumb2");
AssertRole(skeleton, result, BoneRole.MiddleMetaL, "LeftInHandMiddle");
AssertRole(skeleton, result, BoneRole.MiddleProxL, "LeftHandMiddle1");
AssertRole(skeleton, result, BoneRole.MiddleMidR, "RightHandMiddle2");
}
/// <summary>
/// Every decoy family on the boxer rig stays unmapped: the glove mesh shells (which
/// carry finger NAMES), the root/trajectory bones above the hips, the second neck bone
/// and the twist/muscle/jiggle simulation layer.
/// </summary>
[Fact]
public void FightNight_GloveShellsRootAndSimulationBones_AreNeverMapped()
{
var skeleton = MappingFixtures.BuildFightNight();
var result = ProfileDetector.Apply(ProfileLibrary.FightNight, skeleton);
var mapped = result.RoleToBone.Values.Select(i => skeleton[i].Name).ToHashSet(StringComparer.Ordinal);
foreach (var decoy in new[]
{
"LeftHandThumbGlove", "LeftHandIndexGlove", "LeftHandMiddleGlove",
"LeftInHandRingGlove", "LeftHandPinkyGlove", "RightHandIndexGlove",
"Reference", "AITrajectory", "Neck1", "Crotch",
"LeftArmTwist", "LeftForeArmTwist", "LeftGloveTwist", "LeftUpLegTwist",
"Muscle_Left_Bicep", "Offset_Muscle_Left_Delt", "Left_Elbow_Helper",
"Left_Knee_Helper", "LeftFootEnd", "Spine2_Jiggle", "Gut_Center", "StomachFront",
})
{
Assert.True(skeleton.IndexOf(decoy) >= 0, $"fixture missing decoy bone {decoy}");
Assert.False(mapped.Contains(decoy), $"{decoy} must never carry a role");
}
// No index/ring/pinky roles exist on a gloved hand.
foreach (var role in new[]
{
BoneRole.IndexProxL, BoneRole.IndexProxR, BoneRole.RingProxL,
BoneRole.PinkyProxL, BoneRole.ThumbDistL, BoneRole.MiddleDistL,
})
{
Assert.False(result.RoleToBone.ContainsKey(role), $"{role} must not be mapped");
}
}
/// <summary>The boxer preset must not claim the rigs whose naming it shares.</summary>
[Fact]
public void FightNight_DoesNotOutscoreNativePresetsOnTheirOwnRigs()
{
var mixamo = MappingFixtures.LoadZombieCrawl();
Assert.True(
ProfileDetector.Score(ProfileLibrary.Mixamo, mixamo)
> ProfileDetector.Score(ProfileLibrary.FightNight, mixamo),
"fight_night must not outscore mixamo on a real Mixamo rig");
Assert.Equal("mixamo", ProfileDetector.Detect(mixamo)!.Value.Profile.Name);
var neuron = MappingFixtures.BuildPerceptionNeuron();
Assert.True(
ProfileDetector.Score(ProfileLibrary.PerceptionNeuron, neuron)
> ProfileDetector.Score(ProfileLibrary.FightNight, neuron),
"fight_night must not outscore perception_neuron on a Neuron rig");
Assert.Equal("perception_neuron", ProfileDetector.Detect(neuron)!.Value.Profile.Name);
}
// ---------------------------------------------------------------- detection: valvebiped
[Fact]
public void Detect_SyntheticValveBiped_PicksValveBipedPreset()
{
var skeleton = MappingFixtures.BuildValveBiped();
var detected = ProfileDetector.Detect(skeleton);
Assert.NotNull(detected);
var (profile, result) = detected.Value;
Assert.Equal("valvebiped", profile.Name);
Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
Assert.True(ProfileDetector.Score(ProfileLibrary.Biped, skeleton) < 0.8f,
"the plain Biped preset must not claim a ValveBiped rig");
Assert.True(ProfileDetector.Score(ProfileLibrary.Mixamo, skeleton) < 0.8f,
"mixamo must not claim a ValveBiped rig");
AssertRole(skeleton, result, BoneRole.Hips, "ValveBiped.Bip01_Pelvis");
AssertRole(skeleton, result, BoneRole.Spine0, "ValveBiped.Bip01_Spine");
AssertRole(skeleton, result, BoneRole.Spine2, "ValveBiped.Bip01_Spine2");
// The stock chain skips Spine3: Spine4 is the chest and fills the Spine3 role.
AssertRole(skeleton, result, BoneRole.Spine3, "ValveBiped.Bip01_Spine4");
Assert.False(result.RoleToBone.ContainsKey(BoneRole.Spine4), "Spine4 role double-mapped");
AssertRole(skeleton, result, BoneRole.Neck, "ValveBiped.Bip01_Neck1");
AssertRole(skeleton, result, BoneRole.Head, "ValveBiped.Bip01_Head1");
AssertRole(skeleton, result, BoneRole.ClavicleL, "ValveBiped.Bip01_L_Clavicle");
AssertRole(skeleton, result, BoneRole.UpperArmR, "ValveBiped.Bip01_R_UpperArm");
AssertRole(skeleton, result, BoneRole.LowerArmL, "ValveBiped.Bip01_L_Forearm");
AssertRole(skeleton, result, BoneRole.HandL, "ValveBiped.Bip01_L_Hand");
AssertRole(skeleton, result, BoneRole.ThumbProxL, "ValveBiped.Bip01_L_Finger0");
AssertRole(skeleton, result, BoneRole.ThumbDistL, "ValveBiped.Bip01_L_Finger02");
AssertRole(skeleton, result, BoneRole.IndexProxL, "ValveBiped.Bip01_L_Finger1");
AssertRole(skeleton, result, BoneRole.MiddleMidR, "ValveBiped.Bip01_R_Finger21");
AssertRole(skeleton, result, BoneRole.PinkyDistR, "ValveBiped.Bip01_R_Finger42");
AssertRole(skeleton, result, BoneRole.UpperLegL, "ValveBiped.Bip01_L_Thigh");
AssertRole(skeleton, result, BoneRole.LowerLegR, "ValveBiped.Bip01_R_Calf");
AssertRole(skeleton, result, BoneRole.FootL, "ValveBiped.Bip01_L_Foot");
AssertRole(skeleton, result, BoneRole.ToeL, "ValveBiped.Bip01_L_Toe0");
foreach (var role in FingerProxMidDistBothSides)
Assert.True(result.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");
// Attachment/helper bones carry no role.
foreach (var (role, boneIndex) in result.RoleToBone)
{
var name = skeleton[boneIndex].Name;
Assert.False(name == "ValveBiped.forward" || name.Contains("Anim_Attachment"),
$"{role} mapped to helper {name}");
}
}
// ---------------------------------------------------------------- detection: xsens mvn
[Fact]
public void Detect_SyntheticXsensMvn_PicksXsensPreset()
{
var skeleton = MappingFixtures.BuildXsensMvn();
var detected = ProfileDetector.Detect(skeleton);
Assert.NotNull(detected);
var (profile, result) = detected.Value;
Assert.Equal("xsens_mvn", profile.Name);
Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
Assert.True(ProfileDetector.Score(ProfileLibrary.RokokoBvh, skeleton) < 0.8f,
"rokoko_bvh must not claim an MVN rig (no 'Hips' bone)");
AssertRole(skeleton, result, BoneRole.Hips, "Pelvis");
AssertRole(skeleton, result, BoneRole.Spine0, "L5");
AssertRole(skeleton, result, BoneRole.Spine1, "L3");
AssertRole(skeleton, result, BoneRole.Spine2, "T12");
AssertRole(skeleton, result, BoneRole.Spine3, "T8");
AssertRole(skeleton, result, BoneRole.Neck, "Neck");
AssertRole(skeleton, result, BoneRole.Head, "Head");
AssertRole(skeleton, result, BoneRole.ClavicleL, "LeftShoulder");
AssertRole(skeleton, result, BoneRole.UpperArmR, "RightUpperArm");
AssertRole(skeleton, result, BoneRole.LowerArmL, "LeftForeArm");
AssertRole(skeleton, result, BoneRole.HandR, "RightHand");
AssertRole(skeleton, result, BoneRole.UpperLegL, "LeftUpperLeg");
AssertRole(skeleton, result, BoneRole.LowerLegR, "RightLowerLeg");
AssertRole(skeleton, result, BoneRole.FootL, "LeftFoot");
AssertRole(skeleton, result, BoneRole.ToeR, "RightToe");
}
// ---------------------------------------------------------------- detection: perception neuron
[Fact]
public void Detect_SyntheticPerceptionNeuron_PicksNeuronPreset()
{
var skeleton = MappingFixtures.BuildPerceptionNeuron();
var detected = ProfileDetector.Detect(skeleton);
Assert.NotNull(detected);
var (profile, result) = detected.Value;
Assert.Equal("perception_neuron", profile.Name);
Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
// Mixamo matches most Neuron names but misses Spine3 and the (absent) toes; the
// dedicated preset must outscore it.
Assert.True(
ProfileDetector.Score(ProfileLibrary.Mixamo, skeleton) < result.Confidence,
"perception_neuron must outscore mixamo on a Neuron rig");
AssertRole(skeleton, result, BoneRole.Hips, "Hips");
AssertRole(skeleton, result, BoneRole.Spine0, "Spine");
AssertRole(skeleton, result, BoneRole.Spine3, "Spine3");
AssertRole(skeleton, result, BoneRole.Neck, "Neck");
AssertRole(skeleton, result, BoneRole.Head, "Head");
AssertRole(skeleton, result, BoneRole.ClavicleL, "LeftShoulder");
AssertRole(skeleton, result, BoneRole.UpperArmL, "LeftArm");
AssertRole(skeleton, result, BoneRole.LowerArmR, "RightForeArm");
AssertRole(skeleton, result, BoneRole.HandL, "LeftHand");
AssertRole(skeleton, result, BoneRole.ThumbProxL, "LeftHandThumb1");
AssertRole(skeleton, result, BoneRole.IndexProxL, "LeftHandIndex1");
AssertRole(skeleton, result, BoneRole.MiddleMidR, "RightHandMiddle2");
AssertRole(skeleton, result, BoneRole.PinkyDistR, "RightHandPinky3");
AssertRole(skeleton, result, BoneRole.UpperLegL, "LeftUpLeg");
AssertRole(skeleton, result, BoneRole.LowerLegR, "RightLeg");
AssertRole(skeleton, result, BoneRole.FootL, "LeftFoot");
foreach (var role in FingerProxMidDistBothSides)
Assert.True(result.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");
// The InHand metacarpal helpers must never be mapped (mapping them as phalanges
// shifts every curl one joint outward — the SOMA finger bug class).
foreach (var (role, boneIndex) in result.RoleToBone)
Assert.DoesNotContain("InHand", skeleton[boneIndex].Name);
}
// ---------------------------------------------------------------- detection: daz genesis
[Fact]
public void Detect_SyntheticDazGenesis_PicksDazGenesisPreset()
{
var skeleton = MappingFixtures.BuildDazGenesis();
var detected = ProfileDetector.Detect(skeleton);
Assert.NotNull(detected);
var (profile, result) = detected.Value;
Assert.Equal("daz_genesis", profile.Name);
Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
Assert.True(ProfileDetector.Score(ProfileLibrary.DazPoser, skeleton) < 0.8f,
"daz_poser must not claim a Genesis 3/8 rig (lShldr vs lShldrBend)");
// hip is the LCA of the pelvis leg branch and the abdomen spine branch — it is
// the hips; pelvis stays unmapped (ActorCore Hip/Pelvis policy).
AssertRole(skeleton, result, BoneRole.Hips, "hip");
AssertRole(skeleton, result, BoneRole.Spine0, "abdomenLower");
AssertRole(skeleton, result, BoneRole.Spine1, "abdomenUpper");
AssertRole(skeleton, result, BoneRole.Spine2, "chestLower");
AssertRole(skeleton, result, BoneRole.Spine3, "chestUpper");
AssertRole(skeleton, result, BoneRole.Neck, "neckLower");
AssertRole(skeleton, result, BoneRole.Head, "head");
AssertRole(skeleton, result, BoneRole.ClavicleL, "lCollar");
AssertRole(skeleton, result, BoneRole.UpperArmL, "lShldrBend");
AssertRole(skeleton, result, BoneRole.LowerArmR, "rForearmBend");
AssertRole(skeleton, result, BoneRole.HandL, "lHand");
AssertRole(skeleton, result, BoneRole.ThumbProxL, "lThumb1");
AssertRole(skeleton, result, BoneRole.MiddleMidR, "rMid2");
AssertRole(skeleton, result, BoneRole.PinkyDistL, "lPinky3");
AssertRole(skeleton, result, BoneRole.UpperLegL, "lThighBend");
AssertRole(skeleton, result, BoneRole.LowerLegR, "rShin");
AssertRole(skeleton, result, BoneRole.FootL, "lFoot");
AssertRole(skeleton, result, BoneRole.ToeL, "lToe");
foreach (var role in FingerProxMidDistBothSides)
Assert.True(result.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");
// Twist roll helpers, the pelvis intermediate, neckUpper and the metatarsals
// carry no role.
foreach (var (role, boneIndex) in result.RoleToBone)
{
var name = skeleton[boneIndex].Name;
Assert.DoesNotContain("Twist", name);
Assert.False(name is "pelvis" or "neckUpper", $"{role} mapped to {name}");
Assert.DoesNotContain("Metatarsals", name);
}
}
// ---------------------------------------------------------------- detection: 3ds max biped
[Fact]
public void Detect_SyntheticBiped_PicksBipedPreset()
{
var skeleton = MappingFixtures.BuildBiped();
var detected = ProfileDetector.Detect(skeleton);
Assert.NotNull(detected);
var (profile, result) = detected.Value;
Assert.Equal("biped", profile.Name);
Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
Assert.True(ProfileDetector.Score(ProfileLibrary.Mixamo, skeleton) < 0.8f,
"mixamo must not claim a Biped rig");
AssertRole(skeleton, result, BoneRole.Hips, "Bip01 Pelvis");
AssertRole(skeleton, result, BoneRole.Spine0, "Bip01 Spine");
AssertRole(skeleton, result, BoneRole.Spine3, "Bip01 Spine3");
AssertRole(skeleton, result, BoneRole.Neck, "Bip01 Neck");
AssertRole(skeleton, result, BoneRole.Head, "Bip01 Head");
AssertRole(skeleton, result, BoneRole.ClavicleL, "Bip01 L Clavicle");
AssertRole(skeleton, result, BoneRole.UpperArmR, "Bip01 R UpperArm");
AssertRole(skeleton, result, BoneRole.LowerArmL, "Bip01 L Forearm");
AssertRole(skeleton, result, BoneRole.HandL, "Bip01 L Hand");
// Numbered fingers: Finger0 chain is the thumb, segment digits are phalanges.
AssertRole(skeleton, result, BoneRole.ThumbProxL, "Bip01 L Finger0");
AssertRole(skeleton, result, BoneRole.ThumbDistL, "Bip01 L Finger02");
AssertRole(skeleton, result, BoneRole.IndexProxL, "Bip01 L Finger1");
AssertRole(skeleton, result, BoneRole.MiddleMidR, "Bip01 R Finger21");
AssertRole(skeleton, result, BoneRole.PinkyDistR, "Bip01 R Finger42");
AssertRole(skeleton, result, BoneRole.UpperLegL, "Bip01 L Thigh");
AssertRole(skeleton, result, BoneRole.LowerLegR, "Bip01 R Calf");
AssertRole(skeleton, result, BoneRole.FootL, "Bip01 L Foot");
AssertRole(skeleton, result, BoneRole.ToeL, "Bip01 L Toe0");
foreach (var role in FingerProxMidDistBothSides)
Assert.True(result.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");
// The COM root, Footsteps and toe segments carry no role.
foreach (var (role, boneIndex) in result.RoleToBone)
{
var name = skeleton[boneIndex].Name;
Assert.False(name is "Bip01" or "Bip01 Footsteps", $"{role} mapped to {name}");
Assert.False(name.EndsWith("Toe01") || name.EndsWith("Toe02"), $"{role} mapped to toe segment {name}");
}
}
[Fact]
public void Detect_BipedUnderscoreNames_StillDetected()
{
// Some exporters mangle the Biped spaces to underscores (Bip001_L_Thigh); the
// namespace pattern and separator-insensitive normalization must absorb both.
var original = MappingFixtures.BuildBiped();
var skeleton = MappingFixtures.RenameAll(
original, i => original[i].Name.Replace("Bip01 ", "Bip001_").Replace(' ', '_'));
var detected = ProfileDetector.Detect(skeleton);
Assert.NotNull(detected);
Assert.Equal("biped", detected.Value.Profile.Name);
Assert.True(detected.Value.Result.Confidence >= 0.9f);
AssertRole(skeleton, detected.Value.Result, BoneRole.UpperLegL, "Bip001_L_Thigh");
}
// ---------------------------------------------------------------- detection: daz / poser
[Fact]
public void Detect_SyntheticDazPoser_PicksDazPreset()
{
var skeleton = MappingFixtures.BuildDazPoser();
var detected = ProfileDetector.Detect(skeleton);
Assert.NotNull(detected);
var (profile, result) = detected.Value;
Assert.Equal("daz_poser", profile.Name);
Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
Assert.True(ProfileDetector.Score(ProfileLibrary.Mixamo, skeleton) < 0.8f,
"mixamo must not claim a DAZ/Poser rig");
AssertRole(skeleton, result, BoneRole.Hips, "hip");
AssertRole(skeleton, result, BoneRole.Spine0, "abdomen");
AssertRole(skeleton, result, BoneRole.Spine1, "abdomen2");
AssertRole(skeleton, result, BoneRole.Spine2, "chest");
AssertRole(skeleton, result, BoneRole.Neck, "neck");
AssertRole(skeleton, result, BoneRole.Head, "head");
AssertRole(skeleton, result, BoneRole.ClavicleL, "lCollar");
AssertRole(skeleton, result, BoneRole.UpperArmL, "lShldr");
AssertRole(skeleton, result, BoneRole.LowerArmR, "rForeArm");
AssertRole(skeleton, result, BoneRole.HandL, "lHand");
AssertRole(skeleton, result, BoneRole.ThumbProxL, "lThumb1");
AssertRole(skeleton, result, BoneRole.MiddleMidR, "rMid2");
AssertRole(skeleton, result, BoneRole.PinkyDistL, "lPinky3");
AssertRole(skeleton, result, BoneRole.UpperLegL, "lThigh");
AssertRole(skeleton, result, BoneRole.LowerLegR, "rShin");
AssertRole(skeleton, result, BoneRole.FootL, "lFoot");
AssertRole(skeleton, result, BoneRole.ToeL, "lToe");
// Buttock thigh-helpers and eyes carry no role.
foreach (var (role, boneIndex) in result.RoleToBone)
{
var name = skeleton[boneIndex].Name;
Assert.DoesNotContain("Buttock", name);
Assert.DoesNotContain("Eye", name);
}
}
// ---------------------------------------------------------------- detection: rigify
[Theory]
[InlineData(false)] // metarig names: spine, spine.001.., upper_arm.L
[InlineData(true)] // generated deform skeleton: DEF-spine, DEF-upper_arm.L (+ .001 twins)
public void Detect_SyntheticRigify_PicksRigifyPreset(bool defVariant)
{
var skeleton = MappingFixtures.BuildRigify(defVariant);
var detected = ProfileDetector.Detect(skeleton);
Assert.NotNull(detected);
var (profile, result) = detected.Value;
Assert.Equal("rigify", profile.Name);
Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
Assert.True(ProfileDetector.Score(ProfileLibrary.Mixamo, skeleton) < 0.8f,
"mixamo must not claim a rigify rig");
var d = defVariant ? "DEF-" : string.Empty;
// rigify's "spine" IS the pelvis bone (rigify/metarigs/human.py); spine.004 is
// the first neck bone, spine.006 the head. spine.005 stays unmapped.
AssertRole(skeleton, result, BoneRole.Hips, $"{d}spine");
AssertRole(skeleton, result, BoneRole.Spine0, $"{d}spine.001");
AssertRole(skeleton, result, BoneRole.Spine2, $"{d}spine.003");
AssertRole(skeleton, result, BoneRole.Neck, $"{d}spine.004");
AssertRole(skeleton, result, BoneRole.Head, $"{d}spine.006");
Assert.DoesNotContain(skeleton.Bones.First(b => b.Name == $"{d}spine.005").Index,
result.RoleToBone.Values);
AssertRole(skeleton, result, BoneRole.ClavicleL, $"{d}shoulder.L");
AssertRole(skeleton, result, BoneRole.UpperArmL, $"{d}upper_arm.L");
AssertRole(skeleton, result, BoneRole.LowerArmR, $"{d}forearm.R");
AssertRole(skeleton, result, BoneRole.HandL, $"{d}hand.L");
AssertRole(skeleton, result, BoneRole.ThumbProxL, $"{d}thumb.01.L");
AssertRole(skeleton, result, BoneRole.IndexProxL, $"{d}f_index.01.L");
AssertRole(skeleton, result, BoneRole.RingDistR, $"{d}f_ring.03.R");
AssertRole(skeleton, result, BoneRole.UpperLegL, $"{d}thigh.L");
AssertRole(skeleton, result, BoneRole.LowerLegR, $"{d}shin.R");
AssertRole(skeleton, result, BoneRole.FootL, $"{d}foot.L");
AssertRole(skeleton, result, BoneRole.ToeL, $"{d}toe.L");
foreach (var role in FingerProxMidDistBothSides)
Assert.True(result.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");
// pelvis.L/R, palm, heel helpers and segmented DEF twins carry no role.
foreach (var (role, boneIndex) in result.RoleToBone)
{
var name = skeleton[boneIndex].Name;
Assert.False(name.StartsWith("pelvis.") || name.StartsWith("palm.") || name.StartsWith("heel."),
$"{role} mapped to helper {name}");
Assert.False(name.EndsWith(".001") && name.StartsWith("DEF-") && !name.Contains("spine"),
$"{role} mapped to segmented deform twin {name}");
}
}
// ---------------------------------------------------------------- detection: vrm / vroid
[Fact]
public void Detect_SyntheticVrm_PicksVrmPreset()
{
var skeleton = MappingFixtures.BuildVrm();
var detected = ProfileDetector.Detect(skeleton);
Assert.NotNull(detected);
var (profile, result) = detected.Value;
Assert.Equal("vrm", profile.Name);
Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
Assert.True(ProfileDetector.Score(ProfileLibrary.Mixamo, skeleton) < 0.8f,
"mixamo must not claim a VRM rig");
AssertRole(skeleton, result, BoneRole.Hips, "J_Bip_C_Hips");
AssertRole(skeleton, result, BoneRole.Spine0, "J_Bip_C_Spine");
AssertRole(skeleton, result, BoneRole.Spine1, "J_Bip_C_Chest");
AssertRole(skeleton, result, BoneRole.Spine2, "J_Bip_C_UpperChest");
AssertRole(skeleton, result, BoneRole.Neck, "J_Bip_C_Neck");
AssertRole(skeleton, result, BoneRole.Head, "J_Bip_C_Head");
AssertRole(skeleton, result, BoneRole.ClavicleL, "J_Bip_L_Shoulder");
AssertRole(skeleton, result, BoneRole.UpperArmL, "J_Bip_L_UpperArm");
AssertRole(skeleton, result, BoneRole.LowerArmR, "J_Bip_R_LowerArm");
AssertRole(skeleton, result, BoneRole.HandL, "J_Bip_L_Hand");
AssertRole(skeleton, result, BoneRole.ThumbProxL, "J_Bip_L_Thumb1");
AssertRole(skeleton, result, BoneRole.MiddleMidR, "J_Bip_R_Middle2");
// VRM names the pinky "Little" (littleProximal.. humanoid bones).
AssertRole(skeleton, result, BoneRole.PinkyDistL, "J_Bip_L_Little3");
AssertRole(skeleton, result, BoneRole.UpperLegL, "J_Bip_L_UpperLeg");
AssertRole(skeleton, result, BoneRole.LowerLegR, "J_Bip_R_LowerLeg");
AssertRole(skeleton, result, BoneRole.FootL, "J_Bip_L_Foot");
AssertRole(skeleton, result, BoneRole.ToeL, "J_Bip_L_ToeBase");
foreach (var role in FingerProxMidDistBothSides)
Assert.True(result.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");
// Root and the secondary physics/adjust bones carry no role.
foreach (var (role, boneIndex) in result.RoleToBone)
{
var name = skeleton[boneIndex].Name;
Assert.False(name == "Root" || name.StartsWith("J_Sec_") || name.StartsWith("J_Adj_"),
$"{role} mapped to non-humanoid bone {name}");
}
}
// ---------------------------------------------------------------- detection: auto-rig pro
[Fact]
public void Detect_SyntheticAutoRigPro_PicksAutoRigProPreset()
{
var skeleton = MappingFixtures.BuildAutoRigPro();
var detected = ProfileDetector.Detect(skeleton);
Assert.NotNull(detected);
var (profile, result) = detected.Value;
Assert.Equal("auto_rig_pro", profile.Name);
Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
Assert.True(ProfileDetector.Score(ProfileLibrary.Mixamo, skeleton) < 0.8f,
"mixamo must not claim an Auto-Rig Pro rig");
// root.x is the hips (the ground bone "root" carries no role).
AssertRole(skeleton, result, BoneRole.Hips, "root.x");
AssertRole(skeleton, result, BoneRole.Spine0, "spine_01.x");
AssertRole(skeleton, result, BoneRole.Spine2, "spine_03.x");
AssertRole(skeleton, result, BoneRole.Neck, "neck.x");
AssertRole(skeleton, result, BoneRole.Head, "head.x");
AssertRole(skeleton, result, BoneRole.ClavicleL, "shoulder.l");
AssertRole(skeleton, result, BoneRole.UpperArmL, "arm_stretch.l");
AssertRole(skeleton, result, BoneRole.LowerArmR, "forearm_stretch.r");
AssertRole(skeleton, result, BoneRole.HandL, "hand.l");
AssertRole(skeleton, result, BoneRole.ThumbProxL, "c_thumb1.l");
AssertRole(skeleton, result, BoneRole.MiddleMidR, "c_middle2.r");
AssertRole(skeleton, result, BoneRole.PinkyDistL, "c_pinky3.l");
AssertRole(skeleton, result, BoneRole.UpperLegL, "thigh_stretch.l");
AssertRole(skeleton, result, BoneRole.LowerLegR, "leg_stretch.r");
AssertRole(skeleton, result, BoneRole.FootL, "foot.l");
AssertRole(skeleton, result, BoneRole.ToeL, "toes_01.l");
foreach (var role in FingerProxMidDistBothSides)
Assert.True(result.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");
// The ground bone and the leftover mixamorig finger-tip markers carry no role.
foreach (var (role, boneIndex) in result.RoleToBone)
{
var name = skeleton[boneIndex].Name;
Assert.False(name == "root" || name.StartsWith("mixamorig:"),
$"{role} mapped to non-deform bone {name}");
}
}
// ---------------------------------------------------------------- helpers
private static void AssertRole(
HumanoidRetargeter.Core.Skeleton.Skeleton skeleton, MappingResult result, BoneRole role, string expectedBone)
{
Assert.True(result.RoleToBone.TryGetValue(role, out var index), $"Role {role} unmapped");
Assert.Equal(expectedBone, skeleton[index].Name);
}
private static string Normalize(string text) => text.Replace("\r\n", "\n").TrimEnd('\n');
private static string FindRepoFile(string relativePath)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, "humanoid-retargeter.sbproj")))
return Path.Combine(dir.FullName, relativePath);
dir = dir.Parent;
}
throw new InvalidOperationException("Repo root (humanoid-retargeter.sbproj) not found above test directory.");
}
}
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using Xunit;
namespace HumanoidRetargeter.Tests.Skeleton;
public class ClipTests
{
private static Clip WithFrames(int frameCount, float fps)
{
var frames = new List<XForm[]>(frameCount);
for (var i = 0; i < frameCount; i++)
frames.Add(new[] { XForm.Identity });
return new Clip("clip", fps, looping: false, frames);
}
[Theory]
[InlineData(0, 30f, 0f)] // empty
[InlineData(1, 30f, 0f)] // a single sample spans no time
[InlineData(2, 30f, 1f / 30f)]
[InlineData(31, 30f, 1f)] // 31 fence posts = 30 intervals = 1 s
[InlineData(3, 30f, 2f / 30f)] // matches the DMX timeFrame duration (FrameCount-1)/Fps
public void Duration_IsSpanBetweenFirstAndLastSample(int frameCount, float fps, float expected)
{
Assert.Equal(expected, WithFrames(frameCount, fps).Duration, 6);
}
[Fact]
public void Duration_NonPositiveFpsRejectedAtConstruction()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new Clip("clip", 0f, false));
}
}
using System.Numerics;
using System.Text;
using HumanoidRetargeter.Core.Formats.Bvh;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Target;
using Xunit;
using Skel = HumanoidRetargeter.Core.Skeleton.Skeleton;
using HumanoidRetargeter.Core;
namespace HumanoidRetargeter.Tests.Solve;
public class GroundedLegDirectionTests
{
[Theory]
[InlineData(20f)]
[InlineData(-20f)]
public void GroundingDoesNotTiltPlantedLegsWithTargetTorso(float lean)
{
var bytes = Encoding.UTF8.GetBytes(WalkFixture.SyntheticWalkBvh());
var source = BvhImporter.Import(bytes);
var definitions = source.Skeleton.Bones.Select(b =>
{
var rest = b.RestLocal;
if (b.Name == "mixamorig:Spine")
rest.Rot = Quaternion.CreateFromAxisAngle(Vector3.UnitX, lean * MathF.PI / 180f);
return new BoneDefinition(b.Name, b.ParentIndex < 0 ? null : source.Skeleton[b.ParentIndex].Name, rest);
});
var skeleton = Skel.Create(definitions.ToArray());
var (map, _) = Retargeter.ResolveMapping(skeleton);
var target = new RetargetTargetSpec { Rig = TargetRig.FromSkeleton(skeleton, map), VmdlScale = RetargetTargetSpec.SboxSourceScale };
float PlantedLegAngle(bool cleanup)
{
var result = Retargeter.Convert(new RetargetRequest
{
SourceData = bytes, SourceFileName = "walk.bvh", FootPlantCleanup = cleanup,
}, target);
Assert.True(result.Success);
var world = new Pose(result.Clips[0].SolvedFrames![7]).ToWorld(skeleton);
var direction = world[map.RoleToBone[BoneRole.FootR]].Pos
- world[map.RoleToBone[BoneRole.LowerLegR]].Pos;
return MathQ.AngleBetween(direction, -Vector3.UnitY) * 180f / MathF.PI;
}
Assert.True(PlantedLegAngle(false) > 10f); // Repro: torso lean contaminates the stance.
Assert.True(PlantedLegAngle(true) < 1f);
}
}
using HumanoidRetargeter.Core.Formats.Fbx;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Target;
using Xunit;
using Xunit.Abstractions;
using HumanoidRetargeter.Core;
namespace HumanoidRetargeter.Tests.Target;
/// <summary>User repro (2026-07-04, local-only): DieExported.fbx picked as a custom FBX
/// target — skinned character, inch units (UnitScaleFactor 2.54), Y-up, textures folder
/// NEXT TO the source folder. Must import, be recognized (or at least accepted
/// best-effort), and convert cleanly onto its own skeleton.</summary>
public class UserDieFbxTargetTests
{
private static readonly string FbxPath =
TestUtil.RepoFile("dev", "corpus", "user_rigs", "die", "source", "DieExported.fbx");
private readonly ITestOutputHelper _out;
public UserDieFbxTargetTests(ITestOutputHelper output) => _out = output;
[Fact]
public void DieExported_ImportsAndResolvesAsTarget()
{
if (!File.Exists(FbxPath))
return; // local-only
var scene = FbxImporter.Import(File.ReadAllBytes(FbxPath));
_out.WriteLine($"bones={scene.Skeleton.Count} unitScaleCm={scene.UnitScaleCm} upAxis={scene.UpAxis}");
foreach (var bone in scene.Skeleton.Bones.Take(24))
_out.WriteLine($" {bone.Name}");
var (map, report) = Retargeter.ResolveMapping(scene.Skeleton);
_out.WriteLine($"profile={map.ProfileName} source={map.Source} conf={map.Confidence:0.00} "
+ $"needsUser={report.NeedsUserDecision} roles={map.RoleToBone.Count}");
foreach (var (role, index) in map.RoleToBone.OrderBy(kv => kv.Key.ToString()))
_out.WriteLine($" {role} -> {scene.Skeleton[index].Name}");
// The structural minimum TargetPickers accepts best-effort maps on.
Assert.True(map.RoleToBone.ContainsKey(BoneRole.Hips), "no hips");
Assert.True(map.RoleToBone.ContainsKey(BoneRole.UpperLegL), "no upper leg L");
Assert.True(map.RoleToBone.ContainsKey(BoneRole.UpperLegR), "no upper leg R");
// Converting a citizen clip onto it must solve.
var rig = TargetRig.FromSkeleton(scene.Skeleton, map);
var target = new HumanoidRetargeter.Core.RetargetTargetSpec
{
Rig = rig,
VmdlScale = HumanoidRetargeter.Core.RetargetTargetSpec.SboxSourceScale,
DefaultRootBone = scene.Skeleton[0].Name,
};
var bvh = TestUtil.RepoFile("dev", "corpus", "bvh", "cmu_01_01.bvh");
if (!File.Exists(bvh))
return;
var batch = HumanoidRetargeter.Core.Retargeter.ConvertBatch(
new[]
{
new HumanoidRetargeter.Core.RetargetRequest
{
SourceData = File.ReadAllBytes(bvh),
SourceFileName = "cmu_01_01.bvh",
},
},
target,
new HumanoidRetargeter.Core.BatchOptions { DmxFolderRelative = "animations" });
foreach (var clip in batch.Clips.Where(c => !c.Success))
_out.WriteLine($"FAIL {clip.ClipName}: {clip.Error}");
Assert.True(batch.Clips.All(c => c.Success));
Assert.Contains("cmu_01_01", batch.StandaloneVmdl);
}
/// <summary>User request: "apply the Neutral throw ball animation to that model, see
/// if the animation plays correctly" (and "try the surprised.fbx animation as well,
/// which is mixamo"). Solves each clip onto DieExported and requires real motion: the
/// hands must travel, the character must stay upright (pelvis height near rest, no
/// axis flip), and the pelvis must not sink.</summary>
[Theory]
[InlineData("todo/Neutral_throw_ball_001__A057.bvh", 0.5f)]
[InlineData("mixamo/Surprised.fbx", 0.15f)] // startled recoil - hands travel less than a throw
public void DieExported_UserClips_SolveWithRealMotion(string corpusRelative, float travelFactor)
{
var throwPath = TestUtil.RepoFile(
("dev/corpus/" + corpusRelative).Split('/'));
if (!File.Exists(FbxPath) || !File.Exists(throwPath))
return; // local-only
var scene = FbxImporter.Import(File.ReadAllBytes(FbxPath));
var (map, _) = Retargeter.ResolveMapping(scene.Skeleton);
var rig = TargetRig.FromSkeleton(scene.Skeleton, map);
var target = new HumanoidRetargeter.Core.RetargetTargetSpec
{
Rig = rig,
VmdlScale = HumanoidRetargeter.Core.RetargetTargetSpec.SboxSourceScale,
DefaultRootBone = scene.Skeleton[0].Name,
};
var batch = HumanoidRetargeter.Core.Retargeter.ConvertBatch(
new[]
{
new HumanoidRetargeter.Core.RetargetRequest
{
SourceData = File.ReadAllBytes(throwPath),
SourceFileName = Path.GetFileName(throwPath),
FootPlantCleanup = true,
},
},
target,
new HumanoidRetargeter.Core.BatchOptions { DmxFolderRelative = "animations" });
var clip = Assert.Single(batch.Clips);
_out.WriteLine($"clip={clip.ClipName} success={clip.Success} err={clip.Error} "
+ $"frames={clip.SolvedFrames?.Count}");
Assert.True(clip.Success, clip.Error);
Assert.True(clip.SolvedFrames!.Count > 30, "suspiciously short solve");
var skeleton = rig.Skeleton;
var hips = rig.BoneForRole(BoneRole.Hips)!.Value;
var handR = rig.BoneForRole(BoneRole.HandR)!.Value;
var restPelvisY = skeleton.RestWorld[hips].Pos.Y;
var minPelvis = float.MaxValue;
var maxPelvis = float.MinValue;
var handMin = new System.Numerics.Vector3(float.MaxValue);
var handMax = new System.Numerics.Vector3(float.MinValue);
foreach (var frame in clip.SolvedFrames)
{
var world = new HumanoidRetargeter.Core.Skeleton.Pose(frame).ToWorld(skeleton);
minPelvis = MathF.Min(minPelvis, world[hips].Pos.Y);
maxPelvis = MathF.Max(maxPelvis, world[hips].Pos.Y);
handMin = System.Numerics.Vector3.Min(handMin, world[handR].Pos);
handMax = System.Numerics.Vector3.Max(handMax, world[handR].Pos);
}
var handTravel = (handMax - handMin).Length();
_out.WriteLine($"restPelvisY={restPelvisY:0.#} pelvisY=[{minPelvis:0.#}..{maxPelvis:0.#}] "
+ $"handTravel={handTravel:0.#}cm (rig units)");
// Upright: pelvis stays within a sane band around its rest height (a lying/flipped
// or buried character violates this immediately).
Assert.InRange(minPelvis, restPelvisY * 0.5f, restPelvisY * 1.5f);
Assert.InRange(maxPelvis, restPelvisY * 0.5f, restPelvisY * 1.5f);
// The clip must move the hand substantially (relative to character size).
Assert.True(handTravel > restPelvisY * travelFactor,
$"hand barely moves ({handTravel:0.#} vs pelvis height {restPelvisY:0.#})");
}
}
public class UserDieFbxHandDiagnostics
{
private readonly Xunit.Abstractions.ITestOutputHelper _out;
public UserDieFbxHandDiagnostics(Xunit.Abstractions.ITestOutputHelper o) => _out = o;
[Xunit.Fact]
public void Surprised_OnDieExported_HandTransferAudit()
{
var fbx = TestUtil.RepoFile("dev", "corpus", "user_rigs", "die", "source", "DieExported.fbx");
var clipPath = TestUtil.RepoFile("dev", "corpus", "mixamo", "Surprised.fbx");
if (!File.Exists(fbx) || !File.Exists(clipPath)) return;
var tgtScene = HumanoidRetargeter.Core.Formats.Fbx.FbxImporter.Import(File.ReadAllBytes(fbx));
var (tgtMap, tgtReport) = HumanoidRetargeter.Core.Retargeter.ResolveMapping(tgtScene.Skeleton);
var rig = HumanoidRetargeter.Core.Target.TargetRig.FromSkeleton(tgtScene.Skeleton, tgtMap);
var target = new HumanoidRetargeter.Core.RetargetTargetSpec
{
Rig = rig, VmdlScale = 0.3937f, DefaultRootBone = tgtScene.Skeleton[0].Name,
};
var batch = HumanoidRetargeter.Core.Retargeter.ConvertBatch(
new[] { new HumanoidRetargeter.Core.RetargetRequest {
SourceData = File.ReadAllBytes(clipPath), SourceFileName = "Surprised.fbx" } },
target, new HumanoidRetargeter.Core.BatchOptions { DmxFolderRelative = "animations" });
var clip = batch.Clips.Single();
Xunit.Assert.True(clip.Success, clip.Error);
foreach (var note in clip.Mapping!.Notes.Where(n => n.Contains("and") || n.Contains("roll")))
_out.WriteLine("NOTE: " + note);
// Local-rotation swing per bone at the surprise peak vs source counterparts.
var srcScene = HumanoidRetargeter.Core.Retargeter.ImportSource(File.ReadAllBytes(clipPath), "Surprised.fbx");
var (srcMap, _) = HumanoidRetargeter.Core.Retargeter.ResolveMapping(srcScene.Skeleton);
var f = Math.Min(60, clip.SolvedFrames!.Count - 1);
var srcF = Math.Min(60, srcScene.Clips[0].Frames.Count - 1);
float Angle(System.Numerics.Quaternion a, System.Numerics.Quaternion b)
{
var d = MathF.Min(MathF.Abs(System.Numerics.Quaternion.Dot(
System.Numerics.Quaternion.Normalize(a), System.Numerics.Quaternion.Normalize(b))), 1f);
return 2f * MathF.Acos(d) * 180f / MathF.PI;
}
foreach (var role in new[] {
HumanoidRetargeter.Core.Mapping.BoneRole.LowerArmR, HumanoidRetargeter.Core.Mapping.BoneRole.HandR,
HumanoidRetargeter.Core.Mapping.BoneRole.IndexProxR, HumanoidRetargeter.Core.Mapping.BoneRole.MiddleProxR,
HumanoidRetargeter.Core.Mapping.BoneRole.ThumbProxR,
HumanoidRetargeter.Core.Mapping.BoneRole.HandL, HumanoidRetargeter.Core.Mapping.BoneRole.IndexProxL })
{
var t = rig.BoneForRole(role);
srcMap.RoleToBone.TryGetValue(role, out var s);
if (t is null) { _out.WriteLine($"{role}: unmapped on target"); continue; }
var tgtSwing = Angle(clip.SolvedFrames[f][t.Value].Rot, rig.Skeleton[t.Value].RestLocal.Rot);
var srcSwing = srcMap.RoleToBone.ContainsKey(role)
? Angle(srcScene.Clips[0].Frames[srcF][s].Rot, srcScene.Skeleton[s].RestLocal.Rot)
: -1f;
_out.WriteLine($"{role}: target swing {tgtSwing:0.#} deg source swing {srcSwing:0.#} deg");
}
}
}
public class UserDieFbxFingerLengths
{
private readonly Xunit.Abstractions.ITestOutputHelper _out;
public UserDieFbxFingerLengths(Xunit.Abstractions.ITestOutputHelper o) => _out = o;
[Xunit.Fact]
public void Surprised_OnDieExported_FingerLengthAudit()
{
var fbx = TestUtil.RepoFile("dev", "corpus", "user_rigs", "die", "source", "DieExported.fbx");
var clipPath = TestUtil.RepoFile("dev", "corpus", "mixamo", "Surprised.fbx");
if (!File.Exists(fbx) || !File.Exists(clipPath)) return;
var tgtScene = HumanoidRetargeter.Core.Formats.Fbx.FbxImporter.Import(File.ReadAllBytes(fbx));
var (tgtMap, _) = HumanoidRetargeter.Core.Retargeter.ResolveMapping(tgtScene.Skeleton);
foreach (var b in tgtScene.Skeleton.Bones)
if (b.Name.Contains("Finger") || b.Name.Contains("Hand") || b.Name.Contains("Forearm"))
_out.WriteLine($"bone: {b.Name} mapped={tgtMap.RoleToBone.ContainsValue(b.Index)}");
var rig = HumanoidRetargeter.Core.Target.TargetRig.FromSkeleton(tgtScene.Skeleton, tgtMap);
var target = new HumanoidRetargeter.Core.RetargetTargetSpec
{ Rig = rig, VmdlScale = 0.3937f, DefaultRootBone = tgtScene.Skeleton[0].Name };
var batch = HumanoidRetargeter.Core.Retargeter.ConvertBatch(
new[] { new HumanoidRetargeter.Core.RetargetRequest {
SourceData = File.ReadAllBytes(clipPath), SourceFileName = "Surprised.fbx" } },
target, new HumanoidRetargeter.Core.BatchOptions { DmxFolderRelative = "animations" });
var clip = batch.Clips.Single();
var f = Math.Min(60, clip.SolvedFrames!.Count - 1);
var world = new HumanoidRetargeter.Core.Skeleton.Pose(clip.SolvedFrames[f]).ToWorld(rig.Skeleton);
var rest = rig.Skeleton.RestWorld;
var hand = rig.BoneForRole(HumanoidRetargeter.Core.Mapping.BoneRole.HandR)!.Value;
foreach (var role in new[] {
HumanoidRetargeter.Core.Mapping.BoneRole.IndexDistR, HumanoidRetargeter.Core.Mapping.BoneRole.MiddleDistR,
HumanoidRetargeter.Core.Mapping.BoneRole.ThumbDistR, HumanoidRetargeter.Core.Mapping.BoneRole.PinkyDistR })
{
if (rig.BoneForRole(role) is not { } tip) { _out.WriteLine($"{role}: unmapped"); continue; }
var restD = (rest[tip].Pos - rest[hand].Pos).Length();
var frameD = (world[tip].Pos - world[hand].Pos).Length();
_out.WriteLine($"{role}: rest {restD:0.##} frame {frameD:0.##} ratio {frameD / restD:0.###}");
}
}
}
public class UserSimpsonRigDiagnostics
{
private readonly Xunit.Abstractions.ITestOutputHelper _out;
public UserSimpsonRigDiagnostics(Xunit.Abstractions.ITestOutputHelper o) => _out = o;
[Xunit.Fact]
public void Simpson_ImportAudit()
{
var fbx = TestUtil.RepoFile("dev", "corpus", "user_rigs", "simpson", "source", "C8V7NUC53TF8TTF3BXDMVGQIQ.fbx");
if (!File.Exists(fbx)) return;
var scene = HumanoidRetargeter.Core.Formats.Fbx.FbxImporter.Import(File.ReadAllBytes(fbx));
_out.WriteLine($"bones={scene.Skeleton.Count} unitScaleCm={scene.UnitScaleCm} upAxis={scene.UpAxis} clips={scene.Clips.Count}");
var (map, report) = HumanoidRetargeter.Core.Retargeter.ResolveMapping(scene.Skeleton);
_out.WriteLine($"profile={map.ProfileName} conf={map.Confidence:0.00} roles={map.RoleToBone.Count} needsUser={report.NeedsUserDecision}");
// Character extents + suspicious rest locals
var rest = scene.Skeleton.RestWorld;
float maxY = float.MinValue, minY = float.MaxValue;
foreach (var w in rest) { maxY = MathF.Max(maxY, w.Pos.Y); minY = MathF.Min(minY, w.Pos.Y); }
_out.WriteLine($"rest height span Y: {minY:0.#}..{maxY:0.#} cm");
foreach (var role in new[] { HumanoidRetargeter.Core.Mapping.BoneRole.Hips, HumanoidRetargeter.Core.Mapping.BoneRole.Head,
HumanoidRetargeter.Core.Mapping.BoneRole.HandR, HumanoidRetargeter.Core.Mapping.BoneRole.FootL })
{
if (map.RoleToBone.TryGetValue(role, out var i))
_out.WriteLine($"{role} -> {scene.Skeleton[i].Name} world {rest[i].Pos}");
else _out.WriteLine($"{role}: UNMAPPED");
}
foreach (var b in scene.Skeleton.Bones.Take(12))
_out.WriteLine($" [{b.Index}] {b.Name} parent={b.ParentIndex} localPos={b.RestLocal.Pos}");
}
}
using HumanoidRetargeter.Core.Target;
using Xunit;
using HumanoidRetargeter.Core;
namespace HumanoidRetargeter.Tests.Target;
/// <summary>
/// RenderMeshList embedding in standalone vmdls (user report 2026-07-04): a custom FBX
/// target has no compiled base model, so its generated vmdl used to compile into an EMPTY
/// model — 0 bones, 0 sequences — and playing any converted animation did nothing. With
/// <see cref="RetargetTargetSpec.MeshFilePath"/> set, the vmdl must embed the mesh source
/// so the compiled model carries the skeleton and skin the sequences play on.
/// </summary>
public class VmdlMeshEmbedTests
{
private static readonly AnimEntry[] OneAnim =
{
new() { Name = "clip", SourceFilename = "animations/clip.dmx" },
};
[Fact]
public void GenerateStandalone_WithMeshFile_EmbedsRenderMeshNode()
{
var vmdl = VmdlWriter.GenerateStandalone(
"", OneAnim, 0.3937f, "pelvis",
meshFilePath: "animations/retargeted/fox.fbx", meshImportScale: 100f);
Assert.Contains("\"RenderMeshList\"", vmdl);
Assert.Contains("\"RenderMeshFile\"", vmdl);
Assert.Contains("animations/retargeted/fox.fbx", vmdl);
Assert.Contains("import_scale = 100", vmdl);
Assert.Contains("name = \"fox\"", vmdl);
// The cm→inch modifier and the animation list still ride along.
Assert.Contains("ModelModifier_ScaleAndMirror", vmdl);
Assert.Contains("\"AnimationList\"", vmdl);
// Round-trips through the kv3 parser (augmentation reads these files back).
Kv3.Parse(vmdl);
}
[Fact]
public void GenerateStandalone_WithoutMeshFile_OmitsRenderMeshNode()
{
var vmdl = VmdlWriter.GenerateStandalone(
RetargetTargetSpec.SboxHumanMalePath, OneAnim, 0.3937f, "pelvis");
Assert.DoesNotContain("RenderMeshList", vmdl);
Assert.DoesNotContain("RenderMeshFile", vmdl);
}
/// <summary>The accumulate path augments an existing standalone vmdl IN PLACE, so a
/// pipeline-owned output generated before mesh embedding existed (empty
/// base_model_name, no mesh) would stay an empty unplayable model forever - user
/// report: "convert onto the custom FBX, open the new vmdl, the animation doesn't
/// play". EnsureMeshFile heals it.</summary>
[Fact]
public void EnsureMeshFile_HealsPreMeshEmbedVmdl_AndIsIdempotent()
{
// A pre-fix standalone output: no base model, no mesh, one accumulated animation.
var old = VmdlWriter.GenerateStandalone("", OneAnim, 0.3937f, "pelvis");
Assert.DoesNotContain("RenderMeshFile", old);
var healed = VmdlAugmenter.EnsureMeshFile(old, "animations/retargeted/die.fbx", 2.54f);
Assert.Contains("\"RenderMeshFile\"", healed);
Assert.Contains("animations/retargeted/die.fbx", healed);
Assert.Contains("import_scale = 2.54", healed);
// The accumulated animation survives.
Assert.Contains("\"clip\"", healed);
Kv3.Parse(healed);
// Same mesh again: no change at all.
Assert.Equal(healed, VmdlAugmenter.EnsureMeshFile(healed, "animations/retargeted/die.fbx", 2.54f));
// Switched target FBX: the pipeline-owned mesh node is replaced, not duplicated.
var switched = VmdlAugmenter.EnsureMeshFile(healed, "animations/retargeted/fox.fbx", 100f);
Assert.Contains("animations/retargeted/fox.fbx", switched);
Assert.DoesNotContain("animations/retargeted/die.fbx", switched);
Kv3.Parse(switched);
}
[Fact]
public void EnsureMeshFile_EmptyMeshPath_IsNoOp()
{
var vmdl = VmdlWriter.GenerateStandalone("", OneAnim, 0.3937f, "pelvis");
Assert.Same(vmdl, VmdlAugmenter.EnsureMeshFile(vmdl, "", 1f));
}
/// <summary>FBX materials are bare names the compiler cannot resolve as resource paths
/// ("Trying to load an illegal resource name X.vmat") - generated vmdls must remap them
/// to the real vmat files via MaterialGroupList (the shipped citizen mechanism).</summary>
[Fact]
public void GenerateStandalone_WithMaterialRemaps_EmitsMaterialGroup()
{
var remaps = new Dictionary<string, string>
{
["mi_dante_head.vmat"] = "animations/retargeted/mi_dante_head.vmat",
};
var vmdl = VmdlWriter.GenerateStandalone(
"", OneAnim, 0.3937f, "pelvis",
meshFilePath: "animations/retargeted/die.fbx", meshImportScale: 2.54f,
materialRemaps: remaps);
Assert.Contains("\"MaterialGroupList\"", vmdl);
Assert.Contains("\"DefaultMaterialGroup\"", vmdl);
Assert.Contains("from = \"mi_dante_head.vmat\"", vmdl);
Assert.Contains("to = \"animations/retargeted/mi_dante_head.vmat\"", vmdl);
Kv3.Parse(vmdl);
// And the healing path adds the same node to pre-remap outputs (idempotent).
var old = VmdlWriter.GenerateStandalone("", OneAnim, 0.3937f, "pelvis");
var healed = VmdlAugmenter.EnsureMeshFile(
old, "animations/retargeted/die.fbx", 2.54f, remaps);
Assert.Contains("\"MaterialGroupList\"", healed);
Assert.Contains("from = \"mi_dante_head.vmat\"", healed);
Assert.Equal(healed, VmdlAugmenter.EnsureMeshFile(
healed, "animations/retargeted/die.fbx", 2.54f, remaps));
}
/// <summary>An output vmdl whose remap table predates a texture-matching improvement
/// must LEARN new entries on the next conversion (observed: textured preview but
/// untextured ModelDoc forever) - while existing entries stay untouched.</summary>
[Fact]
public void EnsureMeshFile_MergesNewMaterialRemaps()
{
var oldRemaps = new Dictionary<string, string> { ["a.vmat"] = "out/a.vmat" };
var old = VmdlWriter.GenerateStandalone("", OneAnim, 0.3937f, "pelvis",
meshFilePath: "out/m.fbx", meshImportScale: 1f, materialRemaps: oldRemaps);
var merged = VmdlAugmenter.EnsureMeshFile(old, "out/m.fbx", 1f,
new Dictionary<string, string>
{
["a.vmat"] = "IGNORED/other.vmat",
["homer.vmat"] = "out/homer.vmat",
});
Assert.Contains("to = \"out/a.vmat\"", merged); // existing entry untouched
Assert.DoesNotContain("IGNORED", merged);
Assert.Contains("from = \"homer.vmat\"", merged); // new entry learned
Assert.Contains("to = \"out/homer.vmat\"", merged);
Kv3.Parse(merged);
Assert.Equal(merged, VmdlAugmenter.EnsureMeshFile(merged, "out/m.fbx", 1f,
new Dictionary<string, string> { ["homer.vmat"] = "out/homer.vmat" }));
}
}
using System.Numerics;
using System.Text;
using HumanoidRetargeter.Core.Cleanup;
using HumanoidRetargeter.Core.Formats.Bvh;
using HumanoidRetargeter.Core.Formats.Fbx;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Target;
using Xunit;
using HumanoidRetargeter.Core;
namespace HumanoidRetargeter.Tests.Solve;
public class PosedLocomotionTests
{
[Theory]
[InlineData(1, 500)]
[InlineData(1, -500)]
[InlineData(2, 500)]
[InlineData(2, -500)]
public void InPlaceCentersClipsWhoseStaticReferenceIsFarFromTheTake(int upAxis, float restTravel)
{
var target = Target(upAxis);
var frames = Convert(Fixture(upAxis, restTravel: restTravel), target, RootMotionMode.InPlace);
var hips = target.Rig.BoneForRole(HumanoidRetargeter.Core.Mapping.BoneRole.Hips)!.Value;
var center = frames.Aggregate(Vector3.Zero, (sum, frame) => sum + new Pose(frame).ToWorld(target.Rig.Skeleton)[hips].Pos) / frames.Count;
var delta = center - target.Rig.Skeleton.RestWorld[hips].Pos;
if (upAxis == 1) delta.Y = 0; else delta.Z = 0;
Assert.True(delta.Length() < .01f, $"In-place clip is displaced from the target bind by {delta}.");
}
[Theory]
[InlineData(1, false, 0)]
[InlineData(1, true, -90)]
[InlineData(2, false, 90)]
[InlineData(2, true, 90)]
public void SerializedRootYawMatchesTargetImportConvention(int upAxis, bool embedsMesh, float degrees)
{
var original = Target(upAxis);
var target = new RetargetTargetSpec
{
Rig = original.Rig, UpAxis = original.UpAxis, VmdlScale = 1,
MeshFilePath = embedsMesh ? "test.fbx" : "",
};
var frame = target.Rig.Skeleton.Bones.Select(b => b.RestLocal).ToArray();
var before = frame.ToArray();
var serialized = Retargeter.TestHook_CompensateEmbeddedMeshRootYaw(new[] { frame }, target).Single();
var yaw = Quaternion.CreateFromAxisAngle(upAxis == 1 ? Vector3.UnitY : Vector3.UnitZ, degrees * MathF.PI / 180);
for (var i = 0; i < frame.Length; i++)
{
var root = target.Rig.Skeleton[i].ParentIndex < 0;
var expectedPosition = root ? Vector3.Transform(before[i].Pos, yaw) : before[i].Pos;
var expectedRotation = root ? Quaternion.Normalize(yaw * before[i].Rot) : before[i].Rot;
Assert.True(Vector3.Distance(expectedPosition, serialized[i].Pos) < .0001f);
Assert.True(MathF.Abs(Quaternion.Dot(expectedRotation, serialized[i].Rot)) > .99999f);
Assert.Equal(before[i], frame[i]); // Serialization must not mutate solved poses.
}
}
[Theory]
[InlineData(1)]
[InlineData(2)]
public void LeaningSourceRestDoesNotTurnForwardTravelIntoVerticalMotion(int upAxis)
{
var target = Target(upAxis);
var result = Convert(Fixture(upAxis, torsoLean: 70), target, RootMotionMode.InPlace);
var hip = target.Rig.BoneForRole(HumanoidRetargeter.Core.Mapping.BoneRole.Hips)!.Value;
var heights = result.Select(frame => Vertical(new Pose(frame).ToWorld(target.Rig.Skeleton)[hip].Pos, upAxis)).ToArray();
Assert.True(heights.Max() - heights.Min() < .05f,
$"Horizontal travel changed pelvis height by {heights.Max() - heights.Min()}.");
}
[Theory]
[InlineData(1)]
[InlineData(2)]
public void RemovingRootTravelPreservesGroundedHeightsOnLeaningTarget(int upAxis)
{
var target = Target(upAxis, torsoLean: 20);
var bytes = Fixture(upAxis);
var moving = Convert(bytes, target, RootMotionMode.Off);
var inPlace = Convert(bytes, target, RootMotionMode.InPlace);
for (var f = 0; f < moving.Count; f++)
{
var before = new Pose(moving[f]).ToWorld(target.Rig.Skeleton);
var after = new Pose(inPlace[f]).ToWorld(target.Rig.Skeleton);
for (var b = 0; b < before.Length; b++)
Assert.True(MathF.Abs(Vertical(before[b].Pos - after[b].Pos, upAxis)) < .005f,
$"Frame {f}, bone {b}: root-motion removal changed ground clearance.");
}
}
[Fact]
public void LiftedFootInExportedRestDoesNotPushPlantedFootBelowGround()
{
var target = Target(1);
var result = Convert(Fixture(1, liftedRest: 35, travel: 0), target, RootMotionMode.Off);
var toe = target.Rig.BoneForRole(HumanoidRetargeter.Core.Mapping.BoneRole.ToeR)!.Value;
var floor = target.Rig.Skeleton.RestWorld[toe].Pos.Y;
foreach (var frame in result)
Assert.True(new Pose(frame).ToWorld(target.Rig.Skeleton)[toe].Pos.Y >= floor - .1f,
"A mid-step rest was incorrectly used as the planted-foot height reference.");
}
static float Vertical(Vector3 value, int axis) => axis == 1 ? value.Y : value.Z;
static RetargetTargetSpec Target(int upAxis, float torsoLean = 0)
{
var skeleton = FbxImporter.Import(Fixture(upAxis, torsoLean)).Skeleton;
var (map, _) = Retargeter.ResolveMapping(skeleton);
return new RetargetTargetSpec
{
Rig = TargetRig.FromSkeleton(skeleton, map), VmdlScale = 1,
UpAxis = upAxis == 1 ? TargetUpAxis.YUpCm : TargetUpAxis.ZUpEngine,
};
}
static List<HumanoidRetargeter.Core.Maths.XForm[]> Convert(byte[] bytes, RetargetTargetSpec target, RootMotionMode mode)
{
var result = Retargeter.Convert(new RetargetRequest
{
SourceData = bytes, SourceFileName = "posed-locomotion.fbx",
RootMotion = mode, FootPlantCleanup = true,
}, target);
Assert.True(result.Success, string.Join("; ", result.Clips.Select(c => c.Error)));
return result.Clips[0].SolvedFrames!;
}
// A tiny animation-only FBX: no proprietary pack files or optional corpus needed.
// The static rig can be bent over or caught mid-step; the take moves horizontally.
static byte[] Fixture(int upAxis, float torsoLean = 0, float liftedRest = 0, float travel = 100, float restTravel = 0)
{
var skeleton = BvhImporter.Import(Encoding.UTF8.GetBytes(PosedSkeletonFixture.SyntheticWalkBvh())).Skeleton;
var objects = new StringBuilder();
var connections = new StringBuilder();
foreach (var bone in skeleton.Bones)
{
var p = bone.RestLocal.Pos;
if (bone.ParentIndex < 0) { p.Y = 93; p.Z = restTravel; }
if (upAxis == 2) p = new Vector3(p.X, -p.Z, p.Y);
var angle = bone.Name == "mixamorig:Spine" ? torsoLean
: bone.Name == "mixamorig:RightUpLeg" ? liftedRest : 0;
objects.AppendLine(FormattableString.Invariant($$"""
Model: {{bone.Index + 1}}, "Model::{{bone.Name}}", "LimbNode" {
Properties70: {
P: "Lcl Translation", "Lcl Translation", "", "A",{{p.X}},{{p.Y}},{{p.Z}}
P: "Lcl Rotation", "Lcl Rotation", "", "A",{{angle}},0,0
}
}
"""));
connections.AppendLine($"C: \"OO\",{bone.Index + 1},{bone.ParentIndex + 1}");
}
void Curve(int id, int bone, string property, string axis, float end)
{
objects.AppendLine(FormattableString.Invariant($$"""
AnimationCurveNode: {{id}}, "AnimCurveNode::curve", "" { }
AnimationCurve: {{id + 1}}, "AnimCurve::value", "" {
KeyTime: *2 { a: 0,46186158000 }
KeyValueFloat: *2 { a: 0,{{end}} }
}
"""));
connections.AppendLine($"C: \"OO\",{id},10001\nC: \"OP\",{id},{bone},\"{property}\"\nC: \"OP\",{id + 1},{id},\"d|{axis}\"");
}
Curve(10002, 1, "Lcl Translation", upAxis == 1 ? "Z" : "Y", upAxis == 1 ? travel : -travel);
Curve(10004, skeleton.IndexOf("mixamorig:RightUpLeg") + 1, "Lcl Rotation", "X", 0);
return Encoding.UTF8.GetBytes(FormattableString.Invariant($$"""
GlobalSettings: { Properties70: {
P: "UpAxis", "int", "Integer", "",{{upAxis}}
P: "UpAxisSign", "int", "Integer", "",1
P: "UnitScaleFactor", "double", "Number", "",1
} }
Objects: {
{{objects}}
AnimationStack: 10000, "AnimStack::walk", "" { }
AnimationLayer: 10001, "AnimLayer::base", "" { }
}
Connections: {
{{connections}}
C: "OO",10001,10000
}
"""));
}
}
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;
using Editor;
using Sandbox;
// A custom rig without stock attachments must receive calibrated copied sockets.
// Run only in an explicitly opted-in isolated project, never a user's active editor.
public static class SmartPortCopiedSocketEngineTest
{
static bool started;
[EditorEvent.Frame]
public static void Tick()
{
var root = Environment.GetEnvironmentVariable("HR_SMART_PORT_COPIED_SOCKET_PROJECT");
if (started || Project.Current is null || string.IsNullOrEmpty(root)
|| !string.Equals(Path.GetFullPath(Project.Current.GetRootPath()).TrimEnd('/', '\\'), Path.GetFullPath(root).TrimEnd('/', '\\'), StringComparison.OrdinalIgnoreCase)
|| !AssetSystem.All.Any()) return;
started = true;
_ = Run(root);
}
static async Task Run(string root)
{
var data = new Dictionary<string, object>();
void Save() => File.WriteAllText(Path.Combine(root, "copied-socket-result.json"),
JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }));
try
{
data["started"] = DateTime.UtcNow; Save();
var source = AssetSystem.FindByPath("models/citizen/citizen.vmdl") ?? throw new Exception("Missing Citizen source");
var target = AssetSystem.FindByPath("humanoid_retargeter_smoke/customfbx/catgirl_2_preview_bind_9e13ad1f.vmdl") ?? throw new Exception("Missing Catgirl target fixture");
var type = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType("HumanoidRetargeter.EditorTools.SmartPortModels")).First(t => t != null);
var name = "copied_socket_" + DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
Action<string> progress = s => { data["stage"] = s; Save(); };
var task = (Task)type.GetMethod("CreateAsync", BindingFlags.Static | BindingFlags.NonPublic)
.Invoke(null, new object[] { source, target, "smart_port", name, progress, CancellationToken.None });
await task;
var result = task.GetType().GetProperty("Result").GetValue(task);
data["compiled"] = result.GetType().GetProperty("Compiled").GetValue(result);
data["errors"] = result.GetType().GetProperty("Errors").GetValue(result);
data["model"] = "smart_port/" + name + ".vmdl"; Save();
if (!(bool)data["compiled"]) throw new Exception("Compile failed");
var session = SceneEditorSession.CreateDefault(); session.MakeActive();
using (session.Scene.Push())
foreach (var path in new[] { source.Path, (string)data["model"] })
{
var go = session.Scene.CreateObject(); go.Name = path;
var actor = go.Components.Create<SkinnedModelRenderer>();
actor.Model = Model.Load(path); actor.UseAnimGraph = true;
}
EditorScene.Play(false, session); await Task.Delay(1500);
if (!Game.IsPlaying || Game.ActiveScene is null || Game.ActiveScene.IsEditor) throw new Exception("Did not enter play mode");
var actors = Game.ActiveScene.GetAllComponents<SkinnedModelRenderer>().ToArray();
var observations = new List<object>();
const int hold = 2; // Source graph's rifle hold type.
foreach (var weight in new[] { 0f, 1f })
foreach (var pitch in new[] { 0f, -25f, 25f })
{
for (var settle = 0; settle < 50; settle++)
{
foreach (var actor in actors)
{
actor.Set("b_grounded", true); actor.Set("holdtype", hold); actor.Set("weapon_pose", 0);
actor.Set("aim_body", Rotation.From(pitch, 0, 0).Forward); actor.Set("aim_body_weight", weight);
actor.Set("aim_head", Rotation.From(pitch, 0, 0).Forward); actor.Set("aim_head_weight", 1f);
}
await Task.Delay(100);
}
var sourceActor = actors.Single(a => a.Model.Name == source.Path);
var targetActor = actors.Single(a => a.Model.Name == (string)data["model"]);
var sourceGrip = sourceActor.SceneModel.GetAttachment("hold_R", true).Value;
var targetGrip = targetActor.SceneModel.GetAttachment("hold_R", true).Value;
var a = sourceGrip.Rotation; var b = targetGrip.Rotation;
var error = 2 * MathF.Acos(Math.Clamp(MathF.Abs(a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w), 0, 1)) * 180 / MathF.PI;
if (!float.IsFinite(error) || error > 3) throw new Exception($"Rifle attachment diverged {error} degrees (aim weight {weight}, pitch {pitch})");
foreach (var actor in actors)
{
var att = actor.SceneModel.GetAttachment("hold_R", true) ?? throw new Exception("Missing grip");
observations.Add(new { model = actor.Model.Name, hold, weight, pitch, error,
rotation = att.Rotation.Angles().ToString(), forward = att.Rotation.Forward.ToString(), position = att.Position.ToString() });
}
}
data["observations"] = observations; data["passed"] = true;
}
catch (Exception e) { data["error"] = e.ToString(); data["passed"] = false; }
finally
{
data["completed"] = true; Save();
if (Game.IsPlaying) EditorScene.Stop();
await Task.Delay(1000); EditorUtility.Quit(true);
}
}
}
using HumanoidRetargeter.EditorTools;
using HumanoidRetargeter.EditorTools.Embedded.ValveResourceFormat;
using HumanoidRetargeter.EditorTools.Embedded.ValveResourceFormat.ResourceTypes;
using Xunit;
namespace SmartPort.Parser.Tests;
public class SmartPortHandSocketMetadataTests
{
[CompiledFixtureFact]
public void AttachmentFittingUsesCompiledParentBones()
{
var path = Path.Combine(Environment.GetEnvironmentVariable("HR_SMART_PORT_FIXTURE")!, "models/player/human/frank_mp.vmdl_c");
var names = SmartPortAttachments.BoneNames(path);
using var resource = new Resource();
resource.Read(path);
var model = Assert.IsType<Model>(resource.DataBlock);
Assert.Contains("hold_R", names);
Assert.Contains("hold_L", names);
Assert.Equal(names.Length, names.Distinct().Count());
Assert.All(names.Where(n => !string.IsNullOrEmpty(n)), n => Assert.Contains(model.Skeleton.Bones, b => b.Name == n));
}
}
global using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class TestInit
{
public static Sandbox.TestAppSystem AppSystem;
[AssemblyInitialize]
public static void AssemblyInitialize( TestContext context )
{
AppSystem = new Sandbox.TestAppSystem();
AppSystem.Init();
}
[AssemblyCleanup]
public static void AssemblyCleanup()
{
AppSystem.Shutdown();
}
}
global using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace WackyLib.Tests;
[TestClass]
public class TestInit
{
private static Sandbox.TestAppSystem s_appSystem;
[AssemblyInitialize]
public static void AssemblyInitialize( TestContext context )
{
s_appSystem = new Sandbox.TestAppSystem();
s_appSystem.Init();
}
[AssemblyCleanup]
public static void AssemblyCleanup()
{
s_appSystem.Shutdown();
}
}
using Sandbox;
[TestClass]
public class SyncToolYamlRendererTests
{
[TestMethod]
public void EmptyInputProducesEmptyOutput()
{
Assert.AreEqual( "", SyncToolYamlRenderer.RenderFromJson( null ) );
Assert.AreEqual( "", SyncToolYamlRenderer.RenderFromJson( "" ) );
}
[TestMethod]
public void EmptyObjectAndArrayRenderAsFlowStyle()
{
Assert.AreEqual( "{}\n", SyncToolYamlRenderer.RenderFromJson( "{}" ) );
Assert.AreEqual( "[]\n", SyncToolYamlRenderer.RenderFromJson( "[]" ) );
}
[TestMethod]
public void InvalidJsonReturnsInputUnchanged()
{
var notJson = "not: real: json: ::";
Assert.AreEqual( notJson, SyncToolYamlRenderer.RenderFromJson( notJson ) );
}
[TestMethod]
public void TopLevelKeysAreSortedAlphabetically()
{
var json = "{\"zeta\":1,\"alpha\":2,\"mu\":3}";
var yaml = SyncToolYamlRenderer.RenderFromJson( json );
Assert.AreEqual( "alpha: 2\nmu: 3\nzeta: 1\n", yaml );
}
[TestMethod]
public void NestedObjectsAreSortedRecursively()
{
var json = "{\"outer\":{\"zeta\":1,\"alpha\":2}}";
var yaml = SyncToolYamlRenderer.RenderFromJson( json );
Assert.AreEqual( "outer:\n alpha: 2\n zeta: 1\n", yaml );
}
[TestMethod]
public void DifferentKeyOrdersProduceIdenticalOutput()
{
var a = "{\"slug\":\"hello\",\"method\":\"POST\",\"enabled\":true}";
var b = "{\"enabled\":true,\"method\":\"POST\",\"slug\":\"hello\"}";
Assert.AreEqual(
SyncToolYamlRenderer.RenderFromJson( a ),
SyncToolYamlRenderer.RenderFromJson( b )
);
}
[TestMethod]
public void OutputUsesYamlSyntaxNotJsonSyntax()
{
var json = "{\"name\":\"hooked\",\"enabled\":true,\"max\":42}";
var yaml = SyncToolYamlRenderer.RenderFromJson( json );
// Smoke check: the rendered text must look like YAML, not JSON.
// This is the regression we're guarding: prior to the fix the diff
// view rendered structured data as JSON.
StringAssert.DoesNotMatch( yaml, new System.Text.RegularExpressions.Regex( @"^\s*\{" ) );
StringAssert.Contains( yaml, "enabled: true" );
StringAssert.Contains( yaml, "max: 42" );
StringAssert.Contains( yaml, "name: \"hooked\"" );
}
[TestMethod]
public void StringValuesAreQuotedAndEscapedSafely()
{
var json = "{\"text\":\"a:b\\nc\"}";
var yaml = SyncToolYamlRenderer.RenderFromJson( json );
// Strings go through JSON quoting which is also valid YAML.
// The colon/newline must not leak as YAML structure.
Assert.AreEqual( "text: \"a:b\\nc\"\n", yaml );
}
[TestMethod]
public void BooleanAndNullAreUnquoted()
{
var json = "{\"a\":true,\"b\":false,\"c\":null}";
var yaml = SyncToolYamlRenderer.RenderFromJson( json );
Assert.AreEqual( "a: true\nb: false\nc: null\n", yaml );
}
[TestMethod]
public void IntegerAndDoubleAreUnquoted()
{
var json = "{\"i\":7,\"d\":1.5}";
var yaml = SyncToolYamlRenderer.RenderFromJson( json );
Assert.AreEqual( "d: 1.5\ni: 7\n", yaml );
}
[TestMethod]
public void ArraysOfObjectsRenderAsBlockSequence()
{
var json = "{\"steps\":[{\"name\":\"first\",\"id\":1},{\"name\":\"second\",\"id\":2}]}";
var yaml = SyncToolYamlRenderer.RenderFromJson( json );
var expected =
"steps:\n" +
" -\n" +
" id: 1\n" +
" name: \"first\"\n" +
" -\n" +
" id: 2\n" +
" name: \"second\"\n";
Assert.AreEqual( expected, yaml );
}
[TestMethod]
public void ArraysOfScalarsRenderAsBlockSequence()
{
var json = "{\"tags\":[\"a\",\"b\",\"c\"]}";
var yaml = SyncToolYamlRenderer.RenderFromJson( json );
Assert.AreEqual( "tags:\n - \"a\"\n - \"b\"\n - \"c\"\n", yaml );
}
[TestMethod]
public void TopLevelArrayRenders()
{
var json = "[1,2,3]";
var yaml = SyncToolYamlRenderer.RenderFromJson( json );
Assert.AreEqual( "- 1\n- 2\n- 3\n", yaml );
}
[TestMethod]
public void KeysWithNonIdentifierCharactersAreQuoted()
{
var json = "{\"weird key\":1,\"x:y\":2}";
var yaml = SyncToolYamlRenderer.RenderFromJson( json );
// Bare YAML keys must not contain spaces or colons, so the renderer
// quotes them. Sort order is by raw key (Ordinal).
StringAssert.Contains( yaml, "\"weird key\": 1" );
StringAssert.Contains( yaml, "\"x:y\": 2" );
}
[TestMethod]
public void EmptyNestedObjectAndArrayUseFlowStyle()
{
var json = "{\"obj\":{},\"arr\":[]}";
var yaml = SyncToolYamlRenderer.RenderFromJson( json );
Assert.AreEqual( "arr: []\nobj: {}\n", yaml );
}
}
#nullable enable
using Hexagon.V2.Application;
using Hexagon.V2.Domain;
using Hexagon.V2.Kernel;
using Hexagon.V2.Kernel.Events;
using Hexagon.V2.Persistence;
namespace Hexagon.V2.Tests.Application;
[TestClass]
public sealed class SceneAndAggregateMutationTests
{
[TestMethod]
public async Task SceneStateUsesRegisteredTypesAndPublishesOnlyCommittedReplacement()
{
await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
var service = new SceneEntityStateService(
environment.Repositories,
environment.Schema,
ApplicationServiceTestEnvironment.AllowPolicy<SceneEntityStateMutationContext>());
var sceneEntityId = SceneEntityId.New();
var actor = new AccountId(9101);
var initial = ApplicationServiceTestEnvironment.StatePayload("closed");
var ensured = await service.EnsureAsync(sceneEntityId, "door", initial);
var replaced = await service.ReplaceStateAsync(
actor, null, sceneEntityId, ApplicationServiceTestEnvironment.StatePayload("open"));
var incompatible = await service.ReplaceStateAsync(
actor, null, sceneEntityId, ApplicationServiceTestEnvironment.StatePayload("future", version: 2));
environment.Provider.FailNextCommit();
var failedCommit = await service.ReplaceStateAsync(
actor, null, sceneEntityId, ApplicationServiceTestEnvironment.StatePayload("unpublished"));
Assert.IsTrue(ensured.Succeeded, ensured.Error?.Message);
Assert.IsTrue(replaced.Succeeded, replaced.Error?.Message);
Assert.AreEqual(ErrorCode.PersistedTypeInvalid, incompatible.Error!.Code);
Assert.AreEqual(ErrorCode.InternalError, failedCommit.Error!.Code);
var stored = service.Find(sceneEntityId)!;
Assert.AreEqual("open", stored.State.Data.GetProperty("name").GetString());
Assert.AreEqual(ApplicationServiceTestEnvironment.StateTypeId, stored.State.TypeId.Value);
Assert.AreEqual(1, stored.State.TypeVersion);
}
[TestMethod]
public async Task TouchLastPlayedReturnsOnlyProviderIssuedDurableReceipt()
{
await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
var account = new AccountId( 9103 );
var character = ApplicationServiceTestEnvironment.Character( account, 0 ) with
{
LastPlayedAt = DateTimeOffset.UnixEpoch
};
await environment.SeedAsync( unitOfWork =>
unitOfWork.Create( environment.Repositories.Characters, DomainKeys.Character( character.Id ), character ) );
var service = new AggregateMutationService(
environment.Repositories,
environment.Schema,
ApplicationServiceTestEnvironment.AllowPolicy<CharacterMutationContext>(),
ApplicationServiceTestEnvironment.AllowPolicy<ItemTraitMutationContext>() );
var timestamp = DateTimeOffset.UnixEpoch.AddMinutes( 1 );
var touched = await service.TouchLastPlayedAsync( account, character.Id, timestamp );
Assert.IsTrue( touched.Succeeded, touched.Error?.Message );
Assert.AreEqual( CharacterMutationKind.TouchLastPlayed, touched.Value.Kind );
Assert.AreEqual( character.LastPlayedAt, touched.Value.Before.LastPlayedAt );
Assert.AreEqual( timestamp, touched.Value.After.LastPlayedAt );
Assert.IsGreaterThan( 0L, touched.Value.Commit.Sequence );
Assert.IsTrue( touched.Value.Commit.Documents.Any( document =>
document.Address.Collection == DomainCollections.Characters &&
document.Address.Key == DomainKeys.Character( character.Id ) ) );
environment.Provider.FailNextCommit();
var failed = await service.TouchLastPlayedAsync(
account, character.Id, timestamp.AddMinutes( 1 ) );
Assert.IsTrue( failed.Failed );
Assert.AreEqual( ErrorCode.InternalError, failed.Error!.Code );
Assert.AreEqual( timestamp, environment.Repositories.Characters.Find(
DomainKeys.Character( character.Id ) )!.Value.LastPlayedAt );
}
[TestMethod]
public async Task PreparedTouchStagesIntoCombinedCommitAndPublishesOnlyOnMatchingCompletion()
{
await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
var account = new AccountId( 9104 );
var character = ApplicationServiceTestEnvironment.Character( account, 0 ) with
{
LastPlayedAt = DateTimeOffset.UnixEpoch
};
var item = ApplicationServiceTestEnvironment.Item();
await environment.SeedAsync( unitOfWork =>
{
unitOfWork.Create(
environment.Repositories.Characters, DomainKeys.Character( character.Id ), character );
unitOfWork.Create( environment.Repositories.Items, DomainKeys.Item( item.Id ), item );
} );
var events = new RecordingCharacterChangedHandler();
var service = new AggregateMutationService(
environment.Repositories,
environment.Schema,
ApplicationServiceTestEnvironment.AllowPolicy<CharacterMutationContext>(),
ApplicationServiceTestEnvironment.AllowPolicy<ItemTraitMutationContext>(),
new PostCommitEventBus<CharacterChangedEvent>( new[]
{
new EventHandlerRegistration<CharacterChangedEvent>( "record", events )
} ) );
var timestamp = DateTimeOffset.UnixEpoch.AddMinutes( 1 );
var prepared = service.PrepareTouchLastPlayed( account, character.Id, timestamp );
Assert.IsTrue( prepared.Succeeded, prepared.Error?.Message );
var unitOfWork = environment.Repositories.Provider.BeginUnitOfWork();
Assert.IsTrue( service.StageTouchLastPlayed( unitOfWork, prepared.Value ).Succeeded );
var itemDocument = environment.Repositories.Items.Find( DomainKeys.Item( item.Id ) )!;
var itemEditor = unitOfWork.Edit( environment.Repositories.Items, itemDocument )!;
itemEditor.Replace( itemEditor.Value with { Revision = 1 } );
unitOfWork.Save( itemEditor );
var committed = await unitOfWork.CommitAsync();
await unitOfWork.DisposeAsync();
Assert.IsTrue( committed.Succeeded, committed.Error?.Message );
Assert.HasCount( 2, committed.Value!.Documents );
Assert.IsEmpty( events.Events );
var premature = service.CompleteTouchLastPlayed(
prepared.Value, new CommitReceipt( committed.Value.Sequence, Array.Empty<CommittedDocumentVersion>() ) );
Assert.AreEqual( ErrorCode.InvariantViolation, premature.Error!.Code );
Assert.IsEmpty( events.Events );
var completed = service.CompleteTouchLastPlayed( prepared.Value, committed.Value );
Assert.IsTrue( completed.Succeeded, completed.Error?.Message );
Assert.AreSame( committed.Value, completed.Value.Commit );
Assert.AreEqual( timestamp, completed.Value.After.LastPlayedAt );
Assert.HasCount( 1, events.Events );
Assert.AreEqual( committed.Value.Sequence, events.Events[0].CommitSequence );
}
[TestMethod]
public async Task CompletionSucceedsFromTheReceiptAloneAfterAnInterleavedCommit()
{
await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
var account = new AccountId( 9106 );
var character = ApplicationServiceTestEnvironment.Character( account, 0 ) with
{
LastPlayedAt = DateTimeOffset.UnixEpoch
};
await environment.SeedAsync( unitOfWork =>
unitOfWork.Create(
environment.Repositories.Characters, DomainKeys.Character( character.Id ), character ) );
var events = new RecordingCharacterChangedHandler();
var service = new AggregateMutationService(
environment.Repositories,
environment.Schema,
ApplicationServiceTestEnvironment.AllowPolicy<CharacterMutationContext>(),
ApplicationServiceTestEnvironment.AllowPolicy<ItemTraitMutationContext>(),
new PostCommitEventBus<CharacterChangedEvent>( new[]
{
new EventHandlerRegistration<CharacterChangedEvent>( "record", events )
} ) );
var timestamp = DateTimeOffset.UnixEpoch.AddMinutes( 1 );
var prepared = service.PrepareTouchLastPlayed( account, character.Id, timestamp );
Assert.IsTrue( prepared.Succeeded, prepared.Error?.Message );
var unitOfWork = environment.Repositories.Provider.BeginUnitOfWork();
Assert.IsTrue( service.StageTouchLastPlayed( unitOfWork, prepared.Value ).Succeeded );
var committed = await unitOfWork.CommitAsync();
await unitOfWork.DisposeAsync();
Assert.IsTrue( committed.Succeeded, committed.Error?.Message );
var current = environment.Repositories.Characters.Find( DomainKeys.Character( character.Id ) )!;
var competingUnit = environment.Repositories.Provider.BeginUnitOfWork();
var competingEditor = competingUnit.Edit( environment.Repositories.Characters, current )!;
competingEditor.Replace( competingEditor.Value with
{
LastPlayedAt = DateTimeOffset.UnixEpoch.AddMinutes( 2 )
} );
competingUnit.Save( competingEditor );
Assert.IsTrue( (await competingUnit.CommitAsync()).Succeeded );
await competingUnit.DisposeAsync();
var completed = service.CompleteTouchLastPlayed( prepared.Value, committed.Value! );
Assert.IsTrue( completed.Succeeded,
"A durably committed mutation must complete from its receipt even after an interleaved commit." );
Assert.AreEqual( timestamp, completed.Value.After.LastPlayedAt );
Assert.HasCount( 1, events.Events );
}
[TestMethod]
public async Task PreparedTouchCommitFailureAndRevisionConflictEmitNoEvent()
{
await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
var account = new AccountId( 9105 );
var character = ApplicationServiceTestEnvironment.Character( account, 0 ) with
{
LastPlayedAt = DateTimeOffset.UnixEpoch
};
await environment.SeedAsync( unitOfWork =>
unitOfWork.Create(
environment.Repositories.Characters, DomainKeys.Character( character.Id ), character ) );
var events = new RecordingCharacterChangedHandler();
var service = new AggregateMutationService(
environment.Repositories,
environment.Schema,
ApplicationServiceTestEnvironment.AllowPolicy<CharacterMutationContext>(),
ApplicationServiceTestEnvironment.AllowPolicy<ItemTraitMutationContext>(),
new PostCommitEventBus<CharacterChangedEvent>( new[]
{
new EventHandlerRegistration<CharacterChangedEvent>( "record", events )
} ) );
var failedPlan = service.PrepareTouchLastPlayed(
account, character.Id, DateTimeOffset.UnixEpoch.AddMinutes( 1 ) );
var failedUnit = environment.Repositories.Provider.BeginUnitOfWork();
Assert.IsTrue( service.StageTouchLastPlayed( failedUnit, failedPlan.Value ).Succeeded );
environment.Provider.FailNextCommit();
var failedCommit = await failedUnit.CommitAsync();
await failedUnit.DisposeAsync();
Assert.IsFalse( failedCommit.Succeeded );
Assert.IsEmpty( events.Events );
Assert.AreEqual( DateTimeOffset.UnixEpoch, environment.Repositories.Characters.Find(
DomainKeys.Character( character.Id ) )!.Value.LastPlayedAt );
var conflictedPlan = service.PrepareTouchLastPlayed(
account, character.Id, DateTimeOffset.UnixEpoch.AddMinutes( 2 ) );
var staleUnit = environment.Repositories.Provider.BeginUnitOfWork();
Assert.IsTrue( service.StageTouchLastPlayed( staleUnit, conflictedPlan.Value ).Succeeded );
var current = environment.Repositories.Characters.Find( DomainKeys.Character( character.Id ) )!;
var competingUnit = environment.Repositories.Provider.BeginUnitOfWork();
var competingEditor = competingUnit.Edit( environment.Repositories.Characters, current )!;
competingEditor.Replace( competingEditor.Value with
{
LastPlayedAt = DateTimeOffset.UnixEpoch.AddMinutes( 3 )
} );
competingUnit.Save( competingEditor );
var competingCommit = await competingUnit.CommitAsync();
await competingUnit.DisposeAsync();
Assert.IsTrue( competingCommit.Succeeded, competingCommit.Error?.Message );
var conflict = await staleUnit.CommitAsync();
await staleUnit.DisposeAsync();
Assert.IsFalse( conflict.Succeeded );
Assert.AreEqual( PersistenceErrorCode.RevisionConflict, conflict.Error!.Code );
Assert.IsEmpty( events.Events );
// Completion validates from the receipt alone; a receipt that does not contain the
// prepared revision is rejected without publishing.
var wrongCompletion = service.CompleteTouchLastPlayed(
conflictedPlan.Value,
new CommitReceipt( competingCommit.Value!.Sequence, Array.Empty<CommittedDocumentVersion>() ) );
Assert.AreEqual( ErrorCode.InvariantViolation, wrongCompletion.Error!.Code );
Assert.IsEmpty( events.Events );
}
[TestMethod]
public async Task AggregateMutationFailuresLeaveTimestampBanBalanceStateAndTraitsUntouched()
{
await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
var account = new AccountId(9102);
var character = ApplicationServiceTestEnvironment.Character(account, 0) with
{
Balance = 10,
LastPlayedAt = DateTimeOffset.UnixEpoch.AddHours(2)
};
var item = ApplicationServiceTestEnvironment.Item() with
{
Traits = new Dictionary<string, TypedPayload>(StringComparer.Ordinal)
{
["state"] = ApplicationServiceTestEnvironment.StatePayload("old")
}
};
await environment.SeedAsync(unitOfWork =>
{
unitOfWork.Create(environment.Repositories.Characters, DomainKeys.Character(character.Id), character);
unitOfWork.Create(environment.Repositories.Items, DomainKeys.Item(item.Id), item);
});
var service = new AggregateMutationService(
environment.Repositories,
environment.Schema,
ApplicationServiceTestEnvironment.AllowPolicy<CharacterMutationContext>(),
ApplicationServiceTestEnvironment.AllowPolicy<ItemTraitMutationContext>());
var oldTimestamp = await service.TouchLastPlayedAsync(
account, character.Id, DateTimeOffset.UnixEpoch);
var nonUtcBan = await service.SetBanAsync(
account,
character.Id,
true,
new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.FromHours(1)));
var insufficientFunds = await service.DebitAsync(account, character.Id, 11);
var unknownState = await service.ReplaceSchemaStateAsync(
account,
character.Id,
ApplicationServiceTestEnvironment.StatePayload(typeId: "unknown.state"));
var unknownTrait = await service.ReplaceTraitAsync(
account,
character.Id,
item.Id,
"state",
ApplicationServiceTestEnvironment.StatePayload(typeId: "unknown.trait"));
environment.Provider.FailNextCommit();
var failedCredit = await service.CreditAsync(account, character.Id, 1);
environment.Provider.FailNextCommit();
var failedTraitCommit = await service.ReplaceTraitAsync(
account,
character.Id,
item.Id,
"state",
ApplicationServiceTestEnvironment.StatePayload("new"));
Assert.AreEqual(ErrorCode.InvalidArgument, oldTimestamp.Error!.Code);
Assert.AreEqual(ErrorCode.InvalidArgument, nonUtcBan.Error!.Code);
Assert.AreEqual(ErrorCode.Conflict, insufficientFunds.Error!.Code);
Assert.AreEqual(ErrorCode.PersistedTypeInvalid, unknownState.Error!.Code);
Assert.AreEqual(ErrorCode.PersistedTypeInvalid, unknownTrait.Error!.Code);
Assert.AreEqual(ErrorCode.InternalError, failedCredit.Error!.Code);
Assert.AreEqual(ErrorCode.InternalError, failedTraitCommit.Error!.Code);
var storedCharacter = environment.Repositories.Characters.Find(DomainKeys.Character(character.Id))!.Value;
var storedItem = environment.Repositories.Items.Find(DomainKeys.Item(item.Id))!.Value;
Assert.AreEqual(character.LastPlayedAt, storedCharacter.LastPlayedAt);
Assert.IsFalse(storedCharacter.IsBanned);
Assert.IsNull(storedCharacter.BanExpiresAt);
Assert.AreEqual(10L, storedCharacter.Balance);
Assert.AreEqual("citizen", storedCharacter.SchemaState.Data.GetProperty("name").GetString());
Assert.AreEqual("old", storedItem.Traits["state"].Data.GetProperty("name").GetString());
}
private sealed class RecordingCharacterChangedHandler : IEventHandler<CharacterChangedEvent>
{
public List<CharacterChangedEvent> Events { get; } = new();
public void Handle( CharacterChangedEvent @event ) => Events.Add( @event );
}
}
using Hexagon.V2.Application;
using Hexagon.V2.Domain;
namespace Hexagon.V2.Tests.Application;
[TestClass]
public sealed class SceneIdentityValidatorTests
{
[TestMethod]
public void ProvenanceClassifier_TrustsOnlyCapturedNonNetworkComponents()
{
var authored = Guid.NewGuid();
var laterRuntime = Guid.NewGuid();
var classifier = new SceneIdentityProvenanceClassifier();
Assert.AreEqual(
SceneIdentityProvenance.RuntimeOrNetwork,
classifier.Classify( authored, hasActiveNetworkRoot: false ) );
classifier.CaptureAuthoredSnapshot( new[] { authored } );
Assert.IsTrue( classifier.HasAuthoredSnapshot );
Assert.AreEqual( 1, classifier.AuthoredComponentCount );
Assert.AreEqual(
SceneIdentityProvenance.EditorAuthored,
classifier.Classify( authored, hasActiveNetworkRoot: false ) );
Assert.AreEqual(
SceneIdentityProvenance.RuntimeOrNetwork,
classifier.Classify( authored, hasActiveNetworkRoot: true ) );
Assert.AreEqual(
SceneIdentityProvenance.RuntimeOrNetwork,
classifier.Classify( laterRuntime, hasActiveNetworkRoot: false ) );
}
[TestMethod]
public void ProvenanceClassifier_NewSceneSnapshotReplacesPriorAuthority()
{
var firstScene = Guid.NewGuid();
var secondScene = Guid.NewGuid();
var classifier = new SceneIdentityProvenanceClassifier();
classifier.CaptureAuthoredSnapshot( new[] { firstScene } );
classifier.CaptureAuthoredSnapshot( new[] { secondScene } );
Assert.AreEqual(
SceneIdentityProvenance.RuntimeOrNetwork,
classifier.Classify( firstScene, hasActiveNetworkRoot: false ) );
Assert.AreEqual(
SceneIdentityProvenance.EditorAuthored,
classifier.Classify( secondScene, hasActiveNetworkRoot: false ) );
}
[TestMethod]
public void EditorRepair_FillsMissingAndRepairsOnlyLaterDuplicate()
{
var duplicate = new SceneEntityId( Guid.Parse( "11111111-1111-1111-1111-111111111111" ) );
var generated = new Queue<SceneEntityId>( new[]
{
new SceneEntityId( Guid.Parse( "22222222-2222-2222-2222-222222222222" ) ),
new SceneEntityId( Guid.Parse( "33333333-3333-3333-3333-333333333333" ) )
} );
var result = SceneIdentityValidator.RepairForEditor( new[]
{
new SceneIdentityCandidate( "a", duplicate ),
new SceneIdentityCandidate( "b", duplicate ),
new SceneIdentityCandidate( "c", null )
}, () => generated.Dequeue() );
Assert.IsFalse( result[0].Repaired );
Assert.IsTrue( result[1].Repaired );
Assert.IsTrue( result[2].Repaired );
Assert.AreNotEqual( result[0].EffectiveId, result[1].EffectiveId );
}
[TestMethod]
public void RuntimeValidation_DisablesEveryAmbiguousOrBlankEntity()
{
var duplicate = new SceneEntityId( Guid.Parse( "11111111-1111-1111-1111-111111111111" ) );
var result = SceneIdentityValidator.ValidateRuntime( new[]
{
new SceneIdentityCandidate( "a", duplicate ),
new SceneIdentityCandidate( "b", duplicate ),
new SceneIdentityCandidate( "c", null )
} );
Assert.IsTrue( result.All( value => !value.Enabled && value.FatalDiagnostic is not null ) );
}
[TestMethod]
public void PersistentIndexEnumeratesTenThousandCandidatesOnceAndProvidesConstantTimeLookups()
{
var ids = Enumerable.Range( 0, 10_000 ).Select( _ => SceneEntityId.New() ).ToArray();
var enumerated = 0;
IEnumerable<SceneIdentityCandidate> Candidates()
{
for ( var index = 0; index < ids.Length; index++ )
{
enumerated++;
yield return new SceneIdentityCandidate( $"entity/{index}", ids[index] );
}
}
var index = PersistentSceneIdentityIndex.Build( Candidates() );
Assert.AreEqual( 10_000, enumerated );
Assert.AreEqual( 10_000, index.Count );
for ( var candidate = 0; candidate < ids.Length; candidate++ )
{
Assert.IsTrue( index.TryResolveId( ids[candidate], out var byId ) );
Assert.IsTrue( index.TryResolvePath( $"entity/{candidate}", out var byPath ) );
Assert.AreSame( byId, byPath );
}
}
[TestMethod]
public void RuntimeNetworkDuplicateCannotPoisonEditorAuthoredIdentity()
{
var duplicate = SceneEntityId.New();
var initial = PersistentSceneIdentityIndex.Build( new[]
{
new SceneIdentityCandidate( "first", duplicate )
} );
Assert.IsTrue( initial.TryResolveId( duplicate, out _ ) );
var rebuilt = PersistentSceneIdentityIndex.Build( new[]
{
new SceneIdentityCandidate( "first", duplicate ),
new SceneIdentityCandidate(
"first", duplicate, SceneIdentityProvenance.RuntimeOrNetwork )
} );
Assert.IsTrue( rebuilt.TryResolveId( duplicate, out var resolved ) );
Assert.AreEqual( "first", resolved.StablePath );
Assert.IsTrue( rebuilt.Resolutions[0].Enabled );
Assert.IsFalse( rebuilt.Resolutions[1].Enabled );
Assert.AreEqual( SceneIdentityProvenance.RuntimeOrNetwork, rebuilt.Resolutions[1].Provenance );
Assert.IsNull( rebuilt.Resolutions[1].EffectiveId );
StringAssert.Contains( rebuilt.Resolutions[1].FatalDiagnostic, "runtime/network root" );
}
[TestMethod]
public void TwoEditorAuthoredDuplicatesStillFailClosed()
{
var duplicate = SceneEntityId.New();
var rebuilt = PersistentSceneIdentityIndex.Build( new[]
{
new SceneIdentityCandidate( "first", duplicate ),
new SceneIdentityCandidate( "second", duplicate )
} );
Assert.IsFalse( rebuilt.TryResolveId( duplicate, out _ ) );
Assert.IsTrue( rebuilt.Resolutions.All( resolution => !resolution.Enabled ) );
}
}
using System.IO;
namespace Hexagon.V2.Tests.Foundation;
[TestClass]
public sealed class SandboxCompatibilityTests
{
private static readonly string[] ForbiddenSourceTokens =
[
".ConfigureAwait(",
"Volatile.",
"ReaderWriterLockSlim",
"CryptographicOperations.FixedTimeEquals",
".IsInterface",
".IsByRef",
".IsPointer",
".IsInstanceOfType",
"ExceptionDispatchInfo",
"System.Reflection",
"RuntimeHelpers.",
"Activator.CreateInstance",
".GetProperties(",
".GetFields(",
".MakeGenericType(",
".GetGenericArguments(",
"await using",
"Task.WhenAll(",
"TaskScheduler",
"Environment.ProcessId"
];
private static readonly (string Name, string Pattern)[] ForbiddenRawIoSignatures =
[
("File static API", @"\bFile\s*\."),
("Directory static API", @"\bDirectory\s*\."),
("Path.GetFullPath", @"\bPath\s*\.\s*GetFullPath\s*\("),
("FileStream", @"\bFileStream\b"),
("FileInfo", @"\bFileInfo\b"),
("DirectoryInfo", @"\bDirectoryInfo\b"),
("FileMode", @"\bFileMode\b"),
("FileAccess", @"\bFileAccess\b"),
("FileShare", @"\bFileShare\b"),
("FileOptions", @"\bFileOptions\b"),
("SearchOption", @"\bSearchOption\b")
];
[TestMethod]
public void SandboxIndependentV2CodeAvoidsKnownWhitelistViolations()
{
var root = FindHexagonRoot();
var sourceRoots = new[] { Path.Combine( root, "Code", "V2" ) }
.Where( Directory.Exists )
.ToArray();
var violations = new List<string>();
foreach ( var path in sourceRoots.SelectMany( sourceRoot =>
Directory.GetFiles( sourceRoot, "*.cs", SearchOption.AllDirectories ) )
.Where( path => !path.Contains(
$"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}",
StringComparison.OrdinalIgnoreCase ) ) )
{
var source = File.ReadAllText( path );
if ( System.Text.RegularExpressions.Regex.IsMatch(
source,
@"^\s*(?:private|protected|internal|public)\s+(?:static\s+)?volatile\s+",
System.Text.RegularExpressions.RegexOptions.Multiline ) )
violations.Add(
$"{Path.GetRelativePath( root, path )} declares a volatile field (lowers to forbidden IsVolatile)" );
if ( System.Text.RegularExpressions.Regex.IsMatch(
source,
@"finally\s*\{[^{}]*\bawait\b",
System.Text.RegularExpressions.RegexOptions.Singleline ) )
violations.Add(
$"{Path.GetRelativePath( root, path )} awaits inside a finally block (lowers to forbidden ExceptionDispatchInfo)" );
if ( System.Text.RegularExpressions.Regex.IsMatch(
source,
@"catch(?:\s*\([^)]*\))?\s*\{[^{}]*\bawait\b[^{}]*\bthrow\s*;",
System.Text.RegularExpressions.RegexOptions.Singleline ) )
violations.Add(
$"{Path.GetRelativePath( root, path )} awaits before a bare catch rethrow (lowers to forbidden ExceptionDispatchInfo)" );
var lines = File.ReadAllLines( path );
for ( var index = 0; index < lines.Length; index++ )
{
foreach ( var token in ForbiddenSourceTokens )
{
if ( lines[index].Contains( token, StringComparison.Ordinal ) )
violations.Add( $"{Path.GetRelativePath( root, path )}:{index + 1} contains '{token}'" );
}
foreach ( var signature in FindForbiddenRawIoSignatures( lines[index] ) )
violations.Add(
$"{Path.GetRelativePath( root, path )}:{index + 1} uses forbidden raw I/O signature '{signature}'" );
}
}
Assert.HasCount(
0,
violations,
"Known s&box whitelist violations were found:" + Environment.NewLine + string.Join( Environment.NewLine, violations ) );
}
[TestMethod]
public void RawIoSignatureGuardCoversOriginalSb1000SurfaceWithoutFalsePositives()
{
// Keep fixtures split so the production-source scanner can never match this test file if its roots expand.
var separator = string.Empty;
var forbiddenFixtures = new[]
{
"File" + separator + ".Exists( path )",
"Path" + separator + ".GetFullPath( path )",
"File" + separator + "Stream? lease",
"File" + separator + "Info info",
"Directory" + separator + "Info directory",
"File" + separator + "Mode.OpenOrCreate",
"File" + separator + "Access.ReadWrite",
"File" + separator + "Share.None",
"File" + separator + "Options.WriteThrough",
"Search" + separator + "Option.AllDirectories",
"Directory" + separator + " . EnumerateFiles( root )"
};
foreach ( var fixture in forbiddenFixtures )
Assert.IsNotEmpty(
FindForbiddenRawIoSignatures( fixture ),
$"Expected raw I/O fixture to be rejected: {fixture}" );
var allowedFixtures = new[]
{
"var path = issue.Path;",
"var fileName = record.FileName;",
"IReadOnlyList<string> directories",
"storage.ExistsAsync( path, cancellationToken )"
};
foreach ( var fixture in allowedFixtures )
Assert.IsEmpty(
FindForbiddenRawIoSignatures( fixture ),
$"Expected non-I/O fixture to remain allowed: {fixture}" );
}
private static string[] FindForbiddenRawIoSignatures( string sourceLine ) => ForbiddenRawIoSignatures
.Where( signature => System.Text.RegularExpressions.Regex.IsMatch(
sourceLine,
signature.Pattern,
System.Text.RegularExpressions.RegexOptions.CultureInvariant ) )
.Select( signature => signature.Name )
.ToArray();
private static string FindHexagonRoot()
{
var directory = new DirectoryInfo( AppContext.BaseDirectory );
while ( directory is not null && !File.Exists( Path.Combine( directory.FullName, "hexagon.sbproj" ) ) )
directory = directory.Parent;
Assert.IsNotNull( directory, "Could not locate the Hexagon repository root." );
return directory.FullName;
}
}
#nullable enable
using System;
using System.IO;
using System.Text.RegularExpressions;
using Hexagon.V2.Networking;
using Hexagon.V2.Tests.Foundation;
namespace Hexagon.V2.Tests.Networking;
/// <summary>
/// Sustained-run guards for the per-connection command budget, and a pin tying the numbers
/// <c>docs/security.md</c> publishes to the constants that enforce them.
/// <para>
/// The movement validator's failure was that a per-tick allowance was never checked as a rate, so prose
/// describing a bound and code granting one every tick could both read as true while disagreeing by
/// fifty times. Every other rate-limited surface therefore gets the same treatment: the published number
/// is read out of the document and asserted against the constant, and the constant is asserted against
/// what a client can actually extract over a run.
/// </para>
/// </summary>
[TestClass]
public sealed class CommandAdmissionSustainedTests
{
private const long Frequency = 1000; // timestamps in milliseconds
private const int Hz = 50;
private const int TickMilliseconds = 1000 / Hz;
private const int Ticks = 10 * Hz; // ten seconds
private const double ElapsedSeconds = 10.0;
[TestMethod]
public void TheSecurityDocumentPublishesTheBudgetThatIsActuallyEnforced()
{
// docs/security.md: "a weighted per-connection token bucket before payload construction:
// 16-unit burst, 8 units/second refill, and at most 16 active requests."
// Reading the numbers out of the document makes the prose a claim this suite owns: editing
// either side without the other fails here rather than shipping a document that describes a
// system nobody built.
var document = File.ReadAllText( Path.Combine( RepositoryRoot(), "docs", "security.md" ) );
var match = Regex.Match( document,
@"(?<burst>\d+)-unit burst,\s*(?<refill>\d+)\s*units?/second refill,\s*and at most\s*(?<active>\d+)\s*active requests" );
Assert.IsTrue( match.Success,
"docs/security.md no longer states the command budget in the form this guard reads. " +
"Update the guard deliberately rather than letting the published numbers go unchecked." );
Assert.AreEqual( CommandAdmissionController.BurstUnits, int.Parse( match.Groups["burst"].Value ),
"Documented burst does not match the enforced constant." );
Assert.AreEqual( CommandAdmissionController.RefillUnitsPerSecond, int.Parse( match.Groups["refill"].Value ),
"Documented refill rate does not match the enforced constant." );
Assert.AreEqual( CommandAdmissionController.MaximumActiveRequests, int.Parse( match.Groups["active"].Value ),
"Documented active-request cap does not match the enforced constant." );
}
[TestMethod]
public void SustainedAdmissionConvergesOnTheRefillRateNotTheBurst()
{
// The burst is a one-off; the refill rate is the ceiling. A bucket that re-granted its burst
// the way the movement validator re-granted its skin would admit 800 units here, not 96.
var controller = new CommandAdmissionController( Frequency );
var admittedCount = 0;
var run = SustainedEnvelope.Measure( Ticks, 1.0 / Hz, tick =>
{
var timestamp = (long)tick * TickMilliseconds;
var requestId = new CommandRequestId( Guid.NewGuid() );
var result = controller.TryBegin( requestId, cost: 1, timestamp );
if ( !result.Accepted ) return SustainedEnvelope.StepOutcome.Rejected;
controller.Finish( requestId ); // keep the active-request bound out of the way
admittedCount++;
return SustainedEnvelope.StepOutcome.Allowed( 1 );
} );
var bound = (CommandAdmissionController.RefillUnitsPerSecond * ElapsedSeconds)
+ CommandAdmissionController.BurstUnits;
Assert.IsLessThanOrEqualTo( bound, run.TotalWork, SustainedEnvelope.Describe(
$"Sustained command admission over 10s ({run.WorkPerSecond:F1}/s)",
run.TotalWork, bound, " units" ) );
Assert.IsGreaterThan( 0, admittedCount,
"The run admitted nothing, so the bound above proves nothing about the bucket." );
}
[TestMethod]
public void RejectedAttemptsAreNotRefundedAcrossASustainedRun()
{
// docs/security.md: "Rejected, duplicate, and malformed attempts are not refunded." A refund on
// rejection would make the bucket a no-op under exactly the load it exists to bound.
var controller = new CommandAdmissionController( Frequency );
var duplicate = new CommandRequestId( Guid.NewGuid() );
// Drain the burst with duplicates, which are charged and then refused.
for ( var i = 0; i < CommandAdmissionController.BurstUnits; i++ )
controller.TryBegin( duplicate, cost: 1, 0 );
Assert.IsLessThanOrEqualTo( 0.001, controller.AvailableUnits,
$"Duplicates were refunded: {controller.AvailableUnits:F2} units remain after draining the burst." );
}
[TestMethod]
public void ACostlierCommandDrainsTheBudgetProportionally()
{
// The bucket is weighted, so an expensive command must consume its weight rather than one slot.
var controller = new CommandAdmissionController( Frequency );
var admitted = 0;
for ( var i = 0; i < 100; i++ )
{
var requestId = new CommandRequestId( Guid.NewGuid() );
if ( !controller.TryBegin( requestId, CommandAdmissionController.BurstUnits, 0 ).Accepted ) break;
controller.Finish( requestId );
admitted++;
}
Assert.AreEqual( 1, admitted,
"A full-burst-cost command should be admissible exactly once from a full bucket." );
}
private static string RepositoryRoot()
{
var directory = new DirectoryInfo( AppContext.BaseDirectory );
while ( directory is not null && !File.Exists( Path.Combine( directory.FullName, "hexagon.sbproj" ) ) )
directory = directory.Parent;
Assert.IsNotNull( directory, "Could not locate the Hexagon repository root." );
return directory!.FullName;
}
}
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Hexagon.V2.Domain;
using Hexagon.V2.Kernel;
using Hexagon.V2.Networking;
namespace Hexagon.V2.Tests.Networking;
[TestClass]
public sealed class CommandDispatchOrchestratorTests
{
[TestMethod]
public void UnauthenticatedCallerIsRejectedBeforeAnyChargeOrResolution()
{
var host = new FakeDispatchHost { Authenticated = false };
CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );
Assert.IsEmpty( host.Calls, "Unauthenticated traffic must never reach the per-connection bucket." );
Assert.HasCount( 1, host.Sent );
Assert.AreEqual( "caller", host.Sent[0].Target );
Assert.AreEqual( ErrorCode.Unauthorized, host.Sent[0].Result.Error!.Code );
}
[TestMethod]
public void ChargeHappensBeforeResolutionAndCarriesTheDeclaredCost()
{
var host = new FakeDispatchHost();
CommandDispatchOrchestrator.Dispatch( host, Header(), 3, static () => new RequestCharacterListCommand() );
Assert.AreEqual( "charge:3", host.Calls[0], "The caller is charged before its scope is inspected." );
Assert.AreEqual( "resolve:initial", host.Calls[1] );
}
[TestMethod]
public void RateLimitedAdmissionCarriesRetryAfterAndSkipsResolution()
{
var host = new FakeDispatchHost
{
Admission = CommandAdmissionResult.Reject(
CommandAdmissionFailure.RateLimited, TimeSpan.FromSeconds( 2 ) )
};
CommandDispatchOrchestrator.Dispatch( host, Header(), 4, static () => new RequestCharacterListCommand() );
CollectionAssert.AreEqual( new[] { "charge:4" }, host.Calls );
Assert.HasCount( 1, host.Sent );
Assert.AreEqual( ErrorCode.RateLimited, host.Sent[0].Result.Error!.Code );
}
[TestMethod]
public void DuplicateRequestIdsConflict()
{
var host = new FakeDispatchHost
{
Admission = CommandAdmissionResult.Reject( CommandAdmissionFailure.Duplicate )
};
CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );
Assert.AreEqual( ErrorCode.Conflict, host.Sent[0].Result.Error!.Code );
}
[TestMethod]
public void ResolutionFailureIsForwardedAndTheAcceptedChargeIsClosed()
{
var host = new FakeDispatchHost
{
Resolution = OperationResult<string>.Failure( ErrorCode.Unauthorized, "scope mismatch" )
};
CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );
CollectionAssert.AreEqual(
new[] { "charge:1", "resolve:initial", "finish-rejected" },
host.Calls );
Assert.AreEqual( "scope mismatch", host.Sent[0].Result.Error!.Message );
}
[TestMethod]
public void EmptyRequestIdCompletesWithInvalidArgument()
{
var host = new FakeDispatchHost();
// Wire deserialization can materialize a default header; the constructor itself
// rejects empty request ids, so the guard is only reachable through default structs.
var header = default( ClientCommandHeader );
CommandDispatchOrchestrator.Dispatch( host, header, 1, static () => new RequestCharacterListCommand() );
CollectionAssert.Contains( host.Calls, "complete" );
Assert.HasCount( 1, host.Sent );
Assert.AreEqual( "actor", host.Sent[0].Target );
Assert.AreEqual( ErrorCode.InvalidArgument, host.Sent[0].Result.Error!.Code );
}
[TestMethod]
public void ThrowingCommandFactoryFailsClosedWithoutStartingAHostOperation()
{
var host = new FakeDispatchHost();
CommandDispatchOrchestrator.Dispatch( host, Header(), 1,
static () => throw new FormatException( "bad wire payload" ) );
Assert.HasCount( 1, host.MalformedPayloads );
CollectionAssert.DoesNotContain( host.Calls, "host-operation" );
Assert.AreEqual( ErrorCode.InvalidArgument, host.Sent[0].Result.Error!.Code );
}
[TestMethod]
public void OversizedPayloadIsRejectedBeforeStableResolutionAndExecution()
{
var host = new FakeDispatchHost();
CommandDispatchOrchestrator.Dispatch( host, Header(), 2,
static () => new SendChatCommand( "ic", new string( 'a', 1_000_000 ) ) );
CollectionAssert.DoesNotContain( host.Calls, "resolve:stable" );
CollectionAssert.DoesNotContain( host.Calls, "host-operation" );
Assert.HasCount( 1, host.Sent );
Assert.IsFalse( host.Sent[0].Result.Succeeded );
}
[TestMethod]
public void StableCharacterCommandsAreReResolvedAfterPayloadValidation()
{
var host = new FakeDispatchHost();
CommandDispatchOrchestrator.Dispatch( host, Header(), 2,
static () => new SendChatCommand( "ic", "hello" ) );
CollectionAssert.AreEqual(
new[]
{
"charge:2", "resolve:initial", "resolve:stable",
"host-operation", "lease-check", "execute", "complete"
},
host.Calls );
Assert.AreEqual( "stable-actor", host.Sent[0].Target,
"Execution and completion must use the re-resolved stable actor." );
}
[TestMethod]
public void StableResolutionFailureCompletesWithoutExecuting()
{
var host = new FakeDispatchHost
{
StableResolution = OperationResult<string>.Failure(
ErrorCode.Unauthorized, "character changed" )
};
CommandDispatchOrchestrator.Dispatch( host, Header(), 2,
static () => new SendChatCommand( "ic", "hello" ) );
CollectionAssert.DoesNotContain( host.Calls, "execute" );
Assert.AreEqual( "character changed", host.Sent[0].Result.Error!.Message );
}
[TestMethod]
public void CharacterLifecycleCommandsSkipTheStableCharacterResolution()
{
foreach ( var factory in new Func<ClientCommand>[]
{
static () => new RequestCharacterListCommand(),
static () => new LoadCharacterCommand( CharacterId.New() ),
static () => new DeleteCharacterCommand( CharacterId.New() ),
static () => new UnloadCharacterCommand()
} )
{
var host = new FakeDispatchHost();
CommandDispatchOrchestrator.Dispatch( host, Header(), 1, factory );
CollectionAssert.DoesNotContain( host.Calls, "resolve:stable",
$"{factory().GetType().Name} must dispatch without an active character." );
Assert.AreEqual( "actor", host.Sent[0].Target );
}
}
[TestMethod]
public void HostOperationRefusalCompletesWithDrainingConflict()
{
var host = new FakeDispatchHost { AcceptHostOperation = false };
CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );
Assert.AreEqual( ErrorCode.Conflict, host.Sent[0].Result.Error!.Code );
StringAssert.Contains( host.StartedOperationName!, "command:" );
}
[TestMethod]
public void StaleLeaseInsideTheHostOperationRejectsBeforeExecution()
{
var host = new FakeDispatchHost { LeaseCurrent = false };
CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );
CollectionAssert.DoesNotContain( host.Calls, "execute" );
Assert.AreEqual( ErrorCode.Unauthorized, host.Sent[0].Result.Error!.Code );
}
[TestMethod]
public void MissingHostApplicationCompletesWithInternalError()
{
var host = new FakeDispatchHost { ExecutionAvailable = false };
CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );
CollectionAssert.DoesNotContain( host.Calls, "execute" );
Assert.AreEqual( ErrorCode.InternalError, host.Sent[0].Result.Error!.Code );
}
[TestMethod]
public void ExecutionFaultBecomesInternalError()
{
var host = new FakeDispatchHost
{
Execution = static () => throw new InvalidOperationException( "handler exploded" )
};
CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );
Assert.HasCount( 1, host.ExecutionFaults );
Assert.AreEqual( ErrorCode.InternalError, host.Sent[0].Result.Error!.Code );
}
[TestMethod]
public void ExecutionFaultUnderCancellationBecomesUnauthorized()
{
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var host = new FakeDispatchHost
{
Cancellation = cancellation.Token,
Execution = static () => throw new OperationCanceledException()
};
CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );
Assert.AreEqual( ErrorCode.Unauthorized, host.Sent[0].Result.Error!.Code );
}
[TestMethod]
public void DisconnectedCompletionSendsNothing()
{
var host = new FakeDispatchHost { CompletionStatus = CommandCompletionStatus.Disconnected };
CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );
Assert.IsEmpty( host.Sent );
}
[TestMethod]
public void StaleCompletionKeepsASuccessfulExecutionResultButRejectsAFailedOne()
{
var success = new FakeDispatchHost { CompletionStatus = CommandCompletionStatus.Stale };
CommandDispatchOrchestrator.Dispatch( success, Header(), 1, static () => new RequestCharacterListCommand() );
Assert.IsTrue( success.Sent[0].Result.Succeeded,
"A successful application result stays authoritative even under a stale lease." );
var failure = new FakeDispatchHost
{
CompletionStatus = CommandCompletionStatus.Stale,
Execution = static () => ValueTask.FromResult(
OperationResult.Failure( ErrorCode.NotFound, "gone" ) )
};
CommandDispatchOrchestrator.Dispatch( failure, Header(), 1, static () => new RequestCharacterListCommand() );
Assert.AreEqual( ErrorCode.Unauthorized, failure.Sent[0].Result.Error!.Code );
}
[TestMethod]
public void SuccessfulExecutionForwardsTheApplicationResult()
{
var host = new FakeDispatchHost
{
Execution = static () => ValueTask.FromResult(
OperationResult.Failure( ErrorCode.PolicyDenied, "application said no" ) )
};
CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );
Assert.AreEqual( ErrorCode.PolicyDenied, host.Sent[0].Result.Error!.Code );
Assert.AreEqual( "application said no", host.Sent[0].Result.Error!.Message );
}
[TestMethod]
public void ThrowingCompletionHookIsObservedWithoutEscaping()
{
var host = new FakeDispatchHost { CompleteThrows = true };
CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );
Assert.HasCount( 1, host.CompletionFaults );
Assert.IsEmpty( host.Sent );
}
[TestMethod]
public void CommandCostTableIsPinned()
{
var table = new Dictionary<string, int>( StringComparer.Ordinal )
{
[nameof( ClientCommandCosts.CharacterList )] = ClientCommandCosts.CharacterList,
[nameof( ClientCommandCosts.CreateCharacter )] = ClientCommandCosts.CreateCharacter,
[nameof( ClientCommandCosts.LoadCharacter )] = ClientCommandCosts.LoadCharacter,
[nameof( ClientCommandCosts.DeleteCharacter )] = ClientCommandCosts.DeleteCharacter,
[nameof( ClientCommandCosts.UnloadCharacter )] = ClientCommandCosts.UnloadCharacter,
[nameof( ClientCommandCosts.MoveItem )] = ClientCommandCosts.MoveItem,
[nameof( ClientCommandCosts.ItemAction )] = ClientCommandCosts.ItemAction,
[nameof( ClientCommandCosts.DropItem )] = ClientCommandCosts.DropItem,
[nameof( ClientCommandCosts.PickupItem )] = ClientCommandCosts.PickupItem,
[nameof( ClientCommandCosts.Chat )] = ClientCommandCosts.Chat,
[nameof( ClientCommandCosts.CancelAction )] = ClientCommandCosts.CancelAction,
[nameof( ClientCommandCosts.BeginInteraction )] = ClientCommandCosts.BeginInteraction,
[nameof( ClientCommandCosts.ContinueInteraction )] = ClientCommandCosts.ContinueInteraction,
[nameof( ClientCommandCosts.CloseInteraction )] = ClientCommandCosts.CloseInteraction,
[nameof( ClientCommandCosts.SchemaCommandFallback )] = ClientCommandCosts.SchemaCommandFallback
};
var expected = new Dictionary<string, int>( StringComparer.Ordinal )
{
["CharacterList"] = 1,
["CreateCharacter"] = 4,
["LoadCharacter"] = 4,
["DeleteCharacter"] = 4,
["UnloadCharacter"] = 1,
["MoveItem"] = 4,
["ItemAction"] = 4,
["DropItem"] = 4,
["PickupItem"] = 4,
["Chat"] = 2,
["CancelAction"] = 1,
["BeginInteraction"] = 2,
["ContinueInteraction"] = 1,
["CloseInteraction"] = 1,
["SchemaCommandFallback"] = 8
};
CollectionAssert.AreEquivalent( expected, table );
}
[TestMethod]
public void OnlyCharacterLifecycleCommandsAreExemptFromTheStableCharacterRequirement()
{
Assert.IsFalse( CommandDispatchOrchestrator.RequiresStableCharacter( new RequestCharacterListCommand() ) );
Assert.IsFalse( CommandDispatchOrchestrator.RequiresStableCharacter(
new LoadCharacterCommand( CharacterId.New() ) ) );
Assert.IsFalse( CommandDispatchOrchestrator.RequiresStableCharacter(
new DeleteCharacterCommand( CharacterId.New() ) ) );
Assert.IsFalse( CommandDispatchOrchestrator.RequiresStableCharacter( new UnloadCharacterCommand() ) );
Assert.IsTrue( CommandDispatchOrchestrator.RequiresStableCharacter( new SendChatCommand( "ic", "hi" ) ) );
Assert.IsTrue( CommandDispatchOrchestrator.RequiresStableCharacter(
new CloseInteractionCommand( InteractionSessionId.New() ) ) );
}
private static ClientSessionScope Scope() =>
new( ClientSessionNonce.New(), ConnectionEpoch.New() );
private static ClientCommandHeader Header() =>
new( Scope(), new CommandRequestId( Guid.NewGuid() ) );
private sealed class FakeDispatchHost : ICommandDispatchHost<string>
{
public List<string> Calls { get; } = new();
public bool Authenticated { get; set; } = true;
public bool ExecutionAvailable { get; set; } = true;
public CommandAdmissionResult Admission { get; set; } = CommandAdmissionResult.Success();
public OperationResult<string> Resolution { get; set; } = OperationResult<string>.Success( "actor" );
public OperationResult<string> StableResolution { get; set; } = OperationResult<string>.Success( "stable-actor" );
public bool LeaseCurrent { get; set; } = true;
public bool AcceptHostOperation { get; set; } = true;
public bool CompleteThrows { get; set; }
public Func<ValueTask<OperationResult>>? Execution { get; set; }
public CancellationToken Cancellation { get; set; }
public CommandCompletionStatus CompletionStatus { get; set; } = CommandCompletionStatus.Current;
public List<(string Target, OperationResult Result)> Sent { get; } = new();
public List<Exception> MalformedPayloads { get; } = new();
public List<Exception> ExecutionFaults { get; } = new();
public List<Exception> CompletionFaults { get; } = new();
public string? StartedOperationName { get; private set; }
public bool CallerIsAuthenticated => Authenticated;
public bool IsExecutionAvailable => ExecutionAvailable;
public CommandAdmissionResult TryBeginCommand( CommandRequestId requestId, int cost )
{
Calls.Add( $"charge:{cost}" );
return Admission;
}
public OperationResult<string> ResolveActor( ClientSessionScope scope, bool requireStableCharacter )
{
Calls.Add( requireStableCharacter ? "resolve:stable" : "resolve:initial" );
return requireStableCharacter ? StableResolution : Resolution;
}
public void FinishRejectedCommand( CommandRequestId requestId ) => Calls.Add( "finish-rejected" );
public bool IsCommandLeaseCurrent( string actor )
{
Calls.Add( "lease-check" );
return LeaseCurrent;
}
public bool TryStartHostOperation( string name, Func<Task> operation )
{
Calls.Add( "host-operation" );
StartedOperationName = name;
if ( !AcceptHostOperation ) return false;
operation().GetAwaiter().GetResult();
return true;
}
public CancellationToken CommandCancellation( string actor ) => Cancellation;
public ValueTask<OperationResult> ExecuteAsync(
string actor,
ClientCommand command,
CancellationToken cancellationToken )
{
Calls.Add( "execute" );
return Execution?.Invoke() ?? ValueTask.FromResult( OperationResult.Success() );
}
public CommandCompletionStatus CompleteCommand( string actor, CommandRequestId requestId )
{
Calls.Add( "complete" );
if ( CompleteThrows ) throw new InvalidOperationException( "completion registry exploded" );
return CompletionStatus;
}
public void SendResultToCaller( ClientSessionScope scope, CommandRequestId requestId, OperationResult result ) =>
Sent.Add( ("caller", result) );
public void SendResult( string actor, CommandRequestId requestId, OperationResult result ) =>
Sent.Add( (actor, result) );
public void OnMalformedPayload( Exception exception ) => MalformedPayloads.Add( exception );
public void OnExecutionFault( CommandRequestId requestId, Exception exception ) => ExecutionFaults.Add( exception );
public void OnCompletionFault( CommandRequestId requestId, Exception exception ) => CompletionFaults.Add( exception );
}
}
#nullable enable
using System;
using Hexagon.V2.Runtime;
using static Hexagon.V2.Tests.Foundation.SustainedEnvelope.EngineDefaults;
namespace Hexagon.V2.Tests.Runtime;
/// <summary>
/// Guards for the windowed movement audit.
/// <para>
/// The host observes an INTERPOLATED proxy transform, not what the client reported, so its per-tick
/// deltas are artifacts of network buffering. The audit's soundness rests on one property —
/// <b>summing deltas over a window is invariant to how the travel is chunked</b> — and
/// <see cref="AnIdenticalPathIsMeasuredTheSameHoweverItIsChunked"/> is the test that pins it. Everything
/// else here is a bound stated over a window; nothing is stated per tick, because per tick the signal
/// carries no truth.
/// </para>
/// </summary>
[TestClass]
public sealed class HexMovementValidatorTests
{
private static readonly HexMovementEnvelope Envelope = HexMovementEnvelope.Default;
private static MovementSample At( float x, float y, float z ) => new( x, y, z );
/// <summary>
/// Walks a straight horizontal path of <paramref name="totalDistance"/> over
/// <paramref name="seconds"/>, delivered in <paramref name="samples"/> chunks, and returns the
/// decision of the window that closes.
/// </summary>
private static HexMovementValidator.Decision WalkHorizontally(
float totalDistance, double seconds, int samples, bool frozen = false )
{
var audit = MovementAudit.OpenAt( At( 0, 0, 0 ), 0 );
var last = default( HexMovementValidator.Decision );
for ( var i = 1; i <= samples; i++ )
{
var t = seconds * i / samples;
var x = totalDistance * i / samples;
(last, audit) = HexMovementValidator.Observe(
audit, At( (float)x, 0, 0 ), t, RunSpeed, JumpSpeed, frozen, Envelope );
}
return last;
}
[TestMethod]
public void AnIdenticalPathIsMeasuredTheSameHoweverItIsChunked()
{
// THE property the whole design rests on. The interpolation buffer decides how travel is sliced
// — smoothly, in bursts of three ticks, or irregularly — and the audit must be blind to that.
// Per-tick checking is not: measured live, an honest sprint arrived as 19.2-unit bursts against
// a ~15-unit per-tick budget and was refused twelve times.
var smooth = WalkHorizontally( 300f, 1.0, samples: 60 );
var bursty = WalkHorizontally( 300f, 1.0, samples: 20 );
var coarse = WalkHorizontally( 300f, 1.0, samples: 5 );
Assert.AreEqual( smooth.HorizontalPath, bursty.HorizontalPath, 0.01f,
"Chunking changed the measured path; the audit is not sampling-invariant." );
Assert.AreEqual( smooth.HorizontalPath, coarse.HorizontalPath, 0.01f,
"Chunking changed the measured path; the audit is not sampling-invariant." );
}
[TestMethod]
public void AnHonestSprintIsAcceptedHoweverItIsSampled()
{
// A player at exactly run speed for a full window, delivered every way the network might.
foreach ( var samples in new[] { 60, 20, 12, 5 } )
{
var decision = WalkHorizontally( RunSpeed, 1.0, samples );
Assert.IsTrue( decision.WindowClosed, $"Window did not close for {samples} samples." );
Assert.IsFalse( decision.Corrected,
$"Honest sprint flagged at {samples} samples/window: " +
$"path {decision.HorizontalPath:F1} vs budget {decision.HorizontalBudget:F1}." );
}
}
[TestMethod]
public void ASpeedCheatIsFlagged()
{
// Twice run speed for a window: 640 units against a 496-unit budget.
var decision = WalkHorizontally( RunSpeed * 2f, 1.0, samples: 60 );
Assert.AreEqual( HexMovementValidator.Verdict.WindowExceeded, decision.Verdict,
$"path {decision.HorizontalPath:F1} vs budget {decision.HorizontalBudget:F1}" );
}
[TestMethod]
public void SustainedClimbBeyondTheGroundAngleIsFlagged()
{
// Rising far faster than the steepest standable surface would permit for the distance walked.
var audit = MovementAudit.OpenAt( At( 0, 0, 0 ), 0 );
var decision = default( HexMovementValidator.Decision );
for ( var i = 1; i <= 60; i++ )
{
// 100 units of horizontal travel, 900 units of rise, in one window.
(decision, audit) = HexMovementValidator.Observe(
audit, At( 100f * i / 60f, 0, 900f * i / 60f ), i / 60.0,
RunSpeed, JumpSpeed, false, Envelope );
}
Assert.AreEqual( HexMovementValidator.Verdict.WindowExceeded, decision.Verdict,
$"rise {decision.NetRise:F1} vs budget {decision.RiseBudget:F1}" );
}
[TestMethod]
public void AJumpAndLandIsNotAClimb()
{
// Net rise over a window is what "climbing" means. Jumping repeatedly nets nothing, and must
// not accumulate into a violation the way summed positive rise would.
var audit = MovementAudit.OpenAt( At( 0, 0, 0 ), 0 );
var decision = default( HexMovementValidator.Decision );
for ( var i = 1; i <= 60; i++ )
{
var t = i / 60.0;
var phase = Math.Sin( t * Math.PI * 4 ); // two full jump arcs in the window
var z = (float)(Math.Max( phase, 0 ) * 56.0); // apex ~ one jump
(decision, audit) = HexMovementValidator.Observe(
audit, At( 200f * i / 60f, 0, z ), t, RunSpeed, JumpSpeed, false, Envelope );
}
Assert.IsFalse( decision.Corrected,
$"Jumping while running was flagged: rise {decision.NetRise:F1} vs {decision.RiseBudget:F1}" );
}
[TestMethod]
public void ATeleportIsRefusedImmediatelyWithoutWaitingForTheWindow()
{
var audit = MovementAudit.OpenAt( At( 0, 0, 0 ), 0 );
var (decision, _) = HexMovementValidator.Observe(
audit, At( 5000, 0, 0 ), 0.016, RunSpeed, JumpSpeed, false, Envelope );
Assert.AreEqual( HexMovementValidator.Verdict.Teleport, decision.Verdict );
Assert.IsFalse( decision.WindowClosed, "A teleport is caught on the sample, not at window end." );
}
[TestMethod]
public void AnInterpolationSizedBurstIsNotMistakenForATeleport()
{
// The bursts that broke per-tick checking (~19 units) must be nowhere near the teleport guard.
var audit = MovementAudit.OpenAt( At( 0, 0, 0 ), 0 );
var (decision, _) = HexMovementValidator.Observe(
audit, At( 19.2f, 0, 0 ), 0.016, RunSpeed, JumpSpeed, false, Envelope );
Assert.AreEqual( HexMovementValidator.Verdict.Accepted, decision.Verdict );
}
[TestMethod]
public void FrozenPlayersAreHeldToTheDriftAllowance()
{
var moved = WalkHorizontally( 200f, 1.0, samples: 60, frozen: true );
Assert.AreEqual( HexMovementValidator.Verdict.WindowExceeded, moved.Verdict );
var jitter = WalkHorizontally( Envelope.FrozenDriftAllowance * 0.5f, 1.0, samples: 60, frozen: true );
Assert.IsFalse( jitter.Corrected, "Jitter within the frozen allowance must not be flagged." );
}
[TestMethod]
public void CorrectsNonFinitePosition()
{
var audit = MovementAudit.OpenAt( At( 0, 0, 0 ), 0 );
var (decision, _) = HexMovementValidator.Observe(
audit, At( float.NaN, 0, 0 ), 0.016, RunSpeed, JumpSpeed, false, Envelope );
Assert.AreEqual( HexMovementValidator.Verdict.Teleport, decision.Verdict );
}
[TestMethod]
public void AnUnprimedAuditAdoptsTheFirstSampleWithoutFlagging()
{
var (decision, audit) = HexMovementValidator.Observe(
MovementAudit.Unprimed, At( 500, 500, 500 ), 0, RunSpeed, JumpSpeed, false, Envelope );
Assert.IsFalse( decision.Corrected );
Assert.IsTrue( audit.Primed );
Assert.AreEqual( 500f, audit.Anchor.X );
}
[TestMethod]
public void AMalformedEnvelopeIsRejectedRatherThanAuditingNothing()
{
Assert.IsTrue( HexMovementEnvelope.Default.IsWellFormed( out _ ) );
var permissive = HexMovementEnvelope.Default with { HorizontalTolerance = 50f };
Assert.IsFalse( permissive.IsWellFormed( out var error ) );
Assert.IsNotEmpty( error );
var blind = HexMovementEnvelope.Default with { AuditWindowSeconds = 999f };
Assert.IsFalse( blind.IsWellFormed( out _ ) );
}
}
using Sandbox;
[TestClass]
public partial class LibraryTests
{
[TestMethod]
public void SceneTest()
{
var scene = new Scene();
using ( scene.Push() )
{
var go = new GameObject();
Assert.AreEqual( 1, scene.Directory.GameObjectCount );
}
}
}
using HumanoidRetargeter.Core.Target;
using Xunit;
namespace HumanoidRetargeter.Tests;
public class Kv3ModelDocNumberTests
{
[Theory]
[InlineData(1.0380962e-05)]
[InlineData(-1.2e-12)]
[InlineData(1.25e25)]
[InlineData(double.Epsilon)]
[InlineData(double.MaxValue)]
public void ModelDocNumbersHaveNoExponentAndRoundTripExactly(double value)
{
var doc = Kv3.Parse(VmdlWriter.Kv3Header + "\n{ value = 0.0 }");
((KvObject)doc.Root)["value"] = new KvDouble(value);
var text = Kv3.Serialize(doc);
var number = text[(text.IndexOf("value = ", StringComparison.Ordinal) + 8)..].Trim().TrimEnd('}').Trim();
Assert.DoesNotContain("E", number);
Assert.DoesNotContain("e", number);
Assert.Equal(value, ((KvDouble)((KvObject)Kv3.Parse(text).Root)["value"]).Value);
}
}
using HumanoidRetargeter.Core.Target;
using Xunit;
namespace HumanoidRetargeter.Tests.Target;
/// <summary>Bone renaming that follows a graph into its subgraphs (the stock Citizen graph keeps its logic there).</summary>
public class SmartPortGraphsTests
{
static string Graph(string cls, string body) => VmdlWriter.Kv3Header + "{ _class = \"" + cls + "\" " + body + " }";
static readonly Dictionary<string, string> Renames = new()
{
["pelvis"] = "ValveBiped_Bip01_Pelvis", ["hand_R"] = "ValveBiped_Bip01_R_Hand", ["hold_R"] = "hold_R",
};
static Dictionary<string, string> Files(params (string Path, string Text)[] files)
=> files.ToDictionary(f => f.Path, f => f.Text, StringComparer.OrdinalIgnoreCase);
[Fact]
public void SubgraphsNamingRenamedBonesAreCopiedAndReferenced()
{
var entry = Graph("CAnimationGraph", """
m_nodes = [
{ m_subGraphFilename = "models/citizen/subgraphs/core.vsubgrph" },
{ m_subGraphFilename = "models\\citizen\\subgraphs\\face.vsubgrph" },
{ m_bone = "hand_R" }
]
""");
var files = Files(
("models/citizen/subgraphs/core.vsubgrph", Graph("CAnimationSubGraph", "m_hipBoneName = \"pelvis\" m_attachmentName = \"hold_R\"")),
("models/citizen/subgraphs/face.vsubgrph", Graph("CAnimationSubGraph", "m_sequenceName = \"Eyes_Blink\"")));
var copy = SmartPortGraphs.Rebase(entry, Renames, p => files.GetValueOrDefault(p), "models/x_src/subgraphs");
var core = Assert.Single(copy.Subgraphs);
Assert.Equal("models/x_src/subgraphs/core.vsubgrph", core.Key);
Assert.Contains("m_hipBoneName = \"ValveBiped_Bip01_Pelvis\"", core.Value);
Assert.Contains("m_attachmentName = \"hold_R\"", core.Value);
Assert.Contains("\"models/x_src/subgraphs/core.vsubgrph\"", copy.Graph);
Assert.Contains("face.vsubgrph", copy.Graph); // unchanged: still the shipped one
Assert.DoesNotContain("x_src/subgraphs/face", copy.Graph);
Assert.Contains("\"ValveBiped_Bip01_R_Hand\"", copy.Graph);
Assert.Empty(copy.Unread);
}
[Fact]
public void AChangeDeepInsideCopiesEveryGraphAboveIt()
{
var entry = Graph("CAnimationGraph", "m_subGraphFilename = \"a.vsubgrph\"");
var files = Files(
("a.vsubgrph", Graph("CAnimationSubGraph", "m_subGraphFilename = \"b.vsubgrph\"")),
("b.vsubgrph", Graph("CAnimationSubGraph", "m_hipBoneName = \"pelvis\" m_subGraphFilename = \"a.vsubgrph\"")));
var copy = SmartPortGraphs.Rebase(entry, Renames, p => files.GetValueOrDefault(p), "out");
Assert.Equal(new[] { "out/a.vsubgrph", "out/b.vsubgrph" }, copy.Subgraphs.Keys.OrderBy(k => k, StringComparer.Ordinal));
Assert.Contains("\"out/b.vsubgrph\"", copy.Subgraphs["out/a.vsubgrph"]);
Assert.Contains("\"out/a.vsubgrph\"", copy.Graph);
Assert.Contains("\"a.vsubgrph\"", copy.Subgraphs["out/b.vsubgrph"]); // a reference back up the chain is not followed again
}
[Fact]
public void UnreadableSubgraphsAreListedAndIdentityNamesChangeNothing()
{
var entry = Graph("CAnimationGraph", "m_subGraphFilename = \"missing.vsubgrph\" m_bone = \"hold_R\"");
var copy = SmartPortGraphs.Rebase(entry, Renames, _ => null, "out");
Assert.Empty(copy.Subgraphs);
Assert.Equal(new[] { "missing.vsubgrph" }, copy.Unread);
Assert.Contains("\"missing.vsubgrph\"", copy.Graph);
}
[Fact]
public void SameNamedSubgraphsFromDifferentFoldersGetDistinctCopies()
{
var entry = Graph("CAnimationGraph", "m_nodes = [ { m_subGraphFilename = \"one/core.vsubgrph\" }, { m_subGraphFilename = \"two/core.vsubgrph\" } ]");
var files = Files(
("one/core.vsubgrph", Graph("CAnimationSubGraph", "m_bone = \"pelvis\"")),
("two/core.vsubgrph", Graph("CAnimationSubGraph", "m_bone = \"hand_R\"")));
var copy = SmartPortGraphs.Rebase(entry, Renames, p => files.GetValueOrDefault(p), "out");
Assert.Equal(new[] { "out/core.vsubgrph", "out/core_2.vsubgrph" }, copy.Subgraphs.Keys.OrderBy(k => k, StringComparer.Ordinal));
}
}
using System.Numerics;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Target;
using Xunit;
using SkeletonModel = HumanoidRetargeter.Core.Skeleton.Skeleton;
using HumanoidRetargeter.Core;
namespace HumanoidRetargeter.Tests.Target;
public class StockBindPoseTests
{
private const string RigJson = """
{ "name": "stock", "bones": [
{ "name": "pelvis", "parent": null, "class": "Animated", "role": "Hips", "local_pos": [0, 100, 0], "local_rot_xyzw": [0, 0, 0, 1], "tail_world": [0, 110, 0] },
{ "name": "helper", "parent": "pelvis", "class": "ConstraintDriven", "local_pos": [1, 0, 0], "local_rot_xyzw": [0, 0, 0, 1] },
{ "name": "root_IK", "parent": null, "class": "IkBaked", "local_pos": [0, 0, 0], "local_rot_xyzw": [0, 0, 0, 1] }
] }
""";
[Fact]
public void RebindingPreservesCuratedHelpersAndAdoptsCompiledRest()
{
var stock = TargetRig.Load(RigJson);
var rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2);
var definitions = stock.Skeleton.Bones.Select(b => new BoneDefinition(b.Name,
b.ParentIndex < 0 ? null : stock.Skeleton[b.ParentIndex].Name,
b.ParentIndex < 0 ? new XForm(Vector3.Transform(b.RestLocal.Pos, rotation), rotation) : b.RestLocal)).ToList();
definitions.Add(new BoneDefinition("custom_extra", "pelvis", XForm.Identity));
var bind = SkeletonModel.Create(definitions);
var rig = stock.WithBindPose(bind);
Assert.Same(bind, rig.Skeleton);
Assert.True(rig.HelpersAreConstraintDriven);
Assert.Equal(BoneClass.ConstraintDriven, rig.ClassOf(bind.IndexOf("helper")));
Assert.Equal(BoneClass.IkBaked, rig.ClassOf(bind.IndexOf("root_IK")));
Assert.Equal(BoneClass.Animated, rig.ClassOf(bind.IndexOf("custom_extra")));
Assert.Equal(bind.IndexOf("pelvis"), rig.BoneForRole(BoneRole.Hips));
Assert.True(Vector3.Distance(new Vector3(-110, 0, 0), rig.TailWorldOf(bind.IndexOf("pelvis"))!.Value) < .001f);
Assert.Equal(new Vector3(0, 100, 0), stock.Skeleton.RestWorld[stock.Skeleton.IndexOf("pelvis")].Pos);
}
[Fact]
public void MissingMappedBonesFailRatherThanDroppingMotion()
=> Assert.Throws<ArgumentException>(() => TargetRig.Load(RigJson).WithBindPose(
SkeletonModel.Create(new[] { new BoneDefinition("unrelated", null, XForm.Identity) })));
[Fact]
public void PrefabMeshUsesTheSameDmxRootCorrectionAsAnExplicitMesh()
{
var rig = TargetRig.Load(RigJson);
var frames = new[] { rig.Skeleton.Bones.Select(b => b.RestLocal).ToArray() };
var prefab = new RetargetTargetSpec { Rig = rig, VmdlScale = .3937f, CompensateDmxRootYaw = true };
var explicitMesh = new RetargetTargetSpec { Rig = rig, VmdlScale = .3937f, MeshFilePath = "body.fbx" };
var expected = Retargeter.TestHook_CompensateEmbeddedMeshRootYaw(frames, explicitMesh);
var actual = Retargeter.TestHook_CompensateEmbeddedMeshRootYaw(frames, prefab);
Assert.Equal(expected[0], actual[0]);
Assert.NotEqual(frames[0][0].Rot, actual[0][0].Rot);
Assert.Equal(rig.Skeleton[0].RestLocal, frames[0][0]);
}
}
using System.Numerics;
using System.Text;
using System.Text.Json;
using HumanoidRetargeter.Core.Formats.Bvh;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using Xunit;
using Skel = HumanoidRetargeter.Core.Skeleton.Skeleton;
namespace HumanoidRetargeter.Tests.Formats;
// =============================================================================================
// SPACE / CONVENTION RESOLUTION (determined from Blender 5.1 io_anim_bvh/import_bvh.py and
// verified empirically against fixtures/anim_truth/freebvh.json, produced by
// research/extract_anim.py = import_anim.bvh(axis_forward='-Z', axis_up='Y', global_scale=1)):
//
// 1. AXES. Blender's BVH importer builds global_matrix = Rx(+90 deg) (file Y-up -> Blender
// Z-up) and — unlike the FBX importer — BAKES it into the armature data
// (`arm_ob.matrix_world = global_matrix; transform_apply(rotation=True)`, import_bvh.py).
// The object ends up with identity transform (object_scale == 1). Therefore BOTH the
// truth's rest data (`bones[].rest_world_*`, armature space) and its frame data
// (`frames[][].wp/wr`, world space) are in Blender Z-up space, and converting EITHER back
// to native BVH space is the same fixed rotation applied on the left:
// p_bvh = Rx(-90 deg) * p_blender, q_bvh = Rx(-90 deg) * q_blender.
// (Verified: file's Spine OFFSET (0, 9.9235, -1.2273) appears in the truth as rest world
// (0, 1.2273, 9.9235) = Rx(+90 deg) of the native offset.)
//
// 2. BONE ORIENTATION. Blender bones are oriented along head->tail with the joint's world
// position at the HEAD; BVH joints have identity rest rotation. The importer keys pose
// rotations as basis = rest^-1 * R_bvh * rest, which makes the pose bone's world rotation
// q_pose(t) = Q_joint(t) * q_rest (column-vector quaternion product, our a*b-applies-b-
// first convention). The BVH joint world rotation is therefore recovered per bone as
// Q_joint(t) = q_pose_bvh(t) * conj(q_rest_bvh); positions compare directly (heads).
//
// 3. END SITES. Blender turns End Sites into bone TAILS (no extra bones; the "<name>_end"
// naming exists only in its non-armature OBJECT import path). Our importer synthesizes a
// leaf bone "<parent>_end" per End Site, so: our joint count == truth bone count + number
// of End Sites, and every extra bone name ends with "_end".
//
// 4. FRAME ALIGNMENT. Blender places BVH motion line 0 at action frame 1 (it prepends and
// then skips an internal rest frame), and extract_anim.py samples from frame_start = 1 on
// the same 30 fps grid our importer uses, so truth frame i == our clip frame i (the file's
// 30.000003 fps vs 30 fps grid mismatch is ~1e-7 s per frame — negligible over 40 frames;
// Blender fcurves are keyed LINEAR so subframe evaluation is well-defined).
//
// 5. UNITS. global_scale=1, so truth values are in raw file units. freebvh is Mixamo-derived
// (centimeters, rest height ~185) -> our meters-vs-cm heuristic keeps scale 1 and values
// compare 1:1.
//
// TOLERANCES per the task gate: rest offsets ~exact (0.02 cm); animation world rotation
// within 1 deg and world position within 0.5% of skeleton height across 10 sampled frames.
// Observed residuals are float-precision noise (max 0.0002 cm / 0.0004 deg over all 40
// truth frames x 55 joints), so the gates have ~2500x margin. A deliberately flipped
// rotation-composition order fails 55/55 joints — the comparison is discriminating.
// =============================================================================================
public class BvhImporterTests
{
private const float KDegPerRad = 180f / MathF.PI;
private static byte[] Fixture(string name)
=> File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "fixtures", "bvh", name));
private static SourceScene ImportFixture(string name, float fps = 30f)
=> BvhImporter.Import(Fixture(name), new BvhImportOptions { SampleFps = fps });
// ---- ground truth -----------------------------------------------------------------
private sealed record TruthBone(
string Name, string? Parent, Vector3 RestWorldPos, Quaternion RestWorldRot);
private sealed record TruthFrameBone(Vector3 WorldPos, Quaternion WorldRot);
private sealed class Truth
{
public required int NSamples;
public required List<TruthBone> Bones;
public required List<TruthFrameBone[]> Frames;
}
private static Truth LoadTruth(string name)
{
var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "anim_truth", name);
using var doc = JsonDocument.Parse(File.ReadAllBytes(path));
var r = doc.RootElement;
static Vector3 V3(JsonElement e) => new(
e[0].GetSingle(), e[1].GetSingle(), e[2].GetSingle());
static Quaternion Q(JsonElement e) => new(
e[0].GetSingle(), e[1].GetSingle(), e[2].GetSingle(), e[3].GetSingle());
var bones = new List<TruthBone>();
foreach (var b in r.GetProperty("bones").EnumerateArray())
{
bones.Add(new TruthBone(
b.GetProperty("name").GetString()!,
b.GetProperty("parent").ValueKind == JsonValueKind.Null
? null : b.GetProperty("parent").GetString(),
V3(b.GetProperty("rest_world_pos")),
Q(b.GetProperty("rest_world_rot_xyzw"))));
}
var frames = new List<TruthFrameBone[]>();
foreach (var f in r.GetProperty("frames").EnumerateArray())
{
var fb = new TruthFrameBone[bones.Count];
int i = 0;
foreach (var e in f.EnumerateArray())
fb[i++] = new TruthFrameBone(V3(e.GetProperty("wp")), Q(e.GetProperty("wr")));
frames.Add(fb);
}
return new Truth
{
NSamples = r.GetProperty("n_samples").GetInt32(),
Bones = bones,
Frames = frames,
};
}
// Blender Z-up world -> native BVH Y-up space (see header note 1).
private static readonly Quaternion BlenderToBvh =
Quaternion.CreateFromAxisAngle(Vector3.UnitX, -MathF.PI / 2f);
private static Vector3 PosToBvh(Vector3 p) => Vector3.Transform(p, BlenderToBvh);
private static Quaternion RotToBvh(Quaternion q) => BlenderToBvh * q;
/// <summary>BVH joint world rotation from a truth pose rotation (see header note 2).</summary>
private static Quaternion JointRot(Quaternion poseWorldRot, Quaternion restWorldRot)
=> RotToBvh(poseWorldRot) * Quaternion.Conjugate(RotToBvh(restWorldRot));
private static float SkeletonHeight(Skel skeleton)
{
float min = float.MaxValue, max = float.MinValue;
foreach (var w in skeleton.RestWorld)
{
min = MathF.Min(min, w.Pos.Y);
max = MathF.Max(max, w.Pos.Y);
}
return max - min;
}
// ---- 1. freebvh vs Blender ground truth ---------------------------------------------
[Fact]
public void Freebvh_SkeletonTopology_MatchesBlender()
{
var scene = ImportFixture("bvhpython_test_freebvh.bvh");
var truth = LoadTruth("freebvh.json");
// Every Blender bone (= BVH joint) exists, with the same parent.
var truthNames = new HashSet<string>();
foreach (var b in truth.Bones)
{
truthNames.Add(b.Name);
int idx = scene.Skeleton.IndexOf(b.Name);
Assert.True(idx >= 0, $"missing joint {b.Name}");
int parentIdx = scene.Skeleton[idx].ParentIndex;
string? parent = parentIdx < 0 ? null : scene.Skeleton[parentIdx].Name;
Assert.Equal(b.Parent, parent);
}
// Our extra bones are exactly the synthesized End Site leaves (15 in this file).
var extras = scene.Skeleton.Bones
.Where(b => !truthNames.Contains(b.Name))
.Select(b => b.Name)
.ToList();
Assert.Equal(15, extras.Count);
Assert.All(extras, n => Assert.EndsWith("_end", n, StringComparison.Ordinal));
Assert.Equal(truth.Bones.Count + 15, scene.Skeleton.Count);
}
[Fact]
public void Freebvh_RestPose_MatchesBlender()
{
var scene = ImportFixture("bvhpython_test_freebvh.bvh");
var truth = LoadTruth("freebvh.json");
Assert.Equal(1f, scene.UnitScaleCm); // centimeter-scale file, heuristic keeps x1
Assert.Equal(1, scene.UpAxis); // BVH convention: Y-up, recorded not converted
foreach (var b in truth.Bones)
{
int idx = scene.Skeleton.IndexOf(b.Name);
var ours = scene.Skeleton.RestWorld[idx];
// Rest world position = accumulated OFFSETs (cm).
TestUtil.AssertVectorEqual(PosToBvh(b.RestWorldPos), ours.Pos, 0.02f);
// BVH rest rotations are identity by convention (Blender's nontrivial rest
// rotations are bone head->tail orientations, cancelled in the frame tests).
float identErr = MathQ.AngleBetween(Quaternion.Identity, ours.Rot) * KDegPerRad;
Assert.True(identErr < 1e-3f, $"{b.Name}: rest rot not identity ({identErr} deg)");
}
}
[Fact]
public void Freebvh_Animation_MatchesBlender()
{
var scene = ImportFixture("bvhpython_test_freebvh.bvh");
var truth = LoadTruth("freebvh.json");
Assert.Single(scene.Clips);
var clip = scene.Clips[0];
float height = SkeletonHeight(scene.Skeleton);
Assert.InRange(height, 100f, 400f); // sanity: a cm-scale humanoid
float posTol = 0.005f * height;
const float rotTolDeg = 1f;
// 10 frames spread over the 40 extracted truth samples.
int[] sampleFrames = { 0, 4, 8, 12, 16, 20, 24, 28, 32, 36 };
foreach (int f in sampleFrames)
{
Assert.True(f < truth.NSamples && f < clip.FrameCount);
var worlds = new Pose(clip.Frames[f]).ToWorld(scene.Skeleton);
var truthFrame = truth.Frames[f];
var failures = new List<string>();
for (int i = 0; i < truth.Bones.Count; i++)
{
var tb = truth.Bones[i];
int idx = scene.Skeleton.IndexOf(tb.Name);
var expectedPos = PosToBvh(truthFrame[i].WorldPos);
var expectedRot = JointRot(truthFrame[i].WorldRot, tb.RestWorldRot);
float posErr = Vector3.Distance(worlds[idx].Pos, expectedPos);
float rotErr = MathQ.AngleBetween(worlds[idx].Rot, expectedRot) * KDegPerRad;
if (posErr > posTol || rotErr > rotTolDeg)
failures.Add($" {tb.Name}: posErr={posErr:F3} cm (tol {posTol:F3}), rotErr={rotErr:F3} deg");
}
Assert.True(failures.Count == 0,
$"frame {f}: {failures.Count}/{truth.Bones.Count} joints out of tolerance:\n" +
string.Join("\n", failures.Take(12)));
}
}
// ---- 2. cmu_01_01 basics --------------------------------------------------------------
[Fact]
public void Cmu0101_ImportBasics()
{
var scene = ImportFixture("cmu_01_01.bvh");
// 31 joints + 7 End Sites.
int endBones = scene.Skeleton.Bones.Count(b => b.Name.EndsWith("_end", StringComparison.Ordinal));
Assert.Equal(7, endBones);
Assert.Equal(31 + 7, scene.Skeleton.Count);
Assert.True(scene.Skeleton.IndexOf("Hips") >= 0);
Assert.Single(scene.Clips);
var clip = scene.Clips[0];
Assert.True(clip.Duration > 1f, $"duration {clip.Duration}s"); // 2752 frames at 120 fps ~ 22.9 s
Assert.Equal(30f, clip.Fps);
// No NaNs anywhere.
static bool Finite(XForm x) =>
float.IsFinite(x.Pos.X) && float.IsFinite(x.Pos.Y) && float.IsFinite(x.Pos.Z) &&
float.IsFinite(x.Rot.X) && float.IsFinite(x.Rot.Y) && float.IsFinite(x.Rot.Z) &&
float.IsFinite(x.Rot.W);
for (int i = 0; i < scene.Skeleton.Count; i++)
Assert.True(Finite(scene.Skeleton.RestWorld[i]), $"non-finite rest {scene.Skeleton[i].Name}");
for (int f = 0; f < clip.FrameCount; f++)
foreach (var x in clip.Frames[f])
Assert.True(Finite(x), $"non-finite transform at frame {f}");
// Root has 6 channels (3 position): its local translation is animated.
int hips = scene.Skeleton.IndexOf("Hips");
bool rootMoves = false;
for (int f = 1; f < clip.FrameCount && !rootMoves; f++)
rootMoves = Vector3.Distance(clip.Frames[f][hips].Pos, clip.Frames[0][hips].Pos) > 0.1f;
Assert.True(rootMoves, "root translation never changes — position channels not applied");
// A knee joint has only 3 (rotation) channels: local translation stays at the OFFSET.
int knee = scene.Skeleton.IndexOf("LeftLeg");
Assert.True(knee >= 0, "missing LeftLeg");
var kneeRest = scene.Skeleton[knee].RestLocal.Pos;
for (int f = 0; f < clip.FrameCount; f++)
TestUtil.AssertVectorEqual(kneeRest, clip.Frames[f][knee].Pos, 1e-4f);
}
// ---- 3. resampling ---------------------------------------------------------------------
// freebvh: Frames: 69, Frame Time: 0.0333333 -> duration = 68 * 0.0333333 s.
// Expected frame count on the target grid = round(duration * fps) + 1.
[Theory]
[InlineData(30f, 69)]
[InlineData(60f, 137)]
[InlineData(15f, 35)]
public void Freebvh_Resampling_FrameCountMatchesGrid(float fps, int expectedFrames)
{
var scene = ImportFixture("bvhpython_test_freebvh.bvh", fps);
var clip = Assert.Single(scene.Clips);
Assert.Equal(fps, clip.Fps);
Assert.Equal(expectedFrames, clip.FrameCount);
double duration = 68 * 0.0333333;
Assert.Equal((int)Math.Round(duration * fps) + 1, clip.FrameCount);
}
// ---- 4. unit heuristic + channel-order unit check ---------------------------------------
// Meter-scale skeleton (rest height 1.7 < 10) must be scaled x100 to centimeters,
// including the root position channels. Frame 1 also pins down the rotation-channel
// convention independently of Blender: channels "Zrotation Yrotation Xrotation" with
// values (90, 0, 0) must yield world R = Rz(90 deg), so the Head joint at local offset
// (0, 0.7, 0) m lands at hips + Rz90*(0, 70, 0) = hips + (-70, 0, 0) cm.
private const string MeterBvh = @"HIERARCHY
ROOT Hips
{
OFFSET 0 0.9 0
CHANNELS 6 Xposition Yposition Zposition Zrotation Yrotation Xrotation
JOINT Head
{
OFFSET 0 0.7 0
CHANNELS 3 Zrotation Yrotation Xrotation
End Site
{
OFFSET 0 0.1 0
}
}
}
MOTION
Frames: 2
Frame Time: 0.0333333
0 0.9 0 0 0 0 0 0 0
0 1.0 0.2 90 0 0 0 0 0
";
[Fact]
public void MeterScaleFile_IsConvertedToCentimeters()
{
var scene = BvhImporter.Import(Encoding.ASCII.GetBytes(MeterBvh), new BvhImportOptions());
Assert.Equal(100f, scene.UnitScaleCm);
int hips = scene.Skeleton.IndexOf("Hips");
int head = scene.Skeleton.IndexOf("Head");
int end = scene.Skeleton.IndexOf("Head_end");
Assert.True(hips >= 0 && head >= 0 && end >= 0);
TestUtil.AssertVectorEqual(new Vector3(0f, 90f, 0f), scene.Skeleton.RestWorld[hips].Pos, 1e-3f);
TestUtil.AssertVectorEqual(new Vector3(0f, 160f, 0f), scene.Skeleton.RestWorld[head].Pos, 1e-3f);
TestUtil.AssertVectorEqual(new Vector3(0f, 10f, 0f), scene.Skeleton[end].RestLocal.Pos, 1e-3f);
var clip = Assert.Single(scene.Clips);
Assert.Equal(2, clip.FrameCount);
var worlds = new Pose(clip.Frames[1]).ToWorld(scene.Skeleton);
TestUtil.AssertVectorEqual(new Vector3(0f, 100f, 20f), worlds[hips].Pos, 0.01f);
TestUtil.AssertVectorEqual(new Vector3(-70f, 100f, 20f), worlds[head].Pos, 0.01f);
var rz90 = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f);
Assert.True(MathQ.AngleBetween(rz90, worlds[hips].Rot) * KDegPerRad < 0.01f,
$"hips world rot {worlds[hips].Rot} != Rz(90)");
}
// ---- 5. malformed input ------------------------------------------------------------------
[Fact]
public void TruncatedFile_Throws()
{
var full = Fixture("bvhpython_test_freebvh.bvh");
// Cut mid-MOTION (file is ~60% hierarchy, so 80% leaves a partial frame block).
var midMotion = full.AsSpan(0, full.Length * 8 / 10).ToArray();
Assert.Throws<FormatException>(() => BvhImporter.Import(midMotion, new BvhImportOptions()));
// Cut mid-HIERARCHY.
var midHierarchy = full.AsSpan(0, full.Length / 4).ToArray();
Assert.Throws<FormatException>(() => BvhImporter.Import(midHierarchy, new BvhImportOptions()));
}
[Fact]
public void NonBvhData_Throws()
{
Assert.Throws<FormatException>(() => BvhImporter.Import(
Encoding.ASCII.GetBytes("this is not a bvh file"), new BvhImportOptions()));
Assert.Throws<FormatException>(() => BvhImporter.Import(
Array.Empty<byte>(), new BvhImportOptions()));
}
}
using System.Numerics;
using HumanoidRetargeter.Core.Formats;
using HumanoidRetargeter.Core.Maths;
using Xunit;
namespace HumanoidRetargeter.Tests.Formats;
public class QuaternionContinuityTests
{
private static Quaternion Rz(float radians)
=> Quaternion.CreateFromAxisAngle(Vector3.UnitZ, radians);
private static void AssertAligned(IReadOnlyList<Quaternion> track)
{
for (var i = 1; i < track.Count; i++)
Assert.True(Quaternion.Dot(track[i - 1], track[i]) >= 0f,
$"negative dot between samples {i - 1} and {i}: {track[i - 1]} -> {track[i]}");
}
[Fact]
public void Align_FlippedSample_NegatedBackOntoSameHemisphere()
{
var track = new[] { Quaternion.Identity, Quaternion.Negate(Rz(0.1f)) };
QuaternionContinuity.Align(track);
AssertAligned(track);
// Semantically the same rotation: only the sign changed.
Assert.Equal(Rz(0.1f).X, track[1].X, 6);
Assert.Equal(Rz(0.1f).W, track[1].W, 6);
}
[Fact]
public void Align_AlternatingFlips_PropagatesCumulatively()
{
// Flips must be judged against the ALIGNED predecessor, not the raw one: after the
// second sample is negated, the third (raw-consistent with the second) must flip too.
var track = new[]
{
Rz(0.0f),
Quaternion.Negate(Rz(0.1f)),
Quaternion.Negate(Rz(0.2f)),
Rz(0.3f),
};
QuaternionContinuity.Align(track);
AssertAligned(track);
}
[Fact]
public void Align_AlreadyContinuous_Unchanged()
{
var track = new[] { Rz(0.0f), Rz(0.1f), Rz(0.2f) };
var expected = (Quaternion[])track.Clone();
QuaternionContinuity.Align(track);
Assert.Equal(expected, track);
}
[Fact]
public void AlignFrames_PerBoneTracks_AlignedIndependently()
{
var frames = new List<XForm[]>
{
new[]
{
new XForm(Vector3.Zero, Rz(0.0f)),
new XForm(Vector3.Zero, Rz(1.0f)),
},
new[]
{
new XForm(Vector3.Zero, Quaternion.Negate(Rz(0.1f))), // bone 0 flips
new XForm(Vector3.Zero, Rz(1.1f)), // bone 1 stays
},
new[]
{
new XForm(Vector3.Zero, Quaternion.Negate(Rz(0.2f))),
new XForm(Vector3.Zero, Quaternion.Negate(Rz(1.2f))), // bone 1 flips
},
};
QuaternionContinuity.AlignFrames(frames);
for (var bone = 0; bone < 2; bone++)
AssertAligned(frames.Select(f => f[bone].Rot).ToList());
// Positions untouched.
foreach (var frame in frames)
foreach (var x in frame)
Assert.Equal(Vector3.Zero, x.Pos);
}
[Fact]
public void AlignFrames_SingleFrame_NoOp()
{
var frames = new List<XForm[]> { new[] { new XForm(Vector3.Zero, Rz(0.4f)) } };
QuaternionContinuity.AlignFrames(frames);
Assert.Equal(Rz(0.4f), frames[0][0].Rot);
}
}
using HumanoidRetargeter.Core.Mapping;
using Xunit;
using HumanoidRetargeter.Core;
namespace HumanoidRetargeter.Tests.Mapping;
/// <summary>
/// Local-only preset-detection checks against REAL rig files in <c>dev/corpus/</c> (not
/// committed; every test silently passes when the file is absent):
/// <list type="bullet">
/// <item><c>unknown_rigs/makehuman_cmu_03_03_dazNames.bvh</c> — MakeHuman CMU retarget with
/// Poser/DAZ classic names (hip/abdomen/chest, lShldr/lForeArm, lThigh/lShin, 2-segment
/// fingers, no toes). Previously fell through to the auto-mapper; must now hit the
/// <c>daz_poser</c> preset.</item>
/// <item><c>todo/Defenses.fbx</c> — Auto-Rig Pro export (PunchPerfect family): root.x hips,
/// *_stretch limbs, c_-prefixed fingers, leftover mixamorig:*4 finger-tip markers.
/// Previously auto-mapped at 0.94; must now hit the <c>auto_rig_pro</c> preset with a
/// full mapping.</item>
/// </list>
/// </summary>
public class RealRigPresetTests
{
/// <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.");
}
[Fact] // skipped (silently green) when the local corpus file is not present
public void MakehumanDazNames_DetectedAsDazPoser_LimbsFullyMapped()
{
var path = RepoFile("dev", "corpus", "unknown_rigs", "makehuman_cmu_03_03_dazNames.bvh");
if (!File.Exists(path))
return;
var scene = Retargeter.ImportSource(File.ReadAllBytes(path), Path.GetFileName(path));
var (map, report) = Retargeter.ResolveMapping(scene.Skeleton);
Assert.Equal(MappingSource.Preset, map.Source);
Assert.Equal("daz_poser", map.ProfileName);
Assert.True(map.Confidence >= 0.8f, $"Confidence {map.Confidence} < 0.8");
Assert.False(report.NeedsUserDecision);
string BoneOf(BoneRole role) => scene.Skeleton[map.RoleToBone[role]].Name;
Assert.Equal("hip", BoneOf(BoneRole.Hips));
Assert.Equal("abdomen", BoneOf(BoneRole.Spine0));
Assert.Equal("chest", BoneOf(BoneRole.Spine1)); // no abdomen2 in this file
Assert.Equal("neck", BoneOf(BoneRole.Neck));
Assert.Equal("head", BoneOf(BoneRole.Head));
Assert.Equal("lCollar", BoneOf(BoneRole.ClavicleL));
Assert.Equal("lShldr", BoneOf(BoneRole.UpperArmL));
Assert.Equal("rShldr", BoneOf(BoneRole.UpperArmR));
Assert.Equal("lForeArm", BoneOf(BoneRole.LowerArmL));
Assert.Equal("rForeArm", BoneOf(BoneRole.LowerArmR));
Assert.Equal("lHand", BoneOf(BoneRole.HandL));
Assert.Equal("rHand", BoneOf(BoneRole.HandR));
Assert.Equal("lThigh", BoneOf(BoneRole.UpperLegL));
Assert.Equal("rThigh", BoneOf(BoneRole.UpperLegR));
Assert.Equal("lShin", BoneOf(BoneRole.LowerLegL));
Assert.Equal("rShin", BoneOf(BoneRole.LowerLegR));
Assert.Equal("lFoot", BoneOf(BoneRole.FootL));
Assert.Equal("rFoot", BoneOf(BoneRole.FootR));
// 2-segment fingers: Prox+Mid resolve, Dist roles stay unmapped, honestly.
Assert.Equal("lThumb1", BoneOf(BoneRole.ThumbProxL));
Assert.Equal("rMid2", BoneOf(BoneRole.MiddleMidR));
Assert.False(map.RoleToBone.ContainsKey(BoneRole.ThumbDistL));
// The lButtock/rButtock thigh helpers and eyes carry no role.
foreach (var (role, boneIndex) in map.RoleToBone)
{
var name = scene.Skeleton[boneIndex].Name;
Assert.DoesNotContain("Buttock", name);
Assert.DoesNotContain("Eye", name);
}
}
[Fact] // skipped (silently green) when the local corpus file is not present
public void DefensesFbx_DetectedAsAutoRigPro_FullMappingBeatsAutoMap()
{
var path = RepoFile("dev", "corpus", "todo", "Defenses.fbx");
if (!File.Exists(path))
return;
var scene = Retargeter.ImportSource(File.ReadAllBytes(path), Path.GetFileName(path));
var (map, report) = Retargeter.ResolveMapping(scene.Skeleton);
Assert.Equal(MappingSource.Preset, map.Source);
Assert.Equal("auto_rig_pro", map.ProfileName);
Assert.True(map.Confidence >= 0.9f, $"Confidence {map.Confidence} < 0.9");
Assert.False(report.NeedsUserDecision);
// Must beat the auto-mapper that used to handle this rig (0.94).
var auto = AutoMapper.Map(scene.Skeleton);
Assert.True(map.Confidence > auto.Confidence,
$"Preset {map.Confidence} must beat auto-map {auto.Confidence}");
string BoneOf(BoneRole role) => scene.Skeleton[map.RoleToBone[role]].Name;
Assert.Equal("root.x", BoneOf(BoneRole.Hips));
Assert.Equal("spine_01.x", BoneOf(BoneRole.Spine0));
Assert.Equal("spine_02.x", BoneOf(BoneRole.Spine1));
Assert.Equal("spine_03.x", BoneOf(BoneRole.Spine2));
Assert.Equal("neck.x", BoneOf(BoneRole.Neck));
Assert.Equal("head.x", BoneOf(BoneRole.Head));
Assert.Equal("shoulder.l", BoneOf(BoneRole.ClavicleL));
Assert.Equal("arm_stretch.l", BoneOf(BoneRole.UpperArmL));
Assert.Equal("forearm_stretch.r", BoneOf(BoneRole.LowerArmR));
Assert.Equal("hand.l", BoneOf(BoneRole.HandL));
Assert.Equal("thigh_stretch.l", BoneOf(BoneRole.UpperLegL));
Assert.Equal("leg_stretch.r", BoneOf(BoneRole.LowerLegR));
Assert.Equal("foot.l", BoneOf(BoneRole.FootL));
Assert.Equal("toes_01.l", BoneOf(BoneRole.ToeL));
Assert.Equal("c_thumb1.l", BoneOf(BoneRole.ThumbProxL));
Assert.Equal("c_middle2.r", BoneOf(BoneRole.MiddleMidR));
Assert.Equal("c_pinky3.l", BoneOf(BoneRole.PinkyDistL));
// All 30 finger phalanx roles resolve; the leftover mixamorig:*4 tip markers and
// the ground bone "root" never carry a role.
var fingers =
from finger in new[] { "Thumb", "Index", "Middle", "Ring", "Pinky" }
from segment in new[] { "Prox", "Mid", "Dist" }
from side in new[] { "L", "R" }
select Enum.Parse<BoneRole>(finger + segment + side);
foreach (var role in fingers)
Assert.True(map.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");
foreach (var (role, boneIndex) in map.RoleToBone)
{
var name = scene.Skeleton[boneIndex].Name;
Assert.False(name == "root" || name.StartsWith("mixamorig:"),
$"{role} mapped to non-deform bone {name}");
}
}
}
using HumanoidRetargeter.Core.Mapping;
using Xunit;
using HumanoidRetargeter.Core;
namespace HumanoidRetargeter.Tests.Mapping;
/// <summary>
/// Local-only mapping/detection checks against the REAL user-reported repro files in
/// <c>dev/corpus/todo/</c> (not committed; every test silently passes when a file is
/// absent):
/// <list type="bullet">
/// <item><c>Neutral_throw_ball_001__A057.bvh</c> — NVIDIA SOMA uniform-proportion skeleton
/// (github.com/NVIDIA/soma-retargeter assets/motions/bvh). Mixamo-identical upper body and
/// fingers but legs <c>LeftLeg→LeftShin</c> ("Leg" is the THIGH). Used to falsely detect
/// "90% mixamo" with both upper legs unmapped.</item>
/// <item><c>Armchair1.bvh</c> — classic MotionBuilder/Character-Studio BVH naming
/// (<c>Chest..Chest4</c>, <c>Collar→Shoulder→Elbow→Wrist</c>, <c>Hip→Knee→Ankle→Toe</c>).</item>
/// <item><c>47_01.bvh</c> — CMU mocap naming (<c>LHipJoint</c> helpers, <c>LowerBack</c>
/// spine start, otherwise mixamo-style limb names).</item>
/// </list>
/// </summary>
public class TodoCorpusRealFileTests
{
/// <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 string TodoFile(string name) => RepoFile("dev", "corpus", "todo", name);
private static readonly BoneRole[] CoreLimbAndAxisRoles =
{
BoneRole.Hips, BoneRole.Spine0, BoneRole.Neck, BoneRole.Head,
BoneRole.ClavicleL, BoneRole.ClavicleR,
BoneRole.UpperArmL, BoneRole.UpperArmR, BoneRole.LowerArmL, BoneRole.LowerArmR,
BoneRole.HandL, BoneRole.HandR,
BoneRole.UpperLegL, BoneRole.UpperLegR, BoneRole.LowerLegL, BoneRole.LowerLegR,
BoneRole.FootL, BoneRole.FootR,
};
// NOTE: the killed profile-support round (SOMA/classic-BVH presets for
// Neutral_throw_ball_001__A057.bvh and Armchair1.bvh) lost its Code-side work; its
// tests were removed with it. Todo.txt still tracks those repros.
[Fact] // skipped (silently green) when the local corpus file is not present
public void Cmu4701_LimbsFullyMappedBothSides()
{
var path = TodoFile("47_01.bvh");
if (!File.Exists(path))
return;
var scene = Retargeter.ImportSource(File.ReadAllBytes(path), Path.GetFileName(path));
var (map, _) = Retargeter.ResolveMapping(scene.Skeleton);
string? BoneOf(BoneRole role)
=> map.RoleToBone.TryGetValue(role, out var i) ? scene.Skeleton[i].Name : null;
// Mapping path is unpinned (CMU naming is mixamo-adjacent: a preset or the
// auto-mapper may resolve it) — what matters is the anatomy.
Assert.Equal("Hips", BoneOf(BoneRole.Hips));
Assert.Equal("LeftUpLeg", BoneOf(BoneRole.UpperLegL));
Assert.Equal("RightUpLeg", BoneOf(BoneRole.UpperLegR));
Assert.Equal("LeftLeg", BoneOf(BoneRole.LowerLegL));
Assert.Equal("RightLeg", BoneOf(BoneRole.LowerLegR));
Assert.Equal("LeftFoot", BoneOf(BoneRole.FootL));
Assert.Equal("LeftToeBase", BoneOf(BoneRole.ToeL));
Assert.Equal("LeftShoulder", BoneOf(BoneRole.ClavicleL));
Assert.Equal("LeftArm", BoneOf(BoneRole.UpperArmL));
Assert.Equal("RightForeArm", BoneOf(BoneRole.LowerArmR));
Assert.Equal("LeftHand", BoneOf(BoneRole.HandL));
Assert.NotNull(BoneOf(BoneRole.Head));
Assert.NotNull(BoneOf(BoneRole.Neck));
Assert.NotNull(BoneOf(BoneRole.Spine0));
foreach (var role in CoreLimbAndAxisRoles)
Assert.True(map.RoleToBone.ContainsKey(role), $"{role} unmapped");
// The LHipJoint/RHipJoint helper joints carry no role.
foreach (var (role, boneIndex) in map.RoleToBone)
Assert.DoesNotContain("HipJoint", scene.Skeleton[boneIndex].Name);
}
}
using System.Numerics;
using System.Text;
using System.Text.Json;
using HumanoidRetargeter.Core.Formats.Fbx;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Solve;
using HumanoidRetargeter.Core.Target;
using HumanoidRetargeter.Tests.Mapping;
using Xunit;
using Skel = HumanoidRetargeter.Core.Skeleton.Skeleton;
namespace HumanoidRetargeter.Tests.Solve;
public class GeometricSolverTests
{
internal const float RadToDeg = 180f / MathF.PI;
// ---------------------------------------------------------------- fixtures (shared with FingerSolverTests)
private static readonly Lazy<SourceScene> CitizenWalk =
new(() => ImportFbx("Citizen@Walk_N.fbx"));
private static readonly Lazy<SourceScene> ZombieCrawl =
new(() => ImportFbx("Zombie Crawl.fbx"));
private static readonly Lazy<SourceScene> ActorCoreCatwalk =
new(() => ImportFbx("catwalk-loop-378982.fbx"));
internal static SourceScene ImportFbx(string name)
=> FbxImporter.Import(
File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "fixtures", "fbx", name)),
new FbxImportOptions());
internal static SourceScene Citizen() => CitizenWalk.Value;
internal static SourceScene Zombie() => ZombieCrawl.Value;
internal static SourceScene ActorCore() => ActorCoreCatwalk.Value;
/// <summary>The shipped s&box target rig (same generated definition as the committed
/// Assets/humanoid_retargeter/target_rig_sbox.json).</summary>
internal static TargetRig SboxRig()
=> TargetRig.Load(TargetRigGenerator.Generate(
File.ReadAllText(MappingFixtures.FixturePath("rig_human_male.json"))));
/// <summary>
/// Builds a TargetRig over an arbitrary (imported) skeleton using the s&box name
/// classifier — the round-trip target: same skeleton, same rest, role/class annotations
/// from <see cref="SboxBoneClassifier"/>. Animated bones the classifier does not know
/// simply get no role (they stay at rest).
/// </summary>
internal static TargetRig RigOverSkeleton(Skel skeleton, string name)
{
using var buffer = new MemoryStream();
using (var writer = new Utf8JsonWriter(buffer))
{
writer.WriteStartObject();
writer.WriteString("name", name);
writer.WriteStartArray("bones");
foreach (var bone in skeleton.Bones)
{
writer.WriteStartObject();
writer.WriteString("name", bone.Name);
if (bone.ParentIndex < 0)
writer.WriteNull("parent");
else
writer.WriteString("parent", skeleton[bone.ParentIndex].Name);
var boneClass = SboxBoneClassifier.Classify(bone.Name);
writer.WriteString("class", boneClass.ToString());
if (boneClass == BoneClass.Animated && SboxBoneClassifier.RoleFor(bone.Name) is BoneRole role)
writer.WriteString("role", role.ToString());
writer.WriteStartArray("local_pos");
writer.WriteNumberValue(bone.RestLocal.Pos.X);
writer.WriteNumberValue(bone.RestLocal.Pos.Y);
writer.WriteNumberValue(bone.RestLocal.Pos.Z);
writer.WriteEndArray();
writer.WriteStartArray("local_rot_xyzw");
writer.WriteNumberValue(bone.RestLocal.Rot.X);
writer.WriteNumberValue(bone.RestLocal.Rot.Y);
writer.WriteNumberValue(bone.RestLocal.Rot.Z);
writer.WriteNumberValue(bone.RestLocal.Rot.W);
writer.WriteEndArray();
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
return TargetRig.Load(Encoding.UTF8.GetString(buffer.ToArray()));
}
/// <summary>Identity source mapping for the round-trip: role → source bone index resolved
/// by the rig's bone names against the source skeleton.</summary>
internal static MappingResult RoleMapByName(Skel source, TargetRig rig)
{
var map = new MappingResult("identity", MappingSource.Manual) { Confidence = 1f };
for (var i = 0; i < rig.Skeleton.Count; i++)
{
if (rig.RoleOf(i) is not BoneRole role)
continue;
var srcIndex = source.IndexOf(rig.Skeleton[i].Name);
Assert.True(srcIndex >= 0, $"source skeleton lacks bone {rig.Skeleton[i].Name}");
map.RoleToBone[role] = srcIndex;
}
return map;
}
internal static MappingResult DetectMap(SourceScene scene, string expectedProfile)
{
var detected = ProfileDetector.Detect(scene.Skeleton);
Assert.NotNull(detected);
Assert.Equal(expectedProfile, detected!.Value.Profile.Name);
return detected.Value.Result;
}
// ---------------------------------------------------------------- helpers
internal static XForm[] Fk(Skel skeleton, XForm[] locals)
{
var world = new XForm[locals.Length];
for (var i = 0; i < locals.Length; i++)
{
var parent = skeleton[i].ParentIndex;
world[i] = parent < 0 ? locals[i] : XForm.Compose(world[parent], locals[i]);
}
return world;
}
internal static float DegBetween(Quaternion a, Quaternion b) => MathQ.AngleBetween(a, b) * RadToDeg;
internal static float DegBetween(Vector3 a, Vector3 b) => MathQ.AngleBetween(a, b) * RadToDeg;
/// <summary>Normalized rest + canonical frames + character-frame basis, exactly like the
/// solver builds them.</summary>
internal static (XForm[] NormRest, CanonicalFrames Canon, Quaternion ChrBasis) NormCanon(
Skel skeleton, MappingResult map)
{
var (norm, _) = RestNormalizer.Normalize(skeleton, map);
var canon = CanonicalFrames.Build(skeleton, map, norm.WorldRest);
return (norm.WorldRest, canon, MathQ.BasisFromForwardUp(canon.CharacterForward, canon.CharacterUp));
}
/// <summary>All chains of consecutive anatomical roles (pinned independently of the
/// production tables).</summary>
internal static IEnumerable<BoneRole[]> ChainDefinitions()
{
yield return new[]
{
BoneRole.Hips, BoneRole.Spine0, BoneRole.Spine1, BoneRole.Spine2, BoneRole.Spine3,
BoneRole.Spine4, BoneRole.Neck, BoneRole.Head,
};
foreach (var s in new[] { "L", "R" })
{
yield return new[] { R("Clavicle", s), R("UpperArm", s), R("LowerArm", s), R("Hand", s) };
yield return new[] { R("UpperLeg", s), R("LowerLeg", s), R("Foot", s), R("Toe", s) };
foreach (var f in new[] { "Thumb", "Index", "Middle", "Ring", "Pinky" })
yield return new[] { R(f + "Meta", s), R(f + "Prox", s), R(f + "Mid", s), R(f + "Dist", s) };
}
}
internal static BoneRole R(string baseName, string side) => Enum.Parse<BoneRole>(baseName + side);
/// <summary>Consecutive role pairs mapped on BOTH the source map and the target rig.</summary>
internal static List<(BoneRole A, BoneRole B)> MappedChainPairs(MappingResult srcMap, TargetRig rig)
{
var pairs = new List<(BoneRole, BoneRole)>();
foreach (var chain in ChainDefinitions())
{
var mapped = chain
.Where(r => srcMap.RoleToBone.ContainsKey(r) && rig.BoneForRole(r) is not null)
.ToArray();
for (var i = 0; i + 1 < mapped.Length; i++)
pairs.Add((mapped[i], mapped[i + 1]));
}
return pairs;
}
/// <summary>5 frame indices spread over a clip.</summary>
internal static int[] SampleFrames(int frameCount)
=> new[] { 0, frameCount / 4, frameCount / 2, 3 * frameCount / 4, frameCount - 1 }
.Distinct().ToArray();
/// <summary>
/// The cross-rig direction metric: worldspace anatomical direction (chain child head −
/// bone head), each expressed in its own rig's character-frame coordinates; returns the
/// per-pair angles in degrees.
/// </summary>
internal static IEnumerable<(BoneRole A, BoneRole B, float Deg)> DirectionErrors(
Skel src, MappingResult srcMap, XForm[] srcWorld, Quaternion srcChr,
TargetRig rig, XForm[] tgtWorld, Quaternion tgtChr)
{
var srcChrInv = Quaternion.Conjugate(srcChr);
var tgtChrInv = Quaternion.Conjugate(tgtChr);
foreach (var (a, b) in MappedChainPairs(srcMap, rig))
{
var ds = srcWorld[srcMap.RoleToBone[b]].Pos - srcWorld[srcMap.RoleToBone[a]].Pos;
var dt = tgtWorld[rig.BoneForRole(b)!.Value].Pos - tgtWorld[rig.BoneForRole(a)!.Value].Pos;
if (ds.LengthSquared() < 1e-8f || dt.LengthSquared() < 1e-8f)
continue;
var dsc = Vector3.Transform(Vector3.Normalize(ds), srcChrInv);
var dtc = Vector3.Transform(Vector3.Normalize(dt), tgtChrInv);
yield return (a, b, DegBetween(dsc, dtc));
}
}
// ================================================================ A. citizen round-trip (identity gate)
[Fact]
public void CitizenRoundTrip_IsIdentity_ForAllMappedBones()
{
var scene = Citizen();
var rig = RigOverSkeleton(scene.Skeleton, "citizen_roundtrip");
var srcMap = RoleMapByName(scene.Skeleton, rig);
var clip = scene.Clips[0];
var solved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());
Assert.Equal(clip.FrameCount, solved.FrameCount);
Assert.Equal(clip.Fps, solved.Fps);
Assert.Equal(clip.Looping, solved.Looping);
// (source bone, target bone, name) for every mapped role.
var mapped = new List<(int Src, int Tgt, string Name)>();
for (var i = 0; i < rig.Skeleton.Count; i++)
{
if (rig.RoleOf(i) is not BoneRole role)
continue;
mapped.Add((srcMap.RoleToBone[role], i, $"{rig.Skeleton[i].Name} ({role})"));
}
Assert.True(mapped.Count >= 50, $"only {mapped.Count} mapped bones in the round-trip");
float maxRot = 0f, maxPos = 0f;
var worst = "";
for (var f = 0; f < clip.FrameCount; f++)
{
var srcWorld = Fk(scene.Skeleton, clip.Frames[f]);
var outWorld = Fk(rig.Skeleton, solved.Frames[f]);
foreach (var (s, t, name) in mapped)
{
var rotDeg = DegBetween(srcWorld[s].Rot, outWorld[t].Rot);
var posCm = (srcWorld[s].Pos - outWorld[t].Pos).Length();
if (rotDeg > maxRot)
{
maxRot = rotDeg;
worst = $"{name} @ frame {f}";
}
maxPos = MathF.Max(maxPos, posCm);
}
}
Assert.True(maxRot <= 0.5f, $"round-trip rotation error {maxRot:F3} deg (worst: {worst})");
Assert.True(maxPos <= 0.2f, $"round-trip position error {maxPos:F3} cm");
}
[Fact]
public void CitizenRoundTrip_PelvisPosition_IsIdentity()
{
var scene = Citizen();
var rig = RigOverSkeleton(scene.Skeleton, "citizen_roundtrip");
var srcMap = RoleMapByName(scene.Skeleton, rig);
var clip = scene.Clips[0];
var solved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());
var srcPelvis = srcMap.RoleToBone[BoneRole.Hips];
var tgtPelvis = rig.BoneForRole(BoneRole.Hips)!.Value;
var maxPos = 0f;
for (var f = 0; f < clip.FrameCount; f++)
{
var srcWorld = Fk(scene.Skeleton, clip.Frames[f]);
var outWorld = Fk(rig.Skeleton, solved.Frames[f]);
maxPos = MathF.Max(maxPos, (srcWorld[srcPelvis].Pos - outWorld[tgtPelvis].Pos).Length());
}
Assert.True(maxPos <= 0.2f, $"pelvis round-trip error {maxPos:F3} cm");
}
/// <summary>
/// Per-role canonical rest-direction divergence between the two rigs (angle between each
/// rig's rest chain direction in its own character frame) — the constant offset that
/// <see cref="RoleTransferMode.DeltaFromRest"/> roles intentionally keep instead of
/// adopting the source's rest carriage.
/// </summary>
internal static float RestDivergenceDeg(
BoneRole role, Skel src, MappingResult srcMap, TargetRig rig)
{
var (_, srcCanon, srcChr) = NormCanon(src, srcMap);
var (_, tgtCanon, tgtChr) = NormCanon(rig.Skeleton, rig.ToMappingResult());
var ds = Vector3.Transform(
Vector3.UnitX, Quaternion.Conjugate(srcChr) * srcCanon.WorldFrameOf(role));
var dt = Vector3.Transform(
Vector3.UnitX, Quaternion.Conjugate(tgtChr) * tgtCanon.WorldFrameOf(role));
return DegBetween(ds, dt);
}
/// <summary>
/// Asserts the cross-rig direction contract on 5 sample frames: absolute-mode roles match
/// the source direction within 3°, while delta-mode roles (clavicles, neck — they keep the
/// target's own rest carriage by design) stay within their rest divergence + 5°.
/// </summary>
private static void AssertDirectionContract(SourceScene scene, MappingResult srcMap)
{
var rig = SboxRig();
var clip = scene.Clips[0];
var solved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());
var (_, _, srcChr) = NormCanon(scene.Skeleton, srcMap);
var (_, _, tgtChr) = NormCanon(rig.Skeleton, rig.ToMappingResult());
var maxStrict = 0f;
var maxDeltaOver = float.MinValue; // worst (error − per-role bound); ≤ 0 passes
string worstStrict = "", worstDelta = "";
var bounds = new Dictionary<BoneRole, float>();
foreach (var f in SampleFrames(clip.FrameCount))
{
var srcWorld = Fk(scene.Skeleton, clip.Frames[f]);
var tgtWorld = Fk(rig.Skeleton, solved.Frames[f]);
foreach (var (a, b, deg) in DirectionErrors(
scene.Skeleton, srcMap, srcWorld, srcChr, rig, tgtWorld, tgtChr))
{
if (SolveOptions.DefaultTransferModes.ContainsKey(a))
{
if (!bounds.TryGetValue(a, out var bound))
bounds[a] = bound = RestDivergenceDeg(a, scene.Skeleton, srcMap, rig) + 5f;
if (deg - bound > maxDeltaOver)
{
maxDeltaOver = deg - bound;
worstDelta = $"{a}->{b} @ frame {f}: {deg:F2} deg (bound {bound:F2})";
}
}
else if (deg > maxStrict)
{
maxStrict = deg;
worstStrict = $"{a}->{b} @ frame {f}";
}
}
}
Assert.True(maxStrict <= 3.0f, $"direction error {maxStrict:F2} deg (worst: {worstStrict})");
Assert.True(maxDeltaOver <= 0f,
$"delta-mode direction sanity exceeded rest divergence + 5 deg: {worstDelta}");
}
// ================================================================ B. cross-rig direction gate (Mixamo)
[Fact]
public void ZombieCrawl_ToSbox_DirectionsMatchWithin3Degrees()
=> AssertDirectionContract(Zombie(), DetectMap(Zombie(), "mixamo"));
[Fact]
public void ZombieCrawl_ToSbox_PelvisTravelScalesWithHipRatio()
{
var scene = Zombie();
var srcMap = DetectMap(scene, "mixamo");
var rig = SboxRig();
var clip = scene.Clips[0];
var solved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());
var (_, srcCanon, srcChr) = NormCanon(scene.Skeleton, srcMap);
var (_, tgtCanon, tgtChr) = NormCanon(rig.Skeleton, rig.ToMappingResult());
var srcPelvis = srcMap.RoleToBone[BoneRole.Hips];
var tgtPelvis = rig.BoneForRole(BoneRole.Hips)!.Value;
Vector3 Horizontal(Vector3 worldTravel, Quaternion chr)
{
var v = Vector3.Transform(worldTravel, Quaternion.Conjugate(chr));
return new Vector3(v.X, v.Y, 0f); // character basis: Z = up
}
var srcTravel = Horizontal(
Fk(scene.Skeleton, clip.Frames[^1])[srcPelvis].Pos
- Fk(scene.Skeleton, clip.Frames[0])[srcPelvis].Pos, srcChr);
var tgtTravel = Horizontal(
Fk(rig.Skeleton, solved.Frames[^1])[tgtPelvis].Pos
- Fk(rig.Skeleton, solved.Frames[0])[tgtPelvis].Pos, tgtChr);
Assert.True(srcTravel.Length() > 5f,
$"fixture sanity: source pelvis should travel (got {srcTravel.Length():F1} cm)");
var expected = tgtCanon.HipHeight / srcCanon.HipHeight;
var ratio = tgtTravel.Length() / srcTravel.Length();
Assert.InRange(ratio / expected, 0.85f, 1.15f);
}
// ================================================================ C. ActorCore cross-axis gate (Z-up source)
[Fact]
public void ActorCoreCatwalk_ToSbox_DirectionsMatchWithin3Degrees()
=> AssertDirectionContract(ActorCore(), DetectMap(ActorCore(), "actorcore_cc"));
// ================================================================ D. per-role transfer modes
[Fact]
public void DefaultTransferModes_AreClavicleNeckDelta_AndHeadFeetCharacterDelta()
{
var modes = SolveOptions.DefaultTransferModes;
Assert.Equal(RoleTransferMode.DeltaFromRest, modes[BoneRole.ClavicleL]);
Assert.Equal(RoleTransferMode.DeltaFromRest, modes[BoneRole.ClavicleR]);
Assert.Equal(RoleTransferMode.DeltaFromRest, modes[BoneRole.Neck]);
Assert.Equal(RoleTransferMode.CharacterDeltaFromRest, modes[BoneRole.Head]);
Assert.Equal(RoleTransferMode.CharacterDeltaFromRest, modes[BoneRole.FootL]);
Assert.Equal(RoleTransferMode.CharacterDeltaFromRest, modes[BoneRole.FootR]);
// Spine/limbs/toes stay absolute. The head keeps the target's neutral skull
// attitude (rest neck→head lean is head-joint-placement anatomy, 0–27° across
// neutral rigs); on a POSED-rest source the solver falls back to absolute gaze
// matching instead — see TransferModeContractTests.
Assert.Equal(6, modes.Count);
}
/// <summary>
/// With the source posed at its own rest, a delta-mode role's source delta is identity, so
/// the target bone must sit at its own normalized rest orientation (its natural carriage)
/// even though the rigs' rest directions diverge — the defining property of
/// <see cref="RoleTransferMode.DeltaFromRest"/>. An all-absolute override must instead
/// adopt the source's rest direction (the legacy behavior).
/// </summary>
[Fact]
public void SourceAtRest_DeltaRoles_KeepTargetRestCarriage()
{
var skeleton = Zombie().Skeleton;
var srcMap = DetectMap(Zombie(), "mixamo");
var rig = SboxRig();
var scene = SceneFromWorldFrame(skeleton, skeleton.RestWorld.ToArray(), "rest_frame");
// Fixture sanity: this source's clavicle line genuinely diverges from the target's.
var clavDiv = RestDivergenceDeg(BoneRole.ClavicleL, skeleton, srcMap, rig);
Assert.True(clavDiv > 5f, $"fixture sanity: clavicle rest divergence {clavDiv:F1} deg");
var (tgtNorm, _) = RestNormalizer.Normalize(rig.Skeleton, rig.ToMappingResult());
// Mixamo rests are unnormalized T-poses for the body (only arms could be swung, and
// Mixamo is already a T-pose), so the source delta at this frame is identity for the
// delta roles. RestNormalizer never touches clavicles or the neck regardless.
var deltaSolved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());
var absSolved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions
{
TransferModes = new Dictionary<BoneRole, RoleTransferMode>(),
});
var deltaWorld = Fk(rig.Skeleton, deltaSolved.Frames[0]);
var absWorld = Fk(rig.Skeleton, absSolved.Frames[0]);
// Head included: its CharacterDeltaFromRest default shares the defining property
// (source at rest → target keeps its own neutral skull attitude). Zombie's rest head
// is neutral, so the posed-rest gaze fallback stays dormant here.
foreach (var role in new[]
{
BoneRole.ClavicleL, BoneRole.ClavicleR, BoneRole.Neck, BoneRole.Head,
})
{
var bone = rig.BoneForRole(role)!.Value;
var restRot = tgtNorm.WorldRest[bone].Rot;
var deltaDeg = DegBetween(deltaWorld[bone].Rot, restRot);
Assert.True(deltaDeg <= 0.5f,
$"{role}: delta mode should keep the target rest orientation, off {deltaDeg:F2} deg");
var absDeg = DegBetween(absWorld[bone].Rot, restRot);
Assert.True(absDeg > 2f,
$"{role}: all-absolute override should adopt the source rest carriage "
+ $"(only {absDeg:F2} deg from target rest — override not effective?)");
}
// Absolute roles are unaffected by the mode map: same output on both solves.
foreach (var role in new[] { BoneRole.HandR, BoneRole.UpperArmL, BoneRole.Spine0 })
{
var bone = rig.BoneForRole(role)!.Value;
Assert.True(DegBetween(deltaWorld[bone].Rot, absWorld[bone].Rot) <= 1e-3f,
$"{role} should be identical across mode maps");
}
}
/// <summary>
/// A toe-less source's foot direction is a virtual character-forward extension; any
/// direction-matching against it is meaningless (measured: constant ~41° heel-standing
/// on the makehuman/daz rig under absolute matching). The solver must fall back to
/// canonical delta for feet in that case: at source rest the target foot keeps its own
/// rest orientation. The toed default (CharacterDeltaFromRest) shares that property —
/// the contrast is the explicit all-absolute map, which adopts the source direction.
/// </summary>
[Fact]
public void ToelessSource_FootFallsBackToDelta()
{
var skeleton = Zombie().Skeleton;
var rig = SboxRig();
var scene = SceneFromWorldFrame(skeleton, skeleton.RestWorld.ToArray(), "rest_frame");
var toeless = DetectMap(Zombie(), "mixamo");
toeless.RoleToBone.Remove(BoneRole.ToeL);
toeless.RoleToBone.Remove(BoneRole.ToeR);
var (tgtNorm, _) = RestNormalizer.Normalize(rig.Skeleton, rig.ToMappingResult());
var solved = new GeometricSolver().Solve(scene, toeless, rig, new SolveOptions());
var world = Fk(rig.Skeleton, solved.Frames[0]);
foreach (var role in new[] { BoneRole.FootL, BoneRole.FootR })
{
var bone = rig.BoneForRole(role)!.Value;
var deg = DegBetween(world[bone].Rot, tgtNorm.WorldRest[bone].Rot);
Assert.True(deg <= 0.5f,
$"{role}: toe-less source should keep the target foot rest carriage, off {deg:F2} deg");
}
// Control: under an explicit all-absolute map, the same rest frame drives the foot
// to the SOURCE's direction, away from the target rest (rigs' foot pitches differ).
var withToes = DetectMap(Zombie(), "mixamo");
var absSolved = new GeometricSolver().Solve(scene, withToes, rig, new SolveOptions
{
TransferModes = new Dictionary<BoneRole, RoleTransferMode>(),
});
var absWorld = Fk(rig.Skeleton, absSolved.Frames[0]);
var footBone = rig.BoneForRole(BoneRole.FootL)!.Value;
Assert.True(DegBetween(absWorld[footBone].Rot, tgtNorm.WorldRest[footBone].Rot) > 1.5f,
"control: explicit absolute mode should adopt the source foot direction");
}
/// <summary>
/// The TransferModes contract (xmldoc on <see cref="SolveOptions.TransferModes"/>): a
/// NON-NULL map replaces the defaults entirely AND disables every fallback heuristic —
/// an explicit (or implicit-absent = absolute) foot entry must win over the virtual-foot
/// delta fallback that null-mode solves apply on toe-less sources
/// (<see cref="ToelessSource_FootFallsBackToDelta"/> pins the null behavior).
/// </summary>
[Fact]
public void ExplicitTransferModes_DisableVirtualFootFallback()
{
var skeleton = Zombie().Skeleton;
var rig = SboxRig();
var scene = SceneFromWorldFrame(skeleton, skeleton.RestWorld.ToArray(), "rest_frame");
var toeless = DetectMap(Zombie(), "mixamo");
toeless.RoleToBone.Remove(BoneRole.ToeL);
toeless.RoleToBone.Remove(BoneRole.ToeR);
var (tgtNorm, _) = RestNormalizer.Normalize(rig.Skeleton, rig.ToMappingResult());
// Explicit EMPTY map = all-absolute, no heuristics: the toe-less source's virtual
// (character-forward) foot direction is imposed on the target — the foot must leave
// its target rest carriage, unlike the null-mode fallback which keeps it there.
var absSolved = new GeometricSolver().Solve(scene, toeless, rig, new SolveOptions
{
TransferModes = new Dictionary<BoneRole, RoleTransferMode>(),
});
var absWorld2 = Fk(rig.Skeleton, absSolved.Frames[0]);
foreach (var role in new[] { BoneRole.FootL, BoneRole.FootR })
{
var bone = rig.BoneForRole(role)!.Value;
var deg = DegBetween(absWorld2[bone].Rot, tgtNorm.WorldRest[bone].Rot);
Assert.True(deg > 1.5f,
$"{role}: explicit all-absolute map must be honored on a toe-less source "
+ $"(virtual-foot fallback must not override it) — only {deg:F2} deg from target rest");
}
// Explicit per-role DELTA entries are honored exactly too (same outcome as the
// null-mode fallback, but by the caller's explicit choice).
var deltaSolved = new GeometricSolver().Solve(scene, toeless, rig, new SolveOptions
{
TransferModes = new Dictionary<BoneRole, RoleTransferMode>
{
[BoneRole.FootL] = RoleTransferMode.DeltaFromRest,
[BoneRole.FootR] = RoleTransferMode.DeltaFromRest,
},
});
var deltaWorld = Fk(rig.Skeleton, deltaSolved.Frames[0]);
foreach (var role in new[] { BoneRole.FootL, BoneRole.FootR })
{
var bone = rig.BoneForRole(role)!.Value;
var deg = DegBetween(deltaWorld[bone].Rot, tgtNorm.WorldRest[bone].Rot);
Assert.True(deg <= 0.5f,
$"{role}: explicit DeltaFromRest entry should keep the target rest carriage "
+ $"at source rest, off {deg:F2} deg");
}
}
/// <summary>Delta-mode roles still move 1:1 with the source: rotating the source neck by
/// 20° about the character lateral axis must rotate the target neck by 20° too (the delta
/// is replayed, only the rest carriage stays the target's own).</summary>
[Fact]
public void DeltaMode_ReplaysSourceRotationDelta()
{
var skeleton = Zombie().Skeleton;
var srcMap = DetectMap(Zombie(), "mixamo");
var rig = SboxRig();
var (_, srcCanon, _) = NormCanon(skeleton, srcMap);
var lateral = Vector3.Cross(srcCanon.CharacterUp, srcCanon.CharacterForward);
var bent = skeleton.RestWorld.ToArray();
var srcNeck = srcMap.RoleToBone[BoneRole.Neck];
RotateWorldSubtree(skeleton, bent, srcNeck,
Quaternion.CreateFromAxisAngle(lateral, 20f / RadToDeg), bent[srcNeck].Pos);
var restScene = SceneFromWorldFrame(skeleton, skeleton.RestWorld.ToArray(), "rest");
var bentScene = SceneFromWorldFrame(skeleton, bent, "neck_bend");
var solver = new GeometricSolver();
var restOut = Fk(rig.Skeleton, solver.Solve(restScene, srcMap, rig, new SolveOptions()).Frames[0]);
var bentOut = Fk(rig.Skeleton, solver.Solve(bentScene, srcMap, rig, new SolveOptions()).Frames[0]);
var tgtNeck = rig.BoneForRole(BoneRole.Neck)!.Value;
var moved = DegBetween(restOut[tgtNeck].Rot, bentOut[tgtNeck].Rot);
Assert.True(MathF.Abs(moved - 20f) <= 1.0f,
$"target neck rotated {moved:F2} deg for a 20 deg source neck bend");
}
[Fact]
public void ActorCoreCatwalk_ToSbox_HandsEndOnCorrectSides()
{
var scene = ActorCore();
var srcMap = DetectMap(scene, "actorcore_cc");
var rig = SboxRig();
var clip = scene.Clips[0];
var solved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());
var (_, srcCanon, _) = NormCanon(scene.Skeleton, srcMap);
var (_, tgtCanon, _) = NormCanon(rig.Skeleton, rig.ToMappingResult());
var srcLateral = Vector3.Cross(srcCanon.CharacterUp, srcCanon.CharacterForward);
var tgtLateral = Vector3.Cross(tgtCanon.CharacterUp, tgtCanon.CharacterForward);
var srcPelvis = srcMap.RoleToBone[BoneRole.Hips];
var tgtPelvis = rig.BoneForRole(BoneRole.Hips)!.Value;
foreach (var f in SampleFrames(clip.FrameCount))
{
var srcWorld = Fk(scene.Skeleton, clip.Frames[f]);
var tgtWorld = Fk(rig.Skeleton, solved.Frames[f]);
foreach (var (role, name) in new[] { (BoneRole.HandL, "left"), (BoneRole.HandR, "right") })
{
var srcSide = Vector3.Dot(
srcWorld[srcMap.RoleToBone[role]].Pos - srcWorld[srcPelvis].Pos, srcLateral);
var tgtSide = Vector3.Dot(
tgtWorld[rig.BoneForRole(role)!.Value].Pos - tgtWorld[tgtPelvis].Pos, tgtLateral);
Assert.True(srcSide * tgtSide > 0f,
$"{name} hand crossed sides at frame {f} (src {srcSide:F1}, tgt {tgtSide:F1})");
}
}
}
// ================================================================ spine interpolation (5 → 3 bones)
[Fact]
public void UeMannequinSpine_FiveToThree_InterpolatesBend()
{
var skeleton = MappingFixtures.BuildUeMannequin();
var detected = ProfileDetector.Detect(skeleton);
Assert.NotNull(detected);
Assert.Equal("ue_mannequin", detected!.Value.Profile.Name);
var srcMap = detected.Value.Result;
// 5 source spine bones must actually be mapped for this to exercise interpolation.
var srcSpines = new[]
{
BoneRole.Spine0, BoneRole.Spine1, BoneRole.Spine2, BoneRole.Spine3, BoneRole.Spine4,
}.Count(r => srcMap.RoleToBone.ContainsKey(r));
Assert.Equal(5, srcSpines);
// Build a 1-frame clip bending each spine bone 10 deg forward (about character lateral).
var (_, canon, _) = NormCanon(skeleton, srcMap);
var lateral = Vector3.Cross(canon.CharacterUp, canon.CharacterForward);
var world = skeleton.RestWorld.ToArray();
foreach (var role in new[]
{
BoneRole.Spine0, BoneRole.Spine1, BoneRole.Spine2, BoneRole.Spine3, BoneRole.Spine4,
})
{
var bone = srcMap.RoleToBone[role];
RotateWorldSubtree(skeleton, world, bone,
Quaternion.CreateFromAxisAngle(lateral, 10f / RadToDeg), world[bone].Pos);
}
var scene = SceneFromWorldFrame(skeleton, world, "spine_bend");
var rig = SboxRig();
var solved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());
// The target spine must bend by the same amount as the source: compare each rig's
// chord (Spine0 head -> Neck head) bend angle between rest and the solved pose.
// (The chord's absolute direction is proportion-sensitive — different spine station
// spacing shifts it a few degrees even when every segment direction interpolates
// correctly — so the invariant is the bend angle, not the chord itself.)
var tgtWorld = Fk(rig.Skeleton, solved.Frames[0]);
Vector3 Chord(IReadOnlyList<XForm> w, int from, int to) => w[to].Pos - w[from].Pos;
var srcBend = DegBetween(
Chord(skeleton.RestWorld, srcMap.RoleToBone[BoneRole.Spine0], srcMap.RoleToBone[BoneRole.Neck]),
Chord(world, srcMap.RoleToBone[BoneRole.Spine0], srcMap.RoleToBone[BoneRole.Neck]));
var tgtBend = DegBetween(
Chord(rig.Skeleton.RestWorld, rig.BoneForRole(BoneRole.Spine0)!.Value, rig.BoneForRole(BoneRole.Neck)!.Value),
Chord(tgtWorld, rig.BoneForRole(BoneRole.Spine0)!.Value, rig.BoneForRole(BoneRole.Neck)!.Value));
Assert.True(srcBend > 25f, $"fixture sanity: source spine should bend (got {srcBend:F1} deg)");
Assert.True(MathF.Abs(srcBend - tgtBend) <= 3.0f,
$"spine bend {tgtBend:F2} deg vs source {srcBend:F2} deg");
// And every target spine bone actually moved (no rest freeze on the interpolated path).
foreach (var role in new[] { BoneRole.Spine0, BoneRole.Spine1, BoneRole.Spine2 })
{
var bone = rig.BoneForRole(role)!.Value;
Assert.True(
DegBetween(solved.Frames[0][bone].Rot, rig.Skeleton[bone].RestLocal.Rot) > 1f,
$"{role} did not move");
}
}
// ================================================================ E. determinism + rest preservation
[Fact]
public void Solve_IsDeterministic()
{
var scene = Zombie();
var srcMap = DetectMap(scene, "mixamo");
var rig = SboxRig();
var a = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());
var b = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());
Assert.Equal(a.FrameCount, b.FrameCount);
for (var f = 0; f < a.FrameCount; f++)
{
for (var i = 0; i < a.Frames[f].Length; i++)
Assert.Equal(a.Frames[f][i], b.Frames[f][i]);
}
}
[Fact]
public void Solve_PreservesRest_ForUnmappedAndNonAnimatedBones()
{
var scene = Zombie();
var srcMap = DetectMap(scene, "mixamo");
var rig = SboxRig();
var solved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());
var restBones = new List<int>();
for (var i = 0; i < rig.Skeleton.Count; i++)
{
var role = rig.RoleOf(i);
// ConstraintDriven/IkBaked, role-less animated bones, and animated bones whose
// role the Mixamo source does not map (e.g. finger metacarpals) must stay at rest.
if (rig.ClassOf(i) != BoneClass.Animated
|| role is null
|| !srcMap.RoleToBone.ContainsKey(role.Value))
{
restBones.Add(i);
}
}
Assert.True(restBones.Count >= 30, $"only {restBones.Count} rest-pose bones — fixture changed?");
foreach (var frame in solved.Frames)
{
foreach (var i in restBones)
Assert.Equal(rig.Skeleton[i].RestLocal, frame[i]);
}
}
// ---------------------------------------------------------------- frame-construction helpers (shared)
/// <summary>Hierarchically rotates the world rest of <paramref name="root"/> and its
/// descendants about a pivot (positions orbit, orientations premultiplied).</summary>
internal static void RotateWorldSubtree(Skel skeleton, XForm[] world, int root, Quaternion q, Vector3 pivot)
{
var inSubtree = new bool[skeleton.Count];
inSubtree[root] = true;
for (var i = root; i < skeleton.Count; i++)
{
if (i != root)
{
var parent = skeleton[i].ParentIndex;
if (parent < 0 || !inSubtree[parent])
continue;
inSubtree[i] = true;
}
world[i] = new XForm(
pivot + Vector3.Transform(world[i].Pos - pivot, q),
MathQ.Normalize(q * world[i].Rot));
}
}
/// <summary>Wraps a world-space pose as a 1-frame SourceScene clip (locals derived
/// against the skeleton hierarchy).</summary>
internal static SourceScene SceneFromWorldFrame(Skel skeleton, XForm[] world, string clipName)
{
var locals = new XForm[skeleton.Count];
for (var i = 0; i < skeleton.Count; i++)
{
var parent = skeleton[i].ParentIndex;
locals[i] = parent < 0 ? world[i] : XForm.ToLocal(world[parent], world[i]);
}
var clip = new Clip(clipName, 30f, false);
clip.Frames.Add(locals);
return new SourceScene(skeleton, new[] { clip }, 1f);
}
}
using System.Numerics;
using HumanoidRetargeter.Core.Cleanup;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Solve;
using HumanoidRetargeter.Core.Target;
using SkeletonModel = HumanoidRetargeter.Core.Skeleton.Skeleton;
using Xunit;
using HumanoidRetargeter.Core;
namespace HumanoidRetargeter.Tests.Solve;
/// <summary>
/// Local-only planted-foot quality gates against the REAL user-reported repro files in
/// <c>dev/corpus/todo/</c> (not committed; every test silently passes when a file is
/// absent). User reports: 47_01.bvh "feet are a little bent upward", Defenses.fbx "feet
/// seem to be slightly bent inward, like part of the bone sticks out" — both were the
/// rest foot-direction divergence (23–44° vs the s&box ankle) leaking through absolute
/// direction matching. The fix: feet transfer as
/// <see cref="RoleTransferMode.CharacterDeltaFromRest"/>, plus the
/// <see cref="FootGroundAlign"/> stance recalibration when the source's normalized rest is
/// not a plausible stance (Defenses: T-pose normalization swings its bent-leg rest feet
/// 14–26° off the rig's true stance).
/// </summary>
public class GroundedFeetReproTests
{
private const float Rad2Deg = 180f / MathF.PI;
// ---------------------------------------------------------------- gates
/// <summary>CMU-style rig whose rest IS a stance: the full pipeline must track the
/// source's planted foot pitch (deviation from each rig's own rest anatomy) — the old
/// constant ~23–25° "bent upward" leak is gone, and the stance recalibration must stay
/// dormant (the planted offset is genuine articulation).</summary>
[Fact] // skipped (silently green) when the local corpus file is not present
public void Cmu4701_PlantedFootPitch_TracksSource_Within3Degrees()
{
if (Load("47_01.bvh") is not { } data)
return;
foreach (var side in Sides(data))
{
Assert.True(side.PlantedFrames >= 30, $"{side.Name}: too few planted frames");
Assert.True(MathF.Abs(side.MeanPitchErrVsSource) <= 3f,
$"{side.Name}: planted pitch error vs source {side.MeanPitchErrVsSource:F2} deg");
Assert.True(side.MaxAbsPitchErrVsSource <= 5f,
$"{side.Name}: planted pitch error max {side.MaxAbsPitchErrVsSource:F2} deg");
}
}
/// <summary>ARP-style rig whose (normalized) rest is NOT a stance: the recalibration
/// must level the planted soles against the ground — mean planted pitch deviation from
/// the target's own flat-stance rest ≈ 0 (was +15…+17° "bent inward/upward" before, and
/// would be −12…−25° toe-through-floor under a plain rest-relative replay).</summary>
[Fact] // skipped (silently green) when the local corpus file is not present
public void Defenses_PlantedFeet_LevelWithGround_Within3Degrees()
{
if (Load("Defenses.fbx") is not { } data)
return;
foreach (var side in Sides(data))
{
Assert.True(side.PlantedFrames >= 100, $"{side.Name}: too few planted frames");
Assert.True(MathF.Abs(side.MeanTargetPitchDelta) <= 3f,
$"{side.Name}: planted feet not level with ground — mean pitch delta "
+ $"{side.MeanTargetPitchDelta:F2} deg from the target's flat-stance rest");
}
}
// ---------------------------------------------------------------- pipeline + metrics
private sealed record SideStats(
string Name, int PlantedFrames, float MeanTargetPitchDelta,
float MeanPitchErrVsSource, float MaxAbsPitchErrVsSource);
private sealed record ReproData(
SourceScene Scene, MappingResult Map, TargetRig Rig, List<XForm[]> TargetFrames);
private static ReproData? Load(string todoFileName)
{
var path = RepoFile("dev", "corpus", "todo", todoFileName);
if (!File.Exists(path))
return null;
var bytes = File.ReadAllBytes(path);
var scene = Retargeter.ImportSource(bytes, todoFileName);
var (map, _) = Retargeter.ResolveMapping(scene.Skeleton);
var target = RetargetTargetSpec.SboxDefault(
File.ReadAllText(RepoFile("Assets", "data", "humanoid_retargeter", "target_rig_sbox.json")));
var result = Retargeter.Convert(new RetargetRequest
{
SourceData = bytes,
SourceFileName = todoFileName,
MappingOverride = map,
}, target);
var clip = result.Clips.First(c => c.Success);
return new ReproData(scene, map, target.Rig, clip.SolvedFrames!);
}
private static IEnumerable<SideStats> Sides(ReproData data)
{
var (scene, map, rig, tgtFrames) = (data.Scene, data.Map, data.Rig, data.TargetFrames);
var srcSkel = scene.Skeleton;
var tgtSkel = rig.Skeleton;
var tgtMap = rig.ToMappingResult();
var (srcNorm, _) = RestNormalizer.Normalize(srcSkel, map);
var (tgtNorm, _) = RestNormalizer.Normalize(tgtSkel, tgtMap);
var srcUp = Vector3.Normalize(CanonicalFrames.Build(srcSkel, map, srcNorm.WorldRest).CharacterUp);
var tgtUp = Vector3.Normalize(CanonicalFrames.Build(tgtSkel, tgtMap, tgtNorm.WorldRest).CharacterUp);
FootChain Chain(Func<BoneRole, int?> bone, BoneRole u, BoneRole l, BoneRole f, BoneRole t) => new()
{
Hip = bone(u)!.Value,
Knee = bone(l)!.Value,
Ankle = bone(f)!.Value,
Toe = bone(t),
};
int? SrcBone(BoneRole r) => map.RoleToBone.TryGetValue(r, out var b) ? b : null;
var srcL = Chain(SrcBone, BoneRole.UpperLegL, BoneRole.LowerLegL, BoneRole.FootL, BoneRole.ToeL);
var srcR = Chain(SrcBone, BoneRole.UpperLegR, BoneRole.LowerLegR, BoneRole.FootR, BoneRole.ToeR);
var clip = scene.Clips[0];
var (plantsL, plantsR) = FootPlant.DetectPlantIntervals(
clip.Frames, srcSkel, srcL, srcR, srcUp, clip.Fps);
foreach (var (name, footRole, toeRole, plants) in new[]
{
("LEFT", BoneRole.FootL, BoneRole.ToeL, plantsL),
("RIGHT", BoneRole.FootR, BoneRole.ToeR, plantsR),
})
{
var srcPitch = PitchDeltas(
clip.Frames, srcSkel, map.RoleToBone[footRole], map.RoleToBone[toeRole],
srcNorm.WorldRest, srcUp);
var tgtPitch = PitchDeltas(
tgtFrames, tgtSkel, rig.BoneForRole(footRole)!.Value, rig.BoneForRole(toeRole)!.Value,
tgtNorm.WorldRest, tgtUp);
var planted = new List<int>();
foreach (var p in plants)
{
for (int f = Math.Max(p.Start, 0); f <= p.End && f < tgtFrames.Count; f++)
planted.Add(f);
}
if (planted.Count == 0)
{
yield return new SideStats(name, 0, 0f, 0f, 0f);
continue;
}
var meanTgt = planted.Average(f => tgtPitch[f]);
var meanErr = planted.Average(f => tgtPitch[f] - srcPitch[f]);
var maxErr = planted.Max(f => MathF.Abs(tgtPitch[f] - srcPitch[f]));
yield return new SideStats(name, planted.Count, meanTgt, meanErr, maxErr);
}
}
/// <summary>Per-frame foot pitch (vs the ground plane) as a delta from the rig's own
/// rest anatomy: rest foot→toe direction carried by the foot's world delta from the
/// normalized rest.</summary>
private static float[] PitchDeltas(
List<XForm[]> frames, SkeletonModel skeleton, int foot, int toe,
IReadOnlyList<XForm> normRest, Vector3 up)
{
var restDir = normRest[toe].Pos - normRest[foot].Pos;
float Pitch(Vector3 d) =>
MathF.Asin(Math.Clamp(Vector3.Dot(Vector3.Normalize(d), up), -1f, 1f)) * Rad2Deg;
var restPitch = Pitch(restDir);
var restRotInv = Quaternion.Conjugate(normRest[foot].Rot);
var result = new float[frames.Count];
for (int f = 0; f < frames.Count; f++)
{
var world = FootWorldRot(frames[f], skeleton, foot);
var dir = Vector3.Transform(restDir, MathQ.Normalize(world * restRotInv));
result[f] = Pitch(dir) - restPitch;
}
return result;
}
private static Quaternion FootWorldRot(XForm[] locals, SkeletonModel skeleton, int bone)
{
var rot = locals[bone].Rot;
for (var b = skeleton[bone].ParentIndex; b >= 0; b = skeleton[b].ParentIndex)
rot = locals[b].Rot * rot;
return MathQ.Normalize(rot);
}
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 above test directory.");
}
}
using HumanoidRetargeter.Core.Formats;
using HumanoidRetargeter.Core.Mapping;
using Xunit;
using HumanoidRetargeter.Core;
namespace HumanoidRetargeter.Tests;
/// <summary>
/// Local-only end-to-end check against a REAL Unity animation pack file
/// (PunchPerfect "Stance.fbx" + its .fbx.meta sidecar: one 44-frame 30 fps take
/// "root|Animation" carrying two clipAnimations — 'Stance' frames 0–43 and
/// 'Stance No Move' frames 0–1, both loopTime 1; Auto-Rig-Pro-style bone names like
/// thigh_stretch.l / spine_01.x / root.x). The licensed file is NOT committed: every test
/// silently passes when it is absent.
/// </summary>
public class UnityMetaRealFileTests
{
private const string RealFbx =
@"P:\10 3 2025\Pointless Group\PunchPerfect Boxing Animations & Tools\Core\Animations\Stance.fbx";
private static bool Available => File.Exists(RealFbx) && File.Exists(RealFbx + ".meta");
/// <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 readonly Lazy<RetargetTargetSpec> SboxTarget = new(()
=> RetargetTargetSpec.SboxDefault(
File.ReadAllText(RepoFile("Assets", "data", "humanoid_retargeter", "target_rig_sbox.json"))));
[Fact] // skipped (silently green) when the local pack is not present
public void RealStanceMeta_ParsesBothClipDefinitions()
{
if (!Available)
return;
var defs = UnityMeta.ParseClipAnimations(File.ReadAllText(RealFbx + ".meta"));
Assert.Equal(2, defs.Count);
Assert.Equal("Stance", defs[0].Name);
Assert.Equal("Stance No Move", defs[1].Name);
Assert.All(defs, d => Assert.Equal("root|Animation", d.TakeName));
Assert.Equal(0f, defs[0].FirstFrame);
Assert.Equal(43f, defs[0].LastFrame);
Assert.Equal(0f, defs[1].FirstFrame);
Assert.Equal(1f, defs[1].LastFrame);
Assert.All(defs, d => Assert.True(d.Loop)); // loopTime: 1 on both
}
[Fact] // skipped (silently green) when the local pack is not present
public void RealStanceFbx_AutoMapsTheAutoRigProSkeleton()
{
if (!Available)
return;
var scene = Retargeter.ImportSource(File.ReadAllBytes(RealFbx), "Stance.fbx");
var (map, _) = Retargeter.ResolveMapping(scene.Skeleton);
string? BoneOf(BoneRole role)
=> map.RoleToBone.TryGetValue(role, out var i) ? scene.Skeleton[i].Name : null;
Assert.Equal("root.x", BoneOf(BoneRole.Hips));
Assert.Equal("thigh_stretch.l", BoneOf(BoneRole.UpperLegL));
Assert.Equal("leg_stretch.r", BoneOf(BoneRole.LowerLegR));
Assert.Equal("foot.l", BoneOf(BoneRole.FootL));
Assert.Equal("toes_01.l", BoneOf(BoneRole.ToeL));
Assert.Equal("spine_01.x", BoneOf(BoneRole.Spine0));
Assert.Equal("spine_03.x", BoneOf(BoneRole.Spine2));
Assert.Equal("neck.x", BoneOf(BoneRole.Neck));
Assert.Equal("head.x", BoneOf(BoneRole.Head));
Assert.Equal("arm_stretch.l", BoneOf(BoneRole.UpperArmL));
Assert.Equal("forearm_stretch.r", BoneOf(BoneRole.LowerArmR));
Assert.Equal("hand.l", BoneOf(BoneRole.HandL));
Assert.True(map.Confidence >= 0.9f, $"confidence {map.Confidence}");
}
[Fact] // skipped (silently green) when the local pack is not present
public void RealStanceFbx_MetaDefinitions_ConvertToSboxTarget()
{
if (!Available)
return;
var defs = UnityMeta.ParseClipAnimations(File.ReadAllText(RealFbx + ".meta"));
Assert.Equal(2, defs.Count);
var result = Retargeter.Convert(new RetargetRequest
{
SourceData = File.ReadAllBytes(RealFbx),
SourceFileName = "Stance.fbx",
ClipDefinitions = defs,
}, SboxTarget.Value);
Assert.Equal(2, result.Clips.Count);
Assert.All(result.Clips, c => Assert.True(c.Success, c.Error));
Assert.Equal("Stance", result.Clips[0].ClipName);
Assert.Equal("Stance_No_Move", result.Clips[1].ClipName); // sanitized like take names
// The take is 44 frames (0..43) at native 30 fps == the 30 fps sample grid:
// 'Stance' spans the whole timeline, 'Stance No Move' is the 2-frame pose range.
Assert.Equal(44, result.Clips[0].SolvedFrames!.Count);
Assert.Equal(2, result.Clips[1].SolvedFrames!.Count);
Assert.True(result.Clips[0].SolvedFrames!.Count > 10);
Assert.All(result.Clips, c => Assert.True(c.Looping)); // loopTime on both
// Plausibility: every solved transform finite, pelvis at a sane standing height.
var pelvis = SboxTarget.Value.Rig.BoneForRole(BoneRole.Hips)!.Value;
foreach (var clip in result.Clips)
{
foreach (var frame in clip.SolvedFrames!)
{
foreach (var xf in frame)
{
Assert.True(float.IsFinite(xf.Pos.X) && float.IsFinite(xf.Pos.Y) && float.IsFinite(xf.Pos.Z));
Assert.True(float.IsFinite(xf.Rot.X) && float.IsFinite(xf.Rot.Y)
&& float.IsFinite(xf.Rot.Z) && float.IsFinite(xf.Rot.W));
}
}
// s&box source space is Y-up cm; a standing pelvis sits roughly 70–120 cm up.
var hipsY = clip.SolvedFrames![0][pelvis].Pos.Y;
Assert.InRange(hipsY, 50f, 150f);
Assert.NotNull(clip.DmxContent);
Assert.NotEmpty(clip.DmxContent!);
}
}
}
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);
}
}
using System.Reflection;
using System.Text;
using HumanoidRetargeter.EditorTools;
using HumanoidRetargeter.EditorTools.Embedded.ValveResourceFormat;
using HumanoidRetargeter.EditorTools.Embedded.ValveResourceFormat.IO;
using Xunit;
namespace SmartPort.Parser.Tests;
public class OptionalMaterialTests
{
const string Outfit = "models/human_clothes/default_outfit/default_outfit_male_lower.vmat";
sealed class FailingLoader(Exception error) : IFileLoader
{
public Resource LoadFile(string file) => throw error;
public Resource LoadFileCompiled(string file) => LoadFile(file);
}
[Fact]
public void MissingOutfitMetadataUsesVertexBufferSemantics()
{
var loader = new FailingLoader(new FileNotFoundException("Missing material", Outfit));
Assert.Empty(ModelExtract.ReadMaterialInputSignature(loader, Outfit).Elements);
}
[Fact]
public void CorruptMetadataAndCancellationAreNotHidden()
{
Assert.Throws<InvalidDataException>(() => ModelExtract.ReadMaterialInputSignature(
new FailingLoader(new InvalidDataException("Corrupt material")), Outfit));
Assert.Throws<OperationCanceledException>(() => ModelExtract.ReadMaterialInputSignature(
new FailingLoader(new OperationCanceledException()), Outfit));
}
[Theory]
[InlineData("required.vmesh")]
[InlineData("required.vphys")]
[InlineData("required.vanim")]
[InlineData("required.vagrp")]
[InlineData("required.vmdl")]
public void RequiredDependenciesStillFailWithTheirFilename(string path)
{
var type = typeof(CompiledAssetRecovery).GetNestedType("Loader", BindingFlags.NonPublic)!;
using var loader = (IDisposable)Activator.CreateInstance(type,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null,
new object[] { new Dictionary<string, string>(), CancellationToken.None }, null)!;
var error = Assert.Throws<FileNotFoundException>(() => ((IFileLoader)loader).LoadFileCompiled(path));
Assert.Equal(path, error.FileName);
Assert.Contains(path, error.Message);
}
[CompiledFixtureFact]
public void MissingMaterialFilesDoNotBlockRecoveryOrEraseMaterialAssignments()
{
var fixture = Environment.GetEnvironmentVariable("HR_SMART_PORT_FIXTURE")!;
var paths = Directory.GetFiles(fixture, "*_c", SearchOption.AllDirectories)
.Where(p => !p.EndsWith(".vmat_c", StringComparison.OrdinalIgnoreCase))
.ToDictionary(p => Path.GetRelativePath(fixture, p)[..^2].Replace('\\', '/'), p => p, StringComparer.OrdinalIgnoreCase);
const string model = "models/player/human/frank_mp.vmdl";
var output = Path.Combine(Path.GetTempPath(), "hr-smart-port-no-materials-" + Guid.NewGuid().ToString("N"));
var text = CompiledAssetRecovery.Recover(paths[model], model, output, "recovered", paths, default);
using var resource = new Resource();
resource.Read(paths[model]);
Assert.NotNull(resource.ExternalReferences);
var materials = resource.ExternalReferences.ResourceRefInfoList.Where(r => r.Name.EndsWith(".vmat")).Select(r => r.Name).ToArray();
Assert.NotEmpty(materials);
var files = Directory.GetFiles(output, "*.dmx", SearchOption.AllDirectories);
Assert.Equal(398, files.Length); // Seven meshes and all 391 animation sources.
var meshText = string.Join("\n", files.Where(f => Path.GetFileName(f).StartsWith("frank_mp_")).Select(f => Encoding.UTF8.GetString(File.ReadAllBytes(f))));
foreach (var material in materials) Assert.Contains(material, text + meshText);
}
}
using Sandbox;
[TestClass]
public partial class LibraryTests
{
[TestMethod]
public void SceneTest()
{
var scene = new Scene();
using ( scene.Push() )
{
var go = new GameObject();
Assert.AreEqual( 1, scene.Directory.GameObjectCount );
}
}
}
global using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Sandbox.States.Test;
[TestClass]
public class TestInit
{
[AssemblyInitialize]
public static void ClassInitialize( TestContext context )
{
Sandbox.Application.InitUnitTest();
}
}
global using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class TestInit
{
public static Sandbox.TestAppSystem AppSystem;
[AssemblyInitialize]
public static void AssemblyInitialize( TestContext context )
{
AppSystem = new Sandbox.TestAppSystem();
AppSystem.Init();
}
[AssemblyCleanup]
public static void AssemblyCleanup()
{
AppSystem.Shutdown();
}
}
#nullable enable
using System;
using System.Linq;
using Hexagon.V2.Infrastructure;
using Hexagon.V2.Persistence;
namespace Hexagon.V2.Tests.Infrastructure;
[TestClass]
public sealed class PrefixedPersistenceStorageTests
{
[TestMethod]
public async Task EveryOperationIsMappedBelowTheIsolatedPrefix()
{
var physical = new InMemoryPersistenceStorage();
var isolated = new PrefixedPersistenceStorage( physical, "verification/run-42" );
Assert.IsTrue( await isolated.TryWriteImmutableAsync( "hexagon/v2/schema/format.json", new byte[] { 1, 2 } ) );
Assert.IsTrue( await physical.ExistsAsync( "verification/run-42/hexagon/v2/schema/format.json" ) );
Assert.IsFalse( await physical.ExistsAsync( "hexagon/v2/schema/format.json" ) );
var listed = await isolated.ListAsync( "hexagon/v2/schema" );
Assert.HasCount( 1, listed );
Assert.AreEqual( "hexagon/v2/schema/format.json", listed.Single() );
}
[TestMethod]
public void PrefixRejectsParentAndCurrentDirectorySegments()
{
var storage = new InMemoryPersistenceStorage();
Assert.ThrowsExactly<ArgumentException>( () => new PrefixedPersistenceStorage( storage, "../escape" ) );
Assert.ThrowsExactly<ArgumentException>( () => new PrefixedPersistenceStorage( storage, "safe/./escape" ) );
}
}