LOG IN
Docs
By GitHub Copilot

Cliff Cells

How steep terrain becomes cliff bits in the heightmap, expanded cliff cells in pathfinding, and death checks for stunned units on illegal ground.

In Generals Remastered, a cliff is not just a visual slope. It becomes a bit in the terrain logic heightmap, then a pathfinding cell classification, and finally a gameplay rule that can kill a unit that lands where it has no locomotor support.

The first decision happens in WorldHeightMap.cpp. For each terrain quad, the code samples the four corner heights, computes minZ and maxZ, and compares the difference against PATHFIND_CLIFF_SLOPE_LIMIT_F, which is hard-coded to 9.8f:

Real height1 = getHeight(xIndex,     yIndex)     * MAP_HEIGHT_SCALE;
Real height2 = getHeight(xIndex + 1, yIndex)     * MAP_HEIGHT_SCALE;
Real height3 = getHeight(xIndex,     yIndex + 1) * MAP_HEIGHT_SCALE;
Real height4 = getHeight(xIndex + 1, yIndex + 1) * MAP_HEIGHT_SCALE;
Real minZ = std::min({height1, height2, height3, height4});
Real maxZ = std::max({height1, height2, height3, height4});
const Real cliffRange = PATHFIND_CLIFF_SLOPE_LIMIT_F;  // 9.8f
Bool isCliff = (maxZ - minZ > cliffRange);
setCliffState(xIndex, yIndex, isCliff);

If the height spread across that tile exceeds the threshold, setCliffState marks it as a cliff in the logic heightmap. This is not a mesh-normal test or a renderer-only heuristic — it is a terrain-logic bit derived straight from world height samples.

Once that bit exists, BaseHeightMapRenderObjClass::isCliffCell in Core/GameEngineDevice/Source/W3DDevice/GameClient/BaseHeightMap.cpp becomes the main world-space query. It converts a world position into logic-heightmap indices, applies border-size offsets, clamps to valid extents, and returns getCliffState from the logic heightmap. The render object is really acting as a thin adapter around the shared terrain logic data.

The D3D11 path had to replicate that adapter explicitly. W3DTerrainLogic::isCliffCell in GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/D3D11Shims.cpp contains a comment explaining the reason: the D3D11 build no longer has a BaseHeightMapRenderObjClass instance to forward through, so the shim goes straight to TheTerrainVisual->getLogicHeightMap(). Without that mirror implementation, the pathfinder saw no cliff cells at all and tanks could drive up terrain that should have been illegal.

Pathfinding consumes the same query in GeneralsMD/Code/GameEngine/Source/GameLogic/AI/AIPathfind.cpp. Pathfinder::classifyMapCell samples the top-left corner of each pathfind cell, marks it as CELL_CLIFF if TheTerrainLogic->isCliffCell says so, then lets water override that classification if any of the four corners are underwater. Obstacles are applied afterward by object footprint logic.

The cliff pass does not stop there. classifyMap immediately expands every cliff cell outward by one cell. It first marks neighboring clear cells as pinched, then converts pinched clear cells into CELL_CLIFF, then runs another pass that adds a border of pinched cells around cliffs. That means the pathfinder intentionally thickens cliff regions rather than treating the raw height-threshold output as precise navigable edges. It is a gameplay safety margin.

That same safety margin shows up later in physics. PhysicsBehavior::testStunnedUnitForDestruction in GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Update/PhysicsUpdate.cpp checks whether a stunned object ended up on a cliff cell. If so, and its AI locomotor set does not support LOCOMOTORSURFACE_CLIFF, the object is killed immediately. The same routine does an equivalent test for water. A cliff classification can therefore become a direct death condition, not just a pathfinding hint.

Why The System Is Conservative

The engine is biased toward making cliffs slightly larger than the raw height data suggests. That is the point of the pinched-cell expansion pass. In an RTS, units wedging onto steep edges is worse than forbidding movement a little early, so the pathfinding layer deliberately widens cliff boundaries.

That also explains why the D3D11 shim fix mattered. If one renderer path stops exposing cliff bits to logic, the bug is not cosmetic. It changes where units can drive and whether stunned units survive terrain impacts they were previously supposed to lose.

Quirks

  • Cliff state is based on the height spread of a terrain quad, not rendered geometry normals. A flat sloped cliff face composed of a few short steep tiles counts; a single tall wall triangle with gentle neighbors does not. The threshold of 9.8 world units was tuned for Generals infantry scale and would need retuning for a mod with radically different unit sizes.
  • The pathfinder samples a corner, then expands cliff coverage outward. The final navigability region is intentionally fatter than the raw heightmap bitfield. Art-side tweaks that make a cliff look "a little less steep" can stop being cliffs in pathing first, with visual parity lagging.
  • Water overrides cliffs in cell classification. Underwater corners win even if the height delta also qualifies as a cliff. A bridge over water that samples underwater corners gets water semantics, not cliff semantics — which matters because amphibious locomotors treat them differently.
  • Cliff legality is checked again during stunned-unit cleanup. The terrain bit is part of gameplay death handling (see PhysicsBehavior::testStunnedUnitForDestruction). A unit force-moved to a cliff cell by a gameplay effect is killed on the frame it recovers from stun if its locomotor set lacks LOCOMOTORSURFACE_CLIFF.
  • The D3D11 shim fix was a one-line quirk. W3DTerrainLogic::isCliffCell in D3D11Shims.cpp had returned FALSE unconditionally because the refactor removed the BaseHeightMapRenderObjClass instance the original code forwarded through. Nothing crashed, nothing logged — tanks just drove up sheer hills. The replacement queries TheTerrainVisual->getLogicHeightMap() directly. See bug_isclifcell_d3d11_stub.md.
  • The 9.8f constant is shared between two compile units. WorldHeightMap.cpp defines PATHFIND_CLIFF_SLOPE_LIMIT_F; no other file references the constant by name, so any retune must happen in one place and the cliff pass re-runs automatically on map load. There is no runtime INI exposure.