Static utility for bullet tracers. It manages tracer prefab lookup, global settings (chance, scale, enabled, max per shot), per-shot budgeting, applies tracer defaults to SWB weapons on deploy, recolours tracer GameObjects for 'packed' weapons, and provides console commands for testing and tuning.
using Sandbox;
using System.Linq;
namespace NZombies;
/// <summary>
/// Visible tracers for hitscan fire.
///
/// ⛔ SWB ALREADY IMPLEMENTS TRACERS — THE PORT SWITCHED THEM OFF. `ShootInfo`
/// defaults `BulletTracerParticle` to `prefabs/particles/tracer/tracer.prefab` and
/// `BulletTracerChance` to 0.33, and `BulletInfo.HitScan` spawns one per shot along
/// the real trace. Every ported prefab then wrote `"BulletTracerParticle": null` and
/// `"BulletTracerChance": 0.0` over the top, so the feature has been present and
/// invisible since the first weapon landed. Nothing here is new behaviour; it is the
/// authored behaviour switched back on.
///
/// ⚠️ FIXED IN CODE, NOT IN THE PREFABS. `PrefabScene` has no populated example
/// anywhere in this project's prefab JSON — every particle field is null — so the
/// serialized shape would be a guess, and a wrong guess in 31 files is a wrong guess
/// that compiles. Assigning the resource at deploy is unambiguous.
///
/// ⚠️ THE BULLET STAYS HITSCAN. A tracer is a cosmetic streak drawn along a trace
/// that has already resolved — hits, damage and penetration are decided the frame you
/// fire, exactly as before. `BulletInfo.Physical` is the projectile path and is NOT
/// what this touches.
/// </summary>
public static class BulletTracers
{
/// <summary>Where the authored tracer lives — `ShootInfo`'s own default.</summary>
public const string TracerPrefab = "prefabs/particles/tracer/tracer.prefab";
/// <summary>
/// How often a shot draws one, 0-1.
///
/// ⛔ EVERY SHOT. I first set this to 0.4, reasoning from real tracer rounds being
/// loaded every few rounds — and it read as broken rather than as authentic:
/// *"super inconsistent, i only see it every few shots."* A random streak gives no
/// feedback about where a specific bullet went, which is the entire job here, and
/// a player cannot tell "1-in-3 by design" from "the effect is failing".
///
/// ⚠️ The thing that made intermittent tracers necessary in other games is SIZE —
/// a fat streak on every bullet becomes a laser. Fixed by shrinking it (see
/// <see cref="Scale"/>) rather than by hiding it at random.
/// </summary>
public static float Chance { get; set; } = 1f;
/// <summary>
/// Tracer size, on top of the weapon's particle scale.
///
/// ⛔ SEPARATE FROM `VMParticleScale`, which is SHARED with the muzzle flash and
/// the shell ejection — shrinking the tracer through that field shrinks the flash
/// too, and the flash is the one that should stay big.
///
/// ⚠️ 0.1 of the authored size, chosen in play. The stock tracer is built for a
/// third-person view; at first-person distances it starts a few units from the eye,
/// so it reads as a glowing bar unless it is far smaller than its author intended.
/// 0.35 was still too fat.
/// </summary>
public static float Scale { get; set; } = 0.1f;
/// <summary>Master switch.</summary>
public static bool Enabled { get; set; } = true;
/// <summary>
/// How many tracers ONE TRIGGER PULL may draw, however many projectiles it fires. 2.
///
/// ⛔ THIS IS THE WHOLE FIX, AND IT IS ABOUT COUNT, NOT COST. A tracer is a two-object
/// prefab, but every one is a full `particle.Clone(...)` and s&box charges roughly 667us for
/// it. The Olympia fires 48 pellets, doubled to 96 by Double Tap's M1, and every one asked for
/// its own streak -- 64ms of clones in a single frame, measured, which is the entire reason
/// that gun dropped the game to 7fps.
///
/// ⚠️ AND 96 STREAKS FROM ONE BARREL WAS NEVER THE INTENDED LOOK. A shotgun blast reads as a
/// spray of light whether it draws two streaks or ninety-six; past a handful they overlap into
/// a solid cone. This caps the count without touching the pellets, the spread, or where any
/// bullet actually goes.
///
/// ⚠️ SINGLE-PROJECTILE WEAPONS ARE COMPLETELY UNAFFECTED. A rifle fires one bullet and asks
/// for at most one tracer, so it never reaches the budget. Only shotguns and Double Tap change.
///
/// ⚠️ NULLABLE-BACKED, so hotload cannot carry a stale value forward past a changed default.
/// </summary>
public static int MaxPerShot
{
get => _maxPerShot ?? 2;
set => _maxPerShot = value;
}
static int? _maxPerShot;
static int _shotBudget;
/// <summary>
/// Reset the per-trigger-pull budget. Called once from Weapon.Shoot.
///
/// ⚠️ ONCE PER SHOT, NOT PER BULLET -- that is what makes it a budget rather than a chance.
/// `BulletTracerChance` already thins tracers statistically and it is not enough on its own:
/// at the default 0.33 a 96-projectile burst still draws about 32.
/// </summary>
public static void BeginShot() => _shotBudget = 0;
/// <summary>
/// Claim one tracer from this shot's budget. False once it is spent.
///
/// ⚠️ CLAIMED AFTER THE CHANCE ROLL, so the two compose: chance decides whether this bullet
/// wants a streak, the budget decides whether it can have one.
/// </summary>
public static bool TakeShotBudget()
{
if ( MaxPerShot <= 0 ) return false;
if ( _shotBudget >= MaxPerShot ) return false;
_shotBudget++;
return true;
}
/// <summary>`nz_tracers_max [n]` — tracers per trigger pull. 0 disables them entirely.</summary>
[ConCmd( "nz_tracers_max" )]
public static void MaxPerShotCmd( int n = -1 )
{
if ( n >= 0 ) MaxPerShot = n;
Log.Info( $"[nz] max {MaxPerShot} tracer(s) per trigger pull"
+ (MaxPerShot <= 0 ? " (off)" : "")
+ $" · a 48-pellet shotgun doubled by Double Tap asks for 96" );
}
static PrefabScene _tracer;
static bool _looked;
/// <summary>
/// The tracer scene, loaded once.
///
/// ⚠️ CACHED INCLUDING THE FAILURE. A missing prefab would otherwise be looked up
/// once per weapon per deploy forever, and a failing resource load in this engine
/// spams `ERROR_FILEOPEN` at frame rate — that exact loop cost the mystery box a
/// debugging session.
/// </summary>
/// <summary>
/// The tracer scene, for anything that wants to spawn one itself.
///
/// ⚠️ THE SAME CACHE, deliberately, not a second lookup. Fire Works clones a tracer per
/// shot; a separate accessor would mean a second `ResourceLibrary.Get` and a second
/// place for the missing-prefab warning to spam from — which is the loop the cache note
/// below says already cost a debugging session once.
/// </summary>
public static PrefabScene TracerScene => Tracer;
static PrefabScene Tracer
{
get
{
if ( _looked ) return _tracer;
_looked = true;
var file = ResourceLibrary.Get<PrefabFile>( TracerPrefab );
if ( file is null )
{
Log.Warning( $"[nz] tracer prefab '{TracerPrefab}' not found — no tracers" );
return null;
}
_tracer = SceneUtility.GetPrefabScene( file );
return _tracer;
}
}
/// <summary>
/// Give a weapon its tracer back. Called as it deploys.
///
/// ⚠️ ONLY FILLS IN A NULL. A weapon that has been given its own tracer — by a
/// prefab, an attachment, or a future special weapon — keeps it.
/// </summary>
public static void Apply( SWB.Base.Weapon wep )
{
if ( !wep.IsValid() || wep.Primary is null ) return;
if ( !Enabled )
{
wep.Primary.BulletTracerChance = 0f;
return;
}
wep.Primary.BulletTracerParticle ??= Tracer;
// ⛔ ONLY WHEN IT IS ZERO. Overwriting unconditionally would stamp on a
// per-weapon chance set through the weapon editor — which is stored in
// `weapon_tuning.json` and applied moments earlier in the same deploy.
if ( wep.Primary.BulletTracerChance <= 0f )
wep.Primary.BulletTracerChance = Chance;
}
/// <summary>
/// Paint a just-spawned tracer violet if the gun that fired it is packed.
///
/// ⛔ THE SAME PALETTE OBJECT AS THE MUZZLE FLASH, not a copy of the five colours. They are
/// `nzMapping.Settings.papmuzzlecol` and a second literal list would drift the moment either is
/// tuned — INSTRUCTIONS.md §3. It also reuses `PapMuzzleFlash.TintParticle`, which already knows
/// the thing that is easy to get wrong: `ApplyColor` gates `Tint`, so setting a colour without
/// it changes nothing and looks exactly like the tint failing.
///
/// ⚠️ THE SAME "IS IT PACKED" TEST AS THE FLASH — `ShootInfo.IsPacked`, read off the weapon's
/// own ShootInfo. Asking PapLevelFor here would be a second source of truth for one question, and
/// the flash and the tracer disagreeing about whether a gun is packed is a bug with no visible
/// cause.
///
/// ⛔ IT USED TO BE `DamageMultiplier > 1.01f` AND THAT WAS WRONG HERE, uniquely among the four
/// sites that asked it. This one runs PER BULLET, inside the window where `Weapon.Shoot` has
/// multiplied the charged trigger and Micro-Burst into that field — so an unpacked gun under
/// Double Tap's m2 drew violet tracers. See `ShootInfo.IsPacked`.
///
/// ⚠️ ITS OWN PICK, NOT THE FLASH'S PICK FOR THIS SHOT. `PapMuzzleFlash.Fire` deliberately
/// shares one colour between the flash and its light because they are one effect at one point in
/// space. The tracer is a different code path — it arrives through the `SpawnEffects` broadcast
/// RPC, so threading that shot's colour to it would mean either a static the RPC races against
/// or a new RPC parameter. Both of the palette's ends are violet, the streak is gone in under a
/// tenth of a second, and the variation reads between shots either way.
///
/// ⚠️ FOLLOWS `nz_pap_flash`. One switch turns the whole packed-weapon violet off for
/// comparison; a tracer that stayed purple after the flash went orange would look like a
/// half-applied revert.
/// </summary>
/// <summary>
/// The colour a tracer should be, without needing a spawned object to recolour.
///
/// ⚠️ TintIfPacked WALKS COMPONENTS ON AN ALREADY-CLONED PREFAB; this just answers the
/// question. FastTracer sets one Color on one LineRenderer, so there is nothing to walk.
/// Returns null for an unpacked weapon, meaning "use the default streak colour".
/// </summary>
public static Color? PackedTint( SWB.Base.ShootInfo shootInfo )
{
if ( shootInfo is null || !shootInfo.IsPacked ) return null;
// ⚠️ THE TIER'S PALETTE NOW, NOT THE ONE VIOLET ONE. `ColourFor` also carries the Enabled
// check this used to make itself — one author, see its note.
return PapMuzzleFlash.ColourFor( shootInfo.PapLevel );
}
/// <summary>
/// The streak colour for a shot described only as "packed or not".
///
/// ⚠️ FOR A RELAYED SHOT, WHICH HAS NO `ShootInfo` ON THIS MACHINE. Another player's weapon
/// is `NetworkMode.Never` and does not exist here at all — only the fact travels. One author
/// with `PackedTint` so a remote violet cannot drift from a local one.
/// </summary>
public static Color? PackedStreak( int papLevel )
{
// ⛔ TAKES THE LEVEL, NOT A BOOL. A relayed shot used to carry only "packed or not", so
// every other player saw an MK5 draw an MK1 violet streak — the one case where the local
// and remote presentation of the SAME shot disagreed. The wire carries the tier now.
return PapMuzzleFlash.ColourFor( papLevel );
}
public static void TintIfPacked( GameObject tracer, SWB.Base.ShootInfo shootInfo )
{
if ( shootInfo is null || !shootInfo.IsPacked ) return;
// ⛔ THIS READ `PapMuzzleFlash.Palette` — THE LEGACY VIOLET — UNTIL 2026-09-14. When the
// per-tier palettes went in, four call sites were converted to `ColourFor` and this fifth
// one was missed, so the PARTICLE tracer kept firing the original violet while the fast
// tracer, the muzzle flash and the burn decal all used the tier colour.
//
// ⚠️ AND IT HID BEHIND `FastTracer.Enabled`. That branch returns before this line, so the
// bug is invisible on the default path and appears only when the fast tracer is switched
// off — which is exactly how it was found: `nz_tracer_pap` reported `PackedTint` returning
// a correct green on a shot that drew warm-yellow, because the value it reported was not
// the value that branch uses.
//
// ⚠️ `ColourFor` ALSO CARRIES THE `Enabled` CHECK this used to make itself — one author, see
// its note. Nothing in gameplay reads `Palette` any more.
if ( PapMuzzleFlash.ColourFor( shootInfo.PapLevel ) is Color colour )
Recolour( tracer, colour );
}
/// <summary>
/// Repaint a spawned tracer, gradients and all.
///
/// ⛔ `Tint` ALONE DOES NOTHING HERE, AND THAT IS THE BUG THIS EXISTS TO FIX. The first version
/// called `PapMuzzleFlash.TintParticle`, which sets `ApplyColor` + `Tint` — enough for the
/// muzzle flash, and provably not enough for this prefab: the shot stayed yellow/orange. Reading
/// `prefabs/particles/tracer/tracer.prefab` shows why. TWO separate gradients own the colour and
/// neither is `Tint`:
///
/// 1. `ParticleEffect.Gradient` — a Life gradient, white → (1, 0.984, 0.110) → (1, 0.467, 0),
/// i.e. yellow into orange. It colours the sprite, and it outranks Tint.
/// 2. `ParticleTrailRenderer.Color` — the STREAK, carrying its own copy of that same ramp,
/// with `TintFromParticle = false`. That flag means it ignores the particle's colour
/// outright, so nothing done to the ParticleEffect can reach it.
///
/// The trail is the part you actually see, and it was the part nothing was touching.
///
/// ⚠️ BOTH GRADIENTS COLLAPSE TO A FLAT COLOUR, not to a violet ramp. The authored ramp fades
/// hot-to-cool to sell a burning round; a 0.1s streak 100 units long does not read as a fade,
/// and a violet-to-darker-violet ramp just looks like the near end is brighter.
///
/// ⚠️ `TintFromParticle` IS ALSO SET, belt and braces — with a flat Color gradient the trail no
/// longer needs the particle's colour, but leaving it false would mean anything that later
/// tints the ParticleEffect silently fails to reach the trail all over again.
/// </summary>
public static void Recolour( GameObject tracer, Color colour )
{
if ( !tracer.IsValid() )
{
if ( PapMuzzleFlash.Debug )
Log.Warning( "[nz-tracer] nothing to recolour — CreateParticle returned null" );
return;
}
int effects = 0, trails = 0;
foreach ( var effect in tracer.Components
.GetAll<ParticleEffect>( FindMode.EverythingInSelfAndDescendants ) )
{
effect.ApplyColor = true;
effect.Tint = colour;
effect.Gradient = colour;
effects++;
}
foreach ( var trail in tracer.Components
.GetAll<ParticleTrailRenderer>( FindMode.EverythingInSelfAndDescendants ) )
{
trail.TintFromParticle = true;
trail.Color = colour;
trails++;
}
// ⚠️ COUNTS BOTH KINDS SEPARATELY. "0 effects" and "1 effect, 0 trails" are different
// faults — the second is exactly the state that shipped a yellow tracer while the tint
// code ran perfectly — and on screen they are indistinguishable.
if ( PapMuzzleFlash.Debug )
Log.Info( $"[nz-tracer] recoloured {effects} effect(s) + {trails} trail(s) {colour}" );
}
// ── commands ─────────────────────────────────────────────────────────────
/// <summary>
/// `nz_tracer_test [violet]` — fire one tracer from the eye and look at it.
///
/// ⛔ BECAUSE "IS IT VIOLET" TOOK A ROUND TRIP TO ANSWER AND CAME BACK WRONG. Seeing the packed
/// colour otherwise means owning a gun, packing it, and firing — and the first attempt at this
/// feature looked completely correct in code while shipping a yellow streak. Pass 0 for the
/// stock tracer and 1 for the packed one; flipping between them is the whole test.
///
/// ⚠️ Spawns the same prefab through the same <see cref="Recolour"/> the shot path uses, so a
/// pass here is evidence about the real thing rather than about this command.
/// </summary>
[ConCmd( "nz_tracer_test" )]
public static void TestCmd( int violet = 1 )
{
var scene = Game.ActiveScene;
var tracer = Tracer;
if ( !scene.IsValid() || tracer is null )
{
Log.Warning( "[nz-tracer] no scene or no tracer prefab" );
return;
}
var player = PlayerCharacters.Local();
if ( !player.IsValid() ) { Log.Warning( "[nz-tracer] no player" ); return; }
// ⚠️ Aimed where the player is LOOKING and started slightly ahead of the eye, or the streak
// spawns inside the camera's near plane and is invisible for the reason the Scale note
// above describes.
var rot = player.EyeAngles.ToRotation();
var pos = player.WorldPosition + Vector3.Up * 64f + rot.Forward * 24f;
var go = tracer.Clone( new CloneConfig { Transform = new Transform( pos, rot ) } );
// ⚠️ THE ARGUMENT IS A TIER NOW, NOT A BOOLEAN "violet". It was written when there was one
// packed colour; passing 1-5 tests the tier the player would actually see, and a test
// command that draws a colour the game no longer uses is worse than not having one.
if ( violet != 0 && PapMuzzleFlash.ColourFor( violet < 0 ? 1 : violet ) is Color colour )
{
Recolour( go, colour );
Log.Info( $"[nz-tracer] test tracer MK{(violet < 0 ? 1 : violet)} {colour}" );
}
else
{
Log.Info( "[nz-tracer] test tracer stock (yellow/orange)" );
}
}
/// <summary>
/// Tune tracers: `nz_tracers [chance] [scale]`, chance 0 to turn them off.
///
/// ⚠️ SCALE APPLIES INSTANTLY, chance on the next shot — scale is read when the
/// particle spawns, so there is nothing to re-deploy for.
/// </summary>
/// <summary>
/// `nz_tracer_pap` — why is the tracer not the tier colour.
///
/// ⛔ IT PRINTS EVERY GATE ON THE PATH, not the conclusion. The tracer colour survives four
/// separate conditions and any one of them silently falls back to the default warm streak —
/// which looks identical to "the feature is not implemented".
/// </summary>
[ConCmd( "nz_tracer_pap" )]
public static void PapDiag()
{
var player = NZPlayer.Local;
if ( !player.IsValid() ) { Log.Warning( "[nz-tracer] no player" ); return; }
var active = player.Inventory?.Active;
var wep = active.IsValid()
? active.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf )
: null;
if ( !wep.IsValid() || wep.Primary is null )
{ Log.Info( "[nz-tracer] no weapon in hand" ); return; }
var si = wep.Primary;
var tint = PackedTint( si );
Log.Info( $"[nz-tracer] '{wep.GameObject.Name}' IsPacked {si.IsPacked} PapLevel {si.PapLevel}"
+ $" DamageMultiplier x{si.DamageMultiplier:0.##}" );
Log.Info( $"[nz-tracer] PapMuzzleFlash.Enabled {PapMuzzleFlash.Enabled}"
+ $" ColourFor({si.PapLevel}) {(PapMuzzleFlash.ColourFor( si.PapLevel ) is Color c2 ? $"{c2.r:0.##},{c2.g:0.##},{c2.b:0.##}" : "NULL")}" );
Log.Info( $"[nz-tracer] PackedTint -> {(tint is Color c ? $"{c.r:0.##},{c.g:0.##},{c.b:0.##}" : "NULL — falls back to the default streak")}" );
Log.Info( $"[nz-tracer] tracers {(Enabled ? "on" : "OFF")}, chance {Chance:0.##},"
+ $" fast path {(FastTracer.Enabled ? "on" : "off — particle tracer instead")},"
+ $" default streak {FastTracer.Tint.r:0.##},{FastTracer.Tint.g:0.##},{FastTracer.Tint.b:0.##}" );
if ( si.IsPacked && si.PapLevel <= 0 )
Log.Warning( "[nz-tracer] ⛔ PACKED BUT NO TIER — ShootInfo.PapLevel is only written by"
+ " NZPlayer.ApplyStoredUpgrades, which runs on EQUIP. Re-equip the weapon." );
}
[ConCmd( "nz_tracers" )]
public static void Cmd( float chance = -1f, float scale = -1f )
{
if ( scale >= 0f )
Scale = MathX.Clamp( scale, 0.01f, 5f );
if ( chance >= 0f )
{
Chance = MathX.Clamp( chance, 0f, 1f );
Enabled = Chance > 0f;
// Live, so the change is visible without a weapon switch.
foreach ( var w in Game.ActiveScene?.GetAllComponents<SWB.Base.Weapon>()
?? Enumerable.Empty<SWB.Base.Weapon>() )
{
if ( w.Primary is null ) continue;
w.Primary.BulletTracerChance = Chance;
if ( Enabled ) w.Primary.BulletTracerParticle ??= Tracer;
}
}
Log.Info( Enabled
? $"[nz] tracers ON — {Chance:0.##} of shots, size ×{Scale:0.##}"
+ $" (effective {0.5f * Scale:0.###} in first person)"
: "[nz] tracers off" );
}
}