5.3 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).
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).
Still to recover
MPPositionListlayout (per-player starts for maps without waypoints).- Map dimensions /
HeightMapData/BlendTileData. - Compiled asset blobs (
map.bin,global.bin,static.*.bin) and the.manifestschema used to deserialise them.