swb_base/bullets/BulletInfo.HitScan.cs

HitScanBulletInfo class for hitscan bullets. Implements shooting logic including tracing, penetration, ricochet, tech effects (flechette, adrenaline, explosive, collateral, thrifty, bank shot), tracer and impact effects, and wide-bore special trace; contains helper IsBody and Deflect methods.

NetworkingFile Access
using SWB.Shared;
using System;
using System.Collections.Generic;
using System.Linq;

namespace SWB.Base;

[Group( "SWB" )]
[Title( "HitScan Bullet Info" )]
public class HitScanBulletInfo : BulletInfo
{
	private const int MaxPenetrations = 10; // safety valve, also acts as a practical per-bullet pierce cap

	/// <summary>
	/// Tell everybody else about this bullet, so it leaves a streak on their screens too.
	///
	/// ⛔ WEAPONS ARE `NetworkMode.Never`, SO SWB'S OWN EFFECT BROADCAST ARRIVES NOWHERE. It is
	/// an RPC on the weapon object, and no other machine has that object — which is both why
	/// nobody ever saw anybody else's tracers and where the `Unknown GameObject` log spam comes
	/// from. This goes through the PLAYER instead, which every machine does have.
	///
	/// ⚠️ ONLY MY OWN SHOTS. A proxy weapon never fires, but a bullet resolved on this machine
	/// on behalf of somebody else would otherwise be relayed back out and drawn twice.
	/// </summary>
	static void RelayTracer( SWB.Base.Weapon weapon, Vector3 from, Vector3 to,
		SWB.Base.ShootInfo shootInfo, bool landed = true )
	{
		if ( !Networking.IsActive ) return;
		if ( !weapon.IsValid() || !weapon.Owner.IsValid() ) return;

		var owner = NZombies.NZPlayers.OwnerOf( weapon.Owner.GameObject );
		if ( string.IsNullOrEmpty( owner ) ) return;
		if ( owner != Connection.Local?.Id.ToString() ) return;

		// ⚠️ THE TIER, NOT THE FACT. `PapLevel` is 0 when unpacked, so the receiver's "is this
		// packed" test is `> 0` and one field says both things.
		// ⚠️ AND WHETHER IT IS A PULSE. The relay carried only the pap tier, so every other
		// machine drew a plain streak for a weapon whose whole look is that it does not — the
		// shooter saw an energy bolt and everybody else saw a bullet.
		// ⚠️ AND WHETHER THE LEG LANDED (2026-09-30). The travelling tracer leaves an ember where a round stopped on something
		// and burns out where it ran out of range; the receiver cannot tell the two apart from two points.
		NZombies.NZNet.ShotTracer( owner, from, to,
			shootInfo?.PapLevel ?? 0,
			shootInfo?.RPM ?? 0f,
			NZombies.PrismaFx.IsFor( weapon ),
			landed );
	}

	public override void Shoot( Weapon weapon, bool isPrimary, Vector3 spreadOffset )
	{
		if ( !weapon.IsValid() ) return;

		var player = weapon.Owner;
		if ( !player.IsValid() ) return;


		var forward = player.EyeAngles.Forward + spreadOffset;
		forward = forward.Normal;
		// ⛔ WAS `* 999999`, AND IT WAS THE HOTTEST LINE IN THE DAMAGE PATH. See
		// Weapon.TraceRange for the measurement; the short version is that a sweep this long
		// defeats broadphase culling entirely, so every trace narrow-phased against every
		// zombie in the level. Bounding it changes nothing a bullet could previously reach.
		var endPos = player.EyePos + forward * Weapon.TraceRange;
		var shootInfo = weapon.GetShootInfo( isPrimary );
		var hasTracer = ShouldSpawnTracer( shootInfo );

		var traceStart = player.EyePos;
		var ignoreGameObjects = new List<GameObject>();
		var traceIgnoreTags = shootInfo.Penetration ? Weapon.PenetrationBulletTraceIgnoreTags : null;
		var ricochetCount = 0;

		// ── ARC9 penetration budget ─────────────────────────────────────────
		//
		// ⛔ SWB's `Penetration` IS A BOOL — pierce or do not, with no limit but
		// the MaxPenetrations safety valve. ARC9 authors a DISTANCE
		// (`SWEP.Penetration = 4 * 39` = 4 metres of material), so a pistol stops
		// inside a wall a rifle would pass through. This tracks how much of that
		// budget is left and how much damage survives each surface.
		//
		// ⚠️ 0 means "unlimited", i.e. exactly the old behaviour — so weapons that
		// have not been authored for this are unchanged.
		var penBudget = shootInfo.PenetrationDepth;
		var penDamageMult = 1f;
		var shotId = Guid.NewGuid(); // correlates every hit from this single bullet (penetration + ricochet)

		// ⚠️ A FRESH PENETRATION BUDGET PER PELLET, matching `shotId`'s scope exactly — this method
		// IS one bullet. The shot-wide half of the rule is held by `BeginShot` in `Weapon.Shoot`.
		// ⚠️ AND THE GUN GOES WITH IT (2026-10-05): its ammo mod's level III can change what a hit is worth — Midas III's cap,
		// Scrapper III's salvage, Leech III's heal — and this is where a bullet knows which gun fired it.
		NZombies.ShotPoints.BeginPellet( weapon );
		Vector3? tracerSegmentStart = null; // null = draw from the muzzle (first segment)

		// ── TIER-5 LOOKUPS, HOISTED OUT OF THE LOOP ─────────────────────────
		//
		// ⛔ ONE `TechEffects.Has` IS AN ANCESTOR `Get<NZPlayer>`, A `Rarity.PrefabOf`,
		// A DICTIONARY LOOKUP AND A `List.Contains`, and `NZPlayer.TechFor` allocates a
		// fresh `List<string>` on a weapon with no tech. Both answers below are constant
		// for the whole bullet, so asking inside the ten-iteration loop would be up to
		// ten times the work for the same value — 160 lookups per trigger pull on a
		// 16-pellet KS23 instead of 16.
		var forcedRicochet = NZombies.TechEffects.Has( weapon, "t5_ricochet" );

		// ⛔ VIGOR RUSH'S m2 IS A SECOND SOURCE OF GUARANTEED BOUNCES, HOISTED FOR THE SAME
		// REASON AS THE LINE ABOVE: it resolves an ancestor `Get<NZPlayer>` and a dictionary
		// lookup, both constant for the whole bullet, and asking inside the ten-iteration
		// loop would be up to ten times the work per pellet.
		//
		// ⚠️ IT DOES NOT NEED TO EXCLUDE ZOMBIES. The gate below only considers a bounce when
		// the trace found no `IDamageable` at all, so "not counting zombies" is behaviour the
		// path already has rather than something this augment adds.
		var vigorBounces = NZombies.VigorAugments.BouncesFor( weapon );

		// ⛔ THE TWO ZOMBIE-CONTACT NODES, HOISTED FOR THE REASON THE BLOCK ABOVE GIVES. Both are
		// asked inside the ten-iteration penetration loop, both are constant for the whole bullet,
		// and one `TechEffects.Has` is an ancestor `Get<NZPlayer>`, a `Rarity.PrefabOf`, a dictionary
		// lookup and a `List.Contains`. Asking them in the loop would be up to twenty extra lookups
		// per pellet — 320 on a 16-pellet KS23 instead of 32.
		var hasFlechette = NZombies.TechEffects.Has( weapon, "t5_flechette" );
		var hasAdrenaline = NZombies.TechEffects.Has( weapon, "t5_adrenaline" );

		// ⚠️ COLLATERAL AND THRIFTY (2026-10-04), hoisted for the same reason: constant for the whole bullet.
		var collateral = NZombies.TechEffects.Mag( weapon, "t5_sn_collateral", "per" );
		var hasThrifty = NZombies.TechEffects.Has( weapon, "t5_mag_thrifty" );

		// ⚠️ BANK SHOT (revolver tier 3, 2026-10-04): the bounces at a zombie this round has left — once, per pellet like `split`.
		var bankLeft = (int)NZombies.TechEffects.Mag( weapon, "t3_rv_bankshot", "bounces", 0f );

		// ⛔ WIDE BORE PLUMBS A RADIUS; IT DOES NOT SCALE `BulletSize`. That field is
		// read by NOTHING — `grep BulletSize` over Code/ returns its declaration and two
		// comment lines — so the catalogue's authored lever ("BulletSize x5") would be a
		// perfect no-op if written as a spawn-time multiply. It is used here as the
		// AUTHORED BASE for the radius instead, which is the one reading that makes the
		// catalogue text true: all 31 prefabs author 2, so x5 is 10 on every weapon.

		// ⚠️ At most one blast per pellet reaches TechBlast, and its own rate limit is
		// what collapses that into one per trigger pull. See TechBlast's header.
		var blasted = false;

		// ── FLECHETTE (`t5_flechette`) ───────────────────────────────
		//
		// ⛔ THE FIRST ZOMBIE THIS PELLET TOUCHES, ONCE — the same latch shape as `blasted`
		// above and for a sharper reason. A penetrating round crosses up to ten bodies, and
		// without this a rifle shot through five zombies would split at every one of them: 35
		// shard traces for one pellet instead of 7, and about x12 the bullet's damage.
		//
		// ⚠️ DECLARED HERE, SO IT IS PER PELLET. `Shoot` IS one bullet — the same scope that
		// owns `shotId` and the penetration budget — so a 16-pellet shotgun gets sixteen splits,
		// which is the honest reading of "a bullet splits into 7" when the bullet is a pellet.
		var split = false;

		for ( int i = 0; i < MaxPenetrations; i++ )
		{
			// ⚠️ OUTERMOST DAMAGE SCOPE, AND ONE ITERATION IS ONE BODY THE BULLET PASSES THROUGH.
			// `using var` in a loop body disposes at the end of EACH iteration, so a 16-pellet
			// shotgun through 8 zombies accumulates 128 samples here -- which is the whole point:
			// this is the scope that shows penetration multiplying the per-hit cost.
			// ⚠️ QUALIFIED: THIS FILE IS IN THE SWB NAMESPACE, not NZombies, so a bare `CpuScope`
			// does not resolve. The file already writes NZombies.TechEffects and
			// NZombies.BulletDecals for the same reason -- matched rather than adding a using.
			using var _cpu = NZombies.CpuScope.Measure( "dmg.hit" );

			// ⚠️ DECLARATION SPLIT SO THE TRACE CAN BE TIMED. `var x = f();` cannot be wrapped in a
			// using-block without scoping x out of the rest of the loop, so the type is written out
			// and the assignment measured. Same value, same order, just measurable.
			SceneTraceResult bulletTr;
			using ( NZombies.CpuScope.Measure( "dmg.trace" ) )
				bulletTr = weapon.TraceBullet( traceStart, endPos, ignoreTags: traceIgnoreTags, extraIgnoreGOs: ignoreGameObjects );
			var hitObj = bulletTr.GameObject;

			// ⚠️ RESOLVED ONCE, ABOVE THE DAMAGE BLOCK. Wide Bore's second trace is what
			// originally forced it up here — it needed to know whether the thin trace had found a
			// body before deciding to look wider — and the hoist outlived the node: two lookups per
			// iteration for one answer is the shape INSTRUCTIONS.md §3 warns diverges.
			var target = hitObj?.Components.GetInAncestorsOrSelf<IDamageable>();

			// ⚠️ `Surface is not null` WAS ADDED WITH WIDE BORE'S FAT TRACE, WHICH IS GONE, AND
			// IT STAYS. That trace resolved against a CapsuleCollider which need not carry a
			// surface, and `IsSkybox` dereferences it — but a null surface is reachable by other
			// routes too, and this also guards `CanRicochet` below. Removing a null check because
			// the bug that revealed it went away is how the bug comes back.
			var hasImpact = bulletTr.Surface is not null
				&& !SurfaceUtil.IsSkybox( bulletTr.Surface )
				&& bulletTr.HitPosition != Vector3.Zero;
			// ⛔ WAS `IPlayerBase penetratedPlayer` — SO BULLETS ONLY PENETRATED
			// PLAYERS. SWB is built for PvP, where the only thing worth shooting
			// through is another player. In nZombies that means every round stops
			// dead in the first zombie, and a whole ARC9 stat (Penetration, up to
			// 4 METRES of material) silently does nothing.
			GameObject penetratedBody = null;

			// Damage
			if ( hitObj is not null )
			{
				var hitTags = Array.Empty<string>();

				// ⚠️ THIS ALLOCATES AN ARRAY PER HIT. TryGetAll().ToArray() is a prime suspect for
				// the penetration stall -- 128 array allocations from one shotgun blast feed
				// straight into gen0, which the gc_ms and gen0 columns already track.
				using ( NZombies.CpuScope.Measure( "dmg.tags" ) )
				if ( bulletTr.Hitbox is not null )
					hitTags = bulletTr.Hitbox.Tags.TryGetAll().ToArray();

				var force = forward * 100 * shootInfo.Force;
				var dmgInfo = Shared.DamageInfo.FromBullet(
					weapon.Owner.GameObject,
					weapon.GameObject,
					bulletTr.Hitbox,
					bulletTr.EndPosition,
					bulletTr.Shape,
					weapon.ClassName,
					// ⚠️ was flat `shootInfo.Damage` — now range- and hit-group-aware.
					// ⚠️ MARKED IS RESOLVED ONCE PER BULLET, HERE, because this is the only place that
					// knows what was hit. It advances the streak on the first zombie of the trigger
					// pull and answers without advancing for every pellet and every pierced body
					// after that — `Weapon.MarkedFactor` carries the rules.
					shootInfo.DamageFor( bulletTr.StartPosition.Distance( bulletTr.HitPosition ), hitTags )
						* penDamageMult * weapon.MarkedFactor( hitObj )
						// ⚠️ HOT HAND AND GROUPING (action sets, tier 3, 2026-10-04) learn which zombie the pull landed on here, and a
						// grouped burst's last round carries its bonus — on the shooter, the way Fixation's streak is.
						* weapon.ActionTechHitFactor( hitObj ),
					bulletTr.HitPosition,
					force,
					shootInfo.HitFlinch,
					Weapon.GetMovementImpactFromForce( shootInfo.Force ),
					hitTags,
					weapon.GetKillDetails(),
					shotId
				);
				// ⛔ ASKED ONCE, HERE, AND THE ANSWER RIDES THE DAMAGE. `ShotPoints.Pays` MUTATES the
				// per-pellet counter, so asking a second time anywhere downstream would spend two of
				// the four penetration slots on one zombie. The tag is the carrier for the same
				// reason `head` and `melee` are — it survives the relay to the host, which is where
				// the award actually happens for a client's shot.
				if ( !NZombies.ShotPoints.Pays( hitObj ) )
					dmgInfo.Tags?.Add( "nopay" );

				// ⚠️ BULLSEYE (sniper tier 3, 2026-10-04): the pull's first zombie counts as a headshot, carried the way `nopay`
				// is. Its own tag, not `head`, so the gore still reads the hitbox (`NZombies.ClassTech.BullseyeHead`).
				if ( weapon.ClassTechTakeBullseye( hitObj ) )
					dmgInfo.Tags?.Add( NZombies.ClassTech.BullseyeTag );

				// ⚠️ THE DAMAGE THIS BULLET ACTUALLY DEALT, captured before it is handed over, so
				// Flechette takes a third OF the number the zombie really took — range falloff,
				// penetration decay, Fixation and all. Recomputing it downstream would be a second
				// author for one figure.
				var dealt = dmgInfo.Damage;

				target?.OnDamage( dmgInfo );

				// ── FLECHETTE (`t5_flechette`) ──────────────────────────────
				//
				// ⛔ AFTER THE HIT LANDS, ONCE PER PELLET, AND ONLY IN FLESH. `target is not null`
				// would have been the cheap test and it is the wrong set — every prop in a map is an
				// `IDamageable` — so the gate is `ZombieAI.RootOf`, which is also the handle the
				// shards need in order to ignore the body that produced them.
				//
				// ⚠️ `dealt` RATHER THAN A RECOMPUTED FIGURE, because a shard is a third OF this
				// bullet: range falloff, penetration decay and Fixation's streak are already in it.
				//
				// ⚠️ BOUNCY ROUNDS USED TO BE HOOKED HERE AND IS NOT ANY MORE. Its trigger is a
				// KILL, and on a client this machine's copy of a zombie never takes the damage at all
				// — `Health.OnDamage` relays and returns before `Apply` — so `IsDead` was permanently
				// false and the node did nothing for anybody but the host. It now lives in
				// `Health.OnDamage`, beside Adrenaline Rounds, which is the only place the kill is
				// knowable. Flechette needs no such answer, so it stays on the shooter.
				// ⚠️ ONE `RootOf` FOR BOTH NODES. It walks up the hitbox's parents, and a
				// player carrying both would otherwise pay for the same walk twice per body.
				if ( (hasFlechette || hasAdrenaline) && hitObj.IsValid()
					&& NZombies.ZombieAI.RootOf( hitObj ).IsValid() )
				{
					// ── ADRENALINE ROUNDS (`t5_adrenaline`) ────────────────
					//
					// ⚠️ ON THE SHOOTER, WHICH IS WHY IT IS HERE AND NOT IN `Health`. The rush
					// is the shooter's own state — the same placement and the same argument as
					// `DeadshotAugments.OnHeadshotHit` — and it is what lets this node work for a
					// CLIENT's own shots, unlike everything that has to ask the host whether
					// something died.
					//
					// ⚠️ UNLATCHED, DELIBERATELY. `Rush` writes an absolute deadline, so sixteen
					// pellets refresh it sixteen times to the same value; a latch would cost a
					// branch to prevent nothing.
					if ( hasAdrenaline ) NZombies.AdrenalineRounds.Rush( player );

					if ( hasFlechette && !split )
					{
						split = true;
						NZombies.Flechette.Split( weapon, hitObj, bulletTr.HitPosition, dealt );
					}
				}

				// ⚠️ THRIFTY (9-20 rounds, tier 5, 2026-10-04): a hit on a zombie may put the round back, one roll per pull.
				if ( hasThrifty && hitObj.IsValid() && NZombies.ZombieAI.RootOf( hitObj ).IsValid() )
					weapon.ClassTechThrifty();

				// ⚠️ ANY damageable body, not just players. A zombie is an
				// IDamageable with no IPlayerBase anywhere on it.
				if ( shootInfo.Penetration && target is not null )
					penetratedBody = hitObj;

				// ⚠️ Charged on the way OUT of a surface, not on entry: the cost is
				// the thickness actually crossed, which is only known once the exit
				// point is traced. Charging on entry would let a bullet die against
				// a pane of glass it barely touched.
				// ⛔ COLLATERAL (sniper tier 5, 2026-10-04) GROWS THE ROUND THROUGH EVERY ZOMBIE, x1.25 a body, where the clamp
				// below holds any keep at 1. Bodies only: a crate is not a zombie passed through.
				if ( collateral > 1f && IsBody( target ) )
					penDamageMult *= collateral;
				else if ( penBudget > 0f )
					penDamageMult *= shootInfo.PenetrationDamageMult.Clamp( 0f, 1f );
			}

			// ⛔ "WORLD GEOMETRY ONLY" IS `target is null`, AND UNTIL NOW THE COMMENT
			// HERE CLAIMED IT WITHOUT THE CODE EXPRESSING IT. The old first term was
			// `penetratedBody is null`, which is set only `if ( shootInfo.Penetration &&
			// target is not null )` — so a bullet with penetration OFF that struck a
			// zombie was free to bounce off the body, and `SurfaceUtil.CanRicochet`
			// happened to hide it because its whitelist contains "default", the fallback
			// material name. Both accidents fail together under `t4_solidslug`, which
			// sets `si.Penetration = false`. Asking about `target` is the one-token
			// change that makes the sentence above true.
			//
			// ── RICOCHET ROUNDS (`t5_ricochet`) ──────────────────────────────
			//
			// ⛔ THE FORCED PATH DROPS THE SURFACE WHITELIST TOO, NOT JUST THE ANGLE AND
			// THE ROLL. `SurfaceUtil.RicochetSurfaces` is eight material names —
			// default, metal, metal.sheet, ceramic, plastic, plastic.sheet, wood,
			// wood.sheet — and a real map's concrete, brick and plaster are on NONE of
			// them. Keeping it would make a node that promises "ALWAYS bounce 3x" bounce
			// only off metal and wood, which is the unobservable-node failure this tier
			// has already paid for twice. The whitelist's real job — "do not bounce off
			// flesh" — is now done properly by `target is null` above.
			//
			// ⚠️ WHAT THE FORCED PATH KEEPS: `hasImpact` (no bouncing off the skybox) and
			// `shootInfo.Ricochet` (authored true on all 31 prefabs).
			//
			// ⛔ BUT THE CAP COMES FROM THE NODE WHEN FORCED, NOT FROM THE WEAPON. This
			// read `shootInfo.MaxRicochets` unconditionally, which is authored 3 on all 31
			// prefabs — fine while the node also advertised 3, and silently wrong the
			// moment it advertised anything else. Raising the catalogue to 7 bounces would
			// have kept bouncing exactly 3 times with nothing to see and nothing failing.
			//
			// ⚠️ The unforced path still uses the weapon's own cap, because a weapon that
			// ricochets by luck should keep the authored budget it was balanced with.
			// ⚠️ THE TECH NODE WINS OUTRIGHT when both are owned, rather than the two adding.
			// Its 7 already exceeds anything the augment grants, so a sum would only matter in
			// creative and would produce a number neither the catalogue nor the augment text
			// claims.
			var bounceCap = forcedRicochet
				? (int)NZombies.WeaponTech.Find( "t5_ricochet" ).Factor
				: vigorBounces > 0
					? vigorBounces
					: shootInfo.MaxRicochets;

			// ⛔ `IsBody`, NOT `target is null`, AND THAT DISTINCTION IS A REAL BUG FIXED.
			// `target` is whatever `IDamageable` the trace found, and it drives DAMAGE - where
			// "any damageable" is exactly right. The ricochet gate borrowed it to mean "not a
			// zombie", and those are not the same set: every `prop_static` in a map carries a
			// `Prop` component, `Prop` implements `IDamageable`, so bullets refused to bounce off
			// crates, barrels and every other prop while bouncing happily off the floor.
			//
			// Measured with `nz_ricochet_probe`, which is why this is a fix and not a guess:
			//
			//   forward: hit 'prop_static'     · IDamageable Prop → refused
			//   floor  : hit 'Displacement 66' · IDamageable no   → bounced
			//
			// The floor is raw world geometry with no component at all, which is the only reason
			// it ever worked. Reported as "the ricochet is only happening off the floor".
			// ⚠️ SPLIT DECLARATION AGAIN, for the same reason as the trace above. The && chain
			// short-circuits exactly as before -- moving the type out of `var` changes nothing
			// about how the expression evaluates.
			bool canRicochet;
			using ( NZombies.CpuScope.Measure( "dmg.ricochet" ) )
			canRicochet = !IsBody( target )
				&& hasImpact
				&& shootInfo.Ricochet
				&& ricochetCount < bounceCap
				// ⚠️ THE AUGMENT BYPASSES THE SURFACE, ANGLE AND CHANCE ROLL exactly as the
				// tech node does — that bypass is what "guaranteed" means. Without it the
				// bounce would still be gated on eight material names and a 30-degree grazing
				// angle, and the augment would fire on a small fraction of wall hits with
				// nothing on screen to explain the difference.
				&& (forcedRicochet
					|| vigorBounces > 0
					|| (SurfaceUtil.CanRicochet( bulletTr.Surface )
						&& SurfaceUtil.GetGrazingAngle( forward, bulletTr.Normal ) <= shootInfo.RicochetAngle
						&& Game.Random.Float( 0f, 1f ) < shootInfo.RicochetChance));

			// ── BANK SHOT (`t3_rv_bankshot`, revolver tier 3, 2026-10-04) ──────────────────
			//
			// ⚠️ RICOCHET ROUNDS' BOUNCE BELOW, AIMED: a round that struck the world (`IsBody`'s world, props included) bounces
			// at the nearest zombie in sight of the bounce point (`ClassTech.BankShotAim`). Its own count, so it spends none of
			// the gun's ricochets, and a gun that would ricochet anyway takes this bounce first. No zombie in sight, no bounce.
			// ⚠️ THE SURFACE'S NORMAL GOES WITH IT: only zombies on the side the round bounces off are candidates.
			Vector3? bank = null;
			if ( bankLeft > 0 && hasImpact && !IsBody( target ) )
				bank = NZombies.ClassTech.BankShotAim( bulletTr.EndPosition + bulletTr.Normal * 1.0f, bulletTr.Normal, ignoreGameObjects );

			var bounces = canRicochet || bank.HasValue;

			// Effects
			var isFinalHit = penetratedBody is null && !bounces;
			var tracerSegmentEnds = isFinalHit || bounces; // penetration keeps the same straight tracer segment going
			var tracerThisIteration = hasTracer && tracerSegmentEnds;

			// ⚠️ BulletDecals.IsFlesh IS AN ARGUMENT HERE, so it is counted inside dmg.effects
			// rather than getting its own column -- separating it would mean hoisting it, and it is
			// cheap enough that the hoist is not worth the reordering.
			using ( NZombies.CpuScope.Measure( "dmg.effects" ) )
			if ( hasImpact || tracerThisIteration )
				SpawnEffects( weapon, isPrimary, hasImpact, tracerThisIteration, tracerSegmentStart, bulletTr.EndPosition, bulletTr.Normal, bulletTr.Surface?.SoundCollection.Bullet, bulletTr.Surface?.PrefabCollection.BulletImpact, NZombies.BulletDecals.IsFlesh( hitObj ), NZombies.BulletDecals.BurnableBody( hitObj, shootInfo.PapLevel, NZombies.PrismaFx.IsFor( weapon ) ) );

			// ── EXPLOSIVE ROUNDS (`t5_explosive`) ────────────────────────────
			//
			// ⛔ THE FIRST IMPACT OF THIS PELLET, ONCE — not once per penetration
			// iteration. A pierced body is an impact, so without the latch a KS23 round
			// through ten zombies would ask ten times, and the rate limit inside
			// TechBlast would absorb nine of them at the cost of ten owner lookups.
			//
			// ⚠️ `bulletTr.EndPosition`, not `HitPosition`: for a swept sphere that is
			// the shape origin at contact, i.e. already a couple of units clear of the
			// surface — which is what the blast's line-of-sight rays need as an origin,
			// and it costs no nudge constant of its own.
			if ( hasImpact && !blasted )
			{
				blasted = true;
				NZombies.TechBlast.TryBlast( weapon, shootInfo, bulletTr.EndPosition );
			}

			// ── BASALT'S MASTERMIND LIGHTS ───────────────────────────────────
			//
			// ⚠️ THEY ARE MAP GEOMETRY, NOT OBJECTS, so a Prisma round on one is found here, by where it landed: every
			// impact on the world passes this line, on the shooter's machine, which tells the host. The cheap questions
			// come first inside it (`HexPlatforms.OnBulletImpact`).
			if ( hasImpact && target is null )
				NZombies.HexPlatforms.OnBulletImpact( weapon, bulletTr.HitPosition );

			if ( isFinalHit )
				break;

			if ( bounces )
			{
				forward = bank ?? Deflect( bulletTr.Normal );
				// ⚠️ A RICOCHET RE-AIMS FROM THE BOUNCE POINT, so it gets a fresh full range
				// rather than what was left of the old one -- the deflected bullet is a new line.
				endPos = bulletTr.EndPosition + forward * Weapon.TraceRange;
				traceStart = bulletTr.EndPosition + bulletTr.Normal * 1.0f; // nudge off the surface so we don't immediately re-hit it
				tracerSegmentStart = bulletTr.EndPosition;

				if ( bank.HasValue ) bankLeft--;
				else ricochetCount++;
			}
			else
			{
				// ⚠️ Ignore the BODY we just crossed, or the next trace starts
				// inside it and re-hits the same target forever.
				ignoreGameObjects.Add( penetratedBody );

				// ⚠️ Spend the budget on the distance actually crossed inside this
				// surface. Out of budget = the bullet stops here rather than
				// carrying on to the MaxPenetrations safety valve.
				//
				// ⛔ A WIDE BORE HIT IS CHARGED NOTHING, AND THAT IS NOT GENEROSITY. The
				// span below is `EndPosition.Distance( HitPosition )`, and for a swept
				// sphere `EndPosition` is the shape ORIGIN at contact — so this measures
				// approximately the TRACE RADIUS, not the material thickness: ~3 units
				// per body at radius 2, ~11 at radius 10. Charging the fat result would
				// take a 9.97-depth weapon from ~3 pierced bodies to none, i.e. Wide
				// Bore would DISABLE Overpenetrator. And "charge from the thin result"
				// resolves to zero here by construction: the fat trace is consulted only
				// when the thin one found no body, so nothing was crossed to charge for.
				if ( penBudget > 0f )
				{
					// ⚠️ SAMPLED BEFORE IT IS SPENT, and the raw distance rather than the charge --
					// the +1.0 surcharge is not what is in dispute. See NZombies.PenProbe.
					var penStep = bulletTr.EndPosition.Distance( bulletTr.HitPosition );
					NZombies.PenProbe.Sample( penStep );

					// ⛔ THE CHARGE IS ONE BODY, NOT A MEASURED THICKNESS, AND PenProbe's FIRST
					// VERDICT IS WHY. `penStep` is `EndPosition.Distance( HitPosition )`, and for a
					// trace that HITS, EndPosition is the swept shape's ORIGIN at contact -- so the
					// span it measures is approximately the TRACE RADIUS, never the material. At the
					// shipped `TraceRadius = 0` the two points coincide and penStep is zero, which
					// made the old `penStep + 1.0f` charge exactly 1.0 per body by accident. The
					// convention already in the prefab data agrees: 9.97, 3.97, 5.97, 157.97 all
					// read as `ceil(depth)` bodies, so someone authored those against a cost of 1.
					//
					// ⛔ AND THE OLD FORM COUPLED PIERCE DEPTH TO `nz_trace_radius`, WHICH NOBODY
					// INTENDED. Setting the radius back to 2 -- a supported A/B command with its own
					// header explaining it changes aim grace -- charged ~3 per body instead of 1 and
					// silently cut every weapon in the game to a third of its pierce. A tuning knob
					// for bullet thickness must not be a balance lever for penetration.
					//
					// ⚠️ `BodyDepth` IS NOW THE ONLY AUTHOR OF THE COST, which is what its name
					// always claimed. `DtapAugments.PierceDepthBonus` converts bodies to budget
					// through it and `WeaponStatsPanel.PenDepth` converts back; with the spend line
					// reading the same field, all three agree by construction instead of by comment.
					//
					// ⚠️ PenProbe STILL SAMPLES THE RAW SPAN above, because "is this a thickness
					// or a shape artefact" is now settled by construction but the histogram is how
					// anyone checks that later.
					penBudget -= MathF.Max( 0.01f, NZombies.DtapAugments.BodyDepth );
					if ( penBudget <= 0f ) return;
				}

				traceStart = bulletTr.EndPosition + forward * 1.0f;
			}
		}
	}

	/// <summary>
	/// WIDE BORE's fat trace: a swept sphere that only living zombies can stop.
	///
	/// ⚠️ BUILT INLINE RATHER THAN CALLING `Weapon.TraceBullet`, for the reason the
	/// call site records: that method's `StartedSolid` retry would silently thin this
	/// trace to a ray next to a wall. It also takes `ignoreTags`, and what this trace
	/// needs is the opposite — a single REQUIRED tag.
	///
	/// ⚠️ It inherits the bullet's ignore list, so a body this round has already
	/// pierced cannot be caught a second time by the wider radius.
	/// </summary>
	static SceneTraceResult WideBoreTrace( IPlayerBase player, Vector3 start, Vector3 end, float radius, List<GameObject> ignoreGameObjects )
	{
		// ⚠️ The aim-assist fat trace. Runs only when the thin trace missed, so its call count is
		// normally well below dmg_hits -- check Calls, not just the total.
		using var _cpu = NZombies.CpuScope.Measure( "dmg.trace.wide" );
		var trace = Game.ActiveScene.Trace.Ray( start, end )
			.WithTag( "zombie" )
			.Size( radius )
			.IgnoreGameObjectHierarchy( player.GameObject );

		foreach ( var go in ignoreGameObjects )
			trace = trace.IgnoreGameObjectHierarchy( go );

		return trace.Run();
	}

	/// <summary>
	/// Is this damageable a BODY - something a bullet should stop in rather than bounce off.
	///
	/// ⛔ A PROP IS DAMAGEABLE AND IS STILL WORLD. `Sandbox.Prop` implements `IDamageable` so
	/// that crates and barrels can be shot apart, which means "found an IDamageable" is not the
	/// same question as "hit something alive" - and the ricochet gate needs the second one.
	///
	/// ⚠ IT DOES NOT CHANGE WHAT TAKES DAMAGE. Props are still hit and still damaged through
	/// `target`; this only decides whether the bullet carries on. Widening it to gate damage
	/// would make every prop in every map bulletproof.
	///
	/// ⚠ EXCLUDES PROPS RATHER THAN LISTING BODIES, deliberately. A list of body types is a
	/// list that a future zombie variant can fall off, and falling off it would make that
	/// variant bounce bullets - a much worse failure than a new prop type stopping one.
	/// </summary>
	// ⚠ PUBLIC SO `nz_ricochet_probe` CAN CALL THE REAL TEST. The probe first shipped with
	// its own copy of this rule and therefore kept reporting the OLD verdict after the gate
	// was fixed - a diagnostic agreeing with itself while the game did something else, which
	// is §2 and is the exact failure its own doc comment warned about.
	public static bool IsBody( Sandbox.Component.IDamageable target )
		=> target is not null && target is not Prop;

	/// <summary>
	/// How many draws to spend finding a uniform direction before giving up. 8.
	///
	/// ⚠️ THE BALL FILLS 52% OF THE CUBE, so a draw is rejected 48% of the time and eight
	/// consecutive failures is a 2.7-in-10000 event. The fallback is the surface normal, which
	/// is a perfectly reasonable bounce — so the worst case is one slightly boring ricochet
	/// every few thousand, not a stall and not a wrong answer.
	/// </summary>
	private const int RandomBounceTries = 8;

	/// <summary>
	/// Where the bullet goes after a bounce: a uniformly random direction over the hemisphere
	/// the surface faces. Requested, for every bounce of every pellet.
	///
	/// ⛔ `forward` IS GONE FROM THE SIGNATURE AND THAT IS THE CHANGE. This used to be
	/// `Vector3.Reflect( forward, normal )` — a mirror, so the outgoing direction was fully
	/// determined by the incoming one and a wall hit the same way always sent the round the same
	/// place. The incoming direction is now not consulted at all, which is what "completely
	/// random" means; dropping the parameter rather than ignoring it is what stops someone
	/// reintroducing a dependency on it by accident.
	///
	/// ⚠️ THE HEMISPHERE IS THE SURFACE, NOT A RESTRICTION ON THE RANDOMNESS. The other half
	/// of the sphere points INTO the wall the bullet just hit; a round sent there re-hits the
	/// same surface on the next trace and burns a bounce doing nothing visible. Every direction
	/// the bullet could physically take is available, including straight back at the shooter.
	///
	/// ⛔ AND THE 25-DEGREE "NEVER STRAIGHT BACK" FLOOR IS DELETED WITH THE MIRROR, not
	/// overlooked. It existed because reflection off a surface struck square-on returns the
	/// incoming direction NEGATED — so every round of a burst into a wall in front of the player
	/// came back down the line it arrived on, deterministically, and zombies behind the player
	/// ate shots aimed in front of him. A uniform hemisphere sends a small random share back
	/// instead of all of them, which is a ricochet behaving like one rather than a failure to
	/// guard against. Its `WeaponTech.BoundOf( "t5_ricochet", ... )` read goes too; the node
	/// declares no `Bound`, so nothing else was reading it.
	///
	/// ⚠️ PER PELLET FOR FREE. Each pellet runs its own `HitScan`, so a fresh draw here is a
	/// fresh draw per pellet per bounce — a shotgun into a wall fans out instead of sending
	/// sixteen rounds along one line, which is the visible point of the request.
	///
	/// ⛔ REJECTION-SAMPLED IN THE CUBE RATHER THAN `Vector3.Random`, for the reason
	/// `GlobalHandling`'s spread note already gives: whether that property returns a unit vector
	/// or a point in a cube is engine behaviour this project has decided not to assume, and the
	/// difference here is the difference between uniform and biased toward the eight corners.
	/// Three explicit draws and a length test cannot be wrong about it.
	/// </summary>
	static Vector3 Deflect( Vector3 normal )
	{
		for ( int i = 0; i < RandomBounceTries; i++ )
		{
			var v = new Vector3(
				Game.Random.Float( -1f, 1f ),
				Game.Random.Float( -1f, 1f ),
				Game.Random.Float( -1f, 1f ) );

			// ⚠️ BOTH ENDS TESTED. Longer than 1 is outside the ball and would bias toward the
			// corners; near zero cannot be normalised at all.
			var len = v.Length;
			if ( len > 1f || len < 0.0001f ) continue;

			var dir = v / len;
			return Vector3.Dot( dir, normal ) < 0f ? -dir : dir;
		}

		return normal;
	}

	/// <param name="onFlesh">
	/// The hit was a zombie, corpse or player, so no bullet hole. ⚠️ Decided by the CALLER, which
	/// still has the trace — by the time this runs only a position and a normal are left.
	/// </param>
	/// <param name="fleshBody">
	/// The zombie a packed round should burn, null otherwise.
	///
	/// ⛔ A GameObject DOES SURVIVE THIS RPC, WHICH THE COMMENT ABOVE USED TO DENY. Zombies are
	/// `NetworkSpawn`ed (ZombieCommands:2202), so the reference resolves on every client by network
	/// id — what cannot be carried is the HIT object, which is a per-bone hitbox child and is not
	/// networked in its own right. `BulletDecals.BurnableBody` walks up to the networked root
	/// before the call for exactly that reason.
	///
	/// ⚠️ NULL ON A CLIENT THAT HAS NOT RECEIVED THE ZOMBIE YET is a no-op, not an error, which
	/// is the right failure for an unreliable effects RPC: one player briefly misses one mark.
	/// </param>
	// ⛔ NOT AN RPC SINCE 2026-10-05, WHATEVER THE NOTE ABOVE EXPECTED. The zombie would resolve on another machine, but THIS
	// component sits on the weapon, which no other machine has: every call arrived as "Unknown GameObject … for RPC
	// SpawnEffects", 16,011 times in tonight's logs, one per pellet. See `Weapon.HandleShootEffects`.
	public void SpawnEffects( Weapon weapon, bool isPrimary, bool hasImpact, bool hasTracer, Vector3? tracerStart, Vector3 hitPos, Vector3 hitNormal, SoundEvent hitSound, GameObject hitParticles, bool onFlesh, GameObject fleshBody )
	{
		if ( !weapon.IsValid() || Application.IsDedicatedServer ) return;

		// Impact
		if ( hasImpact )
			// ⚠️ THE ONLY CALLER THAT KNOWS THE TIER, which is why the parameter is optional there.
			// ⛔ RESOLVED HERE RATHER THAN REUSING THE `shootInfo` FURTHER DOWN — that one is declared
			// below this line, in the tracer half of the method. Same weapon, same call, but hoisting
			// it would move a lookup above an early return that currently skips it.
			Weapon.CreateBulletImpact( hitPos, hitNormal, hitSound, hitParticles, onFlesh,
				weapon.GetShootInfo( isPrimary )?.PapLevel ?? 0, fleshBody,
				NZombies.PrismaFx.IsFor( weapon ) );

		// Tracer
		if ( hasTracer )
			TracerEffects( weapon, isPrimary, tracerStart, hitPos, hasImpact );
	}

	public virtual void TracerEffects( Weapon weapon, bool isPrimary, Vector3? tracerStart, Vector3 hitPos, bool landed = true )
	{
		Vector3 startPos;

		if ( tracerStart.HasValue )
		{
			startPos = tracerStart.Value;
		}
		else
		{
			var muzzleTransform = weapon.GetMuzzleTransform();
			if ( !muzzleTransform.HasValue ) return;
			startPos = muzzleTransform.Value.Position;
		}

		var shootInfo = weapon.GetShootInfo( isPrimary );

		// ⛔ THE CHEAP PATH, AND IT RETURNS BEFORE ANY PREFAB IS TOUCHED. FastTracer draws the
		// streak as a pooled two-point line: no Clone (about 667us), no emitter, no particle
		// simulation and no trail mesh rebuilt per frame. Everything below this line is the
		// particle tracer, kept so the two can be compared in one log rather than swapped on a
		// guess -- `nz_fasttracer 1` chooses.
		// ⚠️ RELAYED FROM HERE, WHERE BOTH ENDS ARE ALREADY IN HAND. Anywhere else would mean
		// re-deriving the muzzle and the hit point, and the muzzle is the half that is hard: the
		// weapon object is parked far below the map in first person.
		//
		// ⚠️ BEFORE THE LOCAL DRAW, not after, so a shot is relayed even when this machine's own
		// `FastTracer` is switched off for comparison — `nz_fasttracer 0` is a rendering choice and
		// should not silently stop other players seeing anything.
		RelayTracer( weapon, startPos, hitPos, shootInfo, landed );

		// ⛔ THE PULSE REPLACES THE TRACER, IT DOES NOT ACCOMPANY IT. A streak drawn along the
		// same line the bolt is travelling arrives first and gives the whole thing away — the
		// pulse is then a second, slower object following a line that is already there.
		//
		// ⚠️ AFTER THE RELAY, so other machines are told before this one draws — the same
		// ordering the `FastTracer` branch below documents.
		if ( NZombies.PrismaFx.IsFor( weapon ) )
		{
			NZombies.PrismaFx.Pulse( startPos, hitPos );
			return;
		}

		// ⛔ THE TRAVELLING ROUND, AND IT COMES FIRST (2026-09-30). The look chosen on `Docs/tracer_lab.html`: the hit above
		// was instant, and this round only shows the way the shot went, arriving after it. `nz_tracer_style line` or
		// `particle` gets the older two back.
		if ( NZombies.TravelTracer.Enabled )
		{
			NZombies.TravelTracer.Fire( startPos, hitPos, NZombies.BulletTracers.PackedTint( shootInfo ), landed );
			return;
		}

		if ( NZombies.FastTracer.Enabled )
		{
			NZombies.FastTracer.Draw( startPos, hitPos,
				NZombies.BulletTracers.PackedTint( shootInfo ) );
			return;
		}

		// ⛔ A TRACER-ONLY MULTIPLIER, because `VMParticleScale` is SHARED. That field
		// scales the muzzle flash and the shell ejection as well — its own summary
		// says "BulletEject + BulletTracer" — so shrinking the tracer through it also
		// shrinks the flash, which is the one particle that should stay big.
		var scale = (weapon.CanSeeViewModel ? shootInfo.VMParticleScale : shootInfo.WMParticleScale)
			* NZombies.BulletTracers.Scale;
		var direction = (hitPos - startPos).Normal;
		var rotation = Rotation.LookAt( direction );
		var particleTransform = new Transform( startPos, rotation );
		var tracer = weapon.CreateParticle( shootInfo.BulletTracerParticle, particleTransform, scale );

		// ⛔ THE STREAK MATCHES THE FLASH ON A PACKED GUN. Both are the muzzle-flash palette, so a
		// packed weapon throws violet from the barrel AND down the shot line instead of a purple
		// flash followed by a stock orange tracer.
		NZombies.BulletTracers.TintIfPacked( tracer, shootInfo );
	}
}