OpenRA3 v0.0.1: C++26 modules, BIG4/RefPack reader, minimal skirmish

This commit is contained in:
EnderTheCoder
2026-09-12 00:26:45 +08:00
commit 3ad380c81e
24 changed files with 2630 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
---
Language: Cpp
BasedOnStyle: LLVM
Standard: c++20
UseTab: Never
IndentWidth: 4
TabWidth: 4
NamespaceIndentation: All
AccessModifierOffset: -4
IndentCaseLabels: true
ColumnLimit: 150
KeepEmptyLinesAtTheStartOfBlocks: true
MaxEmptyLinesToKeep: 1
PointerAlignment: Right
ReferenceAlignment: Right
DerivePointerAlignment: false
AllowShortIfStatementsOnASingleLine: WithoutElse
AllowShortBlocksOnASingleLine: Never
AllowShortFunctionsOnASingleLine: Inline
Cpp11BracedListStyle: true
ConstructorInitializerAllOnOneLineOrOnePerLine: true
BreakConstructorInitializers: BeforeColon
ConstructorInitializerIndentWidth: 4
BinPackParameters: false
BinPackArguments: true
AlignAfterOpenBracket: Align
SpaceBeforeRangeBasedForLoopColon: false
AlwaysBreakTemplateDeclarations: Yes
SortIncludes: Never
FixNamespaceComments: false
+12
View File
@@ -0,0 +1,12 @@
.git
build
build-*
cmake-build-*
out
artifacts_bin
reference
*.gcm
gcm.cache
dev-image.tar
.idea
.vscode
+32
View File
@@ -0,0 +1,32 @@
# build trees
build/
build-*/
cmake-build-*/
out/
artifacts_bin/
*.gcm
gcm.cache/
# CI image artifacts
dev-image.tar
# reference sources (fetched, not vendored)
reference/
# retail game data must never be committed (read from the user's install)
gamedata/
local/
*.big
*.manifest
*.relo
*.imp
# tooling
.idea/
.vscode/
.cache/
compile_commands.json
# local data dumps / RE artifacts
re-data/
*.log
+64
View File
@@ -0,0 +1,64 @@
stages:
- image
- build
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: ""
# Where to push the dev image. Leave empty to not push at all.
# Examples: "192.168.1.11:9090/<group>/openra3" or "docker.io/<user>/openra3"
IMAGE_NAME: ""
APT_MIRROR: "http://mirrors.tuna.tsinghua.edu.cn/ubuntu"
image_build:
stage: image
image: docker:latest
services:
- name: docker:dind
# Pull docker.io images through the intranet Harbor pull-through cache;
# plain HTTP registry, hence --insecure-registry.
command:
- --registry-mirror=http://192.168.1.11:9090/dockerhub
- --insecure-registry=192.168.1.11:9090
script:
- docker build --target dev -t openra3-dev:local --build-arg APT_MIRROR=$APT_MIRROR .
- docker save openra3-dev:local -o dev-image.tar
- |
if [ -n "$IMAGE_NAME" ]; then
# add `docker login` here if IMAGE_NAME points to a private registry
docker tag openra3-dev:local $IMAGE_NAME:$CI_COMMIT_SHORT_SHA
docker push $IMAGE_NAME:$CI_COMMIT_SHORT_SHA
fi
artifacts:
name: "dev-image-$CI_COMMIT_SHORT_SHA"
paths:
- dev-image.tar
expire_in: 1 day
tags:
- docker
build_test_package:
stage: build
image: docker:latest
services:
- name: docker:dind
command:
- --registry-mirror=http://192.168.1.11:9090/dockerhub
- --insecure-registry=192.168.1.11:9090
needs:
- image_build
before_script:
- docker load -i dev-image.tar
script:
- docker run --rm -v "$(pwd):/work" -w /work openra3-dev:local cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
- docker run --rm -v "$(pwd):/work" -w /work openra3-dev:local cmake --build build -j
- docker run --rm -v "$(pwd):/work" -w /work openra3-dev:local ctest --test-dir build --output-on-failure
- docker run --rm -v "$(pwd):/work" -w /work openra3-dev:local bash -c "mkdir -p artifacts_bin && cp build/bin/openra3 artifacts_bin/"
artifacts:
name: "$CI_PROJECT_NAME-executables-$CI_COMMIT_SHORT_SHA"
paths:
- artifacts_bin/
expire_in: 7 days
tags:
- docker
+105
View File
@@ -0,0 +1,105 @@
cmake_minimum_required(VERSION 3.28)
project(OpenRA3 VERSION 0.0.1 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 26)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
option(OPENRA3_WERROR "Treat warnings as errors" OFF)
function(openra3_target_defaults target)
target_compile_options(${target} PRIVATE -Wall -Wextra -Wpedantic)
if(OPENRA3_WERROR)
target_compile_options(${target} PRIVATE -Werror)
endif()
endfunction()
# --- engine core: fundamental types, math, strings, random, message stream ---
add_library(ra3_core STATIC)
target_sources(ra3_core
PUBLIC FILE_SET CXX_MODULES FILES
src/core/ra3.core.cppm
)
openra3_target_defaults(ra3_core)
# --- game logic: objects, players, teams, spatial partition, game loop ---
add_library(ra3_logic STATIC)
target_sources(ra3_logic
PUBLIC FILE_SET CXX_MODULES FILES
src/logic/ra3.logic.cppm
)
target_link_libraries(ra3_logic PUBLIC ra3_core)
openra3_target_defaults(ra3_logic)
# --- client: headless display/input abstraction and the client facade ---
add_library(ra3_client STATIC)
target_sources(ra3_client
PUBLIC FILE_SET CXX_MODULES FILES
src/client/ra3.client.cppm
)
target_link_libraries(ra3_client PUBLIC ra3_core ra3_logic)
openra3_target_defaults(ra3_client)
# --- RA3 game definitions: factions, player templates, science, special powers ---
add_library(ra3_game STATIC)
target_sources(ra3_game
PUBLIC FILE_SET CXX_MODULES FILES
src/game/ra3.game.cppm
)
target_link_libraries(ra3_game PUBLIC ra3_core ra3_logic)
openra3_target_defaults(ra3_game)
# --- filesystem: BIG4 archives, RefPack codec, local install locator ---
add_library(ra3_fs STATIC)
target_sources(ra3_fs
PUBLIC FILE_SET CXX_MODULES FILES
src/fs/ra3.fs.cppm
)
target_link_libraries(ra3_fs PUBLIC ra3_core)
openra3_target_defaults(ra3_fs)
# --- map discovery/loading (real .big data, user's local install) ---
add_library(ra3_map STATIC)
target_sources(ra3_map
PUBLIC FILE_SET CXX_MODULES FILES
src/map/ra3.map.cppm
)
target_link_libraries(ra3_map PUBLIC ra3_core ra3_fs)
openra3_target_defaults(ra3_map)
# --- minimal headless skirmish simulation ---
add_library(ra3_skirmish STATIC)
target_sources(ra3_skirmish
PUBLIC FILE_SET CXX_MODULES FILES
src/skirmish/ra3.skirmish.cppm
)
target_link_libraries(ra3_skirmish PUBLIC ra3_core ra3_logic ra3_game ra3_map)
openra3_target_defaults(ra3_skirmish)
# --- umbrella module re-exporting the whole SDK ---
add_library(ra3 STATIC)
target_sources(ra3
PUBLIC FILE_SET CXX_MODULES FILES
src/ra3.cppm
)
target_link_libraries(ra3 PUBLIC ra3_core ra3_logic ra3_client ra3_game ra3_fs ra3_map ra3_skirmish)
openra3_target_defaults(ra3)
# --- the headless game executable ---
add_executable(openra3 apps/openra3/main.cpp)
target_link_libraries(openra3 PRIVATE ra3)
openra3_target_defaults(openra3)
# --- unit tests ---
enable_testing()
add_executable(ra3_tests tests/ra3_tests.cpp)
target_link_libraries(ra3_tests PRIVATE ra3)
openra3_target_defaults(ra3_tests)
add_test(NAME ra3_tests COMMAND ra3_tests)
+58
View File
@@ -0,0 +1,58 @@
# syntax=docker/dockerfile:1
# dev target: full toolchain + debug tools for development and debugging.
# Base image and compiler are the latest stable (ubuntu:26.04 ships gcc/g++ 16).
FROM ubuntu:26.04 AS dev
ENV DEBIAN_FRONTEND=noninteractive
# Optional apt mirror override, e.g. --build-arg APT_MIRROR=http://mirrors.tuna.tsinghua.edu.cn/ubuntu
ARG APT_MIRROR=""
RUN if [ -n "$APT_MIRROR" ]; then \
sed -i -e "s|http://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
-e "s|http://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
-e "s|http://ports.ubuntu.com/ubuntu-ports|${APT_MIRROR}|g" \
/etc/apt/sources.list /etc/apt/sources.list.d/*.sources 2>/dev/null || true; \
fi
RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
--mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
cmake \
g++-16 \
gdb \
git \
ninja-build \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
# Pin the toolchain to GCC 16 (the distro default is still GCC 15).
ENV CC=gcc-16
ENV CXX=g++-16
RUN update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-16 100 \
&& update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-16 100 \
&& update-alternatives --install /usr/bin/c++ c++ /usr/bin/g++-16 100
WORKDIR /work
COPY . .
# C++26 modules need CMake >= 3.28, the Ninja generator and a module-aware gcc.
RUN cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
&& cmake --build build -j \
&& ctest --test-dir build --output-on-failure
# deploy target: runtime dependencies + final binary, minimal and fast.
FROM ubuntu:26.04 AS deploy
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
libstdc++6 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=dev /work/build/bin/openra3 /app/openra3
ENTRYPOINT ["/app/openra3"]
+20
View File
@@ -0,0 +1,20 @@
OpenRA3
Copyright (C) 2026 OpenRA3 contributors
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
this program. If not, see <https://www.gnu.org/licenses/>.
The architecture of this project is derived from
electronicarts/CnC_Generals_Zero_Hour, which is licensed under the GNU General
Public License v3 with additional terms. Command & Conquer and Red Alert are
trademarks of Electronic Arts. This project is unaffiliated with and
unsupported by EA.
+115
View File
@@ -0,0 +1,115 @@
# OpenRA3
A from-scratch, portable re-implementation of **Command & Conquer: Red Alert 3**
in **C++26** using **C++ modules**, built with **GCC 16**.
Red Alert 3 runs SAGE 2.0. EA never released that engine's C++ source, but it
did open-source the closely related SAGE 1.0 engine as
[`electronicarts/CnC_Generals_Zero_Hour`](https://github.com/electronicarts/CnC_Generals_Zero_Hour)
(GPLv3). OpenRA3 uses that tree as the architectural reference and reconstructs
the RA3-specific behaviour by decompiling the retail `ra3_1.12.game` binary with
Ghidra.
> OpenRA3 ships **no game assets or binaries**. You must own Red Alert 3. The
> engine reads your own local installation at runtime; nothing from it is ever
> copied into this repository. Command & Conquer and Red Alert are trademarks of
> Electronic Arts; this project is unaffiliated with and unsupported by EA.
## Status
OpenRA3 is at **v0.0.1**. The engine compiles and runs headless, and a **minimal
skirmish** is playable: it reads a real multiplayer map out of your install,
recovers the player start waypoints, and simulates two sides earning credits,
training units and fighting until one base falls. There is no renderer yet, so
the match is observed as a deterministic headless result.
```text
$ openra3 skirmish --game-dir "/game" --map map_mp_2_feasel4
OpenRA3 skirmish map=map_mp_2_feasel4 source=archive:map_mp_2_feasel4 seed=1
start positions:
P0 (1338, 1940)
P1 (1291, 1404)
result: decided winner=0 frames=3397 (113.2 s)
P0 Commander Allied money= 129 units=19 kills=21 losses=3
P1 AI Soviet money= 840 units= 0 kills=3 losses=21
```
## Offline only
OpenRA3 implements **offline skirmish and, eventually, LAN/single-player**.
There is deliberately **no online mode**: no EA account, no online service, no
matchmaking, no GameSpy/Steam integration, and no networking in the simulation.
The match loop is deterministic and self-contained so it can be replayed and
tested without any external service.
## Assets
The engine reads the retail game from your local install:
- default path `C:\Red Alert 3`, or `--game-dir DIR`, or `$RA3_GAME_DIR`;
- `Data\MapsMultiplayer.big` is read for the map catalog;
- maps are unpacked from `BIG4` + RefPack and their start waypoints recovered.
**No game data is committed or distributed.** `reference/` (the GPLv3 Generals
source) and any extracted assets are git-ignored; `tools/fetch_reference.sh`
fetches the former on demand. Without an install, the engine falls back to a
built-in test map so the project still builds and runs in CI.
## Layout
| Path | Purpose |
| --- | --- |
| `src/core/ra3.core.cppm` | fundamental types, math, strings, random, message stream |
| `src/logic/ra3.logic.cppm` | objects, players, teams, spatial partition, game loop |
| `src/client/ra3.client.cppm` | display abstraction + client facade / frame loop |
| `src/game/ra3.game.cppm` | RA3 sides, player templates, skirmish defaults |
| `src/fs/ra3.fs.cppm` | `BIG4` archives, RefPack codec, local install locator |
| `src/map/ra3.map.cppm` | map catalog, `EAR`/RefPack unwrap, start waypoints |
| `src/skirmish/ra3.skirmish.cppm` | units, economy, AI, combat, win condition |
| `src/ra3.cppm` | umbrella module re-exporting the SDK |
| `apps/openra3/main.cpp` | `maps` / `skirmish` CLI |
| `tests/ra3_tests.cpp` | smoke tests (run via `ctest`) |
| `tools/` | reference fetch + Ghidra-driven reconstruction helpers |
| `docs/` | architecture, reverse-engineering notes, roadmap |
| `Dockerfile` | `dev` (toolchain) and `deploy` (runtime) targets |
| `.gitlab-ci.yml` | build → test → package pipeline |
## Build
The host needs no toolchain: build inside the container.
```bash
docker build --target dev -t openra3-dev:local .
docker run --rm -v "$PWD:/work" -w /work openra3-dev:local cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
docker run --rm -v "$PWD:/work" -w /work openra3-dev:local cmake --build build -j
docker run --rm -v "$PWD:/work" -w /work openra3-dev:local ctest --test-dir build --output-on-failure
```
Behind a slow or blocked mirror, pass `--build-arg APT_MIRROR=<url>`.
## Running a skirmish
```bash
# list the maps in your install
docker run --rm -v "/path/to/Red Alert 3:/game:ro" openra3-dev:local \
./build/bin/openra3 maps --game-dir /game
# play a headless skirmish on a real map
docker run --rm -v "/path/to/Red Alert 3:/game:ro" openra3-dev:local \
./build/bin/openra3 skirmish --game-dir /game --map map_mp_2_feasel4 --seed 7
```
`--frames N` caps the simulation length (default 15 minutes of game time at
30 Hz). The result is deterministic for a given map and seed.
## Reverse engineering
The reconstruction is driven by Ghidra against the retail binary. See
[`docs/REVERSE_ENGINEERING.md`](docs/REVERSE_ENGINEERING.md) for the workflow and
the recovered symbol map, and [`tools/`](tools/) for the helpers.
## License
Project code: GPLv3, matching the `CnC_Generals_Zero_Hour` reference (see
[`LICENSE`](LICENSE)).
+1
View File
@@ -0,0 +1 @@
0.0.1
+125
View File
@@ -0,0 +1,125 @@
#include <cstdio>
#include <exception>
#include <filesystem>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
import ra3;
namespace {
auto print_usage() -> void {
std::puts("OpenRA3 - headless Red Alert 3 reconstruction");
std::puts("usage:");
std::puts(" openra3 maps [--game-dir DIR]");
std::puts(" openra3 skirmish [--game-dir DIR] [--map ID] [--frames N] [--seed N]");
std::puts("env: RA3_GAME_DIR (default C:\\Red Alert 3); game data is never copied into the repo");
}
auto option_value(const std::vector<std::string> &args, std::string_view name) -> std::optional<std::string> {
for (std::size_t i = 0; i + 1U < args.size(); ++i) {
if (args[i] == name) return args[i + 1U];
}
return std::nullopt;
}
/** Build `<dir>/Data/<name>` portably (avoids path/char[] ambiguity). */
auto data_path(const std::filesystem::path &dir, std::string_view name) -> std::filesystem::path {
return dir / std::string{"Data"} / std::string{name};
}
auto command_maps(const std::optional<std::filesystem::path> &game_dir) -> int {
const auto dir = ra3::fs::find_game_dir(game_dir);
if (!dir) {
std::puts("no Red Alert 3 install found; pass --game-dir or set RA3_GAME_DIR");
return 1;
}
const auto archive_path = data_path(*dir, "MapsMultiplayer.big");
const auto archive = ra3::fs::big_archive::open(archive_path);
const auto catalog = ra3::map::map_catalog::from_archive(archive);
const auto name = archive_path.filename().string();
std::printf("maps in %s: %zu\n", name.c_str(), catalog.size());
for (const auto &entry: catalog.maps()) {
std::printf(" %-28s %6u KB packed / %6u KB unpacked\n", entry.id.c_str(), entry.stored_size / 1024U, entry.unpacked_size / 1024U);
}
return 0;
}
auto command_skirmish(const std::vector<std::string> &args, const std::optional<std::filesystem::path> &game_dir) -> int {
ra3::skirmish::match_config config;
std::vector<ra3::map::start_position> starts;
std::string source = "builtin";
const auto requested_map = option_value(args, "--map");
if (const auto dir = ra3::fs::find_game_dir(game_dir)) {
try {
const auto archive = ra3::fs::big_archive::open(data_path(*dir, "MapsMultiplayer.big"));
const auto catalog = ra3::map::map_catalog::from_archive(archive);
const ra3::map::map_info *info = nullptr;
if (requested_map) {
info = catalog.find(*requested_map);
if (info == nullptr) std::printf("map '%s' not found; falling back\n", requested_map->c_str());
} else if (catalog.size() > 0U) {
info = &catalog.maps().front();
}
if (info != nullptr) {
const auto loaded = ra3::map::load_map(archive, *info);
starts = loaded.starts;
config.map_id = info->id;
source = "archive:" + info->id;
}
} catch (const std::exception &error) {
std::printf("map load failed: %s\n", error.what());
}
}
if (starts.size() < 2U) {
starts = ra3::skirmish::builtin_start_positions();
source = "builtin";
}
if (const auto seed = option_value(args, "--seed")) config.seed = static_cast<ra3::core::uint32>(std::stoul(*seed));
if (const auto frames = option_value(args, "--frames")) config.max_frames = static_cast<ra3::core::uint32>(std::stoul(*frames));
auto match = ra3::skirmish::skirmish_match::create(config, starts);
const auto summary = match.run();
std::printf("OpenRA3 skirmish map=%s source=%s seed=%u\n", config.map_id.c_str(), source.c_str(), config.seed);
std::puts("start positions:");
for (std::size_t i = 0; i < starts.size(); ++i) {
std::printf(" P%zu (%.0f, %.0f)\n", i, static_cast<double>(starts[i].x), static_cast<double>(starts[i].y));
}
std::printf("result: %s winner=%d frames=%u (%.1f s)\n", summary.decided ? "decided" : "timeout", summary.winner, summary.frames,
static_cast<double>(summary.frames) / 30.0);
for (const auto &player: match.players()) {
const auto side = std::string{ra3::game::to_string(player.side)};
std::printf(" P%u %-10s %-7s money=%6d units=%2zu kills=%d losses=%d\n", player.index, player.name.c_str(), side.c_str(), player.money,
match.unit_count(player.index), player.kills, player.losses);
}
return 0;
}
}
auto main(int argc, char **argv) -> int {
const std::vector<std::string> args{argv + 1, argv + argc};
if (args.empty()) {
print_usage();
return 0;
}
const auto command = args[0];
const std::vector<std::string> rest{args.begin() + 1, args.end()};
std::optional<std::filesystem::path> game_dir;
if (const auto dir = option_value(rest, "--game-dir")) game_dir = *dir;
if (command == "maps") return command_maps(game_dir);
if (command == "skirmish") return command_skirmish(rest, game_dir);
if (command == "help" || command == "--help" || command == "-h") {
print_usage();
return 0;
}
print_usage();
return 2;
}
+78
View File
@@ -0,0 +1,78 @@
# Architecture
OpenRA3 is organised as a stack of C++ modules. Each layer may import the ones
below it, never the ones above.
```
┌───────────────────────────────┐
applications │ openra3 (apps/openra3) │
└───────────────┬───────────────┘
│ import ra3
┌───────────────▼───────────────┐
umbrella │ ra3 (re-exports everything) │
└───────────────┬───────────────┘
┌───────────────┬────────┴────────┬───────────────┐
▼ ▼ ▼ ▼
ra3.skirmish ra3.client ra3.game ra3.map
match rules display/loop RA3 sides map catalog
│ │ │ │
└───────┬───────┴────────┬────────┘ │
▼ ▼ ▼
ra3.logic ra3.core ra3.fs
simulation types/math/random BIG4 + RefPack
```
## Module responsibilities
### `ra3.core`
The vocabulary every other module shares, mirroring SAGE's `GameEngine/Common`:
`real`/`int32`/`uint32`, `coord3d`/`coord2d`/`rgb_color`, `ascii_string` +
`make_name_key`, the deterministic `random` stream, and the `message_stream`
command bus (node layout derived from retail `MessageStream::appendMessage`,
`0x0060c4a0`).
### `ra3.logic`
The deterministic simulation, mirroring SAGE's `GameLogic`: `thing` → `object`
with pluggable `update_module`s, `player`/`player_list`, the `partition_manager`
spatial grid, and the 30 Hz `game_logic` driver (`prepare_new_game` /
`start_new_game` / `update`).
### `ra3.client`
The presentation boundary: an abstract `display` with a `headless_display`
implementation, and `game_client`, the seam a future W3D/D3D9 renderer plugs
into.
### `ra3.game`
Red Alert 3 data that SAGE keeps in `PlayerTemplate`: the three sides
(`faction` flags `Empire=2`, `Allied=4`, `Soviet=8`, `Random=7`, recovered from
the retail skirmish setup) and skirmish defaults.
### `ra3.fs`
Reading the user's installation. Implements the `BIG4` archive container and
EA's RefPack codec, plus `find_game_dir` (`--game-dir` / `$RA3_GAME_DIR` /
`C:\Red Alert 3`). Only the archive index is held in memory; payloads are read
on demand.
### `ra3.map`
Map discovery and loading: scans `MapsMultiplayer.big` for main map entries,
unwraps the two compression layers (`BIG4` RefPack → `EAR\0` wrapper → RefPack →
`CkMp`), and recovers `Player_N_Start` waypoint coordinates. Degenerate
extractions are rejected so the caller can fall back.
### `ra3.skirmish`
The minimal match: two players, unit classes (harvester/infantry/tank/base),
passive + harvester income, a simple build AI, movement and combat on a fixed
30 Hz step, and a base-destruction win condition. Fully deterministic.
## Design rules
1. **No raw owning pointers.** Ownership is `std::unique_ptr`; the partition
manager holds non-owning `thing *` views only.
2. **Portable simulation.** No platform APIs in `ra3.core` / `ra3.logic`. All
platform concerns live behind `display`.
3. **Determinism.** Anything that can diverge between runs (random, iteration
order) is explicit and seedable.
4. **RE-traceable.** Where a structure or constant comes from the retail
binary, the address is cited in the comment.
5. **No bundled assets, no online.** Game data is read from the user's install
and never committed; there is no networking or online service.
+135
View File
@@ -0,0 +1,135 @@
# Reverse engineering workflow
OpenRA3 is reconstructed from two sources:
1. **Architecture** — the GPLv3 SAGE 1.0 tree,
[`electronicarts/CnC_Generals_Zero_Hour`](https://github.com/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.
2. **Facts** — the retail `ra3_1.12.game` binary (image base `0x400000`),
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
```bash
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
1. Pick a subsystem from the reference tree (e.g. `MessageStream`).
2. Find its RTTI/vtable in the binary via `search_strings` and xrefs.
3. Decompile the constructor to recover object size and field init order.
4. Decompile the hot methods to recover field meaning.
5. Write the OpenRA3 module with a comment citing the address.
6. 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); `starting` guard flag at `+0xa7`, raised while a new match
initialises.
- **`MessageStream`** — intrusive doubly linked list; head at `+0x24`, tail at
`+0x28`. `appendMessage` (`0x0060c4a0`) allocates a `0x74`-byte node:
`+0x00` next, `+0x04` prev, `+0x08` owner stream, `+0x0c` message type,
`+0x10` player index, `+0x18` capacity, `+0x1c` data pointer (`node + 0x20`).
- **`Player`** — money via `std::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` (stride `0x5c`, 6 slots), faction at
`slot + 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 `float`s) 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
- `MPPositionList` layout (per-player starts for maps without waypoints).
- Map dimensions / `HeightMapData` / `BlendTileData`.
- Compiled asset blobs (`map.bin`, `global.bin`, `static.*.bin`) and the
`.manifest` schema used to deserialise them.
+41
View File
@@ -0,0 +1,41 @@
# Roadmap
OpenRA3 is a very large undertaking. This roadmap is deliberately honest about
scope: reconstructing a 2008 RTS engine from a decompiler plus a related open
engine is a multi-year, multi-person effort. The milestones below are ordered so
that each one produces something that builds and runs.
## Done
- [x] **v0.0.1 — skeleton + minimal skirmish.** C++26 modules, GCC 16,
CMake/Ninja, Docker `dev`/`deploy`, GitLab CI. Reads `BIG4`/RefPack data
from a local install, recovers map start waypoints, and runs a
deterministic headless two-player skirmish to a decision.
## Next
- [ ] **v0.1.0 — complete map parsing.** Parse `MPPositionList` for maps that do
not use `Player_N_Start` waypoints; recover map dimensions, terrain
heightmap and placement grid; place real starting structures/units.
- [ ] **v0.2.0 — data & file formats.** Parse compiled gameplay assets
(`GameObject`, `WeaponTemplate`, `ArmorTemplate`, `LocomotorTemplate`) so
units use the real balance numbers instead of OpenRA3's stand-ins.
- [ ] **v0.3.0 — deterministic simulation.** Real update modules, locomotor
movement, weapons/damage/armour resolution, build queues and the tech
tree, pathfinding and shroud.
- [ ] **v0.4.0 — AI.** Skirmish AI: build states, team composition, attack
waves (the reference tree's `AI*` modules).
- [ ] **v0.5.0 — renderer.** A W3D/D3D9 (or portable Vulkan/OpenGL) client
backend implementing `ra3::client::display`, plus input.
- [ ] **v0.6.0 — content.** Load real maps, units, powers and strings; play a
skirmish end-to-end with a UI.
## Cross-cutting tracks
- **RE depth** — keep recovering retail layouts (see
[`REVERSE_ENGINEERING.md`](REVERSE_ENGINEERING.md)); every structure gets an
address citation and a test.
- **Determinism & replay** — the logic random stream and frame ordering must be
reproducible; replay format and a golden-replay test suite.
- **Offline only** — no online mode. Multiplayer, if pursued, is LAN lockstep on
the message stream, never an online service.
+74
View File
@@ -0,0 +1,74 @@
module;
#include <cstdint>
#include <memory>
#include <string_view>
export module ra3.client;
import ra3.core;
import ra3.logic;
/**
* The presentation layer: an abstract output surface plus the client facade
* that owns the simulation and drives the frame loop.
*
* A future W3D/D3D9 backend implements the same `display` interface; the
* default headless backend keeps OpenRA3 runnable on a CPU-only box.
*/
export namespace ra3::client {
using ra3::core::uint32;
/**
* Abstract output surface presented once per logic frame.
*/
class display {
public:
virtual ~display() = default;
virtual auto begin_frame() -> void = 0;
virtual auto end_frame() -> void = 0;
[[nodiscard]] virtual auto name() const -> std::string_view = 0;
};
/**
* A display that renders nothing and consumes no resources.
*/
class headless_display final : public display {
public:
auto begin_frame() -> void override {}
auto end_frame() -> void override {}
[[nodiscard]] auto name() const -> std::string_view override { return "headless"; }
};
/**
* Owns the simulation and its display, and drives the frame loop.
*/
class game_client {
public:
game_client() = default;
[[nodiscard]] auto logic() -> logic::game_logic & { return logic_; }
[[nodiscard]] auto display() -> client::display & { return *display_; }
auto set_display(std::unique_ptr<client::display> value) -> void { display_ = std::move(value); }
/**
* Run `frames` logic steps, bracketing each with a present.
*
* @param target Simulation to advance.
* @param frames Number of 30 Hz logic frames to run.
*/
auto run(logic::game_logic &target, uint32 frames) -> void {
for (uint32 i = 0; i < frames; ++i) {
display_->begin_frame();
target.update();
display_->end_frame();
}
}
private:
logic::game_logic logic_;
std::unique_ptr<client::display> display_ = std::make_unique<headless_display>();
};
}
+247
View File
@@ -0,0 +1,247 @@
module;
#include <cmath>
#include <compare>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <optional>
#include <ostream>
#include <span>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
export module ra3.core;
/**
* Fundamental engine types shared by every OpenRA3 module.
*
* The vocabulary mirrors SAGE 2.0 (the engine Red Alert 3 runs on) as it
* appears in the CnC Generals source, with layouts cross-checked against the
* retail `ra3_1.12.game` binary through Ghidra.
*/
export namespace ra3::core {
inline constexpr int version_major = 0;
inline constexpr int version_minor = 0;
inline constexpr int version_patch = 1;
using real = float;
using int32 = std::int32_t;
using uint32 = std::uint32_t;
using uint16 = std::uint16_t;
using uint8 = std::uint8_t;
using usize = std::size_t;
/** SAGE advances the simulation on a fixed 30 Hz logic tick. */
inline constexpr int logic_frames_per_second = 30;
inline constexpr real logic_frame_seconds = 1.0F / static_cast<real>(logic_frames_per_second);
/**
* A world-space position or direction, matching SAGE's `Coord3D`.
*/
struct coord3d {
real x = 0.0F;
real y = 0.0F;
real z = 0.0F;
auto operator+(const coord3d &rhs) const -> coord3d { return {x + rhs.x, y + rhs.y, z + rhs.z}; }
auto operator-(const coord3d &rhs) const -> coord3d { return {x - rhs.x, y - rhs.y, z - rhs.z}; }
auto operator*(real scalar) const -> coord3d { return {x * scalar, y * scalar, z * scalar}; }
auto operator==(const coord3d &) const -> bool = default;
/** Squared length, cheaper than `length()` when only comparing. */
[[nodiscard]] auto length_squared() const -> real { return x * x + y * y + z * z; }
[[nodiscard]] auto length() const -> real;
[[nodiscard]] auto distance_to(const coord3d &other) const -> real { return (*this - other).length(); }
/** Unit vector, or the zero vector when this one is degenerate. */
[[nodiscard]] auto normalized() const -> coord3d;
};
/**
* Integer grid coordinates, matching SAGE's `ICoord2D`.
*/
struct coord2d {
int32 x = 0;
int32 y = 0;
auto operator==(const coord2d &) const -> bool = default;
};
/**
* An 8-bit-per-channel RGB colour.
*/
struct rgb_color {
uint8 r = 0;
uint8 g = 0;
uint8 b = 0;
auto operator==(const rgb_color &) const -> bool = default;
};
/**
* Derive the 32-bit identifier SAGE associates with an ASCII name.
*
* The retail `NameKeyGenerator` hash is still being recovered; this
* deterministic, case-insensitive FNV-1a stand-in keeps the interface
* stable until exact parity is proven against the binary.
*/
constexpr auto make_name_key(std::string_view name) -> uint32 {
uint32 key = 2166136261U;
for (const auto ch: name) {
auto c = static_cast<unsigned char>(ch);
if (c >= 'a' && c <= 'z') c = static_cast<unsigned char>(c - 'a' + 'A');
key ^= c;
key *= 16777619U;
}
return key;
}
/**
* An owned ASCII identifier carrying a cached `NameKey`.
*/
class ascii_string {
public:
ascii_string() = default;
ascii_string(std::string_view text) : text_(text) {}
explicit ascii_string(std::string text) : text_(std::move(text)) {}
[[nodiscard]] auto view() const -> std::string_view { return text_; }
[[nodiscard]] auto empty() const -> bool { return text_.empty(); }
[[nodiscard]] auto c_str() const -> const char * { return text_.c_str(); }
[[nodiscard]] auto key() const -> uint32 { return make_name_key(text_); }
auto operator==(const ascii_string &) const -> bool = default;
auto operator<=>(const ascii_string &) const = default;
friend auto operator<<(std::ostream &out, const ascii_string &value) -> std::ostream &;
private:
std::string text_;
};
inline auto operator<<(std::ostream &out, const ascii_string &value) -> std::ostream & {
return out << value.text_;
}
/**
* A deterministic pseudo-random source.
*
* SAGE separates the logic and client random streams so replays stay in
* sync; this is the logic-stream stand-in. The retail generator constants
* are pending recovery, so this uses a standard 32-bit LCG.
*/
class random {
public:
explicit random(uint32 seed = 1U) : state_(seed == 0U ? 1U : seed) {}
auto seed(uint32 value) -> void { state_ = (value == 0U ? 1U : value); }
[[nodiscard]] auto state() const -> uint32 { return state_; }
auto next_uint() -> uint32 {
state_ = state_ * 1664525U + 1013904223U;
return state_;
}
/** Uniform integer in the inclusive range `[lo, hi]`. */
auto next_int(int32 lo, int32 hi) -> int32 {
if (hi <= lo) return lo;
const auto span = static_cast<uint32>(hi - lo + 1);
return lo + static_cast<int32>(this->next_uint() % span);
}
/** Uniform real in `[0, 1)`. */
auto next_real() -> real { return static_cast<real>(this->next_uint() >> 8) / static_cast<real>(1U << 24); }
private:
uint32 state_;
};
/**
* Message types carried by the engine's `MessageStream`.
*
* `new_game` is confirmed against the retail binary: the skirmish BEGIN
* handler appends type `0x2` to start a match.
*/
enum class message_id : uint32 {
invalid = 0x0,
new_game = 0x2,
clear_game_data = 0x3,
begin_block = 0x5,
end_block = 0x6,
};
[[nodiscard]] constexpr auto to_string(message_id id) -> std::string_view {
switch (id) {
case message_id::invalid:
return "Invalid";
case message_id::new_game:
return "NewGame";
case message_id::clear_game_data:
return "ClearGameData";
case message_id::begin_block:
return "BeginBlock";
case message_id::end_block:
return "EndBlock";
}
return "Unknown";
}
/**
* A single entry in the message stream.
*
* The retail `MessageStream::appendMessage` allocates a 0x74-byte node
* (recovered at `0x0060c4a0`); this is its owning equivalent.
*/
struct game_message {
message_id id = message_id::invalid;
uint32 player_index = 0;
std::vector<uint8> payload;
};
/**
* A FIFO of `game_message`s drained once per logic frame.
*
* In the retail engine the stream is an intrusive doubly linked list
* (head at `+0x24`, tail at `+0x28` of the stream object); this is the
* behaviourally equivalent owning version.
*/
class message_stream {
public:
auto append(message_id id, uint32 player_index = 0U) -> game_message & {
auto &slot = messages_.emplace_back();
slot.id = id;
slot.player_index = player_index;
return slot;
}
[[nodiscard]] auto empty() const -> bool { return messages_.empty(); }
[[nodiscard]] auto size() const -> usize { return messages_.size(); }
[[nodiscard]] auto pending() const -> std::span<const game_message> { return messages_; }
auto clear() -> void { messages_.clear(); }
/** Remove and return the oldest message, if any. */
auto pop() -> std::optional<game_message> {
if (messages_.empty()) return std::nullopt;
auto front = std::move(messages_.front());
messages_.erase(messages_.begin());
return front;
}
private:
std::vector<game_message> messages_;
};
inline auto coord3d::length() const -> real { return std::sqrt(this->length_squared()); }
inline auto coord3d::normalized() const -> coord3d {
const auto len = this->length();
if (len <= std::numeric_limits<real>::epsilon()) return {0.0F, 0.0F, 0.0F};
return *this * (1.0F / len);
}
}
+339
View File
@@ -0,0 +1,339 @@
module;
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <optional>
#include <span>
#include <stdexcept>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
export module ra3.fs;
export import ra3.core;
/**
* Reading of the retail game's on-disk assets.
*
* Red Alert 3 ships its data in `BIG4` archives under `<install>\Data`, with
* individual payloads compressed by EA's RefPack codec. This module implements
* the container and codec so OpenRA3 can read a user's own installation.
*
* No game data is ever written into the repository; callers point the loader at
* their local install (`--game-dir` / `RA3_GAME_DIR`, default `C:\Red Alert 3`).
*/
export namespace ra3::fs {
using ra3::core::uint32;
using ra3::core::uint8;
using ra3::core::usize;
/** Thrown when a `BIG4` archive is malformed or an entry is missing. */
class archive_error : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
/** Thrown when a RefPack stream is malformed. */
class refpack_error : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
inline constexpr std::array<uint8, 4> big_magic{'B', 'I', 'G', '4'};
inline constexpr uint8 refpack_mask = 0x3EU;
inline constexpr uint8 refpack_magic2 = 0xFBU;
/** One file inside a `BIG4` archive. */
struct big_entry {
std::string name;
uint32 offset = 0;
uint32 size = 0;
};
/** True when `data` starts with a RefPack header (`0b??010000`, `0xFB`). */
[[nodiscard]] inline auto is_refpack(std::span<const uint8> data) -> bool {
return data.size() >= 2 && (data[0] & refpack_mask) == 0x10U && data[1] == refpack_magic2;
}
/**
* Decompress an EA RefPack stream.
*
* @param data Compressed stream, starting at the header byte.
* @return The decompressed bytes.
* @throws refpack_error if the stream is malformed or the length disagrees.
*/
[[nodiscard]] inline auto refpack_decompress(std::span<const uint8> data) -> std::vector<uint8> {
if (!is_refpack(data)) throw refpack_error("not a RefPack stream");
usize pos = 0;
const auto header = data[pos++];
const bool large_files = (header & 0x80U) != 0;
const bool compressed_size_present = (header & 0x01U) != 0;
pos++; // 0xFB
const usize size_bytes = large_files ? 4U : 3U;
auto read_size = [&]() -> uint32 {
uint32 value = 0;
for (usize i = 0; i < size_bytes; ++i) {
if (pos >= data.size()) throw refpack_error("truncated RefPack size field");
value = (value << 8) | data[pos++];
}
return value;
};
if (compressed_size_present) (void)read_size();
const auto out_len = read_size();
std::vector<uint8> out;
out.reserve(out_len);
auto copy_literals = [&](usize count) {
if (pos + count > data.size()) throw refpack_error("truncated RefPack literals");
out.insert(out.end(), data.begin() + static_cast<std::ptrdiff_t>(pos), data.begin() + static_cast<std::ptrdiff_t>(pos + count));
pos += count;
};
auto copy_reference = [&](usize length, usize distance) {
if (distance == 0 || distance > out.size()) throw refpack_error("RefPack back-reference out of range");
usize start = out.size() - distance;
for (usize i = 0; i < length; ++i) out.push_back(out[start + i]);
};
while (pos < data.size()) {
const auto cmd = data[pos++];
if ((cmd & 0x80U) == 0) { // 2-byte command
if (pos >= data.size()) throw refpack_error("truncated 2-byte command");
const auto b2 = data[pos++];
copy_literals(cmd & 0x03U);
copy_reference(((cmd & 0x1CU) >> 2) + 3, ((cmd & 0x60U) << 3) + b2 + 1);
} else if ((cmd & 0x40U) == 0) { // 3-byte command
if (pos + 1 >= data.size()) throw refpack_error("truncated 3-byte command");
const auto b2 = data[pos];
const auto b3 = data[pos + 1];
pos += 2;
copy_literals((b2 & 0xC0U) >> 6);
copy_reference((cmd & 0x3FU) + 4, ((b2 & 0x3FU) << 8) + b3 + 1);
} else if ((cmd & 0x20U) == 0) { // 4-byte command
if (pos + 2 >= data.size()) throw refpack_error("truncated 4-byte command");
const auto b2 = data[pos];
const auto b3 = data[pos + 1];
const auto b4 = data[pos + 2];
pos += 3;
copy_literals(cmd & 0x03U);
copy_reference(((cmd & 0x0CU) << 6) + b4 + 5, ((cmd & 0x10U) << 12) + (b2 << 8) + b3 + 1);
} else if (cmd < 0xFCU) { // long literal run
copy_literals(((cmd & 0x1FU) + 1) << 2);
} else { // stop
copy_literals(cmd & 0x03U);
break;
}
}
if (out.size() != out_len) throw refpack_error("RefPack length mismatch");
return out;
}
/** Decompress `data` when it is RefPack, otherwise copy it unchanged. */
[[nodiscard]] inline auto maybe_decompress(std::span<const uint8> data) -> std::vector<uint8> {
if (is_refpack(data)) return refpack_decompress(data);
return {data.begin(), data.end()};
}
/**
* Read the declared output size from a RefPack header without decompressing.
*
* @throws refpack_error if the stream is malformed.
*/
[[nodiscard]] inline auto refpack_output_size(std::span<const uint8> data) -> uint32 {
if (!is_refpack(data)) throw refpack_error("not a RefPack stream");
usize pos = 0;
const auto header = data[pos++];
const bool large_files = (header & 0x80U) != 0;
const bool compressed_size_present = (header & 0x01U) != 0;
pos++; // 0xFB
const usize size_bytes = large_files ? 4U : 3U;
auto read_size = [&]() -> uint32 {
uint32 value = 0;
for (usize i = 0; i < size_bytes; ++i) {
if (pos >= data.size()) throw refpack_error("truncated RefPack size field");
value = (value << 8) | data[pos++];
}
return value;
};
if (compressed_size_present) (void)read_size();
return read_size();
}
[[nodiscard]] constexpr auto read_be32(const uint8 *p) -> uint32 {
return (static_cast<uint32>(p[0]) << 24U) | (static_cast<uint32>(p[1]) << 16U) | (static_cast<uint32>(p[2]) << 8U) | static_cast<uint32>(p[3]);
}
[[nodiscard]] constexpr auto read_le32(const uint8 *p) -> uint32 {
return static_cast<uint32>(p[0]) | (static_cast<uint32>(p[1]) << 8U) | (static_cast<uint32>(p[2]) << 16U) | (static_cast<uint32>(p[3]) << 24U);
}
/**
* A parsed `BIG4` archive. Only the index is held in memory; payloads are
* read from disk on demand so multi-hundred-megabyte archives stay cheap.
*/
class big_archive {
public:
/**
* Parse the index of a `BIG4` archive.
*
* @param path Archive path.
* @throws archive_error if the file is missing, not `BIG4`, or truncated.
*/
[[nodiscard]] static auto open(const std::filesystem::path &path) -> big_archive {
big_archive archive;
archive.path_ = path;
std::error_code ec;
archive.file_size_ = static_cast<usize>(std::filesystem::file_size(path, ec));
if (ec) throw archive_error("cannot stat archive: " + path.string());
std::ifstream in(path, std::ios::binary);
if (!in) throw archive_error("cannot open archive: " + path.string());
std::array<uint8, 16> header{};
in.read(reinterpret_cast<char *>(header.data()), static_cast<std::streamsize>(header.size()));
if (!in || std::memcmp(header.data(), big_magic.data(), big_magic.size()) != 0) throw archive_error("not a BIG4 archive: " + path.string());
const auto count = read_be32(header.data() + 8);
// Read the variable-length index, growing the window until parsed.
usize window = std::min(archive.file_size_, std::max<usize>(1U << 20U, static_cast<usize>(count) * 256U));
std::vector<uint8> index;
for (;;) {
index.resize(window);
in.clear();
in.seekg(0);
in.read(reinterpret_cast<char *>(index.data()), static_cast<std::streamsize>(window));
const auto got = static_cast<usize>(in.gcount());
index.resize(got);
archive.entries_.clear();
archive.entries_.reserve(count);
usize pos = 16;
bool complete = true;
for (uint32 i = 0; i < count; ++i) {
if (pos + 8 > index.size()) {
complete = false;
break;
}
big_entry entry;
entry.offset = read_be32(index.data() + pos);
entry.size = read_be32(index.data() + pos + 4);
pos += 8;
const auto *begin = reinterpret_cast<const char *>(index.data() + pos);
const auto *end = reinterpret_cast<const char *>(std::memchr(begin, '\0', index.size() - pos));
if (end == nullptr) {
complete = false;
break;
}
entry.name.assign(begin, end);
pos += static_cast<usize>(end - begin) + 1U;
archive.entries_.push_back(std::move(entry));
}
if (complete) break;
if (window >= archive.file_size_) throw archive_error("truncated BIG4 index: " + path.string());
window = std::min(archive.file_size_, window * 2U);
}
archive.index_.reserve(archive.entries_.size());
for (usize i = 0; i < archive.entries_.size(); ++i) archive.index_.emplace(archive.entries_[i].name, i);
return archive;
}
[[nodiscard]] auto path() const -> const std::filesystem::path & { return path_; }
[[nodiscard]] auto entries() const -> const std::vector<big_entry> & { return entries_; }
[[nodiscard]] auto size() const -> usize { return entries_.size(); }
[[nodiscard]] auto contains(std::string_view name) const -> bool { return index_.contains(std::string{name}); }
/** Entry names whose path contains `needle`, in index order. */
[[nodiscard]] auto find(std::string_view needle) const -> std::vector<const big_entry *> {
std::vector<const big_entry *> matches;
for (const auto &entry: entries_) {
if (entry.name.find(needle) != std::string::npos) matches.push_back(&entry);
}
return matches;
}
/**
* Read an entry's payload.
*
* @param name Entry name (backslash-separated, case-sensitive).
* @param decompress RefPack-decompress the payload when true.
* @throws archive_error if the entry is missing or unreadable.
*/
[[nodiscard]] auto read(std::string_view name, bool decompress = true) const -> std::vector<uint8> {
const auto it = index_.find(std::string{name});
if (it == index_.end()) throw archive_error("no such entry: " + std::string{name});
const auto &entry = entries_[it->second];
std::ifstream in(path_, std::ios::binary);
if (!in) throw archive_error("cannot open archive: " + path_.string());
in.seekg(static_cast<std::streamoff>(entry.offset));
std::vector<uint8> raw(entry.size);
in.read(reinterpret_cast<char *>(raw.data()), static_cast<std::streamsize>(raw.size()));
if (!in) throw archive_error("short read for entry: " + entry.name);
return decompress ? maybe_decompress(raw) : raw;
}
/** Read the first `count` stored bytes of an entry (no decompression). */
[[nodiscard]] auto read_prefix(std::string_view name, usize count) const -> std::vector<uint8> {
const auto it = index_.find(std::string{name});
if (it == index_.end()) throw archive_error("no such entry: " + std::string{name});
const auto &entry = entries_[it->second];
std::ifstream in(path_, std::ios::binary);
if (!in) throw archive_error("cannot open archive: " + path_.string());
in.seekg(static_cast<std::streamoff>(entry.offset));
const auto want = std::min(count, static_cast<usize>(entry.size));
std::vector<uint8> raw(want);
in.read(reinterpret_cast<char *>(raw.data()), static_cast<std::streamsize>(raw.size()));
raw.resize(static_cast<usize>(in.gcount()));
return raw;
}
private:
big_archive() = default;
std::filesystem::path path_;
usize file_size_ = 0;
std::vector<big_entry> entries_;
std::unordered_map<std::string, usize> index_;
};
/**
* Locate a Red Alert 3 installation.
*
* Resolution order: the explicit argument, then `$RA3_GAME_DIR`, then the
* default `C:\Red Alert 3`. A directory qualifies only if it has a `Data`
* subdirectory.
*
* @return The install root, or `std::nullopt` when none is found.
*/
[[nodiscard]] inline auto find_game_dir(const std::optional<std::filesystem::path> &explicit_dir = std::nullopt) -> std::optional<std::filesystem::path> {
auto qualifies = [](const std::filesystem::path &candidate) {
std::error_code ec;
return !candidate.empty() && std::filesystem::is_directory(candidate / "Data", ec);
};
if (explicit_dir && qualifies(*explicit_dir)) return explicit_dir;
if (const auto *env = std::getenv("RA3_GAME_DIR"); env != nullptr && *env != '\0') {
const std::filesystem::path candidate{env};
if (qualifies(candidate)) return candidate;
}
const std::filesystem::path default_dir{"C:/Red Alert 3"};
if (qualifies(default_dir)) return default_dir;
return std::nullopt;
}
}
+72
View File
@@ -0,0 +1,72 @@
module;
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
export module ra3.game;
import ra3.core;
import ra3.logic;
/**
* Red Alert 3 game definitions: the three base-game sides and the data that
* seeds a skirmish.
*
* The `faction` values are recovered from the retail skirmish setup object,
* where a slot stores its side at `slot + 0x18`.
*/
export namespace ra3::game {
using ra3::core::int32;
using ra3::core::uint32;
/**
* Faction bit flags as stored by the retail skirmish setup.
*/
enum class faction : uint32 {
none = 0U,
empire = 2U,
allied = 4U,
random = 7U,
soviet = 8U,
};
[[nodiscard]] constexpr auto to_string(faction value) -> std::string_view {
switch (value) {
case faction::empire:
return "Empire";
case faction::allied:
return "Allied";
case faction::soviet:
return "Soviet";
case faction::random:
return "Random";
case faction::none:
return "None";
}
return "Unknown";
}
/**
* Static description of a playable side, the OpenRA3 analogue of a
* `PlayerTemplate` entry.
*/
struct player_template {
std::string name;
faction side = faction::none;
int32 starting_money = 10000;
};
/**
* The three base-game sides. Skirmish defaults to 10,000 credits, matching
* the retail setup object at `setup + 0x64`.
*/
[[nodiscard]] inline auto base_templates() -> std::vector<player_template> {
return {
{"Allied", faction::allied, 10000},
{"Soviet", faction::soviet, 10000},
{"Empire", faction::empire, 10000},
};
}
}
+304
View File
@@ -0,0 +1,304 @@
module;
#include <cstddef>
#include <cstdint>
#include <memory>
#include <span>
#include <string>
#include <string_view>
#include <unordered_map>
#include <utility>
#include <vector>
export module ra3.logic;
import ra3.core;
/**
* The deterministic simulation layer: objects, players, teams, spatial
* partitioning and the 30 Hz game loop.
*
* This is the OpenRA3 counterpart of SAGE's `GameLogic` subsystem. Addresses
* cited in comments come from the retail `ra3_1.12.game` binary (image base
* `0x400000`); they are the anchors used to keep this reconstruction honest.
*/
export namespace ra3::logic {
using ra3::core::coord3d;
using ra3::core::int32;
using ra3::core::message_id;
using ra3::core::message_stream;
using ra3::core::random;
using ra3::core::real;
using ra3::core::uint32;
using ra3::core::uint8;
using ra3::core::usize;
using object_id = uint32;
/**
* Broad classification of a game object.
*/
enum class object_kind : uint8 {
invalid = 0,
infantry,
vehicle,
aircraft,
structure,
projectile,
};
/**
* A thing the partition manager can index (SAGE `Thing`).
*/
class thing {
public:
explicit thing(object_id id) : id_(id) {}
virtual ~thing() = default;
[[nodiscard]] auto id() const -> object_id { return id_; }
[[nodiscard]] auto position() const -> const coord3d & { return position_; }
auto set_position(const coord3d &value) -> void { position_ = value; }
/** Bounding radius used by the spatial partition. */
[[nodiscard]] virtual auto radius() const -> real { return 0.0F; }
protected:
object_id id_;
coord3d position_;
};
class object;
/**
* A pluggable behaviour ticked every logic frame (SAGE `UpdateModule`).
*/
class update_module {
public:
virtual ~update_module() = default;
virtual auto update(object &owner) -> void = 0;
[[nodiscard]] virtual auto name() const -> std::string_view = 0;
};
/**
* A simulation entity (SAGE `Object`): identity, ownership, health and a
* list of behaviour modules.
*/
class object : public thing {
public:
object(object_id id, object_kind kind, uint32 owner_player) : thing(id), kind_(kind), owner_(owner_player) {}
[[nodiscard]] auto kind() const -> object_kind { return kind_; }
[[nodiscard]] auto owner() const -> uint32 { return owner_; }
[[nodiscard]] auto health() const -> real { return health_; }
auto set_health(real value) -> void { health_ = value; }
auto damage(real amount) -> void { health_ = health_ > amount ? health_ - amount : 0.0F; }
[[nodiscard]] auto destroyed() const -> bool { return health_ <= 0.0F; }
[[nodiscard]] auto radius() const -> real override { return radius_; }
auto set_radius(real value) -> void { radius_ = value; }
auto add_module(std::unique_ptr<update_module> module) -> void { modules_.push_back(std::move(module)); }
[[nodiscard]] auto module_count() const -> usize { return modules_.size(); }
/** Tick every attached behaviour module. */
auto update() -> void {
for (const auto &module: modules_) module->update(*this);
}
private:
object_kind kind_ = object_kind::invalid;
uint32 owner_ = 0U;
real health_ = 1.0F;
real radius_ = 0.0F;
std::vector<std::unique_ptr<update_module>> modules_;
};
/**
* A participant in a match.
*
* The retail `Player` keeps money behind a `std::vector<Money*>` at
* `+0xe4` and power at `+0x74`; the fields here collapse those into plain
* values until the pointer indirection matters.
*/
class player {
public:
player(uint32 index, std::string name) : index_(index), name_(std::move(name)) {}
[[nodiscard]] auto index() const -> uint32 { return index_; }
[[nodiscard]] auto name() const -> const std::string & { return name_; }
[[nodiscard]] auto money() const -> int32 { return money_; }
auto set_money(int32 value) -> void { money_ = value < 0 ? 0 : value; }
auto add_money(int32 delta) -> void { this->set_money(money_ + delta); }
[[nodiscard]] auto power_produced() const -> int32 { return power_produced_; }
[[nodiscard]] auto power_consumed() const -> int32 { return power_consumed_; }
auto set_power(int32 produced, int32 consumed) -> void {
power_produced_ = produced;
power_consumed_ = consumed;
}
/** Faction bit flag (see `ra3::game::faction`). */
[[nodiscard]] auto faction() const -> uint32 { return faction_; }
auto set_faction(uint32 value) -> void { faction_ = value; }
[[nodiscard]] auto human() const -> bool { return human_; }
auto set_human(bool value) -> void { human_ = value; }
private:
uint32 index_;
std::string name_;
int32 money_ = 0;
int32 power_produced_ = 0;
int32 power_consumed_ = 0;
uint32 faction_ = 0U;
bool human_ = false;
};
/**
* The ordered set of players in a match (SAGE `PlayerList`).
*
* The retail list materialises an inline array of 20 `Player*` starting at
* `+0x30`; this owns them instead.
*/
class player_list {
public:
auto add(std::unique_ptr<player> value) -> player & {
auto &ref = *value;
players_.push_back(std::move(value));
return ref;
}
[[nodiscard]] auto size() const -> usize { return players_.size(); }
[[nodiscard]] auto at(usize index) -> player & { return *players_.at(index); }
[[nodiscard]] auto at(usize index) const -> const player & { return *players_.at(index); }
/** First human-controlled player, or `nullptr` in an all-AI match. */
[[nodiscard]] auto human() -> player * {
for (const auto &entry: players_) {
if (entry->human()) return entry.get();
}
return nullptr;
}
private:
std::vector<std::unique_ptr<player>> players_;
};
/**
* A spatial index over `Thing`s, bucketed on a fixed-size grid.
*
* A stand-in for SAGE's `PartitionManager`, which indexes the world into
* cells and answers proximity queries.
*/
class partition_manager {
public:
explicit partition_manager(real cell_size = 40.0F) : cell_size_(cell_size) {}
auto insert(thing &value) -> void { cells_[this->cell_of(value.position())].push_back(&value); }
auto clear() -> void { cells_.clear(); }
[[nodiscard]] auto cell_count() const -> usize { return cells_.size(); }
/** Non-owning view of everything bucketed with `position`. */
[[nodiscard]] auto things_at(const coord3d &position) -> std::span<thing *> {
const auto it = cells_.find(this->cell_of(position));
if (it == cells_.end()) return {};
return it->second;
}
private:
struct cell_key {
int32 x = 0;
int32 y = 0;
auto operator==(const cell_key &) const -> bool = default;
};
struct cell_hash {
auto operator()(const cell_key &key) const -> usize {
return (static_cast<usize>(static_cast<uint32>(key.x)) << 32U) ^ static_cast<uint32>(key.y);
}
};
[[nodiscard]] auto cell_of(const coord3d &position) const -> cell_key {
return {static_cast<int32>(position.x / cell_size_), static_cast<int32>(position.y / cell_size_)};
}
real cell_size_;
std::unordered_map<cell_key, std::vector<thing *>, cell_hash> cells_;
};
/**
* The simulation root and frame driver (SAGE `GameLogic`, singleton at
* `[0x00cd8ce4]` in the retail binary).
*
* `frame()` corresponds to the tick counter at `TheGameLogic + 0x50`, and
* `starting()` to the guard flag at `+0xa7` that the retail start routine
* raises while subsystems initialise.
*/
class game_logic {
public:
game_logic() = default;
[[nodiscard]] auto frame() const -> uint32 { return frame_; }
[[nodiscard]] auto starting() const -> bool { return starting_; }
[[nodiscard]] auto players() -> player_list & { return players_; }
[[nodiscard]] auto partition() -> partition_manager & { return partition_; }
[[nodiscard]] auto messages() -> message_stream & { return messages_; }
[[nodiscard]] auto random() -> ra3::core::random & { return random_; }
/**
* First half of starting a match: reset subsystems and seed the random
* stream. Mirrors the retail reset that runs before the message pump
* drains (the retail `startNewGame` raises `TheGameLogic + 0xa7`).
*
* @param seed Deterministic seed for the logic random stream.
*/
auto prepare_new_game(uint32 seed) -> void {
frame_ = 0U;
starting_ = true;
partition_.clear();
objects_.clear();
messages_.clear();
random_.seed(seed);
}
/** Second half: emit `new_game` so the pump starts the match. */
auto start_new_game() -> void {
messages_.append(message_id::new_game, 0U);
starting_ = false;
}
auto add_object(std::unique_ptr<object> value) -> object & {
auto &ref = *value;
partition_.insert(ref);
objects_.push_back(std::move(value));
return ref;
}
[[nodiscard]] auto object_count() const -> usize { return objects_.size(); }
/**
* Advance the simulation by exactly one 30 Hz logic frame: drain the
* message stream, tick every object, then bump the frame counter.
*/
auto update() -> void {
while (const auto message = messages_.pop()) {
(void)message;
}
for (const auto &entry: objects_) entry->update();
++frame_;
}
private:
uint32 frame_ = 0U;
bool starting_ = false;
player_list players_;
partition_manager partition_;
message_stream messages_;
ra3::core::random random_{1U};
std::vector<std::unique_ptr<object>> objects_;
};
}
+222
View File
@@ -0,0 +1,222 @@
module;
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
export module ra3.map;
export import ra3.core;
export import ra3.fs;
/**
* Red Alert 3 map discovery and loading.
*
* Multiplayer maps live in `Data\MapsMultiplayer.big` as one directory per map:
* `<id>\<id>.map` plus compiled companions. The `.map` file is an `EAR\0`
* wrapper around a RefPack stream whose payload is the `CkMp` chunk tree.
*
* This module reads the archive for real, unwraps the map, and extracts the
* `Player_N_Start` waypoint coordinates that seed a skirmish. Anything beyond
* start positions (terrain, objects) is the next milestone.
*/
export namespace ra3::map {
using ra3::core::coord3d;
using ra3::core::real;
using ra3::core::uint32;
using ra3::core::uint8;
using ra3::core::usize;
/** A player start location extracted from a map's waypoints. */
struct start_position {
real x = 0.0F;
real y = 0.0F;
real z = 0.0F;
[[nodiscard]] auto as_coord() const -> coord3d { return {x, y, z}; }
};
/** Identity and sizes of one map inside an archive. */
struct map_info {
std::string id;
std::string entry;
uint32 stored_size = 0;
uint32 unpacked_size = 0;
};
/** The result of loading a map: its metadata and recovered start positions. */
struct loaded_map {
map_info info;
std::vector<start_position> starts;
usize ckmp_size = 0;
};
namespace detail {
[[nodiscard]] inline auto split_path(std::string_view path) -> std::vector<std::string> {
std::vector<std::string> parts;
usize start = 0;
while (start <= path.size()) {
const auto at = path.find_first_of("\\/", start);
const auto end = at == std::string_view::npos ? path.size() : at;
parts.emplace_back(path.substr(start, end - start));
if (at == std::string_view::npos) break;
start = at + 1;
}
return parts;
}
/** True for a main map entry (`.../<stem>/<stem>.map`). */
[[nodiscard]] inline auto is_main_map(const fs::big_entry &entry) -> bool {
const auto parts = split_path(entry.name);
if (parts.size() < 2) return false;
const auto &file = parts[parts.size() - 1];
const auto &parent = parts[parts.size() - 2];
if (file.size() < 5 || file.compare(file.size() - 4, 4, ".map") != 0) return false;
const auto stem = file.substr(0, file.size() - 4);
return stem == parent && !stem.ends_with("_edit");
}
[[nodiscard]] inline auto stem_of(std::string_view path) -> std::string {
const auto parts = split_path(path);
if (parts.empty()) return {};
auto file = parts.back();
if (file.size() > 4 && file.compare(file.size() - 4, 4, ".map") == 0) file.resize(file.size() - 4);
return file;
}
[[nodiscard]] inline auto find_bytes(std::span<const uint8> haystack, std::string_view needle) -> usize {
if (needle.empty()) return 0;
const auto *needle_begin = reinterpret_cast<const uint8 *>(needle.data());
const auto it = std::search(haystack.begin(), haystack.end(), needle_begin, needle_begin + needle.size());
return it == haystack.end() ? static_cast<usize>(-1) : static_cast<usize>(it - haystack.begin());
}
[[nodiscard]] inline auto read_le_f32(const uint8 *p) -> real {
const auto bits = fs::read_le32(p);
real value = 0.0F;
std::memcpy(&value, &bits, sizeof(value));
return value;
}
/** First plausible `(x, y, 0)` float triple at or after `from`. */
[[nodiscard]] inline auto scan_triple(std::span<const uint8> data, usize from, usize window) -> std::optional<start_position> {
const auto end = std::min(data.size(), from + window);
for (usize p = from; p + 12U <= end; ++p) {
const auto x = read_le_f32(data.data() + p);
const auto y = read_le_f32(data.data() + p + 4U);
const auto z = read_le_f32(data.data() + p + 8U);
if (z == 0.0F && std::isfinite(x) && std::isfinite(y) && x > 0.0F && x < 20000.0F && y > 0.0F && y < 20000.0F) {
return start_position{x, y, z};
}
}
return std::nullopt;
}
/** Extract `Player_1_Start` .. `Player_N_Start` waypoint coordinates. */
[[nodiscard]] inline auto extract_start_positions(std::span<const uint8> ckmp, int max_players = 8) -> std::vector<start_position> {
std::vector<start_position> result;
for (int n = 1; n <= max_players; ++n) {
const auto needle = std::string{"Player_"} + std::to_string(n) + "_Start";
usize cursor = 0;
while (cursor < ckmp.size()) {
const auto at = find_bytes(ckmp.subspan(cursor), needle);
if (at == static_cast<usize>(-1)) break;
const auto abs = cursor + at;
if (const auto triple = scan_triple(ckmp, abs + needle.size(), 128); triple) {
result.push_back(*triple);
break;
}
cursor = abs + 1;
}
}
return result;
}
/** Number of distinct integer positions (used to reject degenerate sets). */
[[nodiscard]] inline auto distinct_positions(const std::vector<start_position> &starts) -> usize {
std::vector<std::pair<int, int>> seen;
for (const auto &start: starts) {
const auto key = std::pair{static_cast<int>(start.x), static_cast<int>(start.y)};
if (std::find(seen.begin(), seen.end(), key) == seen.end()) seen.push_back(key);
}
return seen.size();
}
}
/**
* Every main map discoverable in one archive.
*/
class map_catalog {
public:
/**
* Scan an archive for main map entries.
*
* @param archive An opened `MapsMultiplayer.big` (or similar).
*/
[[nodiscard]] static auto from_archive(const fs::big_archive &archive) -> map_catalog {
map_catalog catalog;
for (const auto &entry: archive.entries()) {
if (!detail::is_main_map(entry)) continue;
map_info info;
info.entry = entry.name;
info.id = detail::stem_of(entry.name);
info.stored_size = entry.size;
info.unpacked_size = entry.size;
// The BIG4 payload is RefPack; its output is the `EAR\0` wrapper.
if (const auto prefix = archive.read_prefix(entry.name, 16); fs::is_refpack(prefix)) {
info.unpacked_size = fs::refpack_output_size(prefix);
}
catalog.maps_.push_back(std::move(info));
}
return catalog;
}
[[nodiscard]] auto maps() const -> const std::vector<map_info> & { return maps_; }
[[nodiscard]] auto size() const -> usize { return maps_.size(); }
[[nodiscard]] auto find(std::string_view id) const -> const map_info * {
for (const auto &info: maps_) {
if (info.id == id) return &info;
}
return nullptr;
}
private:
std::vector<map_info> maps_;
};
/**
* Load a map: unwrap the `EAR\0` + RefPack container and recover the
* player start waypoints.
*
* @param archive Archive that holds the map.
* @param info Map selected from a `map_catalog`.
* @throws fs::archive_error / fs::refpack_error on malformed input.
*/
[[nodiscard]] inline auto load_map(const fs::big_archive &archive, const map_info &info) -> loaded_map {
loaded_map result;
result.info = info;
// Layer 1: BIG4 payload is RefPack, yielding the `EAR\0` wrapper.
const auto wrapper = archive.read(info.entry, true);
std::span<const uint8> payload{wrapper};
if (wrapper.size() >= 8U && std::memcmp(wrapper.data(), "EAR\0", 4) == 0) payload = payload.subspan(8);
// Layer 2: the wrapper body is RefPack again, yielding the `CkMp` tree.
auto ckmp = fs::maybe_decompress(payload);
result.ckmp_size = ckmp.size();
result.starts = detail::extract_start_positions(ckmp);
// Maps that keep starts in `MPPositionList` instead of `Player_N_Start`
// waypoints yield fewer than two distinct points; signal "unknown" so
// the caller can fall back rather than spawn everyone at the origin.
if (detail::distinct_positions(result.starts) < 2U) result.starts.clear();
return result;
}
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Umbrella module re-exporting the whole OpenRA3 SDK.
*
* Consumers that want everything can `import ra3;`; narrower targets should
* import only the module they need.
*/
export module ra3;
export import ra3.core;
export import ra3.logic;
export import ra3.client;
export import ra3.game;
export import ra3.fs;
export import ra3.map;
export import ra3.skirmish;
+371
View File
@@ -0,0 +1,371 @@
module;
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <span>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
export module ra3.skirmish;
export import ra3.core;
export import ra3.map;
export import ra3.game;
import ra3.logic;
/**
* A minimal, headless skirmish simulation.
*
* Two sides start on a real map's waypoint positions, earn credits, train
* units and fight until one side's base is destroyed. The simulation is fully
* deterministic: fixed 30 Hz steps, no wall-clock, and a seeded logic random
* stream. There is no networking, no online service and no EA account - this is
* offline skirmish only.
*/
export namespace ra3::skirmish {
using ra3::core::coord3d;
using ra3::core::int32;
using ra3::core::real;
using ra3::core::uint32;
using ra3::core::uint8;
using ra3::core::usize;
/** Broad unit role. */
enum class unit_class : uint8 { harvester, infantry, tank, base };
[[nodiscard]] constexpr auto to_string(unit_class value) -> std::string_view {
switch (value) {
case unit_class::harvester:
return "Harvester";
case unit_class::infantry:
return "Infantry";
case unit_class::tank:
return "Tank";
case unit_class::base:
return "Base";
}
return "Unknown";
}
/**
* Static combat/economy stats for a unit class.
*
* These are OpenRA3's own balance values (the retail numbers live in
* compiled assets that are not parsed yet), chosen so a skirmish resolves.
*/
struct unit_type {
unit_class cls = unit_class::infantry;
std::string_view name;
real max_health = 1.0F;
real speed = 0.0F;
real weapon_damage = 0.0F;
real weapon_range = 0.0F;
real weapon_cooldown = 1.0F;
int32 cost = 0;
real build_time = 1.0F;
bool mobile = false;
bool produces = false;
};
inline constexpr unit_type harvester_type{unit_class::harvester, "Harvester", 300.0F, 10.0F, 0.0F, 0.0F, 0.0F, 1400, 8.0F, true, false};
inline constexpr unit_type infantry_type{unit_class::infantry, "Infantry", 120.0F, 12.0F, 8.0F, 55.0F, 1.0F, 150, 3.0F, true, false};
inline constexpr unit_type tank_type{unit_class::tank, "Tank", 480.0F, 9.0F, 30.0F, 70.0F, 1.6F, 900, 8.0F, true, false};
inline constexpr unit_type base_type{unit_class::base, "Base", 2500.0F, 0.0F, 20.0F, 90.0F, 2.0F, 0, 0.0F, false, true};
[[nodiscard]] constexpr auto unit_stats(unit_class value) -> const unit_type & {
switch (value) {
case unit_class::harvester:
return harvester_type;
case unit_class::infantry:
return infantry_type;
case unit_class::tank:
return tank_type;
case unit_class::base:
return base_type;
}
return infantry_type;
}
/** A live unit instance. */
struct unit {
uint32 id = 0;
uint32 owner = 0;
unit_class cls = unit_class::infantry;
coord3d position{};
real health = 0.0F;
real max_health = 0.0F;
real cooldown = 0.0F;
bool alive = true;
};
/** Per-player state for the match. */
struct player_state {
uint32 index = 0;
std::string name;
game::faction side = game::faction::none;
bool human = false;
int32 money = 0;
real money_accumulator = 0.0F;
coord3d start{};
uint32 base_unit = 0;
real build_timer = 0.0F;
int32 kills = 0;
int32 losses = 0;
};
/** Skirmish parameters. */
struct match_config {
std::string map_id = "builtin";
int32 starting_money = 10000;
uint32 seed = 1;
uint32 max_frames = 30U * 60U * 15U;
real income_per_second = 25.0F;
real harvester_income_per_second = 15.0F;
};
/** Outcome of a completed (or capped) match. */
struct match_result {
bool decided = false;
int32 winner = -1;
uint32 frames = 0;
int32 units_left[2] = {0, 0};
int32 kills[2] = {0, 0};
int32 losses[2] = {0, 0};
};
/** Fallback start positions when a map yields none (CI, no game data). */
[[nodiscard]] inline auto builtin_start_positions() -> std::vector<ra3::map::start_position> {
return {{300.0F, 300.0F, 0.0F}, {1500.0F, 1500.0F, 0.0F}};
}
/**
* A running skirmish. Construct with `create`, then `run` or `step`.
*/
class skirmish_match {
public:
/**
* Set up a two-player match.
*
* @param config Match parameters.
* @param starts Player start positions (at least two recommended).
*/
[[nodiscard]] static auto create(const match_config &config, std::span<const ra3::map::start_position> starts) -> skirmish_match {
skirmish_match match;
match.config_ = config;
match.random_.seed(config.seed);
const coord3d first = starts.size() >= 1U ? starts[0].as_coord() : coord3d{300.0F, 300.0F, 0.0F};
const coord3d second = starts.size() >= 2U ? starts[1].as_coord() : coord3d{1500.0F, 1500.0F, 0.0F};
match.spawn_player(0U, "Commander", game::faction::allied, true, first);
match.spawn_player(1U, "AI", game::faction::soviet, false, second);
return match;
}
/** Run until a base falls or the frame cap is reached. */
auto run() -> match_result {
while (!this->decided() && frame_ < config_.max_frames) this->step();
return this->result();
}
/** Advance exactly one 30 Hz frame. */
auto step() -> void {
constexpr real dt = 1.0F / 30.0F;
this->apply_income(dt);
this->run_ai(dt);
this->update_units(dt);
this->check_victory();
++frame_;
}
[[nodiscard]] auto frame() const -> uint32 { return frame_; }
[[nodiscard]] auto decided() const -> bool { return decided_; }
[[nodiscard]] auto winner() const -> int32 { return winner_; }
[[nodiscard]] auto players() const -> const std::vector<player_state> & { return players_; }
[[nodiscard]] auto units() const -> const std::vector<unit> & { return units_; }
[[nodiscard]] auto unit_count(uint32 owner) const -> usize {
usize count = 0;
for (const auto &entry: units_) {
if (entry.alive && entry.owner == owner) ++count;
}
return count;
}
[[nodiscard]] auto result() const -> match_result {
match_result summary;
summary.decided = decided_;
summary.winner = winner_;
summary.frames = frame_;
for (const auto &player: players_) {
if (player.index < 2U) {
summary.kills[player.index] = player.kills;
summary.losses[player.index] = player.losses;
summary.units_left[player.index] = static_cast<int32>(this->unit_count(player.index));
}
}
return summary;
}
private:
static constexpr real build_interval_seconds = 4.0F;
auto spawn_player(uint32 index, std::string name, game::faction side, bool human, const coord3d &start) -> void {
player_state player;
player.index = index;
player.name = std::move(name);
player.side = side;
player.human = human;
player.money = config_.starting_money;
player.start = start;
player.base_unit = this->spawn_unit(index, unit_class::base, start);
this->spawn_unit(index, unit_class::harvester, {start.x + 60.0F, start.y + 20.0F, 0.0F});
this->spawn_unit(index, unit_class::tank, {start.x + 40.0F, start.y + 50.0F, 0.0F});
this->spawn_unit(index, unit_class::tank, {start.x + 70.0F, start.y - 30.0F, 0.0F});
this->spawn_unit(index, unit_class::infantry, {start.x + 30.0F, start.y - 60.0F, 0.0F});
this->spawn_unit(index, unit_class::infantry, {start.x + 90.0F, start.y - 60.0F, 0.0F});
players_.push_back(std::move(player));
}
auto spawn_unit(uint32 owner, unit_class cls, const coord3d &position) -> uint32 {
const auto &stats = unit_stats(cls);
unit created;
created.id = next_unit_id_++;
created.owner = owner;
created.cls = cls;
created.position = position;
created.health = stats.max_health;
created.max_health = stats.max_health;
units_.push_back(created);
return created.id;
}
auto apply_income(real dt) -> void {
for (auto &player: players_) {
real rate = config_.income_per_second;
for (const auto &entry: units_) {
if (entry.alive && entry.owner == player.index && entry.cls == unit_class::harvester) rate += config_.harvester_income_per_second;
}
player.money_accumulator += rate * dt;
const auto whole = static_cast<int32>(player.money_accumulator);
if (whole > 0) {
player.money += whole;
player.money_accumulator -= static_cast<real>(whole);
}
}
}
auto run_ai(real dt) -> void {
for (auto &player: players_) {
player.build_timer += dt;
if (player.build_timer < build_interval_seconds) continue;
player.build_timer = 0.0F;
const auto *base = this->find_unit(player.base_unit);
if (base == nullptr || !base->alive) continue;
if (player.money < tank_type.cost) continue;
player.money -= tank_type.cost;
this->spawn_unit(player.index, unit_class::tank, {base->position.x + 50.0F, base->position.y - 50.0F, 0.0F});
}
}
auto update_units(real dt) -> void {
for (auto &entry: units_) {
if (!entry.alive) continue;
if (entry.cooldown > 0.0F) entry.cooldown -= dt;
if (entry.cls == unit_class::harvester) continue; // economy only, no combat
const auto &stats = unit_stats(entry.cls);
auto *target = this->nearest_enemy(entry);
if (target == nullptr) continue;
const auto dx = target->position.x - entry.position.x;
const auto dy = target->position.y - entry.position.y;
const auto dz = target->position.z - entry.position.z;
const auto distance = std::sqrt(dx * dx + dy * dy + dz * dz);
if (distance > stats.weapon_range && stats.mobile && stats.speed > 0.0F) {
if (distance > 1.0e-3F) {
const auto travel = stats.speed * dt;
entry.position.x += dx / distance * travel;
entry.position.y += dy / distance * travel;
}
} else if (stats.weapon_damage > 0.0F && distance <= stats.weapon_range && entry.cooldown <= 0.0F) {
entry.cooldown = stats.weapon_cooldown;
target->health -= stats.weapon_damage;
if (target->health <= 0.0F) {
target->health = 0.0F;
target->alive = false;
this->player_ref(entry.owner).kills += 1;
this->player_ref(target->owner).losses += 1;
}
}
}
}
auto check_victory() -> void {
const bool first_alive = this->base_alive(0U);
const bool second_alive = this->base_alive(1U);
if (first_alive && second_alive) return;
decided_ = true;
if (first_alive) {
winner_ = 0;
} else if (second_alive) {
winner_ = 1;
} else {
winner_ = -1;
}
}
[[nodiscard]] auto base_alive(uint32 owner) const -> bool {
const auto &player = players_.at(owner);
const auto *base = this->find_unit(player.base_unit);
return base != nullptr && base->alive;
}
[[nodiscard]] auto nearest_enemy(const unit &self) -> unit * {
unit *best = nullptr;
real best_distance = 1.0e30F;
for (auto &candidate: units_) {
if (!candidate.alive || candidate.owner == self.owner) continue;
const auto dx = candidate.position.x - self.position.x;
const auto dy = candidate.position.y - self.position.y;
const auto squared = dx * dx + dy * dy;
if (squared < best_distance) {
best_distance = squared;
best = &candidate;
}
}
return best;
}
[[nodiscard]] auto find_unit(uint32 id) -> unit * {
for (auto &entry: units_) {
if (entry.id == id) return &entry;
}
return nullptr;
}
[[nodiscard]] auto find_unit(uint32 id) const -> const unit * {
for (const auto &entry: units_) {
if (entry.id == id) return &entry;
}
return nullptr;
}
[[nodiscard]] auto player_ref(uint32 index) -> player_state & { return players_.at(index); }
match_config config_;
ra3::core::random random_{1U};
std::vector<player_state> players_;
std::vector<unit> units_;
uint32 next_unit_id_ = 1U;
uint32 frame_ = 0U;
bool decided_ = false;
int32 winner_ = -1;
};
}
+126
View File
@@ -0,0 +1,126 @@
#include <cstdint>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
import ra3;
namespace {
int failures = 0;
auto check(bool condition, std::string_view what) -> void {
if (!condition) {
std::printf("FAIL: %.*s\n", static_cast<int>(what.size()), what.data());
++failures;
}
}
auto append_be32(std::vector<std::uint8_t> &out, std::uint32_t value) -> void {
out.push_back(static_cast<std::uint8_t>(value >> 24U));
out.push_back(static_cast<std::uint8_t>(value >> 16U));
out.push_back(static_cast<std::uint8_t>(value >> 8U));
out.push_back(static_cast<std::uint8_t>(value));
}
auto append_le32(std::vector<std::uint8_t> &out, std::uint32_t value) -> void {
out.push_back(static_cast<std::uint8_t>(value));
out.push_back(static_cast<std::uint8_t>(value >> 8U));
out.push_back(static_cast<std::uint8_t>(value >> 16U));
out.push_back(static_cast<std::uint8_t>(value >> 24U));
}
/** Build a one-entry BIG4 archive and return its path. */
auto make_test_archive(const std::filesystem::path &path) -> void {
const std::string name = "data\\hello.txt";
const std::string payload = "hello big4";
std::vector<std::uint8_t> index;
append_be32(index, 0); // offset, patched below
append_be32(index, static_cast<std::uint32_t>(payload.size()));
index.insert(index.end(), name.begin(), name.end());
index.push_back(0);
const std::uint32_t payload_offset = 16U + static_cast<std::uint32_t>(index.size());
std::vector<std::uint8_t> index_patched;
append_be32(index_patched, payload_offset);
index_patched.insert(index_patched.end(), index.begin() + 4, index.end());
std::vector<std::uint8_t> file;
file.insert(file.end(), {'B', 'I', 'G', '4'});
append_le32(file, static_cast<std::uint32_t>(16U + index_patched.size() + payload.size()));
append_be32(file, 1U);
append_le32(file, 0U);
file.insert(file.end(), index_patched.begin(), index_patched.end());
file.insert(file.end(), payload.begin(), payload.end());
std::ofstream out(path, std::ios::binary);
out.write(reinterpret_cast<const char *>(file.data()), static_cast<std::streamsize>(file.size()));
}
}
auto main() -> int {
using namespace ra3;
check(core::make_name_key("Tank") == core::make_name_key("tank"), "name keys are case-insensitive");
core::message_stream stream;
stream.append(core::message_id::begin_block);
stream.append(core::message_id::new_game);
check(stream.size() == 2U, "message stream counts entries");
check(stream.pop()->id == core::message_id::begin_block, "message stream is FIFO");
logic::game_logic simulation;
simulation.prepare_new_game(7U);
check(simulation.starting(), "prepare raises the starting flag");
simulation.start_new_game();
check(!simulation.messages().empty(), "start appends new_game");
simulation.update();
check(simulation.frame() == 1U, "update advances the frame counter");
check(simulation.messages().empty(), "update drains the message stream");
auto &side = simulation.players().add(std::make_unique<logic::player>(0U, "Test"));
side.set_money(250);
side.add_money(-100);
check(side.money() == 150, "money clamps and accumulates");
check(game::to_string(game::faction::soviet) == "Soviet", "faction names round-trip");
// RefPack: a literal-only stream decodes to its payload.
const std::vector<core::uint8> refpack{0x10, 0xFB, 0x00, 0x00, 0x03, 0xFF, 'A', 'B', 'C'};
check(fs::is_refpack(refpack), "literal RefPack stream is recognised");
const auto decoded = fs::refpack_decompress(refpack);
check(decoded.size() == 3U && decoded[0] == 'A' && decoded[2] == 'C', "RefPack literals decode");
check(fs::maybe_decompress(std::vector<core::uint8>{'x', 'y'}).size() == 2U, "non-RefPack passes through");
// BIG4: build and read a synthetic archive.
const auto archive_path = std::filesystem::temp_directory_path() / "openra3_test.big";
make_test_archive(archive_path);
const auto archive = fs::big_archive::open(archive_path);
check(archive.size() == 1U, "BIG4 index has one entry");
check(archive.contains("data\\hello.txt"), "BIG4 entry is indexed");
const auto payload = archive.read("data\\hello.txt");
check(std::string(payload.begin(), payload.end()) == "hello big4", "BIG4 payload reads back");
std::error_code ignored;
std::filesystem::remove(archive_path, ignored);
// Skirmish: deterministic, resolves within the frame cap.
skirmish::match_config config;
config.seed = 42U;
const auto starts = skirmish::builtin_start_positions();
auto first = skirmish::skirmish_match::create(config, starts);
const auto first_result = first.run();
auto second = skirmish::skirmish_match::create(config, starts);
const auto second_result = second.run();
check(first_result.decided, "skirmish reaches a decision");
check(first_result.winner == 0 || first_result.winner == 1, "skirmish has a winner");
check(first_result.frames == second_result.frames && first_result.winner == second_result.winner, "skirmish is deterministic");
if (failures == 0) {
std::puts("ra3_tests: OK");
}
return failures == 0 ? 0 : 1;
}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Sparse-clone the GPLv3 SAGE 1.0 engine sources used as OpenRA3's reference.
#
# Usage: tools/fetch_reference.sh [destination]
set -euo pipefail
REPO="https://github.com/electronicarts/CnC_Generals_Zero_Hour.git"
DEST="${1:-reference/CnC_Generals_Zero_Hour}"
if [ -d "$DEST/.git" ]; then
echo "reference already present at $DEST"
exit 0
fi
mkdir -p "$(dirname "$DEST")"
git clone --depth 1 --filter=blob:none --sparse "$REPO" "$DEST"
git -C "$DEST" sparse-checkout set Generals GeneralsMD
echo "reference ready at $DEST"
+26
View File
@@ -0,0 +1,26 @@
# Ghidra helpers
OpenRA3's retail facts are recovered through the Ghidra MCP bridge. The retail
module is loaded as `ra3_1.12.game` (image base `0x400000`, x86 32-bit).
## Quick queries
```text
decompile_function(address=0x00623e40) # GameLogic::startNewGame
decompile_function(address=0x0060c4a0) # MessageStream::appendMessage
get_function_by_address(address=0x00cd8ce4) # TheGameLogic global
get_xrefs_to(address=0x00ce3a78) # who touches the skirmish setup
search_strings(search_term="SkirmishGameInfo")
```
## Recording a finding
When a layout or constant is recovered:
1. Add it to [`../../docs/REVERSE_ENGINEERING.md`](../../docs/REVERSE_ENGINEERING.md)
with its retail address.
2. Cite the address in the OpenRA3 source comment that depends on it.
3. Add a test that pins the behaviour.
The goal is that every assertion in the code can be re-derived from the binary
by someone who has never seen this repo.