Effigy.Tests/DmxAnimTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Effigy;
using static Effigy.Tests.Report;
namespace Effigy.Tests;
/// <summary>
/// The animation DMX, parsed rather than searched for substrings.
///
/// SAME LESSON AS DmxGrammarTests, which is why this file exists at all rather than a handful of
/// Contains() checks bolted onto that one. Every mistake this format can make produces a file that
/// looks right in a text editor and fails as "Couldn't load DMX file" with no line number: a
/// missing comma between array members, a bare id where the two-token reference form belongs, a
/// channel pointing at a DmeJoint instead of its DmeTransform. Only a parse sees any of them.
///
/// WHAT A HEADLESS TEST CAN AND CANNOT SETTLE HERE. It can settle that the document parses, that
/// its channels reach real bones, that the time and value arrays agree in length, and that the
/// poses that went in are the poses that came out. It cannot settle whether ModelDoc likes the
/// result — that is a compile, and the sample file the suite writes to out/ is there to be
/// compiled. `dmxconvert.exe -i out/sample_anim.dmx -o check.dmx` is the intermediate step, and it
/// reports the first structural fault with a line number in about a second.
/// </summary>
public static class DmxAnimTests
{
public static void Run()
{
Section( "animation DMX: the document parses as KeyValues2" );
TestParses();
Section( "animation DMX: the channels reach the bones they name" );
TestChannels();
Section( "animation DMX: the poses that went in come back out" );
TestValues();
Section( "animation clips refuse to be written wrong" );
TestContract();
Section( "vmdl animation: clips in the AnimationList" );
TestVmdlNode();
}
// --- fixtures -----------------------------------------------------------------------------
/// <summary>A two-bone chain, which is the smallest rig where a parent-relative pose can be
/// wrong in a way a single bone would hide.</summary>
static Skeleton Chain()
{
var skeleton = new Skeleton();
var root = skeleton.AddBoneFromPoints( "root", -1, new Vec3( 0, 0, 0 ), new Vec3( 0, 1, 0 ) );
skeleton.AddBoneFromPoints( "child", root, new Vec3( 0, 1, 0 ), new Vec3( 0, 2, 0 ) );
return skeleton;
}
/// <summary>A clip that moves: the child bone swings through a quarter turn while the root
/// travels. A clip of identical frames would pass a length check and hide a writer that
/// emitted frame zero every time.</summary>
static AnimClip Swing( Skeleton skeleton, int frames = 4 )
{
var clip = new AnimClip { Name = "swing", FrameRate = 30f, Looping = true };
for ( var f = 0; f < frames; f++ )
{
var t = f / (float)(frames - 1);
var pose = new Xform[skeleton.Count];
pose[0] = Xform.Translate( new Vec3( t * 10f, 0, 0 ) );
pose[1] = Xform.Rotate( new Vec3( 0, 0, 1 ), t * MathF.PI / 2f )
* Xform.Translate( new Vec3( 0, 1, 0 ) );
clip.AddFrame( pose );
}
return clip;
}
// --- the checks ---------------------------------------------------------------------------
static void TestParses()
{
var skeleton = Chain();
var text = DmxAnimWriter.Write( skeleton, Swing( skeleton ), "anim_grammar" );
var root = DmxGrammarTests.Parse( text, out var error );
Check( "a clip export parses", root is not null, error );
if ( root is null )
return;
// The two mistakes the mesh writer made, which this writer inherits the shape of and could
// therefore make again. Named individually so a failure says which one came back.
Check( "every element_array member is comma-separated",
!text.Contains( "}\n\t\t\t\"Dme" ) && !text.Contains( "}\n\t\t\"Dme" ),
"an element sits directly against the next member's type name" );
Check( "references are the two-token form, not a bare id",
!System.Text.RegularExpressions.Regex.IsMatch( text, "\n\\s*\"[0-9a-f]{8}-" ),
"a bare quoted id would be read as an element type name" );
Check( "no array ends on a trailing comma",
!System.Text.RegularExpressions.Regex.IsMatch( text, ",\\s*\\]" ) );
// A one-bone rig with a one-frame clip is the degenerate end, where an off-by-one in
// duration or in the comma trimming has nowhere to hide.
var single = Skeleton.SingleRoot();
var still = new AnimClip { Name = "still" };
still.AddFrame( new[] { Xform.Identity } );
Check( "a single-bone single-frame clip parses",
DmxGrammarTests.Parse( DmxAnimWriter.Write( single, still ), out var stillError ) is not null,
stillError );
}
static void TestChannels()
{
var skeleton = Chain();
var clip = Swing( skeleton );
var root = DmxGrammarTests.Parse( DmxAnimWriter.Write( skeleton, clip, "anim_grammar" ), out var error );
if ( root is null )
{
Check( "the tree is readable", false, error );
return;
}
// animationList hangs off the ROOT, beside skeleton and model. Inside the DmeModel is
// where it looks like it belongs and is not where fbx2dmx puts it.
var list = root.Element( "animationList" );
Check( "the root carries a DmeAnimationList",
list is not null && list.Type == "DmeAnimationList",
list is null ? "no animationList attribute on the root element" : list.Type );
Check( "the skeleton is still there beside it",
root.Element( "skeleton" )?.Type == "DmeModel" );
var channels = DmxGrammarTests.FindAll( root, "DmeChannel" );
// Two per bone, position and orientation. One channel carrying both does not exist.
Check( "two channels per bone", channels.Count == skeleton.Count * 2,
$"{channels.Count} channels for {skeleton.Count} bone(s)" );
foreach ( var bone in skeleton.Bones )
{
Check( $"{bone.Name} has a position channel",
channels.Any( c => c.Name == $"{bone.Name}_p" ) );
Check( $"{bone.Name} has an orientation channel",
channels.Any( c => c.Name == $"{bone.Name}_o" ) );
}
Check( "position channels write the position attribute",
channels.Where( c => c.Name.EndsWith( "_p" ) ).All( c => c.Values.GetValueOrDefault( "toAttribute" ) == "position" ) );
Check( "orientation channels write the orientation attribute",
channels.Where( c => c.Name.EndsWith( "_o" ) ).All( c => c.Values.GetValueOrDefault( "toAttribute" ) == "orientation" ) );
// THE ONE THAT IS EASY TO GET WRONG AND IMPOSSIBLE TO SEE. A channel targets the bone's
// DmeTransform. Pointing it at the DmeJoint parses, loads, and animates nothing.
var transformIds = DmxGrammarTests.FindAll( root, "DmeTransform" )
.Select( t => t.Values.GetValueOrDefault( "id" ) )
.Where( id => id is not null )
.ToHashSet();
var jointIds = DmxGrammarTests.FindAll( root, "DmeJoint" )
.Select( j => j.Values.GetValueOrDefault( "id" ) )
.Where( id => id is not null )
.ToHashSet();
var targets = channels.Select( c => c.Values.GetValueOrDefault( "toElement" ) ).ToList();
Check( "every channel targets a DmeTransform that exists",
targets.All( t => t is not null && transformIds.Contains( t ) ),
"a channel points at an id no DmeTransform in this document has" );
Check( "and no channel targets a DmeJoint",
targets.All( t => !jointIds.Contains( t ) ),
"a joint-targeted channel animates nothing and reports no error" );
Check( "every channel is mode 3, as fbx2dmx writes them",
channels.All( c => c.Values.GetValueOrDefault( "mode" ) == "3" ) );
Check( "nothing drives these channels",
channels.All( c => c.Values.GetValueOrDefault( "fromElement" ) == "" ) );
}
static void TestValues()
{
var skeleton = Chain();
var clip = Swing( skeleton, 4 );
var root = DmxGrammarTests.Parse( DmxAnimWriter.Write( skeleton, clip ), out var error );
if ( root is null )
{
Check( "the tree is readable", false, error );
return;
}
var channels = DmxGrammarTests.FindAll( root, "DmeChannel" );
var rootPos = channels.FirstOrDefault( c => c.Name == "root_p" );
Check( "the root's position channel is there", rootPos is not null );
if ( rootPos is null )
return;
var layer = DmxGrammarTests.FindFirst( rootPos, "DmeVector3LogLayer" );
Check( "it carries a log layer", layer is not null );
if ( layer is null )
return;
var times = layer.Array( "times" );
var values = layer.Array( "values" );
// A times array that disagrees with its values array is a clip that plays at the wrong
// speed or truncates, depending on which is shorter, and never says so.
Check( "one time per frame", times.Count == clip.FrameCount,
$"{times.Count} times for {clip.FrameCount} frames" );
Check( "one value per frame", values.Count == clip.FrameCount,
$"{values.Count} values for {clip.FrameCount} frames" );
Check( "curvetypes is present and empty", layer.Arrays.ContainsKey( "curvetypes" )
&& layer.Array( "curvetypes" ).Count == 0,
"an absent array and an empty one are different statements to this reader" );
// Frame times come from the frame rate, not from an index. At 30fps frame 3 is 0.1s.
Check( "frame times follow the frame rate",
times.Count == 4 && times[0].Value == "0.0000" && times[3].Value == "0.1000",
times.Count == 4 ? $"{times[0].Value} .. {times[3].Value}" : "wrong count" );
// The values themselves: the root travels 0 -> 10 on x across the clip.
Check( "the first frame's position is the one that went in",
values.Count == 4 && values[0].Value == "0 0 0", values.FirstOrDefault()?.Value );
Check( "and the last frame's is too",
values.Count == 4 && values[3].Value == "10 0 0", values.LastOrDefault()?.Value );
// A writer that emitted frame zero every time would pass every length check above.
Check( "the frames are not all the same",
values.Select( v => v.Value ).Distinct().Count() == 4,
"every frame wrote the same value" );
var clipNode = DmxGrammarTests.FindFirst( root, "DmeChannelsClip" );
Check( "the clip keeps its name", clipNode?.Name == "swing", clipNode?.Name );
// N frames span N-1 intervals: four frames at 30fps is a tenth of a second, not two
// fifteenths. Off by one here stretches every clip by a frame.
var timeFrame = DmxGrammarTests.FindFirst( root, "DmeTimeFrame" );
Check( "duration counts intervals, not frames",
timeFrame?.Values.GetValueOrDefault( "duration" ) == "0.1000",
timeFrame?.Values.GetValueOrDefault( "duration" ) );
}
static void TestContract()
{
var skeleton = Chain();
Check( "a clip with no frames is refused", Throws( () =>
DmxAnimWriter.Write( skeleton, new AnimClip { Name = "empty" } ) ) );
// A ragged frame list comes from a sampler that grew a bone mid-loop. Downstream it is an
// index walk off the end of an array in the middle of writing a file.
var ragged = new AnimClip { Name = "ragged" };
ragged.AddFrame( new[] { Xform.Identity, Xform.Identity } );
ragged.AddFrame( new[] { Xform.Identity } );
Check( "a ragged frame list is refused", Throws( () => DmxAnimWriter.Write( skeleton, ragged ) ) );
var wrongWidth = new AnimClip { Name = "narrow" };
wrongWidth.AddFrame( new[] { Xform.Identity } );
Check( "a frame that does not cover every bone is refused",
Throws( () => DmxAnimWriter.Write( skeleton, wrongWidth ) ) );
Check( "a null skeleton is refused", Throws( () => DmxAnimWriter.Write( null, Swing( skeleton ) ) ) );
Check( "a null clip is refused", Throws( () => DmxAnimWriter.Write( skeleton, null ) ) );
var zeroRate = new AnimClip { Name = "zero", FrameRate = 0f };
zeroRate.AddFrame( new[] { Xform.Identity, Xform.Identity } );
Check( "a zero frame rate is refused", Throws( () => DmxAnimWriter.Write( skeleton, zeroRate ) ) );
// Validate is the same answer without the throw, so a UI can say what is wrong before it
// asks for a file.
Check( "Validate names the problem rather than just failing",
ragged.Validate( skeleton )?.Contains( "frame 1" ) == true, ragged.Validate( skeleton ) );
Check( "and says nothing about a clip that is fine",
Swing( skeleton ).Validate( skeleton ) is null );
// Two exports of the same clip are byte-identical, which is what makes a diff mean
// something when one of them stops working.
Check( "the writer is deterministic",
DmxAnimWriter.Write( skeleton, Swing( skeleton ) ) == DmxAnimWriter.Write( skeleton, Swing( skeleton ) ) );
}
static void TestVmdlNode()
{
// No clips: byte-identical to what BindPoseList always wrote, because every existing
// skinned export goes through this path now.
Check( "an empty animation list is still just the bind pose",
VmdlAnimation.AnimationList() == VmdlAnimation.BindPoseList() );
Check( "and it carries exactly one AnimBindPose",
Count( VmdlAnimation.BindPoseList(), "_class = \"AnimBindPose\"" ) == 1 );
var node = VmdlAnimation.AnimationList(
new VmdlAnimation.ClipEntry( "wave", "models/effigy/wave.dmx", true ),
new VmdlAnimation.ClipEntry( "idle", "models/effigy/idle.dmx" ) );
Check( "clips join the bind pose rather than replacing it",
Count( node, "_class = \"AnimBindPose\"" ) == 1 && Count( node, "_class = \"AnimFile\"" ) == 2,
$"{Count( node, "_class = \"AnimBindPose\"" )} bind pose(s), {Count( node, "_class = \"AnimFile\"" )} clip(s)" );
Check( "still one AnimationList", Count( node, "_class = \"AnimationList\"" ) == 1 );
Check( "braces balance", Count( node, "{" ) == Count( node, "}" ) );
Check( "and brackets balance", Count( node, "[" ) == Count( node, "]" ) );
Check( "the node is a complete child entry, comma and all", node.TrimEnd( '\n' ).EndsWith( "}," ) );
Check( "each clip names itself and its source file",
node.Contains( "name = \"wave\"" ) && node.Contains( "source_filename = \"models/effigy/wave.dmx\"" ) );
Check( "looping is carried per clip, not shared",
node.Contains( "looping = true" ) && node.Contains( "looping = false" ) );
// EVERY FIELD, for the same reason as the bind pose: the compiler's defaults are not
// documented anywhere this project can read, and the file known to work carries all of them.
foreach ( var field in new[]
{
"name", "activity_name", "activity_weight", "weight_list_name", "fade_in_time",
"fade_out_time", "looping", "delta", "worldSpace", "hidden", "anim_markup_ordered",
"disable_compression", "disable_interpolation", "enable_scale", "source_filename",
"start_frame", "end_frame", "framerate", "take", "reverse",
} )
{
Check( $"an AnimFile carries {field}",
VmdlAnimation.AnimFile( new VmdlAnimation.ClipEntry( "c", "c.dmx" ) ).Contains( $"{field} = " ),
"missing" );
}
// -1 means "the whole file at its own rate". A real number here trims or resamples the
// clip, which is a silently shorter animation rather than an error.
var one = VmdlAnimation.AnimFile( new VmdlAnimation.ClipEntry( "c", "c.dmx" ) );
Check( "the frame range says 'all of it'",
one.Contains( "start_frame = -1" ) && one.Contains( "end_frame = -1" ) );
Check( "and the framerate defers to the source",
one.Contains( "framerate = -1.0" ) );
}
// --- the sample ---------------------------------------------------------------------------
/// <summary>
/// A clip on the rigged sample's own skeleton, plus a .vmdl that compiles the two together —
/// the whole animation path as files, so the engine can pass judgement on it:
///
/// bin/win64/dmxconvert.exe -i out/sample_anim.dmx -o check.dmx
/// copy out/sample_{rigged,anim}.dmx and out/sample_anim.vmdl into Assets/models/effigy_probe/
/// register_external_assets, asset_compile, then load it and ask for its animation list
///
/// A model that compiles and reports a clip called "wave" is the answer. The suite cannot get
/// that far on its own — the compiler is the engine's — but the files are the whole input, so
/// nothing else has to be running to check them.
///
/// THE SKELETON IS THE CALLER'S, not one built here, and that is the point of the parameter.
/// A clip written against its own idea of the rig would compile and animate nothing, which is
/// exactly the failure this sample exists to make visible.
/// </summary>
internal static void WriteSample( string outDir, Skeleton skeleton )
{
// A quarter turn of the child bone over half a second, which is enough movement to see in
// a preview and short enough to scrub through.
var clip = new AnimClip { Name = "wave", FrameRate = 30f, Looping = true };
for ( var f = 0; f < 16; f++ )
{
var t = f / 15f;
var pose = new Xform[skeleton.Count];
pose[0] = skeleton.Bones[0].Local;
pose[1] = Xform.Rotate( new Vec3( 1, 0, 0 ), MathF.Sin( t * MathF.Tau ) * 0.5f )
* skeleton.Bones[1].Local;
clip.AddFrame( pose );
}
DmxAnimWriter.WriteFile( System.IO.Path.Combine( outDir, "sample_anim.dmx" ),
skeleton, clip, "sample_rigged" );
var vmdl =
"<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc29:version{3cec427c-1b0e-4d48-a90a-0436f33a6041} -->\n"
+ "{\n\trootNode = \n\t{\n\t\t_class = \"RootNode\"\n\t\tchildren = \n\t\t[\n"
+ "\t\t\t{\n\t\t\t\t_class = \"RenderMeshList\"\n\t\t\t\tchildren = \n\t\t\t\t[\n"
+ "\t\t\t\t\t{\n\t\t\t\t\t\t_class = \"RenderMeshFile\"\n\t\t\t\t\t\tname = \"Body_LOD0\"\n"
+ "\t\t\t\t\t\tchildren = \n\t\t\t\t\t\t[\n\t\t\t\t\t\t]\n"
+ "\t\t\t\t\t\tfilename = \"models/effigy_probe/sample_rigged.dmx\"\n"
+ "\t\t\t\t\t\timport_translation = [ 0.0, 0.0, 0.0 ]\n"
+ "\t\t\t\t\t\timport_rotation = [ 0.0, 0.0, 0.0 ]\n"
+ "\t\t\t\t\t\timport_scale = 1.0\n"
+ "\t\t\t\t\t\talign_origin_x_type = \"None\"\n"
+ "\t\t\t\t\t\talign_origin_y_type = \"None\"\n"
+ "\t\t\t\t\t\talign_origin_z_type = \"None\"\n"
+ "\t\t\t\t\t\tparent_bone = \"\"\n\t\t\t\t\t},\n\t\t\t\t]\n\t\t\t},\n"
+ VmdlAnimation.BoneMarkupList( skeleton )
+ VmdlAnimation.AnimationList(
new VmdlAnimation.ClipEntry( "wave", "models/effigy_probe/sample_anim.dmx", true ) )
+ "\t\t]\n\t\tmodel_archetype = \"\"\n\t\tprimary_associated_entity = \"\"\n"
+ "\t\tanim_graph_name = \"\"\n\t\tbase_model_name = \"\"\n\t}\n}\n";
System.IO.File.WriteAllText( System.IO.Path.Combine( outDir, "sample_anim.vmdl" ), vmdl );
Check( $"wrote {outDir}/sample_anim.dmx and .vmdl - compile them and the model should carry a 'wave' clip",
System.IO.File.Exists( System.IO.Path.Combine( outDir, "sample_anim.dmx" ) )
&& Count( vmdl, "_class = \"AnimFile\"" ) == 1
&& Count( vmdl, "_class = \"AnimBindPose\"" ) == 1
&& Count( vmdl, "{" ) == Count( vmdl, "}" ) );
}
// --- helpers ------------------------------------------------------------------------------
static bool Throws( Action action )
{
try
{
action();
return false;
}
catch
{
return true;
}
}
static int Count( string text, string needle )
{
var n = 0;
for ( var i = text.IndexOf( needle, StringComparison.Ordinal ); i >= 0;
i = text.IndexOf( needle, i + needle.Length, StringComparison.Ordinal ) )
{
n++;
}
return n;
}
}