2 Commits
24 changed files with 1000 additions and 246 deletions
+72 -40
View File
@@ -6,14 +6,11 @@ variables:
DOCKER_HOST: tcp://docker:2375 DOCKER_HOST: tcp://docker:2375
DOCKER_DRIVER: overlay2 DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: "" DOCKER_TLS_CERTDIR: ""
# Where to push the dev image. Leave empty to not push at all. DOCKER_BUILDKIT: "1"
# Examples: "192.168.1.11:9090/<group>/openra3" or "docker.io/<user>/openra3" # apt mirror used inside both images (override per pipeline if needed)
IMAGE_NAME: ""
APT_MIRROR: "http://mirrors.tuna.tsinghua.edu.cn/ubuntu" APT_MIRROR: "http://mirrors.tuna.tsinghua.edu.cn/ubuntu"
image_build: .dind: &dind
stage: image
image: docker:latest
services: services:
- name: docker:dind - name: docker:dind
# Pull docker.io images through the intranet Harbor pull-through cache; # Pull docker.io images through the intranet Harbor pull-through cache;
@@ -21,44 +18,79 @@ image_build:
command: command:
- --registry-mirror=http://192.168.1.11:9090/dockerhub - --registry-mirror=http://192.168.1.11:9090/dockerhub
- --insecure-registry=192.168.1.11:9090 - --insecure-registry=192.168.1.11:9090
script:
- docker build --target dev -t openra3-dev:local --build-arg APT_MIRROR=$APT_MIRROR . # --- build the two isolated toolchain images ---------------------------------
- docker save openra3-dev:local -o dev-image.tar linux_image:
- | stage: image
if [ -n "$IMAGE_NAME" ]; then image: docker:latest
# add `docker login` here if IMAGE_NAME points to a private registry services: *dind
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: tags:
- docker - docker
script:
- docker build --target dev -t openra3-linux:local --build-arg APT_MIRROR=$APT_MIRROR .
- docker save openra3-linux:local -o linux-image.tar
artifacts:
name: "linux-image-$CI_COMMIT_SHORT_SHA"
paths:
- linux-image.tar
expire_in: 1 day
build_test_package: windows_image:
stage: image
image: docker:latest
services: *dind
tags:
- docker
script:
- docker build -f Dockerfile.win --target dev -t openra3-windows:local --build-arg APT_MIRROR=$APT_MIRROR .
- docker save openra3-windows:local -o windows-image.tar
artifacts:
name: "windows-image-$CI_COMMIT_SHORT_SHA"
paths:
- windows-image.tar
expire_in: 1 day
# --- build and test each target ----------------------------------------------
build_linux:
stage: build stage: build
image: docker:latest image: docker:latest
services: services: *dind
- 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: tags:
- docker - docker
needs:
- linux_image
before_script:
- docker load -i linux-image.tar
script:
- docker run --rm -v "$(pwd):/work" -w /work openra3-linux:local cmake -S . -B build/linux -G Ninja -DCMAKE_BUILD_TYPE=Release
- docker run --rm -v "$(pwd):/work" -w /work openra3-linux:local cmake --build build/linux -j
- docker run --rm -v "$(pwd):/work" -w /work openra3-linux:local ctest --test-dir build/linux --output-on-failure
- docker run --rm -v "$(pwd):/work" -w /work openra3-linux:local bash -c "mkdir -p artifacts/linux && cp build/linux/bin/openra3 artifacts/linux/"
artifacts:
name: "$CI_PROJECT_NAME-linux-$CI_COMMIT_SHORT_SHA"
paths:
- artifacts/linux/
expire_in: 7 days
build_windows:
stage: build
image: docker:latest
services: *dind
tags:
- docker
needs:
- windows_image
before_script:
- docker load -i windows-image.tar
script:
- docker run --rm -v "$(pwd):/work" -w /work openra3-windows:local
cmake -S . -B build/windows -G Ninja
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/llvm-mingw-x86_64.cmake
-DCMAKE_BUILD_TYPE=Release
- docker run --rm -v "$(pwd):/work" -w /work openra3-windows:local cmake --build build/windows -j
- docker run --rm -v "$(pwd):/work" -w /work openra3-windows:local bash -c "mkdir -p artifacts/windows && cp build/windows/bin/openra3.exe build/windows/bin/SDL3.dll artifacts/windows/"
artifacts:
name: "$CI_PROJECT_NAME-windows-$CI_COMMIT_SHORT_SHA"
paths:
- artifacts/windows/
expire_in: 7 days
+91 -46
View File
@@ -1,10 +1,31 @@
cmake_minimum_required(VERSION 3.28) cmake_minimum_required(VERSION 3.30)
project(OpenRA3 VERSION 0.0.1 LANGUAGES CXX) # --- C++26 modules + `import std;` (clang + libc++) -------------------------
# CMake gates `import std` behind an experimental value documented in
# Help/dev/experimental.rst for the CMake version in use.
set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "d0edc3af-4c50-42ea-a356-e2862fe7a444")
# Path to the standard-library module description. A cross toolchain overrides
# this before the compiler is probed; this default targets the Linux LLVM
# package.
if(NOT CMAKE_CXX_STDLIB_MODULES_JSON)
set(CMAKE_CXX_STDLIB_MODULES_JSON "/usr/lib/llvm-21/lib/libc++.modules.json")
endif()
# libc++ must be selected before the compiler is probed: CMake builds its
# internal standard-library module target during `project()`, and that target
# only sees `CMAKE_CXX_FLAGS` (not later `add_compile_options`). The standard
# and extension settings must also be in place before `project()` so the
# internal `std.pcm` is built with the same configuration as our modules.
set(CMAKE_CXX_FLAGS_INIT "-stdlib=libc++")
set(CMAKE_EXE_LINKER_FLAGS_INIT "-stdlib=libc++")
set(CMAKE_SHARED_LINKER_FLAGS_INIT "-stdlib=libc++")
set(CMAKE_CXX_STANDARD 26) set(CMAKE_CXX_STANDARD 26)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_SCAN_FOR_MODULES ON)
project(OpenRA3 VERSION 0.1.0 LANGUAGES CXX)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
@@ -15,89 +36,113 @@ set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
option(OPENRA3_WERROR "Treat warnings as errors" OFF) option(OPENRA3_WERROR "Treat warnings as errors" OFF)
function(openra3_target_defaults target) function(openra3_target_defaults target)
target_compile_options(${target} PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(${target} PRIVATE -Wall -Wextra)
set_property(TARGET ${target} PROPERTY CXX_MODULE_STD ON)
if(OPENRA3_WERROR) if(OPENRA3_WERROR)
target_compile_options(${target} PRIVATE -Werror) target_compile_options(${target} PRIVATE -Werror)
endif() endif()
endfunction() endfunction()
# --- engine core: fundamental types, math, strings, random, message stream --- # --- SDL3: pkg-config on Linux, or an explicit MinGW root for Windows --------
add_library(openra3_sdl3 INTERFACE)
set(OPENRA3_HAS_SDL3 OFF)
if(OPENRA3_SDL3_ROOT)
target_include_directories(openra3_sdl3 INTERFACE "${OPENRA3_SDL3_ROOT}/x86_64-w64-mingw32/include")
target_link_libraries(openra3_sdl3 INTERFACE "${OPENRA3_SDL3_ROOT}/x86_64-w64-mingw32/lib/libSDL3.dll.a")
set(OPENRA3_HAS_SDL3 ON)
else()
find_package(PkgConfig QUIET)
if(PkgConfig_FOUND)
pkg_check_modules(SDL3 QUIET IMPORTED_TARGET sdl3)
endif()
if(SDL3_FOUND)
target_link_libraries(openra3_sdl3 INTERFACE PkgConfig::SDL3)
set(OPENRA3_HAS_SDL3 ON)
endif()
endif()
if(OPENRA3_HAS_SDL3)
message(STATUS "OpenRA3: SDL3 found - windowed viewer enabled")
else()
message(STATUS "OpenRA3: SDL3 not found - offscreen BMP output only")
endif()
# --- engine core -------------------------------------------------------------
add_library(ra3_core STATIC) add_library(ra3_core STATIC)
target_sources(ra3_core target_sources(ra3_core PUBLIC FILE_SET CXX_MODULES FILES src/core/ra3.core.cppm)
PUBLIC FILE_SET CXX_MODULES FILES
src/core/ra3.core.cppm
)
openra3_target_defaults(ra3_core) openra3_target_defaults(ra3_core)
# --- game logic: objects, players, teams, spatial partition, game loop --- # --- game logic --------------------------------------------------------------
add_library(ra3_logic STATIC) add_library(ra3_logic STATIC)
target_sources(ra3_logic target_sources(ra3_logic PUBLIC FILE_SET CXX_MODULES FILES src/logic/ra3.logic.cppm)
PUBLIC FILE_SET CXX_MODULES FILES
src/logic/ra3.logic.cppm
)
target_link_libraries(ra3_logic PUBLIC ra3_core) target_link_libraries(ra3_logic PUBLIC ra3_core)
openra3_target_defaults(ra3_logic) openra3_target_defaults(ra3_logic)
# --- client: headless display/input abstraction and the client facade --- # --- client ------------------------------------------------------------------
add_library(ra3_client STATIC) add_library(ra3_client STATIC)
target_sources(ra3_client target_sources(ra3_client PUBLIC FILE_SET CXX_MODULES FILES src/client/ra3.client.cppm)
PUBLIC FILE_SET CXX_MODULES FILES
src/client/ra3.client.cppm
)
target_link_libraries(ra3_client PUBLIC ra3_core ra3_logic) target_link_libraries(ra3_client PUBLIC ra3_core ra3_logic)
openra3_target_defaults(ra3_client) openra3_target_defaults(ra3_client)
# --- RA3 game definitions: factions, player templates, science, special powers --- # --- RA3 game definitions ----------------------------------------------------
add_library(ra3_game STATIC) add_library(ra3_game STATIC)
target_sources(ra3_game target_sources(ra3_game PUBLIC FILE_SET CXX_MODULES FILES src/game/ra3.game.cppm)
PUBLIC FILE_SET CXX_MODULES FILES
src/game/ra3.game.cppm
)
target_link_libraries(ra3_game PUBLIC ra3_core ra3_logic) target_link_libraries(ra3_game PUBLIC ra3_core ra3_logic)
openra3_target_defaults(ra3_game) openra3_target_defaults(ra3_game)
# --- filesystem: BIG4 archives, RefPack codec, local install locator --- # --- filesystem: BIG4 + RefPack ---------------------------------------------
add_library(ra3_fs STATIC) add_library(ra3_fs STATIC)
target_sources(ra3_fs target_sources(ra3_fs PUBLIC FILE_SET CXX_MODULES FILES src/fs/ra3.fs.cppm)
PUBLIC FILE_SET CXX_MODULES FILES
src/fs/ra3.fs.cppm
)
target_link_libraries(ra3_fs PUBLIC ra3_core) target_link_libraries(ra3_fs PUBLIC ra3_core)
openra3_target_defaults(ra3_fs) openra3_target_defaults(ra3_fs)
# --- map discovery/loading (real .big data, user's local install) --- # --- map discovery/loading ---------------------------------------------------
add_library(ra3_map STATIC) add_library(ra3_map STATIC)
target_sources(ra3_map target_sources(ra3_map PUBLIC FILE_SET CXX_MODULES FILES src/map/ra3.map.cppm)
PUBLIC FILE_SET CXX_MODULES FILES
src/map/ra3.map.cppm
)
target_link_libraries(ra3_map PUBLIC ra3_core ra3_fs) target_link_libraries(ra3_map PUBLIC ra3_core ra3_fs)
openra3_target_defaults(ra3_map) openra3_target_defaults(ra3_map)
# --- minimal headless skirmish simulation --- # --- minimal skirmish simulation --------------------------------------------
add_library(ra3_skirmish STATIC) add_library(ra3_skirmish STATIC)
target_sources(ra3_skirmish target_sources(ra3_skirmish PUBLIC FILE_SET CXX_MODULES FILES src/skirmish/ra3.skirmish.cppm)
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) target_link_libraries(ra3_skirmish PUBLIC ra3_core ra3_logic ra3_game ra3_map)
openra3_target_defaults(ra3_skirmish) openra3_target_defaults(ra3_skirmish)
# --- umbrella module re-exporting the whole SDK --- # --- software renderer -------------------------------------------------------
add_library(ra3_render STATIC)
target_sources(ra3_render PUBLIC FILE_SET CXX_MODULES FILES src/render/ra3.render.cppm)
target_link_libraries(ra3_render PUBLIC ra3_core)
openra3_target_defaults(ra3_render)
# --- windowed viewer (SDL3 or null) -----------------------------------------
add_library(ra3_ui STATIC)
if(OPENRA3_HAS_SDL3)
target_sources(ra3_ui PUBLIC FILE_SET CXX_MODULES FILES src/ui/ra3.ui.sdl.cppm)
target_link_libraries(ra3_ui PUBLIC ra3_core ra3_render openra3_sdl3)
else()
target_sources(ra3_ui PUBLIC FILE_SET CXX_MODULES FILES src/ui/ra3.ui.null.cppm)
target_link_libraries(ra3_ui PUBLIC ra3_core ra3_render)
endif()
openra3_target_defaults(ra3_ui)
# --- umbrella -----------------------------------------------------------------
add_library(ra3 STATIC) add_library(ra3 STATIC)
target_sources(ra3 target_sources(ra3 PUBLIC FILE_SET CXX_MODULES FILES src/ra3.cppm)
PUBLIC FILE_SET CXX_MODULES FILES target_link_libraries(ra3 PUBLIC ra3_core ra3_logic ra3_client ra3_game ra3_fs ra3_map ra3_skirmish ra3_render ra3_ui)
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) openra3_target_defaults(ra3)
# --- the headless game executable --- # --- executable ---------------------------------------------------------------
add_executable(openra3 apps/openra3/main.cpp) add_executable(openra3 apps/openra3/main.cpp)
target_link_libraries(openra3 PRIVATE ra3) target_link_libraries(openra3 PRIVATE ra3)
openra3_target_defaults(openra3) openra3_target_defaults(openra3)
# --- unit tests --- if(WIN32 AND OPENRA3_SDL3_ROOT)
add_custom_command(TARGET openra3 POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${OPENRA3_SDL3_ROOT}/x86_64-w64-mingw32/bin/SDL3.dll"
"$<TARGET_FILE_DIR:openra3>")
endif()
# --- tests --------------------------------------------------------------------
enable_testing() enable_testing()
add_executable(ra3_tests tests/ra3_tests.cpp) add_executable(ra3_tests tests/ra3_tests.cpp)
target_link_libraries(ra3_tests PRIVATE ra3) target_link_libraries(ra3_tests PRIVATE ra3)
+28 -18
View File
@@ -1,7 +1,9 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# dev target: full toolchain + debug tools for development and debugging. # Linux build environment. Isolated from the Windows cross image (Dockerfile.win)
# Base image and compiler are the latest stable (ubuntu:26.04 ships gcc/g++ 16). # so the two toolchains and their standard libraries never interfere.
#
# dev target: clang + libc++ (for `import std;`) + SDL3 + debug tools.
FROM ubuntu:26.04 AS dev FROM ubuntu:26.04 AS dev
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
@@ -17,42 +19,50 @@ RUN if [ -n "$APT_MIRROR" ]; then \
RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \ RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
--mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \ for attempt in 1 2 3 4 5; do \
build-essential \ apt-get -o Acquire::Retries=5 update && break; \
echo "apt-get update failed (attempt $attempt), retrying"; \
sleep 5; \
done \
&& apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
ca-certificates \ ca-certificates \
clang-21 \
clang-tools-21 \
cmake \ cmake \
g++-16 \
gdb \ gdb \
git \ git \
libc++-21-dev \
libc++abi-21-dev \
libsdl3-dev \
ninja-build \ ninja-build \
pkg-config \ pkg-config \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Pin the toolchain to GCC 16 (the distro default is still GCC 15). ENV CC=clang-21
ENV CC=gcc-16 ENV CXX=clang++-21
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 WORKDIR /work
COPY . . COPY . .
# C++26 modules need CMake >= 3.28, the Ninja generator and a module-aware gcc. RUN cmake -S . -B build/linux -G Ninja -DCMAKE_BUILD_TYPE=Release \
RUN cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \ && cmake --build build/linux -j \
&& cmake --build build -j \ && ctest --test-dir build/linux --output-on-failure
&& ctest --test-dir build --output-on-failure
# deploy target: runtime dependencies + final binary, minimal and fast. # deploy target: runtime dependencies + final binary, minimal and fast.
FROM ubuntu:26.04 AS deploy FROM ubuntu:26.04 AS deploy
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
libstdc++6 \ --mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get -o Acquire::Retries=5 update \
&& apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
libc++1 \
libc++abi1 \
libsdl3-0 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY --from=dev /work/build/bin/openra3 /app/openra3 COPY --from=dev /work/build/linux/bin/openra3 /app/openra3
ENTRYPOINT ["/app/openra3"] ENTRYPOINT ["/app/openra3"]
+63
View File
@@ -0,0 +1,63 @@
# syntax=docker/dockerfile:1
# Windows cross-build environment (x86_64), fully isolated from the Linux image
# in Dockerfile so the two toolchains and standard libraries never interfere.
#
# Toolchain: llvm-mingw (clang + libc++ + the libc++ `std` module, MSVCRT
# runtime) with the official SDL3 MinGW development package.
FROM ubuntu:26.04 AS dev
ENV DEBIAN_FRONTEND=noninteractive
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 \
for attempt in 1 2 3 4 5; do \
apt-get -o Acquire::Retries=5 update && break; \
echo "apt-get update failed (attempt $attempt), retrying"; \
sleep 5; \
done \
&& apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
ca-certificates \
cmake \
curl \
ninja-build \
xz-utils \
&& rm -rf /var/lib/apt/lists/*
# Pinned toolchain + SDL3 versions (override with --build-arg to bump).
ARG LLVM_MINGW_VERSION=20260908
ARG LLVM_MINGW_FLAVOR=msvcrt
ARG SDL3_VERSION=3.4.16
RUN mkdir -p /opt/llvm-mingw /opt/sdl3-mingw \
&& curl -fL "https://github.com/mstorsjo/llvm-mingw/releases/download/${LLVM_MINGW_VERSION}/llvm-mingw-${LLVM_MINGW_VERSION}-${LLVM_MINGW_FLAVOR}-ubuntu-22.04-x86_64.tar.xz" \
| tar -xJ -C /opt/llvm-mingw --strip-components=1 \
&& curl -fL "https://github.com/libsdl-org/SDL/releases/download/release-${SDL3_VERSION}/SDL3-devel-${SDL3_VERSION}-mingw.tar.gz" \
| tar -xz -C /opt/sdl3-mingw --strip-components=1
ENV OPENRA3_LLVM_MINGW_ROOT=/opt/llvm-mingw
ENV OPENRA3_SDL3_ROOT=/opt/sdl3-mingw
WORKDIR /work
COPY . .
RUN cmake -S . -B build/windows -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/llvm-mingw-x86_64.cmake \
-DCMAKE_BUILD_TYPE=Release \
&& cmake --build build/windows -j
# package target: the .exe plus the SDL3 runtime DLL.
FROM ubuntu:26.04 AS package
WORKDIR /app
COPY --from=dev /work/build/windows/bin/openra3.exe /app/
COPY --from=dev /work/build/windows/bin/SDL3.dll /app/
+81 -15
View File
@@ -1,7 +1,8 @@
# OpenRA3 # OpenRA3
A from-scratch, portable re-implementation of **Command & Conquer: Red Alert 3** A from-scratch, portable re-implementation of **Command & Conquer: Red Alert 3**
in **C++26** using **C++ modules**, built with **GCC 16**. in **C++26** using **C++ modules** and `import std;`, built with **Clang** for
both **Linux** and **Windows**.
Red Alert 3 runs SAGE 2.0. EA never released that engine's C++ source, but it 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 did open-source the closely related SAGE 1.0 engine as
@@ -17,11 +18,13 @@ Ghidra.
## Status ## Status
OpenRA3 is at **v0.0.1**. The engine compiles and runs headless, and a **minimal OpenRA3 is at **v0.2.0**. The engine compiles and runs headless, and a **minimal
skirmish** is playable: it reads a real multiplayer map out of your install, skirmish** is playable: it reads a real multiplayer map out of your install,
recovers the player start waypoints, and simulates two sides earning credits, 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 training units and fighting until one base falls. The map and match state can be
the match is observed as a deterministic headless result. **rendered** to a window (SDL3) or to an image. The whole tree builds for
**Linux** (clang + libc++) and cross-compiles to **Windows** (`openra3.exe` +
`SDL3.dll`) with llvm-mingw — both using C++26 modules and `import std;`.
```text ```text
$ openra3 skirmish --game-dir "/game" --map map_mp_2_feasel4 $ openra3 skirmish --game-dir "/game" --map map_mp_2_feasel4
@@ -66,24 +69,55 @@ built-in test map so the project still builds and runs in CI.
| `src/fs/ra3.fs.cppm` | `BIG4` archives, RefPack codec, local install locator | | `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/map/ra3.map.cppm` | map catalog, `EAR`/RefPack unwrap, start waypoints |
| `src/skirmish/ra3.skirmish.cppm` | units, economy, AI, combat, win condition | | `src/skirmish/ra3.skirmish.cppm` | units, economy, AI, combat, win condition |
| `src/render/ra3.render.cppm` | ARGB framebuffer, TGA decode, BMP encode, map compositing |
| `src/ui/ra3.ui.*.cppm` | SDL3 window viewer (null backend when SDL3 is absent) |
| `src/ra3.cppm` | umbrella module re-exporting the SDK | | `src/ra3.cppm` | umbrella module re-exporting the SDK |
| `apps/openra3/main.cpp` | `maps` / `skirmish` CLI | | `apps/openra3/main.cpp` | `maps` / `skirmish` CLI |
| `tests/ra3_tests.cpp` | smoke tests (run via `ctest`) | | `tests/ra3_tests.cpp` | smoke tests (run via `ctest`) |
| `tools/` | reference fetch + Ghidra-driven reconstruction helpers | | `tools/` | reference fetch + Ghidra-driven reconstruction helpers |
| `docs/` | architecture, reverse-engineering notes, roadmap | | `docs/` | architecture, reverse-engineering notes, roadmap |
| `Dockerfile` | `dev` (toolchain) and `deploy` (runtime) targets | | `Dockerfile` | Linux build image (`dev` toolchain + `deploy` runtime) |
| `.gitlab-ci.yml` | build → test → package pipeline | | `Dockerfile.win` | isolated Windows cross-build image (llvm-mingw + SDL3 MinGW) |
| `cmake/toolchains/` | `llvm-mingw-x86_64.cmake` cross toolchain |
| `scripts/` | `build-linux.sh` / `build-windows.sh` one-shot builders |
| `.gitlab-ci.yml` | build both targets → test → package pipeline |
## Why Clang + `import std;`
The engine never `#include`s the standard library: every module does
`import std;`. CMake's support for that (`CXX_MODULE_STD`) works today with
Clang + libc++. Linux uses the distro clang (LLVM 21); Windows cross-compiles
with [llvm-mingw](https://github.com/mstorsjo/llvm-mingw) (clang 23 + libc++ +
the libc++ `std` module). The two toolchains live in **separate images** so
their compilers and standard libraries never interfere.
## Build ## Build
The host needs no toolchain: build inside the container. The host needs no toolchain: each target builds inside its own image.
```bash ```bash
docker build --target dev -t openra3-dev:local . # Linux -> build/linux/bin/openra3
scripts/build-linux.sh
docker run --rm -v "$PWD:/work" -w /work openra3-dev:local cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release # Windows -> build/windows/bin/openra3.exe (+ SDL3.dll)
docker run --rm -v "$PWD:/work" -w /work openra3-dev:local cmake --build build -j scripts/build-windows.sh
docker run --rm -v "$PWD:/work" -w /work openra3-dev:local ctest --test-dir build --output-on-failure ```
Or drive Docker directly:
```bash
docker build --target dev -t openra3-linux:local .
docker run --rm -v "$PWD:/work" -w /work openra3-linux:local \
cmake -S . -B build/linux -G Ninja -DCMAKE_BUILD_TYPE=Release
docker run --rm -v "$PWD:/work" -w /work openra3-linux:local cmake --build build/linux -j
docker run --rm -v "$PWD:/work" -w /work openra3-linux:local ctest --test-dir build/linux --output-on-failure
docker build -f Dockerfile.win --target dev -t openra3-windows:local .
docker run --rm -v "$PWD:/work" -w /work openra3-windows:local \
cmake -S . -B build/windows -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/llvm-mingw-x86_64.cmake \
-DCMAKE_BUILD_TYPE=Release
docker run --rm -v "$PWD:/work" -w /work openra3-windows:local cmake --build build/windows -j
``` ```
Behind a slow or blocked mirror, pass `--build-arg APT_MIRROR=<url>`. Behind a slow or blocked mirror, pass `--build-arg APT_MIRROR=<url>`.
@@ -92,17 +126,49 @@ Behind a slow or blocked mirror, pass `--build-arg APT_MIRROR=<url>`.
```bash ```bash
# list the maps in your install # list the maps in your install
docker run --rm -v "/path/to/Red Alert 3:/game:ro" openra3-dev:local \ docker run --rm -v "/path/to/Red Alert 3:/game:ro" openra3-linux:local \
./build/bin/openra3 maps --game-dir /game /work/build/linux/bin/openra3 maps --game-dir /game
# play a headless skirmish on a real map # play a headless skirmish on a real map
docker run --rm -v "/path/to/Red Alert 3:/game:ro" openra3-dev:local \ docker run --rm -v "/path/to/Red Alert 3:/game:ro" openra3-linux:local \
./build/bin/openra3 skirmish --game-dir /game --map map_mp_2_feasel4 --seed 7 /work/build/linux/bin/openra3 skirmish --game-dir /game --map map_mp_2_feasel4 --seed 7
``` ```
The Windows build is a native `openra3.exe` — copy it next to `SDL3.dll` and run
it from `cmd`/PowerShell, e.g. `openra3.exe skirmish --game-dir "C:\Red Alert 3"`.
`--frames N` caps the simulation length (default 15 minutes of game time at `--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. 30 Hz). The result is deterministic for a given map and seed.
## Rendering the map
The renderer draws the map's own overview art, a world grid and the match state
(start markers in yellow, player 0 in blue, player 1 in red).
**Offscreen image** (works anywhere, no display needed):
```bash
docker run --rm -v "/path/to/Red Alert 3:/game:ro" -v "$PWD/out:/out" openra3-linux:local \
/work/build/linux/bin/openra3 render --game-dir /game --map map_mp_2_feasel4 --out /out/map.bmp
```
On Windows the same command runs natively: `openra3.exe render --game-dir
"C:\Red Alert 3" --map map_mp_2_feasel4` opens an SDL3 window.
**Interactive window** (SDL3; drag to pan, wheel to zoom, Esc to quit). In a
container you need an X server on the host — on Windows run
[VcXsrv](https://sourceforge.net/projects/vcxsrv/) and launch it with "Disable
access control", then:
```bash
docker run --rm -v "/path/to/Red Alert 3:/game:ro" -e DISPLAY=host.docker.internal:0.0 \
openra3-linux:local /work/build/linux/bin/openra3 render --game-dir /game --map map_mp_2_feasel4
```
If no display is available the viewer falls back to writing `openra3_view.bmp`.
The unit overlay uses an approximate world scale (`--world-size`, default
5120); exact calibration from the map's heightmap is on the roadmap.
## Reverse engineering ## Reverse engineering
The reconstruction is driven by Ghidra against the retail binary. See The reconstruction is driven by Ghidra against the retail binary. See
+1 -1
View File
@@ -1 +1 @@
0.0.1 0.2.0
+89 -8
View File
@@ -1,11 +1,4 @@
#include <cstdio> import std;
#include <exception>
#include <filesystem>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
import ra3; import ra3;
namespace { namespace {
@@ -14,6 +7,7 @@ namespace {
std::puts("usage:"); std::puts("usage:");
std::puts(" openra3 maps [--game-dir DIR]"); std::puts(" openra3 maps [--game-dir DIR]");
std::puts(" openra3 skirmish [--game-dir DIR] [--map ID] [--frames N] [--seed N]"); std::puts(" openra3 skirmish [--game-dir DIR] [--map ID] [--frames N] [--seed N]");
std::puts(" openra3 render [--game-dir DIR] [--map ID] [--frames N] [--seed N] [--world-size S] [--out FILE.bmp]");
std::puts("env: RA3_GAME_DIR (default C:\\Red Alert 3); game data is never copied into the repo"); std::puts("env: RA3_GAME_DIR (default C:\\Red Alert 3); game data is never copied into the repo");
} }
@@ -98,6 +92,92 @@ namespace {
} }
return 0; return 0;
} }
/** Entry name of a map's overview art (`<stem>_art.tga`). */
auto art_entry_for(const ra3::map::map_info &info) -> std::string {
auto entry = info.entry;
if (entry.size() > 4U) entry.resize(entry.size() - 4U);
entry += "_art.tga";
return entry;
}
auto command_render(const std::vector<std::string> &args, 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 = ra3::fs::big_archive::open(data_path(*dir, "MapsMultiplayer.big"));
const auto catalog = ra3::map::map_catalog::from_archive(archive);
const auto requested = option_value(args, "--map");
const ra3::map::map_info *info = nullptr;
if (requested) {
info = catalog.find(*requested);
} else if (catalog.size() > 0U) {
info = &catalog.maps().front();
}
if (info == nullptr) {
std::printf("map '%s' not found\n", requested ? requested->c_str() : "");
return 1;
}
const auto tga = archive.read(art_entry_for(*info), true);
const auto art = ra3::render::decode_tga(tga);
ra3::render::scene_options scene;
scene.title = "OpenRA3 - " + info->id;
if (const auto size = option_value(args, "--world-size")) scene.world_width = scene.world_height = std::stod(*size);
ra3::skirmish::match_config config;
config.map_id = info->id;
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 starts = ra3::map::load_map(archive, *info).starts;
if (starts.size() < 2U) starts = ra3::skirmish::builtin_start_positions();
auto match = ra3::skirmish::skirmish_match::create(config, starts);
const auto summary = match.run();
std::vector<ra3::render::marker> markers;
for (const auto &player: match.players()) {
markers.push_back({static_cast<double>(player.start.x), static_cast<double>(player.start.y), ra3::render::yellow, 6});
}
for (const auto &entry: match.units()) {
if (!entry.alive) continue;
const auto color = entry.owner == 0U ? ra3::render::blue : ra3::render::red;
int radius = 2;
if (entry.cls == ra3::skirmish::unit_class::tank) radius = 4;
if (entry.cls == ra3::skirmish::unit_class::base) radius = 6;
markers.push_back({static_cast<double>(entry.position.x), static_cast<double>(entry.position.y), color, radius});
}
const auto composed = ra3::render::compose(art, markers, scene);
const auto out = option_value(args, "--out");
const bool no_window = std::find(args.begin(), args.end(), "--no-window") != args.end();
bool shown = false;
if (!out && !no_window) {
ra3::ui::viewer_options viewer;
viewer.title = scene.title;
shown = ra3::ui::run_viewer(composed, viewer);
}
if (!shown) {
const auto path = out.value_or("openra3_view.bmp");
const auto bmp = ra3::render::encode_bmp(composed);
std::ofstream file(path, std::ios::binary);
if (!file) {
std::printf("cannot write %s\n", path.c_str());
return 1;
}
file.write(reinterpret_cast<const char *>(bmp.data()), static_cast<std::streamsize>(bmp.size()));
std::printf("wrote %s (%ux%u)\n", path.c_str(), composed.width(), composed.height());
}
std::printf("map=%s units P0=%d P1=%d winner=%d frames=%u\n", info->id.c_str(), summary.units_left[0], summary.units_left[1], summary.winner,
summary.frames);
return 0;
}
} }
auto main(int argc, char **argv) -> int { auto main(int argc, char **argv) -> int {
@@ -115,6 +195,7 @@ auto main(int argc, char **argv) -> int {
if (command == "maps") return command_maps(game_dir); if (command == "maps") return command_maps(game_dir);
if (command == "skirmish") return command_skirmish(rest, game_dir); if (command == "skirmish") return command_skirmish(rest, game_dir);
if (command == "render") return command_render(rest, game_dir);
if (command == "help" || command == "--help" || command == "-h") { if (command == "help" || command == "--help" || command == "-h") {
print_usage(); print_usage();
return 0; return 0;
+43
View File
@@ -0,0 +1,43 @@
# Cross-compile for Windows x86_64 with llvm-mingw: clang + libc++ + the
# libc++ `std` module, targeting the MSVCRT MinGW runtime (which is what the
# official SDL3 MinGW package links against).
# The `import std` gate must be set before the compiler is probed; a toolchain
# file is processed earlier than the project's own top-level settings.
set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "d0edc3af-4c50-42ea-a356-e2862fe7a444")
set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR x86_64)
set(OPENRA3_LLVM_MINGW_ROOT "/opt/llvm-mingw" CACHE PATH "llvm-mingw installation root")
if(NOT EXISTS "${OPENRA3_LLVM_MINGW_ROOT}/bin/clang++")
message(FATAL_ERROR "llvm-mingw not found at ${OPENRA3_LLVM_MINGW_ROOT} (expected bin/clang++)")
endif()
set(CMAKE_C_COMPILER "${OPENRA3_LLVM_MINGW_ROOT}/bin/clang")
set(CMAKE_CXX_COMPILER "${OPENRA3_LLVM_MINGW_ROOT}/bin/clang++")
set(CMAKE_C_COMPILER_TARGET x86_64-w64-mingw32)
set(CMAKE_CXX_COMPILER_TARGET x86_64-w64-mingw32)
# CMake's standard-library detection does not pass COMPILER_TARGET, so without
# this it probes the host standard library. Putting the target in ARG1 makes
# the probe see libc++ (the MinGW target's library).
set(CMAKE_C_COMPILER_ARG1 "--target=x86_64-w64-mingw32")
set(CMAKE_CXX_COMPILER_ARG1 "--target=x86_64-w64-mingw32")
# libc++ standard-library module description for the MinGW target. Must be set
# before the compiler is probed so CMake can build `import std;`.
set(CMAKE_CXX_STDLIB_MODULES_JSON "${OPENRA3_LLVM_MINGW_ROOT}/x86_64-w64-mingw32/lib/libc++.modules.json")
# Select libc++ explicitly so CMake's `import std` detection recognises the
# standard library (llvm-mingw defaults to it, but CMake keys off -stdlib=).
set(CMAKE_CXX_FLAGS_INIT "-stdlib=libc++")
set(CMAKE_EXE_LINKER_FLAGS_INIT "-stdlib=libc++")
set(CMAKE_SHARED_LINKER_FLAGS_INIT "-stdlib=libc++")
# Official SDL3 MinGW development package (headers + import library + DLL).
set(OPENRA3_SDL3_ROOT "/opt/sdl3-mingw" CACHE PATH "SDL3 MinGW development package root")
set(CMAKE_FIND_ROOT_PATH "${OPENRA3_LLVM_MINGW_ROOT}/x86_64-w64-mingw32")
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
+32 -15
View File
@@ -1,25 +1,31 @@
# Architecture # Architecture
OpenRA3 is organised as a stack of C++ modules. Each layer may import the ones OpenRA3 is organised as a stack of C++26 modules. Each layer may import the ones
below it, never the ones above. below it, never the ones above. Every module uses `import std;` for the standard
library; the engine is built with Clang + libc++ on Linux and cross-compiled
with llvm-mingw for Windows.
``` ```
┌───────────────────────────────┐ ┌───────────────────────────────────────┐
applications │ openra3 (apps/openra3) │ applications │ openra3 (apps/openra3) │
└───────────────┬───────────────┘ └───────────────────┬───────────────────┘
│ import ra3 │ import ra3
┌───────────────▼───────────────┐ ┌───────────────────▼───────────────────┐
umbrella │ ra3 (re-exports everything) │ umbrella │ ra3 (re-exports everything) │
└───────────────┬───────────────┘ └───────────────────┬───────────────────┘
┌───────────────┬────────┴────────┬───────────────┐ ┌──────────────┬───────────────┼───────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
ra3.skirmish ra3.client ra3.game ra3.map ra3.ui ra3.render ra3.skirmish ra3.client ra3.game
match rules display/loop RA3 sides map catalog SDL3 window framebuffer match rules display/loop RA3 sides
│ │ │ │ │ │ │ │ │
└───────┬───────┴────────┬────────┘ │ └──────┬───────┘ └───────┬───────┘ │
▼ ▼ ▼ ▼ ▼ │
ra3.logic ra3.core ra3.fs ra3.core ra3.logic │
simulation types/math/random BIG4 + RefPack simulation │
┌───────────────────────────────────────┬──────────────────────┘
▼ ▼
ra3.map ra3.fs
map catalog BIG4 + RefPack
``` ```
## Module responsibilities ## Module responsibilities
@@ -64,6 +70,17 @@ The minimal match: two players, unit classes (harvester/infantry/tank/base),
passive + harvester income, a simple build AI, movement and combat on a fixed passive + harvester income, a simple build AI, movement and combat on a fixed
30 Hz step, and a base-destruction win condition. Fully deterministic. 30 Hz step, and a base-destruction win condition. Fully deterministic.
### `ra3.render`
A dependency-free software renderer: an ARGB8888 `image` framebuffer with
blit/line/circle primitives, a TGA decoder for the game's map art, a 24-bit BMP
encoder for headless output, and `compose` which overlays a world grid and
markers (start positions, live units) onto the map art.
### `ra3.ui`
The windowed viewer. The SDL3 backend streams the rendered image to a texture
with pan/zoom and Esc-to-quit; when the build has no SDL3, a null backend
returns `false` so the caller writes an offscreen image instead.
## Design rules ## Design rules
1. **No raw owning pointers.** Ownership is `std::unique_ptr`; the partition 1. **No raw owning pointers.** Ownership is `std::unique_ptr`; the partition
+13 -5
View File
@@ -11,12 +11,19 @@ that each one produces something that builds and runs.
CMake/Ninja, Docker `dev`/`deploy`, GitLab CI. Reads `BIG4`/RefPack data CMake/Ninja, Docker `dev`/`deploy`, GitLab CI. Reads `BIG4`/RefPack data
from a local install, recovers map start waypoints, and runs a from a local install, recovers map start waypoints, and runs a
deterministic headless two-player skirmish to a decision. deterministic headless two-player skirmish to a decision.
- [x] **v0.1.0 — map renderer + window.** Software ARGB framebuffer, TGA
decoder for the map art, BMP output, map/grid/unit compositing, and an
SDL3 window viewer (pan/zoom).
- [x] **v0.2.0 — dual toolchain.** Switched to Clang + libc++ with
`import std;` in every module; added an isolated Windows cross-build
(llvm-mingw + SDL3 MinGW) producing `openra3.exe` + `SDL3.dll`, alongside
the Linux build. Both images are separate to keep the toolchains apart.
## Next ## Next
- [ ] **v0.1.0 — complete map parsing.** Parse `MPPositionList` for maps that do - [ ] **v0.1.1 — correct world calibration.** Parse the map heightmap
not use `Player_N_Start` waypoints; recover map dimensions, terrain dimensions (and `MPPositionList` starts) so the unit overlay lines up with
heightmap and placement grid; place real starting structures/units. the map art instead of using an assumed world size.
- [ ] **v0.2.0 — data & file formats.** Parse compiled gameplay assets - [ ] **v0.2.0 — data & file formats.** Parse compiled gameplay assets
(`GameObject`, `WeaponTemplate`, `ArmorTemplate`, `LocomotorTemplate`) so (`GameObject`, `WeaponTemplate`, `ArmorTemplate`, `LocomotorTemplate`) so
units use the real balance numbers instead of OpenRA3's stand-ins. units use the real balance numbers instead of OpenRA3's stand-ins.
@@ -25,8 +32,9 @@ that each one produces something that builds and runs.
tree, pathfinding and shroud. tree, pathfinding and shroud.
- [ ] **v0.4.0 — AI.** Skirmish AI: build states, team composition, attack - [ ] **v0.4.0 — AI.** Skirmish AI: build states, team composition, attack
waves (the reference tree's `AI*` modules). waves (the reference tree's `AI*` modules).
- [ ] **v0.5.0 — renderer.** A W3D/D3D9 (or portable Vulkan/OpenGL) client - [ ] **v0.5.0 — full renderer.** Terrain heightmap rendering, W3D models and a
backend implementing `ra3::client::display`, plus input. D3D9/Vulkan/OpenGL client backend implementing `ra3::client::display`,
plus full input.
- [ ] **v0.6.0 — content.** Load real maps, units, powers and strings; play a - [ ] **v0.6.0 — content.** Load real maps, units, powers and strings; play a
skirmish end-to-end with a UI. skirmish end-to-end with a UI.
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Build the Linux target inside its own isolated image (Dockerfile).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
IMAGE="${OPENRA3_LINUX_IMAGE:-openra3-linux:local}"
BUILD_DIR="${1:-build/linux}"
docker build --target dev -t "$IMAGE" "$ROOT"
docker run --rm -v "$ROOT:/work" -w /work "$IMAGE" cmake -S . -B "$BUILD_DIR" -G Ninja -DCMAKE_BUILD_TYPE=Release
docker run --rm -v "$ROOT:/work" -w /work "$IMAGE" cmake --build "$BUILD_DIR" -j
docker run --rm -v "$ROOT:/work" -w /work "$IMAGE" ctest --test-dir "$BUILD_DIR" --output-on-failure
echo "Linux build: $ROOT/$BUILD_DIR/bin/openra3"
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Build the Windows x86_64 target inside its own isolated image (Dockerfile.win).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
IMAGE="${OPENRA3_WINDOWS_IMAGE:-openra3-windows:local}"
BUILD_DIR="${1:-build/windows}"
docker build --target dev -f "$ROOT/Dockerfile.win" -t "$IMAGE" "$ROOT"
docker run --rm -v "$ROOT:/work" -w /work "$IMAGE" \
cmake -S . -B "$BUILD_DIR" -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/llvm-mingw-x86_64.cmake \
-DCMAKE_BUILD_TYPE=Release
docker run --rm -v "$ROOT:/work" -w /work "$IMAGE" cmake --build "$BUILD_DIR" -j
echo "Windows build: $ROOT/$BUILD_DIR/bin/openra3.exe (+ SDL3.dll)"
+2 -6
View File
@@ -1,11 +1,7 @@
module;
#include <cstdint>
#include <memory>
#include <string_view>
export module ra3.client; export module ra3.client;
import std;
import ra3.core; import ra3.core;
import ra3.logic; import ra3.logic;
+4 -17
View File
@@ -1,20 +1,7 @@
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; export module ra3.core;
import std;
/** /**
* Fundamental engine types shared by every OpenRA3 module. * Fundamental engine types shared by every OpenRA3 module.
* *
@@ -24,8 +11,8 @@ export module ra3.core;
*/ */
export namespace ra3::core { export namespace ra3::core {
inline constexpr int version_major = 0; inline constexpr int version_major = 0;
inline constexpr int version_minor = 0; inline constexpr int version_minor = 2;
inline constexpr int version_patch = 1; inline constexpr int version_patch = 0;
using real = float; using real = float;
using int32 = std::int32_t; using int32 = std::int32_t;
+2 -18
View File
@@ -1,23 +1,7 @@
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 module ra3.fs;
import std;
export import ra3.core; export import ra3.core;
/** /**
+2 -7
View File
@@ -1,12 +1,7 @@
module;
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
export module ra3.game; export module ra3.game;
import std;
import ra3.core; import ra3.core;
import ra3.logic; import ra3.logic;
+2 -12
View File
@@ -1,17 +1,7 @@
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; export module ra3.logic;
import std;
import ra3.core; import ra3.core;
/** /**
+2 -13
View File
@@ -1,18 +1,7 @@
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 module ra3.map;
import std;
export import ra3.core; export import ra3.core;
export import ra3.fs; export import ra3.fs;
+2
View File
@@ -13,3 +13,5 @@ export import ra3.game;
export import ra3.fs; export import ra3.fs;
export import ra3.map; export import ra3.map;
export import ra3.skirmish; export import ra3.skirmish;
export import ra3.render;
export import ra3.ui;
+300
View File
@@ -0,0 +1,300 @@
export module ra3.render;
import std;
export import ra3.core;
/**
* A dependency-free software renderer: an ARGB framebuffer, a TGA decoder for
* the game's map art, a BMP encoder for headless output, and map compositing.
*
* The windowed viewer lives in `ra3.ui`; this module is pure computation so it
* builds and runs anywhere, including CI.
*/
export namespace ra3::render {
using ra3::core::uint8;
using ra3::core::uint32;
using ra3::core::usize;
/** Thrown when an image payload is malformed. */
class image_error : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
/** Pack 8-bit channels into the engine's 0xAARRGGBB pixel format. */
[[nodiscard]] constexpr auto argb(uint8 r, uint8 g, uint8 b, uint8 a = 255U) -> uint32 {
return (static_cast<uint32>(a) << 24U) | (static_cast<uint32>(r) << 16U) | (static_cast<uint32>(g) << 8U) | static_cast<uint32>(b);
}
inline constexpr uint32 black = argb(0, 0, 0);
inline constexpr uint32 white = argb(255, 255, 255);
inline constexpr uint32 red = argb(220, 40, 40);
inline constexpr uint32 green = argb(60, 200, 80);
inline constexpr uint32 blue = argb(70, 120, 230);
inline constexpr uint32 yellow = argb(230, 210, 60);
/**
* A top-left-origin ARGB8888 image.
*/
class image {
public:
image() = default;
image(uint32 width, uint32 height, uint32 fill = black) : width_(width), height_(height), pixels_(static_cast<usize>(width) * height, fill) {}
[[nodiscard]] auto width() const -> uint32 { return width_; }
[[nodiscard]] auto height() const -> uint32 { return height_; }
[[nodiscard]] auto empty() const -> bool { return pixels_.empty(); }
[[nodiscard]] auto data() const -> const uint32 * { return pixels_.data(); }
[[nodiscard]] auto data() -> uint32 * { return pixels_.data(); }
auto set(int x, int y, uint32 color) -> void {
if (x < 0 || y < 0 || x >= static_cast<int>(width_) || y >= static_cast<int>(height_)) return;
pixels_[static_cast<usize>(y) * width_ + static_cast<usize>(x)] = color;
}
auto fill(uint32 color) -> void { std::fill(pixels_.begin(), pixels_.end(), color); }
auto blend(int x, int y, uint32 color, uint8 alpha) -> void {
if (alpha == 0U) return;
if (x < 0 || y < 0 || x >= static_cast<int>(width_) || y >= static_cast<int>(height_)) return;
auto &dst = pixels_[static_cast<usize>(y) * width_ + static_cast<usize>(x)];
const auto inv = static_cast<uint32>(255U - alpha);
const auto mix = [alpha, inv](uint32 lo, uint32 hi) { return (hi * alpha + lo * inv) / 255U; };
dst = (0xFF000000U) | (mix((dst >> 16U) & 0xFFU, (color >> 16U) & 0xFFU) << 16U) | (mix((dst >> 8U) & 0xFFU, (color >> 8U) & 0xFFU) << 8U) |
mix(dst & 0xFFU, color & 0xFFU);
}
/** Copy `src` with its top-left at (dst_x, dst_y). */
auto blit(const image &src, int dst_x, int dst_y) -> void {
for (uint32 sy = 0; sy < src.height_; ++sy) {
for (uint32 sx = 0; sx < src.width_; ++sx) {
this->set(dst_x + static_cast<int>(sx), dst_y + static_cast<int>(sy), src.pixels_[static_cast<usize>(sy) * src.width_ + sx]);
}
}
}
auto draw_rect(int x, int y, int w, int h, uint32 color) -> void {
for (int i = 0; i < h; ++i) {
for (int j = 0; j < w; ++j) this->set(x + j, y + i, color);
}
}
auto draw_line(int x0, int y0, int x1, int y1, uint32 color) -> void {
const int dx = std::abs(x1 - x0);
const int dy = -std::abs(y1 - y0);
const int sx = x0 < x1 ? 1 : -1;
const int sy = y0 < y1 ? 1 : -1;
int error = dx + dy;
for (;;) {
this->set(x0, y0, color);
if (x0 == x1 && y0 == y1) break;
const int twice = 2 * error;
if (twice >= dy) {
error += dy;
x0 += sx;
}
if (twice <= dx) {
error += dx;
y0 += sy;
}
}
}
auto fill_circle(int cx, int cy, int radius, uint32 color) -> void {
for (int y = -radius; y <= radius; ++y) {
for (int x = -radius; x <= radius; ++x) {
if (x * x + y * y <= radius * radius) this->set(cx + x, cy + y, color);
}
}
}
auto draw_circle(int cx, int cy, int radius, uint32 color) -> void {
for (int a = 0; a < 360; ++a) {
const auto rad = static_cast<double>(a) * 3.14159265358979323846 / 180.0;
this->set(cx + static_cast<int>(std::lround(std::cos(rad) * radius)), cy + static_cast<int>(std::lround(std::sin(rad) * radius)), color);
}
}
private:
uint32 width_ = 0;
uint32 height_ = 0;
std::vector<uint32> pixels_;
};
[[nodiscard]] inline auto read_u16(std::span<const uint8> data, usize at) -> uint32 {
return static_cast<uint32>(data[at]) | (static_cast<uint32>(data[at + 1U]) << 8U);
}
/**
* Decode a Truevision TGA image (types 2/3/10/11, 8/24/32-bit).
*
* RA3's map art and minimaps are uncompressed 24/32-bit TGAs with a
* bottom-left origin; the decoder handles both origins.
*
* @throws image_error if the header is short or the type is unsupported.
*/
[[nodiscard]] inline auto decode_tga(std::span<const uint8> data) -> image {
if (data.size() < 18U) throw image_error("TGA too short");
const auto id_length = data[0];
const auto color_map_type = data[1];
const auto image_type = data[2];
const auto width = read_u16(data, 12);
const auto height = read_u16(data, 14);
const auto depth = data[16];
const auto descriptor = data[17];
if (color_map_type != 0U) throw image_error("color-mapped TGA unsupported");
const bool rle = image_type == 10U || image_type == 11U;
const bool grayscale = image_type == 3U || image_type == 11U;
if (!grayscale && image_type != 2U && image_type != 10U) throw image_error("unsupported TGA image type");
const usize bytes_per_pixel = static_cast<usize>(depth / 8U);
if (bytes_per_pixel < 1U || bytes_per_pixel > 4U) throw image_error("unsupported TGA depth");
const bool top_origin = (descriptor & 0x20U) != 0U;
image out(width, height);
std::vector<uint32> row(width);
usize pos = 18U + id_length;
auto read_pixel = [&]() -> uint32 {
uint8 r = 0;
uint8 g = 0;
uint8 b = 0;
if (grayscale) {
r = g = b = data[pos];
} else {
b = data[pos];
g = data[pos + 1U];
r = data[pos + 2U];
}
pos += bytes_per_pixel;
return argb(r, g, b);
};
for (uint32 y = 0; y < height; ++y) {
if (rle) {
uint32 x = 0;
while (x < width) {
const auto packet = data[pos++];
const uint32 run = (static_cast<uint32>(packet) & 0x7FU) + 1U;
if ((packet & 0x80U) != 0U) {
const auto value = read_pixel();
for (uint32 i = 0; i < run && x < width; ++i, ++x) row[x] = value;
} else {
for (uint32 i = 0; i < run && x < width; ++i, ++x) row[x] = read_pixel();
}
}
} else {
for (uint32 x = 0; x < width; ++x) row[x] = read_pixel();
}
const auto dest_y = top_origin ? y : (height - 1U - y);
for (uint32 x = 0; x < width; ++x) out.set(static_cast<int>(x), static_cast<int>(dest_y), row[x]);
}
return out;
}
/**
* Encode an image as a 24-bit bottom-up BMP.
*/
[[nodiscard]] inline auto encode_bmp(const image &source) -> std::vector<uint8> {
const auto width = static_cast<uint32>(source.width());
const auto height = static_cast<uint32>(source.height());
const uint32 row_bytes = ((width * 3U + 3U) / 4U) * 4U;
const uint32 pixel_bytes = row_bytes * height;
const uint32 file_size = 54U + pixel_bytes;
std::vector<uint8> out(file_size, 0U);
auto put32 = [&out](usize at, uint32 value) {
out[at] = static_cast<uint8>(value);
out[at + 1U] = static_cast<uint8>(value >> 8U);
out[at + 2U] = static_cast<uint8>(value >> 16U);
out[at + 3U] = static_cast<uint8>(value >> 24U);
};
auto put16 = [&out](usize at, std::uint16_t value) {
out[at] = static_cast<uint8>(value);
out[at + 1U] = static_cast<uint8>(value >> 8U);
};
out[0] = 'B';
out[1] = 'M';
put32(2, file_size);
put32(10, 54U); // pixel data offset
put32(14, 40U); // DIB header size
put32(18, width);
put32(22, height);
put16(26, 1U); // planes
put16(28, 24U); // bits per pixel
put32(34, pixel_bytes);
put32(38, 2835U); // 72 DPI
put32(42, 2835U);
for (uint32 y = 0; y < height; ++y) {
const auto src_y = height - 1U - y; // BMP is bottom-up
const auto row_base = 54U + static_cast<usize>(y) * row_bytes;
for (uint32 x = 0; x < width; ++x) {
const auto px = source.data()[static_cast<usize>(src_y) * width + x];
out[row_base + x * 3U] = static_cast<uint8>(px & 0xFFU);
out[row_base + x * 3U + 1U] = static_cast<uint8>((px >> 8U) & 0xFFU);
out[row_base + x * 3U + 2U] = static_cast<uint8>((px >> 16U) & 0xFFU);
}
}
return out;
}
/** A point to overlay on a map, in world coordinates. */
struct marker {
double x = 0.0;
double y = 0.0;
uint32 color = white;
int radius = 4;
};
/** How to composite a map scene. */
struct scene_options {
std::string title;
double world_width = 5120.0;
double world_height = 5120.0;
bool show_grid = true;
int grid_divisions = 8;
uint32 grid_color = argb(120, 140, 170);
uint32 background = argb(24, 28, 36);
};
/**
* Composite a map image with a world grid and marker overlays.
*
* World `(0,0)` maps to the bottom-left of `base`; world `+Y` points up, so
* the image is flipped vertically. This is the engine's own top-down view
* of the map; precise world calibration is a later milestone.
*/
[[nodiscard]] inline auto compose(const image &base, std::span<const marker> markers, const scene_options &options) -> image {
image scene(base.width(), base.height(), options.background);
scene.blit(base, 0, 0);
const auto to_px = [&](double world_x, double world_y) -> std::pair<int, int> {
const auto fx = options.world_width > 0.0 ? world_x / options.world_width : 0.0;
const auto fy = options.world_height > 0.0 ? world_y / options.world_height : 0.0;
return {static_cast<int>(std::lround(fx * static_cast<double>(base.width()))),
static_cast<int>(std::lround((1.0 - fy) * static_cast<double>(base.height())))};
};
if (options.show_grid && options.grid_divisions > 0) {
for (int i = 0; i <= options.grid_divisions; ++i) {
const auto fx = static_cast<double>(i) / options.grid_divisions;
const auto x = static_cast<int>(std::lround(fx * base.width()));
const auto y = static_cast<int>(std::lround(fx * base.height()));
scene.draw_line(x, 0, x, static_cast<int>(base.height()) - 1, options.grid_color);
scene.draw_line(0, y, static_cast<int>(base.width()) - 1, y, options.grid_color);
}
}
for (const auto &point: markers) {
const auto [px, py] = to_px(point.x, point.y);
scene.fill_circle(px, py, point.radius, point.color);
scene.draw_circle(px, py, point.radius + 1, black);
}
return scene;
}
}
+2 -11
View File
@@ -1,16 +1,7 @@
module;
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <span>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
export module ra3.skirmish; export module ra3.skirmish;
import std;
export import ra3.core; export import ra3.core;
export import ra3.map; export import ra3.map;
export import ra3.game; export import ra3.game;
+21
View File
@@ -0,0 +1,21 @@
export module ra3.ui;
import std;
export import ra3.core;
import ra3.render;
/**
* Fallback viewer used when the build has no SDL3. It never opens a window;
* the caller should write an offscreen image instead.
*/
export namespace ra3::ui {
/** Window parameters (mirrors the SDL backend). */
struct viewer_options {
std::string title = "OpenRA3";
int width = 1024;
int height = 768;
};
/** Always returns false: this build has no windowing backend. */
[[nodiscard]] inline auto run_viewer(const ra3::render::image &, const viewer_options &) -> bool { return false; }
}
+91
View File
@@ -0,0 +1,91 @@
module;
#include <SDL3/SDL.h>
export module ra3.ui;
import std;
export import ra3.core;
import ra3.render;
/**
* A minimal SDL3 window for viewing a rendered scene.
*
* Controls: drag with the left mouse button to pan, wheel to zoom, Esc/close
* to quit. When no display is available `run_viewer` returns false so the
* caller can fall back to an offscreen image.
*/
export namespace ra3::ui {
/** Window parameters. */
struct viewer_options {
std::string title = "OpenRA3";
int width = 1024;
int height = 768;
};
/**
* Show `scene` in a window until the user quits.
*
* @return true if the window ran; false if video init or window creation
* failed (typically no display).
*/
[[nodiscard]] inline auto run_viewer(const ra3::render::image &scene, const viewer_options &options) -> bool {
if (scene.empty()) return false;
if (!SDL_Init(SDL_INIT_VIDEO)) return false;
SDL_Window *window = SDL_CreateWindow(options.title.c_str(), options.width, options.height, SDL_WINDOW_RESIZABLE);
if (window == nullptr) {
SDL_Quit();
return false;
}
SDL_Renderer *renderer = SDL_CreateRenderer(window, nullptr);
if (renderer == nullptr) {
SDL_DestroyWindow(window);
SDL_Quit();
return false;
}
SDL_Texture *texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_ARGB8888, SDL_TEXTUREACCESS_STREAMING, static_cast<int>(scene.width()),
static_cast<int>(scene.height()));
if (texture == nullptr) {
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return false;
}
SDL_UpdateTexture(texture, nullptr, scene.data(), static_cast<int>(scene.width()) * 4);
SDL_SetTextureScaleMode(texture, SDL_SCALEMODE_NEAREST);
float zoom = 1.0F;
float pan_x = 0.0F;
float pan_y = 0.0F;
bool running = true;
SDL_Event event;
while (running) {
while (SDL_PollEvent(&event)) {
if (event.type == SDL_EVENT_QUIT) {
running = false;
} else if (event.type == SDL_EVENT_KEY_DOWN && event.key.key == SDLK_ESCAPE) {
running = false;
} else if (event.type == SDL_EVENT_MOUSE_WHEEL) {
zoom *= event.wheel.y > 0.0F ? 1.1F : (1.0F / 1.1F);
zoom = std::clamp(zoom, 0.1F, 20.0F);
} else if (event.type == SDL_EVENT_MOUSE_MOTION && (event.motion.state & SDL_BUTTON_LMASK) != 0U) {
pan_x += event.motion.xrel;
pan_y += event.motion.yrel;
}
}
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
SDL_RenderClear(renderer);
const SDL_FRect dst{pan_x, pan_y, static_cast<float>(scene.width()) * zoom, static_cast<float>(scene.height()) * zoom};
SDL_RenderTexture(renderer, texture, nullptr, &dst);
SDL_RenderPresent(renderer);
}
SDL_DestroyTexture(texture);
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return true;
}
}
+22 -9
View File
@@ -1,12 +1,4 @@
#include <cstdint> import std;
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
import ra3; import ra3;
namespace { namespace {
@@ -107,6 +99,27 @@ auto main() -> int {
std::error_code ignored; std::error_code ignored;
std::filesystem::remove(archive_path, ignored); std::filesystem::remove(archive_path, ignored);
// TGA: a 2x2 top-origin truecolor image decodes with correct pixels.
std::vector<core::uint8> tga{0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0, 24, 0x20};
const core::uint8 tga_pixels[] = {0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255};
tga.insert(tga.end(), tga_pixels, tga_pixels + 12);
const auto decoded_tga = render::decode_tga(tga);
check(decoded_tga.width() == 2U && decoded_tga.height() == 2U, "TGA dimensions");
check(decoded_tga.data()[0] == render::argb(255, 0, 0), "TGA top-left is red");
check(decoded_tga.data()[3] == render::argb(255, 255, 255), "TGA bottom-right is white");
const auto bmp = render::encode_bmp(decoded_tga);
check(bmp.size() > 54U && bmp[0] == 'B' && bmp[1] == 'M', "BMP header");
check(bmp[18] == 2U && bmp[19] == 0U && bmp[22] == 2U, "BMP dimensions");
// Compositing keeps the source size and accepts markers.
render::scene_options scene;
scene.world_width = 100.0;
scene.world_height = 100.0;
const std::vector<render::marker> markers{{10.0, 20.0, render::red, 3}};
const auto composed = render::compose(decoded_tga, markers, scene);
check(composed.width() == 2U && composed.height() == 2U, "compose preserves size");
// Skirmish: deterministic, resolves within the frame cap. // Skirmish: deterministic, resolves within the frame cap.
skirmish::match_config config; skirmish::match_config config;
config.seed = 42U; config.seed = 42U;