Map Renderer Architecture
The Warcraft III map renderer (games/warcraft-3/renderer/w3m/) is responsible for turning a parsed Warcraft III terrain file (war3map.w3e) into textured 3-D geometry on screen. It is compiled into the compound renderer library beside the generic engine renderer sources in renderer/; the server and client never touch the geometry directly.
High-Level Pipeline
Each frame the renderer executes the following passes in order:
R_RenderFogOfWar → update fog-of-war render targets
R_RenderShadowMap → depth-only pass into RT_DEPTHMAP
R_RenderView → colour pass: ground + cliffs, then entities,
then water depth/color, transparent MDX geosets,
supported selection overlays + particles
R_DrawWorld is called in both the shadow-map and the colour passes. It iterates every MAPSEGMENT and draws its GROUND and CLIFF layers. R_DrawAlphaSurfaces is called only in the colour pass; it establishes water depth, blends water color, emits lightning/weather, and then draws transparent MDX geosets.
Data Loading
R_RegisterMap is the entry point called by the client when a map filename arrives via svc_serverdata:
ri.FileExtractcopies the map MPQ from the main archive to a temp path (TMP_MAP).SFileOpenArchiveopens the extracted MPQ.FileReadWar3Mapreadswar3map.w3eand populates aWAR3MAPstruct:- Header, version, tileset character (
map->tileset). - Ground tile ID list (
map->grounds, lengthmap->num_grounds) and cliff ID list (map->cliffs, lengthmap->num_cliffs). - Map dimensions in vertices:
map->width×map->height. - World-space centre offset
map->center(loaded directly from the file). - Flat vertex array
map->vertices—map->width × map->heightentries, eachMAP_VERTEX_SIZEbytes. R_FileReadShadowMapreadswar3map.shdand uploads it as an inverted greyscale texture totr.texture[TEX_SHADOWMAP].R_LoadMapSegmentspartitions the vertex grid into segments. Cliff triangles remain in a map-wide CPU bake untilR_FinishCliffswelds seams and uploads the per-segment/material ranges. Ground layers are built afterward.
Segment System
The map is divided into axis-aligned segments of SEGMENT_SIZE × SEGMENT_SIZE tiles (8 × 8 by default, defined in games/warcraft-3/common/mapinfo.h). Every segment is a MAPSEGMENT node in the singly-linked list g_mapSegments.
Map grid (width-1) × (height-1) tiles
└── [(width-1)/8] × [(height-1)/8] segments
└── each segment: MAPLAYER linked list
├── WATER layer (built first, drawn last)
├── CLIFF layer(s) (one per cliff ID in map->cliffs)
└── GROUND layer(s) (one per ground ID, highest index first)
Each segment stores a BOX3 bbox used for frustum culling in R_DrawSegment. If the bounding box is outside the current view frustum the entire segment — all layers — is skipped.
Layer Render Order Within a Segment
R_DrawSegment receives a bitmask that selects which layer types to draw. When the first layer in the linked list is being drawn, blending is disabled (opaque base pass). All subsequent layers within the same segment draw with GL_SRC_ALPHA / GL_ONE_MINUS_SRC_ALPHA blending so that higher ground textures alpha-blend over lower ones at tile boundaries.
Because MAPLAYERTYPE_WATER is masked out of R_DrawWorld and handled separately in R_DrawAlphaSurfaces, the water pass first writes water depth with color writes disabled, then alpha-blends the water color without depth writes. Opaque geometry above the water can occlude it; the depth prepass also hides transparent MDX fragments below the water surface. R_DrawAlphaSurfaces then renders the MDX blended geosets and per-instance translucent geosets over the water. Particle rendering and water/deck-supported selection, hover, and indicator overlays follow that pass so they remain visible at their authored support height.
Ground Layers (r_war3map_ground.c)
Tile Selection
Each ground layer corresponds to one entry in map->grounds[]. The per-vertex ground field (0-based index into map->grounds) determines which texture is painted on each tile corner.
GetTile(mv, layer) returns a 4-bit index (0–15) describing the blend shape at the boundary between layer layer and the layer below it. For the bottom-most layer (layer 0) every tile is assigned index 15 (fully covered).
// games/warcraft-3/renderer/w3m/r_war3map_utils.c
DWORD GetTile(LPCWAR3MAPVERTEX mv, DWORD ground) {
if (ground == 0) return 15;
return (mv[0].ground >= ground ? 4 : 0) +
(mv[1].ground >= ground ? 8 : 0) +
(mv[2].ground >= ground ? 1 : 0) +
(mv[3].ground >= ground ? 2 : 0);
}
The 4-bit index selects one of 16 blend shapes laid out in a 4×4 sub-tile atlas inside the ground texture (each sub-tile is 64×64 texels).
UV Mapping and Seam Bleeding
SetTileUV maps each tile quad to the correct atlas cell. To suppress texture bleeding at sub-tile borders the UV coordinates are nudged 5% inward toward the cell centre:
Quirk — tile 15 and double-wide textures: when
tile == 15and the texture is wider than it is tall (a "variation" sheet), the tile index is replaced withmv->groundVariationand the U coordinate is shifted byux = 0.5to address the right half of the texture. The left half contains the normal set of 16 tiles and the right half contains variation tiles.
Cliff and Ramp Tiles Are Skipped
R_MakeTile refuses to emit geometry for a tile if:
IsTileCliff(tile) && GetTileRamps(tile) < 4— at least one vertex has a differentlevelfrom the others, and fewer than all four vertices are ramp vertices. Cliff-face geometry is handled by the cliff layer instead.- A flat cell with two ramp flags and one computed mid-ramp corner is skipped only when an adjacent transition's
low-side footprint actually covers it. A mid-ramp corner alone is insufficient: it can touch unrelated flat ground.
R_TileHasGround(map,x,y)is shared by ground, splats, Blight corner preservation and cliff joins.
NightElfX04 isolated holes (#615)
The inspected TFT W3E has size 161x97, centre (-6144,-5632), and cliff palette CZdi,CZlb.
After the renderer computes mid-ramp markers, 46 flat cells match the old two-ramp/one-mid-corner rejection.
Two have no covering transition: (60,34) at world (1536,-1280) and (55,42) at (896,-256).
The nearby BALH at (61,34) extends east to (62,34); the LHBA at (54,42) extends west to (53,42).
Neither covers the rejected cell. Those two omissions produced the report's isolated black patches.
Do not diagnose this from raw W3E cliffVariation values: FileReadWar3Map replaces them on ramp vertices with
computed midpoint markers before baking. renderer_terrain.nightelfx04_uncovered_ramp_corner_keeps_ground_and_splats
uses the sampled heights, levels and historical midpoint flags around both cells, drives R_MakeTile and the splat eligibility
path, and checks both uncovered ground and a genuine low-neighbour transition. It keeps the ground-ownership
regression independent of the loader correction for #642 below. No retail archives are needed.
High ramp endpoints and side-view floor gaps (#642)
FileReadWar3Map discards authored W3E cliffVariation on ramp vertices and recomputes it as a
midpoint marker. R_GetVertexPosition(...,true) lifts a marked vertex by 64 world units. Opposite
ramp neighbours with different layers do not establish that the current corner is a midpoint:
at a bend, the corner can already be the high endpoint. Require the current layer to equal the
lower of those neighbours before marking it, for both horizontal and vertical pairs.
The old test marked these high endpoints and lifted adjoining ground 64 units above its authored
transition mesh. The resulting triangular slit was especially visible from the side. Increasing
R_CliffGroundJoin's half-tier snapping distance would stretch the cliff to the erroneous ground;
keep the height classification correct instead. git blame traces the unrestricted midpoint
classification to 3babb327da (July 2023), predating the recent ramp placement and #615 fixes.
| Map | High endpoint W3E vertex | Raw height / layer | Correct world Z |
|---|---|---|---|
| ROC Human03 | (19,23) |
8268 / 3 |
147 |
| TFT NightElfX04 | (55,43) |
8723 / 7 |
772 |
| TFT NightElfX04 | (61,34) |
8324 / 7 |
673 |
Human03's LHBA at (18,22) still raises its genuine lower midpoint (18,23) to Z 83;
its model-owned low neighbour (17,22) must remain omitted by the ground baker. The two
NightElfX04 cells restored for #615 still draw their ground and accept splats, with their high
corners returned to the correct plateau heights.
tools/wc3terrainfixturegen.py retains three numerical 6x6 W3E crops with a one-vertex halo.
make test-assets packs them at the real campaign map paths in build/tests/tests.mpq; the
NightElf map puts its two crops side by side; Human03 also includes a transposed crop to
exercise the north/south midpoint branch. Nonzero authored variation verifies that the
production W3E loader recomputes the markers. Tests never read installed Warcraft archives.
renderer_terrain.human03_high_ramp_endpoint_joins_ground_without_a_false_midpoint drives
FileReadWar3Map, the ground baker and the cliff baker using the native LHBA high/middle
endpoints. It checks coincident ground/mesh Z and preserves the low cell's mesh ownership.
renderer_terrain.nightelfx04_restored_ground_has_no_raised_high_ramp_corner covers both
NightElf crops through the loader, emitted ground, splat eligibility and exact endpoint height.
Both failed before this correction. Run make test-renderer-model test-renderer-shadows.
For a bounded silhouette check, retain every entry of a campaign archive in a temporary copy,
replace only its war3map.j, disable fog and remove trees in that diagnostic script, then position
the camera at Human03 (-5760,-3200) (rotation 90) or NightElfX04 (960,-128) (rotation 0).
Distance 2200, angle of attack 315 and noon lighting show the adjoining cliff faces. Camera natives
need a local-player context: wrap them in if GetLocalPlayer() == GetLocalPlayer() then ... endif.
Raw camera calls in an otherwise empty diagnostic main have no current player and do nothing.
Call the diagnostic function again through +jass DebugRampCamera after client entry, since
G_ClientBegin installs the initial map camera. Put sv_cheats 1 in the late config for that command.
build/bin/openwarcraft3 -data 'data/Warcraft III' +exec /tmp/ramp-capture.cfg \
+map Maps/RampDiagnostic.w3m +jass DebugRampCamera +screenshot 60 +com_frame_limit 80
The fixed Human03 and side-view NightElfX04 captures were inspected at these locations: the ground joins remain continuous without the reported black slits. The numerical regression remains the reproducible check; the diagnostic captures do not exercise the original campaign scripts.
Water Depth Tinting
Ground tiles that sit under water are tinted darker the deeper they are. GetTileDepth(waterlevel, height) returns a value in [0.05, 1] that is stored in the vertex color channel and multiplied with the sampled texture colour in the fragment shader. The colour channels are additionally brightened toward white proportionally to depth to simulate water scattering:
The separate water-surface layer also consumes viewDef.waterBaseColor. WC3 SetWaterBaseColor publishes this runtime RGBA through the generic scene contract; R_DrawTerrainSegment assigns the multiplier only while drawing MAPLAYERTYPE_WATER, then restores neutral white so ground, cliffs and later default-shader users are unaffected. See Water Base Color.
#define WATER(INDEX) MakeColor(color[INDEX],
LerpNumber(color[INDEX], 1, 0.25f),
LerpNumber(color[INDEX], 1, 0.5f), 1)
Cliff Layers (r_war3map_cliffs.c)
Cliffs are not tessellated from the heightmap. Instead, each cliff tile loads a pre-built MDX model from Doodads\Terrain\<dir>\<dir><cfg>0.mdx and copies its geoset vertices into the segment's GPU buffer.
Cliff Configuration String
For each tile the four corner vertices are examined. Their relative height differences (0, 1, or 2 levels above the tile base) and ramp flags are encoded into a four-character string cliffcfg:
| Vertex relative level | Non-ramp | Ramp |
|---|---|---|
| 0 | A |
L |
| 1 | B |
H |
| 2 | C |
X |
Corner order is [NW, NE, SE, SW] (r_cliff_corners[] = {1,0,2,3}), while GetTileVertices stores
[NE, NW, SE, SW]. The resulting string like "AABB" selects the correct model variant.
R_CliffTexture selects the first authored cliff index in SW, SE, NW, NE order, independently of the model order.
W3E index 15 means no explicit cliff texture on that corner. When all four are 15, retail searches vertices in
[x-2,x+2] × [y-2,y+2], ascending X first, then ascending Y, skipping out-of-map coordinates. The first non-15
index wins. If none exists, retail uses slot zero; OpenRealm logs this unresolved-data case. Never interpret
all-15 as a fixed second cliff set. See retail evidence.
The per-map model cache keys the full asset path, not just four configuration letters. Cliffs and CityCliffs
(likewise CliffTrans and CityCliffTrans) contain different geometry with identical configuration suffixes.
The former four-letter key, introduced in May 2023, returned city models for dirt requests in Human02Interlude.
This was confirmed by logging requested directory versus the cached model's directory; it was not the courtyard border cause.
Ground Override
Before baking ground layers, the renderer overrides the ground index around the entire cliff model footprint:
corners at the cliff's base level get the type's groundTile, higher corners get its upperTile when the row defines
one ("_" = none, then groundTile). Only TFT Outland's abyss cliff COrd has both (Oaby below, Osmb above);
painting Oaby on high corners turned flat plateau tiles in concave corners solid black (issue #597). A high corner
whose upperTile is not in the map palette keeps its authored ground. Ordinary cliffs cover one cell/four vertices;
ramps cover two cells/six vertices, including the low-side neighbour. Merely painting the first cell leaves the ground
texture border one tile short even when the cliff mesh itself covers the correct area.
Ramp Placement
Native MDX positions and normals are rotated -90 degrees about Z: (x,y,z) -> (y,-x,z), preserving the authored UVs.
The base translation is (x*TILE_SIZE, y*TILE_SIZE). R_CliffRampOffset extends the two-cell model into the
low-side neighbour intentionally omitted by the ground baker. Native Y-long models are world east–west ramps;
native X-long models are world north–south ramps. Both rotated long axes span [0,256].
East-high ramps shift X by -128; west-high ramps do not shift. North-high ramps shift Y by -128; south-high do not shift.
Do not replace this rotation with cyclically shifted filename letters. The assets are not exact rotated copies:
in the inspected archive CityCliffTransHBAL0 has 31 vertices, while CityCliffTransBALH0 has 32, with different
interior heights and UV details. The earlier SW-first filename plus X translation repaired coverage but chose the wrong mesh.
Local Game.dll evidence
The user-provided data/Warcraft3demo/Game.dll (SHA-256
286823c37a1083e91f07d040e46a9df7af4c4952e01fcbba460589bd4e297654, preferred image base 0x6f000000)
provides these version-specific disassembly anchors:
| Address | Observed contract |
|---|---|
0x6f1283e0 |
Reads corners (x,y+1), (x+1,y+1), (x+1,y), (x,y): NW, NE, SE, SW. |
0x6f128770 |
Ramp filename construction and model lookup. |
0x6f4f9e28 |
Sixteen 24-byte ramp records: two four-letter masks, origin X/Y, low-neighbour X/Y. |
0x6f128b90 |
Loads -90 degrees (0x6f4f9e20) before the position/normal transform at 0x6f11dfb0. |
0x6f11e200 |
Heightmap deformation after rotation; adjusts Z, not an additional XY tile offset. |
0x6f0ea900 |
Geometry cache hashes and compares the full filename. |
These are inspection addresses for this demo DLL, not universal offsets or a claim of matching every retail version.
For example, radare2 -q -e scr.color=0 -c 'pD 512 @ 0x6f1283e0; q' data/Warcraft3demo/Game.dll inspects corner order.
The local Warsmash reference (environment/Terrain.java, realTileTexture) independently explains the ground-border rule:
a vertex adjacent to either the high or low ramp cell receives that cliff's ground tile.
Human02Interlude terrain-hole regression
Maps/Campaign/Human02Interlude.w3m is a standalone cinematic map and can be launched directly; completing Human02 with
the win cheat reaches the same map. The inspected local ROC data has 97x65 vertices, tileset X, 9 ground textures,
2 cliff textures, tile size 128, and world offset (-7168,-3072). Coordinates below are zero-based SW cell coordinates.
The first investigation's targeted logs at ground rejection and cliff-model baking identified two geometry omissions:
- 73 of 2,284 cliff cells had SW cliff index 15 and were rejected by every cliff layer despite valid indices at other corners.
Examples:
(10,29)and(65,35)use cliff 1;(75,25)and(77,9)use cliff 0. - The screenshot's courtyard holes remained after fixing that selection. E/W ramp models at
(36,33)HAAL,(36,34)AHLA, and(36,36)/(36,37)/(36,41)/(36,42)HBAL/BHLA were baked across columns 35–36 instead of 36–37. The ground baker correctly skipped low-side cells(37,33),(37,34),(37,36),(37,37),(37,41),(37,42); the misplaced meshes never covered them. The foreground pair starts at world(-2432,1536)and(-2432,1664); the background pair starts at(-2432,2176)and(-2432,2304).
Those configuration names describe the old SW-first implementation. The incorrect X rule dates to 62e559a76
(2023), not the recent ramp-classification change. Drawing the skipped ground does not repair the ramp holes.
The first correction fixed mesh coverage, but the user's retail comparison exposed a remaining one-tile purple-border error.
Follow-up logs and DLL inspection identified the native corner/rotation contract above and the missing ground override:
| High cell | Native city ramp | Low cell | Far-end ground vertices omitted by the old override |
|---|---|---|---|
(36,36) |
BALH0 |
(37,36) |
(38,36), (38,37) |
(36,37) |
HLAB0 |
(37,37) |
(38,37), (38,38) |
(36,41) |
BALH0 |
(37,41) |
(38,41), (38,42) |
(36,42) |
HLAB0 |
(37,42) |
(38,42), (38,43) |
For (36,36), the native rotated model is translated by (36,36) tiles and covers [36,38] x [36,37].
Its cliff type CXsq selects CityCliffTrans, Cliff1, and ground Xsqd (index 4). Logs showed (38,36) and (38,37)
still carrying ground index 0, while the first four corners carried 4. Covering all six vertices moves the painted edge
to the ramp's actual low end, beneath Arthas, and likewise repairs the background edge.
The base and locale archives' W3E data were identical; the discrepancy was not different map input.
Windowed, bounded visual check (the built-in screenshot captures only the game drawable):
make -j8 build test-renderer-model test-renderer-shadows
build/bin/openwarcraft3 -data 'data/Warcraft III' +set vid_fullscreen 0 +set vid_native 0 +set vid_mode 4 +set skip_cutscene 1 +com_frame_limit 230 +map 'Maps/Campaign/Human02Interlude.w3m' +screenshot 180
Inspect the generated screenshots/shot*.jpg at the courtyard close-up: the foreground and background ramp edges must be
continuous, without black/sky-coloured gaps. Cinematic timing can vary; adjust the capture frame if necessary.
renderer_terrain.cliff_texture_skips_non_cliff_corners covers corner priority/sentinel handling, and
renderer_terrain.ramp_footprints_cover_the_low_neighbour checks sampled ramp levels and all four directions.
renderer_terrain.cliff_baker_preserves_native_axes_uvs_and_ground_coverage runs the actual baker on a synthetic grid,
checking model names, transformed positions/normals, untouched UVs, all six ramp ground vertices, and ordinary four-corner cliffs.
renderer_terrain.cliff_cache_distinguishes_model_directories exercises actual cache lookup/reuse and cleanup.
These tests require no retail MPQ data. Camera behavior is separate; see cinematics.
Windowed follow-up captures before/after the ground override show both purple edges reaching their low-side endpoints; rotating the native geometry alone did not fix that border. The cleaned build was captured again after the cache fix.
Height Snapping
Every vertex of the copied cliff model has its Z adjusted to match the actual heightmap:
This grounds cliff models to terrain depressions or rises that occur within a tile. R_CliffGroundJoin then finds edges bordering
emitted ground through R_TileHasGround and snaps vertices within half a tier to that exact ground edge. This includes ramp-adjusted
heights from R_GetVertexPosition; the decorative MDX lip alone is not the ground surface. Upward-facing join normals use the terrain
normal while vertical faces retain their authored direction.
Shared Normal Welding and Undead04
Issue #477 exposed a second cliff-index case after the mixed-corner fix in
5ae558b7. The local ROC Undead04.w3m has 671 cliff cells with all four indices 15, plus 606 with explicit cliff 0. Its W3E cliff
list is CLgr,CVdi. Returning 15 skipped every material layer, leaving black holes beside the river and waygates.
Commit 639ce3a4 restored those faces by returning 1, following Warsmash, but selected the wrong material: dirt
instead of grass. Retail resolves nearby authored corners; all 671 cells select CLgr after that lookup (652
find an explicit neighbour and 19 use the logged slot-zero default). The northeast waygate is at world (4032,-1856).
renderer/r_cliff.h shares the dynamic triangle bake and normal welder with SC2. The welder keys quantized XYZ, combines only
similarly-facing normals from distinct placements, and leaves stacked, opposing and zero normals alone. Expanded triangles must
count each placement/authored normal once: counting repeated indices biases opposite sides of a seam differently. WC3 defers uploads
until all segments and cliff materials have been baked, so seams across segment/material boundaries participate in the same pass.
Native MDX axes, ramp footprints and UVs remain WC3-owned; SC2's M3 placement/catalog rules remain SC2-owned.
Blight preserves corners owned by any omitted cliff/transition cell, including low ramp neighbours. Ground, splats, snapping and
Blight share R_TileHasGround; see Blight presentation and SC2 ramps and welding.
Regression coverage in test_renderer_model includes undead04_implicit_cliff_and_ground_join,
blight_preserves_cliff_corners, and cliff_welding_ignores_triangle_multiplicity_and_stacked_faces. Run make test-renderer-model
and make test. A bounded retail-data load is:
build/bin/openwarcraft3 -data "data/Warcraft III" +vid_hidden 1 +map Maps/Campaign/Undead04.w3m +com_fast_forward 1 +com_frame_limit 120
For camera commands with negative coordinates, use a config passed through +exec (for example camera move 4032 -1856);
the startup argument parser treats a separate negative argument as a command-line option. Fog and the opening cinematic must be
disabled for a visual inspection of that location; automated geometry tests do not need either runtime change.
Water Layer (r_war3map_water.c)
Water tiles are flat quads at the waterlevel height of each vertex (per-vertex because the water surface can vary across a tile). Only tiles where at least one corner has the water flag set — and no corner has the mapedge flag — are emitted.
Water art, tint, animation and surface height come from the tileset's TerrainArt\Water.slk row; see
terrain-tiles-and-water.md. UVs are generated by WATER_SCALE:
Quirk — WATER_SCALE tiling: the
x%3term creates a 3-tile staggered repeat that prevents an obvious grid pattern while keeping UV coordinates cheap to compute. The tile coordinates passed in are the raw grid integers so the texture tiles every 3 units in X and every 1 unit in Y.
Water color and opacity are computed per vertex from the Water.slk shallow/deep bands (R_WaterDepthColor).
Shadow Map Pass
R_RenderShadowMap uses the same SHADER_DEFAULT program but passes tr.viewDef.lightMatrix as the view-projection matrix. OpenGL depth-writes go into RT_DEPTHMAP (a 1024×1024 depth texture). The colour pass reads this texture to compute get_shadow() in the fragment shader.
The static baked shadow texture (war3map.shd) is bound to texture unit 1 during R_DrawWorld. It stores pre-computed shadow data from the Warcraft III World Editor at 4× heightmap resolution ((width-1)×4 × (height-1)×4 texels).
Gotcha — two shadow inputs: the fragment shader currently reads both the runtime depth map (
uShadowmap) and the static baked shadow map (TEX_SHADOWMAP) via the sameuShadowmapuniform slot. The baked shadow map is bound at load time; the runtime depth map is bound inR_RenderViewviaglBindTexture(GL_TEXTURE2D, tr.rt[RT_DEPTHMAP]->texture). Whichever binding wins at draw time determines the shadow look.
Fog of War (r_fogofwar.c)
The fog of war uses three off-screen render targets, all sized at (width-1)×4 × (height-1)×4 texels (same resolution as the baked shadow map):
Server snapshot visibility and client fog shading have separate ownership. Mobile units require current server-side vision;
buildings and map doodads/destructibles enter snapshots after their cell is explored and remain while the client fog mask
shrouds them. Doodads/destructibles carry SVF_STATIC_SCENERY so the server can use explored rather than current vision
without per-frame metadata lookups. Never send static scenery unconditionally: ROC Human02 has 2,299 such entities, which
saturates the 1,024-entity snapshot and exposes unexplored waterfalls through black fog.
| Target | Contents |
|---|---|
FOW_RT_IMMEDIATE |
Sight revealed this frame by all player-1 units |
FOW_RT_HISTORY |
Accumulated maximum visibility (ever-seen areas) |
FOW_RT_RESULT |
50 % history + 50 % immediate — the texture bound to uFogOfWar |
Each entity with radius >= 10 casts a circle of sight. A custom ray-cast shader (vs_shadow / fs_shadow) subtracts visibility blocked by other entities' silhouettes from the circle.
R_GetFogOfWarTexture prefers the server-authored fow_resources.network texture. The older render-target raycast is used
only when no network texture exists; white is reserved for RDF_NOFOG, RDF_NOWORLDMODEL, or unavailable fog resources.
World particles sample the same texture matrix and unit-2 fog mask as model geosets. This matters for
Doodads\\Terrain\\CliffDoodad\\Waterfall\\Waterfall.mdx: the asset has two PRE2 emitters and no geosets, so applying
FOW only in the model shader leaves the complete waterfall visible through otherwise black fog. UI/model scenes receive
the white FOW texture through RDF_NOWORLDMODEL, preserving their particle presentation without a parallel shader path.
Gotcha — only team 1: the fog loop filters
ent->team != 1, meaning only the first player's units reveal the map. Multi-player fog of war is not yet implemented.
Splat Rendering (R_RenderSplat)
R_RenderSplat draws a world-space decal (selection circles, ground effects) by re-using the static vertex buffer used for ground tiles. It:
- Converts the splat's world-space AABB to heightmap grid coordinates.
- Re-runs
R_MakeTileover those tiles into the sharedaVertexBuffer(withtexture = NULLto skip UV assignment). - Computes UV per vertex from the splat's position and radius so the decal texture projects onto the terrain surface regardless of terrain slope.
Gotcha — shared static buffer:
aVertexBufferinr_war3map_ground.cis a file-scope array also used during segment building.R_RenderSplatmust not be called while segment building is in progress (it is safe at runtime because segments are built once at load time).
R_RenderRectSplat is the immediate (single-decal) entry point. For many same-shader decals — unit shadows — R_DrawEntityShadows uses the batched R_BeginSplatBatch / R_AddRectSplat / R_EndSplatBatch API to collapse hundreds of splats into one vertex-buffer upload + draw per distinct texture. See performance.
Height and Normal Queries
GetAccurateHeightAtPoint(sx, sy) performs bilinear interpolation across the four heightmap vertices surrounding the world-space point (sx, sy). This is used by cliff snapping and by R_GetAPI().GetHeightAtPoint which the server calls to place units on the ground.
R_TraceLocation casts a screen-space ray through the terrain by testing every pair of triangles in the full heightmap (O(width × height) triangle intersection tests). This is only called on mouse input events so the cost is acceptable for the current map sizes.
Gotcha — coordinate spaces: the heightmap grid runs from
(0, 0)to(width-1, height-1)in tile space, while world space has its origin atmap->center. The conversion isworld.x = center.x + tile.x * TILE_SIZE.TILE_SIZEis 128 world units.
Texture Loading
Undead04 cliff material verification
The locally inspected ROC Undead04 W3E has tileset L, custom=1, ground palette
Vdrt,Vdrr,Vcbp,Lgrs,Lgrd,Ybtl,Yrtl, and cliff palette CLgr,CVdi. The 606 cliff cells with an explicit
index 0 select CLgr. The 671 cells whose four corners are 15 also resolve to CLgr: 652 find a non-15
vertex within retail's search window; 19 have no such vertex and use the logged slot-zero default.
Cell (93,54) near the northeast waygate has NW/NE/SE/SW levels [4,4,6,6], four ground indices 3,
and four cliff indices 15. The first explicit vertex in retail's search order is (92,56), whose packed
height/cliff byte is 0x04: cliff slot 0, layer 4. These are ordinary grass cliffs with sparse explicit
material indices, not a special dirt-cliff family. R_MakeCliff also sets the footprint's ground corners
to the selected cliff row's groundTile, so a wrong cliff selection changes both the wall and its surround.
| Selected slot | ROC/TFT SLK row | Loaded wall image on tileset L | Adjoining ground |
|---|---|---|---|
| 0 (Undead04 river and waygates) | CLgr |
ReplaceableTextures/Cliff/Cliff1.blp |
Lgrs (ground index 3) |
| 1 (only when an authored neighbour requests it) | CVdi |
ReplaceableTextures/Cliff/Cliff0.blp |
Vdrt (ground index 0) |
Both archive versions give these rows the same texFile and groundTile values. L.mpq has no cliff
override, so texture lookup resolves the base images. Cliff0 contains the layered
rock face and brown rim; selecting it for the waygate was the bug. Neither palette sorting nor a replacement
texture is required. The packed W3E cliff nibble was read correctly; the missing step was neighbourhood resolution.
Retail binary evidence
The original code checks SW, SE, NW, NE, then the bounded 5×5 window in ascending X/Y order. It returns the
selected vertex's cliff index and, when requested, its cliff variation. The no-match result is index 0 and
variation 0; the demo additionally prints CLIFF ERROR! No cliff type found for cell (%d, %d).
| Binary | SHA-256 | Selection function VA | X/Y corner offset tables |
|---|---|---|---|
September 2002 demo Game.dll |
286823c37a1083e91f07d040e46a9df7af4c4952e01fcbba460589bd4e297654 |
0x6f11d820 |
0x6f4f46c8, 0x6f4f46d8 |
TFT 1.29.2.9231 Warcraft III.exe |
a1950f17905b9cd7d5461d45e6723af36dda5304e12dba27a1fea85593b15f3f |
0x00781d10 |
0x00ea93c8, 0x00ea93d8 |
Both tables are X=[0,1,0,1], Y=[0,0,1,1]. Inspect without running the retail executables:
r2 -q -c 'af @ 0x6f11d820; pdg @ 0x6f11d820; pxw 32 @ 0x6f4f46c8' data/Warcraft3demo/Game.dll
r2 -q -c 'af @ 0x781d10; pdg @ 0x781d10; pxw 32 @ 0xea93c8' "data/Warcraft III/Warcraft III.exe"
The demo's W3E loader 0x6f0fc490 reads the stored cliff list; 0x6f11ce60 deduplicates it and remaps valid
indices while preserving 15. This is not the editor's ground-palette-derived cliff list described by some
format references. OpenRealm keeps the stored palette order.
Regression and visual evidence
renderer_terrain.undead04_cliff_material_inherits_nearby_authored_slot drives R_BuildMapSegmentCliffs
and R_FinishCliffs with all-15 cliff cells and explicit neighbours outside those cells. It checks geometry,
actual texture requests, and footprint ground replacement for both grass and dirt neighbours. It failed
before the correction: the grass layer was absent and the dirt layer was emitted. Asset lookup and GPU
upload are mocked; selection, geometry baking and ground replacement are production code.
undead04_waygate_cliff_type_from_native_corners records the native 5×5 packed layer/cliff bytes around
(93,54). cliff_texture_searches_retail_neighbourhood covers search priority, radius, map boundaries,
explicit-corner precedence and the unresolved default. These tests do not exercise W3E file loading or real
image decoding. Run make test-renderer-model and make test.
The earlier capture on 2b673bb1 showed the faces restored, but still showed the incorrect brown rim and
layered dirt rock. The claim in 8d3f61dc that this matched retail was wrong: its test only confirmed our
assumed slot-1 behavior. The user's retail waygate photo shows grass-topped cliffs and contradicts that assumption.
A fresh TFT capture after the neighbourhood correction (screenshots/shot0182.jpg locally) shows grass-topped
rock at the same waygate and no brown dirt rim. The native W3E was byte-identical to the map extracted from
War3.mpq; the run exited successfully after 150 frames. The full test suite passed, including both ROC/TFT
engine runs; the focused renderer suite passed 5,298 assertions across 100 tests.
For a framebuffer check, the diagnostic map retains native war3map.w3e unchanged and disables
InitCustomTriggers/RunInitializationTriggers so the cinematic cannot override the camera. Its JASS helper
disables fog, sets noon, suspends time of day and calls ResetToGameCamera(0) before camera move 4032 -1856
and screenshot 30; bound the run with +com_frame_limit 150. Mission behavior is not under test.
The earlier capture exceeded CL_TIMEOUT_MSEC during registration; a one-shot conditional LLDB breakpoint
at CL_CheckTimeout reset cl_last_packet_time = cl_realtime when the timeout condition became true.
That capture-only workaround does not resolve the separate normal-launch loading timeout.
Ground and cliff textures are located by looking up the tile or cliff ID string (four-character FourCC, e.g. "Ldrt") in the TerrainArt\Terrain.slk or TerrainArt\CliffTypes.slk spreadsheets respectively.
Cliffs load <texDir>\<texFile>.blp; the tileset variant comes from the nested <tileset>.mpq archive layer
(see terrain-tiles-and-water.md), not a filename prefix.
Ground textures are cached in g_groundTextures[], indexed by ground layer. _W3M_ClearMap resets the ground
and cliff choices before the next map is registered; texture storage remains owned by the renderer cache.
Key Constants
| Constant | Value | Defined in | Meaning |
|---|---|---|---|
TILE_SIZE |
128 | games/warcraft-3/common/mapinfo.h |
World units per heightmap tile |
SEGMENT_SIZE |
8 | games/warcraft-3/common/mapinfo.h |
Tiles per segment side |
SHADOW_TEXSIZE |
1024 | r_local.h |
Shadow-map texture resolution |
DECODE_HEIGHT(v) |
(v - 0x2000) / 4 |
games/warcraft-3/common/terrain.h |
Raw W3E heightmap int → world units |
HEIGHT_COR |
TILE_SIZE * 2 |
games/warcraft-3/common/terrain.h |
Cliff-level baseline correction |
W3_WaterSurfaceHeight |
raw/4 - 0x800 + height*128 |
games/warcraft-3/common/terrain.h |
Water surface from W3E level and Water.slk height |
MAX_MAP_LAYERS |
16 | r_war3map_ground.c |
Max ground texture slots |
Key Files
| File | Purpose |
|---|---|
games/warcraft-3/renderer/w3m/r_war3map.c |
Map loading, segment building, R_DrawWorld, R_DrawAlphaSurfaces |
games/warcraft-3/renderer/w3m/r_war3map.h |
MAPSEGMENT / MAPLAYER structs, public declarations |
games/warcraft-3/renderer/w3m/r_war3map_ground.c |
Ground and ramp tile geometry, splat rendering, height/normal queries |
games/warcraft-3/renderer/w3m/r_war3map_cliffs.c |
Cliff model loading, vertex baking, height snapping |
games/warcraft-3/renderer/w3m/r_war3map_water.c |
Water tile geometry and opacity |
games/warcraft-3/renderer/w3m/r_war3map_utils.c |
Shared helpers: GetTile, SetTileUV, GetTileDepth, vertex accessors |
renderer/r_fogofwar.c |
Fog-of-war render targets and ray-cast sight shader |
renderer/r_main.c |
R_RenderShadowMap, R_RenderView, R_RenderFrame |
renderer/r_shader.c |
GLSL sources for SHADER_DEFAULT, SHADER_UI, SHADER_SPLAT |
games/warcraft-3/common/mapinfo.h |
TILE_SIZE, SEGMENT_SIZE, WAR3MAP struct layout |