5 Commits
Author SHA1 Message Date
EnderTheCoder 3caf18409b v0.4.0: Direct3D 11/12 backends, in-game renderer selection, per-run logging
- ra3.dx: D3D11 and D3D12 presentation backends (runtime HLSL via d3dcompiler);
  2D image blit and the GPU heightfield terrain raymarch, with the corner
  minimap/FPS overlays. Non-Windows builds link a null fallback.
- ra3.display: preferred backend plus ordered fallback (Vulkan/D3D11/D3D12/SDL);
  menu gains a Renderer option and the CLI gains --dx11/--dx12/--sdl, which the
  render command now honours too.
- vendor libenderlog (MIT): every run writes openra3.log next to the exe and
  archives the previous run's log; records at warn and above carry a call stack
  (native fallback, since libc++ has no <stacktrace>).
- Windows crash reporter writes openra3_crash.log (faulting module + backtrace);
  D3D/DXGI diagnostics are routed through the logger.
2026-09-28 13:03:37 +08:00
EnderTheCoder 59261868e1 docs: rewrite ARCHITECTURE as a Module/Function/Feature master plan
Decompose the full Red Alert 3 feature set three levels deep (29 modules, 169 functions, ~500 features) with per-feature status and milestone tags. Fold the release roadmap into ARCHITECTURE section 5 and remove docs/ROADMAP.md.
2026-09-26 22:28:19 +08:00
EnderTheCoder a7612878e5 ci: use the dind containerd snapshotter so the docker driver can push/cache to Harbor over plain HTTP 2026-09-20 02:35:31 +08:00
EnderTheCoder bb3f8306ef ci: push toolchain images to Harbor (192.168.1.11:9090/openra3) instead of docker-save artifacts 2026-09-20 02:32:05 +08:00
EnderTheCoder 7cb4d18308 ci: fix dind anchor (services must be an array); push images to Docker Hub instead of docker-save artifacts 2026-09-20 02:30:00 +08:00
18 changed files with 3613 additions and 261 deletions
+1
View File
@@ -30,3 +30,4 @@ compile_commands.json
# local data dumps / RE artifacts # local data dumps / RE artifacts
re-data/ re-data/
*.log *.log
*.log.[0-9]*
+62 -34
View File
@@ -4,36 +4,58 @@ stages:
variables: variables:
DOCKER_HOST: tcp://docker:2375 DOCKER_HOST: tcp://docker:2375
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: "" DOCKER_TLS_CERTDIR: ""
DOCKER_BUILDKIT: "1" DOCKER_BUILDKIT: "1"
# apt mirror used inside both images (override per pipeline if needed) # apt mirror used inside both images (override per pipeline if needed)
APT_MIRROR: "http://mirrors.tuna.tsinghua.edu.cn/ubuntu" APT_MIRROR: "http://mirrors.tuna.tsinghua.edu.cn/ubuntu"
# Images are shared through Harbor (plain HTTP), never as artifacts.
HARBOR_HOST: "192.168.1.11:9090"
HARBOR_PROJECT: "openra3"
IMAGE: "$HARBOR_HOST/$HARBOR_PROJECT/$CI_PROJECT_NAME"
# dind: pull docker.io through the intranet Harbor proxy (plain HTTP ->
# --insecure-registry) and enable the containerd snapshotter so the *docker*
# driver can export the registry BuildKit cache (the classic overlay2 driver
# rejects it). Anchor is an array, so `services: *dind` stays an array.
.dind: &dind .dind: &dind
services: - name: docker:dind
- name: docker:dind command:
# Pull docker.io images through the intranet Harbor pull-through cache; - --registry-mirror=http://192.168.1.11:9090/dockerhub
# plain HTTP registry, hence --insecure-registry. - --insecure-registry=192.168.1.11:9090
command: - --feature=containerd-snapshotter=true
- --registry-mirror=http://192.168.1.11:9090/dockerhub
- --insecure-registry=192.168.1.11:9090
# --- build the two isolated toolchain images --------------------------------- # Retry helper for transient network failures (login / build / push / pull).
.retry: &retry
- |
retry() {
n=0
until "$@"; do
n=$((n + 1))
[ "$n" -ge 5 ] && { echo "failed after $n tries: $*" >&2; return 1; }
echo "attempt $n failed, retrying: $*" >&2
sleep 15
done
}
# --- build the two isolated toolchain images and push them to Harbor ----------
linux_image: linux_image:
stage: image stage: image
image: docker:latest image: docker:latest
services: *dind services: *dind
tags: tags:
- docker - docker
before_script: *retry
script: script:
- docker build --target dev -t openra3-linux:local --build-arg APT_MIRROR=$APT_MIRROR . - retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
- docker save openra3-linux:local -o linux-image.tar - |
artifacts: retry docker build \
name: "linux-image-$CI_COMMIT_SHORT_SHA" --cache-from type=registry,ref=$IMAGE:cache-linux,insecure=true \
paths: --cache-to type=registry,ref=$IMAGE:cache-linux,mode=max,insecure=true \
- linux-image.tar --build-arg APT_MIRROR=$APT_MIRROR \
expire_in: 1 day --target dev \
-t $IMAGE:$CI_COMMIT_SHORT_SHA-linux \
-f Dockerfile .
- retry docker push $IMAGE:$CI_COMMIT_SHORT_SHA-linux
windows_image: windows_image:
stage: image stage: image
@@ -41,14 +63,18 @@ windows_image:
services: *dind services: *dind
tags: tags:
- docker - docker
before_script: *retry
script: script:
- docker build -f Dockerfile.win --target dev -t openra3-windows:local --build-arg APT_MIRROR=$APT_MIRROR . - retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
- docker save openra3-windows:local -o windows-image.tar - |
artifacts: retry docker build \
name: "windows-image-$CI_COMMIT_SHORT_SHA" --cache-from type=registry,ref=$IMAGE:cache-windows,insecure=true \
paths: --cache-to type=registry,ref=$IMAGE:cache-windows,mode=max,insecure=true \
- windows-image.tar --build-arg APT_MIRROR=$APT_MIRROR \
expire_in: 1 day --target dev \
-t $IMAGE:$CI_COMMIT_SHORT_SHA-windows \
-f Dockerfile.win .
- retry docker push $IMAGE:$CI_COMMIT_SHORT_SHA-windows
# --- build and test each target ---------------------------------------------- # --- build and test each target ----------------------------------------------
build_linux: build_linux:
@@ -59,13 +85,14 @@ build_linux:
- docker - docker
needs: needs:
- linux_image - linux_image
before_script: before_script: *retry
- docker load -i linux-image.tar
script: script:
- docker run --rm -v "$(pwd):/work" -w /work openra3-linux:local cmake -S . -B build/linux -G Ninja -DCMAKE_BUILD_TYPE=Release - retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
- docker run --rm -v "$(pwd):/work" -w /work openra3-linux:local cmake --build build/linux -j - retry docker pull $IMAGE:$CI_COMMIT_SHORT_SHA-linux
- 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 $IMAGE:$CI_COMMIT_SHORT_SHA-linux cmake -S . -B build/linux -G Ninja -DCMAKE_BUILD_TYPE=Release
- docker run --rm -v "$(pwd):/work" -w /work openra3-linux:local bash -c "mkdir -p artifacts/linux && cp build/linux/bin/openra3 artifacts/linux/" - docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-linux cmake --build build/linux -j
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-linux ctest --test-dir build/linux --output-on-failure
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-linux bash -c "mkdir -p artifacts/linux && cp build/linux/bin/openra3 artifacts/linux/"
artifacts: artifacts:
name: "$CI_PROJECT_NAME-linux-$CI_COMMIT_SHORT_SHA" name: "$CI_PROJECT_NAME-linux-$CI_COMMIT_SHORT_SHA"
paths: paths:
@@ -80,15 +107,16 @@ build_windows:
- docker - docker
needs: needs:
- windows_image - windows_image
before_script: before_script: *retry
- docker load -i windows-image.tar
script: script:
- docker run --rm -v "$(pwd):/work" -w /work openra3-windows:local - retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
- retry docker pull $IMAGE:$CI_COMMIT_SHORT_SHA-windows
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-windows
cmake -S . -B build/windows -G Ninja cmake -S . -B build/windows -G Ninja
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/llvm-mingw-x86_64.cmake -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/llvm-mingw-x86_64.cmake
-DCMAKE_BUILD_TYPE=Release -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 $IMAGE:$CI_COMMIT_SHORT_SHA-windows 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/" - docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-windows bash -c "mkdir -p artifacts/windows && cp build/windows/bin/openra3.exe build/windows/bin/SDL3.dll artifacts/windows/"
artifacts: artifacts:
name: "$CI_PROJECT_NAME-windows-$CI_COMMIT_SHORT_SHA" name: "$CI_PROJECT_NAME-windows-$CI_COMMIT_SHORT_SHA"
paths: paths:
+66 -3
View File
@@ -25,7 +25,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_SCAN_FOR_MODULES ON) set(CMAKE_CXX_SCAN_FOR_MODULES ON)
project(OpenRA3 VERSION 0.3.0 LANGUAGES C CXX) project(OpenRA3 VERSION 0.4.0 LANGUAGES C 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)
@@ -112,6 +112,36 @@ function(openra3_embed_spirv out_header)
file(WRITE "${out_header}" "${content}") file(WRITE "${out_header}" "${content}")
endfunction() endfunction()
# Embed the HLSL sources as string literals. The Direct3D backends compile them
# at runtime with d3dcompiler_47, so no HLSL compiler is needed at build time
# (the Windows target cross-compiles from Linux).
function(openra3_embed_hlsl out_header)
set(content "// Generated by CMake from shaders/*.hlsl.\n")
string(APPEND content "#pragma once\n\nnamespace ra3_shaders {\n")
foreach(src IN LISTS ARGN)
if(NOT EXISTS "${src}")
message(FATAL_ERROR "Missing HLSL shader ${src}")
endif()
get_filename_component(base "${src}" NAME_WE)
string(MAKE_C_IDENTIFIER "${base}" ident)
string(APPEND ident "_hlsl")
file(READ "${src}" text)
string(APPEND content "inline constexpr const char ${ident}[] = R\"RA3HLSL(${text})RA3HLSL\";\n\n")
endforeach()
string(APPEND content "} // namespace ra3_shaders\n")
file(WRITE "${out_header}" "${content}")
endfunction()
# --- logging (vendored libenderlog, MIT) -------------------------------------
# A standalone C++26 module logger (`import ender.log;`) with a per-run
# archiving file sink. libc++ has no <stacktrace>, so on this toolchain the
# module records call sites but no stacks; it defaults ENDERLOG_HAS_STACKTRACE
# to 0 without any define.
add_library(ra3_enderlog STATIC)
target_sources(ra3_enderlog PUBLIC FILE_SET CXX_MODULES FILES third_party/libenderlog/src/ender.log.cppm)
target_compile_features(ra3_enderlog PUBLIC cxx_std_26)
openra3_target_defaults(ra3_enderlog)
# --- engine core ------------------------------------------------------------- # --- engine core -------------------------------------------------------------
add_library(ra3_core STATIC) add_library(ra3_core STATIC)
target_sources(ra3_core PUBLIC FILE_SET CXX_MODULES FILES src/core/ra3.core.cppm) target_sources(ra3_core PUBLIC FILE_SET CXX_MODULES FILES src/core/ra3.core.cppm)
@@ -210,16 +240,49 @@ else()
endif() endif()
openra3_target_defaults(ra3_vulkan) openra3_target_defaults(ra3_vulkan)
# --- Direct3D viewer (D3D11 + D3D12, or null fallback) ------------------------
# Direct3D is Windows-only, so the real backends build only for the MinGW target;
# every other platform links the `ra3.dx.null` fallback that fails `init` cleanly.
option(OPENRA3_DX "Build the Direct3D 11/12 viewer" ON)
set(OPENRA3_HAS_DX OFF)
if(OPENRA3_DX AND WIN32 AND OPENRA3_HAS_SDL3)
set(OPENRA3_HAS_DX ON)
endif()
if(OPENRA3_HAS_DX)
message(STATUS "OpenRA3: Direct3D 11/12 viewer enabled (runtime HLSL via d3dcompiler)")
else()
message(STATUS "OpenRA3: Direct3D viewer disabled - offscreen image only")
endif()
add_library(ra3_dx STATIC)
if(OPENRA3_HAS_DX)
openra3_embed_hlsl(
"${CMAKE_CURRENT_BINARY_DIR}/generated/dx_shaders_embedded.hpp"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/dx_scene.hlsl"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/dx_terrain.hlsl"
)
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/dx_scene.hlsl"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/dx_terrain.hlsl")
target_sources(ra3_dx PUBLIC FILE_SET CXX_MODULES FILES src/dx/ra3.dx.cppm)
target_include_directories(ra3_dx PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
target_link_libraries(ra3_dx PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_enderlog openra3_sdl3 d3d11 d3d12 dxgi d3dcompiler)
else()
target_sources(ra3_dx PUBLIC FILE_SET CXX_MODULES FILES src/dx/ra3.dx.null.cppm)
target_link_libraries(ra3_dx PUBLIC ra3_core ra3_render ra3_client)
endif()
openra3_target_defaults(ra3_dx)
# --- display backend selection ------------------------------------------------ # --- display backend selection ------------------------------------------------
add_library(ra3_display STATIC) add_library(ra3_display STATIC)
target_sources(ra3_display PUBLIC FILE_SET CXX_MODULES FILES src/display/ra3.display.cppm) target_sources(ra3_display PUBLIC FILE_SET CXX_MODULES FILES src/display/ra3.display.cppm)
target_link_libraries(ra3_display PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_ui ra3_vulkan) target_link_libraries(ra3_display PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_ui ra3_vulkan ra3_dx)
openra3_target_defaults(ra3_display) openra3_target_defaults(ra3_display)
# --- umbrella ----------------------------------------------------------------- # --- umbrella -----------------------------------------------------------------
add_library(ra3 STATIC) add_library(ra3 STATIC)
target_sources(ra3 PUBLIC FILE_SET CXX_MODULES FILES src/ra3.cppm) target_sources(ra3 PUBLIC FILE_SET CXX_MODULES FILES src/ra3.cppm)
target_link_libraries(ra3 PUBLIC ra3_core ra3_logic ra3_client ra3_data ra3_game ra3_fs ra3_map ra3_skirmish ra3_render ra3_terrain ra3_display ra3_ui ra3_vulkan) target_link_libraries(ra3 PUBLIC ra3_core ra3_logic ra3_client ra3_data ra3_game ra3_fs ra3_map ra3_skirmish ra3_render ra3_terrain ra3_display
ra3_ui ra3_vulkan ra3_dx ra3_enderlog)
openra3_target_defaults(ra3) openra3_target_defaults(ra3)
# --- executable --------------------------------------------------------------- # --- executable ---------------------------------------------------------------
+20 -5
View File
@@ -18,14 +18,16 @@ Ghidra.
## Status ## Status
OpenRA3 is at **v0.3.0**. The engine compiles and runs headless, and a **minimal OpenRA3 is at **v0.4.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 building a base, recovers the player start waypoints, and simulates two sides building a base,
extracting ore and fighting until one side is wiped out. The balance is the extracting ore and fighting until one side is wiped out. The balance is the
**retail Red Alert 3 balance**, pinned in `ra3.data` from EA's open RA3 XML: **retail Red Alert 3 balance**, pinned in `ra3.data` from EA's open RA3 XML:
damage types, `ArmorTemplate` percentages, weapon target masks, build costs and damage types, `ArmorTemplate` percentages, weapon target masks, build costs and
the ore economy. Presentation is a **Vulkan** backend (`ra3.vulkan`) with a null the ore economy. Presentation offers **Vulkan** (`ra3.vulkan`) and **Direct3D 11 / 12**
fallback; the software renderer still produces headless images. Terrain tiles (`ra3.dx`) GPU backends with an SDL software blit and null fallbacks; the
backend is selectable from the in-game menu and the software renderer still
produces headless images. Terrain tiles
cross-fade the way the retail `Terrain.fx` does (a per-cell blend ramp plus a cross-fade the way the retail `Terrain.fx` does (a per-cell blend ramp plus a
gutter-padded atlas), so material boundaries are smooth instead of a grid of gutter-padded atlas), so material boundaries are smooth instead of a grid of
hard lines. An in-window **menu** lists the maps by their localized name and hard lines. An in-window **menu** lists the maps by their localized name and
@@ -83,7 +85,7 @@ built-in test map so the project still builds and runs in CI.
| `src/core/ra3.core.cppm` | fundamental types, math, strings, random, message stream | | `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/logic/ra3.logic.cppm` | objects, players, teams, spatial partition, game loop |
| `src/client/ra3.client.cppm` | `display` abstraction + shared interactive loops + client facade | | `src/client/ra3.client.cppm` | `display` abstraction + shared interactive loops + client facade |
| `src/display/ra3.display.cppm` | picks the backend (Vulkan, then SDL) for the app | | `src/display/ra3.display.cppm` | picks the backend (Vulkan/D3D11/D3D12, then SDL) for the app |
| `src/game/ra3.game.cppm` | RA3 sides, player templates, skirmish defaults | | `src/game/ra3.game.cppm` | RA3 sides, player templates, skirmish defaults |
| `src/data/ra3.data.cppm` | retail RA3 balance: damage types, armour, weapons, units, economy | | `src/data/ra3.data.cppm` | retail RA3 balance: damage types, armour, weapons, units, economy |
| `src/fs/ra3.fs.cppm` | `BIG4` archives, RefPack codec, local install locator | | `src/fs/ra3.fs.cppm` | `BIG4` archives, RefPack codec, local install locator |
@@ -93,11 +95,13 @@ built-in test map so the project still builds and runs in CI.
| `src/render/ra3.render.cppm` | ARGB framebuffer, TGA decode, BMP encode, map compositing, bitmap-font text | | `src/render/ra3.render.cppm` | ARGB framebuffer, TGA decode, BMP encode, map compositing, bitmap-font text |
| `src/ui/ra3.ui.*.cppm` | SDL3 window viewer and menu (null backend when SDL3 is absent) | | `src/ui/ra3.ui.*.cppm` | SDL3 window viewer and menu (null backend when SDL3 is absent) |
| `src/vulkan/ra3.vulkan.*.cppm` | Vulkan presentation backend and menu (null fallback without a loader) | | `src/vulkan/ra3.vulkan.*.cppm` | Vulkan presentation backend and menu (null fallback without a loader) |
| `src/dx/ra3.dx.*.cppm` | Direct3D 11/12 presentation backend (runtime HLSL; null fallback off Windows) |
| `third_party/libenderlog/` | vendored C++26 module logger (`import ender.log;`, MIT) with a native stack fallback for libc++ |
| `src/ra3.cppm` | umbrella module re-exporting the SDK | | `src/ra3.cppm` | umbrella module re-exporting the SDK |
| `apps/openra3/main.cpp` | `menu` / `maps` / `skirmish` / `render` CLI | | `apps/openra3/main.cpp` | `menu` / `maps` / `skirmish` / `render` 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 & master plan, reverse-engineering notes |
| `Dockerfile` | Linux build image (`dev` toolchain + `deploy` runtime) | | `Dockerfile` | Linux build image (`dev` toolchain + `deploy` runtime) |
| `Dockerfile.win` | isolated Windows cross-build image (llvm-mingw + SDL3 MinGW) | | `Dockerfile.win` | isolated Windows cross-build image (llvm-mingw + SDL3 MinGW) |
| `cmake/toolchains/` | `llvm-mingw-x86_64.cmake` cross toolchain | | `cmake/toolchains/` | `llvm-mingw-x86_64.cmake` cross toolchain |
@@ -156,6 +160,17 @@ openra3.exe render --game-dir "C:\Red Alert 3" --vulkan
Vulkan is provided by **vendored volk + headers** (`third_party/`), resolved at Vulkan is provided by **vendored volk + headers** (`third_party/`), resolved at
runtime, so neither image needs a Vulkan SDK. runtime, so neither image needs a Vulkan SDK.
## Logging
Every run writes `openra3.log` next to the executable through the vendored
[`libenderlog`](third_party/libenderlog) module (`import ender.log;`). A file
sink archives the previous log to `openra3.log.<YYYYmmdd-HHMMSS>` on open, so
each run gets its own file; the active file rotates at 4 MiB and the last 10
archives are kept. Records at **`warn` and above** carry a call stack (Windows
`CaptureStackBackTrace` / POSIX `execinfo`, because libc++ has no
`<stacktrace>`). A hard crash also writes `openra3_crash.log` with the faulting
module and a raw backtrace.
## Running a skirmish ## Running a skirmish
```bash ```bash
+1 -1
View File
@@ -1 +1 @@
0.3.0 0.4.0
+160 -12
View File
@@ -1,7 +1,104 @@
#if defined(_WIN32)
#define NOMINMAX
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#endif
import std; import std;
import ra3; import ra3;
import ender.log;
namespace { namespace {
#if defined(_WIN32)
/** Path of the crash report written next to the executable. */
[[nodiscard]] auto crash_log_path() -> const std::filesystem::path & {
static const auto path = [] {
std::wstring buffer(32768U, L'\0');
const DWORD length = GetModuleFileNameW(nullptr, buffer.data(), static_cast<DWORD>(buffer.size()));
buffer.resize(length);
return std::filesystem::path{buffer}.parent_path() / L"openra3_crash.log";
}();
return path;
}
auto crash_write(std::string_view text) -> void {
const HANDLE file = CreateFileW(crash_log_path().wstring().c_str(), FILE_APPEND_DATA, FILE_SHARE_READ, nullptr, OPEN_ALWAYS,
FILE_ATTRIBUTE_NORMAL, nullptr);
if (file == INVALID_HANDLE_VALUE) return;
DWORD written = 0;
WriteFile(file, text.data(), static_cast<DWORD>(text.size()), &written, nullptr);
CloseHandle(file);
}
/** `address` rendered as `module.dll+0xRVA`, or `0x...` when unmapped. */
auto crash_describe(std::uintptr_t address, char *out, std::size_t size) -> void {
HMODULE module = nullptr;
if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(address), &module)) {
wchar_t wide[260] = L"?";
GetModuleFileNameW(module, wide, 260U);
char narrow[260] = "?";
WideCharToMultiByte(CP_UTF8, 0, wide, -1, narrow, sizeof(narrow), nullptr, nullptr);
const char *base = std::strrchr(narrow, '\\');
const auto rva = address - reinterpret_cast<std::uintptr_t>(module);
std::snprintf(out, size, "%s+0x%llX", base != nullptr ? base + 1 : narrow, static_cast<unsigned long long>(rva));
return;
}
std::snprintf(out, size, "0x%llX", static_cast<unsigned long long>(address));
}
LONG WINAPI openra3_crash_handler(EXCEPTION_POINTERS *info) {
char line[512];
const auto *record = info->ExceptionRecord;
std::snprintf(line, sizeof(line), "\n=== OpenRA3 crash ===\nmodule=%s\nexception=0x%08lX address=0x%p\n",
"openra3", static_cast<unsigned long>(record->ExceptionCode), record->ExceptionAddress);
crash_write(line);
void *frames[48] = {};
const USHORT count = CaptureStackBackTrace(0U, 48U, frames, nullptr);
for (USHORT i = 0; i < count; ++i) {
char described[320] = {};
crash_describe(reinterpret_cast<std::uintptr_t>(frames[i]), described, sizeof(described));
std::snprintf(line, sizeof(line), " #%02u %s\n", static_cast<unsigned>(i), described);
crash_write(line);
}
crash_write("=== end ===\n");
return EXCEPTION_EXECUTE_HANDLER;
}
auto install_crash_handler() -> void {
SetUnhandledExceptionFilter(&openra3_crash_handler);
std::set_terminate([] {
crash_write("\n=== OpenRA3 std::terminate ===\n");
if (const auto exception = std::current_exception()) {
try {
std::rethrow_exception(exception);
} catch (const std::exception &error) {
char line[512];
std::snprintf(line, sizeof(line), "what(): %s\n", error.what());
crash_write(line);
} catch (...) {
crash_write("what(): (non-std exception)\n");
}
}
std::abort();
});
}
#endif
/**
* Configure the process logger and start a fresh per-run log file.
*
* `ender::log::file_sink` archives the previous `openra3.log` to a
* timestamped file on open, so every run gets its own log and the previous
* run's log is preserved.
*/
auto setup_logging(const std::filesystem::path &exe_dir) -> void {
namespace log = ender::log;
log::configure({.minimum = log::level::info, .stacktrace_from = log::level::warn});
log::add_file_sink(exe_dir / "openra3.log", {.max_file_size = 4U * 1024U * 1024U, .max_archives = 10U});
log::info("OpenRA3 started");
}
auto print_usage() -> void { auto print_usage() -> void {
std::puts("OpenRA3 - Red Alert 3 reconstruction"); std::puts("OpenRA3 - Red Alert 3 reconstruction");
std::puts("usage:"); std::puts("usage:");
@@ -15,6 +112,7 @@ namespace {
std::puts(" [--3d] [--cam-pitch DEG] [--cam-yaw DEG] [--cam-x U] [--cam-y U]"); std::puts(" [--3d] [--cam-pitch DEG] [--cam-yaw DEG] [--cam-x U] [--cam-y U]");
std::puts(" [--cam-height U] [--fov DEG] [--width W] [--height H]"); std::puts(" [--cam-height U] [--fov DEG] [--width W] [--height H]");
std::puts(" [--fullscreen] [--fps N] (0 = vsync)"); std::puts(" [--fullscreen] [--fps N] (0 = vsync)");
std::puts(" [--vulkan] [--dx11] [--dx12] [--sdl] (preferred display backend)");
std::puts("assets: read from <exe_dir>/assets (extracted at build time by the openra3_assets target)"); std::puts("assets: read from <exe_dir>/assets (extracted at build time by the openra3_assets target)");
} }
@@ -29,6 +127,14 @@ namespace {
return std::find(args.begin(), args.end(), name) != args.end(); return std::find(args.begin(), args.end(), name) != args.end();
} }
/** Preferred display backend from the command line (default: Vulkan). */
[[nodiscard]] auto display_backend_from_args(const std::vector<std::string> &args) -> ra3::display::backend {
if (has_flag(args, "--dx11") || has_flag(args, "--d3d11")) return ra3::display::backend::d3d11;
if (has_flag(args, "--dx12") || has_flag(args, "--d3d12")) return ra3::display::backend::d3d12;
if (has_flag(args, "--sdl")) return ra3::display::backend::sdl;
return ra3::display::backend::vulkan;
}
/** Directory the executable lives in. */ /** Directory the executable lives in. */
auto executable_dir(const char *argv0) -> std::filesystem::path { auto executable_dir(const char *argv0) -> std::filesystem::path {
std::error_code ec; std::error_code ec;
@@ -328,7 +434,14 @@ namespace {
options.height = static_cast<int>(height); options.height = static_cast<int>(height);
options.fullscreen = has_flag(args, "--fullscreen"); options.fullscreen = has_flag(args, "--fullscreen");
options.fps_limit = option_value(args, "--fps") ? std::stoi(*option_value(args, "--fps")) : 0; options.fps_limit = option_value(args, "--fps") ? std::stoi(*option_value(args, "--fps")) : 0;
gpu_shown = ra3::display::run_terrain(options, gpu, camera); ra3::render::image mini;
if (has_flag(args, "--minimap")) {
terrain::render_options mini_opts;
mini_opts.scale = 1U;
mini_opts.pitch = 1.0F;
mini = render::downscale(terrain::render(terrain, textures, mini_opts), 220U);
}
gpu_shown = ra3::display::run_terrain(options, gpu, camera, mini, display_backend_from_args(args));
if (gpu_shown) return 0; if (gpu_shown) return 0;
std::printf("GPU terrain unavailable; showing the top-down map instead\n"); std::printf("GPU terrain unavailable; showing the top-down map instead\n");
} }
@@ -409,7 +522,7 @@ namespace {
viewer.height = static_cast<int>(composed.height()); viewer.height = static_cast<int>(composed.height());
viewer.fullscreen = has_flag(args, "--fullscreen"); viewer.fullscreen = has_flag(args, "--fullscreen");
viewer.fps_limit = option_value(args, "--fps") ? std::stoi(*option_value(args, "--fps")) : 0; viewer.fps_limit = option_value(args, "--fps") ? std::stoi(*option_value(args, "--fps")) : 0;
shown = ra3::display::run_image(viewer, composed, camera, has_flag(args, "--vulkan")); shown = ra3::display::run_image(viewer, composed, camera, display_backend_from_args(args));
} }
if (!shown) { if (!shown) {
const auto path = out.value_or("openra3_view.bmp"); const auto path = out.value_or("openra3_view.bmp");
@@ -448,6 +561,7 @@ namespace {
bool thumbnail = false; bool thumbnail = false;
bool fullscreen = false; bool fullscreen = false;
int fps_index = 0; ///< 0 = vsync, 1..3 = 30/60/120, 4 = uncapped. int fps_index = 0; ///< 0 = vsync, 1..3 = 30/60/120, 4 = uncapped.
int renderer = 0; ///< Preferred display backend (see renderer_backends).
std::string out; std::string out;
}; };
@@ -455,6 +569,19 @@ namespace {
inline constexpr std::array<int, 5> fps_caps{0, 30, 60, 120, -1}; inline constexpr std::array<int, 5> fps_caps{0, 30, 60, 120, -1};
inline constexpr std::array<std::string_view, 5> fps_names{"vsync", "30", "60", "120", "uncapped"}; inline constexpr std::array<std::string_view, 5> fps_names{"vsync", "30", "60", "120", "uncapped"};
/**
* Preferred display backends offered by the menu. Each entry is tried first,
* then the others in turn, so an unavailable backend degrades gracefully.
* Direct3D is Windows-only; on other hosts those entries fall through.
*/
inline constexpr std::array<ra3::display::backend, 4> renderer_backends{ra3::display::backend::vulkan, ra3::display::backend::d3d11,
ra3::display::backend::d3d12, ra3::display::backend::sdl};
inline constexpr std::array<std::string_view, 4> renderer_names{"Vulkan", "Direct3D 11", "Direct3D 12", "SDL (software)"};
[[nodiscard]] inline auto renderer_backend(int index) -> ra3::display::backend {
return renderer_backends[static_cast<std::size_t>(std::clamp(index, 0, static_cast<int>(renderer_backends.size()) - 1))];
}
inline constexpr int menu_width = 1280; inline constexpr int menu_width = 1280;
inline constexpr int menu_height = 720; inline constexpr int menu_height = 720;
@@ -479,7 +606,7 @@ namespace {
return buffer; return buffer;
} }
[[nodiscard]] auto menu_field_count() -> int { return 19; } [[nodiscard]] auto menu_field_count() -> int { return 20; }
[[nodiscard]] auto menu_field_label(int field) -> std::string_view { [[nodiscard]] auto menu_field_label(int field) -> std::string_view {
switch (field) { switch (field) {
@@ -501,6 +628,7 @@ namespace {
case 15: return "Fullscreen"; case 15: return "Fullscreen";
case 16: return "Frame rate"; case 16: return "Frame rate";
case 17: return "Play"; case 17: return "Play";
case 18: return "Renderer";
default: return "Quit"; default: return "Quit";
} }
} }
@@ -533,6 +661,7 @@ namespace {
case 15: return s.fullscreen ? "yes" : "no"; case 15: return s.fullscreen ? "yes" : "no";
case 16: return std::string{fps_names[static_cast<std::size_t>(std::clamp(s.fps_index, 0, 4))]} + " fps"; case 16: return std::string{fps_names[static_cast<std::size_t>(std::clamp(s.fps_index, 0, 4))]} + " fps";
case 17: return "start"; case 17: return "start";
case 18: return std::string{renderer_names[static_cast<std::size_t>(std::clamp(s.renderer, 0, 3))]};
default: return "exit"; default: return "exit";
} }
} }
@@ -555,6 +684,7 @@ namespace {
case 13: s.thumbnail = !s.thumbnail; break; case 13: s.thumbnail = !s.thumbnail; break;
case 15: s.fullscreen = !s.fullscreen; break; case 15: s.fullscreen = !s.fullscreen; break;
case 16: s.fps_index = (s.fps_index + delta + 5) % 5; break; case 16: s.fps_index = (s.fps_index + delta + 5) % 5; break;
case 18: s.renderer = (s.renderer + delta + 4) % 4; break;
default: break; default: break;
} }
} }
@@ -589,7 +719,7 @@ namespace {
L.rows = std::max(1, (L.list_h - L.S(46)) / L.row_h); L.rows = std::max(1, (L.list_h - L.S(46)) / L.row_h);
L.set_x = L.S(650); L.set_x = L.S(650);
L.set_w = static_cast<int>(width) - L.set_x - L.S(24); L.set_w = static_cast<int>(width) - L.set_x - L.S(24);
L.field_row = std::max(1, L.S(27)); L.field_row = std::max(1, L.S(26));
return L; return L;
} }
@@ -724,9 +854,9 @@ namespace {
st.field = field; st.field = field;
if (field == 17) { if (field == 17) {
st.action = menu_action::play; st.action = menu_action::play;
} else if (field == 18) { } else if (field == 19) {
st.action = menu_action::quit; st.action = menu_action::quit;
} else if (field == 0 || field == 13 || field == 15 || field == 16) { } else if (field == 0 || field == 13 || field == 15 || field == 16 || field == 18) {
menu_adjust(field, st.settings, 1); menu_adjust(field, st.settings, 1);
st.dirty = true; st.dirty = true;
} else if (field == 14) { } else if (field == 14) {
@@ -783,9 +913,9 @@ namespace {
case ui_key::confirm: case ui_key::confirm:
if (st.pane == 0 || st.field == 17) { if (st.pane == 0 || st.field == 17) {
st.action = menu_action::play; st.action = menu_action::play;
} else if (st.field == 18) { } else if (st.field == 19) {
st.action = menu_action::quit; st.action = menu_action::quit;
} else if (st.field == 0 || st.field == 13 || st.field == 15 || st.field == 16) { } else if (st.field == 0 || st.field == 13 || st.field == 15 || st.field == 16 || st.field == 18) {
menu_adjust(st.field, st.settings, 1); menu_adjust(st.field, st.settings, 1);
st.dirty = true; st.dirty = true;
} }
@@ -951,6 +1081,10 @@ namespace {
if (has_flag(args, "--thumbnail")) st.settings.thumbnail = true; if (has_flag(args, "--thumbnail")) st.settings.thumbnail = true;
if (has_flag(args, "--fullscreen")) st.settings.fullscreen = true; if (has_flag(args, "--fullscreen")) st.settings.fullscreen = true;
if (has_flag(args, "--3d")) st.settings.mode = 0; if (has_flag(args, "--3d")) st.settings.mode = 0;
if (has_flag(args, "--vulkan")) st.settings.renderer = 0;
if (has_flag(args, "--dx11") || has_flag(args, "--d3d11")) st.settings.renderer = 1;
if (has_flag(args, "--dx12") || has_flag(args, "--d3d12")) st.settings.renderer = 2;
if (has_flag(args, "--sdl")) st.settings.renderer = 3;
if (const auto value = option_value(args, "--width")) st.settings.width = std::stoi(*value); if (const auto value = option_value(args, "--width")) st.settings.width = std::stoi(*value);
if (const auto value = option_value(args, "--height")) st.settings.height = std::stoi(*value); if (const auto value = option_value(args, "--height")) st.settings.height = std::stoi(*value);
if (const auto value = option_value(args, "--cam-pitch")) st.settings.cam_pitch = std::stof(*value); if (const auto value = option_value(args, "--cam-pitch")) st.settings.cam_pitch = std::stof(*value);
@@ -978,7 +1112,9 @@ namespace {
options.height = menu_height; options.height = menu_height;
options.fullscreen = st.settings.fullscreen; options.fullscreen = st.settings.fullscreen;
options.fps_limit = fps_caps[static_cast<std::size_t>(std::clamp(st.settings.fps_index, 0, 4))]; options.fps_limit = fps_caps[static_cast<std::size_t>(std::clamp(st.settings.fps_index, 0, 4))];
auto display = ra3::display::create(options, true); int applied_renderer = st.settings.renderer;
auto display = ra3::display::create(options, renderer_backend(applied_renderer));
if (display) ender::log::info(std::format("menu display backend: {}", display->name()));
if (!display) { if (!display) {
if (!windowed) return console_menu(assets, maps, names, st.settings); if (!windowed) return console_menu(assets, maps, names, st.settings);
return 0; return 0;
@@ -1006,9 +1142,16 @@ namespace {
return 0; return 0;
} }
// Apply fullscreen / frame-rate changes immediately (re-init the window). // Apply renderer / fullscreen / frame-rate changes (recreate the window).
const int want_fps = fps_caps[static_cast<std::size_t>(std::clamp(st.settings.fps_index, 0, 4))]; const int want_fps = fps_caps[static_cast<std::size_t>(std::clamp(st.settings.fps_index, 0, 4))];
if (st.settings.fullscreen != options.fullscreen || want_fps != options.fps_limit) { if (st.settings.renderer != applied_renderer) {
display->shutdown();
applied_renderer = st.settings.renderer;
options.fullscreen = st.settings.fullscreen;
options.fps_limit = want_fps;
display = ra3::display::create(options, renderer_backend(applied_renderer));
if (!display) return console_menu(assets, maps, names, st.settings);
} else if (st.settings.fullscreen != options.fullscreen || want_fps != options.fps_limit) {
display->shutdown(); display->shutdown();
options.fullscreen = st.settings.fullscreen; options.fullscreen = st.settings.fullscreen;
options.fps_limit = want_fps; options.fps_limit = want_fps;
@@ -1071,8 +1214,13 @@ namespace {
} }
auto main(int argc, char **argv) -> int { auto main(int argc, char **argv) -> int {
#if defined(_WIN32)
install_crash_handler();
#endif
const std::vector<std::string> args{argv + 1, argv + argc}; const std::vector<std::string> args{argv + 1, argv + argc};
const auto assets = executable_dir(argc > 0 ? argv[0] : ".") / "assets"; const auto exe_dir = executable_dir(argc > 0 ? argv[0] : ".");
const auto assets = exe_dir / "assets";
setup_logging(exe_dir);
if (args.empty()) return command_menu({}, assets); if (args.empty()) return command_menu({}, assets);
+757 -101
View File
@@ -1,131 +1,787 @@
# Architecture # Architecture & Master Plan
OpenRA3 is organised as a stack of C++26 modules. Each layer may import the ones OpenRA3 is a from-scratch, portable re-implementation of **Command & Conquer:
below it, never the ones above. Every module uses `import std;` for the standard Red Alert 3** (SAGE 2.0) in **pure C++26** — C++ modules, `import std;`, no
library; the engine is built with Clang + libc++ on Linux and cross-compiled scripting language, no managed runtime, no other programming language anywhere
with llvm-mingw for Windows. in the tree. It is built with Clang + libc++ on Linux and cross-compiled to
Windows with llvm-mingw.
This document is two things at once:
1. the **layer architecture** (how the modules sit on top of one another), and
2. the **master plan** — the complete Red Alert 3 feature set decomposed three
levels deep into **Module → Function → Feature**, each tagged with its
implementation status and target milestone.
The decomposition is the contract: every feature RA3 has is either implemented,
being implemented, or explicitly planned here. Nothing is silently dropped.
> **Scope.** Offline only. Single-player campaign, skirmish, Commander's
> Challenge and LAN lockstep — never an online service, matchmaking, EA account
> or GameSpy/Steam integration. The simulation is deterministic and
> self-contained. No game assets or binaries are shipped; the engine reads the
> user's own install at runtime.
---
## 1. Principles
Same five rules as the rest of the tree, restated because the plan is written
against them:
1. **Pure C++.** One language. No Lua, no JS, no C#, no Python at runtime.
Python is allowed *only* in offline build tooling (`tools/`, cmake helper
scripts), never linked into `openra3`.
2. **No raw owning pointers.** Ownership is `std::unique_ptr`; cross-references
are non-owning views (`thing *`, handles, indices).
3. **Portable simulation.** No platform API in the foundation or simulation
layers. All platform concerns live behind `display` / `audio` / `video`.
4. **Determinism.** Anything that can diverge between runs (RNG, iteration
order, float accumulation, hash order) is explicit, seeded and testable.
5. **RE-traceable.** Every structure or constant taken from the retail
`ra3_1.12.game` (image base `0x400000`) or from the GPLv3 SAGE 1.0 reference
cites its origin. No asserted fact without a source.
---
## 2. Layer architecture
Each layer may import the ones below it, never the ones above. The `ra3`
umbrella module re-exports the SDK; applications import `ra3` only.
``` ```
┌───────────────────────────────────────┐ L6 tooling / apps openra3 (CLI) tools/ (offline, Python allowed)
applications │ openra3 (apps/openra3) │ | import ra3
└───────────────────┬───────────────────┘ ---------------------------------------------------------------------------
│ import ra3 v
┌───────────────────▼───────────────────┐ L5 meta ra3.i18n ra3.mod ra3.net (deferred)
umbrella │ ra3 (re-exports everything) │ |
└───────────────────┬───────────────────┘ L4 match services ra3.match ra3.replay ra3.save
┌──────────────┬───────────────┼───────────────┬──────────────┐ |
▼ ▼ ▼ ▼ ▼ L3 presentation ra3.client ── ra3.render ── ra3.audio ── ra3.video
ra3.display ra3.render ra3.skirmish ra3.client ra3.game | \ |
backend pick framebuffer match rules display+loops RA3 sides | \ v
│ │ │ │ │ | ra3.display (backend pick)
┌──┴───┐ │ └───────┬───────┘ │ | / | \
▼ ▼ ▼ ▼ │ v ra3.ui ra3.vulkan ra3.dx
ra3.ui ra3.vulkan ra3.terrain ra3.logic │ | (SDL3) (Vulkan) (D3D11/12)
SDL Vulkan heightmap/blends simulation │ ---------------------------------------------------------------------------
┌──────────────────────┘ v
▼ L2 simulation ra3.logic ra3.modules ra3.combat ra3.movement
ra3.map / ra3.fs ra3.economy ra3.ai ra3.script ra3.powers ra3.shroud
catalog / BIG4 + RefPack |
L1 data & assets ra3.data ── ra3.assets ── ra3.map ── ra3.terrain
| |
v v
ra3.fs (BIG4 / RefPack / install)
|
L0 foundation ra3.core (types, math, containers, RNG, message bus)
``` ```
## Presentation layer The current concrete modules (`ra3.core`, `ra3.logic`, `ra3.data`,
`ra3.skirmish`, `ra3.fs`, `ra3.map`, `ra3.terrain`, `ra3.render`, `ra3.ui.*`,
`ra3.vulkan.*`, `ra3.dx.*`, `ra3.display`, `ra3.game`, `ra3.client`, and the
vendored `ender.log`) are the **seeds** of the
target modules below. `ra3.skirmish` and `ra3.game` will be absorbed into
`ra3.ai` / `ra3.match`; new modules are added as their subsystems are recovered.
`ra3.client::display` is the single seam between the engine and the windowing ### Status legend
stack. A backend implements only the low-level primitives (`init`, `present`,
`poll_event`, `window_size`, `key_down`, `shutdown`) and, optionally,
`present_terrain`; the interactive loops (menu, image viewer, camera viewer,
GPU terrain) and the native-to-`ui_event` mapping live once in the base class,
so the SDL and Vulkan paths cannot drift. SDL3 remains the platform layer (it
owns the window, input and the low-cost blit backend); Vulkan is the accelerated
backend. `ra3.display` selects the first backend that starts, and the app never
references a backend by name.
The Vulkan backend currently hand-rolls its pipelines. As materials, skybox and | Tag | Meaning |
HUD arrive, a thin RHI + render-graph belongs between `ra3.client` and the | --- | --- |
Vulkan/SDL backends; `present_terrain` is the placeholder for that step. | `[x]` | implemented and tested on `main` |
| `[~]` | partially implemented / works for the happy path |
| `[ ]` | planned, not started |
| `(vX.Y)` | target milestone (see the roll-up in §5) |
| `!!` | needs reverse engineering before it can be built |
| Function tag | Meaning |
| --- | --- |
| `D` | **done** — the function's features are largely present |
| `P` | **partial** — some features present |
## Module responsibilities ---
### `ra3.core` ## 3. Master plan — Module → Function → Feature
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` ### M00 `ra3.core` — foundation vocabulary `[D]`
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` Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
The presentation boundary: the abstract `display` (low-level primitives plus the
shared interactive loops and `ui_event` mapping), a `headless_display` for
CPU-only runs, and `game_client`, the facade that owns the simulation and drives
the frame loop.
### `ra3.display` - **F1 Types & math** `[D]`
Backend selection: tries Vulkan, then SDL, then reports failure so the caller can - `[x]` `real` (float), `int32`/`uint32`/`uint16`/`uint8`, `bool` aliases
fall back to an offscreen image. The only module that names a concrete backend. - `[x]` `coord2d` / `coord3d` vectors, dot/cross/length/normalize
- `[x]` `rgb_color` / `argb_color`, packing and lerp
- `[x]` geometry: `segment`, `triangle`, `plane`, ray/segment intersection
- `[ ]` fixed-point helpers for replay-stable accumulation `(v0.4)`
- `[ ]` matrix / quaternion (needed by W3D models) `(v0.6)`
- `[ ]` `KindOf` flag bitset (SAGE object taxonomy) `(v0.4)`
- **F2 Containers** `[D]`
- `[x]` `ascii_string` + `make_name_key` (case-folded hash for data lookup)
- `[x]` intrusive doubly linked list (message stream node layout)
- `[x]` deterministic iteration-order map (insertion-ordered)
- `[ ]` object pool / arena for per-frame allocations `(v0.4)`
- `[ ]` interned string table `(v0.5)`
- **F3 Deterministic RNG** `[D]`
- `[x]` seedable `random` stream (`game_logic` random)
- `[ ]` independent per-player / per-subsystem streams `(v0.4)`
- `[ ]` shuffle / weighted-pick primitives `(v0.4)`
- **F4 Message stream** `[D]`
- `[x]` command bus with retail node layout (`appendMessage` `0x0060c4a0`)
- `[x]` ordered per-frame command drain
- `[ ]` command argument blocks (build/target/waypoint payloads) `(v0.4)`
- `[ ]` network/replay source tagging `(v0.4)`
- **F5 Diagnostics** `[~]`
- `[x]` logging sink + assert macro
- `[ ]` scoped profiling timers, per-subsystem counters `(v0.4)`
- `[ ]` structured crash/report capture `(v0.8)`
- **F6 Serialization primitives** `[~]`
- `[x]` little-endian byte reader/writer
- `[ ]` chunked binary reader/writer with versioning `(v0.5)`
- `[ ]` stable content hashing (replay/desync checks) `(v0.4)`
- **F7 Localization primitives** `[~]` (see M26)
- `[x]` UTF-16 unit access, CSF low-byte `^0xFF` decode
- `[ ]` placeholder substitution, plural/gender rules `(v0.5)`
### `ra3.terrain` ### M01 `ra3.fs` — containers & install `[D]`
The real map terrain: parses `CkMp`'s `HeightMapData` and `BlendTileData`
(tiles, `Blends`/`ThreeWayBlends` and their `BlendDescription`s), loads the
`Terrain.big` tile textures, and rasterises the map (software `render`/`render3d`
and the GPU `gpu_terrain` for `present_terrain`).
### `ra3.game` - **F1 BIG archive** `[D]`
Red Alert 3 data that SAGE keeps in `PlayerTemplate`: the three sides - `[x]` `BIG4` header parse (`fileSize` LE, `fileCount`/offsets BE) + name index
(`faction` flags `Empire=2`, `Allied=4`, `Soviet=8`, `Random=7`, recovered from - `[x]` payload read on demand (index-only resident memory)
the retail skirmish setup) and skirmish defaults. - `[ ]` `BIGF` (RefPack whole-archive) variant `!!` `(v0.5)`
- `[ ]` write support (pack/repack, used by tooling) `(v0.8)`
- **F2 RefPack codec** `[D]`
- `[x]` decode: 2/3/4-byte commands, long-literal, stop opcode
- `[x]` `refpack_output_size` without full decompress
- `[ ]` encode (for tooling round-trips) `(v0.8)`
- **F3 Install locator** `[D]`
- `[x]` `--game-dir` / `$RA3_GAME_DIR` / `C:\Red Alert 3` defaults
- `[ ]` registry / Steam / EA-app discovery `(v0.5)`
- `[ ]` Uprising as an optional content source `(v0.5)`
- **F4 Virtual file system** `[~]`
- `[x]` layered archive mounts (loose files → `*.big`)
- `[ ]` patch/language precedence rules (newest `Lang-*.big` wins) `(v0.5)`
- `[ ]` case-insensitive lookup + path normalisation `(v0.5)`
- **F5 Extraction targets** `[D]`
- `[x]` `openra3 extract` dumps maps/terrain to `assets/`
- `[ ]` full asset dump via `ra3tools` integration as a build target `(v0.5)`
### `ra3.fs` ### M02 `ra3.data` — data schema & balance `[P]`
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` - **F1 Data schema** `[ ]` `!!`
Map discovery and loading: scans `MapsMultiplayer.big` for main map entries, - `[ ]` SAGE INI parser (`#include`, `#define`, inheritance) `(v0.5)`
unwraps the two compression layers (`BIG4` RefPack → `EAR\0` wrapper → RefPack → - `[ ]` XML rule schema
`CkMp`), recovers `Player_N_Start` waypoint coordinates, and decodes the - `[ ]` `.manifest` compiled-blob schema `!!` `(v0.5)`
localized display names from the install's `gamestrings.csf`. Degenerate - **F2 Compiled asset blobs** `[ ]` `!!`
extractions are rejected so the caller can fall back. - `[ ]` `global.bin` deserialisation `(v0.5)`
- `[ ]` `static.*.bin` deserialisation `(v0.5)`
- `[ ]` version/dependency validation `(v0.5)`
- **F3 Balance tables** `[~]`
- `[x]` damage types, `ArmorTemplate` percentages
- `[x]` weapon target masks, weapon definitions
- `[x]` unit/structure build costs, times, prerequisites
- `[x]` ore economy constants
- `[ ]` read these from the install instead of pinned constants `(v0.5)`
- **F4 Object definitions** `[ ]`
- `[ ]` `ThingTemplate`/`ObjectTemplate` inheritance graph `(v0.5)`
- `[ ]` module descriptor lists (per-object update/draw module sets)
- `[ ]` `WeaponTemplate`/`ArmorTemplate`/`LocomotorTemplate` stores
- **F5 Faction & player templates** `[~]`
- `[x]` Allied / Soviet / Empire side flags (`2/4/8`), match templates
- `[ ]` commander/sub-commander definitions `(v0.7)`
- `[ ]` build/upgrade unlock trees per faction `(v0.5)`
- **F6 Rules & settings** `[ ]`
- `[ ]` skirmish options (cash, crates, superweapons, speed, limits)
- `[ ]` bonus crate effect table
- `[ ]` AI personality tables
- `[ ]` map-specific rule overrides (`map.ini`)
- **F7 Validation** `[ ]`
- `[ ]` schema diagnostics with source location
- `[ ]` cross-reference integrity (missing templates/refs)
### `ra3.skirmish` ### M03 `ra3.assets` — runtime asset manager `[ ]`
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.
### `ra3.render` - **F1 Textures** `[~]`
A dependency-free software renderer: an ARGB8888 `image` framebuffer with - `[x]` TGA decode (`ra3.render`); 256×256 terrain cells
blit/line/circle/text primitives (an embedded 8x8 bitmap font), a TGA decoder for - `[ ]` DDS / DXT compressed textures `(v0.5)`
the game's map art, a 24-bit BMP encoder for headless output, and `compose` which - `[ ]` atlas + mip generation, gutter padding (GPU) `[~]`
overlays a world grid and markers (start positions, live units) onto the map art. - `[ ]` async upload / streaming `(v0.6)`
- **F2 Models** `[ ]` `!!`
- `[ ]` W3D container parse (chunks, hierarchy, meshes) `(v0.6)`
- `[ ]` materials, shaders, texture references
- `[ ]` LOD sets, collision meshes
- **F3 Animation** `[ ]` `!!`
- `[ ]` W3D animation chunks, bone poses `(v0.6)`
- `[ ]` blend trees / transition animations
- **F4 Audio** `[ ]` `!!`
- `[ ]` audio container + codec decode `(v0.7)`
- `[ ]` cue/event tables (unit responses, weapon foley)
- **F5 Fonts & glyphs** `[ ]`
- `[ ]` bitmap/vector font load; CJK coverage `(v0.5)`
- **F6 Asset registry** `[ ]`
- `[ ]` cache with ref counting, eviction `(v0.6)`
- `[ ]` name-key lookup into the data schema
- **F7 UI art (`.apt`)** `[ ]` `!!`
- `[ ]` RA3 interface art decode (HUD/command bar) `(v0.7)`
### `ra3.ui` ### M04 `ra3.map` — map catalog & loader `[D]`
The SDL3 backend: `sdl_display` implements the `ra3.client::display` primitives
using an `SDL_Renderer` streaming texture. Cheap and dependency-light, it is the
fallback when Vulkan is unavailable. A null backend returns `false` so the build
still runs without SDL3.
### `ra3.vulkan` - **F1 Catalog** `[D]`
The accelerated backend: `vulkan_display` implements the same primitives and - `[x]` scan `MapsMultiplayer.big` for main map entries
adds `present_terrain`, a GPU heightfield raymarcher (mipmapped tile atlas with a - `[ ]` campaign + challenge map catalog `(v0.7)`
replicated gutter and the retail SAGE blend ramp). A null backend reports failure - **F2 Compiled map (`CkMp`)** `[D]`
when no Vulkan loader is present. - `[x]` double unwrap (`BIG4` RefPack → `EAR\0` → RefPack → `CkMp`)
- `[x]` name table + chunk list (`{index,version,size,data}`)
- `[ ]` full typed chunk dispatch for all chunk kinds `(v0.5)`
- **F3 Start positions** `[~]`
- `[x]` `Player_N_Start` waypoint scan → `coord3d`
- `[ ]` `MPPositionList` layout for maps without waypoints `!!` `(v0.5)`
- **F4 Metadata** `[~]`
- `[x]` display names from `gamestrings.csf` (`MAP:<ID>`)
- `[ ]` player count, size, supported game modes `(v0.5)`
- **F5 Map rules** `[ ]`
- `[ ]` `map.ini` override application `(v0.5)`
- **F6 Preview art** `[~]`
- `[x]` `<map>_art.tga` overview decode (`--thumbnail`)
- **F7 Validation & fallback** `[D]`
- `[x]` reject degenerate extractions, fall back to built-in test map
- `[ ]` integrity/version checks with actionable errors `(v0.5)`
## Design rules ### M05 `ra3.terrain` — terrain `[P]`
- **F1 Heightmap** `[D]`
- `[x]` `HeightMapData` v6 (grid, border, `u16` elevations, scale)
- `[ ]` multi-resolution / LOD height sampling `(v0.6)`
- **F2 Blend tiles** `[D]`
- `[x]` `BlendTileData` v27: tile grid, blends/three-way/cliff tables
- `[x]` `BlendDescription` parse; `secondaryTile` decode
- `[x]` retail `Terrain.fx` blend ramp (`blend_factor`, axis flags)
- **F3 Terrain textures** `[D]`
- `[x]` `art\terrain\*.tga` from `Terrain.big` / `Core11.big`
- `[x]` cell atlas with replicated gutter (GPU bleeding fix)
- `[ ]` continuous 32 px / Morton layout (kill residual grid lines) `(v0.4)`
- **F4 Water** `[ ]` `!!`
- `[ ]` water height/type, sea level `(v0.6)`
- `[ ]` animated waves + shoreline blending `(v0.6)`
- `[ ]` shroud-aware water rendering `(v0.6)`
- **F5 Cliffs & roads** `[ ]` `!!`
- `[x]` `CliffTextures` table parsed
- `[ ]` cliff mesh + `CliffTextureMapping` UV remap `(v0.6)`
- `[ ]` roads and bridges (passability + render) `(v0.6)`
- **F6 Terrain lighting** `[ ]`
- `[ ]` per-vertex normals, cell lighting, global light `(v0.6)`
- **F7 Terrain queries** `[~]`
- `[x]` height lookup (render)
- `[ ]` passability grid (ground/naval/amphibious/air) `(v0.4)`
- `[ ]` buildability grid (flatness, slope, water) `(v0.4)`
### M06 `ra3.logic` — simulation core `[P]`
- **F1 Objects & things** `[~]`
- `[x]` `thing` → `object` with pluggable `update_module`s
- `[x]` global object registry with stable ids
- `[ ]` handle/reference system (survives deletion) `(v0.4)`
- `[ ]` object destruction lifecycle + death dispatch `(v0.4)`
- **F2 Players & teams** `[~]`
- `[x]` `player` / `player_list`
- `[x]` money (`std::vector<Money*>`) and power fields
- `[x]` team assignment and relations
- `[ ]` diplomacy matrix, ally vision sharing `(v0.5)`
- **F3 Partition manager** `[~]`
- `[x]` spatial grid, neighborhood queries
- `[ ]` cell-resolution + large-object multi-cell registration `(v0.4)`
- **F4 Game loop** `[~]`
- `[x]` 30 Hz deterministic step with frame counter
- `[x]` `prepare_new_game` / `start_new_game` two-phase start
- `[ ]` per-tick module scheduling with stable ordering `(v0.4)`
- **F5 Commands** `[~]`
- `[x]` `message_stream` bus
- `[ ]` typed commands (build, attack, move, ability, sell, repair) `(v0.4)`
- `[ ]` command validation + feedback (insufficient funds, etc.) `(v0.4)`
- **F6 Determinism** `[~]`
- `[x]` seeded logic random
- `[ ]` replay-hash of state per tick `(v0.4)`
- `[ ]` float determinism policy / fixed-point where required `(v0.4)`
- **F7 Victory / defeat** `[~]`
- `[x]` team-wipe / base-destruction win condition
- `[ ]` surrender, disconnection, timed, objective victories `(v0.5)`
- `[ ]` score / stats accumulation `(v0.8)`
### M07 `ra3.modules` — object update & draw modules `[P]`
- **F1 Module system** `[~]`
- `[x]` update modules attached to objects with a simple order
- `[ ]` module descriptor data-binding (from `ThingTemplate`) `(v0.5)`
- `[ ]` interface queries (get WeaponModule / ContainModule on demand) `(v0.4)`
- **F2 Locomotor** `[ ]`
- `[ ]` movement state machine (idle/moving/attacking) `(v0.4)`
- **F3 Weapon module** `[~]`
- `[x]` simple weapons on units, auto-target + fire
- `[ ]` multi-weapon slots, turret aiming/rotation `(v0.4)`
- `[ ]` reload/clip, deploy/undeploy states `(v0.6)`
- **F4 Contain** `[ ]`
- `[ ]` transport passenger slots, load/unload `(v0.4)`
- `[ ]` garrison of civilian structures `(v0.6)`
- `[ ]` paradrop / airdrop `(v0.6)`
- **F5 Production** `[~]`
- `[x]` pay-as-you-go build queue on a factory
- `[ ]` per-factory queues, rally points, queue reordering `(v0.4)`
- `[ ]` building placement → production handoff `(v0.4)`
- **F6 Power** `[~]`
- `[x]` power production/consumption fields
- `[ ]` brownout/blackout effects on radar + build speed `(v0.4)`
- **F7 Upgrades** `[ ]`
- `[ ]` upgrade research, unlock dependent modules/weapons `(v0.5)`
- **F8 Experience / veterancy** `[ ]`
- `[ ]` XP from kills, ranks (veteran/elite/heroic), bonuses `(v0.6)`
- `[ ]` chevron rendering `(v0.6)`
- **F9 Special abilities** `[~]`
- `[ ]` secondary ability slots with cooldown, target/area types `(v0.6)`
- `[ ]` toggle/instant/targeted ability kinds `(v0.6)`
- **F10 Stealth / disguise / detection** `[ ]`
- `[ ]` stealth states, detection radius, decloak on fire `(v0.6)`
- `[ ]` disguise (spy-like) and detection interaction `(v0.6)`
- **F11 Structure modules** `[~]`
- `[x]` base structures, destruction
- `[ ]` construction/assembly animation, sell, repair `(v0.4)`
- `[ ]` walls/gates if present, defensive structures `(v0.6)`
- **F12 Resource modules** `[~]`
- `[x]` harvester ↔ refinery ore cycle
- `[ ]` ore field spread/depletion, multiple miners, dock queue `(v0.4)`
- **F13 Shroud modules** `[ ]`
- `[ ]` per-object shroud reveal / clearance `(v0.4)`
- **F14 Draw modules** `[ ]`
- `[ ]` model draw, animation, particles, construction ghost, temp effects `(v0.6)`
### M08 `ra3.combat` — weapons, warheads, damage `[P]`
- **F1 Weapons** `[~]`
- `[x]` damage, range, rate of fire, target masks
- `[ ]` clip/burst, scatter, arc, continuous beam `(v0.4)`
- `[ ]` primary vs secondary weapon selection `(v0.4)`
- **F2 Warheads** `[~]`
- `[x]` damage type + armor multiplier resolution
- `[ ]` radius/falloff, affects mask, death type on kill `(v0.4)`
- **F3 Armor** `[~]`
- `[x]` `ArmorTemplate` percentage table
- `[ ]` armor upgrades and per-state armor `(v0.6)`
- **F4 Damage application** `[~]`
- `[x]` damage resolution against armor
- `[ ]` conditional modifiers (from above, in air, moving) `(v0.4)`
- `[ ]` friendly-fire policy, self-damage `(v0.4)`
- **F5 Projectiles** `[ ]`
- `[ ]` ballistic / laser / missile / beam / homing / arcing `(v0.4)`
- `[ ]` projectile draw + impact VFX hook `(v0.6)`
- **F6 Targeting** `[~]`
- `[x]` simple nearest/in-range acquisition
- `[ ]` priority scans (attack-move, guard, force-attack) `(v0.4)`
- `[ ]` re-targeting, leash, target ground `(v0.4)`
- **F7 Special effects** `[ ]`
- `[ ]` EMP/stun, flame/radiation DoT, mind-control, shrink/grow `(v0.7)`
- **F8 Death & corpses** `[ ]`
- `[ ]` death types, wrecks, gibs, salvage, rebuild `(v0.6)`
### M09 `ra3.movement` — locomotion & pathfinding `[ ]`
- **F1 Locomotors** `[ ]`
- `[ ]` ground / air / naval / amphibious / hover / teleport `!!` `(v0.4)`
- `[ ]` turn rates, acceleration, braking, banking `(v0.6)`
- **F2 Pathfinding** `[ ]`
- `[ ]` grid A* over the passability grid `(v0.4)`
- `[ ]` hierarchical / jump-point refinement `(v0.6)`
- `[ ]` dynamic obstacle integration (buildings, units) `(v0.6)`
- **F3 Steering & flocking** `[ ]`
- `[ ]` separation, avoidance, group cohesion `(v0.6)`
- `[ ]` formation slots (line/wedge/box) `(v0.6)`
- **F4 Orders & waypoints** `[~]`
- `[x]` straight-line move toward a target (skirmish)
- `[ ]` waypoint queues, queued orders with shift `(v0.4)`
- `[ ]` guard / patrol / attack-move / stop / scatter `(v0.4)`
- **F5 Collision & crush** `[ ]`
- `[ ]` unit-unit collision, pushing, crush damage `(v0.6)`
- **F6 Naval & amphibious** `[ ]`
- `[ ]` water-only movement, amphibious land↔water transition `!!` `(v0.6)`
- **F7 Transport & airdrop** `[ ]`
- `[ ]` boarding/unloading, airdrop descent `(v0.6)`
### M10 `ra3.economy` — resources, power, construction `[P]`
- **F1 Ore / resource** `[~]`
- `[x]` ore fields and harvester↔refinery cycle
- `[ ]` ore spread/regrowth, depletion, ore density `(v0.4)`
- **F2 Power** `[~]`
- `[x]` power balance fields
- `[ ]` brownout/blackout consequences `(v0.4)`
- **F3 Construction** `[~]`
- `[x]` pay-as-you-go queue, tech prerequisites, build times
- `[ ]` build-radius rules (structures must be in base vicinity) `(v0.4)`
- `[ ]` low-power build-speed penalty `(v0.4)`
- **F4 Placement** `[ ]`
- `[ ]` placement grid, footprint validation, green/red ghost `(v0.4)`
- `[ ]` adjacency bonuses / prerequisite-adjacent structures `(v0.6)`
- **F5 Repair & sell** `[ ]`
- `[ ]` structure repair over time, cost, sell refund `(v0.4)`
- `[ ]` unit repair pads if present `(v0.6)`
- **F6 Rally points** `[ ]`
- `[ ]` factory rally, rally preview, waypoint rally `(v0.6)`
- **F7 Income modifiers** `[ ]`
- `[ ]` bonus crates (cash/units/repair/heal, `random_bonus_crates`) `(v0.5)`
### M11 `ra3.ai` — computer opponents `[P]`
- **F1 Skirmish AI** `[~]`
- `[x]` simple build AI (base, ore, a couple of unit types)
- `[ ]` data-driven build orders per faction `(v0.5)`
- `[ ]` economy management (expand, defend harvesters) `(v0.5)`
- **F2 Attack management** `[~]`
- `[x]` send units at the enemy base
- `[ ]` attack force assembly, waves, retreat/regroup `(v0.5)`
- `[ ]` targeting priorities (harvesters, key structures) `(v0.6)`
- **F3 Team AI / diplomacy** `[ ]`
- `[ ]` allied coordination, shared attacks, base defense `(v0.6)`
- **F4 Difficulty & personalities** `[ ]`
- `[ ]` easy/normal/hard modifiers, AI cheating options `(v0.5)`
- `[ ]` commander personalities (aggressive/turtle/air/naval) `(v0.7)`
- **F5 Scouting** `[ ]`
- `[ ]` exploration, map awareness, threat response `(v0.6)`
- **F6 Superweapon usage** `[ ]`
- `[ ]` AI powers/power targeting `(v0.7)`
- **F7 Script hooks** `[ ]`
- `[ ]` AI cooperation with script triggers (campaign) `(v0.7)`
### M12 `ra3.script` — triggers & missions `[ ]`
- **F1 Trigger system** `[ ]` `!!`
- `[ ]` conditions (elapsed, object in region, destroyed, flag) `(v0.7)`
- `[ ]` actions (spawn, order, reveal, camera, dialog, win/lose) `(v0.7)`
- **F2 Script engine** `[ ]` `!!`
- `[ ]` SAGE script language / mission script parse `(v0.7)`
- `[ ]` timers, counters, flags, per-player state `(v0.7)`
- **F3 Campaign missions** `[ ]`
- `[ ]` mission objectives, sequential phases, briefing `(v0.8)`
- `[ ]` three faction campaigns (Allied/Soviet/Empire) `(v0.8)`
- **F4 Reinforcements & spawns** `[ ]`
- `[ ]` scripted spawns, cinematic units, capture `(v0.8)`
- **F5 Tutorials** `[ ]`
- `[ ]` tutorial message gates, camera lock/unlock actions `(v0.8)`
- **F6 Co-op** `[ ]`
- `[ ]` co-op commander missions `(v0.9)`
### M13 `ra3.powers` — superweapons & support powers `[ ]`
- **F1 Superweapons** `[ ]` `!!`
- `[ ]` Allied Chronosphere `(v0.7)`
- `[ ]` Soviet Vacuum Imploder `(v0.7)`
- `[ ]` Empire Psionic Decimator `(v0.7)`
- `[ ]` charge timer, targeting, ready state, HUD `(v0.7)`
- **F2 Support powers** `[ ]`
- `[ ]` per-faction powers (spy satellite, air support, etc.) `(v0.7)`
- `[ ]` cooldowns, targeting types, cost `(v0.7)`
- **F3 Commander's Challenge powers** `[ ]`
- `[ ]` challenge-mode power loadouts `(v0.8)`
### M14 `ra3.shroud` — fog of war & radar `[ ]`
- **F1 Shroud** `[ ]`
- `[ ]` per-player unexplored grid `(v0.4)`
- **F2 Fog of war** `[ ]`
- `[ ]` explored-but-unseen dimming `(v0.4)`
- **F3 Reveal sources** `[ ]`
- `[ ]` units/structures reveal radius, abilities, spy satellite `(v0.6)`
- **F4 Radar / minimap** `[ ]`
- `[ ]` radar texture, unit blips, radar-offline on low power `(v0.6)`
- **F5 Shroud ↔ logic** `[ ]`
- `[ ]` targetability gating, option for AI to ignore shroud `(v0.4)`
### M15 `ra3.client` — game client & shell `[P]`
- **F1 Game client** `[~]`
- `[x]` `game_client` facade owning the sim, driving the frame loop
- `[ ]` sim/present decoupling, interpolation, catch-up `(v0.6)`
- **F2 Tactical view / camera** `[~]`
- `[x]` controls: wheel zoom, edge scroll, clamped pan, open on player start
- `[x]` camera tuning table (`cameraMinHeight` …) + lock actions
- `[ ]` retail perspective camera, pitch/yaw, FOV (needs 3D terrain) `(v0.6)`
- **F3 Selection** `[ ]`
- `[ ]` click select, drag-box, double-click type select `(v0.6)`
- `[ ]` control groups (Ctrl+N), type filters, select-all-of-type `(v0.6)`
- **F4 Orders** `[ ]`
- `[ ]` contextual right-click orders, force-attack, force-move `(v0.6)`
- `[ ]` order queue with shift, formation move `(v0.6)`
- **F5 Command bar** `[ ]` `!!`
- `[ ]` build/production palettes, ability buttons, portraits `(v0.7)`
- `[ ]` tooltips, cost/time, cooldown sweep, disabled states `(v0.7)`
- **F6 HUD** `[ ]`
- `[x]` minimal match-state overlay (units, start markers)
- `[ ]` resource/power readouts, objectives, notifications `(v0.7)`
- `[ ]` EVA voice announcements hook `(v0.7)`
- **F7 Shell menus** `[~]`
- `[x]` map list by localized name + full render/skirmish options; console fallback
- `[ ]` main menu, skirmish setup, faction/team/color pickers `(v0.7)`
- `[ ]` options (video/audio/keybinds), pause, load/save, credits `(v0.8)`
- **F8 Feedback & cursors** `[ ]`
- `[ ]` action cursor, placement ghost, move/attack markers `(v0.6)`
### M16 `ra3.render` — renderer & RHI `[P]`
- **F1 RHI** `[ ]`
- `[ ]` device/queue/swapchain abstraction over Vulkan `(v0.6)`
- `[ ]` buffers, textures, samplers, descriptor sets, pipelines `(v0.6)`
- `[ ]` `present_terrain` is the seam where this lands today `[~]`
- **F2 Render graph** `[ ]`
- `[ ]` pass scheduling, barriers, transient/aliased resources `(v0.6)`
- **F3 Terrain render** `[~]`
- `[x]` top-down software + GPU heightfield with blend ramp + gutter atlas
- `[ ]` perspective terrain mesh, LOD, cliff, water `(v0.6)`
- **F4 Model render** `[ ]` `!!`
- `[ ]` W3D draw, skinning, materials, team colors `(v0.6)`
- `[ ]` shadows, decals, ground marks `(v0.6)`
- **F5 VFX** `[ ]`
- `[ ]` particle systems, beams, muzzle flashes, explosions `(v0.6)`
- `[ ]` shader effect graph (retail `.fxo` parity where feasible) `!!` `(v0.7)`
- **F6 Sky & atmosphere** `[ ]`
- `[ ]` skybox, fog, weather, time-of-day `(v0.7)`
- **F7 HUD render** `[ ]`
- `[ ]` 2D art layer, fonts, minimap/radar texture `(v0.7)`
- **F8 Software renderer** `[D]`
- `[x]` ARGB framebuffer, blit/line/circle/text, TGA decode, BMP encode
- `[x]` map compositing, grid, markers; headless output
- **F9 Post-processing** `[ ]`
- `[ ]` bloom, color grading, AA, resolution scaling `(v0.7)`
### M17 `ra3.ui` — platform layer & backends `[D]`
- **F1 Display abstraction** `[D]`
- `[x]` shared primitives + interactive loops + `ui_event` mapping in the base
- `[x]` backends implement primitives only (SDL/Vulkan cannot drift)
- **F2 SDL3 backend** `[D]`
- `[x]` window, streaming-texture blit, input polling
- `[ ]` gamepad support `(v0.8)`
- **F3 Backend selection (`ra3.display`)** `[D]`
- `[x]` preferred backend + ordered fallback (Vulkan / D3D11 / D3D12 / SDL)
- `[x]` in-game Renderer option; report failure for offscreen fallback
- **F4 Input mapping** `[~]`
- `[x]` keyboard/mouse state, modifier masks
- `[ ]` rebindable keybinds, mouse capture, scroll wheel events `(v0.6)`
- **F5 Window modes** `[ ]`
- `[ ]` windowed/fullscreen/borderless, resize, multi-monitor `(v0.6)`
- **F6 Null/headless backend** `[D]`
- `[x]` returns false so the tree builds and runs without SDL3/Vulkan
### M18 `ra3.vulkan` — Vulkan backend `[P]`
- **F1 Device & swapchain** `[~]`
- `[x]` embedded SPIR-V, SDL3 surface, present path
- `[ ]` formal RHI integration (see M16 F1) `(v0.6)`
- **F2 Terrain presentation** `[D]`
- `[x]` GPU heightfield raymarch, mipmapped atlas, gutter, retail blend ramp
- **F3 Materials & pipelines** `[ ]`
- `[ ]` model/particle/HUD pipelines `(v0.6)`
- **F4 Null fallback** `[D]`
- `[x]` report failure when no Vulkan loader is present
### M18b `ra3.dx` — Direct3D 11 / 12 backend `[P]`
- **F1 Device & swapchain** `[D]`
- `[x]` SDL3 window → HWND, DXGI flip-model swapchain, resize
- `[x]` runtime HLSL via `d3dcompiler_47` (no build-time shader compiler)
- **F2 2D image path** `[D]`
- `[x]` BGRA scene texture + fullscreen-triangle blit (D3D11/D3D12)
- **F3 Terrain presentation** `[D]`
- `[x]` GPU heightfield raymarch (HLSL port of `terrain.frag`), D3D11 and D3D12
- **F4 Root signatures / PSOs (D3D12)** `[~]`
- `[x]` root constants, descriptor tables, static samplers, barriers
- `[ ]` shared RHI with Vulkan (see M16 F1) `(v0.6)`
- **F5 Null fallback** `[D]`
- `[x]` non-Windows builds link a stub that fails `init`
### M19 `ra3.audio` — audio `[ ]`
- **F1 SFX** `[ ]`
- `[ ]` 3D positional sound from cues/events `(v0.7)`
- **F2 Music** `[ ]`
- `[ ]` streaming music, playlists, combat stingers `(v0.7)`
- **F3 Voice & EVA** `[ ]`
- `[ ]` unit response lines, announcer (EVA) events `(v0.7)`
- **F4 Mixer** `[ ]`
- `[ ]` buses (master/sfx/music/voice), volume, ducking, reverb `(v0.7)`
- **F5 Codecs** `[ ]` `!!`
- `[ ]` decode RA3 audio formats `(v0.7)`
### M20 `ra3.video` — movies & cutscenes `[ ]`
- **F1 Movie playback** `[ ]` `!!`
- `[ ]` intro/briefing/ending video decode + playback `(v0.8)`
- `[ ]` skip, subtitles, aspect handling `(v0.8)`
- **F2 In-engine cutscenes** `[ ]`
- `[ ]` scripted camera + unit animation sequences `(v0.8)`
### M21 `ra3.match` — game setup & match rules `[P]`
- **F1 Game setup** `[~]`
- `[x]` map + two players + seed defaults
- `[ ]` faction/color/team/start-slot selection, AI personalities `(v0.7)`
- **F2 Rules & options** `[ ]`
- `[ ]` starting cash, crates on/off, superweapons on/off, speed, limits `(v0.7)`
- **F3 Match flow** `[~]`
- `[x]` `prepare_new_game` / `start_new_game` split mirroring retail
- `[ ]` loading progress, in-game start countdown `(v0.7)`
- **F4 Factions** `[~]`
- `[x]` Allied / Soviet / Empire player templates
- `[ ]` full faction tech trees and rosters `(v0.5)`
- **F5 Victory & scoring** `[~]`
- `[x]` team-wipe victory
- `[ ]` full victory conditions, post-match score screen `(v0.8)`
### M22 `ra3.replay` — recording & playback `[ ]`
- **F1 Recorder** `[ ]`
- `[ ]` capture command stream + seed + map id per match `(v0.4)`
- **F2 Replay format** `[ ]`
- `[ ]` self-describing header, command log, checksums `(v0.4)`
- `[ ]` compatibility with retail `.RA3Replay` playback `!!` `(v0.9)`
- **F3 Playback** `[ ]`
- `[ ]` deterministic re-simulation, speed control, seek `(v0.4)`
- **F4 Golden replays** `[ ]`
- `[ ]` curated replay corpus as a regression test `(v0.4)`
- **F5 Observer / spectator** `[ ]`
- `[ ]` watch live or recorded matches, fog option `(v0.9)`
### M23 `ra3.save` — save/load & profiles `[ ]`
- **F1 Save / load** `[ ]`
- `[ ]` full simulation state serialization (objects, modules, queues) `(v0.8)`
- `[ ]` versioned saves with migration `(v0.8)`
- **F2 Profiles** `[ ]`
- `[ ]` player profile, stats, progress/unlocks `(v0.8)`
- **F3 Options persistence** `[ ]`
- `[ ]` settings, keybinds, last-used skirmish config `(v0.7)`
- **F4 Checkpoints** `[ ]`
- `[ ]` campaign checkpoint save/restore `(v0.9)`
### M24 `ra3.mod` — data packages `[ ]`
- **F1 Data packages** `[ ]`
- `[ ]` load order, override precedence, loose-file mounting `(v0.8)`
- **F2 Content discovery** `[ ]`
- `[ ]` scan user mod dirs and additional `.big` archives `(v0.8)`
- **F3 Mod validation** `[ ]`
- `[ ]` schema + reference checks, actionable errors `(v0.8)`
### M25 `ra3.net` — LAN lockstep `[ ]` *(deferred, offline-only)*
- **F1 Lockstep** `[ ]`
- `[ ]` deterministic lockstep over LAN, command-exchange only `(v1.0)`
- **F2 Lobby & sync** `[ ]`
- `[ ]` lobby, slot/team assignment, start sync `(v1.0)`
- **F3 Desync detection** `[ ]`
- `[ ]` state-hash comparison, desync report `(v1.0)`
> No online service, matchmaking, EA account or third-party network
> integration — ever. LAN only.
### M26 `ra3.i18n` — localization `[P]`
- **F1 CSF strings** `[~]`
- `[x]` parse `gamestrings.csf` (UTF-16 units, low byte `^0xFF`)
- `[x]` map display-name lookup (`MAP:<ID>`)
- `[ ]` full string-table load for all UI text `(v0.5)`
- **F2 Language selection** `[~]`
- `[x]` newest `Lang-English*.big` wins
- `[ ]` all supported languages, fallback chain `(v0.5)`
- **F3 Fonts & shaping** `[ ]`
- `[ ]` glyph coverage, CJK/RTL shaping where applicable `(v0.6)`
- **F4 Substitution** `[ ]`
- `[ ]` placeholders, numbers, plurals `(v0.5)`
### M27 `apps` — applications `[D]`
- **F1 CLI** `[D]`
- `[x]` `menu` / `menu-preview` / `maps` / `skirmish` / `render` / `extract`
- `[ ]` `replay`, `benchmark`, `validate-data` subcommands `(v0.5)`
- **F2 Extraction pipeline** `[~]`
- `[x]` `extract` → `assets/` (maps, terrain)
- `[ ]` full asset dump (models/textures/audio/movies) as a build target `(v0.5)`
- **F3 Diagnostics** `[ ]`
- `[ ]` headless render/benchmark modes for CI `(v0.4)`
### M28 `tools` — offline tooling `[~]`
*(Python is permitted here; it is never linked into `openra3`.)*
- **F1 Reference fetch** `[D]`
- `[x]` sparse-clone SAGE 1.0 reference into git-ignored `reference/`
- **F2 Ghidra workflow** `[D]`
- `[x]` MCP-driven recovery loop + recovered symbol map (see RE doc)
- `[ ]` automated structure/table extractors `(v0.5)`
- **F3 Format inspectors** `[ ]`
- `[ ]` BIG/RefPack/CkMp/W3D/APT dumpers `(v0.5)`
- **F4 CI & packaging** `[D]`
- `[x]` GitLab CI builds both targets → test → package
---
## 4. Cross-cutting invariants
These hold across **every** module above and are enforced in review:
1. **Language** — runtime code is C++ only. Build tooling may use Python.
2. **Ownership** — no raw owning pointers; `unique_ptr` for ownership, handles or
`thing *` views for references.
3. **Layering** — a module imports only lower layers. `ra3.core` imports no
engine module and no platform API.
4. **Determinism** — RNG, iteration, hashing and float use are explicit and
seedable; the logic step must be bit-reproducible for a given input stream.
5. **RE-traceable** — retail structure/constant changes cite an address in
[`REVERSE_ENGINEERING.md`](REVERSE_ENGINEERING.md).
6. **Offline** — no online service; LAN lockstep (M25) is the only networking,
and only command exchange.
7. **No bundled assets** — game data is read from the user's install; nothing
from it is committed. The tree builds and runs in CI on a built-in test map.
---
## 5. Milestone mapping
The plan rolls up to these releases:
| Milestone | Modules advanced | Delivers |
| --- | --- | --- |
| `v0.3.x` (done) | M00, M01, M04, M05(F1–F3), M06, M07(partial), M08(partial), M10(partial), M16(F3,F8), M17, M18 | minimal deterministic skirmish, real terrain, Vulkan present, menu |
| `v0.4.0` | M05(F4–F7), M06, M07, M08, M09(F1,F2,F4), M14, M22(F1–F4) | real update modules: locomotor, projectiles/warheads, placement, shroud, pathfinding, replay |
| `v0.5.0` | M02, M03, M04(F3,F4), M08, M10(F3,F4), M11(F1,F2), M26 | data-driven content: deserialise `.bin`/`.manifest`, real rosters, maps, strings |
| `v0.6.0` | M03(F1–F3), M07(F4,F6,F8,F14), M09(F2–F7), M16(F1,F2,F4,F5), M18(F3), M15(F1–F4,F8) | full renderer: perspective terrain, W3D models, in-game client + input |
| `v0.7.0` | M13, M15(F5–F7), M19, M11(F4–F6), M20, M21 | HUD/command bar, audio, superweapons, Commander's Challenge |
| `v0.8.0` | M12, M20(F1), M23, M24, M21(F5) | campaigns, cutscenes, save/load, mods |
| `v0.9.0` | M22(F2,F5), M12(F6), M25 | retail replay playback, co-op, LAN lockstep |
| `v1.0.0` | all | feature-complete offline RA3 |
---
## 6. Design rules
The five rules the codebase is held to (restated from §1, with the concrete
consequences that trip people up):
1. **No raw owning pointers.** Ownership is `std::unique_ptr`; the partition 1. **No raw owning pointers.** Ownership is `std::unique_ptr`; the partition
manager holds non-owning `thing *` views only. manager and object registry hold non-owning `thing *` views only.
2. **Portable simulation.** No platform APIs in `ra3.core` / `ra3.logic`. All 2. **Portable simulation.** No platform APIs in `ra3.core` / simulation
platform concerns live behind `display`. modules. All platform concerns live behind `display`.
3. **Determinism.** Anything that can diverge between runs (random, iteration 3. **Determinism.** Anything that can diverge between runs (random, iteration
order) is explicit and seedable. order) is explicit and seedable.
4. **RE-traceable.** Where a structure or constant comes from the retail 4. **RE-traceable.** Where a structure or constant comes from the retail
binary, the address is cited in the comment. binary, the address is cited in the comment.
5. **No bundled assets, no online.** Game data is read from the user's install 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. and never committed; there is no networking or online service.
---
## 7. Where to start
- New to the codebase: read this plan top-to-bottom (§3 is the feature set,
§5 the near-term order of work).
- Picking up a feature: find its **Function** above, take the lowest-numbered
unmet `[ ]` **Feature**, and cite its supporting retail address.
- Reverse engineering a subsystem: follow the loop in
[`REVERSE_ENGINEERING.md`](REVERSE_ENGINEERING.md); the `!!` tags above mark
the functions that still need a recovery pass before they can be built.
-60
View File
@@ -1,60 +0,0 @@
# 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.
- [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.
- [x] **v0.3.2 — real terrain.** `ra3.terrain` parses the map's `HeightMapData`
and `BlendTileData` and rasterises the actual terrain from the install's
`Terrain.big` tile textures (top-down, elevation-shaded), replacing the
`<map>_art.tga` overview as what `render` draws.
- [x] **v0.3.0 — real balance + skirmish + Vulkan.** `ra3.data` pins the retail
numbers (damage types, `ArmorTemplate` percentages, weapons, units,
structures, ore economy) audited from EA's open RA3 XML; `ra3.skirmish` is
rebuilt on them (armour resolution, weapon target masks, pay-as-you-go
build queue, power, tech prerequisites, refinery ore cycle, team-wipe
victory); and `ra3.vulkan` replaces the presentation stack with Vulkan
(SDL3 surface, embedded SPIR-V), with a null fallback where no loader
exists.
## Next
- [ ] **v0.3.1 — tactical map view.** The Vulkan/SDL viewer reproduces the
retail tactical view (`TheTacticalView`, `ra3_1.12.game` `0x00cdb7b4`):
opens centred on the player's start, wheel zoom, edge scroll and clamped
pan. A hand-rolled HUD was tried and removed — the real in-game interface
is `.apt` art that must be recovered first, so it is not on the near
roadmap.
- [ ] **v0.4.0 — real update modules.** Locomotor movement, projectiles and
warheads, build placement, shroud, and pathfinding, driven by the
`GameObject` module set recovered from the binary.
- [ ] **v0.5.0 — data-driven content.** Deserialise the compiled assets
(`global.bin`/`static.*.bin` + `.manifest`) so `ra3.data` reads the
install instead of pinned constants; real maps, models and strings.
- [ ] **v0.6.0 — full renderer.** Perspective terrain (the heightmap is decoded
but drawn top-down today), W3D models and the full in-game client on
Vulkan, plus input.
## 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.
+34
View File
@@ -0,0 +1,34 @@
// 2D image blit for the Direct3D backends (the D3D port of scene.vert/scene.frag).
//
// The scene image is drawn as a single fullscreen triangle sampling the
// software-rendered `ra3::render::image`. The constant buffer carries the
// destination rectangle in window-normalized coordinates (y down), so the map
// is letterboxed rather than stretched. D3D clip space has +Y up, so the vertex
// position flips Y relative to the Vulkan shader (which relies on Vulkan's
// +Y-down clip space); the sampled UVs and the image's top-left origin are
// unchanged.
cbuffer RectCB : register(b0) {
float4 rect; // xy = top-left (0..1), zw = size (0..1)
};
Texture2D scene_tex : register(t0);
SamplerState scene_smp : register(s0);
struct VSOut {
float4 pos : SV_Position;
float2 uv : TEXCOORD0;
};
VSOut VSMain(uint vertex_id : SV_VertexID) {
float2 p = float2((vertex_id << 1) & 2, vertex_id & 2);
VSOut o;
o.uv = (p - rect.xy) / rect.zw;
o.pos = float4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
return o;
}
float4 PSMain(VSOut input) : SV_Target {
if (input.uv.x < 0.0 || input.uv.x > 1.0 || input.uv.y < 0.0 || input.uv.y > 1.0) discard;
return scene_tex.Sample(scene_smp, input.uv);
}
+236
View File
@@ -0,0 +1,236 @@
// GPU heightfield raymarcher for the Direct3D backends (the D3D port of
// terrain.vert/terrain.frag).
//
// Textures: heightmap (R16), a per-cell blend record (R16G16B16A16_UNORM: base
// layer, blend layer, three-way layer, packed direction/flags; unpacked with
// *65535) and a texture array of the tile materials (RGBA8, REPEAT). The
// material is sampled continuously (`uv = cell / span`), as the retail
// `Terrain.fx` / OpenSAGE `Terrain.frag` do, so it never restarts at a cell
// edge; material boundaries cross-fade with the SAGE blend ramp.
//
// The Vulkan push constants (20 floats) become a constant buffer.
cbuffer TerrainCB : register(b0) {
float4 cam; // x=target_x, y=target_y, z=yaw, w=height
float4 params; // x=pitch, y=fov, z=water_z, w=has_water
float4 sun; // xyz=sun dir, w=ambient
float4 mapinfo; // x=W, y=H, z=unused, w=z_scale
float4 misc; // x=time, y=unused, z=cells per texture repeat, w=aspect
};
Texture2D<float> heightmap : register(t0);
Texture2D<float4> celldata : register(t1);
Texture2DArray<float4> atlas : register(t2);
SamplerState height_smp : register(s0);
SamplerState cell_smp : register(s1);
SamplerState atlas_smp : register(s2);
static const float CELL = 10.0; // must match ra3::terrain::cell_size
struct VSOut {
float4 pos : SV_Position;
float2 uv : TEXCOORD0;
};
VSOut VSMain(uint vertex_id : SV_VertexID) {
float2 p = float2((vertex_id << 1) & 2, vertex_id & 2);
VSOut o;
o.uv = p;
o.pos = float4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
return o;
}
float height_at(int2 c) {
c = clamp(c, int2(0, 0), int2((int) mapinfo.x - 1, (int) mapinfo.y - 1));
return heightmap.Load(int3(c, 0)) * 65535.0 * mapinfo.w;
}
float world_height(float wx, float wy) {
float world_w = mapinfo.x * CELL;
float world_h = mapinfo.y * CELL;
if (wx < 0.0 || wy < 0.0 || wx >= world_w || wy >= world_h) return -1.0e9;
int2 c = int2((int) (wx / CELL), (int) ((world_h - wy) / CELL));
return height_at(c);
}
float3 sky_color(float3 dir) {
float3 d = normalize(dir);
float3 sun_dir = normalize(sun.xyz);
float t = clamp(d.z, 0.0, 1.0);
float3 horizon = float3(0.70, 0.78, 0.85);
float3 zenith = float3(0.28, 0.48, 0.80);
float3 col = lerp(horizon, zenith, pow(t, 0.6));
float s = max(dot(d, sun_dir), 0.0);
col += float3(1.0, 0.95, 0.82) * pow(s, 300.0) * 1.6; // sun disk
col += float3(1.0, 0.90, 0.72) * pow(s, 8.0) * 0.18; // glow
return col;
}
// The retail SAGE blend ramp: 0 at one edge of the cell, 1 at the opposite.
// Direction: 1 right, 2 top, 4 top-right, 8 top-left; flag bit 0 flips,
// bit 1 marks a two-sided diagonal.
float blend_factor(uint direction, uint flags, float2 f) {
bool flipped = (flags & 1u) != 0u;
bool two_sided = (flags & 2u) != 0u;
if (flipped) {
if (direction == 1u) {
f.x = 1.0 - f.x;
} else if (direction == 2u || direction == 4u || direction == 8u) {
f.y = 1.0 - f.y;
}
}
if (direction == 1u) return f.x;
if (direction == 2u) return f.y;
if (direction == 4u) {
float s = (1.0 - f.x) + (1.0 - f.y);
return two_sided ? 1.0 - clamp(s - 1.0, 0.0, 1.0) : clamp(1.0 - s, 0.0, 1.0);
}
if (direction == 8u) {
float s = f.x + (1.0 - f.y);
return two_sided ? 1.0 - clamp(s - 1.0, 0.0, 1.0) : clamp(1.0 - s, 0.0, 1.0);
}
return 0.0;
}
// Sample one tile material layer at global cell coordinates. The texture repeats
// every `span` cells with REPEAT addressing, so it never restarts at a cell edge.
float3 sample_layer(uint layer, float wx, float wy) {
float span = max(misc.z, 1.0);
uint lw = 0;
uint lh = 0;
uint layer_count = 0;
atlas.GetDimensions(lw, lh, layer_count);
float l = (float) min(layer, layer_count > 0u ? layer_count - 1u : 0u);
return atlas.Sample(atlas_smp, float3(float2(wx, wy) / span, l)).rgb;
}
float4 PSMain(VSOut input) : SV_Target {
float4 p = cam;
float pitch = clamp(params.x, 0.15, 1.45);
float fov = clamp(params.y, 0.3, 1.4);
float world_w = mapinfo.x * CELL;
float world_h = mapinfo.y * CELL;
float cp = cos(pitch);
float3 fwd = float3(cp * sin(p.z), cp * cos(p.z), -sin(pitch));
float3 right = normalize(cross(fwd, float3(0, 0, 1)));
float3 up = cross(right, fwd);
float target_z = world_height(p.x, p.y);
if (target_z < -1.0e8) target_z = 0.0;
float dist = p.w / sin(pitch);
float3 cam_pos = float3(p.x, p.y, target_z + p.w) - fwd * dist;
float2 ndc = float2(input.uv.x * 2.0 - 1.0, 1.0 - input.uv.y * 2.0);
float aspect = misc.w;
float th = tan(fov * 0.5);
float3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th);
if (dir.z >= -1e-4) {
return float4(sky_color(dir), 1.0);
}
// March the heightfield (bounded work: the step grows toward the horizon).
float t = CELL * 0.5;
float dt = CELL * 0.5;
float prev = t;
bool hit = false;
float hit_t = 0.0;
for (int i = 0; i < 512 && t < 60000.0; ++i) {
float3 w = cam_pos + dir * t;
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
prev = t;
dt *= 1.06;
t += dt;
continue;
}
if (params.w > 0.5 && w.z <= params.z) {
hit = true;
hit_t = t;
break;
}
if (w.z <= world_height(w.x, w.y)) {
hit = true;
hit_t = t;
break;
}
prev = t;
dt *= 1.06;
t += dt;
}
if (!hit) {
return float4(sky_color(dir), 1.0);
}
float lo = prev;
float hi = hit_t;
for (int i = 0; i < 6; ++i) {
float mid = 0.5 * (lo + hi);
float3 w = cam_pos + dir * mid;
bool water = params.w > 0.5 && w.z <= params.z;
if (water || w.z <= world_height(w.x, w.y)) {
hi = mid;
} else {
lo = mid;
}
}
float3 hitpos = cam_pos + dir * hi;
float3 sun_dir = normalize(sun.xyz);
float ambient = sun.w;
if (params.w > 0.5 && hitpos.z <= params.z + 0.01) {
// Water: animated normal from a procedural wave, sky reflection + fresnel.
float time = misc.x;
float2 q = hitpos.xy * 0.015;
float nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3);
float ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9);
float3 n = normalize(float3(nx * 0.06, ny * 0.06, 1.0));
float fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0);
float3 deep = float3(0.03, 0.16, 0.28);
float3 refl = sky_color(reflect(dir, n));
float lam = max(0.0, dot(n, sun_dir));
float3 water = lerp(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9));
water += float3(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6; // sun glint
float wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
water = lerp(water, sky_color(float3(dir.x, dir.y, 0.0)), wfog);
return float4(water, 1.0);
}
// Terrain: read the per-cell blend record, sample the base/blend/three-way
// material layers continuously and ramp between them across the cell.
float wx = hitpos.x / CELL;
float wy = (world_h - hitpos.y) / CELL;
int cx = clamp((int) wx, 0, (int) mapinfo.x - 1);
int cy = clamp((int) wy, 0, (int) mapinfo.y - 1);
float fx = wx - floor(wx);
float fy = wy - floor(wy);
uint4 record = (uint4) (celldata.Load(int3(cx, cy, 0)) * 65535.0 + 0.5);
uint packed = record.w;
uint dir1 = packed & 0xFu;
uint flags1 = (packed >> 4u) & 0x3u;
uint dir2 = (packed >> 8u) & 0xFu;
uint flags2 = (packed >> 12u) & 0x3u;
float2 fracUV = float2(fx, fy);
float3 c0 = sample_layer(record.x, wx, wy);
float3 c1 = sample_layer(record.y, wx, wy);
float3 c2 = sample_layer(record.z, wx, wy);
float f1 = blend_factor(dir1, flags1, fracUV);
float f2 = blend_factor(dir2, flags2, fracUV);
float3 albedo = lerp(lerp(c0, c1, f1), c2, f2);
// Per-pixel normal from the heightfield.
float hl = world_height(hitpos.x - CELL, hitpos.y);
float hr = world_height(hitpos.x + CELL, hitpos.y);
float hd = world_height(hitpos.x, hitpos.y - CELL);
float hu = world_height(hitpos.x, hitpos.y + CELL);
float3 n = normalize(float3(hl - hr, hd - hu, 2.0 * CELL));
float lambert = max(0.0, dot(n, sun_dir));
float3 lit = albedo * (ambient + (1.0 - ambient) * lambert);
// Distance haze toward the horizon so the map edge blends into the sky.
float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
lit = lerp(lit, sky_color(float3(dir.x, dir.y, 0.0)), fog);
return float4(lit, 1.0);
}
+9
View File
@@ -499,6 +499,15 @@ export namespace ra3::client {
this->shutdown(); this->shutdown();
return true; return true;
} }
/** GPU terrain viewer with a corner minimap overlay. */
[[nodiscard]] auto run_terrain(const display_options &options, const ra3::terrain::gpu_terrain &terrain, ra3::render::camera3d camera,
const image &minimap) -> bool {
if (!this->init(options)) return false;
(void)this->terrain_loop(terrain, camera, minimap);
this->shutdown();
return true;
}
}; };
/** /**
+104 -45
View File
@@ -8,14 +8,18 @@ import ra3.terrain;
import ra3.client; import ra3.client;
import ra3.ui; import ra3.ui;
import ra3.vulkan; import ra3.vulkan;
import ra3.dx;
/** /**
* Backend selection for the presentation layer. * Backend selection for the presentation layer.
* *
* The app talks only to `ra3::client::display`; this module picks the concrete * The app talks only to `ra3::client::display`; this module picks the concrete
* backend (Vulkan when available, otherwise SDL) so no caller has to know which * backend so no caller has to know which one is in use. The available backends
* one is in use. The GPU terrain path is Vulkan-only and reports failure so the * are Vulkan, Direct3D 11 and Direct3D 12 (both GPU terrain), and the SDL
* caller can fall back to the software renderer. * software blit fallback. The caller may name a preferred backend (the in-game
* menu exposes this); if it does not start, the others are tried in turn. The
* GPU terrain path is Vulkan/D3D-only and reports failure so the caller can fall
* back to the software renderer.
*/ */
export namespace ra3::display { export namespace ra3::display {
using ra3::client::display_options; using ra3::client::display_options;
@@ -27,72 +31,127 @@ export namespace ra3::display {
using menu_frame = std::function<std::optional<image>(const ui_event &, ra3::core::uint32, ra3::core::uint32, bool &)>; using menu_frame = std::function<std::optional<image>(const ui_event &, ra3::core::uint32, ra3::core::uint32, bool &)>;
using camera_frame = std::function<image(const camera3d &, ra3::core::uint32, ra3::core::uint32)>; using camera_frame = std::function<image(const camera3d &, ra3::core::uint32, ra3::core::uint32)>;
/** The backend that actually initialized, or `none`. */ /** A concrete presentation backend, or `none`. */
enum class backend { none, vulkan, sdl }; enum class backend { none, vulkan, d3d11, d3d12, sdl };
/** [[nodiscard]] inline auto backend_name(backend which) -> std::string_view {
* Create and initialize the preferred display (Vulkan, then SDL), or return switch (which) {
* null when neither starts. The caller owns the display and may reuse it for case backend::vulkan: return "vulkan";
* a menu, a loading bar and a viewer in sequence. case backend::d3d11: return "d3d11";
*/ case backend::d3d12: return "d3d12";
[[nodiscard]] inline auto create(const display_options &options, bool prefer_vulkan) -> std::unique_ptr<ra3::client::display> { case backend::sdl: return "sdl";
const auto try_vulkan = [&]() -> std::unique_ptr<ra3::client::display> { default: return "none";
auto d = std::make_unique<ra3::vulkan::vulkan_display>(); }
if (d->init(options)) return d; }
return nullptr;
}; [[nodiscard]] inline auto make_backend(backend which) -> std::unique_ptr<ra3::client::display> {
const auto try_sdl = [&]() -> std::unique_ptr<ra3::client::display> { switch (which) {
auto d = std::make_unique<ra3::ui::sdl_display>(); case backend::vulkan: return std::make_unique<ra3::vulkan::vulkan_display>();
if (d->init(options)) return d; case backend::d3d11: return std::make_unique<ra3::dx::d3d11_display>();
return nullptr; case backend::d3d12: return std::make_unique<ra3::dx::d3d12_display>();
}; case backend::sdl: return std::make_unique<ra3::ui::sdl_display>();
if (prefer_vulkan) { default: return nullptr;
if (auto d = try_vulkan()) return d; }
return try_sdl(); }
/** The order in which backends are attempted for a preferred one. */
[[nodiscard]] inline auto backend_order(backend preferred) -> std::array<backend, 4> {
switch (preferred) {
case backend::d3d11: return {backend::d3d11, backend::d3d12, backend::vulkan, backend::sdl};
case backend::d3d12: return {backend::d3d12, backend::d3d11, backend::vulkan, backend::sdl};
case backend::sdl: return {backend::sdl, backend::d3d11, backend::d3d12, backend::vulkan};
case backend::vulkan:
default: return {backend::vulkan, backend::d3d11, backend::d3d12, backend::sdl};
} }
if (auto d = try_sdl()) return d;
return try_vulkan();
} }
/** /**
* Run `action` on the first display that initializes: Vulkan, then SDL. * Create and initialize the preferred backend, falling back to the others,
* or return null when none starts. The caller owns the display and may reuse
* it for a menu, a loading bar and a viewer in sequence.
*/
[[nodiscard]] inline auto create(const display_options &options, backend preferred) -> std::unique_ptr<ra3::client::display> {
for (const auto which: backend_order(preferred)) {
auto candidate = make_backend(which);
if (candidate && candidate->init(options)) return candidate;
}
return nullptr;
}
/** Create and initialize the preferred display (Vulkan, then SDL). */
[[nodiscard]] inline auto create(const display_options &options, bool prefer_vulkan) -> std::unique_ptr<ra3::client::display> {
return create(options, prefer_vulkan ? backend::vulkan : backend::sdl);
}
/**
* Run `action` on the first backend that initializes.
* *
* `action` must call one of the shared `display` loops; it returns false to * `action` must call one of the shared `display` loops; it returns false to
* mean "this backend did not start", so the next one is tried. * mean "this backend did not start", so the next one is tried.
*/ */
template<typename Fn> template<typename Fn>
[[nodiscard]] auto with_display(bool prefer_vulkan, Fn &&action) -> bool { [[nodiscard]] auto with_display(backend preferred, Fn &&action) -> bool {
if (prefer_vulkan) { for (const auto which: backend_order(preferred)) {
if (auto vk = std::make_unique<ra3::vulkan::vulkan_display>(); action(*vk)) return true; auto candidate = make_backend(which);
} if (candidate && action(*candidate)) return true;
if (auto sdl = std::make_unique<ra3::ui::sdl_display>(); action(*sdl)) return true;
if (!prefer_vulkan) {
if (auto vk = std::make_unique<ra3::vulkan::vulkan_display>(); action(*vk)) return true;
} }
return false; return false;
} }
/** Interactive menu on the first available backend. */ /** Run `action` on the first display that initializes: Vulkan, then SDL. */
[[nodiscard]] inline auto run_menu(const display_options &options, const menu_frame &frame) -> bool { template<typename Fn>
return with_display(true, [&](ra3::client::display &d) { return d.run_menu(options, frame); }); [[nodiscard]] auto with_display(bool prefer_vulkan, Fn &&action) -> bool {
return with_display(prefer_vulkan ? backend::vulkan : backend::sdl, std::forward<Fn>(action));
} }
/** Pan/zoom image viewer on the first available backend. */ /** Interactive menu on the preferred (or first available) backend. */
[[nodiscard]] inline auto run_menu(const display_options &options, const menu_frame &frame, backend preferred = backend::vulkan) -> bool {
return with_display(preferred, [&](ra3::client::display &d) { return d.run_menu(options, frame); });
}
/** Pan/zoom image viewer on the preferred (or first available) backend. */
[[nodiscard]] inline auto run_image(const display_options &options, const image &scene, view_camera camera, backend preferred) -> bool {
return with_display(preferred, [&](ra3::client::display &d) { return d.run_image(options, scene, camera); });
}
/** Pan/zoom image viewer on the preferred (or first available) backend. */
[[nodiscard]] inline auto run_image(const display_options &options, const image &scene, view_camera camera, bool prefer_vulkan) -> bool { [[nodiscard]] inline auto run_image(const display_options &options, const image &scene, view_camera camera, bool prefer_vulkan) -> bool {
return with_display(prefer_vulkan, [&](ra3::client::display &d) { return d.run_image(options, scene, camera); }); return run_image(options, scene, camera, prefer_vulkan ? backend::vulkan : backend::sdl);
} }
/** Software 3D camera viewer on the first available backend. */ /** Software 3D camera viewer on the preferred (or first available) backend. */
[[nodiscard]] inline auto run_camera(const display_options &options, const camera_frame &provider, camera3d camera, bool prefer_vulkan) -> bool { [[nodiscard]] inline auto run_camera(const display_options &options, const camera_frame &provider, camera3d camera, bool prefer_vulkan) -> bool {
return with_display(prefer_vulkan, [&](ra3::client::display &d) { return d.run_camera(options, provider, camera); }); return with_display(prefer_vulkan ? backend::vulkan : backend::sdl,
[&](ra3::client::display &d) { return d.run_camera(options, provider, camera); });
} }
/** /**
* GPU terrain viewer. Vulkan only: returns false when Vulkan is unavailable * GPU terrain viewer on the preferred (or first available) backend. Returns
* so the caller can render offscreen instead. * false when no GPU backend that offers terrain starts, so the caller can
* render offscreen instead.
*/
[[nodiscard]] inline auto run_terrain(const display_options &options, const ra3::terrain::gpu_terrain &terrain, camera3d camera,
backend preferred) -> bool {
return with_display(preferred, [&](ra3::client::display &d) {
if (!d.supports_terrain()) return false;
return d.run_terrain(options, terrain, camera);
});
}
/** GPU terrain viewer with a corner minimap overlay. */
[[nodiscard]] inline auto run_terrain(const display_options &options, const ra3::terrain::gpu_terrain &terrain, camera3d camera,
const image &minimap, backend preferred) -> bool {
return with_display(preferred, [&](ra3::client::display &d) {
if (!d.supports_terrain()) return false;
return d.run_terrain(options, terrain, camera, minimap);
});
}
/**
* GPU terrain viewer. Vulkan/D3D only: returns false when the preferred GPU
* backend is unavailable so the caller can render offscreen instead.
*/ */
[[nodiscard]] inline auto run_terrain(const display_options &options, const ra3::terrain::gpu_terrain &terrain, camera3d camera) -> bool { [[nodiscard]] inline auto run_terrain(const display_options &options, const ra3::terrain::gpu_terrain &terrain, camera3d camera) -> bool {
auto vk = std::make_unique<ra3::vulkan::vulkan_display>(); return run_terrain(options, terrain, camera, backend::vulkan);
return vk->run_terrain(options, terrain, camera);
} }
} }
+1392
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
export module ra3.dx;
import std;
export import ra3.core;
import ra3.render;
import ra3.client;
/**
* Fallback Direct3D backends used when the build has no Windows/D3D (for
* example the Linux development build). `init` fails so the caller can select
* another display; the class names match the real `ra3.dx` module so the
* backend factory in `ra3.display` compiles unchanged.
*/
export namespace ra3::dx {
class d3d11_display final : public ra3::client::display {
public:
[[nodiscard]] auto init(const ra3::client::display_options &) -> bool override { return false; }
[[nodiscard]] auto present(const ra3::render::image &, const ra3::render::view_rect &, bool) -> bool override { return false; }
[[nodiscard]] auto poll_event(ra3::render::ui_event &) -> bool override { return false; }
[[nodiscard]] auto window_size() const -> std::pair<int, int> override { return {0, 0}; }
[[nodiscard]] auto key_down(ra3::render::ui_key) const -> bool override { return false; }
auto shutdown() -> void override {}
[[nodiscard]] auto name() const -> std::string_view override { return "d3d11(null)"; }
};
class d3d12_display final : public ra3::client::display {
public:
[[nodiscard]] auto init(const ra3::client::display_options &) -> bool override { return false; }
[[nodiscard]] auto present(const ra3::render::image &, const ra3::render::view_rect &, bool) -> bool override { return false; }
[[nodiscard]] auto poll_event(ra3::render::ui_event &) -> bool override { return false; }
[[nodiscard]] auto window_size() const -> std::pair<int, int> override { return {0, 0}; }
[[nodiscard]] auto key_down(ra3::render::ui_key) const -> bool override { return false; }
auto shutdown() -> void override {}
[[nodiscard]] auto name() const -> std::string_view override { return "d3d12(null)"; }
};
}
+1
View File
@@ -18,4 +18,5 @@ export import ra3.render;
export import ra3.terrain; export import ra3.terrain;
export import ra3.ui; export import ra3.ui;
export import ra3.vulkan; export import ra3.vulkan;
export import ra3.dx;
export import ra3.display; export import ra3.display;
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 EnderTheCoder
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+170
View File
@@ -0,0 +1,170 @@
# libenderlog
A standalone C++26 logging library, built as a C++20/26 **module** (`import ender.log;`)
with **`std::stacktrace`** call-stack capture on severe records.
It was extracted from the `ender-physics` engine, where it started life as
`ender.log`, and turned into a library that has no other dependency — not on the
engine, not on a logging framework.
## Features
- **Leveled records.** `trace`, `debug`, `info`, `warn`, `error`, `critical`,
filtered by an atomic `minimum` check that is cheap enough to guard expensive
message construction: `if (log::enabled(log::level::debug)) { ... }`.
- **Source location.** Every record carries the file, line and function of the
caller, taken from `std::source_location` at the call site — exact even in a
stripped release binary, because it is a compile-time constant.
- **Call stacks.** Records at or above `options::stacktrace_from` carry a
formatted `std::stacktrace`. The frames belonging to the library itself are
stripped by symbol, so the first reported frame is the caller regardless of
the optimisation level (the level wrappers get inlined away under `-O`).
- **Pluggable sinks.** A `console_sink` (stderr by default) and a `memory_sink`
(for tests and in-game consoles) ship; `sink` is a small interface.
- **File output with archiving.** `file_sink` writes to a file and, on open,
moves an existing log aside to a timestamped archive, so a run never appends
onto a previous run's log. It can also rotate by size and bound how many
archives are kept.
- **No stacktrace? No problem.** Where `<stacktrace>` is missing (libc++, and
therefore every cross target), the module still compiles and records still
carry their call site — they simply have no stack.
## Requirements
C++26 modules and `import std;` need a recent toolchain:
| Requirement | Version |
|---|---|
| Compiler | **GCC 15+** (or Clang with a standard library that provides the `std` module) |
| CMake | **3.30+** (for `CMAKE_EXPERIMENTAL_CXX_IMPORT_STD`) |
| Standard library | libstdc++ for `std::stacktrace` |
Ubuntu 26.04's default `g++` (GCC 15) and CMake 4 satisfy this, and that is the
release the CI targets and the `.deb` is built for. Ubuntu 24.04 ships GCC 13 and
CMake 3.28 and cannot build `import std;` without extra toolchains, so it is not
supported.
## Building
```sh
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
ctest --test-dir build --output-on-failure
```
Options:
| Option | Default | Description |
|---|---|---|
| `ENDERLOG_WERROR` | `OFF` | Treat warnings as errors |
| `ENDERLOG_BACKTRACE_SYMBOLS` | `ON` | Link executables with `-rdynamic` so traces can name frames |
| `ENDERLOG_BUILD_TESTS` | `ON` | Build the test suite |
| `ENDERLOG_BUILD_EXAMPLES` | `ON` | Build the example program |
Build Debug or RelWithDebInfo when you need to read a trace: frame names come
from debug information, and a release build often reports application frames as
`<unknown>`.
## Using the library
### `add_subdirectory`
```cmake
add_subdirectory(libenderlog)
target_link_libraries(my_app PRIVATE enderlog::enderlog)
```
### Installed package
```cmake
find_package(enderlog REQUIRED)
target_link_libraries(my_app PRIVATE enderlog::enderlog)
```
The static archive is installed together with the module interface source
(`ender.log.cppm`) because a module's BMI is compiler-version-specific — the
consumer rebuilds it from the source.
### In code
```cpp
import std;
import ender.log;
namespace log = ender::log;
auto main() -> int {
log::configure({.minimum = log::level::debug, .stacktrace_from = log::level::warn});
log::info(std::format("body {} moved to {:.2f}", 7, 12.35)); // formatted by the caller
log::error("a body left the world"); // carries a stack trace
}
```
Example output:
```
[11:32:18] ERROR example: a body left the world (examples/main.cpp:8)
#0 simulate_one_step (examples/main.cpp:8)
#1 main (examples/main.cpp:20)
#2 <unknown>
#3 __libc_start_main
#4 _start
```
### Configuration
```cpp
log::configure({
.minimum = log::level::debug, // drop everything below this
.stacktrace_from = log::level::error, // capture a stack at/above this
.stacktrace_depth = 16, // max frames kept
.stacktrace_skip = 2, // frames dropped before the caller is found
});
```
`log::current_options()` reads it back, `log::add_sink(...)` adds a destination,
and `log::set_sinks({...})` replaces them.
### Writing to a file
```cpp
namespace log = ender::log;
// Archive any existing enderlog.log to enderlog.log.<timestamp>, then start a
// fresh file for this run. Rotate at 64 KiB and keep the last 5 archives.
auto sink = log::add_file_sink("enderlog.log", {.max_file_size = 64 * 1024, .max_archives = 5});
```
- **No appending onto a previous run.** On open, an existing non-empty
`enderlog.log` is renamed to `enderlog.log.<YYYYmmdd-HHMMSS>` before the new
file is created, so every run gets its own file and the previous run's log is
preserved. A leftover empty file is simply replaced.
- `file_options::max_file_size` (0 disables) rotates the active file mid-run the
same way, and never archives an empty file. `file_options::max_archives`
(0 keeps all) deletes the oldest archives beyond the limit.
- `file_options::flush_each_record` (on by default) flushes after every record so
a crash keeps the tail.
- `add_file_sink` adds the sink to the global logger and returns it; `path()` and
`archives()` expose what it wrote. The `file_sink` class can also be used
directly and installed with `set_sinks`.
## Packaging
`cpack` produces a Debian package:
```sh
cmake -S . -B build -DENDERLOG_DISTRO=ubuntu26.04
cmake --build build -j
cd build
cpack
# -> libenderlog-dev_0.0.1_amd64_ubuntu26.04.deb
```
The package installs the static archive, the module interface source and the
CMake package config. CI builds it for Ubuntu 26.04 and publishes it as a job
artifact.
## License
MIT — see [LICENSE](LICENSE).
+542
View File
@@ -0,0 +1,542 @@
/**
* Logging with call-stack capture.
*
* Records carry a level, message, source location and — for severe enough
* levels — a formatted `std::stacktrace`. Capturing a trace walks the stack and
* reads debug information, so it is only done when the record's level is at or
* above `options::stacktrace_from`, and the whole call is skipped when the
* level is disabled.
*
* `std::stacktrace` is implemented by libstdc++ only. With GCC the module has
* to link `stdc++exp` (the static library that implements it); the CMake target
* takes care of that. File and line numbers in the trace come from debug
* information, so build with `-g` (Debug or RelWithDebInfo) to see them; symbol
* names work in any build.
*/
module;
// libc++ (the OpenRA3 toolchain) has no <stacktrace>, so this vendored copy adds
// a native fallback for the frames: Windows CaptureStackBackTrace and POSIX
// execinfo. These live in the global module fragment because they are C headers.
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <windows.h>
#elif defined(__unix__) || defined(__APPLE__)
#include <execinfo.h>
#endif
export module ender.log;
import std;
export namespace ender::log {
/** Severity of a record, ordered from most to least verbose. */
enum class level: std::uint8_t {
trace = 0,
debug,
info,
warn,
error,
critical,
};
/** Short upper-case name of a level, for output. */
[[nodiscard]] inline auto to_string(const level severity) -> std::string_view {
switch (severity) {
case level::trace: return "TRACE";
case level::debug: return "DEBUG";
case level::info: return "INFO";
case level::warn: return "WARN";
case level::error: return "ERROR";
case level::critical: return "CRITICAL";
}
return "?";
}
/** Logger configuration. */
struct options {
/** Records below this level are dropped before anything is built. */
level minimum{level::info};
/** Capture a stack trace for records at this level and above. */
level stacktrace_from{level::error};
/** Maximum number of frames kept in a captured trace. */
std::size_t stacktrace_depth{16};
/**
* Frames to drop from the top of a captured trace.
*
* The default drops `capture_stacktrace` and `emit`, which always exist
* as frames. The level wrappers are inlined away in optimised builds, so
* a fixed count cannot cover them; any leading frame that belongs to
* this module is therefore stripped by name instead, which keeps the
* caller visible whether or not the wrappers were inlined.
*/
std::size_t stacktrace_skip{2};
};
/** One log record. */
struct record {
level severity{level::info};
std::string message{};
std::string file{};
std::uint32_t line{0};
std::string function{};
/** Formatted call stack; empty when it was not captured. */
std::string stacktrace{};
std::chrono::system_clock::time_point time{};
std::thread::id thread{};
[[nodiscard]] auto has_stacktrace() const -> bool { return !stacktrace.empty(); }
};
namespace detail {
/**
* Render one record as a human-readable block: a header line and, when
* present, the indented stack frames. Shared by the stream sinks.
*/
[[nodiscard]] inline auto format_record(const record &entry) -> std::string {
auto text = std::format("[{:%H:%M:%S}] {:<8} {}",
std::chrono::floor<std::chrono::seconds>(entry.time),
to_string(entry.severity),
entry.message);
if (!entry.file.empty()) {
text += std::format(" ({}:{})", entry.file, entry.line);
}
text += '\n';
if (entry.has_stacktrace()) {
text += entry.stacktrace;
}
return text;
}
}
/** Where records go. */
class sink {
public:
virtual ~sink() = default;
/** Receive one record; called with the logger's mutex held. */
virtual auto write(const record &entry) -> void = 0;
/** Flush any buffering. */
virtual auto flush() -> void {}
};
/** Writes a human-readable line per record to a stream (stderr by default). */
class console_sink final: public sink {
public:
explicit console_sink(std::ostream &stream = std::cerr): stream_(&stream) {}
auto write(const record &entry) -> void override {
*stream_ << detail::format_record(entry);
stream_->flush();
}
private:
std::ostream *stream_;
};
/** Keeps every record in memory; useful for tests and in-game consoles. */
class memory_sink final: public sink {
public:
auto write(const record &entry) -> void override {
const auto lock = std::scoped_lock{mutex_};
records_.push_back(entry);
}
[[nodiscard]] auto records() const -> std::vector<record> {
const auto lock = std::scoped_lock{mutex_};
return records_;
}
[[nodiscard]] auto size() const -> std::size_t {
const auto lock = std::scoped_lock{mutex_};
return records_.size();
}
auto clear() -> void {
const auto lock = std::scoped_lock{mutex_};
records_.clear();
}
private:
mutable std::mutex mutex_;
std::vector<record> records_;
};
/** Configuration for `file_sink`. */
struct file_options {
/** Move an existing log file aside to an archive when the sink opens it. */
bool archive_on_open{true};
/** Flush after every record, so the tail survives a crash. */
bool flush_each_record{true};
/** Rotate once the active file would grow past this many bytes; 0 disables. */
std::size_t max_file_size{0};
/** Keep at most this many archives, dropping the oldest first; 0 keeps them all. */
std::size_t max_archives{0};
};
/**
* Writes records to a file, archiving the previous one on open.
*
* `path` is the active file. When the sink opens it and the file already
* holds data, that file is renamed to a timestamped archive first, so a run
* never appends onto a previous run's log: every start begins a fresh file
* and the old one is preserved as `<path>.<YYYYmmdd-HHMMSS>`. The same
* happens mid-run once the active file passes `file_options::max_file_size`.
* `file_options::max_archives` bounds how many archives are kept.
*
* As with every sink, `write` is called with the logger's mutex held, so one
* sink is safe to share; it is not safe for two processes to point at the
* same file.
*/
class file_sink final: public sink {
public:
explicit file_sink(std::filesystem::path path, const file_options options = {})
: path_(std::move(path)), options_(options) {
if (options_.archive_on_open && std::filesystem::exists(path_)) {
if (std::filesystem::file_size(path_) > 0) {
archive_current();
} else {
std::filesystem::remove(path_);
}
}
open();
}
auto write(const record &entry) -> void override {
const auto block = detail::format_record(entry);
// Rotate before writing, but never rotate an empty file: that would
// archive nothing and lose the record that is about to be written.
if (options_.max_file_size > 0 && size_ > 0 && size_ + block.size() > options_.max_file_size) {
archive_current();
open();
}
stream_ << block;
size_ += block.size();
if (options_.flush_each_record) stream_.flush();
}
auto flush() -> void override {
if (stream_.is_open()) stream_.flush();
}
/** The active log file. */
[[nodiscard]] auto path() const -> const std::filesystem::path & { return path_; }
/** Archives this sink created, oldest first. */
[[nodiscard]] auto archives() const -> const std::vector<std::filesystem::path> & { return archives_; }
private:
auto open() -> void {
stream_.clear();
stream_.open(path_, std::ios::out | std::ios::trunc | std::ios::binary);
size_ = 0;
}
auto archive_current() -> void {
if (stream_.is_open()) stream_.close();
const auto stamp = std::format("{:%Y%m%d-%H%M%S}",
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now()));
auto archive = path_;
archive += "." + stamp;
// Two rotations can land in the same second; disambiguate with a
// counter rather than overwrite the earlier archive.
for (auto counter = 1; std::filesystem::exists(archive); ++counter) {
archive = path_;
archive += std::format(".{}.{}", stamp, counter);
}
std::filesystem::rename(path_, archive);
archives_.push_back(archive);
prune_archives();
}
auto prune_archives() -> void {
if (options_.max_archives == 0) return;
while (archives_.size() > options_.max_archives) {
auto ignored = std::error_code{};
std::filesystem::remove(archives_.front(), ignored);
archives_.erase(archives_.begin());
}
}
std::filesystem::path path_;
file_options options_;
std::ofstream stream_;
std::size_t size_{0};
std::vector<std::filesystem::path> archives_;
};
/*
* <stacktrace> is not portable: libc++ has never implemented it, and only
* libstdc++ provides it here. Where it is missing, records still carry their
* call site through std::source_location - they simply carry no stack, and
* everything below degrades to an empty string rather than the module
* refusing to compile.
*
* CMake decides this and passes it in, rather than the module testing
* `__cpp_lib_stacktrace` itself: feature-test macros come from the standard
* library's headers, and `import std;` does not export them, so probing for
* one here silently reports "absent" even on libstdc++, which has it.
*/
#ifndef ENDERLOG_HAS_STACKTRACE
#define ENDERLOG_HAS_STACKTRACE 0
#endif
#if ENDERLOG_HAS_STACKTRACE
/** True when a frame belongs to the logging module itself. */
[[nodiscard]] inline auto is_logger_frame(const std::stacktrace_entry &entry) -> bool {
if (entry.description().find("ender::log") != std::string::npos) return true;
return entry.source_file().find("ender.log.cppm") != std::string::npos;
}
/**
* Render a trace as one indented line per frame.
*
* @param skip_logger_frames Drop leading frames belonging to this module, so
* the first reported frame is the caller. This is what makes the
* output stable across optimisation levels: in a release build the
* level wrappers are inlined into the caller, so counting frames
* alone would either over- or under-skip.
*/
[[nodiscard]] inline auto format_stacktrace(const std::stacktrace &trace,
const bool skip_logger_frames = true) -> std::string {
if (trace.empty()) return " <empty stacktrace>\n";
auto first = std::size_t{0};
if (skip_logger_frames) {
while (first < trace.size() && is_logger_frame(trace.at(first))) ++first;
if (first >= trace.size()) first = 0; // never hide the whole trace
}
auto text = std::string{};
for (auto index = first; index < trace.size(); ++index) {
const auto &entry = trace.at(index);
auto description = entry.description();
if (description.empty()) description = "<unknown>";
auto location = std::string{};
if (!entry.source_file().empty()) {
location = std::format(" ({}:{})", entry.source_file(), entry.source_line());
}
text += std::format(" #{:<3}{}{}\n", index - first, description, location);
}
return text;
}
/** Capture and render the current call stack, innermost frame first. */
[[nodiscard]] inline auto capture_stacktrace(const std::size_t skip = 2, const std::size_t depth = 16)
-> std::string {
return format_stacktrace(std::stacktrace::current(skip, depth));
}
#else
/**
* libc++ fallback: capture the current call stack with the platform's own
* backtrace API and render one indented line per frame.
*
* On Windows a frame is reported as `module.dll+0xRVA` (a MinGW release build
* has DWARF, not the PDB symbols dbghelp resolves, so a module+offset is the
* practical answer). On POSIX `backtrace_symbols` is used, which names a
* frame when the executable was linked with `-rdynamic`.
*/
#if defined(_WIN32)
[[nodiscard]] inline auto symbolicate_frame(void *address) -> std::string {
const auto value = reinterpret_cast<std::uintptr_t>(address);
HMODULE module = nullptr;
if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(address), &module)) {
wchar_t wide[260] = L"?";
GetModuleFileNameW(module, wide, 260U);
char narrow[260] = "?";
WideCharToMultiByte(CP_UTF8, 0, wide, -1, narrow, sizeof(narrow), nullptr, nullptr);
const char *base = std::strrchr(narrow, '\\');
return std::format("{}+0x{:X}", base != nullptr ? base + 1 : narrow, value - reinterpret_cast<std::uintptr_t>(module));
}
return std::format("0x{:X}", value);
}
#endif
[[nodiscard]] inline auto capture_stacktrace(const std::size_t skip = 2, const std::size_t depth = 16) -> std::string {
constexpr std::size_t max_frames = 64;
#if defined(_WIN32)
void *frames[max_frames] = {};
const auto want = static_cast<DWORD>(std::min(max_frames, skip + std::max<std::size_t>(depth, 1U)));
const USHORT count = CaptureStackBackTrace(static_cast<DWORD>(skip), want, frames, nullptr);
auto text = std::string{};
for (USHORT index = 0; index < count; ++index) {
text += std::format(" #{:<3}{}\n", index, symbolicate_frame(frames[index]));
}
return text;
#elif defined(__unix__) || defined(__APPLE__)
void *frames[max_frames] = {};
const int count = ::backtrace(frames, static_cast<int>(std::min(max_frames, skip + std::max<std::size_t>(depth, 1U))));
char **symbols = ::backtrace_symbols(frames, count);
auto text = std::string{};
for (int index = static_cast<int>(std::min<std::size_t>(skip, static_cast<std::size_t>(count))); index < count; ++index) {
text += std::format(" #{:<3}{}\n", index - static_cast<int>(skip),
symbols != nullptr ? symbols[index] : std::format("0x{:X}", reinterpret_cast<std::uintptr_t>(frames[index])));
}
if (symbols != nullptr) std::free(symbols);
return text;
#else
(void) skip;
(void) depth;
return {};
#endif
}
#endif
/**
* The process-wide logger.
*
* `enabled` is an atomic read so hot paths can guard expensive message
* construction; everything else takes the mutex.
*/
class logger {
public:
[[nodiscard]] static auto instance() -> logger & {
static logger shared;
return shared;
}
auto configure(const options &config) -> void {
const auto lock = std::scoped_lock{mutex_};
options_ = config;
minimum_.store(static_cast<std::uint8_t>(config.minimum), std::memory_order_relaxed);
}
[[nodiscard]] auto configuration() const -> options {
const auto lock = std::scoped_lock{mutex_};
return options_;
}
[[nodiscard]] auto enabled(const level severity) const -> bool {
return static_cast<std::uint8_t>(severity) >= minimum_.load(std::memory_order_relaxed);
}
auto add_sink(std::shared_ptr<sink> destination) -> void {
const auto lock = std::scoped_lock{mutex_};
sinks_.push_back(std::move(destination));
}
auto set_sinks(std::vector<std::shared_ptr<sink>> destinations) -> void {
const auto lock = std::scoped_lock{mutex_};
sinks_ = std::move(destinations);
}
auto dispatch(const record &entry) -> void {
const auto lock = std::scoped_lock{mutex_};
for (const auto &destination: sinks_) {
destination->write(entry);
}
}
private:
logger() { sinks_.push_back(std::make_shared<console_sink>()); }
mutable std::mutex mutex_;
options options_{};
std::atomic<std::uint8_t> minimum_{static_cast<std::uint8_t>(options{}.minimum)};
std::vector<std::shared_ptr<sink>> sinks_;
};
/** Apply a configuration to the process-wide logger. */
inline auto configure(const options &config) -> void { logger::instance().configure(config); }
/** Current configuration of the process-wide logger. */
[[nodiscard]] inline auto current_options() -> options { return logger::instance().configuration(); }
/** Route records to an additional sink. */
inline auto add_sink(std::shared_ptr<sink> destination) -> void {
logger::instance().add_sink(std::move(destination));
}
/**
* Create a file sink, route records to it, and hand it back.
*
* The previous log at `path` is archived on open, so this never appends onto
* an earlier run.
*
* @return The sink, so the caller can inspect the archives it creates.
*/
inline auto add_file_sink(std::filesystem::path path, const file_options &options = {})
-> std::shared_ptr<file_sink> {
auto destination = std::make_shared<file_sink>(std::move(path), options);
logger::instance().add_sink(destination);
return destination;
}
/** Replace every sink. */
inline auto set_sinks(std::vector<std::shared_ptr<sink>> destinations) -> void {
logger::instance().set_sinks(std::move(destinations));
}
/** True when a record at this level would be emitted. */
[[nodiscard]] inline auto enabled(const level severity) -> bool { return logger::instance().enabled(severity); }
namespace detail {
/** Build and dispatch one record. Not for direct use. */
inline auto emit(const level severity, std::string message, const std::source_location location) -> void {
auto &target = logger::instance();
if (!target.enabled(severity)) return;
const auto config = target.configuration();
auto entry = record{
.severity = severity,
.message = std::move(message),
.file = location.file_name(),
.line = static_cast<std::uint32_t>(location.line()),
.function = location.function_name(),
.time = std::chrono::system_clock::now(),
.thread = std::this_thread::get_id(),
};
if (severity >= config.stacktrace_from) {
entry.stacktrace = capture_stacktrace(config.stacktrace_skip, config.stacktrace_depth);
}
target.dispatch(entry);
}
}
/**
* Emit a record.
*
* The source location defaults to the call site, so this reports exactly
* where it was called from.
*/
inline auto log(const level severity,
std::string message,
const std::source_location location = std::source_location::current()) -> void {
detail::emit(severity, std::move(message), location);
}
inline auto trace(std::string message, const std::source_location location = std::source_location::current())
-> void {
detail::emit(level::trace, std::move(message), location);
}
inline auto debug(std::string message, const std::source_location location = std::source_location::current())
-> void {
detail::emit(level::debug, std::move(message), location);
}
inline auto info(std::string message, const std::source_location location = std::source_location::current())
-> void {
detail::emit(level::info, std::move(message), location);
}
inline auto warn(std::string message, const std::source_location location = std::source_location::current())
-> void {
detail::emit(level::warn, std::move(message), location);
}
/** Emits at `error`, which captures a call stack by default. */
inline auto error(std::string message, const std::source_location location = std::source_location::current())
-> void {
detail::emit(level::error, std::move(message), location);
}
inline auto critical(std::string message, const std::source_location location = std::source_location::current())
-> void {
detail::emit(level::critical, std::move(message), location);
}
}