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