Compare commits
7
Commits
v0.3.0
...
70d4beca4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70d4beca4f | ||
|
|
d0ae4c76ba | ||
|
|
3caf18409b | ||
|
|
59261868e1 | ||
|
|
a7612878e5 | ||
|
|
bb3f8306ef | ||
|
|
7cb4d18308 |
@@ -30,3 +30,4 @@ compile_commands.json
|
||||
# local data dumps / RE artifacts
|
||||
re-data/
|
||||
*.log
|
||||
*.log.[0-9]*
|
||||
|
||||
+272
-34
@@ -4,36 +4,58 @@ stages:
|
||||
|
||||
variables:
|
||||
DOCKER_HOST: tcp://docker:2375
|
||||
DOCKER_DRIVER: overlay2
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
DOCKER_BUILDKIT: "1"
|
||||
# apt mirror used inside both images (override per pipeline if needed)
|
||||
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
|
||||
services:
|
||||
- name: docker:dind
|
||||
# Pull docker.io images through the intranet Harbor pull-through cache;
|
||||
# plain HTTP registry, hence --insecure-registry.
|
||||
command:
|
||||
- --registry-mirror=http://192.168.1.11:9090/dockerhub
|
||||
- --insecure-registry=192.168.1.11:9090
|
||||
- name: docker:dind
|
||||
command:
|
||||
- --registry-mirror=http://192.168.1.11:9090/dockerhub
|
||||
- --insecure-registry=192.168.1.11:9090
|
||||
- --feature=containerd-snapshotter=true
|
||||
|
||||
# --- 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:
|
||||
stage: image
|
||||
image: docker:latest
|
||||
services: *dind
|
||||
tags:
|
||||
- docker
|
||||
before_script: *retry
|
||||
script:
|
||||
- docker build --target dev -t openra3-linux:local --build-arg APT_MIRROR=$APT_MIRROR .
|
||||
- docker save openra3-linux:local -o linux-image.tar
|
||||
artifacts:
|
||||
name: "linux-image-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- linux-image.tar
|
||||
expire_in: 1 day
|
||||
- retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
|
||||
- |
|
||||
retry docker build \
|
||||
--cache-from type=registry,ref=$IMAGE:cache-linux,insecure=true \
|
||||
--cache-to type=registry,ref=$IMAGE:cache-linux,mode=max,insecure=true \
|
||||
--build-arg APT_MIRROR=$APT_MIRROR \
|
||||
--target dev \
|
||||
-t $IMAGE:$CI_COMMIT_SHORT_SHA-linux \
|
||||
-f Dockerfile .
|
||||
- retry docker push $IMAGE:$CI_COMMIT_SHORT_SHA-linux
|
||||
|
||||
windows_image:
|
||||
stage: image
|
||||
@@ -41,14 +63,18 @@ windows_image:
|
||||
services: *dind
|
||||
tags:
|
||||
- docker
|
||||
before_script: *retry
|
||||
script:
|
||||
- docker build -f Dockerfile.win --target dev -t openra3-windows:local --build-arg APT_MIRROR=$APT_MIRROR .
|
||||
- docker save openra3-windows:local -o windows-image.tar
|
||||
artifacts:
|
||||
name: "windows-image-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- windows-image.tar
|
||||
expire_in: 1 day
|
||||
- retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
|
||||
- |
|
||||
retry docker build \
|
||||
--cache-from type=registry,ref=$IMAGE:cache-windows,insecure=true \
|
||||
--cache-to type=registry,ref=$IMAGE:cache-windows,mode=max,insecure=true \
|
||||
--build-arg APT_MIRROR=$APT_MIRROR \
|
||||
--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_linux:
|
||||
@@ -59,13 +85,15 @@ build_linux:
|
||||
- docker
|
||||
needs:
|
||||
- linux_image
|
||||
before_script:
|
||||
- docker load -i linux-image.tar
|
||||
before_script: *retry
|
||||
script:
|
||||
- docker run --rm -v "$(pwd):/work" -w /work openra3-linux:local cmake -S . -B build/linux -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||
- docker run --rm -v "$(pwd):/work" -w /work openra3-linux:local cmake --build build/linux -j
|
||||
- docker run --rm -v "$(pwd):/work" -w /work openra3-linux:local ctest --test-dir build/linux --output-on-failure
|
||||
- docker run --rm -v "$(pwd):/work" -w /work openra3-linux:local bash -c "mkdir -p artifacts/linux && cp build/linux/bin/openra3 artifacts/linux/"
|
||||
- retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
|
||||
- retry docker pull $IMAGE:$CI_COMMIT_SHORT_SHA-linux
|
||||
- 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 -DOPENRA3_DISTRO_TAG=ubuntu26.04
|
||||
- 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 "cd build/linux && cpack -G DEB"
|
||||
- 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 build/linux/*.deb artifacts/linux/"
|
||||
artifacts:
|
||||
name: "$CI_PROJECT_NAME-linux-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
@@ -80,17 +108,227 @@ build_windows:
|
||||
- docker
|
||||
needs:
|
||||
- windows_image
|
||||
before_script:
|
||||
- docker load -i windows-image.tar
|
||||
before_script: *retry
|
||||
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
|
||||
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/llvm-mingw-x86_64.cmake
|
||||
-DCMAKE_BUILD_TYPE=Release
|
||||
- docker run --rm -v "$(pwd):/work" -w /work openra3-windows:local cmake --build build/windows -j
|
||||
- docker run --rm -v "$(pwd):/work" -w /work openra3-windows:local bash -c "mkdir -p artifacts/windows && cp build/windows/bin/openra3.exe build/windows/bin/SDL3.dll artifacts/windows/"
|
||||
- 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 $IMAGE:$CI_COMMIT_SHORT_SHA-windows cmake --build build/windows --target openra3_msi
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-windows bash -c "cd build/windows && cpack -G ZIP"
|
||||
- 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 build/windows/bin/*.msi build/windows/*.zip artifacts/windows/"
|
||||
artifacts:
|
||||
name: "$CI_PROJECT_NAME-windows-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- artifacts/windows/
|
||||
expire_in: 7 days
|
||||
|
||||
# --- arm64 + Debian toolchain images -----------------------------------------
|
||||
linux_arm64_image:
|
||||
stage: image
|
||||
image: docker:latest
|
||||
services: *dind
|
||||
tags:
|
||||
- docker
|
||||
before_script: *retry
|
||||
script:
|
||||
- retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
|
||||
- |
|
||||
retry docker build \
|
||||
--cache-from type=registry,ref=$IMAGE:cache-linux-arm64,insecure=true \
|
||||
--cache-to type=registry,ref=$IMAGE:cache-linux-arm64,mode=max,insecure=true \
|
||||
--build-arg APT_MIRROR=$APT_MIRROR \
|
||||
--target dev \
|
||||
-t $IMAGE:$CI_COMMIT_SHORT_SHA-linux-arm64 \
|
||||
-f Dockerfile.linux-arm64 .
|
||||
- retry docker push $IMAGE:$CI_COMMIT_SHORT_SHA-linux-arm64
|
||||
|
||||
windows_arm64_image:
|
||||
stage: image
|
||||
image: docker:latest
|
||||
services: *dind
|
||||
tags:
|
||||
- docker
|
||||
before_script: *retry
|
||||
script:
|
||||
- retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
|
||||
- |
|
||||
retry docker build \
|
||||
--cache-from type=registry,ref=$IMAGE:cache-windows-arm64,insecure=true \
|
||||
--cache-to type=registry,ref=$IMAGE:cache-windows-arm64,mode=max,insecure=true \
|
||||
--build-arg APT_MIRROR=$APT_MIRROR \
|
||||
--target dev \
|
||||
-t $IMAGE:$CI_COMMIT_SHORT_SHA-windows-arm64 \
|
||||
-f Dockerfile.win-arm64 .
|
||||
- retry docker push $IMAGE:$CI_COMMIT_SHORT_SHA-windows-arm64
|
||||
|
||||
debian_image:
|
||||
stage: image
|
||||
image: docker:latest
|
||||
services: *dind
|
||||
tags:
|
||||
- docker
|
||||
before_script: *retry
|
||||
script:
|
||||
- retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
|
||||
- |
|
||||
retry docker build \
|
||||
--cache-from type=registry,ref=$IMAGE:cache-debian,insecure=true \
|
||||
--cache-to type=registry,ref=$IMAGE:cache-debian,mode=max,insecure=true \
|
||||
--build-arg APT_MIRROR=$APT_MIRROR \
|
||||
--target dev \
|
||||
-t $IMAGE:$CI_COMMIT_SHORT_SHA-debian \
|
||||
-f Dockerfile.debian .
|
||||
- retry docker push $IMAGE:$CI_COMMIT_SHORT_SHA-debian
|
||||
|
||||
debian_arm64_image:
|
||||
stage: image
|
||||
image: docker:latest
|
||||
services: *dind
|
||||
tags:
|
||||
- docker
|
||||
before_script: *retry
|
||||
script:
|
||||
- retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
|
||||
- |
|
||||
retry docker build \
|
||||
--cache-from type=registry,ref=$IMAGE:cache-debian-arm64,insecure=true \
|
||||
--cache-to type=registry,ref=$IMAGE:cache-debian-arm64,mode=max,insecure=true \
|
||||
--build-arg APT_MIRROR=$APT_MIRROR \
|
||||
--target dev \
|
||||
-t $IMAGE:$CI_COMMIT_SHORT_SHA-debian-arm64 \
|
||||
-f Dockerfile.debian-arm64 .
|
||||
- retry docker push $IMAGE:$CI_COMMIT_SHORT_SHA-debian-arm64
|
||||
|
||||
# --- build each target (binary + packages) -----------------------------------
|
||||
build_linux_arm64:
|
||||
stage: build
|
||||
image: docker:latest
|
||||
services: *dind
|
||||
tags:
|
||||
- docker
|
||||
needs:
|
||||
- linux_arm64_image
|
||||
before_script: *retry
|
||||
script:
|
||||
- retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
|
||||
- retry docker pull $IMAGE:$CI_COMMIT_SHORT_SHA-linux-arm64
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-linux-arm64 cmake -S . -B build/linux-arm64 -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/clang-aarch64-linux-gnu.cmake -DCMAKE_BUILD_TYPE=Release -DOPENRA3_DISTRO_TAG=ubuntu26.04
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-linux-arm64 cmake --build build/linux-arm64 -j
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-linux-arm64 bash -c "cd build/linux-arm64 && cpack -G DEB"
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-linux-arm64 bash -c "mkdir -p artifacts/linux-arm64 && cp build/linux-arm64/bin/openra3 build/linux-arm64/*.deb artifacts/linux-arm64/"
|
||||
artifacts:
|
||||
name: "$CI_PROJECT_NAME-linux-arm64-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- artifacts/linux-arm64/
|
||||
expire_in: 7 days
|
||||
|
||||
build_windows_arm64:
|
||||
stage: build
|
||||
image: docker:latest
|
||||
services: *dind
|
||||
tags:
|
||||
- docker
|
||||
needs:
|
||||
- windows_arm64_image
|
||||
before_script: *retry
|
||||
script:
|
||||
- retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
|
||||
- retry docker pull $IMAGE:$CI_COMMIT_SHORT_SHA-windows-arm64
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-windows-arm64 cmake -S . -B build/windows-arm64 -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/llvm-mingw-aarch64.cmake -DCMAKE_BUILD_TYPE=Release
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-windows-arm64 cmake --build build/windows-arm64 -j
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-windows-arm64 bash -c "cd build/windows-arm64 && cpack -G ZIP"
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-windows-arm64 bash -c "mkdir -p artifacts/windows-arm64 && cp build/windows-arm64/bin/openra3.exe build/windows-arm64/bin/SDL3.dll build/windows-arm64/*.zip artifacts/windows-arm64/"
|
||||
artifacts:
|
||||
name: "$CI_PROJECT_NAME-windows-arm64-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- artifacts/windows-arm64/
|
||||
expire_in: 7 days
|
||||
|
||||
build_debian:
|
||||
stage: build
|
||||
image: docker:latest
|
||||
services: *dind
|
||||
tags:
|
||||
- docker
|
||||
needs:
|
||||
- debian_image
|
||||
before_script: *retry
|
||||
script:
|
||||
- retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
|
||||
- retry docker pull $IMAGE:$CI_COMMIT_SHORT_SHA-debian
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-debian cmake -S . -B build/debian -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_STDLIB_MODULES_JSON=/usr/lib/llvm-19/lib/libc++.modules.json -DOPENRA3_DISTRO_TAG=debian13
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-debian cmake --build build/debian -j
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-debian ctest --test-dir build/debian --output-on-failure
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-debian bash -c "cd build/debian && cpack -G DEB"
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-debian bash -c "mkdir -p artifacts/debian && cp build/debian/bin/openra3 build/debian/*.deb artifacts/debian/"
|
||||
artifacts:
|
||||
name: "$CI_PROJECT_NAME-debian-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- artifacts/debian/
|
||||
expire_in: 7 days
|
||||
|
||||
build_debian_arm64:
|
||||
stage: build
|
||||
image: docker:latest
|
||||
services: *dind
|
||||
tags:
|
||||
- docker
|
||||
needs:
|
||||
- debian_arm64_image
|
||||
before_script: *retry
|
||||
script:
|
||||
- retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
|
||||
- retry docker pull $IMAGE:$CI_COMMIT_SHORT_SHA-debian-arm64
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-debian-arm64 cmake -S . -B build/debian-arm64 -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/clang-aarch64-linux-gnu.cmake -DCMAKE_BUILD_TYPE=Release -DOPENRA3_AARCH64_CC=clang-19 -DOPENRA3_AARCH64_CXX=clang++-19 -DOPENRA3_AARCH64_STDLIB_MODULES_JSON=/usr/lib/llvm-19/lib/libc++.modules.json -DOPENRA3_DISTRO_TAG=debian13
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-debian-arm64 cmake --build build/debian-arm64 -j
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-debian-arm64 bash -c "cd build/debian-arm64 && cpack -G DEB"
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-debian-arm64 bash -c "mkdir -p artifacts/debian-arm64 && cp build/debian-arm64/bin/openra3 build/debian-arm64/*.deb artifacts/debian-arm64/"
|
||||
artifacts:
|
||||
name: "$CI_PROJECT_NAME-debian-arm64-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- artifacts/debian-arm64/
|
||||
expire_in: 7 days
|
||||
# --- WebAssembly (Emscripten) -------------------------------------------------
|
||||
wasm_image:
|
||||
stage: image
|
||||
image: docker:latest
|
||||
services: *dind
|
||||
tags:
|
||||
- docker
|
||||
before_script: *retry
|
||||
script:
|
||||
- retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
|
||||
- |
|
||||
retry docker build \
|
||||
--cache-from type=registry,ref=$IMAGE:cache-wasm,insecure=true \
|
||||
--cache-to type=registry,ref=$IMAGE:cache-wasm,mode=max,insecure=true \
|
||||
--build-arg APT_MIRROR=$APT_MIRROR \
|
||||
--target dev \
|
||||
-t $IMAGE:$CI_COMMIT_SHORT_SHA-wasm \
|
||||
-f Dockerfile.wasm .
|
||||
- retry docker push $IMAGE:$CI_COMMIT_SHORT_SHA-wasm
|
||||
|
||||
build_wasm:
|
||||
stage: build
|
||||
image: docker:latest
|
||||
services: *dind
|
||||
tags:
|
||||
- docker
|
||||
needs:
|
||||
- wasm_image
|
||||
before_script: *retry
|
||||
script:
|
||||
- retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
|
||||
- retry docker pull $IMAGE:$CI_COMMIT_SHORT_SHA-wasm
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-wasm cmake -S . -B build/wasm -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/emscripten-wasm.cmake -DCMAKE_BUILD_TYPE=Release
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-wasm cmake --build build/wasm -j
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-wasm bash -c "mkdir -p artifacts/wasm && cp build/wasm/bin/openra3.html build/wasm/bin/openra3.js build/wasm/bin/openra3.wasm build/wasm/bin/openra3.data artifacts/wasm/ 2>/dev/null || cp build/wasm/bin/openra3.html build/wasm/bin/openra3.js build/wasm/bin/openra3.wasm artifacts/wasm/"
|
||||
artifacts:
|
||||
name: "$CI_PROJECT_NAME-wasm-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- artifacts/wasm/
|
||||
expire_in: 7 days
|
||||
+238
-14
@@ -7,8 +7,9 @@ set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "d0edc3af-4c50-42ea-a356-e2862fe7a444")
|
||||
|
||||
# Path to the standard-library module description. A cross toolchain overrides
|
||||
# this before the compiler is probed; this default targets the Linux LLVM
|
||||
# package.
|
||||
if(NOT CMAKE_CXX_STDLIB_MODULES_JSON)
|
||||
# package. Skipped for Emscripten, whose toolchain supplies its own sysroot
|
||||
# modules.json.
|
||||
if(NOT CMAKE_CXX_STDLIB_MODULES_JSON AND NOT DEFINED ENV{EMSDK})
|
||||
set(CMAKE_CXX_STDLIB_MODULES_JSON "/usr/lib/llvm-21/lib/libc++.modules.json")
|
||||
endif()
|
||||
|
||||
@@ -25,7 +26,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
set(CMAKE_CXX_SCAN_FOR_MODULES ON)
|
||||
|
||||
project(OpenRA3 VERSION 0.3.0 LANGUAGES C CXX)
|
||||
project(OpenRA3 VERSION 0.6.0 LANGUAGES C CXX)
|
||||
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
||||
@@ -52,9 +53,18 @@ endfunction()
|
||||
# --- SDL3: pkg-config on Linux, or an explicit MinGW root for Windows --------
|
||||
add_library(openra3_sdl3 INTERFACE)
|
||||
set(OPENRA3_HAS_SDL3 OFF)
|
||||
if(OPENRA3_SDL3_ROOT)
|
||||
target_include_directories(openra3_sdl3 INTERFACE "${OPENRA3_SDL3_ROOT}/x86_64-w64-mingw32/include")
|
||||
target_link_libraries(openra3_sdl3 INTERFACE "${OPENRA3_SDL3_ROOT}/x86_64-w64-mingw32/lib/libSDL3.dll.a")
|
||||
# The triple-named subdirectory inside a MinGW SDL3 development package
|
||||
# (`<root>/<triple>/{include,lib,bin}`); x86_64 unless a toolchain overrides it.
|
||||
set(OPENRA3_SDL3_MINGW_TRIPLE "x86_64-w64-mingw32" CACHE STRING "SDL3 MinGW triple subdirectory")
|
||||
if(EMSCRIPTEN)
|
||||
# Emscripten's SDL3 web port provides the headers and the (static) library
|
||||
# via the `-sUSE_SDL=3` flag, on every target that uses SDL.
|
||||
target_compile_options(openra3_sdl3 INTERFACE -sUSE_SDL=3)
|
||||
target_link_options(openra3_sdl3 INTERFACE -sUSE_SDL=3)
|
||||
set(OPENRA3_HAS_SDL3 ON)
|
||||
elseif(OPENRA3_SDL3_ROOT)
|
||||
target_include_directories(openra3_sdl3 INTERFACE "${OPENRA3_SDL3_ROOT}/${OPENRA3_SDL3_MINGW_TRIPLE}/include")
|
||||
target_link_libraries(openra3_sdl3 INTERFACE "${OPENRA3_SDL3_ROOT}/${OPENRA3_SDL3_MINGW_TRIPLE}/lib/libSDL3.dll.a")
|
||||
set(OPENRA3_HAS_SDL3 ON)
|
||||
else()
|
||||
find_package(PkgConfig QUIET)
|
||||
@@ -75,9 +85,15 @@ endif()
|
||||
# --- Vulkan: vendored volk + headers, so no Vulkan SDK is needed ---------------
|
||||
# The loader is resolved at runtime (volk dlopen/LoadLibrary), which lets the
|
||||
# same source build the Vulkan backend on Linux and on the MinGW Windows target.
|
||||
option(OPENRA3_VULKAN "Build the Vulkan viewer" ON)
|
||||
# WebAssembly has no Vulkan, so the viewer is off there (the null backend).
|
||||
if(EMSCRIPTEN)
|
||||
set(OPENRA3_VULKAN_DEFAULT OFF)
|
||||
else()
|
||||
set(OPENRA3_VULKAN_DEFAULT ON)
|
||||
endif()
|
||||
option(OPENRA3_VULKAN "Build the Vulkan viewer" ${OPENRA3_VULKAN_DEFAULT})
|
||||
set(OPENRA3_HAS_VULKAN OFF)
|
||||
if(OPENRA3_VULKAN AND OPENRA3_HAS_SDL3)
|
||||
if(OPENRA3_VULKAN AND OPENRA3_HAS_SDL3 AND NOT EMSCRIPTEN)
|
||||
set(OPENRA3_HAS_VULKAN ON)
|
||||
endif()
|
||||
if(OPENRA3_HAS_VULKAN)
|
||||
@@ -86,9 +102,11 @@ else()
|
||||
message(STATUS "OpenRA3: Vulkan viewer disabled - offscreen image only")
|
||||
endif()
|
||||
|
||||
add_library(ra3_volk STATIC third_party/volk/volk.c)
|
||||
target_include_directories(ra3_volk PUBLIC third_party/volk third_party/vulkan/include)
|
||||
target_compile_definitions(ra3_volk PUBLIC VK_NO_PROTOTYPES)
|
||||
if(NOT EMSCRIPTEN)
|
||||
add_library(ra3_volk STATIC third_party/volk/volk.c)
|
||||
target_include_directories(ra3_volk PUBLIC third_party/volk third_party/vulkan/include)
|
||||
target_compile_definitions(ra3_volk PUBLIC VK_NO_PROTOTYPES)
|
||||
endif()
|
||||
|
||||
# Embed the committed SPIR-V blobs into a generated header at configure time.
|
||||
# Regenerate with shaders/compile.sh after editing a .vert/.frag.
|
||||
@@ -112,6 +130,55 @@ function(openra3_embed_spirv out_header)
|
||||
file(WRITE "${out_header}" "${content}")
|
||||
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()
|
||||
|
||||
# Embed the GLSL ES sources for the WebGL backend as string literals (compiled
|
||||
# at runtime by the browser's GL, like the HLSL for Direct3D).
|
||||
function(openra3_embed_glsl out_header)
|
||||
set(content "// Generated by CMake from shaders/*.glsl.\n")
|
||||
string(APPEND content "#pragma once\n\nnamespace ra3_shaders {\n")
|
||||
foreach(src IN LISTS ARGN)
|
||||
if(NOT EXISTS "${src}")
|
||||
message(FATAL_ERROR "Missing GLSL shader ${src}")
|
||||
endif()
|
||||
get_filename_component(base "${src}" NAME_WE)
|
||||
string(MAKE_C_IDENTIFIER "${base}" ident)
|
||||
string(APPEND ident "_glsl")
|
||||
file(READ "${src}" text)
|
||||
string(APPEND content "inline constexpr const char ${ident}[] = R\"RA3GLSL(${text})RA3GLSL\";\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 -------------------------------------------------------------
|
||||
add_library(ra3_core STATIC)
|
||||
target_sources(ra3_core PUBLIC FILE_SET CXX_MODULES FILES src/core/ra3.core.cppm)
|
||||
@@ -210,16 +277,87 @@ else()
|
||||
endif()
|
||||
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)
|
||||
|
||||
# --- WebGL viewer (Emscripten/WebAssembly, or null fallback) ------------------
|
||||
# A peer of the Vulkan/Direct3D backends; built only for the wasm target, where
|
||||
# it uses an SDL3 canvas + GLES 3.0 (WebGL2). Elsewhere it is the null stub.
|
||||
option(OPENRA3_WEBGL "Build the WebGL viewer (Emscripten)" ON)
|
||||
set(OPENRA3_HAS_WEBGL OFF)
|
||||
if(OPENRA3_WEBGL AND EMSCRIPTEN)
|
||||
set(OPENRA3_HAS_WEBGL ON)
|
||||
endif()
|
||||
if(OPENRA3_HAS_WEBGL)
|
||||
message(STATUS "OpenRA3: WebGL viewer enabled")
|
||||
else()
|
||||
message(STATUS "OpenRA3: WebGL viewer disabled - null backend")
|
||||
endif()
|
||||
add_library(ra3_webgl STATIC)
|
||||
if(OPENRA3_HAS_WEBGL)
|
||||
openra3_embed_glsl(
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/generated/glsl_embedded.hpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_scene_vert.glsl"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_scene_frag.glsl"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_terrain_vert.glsl"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_terrain_frag.glsl"
|
||||
)
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_scene_vert.glsl"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_scene_frag.glsl"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_terrain_vert.glsl"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_terrain_frag.glsl")
|
||||
target_sources(ra3_webgl PUBLIC FILE_SET CXX_MODULES FILES src/webgl/ra3.webgl.cppm)
|
||||
target_include_directories(ra3_webgl PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
|
||||
target_link_libraries(ra3_webgl PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_enderlog openra3_sdl3)
|
||||
# Target WebGL2 so GLES3-only entry points (glTexImage3D, VAOs, ...) link.
|
||||
target_link_options(ra3_webgl PUBLIC -sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2)
|
||||
else()
|
||||
target_sources(ra3_webgl PUBLIC FILE_SET CXX_MODULES FILES src/webgl/ra3.webgl.null.cppm)
|
||||
target_link_libraries(ra3_webgl PUBLIC ra3_core ra3_render ra3_client)
|
||||
endif()
|
||||
openra3_target_defaults(ra3_webgl)
|
||||
|
||||
# --- display backend selection ------------------------------------------------
|
||||
add_library(ra3_display STATIC)
|
||||
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 ra3_webgl)
|
||||
openra3_target_defaults(ra3_display)
|
||||
|
||||
# --- umbrella -----------------------------------------------------------------
|
||||
add_library(ra3 STATIC)
|
||||
target_sources(ra3 PUBLIC FILE_SET CXX_MODULES FILES src/ra3.cppm)
|
||||
target_link_libraries(ra3 PUBLIC ra3_core ra3_logic ra3_client ra3_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_webgl ra3_enderlog)
|
||||
openra3_target_defaults(ra3)
|
||||
|
||||
# --- executable ---------------------------------------------------------------
|
||||
@@ -230,10 +368,96 @@ openra3_target_defaults(openra3)
|
||||
if(WIN32 AND OPENRA3_SDL3_ROOT)
|
||||
add_custom_command(TARGET openra3 POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${OPENRA3_SDL3_ROOT}/x86_64-w64-mingw32/bin/SDL3.dll"
|
||||
"${OPENRA3_SDL3_ROOT}/${OPENRA3_SDL3_MINGW_TRIPLE}/bin/SDL3.dll"
|
||||
"$<TARGET_FILE_DIR:openra3>")
|
||||
endif()
|
||||
|
||||
if(EMSCRIPTEN)
|
||||
# Emit a ready-to-serve openra3.html that loads the module and drives the
|
||||
# #canvas element SDL3 renders into.
|
||||
set_target_properties(openra3 PROPERTIES SUFFIX ".html")
|
||||
target_link_options(openra3 PRIVATE
|
||||
"--shell-file=${CMAKE_CURRENT_SOURCE_DIR}/apps/web/shell.html"
|
||||
-sFORCE_FILESYSTEM=1)
|
||||
# Preload a compact assets directory into the virtual filesystem at /assets,
|
||||
# where the runtime looks next to the module. Prefer a per-map set (one map +
|
||||
# the tiles it uses, listed by `openra3 textures --map ID`): browsers forbid
|
||||
# synchronous on-demand file reads on the main thread, so bundling a small
|
||||
# set is the portable choice.
|
||||
set(OPENRA3_WEB_ASSETS "" CACHE PATH "Assets directory preloaded into the wasm FS at /assets")
|
||||
if(OPENRA3_WEB_ASSETS AND EXISTS "${OPENRA3_WEB_ASSETS}")
|
||||
target_link_options(openra3 PRIVATE "--preload-file=${OPENRA3_WEB_ASSETS}@/assets")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# --- packaging (.deb / .msi / portable archives) -----------------------------
|
||||
# CPack produces the release artifacts in one shot:
|
||||
# Linux -> DEB (dependencies derived from the binary with dpkg-shlibdeps)
|
||||
# plus a portable .tar.gz
|
||||
# Windows -> a Windows Installer .msi (built with wixl from msitools when the
|
||||
# WiX tools are absent, so the Linux cross image can make it) plus a
|
||||
# portable .zip with the exe and SDL3.dll
|
||||
# OPENRA3_DISTRO_TAG (e.g. ubuntu26.04) is appended to the Linux file name so the
|
||||
# per-distro images do not collide.
|
||||
install(TARGETS openra3 RUNTIME DESTINATION bin)
|
||||
if(WIN32 AND OPENRA3_SDL3_ROOT)
|
||||
install(FILES "${OPENRA3_SDL3_ROOT}/${OPENRA3_SDL3_MINGW_TRIPLE}/bin/SDL3.dll" DESTINATION bin)
|
||||
endif()
|
||||
|
||||
set(OPENRA3_DISTRO_TAG "" CACHE STRING "Distro tag appended to package file names (e.g. ubuntu26.04)")
|
||||
set(CPACK_PACKAGE_NAME "openra3")
|
||||
set(CPACK_PACKAGE_VENDOR "OpenRA3")
|
||||
set(CPACK_PACKAGE_CONTACT "OpenRA3 <noreply@openra3.invalid>")
|
||||
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "OpenRA3 - a from-scratch reimplementation of Red Alert 3")
|
||||
set(CPACK_PACKAGE_HOMEPAGE_URL "https://git.ender.cool/EnderTheCoder/openra3")
|
||||
set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}")
|
||||
set(CPACK_PACKAGE_EXECUTABLES "openra3" "OpenRA3")
|
||||
set(CPACK_STRIP_FILES ON)
|
||||
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/LICENSE")
|
||||
set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_CURRENT_SOURCE_DIR}/LICENSE")
|
||||
endif()
|
||||
|
||||
if(WIN32)
|
||||
if(CMAKE_SYSTEM_PROCESSOR STREQUAL "aarch64")
|
||||
set(OPENRA3_WIN_ARCH "arm64")
|
||||
else()
|
||||
set(OPENRA3_WIN_ARCH "x86_64")
|
||||
endif()
|
||||
set(CPACK_PACKAGE_FILE_NAME "openra3-${PROJECT_VERSION}-windows-${OPENRA3_WIN_ARCH}")
|
||||
# Portable .zip (exe + SDL3.dll).
|
||||
set(CPACK_GENERATOR "ZIP")
|
||||
|
||||
# A Windows Installer (.msi) straight from the Linux cross build via wixl
|
||||
# (msitools). wixl 0.106 has no arm64 support, so the MSI is offered for
|
||||
# x86_64 and ARM64 ships the portable zip.
|
||||
find_program(OPENRA3_WIXL wixl)
|
||||
if(OPENRA3_WIXL AND NOT CMAKE_SYSTEM_PROCESSOR STREQUAL "aarch64")
|
||||
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/cmake/packaging/openra3.wxs.in"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/openra3.wxs" @ONLY)
|
||||
add_custom_target(openra3_msi
|
||||
COMMAND "${OPENRA3_WIXL}" --arch x64
|
||||
-D "exe=$<TARGET_FILE:openra3>"
|
||||
-D "dll=${OPENRA3_SDL3_ROOT}/${OPENRA3_SDL3_MINGW_TRIPLE}/bin/SDL3.dll"
|
||||
-o "$<TARGET_FILE_DIR:openra3>/${CPACK_PACKAGE_FILE_NAME}.msi"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/openra3.wxs"
|
||||
DEPENDS openra3
|
||||
COMMENT "Building Windows Installer (${CPACK_PACKAGE_FILE_NAME}.msi) with wixl"
|
||||
VERBATIM)
|
||||
endif()
|
||||
else()
|
||||
if(CMAKE_SYSTEM_PROCESSOR STREQUAL "aarch64")
|
||||
set(CPACK_DEBIAN_PACKAGE_ARCHITECTURE "arm64")
|
||||
else()
|
||||
set(CPACK_DEBIAN_PACKAGE_ARCHITECTURE "amd64")
|
||||
endif()
|
||||
set(CPACK_PACKAGE_FILE_NAME "openra3_${PROJECT_VERSION}_${CPACK_DEBIAN_PACKAGE_ARCHITECTURE}${OPENRA3_DISTRO_TAG}")
|
||||
set(CPACK_GENERATOR "TGZ;DEB")
|
||||
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
|
||||
set(CPACK_DEBIAN_PACKAGE_SECTION "games")
|
||||
set(CPACK_DEBIAN_PACKAGE_PRIORITY "optional")
|
||||
endif()
|
||||
include(CPack)
|
||||
|
||||
# --- extracted assets ---------------------------------------------------------
|
||||
# At build time, extract the retail assets into <exe_dir>/assets so the
|
||||
# executable is self-contained (no --game-dir at run time). This drives the
|
||||
|
||||
+5
-2
@@ -29,6 +29,8 @@ RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
clang-21 \
|
||||
clang-tools-21 \
|
||||
cmake \
|
||||
dpkg-dev \
|
||||
file \
|
||||
gdb \
|
||||
git \
|
||||
libc++-21-dev \
|
||||
@@ -45,9 +47,10 @@ ENV CXX=clang++-21
|
||||
WORKDIR /work
|
||||
COPY . .
|
||||
|
||||
RUN cmake -S . -B build/linux -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||
RUN cmake -S . -B build/linux -G Ninja -DCMAKE_BUILD_TYPE=Release -DOPENRA3_DISTRO_TAG=ubuntu26.04 \
|
||||
&& cmake --build build/linux -j \
|
||||
&& ctest --test-dir build/linux --output-on-failure
|
||||
&& ctest --test-dir build/linux --output-on-failure \
|
||||
&& (cd build/linux && cpack -G DEB)
|
||||
|
||||
# deploy target: runtime dependencies + final binary, minimal and fast.
|
||||
FROM ubuntu:26.04 AS deploy
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Debian build environment (latest stable, Debian 13 "trixie") producing a .deb
|
||||
# alongside the binary. Separate from Dockerfile (Ubuntu 26.04) because the
|
||||
# distro and its libc++/SDL3 versions differ, and a .deb's dependencies must be
|
||||
# generated against the distro it targets.
|
||||
#
|
||||
# Toolchain: clang-19 + libc++-19 (for `import std;`). Debian 13 ships CMake
|
||||
# 3.31, whose experimental `import std` gate value the project does not target,
|
||||
# so an upstream CMake is installed from the Kitware tarball.
|
||||
|
||||
FROM debian:stable AS dev
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ARG APT_MIRROR=""
|
||||
RUN if [ -n "$APT_MIRROR" ]; then \
|
||||
sed -i -e "s|http://deb.debian.org/debian|${APT_MIRROR}|g" \
|
||||
-e "s|http://security.debian.org/debian-security|${APT_MIRROR}|g" \
|
||||
/etc/apt/sources.list /etc/apt/sources.list.d/*.sources 2>/dev/null || true; \
|
||||
fi
|
||||
|
||||
RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
for attempt in 1 2 3 4 5; do \
|
||||
apt-get -o Acquire::Retries=5 update && break; \
|
||||
echo "apt-get update failed (attempt $attempt), retrying"; \
|
||||
sleep 5; \
|
||||
done \
|
||||
&& apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
clang-19 \
|
||||
clang-tools-19 \
|
||||
curl \
|
||||
dpkg-dev \
|
||||
file \
|
||||
libc++-19-dev \
|
||||
libc++abi-19-dev \
|
||||
libsdl3-dev \
|
||||
libunwind-19-dev \
|
||||
lld-19 \
|
||||
ninja-build \
|
||||
pkg-config \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Upstream CMake, because Debian 13's 3.31 experimental gate does not enable the
|
||||
# `import std;` support the project relies on.
|
||||
ARG CMAKE_VERSION=4.2.3
|
||||
RUN mkdir -p /opt/cmake \
|
||||
&& curl -fL "https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}-linux-x86_64.tar.gz" \
|
||||
| tar -xz -C /opt/cmake --strip-components=1
|
||||
ENV PATH="/opt/cmake/bin:${PATH}"
|
||||
|
||||
ENV CC=clang-19
|
||||
ENV CXX=clang++-19
|
||||
|
||||
WORKDIR /work
|
||||
COPY . .
|
||||
|
||||
RUN cmake -S . -B build/debian -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_CXX_STDLIB_MODULES_JSON=/usr/lib/llvm-19/lib/libc++.modules.json \
|
||||
-DOPENRA3_DISTRO_TAG=debian13 \
|
||||
&& cmake --build build/debian -j \
|
||||
&& ctest --test-dir build/debian --output-on-failure \
|
||||
&& (cd build/debian && cpack -G DEB)
|
||||
|
||||
# deploy target: runtime dependencies + final binary, minimal and fast.
|
||||
FROM debian:stable AS deploy
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
apt-get -o Acquire::Retries=5 update \
|
||||
&& apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
|
||||
libc++1-19 \
|
||||
libc++abi1-19 \
|
||||
libsdl3-0 \
|
||||
libvulkan1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=dev /work/build/debian/bin/openra3 /app/openra3
|
||||
|
||||
ENTRYPOINT ["/app/openra3"]
|
||||
@@ -0,0 +1,86 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Debian build environment for Linux on ARM64 (aarch64-linux-gnu), producing an
|
||||
# arm64 .deb. Cross-compiled from amd64 with Debian's clang-19 + libc++-19 and
|
||||
# the arm64 packages installed via dpkg multiarch.
|
||||
|
||||
FROM debian:stable AS dev
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ARG APT_MIRROR=""
|
||||
RUN if [ -n "$APT_MIRROR" ]; then \
|
||||
sed -i -e "s|http://deb.debian.org/debian|${APT_MIRROR}|g" \
|
||||
-e "s|http://security.debian.org/debian-security|${APT_MIRROR}|g" \
|
||||
/etc/apt/sources.list /etc/apt/sources.list.d/*.sources 2>/dev/null || true; \
|
||||
fi
|
||||
|
||||
RUN dpkg --add-architecture arm64
|
||||
|
||||
RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
for attempt in 1 2 3 4 5; do \
|
||||
apt-get -o Acquire::Retries=5 update && break; \
|
||||
echo "apt-get update failed (attempt $attempt), retrying"; \
|
||||
sleep 5; \
|
||||
done \
|
||||
&& apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
clang-19 \
|
||||
clang-tools-19 \
|
||||
curl \
|
||||
dpkg-dev \
|
||||
file \
|
||||
gcc-aarch64-linux-gnu \
|
||||
libc++-19-dev:arm64 \
|
||||
libc++abi-19-dev:arm64 \
|
||||
libsdl3-dev:arm64 \
|
||||
libunwind-19-dev:arm64 \
|
||||
lld-19 \
|
||||
ninja-build \
|
||||
pkg-config \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ARG CMAKE_VERSION=4.2.3
|
||||
RUN mkdir -p /opt/cmake \
|
||||
&& curl -fL "https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}-linux-x86_64.tar.gz" \
|
||||
| tar -xz -C /opt/cmake --strip-components=1
|
||||
ENV PATH="/opt/cmake/bin:${PATH}"
|
||||
|
||||
ENV CC=clang-19
|
||||
ENV CXX=clang++-19
|
||||
# Point pkg-config at the arm64 packages so SDL3 resolves to the aarch64 build.
|
||||
ENV PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig
|
||||
|
||||
WORKDIR /work
|
||||
COPY . .
|
||||
|
||||
RUN cmake -S . -B build/debian-arm64 -G Ninja \
|
||||
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/clang-aarch64-linux-gnu.cmake \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DOPENRA3_AARCH64_CC=clang-19 \
|
||||
-DOPENRA3_AARCH64_CXX=clang++-19 \
|
||||
-DOPENRA3_AARCH64_STDLIB_MODULES_JSON=/usr/lib/llvm-19/lib/libc++.modules.json \
|
||||
-DOPENRA3_DISTRO_TAG=debian13 \
|
||||
&& cmake --build build/debian-arm64 -j \
|
||||
&& (cd build/debian-arm64 && cpack -G DEB)
|
||||
|
||||
FROM --platform=linux/arm64 debian:stable AS deploy
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
apt-get -o Acquire::Retries=5 update \
|
||||
&& apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
|
||||
libc++1-19 \
|
||||
libc++abi1-19 \
|
||||
libsdl3-0 \
|
||||
libvulkan1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=dev /work/build/debian-arm64/bin/openra3 /app/openra3
|
||||
|
||||
ENTRYPOINT ["/app/openra3"]
|
||||
@@ -0,0 +1,84 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Linux on ARM64 cross-build environment (aarch64-linux-gnu), isolated from the
|
||||
# x86_64 Linux image and the Windows images.
|
||||
#
|
||||
# Toolchain: clang-21 + libc++ with the arm64 libc++ (for `import std;`), the
|
||||
# arm64 glibc from `gcc-aarch64-linux-gnu`, arm64 SDL3 from `libsdl3-dev:arm64`,
|
||||
# and lld as the linker. The arm64 packages are installed with dpkg multiarch
|
||||
# (`arm64` added to the sources); the native clang/cmake build tools stay amd64.
|
||||
|
||||
FROM ubuntu:26.04 AS dev
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ARG APT_MIRROR=""
|
||||
RUN if [ -n "$APT_MIRROR" ]; then \
|
||||
sed -i -e "s|http://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|http://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|http://ports.ubuntu.com/ubuntu-ports|${APT_MIRROR}|g" \
|
||||
/etc/apt/sources.list /etc/apt/sources.list.d/*.sources 2>/dev/null || true; \
|
||||
fi
|
||||
|
||||
# Enable the arm64 architecture on top of the emulated amd64 userland.
|
||||
RUN dpkg --add-architecture arm64
|
||||
|
||||
RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
for attempt in 1 2 3 4 5; do \
|
||||
apt-get -o Acquire::Retries=5 update && break; \
|
||||
echo "apt-get update failed (attempt $attempt), retrying"; \
|
||||
sleep 5; \
|
||||
done \
|
||||
&& apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
clang-21 \
|
||||
cmake \
|
||||
dpkg-dev \
|
||||
file \
|
||||
gcc-aarch64-linux-gnu \
|
||||
libc++-21-dev:arm64 \
|
||||
libc++abi-21-dev:arm64 \
|
||||
libsdl3-dev:arm64 \
|
||||
libunwind-21-dev:arm64 \
|
||||
lld-21 \
|
||||
ninja-build \
|
||||
pkg-config \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV CC=clang-21
|
||||
ENV CXX=clang++-21
|
||||
# Point pkg-config at the arm64 packages so SDL3 resolves to the aarch64 build.
|
||||
ENV PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig
|
||||
|
||||
WORKDIR /work
|
||||
COPY . .
|
||||
|
||||
RUN cmake -S . -B build/linux-arm64 -G Ninja \
|
||||
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/clang-aarch64-linux-gnu.cmake \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DOPENRA3_DISTRO_TAG=ubuntu26.04 \
|
||||
&& cmake --build build/linux-arm64 -j \
|
||||
&& (cd build/linux-arm64 && cpack -G DEB)
|
||||
|
||||
# deploy target: runtime dependencies + final binary, minimal and fast. This is
|
||||
# an arm64 image; build it on (or with emulation for) an arm64 host.
|
||||
FROM --platform=linux/arm64 ubuntu:26.04 AS deploy
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
apt-get -o Acquire::Retries=5 update \
|
||||
&& apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
|
||||
libc++1 \
|
||||
libc++abi1 \
|
||||
libsdl3-0 \
|
||||
libvulkan1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=dev /work/build/linux-arm64/bin/openra3 /app/openra3
|
||||
|
||||
ENTRYPOINT ["/app/openra3"]
|
||||
@@ -0,0 +1,46 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# WebAssembly build environment (Emscripten), producing openra3.html/.js/.wasm.
|
||||
#
|
||||
# The emsdk image ships CMake 3.28, whose experimental `import std` support is
|
||||
# too old for the project, so an upstream CMake is layered on top.
|
||||
|
||||
FROM emscripten/emsdk:6.0.10 AS dev
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ARG APT_MIRROR=""
|
||||
RUN if [ -n "$APT_MIRROR" ]; then \
|
||||
sed -i -e "s|http://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|http://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|http://ports.ubuntu.com/ubuntu-ports|${APT_MIRROR}|g" \
|
||||
/etc/apt/sources.list /etc/apt/sources.list.d/*.sources 2>/dev/null || true; \
|
||||
fi
|
||||
|
||||
RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
for attempt in 1 2 3 4 5; do \
|
||||
apt-get -o Acquire::Retries=5 update && break; \
|
||||
echo "apt-get update failed (attempt $attempt), retrying"; \
|
||||
sleep 5; \
|
||||
done \
|
||||
&& apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
ninja-build \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Upstream CMake, because the emsdk image's 3.28 cannot build the project.
|
||||
ARG CMAKE_VERSION=4.2.3
|
||||
RUN mkdir -p /opt/cmake \
|
||||
&& curl -fL "https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}-linux-x86_64.tar.gz" \
|
||||
| tar -xz -C /opt/cmake --strip-components=1
|
||||
ENV PATH="/opt/cmake/bin:${PATH}"
|
||||
|
||||
WORKDIR /work
|
||||
COPY . .
|
||||
|
||||
RUN cmake -S . -B build/wasm -G Ninja \
|
||||
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/emscripten-wasm.cmake \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
&& cmake --build build/wasm -j
|
||||
+4
-1
@@ -31,6 +31,7 @@ RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
curl \
|
||||
ninja-build \
|
||||
python3 \
|
||||
wixl \
|
||||
xz-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -54,7 +55,9 @@ COPY . .
|
||||
RUN cmake -S . -B build/windows -G Ninja \
|
||||
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/llvm-mingw-x86_64.cmake \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
&& cmake --build build/windows -j
|
||||
&& cmake --build build/windows -j \
|
||||
&& cmake --build build/windows --target openra3_msi \
|
||||
&& (cd build/windows && cpack -G ZIP)
|
||||
|
||||
# package target: the .exe plus the SDL3 runtime DLL.
|
||||
FROM ubuntu:26.04 AS package
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Windows on ARM64 cross-build environment (aarch64-w64-mingw32), isolated from
|
||||
# the x86_64 Windows image and the Linux images.
|
||||
#
|
||||
# Toolchain: the same multi-target llvm-mingw as Dockerfile.win (clang + libc++ +
|
||||
# the libc++ `std` module, MSVCRT runtime), targeting aarch64. The official SDL
|
||||
# release ships no MinGW ARM64 dev package, so SDL3 comes from MSYS2's clangarm64
|
||||
# repository, staged into the `<triple>/{include,lib,bin}` layout the toolchain
|
||||
# expects.
|
||||
|
||||
FROM ubuntu:26.04 AS dev
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ARG APT_MIRROR=""
|
||||
RUN if [ -n "$APT_MIRROR" ]; then \
|
||||
sed -i -e "s|http://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|http://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|http://ports.ubuntu.com/ubuntu-ports|${APT_MIRROR}|g" \
|
||||
/etc/apt/sources.list /etc/apt/sources.list.d/*.sources 2>/dev/null || true; \
|
||||
fi
|
||||
|
||||
RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
for attempt in 1 2 3 4 5; do \
|
||||
apt-get -o Acquire::Retries=5 update && break; \
|
||||
echo "apt-get update failed (attempt $attempt), retrying"; \
|
||||
sleep 5; \
|
||||
done \
|
||||
&& apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
cmake \
|
||||
curl \
|
||||
ninja-build \
|
||||
python3 \
|
||||
xz-utils \
|
||||
zstd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pinned toolchain + SDL3 versions (override with --build-arg to bump).
|
||||
ARG LLVM_MINGW_VERSION=20260908
|
||||
ARG LLVM_MINGW_FLAVOR=msvcrt
|
||||
# SDL3 for aarch64-w64-mingw32 from MSYS2's clangarm64 repo (name includes the
|
||||
# MSYS2 package release, hence the `-1`).
|
||||
ARG MSYS2_SDL3_PKG=mingw-w64-clang-aarch64-sdl3-3.4.16-1-any.pkg.tar.zst
|
||||
|
||||
RUN mkdir -p /opt/llvm-mingw \
|
||||
&& curl -fL "https://github.com/mstorsjo/llvm-mingw/releases/download/${LLVM_MINGW_VERSION}/llvm-mingw-${LLVM_MINGW_VERSION}-${LLVM_MINGW_FLAVOR}-ubuntu-22.04-x86_64.tar.xz" \
|
||||
| tar -xJ -C /opt/llvm-mingw --strip-components=1
|
||||
|
||||
RUN mkdir -p /opt/sdl3-aarch64/aarch64-w64-mingw32/include /opt/sdl3-aarch64/aarch64-w64-mingw32/lib /opt/sdl3-aarch64/aarch64-w64-mingw32/bin \
|
||||
&& curl -fL "https://repo.msys2.org/mingw/clangarm64/${MSYS2_SDL3_PKG}" -o /tmp/sdl3.pkg.tar.zst \
|
||||
&& mkdir -p /tmp/sdl3 && tar --zstd -xf /tmp/sdl3.pkg.tar.zst -C /tmp/sdl3 \
|
||||
&& cp -r /tmp/sdl3/clangarm64/include/SDL3 /opt/sdl3-aarch64/aarch64-w64-mingw32/include/ \
|
||||
&& cp /tmp/sdl3/clangarm64/lib/libSDL3.dll.a /opt/sdl3-aarch64/aarch64-w64-mingw32/lib/ \
|
||||
&& cp /tmp/sdl3/clangarm64/bin/SDL3.dll /opt/sdl3-aarch64/aarch64-w64-mingw32/bin/ \
|
||||
&& rm -rf /tmp/sdl3 /tmp/sdl3.pkg.tar.zst
|
||||
|
||||
ENV OPENRA3_LLVM_MINGW_ROOT=/opt/llvm-mingw
|
||||
ENV OPENRA3_SDL3_ROOT=/opt/sdl3-aarch64
|
||||
|
||||
WORKDIR /work
|
||||
COPY . .
|
||||
|
||||
RUN cmake -S . -B build/windows-arm64 -G Ninja \
|
||||
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/llvm-mingw-aarch64.cmake \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
&& cmake --build build/windows-arm64 -j \
|
||||
&& (cd build/windows-arm64 && cpack -G ZIP)
|
||||
|
||||
# package target: the .exe plus the SDL3 runtime DLL.
|
||||
FROM ubuntu:26.04 AS package
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=dev /work/build/windows-arm64/bin/openra3.exe /app/
|
||||
COPY --from=dev /work/build/windows-arm64/bin/SDL3.dll /app/
|
||||
@@ -18,14 +18,16 @@ Ghidra.
|
||||
|
||||
## Status
|
||||
|
||||
OpenRA3 is at **v0.3.0**. The engine compiles and runs headless, and a **minimal
|
||||
OpenRA3 is at **v0.6.0**. The engine compiles and runs headless, and a **minimal
|
||||
skirmish** is playable: it reads a real multiplayer map out of your install,
|
||||
recovers the player start waypoints, and simulates two sides building a base,
|
||||
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:
|
||||
damage types, `ArmorTemplate` percentages, weapon target masks, build costs and
|
||||
the ore economy. Presentation is a **Vulkan** backend (`ra3.vulkan`) with a null
|
||||
fallback; the software renderer still produces headless images. Terrain tiles
|
||||
the ore economy. Presentation offers **Vulkan** (`ra3.vulkan`), **Direct3D 11 / 12**
|
||||
(`ra3.dx`) and **WebGL 2** (`ra3.webgl`, the wasm build) 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
|
||||
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
|
||||
@@ -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/logic/ra3.logic.cppm` | objects, players, teams, spatial partition, game loop |
|
||||
| `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/WebGL, then SDL) for the app |
|
||||
| `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/fs/ra3.fs.cppm` | `BIG4` archives, RefPack codec, local install locator |
|
||||
@@ -93,11 +95,14 @@ 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/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/dx/ra3.dx.*.cppm` | Direct3D 11/12 presentation backend (runtime HLSL; null fallback off Windows) |
|
||||
| `src/webgl/ra3.webgl.*.cppm` | WebGL2 presentation backend for the wasm build (GLSL ES; null fallback off Emscripten) |
|
||||
| `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 |
|
||||
| `apps/openra3/main.cpp` | `menu` / `maps` / `skirmish` / `render` CLI |
|
||||
| `tests/ra3_tests.cpp` | smoke tests (run via `ctest`) |
|
||||
| `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.win` | isolated Windows cross-build image (llvm-mingw + SDL3 MinGW) |
|
||||
| `cmake/toolchains/` | `llvm-mingw-x86_64.cmake` cross toolchain |
|
||||
@@ -118,13 +123,32 @@ their compilers and standard libraries never interfere.
|
||||
The host needs no toolchain: each target builds inside its own image.
|
||||
|
||||
```bash
|
||||
# Linux -> build/linux/bin/openra3
|
||||
# Linux (amd64) -> build/linux/bin/openra3 + .deb
|
||||
scripts/build-linux.sh
|
||||
|
||||
# Windows -> build/windows/bin/openra3.exe (+ SDL3.dll)
|
||||
# Linux/arm64 -> build/linux-arm64/bin/openra3 + .deb (cross)
|
||||
scripts/build-linux-arm64.sh
|
||||
|
||||
# Debian (amd64) -> build/debian/bin/openra3 + .deb
|
||||
scripts/build-debian.sh
|
||||
|
||||
# Debian (arm64) -> build/debian-arm64/bin/openra3 + .deb (cross)
|
||||
scripts/build-debian-arm64.sh
|
||||
|
||||
# Windows (x86_64) -> build/windows/bin/openra3.exe + SDL3.dll, .zip, .msi
|
||||
scripts/build-windows.sh
|
||||
|
||||
# Windows (arm64) -> build/windows-arm64/bin/openra3.exe + SDL3.dll, .zip (cross)
|
||||
scripts/build-windows-arm64.sh
|
||||
|
||||
# WebAssembly -> build/wasm/bin/openra3.html (+ .js/.wasm)
|
||||
scripts/build-wasm.sh
|
||||
```
|
||||
|
||||
Every target is cross-built from x86_64 Linux in its own image: llvm-mingw for
|
||||
the Windows targets, clang + libc++ with dpkg multiarch for the arm64 targets,
|
||||
and Debian/Ubuntu-specific images for the `.deb` packages.
|
||||
|
||||
Or drive Docker directly:
|
||||
|
||||
```bash
|
||||
@@ -156,6 +180,67 @@ openra3.exe render --game-dir "C:\Red Alert 3" --vulkan
|
||||
Vulkan is provided by **vendored volk + headers** (`third_party/`), resolved at
|
||||
runtime, so neither image needs a Vulkan SDK.
|
||||
|
||||
## WebAssembly
|
||||
|
||||
OpenRA3 also builds to **WebAssembly** with Emscripten (`scripts/build-wasm.sh`),
|
||||
producing `openra3.html` + `.js` + `.wasm`. The browser gets the same GPU renderer
|
||||
as the desktop builds: a **`ra3.webgl`** backend peers with Vulkan/Direct3D — SDL3
|
||||
provides the canvas and input, and GLES 3.0 / WebGL2 runs the 2D blit and the
|
||||
terrain raymarcher (GLSL ES ports of the desktop shaders).
|
||||
|
||||
Browsers forbid synchronous on-demand file reads on the main thread, so the
|
||||
assets are **preloaded into the module's filesystem** at build time. Bundle a
|
||||
compact per-map set (one map plus the tiles it uses) rather than the whole dump:
|
||||
|
||||
```bash
|
||||
# 1. which loose TGAs does the map resolve to?
|
||||
openra3 textures --map map_mp_2_feasel1 # prints paths under assets/terrain
|
||||
|
||||
# 2. stage one map + those tiles (+ maps/map_names.tsv) into a directory, then
|
||||
OPENRA3_WEB_ASSETS=/path/to/that/set scripts/build-wasm.sh
|
||||
|
||||
# 3. serve the build output (a Range-capable server is included)
|
||||
python3 apps/web/serve.py --root build/wasm/bin --port 8199
|
||||
# open http://localhost:8199/openra3.html
|
||||
```
|
||||
|
||||
The engine's frame loops are blocking; the wasm build yields to the browser via
|
||||
Asyncify (`display::sleep_frame` calls `emscripten_sleep`), so the page repaints
|
||||
and handles input. Log records go to `console.log` at their real level (the
|
||||
console sink uses stdout on the web instead of stderr). Without
|
||||
`OPENRA3_WEB_ASSETS` the module still loads and starts, but exits at the menu
|
||||
because no maps are found.
|
||||
|
||||
## Packages
|
||||
|
||||
CPack produces the release artifacts; the Docker images and CI run it for every
|
||||
target.
|
||||
|
||||
| Target | Portable | Installer |
|
||||
| --- | --- | --- |
|
||||
| Linux amd64 (Ubuntu 26.04) | `.tar.gz` | `openra3_<v>_amd64ubuntu26.04.deb` |
|
||||
| Linux arm64 (Ubuntu 26.04) | `.tar.gz` | `openra3_<v>_arm64ubuntu26.04.deb` |
|
||||
| Linux amd64 (Debian 13) | `.tar.gz` | `openra3_<v>_amd64debian13.deb` |
|
||||
| Linux arm64 (Debian 13) | `.tar.gz` | `openra3_<v>_arm64debian13.deb` |
|
||||
| Windows x86_64 | `openra3-<v>-windows-x86_64.zip` | `openra3-<v>-windows-x86_64.msi` |
|
||||
| Windows arm64 | `openra3-<v>-windows-arm64.zip` | (WiX-only; see below) |
|
||||
|
||||
The `.deb` dependencies are derived from the binary with `dpkg-shlibdeps`. The
|
||||
`.msi` is built with **wixl** (msitools) straight from the Linux cross image;
|
||||
wixl 0.106 has no arm64 support, so Windows/ARM64 ships the portable `.zip`
|
||||
(the MSI toolchain for ARM64 would be WiX v4 via the .NET 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
|
||||
|
||||
```bash
|
||||
|
||||
+199
-12
@@ -1,7 +1,112 @@
|
||||
#if defined(_WIN32)
|
||||
#define NOMINMAX
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
import std;
|
||||
import ra3;
|
||||
import ender.log;
|
||||
|
||||
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});
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
// The default sink writes to stderr, which the browser maps to
|
||||
// console.error regardless of level; use stdout so INFO/WARN appear at
|
||||
// their real level. There is no file sink on the web.
|
||||
log::set_sinks({std::make_shared<log::console_sink>(std::cout)});
|
||||
(void) exe_dir;
|
||||
#else
|
||||
log::add_file_sink(exe_dir / "openra3.log", {.max_file_size = 4U * 1024U * 1024U, .max_archives = 10U});
|
||||
#endif
|
||||
log::info("OpenRA3 started");
|
||||
}
|
||||
|
||||
auto print_usage() -> void {
|
||||
std::puts("OpenRA3 - Red Alert 3 reconstruction");
|
||||
std::puts("usage:");
|
||||
@@ -9,12 +114,14 @@ namespace {
|
||||
std::puts(" openra3 menu (pick a map, then view it)");
|
||||
std::puts(" openra3 menu-preview [--out FILE.bmp] (headless render of the menu)");
|
||||
std::puts(" openra3 maps");
|
||||
std::puts(" openra3 textures --map ID (loose terrain TGAs a map uses)");
|
||||
std::puts(" openra3 skirmish [--map ID] [--frames N] [--seed N]");
|
||||
std::puts(" openra3 render [--map ID] [--frames N] [--seed N] [--zoom Z]");
|
||||
std::puts(" [--out FILE.bmp] [--vulkan] [--no-window] [--no-sim] [--thumbnail]");
|
||||
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(" [--fullscreen] [--fps N] (0 = vsync)");
|
||||
std::puts(" [--vulkan] [--dx11] [--dx12] [--webgl] [--sdl] (preferred display backend)");
|
||||
std::puts("assets: read from <exe_dir>/assets (extracted at build time by the openra3_assets target)");
|
||||
}
|
||||
|
||||
@@ -29,6 +136,15 @@ namespace {
|
||||
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, "--webgl")) return ra3::display::backend::webgl;
|
||||
if (has_flag(args, "--sdl")) return ra3::display::backend::sdl;
|
||||
return ra3::display::backend::vulkan;
|
||||
}
|
||||
|
||||
/** Directory the executable lives in. */
|
||||
auto executable_dir(const char *argv0) -> std::filesystem::path {
|
||||
std::error_code ec;
|
||||
@@ -190,6 +306,32 @@ namespace {
|
||||
return ready;
|
||||
}
|
||||
|
||||
/** Print the loose terrain TGA files a map resolves to (for a wasm preload). */
|
||||
auto command_textures(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
|
||||
if (!ensure_assets(assets)) return 1;
|
||||
const auto requested = option_value(args, "--map");
|
||||
if (!requested) {
|
||||
std::puts("usage: openra3 textures --map ID");
|
||||
return 2;
|
||||
}
|
||||
const auto maps = list_asset_maps(assets);
|
||||
const asset_map *picked = nullptr;
|
||||
for (const auto &map: maps) {
|
||||
if (map.id == *requested) picked = ↦
|
||||
}
|
||||
if (picked == nullptr) {
|
||||
std::printf("no map '%s'\n", requested->c_str());
|
||||
return 2;
|
||||
}
|
||||
const auto parsed = ra3::terrain::parse_map(ra3::map::to_ckmp(read_file(picked->file)));
|
||||
const auto files = ra3::terrain::resolve_texture_files(parsed, assets / "terrain");
|
||||
std::printf("map %s: %zu resolved textures\n", picked->id.c_str(), files.size());
|
||||
for (const auto &file: files) {
|
||||
std::printf("%s\n", file.string().c_str());
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto command_maps(const std::filesystem::path &assets) -> int {
|
||||
if (!ensure_assets(assets)) return 1;
|
||||
const auto maps = list_asset_maps(assets);
|
||||
@@ -328,7 +470,14 @@ namespace {
|
||||
options.height = static_cast<int>(height);
|
||||
options.fullscreen = has_flag(args, "--fullscreen");
|
||||
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;
|
||||
std::printf("GPU terrain unavailable; showing the top-down map instead\n");
|
||||
}
|
||||
@@ -409,7 +558,7 @@ namespace {
|
||||
viewer.height = static_cast<int>(composed.height());
|
||||
viewer.fullscreen = has_flag(args, "--fullscreen");
|
||||
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) {
|
||||
const auto path = out.value_or("openra3_view.bmp");
|
||||
@@ -448,6 +597,7 @@ namespace {
|
||||
bool thumbnail = false;
|
||||
bool fullscreen = false;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -455,6 +605,20 @@ namespace {
|
||||
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"};
|
||||
|
||||
/**
|
||||
* 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, 5> renderer_backends{ra3::display::backend::vulkan, ra3::display::backend::d3d11,
|
||||
ra3::display::backend::d3d12, ra3::display::backend::webgl,
|
||||
ra3::display::backend::sdl};
|
||||
inline constexpr std::array<std::string_view, 5> renderer_names{"Vulkan", "Direct3D 11", "Direct3D 12", "WebGL", "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_height = 720;
|
||||
|
||||
@@ -479,7 +643,7 @@ namespace {
|
||||
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 {
|
||||
switch (field) {
|
||||
@@ -501,6 +665,7 @@ namespace {
|
||||
case 15: return "Fullscreen";
|
||||
case 16: return "Frame rate";
|
||||
case 17: return "Play";
|
||||
case 18: return "Renderer";
|
||||
default: return "Quit";
|
||||
}
|
||||
}
|
||||
@@ -533,6 +698,7 @@ namespace {
|
||||
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 17: return "start";
|
||||
case 18: return std::string{renderer_names[static_cast<std::size_t>(std::clamp(s.renderer, 0, 4))]};
|
||||
default: return "exit";
|
||||
}
|
||||
}
|
||||
@@ -555,6 +721,7 @@ namespace {
|
||||
case 13: s.thumbnail = !s.thumbnail; break;
|
||||
case 15: s.fullscreen = !s.fullscreen; break;
|
||||
case 16: s.fps_index = (s.fps_index + delta + 5) % 5; break;
|
||||
case 18: s.renderer = (s.renderer + delta + 5) % 5; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
@@ -589,7 +756,7 @@ namespace {
|
||||
L.rows = std::max(1, (L.list_h - L.S(46)) / L.row_h);
|
||||
L.set_x = L.S(650);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -724,9 +891,9 @@ namespace {
|
||||
st.field = field;
|
||||
if (field == 17) {
|
||||
st.action = menu_action::play;
|
||||
} else if (field == 18) {
|
||||
} else if (field == 19) {
|
||||
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);
|
||||
st.dirty = true;
|
||||
} else if (field == 14) {
|
||||
@@ -783,9 +950,9 @@ namespace {
|
||||
case ui_key::confirm:
|
||||
if (st.pane == 0 || st.field == 17) {
|
||||
st.action = menu_action::play;
|
||||
} else if (st.field == 18) {
|
||||
} else if (st.field == 19) {
|
||||
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);
|
||||
st.dirty = true;
|
||||
}
|
||||
@@ -951,6 +1118,11 @@ namespace {
|
||||
if (has_flag(args, "--thumbnail")) st.settings.thumbnail = true;
|
||||
if (has_flag(args, "--fullscreen")) st.settings.fullscreen = true;
|
||||
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, "--webgl")) st.settings.renderer = 3;
|
||||
if (has_flag(args, "--sdl")) st.settings.renderer = 4;
|
||||
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, "--cam-pitch")) st.settings.cam_pitch = std::stof(*value);
|
||||
@@ -978,7 +1150,9 @@ namespace {
|
||||
options.height = menu_height;
|
||||
options.fullscreen = st.settings.fullscreen;
|
||||
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 (!windowed) return console_menu(assets, maps, names, st.settings);
|
||||
return 0;
|
||||
@@ -1006,9 +1180,16 @@ namespace {
|
||||
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))];
|
||||
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();
|
||||
options.fullscreen = st.settings.fullscreen;
|
||||
options.fps_limit = want_fps;
|
||||
@@ -1071,8 +1252,13 @@ namespace {
|
||||
}
|
||||
|
||||
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 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);
|
||||
|
||||
@@ -1095,6 +1281,7 @@ auto main(int argc, char **argv) -> int {
|
||||
if (command == "menu") return command_menu(rest, assets);
|
||||
if (command == "menu-preview") return command_menu_preview(rest, assets);
|
||||
if (command == "maps") return command_maps(assets);
|
||||
if (command == "textures") return command_textures(rest, assets);
|
||||
if (command == "skirmish") return command_skirmish(rest, assets);
|
||||
if (command == "render") return command_render(rest, assets);
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static file server with HTTP Range support, for the OpenRA3 wasm build.
|
||||
|
||||
Emscripten's lazy MEMFS reads assets back as byte ranges, so the server must
|
||||
honour the ``Range`` header -- Python's stdlib ``http.server`` does not.
|
||||
|
||||
python3 apps/web/serve.py --root build/wasm/bin --port 8199
|
||||
# then open http://localhost:8199/openra3.html
|
||||
|
||||
Serve ``--root`` containing ``openra3.html/.js/.wasm``, ``assets.manifest.json``
|
||||
and the extracted ``assets/`` tree (a directory or a junction/symlink to it).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
_RANGE = re.compile(r"bytes=(\d*)-(\d*)$")
|
||||
|
||||
|
||||
class RangeHandler(SimpleHTTPRequestHandler):
|
||||
# SharedArrayBuffer (needed by the pthreads build) requires cross-origin
|
||||
# isolation.
|
||||
def end_headers(self):
|
||||
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
|
||||
self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
|
||||
super().end_headers()
|
||||
|
||||
def send_head(self):
|
||||
header = self.headers.get("Range")
|
||||
if not header:
|
||||
return super().send_head()
|
||||
|
||||
path = self.translate_path(self.path)
|
||||
if os.path.isdir(path):
|
||||
return super().send_head()
|
||||
try:
|
||||
handle = open(path, "rb")
|
||||
except OSError:
|
||||
self.send_error(404, "File not found")
|
||||
return None
|
||||
|
||||
match = _RANGE.match(header.strip())
|
||||
if not match:
|
||||
handle.close()
|
||||
return super().send_head()
|
||||
|
||||
size = os.fstat(handle.fileno()).st_size
|
||||
start = int(match.group(1)) if match.group(1) else 0
|
||||
end = int(match.group(2)) if match.group(2) else size - 1
|
||||
if start > end or start >= size:
|
||||
handle.close()
|
||||
self.send_error(416, "Requested Range Not Satisfiable")
|
||||
return None
|
||||
end = min(end, size - 1)
|
||||
|
||||
self.send_response(206)
|
||||
self.send_header("Content-Type", self.guess_type(path))
|
||||
self.send_header("Accept-Ranges", "bytes")
|
||||
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
||||
self.send_header("Content-Length", str(end - start + 1))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
handle.seek(start)
|
||||
self._remaining = end - start + 1
|
||||
return handle
|
||||
|
||||
def copyfile(self, source, outputfile):
|
||||
remaining = getattr(self, "_remaining", None)
|
||||
if remaining is None:
|
||||
return super().copyfile(source, outputfile)
|
||||
try:
|
||||
while remaining > 0:
|
||||
chunk = source.read(min(65536, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
outputfile.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
finally:
|
||||
self._remaining = None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="OpenRA3 wasm dev server")
|
||||
parser.add_argument("--root", default="build/wasm/bin", help="directory to serve")
|
||||
parser.add_argument("--port", type=int, default=8199)
|
||||
parser.add_argument("--bind", default="127.0.0.1")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.isdir(args.root):
|
||||
print(f"root not found: {args.root}", file=sys.stderr)
|
||||
return 1
|
||||
handler = functools.partial(RangeHandler, directory=args.root)
|
||||
server = ThreadingHTTPServer((args.bind, args.port), handler)
|
||||
print(f"serving {args.root} at http://{args.bind}:{args.port}/openra3.html")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<!-- Emscripten shell for OpenRA3. SDL3 (built for Emscripten) renders into the
|
||||
canvas element below; the SCRIPT placeholder is replaced by the generated
|
||||
loader. Note: the shell preprocessor treats a leading hash as a directive,
|
||||
so do not begin a line with one. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>OpenRA3</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #1a0a08; overflow: hidden; }
|
||||
#canvas { display: block; width: 100vw; height: 100vh; outline: none; }
|
||||
#loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: #ecbe50; font: 16px/1.4 monospace; pointer-events: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas" tabindex="-1" oncontextmenu="event.preventDefault()"></canvas>
|
||||
<div id="loading">Loading OpenRA3…</div>
|
||||
<script>
|
||||
// Emscripten merges this into the generated Module; drop the loading
|
||||
// placeholder once the runtime is up.
|
||||
var Module = {
|
||||
onRuntimeInitialized: function () {
|
||||
var el = document.getElementById('loading');
|
||||
if (el) el.remove();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
{{{ SCRIPT }}}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<!--
|
||||
Windows Installer source for OpenRA3, driven by wixl (msitools) so the Linux
|
||||
cross-build image can produce the .msi. `exe` and `dll` are passed with
|
||||
wixl -D; the version is substituted at configure time.
|
||||
-->
|
||||
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi'>
|
||||
<Product Id='*'
|
||||
Name='OpenRA3'
|
||||
Language='1033'
|
||||
Version='@PROJECT_VERSION@'
|
||||
Manufacturer='OpenRA3'
|
||||
UpgradeCode='6C6B1F4E-0B9A-4C2E-9E2A-2E4D7B5C1A31'>
|
||||
<Package Id='*' InstallerVersion='500' Compressed='yes' InstallScope='perMachine'/>
|
||||
<Media Id='1' Cabinet='openra3.cab' EmbedCab='yes'/>
|
||||
|
||||
<Directory Id='TARGETDIR' Name='SourceDir'>
|
||||
<Directory Id='ProgramFiles64Folder'>
|
||||
<Directory Id='INSTALLFOLDER' Name='OpenRA3'>
|
||||
<Component Id='MainExecutable' Guid='1F2E3D4C-5B6A-4798-8C1D-2E3F4A5B6C7D'>
|
||||
<File Id='OpenRA3Exe' Name='openra3.exe' Source='$(var.exe)' KeyPath='yes'>
|
||||
<Shortcut Id='StartMenuShortcut' Directory='ProgramMenuFolder' Name='OpenRA3'
|
||||
WorkingDirectory='INSTALLFOLDER' Advertise='no'/>
|
||||
</File>
|
||||
</Component>
|
||||
<Component Id='SdlRuntime' Guid='2A3B4C5D-6E7F-4809-9D2E-3F4A5B6C7D8E'>
|
||||
<File Id='Sdl3Dll' Name='SDL3.dll' Source='$(var.dll)' KeyPath='yes'/>
|
||||
</Component>
|
||||
</Directory>
|
||||
</Directory>
|
||||
<Directory Id='ProgramMenuFolder'/>
|
||||
</Directory>
|
||||
|
||||
<Feature Id='Main' Title='OpenRA3' Level='1'>
|
||||
<ComponentRef Id='MainExecutable'/>
|
||||
<ComponentRef Id='SdlRuntime'/>
|
||||
</Feature>
|
||||
</Product>
|
||||
</Wix>
|
||||
@@ -0,0 +1,53 @@
|
||||
# Cross-compile for Linux on ARM64 (aarch64-linux-gnu) from x86_64.
|
||||
#
|
||||
# clang-21 + libc++ (for `import std;`) with the arm64 libc++ and SDL3 taken
|
||||
# from Ubuntu's arm64 packages (installed with dpkg multiarch), and the arm64
|
||||
# glibc headers/libs from `gcc-aarch64-linux-gnu`. lld links the result; the C
|
||||
# multiarch include directory is added explicitly because clang does not always
|
||||
# infer Debian's `bits/` layout from the target triple alone.
|
||||
|
||||
# The `import std` gate must be set before the compiler is probed; a toolchain
|
||||
# file is processed earlier than the project's own top-level settings.
|
||||
set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "d0edc3af-4c50-42ea-a356-e2862fe7a444")
|
||||
|
||||
set(CMAKE_SYSTEM_NAME Linux)
|
||||
set(CMAKE_SYSTEM_PROCESSOR aarch64)
|
||||
|
||||
set(OPENRA3_AARCH64_TRIPLE "aarch64-linux-gnu")
|
||||
# Multiarch paths the arm64 packages install into.
|
||||
set(OPENRA3_AARCH64_INCLUDE "/usr/include/${OPENRA3_AARCH64_TRIPLE}")
|
||||
set(OPENRA3_AARCH64_LIB "/usr/lib/${OPENRA3_AARCH64_TRIPLE}")
|
||||
|
||||
# Compiler + libc++ module description are cache vars so a distro with a
|
||||
# differently-versioned clang (e.g. Debian's clang-19) can override on the
|
||||
# command line.
|
||||
set(OPENRA3_AARCH64_CC "clang-21" CACHE STRING "aarch64 C compiler")
|
||||
set(OPENRA3_AARCH64_CXX "clang++-21" CACHE STRING "aarch64 C++ compiler")
|
||||
set(OPENRA3_AARCH64_STDLIB_MODULES_JSON "/usr/lib/llvm-21/lib/libc++.modules.json" CACHE FILEPATH
|
||||
"libc++ std module description for the aarch64 target")
|
||||
|
||||
set(CMAKE_C_COMPILER ${OPENRA3_AARCH64_CC})
|
||||
set(CMAKE_CXX_COMPILER ${OPENRA3_AARCH64_CXX})
|
||||
set(CMAKE_C_COMPILER_TARGET ${OPENRA3_AARCH64_TRIPLE})
|
||||
set(CMAKE_CXX_COMPILER_TARGET ${OPENRA3_AARCH64_TRIPLE})
|
||||
# CMake's standard-library detection does not pass COMPILER_TARGET, so without
|
||||
# this it probes the host standard library.
|
||||
set(CMAKE_C_COMPILER_ARG1 "--target=${OPENRA3_AARCH64_TRIPLE}")
|
||||
set(CMAKE_CXX_COMPILER_ARG1 "--target=${OPENRA3_AARCH64_TRIPLE}")
|
||||
|
||||
# libc++ standard-library module description for the aarch64 target. On a
|
||||
# multiarch image only the arm64 libc++ is installed, so the standard path
|
||||
# already resolves to the aarch64 module description.
|
||||
set(CMAKE_CXX_STDLIB_MODULES_JSON "${OPENRA3_AARCH64_STDLIB_MODULES_JSON}")
|
||||
|
||||
# Strip with the cross binutils, so CPack can strip the arm64 binary.
|
||||
set(CMAKE_STRIP aarch64-linux-gnu-strip)
|
||||
|
||||
set(CMAKE_C_FLAGS_INIT "-isystem ${OPENRA3_AARCH64_INCLUDE}")
|
||||
set(CMAKE_CXX_FLAGS_INIT "-stdlib=libc++ -isystem ${OPENRA3_AARCH64_INCLUDE}")
|
||||
set(CMAKE_EXE_LINKER_FLAGS_INIT "-stdlib=libc++ -fuse-ld=lld -L${OPENRA3_AARCH64_LIB}")
|
||||
set(CMAKE_SHARED_LINKER_FLAGS_INIT "-stdlib=libc++ -fuse-ld=lld -L${OPENRA3_AARCH64_LIB}")
|
||||
|
||||
# Run arm64 test binaries through qemu when it is installed, so `ctest` works
|
||||
# during the image build; harmless when the tests are only cross-compiled.
|
||||
set(CMAKE_CROSSCOMPILING_EMULATOR qemu-aarch64-static)
|
||||
@@ -0,0 +1,46 @@
|
||||
# WebAssembly cross-build via Emscripten.
|
||||
#
|
||||
# Includes Emscripten's own platform toolchain (found through $EMSDK, set by the
|
||||
# emsdk image / `emsdk_env.sh`) and then layers the project's requirements:
|
||||
#
|
||||
# * C++26 `import std;`. Emscripten ships the libc++ `std` module source and a
|
||||
# modules.json in its sysroot; its toolchain wires that up, but only if
|
||||
# `CMAKE_CXX_MODULE_STD` is on and no std-modules JSON has been set yet, so
|
||||
# both are arranged before Emscripten.cmake is included and before the
|
||||
# compiler is probed.
|
||||
# * Global `-fexceptions`. Emscripten disables exceptions by default, and the
|
||||
# libc++ `std` module must be built with the same setting as its consumers,
|
||||
# or clang rejects the prebuilt module ("configuration mismatch"). The engine
|
||||
# throws, so exceptions are enabled everywhere.
|
||||
# * The SDL3 web port (`-sUSE_SDL=3`) is attached by CMake to the SDL interface
|
||||
# target, not here, so only the targets that use SDL pay for it.
|
||||
|
||||
# `import std` support: the gate and the module switch must be in place before
|
||||
# Emscripten.cmake runs and before the compiler is probed.
|
||||
set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "d0edc3af-4c50-42ea-a356-e2862fe7a444")
|
||||
set(CMAKE_CXX_MODULE_STD ON)
|
||||
# Let Emscripten.cmake point CMake at its own sysroot modules.json.
|
||||
unset(CMAKE_CXX_STDLIB_MODULES_JSON)
|
||||
|
||||
if(DEFINED ENV{EMSDK})
|
||||
set(OPENRA3_EMSCRIPTEN_CMAKE "$ENV{EMSDK}/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake")
|
||||
elseif(DEFINED ENV{EMSCRIPTEN_ROOT})
|
||||
set(OPENRA3_EMSCRIPTEN_CMAKE "$ENV{EMSCRIPTEN_ROOT}/cmake/Modules/Platform/Emscripten.cmake")
|
||||
endif()
|
||||
if(NOT EXISTS "${OPENRA3_EMSCRIPTEN_CMAKE}")
|
||||
message(FATAL_ERROR "Emscripten.cmake not found (set EMSDK); looked at '${OPENRA3_EMSCRIPTEN_CMAKE}'")
|
||||
endif()
|
||||
include("${OPENRA3_EMSCRIPTEN_CMAKE}")
|
||||
|
||||
# Exceptions are used by the engine; keep them on globally so the std module and
|
||||
# its consumers agree.
|
||||
#
|
||||
# Asyncify turns `emscripten_sleep` (used by display::sleep_frame on Emscripten)
|
||||
# into a yield to the browser: the engine's frame loops are blocking, and
|
||||
# without yielding the page freezes and never paints.
|
||||
set(CMAKE_CXX_FLAGS_INIT "-fexceptions -sASYNCIFY=1")
|
||||
set(CMAKE_C_FLAGS_INIT "-sASYNCIFY=1")
|
||||
set(CMAKE_EXE_LINKER_FLAGS_INIT
|
||||
"-fexceptions -sDISABLE_EXCEPTION_CATCHING=0 -sALLOW_MEMORY_GROWTH=1 -sASYNCIFY=1")
|
||||
set(CMAKE_SHARED_LINKER_FLAGS_INIT
|
||||
"-fexceptions -sDISABLE_EXCEPTION_CATCHING=0 -sALLOW_MEMORY_GROWTH=1 -sASYNCIFY=1")
|
||||
@@ -0,0 +1,50 @@
|
||||
# Cross-compile for Windows on ARM64 (aarch64-w64-mingw32) with llvm-mingw:
|
||||
# clang + libc++ + the libc++ `std` module, targeting the MSVCRT MinGW runtime.
|
||||
# The same llvm-mingw release is multi-target, so only the target triple, the
|
||||
# SDL3 SDK and the sysroot paths differ from the x86_64 toolchain.
|
||||
|
||||
# The `import std` gate must be set before the compiler is probed; a toolchain
|
||||
# file is processed earlier than the project's own top-level settings.
|
||||
set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "d0edc3af-4c50-42ea-a356-e2862fe7a444")
|
||||
|
||||
set(CMAKE_SYSTEM_NAME Windows)
|
||||
set(CMAKE_SYSTEM_PROCESSOR aarch64)
|
||||
|
||||
set(OPENRA3_LLVM_MINGW_ROOT "/opt/llvm-mingw" CACHE PATH "llvm-mingw installation root")
|
||||
if(NOT EXISTS "${OPENRA3_LLVM_MINGW_ROOT}/bin/clang++")
|
||||
message(FATAL_ERROR "llvm-mingw not found at ${OPENRA3_LLVM_MINGW_ROOT} (expected bin/clang++)")
|
||||
endif()
|
||||
|
||||
# llvm-mingw's per-target prefix/sysroot for the ARM64 target.
|
||||
set(OPENRA3_MINGW_TRIPLE "aarch64-w64-mingw32")
|
||||
|
||||
set(CMAKE_C_COMPILER "${OPENRA3_LLVM_MINGW_ROOT}/bin/clang")
|
||||
set(CMAKE_CXX_COMPILER "${OPENRA3_LLVM_MINGW_ROOT}/bin/clang++")
|
||||
set(CMAKE_C_COMPILER_TARGET ${OPENRA3_MINGW_TRIPLE})
|
||||
set(CMAKE_CXX_COMPILER_TARGET ${OPENRA3_MINGW_TRIPLE})
|
||||
# CMake's standard-library detection does not pass COMPILER_TARGET, so without
|
||||
# this it probes the host standard library. Putting the target in ARG1 makes
|
||||
# the probe see libc++ (the MinGW target's library).
|
||||
set(CMAKE_C_COMPILER_ARG1 "--target=${OPENRA3_MINGW_TRIPLE}")
|
||||
set(CMAKE_CXX_COMPILER_ARG1 "--target=${OPENRA3_MINGW_TRIPLE}")
|
||||
|
||||
# libc++ standard-library module description for the MinGW target. Must be set
|
||||
# before the compiler is probed so CMake can build `import std;`.
|
||||
set(CMAKE_CXX_STDLIB_MODULES_JSON "${OPENRA3_LLVM_MINGW_ROOT}/${OPENRA3_MINGW_TRIPLE}/lib/libc++.modules.json")
|
||||
|
||||
# Select libc++ explicitly so CMake's `import std` detection recognises the
|
||||
# standard library (llvm-mingw defaults to it, but CMake keys off -stdlib=).
|
||||
set(CMAKE_CXX_FLAGS_INIT "-stdlib=libc++")
|
||||
set(CMAKE_EXE_LINKER_FLAGS_INIT "-stdlib=libc++")
|
||||
set(CMAKE_SHARED_LINKER_FLAGS_INIT "-stdlib=libc++")
|
||||
|
||||
# SDL3 for aarch64-w64-mingw32. The official SDL release ships no MinGW ARM64
|
||||
# dev package, so the image stages one from MSYS2's clangarm64 repo under this
|
||||
# root in the same `<triple>/{include,lib,bin}` layout.
|
||||
set(OPENRA3_SDL3_ROOT "/opt/sdl3-aarch64" CACHE PATH "SDL3 aarch64 MinGW development package root")
|
||||
set(OPENRA3_SDL3_MINGW_TRIPLE "${OPENRA3_MINGW_TRIPLE}" CACHE STRING "SDL3 MinGW triple subdirectory")
|
||||
|
||||
set(CMAKE_FIND_ROOT_PATH "${OPENRA3_LLVM_MINGW_ROOT}/${OPENRA3_MINGW_TRIPLE}")
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||
@@ -36,6 +36,8 @@ set(CMAKE_SHARED_LINKER_FLAGS_INIT "-stdlib=libc++")
|
||||
|
||||
# Official SDL3 MinGW development package (headers + import library + DLL).
|
||||
set(OPENRA3_SDL3_ROOT "/opt/sdl3-mingw" CACHE PATH "SDL3 MinGW development package root")
|
||||
# The triple-named subdirectory inside the SDL3 MinGW package.
|
||||
set(OPENRA3_SDL3_MINGW_TRIPLE "x86_64-w64-mingw32" CACHE STRING "SDL3 MinGW triple subdirectory")
|
||||
|
||||
set(CMAKE_FIND_ROOT_PATH "${OPENRA3_LLVM_MINGW_ROOT}/x86_64-w64-mingw32")
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
|
||||
+771
-101
@@ -1,131 +1,801 @@
|
||||
# Architecture
|
||||
# Architecture & Master Plan
|
||||
|
||||
OpenRA3 is organised as a stack of C++26 modules. Each layer may import the ones
|
||||
below it, never the ones above. Every module uses `import std;` for the standard
|
||||
library; the engine is built with Clang + libc++ on Linux and cross-compiled
|
||||
with llvm-mingw for Windows.
|
||||
OpenRA3 is a from-scratch, portable re-implementation of **Command & Conquer:
|
||||
Red Alert 3** (SAGE 2.0) in **pure C++26** — C++ modules, `import std;`, no
|
||||
scripting language, no managed runtime, no other programming language anywhere
|
||||
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.
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────┐
|
||||
applications │ openra3 (apps/openra3) │
|
||||
└───────────────────┬───────────────────┘
|
||||
│ import ra3
|
||||
┌───────────────────▼───────────────────┐
|
||||
umbrella │ ra3 (re-exports everything) │
|
||||
└───────────────────┬───────────────────┘
|
||||
┌──────────────┬───────────────┼───────────────┬──────────────┐
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
ra3.display ra3.render ra3.skirmish ra3.client ra3.game
|
||||
backend pick framebuffer match rules display+loops RA3 sides
|
||||
│ │ │ │ │
|
||||
┌──┴───┐ │ └───────┬───────┘ │
|
||||
▼ ▼ ▼ ▼ │
|
||||
ra3.ui ra3.vulkan ra3.terrain ra3.logic │
|
||||
SDL Vulkan heightmap/blends simulation │
|
||||
┌──────────────────────┘
|
||||
▼
|
||||
ra3.map / ra3.fs
|
||||
catalog / BIG4 + RefPack
|
||||
L6 tooling / apps openra3 (CLI) tools/ (offline, Python allowed)
|
||||
| import ra3
|
||||
---------------------------------------------------------------------------
|
||||
v
|
||||
L5 meta ra3.i18n ra3.mod ra3.net (deferred)
|
||||
|
|
||||
L4 match services ra3.match ra3.replay ra3.save
|
||||
|
|
||||
L3 presentation ra3.client ── ra3.render ── ra3.audio ── ra3.video
|
||||
| \ |
|
||||
| \ v
|
||||
| ra3.display (backend pick)
|
||||
| / | \ \
|
||||
v ra3.ui ra3.vulkan ra3.dx ra3.webgl
|
||||
| (SDL3) (Vulkan) (D3D11/12) (WebGL2)
|
||||
---------------------------------------------------------------------------
|
||||
v
|
||||
L2 simulation ra3.logic ra3.modules ra3.combat ra3.movement
|
||||
ra3.economy ra3.ai ra3.script ra3.powers ra3.shroud
|
||||
|
|
||||
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.webgl.*`, `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
|
||||
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.
|
||||
### Status legend
|
||||
|
||||
The Vulkan backend currently hand-rolls its pipelines. As materials, skybox and
|
||||
HUD arrive, a thin RHI + render-graph belongs between `ra3.client` and the
|
||||
Vulkan/SDL backends; `present_terrain` is the placeholder for that step.
|
||||
| Tag | Meaning |
|
||||
| --- | --- |
|
||||
| `[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`
|
||||
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`).
|
||||
## 3. Master plan — Module → Function → Feature
|
||||
|
||||
### `ra3.logic`
|
||||
The deterministic simulation, mirroring SAGE's `GameLogic`: `thing` → `object`
|
||||
with pluggable `update_module`s, `player`/`player_list`, the `partition_manager`
|
||||
spatial grid, and the 30 Hz `game_logic` driver (`prepare_new_game` /
|
||||
`start_new_game` / `update`).
|
||||
### M00 `ra3.core` — foundation vocabulary `[D]`
|
||||
|
||||
### `ra3.client`
|
||||
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.
|
||||
Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
|
||||
|
||||
### `ra3.display`
|
||||
Backend selection: tries Vulkan, then SDL, then reports failure so the caller can
|
||||
fall back to an offscreen image. The only module that names a concrete backend.
|
||||
- **F1 Types & math** `[D]`
|
||||
- `[x]` `real` (float), `int32`/`uint32`/`uint16`/`uint8`, `bool` aliases
|
||||
- `[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`
|
||||
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`).
|
||||
### M01 `ra3.fs` — containers & install `[D]`
|
||||
|
||||
### `ra3.game`
|
||||
Red Alert 3 data that SAGE keeps in `PlayerTemplate`: the three sides
|
||||
(`faction` flags `Empire=2`, `Allied=4`, `Soviet=8`, `Random=7`, recovered from
|
||||
the retail skirmish setup) and skirmish defaults.
|
||||
- **F1 BIG archive** `[D]`
|
||||
- `[x]` `BIG4` header parse (`fileSize` LE, `fileCount`/offsets BE) + name index
|
||||
- `[x]` payload read on demand (index-only resident memory)
|
||||
- `[ ]` `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`
|
||||
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.
|
||||
### M02 `ra3.data` — data schema & balance `[P]`
|
||||
|
||||
### `ra3.map`
|
||||
Map discovery and loading: scans `MapsMultiplayer.big` for main map entries,
|
||||
unwraps the two compression layers (`BIG4` RefPack → `EAR\0` wrapper → RefPack →
|
||||
`CkMp`), recovers `Player_N_Start` waypoint coordinates, and decodes the
|
||||
localized display names from the install's `gamestrings.csf`. Degenerate
|
||||
extractions are rejected so the caller can fall back.
|
||||
- **F1 Data schema** `[ ]` `!!`
|
||||
- `[ ]` SAGE INI parser (`#include`, `#define`, inheritance) `(v0.5)`
|
||||
- `[ ]` XML rule schema
|
||||
- `[ ]` `.manifest` compiled-blob schema `!!` `(v0.5)`
|
||||
- **F2 Compiled asset blobs** `[ ]` `!!`
|
||||
- `[ ]` `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`
|
||||
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.
|
||||
### M03 `ra3.assets` — runtime asset manager `[ ]`
|
||||
|
||||
### `ra3.render`
|
||||
A dependency-free software renderer: an ARGB8888 `image` framebuffer with
|
||||
blit/line/circle/text primitives (an embedded 8x8 bitmap font), a TGA decoder for
|
||||
the game's map art, a 24-bit BMP encoder for headless output, and `compose` which
|
||||
overlays a world grid and markers (start positions, live units) onto the map art.
|
||||
- **F1 Textures** `[~]`
|
||||
- `[x]` TGA decode (`ra3.render`); 256×256 terrain cells
|
||||
- `[ ]` DDS / DXT compressed textures `(v0.5)`
|
||||
- `[ ]` atlas + mip generation, gutter padding (GPU) `[~]`
|
||||
- `[ ]` 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`
|
||||
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.
|
||||
### M04 `ra3.map` — map catalog & loader `[D]`
|
||||
|
||||
### `ra3.vulkan`
|
||||
The accelerated backend: `vulkan_display` implements the same primitives and
|
||||
adds `present_terrain`, a GPU heightfield raymarcher (mipmapped tile atlas with a
|
||||
replicated gutter and the retail SAGE blend ramp). A null backend reports failure
|
||||
when no Vulkan loader is present.
|
||||
- **F1 Catalog** `[D]`
|
||||
- `[x]` scan `MapsMultiplayer.big` for main map entries
|
||||
- `[ ]` campaign + challenge map catalog `(v0.7)`
|
||||
- **F2 Compiled map (`CkMp`)** `[D]`
|
||||
- `[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`
|
||||
|
||||
### M18c `ra3.webgl` — WebGL backend `[P]`
|
||||
|
||||
- **F1 Device & canvas** `[D]`
|
||||
- `[x]` Emscripten-only; SDL3 canvas + GLES 3.0 (WebGL2) via `-sUSE_SDL=3`
|
||||
- **F2 2D image path** `[D]`
|
||||
- `[x]` BGRA texture + fullscreen triangle (GLSL ES 300), `.bgra` swizzle
|
||||
- **F3 Terrain presentation** `[D]`
|
||||
- `[x]` GPU heightfield raymarch (GLSL ES port of `terrain.frag`)
|
||||
- **F4 Wasm packaging** `[~]`
|
||||
- `[x]` `openra3.html/.js/.wasm`, assets preloaded into the FS at `/assets`
|
||||
- `[ ]` async/fetch streaming assets instead of a single preload blob `(v0.6)`
|
||||
- **F5 Null fallback** `[D]`
|
||||
- `[x]` non-Emscripten 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
|
||||
manager holds non-owning `thing *` views only.
|
||||
2. **Portable simulation.** No platform APIs in `ra3.core` / `ra3.logic`. All
|
||||
platform concerns live behind `display`.
|
||||
manager and object registry hold non-owning `thing *` views only.
|
||||
2. **Portable simulation.** No platform APIs in `ra3.core` / simulation
|
||||
modules. All platform concerns live behind `display`.
|
||||
3. **Determinism.** Anything that can diverge between runs (random, iteration
|
||||
order) is explicit and seedable.
|
||||
4. **RE-traceable.** Where a structure or constant comes from the retail
|
||||
binary, the address is cited in the comment.
|
||||
5. **No bundled assets, no online.** Game data is read from the user's install
|
||||
and never committed; there is no networking or online service.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Debian (latest stable) Linux/ARM64 target (.deb + binary).
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
IMAGE="${OPENRA3_DEBIAN_ARM64_IMAGE:-openra3-debian-arm64:local}"
|
||||
BUILD_DIR="${1:-build/debian-arm64}"
|
||||
|
||||
docker build --target dev -f "$ROOT/Dockerfile.debian-arm64" -t "$IMAGE" "$ROOT"
|
||||
docker run --rm -v "$ROOT:/work" -w /work "$IMAGE" cmake -S . -B "$BUILD_DIR" -G Ninja \
|
||||
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/clang-aarch64-linux-gnu.cmake \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DOPENRA3_AARCH64_CC=clang-19 -DOPENRA3_AARCH64_CXX=clang++-19 \
|
||||
-DOPENRA3_AARCH64_STDLIB_MODULES_JSON=/usr/lib/llvm-19/lib/libc++.modules.json \
|
||||
-DOPENRA3_DISTRO_TAG=debian13
|
||||
docker run --rm -v "$ROOT:/work" -w /work "$IMAGE" cmake --build "$BUILD_DIR" -j
|
||||
docker run --rm -v "$ROOT:/work" -w /work "$IMAGE" bash -c "cd $BUILD_DIR && cpack -G DEB"
|
||||
|
||||
echo "Debian arm64: $ROOT/$BUILD_DIR/bin/openra3 (+ .deb)"
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Debian (latest stable) target (.deb + binary) with the isolated image.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
IMAGE="${OPENRA3_DEBIAN_IMAGE:-openra3-debian:local}"
|
||||
BUILD_DIR="${1:-build/debian}"
|
||||
|
||||
docker build --target dev -f "$ROOT/Dockerfile.debian" -t "$IMAGE" "$ROOT"
|
||||
docker run --rm -v "$ROOT:/work" -w /work "$IMAGE" cmake -S . -B "$BUILD_DIR" -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_CXX_STDLIB_MODULES_JSON=/usr/lib/llvm-19/lib/libc++.modules.json \
|
||||
-DOPENRA3_DISTRO_TAG=debian13
|
||||
docker run --rm -v "$ROOT:/work" -w /work "$IMAGE" cmake --build "$BUILD_DIR" -j
|
||||
docker run --rm -v "$ROOT:/work" -w /work "$IMAGE" bash -c "cd $BUILD_DIR && cpack -G DEB"
|
||||
|
||||
echo "Debian: $ROOT/$BUILD_DIR/bin/openra3 (+ .deb)"
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Linux/ARM64 target (.deb + binary) with the isolated cross image.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
IMAGE="${OPENRA3_LINUX_ARM64_IMAGE:-openra3-linux-arm64:local}"
|
||||
BUILD_DIR="${1:-build/linux-arm64}"
|
||||
|
||||
docker build --target dev -f "$ROOT/Dockerfile.linux-arm64" -t "$IMAGE" "$ROOT"
|
||||
docker run --rm -v "$ROOT:/work" -w /work "$IMAGE" cmake -S . -B "$BUILD_DIR" -G Ninja \
|
||||
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/clang-aarch64-linux-gnu.cmake \
|
||||
-DCMAKE_BUILD_TYPE=Release -DOPENRA3_DISTRO_TAG=ubuntu26.04
|
||||
docker run --rm -v "$ROOT:/work" -w /work "$IMAGE" cmake --build "$BUILD_DIR" -j
|
||||
docker run --rm -v "$ROOT:/work" -w /work "$IMAGE" bash -c "cd $BUILD_DIR && cpack -G DEB"
|
||||
|
||||
echo "Linux arm64: $ROOT/$BUILD_DIR/bin/openra3 (+ .deb)"
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the WebAssembly target (openra3.html/.js/.wasm) with the Emscripten image.
|
||||
#
|
||||
# Browsers forbid synchronous on-demand file reads on the main thread, so assets
|
||||
# are preloaded into the module's filesystem. Prefer a compact, per-map set: one
|
||||
# map plus the tiles it uses:
|
||||
#
|
||||
# OPENRA3_WEB_ASSETS="$(mktemp -d)" \
|
||||
# openra3 textures --map map_mp_2_feasel1 # copy these under the set's terrain/
|
||||
#
|
||||
# then point OPENRA3_WEB_ASSETS at that directory.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
IMAGE="${OPENRA3_WASM_IMAGE:-openra3-wasm:local}"
|
||||
BUILD_DIR="${1:-build/wasm}"
|
||||
|
||||
docker build --target dev -f "$ROOT/Dockerfile.wasm" -t "$IMAGE" "$ROOT"
|
||||
|
||||
mounts=(-v "$ROOT:/work")
|
||||
configure=(-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/emscripten-wasm.cmake -DCMAKE_BUILD_TYPE=Release)
|
||||
if [ -n "${OPENRA3_WEB_ASSETS:-}" ]; then
|
||||
mounts+=(-v "$OPENRA3_WEB_ASSETS:/assets:ro")
|
||||
configure+=(-DOPENRA3_WEB_ASSETS=/assets)
|
||||
fi
|
||||
|
||||
docker run --rm "${mounts[@]}" -w /work "$IMAGE" cmake -S . -B "$BUILD_DIR" -G Ninja "${configure[@]}"
|
||||
docker run --rm "${mounts[@]}" -w /work "$IMAGE" cmake --build "$BUILD_DIR" -j
|
||||
|
||||
echo "WebAssembly: $ROOT/$BUILD_DIR/bin/openra3.html (+ .js/.wasm/.data)"
|
||||
echo "serve: python3 \"$ROOT/apps/web/serve.py\" --root \"$ROOT/$BUILD_DIR/bin\" --port 8199"
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Windows/ARM64 target (portable .zip) with the isolated cross image.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
IMAGE="${OPENRA3_WINDOWS_ARM64_IMAGE:-openra3-windows-arm64:local}"
|
||||
BUILD_DIR="${1:-build/windows-arm64}"
|
||||
|
||||
docker build --target dev -f "$ROOT/Dockerfile.win-arm64" -t "$IMAGE" "$ROOT"
|
||||
docker run --rm -v "$ROOT:/work" -w /work "$IMAGE" cmake -S . -B "$BUILD_DIR" -G Ninja \
|
||||
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/llvm-mingw-aarch64.cmake \
|
||||
-DCMAKE_BUILD_TYPE=Release
|
||||
docker run --rm -v "$ROOT:/work" -w /work "$IMAGE" cmake --build "$BUILD_DIR" -j
|
||||
docker run --rm -v "$ROOT:/work" -w /work "$IMAGE" bash -c "cd $BUILD_DIR && cpack -G ZIP"
|
||||
|
||||
echo "Windows arm64: $ROOT/$BUILD_DIR/bin/openra3.exe (+ .zip)"
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#version 300 es
|
||||
// Samples the software-rendered scene image (the WebGL port of scene.frag).
|
||||
precision highp float;
|
||||
precision highp sampler2D;
|
||||
|
||||
uniform sampler2D u_scene;
|
||||
|
||||
in vec2 v_uv;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main() {
|
||||
if (v_uv.x < 0.0 || v_uv.x > 1.0 || v_uv.y < 0.0 || v_uv.y > 1.0) discard;
|
||||
// The engine stores 0xAARRGGBB (BGRA in memory), uploaded as GL_RGBA.
|
||||
frag_color = texture(u_scene, v_uv).bgra;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#version 300 es
|
||||
// Fullscreen triangle for the 2D image path (the WebGL port of scene.vert).
|
||||
// WebGL clip space is +Y up, so the vertex position flips Y relative to the
|
||||
// Vulkan shader; the sampled UVs and the image's top-left origin are unchanged.
|
||||
precision highp float;
|
||||
|
||||
uniform vec4 u_rect; // xy = top-left (0..1), zw = size (0..1)
|
||||
|
||||
out vec2 v_uv;
|
||||
|
||||
void main() {
|
||||
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
|
||||
v_uv = (p - u_rect.xy) / u_rect.zw;
|
||||
gl_Position = vec4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
#version 300 es
|
||||
// GPU heightfield raymarcher for the real RA3 terrain (the WebGL port of
|
||||
// terrain.frag). The Vulkan push constants become a set of vec4 uniforms.
|
||||
precision highp float;
|
||||
precision highp int;
|
||||
precision highp sampler2D;
|
||||
precision highp sampler2DArray;
|
||||
|
||||
uniform sampler2D u_heightmap; // R16 heights
|
||||
uniform sampler2D u_celldata; // per-cell blend record (RGBA16)
|
||||
uniform sampler2DArray u_atlas; // tile material array (RGBA8)
|
||||
|
||||
// One contiguous array so the host can upload all five vec4s with a single
|
||||
// glUniform4fv; the names keep the shader body identical to terrain.frag.
|
||||
uniform vec4 u_data[5];
|
||||
#define u_cam u_data[0] // x=target_x, y=target_y, z=yaw, w=height
|
||||
#define u_params u_data[1] // x=pitch, y=fov, z=water_z, w=has_water
|
||||
#define u_sun u_data[2] // xyz=sun dir, w=ambient
|
||||
#define u_mapinfo u_data[3] // x=W, y=H, z=unused, w=z_scale
|
||||
#define u_misc u_data[4] // x=time, y=unused, z=cells per texture repeat, w=aspect
|
||||
|
||||
in vec2 v_uv;
|
||||
out vec4 frag_color;
|
||||
|
||||
const float CELL = 10.0; // must match ra3::terrain::cell_size
|
||||
|
||||
float height_at(ivec2 c) {
|
||||
c = clamp(c, ivec2(0), ivec2(u_mapinfo.xy) - 1);
|
||||
return texelFetch(u_heightmap, c, 0).r * 65535.0 * u_mapinfo.w;
|
||||
}
|
||||
|
||||
float world_height(float wx, float wy) {
|
||||
float world_w = u_mapinfo.x * CELL;
|
||||
float world_h = u_mapinfo.y * CELL;
|
||||
if (wx < 0.0 || wy < 0.0 || wx >= world_w || wy >= world_h) return -1.0e9;
|
||||
ivec2 c = ivec2(int(wx / CELL), int((world_h - wy) / CELL));
|
||||
return height_at(c);
|
||||
}
|
||||
|
||||
vec3 sky_color(vec3 dir) {
|
||||
vec3 d = normalize(dir);
|
||||
vec3 sun_dir = normalize(u_sun.xyz);
|
||||
float t = clamp(d.z, 0.0, 1.0);
|
||||
vec3 horizon = vec3(0.70, 0.78, 0.85);
|
||||
vec3 zenith = vec3(0.28, 0.48, 0.80);
|
||||
vec3 col = mix(horizon, zenith, pow(t, 0.6));
|
||||
float sun = max(dot(d, sun_dir), 0.0);
|
||||
col += vec3(1.0, 0.95, 0.82) * pow(sun, 300.0) * 1.6; // sun disk
|
||||
col += vec3(1.0, 0.90, 0.72) * pow(sun, 8.0) * 0.18; // glow
|
||||
return col;
|
||||
}
|
||||
|
||||
// The retail SAGE blend ramp (see terrain.frag).
|
||||
float blend_factor(uint direction, uint flags, vec2 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;
|
||||
}
|
||||
|
||||
vec3 sample_layer(uint layer, float wx, float wy) {
|
||||
float span = max(u_misc.z, 1.0);
|
||||
int layer_count = textureSize(u_atlas, 0).z;
|
||||
float l = float(min(layer, uint(layer_count - 1)));
|
||||
// The atlas is 0xAARRGGBB (BGRA in memory), uploaded as GL_RGBA.
|
||||
return texture(u_atlas, vec3(vec2(wx, wy) / span, l)).bgr;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 p = u_cam;
|
||||
float pitch = clamp(u_params.x, 0.15, 1.45);
|
||||
float fov = clamp(u_params.y, 0.3, 1.4);
|
||||
float world_w = u_mapinfo.x * CELL;
|
||||
float world_h = u_mapinfo.y * CELL;
|
||||
|
||||
float cp = cos(pitch);
|
||||
vec3 fwd = vec3(cp * sin(p.z), cp * cos(p.z), -sin(pitch));
|
||||
vec3 right = normalize(cross(fwd, vec3(0, 0, 1)));
|
||||
vec3 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);
|
||||
vec3 cam = vec3(p.x, p.y, target_z + p.w) - fwd * dist;
|
||||
|
||||
vec2 ndc = vec2(v_uv.x * 2.0 - 1.0, 1.0 - v_uv.y * 2.0);
|
||||
float aspect = u_misc.w;
|
||||
float th = tan(fov * 0.5);
|
||||
vec3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th);
|
||||
|
||||
if (dir.z >= -1e-4) {
|
||||
frag_color = vec4(sky_color(dir), 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
vec3 w = cam + 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 (u_params.w > 0.5 && w.z <= u_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) {
|
||||
frag_color = vec4(sky_color(dir), 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
float lo = prev;
|
||||
float hi = hit_t;
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
float mid = 0.5 * (lo + hi);
|
||||
vec3 w = cam + dir * mid;
|
||||
bool water = u_params.w > 0.5 && w.z <= u_params.z;
|
||||
if (water || w.z <= world_height(w.x, w.y)) {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
}
|
||||
vec3 hitpos = cam + dir * hi;
|
||||
|
||||
vec3 sun = normalize(u_sun.xyz);
|
||||
float ambient = u_sun.w;
|
||||
|
||||
if (u_params.w > 0.5 && hitpos.z <= u_params.z + 0.01) {
|
||||
float time = u_misc.x;
|
||||
vec2 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);
|
||||
vec3 n = normalize(vec3(nx * 0.06, ny * 0.06, 1.0));
|
||||
float fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0);
|
||||
vec3 deep = vec3(0.03, 0.16, 0.28);
|
||||
vec3 refl = sky_color(reflect(dir, n));
|
||||
float lam = max(0.0, dot(n, sun));
|
||||
vec3 water = mix(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9));
|
||||
water += vec3(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6;
|
||||
float wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
|
||||
water = mix(water, sky_color(vec3(dir.x, dir.y, 0.0)), wfog);
|
||||
frag_color = vec4(water, 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
float wx = hitpos.x / CELL;
|
||||
float wy = (world_h - hitpos.y) / CELL;
|
||||
int cx = clamp(int(wx), 0, int(u_mapinfo.x) - 1);
|
||||
int cy = clamp(int(wy), 0, int(u_mapinfo.y) - 1);
|
||||
float fx = wx - floor(wx);
|
||||
float fy = wy - floor(wy);
|
||||
|
||||
uvec4 record = uvec4(texelFetch(u_celldata, ivec2(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;
|
||||
|
||||
vec2 fracUV = vec2(fx, fy);
|
||||
vec3 c0 = sample_layer(record.x, wx, wy);
|
||||
vec3 c1 = sample_layer(record.y, wx, wy);
|
||||
vec3 c2 = sample_layer(record.z, wx, wy);
|
||||
float f1 = blend_factor(dir1, flags1, fracUV);
|
||||
float f2 = blend_factor(dir2, flags2, fracUV);
|
||||
vec3 albedo = mix(mix(c0, c1, f1), c2, f2);
|
||||
|
||||
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);
|
||||
vec3 n = normalize(vec3(hl - hr, hd - hu, 2.0 * CELL));
|
||||
float lambert = max(0.0, dot(n, sun));
|
||||
vec3 lit = albedo * (ambient + (1.0 - ambient) * lambert);
|
||||
float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
|
||||
lit = mix(lit, sky_color(vec3(dir.x, dir.y, 0.0)), fog);
|
||||
frag_color = vec4(lit, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#version 300 es
|
||||
// Fullscreen triangle for the terrain raymarcher (the WebGL port of terrain.vert).
|
||||
precision highp float;
|
||||
|
||||
out vec2 v_uv;
|
||||
|
||||
void main() {
|
||||
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
|
||||
v_uv = p;
|
||||
gl_Position = vec4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
module;
|
||||
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
// Yields to the browser (requires -sASYNCIFY); see display::sleep_frame.
|
||||
extern "C" void emscripten_sleep(unsigned int ms);
|
||||
#endif
|
||||
|
||||
export module ra3.client;
|
||||
|
||||
import std;
|
||||
@@ -85,12 +92,19 @@ export namespace ra3::client {
|
||||
|
||||
/** Throttle an idle frame according to the configured limit. */
|
||||
auto sleep_frame() const -> void {
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
// The frame loops are blocking; on the web the only way to let the
|
||||
// browser paint and handle input is to yield (Asyncify sleep).
|
||||
const int fps = fps_limit_ > 0 ? fps_limit_ : 60;
|
||||
emscripten_sleep(static_cast<unsigned>(std::max(1, 1000 / fps)));
|
||||
#else
|
||||
if (fps_limit_ > 0) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(std::max(1, 1000 / fps_limit_)));
|
||||
} else if (fps_limit_ < 0) {
|
||||
std::this_thread::yield();
|
||||
}
|
||||
// fps_limit_ == 0: vertical sync already blocks in present().
|
||||
#endif
|
||||
}
|
||||
|
||||
// ---- shared interactive loops (assume `init` succeeded) --------------
|
||||
@@ -499,6 +513,15 @@ export namespace ra3::client {
|
||||
this->shutdown();
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+117
-45
@@ -8,14 +8,19 @@ import ra3.terrain;
|
||||
import ra3.client;
|
||||
import ra3.ui;
|
||||
import ra3.vulkan;
|
||||
import ra3.dx;
|
||||
import ra3.webgl;
|
||||
|
||||
/**
|
||||
* Backend selection for the presentation layer.
|
||||
*
|
||||
* 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
|
||||
* one is in use. The GPU terrain path is Vulkan-only and reports failure so the
|
||||
* caller can fall back to the software renderer.
|
||||
* backend so no caller has to know which one is in use. The available backends
|
||||
* are Vulkan, Direct3D 11 and Direct3D 12, WebGL (all GPU terrain, available
|
||||
* per platform) and the SDL 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 reports failure so the caller
|
||||
* can fall back to the software renderer.
|
||||
*/
|
||||
export namespace ra3::display {
|
||||
using ra3::client::display_options;
|
||||
@@ -27,72 +32,139 @@ export namespace ra3::display {
|
||||
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)>;
|
||||
|
||||
/** The backend that actually initialized, or `none`. */
|
||||
enum class backend { none, vulkan, sdl };
|
||||
/** A concrete presentation backend, or `none`. */
|
||||
enum class backend { none, vulkan, d3d11, d3d12, webgl, sdl };
|
||||
|
||||
/**
|
||||
* Create and initialize the preferred display (Vulkan, then SDL), or return
|
||||
* null when neither 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, bool prefer_vulkan) -> std::unique_ptr<ra3::client::display> {
|
||||
const auto try_vulkan = [&]() -> std::unique_ptr<ra3::client::display> {
|
||||
auto d = std::make_unique<ra3::vulkan::vulkan_display>();
|
||||
if (d->init(options)) return d;
|
||||
return nullptr;
|
||||
};
|
||||
const auto try_sdl = [&]() -> std::unique_ptr<ra3::client::display> {
|
||||
auto d = std::make_unique<ra3::ui::sdl_display>();
|
||||
if (d->init(options)) return d;
|
||||
return nullptr;
|
||||
};
|
||||
if (prefer_vulkan) {
|
||||
if (auto d = try_vulkan()) return d;
|
||||
return try_sdl();
|
||||
/** The backend preferred on this host: WebGL under Emscripten, else Vulkan. */
|
||||
[[nodiscard]] inline constexpr auto default_backend() -> backend {
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
return backend::webgl;
|
||||
#else
|
||||
return backend::vulkan;
|
||||
#endif
|
||||
}
|
||||
|
||||
[[nodiscard]] inline auto backend_name(backend which) -> std::string_view {
|
||||
switch (which) {
|
||||
case backend::vulkan: return "vulkan";
|
||||
case backend::d3d11: return "d3d11";
|
||||
case backend::d3d12: return "d3d12";
|
||||
case backend::webgl: return "webgl";
|
||||
case backend::sdl: return "sdl";
|
||||
default: return "none";
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] inline auto make_backend(backend which) -> std::unique_ptr<ra3::client::display> {
|
||||
switch (which) {
|
||||
case backend::vulkan: return std::make_unique<ra3::vulkan::vulkan_display>();
|
||||
case backend::d3d11: return std::make_unique<ra3::dx::d3d11_display>();
|
||||
case backend::d3d12: return std::make_unique<ra3::dx::d3d12_display>();
|
||||
case backend::webgl: return std::make_unique<ra3::webgl::webgl_display>();
|
||||
case backend::sdl: return std::make_unique<ra3::ui::sdl_display>();
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
/** The order in which backends are attempted for a preferred one. */
|
||||
[[nodiscard]] inline auto backend_order(backend preferred) -> std::array<backend, 5> {
|
||||
switch (preferred) {
|
||||
case backend::d3d11: return {backend::d3d11, backend::d3d12, backend::vulkan, backend::webgl, backend::sdl};
|
||||
case backend::d3d12: return {backend::d3d12, backend::d3d11, backend::vulkan, backend::webgl, backend::sdl};
|
||||
case backend::webgl: return {backend::webgl, backend::vulkan, backend::d3d11, backend::d3d12, backend::sdl};
|
||||
case backend::sdl: return {backend::sdl, backend::d3d11, backend::d3d12, backend::vulkan, backend::webgl};
|
||||
case backend::vulkan:
|
||||
default: return {backend::vulkan, backend::d3d11, backend::d3d12, backend::webgl, 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
|
||||
* mean "this backend did not start", so the next one is tried.
|
||||
*/
|
||||
template<typename Fn>
|
||||
[[nodiscard]] auto with_display(bool prefer_vulkan, Fn &&action) -> bool {
|
||||
if (prefer_vulkan) {
|
||||
if (auto vk = std::make_unique<ra3::vulkan::vulkan_display>(); action(*vk)) 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;
|
||||
[[nodiscard]] auto with_display(backend preferred, Fn &&action) -> bool {
|
||||
for (const auto which: backend_order(preferred)) {
|
||||
auto candidate = make_backend(which);
|
||||
if (candidate && action(*candidate)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Interactive menu on the first available backend. */
|
||||
[[nodiscard]] inline auto run_menu(const display_options &options, const menu_frame &frame) -> bool {
|
||||
return with_display(true, [&](ra3::client::display &d) { return d.run_menu(options, frame); });
|
||||
/** Run `action` on the first display that initializes: Vulkan, then SDL. */
|
||||
template<typename Fn>
|
||||
[[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 = default_backend()) -> 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 {
|
||||
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 {
|
||||
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
|
||||
* so the caller can render offscreen instead.
|
||||
* GPU terrain viewer on the preferred (or first available) backend. Returns
|
||||
* 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 {
|
||||
auto vk = std::make_unique<ra3::vulkan::vulkan_display>();
|
||||
return vk->run_terrain(options, terrain, camera);
|
||||
return run_terrain(options, terrain, camera, backend::vulkan);
|
||||
}
|
||||
}
|
||||
|
||||
+1392
File diff suppressed because it is too large
Load Diff
@@ -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)"; }
|
||||
};
|
||||
}
|
||||
@@ -18,4 +18,6 @@ export import ra3.render;
|
||||
export import ra3.terrain;
|
||||
export import ra3.ui;
|
||||
export import ra3.vulkan;
|
||||
export import ra3.dx;
|
||||
export import ra3.webgl;
|
||||
export import ra3.display;
|
||||
|
||||
@@ -394,12 +394,13 @@ export namespace ra3::terrain {
|
||||
uint32 supersample = 2U; ///< Render at Nx and box-downsample (antialiasing).
|
||||
};
|
||||
|
||||
/** Load tile textures from a directory of loose `*.tga` files (extracted assets). */
|
||||
[[nodiscard]] inline auto load_textures_from_dir(const map_data &map, const std::filesystem::path &dir,
|
||||
const std::function<void(float)> &progress = {}) -> texture_set {
|
||||
std::unordered_map<std::string, std::filesystem::path> files;
|
||||
std::error_code ec;
|
||||
if (std::filesystem::is_directory(dir, ec)) {
|
||||
namespace detail {
|
||||
/** Index `*.tga` under a terrain dir by stem (lower-cased), ignoring normals. */
|
||||
[[nodiscard]] inline auto terrain_file_index(const std::filesystem::path &dir)
|
||||
-> std::unordered_map<std::string, std::filesystem::path> {
|
||||
std::unordered_map<std::string, std::filesystem::path> files;
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::is_directory(dir, ec)) return files;
|
||||
// Recursive: our own extract writes flat `terrain/*.tga`, ra3tools
|
||||
// writes nested `.../art/terrain/*.tga`. Only index `terrain` dirs
|
||||
// so a full asset dump does not pull in every unrelated TGA.
|
||||
@@ -409,38 +410,59 @@ export namespace ra3::terrain {
|
||||
auto parent = path.parent_path().filename().string();
|
||||
std::transform(parent.begin(), parent.end(), parent.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
|
||||
if (parent != "terrain") continue;
|
||||
auto stem = detail::tga_stem(path.filename().string());
|
||||
auto stem = tga_stem(path.filename().string());
|
||||
if (stem.size() > 4U && stem.ends_with("_nrm")) continue;
|
||||
files.try_emplace(stem, path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Match one map texture name against the index (see `load_textures_from_dir`). */
|
||||
[[nodiscard]] inline auto match_terrain_file(const std::unordered_map<std::string, std::filesystem::path> &files, std::string name)
|
||||
-> std::filesystem::path {
|
||||
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
|
||||
for (const auto &candidate: {name, "t" + name, "tmisc_" + name}) {
|
||||
if (const auto it = files.find(candidate); it != files.end()) return it->second;
|
||||
}
|
||||
for (const auto &[stem, path]: files) {
|
||||
if (stem.ends_with(name)) return path;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
} // namespace detail
|
||||
|
||||
/**
|
||||
* The loose `*.tga` files `map` resolves to under `dir`, de-duplicated.
|
||||
*
|
||||
* Used to stage just the tiles a single map needs (e.g. the wasm preload).
|
||||
*/
|
||||
[[nodiscard]] inline auto resolve_texture_files(const map_data &map, const std::filesystem::path &dir) -> std::vector<std::filesystem::path> {
|
||||
const auto files = detail::terrain_file_index(dir);
|
||||
std::vector<std::filesystem::path> resolved;
|
||||
for (const auto &texture: map.textures) {
|
||||
if (auto found = detail::match_terrain_file(files, texture.name); !found.empty()) resolved.push_back(std::move(found));
|
||||
}
|
||||
std::sort(resolved.begin(), resolved.end());
|
||||
resolved.erase(std::unique(resolved.begin(), resolved.end()), resolved.end());
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Load tile textures from a directory of loose `*.tga` files (extracted assets). */
|
||||
[[nodiscard]] inline auto load_textures_from_dir(const map_data &map, const std::filesystem::path &dir,
|
||||
const std::function<void(float)> &progress = {}) -> texture_set {
|
||||
const auto files = detail::terrain_file_index(dir);
|
||||
texture_set set;
|
||||
set.images.resize(map.textures.size());
|
||||
for (usize i = 0; i < map.textures.size(); ++i) {
|
||||
auto name = map.textures[i].name;
|
||||
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
|
||||
std::filesystem::path found;
|
||||
for (const auto &candidate: {name, "t" + name, "tmisc_" + name}) {
|
||||
if (const auto it = files.find(candidate); it != files.end()) {
|
||||
found = it->second;
|
||||
break;
|
||||
const auto found = detail::match_terrain_file(files, map.textures[i].name);
|
||||
if (!found.empty()) {
|
||||
try {
|
||||
std::ifstream in(found, std::ios::binary);
|
||||
std::vector<uint8> raw((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
set.images[i] = ra3::render::decode_tga(raw);
|
||||
} catch (const std::exception &) {
|
||||
}
|
||||
}
|
||||
if (found.empty()) {
|
||||
for (const auto &[stem, path]: files) {
|
||||
if (stem.ends_with(name)) {
|
||||
found = path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found.empty()) continue;
|
||||
try {
|
||||
std::ifstream in(found, std::ios::binary);
|
||||
std::vector<uint8> raw((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
set.images[i] = ra3::render::decode_tga(raw);
|
||||
} catch (const std::exception &) {
|
||||
}
|
||||
if (progress && !map.textures.empty()) progress(static_cast<float>(i + 1U) / static_cast<float>(map.textures.size()));
|
||||
}
|
||||
return set;
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
module;
|
||||
|
||||
// Emscripten/GLES3 headers in the global module fragment (C headers).
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <GLES3/gl3.h>
|
||||
|
||||
// Sized normalized texture formats used by the terrain textures; core in
|
||||
// WebGL2/GLES3 but missing from some GLES3 headers.
|
||||
#ifndef GL_R16
|
||||
#define GL_R16 0x822A
|
||||
#endif
|
||||
#ifndef GL_RGBA16
|
||||
#define GL_RGBA16 0x805B
|
||||
#endif
|
||||
|
||||
#include "glsl_embedded.hpp"
|
||||
|
||||
export module ra3.webgl;
|
||||
|
||||
import std;
|
||||
|
||||
export import ra3.core;
|
||||
import ra3.render;
|
||||
import ra3.terrain;
|
||||
import ra3.client;
|
||||
import ender.log;
|
||||
|
||||
/**
|
||||
* A WebGL (GLES 3.0) presentation backend, a peer of the Vulkan and Direct3D
|
||||
* backends. It owns a GL context on an SDL3 canvas and implements the same
|
||||
* `ra3::client::display` primitives, including the GPU heightfield terrain, so
|
||||
* the web build gets the same renderer as the desktop ones rather than the
|
||||
* software SDL blit.
|
||||
*
|
||||
* The 2D path draws a fullscreen triangle sampling a software `ra3::render::image`;
|
||||
* the terrain path ray-marches the heightfield in a fragment shader (the GLSL ES
|
||||
* port of `terrain.frag`). Textures carry the engine's 0xAARRGGBB pixels, so the
|
||||
* shaders swizzle `.bgra`.
|
||||
*/
|
||||
export namespace ra3::webgl {
|
||||
namespace detail {
|
||||
[[nodiscard]] inline auto key_from_sdl(SDL_Keycode key) -> ra3::render::ui_key {
|
||||
using ra3::render::ui_key;
|
||||
switch (key) {
|
||||
case SDLK_UP: return ui_key::up;
|
||||
case SDLK_DOWN: return ui_key::down;
|
||||
case SDLK_LEFT: return ui_key::left;
|
||||
case SDLK_RIGHT: return ui_key::right;
|
||||
case SDLK_PAGEUP: return ui_key::page_up;
|
||||
case SDLK_PAGEDOWN: return ui_key::page_down;
|
||||
case SDLK_RETURN:
|
||||
case SDLK_KP_ENTER: return ui_key::confirm;
|
||||
case SDLK_ESCAPE: return ui_key::cancel;
|
||||
case SDLK_TAB: return ui_key::tab;
|
||||
case SDLK_BACKSPACE: return ui_key::backspace;
|
||||
default: return ui_key::none;
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] inline auto poll_event(SDL_Window *window, ra3::render::ui_event &out) -> bool {
|
||||
(void) window;
|
||||
SDL_Event event;
|
||||
while (SDL_PollEvent(&event)) {
|
||||
switch (event.type) {
|
||||
case SDL_EVENT_QUIT:
|
||||
out = {};
|
||||
out.type = ra3::render::ui_event_type::quit;
|
||||
return true;
|
||||
case SDL_EVENT_KEY_DOWN:
|
||||
if (const auto key = key_from_sdl(event.key.key); key != ra3::render::ui_key::none) {
|
||||
out = {};
|
||||
out.type = ra3::render::ui_event_type::key;
|
||||
out.key = key;
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case SDL_EVENT_TEXT_INPUT:
|
||||
if (event.text.text[0] != '\0') {
|
||||
out = {};
|
||||
out.type = ra3::render::ui_event_type::text;
|
||||
out.character = event.text.text[0];
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case SDL_EVENT_MOUSE_MOTION:
|
||||
out = {};
|
||||
out.type = ra3::render::ui_event_type::mouse_move;
|
||||
out.x = event.motion.x;
|
||||
out.y = event.motion.y;
|
||||
out.dx = event.motion.xrel;
|
||||
out.dy = event.motion.yrel;
|
||||
out.left = (event.motion.state & SDL_BUTTON_LMASK) != 0U;
|
||||
return true;
|
||||
case SDL_EVENT_MOUSE_BUTTON_DOWN:
|
||||
out = {};
|
||||
out.type = ra3::render::ui_event_type::mouse_button;
|
||||
out.x = event.button.x;
|
||||
out.y = event.button.y;
|
||||
out.left = event.button.button == SDL_BUTTON_LEFT;
|
||||
return true;
|
||||
case SDL_EVENT_MOUSE_WHEEL:
|
||||
out = {};
|
||||
out.type = ra3::render::ui_event_type::wheel;
|
||||
out.wheel = event.wheel.y;
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
[[nodiscard]] inline auto key_down(ra3::render::ui_key key) -> bool {
|
||||
const bool *keys = SDL_GetKeyboardState(nullptr);
|
||||
if (keys == nullptr) return false;
|
||||
switch (key) {
|
||||
case ra3::render::ui_key::up: return keys[SDL_SCANCODE_W] || keys[SDL_SCANCODE_UP];
|
||||
case ra3::render::ui_key::down: return keys[SDL_SCANCODE_S] || keys[SDL_SCANCODE_DOWN];
|
||||
case ra3::render::ui_key::left: return keys[SDL_SCANCODE_A] || keys[SDL_SCANCODE_LEFT];
|
||||
case ra3::render::ui_key::right: return keys[SDL_SCANCODE_D] || keys[SDL_SCANCODE_RIGHT];
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Camera + terrain uniforms (5 x vec4), matching the other backends. */
|
||||
[[nodiscard]] inline auto terrain_uniforms(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s,
|
||||
float aspect) -> std::array<float, 20> {
|
||||
return {camera.target_x,
|
||||
camera.target_y,
|
||||
camera.yaw,
|
||||
camera.height,
|
||||
camera.pitch,
|
||||
camera.fov,
|
||||
terrain.water_z,
|
||||
terrain.has_water ? 1.0F : 0.0F,
|
||||
0.45F,
|
||||
0.35F,
|
||||
0.82F,
|
||||
0.38F,
|
||||
static_cast<float>(terrain.width),
|
||||
static_cast<float>(terrain.height),
|
||||
0.0F,
|
||||
terrain.z_scale,
|
||||
time_s,
|
||||
0.0F,
|
||||
terrain.cell_span,
|
||||
aspect};
|
||||
}
|
||||
} // namespace detail
|
||||
|
||||
class webgl_display final : public ra3::client::display {
|
||||
public:
|
||||
webgl_display() = default;
|
||||
webgl_display(const webgl_display &) = delete;
|
||||
auto operator=(const webgl_display &) -> webgl_display & = delete;
|
||||
~webgl_display() override { this->shutdown(); }
|
||||
|
||||
[[nodiscard]] auto init(const ra3::client::display_options &options) -> bool override {
|
||||
if (!SDL_Init(SDL_INIT_VIDEO)) return false;
|
||||
this->fps_limit_ = options.fps_limit;
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES);
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0);
|
||||
window_ = SDL_CreateWindow(options.title.c_str(), options.width, options.height,
|
||||
SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | (options.fullscreen ? SDL_WINDOW_FULLSCREEN : 0));
|
||||
if (window_ == nullptr) {
|
||||
SDL_Quit();
|
||||
return false;
|
||||
}
|
||||
SDL_RaiseWindow(window_);
|
||||
context_ = SDL_GL_CreateContext(window_);
|
||||
if (context_ == nullptr) {
|
||||
ender::log::error(std::format("ra3.webgl: SDL_GL_CreateContext failed: {}", SDL_GetError()));
|
||||
this->shutdown();
|
||||
return false;
|
||||
}
|
||||
SDL_GL_SetSwapInterval(options.fps_limit == 0 ? 1 : 0);
|
||||
if (!this->create_scene_pipeline()) {
|
||||
this->shutdown();
|
||||
return false;
|
||||
}
|
||||
glGenVertexArrays(1, &vao_);
|
||||
glBindVertexArray(vao_);
|
||||
SDL_StartTextInput(window_);
|
||||
ender::log::info(std::format("ra3.webgl: WebGL backend initialized ({})", reinterpret_cast<const char *>(glGetString(GL_VERSION))));
|
||||
return true;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto present(const ra3::render::image &frame, const ra3::render::view_rect &dest, bool changed) -> bool override {
|
||||
if (frame.empty()) return false;
|
||||
if (!this->ensure_size()) return false;
|
||||
if (changed || scene_tex_ == 0U || frame.width() != scene_w_ || frame.height() != scene_h_) {
|
||||
this->upload_scene(frame);
|
||||
}
|
||||
const auto [window_w, window_h] = this->window_size();
|
||||
if (window_w <= 0 || window_h <= 0) return true;
|
||||
|
||||
glViewport(0, 0, window_w, window_h);
|
||||
glDisable(GL_DEPTH_TEST);
|
||||
glEnable(GL_BLEND);
|
||||
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
||||
glClearColor(0.05F, 0.06F, 0.08F, 1.0F);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
this->draw_fullscreen(scene_program_, scene_rect_loc_, scene_tex_, dest, window_w, window_h);
|
||||
SDL_GL_SwapWindow(window_);
|
||||
return true;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto poll_event(ra3::render::ui_event &out) -> bool override { return detail::poll_event(window_, out); }
|
||||
|
||||
[[nodiscard]] auto window_size() const -> std::pair<int, int> override {
|
||||
int w = 0;
|
||||
int h = 0;
|
||||
SDL_GetWindowSizeInPixels(window_, &w, &h);
|
||||
return {w, h};
|
||||
}
|
||||
|
||||
[[nodiscard]] auto key_down(ra3::render::ui_key key) const -> bool override { return detail::key_down(key); }
|
||||
|
||||
[[nodiscard]] auto supports_terrain() const -> bool override { return true; }
|
||||
|
||||
[[nodiscard]] auto present_terrain(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s,
|
||||
const ra3::client::terrain_overlay &overlay) -> bool override {
|
||||
if (!terrain_ready_) {
|
||||
if (!this->create_terrain_pipeline()) return false;
|
||||
if (!this->create_terrain_textures(terrain)) return false;
|
||||
terrain_ready_ = true;
|
||||
ender::log::info(std::format("ra3.webgl: terrain ready ({}x{}, {} layers)", terrain.width, terrain.height, terrain.layer_count));
|
||||
}
|
||||
if (!this->ensure_size()) return false;
|
||||
if (overlay.label_changed) this->upload_overlay(label_tex_, overlay.label);
|
||||
if (overlay.minimap_changed) this->upload_overlay(minimap_tex_, overlay.minimap);
|
||||
|
||||
const auto [window_w, window_h] = this->window_size();
|
||||
if (window_w <= 0 || window_h <= 0) return true;
|
||||
const auto aspect = static_cast<float>(window_w) / static_cast<float>(std::max(1, window_h));
|
||||
|
||||
glViewport(0, 0, window_w, window_h);
|
||||
glDisable(GL_DEPTH_TEST);
|
||||
glEnable(GL_BLEND);
|
||||
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
||||
glClearColor(0.45F, 0.55F, 0.70F, 1.0F);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
glBindVertexArray(vao_);
|
||||
|
||||
glUseProgram(terrain_program_);
|
||||
const auto uniforms = detail::terrain_uniforms(terrain, camera, time_s, aspect);
|
||||
glUniform4fv(terrain_cam_loc_, 5, uniforms.data());
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_2D, height_tex_);
|
||||
glActiveTexture(GL_TEXTURE1);
|
||||
glBindTexture(GL_TEXTURE_2D, cell_tex_);
|
||||
glActiveTexture(GL_TEXTURE2);
|
||||
glBindTexture(GL_TEXTURE_2D_ARRAY, atlas_tex_);
|
||||
glUniform1i(terrain_height_loc_, 0);
|
||||
glUniform1i(terrain_cell_loc_, 1);
|
||||
glUniform1i(terrain_atlas_loc_, 2);
|
||||
glDrawArrays(GL_TRIANGLES, 0, 3);
|
||||
|
||||
// Overlays (FPS label, corner minimap) use the image program.
|
||||
const float margin = 12.0F;
|
||||
this->draw_overlay(label_tex_, overlay.label.width(), overlay.label.height(), margin, margin, window_w, window_h);
|
||||
this->draw_overlay(minimap_tex_, overlay.minimap.width(), overlay.minimap.height(),
|
||||
static_cast<float>(window_w) - static_cast<float>(overlay.minimap.width()) - margin, margin, window_w, window_h);
|
||||
SDL_GL_SwapWindow(window_);
|
||||
return true;
|
||||
}
|
||||
|
||||
auto shutdown() -> void override {
|
||||
if (context_ != nullptr) {
|
||||
this->destroy_textures();
|
||||
if (scene_program_ != 0U) glDeleteProgram(scene_program_);
|
||||
if (terrain_program_ != 0U) glDeleteProgram(terrain_program_);
|
||||
if (vao_ != 0U) glDeleteVertexArrays(1, &vao_);
|
||||
SDL_GL_DestroyContext(context_);
|
||||
}
|
||||
if (window_ != nullptr) SDL_DestroyWindow(window_);
|
||||
SDL_Quit();
|
||||
window_ = nullptr;
|
||||
context_ = nullptr;
|
||||
vao_ = 0U;
|
||||
scene_program_ = terrain_program_ = 0U;
|
||||
terrain_ready_ = false;
|
||||
scene_w_ = scene_h_ = 0U;
|
||||
swapchain_w_ = swapchain_h_ = 0;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto name() const -> std::string_view override { return "webgl"; }
|
||||
|
||||
private:
|
||||
[[nodiscard]] auto compile(unsigned type, const char *source, std::string_view what) -> GLuint {
|
||||
const GLuint shader = glCreateShader(type);
|
||||
glShaderSource(shader, 1, &source, nullptr);
|
||||
glCompileShader(shader);
|
||||
GLint ok = GL_FALSE;
|
||||
glGetShaderiv(shader, GL_COMPILE_STATUS, &ok);
|
||||
if (ok == GL_FALSE) {
|
||||
std::array<char, 1024> log{};
|
||||
glGetShaderInfoLog(shader, static_cast<GLsizei>(log.size()), nullptr, log.data());
|
||||
ender::log::error(std::format("ra3.webgl: {} shader compile failed: {}", what, log.data()));
|
||||
glDeleteShader(shader);
|
||||
return 0U;
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto link(const char *vertex, const char *fragment, std::string_view what) -> GLuint {
|
||||
const GLuint vs = this->compile(GL_VERTEX_SHADER, vertex, what);
|
||||
const GLuint fs = this->compile(GL_FRAGMENT_SHADER, fragment, what);
|
||||
if (vs == 0U || fs == 0U) {
|
||||
if (vs != 0U) glDeleteShader(vs);
|
||||
if (fs != 0U) glDeleteShader(fs);
|
||||
return 0U;
|
||||
}
|
||||
const GLuint program = glCreateProgram();
|
||||
glAttachShader(program, vs);
|
||||
glAttachShader(program, fs);
|
||||
glLinkProgram(program);
|
||||
glDeleteShader(vs);
|
||||
glDeleteShader(fs);
|
||||
GLint ok = GL_FALSE;
|
||||
glGetProgramiv(program, GL_LINK_STATUS, &ok);
|
||||
if (ok == GL_FALSE) {
|
||||
std::array<char, 1024> log{};
|
||||
glGetProgramInfoLog(program, static_cast<GLsizei>(log.size()), nullptr, log.data());
|
||||
ender::log::error(std::format("ra3.webgl: {} program link failed: {}", what, log.data()));
|
||||
glDeleteProgram(program);
|
||||
return 0U;
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto create_scene_pipeline() -> bool {
|
||||
scene_program_ = this->link(ra3_shaders::webgl_scene_vert_glsl, ra3_shaders::webgl_scene_frag_glsl, "scene");
|
||||
if (scene_program_ == 0U) return false;
|
||||
scene_rect_loc_ = glGetUniformLocation(scene_program_, "u_rect");
|
||||
glUseProgram(scene_program_);
|
||||
glUniform1i(glGetUniformLocation(scene_program_, "u_scene"), 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto create_terrain_pipeline() -> bool {
|
||||
terrain_program_ = this->link(ra3_shaders::webgl_terrain_vert_glsl, ra3_shaders::webgl_terrain_frag_glsl, "terrain");
|
||||
if (terrain_program_ == 0U) return false;
|
||||
terrain_cam_loc_ = glGetUniformLocation(terrain_program_, "u_data");
|
||||
terrain_height_loc_ = glGetUniformLocation(terrain_program_, "u_heightmap");
|
||||
terrain_cell_loc_ = glGetUniformLocation(terrain_program_, "u_celldata");
|
||||
terrain_atlas_loc_ = glGetUniformLocation(terrain_program_, "u_atlas");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** (Re)create the 2D texture used by the scene/overlay path. */
|
||||
auto make_texture2d(GLuint &texture, GLint internal, GLenum format, GLenum type, int w, int h, const void *data, bool linear,
|
||||
bool wrap) -> void {
|
||||
if (texture == 0U) glGenTextures(1, &texture);
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, internal, w, h, 0, format, type, data);
|
||||
if (const GLenum error = glGetError(); error != GL_NO_ERROR) {
|
||||
ender::log::error(std::format("ra3.webgl: texImage2D failed: internal=0x{:X} format=0x{:X} type=0x{:X} size={}x{} err=0x{:X}",
|
||||
static_cast<unsigned>(internal), static_cast<unsigned>(format), static_cast<unsigned>(type), w, h,
|
||||
static_cast<unsigned>(error)));
|
||||
}
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, linear ? GL_LINEAR : GL_NEAREST);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, linear ? GL_LINEAR : GL_NEAREST);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrap ? GL_REPEAT : GL_CLAMP_TO_EDGE);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrap ? GL_REPEAT : GL_CLAMP_TO_EDGE);
|
||||
}
|
||||
|
||||
auto upload_scene(const ra3::render::image &frame) -> void {
|
||||
// The image is 0xAARRGGBB (BGRA in memory); store it as RGBA and let
|
||||
// the shader swizzle `.bgra`.
|
||||
this->make_texture2d(scene_tex_, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, static_cast<int>(frame.width()),
|
||||
static_cast<int>(frame.height()), frame.data(), true, false);
|
||||
scene_w_ = frame.width();
|
||||
scene_h_ = frame.height();
|
||||
}
|
||||
|
||||
auto upload_overlay(GLuint &texture, const ra3::render::image &source) -> void {
|
||||
if (source.empty()) return;
|
||||
this->make_texture2d(texture, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, static_cast<int>(source.width()),
|
||||
static_cast<int>(source.height()), source.data(), true, false);
|
||||
}
|
||||
|
||||
[[nodiscard]] auto create_terrain_textures(const ra3::terrain::gpu_terrain &terrain) -> bool {
|
||||
this->make_texture2d(height_tex_, GL_R16, GL_RED, GL_UNSIGNED_SHORT, static_cast<int>(terrain.width),
|
||||
static_cast<int>(terrain.height), terrain.heights.data(), false, false);
|
||||
this->make_texture2d(cell_tex_, GL_RGBA16, GL_RGBA, GL_UNSIGNED_SHORT, static_cast<int>(terrain.width),
|
||||
static_cast<int>(terrain.height), terrain.cell_data.data(), false, false);
|
||||
if (atlas_tex_ == 0U) glGenTextures(1, &atlas_tex_);
|
||||
glBindTexture(GL_TEXTURE_2D_ARRAY, atlas_tex_);
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||
glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, static_cast<GLsizei>(terrain.layer_size), static_cast<GLsizei>(terrain.layer_size),
|
||||
static_cast<GLsizei>(terrain.layer_count), 0, GL_RGBA, GL_UNSIGNED_BYTE, terrain.layers.data());
|
||||
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_REPEAT);
|
||||
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_REPEAT);
|
||||
return true;
|
||||
}
|
||||
|
||||
auto destroy_textures() -> void {
|
||||
for (GLuint *texture: {&scene_tex_, &height_tex_, &cell_tex_, &atlas_tex_, &label_tex_, &minimap_tex_}) {
|
||||
if (*texture != 0U) glDeleteTextures(1, texture);
|
||||
*texture = 0U;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resize the backing store when the canvas changed; false on failure. */
|
||||
[[nodiscard]] auto ensure_size() -> bool {
|
||||
const auto [width, height] = this->window_size();
|
||||
if (width <= 0 || height <= 0) return true;
|
||||
swapchain_w_ = width;
|
||||
swapchain_h_ = height;
|
||||
return true;
|
||||
}
|
||||
|
||||
auto draw_fullscreen(GLuint program, GLint rect_location, GLuint texture, const ra3::render::view_rect &dest, int window_w, int window_h)
|
||||
-> void {
|
||||
glUseProgram(program);
|
||||
glBindVertexArray(vao_);
|
||||
const float rect[4] = {dest.x / static_cast<float>(window_w), dest.y / static_cast<float>(window_h),
|
||||
dest.w / static_cast<float>(window_w), dest.h / static_cast<float>(window_h)};
|
||||
glUniform4fv(rect_location, 1, rect);
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
glDrawArrays(GL_TRIANGLES, 0, 3);
|
||||
}
|
||||
|
||||
auto draw_overlay(GLuint texture, core::uint32 w, core::uint32 h, float x, float y, int window_w, int window_h) -> void {
|
||||
if (texture == 0U || w == 0U || h == 0U) return;
|
||||
const ra3::render::view_rect rect{x, y, static_cast<float>(w), static_cast<float>(h)};
|
||||
this->draw_fullscreen(scene_program_, scene_rect_loc_, texture, rect, window_w, window_h);
|
||||
}
|
||||
|
||||
SDL_Window *window_ = nullptr;
|
||||
SDL_GLContext context_ = nullptr;
|
||||
GLuint vao_ = 0U;
|
||||
|
||||
GLuint scene_program_ = 0U;
|
||||
GLint scene_rect_loc_ = -1;
|
||||
GLuint terrain_program_ = 0U;
|
||||
GLint terrain_cam_loc_ = -1;
|
||||
GLint terrain_height_loc_ = -1;
|
||||
GLint terrain_cell_loc_ = -1;
|
||||
GLint terrain_atlas_loc_ = -1;
|
||||
|
||||
GLuint scene_tex_ = 0U;
|
||||
core::uint32 scene_w_ = 0U;
|
||||
core::uint32 scene_h_ = 0U;
|
||||
bool terrain_ready_ = false;
|
||||
GLuint height_tex_ = 0U;
|
||||
GLuint cell_tex_ = 0U;
|
||||
GLuint atlas_tex_ = 0U;
|
||||
GLuint label_tex_ = 0U;
|
||||
GLuint minimap_tex_ = 0U;
|
||||
int swapchain_w_ = 0;
|
||||
int swapchain_h_ = 0;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export module ra3.webgl;
|
||||
|
||||
import std;
|
||||
|
||||
export import ra3.core;
|
||||
import ra3.render;
|
||||
import ra3.client;
|
||||
|
||||
/**
|
||||
* Fallback WebGL backend for builds without Emscripten. `init` fails so the
|
||||
* caller can select another display; the class name matches the real
|
||||
* `ra3.webgl` module so the backend factory in `ra3.display` compiles unchanged.
|
||||
*/
|
||||
export namespace ra3::webgl {
|
||||
class webgl_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 "webgl(null)"; }
|
||||
};
|
||||
}
|
||||
Vendored
+21
@@ -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.
|
||||
Vendored
+170
@@ -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
@@ -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__)) && !defined(__EMSCRIPTEN__)
|
||||
#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__)) && !defined(__EMSCRIPTEN__)
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user