swb_base/Weapon.Shoot.cs

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.

NetworkingFile Access
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;
	}
}