Waiting for Navmesh

TLDR: In UE 5.8, a streamed sublevel's discarded RecastNavMesh can either crash the navigation system or stop the real navmesh from ever spawning. A two-line engine fix, a game-side workaround, and a content clean-up that removes the trigger altogether.
Some bugs get written. Others get inherited. This one is a bit of both - a perfectly sensible fix in 5.8 that leaned on something which had been quietly wrong for years. It's the engine equivalent of fixing the squeaky floorboard and finding out it was holding the wall up ;p
Same engine version as last time, same level streaming, different system. This time it's the navigation system itself.
The Crash #
EXCEPTION_ACCESS_VIOLATION reading address 0x00000000000004b8
UNavigationSystemV1::SpawnMissingNavigationDataInLevel
UNavigationSystemV1::SpawnMissingNavigationData
UNavigationSystemV1::PerformNavigationBoundsUpdate
UNavigationSystemV1::Tick
UWorld::Tick
The faulting instruction compares a field at offset 0x4B8 of a null pointer. Here's the loop it's in:-
const bool bIsWaitingForRegistration = NavDataRegistrationQueue.ContainsByPredicate([&NavConfig](const TObjectPtr<ANavigationData>& NavData)
{
return NavData->GetConfig().Name == NavConfig.Name;
});
That check arrived in 5.8 with UE-350790 (commit b4ffa85, "Don't spawn missing navigation data that is already pending for registration"). It assumes every entry in NavDataRegistrationQueue is a live actor. They aren't always.
The Quieter Symptom #
The crash is the loud version. The same check can also leave the world with no navmesh at all, with no crash and no warning. With -LogCmds="LogNavigation Verbose" you'll see this, over and over:-
SpawnMissingNavigationDataInLevel AgentIndex: 0 is supported. NavConfig name: Default.
SpawnMissingNavigationDataInLevel Skipping Default already waiting for registration.
If that appears and no RegistrationSuccessful (RecastNavMesh...) line ever does, you have this bug. AI simply can't path.
How It Happens #
- A streamed sublevel contains a saved
RecastNavMesh. InPostLoadit adds itself toNavDataRegistrationQueue(NavigationData.cpp:273). - A moment later, still in
PostLoad,ARecastNavMesh::CheckToDiscardSubLevelNavDatadestroys it, as sublevel nav data isn't allowed (bShouldDiscardSubLevelNavData, on by default). Nothing removes it from the queue. ProcessRegistrationCandidatesonly removes entries whose level is visible. Until then, the dead entry stays (NavigationSystem.cpp:2764).- The queue is a
UPROPERTY, so the next garbage collection sets the dead entry to null. A null entry has no level, so it now stays forever.OnLevelRemovedFromWorlddoesn't purge the queue either. - If no navmesh is registered for the agent, every navigation bounds update goes through
SpawnMissingNavigationData. A common way to get there: the persistent level has noNavMeshBoundsVolumeof its own, so atOnWorldInitDonethe engine destroys the persistent navmesh (NavigationSystem.cpp:1375). - The UE-350790 check (
NavigationSystem.cpp:4649) walks the queue:- a destroyed entry counts as "already waiting for registration", so the real navmesh is never spawned - even though the destroyed one can never register;
- a null entry gets dereferenced. Crash.
Before 5.8 these dead and null entries were harmless, because nothing looked at them. The new check is what turns them into a crash.
In real-life terms: a restaurant waiting list. A party puts their name down for the big table, then leaves before they're called. Nobody crosses them off. The host won't give the table to anyone else - "sorry, that one's already spoken for" - so it stays empty all night. Later someone rubs the name out, leaving a blank line, and the next time the host checks the list they try to read a name that isn't there.
The GC doesn't have to follow a level unload, either. A sublevel that's loaded but not yet visible is enough: one GC in that window and its dead entry becomes null.
Where the Sublevel Navmeshes Come From #
You probably didn't put them there. In the editor, the navigation system auto-creates a RecastNavMesh in any world that has a NavMeshBoundsVolume. Open a sublevel on its own, save it, and it now contains one.
At runtime those actors are always discarded, so they do nothing useful - apart from creating the dead queue entries above. Delete one and save, and it comes straight back the next time someone opens that sublevel on its own and saves it.
Seeing It For Yourself #
In the project where we found it, these steps crash every time:-
- A persistent level with no
NavMeshBoundsVolume. Navmesh runtime generation set to Dynamic; auto-create navigation data and discard sublevel nav data both on (the defaults). - Sublevels A and B, each with a floor and a
NavMeshBoundsVolume, each opened on its own and built, so each has a savedRecastNavMesh. - At runtime: load A but keep it hidden (
SetShouldBeLoaded(true),SetShouldBeVisible(false)), wait for it to load, unload it, runobj gc, then make B visible.
The bounds update from B hits the null entry and crashes in SpawnMissingNavigationDataInLevel. Without the obj gc it doesn't crash, but the dead entry still blocks the spawn - the "no navmesh" symptom above.
Editor and uncooked builds tend to hit this more often than packaged ones, because slower loading widens the gap between a sublevel's PostLoad and it becoming visible. Checkpoint travel, respawns and loads during streaming all widen it in packaged builds too.
The Fix #
In SpawnMissingNavigationDataInLevel, ignore entries that can never register by adding an IsValid() check:-
const bool bIsWaitingForRegistration = NavDataRegistrationQueue.ContainsByPredicate([&NavConfig](const TObjectPtr<ANavigationData>& NavData)
{
return IsValid(NavData) && NavData->GetConfig().Name == NavConfig.Name;
});
And in ProcessRegistrationCandidates, drop dead entries rather than keeping them until their level is visible (the if block is new):-
ANavigationData* NavDataPtr = NavDataRegistrationQueue[CandidateIndex];
if (!IsValid(NavDataPtr))
{
NavDataRegistrationQueue.RemoveAtSwap(CandidateIndex);
continue;
}
ULevel* OwningLevel = NavDataPtr != nullptr ? NavDataPtr->GetLevel() : nullptr;
For belt and braces, CheckToDiscardSubLevelNavData could take the actor out of the queue when it discards it, and OnLevelRemovedFromWorld could purge queued entries from the removed level.
Workarounds You Can Ship Today #
Purge the queue. NavDataRegistrationQueue is public in 5.8.4, so a world subsystem can clean it up:-
// Call after garbage collection (FCoreUObjectDelegates::GetPostGarbageCollect) with no level,
// and when a level is removed (FWorldDelegates::LevelRemovedFromWorld) with that level.
void UNavRegistrationQueueGuard::PurgeQueue(const ULevel* Level)
{
if (UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(GetWorld()))
{
NavSys->NavDataRegistrationQueue.RemoveAll([Level](const TObjectPtr<ANavigationData>& NavData)
{
return !IsValid(NavData) || (Level && NavData->GetLevel() == Level);
});
}
}
Null entries only appear at GC, so purging straight after GC stops the crash. It doesn't close the "no navmesh" window between a discard and the next GC, though - for that you need the content fix as well.
Remove the trigger. Delete the RecastNavMesh actors saved in your streamed sublevels. Keep their NavMeshBoundsVolumes, and keep the navmesh in the persistent level. Optionally, give the persistent level a small NavMeshBoundsVolume of its own, so its navmesh is never destroyed at startup and SpawnMissingNavigationData isn't needed at all.
With the sublevel navmeshes gone, the persistent navmesh registered on the first bounds update, the queue stayed empty, and the crash couldn't be reproduced even without the purge. Just remember they come back if anyone opens and saves those sublevels on their own - worth a check before submitting.
- Previous: The Floor That Wasn't There