Part of the Weapon class implementing shooting behavior and burst/fire-mode logic. It defines trace/radius tuning, console commands, burst mechanics, firing-mode resolution, trigger-charge ticking, shot execution (ammo, sound, particles, recoil), and various tech/augment integrations.
using SWB.Base.Particles;
using SWB.Shared;
using System;
using System.Collections.Generic;
namespace SWB.Base;
public partial class Weapon
{
/// <summary>
/// How far a bullet trace reaches, in world units. 65536.
///
/// ⛔ IT WAS 999999, HARDCODED AT THREE CALL SITES, AND THAT WAS THE SINGLE BIGGEST COST IN
/// THE DAMAGE PATH. A swept sphere that long has a bounding box spanning the whole map, so the
/// broadphase cannot cull anything and every collider in the level becomes a narrow-phase
/// candidate -- with `.UseHitboxes()` expanding each zombie into its full per-bone set. The
/// measurement is unambiguous: cpu_dmg_trace per call went 44us at 0-4 zombies to 198us at
/// 25-37. A correctly culled trace does not care how many zombies stand elsewhere on the map.
///
/// ⛔ AND THIS FILE ALREADY KNEW. The wide-bore trace's own comment says "Unclamped it
/// sweeps 999999 units through the whole map ... a player shooting a wall two metres away
/// sweeps two metres", and clamps the SECONDARY trace for that reason. The primary trace, which
/// runs up to ten times per bullet and ninety-six times per Olympia trigger pull, never got it.
///
/// ⚠️ 65536 IS CHOSEN, NOT ROUNDED. Source 2 world bounds are +/-16384 per axis, so the
/// longest shot possible inside a legal map is the diagonal, about 56755 units. 65536 clears
/// that with room to spare, so no shot that could previously connect now falls short -- this is
/// a bound on geometry that cannot exist, not a weapon range. Do NOT lower it to a "sensible
/// weapon range": bullets must still reach anything the player can see.
///
/// ⚠️ NULLABLE-BACKED, matching DtapAugments.PierceBodies and CpuScope.Enabled. Hotload copies
/// statics forward BY NAME and skips initialisers, so a plain `= 65536f` would come back as
/// whatever a previous compile left behind. `nz_aug_dtap_set 1` cost an hour of confusion
/// through exactly that trap earlier today.
/// </summary>
public static float TraceRange
{
get => _traceRange ?? 65536f;
set => _traceRange = value;
}
static float? _traceRange;
/// <summary>
/// `nz_trace_range [units]` — read or set the bullet trace length. No argument reports it.
///
/// ⚠️ THE OLD BEHAVIOUR IS ONE COMMAND AWAY: `nz_trace_range 999999`. That is deliberate, so
/// an A/B is a console line rather than a rebuild, and so a regression can be ruled in or out
/// without reverting anything.
/// </summary>
[ConCmd( "nz_trace_range" )]
public static void TraceRangeCmd( float units = -1f )
{
if ( units > 0f ) TraceRange = MathX.Clamp( units, 1024f, 999999f );
Log.Info( $"[nz] bullet trace range {TraceRange:0} units"
+ (TraceRange >= 999999f ? " (UNCLAMPED — the pre-fix behaviour)" : "")
+ $" · a legal Source map's longest diagonal is ~56755" );
}
/// <summary>
/// The bullet's thickness in world units. 0 — a raycast.
///
/// ⛔ IT WAS 2.0, WHICH MADE EVERY BULLET A SWEPT SPHERE RATHER THAN A LINE. Two units is
/// about 5cm of aim grace: anything the ball brushed counted as a hit. At 0 the bullet is a
/// laser and hits exactly what it is pointed at.
///
/// ⚠️ THE GAMEPLAY DIFFERENCE IS TARGET SELECTION IN A CROWD, not accuracy in the abstract.
/// Aiming at a zombie ten metres away while another's shoulder sits 3cm off the line two metres
/// ahead: the sphere brushed the near shoulder and hit THAT one, the ray passes it and hits the
/// one you aimed at. Stricter, and arguably what the player intended.
///
/// ⚠️ HEADSHOTS ARE COMPLETELY UNAFFECTED. Radius is the bullet's thickness; `.UseHitboxes()`
/// is what reports which body part it crossed. They are independent settings.
///
/// ⚠️ `nz_trace_radius 2` restores the old behaviour without a rebuild.
/// </summary>
public static float TraceRadius
{
get => _traceRadius ?? 0f;
set => _traceRadius = value;
}
static float? _traceRadius;
/// <summary>`nz_trace_radius [r]` — bullet thickness. 0 is a raycast, 2 was the old sphere.</summary>
[ConCmd( "nz_trace_radius" )]
public static void TraceRadiusCmd( float r = -1f )
{
if ( r >= 0f ) TraceRadius = MathX.Clamp( r, 0f, 16f );
Log.Info( $"[nz] bullet trace radius {TraceRadius:0.##}"
+ (TraceRadius <= 0f
? " (raycast — hits exactly what you aim at)"
: $" (swept sphere — {TraceRadius * 2.54f:0.#}cm of aim grace)")
+ (TraceRadius >= 2f ? " [the pre-fix behaviour]" : "") );
}
/// <summary>
/// The three trace configurations, as one command each — for A/B testing without having to
/// remember which two knobs make which rung.
///
/// ⛔ EACH ONE SETS BOTH VALUES. Setting only the radius while the range is still on the
/// previous rung produces a fourth configuration nobody meant to test, and it looks identical
/// in the log to the one you thought you were running.
///
/// nz_trace_original 999999 / 2.0 the code as it shipped
/// nz_trace_sphere 65536 / 2.0 length bounded, still a swept sphere
/// nz_trace_ray 65536 / 0 length bounded and a true raycast
/// </summary>
static void ReportTrace( string label )
=> Log.Info( $"[nz] trace: {label} range {TraceRange:0} radius {TraceRadius:0.##}"
+ (TraceRadius <= 0f ? " (raycast)" : " (swept sphere)") );
/// <summary>`nz_trace_original` — 999999 units, radius 2. The pre-fix behaviour.</summary>
[ConCmd( "nz_trace_original" )]
public static void TraceOriginalCmd()
{
TraceRange = 999999f;
TraceRadius = 2f;
ReportTrace( "ORIGINAL" );
}
/// <summary>`nz_trace_sphere` — 65536 units, radius 2. Length fix only.</summary>
[ConCmd( "nz_trace_sphere" )]
public static void TraceSphereCmd()
{
TraceRange = 65536f;
TraceRadius = 2f;
ReportTrace( "BOUNDED SPHERE" );
}
/// <summary>`nz_trace_ray` — 65536 units, radius 0. Both fixes; the current default.</summary>
[ConCmd( "nz_trace_ray" )]
public static void TraceRayCmd()
{
TraceRange = 65536f;
TraceRadius = 0f;
ReportTrace( "RAY" );
}
public static readonly string[] BulletTraceIgnoreTags =
{
TagsHelper.Trigger,
TagsHelper.PlayerClip,
TagsHelper.PassBullets,
TagsHelper.ViewModel,
TagsHelper.Sky
};
public static readonly string[] TuckingTraceIgnoreTags =
[
..BulletTraceIgnoreTags,
TagsHelper.Player,
TagsHelper.DeadPlayer
];
public static readonly string[] PenetrationBulletTraceIgnoreTags =
[
..BulletTraceIgnoreTags,
TagsHelper.DeadPlayer
];
/// <summary>
/// Checks if the weapon can do the provided attack
/// </summary>
/// <param name="shootInfo">Attack information</param>
/// <param name="lastAttackTime">Time since this attack</param>
/// <param name="inputButton">The input button for this attack</param>
/// <returns></returns>
public virtual bool CanShoot( ShootInfo shootInfo, TimeSince lastAttackTime, string inputButton )
{
// ⚠️ HOISTED ABOVE EVERY OTHER GATE so the latch below can be resolved from a
// shootInfo and an owner that are known good. Both tests were already being made,
// separately, by the two branches that used to come first — this is one copy of
// them, not a new gate.
if ( shootInfo is null || !Owner.IsValid() ) return false;
// ⛔ SHOCKED BY AVOGADRO (2026-10-06): no shot at all — the user's *"when shocked, we can make the player unable to shoot"*.
// Read on the shooter's own machine, where this whole method runs and where the shock is applied (`NZombies.PlayerShock`).
// ⚠️ A LATCHED BURST IS DROPPED, NOT FROZEN, or its leftover rounds would fire on their own the moment the shock ends
// (the reload branch below releases the latch for the same reason).
if ( Owner is NZombies.NZPlayer shockedOwner && NZombies.PlayerShock.Blocks( shockedOwner ) )
{
burstCount = 0;
return false;
}
// ⛔ THE UNINTERRUPTIBLE LATCH IS RESOLVED ONCE, HERE, AND EVERY GATE BELOW READS
// THE SAME ANSWER. Ten-Round Burst's entire cost is that a tap commits ten rounds,
// so three separate gates have to stop cancelling a burst: the release-reset
// immediately below, the trigger-down/sprint master gate, and the continuation gate
// inside the burst branch. Asking `BurstLatched` at each of them would let the
// three disagree inside one frame, because the burst branch moves `burstCount`.
var latched = BurstLatched( shootInfo );
// ⛔ A BURST CANNOT SPAN A RELOAD, SO THE LATCH IS RELEASED RATHER THAN FROZEN.
// The reload gate below returns false for the whole reload; a latched `burstCount`
// would survive it and then resume — from a trigger press the player made before
// reloading, with the release reset still suppressed, so the leftover rounds fire
// on their own with nothing held down. The catalogue's "five whole Olympia
// magazines" is the copy that is wrong here, not this line.
if ( latched && IsReloading )
{
burstCount = 0;
latched = false;
}
// ⛔ MICRO-BURST BURSTS WITHOUT CHANGING THE AUTHORED `FiringType`, AND THE
// UPSTREAM RESET KEYS OFF THAT FIELD. `ResetBurstFireCount` (Weapon.Extra.cs,
// called every frame from Weapon.cs immediately before CanPrimaryShoot) returns
// on its first line unless `shootInfo.FiringType == burst` — so on a weapon this
// node converts at READ time the counter would never clear: one burst, then a
// gun that never fires again for the life of the clone. Both files were read
// before this was written; read them again before changing it.
//
// ⚠️ Fills the GAP ONLY, and `Input.Released` short-circuits it — a weapon that
// AUTHORS burst is still cleared by the upstream call, and the tech lookup
// happens once per trigger release rather than once per frame.
//
// ⛔ AND `!latched` COMES FIRST, because this is precisely the cancellation
// Ten-Round Burst is bought to remove. Without it the node would fire one round
// and stop the instant the player let go of the trigger — a ten-round commitment
// that any player cancels by reflex.
if ( !latched && Input.Released( inputButton ) && !Owner.IsBot
&& shootInfo.FiringType != FiringType.burst
&& EffectiveFiringType( shootInfo ) == FiringType.burst )
burstCount = 0;
if ( (IsReloading && !ShellReloading) || (IsReloading && ShellReloading && !ShellReloadingShootCancel) || InBoltBack ) return false;
// ⛔ THE LATCH BYPASSES THE TRIGGER *AND* THE SPRINT CANCEL, AND IT HAS TO BYPASS
// BOTH. Leaving the sprint half in place would make sprint the cancel button, so
// the node's whole downside would be avoidable with one key that players hold
// anyway. Same clause otherwise, one `!latched &&` in front of it.
// ⛔ STAMIN-UP'S M4 "RUN & GUN" LIFTS THE SPRINT HALF OF THIS GATE, AND ONLY THAT
// HALF. The trigger test stays: an augment that let you fire without pressing fire
// is not the augment. The clause is `(sprintBlocked && Secondary is null)`, so the
// dual-wield exemption already there is untouched.
//
// ⚠️ IT ALSO CLEARS THE `TimeSinceRunning < 0.1f` TAIL, deliberately. That tail is
// the out-of-sprint delay — the fraction of a second after releasing sprint where
// the gun still refuses — and leaving it in place would mean a Run & Gun player
// could fire WHILE sprinting but not in the moment they stopped, which reads as the
// augment breaking rather than as a separate rule.
var sprintBlocked = !NZombies.StaminUpAugments.RunAndGunFor( this )
// ⚠️ the per-class augments that "fire while sprinting" (2026-10-04): Run 'n' Gun, Walking Fire
&& !NZombies.TechStats.Flag( this, "f.sprintfire" )
&& (IsRunning || (TimeSinceRunning < 0.1f && Owner.IsOnGround));
if ( !latched && ((!Owner.IsBot && !Input.Down( inputButton ))
|| (sprintBlocked && Secondary is null)) ) return false;
if ( !HasAmmo() )
{
// ⛔ THE LATCH IS RELEASED ON AN EMPTY CLIP OR THE COUNTER FREEZES. This is the
// COMMON case for Ten-Round Burst, not an edge one: ten of the 31 weapon
// prefabs cannot complete a ten-round burst from a FULL magazine — Olympia 2,
// KS23 4, AWM 5, HS10/Python/WA2000 6, ASP/M1911/Makarov/SPAS12 8 (read from
// the prefabs). The burst stops on this branch with `burstCount` part-way, the
// release reset above is latched off, and the next trigger pull would then
// deliver only the rounds the previous burst had left over.
//
// ⚠️ Cleared ONLY when latched. An authored-burst weapon running dry mid-burst
// keeps exactly the behaviour it has today.
if ( latched ) burstCount = 0;
if ( Input.Pressed( inputButton ) )
{
// Check for auto reloading
if ( Settings.AutoReload && Owner.AmmoCount( shootInfo.AmmoType ) > 0 && lastAttackTime > GetRealRPM( shootInfo.RPM ) )
{
TimeSincePrimaryShoot = 999;
TimeSinceSecondaryShoot = 999;
if ( ShellReloading )
OnShellReload();
else
Reload();
return false;
}
// Dry fire
if ( shootInfo.DryShootSound is not null )
PlaySound( shootInfo.DryShootSound );
}
return false;
}
// ⚠️ Resolved ONCE and reused, so the semi test and the burst test cannot
// disagree about what this weapon is this frame.
var firingType = EffectiveFiringType( shootInfo );
// ⛔ SINGLE ACTION (revolver tier 5, 2026-10-04): "each shot needs the trigger held for 0.2 s; letting go early
// fires nothing". It replaces the semi press-edge test for the primary: the shot is released by the HOLD.
if ( firingType == FiringType.semi && shootInfo == Primary && !Owner.IsBot
&& NZombies.TechEffects.Has( this, "t5_rv_singleaction" ) )
return SingleActionReady( inputButton, lastAttackTime, shootInfo );
if ( firingType == FiringType.semi && !Owner.IsBot && !Input.Pressed( inputButton ) ) return false;
if ( firingType == FiringType.burst )
{
// ⚠️ `>= BurstRoundsFor` is the same gate as the old `burstCount > 2`, because
// the counter is incremented BEFORE the shot below — indices 0, 1 and 2 got
// through, i.e. three rounds. The default returns 3 for exactly that reason.
if ( burstCount >= BurstRoundsFor( shootInfo ) )
{
// ⛔ THE ONE PLACE A BURST CAN RESTART WITHOUT THE TRIGGER BEING RELEASED,
// and it is genuinely new behaviour rather than a new branch. The matrix
// authors Overclocked + Micro-Burst as "two rounds, a slight delay, two
// more, and on while the trigger is HELD" — but `burstCount` has only ever
// been cleared on `Input.Released`, so holding the trigger gave two rounds
// and then a dead gun. This clears it on a TIMER instead.
//
// ⚠️ CLEARS AND STILL RETURNS FALSE, so the next round comes on the
// following frame from the normal path. One frame of extra gap on top of
// the authored one, against not having to duplicate the fire decision here.
//
// ⚠️ Gated on the PAIR, so plain Overclocked stays a single stream (it is
// `auto`, it never reaches this branch) and plain Micro-Burst still needs
// the trigger released between bursts.
if ( AutoBurst() && (Owner.IsBot || Input.Down( inputButton ))
&& lastAttackTime > GetRealRPM( shootInfo.RPM ) + AutoBurstGap() )
burstCount = 0;
// ⛔ AND THE TRIGGER BEING UP CLEARS IT TOO, BECAUSE THE RELEASE EDGE IS SPENT
// BY THEN. This is the "you have to click twice" bug, and it is on all 26 burst
// weapons rather than one of them — reported on the B23R.
//
// Both existing resets are EDGE-triggered on `Input.Released`, and both are
// consumed DURING the burst rather than after it, because
// `BurstUninterruptible` now returns true for every burst:
//
// press round 1 fires, burstCount 1
// release `BurstLatched` is still true (1 < 3), so NEITHER reset runs
// — correctly, the latch owes the player rounds 2 and 3
// (latched) rounds 2 and 3 fire with the trigger already up
// burstCount is now 3 and the release edge is GONE
// press burstCount >= 3, this branch returns false. NOTHING FIRES.
// release only NOW does an edge arrive and clear the counter
// press fires
//
// So every other trigger pull was swallowed. It could not happen while bursts
// were cancellable, which is why it arrived with that change rather than with
// any of the burst weapons.
//
// ⚠️ LEVEL-TRIGGERED, NOT EDGE-TRIGGERED, and that is the whole fix. "The burst
// is over and the trigger is up" is a STATE, true on every frame until the next
// press; an edge is one frame that something else already used. A third
// `Input.Released` test would have had exactly the same hole.
//
// ⚠️ IT SITS BELOW AutoBurst DELIBERATELY. That branch is the trigger-HELD case
// (Overclocked + Micro-Burst restarting on a timer); this is the trigger-UP case.
// They cannot both apply — `Input.Down` is the discriminator — so the order is
// only about which reads first.
//
// ⚠️ AND IT COVERS BOTH KINDS OF BURST, which neither existing reset does alone:
// `ResetBurstFireCount` returns early unless the weapon AUTHORS burst, and the
// Micro-Burst reset in this file requires that it does NOT. This gate is reached
// by whatever `EffectiveFiringType` calls a burst, which is the set that matters.
else if ( !Owner.IsBot && !Input.Down( inputButton ) )
burstCount = 0;
return false;
}
// ⚠️ `latched ||` IS WHAT ACTUALLY FIRES ROUNDS 2-10 OF AN UNINTERRUPTIBLE
// BURST. The master gate above only stops CanShoot returning early; this is
// the gate that still wanted the trigger down, so without it the burst would
// reach here and stall at whatever round the player let go on.
// ⚠️ SELECT FIRE'S 0.1 s BETWEEN BURSTS (2026-10-04) is added at `burstCount == 0`, the wait before a burst
// begins, and only on this gate: `GetRealRPM` also feeds the stats card (see `SelectFireBurstGap`).
var wait = GetRealRPM( shootInfo.RPM ) + (burstCount == 0 ? SelectFireBurstGap( shootInfo ) : 0f);
if ( (latched || Owner.IsBot || Input.Down( inputButton )) && lastAttackTime > wait )
{
burstCount++;
return true;
}
return false;
}
if ( shootInfo.RPM <= 0 ) return true;
return lastAttackTime > GetRealRPM( shootInfo.RPM );
}
/// <summary>
/// Checks if weapon can do the primary attack
/// </summary>
public virtual bool CanPrimaryShoot()
{
return CanShoot( Primary, TimeSincePrimaryShoot, InputButtonHelper.PrimaryAttack );
}
/// <summary>
/// Checks if weapon can do the secondary attack
/// </summary>
public virtual bool CanSecondaryShoot()
{
return CanShoot( Secondary, TimeSinceSecondaryShoot, InputButtonHelper.SecondaryAttack );
}
// ── MICRO-BURST (t4_microburst) ───────────────────────────────────────────
//
// "2-round burst, +40% damage; 2nd round x2 if the 1st hit a zombie"
/// <summary>
/// SWB's own burst length — the three rounds the old `burstCount > 2` let through.
///
/// ⚠️ NOT A TECH MAGNITUDE, so it does not belong in the catalogue — it is upstream
/// behaviour, and the G11 is the only one of the 31 weapon prefabs that authors
/// `FiringType: burst` (verified by grep over Assets/prefabs/weapons). Naming it is
/// what makes the default in <see cref="BurstRoundsFor"/> readable as "unchanged".
/// </summary>
const int SwbBurstRounds = 3;
/// <summary>
/// This weapon's burst length. 0 keeps SWB's three.
///
/// ⛔ THE LENGTH WAS A PRIVATE const AND THAT MADE IT UNAUTHORABLE. Every burst weapon in the
/// game fired exactly three rounds because the only way to change it was a tech node; the
/// Destiny pack has 4-round and 5-round bursts that no prefab could express. 0 rather than 3 as
/// the default so an unset prefab means "whatever SWB does", not "three, decided here".
/// </summary>
[Property, Group( "Shooting" )] public int BurstRounds { get; set; } = 0;
/// <summary>
/// Keep firing bursts while the trigger is HELD, instead of one burst per pull.
///
/// ⛔ AUTHORED PER WEAPON, because it is a property of the gun and not of an upgrade. The
/// repeat-while-held path already existed but was reachable only through the Overclocked +
/// Micro-Burst pairing, so a weapon that fires that way out of the box had no way to say so.
/// </summary>
[Property, Group( "Shooting" )] public bool BurstIsAutomatic { get; set; } = false;
/// <summary>
/// Whether this weapon plays its authored fire clip.
///
/// ⛔ SOME PORTED CLIPS THROW THE WHOLE GUN AROUND, and there is no dial for it: the motion is
/// baked into the .vmdl at compile time, so no recoil or sway setting reaches it. Suppressing
/// the clip is the only runtime answer. Confirmed with `nz_shootanim`, which turns it off
/// globally -- this is the same switch, per weapon.
///
/// ⚠️ A FLAG RATHER THAN A BLANK `ShootAnim`. Clearing the name would work identically and
/// read as missing data -- the clip is still there, still named, and still one edit away from
/// coming back if it is ever fixed at the source.
/// </summary>
[Property, Group( "Shooting" )] public bool PlayShootAnim { get; set; } = true;
/// <summary>
/// Whether this weapon sways when you turn.
///
/// ⛔ SWAY IS A GLOBAL SYSTEM WITH NO PER-WEAPON DIAL. `HandleSwayAnimation` lags the eye
/// rotation and turns that lag into gun movement using constants shared by every weapon in
/// the game -- so a pack that should sit still had no way to say so. This is that switch.
///
/// ⚠️ SUPPRESSES ONLY THE SWAY TERM. Idle breathing, walk bob, the aim pose and recoil are
/// separate contributions and are left alone -- turning the gun into a rigid prop would be a
/// much bigger change than "it should not swing when I turn".
/// </summary>
[Property, Group( "Animations" )] public bool UseSway { get; set; } = true;
/// <summary>
/// Fallback burst length for Micro-Burst, used only until the node declares a `Bound`.
///
/// ⛔ THE CATALOGUE HAS ONE SPARE NUMBER PER NODE AND THIS NODE NEEDS TWO. `Factor`
/// is spent on the +40% damage and `Bound` is the only other field on
/// `WeaponTech.Node`, so the burst LENGTH and the conditional SECOND-ROUND
/// MULTIPLIER cannot both live there. Length is read through
/// `WeaponTech.BoundOf( "t4_microburst", … )` so that declaring `Bound: 2f` on the
/// node takes over with no edit here — the same fallback shape
/// `WeaponTech.FalloffCeiling` already uses. `t4_microburst` declares no Bound
/// today, so this value is what is actually in force.
/// </summary>
const float MicroBurstRounds = 2f;
/// <summary>
/// What the round after a connecting round is multiplied by. Micro-Burst's "x2".
///
/// ⛔ THE ONE NUMBER WITH NOWHERE IN THE CATALOGUE TO LIVE — see
/// <see cref="MicroBurstRounds"/>. It is a single named constant read from a single
/// place (<see cref="BurstHitBonus"/>) precisely so that giving `WeaponTech.Node` a
/// second spare field later is a one-line change, but until then `nz_tech` cannot
/// print it and this file is its only source. That is a reported gap, not a design.
/// </summary>
const float MicroBurstHitBonus = 2f;
/// <summary>
/// Did the previous round of the CURRENT burst land on a living zombie.
///
/// ⚠️ AN INSTANCE FIELD ON THE WEAPON, which is what makes (a) it impossible to leak
/// between weapons — a holstered gun and the held one are two components, and a
/// Pack-a-Punch clone is a third with fresh state — and (b) the reset trivial: it is
/// cleared in <see cref="Shoot"/> whenever the round index is 0, so every burst
/// starts from "nothing has connected yet" without depending on anything noticing
/// that the previous burst ended.
///
/// ⚠️ Shared between the primary and secondary attacks, exactly as `burstCount`
/// already is. Weapon.cs picks one or the other per frame with an `else if`, so the
/// two cannot be mid-burst simultaneously.
/// </summary>
bool _burstPrevHit;
/// <summary>One warning per weapon, not one per shot.</summary>
bool _burstHitBonusWarned;
/// <summary>
/// What fire mode this weapon actually has right now.
///
/// ⛔ RESOLVED AT READ TIME, NOT STAMPED ON THE WEAPON. Every tech node that writes a
/// stored field goes through NZPlayer's spawn-time path, and that path has to
/// remember the authored value in `TechBase` so a re-equip can restore it —
/// `ApplyStoredUpgrades` runs on EVERY equip. `FiringType` is an enum with no neutral
/// value to multiply by, so a spawn-time write would need its own remembered base
/// for one node's sake. Reading it here costs one list lookup per trigger event and
/// leaves the prefab's field alone, which is the trade `GetRealRPM` and
/// `GetRealSpread` already make.
///
/// ⛔ AND IT IS A SWITCHBOARD, NOT A PRECEDENCE CHAIN. Four tier-5 nodes also decide
/// fire mode — Overclocked, Bolt Gun, Ricochet Rounds and Ten-Round Burst — and the
/// matrix authored on `t4_microburst` in WeaponTech.cs says Micro-Burst COMBINES with
/// each of them rather than losing to one. Resolving fire mode by "the higher tier
/// wins" would silently discard a 1,050-salvage pick.
///
/// ⚠️ COSTS UP TO FIVE TECH LOOKUPS PER CALL, and that is affordable only because of
/// where it is called from: CanShoot's master gate returns false before reaching it
/// unless the trigger is down or a burst is latched, so a player walking around pays
/// nothing. Do not move it above that gate.
/// </summary>
public virtual FiringType EffectiveFiringType( ShootInfo shootInfo )
{
if ( shootInfo is null ) return FiringType.semi;
// ⛔ MICRO-BURST FIRST, AND THAT IS THE COMBINATION RULE RATHER THAN A PRECEDENCE.
// All four authored pairings in the matrix resolve to BURST — automatic burst with
// Overclocked, two rounds then a five-times gap with Bolt Gun, a kept burst instead
// of semi with Ricochet, a ramping ten-round burst with Ten-Round — so one `return
// burst` satisfies every one of them. Nothing is discarded, because no pairing's
// tier-5 half lives in this method: Bolt Gun's x0.2 is in GetRealRPM, Ten-Round's
// length and ramp are in BurstRoundsFor and BurstDamageRamp, Overclocked's rate is
// spawn-time and its inter-burst gap is in CanShoot, Ricochet's bounces are in the
// bullet path.
if ( NZombies.TechEffects.Has( this, "t4_microburst" ) ) return FiringType.burst;
// ⚠️ THE AUTHORED VALUE IS ALSO WHERE CHIMERA SUBSTITUTES, when that node is wired
// — last, as a replacement for the prefab's own mode rather than a fifth competing
// branch, because ~48% of its draws land on the mode the weapon already had.
// ⛔ CHIMERA'S ROLLED MODE STANDS IN FOR THE AUTHORED ONE, PRIMARY ONLY (2026-10-03, the user: "for
// chimera, we should also add ... fire mode"). The roll has stored a mode since the node was built
// and nothing read it, so a Chimera gun always fired as authored. It replaces the BASE, not the
// result: Micro-Burst above, the mode nodes below and Double Tap's Full Auto all still act on it,
// exactly as they act on an authored mode. A roll of burst on a gun authored otherwise is covered
// by `CanShoot`'s read-time burst clear, the same one Ten-Round Burst relies on.
var mode = shootInfo == Primary && NZombies.TechEffects.ChimeraMode( this ) is FiringType rolled
? rolled
: shootInfo.FiringType;
var owned = 0;
// ⛔ LAST MATCH WINS, IN A FIXED ORDER, AND THE ORDER IS A DECISION. Tier 5 is
// pick-one so these four cannot co-exist in normal play — but `WeaponTech.Unlimited`
// lifts that in creative, which is exactly where fire modes get tested, so leaving
// the outcome to whichever `Has` happens to be written first would make the test
// bench disagree with the game for reasons nobody could see. The order is
// DESCENDING FIRE VOLUME (burst, auto, semi, semi), so a creative stack settles on
// the most restrictive mode it owns rather than the loudest.
if ( NZombies.TechEffects.Has( this, "t4_tenburst" ) ) { mode = FiringType.burst; owned++; }
if ( NZombies.TechEffects.Has( this, "t4_overclock" ) ) { mode = FiringType.auto; owned++; }
// ⚠️ AUTOLOADER SITS WITH THE OTHER `auto` IN THE DESCENDING-VOLUME ORDER the block
// above argues for. It converts unconditionally — including on a weapon whose pellet count
// made the rest of the node a no-op — because "full-auto" is the half of it a player can see,
// and a node that silently did nothing on a rifle would be the unobservable failure this
// tier has already paid for twice.
if ( NZombies.TechEffects.Has( this, "t4_autoload" ) ) { mode = FiringType.auto; owned++; }
if ( NZombies.TechEffects.Has( this, "t4_boltgun" ) ) { mode = FiringType.semi; owned++; }
// ⚠️ IN THE SAME DESCENDING-VOLUME ORDER as the three above, so a creative stack still
// settles on the most restrictive mode it owns rather than on whichever `Has` runs last.
// Hair Trigger is semi and therefore goes below the auto conversion.
if ( NZombies.TechEffects.Has( this, "t4_fullauto" ) ) { mode = FiringType.auto; owned++; }
if ( NZombies.TechEffects.Has( this, "t4_hairtrigger" ) ) { mode = FiringType.semi; owned++; }
// ⛔ THE PER-CLASS AUGMENTS' FIRE MODES (2026-10-04), as `f.*` switches: semi (Marksman Conversion, Scout),
// auto (Battle Rifle Conversion, Machine Pistol, Fan the Hammer), burst (Salvo, and Unload's whole-cylinder
// burst). A gun can only own one of each tier, so these never meet one another in a real game.
if ( NZombies.TechStats.Flag( this, "f.semi" ) ) { mode = FiringType.semi; owned++; }
if ( NZombies.TechStats.Flag( this, "f.auto" ) ) { mode = FiringType.auto; owned++; }
if ( NZombies.TechStats.Flag( this, "f.burst" ) || NZombies.TechEffects.Has( this, "t5_rv_unload" ) )
{ mode = FiringType.burst; owned++; }
// ⛔ SELECT FIRE'S CHOICE WINS OVER EVERYTHING ABOVE: it is the player choosing (E+R, `Weapon.SelectFire`).
if ( shootInfo == Primary && SelectFireMode( shootInfo ) is FiringType chosen ) mode = chosen;
// ── DOUBLE TAP'S M4 "FULL AUTO" ──────────────────────────────────────────
//
// ⛔ AFTER THE TIER-5 LADDER, NOT INSIDE IT, and it does not touch `owned`. Those
// four are weapon TECH and pick-one within themselves; this is a PERK AUGMENT and
// belongs to the player, so a Full Auto holder carrying Bolt Gun is a legitimate
// combination rather than a creative-only clash the warning above should fire on.
//
// ⚠️ AND IT WINS OVER THEM, which is a decision. Bolt Gun and Ricochet both force
// `semi`; an augment the player spent a major slot on being silently cancelled by a
// weapon node would be the worse outcome, and the descending-volume argument that
// orders the four above is about resolving a state normal play cannot reach — it
// does not apply to a legitimate perk-plus-tech pair.
//
// ⚠️ ONLY `semi` IS CONVERTED. Leaving `burst` alone matters: Micro-Burst returns
// above this line anyway, but Ten-Round Burst does not, and turning a ten-round
// ramping burst into plain automatic fire would delete a tier-5 node the player
// bought rather than combining with it.
if ( mode == FiringType.semi && NZombies.DtapAugments.FullAutoFor( this ) )
mode = FiringType.auto;
// ⚠️ LOGGED, ONCE PER WEAPON. A silent tie-break is how "the node I bought did
// nothing" becomes unreproducible — and this is the only diagnostic that can see it,
// because a fire mode leaves no trace in any stat panel.
if ( owned > 1 && !_fireModeClashWarned )
{
_fireModeClashWarned = true;
Log.Warning( $"[nz-tech] ⚠️ {DisplayName}: {owned} tier-5 fire-mode nodes owned at"
+ " once, which normal play cannot reach — resolved to"
+ $" `{mode}` by EffectiveFiringType's documented descending-volume order." );
}
return mode;
}
/// <summary>One warning per weapon, not one per trigger event.</summary>
bool _fireModeClashWarned;
// ── TRIGGER CHARGE, for Double Tap's M3 "Trigger Discipline" ─────────────
/// <summary>
/// Normalised 0..1 charge that fills while this weapon is NOT firing and drains while
/// it is.
///
/// ⛔ TRACKED ON THE WEAPON, NOT THE PLAYER, so Mule Kick's second and third guns each
/// hold their own charge and a swap does not inherit the other's.
///
/// ⛔ STARTS FULL, AND THAT IS CORRECT RATHER THAN GENEROUS. The charge measures time
/// spent not shooting, and a weapon you have never fired has been not-shooting for the
/// whole game. Starting at zero would mean every spawn, every wall-buy and every box
/// pull began with a ten-second penalty for something the player did not do.
///
/// ⚠️ 1f INLINE, NOT SET IN A CONSTRUCTOR OR OnStart. A field that starts wrong does not
/// survive a hotload (INSTRUCTIONS.md §1) and an auto-property initialiser is the form
/// that does.
/// </summary>
public float TriggerCharge { get; private set; } = 1f;
/// <summary>
/// Has Vigor Rush's m4 "Last Round" already splashed for the shot in progress.
///
/// ⛔ EXISTS BECAUSE EVERY PELLET OF A SHOTGUN BLAST PASSES m4's TEST. That test is
/// "the magazine is now empty", and a 16-pellet KS23 emptying its tube reaches the
/// damage path sixteen times for one trigger pull — so without this latch the splash
/// would land sixteen times.
///
/// ⚠️ CLEARED AT THE TOP OF THE BULLET LOOP, not on a timer. One shot is one pass
/// through that loop by definition, so the loop is the only thing that knows where a
/// shot begins.
/// </summary>
public bool LastRoundSplashed { get; set; }
/// <summary>
/// Fractional rounds banked by Speed Cola's M2 "Auto-Loader".
///
/// ⛔ PER WEAPON AND FRACTIONAL, both load-bearing. A 6-round shotgun earns 0.6 rounds
/// a second at a full-clip-per-10s rate; truncating that each frame would earn it
/// nothing at all, forever. And it has to be per weapon because every weapon refills
/// independently — a shared accumulator would let a rifle's progress fill a shotgun.
///
/// ⚠️ Written by `SpeedColaAugments.Tick` and reset when the magazine is full, so a
/// weapon cannot bank progress it has nowhere to put.
/// </summary>
public float AutoLoadProgress { get; set; }
/// <summary>
/// Set the charge directly. For `nz_aug_dtap_charge`.
///
/// ⚠️ A METHOD RATHER THAN A PUBLIC SETTER, so the only writers are this weapon's own
/// tick and a console command that says what it is doing. A settable property invites
/// a gameplay system to write it, and then two things own the charge.
/// </summary>
public void SetTriggerCharge( float charge ) => TriggerCharge = charge.Clamp( 0f, 1f );
/// <summary>
/// Fill or drain the charge by one frame.
///
/// ⛔ TICKED FROM `Weapon.OnUpdate`, NOT FROM `CanShoot`, AND THAT IS THE WHOLE
/// CORRECTNESS ARGUMENT. `CanShoot` has eight early returns above the point where it
/// reads the trigger — reloading, empty clip, bolt-back, sprinting — so anything
/// observed only there is missed in every one of those states. `OnUpdate` runs
/// unconditionally.
///
/// ⛔ IT ASKS `IsShooting`, WHICH IS RATE-AWARE, rather than reading the trigger. That
/// accessor is "a shot landed within one shot-interval", so a held 600 RPM weapon drains
/// continuously and a bolt-action counts as firing across its whole long interval.
/// Reading the trigger instead would drain on a DRY gun, and would never drain at all on
/// a semi-auto — where holding the trigger fires nothing after the first round.
///
/// ⚠️ RELOADING FILLS. It is not shooting, which is exactly what the augment measures.
/// That does mean a reload is worth a fifth of a charge, and that is intended: the perk
/// rewards any pause, and forcing a reload to be neutral would need a third rate and a
/// reason for it.
///
/// ⚠️ THE RATES LIVE IN `DtapAugments`, not here. This method holds the number; that one
/// holds both durations and the console command that tunes them.
/// </summary>
void TickTriggerHold()
{
if ( !Owner.IsValid() ) return;
// ⚠️ CONTAINS wep.isshooting -- IsShooting() is one of the arguments, so this scope is an
// outer over it and over the two GetRealRPM calls inside.
using ( NZombies.CpuScope.Measure( "dtap.charge" ) )
TriggerCharge = NZombies.DtapAugments.AdvanceCharge(
TriggerCharge, IsShooting(), Time.Delta );
}
/// <summary>
/// Is this weapon's burst the Overclocked + Micro-Burst automatic one.
///
/// ⚠️ THE PAIR, NOT EITHER NODE. Overclocked alone is `auto` and never reaches a burst
/// branch; Micro-Burst alone is authored to need the trigger released between bursts.
/// Only the combination is "on while the trigger is HELD".
/// </summary>
bool AutoBurst()
=> BurstIsAutomatic
|| (NZombies.TechEffects.Has( this, "t4_microburst" )
&& NZombies.TechEffects.Has( this, "t4_overclock" ))
// ⚠️ Salvo (sniper tier 5, 2026-10-04): bursts keep coming while the trigger is held, with its pause between.
|| NZombies.TechEffects.Has( this, "t5_sn_salvo" );
/// <summary>
/// Extra seconds between two automatic bursts, on top of the normal shot interval.
///
/// ⛔ THE ONE MAGNITUDE THIS PAIRING NEEDS AND THE CATALOGUE HAS NEVER AUTHORED. The
/// matrix says "a slight delay" and stops there, so it is read by NAME through `MagOf`
/// — which warns once and returns the NEUTRAL 0 when the node declares nothing, rather
/// than hiding a literal at this call site where `nz_tech` could never print it. At 0
/// the pairing degenerates into Overclocked's own single stream, which is the honest
/// meaning of "this half of the node is not authored yet".
///
/// ⚠️ ABSOLUTE SECONDS, NOT A MULTIPLE OF THE SHOT INTERVAL. A relative gap scales
/// itself away on exactly the weapons that need it most — three intervals on a 3,150
/// RPM Overclocked G11 is 57 ms, which no player hears as a separate burst, and the
/// matrix's "never a single stream" is the whole authored point.
/// </summary>
/// <summary>
/// The pause between two bursts on a weapon that fires them while the trigger is held.
///
/// ⛔ WITHOUT THIS THE WEAPON IS JUST FULL-AUTO. The gap is what separates "bursts, repeating"
/// from "one continuous stream" -- at 0 the next burst starts on the very next shot interval
/// and the burst length stops being audible or visible at all.
/// </summary>
[Property, Group( "Shooting" )] public float BurstGap { get; set; } = 0.18f;
// ⚠️ THE TECH NODE STILL WINS WHERE IT DECLARES ONE. `MagOf` returns the neutral fallback when
// the catalogue is silent, so passing the weapon's own gap as that fallback keeps Overclocked +
// Micro-Burst behaving exactly as before on weapons that are not authored as burst-automatic.
float AutoBurstGap()
=> NZombies.TechEffects.Has( this, "t5_sn_salvo" )
? NZombies.WeaponTech.MagOf( "t5_sn_salvo", "pause", 0.3f )
: BurstIsAutomatic
? NZombies.WeaponTech.MagOf( "t4_overclock", "burstgap", BurstGap )
: NZombies.WeaponTech.MagOf( "t4_overclock", "burstgap", 0f );
/// <summary>
/// Can this weapon's burst be cancelled once it has started. Ten-Round Burst's cost.
///
/// ⚠️ A VIRTUAL OF ITS OWN rather than a `Has` inlined into the three gates that need
/// it, for the reason <see cref="BurstRoundsFor"/> records: a literal at a gate is what
/// made the previous burst behaviour impossible to extend.
/// </summary>
public virtual bool BurstUninterruptible( ShootInfo shootInfo )
// ⛔ EVERY BURST COMMITS NOW, not just Ten-Round Burst's. A burst you can cut short by
// releasing the trigger is a burst in name only -- it makes a 3-round weapon fire one round
// on a tap, which reads as the burst being broken rather than as a feature. The latch is
// what carries rounds 2..N through the trigger test in CanShoot.
//
// ⚠️ EffectiveFiringType, NOT the authored one, so a weapon turned into a burst gun by a
// tech node commits the same way a weapon born as one does.
=> EffectiveFiringType( shootInfo ) == FiringType.burst
|| NZombies.TechEffects.Has( this, "t4_tenburst" );
/// <summary>
/// Is a burst in progress that the player is no longer allowed to stop.
///
/// ⚠️ DERIVED, NOT A SECOND COUNTER — the shape <see cref="BurstRoundIndex"/> already
/// documents. `burstCount` is the whole state; a `bool _inUninterruptibleBurst` would
/// be a second thing to clear on holster, on empty, on reload and on death.
///
/// ⚠️ THE TEST ORDER IS THE PERFORMANCE ANSWER. `burstCount > 0` is an int compare and
/// is false on almost every frame of almost every weapon, so the tech lookups behind it
/// never run; `BurstUninterruptible` is one lookup and comes before `BurstRoundsFor`'s
/// two and `EffectiveFiringType`'s five.
///
/// ⚠️ AND IT RE-TESTS THE FIRE MODE, which is not redundant: in creative a Ten-Round
/// weapon can also own Bolt Gun and resolve to `semi`, and a latch holding a semi
/// weapon's trigger down for it would be a gun that fires by itself.
/// </summary>
bool BurstLatched( ShootInfo shootInfo )
=> burstCount > 0
&& BurstUninterruptible( shootInfo )
&& burstCount < BurstRoundsFor( shootInfo )
&& EffectiveFiringType( shootInfo ) == FiringType.burst;
/// <summary>
/// How many rounds one burst fires.
///
/// ⚠️ A PARAMETER BECAUSE TEN-ROUND BURST NEEDS IT TO BE 10 and Micro-Burst needs it
/// to be 2, on the same weapon, from the same gate. A literal in the CanShoot branch
/// is what made that pairing impossible to add.
/// </summary>
public virtual int BurstRoundsFor( ShootInfo shootInfo )
{
// ⚠️ The AUTHORED length first, so the tech nodes below still override it rather than
// being overridden by it — Ten-Round Burst has to win on a 5-round weapon too.
var rounds = BurstRounds > 0 ? BurstRounds : SwbBurstRounds;
if ( NZombies.TechEffects.Has( this, "t4_microburst" ) )
rounds = (int)NZombies.WeaponTech.BoundOf( "t4_microburst", MicroBurstRounds );
// ⛔ TEN-ROUND IS APPLIED LAST AND OVERWRITES, WHICH IS THE PAIRING. The matrix
// authors Micro-Burst + Ten-Round as a TEN-round ramping burst, not a two-round one
// — so this cannot be an `else`, and it cannot come first either or the Micro-Burst
// line would take the length back.
//
// ⚠️ THE FALLBACK IS WHATEVER THE LENGTH WOULD HAVE BEEN WITHOUT THIS NODE, which
// is what MagOf's contract means by neutral: an undeclared `rounds` leaves the burst
// at 3, or at 2 when paired, and warns once — rather than putting a second copy of
// the 10 at this call site where `nz_tech` cannot print it.
if ( NZombies.TechEffects.Has( this, "t4_tenburst" ) )
rounds = (int)NZombies.WeaponTech.MagOf( "t4_tenburst", "rounds", rounds );
// ⚠️ DOUBLE BURST (burst action, tier 4, 2026-10-04): "each burst fires twice the rounds (3 -> 6)".
rounds = (int)MathF.Round( rounds * NZombies.TechStats.Mul( this, "s.burst" ) );
// ⚠️ EXTRA SHOT (burst action, tier 2, 2026-10-04): one more round, ADDED AFTER Double Burst's x2 — 3 -> 4, or 6 -> 7 with it
// (the user: *"add 1 shot to the burst"*). Every burst gate reads this length, so the latch carries it too.
rounds += (int)NZombies.TechEffects.Mag( this, "t2_act_extrashot", "rounds", 0f );
// ⚠️ UNLOAD (revolver tier 5): one pull is the whole cylinder. The magazine size, so the burst ends when the
// cylinder runs dry (`HasAmmo` stops it a round early if it was not full).
// ⚠️ THE CYLINDER AS ROLLED: Spin the Cylinder's ×3 holds 18, and Unload empties all of them.
if ( NZombies.TechEffects.Has( this, "t5_rv_unload" ) && shootInfo is not null && shootInfo.ClipSize > 0 )
rounds = ClassTechClip( shootInfo.ClipSize );
return rounds;
}
/// <summary>
/// COMPOUNDING damage step from one round of a burst to the next. 1 = a flat burst.
///
/// ⚠️ 1 IS THE ONLY CORRECT DEFAULT and is why neither node ramps ALONE: the escalating
/// burst belongs to the Micro-Burst + Ten-Round PAIRING, per the matrix. Micro-Burst
/// alone fires two flat rounds at +40%; Ten-Round Burst alone fires ten flat rounds at
/// -33%. <see cref="BurstDamageFactor"/> raises the step returned here to the round
/// index — compounding, so round ten is 1.20^9 = 5.16x round one, not the 2.8x an
/// additive read would give.
/// </summary>
public virtual float BurstDamageRamp( ShootInfo shootInfo )
{
// ⚠️ ESCALATION (burst action, tier 5, 2026-10-04): "each round in a burst deals x1.2 the damage of the one
// before", the ramp Ten-Round Burst's pairing already drives.
if ( NZombies.TechEffects.Has( this, "t5_act_escalation" ) )
return NZombies.WeaponTech.MagOf( "t5_act_escalation", "ramp", 1f );
// ⛔ BOTH NODES, OR NEITHER. This is the only place in the burst path where a
// pairing rather than a node decides a number, and the matrix is explicit about why:
// the ramp is what makes this pairing work on physical-bullet weapons, where
// Micro-Burst's own "did the last round hit" can never read true.
if ( NZombies.TechEffects.Has( this, "t4_tenburst" )
&& NZombies.TechEffects.Has( this, "t4_microburst" ) )
// ⚠️ NEUTRAL FALLBACK OF 1, so an undeclared `ramp` gives a flat ten-round
// burst and one warning — not a silently plausible curve.
return NZombies.WeaponTech.MagOf( "t4_tenburst", "ramp", 1f );
return 1f;
}
/// <summary>
/// What a round is multiplied by when the round BEFORE it connected with a zombie.
/// 1 = no conditional bonus, and therefore no hit tracking at all.
/// </summary>
public virtual float BurstHitBonus( ShootInfo shootInfo )
{
// ⛔ WITHDRAWN WHENEVER A RAMP IS PRESENT, AND THIS IS NOT A DUPLICATE OF
// BurstDamageFactor'S OWN `ramp == 1f` GUARD. That guard is the arithmetic backstop
// and must stay. This line is the PERFORMANCE half of the same decision: `wantsHit`
// in Shoot() keys off THIS method, so without it a ten-round burst runs the
// ShotHitsZombie probe trace on rounds 0-8 — nine extra traces per burst, at x3 fire
// rate, to compute a number the guard then throws away.
//
// ⚠️ Keyed off BurstDamageRamp rather than off `t4_tenburst`, so the two can never
// disagree about which pairings ramp.
if ( BurstDamageRamp( shootInfo ) != 1f ) return 1f;
return NZombies.TechEffects.Has( this, "t4_microburst" ) ? MicroBurstHitBonus : 1f;
}
/// <summary>
/// Which round of the current burst is about to fire. 0-based.
///
/// ⚠️ DERIVED FROM `burstCount` RATHER THAN COUNTED AGAIN. CanShoot increments it
/// immediately before Shoot runs, so the round that is firing is `burstCount - 1`,
/// and it is already reset at the end of every burst — a second counter would be a
/// second thing to reset and a second chance to get the boundary wrong.
/// </summary>
int BurstRoundIndex( ShootInfo shootInfo )
=> EffectiveFiringType( shootInfo ) == FiringType.burst
? Math.Max( 0, burstCount - 1 )
: 0;
/// <summary>
/// Per-BULLET damage multiplier for the round about to leave the barrel.
///
/// ⛔ PER BULLET, WHICH IS THE WHOLE MAGNITUDE OF THIS NODE. Nothing here divides by
/// the burst length: a 2-round burst at +40% each is 2.8 base-bullet units against
/// one unmodified shot, not 1.4, and 4.2 when the first round connects (1.4 + 2.8).
/// A shotgun multiplies every pellet by the same figure, as it does for Pack-a-Punch.
/// </summary>
float BurstDamageFactor( ShootInfo shootInfo, int round )
{
// ⚠️ Returns 1 on a weapon that does not own the node, so this whole path is
// neutral rather than conditional — and the 1.40 comes from the catalogue, which
// is what `nz_tech` prints.
var factor = NZombies.TechEffects.Factor( this, "t4_microburst" );
var ramp = BurstDamageRamp( shootInfo );
if ( ramp != 1f && round > 0 ) factor *= MathF.Pow( ramp, round );
// ⛔ THE RAMP AND THE DOUBLING ARE MUTUALLY EXCLUSIVE, AND WITHOUT THIS GUARD THEY
// BOTH APPLIED. Micro-Burst alone is unaffected (its ramp is 1), but the moment
// Ten-Round Burst wires a ramp the two stack, and on a ten-round burst the x2 lands
// on NINE of the rounds: measured, round ten becomes 9.68 base-bullet units against
// the 4.84 the ramp alone gives, and the burst totals 47.8 rather than 24.3 — twice
// the intended node.
//
// The catalogue's own matrix on t4_microburst already states the resolution: with
// Ten-Round the ramp is UNCONDITIONAL and takes the doubling's place, which is also
// what makes that pairing work on physical-bullet weapons where "did the last round
// hit" can never read true. So a ramp being present is exactly the signal that the
// conditional bonus should stand down.
//
// ⚠️ Found by an adversarial verifier reading the arithmetic against the
// published figures, not by anything failing. The two numbers disagreed and only
// one of them was in the code.
if ( ramp == 1f && round > 0 && _burstPrevHit ) factor *= BurstHitBonus( shootInfo );
return factor;
}
/// <summary>
/// Would a shot down this exact line land on a LIVING zombie.
///
/// ⛔ A SECOND TRACE, DELIBERATELY, BECAUSE THE BULLET DOES NOT REPORT BACK.
/// `HitScanBulletInfo.Shoot` resolves the hit and applies the damage without
/// returning anything, and it is not this file. So the shot's own line — the same
/// eye position, the same `EyeAngles.Forward + spreadOffset`, the same
/// `TraceBullet` — is re-run here. It is at most one extra trace per burst on a
/// weapon that owns the node, because <see cref="Shoot"/> only probes while a LATER
/// round could still use the answer and stops at the first pellet that connects.
///
/// ⚠️ ONE KNOWN CONSERVATIVE MISS: a zombie behind penetrable cover. The real bullet
/// spends its `PenetrationDepth` budget and reaches the body; this single trace stops
/// at the cover and reads "no hit", so the bonus is withheld rather than wrongly
/// given. Reproducing the penetration walk here would be a second copy of that loop
/// — the fix is a hit report from the bullet path itself, which is
/// BulletInfo.HitScan.cs and not owned here.
///
/// ⚠️ Reads the zombie's own `State`, not the "zombie" TAG. `StopBeingSolid` strips
/// that tag at death while the ZombieAI component survives on the corpse, so the
/// component test alone (the one Health.cs and PerkEffects.cs use for "is this a
/// zombie") would count a ragdoll as a live hit.
/// </summary>
bool ShotHitsZombie( ShootInfo shootInfo, Vector3 spreadOffset )
{
if ( !Owner.IsValid() ) return false;
var forward = (Owner.EyeAngles.Forward + spreadOffset).Normal;
var tr = TraceBullet( Owner.EyePos, Owner.EyePos + forward * TraceRange,
ignoreTags: shootInfo.Penetration ? PenetrationBulletTraceIgnoreTags : null );
if ( !tr.GameObject.IsValid() ) return false;
var zombie = tr.GameObject.Components
.Get<NZombies.ZombieAI>( FindMode.EverythingInSelfAndAncestors );
return zombie.IsValid() && zombie.State != NZombies.ZombieState.Dead;
}
/// <summary>
/// The upgraded-weapon report, looked up once.
///
/// ⚠️ Cached because this is a per-shot path — an automatic weapon would
/// otherwise hit the resource library ten times a second for a constant.
/// </summary>
/// <summary>
/// Tell everybody else this gun went off, so they hear it from where the shooter is.
///
/// ⚠️ THE CUE TRAVELS AS A RESOURCE PATH. The `SoundEvent` is a resource held by a weapon
/// that exists on one machine; a path is something any machine can resolve for itself.
///
/// ⛔ AT THE OWNER'S EYE, NOT THE WEAPON'S TRANSFORM. `Weapon.PlaySound` documents why that
/// transform is useless — in first person the object is parked about a million units below the
/// map so its world model cannot be seen, which once put a cue `1001200u from ear`. The eye is
/// also simply where a gunshot should sound like it came from.
///
/// ⚠️ ONLY MY OWN SHOTS, and the receiver skips its own copy as well. Between them a shot
/// is heard exactly once on every machine.
/// </summary>
void RelayShotSound( SoundEvent sound, GunCue cue = null )
{
if ( !Networking.IsActive || sound is null ) return;
if ( !Owner.IsValid() ) return;
var owner = NZombies.NZPlayers.OwnerOf( Owner.GameObject );
if ( string.IsNullOrEmpty( owner ) ) return;
if ( owner != Connection.Local?.Id.ToString() ) return;
// ⛔ A BUILT CUE HAS NO PATH, SO IT TRAVELS AS ITS KEY: the template and the recordings, which every machine
// rebuilds the same way (GunSounds.FromKey, in NZSound.Play). Sending the template's path instead would play
// the template's own recordings on everyone else's machine: the wrong gun.
// ⛔ THE WHOLE CUE, packed recordings included (step 4): a key of `Clips` alone is EMPTY for a packed cue, and the
// other players would hear nothing.
NZombies.NZNet.ShotSound( owner, cue is not null && cue.IsSet ? GunSounds.Key( sound, cue ) : sound.ResourcePath,
Owner.EyePos );
}
static SoundEvent _papShootSound;
public virtual void Shoot( ShootInfo shootInfo, bool isPrimary )
{
// ⛔ THE WHOLE COST OF PULLING THE TRIGGER, and the number that decides where to look
// next. A frame hitting 1-2 zombies costs 22.87 ms against a quiet frame's 12.28; the
// damage path explains 3.75 of that 10.59 ms gap. If shot.total does not cover most of
// the remaining 6.69, the cost is NOT this method -- it is what firing hands to the
// engine afterwards (audio mixing, particle simulation, animation evaluation), and no
// scope in here can see any of it.
using var _cpuShot = NZombies.CpuScope.Measure( "shot.total" );
// ⛔ DOUBLE TAP m5 OVERPRESSURE ROLLS HERE, BEFORE THE MAGAZINE IS TOUCHED, because it
// changes what this pull costs — and it is read ONCE for the whole trigger pull, like the
// burst and charged-trigger factors below. A shotgun's eight pellets are one shot and must
// all be the same round; rolling per bullet would give you a blast that was partly
// overpressured, which is not a thing a cartridge can be.
var overpressure = NZombies.DtapAugments.RollOverpressure( this, shootInfo );
// ⛔ THE POINTS BUDGET FOR THIS TRIGGER PULL OPENS HERE — above the pellet loop AND above
// Double Tap M1's outer pass, because both of those are still ONE SHOT. Opening it per
// pellet would restore exactly the exploit it exists to close: twelve pellets each paying
// for ten penetrated bodies. See `ShotPoints`.
NZombies.ShotPoints.BeginShot();
// ⚠️ THE MAGAZINE BEFORE THIS SHOT PAYS (2026-10-04): Final Round, Last Ten and Long Haul read it.
var roundsBefore = shootInfo.Ammo;
ClassTechBeginShot();
// Ammo
if ( shootInfo.InfiniteAmmo != InfiniteAmmoType.clip )
{
// ⚠️ ARC9 authors AmmoPerShot; SWB always consumed exactly 1. Clamped
// so a misconfigured 0 cannot make the weapon fire forever.
shootInfo.Ammo -= Math.Max( 1, shootInfo.AmmoPerShot );
// ⚠️ THE EXTRA ROUND, ON TOP OF WHATEVER THE SHOT ALREADY COST. `RollOverpressure`
// has already refused to roll on a magazine that cannot pay, so this cannot go
// negative — and adding to the existing cost rather than assigning 2 keeps an
// `AmmoPerShot`-2 weapon honest.
if ( overpressure ) shootInfo.Ammo -= NZombies.DtapAugments.OverpressureExtraAmmo();
// ⛔ A FLOOR OF ZERO, AND DOUBLE FEED IS WHAT MADE IT NECESSARY. `HasAmmo` asks only
// `Ammo == 0`, so a magazine holding ONE round happily fires a shot that costs two and
// lands on -1 — after which `Ammo == 0` is false forever and the weapon will neither
// fire nor auto-reload. Authored `AmmoPerShot > 1` could always reach this; the node is
// simply the first thing that makes it common.
if ( shootInfo.Ammo < 0 ) shootInfo.Ammo = 0;
}
// ⚠️ WHAT THE PER-CLASS AUGMENTS DO ON EVERY SHOT (2026-10-04): Blood Price's health, Momentum, Dynamo, Bolt Strike.
ClassTechOnShot( shootInfo );
// Animations
var shootAnim = GetShootAnimation( shootInfo );
if ( !string.IsNullOrEmpty( shootAnim ) )
using ( NZombies.CpuScope.Measure( "shot.anim" ) )
ApplyShootAnimation( shootAnim );
// ⚠️ Bolt guns cycle after the shot. Only when rounds remain — an empty rifle
// goes straight to its reload, which does its own bolt work.
if ( BoltActionPerShot && shootInfo == Primary && shootInfo.Ammo > 0 )
using ( NZombies.CpuScope.Measure( "shot.bolt" ) )
AsyncBoltCycle();
// Sound
if ( WeaponDebug )
Log.Info( $"[swb-dbg] Shoot() reached ShootSound="
+ $"{(shootInfo.ShootSound is null ? "NULL" : shootInfo.ShootSound.ResourceName)}"
+ $" IsProxy={IsProxy} shootInfo=={(shootInfo == Primary ? "Primary" : "Secondary/other")}" );
if ( shootInfo.ShootSound is not null )
{
// ⚠️ STEAM AUDIO IS ON (occlusion, diffraction and reverb all enabled) and the bullet-impact
// sound already measured at roughly 0.85 ms a call. A gunshot is the same code path.
using ( NZombies.CpuScope.Measure( "shot.sound" ) )
PlayCue( shootInfo.ShootSound, shootInfo.ShootSoundCue );
// ⛔ AND EVERYBODY ELSE HEARS NOTHING WITHOUT THIS. `PlaySound` is local; SWB's own
// networked audio is an RPC on the WEAPON, which is `NetworkMode.Never` and therefore
// does not exist on any other machine — the `Unknown GameObject ... for RPC PlaySound`
// spam in the log is that message failing, once per shot, forever. A teammate emptying
// an LMG beside you was completely silent.
RelayShotSound( shootInfo.ShootSound, shootInfo.ShootSoundCue );
}
// ⚠️ LAYERED OVER the weapon's own report, never instead of it. Every gun
// keeps its voice and gains a signature on top — which is why one sample
// covers all 31 weapons instead of needing a packed variant each.
//
// ⚠️ Keyed off the DAMAGE MULTIPLIER rather than asking the player their PaP
// level. This file is SWB base code and knows nothing about nZombies; a
// weapon that hits harder than it was authored to is the whole condition,
// and it stays true for anything else that ever boosts damage.
//
// ⛔ GOES THROUGH PlaySound, NOT Sound.Play AT WorldPosition. The weapon
// object is parked ~1,000,000 units below the map in first person so its
// world model cannot be seen — playing at its transform put this cue
// `1001200u from ear`, i.e. silent. PlaySound already solves that by
// emitting at the eye when the viewmodel is up, and that fix is documented
// twenty lines into Weapon.PlaySound. Reusing it beats rediscovering it.
if ( shootInfo.IsPacked )
{
_papShootSound ??= ResourceLibrary
.Get<SoundEvent>( $"sounds/nz/{NZombies.NZSound.PapShoot}.sound" );
// ⚠️ A SECOND SPATIALISED SOUND ON EVERY SHOT once the weapon is Pack-a-Punched. Same scope
// name on purpose: shot.sound reports the pair, and n_shot_sound says whether it was one
// or two.
using ( NZombies.CpuScope.Measure( "shot.sound" ) )
if ( _papShootSound is not null ) PlaySound( _papShootSound );
}
// Particles
using ( NZombies.CpuScope.Measure( "shot.effects" ) )
HandleShootEffects( isPrimary );
// The kick this shot will add to the view once its bullets are away. See the apply
// at the end of Shoot().
// ⚠️ ONCE PER TRIGGER PULL, ABOVE THE PELLET LOOP. Marked counts shots, and a shotgun
// fires one shot with eight bullets — see `BeginMarkedShot`.
BeginMarkedShot();
var pendingRecoil = Angles.Zero;
if ( !Owner.IsBot )
{
// Barrel smoke
barrelHeat += 1;
// Recoil
//
// ⛔ RAILGUN'S "NO RECOIL" IS ZEROED INSIDE GetRecoilAngles (see FinishRecoil in
// Weapon.Getters.cs), DELIBERATELY NOT HERE, and it stops at this channel. The
// screenshake and the visual recoil below are NOT zeroed, and that is a decision
// rather than an oversight: neither goes through FinishRecoil, neither moves
// where the bullet goes, and a capstone whose gun sits perfectly still in the
// hands reads as a toy rather than as a railgun. The node buys perfect AIM, not
// a silent weapon. If design ever wants the buck gone too, it is these two
// blocks and a second named magnitude — not a change at FinishRecoil.
// ⚠️ COMPUTED HERE, APPLIED AT THE END OF THIS METHOD. The call stays put because it
// ADVANCES STATE — _recoilShot, _recoilAccum and _timeSinceRecoil — and the visual
// recoil below reads _recoilShot for its phase. Only the write to the view is
// deferred, so the pattern a player learns is byte-for-byte the one they had.
using ( NZombies.CpuScope.Measure( "shot.recoil" ) )
pendingRecoil = GetRecoilAngles( shootInfo );
// ⚠️ THE MW BASE'S LOOK, FROM THE KICK JUST MADE (`NZombies.MwRecoilFx`): the screen rattle, the gun's
// springs and what is left of the view punch. It READS the kick and never writes the view, so the pattern
// a player learns and where the bullets go are exactly what they were.
using ( NZombies.CpuScope.Measure( "shot.mwfx" ) )
NZombies.MwRecoilFx.OnShot( this, shootInfo, pendingRecoil );
// Screenshake
if ( shootInfo.ScreenShake is not null )
using ( NZombies.CpuScope.Measure( "shot.shake" ) )
Owner.ShakeScreen( shootInfo.ScreenShake );
// ⚠️ Fired alongside the aim recoil, not instead of it: the two are
// different channels. Aim recoil moves where the bullet goes; this
// only moves the model, so the gun visibly bucks even on a weapon
// tuned to have almost no aim climb.
if ( shootInfo.UseVisualRecoil && ViewModelHandler is not null )
{
var sightsUp = IsAiming ? shootInfo.VisualRecoilUpMultSights : 1f;
var sightsSide = IsAiming ? shootInfo.VisualRecoilSideMultSights : 1f;
// ⛔ EVERY COMPONENT NEEDS VARIANCE, NOT JUST THE SIGNED ONES.
// Up and Punch were applied at their exact authored value on every
// shot, so the gun bucked identically each time — which reads as a
// scripted loop rather than as a gun, and makes the randomised side
// and roll look like a consistent lean because they are the only
// things changing.
//
// ⚠️ Magnitudes vary 65-100%; direction stays signed-random. Varying
// the SIGN of the vertical would make the muzzle dip on some shots,
// which no real weapon does.
var vary = Game.Random.Float( 0.65f, 1f );
// ⛔ SIDE AND ROLL FOLLOW A SINE, NOT A COIN FLIP. Independent random
// signs on an ADDITIVE, decaying value random-walk: during sustained
// fire the sum can sit on one side for a whole burst, which is why
// the ADS sight showed a constant tilt rather than a shimmy. A sine
// is bounded, visits both sides evenly, and sums to zero per cycle.
//
// ⚠️ Phase is the RECOIL SHOT INDEX, so the model's sway stays in
// step with the aim pattern instead of fighting it.
//
// ⚠️ ROLL GETS THE SIGHTS MULTIPLIER TOO. ARC9 only authors one for
// up and side, so roll ran at FULL strength while aiming — the one
// component least tolerable there, because a rolled viewmodel tips
// the sight picture itself.
// ⛔ A FRESH RANDOM DIRECTION PER SHOT, NOT A POSITION ON A SWEEP. Two previous
// attempts tried to make an ACCUMULATING kick unbiased — independent random signs
// (random-walked onto one side), then a sine (one-sided for any burst shorter than
// its period). Both failed the same way, because accumulation is what carries a bias
// from one shot into the next. User, after the second: *"now its always to the left
// ... i want each shot to be going in a random direction, and recenter before the
// next shot."*
//
// ⚠️ ONE ANGLE DRIVES BOTH SIDE AND ROLL, as cos and sin of it. Rolling them
// independently would let the gun twist one way while sliding the other, which reads
// as two effects rather than one weapon moving; a single direction on the side/roll
// plane keeps the kick coherent.
//
// ⚠️ UP IS STILL NEVER NEGATIVE. A muzzle that dips on some shots is not a thing any
// weapon does; only its MAGNITUDE varies, which is what `vary` above is for.
var recentre = NZombies.GlobalHandling.RecoilStability;
var kickDir = Game.Random.Float( 0f, MathF.Tau );
var sideWave = recentre
? MathF.Cos( kickDir )
: MathF.Sin( RecoilPhase + _recoilShot * NZombies.GlobalHandling.VisualSideFreq );
var rollWave = recentre
? MathF.Sin( kickDir )
: MathF.Sin( RecoilPhase + _recoilShot * NZombies.GlobalHandling.VisualRollFreq );
// ⛔ RECOVERY DERIVED FROM FIRE RATE, because "before the next shot" cannot be a fixed
// number across a fleet whose rates differ fourfold. `VisualRecoilRecovery` is 0.15s
// on most weapons — about right at 400 RPM, far too slow at 900.
//
// ⚠️ IT ONLY SHORTENS. A slow weapon keeps what its author asked for.
var shotGap = shootInfo.RPM > 1f ? 60f / shootInfo.RPM : 0.15f;
var settle = recentre
? MathF.Min( shootInfo.VisualRecoilRecovery,
shotGap * NZombies.GlobalHandling.StabilitySettle )
: shootInfo.VisualRecoilRecovery;
// ── THE MODEL LEANS THE WAY THE SHOT ACTUALLY PUSHED ────────────────────
//
// ⛔ UNTIL NOW THESE TWO CHANNELS DISAGREED BY DESIGN. The aim kick has a pattern,
// jitter and per-weapon multipliers; the model's horizontal direction was
// `kickDir`, a fresh random angle per shot. The view went one way and the gun
// leaned another. This reads the finished kick instead, so they agree.
//
// ⚠️ SIGNS. `GetRecoilAngles` returns `new Angles( -up, side, 0 )` — pitch is
// NEGATIVE for a muzzle rise, Source convention. `ApplyVisualRecoil` takes its
// first argument as "up" and the handler negates it again
// (`Rotation.From( -_visualRecoil.x, ... )`), so `-pitch` here is a rise at both
// ends. Yaw passes straight through: the gun leans the way the view was pushed.
//
// ⚠️ NO `vary`, AND NO `sightsUp`/`sightsSide`, ON THIS PATH. The kick already
// carries its own jitter (`VerticalJitter`, `HorizontalJitter`) and its own ADS
// damping (`aimMult`, ×0.4) — applying either again would randomise a random
// number and damp twice from two unrelated sources. `VisualFollowAds` is the one
// deliberate extra reduction; see its note.
var follow = NZombies.GlobalHandling.VisualFollowsRecoil;
// ⛔ A ZEROED KICK FALLS BACK RATHER THAN STANDING STILL, and that is not a
// tidiness guard. The railgun tech zeroes recoil inside `GetRecoilAngles`, and the
// block above says in as many words that the visual buck is deliberately left
// alive: *"a capstone whose gun sits perfectly still in the hands reads as a toy
// rather than as a railgun."* Following a zero would have silently repealed that.
if ( follow && pendingRecoil.pitch == 0f && pendingRecoil.yaw == 0f )
follow = false;
// ⚠️ UP AND SIDE ARE SEPARATE GAINS, because they are judged separately: a muzzle
// rise reads as power, a sideways lean reads as the gun being hard to hold, and
// they want different amounts almost immediately.
var ads = IsAiming ? NZombies.GlobalHandling.VisualFollowAds : 1f;
// ⛔ THE PER-WEAPON MULTIPLIER IS COMPRESSED BEFORE IT REACHES THE LEAN. The kick
// already carries it in full, and in full it is a per-SECOND number: a 42 rpm rifle
// is stamped ×22.86 so that firing it nineteen times less often averages out. Right
// for aim, a cartwheel for a per-shot visual — measured, 102 of 490 weapons would
// lean past 20° and the worst past 100°. `SpreadFactor` carries the arithmetic and
// why the exponent is minus one.
var tiltUp = NZombies.GlobalHandling.VisualFollowUp * ads
* NZombies.GlobalHandling.SpreadFactor( shootInfo.RecoilVerticalMult );
var tiltSide = NZombies.GlobalHandling.VisualFollowSide * ads
* NZombies.GlobalHandling.SpreadFactor( shootInfo.RecoilHorizontalMult );
var vmUp = follow
? -pendingRecoil.pitch * tiltUp
: shootInfo.VisualRecoilUp * sightsUp * vary;
var vmSide = follow
? pendingRecoil.yaw * tiltSide
: sideWave * shootInfo.VisualRecoilSide * sightsSide * vary;
// ⚠️ ROLL RIDES THE SIDE GAIN, NOT ITS OWN, so turning the lean down turns the
// twist down with it. They are one motion; `VisualFollowRoll` only sets how much
// of that motion is twist rather than slide.
var vmRoll = follow
? pendingRecoil.yaw * tiltSide * NZombies.GlobalHandling.VisualFollowRoll
: rollWave * shootInfo.VisualRecoilRoll * sightsSide * vary;
// ⚠️ THE REQUEST IS LOGGED BEFORE IT IS HANDED OVER, so a lean that never appears
// can be split into "never asked for" and "asked for and lost". See TiltProbe.
// ⚠️ THE GUN'S OWN SCALE ON ALL OF IT (Stats' "visual recoil"), once the three are composed, so it
// reaches both paths — following the kick or the authored random one — and the punch below.
var vscale = shootInfo.VisualRecoilScale;
vmUp *= vscale;
vmSide *= vscale;
vmRoll *= vscale;
NZombies.TiltProbe.Asked( pendingRecoil, vmUp, vmSide, vmRoll, follow );
// ⛔ NOT WHILE THE MW LOOK OWNS THE GUN (`MwRecoilFx.OwnsGun`). Its springs were kicked above, and the two
// on one model would stack into a recoil neither was tuned for. `nz_mw_gun 0` hands the gun back to this.
if ( !NZombies.MwRecoilFx.OwnsGun )
using ( NZombies.CpuScope.Measure( "shot.vmrecoil" ) )
ViewModelHandler.ApplyVisualRecoil(
vmUp,
vmSide,
vmRoll,
// ⛔ PUNCH MUST BE DAMPED IN ADS, and it was the ONLY component
// with no sights multiplier at all.
//
// The punch slides the weapon back along its OWN forward axis
// (`vrPos.y * WorldRotation.Forward`). While aiming the model sits
// off the eye axis — the Galil's ADS offset is (-2.01, 2.71, 0.42)
// — so moving it back does not merely change distance, it SWEEPS
// THE SIGHT SIDEWAYS. That is an apparent tilt of the sight
// picture produced by a purely translational effect, which is why
// damping the rotational components did nothing.
shootInfo.VisualRecoilPunch * vscale * Game.Random.Float( 0.65f, 1f ) * sightsUp,
settle,
recentre );
}
// UI
// ⚠️ ONE UI BROADCAST PER SHOT. Whether that is cheap is exactly the sort of thing that gets
// assumed rather than measured -- at 900 rpm it runs 15 times a second.
using ( NZombies.CpuScope.Measure( "shot.ui" ) )
BroadcastUIEvent( "shoot", GetRealRPM( shootInfo.RPM ) );
}
// Bullet
var burstRound = BurstRoundIndex( shootInfo );
// ⚠️ THE RESET. Round 0 is the start of a burst by definition, so the flag cannot
// survive into the next one and nothing has to notice that the last one ended.
if ( burstRound == 0 ) _burstPrevHit = false;
var burstFactor = BurstDamageFactor( shootInfo, burstRound );
// Is anything still going to ASK whether this round connected? Only if a later
// round of this same burst could spend the answer.
var wantsHit = BurstHitBonus( shootInfo ) != 1f
&& burstRound + 1 < BurstRoundsFor( shootInfo );
// ⛔ THE CONDITIONAL BONUS IS GATED TO HITSCAN, AND IT SAYS SO OUT LOUD. A
// physical bullet is still in the air when the next round leaves the barrel, so
// "did the last round hit" can only ever read false on those weapons — the exact
// silent no-op WeaponTech.cs warns about on this node. The alternative, holding
// round two until round one lands, was rejected: flight time is unbounded (a shot
// into the sky never resolves), so it would stall the burst indefinitely and turn
// a damage bonus into an input bug.
//
// ⚠️ Costs nothing today: all 31 weapon prefabs author `"BulletType": null` and
// Weapon.cs substitutes a `HitScanBulletInfo` on start, so no shipped weapon can
// take this branch. The warning is for whoever authors the first physical one.
var canProbe = shootInfo.BulletType is HitScanBulletInfo;
if ( wantsHit && !canProbe && !_burstHitBonusWarned )
{
_burstHitBonusWarned = true;
Log.Warning( $"[nz-tech] ⛔ {DisplayName}: Micro-Burst's conditional second"
+ $" round is INACTIVE on a {shootInfo.BulletType?.GetType().Name ?? "null"}"
+ " weapon — a physical bullet is still travelling when round two fires, so"
+ " \"did round one hit\" can never read true. The burst and its +40% per"
+ " bullet still apply; only the x2 is withheld." );
}
var probeLine = wantsHit && canProbe;
// ⛔ THE BURST MULTIPLIER RIDES ON `DamageMultiplier`, NEVER ON `Damage`.
// `Damage` is the authored value a re-equip restores from, and `DamageFor` is the
// one choke point both bullet paths read — ShootInfo's own header says so. It is
// set here and restored in the `finally`, so the window is the bullet loop and
// nothing else.
//
// ⚠️ THIS USED TO CARRY A LOAD-BEARING ORDERING ARGUMENT — that the two tests meaning
// "this gun is PACKED" had already run by this line, so a burst round could not hand a
// stock weapon the Pack-a-Punch presentation. It was true of those two and FALSE of the
// two that run per bullet, inside this window. They all read `ShootInfo.IsPacked` now, so
// nothing downstream depends on where in this method it sits.
var authoredDamageMult = shootInfo.DamageMultiplier;
// ⛔ TRIGGER DISCIPLINE RIDES ON `DamageMultiplier` FOR EXACTLY THE REASON THE
// BLOCK ABOVE GIVES FOR MICRO-BURST. `Damage` is the authored value a re-equip
// restores from; `DamageFor` is the one chokepoint both bullet paths read. Writing
// `Damage` here would make the ramp permanent the moment anything threw between the
// write and the restore.
//
// ⚠️ IT MULTIPLIES WITH THE BURST FACTOR rather than replacing it, so a Micro-Burst
// weapon under a charged trigger gets both. Neither line assigns.
//
// ⚠️ AND IT IS READ ONCE, HERE, NOT PER BULLET. Every pellet of one blast is the
// same shot and must carry the same charge — sampling inside the loop would be
// identical today (Time.Delta does not advance mid-frame) but would silently start
// to differ the first time anything in the loop yielded.
// ⚠️ AND m5's ×2 JOINS THEM, on the same local rather than as a fourth term below, for the
// reason the note under Speed Cola gives: every factor being non-neutral has to open the
// `!= 1f` window, and a term written straight into the assignment would not.
var triggerFactor = (overpressure ? MathF.Max( 0f, NZombies.DtapAugments.OverpressureDamage ) : 1f)
* NZombies.DtapAugments.TriggerDamageFor( this )
// ⚠️ SPEED COLA'S M3 RIDES THE SAME SEAM, and multiplying it into this local
// rather than adding a third term to the assignment below keeps the `!= 1f`
// guard honest — with two independent factors, either one being non-neutral has
// to open the window.
* NZombies.SpeedColaAugments.AdrenalineFor( this )
// ⚠️ THE PER-CLASS AUGMENTS' SHOT (2026-10-04): Select Fire's mode, Steady Breath, Overwatch, Last Ten, Final
// Round, Long Haul, Lucky Six, Spin the Cylinder and Adrenaline Rounds, read once for the whole pull.
* ClassTechShotDamage( shootInfo, roundsBefore );
try
{
if ( burstFactor * triggerFactor != 1f )
shootInfo.DamageMultiplier = authoredDamageMult * burstFactor * triggerFactor;
// ⚠️ AND THIS PULL'S PENETRATION AND PELLETS (Final Round, Overwatch, Spin the Cylinder), closed in the same
// `finally` as the damage window.
ClassTechOpenShot( shootInfo, roundsBefore );
// ── DOUBLE TAP'S M1 "DOUBLE FIRE" ────────────────────────────────────
//
// ⛔ AN OUTER PASS, NOT A WRITE TO `shootInfo.Bullets`, and writing that field
// would have been the obvious mistake. Two things read it and both would
// break: `GetRealSpread` does `if ( IsAiming && Primary.Bullets == 1 )` before
// applying the ADS bonus, so a 1 → 2 write silently switches the aim bonus off
// on every single-pellet weapon; and `WeaponTuning.Apply` reverts saved
// `Bullets` overrides on every deploy, which TechEffects already records being
// bitten by twice.
//
// ⛔ AND IT IS A REAL SECOND PROJECTILE, WHICH THE ORIGINAL COULD NOT MANAGE.
// GMod's version retreated to a flat x2 damage on hitscan because two pellets
// down a near-identical line "frequently resolve as a SINGLE hit". That is a
// property of ITS damage path, not of the idea: zombies here carry
// `ImmunityAfterHit = 0f` ("zombies get no mercy window", ZombieAI), so two
// traces on one zombie in one frame each run Health.Apply in full.
//
// ⚠️ ONE LOOP COVERS BOTH BULLET TYPES. HitScan and Physical are both reached
// through this single `BulletType.Shoot` call, so a second pass doubles traces
// AND spawns a genuine second travelling projectile with its own tracer. The
// original needed two separate primitives for exactly the lack of this seam.
// ⚠️ LOOKED UP PER SHOT, not cached on the component, matching what
// Weapon.Reload already does. A cached NZPlayer would go stale the moment a
// weapon changed hands — `IsValid()` stays true on the previous owner, so the
// usual revalidate-if-invalid trick does not catch it — and a walk up the
// ancestors a few times a second costs nothing worth protecting.
var nzShooter = Components.Get<NZombies.NZPlayer>(
FindMode.InAncestors | FindMode.Enabled );
// ⚠️ CLEARED HERE, ONCE PER TRIGGER PULL, so Vigor Rush's m4 splashes once for a
// shotgun blast rather than once per pellet. This is the only place that knows
// where a shot begins.
LastRoundSplashed = false;
// ⛔ M1 RETURNS 2 HERE, AND THAT IS THE 20-BODY WALL. The bullet loop below runs
// `passes` times and each pass is capped by MaxPenetrations = 10, so 2 x 10 = 20 --
// which is exactly where the measured bodies-per-frame histogram stops dead.
// ⚠️ ONE TRIGGER PULL, ONE BUDGET. Reset here rather than per bullet, because the
// point is to bound how many streaks a single shot draws no matter how many
// projectiles it spawns.
NZombies.BulletTracers.BeginShot();
int passes;
using ( NZombies.CpuScope.Measure( "dtap.shotmult" ) )
passes = NZombies.DtapAugments.ShotMultiplier( nzShooter );
// ⛔ HIGH NOON (revolver tier 5, 2026-10-04): this pull fires one line at every marked zombie, dead on, instead of
// one where the sights point. Taken once per pull, so the marks are spent.
var noonAims = ClassTechTakeHighNoon( isPrimary );
var lines = noonAims?.Count ?? 1;
for ( int pass = 0; pass < passes; pass++ )
{
// ⚠️ Only the COPIES fan out — pass 0 is the weapon's own shot and takes no
// extra spread at all, so the augment cannot make an aimed shot worse.
float twin;
using ( NZombies.CpuScope.Measure( "dtap.twin" ) )
twin = NZombies.DtapAugments.TwinSpreadFor( pass );
for ( int line = 0; line < lines; line++ )
for ( int i = 0; i < shootInfo.Bullets; i++ )
{
var realSpread = GetRealSpread( shootInfo.Spread ) + twin;
var spreadOffset = noonAims is null
? shootInfo.BulletType.GetRandomSpread( realSpread )
: ClassTechAimOffset( noonAims[line] );
// ⛔ PROBED BEFORE THE BULLET FIRES, NOT AFTER. A killing round destroys
// the thing that proves it hit — the corpse's ZombieAI goes to state Dead
// — so probing afterwards would read "missed" on precisely the shots that
// most deserve the bonus. Same frame, same line, unfired world.
if ( probeLine && !_burstPrevHit )
// ⚠️ AN EXTRA TRACE PER BULLET, on top of the bullet's own, and only when Micro-Burst's
// conditional bonus is owned -- which is why it gets its own column instead of hiding
// inside shot.total.
using ( NZombies.CpuScope.Measure( "shot.probe" ) )
_burstPrevHit = ShotHitsZombie( shootInfo, spreadOffset );
// ⚠️ CONTAINS THE ENTIRE DAMAGE PATH (dmg.hit and everything beneath it), so shot.bullet
// minus dmg.hit is the bullet setup that is not the damage path.
using ( NZombies.CpuScope.Measure( "shot.bullet" ) )
shootInfo?.BulletType?.Shoot( this, isPrimary, spreadOffset );
}
}
}
finally
{
shootInfo.DamageMultiplier = authoredDamageMult;
ClassTechCloseShot( shootInfo );
}
// AIM RECOIL, DELIBERATELY LAST.
//
// ⛔ A BULLET MUST NOT INHERIT ITS OWN SHOT'S KICK. Both bullet types read
// `player.EyeAngles.Forward` at the moment they fire (BulletInfo.HitScan,
// BulletInfo.Physical) and ApplyEyeAnglesOffset writes Controller.EyeAngles
// IMMEDIATELY, so applying recoil before the bullet loop aimed every round through
// the muzzle rise it had just caused. On a high-recoil weapon that put the shot well
// above the crosshair — consistently, not randomly, because the first kick is
// deterministic and RecoilKick makes shot 0 the hardest of the string.
//
// A shot now carries the recoil of every round BEFORE it and none of its own, which
// is what a real weapon does: the bullet has left the barrel before the gun moves.
//
// ⚠️ THE VALIDITY CHECK IS NEW AND IS NOT PARANOIA. At the old site the owner had
// just been dereferenced a line earlier; here a full bullet loop has run in between,
// and a bullet can kill its own shooter.
if ( Owner.IsValid() && !Owner.IsBot )
using ( NZombies.CpuScope.Measure( "shot.eyeangles" ) )
Owner.ApplyEyeAnglesOffset( pendingRecoil );
}
protected virtual void ApplyShootAnimation( string anim )
{
PlayAnim( anim, true );
}
/// <summary> A single bullet trace from start to end with a certain radius.</summary>
public static SceneTraceResult TraceBullet( GameObject toIgnoreGO, Vector3 start, Vector3 end, float radius = -1f, string[] ignoreTags = null, IEnumerable<GameObject> extraIgnoreGOs = null )
{
// TODO: find another solution when water becomes more available
// var startsInWater = SurfaceUtil.IsPointWater( start );
// if ( startsInWater )
// withoutTags.Add( TagsHelper.Water );
// ⚠️ NEGATIVE MEANS "USE THE TUNABLE". Callers that want a specific thickness -- aim
// assist at 1, the tucking check at 2 -- pass it explicitly and are unaffected.
if ( radius < 0f ) radius = TraceRadius;
var trace = Game.ActiveScene.Trace.Ray( start, end )
.UseHitboxes()
.WithoutTags( ignoreTags ?? BulletTraceIgnoreTags )
.IgnoreGameObjectHierarchy( toIgnoreGO );
// ⛔ OMITTED, NOT SET TO ZERO. `.Size( 0 )` would very likely still resolve as a shape
// cast -- keeping the swept-AABB broadphase path that is the whole reason for this change --
// and hand back nothing for it. The call has to be ABSENT for this to be a raycast.
if ( radius > 0f )
trace = trace.Size( radius );
if ( extraIgnoreGOs is not null )
{
foreach ( var go in extraIgnoreGOs )
trace = trace.IgnoreGameObjectHierarchy( go );
}
var tr = trace.Run();
// ⛔ "I CANNOT SHOOT A ZOMBIE THAT IS ON TOP OF ME" LIVES HERE. The bullet
// trace is a swept SPHERE (radius 2 by default), and a sphere sweep that
// BEGINS inside geometry starts solid — which is exactly what happens when
// a zombie is close enough for its hitbox to enclose the eye position. The
// shot then resolves against nothing and the player, reasonably, reads it
// as the gun refusing to fire at point-blank range.
//
// ⚠️ Retried as a RAY, not by nudging the start point forward. Moving the
// start would push the origin PAST a body that close and miss it from the
// other side — trading one point-blank failure for a subtler one. A
// zero-radius ray from the same origin cannot start solid against a
// hitbox, so it resolves the shot where the sphere could not.
// ⛔ SKIPPED ENTIRELY FOR A RAY, AND THAT IS NOT AN OPTIMISATION -- IT IS CORRECTNESS.
// This block exists because a swept SPHERE that begins inside geometry starts solid, and it
// recovers by retrying as a plain ray. When radius is 0 the primary trace IS that ray, so the
// retry would run a byte-identical query and return the same result, at double the cost.
if ( radius > 0f && tr.StartedSolid )
{
var ray = Game.ActiveScene.Trace.Ray( start, end )
.UseHitboxes()
.WithoutTags( ignoreTags ?? BulletTraceIgnoreTags )
.IgnoreGameObjectHierarchy( toIgnoreGO );
if ( extraIgnoreGOs is not null )
{
foreach ( var go in extraIgnoreGOs )
ray = ray.IgnoreGameObjectHierarchy( go );
}
var rayTr = ray.Run();
if ( rayTr.Hit ) return rayTr;
}
return tr;
}
/// <summary> A single bullet trace from start to end with a certain radius.</summary>
public virtual SceneTraceResult TraceBullet( Vector3 start, Vector3 end, float radius = -1f, string[] ignoreTags = null, IEnumerable<GameObject> extraIgnoreGOs = null )
{
return TraceBullet( Owner.GameObject, start, end, radius, ignoreTags, extraIgnoreGOs );
}
// ⛔ NOT AN RPC SINCE 2026-10-05, AND NEITHER ARE ITS FIVE SIBLINGS: `SpawnEffects` (BulletInfo.HitScan), `HandleReloadEffects`,
// `OnCarryStart`, `OnCarryStop` and `PlaySound`. A weapon is `NetworkMode.Never` (all 1,069 prefabs) and is stripped off a body
// before it is network-spawned, so no other machine has the object, and every broadcast from it arrived as "OnObjectMessage:
// Unknown GameObject … for RPC HandleShootEffects". That was 24,597 lines in tonight's logs, 42-52% of each file, and as many
// messages sent for nothing (a shotgun shot: one of these and a SpawnEffects per pellet). It never showed anyone anything:
// remote shots travel by `NZNet.ShotTracer` and `NZNet.ShotSound`, remote gestures by `NZNet.PlayerAnim`. Plain calls do
// exactly what the local half of the broadcast did.
public virtual void HandleShootEffects( bool isPrimary )
{
if ( !IsValid || Owner is null || Application.IsDedicatedServer ) return;
// Player
Owner.TriggerAnimation( Shared.Animations.Attack );
// Weapon
var shootInfo = GetShootInfo( isPrimary );
if ( shootInfo is null ) return;
// Bullet eject
if ( shootInfo.BulletEjectParticle is not null )
{
if ( !BoltBack )
{
if ( !ShellReloading || (ShellReloading && ShellEjectDelay == 0) )
{
CreateBulletEjectParticle( shootInfo.BulletEjectParticle, "ejection_point" );
}
else
{
var delayedEject = async () =>
{
await GameTask.DelaySeconds( ShellEjectDelay );
if ( !IsValid ) return;
CreateBulletEjectParticle( shootInfo.BulletEjectParticle, "ejection_point" );
};
delayedEject();
}
}
else if ( shootInfo.Ammo > 0 )
{
AsyncBoltBack( GetRealRPM( shootInfo.RPM ) );
}
}
var muzzleObj = GetMuzzleObject();
// ⚠️ Logged BEFORE the null-return below, because that return is exactly
// where a missing muzzle attachment silently swallows every muzzle effect —
// including the Pack-a-Punch flash. A diagnostic placed after it can only
// ever report success.
if ( WeaponDebug )
Log.Info( $"[swb-dbg] muzzle effects: obj="
+ $"{(muzzleObj is null ? "NULL — all muzzle effects skipped" : muzzleObj.Name)}"
+ $" dmgMult={GetShootInfo( isPrimary )?.DamageMultiplier:0.##}"
+ $" viewmodel={CanSeeViewModel}" );
if ( muzzleObj is null ) return;
// ⚠️ THE PROJECT-WIDE SCALE IS APPLIED HERE, at the one line both the view model and the
// world model pass through. Every one of the 496 prefabs authors 0.5 for each of these —
// identically, which is an import default rather than 496 decisions — so the fleet has
// always flashed at half the size its particles were designed for. See `NZombies.MuzzleFlash`.
var muzzleScale = (CanSeeViewModel ? shootInfo.VMParticleScale : shootInfo.WMMuzzleParticleScale)
* NZombies.MuzzleFlash.Scale;
// ⚠️ A packed weapon flashes BIGGER as well as purple — the original exposes
// the same knob as `nz_pap_muzzleflash_size`. Tint alone reads as a filter
// over the same gun; scale reads as a gun under strain.
bool packed = shootInfo.IsPacked;
if ( packed ) muzzleScale *= NZombies.PapMuzzleFlash.ParticleScale;
// Muzzle flash
// ⚠️ THIS WEAPON'S OWN MUZZLE OFFSET, in the attachment's own frame. The flash is only
// ever as well placed as the model's `muzzle` attachment; this corrects THIS model's,
// without touching the other 495. Zero by default, so it is a no-op until dialled.
var muzzlePose = new Transform( MuzzleOffset.Pos, MuzzleOffset.Angle.ToRotation() );
// ⛔ THE PRISMA DISCHARGES INSTEAD OF FLASHING, AND THE TWO MUST NOT BOTH DRAW. An orange
// powder flash behind a blue discharge reads as two guns firing at once, which is worse
// than either on its own. One `ClassName` compare on a path that already has the weapon
// in hand.
var energy = NZombies.PrismaFx.IsFor( this );
if ( energy )
NZombies.PrismaFx.Flash(
muzzleObj.WorldTransform.PointToWorld( muzzlePose.Position ),
muzzleObj.WorldRotation * muzzlePose.Rotation,
muzzleObj );
GameObject flash = null;
if ( !energy && shootInfo.MuzzleFlashParticle is not null )
flash = CreateParticle( shootInfo.MuzzleFlashParticle, muzzleObj,
muzzlePose, muzzleScale );
// ⛔ THE VIOLET. Recolours the weapon's OWN flash from the palette and throws
// a matching light — the half of the original effect people actually
// remember is a packed gun painting the room purple on every shot.
//
// ⚠️ Passed the spawned particle so the flash and the light share one colour
// per shot. Tinting the existing effect beats authoring a separate purple
// one: every weapon keeps its own flash shape, muzzle position and timing,
// and simply changes hue.
if ( packed )
NZombies.PapMuzzleFlash.Fire( muzzleObj, Owner?.GameObject, shootInfo.RPM, flash,
shootInfo.PapLevel );
// Barrel smoke
if ( !IsProxy && !Owner.IsBot && shootInfo.BarrelSmokeParticle is not null && barrelHeat >= shootInfo.ClipSize * 0.75 )
// ⚠️ THE SMOKE GOES WITH THE FLASH. They come out of the same hole, so a correction
// applied to one and not the other separates them the moment it is non-zero.
CreateParticle( shootInfo.BarrelSmokeParticle, muzzleObj,
muzzlePose, muzzleScale );
}
/// <summary>Create a bullet impact effect</summary>
/// <summary>
/// Spawn dust puffs on bullet impacts? OFF — they were costing frames.
///
/// ⚠️ Decals are unaffected either way; only the particle emitters are gated.
/// </summary>
public static bool ImpactParticles { get; set; }
/// <summary>`nz_impact_particles [0/1]` — put the dust back to look at it.</summary>
[ConCmd( "nz_impact_particles" )]
public static void CmdImpactParticles( int on = -1 )
{
ImpactParticles = on < 0 ? !ImpactParticles : on > 0;
Log.Info( $"[nz] bullet impact particles {(ImpactParticles ? "ON" : "off")} — decals unaffected" );
}
public static GameObject CreateBulletImpact( SceneTraceResult tr )
{
// ⚠️ THE TRACE KNOWS WHAT IT HIT, so this overload can answer the flesh question itself.
// The position/normal overload cannot, which is why it takes the answer as an argument.
return CreateBulletImpact( tr.HitPosition, tr.Normal, tr.Surface?.SoundCollection.Bullet,
tr.Surface?.PrefabCollection.BulletImpact,
NZombies.BulletDecals.IsFlesh( tr.GameObject ) );
}
/// <summary>Create a bullet impact effect</summary>
/// <param name="onFlesh">
/// True when the hit was a zombie, corpse or player — no bullet hole is stuck to it.
/// ⚠️ PASSED IN RATHER THAN DETECTED. This overload is called from an RPC that carries only a
/// position and a normal, so by the time it runs the hit object is gone. The caller that still
/// has the trace is the only one that can answer.
/// </param>
/// <param name="papLevel">
/// The shooter's Pack-a-Punch tier, 0 for unpacked — it only reaches the decal.
///
/// ⚠️ OPTIONAL AND LAST, because four of this method's five callers have no ShootInfo to ask.
/// The knife, the physical-bullet mover, the trace overload and the impact check tool all
/// want the ordinary hole; only the hitscan path knows which gun fired.
/// </param>
/// <param name="fleshBody">
/// The zombie to hang a packed burn on, null for everything else — resolved by the caller
/// through `BulletDecals.BurnableBody`, which returns null for walls, for players and for
/// unpacked rounds.
///
/// ⚠️ SEPARATE FROM `onFlesh` RATHER THAN REPLACING IT, because they are not the same
/// question. `onFlesh` is true for players and for corpses as well, and it governs whether a
/// HOLE is refused; this governs whether a BURN is placed, and only zombies take one.
/// </param>
/// ⚠️ `energy` RIDES ALONGSIDE `papLevel` AND FOR THE SAME REASON: this method is STATIC, so
/// there is no `this` to ask which weapon fired. Both facts are known only to the hitscan
/// caller and both are therefore optional here — see the note at that call site.
public static GameObject CreateBulletImpact( Vector3 pos, Vector3 normal, SoundEvent sound,
GameObject particles, bool onFlesh = false, int papLevel = 0, GameObject fleshBody = null,
bool energy = false )
{
// Sound
//
// ⛔ GATED UNDER THE KEY "impact", BECAUSE HITSCAN PENETRATION ASKS FOR ONE PER BODY IN ONE
// FRAME. A single shot through thirteen zombies wanted thirteen copies of the same cue at
// almost the same instant — which is not only the cost, it is thirteen identical samples
// comb-filtering into a smear. SoundGate.Policies caps it at 2 per frame.
//
// ⚠️ A LITERAL KEY, not the SoundEvent's name. This path takes a SoundEvent that varies by
// surface, so there is no one cue string to key on — and the limit wanted is "impacts per
// frame" regardless of what was hit.
SoundHandle soundHandle = null;
if ( NZombies.SoundGate.Allow( "impact" ) )
{
if ( sound is not null )
soundHandle = Sound.Play( sound );
soundHandle ??= Sound.Play( "impact-bullet-generic" );
soundHandle.Position = pos;
// ⚠️ ON THE HANDLE NOW, NOT ON THE SoundEvent. It used to read
// `sound.Distance = 10000` before the play, and a SoundEvent is a SHARED GameResource —
// so the FIRST bullet to hit a given surface permanently re-authored that surface's
// impact cue to carry ten thousand units, for everything that ever plays it again.
// The same write on the handle affects this one impact and nothing else.
//
// ⚠️ THE RANGE ITSELF IS UNCHANGED, deliberately. It is still map-wide and still too
// far — how loud the game is belongs in the mix pass, not in a correctness fix — but it
// is now a number this line owns rather than one it leaves behind in an asset.
soundHandle.Distance = 10000;
// ⛔ AND ONTO THE `Impacts` BUS BY HAND, BECAUSE THIS CUE'S ASSET IS NOT OURS. The
// SoundEvent comes from the SURFACE's own `PrefabCollection`, which is engine content —
// `Tools/sound_buses.py` stamps `DefaultMixer` into the project's 2,067 .sound files and
// cannot reach a single one of these. Impacts are the highest-frequency sound in the
// game, so leaving them on the default bus would put the loudest, densest source in the
// mix into the same voice budget as everything unrouted. See `MixerBus`.
NZombies.MixerBus.Send( soundHandle, NZombies.MixerBus.Impacts );
NZombies.SoundGate.Note( "impact", soundHandle );
}
// ⛔ THE HOLE IS A SEPARATE PREFAB AND NOTHING WAS SPAWNING IT.
//
// `Surface.PrefabCollection.BulletImpact` resolves to
// `prefabs/surface/default-bullet.prefab`, which contains a TemporaryEffect
// and three children — smokering, smoke, fleks — and **no decal of any kind**.
// The bullet hole lives in a different asset entirely,
// `prefabs/surface/default-bullet-decal.prefab`, which no code path referenced.
//
// So the clone below was named "bullet_decal", had its particles stripped
// (ImpactParticles defaults off), and rendered an empty GameObject. The
// comment further down claiming "the dust is stripped, the DECAL is kept" was
// simply wrong — there was never a decal in it to keep. Verified by reading
// the prefab: three ParticleEffect children, zero decals.
//
// ⚠️ Spawned BEFORE the early-return, because a surface with no impact prefab
// should still get a hole.
// ⛔ NO HOLE ON FLESH. See BulletDecals.IsFlesh — hitscan penetration puts one decal per
// body in a single frame, and a hole projected onto a walking skinned model slides off it
// anyway.
// ⚠️ THE TIER RIDES ALONG, so a packed round burns the wall in its own colour instead of
// punching the same grey hole every other gun does. 0 is unpacked and takes the old path.
// ⚠️ AND A PACKED ROUND DOES MARK THE BODY, through a different call with a different
// budget — capped per zombie, parented to it, gone in a second. `BulletDecals.SpawnOnFlesh`
// carries why neither of the two objections above applies to it.
if ( !onFlesh )
NZombies.BulletDecals.Spawn( pos, normal, papLevel, energy );
else
NZombies.BulletDecals.SpawnOnFlesh( fleshBody, pos, normal, papLevel, energy );
// Decal & Particles
if ( !particles.IsValid() ) return null;
// ⛔ NOTHING TO CLONE WHEN THE PARTICLES ARE OFF, AND OFF IS THE DEFAULT. Verified by reading
// the asset: `prefabs/surface/default-bullet.prefab` is a TemporaryEffect root plus exactly
// three particle children — smokering, smoke, fleks — and NO decal. So with ImpactParticles
// false the block below cloned 4 objects and 9 components, walked the hierarchy TWICE with
// GetAll(EverythingInSelfAndDescendants).ToList() to destroy all six particle components,
// kept the empty husk alive for 30 seconds, and spent one of MaxDecals' 30 slots on it.
// Per bullet. At automatic fire rates against a horde that is the cost, and it bought
// nothing at all.
//
// ⚠️ THE HOLE IS UNAFFECTED. The comment below used to argue that dropping the clone "would
// have taken the holes with it" — that was true before `BulletDecals.Spawn` was added above,
// and has been stale since. The hole is its own prefab and its own call.
//
// ⚠️ AND IT STILL RETURNS null, which every caller already handles: the early-return above
// does the same thing for a surface with no impact prefab.
if ( !ImpactParticles ) return null;
var cloneConfig = new CloneConfig()
{
Name = "bullet_decal",
StartEnabled = true,
Transform = new()
{
Position = pos,
Rotation = Rotation.LookAt( -normal ),
},
//Parent = tr.GameObject,
};
var decalGO = particles.Clone( cloneConfig );
decalGO.NetworkMode = NetworkMode.Never;
// ⛔ THE DUST IS STRIPPED, THE DECAL IS KEPT. The surface prefab carries
// both, and at automatic fire rates against a horde the particle systems
// were the cost — each impact spawning an emitter that lives for its own
// lifetime, dozens alive at once. Bullet holes are cheap and are most of
// what the effect is FOR, so this removes the emitters rather than
// skipping the clone: dropping the clone entirely would have taken the
// holes with it.
//
// ⚠️ Behind a toggle rather than deleted, because "the particles cost
// frames" is a measurement that can change with the effect, and the next
// person to wonder should be able to turn them back on and look.
if ( !ImpactParticles )
{
foreach ( var p in decalGO.Components
.GetAll<ParticleEffect>( FindMode.EverythingInSelfAndDescendants ).ToList() )
p.Destroy();
foreach ( var e in decalGO.Components
.GetAll<ParticleEmitter>( FindMode.EverythingInSelfAndDescendants ).ToList() )
e.Destroy();
}
decalGO.DestroyAsync( 30f );
WeaponParticleManager.Instance?.AddDecal( decalGO );
return decalGO;
}
/// <summary>Create a bullet eject particle (always world)</summary>
public virtual GameObject CreateBulletEjectParticle( GameObject particle, string attachment, Action<GameObject> OnParticleCreated = null )
{
var effectRenderer = GetEffectRenderer();
if ( effectRenderer is null || effectRenderer.SceneModel is null ) return null;
var transform = effectRenderer.GetAttachment( attachment );
if ( !transform.HasValue ) return null;
// Rotate bullet with attachment yaw
var spawnInViewSpace = CanSeeViewModel && !IsScoping;
var pitch = spawnInViewSpace ? ViewModelHandler.WorldRotation.Pitch() : WorldRotation.Pitch();
var yaw = transform.Value.Rotation.Yaw();
var newRot = Rotation.From( new Angles( 0, yaw, -pitch ) );
transform = transform.Value.WithRotation( newRot );
if ( spawnInViewSpace )
{
var viewSpacePos = CameraUtil.ProjectToViewSpace( transform.Value.Position, Owner.ViewModelCamera, Owner.Camera );
transform = transform.Value.WithPosition( viewSpacePos );
}
var go = CreateParticle( particle, null, transform.Value, 1, false, OnParticleCreated );
WeaponParticleManager.Instance?.AddEject( go );
// Attach owner
var ejectParticle = go.GetComponentInChildren<BulletEjectParticle>();
ejectParticle?.Owner = Owner;
return go;
}
/// <summary>Create a weapon particle</summary>
public virtual GameObject CreateParticle( GameObject particle, GameObject parent, float scale, Action<GameObject> OnParticleCreated = null )
{
return CreateParticle( particle, parent, new Transform(), scale, OnParticleCreated );
}
/// <summary>Create a weapon particle</summary>
public virtual GameObject CreateParticle( GameObject particle, Transform transform, float scale, Action<GameObject> OnParticleCreated = null )
{
return CreateParticle( particle, null, transform, scale, OnParticleCreated );
}
/// <summary>Create a weapon particle</summary>
public virtual GameObject CreateParticle( GameObject particle, GameObject parent, Transform transform, float scale, Action<GameObject> OnParticleCreated = null )
{
return CreateParticle( particle, parent, transform, scale, CanSeeViewModel, OnParticleCreated );
}
public virtual GameObject CreateParticle( GameObject particle, GameObject parent, Transform transform, float scale, bool forViewModel, Action<GameObject> OnParticleCreated = null )
{
var go = particle.Clone( transform.WithScale( scale ), parent );
if ( forViewModel )
go.Tags.Add( TagsHelper.ViewModel );
if ( OnParticleCreated is not null )
{
var p = go.GetComponentInChildren<ParticleEffect>();
p.OnParticleCreated += ( p ) =>
{
OnParticleCreated.Invoke( go );
};
}
return go;
}
}