Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Walkmesh (BWM / WOK)

A walkmesh is where a character may stand. It carries the collision and pathfinding surface for an area, the slopes that can be climbed, and the material under each footstep.

The pieces, and what each is for

A walkmesh is a triangle mesh plus four things layered on top of it. The layout tables below name all of them, so it is worth knowing what they are first.

PieceWhat it is
FaceOne triangle, given as three vertex indices. Everything else is per-face.
MaterialA row in surfacemat.2da, one per face. The row’s Walk column decides whether a creature can stand there, which is what walkable means throughout this page.
Normal and planar distanceThe face’s plane, as a direction plus the d in ax + by + cz + d = 0. Together they answer “which side of this triangle am I on”, which is how the engine tests footing and height.
AdjacencyFor each edge of each walkable face, which face lies across it. This is what lets pathfinding walk from one triangle to the next without searching.
Edge and transitionAn edge where the mesh simply stops, annotated with the room on the other side. Walking off the mesh here means moving into another room rather than falling off the world.
PerimeterWhere one boundary loop of the mesh ends. The outer edge of a room is one loop; a pillar in the middle of it is another.
AABB treeA tree of axis-aligned bounding boxes over the faces, so a collision query can discard most of the mesh without testing every triangle.

Three kinds of file share this layout. .wok is a room’s floor, .dwk a door, and .pwk a placeable. They differ in which pieces they populate rather than in structure, and those differences are called out where they matter.


BWM Binary

The binary walkmesh is meant to be read straight into memory. Rather than parsing the file front to back, the engine reaches each block through an offset held in the header.

At a Glance

PropertyValue
Extension(s).bwm, .wok, .dwk, .pwk
Magic SignatureBWM / V1.0, present in every file but not checked by LoadMeshBinary
TypeMemory-Mapped Collision Net
Rust ReferenceView rakata_formats::Bwm in Rustdocs

File Layout

A 136-byte header, then nine data blocks, each located by its own offset out of that header. The blocks are contiguous in practice but nothing requires it, since every one is addressed independently.

BlockElementLocated by
Vertices3 × f32 (12 B), a positionvertex_offset, counted by vertex_count
Face indices3 × u32 (12 B), vertex indices, zero-basedface_indices_offset, counted by face_count
Face materialsu32 (4 B), a surfacemat.2da rowmaterials_offset, face_count again
Face normals3 × f32 (12 B)normals_offset, face_count
Planar distancesf32 (4 B)planar_distances_offset, face_count
AABB nodes44-byte record, laid out belowaabb_offset, counted by aabb_count
Adjacency3 × i32 (12 B), laid out belowadjacency_offset, counted by adjacency_count
Edge transitionsu32 + i32 (8 B), laid out belowedges_offset, counted by edge_count
Perimetersu32 (4 B), an end index into the edge blockperimeters_offset, counted by perimeter_count

Face vertex indices are zero-based, which is measurable rather than assumed: across every walkmesh in chitin.key carrying geometry, the largest index in the block is exactly one below the file’s vertex_count, never equal to it and never negative.

Note that one face_count sizes four separate blocks. Indices, materials, normals and planar distances are parallel arrays over the same faces rather than independent tables.

Adjacency and edge records

OffsetFieldTypeNotes
Adjacency record12 bytesOne per walkable face, three slots, one per edge
0x00slot 0i32See below. Not a face index
0x04slot 1i32
0x08slot 2i32
Edge record8 bytesOne per mesh-boundary edge
0x00indexu32An adjacency slot index. Never negative in any file measured.
0x04transitioni32Destination room index, or -1 for none. The sentinel lives only here.

An adjacency slot stores an edge-slot index, not a face index. Any value other than the sentinel is divided by three to obtain the face, so a consumer reading it directly as a face index is off by a factor of three. -1 means the edge has no neighbour, and the engine compares it as signed and passes it through unchanged rather than converting it.

A -1 result terminates a walk rather than skipping it. The caller that consumes an adjacency lookup treats reaching -1 as arriving at the mesh boundary and stops there. A reader that treats it as “no neighbour on this edge, try the next” produces a different traversal from the engine’s.

The edge block has exactly one record per -1 adjacency slot. That holds in every walkmesh in chitin.key that carries adjacency data, with no exception, and every edge index field points inside the adjacency slot space. So the block is not a free-standing list: it is an annotation on precisely the edges where the mesh stops, saying which room lies beyond each one.

Perimeters

Important

A perimeter is a run of edges, and the array holds where each run ends The block is not a list of things. It is a partition of the edge block: perimeter i covers the edges from the previous entry up to but not including its own value, and the first covers everything before entry zero.

Measured across every walkmesh in chitin.key carrying perimeters: the values are strictly increasing and the last one always equals edge_count, without exception. So the array tiles the edge block exactly, and a mesh with one perimeter has a single entry equal to edge_count. Most have one; a few have two to four.

Each run is most likely one closed boundary loop, an outer perimeter plus a hole for each additional entry. (Provenance: inferred. The bytes establish the partition, not the geometry.)

The block’s length is not free. The last entry must equal edge_count, so a mesh whose edges you have laid out already determines its own final perimeter value.

Face ordering and adjacency_count

Important

Walkable faces come first in the face array, and the adjacency index space depends on it Adjacency values index edge slots of the walkable faces, so the range [0, 3 x adjacency_count) only means anything if those faces are the leading entries of the face block.

Measured across every walkmesh in chitin.key carrying geometry: in every mesh that has adjacency data at all, the walkable faces are exactly the leading block, and their count equals adjacency_count.

Four shipped meshes interleave walkable and non-walkable faces. All four are door walkmeshes carrying no adjacency, no edges and no perimeters, so nothing indexes into them: the ordering holds wherever it is load-bearing, and is not a property of the face array on its own.

So a writer sorting faces must put the walkable ones first and set adjacency_count to their number, rather than sorting for tidiness and hoping the counts line up.

The convention has a cause, and it is not in the binary format at all: the ASCII loader sorts faces into exactly these two buckets against surfacemat.2da and writes them in exactly this order. The tool that produced the shipped binaries did the sort once, there, and the binary inherited the result.

Warning

adjacency_count counts walkable faces, not all faces, and the index space follows Adjacency values do not index the face array. They index edge slots of the walkable faces, so the valid range is [0, 3 x adjacency_count). Bounds-checking them against face_count accepts out-of-range data on every mesh that has non-walkable geometry, and every area mesh does.

Measured across the base archives, adjacency_count equals the walkable-face count in almost every walkmesh, and where it does not every face is walkable so the two readings coincide. Every adjacency entry is in range, with -1 the only negative value that occurs anywhere.

An edge record’s index field indexes the same space and is unique within a file in every mesh carrying them, so it names one specific boundary edge rather than repeating. Its transition is -1 in most entries and a small non-negative room index in the rest.

The bounds check is confirmed at the instruction level rather than inferred from file structure: the engine’s adjacency lookup bounds-checks its entry against face_count while indexing an array sized by adjacency_count. Its caller separately checks a different index against face_count before indexing a face_count-sized array, which is correct, so this is one specific routine checking the wrong count rather than a general confusion about which count governs what.

Transitions

Warning

A non-sentinel transition is used as a room index with no bounds check When the direct-line test finds a transition other than -1, it uses that value immediately to index the area’s room array. Nothing validates it. The only things standing between an out-of-range value and an out-of-bounds read are the -1 sentinel and the target room’s own null flag.

A writer emitting a walkmesh must therefore keep transition either at -1 or inside the room count of the area the mesh belongs to. This is a file-level invariant the format does not enforce and the engine does not check.

Note

The on-disk transitions are what the game trusts, despite appearances The engine contains a full geometric edge-matching routine that walks both rooms’ edges, matches endpoints within a small tolerance, and writes fresh transition values keyed by load order rather than read from any file. It is called for every room pair on every area load, which makes “the on-disk values are recomputed at runtime” a reasonable first reading.

It is wrong, and it is worth recording so nobody re-derives it. The whole body is gated on a flag that the binary load path sets and only the text loader clears. Every retail walkmesh takes the binary path, so for shipped content that recompute is dead code. The flag is a load-source discriminator, not an “adjacency already computed” marker.

Which materials are walkable comes from surfacemat.2da’s walk column. Read from a retail install, the walkable ids are 1, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14, 18 and 30, out of 31 rows.

AABB node (44 bytes each)

OffsetFieldTypeNotes
0x00bounding box6 × f32Min and max corners.
0x18face indexi32-1 on an interior node, a real face index on a leaf.
0x1Cfour further indices4 × i32Two of them are -1 exactly on leaves and hold in-range node indices on interior nodes, which is what makes them the child links.

The tree is a binary partition over the face table: a node either names a face or names two children, never both. In m01aa_01a, 389 nodes resolve to exactly 195 leaves for 195 faces.

Header, counts and offsets (0x480x88)

OffsetFieldOffsetField
0x48vertex_count0x4Cvertex_offset
0x50face_count0x54face_indices_offset
0x58materials_offset0x5Cnormals_offset
0x60planar_distances_offset0x64aabb_count
0x68aabb_offset0x6Caabb_root
0x70adjacency_count0x74adjacency_offset
0x78edge_count0x7Cedges_offset
0x80perimeter_count0x84perimeters_offset

This half of the header is verified against a real file rather than assumed. In test.wok, vertex_offset is 136, exactly the header size, and every subsequent block begins precisely where the previous one ends: 6 vertices at 12 bytes reach 208, which is face_indices_offset; 4 faces reach 256, which is materials_offset; and the chain continues through all nine blocks to end at byte 440, exactly the file’s length. A misread field map does not close like that.

Header, leading region (0x000x48)

OffsetFieldTypeNotes
0x00magicBWM
0x04versionV1.0
0x08walkmesh kindu321 for .wok, 0 for .pwk and .dwk
0x0C, 0x18relative use positions 1 and 2vec3 eachRead by every walkmesh kind.
0x24, 0x30absolute use positions 1 and 2vec3 eachDoors only. See below.
0x3Cpositionvec3

Important

These are two runtime slots, not four independent hooks 0x0C and 0x18 load unconditionally into the object’s two use-position slots. 0x24 and 0x30 are a door-only override: the door loader runs after the shared one and overwrites those same two slots from the absolute pair, but only where the absolute value is nonzero. No placeable-specific override exists at all.

That is exactly why the corpus splits the way it does. Placeables populate only the first pair because nothing ever consumes the second for them; doors populate all four because the door loader actively prefers the absolute values when present. A reader treating all four as one array of hooks gets four positions where the engine has two.

They are use positions in the gameplay sense, meaning where a creature stands to interact. A placeable transforms both slots into world space and picks whichever is nearer the querying creature, falling back to its own object position when slot 1 is zero.

Both halves are now measured across every vanilla walkmesh in the base archives, .wok, .pwk and .dwk alike, rather than from the two fixtures.

0x08 is a kind discriminator, not a count: it holds 1 in every .wok and 0 in every .pwk and .dwk, invariant within kind and unrelated to any file’s vertex count. And the hook region is populated in most vanilla walkmeshes, every .dwk, nearly every .pwk, and the large majority of .wok, so the fixtures that show it zeroed are the outliers. A door walkmesh such as dor_lda010 yields five planar vec3s across 0x0C0x44, exactly the four-hooks-plus-position shape above, and a placeable populates only the first two, which is consistent with a placeable carrying fewer attachment points than a door.

The blocks tile the file exactly, with nothing left over. Every walkmesh carrying geometry is contiguous from byte 136 and ends precisely at the last block. There is no trailing region and no alignment padding anywhere in the format.

The rest are header-only, both .wok and .pwk: every count in the header is zero and the file is exactly 136 bytes, which confirms the header size from a direction the field map does not. Every empty .pwk belongs to a placeable with no collision geometry at all.

Note

An empty walkmesh has two vanilla shapes, and they differ by kind The counts are zero in all of them, but the offsets are not written the same way. 0x4C, 0x54, 0x58, 0x5C and 0x60 hold 136 regardless of kind. The other four, 0x68, 0x74, 0x7C and 0x84, hold 136 in an empty .wok and 0 in an empty .pwk.

Both shapes ship, so neither is wrong, but a writer emitting an empty walkmesh has a choice to make and vanilla does not make it consistently. Writing 136 throughout matches the .wok convention and keeps every offset pointing at the end of the header, which is the reading a consumer is most likely to survive.

0x6C is aabb_root

Two instruments answer this field and they say different things, both correct, so the page carries both.

Traced: 0x6C holds the root node index for the AABB tree at 0x64/0x68. The base loader CSWCollisionMesh::LoadMeshBinary (0x00597120) reads only through 0x480x70; the room-specific override CSWRoomSurfaceMesh::LoadMeshBinary (0x005807c0) reads the rest and stores this word as a plain int. Its consumer is CSWRoomSurfaceMesh::CheckAABBAll (0x00581610), which passes it to CheckAABBNode (0x00580920) as the node to begin descending from. The node array is unordered, so a consumer genuinely needs to be told where the tree starts.

Measured: it is 0 in every static walkmesh carrying geometry, and in every .pwk and .dwk. Vanilla writers emit the root node first and therefore always index zero. The population is a retail install’s base archives; saves were not searched, and a saved walkmesh is not something this measurement speaks to.

The handful of non-zero instances are all header-only .wok and are uninitialised writer memory, not values. They are not one value or one kind of value: several are 0xFFFFFFFF, some are small integers, some read as plausible world coordinates as floats, and two are heap-address shaped. Values recur across unrelated modules, which is what a deterministic build-time allocator leaves behind. The same files prove the field is neither a count nor an offset, since every count around it is 0 and every offset is 136, and it is neither.

A reader should follow the traced meaning rather than the measurement: start the tree walk at aabb_root, which will be zero in every shipped file but is not defined to be. A reader that hardcodes zero happens to work on vanilla and has no reason to.

Note

A placeable’s normals and plane distances are not populated Across every .pwk that carries geometry, all face normals have magnitude zero, as do their planar distances. WOK and DWK are unit-length to within 1e-4 throughout. The layout is identical across all three kinds and this field is not, so a consumer computing lighting or slope from a placeable walkmesh gets zeroes rather than an error.

Engine Audits & Decompilation

Read from CSWCollisionMesh::LoadMeshBinary at 0x00597120 in swkotor.exe. Provenance: derived, not attested unless a claim says otherwise: the rows have not been separately re-derived, so they sit on the reverse-engineering queue. Individual claims below may carry a level of their own, and where one does it overrides this line for that claim.

Pipeline EventGhidra Provenance & Engine Behaviour
Pointer JumpingThe engine does not read the file front to back. It reaches each block by pointer arithmetic from the header.
Ignoring the Magic IDLoadMeshBinary does not check the BWM magic or the version. Signature verification happens elsewhere, before this function runs.
Read-Only FormatNothing in the shipped game writes a BWM. The binary path reads only, so collision data cannot be compiled or saved at runtime.

Note

Nothing has independently confirmed the three rows above They are decompilation-only, and this table is one of the two in the manual whose rows have been refuted by measurement. The offsets and spans that measurement settled are in the header tables further up this page, and the incident is recorded with the provenance ladder. Treat what remains as derived, not attested: the field map above it is the measured half of this page, and these three rows are not.


BWM ASCII

The engine can also read a walkmesh as plain text, parsed line by line at load rather than mapped into memory. No shipped file uses it, so everything below describes what the parser accepts rather than what real files contain.

At a Glance

PropertyValue
Extension(s).bwm (ASCII formatted)
Magic SignatureASCII Text Directives
TypeUncompiled Collision Text

File Structure

A flat sequence of lines. There is no header, no counts block and no terminator on the file as a whole: structure comes from keywords, and the two array directives carry their own counts.

node trimesh <name>
  position    <x> <y> <z>
  orientation <ax> <ay> <az> <angle>
  verts <count>
    <x> <y> <z>            # repeated <count> times
  faces <count>
    <v1> <v2> <v3> <a> <b> <c> <d> <material>    # repeated <count> times
endnode

node dummy pwk_use01
  position <x> <y> <z>
endnode

Lines end at \n and nothing else. The reader copies bytes until it meets a line feed, and a lone carriage return is not a terminator. So a CRLF-authored file parses, with a trailing \r left on the end of every line’s content for whatever consumes it.

A line may be 255 characters plus its terminator, and 256 is a hard failure. The buffer every caller supplies is 0x100 bytes. If it fills before a \n arrives the read returns failure and the loader falls back to a default mesh, so an over-long line does not truncate or corrupt one face. It loses the whole walkmesh.

DirectiveParsed asNotes
node trimesh <name>keyword match on node followed by " trimesh ", both spaces requiredOpens a mesh body
node dummy <name>as above with " dummy "See the use-position note below
node <anything else>n/aSkipped, not rejected
endnodekeywordCloses a mesh body
positionthree floatsMeaning depends on the enclosing node; see below
orientationfour floats, axis then angle, built into a quaternionSee the asymmetry note
verts or verticesa count, then that many lines of three floats eachBoth spellings accepted, and both are case-sensitive
facesa count, then that many lines of exactly eight integersSee below
aabbn/aRecognised; the tree is rebuilt rather than trusted

A face line is eight integers and only four of them survive. Three are the vertex indices and the eighth is the material index. The four in between are parsed into locals and never stored anywhere, and nothing downstream reads them, so a writer emitting zeros there loses nothing.

position is context-sensitive, and one context is a hard error. Inside a trimesh body it sets the mesh’s position. After a use-position dummy node it feeds that hook instead. Reached with neither context open it fails the parse outright rather than being ignored, which makes a stray position line a file-level failure and not a skipped line.

Note

The two runtime use-position slots are a naming convention, not a field The binary half of this page documents two relative use positions in the header and gives no account of where they come from. This is where. A dummy node whose name begins with pwk_use or pwk_dp_use_, matched without regard to case, has the two digits immediately following that prefix read as a literal pair: 01 and 02 select which of the two hooks the node’s own position line fills.

So they are not a dedicated structure anywhere. They are named dummy nodes in the source art, carried through the tool chain into two fixed header slots.

Warning

The placeable and door reader parses orientation and throws it away The room reader stores the quaternion it builds. The placeable and door reader runs the identical parse, builds the identical quaternion, and never writes it anywhere. That is a genuine dead computation rather than an asymmetry with a purpose behind it, and it is worth stating because assuming the two readers are the same is the natural default.

The two also disagree on leading whitespace: the placeable and door reader skips spaces and tabs before matching a keyword, and the room reader skips only spaces. A tab-indented file therefore parses as one and not the other.

Important

This is where the binary format’s walkable-first convention comes from The binary half of this page states as a bare fact that walkable faces lead the face array and that adjacency_count is their number. The reason is here, in the text loader that the binary files were produced from.

After parsing all faces, the room reader looks each face’s material index up against the Walk column of surfacemat.2da. Faces reading 0 go to one bucket and everything else to another, each keeping its original relative order. The final arrays are written walkable bucket first, non-walkable appended, and adjacency_count is set to the walkable bucket’s size.

That is the same rule the binary corpus was measured to obey, arrived at from the opposite direction: not “shipped files happen to be sorted this way” but “the tool that produced them sorted them, once, here.” The convention was inherited rather than designed into the binary format.

It also has a consequence for tooling. Because the sort discards the original ordering, a binary walkmesh converted to this text form and back does not come out with its face indices where they started.

Engine Audits & Decompilation

Read from three sibling LoadMeshText overrides, CSWRoomSurfaceMesh::LoadMeshText (0x00582d70), CSWPlaceableSurfaceMesh::LoadMeshText (0x005cdad0, the .dwk/.pwk reader) and the base CSWCollisionMesh::LoadMeshText (0x00596890, a stub that only sets the text-versus-binary flag), plus the shared line reader CSWCollisionMesh::LoadMeshString (0x005968a0). Provenance: traced. The grammar above and the rows below come from reading those functions.

There is still nothing to check any of it against. Every walkmesh resource in a retail install’s archives opens with the binary BWM signature, and none is ASCII, so no shipped file can confirm or refute a claim on this half of the page. That is why the grammar is stated at the level of what the parser does rather than what files look like.

Pipeline EventEngine Behaviour
Keyword scanThe file is read line by line, each line trimmed of leading whitespace and matched against the directive set in the grammar above. Unrecognised node types are skipped rather than rejected.
Face fieldsEight integers per face, of which the parser keeps the three vertex indices and the material index. The four in between are read and dropped. Adjacency is not taken from the file at all; it is recomputed after load.
Line lengthA hard limit, not a soft one. The line buffer is 0x100 bytes, and a line that fills it without producing a line feed returns failure, at which point the caller loads a default mesh instead.
Face reorderingFaces are bucketed by the Walk column of surfacemat.2da and rewritten walkable-first, with adjacency_count set to the walkable count. See the box above for what this explains about the binary format.
Bounding boxThe AABB limits are expanded outward by roughly 0.01 on every axis. Because the faces have moved, the loader also builds a temporary remap table to track where each one went.