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