From 3ad380c81e355e47f87f77d706f2a3126cee5386 Mon Sep 17 00:00:00 2001 From: EnderTheCoder Date: Sat, 12 Sep 2026 00:26:45 +0800 Subject: [PATCH] OpenRA3 v0.0.1: C++26 modules, BIG4/RefPack reader, minimal skirmish --- .clang-format | 30 +++ .dockerignore | 12 ++ .gitignore | 32 +++ .gitlab-ci.yml | 64 ++++++ CMakeLists.txt | 105 ++++++++++ Dockerfile | 58 ++++++ LICENSE | 20 ++ README.md | 115 ++++++++++ VERSION | 1 + apps/openra3/main.cpp | 125 +++++++++++ docs/ARCHITECTURE.md | 78 +++++++ docs/REVERSE_ENGINEERING.md | 135 ++++++++++++ docs/ROADMAP.md | 41 ++++ src/client/ra3.client.cppm | 74 +++++++ src/core/ra3.core.cppm | 247 ++++++++++++++++++++++ src/fs/ra3.fs.cppm | 339 ++++++++++++++++++++++++++++++ src/game/ra3.game.cppm | 72 +++++++ src/logic/ra3.logic.cppm | 304 +++++++++++++++++++++++++++ src/map/ra3.map.cppm | 222 ++++++++++++++++++++ src/ra3.cppm | 15 ++ src/skirmish/ra3.skirmish.cppm | 371 +++++++++++++++++++++++++++++++++ tests/ra3_tests.cpp | 126 +++++++++++ tools/fetch_reference.sh | 18 ++ tools/ghidra/README.md | 26 +++ 24 files changed, 2630 insertions(+) create mode 100644 .clang-format create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 CMakeLists.txt create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 VERSION create mode 100644 apps/openra3/main.cpp create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/REVERSE_ENGINEERING.md create mode 100644 docs/ROADMAP.md create mode 100644 src/client/ra3.client.cppm create mode 100644 src/core/ra3.core.cppm create mode 100644 src/fs/ra3.fs.cppm create mode 100644 src/game/ra3.game.cppm create mode 100644 src/logic/ra3.logic.cppm create mode 100644 src/map/ra3.map.cppm create mode 100644 src/ra3.cppm create mode 100644 src/skirmish/ra3.skirmish.cppm create mode 100644 tests/ra3_tests.cpp create mode 100644 tools/fetch_reference.sh create mode 100644 tools/ghidra/README.md diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..4eb9c9e --- /dev/null +++ b/.clang-format @@ -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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ecfec3f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +build +build-* +cmake-build-* +out +artifacts_bin +reference +*.gcm +gcm.cache +dev-image.tar +.idea +.vscode diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..04f0ffb --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..fecc1a4 --- /dev/null +++ b/.gitlab-ci.yml @@ -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//openra3" or "docker.io//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 diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..68e2316 --- /dev/null +++ b/CMakeLists.txt @@ -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) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b0535b9 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..05c8169 --- /dev/null +++ b/LICENSE @@ -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 . + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b78abba --- /dev/null +++ b/README.md @@ -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=`. + +## 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)). diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..8acdd82 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.1 diff --git a/apps/openra3/main.cpp b/apps/openra3/main.cpp new file mode 100644 index 0000000..da5459c --- /dev/null +++ b/apps/openra3/main.cpp @@ -0,0 +1,125 @@ +#include +#include +#include +#include +#include +#include +#include + +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 &args, std::string_view name) -> std::optional { + for (std::size_t i = 0; i + 1U < args.size(); ++i) { + if (args[i] == name) return args[i + 1U]; + } + return std::nullopt; + } + + /** Build `/Data/` 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 &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 &args, const std::optional &game_dir) -> int { + ra3::skirmish::match_config config; + std::vector 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(std::stoul(*seed)); + if (const auto frames = option_value(args, "--frames")) config.max_frames = static_cast(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(starts[i].x), static_cast(starts[i].y)); + } + std::printf("result: %s winner=%d frames=%u (%.1f s)\n", summary.decided ? "decided" : "timeout", summary.winner, summary.frames, + static_cast(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 args{argv + 1, argv + argc}; + if (args.empty()) { + print_usage(); + return 0; + } + + const auto command = args[0]; + const std::vector rest{args.begin() + 1, args.end()}; + + std::optional 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; +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..e3e149a --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/REVERSE_ENGINEERING.md b/docs/REVERSE_ENGINEERING.md new file mode 100644 index 0000000..942921d --- /dev/null +++ b/docs/REVERSE_ENGINEERING.md @@ -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` 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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..f18edb1 --- /dev/null +++ b/docs/ROADMAP.md @@ -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. diff --git a/src/client/ra3.client.cppm b/src/client/ra3.client.cppm new file mode 100644 index 0000000..d9c6d82 --- /dev/null +++ b/src/client/ra3.client.cppm @@ -0,0 +1,74 @@ +module; + +#include +#include +#include + +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 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 display_ = std::make_unique(); + }; +} diff --git a/src/core/ra3.core.cppm b/src/core/ra3.core.cppm new file mode 100644 index 0000000..abf0ff8 --- /dev/null +++ b/src/core/ra3.core.cppm @@ -0,0 +1,247 @@ +module; + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(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(ch); + if (c >= 'a' && c <= 'z') c = static_cast(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(hi - lo + 1); + return lo + static_cast(this->next_uint() % span); + } + + /** Uniform real in `[0, 1)`. */ + auto next_real() -> real { return static_cast(this->next_uint() >> 8) / static_cast(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 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 { return messages_; } + + auto clear() -> void { messages_.clear(); } + + /** Remove and return the oldest message, if any. */ + auto pop() -> std::optional { + if (messages_.empty()) return std::nullopt; + auto front = std::move(messages_.front()); + messages_.erase(messages_.begin()); + return front; + } + + private: + std::vector 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::epsilon()) return {0.0F, 0.0F, 0.0F}; + return *this * (1.0F / len); + } +} diff --git a/src/fs/ra3.fs.cppm b/src/fs/ra3.fs.cppm new file mode 100644 index 0000000..1b7e7c3 --- /dev/null +++ b/src/fs/ra3.fs.cppm @@ -0,0 +1,339 @@ +module; + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 `\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 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 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 data) -> std::vector { + 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 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(pos), data.begin() + static_cast(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 data) -> std::vector { + 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 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(p[0]) << 24U) | (static_cast(p[1]) << 16U) | (static_cast(p[2]) << 8U) | static_cast(p[3]); + } + + [[nodiscard]] constexpr auto read_le32(const uint8 *p) -> uint32 { + return static_cast(p[0]) | (static_cast(p[1]) << 8U) | (static_cast(p[2]) << 16U) | (static_cast(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(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 header{}; + in.read(reinterpret_cast(header.data()), static_cast(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(1U << 20U, static_cast(count) * 256U)); + std::vector index; + for (;;) { + index.resize(window); + in.clear(); + in.seekg(0); + in.read(reinterpret_cast(index.data()), static_cast(window)); + const auto got = static_cast(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(index.data() + pos); + const auto *end = reinterpret_cast(std::memchr(begin, '\0', index.size() - pos)); + if (end == nullptr) { + complete = false; + break; + } + entry.name.assign(begin, end); + pos += static_cast(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 & { 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 { + std::vector 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 { + 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(entry.offset)); + std::vector raw(entry.size); + in.read(reinterpret_cast(raw.data()), static_cast(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 { + 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(entry.offset)); + const auto want = std::min(count, static_cast(entry.size)); + std::vector raw(want); + in.read(reinterpret_cast(raw.data()), static_cast(raw.size())); + raw.resize(static_cast(in.gcount())); + return raw; + } + + private: + big_archive() = default; + + std::filesystem::path path_; + usize file_size_ = 0; + std::vector entries_; + std::unordered_map 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 &explicit_dir = std::nullopt) -> std::optional { + 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; + } +} diff --git a/src/game/ra3.game.cppm b/src/game/ra3.game.cppm new file mode 100644 index 0000000..779afdd --- /dev/null +++ b/src/game/ra3.game.cppm @@ -0,0 +1,72 @@ +module; + +#include +#include +#include +#include + +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 { + return { + {"Allied", faction::allied, 10000}, + {"Soviet", faction::soviet, 10000}, + {"Empire", faction::empire, 10000}, + }; + } +} diff --git a/src/logic/ra3.logic.cppm b/src/logic/ra3.logic.cppm new file mode 100644 index 0000000..0b292cc --- /dev/null +++ b/src/logic/ra3.logic.cppm @@ -0,0 +1,304 @@ +module; + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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> modules_; + }; + + /** + * A participant in a match. + * + * The retail `Player` keeps money behind a `std::vector` 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 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> 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 { + 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(static_cast(key.x)) << 32U) ^ static_cast(key.y); + } + }; + + [[nodiscard]] auto cell_of(const coord3d &position) const -> cell_key { + return {static_cast(position.x / cell_size_), static_cast(position.y / cell_size_)}; + } + + real cell_size_; + std::unordered_map, 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 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> objects_; + }; +} diff --git a/src/map/ra3.map.cppm b/src/map/ra3.map.cppm new file mode 100644 index 0000000..c9f0a00 --- /dev/null +++ b/src/map/ra3.map.cppm @@ -0,0 +1,222 @@ +module; + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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: + * `\.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 starts; + usize ckmp_size = 0; + }; + + namespace detail { + [[nodiscard]] inline auto split_path(std::string_view path) -> std::vector { + std::vector 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 (`...//.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 haystack, std::string_view needle) -> usize { + if (needle.empty()) return 0; + const auto *needle_begin = reinterpret_cast(needle.data()); + const auto it = std::search(haystack.begin(), haystack.end(), needle_begin, needle_begin + needle.size()); + return it == haystack.end() ? static_cast(-1) : static_cast(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 data, usize from, usize window) -> std::optional { + 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 ckmp, int max_players = 8) -> std::vector { + std::vector 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(-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 &starts) -> usize { + std::vector> seen; + for (const auto &start: starts) { + const auto key = std::pair{static_cast(start.x), static_cast(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 & { 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 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 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; + } +} diff --git a/src/ra3.cppm b/src/ra3.cppm new file mode 100644 index 0000000..3540b42 --- /dev/null +++ b/src/ra3.cppm @@ -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; diff --git a/src/skirmish/ra3.skirmish.cppm b/src/skirmish/ra3.skirmish.cppm new file mode 100644 index 0000000..bc86928 --- /dev/null +++ b/src/skirmish/ra3.skirmish.cppm @@ -0,0 +1,371 @@ +module; + +#include +#include +#include +#include +#include +#include +#include +#include + +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 { + 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 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 & { return players_; } + [[nodiscard]] auto units() const -> const std::vector & { 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(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(player.money_accumulator); + if (whole > 0) { + player.money += whole; + player.money_accumulator -= static_cast(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 players_; + std::vector units_; + uint32 next_unit_id_ = 1U; + uint32 frame_ = 0U; + bool decided_ = false; + int32 winner_ = -1; + }; +} diff --git a/tests/ra3_tests.cpp b/tests/ra3_tests.cpp new file mode 100644 index 0000000..07afb39 --- /dev/null +++ b/tests/ra3_tests.cpp @@ -0,0 +1,126 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +import ra3; + +namespace { + int failures = 0; + + auto check(bool condition, std::string_view what) -> void { + if (!condition) { + std::printf("FAIL: %.*s\n", static_cast(what.size()), what.data()); + ++failures; + } + } + + auto append_be32(std::vector &out, std::uint32_t value) -> void { + out.push_back(static_cast(value >> 24U)); + out.push_back(static_cast(value >> 16U)); + out.push_back(static_cast(value >> 8U)); + out.push_back(static_cast(value)); + } + + auto append_le32(std::vector &out, std::uint32_t value) -> void { + out.push_back(static_cast(value)); + out.push_back(static_cast(value >> 8U)); + out.push_back(static_cast(value >> 16U)); + out.push_back(static_cast(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 index; + append_be32(index, 0); // offset, patched below + append_be32(index, static_cast(payload.size())); + index.insert(index.end(), name.begin(), name.end()); + index.push_back(0); + + const std::uint32_t payload_offset = 16U + static_cast(index.size()); + std::vector index_patched; + append_be32(index_patched, payload_offset); + index_patched.insert(index_patched.end(), index.begin() + 4, index.end()); + + std::vector file; + file.insert(file.end(), {'B', 'I', 'G', '4'}); + append_le32(file, static_cast(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(file.data()), static_cast(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(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 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{'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; +} diff --git a/tools/fetch_reference.sh b/tools/fetch_reference.sh new file mode 100644 index 0000000..b36be6f --- /dev/null +++ b/tools/fetch_reference.sh @@ -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" diff --git a/tools/ghidra/README.md b/tools/ghidra/README.md new file mode 100644 index 0000000..21009ef --- /dev/null +++ b/tools/ghidra/README.md @@ -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.