A diagnostic component that measures how much specified bones in a weapon viewmodel move over time. It samples world and root-relative positions for listed arm bones and driver bones for a set duration, then logs rigid vs articulated travel and a worst-case hand merge distance.
using Sandbox;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// DOES THE ARM CHAIN ARTICULATE, OR JUST RIDE ALONG? `nz_bone_travel [seconds]`.
///
/// ⛔ GROSS TRAVEL IS A TRAP, AND IT ALREADY FOOLED THIS INVESTIGATION ONCE. Measuring each bone in
/// renderer space showed all 41 arm bones "moving" and looked like proof the animation played. It
/// was not: every bone in the chain travelled nearly the same distance — G41 4.85 to 5.95,
/// Spine4 included, whose own motion in the source clip is exactly zero. That is the whole arm tree
/// translating with the viewmodel, not an arm bending.
///
/// ⚠️ SO EVERYTHING IS MEASURED RELATIVE TO THE CHAIN'S OWN ROOT. A bone's position inside Spine4's
/// space cannot change unless a joint between them actually rotates, so the second column is
/// articulation and nothing else. A reload moves a hand a long way in that space; a rigid tree
/// reports zero no matter how far the gun swings.
/// </summary>
public sealed class BoneTravelProbe : Component
{
public SkinnedModelRenderer Gun { get; set; }
public SkinnedModelRenderer Hands { get; set; }
public float Seconds { get; set; } = 6f;
// ⚠️ s&box reports bone names with UNDERSCORES — the source rigs call them ValveBiped.Bip01_*
// and the dot is rewritten somewhere in the export/compile round trip.
// ⚠️ THE TWO RIG FAMILIES HANG THEIR ARMS OFF DIFFERENT BONES. The low-poly rigs root the
// chain at Spine4; the CoD rigs have no Spine4 at all and drive theirs from tag_torso. The
// control run is worthless if it silently reports "no root", so try both.
static readonly string[] ArmRoots = { "ValveBiped_Bip01_Spine4", "tag_torso" };
static readonly string[] Watch = {
"ValveBiped_Bip01_L_Clavicle", "ValveBiped_Bip01_L_UpperArm",
"ValveBiped_Bip01_L_Forearm", "ValveBiped_Bip01_L_Hand",
"ValveBiped_Bip01_R_UpperArm", "ValveBiped_Bip01_R_Forearm", "ValveBiped_Bip01_R_Hand",
};
// The CoD rigs drive their arms through these instead, and they are the control: if a CoD gun
// also reports a rigid ValveBiped chain, its arms move some other way entirely.
static readonly string[] Drivers = { "j_shoulder_le", "j_elbow_le", "j_wrist_le", "tag_weapon", "weapon", "mag_e" };
readonly Dictionary<string, Vector3> min = new(), max = new();
float elapsed;
int frames;
float mergeApart;
static bool World( SkinnedModelRenderer r, string bone, out Transform t )
{
t = default;
if ( !r.IsValid() || r.Model is null ) return false;
var b = r.Model.Bones.GetBone( bone );
return b is not null && r.TryGetBoneTransform( b, out t );
}
void Accumulate( string key, Vector3 p )
{
if ( min.TryGetValue( key, out var lo ) )
{
min[key] = Vector3.Min( lo, p );
max[key] = Vector3.Max( max[key], p );
}
else { min[key] = p; max[key] = p; }
}
protected override void OnUpdate()
{
if ( !Gun.IsValid() ) { Log.Info( "[travel] weapon went away" ); GameObject.Destroy(); return; }
Transform root = default;
var hasRoot = ArmRoots.Any( n => World( Gun, n, out root ) );
foreach ( var name in Watch.Concat( Drivers ) )
{
if ( !World( Gun, name, out var t ) ) continue;
Accumulate( name + "|rigid", Gun.WorldTransform.PointToLocal( t.Position ) );
if ( hasRoot )
Accumulate( name + "|joint", root.PointToLocal( t.Position ) );
if ( World( Hands, name, out var th ) )
mergeApart = System.MathF.Max( mergeApart, t.Position.Distance( th.Position ) );
}
frames++;
elapsed += Time.Delta;
if ( elapsed < Seconds ) return;
Report();
GameObject.Destroy();
}
float Travel( string key ) => min.ContainsKey( key ) ? (max[key] - min[key]).Length : -1f;
void Report()
{
Log.Info( $"[travel] {frames} frames over {elapsed:0.0}s on {Gun.Model?.ResourcePath}" );
Log.Info( $"[travel] hands merge worst gap = {mergeApart:0.000}" );
Log.Info( "[travel] bone rigid ARTICULATED" );
foreach ( var name in Watch.Concat( Drivers ) )
{
var r = Travel( name + "|rigid" );
if ( r < 0 ) continue;
var j = Travel( name + "|joint" );
Log.Info( $"[travel] {name,-30} {r,7:0.000} {(j < 0 ? " no root" : j.ToString( "0.000" )),13}" );
}
}
[ConCmd( "nz_bone_travel" )]
public static void Run( float seconds = 6f )
{
var scene = Game.ActiveScene;
if ( scene is null ) { Log.Info( "[travel] no scene" ); return; }
var w = scene.GetAllComponents<SWB.Base.Weapon>().FirstOrDefault( x => x.IsValid() );
if ( w is null ) { Log.Info( "[travel] no weapon equipped" ); return; }
var vm = w.ViewModelRenderer;
if ( !vm.IsValid() ) { Log.Info( "[travel] weapon has no viewmodel renderer" ); return; }
var go = scene.CreateObject();
go.Name = "bone_travel_probe";
var probe = go.Components.Create<BoneTravelProbe>();
probe.Gun = vm;
probe.Hands = w.ViewModelHandsRenderer ?? w.ViewModelHandler?.ViewModelHandsRenderer;
probe.Seconds = seconds;
Log.Info( $"[travel] sampling for {seconds:0.0}s — RELOAD NOW" );
}
}