Offline navigation mesh generation for Source engine maps.
Meshwright builds .nav files from a compiled .bsp without launching the game. It replaces typing
nav_generate at the console: it runs as an ordinary program, uses every core on the machine, fits
into a build script, and tells you what it did.
It works as a Compile Pal plugin or as a standalone command-line tool.
- Why not
nav_generate? - Installation
- Usage
- Options
- Diagnostics
- Results
- Limitations
- Building from source
- Licence
The engine's generator works, and for many maps it is enough. These are the problems it does not solve.
It only runs inside the game. You have to load the map to generate a mesh for it, which makes it awkward to script and impossible on a build machine with no game installed.
It is single-threaded and blocks the client. On a large map you watch a frozen game and cannot cancel.
It builds no ladders outside Left 4 Dead. CNavMesh::BuildLadders is compiled out elsewhere, and
even where it runs it only looks for func_simpleladder entities. Ordinary brush ladders are
invisible to it, so bots cannot use them.
It ignores lifts. Elevator platforms get no connections, so a working lift is a dead end.
It cannot explain itself. When a mesh comes out wrong you get a mesh and nothing else. Working out why a staircase was missed means looking around in game and guessing.
Meshwright addresses all five.
nav_generate |
Meshwright | |
|---|---|---|
Runs from a .bsp alone |
No | Yes |
| Uses multiple cores | No | Yes |
| Scriptable / CI-friendly | No | Yes |
| Brush ladders | Left 4 Dead only | Yes |
| Lift and elevator stops | No | Yes |
| Static prop collision | Yes | Yes |
Visibility (nav_analyze) |
Yes, in game | Yes, offline |
| Hiding spots | Yes | Yes |
| Sniper spots and encounters | Only with nav_quicksave 0 |
Always |
| Diagnostics | No | Yes |
- Download
Meshwright-plugin.zipfrom the releases page, or build it with./build-plugin.ps1 -Zip. - Extract the
Meshwrightfolder into Compile Pal'sPluginsdirectory. - Restart Compile Pal, press + on the process list, and add Meshwright.
Nothing is compiled into Compile Pal and there is no fork to run. The step exists because the folder exists, and disappears if you delete it. It runs at order 8.5, after Compile Pal's own NAV step and after the BSP has been copied to your maps folder, so it operates on the map the game will load. Every option below has a corresponding checkbox.
Download meshwright.exe from the releases page, or build it yourself. See
Building from source.
meshwright generate map.bspThis finishes the .nav beside the BSP and writes it back in place: ladders, movement connections,
cover spots and visibility.
To also find walkable ground the mesh is missing, or to build a mesh where there is none:
meshwright generate map.bsp -generateareasmeshwright batch maps/ -generateareasRuns the same build over every .bsp in a directory, or over the files and patterns you name,
finishing each .nav in place. Every option below applies. One map failing does not stop the rest:
failures are reported as they happen, listed again at the end, and set a non-zero exit code.
Each staged command writes a new file rather than editing in place, so you can inspect any stage and step back. Run them in this order, because later passes read what earlier ones wrote.
meshwright build-areas map.bsp map.nav -o map.areas.nav
meshwright build-movement map.bsp map.areas.nav -o map.movement.nav
meshwright build-ladders map.bsp map.movement.nav -o map.ladders.nav
meshwright build-spots map.bsp map.ladders.nav -o map.spots.nav
meshwright build-visibility map.bsp map.spots.nav -o map.final.navbuild-spots only matters for games whose bots use cover positions, and needs build-movement to
have run first. If you have no mesh at all, add -scratch to build-areas to generate one from the
map's player spawns.
generate and the staged commands drive the same pipeline, so both produce the same mesh.
meshwright info map.navmeshwright verify map.navinfo summarises a mesh and confirms every id in it resolves. verify reads and rewrites it, then
diffs byte for byte.
Available on generate; each has a Compile Pal checkbox.
| Option | Effect |
|---|---|
-generateareas |
Find walkable ground the mesh is missing and add it |
-scratch |
Discard the existing mesh first. Requires -generateareas |
-noladders |
Skip nav ladders for the map's ladder brushes |
-nomovement |
Skip stair marking and step, jump, drop and lift connections |
-nospots |
Skip hiding spots, sniper grading and encounter spots |
-nosnipers |
Find cover spots but do not grade sniper positions |
-noencounters |
Find cover spots but do not build encounter spots |
-novisibility |
Skip area-to-area visibility, by far the slowest stage |
-nocompress |
Store full visibility instead of Valve's delta encoding |
-maxviewdistance N |
How far two areas can see each other. Default 6000 |
-pruneunreachable |
Delete small groups of areas no player spawn can reach |
-resume |
Cache the mesh between runs and reuse it when only later settings changed |
-o <path> |
Write somewhere other than in place |
These apply to every command, not just generate:
| Option | Effect |
|---|---|
-threads N |
Cap parallel work. Default is every core |
-game NAME |
Movement limits: cs/css/csgo, or gmod/hl2/tf2/source (default) |
-content DIR |
Extra directory to resolve prop models from. May be repeated |
-content matters more than it looks. Meshwright works out where the game's content lives from the
map's own path: a .bsp sits in <mod>/maps, so the mod directory is two levels up. That covers the
normal case and nothing else: a BSP in a build server's output directory infers a mod directory that
does not exist, so no model resolves, no prop is collided against, and the mesh floats over every one
of them. The run still succeeds. Point -content at the mod directory and it resolves normally.
You only need to name the mod directory, not each game it inherits from. Meshwright reads the mod's
gameinfo.txt and mounts what its SearchPaths block declares, the same list, in the same order,
that the engine uses, so a mod inheriting from a base game gets that game's content too.
meshwright generate out/map.bsp -content "C:/steamapps/common/GarrysMod/garrysmod"-resume keeps the mesh as it stands after the movement passes in <map>.bsp.mwresume, and reuses it
next time. That covers about a third of a run: area generation, ladders, connections, clipping,
stairs and lifts. Visibility is the other two thirds and is always recomputed, so this makes tuning
-maxviewdistance or adding visibility to a -novisibility run cheaper; it does not make a repeat
build instant.
It rebuilds whenever anything upstream could have changed the mesh: the map, the .nav it started
from, the options above that feed those passes, -game, -content, or Meshwright itself. Every doubt
rebuilds, and the run says which input moved. The staged build-* commands do the same job explicitly
if you would rather control it yourself.
-game is not cosmetic. Counter-Strike uses a 58-unit crouch jump, a 200-unit survivable drop and a
58-unit climb; everything else uses 64, 400 and 200. The same map generates a different mesh under
each, because a ledge 62 units up stops being climbable. Nothing in the BSP says which is right, so
you have to tell it.
nav_generate has no equivalent for these. Each answers one specific question about a mesh you are
unhappy with.
| Command | Answers |
|---|---|
meshwright fit <bsp> <nav> |
Which areas float above the ground or hang over air, and by how much |
meshwright shape <bsp> <nav> |
Which areas have grown into walls |
meshwright reachable <bsp> <nav> |
What a bot can walk to from a spawn, and what is stranded where |
meshwright compare-areas <ref.nav> <nav> |
How much of a known-good mesh this one covers |
meshwright compare-areas ... -reachable <bsp> |
The same, counting only reference ground a player can actually walk to |
meshwright stairs <bsp> <nav> -at x y z |
Why one area was or was not marked as stairs |
meshwright reach <bsp> x y z |
Why the generator never reached a spot it should have |
meshwright floors <bsp> x y |
Every surface found in one vertical column |
meshwright probe <bsp> x1 y1 z1 x2 y2 z2 |
What blocks one straight line |
meshwright props <bsp> |
Every static prop, which models resolved, how much collision was found |
meshwright disp <bsp> |
What every displacement reconstructed to |
meshwright vis-compare <bsp> <analysed.nav> |
How computed visibility scores against an analysed mesh |
If anything rewrites the BSP after the mesh is built, re-stamp it as the last step of the build:
meshwright stamp map.bspA mesh records the size of the BSP it was built for, and the engine prints
Warning! .nav file is out of date! when that no longer matches the map it is loading. Packing
content into the BSP, repacking it, or moving its entity lump out all change that size, and all of
them run after the mesh is built, because a mesh has to be built while the entities are still there
to read. The mesh is not stale in that case, only the stamp is. This rewrites the one field and
nothing else.
Compile Pal does this for you: it knows which of its own steps rewrote the BSP, so it re-stamps any mesh beside the map once the compile has finished. The command is here for every other pipeline.
Run meshwright with no arguments for the full list.
Two maps, both public, both scored against a mesh the game generated for the same map. Machine is an 8-core Ryzen 7 6800H, 16 threads.
gm_construct ships with Garry's Mod. rp_downtown_tits_v2 is a large roleplay map, 19,275 areas in
the engine's own mesh, and is the harder case by some distance.
The measure that shows up first in game is whether an area is on the ground. meshwright fit traces
down from 25 points on every area and reports the gap.
| gm_construct | rp_downtown_tits_v2 | |||
|---|---|---|---|---|
| engine | Meshwright | engine | Meshwright | |
| Areas | 2,271 | 2,753 | 19,275 | 22,963 |
| Connections | 11,446 | 14,659 | 85,083 | 104,216 |
| Height error, mean | 1.0 | 1.0 | 2.9 | 0.6 |
| Height error, median | 0.3 | 0.2 | 0.3 | 0.0 |
| Areas floating above the floor | 1 | 0 | 924 (4.8%) | 18 (0.1%) |
| Areas over open air | 3 | 0 | 19 | 1 |
On gm_construct the two are level. On the large map they are not: the engine leaves 924 areas above the floor and 19 over nothing.
| gm_construct | rp_downtown_tits_v2 | |
|---|---|---|
| Coverage of the engine's ground a player can reach | 98.0% | 90.1% |
| Coverage of every engine area including stranded | 88.4% | 88.6% |
| Areas on ground the engine's mesh does not have | 5.3% | 40.0% |
Score against reachable ground rather than against every area, because an engine mesh contains ground
nothing can path to. gm_construct's own mesh strands 224 of its 2,271 areas that its own connection
graph cannot reach. Counting those as misses is what drops the second row to 88.4%.
compare-areas -reachable <map.bsp> does the filtering.
90.1% on the large map is the weakest result here. Most of the missing ground is ground the sampling
flood never reached; the rest was judged unstandable, had no floor found in its column, or was pulled
back out of geometry by the clip. build-areas -reference <known-good.nav> breaks the misses down by
cause.
rp_downtown_tits_v2, both tools running the same passes: areas, movement, ladders, hiding spots,
sniper grading, encounter spots and visibility.
| time | |
|---|---|
nav_generate, one core, game blocked |
about 80 minutes |
| Meshwright, 16 threads | 255 seconds |
gm_construct, the same passes, takes 8.6 seconds.
Runs are not yet bit-for-bit reproducible across different thread counts. On
rp_downtown_tits_v2 the same build produces 25,382 areas at some thread counts and 25,379 at
others, a difference of one sampled cell out of 562,905, and which counts disagree is not itself
stable. Repeat runs at a fixed thread count do agree. This is a
known defect rather than a property being claimed.
A timing comparison is worthless unless it says what nav_quicksave was set to. It defaults to 1, and
both ComputeSniperSpots and ComputeSpotEncounters return immediately when it is, so a stock
nav_generate skips both phases and finishes far sooner than the figure above. The run measured here
used nav_quicksave 0, so both tools did the same work. To compare against the default instead, run
Meshwright with -nosnipers -noencounters.
A prop whose model you do not have is invisible. Collision comes from each model's .phy, found
through the map's embedded pakfile, the loose game and addon directories, .gma workshop archives and
then VPKs, the order the engine mounts them, taken from the mod's own gameinfo.txt where there is
one. Anything not found contributes nothing, and the mesh will float where those props are.
meshwright props <bsp> reports the split; check it if a mesh looks wrong, because nothing else will
tell you. If the content exists but the map is not inside the game directory, -content is the fix.
A missing hull is left missing rather than replaced with the model's bounding box. Substituting a box
measured worse: the models that lack a .phy are typically bushes, tree cards and skybox props, and a
box around those deletes the ground beside them.
Generated meshes are more fragmented than the engine's. Props are solid here, so an area cannot grow across one and ends at it instead, and slivers too narrow to walk are discarded. The mesh is more truthful and a bot will not walk into scenery, but it is larger and more broken up.
An obstacle in the middle of an area is left there. Clipping pulls the four edges back out of
geometry, which is the only shape of correction it can make. A pillar or a post that ends up inside
an area, rather than at its edge, stays inside it and nothing splits the area around it. It is a
small residue: on rp_downtown_meowy 11 of 14,268 areas contain any solid at all, and 0.01% of the
sampled interior sits inside geometry. meshwright shape <bsp> <nav> reports it.
Some areas cannot be reached from a player spawn. Every run reports the count;
meshwright reachable shows where. -pruneunreachable deletes them, capped to groups of eight or
fewer, and refusing outright if the result would remove more than a third of the mesh.
It is off by default because deleting it measures worse. Most unreachable ground is real ground the
movement pass failed to link, and the engine walks it perfectly well: on rp_downtown_tits_v2 pruning
removes 665 areas and takes coverage of the engine's reachable ground from 90.1% to 88.2%. A stranded
area costs nothing at runtime, since nothing can path into it. Deleting real ground does cost
something.
The engine strands areas too: 1,032 of the 19,275 in its own mesh for that map, 5.4%. Meshwright strands 7.0%, which is the worse of the two figures, though 276 of its 430 stranded groups are a single area.
Some collision questions use a line rather than a swept box. Movement between samples is tested with a proper hull sweep against world brushes, brush entities and displacement terrain. "Is there room standing right here" is a point question, and a zero-length sweep is degenerate for a brush clipper, so headroom is still a line trace. It can drop through a grating or a gap slightly narrower than itself.
Stair marking is limited by area shape, not by the test. Run over the engine's own areas the classifier reproduces the engine's verdicts. Run over a mesh generated here it finds fewer, because an area that runs off the end of a flight or picks up a neighbouring ramp is correctly rejected. The shape is wrong, not the verdict.
Generated areas are marked experimental. -generateareas warns on every run. Review the result
before shipping it.
Requires the .NET 10 SDK.
dotnet build -c Releasedotnet test./build-plugin.ps1 -ZipGPL-3.0. See LICENSE.
Meshwright reimplements algorithms Valve documents in the public Source SDK 2013 and contains no Valve source code. Please read NOTICE.md before contributing. In particular, do not paste code from the SDK or from any leaked or decompiled source.