Three editor console probe utilities. EffigySkeletonProbe loads a model, imports its skeleton with Effigy, compares bind-pose positions and orientations to the engine bone transforms, and can dump bone lists or local bases to text files. EffigyModelProbe reflects the Model type at runtime and prints its public members. EffigyClothingProbe reflects the Sandbox.Clothing type and prints its properties and enum values.
using Effigy;
using Sandbox;
using System;
using System.Linq;
using Skeleton = Effigy.Skeleton;
namespace Marionette.EditorTools;
/// <summary>
/// Import a compiled model's skeleton and report whether it survived - from the console, because
/// the answer needs a running engine and the kernel test suite deliberately has none.
///
/// effigy_skeleton_probe citizen
/// effigy_skeleton_probe models/citizen/citizen.vmdl any model
/// effigy_skeleton_probe models/citizen/citizen.vmdl names plus every bone name
///
/// WHAT IT IS ACTUALLY ASKING. Reading names and parents out of a model is easy and proves
/// nothing; the question that decides whether a playermodel can be built on top of an imported
/// skeleton is whether the BIND POSE is still the same pose afterwards. So this rebuilds each
/// bone's world transform by walking Effigy's parent chain and compares it against the engine's
/// own <c>GetBoneTransform</c>. A non-zero worst error means the import rotated or moved a bone,
/// and every animation authored against that skeleton would be wrong by exactly that much.
/// </summary>
public static class EffigySkeletonProbe
{
const string Citizen = "models/citizen/citizen.vmdl";
[ConCmd( "effigy_skeleton_probe" )]
public static void Probe( string path = Citizen, string option = "" )
{
if ( string.IsNullOrWhiteSpace( path ) )
path = Citizen;
var model = Model.Load( path );
if ( model is null || model.IsError )
{
Log.Error( $"[skel] could not load {path}" );
return;
}
var result = EffigySkeletonImport.FromModel( model );
var skeleton = result.Skeleton;
Log.Info( $"[skel] {path}" );
// Attachments used to be counted here. Model.AttachmentCount is obsolete and this line is
// diagnostic only, so it is dropped rather than guessed at - nothing downstream reads it.
Log.Info( $"[skel] engine bones {model.BoneCount}, imported {skeleton.Count}, "
+ $"roots {result.Roots}, "
+ $"anim graph {(model.AnimGraph is null ? "none" : "yes")}" );
Log.Info( $"[skel] topological order {(TopologicallyOrdered( skeleton ) ? "held" : "BROKEN")}" );
ReportBindPose( model, skeleton );
foreach ( var note in result.Notes )
Log.Info( $"[skel] note: {note}" );
// A DUMP RATHER THAN 95 LOG LINES. Printing a whole skeleton to the console is both hard
// to read back and enough output to make the editor stall; a file can be diffed, parsed
// and compared against another skeleton offline, which is what mapping one rig onto
// another actually needs.
if ( option.Contains( "dump", StringComparison.OrdinalIgnoreCase ) )
{
var to = System.IO.Path.Combine( Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData ), "skeleton-dump.txt" );
var text = new System.Text.StringBuilder();
text.AppendLine( $"# {path} bones={skeleton.Count} roots={result.Roots}" );
text.AppendLine( "# index name parent model-space x,y,z length" );
for ( var i = 0; i < skeleton.Count; i++ )
{
var bone = skeleton.Bones[i];
var parent = bone.Parent < 0 ? "-" : skeleton.Bones[bone.Parent].Name;
var w = skeleton.WorldBind( i ).Origin;
text.AppendLine( $"{i} {bone.Name} {parent} {w.x:0.####},{w.y:0.####},{w.z:0.####} {bone.Length:0.####}" );
}
System.IO.File.WriteAllText( to, text.ToString() );
Log.Info( $"[skel] wrote {to}" );
}
if ( option.Contains( "names", StringComparison.OrdinalIgnoreCase ) )
{
for ( var i = 0; i < skeleton.Count; i++ )
{
var bone = skeleton.Bones[i];
var parent = bone.Parent < 0 ? "-" : skeleton.Bones[bone.Parent].Name;
Log.Info( $"[skel] {i,3} {bone.Name,-24} parent {parent,-24} len {bone.Length:0.##}" );
}
}
// THE ORIENTATION DUMP. The plain dump above writes model-space positions only, which is
// exactly the blind spot that let the bind-pose roll be wrong for years - positions are
// roll-independent, so a skeleton whose orientations are all wrong reads back perfect.
// This writes each bone's LOCAL basis (the three axes plus origin, in the parent's frame)
// as a ready-to-paste `Add(...)` row, so the CitizenSkeleton table can be regenerated with
// the roll the engine actually reports rather than whatever the old converter produced.
if ( option.Contains( "local", StringComparison.OrdinalIgnoreCase ) )
{
var to = System.IO.Path.Combine( Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData ), "skeleton-local.txt" );
var text = new System.Text.StringBuilder();
text.AppendLine( $"# {path} bones={skeleton.Count} roots={result.Roots}" );
for ( var i = 0; i < skeleton.Count; i++ )
{
var bone = skeleton.Bones[i];
var l = bone.Local;
text.AppendLine( $"Add( s, \"{bone.Name}\", {bone.Parent}, "
+ $"{l.X.x:0.000000}f, {l.X.y:0.000000}f, {l.X.z:0.000000}f, "
+ $"{l.Y.x:0.000000}f, {l.Y.y:0.000000}f, {l.Y.z:0.000000}f, "
+ $"{l.Z.x:0.000000}f, {l.Z.y:0.000000}f, {l.Z.z:0.000000}f, "
+ $"{l.Origin.x:0.000000}f, {l.Origin.y:0.000000}f, {l.Origin.z:0.000000}f );" );
}
System.IO.File.WriteAllText( to, text.ToString() );
Log.Info( $"[skel] wrote {to}" );
}
}
/// <summary>The invariant Skeleton.AddBone exists to enforce - a parent always sits at a lower
/// index. Checked rather than assumed, since the import chose the order itself.</summary>
static bool TopologicallyOrdered( Skeleton skeleton )
{
for ( var i = 0; i < skeleton.Count; i++ )
{
if ( skeleton.Bones[i].Parent >= i )
return false;
}
return true;
}
/// <summary>
/// Worst disagreement between Effigy's rebuilt world bind and the engine's, over every bone,
/// in inches and in degrees. Position and orientation are reported separately because they
/// fail for different reasons: a position drift is a units or parenting mistake, an
/// orientation drift is the axis convention.
/// </summary>
static void ReportBindPose( Model model, Skeleton skeleton )
{
var worstPos = 0f;
var worstAng = 0f;
var worstPosBone = "-";
var worstAngBone = "-";
for ( var i = 0; i < skeleton.Count; i++ )
{
var name = skeleton.Bones[i].Name;
var engine = model.GetBoneTransform( name );
var mine = skeleton.WorldBind( i );
var dp = Vector3.DistanceBetween( engine.Position, EffigySkeletonImport.ToVector3( mine.Origin ) );
if ( dp > worstPos )
{
worstPos = dp;
worstPosBone = name;
}
// Compare the three axes rather than the quaternions, which have a sign ambiguity and
// would report 180 degrees for two identical rotations.
var da = MathF.Max(
Angle( engine.Rotation.Forward, mine.X ),
MathF.Max( Angle( engine.Rotation.Left, mine.Y ), Angle( engine.Rotation.Up, mine.Z ) ) );
if ( da > worstAng )
{
worstAng = da;
worstAngBone = name;
}
}
Log.Info( $"[skel] worst bind error: {worstPos:0.####} in at '{worstPosBone}', "
+ $"{worstAng:0.####} deg at '{worstAngBone}'" );
// THE RIVAL HYPOTHESIS. If BoneCollection's LocalTransform is not parent-relative after
// all but already model-space, then walking the parent chain compounds it and the error
// above is meaningless. Measuring both tells the two apart in one run instead of two.
var worstFlat = 0f;
var flatBone = "-";
for ( var i = 0; i < skeleton.Count; i++ )
{
var name = skeleton.Bones[i].Name;
var d = Vector3.DistanceBetween( model.GetBoneTransform( name ).Position,
EffigySkeletonImport.ToVector3( skeleton.Bones[i].Local.Origin ) );
if ( d > worstFlat )
{
worstFlat = d;
flatBone = name;
}
}
Log.Info( $"[skel] if local were already model-space: worst {worstFlat:0.####} in at '{flatBone}'" );
for ( var i = 0; i < Math.Min( 6, skeleton.Count ); i++ )
{
var name = skeleton.Bones[i].Name;
Log.Info( $"[skel] {name,-20} engine {model.GetBoneTransform( name ).Position} "
+ $"| local {EffigySkeletonImport.ToVector3( skeleton.Bones[i].Local.Origin )} "
+ $"| walked {EffigySkeletonImport.ToVector3( skeleton.WorldBind( i ).Origin )}" );
}
}
static float Angle( Vector3 engine, Vec3 mine )
{
var a = engine.Normal;
var b = EffigySkeletonImport.ToVector3( mine ).Normal;
return MathF.Acos( Math.Clamp( Vector3.Dot( a, b ), -1f, 1f ) ).RadianToDegree();
}
}
/// <summary>
/// What a compiled model's RENDER mesh looks like from managed code — reflected, not assumed.
///
/// WHY REFLECTION RATHER THAN A CALL. The Wearer feature needs a body's actual surface, and the
/// only mesh reader this codebase has proven is <c>Model.Physics</c>, which is a collision hull:
/// far too coarse to cut a collar out of. s&box's render-mesh API has moved between SDK versions,
/// so this prints the members that exist in the engine actually running rather than the ones the
/// docs last described — same argument EffigySkeletonProbe makes for existing at all.
///
/// effigy_model_probe citizen, members only
/// effigy_model_probe models/citizen/citizen.vmdl any model
/// </summary>
public static class EffigyModelProbe
{
const string Citizen = "models/citizen/citizen.vmdl";
[ConCmd( "effigy_model_probe" )]
public static void Probe( string path = Citizen )
{
if ( string.IsNullOrWhiteSpace( path ) )
path = Citizen;
var model = Model.Load( path );
if ( model is null || model.IsError )
{
Log.Error( $"[mesh] could not load {path}" );
return;
}
Log.Info( $"[mesh] {path}: bones {model.BoneCount}, bounds {model.Bounds.Size}" );
var type = model.GetType();
foreach ( var member in type.GetMembers( System.Reflection.BindingFlags.Public
| System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Static )
.OrderBy( m => m.Name ) )
{
if ( member is System.Reflection.PropertyInfo prop )
Log.Info( $"[mesh] prop {prop.PropertyType.Name} {prop.Name}" );
else if ( member is System.Reflection.MethodInfo method && !method.IsSpecialName )
Log.Info( $"[mesh] method {method.ReturnType.Name} {method.Name}("
+ string.Join( ", ", method.GetParameters().Select( p => $"{p.ParameterType.Name} {p.Name}" ) ) + ")" );
}
}
}
/// <summary>
/// What an s&box `.clothing` definition actually is, reflected out of the engine that is running.
///
/// WHY THIS EXISTS. A garment is only clothing once it is a Clothing resource: a small record naming
/// a model, a category, which body slots it covers and which it hides. Every one of those is an
/// ENUM, and an exporter that writes a category or a slot the engine does not know produces an asset
/// that compiles, loads, and silently does not work. The names change between SDK versions, so this
/// prints the ones this build has rather than the ones the docs last described - same argument
/// EffigySkeletonProbe and EffigyModelProbe both make.
///
/// effigy_clothing_probe
/// </summary>
public static class EffigyClothingProbe
{
[ConCmd( "effigy_clothing_probe" )]
public static void Probe()
{
var type = TypeLibrary.GetType( "Clothing" )?.TargetType
?? System.AppDomain.CurrentDomain.GetAssemblies()
.SelectMany( a =>
{
try { return a.GetTypes(); }
catch { return System.Array.Empty<System.Type>(); }
} )
.FirstOrDefault( t => t.Name == "Clothing" && t.Namespace == "Sandbox" );
if ( type is null )
{
Log.Error( "[cloth] no Sandbox.Clothing type in this build" );
return;
}
Log.Info( $"[cloth] {type.FullName}" );
foreach ( var prop in type.GetProperties( System.Reflection.BindingFlags.Public
| System.Reflection.BindingFlags.Instance ).OrderBy( p => p.Name ) )
{
var pt = prop.PropertyType;
var inner = System.Nullable.GetUnderlyingType( pt ) ?? pt;
if ( inner.IsEnum )
{
Log.Info( $"[cloth] {prop.Name} : {inner.Name}"
+ (inner.GetCustomAttributes( typeof( System.FlagsAttribute ), false ).Length > 0 ? " [Flags]" : "")
+ " = " + string.Join( ", ", System.Enum.GetNames( inner ) ) );
}
else
{
Log.Info( $"[cloth] {prop.Name} : {pt.Name}" );
}
}
// The nested types carry the enums that are not reachable through a property's type alone.
foreach ( var nested in type.GetNestedTypes().Where( t => t.IsEnum ) )
Log.Info( $"[cloth] nested enum {nested.Name} = " + string.Join( ", ", System.Enum.GetNames( nested ) ) );
}
}