OpenRA3 v0.0.1: C++26 modules, BIG4/RefPack reader, minimal skirmish
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
build
|
||||
build-*
|
||||
cmake-build-*
|
||||
out
|
||||
artifacts_bin
|
||||
reference
|
||||
*.gcm
|
||||
gcm.cache
|
||||
dev-image.tar
|
||||
.idea
|
||||
.vscode
|
||||
+32
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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)).
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>();
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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_;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user