Zombies/ZombieCommands.cs

Console command collection for the NZombies gamemode. Provides many editor/runtime diagnostic and test commands to spawn, configure, injure, inspect and iterate over zombie entities, animations, physics, configs and player/weapon state.

File AccessNetworking
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// ZOMBIE/COMMANDS — the test harness.
///
/// The GMod gamemode shipped exactly this and it's why they could tune
/// performance at all: nz_perf_horde spawned a fixed repeatable horde,
/// nz_perf_nozombies froze the spawner at its source, nz_perf_god let you
/// stand in a crowd and take stable readings. Reference doc §8.
///
/// Their design note is worth repeating: those commands REMOVE entities rather
/// than hiding them, deliberately — "so you can measure their true cost, since
/// the whole question is whether hiding them is enough."
///
/// Type these in the editor console while playing.
/// </summary>
public static class ZombieCommands
{
	// ── COMMANDS/SPAWN ───────────────────────────────────────────────────────

	/// <summary>Spawn one zombie in front of the first player.</summary>
	/// <summary>
	/// Spawn a zombie at an offset from where you are looking:
	/// nz_spawn_at &lt;forward&gt; &lt;right&gt;.
	///
	/// nz_spawn drops one at a RANDOM bearing, which is useless for testing
	/// anything positional — like whether a zombie behind a barrier can reach
	/// you.
	/// </summary>
	/// <summary>
	/// `nz_spawn_at <forward> <right> <hp>` — one zombie, optionally a tanky one.
	///
	/// ⚠ `hp` IS A MULTIPLIER ON THE ROUND CURVE, not an absolute. `SpawnAt` has always
	/// accepted one and this command simply never passed it, so the only way to get a durable
	/// test target was `nz_round_set 50` — which also changes speed, wave size and the max
	/// alive, i.e. four variables to test one. This changes one.
	/// </summary>
	/// <summary>
	/// `nz_repath [scale] [crowdMod]` — how often zombies recalculate their route.
	///
	/// ⚠️ IT PRINTS THE RESULTING TABLE, NOT THE TWO KNOBS. The interval is
	/// `(floor + crowdMod × count) × scale` clamped against a distance term, which means the
	/// number that actually matters — seconds between path updates in a heavy round at melee range
	/// — is not either input and cannot be read off them. Printing the table is the difference
	/// between tuning this and guessing at it.
	///
	/// ⚠️ APPLIES TO ZOMBIES ALREADY ALIVE. Both are `[Property]` on the component, so this walks
	/// the live set; a value set here does NOT persist to new rounds unless it is put in the code.
	/// </summary>
	[ConCmd( "nz_repath" )]
	public static void RepathCmd( float scale = -1f, float crowdMod = -1f )
	{
		var all = ZombieAI.All.Where( z => z.IsValid() ).ToList();

		foreach ( var z in all )
		{
			if ( scale > 0f ) z.RepathIntervalScale = scale;
			if ( crowdMod >= 0f ) z.RepathCrowdMod = crowdMod;
		}

		// ⚠️ READ BACK OFF A LIVE ZOMBIE WHERE THERE IS ONE, so the report cannot claim a value
		// the horde does not actually have — the two only agree until something else writes them.
		var sc = all.Count > 0 ? all[0].RepathIntervalScale : (scale > 0f ? scale : 0.3f);
		var cm = all.Count > 0 ? all[0].RepathCrowdMod : (crowdMod >= 0f ? crowdMod : 0.02f);

		Log.Info( $"[nz-repath] scale x{sc:0.##}  crowd +{cm:0.###}s per zombie"
			+ $"  · {all.Count} alive now" );
		Log.Info( "[nz-repath]   horde    melee (<295u)     chasing (850u+)" );

		foreach ( var n in new[] { 1, 10, 20, 35, 50 } )
		{
			// ⚠️ THE TWO FLOORS, NOT THE CLAMP. `RepathInterval` clamps a distance term between a
			// floor and a ceiling; at melee range that term is ~0 and at 850-1000u it is ~1, so in
			// both of the cases worth printing the FLOOR is what wins and the distance term never
			// binds. Reproducing the whole clamp here would add arithmetic that cannot change the
			// answer — and a second copy of it to drift from the original.
			var mod = cm * n;
			var near = (0.5f + mod) * sc;
			var far = (1.5f + mod) * sc;

			Log.Info( $"[nz-repath]   {n,3} zombies   {near,5:0.00}s"
				+ $"           {far,5:0.00}s"
				+ (n == all.Count ? "   ← now" : "") );
		}

		Log.Info( "[nz-repath]   lower = sharper turns and less corner-cutting, more pathfinding cost."
			+ "  Watch `zombie.update` in nz_cpu before keeping a value." );
	}

	/// <summary>
	/// `nz_zwhy` — why is the horde not coming.
	///
	/// ⛔ IT ANSWERS THE TWO HALVES SEPARATELY, because they look identical from the floor and have
	/// nothing in common. A zombie standing still either (a) has NO TARGET, in which case
	/// `OnNoTarget` walks it to the nearest spawn and it stays there — or (b) HAS a target it cannot
	/// reach, in which case the agent walks the path as far as it goes and stops. Both read as
	/// "zombies walk to a random spot and stay".
	///
	/// ⚠️ FOR (a) IT PRINTS THE CANDIDATE FILTERS PER PLAYER. `GetTargetables` drops a player for
	/// four different reasons and the list simply comes back shorter; this says which reason.
	///
	/// ⚠️ FOR (b) IT ASKS THE NAVMESH WHERE EACH PLAYER ACTUALLY IS. `GetClosestPoint` returns the
	/// nearest point ON the mesh — so a player standing somewhere the mesh does not cover (placed by
	/// a revive, a teleport, or frozen mid-fall by a bleedout) shows up as a large distance, and
	/// every zombie pathing to them stops short by exactly that much.
	/// </summary>
	[ConCmd( "nz_zwhy" )]
	public static void ZombieWhy()
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz-zwhy] no scene" ); return; }

		var nav = scene.NavMesh;

		Log.Info( $"[nz-zwhy] {(NZGame.IsHost ? "HOST — these zombies really think" : "CLIENT — zombies here are puppets, the host decides")}"
			+ $" · navmesh {(nav is null ? "MISSING" : nav.IsEnabled ? "on" : "DISABLED")}" );

		// ── (a) who can be targeted at all ──
		foreach ( var ctrl in scene.GetAllComponents<PlayerController>() )
		{
			var go = ctrl.GameObject;
			var np = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );

			var why = np is null ? "no NZPlayer (still targetable)"
				: np.IsDown ? "⛔ DOWN"
				: np.IsOutOfRound ? "⛔ OUT OF ROUND"
				: np.IsUntargetable ? "⛔ UNTARGETABLE (gas, a window, or at the Arsenal / Wunderfizz)"
				: "✅ targetable";

			var at = go.WorldPosition;
			var onMesh = nav?.GetClosestPoint( at );
			var off = onMesh.HasValue ? at.Distance( onMesh.Value ) : -1f;

			Log.Info( $"[nz-zwhy]   {go.Name,-26} {why,-34}"
				+ $" navmesh {(off < 0f ? "NO POINT FOUND ⛔" : $"{off:0.#}u away{(off > 64f ? "  ⛔ OFF-MESH — nothing can path to them" : "")}")}" );
		}

		// ── (b) what the horde is actually doing ──
		var all = ZombieAI.All.Where( z => z.IsValid() && z.State != ZombieState.Dead ).ToList();
		var targeted = all.Count( z => z.Target.IsValid() );

		Log.Info( $"[nz-zwhy] {all.Count} alive · {targeted} have a target · {all.Count - targeted} do NOT"
			+ (all.Count > 0 && targeted == 0
				? "   ⛔ NOBODY IS TARGETED — that is case (a), read the filters above"
				: "") );

		foreach ( var z in all.Take( 6 ) )
			Log.Info( $"[nz-zwhy]   {z.GameObject.Name,-22} {z.State,-10}"
				+ $" target {(z.Target.IsValid() ? z.Target.Name : "⛔ none")}"
				+ $" · retarget in {z.RetargetIn:0.0}s" );

		// ── what the watchdogs have had to do ──
		//
		// ⚠️ THESE ARE NOT REASSURANCE. Every one of them is a self-repair, and a self-repair that
		// keeps firing is a bug being papered over rather than fixed. A climbing number is the
		// evidence that the thing it recovers from is still happening.
		Log.Info( $"[nz-zwhy] recoveries · unstuck {ZombieAI.UnstuckCount}"
			+ $" · idle {ZombieAI.IdleRecoveries}"
			+ $" · speed {ZombieAI.SpeedRescues}"
			+ $" · unreachable skips {ZombieAI.UnreachableSkips}" );

		if ( ZombieAI.IdleRecoveries > 0 )
			Log.Info( "[nz-zwhy]   idle > 0 — zombies HAVE been found standing at a wander point."
				+ " That is the reported bug being caught, not avoided." );

		if ( ZombieAI.UnreachableSkips > 0 )
			Log.Info( "[nz-zwhy]   unreachable > 0 — a player has been off the navmesh."
				+ " Check the OFF-MESH column above; nothing can path to them." );
	}

	/// <summary>
	/// `nz_zrobust [idlePatience] [reachSlack]` — tune the watchdogs, or just read them.
	///
	/// ⚠️ `idlePatience` is how long a zombie may stand at a wander point before it is made to
	/// look again (3s). `reachSlack` is how far off the navmesh a player may be and still be
	/// chaseable (96u) — raise it if legitimate players are being skipped, lower it if zombies are
	/// still freezing short of somebody.
	/// </summary>
	[ConCmd( "nz_zrobust" )]
	public static void ZombieRobust( float idlePatience = -1f, float reachSlack = -1f )
	{
		if ( reachSlack > 0f ) ZombieAI.ReachableSlack = reachSlack;

		var all = ZombieAI.All.Where( z => z.IsValid() ).ToList();

		if ( idlePatience > 0f )
			foreach ( var z in all ) z.IdlePatience = idlePatience;

		var patience = all.Count > 0 ? all[0].IdlePatience : idlePatience;

		Log.Info( $"[nz-zrobust] idle patience {patience:0.#}s"
			+ $" · reachable slack {ZombieAI.ReachableSlack:0}u"
			+ $" · {all.Count} zombie(s)" );

		Log.Info( "[nz-zrobust] watchdogs:" );
		Log.Info( "[nz-zrobust]   anti-stuck    chasing + not moving + nothing blocking → relocate" );
		Log.Info( "[nz-zrobust]   idle recovery idle or targetless + not moving → re-look, then a"
			+ " DIFFERENT spawn" );
		Log.Info( "[nz-zrobust]   speed guard   chasing a live target at MaxSpeed 0 → restore" );
		Log.Info( "[nz-zrobust]   reachability  a player off the navmesh is not a candidate at all" );
	}

	[ConCmd( "nz_spawn_at" )]
	public static void SpawnAtOffset( float forward = 300f, float right = 0f, float hp = 1f,
		float speed = 1f )
	{
		var scene = Game.ActiveScene;

		// ⚠️ From the PLAYER, not Scene.Camera. The camera is a separate object
		// that trails the player, so this spawned zombies relative to wherever
		// the view had been — which is why a "spawn 200 in front of me" landed
		// off screen and made the entrance impossible to film.
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		var rot = p.Components.Get<PlayerController>()?.EyeAngles.ToRotation()
			?? p.WorldRotation;

		var flat = rot.Forward.WithZ( 0 ).Normal;
		var side = rot.Right.WithZ( 0 ).Normal;
		var at = p.WorldPosition + flat * forward + side * right;

		// Drop to the floor, or the zombie spawns in the air and the navmesh
		// agent has nothing to stand on.
		var tr = scene.Trace.Ray( at + Vector3.Up * 128f, at - Vector3.Up * 4096f ).Run();
		var pos = tr.Hit ? tr.HitPosition : at;

		var z = SpawnAt( scene, pos,
			healthMultiplier: MathF.Max( 0.01f, hp ),
			speedMultiplier: MathF.Max( 0.01f, speed ) );

		// ⚠ PRINTS THE RESOLVED HP, not the multiplier. "x40" cannot be checked against the
		// zombie; "3000 hp" can.
		var health = z?.Components.Get<Health>();

		Log.Info( z is null
			? "[nz] spawn failed"
			: $"[nz] spawned at {pos}"
				+ (health.IsValid() ? $" · {health.Max:0} hp" : "")
				+ (hp != 1f ? $" (x{hp:0.##} hp of the round curve)" : "")
			+ (speed != 1f ? $" (x{speed:0.##} speed)" : "") );

		// ⚠ A FAST ZOMBIE IS THE ONLY WAY TO TEST A REAL PROPORTIONAL SLOW. `MinAgentSpeed` is
		// 42 and a normal zombie walks at 55, so anything stronger than about x0.76 is floored
		// and every slow looks identical. Spawn one at x2 (110 u/s) and a x0.5 aura delivers 55 —
		// comfortably above the dead zone — so the slow can actually be observed rather than
		// clamped. Measured with `nz_zspeed_track`.
		//
		// ⚠ SPEED SCALES THE CLIP, AND `MinMoveSpeed` (55) FLOORS THE RESULT, so `speed` below 1
		// does nothing on a normal walker: 0.5 x 46 = 23, floored back to 55. Only values above 1
		// change anything.
	}

	/// <summary>
	/// Damage the nearest zombie: nz_zombie_hurt &lt;amount&gt; [headshot 0/1].
	///
	/// The only way to exercise the economy without pulling a trigger — which
	/// nobody can do over MCP. Goes through Health.Apply, the same path a
	/// bullet takes once DamageInfo has been unpacked, so the point awards it
	/// triggers are the real ones.
	///
	/// ⚠️ Cannot test the KNIFE award: melee is flagged from DamageInfo tags in
	/// Health.OnDamage, and Apply has no tags to read. That award stays
	/// unverified until a melee weapon exists.
	/// </summary>
	[ConCmd( "nz_zombie_hurt" )]
	public static void HurtZombie( float amount = 25f, int headshot = 0 )
	{
		var scene = Game.ActiveScene;
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		var z = ZombieAI.All
			.Where( x => x.State != ZombieState.Dead )
			.OrderBy( x => x.WorldPosition.Distance( player.WorldPosition ) )
			.FirstOrDefault();

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

		var hp = z.Components.Get<Health>();
		if ( !hp.IsValid() ) { Log.Warning( "[nz] zombie has no Health" ); return; }

		var before = player.Points;
		var dealt = hp.Apply( amount, headshot != 0 );

		Log.Info( $"[nz] dealt {dealt:0} ({(headshot != 0 ? "head" : "body")}) — "
			+ $"zombie {hp.Current:0}/{hp.Max:0}, "
			+ $"points {before} -> {player.Points} (+{player.Points - before})" );
	}

	/// <summary>
	/// Damage the nearest zombie through the REAL damage path, with tags:
	/// nz_zombie_dmg &lt;amount&gt; [melee 0/1] [head 0/1].
	///
	/// nz_zombie_hurt goes via Health.Apply, which has no tags and therefore
	/// cannot reach the melee award. This builds a DamageInfo the way a weapon
	/// does and calls Health.OnDamage, so tag-driven logic actually runs.
	///
	/// Setting BOTH melee and head is the interesting case — it proves melee
	/// wins rather than the two stacking.
	/// </summary>
	[ConCmd( "nz_zombie_dmg" )]
	public static void DamageZombie( float amount = 25f, int melee = 0, int head = 0 )
	{
		var scene = Game.ActiveScene;
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		var z = ZombieAI.All
			.Where( x => x.State != ZombieState.Dead )
			.OrderBy( x => x.WorldPosition.Distance( player.WorldPosition ) )
			.FirstOrDefault();

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

		var hp = z.Components.Get<Health>();
		if ( !hp.IsValid() ) { Log.Warning( "[nz] zombie has no Health (spawned this frame?)" ); return; }

		// ⛔ THE ATTACKER IS THE PLAYER, AND UNTIL NOW IT WAS NULL. Every attacker-dependent
		// augment reads `damage.Attacker` - Deadshot's Focus and Concussion, Mule Kick's
		// Overflow, Vigor Rush's Last Round and Point Blank - so all of them silently did
		// nothing when driven from this command, and the command reported a successful hit
		// either way. A test tool that cannot exercise the thing under test is worse than no
		// tool, because it produces a confident green: Focus read 0/27 after three clean
		// headshots and the code was correct the whole time.
		var dmg = new DamageInfo
		{
			Damage = amount,
			Attacker = player.GameObject,
		};

		if ( dmg.Tags is null )
		{
			Log.Warning( "[nz] DamageInfo.Tags is null — cannot tag damage" );
			return;
		}

		if ( melee != 0 ) dmg.Tags.Add( "melee" );
		if ( head != 0 ) dmg.Tags.Add( "head" );

		var before = player.Points;
		hp.OnDamage( dmg );

		Log.Info( $"[nz] dealt {amount:0}"
			+ $" [{(melee != 0 ? "melee" : "bullet")}{(head != 0 ? "+head" : "")}]"
			+ $" — zombie {hp.Current:0}/{hp.Max:0},"
			+ $" points {before} -> {player.Points} (+{player.Points - before})" );
	}

	/// <summary>Per-tick entrance logging: nz_spawn_debug [0/1]. Off by default —
	/// a wave of 24 risers would bury the console.</summary>
	[ConCmd( "nz_spawn_debug" )]
	public static void SpawnDebug( int on = -1 )
	{
		ZombieAI.DebugSpawn = on < 0 ? !ZombieAI.DebugSpawn : on != 0;

		Log.Info( ZombieAI.DebugSpawn
			? "[nz] spawn debug ON — per-tick Z/rotation while entrances play"
			: "[nz] spawn debug off" );
	}

	/// <summary>Restrict spawns to the verified entrance list: nz_spawn_verified
	/// [0/1]. Bare command reports the current state and what is in the list.</summary>
	[ConCmd( "nz_spawn_verified" )]
	public static void SpawnVerified( int on = -1 )
	{
		if ( on >= 0 ) ZombieAI.VerifiedSpawnOnly = on != 0;

		Log.Info( ZombieAI.VerifiedSpawnOnly
			? $"[nz] verified entrances ONLY — {WalkerAnimations.SpawnVerified.Count} of "
				+ $"{WalkerAnimations.Spawn.Count}: {string.Join( ", ", WalkerAnimations.SpawnVerified )}"
			: $"[nz] all {WalkerAnimations.Spawn.Count} entrances in rotation" );
	}

	/// <summary>
	/// Trace one entrance's POSE, tick by tick: nz_spawn_trace [clip] [rate].
	///
	/// ⚠️ THE COMMAND THAT ANSWERS "IS IT ANIMATING". Every other diagnostic
	/// reports world positions, and our own root motion moves the object — so a
	/// static skeleton being dragged upward reads exactly like a climb. This
	/// prints each bone RELATIVE TO THE OBJECT, in XYZ. Numbers that change mean
	/// the clip is playing; numbers that sit still mean it is not.
	///
	/// Existed as a bool with no way to set it: nz_spawn_verify turns the trace
	/// OFF (twenty clips at 10 lines a second is unreadable) and nothing turned
	/// it back on, so the one measurement that settles the question was
	/// unreachable from the console after the first sweep.
	/// </summary>
	[ConCmd( "nz_spawn_trace" )]
	public static void SpawnTrace( string clip = "", float rate = 0f )
	{
		ZombieAI.DebugSpawn = true;
		ZombieAI.DebugSpawnVerboseTicks = true;

		if ( rate > 0f ) ZombieAI.SpawnPlaybackRate = MathF.Max( 0.05f, rate );
		if ( !string.IsNullOrWhiteSpace( clip ) ) ZombieAI.ForcedSpawnClip = clip;

		// A sweep left running would clear this zombie out from under the trace.
		_verifyQueue.Clear();
		_sweeping = false;

		Clear();
		SpawnAtOffset( 220f, 0f );

		Log.Info( $"[nz] tracing '{(string.IsNullOrWhiteSpace( ZombieAI.ForcedSpawnClip ) ? "random" : ZombieAI.ForcedSpawnClip)}' "
			+ $"at {ZombieAI.SpawnPlaybackRate:0.00}x — pose is printed relative to the object" );
	}

	/// <summary>
	/// Everything needed to watch one entrance: aimlock on, spawn debug on,
	/// clear the field, then drop a single zombie at an offset.
	///
	/// One command because the setup is four steps and getting it wrong wastes
	/// the very thing being observed — the animation only plays once.
	/// </summary>
	[ConCmd( "nz_spawn_watch" )]
	public static void SpawnWatch( float forward = 220f, float right = 0f )
	{
		ZombieAI.DebugSpawn = true;
		NZPlayer.AimLock = true;

		Clear();
		SpawnAtOffset( forward, right );

		Log.Info( $"[nz] watching — aimlock on, "
			+ $"clip {(string.IsNullOrWhiteSpace( ZombieAI.ForcedSpawnClip ) ? "random" : ZombieAI.ForcedSpawnClip)}, "
			+ $"rate {ZombieAI.SpawnPlaybackRate:0.00}x" );
	}

	/// <summary>
	/// Force one entrance clip: nz_spawn_clip &lt;name&gt;. Empty restores random,
	/// "?" lists them.
	/// </summary>
	[ConCmd( "nz_spawn_clip" )]
	public static void SpawnClip( string name = "" )
	{
		if ( name == "?" )
		{
			Log.Info( $"[nz] {WalkerAnimations.Spawn.Count} entrance clips:" );
			foreach ( var c in WalkerAnimations.Spawn ) Log.Info( $"[nz]   {c}" );
			return;
		}

		ZombieAI.ForcedSpawnClip = name;
		Log.Info( string.IsNullOrWhiteSpace( name )
			? "[nz] entrance clip: random"
			: $"[nz] entrance clip forced: {name}" );
	}

	/// <summary>
	/// Tune the entrance root motion:
	/// nz_spawn_motion &lt;on&gt; [scale] [yaw] [endAtSpawn].
	///
	/// Bare command prints the current values.
	/// </summary>
	[ConCmd( "nz_spawn_motion" )]
	public static void SpawnMotion( int on = -1, float scale = -1f,
		float yaw = -999f, int endAtSpawn = -1 )
	{
		if ( on >= 0 ) ZombieAI.ApplyRootMotion = on != 0;
		if ( scale > 0f ) ZombieAI.RootMotionScale = scale;
		if ( yaw > -998f ) ZombieAI.RootMotionYaw = yaw;
		if ( endAtSpawn >= 0 ) ZombieAI.EndAtSpawnPoint = endAtSpawn != 0;

		Log.Info( $"[nz] root motion {(ZombieAI.ApplyRootMotion ? "ON" : "off")}"
			+ $"  scale {ZombieAI.RootMotionScale:0.00}"
			+ $"  yaw {ZombieAI.RootMotionYaw:0}"
			+ $"  endAtSpawn {ZombieAI.EndAtSpawnPoint}" );
	}

	/// <summary>
	/// Verify one entrance clip, or sweep them all: nz_spawn_verify [clip].
	///
	/// ⚠️ THIS IS HOW ENTRANCES GET TESTED — not by screenshots. A single frame
	/// cannot tell a climb from a pose, and the object's own Z only reports what
	/// our code did to it. The verdict is measured off the lowest bone: starts
	/// buried, lands on the floor, never pops above it mid-clip.
	///
	/// With no argument it queues every clip and runs them back to back.
	/// </summary>
	[ConCmd( "nz_spawn_verify" )]
	public static void SpawnVerify( string clip = "" )
	{
		ZombieAI.DebugSpawnVerboseTicks = false;
		ZombieAI.DebugSpawn = true;
		ZombieAI.SpawnPlaybackRate = 1f;

		if ( !string.IsNullOrWhiteSpace( clip ) )
		{
			_verifyQueue.Clear();
			ZombieAI.ForcedSpawnClip = clip;
			Clear();
			SpawnAtOffset( 220f, 0f );
			return;
		}

		// Sweep. One at a time — running them together would interleave the
		// verdicts and, worse, let zombies collide and shove each other off
		// the floor the check is measuring against.
		_verifyQueue.Clear();
		foreach ( var c in WalkerAnimations.Spawn ) _verifyQueue.Enqueue( c );

		_sweeping = true;
		Log.Info( $"[nz] verifying {_verifyQueue.Count} entrance clips…" );
		NextVerify();
	}

	static readonly Queue<string> _verifyQueue = new();

	/// <summary>A sweep is running. Separate from the queue's count, which is
	/// already zero while the LAST clip is still playing.</summary>
	static bool _sweeping;

	/// <summary>Start the next queued clip. Called by the round loop's tick via
	/// VerifyTick so the sweep advances without a coroutine.</summary>
	static void NextVerify()
	{
		if ( _verifyQueue.Count == 0 )
		{
			ZombieAI.ForcedSpawnClip = "";

			// ⚠️ Only ever printed if we get here — and VerifyTick used to return
			// on an empty queue BEFORE calling this, so the completion line never
			// appeared once in twenty clips. _sweeping is what lets the last clip
			// close the sweep out.
			if ( _sweeping ) Log.Info( "[nz] entrance sweep complete" );
			_sweeping = false;
			return;
		}

		ZombieAI.ForcedSpawnClip = _verifyQueue.Dequeue();
		Clear();
		SpawnAtOffset( 220f, 0f );
	}

	/// <summary>Advance the sweep when the field empties. Driven from
	/// RoundManager's tick, which already runs every frame.</summary>
	public static void VerifyTick()
	{
		if ( !_sweeping ) return;
		if ( ZombieAI.All.Any( z => z.State == ZombieState.Spawning ) ) return;

		NextVerify();
	}

	/// <summary>
	/// Dump the skeleton: nz_bones [filter].
	///
	/// ⚠️ Written after guessing bone INDICES three separate times and being
	/// wrong each time — bone 0 is not necessarily the motion root, and
	/// GetBoneObject silently returns nothing for bones without a GameObject.
	/// Look the index up by NAME before measuring anything with it.
	/// </summary>
	[ConCmd( "nz_bones" )]
	public static void Bones( string filter = "" )
	{
		var z = ZombieAI.All.FirstOrDefault();
		if ( !z.IsValid() ) { Log.Warning( "[nz] no zombies — nz_spawn first" ); return; }

		var r = z.Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );
		var bones = r?.Model?.Bones?.AllBones;
		if ( bones is null ) { Log.Warning( "[nz] no skeleton" ); return; }

		var world = r.BoneWorldTransforms;
		var origin = z.WorldPosition;

		// ⚠️ Both frames of reference, every time. WORLD says where the bone is;
		// LOCAL (bone minus object origin) says what the POSE is doing, and only
		// the second one can tell an animation from a zombie being slid around by
		// our own root-motion code. Reading world Z alone is what made a static
		// pose look like a climb.
		Log.Info( $"[nz] {bones.Count} bones — object at "
			+ $"{origin.x:0.0},{origin.y:0.0},{origin.z:0.0}"
			+ (string.IsNullOrWhiteSpace( filter ) ? "  (showing first 30)" : $"  matching '{filter}'") );
		Log.Info( $"[nz]   {"idx",-4} {"bone",-28} {"world x,y,z",-26} local x,y,z" );

		int shown = 0;
		var lowest = float.MaxValue;
		var lowestName = "?";

		foreach ( var b in bones )
		{
			if ( b.Index < world.Length )
			{
				var p = world[b.Index].Position;
				if ( p.z < lowest ) { lowest = p.z; lowestName = b.Name; }
			}

			if ( !string.IsNullOrWhiteSpace( filter )
				&& !b.Name.Contains( filter, StringComparison.OrdinalIgnoreCase ) ) continue;

			// Keep scanning for the lowest bone even once the print limit is hit —
			// the summary below has to cover the WHOLE skeleton, not the first 30.
			if ( string.IsNullOrWhiteSpace( filter ) && shown >= 30 ) continue;

			if ( b.Index >= world.Length )
			{
				Log.Info( $"[nz]   [{b.Index,3}] {b.Name,-28} —" );
				shown++;
				continue;
			}

			var w = world[b.Index].Position;
			var l = w - origin;

			Log.Info( $"[nz]   [{b.Index,3}] {b.Name,-28} "
				+ $"{w.x,8:0.0},{w.y,8:0.0},{w.z,8:0.0}   "
				+ $"{l.x,7:+0.0;-0.0;0.0},{l.y,7:+0.0;-0.0;0.0},{l.z,7:+0.0;-0.0;0.0}" );
			shown++;
		}

		// The number the entrance verifier measures everything against: how far
		// the lowest bone sits from the object origin. For a zombie standing on
		// the ground this IS the resting offset, and every "lands on floor"
		// reading has to be judged against it rather than against zero.
		if ( lowest < float.MaxValue )
			Log.Info( $"[nz]   lowest bone '{lowestName}' at world z {lowest:0.0}, "
				+ $"{lowest - origin.z:+0.0;-0.0;0.0} from the object origin" );
	}

	/// <summary>Slow-motion entrances for capture: nz_spawn_rate 0.25.</summary>
	[ConCmd( "nz_spawn_rate" )]
	public static void SpawnRate( float rate = 1f )
	{
		ZombieAI.SpawnPlaybackRate = MathF.Max( 0.05f, rate );
		Log.Info( $"[nz] entrance playback {ZombieAI.SpawnPlaybackRate:0.00}x" );
	}

	/// <summary>
	/// `nz_walker_skin` reports the map's walker skin; `nz_walker_skin <name>` sets it, and
	/// `nz_walker_skin ""` puts the stock walker back.
	///
	/// ⚠️ IT AFFECTS THE NEXT ZOMBIE TO SPAWN, NOT THE ONES ALREADY STANDING. The body model and
	/// the animation tier are chosen in `OnStart` from the variant, so a live zombie cannot change
	/// skin without being rebuilt — `nz_cleanup` then `nz_horde` is the quick way to see it.
	///
	/// ⚠️ IT WRITES THE MAP CONFIG IN MEMORY ONLY. Save the config to keep it.
	/// </summary>
	[ConCmd( "nz_walker_skin" )]
	public static void WalkerSkin( string name = null )
	{
		var cfg = ActiveConfig.Current;
		if ( cfg is null ) { Log.Warning( "[nz-skin] no active map config" ); return; }

		if ( name is not null )
		{
			cfg.Zombies.WalkerSkin = name.Trim();

			// Resolve immediately so a typo is reported now rather than on the next wave.
			if ( !string.IsNullOrWhiteSpace( cfg.Zombies.WalkerSkin ) )
				WalkerSkins.VariantFor( cfg.Zombies.WalkerSkin );
		}

		var skin = cfg.Zombies.WalkerSkin;
		var resolved = WalkerSkins.Current;

		Log.Info( $"[nz-skin] walker skin: {( string.IsNullOrWhiteSpace( skin ) ? "(stock walker)" : skin )}"
			+ $"   → {( resolved is null ? "no variant" : resolved.ResourceName )}"
			+ $"   registered: {string.Join( ", ", WalkerSkins.Names.Select( n => string.IsNullOrEmpty( n ) ? "(stock)" : n ) )}" );
		Log.Info( "[nz-skin]   applies to the NEXT spawn — nz_cleanup then nz_horde to see it" );
	}

	[ConCmd( "nz_spawn" )]
	public static void Spawn()
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz] no active scene" ); return; }

		var origin = PlayerPosition( scene );
		var pos = origin + Vector3.Random.WithZ( 0 ).Normal * 150f;

		var z = SpawnAt( scene, pos );
		Log.Info( z is null ? "[nz] spawn failed" : $"[nz] spawned at {pos}" );
	}

	/// <summary>Spawn N zombies around the player — the performance test.
	/// Defaults to the concurrent cap (ZombieStats.MaxAlive, now 50), which is the
	/// population a real late round actually stands up and therefore the FPS target.</summary>
	[ConCmd( "nz_horde" )]
	public static void Horde( int count = ZombieStats.MaxAlive )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz] no active scene" ); return; }

		var origin = PlayerPosition( scene );
		int spawned = 0;

		for ( int i = 0; i < count; i++ )
		{
			// Ring them around the player so they're all in view and converging
			float angle = (i / (float)count) * MathF.PI * 2f;
			float dist = 300f + (i % 5) * 80f;
			var pos = origin + new Vector3( MathF.Cos( angle ) * dist,
											MathF.Sin( angle ) * dist, 0 );

			if ( SpawnAt( scene, pos ) is not null ) spawned++;
		}

		Log.Info( $"[nz] spawned {spawned}/{count} — {ZombieAI.All.Count} alive" );
	}

	/// <summary>
	/// `nz_special_spawn &lt;id&gt; [n]` — spawn any special by its roster id, ringed around you.
	///
	/// ⚠️ NAMED INTO THE `nz_special_*` FAMILY, NOT `nz_special`. The report command beside it is
	/// `nz_specialS` — plural — and a singular/plural pair that does two different things is a trap
	/// that costs somebody a minute every time, including me while testing this.
	///
	/// ⛔ ONE COMMAND FOR THE WHOLE ROSTER, BECAUSE THE ROSTER IS 193 ENTRIES LONG. `NapalmZombie`
	/// and `ShriekerZombie` each carry their own spawn command, which was reasonable while there
	/// were two — but the Pest has no component of its own to put one in, and every future special
	/// whose idea fits entirely in a `.zvar` will have the same problem. A per-enemy command would
	/// mean a C# file per enemy for no reason other than testing.
	///
	/// ⚠️ IT NAMES THE VALID IDS WHEN IT FAILS. A typo'd id is otherwise indistinguishable from an
	/// asset that did not load, and those need completely different fixes.
	/// </summary>
	[ConCmd( "nz_special_spawn" )]
	public static void SpawnSpecialCmd( string id = "", int count = 1 )
	{
		if ( string.IsNullOrWhiteSpace( id ) || !SpecialEnemies.IsKnown( id ) )
		{
			Log.Warning( $"[nz] '{id}' is not a special — try: "
				+ string.Join( ", ", SpecialEnemies.Names ) );
			return;
		}

		SpawnSpecial( id, count );
	}

	/// <summary>Spawn a special by roster id. Returns how many actually landed.</summary>
	public static int SpawnSpecial( string id, int count = 1 )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz] no active scene" ); return 0; }

		var variant = SpecialEnemies.VariantFor( id );

		if ( variant is null )
		{
			Log.Warning( $"[nz] '{SpecialEnemies.PathFor( id )}' did not load" );
			return 0;
		}

		var origin = PlayerPosition( scene );
		int made = 0;

		for ( int i = 0; i < count.Clamp( 1, 24 ); i++ )
		{
			var angle = ( i / (float)MathF.Max( 1, count ) ) * MathF.PI * 2f;
			var at = origin + new Vector3( MathF.Cos( angle ), MathF.Sin( angle ), 0f ) * 280f;

			if ( SpawnAt( scene, at, variant ) is not null ) made++;
		}

		Log.Info( $"[nz] spawned {made} {id} — {SpecialEnemies.DescriptionFor( id )}" );
		return made;
	}

	/// <summary>Remove every zombie. Deliberately destroys rather than hides —
	/// hiding wouldn't tell you what they actually cost.</summary>
	[ConCmd( "nz_clear" )]
	public static void Clear()
	{
		int n = ZombieAI.All.Count;
		foreach ( var z in ZombieAI.All.ToList() )
			z.GameObject.Destroy();

		Log.Info( $"[nz] removed {n} zombies" );
	}

	/// <summary>Report what the horde is doing. Cheap alternative to a profiler
	/// when you just want to know whether the AI is alive.
	///
	/// ⛔ RENAMED FROM `nz_status`, WHICH IT WAS SILENTLY WINNING. `StatusEffects` also
	/// registered `nz_status`, and s&amp;box keeps the FIRST registration and logs
	/// "Command nz_status already exists - not overwriting" at Warn — one line among
	/// hundreds at startup. The visible symptom was that `nz_status burn 60` did nothing
	/// but print a horde summary, so there was no way to apply a status for longer than
	/// the 6s `nz_status_test` leaves it, which is what blocked the flame from ever being
	/// looked at. Two commands wanting one name is not a naming problem, it is a bug that
	/// disables the loser completely.</summary>
	[ConCmd( "nz_zombies" )]
	public static void Status()
	{
		var all = ZombieAI.All;
		// ⛔ THE LIVE CAP, NOT THE CONSTANT. `ZombieStats.MaxAlive` is only the DEFAULT that
		// `ZombieSettings` initialises from; a map config or `nz_zcap` can hold something else, and
		// a status line reporting the constant would confidently disagree with the spawner.
		Log.Info( $"[nz] {all.Count} alive (cap {ZombieStats.MaxAliveForRound( RoundManager.Instance?.Round ?? 1 )})" );

		foreach ( var group in all.GroupBy( z => z.State ) )
			Log.Info( $"       {group.Key,-10} {group.Count()}" );

		int withTarget = all.Count( z => z.Target.IsValid() );
		Log.Info( $"       with target: {withTarget}/{all.Count}" );

		// Is the nav agent actually driving them? This is the question when
		// zombies target correctly but stand still.
		int moving = all.Count( z => z.Velocity.Length > 1f );
		Log.Info( $"       moving     : {moving}/{all.Count}" );
		foreach ( var z in all.Take( 3 ) )
			Log.Info( $"       {z.AgentDebug}" );

		// The repath interval scales with horde size — reference doc §3.3.
		// Worth surfacing, because it's the main AI cost governor.
		Log.Info( $"       repath mod : +{0.05f * all.Count:0.00}s per zombie" );
	}

	/// <summary>
	/// Dump what the walker model actually contains, and what live zombies are
	/// playing.
	///
	/// This is the command to run when the answer is "no animation" — it splits
	/// one vague symptom into three answerable questions:
	///
	///   0 animations listed  -> the vmdl didn't compile its AnimationList
	///   listed but not played -> the names in WalkSequences don't match
	///   played but time frozen -> playback is stalled (animgraph, or rate 0)
	/// </summary>
	/// <summary>
	/// `nz_seq <name>` — how long one sequence on the walker actually is.
	///
	/// ⛔ BECAUSE "THE MODEL HAS 285 ANIMATIONS" DOES NOT MEAN THEY MOVE. A clip whose DMX carries a
	/// single pose still compiles, still counts, and still reports as present — and plays as a
	/// zombie standing frozen, which is exactly what the slick bar produced. Duration is the
	/// number that tells them apart.
	///
	/// ⚠️ ON A THROWAWAY RENDERER, so it needs no live zombie — `nz_anims` refuses without one.
	/// </summary>
	[ConCmd( "nz_seq" )]
	public static void SeqCmd( string name = "", string modelPath = "models/zombies/walker_honorguard_dmx.vmdl" )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz-seq] no scene" ); return; }

		var go = scene.CreateObject();
		go.Name = "nz_seq probe";
		go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;

		try
		{
			var r = go.Components.Create<SkinnedModelRenderer>();
			r.Model = Model.Load( modelPath );

			if ( r.Model is null || r.Model.IsError )
			{
				Log.Warning( $"[nz-seq] {modelPath} failed to load" );
				return;
			}

			// ⚠️ THE GRAPH HAS TO BE OFF or `Sequence` is ignored — the same rule the view models
			// document: with an animgraph active, `Sequence.Name` does nothing.
			r.UseAnimGraph = false;

			foreach ( var n in name.Split( ',', StringSplitOptions.RemoveEmptyEntries ) )
			{
				r.Sequence.Name = n.Trim();

				Log.Info( $"[nz-seq] {n.Trim(),-34} duration {r.Sequence.Duration:0.###}s"
					+ $"  (name back: '{r.Sequence.Name}')" );
			}
		}
		finally
		{
			go.Destroy();
		}
	}

	[ConCmd( "nz_anims" )]
	public static void Anims( string modelPath = "models/zombies/walker_honorguard_dmx.vmdl" )
	{
		var model = Model.Load( modelPath );
		if ( model is null || model.IsError )
		{
			Log.Warning( $"[nz] {modelPath} -> {(model is null ? "null" : "ERROR MODEL")}. "
				+ "It didn't compile — open it in ModelDoc and check for errors." );
			return;
		}

		Log.Info( $"[nz] {model.Name}" );
		Log.Info( $"[nz]   bones {model.BoneCount}   animations {model.AnimationCount}" );

		if ( model.AnimationCount == 0 )
			Log.Warning( "[nz]   NO ANIMATIONS. The AnimationList is missing from the "
				+ "vmdl, or ModelDoc rejected it. Recompile and watch its error pane." );

		var all = ZombieAI.All;
		Log.Info( $"[nz] {all.Count} live zombies" );

		// ⚠️ The decisive part. Animations and SEQUENCES are different lists,
		// and only a sequence name will play — so counting animations can say
		// "265, all fine" while every attack and death silently fails. This
		// asks a live renderer which names it actually accepts, per category.
		var zombie = all.FirstOrDefault();
		if ( zombie is null )
		{
			Log.Warning( "[nz]   no live zombie — run nz_spawn to check "
				+ "sequences. Animation count alone cannot tell you." );
			return;
		}

		var seq = zombie.SequenceNames;
		if ( seq is null || seq.Count == 0 )
		{
			Log.Warning( "[nz]   renderer exposes NO sequences — nothing can "
				+ "play. The vmdl compiled animations but no sequences." );
			return;
		}

		Log.Info( $"[nz]   sequences {seq.Count}   hitboxes {zombie.HitboxCount}" );

		var known = new HashSet<string>( seq, StringComparer.OrdinalIgnoreCase );
		void Report( string label, List<string> clips )
		{
			int ok = clips.Count( known.Contains );
			var miss = clips.FirstOrDefault( c => !known.Contains( c ) );
			var flag = ok == 0 ? "  <-- DEAD" : ok < clips.Count ? "  (partial)" : "";
			Log.Info( $"[nz]   {label,-18} {ok,3}/{clips.Count,-3}{flag}"
				+ (miss is null ? "" : $"   first missing: {miss}") );
		}

		Report( "walk", WalkerAnimations.Walk );
		Report( "run", WalkerAnimations.Run );
		Report( "sprint", WalkerAnimations.Sprint );
		Report( "supersprint", WalkerAnimations.SuperSprint );
		Report( "attack stand", WalkerAnimations.AttackStand );
		Report( "attack walk", WalkerAnimations.AttackWalk );
		Report( "attack run", WalkerAnimations.AttackRun );
		Report( "attack sprint", WalkerAnimations.AttackSprint );
		Report( "attack ssprint", WalkerAnimations.AttackSupersprint );
		Report( "death (normal)", WalkerDeaths.Normal );
		Report( "death (conditional, not rolled on a bullet)",
			WalkerDeaths.AllConditional.ToList() );
		Report( "pain", WalkerAnimations.Pain );
		Report( "spawn", WalkerAnimations.Spawn );

		foreach ( var z in all.Take( 5 ) )
			Log.Info( $"[nz]   {z.AnimDebug}" );
	}

	/// <summary>
	/// The player's side of the loop: health, points, and whether a weapon is
	/// actually held.
	///
	/// "Held" is the one that catches people out — a weapon can be present and
	/// correctly configured but never fire, because a BaseInventoryItem has to
	/// be added to an inventory and made ACTIVE before input reaches it.
	/// </summary>
	/// <summary>
	/// Why is X colliding with Y?
	///
	/// Collision filtering in s&amp;box is TAG-PAIR based and the rules live in
	/// project settings, so there are three independent things that can each
	/// silently produce "no change", and from in-game they look identical:
	///
	///   1. the tags never got applied      -> this prints them
	///   2. the player's tag isn't "player" -> this prints the real one
	///   3. the matrix rows aren't set      -> the only one left if 1 and 2 are fine
	///
	/// Guessing between those is what wastes a playtest.
	/// </summary>
	[ConCmd( "nz_collide" )]
	public static void Collide()
	{
		static string TagsOf( GameObject go )
		{
			if ( !go.IsValid() ) return "(invalid)";
			try
			{
				var all = go.Tags?.TryGetAll();
				var s = all is null ? "" : string.Join( " ", all );
				return string.IsNullOrWhiteSpace( s ) ? "(none)" : s;
			}
			catch { return "(unreadable)"; }
		}

		// The player's tag is the one the matrix row has to name, and it is the
		// single most likely thing to be wrong — a mismatched string fails
		// silently, with no error anywhere.
		foreach ( var p in Game.ActiveScene.GetAllComponents<PlayerController>() )
		{
			Log.Info( $"[nz] PLAYER '{p.GameObject.Name}' tags: {TagsOf( p.GameObject )}" );

			// ⚠️ THE MATRIX IS OPT-IN PER CONTROLLER. The docs for
			// CharacterController.UseCollisionRules say it decides "what to
			// collide with using current project's collision rules for the
			// GameObject.Tags" — so with it OFF the player sweeps against
			// everything and the ragdoll/player rule is dead no matter what
			// the project file says. This is the first thing to check before
			// blaming the rules themselves.
			var cc = p.Components.Get<CharacterController>();
			if ( cc is null )
				Log.Info( "[nz]   no CharacterController — player collision is "
					+ "driven by something else, so the matrix may not apply" );
			else if ( !cc.UseCollisionRules )
				Log.Warning( "[nz]   CharacterController.UseCollisionRules = "
					+ "FALSE — the player IGNORES the collision matrix. Turn it "
					+ "on or the ragdoll/player rule can never work." );
			else
				Log.Info( "[nz]   CharacterController.UseCollisionRules = true" );
		}

		var all = ZombieAI.All;
		Log.Info( $"[nz] {all.Count} zombies" );

		int living = 0, corpses = 0, untagged = 0;
		foreach ( var z in all )
		{
			bool rag = z.Components.Get<ModelPhysics>() is not null;
			bool cap = z.Components.Get<CapsuleCollider>() is not null;
			var tags = TagsOf( z.GameObject );

			if ( rag ) corpses++; else living++;
			if ( tags == "(none)" ) untagged++;

			if ( living + corpses <= 6 )
				Log.Info( $"[nz]   {z.State,-9} tags[{tags}] "
					+ $"capsule={cap} ragdoll={rag}" );
		}

		Log.Info( $"[nz] living {living}  corpses {corpses}  untagged {untagged}" );

		if ( untagged > 0 )
			Log.Warning( "[nz] SOME ZOMBIES HAVE NO TAGS — the matrix cannot "
				+ "filter them. Tagging happens in EnsureHitDetection; these "
				+ "spawned before that ran, or the code didn't recompile." );
		else
			Log.Info( "[nz] tags OK. If things still collide, the matrix rows "
				+ "are missing: Project Settings > Collision — ignore "
				+ "zombie/zombie, ragdoll/zombie, ragdoll/ragdoll, and "
				+ "ragdoll/<the player tag printed above>." );
	}

	/// <summary>
	/// Dump what the PHYSICS WORLD actually contains for zombies, corpses and
	/// the player.
	///
	/// ⛔ Deliberately uses ONLY members already compiled successfully in this
	/// project. The first cut of this command used PhysicsShape.BoneIndex and
	/// Collider.PhysicsBody — both are in Sandbox's XML docs and NEITHER is
	/// public. Those files document internal members too, so they are a member
	/// list, not an accessibility guide. That mistake has now cost four
	/// round-trips on this one problem.
	///
	/// Everything printed is read off live objects. No inference.
	/// </summary>
	[ConCmd( "nz_physics" )]
	public static void PhysicsDump()
	{
		static void DumpObject( string label, GameObject go )
		{
			if ( !go.IsValid() ) { Log.Info( $"[nz] {label}: (invalid)" ); return; }

			var tags = "";
			try { tags = string.Join( " ", go.Tags?.TryGetAll() ?? Enumerable.Empty<string>() ); }
			catch { tags = "(unreadable)"; }

			Log.Info( $"[nz] {label} '{go.Name}' tags[{(tags == "" ? "none" : tags)}]" );

			foreach ( var c in go.Components.GetAll() )
				Log.Info( $"[nz]     {c.GetType().Name}  enabled={c.Enabled}" );

			var mp = go.Components.Get<ModelPhysics>();
			if ( mp is not null )
				Log.Info( $"[nz]     ModelPhysics.MotionEnabled = {mp.MotionEnabled}" );
		}

		foreach ( var p in Game.ActiveScene.GetAllComponents<PlayerController>() )
			DumpObject( "PLAYER", p.GameObject );

		var living = ZombieAI.All.FirstOrDefault( z => z.State != ZombieState.Dead );
		var corpse = ZombieAI.All.FirstOrDefault( z => z.State == ZombieState.Dead );

		if ( living is null ) Log.Info( "[nz] LIVING: none present" );
		else DumpObject( "LIVING", living.GameObject );

		if ( corpse is null ) Log.Info( "[nz] CORPSE: none present — kill one first" );
		else DumpObject( "CORPSE", corpse.GameObject );

		Log.Info( "[nz] --- what differs between LIVING and CORPSE? That is the "
			+ "only thing that could change how they collide. ---" );
	}

	/// <summary>
	/// Round-trip a config: build one, save it, load it back, compare.
	///
	/// Worth having as a command rather than a one-off test — serialisation
	/// breaks silently when a field is added without a setter or a type stops
	/// being serialisable, and the symptom is a value quietly reverting to its
	/// default rather than an error.
	/// </summary>
	[ConCmd( "nz_config_test" )]
	public static void ConfigTest()
	{
		const string MAP = "countdown";
		const string NAME = "roundtrip test";

		var cfg = new MapConfig();
		cfg.Player.MaxHealth = 175f;
		cfg.Player.StaminaMax = 80f;
		cfg.Player.HealthRegenDelay = 3.5f;
		cfg.Player.StaminaDrainPerTick = 1.5f;
		cfg.Zombies.BaseHealth = 999;
		cfg.Zombies.MaxAlive = 12;
		cfg.PlayerSpawns.Add( new SpawnPoint( new Vector3( 100, 200, 300 ), 90f ) );
		cfg.ZombieSpawns.Add( new SpawnPoint( new Vector3( -50, 0, 64 ), 180f ) );
		cfg.ZombieSpawns.Add( new SpawnPoint( new Vector3( 400, 25, 64 ), 270f ) );
		cfg.Save( MAP, NAME );

		var back = MapConfig.Load( MAP, NAME );
		if ( back is null ) { Log.Error( "[nz] load returned null — save failed" ); return; }

		void Check( string what, object wrote, object read )
			=> Log.Info( $"[nz]   {(Equals( wrote, read ) ? "ok  " : "FAIL")} {what,-22} "
				+ $"wrote {wrote}  read {read}" );

		Log.Info( "[nz] config round-trip:" );
		Check( "player MaxHealth", 175f, back.Player.MaxHealth );
		Check( "player StaminaMax", 80f, back.Player.StaminaMax );
		Check( "player RegenDelay", 3.5f, back.Player.HealthRegenDelay );
		Check( "stamina drain", 1.5f, back.Player.StaminaDrainPerTick );
		Check( "default hp", 150f, new PlayerSettings().MaxHealth );
		Check( "default regen %", 10f, back.Player.HealthRegenPercent );
		Check( "default startpoints", 500, back.Gameplay.StartingPoints );
		// ⛔ WAS HARDCODED 10 AND HAD BEEN FAILING SILENTLY SINCE `PointsHit` BECAME 5. A test that
		// states the default in its own words stops testing serialisation the moment the default
		// moves — it tests whether anyone remembered to edit the test. Ask the type, like the
		// WaveBase check below already does.
		Check( "default hit points", ZombieStats.PointsHit, back.Gameplay.PointsHit );
		Check( "default knife points", 130, back.Gameplay.PointsKillKnife );
		Check( "zombie BaseHealth", 999, back.Zombies.BaseHealth );
		Check( "zombie MaxAlive", 12, back.Zombies.MaxAlive );
		Check( "player spawns", 1, back.PlayerSpawns.Count );
		Check( "zombie spawns", 2, back.ZombieSpawns.Count );
		Check( "spawn position", new Vector3( 100, 200, 300 ), back.PlayerSpawns[0].Position );
		Check( "spawn yaw", 90f, back.PlayerSpawns[0].Yaw );

		// Untouched values must come back as the ported defaults, not zero —
		// this is what catches a field that failed to serialise.
		Check( "default WaveBase", ZombieStats.WaveBase, back.Zombies.WaveBase );
		Check( "default SpeedCap", ZombieStats.SpeedCap, back.Zombies.SpeedCap );
		Check( "default SpeedCapRound", ZombieStats.SpeedCapRound, back.Zombies.SpeedCapRound );

		Log.Info( $"[nz] configs for {MAP}: "
			+ string.Join( ", ", MapConfig.ListFor( MAP ) ) );
	}

	/// <summary>
	/// Prove the config actually drives the round curves.
	///
	/// Specifically checks the memoization trap: HealthForRound caches on ROUND
	/// ONLY, so if the cache is not cleared when settings change it keeps
	/// returning values from the previous config. That failure is silent — the
	/// numbers look plausible, they are just the old ones.
	/// </summary>
	[ConCmd( "nz_curves" )]
	public static void Curves()
	{
		void Table( string label )
		{
			var s = ActiveConfig.Zombies;
			Log.Info( $"[nz] {label}  (base {s.BaseHealth} hp, +{s.HealthIncrement}/round, "
				+ (s.SpeedCapRound > 1 ? $"top speed on round {s.SpeedCapRound}" : $"speed +{s.SpeedPerRound}/round") + $" cap {s.SpeedCap})" );
			foreach ( var r in new[] { 1, 5, 10, 20 } )
				Log.Info( $"[nz]   round {r,-3} hp {ZombieStats.HealthForRound( r ),-6} "
					+ $"speed {ZombieStats.SpeedForRound( r ),-4} "
					+ $"wave {ZombieStats.WaveTotal( r, 1 ),-4} "
					+ $"delay {ZombieStats.SpawnDelayForRound( r ):0.00}" );
		}

		ActiveConfig.Reset();
		Table( "DEFAULTS — should match the original" );

		var cfg = new MapConfig();
		cfg.Zombies.BaseHealth = 500;
		cfg.Zombies.HealthIncrement = 200;
		cfg.Zombies.SpeedCapRound = 20;   // ⚠️ NOT SpeedPerRound, which only drives the curve when this is 0 (2026-10-05)
		cfg.Zombies.SpeedCap = 120;
		cfg.Zombies.WaveBase = 6;
		ActiveConfig.Set( cfg );
		Table( "MODIFIED — every number must change" );

		ActiveConfig.Reset();
		Table( "RESET — must return to the defaults, NOT stay modified" );
	}

	/// <summary>
	/// Player settings: are they applied, and do the derived timings match the
	/// original's feel?
	///
	/// The derived numbers matter more than the raw ones. "0.9 per tick" means
	/// nothing on its own; "8 seconds of sprint" is the thing that was tuned,
	/// and it is what tells you the tick model was ported correctly rather than
	/// reinterpreted as a per-second rate.
	/// </summary>
	[ConCmd( "nz_playersettings" )]
	public static void PlayerSettingsDump()
	{
		var s = ActiveConfig.Player;

		Log.Info( "[nz] player settings (config)" );
		Log.Info( $"[nz]   health {s.MaxHealth}   walk {s.WalkSpeed}   "
			+ $"sprint {s.SprintSpeed}   jump {s.JumpPower}" );

		var sprintSeconds = s.StaminaDrainPerTick > 0f
			? s.StaminaMax / s.StaminaDrainPerTick * Stamina.TickInterval : 0f;
		var refillSeconds = s.StaminaRegenPerTick > 0f
			? s.StaminaMax / s.StaminaRegenPerTick * Stamina.TickInterval : 0f;
		var healSeconds = s.HealthRegenPercent > 0f
			? 100f / s.HealthRegenPercent * s.HealthRegenRate : 0f;

		Log.Info( $"[nz]   stamina {s.StaminaMax}  drain {s.StaminaDrainPerTick}/tick  "
			+ $"regen {s.StaminaRegenPerTick}/tick  delay {s.StaminaRegenDelay}s" );
		Log.Info( $"[nz]     -> {sprintSeconds:0.0}s of sprint, "
			+ $"{refillSeconds:0.0}s to refill  (original: ~8s sprint)" );

		Log.Info( $"[nz]   health regen  delay {s.HealthRegenDelay}s  "
			+ $"amount {s.HealthRegenPercent}%/tick  rate {s.HealthRegenRate}s" );
		Log.Info( $"[nz]     -> {healSeconds:0.00}s from empty to full, "
			+ $"after a {s.HealthRegenDelay}s wait" );
		var g = ActiveConfig.Gameplay;
		Log.Info( $"[nz]   economy: start {g.StartingPoints}  hit {g.PointsHit}  "
			+ $"kill {g.PointsKill}  headshot {g.PointsKillHeadshot}  "
			+ $"knife {g.PointsKillKnife} (no melee weapon yet)" );

		foreach ( var p in Game.ActiveScene.GetAllComponents<NZPlayer>() )
		{
			var c = p.Components.Get<PlayerController>();
			var st = p.Components.Get<Stamina>();
			var hp = p.Components.Get<Health>();

			Log.Info( $"[nz] live player '{p.GameObject.Name}'" );
			Log.Info( $"[nz]   controller walk {c?.WalkSpeed} run {c?.RunSpeed} "
				+ $"jump {c?.JumpSpeed}" );
			Log.Info( $"[nz]   health {hp?.Current}/{hp?.Max}   "
				+ $"stamina {(st is null ? "NO COMPONENT" : $"{st.Current:0.0}/{s.StaminaMax}")}" );
			Log.Info( $"[nz]   regen component: "
				+ (p.Components.Get<HealthRegen>() is null ? "MISSING" : "present") );
		}
	}

	[ConCmd( "nz_player" )]
	public static void PlayerStatus()
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz] no active scene" ); return; }

		var player = NZPlayer.Local;
		if ( !player.IsValid() )
		{
			Log.Warning( "[nz] no NZPlayer in the scene — add it to the player object" );
			return;
		}

		Log.Info( $"[nz] health {player.Hp?.Current:0}/{player.Hp?.Max:0}"
			+ $"   points {player.Points}   down {player.IsDown}" );

		// ⛔ THE CONTROLLER'S ACTUAL WALK SPEED, NOT THE MULTIPLIER THAT WENT INTO IT. A slow that
		// reports its own input agrees with itself whatever the wiring does — INSTRUCTIONS.md §2,
		// four occurrences and every one returned a confident PASS on a broken system. The number
		// below is read back off `PlayerController` AFTER every term has been applied, which is the
		// only reading that can disagree with the code that produced it.
		var ctrl = player.Components.Get<PlayerController>(
			FindMode.EverythingInSelfAndDescendants );

		if ( ctrl.IsValid() )
			Log.Info( $"[nz] walk {ctrl.WalkSpeed:0.#}   run {ctrl.RunSpeed:0.#}"
				+ $"   ducked {ctrl.DuckedSpeed:0.#}"
				+ $"   (perk x{NZombies.PerkEffects.SpeedMultiplier( player ):0.###},"
				+ $" daze x{NZombies.SonicDaze.SpeedScale( player ):0.###})" );

		// ⚠️ Read it HERE, not from the editor's scene tree — get_game_object
		// returns the SAVED scene's transform, not the running session's, so it
		// reports the spawn-time position no matter where the player has walked.
		// This is the only way to see where a player actually is at runtime.
		Log.Info( $"[nz] at {player.WorldPosition}   "
			+ $"yaw {player.WorldRotation.Yaw():0}" );

		var inv = player.Components.Get<BaseInventoryComponent>();
		Log.Info( inv.IsValid()
			? $"[nz] inventory: active = {inv.ActiveItem?.GameObject?.Name ?? "nothing"}"
			: "[nz] no BaseInventoryComponent on the player" );

		// ⚠️ SWB first — a ported weapon is an SWB.Base.Weapon, and this
		// diagnostic is the quickest way to tell "the prefab did not spawn" from
		// "it spawned and has no ammo", which look identical from inside the game.
		var swb = player.Components.GetInChildren<SWB.Base.Weapon>( true );
		if ( swb.IsValid() )
		{
			var ammo = swb.Components.Get<NZAmmo>();
			Log.Info( $"[nz] weapon: {swb.DisplayName}  active {swb.Active}"
				+ $"  clip {swb.Primary?.Ammo ?? -1}/{swb.Primary?.ClipSize ?? -1}"
				+ (ammo.IsValid() ? $"  reserve {ammo.Reserve}/{ammo.MaxReserve}"
					: "  ⚠ NO NZAmmo — it will never reload") );
			return;
		}

		var wep = player.Components.GetInChildren<NZWeapon>( true );
		Log.Info( wep.IsValid()
			? $"[nz] weapon (legacy): active {wep.IsActive}  clip {wep.Clip1}  reserve {wep.Ammo1}"
			: "[nz] no weapon under the player — neither SWB nor NZWeapon" );
	}

	/// <summary>
	/// What the held weapon's viewmodel can actually play: nz_wep_anims.
	///
	/// ⛔ MEASURED AT RUNTIME, ON PURPOSE. The same report at weapon-creation time
	/// printed `sequences=0` — but that read the renderer three lines after its
	/// Model was assigned, while it was still `Enabled = false`. A sequence table
	/// that has not been built yet reports empty, which is indistinguishable from
	/// a model that genuinely has none. This asks the live renderer instead.
	/// </summary>
	[ConCmd( "nz_wep_anims" )]
	public static void WeaponAnims()
	{
		var player = NZPlayer.Local;

		// ⛔ THE ACTIVE SLOT, NOT THE FIRST CHILD. `GetInChildren<Weapon>( true )`
		// includes disabled components and returns whichever weapon happens to be
		// first in the hierarchy — which since the second slot landed is usually the
		// HOLSTERED one. It reported "no viewmodel renderer" for a weapon that was
		// visibly in hand and animating, because it was inspecting the other gun.
		var inv = player?.Components.Get<NZInventory>( FindMode.EverythingInSelf );
		var active = inv?.Active;

		var wep = active.IsValid()
			? active.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf )
			: player?.Components.GetInChildren<SWB.Base.Weapon>( true );

		if ( !wep.IsValid() ) { Log.Warning( "[nz] no SWB weapon held" ); return; }

		var r = wep.ViewModelRenderer;
		if ( !r.IsValid() ) { Log.Warning( "[nz] weapon has no viewmodel renderer" ); return; }

		var model = r.Model;
		var seqs = r.Sequence.SequenceNames;

		Log.Info( $"[nz] {wep.DisplayName}: animgraph={r.UseAnimGraph}"
			+ $"  enabled={r.Enabled}"
			+ $"  animations={model?.AnimationCount ?? -1}"
			+ $"  sequences={(seqs is null ? "NULL" : seqs.Count.ToString())}" );

		if ( seqs is not null )
			foreach ( var n in seqs ) Log.Info( $"[nz]    seq: {n}" );

		if ( model is not null )
			for ( int i = 0; i < Math.Min( model.AnimationCount, 50 ); i++ )
				Log.Info( $"[nz]    anim: {model.GetAnimationName( i )}" );

		// ⚠️ RATE AND DURATION, not just the name. A clip playing at the wrong SPEED
		// looks identical in a log that only names it — which is exactly how the draw
		// animation stayed unscaled while `DrawTime` claimed to control it. The
		// effective figure (duration / rate) is what the player actually experiences.
		var dur = r.Sequence.Duration;
		Log.Info( $"[nz] currently playing: '{r.Sequence.Name}'  t={r.Sequence.Time:0.00}"
			+ $"  rate={r.PlaybackRate:0.##}  clip={dur:0.00}s"
			+ $"  effective={(r.PlaybackRate > 0 ? dur / r.PlaybackRate : 0f):0.00}s" );
	}

	/// <summary>
	/// Snapshot the viewmodel's pose right now: nz_wep_bones.
	///
	/// ⛔ RUN IT TWICE — once at rest, once DURING a reload — and compare the two
	/// printouts. It deliberately does NOT set a sequence and read back in the
	/// same call: bone transforms are evaluated with the previous frame's pose,
	/// so both samples come out identical and stale. My first version did exactly
	/// that and reported a 0° delta on a model that visibly rotates, which is a
	/// measurement that cannot detect the thing it was written for.
	///
	/// ⚠️ Prints several bones, not just the root — the source SMDs put the
	/// mismatch on bone 0, but that was not confirmed on the COMPILED model and
	/// the compiled skeleton need not share the SMD's indices.
	/// </summary>
	[ConCmd( "nz_wep_bones" )]
	public static void WeaponBones()
	{
		var player = NZPlayer.Local;
		var wep = player?.Components.GetInChildren<SWB.Base.Weapon>( true );
		var r = wep?.ViewModelRenderer;

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

		string playing;
		try { playing = string.IsNullOrEmpty( r.Sequence.Name ) ? "(none — bind pose)" : r.Sequence.Name; }
		catch ( Exception ) { playing = "(unavailable)"; }

		Log.Info( $"[nz] --- viewmodel pose, playing: {playing} ---" );
		Log.Info( $"[nz] object rot: {r.WorldRotation.Angles()}" );

		for ( int i = 0; i < 4; i++ )
		{
			var bone = r.GetBoneObject( i );
			if ( bone is null ) continue;
			Log.Info( $"[nz]   bone{i} '{bone.Name}' local {bone.LocalRotation.Angles()}"
				+ $"  world {bone.WorldRotation.Angles()}" );
		}
	}

	/// <summary>
	/// Is the held weapon's shoot sound actually resolving? nz_wep_sound
	///
	/// ⛔ SEPARATES THE TWO CAUSES THAT LOOK IDENTICAL. Everything on disk checks
	/// out — valid PCM, compiled .vsnd_c, a .sound asset whose reference format
	/// matches the working Colt template — so the failure is either the field
	/// deserialising to NULL, or SWB never reaching the call. Silence looks the
	/// same either way, and `PlaySound` early-returns on a null SoundEvent
	/// without logging.
	/// </summary>
	[ConCmd( "nz_wep_sound" )]
	public static void WeaponSound()
	{
		var player = NZPlayer.Local;
		var wep = player?.Components.GetInChildren<SWB.Base.Weapon>( true );
		if ( !wep.IsValid() ) { Log.Warning( "[nz] no SWB weapon held" ); return; }

		var si = wep.Primary;
		Log.Info( $"[nz] {wep.DisplayName}" );
		Log.Info( $"[nz]   Primary        : {(si is null ? "NULL — the prefab graph is broken" : "ok")}" );
		Log.Info( $"[nz]   ShootSound     : {si?.ShootSound?.ResourceName ?? "NULL"}"
			+ (si?.ShootSoundCue?.IsSet == true ? $"  (built from {si.ShootSoundCue.Event})" : "") );
		Log.Info( $"[nz]   DryShootSound  : {si?.DryShootSound?.ResourceName ?? "NULL"}" );
		Log.Info( $"[nz]   DeploySound    : {wep.DeploySound?.ResourceName ?? "NULL"}" );

		// ⚠️ THE CUE AS THE GUN PLAYS IT: built from its recordings when it has them (GunCue), else the event.
		var shot = SWB.Base.GunSounds.Resolve( si?.ShootSound, si?.ShootSoundCue );
		if ( shot is not null )
		{
			// ⛔ THREE WAYS, TO SPLIT THREE CAUSES. The direct call was audible
			// while the weapon was silent, so the asset is fine and the fault is
			// downstream — but "downstream" is still two things: SWB's PlaySound
			// (positioning, parenting, FollowParent) or Shoot() never reaching it.
			// Calling PlaySound directly separates them.
			Log.Info( "[nz]   1) Sound.Play at the player" );
			Sound.Play( shot, player.WorldPosition );

			Log.Info( $"[nz]   2) weapon.PlaySound (SWB's own path)"
				+ $"  weapon at {wep.WorldPosition}, CanSeeViewModel={wep.CanSeeViewModel}" );
			wep.PlayCue( si.ShootSound, si.ShootSoundCue );

			Log.Info( "[nz]   3) Sound.Play at the weapon's own position" );
			Sound.Play( shot, wep.WorldPosition );

			Log.Info( "[nz]   -> which of the three did you hear?" );
		}
	}

	/// <summary>Draw path/target/state over every zombie.</summary>
	[ConVar( "nz_zdebug" )]
	public static bool Debug { get; set; } = false;

	/// <summary>
	/// Turn rate in degrees/sec, applied to every zombie alive: nz_zombie_turn 210.
	///
	/// Live-tunable because it is game feel, and because the last value (720) was
	/// picked against maths that was later fixed and never re-judged. Call with
	/// no argument to read the current one.
	/// </summary>
	[ConCmd( "nz_zombie_turn" )]
	public static void TurnRate( float degreesPerSecond = -1f )
	{
		var all = Game.ActiveScene?.GetAllComponents<ZombieAI>().ToList()
			?? new List<ZombieAI>();

		if ( degreesPerSecond <= 0f )
		{
			var now = all.FirstOrDefault();
			Log.Info( $"[nz] zombie turn rate: {(now.IsValid() ? now.MaxYawRate : 210f):0}"
				+ $" deg/s   ({all.Count} alive)   180deg turn takes "
				+ $"{180f / MathF.Max( 1f, now.IsValid() ? now.MaxYawRate : 210f ):0.00}s" );
			return;
		}

		foreach ( var z in all )
			z.MaxYawRate = degreesPerSecond;

		Log.Info( $"[nz] zombie turn rate -> {degreesPerSecond:0} deg/s on {all.Count}"
			+ $"   (180deg turn takes {180f / degreesPerSecond:0.00}s)" );
	}

	/// <summary>
	/// How long the desired facing takes to catch up: nz_zombie_turn_smooth 0.2.
	///
	/// ⚠️ NOT the same knob as nz_zombie_turn. The rate limits how fast the body
	/// comes about; this limits how abruptly the direction it is aiming at can
	/// move, which is what a once-a-second repath does to it. 0 disables.
	/// </summary>
	[ConCmd( "nz_zombie_turn_smooth" )]
	public static void TurnSmoothing( float seconds = -1f )
	{
		var all = Game.ActiveScene?.GetAllComponents<ZombieAI>().ToList()
			?? new List<ZombieAI>();

		if ( seconds < 0f )
		{
			var now = all.FirstOrDefault();
			Log.Info( $"[nz] facing smoothing: "
				+ $"{(now.IsValid() ? now.FaceSmoothing : 0.2f):0.00}s   ({all.Count} alive)" );
			return;
		}

		foreach ( var z in all )
			z.FaceSmoothing = seconds;

		Log.Info( $"[nz] facing smoothing -> {seconds:0.00}s on {all.Count}" );
	}

	/// <summary>
	/// Backstop before physics takes a dying body: nz_zombie_ragdoll_after 4.
	///
	/// Doubles as the A/B for the "falls backwards" fix. Deaths now play their
	/// clip out and ragdoll from a body already committed face-down; setting
	/// this LOW (0.25) restores the old behaviour of dropping to physics while
	/// the zombie is still upright, which is what made it crumple backwards.
	/// </summary>
	[ConCmd( "nz_zombie_ragdoll_after" )]
	public static void RagdollAfter( float seconds = -1f )
	{
		var all = Game.ActiveScene?.GetAllComponents<ZombieAI>().ToList()
			?? new List<ZombieAI>();

		if ( seconds < 0f )
		{
			var now = all.FirstOrDefault();
			Log.Info( $"[nz] ragdoll backstop: "
				+ $"{(now.IsValid() ? now.RagdollAfter : 4f):0.00}s   ({all.Count} alive)"
				+ "   — deaths normally hand over when the clip ENDS" );
			return;
		}

		foreach ( var z in all )
			z.RagdollAfter = seconds;

		Log.Info( $"[nz] ragdoll backstop -> {seconds:0.00}s on {all.Count}"
			+ (seconds < 1f ? "   ⚠ low enough to cut the death clip short" : "") );
	}

	/// <summary>
	/// Fire a dirt burst where you are looking: nz_dirt [count] [power].
	///
	/// Iterating on this by spawning zombies is slow — you get one burst per
	/// wave, at whatever spot the config picked, and it is over in a second. This
	/// puts one at your feet on demand so the look can actually be tuned.
	/// </summary>
	[ConCmd( "nz_dirt" )]
	public static void Dirt( int count = 14, float power = 1f )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		var player = scene.GetAllComponents<PlayerController>().FirstOrDefault();
		if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		// Aim down at the floor rather than using the player's origin, so the
		// burst can be placed and looked at from a distance.
		var cam = scene.Camera;
		var from = cam.IsValid() ? cam.WorldPosition : player.WorldPosition;
		var dir = cam.IsValid() ? cam.WorldRotation.Forward : player.WorldRotation.Forward;

		var tr = scene.Trace.Ray( from, from + dir * 400f )
			.IgnoreGameObjectHierarchy( player.GameObject )
			.Run();

		var at = tr.Hit ? tr.HitPosition : player.WorldPosition;
		SpawnDirt.Burst( scene, at, count, power );

		Log.Info( $"[nz] dirt at {at}   trace hit: {tr.Hit}   "
			+ $"distance from you: {Vector3.DistanceBetween( player.WorldPosition, at ):0}u" );
	}

	/// <summary>
	/// Switch between the engine's dust prefab and the cube fallback:
	/// nz_dirt_mode [dust|cubes]. Mostly so the two can be compared directly.
	/// </summary>
	[ConCmd( "nz_dirt_mode" )]
	public static void DirtMode( string mode = "" )
	{
		if ( !string.IsNullOrWhiteSpace( mode ) )
			SpawnDirt.UseDustPrefab = mode.ToLowerInvariant().StartsWith( "d" );

		Log.Info( $"[nz] dirt mode: {(SpawnDirt.UseDustPrefab ? "DUST" : "CUBES")}"
			+ $"   resolved path: {SpawnDirt.ResolvedDustPrefab ?? "(none yet — run nz_dirt once)"}" );
	}

	/// <summary>
	/// Clod size multiplier: nz_dirt_size 2.
	///
	/// ⚠️ The first version's clods were 0.9–2.8 units against a 72-unit zombie
	/// and read as nothing at all — which looks exactly like the code not
	/// running. Tune by eye rather than guessing again.
	/// </summary>
	/// <summary>
	/// How much dust per burst: nz_dirt_dust [density].
	///
	/// ⚠️ THE DUST PREFAB CANNOT BE SCALED, so this is the only knob that changes
	/// how much of it there is. The effect is authored for a single bullet and its
	/// size lives in curves inside the effect — GameObject scale does not drive
	/// particle size — so volume comes from count and nothing else.
	///
	/// 0 turns the dust off entirely and leaves the clods, which is also the way
	/// to see which half of the effect you are looking at.
	/// </summary>
	/// <summary>
	/// Re-check which dust prefab resolves: nz_dirt_reload.
	///
	/// ⚠️ THE CACHE THIS EXISTED TO CLEAR IS GONE. CloneDust no longer caches
	/// anything — it tries the paths in order every burst — so nothing can pin the
	/// wrong asset any more. `ResolvedDustPrefab` is now only a REPORT of what last
	/// worked, and this clears it so the next burst names its choice out loud.
	/// </summary>
	[ConCmd( "nz_dirt_reload" )]
	public static void DirtReload()
	{
		SpawnDirt.ForgetResolvedPrefab();
		Log.Info( "[nz] forgotten — the next burst will name the prefab it uses" );
	}

	/// <summary>
	/// Throw the cube clods or not: nz_dirt_clods [0/1].
	///
	/// ⚠️ Off judges the dust on its own — which is the only way to tell whether
	/// the cloud is rendering, since the clods are big enough to be the only thing
	/// you notice. They were the fallback for a missing asset, not the effect.
	/// </summary>
	[ConCmd( "nz_dirt_clods" )]
	public static void DirtClods( int on = -1 )
	{
		SpawnDirt.UseClods = on < 0 ? !SpawnDirt.UseClods : on != 0;

		Log.Info( SpawnDirt.UseClods
			? "[nz] clods ON (cubes) + dust"
			: "[nz] clods off — dust only, so what you see is the real effect" );
	}

	/// <summary>Cap on puffs per burst: nz_dirt_max [n].</summary>
	[ConCmd( "nz_dirt_max" )]
	public static void DirtMax( int max = -1 )
	{
		if ( max < 1 )
		{
			Log.Info( $"[nz] max puffs {SpawnDirt.MaxPuffs}" );
			return;
		}

		SpawnDirt.MaxPuffs = max;
		Log.Info( $"[nz] max puffs {max}   (each is a full cloud — this is what a "
			+ "35-zombie wave pays for)" );
	}

	[ConCmd( "nz_dirt_dust" )]
	public static void DirtDust( float density = -1f )
	{
		if ( density < 0f )
		{
			Log.Info( $"[nz] dust density {SpawnDirt.DustDensity:0.00}, "
				+ $"dust {(SpawnDirt.UseDustPrefab ? "ON" : "off")}, "
				+ $"cap {SpawnDirt.MaxPuffs}"
				+ $"   prefab: {SpawnDirt.ResolvedDustPrefab ?? "(not resolved yet)"}" );
			return;
		}

		SpawnDirt.UseDustPrefab = density > 0f;
		if ( density > 0f ) SpawnDirt.DustDensity = density;

		Log.Info( SpawnDirt.UseDustPrefab
			? $"[nz] dust density {SpawnDirt.DustDensity:0.00}"
			: "[nz] dust OFF — clods only" );
	}

	[ConCmd( "nz_dirt_size" )]
	public static void DirtSize( float scale = -1f )
	{
		if ( scale <= 0f )
		{
			Log.Info( $"[nz] dirt size scale: {SpawnDirt.SizeScale:0.00}"
				+ "   (clods are 3.5-9u before scaling; a zombie is ~72u tall)" );
			return;
		}

		SpawnDirt.SizeScale = scale;
		Log.Info( $"[nz] dirt size scale -> {scale:0.00}" );
	}

	/// <summary>
	/// Which entrance clips are treated as coming out of the ground:
	/// nz_dirt_clips. The classification is by NAME, so it is worth being able
	/// to read the verdict rather than trusting it.
	/// </summary>
	[ConCmd( "nz_dirt_clips" )]
	public static void DirtClips()
	{
		var all = WalkerAnimations.Spawn;
		if ( all is null || all.Count == 0 ) { Log.Warning( "[nz] no spawn clips" ); return; }

		int ground = 0;
		foreach ( var c in all )
		{
			bool g = ZombieAI.ClipIsGroundEntrance( c );
			if ( g ) ground++;
			Log.Info( $"[nz]   {(g ? "DIRT " : "     ")} {c}" );
		}

		Log.Info( $"[nz] {ground}/{all.Count} entrance clips classed as ground risers" );
	}

	/// <summary>
	/// Trace the NEXT entrance frame by frame, through the handover: nz_spawn_watch.
	///
	/// ⛔ WATCHES THE HANDOVER, WHICH IS THE BIT NOTHING ELSE COVERS.
	/// `nz_spawn_debug` logs from inside TickSpawn, and TickSpawn stops being
	/// called the moment the entrance ends — so the exact frame the zombie drops
	/// back underground has never been in any log. This keeps printing for
	/// `seconds` after the transition.
	///
	/// Per line: object Z and the change since last frame, the floor and anchor
	/// heights to compare against, the sequence and its RAW time, our monotonic
	/// progress, the wrap flag, playback rate, state, and whether a nav agent
	/// exists yet. Whatever moves that zombie will show up as a dz on one line
	/// with everything else that was true at the time.
	///
	/// Arms ONE entrance and disarms — a wave of 24 risers at 60fps is unreadable.
	///
	/// ⚠️ NAMED nz_handover, NOT nz_spawn_watch. That name was already taken by
	/// the aimlock/verify trace, and s&box silently kept the FIRST registration —
	/// so this command existed, compiled, and did nothing, while the old one
	/// answered and looked like it. The old trace also stops at completion, which
	/// is the exact moment in question, so the output looked like a clean PASS
	/// and hid that the new command had never run at all.
	/// </summary>
	[ConCmd( "nz_handover" )]
	public static void SpawnHandover( float seconds = 1.5f )
	{
		// ⚠️ nz_handover 0 CANCELS. Without this the watch could be armed but
		// never called off, and `nz_spawn_debug 0` does not touch it — the watch
		// runs off its own subject, so the command everyone reaches for to stop
		// the spawn logging leaves this one printing a line per frame.
		if ( seconds <= 0f )
		{
			ZombieAI.DisarmWatch();
			Log.Info( "[nz-watch] disarmed — nothing armed, "
				+ "and any trace in progress has been stopped." );
			return;
		}

		ZombieAI.WatchAfter = seconds;
		ZombieAI.ArmWatch();

		Log.Info( $"[nz-watch] armed — tracing the next entrance and keeping it "
			+ $"under watch for {seconds:0.0}s AFTER the handover. nz_spawn to fire one."
			+ "  (nz_handover 0 to cancel)" );
	}

	/// <summary>
	/// Log the entrance EVERY FRAME instead of 10x a second: nz_spawn_hz [0/1].
	///
	/// ⚠️ The per-tick entrance log is throttled to 10Hz so a wave of 24 risers
	/// cannot bury the console. At 120fps that is **one line per twelve frames** —
	/// more than enough to miss a drop that lasts two or three. Turn this on when
	/// chasing something that might be a single-frame event; leave it off
	/// otherwise.
	/// </summary>
	[ConCmd( "nz_spawn_hz" )]
	public static void SpawnLogEveryFrame( int on = -1 )
	{
		ZombieAI.DebugSpawnEveryFrame = on < 0 ? !ZombieAI.DebugSpawnEveryFrame : on != 0;

		Log.Info( ZombieAI.DebugSpawnEveryFrame
			? "[nz] entrance log: EVERY FRAME (spawn one zombie at a time)"
			: "[nz] entrance log: 10Hz (throttled)" );
	}

	/// <summary>
	/// What every zombie's renderer is actually doing: nz_pose.
	///
	/// ⛔ THE COMMAND FOR "IT STANDS UP AFTER THE ANIMATION". That symptom has
	/// three different causes that are indistinguishable on screen, and the fix
	/// for each is somewhere else entirely — the playback clock, the animation
	/// state machine, or the AI. This says which.
	/// </summary>
	[ConCmd( "nz_pose" )]
	public static void Pose()
	{
		var all = Game.ActiveScene?.GetAllComponents<ZombieAI>().ToList()
			?? new List<ZombieAI>();

		if ( all.Count == 0 ) { Log.Info( "[nz-pose] no zombies" ); return; }

		foreach ( var z in all )
			Log.Info( $"[nz-pose] {z.PoseDebug}" );

		int dead = all.Count( z => z.State == ZombieState.Dead );
		Log.Info( $"[nz-pose] {all.Count} zombies, {dead} dead" );
	}

	/// <summary>
	/// Ragdoll on death, on or off: nz_ragdoll_on_death [0/1].
	///
	/// ⚠️ OFF by default since 2026-08-15 — corpses hold the last frame of their
	/// death animation until they despawn. The clips land the body properly now,
	/// so the ragdoll stopped earning its place. Everything behind it still
	/// works (17 bodies / 16 joints in the .vmdl), so this is a real switch, not
	/// a stub — flip it to compare.
	/// </summary>
	[ConCmd( "nz_ragdoll_on_death" )]
	public static void RagdollOnDeath( int on = -1 )
	{
		var all = Game.ActiveScene?.GetAllComponents<ZombieAI>().ToList()
			?? new List<ZombieAI>();

		if ( on < 0 )
		{
			var now = all.FirstOrDefault();
			Log.Info( $"[nz] ragdoll on death: {(now.IsValid() && now.RagdollOnDeath ? "ON" : "OFF")}"
				+ $"   ({all.Count} alive)   OFF = hold the last death frame" );
			return;
		}

		foreach ( var z in all )
			z.RagdollOnDeath = on != 0;

		Log.Info( $"[nz] ragdoll on death -> {(on != 0 ? "ON" : "OFF")} on {all.Count}"
			+ "   ⚠ affects zombies that die from now on, not existing corpses" );
	}

	/// <summary>
	/// Minimum zombie speed, applied to every zombie alive: nz_zombie_minspeed 55.
	/// 0 restores the pure authored per-clip speed.
	/// </summary>
	[ConCmd( "nz_zombie_minspeed" )]
	public static void MinSpeed( float speed = -1f )
	{
		var all = Game.ActiveScene?.GetAllComponents<ZombieAI>().ToList()
			?? new List<ZombieAI>();

		if ( speed < 0f )
		{
			var now = all.FirstOrDefault();
			Log.Info( $"[nz] minimum speed: {(now.IsValid() ? now.MinMoveSpeed : 55f):0}"
				+ $" u/s   ({all.Count} alive)   authored walks run 35-60" );
			return;
		}

		foreach ( var z in all )
			z.MinMoveSpeed = speed;

		Log.Info( $"[nz] minimum speed -> {speed:0} u/s on {all.Count}"
			+ "   ⚠ takes effect on each zombie's next clip change" );
	}

	/// <summary>
	/// Why a zombie is slow: nz_zspeed.
	///
	/// ⛔ "MOVING AT ABOUT 1" HAS TWO COMPLETELY DIFFERENT CAUSES and they need
	/// opposite fixes, so guessing is worse than useless:
	///
	///   MoveSpeed low, vel matches it   -> it is genuinely a SLOW clip. A
	///                                      minimum-speed floor fixes it.
	///   MoveSpeed fine, vel ~0          -> it is STUCK. The agent is being told
	///                                      to move and is not. A speed floor
	///                                      does nothing; the fault is pathing,
	///                                      navmesh or crowding.
	///
	/// Prints both per zombie, plus the gap between them, and summarises which
	/// case dominates.
	/// </summary>
	/// <summary>
	/// Tune the per-round speed bonus live: `nz_zspeed_bonus 3`.
	///
	/// ⚠️ Takes effect on the next clip change, because MoveSpeed is recomputed in
	/// ApplyGroundSpeed — so it lands within a step or two of walking rather than
	/// instantly. That is worth knowing before concluding it did nothing.
	///
	/// ⚠️ Prints the resulting speed against the PLAYER'S WALK SPEED, which is the
	/// only comparison that answers "can I be caught". A zombie speed on its own
	/// says nothing.
	/// </summary>
	[ConCmd( "nz_zspeed_bonus" )]
	public static void ZombieSpeedBonus( float perRound = -1f )
	{
		if ( perRound >= 0f ) ZombieAI.RoundSpeedBonus = perRound;

		int round = RoundManager.Instance?.Round ?? 1;
		float walk = ActiveConfig.Player.WalkSpeed;

		Log.Info( $"[nz] round speed bonus: {ZombieAI.RoundSpeedBonus:0.#} u/s per round" );
		Log.Info( $"[nz] round {round}: floor {70:0} + {MathF.Max( 0, round - 1 ) * ZombieAI.RoundSpeedBonus:0} "
			+ $"= {70 + MathF.Max( 0, round - 1 ) * ZombieAI.RoundSpeedBonus:0} u/s "
			+ $"(before the clip's 2x cap)  vs player walk {walk:0}" );

		for ( int r = 1; r <= 25; r += 4 )
		{
			float s = 70 + MathF.Max( 0, r - 1 ) * ZombieAI.RoundSpeedBonus;
			Log.Info( $"[nz]   round {r,-3} {s,4:0} u/s  {(s >= walk ? "CATCHES a walking player" : "outrunnable at a walk")}" );
		}
	}

	/// <summary>Per-frame zombie trace. Off by default — it is 60 lines/sec.</summary>
	public static bool Trace;

	/// <summary>
	/// Which zombie the trace follows: the one nearest the player.
	///
	/// ⚠️ Recomputed every frame rather than latched. A latched subject that dies
	/// mid-vault takes the trace with it, and the interesting zombie is always
	/// the one in front of you.
	/// </summary>
	public static ZombieAI TraceSubject()
	{
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) return null;

		return ZombieAI.All
			.Where( z => z.IsValid() && z.State != ZombieState.Dead )
			.OrderBy( z => z.WorldPosition.DistanceSquared( player.WorldPosition ) )
			.FirstOrDefault();
	}

	/// <summary>
	/// `nz_ztrace [0/1]` — dump the nearest zombie's position, facing, velocity,
	/// current clip and vault progress EVERY FRAME.
	///
	/// Reads the three things that disagree when a scripted move looks wrong:
	/// where it is, where it points, and where it is travelling. A position that
	/// steps in bursts with idle frames between them is a THINK-RATE problem
	/// (ThinkRate is 0.1s = 10/sec against 60+ fps); a yaw that swings while the
	/// position moves cleanly is a FACING problem.
	/// </summary>
	[ConCmd( "nz_ztrace" )]
	public static void ZombieTrace( int on = -1 )
	{
		Trace = on < 0 ? !Trace : on != 0;

		Log.Info( Trace
			? "[nz] ztrace ON — nearest zombie, every frame. nz_ztrace 0 to stop."
			: "[nz] ztrace off" );
	}

	/// <summary>
	/// Tune the swing live: `nz_zattack [reachMultiplier] [damagePoint]`.
	///
	/// Bare command reports the current numbers in UNITS and SECONDS rather than
	/// as the raw multipliers — "reaches 105u, lands 0.36s in" is the sentence you
	/// can check against what just happened on screen; "2x and 0.3" is not.
	///
	/// ⚠️ Applies to EVERY live zombie, not the prefab. Values reset on respawn
	/// unless the property is changed in the scene too.
	/// </summary>
	[ConCmd( "nz_zattack" )]
	public static void ZombieAttack( float reach = -1f, float point = -1f, float speed = -1f )
	{
		var all = Game.ActiveScene?.GetAllComponents<ZombieAI>().ToList()
			?? new List<ZombieAI>();

		foreach ( var z in all )
		{
			if ( reach >= 1f ) z.AttackReachMultiplier = reach;
			if ( point > 0f ) z.AttackDamagePoint = point;
			if ( speed > 0f ) z.AttackSpeed = speed;
		}

		var first = all.FirstOrDefault();
		if ( !first.IsValid() ) { Log.Info( "[nz] no zombies alive to report on" ); return; }

		// ⚠️ A typical attack clip is ~1.2s; the exact length varies per clip, so
		// this is the shape of the timing rather than a promise about one swing.
		//
		// ⛔ REPORTS THE SCALED VALUES, NOT THE AUTHORED ONES. The authored fields are the
		// ROUND-1 numbers now; on round 40 this printing `speed x1.8` while the game ran the
		// swing at x2.1 would be a report that quietly disagrees with the thing it is reporting
		// on — which is the failure mode this whole command exists to avoid. Both are shown.
		const float TypicalClip = 1.2f;
		int round = Math.Max( 1, RoundManager.Instance?.Round ?? 1 );
		float eSpeed = first.ScaledAttackSpeed;
		float eReach = first.ScaledAttackReach;
		float ePoint = first.ScaledAttackDamagePoint;
		float swing = TypicalClip / MathF.Max( 0.05f, eSpeed );

		Log.Info( $"[nz] attack @ round {round}: trigger at ~52u, reach x{eReach:0.##}, "
			+ $"damage at {ePoint * 100:0}% of the swing, speed x{eSpeed:0.##}" );
		Log.Info( $"[nz]   -> connects out to ~{52.5f * eReach:0}u, "
			+ $"swing lasts ~{swing:0.00}s, hit lands ~{swing * ePoint:0.00}s in" );
		Log.Info( $"[nz]   -> that is your reaction window: ~{swing * ePoint:0.00}s "
			+ "from hearing the swing to taking the damage" );
		Log.Info( $"[nz]   authored (round 1): reach x{first.AttackReachMultiplier:0.##}, "
			+ $"point {first.AttackDamagePoint:0.###}, speed x{first.AttackSpeed:0.##}"
			+ $"  —  ramp {ZombieStats.AttackRampT( round ) * 100:0}% of the way to round "
			+ $"{ActiveConfig.Zombies.AttackScaleEndRound}" );
		Log.Info( $"[nz]   applied to {all.Count} live zombie(s)" );
	}

	/// <summary>
	/// Print the whole attack-pressure curve: `nz_zattack_curve [step]`.
	///
	/// ⚠️ THE LAST COLUMN IS THE ONE THAT MATTERS. Reach and swing speed change how often a
	/// zombie CONNECTS; the immunity window is what decides how much of that the player actually
	/// takes, because it swallows every hit the whole horde lands inside it.
	/// </summary>
	[ConCmd( "nz_zattack_curve" )]
	public static void ZombieAttackCurve( int step = 5 )
	{
		if ( step < 1 ) step = 1;
		var s = ActiveConfig.Zombies;
		int end = Math.Max( 2, s.AttackScaleEndRound );

		// Base values come from a live zombie when there is one, so the table describes THIS
		// map's zombies rather than the class defaults — a variant with its own AttackSpeed
		// rides the same curve from a different starting point.
		var first = Game.ActiveScene?.GetAllComponents<ZombieAI>().FirstOrDefault();
		float bSpeed = first.IsValid() ? first.AttackSpeed : 1.8f;
		float bReach = first.IsValid() ? first.AttackReachMultiplier : 2f;
		float bPoint = first.IsValid() ? first.AttackDamagePoint : 0.1f;
		// ⚠️ THE CALLER'S OWN WINDOW, not "a player's". VictimImmunity is a field on NZPlayer
		// rather than a config setting, so there is no map-wide number to read — and in a
		// four-player game the only one this console line can honestly describe is the console's.
		var me = NZPlayer.Local;
		float bImm = me.IsValid() ? me.VictimImmunity : 0.5f;

		Log.Info( $"[nz] attack pressure — linear from round 1 to {end}, flat after" );
		Log.Info( "[nz]  round |  swing  |   reach  |  hit at |  immunity | horde DPS cap" );

		var rounds = new List<int>();
		for ( int r = 1; r <= end; r += step ) rounds.Add( r );
		if ( rounds[^1] != end ) rounds.Add( end );
		rounds.Add( end + 10 );

		foreach ( int r in rounds )
		{
			float sp = bSpeed * ZombieStats.AttackSpeedScale( r );
			float rc = bReach * ZombieStats.AttackReachScale( r );
			float pt = Math.Clamp( bPoint * ZombieStats.AttackDamagePointScale( r ), 0.05f, 1f );
			float im = bImm * ZombieStats.VictimImmunityScale( r );
			float swing = 1.2f / MathF.Max( 0.05f, sp );
			float dps = ZombieStats.AttackDamageForRound( r ) / MathF.Max( 0.01f, im );

			Log.Info( $"[nz]  {r,5} | {swing,5:0.00}s | {52.5f * rc,6:0}u | {swing * pt,5:0.00}s"
				+ $" | {im,7:0.00}s | {dps,6:0}/s" );
		}

		Log.Info( "[nz]  swing = how long one attack takes · hit at = your reaction window" );
		Log.Info( "[nz]  horde DPS cap = the most the WHOLE horde can land, immunity-limited" );
	}

	/// <summary>
	/// Retune the curve live: `nz_zattack_scale [speed] [reach] [point] [immunity] [endRound]`.
	///
	/// Every argument is the multiplier AT THE END ROUND — 1 switches that knob off. Bare
	/// command reports without changing anything.
	///
	/// ⚠️ RE-WINDOWS EVERY PLAYER IMMEDIATELY. The immunity value is pushed on a round change,
	/// so without this a tuning pass would appear to do nothing until the next round.
	/// </summary>
	[ConCmd( "nz_zattack_scale" )]
	public static void ZombieAttackScale( float speed = -1f, float reach = -1f,
		float point = -1f, float immunity = -1f, int endRound = -1 )
	{
		var s = ActiveConfig.Zombies;
		if ( speed > 0f ) s.AttackSpeedEndScale = speed;
		if ( reach > 0f ) s.AttackReachEndScale = reach;
		if ( point > 0f ) s.AttackDamagePointEndScale = point;
		if ( immunity > 0f ) s.VictimImmunityEndScale = immunity;
		if ( endRound > 1 ) s.AttackScaleEndRound = endRound;

		NZPlayer.OnRoundStart();

		Log.Info( $"[nz] attack scale @ round {s.AttackScaleEndRound}: speed x{s.AttackSpeedEndScale:0.###}, "
			+ $"reach x{s.AttackReachEndScale:0.###}, point x{s.AttackDamagePointEndScale:0.###}, "
			+ $"immunity x{s.VictimImmunityEndScale:0.###}" );
		Log.Info( "[nz]   nz_zattack_curve to see what that does per round" );
	}

	/// <summary>
	/// `nz_zdamage [endScale] [endRound]` — zombie damage's rise past round 31 (2026-10-03): from 90 at round 31 to
	/// `endScale` times that by `endRound`, then held. No arguments prints it. `nz_zdamage 1` switches the rise off.
	/// </summary>
	[ConCmd( "nz_zdamage" )]
	public static void ZombieDamageRamp( float endScale = -1f, int endRound = -1 )
	{
		var s = ActiveConfig.Zombies;
		if ( endScale > 0f ) s.DamageRampEndScale = endScale;
		if ( endRound > 31 ) s.DamageRampEndRound = endRound;

		Log.Info( $"[nz] zombie damage: 30 / 50 / 75 to round 30, then 90 at round 31 rising to"
			+ $" {90f * s.DamageRampEndScale:0} by round {s.DamageRampEndRound}" );
		foreach ( var r in new[] { 31, 40, 50, 60, 70 } )
			Log.Info( $"[nz]   round {r}: {ZombieStats.AttackDamageForRound( r )} a hit" );
	}

	[ConCmd( "nz_zspeed" )]
	public static void ZombieSpeeds()
	{
		var all = Game.ActiveScene?.GetAllComponents<ZombieAI>().ToList()
			?? new List<ZombieAI>();

		if ( all.Count == 0 ) { Log.Info( "[nz-speed] no zombies alive" ); return; }

		int slowClip = 0, stuck = 0, fine = 0;

		Log.Info( "[nz-speed] state        told  actual   ratio  verdict" );

		foreach ( var z in all )
		{
			float told = z.MoveSpeed;
			float actual = z.Velocity.WithZ( 0 ).Length;
			float ratio = told > 1f ? actual / told : 0f;

			string verdict;
			if ( z.State != ZombieState.Chasing )
				verdict = "(not chasing — expected to be still)";
			else if ( told < 1f )
			{ verdict = "⛔ NO SPEED SET — clip had no ground speed"; slowClip++; }
			else if ( ratio < 0.25f )
			{ verdict = "⛔ STUCK — told to move, is not moving"; stuck++; }
			else if ( told < 40f )
			{ verdict = "⚠ genuinely slow clip"; slowClip++; }
			else { verdict = "ok"; fine++; }

			Log.Info( $"[nz-speed] {z.State,-10} {told,6:0} {actual,7:0} {ratio,6:0.00}  {verdict}" );
		}

		Log.Info( $"[nz-speed] {fine} ok · {slowClip} slow clip · {stuck} STUCK  (of {all.Count})" );

		if ( stuck > 0 )
			Log.Warning( "[nz-speed] ⛔ stuck zombies are a PATHING problem — a minimum "
				+ "speed would not move them. Check navmesh under them (nz_nav_rebuild) "
				+ "and whether they are crowded against something." );
	}

	/// <summary>
	/// Move the concurrent zombie cap live: `nz_zcap [n]`. Bare command reports.
	///
	/// ⚠️ THE CAP IS A POPULATION TARGET, NOT A RATE LIMIT, and that is what makes this worth
	/// sweeping rather than guessing. While the horde is at the cap the spawn timer sits expired,
	/// so a kill is refilled on the next tick — raising the number raises the crowd standing on
	/// you, immediately, without waiting for the next round.
	///
	/// ⛔ RAISING IT DOES NOT RETROACTIVELY FILL THE MAP. The spawner tops up as zombies die or
	/// as its delay elapses, so the count climbs to the new number over the next few seconds
	/// rather than popping. Lowering it does not delete anyone either — the surplus simply is not
	/// replaced. Neither is a bug, and both read like one for about five seconds.
	/// </summary>
	[ConCmd( "nz_zcap" )]
	public static void ZombieCap( int n = -1 )
	{
		var s = ActiveConfig.Zombies;
		if ( n > 0 ) s.MaxAlive = n;

		int round = RoundManager.Instance?.Round ?? 1;
		int alive = ZombieAI.All.Count;
		Log.Info( $"[nz] concurrent cap {s.MaxAlive} (default {ZombieStats.MaxAlive}) — "
			+ $"{alive} alive right now, round {round} wants "
			+ $"{ZombieStats.MaxAliveForRound( round )}" );
		Log.Info( $"[nz]   wave total this round is {RoundManager.Instance?.WaveTotal ?? 0}, "
			+ $"so the round is {(s.MaxAlive > 0 ? (RoundManager.Instance?.WaveTotal ?? 0) / (float)s.MaxAlive : 0f):0.0}"
			+ " batches of the cap" );
		Log.Info( "[nz]   nz_horde " + s.MaxAlive + " to stand one up on demand for an FPS read" );
	}

	/// <summary>The clip list behind a tier name, so the two commands below agree with the game.</summary>
	private static List<string> TierClips( string tier ) => tier switch
	{
		"Run" => WalkerAnimations.Run,
		"Sprint" => WalkerAnimations.Sprint,
		"SuperSprint" => WalkerAnimations.SuperSprint,
		_ => WalkerAnimations.Walk,
	};

	private static readonly string[] TierNames = { "Walk", "Run", "Sprint", "SuperSprint" };

	/// <summary>
	/// What a tier is worth, and what the horde looks like round by round: `nz_zspeed_curve [step]`.
	///
	/// ⚠️ ENUMERATES THE REAL DISTRIBUTION rather than sampling it. A zombie's speed is
	/// `clip x tier scale`, floored, where the clip is one random draw from its tier and the tier
	/// comes from `curve + jitter(0..35)` — all finite, so every one of the 36 jitter values is
	/// walked against every clip in whichever tier it lands in. The min/mean/max are exact.
	/// </summary>
	[ConCmd( "nz_zspeed_curve" )]
	public static void ZombieSpeedCurve( int step = 5 )
	{
		if ( step < 1 ) step = 1;
		var s = ActiveConfig.Zombies;
		float floorSpeed = 55f;
		var live = Game.ActiveScene?.GetAllComponents<ZombieAI>().FirstOrDefault();
		if ( live.IsValid() ) floorSpeed = live.MinMoveSpeed;

		Log.Info( "[nz-speed] tier            clips   raw u/s      scale    ->  travelled u/s" );
		foreach ( var t in TierNames )
		{
			var clips = TierClips( t );
			var raw = clips.Select( c => WalkerGroundSpeeds.For( c, t ) ).ToList();
			float sc = ZombieStats.TierSpeedScale( t );
			var outv = raw.Select( r => MathF.Max( r * sc, floorSpeed ) ).ToList();

			// ⚠️ THE HEADROOM COLUMN IS THE ONE THAT STOPS A BAD RETUNE. Past MaxAnimRate the legs
			// cannot cycle any faster and the zombie skates; the binding clip is the SLOWEST in
			// the tier, since a uniform scale pushes that one over the line first.
			float worstRate = outv.Zip( raw, ( o, r ) => r > 1f ? o / r : 1f ).Max();
			string warn = worstRate > s.MaxAnimRate + 0.001f
				? $"  ⛔ SKATING — needs anim rate {worstRate:0.00}, clamp is {s.MaxAnimRate:0.00}"
				: $"  (headroom x{s.MaxAnimRate / MathF.Max( 0.01f, worstRate ):0.00})";

			Log.Info( $"[nz-speed] {t,-13} {clips.Count,5}  {raw.Min(),4:0}-{raw.Max(),-4:0}"
				+ $"  x{sc,5:0.##}   ->  {outv.Min(),4:0}-{outv.Max(),-4:0}{warn}" );
		}

		// The player's OWN current numbers, not the config defaults — perks and augments multiply
		// into sprint, and comparing a round-40 zombie against an unperked 340 flatters it badly.
		var me = NZPlayer.Local;
		float pWalk = ActiveConfig.Player.WalkSpeed, pSprint = ActiveConfig.Player.SprintSpeed;
		if ( me.IsValid() )
		{
			float m = PerkEffects.SpeedMultiplier( me );
			pWalk *= m;
			pSprint *= m * StaminUpAugments.SprintMultiplier( me );
		}
		Log.Info( $"[nz-speed] you right now: walk {pWalk:0}, sprint {pSprint:0}" );

		Log.Info( "[nz-speed]  round | Walk  Run  Sprt SupS |  min  mean   max | vs your sprint" );
		// ⚠️ ONE STEP PAST THE ROUND THE WHOLE HORDE TOPS OUT (`SpeedCapRound`, 60), so the table shows it arrive
		int end = Math.Max( 60, s.SpeedCapRound ) + step;
		for ( int r = 1; r <= end; r += step )
		{
			int curve = ZombieStats.SpeedForRound( r );
			var mix = new Dictionary<string, int> { ["Walk"] = 0, ["Run"] = 0, ["Sprint"] = 0, ["SuperSprint"] = 0 };
			float lo = float.MaxValue, hi = 0f, sum = 0f; int n = 0;

			for ( int j = 0; j <= 35; j++ )
			{
				string t = WalkerAnimations.TierName( curve + j );
				mix[t]++;
				float sc = ZombieStats.TierSpeedScale( t );
				foreach ( var c in TierClips( t ) )
				{
					float v = MathF.Max( WalkerGroundSpeeds.For( c, t ) * sc, floorSpeed );
					lo = MathF.Min( lo, v ); hi = MathF.Max( hi, v ); sum += v; n++;
				}
			}

			float mean = n > 0 ? sum / n : 0f;
			Log.Info( $"[nz-speed]  {r,5} | {mix["Walk"] * 100 / 36,3}% {mix["Run"] * 100 / 36,3}%"
				+ $" {mix["Sprint"] * 100 / 36,3}% {mix["SuperSprint"] * 100 / 36,3}%"
				+ $" | {lo,4:0} {mean,5:0} {hi,5:0} | {mean / MathF.Max( 1f, pSprint ) * 100,3:0}%" );
		}
	}

	/// <summary>
	/// Retune tier speeds live: `nz_zspeed_scale [walk] [run] [sprint] [supersprint] [maxAnimRate]`.
	///
	/// ⚠️ REACHES ZOMBIES ALREADY IN THE MAP. Speed is resolved once when the clip is picked, so
	/// without the refresh below a retune would only apply to things spawned after it — which
	/// reads as the command having done nothing.
	/// </summary>
	[ConCmd( "nz_zspeed_scale" )]
	public static void ZombieSpeedScale( float walk = -1f, float run = -1f, float sprint = -1f,
		float superSprint = -1f, float maxAnimRate = -1f )
	{
		var s = ActiveConfig.Zombies;
		if ( walk > 0f ) s.WalkSpeedScale = walk;
		if ( run > 0f ) s.RunSpeedScale = run;
		if ( sprint > 0f ) s.SprintSpeedScale = sprint;
		if ( superSprint > 0f ) s.SuperSprintSpeedScale = superSprint;
		if ( maxAnimRate > 0f ) s.MaxAnimRate = maxAnimRate;

		int touched = 0;
		foreach ( var z in Game.ActiveScene?.GetAllComponents<ZombieAI>() ?? Enumerable.Empty<ZombieAI>() )
		{
			z.RefreshSpeed();
			touched++;
		}

		Log.Info( $"[nz-speed] scales: walk x{s.WalkSpeedScale:0.##}, run x{s.RunSpeedScale:0.##}, "
			+ $"sprint x{s.SprintSpeedScale:0.##}, supersprint x{s.SuperSprintSpeedScale:0.##}, "
			+ $"anim clamp {s.MaxAnimRate:0.##}  — refreshed {touched} live zombie(s)" );
		Log.Info( "[nz-speed]   nz_zspeed_curve to see the per-round result" );
	}

	// ── COMMANDS/HELPERS ─────────────────────────────────────────────────────

	private static Vector3 PlayerPosition( Scene scene )
	{
		var player = scene.GetAllComponents<PlayerController>().FirstOrDefault();
		return player.IsValid() ? player.WorldPosition : Vector3.Zero;
	}

	/// <summary>
	/// Where a zombie from this spawner stands, and the window it is held at if it is held (`parkAt`, else null). `SpawnAt`
	/// uses it, and so does `ZombieAI.Unstick`, which puts a stuck zombie back at a spawner.
	///
	/// ⚠️ ONE PLACE, SINCE 2026-10-05. The relocation used the spawner's bare position and handed it to the agent, which snapped
	/// a closet with no navmesh up onto the closet's roof: on Defocus, every relocation into such a closet ended on a roof,
	/// stuck, relocated again, 717 times in one night.
	/// </summary>
	public static Vector3 SpawnStand( Scene scene, Vector3 pos, ZombieVariant variant, bool atWindow, out Barricade parkAt )
	{
		parkAt = null;

		// ⛔ A SPAWNER AT A WINDOW SPAWNS AT THE WINDOW, ON ITS OWN SIDE (the user, 2026-10-01: "zombies not getting behind the
		// barricade when spawning ... make it so they are moved to the barricade after spawning, on the side nearest to their
		// spawn point"). The snap below found the nearest navmesh, and behind a window whose spawn closet the navmesh does not
		// reach, that was the room past the boards. Where the mesh does not reach the stand, the zombie is held there
		// (`ZombieAI.ParkedAt`) until the boards are down.
		// ⚠️ NOT FOR ONE THAT IGNORES BARRICADES (a hound): it runs past windows, and needs the mesh to run on.
		var window = atWindow && variant?.IgnoresBarricades != true ? Barricade.WindowFor( pos ) : null;

		if ( window is not null && window.SpawnSideFor( pos, out var onMesh ) is Vector3 stand )
		{
			if ( !onMesh ) parkAt = window;
			return stand;
		}

		// Snap to the navmesh so we never spawn inside geometry or in the air.
		// ⛔ ON THE SPAWNER'S OWN LEVEL (ZombieAI.NavGround), NOT THE NEAREST MESH IN ANY DIRECTION: that spawned a zombie
		// whose spawner stood under a platform up ON the platform (the user, 2026-10-01).
		return ZombieAI.NavGround( scene, pos );
	}

	/// <summary>Create a zombie at a position, snapped onto the navmesh.</summary>
	/// <summary>Public so RoundManager can spawn at a placed spawn point rather
	/// than reimplementing zombie creation.</summary>
	/// <param name="variant">Optional .zvar. ⚠️ MUST be passed in rather than
	/// assigned to the returned component: OnStart reads the variant to choose
	/// the body model and the animation tier, so setting it afterwards gets you
	/// a WALKER carrying the variant's speeds and clips — which is precisely how
	/// the first hellhound spawned looking like a normal zombie.</param>
	/// <param name="altarWave">One of basalt's altar defense's wave (`ZombieAI.AltarWave`): set here, before the zombie
	/// starts, for the same reason as the variant.</param>
	/// <param name="atWindow">A placed spawner's zombie (the wave's, a special round's): if the spawner stands at a window, the
	/// zombie starts at that window on the spawner's side (`Barricade.SpawnSideFor`). Off for everything spawned anywhere else
	/// — `nz_spawn`, a summon, basalt's scripted waves — which spawn exactly where asked.</param>
	public static ZombieAI SpawnAt( Scene scene, Vector3 pos, ZombieVariant variant = null,
		float healthMultiplier = 1f, float speedMultiplier = 1f, bool altarWave = false, bool atWindow = false )
	{
		// ⛔ THE MAP'S WALKER SKIN IS FILLED IN HERE, AND ONLY WHEN NOTHING WAS ASKED FOR. Every
		// ordinary walker in the game — the round spawner, `nz_spawn`, `nz_horde`, the dev menu —
		// arrives through this function with a null variant, so this is the single place a skin can
		// apply to all of them at once. Doing it per caller would let `nz_horde` test a different
		// body from the one a real round spawns, which makes a visual change untestable.
		//
		// ⚠️ A NON-NULL VARIANT IS LEFT ALONE. Hellhounds, Brutus and every future special arrive
		// with theirs already chosen; overwriting those would reskin the bosses too.
		// ⚠️ BEFORE THE SNAP, because the window choice below reads the variant.
		variant ??= WalkerSkins.Current;

		var snapped = SpawnStand( scene, pos, variant, atWindow, out var parkAt );

		var go = scene.CreateObject();
		go.Name = "Zombie";
		go.WorldPosition = snapped;

		// Created DISABLED so Variant is in place before OnStart runs; enabling
		// is what starts the AI, so it has to be the last step.
		var z = go.Components.Create<ZombieAI>( startEnabled: false );
		z.Variant = variant;
		z.ExtraHealthMultiplier = healthMultiplier;
		z.ExtraSpeedMultiplier = speedMultiplier;
		z.AltarWave = altarWave;
		z.ParkedAt = parkAt;
		z.Enabled = true;

		// ⛔ AND EVERY OTHER MACHINE GETS ONE TOO. Without this a zombie was a purely local
		// object: the host had a horde and a client stood in the same map, at the same round,
		// with nothing in it — "the map is there, the config objects are there, but the rounds
		// don't exist". `ZombieAI.IsPuppet` is what stops the copy trying to think for itself.
		//
		// ⚠️ AFTER `Enabled = true`, WHICH MATTERS. The spawn is serialised at this moment,
		// so the variant, the multipliers and the animation chosen from them all travel with it;
		// spawning it before the component was configured would send a default zombie and then
		// never correct it, because a `[Property]` does not replicate on change.
		//
		// ⚠️ UNCONDITIONAL. `NetworkSpawn` on a machine with no networking active is a no-op,
		// so single player pays nothing and there is no second code path to keep correct.
		go.NetworkSpawn();

		return z;
	}

	/// <summary>
	/// `nz_spawn_remove &lt;index&gt;` — remove one zombie spawner by its number in `nz_spawns`, from the loaded config; "Save
	/// config" keeps it. For a spawner nobody can aim at: Defocus's #14, left on top of a slab by the old placement bug
	/// (2026-10-01). That one is out of the config since 2026-10-05: it stood on the roof of #16's closet, 96 units up.
	/// </summary>
	[ConCmd( "nz_spawn_remove" )]
	public static void SpawnRemoveCmd( int index = -1 )
	{
		var list = ActiveConfig.Current?.ZombieSpawns;
		if ( list is null || index < 0 || index >= list.Count )
		{
			Log.Info( $"[nz] nz_spawn_remove <index> — 0 to {(list?.Count ?? 0) - 1}, as nz_spawns numbers them" );
			return;
		}

		var gone = list[index];
		list.RemoveAt( index );
		Log.Info( $"[nz] zombie spawn #{index} at {gone.Position} removed ({list.Count} left) — Save config to keep it" );
	}
	/// <summary>
	/// `nz_target` — why the nearest zombie is or is not chasing you.
	///
	/// ⛔ WRITTEN AFTER THREE WRONG GUESSES AT THE SAME BUG. "Zombies never target me again
	/// after I leave the gas" has at least five causes that are indistinguishable from
	/// outside: `Think` not running in the current state, the retarget timer never elapsing,
	/// the candidate filter rejecting the player, `IsInGas` still reading true, or the agent
	/// sitting on a stale destination. Reading the code narrows it to "all five look correct",
	/// which is the point at which reasoning stops paying and INSTRUCTIONS.md §7 applies.
	///
	/// This prints every link in the chain, in the order `TickChase` evaluates them, so the
	/// broken one names itself.
	/// </summary>
	/// <summary>
	/// `nz_retarget` — push a re-acquire at every zombie, the same push the gas edge sends.
	///
	/// ⚠️ EXISTS SO THE FIX IS TESTABLE WITHOUT THE GAS. If this un-freezes a stuck horde then
	/// the stall is downstream of targeting and the push is the right shape; if it does not,
	/// the problem is in the agent or the tick and no amount of retargeting will help.
	/// </summary>
	[ConCmd( "nz_retarget" )]
	public static void RetargetCmd()
	{
		var before = ZombieAI.All.Count( z => z.IsValid() && z.Target.IsValid() );
		ZombieAI.ForceRetargetAll();
		var after = ZombieAI.All.Count( z => z.IsValid() && z.Target.IsValid() );

		Log.Info( $"[nz] forced retarget on {ZombieAI.All.Count} zombie(s)"
			+ $" — with a target: {before} -> {after}" );
	}

	/// <summary>
	/// `nz_zspeed_why [count]` — the full speed chain for the nearest zombies.
	///
	/// ⛔ WRITTEN BECAUSE "THEY ARE STOPPED" HAS THREE CAUSES THAT LOOK IDENTICAL: the computed
	/// `MoveSpeed` being near zero, the agent's `MaxSpeed` having been left stale by one of the
	/// NINE places that write it, or the agent having a correct speed and not moving anyway
	/// (pathing, steering, separation). Two rounds were already spent retuning the arithmetic on
	/// the assumption it was the first one.
	///
	/// ⚠ IT PRINTS THE AGENT'S OWN FIGURES, not this project's idea of them. Everything to the
	/// left of the `|` is what `ZombieAI` computes; everything right of it is what the navmesh
	/// agent actually holds. A `DIVERGED` marker means those two disagree, which is the answer.
	/// </summary>
	[ConCmd( "nz_zspeed_why" )]
	public static void SpeedWhyCmd( int count = 5 )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz-zspeed] no scene" ); return; }

		var player = NZPlayer.Local;
		var from = player.IsValid() ? player.WorldPosition : Vector3.Zero;

		var all = ZombieAI.All.Where( z => z.IsValid() ).ToList();

		Log.Info( $"[nz-zspeed] {all.Count} zombie(s) alive"
			+ $" · timeslip aura {NZombies.TimeAugments.AuraScale:0.##} within {NZombies.TimeAugments.AuraRadius:0}u"
			+ $" · pit {NZombies.TimeAugments.PitScale:0.###} within {NZombies.TimeAugments.PitRadius:0}u" );

		foreach ( var z in all
			.OrderBy( z => z.WorldPosition.Distance( from ) )
			.Take( Math.Clamp( count, 1, 40 ) ) )
		{
			Log.Info( $"[nz-zspeed]   {z.WorldPosition.Distance( from ),5:0}u  {z.SpeedReport()}" );
		}

		Log.Info( "[nz-zspeed] left of | = what ZombieAI computes · right of | = what the agent holds."
			+ " DIVERGED means a stale MaxSpeed; matching-but-zero-velocity means pathing." );
	}

	/// <summary>
	/// `nz_zspeed_push` — force every agent's MaxSpeed back to its MoveSpeed.
	///
	/// ⚠ A TEST, NOT A FIX, and it is the decisive half of the pair. If the horde starts moving
	/// again, the cause is a stale `MaxSpeed` and the bug is at whichever of the nine writers left
	/// it behind. If nothing changes, speed was never the problem.
	/// </summary>
	// ══ GROUND TRUTH: DID THE BODY ACTUALLY MOVE ══════════════════════════════
	//
	// ⛔ EVERY OTHER FIGURE IN THIS FILE IS A PROXY. `MoveSpeed` is an intention,
	// `agent.MaxSpeed` is a permission, `agent.Velocity` is the agent's own opinion, and
	// `PlaybackRate` is derived from that opinion. All four can disagree with where the zombie
	// actually is, and chasing them cost several rounds. World position over elapsed time cannot
	// disagree with anything.

	static readonly Dictionary<ZombieAI, (Vector3 At, float When)> _trackMarks = new();

	/// <summary>
	/// `nz_model_turn [pitch] [yaw] [roll] [name]` — turn a rig's model on all three axes, live.
	/// </summary>
	///
	/// ⚠️ THIS IS THE RIG'S OWN CORRECTION, NOT WHERE THE ZOMBIE IS LOOKING. It is composed after
	/// the facing (`LookAt( faceDir ) * ModelTurn`), so it says "this model is drawn wrong by this
	/// much" and stays true whichever way the thing walks.
	///
	/// ⚠️ BARE, IT REPORTS. Every tuner in this project does, for the same reason: a command that
	/// silently zeroed an hour of dialling in would be the most expensive thing here to mistype.
	///
	/// ⛔ IT DOES NOT WRITE THE `.zvar` AND A RESPAWN UNDOES IT. The line it prints is the one to
	/// paste into the variant — `nz_zscale` has carried the same warning since the day a tuned
	/// scale was lost to a round change.
	[ConCmd( "nz_model_turn" )]
	public static void ModelTurn( float pitch = float.NaN, float yaw = 0f, float roll = 0f,
		string name = "" )
	{
		var all = Game.ActiveScene?.GetAllComponents<ZombieAI>()
			.Where( z => string.IsNullOrWhiteSpace( name )
				|| (z.Variant?.ResourceName?.Contains( name, StringComparison.OrdinalIgnoreCase ) ?? false) )
			.ToList() ?? new System.Collections.Generic.List<ZombieAI>();

		if ( all.Count == 0 )
		{
			Log.Warning( "[nz] no zombie in the scene"
				+ (string.IsNullOrWhiteSpace( name ) ? "" : $" matching '{name}'") );
			return;
		}

		if ( !float.IsNaN( pitch ) )
		{
			foreach ( var z in all )
			{
				z.ModelPitchOffset = pitch;
				z.ModelYawOffset = yaw;
				z.ModelRollOffset = roll;
			}
		}

		// ⚠️ GROUPED BY VARIANT, because a report of forty walkers all reading 0,0,0 buries the one
		// boss you are actually turning.
		foreach ( var g in all.GroupBy( z => z.Variant?.ResourceName ?? "walker" ) )
		{
			var z = g.First();
			Log.Info( $"[nz] {g.Key} ×{g.Count()} — pitch {z.ModelPitchOffset:0.##}"
				+ $" yaw {z.ModelYawOffset:0.##} roll {z.ModelRollOffset:0.##}" );
			Log.Info( $"[nz]   for {g.Key}.zvar:  \"ModelPitchOffset\": {z.ModelPitchOffset:0.###},"
				+ $" \"ModelYawOffset\": {z.ModelYawOffset:0.###},"
				+ $" \"ModelRollOffset\": {z.ModelRollOffset:0.###}" );
		}
	}

	/// <summary>
	/// `nz_zscale &lt;scale&gt; [name]` — resize every living zombie's body, or only one variant's.
	///
	/// ⚠️ "BIG ENOUGH" IS A LOOK, NOT A NUMBER, so it needs to be tried rather than derived. The
	/// model is 80 units against a 72-unit player; whether 1.2 or 1.6 reads as "boss" is a question
	/// only the screen answers.
	///
	/// ⚠️ IT DOES NOT WRITE THE `.zvar` — a respawn undoes it. Put the number that looks right into
	/// `ModelScale`.
	/// </summary>
	[ConCmd( "nz_zscale" )]
	public static void ZScale( float scale = 1f, string name = "" )
	{
		var all = Game.ActiveScene?.GetAllComponents<ZombieAI>()
			.Where( z => string.IsNullOrWhiteSpace( name )
				|| (z.Variant?.ResourceName?.Contains( name, StringComparison.OrdinalIgnoreCase ) ?? false) )
			.ToList() ?? new System.Collections.Generic.List<ZombieAI>();

		foreach ( var z in all ) z.ApplyModelScale( scale );

		Log.Info( $"[nz] scaled {all.Count} zombie(s) to ×{scale:0.##}"
			+ (string.IsNullOrWhiteSpace( name ) ? "" : $" (matching '{name}')") );
	}

	/// <summary>
	/// `nz_agent_size &lt;radius&gt; [height]` — resize every zombie's nav agent live.
	///
	/// ⛔ THIS IS THE COMMAND THAT SEPARATES "SLOW" FROM "STUCK". `nz_zspeed_why` tells you the
	/// speed chain is correct and the velocity is zero; it cannot tell you WHY the agent will not
	/// move. Radius and height are the only two things a variant pushes into the agent, and a
	/// navmesh baked for a walker (radius 9, height 72) does not necessarily admit a boss
	/// (radius 22, height 80). Halve the radius and watch the velocity.
	///
	/// ⚠️ 0 LEAVES A FIELD ALONE — `nz_agent_size 9` tests the radius without touching the height.
	///
	/// ⚠️ IT DOES NOT WRITE THE `.zvar`. The fix, once a number is proven, goes in the variant;
	/// this only changes the zombies currently alive, so a respawn undoes it.
	/// </summary>
	[ConCmd( "nz_agent_size" )]
	public static void AgentSize( float radius = 0f, float height = 0f )
	{
		var all = Game.ActiveScene?.GetAllComponents<ZombieAI>().ToList()
			?? new System.Collections.Generic.List<ZombieAI>();

		foreach ( var z in all )
			z.SetAgentSize( radius, height );

		Log.Info( $"[nz] resized {all.Count} agent(s)"
			+ (radius > 0f ? $" · radius {radius:0.#}" : " · radius unchanged")
			+ (height > 0f ? $" · height {height:0.#}" : " · height unchanged") );

		// ⚠️ NOT READ BACK IN THE SAME BREATH. The velocity needs a moment to settle, and a
		// measurement taken beside its own mutation reads the old world. Follow with
		// `nz_zspeed_why` a second later — the same discipline `nz_zspeed_force` documents.
		Log.Info( "[nz]   now run `nz_zspeed_why` — a velocity above 0 means the old size was"
			+ " the blocker" );
	}

	/// <summary>
	/// `nz_zspeed_track` — call once to mark, again a second later to read REAL speed.
	///
	/// ⚠ TWO CALLS ON PURPOSE. A single call cannot measure a rate, and sampling inside one
	/// frame measures nothing at all — the same §16 trap that made a batched `nz_stink` read the
	/// state before the cloud existed.
	/// </summary>
	[ConCmd( "nz_zspeed_track" )]
	public static void SpeedTrackCmd()
	{
		var now = Time.Now;
		var live = ZombieAI.All.Where( z => z.IsValid() ).ToList();

		var reported = 0;

		foreach ( var z in live )
		{
			if ( _trackMarks.TryGetValue( z, out var mark ) )
			{
				var dt = now - mark.When;

				if ( dt > 0.05f )
				{
					var moved = z.WorldPosition.Distance( mark.At );
					var real = moved / dt;
					var told = z.MoveSpeed;
					var ratio = told > 0.5f ? real / told : 0f;

					Log.Info( $"[nz-track] {z.State,-9}"
						+ $" told {told,5:0.#} u/s"
						+ $" · REAL {real,5:0.#} u/s"
						+ $" · moved {moved,6:0.#}u in {dt:0.00}s"
						+ $" · ratio {ratio:0.00}"
						+ (z.State == ZombieState.Attacking
							? "   (attacking — standing still is correct)"
							: ratio < 0.25f
								? "   ⛔ NOT MOVING"
								: ratio < 0.8f ? "   ⚠ slower than told" : "   ok") );

					reported++;
				}
			}

			_trackMarks[z] = (z.WorldPosition, now);
		}

		// ⚠ Pruning here rather than never: the dictionary is static and would otherwise hold
		// every zombie that ever died for the rest of the session.
		foreach ( var dead in _trackMarks.Keys.Where( k => !k.IsValid() ).ToList() )
			_trackMarks.Remove( dead );

		if ( reported == 0 )
			Log.Info( $"[nz-track] marked {live.Count} zombie(s) — run it again in a second to read"
				+ " how far they actually got" );
	}

	/// <summary>
	/// `nz_zspeed_force <speed>` — set a raw agent speed on every zombie and see if they move.
	///
	/// ⛔ THIS ISOLATES THE ENGINE FROM THE PERK. Run it at 55 and they should walk; run it at
	/// 27.5 and if they stop, the navmesh agent is what cannot do half speed — nothing about
	/// Timeslip's arithmetic is involved, because this write bypasses all of it.
	///
	/// ⚠ FOLLOW IT WITH `nz_zspeed_why` A SECOND LATER. The velocity takes a moment to settle,
	/// so reading in the same frame measures the state before the change.
	/// </summary>
	[ConCmd( "nz_zspeed_force" )]
	public static void SpeedForceCmd( float speed = 27.5f )
	{
		var n = 0;

		foreach ( var z in ZombieAI.All )
		{
			if ( !z.IsValid() ) continue;

			z.ForceAgentSpeed( speed );
			n++;
		}

		Log.Info( $"[nz-zspeed] forced raw agent speed {speed:0.#} onto {n} zombie(s)."
			+ " Read nz_zspeed_why a second later — if velocity stays 0, the AGENT cannot do"
			+ " this speed and the perk arithmetic is innocent." );
	}

	[ConCmd( "nz_zspeed_push" )]
	public static void SpeedPushCmd()
	{
		var n = 0;

		foreach ( var z in ZombieAI.All )
		{
			if ( !z.IsValid() ) continue;

			z.PushSpeed();
			n++;
		}

		Log.Info( $"[nz-zspeed] pushed MoveSpeed onto {n} agent(s)."
			+ " If they move now, the bug is a stale MaxSpeed — not the slow arithmetic." );
	}

	[ConCmd( "nz_target" )]
	public static void TargetCmd()
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz-target] no scene" ); return; }

		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz-target] no player" ); return; }

		// ⚠️ THE PLAYER SIDE FIRST. Two of the five causes live here and neither needs a
		// zombie to diagnose.
		Log.Info( $"[nz-target] player at {player.WorldPosition} · down {player.IsDown}"
			+ $" · IN GAS {VultureStink.IsInGas( player )}"
			// ⛔ THE GAS IS NO LONGER THE ONLY WAY TO BE UNTARGETABLE. When this
			// line printed gas alone, a Death Perception M3 proc read as "IN GAS False,
			// down False" beside an EMPTY candidate list — the diagnostic said
			// nothing was hiding the player while the filter was hiding the player.
			//
			// ⚠️ `IsUntargetable` IS THE FILTER'S OWN QUESTION, so this prints
			// exactly what the candidate list is deciding on rather than a guess at it.
			// Timeslip m2 and Death Perception M3 both write `UntargetableUntil`.
			+ $" · UNTARGETABLE {player.IsUntargetable}"
			+ $" (timer {MathF.Max( 0f, player.UntargetableUntil ):0.0}s)"
			+ $" · footprint {VultureStink.Radius:0}u" );

		// ⛔ THE CLOUD COUNT IS THE ONE I KEEP ASSUMING. Each `nz_stink` leaves a cloud for
		// 12s, so a session spent testing the spawn command can have several standing at once
		// — and a player who has walked out of the one they can see may still be inside an
		// older one they cannot.
		var clouds = scene.Directory.FindByName( "vulture_stink" )
			.Where( g => g.IsValid() ).ToArray();

		Log.Info( $"[nz-target] {clouds.Length} live cloud(s)" );

		foreach ( var c in clouds )
			Log.Info( $"[nz-target]   cloud {c.WorldPosition}"
				+ $" · {c.WorldPosition.WithZ( 0f ).Distance( player.WorldPosition.WithZ( 0f ) ):0}u"
				+ $" from you (inside at <= {VultureStink.Radius:0}u)" );

		// ⚠️ THE NEAREST ZOMBIE ONLY. Thirty-five identical reports is not a diagnostic, and
		// if one of them is wrong they all are.
		var z = ZombieAI.All
			.Where( a => a.IsValid() )
			.OrderBy( a => a.WorldPosition.Distance( player.WorldPosition ) )
			.FirstOrDefault();

		if ( !z.IsValid() ) { Log.Info( "[nz-target] no zombies alive" ); return; }

		Log.Info( $"[nz-target] nearest zombie {z.WorldPosition.Distance( player.WorldPosition ):0}u away"
			+ $" · state {z.State}"
			+ $" · target {(z.Target.IsValid() ? z.Target.Name : "NONE")}"
			+ $" · retargets in {z.RetargetIn:0.00}s" );

		// ⛔ THE ENABLE FLAGS, because "Think is not running" has two shapes and they need
		// different fixes: a state the switch does not dispatch, or a component that is not
		// ticking at all. A disabled component still appears in `ZombieAI.All` and still
		// answers every property, so it is invisible from every other reading.
		Log.Info( $"[nz-target]   component enabled {z.Enabled} · active {z.Active}"
			+ $" · gameobject enabled {z.GameObject.Enabled}"
			+ $" · thinkrate {z.ThinkRate:0.###}" );

		// ⛔ WHERE THE TICK ACTUALLY STOPS. think 0 → OnUpdate returns before Think;
		// think > 0 with chase 0 → the state switch does not dispatch this state;
		// chase > 0 with past-vault 0 → the `_vaulting` guard is stuck.
		Log.Info( $"[nz-target]   think {z.ThinkCount} · chase {z.ChaseCount}"
			+ $" · past-vault {z.ChasePastVault}" );

		var cands = z.TargetableNames();
		Log.Info( $"[nz-target] candidate list: {(cands.Length == 0 ? "EMPTY" : string.Join( ", ", cands ))}" );

		// The verdict, so the reading does not need interpreting.
		if ( cands.Length == 0 )
		{
			Log.Warning( "[nz-target] VERDICT: the candidate FILTER is excluding everyone."
				+ " Check `down` and `UNTARGETABLE` above — one is true." );
			Log.Warning( "[nz-target]   UNTARGETABLE has three sources: Vulture's gas, Timeslip m2," );
			Log.Warning( "[nz-target]   and Death Perception M3. `IN GAS` only rules out the first." );
		}
		else if ( !z.Target.IsValid() )
			Log.Warning( "[nz-target] VERDICT: candidates exist but no target is set."
				+ " The filter is fine; the retarget timer or the state machine is not."
				+ " Run this twice — if `retargets in` never falls, Think is not running"
				+ $" for state {z.State}." );
		else
			Log.Info( "[nz-target] VERDICT: targeting normally." );
	}

	/// <summary>
	/// `nz_spawn_dirt [0|1]` — the dirt burst thrown up by a ground entrance.
	///
	/// ⚠️ TURNED OFF ON REQUEST, and kept as a switch rather than deleted because the effect itself
	/// is not the problem — it is one burst of SpawnDirtCount clods per zombie that uses a ground
	/// entrance clip, so a wave that spawns twelve at once pays for twelve bursts at the same moment.
	/// Whether that is worth its cost is a look-and-feel call, and it needs to be flippable while
	/// watching rather than settled here.
	/// </summary>
	[ConCmd( "nz_spawn_dirt" )]
	public static void SpawnDirtCmd( int on = -1 )
	{
		if ( on >= 0 ) ZombieAI.SpawnDirtOn = on != 0;

		Log.Info( $"[nz] spawn dirt {(ZombieAI.SpawnDirtOn ? "ON" : "off")}"
			+ " — applies to zombies spawning from now on" );
	}
}