Skip to main content

.nvm — Navigation Mesh

Verified via reverse engineering

Traced live via GhidraMCP against FarCry2_server. Covers the level-file/sector-file container structure, the per-sector header, and the full field-order layout of a sector's content (node graph, vertex positions, cover points, obstacles, spatial index) — but not yet the byte layout inside those per-element classes themselves (CNavMeshNode, CNavCover, etc. — see Unknowns). Before this pass, .nvm had no reverse-engineering work behind it at all — see the file manifest, where it was the one format with a "Locked" status and zero RE.

Confirmed built on the open-source Recast navmesh library (community-reported, via a leaked internal build-tool plugin list — RecastNavmeshCompiler/Exporter; not independently re-verified here). This page covers Dunia's own file/header structure wrapping whatever Recast-derived mesh data lives inside a sector — not Recast's own format.

A two-tier file scheme, unlike every other per-sector format

Every other per-sector format documented so far (.sdat, .srl, .zsr) packs one physical file per sector, addressed by a flat or 2D index. .nvm is structured differently: one level file (nv\nv.nvm) holds a header plus a per-sector descriptor table, and — depending on a mode flag read from that header — the actual sector mesh data lives in separate satellite files, nv\sectors\nv_<index>.nvm (decimal index, no padding), each loaded independently.

Both paths are built by CNavMeshLevel::MakeSectorFileName(name, index, bool) (0x09a0c590), which switches format entirely on its bool argument:

  • true<root>nv\nv<index>_<index>.nvm (same index formatted twice — not two coordinates, despite the two %ds)
  • false<root>nv\sectors\nv_<index>.nvm — the per-sector satellite file path

Versioned serialization, not a size split

Every SerializeData-family function in this format (level, sector, and sector-content) is a dual-direction archive function in the CryEngine-lineage "serialize" idiom: the same code path handles both save and load, branching on the CNavArchive's own internal write-mode flag rather than being split into separate reader/writer functions.

Every one of these functions also guards blocks of fields behind a check on CNavArchive+0x48 — an integer carried by the archive itself, not the sector or level. Initially this looked like a size threshold ("small vs. large navmesh"), but CNavMeshSector::SerializeDataContent alone checks it against eight different graduated values (0x10000, 0x125ff, 0x13000, 0x13200, 0x133ff, 0x13400, 0x134ff, 0x13600) to decide whether to read/write successive optional field blocks. That many distinct thresholds only makes sense as a stored format version number, each threshold marking a point where a new field or array was added to the format — standard incremental-versioning serialization, not a small/large split. Treat CNavArchive+0x48 as "format version" going forward.

CNavMeshLevel::SerializeData — the level-file header

CNavMeshLevel::SerializeData(CNavArchive&) (0x09a0e210) reconstructed field order for the level-file header, in the newest-version branch:

u32 field_0x54 ┐
u32 field_0x58 │
u32 field_0x5c │ six header words, semantics not decoded — populated from a shared
u32 field_0x60 │ zero-initialized global on old-format archives, read individually on new ones
u32 field_0x64 │
u32 field_0x68 ┘
u32 field_0x6c (version >= 0x10000 only)
u32 field_0x70 (version >= 0xffff only)
--- CNavMeshLevel::InitSectorMatrices(this) runs here, presumably deriving grid dimensions from the above ---
u32 modeFlag (version >= 0x10000 only; older archives default this to 0) — 0/2 both mean
"single-file", 3 means "per-sector satellite files" (see below)
u32 sectorCount
u32[sectorCount] sector descriptor table — raw u32 per sector, non-zero = "this sector has data"
if modeFlag == 3:
for each non-zero descriptor: CNavMeshLevel::LoadIndSector(index, ...) reads
nv\sectors\nv_<index>.nvm as its own standalone CNavArchive

The write side mirrors this exactly: for modeFlag == 3, each non-null sector gets its own MakeSectorFileName(index, false) path and a fresh CNavArchive, which the sector serializes itself into before the resulting size/handle is recorded back into the level file's descriptor slot.

LoadIndSector — per-sector load

CNavMeshLevel::LoadIndSector(sectorIndex, CNavArchive*, buffer, size) (0x09a0d440) either takes an already-loaded buffer or opens nv\sectors\nv_<sectorIndex>.nvm itself via MakeSectorFileName, wraps it in a CNavArchive, allocates a CNavMeshSector (0x78 / 120 bytes), and calls its virtual SerializeData (see below) to deserialize the sector's content. After a successful load it does spatial-region culling against CWorldRegion::Includes (sectors outside the currently-relevant world region get dropped via CNavMeshSector::DeleteSector rather than kept resident), updates two bitmask grids at this+0x84/this+0xa0 (present/pending-load flags per sector, same bit-per-sector-index pattern seen in other systems this session), fires a CNavMeshSector::NotifySectorEvent, and clears CPathManager's cached pathfinding results — a loaded sector invalidates any in-flight path queries that might have assumed it was still absent.

CNavMeshSector::SerializeData — the real per-sector payload

CNavMeshSector::SerializeData (0x09a21d20) is vtable slot 0, the method LoadIndSector calls through a virtual dispatch. It splits into two: SerializeDataHeader (0x09a21710) then, if that succeeds, SerializeDataContent (0x09a1e780) — by far the richest function traced in this whole format.

Header (SerializeDataHeader): sector id/coordinates and bounding box (already known from the constructor), followed by two constant-looking values written unconditionally on save — 0x4e764d68 (reads as ASCII hMvN in raw byte order — plausibly a per-sector magic/tag) and 0x14100 — then a computed GetReloadSize(sector) value. On load, the equivalent slot is read back and compared against the archive's version field, and the whole header read fails (returns 0) if they disagree — a real version/consistency check, not just informational.

Content (SerializeDataContent), in field order:

u16 sectorX, sectorY (already known from the constructor)
f32 bbox[4] (already known)
u32 field_0x6c (default 0x4f800000 = ~4.29e9, a sentinel-looking float)
u32 field_0x70, field_0x74 (a pair; falls back to CNavmeshEdition::GetInstance()'s own
+0x54/+0x58 fields when unset — editor-time defaults)
u16 field_0x5c
--- CNavArchive::SetPackedVectorSettings() runs here: every vec3 below this point is quantized to
3×int16, scaled relative to this sector's own bounding-box center/extent ---
u32 field_0x64
u32[field_0x64] a raw (no per-element parser) block of u32s
u32 nodeCount
CNavMeshNode[nodeCount] the actual navmesh graph — 60 bytes each, own SerializeData
f32vec3[nodeCount] → packed quantized vertex positions (one per node, 6 bytes each on disk)
u32 coverCount
CNavCover[coverCount] static AI cover points — 28 bytes each, own SerializeData
u32 dynCoverCount
CDynamicNavCover[dynCoverCount] dynamic/toggleable cover points — 40 bytes each, own SerializeData
f32vec3[] → packed a second quantized vertex-position array (purpose distinct from the first,
not identified — candidate: edge midpoints or off-mesh link endpoints)
CNavMeshQTree a spatial index over the node list, built fresh from node positions via
CNavMeshQTreeWriter and serialized inline — baked into the file, not
rebuilt at load time
--- everything below this point is version-gated (see above) and progressively larger version numbers
unlock more of it ---
u32 obstacleCount (version >= 0x13600)
CNavMeshObstacle[obstacleCount] dynamic blockers — 40 bytes each, own SerializeData
f32vec3[] → packed additional quantized vertex arrays (version >= 0x13000 / >= 0x134ff)
CNavMeshQTree (again) a second CNavMeshQTree is allocated and its own virtual SerializeData
called unconditionally at the very end — relationship to the inline one
above not resolved (see Unknowns)

AfterLoad(sector) runs as the final step on the read path — a post-processing hook, presumably rebuilding runtime-only derived structures (adjacency, the live A* graph) from what was just deserialized, before the sector is marked ready (this[0x5e] = 0).

This gives a genuinely complete structural map of what a navmesh sector contains: a polygon/node graph (CNavMeshNode), quantized vertex positions, two flavors of AI cover point, dynamic obstacles, and a baked spatial index — everything needed to actually decode geometry now has a named target class and a known position in the byte stream.

Unknowns

  • The semantic meaning of the level-file header fields (+0x54 through +0x70) and the sector-content scalar fields (+0x5c, +0x64, +0x6c, +0x70/+0x74) — only their storage location and read/write order are confirmed, not what they represent.
  • The byte layout inside CNavMeshNode, CNavCover, CDynamicNavCover, CNavMeshObstacle, and CNavMeshQTree — each has its own SerializeData, none opened yet. This is the natural next layer: opening these five gets to the actual triangle/graph/cover-point field values.
  • The purpose of the second quantized-vertex array, and the relationship between the inline CNavMeshQTreeWriter-built tree and the second CNavMeshQTree serialized unconditionally at the end of SerializeDataContent — possibly one is a full-precision editor-time tree and the other a runtime-optimized rebuild, not confirmed.
  • What exactly selects modeFlag 0/2 vs. 3 (single-file vs. per-sector-satellite-files) — whether it's a global setting, a per-level authoring choice, or tied to the format version the same way the header-length gate is.
  • Whether nv\nv.nvm (the writer's "same index twice" MakeSectorFileName(true) branch) is ever actually reached in practice, or is dead/legacy code — no confirmed caller was found using that branch during this pass.