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:
@@ -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
@@ -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 ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+160
-12
@@ -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
@@ -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** `[ ]`
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+102
-43
@@ -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 {
|
||||||
|
switch (which) {
|
||||||
|
case backend::vulkan: return "vulkan";
|
||||||
|
case backend::d3d11: return "d3d11";
|
||||||
|
case backend::d3d12: return "d3d12";
|
||||||
|
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::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, 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};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create and initialize the preferred display (Vulkan, then SDL), or return
|
* Create and initialize the preferred backend, falling back to the others,
|
||||||
* null when neither starts. The caller owns the display and may reuse it for
|
* or return null when none starts. The caller owns the display and may reuse
|
||||||
* a menu, a loading bar and a viewer in sequence.
|
* 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> {
|
[[nodiscard]] inline auto create(const display_options &options, backend preferred) -> std::unique_ptr<ra3::client::display> {
|
||||||
const auto try_vulkan = [&]() -> std::unique_ptr<ra3::client::display> {
|
for (const auto which: backend_order(preferred)) {
|
||||||
auto d = std::make_unique<ra3::vulkan::vulkan_display>();
|
auto candidate = make_backend(which);
|
||||||
if (d->init(options)) return d;
|
if (candidate && candidate->init(options)) return candidate;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
if (auto d = try_sdl()) return d;
|
return nullptr;
|
||||||
return try_vulkan();
|
}
|
||||||
|
|
||||||
|
/** 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 display that initializes: Vulkan, then 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
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,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;
|
||||||
|
|||||||
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__)
|
||||||
|
#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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user