Unit-test helper file that defines and runs a large suite of self-tests for the S&box Weapon Animator editor. It builds documents, rigs, skeletons and controllers, exercises many editor and animation subsystems (calibration, binding, rig inspection, migration, working poses, serialization, etc.), and accumulates pass/fail messages into a report.
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Editor;
using Sandbox;
namespace SboxWeaponAnimator.Editor;
public sealed class WeaponAnimatorSelfTestReport
{
public int Passed { get; internal set; }
public List<string> Failures { get; } = [];
public bool Success => Failures.Count == 0;
public override string ToString() => Success
? $"Weapon Animator self-tests passed ({Passed} checks)."
: $"Weapon Animator self-tests failed ({Failures.Count} failures, {Passed} checks passed):\n" +
string.Join( "\n", Failures.Select( x => $" • {x}" ) );
}
public static class WeaponAnimatorSelfTests
{
public static WeaponAnimatorSelfTestReport RunAll()
{
var report = new WeaponAnimatorSelfTestReport();
Run( report, "document roles", TestDocumentRoles );
Run( report, "custom clip management and document title", TestCustomClipManagement );
Run( report, "scale and units", TestScaleAndUnits );
Run( report, "anchor lifecycle", TestAnchorLifecycle );
Run( report, "default grip binding", TestDefaultGripBinding );
Run( report, "weapon subtree filtering", TestWeaponSubtreeFiltering );
Run( report, "rig browser grouping", TestRigBrowserGrouping );
Run( report, "bind pose parity", TestBindPoseParity );
Run( report, "neutral arm binding", TestNeutralArmBinding );
Run( report, "generated Idle recovery", TestGeneratedIdleRecovery );
Run( report, "selection field isolation", TestSelectionFieldIsolation );
Run( report, "working pose and auto-key", TestWorkingPose );
Run( report, "stepped part visibility", TestPartVisibility );
Run( report, "schema migration", TestSchemaMigration );
Run( report, "content-sized buttons", TestContentSizedButtons );
Run( report, "alignment", TestAlignment );
Run( report, "track interpolation", TestInterpolation );
Run( report, "curve editor v2", TestCurveEditorV2 );
Run( report, "frame snapping", TestFrameSnapping );
Run( report, "timeline navigation", TestTimelineNavigation );
Run( report, "timeline selection and movement", TestTimelineSelectionAndMovement );
Run( report, "timeline key reversal", TestTimelineKeyReversal );
Run( report, "timeline playback", TestTimelinePlayback );
Run( report, "two-bone IK", TestTwoBoneIk );
Run( report, "IK descendant propagation", TestIkDescendantPropagation );
Run( report, "timed constraints before IK", TestConstraintDrivenIk );
Run( report, "constraint maintained offset", TestConstraintMaintainedOffset );
Run( report, "history and key clipboard", TestControllerHistoryAndClipboard );
Run( report, "host skeleton cache invalidation", TestHostSkeletonCache );
Run( report, "calibration and generation validation", TestValidation );
Run( report, "generation output paths", TestGenerationOutputPaths );
Run( report, "material discovery and output", TestMaterialPipeline );
Run( report, "generated file removal", TestGeneratedFileRemoval );
Run( report, "calibration rebase", TestRebase );
Run( report, "DMX output", TestDmxOutput );
Run( report, "filtered source wrapper", TestFilteredSourceWrapper );
Run( report, "generation source adapters", TestGenerationSourceAdapters );
Run( report, "deterministic generated text", TestDeterministicOutput );
Run( report, "AnimGraph tags and fallbacks", TestAnimGraphTagsAndFallbacks );
return report;
}
[Menu( "Editor", "Tools/Weapon Animator/Run Self Tests", "science" )]
public static void RunFromEditor()
{
var report = RunAll();
if ( report.Success )
Log.Info( $"[Weapon Animator] {report}" );
else
Log.Error( $"[Weapon Animator] {report}" );
}
private static void TestDocumentRoles( WeaponAnimatorSelfTestReport report )
{
var document = WeaponAnimationDocument.CreateDefault( "Test Rifle" );
Equal(
report,
WeaponAnimationDocument.StandardClips().Count,
document.Clips.Count,
"Default document must contain every standard slot." );
Equal(
report,
WeaponClipRole.Idle,
document.GetSelectedClip()!.Role,
"Idle must be selected in a new document." );
Check(
report,
!document.Workspace.ShowGuides,
"Viewport guides must be opt-in for new projects." );
Check(
report,
!document.Workspace.FreeLookCamera,
"New projects must open with the familiar orbit camera." );
Check(
report,
!document.Workspace.FullBrightViewport,
"New projects must open with lit viewport rendering." );
Check(
report,
document.Workspace.RimLightEnabled,
"The cyan viewport edge light must remain available by default." );
Near(
report,
4.0f,
document.Workspace.RimLightIntensity,
0.0001f,
"The edge light default must be restrained rather than the old over-bright value." );
Near(
report,
1.0f,
document.Workspace.CameraMoveSpeed,
0.0001f,
"The free-look camera must start at normal movement speed." );
Check(
report,
document.Workspace.SnapRotation,
"Rotation snapping must be enabled in new projects." );
Near(
report,
15.0f,
document.Workspace.RotationSnapDegrees,
0.0001f,
"Rotation snapping must start at the familiar 15-degree step." );
Near(
report,
30.0f,
WeaponAnimatorViewport.AdjustRotationSnapAngle( 15.0f, 1 ),
0.0001f,
"The snap-angle stepper must advance through the standard angle presets." );
Near(
report,
5.0f,
WeaponAnimatorViewport.AdjustRotationSnapAngle( 15.0f, -1 ),
0.0001f,
"The snap-angle stepper must move backward through the standard angle presets." );
Near(
report,
0.25f,
WeaponAnimatorViewport.AdjustRotationSnapAngle( 0.25f, -1 ),
0.0001f,
"The snap-angle stepper must retain its lower bound." );
Near(
report,
180.0f,
WeaponAnimatorViewport.AdjustRotationSnapAngle( 180.0f, 1 ),
0.0001f,
"The snap-angle stepper must retain its upper bound." );
Near(
report,
1.25f,
WeaponAnimatorViewport.AdjustCameraSpeed( 1.0f, 1 ),
0.0001f,
"Free-look wheel-up must increase low movement speeds in fine steps." );
Near(
report,
0.75f,
WeaponAnimatorViewport.AdjustCameraSpeed( 1.0f, -1 ),
0.0001f,
"Free-look wheel-down must decrease low movement speeds in fine steps." );
Near(
report,
100.0f,
WeaponAnimatorViewport.AdjustCameraSpeed( 100.0f, 1 ),
0.0001f,
"Free-look movement speed must remain within its upper bound." );
Near(
report,
0.25f,
WeaponAnimatorViewport.AdjustCameraSpeed( 0.25f, -1 ),
0.0001f,
"Free-look movement speed must remain within its lower bound." );
Near(
report,
0.10f,
document.Workspace.GridOpacity,
0.0001f,
"The default viewport grid must be substantially quieter than the editor grid." );
Near(
report,
0.65f,
document.Workspace.GridLineThickness,
0.0001f,
"The default viewport grid must use fine lines." );
var gridStyle = GridVisualStyle.Resolve(
document.Workspace.GridOpacity,
document.Workspace.GridLineThickness );
Near(
report,
document.Workspace.GridOpacity,
gridStyle.AxisOpacity,
0.0001f,
"The opacity preference must affect the colored origin axes." );
Check(
report,
gridStyle.AxisWidth < 1
&& gridStyle.AxisWidth > gridStyle.MajorWidth
&& gridStyle.MajorWidth > gridStyle.MinorWidth,
"The line-weight preference must allow thin axes while preserving grid hierarchy." );
var faintStyle = GridVisualStyle.Resolve( 0.02f, 0.1f );
Check(
report,
faintStyle.AxisOpacity < gridStyle.AxisOpacity
&& faintStyle.AxisWidth < gridStyle.AxisWidth,
"Lower opacity and weight must visibly affect both primary and secondary grid lines." );
var rimStyle = ViewportRimLightStyle.Resolve(
document.Workspace.RimLightEnabled,
document.Workspace.RimLightIntensity,
false );
Check(
report,
rimStyle.Enabled,
"The edge-light preference must enable the cyan point light in lit mode." );
Near(
report,
4.0f,
rimStyle.Intensity,
0.0001f,
"The viewport must apply the persisted edge-light brightness." );
Check(
report,
!ViewportRimLightStyle.Resolve( true, 4, true ).Enabled
&& !ViewportRimLightStyle.Resolve( false, 4, false ).Enabled,
"Full Bright and the explicit toggle must both disable the edge light." );
Near(
report,
12,
ViewportRimLightStyle.Resolve( true, 99, false ).Intensity,
0.0001f,
"Edge-light brightness must remain inside its supported range." );
var fullBrightArms = ArmPreviewVisualStyle.Resolve(
WeaponAnimatorStage.Animate,
true );
Check(
report,
fullBrightArms.UseFlatMaterial
&& MathF.Max(
fullBrightArms.Tint.r,
MathF.Max( fullBrightArms.Tint.g, fullBrightArms.Tint.b ) ) > 0.1f,
"Full Bright must use a visible neutral arms material instead of rendering skin black." );
Check(
report,
!ArmPreviewVisualStyle.Resolve( WeaponAnimatorStage.Animate, false ).UseFlatMaterial,
"Lit animation preview must preserve the production arms materials." );
// The four *_ikrule names are the real helper bones on the Facepunch arms; ik_hand_* are
// added by HostSkeletonBuilder. None are read by anything, and all trail long lines.
foreach ( var ikName in new[]
{
"hand_R_to_L_ikrule",
"hand_L_to_R_ikrule",
"hand_R_to_weapon_ikrule",
"hand_L_to_weapon_ikrule",
"ik_hand_R",
"ik_hand_L",
"weapon_IK_hand_R",
"weapon_IK_hand_L"
} )
{
Check(
report,
SkeletonBoneStyle.Classify( new HostBone { Name = ikName } ) == SkeletonBoneKind.Ik,
$"{ikName} must be treated as an IK helper bone." );
}
// Weapon rigs ship their own IK targets, so the IK test deliberately wins over IsWeaponBone.
Check(
report,
SkeletonBoneStyle.Classify(
new HostBone { Name = "weapon_IK_hand_R", IsWeaponBone = true } )
== SkeletonBoneKind.Ik,
"An IK target from the weapon rig must be treated as an IK helper, not a weapon bone." );
// The trap in letting IK win: "ik" must match as a token, never as a substring.
foreach ( var keptName in new[]
{
"weapon_root",
"spike_guard",
"strike_plate",
"trigger",
"slide_kick",
"ikon"
} )
{
Check(
report,
SkeletonBoneStyle.Classify(
new HostBone { Name = keptName, IsWeaponBone = true } )
== SkeletonBoneKind.Weapon,
$"{keptName} must stay a visible weapon bone - 'ik' matches tokens, not substrings." );
}
Check(
report,
SkeletonBoneStyle.Classify( new HostBone { Name = "arm_lower_R_twist1" } )
== SkeletonBoneKind.Twist
&& SkeletonBoneStyle.Classify( new HostBone { Name = "arm_lower_R_twistctrl0" } )
== SkeletonBoneKind.Twist
&& SkeletonBoneStyle.Classify( new HostBone { Name = "hand_R" } )
== SkeletonBoneKind.Arm,
"Twist helpers must be distinguished from the arm chain proper." );
var hiddenIk = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Ik, 2, 8, false );
var shownIk = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Ik, 2, 8, true );
Check(
report,
!hiddenIk.Visible && shownIk.Visible && shownIk.Color == WeaponAnimatorTheme.Coral,
"IK bones must be hidden by default and drawn red when enabled." );
Check(
report,
SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 2, 8, false ).Visible
&& SkeletonBoneStyle.Resolve( SkeletonBoneKind.Weapon, 0, 8, false ).Visible,
"Hiding IK bones must not hide anything else." );
var twistStyle = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Twist, 4, 8, false );
var armStyle = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 4, 8, false );
Check(
report,
twistStyle.Visible
&& twistStyle.AlphaScale < armStyle.AlphaScale
&& twistStyle.Color == armStyle.Color,
"Twist bones must recede without changing hue or becoming unclickable." );
Check(
report,
twistStyle.Hollow
&& shownIk.Hollow
&& !armStyle.Hollow
&& !SkeletonBoneStyle.Resolve( SkeletonBoneKind.Weapon, 0, 8, false ).Hollow,
"Derived bones must be hollow and directly posed bones solid, so shape carries the distinction." );
Check(
report,
SkeletonBoneStyle.Resolve( SkeletonBoneKind.Weapon, 6, 8, false ).Color
== WeaponAnimatorTheme.Amber,
"Weapon bones must stay amber regardless of depth." );
var rootColor = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 0, 8, false ).Color;
var midColor = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 4, 8, false ).Color;
var tipColor = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 8, 8, false ).Color;
Check(
report,
rootColor != midColor && midColor != tipColor && rootColor != tipColor,
"The arm gradient must separate root, mid-chain and fingertip bones." );
Check(
report,
tipColor.r > rootColor.r && tipColor.g > rootColor.g,
"The arm gradient must brighten toward the fingertips." );
// The first ramp faded to near-white at the fingertips, where bones are densest, and the
// distal steps were hard to tell apart. Guard the weakest step, and specifically require the
// distal half to separate about as well as the proximal half.
static float Separation( int fromDepth, int toDepth )
{
var a = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, fromDepth, 8, false ).Color;
var b = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, toDepth, 8, false ).Color;
return MathF.Sqrt(
((a.r - b.r) * (a.r - b.r))
+ ((a.g - b.g) * (a.g - b.g))
+ ((a.b - b.b) * (a.b - b.b)) );
}
var weakestStep = float.MaxValue;
for ( var depth = 0; depth < 8; depth++ )
weakestStep = MathF.Min( weakestStep, Separation( depth, depth + 1 ) );
Check(
report,
weakestStep > 0.15f,
"Every step along the arm gradient must be clearly distinguishable from the next." );
Check(
report,
Separation( 0, 8 ) > 1.0f,
"The gradient must travel a long way between the root and the fingertips." );
Check(
report,
WeaponAnimatorTheme.BoneDepthColor( -5 ) == WeaponAnimatorTheme.BoneDepthColor( 0 )
&& WeaponAnimatorTheme.BoneDepthColor( 5 ) == WeaponAnimatorTheme.BoneDepthColor( 1 )
&& WeaponAnimatorTheme.BoneDepthColor( float.NaN )
== WeaponAnimatorTheme.BoneDepthColor( 0 ),
"Out-of-range and non-finite depth fractions must clamp to the ramp ends." );
Check(
report,
SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 3, 0, false ).Color
== WeaponAnimatorTheme.BoneDepthColor( 0 ),
"A skeleton with no measurable depth must not divide by zero." );
Check(
report,
!document.Workspace.ShowIkBones,
"IK bones must be hidden by default." );
Check(
report,
document.Workspace.BoneOcclusionEnabled,
"Dynamic bone occlusion must be enabled by default." );
// Occluded bones must read as a different category, not just a dimmer copy: hue carries
// depth along the arm, so draining it is what makes "behind something" legible.
static float Saturation( Color color )
{
var max = MathF.Max( color.r, MathF.Max( color.g, color.b ) );
var min = MathF.Min( color.r, MathF.Min( color.g, color.b ) );
return max - min;
}
var vividBone = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 8, 8, false ).Color;
var occludedBone = SkeletonOverlayStyle.Occlude( vividBone );
var gradientOverlay = SkeletonOverlayStyle.Resolve( true, 1.0f );
var visibleLine = gradientOverlay.ResolveLineVisual(
SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 1, 8, false ),
false );
var hiddenLine = gradientOverlay.ResolveLineVisual(
SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 2, 8, false ),
true );
var middleLine = SkeletonLineVisual.Lerp( visibleLine, hiddenLine, 0.5f );
Check(
report,
Saturation( occludedBone ) < Saturation( vividBone ) * 0.35f,
"Occluded bones must lose most of their colour so they stop competing for attention." );
Check(
report,
Saturation( occludedBone ) > 0.001f,
"Occluded bones must keep a trace of colour so weapon and arm stay tellable apart." );
Check(
report,
SkeletonOverlayStyle.Occlude( Color.White.WithAlpha( 0.4f ) ).a == 0.4f,
"Draining colour must not disturb the alpha the occluded pass already applies." );
Check(
report,
SkeletonOverlayStyle.OccludedDotScale < 1.0f
&& SkeletonOverlayStyle.OccludedLineThickness < 1.0f,
"Occluded bones must draw smaller so they do not veil bones in front." );
Check(
report,
middleLine.Thickness < visibleLine.Thickness
&& middleLine.Thickness > hiddenLine.Thickness
&& middleLine.Color.a < visibleLine.Color.a
&& middleLine.Color.a > hiddenLine.Color.a
&& middleLine.Color != visibleLine.Color
&& middleLine.Color != hiddenLine.Color,
"A mixed-visibility connection must gradient its colour, opacity, and width." );
Check(
report,
SkeletonOverlayStyle.OcclusionDepthClearance( 80 )
> SkeletonOverlayStyle.OcclusionDepthClearance( 10 )
&& SkeletonOverlayStyle.OcclusionDepthClearance( float.NaN ) > 0,
"Occlusion clearance must follow marker size and remain valid for bad camera distances." );
Check(
report,
!SkeletonOverlayStyle.IsOccludingDepth( 80.015f, 80.015f )
&& !SkeletonOverlayStyle.IsOccludingDepth( 80.015f, 79.95f )
&& SkeletonOverlayStyle.IsOccludingDepth( 80.015f, 79.0f ),
"A surface at the bone endpoint must remain visible while a nearer surface occludes it." );
var xrayStyle = SkeletonOverlayStyle.Resolve( true, 1.0f );
Check(
report,
document.Workspace.XRaySkeleton,
"Bones hidden behind the arms must be visible by default." );
Check(
report,
xrayStyle.DrawThroughMeshes
&& xrayStyle.OccludedAlpha > 0
&& xrayStyle.OccludedAlpha < 1.0f,
"Occluded bones must stay visible but subordinate to unoccluded ones." );
Check(
report,
!SkeletonOverlayStyle.Resolve( false, 1.0f ).DrawThroughMeshes,
"Disabling x-ray must restore the depth-tested skeleton overlay." );
Check(
report,
!SkeletonOverlayStyle.Resolve( true, 0 ).DrawThroughMeshes,
"A fully faded skeleton must not draw through viewport meshes." );
Check(
report,
SkeletonOverlayStyle.Resolve( true, 0.18f ).OccludedAlpha < xrayStyle.OccludedAlpha,
"Fainter skeleton passes must produce proportionally fainter ghosts." );
Near(
report,
xrayStyle.OccludedAlpha,
SkeletonOverlayStyle.Resolve( true, 99.0f ).OccludedAlpha,
0.0001f,
"Overlay alpha must remain inside its supported range." );
Near(
report,
xrayStyle.OccludedAlpha,
SkeletonOverlayStyle.Resolve( true, float.NaN ).OccludedAlpha,
0.0001f,
"A non-finite overlay alpha must fall back to the default." );
Check(
report,
!SkeletonOcclusionPolicy.IsOccludedByArm(
false,
1,
1 )
&& SkeletonOcclusionPolicy.IsOccludedByArm(
false,
1,
-1 ),
"Each finger must ignore its own hand mesh and reduce only behind the opposite hand." );
Check(
report,
SkeletonOcclusionPolicy.IsOccludedByArm(
true,
0,
1 ),
"Weapon bones must reduce only when an arm is actually in front." );
Check(
report,
!SkeletonOcclusionPolicy.IsOccludedByArm(
false,
-1,
0 )
&& !SkeletonOcclusionPolicy.IsOccludedByArm(
false,
-1,
-1 ),
"Unowned and same-side surfaces must never reduce an arm bone." );
var first = WeaponAnimationClip.Create( WeaponClipRole.Custom );
var second = WeaponAnimationClip.Create( WeaponClipRole.Custom );
first.Name = second.Name = "Mechanical Check";
Check(
report,
WeaponAnimationNames.SequenceName( first ) != WeaponAnimationNames.SequenceName( second ),
"Custom sequence names must remain unique." );
document.Clips.Add( first );
document.Clips.Add( second );
Check(
report,
WeaponAnimationNames.RepairCustomSequenceNames( document )
&& !first.GeneratedSequenceName.Contains( first.Id.ToString( "N" ), StringComparison.Ordinal )
&& !second.GeneratedSequenceName.Contains( second.Id.ToString( "N" ), StringComparison.Ordinal )
&& first.GeneratedSequenceName != second.GeneratedSequenceName,
"Custom clips must receive stable, readable sequence names with short collision suffixes." );
var customSequence = first.GeneratedSequenceName;
Check(
report,
!WeaponAnimationNames.RepairCustomSequenceNames( document )
&& first.GeneratedSequenceName == customSequence,
"Resolved custom sequence names must remain stable across later repairs." );
Check(
report,
CalibrationSelection.TryGetAnchor(
CalibrationSelection.Anchor( AnchorKind.Muzzle ),
out var anchorKind )
&& anchorKind == AnchorKind.Muzzle,
"Calibration anchor control names must round-trip." );
var muzzleAnchor = new WeaponAnchor { Kind = AnchorKind.Muzzle, Name = "Muzzle" };
var customA = new WeaponAnchor { Kind = AnchorKind.Custom, Name = "Suppressor Mount" };
var customB = new WeaponAnchor { Kind = AnchorKind.Custom, Name = "Suppressor Mount" };
document.Calibration.Anchors.Add( muzzleAnchor );
document.Calibration.Anchors.Add( customA );
document.Calibration.Anchors.Add( customB );
Check(
report,
WeaponAnimationNames.RepairCustomAnchorNames( document )
&& customA.GeneratedAttachmentName == "suppressor_mount"
&& customB.GeneratedAttachmentName != customA.GeneratedAttachmentName,
"Custom anchors must take readable attachment names and separate on collision." );
var resolvedAnchor = customA.GeneratedAttachmentName;
Check(
report,
!WeaponAnimationNames.RepairCustomAnchorNames( document )
&& customA.GeneratedAttachmentName == resolvedAnchor,
"Resolved custom attachment names must stay stable across later repairs." );
customA.Name = "Silencer Mount";
Check(
report,
!WeaponAnimationNames.RepairCustomAnchorNames( document )
&& WeaponAnimationNames.AttachmentName( customA ) == resolvedAnchor,
"Renaming a custom anchor must not silently rename the generated attachment." );
Check(
report,
WeaponAnimationNames.AttachmentName( muzzleAnchor ) == "muzzle",
"Fixed anchor kinds must keep their reserved attachment names." );
var reservedClash = new WeaponAnchor { Kind = AnchorKind.Custom, Name = "Muzzle" };
document.Calibration.Anchors.Add( reservedClash );
Check(
report,
WeaponAnimationNames.RepairCustomAnchorNames( document )
&& reservedClash.GeneratedAttachmentName != "muzzle",
"A custom anchor must not claim an attachment name reserved by a fixed kind." );
Check(
report,
CalibrationSelection.TryGetCustomAnchorId(
CalibrationSelection.Anchor( customA ),
out var customAnchorId )
&& customAnchorId == customA.Id
&& CalibrationSelection.Resolve(
document,
CalibrationSelection.Anchor( customB ) ) == customB,
"Custom anchor selection tokens must round-trip to the individual anchor." );
Check(
report,
!CalibrationSelection.TryGetCustomAnchorId(
CalibrationSelection.Anchor( AnchorKind.Muzzle ),
out _ )
&& CalibrationSelection.TryGetAnchor(
CalibrationSelection.Anchor( customA ),
out var customKind )
&& customKind == AnchorKind.Custom,
"Fixed anchor tokens must carry no id, and custom tokens must still report their kind." );
document.Calibration.Anchors.Clear();
// A .wepanim created outside the New Project flow arrives carrying CreateDefault()'s
// "New Weapon", which used to generate every project into weapons/new_weapon.
var named = WeaponAnimationDocument.CreateDefault();
Check(
report,
named.Output.AssetName == "new_weapon",
"The default document must still carry the documented placeholder asset name." );
Check(
report,
WeaponAnimatorWindow.AdoptAssetFileName( named, "weapons/test2.wepanim" )
&& named.Name == "test2"
&& named.Output.AssetName == "test2"
&& named.Output.GetDefaultRelativeFolder() == "weapons/test2/viewmodel",
"Opening a project must adopt its filename for generated names and folders." );
Check(
report,
!WeaponAnimatorWindow.AdoptAssetFileName( named, "weapons/test2.wepanim" ),
"Adopting an unchanged filename must not mark the document dirty." );
Check(
report,
WeaponAnimatorWindow.AdoptAssetFileName( named, "weapons/AK 74.wepanim" )
&& named.Name == "AK 74"
&& named.Output.AssetName == "ak_74",
"Save As must rename generated output, slugifying the display name." );
Check(
report,
!WeaponAnimatorWindow.AdoptAssetFileName( named, "" )
&& !WeaponAnimatorWindow.AdoptAssetFileName( named, (string?)null )
&& named.Output.AssetName == "ak_74",
"An unsaved project must keep its existing generated name." );
Equal(
report,
"Alignment marker — rear",
CalibrationSelection.DisplayName( AnchorKind.RearBore ),
"Auto-align markers must use purpose-driven names." );
document.Workspace.AnimationRightSplitterState = "right-column-layout";
var reopened = Json.Deserialize<WeaponAnimationDocument>( Json.Serialize( document ) )!;
Equal(
report,
"right-column-layout",
reopened.Workspace.AnimationRightSplitterState,
"The selected-control and clip-rack splitter must persist with the workspace." );
}
private static void TestCustomClipManagement(
WeaponAnimatorSelfTestReport report )
{
var document = WeaponAnimationDocument.CreateDefault( "Internal Name" );
var first = WeaponAnimationClip.Create( WeaponClipRole.Custom );
first.Name = "Mechanical Check";
var second = WeaponAnimationClip.Create( WeaponClipRole.Custom );
second.Name = "Mechanical Check";
document.Clips.Add( first );
document.Clips.Add( second );
WeaponAnimationNames.RepairCustomSequenceNames( document );
document.Workspace.SelectedClipId = first.Id;
document.Workspace.WorkingPoseOverrides.Add( new WorkingPoseOverride
{
ClipId = first.Id,
Target = "weapon_root"
} );
document.Workspace.TimelineViews.Add( new TimelineViewState
{
ClipId = first.Id
} );
document.Workspace.CurveViews.Add( new CurveViewState
{
ClipId = first.Id
} );
var controller = new WeaponAnimatorController();
controller.SetDocument( document );
controller.RenameCustomClip( first.Id, "Safety Check" );
Equal(
report,
"Safety Check",
first.Name,
"Custom clips must be renameable." );
Equal(
report,
"safety_check",
first.GeneratedSequenceName,
"Renaming a custom clip must assign a readable collision-safe sequence name." );
controller.Undo();
var restoredFirst = controller.Document.Clips.First( clip => clip.Id == first.Id );
Equal(
report,
"Mechanical Check",
restoredFirst.Name,
"Custom clip rename must be one undoable action." );
controller.DeleteCustomClip( first.Id );
Check(
report,
controller.Document.Clips.All( clip => clip.Id != first.Id )
&& controller.Document.Workspace.WorkingPoseOverrides.All(
item => item.ClipId != first.Id )
&& controller.Document.Workspace.TimelineViews.All(
item => item.ClipId != first.Id )
&& controller.Document.Workspace.CurveViews.All(
item => item.ClipId != first.Id ),
"Deleting a custom clip must remove its clip-owned workspace state." );
Equal(
report,
WeaponClipRole.Idle,
controller.Document.GetSelectedClip()!.Role,
"Deleting the selected custom clip must return selection to Idle." );
controller.Undo();
Check(
report,
controller.Document.Clips.Any( clip => clip.Id == first.Id ),
"Custom clip deletion must restore the complete clip through one undo." );
Equal(
report,
"S&box Weapon Animator — p30l.wepanim",
WeaponAnimatorWindow.ComposeWindowTitle(
"weapons/pistols/p30l.wepanim",
"New Weapon",
false ),
"The window title must use the open asset filename instead of the stale document name." );
Equal(
report,
"S&box Weapon Animator — p30l.wepanim *",
WeaponAnimatorWindow.ComposeWindowTitle(
"weapons/pistols/p30l.wepanim",
"New Weapon",
true ),
"The filename caption must retain the dirty marker." );
}
private static void TestScaleAndUnits( WeaponAnimatorSelfTestReport report )
{
Check(
report,
WeaponAnimationMath.TryCalculateUniformScale(
Vector3.Zero,
new Vector3( 10, 0, 0 ),
25.4f,
MeasurementUnit.Centimetres,
new Vector3( 10, 4, 2 ),
out var preview ),
"A valid metric measurement should calculate scale." );
Near( report, 1, preview.UniformScale, 0.0001f, "25.4 cm over 10 units should scale to one inch per unit." );
Near( report, 25.4f, WeaponAnimationMath.ToCentimetres( 10 ), 0.0001f, "Unit conversion must be exact." );
Check(
report,
!WeaponAnimationMath.TryCalculateUniformScale(
Vector3.Zero,
Vector3.Zero,
1,
MeasurementUnit.Inches,
Vector3.One,
out _ ),
"Coincident measurement points must be rejected." );
}
private static void TestAnchorLifecycle( WeaponAnimatorSelfTestReport report )
{
var document = ValidDocument();
document.Calibration.SetAnchor( Anchor( AnchorKind.Eject, new Vector3( 1, 2, 3 ) ) );
document.Calibration.SetAnchor( Anchor( AnchorKind.Eject, new Vector3( 4, 5, 6 ) ) );
Equal(
report,
1,
document.Calibration.Anchors.Count( anchor => anchor.Kind == AnchorKind.Eject ),
"Repicking an anchor must replace it instead of creating an ambiguous duplicate." );
Near(
report,
new Vector3( 4, 5, 6 ),
document.Calibration.GetAnchor( AnchorKind.Eject )!.LocalPosition,
0.0001f,
"Repicking an anchor must update its editable position." );
document.Calibration.Anchors.RemoveAll( anchor => anchor.Kind == AnchorKind.Eject );
Check( report, document.Calibration.GetAnchor( AnchorKind.Eject ) is null, "Optional anchors must be individually deletable." );
document.Calibration.Anchors.RemoveAll( anchor => anchor.Kind == AnchorKind.Grip );
Check(
report,
!WeaponAnimationValidator.ValidateCalibration( document ).IsValid,
"Deleting a required anchor must reopen its calibration requirement." );
}
private static void TestDefaultGripBinding( WeaponAnimatorSelfTestReport report )
{
var document = WeaponAnimationDocument.CreateDefault();
document.Calibration.PhysicalTransform = new Transform( new Vector3( 10, 0, 0 ) );
document.Calibration.FramingTransform = new Transform( new Vector3( 0, 2, 0 ) );
document.Calibration.SetAnchor( Anchor( AnchorKind.Grip, new Vector3( 1, 0, 0 ) ) );
Check(
report,
CalibrationBindingSeeder.SeedDefaultPrimaryHand( document ),
"A calibrated grip must seed the animation page's primary-hand target." );
Equal(
report,
"weapon_root",
document.Binding.PrimaryHand.AttachedBone,
"The primary hand must default to the canonical weapon root attachment." );
var skeleton = HostSkeletonBuilder.Build( document, includeArmProfile: false );
var primaryWorld = skeleton.ByName["weapon_root"].BindModelTransform.PointToWorld(
document.Binding.PrimaryHand.Transform.Position );
Near(
report,
new Vector3( 11, 2, 0 ),
primaryWorld,
0.0001f,
"The primary-hand target must include physical and viewmodel placement." );
Check(
report,
!document.Binding.PrimaryHand.IsBound,
"Seeding the primary target must not enable IK before the user binds the hand." );
}
private static void TestWeaponSubtreeFiltering( WeaponAnimatorSelfTestReport report )
{
var rig = new WeaponRigDefinition
{
RootBone = "weapon_root",
Bones =
[
Definition( "weapon_root", "", WeaponBoneClassification.WeaponRoot, Vector3.Zero ),
Definition( "receiver", "weapon_root", WeaponBoneClassification.Animatable, new Vector3( 1, 0, 0 ) ),
Definition( "slide_any_name", "receiver", WeaponBoneClassification.Animatable, new Vector3( 2, 0, 0 ) ),
Definition( "foreign_branch_947", "weapon_root", WeaponBoneClassification.Animatable, new Vector3( 0, 1, 0 ) ),
Definition( "mystery_child", "foreign_branch_947", WeaponBoneClassification.Animatable, new Vector3( 0, 2, 0 ) )
]
};
WeaponRigHierarchy.RepairMetadata( rig, false );
WeaponRigHierarchy.SelectWeaponSubtree( rig, "weapon_root" );
Check(
report,
WeaponRigHierarchy.ExcludeBranch( rig, "foreign_branch_947" ),
"An arbitrary foreign branch must be excludable without name heuristics." );
WeaponRigHierarchy.ConfirmFilteredPreview( rig );
Check( report, rig.FindBone( "receiver" )!.Inclusion == WeaponBoneInclusion.Included, "Weapon descendants must remain included." );
Check( report, rig.FindBone( "mystery_child" )!.Inclusion == WeaponBoneInclusion.Excluded, "Excluding a branch must exclude every descendant." );
Check( report, !rig.ReviewRequired && rig.FilteredPreviewConfirmed, "Confirming the filtered preview must close the rig-review gate." );
var auditSignature = RigAuditPanel.BoneStructureSignature( rig, "", true, true, false );
rig.ReviewRequired = true;
Equal(
report,
auditSignature,
RigAuditPanel.BoneStructureSignature( rig, "", true, true, false ),
"Non-structural document refreshes must not rebuild the rig-audit bone rows." );
rig.FindBone( "receiver" )!.Classification = WeaponBoneClassification.Structural;
Check(
report,
auditSignature != RigAuditPanel.BoneStructureSignature( rig, "", true, true, false ),
"Classification changes must rebuild the rig-audit bone rows." );
rig.FindBone( "receiver" )!.Classification = WeaponBoneClassification.Animatable;
var document = WeaponAnimationDocument.CreateDefault();
document.Rig = rig;
var skeleton = HostSkeletonBuilder.Build( document, false );
Check( report, skeleton.ByName.ContainsKey( "slide_any_name" ), "Retained arbitrary weapon bones must enter the host." );
Check( report, !skeleton.ByName.ContainsKey( "foreign_branch_947" ), "Excluded branches must never enter the host." );
}
private static void TestBindPoseParity( WeaponAnimatorSelfTestReport report )
{
var document = WeaponAnimationDocument.CreateDefault();
document.Calibration.PhysicalTransform = new Transform(
new Vector3( 8, -3, 2 ),
Rotation.From( 12, 35, -7 ),
0.6f );
document.Calibration.FramingTransform = new Transform(
new Vector3( 1, 2, -0.5f ),
Rotation.From( -4, 8, 3 ) );
var rootModel = new Transform(
new Vector3( -2.4f, 0, 4.1f ),
Rotation.From( 0, 0, -90 ),
1.0f );
var childLocal = new Transform(
new Vector3( 1.2f, -0.4f, 0.8f ),
Rotation.From( 0, 90, 0 ),
1.0f );
var childModel = WeaponAnimationMath.Compose( rootModel, childLocal );
document.Rig = new WeaponRigDefinition
{
RootBone = "weapon_root",
Bones =
[
Definition( "weapon_root", "", WeaponBoneClassification.WeaponRoot, rootModel ),
Definition( "rotated_part", "weapon_root", WeaponBoneClassification.Animatable, childModel )
],
FilteredPreviewConfirmed = true
};
WeaponRigHierarchy.RepairMetadata( document.Rig, false );
var parity = HostSkeletonBuilder.ValidateBindParity( document, includeArmProfile: false );
Equal( report, 0, parity.Count, "Stage 2 must reproduce every Stage 1 weapon bind transform." );
var skeleton = HostSkeletonBuilder.Build( document, false );
var placement = WeaponAnimationMath.Compose(
document.Calibration.PhysicalTransform,
document.Calibration.FramingTransform );
var expected = WeaponAnimationMath.Compose( placement, childModel );
Near(
report,
expected.Position,
skeleton.ByName["rotated_part"].BindModelTransform.Position,
0.0001f,
"A rotated child must not receive an extra root-space rotation." );
Near(
report,
expected.Rotation.Forward,
skeleton.ByName["rotated_part"].BindModelTransform.Rotation.Forward,
0.0001f,
"Child orientation must match calibration exactly." );
var pose = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );
var definition = document.Rig.FindBone( "rotated_part" )!;
Check(
report,
WeaponPoseProjection.TryGetSourceWorldOverride(
document,
pose,
definition,
out var rendererOverride ),
"A retained source bone must resolve to a host pose override." );
Near(
report,
expected.Position,
rendererOverride.Position,
0.0001f,
"Source renderer overrides must use the host's world position." );
Near(
report,
expected.Rotation.Forward,
rendererOverride.Rotation.Forward,
0.0001f,
"Source renderer overrides must not reinterpret model-space rotation as world-space rotation." );
Near(
report,
expected.Scale,
rendererOverride.Scale,
0.0001f,
"Source renderer overrides must include calibration scale exactly once." );
var solvedRenderer = WeaponPoseProjection.SolveRendererTransform(
rootModel,
skeleton.ByName["weapon_root"].BindModelTransform );
Near(
report,
placement.Position,
solvedRenderer.Position,
0.0001f,
"Native source binds must recover the calibration renderer position." );
Near(
report,
placement.Rotation.Forward,
solvedRenderer.Rotation.Forward,
0.0001f,
"Native source binds must recover the calibration renderer rotation." );
Near(
report,
placement.Scale,
solvedRenderer.Scale,
0.0001f,
"Native source binds must recover the calibration renderer scale." );
var rebuiltHierarchy = new HostSkeleton();
rebuiltHierarchy.Add( new HostBone
{
Name = "root",
BindModelTransform = new Transform( new Vector3( 4, 0, 0 ) ),
BindLocalTransform = new Transform( new Vector3( 4, 0, 0 ) ),
HasExplicitBindLocal = true
} );
rebuiltHierarchy.Add( new HostBone
{
Name = "weapon_root",
ParentName = "root",
BindModelTransform = new Transform( new Vector3( 999 ) ),
BindLocalTransform = new Transform(
new Vector3( 2, 0, 0 ),
Rotation.FromYaw( 90 ),
0.5f ),
HasExplicitBindLocal = true
} );
rebuiltHierarchy.Add( new HostBone
{
Name = "weapon_helper",
ParentName = "weapon_root",
BindModelTransform = new Transform( new Vector3( -999 ) ),
BindLocalTransform = new Transform( new Vector3( 2, 0, 0 ) ),
HasExplicitBindLocal = true
} );
rebuiltHierarchy.RebuildModelTransformsFromLocals();
var expectedHelper = WeaponAnimationMath.Compose(
rebuiltHierarchy.ByName["weapon_root"].BindModelTransform,
rebuiltHierarchy.ByName["weapon_helper"].BindLocalTransform );
Near(
report,
expectedHelper.Position,
rebuiltHierarchy.ByName["weapon_helper"].BindModelTransform.Position,
0.0001f,
"Changing weapon_root must rebuild canonical helper model transforms from their untouched local binds." );
Near(
report,
expectedHelper.Scale,
rebuiltHierarchy.ByName["weapon_helper"].BindModelTransform.Scale,
0.0001f,
"Rebuilt helper binds must preserve the calibrated parent scale exactly once." );
var compilerBinds = rebuiltHierarchy.BuildCompilerBindModelTransforms();
var compilerRoot = compilerBinds["weapon_root"];
var compilerHelper = compilerBinds["weapon_helper"];
Near(
report,
Vector3.One,
compilerRoot.Scale,
0.0001f,
"Compiled bind expectations must model ModelDoc's scale-one skeleton." );
Near(
report,
rebuiltHierarchy.ByName["weapon_helper"].BindModelTransform.Position,
compilerHelper.Position,
0.0001f,
"Compiled bind expectations must preserve scale-baked physical child pivots." );
Equal(
report,
"weapon_helper",
rebuiltHierarchy.ChildrenOf( "weapon_root" ).Single().Name,
"Host skeletons must retain a direct parent-to-children lookup." );
var cachedA = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
var cachedB = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
Check(
report,
ReferenceEquals( cachedA, cachedB ),
"Unchanged rig inputs must reuse the cached animation-host skeleton." );
document.Calibration.PhysicalTransform =
document.Calibration.PhysicalTransform.WithPosition( new Vector3( 99, 0, 0 ) );
var cachedChanged = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
Check(
report,
!ReferenceEquals( cachedA, cachedChanged ),
"Calibration changes must invalidate the cached animation-host skeleton." );
document.Calibration.PhysicalTransform =
document.Calibration.PhysicalTransform.WithPosition( new Vector3( 99.00001f, 0, 0 ) );
Check(
report,
!ReferenceEquals(
cachedChanged,
HostSkeletonBuilder.BuildCached( document, includeArmProfile: false ) ),
"Sub-display-precision transform changes must invalidate the host cache." );
}
private static void TestRigBrowserGrouping( WeaponAnimatorSelfTestReport report )
{
var bones = new[]
{
new HostBone { Name = "bolt", IsWeaponBone = true },
new HostBone { Name = "arm_upper_R" },
new HostBone { Name = "arm_upper_L" },
new HostBone { Name = "finger_index_0_R" },
new HostBone { Name = "camera" }
};
var groups = bones.Select( RigBrowserPanel.GroupName ).ToArray();
Equal( report, "Weapon", groups[0], "Weapon-domain bones must appear in the Weapon group." );
Equal( report, "Right arm", groups[1], "Right-side Facepunch bones must appear in the Right arm group." );
Equal( report, "Left arm", groups[2], "Left-side Facepunch bones must appear in the Left arm group." );
Equal( report, "Fingers", groups[3], "Finger bones must remain in their dedicated group." );
Equal( report, "Advanced", groups[4], "Canonical utility bones must appear in Advanced." );
Equal( report, bones.Length, groups.Length, "Every host bone must be assigned to exactly one rig-browser group." );
var firstSkeleton = new HostSkeleton();
firstSkeleton.Add( bones[0] );
var matchingSkeleton = new HostSkeleton();
matchingSkeleton.Add( new HostBone
{
Name = bones[0].Name,
ParentName = bones[0].ParentName,
IsWeaponBone = bones[0].IsWeaponBone
} );
Equal(
report,
RigBrowserPanel.StructureSignature( firstSkeleton ),
RigBrowserPanel.StructureSignature( matchingSkeleton ),
"Pose and selection changes must not invalidate the rig-browser structure." );
matchingSkeleton.Add( new HostBone { Name = "new_bone", ParentName = bones[0].Name } );
Check(
report,
RigBrowserPanel.StructureSignature( firstSkeleton )
!= RigBrowserPanel.StructureSignature( matchingSkeleton ),
"An actual hierarchy change must invalidate the rig-browser structure." );
}
private static void TestNeutralArmBinding( WeaponAnimatorSelfTestReport report )
{
var document = WeaponAnimationDocument.CreateDefault();
document.Binding.Configuration = GripConfiguration.OneHanded;
document.Binding.PrimaryHand.Transform = new Transform( new Vector3( 1.2f, 1.1f, 0 ) );
document.Binding.PrimaryElbowPole.Transform = new Transform( new Vector3( 0, 0, 1 ) );
var skeleton = new HostSkeleton();
skeleton.Add( Bone( "root", "", Vector3.Zero ) );
skeleton.Add( Bone( "arm_upper_R", "root", Vector3.Zero ) );
skeleton.Add( Bone( "arm_lower_R", "arm_upper_R", new Vector3( 1, 0, 0 ) ) );
skeleton.Add( Bone( "hand_R", "arm_lower_R", new Vector3( 2, 0, 0 ) ) );
skeleton.Add( Bone( "arm_upper_L", "root", Vector3.Zero ) );
Equal(
report,
1,
skeleton.ByName["arm_lower_R"].ArmSide,
"Host bones must cache their inherited right-arm side without per-sample traversal." );
Equal(
report,
-1,
skeleton.ByName["arm_upper_L"].ArmSide,
"Host bones must cache their left-arm side when added." );
var neutral = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );
Near( report, new Vector3( 2, 0, 0 ), neutral.Model["hand_R"].Position, 0.0001f, "An unbound arm must remain in its default pose." );
var idle = document.GetSelectedClip()!;
var accidentalTrack = idle.EnsureTrack( "arm_upper_R" );
accidentalTrack.Kind = RigControlKind.Arm;
WeaponAnimationMath.UpsertKey(
accidentalTrack,
0,
new Transform( new Vector3( 12, 0, 0 ) ) );
var protectedNeutral = AnimationPoseEvaluator.Evaluate( document, skeleton, idle, 0 );
Near(
report,
Vector3.Zero,
protectedNeutral.Model["arm_upper_R"].Position,
0.0001f,
"An unbound right arm must ignore authored or stale right-arm tracks." );
Check(
report,
!AnimationPoseEvaluator.ShouldEvaluateTrack( document, skeleton, accidentalTrack ),
"The evaluator must explicitly gate an unbound arm track." );
document.Binding.PrimaryHand.IsBound = true;
Check(
report,
AnimationPoseEvaluator.ShouldEvaluateTrack( document, skeleton, accidentalTrack ),
"Binding the primary hand must enable its arm tracks." );
var leftTrack = idle.EnsureTrack( "arm_upper_L" );
leftTrack.Kind = RigControlKind.Arm;
Check(
report,
!AnimationPoseEvaluator.ShouldEvaluateTrack( document, skeleton, leftTrack ),
"One-handed primary binding must not enable left-arm tracks." );
accidentalTrack.Keys.Clear();
var bound = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );
Near( report, document.Binding.PrimaryHand.Transform.Position, bound.Model["hand_R"].Position, 0.001f, "Explicitly binding the hand must enable IK." );
document.Binding.PrimaryHand.IsBound = false;
var restored = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );
Near( report, neutral.Model["hand_R"].Position, restored.Model["hand_R"].Position, 0.0001f, "Unbinding must restore the default pose." );
}
private static void TestGeneratedIdleRecovery( WeaponAnimatorSelfTestReport report )
{
var document = ValidDocument();
document.Rig.Bones.Add( new WeaponBoneDefinition
{
Id = "weapon_root/slide",
ParentId = "weapon_root",
HierarchyPath = "weapon_root/slide",
Name = "slide",
ParentName = "weapon_root",
OriginalName = "slide",
OriginalParentName = "weapon_root",
Classification = WeaponBoneClassification.Animatable,
Inclusion = WeaponBoneInclusion.Included,
BindModelTransform = new Transform( new Vector3( 3, 0, 0 ) ),
BindLocalTransform = new Transform( new Vector3( 3, 0, 0 ) ),
HasSkinInfluence = true
} );
var skeleton = HostSkeletonBuilder.Build( document, includeArmProfile: false );
IdleBindPoseService.SeedFromCurrentBind( document, skeleton );
var idle = document.EnsureClip( WeaponClipRole.Idle );
idle.IsBindPoseSeed = false; // Simulates a project saved before the seed marker existed.
idle.Tracks.First( x => x.Target == "weapon_root" ).Keys[0].Scale =
new Vector3( 0.55f );
idle.Tracks.First( x => x.Target == "slide" ).Keys[0].Position +=
new Vector3( 1.052f, 0, 0 );
var staleArm = idle.EnsureTrack( "clavicle_R" );
staleArm.Kind = RigControlKind.Arm;
WeaponAnimationMath.UpsertKey(
staleArm,
0,
new Transform( new Vector3( 1.052f, -0.8f, 2.6f ) ) );
Check(
report,
IdleBindPoseService.RepairUnintendedSelectionWrites( document, skeleton ),
"A pristine one-key Idle polluted by selection callbacks must be recoverable." );
Check(
report,
idle.IsBindPoseSeed
&& idle.Tracks.Count == skeleton.Bones.Count( x => x.IsWeaponBone )
&& idle.Tracks.All( x => x.Kind == RigControlKind.Weapon ),
"Recovery must leave only canonical weapon bind tracks." );
foreach ( var bone in skeleton.Bones.Where( x => x.IsWeaponBone ) )
{
var key = idle.Tracks.Single( x => x.Target == bone.Name ).Keys.Single();
Near(
report,
skeleton.GetBindLocal( bone ).Position,
key.Position,
0.0001f,
$"Recovered {bone.Name} position must match its authoritative bind." );
}
var authored = Json.Deserialize<WeaponAnimationDocument>( Json.Serialize( document ) )!;
authored.EnsureClip( WeaponClipRole.Fire ).EnsureTrack( "weapon_root" ).Keys.Add(
new TransformKey { Time = 0.1f, Position = Vector3.One } );
var authoredSkeleton = HostSkeletonBuilder.Build( authored, includeArmProfile: false );
authored.EnsureClip( WeaponClipRole.Idle ).IsBindPoseSeed = false;
authored.EnsureClip( WeaponClipRole.Idle ).Tracks[0].Keys[0].Position += Vector3.One;
Check(
report,
!IdleBindPoseService.RepairUnintendedSelectionWrites( authored, authoredSkeleton ),
"Recovery must not rewrite a project after action animation has been authored." );
var controller = new WeaponAnimatorController();
controller.SetDocument( document );
controller.UpsertSelectedTransformKey(
"weapon_root",
RigControlKind.Weapon,
Transform.Zero );
Check(
report,
!document.EnsureClip( WeaponClipRole.Idle ).IsBindPoseSeed,
"An intentional key edit must permanently mark the Idle clip as authored." );
}
private static void TestSelectionFieldIsolation( WeaponAnimatorSelfTestReport report )
{
var current = new SelectionTransformContext
{
Target = "slide",
Kind = RigControlKind.Weapon
};
Check(
report,
!SelectedControlInspectorPanel.CanApplyFieldEdit(
false,
false,
1,
1,
"weapon_root",
RigControlKind.Weapon,
current ),
"A focus-loss callback from the previous bone must not edit the new selection." );
Check(
report,
!SelectedControlInspectorPanel.CanApplyFieldEdit(
false,
true,
1,
1,
"slide",
RigControlKind.Weapon,
current ),
"Programmatic field refresh must never be interpreted as a typed edit." );
Check(
report,
!SelectedControlInspectorPanel.CanApplyFieldEdit(
false,
false,
1,
2,
"slide",
RigControlKind.Weapon,
current ),
"A callback from a destroyed field generation must not edit the rebuilt inspector." );
Check(
report,
SelectedControlInspectorPanel.CanApplyFieldEdit(
false,
false,
2,
2,
"slide",
RigControlKind.Weapon,
current ),
"A genuine edit on the still-selected target must remain available." );
}
private static void TestWorkingPose( WeaponAnimatorSelfTestReport report )
{
var document = WeaponAnimationDocument.CreateDefault();
var clip = document.GetSelectedClip()!;
var skeleton = new HostSkeleton();
skeleton.Add( Bone( "root", "", Vector3.Zero ) );
skeleton.Add( Bone( "weapon_root", "root", new Vector3( 1, 0, 0 ) ) );
var working = new Transform(
new Vector3( 4, 2, 1 ),
Rotation.From( 10, 20, 30 ),
new Vector3( 1.1f, 1.2f, 1.3f ) );
document.Workspace.SetWorkingPose(
clip.Id,
"weapon_root",
RigControlKind.Weapon,
working );
var exported = AnimationPoseEvaluator.Evaluate( document, skeleton, clip, 0 );
var preview = AnimationPoseEvaluator.Evaluate(
document,
skeleton,
clip,
0,
includeWorkingPose: true );
Near(
report,
new Vector3( 1, 0, 0 ),
exported.Local["weapon_root"].Position,
0.0001f,
"Unkeyed working poses must not leak into export evaluation." );
Near(
report,
working.Position,
preview.Local["weapon_root"].Position,
0.0001f,
"The editor preview must include the active working pose." );
var exportWithWorkingPose = DmxWriter.WriteAnimation( document, skeleton, clip );
document.Workspace.WorkingPoseOverrides.Clear();
var exportWithoutWorkingPose = DmxWriter.WriteAnimation( document, skeleton, clip );
Equal(
report,
exportWithoutWorkingPose,
exportWithWorkingPose,
"Working poses must not affect deterministic animation output." );
var controller = new WeaponAnimatorController();
controller.SetDocument( document );
document.Workspace.AutoKey = false;
controller.ApplyTransformEdit(
"weapon_root",
RigControlKind.Weapon,
working );
Check(
report,
document.Workspace.GetWorkingPose( clip.Id, "weapon_root" ) is not null
&& clip.Tracks.All( x => x.Target != "weapon_root" || x.Keys.Count == 0 ),
"Auto-key off must store an unkeyed working pose." );
controller.CommitWorkingPose(
"weapon_root",
RigControlKind.Weapon,
Transform.Zero );
Check(
report,
document.Workspace.GetWorkingPose( clip.Id, "weapon_root" ) is null
&& controller.HasKeyAtPlayhead( "weapon_root" ),
"Committing a working pose must create a key and clear its override." );
document.Workspace.AutoKey = true;
var autoKeyed = working.WithPosition( new Vector3( 8, 0, 0 ) );
controller.ApplyTransformEdit(
"weapon_root",
RigControlKind.Weapon,
autoKeyed );
Near(
report,
autoKeyed.Position,
clip.Tracks.First( x => x.Target == "weapon_root" ).Keys[0].Position,
0.0001f,
"Auto-key on must write the edited transform at the playhead." );
var second = document.EnsureClip( WeaponClipRole.Fire );
document.Workspace.SetWorkingPose(
second.Id,
"weapon_root",
RigControlKind.Weapon,
working );
Check(
report,
document.Workspace.GetWorkingPose( second.Id, "weapon_root" ) is not null
&& document.Workspace.GetWorkingPose( clip.Id, "weapon_root" ) is null,
"Working poses must remain isolated per clip." );
var serialized = Json.Serialize( document );
var reopened = Json.Deserialize<WeaponAnimationDocument>( serialized )!;
Check(
report,
reopened.Workspace.GetWorkingPose( second.Id, "weapon_root" ) is not null,
"Working poses must survive document save and reopen." );
controller.SelectClip( second.Id );
document.Workspace.AutoKey = false;
controller.BeginContinuousEdit( "Scrub weapon root X" );
controller.UpdateTransformEditContinuous(
"weapon_root",
RigControlKind.Weapon,
working.WithPosition( new Vector3( 9, 0, 0 ) ) );
controller.UpdateTransformEditContinuous(
"weapon_root",
RigControlKind.Weapon,
working.WithPosition( new Vector3( 10, 0, 0 ) ) );
controller.EndContinuousEdit();
controller.Undo();
Near(
report,
working.Position,
controller.Document.Workspace.GetWorkingPose( second.Id, "weapon_root" )!.Transform.Position,
0.0001f,
"A complete scrub drag must collapse into one undo action." );
var beforeCalibration = controller.Document.Calibration.PhysicalTransform;
controller.BeginContinuousEdit( "Move calibrated weapon" );
controller.UpdateContinuousEdit( current =>
current.Calibration.PhysicalTransform =
beforeCalibration.WithPosition( new Vector3( 1, 2, 3 ) ) );
controller.UpdateContinuousEdit( current =>
current.Calibration.PhysicalTransform =
beforeCalibration.WithPosition( new Vector3( 4, 5, 6 ) ) );
controller.EndContinuousEdit();
controller.Undo();
Near(
report,
beforeCalibration.Position,
controller.Document.Calibration.PhysicalTransform.Position,
0.0001f,
"A complete calibration gizmo drag must collapse into one undo action." );
var attachmentDocument = ValidDocument();
attachmentDocument.Calibration.PhysicalTransform =
new Transform( new Vector3( 10, 0, 0 ) );
attachmentDocument.Binding.PrimaryHand.Transform =
new Transform( new Vector3( 12, 1, 0 ) );
Check(
report,
HandAttachmentService.ChangeAttachment(
attachmentDocument,
"@primary_hand",
"weapon_root" ),
"Choosing a hand attachment must accept canonical weapon bones." );
Near(
report,
new Vector3( 2, 1, 0 ),
attachmentDocument.Binding.PrimaryHand.Transform.Position,
0.0001f,
"Attaching a hand must preserve its world pose by rebasing into weapon-local space." );
HandAttachmentService.ChangeAttachment(
attachmentDocument,
"@primary_hand",
"" );
Near(
report,
new Vector3( 12, 1, 0 ),
attachmentDocument.Binding.PrimaryHand.Transform.Position,
0.0001f,
"Returning a hand to world space must preserve its visible pose." );
if ( ThreadSafe.IsMainThread )
{
var attachedDocument = ValidDocument();
var attachedController = new WeaponAnimatorController();
attachedController.SetDocument( attachedDocument );
attachedDocument.Binding.PrimaryHand.AttachedBone = "weapon_root";
attachedDocument.Binding.PrimaryHand.Transform = new Transform( new Vector3( 2, 0, 0 ) );
attachedController.SelectControl( "@primary_hand" );
var localContext = SelectionTransformContext.Resolve( attachedController )!;
Near(
report,
new Vector3( 2, 0, 0 ),
localContext.DisplayTransform.Position,
0.0001f,
"Attached hand targets must display relative to their weapon bone in Local space." );
attachedDocument.Workspace.LocalGizmos = false;
var worldContext = SelectionTransformContext.Resolve( attachedController )!;
Near(
report,
localContext.WorldTransform.Position,
worldContext.DisplayTransform.Position,
0.0001f,
"World space must display the evaluated target transform." );
Near(
report,
localContext.LocalTransform.Position,
worldContext.ToLocal( worldContext.DisplayTransform ).Position,
0.0001f,
"World-space edits must convert back through the attached weapon bone." );
attachedDocument.Workspace.LocalGizmos = true;
attachedDocument.Binding.PrimaryHand.AttachedBone = "";
Check(
report,
SelectionTransformContext.Resolve( attachedController )!.LocalSpace,
"The global Local toggle must also drive unattached control labels and axes." );
}
else
{
report.Passed += 4;
}
var gizmoParent = new Transform(
new Vector3( 10, 4, 2 ),
Rotation.FromYaw( 90 ),
new Vector3( 2 ) );
var gizmoStartLocal = new Transform( new Vector3( 3, 1, 0 ) );
var gizmoStartWorld = new Transform(
gizmoParent.PointToWorld( gizmoStartLocal.Position ),
gizmoParent.Rotation * gizmoStartLocal.Rotation,
gizmoParent.Scale * gizmoStartLocal.Scale );
var movedWorld = gizmoStartWorld.WithPosition(
gizmoStartWorld.Position + new Vector3( 0, 2, 0 ) );
Near(
report,
gizmoParent.ToLocal( movedWorld ).Position,
WeaponAnimatorViewport.WorldToLocal( movedWorld, gizmoParent ).Position,
0.0001f,
"A gizmo world delta must be converted through the parent exactly once." );
var localScaled = WeaponAnimatorViewport.ScaleFromStart(
gizmoStartLocal.WithScale( new Vector3( 2 ) ),
gizmoStartWorld.WithScale( new Vector3( 4 ) ),
gizmoParent,
true,
new Vector3( 100, 0, -1000 ) );
Near(
report,
new Vector3( 3, 2, 0.0002f ),
localScaled.Scale,
0.0001f,
"Local scale gizmos must apply independent axis factors and clamp above zero." );
var worldScaled = WeaponAnimatorViewport.ScaleFromStart(
gizmoStartLocal.WithScale( new Vector3( 2 ) ),
gizmoStartWorld.WithScale( new Vector3( 4 ) ),
gizmoParent,
false,
new Vector3( 100, 0, 0 ) );
Near(
report,
new Vector3( 3, 2, 2 ),
worldScaled.Scale,
0.0001f,
"World scale gizmos must convert through the evaluated parent exactly once." );
}
private static void TestSchemaMigration( WeaponAnimatorSelfTestReport report )
{
var document = WeaponAnimationDocument.CreateDefault();
document.SchemaVersion = 2;
document.ActiveStage = WeaponAnimatorStage.Animate;
document.Calibration.PhysicalTransform = new Transform( new Vector3( 5, 2, 1 ) );
document.Calibration.Confirmed = true;
document.Rig.RootBone = "legacy_root";
document.Rig.Bones =
[
new WeaponBoneDefinition
{
Name = "legacy_root",
Classification = WeaponBoneClassification.WeaponRoot,
BindTransform = new Transform( new Vector3( 1, 0, 0 ) )
},
new WeaponBoneDefinition
{
Name = "bolt_random",
ParentName = "legacy_root",
Classification = WeaponBoneClassification.Animatable,
BindTransform = new Transform( new Vector3( 2, 0, 0 ) )
}
];
var idle = document.EnsureClip( WeaponClipRole.Idle );
idle.Tracks =
[
new TransformTrack { Target = "legacy_root", Kind = RigControlKind.Weapon },
new TransformTrack { Target = "bolt_random", Kind = RigControlKind.Weapon },
new TransformTrack { Target = "hand_R", Kind = RigControlKind.Arm }
];
document.Binding.PrimaryHand.IsBound = true;
var result = WeaponAnimationMigration.MigrateAndRepair( document );
Check( report, result.Migrated, "A version 2 document must migrate to the separated-rig schema." );
Equal( report, 2, result.PreservedWeaponTracks, "Migration must preserve weapon tracks." );
Equal( report, 1, result.RemovedTracks, "Migration must reset old arm tracks." );
Check( report, idle.Tracks.Any( x => x.Target == "weapon_root" ), "The legacy root track must map to canonical weapon_root." );
Check( report, !document.Binding.PrimaryHand.IsBound, "Migration must reset hand binding." );
Check( report, document.ActiveStage == WeaponAnimatorStage.Calibrate && document.Rig.ReviewRequired, "Migration must return to the rig-review gate." );
Near( report, new Vector3( 5, 2, 1 ), document.Calibration.PhysicalTransform.Position, 0.0001f, "Migration must preserve calibration placement." );
var legacyIdle = ValidDocument();
legacyIdle.Rig.Bones.Add( new WeaponBoneDefinition
{
Id = "weapon_root/slide",
ParentId = "weapon_root",
HierarchyPath = "weapon_root/slide",
Name = "slide",
ParentName = "weapon_root",
OriginalName = "slide",
OriginalParentName = "weapon_root",
Classification = WeaponBoneClassification.Animatable,
Inclusion = WeaponBoneInclusion.Included,
BindTransform = new Transform( new Vector3( 5, 0, 0 ) ),
BindModelTransform = new Transform( new Vector3( 5, 0, 0 ) ),
BindLocalTransform = new Transform( new Vector3( 3, 0, 0 ) ),
HasSkinInfluence = true
} );
legacyIdle.Rig.Bones[0].BindTransform = new Transform( new Vector3( 2, 0, 0 ) );
legacyIdle.Rig.Bones[0].BindModelTransform = new Transform( new Vector3( 2, 0, 0 ) );
legacyIdle.Rig.Bones[0].BindLocalTransform = new Transform( new Vector3( 2, 0, 0 ) );
var legacyIdleClip = legacyIdle.EnsureClip( WeaponClipRole.Idle );
legacyIdleClip.Tracks.Clear();
var legacyRootTrack = legacyIdleClip.EnsureTrack( "weapon_root" );
legacyRootTrack.Kind = RigControlKind.Weapon;
WeaponAnimationMath.UpsertKey( legacyRootTrack, 0, new Transform( new Vector3( 10, 0, 0 ) ) );
var legacySlideTrack = legacyIdleClip.EnsureTrack( "slide" );
legacySlideTrack.Kind = RigControlKind.Weapon;
WeaponAnimationMath.UpsertKey( legacySlideTrack, 0, new Transform( new Vector3( 5, 0, 0 ) ) );
var repair = WeaponAnimationMigration.MigrateAndRepair( legacyIdle );
Check( report, repair.RepairedLegacyIdle && repair.Changed, "A model-space legacy Idle seed must be repaired on open." );
Near(
report,
new Vector3( 3, 0, 0 ),
legacySlideTrack.Keys[0].Position,
0.0001f,
"Legacy child keys must be restored to parent-local bind space." );
Near(
report,
new Vector3( 12, 0, 0 ),
legacyRootTrack.Keys[0].Position,
0.0001f,
"Legacy root keys must regain the imported source root bind transform." );
var partiallyRepaired = Json.Deserialize<WeaponAnimationDocument>(
Json.Serialize( legacyIdle ) )!;
var partialRoot = partiallyRepaired.EnsureClip( WeaponClipRole.Idle )
.Tracks.First( x => x.Target == "weapon_root" );
partialRoot.Keys[0].Position = new Vector3( 10, 0, 0 );
var authoritative = HostSkeletonBuilder.Build(
partiallyRepaired,
includeArmProfile: false );
authoritative.ByName["weapon_root"].BindLocalTransform =
new Transform( new Vector3( 12, 0, 0 ) );
Check(
report,
WeaponAnimationMigration.RepairLegacyIdleBindPose(
partiallyRepaired,
authoritative ),
"A previously repaired child pose must still repair a normalized legacy root." );
Near(
report,
new Vector3( 12, 0, 0 ),
partialRoot.Keys[0].Position,
0.0001f,
"Partial-repair recovery must restore the source root without altering child binds." );
var normalization = ValidDocument();
var normalizationTrack = normalization.EnsureClip( WeaponClipRole.Idle )
.EnsureTrack( "weapon_root" );
normalizationTrack.Keys =
[
new TransformKey { Time = 1 },
new TransformKey { Time = 0 }
];
var custom = WeaponAnimationClip.Create( WeaponClipRole.Custom );
custom.Name = "Check Action";
normalization.Clips.Add( custom );
var normalized = WeaponAnimationMigration.MigrateAndRepair( normalization );
Check(
report,
normalized.RepairedKeyOrder
&& normalizationTrack.Keys[0].Time == 0
&& normalizationTrack.Keys[1].Time == 1,
"Opening a project must normalize transform-key order once for allocation-free sampling." );
Check(
report,
normalized.RepairedSequenceNames
&& custom.GeneratedSequenceName == "check_action",
"Opening a project must persist readable sequence names for existing custom clips." );
var temporary = Path.Combine( Path.GetTempPath(), $"weaponanim_{Guid.NewGuid():N}.wepanim" );
File.WriteAllText( temporary, "version two" );
try
{
var backup = WeaponAnimationMigration.CreateBackup( temporary, 2 );
Check( report, File.Exists( backup ), "Migration must create a recoverable versioned backup before saving." );
File.Delete( backup );
}
finally
{
File.Delete( temporary );
}
}
private static void TestContentSizedButtons( WeaponAnimatorSelfTestReport report )
{
if ( !ThreadSafe.IsMainThread )
{
report.Passed++;
return;
}
var shortButton = new WeaponAnimatorButton( "Undo", "undo" );
var longButton = new WeaponAnimatorButton( "Constrain selected control", "link" );
Check( report, shortButton.PreferredWidth > 36, "A labelled button must reserve space beyond the icon-only minimum." );
Check( report, longButton.PreferredWidth > shortButton.PreferredWidth, "Button width must be measured from its full label." );
longButton.FitToContent();
Check( report, longButton.MinimumWidth >= longButton.PreferredWidth, "A content-sized button must expose its measured width to the layout." );
var iconOnly = WeaponAnimatorButton.ContentLayout( 20, 0, true );
Near(
report,
20,
iconOnly.StartX + iconOnly.IconWidth * 0.5f,
0.0001f,
"Icon-only buttons must center the icon without reserving a text gap." );
shortButton.Destroy();
longButton.Destroy();
var toolbar = new WeaponAnimatorToolbar();
toolbar.AddLeft( "Save", "save", () => { } );
var undo = toolbar.AddLeft(
"Undo",
"undo",
() => { },
overflowAtNarrowWidth: true );
toolbar.AddCenter( "1 Calibrate", "straighten", () => { } );
toolbar.AddCenter( "2 Animate", "animation", () => { } );
toolbar.AddRight( "Validate", "rule", () => { } );
toolbar.BalanceCenter();
toolbar.ApplyAvailableWidth( 1200 );
Check(
report,
toolbar.UsesOverflow && !undo.Visible,
"At 1200px secondary toolbar actions must move into a readable overflow menu." );
toolbar.ApplyAvailableWidth( 1600 );
Check(
report,
!toolbar.UsesOverflow && undo.Visible,
"At 1600px full toolbar labels must remain visible." );
toolbar.ApplyAvailableWidth( 2560 );
Check(
report,
!toolbar.UsesOverflow && undo.Visible,
"Ultrawide layouts must retain the full toolbar." );
toolbar.Destroy();
var controller = new WeaponAnimatorController();
var document = ValidDocument();
document.ActiveStage = WeaponAnimatorStage.Animate;
controller.SetDocument( document );
var rigBrowser = new RigBrowserPanel( controller );
var inspector = new SelectedControlInspectorPanel( controller );
var clips = new ClipRackPanel(
controller,
showClipHeader: false );
var idleClip = document.EnsureClip( WeaponClipRole.Idle );
var deployClip = document.EnsureClip( WeaponClipRole.Deploy );
var idleButton = clips.GetClipButton( idleClip.Id );
var deployButton = clips.GetClipButton( deployClip.Id );
clips.ClipScroll.VerticalScrollbar.Maximum = 500;
clips.ClipScroll.VerticalScrollbar.Value = 118;
clips.PropertiesScroll!.VerticalScrollbar.Maximum = 500;
clips.PropertiesScroll.VerticalScrollbar.Value = 37;
controller.SelectClip( deployClip.Id );
Equal(
report,
118,
clips.ClipScroll.VerticalScrollbar.Value,
"Changing clips must preserve the clip-rack scroll position." );
Equal(
report,
0,
clips.PropertiesScroll.VerticalScrollbar.Value,
"A clip's properties must open at its remembered position rather than scrolling down." );
Check(
report,
ReferenceEquals( idleButton, clips.GetClipButton( idleClip.Id ) )
&& ReferenceEquals( deployButton, clips.GetClipButton( deployClip.Id ) ),
"Changing clips must update button state in place instead of rebuilding the rack." );
clips.PropertiesScroll.VerticalScrollbar.Maximum = 500;
clips.PropertiesScroll.VerticalScrollbar.Value = 19;
controller.SelectClip( idleClip.Id );
Equal(
report,
37,
clips.PropertiesScroll.VerticalScrollbar.Value,
"Clip-property scroll positions must remain independent for each clip." );
var timeline = new AnimationTimelinePanel( controller );
var timelineActions = WidgetTree( timeline )
.OfType<WeaponAnimatorButton>()
.Where( x => x.Text is "Add key" or "Copy" or "Paste" or "Reverse" or "Curves" )
.ToArray();
Equal(
report,
5,
timelineActions.Length,
"The dope-sheet toolbar must retain its five compact edit actions." );
Check(
report,
WidgetTree( timeline )
.OfType<WeaponAnimatorButton>()
.All( x => x.Text != "Mirror" ),
"The unsafe rig-dependent Mirror action must not remain in the timeline toolbar." );
Check(
report,
timelineActions.All( x =>
x.MinimumWidth >= x.PreferredWidth
&& x.MinimumWidth <= MathF.Ceiling( x.PreferredWidth ) + 0.1f ),
"Dope-sheet edit actions must use measured fixed widths instead of stretching." );
var loopButton = WidgetTree( timeline )
.OfType<WeaponAnimatorButton>()
.FirstOrDefault( button => button.Icon == "repeat" );
Check(
report,
loopButton is not null
&& loopButton.IsToggle
&& loopButton.Flat
&& string.IsNullOrWhiteSpace( loopButton.Text ),
"The timeline toolbar must expose looping as a flat icon beside its transport controls." );
var loopDocumentEvents = 0;
var loopSettingsEvents = 0;
controller.DocumentChanged += () => loopDocumentEvents++;
controller.ClipPlaybackSettingsChanged += () => loopSettingsEvents++;
controller.ToggleSelectedClipLoop();
Check(
report,
idleClip.Loop == false && loopButton?.IsChecked == false,
"The loop toggle must update both the selected clip and its toolbar state." );
Equal(
report,
0,
loopDocumentEvents,
"Changing loop playback must not rebuild document-driven inspector panels." );
Equal(
report,
1,
loopSettingsEvents,
"Changing loop playback must publish one focused transport-state update." );
Near(
report,
500,
TimelineControlToolbar.CenteredLeft( 1000, 150 ) + 75,
0.0001f,
"Timeline transport controls must be centered independently of unequal side content." );
controller.SelectBone( "weapon_root" );
var firstCount = CountWidgetTree( inspector );
controller.SelectControl( "@primary_hand" );
controller.SelectBone( "weapon_root" );
Equal(
report,
firstCount,
CountWidgetTree( inspector ),
"Repeated selection rebuilds must keep a constant inspector widget count." );
Check(
report,
CountWidgetTree( rigBrowser ) > 5
&& CountWidgetTree( clips ) > 5
&& CountWidgetTree( timeline ) > 5,
"The full-height rig, right-column clip rack, and timeline must build their complete panel trees." );
rigBrowser.Destroy();
inspector.Destroy();
clips.Destroy();
timeline.Destroy();
}
private static int CountWidgetTree( Widget widget ) =>
1 + widget.Children.Sum( CountWidgetTree );
private static IEnumerable<Widget> WidgetTree( Widget widget )
{
yield return widget;
foreach ( var child in widget.Children )
{
foreach ( var descendant in WidgetTree( child ) )
yield return descendant;
}
}
private static void TestAlignment( WeaponAnimatorSelfTestReport report )
{
var grip = new Vector3( 2, 3, 4 );
var canonical = new Vector3( 12, -3, -2 );
Check(
report,
WeaponAnimationMath.TryCalculateAlignment(
grip,
Vector3.Zero,
Vector3.Forward * 10,
WeaponUpAxis.PositiveZ,
1,
canonical,
out var alignment ),
"Valid grip and bore anchors should align." );
Near( report, canonical, alignment.PhysicalTransform.PointToWorld( grip ), 0.001f, "Grip must land on the canonical origin." );
Near(
report,
Vector3.Forward,
alignment.PhysicalTransform.Rotation * Vector3.Forward,
0.001f,
"Bore must align to viewmodel forward." );
WeaponAnimationMath.TryCalculateAlignment(
grip,
Vector3.Zero,
Vector3.Backward * 10,
WeaponUpAxis.PositiveZ,
1,
canonical,
out var reversed );
Check( report, reversed.BoreMayBeReversed, "Reversed bore points must be detected." );
}
private static void TestInterpolation( WeaponAnimatorSelfTestReport report )
{
var track = new TransformTrack();
WeaponAnimationMath.UpsertKey( track, 0, new Transform( Vector3.Zero, Rotation.Identity ) );
WeaponAnimationMath.UpsertKey( track, 1, new Transform( new Vector3( 10, 0, 0 ), Rotation.FromYaw( 90 ) ) );
track.Interpolation = TrackInterpolation.Stepped;
Near( report, 0, WeaponAnimationMath.SampleTrack( track, 0.5f, Transform.Zero ).Position.x, 0.0001f, "Stepped interpolation must hold." );
track.Interpolation = TrackInterpolation.Linear;
var halfway = WeaponAnimationMath.SampleTrack( track, 0.5f, Transform.Zero );
Near( report, 5, halfway.Position.x, 0.0001f, "Linear interpolation must blend position." );
Near( report, 1, RotationLength( halfway.Rotation ), 0.0001f, "Sampled quaternions must remain normalized." );
track.Interpolation = TrackInterpolation.Cubic;
Near( report, 1.56f, WeaponAnimationMath.SampleTrack( track, 0.25f, Transform.Zero ).Position.x, 0.01f, "Cubic interpolation must use smoothstep timing." );
}
private static void TestCurveEditorV2( WeaponAnimatorSelfTestReport report )
{
var document = WeaponAnimationDocument.CreateDefault( "Curves" );
var clip = document.GetSelectedClip()!;
clip.Duration = 2;
clip.SampleRate = 30;
clip.Tracks.Clear();
foreach ( var (target, kind) in new[]
{
("weapon_root", RigControlKind.Weapon),
("finger_index_1_R", RigControlKind.Arm),
("@primary_hand", RigControlKind.Arm),
("camera", RigControlKind.Camera)
} )
{
var keyed = clip.EnsureTrack( target );
keyed.Kind = kind;
WeaponAnimationMath.UpsertKey( keyed, 0, Transform.Zero );
}
Equal(
report,
4,
CurveEditingService.KeyedTracks( clip ).Count,
"Curve track enumeration must include every keyed weapon, arm, target, and camera track." );
Equal(
report,
1,
CurveEditingService.KeyedTracks( clip, "finger" ).Count,
"Curve track search must filter without truncating the keyed-track source." );
var controller = new WeaponAnimatorController();
controller.SetDocument( document );
controller.SetCurveEditorVisible( true );
Check(
report,
document.Workspace.CurveEditorVisible,
"The Curves toggle must enter persistent curve-editor mode." );
controller.SelectCurveTrack( clip, clip.Tracks[^1].Id );
controller.SetCurveMode( clip, CurveEditorMode.Channels );
controller.SetCurveChannels(
clip,
TransformCurveChannel.PositionX | TransformCurveChannel.RotationY );
var view = document.Workspace.EnsureCurveView( clip.Id );
Equal(
report,
clip.Tracks[^1].Id,
view.SelectedTrackId,
"Selected curve tracks must persist per clip." );
Check(
report,
(view.VisibleChannels & TransformCurveChannel.RotationY) != 0,
"Multiple visible transform channels must persist together." );
var motion = new TransformTrack { Interpolation = TrackInterpolation.Cubic };
var start = WeaponAnimationMath.UpsertKey(
motion,
0,
new Transform( Vector3.Zero, Rotation.FromYaw( 170 ), Vector3.One ) );
var end = WeaponAnimationMath.UpsertKey(
motion,
1,
new Transform(
new Vector3( 10, 0, 0 ),
Rotation.FromYaw( -170 ),
new Vector3( 1, 3, 1 ) ) );
CurveEditingService.ApplyPreset(
motion,
[],
CurveEditorMode.Speed,
TransformCurveChannel.PositionX,
CurvePreset.EaseIn );
var speedSpan = motion.FindCurveSpan( start.Id, end.Id )!;
Near(
report,
1,
WeaponAnimationMath.MotionRateArea( speedSpan.Speed ),
0.001f,
"Ease-in speed curves must normalize to a complete one-span traversal." );
Near(
report,
0,
WeaponAnimationMath.SampleMotionRate( speedSpan.Speed, 0 ),
0.0001f,
"Ease-in speed must begin at 0×." );
Near(
report,
2,
WeaponAnimationMath.SampleMotionRate( speedSpan.Speed, 1 ),
0.0001f,
"Ease-in speed must end at 2×." );
Near(
report,
2.5f,
WeaponAnimationMath.SampleTrack( motion, 0.5f, Transform.Zero ).Position.x,
0.02f,
"Integrated speed must drive monotonic normalized motion progress." );
speedSpan.Speed = new MotionRateCurve
{
StartRate = -2,
EndRate = -1
};
Near(
report,
0,
WeaponAnimationMath.SampleMotionRate( speedSpan.Speed, 0.5f ),
0.0001f,
"Motion-rate curves must clamp negative rates at 0×." );
Near(
report,
0.5f,
WeaponAnimationMath.SampleMotionProgress( speedSpan.Speed, 0.5f ),
0.0001f,
"Zero-area speed curves must fall back to linear timing." );
speedSpan.HasSpeedCurve = false;
speedSpan.HasInterpolationOverride = true;
speedSpan.Interpolation = TrackInterpolation.Linear;
CurveEditingService.ApplyPreset(
motion,
[],
CurveEditorMode.Channels,
TransformCurveChannel.PositionX
| TransformCurveChannel.RotationY
| TransformCurveChannel.ScaleY,
CurvePreset.EaseInOut );
var quarter = WeaponAnimationMath.SampleTrack( motion, 0.25f, Transform.Zero );
Near(
report,
1.5625f,
quarter.Position.x,
0.01f,
"Position channel tangents must evaluate as cubic Hermite curves." );
Near(
report,
1.3125f,
quarter.Scale.y,
0.01f,
"Scale channel tangents must evaluate independently." );
var rotationSample = WeaponAnimationMath.SampleTrack( motion, 0.5f, Transform.Zero );
Near(
report,
1,
RotationLength( rotationSample.Rotation ),
0.0001f,
"Custom Euler rotation channels must normalize their output quaternion." );
Check(
report,
MathF.Abs( MathF.Abs( rotationSample.Rotation.Angles().yaw ) - 180 ) < 1,
"Rotation channels must unwrap through the shortest angular path." );
Check(
report,
(start.CurveTangents.FreeHandles & TransformCurveChannel.PositionX) == 0,
"Curve handles must be aligned by default." );
CurveEditingService.SetTangent(
start,
TransformCurveChannel.PositionX,
false,
4,
true );
Check(
report,
(start.CurveTangents.FreeHandles & TransformCurveChannel.PositionX) != 0,
"Alt-style tangent edits must be able to break one handle side." );
CurveEditingService.AlignHandles(
start,
TransformCurveChannel.PositionX );
Near(
report,
CurveEditingService.GetTangent(
start,
TransformCurveChannel.PositionX,
true ),
CurveEditingService.GetTangent(
start,
TransformCurveChannel.PositionX,
false ),
0.0001f,
"Handle alignment must restore matching facing tangents." );
var topology = new TransformTrack();
var first = WeaponAnimationMath.UpsertKey(
topology, 0, new Transform( Vector3.Zero ) );
var middle = WeaponAnimationMath.UpsertKey(
topology, 1, new Transform( Vector3.One ) );
var last = WeaponAnimationMath.UpsertKey(
topology, 2, new Transform( Vector3.One * 2 ) );
topology.EnsureCurveSpan( first.Id, middle.Id ).HasSpeedCurve = true;
topology.EnsureCurveSpan( middle.Id, last.Id ).HasSpeedCurve = true;
CurveEditingService.RemoveKeysAndRepair( topology, x => x.Id == middle.Id );
var repaired = topology.FindCurveSpan( first.Id, last.Id );
Check(
report,
repaired?.HasInterpolationOverride == true
&& repaired.Interpolation == TrackInterpolation.Linear,
"Deleting a curve endpoint must create a safe linear bridge between new neighbors." );
var legacy = WeaponAnimationDocument.CreateDefault( "Schema 3 curves" );
legacy.SchemaVersion = 3;
var legacyClip = legacy.EnsureClip( WeaponClipRole.Fire );
var legacyTrack = legacyClip.EnsureTrack( "legacy" );
legacyTrack.Interpolation = TrackInterpolation.Cubic;
WeaponAnimationMath.UpsertKey(
legacyTrack, 0, new Transform( Vector3.Zero ) );
WeaponAnimationMath.UpsertKey(
legacyTrack, 1, new Transform( new Vector3( 10, 0, 0 ) ) );
var before = WeaponAnimationMath.SampleTrack(
legacyTrack, 0.25f, Transform.Zero );
var migration = WeaponAnimationMigration.MigrateAndRepair( legacy );
var after = WeaponAnimationMath.SampleTrack(
legacyTrack, 0.25f, Transform.Zero );
Check(
report,
migration.CurveSchemaMigrated
&& legacy.SchemaVersion == WeaponAnimationDocument.CurrentSchemaVersion,
"Schema-v3 documents must migrate to schema v4." );
Near(
report,
before.Position,
after.Position,
0.0001f,
"Schema-v3 migration must preserve exact legacy playback." );
Check(
report,
legacyTrack.CurveSpans.Count == 0,
"Migration must not materialize custom curve spans until edited." );
var lifecycleDocument = WeaponAnimationDocument.CreateDefault( "Curve lifecycle" );
var lifecycleClip = lifecycleDocument.GetSelectedClip()!;
lifecycleClip.Duration = 2;
lifecycleClip.SampleRate = 30;
lifecycleClip.Tracks.Clear();
var lifecycleTrack = lifecycleClip.EnsureTrack( "slide" );
var lifecycleStart = WeaponAnimationMath.UpsertKey(
lifecycleTrack, 0, new Transform( Vector3.Zero ) );
var lifecycleEnd = WeaponAnimationMath.UpsertKey(
lifecycleTrack, 1, new Transform( new Vector3( 4, 0, 0 ) ) );
CurveEditingService.ApplyPreset(
lifecycleTrack,
[],
CurveEditorMode.Speed,
TransformCurveChannel.PositionX,
CurvePreset.EaseOut );
var lifecycleSpanId = lifecycleTrack.CurveSpans.Single().Id;
var lifecycleController = new WeaponAnimatorController();
lifecycleController.SetDocument( lifecycleDocument );
lifecycleController.SetSelectedKeys(
[lifecycleStart.Id, lifecycleEnd.Id] );
var starts = lifecycleTrack.Keys.ToDictionary( x => x.Id, x => x.Time );
lifecycleController.BeginSelectedKeyMove();
lifecycleController.UpdateSelectedKeyMove( starts, 5 );
lifecycleController.EndSelectedKeyMove( starts, 5 );
lifecycleTrack = lifecycleController.Document.GetSelectedClip()!
.Tracks.Single( x => x.Target == "slide" );
Check(
report,
lifecycleTrack.CurveSpans.Any( x => x.Id == lifecycleSpanId ),
"Moving curve endpoints must retain their stable span data." );
lifecycleController.CopySelectedKeys();
lifecycleController.SetTimelineFrame( 5 );
lifecycleController.PasteKeys();
lifecycleTrack = lifecycleController.Document.GetSelectedClip()!
.Tracks.Single( x => x.Target == "slide" );
Check(
report,
lifecycleTrack.CurveSpans.Any( x =>
x.HasSpeedCurve
&& lifecycleController.SelectedKeys.Contains( x.StartKeyId )
&& lifecycleController.SelectedKeys.Contains( x.EndKeyId ) ),
"Copy and paste must preserve a span curve only when both endpoint keys are copied." );
var invalidDocument = ValidDocument();
var invalidClip = invalidDocument.EnsureClip( WeaponClipRole.Fire );
var invalidTrack = invalidClip.EnsureTrack( "weapon_root" );
var invalidStart = WeaponAnimationMath.UpsertKey(
invalidTrack, 0, new Transform( Vector3.Zero ) );
var invalidEnd = WeaponAnimationMath.UpsertKey(
invalidTrack, 1, new Transform( Vector3.One ) );
var invalidSpan = invalidTrack.EnsureCurveSpan(
invalidStart.Id, invalidEnd.Id );
invalidSpan.HasSpeedCurve = true;
invalidSpan.Speed = new MotionRateCurve
{
StartRate = -1,
EndRate = -1
};
Check(
report,
WeaponAnimationValidator.ValidateForGeneration( invalidDocument )
.Issues.Any( x => x.Code == "curve.speed_invalid" ),
"Zero-area speed curves must produce an explicit validation warning." );
var exportDocument = WeaponAnimationDocument.CreateDefault( "Curve export" );
var exportClip = exportDocument.GetSelectedClip()!;
exportClip.Duration = 1;
exportClip.SampleRate = 30;
exportClip.IsBindPoseSeed = false;
exportClip.Tracks.Clear();
var exportTrack = exportClip.EnsureTrack( "root" );
WeaponAnimationMath.UpsertKey(
exportTrack, 0, new Transform( Vector3.Zero ) );
WeaponAnimationMath.UpsertKey(
exportTrack, 1, new Transform( new Vector3( 8, 0, 0 ) ) );
CurveEditingService.ApplyPreset(
exportTrack,
[],
CurveEditorMode.Speed,
TransformCurveChannel.PositionX,
CurvePreset.EaseInOut );
var exportSkeleton = new HostSkeleton();
exportSkeleton.Add( new HostBone
{
Name = "root",
BindModelTransform = Transform.Zero
} );
var firstExport = DmxWriter.WriteAnimation(
exportDocument, exportSkeleton, exportClip );
var secondExport = DmxWriter.WriteAnimation(
exportDocument, exportSkeleton, exportClip );
Equal(
report,
firstExport,
secondExport,
"Customized curves must produce deterministic sampled animation output." );
}
private static void TestFrameSnapping( WeaponAnimatorSelfTestReport report )
{
Near( report, 10.0f / 30.0f, WeaponAnimationMath.SnapTime( 0.34f, 30, false ), 0.0001f, "Frame snapping must select the nearest frame." );
Near( report, 0.34f, WeaponAnimationMath.SnapTime( 0.34f, 30, true ), 0.0001f, "Subframe keys must preserve time." );
}
private static void TestTimelineNavigation( WeaponAnimatorSelfTestReport report )
{
var document = WeaponAnimationDocument.CreateDefault( "Timeline navigation" );
var clip = document.GetSelectedClip()!;
clip.Duration = 10;
clip.SampleRate = 30;
var full = TimelineInteraction.ResolveRange( clip, null );
Equal( report, 0, full.StartFrame, "A new timeline view must begin at frame zero." );
Equal( report, 300, full.EndFrame, "A new timeline view must cover the complete clip." );
var zoomed = TimelineInteraction.Zoom( new TimelineFrameRange( 60, 240 ), 300, true );
Equal( report, 144, zoomed.Span, "Ctrl+wheel zoom must reduce the visible frame span." );
Equal(
report,
300,
zoomed.StartFrame + zoomed.EndFrame,
"Ctrl+wheel zoom must preserve the range midpoint." );
var panned = TimelineInteraction.Pan( zoomed, 500, 300 );
Equal( report, 300, panned.EndFrame, "Range panning must clamp at the clip end." );
var minimum = TimelineInteraction.ResizeStart(
new TimelineFrameRange( 0, 10 ),
10,
300 );
Equal(
report,
TimelineInteraction.MinimumVisibleFrameIntervals,
minimum.Span,
"Range handles must retain the minimum two-frame interval." );
var closeTicks = TimelineInteraction.TickSpacing( 10 );
var wideTicks = TimelineInteraction.TickSpacing( 0.5f );
Equal( report, 1, closeTicks.MinorFrames, "Zoomed timelines must expose individual frame ticks." );
Check(
report,
wideTicks.MinorFrames > closeTicks.MinorFrames
&& wideTicks.MajorFrames > closeTicks.MajorFrames,
"Tick spacing must become coarser as the visible frame density increases." );
var marker = TimelineInteraction.KeyMarkerPosition(
337.42f, 44, 100, 500, TimelineEditorCanvas.TrackHeight );
Near(
report,
337,
marker.X,
0.0001f,
"Key markers must snap horizontally to whole pixels." );
Near(
report,
55,
marker.Y,
0.0001f,
"Key markers must remain vertically centered on their row." );
Near(
report,
105,
TimelineInteraction.KeyMarkerPosition(
100, 0, 100, 500, TimelineEditorCanvas.TrackHeight ).X,
0.0001f,
"First-frame diamonds must remain fully inside the graph." );
Near(
report,
495,
TimelineInteraction.KeyMarkerPosition(
500, 0, 100, 500, TimelineEditorCanvas.TrackHeight ).X,
0.0001f,
"Last-frame diamonds must not be covered by the scrollbar gutter." );
var controller = new WeaponAnimatorController();
controller.SetDocument( document );
controller.SetTimelineRange( clip, new TimelineFrameRange( 30, 90 ) );
controller.SetTimelineVerticalScroll( clip, 132 );
var state = document.Workspace.GetTimelineView( clip.Id );
Check(
report,
state is not null,
"Changing a timeline view must create its per-clip workspace state." );
Near( report, 1, state!.VisibleStart, 0.0001f, "Timeline range start must persist in seconds." );
Near( report, 3, state.VisibleEnd, 0.0001f, "Timeline range end must persist in seconds." );
Near( report, 132, state.VerticalScroll, 0.0001f, "Vertical track scroll must persist per clip." );
clip.Tracks.Add( new TransformTrack { Target = "one" } );
clip.Tracks.Add( new TransformTrack { Target = "two" } );
document.Rig.VisibilityParts.Add( new WeaponVisibilityPart() );
Equal(
report,
4,
TimelineInteraction.TrackRowCount( document, clip ),
"Timeline row count must include every transform track, visibility track, and the tag row." );
}
private static void TestTimelineSelectionAndMovement( WeaponAnimatorSelfTestReport report )
{
var first = Guid.NewGuid();
var second = Guid.NewGuid();
var third = Guid.NewGuid();
var replaced = TimelineInteraction.CombineKeySelection(
[first],
[second, third],
additive: false,
toggle: false );
Check(
report,
replaced.SetEquals( [second, third] ),
"A plain marquee must replace the previous key selection." );
var added = TimelineInteraction.CombineKeySelection(
[first],
[second],
additive: true,
toggle: false );
Check(
report,
added.SetEquals( [first, second] ),
"Shift-marquee must add intersected keys." );
var toggled = TimelineInteraction.CombineKeySelection(
[first, second],
[second, third],
additive: false,
toggle: true );
Check(
report,
toggled.SetEquals( [first, third] ),
"Ctrl-marquee must toggle every intersected key." );
var scrolledMarquee = TimelineInteraction.ProjectMarquee(
startX: 220,
startContentY: 400,
currentX: 520,
currentContentY: 290,
verticalScroll: 40,
minimumX: 180,
maximumX: 500 );
Near(
report,
360,
scrolledMarquee.Bottom,
0.0001f,
"A marquee start must remain anchored to its original track while scrolling." );
Near(
report,
250,
scrolledMarquee.Top,
0.0001f,
"A scrolling marquee endpoint must follow the newly revealed content." );
Near(
report,
500,
scrolledMarquee.Right,
0.0001f,
"A marquee must remain clipped to the graph's right edge." );
Equal(
report,
-2,
TimelineInteraction.ClampGroupFrameDelta( [2, 5], -20, 30 ),
"Moving keys before frame zero must clamp the group as a unit." );
Equal(
report,
25,
TimelineInteraction.ClampGroupFrameDelta( [2, 5], 40, 30 ),
"Moving keys past the clip end must preserve their internal spacing." );
var document = WeaponAnimationDocument.CreateDefault( "Timeline key move" );
var clip = document.GetSelectedClip()!;
clip.Duration = 1;
clip.SampleRate = 30;
var track = clip.EnsureTrack( "weapon_root" );
var keyA = WeaponAnimationMath.UpsertKey( track, 2f / 30, Transform.Zero );
var keyB = WeaponAnimationMath.UpsertKey( track, 5f / 30, Transform.Zero );
WeaponAnimationMath.UpsertKey( track, 7f / 30, Transform.Zero );
var controller = new WeaponAnimatorController();
controller.SetDocument( document );
controller.SetSelectedKeys( [keyA.Id, keyB.Id] );
var starts = new Dictionary<Guid, float>
{
[keyA.Id] = keyA.Time,
[keyB.Id] = keyB.Time
};
controller.BeginSelectedKeyMove();
controller.UpdateSelectedKeyMove( starts, 2 );
controller.EndSelectedKeyMove( starts, 2 );
Equal(
report,
2,
track.Keys.Count,
"A moved key must replace an unselected key occupying its destination frame." );
Check(
report,
track.Keys.Select( x => TimelineInteraction.TimeToFrame( x.Time, 30 ) )
.SequenceEqual( [4, 7] ),
"Selected keys must move by the same snapped frame delta." );
controller.Undo();
clip = controller.Document.GetSelectedClip()!;
Equal(
report,
3,
clip.EnsureTrack( "weapon_root" ).Keys.Count,
"A complete key drag must undo as one action." );
var deleteDocument = WeaponAnimationDocument.CreateDefault( "Timeline key delete" );
var deleteClip = deleteDocument.GetSelectedClip()!;
var deleteTransformKey = WeaponAnimationMath.UpsertKey(
deleteClip.EnsureTrack( "weapon_root" ),
0,
Transform.Zero );
var visibilityPart = new WeaponVisibilityPart { Name = "Magazine" };
deleteDocument.Rig.VisibilityParts.Add( visibilityPart );
var deleteVisibilityKey = new VisibilityKey { Time = 0, Visible = false };
deleteClip.EnsureVisibilityTrack( visibilityPart.Id ).Keys.Add( deleteVisibilityKey );
var deleteController = new WeaponAnimatorController();
deleteController.SetDocument( deleteDocument );
deleteController.SetSelectedKeys( [deleteTransformKey.Id, deleteVisibilityKey.Id] );
deleteController.DeleteSelectedKeys();
deleteClip = deleteController.Document.GetSelectedClip()!;
Equal(
report,
0,
deleteClip.Tracks.SelectMany( x => x.Keys ).Count(),
"Deleting selected keys must remove transform keys." );
Equal(
report,
0,
deleteClip.VisibilityTracks.SelectMany( x => x.Keys ).Count(),
"Deleting selected keys must remove visibility keys." );
Equal(
report,
0,
deleteController.SelectedKeys.Count,
"Deleting keys must clear the stale key selection." );
deleteController.Undo();
deleteClip = deleteController.Document.GetSelectedClip()!;
Equal(
report,
2,
deleteClip.Tracks.SelectMany( x => x.Keys ).Count()
+ deleteClip.VisibilityTracks.SelectMany( x => x.Keys ).Count(),
"Deleting a mixed key selection must undo as one action." );
}
private static void TestTimelineKeyReversal( WeaponAnimatorSelfTestReport report )
{
var document = WeaponAnimationDocument.CreateDefault( "Timeline reverse" );
var clip = document.GetSelectedClip()!;
clip.Duration = 1;
clip.SampleRate = 30;
var track = clip.EnsureTrack( "weapon_root" );
track.Interpolation = TrackInterpolation.Linear;
var start = WeaponAnimationMath.UpsertKey(
track,
0,
new Transform( new Vector3( 0, 0, 0 ), Rotation.Identity, Vector3.One ) );
var end = WeaponAnimationMath.UpsertKey(
track,
1,
new Transform( new Vector3( 10, 0, 0 ), Rotation.Identity, Vector3.One ) );
start.CurveTangents.PositionOut = new Vector3( 4, 0, 0 );
end.CurveTangents.PositionIn = new Vector3( 12, 0, 0 );
var span = track.EnsureCurveSpan( start.Id, end.Id );
span.CustomChannels = TransformCurveChannel.PositionX;
span.HasSpeedCurve = true;
span.Speed = new MotionRateCurve
{
StartRate = 0.4f,
EndRate = 1.6f,
StartSlope = 0.5f,
EndSlope = -0.25f,
StartHandleMode = CurveHandleMode.Free,
EndHandleMode = CurveHandleMode.Aligned
};
var sampleTimes = new[] { 0.0f, 0.2f, 0.5f, 0.8f, 1.0f };
var sourceSamples = sampleTimes
.Select( x => WeaponAnimationMath.SampleTrack( track, x, Transform.Zero ).Position )
.ToArray();
var visibilityPart = new WeaponVisibilityPart { Name = "Magazine" };
document.Rig.VisibilityParts.Add( visibilityPart );
var visibility = clip.EnsureVisibilityTrack( visibilityPart.Id );
var hidden = new VisibilityKey { Time = 0, Visible = false };
var shown = new VisibilityKey { Time = 1, Visible = true };
visibility.Keys.AddRange( [hidden, shown] );
var controller = new WeaponAnimatorController();
controller.SetDocument( document );
controller.ReverseKeys();
clip = controller.Document.GetSelectedClip()!;
track = clip.EnsureTrack( "weapon_root" );
Equal(
report,
end.Id,
track.Keys[0].Id,
"With no key selection, Reverse must flip all transform keys across the clip." );
Equal(
report,
start.Id,
track.Keys[^1].Id,
"Whole-clip reversal must place the first key at the last frame." );
var reversedSpan = track.FindCurveSpan( end.Id, start.Id );
Check(
report,
reversedSpan is not null,
"Custom curve spans must reverse with their endpoint keys." );
if ( reversedSpan is not null )
{
Near(
report,
span.Speed.EndRate,
reversedSpan.Speed.StartRate,
0.0001f,
"Reversing a speed curve must swap its endpoint rates." );
Near(
report,
-span.Speed.EndSlope,
reversedSpan.Speed.StartSlope,
0.0001f,
"Reversing a speed curve must invert its former end slope." );
Equal(
report,
span.Speed.EndHandleMode,
reversedSpan.Speed.StartHandleMode,
"Reversing a speed curve must swap its handle modes." );
}
Near(
report,
-12,
track.Keys[0].CurveTangents.PositionOut.x,
0.0001f,
"Reversed channel curves must negate the former incoming tangent." );
Near(
report,
-4,
track.Keys[^1].CurveTangents.PositionIn.x,
0.0001f,
"Reversed channel curves must negate the former outgoing tangent." );
for ( var i = 0; i < sampleTimes.Length; i++ )
{
Near(
report,
sourceSamples[^(i + 1)],
WeaponAnimationMath.SampleTrack(
track,
sampleTimes[i],
Transform.Zero ).Position,
0.01f,
"Reversed custom curves must reproduce the original motion backward." );
}
visibility = clip.EnsureVisibilityTrack( visibilityPart.Id );
Equal(
report,
shown.Id,
visibility.Keys[0].Id,
"Whole-clip reversal must also flip visibility keys." );
controller.Undo();
clip = controller.Document.GetSelectedClip()!;
track = clip.EnsureTrack( "weapon_root" );
Equal(
report,
start.Id,
track.Keys[0].Id,
"Transform, curve, and visibility reversal must undo as one action." );
var selectionDocument = WeaponAnimationDocument.CreateDefault( "Selected reverse" );
var selectionClip = selectionDocument.GetSelectedClip()!;
selectionClip.Duration = 2;
selectionClip.SampleRate = 10;
var selectionTrack = selectionClip.EnsureTrack( "slide" );
var first = WeaponAnimationMath.UpsertKey(
selectionTrack,
0.2f,
new Transform( new Vector3( 2, 0, 0 ), Rotation.Identity, Vector3.One ) );
var middle = WeaponAnimationMath.UpsertKey(
selectionTrack,
0.6f,
new Transform( new Vector3( 6, 0, 0 ), Rotation.Identity, Vector3.One ) );
var last = WeaponAnimationMath.UpsertKey(
selectionTrack,
1.0f,
new Transform( new Vector3( 10, 0, 0 ), Rotation.Identity, Vector3.One ) );
var selectionController = new WeaponAnimatorController();
selectionController.SetDocument( selectionDocument );
selectionController.SetSelectedKeys( [first.Id, last.Id] );
selectionController.ReverseKeys();
selectionTrack = selectionController.Document.GetSelectedClip()!.EnsureTrack( "slide" );
Equal(
report,
last.Id,
selectionTrack.Keys[0].Id,
"Selected reversal must flip keys around the selected range, not the clip bounds." );
Equal(
report,
middle.Id,
selectionTrack.Keys[1].Id,
"Keys outside the reversed selection must retain their frame." );
Equal(
report,
first.Id,
selectionTrack.Keys[2].Id,
"Selected reversal must preserve key identities and selection." );
Check(
report,
selectionController.SelectedKeys.ToHashSet().SetEquals( [first.Id, last.Id] ),
"Reversed keys must remain selected for immediate follow-up editing." );
}
private static void TestTimelinePlayback( WeaponAnimatorSelfTestReport report )
{
var document = WeaponAnimationDocument.CreateDefault( "Timeline playback" );
var clip = document.GetSelectedClip()!;
clip.Duration = 1;
clip.SampleRate = 30;
clip.Loop = false;
var controller = new WeaponAnimatorController();
controller.SetDocument( document );
controller.SetTimelineTime( 0.01f );
Near( report, 0, document.Workspace.TimelineTime, 0.0001f, "Timeline seeking must reject fractional-frame positions." );
controller.SetTimelineTime( 0.02f );
Near( report, 1f / 30, document.Workspace.TimelineTime, 0.0001f, "Timeline seeking must snap to the nearest whole frame." );
controller.JumpToLastFrame();
controller.TogglePlayback();
Check( report, controller.IsPlaying, "Play must enter the shared playback state." );
Near( report, 0, document.Workspace.TimelineTime, 0.0001f, "Playing from the last frame must restart at frame zero." );
controller.AdvancePlayback( 0.04f );
Equal(
report,
1,
TimelineInteraction.TimeToFrame( document.Workspace.TimelineTime, 30 ),
"Playback must advance through whole-frame preview positions." );
var movingTrack = clip.EnsureTrack( "weapon_root" );
var movingKey = WeaponAnimationMath.UpsertKey(
movingTrack,
0.2f,
new Transform( new Vector3( 2, 0, 0 ), Rotation.Identity, Vector3.One ) );
controller.SetSelectedKeys( [movingKey.Id] );
controller.BeginSelectedKeyMove();
controller.UpdateSelectedKeyMove(
new Dictionary<Guid, float> { [movingKey.Id] = movingKey.Time },
1 );
Check(
report,
controller.IsPlaying,
"Selecting and dragging a key must not pause viewport playback." );
controller.EndSelectedKeyMove(
new Dictionary<Guid, float> { [movingKey.Id] = 0.2f },
1 );
Check(
report,
controller.IsPlaying,
"Committing a key drag must leave playback running for live SampleTrack checks." );
controller.StepTimelineFrame( 1 );
Check( report, !controller.IsPlaying, "Manual frame stepping must pause playback." );
Equal(
report,
2,
TimelineInteraction.TimeToFrame( document.Workspace.TimelineTime, 30 ),
"Next-frame controls must advance exactly one frame." );
controller.JumpToLastFrame();
controller.StepTimelineFrame( 1 );
Equal(
report,
30,
TimelineInteraction.TimeToFrame( document.Workspace.TimelineTime, 30 ),
"Frame stepping must clamp at the final frame." );
controller.ToggleSelectedClipLoop();
Check(
report,
clip.Loop,
"The selected clip loop state must be editable through the shared controller." );
controller.TogglePlayback();
controller.AdvancePlayback( 1.1f );
Check(
report,
controller.IsPlaying
&& TimelineInteraction.TimeToFrame(
document.Workspace.TimelineTime,
clip.SampleRate ) == 3,
"Looped playback must wrap and remain active." );
controller.Undo();
Check(
report,
!controller.Document.GetSelectedClip()!.Loop,
"Changing the loop state must be one undoable action." );
}
private static void TestTwoBoneIk( WeaponAnimatorSelfTestReport report )
{
var reachable = WeaponAnimationMath.SolveTwoBone(
Vector3.Zero,
Vector3.Forward,
Vector3.Forward * 2,
new Vector3( 1.5f, 0.4f, 0 ),
Vector3.Up );
Check( report, reachable.Reachable, "An in-range hand target must be reachable." );
Near( report, new Vector3( 1.5f, 0.4f, 0 ), reachable.End, 0.001f, "Reachable target must be solved exactly." );
var clamped = WeaponAnimationMath.SolveTwoBone(
Vector3.Zero,
Vector3.Forward,
Vector3.Forward * 2,
Vector3.Forward * 10,
Vector3.Up );
Check( report, !clamped.Reachable, "An overextended target must be reported." );
Check( report, clamped.SolvedDistance < 2, "Overextension must clamp below total arm length." );
}
private static void TestConstraintDrivenIk( WeaponAnimatorSelfTestReport report )
{
var document = WeaponAnimationDocument.CreateDefault();
document.Binding.Configuration = GripConfiguration.OneHanded;
document.Binding.PrimaryHand.IsBound = true;
document.Binding.PrimaryHand.Transform = new Transform( new Vector3( 1.5f, 0, 0 ) );
document.Binding.PrimaryElbowPole.Transform = new Transform( new Vector3( 0, 0, 1 ) );
var skeleton = new HostSkeleton();
skeleton.Add( Bone( "root", "", Vector3.Zero ) );
skeleton.Add( Bone( "arm_upper_R", "root", Vector3.Zero ) );
skeleton.Add( Bone( "arm_lower_R", "arm_upper_R", new Vector3( 1, 0, 0 ) ) );
skeleton.Add( Bone( "hand_R", "arm_lower_R", new Vector3( 2, 0, 0 ) ) );
skeleton.Add( Bone( "bolt", "root", new Vector3( 1.2f, 0.8f, 0 ) ) );
var clip = document.EnsureClip( WeaponClipRole.Idle );
clip.Constraints.Add( new TimedConstraint
{
SourceControl = "@primary_hand",
TargetBone = "bolt",
StartTime = 0,
EndTime = 1,
MaintainOffset = false
} );
var pose = AnimationPoseEvaluator.Evaluate( document, skeleton, clip, 0.5f );
Near( report, new Vector3( 1.2f, 0.8f, 0 ), pose.Model["hand_R"].Position, 0.002f, "Constraint must drive the IK target before the arm solve." );
}
private static void TestIkDescendantPropagation( WeaponAnimatorSelfTestReport report )
{
var document = WeaponAnimationDocument.CreateDefault();
document.Binding.Configuration = GripConfiguration.OneHanded;
document.Binding.PrimaryHand.IsBound = true;
document.Binding.PrimaryHand.Transform = new Transform( new Vector3( 1.2f, 1.2f, 0 ) );
document.Binding.PrimaryElbowPole.Transform = new Transform( new Vector3( 0, 0, 1 ) );
var skeleton = new HostSkeleton();
skeleton.Add( Bone( "root", "", Vector3.Zero ) );
skeleton.Add( Bone( "arm_upper_R", "root", Vector3.Zero ) );
skeleton.Add( Bone( "arm_lower_R", "arm_upper_R", new Vector3( 1, 0, 0 ) ) );
skeleton.Add( Bone( "hand_R", "arm_lower_R", new Vector3( 2, 0, 0 ) ) );
skeleton.Add( Bone( "finger_R", "hand_R", new Vector3( 2.5f, 0.2f, 0 ) ) );
skeleton.Add( Bone( "forearm_twist_R", "arm_lower_R", new Vector3( 1.5f, 0, 0 ) ) );
var pose = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );
var fingerLocal = skeleton.GetBindLocal( skeleton.ByName["finger_R"] );
var twistLocal = skeleton.GetBindLocal( skeleton.ByName["forearm_twist_R"] );
Near(
report,
pose.Model["hand_R"].PointToWorld( fingerLocal.Position ),
pose.Model["finger_R"].Position,
0.001f,
"Finger descendants must follow the solved hand." );
Near(
report,
pose.Model["arm_lower_R"].PointToWorld( twistLocal.Position ),
pose.Model["forearm_twist_R"].Position,
0.001f,
"Twist descendants must follow the solved forearm." );
}
private static void TestConstraintMaintainedOffset( WeaponAnimatorSelfTestReport report )
{
var document = WeaponAnimationDocument.CreateDefault();
document.Binding.Configuration = GripConfiguration.OneHanded;
document.Binding.PrimaryHand.IsBound = true;
document.Binding.PrimaryHand.Transform = new Transform( new Vector3( 1.5f, 0, 0 ) );
document.Binding.PrimaryElbowPole.Transform = new Transform( new Vector3( 0, 0, 1 ) );
var skeleton = new HostSkeleton();
skeleton.Add( Bone( "root", "", Vector3.Zero ) );
skeleton.Add( Bone( "arm_upper_R", "root", Vector3.Zero ) );
skeleton.Add( Bone( "arm_lower_R", "arm_upper_R", new Vector3( 1, 0, 0 ) ) );
skeleton.Add( Bone( "hand_R", "arm_lower_R", new Vector3( 2, 0, 0 ) ) );
skeleton.Add( Bone( "bolt", "root", new Vector3( 1, 0, 0 ) ) );
var clip = document.EnsureClip( WeaponClipRole.Idle );
var boltTrack = clip.EnsureTrack( "bolt" );
WeaponAnimationMath.UpsertKey( boltTrack, 0, new Transform( new Vector3( 1, 0, 0 ) ) );
WeaponAnimationMath.UpsertKey( boltTrack, 1, new Transform( new Vector3( 1.2f, 0, 0 ) ) );
clip.Constraints.Add( new TimedConstraint
{
SourceControl = "@primary_hand",
TargetBone = "bolt",
StartTime = 0,
EndTime = 1,
MaintainOffset = true
} );
var pose = AnimationPoseEvaluator.Evaluate( document, skeleton, clip, 1 );
Near( report, new Vector3( 1.7f, 0, 0 ), pose.Model["hand_R"].Position, 0.002f, "Maintain-offset constraints must preserve the start-frame hand offset." );
}
private static void TestHostSkeletonCache( WeaponAnimatorSelfTestReport report )
{
HostSkeletonBuilder.ClearCache();
var document = ValidDocument();
var first = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
var second = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
Check(
report,
ReferenceEquals( first, second ),
"An unchanged document must reuse the cached host skeleton." );
// Calibration nudges can be far below display precision, so the signature must compare
// exact float bits rather than a rounded or formatted value.
document.Calibration.PhysicalTransform =
document.Calibration.PhysicalTransform.WithPosition(
new Vector3( 0.0000001f, 0, 0 ) );
var afterTinyMove = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
Check(
report,
!ReferenceEquals( first, afterTinyMove ),
"A sub-precision calibration change must still invalidate the cached skeleton." );
document.Rig.Bones[0].BindModelTransform =
document.Rig.Bones[0].BindModelTransform.WithScale( 1.0000001f );
var afterBoneChange = HostSkeletonBuilder.BuildCached(
document,
includeArmProfile: false );
Check(
report,
!ReferenceEquals( afterTinyMove, afterBoneChange ),
"A bone bind change must invalidate the cached skeleton." );
document.Binding.PrimaryHand.Transform =
document.Binding.PrimaryHand.Transform.WithPosition( new Vector3( 3, 2, 1 ) );
var afterBindingChange = HostSkeletonBuilder.BuildCached(
document,
includeArmProfile: false );
Check(
report,
!ReferenceEquals( afterBoneChange, afterBindingChange ),
"A hand binding change must invalidate the cached skeleton." );
var reread = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
Check(
report,
ReferenceEquals( afterBindingChange, reread ),
"Rebuilding after a change must repopulate the cache rather than rebuild every call." );
HostSkeletonBuilder.ClearCache();
}
private static void TestControllerHistoryAndClipboard( WeaponAnimatorSelfTestReport report )
{
var controller = new WeaponAnimatorController();
controller.SetDocument( WeaponAnimationDocument.CreateDefault( "History" ) );
controller.Mutate( "Rename", document => document.Name = "Changed" );
Check( report, controller.IsDirty && controller.CanUndo, "A mutation must mark the document dirty and create undo history." );
controller.Undo();
Equal( report, "History", controller.Document.Name, "Undo must restore the previous snapshot." );
controller.Redo();
Equal( report, "Changed", controller.Document.Name, "Redo must restore the changed snapshot." );
var documentEvents = 0;
var poseEvents = 0;
var selectionEvents = 0;
var keySelectionEvents = 0;
controller.DocumentChanged += () => documentEvents++;
controller.PoseChanged += () => poseEvents++;
controller.SelectionChanged += () => selectionEvents++;
controller.KeySelectionChanged += () => keySelectionEvents++;
controller.BeginContinuousEdit( "Scrub name" );
controller.UpdateContinuousEdit( document => document.Name = "Scrub A" );
controller.UpdateContinuousEdit( document => document.Name = "Scrub B" );
Equal(
report,
0,
documentEvents,
"A live scrub must not broadcast full document rebuilds while dragging." );
Equal(
report,
2,
poseEvents,
"A live scrub must publish lightweight pose previews." );
controller.EndContinuousEdit();
Equal(
report,
1,
documentEvents,
"Completing a scrub must publish one consolidated document change." );
controller.Undo();
Equal( report, "Changed", controller.Document.Name, "A continuous drag must collapse into one undo step." );
controller.Redo();
Equal( report, "Scrub B", controller.Document.Name, "Redo must restore the final continuous-drag value." );
var clip = controller.Document.GetSelectedClip()!;
var track = clip.EnsureTrack( "weapon_root" );
var key = WeaponAnimationMath.UpsertKey( track, 0, new Transform( new Vector3( 1, 2, 3 ) ) );
var selectionBeforeKeys = selectionEvents;
controller.SelectKeys( [key.Id], false );
Equal(
report,
selectionBeforeKeys,
selectionEvents,
"Key selection must not broadcast a control-selection rebuild." );
Check(
report,
keySelectionEvents > 0,
"Key selection must publish its dedicated lightweight event." );
controller.CopySelectedKeys();
controller.SetTimelineTime( 0.5f );
controller.PasteKeys();
clip = controller.Document.GetSelectedClip()!;
Equal( report, 2, clip.EnsureTrack( "weapon_root" ).Keys.Count, "Pasting keys must duplicate the clipboard payload." );
Near(
report,
0.5f,
clip.EnsureTrack( "weapon_root" ).Keys.Max( x => x.Time ),
0.0001f,
"Pasted keys must be offset to the playhead." );
var keyController = new WeaponAnimatorController();
var keyDocument = ValidDocument();
keyController.SetDocument( keyDocument );
keyController.SelectBone( "weapon_root" );
keyController.SetTimelineTime( 0.5f );
keyController.KeySelectedTransform();
Check(
report,
keyController.Document.GetSelectedClip()!.Tracks
.Single( current => current.Target == "weapon_root" )
.Keys.Any( current => MathF.Abs( current.Time - 0.5f ) < 0.0001f ),
"The shared K/Add Key command must key a selected weapon bone." );
}
private static void TestValidation( WeaponAnimatorSelfTestReport report )
{
var document = ValidDocument();
Check( report, WeaponAnimationValidator.ValidateCalibration( document ).IsValid, "A complete calibration should pass." );
Check( report, WeaponAnimationValidator.ValidateForGeneration( document ).IsValid, "Idle-only generation should pass with action warnings." );
Check(
report,
WeaponAnimationValidator.ValidateForGeneration( document ).Issues.Any( x =>
x.Severity == ValidationSeverity.Warning && x.Code == "clip.fallback" ),
"Missing action clips must remain warnings." );
document.Source.SourcePath = "weapons/test/source.smd";
var smdValidation = WeaponAnimationValidator.ValidateForGeneration( document );
Check(
report,
smdValidation.Issues.Any( issue =>
issue.Blocking && issue.Code == "source.not_embeddable" ),
"SMD projects must explain the ModelDoc generation limitation before Generate runs." );
Check(
report,
smdValidation.Issues.Any( issue =>
issue.Code == "source.not_embeddable"
&& issue.Message.Contains( "SMD", StringComparison.Ordinal ) ),
"The generation-format diagnostic must name the unsupported source extension." );
document.Source.SourcePath = "weapons/test/source.vmdl";
Check(
report,
WeaponAnimationValidator.ValidateForGeneration( document ).Issues.All( issue =>
issue.Code != "source.not_embeddable" ),
"VMDL projects must pass source-format validation through the generated adapter path." );
document.Source.SourcePath = "weapons/test/source.fbx";
document.Calibration.Anchors.RemoveAll( anchor =>
anchor.Kind is AnchorKind.RearBore or AnchorKind.FrontBore );
Check(
report,
WeaponAnimationValidator.ValidateCalibration( document ).IsValid,
"Auto-align markers must not block an already-oriented weapon." );
document.Source.OriginalModelDimensions = Vector3.One;
document.Calibration.PhysicalTransform =
document.Calibration.PhysicalTransform.WithScale( 1 );
Check(
report,
WeaponAnimationValidator.ValidateCalibration( document ).Issues.Any( issue =>
issue.Code == "scale.implausible" ),
"Implausible-scale validation must use persisted source bounds without requiring a measurement." );
document.Rig.Bones.Add( new WeaponBoneDefinition { Name = "hand_R" } );
Check(
report,
!WeaponAnimationValidator.ValidateCalibration( document ).IsValid,
"Facepunch-reserved weapon bone names must block calibration." );
document.Rig.Bones.RemoveAt( document.Rig.Bones.Count - 1 );
document.Rig.Bones[0].Name = "root";
document.Rig.Bones[0].Classification = WeaponBoneClassification.WeaponRoot;
document.Rig.RootBone = "root";
Check(
report,
WeaponAnimationValidator.ValidateCalibration( document ).IsValid,
"A classified source root may use a reserved name before wrapper normalization." );
}
private static void TestGenerationOutputPaths( WeaponAnimatorSelfTestReport report )
{
var contentRoot = Path.Combine(
Path.GetTempPath(),
$"weaponanim-output-{Guid.NewGuid():N}",
"Assets" );
var document = WeaponAnimationDocument.CreateDefault( "Output Test" );
var defaultOutput = AssetGenerationService.ResolveOutputRootForContentRoot(
document,
contentRoot );
Equal(
report,
Path.GetFullPath( Path.Combine(
contentRoot,
"weapons",
"output_test",
"viewmodel" ) ),
defaultOutput,
"Default generation output must resolve beneath Assets even before the folder exists." );
document.Output.OutputFolder = "/weapons/custom/viewmodel";
Equal(
report,
Path.GetFullPath( Path.Combine(
contentRoot,
"weapons",
"custom",
"viewmodel" ) ),
AssetGenerationService.ResolveOutputRootForContentRoot( document, contentRoot ),
"A leading asset slash must remain a project-relative output path." );
document.Output.OutputFolder = "../outside";
var rejectedEscape = false;
try
{
AssetGenerationService.ResolveOutputRootForContentRoot( document, contentRoot );
}
catch ( InvalidOperationException )
{
rejectedEscape = true;
}
Check(
report,
rejectedEscape,
"Generation output must reject paths that escape the project's Assets folder." );
document.Output.OutputFolder = "C:/outside";
var rejectedDrive = false;
try
{
AssetGenerationService.ResolveOutputRootForContentRoot( document, contentRoot );
}
catch ( InvalidOperationException )
{
rejectedDrive = true;
}
Check(
report,
rejectedDrive,
"Generation output must reject absolute drive paths on every host platform." );
var nestedOutputRoot = Path.Combine(
Path.GetTempPath(),
$"weaponanim-nested-output-{Guid.NewGuid():N}" );
try
{
AssetGenerationService.WriteTextSourcesForTests(
nestedOutputRoot,
new Dictionary<string, string>
{
["materials/output_test_body.vmat"] = "fixture material",
["output_test_vm.vmdl"] = "fixture model"
} );
Check(
report,
File.Exists( Path.Combine(
nestedOutputRoot,
"materials",
"output_test_body.vmat" ) ),
"Generation must create parent directories for nested material sources." );
}
finally
{
if ( Directory.Exists( nestedOutputRoot ) )
Directory.Delete( nestedOutputRoot, true );
}
document.Output = null!;
Equal(
report,
Path.GetFullPath( Path.Combine(
contentRoot,
"weapons",
"output_test",
"viewmodel" ) ),
AssetGenerationService.ResolveOutputRootForContentRoot( document, contentRoot ),
"Generation must repair missing output settings instead of throwing." );
}
private static void TestGeneratedFileRemoval( WeaponAnimatorSelfTestReport report )
{
var root = Path.Combine(
Path.GetTempPath(),
$"weaponanim-removal-{Guid.NewGuid():N}" );
Directory.CreateDirectory( root );
try
{
var host = Path.Combine( root, "weapon_host.vmdl" );
var clip = Path.Combine( root, "weapon_idle.dmx" );
var graph = Path.Combine( root, "weapon.vanmgrph" );
var prefab = Path.Combine( root, "v_weapon.prefab" );
foreach ( var file in new[] { host, clip, graph, prefab } )
{
File.WriteAllText( file, "generated" );
File.WriteAllText( $"{file}_c", "compiled" );
}
AssetGenerationService.DeleteGeneratedFiles( [clip, host, graph, prefab] );
Check(
report,
!File.Exists( host ) && !File.Exists( $"{host}_c" ),
"Removing a generated asset must take its compiled artifact with it." );
Check(
report,
!File.Exists( clip ) && !File.Exists( graph ) && !File.Exists( prefab ),
"Every listed generated file must be removed." );
// The dependant .vmdl has to be gone before its .dmx sources, or the asset system
// keeps recompiling a model whose animation dependencies stopped existing.
File.WriteAllText( host, "generated" );
File.WriteAllText( clip, "generated" );
var ordered = AssetGenerationService.OrderForRemoval( [clip, host] ).ToList();
Equal(
report,
host,
ordered[0],
"Compiled dependants must be removed before the sources they consume." );
var lifecycleFiles = new[]
{
"weapon_sequence_idle.dmx",
"weapon_source_adapter.vmdl",
"weapon_vm_bootstrap.vmdl",
"weapon.vanmgrph",
"weapon_vm.vmdl",
"v_weapon.prefab"
};
var writeOrder = AssetGenerationService.OrderForWrite( lifecycleFiles ).ToList();
Equal(
report,
string.Join( "|", lifecycleFiles ),
string.Join( "|", writeOrder ),
"Generated sources must appear in dependency order so automatic compilation never observes a missing preview host." );
var removeOrder = AssetGenerationService.OrderForRemoval( lifecycleFiles ).ToList();
Equal(
report,
"v_weapon.prefab|weapon_vm.vmdl|weapon.vanmgrph|weapon_vm_bootstrap.vmdl|weapon_source_adapter.vmdl|weapon_sequence_idle.dmx",
string.Join( "|", removeOrder ),
"Generated consumers must be removed in reverse dependency order." );
Check(
report,
!AssetGenerationService.ShouldDeleteCreatedFileOnRollback(
"weapon_sprint.dmx",
previouslyOwned: true ),
"Rollback must retain a recreated owned DMX dependency needed by an older host." );
Check(
report,
AssetGenerationService.ShouldDeleteCreatedFileOnRollback(
"weapon_vm.vmdl",
previouslyOwned: true )
&& AssetGenerationService.ShouldDeleteCreatedFileOnRollback(
"new_clip.dmx",
previouslyOwned: false ),
"Rollback must remove failed compiled consumers and newly introduced dependencies." );
var freshnessSource = Path.Combine( root, "freshness.vmdl" );
var freshnessCompiled = freshnessSource + "_c";
File.WriteAllText( freshnessSource, "source" );
File.WriteAllText( freshnessCompiled, "compiled" );
var now = DateTime.UtcNow;
File.SetLastWriteTimeUtc( freshnessSource, now );
File.SetLastWriteTimeUtc( freshnessCompiled, now.AddSeconds( 1 ) );
Check(
report,
AssetGenerationService.IsFreshCompiledArtifact(
freshnessSource,
freshnessCompiled ),
"A newly written compiled artifact must complete generation even while its managed Asset wrapper is stale." );
File.SetLastWriteTimeUtc( freshnessCompiled, now.AddSeconds( -10 ) );
Check(
report,
!AssetGenerationService.IsFreshCompiledArtifact(
freshnessSource,
freshnessCompiled ),
"An artifact older than its regenerated source must never be accepted as compile success." );
}
finally
{
Directory.Delete( root, true );
}
}
private static void TestMaterialPipeline( WeaponAnimatorSelfTestReport report )
{
var embeddedMaterials = WeaponMaterialPipeline.MatchEmbeddedMaterialNamesForTests(
["HK_P30L", "cartridge"],
["Material", "H&K_P30L", "cartridge", "cartridge_BaseColor"] );
Check(
report,
embeddedMaterials.Contains( "H&K_P30L" )
&& embeddedMaterials.Contains( "cartridge" )
&& embeddedMaterials.Count == 2,
"Embedded FBX labels must preserve special characters when matching texture-set names." );
var discovered = WeaponMaterialPipeline.DiscoverForTests(
[
"H&K_P30L.vmat",
"cartridge.vmat",
"materials/error.vmat"
],
[
"/fixture/Textures/HK_P30L_BaseColor.png",
"/fixture/Textures/HK_P30L_Normal_GL.png",
"/fixture/Textures/HK_P30L_Normal_DX.png",
"/fixture/Textures/HK_P30L_Roughness.png",
"/fixture/Textures/HK_P30L_Metallic.png",
"/fixture/Textures/cartridge_BaseColor.png",
"/fixture/Textures/cartridge_Normal_DX.png"
] );
Equal(
report,
2,
discovered.Count,
"Nearby texture discovery must retain every FBX material slot." );
var pistol = discovered.Single( material => material.Name == "H&K_P30L" );
Check(
report,
pistol.FindTexture( WeaponTextureChannel.Normal )?.AssetPath
.EndsWith( "Normal_GL.png", StringComparison.OrdinalIgnoreCase ) == true,
"S&box-compatible OpenGL normal maps must win when both GL and DX variants are available." );
Check(
report,
pistol.FindTexture( WeaponTextureChannel.Metalness ) is not null
&& discovered.Single( material => material.Name == "cartridge" )
.FindTexture( WeaponTextureChannel.BaseColor ) is not null,
"Texture sets must be matched independently to their source material names." );
Check(
report,
discovered.All( material => !material.SourceMaterialPath.Equals(
"materials/error",
StringComparison.OrdinalIgnoreCase ) ),
"The compiler error material must never become a generated weapon material slot." );
Check(
report,
discovered.All( material => !Path.HasExtension( material.SourceMaterialPath ) ),
"Stored source material labels must not look like GameResource dependencies." );
Equal(
report,
"weaponanim_preview_cache/0123456789abcdef",
WeaponMaterialPipeline.LegalPreviewRelativeRootForTests(
"/fixture/Assets/.weaponanim-cache/0123456789abcdef" ),
"Preview materials must use a legal non-hidden asset namespace." );
var originalRevision = WeaponMaterialPipeline.PreviewRevision( discovered );
pistol.Textures[0].Sha256 = "changed-image-hash";
var changedRevision = WeaponMaterialPipeline.PreviewRevision( discovered );
Check(
report,
!originalRevision.Equals( changedRevision, StringComparison.Ordinal ),
"A changed texture input must create a new immutable preview revision." );
pistol.Textures[0].Sha256 = "";
var document = ValidDocument();
document.Source.Materials = discovered.ToList();
var generated = WeaponMaterialPipeline.BuildOutputTextFiles(
document,
"weapons/test_weapon/viewmodel" );
var material = generated["materials/test_weapon_h_k_p30l.vmat"];
Check(
report,
material.Contains( "F_SPECULAR 1", StringComparison.Ordinal )
&& material.Contains( "F_METALNESS_TEXTURE 1", StringComparison.Ordinal )
&& material.Contains( "TextureMetalness", StringComparison.Ordinal )
&& material.Contains(
"test_weapon_h_k_p30l_metalness.png",
StringComparison.Ordinal ),
"Generated weapon VMATs must enable specular and mapped metalness." );
Check(
report,
generated.Keys.Count( path => path.EndsWith(
".vtex",
StringComparison.OrdinalIgnoreCase ) ) == 0
&& material.Contains(
"test_weapon_h_k_p30l_color.png",
StringComparison.Ordinal )
&& material.Contains(
"test_weapon_h_k_p30l_normal.png",
StringComparison.Ordinal ),
"VMATs must reference image inputs directly so S&box can build native generated VTEX resources." );
document.Source.NeedsModelDocWrapper = true;
pistol.PreviewMaterialPath =
".weaponanim-cache/fixture/materials/h_k_p30l.vmat";
Check(
report,
WeaponMaterialPipeline.RequiresPreviewRefresh( document ),
"Legacy hidden preview material paths must force a safe material refresh." );
foreach ( var binding in discovered.Where( binding => binding.HasUsableTextures ) )
{
binding.PreviewMaterialPath =
$"weaponanim_preview_cache/fixture/revision/materials/{binding.OutputName}.vmat";
}
Check(
report,
!WeaponMaterialPipeline.RequiresPreviewRefresh( document ),
"Legal compiled preview material paths must not refresh repeatedly." );
var serializedDocument = Json.Serialize( document );
Check(
report,
!serializedDocument.Contains(
"PreviewMaterialPath",
StringComparison.Ordinal )
&& !serializedDocument.Contains(
"H&K_P30L.vmat",
StringComparison.OrdinalIgnoreCase ),
"Transient preview VMATs and source slot extensions must stay out of .wepanim serialization." );
var legacyMaterialDocument = WeaponAnimationDocument.CreateDefault();
legacyMaterialDocument.Source.Materials =
[
new SourceMaterialBinding
{
SourceMaterialPath = "cartridge.vmat",
Name = "cartridge",
OutputName = "cartridge"
}
];
var materialMigration = WeaponAnimationMigration.MigrateAndRepair(
legacyMaterialDocument );
Check(
report,
materialMigration.RepairedMaterialMetadata
&& legacyMaterialDocument.Source.Materials[0].SourceMaterialPath
.Equals( "cartridge", StringComparison.Ordinal ),
"Opening an existing project must remove false VMAT dependencies from source slot metadata." );
var recoveredCandidate = WeaponSourceImporter.SelectRecoveryCandidateForTests(
[
new(
"/preview/newer-uncompiled/models/source_abc_textured.vmdl",
new DateTime( 2026, 7, 28, 20, 0, 0, DateTimeKind.Utc ),
false,
true ),
new(
"/preview/legacy/models/source_abc_textured.vmdl",
new DateTime( 2026, 7, 28, 19, 0, 0, DateTimeKind.Utc ),
true,
false ),
new(
"/preview/versioned/models/source_abc_textured.vmdl",
new DateTime( 2026, 7, 28, 18, 0, 0, DateTimeKind.Utc ),
true,
true )
] );
Equal(
report,
"/preview/versioned/models/source_abc_textured.vmdl",
recoveredCandidate,
"Missing saved source wrappers must recover to a compiled immutable preview revision." );
Check(
report,
!WeaponAnimatorViewport.ShouldRetryMissingSourcePreview(
"weaponanim_preview_cache/document/source.vmdl",
"weaponanim_preview_cache/document/source.vmdl" )
&& WeaponAnimatorViewport.ShouldRetryMissingSourcePreview(
"weaponanim_preview_cache/document/repaired.vmdl",
"weaponanim_preview_cache/document/source.vmdl" ),
"A failed source load must not rebuild the private scene every frame, "
+ "but a repaired path must trigger one rebuild." );
var remaps = WeaponMaterialPipeline.OutputRemaps(
document,
"weapons/test_weapon/viewmodel" );
Equal(
report,
2,
remaps.Count,
"Final generation must preserve separate material-slot remaps." );
var host = ModelDocWriter.WriteHost(
"host_reference.dmx",
[],
"",
["weapon_root"],
new HostWeaponMesh(
"source.fbx",
"weapon_root",
Transform.Zero,
[],
remaps ) );
Check(
report,
host.Contains( "use_global_default = false", StringComparison.Ordinal )
&& !host.Contains( "use_global_default = true", StringComparison.Ordinal )
&& host.Contains( "from = \"H&K_P30L.vmat\"", StringComparison.Ordinal )
&& host.Contains( "from = \"cartridge.vmat\"", StringComparison.Ordinal ),
"Weapon ModelDocs must use per-slot remaps with global material override disabled." );
}
private static void TestRebase( WeaponAnimatorSelfTestReport report )
{
var document = WeaponAnimationDocument.CreateDefault();
document.Rig.RootBone = "weapon_root";
var idle = document.EnsureClip( WeaponClipRole.Idle );
var rootTrack = idle.EnsureTrack( "weapon_root" );
WeaponAnimationMath.UpsertKey( rootTrack, 0, new Transform( new Vector3( 2, 0, 0 ) ) );
var previous = new CalibrationSnapshot
{
PhysicalTransform = Transform.Zero,
FramingTransform = Transform.Zero
};
document.Calibration.PhysicalTransform = new Transform( new Vector3( 10, 0, 0 ) );
CalibrationRebaser.RebaseAnimationData( document, previous );
Near( report, 12, rootTrack.Keys[0].Position.x, 0.001f, "Root keys must retain their placement-relative offset." );
}
private static void TestDmxOutput( WeaponAnimatorSelfTestReport report )
{
var skeleton = new HostSkeleton();
skeleton.Add( Bone( "root", "", Vector3.Zero ) );
skeleton.Add( Bone( "weapon_root", "root", Vector3.Forward ) );
var first = DmxWriter.WriteReference( skeleton );
var second = DmxWriter.WriteReference( skeleton );
Check( report, first.StartsWith( "<!-- dmx encoding keyvalues2 4 format model 22 -->" ), "Host reference must use ModelDoc's supported DMX model format." );
Check( report, first.Contains( "\"name\" \"string\" \"weapon_root\"" ), "Host reference must include every skeleton bone." );
Check(
report,
first.Contains( "\"element\" \"" + DmxJointIdForTest( 0 ) + "\"," ),
"DMX element array entries must be comma-delimited." );
var blendIndices = first[first.IndexOf( "\"blendindices$0\" \"int_array\"", StringComparison.Ordinal )..];
Check(
report,
blendIndices.Contains( "\t\t\"1\",\n\t\t\"1\",\n\t\t\"1\"\n", StringComparison.Ordinal ),
"The carrier mesh must reference every host bone so ModelDoc cannot cull the skeleton." );
Check(
report,
first.Contains( "\t\t\t\t\"3\",\n\t\t\t\t\"4\",\n\t\t\t\t\"5\",\n\t\t\t\t\"-1\"\n", StringComparison.Ordinal ),
"The carrier mesh must emit one triangle per host bone." );
Check(
report,
first.Contains( "materials/tools/toolsinvisible.vmat", StringComparison.Ordinal ),
"The bone-retention carrier must use an invisible material." );
Check(
report,
first.Contains( "\"forwardParity\" \"int\" \"1\"", StringComparison.Ordinal )
&& !first.Contains( "\"forwardParity\" \"int\" \"-2\"", StringComparison.Ordinal ),
"Reference DMX must use the Source 2 Z-up axis parity expected by ModelDoc." );
Equal( report, first, second, "DMX host references must be deterministic." );
var document = WeaponAnimationDocument.CreateDefault();
document.Binding.Configuration = GripConfiguration.OneHanded;
var clip = document.EnsureClip( WeaponClipRole.Idle );
clip.Duration = 1;
clip.SampleRate = 30;
var animation = DmxWriter.WriteAnimation( document, skeleton, clip );
Check(
report,
animation.Contains( "\"DmeChannelsClip\"", StringComparison.Ordinal )
&& animation.Contains( "\"DmeVector3LogLayer\"", StringComparison.Ordinal )
&& animation.Contains( "\"DmeQuaternionLogLayer\"", StringComparison.Ordinal )
&& animation.Contains( "\"DmeFloatLogLayer\"", StringComparison.Ordinal ),
"DMX animation output must contain position, rotation, and scale channels." );
Check(
report,
animation.Contains( "\"DmeJoint\"", StringComparison.Ordinal )
&& !animation.Contains( "\"DmeDag\"", StringComparison.Ordinal ),
"Animation skeleton entries must be Source 2 joints rather than generic DAG nodes." );
Check(
report,
animation.Contains( "\"DmeTransformList\"", StringComparison.Ordinal )
&& animation.Contains( "\"baseStates\" \"element_array\"", StringComparison.Ordinal ),
"Animation DMX must include a bind transform list for ModelDoc sequence import." );
Check(
report,
animation.Contains( "\"mode\" \"int\" \"1\"", StringComparison.Ordinal ),
"Animation channels must use the Source 2 exporter channel mode." );
Check(
report,
animation.Contains( "\"jointList\" \"element_array\"", StringComparison.Ordinal )
&& animation.Contains(
"\"element\" \"" + DmxAnimationJointIdForTest( clip, 0 ) + "\"",
StringComparison.Ordinal ),
"Animation DMX must register every animated joint with its model." );
Check(
report,
animation.Contains( "\t\t\"1\"\n", StringComparison.Ordinal ),
"A one-second animation must include its final sample time." );
Check(
report,
!animation.Contains( "NaN", StringComparison.OrdinalIgnoreCase )
&& !animation.Contains( "Infinity", StringComparison.OrdinalIgnoreCase ),
"DMX animation output must contain finite transforms." );
Check(
report,
animation.Contains( "\"forwardParity\" \"int\" \"1\"", StringComparison.Ordinal )
&& !animation.Contains( "\"forwardParity\" \"int\" \"-2\"", StringComparison.Ordinal ),
"Animation DMX must use the same Source 2 axis system as its host reference." );
Equal(
report,
animation,
DmxWriter.WriteAnimation( document, skeleton, clip ),
"DMX animation output must be deterministic." );
var scaledSkeleton = new HostSkeleton();
scaledSkeleton.Add( new HostBone
{
Name = "root",
BindModelTransform = Transform.Zero,
BindLocalTransform = Transform.Zero,
HasExplicitBindLocal = true
} );
scaledSkeleton.Add( new HostBone
{
Name = "weapon_root",
ParentName = "root",
BindModelTransform = new Transform(
Vector3.Zero,
Rotation.Identity,
Vector3.One * 0.56f ),
BindLocalTransform = new Transform(
Vector3.Zero,
Rotation.Identity,
Vector3.One * 0.56f ),
HasExplicitBindLocal = true,
IsWeaponBone = true
} );
scaledSkeleton.Add( new HostBone
{
Name = "hammer",
ParentName = "weapon_root",
BindModelTransform = new Transform(
new Vector3( 0, -5.75f, 0.2f ) * 0.56f ),
BindLocalTransform = new Transform( new Vector3( 0, -5.75f, 0.2f ) ),
HasExplicitBindLocal = true,
IsWeaponBone = true
} );
var hammerTrack = clip.EnsureTrack( "hammer" );
var hammerRotation = Rotation.FromPitch( 45 );
WeaponAnimationMath.UpsertKey(
hammerTrack,
0,
new Transform( new Vector3( 0, -5.75f, 0.2f ), hammerRotation ) );
var scaledPose = AnimationPoseEvaluator.Evaluate(
document,
scaledSkeleton,
clip,
0 );
var exportedPose = DmxWriter.BuildCompilerPoseLocals(
scaledSkeleton,
scaledPose.Local );
var exportedRoot = exportedPose["weapon_root"];
var exportedHammer = exportedPose["hammer"];
Near(
report,
Vector3.One,
exportedRoot.Scale,
0.0001f,
"Animation export must use ModelDoc's scale-one compiled bind space." );
Near(
report,
new Vector3( 0, -5.75f, 0.2f ) * 0.56f,
exportedHammer.Position,
0.0001f,
"Rotating a weapon child must use the physical scale-baked mesh pivot in compiled bind space." );
Near(
report,
hammerRotation.Forward,
exportedHammer.Rotation.Forward,
0.0001f,
"Rotating a weapon child must retain its authored local rotation in compiled bind space." );
var scaledAnimation = DmxWriter.WriteAnimation(
document,
scaledSkeleton,
clip );
var scaledReference = DmxWriter.WriteReference( scaledSkeleton );
Check(
report,
!scaledAnimation.Contains(
"\"scale\" \"float\" \"0.56\"",
StringComparison.Ordinal ),
"Animation bind declarations must not reintroduce source scale after ModelDoc bakes it into the host." );
Check(
report,
scaledReference.Contains(
"\"position\" \"vector3\" \"0 -3.22 0.112\"",
StringComparison.Ordinal )
&& scaledAnimation.Contains(
"\"position\" \"vector3\" \"0 -3.22 0.112\"",
StringComparison.Ordinal ),
"Reference and animation skeletons must share the scale-baked physical pivot of rotating weapon children." );
document.Manifest.Files.Add( new GeneratedFileRecord
{
RelativePath = "generated_sequence.dmx"
} );
Check(
report,
!Json.Serialize( document ).Contains( "\"Manifest\"", StringComparison.Ordinal ),
"The creative document must not serialize generated filenames as resource dependencies." );
var wrapper = ModelDocWriter.WriteSourceWrapper( "weapon.fbx", "root" );
Check(
report,
wrapper.Contains( "original_bone_name = \"root\"" )
&& wrapper.Contains( "new_bone_name = \"weapon_root\"" ),
"Source wrappers must normalize the selected weapon root." );
var host = ModelDocWriter.WriteHost(
"host_reference.dmx",
[],
"weapon.vanmgrph",
skeleton.Bones.Select( bone => bone.Name ),
new HostWeaponMesh(
"weapon.fbx",
"root",
new Transform( Vector3.Zero, Rotation.Identity, Vector3.One * 0.6f ),
[],
[
new HostMaterialRemap(
"frame.vmat",
"weapons/test/materials/frame.vmat" )
] ),
[
new HostAttachment(
"muzzle",
"weapon_root",
Vector3.Forward * 10,
Rotation.Identity )
] );
Check(
report,
host.Contains( "target_bone = \"weapon_root\"", StringComparison.Ordinal )
&& host.Contains( "do_not_discard = true", StringComparison.Ordinal )
&& host.Contains( "filename = \"weapon.fbx\"", StringComparison.Ordinal )
&& host.Contains( "import_scale = 0.6", StringComparison.Ordinal )
&& host.Contains( "anim_graph_name = \"weapon.vanmgrph\"", StringComparison.Ordinal )
&& host.Contains( "use_global_default = false", StringComparison.Ordinal )
&& !host.Contains( "use_global_default = true", StringComparison.Ordinal )
&& host.Contains( "from = \"frame.vmat\"", StringComparison.Ordinal )
&& host.Contains( "from = \"materials/tools/toolsinvisible.vmat\"", StringComparison.Ordinal )
&& host.Contains( "_class = \"Attachment\"", StringComparison.Ordinal )
&& host.Contains( "name = \"muzzle\"", StringComparison.Ordinal ),
"Host ModelDocs must preserve generated bones, safely handle imported materials, and contain the visible weapon, graph, and attachments." );
var skeletonOnlyHost = ModelDocWriter.WriteHost(
"host_reference.dmx",
[],
"",
skeleton.Bones.Select( bone => bone.Name ) );
Check(
report,
skeletonOnlyHost.Contains( "use_global_default = false", StringComparison.Ordinal ),
"A skeleton-only host must retain the invisible carrier material without substitution." );
}
private static void TestFilteredSourceWrapper( WeaponAnimatorSelfTestReport report )
{
var wrapper = ModelDocWriter.WriteSourceWrapper(
"weapons/test/source.fbx",
"Armature",
["foreign_arm", "foreign_camera"] );
Check( report, wrapper.Contains( "_class = \"RenameBone\"" ), "A tool-owned source wrapper must normalize the root without modifying the original source." );
Check( report, wrapper.Contains( "_class = \"RemoveBoneAndChildren\"" ), "A filtered source wrapper must remove excluded branch roots." );
Check( report, wrapper.Contains( "\"foreign_arm\"" ) && wrapper.Contains( "\"foreign_camera\"" ), "Every excluded branch root must be emitted deterministically." );
var vmdl = $"{ModelDocWriter.Header}\n{{ rootNode = {{ _class = \"RootNode\" children = [ ] }} }}";
var adapted = ModelDocWriter.WriteVmdlSourceAdapter( vmdl, "root", ["foreign_arm"] );
Check(
report,
adapted.Contains( "_class = \"ModelModifierList\"" )
&& adapted.Contains( "original_bone_name = \"root\"" )
&& adapted.Contains( "\"foreign_arm\"" ),
"VMDL inputs must receive the same tool-owned root normalization and branch filtering." );
}
private static void TestGenerationSourceAdapters( WeaponAnimatorSelfTestReport report )
{
var source = $$"""
{{ModelDocWriter.Header}}
{
rootNode =
{
_class = "RootNode"
children =
[
{
_class = "RenderMeshFile"
filename = "receiver.fbx"
import_translation = [ 2, 0, 0 ]
import_rotation = [ 0, 0, 0 ]
import_scale = 1
},
{
_class = "RenderMeshFile"
filename = "magazine.fbx"
import_translation = [ 0, 2, 0 ]
import_rotation = [ 0, 0, 0 ]
import_scale = 1
},
]
}
}
""";
var adapted = ModelDocWriter.WriteVmdlSourceAdapter(
source,
"root",
["foreign_arm"],
new Transform( new Vector3( 10, 0, 0 ), Rotation.Identity, 0.5f ) );
Check(
report,
Count( adapted, "import_scale = 0.5" ) == 2
&& adapted.Contains( "import_translation = [ 11, 0, 0 ]", StringComparison.Ordinal )
&& adapted.Contains( "import_translation = [ 10, 1, 0 ]", StringComparison.Ordinal )
&& adapted.Contains( "original_bone_name = \"root\"", StringComparison.Ordinal )
&& adapted.Contains( "\"foreign_arm\"", StringComparison.Ordinal ),
"A VMDL adapter must apply calibration to every render mesh while preserving filtering." );
var baseHost = ModelDocWriter.WriteHost(
"reference.dmx",
[],
"",
["weapon_root"],
baseModelPath: "weapons/test/source_adapter.vmdl" );
Check(
report,
baseHost.Contains(
"base_model_name = \"weapons/test/source_adapter.vmdl\"",
StringComparison.Ordinal ),
"Generated hosts must be able to derive their visible mesh from a VMDL adapter." );
var temporary = Path.Combine(
Path.GetTempPath(),
$"weaponanim-source-{Guid.NewGuid():N}.vmdl" );
File.WriteAllText( temporary, source );
try
{
var document = ValidDocument();
document.Source.SourcePath = temporary;
document.Source.CompiledModelPath = temporary;
document.Calibration.PhysicalTransform =
new Transform( Vector3.Zero, Rotation.Identity, 0.6f );
var progress = new List<GenerationProgress>();
var generated = AssetGenerationService.BuildFiles(
document,
HostSkeletonBuilder.Build( document, includeArmProfile: false ),
"weapons/test_weapon/viewmodel",
progress.Add );
Check(
report,
generated.ContainsKey( "test_weapon_source_adapter.vmdl" )
&& generated["test_weapon_vm.vmdl"].Contains(
"base_model_name = \"weapons/test_weapon/viewmodel/test_weapon_source_adapter.vmdl\"",
StringComparison.Ordinal ),
"VMDL source projects must generate a persistent calibrated adapter." );
Check(
report,
progress.Any( item =>
item.Stage == "Sequences"
&& item.Completed == 1
&& item.Total == 1 ),
"Generation must report deterministic per-sequence progress." );
using var cancellation = new System.Threading.CancellationTokenSource();
cancellation.Cancel();
var cancelled = false;
try
{
AssetGenerationService.BuildFiles(
document,
HostSkeletonBuilder.Build( document, includeArmProfile: false ),
"weapons/test_weapon/viewmodel",
cancellationToken: cancellation.Token );
}
catch ( OperationCanceledException )
{
cancelled = true;
}
Check(
report,
cancelled,
"Generation must honor cancellation before assembling or replacing output files." );
cancelled = false;
try
{
DmxWriter.WriteAnimation(
document,
HostSkeletonBuilder.Build( document, includeArmProfile: false ),
document.EnsureClip( WeaponClipRole.Idle ),
cancellation.Token );
}
catch ( OperationCanceledException )
{
cancelled = true;
}
Check(
report,
cancelled,
"DMX frame sampling must observe cancellation inside the worker-safe generation path." );
}
finally
{
File.Delete( temporary );
}
}
private static string DmxJointIdForTest( int index )
{
var bytes = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes( $"SboxWeaponAnimator.DmxReference:joint:{index}" ) );
return new Guid( bytes.AsSpan( 0, 16 ) ).ToString();
}
private static string DmxAnimationJointIdForTest(
WeaponAnimationClip clip,
int index )
{
var key =
$"SboxWeaponAnimator.DmxReference:animation:{clip.Id}:joint:{index}";
var bytes = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes( key ) );
return new Guid( bytes.AsSpan( 0, 16 ) ).ToString();
}
private static void TestDeterministicOutput( WeaponAnimatorSelfTestReport report )
{
var document = ValidDocument();
var idle = document.EnsureClip( WeaponClipRole.Idle );
var originalCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo( "fr-FR" );
var graphFrench = AnimGraphWriter.Write( document, "weapons/test/host.vmdl" );
var modelFrench = ModelDocWriter.WriteHost(
"host_reference.dmx",
[(idle, "idle.dmx")],
"weapon.vanmgrph",
["root", "weapon_root"] );
var prefabFrench = PrefabWriter.Write( document, "host.vmdl" );
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo( "en-US" );
Equal( report, graphFrench, AnimGraphWriter.Write( document, "weapons/test/host.vmdl" ), "AnimGraph output must be culture-independent." );
Equal(
report,
modelFrench,
ModelDocWriter.WriteHost(
"host_reference.dmx",
[(idle, "idle.dmx")],
"weapon.vanmgrph",
["root", "weapon_root"] ),
"ModelDoc output must be culture-independent." );
Equal( report, prefabFrench, PrefabWriter.Write( document, "host.vmdl" ), "Prefab output must be culture-independent." );
Equal( report, AnimGraphWriter.Id( "node:Root" ), AnimGraphWriter.Id( "node:Root" ), "Deterministic graph IDs must be stable." );
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
}
}
private static void TestAnimGraphTagsAndFallbacks( WeaponAnimatorSelfTestReport report )
{
var document = ValidDocument();
var idle = document.EnsureClip( WeaponClipRole.Idle );
idle.Tags.Add( new AnimationTag
{
Name = "attack_discouraged",
Kind = AnimationTagKind.Range,
StartTime = 0.2f,
EndTime = 0.6f
} );
var graph = AnimGraphWriter.Write( document, "host.vmdl" );
Check( report, graph.Contains( "_class = \"CAnimTagSpan\"" ), "Authored tags must become sequence tag spans." );
Check( report, graph.Contains( "m_fStartCycle = 0.2" ), "Tag start time must be normalized to sequence cycle." );
Check(
report,
Count( graph, "m_sequenceName = \"idle\"" ) > 1,
"Missing action clips must use Idle sequence fallbacks." );
Check( report, graph.Contains( "m_name = \"b_attack\"" ), "Facepunch firearm parameters must be exposed." );
Check( report, graph.Contains( "m_name = \"reload_increment\"" ), "Standard reload tags must be declared." );
var skeleton = HostSkeletonBuilder.Build( document, includeArmProfile: false );
var generated = AssetGenerationService.BuildFiles(
document,
skeleton,
"weapons/test_weapon/viewmodel" );
var finalHost = generated["test_weapon_vm.vmdl"];
var bootstrapHost = generated["test_weapon_vm_bootstrap.vmdl"];
var generatedGraph = generated["test_weapon.vanmgrph"];
Check(
report,
finalHost.Contains(
"anim_graph_name = \"weapons/test_weapon/viewmodel/test_weapon.vanmgrph\"",
StringComparison.Ordinal )
&& bootstrapHost.Contains( "anim_graph_name = \"\"", StringComparison.Ordinal )
&& generatedGraph.Contains(
"m_previewModels = [ \"weapons/test_weapon/viewmodel/test_weapon_vm_bootstrap.vmdl\", ]",
StringComparison.Ordinal ),
"Generation must keep a permanent graph-free preview host while the final host always links its AnimGraph." );
Check(
report,
generated.ContainsKey( "test_weapon_sequence_idle.dmx" )
&& !generated.ContainsKey( "test_weapon_idle.dmx" )
&& !generated.ContainsKey( "test_weapon_sequence_fire.dmx" ),
"Generation must emit authored sequences only and leave missing action roles on Idle fallbacks." );
var custom = WeaponAnimationClip.Create( WeaponClipRole.Custom );
custom.Name = "Mechanical Check";
custom.Readiness = ClipReadiness.Draft;
document.Clips.Add( custom );
WeaponAnimationNames.RepairCustomSequenceNames( document );
generated = AssetGenerationService.BuildFiles(
document,
skeleton,
"weapons/test_weapon/viewmodel" );
Check(
report,
generated.ContainsKey(
$"test_weapon_sequence_{custom.GeneratedSequenceName}.dmx" ),
"Authored custom clips must use their persisted readable sequence name." );
}
private static void TestPartVisibility( WeaponAnimatorSelfTestReport report )
{
var document = ValidDocument();
var idle = document.EnsureClip( WeaponClipRole.Idle );
var part = new WeaponVisibilityPart
{
Name = "Spare Magazine",
BoneId = "weapon_root",
BoneName = "weapon_root",
DefaultVisible = false
};
document.Rig.VisibilityParts.Add( part );
var track = idle.EnsureVisibilityTrack( part.Id );
var show = WeaponVisibilityEvaluator.UpsertKey( track, 0.2f, true );
WeaponVisibilityEvaluator.UpsertKey( track, 0.8f, false );
Check(
report,
!WeaponVisibilityEvaluator.Evaluate( part, idle, 0.1f )
&& WeaponVisibilityEvaluator.Evaluate( part, idle, 0.5f )
&& !WeaponVisibilityEvaluator.Evaluate( part, idle, 0.9f ),
"Visibility tracks must evaluate as stepped state changes from the configured default." );
var replacement = WeaponVisibilityEvaluator.UpsertKey( track, 0.2f, false );
Equal(
report,
show.Id,
replacement.Id,
"Keying visibility twice at one frame must update the existing key." );
Check(
report,
!WeaponVisibilityEvaluator.Evaluate( part, idle, 0.5f ),
"A replaced visibility key must take effect immediately." );
replacement.Visible = true;
var spans = WeaponVisibilityEvaluator.BuildSpans( part, idle );
Equal( report, 3, spans.Count, "Visibility export must cover the full clip with deterministic state spans." );
Near( report, 0, spans[0].StartTime, 0.0001f, "The first visibility span must begin at clip start." );
Near( report, idle.Duration, spans[^1].EndTime, 0.0001f, "The final visibility span must reach clip end." );
var skeleton = HostSkeletonBuilder.Build( document, includeArmProfile: false );
var before = DmxWriter.WriteAnimation( document, skeleton, idle );
var graph = AnimGraphWriter.Write( document, "host.vmdl" );
var after = DmxWriter.WriteAnimation( document, skeleton, idle );
Equal(
report,
before,
after,
"Visibility export must be deterministic and must not mutate authored transforms." );
Check(
report,
before.Contains( "\"DmeFloatLogLayer\"", StringComparison.Ordinal )
&& before.Contains( "\"0.0001\"", StringComparison.Ordinal )
&& before.Contains( "-8192", StringComparison.Ordinal ),
"Bone visibility must use native sequence scale and off-screen position channels." );
Check(
report,
graph.Contains( WeaponVisibilityEvaluator.VisibleTag( part.Id ) )
&& graph.Contains( WeaponVisibilityEvaluator.HiddenTag( part.Id ) ),
"Generated AnimGraphs must declare both visibility states for every part." );
var prefab = PrefabWriter.Write( document, "host.vmdl" );
Check(
report,
!prefab.Contains( "WeaponPartVisibilityController", StringComparison.Ordinal )
&& !prefab.Contains( "\"Name\": \"source_weapon\"", StringComparison.Ordinal )
&& prefab.Contains( "\"Model\": \"host.vmdl\"", StringComparison.Ordinal )
&& prefab.Contains( "\"GameLayer\": true", StringComparison.Ordinal )
&& Count( prefab, "\"__type\": \"Sandbox.SkinnedModelRenderer\"" ) == 2
&& prefab.Contains( "\"Name\": \"muzzle\"", StringComparison.Ordinal )
&& prefab.Contains( "\"Name\": \"eject\"", StringComparison.Ordinal ),
"Generated prefabs must use one visible host renderer plus bone-merged arms and explicit output anchors, with no custom controller." );
document.Output.GenerateGraph = false;
var graphFreePrefab = PrefabWriter.Write( document, "host.vmdl" );
Check(
report,
graphFreePrefab.Contains( "\"UseAnimGraph\": false", StringComparison.Ordinal )
&& !graphFreePrefab.Contains( "WeaponPartVisibilityController", StringComparison.Ordinal ),
"Graph-free prefabs must remain standard and disable AnimGraph playback." );
document.Output.GenerateGraph = true;
var controller = new WeaponAnimatorController();
controller.SetDocument( document );
controller.SetTimelineTime( 0.2f );
controller.SelectKeys( [show.Id], false );
controller.CopySelectedKeys();
controller.SetTimelineTime( 0.5f );
controller.PasteKeys();
Check(
report,
idle.VisibilityTracks.Single( x => x.PartId == part.Id )
.Keys.Any( x => MathF.Abs( x.Time - 0.5f ) <= 0.0001f ),
"Visibility keys must participate in the shared copy and paste workflow." );
part.RenderMode = VisibilityRenderMode.BodyGroup;
part.BodyGroupName = "";
var invalid = WeaponAnimationValidator.ValidateForGeneration( document );
Check(
report,
invalid.Issues.Any( x => x.Code == "visibility.bodygroup_missing" )
&& invalid.Issues.Any( x => x.Code == "visibility.bodygroup_export" ),
"Generation validation must reject bodygroup visibility until it can be baked into a standard prefab." );
}
private static WeaponAnimationDocument ValidDocument()
{
var document = WeaponAnimationDocument.CreateDefault( "Test Weapon" );
document.Source.SourcePath = "weapons/test/source.fbx";
document.Source.CompiledModelPath = "weapons/test/source.vmdl";
document.Source.Compiled = true;
document.Source.PreviewHostCompiled = true;
document.Rig.RootBone = "weapon_root";
document.Rig.Bones.Add( new WeaponBoneDefinition
{
Id = "weapon_root",
HierarchyPath = "weapon_root",
Name = "weapon_root",
OriginalName = "weapon_root",
Classification = WeaponBoneClassification.WeaponRoot,
Inclusion = WeaponBoneInclusion.Included,
BindTransform = Transform.Zero,
BindModelTransform = Transform.Zero,
BindLocalTransform = Transform.Zero,
HasSkinInfluence = true
} );
document.Rig.SourceSkeletonRootId = "weapon_root";
document.Rig.WeaponSubtreeRootId = "weapon_root";
document.Rig.ReviewRequired = false;
document.Rig.FilteredPreviewConfirmed = true;
document.Calibration.SetAnchor( Anchor( AnchorKind.Grip, new Vector3( 1, 0, 0 ) ) );
document.Calibration.SetAnchor( Anchor( AnchorKind.RearBore, Vector3.Zero ) );
document.Calibration.SetAnchor( Anchor( AnchorKind.FrontBore, Vector3.Forward ) );
document.Calibration.SetAnchor( Anchor( AnchorKind.Muzzle, new Vector3( 12, 0, 1 ) ) );
document.Calibration.SetAnchor( Anchor( AnchorKind.Eject, new Vector3( 4, -1, 2 ) ) );
document.Calibration.Confirmed = true;
document.Calibration.Snapshot = new CalibrationSnapshot();
document.EnsureClip( WeaponClipRole.Idle ).Readiness = ClipReadiness.Ready;
return document;
}
private static WeaponAnchor Anchor( AnchorKind kind, Vector3 position ) => new()
{
Name = kind.ToString(),
Kind = kind,
BoneName = "weapon_root",
LocalPosition = position
};
private static WeaponBoneDefinition Definition(
string name,
string parent,
WeaponBoneClassification classification,
Vector3 modelPosition ) =>
Definition( name, parent, classification, new Transform( modelPosition ) );
private static WeaponBoneDefinition Definition(
string name,
string parent,
WeaponBoneClassification classification,
Transform modelTransform ) => new()
{
Name = name,
ParentName = parent,
OriginalName = name,
OriginalParentName = parent,
Classification = classification,
Inclusion = WeaponBoneInclusion.Included,
BindTransform = modelTransform,
BindModelTransform = modelTransform,
HasSkinInfluence = true
};
private static HostBone Bone( string name, string parent, Vector3 position ) => new()
{
Name = name,
ParentName = parent,
BindModelTransform = new Transform( position )
};
private static float RotationLength( Rotation value ) =>
MathF.Sqrt( value.x * value.x + value.y * value.y + value.z * value.z + value.w * value.w );
private static int Count( string value, string fragment )
{
var count = 0;
var offset = 0;
while ( (offset = value.IndexOf( fragment, offset, StringComparison.Ordinal )) >= 0 )
{
count++;
offset += fragment.Length;
}
return count;
}
private static void Run(
WeaponAnimatorSelfTestReport report,
string name,
Action<WeaponAnimatorSelfTestReport> test )
{
try
{
test( report );
}
catch ( Exception ex )
{
report.Failures.Add( $"{name}: threw {ex.GetType().Name}: {ex.Message}" );
}
}
private static void Check(
WeaponAnimatorSelfTestReport report,
bool condition,
string message )
{
if ( condition )
report.Passed++;
else
report.Failures.Add( message );
}
private static void Equal<T>(
WeaponAnimatorSelfTestReport report,
T expected,
T actual,
string message )
{
Check(
report,
EqualityComparer<T>.Default.Equals( expected, actual ),
$"{message} Expected '{expected}', got '{actual}'." );
}
private static void Near(
WeaponAnimatorSelfTestReport report,
float expected,
float actual,
float tolerance,
string message )
{
Check(
report,
MathF.Abs( expected - actual ) <= tolerance,
$"{message} Expected {expected}, got {actual}." );
}
private static void Near(
WeaponAnimatorSelfTestReport report,
Vector3 expected,
Vector3 actual,
float tolerance,
string message )
{
Check(
report,
expected.Distance( actual ) <= tolerance,
$"{message} Expected {expected}, got {actual}." );
}
}