Weapons/BulletDecals.cs

Static utility managing bullet impact decals and packed-round burns. Loads decal prefabs, spawns/clones impact prefabs on walls and bodies, applies tint/scale/lifetime, manages capped queues for wall holes and per-body burns, and provides console commands to tune and test behavior. Also contains small Component classes that animate tint/flare fade and track per-body burn queues.

File Access
using Sandbox;
using SWB.Shared;   // GameObjectExtensions.DestroyAsync
using System.Linq;

namespace NZombies;

/// <summary>
/// Bullet holes on walls.
///
/// ⛔ THE ENGINE SPLITS IMPACT PARTICLES AND THE HOLE INTO TWO PREFABS, AND ONLY THE
/// FIRST WAS EVER SPAWNED. `Surface.PrefabCollection.BulletImpact` is
/// `prefabs/surface/default-bullet.prefab` — a TemporaryEffect plus smokering, smoke
/// and fleks, all particle systems, **no decal**. The hole is
/// `prefabs/surface/default-bullet-decal.prefab`, referenced by nothing in this
/// codebase. `CreateBulletImpact` cloned the particle prefab, named the clone
/// "bullet_decal", stripped its emitters for performance and left an empty object.
///
/// ⚠️ I PREVIOUSLY REPORTED DECALS AS WORKING. `nz_impact_test` printed "decal
/// spawned 'bullet_decal'" and I took that as proof — it proved a GameObject was
/// created, which is not the same claim as a mark appearing on the wall. Checking
/// the prefab's CONTENTS is what settled it.
///
/// ⛔ AND THE CAP IN THIS FILE WAS A SECOND CLAIM OF THE SAME SHAPE. A comment on the
/// `AddDecal` call below said *"Through the same pooled queue as everything else, so
/// `MaxDecals` (30) bounds holes as well"*. It did not. `WeaponParticleManager` is a
/// scene component and it is **in no scene in this project** — grep the .scene files, there
/// is not one — so `Instance` is null, `Instance?.AddDecal( go )` is a null-conditional
/// no-op, and holes were bounded only by `Lifetime` × fire rate. At 60s and 600 RPM that is
/// six hundred of them.
///
/// ⚠️ THE `?.` IS WHY IT WAS INVISIBLE. A null-conditional call on a missing singleton is
/// not a safe fallback, it is a silently skipped feature — the same trap INSTRUCTIONS.md
/// §13 records for `Instance?.Foo()` on a lazily-created singleton, where the same file
/// carried a doc comment warning about it and four call sites doing it anyway.
///
/// ⛔ SO THE QUEUE LIVES HERE NOW. The alternative — creating the missing
/// `WeaponParticleManager` at runtime the way `WunderfizzMenu.EnsureHost` does — would also
/// have switched on capping for EJECT particles (`MaxEject` 180) and for the impact clones,
/// neither of which was asked for and both of which have been running uncapped long enough
/// that turning a limiter on is a behaviour change, not a fix.
/// </summary>
public static class BulletDecals
{
	/// <summary>The engine's bullet hole.</summary>
	public const string DecalPrefab = "prefabs/surface/default-bullet-decal.prefab";

	/// <summary>Master switch.</summary>
	public static bool Enabled { get; set; } = true;

	/// <summary>How long a hole stays, seconds. 60 chosen in play — long enough that a
	/// room you have fought through still shows it.</summary>
	public static float Lifetime { get; set; } = 60f;

	/// <summary>Hole size multiplier. 1.5 chosen in play; the stock decal is small for
	/// a first-person view.</summary>
	public static float Scale { get; set; } = 1.5f;

	/// <summary>
	/// How many holes may exist at once. The oldest is destroyed to make room.
	///
	/// ⚠️ A HARD CEILING ON TOP OF `Lifetime`, not a replacement for it. The lifetime is
	/// what makes a room you fought through still show it; this is what stops a long
	/// firefight from being unbounded. Whichever comes first wins.
	/// </summary>
	public static int MaxHoles { get; set; } = 30;

	/// <summary>
	/// Live holes, oldest first.
	///
	/// ⛔ A PROPERTY OVER A LAZY FIELD, NOT A `static readonly` INITIALISER.
	/// INSTRUCTIONS.md §1 — hotload migrates static collections, so a queue built in an
	/// initialiser survives a code edit holding references to objects that no longer
	/// exist, and a newly added one arrives null. This rebuilds if it has to.
	/// </summary>
	static System.Collections.Generic.Queue<GameObject> _holes;

	static System.Collections.Generic.Queue<GameObject> Holes
		=> _holes ??= new();

	static PrefabFile _prefab;
	static bool _looked;
	static PrefabFile _papPrefab;
	static bool _papLooked;

	/// <summary>
	/// The PACKED impact — a laser burn instead of a hole.
	///
	/// ⛔ ONE DECAL, TINTED, NOT FIVE. `decals/nz/pap_burn.decal`'s colour texture is authored
	/// NEUTRAL and the tier colour arrives as `Decal.ColorTint`, exactly the way the muzzle flash
	/// tints one flash effect rather than shipping five. A sixth tier costs nothing.
	///
	/// ⚠️ IT REPLACES THE HOLE, IT DOES NOT LAYER OVER IT. `MaxHoles` is 30 and covers every
	/// decal in the scene, so stacking two per impact would halve how far back the wall
	/// remembers being shot — and "instead of a hole" is the brief.
	/// </summary>
	public const string PapDecalPrefab = "prefabs/surface/pap-bullet-decal.prefab";

	/// <summary>Packed impacts use the burn. `nz_decal_pap 0` falls back to plain holes.</summary>
	public static bool PapEnabled { get; set; } = true;

	// ── the hot half ─────────────────────────────────────────────────────────
	// ⛔ NO LIGHT. A first pass made the heat a PointLight and that was wrong: the user asked
	// for the MARK to be bright and to dim, not for packed rounds to light the room. A light
	// at every impact also means an automatic weapon strobing the walls, which is a different
	// effect entirely from a glowing scar.
	//
	// ⚠️ SO THE FADE IS THE DECAL'S OWN TINT, ANIMATED. `Decal.ColorTint` is writable at
	// runtime, so the burn starts at full tier colour and is driven down to a dim stain over
	// `CoolTime`. Nothing is emitted, nothing else in the scene changes brightness, and the
	// mark that is left is the one that was already approved.
	//
	// ⚠️ THE SCORCH SURVIVES THIS UNTOUCHED, which is why it can be one tint over the whole
	// decal. The halo's texel values are ~0.035 — multiplying near-black by anything is still
	// near-black — so the tint animation is visible almost entirely in the bright centre,
	// which is exactly the part that was meant to cool.

	/// <summary>
	/// Fade the burn's colour after impact. **OFF** — `nz_decal_glow 1` turns it on.
	///
	/// ⛔ OFF BECAUSE IT LOST A COMPARISON, NOT BECAUSE IT IS UNFINISHED. Two attempts were made
	/// at cooling the mark — a PointLight at the impact, then this animated tint — and the user
	/// preferred the static burn to both: *"the first version looked better."* The code stays so
	/// the decision can be revisited with one command instead of rebuilt from the changelog.
	/// </summary>
	public static bool GlowEnabled { get; set; } = false;

	/// <summary>Tint multiplier the instant it lands. 1 is the tier colour at full strength;
	/// above 1 pushes the centre toward white-hot before it settles.</summary>
	public static float HotTint { get; set; } = 1.6f;

	/// <summary>Tint multiplier once cool. Not 0 — the burn keeps a trace of what made it,
	/// which is the whole point of colouring it per tier.</summary>
	public static float ColdTint { get; set; } = 0.18f;

	/// <summary>Seconds from hot to cold.</summary>
	public static float CoolTime { get; set; } = 1.1f;

	/// <summary>
	/// Drive one burn's tint from hot to cold, then stop.
	///
	/// ⚠️ IT DISABLES ITSELF RATHER THAN DESTROYING ANYTHING. `PapMuzzleFlash.Fade` destroys its
	/// object because a muzzle flash IS the light; here the object is the decal and it has to
	/// outlive the cooling by the full `Lifetime` — 60 seconds of scorch after one second of
	/// heat. Killing it on cool would delete the mark the moment it finished forming.
	/// </summary>
	public sealed class BurnCool : Component
	{
		[Property] public Decal Target { get; set; }
		[Property] public Color Base { get; set; } = Color.White;
		[Property] public float Life { get; set; } = 1.1f;
		[Property] public float Hot { get; set; } = 1.6f;
		[Property] public float Cold { get; set; } = 0.18f;

		TimeSince _since;

		protected override void OnStart() => _since = 0f;

		protected override void OnUpdate()
		{
			if ( !Target.IsValid() ) { Enabled = false; return; }

			// ⚠️ No `System` in this file's usings, and it does not need one for a guard.
			float t = MathX.Clamp( _since / (Life > 0.001f ? Life : 0.001f), 0f, 1f );

			// ⚠️ EASED, NOT LINEAR. Cooling is fast at first and then lingers; a straight ramp
			// reads as a dimmer switch being turned rather than as heat leaving metal.
			float k = MathX.Lerp( Hot, Cold, t * t );
			Target.ColorTint = Base * k;

			if ( t >= 1f ) Enabled = false;
		}
	}

	/// <summary>How much bigger a burn is than a bullet hole. A laser leaves a wider mark.</summary>
	public static float PapScale { get; set; } = 1.62f;   // 1.35 +20%, 2026-09-14

	/// <summary>
	/// Colour intensity of the burn. Multiplies the tier tint before it reaches the decal.
	///
	/// ⛔ ABOVE 1 ON PURPOSE, AND IT IS SATURATION RATHER THAN BRIGHTNESS THAT IT BUYS. The burn
	/// texture is authored NEUTRAL — a texel is 0.82 grey in all three channels — so at tint x1 a
	/// green tier lands as (0.20, 0.82, 0.20): a mid green, which is what "muted" looked like on
	/// a white pillar. At x2 the same texel is (0.39, 1.64, 0.39); the green channel clips to 1
	/// while red and blue stay at 0.39, and clipping ONE channel is precisely what makes a colour
	/// read as vivid.
	///
	/// ⚠️ RAISING `CORE` IN THE GENERATOR WOULD NOT HAVE DONE THIS. That lifts all three channels
	/// together, so the mark gets paler rather than greener — it walks toward white, which is the
	/// opposite of intensity. The texture's approved shape is untouched by this knob.
	///
	/// ⚠️ THE SCORCH IS UNAFFECTED, same reason one tint over the whole decal works at all: its
	/// texels sit near 0.035, and 0.035 x 2 is still black.
	/// </summary>
	public static float PapTint { get; set; } = 3f;

	// ══ the energy burn — the Prisma's hole ════════════════════════
	//
	// ⚠️ THE PACK-A-PUNCH DECAL, BORROWED WHOLE. It is already authored NEUTRAL so the colour
	// can arrive as `Decal.ColorTint`, which is exactly what a second coloured burn needs — there
	// was nothing to author and nothing to duplicate.

	static bool? _energyOn;
	/// <summary>Does the energy weapon leave its own burn. On.</summary>
	public static bool EnergyEnabled { get => _energyOn ?? true; set => _energyOn = value; }

	static float? _energyScale;
	/// <summary>Size on top of the pap burn's. 2 — twice as big, as asked for.</summary>
	///
	/// ⚠️ IT MULTIPLIES `PapScale`, IT DOES NOT REPLACE IT. "Twice the size" means twice the
	/// thing it was compared against, so the final figure is `Scale × PapScale × this` and it
	/// stays twice the pap burn even if that one is retuned.
	public static float EnergyScale { get => _energyScale ?? 2f; set => _energyScale = value; }

	static Color? _energyBurn;
	/// <summary>
	/// The burn's colour. A light blue.
	/// </summary>
	///
	/// ⛔ ITS OWN FIGURE, NOT `PrismaFx.Tint`, AND FOR A MEASURABLE REASON. The muzzle's azure is
	/// (0.16, 0.48, 1.00) — right for an additive line on a dark screen, too dark and too saturated
	/// for a mark left ON a lit wall, where it reads as a navy smudge rather than as something
	/// that burned. This sits between that and `PrismaFx.Core`: same family, lifted until it reads
	/// as light blue against plaster.
	public static Color EnergyBurn
	{
		get => _energyBurn ?? new Color( 0.35f, 0.66f, 1.00f );
		set => _energyBurn = value;
	}

	static float? _energyTint;
	/// <summary>
	/// Colour intensity of the energy burn. 1.5.
	/// </summary>
	///
	/// ⛔ DELIBERATELY WELL UNDER `PapTint`'s 3, BECAUSE 3 WOULD MAKE IT WHITE. Anything over 1
	/// blooms rather than clips, so at ×3 all three channels are over the line and the hue is
	/// gone — the same trap the muzzle flash fell into when the pale measured accent read as
	/// white. At ×1.5 only BLUE goes over: (0.53, 0.99, 1.50). The glow is blue, the core burns
	/// out light, and the hole still reads as its colour.
	public static float EnergyTint { get => _energyTint ?? 1.5f; set => _energyTint = value; }

	// ⚠️ 3 IS ABOUT WHERE THIS STOPS PAYING. At x2 only the tier's dominant channel clipped, which
	// is what made the colour read as vivid. By x3 the SECOND channel starts clipping too — a
	// green tier's 0.24 red reaches 0.72 of the way to white — so past here the mark gets paler
	// rather than more saturated, and the knob starts undoing itself.

	static PrefabFile PapPrefab
	{
		get
		{
			if ( _papLooked ) return _papPrefab;
			_papLooked = true;

			_papPrefab = ResourceLibrary.Get<PrefabFile>( PapDecalPrefab );

			if ( _papPrefab is null )
				Log.Warning( $"[nz] pap decal '{PapDecalPrefab}' not found — packed shots fall back"
					+ " to plain holes" );

			return _papPrefab;
		}
	}

	/// <summary>
	/// The decal prefab, loaded once.
	///
	/// ⚠️ CACHES THE FAILURE TOO — a missing prefab looked up per bullet would spam
	/// `ERROR_FILEOPEN` at fire rate.
	/// </summary>
	static PrefabFile Prefab
	{
		get
		{
			if ( _looked ) return _prefab;
			_looked = true;

			_prefab = ResourceLibrary.Get<PrefabFile>( DecalPrefab );

			if ( _prefab is null )
				Log.Warning( $"[nz] bullet decal '{DecalPrefab}' not found — no holes" );

			return _prefab;
		}
	}

	/// <summary>Put a hole on a surface.</summary>
	/// <summary>
	/// Is this something a bullet hole must NOT be stuck to?
	///
	/// ⛔ HITSCAN PENETRATION IS WHY THIS MATTERS, not decal accumulation. Bullets here penetrate —
	/// a whole ARC9 Penetration stat drives it — and a hitscan round is resolved in ONE frame, so a
	/// single trigger pull down a corridor of thirteen zombies spawned thirteen decal prefabs in the
	/// same frame. The 30-hole cap bounds what survives; it does nothing about the burst.
	///
	/// ⛔ AND A DECAL ON A SKINNED MODEL IS WRONG ANYWAY. A Decal component is a world projection:
	/// stuck to a walking zombie it stays where the geometry WAS, so the hole slides off the body
	/// within a stride — the same static-emitter problem ZombieAI.FollowVoices documents for sound,
	/// with no equivalent fix.
	///
	/// ⚠️ RAGDOLL AND PLAYER TOO. A corpse is tagged `ragdoll` the moment it dies (ZombieAI:5002),
	/// and it is still shootable; players are shootable in friendly fire. Neither should collect
	/// holes, and both are moving skinned models for the same reason.
	/// </summary>
	public static bool IsFlesh( GameObject go )
	{
		if ( !go.IsValid() ) return false;

		// ⚠️ Ancestors too. A hit resolves against a hitbox or a collider on a CHILD of the zombie —
		// `head` is its own tagged object — so testing only the hit object itself would let every
		// headshot through, which is most of them.
		for ( var o = go; o.IsValid(); o = o.Parent )
			if ( o.Tags.HasAny( "zombie", "ragdoll", "player" ) )
				return true;

		return false;
	}

	// ── the burn that rides the body ───────────────────────────────────
	//
	// ⛔ BOTH REASONS THE `IsFlesh` GATE EXISTS ARE ANSWERED HERE, WHICH IS WHY THIS IS AN
	// EXCEPTION AND NOT A REVERSAL. Holes are refused on bodies because (a) one hitscan round
	// through thirteen zombies spawns thirteen decals in a single frame and (b) a decal is a world
	// projection, so it stays where the geometry WAS and slides off a walking model within a
	// stride. The per-body cap answers (a) — a corridor gets three marks per body however many
	// rounds pass through it — and the one-second life answers (b), because a mark that is gone
	// before the next stride never has time to come adrift. Parenting to the body means it does
	// not drift at all.
	//
	// ⚠️ PACKED ROUNDS ONLY, which is the request rather than a limitation: *"i want the pap
	// bullet decal to also be placed on zombies"*. An unpacked round still marks nothing, so the
	// burn stays the thing that says a gun is packed.
	//
	// ⚠️ AND IT IS A SEPARATE BUDGET FROM `MaxHoles`, ON PURPOSE: *"this does not contribute to
	// the decal limit, instead each zombie has a limit of 3"*. Sharing the queue would let one
	// horde push every hole off the walls inside a second of firing — the wall marks are the
	// long-lived record of a fight and these are a one-second flourish, so they must not compete
	// for the same thirty slots.

	/// <summary>Packed rounds burn the bodies they hit too. `nz_decal_flesh 0` turns it off.</summary>
	public static bool FleshEnabled { get => _fleshOn ?? true; set => _fleshOn = value; }
	static bool? _fleshOn;

	/// <summary>
	/// How many burns one body may carry at once; the oldest is destroyed to make room. 3.
	///
	/// ⚠️ THIS IS A PENETRATION NUMBER, NOT A TASTE NUMBER. Penetration here is a literal body
	/// counter — a battle rifle is stamped for seven bodies — so the question the cap answers is
	/// what one trigger pull does to the zombie NEAREST the muzzle, which takes every pellet of
	/// every round on its way to the other six.
	/// </summary>
	public static int FleshMax { get => _fleshMax ?? 3; set => _fleshMax = value; }
	static int? _fleshMax;

	/// <summary>Seconds from landing to gone. 1.</summary>
	public static float FleshFadeTime { get => _fleshFade ?? 1f; set => _fleshFade = value; }
	static float? _fleshFade;

	/// <summary>Size on top of the wall burn's. 1 — the same mark, on a body.</summary>
	public static float FleshScale { get => _fleshScale ?? 1f; set => _fleshScale = value; }
	static float? _fleshScale;

	// ⚠️ FOUR NULLABLE-BACKED GETTERS RATHER THAN FOUR INITIALISERS. INSTRUCTIONS.md §1 —
	// a static auto-property initialiser does NOT re-run on hotload, so a default changed in code
	// arrives at a running editor still holding the old value. This project has been bitten by it
	// in PhdAugments, the PaP palette, `GlowEnabled` and the shoot-anim toggle.

	/// <summary>
	/// The body a burn should be stuck to, or null if this hit does not take one.
	///
	/// ⛔ THE `ZombieAI` COMPONENT IS THE AUTHORITY, NOT THE TAG, AND THE DIFFERENCE IS THE CAP.
	/// Tags are on the CHILDREN too — `head` is its own tagged object, which is the entire reason
	/// `IsFlesh` walks ancestors — so a tag search returns whichever body PART was hit, and "three
	/// per zombie" quietly becomes three per limb. One component per zombie, one queue per
	/// component, three per zombie.
	///
	/// ⚠️ PLAYERS ARE DELIBERATELY EXCLUDED even though `IsFlesh` covers them. The request was
	/// zombies, and friendly fire is off anyway (Health.IsFriendlyFire) — a burn on a team-mate
	/// would be a mark with no damage behind it, which reads as a bug in the damage code.
	///
	/// ⚠️ IT ANSWERS null BEFORE THE WALK FOR AN UNPACKED ROUND, so the ancestor search does not
	/// run per pellet for the great majority of shots that could never produce a burn. `papLevel`
	/// is free at the call site; the walk is not.
	/// </summary>
	public static GameObject BurnableBody( GameObject hit, int papLevel, bool energy = false )
	{
		// ⚠️ THE EARLY-OUT STILL HAS TO BE FREE, which is the whole reason this method exists
		// separately from the walk below. An energy round is a flag the caller already holds, so
		// adding it costs a boolean and keeps the ancestor search off the hot path for every
		// ordinary pellet.
		var hot = energy && EnergyEnabled;

		if ( !Enabled || !FleshEnabled ) return null;
		if ( !hot && (!PapEnabled || papLevel <= 0) ) return null;
		if ( !hit.IsValid() ) return null;

		GameObject tagged = null;

		for ( var o = hit; o.IsValid(); o = o.Parent )
		{
			if ( o.Components.Get<ZombieAI>( FindMode.EverythingInSelf ) is not null )
				return o;

			// ⚠️ A FALLBACK FOR THE THINGS THAT ARE ZOMBIE-SHAPED WITHOUT BEING ZOMBIES — the
			// Armor perk's decoy is tagged `zombie` and carries no AI (Armor.cs:492). Nearest
			// match rather than highest, because with no AI component there is nothing on the
			// hierarchy that says where the body stops.
			if ( tagged is null && o.Tags.HasAny( "zombie", "ragdoll" ) )
				tagged = o;
		}

		return tagged;
	}

	/// <summary>
	/// Burn a body. Returns null for anything that is not a packed hit on a zombie.
	///
	/// ⛔ NO FALLBACK TO THE PLAIN HOLE, WHICH IS THE OPPOSITE OF `Spawn`'s RULE AND FOR THE
	/// OPPOSITE REASON. On a wall, "no mark at all" reads as a bug, so any mark beats none. On a
	/// body the ordinary grey hole is precisely what was deliberately NOT being drawn before this
	/// — falling back to it would scatter unpacked-looking holes over zombies as a side effect of
	/// a packed-only feature, and would bring back the sliding-decal problem the gate was for.
	/// </summary>
	public static GameObject SpawnOnFlesh( GameObject body, Vector3 pos, Vector3 normal,
		int papLevel, bool energy = false )
	{
		if ( !body.IsValid() ) return null;

		var hot = energy && EnergyEnabled;

		if ( !Enabled || !FleshEnabled ) return null;
		if ( !hot && (!PapEnabled || papLevel <= 0) ) return null;

		// ⚠️ ONE ROLL OF THE TIER COLOUR, held and reused below. `ColourFor` picks a random shade
		// within the tier each call, so asking twice would fade the burn toward a shade it never
		// landed in — the same trap `Spawn` documents for its cooler.
		//
		// ⚠️ THE ENERGY BURN IS A FIXED COLOUR AND SO HAS NO SUCH PROBLEM, but it goes through
		// the same single variable anyway so everything below stays one code path.
		Color tint;

		if ( hot ) tint = EnergyBurn;
		else if ( PapMuzzleFlash.ColourFor( papLevel ) is Color rolled ) tint = rolled;
		else return null;

		// ⛔ THE SAME PAIR OF NUMBERS `Spawn` DERIVES, and they have to agree with it. A body and
		// the wall behind it get hit by the same round; a burn that changed size or colour
		// depending on what stopped it would read as two different weapons firing.
		var boost = hot ? EnergyTint : PapTint;
		var fleshSize = Scale * PapScale * FleshScale * (hot ? EnergyScale : 1f);

		var prefab = PapPrefab;
		if ( prefab is null ) return null;

		var scene = SceneUtility.GetPrefabScene( prefab );
		if ( scene is null ) return null;

		var life = MathX.Clamp( FleshFadeTime, 0.05f, 30f );

		var go = scene.Clone( new CloneConfig
		{
			Name = "bullet_burn_flesh",
			StartEnabled = true,
			Transform = new()
			{
				Position = pos,
				Rotation = Rotation.LookAt( -normal ),
				Scale = fleshSize,
			},
		} );

		// ⛔ NETWORK MODE BEFORE THE REPARENT, NOT AFTER. The prefab is authored networked and
		// the body it is about to hang from IS a networked object (ZombieCommands:2202), so a clone
		// still asking for networking as it is parented is a spawn request from every client that
		// already made its own — the same mark five times over in a five-player game. The effect
		// is global because the RPC that reaches this is, not because the object is.
		go.NetworkMode = NetworkMode.Never;

		// ⚠️ `true` KEEPS THE WORLD TRANSFORM, which also divides out whatever `nz_zscale` did to
		// the body: a burn on a giant is the same size as a burn on an ordinary zombie, because a
		// bullet made it and bullets do not scale with what they hit.
		go.SetParent( body, true );

		var decal = go.Components.Get<Decal>( FindMode.EverythingInSelfAndDescendants );

		if ( decal.IsValid() )
		{
			var full = tint * boost;
			decal.ColorTint = full;

			var fade = go.Components.Create<FleshFade>();
			fade.Target = decal;
			fade.Base = full;
			fade.Life = life;
		}

		// ⚠️ THE FULL LIFE, NOT `HotWallTime`. A wall burn leaves an approved static scar behind
		// its flash; a body burn leaves nothing, so cutting the flare short would leave a dull mark
		// sitting on the zombie for the rest of the second — which is the look this was asked to
		// replace.
		AddHotCore( go, tint, Scale * PapScale * FleshScale, life );

		// ⚠️ A BACKSTOP, NOT THE MECHANISM. `FleshFade` destroys the object the frame it finishes;
		// this is what removes a burn whose decal component could not be found, which is the one
		// path where nothing else is left holding the clean-up.
		go.DestroyAsync( life + 0.25f );

		// ⛔ NOT `Track`. See the section header — the wall queue is a different budget and a
		// body must not spend it.
		body.Components.GetOrCreate<FleshBurns>()?.Take( go, System.Math.Max( 0, FleshMax ) );

		return go;
	}

	/// <summary>
	/// One zombie's burns, oldest first.
	///
	/// ⛔ A COMPONENT ON THE BODY RATHER THAN A STATIC DICTIONARY KEYED BY IT. A dictionary would
	/// hold a reference to every zombie ever shot for as long as the round lasted, because nothing
	/// tells a static that a GameObject died — and a round here runs to wave forty. This dies with
	/// the zombie it counts and takes the queue with it.
	///
	/// ⚠️ THE QUEUE IS A PROPERTY, NOT A FIELD INITIALISER, for the reason `Holes` is one:
	/// INSTRUCTIONS.md §1, hotload migrates a component and a collection built in an initialiser
	/// arrives null on the other side.
	/// </summary>
	public sealed class FleshBurns : Component
	{
		System.Collections.Generic.Queue<GameObject> _marks;

		System.Collections.Generic.Queue<GameObject> Marks => _marks ??= new();

		/// <summary>
		/// Add one and shed the excess.
		///
		/// ⚠️ EVICTING BY QUEUE LENGTH SHEDS THE DEAD FIRST, AND THAT IS THE POINT. Every burn
		/// also fades and destroys itself inside a second, so at any fire rate worth capping the
		/// front of this queue is mostly expired references — dequeuing one costs nothing and
		/// brings the count down without taking a visible mark with it. The LIVE total therefore
		/// never exceeds the cap and usually sits under it, which is the right way round: three is
		/// a ceiling on what is on the body, not a quota to keep full.
		///
		/// ⚠️ `while`, NOT `if` — `nz_decal_flesh 1 1` has to shed two marks on the next hit
		/// rather than one per hit for the next two hits.
		/// </summary>
		public void Take( GameObject go, int cap )
		{
			var q = Marks;
			q.Enqueue( go );

			while ( q.Count > cap )
			{
				var oldest = q.Dequeue();
				if ( oldest.IsValid() ) oldest.Destroy();
			}
		}
	}

	/// <summary>
	/// Fade one burn out, then remove it.
	///
	/// ⚠️ IT DESTROYS WHERE `BurnCool` DISABLES ITSELF, and the two are opposite on purpose. A
	/// wall burn cools to a dim scar that has to survive another fifty-nine seconds, so killing it
	/// on cool would delete the mark the moment it finished forming; this one IS gone at the end of
	/// its ramp, so leaving the object behind would park an invisible decal on a zombie for the
	/// rest of that zombie's life — and a horde would accumulate them three at a time.
	///
	/// ⛔ RGB AND ALPHA BOTH. Whether `Decal.ColorTint`'s alpha reaches the blend is not
	/// something this file can assert from here, and a mark that faded to black instead of to
	/// nothing would leave a black smear on the body — worse than the mark it replaced. Driving
	/// both means it vanishes under either answer, and costs one multiply.
	/// </summary>
	public sealed class FleshFade : Component
	{
		[Property] public Decal Target { get; set; }
		[Property] public Color Base { get; set; } = Color.White;
		[Property] public float Life { get; set; } = 1f;

		TimeSince _since;

		protected override void OnStart() => _since = 0f;

		protected override void OnUpdate()
		{
			if ( !Target.IsValid() ) { GameObject.Destroy(); return; }

			float t = MathX.Clamp( _since / (Life > 0.001f ? Life : 0.001f), 0f, 1f );

			// ⚠️ LINEAR, WHERE THE WALL BURN EASES. `BurnCool` eases because heat leaving metal
			// is fast then slow; this is a mark being wiped rather than something cooling, and over
			// one second an ease reads as a stutter at the end rather than as a curve.
			float k = 1f - t;

			Target.ColorTint = new Color( Base.r * k, Base.g * k, Base.b * k, Base.a * k );

			if ( t >= 1f ) GameObject.Destroy();
		}
	}

	// ── the hot core ───────────────────────────────────────────────────
	//
	// Requested: *"i need the decal to be a lot more intense, like make it seem like its emitting a
	// bright light while not making it emmit light."*
	//
	// ⛔ `PapTint` CANNOT GET THERE, AND RAISING IT FURTHER MAKES IT WORSE. That knob scales the
	// decal's ALBEDO, and albedo is a reflectance: it is multiplied by whatever light already falls
	// on the surface, so a burn in a dim corridor is dim no matter what the number says — the one
	// place a packed round most wants to read as hot. Its own note already records the ceiling: by
	// x3 the tier's SECOND channel starts clipping, so the mark walks toward white and gets PALER
	// rather than brighter. That is the opposite of intense.
	//
	// ⚠️ SO THE INTENSITY IS AN ADDITIVE, UNLIT SPRITE LAID OVER THE MARK. Additive blending
	// ADDS to what is already on screen instead of replacing it, and `Lighting = false` takes it out
	// of the lighting solution entirely — so it is exactly as bright in a black corridor as in
	// daylight, which is what a thing that emits its own light looks like. It emits nothing: no
	// PointLight, no shadow, nothing else in the scene changes brightness. The same three lines
	// `Powerup` uses for its glow, for the same reason.
	//
	// ⚠️ AND ABOVE 1 IT BLOOMS RATHER THAN CLIPS. `PapMuzzleFlash.ColourFor` already documents
	// that the palette is LINEAR and that *"values above 1 are allowed and bloom rather than clip"*
	// — so a colour pushed past 1 blows the core out to white and throws the tier's hue into the
	// halo around it. That halo is the whole effect: it is how a bright source reads on camera, and
	// it is why this is a multiplier on the tier colour rather than a white sprite.
	//
	// ⛔ IT FADES, AND ON A WALL IT FADES FAST. A permanent additive quad on each of thirty
	// holes is thirty layers of overdraw and a light show on a wall that is supposed to be a scar —
	// and the STATIC burn is the look that won a comparison against two animated ones
	// (`GlowEnabled`). So the flare is the moment of impact, and what it leaves behind is the mark
	// that was already approved. On a body there is nothing to leave behind: the whole decal is
	// gone in a second, so the flare rides its full life.

	/// <summary>The flare over a packed impact. ON — `nz_decal_hot 0` turns it off.</summary>
	public static bool HotCore { get => _hotCore ?? true; set => _hotCore = value; }
	static bool? _hotCore;

	/// <summary>
	/// The engine's soft radial flare, by way of the sprite `Powerup` already uses.
	///
	/// ⚠️ REUSED RATHER THAN AUTHORED. `textures/particles/flares/light_glow_01` is a radial
	/// falloff with no colour of its own, which is precisely what a tinted additive core wants —
	/// generating another one would be a second asset to keep and no different on screen.
	/// </summary>
	public const string GlowSprite = "sprites/nz/powerup_glow.sprite";

	/// <summary>
	/// The burn's OWN texture, as a sprite, for the layer that carries the detail.
	///
	/// ⛔ A SMOOTH DISC OVER A TEXTURED MARK IS WHY IT LOOKED WRONG. Reported: *"it looks a lot
	/// less detailed than the decal."* Both layers were the same radial flare — a perfect circle
	/// with a perfect gradient — sitting on top of a burn that has grain, streaks, ejecta and an
	/// irregular edge. The glow was the brightest thing on screen, so the eye read ITS shape and the
	/// decal's detail was what got thrown away.
	///
	/// ⚠️ AND THE TEXTURE NEEDS NO PREPARATION, WHICH IS THE WHOLE REASON THIS IS CHEAP.
	/// `gen_pap_decal.py` writes the colour map as luminance — 0.82 at the core, 0.035 over the
	/// scorch — with the grain and streaks in the alpha. Additive blending adds in proportion to
	/// brightness, so the core comes through hot and the scorch adds essentially nothing: the mask
	/// this layer needs is already the image. A dedicated emissive map would be a second texture to
	/// keep in step with the first for no visible difference.
	///
	/// ⚠️ IT IS THE SAME FILE THE DECAL PROJECTS, not a copy, so retuning the burn in the
	/// generator moves the mark and its glow together and they cannot drift apart.
	/// </summary>
	public const string BurnSprite = "sprites/nz/pap_burn_glow.sprite";

	/// <summary>
	/// Glow width as a multiple of the burn's own width. 0.55.
	///
	/// ⚠️ TIED TO THE MARK, NOT A WORLD SIZE, so `nz_decal_pap`'s scale still moves both
	/// together and a bigger burn does not end up with a glow rattling around inside it.
	///
	/// ⛔ IT NOW MEASURES THE ONLY LAYER THERE IS. It used to size the smooth halo, with
	/// `HotCoreSize` taking a fraction of that for the textured core; 1 × 0.55 was the width that
	/// actually reached the screen, so 0.55 here is the same glow, said once instead of twice.
	///
	/// ⚠️ UNDER 1 BECAUSE THE DECAL'S ROTATION IS RANDOM PER IMPACT (the prefab seeds
	/// `Decal.Rotation` across 0—360) and a billboard cannot match it. At full width you would see
	/// the same irregular burn twice at two angles — a double image. Kept inside the mark, the
	/// mismatch is not legible.
	///
	/// ⚠️ AND IT SPREADS ON ITS OWN AS `HotPower` RISES, because power lifts dim texels into
	/// view rather than only brightening bright ones — so the glow covers more of the mark at 24
	/// than at 6 without this number moving at all.
	/// </summary>
	public static float HotSize { get => _hotSize ?? 0.55f; set => _hotSize = value; }
	static float? _hotSize;

	/// <summary>
	/// Colour multiplier on the flare. 6.
	///
	/// ⚠️ 24, AND THE MEASUREMENT SAYS THAT IS SAFE RATHER THAN RECKLESS. Requested: *"increase
	/// hot power a lot."* The obvious fear is the one the smooth halo actually suffered from — that
	/// high power saturates everything into a featureless white disc — so the texture was swept
	/// before the number moved. Fraction of the burn with EVERY channel clipped, i.e. pure white:
	///
	///     power     6      12      20      26      34      44
	///     white   0.0%    0.1%    0.3%    0.4%    0.5%    0.7%
	///     lit     6.6%    7.3%   11.2%   14.7%   17.8%   20.4%
	///
	/// ⛔ RAISING POWER ADDS DETAIL HERE, WHICH IS THE OPPOSITE OF WHAT IT DID TO THE HALO, and
	/// the texture is why. A smooth radial gradient has most of its area in the mid-tones, so the
	/// clip contour sweeps across it and flattens it. The burn's bright region is SMALL and its
	/// scorch sits at 0.035, so power lifts the dark parts into view faster than it crushes the
	/// light ones: the visible area triples between 6 and 44 while the white area never reaches 1%.
	///
	/// ⚠️ CLIPPING IS ALSO PER CHANNEL, WHICH IS WHAT KEEPS THE TIER READABLE. A pink round's
	/// red saturates long before its green does, so the centre goes white-hot while the edge stays
	/// unmistakably pink — pure white needs all three, and a saturated tier colour rarely gets
	/// there.
	/// </summary>
	public static float HotPower { get => _hotPower ?? 24f; set => _hotPower = value; }
	static float? _hotPower;

	/// <summary>Seconds the flare lasts on a WALL. 0.45 — an impact, not a lamp.</summary>
	public static float HotWallTime { get => _hotWall ?? 0.45f; set => _hotWall = value; }
	static float? _hotWall;

	/// <summary>
	/// The burn decal's authored width, in units — `decals/nz/pap_burn.decal` says `Width: 6`.
	///
	/// ⚠️ A NAMED CONSTANT BECAUSE THE FLARE IS SIZED FROM IT. `Decal.Size` is 1,1 in the prefab
	/// and the real footprint comes from the .decal resource, so "how big is this mark in the world"
	/// is 6 x the transform scale — not 1 x it, which is the number a reader of the prefab alone
	/// would reach for.
	/// </summary>
	const float DecalWidth = 6f;

	/// <summary>
	/// Lay an additive flare over a burn and start it dying.
	///
	/// ⛔ A CHILD OBJECT, NOT A COMPONENT ON THE BURN ITSELF, and for a different reason than
	/// `Powerup`'s (which is dodging a tumble). The burn's own transform carries the decal's
	/// `Rotation.LookAt( -normal )` and its scale; a sprite billboards toward the camera and sizes
	/// itself in world units, so sharing that transform would rotate a billboard that is meant to
	/// face the viewer and scale a size that is already absolute.
	/// </summary>
	static void AddHotCore( GameObject go, Color tint, float worldScale, float life )
	{
		if ( !HotCore || life <= 0f || !go.IsValid() ) return;

		// ⚠️ FALLS BACK TO THE OLD RADIAL FLARE RATHER THAN TO NOTHING. A sprite that fails to
		// load is a packaging problem, not a reason for packed rounds to stop glowing — and this
		// asset is exactly the kind that goes missing in a published build, which is why
		// `sprites/nz/*.sprite` and `decals/nz/*.png` are in the .sbproj's resource list. Degrades
		// to a plain glow instead of to a bug report.
		var sprite = ResourceLibrary.Get<Sprite>( BurnSprite )
			?? ResourceLibrary.Get<Sprite>( GlowSprite );

		if ( sprite is null ) return;

		var glowGO = new GameObject( true, "burn_glow" );
		glowGO.SetParent( go, false );
		glowGO.LocalPosition = Vector3.Zero;

		// ⚠️ SAID EXPLICITLY EVEN THOUGH THE PARENT ALREADY IS. A runtime GameObject defaults to
		// wanting networking, and "it inherits from the parent" is the kind of assumption that
		// produced five copies of one mark the first time round.
		glowGO.NetworkMode = NetworkMode.Never;

		// ⛔ ONE LAYER. The second was a smooth radial flare at full width, and it was removed
		// on sight: *"remove this new circle we made, instead increase hot power a lot."* It had
		// become the problem it was added to solve — at a quarter of the power across the WHOLE
		// width it was simply bigger and brighter than the textured layer inside it, so the shape
		// the eye read was a circle and the burn's structure was buried under it.
		//
		// ⚠️ AND THE ENGINE ALREADY DOES THE JOB IT WAS DOING. A soft glow around a bright
		// source is bloom, which the scene applies to anything over 1 — `PapMuzzleFlash.ColourFor`
		// says so in as many words. Drawing a second quad to fake it was spending overdraw to
		// duplicate a post-process, and the fake had a hard geometric edge the real one does not.
		var core = Flare( glowGO, sprite, DecalWidth * worldScale * HotSize, tint, HotPower );

		var fade = glowGO.Components.Create<HotFade>();
		fade.Core = core;
		fade.CoreBase = core.Color;
		fade.Life = life;
	}

	/// <summary>One additive, unlit layer of the flare.</summary>
	static SpriteRenderer Flare( GameObject on, Sprite sprite, float width, Color tint, float power )
	{
		var r = on.Components.Create<SpriteRenderer>();
		r.Sprite = sprite;
		r.Size = new Vector2( width, width );

		// ⚠️ BUILT COMPONENT BY COMPONENT RATHER THAN `tint * power`, so the multiplier cannot
		// reach ALPHA. `Color * float` scales all four, and an alpha of 6 is a value no blend mode
		// has an opinion about — it either clamps or it does something undefined, and neither is
		// worth finding out per bullet.
		r.Color = new Color( tint.r * power, tint.g * power, tint.b * power, 1f );

		r.Additive = true;

		// ⚠️ UNLIT AND SHADOWLESS — it is emissive by definition. `Powerup` carries the same
		// pair with the same note: a glow that takes the room's lighting goes dim in exactly the
		// dark corner where it most needs to be seen.
		r.Lighting = false;
		r.Shadows = false;

		// ⚠️ SOFTENS WHERE THE QUAD MEETS THE SURFACE. A billboard planted on a wall intersects
		// it, and without this the sprite is cut off by a hard diagonal line — which reads as a
		// rendering bug rather than as a glow.
		r.DepthFeather = 8f;

		return r;
	}

	/// <summary>
	/// Drive one flare from full to nothing, then stop drawing it.
	///
	/// ⚠️ IT DISABLES THE RENDERER AT THE END RATHER THAN JUST REACHING ZERO. An additive sprite
	/// at colour zero adds nothing and is invisible, but it is still a quad being rasterised and
	/// blended — for another 59.5 seconds, on up to thirty wall burns at once. Switching it off is
	/// what makes the cost the impact rather than the scar.
	///
	/// ⚠️ SQUARED FALLOFF, so it is brightest instantly and gone quickly. A linear ramp on
	/// something this bright reads as a lamp being dimmed; an impact flash does not dim, it stops.
	/// </summary>
	public sealed class HotFade : Component
	{
		[Property] public SpriteRenderer Core { get; set; }
		[Property] public Color CoreBase { get; set; } = Color.White;
		[Property] public float Life { get; set; } = 0.45f;

		TimeSince _since;

		protected override void OnStart() => _since = 0f;

		protected override void OnUpdate()
		{
			if ( !Core.IsValid() ) { Enabled = false; return; }

			float t = MathX.Clamp( _since / (Life > 0.001f ? Life : 0.001f), 0f, 1f );
			float k = (1f - t) * (1f - t);

			Core.Color = new Color( CoreBase.r * k, CoreBase.g * k, CoreBase.b * k, CoreBase.a * k );

			// ⚠️ THE CLIP POINT WALKS INWARD AS THIS DIMS, AND THAT IS THE FADE'S REAL SHAPE. At
			// ×24 most of the burn is over the ceiling, so the first part of the ramp shrinks the
			// white region rather than dimming anything — the mark appears to cool from the edge in
			// before it starts to disappear, which is what a cooling burn does.
			if ( t >= 1f ) { Core.Enabled = false; Enabled = false; }
		}
	}

	public static GameObject Spawn( Vector3 pos, Vector3 normal, int papLevel = 0,
		bool energy = false )
	{
		if ( !Enabled ) return null;

		// ⚠️ FALLS BACK TO THE PLAIN HOLE rather than to nothing, on every way this can fail — the
		// feature off, the prefab missing, the tier having no colour. A packed gun that stops
		// marking walls at all reads as a bug; one that marks them the ordinary way reads as the
		// burn not being enabled, which is what happened.
		//
		// ⛔ THE ENERGY BURN OUTRANKS THE TIER, and that is the right way round. A packed Prisma
		// would otherwise stop leaving its own mark at the moment it became most interesting, and
		// the weapon's identity matters more here than which tier bought it.
		var hot = energy && EnergyEnabled;

		var burn = hot
			? EnergyBurn
			: (PapEnabled && papLevel > 0 ? PapMuzzleFlash.ColourFor( papLevel ) : null);

		// ⚠️ ONE MULTIPLIER FOR BOTH THE CLONE AND THE FLARE, worked out once. They were already
		// two copies of `Scale * PapScale`, and a third place to keep in step is how the flare
		// ends up a different size from the hole it sits in.
		var size = Scale * PapScale * (hot ? EnergyScale : 1f);
		var boost = hot ? EnergyTint : PapTint;

		var prefab = burn.HasValue ? (PapPrefab ?? Prefab) : Prefab;
		if ( prefab is null ) return null;

		var scene = SceneUtility.GetPrefabScene( prefab );
		if ( scene is null ) return null;

		// ⚠️ `LookAt( -normal )` matches what the impact clone already used, so the
		// hole faces out of the wall rather than into it. A decal projecting the wrong
		// way is invisible, which is indistinguishable from not spawning at all.
		var go = scene.Clone( new CloneConfig
		{
			Name = "bullet_hole",
			StartEnabled = true,
			Transform = new()
			{
				Position = pos,
				Rotation = Rotation.LookAt( -normal ),
				Scale = burn.HasValue ? size : Scale,
			},
		} );

		// ⛔ TINTED AFTER THE CLONE, NOT BY AUTHORING THE PREFAB. The prefab is shared by every
		// impact on screen; writing the colour into it would repaint the burns already on the wall
		// every time somebody with a different tier fired.
		if ( burn is Color tint )
		{
			var decal = go.Components.Get<Decal>( FindMode.EverythingInSelfAndDescendants );
			// ⚠️ `PapTint` APPLIES HERE AND TO THE COOLER'S BASE BOTH, so switching cooling on does
			// not quietly halve the intensity by starting from the unboosted colour.
			if ( decal.IsValid() ) decal.ColorTint = tint * boost;

			// ⚠️ THE COOLER TAKES THE SAME COLOUR OBJECT, never a second `ColourFor` call — that
			// picks a random shade each time, so a second roll would cool the burn toward a
			// different shade than it landed in.
			if ( decal.IsValid() && GlowEnabled && CoolTime > 0f )
			{
				var cool = go.Components.Create<BurnCool>();
				cool.Target = decal;
				cool.Base = tint * boost;
				cool.Life = CoolTime;
				cool.Hot = HotTint;
				cool.Cold = ColdTint;
				decal.ColorTint = tint * boost * HotTint;   // no dim first frame
			}

			// ⚠️ INSIDE THE TIER BLOCK, so an unpacked hole never gets one. The flare IS the
			// packed identity here — a grey hole that flashed would say a gun is packed when it
			// is not.
			AddHotCore( go, tint, size, HotWallTime );
		}

		go.NetworkMode = NetworkMode.Never;
		go.DestroyAsync( Lifetime );

		Track( go );

		return go;
	}

	/// <summary>
	/// Queue a hole and evict the oldest once over the cap.
	///
	/// ⚠️ SKIPS ALREADY-DEAD ENTRIES WHILE EVICTING. Every hole also has a `DestroyAsync`
	/// timer, so the queue is full of objects that expired on their own — dequeuing one of
	/// those and calling it an eviction would leave the live count above the cap, and the
	/// cap would appear to be off by however many had timed out.
	///
	/// ⚠️ `while`, NOT `if`. A cap lowered at runtime — `nz_decals_max 5` with 30 on the
	/// walls — has to shed the excess rather than trim one per shot, or the new limit would
	/// take twenty-five more bullets to take effect.
	/// </summary>
	static void Track( GameObject go )
	{
		var q = Holes;
		q.Enqueue( go );

		var cap = System.Math.Max( 0, MaxHoles );

		while ( q.Count > cap )
		{
			var oldest = q.Dequeue();
			if ( oldest.IsValid() ) oldest.Destroy();
		}
	}

	/// <summary>
	/// Drop expired entries and return how many are actually alive.
	///
	/// ⚠️ THE QUEUE IS THE AUTHORITY, NOT A SCENE SWEEP. The old count in `nz_decals`
	/// walked every object in the scene looking for the name "bullet_hole", which is
	/// correct but says nothing about whether the CAP is working — a sweep finding 30 and a
	/// queue holding 400 stale references look identical from the outside.
	/// </summary>
	static int Prune()
	{
		var q = Holes;
		var live = 0;

		for ( var i = q.Count; i > 0; i-- )
		{
			var go = q.Dequeue();
			if ( !go.IsValid() ) continue;

			q.Enqueue( go );
			live++;
		}

		return live;
	}

	// ── commands ─────────────────────────────────────────────────────────────

	/// <summary>Tune holes: `nz_decals [0/1] [lifetime] [scale]`.</summary>
	[ConCmd( "nz_decals" )]
	public static void Cmd( int on = -1, float lifetime = -1f, float scale = -1f )
	{
		if ( on >= 0 ) Enabled = on != 0;
		if ( lifetime >= 0f ) Lifetime = MathX.Clamp( lifetime, 1f, 600f );
		if ( scale >= 0f ) Scale = MathX.Clamp( scale, 0.05f, 10f );

		// ⚠️ BOTH COUNTS, AND THEY SHOULD AGREE. The queue is what the cap acts on; the
		// scene sweep is the independent check. A sweep higher than the queue means holes
		// are reaching the world by some path that does not go through Track — which is
		// exactly the bug this file just fixed, in a new place.
		var tracked = Prune();

		var swept = Game.ActiveScene?.GetAllObjects( true )
			.Count( o => o.Name == "bullet_hole" ) ?? 0;

		Log.Info( Enabled
			? $"[nz] bullet holes ON — {Lifetime:0.#}s, size ×{Scale:0.##}"
				+ $", {tracked}/{MaxHoles} tracked"
				+ (swept != tracked ? $", {swept} in the scene ⚠ MISMATCH" : "")
			: "[nz] bullet holes off" );
	}

	/// <summary>
	/// `nz_decals_max [n]` — the ceiling, and shed the excess immediately.
	///
	/// ⚠️ ENFORCED ON THE SPOT rather than at the next bullet. A cap you set and cannot
	/// see the effect of until you fire again is a cap nobody can judge.
	/// </summary>
	[ConCmd( "nz_decals_max" )]
	public static void MaxCmd( int max = -1 )
	{
		if ( max >= 0 )
		{
			MaxHoles = max;

			var q = Holes;
			while ( q.Count > MaxHoles )
			{
				var oldest = q.Dequeue();
				if ( oldest.IsValid() ) oldest.Destroy();
			}
		}

		Log.Info( $"[nz] bullet holes capped at {MaxHoles} — {Prune()} on the walls" );
	}

	/// <summary>`nz_decals_clear` — wipe every hole now.</summary>
	[ConCmd( "nz_decals_clear" )]
	public static void ClearCmd()
	{
		var q = Holes;
		var n = 0;

		while ( q.Count > 0 )
		{
			var go = q.Dequeue();
			if ( !go.IsValid() ) continue;

			go.Destroy();
			n++;
		}

		Log.Info( $"[nz] cleared {n} bullet hole(s)" );
	}

	/// <summary>
	/// `nz_decal_pap [0|1] [scale] [tint]` — the packed burn: switch it off, change how much
	/// wider it is than an ordinary hole, or how intense its colour is. Bare command reports.
	///
	/// ⚠️ `tint` IS THE INTENSITY KNOB and it is the one to reach for first. See `PapTint` —
	/// above 1 saturates the tier's dominant channel instead of washing the mark toward white.
	/// </summary>
	[ConCmd( "nz_decal_pap" )]
	public static void PapCmd( int on = -1, float scale = -1f, float tint = -1f )
	{
		if ( on >= 0 ) PapEnabled = on != 0;
		if ( scale > 0f ) PapScale = scale;
		if ( tint > 0f ) PapTint = tint;

		Log.Info( $"[nz-decal] packed burn {(PapEnabled ? "ON" : "off")}, x{PapScale:0.##} the"
			+ $" size of a hole, colour x{PapTint:0.##}"
			+ $" (hole scale {Scale:0.##}, cap {MaxHoles}, life {Lifetime:0}s)" );
		Log.Info( $"[nz-decal]   asset {PapDecalPrefab}"
			+ (PapPrefab is null ? "   ⛔ MISSING — packed shots fall back to holes" : "   ok") );
		Log.Info( $"[nz-decal]   cooling {(GlowEnabled ? "ON" : "off")}"
			+ $" — tint x{HotTint:0.##} -> x{ColdTint:0.##} over {CoolTime:0.##}s" );
	}

	/// <summary>
	/// `nz_decal_energy [on] [scale] [tint]` — the energy weapon's burn. Bare, it reports.
	/// </summary>
	[ConCmd( "nz_decal_energy" )]
	public static void EnergyCmd( int on = -1, float scale = -1f, float tint = -1f )
	{
		if ( on >= 0 ) EnergyEnabled = on > 0;
		if ( scale > 0f ) EnergyScale = scale;
		if ( tint > 0f ) EnergyTint = tint;

		Log.Info( $"[nz-decal] energy burn {(EnergyEnabled ? "on" : "off")}"
			+ $" · ×{EnergyScale:0.##} the pap burn (final {Scale * PapScale * EnergyScale:0.##})"
			+ $" · tint ×{EnergyTint:0.##}" );

		Log.Info( $"[nz-decal]   colour ({EnergyBurn.r:0.00}, {EnergyBurn.g:0.00},"
			+ $" {EnergyBurn.b:0.00}) → lit ({EnergyBurn.r * EnergyTint:0.00},"
			+ $" {EnergyBurn.g * EnergyTint:0.00}, {EnergyBurn.b * EnergyTint:0.00})" );
	}

	/// <summary>
	/// `nz_decal_energy_colour &lt;r&gt; &lt;g&gt; &lt;b&gt;` — 0-1 or 0-255, it works out which.
	/// </summary>
	[ConCmd( "nz_decal_energy_colour" )]
	public static void EnergyColourCmd( float r = -1f, float g = -1f, float b = -1f )
	{
		if ( r < 0f || g < 0f || b < 0f ) { EnergyCmd(); return; }

		if ( r > 1f || g > 1f || b > 1f ) { r /= 255f; g /= 255f; b /= 255f; }

		EnergyBurn = new Color( r.Clamp( 0f, 1f ), g.Clamp( 0f, 1f ), b.Clamp( 0f, 1f ) );
		EnergyCmd();
	}

	/// <summary>
	/// `nz_decal_glow [0|1] [brightness] [radius] [life]` — the cooling flash at a packed impact.
	///
	/// ⚠️ SEPARATE FROM `nz_decal_pap` BECAUSE THEY FAIL SEPARATELY. The burn is a decal and the
	/// heat is a light; "the mark is right but the glow is wrong" is exactly the report this
	/// came from, and one command for both would make that untunable.
	/// </summary>
	[ConCmd( "nz_decal_glow" )]
	public static void GlowCmd( int on = -1, float hot = -1f, float cold = -1f, float time = -1f )
	{
		if ( on >= 0 ) GlowEnabled = on != 0;
		if ( hot > 0f ) HotTint = hot;
		if ( cold >= 0f ) ColdTint = cold;
		if ( time > 0f ) CoolTime = time;

		Log.Info( $"[nz-decal] burn cooling {(GlowEnabled ? "ON" : "off")} — tint x{HotTint:0.##}"
			+ $" at impact, x{ColdTint:0.##} once cool, {CoolTime:0.##}s to get there" );
		Log.Info( "[nz-decal]   no light is emitted — this is the decal's own colour" );
		Log.Info( "[nz-decal]   nz_decal_papwall puts one of every tier up" );
	}

	/// <summary>
	/// `nz_decal_hot [0|1] [power] [size] [wall-seconds]` — the flare over a packed impact.
	///
	/// ⚠️ SEPARATE FROM `nz_decal_glow`, WHICH IS A DIFFERENT EFFECT WITH A CONFUSINGLY SIMILAR
	/// NAME. That one ramps the DECAL'S OWN TINT from hot to cold and is off because it lost a
	/// comparison; this is an additive sprite laid over the top. Folding them together would make
	/// the rejected effect impossible to revisit, which is the only reason its code is still here.
	///
	/// ⚠️ `power` IS THE ONE TO TURN. See `HotPower` — it is not bounded by a reflectance the
	/// way `nz_decal_pap`'s tint is, so it keeps paying instead of washing toward white.
	/// </summary>
	[ConCmd( "nz_decal_hot" )]
	public static void HotCmd( int on = -1, float power = -1f, float size = -1f, float wall = -1f )
	{
		if ( on >= 0 ) HotCore = on != 0;
		if ( power > 0f ) HotPower = power;
		if ( size > 0f ) HotSize = size;
		if ( wall >= 0f ) HotWallTime = wall;

		var width = DecalWidth * Scale * PapScale * HotSize;

		Log.Info( $"[nz-decal] impact flare {(HotCore ? "ON" : "off")} — {width:0.#} units wide,"
			+ $" {HotSize:0.##}x the burn, colour x{HotPower:0.##}" );

		// ⚠️ PRINTS THE CLIP POINT, WHICH IS THE NUMBER THAT ACTUALLY EXPLAINS THE LOOK. The
		// layer saturates wherever its texture exceeds 1/power, so that figure says how much of the
		// burn is rendering flat — "x24" says nothing, "everything above 0.04 is at the ceiling"
		// says why it looks the way it does.
		Log.Info( $"[nz-decal]   clips above {(HotPower > 0f ? 1f / HotPower : 1f):0.###}"
			+ " — per CHANNEL, so a tier's strong channel whites out while its weak one still"
			+ " carries the hue" );

		// ⚠️ SAYS WHICH SPRITE THE CORE ACTUALLY GOT. The fallback is silent by design, and a
		// silent fallback you cannot see is how the old `Instance?.AddDecal` no-op survived for
		// months — if the detail is missing, this line is the first place to look.
		Log.Info( ResourceLibrary.Get<Sprite>( BurnSprite ) is null
			? $"[nz-decal]   sprite ⛔ {BurnSprite} MISSING — fell back to the smooth flare,"
				+ " so the core has no detail"
			: $"[nz-decal]   core sprite {BurnSprite} — the burn's own texture, so it glows with the"
				+ " same grain and streaks the decal has" );
		Log.Info( $"[nz-decal]   {HotWallTime:0.##}s on a wall, {FleshFadeTime:0.##}s on a zombie"
			+ " — a body's burn is gone with it, a wall's leaves the scar behind" );
		Log.Info( "[nz-decal]   additive and unlit: it emits NO light, nothing else in the scene"
			+ " changes brightness" );
		Log.Info( ResourceLibrary.Get<Sprite>( GlowSprite ) is null
			? $"[nz-decal]   ⛔ {GlowSprite} MISSING — no flare will draw"
			: $"[nz-decal]   sprite {GlowSprite}   ok" );
	}


	/// <summary>
	/// `nz_decal_flesh [0|1] [max] [fade] [scale]` — the burn a packed round leaves on a zombie.
	///
	/// ⚠️ ITS OWN COMMAND BECAUSE IT IS ITS OWN BUDGET. `nz_decals_max` moves the WALL cap and
	/// has nothing to say about bodies; one command for both would make 30 and 3 look like the same
	/// number turned down, which is exactly the confusion the separate queue exists to prevent.
	/// </summary>
	[ConCmd( "nz_decal_flesh" )]
	public static void FleshCmd( int on = -1, int max = -1, float fade = -1f, float scale = -1f )
	{
		if ( on >= 0 ) FleshEnabled = on != 0;
		if ( max >= 0 ) FleshMax = max;
		if ( fade > 0f ) FleshFadeTime = fade;
		if ( scale > 0f ) FleshScale = scale;

		Log.Info( $"[nz-decal] burns on zombies {(FleshEnabled ? "ON" : "off")} — {FleshMax} per body,"
			+ $" gone in {FleshFadeTime:0.##}s, size ×{Scale * PapScale * FleshScale:0.##}" );
		Log.Info( "[nz-decal]   packed rounds only — an unpacked hit still marks nothing" );
		Log.Info( $"[nz-decal]   a separate budget from the wall cap ({MaxHoles}); bodies do not spend it" );
	}

	/// <summary>
	/// `nz_decal_fleshtest [tier]` — burn the zombie under the crosshair five times, so the mark,
	/// the fade and the cap can all be judged without packing a gun and finding a horde.
	///
	/// ⚠️ FIVE, NOT THREE, AND THAT IS THE WHOLE TEST. Three would look identical whether the cap
	/// works or not; the two extra are what show the oldest being evicted rather than accumulating.
	/// If five stay on the body, `FleshBurns` is not being reached.
	///
	/// ⚠️ IT REPORTS THE SWITCHES BEFORE THE TRACE, because "nothing happened" has four possible
	/// causes here — decals off, packed burns off, body burns off, or not looking at a zombie —
	/// and three of them are invisible from in front of the monitor.
	/// </summary>
	[ConCmd( "nz_decal_fleshtest" )]
	public static void FleshTest( int tier = 3 )
	{
		if ( !Enabled || !FleshEnabled || !PapEnabled )
		{
			Log.Warning( $"[nz-decal] nothing will draw — holes {(Enabled ? "on" : "OFF")},"
				+ $" packed burns {(PapEnabled ? "on" : "OFF")},"
				+ $" body burns {(FleshEnabled ? "on" : "OFF")}" );
			Log.Warning( "[nz-decal]   nz_decals 1 / nz_decal_pap 1 / nz_decal_flesh 1" );
			return;
		}

		var player = NZPlayer.Local;
		var controller = player?.Components.Get<PlayerController>();
		if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		tier = System.Math.Max( 1, System.Math.Min( tier, PapMuzzleFlash.ActivePalettes.Length ) );

		var eye = controller?.EyePosition ?? player.WorldPosition + Vector3.Up * 64f;
		var rot = controller?.EyeAngles.ToRotation() ?? player.WorldRotation;

		var tr = Game.ActiveScene.Trace.Ray( eye, eye + rot.Forward * 4096f )
			.IgnoreGameObjectHierarchy( player.GameObject )
			.Run();

		var body = BurnableBody( tr.GameObject, tier );

		if ( !body.IsValid() )
		{
			Log.Info( tr.Hit
				? $"[nz-decal] '{tr.GameObject?.Name}' is not a zombie — face one"
				: "[nz-decal] nothing under the crosshair" );
			return;
		}

		// ⚠️ SPACED ALONG THE BODY'S OWN UP, not the world's, so the row lies along a crawler or
		// a ragdoll rather than climbing out of it — the same reason `nz_decal_papwall` steps along
		// the surface it hit instead of along the view.
		var up = body.WorldRotation.Up;
		var made = 0;

		for ( var i = 0; i < 5; i++ )
			if ( SpawnOnFlesh( body, tr.HitPosition + up * ((i - 2) * 6f), tr.Normal, tier ).IsValid() )
				made++;

		Log.Info( $"[nz-decal] {made} burn(s) MK{tier} on '{body.Name}' — {FleshMax} should be left"
			+ $" on it, all gone in {FleshFadeTime:0.##}s" );
	}

	/// <summary>
	/// `nz_decal_papwall` — put one burn of every tier on the wall you are looking at, so the
	/// five can be compared without packing five guns.
	///
	/// ⚠️ SPACED ALONG THE SURFACE, not stacked — five decals at one point is one decal you can
	/// see and four you cannot, which would look exactly like the tint not working.
	/// </summary>
	[ConCmd( "nz_decal_papwall" )]
	public static void PapWall()
	{
		var player = NZPlayer.Local;
		var controller = player?.Components.Get<PlayerController>();
		if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		var eye = controller?.EyePosition ?? player.WorldPosition + Vector3.Up * 64f;
		var rot = controller?.EyeAngles.ToRotation() ?? player.WorldRotation;

		var tr = Game.ActiveScene.Trace.Ray( eye, eye + rot.Forward * 4096f )
			.IgnoreGameObjectHierarchy( player.GameObject )
			.Run();

		if ( !tr.Hit ) { Log.Info( "[nz] nothing to hit — face a wall" ); return; }

		// Step along the surface rather than along the view, so the row lies flat on whatever
		// was hit instead of drifting off it on an angled wall.
		var right = Vector3.Cross( tr.Normal, Vector3.Up ).Normal;
		if ( right.Length < 0.1f ) right = Vector3.Cross( tr.Normal, Vector3.Forward ).Normal;

		int made = 0;
		for ( int tier = 1; tier <= PapMuzzleFlash.ActivePalettes.Length; tier++ )
		{
			var at = tr.HitPosition + right * ((tier - 3) * 14f);
			if ( Spawn( at, tr.Normal, tier ).IsValid() ) made++;
		}

		Log.Info( $"[nz-decal] {made} burn(s) MK1..MK{PapMuzzleFlash.ActivePalettes.Length} on the wall" );
	}

	/// <summary>Put one where you are looking: `nz_decal_test`.</summary>
	[ConCmd( "nz_decal_test" )]
	public static void Test()
	{
		var player = NZPlayer.Local;
		var controller = player?.Components.Get<PlayerController>();

		if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		var eye = controller?.EyePosition ?? player.WorldPosition + Vector3.Up * 64f;
		var fwd = (controller?.EyeAngles.ToRotation() ?? player.WorldRotation).Forward;

		var tr = Game.ActiveScene.Trace.Ray( eye, eye + fwd * 4096f )
			.IgnoreGameObjectHierarchy( player.GameObject )
			.Run();

		if ( !tr.Hit ) { Log.Info( "[nz] nothing to hit" ); return; }

		var go = Spawn( tr.HitPosition, tr.Normal );

		// ⚠️ Reports the child count, because an empty GameObject is exactly the
		// failure this feature was built to fix — "it spawned" was the misleading
		// answer last time.
		Log.Info( go.IsValid()
			? $"[nz] hole at {tr.HitPosition} — {go.Children.Count} child object(s), "
				+ $"{go.Components.GetAll( FindMode.EverythingInSelfAndDescendants ).Count()} component(s)"
			: "[nz] no hole — prefab missing or decals off" );
	}
}