From fed1d0cf466a02b0618037fb25dde4b0eeda2f4b Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Sun, 4 Oct 2026 02:58:00 -0600 Subject: [PATCH 1/4] Bots skip crouch gaps their hull cannot fit A crouch nav area promises HumanCrouchHeight (55 u) of headroom, but Support ducks to 59 u and the Juggernaut to 75 u. They planned through crouch gaps their ducked hull does not fit and got stuck there. For a bot whose ducked hull is taller than HumanCrouchHeight, the path cost now refuses a level step into or out of a crouch area when no lane across the portal holds that hull. There is one lane per 4 u of portal width; the hull is swept a step above the floor against the world and static props, and each verdict is kept until the level changes. Climbs and drops are not tested. Recon and Assault plan as before. --- src/game/server/neo/bot/neo_bot_path_cost.cpp | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/src/game/server/neo/bot/neo_bot_path_cost.cpp b/src/game/server/neo/bot/neo_bot_path_cost.cpp index 0e23d18132..ff1361d51c 100644 --- a/src/game/server/neo/bot/neo_bot_path_cost.cpp +++ b/src/game/server/neo/bot/neo_bot_path_cost.cpp @@ -49,6 +49,183 @@ CNEOBotPathCost::CNEOBotPathCost(CNEOBot* me, RouteType routeType) m_bIgnoreVisibilityExposure = !neo_bot_path_visibility_exposure_enable.GetBool(); } +//------------------------------------------------------------------------------------------------- +// A crouch area promises HumanCrouchHeight (55 u) of room, but Support ducks to 59 u and the +// Juggernaut to 75 u, so some crouch gaps in the mesh are ones they cannot get through. + +static constexpr float NEO_DUCK_LANE_SPACING = 4.0f; // one lane per this much portal width, edge to edge +static constexpr float NEO_DUCK_LANE_REACH = 2.0f; // how far a lane runs into each area +static constexpr float NEO_DUCK_LANE_INSET = 0.5f; // lane ends keep this far inside their area + +// A crouch portal and a ducked hull height: the key of one lane verdict +struct NeoDuckLaneKey +{ + unsigned int m_fromId; + unsigned int m_toId; + int m_height; + + bool operator<( const NeoDuckLaneKey &other ) const + { + if ( m_fromId != other.m_fromId ) + { + return m_fromId < other.m_fromId; + } + + if ( m_toId != other.m_toId ) + { + return m_toId < other.m_toId; + } + + return m_height < other.m_height; + } +}; + +// The lane traces see only the world and static props, so a verdict holds until the level changes +static CUtlMap s_duckLaneVerdicts( DefLessFunc( NeoDuckLaneKey ) ); + +class CNeoDuckLaneReset : public CAutoGameSystem +{ +public: + CNeoDuckLaneReset() : CAutoGameSystem( "CNeoDuckLaneReset" ) + { + } + + virtual void LevelShutdownPostEntity() override + { + s_duckLaneVerdicts.RemoveAll(); + } +}; + +static CNeoDuckLaneReset s_duckLaneReset; + +// The point of the area nearest (x, y), on the floor under the hull's footprint, which can lie +// a few units off the nav plane +static Vector NeoLaneEnd( const CNavArea *area, float x, float y, const Vector &vecMins, const Vector &vecMaxs ) +{ + const Vector nw = area->GetCorner( NORTH_WEST ); + const Vector se = area->GetCorner( SOUTH_EAST ); + const float flInsetX = MIN( NEO_DUCK_LANE_INSET, ( se.x - nw.x ) / 2.0f ); + const float flInsetY = MIN( NEO_DUCK_LANE_INSET, ( se.y - nw.y ) / 2.0f ); + Vector pos( clamp( x, nw.x + flInsetX, se.x - flInsetX ), clamp( y, nw.y + flInsetY, se.y - flInsetY ), 0.0f ); + pos.z = area->GetZ( pos.x, pos.y ); + + // a footprint-sized slab, 1 u thick, dropped from a step above the nav plane to a step below + CTraceFilterWorldAndPropsOnly filter; + trace_t tr; + UTIL_TraceHull( pos + Vector( 0.0f, 0.0f, StepHeight ), pos - Vector( 0.0f, 0.0f, StepHeight ), + Vector( vecMins.x, vecMins.y, 0.0f ), Vector( vecMaxs.x, vecMaxs.y, 1.0f ), MASK_PLAYERSOLID, &filter, &tr ); + if ( !tr.startsolid && tr.fraction < 1.0f ) + { + pos.z = tr.endpos.z; + } + + return pos; +} + +// Can the hull get from a to b? It is swept with its underside a step above the floor, the way +// CGameMovement steps, so a lip or slope lower than a step does not block it +static bool NeoLaneClear( const Vector &a, const Vector &b, const Vector &vecMins, const Vector &vecMaxs ) +{ + const Vector mins( vecMins.x, vecMins.y, StepHeight ); + const Vector maxs( vecMaxs.x, vecMaxs.y, vecMaxs.z - vecMins.z ); + const float flTop = MAX( a.z, b.z ); + const Vector legs[] = { a, Vector( a.x, a.y, flTop ), Vector( b.x, b.y, flTop ), b }; + const int nLegs = ARRAYSIZE( legs ); + + CTraceFilterWorldAndPropsOnly filter; + for ( int i = 0; i + 1 < nLegs; ++i ) + { + trace_t tr; + UTIL_TraceHull( legs[i], legs[i + 1], mins, maxs, MASK_PLAYERSOLID, &filter, &tr ); + if ( tr.startsolid || tr.fraction < 1.0f ) + { + return false; + } + } + + return true; +} + +// Does the hull fit through the portal from 'from' into 'to' on at least one lane? Lanes run +// across the portal, each from just inside one area to just inside the other +static bool NeoDuckLaneFits( const CNavArea *from, const CNavArea *to, const Vector &vecMins, const Vector &vecMaxs ) +{ + NavDirType dir = NUM_DIRECTIONS; + for ( int d = 0; d < NUM_DIRECTIONS; ++d ) + { + if ( from->IsConnected( to, (NavDirType)d ) ) + { + dir = (NavDirType)d; + break; + } + } + + if ( dir == NUM_DIRECTIONS ) + { + return true; + } + + Vector center; + float flHalfWidth; + from->ComputePortal( to, dir, ¢er, &flHalfWidth ); + + Vector2D across; + DirectionToVector2D( dir, &across ); + const Vector2D along( across.y != 0.0f ? 1.0f : 0.0f, across.x != 0.0f ? 1.0f : 0.0f ); + const float flSpan = MAX( 0.0f, flHalfWidth - NEO_DUCK_LANE_INSET ); + const int nLanes = 1 + (int)( 2.0f * flSpan / NEO_DUCK_LANE_SPACING ); + for ( int i = 0; i < nLanes; ++i ) + { + const float flOffset = ( nLanes == 1 ) ? 0.0f : -flSpan + 2.0f * flSpan * i / ( nLanes - 1 ); + const float x = center.x + along.x * flOffset; + const float y = center.y + along.y * flOffset; + const float dx = across.x * NEO_DUCK_LANE_REACH; + const float dy = across.y * NEO_DUCK_LANE_REACH; + const Vector a = NeoLaneEnd( from, x - dx, y - dy, vecMins, vecMaxs ); + const Vector b = NeoLaneEnd( to, x + dx, y + dy, vecMins, vecMaxs ); + if ( NeoLaneClear( a, b, vecMins, vecMaxs ) ) + { + return true; + } + } + + return false; +} + +// Is a level step into or out of a crouch area open to this bot's ducked hull? Climbs and drops, +// and classes that duck within HumanCrouchHeight, are not tested +static bool NeoDuckLaneOpen( CNEOBot *me, const CNavArea *from, const CNavArea *to ) +{ + if ( !from->HasAttributes( NAV_MESH_CROUCH ) && !to->HasAttributes( NAV_MESH_CROUCH ) ) + { + return true; + } + + const Vector vecMins = VEC_DUCK_HULL_MIN_SCALED( me ); + const Vector vecMaxs = VEC_DUCK_HULL_MAX_SCALED( me ); + const float flHeight = vecMaxs.z - vecMins.z; + if ( flHeight <= HumanCrouchHeight ) + { + return true; + } + + if ( fabs( from->ComputeAdjacentConnectionHeightChange( to ) ) > StepHeight ) + { + return true; + } + + const NeoDuckLaneKey key = { from->GetID(), to->GetID(), (int)flHeight }; + const auto idx = s_duckLaneVerdicts.Find( key ); + if ( idx != s_duckLaneVerdicts.InvalidIndex() ) + { + return s_duckLaneVerdicts[idx]; + } + + const bool bOpen = NeoDuckLaneFits( from, to, vecMins, vecMaxs ); + s_duckLaneVerdicts.Insert( key, bOpen ); + return bOpen; +} + //------------------------------------------------------------------------------------------------- float CNEOBotPathCost::operator()(CNavArea* baseArea, CNavArea* fromArea, const CNavLadder* ladder, const CFuncElevator* elevator, float length) const { @@ -67,6 +244,12 @@ float CNEOBotPathCost::operator()(CNavArea* baseArea, CNavArea* fromArea, const return -1.0f; } + // Support and Juggernaut duck too tall for some crouch gaps + if ( !ladder && !elevator && !NeoDuckLaneOpen( m_me, fromArea, area ) ) + { + return -1.0f; + } + if ( !m_bIgnoreHazards && CNEOBotPathReservations()->IsAreaHazardous(area->GetID(), m_me) ) { if ( m_routeType == DEFAULT_ROUTE ) From 6d06de699d1c3116df921f7b159339b4e8b43114 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Wed, 14 Oct 2026 19:05:54 -0600 Subject: [PATCH 2/4] Read the ducked hull once per path search The duck lane rule derived the bot's ducked hull from its class and model scale on every crouch edge the search expanded. The path cost now reads it once in its constructor and tests a single bool first, so Recon and Assault pay one check per edge and never read area attributes for this rule. The level-step test uses the class's m_stepHeight, as the rest of operator() does. --- src/game/server/neo/bot/neo_bot_path_cost.cpp | 23 ++++++++----------- src/game/server/neo/bot/neo_bot_path_cost.h | 3 +++ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/game/server/neo/bot/neo_bot_path_cost.cpp b/src/game/server/neo/bot/neo_bot_path_cost.cpp index ff1361d51c..e2b6da7f8c 100644 --- a/src/game/server/neo/bot/neo_bot_path_cost.cpp +++ b/src/game/server/neo/bot/neo_bot_path_cost.cpp @@ -47,6 +47,9 @@ CNEOBotPathCost::CNEOBotPathCost(CNEOBot* me, RouteType routeType) m_bIgnoreReservations = !neo_bot_path_reservation_enable.GetBool(); m_bIgnoreHazards = (me->m_hCommandingPlayer.Get() != nullptr); m_bIgnoreVisibilityExposure = !neo_bot_path_visibility_exposure_enable.GetBool(); + m_vecDuckHullMins = VEC_DUCK_HULL_MIN_SCALED( me ); + m_vecDuckHullMaxs = VEC_DUCK_HULL_MAX_SCALED( me ); + m_bDucksAboveCrouchHeight = ( m_vecDuckHullMaxs.z - m_vecDuckHullMins.z ) > HumanCrouchHeight; } //------------------------------------------------------------------------------------------------- @@ -192,28 +195,21 @@ static bool NeoDuckLaneFits( const CNavArea *from, const CNavArea *to, const Vec return false; } -// Is a level step into or out of a crouch area open to this bot's ducked hull? Climbs and drops, -// and classes that duck within HumanCrouchHeight, are not tested -static bool NeoDuckLaneOpen( CNEOBot *me, const CNavArea *from, const CNavArea *to ) +// Is a level step into or out of a crouch area open to this ducked hull? Climbs and drops are not tested +static bool NeoDuckLaneOpen( const CNavArea *from, const CNavArea *to, const Vector &vecMins, const Vector &vecMaxs, + float flStepHeight ) { if ( !from->HasAttributes( NAV_MESH_CROUCH ) && !to->HasAttributes( NAV_MESH_CROUCH ) ) { return true; } - const Vector vecMins = VEC_DUCK_HULL_MIN_SCALED( me ); - const Vector vecMaxs = VEC_DUCK_HULL_MAX_SCALED( me ); - const float flHeight = vecMaxs.z - vecMins.z; - if ( flHeight <= HumanCrouchHeight ) - { - return true; - } - - if ( fabs( from->ComputeAdjacentConnectionHeightChange( to ) ) > StepHeight ) + if ( fabs( from->ComputeAdjacentConnectionHeightChange( to ) ) > flStepHeight ) { return true; } + const float flHeight = vecMaxs.z - vecMins.z; const NeoDuckLaneKey key = { from->GetID(), to->GetID(), (int)flHeight }; const auto idx = s_duckLaneVerdicts.Find( key ); if ( idx != s_duckLaneVerdicts.InvalidIndex() ) @@ -245,7 +241,8 @@ float CNEOBotPathCost::operator()(CNavArea* baseArea, CNavArea* fromArea, const } // Support and Juggernaut duck too tall for some crouch gaps - if ( !ladder && !elevator && !NeoDuckLaneOpen( m_me, fromArea, area ) ) + if ( m_bDucksAboveCrouchHeight && !ladder && !elevator + && !NeoDuckLaneOpen( fromArea, area, m_vecDuckHullMins, m_vecDuckHullMaxs, m_stepHeight ) ) { return -1.0f; } diff --git a/src/game/server/neo/bot/neo_bot_path_cost.h b/src/game/server/neo/bot/neo_bot_path_cost.h index f020fcb7a3..f97cfd1fbb 100644 --- a/src/game/server/neo/bot/neo_bot_path_cost.h +++ b/src/game/server/neo/bot/neo_bot_path_cost.h @@ -33,4 +33,7 @@ class CNEOBotPathCost : public IPathCost float m_stepHeight; float m_maxJumpHeight; float m_maxDropHeight; + Vector m_vecDuckHullMins; + Vector m_vecDuckHullMaxs; + bool m_bDucksAboveCrouchHeight; // Support and Juggernaut, whose ducked hull is taller than a crouch area promises }; From b3d5cff383c0a22ebb69ba7f11a9970d31f5346b Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Wed, 14 Oct 2026 19:06:54 -0600 Subject: [PATCH 3/4] Drop duck lane verdicts when the navmesh changes The verdicts are keyed by area id and were cleared only at level shutdown. nav_load, nav_generate_incremental and the nav editing commands (split, merge, delete) renumber areas inside a running level, so old verdicts would apply to new areas. Clear the cache when the area count it was filled on changes. Players never hit this; a nav editing session with bots can. --- src/game/server/neo/bot/neo_bot_path_cost.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/game/server/neo/bot/neo_bot_path_cost.cpp b/src/game/server/neo/bot/neo_bot_path_cost.cpp index e2b6da7f8c..6bc1dbf8c1 100644 --- a/src/game/server/neo/bot/neo_bot_path_cost.cpp +++ b/src/game/server/neo/bot/neo_bot_path_cost.cpp @@ -83,9 +83,13 @@ struct NeoDuckLaneKey } }; -// The lane traces see only the world and static props, so a verdict holds until the level changes +// The lane traces see only the world and static props, so a verdict holds while the navmesh does static CUtlMap s_duckLaneVerdicts( DefLessFunc( NeoDuckLaneKey ) ); +// nav_load and nav editing commands renumber areas without a level change, +// so the verdicts are dropped when the area count they were made on changes +static unsigned int s_nDuckLaneAreaCount = 0; + class CNeoDuckLaneReset : public CAutoGameSystem { public: @@ -209,6 +213,12 @@ static bool NeoDuckLaneOpen( const CNavArea *from, const CNavArea *to, const Vec return true; } + if ( TheNavMesh->GetNavAreaCount() != s_nDuckLaneAreaCount ) + { + s_duckLaneVerdicts.RemoveAll(); + s_nDuckLaneAreaCount = TheNavMesh->GetNavAreaCount(); + } + const float flHeight = vecMaxs.z - vecMins.z; const NeoDuckLaneKey key = { from->GetID(), to->GetID(), (int)flHeight }; const auto idx = s_duckLaneVerdicts.Find( key ); From f94a4375bd9caeb0dec9001e087e96898b722e8c Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Wed, 14 Oct 2026 19:07:54 -0600 Subject: [PATCH 4/4] Reword the duck lane comments The lane sweep comment said it raises the hull the way CGameMovement steps. StepMove lifts the whole hull, while the lane sweep keeps the top at the ducked height and ignores the lowest step of the hull, so say what it does instead. Break comment lines at clause boundaries, and name the key struct with the _t suffix used for plain structs. --- src/game/server/neo/bot/neo_bot_path_cost.cpp | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/game/server/neo/bot/neo_bot_path_cost.cpp b/src/game/server/neo/bot/neo_bot_path_cost.cpp index 6bc1dbf8c1..f23cffedf0 100644 --- a/src/game/server/neo/bot/neo_bot_path_cost.cpp +++ b/src/game/server/neo/bot/neo_bot_path_cost.cpp @@ -53,21 +53,21 @@ CNEOBotPathCost::CNEOBotPathCost(CNEOBot* me, RouteType routeType) } //------------------------------------------------------------------------------------------------- -// A crouch area promises HumanCrouchHeight (55 u) of room, but Support ducks to 59 u and the -// Juggernaut to 75 u, so some crouch gaps in the mesh are ones they cannot get through. +// A crouch area promises HumanCrouchHeight (55 u) of room, but Support ducks to 59 u and the Juggernaut to 75 u, +// so some crouch gaps in the mesh are ones they cannot get through static constexpr float NEO_DUCK_LANE_SPACING = 4.0f; // one lane per this much portal width, edge to edge static constexpr float NEO_DUCK_LANE_REACH = 2.0f; // how far a lane runs into each area static constexpr float NEO_DUCK_LANE_INSET = 0.5f; // lane ends keep this far inside their area // A crouch portal and a ducked hull height: the key of one lane verdict -struct NeoDuckLaneKey +struct NeoDuckLaneKey_t { unsigned int m_fromId; unsigned int m_toId; int m_height; - bool operator<( const NeoDuckLaneKey &other ) const + bool operator<( const NeoDuckLaneKey_t &other ) const { if ( m_fromId != other.m_fromId ) { @@ -84,7 +84,7 @@ struct NeoDuckLaneKey }; // The lane traces see only the world and static props, so a verdict holds while the navmesh does -static CUtlMap s_duckLaneVerdicts( DefLessFunc( NeoDuckLaneKey ) ); +static CUtlMap s_duckLaneVerdicts( DefLessFunc( NeoDuckLaneKey_t ) ); // nav_load and nav editing commands renumber areas without a level change, // so the verdicts are dropped when the area count they were made on changes @@ -105,8 +105,8 @@ class CNeoDuckLaneReset : public CAutoGameSystem static CNeoDuckLaneReset s_duckLaneReset; -// The point of the area nearest (x, y), on the floor under the hull's footprint, which can lie -// a few units off the nav plane +// The point of the area nearest (x, y), on the floor under the hull's footprint, +// which can lie a few units off the nav plane static Vector NeoLaneEnd( const CNavArea *area, float x, float y, const Vector &vecMins, const Vector &vecMaxs ) { const Vector nw = area->GetCorner( NORTH_WEST ); @@ -129,8 +129,8 @@ static Vector NeoLaneEnd( const CNavArea *area, float x, float y, const Vector & return pos; } -// Can the hull get from a to b? It is swept with its underside a step above the floor, the way -// CGameMovement steps, so a lip or slope lower than a step does not block it +// Can the hull get from a to b? It is swept with its underside a step above the floor, +// so it ignores a lip or slope lower than a step, as the bot steps over it static bool NeoLaneClear( const Vector &a, const Vector &b, const Vector &vecMins, const Vector &vecMaxs ) { const Vector mins( vecMins.x, vecMins.y, StepHeight ); @@ -153,8 +153,8 @@ static bool NeoLaneClear( const Vector &a, const Vector &b, const Vector &vecMin return true; } -// Does the hull fit through the portal from 'from' into 'to' on at least one lane? Lanes run -// across the portal, each from just inside one area to just inside the other +// Does the hull fit through the portal from 'from' into 'to' on at least one lane? +// Lanes run across the portal, each from just inside one area to just inside the other static bool NeoDuckLaneFits( const CNavArea *from, const CNavArea *to, const Vector &vecMins, const Vector &vecMaxs ) { NavDirType dir = NUM_DIRECTIONS; @@ -220,7 +220,7 @@ static bool NeoDuckLaneOpen( const CNavArea *from, const CNavArea *to, const Vec } const float flHeight = vecMaxs.z - vecMins.z; - const NeoDuckLaneKey key = { from->GetID(), to->GetID(), (int)flHeight }; + const NeoDuckLaneKey_t key = { from->GetID(), to->GetID(), (int)flHeight }; const auto idx = s_duckLaneVerdicts.Find( key ); if ( idx != s_duckLaneVerdicts.InvalidIndex() ) {