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:
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