Terrain pass now ports the SAGE water model (Ocean.fx / OpenSAGE Water.frag): a de-gridded procedural wave normal combined with the retail ra3_deepocean flow and ra3_deepocean_nrm bump maps (appended as the last two terrain-atlas layers, no new backend binding), Schlick fresnel, sky reflection + depth-graded refraction, SAGE diffuse/specular lighting, depth-based transparency, and an underwater tint (UnderwaterDeferred.fx). Mirrored across terrain.frag / dx_terrain.hlsl / webgl_terrain_frag.glsl / webgpu_terrain.wgsl. Also: logs move to the per-user state dir (%LOCALAPPDATA%\\OpenRA3\\logs, else XDG) and archive as openra3.<stamp>.log; the FPS label shows the active backend; middle-drag camera reset; objects and roads below the water plane are culled.
21 KiB
Reverse engineering workflow
OpenRA3 is reconstructed from two sources:
- Architecture — the GPLv3 SAGE 1.0 tree,
electronicarts/CnC_Generals_Zero_Hour. RA3 runs SAGE 2.0, so class names, message flow and subsystem boundaries carry over even though the code does not. - Facts — the retail
ra3_1.12.gamebinary (image base0x400000), analysed in Ghidra and, when needed, observed live.
Nothing in OpenRA3 should assert a structure or constant that is not either copied from the reference or cited to a retail address.
Fetching the reference
tools/fetch_reference.sh # sparse-clone Code/GameEngine into reference/
The checkout is git-ignored (large, and GPLv3 terms differ from this repo's).
Ghidra
The retail module is loaded into Ghidra as ra3_1.12.game
(x86:LE:32:default, 34k+ functions). The analysis is driven through the
Ghidra MCP bridge, so every recovered fact can be re-derived:
| Question | Tool call |
|---|---|
| What does an address do? | decompile_function(address=0x…) |
| What is this function? | get_function_by_address(address=0x…) |
| Who touches a global? | get_xrefs_to(address=0x…) |
| What are the vtable slots? | list_class_members / analyze_data_region |
| Where is a string referenced? | search_strings + get_xrefs_to |
Recovery loop
- Pick a subsystem from the reference tree (e.g.
MessageStream). - Find its RTTI/vtable in the binary via
search_stringsand xrefs. - Decompile the constructor to recover object size and field init order.
- Decompile the hot methods to recover field meaning.
- Write the OpenRA3 module with a comment citing the address.
- Add a smoke test that pins the behaviour.
Recovered symbol map (retail ra3_1.12.game)
Engine singletons
Each holds an object pointer (0 when the subsystem is down).
| Global | Address | Object vtable |
|---|---|---|
TheGameLogic |
0x00cd8ce4 |
0x00beb630 |
ThePlayerList |
0x00ce8c9c |
0x00c5b9e0 |
ThePartitionManager |
0x00ce2f9c |
0x00c6af98 |
TheShroudManager |
0x00ce2fa0 |
0x00c6aefc |
ThePlacementGrid |
0x00cd8d0c |
0x00bea560 |
TheRecorder |
0x00ce2fd0 |
0x00c10544 |
TheGlobalObjectRegistry |
0x00cd8d08 |
0x00bea644 |
ThePlayerTemplateStore |
0x00ce8ca0 |
0x00c5bc70 |
TheGameState |
0x00cdbbc4 |
0x00bef0c0 |
GlobalData |
0x00ce2fa8 |
0x00c0d8e4 |
TheMessageStream |
0x00ce2fb8 |
0x00c0ecd4 |
Reconstructed layouts
GameLogic— tick counter at+0x50(incremented once per 30 Hz simulation step);startingguard flag at+0xa7, raised while a new match initialises.MessageStream— intrusive doubly linked list; head at+0x24, tail at+0x28.appendMessage(0x0060c4a0) allocates a0x74-byte node:+0x00next,+0x04prev,+0x08owner stream,+0x0cmessage type,+0x10player index,+0x18capacity,+0x1cdata pointer (node + 0x20).Player— money viastd::vector<Money*>at+0xe4; power at+0x74; team at+0xac; relation maps at+0xfc/+0x100.- Skirmish setup (
SkirmishGameInfo, pointer at[0x00ce3a78]) — starting cash+0x64, player slots+0xfc(stride0x5c, 6 slots), faction atslot + 0x18(Empire=2,Allied=4,Soviet=8,Random=7).
Tactical view (camera)
The in-game camera is the TheTacticalView object, held in the global at
0x00cdb7b4. Its vtable accessors return zoom, pitch (current/target), yaw,
world position and FOV; the debug overlay FUN_005ef0a0 prints them through the
format string at 0x00c0b900. Mouse state is the singleton at 0x00ce9284
(cursor position vtable slot +0x3c, button down +0x48); the keyboard manager
is 0x00ce927c (modifier mask +0x38).
Per-map camera tuning is a named-field table in .rdata (around
0x00c11a54): cameraMinHeight, cameraMaxHeight, cameraPitchAngle,
cameraYawAngle, cameraScrollSpeedScalar, cameraGroundMinHeight,
cameraGroundMaxHeight. The map-load chunk CHUNK_TacticalView
(0x00beea14, consumed near 0x00548a00) seeds the view from the map; the
tutorial actions LOCK_CAMERA_SCROLL / LOCK_CAMERA_ZOOM / LOCK_CAMERA_ROTATION
gate the controls.
OpenRA3 has no 3D terrain yet, so ra3::render::view_camera reproduces the
controls - wheel zoom, screen-edge scroll, clamped pan and opening on the
player's start - over the 2D map overview, not the retail perspective camera.
Match start
The BEGIN button calls SkirmishGameOptionsMenu::start (0x00b28d60), which
copies the map, calls GameInfo::startGame(0), seeds the logic random and
appends MSG_NEW_GAME (0x2). startNewGame itself is 0x00623e40. OpenRA3
mirrors this two-phase prepare_new_game / start_new_game split.
Container and map formats
Recovered by inspection of Data\*.big (see ra3.fs / ra3.map).
BIG4 archive
All integers little-endian except where noted:
offset 0 magic "BIG4"
offset 4 fileSize u32 LE total archive size
offset 8 fileCount u32 BE number of entries
offset 12 indexSize u32
offset 16 entries fileCount * { offset u32 BE, size u32 BE, name cstring }
Entry offsets are absolute; payloads are RefPack-compressed.
RefPack
EA's 10 FB stream. ra3.fs::refpack_decompress implements the 2/3/4-byte
commands and the long-literal/stop opcodes; refpack_output_size reads the
declared output length without decompressing.
Map file (.map)
Two layers of compression:
BIG4 payload = RefPack -> "EAR\0" + u32 unpacked_size + RefPack -> "CkMp" ...
The CkMp payload is the compiled SAGE map: a type/field name table followed by
chunk data. Player start positions appear as waypoints named
Player_1_Start, Player_2_Start, ... Each waypoint record carries a Coord3D
(three little-endian floats) shortly after the name; ra3.map scans forward
from the name for the first plausible (x, y, 0) triple. Maps that store starts
in MPPositionList instead yield no waypoints and fall back.
Verified example (map_mp_2_feasel4): Player_1_Start = (1338.9, 1940.5, 0),
Player_2_Start = (1290.8, 1404.9, 0).
Terrain (ra3.terrain)
The CkMp tree is a flat chunk list: "CkMp", u32 assetCount, the name
table ({ u8 len, name, u32 index }, index descending from assetCount), then
{ u32 index, u16 version, u32 size, data } per chunk. On
map_mp_2_feasel4 the terrain is HeightMapData v6 (540 x 600, border 20,
u16 elevations, scale 0.0390625) and BlendTileData v27.
BlendTileData opens with NumTiles, the u16 tile grid, then the
blend/three-way/cliff tables (u16 for v27); the passability flag arrays that
follow are not needed for rendering, so the texture table is located by
scanning for its { cellStart, cellCount, cellSize, magic } + u16-prefixed
name entries. A tile value is (cellIndex << 2) | variant, and cellIndex
indexes the global TextureCellCount-cell table (each texture owning
cellSize^2 64 px cells). The textures themselves are
art\terrain\<stem>.tga in Terrain.big / Core11.big (RefPack + 256x256
TGA). Rendered top-down, map_mp_2_feasel4 correlates 0.94 with the official
_art.tga overview.
Terrain blending (BlendTileData tail)
After the tile grid, BlendTileData stores three per-cell u16 tables —
Blends, ThreeWayBlends and CliffTextures — then TextureCellCount,
BlendsCount, the texture table, two magic words and BlendsCount - 1 blend
descriptions (18 bytes each: u32 secondaryTile, four direction bytes,
u8 flags, u8 twoSided, u32 0xFFFFFFFF, u32 0x7ADA0000). A non-zero
Blends[cell] is a 1-based index into the descriptions; secondaryTile is a
packed tile value (secondaryTile >> 2 is its cell).
The retail Terrain.fx (compiled terrain.fxo, parameters Terrain.BaseTexture,
Terrain.MacroTexture, MapCellSize, IsTerrainAtlasEnabled; technique
TerrainTile) cross-fades a cell's base tile into secondaryTile with a linear
ramp selected by BlendDirection: 1 right, 2 top, 4 top-right, 8
top-left, where flags bit 0 flips the axis and bit 1 marks a two-sided
diagonal. OpenSAGE's Terrain.frag reconstructs the exact
CalculateBlendFactor; ra3::terrain::blend_factor and shaders/terrain.frag
mirror it. The row axis is not inverted (73% of long-axis blends point at a
neighbour of the same texture, versus 25% inverted). On map_mp_2_feasel4,
33828 of 324000 cells carry a blend and there are 7094 descriptions.
The GPU atlas pads every 64 px tile with a 2-texel replicated gutter. Packed
edge-to-edge, bilinear/mipmap filtering averaged two unrelated tiles at every
cell border — that cross-tile bleed was the visible grid line the hardware path
drew. (shaders/terrain.frag samples cell_stride = cell_texels + 2 * gutter.)
Open question: per-cell tile sampling
Measured on map_mp_2_feasel4, sampling each cell as its own 64 px block and
restarting the UV every cell leaves a 1.39x edge spike at cell boundaries
(43.4 vs 31.1 mean gradient at 8 px/cell). Two candidate mappings reduce it and
need a visual decision against the retail art:
| Mapping | Boundary/interior |
|---|---|
| per-cell 64 px block (current) | 1.39 |
OpenSAGE BlendTileTextureIndex Morton layout, 32 px block |
1.18 |
continuous uv / (cellSize * 2), per OpenSAGE Terrain.frag |
1.11 |
The retail Terrain.frag (OpenSAGE) samples the tile texture continuously
(uv / (CellSize * 2)), so adjacent cells never restart the texture; our
per-cell restart is the remaining source of grid lines. Switching the atlas
from 64 px blocks to continuous 32 px regions (or the Morton 8x8 layout) is the
next step, pending an art correlation check.
Compiled art (BinaryAssetBuilder) and map objects
Retail RA3 ships no .w3x/.w3d files: BinaryAssetBuilder bakes every model,
texture and script into a binary asset stream — a .manifest index plus a
.bin of relocatable instance data (and optional .relo/.imp fixups). The
layout (little-endian) is:
ManifestHeader (48 B) isBigEndian u8, isLinked u8, version u16,
streamChecksum, allTypesHash, assetCount u32,
totalInstanceDataSize, maxInstance/maxRelocation/
maxImportsChunkSize, assetReferenceBufferSize,
referenceManifestNameBufferSize, assetNameBufferSize,
sourceFileNameBufferSize
AssetEntry (48 B) * count
typeId, instanceId, typeHash, instanceHash,
assetReferenceOffset i32, assetReferenceCount i32,
nameOffset i32, sourceFileNameOffset i32,
instanceDataSize i32, relocationDataSize i32,
importsDataSize i32, tokenized u32
then the reference / referenced-name / asset-name / source-name buffers
Asset names are Type:Instance (e.g. W3DMesh:BB_GRASS02). Instance pointers
are stored as offsets from the start of the instance data (which begins at byte
4 of .bin, after the stream checksum), so a slice is readable without the
relocation stream.
Each multiplayer map carries its own stream (data\maps\official\<id>\map.bin)
but it is linked: only map-specific assets (the terrain texture atlas,
scripts, GameMap) have data; the rendered props are imported and therefore
have instanceDataSize == 0. The complete prop art (meshes + textures) lives in
Data\WBData.big's data\worldbuilder.bin, which is uncompressed (first
four bytes are the stream checksum, not 10 FB), so ra3.models reads the
1.1 GB stream lazily — the manifest is parsed and only the needed instance
slices are read.
W3DMesh compiled layout (offsets from the instance start):
+4 vertexBufferPtr +52 triangleCount +56 triangleItemPtr
+60 shaderNameLength +64 shaderNamePtr
vertexBuffer: +0 numVertices, +4 stride, +8 elementDataPtr,
+12 declarationBytes, +16 declarationPtr
declaration: text "p0:00:3f32 n0:0C:3f32 t0:1C:2f32" (D3D9 usage:index:offset:type)
triangles: triangleCount * { u32 indexCount, u32 indexPtr } (24 B each), u32 indices
The diffuse texture is found through the mesh's FXShaderConstants
(+76 count, +80 items): a texture-valued constant (TypeId 0xA59096A6)
names its role (DiffuseTexture, NormalMap, SpecMap) and points at a
1-based index into the mesh's cross-asset references, which resolve by
(typeId, instanceId). The Texture instance embeds a standard DDS file (at
u32@+4, or scan for "DDS "); ra3.models::decode_dds decodes DXT1/3/5 and
uncompressed 16/24/32-bit.
Vertices are stored in bone space, not object space. A mesh whose vertex
declaration carries blend data (i0:..:4u8 w0:..:4u8n, e.g. buildings and
vehicles) must be skinned; props without it (BB_GRASS02, IF_STREETSEGMENT01)
are already in object space. The skeleton is a W3DHierarchy asset named after
the mesh's instance prefix (W3DMesh:FI_STRUCTURE_02.NEWSKIN_CIV01 →
W3DHierarchy:FI_STRUCTURE_02). Compiled layout:
W3DHierarchy: u32 pad, u32 boneCount, u32 headerBytes, then boneCount records
100 B each:
u32 nameHash, i32 parent (-1 = root), f32 translation[3],
f32 quaternion[4] (x, y, z, w), f32 matrix[12]
The default (bind) pose is rebuilt by composing each bone's local
translation/quaternion down the parent chain, then skinnedPos = Σ wᵢ · (Rᵢ·p + Tᵢ) (and the normal by the rotation only). ra3.models does this before
placing the mesh at the map object's (x, y, angle).
The compiled shader (below) binds one joint per vertex — WorldBones holds
64 bones as 2 float4 each (quaternion c[128+2j], translation c[129+2j]) —
so the skin is rigid: blendindices.x selects the joint, remapped through the
mesh's per-model bone table (vertex-descriptor +0x14 = bone count,
+0x18 = u16 bone indices into the W3DHierarchy). Applying the raw blend
index without that remap tears models apart (FI_BUILDING01's table is
[0,14,15,16,17,18], not [0..5]).
The map objects that lie flat on the ground (sidewalks, deck pieces) are
coplanar with the terrain; retail biases their depth in the shader so they do
not z-fight. The object pass reproduces that with a small negative depth bias
(Vulkan depthBiasConstant/SlopeFactor, and a half-unit bias in the software
rasteriser).
Meshes whose material has no diffuse texture (DefaultW3D.fx, BasicW3D.fx —
FXLIGHTS/ambient helper billboards) are not opaque geometry and are skipped;
drawing them fills the frame with garbage triangles. Likewise the
BuildingsGenericDamageFill.fx damage-fill sub-meshes are skipped: they are
the wrecked-interior shell (e.g. CBBuilding_Wood, an orange plank texture) that
retail only reveals through damage holes, but our opaque pass would paint it over
the main shell and tint whole buildings warm.
Official shader behaviour (Shaders.big → *.fxo)
The compiled D3D9 effects in Data\Shaders.big name their parameters, so the
model pipeline is recoverable. buildingsgeneric.fxo (BuildingsGeneric.fx)
vertex stage: skinning (above), World/ViewProjection, and vertex color
c0 multiplied into the lit color
((Ambient·AmbientColor + Σ DLᵢ.Color·max(N·DLᵢ,0)) · DiffuseColor · vertexColor).
Pixel stage samples DiffuseTexture/NormalMap/SpecMap/DamagedTexture/
CloudTexture (all at UV0, except DamagedTexture at v0.wz = transposed
UV1), then final.rgb *= TintColor and *= ShroudTexture.rgb. DiffuseVelocity
is not used for static structures. So the diffuse texture is UV0 and is tinted by
vertex color and TintColor; basicw3d.fxo instead modulates a single
macro/lightmap with (vertexColor + additive) * diffuse * 2 and has no normal
map. (Recovered by disassembling the embedded vs_3_0/ps_3_0 bytecode.)
Water / ocean (Ocean.fx, OceanDisplacement.fx, RiverWater.fx, UnderwaterDeferred.fx)
The SAGE water surface is reconstructed by OpenSAGE as
Assets/Shaders/Water.vert+Water.frag (same family as retail Ocean.fx). The
surface is a world-space mesh drawn with alpha blending, fed by two render
targets rendered before it: a reflection map (scene from the mirrored camera
about GlobalWaterSettings.ReflectionPlaneZ) and a refraction map + depth.
The fragment model (Water.frag) is:
waterUV = worldPos.xy / 320; a scrollingWaterTexturesupplies both a flow distortion ((tex.xy*2-1)*0.05) and the flow layer; aBumpTexturesupplies the surfaceworldNormal.fresnelFactor = dot(viewVector, +Z); reflection/refraction are sampled in screen space (gl_FragCoord / ViewportSize), each displaced by the distortion.linearWaterDepth = linearize(RefractionDepth) - linearize(gl_FragCoord.z);alpha = clamp((linearWaterDepth/2)/TransparentWaterDepth, 0, TransparentWaterMinOpacity).final = diffuseColor * textureColor * cloudColor, then mixed withmix(reflectionColor, refractionColor, fresnelFactor)(both maps on) or just one of them, perIsRenderReflection/IsRenderRefraction.- Per-time-of-day
WaterSet:WaterTexture,UScrollPerMS/VScrollPerMS,DiffuseColor,TransparentDiffuseColor;WaterTransparencysuppliesTransparentWaterDepth/TransparentWaterMinOpacity, the skybox faces,RiverTransparencyMultiplier,ReflectionPlaneZ/ReflectionOn.
OpenRA3 has no water mesh or reflection/refraction targets (its terrain is
raymarched), so the model is folded into the terrain pass (shaders/terrain.frag
and its HLSL/GLSL-ES/WGSL twins): the ray's water-plane hit takes a scrolling
wave normal built from the retail bump map combined with a de-gridded procedural
wave, Schlick fresnel (F0 = 0.02), a sky reflection and a depth-graded
refraction, SAGE diffuse + specular lighting, a depth-based transparency fade,
and — when the camera is below ReflectionPlaneZ — an underwater tint/fog
(UnderwaterDeferred.fx). The two retail maps
art/terrain/ra3_deepocean.tga (flow/distortion) and ra3_deepocean_nrm.tga
(bump normal) are appended as the last two layers of the terrain atlas
(ra3::terrain::build_gpu_terrain, water_flow = layer_count - 2,
water_normal = layer_count - 1), so every backend samples them with the
existing atlas binding; absent maps fall back to a neutral layer. True
reflection/refraction render targets are still the next step.
GPUParticleOceanDisplacement.fx drives wave displacement from a GPU particle
buffer and has no analogue here.
The map's ObjectsList chunk is a list of nested Object assets —
Coord3D, Z angle, RoadType u32, a u16-prefixed type-name and a property
list whose keys index the shared name table (ra3.map::parse_objects). Each
type resolves to the W3DMesh parts whose instance name equals it or starts
with <type>..
Roads / sidewalks (Road assets)
The flat sidewalk and road decals are not meshes: the map places them as
consecutive pairs of objects — a RoadType::Start (2) and a
RoadType::End (4) at the segment's two ends (Angled 8, TightCurve
64 and EndCap 128 are curve/cap flags; BridgeStart/End 16/32 are
bridges). The road's type-name (IslandFortressSidewalk01, ...) resolves to a
compiled Road asset in the same art stream:
Road: u32 version, u32 pad, u32 textureCount, f32 roadWidth, f32 pad,
f32 ???, then textureCount texture references (diffuse, normal)
ra3.models pairs the objects in file order, emits a flat quad ribbon of
roadWidth along each segment (overlapping the joins by half a width), sampled
with the diffuse texture, and lifts it onto the terrain. Retail gives roads a
small depth offset toward the screen so they do not clip into the ground; the
object pass reproduces that with the same negative depth bias used for the other
ground decals (depthBiasConstant/SlopeFactor on Vulkan, a half-unit bias in the
software rasteriser).
Map display names
The skirmish map list labels live in Data\English.big's
data\gamestrings.csf (the newest Lang-English*.big wins) under
MAP:<UPPERCASE_ID>, e.g. MAP:MAP_MP_2_FEASEL4 = "Battlebase Beta". CSF
values are UTF-16 code units whose low byte is XORed with 0xFF
(ra3::map::parse_map_names). openra3 extract writes the decoded table to
maps/map_names.tsv; openra3 menu shows them instead of the raw map id.
Still to recover
MPPositionListlayout (per-player starts for maps without waypoints).- Cliff textures and the
CliffTextureMappingUV remap (CliffTexturesis parsed but not yet drawn). - The
Roadnetwork mesher (the map's sidewalk/road objects referenceRoadtemplates, notW3DMeshassets). - W3D container/hierarchy assembly and animation (props are drawn as their static mesh parts; skinned/animated in-game models are not).
- Compiled asset blobs (
global.bin,static.*.bin) and the.manifestschema used to deserialise them.