Effigy.Tests/VmdlAnimationTests.cs
using System;
using System.IO;
using Effigy;
using static Effigy.Tests.Report;
namespace Effigy.Tests;
/// <summary>
/// The AnimBindPose node, and a rigged .vmdl to carry it somewhere it can be compiled.
///
/// WHY THIS IS THIN. The node is a constant — every field copied off
/// `first_person_arms_preview.vmdl`, which ships as source. There is no arithmetic to check, so what
/// a headless test can honestly do is small: the node is one AnimBindPose inside one AnimationList,
/// its punctuation balances, and it goes where a RootNode's children go. Everything that actually
/// matters about it — whether the compiler accepts it, and whether the bones survive — is a compile,
/// which is why the sample file below exists.
///
/// WHAT THE NODE IS FOR. ModelDoc's own documentation says a model that is not fully static needs at
/// least an AnimBindPose or morph targets and IK data silently break. This project's skinned export
/// never had one. A wrong KV3 node fails as a model that will not load, which is worse than a model
/// missing a node.
/// </summary>
public static class VmdlAnimationTests
{
public static void Run()
{
Section( "vmdl animation: the bind pose node" );
var node = VmdlAnimation.BindPoseList();
Check( "it is one AnimBindPose inside one AnimationList",
Count( node, "_class = \"AnimBindPose\"" ) == 1
&& Count( node, "_class = \"AnimationList\"" ) == 1,
$"{Count( node, "_class = \"AnimBindPose\"" )} pose(s), {Count( node, "_class = \"AnimationList\"" )} list(s)" );
Check( "braces balance", Count( node, "{" ) == Count( node, "}" ),
$"{Count( node, "{" )} open, {Count( node, "}" )} close" );
Check( "and brackets balance", Count( node, "[" ) == Count( node, "]" ),
$"{Count( node, "[" )} open, {Count( node, "]" )} close" );
// Spliced between a RootNode's other children, so it has to end the way they do.
Check( "the node is a complete child entry, comma and all", node.TrimEnd( '\n' ).EndsWith( "}," ) );
// EVERY FIELD, including the ones that look like defaults. The compiler's defaults are not
// documented anywhere this project can read, and the file known to work carries all of them -
// so a field quietly dropped here is a difference from the only evidence there is.
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", "frame_count", "frame_rate",
} )
{
Check( $"carries {field}", node.Contains( $"{field} = " ), "missing" );
}
Check( "and the list names its default root bone",
node.Contains( "default_root_bone_name = \"\"" ) );
}
/// <summary>
/// A skinned .vmdl around the rigged sample DMX, so the bind pose can be put in front of the
/// compiler.
///
/// copy out/sample_rigged.{dmx,vmdl} into Assets/models/effigy_probe/
/// register_external_assets, asset_compile, then kit_validate that folder
///
/// A model that compiles and loads is the answer. A compile error naming the node is the other
/// one, and would mean the fields differ between a preview model and a plain one.
///
/// NO -90 YAW HERE, unlike the OBJ samples, and that is not an oversight: it is ModelDoc's OBJ
/// importer that turns the mesh, and this one is a DMX.
/// </summary>
internal static void WriteSample( string outDir, Skeleton skeleton, PolyMesh mesh )
{
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"
// BOTH NODES, because the sample has to be what the editor writes. Without the markup
// list this file compiled to a model with ONE bone out of two - measured, not feared -
// so a sample missing it would answer a question about the sample.
+ VmdlAnimation.BoneMarkupList( skeleton )
+ VmdlAnimation.BindPoseList()
// AND THE MATERIAL LIST, for the same reason as the two above: the editor writes one,
// so a sample without it is not the thing being checked. Its absence is exactly what
// made this file useless for catching the bug where an unbound slot compiled to the
// missing-material shader - the sample had no slots named at all.
+ VmdlMaterials.GroupList( mesh, null, null )
+ "\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";
File.WriteAllText( Path.Combine( outDir, "sample_rigged.vmdl" ), vmdl );
Check( $"wrote {outDir}/sample_rigged.vmdl - compile it beside sample_rigged.dmx and it should load with {skeleton.Count} bones",
Count( vmdl, "_class = \"AnimBindPose\"" ) == 1
&& Count( vmdl, "_class = \"BoneMarkup\"" ) == skeleton.Count
&& Count( vmdl, "_class = \"MaterialGroupList\"" ) == 1
&& vmdl.Contains( VmdlMaterials.DefaultMaterial )
&& Count( vmdl, "{" ) == Count( vmdl, "}" ) );
}
static int Count( string text, string needle )
{
var count = 0;
var at = 0;
while ( (at = text.IndexOf( needle, at, StringComparison.Ordinal )) >= 0 )
{
count++;
at += needle.Length;
}
return count;
}
}