Diagnostics/RecoilWeaponProbe.cs

Console command module that probes the local players held weapon and prints its effective recoil numbers. It computes per-shot vertical and horizontal recoil (hip and ADS), one-second climb rates, and warns if prefab multipliers look unstamped.

NetworkingFile Access
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// `nz_recoil_weapon` — what the gun in your hands actually does, per shot, in degrees.
///
/// ⛔ IT EXISTS BECAUSE THE PREFAB NUMBER IS FOUR MULTIPLIES AWAY FROM THE FELT ONE.
/// `RecoilVerticalMult` is stamped per weapon, then `VerticalBase`, then `RecoilScale`, then
/// `RecoilAutoControl`, then the 0.4x aiming damp — and perks, tech, Double Tap, Overpressure
/// and Railgun all sit on top at fire time. Reading "1.45" off a prefab and calling it the
/// recoil has misled every recoil conversation this project has had; `nz_recoil` prints the
/// delivered figure for the BASE and this prints it for the WEAPON.
///
/// ⚠️ IT IS ALSO THE ONLY WAY TO CONFIRM THE STAMP REACHED THE GAME. `Tools/recoil_mults.py`
/// writes JSON into 496 prefab files; whether the running session has reloaded those assets is
/// a different question, and one no amount of re-reading the files can answer.
/// </summary>
public static class RecoilWeaponProbe
{
	/// <summary>
	/// The local player's active weapon.
	///
	/// ⚠️ `PlayerPresence.Find()`, NOT a FirstOrDefault over every player in the scene — this is
	/// a console command run by one person about their own gun, and in a four-player game the
	/// scene-wide version would answer about somebody else's. SurvivalHud uses the same pair of
	/// lookups for the same reason.
	/// </summary>
	static SWB.Base.Weapon Held()
	{
		var go = PlayerPresence.Find();
		if ( !go.IsValid() ) return null;

		return go.Components.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants )
			.FirstOrDefault( w => w.IsValid() && w.GameObject.Enabled );
	}

	[ConCmd( "nz_recoil_weapon" )]
	public static void Cmd()
	{
		var w = Held();
		if ( !w.IsValid() || w.Primary is null )
		{
			Log.Info( "[nz-recoil] no weapon in hand — hold one and run this again." );
			return;
		}

		var si = w.Primary;
		var rpm = si.RPM > 0f ? si.RPM : 1f;

		// ⚠️ THE SAME CHAIN `GetRecoilAngles` WALKS, IN THE SAME ORDER, and deliberately written
		// out rather than factored into a shared helper. A helper would have to be called from
		// the hot path too, and the point of this command is to be an INDEPENDENT reading — a
		// probe that shares its arithmetic with the thing under test cannot disagree with it.
		var control = 1f - Math.Clamp( si.RecoilAutoControl, 0f, 1f );
		var useBase = GlobalHandling.UseRecoilBase;

		var up = (useBase ? GlobalHandling.VerticalBase * si.RecoilVerticalMult : si.RecoilUp)
			* GlobalHandling.RecoilScale * control;

		var sideAmount = useBase
			? GlobalHandling.HorizontalBase * si.RecoilHorizontalMult
			: si.RecoilSide > 0f ? si.RecoilSide : si.RecoilRandomSide;
		var side = sideAmount * GlobalHandling.RecoilScale * control;

		Log.Info( $"[nz-recoil] {w.DisplayName}   {rpm:0} rpm   "
			+ (useBase ? "BASE MODE" : "authored (base mode is OFF)") );

		Log.Info( $"[nz-recoil]   multipliers   vertical x{si.RecoilVerticalMult:0.00}"
			+ $"   horizontal x{si.RecoilHorizontalMult:0.00}"
			+ (useBase ? "" : "   ⚠ unread — base mode is off") );

		Log.Info( $"[nz-recoil]   per shot      up {up:0.000}° hip / {up * 0.4f:0.000}° ads"
			+ $"   ·   side ±{side:0.000}° hip / ±{side * 0.4f:0.000}° ads" );

		// ⚠️ A SECOND OF HELD TRIGGER, because degrees-per-shot is not a unit anyone can picture
		// and the whole point of the climb-hold change is what a SUSTAINED burst does. This is
		// the figure to compare between two weapons.
		var perSec = up * (rpm / 60f);
		var sticks = 1f - GlobalHandling.RecoilRecoverFraction;

		Log.Info( $"[nz-recoil]   one second    climbs {perSec:0.0}° hip / {perSec * 0.4f:0.0}° ads"
			+ $"   ·   {perSec * sticks:0.0}° of that is permanent" );

		// ⛔ AND THE STAMP'S OWN FAILURE MODE, NAMED. `ShootInfo` defaults both multipliers to 1f,
		// so a prefab the stamp never reached and a weapon deliberately left at the base read
		// identically — except that the stamp gives no weapon exactly 1.00 unless its fire rate
		// is the fleet median AND its class is medium. Two 1.00s is worth a second look.
		if ( si.RecoilVerticalMult == 1f && si.RecoilHorizontalMult == 1f )
			Log.Warning( "[nz-recoil]   both multipliers are exactly 1.00 — this weapon may not"
				+ " have been stamped, or the session has not reloaded the prefab."
				+ " Re-run Tools/recoil_mults.py and restart play mode." );
	}
}