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.
| Piece | What it is |
|---|---|
| Face | One triangle, given as three vertex indices. Everything else is per-face. |
| Material | A 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 distance | The 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. |
| Adjacency | For 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 transition | An 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. |
| Perimeter | Where 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 tree | A 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
| Property | Value |
|---|---|
| Extension(s) | .bwm, .wok, .dwk, .pwk |
| Magic Signature | BWM / V1.0, present in every file but not checked by LoadMeshBinary |
| Type | Memory-Mapped Collision Net |
| Rust Reference | View 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.
| Block | Element | Located by |
|---|---|---|
| Vertices | 3 × f32 (12 B), a position | vertex_offset, counted by vertex_count |
| Face indices | 3 × u32 (12 B), vertex indices, zero-based | face_indices_offset, counted by face_count |
| Face materials | u32 (4 B), a surfacemat.2da row | materials_offset, face_count again |
| Face normals | 3 × f32 (12 B) | normals_offset, face_count |
| Planar distances | f32 (4 B) | planar_distances_offset, face_count |
| AABB nodes | 44-byte record, laid out below | aabb_offset, counted by aabb_count |
| Adjacency | 3 × i32 (12 B), laid out below | adjacency_offset, counted by adjacency_count |
| Edge transitions | u32 + i32 (8 B), laid out below | edges_offset, counted by edge_count |
| Perimeters | u32 (4 B), an end index into the edge block | perimeters_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
| Offset | Field | Type | Notes |
|---|---|---|---|
| Adjacency record | 12 bytes | One per walkable face, three slots, one per edge | |
0x00 | slot 0 | i32 | See below. Not a face index |
0x04 | slot 1 | i32 | |
0x08 | slot 2 | i32 | |
| Edge record | 8 bytes | One per mesh-boundary edge | |
0x00 | index | u32 | An adjacency slot index. Never negative in any file measured. |
0x04 | transition | i32 | Destination 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.keycarrying perimeters: the values are strictly increasing and the last one always equalsedge_count, without exception. So the array tiles the edge block exactly, and a mesh with one perimeter has a single entry equal toedge_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.keycarrying geometry: in every mesh that has adjacency data at all, the walkable faces are exactly the leading block, and their count equalsadjacency_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_countto 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.2daand 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_countcounts 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 againstface_countaccepts out-of-range data on every mesh that has non-walkable geometry, and every area mesh does.Measured across the base archives,
adjacency_countequals 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-1the only negative value that occurs anywhere.An edge record’s
indexfield 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. Itstransitionis-1in 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_countwhile indexing an array sized byadjacency_count. Its caller separately checks a different index againstface_countbefore indexing aface_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
transitionis used as a room index with no bounds check When the direct-line test finds atransitionother 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-1sentinel and the target room’s own null flag.A writer emitting a walkmesh must therefore keep
transitioneither at-1or 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
transitionvalues 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)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | bounding box | 6 × f32 | Min and max corners. |
0x18 | face index | i32 | -1 on an interior node, a real face index on a leaf. |
0x1C | four further indices | 4 × i32 | Two 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 (0x48–0x88)
| Offset | Field | Offset | Field |
|---|---|---|---|
0x48 | vertex_count | 0x4C | vertex_offset |
0x50 | face_count | 0x54 | face_indices_offset |
0x58 | materials_offset | 0x5C | normals_offset |
0x60 | planar_distances_offset | 0x64 | aabb_count |
0x68 | aabb_offset | 0x6C | aabb_root |
0x70 | adjacency_count | 0x74 | adjacency_offset |
0x78 | edge_count | 0x7C | edges_offset |
0x80 | perimeter_count | 0x84 | perimeters_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 (0x00–0x48)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | magic | BWM | |
0x04 | version | V1.0 | |
0x08 | walkmesh kind | u32 | 1 for .wok, 0 for .pwk and .dwk |
0x0C, 0x18 | relative use positions 1 and 2 | vec3 each | Read by every walkmesh kind. |
0x24, 0x30 | absolute use positions 1 and 2 | vec3 each | Doors only. See below. |
0x3C | position | vec3 |
Important
These are two runtime slots, not four independent hooks
0x0Cand0x18load unconditionally into the object’s two use-position slots.0x24and0x30are 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 0x0C–0x44, 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,0x5Cand0x60hold136regardless of kind. The other four,0x68,0x74,0x7Cand0x84, hold136in an empty.wokand0in 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
136throughout matches the.wokconvention 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 0x48–0x70; 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
.pwkthat carries geometry, all face normals have magnitude zero, as do their planar distances. WOK and DWK are unit-length to within1e-4throughout. 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 Event | Ghidra Provenance & Engine Behaviour |
|---|---|
| Pointer Jumping | The engine does not read the file front to back. It reaches each block by pointer arithmetic from the header. |
| Ignoring the Magic ID | LoadMeshBinary does not check the BWM magic or the version. Signature verification happens elsewhere, before this function runs. |
| Read-Only Format | Nothing 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
| Property | Value |
|---|---|
| Extension(s) | .bwm (ASCII formatted) |
| Magic Signature | ASCII Text Directives |
| Type | Uncompiled 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.
| Directive | Parsed as | Notes |
|---|---|---|
node trimesh <name> | keyword match on node followed by " trimesh ", both spaces required | Opens a mesh body |
node dummy <name> | as above with " dummy " | See the use-position note below |
node <anything else> | n/a | Skipped, not rejected |
endnode | keyword | Closes a mesh body |
position | three floats | Meaning depends on the enclosing node; see below |
orientation | four floats, axis then angle, built into a quaternion | See the asymmetry note |
verts or vertices | a count, then that many lines of three floats each | Both spellings accepted, and both are case-sensitive |
faces | a count, then that many lines of exactly eight integers | See below |
aabb | n/a | Recognised; 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
dummynode whose name begins withpwk_useorpwk_dp_use_, matched without regard to case, has the two digits immediately following that prefix read as a literal pair:01and02select which of the two hooks the node’s ownpositionline 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
orientationand 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_countis 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
Walkcolumn ofsurfacemat.2da. Faces reading0go 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, andadjacency_countis 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 Event | Engine Behaviour |
|---|---|
| Keyword scan | The 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 fields | Eight 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 length | A 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 reordering | Faces 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 box | The 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. |