Debug and diagnostic console commands for navmesh and zombie navigation. Provides commands to describe paths, probe mesh between a zombie and player, sample/draw nav dots and grids, toggle editor/nav visuals, plan/apply invisible bridge slabs for nav links, and manage viewmodel camera diagnostics.
using Sandbox;
using Sandbox.Navigation;
using System.Collections.Generic;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// NAV DIAGNOSTICS — is there a path, and where does it go?
///
/// The whole doors-block-zombies claim rests on "no path exists through a
/// standing barrier". That is measurable, so it gets measured rather than
/// eyeballed from a screenshot of a zombie wandering.
/// </summary>
public static class NavCommands
{
static NZPlayer Player => NZPlayer.Local;
/// <summary>
/// Path between two world points, described.
///
/// ⚠️ STATUS IS THE ANSWER, not the point count. Detour does not fail when a
/// target is unreachable — it returns the closest reachable point and marks
/// the result **Partial**. So "a path came back" proves nothing; Complete vs
/// Partial is the whole test.
/// </summary>
static string Describe( Vector3 from, Vector3 to )
{
var nav = Game.ActiveScene?.NavMesh;
if ( nav is null ) return "no navmesh";
var path = nav.CalculatePath( new CalculatePathRequest
{
Start = from,
Target = to,
} );
// ⚠️ NavMeshPath and NavMeshPathPoint are STRUCTS — no null check, and a
// point exposes Position as a FIELD, not a Vector3 conversion.
var pts = path.Points;
float len = 0f;
if ( pts is not null )
for ( int i = 1; i < pts.Count; i++ )
len += pts[i].Position.Distance( pts[i - 1].Position );
var end = pts is not null && pts.Count > 0 ? pts[^1].Position : from;
var shortfall = end.Distance( to );
return $"{path.Status} {pts?.Count ?? 0} pts, {len:0}u travelled, "
+ $"direct {from.Distance( to ):0}u, ends {shortfall:0}u short";
}
/// <summary>
/// `nz_nav_between [index]` — the full account of why one zombie cannot reach you.
///
/// ⛔ EVERY OTHER COMMAND HERE ANSWERS HALF THE QUESTION. `nz_path` says a route is Partial,
/// `nz_navlink_check` says the links are healthy, `nz_nav` says the mesh exists — and all three
/// can be true while a zombie stands still. What none of them say is WHERE the route dies, and
/// that is the only thing worth knowing.
///
/// For the player and one zombie it reports: the surface each is standing on and how far it is
/// below them, how far each is from the navmesh, the path status — and then it WALKS THE
/// STRAIGHT LINE between them, probing the mesh every few units, so the hole shows up as a run
/// of misses with a measured width and a position.
///
/// ⚠️ THE LINE PROBE IS THE POINT. A gap you can see is a gap you can bridge; "the path is
/// Partial" is a gap you can only guess at.
/// </summary>
[ConCmd( "nz_nav_between" )]
public static void Between( int index = 0, float step = 8f )
{
var scene = Game.ActiveScene;
var nav = scene?.NavMesh;
var p = Player;
if ( nav is null ) { Log.Warning( "[nz-between] scene has no NavMesh" ); return; }
if ( !p.IsValid() ) { Log.Warning( "[nz-between] no player" ); return; }
var live = ZombieAI.All.Where( z => z.State != ZombieState.Dead ).ToList();
if ( live.Count == 0 ) { Log.Info( "[nz-between] no live zombies" ); return; }
index = index.Clamp( 0, live.Count - 1 );
// ⚠️ NEAREST FIRST, so `nz_nav_between` with no argument is about the zombie you are looking
// at rather than whichever one happens to be first in the list.
live = live.OrderBy( z => z.WorldPosition.DistanceSquared( p.WorldPosition ) ).ToList();
var a = live[index].WorldPosition;
var b = p.WorldPosition;
Log.Info( $"[nz-between] zombie[{index}] {a:0} → player {b:0}"
+ $" direct {a.Distance( b ):0}u dz {b.z - a.z:0.#}u" );
Report( "zombie", a );
Report( "player", b );
void Report( string who, Vector3 at )
{
// ⚠️ FROM SLIGHTLY ABOVE, because a body standing exactly on a surface can trace from
// inside it and miss — which reads as "standing on nothing" and sends you looking for a
// hole that is not there.
var tr = scene.Trace.Ray( at + Vector3.Up * 8f, at - Vector3.Up * 4096f )
.IgnoreGameObject( p.GameObject ).Run();
var on = nav.GetClosestPoint( at );
var off = on is null ? -1f : at.Distance( on.Value );
Log.Info( $"[nz-between] {who,-6} surface {( tr.Hit ? $"{tr.HitPosition.z:0.#}" : "NONE" )}"
+ $" ({( tr.Hit ? $"{at.z - tr.HitPosition.z:0.#}u below" : "-" )})"
+ $" '{( tr.Hit ? tr.GameObject?.Name ?? "world" : "-" )}'"
+ $" mesh {( off < 0f ? "UNREACHABLE" : $"{off:0.#}u away" )}"
+ ( off > NavLinkManager.SnapTolerance ? " ⛔ OFF-MESH" : "" ) );
}
// ── the path ─────────────────────────────────────────────────────────
var path = nav.CalculatePath( new CalculatePathRequest { Start = a, Target = b } );
var pts = path.Points;
float len = 0f;
if ( pts is not null )
for ( int i = 1; i < pts.Count; i++ )
len += pts[i].Position.Distance( pts[i - 1].Position );
var end = pts is not null && pts.Count > 0 ? pts[^1].Position : a;
Log.Info( $"[nz-between] path {path.Status} {pts?.Count ?? 0} pts {len:0}u travelled"
+ $" stops {end.Distance( b ):0}u short at {end:0}" );
// ── the line probe ───────────────────────────────────────────────────
step = step.Clamp( 2f, 64f );
var dist = a.Distance( b );
int n = (int)( dist / step );
if ( n < 2 ) { Log.Info( "[nz-between] too close to probe" ); return; }
// ⚠️ PROBED AT EACH SAMPLE'S OWN HEIGHT, interpolated between the two ends, NOT at one fixed
// z. A flat probe down the middle of a slope reports a hole that is only the floor sloping
// away from the height it was asked about — the same defect that makes `nz_nav_bridge`'s
// results wander with where the player stands.
var runs = new List<(float from, float to)>();
bool inHole = false;
float holeStart = 0f;
for ( int i = 0; i <= n; i++ )
{
float t = (float)i / n;
var at = Vector3.Lerp( a, b, t );
var on = nav.GetClosestPoint( at );
bool ok = on is not null && (on.Value - at).WithZ( 0 ).Length <= step
&& MathF.Abs( on.Value.z - at.z ) <= 48f;
if ( !ok && !inHole ) { inHole = true; holeStart = t * dist; }
else if ( ok && inHole ) { inHole = false; runs.Add( (holeStart, t * dist) ); }
}
if ( inHole ) runs.Add( (holeStart, dist) );
if ( runs.Count == 0 )
{
Log.Info( $"[nz-between] line probe at {step:0}u: mesh all the way — the break is NOT"
+ " a hole in the floor. Look at the gate (nz_zvert) or the agent's own state." );
return;
}
Log.Warning( $"[nz-between] line probe at {step:0}u: {runs.Count} hole(s) in the mesh"
+ " between them" );
foreach ( var (f, t) in runs )
{
var mid = Vector3.Lerp( a, b, ( (f + t) * 0.5f ) / dist );
Log.Info( $"[nz-between] {t - f:0}u wide, from {f:0}u to {t:0}u along the line"
+ $" centred {mid:0}" );
}
var widest = runs.Max( r => r.to - r.from );
Log.Info( $"[nz-between] widest hole {widest:0}u — nz_nav_bridge needs maxGap above that,"
+ $" and a link across it is {( widest > 96f ? "a JUMP, not a step" : "a step" )}" );
// ── is the FLOOR broken, or only the mesh? ───────────────────────────
//
// ⛔ THIS IS THE QUESTION THE MESH PROBE CANNOT ANSWER AND EVERYTHING DEPENDS ON. A hole in
// the navmesh has two completely different causes with the same symptom: real air between
// two platforms, or a CONTINUOUS floor whose mesh has been inset from both edges by
// `AgentRadius` because Recast treated the two surfaces as separate regions. The first wants
// a link. The second is the mesh lying about the floor, and no number of links makes it
// stop — you would be bridging a gap the player can simply walk across.
//
// ⚠️ THE TELL IS THAT AN INSET HOLE SCALES WITH THE RADIUS AND A REAL ONE DOES NOT. Halve
// `nz_nav_agent`'s radius and re-run: a hole that halves was never in the floor.
// ⛔ "HIT SOMETHING" IS NOT "THERE IS FLOOR HERE", AND THE FIRST VERSION OF THIS GOT IT
// WRONG. A ray down the line hits a floor 80 units lower through the very gap being
// measured, and hits other ZOMBIES on the way — so it reported 30 of 30 solid across
// surfaces spanning 125u and concluded the floor was continuous. A sample only counts as
// floor AT THIS LEVEL when it lands within a step of the height the line is at.
int atLevel = 0, below = 0, air = 0;
float lowest = float.MaxValue, highest = float.MinValue;
for ( int i = 0; i <= n; i++ )
{
float t = (float)i / n;
var at = Vector3.Lerp( a, b, t );
// ⚠️ Zombies are solid to a ray. Skipping anything with a ZombieAI keeps a crowd
// standing in the gap from reading as a floor across it.
var tr = scene.Trace.Ray( at + Vector3.Up * 40f, at - Vector3.Up * 512f )
.IgnoreGameObject( p.GameObject ).Run();
while ( tr.Hit && tr.GameObject.IsValid()
&& tr.GameObject.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid() )
{
tr = scene.Trace.Ray( tr.HitPosition - Vector3.Up * 2f, at - Vector3.Up * 512f )
.IgnoreGameObject( tr.GameObject ).Run();
}
if ( !tr.Hit ) { air++; continue; }
lowest = MathF.Min( lowest, tr.HitPosition.z );
highest = MathF.Max( highest, tr.HitPosition.z );
if ( MathF.Abs( tr.HitPosition.z - at.z ) <= nav.AgentStepSize + 8f ) atLevel++;
else below++;
}
Log.Info( $"[nz-between] floor probe: {atLevel} at this level, {below} far below,"
+ $" {air} nothing at all"
+ ( atLevel + below > 0 ? $" surfaces {lowest:0.#} → {highest:0.#}" : "" ) );
if ( below == 0 && air == 0 )
Log.Warning( "[nz-between] ⛔ THE FLOOR IS CONTINUOUS AT THIS LEVEL — the hole is in"
+ " the MESH, not the map. Recast inset it from both surfaces' edges, so a gap the"
+ " player walks straight over does not exist for a zombie. Halve the radius with"
+ " nz_nav_agent and re-run: a hole that halves was never in the floor." );
else
Log.Info( $"[nz-between] {below + air} sample(s) have no floor at this level — there"
+ " is real air here, so a link is the right mechanism" );
}
/// <summary>Path from every living zombie to the player.</summary>
[ConCmd( "nz_path" )]
public static void Path()
{
var p = Player;
if ( !p.IsValid() ) { Log.Warning( "[nz] no player" ); return; }
var zombies = ZombieAI.All.Where( z => z.State != ZombieState.Dead ).ToList();
if ( zombies.Count == 0 ) { Log.Info( "[nz] no live zombies" ); return; }
Log.Info( $"[nz] paths to player at {p.WorldPosition}:" );
foreach ( var z in zombies )
Log.Info( $"[nz] {Describe( z.WorldPosition, p.WorldPosition )}" );
}
/// <summary>
/// Path between two offsets from where you are looking:
/// nz_path_test <fwdA> <rightA> <fwdB> <rightB>
///
/// Probes a doorway without needing a zombie in the right place.
/// </summary>
[ConCmd( "nz_path_test" )]
public static void PathTest( float fwdA = -200f, float rightA = 0f,
float fwdB = 400f, float rightB = 0f )
{
var scene = Game.ActiveScene;
var p = Player;
if ( !scene.IsValid() || !p.IsValid() ) { Log.Warning( "[nz] no player" ); return; }
// ⚠️ Anchored to the PLAYER, not Scene.Camera. Scene.Camera resolves to
// the EDITOR camera while the editor has focus, so offsets were measured
// from wherever the viewport happened to be — the probe points did not
// move when the player respawned, and every reading came from a corner
// of the map nobody was standing in. Cost: a phantom "poisoned navmesh"
// diagnosis chasing 7000u paths that were simply real geometry
// somewhere else.
var origin = p.WorldPosition;
var rot = p.Components.Get<PlayerController>()?.EyeAngles.ToRotation()
?? p.WorldRotation;
var flat = rot.Forward.WithZ( 0 ).Normal;
var side = rot.Right.WithZ( 0 ).Normal;
Vector3 Ground( float f, float r )
{
var at = origin + flat * f + side * r;
var tr = scene.Trace.Ray( at + Vector3.Up * 128f, at - Vector3.Up * 4096f ).Run();
return tr.Hit ? tr.HitPosition : at;
}
var a = Ground( fwdA, rightA );
var b = Ground( fwdB, rightB );
Log.Info( $"[nz] {a} -> {b}" );
Log.Info( $"[nz] {Describe( a, b )}" );
}
/// <summary>
/// Force a full navmesh rebuild.
///
/// The repair hatch for when tiles have gone stale — a blocker destroyed
/// without regenerating leaves a permanent hole, and nothing about the mesh
/// reports that it is wrong. Paths just quietly detour around nothing.
/// </summary>
[ConCmd( "nz_nav_rebuild" )]
public static void Rebuild()
{
var nav = Game.ActiveScene?.NavMesh;
if ( nav is null ) { Log.Warning( "[nz] scene has no NavMesh" ); return; }
nav.SetDirty();
Log.Info( "[nz] navmesh marked dirty — rebuilding over the next few frames" );
}
/// <summary>Navmesh state — is one loaded, is it rebuilding, how many
/// blockers are up?</summary>
/// <summary>
/// Show or hide the navmesh: `nz_nav_draw [0|1]`, no argument toggles.
///
/// ⚠️ The engine's own `NavMesh.DrawMesh`, whose summary says "in the editor".
/// It is the only navmesh visualisation there is — there is no API to read the
/// polygons back and draw them ourselves — so if it does not appear in play
/// mode, that is the reason and the fallback is to look in the editor viewport
/// with the game running.
/// </summary>
[ConCmd( "nz_nav_draw" )]
public static void NavDraw( string on = "" )
{
var nav = Game.ActiveScene?.NavMesh;
if ( nav is null )
{
// ⚠️ SAY WHICH SCENE IS MISSING. `Game.ActiveScene` is null with play
// STOPPED, so running this from the editor console reports "no
// navmesh" about a map that has 863KB of baked data — which reads as a
// broken navmesh rather than a command in the wrong place.
Log.Warning( "[nz] no ACTIVE scene — this reads Game.ActiveScene, which "
+ "only exists while playing. Press play, or set NavMesh > Draw Mesh "
+ "in the Scene inspector for the editor viewport." );
return;
}
bool want = string.IsNullOrWhiteSpace( on ) ? !nav.DrawMesh
: on is "1" or "true" or "on";
nav.DrawMesh = want;
// ⛔ AUTO-UPDATE TOO, AND THIS IS THE PART THAT WAS MISSING. The baked
// .navdata loads at PLAY; in edit mode there is no live mesh unless the
// editor is building one. Its own description is "constantly updates the
// navigation mesh in the editor" — so DrawMesh alone draws nothing there,
// which is exactly what we saw.
if ( want && !nav.EditorAutoUpdate )
{
nav.EditorAutoUpdate = true;
Log.Info( "[nz] EditorAutoUpdate turned ON so there is a mesh to draw "
+ "— it rebuilds as geometry moves, so turn it off when done "
+ "(nz_nav_editor 0)" );
}
Log.Info( $"[nz] navmesh draw {(nav.DrawMesh ? "ON" : "off")}"
+ $" (enabled {nav.IsEnabled}, generating {nav.IsGenerating}, "
+ $"autoupdate {nav.EditorAutoUpdate})" );
Log.Info( "[nz] if the viewport is still blank: nav_debug_draw_distance 10000" );
}
/// <summary>
/// Toggle the editor's live navmesh build: `nz_nav_editor [0|1]`.
///
/// ⚠️ COSTS PERFORMANCE WHILE ON — it rebuilds as geometry moves, which is why
/// the scene ships with it off. On while authoring, off when done.
/// </summary>
[ConCmd( "nz_nav_editor" )]
public static void NavEditor( string on = "" )
{
var nav = Game.ActiveScene?.NavMesh;
if ( nav is null ) { Log.Warning( "[nz] no active scene — press play first" ); return; }
nav.EditorAutoUpdate = string.IsNullOrWhiteSpace( on )
? !nav.EditorAutoUpdate
: on is "1" or "true" or "on";
Log.Info( $"[nz] navmesh EditorAutoUpdate {(nav.EditorAutoUpdate ? "ON" : "off")}"
+ (nav.EditorAutoUpdate ? " — rebuilds as geometry moves" : "") );
}
/// <summary>
/// Show or hide the authoring markers: `nz_markers [0|1]`.
///
/// ⚠️ This is PreviewMode inverted, not a new flag. Preview already exists to
/// hide every marker so the map can be judged as a player sees it; a second
/// independent toggle would be two things to get out of step. Named for what
/// a mapper is actually asking for — "let me see what I placed".
/// </summary>
[ConCmd( "nz_markers" )]
public static void Markers( string on = "" )
{
NZGame.PreviewMode = string.IsNullOrWhiteSpace( on )
? !NZGame.PreviewMode
: !(on is "1" or "true" or "on");
Log.Info( $"[nz] markers {(NZGame.ShowAuthoringVisuals ? "ON" : "off")}"
+ (NZGame.IsCreative ? "" : " ⚠ NOT IN CREATIVE — markers never draw in a round") );
}
/// <summary>How long the sampled dots linger. 0 = until cleared.
///
/// ⛔ DEFAULTS TO NEVER EXPIRING, AND THAT IS THE POINT. It was 30s, and the
/// dots vanishing got blamed on the V key — because noclip is exactly what
/// you use to fly around inspecting them, so the timeout and the keypress
/// coincided. A debug view with an invisible clock is a debug view that lies
/// about what made it disappear. It now goes away when you say so and not
/// before.</summary>
public static float NavDotsSeconds { get; set; } = 0f;
/// <summary>
/// The sampled points, REDRAWN EVERY FRAME by NavLinkManager.
///
/// ⛔ THE DEBUG OVERLAY'S DURATION ARGUMENT DID NOT HOLD THEM. Passing 20s
/// drew the dots and they vanished after about a second — reported as "I saw
/// the dots for a second and then they disappeared". Every other marker in
/// this project passes 0f and redraws per frame, which is the pattern that
/// demonstrably works, so the sample is kept here and reissued instead of
/// trusting a lifetime the overlay evidently manages differently.
///
/// ⚠️ Static so it survives the command returning, and EMPTY at start — a
/// static that starts empty and fills at runtime is safe across hotload; one
/// with content in its initialiser is not (INSTRUCTIONS pattern 1).
/// </summary>
/// ⛔ AN ARRAY, SWAPPED WHOLESALE — NOT A LIST THAT IS CLEARED AND REFILLED.
/// As a `static readonly List` this threw `IndexOutOfRangeException` inside
/// `List.get_Item` on the frame a scene started: the reader had already taken
/// `Count` when the list's contents were not the ones that count described.
/// A single reference assignment cannot be observed half-done, so the reader
/// either sees the old sample or the new one and never a torn one.
public static Vector3[] NavDotPoints { get; set; } = Array.Empty<Vector3>();
/// <summary>
/// Wireframe segments over the navmesh, as PAIRS: 0-1, 2-3, 4-5...
///
/// ⛔ THE CLOSEST THING TO "THE ACTUAL PLANES" THAT THE API ALLOWS. NavMesh
/// exposes no geometry — `NavMeshPath.Polygons` is internal and its own summary
/// says "you cannot do anything with polygon ids yet", and there is no vertex
/// or tile accessor. So the surface is INFERRED: probe a regular grid with
/// GetClosestPoint, keep the cells that land on the mesh, and join neighbours.
///
/// ⚠️ A GRID, NOT RANDOM POINTS. Same probe, but regular spacing is what makes
/// it read as a surface — random dots of the same density read as noise, which
/// is the complaint this answers.
///
/// ⚠️ Pairs in ONE flat array so the draw loop cannot disagree with the data
/// about where a segment ends, and so the whole sample is published in a single
/// reference swap — see NavDotPoints for why that matters.
/// </summary>
public static Vector3[] NavGridLines { get; set; } = Array.Empty<Vector3>();
/// <summary>When the sample expires and stops being redrawn.</summary>
public static TimeUntil NavDotsUntil { get; set; }
/// <summary>
/// Sample the navmesh around you and draw a dot at every hit:
/// `nz_nav_dots [count] [radius]`.
///
/// ⛔ EXISTS BECAUSE `NavMesh.DrawMesh` IS EDITOR-ONLY. Its own summary says
/// "Draw the navigation mesh in the editor", and in play mode it reports ON
/// and renders nothing — confirmed in a live session, console line
/// "navmesh draw ON (enabled True, generating False)" over a completely
/// unchanged view.
///
/// ⚠️ THIS IS A SAMPLE, NOT THE MESH. There is no geometry accessor on
/// NavMesh — no polygon list, no vertex buffer, nothing to read back — so the
/// only honest way to see coverage from inside the game is to ask for random
/// points and mark where they land. Dense areas of dots mean walkable; holes
/// mean no navmesh. A hole may also just be an unlucky sample, so raise the
/// count before concluding anything from one.
/// </summary>
[ConCmd( "nz_nav_dots" )]
public static void NavDots( int count = 1500, float radius = 1500f )
{
var scene = Game.ActiveScene;
var nav = scene?.NavMesh;
if ( nav is null ) { Log.Warning( "[nz] scene has no NavMesh" ); return; }
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Warning( "[nz] no player to sample around" ); return; }
var from = p.WorldPosition;
NavDotPoints = Array.Empty<Vector3>();
NavLinkManager.ResetDotsBroken();
count = Math.Clamp( count, 1, 20000 );
// ⚠️ Built LOCALLY and published once. Filling the shared field in place
// is what let a reader see a partial sample.
var found = new List<Vector3>( count );
for ( int i = 0; i < count; i++ )
{
var pt = nav.GetRandomPoint( from, radius );
if ( pt is not null ) found.Add( pt.Value );
}
int hits = found.Count;
NavDotPoints = found.ToArray();
NavDotsUntil = NavDotsSeconds > 0f ? NavDotsSeconds : float.MaxValue;
Log.Info( $"[nz] navmesh sample: {hits}/{count} points found within {radius:0}u "
+ (NavDotsSeconds > 0f
? $" — for {NavDotsSeconds:0}s"
: " — staying until nz_nav_dots 0") );
if ( count <= 1 ) { NavDotPoints = Array.Empty<Vector3>(); Log.Info( "[nz] cleared" ); return; }
if ( hits == 0 )
Log.Warning( "[nz] ⚠ NO POINTS AT ALL — there is no navmesh near you. "
+ "nz_nav to check it generated, nz_nav_rebuild to force one." );
}
/// <summary>
/// Wireframe the navmesh around you: `nz_nav_grid [spacing] [extent]`.
///
/// Probes a grid and joins neighbouring cells that are both on the mesh, so
/// walkable surface shows as a lattice and holes show as gaps.
///
/// ⚠️ COST IS QUADRATIC IN extent/spacing. 32u over 1500u is ~94 x 94 = 8,800
/// probes; halving the spacing quadruples that. Clamped, and it reports how
/// many it did.
/// </summary>
[ConCmd( "nz_nav_grid" )]
public static void NavGrid( float spacing = 48f, float extent = 1200f )
{
var scene = Game.ActiveScene;
var nav = scene?.NavMesh;
if ( nav is null ) { Log.Warning( "[nz] scene has no NavMesh" ); return; }
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Warning( "[nz] no player to sample around" ); return; }
spacing = Math.Clamp( spacing, 16f, 512f );
extent = Math.Clamp( extent, 128f, 8000f );
if ( spacing <= 1f ) { Log.Warning( "[nz] spacing too small" ); return; }
int half = (int)(extent / spacing);
int w = half * 2 + 1;
var origin = p.WorldPosition;
var pts = new Vector3[w * w];
var ok = new bool[w * w];
// ⚠️ A cell counts as walkable only if the mesh point it snapped to is
// still NEAR the cell — GetClosestPoint always returns SOMETHING, so
// without this every probe "succeeds" and the grid covers the whole box
// including the walls.
float tol = spacing * 0.75f;
for ( int y = 0; y < w; y++ )
for ( int x = 0; x < w; x++ )
{
var cell = origin + new Vector3( (x - half) * spacing, (y - half) * spacing, 0f );
var hit = nav.GetClosestPoint( cell );
if ( hit is null ) continue;
// Horizontal distance only. Vertical difference is expected and fine —
// that is the floor being where the floor is.
if ( (hit.Value - cell).WithZ( 0 ).Length > tol ) continue;
pts[y * w + x] = hit.Value + Vector3.Up * 2f;
ok[y * w + x] = true;
}
var lines = new List<Vector3>();
for ( int y = 0; y < w; y++ )
for ( int x = 0; x < w; x++ )
{
int i = y * w + x;
if ( !ok[i] ) continue;
// Right and down neighbours only — every edge gets emitted once.
if ( x + 1 < w && ok[i + 1] )
{
// ⚠️ Skip a joint across a big drop. Two adjacent cells on
// different floors are not connected walkable surface, and a line
// between them draws a wall that is not there.
if ( MathF.Abs( pts[i].z - pts[i + 1].z ) < spacing )
{ lines.Add( pts[i] ); lines.Add( pts[i + 1] ); }
}
if ( y + 1 < w && ok[i + w] )
{
if ( MathF.Abs( pts[i].z - pts[i + w].z ) < spacing )
{ lines.Add( pts[i] ); lines.Add( pts[i + w] ); }
}
}
NavGridLines = lines.ToArray();
NavDotsUntil = NavDotsSeconds > 0f ? NavDotsSeconds : float.MaxValue;
NavLinkManager.ResetDotsBroken();
int cells = 0;
foreach ( var b in ok ) if ( b ) cells++;
Log.Info( $"[nz] navmesh grid: {cells} walkable of {w * w} probes at {spacing:0}u "
+ $"— {NavGridLines.Length / 2} segments" );
if ( cells == 0 )
Log.Warning( "[nz] ⚠ nothing walkable found — nz_nav to check the mesh exists" );
}
/// <summary>Show or hide the solid nav-link markers: `nz_navlink_vis [0|1]`.
///
/// ⚠️ These are REAL GEOMETRY, so unlike every other marker in this project
/// they are visible in a round as well as in Creative. That is deliberate —
/// the whole point was a marker that cannot be lost — but it means they need
/// an off switch of their own.</summary>
// ══ BRIDGES ══════════════════════════════════════════════════════════════════════════════
/// <summary>
/// One slab per gap, built from the nav links that cross it. Pitched to match the ends.
/// </summary>
///
/// ⛔ A BRIDGE IS AN `InvisibleWall` WITH `BlocksZombies` ON, AND THAT IS NOT A TRICK. That flag
/// means "do NOT hide this body from navmesh generation" — the collider is solid either way and
/// the mesh is built FROM collision. On a vertical wall the effect is that zombies route around
/// it; on a slab lying at walkway height the effect is that they walk over the top of it. Same
/// one mechanism, and it is why this needed no new system, no new manager and no new config
/// list to save, edit and ship.
///
/// ⛔ GATED ON SLOPE, NOT ON HEIGHT DIFFERENCE, AND THE TWO DISAGREE ABOUT REAL LINKS. A 30-unit
/// drop across a 20-unit gap is a 56° face; the same drop across 200 units is an 8° incline.
/// Measuring the rise alone calls both of them the same thing, so the gate is the angle and it
/// is compared against the map's own `Nav.MaxSlope` — the number the navmesh will actually
/// honour. Anything steeper stays a link, because a slab there is a wall, not a route.
///
/// ⚠️ THE SLAB IS TILTED TO THE COURSE, NOT LAID FLAT. `Rotation.LookAt` along A→B gives the
/// pitch and the yaw together, so the top face passes through both ends exactly — no step at
/// either lip, whatever the incline. Its length is the 3D span for the same reason.
///
/// ⚠️ SUNK ALONG ITS OWN UP, NOT THE WORLD'S. On a pitched slab those are different directions,
/// and dropping it by world-Z would leave the top face cutting through one end and floating at
/// the other.
static List<InvisibleWall> Plan( float maxSlope, float thickness, float overlap,
out List<NavLinkSpot> spent )
{
var made = new List<InvisibleWall>();
var seen = new HashSet<string>();
spent = new List<NavLinkSpot>();
foreach ( var l in ActiveConfig.Current.NavLinks )
{
var span = l.B - l.A;
var flat = span.WithZ( 0f ).Length;
if ( flat < 1f ) continue;
var slope = MathF.Atan2( MathF.Abs( span.z ), flat ) * (180f / MathF.PI);
if ( slope > maxSlope ) continue;
spent.Add( l );
// ⛔ ONE SLAB PER SPAN, NOT PER LINK, AND IT HALVES THE COUNT. Every gap was authored
// as two one-directional links, A to B and B to A — 711 of them on Basalt for 356
// actual gaps. A slab is bidirectional because it is floor, so the second link of each
// pair has nothing left to describe.
//
// ⚠️ KEYED ON THE ENDPOINTS ROUNDED AND SORTED, so the two halves of a pair land on the
// same key whichever order they were authored in, and two links that merely start near
// each other do not.
var ka = $"{(int)(l.A.x / 8)},{(int)(l.A.y / 8)},{(int)(l.A.z / 8)}";
var kb = $"{(int)(l.B.x / 8)},{(int)(l.B.y / 8)},{(int)(l.B.z / 8)}";
var key = string.CompareOrdinal( ka, kb ) < 0 ? ka + "|" + kb : kb + "|" + ka;
if ( !seen.Add( key ) ) continue;
var rot = Rotation.LookAt( span.Normal, Vector3.Up );
var ang = rot.Angles();
made.Add( new InvisibleWall
{
Position = (l.A + l.B) * 0.5f - rot.Up * (thickness * 0.5f),
Pitch = ang.pitch,
Yaw = ang.yaw,
Roll = ang.roll,
// ⚠️ `Size.x` RUNS ALONG THE COURSE because the object's forward is +X and the box
// collider is scaled in local space. ⚠️ The overlap sinks each end INTO the
// platform it meets, so the navmesh joins the slab to the walkway instead of
// leaving a hairline both surfaces stop short of.
Size = new Vector3( span.Length + overlap * 2f,
MathF.Max( 48f, l.Radius * 2f ), thickness ),
Visible = false,
BlocksZombies = true,
} );
}
return made;
}
/// <summary>The map's own walkable limit, which is what the mesh will honour.</summary>
static float MapMaxSlope
=> ActiveConfig.Current?.Nav?.MaxSlope is > 0f and var s ? s : 45f;
/// <summary>What `nz_nav_bridge_apply` would do: `nz_nav_bridge_plan [maxSlope]`.</summary>
/// <remarks>
/// ⛔ `_plan`, NOT `nz_nav_bridge` (2026-10-05): that name is `NavLinkCommands.Bridge`'s, the 09-17 gap scanner that WRITES
/// links into the map config by default. Both were registered under it, the engine kept whichever it met first ("Command
/// nz_nav_bridge already exists - not overwriting"), and a report could run as an edit.
/// </remarks>
[ConCmd( "nz_nav_bridge_plan" )]
public static void Bridge( float maxSlope = 0f )
{
if ( maxSlope <= 0f ) maxSlope = MapMaxSlope;
var made = Plan( maxSlope, 16f, 24f, out var spent );
var links = ActiveConfig.Current.NavLinks.Count;
var ramps = made.Count( w => MathF.Abs( w.Pitch ) > 5f );
Log.Info( $"[nz-bridge] {spent.Count} of {links} link(s) are shallower than {maxSlope:0}° —"
+ $" {made.Count} slab(s) would cover them ({spent.Count - made.Count} were the other"
+ " direction of a gap already covered)" );
Log.Info( $"[nz-bridge] {ramps} of those are RAMPS (pitched past 5°),"
+ $" {made.Count - ramps} lie flat" );
Log.Info( $"[nz-bridge] {links - spent.Count} link(s) are steeper and would be KEPT" );
Log.Info( $"[nz-bridge] invisible walls would go {ActiveConfig.Current.InvisibleWalls.Count}"
+ $" -> {ActiveConfig.Current.InvisibleWalls.Count + made.Count}" );
Log.Info( "[nz-bridge] nz_nav_bridge_apply to do it — then SAVE the config" );
}
/// <summary>
/// Replace the shallow nav links with slabs: `nz_nav_bridge_apply [maxSlope]`.
/// </summary>
///
/// ⛔ IT EDITS THE LIVE CONFIG AND NOTHING ELSE. Save the map config afterwards or it dies with
/// the session, and a saved config lands in Data rather than Assets — `py Tools/ship_data.py
/// --apply` before publishing, or the published game keeps every one of the old links.
[ConCmd( "nz_nav_bridge_apply" )]
public static void BridgeApply( float maxSlope = 0f )
{
if ( maxSlope <= 0f ) maxSlope = MapMaxSlope;
var cfg = ActiveConfig.Current;
var made = Plan( maxSlope, 16f, 24f, out var spent );
if ( made.Count == 0 )
{
Log.Warning( $"[nz-bridge] nothing shallower than {maxSlope:0}° to bridge" );
return;
}
foreach ( var l in spent ) cfg.NavLinks.Remove( l );
cfg.InvisibleWalls.AddRange( made );
InvisibleWallManager.Ensure( Game.ActiveScene )?.Rebuild();
NavLinkManager.Ensure( Game.ActiveScene )?.Rebuild();
// ⛔ THE MESH HAS TO BE REBUILT OR NONE OF THIS IS TRUE YET. The slabs are solid the moment
// they spawn, so a zombie could stand on one — but the navmesh it steers by was baked
// before they existed, so it would never choose to.
NavBake.Regen();
Log.Info( $"[nz-bridge] {spent.Count} link(s) -> {made.Count} slab(s);"
+ $" {cfg.NavLinks.Count} link(s) kept for the steep ones" );
Log.Warning( "[nz-bridge] SAVE THE CONFIG, then py Tools/ship_data.py --apply" );
}
[ConCmd( "nz_navlink_vis" )]
public static void NavLinkVis( string on = "" )
{
NavLinkManager.ShowMarkers = string.IsNullOrWhiteSpace( on )
? !NavLinkManager.ShowMarkers
: on is "1" or "true" or "on";
NavLinkManager.Ensure( Game.ActiveScene )?.Rebuild();
Log.Info( $"[nz] nav link markers {(NavLinkManager.ShowMarkers ? "ON" : "off")}" );
}
/// <summary>Clear the grid: `nz_nav_grid_clear`.</summary>
[ConCmd( "nz_nav_grid_clear" )]
public static void NavGridClear()
{
NavGridLines = Array.Empty<Vector3>();
Log.Info( "[nz] navmesh grid cleared" );
}
/// <summary>Why the dots are or are not on screen: `nz_dots_state`.
///
/// ⚠️ Reports each link in the chain SEPARATELY — points held, timer,
/// redrawer alive, overlay present. "They vanished" has four causes that look
/// identical, and deduction has already failed once on this.</summary>
[ConCmd( "nz_dots_state" )]
public static void DotsState()
{
var mgr = NavLinkManager.Instance;
Log.Info( $"[nz-dots] {NavDotPoints.Length} points held · "
+ $"expires in {(float)NavDotsUntil:0.0}s · "
+ $"redrawer {(mgr.IsValid() ? "alive" : "MISSING")} · "
+ $"overlay {(Game.ActiveScene?.DebugOverlay is not null ? "ok" : "NULL")} · "
+ $"creative {NZGame.IsCreative}" );
// ⚠️ CAMERA COUNT, because the knife CREATES a viewmodel camera the first
// time it swings ("knife created the viewmodel camera (no weapon had made
// one)"), and V fires both noclip and the Knife action. A debug overlay
// draws for a camera; a second one appearing is the most plausible way a
// keypress hides world marks without touching the data behind them.
var cams = Game.ActiveScene?.GetAllComponents<CameraComponent>().ToList();
Log.Info( $"[nz-dots] cameras {(cams?.Count ?? 0)}: "
+ string.Join( ", ", (cams ?? new()).Select( c =>
$"{c.GameObject.Name}{(c.Enabled ? "" : " (off)")}" ) ) );
if ( !mgr.IsValid() )
Log.Warning( "[nz-dots] the NavLinkManager is what redraws these — with it "
+ "gone nothing reissues them. NZGame.ShowConfig ensures it; nz_load "
+ "re-runs that." );
}
/// <summary>
/// TEST HARNESS for the vanishing-markers bug: `nz_vm_priority [n]`.
///
/// The knife's viewmodel camera is created at Priority 2 with
/// RenderTags = { ViewModel, Light } — so from the first swing it is the
/// highest-priority camera in the scene AND it draws only viewmodel-tagged
/// geometry. Every world-space DebugOverlay mark is excluded.
///
/// ⚠️ RUNTIME ONLY, AND DELIBERATELY NOT A CODE CHANGE. Lowering the priority
/// permanently would fix the markers and may put the viewmodel UNDER the world
/// — KnifeViewModel's own comment says "one at the wrong priority draws under
/// it". This lets that trade be SEEN before anything is committed.
///
/// No argument reports the cameras and changes nothing.
/// </summary>
/// <summary>
/// Remove the viewmodel camera so markers can be seen again: `nz_vm_clear`.
///
/// ⚠️ FOR A CAMERA THAT ALREADY EXISTS. The knife is now disabled in Creative
/// so one is never created there — but a camera made during a ROUND survives
/// into Creative, and until it is gone the markers stay hidden. This is the
/// recovery path that does not need a play restart.
///
/// ⚠️ DESTROYS rather than lowering the priority. Priority is what breaks
/// player movement; removing the object entirely leaves the main camera as the
/// only one, which is the state Creative should have been in anyway. The knife
/// rebuilds it on its next swing in a round.
/// </summary>
[ConCmd( "nz_vm_clear" )]
public static void VmClear()
{
var scene = Game.ActiveScene;
if ( scene is null ) { Log.Warning( "[nz-vm] no scene" ); return; }
var cams = scene.GetAllComponents<CameraComponent>()
.Where( c => c.GameObject.Name == "ViewModelCamera" )
.ToList();
if ( cams.Count == 0 )
{
Log.Info( "[nz-vm] no viewmodel camera — markers are not being hidden by one" );
return;
}
foreach ( var c in cams ) c.GameObject.Destroy();
Log.Info( $"[nz-vm] removed {cams.Count} viewmodel camera(s) — "
+ "markers should be visible again" );
}
/// <summary>What KnifeViewModel creates the viewmodel camera at.</summary>
public const int ViewModelDefaultPriority = 2;
[ConCmd( "nz_vm_priority" )]
public static void VmPriority( int priority = -999 )
{
var scene = Game.ActiveScene;
if ( scene is null ) { Log.Warning( "[nz-vm] no scene" ); return; }
var cams = scene.GetAllComponents<CameraComponent>().ToList();
foreach ( var c in cams )
Log.Info( $"[nz-vm] {c.GameObject.Name,-20} priority {c.Priority,3} "
+ $"{(c.Enabled ? "on" : "OFF")} tags [{string.Join( " ", c.RenderTags )}]" );
if ( priority == -999 )
{
Log.Info( "[nz-vm] nz_vm_priority <n> to change the ViewModelCamera. "
+ "Try 0, then look for the markers AND check the knife still draws." );
return;
}
var vm = cams.FirstOrDefault( c => c.GameObject.Name == "ViewModelCamera" );
if ( !vm.IsValid() )
{
Log.Warning( "[nz-vm] no ViewModelCamera — swing the knife once (nz_knife) "
+ "to make one, then run this again." );
return;
}
var was = vm.Priority;
vm.Priority = priority;
Log.Info( $"[nz-vm] ViewModelCamera priority {was} -> {vm.Priority}" );
// ⛔ THIS BREAKS PLAYER CONTROL AND IS A DIAGNOSTIC ONLY. Confirmed by the
// user: at priority 0 the markers come back AND the player can no longer
// move. The controller evidently resolves its camera by priority, so once
// the viewmodel camera ties or outranks the main one, eye angles go to a
// camera that is not driving the view and input dies with it.
//
// So priority is NOT the fix for the hidden-marker bug, however well it
// demonstrates the cause. Left in as the demonstration, with the warning
// attached so nobody strands themselves in a game they cannot move in.
if ( vm.Priority < ViewModelDefaultPriority )
Log.Warning( "[nz-vm] ⚠ THE PLAYER CANNOT MOVE AT THIS PRIORITY. Diagnostic "
+ "only — run `nz_vm_priority 2` to get control back." );
}
[ConCmd( "nz_nav" )]
public static void Nav()
{
var scene = Game.ActiveScene;
var nav = scene?.NavMesh;
if ( nav is null ) { Log.Warning( "[nz] scene has no NavMesh" ); return; }
Log.Info( $"[nz] navmesh enabled {nav.IsEnabled} generating {nav.IsGenerating} "
+ $"dirty {nav.IsDirty}" );
Log.Info( $"[nz] agent radius {nav.AgentRadius} height {nav.AgentHeight} "
+ $"step {nav.AgentStepSize}" );
var areas = scene.GetAllComponents<NavMeshArea>().ToList();
Log.Info( $"[nz] nav areas: {areas.Count}, blockers: "
+ $"{areas.Count( a => a.IsBlocker )}" );
}
}