[5.8.3][Navigation][Editor] Build Paths returns "built" while UNavigationSystemV1::Build refuses under ENavigationBuildLock::AsyncLoadLock; the request is dropped, not deferred

Summary

Engine: 5.8.3 (CL 58210709), installed build. Platform: Linux x86-64 (Vulkan), editor. Area: NavigationSystem, UnrealEd (EditorBuildUtils).

In an editor world, DoInitialSetup adds ENavigationBuildLock::AsyncLoadLock at map load when bWaitForAsyncLoadingBeforeBuildingNavigationAutomatically is true (the default). The lock lifts only after FAssetCompilingManager reports 0 remaining assets and then 2 s and 16 core-ticker ticks pass.

While the lock is held, an explicit Build > Build Paths (or the BuildPaths console command, or FNavigationSystem::Build from a script) is refused. The editor still reports the build as done:

  • UNavigationSystemV1::Build logs one LogNavigation Warning and returns (it is void).
  • FEditorBuildUtils::EditorBuild then runs Map Check, logs “Build time 0:00” and returns true (“the build completed successfully”).
  • When the lock later lifts, it is removed with ELockRemovalRebuildAction::NoRebuild and the dirty areas are reset. The refused build is never run. Navigation changes made while the lock was held are not rebuilt either: the bounds path skips AddDirtyAreas while locked.

The setting’s own comment says it only affects automatic builds: “In editor worlds, wait for async loading to finish before allowing to build navigation automatically”. The explicit build is refused anyway.

What Type of Bug are you experiencing?

AI

Steps to Reproduce

With the editor Python script in Additional Notes (E3_repro.py; run one step per call, so the editor ticks between them):

  1. setup: a new level from /Engine/Maps/Templates/Template_Default, with a NavMeshBoundsVolume over its floor, saved.
  2. build a few seconds later (it builds), then save the level.
  3. repro, in one call: load the level and issue BuildPaths. A user who loads a level and presses Build Paths within about 2 s gets the same result.
  4. build again, a few seconds later.

Expected Result

Step 3 builds, or Build Paths reports that it could not (a return value, a Map Check or Message Log error, a notification) and/or builds once the lock lifts.

Observed Result

  • Step 3 logs a refusal, then Map Check and the editor’s finish line for a completed build, all in the same frame. From the second template run:
[18.20.40:969][392]Cmd: BuildPaths
[18.20.40:977][392]LogNavigationDataBuild: Display: UNavigationSystemV1::Build started...
[18.20.40:977][392]LogNavigation: Warning: Build Navigation NOT building because navigation build is locked (flags: 0x20).
[18.20.40:977][392]Cmd: MAP CHECK DONTDISPLAYDIALOG
[18.20.40:977][392]MapCheck: Map check complete: 0 Error(s), 0 Warning(s), took 0.178ms to complete.
[18.20.40:977][392]LogEditorBuildUtils: Build time 0:00

The first run showed the same. A build issued 0.4 s earlier, before the load, had built normally (UNavigationSystemV1::Build total execution time: 0.01s).

  • Step 4 builds normally (total execution time: 0.01s).
  • Nothing in the log marks the step-3 request as pending, and the source removes the lock with NoRebuild (see Additional Notes).

Affects Versions

5.8

Platform(s)

Linux

Upload an image

Additional Notes

Where in 5.8.3

  • NavigationSystem.cpp 1060-1068 (DoInitialSetup): adds AsyncLoadLock. 5950-6003 (UpdateDelayedUnlockeRegistration / RegisterDelayedUnlock): the lock lifts only after 0 remaining assets, 2 s and 16 ticks; 5996-5997 reset the dirty areas and remove the lock with NoRebuild.
  • NavigationSystem.cpp 4455-4462 (UNavigationSystemV1::Build): refuses on any lock but NoUpdateInEditor, with a Warning only. The same message (“because navigation build is locked”) is also printed when !bHasWork, with no lock held.
  • NavigationSystem.cpp 4367-4371: navigation-bounds updates add no dirty areas while locked.
  • EditorBuildUtils.cpp 442-460 (BuildAIPaths), 954-993 (TriggerNavigationBuilder → FNavigationSystem::Build), 599-609 (EditorBuild returns bBuildCompleted, which does not reflect the navigation result).
  • NavigationSystem.h 350-353: the setting’s comment.

Impact

Any scripted pipeline that loads a level (or rebuilds materials, which keeps assets compiling) and then builds navigation has its navmesh silently not built or left stale. The only sign is one Warning among thousands of lines. In an editor script the lock can never lift inside the same Python call, because the unlock needs ticks.

This cost us one full world-generation run (2026-11-01 19:13). A material rebuild ~70 s after load kept assets compiling, Build Paths was refused, and the levels were saved with every road off the navmesh. Only a later validation step noticed. Since then the refusal has recurred in three runs (2026-11-02 00:14, 07:50, 07:53; the 07:50 one came 1.4 s after its level loaded), caught each time by the check below.

Log excerpt (2026-11-02, our map; ours is World Partition, but the path is the non-partitioned FNavigationSystem::Build):

[07.50.11:439][ 74]LogWorldPartition: Display: WorldPartition initialize started...      <- level load
[07.50.12:822][ 80]Cmd: BuildPaths
[07.50.12:859][ 80]LogNavigationDataBuild: Display: UNavigationSystemV1::Build started...
[07.50.12:859][ 80]LogNavigation: Warning: Build Navigation NOT building because navigation build is locked (flags: 0x20).
[07.50.12:859][ 80]Cmd: MAP CHECK DONTDISPLAYDIALOG
[07.50.12:863][ 80]LogEditorBuildUtils: Build time 0:00
[07.51.13:042][249]Cmd: BuildPaths                                                         <- retry 60 s later: built

Our workaround

Build Paths is checked by its effect, not trusted by its return. Right after the call, our stage projects known walkable points (a road’s two ends) onto the navmesh and fails if either is off. The pipeline then retries that step as a separate editor job (up to 8 times, 60 s apart), so the editor ticks between attempts and the lock can lift. Waiting inside the same script does not work: it blocks the ticks that release the lock.

Untried alternative: set bWaitForAsyncLoadingBeforeBuildingNavigationAutomatically=False under [/Script/NavigationSystem.NavigationSystemV1], which also gives up the wait for automatic builds.

Suggested fixes (any one helps)

  1. Treat an explicit build like NoUpdateInEditor and let it ignore AsyncLoadLock. It could first call FAssetCompilingManager::Get().FinishAllCompilation(), since Build() already blocks until the navmesh finishes.
  2. Or queue it: remember a refused explicit build and run it when the lock lifts, instead of NoRebuild plus a dirty-area reset.
  3. At least report it: return a result from Build(), have EditorBuild return false or add a Map Check error, and make the !bHasWork case print its own message.

Repro script (E3_repro.py)

"""E3 repro (UE 5.8.3): an explicit Build Paths is refused while ENavigationBuildLock::AsyncLoadLock is held, and the
editor still reports a completed build.

Run in the editor's Python console (or Tools > Execute Python Script) with one argument, one call per step:
  setup   a level from the Basic template with a NavMeshBoundsVolume over its floor, saved to MAP.
  build   Build Paths. Run it a few seconds after setup (it builds), then save the level.
  repro   load MAP and issue Build Paths in the same call. The lock taken at load cannot lift inside this call (it waits
          for 2 s and 16 ticks with no asset compiling), so the log shows "Navigation NOT building because navigation
          build is locked (flags: 0x20)", then Map Check and "Build time 0:00", as for a completed build.
  build   again, a few seconds later: it builds.
"""

# The editor's embedded `unreal` module has no stubs outside the editor, so every member reads as unknown here.
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false
# pyright: reportUnknownParameterType=false
import sys

import unreal  # pyright: ignore[reportMissingImports]  # the editor's embedded module, absent outside it

MAP = "/Game/E3Repro/E3_NavLock"
TEMPLATE = "/Engine/Maps/Templates/Template_Default"


def world() -> unreal.World:
    return unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()


def setup() -> None:
    unreal.EditorLoadingAndSavingUtils.new_map_from_template(TEMPLATE, False)
    actors = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
    floor = [a for a in actors.get_all_level_actors() if a.get_actor_label() == "Floor"]
    if not floor:
        raise RuntimeError("no actor labelled Floor: start from the Basic template")
    centre, half = floor[0].get_actor_bounds(False)
    bounds = actors.spawn_actor_from_class(unreal.NavMeshBoundsVolume, centre)
    # The default brush is 2 m: cover the floor and 2 m above it.
    bounds.set_actor_scale3d(
        unreal.Vector(half.x / 100.0 + 1.0, half.y / 100.0 + 1.0, half.z / 100.0 + 2.0)
    )
    unreal.EditorLoadingAndSavingUtils.save_map(world(), MAP)
    print(
        f"E3 setup: saved {MAP}. Run 'build' a few seconds later, then save the level."
    )


def build() -> None:
    unreal.SystemLibrary.execute_console_command(world(), "BuildPaths")
    print("E3 build: Build Paths issued; the log says whether it built")


def repro() -> None:
    unreal.EditorLoadingAndSavingUtils.load_map(MAP)
    unreal.SystemLibrary.execute_console_command(world(), "BuildPaths")
    print(
        "E3 repro: loaded and Build Paths issued in one call; the log shows the refusal"
    )


STEPS = {"setup": setup, "build": build, "repro": repro}

if __name__ == "__main__":
    STEPS[sys.argv[1] if len(sys.argv) > 1 else "repro"]()