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.
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 <forward> <right>.
///
/// 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 <amount> [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 <amount> [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 <name>. 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 <on> [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 <id> [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&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&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 <index>` — 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 <scale> [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 <radius> [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" );
}
}