Weapon partial class getters and recoil/accuracy logic. Provides methods to find effect renderers and muzzle attachments, compute muzzle transform/object with fallback, determine shoot animations and ammo state, compute real RPM and spread with many gameplay modifiers, and compute and queue recoil (pattern, randomness, recovery) with detailed per-perk and tech handling.
using SWB.Base.Attachments;
using SWB.Shared;
using System;
using System.Collections.Generic;
namespace SWB.Base;
public partial class Weapon
{
public virtual SkinnedModelRenderer GetEffectRenderer()
{
SkinnedModelRenderer effectModel = WorldModelRenderer;
if ( CanSeeViewModel )
effectModel = ViewModelRenderer;
return effectModel;
}
/// <summary>
/// Gets the visible muzzle renderer and attachment name
/// </summary>
public virtual (SkinnedModelRenderer, string) GetMuzzleEffectDetails()
{
var activeAttachment = GetActiveAttachmentForCategory( AttachmentCategory.Muzzle );
var effectRenderer = GetEffectRenderer();
var effectAttachment = "muzzle";
if ( activeAttachment is not null )
{
effectAttachment = activeAttachment.EffectAttachmentOrBone;
// If we have custom models use the attachment from those models instead of the main weapon one
if ( CanSeeViewModel && activeAttachment.ViewModelRenderer is not null )
{
effectRenderer = activeAttachment.ViewModelRenderer;
}
else if ( !CanSeeViewModel && activeAttachment.WorldModelRenderer is not null )
{
effectRenderer = activeAttachment.WorldModelRenderer;
}
}
return (effectRenderer, effectAttachment);
}
/// <summary>
/// Gets the muzzle attachment transform, with this weapon's `MuzzleOffset` applied.
/// </summary>
///
/// ⛔ THE OFFSET IS APPLIED HERE SO EVERY CONSUMER GETS IT, AND THAT IS THE WHOLE POINT.
/// This is where the TRACER starts, where a physical bullet spawns and where the laser
/// attachment draws from — and only the flash and the barrel smoke were reading `MuzzleOffset`
/// before. Tuning a weapon moved its flash and left the round leaving from the old spot. On
/// the Prisma, whose entire shot is a visible pulse crossing the gap, a flash and a bolt
/// starting in different places is not a subtlety.
///
/// ⚠️ `GetMuzzleObject()` DELIBERATELY STAYS RAW, SO THE TWO DISAGREE ON PURPOSE. That object
/// is what particles are PARENTED to so they follow the gun as it moves; the offset reaches
/// them as a local pose handed over alongside it — `muzzlePose` in `SpawnMuzzleEffects`.
/// Applying it here as well would move those effects twice.
///
/// ⚠️ ZERO SHORT-CIRCUITS. An untuned weapon returns the attachment untouched rather than
/// composing an identity transform onto it, so nothing in the pack pays for a feature it does
/// not use, and the 131 viewmodels with no `muzzle` attachment still get their null.
public virtual Transform? GetMuzzleTransform()
{
var muzzleDetails = GetMuzzleEffectDetails();
if ( muzzleDetails.Item1?.GetAttachment( muzzleDetails.Item2 ) is not Transform t )
return null;
if ( MuzzleOffset.Pos.Length < 0.0001f && MuzzleOffset.Angle == Angles.Zero )
return t;
return t.ToWorld( new Transform( MuzzleOffset.Pos, MuzzleOffset.Angle.ToRotation() ) );
}
/// <summary>
/// Gets the muzzle attachment gameobject
/// </summary>
public virtual GameObject GetMuzzleObject()
{
var muzzleDetails = GetMuzzleEffectDetails();
var obj = muzzleDetails.Item1?.GetAttachmentObject( muzzleDetails.Item2 );
// ⛔ 131 OF 521 VIEWMODELS HAVE NO `muzzle` ATTACHMENT, AND WITHOUT THIS THEY GET NO
// MUZZLE EFFECTS AT ALL. `SpawnMuzzleEffects` opens with `if ( muzzleObj is null ) return;`
// — one guard covering the flash, the Pack-a-Punch flash, the barrel smoke AND the tracer,
// which is why they were reported missing together: *"not all weapons have muzzle flash and
// tracer, especially destiny weapons but not only those"*.
//
// The 390 that work bind `muzzle` to a bone the port pipeline adds called `MuzzleBone`.
// The other 131 have an EMPTY `AttachmentList` and no such bone — the pipeline built the
// structure and had nothing to anchor it to. So there is no data fix without authoring a
// barrel-tip coordinate for each of 131 models by hand, and `Tools/fix_attachments.py`,
// which exists for exactly this, now skips all 521 because the decompiled model data it
// reads is no longer on disk.
//
// ⚠️ AN APPROXIMATE MUZZLE BEATS NO MUZZLE, AND ONLY FOR THE WEAPONS THAT HAVE NONE. A
// model with a real attachment never reaches this line, so nothing that currently looks
// right can be moved by it.
return obj.IsValid() ? obj : FallbackMuzzle( muzzleDetails.Item1 );
}
GameObject _fallbackMuzzle;
/// <summary>
/// A stand-in muzzle at the front of the model, for the 131 viewmodels with no attachment.
///
/// ⛔ IT WORKS IN WORLD SPACE, ALONG THE WEAPON'S OWN FORWARD, AND THAT IS WHAT MAKES IT
/// SAFE. The obvious implementation — take the model's LOCAL bounds and walk down whichever
/// local axis is the barrel — needs a convention these models do not share: they are ported
/// from several packs through ModelDoc, which the project has already been bitten by guessing
/// bone axes for (see the DMX note in INSTRUCTIONS). A viewmodel is oriented so its barrel
/// points where the player aims, so world forward is the barrel direction by construction and
/// no per-pack convention is involved.
///
/// ⚠️ THE SUPPORT FUNCTION OF THE BOX, not a corner scan. `|f·hx| + |f·hy| + |f·hz|` is
/// exactly how far the bounds extend along `f` from their centre — the same answer eight
/// corner dot-products would give, in three multiplies and without `BBox.Corners`, which is
/// one more member this project would be trusting the whitelist to allow.
///
/// ⚠️ ONE CHILD OBJECT, REUSED AND PARENTED TO THE RENDERER. Particles are spawned attached
/// to whatever this returns, so it has to be a real GameObject that follows the gun rather
/// than a position — and creating one per shot would leak an object per bullet.
///
/// ⚠️ REPOSITIONED EVERY CALL rather than cached once. The bounds move with the animation:
/// a reload that swings the gun down would leave a muzzle parked in the air if the offset were
/// computed at deploy and never revisited.
/// </summary>
GameObject FallbackMuzzle( SkinnedModelRenderer renderer )
{
if ( !renderer.IsValid() ) return null;
if ( !_fallbackMuzzle.IsValid() )
{
_fallbackMuzzle = Scene.CreateObject();
_fallbackMuzzle.Name = "muzzle (fallback)";
_fallbackMuzzle.Flags |= GameObjectFlags.NotSaved;
_fallbackMuzzle.SetParent( renderer.GameObject );
}
var bounds = renderer.Bounds;
var forward = renderer.WorldRotation.Forward;
var half = bounds.Size * 0.5f;
var reach = MathF.Abs( forward.x * half.x )
+ MathF.Abs( forward.y * half.y )
+ MathF.Abs( forward.z * half.z );
_fallbackMuzzle.WorldPosition = bounds.Center + forward * reach;
_fallbackMuzzle.WorldRotation = renderer.WorldRotation;
return _fallbackMuzzle;
}
/// <summary>
/// Gets the correct shoot animation
/// </summary>
/// <param name="shootInfo">Info used for the current attack</param>
/// <returns></returns>
public virtual string GetShootAnimation( ShootInfo shootInfo )
{
// ⛔ SUPPRESSED HERE, NOT AT ApplyShootAnimation. The caller already skips an empty name, so
// returning nothing is the whole of "do not play it" -- and it covers the aimed and empty
// variants too, which a guard further down would have to repeat three times.
//
// ⚠️ THE GUN STILL FIRES. Only the model's clip is withheld: the shot, the muzzle flash,
// the sound, the ammo and the recoil are all elsewhere. If the weapon stops moving with this
// off, the movement is IN THE CLIP and no runtime setting will reach it.
// ⚠️ BOTH GATES. The global one is the diagnostic (`nz_shootanim`); the per-weapon one is
// the authored decision. Either off means no clip.
if ( !NZombies.ShootAnimToggle.Enabled || !PlayShootAnim ) return null;
if ( IsAiming && (!string.IsNullOrEmpty( shootInfo.ShootAimedAnim )) )
{
return shootInfo.ShootAimedAnim;
}
else if ( shootInfo.Ammo == 0 && !string.IsNullOrEmpty( shootInfo.ShootEmptyAnim ) )
{
return shootInfo.ShootEmptyAnim;
}
return shootInfo.ShootAnim;
}
/// <summary>
/// If there is usable ammo left
/// </summary>
public bool HasAmmo()
{
if ( Primary.InfiniteAmmo == InfiniteAmmoType.clip )
return true;
if ( Primary.ClipSize == -1 )
{
return Owner.AmmoCount( Primary.AmmoType ) > 0;
}
if ( Primary.Ammo == 0 )
return false;
return true;
}
public ShootInfo GetShootInfo( bool isPrimary )
{
return isPrimary ? Primary : Secondary;
}
/// <summary>
/// Should this weapon start reloading on its own, right now?
///
/// ⛔️ SWB ALREADY HAD `AutoReload` AND IT NEEDED A TRIGGER PULL. The setting is on by default
/// and its check lives behind `Input.Pressed( inputButton )` in Weapon.Shoot -- so an empty gun
/// reloaded when you tried to fire it again, not when it ran dry. That is a different feature:
/// it saves you a keypress you were about to make anyway, and it still leaves you holding an
/// empty gun for as long as you do not pull the trigger. This starts the reload the moment the
/// magazine hits zero.
///
/// ⛔️ THE RESERVE CHECK IS NOT OPTIONAL, and not just an optimisation. StartReload SPEAKS when
/// it refuses -- `CharacterVoice.Say( "noammo" )` -- which is right for a player pressing R on
/// nothing and very wrong at frame rate. Without this the character would shout "no ammo" every
/// frame they held an empty gun.
///
/// ⚠️ IT ALSO COVERS SWITCHING TO AN EMPTY WEAPON, because it asks about the magazine rather
/// than about the shot that emptied it. Drawing a gun you left empty starts the reload, which
/// is the same answer to the same question.
///
/// ⚠️ `IsShooting()` KEEPS IT OFF THE TAIL OF THE LAST SHOT. Without it the reload would begin
/// in the same frame the final round left the barrel and cut its own animation short.
/// </summary>
public bool ShouldAutoReload()
{
if ( Settings is null || !Settings.AutoReload ) return false;
if ( Primary is null ) return false;
// ClipSize -1 is "feeds straight from reserve" — there is no magazine to fill.
if ( Primary.ClipSize <= 0 || Primary.Ammo > 0 ) return false;
if ( IsReloading || InBoltBack || IsShooting() ) return false;
if ( !Owner.IsValid() ) return false;
return Owner.AmmoCount( Primary.AmmoType ) > 0
|| Primary.InfiniteAmmo == InfiniteAmmoType.reserve;
}
public bool IsShooting()
{
// ⚠️ CALLS GetRealRPM TWICE when the weapon has a secondary, so this is an outer over
// wep.rpm rather than a cost of its own.
using var _cpu = NZombies.CpuScope.Measure( "wep.isshooting" );
if ( Secondary is null )
return GetRealRPM( Primary.RPM ) > TimeSincePrimaryShoot;
return GetRealRPM( Primary.RPM ) > TimeSincePrimaryShoot || GetRealRPM( Secondary.RPM ) > TimeSinceSecondaryShoot;
}
/// <summary>
/// Seconds between shots. SMALLER is faster — every fire gate compares
/// `timeSinceLastShot > GetRealRPM(rpm)`.
///
/// ⛔ DOUBLE TAP IS APPLIED HERE, AND UNTIL NOW IT WAS NOT APPLIED ANYWHERE.
/// WeaponStatsPanel carried a comment claiming "Double Tap divides the shot delay
/// in Weapon.GetRealRPM" and the panel had been corrected to match — but the
/// division was never written. The panel showed a perked RPM the gun never fired
/// at, and because the comment said the work was done, nobody re-checked it.
/// Proved by setting the multiplier to x10 and seeing no change at all.
///
/// ⚠️ NO LONGER STATIC. It could not read the owner as a static, which is
/// plausibly why the perk was never wired in the first place. All eight call sites
/// are unqualified instance calls, so they are unaffected; nothing referenced it as
/// `Weapon.GetRealRPM` outside a comment.
///
/// ⚠️ THE ONE CHOKEPOINT, deliberately — EIGHT production call sites read it:
/// Weapon.Shoot.cs:48 auto-reload check
/// Weapon.Shoot.cs:74 trigger check
/// Weapon.Shoot.cs:86 CanPrimaryAttack
/// Weapon.Shoot.cs:233 the UI shoot event
/// Weapon.Shoot.cs:344 the bolt-back timer
/// Weapon.Getters.cs:111,113 IsShooting
/// Weapon.Getters.cs:230 GetRealSpread's sustained-fire window
/// plus two diagnostics (nz_perk_prove, nz_tech_live). Applying a modifier at each
/// would be eight places to forget, and the one missed would be the gate deciding
/// whether the gun may fire at all.
///
/// ⛔ THIS LIST USED TO SAY "five separate gates" TWO LINES BELOW A CLAIM OF "all
/// eight call sites" — two different counts in one comment, neither enumerated, and
/// both written by the same hand on the same day. It is spelled out now because a
/// count nobody can check is a count that drifts, and this comment's whole job is to
/// stop someone applying a modifier somewhere else.
///
/// ⛔ AND ONE OF THE EIGHT IS NOT A FIRE GATE. Line 230 uses `GetRealRPM * 2f` as
/// GetRealSpread's "still inside the last shot's rhythm" window, so EVERY fire-rate
/// modifier here also shortens the period during which `SpreadMultShooting` applies.
/// A faster gun having a shorter rhythm is defensible, but it is a real accuracy
/// side effect that none of Double Tap, Match Trigger or Rapid Fire advertises — and
/// it is inherent to having one chokepoint, not a bug to fix at this line.
///
/// ⚠️ DIVIDES the interval rather than multiplying the RPM, so a x1.2 perk is a
/// 20% shorter gap. Multiplying `rpm` first would round through an int and lose
/// small multipliers entirely — x1.05 on a 200 RPM gun is 210, which int division
/// would flatten back toward the same interval.
///
/// ⚠️ THE TWO TIER-2 FIRE-RATE NODES LAND HERE TOO, and in this ORDER: Match
/// Trigger's flat RPM goes on before the 60/rpm conversion, Rapid Fire's percentage
/// after it. WeaponTech.cs picked +50 against +10% so the two cross at exactly 500
/// RPM — flat wins below it, percentage above — and applying them in either other
/// order moves that crossover, which is the one property the tier-2 pick is
/// designed around.
/// </summary>
public virtual float GetRealRPM( int rpm )
{
// ⛔ NOT A PER-SHOT CALL. Weapon.Shoot.cs consults this from the per-frame input handling
// (lines 127, 175, 185) and IsShooting() calls it twice more, so this runs on every frame
// the trigger is held. Inside it are TWO `*For( this )` resolvers, each of which walks the
// hierarchy with Components.Get<NZPlayer>( InAncestors ) before it can answer.
using var _cpu = NZombies.CpuScope.Measure( "wep.rpm" );
// ⚠️ GUARDS THE AUTHORED RPM, BEFORE Match Trigger's addend. Adding first would
// hand an unauthored (0 or negative) RPM a working 50 RPM off the back of a node,
// so a weapon with no fire rate authored would start firing once the node is
// bought — a stat that came from nowhere rather than from the prefab.
if ( rpm <= 0 ) return 0f;
// ⛔ THE FLAT NODE IS ADDED IN RPM, NOT IN SECONDS. `interval` below is a
// duration, so adding 50 to it would be "+50 seconds between shots" — the node
// stated as +50 RPM would stall the gun for most of a minute.
//
// ⚠️ `ifAbsent: 0f` PASSED EXPLICITLY — Factor's default is 1, the neutral value
// for the multiply below, and inheriting it here would give every weapon that has
// NOT bought Match Trigger a free +1 RPM.
//
// ⚠️ A FLOAT ADD ONTO THE INT, deliberately not written back into `rpm` — the
// int round-trip is the trap the ⚠️ above already records for the multiplier.
// ⚠️ HAIR TRIGGER RIDES THE SAME FLAT-RPM SITE as tier 2's Match Trigger, because it is
// the same kind of number: rounds per minute added, not a multiplier. Its +1000 exceeds
// every authored rate on the roster, so in practice the gate stops binding and the weapon
// fires as fast as it is asked to — which, being semi-auto by then, means as fast as the
// player clicks.
var effectiveRpm = rpm
+ NZombies.TechEffects.Factor( this, "t2_rpm_flat", 0f )
+ NZombies.TechEffects.Factor( this, "t4_hairtrigger", 0f )
// ⚠️ THE PER-CLASS AUGMENTS' FLAT RPM (2026-10-04), the same kind of addend: Fast Cycle +100 (manual
// actions), and Scout's +1000, which is Hair Trigger's own "as fast as you can click".
+ NZombies.TechStats.Add( this, "s.rpm+" );
var interval = 60f / effectiveRpm;
// ⚠️ The owner lookup lives in PerkEffects.OwnerOf, not here — this was the
// fourth hand-written copy of it. See the note there.
var mult = NZombies.PerkEffects.FireRateMultiplierFor( this );
// ⛔ DOUBLE TAP'S RATE AUGMENTS FOLD INTO THE SAME TERM, and putting them here
// rather than at a call site is what makes them appear on the stats card for free:
// WeaponStatsPanel's Rate of fire row asks this very method, precisely so it cannot
// hold a second copy of the arithmetic to be wrong about. That row's own comment
// records the months when it promised 720 RPM on a gun that fired 600.
//
// ⚠️ MULTIPLIED INTO `mult`, not applied as a second divide. One division keeps
// this on the same footing as the perk and the two tier-2 nodes, and a second
// `interval /=` would be a fourth place for the crossover order to be argued about.
// ⚠️ DOUBLE TAP'S RATE AUGMENT, isolated from the perk and tech terms around it. The
// resolver walks the hierarchy to find the player on every call.
using ( NZombies.CpuScope.Measure( "dtap.rate" ) )
mult *= NZombies.DtapAugments.FireRateMultiplierFor( this );
// ⛔ SPEED COLA'S M3 "ADRENALINE" — THE HALF THE ORIGINAL HAD TO STUB. Its own note
// says why: its stat engine applies a FLAT multiplier re-evaluated only on
// augment-change, so it "cannot re-evaluate per shot as the clip drains" and would
// have granted an always-on +20% instead of "the first 20% of the mag". It shipped
// the damage half alone rather than break the balance.
//
// ⚠️ HERE IT IS FREE, because this method is consulted PER SHOT and already reads
// live clip state for Double Tap's Trigger Discipline. The condition costs one
// comparison.
mult *= NZombies.SpeedColaAugments.AdrenalineFor( this );
if ( mult > 0f ) interval /= mult;
// ⛔ RAPID FIRE DIVIDES, LIKE DOUBLE TAP. `interval` is seconds and smaller is
// faster, so multiplying by 1.10 here would make the gun 10% SLOWER while the
// catalogue and `nz_tech` both advertise +10% fire rate.
//
// ⚠️ Kept OUT of `mult` above so it does not ride on that guard: a Double Tap
// factor of 0 makes the perk fall back to the unmodified interval, and folding
// the two together would silently drop Rapid Fire with it. TechEffects.Factor
// cannot return 0 for a multiplier node — absent is 1 and Amped clamps to 0.01.
interval /= NZombies.TechEffects.Factor( this, "t2_rpm_pct" );
// ⛔ BOLT GUN'S x0.2 IS THE ONE FIRE-RATE NODE THAT CANNOT BE SPAWN-TIME. The
// authored Micro-Burst pairing is "two rounds at the weapon's NORMAL rate, then a
// delay of five times the normal shot interval" — an `si.RPM *= 0.2` at spawn makes
// BOTH rounds slow and there is no way to get the first two back. Read here against
// `burstCount` it is exact: the pairing resolves to a 2-round burst, rounds one and
// two are gated at `burstCount` 0 and 1... which is where the ⚠️ below matters.
//
// ⚠️ `burstCount == 0` IS THE INTER-BURST GATE, NOT THE FIRST ROUND. CanShoot
// increments the counter BEFORE the shot, so the wait that is tested at 0 is the one
// BEFORE a burst begins and the wait tested at 1 is the one between its two rounds.
// Slowing only the former gives exactly the authored cadence, and Bolt Gun ALONE is
// unaffected by the test because semi fire never touches `burstCount`.
//
// ⚠️ LAST, AND THE ORDER IS IMMATERIAL — every term above is a multiply or a divide
// on `interval`. It is written last because "and then everything is five times
// slower" is what the node is, and because a reader looking for why a gun is slow
// should find it on the last line rather than folded into the tier-2 chain.
//
// ⚠️ ONE DOCUMENTED SIDE EFFECT, per this method's own header: GetRealSpread's
// sustained-fire window is `GetRealRPM * 2f`, so a five-times interval also keeps
// `SpreadMultShooting` applied five times longer after each shot, makes `IsShooting`
// report true five times longer, and delays the auto-reload check in CanShoot.
// Inherent to having one chokepoint; accepted, not fixed here.
if ( burstCount == 0 && NZombies.TechEffects.Has( this, "t4_boltgun" ) )
{
// ⚠️ DIVIDES, because `interval` is seconds and the catalogue states a RATE.
// Rate and interval are reciprocals and this is where that bites: x0.2 fire rate
// is a LONGER wait. Multiplying here would make the node fire five times FASTER
// while `nz_tech` printed x0.2 — and 0.2 is a plausible enough number that
// nothing would flag it.
//
// ⚠️ Neutral fallback of 1, so an undeclared `rate` leaves the gun at its
// authored cadence and warns once, rather than hiding a 0.2 at this call site.
// ⛔ `rpm`, NOT `rate` (fixed 2026-10-04). The catalogue declares Bolt Gun's x0.2 as `rpm`, and this line
// asked for an undeclared `rate`, so the node fell back to 1 and the gun kept its full fire rate while
// `nz_tech` printed a fifth. Found while moving Bolt Gun into the marksman set.
var rate = NZombies.WeaponTech.MagOf( "t4_boltgun", "rpm", 1f );
if ( rate > 0f ) interval /= rate;
}
// ⚠️ THE PER-CLASS AUGMENTS' READ-TIME RATE (2026-10-04): Momentum's climb in one trigger hold, Unload's x10. A RATE,
// so it divides, like Rapid Fire above.
interval /= ClassTechRate();
return interval;
}
public virtual float GetRealSpread( float baseSpread = -1 )
{
if ( !Owner.IsValid() ) return 0;
float spread = baseSpread != -1 ? baseSpread : Primary.Spread;
float floatMod = 1f;
// ⛔ THE PER-CLASS AUGMENTS' HIP FIRE (2026-10-04). Gunslinger's is "perfectly accurate", so a hip shot has no
// cone at all; Run 'n' Gun's is "as tight as aiming", so every aimed term below also applies to a hip shot.
// `aimed` stands in for `IsAiming` in each of them, and nowhere else: the pose, the sensitivity and the move
// speed still follow the real aim.
if ( !IsAiming && NZombies.TechStats.Flag( this, "f.hipzero" ) ) return 0f;
var aimed = IsAiming || NZombies.TechStats.Flag( this, "f.hipaimed" );
// Ducking
if ( IsCrouching && !aimed )
floatMod -= 0.25f;
// Aiming
// ⚠️ THE GUN'S OWN BULLETS, WITHOUT FRESH MAG'S EXTRA PELLETS (1–8 rounds, tier 3, 2026-10-04, `Weapon.MagTech.cs`): they never
// take a one-bullet gun's aimed cone away, which would be the node's downside on the shot it pays.
if ( aimed && Primary.Bullets - _freshPellets == 1 )
floatMod *= AimInfo.SpreadModifier;
// ── ARC9 accuracy terms ─────────────────────────────────────────────
//
// ⚠️ ADDITIVE first, then MULTIPLICATIVE — the order ARC9 uses. Applying
// the mults first would scale the hipfire/movement penalties too, so a
// sights bonus would shrink a penalty it has nothing to do with.
var si = Primary;
var moving = Owner.IsValid() && Owner.Velocity.WithZ( 0 ).Length > 10f;
// ⛔ POINT SHOOTING SCALES THE HIPFIRE TERM, NOT THE FINISHED SPREAD. Every one
// of the 31 weapon prefabs ships `Spread` at 0.0, so the whole of a gun's real
// inaccuracy is this one 0.077–0.459 addend — which means a node applied to the
// return value would be indistinguishable from Deadshot's `handling` below and
// would help a player who is AIMING, where SpreadAddHipFire is never added at all.
// A hipfire node that improves ADS accuracy is not the node the catalogue sells.
//
// ⛔ BULL BARREL SCALES THE SAME ADDEND POINT SHOOTING DOES, AND ON THIS WEAPON THAT
// IS THE WHOLE CONE. The node also removes ADS (Weapon.cs), so `!IsAiming` is always
// true for it — this term is the only spread it ever has, and x3 of the Galil's
// authored 0.229546 is 0.6886. Scaling the finished return instead would have looked
// identical in the code and been wrong for the reason the ⛔ above gives.
//
// ⚠️ COMPOSES WITH POINT SHOOTING BY MULTIPLICATION rather than replacing it, so a
// tier-1 pick still helps: x3 x 0.8 = x2.4.
//
// ⚠️ `Has` + a NAMED magnitude, never `Factor` — `Factor( "t4_bullbarrel" )` is 2.5,
// the DAMAGE half, and x2.5 spread against x3 is not a difference any eye can see.
// ⚠ DEADSHOT'S m4 "HIP PRECISION" JOINS THE HIPFIRE TERM, not the finished spread,
// because a factor on the total would tighten ADS accuracy too — which the augment
// explicitly does not do.
//
// ⛔ THE REASON THIS ONCE GAVE WAS FALSE and is corrected below: it claimed `Spread` is
// 0.0 on all 31 prefabs. It is not — every prefab authors a non-zero one. The augment's
// placement was right anyway, which is exactly how a wrong premise survives.
// ⛔ THE PROJECT-WIDE HIP-SPREAD MULTIPLIER JOINS THIS TERM, not the finished spread.
//
// ⛔ `Spread` IS **NOT** ZERO ON THE PREFABS — THE OLD NOTE HERE WAS WRONG. Measured across
// all 31 weapon prefabs: every one authors a non-zero primary `Spread` (galil 0.00204,
// awm 0.00147, hs10 0.0548). What is true is that it is TINY next to the hip-fire addend
// — 0.002 against 0.2295 on the galil, under 1% of hip spread — so the old claim happened
// to give the right answer for the wrong reason.
//
// ⚠ THE CONCLUSION STILL HOLDS, ON BETTER GROUNDS: a factor on the finished spread would
// also tighten or widen ADS, and "hip fire spread" means hip fire. Riding the `!IsAiming`
// addend is what makes that true by construction rather than by luck.
if ( !aimed ) spread += si.SpreadAddHipFire
* NZombies.GlobalHandling.HipSpreadScale
* NZombies.TechEffects.Factor( this, "t1_hipspread" )
* NZombies.DeadshotAugments.HipSpreadFor( this )
* BullBarrelSpread()
// ⛔ AUTOLOADER'S CUT RIDES THE HIPFIRE ADDEND, WHERE A SHOTGUN'S CONE ACTUALLY LIVES.
// The node converts a spread weapon into a single-pellet automatic, and a quarter of
// `SpreadAddHipFire` is what "reduces spread to 25%" means on the guns it applies to —
// scaling the finished return would also tighten ADS, which `Spread` already handles and
// which the single pellet now unlocks for free (see the `Bullets == 1` gate above).
* AutoloaderSpread();
// ⚠️ TIERS 1–3 (2026-10-04, `Weapon.ClassTech.cs`): Slide Fire leaves out what sliding and jumping add, Steady Sling what
// walking adds while aimed, and Locked In what sustained fire adds while aimed — `aimed`, as for every aimed term here.
// Each only REMOVES its own terms, so it stacks with every multiplier of the cone (`s.spread`, Point Shooting…) and
// with Run 'n' Gun, whose aimed hip cone is still the cone the rest starts from.
var slideFire = SlideFireOn();
var stillMove = slideFire || SteadySlingOn( aimed );
if ( Owner.IsValid() && !Owner.IsOnGround && !slideFire ) spread += si.SpreadAddMidAir;
// ⛔ AIMING IS AIMING, WHETHER YOU ARE WALKING OR NOT. By request: down the sights the
// cone is the standing cone, full stop. There were TWO movement terms and missing either
// leaves a penalty the player can still feel — an additive one here and a multiplicative
// `SpreadMultMoveSights` below, which is the one authored specifically for moving while
// aiming and therefore the easier of the two to overlook.
//
// ⚠️ HIP FIRE IS UNTOUCHED. Moving still costs you accuracy from the hip; the trade the
// player is buying with ADS is that it stops mattering.
//
// ⚠️ SWITCHABLE, because it is a feel decision rather than a fix, and the authored
// values are still in the prefabs to go back to — `nz_handling_adsmove 1` restores them.
var movePenalty = moving && !stillMove && (!aimed || NZombies.GlobalHandling.AdsMovePenalty);
if ( movePenalty ) spread += si.SpreadAddMove;
if ( aimed )
{
floatMod *= si.SpreadMultSights;
if ( movePenalty ) floatMod *= si.SpreadMultMoveSights;
}
// "shooting" = still inside the last shot's rhythm, i.e. sustained fire
// ⚠️ AND LONG PULL'S HOLD (auto action, tier 2, 2026-10-04, `Weapon.ActionTech.cs`): past the 10th shot of a pull it never
// widens, aimed or not — Locked In's hold, so with both the answer is the same.
if ( TimeSincePrimaryShoot < GetRealRPM( si.RPM ) * 2f )
floatMod *= LongPullShooting( LockedInShooting( aimed, si.SpreadMultShooting ) );
if ( !Owner.IsOnGround )
{
// Jumping — ⚠️ nothing with Slide Fire
if ( !slideFire ) floatMod += 0.75f;
}
else if ( Owner.Velocity.Length > 100 && !stillMove )
{
// Moving — ⚠️ nothing while sliding with Slide Fire, or walking aimed with Steady Sling
floatMod += 0.25f;
}
var scopeMultiplier = IsScoping ? ScopeInfo.Spread : 1f;
// ⛔ DEADSHOT DAIQUIRI IS APPLIED HERE, AND WAS PREVIOUSLY APPLIED NOWHERE.
// Like Double Tap, its only reader was WeaponStatsPanel — which computed the
// perked spread itself, so the stat moved while the gun did not.
//
// ⚠️ LAST, ON THE FINISHED NUMBER, not folded into `floatMod`. floatMod is
// only one of the three terms; the additive penalties above (hipfire, mid-air,
// movement) land on `spread` and would escape it entirely. A perk advertised as
// "half the spread" has to halve what the gun actually fires, including the
// penalty that made the shot inaccurate in the first place.
var handling = NZombies.PerkEffects.HandlingMultiplierFor( this );
// ⛔ RAILGUN'S ZERO GOES ON THE FINISHED NUMBER, LAST, AND THAT ORDERING IS THE
// ANSWER TO "does it fight the other spread nodes". It does not have to win an
// argument with them — a multiply by zero applied here annihilates every term at
// once: the additive hipfire/mid-air/movement penalties on `spread`, Point Shooting's
// x0.8 inside the hipfire term, Bull Barrel's x3 in the same place, `floatMod`,
// the scope multiplier and Deadshot's handling. Anything added to this method later
// is also inside it for free, which a zero written at any earlier line would not be.
//
// ⚠️ MULTIPLIES, NEVER ASSIGNS. `return 0f` here would read the same on a railgun and
// would silently discard the whole chain on the day the node wants 10% rather than 0.
//
// ⚠️ A NEUTRAL 1 WHEN UNOWNED, so this is one multiply by 1 on the hot path for
// every weapon in the game rather than a branch.
return spread * floatMod * scopeMultiplier * handling * RailgunFactor( "spread" )
// ⚠️ THE PER-CLASS AUGMENTS' SPREAD (2026-10-04), hip and aimed alike: Choke x0.4, Tungsten Belt x0.1,
// Fan the Hammer x3, Counterweight x1.5… A neutral 1 when none is owned.
* NZombies.TechStats.Mul( this, "s.spread" );
}
/// <summary>
/// Bull Barrel's hip-fire spread multiplier, or 1 when the node is not owned.
///
/// ⛔ `Has` + a NAMED magnitude, NOT `Factor`. This node spends `Factor` on the x2.5
/// damage, so `Factor( this, "t4_bullbarrel" )` returns 2.5 at a site that wants 3 —
/// wrong by 17% and completely invisible, which is the failure OverpressureRecoil's own
/// ⛔ block exists to record.
///
/// ⚠️ ONE READER TODAY, and it is still a method because the stats panel needs the same
/// number and a second copy of a magnitude is how the printed table starts lying.
/// </summary>
/// <summary>Autoloader's tighter cone, or 1 when the node is not owned.</summary>
/// <remarks>
/// ⛔ `Has` + A NAMED MAGNITUDE, NEVER `Factor` — `Factor( "t4_autoload" )` is 0.5, the PELLET
/// DIVISOR, so reading it here would halve the cone instead of quartering it and would look
/// entirely correct while doing so. The same trap `BullBarrelSpread` below records.
/// </remarks>
float AutoloaderSpread()
=> NZombies.TechEffects.Has( this, "t4_autoload" )
? NZombies.WeaponTech.MagOf( "t4_autoload", "spread", 1f )
: 1f;
float BullBarrelSpread()
=> NZombies.TechEffects.Has( this, "t4_bullbarrel" )
? NZombies.WeaponTech.MagOf( "t4_bullbarrel", "spread", 1f )
: 1f;
/// <summary>
/// One of Railgun's zeroing multipliers by name, or 1 when the node is not owned.
///
/// ⛔ `Has`, NOT `Factor` — `Factor( this, "t5_railgun" )` is 1.5, the DAMAGE half, so
/// reading it at either of these sites would make the railgun the LEAST accurate and
/// hardest-kicking weapon on the roster while looking entirely correct.
///
/// ⚠️ NAMED PER FIELD (`recoil`, `spread`) rather than one shared zero, even though the
/// catalogue authors both as 0 today. They are two fields with two owners; a single
/// magnitude would have to be split the moment design wants "no recoil, some spread",
/// and splitting it later means finding both call sites again.
///
/// ⚠️ THE FALLBACK IS 1, THE NEUTRAL, NOT 0. An undeclared magnitude therefore leaves
/// the weapon exactly as authored and warns once — a 0 fallback here would make the node
/// work while the catalogue said nothing, which is the second-source problem MagOf's own
/// header refuses.
/// </summary>
float RailgunFactor( string mag )
=> NZombies.TechEffects.Has( this, "t5_railgun" )
? NZombies.WeaponTech.MagOf( "t5_railgun", mag, 1f )
: 1f;
Angles _recoilToRecover;
Angles _recoilRecoverRate;
/// <summary>
/// Walk the camera back down to where it was pointing before the shot.
///
/// ⚠️ LINEAR, with the rate fixed at the moment of the shot — not an
/// exponential decay toward zero. Exponential never actually arrives, so the
/// last fraction of a degree hangs around and the gun never quite re-centres,
/// which reads as drift rather than recovery.
///
/// ⚠️ Applies the offset through the SAME path the kick used
/// (ApplyEyeAnglesOffset), so the player's own mouse movement composes with
/// it normally: pulling down during recovery just gets you there sooner
/// rather than fighting the recovery.
/// </summary>
public virtual void TickRecoilRecovery()
{
// ⚠️ THE MARK IS DROPPED WITH THE OWNER, not kept. A holstered or respawned weapon
// would otherwise measure its first frame back against eye angles from another life and
// hand the whole pool away — or, worse, in the direction that grows it.
if ( !Owner.IsValid() )
{
_recoilEyeMarked = false;
return;
}
CreditOwnAiming();
// ⛔ MARKED EVEN ON THE EARLY OUTS. `CreditOwnAiming` measures against the last mark,
// so a frame that returns without re-marking silently widens the next frame's window to
// two frames of mouse movement. Every path out of this method leaves a fresh mark.
if ( _recoilToRecover == Angles.Zero || RecoilClimbHeld() )
{
MarkEyeAngles();
return;
}
var step = _recoilRecoverRate * Time.Delta;
// do not overshoot past the original aim
if ( MathF.Abs( step.pitch ) > MathF.Abs( _recoilToRecover.pitch ) ) step.pitch = _recoilToRecover.pitch;
if ( MathF.Abs( step.yaw ) > MathF.Abs( _recoilToRecover.yaw ) ) step.yaw = _recoilToRecover.yaw;
// ⚠️ Angles has no unary minus — negate componentwise.
Owner.ApplyEyeAnglesOffset( new Angles( -step.pitch, -step.yaw, -step.roll ) );
_recoilToRecover -= step;
if ( MathF.Abs( _recoilToRecover.pitch ) < 0.001f && MathF.Abs( _recoilToRecover.yaw ) < 0.001f )
_recoilToRecover = Angles.Zero;
MarkEyeAngles();
}
/// <summary>
/// Is the gun still firing, for the purpose of holding the walk-back?
///
/// ⛔ THIS IS THE FIX FOR THE PLATEAU. Recovery used to run every frame, firing or not, so
/// the climb (kick x rounds per second) and the drain (pool / RecoilRecoveryTime) met at a
/// fixed altitude and the sight picture stopped rising however long the trigger was held.
/// Reported as *"if i keep holding it stabiliz at a height ... i want it to climb forever
/// untill i stop shooting"*.
///
/// ⚠️ RATE-DERIVED, NOT A FIXED DELAY — see `GlobalHandling.RecoilHoldGap`, which carries
/// the argument. `GetRealRPM` returns SECONDS BETWEEN SHOTS (smaller is faster) and already
/// includes Double Tap, so a perked weapon's window shortens with its fire rate.
///
/// ⚠️ `_timeSinceRecoil` IS THE CLOCK, and it is reset by `GetRecoilAngles` on every shot
/// that produces a kick — primary or secondary. A weapon whose recoil is zeroed (Railgun)
/// never resets it and therefore never holds, which is right: there is no climb to protect.
/// </summary>
bool RecoilClimbHeld()
{
if ( !NZombies.GlobalHandling.RecoilClimbHold ) return false;
// ⚠️ A FLOOR, BECAUSE RPM 0 IS REACHABLE. GetRealRPM divides by the authored rate; a
// weapon that leaves it at zero would produce a zero window and no hold at all, which
// looks exactly like the feature being off.
var gap = Primary is not null ? GetRealRPM( Primary.RPM ) : 0f;
if ( gap <= 0f ) gap = 0.1f;
var hold = MathF.Min( gap * NZombies.GlobalHandling.RecoilHoldGap,
NZombies.GlobalHandling.RecoilHoldMax );
return _timeSinceRecoil < hold;
}
/// <summary>Eye angles as this system last left them.</summary>
Angles _recoilEyeMark;
bool _recoilEyeMarked;
void MarkEyeAngles()
{
_recoilEyeMark = Owner.EyeAngles;
_recoilEyeMarked = true;
}
/// <summary>
/// Take the part of the climb the player already pulled back off the bill.
///
/// ⛔ WITHOUT THIS THE HOLD DIGS A HOLE. The pool is everything the gun kicked and the
/// walk-back spends all of it, which was harmless while an equilibrium kept the pool at about
/// a degree. Now that it can reach a magazine's worth, a player who fights the climb down to
/// level — which is what fighting recoil IS — stands at zero with twelve degrees still owed,
/// and releasing the trigger drives their aim that far into the floor.
///
/// ⚠️ MEASURED AGAINST OUR OWN LAST WRITE, so the difference is everything that moved the
/// view EXCEPT this method: mouse look, aim assist, anything added later. Nothing has to tell
/// us it moved the camera.
///
/// ⚠️ THE SHOT'S OWN KICK IS NOT CREDITED, and that falls out of the sign rule rather than
/// needing a special case: the kick moves the view the SAME way the pool points, and only
/// movement OPPOSING the pool pays anything off.
/// </summary>
void CreditOwnAiming()
{
if ( !NZombies.GlobalHandling.RecoilCredit ) return;
if ( !_recoilEyeMarked || _recoilToRecover == Angles.Zero ) return;
var now = Owner.EyeAngles;
_recoilToRecover = new Angles(
CreditAxis( _recoilToRecover.pitch, WrapDegrees( now.pitch - _recoilEyeMark.pitch ) ),
CreditAxis( _recoilToRecover.yaw, WrapDegrees( now.yaw - _recoilEyeMark.yaw ) ),
_recoilToRecover.roll );
if ( MathF.Abs( _recoilToRecover.pitch ) < 0.001f && MathF.Abs( _recoilToRecover.yaw ) < 0.001f )
_recoilToRecover = Angles.Zero;
}
/// <summary>
/// One axis of the above. `owed` is what the gun kicked; `moved` is what the player did.
///
/// ⚠️ OPPOSING SIGNS ONLY. Moving further in the direction the gun already pushed does not
/// grow the debt — the gun owes what the gun kicked, and letting a player's own flick upward
/// inflate that would turn into a yank downward a moment later.
/// </summary>
static float CreditAxis( float owed, float moved )
{
if ( owed == 0f || moved * owed >= 0f ) return owed;
var paid = MathF.Min( MathF.Abs( moved ), MathF.Abs( owed ) );
// ⚠️ A ternary, not MathF.Sign, for the whitelist reason above -- and `owed` cannot be
// zero here, the guard above returned on it.
return owed - (owed < 0f ? -paid : paid);
}
/// <summary>
/// A signed difference of two angles, into -180..180.
///
/// ⚠️ YAW WRAPS AND PITCH DOES NOT, and this is applied to both anyway. Turning through
/// south takes yaw from 179 to -179, a real movement of two degrees that subtracts to -358 —
/// which without this would pay off the entire horizontal pool in one frame, or in the other
/// direction look like a 358 degree flick.
/// </summary>
static float WrapDegrees( float degrees )
{
degrees %= 360f;
if ( degrees > 180f ) degrees -= 360f;
else if ( degrees < -180f ) degrees += 360f;
return degrees;
}
/// <summary>
/// Queue a kick to be walked back off over RecoilRecoveryTime.
///
/// ⛔ STABILIZER SHORTENS THE WALK-BACK; IT DOES NOT WRITE THE CATALOGUE'S 0 INTO
/// `t`. WeaponTech.cs states the node's lever as `ShootInfo.RecoilRecoveryTime = 0`,
/// and taken literally that lands on the `t <= 0f` guard below — which RETURNS,
/// leaving `_recoilToRecover` untouched. TickRecoilRecovery only ever moves the
/// camera by what is queued in that field, so nothing queued means nothing walked
/// back: EVERY shot's kick would stay in the eye angles permanently and the sight
/// picture would climb straight up through a magazine and stay there. That is the
/// exact opposite of "returns to centre instantly" — the node would read as the
/// worst recoil in the game while its own description promised the best.
///
/// ⚠️ SO "INSTANT" IS SPELLED AS ONE TICK'S WORTH OF RECOVERY, not as zero time.
/// Feeding `Time.Delta` through the same `_recoilToRecover / t` rate makes the next
/// TickRecoilRecovery step equal the whole pending amount, so the walk-back finishes
/// in the frame after the shot. Anything the frame-to-frame delta jitter leaves over
/// is caught by that method's overshoot clamp and its 0.001 snap-to-zero.
///
/// ⚠️ `Has`, NOT `Factor`. The catalogue stores 0 for this node, so `Factor` would
/// hand back the very value that trips the guard above — and since TechEffects.KindOf
/// treats a factor of 0 as Absolute, `nz_tech_amp` never scales it either. There is
/// nothing here to multiply by; the only question is whether the node is owned.
///
/// ⚠️ THE INVARIANT FROM FinishRecoil IS UNTOUCHED: this changes only how FAST the
/// queued angles come off, never how MANY. `applied` is still the same value
/// FinishRecoil returned as the kick, so the gun recovers exactly what it kicked.
/// </summary>
void QueueRecoilRecovery( ShootInfo shootInfo, Angles applied )
{
// ⚠️ QUICK SETTLE (semi action, tier 1, 2026-10-04, `Weapon.ActionTech.cs`): the walk-back's TIME ×0.769, so it settles 30%
// faster — and its speed cap below rises by the same figure, or a big kick would come back no faster at all.
var settle = QuickSettleTime();
var t = shootInfo.RecoilRecoveryTime * settle;
// ⚠️ Guarded on Time.Delta rather than assumed non-zero — a paused or
// zero-length frame would divide the rate by nothing, and falling back to the
// authored recovery time is a slow gun for one frame instead of an infinity in
// the eye angles.
// ⛔ STABILIZER NO LONGER LIVES HERE. It used to zero the recovery time, which was very
// nearly inert: `TickRecoilRecovery` is gated on `RecoilClimbHeld()`, so recovery does not
// run while you are still firing and an instant walk-back only tidied up after the burst.
// The node halves the KICK now, in `FinishRecoil`, where it is felt on the first shot.
var instant = false;
if ( instant )
t = Time.Delta;
if ( t <= 0f ) return;
// ⛔ ONLY PART OF THE VERTICAL KICK IS EVER QUEUED, AND THAT IS DELIBERATE. Every
// ⛔ block in `FinishRecoil` says a factor applied after the queue makes the gun walk
// back a different amount than it kicked, so the sight picture ratchets and stays
// drifted — and a ratchet is exactly what was asked for here: *"i want 80% of the
// vertical recoil to be recovered per shot instead of 100"*. Twenty percent of each
// shot's climb is permanent, so a magazine leaves the sight genuinely higher than it
// started rather than returning to the same pixel every time.
//
// ⚠️ SO IT IS WRITTEN INSIDE THE QUEUE, NOT AFTER IT. The eye still takes the full
// kick — `FinishRecoil` returns `applied` untouched — and only the BILL is short. Doing
// it the other way (shrinking the kick, recovering all of it) would lower the recoil
// instead of making part of it stick, which is a different feature wearing the same
// number.
//
// ⚠️ VERTICAL ONLY, AND HORIZONTAL MUST NOT FOLLOW. Horizontal is random-signed per
// shot, so an unrecovered fraction of it is a random walk — the aim would wander
// sideways and never come back, which is failure (1) from `GetRecoilAngles`'s own list
// arriving through the recovery instead of through the pattern. Climb has a direction;
// sway does not.
var recovered = NZombies.GlobalHandling.RecoilRecoverFraction.Clamp( 0f, 1f );
_recoilToRecover += new Angles( applied.pitch * recovered, applied.yaw, applied.roll );
var rate = _recoilToRecover / t;
// ⛔ THE RATE IS `pool / t` AND `t` DOES NOT GROW WITH THE POOL, which was fine
// while recovery ran during fire and held the pool near a degree. With the walk-back
// held until firing stops (`RecoilClimbHeld`) the pool reaches a magazine's climb, and
// the same expression then returns it at a hundred and sixty degrees per second. The
// constant did not change; what it divides did.
//
// ⚠️ PITCH AND YAW SCALE BY THE SAME FACTOR. Clamping each on its own would make
// them finish at different moments, so the walk-back would curve away from the line the
// climb came up — the ratchet FinishRecoil warns about twice, through another door.
//
// ⚠️ THE STABILIZER IS EXEMPT. Its whole lever is "recovers in one frame"; a cap
// measured in degrees per second is the one thing that could make the tier-3 node
// slower than the gun it upgrades.
var cap = NZombies.GlobalHandling.RecoilRecoverSpeed / settle;
if ( cap > 0f && !instant )
{
// ⚠️ Vector3's own Length rather than MathF.Sqrt: `Velocity.Length` is used all over
// this project and is therefore known to pass the editor's whitelist, which
// `dotnet build` does not run. MathF.Sqrt appears nowhere else and would be the first
// unproven member on a hot path -- the Array.Clone() lesson, cheaply avoided.
var speed = new Vector3( rate.pitch, rate.yaw, 0f ).Length;
if ( speed > cap ) rate *= cap / speed;
}
_recoilRecoverRate = rate;
}
/// <summary>Shots fired in the current burst — drives the pattern.</summary>
int _recoilShot;
/// <summary>
/// Starting phase of this burst's recoil sweep, radians.
///
/// ⛔ WITHOUT THIS EVERY BURST ON EVERY WEAPON LEANS THE SAME WAY, and that is exactly what
/// was reported: *"the lineup of parts we use to ads always skews in the same direction for
/// all weapons."* `_recoilShot` resets to 0 after `RecoilResetTime`, so each trigger pull
/// restarted the sine at sin(0) — and a sine leaving zero is positive for its entire first
/// half-cycle. The visual ROLL runs at half the side frequency, an 18-shot period, so it
/// stayed positive for NINE shots: longer than almost any burst anyone fires.
///
/// ⚠️ THE SINE WAS ALREADY THE FIX FOR THE PREVIOUS VERSION OF THIS BUG. Independent random
/// signs random-walked and could sit on one side for a whole burst; the sine replaced that
/// with something bounded and zero-mean OVER A FULL CYCLE — and then every burst was cut
/// short before it completed one. Deterministic and one-sided is worse than random and
/// one-sided, because it is the SAME side forever.
///
/// ⚠️ RANDOM PER BURST, NOT PER SHOT. Per shot would be the coin flip again. This keeps each
/// burst a smooth sweep and only varies where that sweep begins.
/// </summary>
float _recoilPhase = Game.Random.Float( 0f, MathF.Tau );
/// <summary>This burst's phase, for the viewmodel kick to stay in step with.</summary>
public float RecoilPhase => _recoilPhase;
TimeSince _timeSinceRecoil;
float _recoilAccum;
/// <summary>
/// ARC9-style recoil: a learnable pattern plus separate randomness, riding an
/// accumulator that decays between shots.
///
/// ⚠️ PATTERN AND RANDOMNESS ARE SEPARATE ON PURPOSE. Rolling them together
/// makes the pattern unlearnable, which defeats the point — the player is
/// supposed to be able to pull down against a known curve.
///
/// ⚠️ Falls back to SWB's single `Recoil` float when RecoilUp is 0, so a
/// weapon that has not been authored for this behaves exactly as before.
/// </summary>
public virtual Angles GetRecoilAngles( ShootInfo shootInfo )
{
// reset the burst after a pause
if ( shootInfo.RecoilResetTime > 0 && _timeSinceRecoil > shootInfo.RecoilResetTime )
{
_recoilShot = 0;
_recoilAccum = 0f;
// ⚠️ A NEW SWEEP START. This is the one place a burst begins, so it is the one place
// that can decide the burst does not begin where the last one did.
_recoilPhase = Game.Random.Float( 0f, MathF.Tau );
}
else if ( shootInfo.RecoilDissipationRate > 0 )
{
_recoilAccum = MathF.Max( 0f, _recoilAccum - shootInfo.RecoilDissipationRate * _timeSinceRecoil );
}
_timeSinceRecoil = 0;
// ⛔ RECOIL CONTROL IS READ HERE NOW, NOT IN `FinishRecoil`. Requested after the recoil
// rework: the node should act on the per-weapon MULTIPLIER, which is where a weapon's
// recoil identity now lives, rather than on the finished kick.
//
// ⚠️ IT IS APPLIED TO `up` AND `sideAmount` AFTER THE BASE/AUTHORED BRANCH, NOT INSIDE
// THE BASE ARM. Multiplying `RecoilVerticalMult` alone would silently stop working the
// moment anyone runs `nz_recoil_authored`, because that arm never reads the multiplier —
// a tier-1 node that quietly does nothing in one of two supported modes.
//
// ⚠️ AND IT SCALES `RecoilKick` WITH THEM. That addend lands AFTER the multiplier, so a
// factor applied to the multiplier alone would leave the first shot of every burst at full
// strength — "-20% recoil" that visibly does not reduce the shot the player notices most.
// Multiplying the finished kick, which is what this replaced, did cover it.
var techRecoil = NZombies.TechEffects.Factor( this, "t1_recoil" );
if ( shootInfo.RecoilUp <= 0f )
{
// legacy path — unchanged apart from going out through FinishRecoil
// ⚠️ `techRecoil` APPLIED HERE TOO. It used to arrive via FinishRecoil, which both
// exits share; moving it upstream would otherwise exempt every weapon on this path.
var legacyX = (IsAiming ? -shootInfo.Recoil * 0.4f : -shootInfo.Recoil) * techRecoil;
var legacy = new Angles( legacyX, Game.Random.NextFloat( -0.2f, 0.2f ) * legacyX, 0 );
return FinishRecoil( shootInfo, legacy );
}
var aimMult = IsAiming ? 0.4f : 1f;
// ⚠️ BASE x MULTIPLIER, OR THE AUTHORED NUMBER. One branch, read once, so vertical and
// horizontal below cannot end up on different sides of the switch.
var useBase = NZombies.GlobalHandling.UseRecoilBase;
var up = (useBase
? NZombies.GlobalHandling.VerticalBase * shootInfo.RecoilVerticalMult
: shootInfo.RecoilUp) * techRecoil;
// first shot punches harder
if ( _recoilShot == 0 )
up += shootInfo.RecoilKick * techRecoil;
// ⛔ OVERPRESSURE USED TO WORK BY MAKING A LEARNABLE PATTERN UNREADABLE, AND THERE IS NO
// LONGER A PATTERN TO UNREAD. Its first term scaled `RecoilPatternDrift` — the sine's
// frequency — so successive shots landed at arbitrary points of a cycle instead of
// stepping along a readable curve. Horizontal is now purely random per shot, so that term
// has nothing to act on and is gone with the field.
//
// ⚠️ WHAT REMAINS IS THE HALF THAT WAS ALWAYS THE REAL NODE: the random terms are scaled
// HERE and then again with the whole kick in FinishRecoil, so the stochastic part of a
// shot ends at x16 while the rest ends at x4. That RATIO is the effect — a uniform x4
// would just be the same recoil four times bigger.
//
// ⚠️ AND IT NOW SCALES THE HORIZONTAL MAGNITUDE DIRECTLY, which is the honest replacement:
// with no frequency left to disturb, "harder to control" has to mean a bigger push.
//
// ⚠️ THE MAGNITUDE IS NOT DECIDED HERE. See OverpressureRecoil.
var overpressure = OverpressureRecoil();
// ⛔ THE HORIZONTAL PATTERN MUST BE BOUNDED.
//
// Two ways to get this wrong, and this code has now had both:
// 1. a constant sign -> the aim SLIDES one way forever;
// 2. an alternating sign with a drift that scales by shot index ->
// every shot swings WIDER than the last, so a long burst goes from
// a readable zig-zag to flailing left-right across the screen.
//
// A sine gives what a spray pattern actually wants: it alternates, it is
// perfectly bounded by RecoilSide, it never accumulates a net drift over
// a full cycle, and it is smooth — so the shape stays LEARNABLE instead
// of becoming noise as the magazine empties.
//
// ⚠️ RecoilPatternDrift is now radians-per-shot — how fast the pattern
// cycles. Bigger = tighter zig-zag, NOT a wider one.
// ⚠️ The 0.01 floor is applied AFTER the scale, so a weapon that authors no drift
// at all still gets the floor rather than 0 — Overpressure cannot multiply its way
// out of a zero.
// ── HORIZONTAL RECOIL ────────────────────────────────────────────────
// ⛔ THE SINE PATTERN IS GONE. It read `RecoilSide * sin( _recoilShot * RecoilPatternDrift )`
// and it carried the same one-sided start the viewmodel's roll did: `_recoilShot` is 0 on
// every trigger pull, `PatternDrift` is 0.5 on the WHOLE FLEET, and sin leaving zero at
// 0.5 rad/shot stays positive for SEVEN shots of a 12.6-shot period. Mean horizontal over
// a five-shot burst was +0.65 — every burst, every one of the 226 weapons with a non-zero
// RecoilSide, always right. Fixing the model alone would not have removed what the user
// was seeing, because half of it was here.
//
// ⚠️ AND A LEARNABLE PATTERN WAS NEVER WORTH DEFENDING HERE. It only pays if players
// memorise it, and with `PatternDrift` at 0.5 across all 496 weapons there was exactly one
// pattern in the game to learn. Requested: *"purely random"*.
//
// ⚠️ 270 OF 496 WEAPONS AUTHOR `RecoilSide` AS ZERO, so it cannot be the only source of
// magnitude — more than half the fleet would have no horizontal at all. `RecoilRandomSide`
// is non-zero on 495 of 496, so it is the fallback, and when it IS the source it must not
// also be added as jitter below or it would count twice.
// ⚠️ IN BASE MODE THE FALLBACK IS IRRELEVANT — every weapon has a multiplier, so the
// "270 weapons author zero" problem the fallback exists for cannot arise.
var sideFromRandom = !useBase && shootInfo.RecoilSide <= 0f;
var sideAmount = (useBase
? NZombies.GlobalHandling.HorizontalBase * shootInfo.RecoilHorizontalMult
: sideFromRandom ? shootInfo.RecoilRandomSide : shootInfo.RecoilSide) * techRecoil;
// ⚠️ SIGN AND MAGNITUDE VARY SEPARATELY. A symmetric draw across the whole range would
// cluster near zero — most shots barely moving — which reads as no horizontal recoil at
// all punctuated by occasional jerks. A committed push in a random direction, at 70-100%
// of the authored amount, is what "randomly left or right by a specific amount" describes.
var sideSign = Game.Random.Int( 0, 1 ) == 0 ? -1f : 1f;
var side = sideAmount * overpressure * sideSign * Game.Random.Float( 0.7f, 1f );
// ⚠️ The AMPLITUDE is scaled, not the drawn sample — NextFloat's arguments must stay
// symmetric about zero. Scaling the result would work identically here, but a
// lopsided range is how a "random" term acquires a mean and starts sliding the aim
// one way, which is failure (1) above wearing a different hat.
// ⚠️ JITTER IS A FRACTION OF THE BASE IN BASE MODE, not an authored absolute. Keeping the
// authored random terms would let 496 independent values leak back in through the side
// door and make the base impossible to judge — which is the exact thing base mode exists
// to prevent.
var randomUp = (useBase ? up * NZombies.GlobalHandling.VerticalJitter
: shootInfo.RecoilRandomUp) * overpressure;
var randomSide = (useBase ? sideAmount * NZombies.GlobalHandling.HorizontalJitter
: shootInfo.RecoilRandomSide) * overpressure;
up += Game.Random.NextFloat( -randomUp, randomUp );
// ⚠️ SKIPPED WHEN `RecoilRandomSide` ALREADY SUPPLIED THE MAGNITUDE. Adding it on top of
// itself would give those 270 weapons roughly double the horizontal of the ones that
// author `RecoilSide` properly — a silent balance split along a line nobody chose.
if ( !sideFromRandom )
side += Game.Random.NextFloat( -randomSide, randomSide );
_recoilShot++;
_recoilAccum += up;
// auto-control returns part of what has accumulated
var control = 1f - shootInfo.RecoilAutoControl.Clamp( 0f, 1f );
var kick = new Angles( -up * aimMult * control, side * aimMult * control, 0 );
return FinishRecoil( shootInfo, kick );
}
/// <summary>
/// Overpressure's recoil multiplier, or 1 when the node is not owned.
///
/// ⛔ `Has` + `Bound`, NOT `Factor`. This node spends its `Factor` on the DAMAGE half
/// (the catalogue stores 2f for the x2 damage), so `Factor( this, "t4_overpressure" )`
/// returns 2 — it would halve the recoil this node exists for while looking completely
/// correct at the call site. `Bound` is the record field that exists for a node needing
/// a SECOND number, and the recoil multiplier is that second number.
///
/// ⚠️ 4f IS A FALLBACK, NOT THE MAGNITUDE. The node should declare `Bound: 4f` in
/// WeaponTech.cs so `nz_tech` prints it; until it does, BoundOf returns this. That is
/// the same arrangement Boat Tail's ceiling uses, and for the same reason — a literal
/// at a call site is a second source the printed table cannot see, so it is written
/// once, here, and reached only if the declaration is missing.
///
/// ⚠️ ONE HELPER FOR TWO CALL SITES — GetRecoilAngles (the pattern and random terms)
/// and FinishRecoil (the kick itself). Both need the SAME number, and §3 is the rule
/// that two copies of one lookup is one fix landing on one of them.
///
/// ⚠️ NOT AMPLIFIED by `nz_tech_amp`, which only scales `Factor`. Same as Boat Tail's
/// ceiling. At x4 there is nothing to exaggerate anyway.
/// </summary>
float OverpressureRecoil()
=> NZombies.TechEffects.Has( this, "t4_overpressure" )
? NZombies.WeaponTech.BoundOf( "t4_overpressure", 4f )
: 1f;
/// <summary>
/// Scale a recoil kick by Deadshot and Recoil Control, then queue its recovery.
/// Both of GetRecoilAngles' exits go through here.
///
/// ⛔ THE SCALE AND THE RECOVERY MUST BE THE SAME NUMBER, which is the whole
/// reason this method exists rather than the perk being applied at the call site.
/// GetRecoilAngles queues the walk-back INTERNALLY, so scaling the returned kick
/// in Weapon.Shoot would apply a halved kick and then recover the FULL one — the
/// camera would sink a little further below its start point with every shot and
/// keep drifting down through a burst. That reads as a broken gun, not a perk.
///
/// ⚠️ AND IT COLLAPSES TWO EXITS INTO ONE. Both branches already called
/// QueueRecoilRecovery separately — §3's exact shape, and applying the perk would
/// have made it two places to remember instead of one. The legacy branch is the
/// one that would have been missed: it only runs for weapons with RecoilUp of 0,
/// so Deadshot would have worked on every authored gun and silently done nothing
/// on the older ones.
/// </summary>
Angles FinishRecoil( ShootInfo shootInfo, Angles kick )
{
// ⛔ THE PROJECT-WIDE RECOIL MULTIPLIER GOES FIRST, and it goes HERE rather than into the
// prefabs so it applies to every weapon including ones added later. See
// `NZombies.GlobalHandling`.
//
// ⚠ ON THIS LINE FOR THE REASON THE BLOCKS BELOW GIVE TWICE OVER: every factor must be
// applied before `QueueRecoilRecovery` reads `kick`, or the gun walks back a different
// amount than it kicked and the sight picture ratchets. A global x2 applied after the
// queue would look like the recovery being broken, not like more recoil.
kick *= NZombies.GlobalHandling.RecoilScale;
// ⚠️ COUNTERWEIGHT ZEROES THE KICK HERE, ABOVE `QueueRecoilRecovery`, for the reason
// every other factor in this method does: a multiplier applied after the queue would have
// the gun walk back a kick it never took. Its `Factor` IS the multiplier (0), so an unowned
// node returns 1 and the line costs nothing.
kick *= NZombies.TechEffects.Factor( this, "t4_counterweight" );
// ⚠️ AND STABILIZER'S HALF, on the same line and for the same reason: its `Factor` IS
// the multiplier, an unowned node returns 1, and both must land before
// `QueueRecoilRecovery` reads `kick` or the gun walks back more than it took.
kick *= NZombies.TechEffects.Factor( this, "t3_recovery" );
// ⚠️ THE PER-CLASS AUGMENTS' RECOIL (2026-10-04): Match Grade x0.75, Autoloader x0.3, Marksman Conversion and
// Machine Pistol x1.5… Here, above `QueueRecoilRecovery`, so the gun walks back exactly what it kicked.
kick *= NZombies.TechStats.Mul( this, "s.recoil" );
// ⚠️ AND THE ACTION SETS' KICK (2026-10-04, `Weapon.ActionTech.cs`), here for the same reason: Flat Burst's 0 on every round
// of a burst after its first, Long Pull's 0 past the 10th shot of a pull. A multiply, like every term around it.
kick *= ActionTechKick( shootInfo );
// ⚠️ AND THE MAGAZINE SETS' KICK (2026-10-04, `Weapon.MagTech.cs`), here for the same reason: Tight Ten's ×0.5 on the last 10
// rounds of a magazine, Opening Volley's 0 on the first 10 since a reload.
kick *= MagTechKick( shootInfo );
// ⛔ DEADSHOT NO LONGER TOUCHES RECOIL, BY REQUEST. `HandlingMultiplierFor` used to be
// read here as well as in `GetRealSpread`, so simply owning the perk halved the kick on
// every weapon in the game before any augment was chosen — a larger reduction than any
// node or augment offers, for free. The perk keeps spread and ADS time; the recoil half
// of its job now belongs to Double Tap's m4, which was raised to x0.5 to take it.
//
// ⚠️ THE SPREAD READER IS UNTOUCHED and must stay that way: `GetRealSpread` calls the
// same method, and deleting the property instead of this one line would have removed the
// perk's accuracy half too.
// ⛔ RECOIL CONTROL HAS MOVED UPSTREAM to `GetRecoilAngles`, where it multiplies the
// per-weapon multiplier pair instead of the finished kick. The invariant this line's old
// comment protected still holds and holds more easily: a factor applied BEFORE the kick is
// composed is inside `kick` by the time `QueueRecoilRecovery` reads it, so the gun still
// walks back exactly what it kicked. Only a factor applied AFTER the queue breaks that.
// ⛔ STEADY BARREL IS RECOIL ONLY, WHICH IS WHY IT IS NOT IN HandlingMultiplier.
// That one is read by `GetRealSpread` as well, so folding a recoil augment into it
// would quietly hand the player accuracy the augment does not advertise — the
// mirror of the mistake the hip-spread row documents avoiding.
//
// ⚠️ ON THIS LINE, ABOVE `QueueRecoilRecovery`, for the reason the ⛔ blocks around
// it give twice over: a factor applied after the queue makes the gun walk back a
// different amount than it kicked, so the sight picture drifts and stays drifted.
using ( NZombies.CpuScope.Measure( "dtap.recoil" ) )
kick *= NZombies.DtapAugments.RecoilMultiplierFor( this );
// ⛔ OVERPRESSURE BELONGS ON THIS LINE FOR THE REASON IN THE ⛔ ABOVE, and it is
// the more dangerous of the two to move. A factor BELOW one applied after the queue
// makes the camera sink, as that note says; a factor of FOUR applied after it does
// the mirror image — the eye angles take the full x4 kick while only a quarter of it
// is ever queued to be walked back, so three quarters of every shot's climb is
// permanent and the sight picture ratchets up through the magazine and stays there.
// Which is indistinguishable from the recovery being broken.
//
// ⚠️ COMPOSES WITH RECOIL CONTROL BY MULTIPLICATION, so a weapon holding both gets
// x4 * x0.8 = x3.2, and the ORDER OF THESE TWO LINES CANNOT CHANGE THAT. Neither
// line assigns, so the tier-4 node does not overwrite the tier-1 one.
kick *= OverpressureRecoil();
// ⛔ RAILGUN'S ZERO IS THE LAST WRITE BEFORE THE QUEUE, AND BOTH HALVES OF THAT
// MATTER. Last, so it composes with — and annihilates — every recoil multiplier
// above it: Deadshot's handling, Recoil Control's x0.8, Overpressure's x4, and the
// pattern and random terms GetRecoilAngles already folded into `kick`. There is no
// ordering question with the tier-1 or tier-4 recoil nodes because zero absorbs
// them; they are all multiplies on this one value and none of them assigns.
//
// ⛔ AND ABOVE `QueueRecoilRecovery`, WHICH IS THE PART THAT HAS COST A BUG TWICE IN
// THIS FILE. A factor applied after the queue makes the camera walk back a different
// amount than it kicked: below 1 the sight sinks a little further every shot, above 1
// it ratchets up and stays. A factor of ZERO applied after the queue would be the
// worst version of that — the eye takes no kick at all while the recovery walks the
// camera DOWN by the full unzeroed amount, so a railgun would drag the player's aim
// into the floor one shot at a time.
//
// ⚠️ MULTIPLIES, NEVER ASSIGNS `Angles.Zero` — same reason as the spread site.
kick *= RailgunFactor( "recoil" );
QueueRecoilRecovery( shootInfo, kick );
return kick;
}
/// <summary>Pass extra details when killing a player</summary>
public virtual Dictionary<string, string> GetKillDetails()
{
return null;
}
public static MovementImpact GetMovementImpactFromForce( float force )
{
// 1.0 → 0.6 (40% max slow)
var maxSlow = 0.4f;
var t = Math.Clamp( force / 10f, 0f, 1f );
var amount = 1f - t * maxSlow;
return new()
{
Amount = amount,
Duration = 0.4f,
};
}
}