Diagnostics/BoneTravelProbe.cs

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.

File Access
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" );
	}
}