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.
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 );
}
}