v0.4.0: Direct3D 11/12 backends, in-game renderer selection, per-run logging

- ra3.dx: D3D11 and D3D12 presentation backends (runtime HLSL via d3dcompiler);
  2D image blit and the GPU heightfield terrain raymarch, with the corner
  minimap/FPS overlays. Non-Windows builds link a null fallback.
- ra3.display: preferred backend plus ordered fallback (Vulkan/D3D11/D3D12/SDL);
  menu gains a Renderer option and the CLI gains --dx11/--dx12/--sdl, which the
  render command now honours too.
- vendor libenderlog (MIT): every run writes openra3.log next to the exe and
  archives the previous run's log; records at warn and above carry a call stack
  (native fallback, since libc++ has no <stacktrace>).
- Windows crash reporter writes openra3_crash.log (faulting module + backtrace);
  D3D/DXGI diagnostics are routed through the logger.
This commit is contained in:
EnderTheCoder
2026-09-28 13:03:37 +08:00
parent 59261868e1
commit 3caf18409b
16 changed files with 2815 additions and 70 deletions
+1
View File
@@ -30,3 +30,4 @@ compile_commands.json
# local data dumps / RE artifacts # local data dumps / RE artifacts
re-data/ re-data/
*.log *.log
*.log.[0-9]*
+66 -3
View File
@@ -25,7 +25,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_SCAN_FOR_MODULES ON) set(CMAKE_CXX_SCAN_FOR_MODULES ON)
project(OpenRA3 VERSION 0.3.0 LANGUAGES C CXX) project(OpenRA3 VERSION 0.4.0 LANGUAGES C CXX)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
@@ -112,6 +112,36 @@ function(openra3_embed_spirv out_header)
file(WRITE "${out_header}" "${content}") file(WRITE "${out_header}" "${content}")
endfunction() endfunction()
# Embed the HLSL sources as string literals. The Direct3D backends compile them
# at runtime with d3dcompiler_47, so no HLSL compiler is needed at build time
# (the Windows target cross-compiles from Linux).
function(openra3_embed_hlsl out_header)
set(content "// Generated by CMake from shaders/*.hlsl.\n")
string(APPEND content "#pragma once\n\nnamespace ra3_shaders {\n")
foreach(src IN LISTS ARGN)
if(NOT EXISTS "${src}")
message(FATAL_ERROR "Missing HLSL shader ${src}")
endif()
get_filename_component(base "${src}" NAME_WE)
string(MAKE_C_IDENTIFIER "${base}" ident)
string(APPEND ident "_hlsl")
file(READ "${src}" text)
string(APPEND content "inline constexpr const char ${ident}[] = R\"RA3HLSL(${text})RA3HLSL\";\n\n")
endforeach()
string(APPEND content "} // namespace ra3_shaders\n")
file(WRITE "${out_header}" "${content}")
endfunction()
# --- logging (vendored libenderlog, MIT) -------------------------------------
# A standalone C++26 module logger (`import ender.log;`) with a per-run
# archiving file sink. libc++ has no <stacktrace>, so on this toolchain the
# module records call sites but no stacks; it defaults ENDERLOG_HAS_STACKTRACE
# to 0 without any define.
add_library(ra3_enderlog STATIC)
target_sources(ra3_enderlog PUBLIC FILE_SET CXX_MODULES FILES third_party/libenderlog/src/ender.log.cppm)
target_compile_features(ra3_enderlog PUBLIC cxx_std_26)
openra3_target_defaults(ra3_enderlog)
# --- engine core ------------------------------------------------------------- # --- engine core -------------------------------------------------------------
add_library(ra3_core STATIC) add_library(ra3_core STATIC)
target_sources(ra3_core PUBLIC FILE_SET CXX_MODULES FILES src/core/ra3.core.cppm) target_sources(ra3_core PUBLIC FILE_SET CXX_MODULES FILES src/core/ra3.core.cppm)
@@ -210,16 +240,49 @@ else()
endif() endif()
openra3_target_defaults(ra3_vulkan) openra3_target_defaults(ra3_vulkan)
# --- Direct3D viewer (D3D11 + D3D12, or null fallback) ------------------------
# Direct3D is Windows-only, so the real backends build only for the MinGW target;
# every other platform links the `ra3.dx.null` fallback that fails `init` cleanly.
option(OPENRA3_DX "Build the Direct3D 11/12 viewer" ON)
set(OPENRA3_HAS_DX OFF)
if(OPENRA3_DX AND WIN32 AND OPENRA3_HAS_SDL3)
set(OPENRA3_HAS_DX ON)
endif()
if(OPENRA3_HAS_DX)
message(STATUS "OpenRA3: Direct3D 11/12 viewer enabled (runtime HLSL via d3dcompiler)")
else()
message(STATUS "OpenRA3: Direct3D viewer disabled - offscreen image only")
endif()
add_library(ra3_dx STATIC)
if(OPENRA3_HAS_DX)
openra3_embed_hlsl(
"${CMAKE_CURRENT_BINARY_DIR}/generated/dx_shaders_embedded.hpp"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/dx_scene.hlsl"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/dx_terrain.hlsl"
)
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/dx_scene.hlsl"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/dx_terrain.hlsl")
target_sources(ra3_dx PUBLIC FILE_SET CXX_MODULES FILES src/dx/ra3.dx.cppm)
target_include_directories(ra3_dx PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
target_link_libraries(ra3_dx PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_enderlog openra3_sdl3 d3d11 d3d12 dxgi d3dcompiler)
else()
target_sources(ra3_dx PUBLIC FILE_SET CXX_MODULES FILES src/dx/ra3.dx.null.cppm)
target_link_libraries(ra3_dx PUBLIC ra3_core ra3_render ra3_client)
endif()
openra3_target_defaults(ra3_dx)
# --- display backend selection ------------------------------------------------ # --- display backend selection ------------------------------------------------
add_library(ra3_display STATIC) add_library(ra3_display STATIC)
target_sources(ra3_display PUBLIC FILE_SET CXX_MODULES FILES src/display/ra3.display.cppm) target_sources(ra3_display PUBLIC FILE_SET CXX_MODULES FILES src/display/ra3.display.cppm)
target_link_libraries(ra3_display PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_ui ra3_vulkan) target_link_libraries(ra3_display PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_ui ra3_vulkan ra3_dx)
openra3_target_defaults(ra3_display) openra3_target_defaults(ra3_display)
# --- umbrella ----------------------------------------------------------------- # --- umbrella -----------------------------------------------------------------
add_library(ra3 STATIC) add_library(ra3 STATIC)
target_sources(ra3 PUBLIC FILE_SET CXX_MODULES FILES src/ra3.cppm) target_sources(ra3 PUBLIC FILE_SET CXX_MODULES FILES src/ra3.cppm)
target_link_libraries(ra3 PUBLIC ra3_core ra3_logic ra3_client ra3_data ra3_game ra3_fs ra3_map ra3_skirmish ra3_render ra3_terrain ra3_display ra3_ui ra3_vulkan) target_link_libraries(ra3 PUBLIC ra3_core ra3_logic ra3_client ra3_data ra3_game ra3_fs ra3_map ra3_skirmish ra3_render ra3_terrain ra3_display
ra3_ui ra3_vulkan ra3_dx ra3_enderlog)
openra3_target_defaults(ra3) openra3_target_defaults(ra3)
# --- executable --------------------------------------------------------------- # --- executable ---------------------------------------------------------------
+19 -4
View File
@@ -18,14 +18,16 @@ Ghidra.
## Status ## Status
OpenRA3 is at **v0.3.0**. The engine compiles and runs headless, and a **minimal OpenRA3 is at **v0.4.0**. The engine compiles and runs headless, and a **minimal
skirmish** is playable: it reads a real multiplayer map out of your install, skirmish** is playable: it reads a real multiplayer map out of your install,
recovers the player start waypoints, and simulates two sides building a base, recovers the player start waypoints, and simulates two sides building a base,
extracting ore and fighting until one side is wiped out. The balance is the extracting ore and fighting until one side is wiped out. The balance is the
**retail Red Alert 3 balance**, pinned in `ra3.data` from EA's open RA3 XML: **retail Red Alert 3 balance**, pinned in `ra3.data` from EA's open RA3 XML:
damage types, `ArmorTemplate` percentages, weapon target masks, build costs and damage types, `ArmorTemplate` percentages, weapon target masks, build costs and
the ore economy. Presentation is a **Vulkan** backend (`ra3.vulkan`) with a null the ore economy. Presentation offers **Vulkan** (`ra3.vulkan`) and **Direct3D 11 / 12**
fallback; the software renderer still produces headless images. Terrain tiles (`ra3.dx`) GPU backends with an SDL software blit and null fallbacks; the
backend is selectable from the in-game menu and the software renderer still
produces headless images. Terrain tiles
cross-fade the way the retail `Terrain.fx` does (a per-cell blend ramp plus a cross-fade the way the retail `Terrain.fx` does (a per-cell blend ramp plus a
gutter-padded atlas), so material boundaries are smooth instead of a grid of gutter-padded atlas), so material boundaries are smooth instead of a grid of
hard lines. An in-window **menu** lists the maps by their localized name and hard lines. An in-window **menu** lists the maps by their localized name and
@@ -83,7 +85,7 @@ built-in test map so the project still builds and runs in CI.
| `src/core/ra3.core.cppm` | fundamental types, math, strings, random, message stream | | `src/core/ra3.core.cppm` | fundamental types, math, strings, random, message stream |
| `src/logic/ra3.logic.cppm` | objects, players, teams, spatial partition, game loop | | `src/logic/ra3.logic.cppm` | objects, players, teams, spatial partition, game loop |
| `src/client/ra3.client.cppm` | `display` abstraction + shared interactive loops + client facade | | `src/client/ra3.client.cppm` | `display` abstraction + shared interactive loops + client facade |
| `src/display/ra3.display.cppm` | picks the backend (Vulkan, then SDL) for the app | | `src/display/ra3.display.cppm` | picks the backend (Vulkan/D3D11/D3D12, then SDL) for the app |
| `src/game/ra3.game.cppm` | RA3 sides, player templates, skirmish defaults | | `src/game/ra3.game.cppm` | RA3 sides, player templates, skirmish defaults |
| `src/data/ra3.data.cppm` | retail RA3 balance: damage types, armour, weapons, units, economy | | `src/data/ra3.data.cppm` | retail RA3 balance: damage types, armour, weapons, units, economy |
| `src/fs/ra3.fs.cppm` | `BIG4` archives, RefPack codec, local install locator | | `src/fs/ra3.fs.cppm` | `BIG4` archives, RefPack codec, local install locator |
@@ -93,6 +95,8 @@ built-in test map so the project still builds and runs in CI.
| `src/render/ra3.render.cppm` | ARGB framebuffer, TGA decode, BMP encode, map compositing, bitmap-font text | | `src/render/ra3.render.cppm` | ARGB framebuffer, TGA decode, BMP encode, map compositing, bitmap-font text |
| `src/ui/ra3.ui.*.cppm` | SDL3 window viewer and menu (null backend when SDL3 is absent) | | `src/ui/ra3.ui.*.cppm` | SDL3 window viewer and menu (null backend when SDL3 is absent) |
| `src/vulkan/ra3.vulkan.*.cppm` | Vulkan presentation backend and menu (null fallback without a loader) | | `src/vulkan/ra3.vulkan.*.cppm` | Vulkan presentation backend and menu (null fallback without a loader) |
| `src/dx/ra3.dx.*.cppm` | Direct3D 11/12 presentation backend (runtime HLSL; null fallback off Windows) |
| `third_party/libenderlog/` | vendored C++26 module logger (`import ender.log;`, MIT) with a native stack fallback for libc++ |
| `src/ra3.cppm` | umbrella module re-exporting the SDK | | `src/ra3.cppm` | umbrella module re-exporting the SDK |
| `apps/openra3/main.cpp` | `menu` / `maps` / `skirmish` / `render` CLI | | `apps/openra3/main.cpp` | `menu` / `maps` / `skirmish` / `render` CLI |
| `tests/ra3_tests.cpp` | smoke tests (run via `ctest`) | | `tests/ra3_tests.cpp` | smoke tests (run via `ctest`) |
@@ -156,6 +160,17 @@ openra3.exe render --game-dir "C:\Red Alert 3" --vulkan
Vulkan is provided by **vendored volk + headers** (`third_party/`), resolved at Vulkan is provided by **vendored volk + headers** (`third_party/`), resolved at
runtime, so neither image needs a Vulkan SDK. runtime, so neither image needs a Vulkan SDK.
## Logging
Every run writes `openra3.log` next to the executable through the vendored
[`libenderlog`](third_party/libenderlog) module (`import ender.log;`). A file
sink archives the previous log to `openra3.log.<YYYYmmdd-HHMMSS>` on open, so
each run gets its own file; the active file rotates at 4 MiB and the last 10
archives are kept. Records at **`warn` and above** carry a call stack (Windows
`CaptureStackBackTrace` / POSIX `execinfo`, because libc++ has no
`<stacktrace>`). A hard crash also writes `openra3_crash.log` with the faulting
module and a raw backtrace.
## Running a skirmish ## Running a skirmish
```bash ```bash
+1 -1
View File
@@ -1 +1 @@
0.3.0 0.4.0
+160 -12
View File
@@ -1,7 +1,104 @@
#if defined(_WIN32)
#define NOMINMAX
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#endif
import std; import std;
import ra3; import ra3;
import ender.log;
namespace { namespace {
#if defined(_WIN32)
/** Path of the crash report written next to the executable. */
[[nodiscard]] auto crash_log_path() -> const std::filesystem::path & {
static const auto path = [] {
std::wstring buffer(32768U, L'\0');
const DWORD length = GetModuleFileNameW(nullptr, buffer.data(), static_cast<DWORD>(buffer.size()));
buffer.resize(length);
return std::filesystem::path{buffer}.parent_path() / L"openra3_crash.log";
}();
return path;
}
auto crash_write(std::string_view text) -> void {
const HANDLE file = CreateFileW(crash_log_path().wstring().c_str(), FILE_APPEND_DATA, FILE_SHARE_READ, nullptr, OPEN_ALWAYS,
FILE_ATTRIBUTE_NORMAL, nullptr);
if (file == INVALID_HANDLE_VALUE) return;
DWORD written = 0;
WriteFile(file, text.data(), static_cast<DWORD>(text.size()), &written, nullptr);
CloseHandle(file);
}
/** `address` rendered as `module.dll+0xRVA`, or `0x...` when unmapped. */
auto crash_describe(std::uintptr_t address, char *out, std::size_t size) -> void {
HMODULE module = nullptr;
if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(address), &module)) {
wchar_t wide[260] = L"?";
GetModuleFileNameW(module, wide, 260U);
char narrow[260] = "?";
WideCharToMultiByte(CP_UTF8, 0, wide, -1, narrow, sizeof(narrow), nullptr, nullptr);
const char *base = std::strrchr(narrow, '\\');
const auto rva = address - reinterpret_cast<std::uintptr_t>(module);
std::snprintf(out, size, "%s+0x%llX", base != nullptr ? base + 1 : narrow, static_cast<unsigned long long>(rva));
return;
}
std::snprintf(out, size, "0x%llX", static_cast<unsigned long long>(address));
}
LONG WINAPI openra3_crash_handler(EXCEPTION_POINTERS *info) {
char line[512];
const auto *record = info->ExceptionRecord;
std::snprintf(line, sizeof(line), "\n=== OpenRA3 crash ===\nmodule=%s\nexception=0x%08lX address=0x%p\n",
"openra3", static_cast<unsigned long>(record->ExceptionCode), record->ExceptionAddress);
crash_write(line);
void *frames[48] = {};
const USHORT count = CaptureStackBackTrace(0U, 48U, frames, nullptr);
for (USHORT i = 0; i < count; ++i) {
char described[320] = {};
crash_describe(reinterpret_cast<std::uintptr_t>(frames[i]), described, sizeof(described));
std::snprintf(line, sizeof(line), " #%02u %s\n", static_cast<unsigned>(i), described);
crash_write(line);
}
crash_write("=== end ===\n");
return EXCEPTION_EXECUTE_HANDLER;
}
auto install_crash_handler() -> void {
SetUnhandledExceptionFilter(&openra3_crash_handler);
std::set_terminate([] {
crash_write("\n=== OpenRA3 std::terminate ===\n");
if (const auto exception = std::current_exception()) {
try {
std::rethrow_exception(exception);
} catch (const std::exception &error) {
char line[512];
std::snprintf(line, sizeof(line), "what(): %s\n", error.what());
crash_write(line);
} catch (...) {
crash_write("what(): (non-std exception)\n");
}
}
std::abort();
});
}
#endif
/**
* Configure the process logger and start a fresh per-run log file.
*
* `ender::log::file_sink` archives the previous `openra3.log` to a
* timestamped file on open, so every run gets its own log and the previous
* run's log is preserved.
*/
auto setup_logging(const std::filesystem::path &exe_dir) -> void {
namespace log = ender::log;
log::configure({.minimum = log::level::info, .stacktrace_from = log::level::warn});
log::add_file_sink(exe_dir / "openra3.log", {.max_file_size = 4U * 1024U * 1024U, .max_archives = 10U});
log::info("OpenRA3 started");
}
auto print_usage() -> void { auto print_usage() -> void {
std::puts("OpenRA3 - Red Alert 3 reconstruction"); std::puts("OpenRA3 - Red Alert 3 reconstruction");
std::puts("usage:"); std::puts("usage:");
@@ -15,6 +112,7 @@ namespace {
std::puts(" [--3d] [--cam-pitch DEG] [--cam-yaw DEG] [--cam-x U] [--cam-y U]"); std::puts(" [--3d] [--cam-pitch DEG] [--cam-yaw DEG] [--cam-x U] [--cam-y U]");
std::puts(" [--cam-height U] [--fov DEG] [--width W] [--height H]"); std::puts(" [--cam-height U] [--fov DEG] [--width W] [--height H]");
std::puts(" [--fullscreen] [--fps N] (0 = vsync)"); std::puts(" [--fullscreen] [--fps N] (0 = vsync)");
std::puts(" [--vulkan] [--dx11] [--dx12] [--sdl] (preferred display backend)");
std::puts("assets: read from <exe_dir>/assets (extracted at build time by the openra3_assets target)"); std::puts("assets: read from <exe_dir>/assets (extracted at build time by the openra3_assets target)");
} }
@@ -29,6 +127,14 @@ namespace {
return std::find(args.begin(), args.end(), name) != args.end(); return std::find(args.begin(), args.end(), name) != args.end();
} }
/** Preferred display backend from the command line (default: Vulkan). */
[[nodiscard]] auto display_backend_from_args(const std::vector<std::string> &args) -> ra3::display::backend {
if (has_flag(args, "--dx11") || has_flag(args, "--d3d11")) return ra3::display::backend::d3d11;
if (has_flag(args, "--dx12") || has_flag(args, "--d3d12")) return ra3::display::backend::d3d12;
if (has_flag(args, "--sdl")) return ra3::display::backend::sdl;
return ra3::display::backend::vulkan;
}
/** Directory the executable lives in. */ /** Directory the executable lives in. */
auto executable_dir(const char *argv0) -> std::filesystem::path { auto executable_dir(const char *argv0) -> std::filesystem::path {
std::error_code ec; std::error_code ec;
@@ -328,7 +434,14 @@ namespace {
options.height = static_cast<int>(height); options.height = static_cast<int>(height);
options.fullscreen = has_flag(args, "--fullscreen"); options.fullscreen = has_flag(args, "--fullscreen");
options.fps_limit = option_value(args, "--fps") ? std::stoi(*option_value(args, "--fps")) : 0; options.fps_limit = option_value(args, "--fps") ? std::stoi(*option_value(args, "--fps")) : 0;
gpu_shown = ra3::display::run_terrain(options, gpu, camera); ra3::render::image mini;
if (has_flag(args, "--minimap")) {
terrain::render_options mini_opts;
mini_opts.scale = 1U;
mini_opts.pitch = 1.0F;
mini = render::downscale(terrain::render(terrain, textures, mini_opts), 220U);
}
gpu_shown = ra3::display::run_terrain(options, gpu, camera, mini, display_backend_from_args(args));
if (gpu_shown) return 0; if (gpu_shown) return 0;
std::printf("GPU terrain unavailable; showing the top-down map instead\n"); std::printf("GPU terrain unavailable; showing the top-down map instead\n");
} }
@@ -409,7 +522,7 @@ namespace {
viewer.height = static_cast<int>(composed.height()); viewer.height = static_cast<int>(composed.height());
viewer.fullscreen = has_flag(args, "--fullscreen"); viewer.fullscreen = has_flag(args, "--fullscreen");
viewer.fps_limit = option_value(args, "--fps") ? std::stoi(*option_value(args, "--fps")) : 0; viewer.fps_limit = option_value(args, "--fps") ? std::stoi(*option_value(args, "--fps")) : 0;
shown = ra3::display::run_image(viewer, composed, camera, has_flag(args, "--vulkan")); shown = ra3::display::run_image(viewer, composed, camera, display_backend_from_args(args));
} }
if (!shown) { if (!shown) {
const auto path = out.value_or("openra3_view.bmp"); const auto path = out.value_or("openra3_view.bmp");
@@ -448,6 +561,7 @@ namespace {
bool thumbnail = false; bool thumbnail = false;
bool fullscreen = false; bool fullscreen = false;
int fps_index = 0; ///< 0 = vsync, 1..3 = 30/60/120, 4 = uncapped. int fps_index = 0; ///< 0 = vsync, 1..3 = 30/60/120, 4 = uncapped.
int renderer = 0; ///< Preferred display backend (see renderer_backends).
std::string out; std::string out;
}; };
@@ -455,6 +569,19 @@ namespace {
inline constexpr std::array<int, 5> fps_caps{0, 30, 60, 120, -1}; inline constexpr std::array<int, 5> fps_caps{0, 30, 60, 120, -1};
inline constexpr std::array<std::string_view, 5> fps_names{"vsync", "30", "60", "120", "uncapped"}; inline constexpr std::array<std::string_view, 5> fps_names{"vsync", "30", "60", "120", "uncapped"};
/**
* Preferred display backends offered by the menu. Each entry is tried first,
* then the others in turn, so an unavailable backend degrades gracefully.
* Direct3D is Windows-only; on other hosts those entries fall through.
*/
inline constexpr std::array<ra3::display::backend, 4> renderer_backends{ra3::display::backend::vulkan, ra3::display::backend::d3d11,
ra3::display::backend::d3d12, ra3::display::backend::sdl};
inline constexpr std::array<std::string_view, 4> renderer_names{"Vulkan", "Direct3D 11", "Direct3D 12", "SDL (software)"};
[[nodiscard]] inline auto renderer_backend(int index) -> ra3::display::backend {
return renderer_backends[static_cast<std::size_t>(std::clamp(index, 0, static_cast<int>(renderer_backends.size()) - 1))];
}
inline constexpr int menu_width = 1280; inline constexpr int menu_width = 1280;
inline constexpr int menu_height = 720; inline constexpr int menu_height = 720;
@@ -479,7 +606,7 @@ namespace {
return buffer; return buffer;
} }
[[nodiscard]] auto menu_field_count() -> int { return 19; } [[nodiscard]] auto menu_field_count() -> int { return 20; }
[[nodiscard]] auto menu_field_label(int field) -> std::string_view { [[nodiscard]] auto menu_field_label(int field) -> std::string_view {
switch (field) { switch (field) {
@@ -501,6 +628,7 @@ namespace {
case 15: return "Fullscreen"; case 15: return "Fullscreen";
case 16: return "Frame rate"; case 16: return "Frame rate";
case 17: return "Play"; case 17: return "Play";
case 18: return "Renderer";
default: return "Quit"; default: return "Quit";
} }
} }
@@ -533,6 +661,7 @@ namespace {
case 15: return s.fullscreen ? "yes" : "no"; case 15: return s.fullscreen ? "yes" : "no";
case 16: return std::string{fps_names[static_cast<std::size_t>(std::clamp(s.fps_index, 0, 4))]} + " fps"; case 16: return std::string{fps_names[static_cast<std::size_t>(std::clamp(s.fps_index, 0, 4))]} + " fps";
case 17: return "start"; case 17: return "start";
case 18: return std::string{renderer_names[static_cast<std::size_t>(std::clamp(s.renderer, 0, 3))]};
default: return "exit"; default: return "exit";
} }
} }
@@ -555,6 +684,7 @@ namespace {
case 13: s.thumbnail = !s.thumbnail; break; case 13: s.thumbnail = !s.thumbnail; break;
case 15: s.fullscreen = !s.fullscreen; break; case 15: s.fullscreen = !s.fullscreen; break;
case 16: s.fps_index = (s.fps_index + delta + 5) % 5; break; case 16: s.fps_index = (s.fps_index + delta + 5) % 5; break;
case 18: s.renderer = (s.renderer + delta + 4) % 4; break;
default: break; default: break;
} }
} }
@@ -589,7 +719,7 @@ namespace {
L.rows = std::max(1, (L.list_h - L.S(46)) / L.row_h); L.rows = std::max(1, (L.list_h - L.S(46)) / L.row_h);
L.set_x = L.S(650); L.set_x = L.S(650);
L.set_w = static_cast<int>(width) - L.set_x - L.S(24); L.set_w = static_cast<int>(width) - L.set_x - L.S(24);
L.field_row = std::max(1, L.S(27)); L.field_row = std::max(1, L.S(26));
return L; return L;
} }
@@ -724,9 +854,9 @@ namespace {
st.field = field; st.field = field;
if (field == 17) { if (field == 17) {
st.action = menu_action::play; st.action = menu_action::play;
} else if (field == 18) { } else if (field == 19) {
st.action = menu_action::quit; st.action = menu_action::quit;
} else if (field == 0 || field == 13 || field == 15 || field == 16) { } else if (field == 0 || field == 13 || field == 15 || field == 16 || field == 18) {
menu_adjust(field, st.settings, 1); menu_adjust(field, st.settings, 1);
st.dirty = true; st.dirty = true;
} else if (field == 14) { } else if (field == 14) {
@@ -783,9 +913,9 @@ namespace {
case ui_key::confirm: case ui_key::confirm:
if (st.pane == 0 || st.field == 17) { if (st.pane == 0 || st.field == 17) {
st.action = menu_action::play; st.action = menu_action::play;
} else if (st.field == 18) { } else if (st.field == 19) {
st.action = menu_action::quit; st.action = menu_action::quit;
} else if (st.field == 0 || st.field == 13 || st.field == 15 || st.field == 16) { } else if (st.field == 0 || st.field == 13 || st.field == 15 || st.field == 16 || st.field == 18) {
menu_adjust(st.field, st.settings, 1); menu_adjust(st.field, st.settings, 1);
st.dirty = true; st.dirty = true;
} }
@@ -951,6 +1081,10 @@ namespace {
if (has_flag(args, "--thumbnail")) st.settings.thumbnail = true; if (has_flag(args, "--thumbnail")) st.settings.thumbnail = true;
if (has_flag(args, "--fullscreen")) st.settings.fullscreen = true; if (has_flag(args, "--fullscreen")) st.settings.fullscreen = true;
if (has_flag(args, "--3d")) st.settings.mode = 0; if (has_flag(args, "--3d")) st.settings.mode = 0;
if (has_flag(args, "--vulkan")) st.settings.renderer = 0;
if (has_flag(args, "--dx11") || has_flag(args, "--d3d11")) st.settings.renderer = 1;
if (has_flag(args, "--dx12") || has_flag(args, "--d3d12")) st.settings.renderer = 2;
if (has_flag(args, "--sdl")) st.settings.renderer = 3;
if (const auto value = option_value(args, "--width")) st.settings.width = std::stoi(*value); if (const auto value = option_value(args, "--width")) st.settings.width = std::stoi(*value);
if (const auto value = option_value(args, "--height")) st.settings.height = std::stoi(*value); if (const auto value = option_value(args, "--height")) st.settings.height = std::stoi(*value);
if (const auto value = option_value(args, "--cam-pitch")) st.settings.cam_pitch = std::stof(*value); if (const auto value = option_value(args, "--cam-pitch")) st.settings.cam_pitch = std::stof(*value);
@@ -978,7 +1112,9 @@ namespace {
options.height = menu_height; options.height = menu_height;
options.fullscreen = st.settings.fullscreen; options.fullscreen = st.settings.fullscreen;
options.fps_limit = fps_caps[static_cast<std::size_t>(std::clamp(st.settings.fps_index, 0, 4))]; options.fps_limit = fps_caps[static_cast<std::size_t>(std::clamp(st.settings.fps_index, 0, 4))];
auto display = ra3::display::create(options, true); int applied_renderer = st.settings.renderer;
auto display = ra3::display::create(options, renderer_backend(applied_renderer));
if (display) ender::log::info(std::format("menu display backend: {}", display->name()));
if (!display) { if (!display) {
if (!windowed) return console_menu(assets, maps, names, st.settings); if (!windowed) return console_menu(assets, maps, names, st.settings);
return 0; return 0;
@@ -1006,9 +1142,16 @@ namespace {
return 0; return 0;
} }
// Apply fullscreen / frame-rate changes immediately (re-init the window). // Apply renderer / fullscreen / frame-rate changes (recreate the window).
const int want_fps = fps_caps[static_cast<std::size_t>(std::clamp(st.settings.fps_index, 0, 4))]; const int want_fps = fps_caps[static_cast<std::size_t>(std::clamp(st.settings.fps_index, 0, 4))];
if (st.settings.fullscreen != options.fullscreen || want_fps != options.fps_limit) { if (st.settings.renderer != applied_renderer) {
display->shutdown();
applied_renderer = st.settings.renderer;
options.fullscreen = st.settings.fullscreen;
options.fps_limit = want_fps;
display = ra3::display::create(options, renderer_backend(applied_renderer));
if (!display) return console_menu(assets, maps, names, st.settings);
} else if (st.settings.fullscreen != options.fullscreen || want_fps != options.fps_limit) {
display->shutdown(); display->shutdown();
options.fullscreen = st.settings.fullscreen; options.fullscreen = st.settings.fullscreen;
options.fps_limit = want_fps; options.fps_limit = want_fps;
@@ -1071,8 +1214,13 @@ namespace {
} }
auto main(int argc, char **argv) -> int { auto main(int argc, char **argv) -> int {
#if defined(_WIN32)
install_crash_handler();
#endif
const std::vector<std::string> args{argv + 1, argv + argc}; const std::vector<std::string> args{argv + 1, argv + argc};
const auto assets = executable_dir(argc > 0 ? argv[0] : ".") / "assets"; const auto exe_dir = executable_dir(argc > 0 ? argv[0] : ".");
const auto assets = exe_dir / "assets";
setup_logging(exe_dir);
if (args.empty()) return command_menu({}, assets); if (args.empty()) return command_menu({}, assets);
+22 -5
View File
@@ -62,9 +62,9 @@ umbrella module re-exports the SDK; applications import `ra3` only.
| \ | | \ |
| \ v | \ v
| ra3.display (backend pick) | ra3.display (backend pick)
| / \ | / | \
v ra3.ui ra3.vulkan v ra3.ui ra3.vulkan ra3.dx
| (SDL3) (Vulkan) | (SDL3) (Vulkan) (D3D11/12)
--------------------------------------------------------------------------- ---------------------------------------------------------------------------
v v
L2 simulation ra3.logic ra3.modules ra3.combat ra3.movement L2 simulation ra3.logic ra3.modules ra3.combat ra3.movement
@@ -80,7 +80,8 @@ umbrella module re-exports the SDK; applications import `ra3` only.
The current concrete modules (`ra3.core`, `ra3.logic`, `ra3.data`, The current concrete modules (`ra3.core`, `ra3.logic`, `ra3.data`,
`ra3.skirmish`, `ra3.fs`, `ra3.map`, `ra3.terrain`, `ra3.render`, `ra3.ui.*`, `ra3.skirmish`, `ra3.fs`, `ra3.map`, `ra3.terrain`, `ra3.render`, `ra3.ui.*`,
`ra3.vulkan.*`, `ra3.display`, `ra3.game`, `ra3.client`) are the **seeds** of the `ra3.vulkan.*`, `ra3.dx.*`, `ra3.display`, `ra3.game`, `ra3.client`, and the
vendored `ender.log`) are the **seeds** of the
target modules below. `ra3.skirmish` and `ra3.game` will be absorbed into 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.ai` / `ra3.match`; new modules are added as their subsystems are recovered.
@@ -557,7 +558,8 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
- `[x]` window, streaming-texture blit, input polling - `[x]` window, streaming-texture blit, input polling
- `[ ]` gamepad support `(v0.8)` - `[ ]` gamepad support `(v0.8)`
- **F3 Backend selection (`ra3.display`)** `[D]` - **F3 Backend selection (`ra3.display`)** `[D]`
- `[x]` try Vulkan → SDL → report failure for offscreen fallback - `[x]` preferred backend + ordered fallback (Vulkan / D3D11 / D3D12 / SDL)
- `[x]` in-game Renderer option; report failure for offscreen fallback
- **F4 Input mapping** `[~]` - **F4 Input mapping** `[~]`
- `[x]` keyboard/mouse state, modifier masks - `[x]` keyboard/mouse state, modifier masks
- `[ ]` rebindable keybinds, mouse capture, scroll wheel events `(v0.6)` - `[ ]` rebindable keybinds, mouse capture, scroll wheel events `(v0.6)`
@@ -578,6 +580,21 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
- **F4 Null fallback** `[D]` - **F4 Null fallback** `[D]`
- `[x]` report failure when no Vulkan loader is present - `[x]` report failure when no Vulkan loader is present
### M18b `ra3.dx` — Direct3D 11 / 12 backend `[P]`
- **F1 Device & swapchain** `[D]`
- `[x]` SDL3 window → HWND, DXGI flip-model swapchain, resize
- `[x]` runtime HLSL via `d3dcompiler_47` (no build-time shader compiler)
- **F2 2D image path** `[D]`
- `[x]` BGRA scene texture + fullscreen-triangle blit (D3D11/D3D12)
- **F3 Terrain presentation** `[D]`
- `[x]` GPU heightfield raymarch (HLSL port of `terrain.frag`), D3D11 and D3D12
- **F4 Root signatures / PSOs (D3D12)** `[~]`
- `[x]` root constants, descriptor tables, static samplers, barriers
- `[ ]` shared RHI with Vulkan (see M16 F1) `(v0.6)`
- **F5 Null fallback** `[D]`
- `[x]` non-Windows builds link a stub that fails `init`
### M19 `ra3.audio` — audio `[ ]` ### M19 `ra3.audio` — audio `[ ]`
- **F1 SFX** `[ ]` - **F1 SFX** `[ ]`
+34
View File
@@ -0,0 +1,34 @@
// 2D image blit for the Direct3D backends (the D3D port of scene.vert/scene.frag).
//
// The scene image is drawn as a single fullscreen triangle sampling the
// software-rendered `ra3::render::image`. The constant buffer carries the
// destination rectangle in window-normalized coordinates (y down), so the map
// is letterboxed rather than stretched. D3D clip space has +Y up, so the vertex
// position flips Y relative to the Vulkan shader (which relies on Vulkan's
// +Y-down clip space); the sampled UVs and the image's top-left origin are
// unchanged.
cbuffer RectCB : register(b0) {
float4 rect; // xy = top-left (0..1), zw = size (0..1)
};
Texture2D scene_tex : register(t0);
SamplerState scene_smp : register(s0);
struct VSOut {
float4 pos : SV_Position;
float2 uv : TEXCOORD0;
};
VSOut VSMain(uint vertex_id : SV_VertexID) {
float2 p = float2((vertex_id << 1) & 2, vertex_id & 2);
VSOut o;
o.uv = (p - rect.xy) / rect.zw;
o.pos = float4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
return o;
}
float4 PSMain(VSOut input) : SV_Target {
if (input.uv.x < 0.0 || input.uv.x > 1.0 || input.uv.y < 0.0 || input.uv.y > 1.0) discard;
return scene_tex.Sample(scene_smp, input.uv);
}
+236
View File
@@ -0,0 +1,236 @@
// GPU heightfield raymarcher for the Direct3D backends (the D3D port of
// terrain.vert/terrain.frag).
//
// Textures: heightmap (R16), a per-cell blend record (R16G16B16A16_UNORM: base
// layer, blend layer, three-way layer, packed direction/flags; unpacked with
// *65535) and a texture array of the tile materials (RGBA8, REPEAT). The
// material is sampled continuously (`uv = cell / span`), as the retail
// `Terrain.fx` / OpenSAGE `Terrain.frag` do, so it never restarts at a cell
// edge; material boundaries cross-fade with the SAGE blend ramp.
//
// The Vulkan push constants (20 floats) become a constant buffer.
cbuffer TerrainCB : register(b0) {
float4 cam; // x=target_x, y=target_y, z=yaw, w=height
float4 params; // x=pitch, y=fov, z=water_z, w=has_water
float4 sun; // xyz=sun dir, w=ambient
float4 mapinfo; // x=W, y=H, z=unused, w=z_scale
float4 misc; // x=time, y=unused, z=cells per texture repeat, w=aspect
};
Texture2D<float> heightmap : register(t0);
Texture2D<float4> celldata : register(t1);
Texture2DArray<float4> atlas : register(t2);
SamplerState height_smp : register(s0);
SamplerState cell_smp : register(s1);
SamplerState atlas_smp : register(s2);
static const float CELL = 10.0; // must match ra3::terrain::cell_size
struct VSOut {
float4 pos : SV_Position;
float2 uv : TEXCOORD0;
};
VSOut VSMain(uint vertex_id : SV_VertexID) {
float2 p = float2((vertex_id << 1) & 2, vertex_id & 2);
VSOut o;
o.uv = p;
o.pos = float4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
return o;
}
float height_at(int2 c) {
c = clamp(c, int2(0, 0), int2((int) mapinfo.x - 1, (int) mapinfo.y - 1));
return heightmap.Load(int3(c, 0)) * 65535.0 * mapinfo.w;
}
float world_height(float wx, float wy) {
float world_w = mapinfo.x * CELL;
float world_h = mapinfo.y * CELL;
if (wx < 0.0 || wy < 0.0 || wx >= world_w || wy >= world_h) return -1.0e9;
int2 c = int2((int) (wx / CELL), (int) ((world_h - wy) / CELL));
return height_at(c);
}
float3 sky_color(float3 dir) {
float3 d = normalize(dir);
float3 sun_dir = normalize(sun.xyz);
float t = clamp(d.z, 0.0, 1.0);
float3 horizon = float3(0.70, 0.78, 0.85);
float3 zenith = float3(0.28, 0.48, 0.80);
float3 col = lerp(horizon, zenith, pow(t, 0.6));
float s = max(dot(d, sun_dir), 0.0);
col += float3(1.0, 0.95, 0.82) * pow(s, 300.0) * 1.6; // sun disk
col += float3(1.0, 0.90, 0.72) * pow(s, 8.0) * 0.18; // glow
return col;
}
// The retail SAGE blend ramp: 0 at one edge of the cell, 1 at the opposite.
// Direction: 1 right, 2 top, 4 top-right, 8 top-left; flag bit 0 flips,
// bit 1 marks a two-sided diagonal.
float blend_factor(uint direction, uint flags, float2 f) {
bool flipped = (flags & 1u) != 0u;
bool two_sided = (flags & 2u) != 0u;
if (flipped) {
if (direction == 1u) {
f.x = 1.0 - f.x;
} else if (direction == 2u || direction == 4u || direction == 8u) {
f.y = 1.0 - f.y;
}
}
if (direction == 1u) return f.x;
if (direction == 2u) return f.y;
if (direction == 4u) {
float s = (1.0 - f.x) + (1.0 - f.y);
return two_sided ? 1.0 - clamp(s - 1.0, 0.0, 1.0) : clamp(1.0 - s, 0.0, 1.0);
}
if (direction == 8u) {
float s = f.x + (1.0 - f.y);
return two_sided ? 1.0 - clamp(s - 1.0, 0.0, 1.0) : clamp(1.0 - s, 0.0, 1.0);
}
return 0.0;
}
// Sample one tile material layer at global cell coordinates. The texture repeats
// every `span` cells with REPEAT addressing, so it never restarts at a cell edge.
float3 sample_layer(uint layer, float wx, float wy) {
float span = max(misc.z, 1.0);
uint lw = 0;
uint lh = 0;
uint layer_count = 0;
atlas.GetDimensions(lw, lh, layer_count);
float l = (float) min(layer, layer_count > 0u ? layer_count - 1u : 0u);
return atlas.Sample(atlas_smp, float3(float2(wx, wy) / span, l)).rgb;
}
float4 PSMain(VSOut input) : SV_Target {
float4 p = cam;
float pitch = clamp(params.x, 0.15, 1.45);
float fov = clamp(params.y, 0.3, 1.4);
float world_w = mapinfo.x * CELL;
float world_h = mapinfo.y * CELL;
float cp = cos(pitch);
float3 fwd = float3(cp * sin(p.z), cp * cos(p.z), -sin(pitch));
float3 right = normalize(cross(fwd, float3(0, 0, 1)));
float3 up = cross(right, fwd);
float target_z = world_height(p.x, p.y);
if (target_z < -1.0e8) target_z = 0.0;
float dist = p.w / sin(pitch);
float3 cam_pos = float3(p.x, p.y, target_z + p.w) - fwd * dist;
float2 ndc = float2(input.uv.x * 2.0 - 1.0, 1.0 - input.uv.y * 2.0);
float aspect = misc.w;
float th = tan(fov * 0.5);
float3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th);
if (dir.z >= -1e-4) {
return float4(sky_color(dir), 1.0);
}
// March the heightfield (bounded work: the step grows toward the horizon).
float t = CELL * 0.5;
float dt = CELL * 0.5;
float prev = t;
bool hit = false;
float hit_t = 0.0;
for (int i = 0; i < 512 && t < 60000.0; ++i) {
float3 w = cam_pos + dir * t;
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
prev = t;
dt *= 1.06;
t += dt;
continue;
}
if (params.w > 0.5 && w.z <= params.z) {
hit = true;
hit_t = t;
break;
}
if (w.z <= world_height(w.x, w.y)) {
hit = true;
hit_t = t;
break;
}
prev = t;
dt *= 1.06;
t += dt;
}
if (!hit) {
return float4(sky_color(dir), 1.0);
}
float lo = prev;
float hi = hit_t;
for (int i = 0; i < 6; ++i) {
float mid = 0.5 * (lo + hi);
float3 w = cam_pos + dir * mid;
bool water = params.w > 0.5 && w.z <= params.z;
if (water || w.z <= world_height(w.x, w.y)) {
hi = mid;
} else {
lo = mid;
}
}
float3 hitpos = cam_pos + dir * hi;
float3 sun_dir = normalize(sun.xyz);
float ambient = sun.w;
if (params.w > 0.5 && hitpos.z <= params.z + 0.01) {
// Water: animated normal from a procedural wave, sky reflection + fresnel.
float time = misc.x;
float2 q = hitpos.xy * 0.015;
float nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3);
float ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9);
float3 n = normalize(float3(nx * 0.06, ny * 0.06, 1.0));
float fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0);
float3 deep = float3(0.03, 0.16, 0.28);
float3 refl = sky_color(reflect(dir, n));
float lam = max(0.0, dot(n, sun_dir));
float3 water = lerp(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9));
water += float3(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6; // sun glint
float wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
water = lerp(water, sky_color(float3(dir.x, dir.y, 0.0)), wfog);
return float4(water, 1.0);
}
// Terrain: read the per-cell blend record, sample the base/blend/three-way
// material layers continuously and ramp between them across the cell.
float wx = hitpos.x / CELL;
float wy = (world_h - hitpos.y) / CELL;
int cx = clamp((int) wx, 0, (int) mapinfo.x - 1);
int cy = clamp((int) wy, 0, (int) mapinfo.y - 1);
float fx = wx - floor(wx);
float fy = wy - floor(wy);
uint4 record = (uint4) (celldata.Load(int3(cx, cy, 0)) * 65535.0 + 0.5);
uint packed = record.w;
uint dir1 = packed & 0xFu;
uint flags1 = (packed >> 4u) & 0x3u;
uint dir2 = (packed >> 8u) & 0xFu;
uint flags2 = (packed >> 12u) & 0x3u;
float2 fracUV = float2(fx, fy);
float3 c0 = sample_layer(record.x, wx, wy);
float3 c1 = sample_layer(record.y, wx, wy);
float3 c2 = sample_layer(record.z, wx, wy);
float f1 = blend_factor(dir1, flags1, fracUV);
float f2 = blend_factor(dir2, flags2, fracUV);
float3 albedo = lerp(lerp(c0, c1, f1), c2, f2);
// Per-pixel normal from the heightfield.
float hl = world_height(hitpos.x - CELL, hitpos.y);
float hr = world_height(hitpos.x + CELL, hitpos.y);
float hd = world_height(hitpos.x, hitpos.y - CELL);
float hu = world_height(hitpos.x, hitpos.y + CELL);
float3 n = normalize(float3(hl - hr, hd - hu, 2.0 * CELL));
float lambert = max(0.0, dot(n, sun_dir));
float3 lit = albedo * (ambient + (1.0 - ambient) * lambert);
// Distance haze toward the horizon so the map edge blends into the sky.
float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
lit = lerp(lit, sky_color(float3(dir.x, dir.y, 0.0)), fog);
return float4(lit, 1.0);
}
+9
View File
@@ -499,6 +499,15 @@ export namespace ra3::client {
this->shutdown(); this->shutdown();
return true; return true;
} }
/** GPU terrain viewer with a corner minimap overlay. */
[[nodiscard]] auto run_terrain(const display_options &options, const ra3::terrain::gpu_terrain &terrain, ra3::render::camera3d camera,
const image &minimap) -> bool {
if (!this->init(options)) return false;
(void)this->terrain_loop(terrain, camera, minimap);
this->shutdown();
return true;
}
}; };
/** /**
+104 -45
View File
@@ -8,14 +8,18 @@ import ra3.terrain;
import ra3.client; import ra3.client;
import ra3.ui; import ra3.ui;
import ra3.vulkan; import ra3.vulkan;
import ra3.dx;
/** /**
* Backend selection for the presentation layer. * Backend selection for the presentation layer.
* *
* The app talks only to `ra3::client::display`; this module picks the concrete * The app talks only to `ra3::client::display`; this module picks the concrete
* backend (Vulkan when available, otherwise SDL) so no caller has to know which * backend so no caller has to know which one is in use. The available backends
* one is in use. The GPU terrain path is Vulkan-only and reports failure so the * are Vulkan, Direct3D 11 and Direct3D 12 (both GPU terrain), and the SDL
* caller can fall back to the software renderer. * software blit fallback. The caller may name a preferred backend (the in-game
* menu exposes this); if it does not start, the others are tried in turn. The
* GPU terrain path is Vulkan/D3D-only and reports failure so the caller can fall
* back to the software renderer.
*/ */
export namespace ra3::display { export namespace ra3::display {
using ra3::client::display_options; using ra3::client::display_options;
@@ -27,72 +31,127 @@ export namespace ra3::display {
using menu_frame = std::function<std::optional<image>(const ui_event &, ra3::core::uint32, ra3::core::uint32, bool &)>; using menu_frame = std::function<std::optional<image>(const ui_event &, ra3::core::uint32, ra3::core::uint32, bool &)>;
using camera_frame = std::function<image(const camera3d &, ra3::core::uint32, ra3::core::uint32)>; using camera_frame = std::function<image(const camera3d &, ra3::core::uint32, ra3::core::uint32)>;
/** The backend that actually initialized, or `none`. */ /** A concrete presentation backend, or `none`. */
enum class backend { none, vulkan, sdl }; enum class backend { none, vulkan, d3d11, d3d12, sdl };
/** [[nodiscard]] inline auto backend_name(backend which) -> std::string_view {
* Create and initialize the preferred display (Vulkan, then SDL), or return switch (which) {
* null when neither starts. The caller owns the display and may reuse it for case backend::vulkan: return "vulkan";
* a menu, a loading bar and a viewer in sequence. case backend::d3d11: return "d3d11";
*/ case backend::d3d12: return "d3d12";
[[nodiscard]] inline auto create(const display_options &options, bool prefer_vulkan) -> std::unique_ptr<ra3::client::display> { case backend::sdl: return "sdl";
const auto try_vulkan = [&]() -> std::unique_ptr<ra3::client::display> { default: return "none";
auto d = std::make_unique<ra3::vulkan::vulkan_display>(); }
if (d->init(options)) return d; }
return nullptr;
}; [[nodiscard]] inline auto make_backend(backend which) -> std::unique_ptr<ra3::client::display> {
const auto try_sdl = [&]() -> std::unique_ptr<ra3::client::display> { switch (which) {
auto d = std::make_unique<ra3::ui::sdl_display>(); case backend::vulkan: return std::make_unique<ra3::vulkan::vulkan_display>();
if (d->init(options)) return d; case backend::d3d11: return std::make_unique<ra3::dx::d3d11_display>();
return nullptr; case backend::d3d12: return std::make_unique<ra3::dx::d3d12_display>();
}; case backend::sdl: return std::make_unique<ra3::ui::sdl_display>();
if (prefer_vulkan) { default: return nullptr;
if (auto d = try_vulkan()) return d; }
return try_sdl(); }
/** The order in which backends are attempted for a preferred one. */
[[nodiscard]] inline auto backend_order(backend preferred) -> std::array<backend, 4> {
switch (preferred) {
case backend::d3d11: return {backend::d3d11, backend::d3d12, backend::vulkan, backend::sdl};
case backend::d3d12: return {backend::d3d12, backend::d3d11, backend::vulkan, backend::sdl};
case backend::sdl: return {backend::sdl, backend::d3d11, backend::d3d12, backend::vulkan};
case backend::vulkan:
default: return {backend::vulkan, backend::d3d11, backend::d3d12, backend::sdl};
} }
if (auto d = try_sdl()) return d;
return try_vulkan();
} }
/** /**
* Run `action` on the first display that initializes: Vulkan, then SDL. * Create and initialize the preferred backend, falling back to the others,
* or return null when none starts. The caller owns the display and may reuse
* it for a menu, a loading bar and a viewer in sequence.
*/
[[nodiscard]] inline auto create(const display_options &options, backend preferred) -> std::unique_ptr<ra3::client::display> {
for (const auto which: backend_order(preferred)) {
auto candidate = make_backend(which);
if (candidate && candidate->init(options)) return candidate;
}
return nullptr;
}
/** Create and initialize the preferred display (Vulkan, then SDL). */
[[nodiscard]] inline auto create(const display_options &options, bool prefer_vulkan) -> std::unique_ptr<ra3::client::display> {
return create(options, prefer_vulkan ? backend::vulkan : backend::sdl);
}
/**
* Run `action` on the first backend that initializes.
* *
* `action` must call one of the shared `display` loops; it returns false to * `action` must call one of the shared `display` loops; it returns false to
* mean "this backend did not start", so the next one is tried. * mean "this backend did not start", so the next one is tried.
*/ */
template<typename Fn> template<typename Fn>
[[nodiscard]] auto with_display(bool prefer_vulkan, Fn &&action) -> bool { [[nodiscard]] auto with_display(backend preferred, Fn &&action) -> bool {
if (prefer_vulkan) { for (const auto which: backend_order(preferred)) {
if (auto vk = std::make_unique<ra3::vulkan::vulkan_display>(); action(*vk)) return true; auto candidate = make_backend(which);
} if (candidate && action(*candidate)) return true;
if (auto sdl = std::make_unique<ra3::ui::sdl_display>(); action(*sdl)) return true;
if (!prefer_vulkan) {
if (auto vk = std::make_unique<ra3::vulkan::vulkan_display>(); action(*vk)) return true;
} }
return false; return false;
} }
/** Interactive menu on the first available backend. */ /** Run `action` on the first display that initializes: Vulkan, then SDL. */
[[nodiscard]] inline auto run_menu(const display_options &options, const menu_frame &frame) -> bool { template<typename Fn>
return with_display(true, [&](ra3::client::display &d) { return d.run_menu(options, frame); }); [[nodiscard]] auto with_display(bool prefer_vulkan, Fn &&action) -> bool {
return with_display(prefer_vulkan ? backend::vulkan : backend::sdl, std::forward<Fn>(action));
} }
/** Pan/zoom image viewer on the first available backend. */ /** Interactive menu on the preferred (or first available) backend. */
[[nodiscard]] inline auto run_menu(const display_options &options, const menu_frame &frame, backend preferred = backend::vulkan) -> bool {
return with_display(preferred, [&](ra3::client::display &d) { return d.run_menu(options, frame); });
}
/** Pan/zoom image viewer on the preferred (or first available) backend. */
[[nodiscard]] inline auto run_image(const display_options &options, const image &scene, view_camera camera, backend preferred) -> bool {
return with_display(preferred, [&](ra3::client::display &d) { return d.run_image(options, scene, camera); });
}
/** Pan/zoom image viewer on the preferred (or first available) backend. */
[[nodiscard]] inline auto run_image(const display_options &options, const image &scene, view_camera camera, bool prefer_vulkan) -> bool { [[nodiscard]] inline auto run_image(const display_options &options, const image &scene, view_camera camera, bool prefer_vulkan) -> bool {
return with_display(prefer_vulkan, [&](ra3::client::display &d) { return d.run_image(options, scene, camera); }); return run_image(options, scene, camera, prefer_vulkan ? backend::vulkan : backend::sdl);
} }
/** Software 3D camera viewer on the first available backend. */ /** Software 3D camera viewer on the preferred (or first available) backend. */
[[nodiscard]] inline auto run_camera(const display_options &options, const camera_frame &provider, camera3d camera, bool prefer_vulkan) -> bool { [[nodiscard]] inline auto run_camera(const display_options &options, const camera_frame &provider, camera3d camera, bool prefer_vulkan) -> bool {
return with_display(prefer_vulkan, [&](ra3::client::display &d) { return d.run_camera(options, provider, camera); }); return with_display(prefer_vulkan ? backend::vulkan : backend::sdl,
[&](ra3::client::display &d) { return d.run_camera(options, provider, camera); });
} }
/** /**
* GPU terrain viewer. Vulkan only: returns false when Vulkan is unavailable * GPU terrain viewer on the preferred (or first available) backend. Returns
* so the caller can render offscreen instead. * false when no GPU backend that offers terrain starts, so the caller can
* render offscreen instead.
*/
[[nodiscard]] inline auto run_terrain(const display_options &options, const ra3::terrain::gpu_terrain &terrain, camera3d camera,
backend preferred) -> bool {
return with_display(preferred, [&](ra3::client::display &d) {
if (!d.supports_terrain()) return false;
return d.run_terrain(options, terrain, camera);
});
}
/** GPU terrain viewer with a corner minimap overlay. */
[[nodiscard]] inline auto run_terrain(const display_options &options, const ra3::terrain::gpu_terrain &terrain, camera3d camera,
const image &minimap, backend preferred) -> bool {
return with_display(preferred, [&](ra3::client::display &d) {
if (!d.supports_terrain()) return false;
return d.run_terrain(options, terrain, camera, minimap);
});
}
/**
* GPU terrain viewer. Vulkan/D3D only: returns false when the preferred GPU
* backend is unavailable so the caller can render offscreen instead.
*/ */
[[nodiscard]] inline auto run_terrain(const display_options &options, const ra3::terrain::gpu_terrain &terrain, camera3d camera) -> bool { [[nodiscard]] inline auto run_terrain(const display_options &options, const ra3::terrain::gpu_terrain &terrain, camera3d camera) -> bool {
auto vk = std::make_unique<ra3::vulkan::vulkan_display>(); return run_terrain(options, terrain, camera, backend::vulkan);
return vk->run_terrain(options, terrain, camera);
} }
} }
+1392
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
export module ra3.dx;
import std;
export import ra3.core;
import ra3.render;
import ra3.client;
/**
* Fallback Direct3D backends used when the build has no Windows/D3D (for
* example the Linux development build). `init` fails so the caller can select
* another display; the class names match the real `ra3.dx` module so the
* backend factory in `ra3.display` compiles unchanged.
*/
export namespace ra3::dx {
class d3d11_display final : public ra3::client::display {
public:
[[nodiscard]] auto init(const ra3::client::display_options &) -> bool override { return false; }
[[nodiscard]] auto present(const ra3::render::image &, const ra3::render::view_rect &, bool) -> bool override { return false; }
[[nodiscard]] auto poll_event(ra3::render::ui_event &) -> bool override { return false; }
[[nodiscard]] auto window_size() const -> std::pair<int, int> override { return {0, 0}; }
[[nodiscard]] auto key_down(ra3::render::ui_key) const -> bool override { return false; }
auto shutdown() -> void override {}
[[nodiscard]] auto name() const -> std::string_view override { return "d3d11(null)"; }
};
class d3d12_display final : public ra3::client::display {
public:
[[nodiscard]] auto init(const ra3::client::display_options &) -> bool override { return false; }
[[nodiscard]] auto present(const ra3::render::image &, const ra3::render::view_rect &, bool) -> bool override { return false; }
[[nodiscard]] auto poll_event(ra3::render::ui_event &) -> bool override { return false; }
[[nodiscard]] auto window_size() const -> std::pair<int, int> override { return {0, 0}; }
[[nodiscard]] auto key_down(ra3::render::ui_key) const -> bool override { return false; }
auto shutdown() -> void override {}
[[nodiscard]] auto name() const -> std::string_view override { return "d3d12(null)"; }
};
}
+1
View File
@@ -18,4 +18,5 @@ export import ra3.render;
export import ra3.terrain; export import ra3.terrain;
export import ra3.ui; export import ra3.ui;
export import ra3.vulkan; export import ra3.vulkan;
export import ra3.dx;
export import ra3.display; export import ra3.display;
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 EnderTheCoder
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+170
View File
@@ -0,0 +1,170 @@
# libenderlog
A standalone C++26 logging library, built as a C++20/26 **module** (`import ender.log;`)
with **`std::stacktrace`** call-stack capture on severe records.
It was extracted from the `ender-physics` engine, where it started life as
`ender.log`, and turned into a library that has no other dependency — not on the
engine, not on a logging framework.
## Features
- **Leveled records.** `trace`, `debug`, `info`, `warn`, `error`, `critical`,
filtered by an atomic `minimum` check that is cheap enough to guard expensive
message construction: `if (log::enabled(log::level::debug)) { ... }`.
- **Source location.** Every record carries the file, line and function of the
caller, taken from `std::source_location` at the call site — exact even in a
stripped release binary, because it is a compile-time constant.
- **Call stacks.** Records at or above `options::stacktrace_from` carry a
formatted `std::stacktrace`. The frames belonging to the library itself are
stripped by symbol, so the first reported frame is the caller regardless of
the optimisation level (the level wrappers get inlined away under `-O`).
- **Pluggable sinks.** A `console_sink` (stderr by default) and a `memory_sink`
(for tests and in-game consoles) ship; `sink` is a small interface.
- **File output with archiving.** `file_sink` writes to a file and, on open,
moves an existing log aside to a timestamped archive, so a run never appends
onto a previous run's log. It can also rotate by size and bound how many
archives are kept.
- **No stacktrace? No problem.** Where `<stacktrace>` is missing (libc++, and
therefore every cross target), the module still compiles and records still
carry their call site — they simply have no stack.
## Requirements
C++26 modules and `import std;` need a recent toolchain:
| Requirement | Version |
|---|---|
| Compiler | **GCC 15+** (or Clang with a standard library that provides the `std` module) |
| CMake | **3.30+** (for `CMAKE_EXPERIMENTAL_CXX_IMPORT_STD`) |
| Standard library | libstdc++ for `std::stacktrace` |
Ubuntu 26.04's default `g++` (GCC 15) and CMake 4 satisfy this, and that is the
release the CI targets and the `.deb` is built for. Ubuntu 24.04 ships GCC 13 and
CMake 3.28 and cannot build `import std;` without extra toolchains, so it is not
supported.
## Building
```sh
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
ctest --test-dir build --output-on-failure
```
Options:
| Option | Default | Description |
|---|---|---|
| `ENDERLOG_WERROR` | `OFF` | Treat warnings as errors |
| `ENDERLOG_BACKTRACE_SYMBOLS` | `ON` | Link executables with `-rdynamic` so traces can name frames |
| `ENDERLOG_BUILD_TESTS` | `ON` | Build the test suite |
| `ENDERLOG_BUILD_EXAMPLES` | `ON` | Build the example program |
Build Debug or RelWithDebInfo when you need to read a trace: frame names come
from debug information, and a release build often reports application frames as
`<unknown>`.
## Using the library
### `add_subdirectory`
```cmake
add_subdirectory(libenderlog)
target_link_libraries(my_app PRIVATE enderlog::enderlog)
```
### Installed package
```cmake
find_package(enderlog REQUIRED)
target_link_libraries(my_app PRIVATE enderlog::enderlog)
```
The static archive is installed together with the module interface source
(`ender.log.cppm`) because a module's BMI is compiler-version-specific — the
consumer rebuilds it from the source.
### In code
```cpp
import std;
import ender.log;
namespace log = ender::log;
auto main() -> int {
log::configure({.minimum = log::level::debug, .stacktrace_from = log::level::warn});
log::info(std::format("body {} moved to {:.2f}", 7, 12.35)); // formatted by the caller
log::error("a body left the world"); // carries a stack trace
}
```
Example output:
```
[11:32:18] ERROR example: a body left the world (examples/main.cpp:8)
#0 simulate_one_step (examples/main.cpp:8)
#1 main (examples/main.cpp:20)
#2 <unknown>
#3 __libc_start_main
#4 _start
```
### Configuration
```cpp
log::configure({
.minimum = log::level::debug, // drop everything below this
.stacktrace_from = log::level::error, // capture a stack at/above this
.stacktrace_depth = 16, // max frames kept
.stacktrace_skip = 2, // frames dropped before the caller is found
});
```
`log::current_options()` reads it back, `log::add_sink(...)` adds a destination,
and `log::set_sinks({...})` replaces them.
### Writing to a file
```cpp
namespace log = ender::log;
// Archive any existing enderlog.log to enderlog.log.<timestamp>, then start a
// fresh file for this run. Rotate at 64 KiB and keep the last 5 archives.
auto sink = log::add_file_sink("enderlog.log", {.max_file_size = 64 * 1024, .max_archives = 5});
```
- **No appending onto a previous run.** On open, an existing non-empty
`enderlog.log` is renamed to `enderlog.log.<YYYYmmdd-HHMMSS>` before the new
file is created, so every run gets its own file and the previous run's log is
preserved. A leftover empty file is simply replaced.
- `file_options::max_file_size` (0 disables) rotates the active file mid-run the
same way, and never archives an empty file. `file_options::max_archives`
(0 keeps all) deletes the oldest archives beyond the limit.
- `file_options::flush_each_record` (on by default) flushes after every record so
a crash keeps the tail.
- `add_file_sink` adds the sink to the global logger and returns it; `path()` and
`archives()` expose what it wrote. The `file_sink` class can also be used
directly and installed with `set_sinks`.
## Packaging
`cpack` produces a Debian package:
```sh
cmake -S . -B build -DENDERLOG_DISTRO=ubuntu26.04
cmake --build build -j
cd build
cpack
# -> libenderlog-dev_0.0.1_amd64_ubuntu26.04.deb
```
The package installs the static archive, the module interface source and the
CMake package config. CI builds it for Ubuntu 26.04 and publishes it as a job
artifact.
## License
MIT — see [LICENSE](LICENSE).
+542
View File
@@ -0,0 +1,542 @@
/**
* Logging with call-stack capture.
*
* Records carry a level, message, source location and — for severe enough
* levels — a formatted `std::stacktrace`. Capturing a trace walks the stack and
* reads debug information, so it is only done when the record's level is at or
* above `options::stacktrace_from`, and the whole call is skipped when the
* level is disabled.
*
* `std::stacktrace` is implemented by libstdc++ only. With GCC the module has
* to link `stdc++exp` (the static library that implements it); the CMake target
* takes care of that. File and line numbers in the trace come from debug
* information, so build with `-g` (Debug or RelWithDebInfo) to see them; symbol
* names work in any build.
*/
module;
// libc++ (the OpenRA3 toolchain) has no <stacktrace>, so this vendored copy adds
// a native fallback for the frames: Windows CaptureStackBackTrace and POSIX
// execinfo. These live in the global module fragment because they are C headers.
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <windows.h>
#elif defined(__unix__) || defined(__APPLE__)
#include <execinfo.h>
#endif
export module ender.log;
import std;
export namespace ender::log {
/** Severity of a record, ordered from most to least verbose. */
enum class level: std::uint8_t {
trace = 0,
debug,
info,
warn,
error,
critical,
};
/** Short upper-case name of a level, for output. */
[[nodiscard]] inline auto to_string(const level severity) -> std::string_view {
switch (severity) {
case level::trace: return "TRACE";
case level::debug: return "DEBUG";
case level::info: return "INFO";
case level::warn: return "WARN";
case level::error: return "ERROR";
case level::critical: return "CRITICAL";
}
return "?";
}
/** Logger configuration. */
struct options {
/** Records below this level are dropped before anything is built. */
level minimum{level::info};
/** Capture a stack trace for records at this level and above. */
level stacktrace_from{level::error};
/** Maximum number of frames kept in a captured trace. */
std::size_t stacktrace_depth{16};
/**
* Frames to drop from the top of a captured trace.
*
* The default drops `capture_stacktrace` and `emit`, which always exist
* as frames. The level wrappers are inlined away in optimised builds, so
* a fixed count cannot cover them; any leading frame that belongs to
* this module is therefore stripped by name instead, which keeps the
* caller visible whether or not the wrappers were inlined.
*/
std::size_t stacktrace_skip{2};
};
/** One log record. */
struct record {
level severity{level::info};
std::string message{};
std::string file{};
std::uint32_t line{0};
std::string function{};
/** Formatted call stack; empty when it was not captured. */
std::string stacktrace{};
std::chrono::system_clock::time_point time{};
std::thread::id thread{};
[[nodiscard]] auto has_stacktrace() const -> bool { return !stacktrace.empty(); }
};
namespace detail {
/**
* Render one record as a human-readable block: a header line and, when
* present, the indented stack frames. Shared by the stream sinks.
*/
[[nodiscard]] inline auto format_record(const record &entry) -> std::string {
auto text = std::format("[{:%H:%M:%S}] {:<8} {}",
std::chrono::floor<std::chrono::seconds>(entry.time),
to_string(entry.severity),
entry.message);
if (!entry.file.empty()) {
text += std::format(" ({}:{})", entry.file, entry.line);
}
text += '\n';
if (entry.has_stacktrace()) {
text += entry.stacktrace;
}
return text;
}
}
/** Where records go. */
class sink {
public:
virtual ~sink() = default;
/** Receive one record; called with the logger's mutex held. */
virtual auto write(const record &entry) -> void = 0;
/** Flush any buffering. */
virtual auto flush() -> void {}
};
/** Writes a human-readable line per record to a stream (stderr by default). */
class console_sink final: public sink {
public:
explicit console_sink(std::ostream &stream = std::cerr): stream_(&stream) {}
auto write(const record &entry) -> void override {
*stream_ << detail::format_record(entry);
stream_->flush();
}
private:
std::ostream *stream_;
};
/** Keeps every record in memory; useful for tests and in-game consoles. */
class memory_sink final: public sink {
public:
auto write(const record &entry) -> void override {
const auto lock = std::scoped_lock{mutex_};
records_.push_back(entry);
}
[[nodiscard]] auto records() const -> std::vector<record> {
const auto lock = std::scoped_lock{mutex_};
return records_;
}
[[nodiscard]] auto size() const -> std::size_t {
const auto lock = std::scoped_lock{mutex_};
return records_.size();
}
auto clear() -> void {
const auto lock = std::scoped_lock{mutex_};
records_.clear();
}
private:
mutable std::mutex mutex_;
std::vector<record> records_;
};
/** Configuration for `file_sink`. */
struct file_options {
/** Move an existing log file aside to an archive when the sink opens it. */
bool archive_on_open{true};
/** Flush after every record, so the tail survives a crash. */
bool flush_each_record{true};
/** Rotate once the active file would grow past this many bytes; 0 disables. */
std::size_t max_file_size{0};
/** Keep at most this many archives, dropping the oldest first; 0 keeps them all. */
std::size_t max_archives{0};
};
/**
* Writes records to a file, archiving the previous one on open.
*
* `path` is the active file. When the sink opens it and the file already
* holds data, that file is renamed to a timestamped archive first, so a run
* never appends onto a previous run's log: every start begins a fresh file
* and the old one is preserved as `<path>.<YYYYmmdd-HHMMSS>`. The same
* happens mid-run once the active file passes `file_options::max_file_size`.
* `file_options::max_archives` bounds how many archives are kept.
*
* As with every sink, `write` is called with the logger's mutex held, so one
* sink is safe to share; it is not safe for two processes to point at the
* same file.
*/
class file_sink final: public sink {
public:
explicit file_sink(std::filesystem::path path, const file_options options = {})
: path_(std::move(path)), options_(options) {
if (options_.archive_on_open && std::filesystem::exists(path_)) {
if (std::filesystem::file_size(path_) > 0) {
archive_current();
} else {
std::filesystem::remove(path_);
}
}
open();
}
auto write(const record &entry) -> void override {
const auto block = detail::format_record(entry);
// Rotate before writing, but never rotate an empty file: that would
// archive nothing and lose the record that is about to be written.
if (options_.max_file_size > 0 && size_ > 0 && size_ + block.size() > options_.max_file_size) {
archive_current();
open();
}
stream_ << block;
size_ += block.size();
if (options_.flush_each_record) stream_.flush();
}
auto flush() -> void override {
if (stream_.is_open()) stream_.flush();
}
/** The active log file. */
[[nodiscard]] auto path() const -> const std::filesystem::path & { return path_; }
/** Archives this sink created, oldest first. */
[[nodiscard]] auto archives() const -> const std::vector<std::filesystem::path> & { return archives_; }
private:
auto open() -> void {
stream_.clear();
stream_.open(path_, std::ios::out | std::ios::trunc | std::ios::binary);
size_ = 0;
}
auto archive_current() -> void {
if (stream_.is_open()) stream_.close();
const auto stamp = std::format("{:%Y%m%d-%H%M%S}",
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now()));
auto archive = path_;
archive += "." + stamp;
// Two rotations can land in the same second; disambiguate with a
// counter rather than overwrite the earlier archive.
for (auto counter = 1; std::filesystem::exists(archive); ++counter) {
archive = path_;
archive += std::format(".{}.{}", stamp, counter);
}
std::filesystem::rename(path_, archive);
archives_.push_back(archive);
prune_archives();
}
auto prune_archives() -> void {
if (options_.max_archives == 0) return;
while (archives_.size() > options_.max_archives) {
auto ignored = std::error_code{};
std::filesystem::remove(archives_.front(), ignored);
archives_.erase(archives_.begin());
}
}
std::filesystem::path path_;
file_options options_;
std::ofstream stream_;
std::size_t size_{0};
std::vector<std::filesystem::path> archives_;
};
/*
* <stacktrace> is not portable: libc++ has never implemented it, and only
* libstdc++ provides it here. Where it is missing, records still carry their
* call site through std::source_location - they simply carry no stack, and
* everything below degrades to an empty string rather than the module
* refusing to compile.
*
* CMake decides this and passes it in, rather than the module testing
* `__cpp_lib_stacktrace` itself: feature-test macros come from the standard
* library's headers, and `import std;` does not export them, so probing for
* one here silently reports "absent" even on libstdc++, which has it.
*/
#ifndef ENDERLOG_HAS_STACKTRACE
#define ENDERLOG_HAS_STACKTRACE 0
#endif
#if ENDERLOG_HAS_STACKTRACE
/** True when a frame belongs to the logging module itself. */
[[nodiscard]] inline auto is_logger_frame(const std::stacktrace_entry &entry) -> bool {
if (entry.description().find("ender::log") != std::string::npos) return true;
return entry.source_file().find("ender.log.cppm") != std::string::npos;
}
/**
* Render a trace as one indented line per frame.
*
* @param skip_logger_frames Drop leading frames belonging to this module, so
* the first reported frame is the caller. This is what makes the
* output stable across optimisation levels: in a release build the
* level wrappers are inlined into the caller, so counting frames
* alone would either over- or under-skip.
*/
[[nodiscard]] inline auto format_stacktrace(const std::stacktrace &trace,
const bool skip_logger_frames = true) -> std::string {
if (trace.empty()) return " <empty stacktrace>\n";
auto first = std::size_t{0};
if (skip_logger_frames) {
while (first < trace.size() && is_logger_frame(trace.at(first))) ++first;
if (first >= trace.size()) first = 0; // never hide the whole trace
}
auto text = std::string{};
for (auto index = first; index < trace.size(); ++index) {
const auto &entry = trace.at(index);
auto description = entry.description();
if (description.empty()) description = "<unknown>";
auto location = std::string{};
if (!entry.source_file().empty()) {
location = std::format(" ({}:{})", entry.source_file(), entry.source_line());
}
text += std::format(" #{:<3}{}{}\n", index - first, description, location);
}
return text;
}
/** Capture and render the current call stack, innermost frame first. */
[[nodiscard]] inline auto capture_stacktrace(const std::size_t skip = 2, const std::size_t depth = 16)
-> std::string {
return format_stacktrace(std::stacktrace::current(skip, depth));
}
#else
/**
* libc++ fallback: capture the current call stack with the platform's own
* backtrace API and render one indented line per frame.
*
* On Windows a frame is reported as `module.dll+0xRVA` (a MinGW release build
* has DWARF, not the PDB symbols dbghelp resolves, so a module+offset is the
* practical answer). On POSIX `backtrace_symbols` is used, which names a
* frame when the executable was linked with `-rdynamic`.
*/
#if defined(_WIN32)
[[nodiscard]] inline auto symbolicate_frame(void *address) -> std::string {
const auto value = reinterpret_cast<std::uintptr_t>(address);
HMODULE module = nullptr;
if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(address), &module)) {
wchar_t wide[260] = L"?";
GetModuleFileNameW(module, wide, 260U);
char narrow[260] = "?";
WideCharToMultiByte(CP_UTF8, 0, wide, -1, narrow, sizeof(narrow), nullptr, nullptr);
const char *base = std::strrchr(narrow, '\\');
return std::format("{}+0x{:X}", base != nullptr ? base + 1 : narrow, value - reinterpret_cast<std::uintptr_t>(module));
}
return std::format("0x{:X}", value);
}
#endif
[[nodiscard]] inline auto capture_stacktrace(const std::size_t skip = 2, const std::size_t depth = 16) -> std::string {
constexpr std::size_t max_frames = 64;
#if defined(_WIN32)
void *frames[max_frames] = {};
const auto want = static_cast<DWORD>(std::min(max_frames, skip + std::max<std::size_t>(depth, 1U)));
const USHORT count = CaptureStackBackTrace(static_cast<DWORD>(skip), want, frames, nullptr);
auto text = std::string{};
for (USHORT index = 0; index < count; ++index) {
text += std::format(" #{:<3}{}\n", index, symbolicate_frame(frames[index]));
}
return text;
#elif defined(__unix__) || defined(__APPLE__)
void *frames[max_frames] = {};
const int count = ::backtrace(frames, static_cast<int>(std::min(max_frames, skip + std::max<std::size_t>(depth, 1U))));
char **symbols = ::backtrace_symbols(frames, count);
auto text = std::string{};
for (int index = static_cast<int>(std::min<std::size_t>(skip, static_cast<std::size_t>(count))); index < count; ++index) {
text += std::format(" #{:<3}{}\n", index - static_cast<int>(skip),
symbols != nullptr ? symbols[index] : std::format("0x{:X}", reinterpret_cast<std::uintptr_t>(frames[index])));
}
if (symbols != nullptr) std::free(symbols);
return text;
#else
(void) skip;
(void) depth;
return {};
#endif
}
#endif
/**
* The process-wide logger.
*
* `enabled` is an atomic read so hot paths can guard expensive message
* construction; everything else takes the mutex.
*/
class logger {
public:
[[nodiscard]] static auto instance() -> logger & {
static logger shared;
return shared;
}
auto configure(const options &config) -> void {
const auto lock = std::scoped_lock{mutex_};
options_ = config;
minimum_.store(static_cast<std::uint8_t>(config.minimum), std::memory_order_relaxed);
}
[[nodiscard]] auto configuration() const -> options {
const auto lock = std::scoped_lock{mutex_};
return options_;
}
[[nodiscard]] auto enabled(const level severity) const -> bool {
return static_cast<std::uint8_t>(severity) >= minimum_.load(std::memory_order_relaxed);
}
auto add_sink(std::shared_ptr<sink> destination) -> void {
const auto lock = std::scoped_lock{mutex_};
sinks_.push_back(std::move(destination));
}
auto set_sinks(std::vector<std::shared_ptr<sink>> destinations) -> void {
const auto lock = std::scoped_lock{mutex_};
sinks_ = std::move(destinations);
}
auto dispatch(const record &entry) -> void {
const auto lock = std::scoped_lock{mutex_};
for (const auto &destination: sinks_) {
destination->write(entry);
}
}
private:
logger() { sinks_.push_back(std::make_shared<console_sink>()); }
mutable std::mutex mutex_;
options options_{};
std::atomic<std::uint8_t> minimum_{static_cast<std::uint8_t>(options{}.minimum)};
std::vector<std::shared_ptr<sink>> sinks_;
};
/** Apply a configuration to the process-wide logger. */
inline auto configure(const options &config) -> void { logger::instance().configure(config); }
/** Current configuration of the process-wide logger. */
[[nodiscard]] inline auto current_options() -> options { return logger::instance().configuration(); }
/** Route records to an additional sink. */
inline auto add_sink(std::shared_ptr<sink> destination) -> void {
logger::instance().add_sink(std::move(destination));
}
/**
* Create a file sink, route records to it, and hand it back.
*
* The previous log at `path` is archived on open, so this never appends onto
* an earlier run.
*
* @return The sink, so the caller can inspect the archives it creates.
*/
inline auto add_file_sink(std::filesystem::path path, const file_options &options = {})
-> std::shared_ptr<file_sink> {
auto destination = std::make_shared<file_sink>(std::move(path), options);
logger::instance().add_sink(destination);
return destination;
}
/** Replace every sink. */
inline auto set_sinks(std::vector<std::shared_ptr<sink>> destinations) -> void {
logger::instance().set_sinks(std::move(destinations));
}
/** True when a record at this level would be emitted. */
[[nodiscard]] inline auto enabled(const level severity) -> bool { return logger::instance().enabled(severity); }
namespace detail {
/** Build and dispatch one record. Not for direct use. */
inline auto emit(const level severity, std::string message, const std::source_location location) -> void {
auto &target = logger::instance();
if (!target.enabled(severity)) return;
const auto config = target.configuration();
auto entry = record{
.severity = severity,
.message = std::move(message),
.file = location.file_name(),
.line = static_cast<std::uint32_t>(location.line()),
.function = location.function_name(),
.time = std::chrono::system_clock::now(),
.thread = std::this_thread::get_id(),
};
if (severity >= config.stacktrace_from) {
entry.stacktrace = capture_stacktrace(config.stacktrace_skip, config.stacktrace_depth);
}
target.dispatch(entry);
}
}
/**
* Emit a record.
*
* The source location defaults to the call site, so this reports exactly
* where it was called from.
*/
inline auto log(const level severity,
std::string message,
const std::source_location location = std::source_location::current()) -> void {
detail::emit(severity, std::move(message), location);
}
inline auto trace(std::string message, const std::source_location location = std::source_location::current())
-> void {
detail::emit(level::trace, std::move(message), location);
}
inline auto debug(std::string message, const std::source_location location = std::source_location::current())
-> void {
detail::emit(level::debug, std::move(message), location);
}
inline auto info(std::string message, const std::source_location location = std::source_location::current())
-> void {
detail::emit(level::info, std::move(message), location);
}
inline auto warn(std::string message, const std::source_location location = std::source_location::current())
-> void {
detail::emit(level::warn, std::move(message), location);
}
/** Emits at `error`, which captures a call stack by default. */
inline auto error(std::string message, const std::source_location location = std::source_location::current())
-> void {
detail::emit(level::error, std::move(message), location);
}
inline auto critical(std::string message, const std::source_location location = std::source_location::current())
-> void {
detail::emit(level::critical, std::move(message), location);
}
}