Effigy/VmdlAnimation.cs
using System.Text;
namespace Effigy;
/// <summary>
/// The AnimationList node a skinned .vmdl needs, and the bind pose inside it.
///
/// WHAT THIS CLOSES. ModelDoc's own documentation says a model that is not fully static needs at
/// least an `AnimBindPose` node or morph targets and IK data silently break — and this project's
/// skinned export has never had one, because nothing here had seen the node's real KV3 and a
/// guessed one risks breaking a compile that currently works. Copied from a real file, not a guess.
///
/// COPIED, NOT GUESSED. `first_person_arms_preview.vmdl` ships as source at
/// `sbox\addons\citizen\Assets\models\first_person\` and carries exactly this: an `AnimationList`
/// with `default_root_bone_name`, holding one `AnimBindPose` named `bindPose`. Citizen's own
/// `citizen_animationlist.vmdl_prefab` has the same node with the same fields, under the name
/// `bindPose_internal`, alongside a note of its own worth knowing: the compiled bind pose picks up
/// a little inaccuracy in bone orientations unless compression is disabled, and the INTERNAL one is
/// exact. Nothing here needs that distinction — one bind pose and no clips — so this writes the
/// plain one under the plain name.
///
/// EVERY FIELD IS WRITTEN, including the ones that look like defaults. A KV3 node with fields
/// missing is not the same thing as a node with them at their defaults: the compiler's defaults are
/// not documented anywhere this project can read, and the file that is known to work has all of
/// them. Copying it whole costs sixteen lines and removes the question.
/// </summary>
public static class VmdlAnimation
{
/// <summary>
/// One clip to compile into the model: what it will be called, the animation file it comes
/// from, and whether it loops.
///
/// <paramref name="SourceFilename"/> is a path the COMPILER resolves, so it is relative to the
/// game's content root the same way a RenderMeshFile's filename is — "models/thing/wave.dmx",
/// not a path on disk. A path that does not resolve is not an error at compile time; the clip
/// is simply not there afterwards.
/// </summary>
public readonly record struct ClipEntry( string Name, string SourceFilename, bool Looping = false );
/// <summary>
/// The AnimationList node, indented to sit among a RootNode's children.
///
/// It is only ever wanted on a SKINNED model. A static export has no bones for a bind pose to be
/// about, and adding one there would be a node that means nothing.
/// </summary>
public static string BindPoseList() => AnimationList();
/// <summary>
/// The same node, carrying clips as well as the bind pose.
///
/// THE BIND POSE STAYS EVEN WHEN THERE ARE CLIPS. It is tempting to read "a non-static model
/// needs an AnimBindPose or morph targets and IK break" as a requirement that clips satisfy
/// too — they do not; the bind pose is what the model is when nothing is playing, and citizen's
/// own animation list carries one alongside several hundred clips.
///
/// Clips come after it in the list, in the order given. ModelDoc also allows `Folder` nodes for
/// grouping, which nothing here writes: a folder changes only what the ModelDoc UI looks like,
/// and every extra node shape is one more thing to have got wrong.
/// </summary>
public static string AnimationList( params ClipEntry[] clips )
{
var sb = new StringBuilder();
sb.Append( "\t\t\t{\n" );
sb.Append( "\t\t\t\t_class = \"AnimationList\"\n" );
sb.Append( "\t\t\t\tchildren = \n" );
sb.Append( "\t\t\t\t[\n" );
sb.Append( BindPose() );
if ( clips is not null )
{
foreach ( var clip in clips )
sb.Append( AnimFile( clip ) );
}
sb.Append( "\t\t\t\t]\n" );
sb.Append( "\t\t\t\tdefault_root_bone_name = \"\"\n" );
sb.Append( "\t\t\t},\n" );
return sb.ToString();
}
/// <summary>The AnimBindPose child, on its own. See the class header for where every field came
/// from.</summary>
static string BindPose() =>
"\t\t\t\t\t{\n"
+ "\t\t\t\t\t\t_class = \"AnimBindPose\"\n"
+ "\t\t\t\t\t\tname = \"bindPose\"\n"
+ "\t\t\t\t\t\tactivity_name = \"\"\n"
+ "\t\t\t\t\t\tactivity_weight = 1\n"
+ "\t\t\t\t\t\tweight_list_name = \"\"\n"
+ "\t\t\t\t\t\tfade_in_time = 0.2\n"
+ "\t\t\t\t\t\tfade_out_time = 0.2\n"
+ "\t\t\t\t\t\tlooping = false\n"
+ "\t\t\t\t\t\tdelta = false\n"
+ "\t\t\t\t\t\tworldSpace = false\n"
+ "\t\t\t\t\t\thidden = false\n"
+ "\t\t\t\t\t\tanim_markup_ordered = false\n"
+ "\t\t\t\t\t\tdisable_compression = false\n"
+ "\t\t\t\t\t\tdisable_interpolation = false\n"
+ "\t\t\t\t\t\tenable_scale = false\n"
+ "\t\t\t\t\t\tframe_count = 1\n"
+ "\t\t\t\t\t\tframe_rate = 30\n"
+ "\t\t\t\t\t},\n";
/// <summary>
/// One AnimFile child — a clip the compiler reads out of an external animation file.
///
/// COPIED, NOT GUESSED, from `citizen_animationlist.vmdl_prefab`, which ships as source at
/// `addons/citizen/Assets/models/citizen/prefabs/` and carries several hundred of these. Same
/// rule as the bind pose above and for the same reason: a KV3 node with fields missing is not
/// a node with them at their defaults, and the compiler's defaults are not documented anywhere
/// this project can read.
///
/// THE THREE FIELDS THAT LOOK LIKE PLACEHOLDERS ARE NOT. `start_frame` and `end_frame` at -1
/// mean "the whole file" — a real frame range here would trim the clip. `framerate` at -1.0
/// means "whatever the source says", which is what a baked DMX carries in its own time values,
/// and setting a number here resamples the clip instead of describing it.
///
/// `fade_in_time` and `fade_out_time` are 0.05 rather than the bind pose's 0.2 because that is
/// what citizen's clips use; they are blend times AnimGraph reads when a transition does not
/// specify its own, so they are a default rather than a constant.
/// </summary>
public static string AnimFile( ClipEntry clip ) =>
"\t\t\t\t\t{\n"
+ "\t\t\t\t\t\t_class = \"AnimFile\"\n"
+ $"\t\t\t\t\t\tname = \"{clip.Name}\"\n"
+ "\t\t\t\t\t\tactivity_name = \"\"\n"
+ "\t\t\t\t\t\tactivity_weight = 1\n"
+ "\t\t\t\t\t\tweight_list_name = \"\"\n"
+ "\t\t\t\t\t\tfade_in_time = 0.05\n"
+ "\t\t\t\t\t\tfade_out_time = 0.05\n"
+ $"\t\t\t\t\t\tlooping = {(clip.Looping ? "true" : "false")}\n"
+ "\t\t\t\t\t\tdelta = false\n"
+ "\t\t\t\t\t\tworldSpace = false\n"
+ "\t\t\t\t\t\thidden = false\n"
+ "\t\t\t\t\t\tanim_markup_ordered = false\n"
+ "\t\t\t\t\t\tdisable_compression = false\n"
+ "\t\t\t\t\t\tdisable_interpolation = false\n"
+ "\t\t\t\t\t\tenable_scale = false\n"
+ $"\t\t\t\t\t\tsource_filename = \"{clip.SourceFilename}\"\n"
+ "\t\t\t\t\t\tstart_frame = -1\n"
+ "\t\t\t\t\t\tend_frame = -1\n"
+ "\t\t\t\t\t\tframerate = -1.0\n"
+ "\t\t\t\t\t\ttake = 0\n"
+ "\t\t\t\t\t\treverse = false\n"
+ "\t\t\t\t\t},\n";
/// <summary>
/// Every bone marked do_not_discard, so ModelDoc keeps bones this export gives it no other
/// reason to keep.
///
/// CONFIRMED TWICE, and the second time is the one worth recording. ModelDoc prunes any bone that
/// is neither weighted by the mesh nor animated by anything imported — first seen on
/// `Assets/models/first_person/fp_arms.vmdl`, where arm_root and the two upper arms kept vanishing
/// (27 bones in the FBX, 24 in the compiled model) until a BoneMarkupList fixed it. Then measured
/// again from the other end: a two-bone sample .vmdl written WITHOUT this node compiled fine, and
/// the loaded model reported exactly one bone. `root` survived, `child` did not.
///
/// So this is not belt and braces. Effigy's export has no AnimationList of clips, so nothing is
/// ever "animated by anything imported", and every bone leans entirely on being weighted.
/// SkinBinder's nearest-vertex fallback gives most bones at least one vertex, and a short helper
/// joint that never ends up nearest to anything has nothing else keeping it alive.
///
/// Marking every bone rather than working out which ones are at risk costs a line each and
/// removes the question. `bone_cull_type = "None"` on the list says the same thing again at the
/// list level, which is what fp_arms does.
///
/// IN THE KERNEL RATHER THAN THE EDITOR because the sample .vmdl the suite writes has to carry
/// the same node the editor writes, or compiling the sample answers a question about the sample.
/// </summary>
public static string BoneMarkupList( Skeleton skeleton )
{
if ( skeleton is null || skeleton.Count == 0 )
return "";
var sb = new StringBuilder();
sb.Append( "\t\t\t{\n" );
sb.Append( "\t\t\t\t_class = \"BoneMarkupList\"\n" );
sb.Append( "\t\t\t\tchildren = \n" );
sb.Append( "\t\t\t\t[\n" );
foreach ( var bone in skeleton.Bones )
{
sb.Append( "\t\t\t\t\t{\n" );
sb.Append( "\t\t\t\t\t\t_class = \"BoneMarkup\"\n" );
sb.Append( $"\t\t\t\t\t\ttarget_bone = \"{bone.Name}\"\n" );
sb.Append( "\t\t\t\t\t\tignore_Translation = false\n" );
sb.Append( "\t\t\t\t\t\tignore_rotation = false\n" );
sb.Append( "\t\t\t\t\t\tdo_not_discard = true\n" );
sb.Append( "\t\t\t\t\t},\n" );
}
sb.Append( "\t\t\t\t]\n" );
sb.Append( "\t\t\t\tbone_cull_type = \"None\"\n" );
sb.Append( "\t\t\t},\n" );
return sb.ToString();
}
}