Diagnostics/SwayProbe.cs

A diagnostic component that samples weapon viewmodel sway over time and prints a summary. It captures camera angular rates, the handler's sway lag/target values, and the applied pose offsets each frame for a configurable duration, then logs peaks, ratios and recovery timings.

File Access
using Sandbox;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// Measure what a weapon actually does when you look around. `nz_sway_probe [seconds]`.
///
/// ⛔ BECAUSE THE CHAIN HAS TWO FILTERS AND READING ONE OF THEM IS WHAT WENT WRONG. The sway a
/// weapon SHOWS is not the sway the code computes: the lagged eye rotation is a low-pass of where
/// you are looking, the difference is clamped and scaled into a TARGET, and the pose is then
/// slerped toward that target at a second, slower rate. Anything that reads the target and scales
/// it is reading the middle of the chain -- which is why matching the gun needed 1200% sideways and
/// still had the wrong shape at a different turn speed.
///
/// ⚠️ MEASURED, NOT DERIVED. The constants can be read off the source, but the rates that matter are
/// per-weapon (`Weapon.AnimSpeed`) and per-state (aiming quadruples `swayspeed`, the ADS ramp scales
/// the pose rate to zero and back), and this project has a history of four reasoned guesses in a row
/// being wrong. So this samples the live values and prints what actually happened.
/// </summary>
public sealed class SwayProbe : Component
{
	record Sample(
		float T, float YawRate, float PitchRate,
		float LagYaw, float LagPitch,
		float TargetYaw, float TargetRight,
		float AppliedYaw, float AppliedPitch, float AppliedRight );

	readonly List<Sample> samples = new();

	SWB.Base.Weapon weapon;
	SWB.Base.ViewModelHandler handler;

	float duration = 4f;
	RealTimeSince since;
	float lastYaw, lastPitch;
	bool primed;

	protected override void OnStart()
	{
		weapon = Scene.GetAllComponents<SWB.Base.Weapon>()
			.FirstOrDefault( w => w.IsValid() && w.GameObject.Enabled );
		handler = weapon?.ViewModelHandler;
		since = 0;

		if ( handler is null )
		{
			Log.Warning( "[sway] no viewmodel handler — hold a weapon and try again" );
			Destroy();
		}
	}

	protected override void OnUpdate()
	{
		if ( handler is null || !weapon.IsValid() ) { Report(); Destroy(); return; }

		var cam = weapon.Owner?.Camera;
		if ( !cam.IsValid() ) return;

		var ang = cam.WorldRotation.Angles();
		var dt = RealTime.Delta;

		// ⚠️ PRIME ON THE FIRST FRAME. Without it the first sample reports a turn rate computed
		// against an uninitialised angle — a huge spike that dominates every peak in the summary.
		if ( !primed )
		{
			lastYaw = ang.yaw; lastPitch = ang.pitch; primed = true;
			return;
		}

		// ⚠️ Wrapped, or crossing 180 reads as a 360-degree-per-frame turn.
		var dYaw = MathX.DegreeToRadian( ang.yaw - lastYaw );
		var yawRate = MathX.RadianToDegree( System.MathF.Atan2( System.MathF.Sin( dYaw ), System.MathF.Cos( dYaw ) ) ) / System.MathF.Max( dt, 0.0001f );
		var pitchRate = (ang.pitch - lastPitch) / System.MathF.Max( dt, 0.0001f );
		lastYaw = ang.yaw; lastPitch = ang.pitch;

		// ⛔ THE APPLIED SWAY IS THE DIFFERENCE BETWEEN THE TWO POSES, not the published target.
		// This is the end of the chain — what is actually on screen.
		var vm = weapon.ViewModelRenderer;
		var applied = Angles.Zero;
		var appliedRight = 0f;

		if ( vm.IsValid() && vm.GameObject is not null )
		{
			var gun = vm.GameObject.WorldTransform;
			var free = handler.SwayFreeTransform;
			applied = (free.Rotation.Inverse * gun.Rotation).Angles().Normal;
			// ⚠️ y is LEFT in Source, so negate for a right-positive number that matches the
			// handler's own (right, forward, up) sway vector.
			appliedRight = -(free.Rotation.Inverse * (gun.Position - free.Position)).y;
		}

		samples.Add( new Sample( since, yawRate, pitchRate,
			handler.SwayLag.yaw, handler.SwayLag.pitch,
			handler.SwayRotOffset.y, handler.SwayPosOffset.x,
			applied.yaw, applied.pitch, appliedRight ) );

		if ( since >= duration ) { Report(); Destroy(); }
	}

	void Report()
	{
		if ( samples.Count < 4 ) { Log.Info( "[sway] not enough samples" ); return; }

		Log.Info( $"[sway] ── {samples.Count} frames over {samples[^1].T:0.##}s ─────────────────" );
		Log.Info( $"[sway] swayspeed={handler.SwaySpeed:0.##}/s (eye lag)   posespeed={handler.PoseSpeed:0.##}/s (pose follow)"
			+ $"   aiming={weapon.IsAiming}  animSpeed={weapon.AnimSpeed:0.##}" );
		Log.Info( "[sway]" );
		Log.Info( "[sway]    t     yaw/s    lag    target   applied   ratio" );

		// ⚠️ Thinned to ~40 rows. A 4-second capture is 240+ frames, and a console dump that long
		// scrolls its own summary away.
		var step = System.Math.Max( 1, samples.Count / 40 );
		for ( int i = 0; i < samples.Count; i += step )
		{
			var s = samples[i];
			var ratio = System.MathF.Abs( s.TargetYaw ) > 0.01f ? s.AppliedYaw / s.TargetYaw : 0f;
			Log.Info( $"[sway]  {s.T,5:0.00} {s.YawRate,8:0.#} {s.LagYaw,7:0.##} {s.TargetYaw,8:0.###} {s.AppliedYaw,9:0.###} {ratio,7:0.##}" );
		}

		// ── what the numbers mean ────────────────────────────────────────────
		var peak = samples.OrderByDescending( s => System.MathF.Abs( s.AppliedYaw ) ).First();
		var peakRate = samples.OrderByDescending( s => System.MathF.Abs( s.YawRate ) ).First();
		var peakTarget = samples.OrderByDescending( s => System.MathF.Abs( s.TargetYaw ) ).First();

		Log.Info( "[sway]" );
		Log.Info( $"[sway] peak turn rate   {peakRate.YawRate,8:0.#} deg/s   at t={peakRate.T:0.00}" );
		Log.Info( $"[sway] peak eye lag     {samples.Max( s => System.MathF.Abs( s.LagYaw ) ),8:0.##} deg"
			+ $"   (clamps the tilt above {SWB.Base.ViewModelHandler.SwayRotLimit / 0.2f:0.#} deg of lag)" );
		Log.Info( $"[sway] peak TARGET yaw  {peakTarget.TargetYaw,8:0.###} deg   at t={peakTarget.T:0.00}" );
		Log.Info( $"[sway] peak APPLIED yaw {peak.AppliedYaw,8:0.###} deg   at t={peak.T:0.00}"
			+ $"   ({(System.MathF.Abs( peakTarget.TargetYaw ) > 0.001f ? peak.AppliedYaw / peakTarget.TargetYaw : 0f):0.0%} of target)" );
		Log.Info( $"[sway] peak APPLIED shift {samples.Max( s => System.MathF.Abs( s.AppliedRight ) ),6:0.###} units sideways" );

		// ⚠️ THE LAG BETWEEN THE TWO PEAKS IS THE POINT. If applied peaks well after target, the pose
		// filter is what you are fighting — and no gain applied to the target can put that back.
		Log.Info( $"[sway] target->applied delay {(peak.T - peakTarget.T) * 1000f,6:0.#} ms" );

		// decay: from the applied peak, how long to fall to 1/e of it
		var after = samples.SkipWhile( s => s.T < peak.T ).ToList();
		var threshold = System.MathF.Abs( peak.AppliedYaw ) * 0.368f;
		var decayed = after.FirstOrDefault( s => System.MathF.Abs( s.AppliedYaw ) <= threshold );
		Log.Info( decayed is not null
			? $"[sway] recovery       {(decayed.T - peak.T) * 1000f,6:0.#} ms to fall to 1/e"
				+ $"  -> effective rate {1f / System.MathF.Max( decayed.T - peak.T, 0.0001f ):0.#}/s"
			: "[sway] recovery       never fell to 1/e inside the capture — turn, then STOP and wait" );
	}

	/// <summary>
	/// `nz_sway_probe [seconds]` — capture, then look around, and it prints what the gun did.
	/// </summary>
	[ConCmd( "nz_sway_probe" )]
	public static void Start( float seconds = 4f )
	{
		var scene = Game.ActiveScene;
		if ( scene is null ) { Log.Warning( "[sway] no scene" ); return; }

		foreach ( var old in scene.GetAllComponents<SwayProbe>().ToList() )
			old.Destroy();

		var player = NZPlayer.Local;
		var host = player.IsValid() ? player.GameObject : scene.CreateObject();

		var probe = host.Components.Create<SwayProbe>();
		probe.duration = seconds.Clamp( 0.5f, 20f );

		Log.Info( $"[sway] capturing {probe.duration:0.#}s — turn left and right at a few speeds," );
		Log.Info( "[sway] then STOP and hold still so the recovery can be measured." );
	}
}