- ra3.webgl: a Web peer of Vulkan/Direct3D (SDL3 canvas + GLES3/WebGL2), with 2D blit and the GPU terrain raymarch; GLSL ES shaders; null fallback off Emscripten. backend::webgl + default_backend() (webgl under Emscripten). - Emscripten toolchain + Dockerfile.wasm + scripts/build-wasm.sh; import std, global -fexceptions, and Asyncify so the blocking frame loop yields to the browser (display::sleep_frame calls emscripten_sleep). - apps/web/shell.html + apps/web/serve.py (Range-capable dev server). - Assets: browsers forbid synchronous on-demand reads on the main thread (and FS.createLazyFile / the WasmFS fetch backend are worker-only; SDL3's Emscripten backend is main-thread DOM only, so the engine cannot run in a worker). The wasm build therefore preloads a compact per-map set via OPENRA3_WEB_ASSETS. - New openra3 textures --map ID lists the loose terrain TGAs a map resolves to (terrain::resolve_texture_files, shared with load_textures_from_dir). - Fixes: webgl heightmap used GL_R16 = 0x8229 (that is R8) -> upload rejected, terrain flattened to water; now 0x822A. Logger uses a stdout console sink on the web (stderr maps to console.error); a GL error check logs bad uploads. - CI: wasm image + build jobs.
543 lines
22 KiB
C++
543 lines
22 KiB
C++
/**
|
|
* Logging with call-stack capture.
|
|
*
|
|
* Records carry a level, message, source location and — for severe enough
|
|
* levels — a formatted `std::stacktrace`. Capturing a trace walks the stack and
|
|
* reads debug information, so it is only done when the record's level is at or
|
|
* above `options::stacktrace_from`, and the whole call is skipped when the
|
|
* level is disabled.
|
|
*
|
|
* `std::stacktrace` is implemented by libstdc++ only. With GCC the module has
|
|
* to link `stdc++exp` (the static library that implements it); the CMake target
|
|
* takes care of that. File and line numbers in the trace come from debug
|
|
* information, so build with `-g` (Debug or RelWithDebInfo) to see them; symbol
|
|
* names work in any build.
|
|
*/
|
|
module;
|
|
|
|
// libc++ (the OpenRA3 toolchain) has no <stacktrace>, so this vendored copy adds
|
|
// a native fallback for the frames: Windows CaptureStackBackTrace and POSIX
|
|
// execinfo. These live in the global module fragment because they are C headers.
|
|
#if defined(_WIN32)
|
|
#define WIN32_LEAN_AND_MEAN
|
|
#define NOMINMAX
|
|
#include <windows.h>
|
|
#elif (defined(__unix__) || defined(__APPLE__)) && !defined(__EMSCRIPTEN__)
|
|
#include <execinfo.h>
|
|
#endif
|
|
|
|
export module ender.log;
|
|
|
|
import std;
|
|
|
|
export namespace ender::log {
|
|
/** Severity of a record, ordered from most to least verbose. */
|
|
enum class level: std::uint8_t {
|
|
trace = 0,
|
|
debug,
|
|
info,
|
|
warn,
|
|
error,
|
|
critical,
|
|
};
|
|
|
|
/** Short upper-case name of a level, for output. */
|
|
[[nodiscard]] inline auto to_string(const level severity) -> std::string_view {
|
|
switch (severity) {
|
|
case level::trace: return "TRACE";
|
|
case level::debug: return "DEBUG";
|
|
case level::info: return "INFO";
|
|
case level::warn: return "WARN";
|
|
case level::error: return "ERROR";
|
|
case level::critical: return "CRITICAL";
|
|
}
|
|
return "?";
|
|
}
|
|
|
|
/** Logger configuration. */
|
|
struct options {
|
|
/** Records below this level are dropped before anything is built. */
|
|
level minimum{level::info};
|
|
/** Capture a stack trace for records at this level and above. */
|
|
level stacktrace_from{level::error};
|
|
/** Maximum number of frames kept in a captured trace. */
|
|
std::size_t stacktrace_depth{16};
|
|
/**
|
|
* Frames to drop from the top of a captured trace.
|
|
*
|
|
* The default drops `capture_stacktrace` and `emit`, which always exist
|
|
* as frames. The level wrappers are inlined away in optimised builds, so
|
|
* a fixed count cannot cover them; any leading frame that belongs to
|
|
* this module is therefore stripped by name instead, which keeps the
|
|
* caller visible whether or not the wrappers were inlined.
|
|
*/
|
|
std::size_t stacktrace_skip{2};
|
|
};
|
|
|
|
/** One log record. */
|
|
struct record {
|
|
level severity{level::info};
|
|
std::string message{};
|
|
std::string file{};
|
|
std::uint32_t line{0};
|
|
std::string function{};
|
|
/** Formatted call stack; empty when it was not captured. */
|
|
std::string stacktrace{};
|
|
std::chrono::system_clock::time_point time{};
|
|
std::thread::id thread{};
|
|
|
|
[[nodiscard]] auto has_stacktrace() const -> bool { return !stacktrace.empty(); }
|
|
};
|
|
|
|
namespace detail {
|
|
/**
|
|
* Render one record as a human-readable block: a header line and, when
|
|
* present, the indented stack frames. Shared by the stream sinks.
|
|
*/
|
|
[[nodiscard]] inline auto format_record(const record &entry) -> std::string {
|
|
auto text = std::format("[{:%H:%M:%S}] {:<8} {}",
|
|
std::chrono::floor<std::chrono::seconds>(entry.time),
|
|
to_string(entry.severity),
|
|
entry.message);
|
|
if (!entry.file.empty()) {
|
|
text += std::format(" ({}:{})", entry.file, entry.line);
|
|
}
|
|
text += '\n';
|
|
if (entry.has_stacktrace()) {
|
|
text += entry.stacktrace;
|
|
}
|
|
return text;
|
|
}
|
|
}
|
|
|
|
/** Where records go. */
|
|
class sink {
|
|
public:
|
|
virtual ~sink() = default;
|
|
|
|
/** Receive one record; called with the logger's mutex held. */
|
|
virtual auto write(const record &entry) -> void = 0;
|
|
|
|
/** Flush any buffering. */
|
|
virtual auto flush() -> void {}
|
|
};
|
|
|
|
/** Writes a human-readable line per record to a stream (stderr by default). */
|
|
class console_sink final: public sink {
|
|
public:
|
|
explicit console_sink(std::ostream &stream = std::cerr): stream_(&stream) {}
|
|
|
|
auto write(const record &entry) -> void override {
|
|
*stream_ << detail::format_record(entry);
|
|
stream_->flush();
|
|
}
|
|
|
|
private:
|
|
std::ostream *stream_;
|
|
};
|
|
|
|
/** Keeps every record in memory; useful for tests and in-game consoles. */
|
|
class memory_sink final: public sink {
|
|
public:
|
|
auto write(const record &entry) -> void override {
|
|
const auto lock = std::scoped_lock{mutex_};
|
|
records_.push_back(entry);
|
|
}
|
|
|
|
[[nodiscard]] auto records() const -> std::vector<record> {
|
|
const auto lock = std::scoped_lock{mutex_};
|
|
return records_;
|
|
}
|
|
|
|
[[nodiscard]] auto size() const -> std::size_t {
|
|
const auto lock = std::scoped_lock{mutex_};
|
|
return records_.size();
|
|
}
|
|
|
|
auto clear() -> void {
|
|
const auto lock = std::scoped_lock{mutex_};
|
|
records_.clear();
|
|
}
|
|
|
|
private:
|
|
mutable std::mutex mutex_;
|
|
std::vector<record> records_;
|
|
};
|
|
|
|
/** Configuration for `file_sink`. */
|
|
struct file_options {
|
|
/** Move an existing log file aside to an archive when the sink opens it. */
|
|
bool archive_on_open{true};
|
|
/** Flush after every record, so the tail survives a crash. */
|
|
bool flush_each_record{true};
|
|
/** Rotate once the active file would grow past this many bytes; 0 disables. */
|
|
std::size_t max_file_size{0};
|
|
/** Keep at most this many archives, dropping the oldest first; 0 keeps them all. */
|
|
std::size_t max_archives{0};
|
|
};
|
|
|
|
/**
|
|
* Writes records to a file, archiving the previous one on open.
|
|
*
|
|
* `path` is the active file. When the sink opens it and the file already
|
|
* holds data, that file is renamed to a timestamped archive first, so a run
|
|
* never appends onto a previous run's log: every start begins a fresh file
|
|
* and the old one is preserved as `<path>.<YYYYmmdd-HHMMSS>`. The same
|
|
* happens mid-run once the active file passes `file_options::max_file_size`.
|
|
* `file_options::max_archives` bounds how many archives are kept.
|
|
*
|
|
* As with every sink, `write` is called with the logger's mutex held, so one
|
|
* sink is safe to share; it is not safe for two processes to point at the
|
|
* same file.
|
|
*/
|
|
class file_sink final: public sink {
|
|
public:
|
|
explicit file_sink(std::filesystem::path path, const file_options options = {})
|
|
: path_(std::move(path)), options_(options) {
|
|
if (options_.archive_on_open && std::filesystem::exists(path_)) {
|
|
if (std::filesystem::file_size(path_) > 0) {
|
|
archive_current();
|
|
} else {
|
|
std::filesystem::remove(path_);
|
|
}
|
|
}
|
|
open();
|
|
}
|
|
|
|
auto write(const record &entry) -> void override {
|
|
const auto block = detail::format_record(entry);
|
|
// Rotate before writing, but never rotate an empty file: that would
|
|
// archive nothing and lose the record that is about to be written.
|
|
if (options_.max_file_size > 0 && size_ > 0 && size_ + block.size() > options_.max_file_size) {
|
|
archive_current();
|
|
open();
|
|
}
|
|
stream_ << block;
|
|
size_ += block.size();
|
|
if (options_.flush_each_record) stream_.flush();
|
|
}
|
|
|
|
auto flush() -> void override {
|
|
if (stream_.is_open()) stream_.flush();
|
|
}
|
|
|
|
/** The active log file. */
|
|
[[nodiscard]] auto path() const -> const std::filesystem::path & { return path_; }
|
|
|
|
/** Archives this sink created, oldest first. */
|
|
[[nodiscard]] auto archives() const -> const std::vector<std::filesystem::path> & { return archives_; }
|
|
|
|
private:
|
|
auto open() -> void {
|
|
stream_.clear();
|
|
stream_.open(path_, std::ios::out | std::ios::trunc | std::ios::binary);
|
|
size_ = 0;
|
|
}
|
|
|
|
auto archive_current() -> void {
|
|
if (stream_.is_open()) stream_.close();
|
|
const auto stamp = std::format("{:%Y%m%d-%H%M%S}",
|
|
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now()));
|
|
auto archive = path_;
|
|
archive += "." + stamp;
|
|
// Two rotations can land in the same second; disambiguate with a
|
|
// counter rather than overwrite the earlier archive.
|
|
for (auto counter = 1; std::filesystem::exists(archive); ++counter) {
|
|
archive = path_;
|
|
archive += std::format(".{}.{}", stamp, counter);
|
|
}
|
|
std::filesystem::rename(path_, archive);
|
|
archives_.push_back(archive);
|
|
prune_archives();
|
|
}
|
|
|
|
auto prune_archives() -> void {
|
|
if (options_.max_archives == 0) return;
|
|
while (archives_.size() > options_.max_archives) {
|
|
auto ignored = std::error_code{};
|
|
std::filesystem::remove(archives_.front(), ignored);
|
|
archives_.erase(archives_.begin());
|
|
}
|
|
}
|
|
|
|
std::filesystem::path path_;
|
|
file_options options_;
|
|
std::ofstream stream_;
|
|
std::size_t size_{0};
|
|
std::vector<std::filesystem::path> archives_;
|
|
};
|
|
|
|
/*
|
|
* <stacktrace> is not portable: libc++ has never implemented it, and only
|
|
* libstdc++ provides it here. Where it is missing, records still carry their
|
|
* call site through std::source_location - they simply carry no stack, and
|
|
* everything below degrades to an empty string rather than the module
|
|
* refusing to compile.
|
|
*
|
|
* CMake decides this and passes it in, rather than the module testing
|
|
* `__cpp_lib_stacktrace` itself: feature-test macros come from the standard
|
|
* library's headers, and `import std;` does not export them, so probing for
|
|
* one here silently reports "absent" even on libstdc++, which has it.
|
|
*/
|
|
#ifndef ENDERLOG_HAS_STACKTRACE
|
|
#define ENDERLOG_HAS_STACKTRACE 0
|
|
#endif
|
|
|
|
#if ENDERLOG_HAS_STACKTRACE
|
|
/** True when a frame belongs to the logging module itself. */
|
|
[[nodiscard]] inline auto is_logger_frame(const std::stacktrace_entry &entry) -> bool {
|
|
if (entry.description().find("ender::log") != std::string::npos) return true;
|
|
return entry.source_file().find("ender.log.cppm") != std::string::npos;
|
|
}
|
|
|
|
/**
|
|
* Render a trace as one indented line per frame.
|
|
*
|
|
* @param skip_logger_frames Drop leading frames belonging to this module, so
|
|
* the first reported frame is the caller. This is what makes the
|
|
* output stable across optimisation levels: in a release build the
|
|
* level wrappers are inlined into the caller, so counting frames
|
|
* alone would either over- or under-skip.
|
|
*/
|
|
[[nodiscard]] inline auto format_stacktrace(const std::stacktrace &trace,
|
|
const bool skip_logger_frames = true) -> std::string {
|
|
if (trace.empty()) return " <empty stacktrace>\n";
|
|
|
|
auto first = std::size_t{0};
|
|
if (skip_logger_frames) {
|
|
while (first < trace.size() && is_logger_frame(trace.at(first))) ++first;
|
|
if (first >= trace.size()) first = 0; // never hide the whole trace
|
|
}
|
|
|
|
auto text = std::string{};
|
|
for (auto index = first; index < trace.size(); ++index) {
|
|
const auto &entry = trace.at(index);
|
|
auto description = entry.description();
|
|
if (description.empty()) description = "<unknown>";
|
|
|
|
auto location = std::string{};
|
|
if (!entry.source_file().empty()) {
|
|
location = std::format(" ({}:{})", entry.source_file(), entry.source_line());
|
|
}
|
|
text += std::format(" #{:<3}{}{}\n", index - first, description, location);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
/** Capture and render the current call stack, innermost frame first. */
|
|
[[nodiscard]] inline auto capture_stacktrace(const std::size_t skip = 2, const std::size_t depth = 16)
|
|
-> std::string {
|
|
return format_stacktrace(std::stacktrace::current(skip, depth));
|
|
}
|
|
#else
|
|
/**
|
|
* libc++ fallback: capture the current call stack with the platform's own
|
|
* backtrace API and render one indented line per frame.
|
|
*
|
|
* On Windows a frame is reported as `module.dll+0xRVA` (a MinGW release build
|
|
* has DWARF, not the PDB symbols dbghelp resolves, so a module+offset is the
|
|
* practical answer). On POSIX `backtrace_symbols` is used, which names a
|
|
* frame when the executable was linked with `-rdynamic`.
|
|
*/
|
|
#if defined(_WIN32)
|
|
[[nodiscard]] inline auto symbolicate_frame(void *address) -> std::string {
|
|
const auto value = reinterpret_cast<std::uintptr_t>(address);
|
|
HMODULE module = nullptr;
|
|
if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
|
reinterpret_cast<LPCWSTR>(address), &module)) {
|
|
wchar_t wide[260] = L"?";
|
|
GetModuleFileNameW(module, wide, 260U);
|
|
char narrow[260] = "?";
|
|
WideCharToMultiByte(CP_UTF8, 0, wide, -1, narrow, sizeof(narrow), nullptr, nullptr);
|
|
const char *base = std::strrchr(narrow, '\\');
|
|
return std::format("{}+0x{:X}", base != nullptr ? base + 1 : narrow, value - reinterpret_cast<std::uintptr_t>(module));
|
|
}
|
|
return std::format("0x{:X}", value);
|
|
}
|
|
#endif
|
|
|
|
[[nodiscard]] inline auto capture_stacktrace(const std::size_t skip = 2, const std::size_t depth = 16) -> std::string {
|
|
constexpr std::size_t max_frames = 64;
|
|
#if defined(_WIN32)
|
|
void *frames[max_frames] = {};
|
|
const auto want = static_cast<DWORD>(std::min(max_frames, skip + std::max<std::size_t>(depth, 1U)));
|
|
const USHORT count = CaptureStackBackTrace(static_cast<DWORD>(skip), want, frames, nullptr);
|
|
auto text = std::string{};
|
|
for (USHORT index = 0; index < count; ++index) {
|
|
text += std::format(" #{:<3}{}\n", index, symbolicate_frame(frames[index]));
|
|
}
|
|
return text;
|
|
#elif (defined(__unix__) || defined(__APPLE__)) && !defined(__EMSCRIPTEN__)
|
|
void *frames[max_frames] = {};
|
|
const int count = ::backtrace(frames, static_cast<int>(std::min(max_frames, skip + std::max<std::size_t>(depth, 1U))));
|
|
char **symbols = ::backtrace_symbols(frames, count);
|
|
auto text = std::string{};
|
|
for (int index = static_cast<int>(std::min<std::size_t>(skip, static_cast<std::size_t>(count))); index < count; ++index) {
|
|
text += std::format(" #{:<3}{}\n", index - static_cast<int>(skip),
|
|
symbols != nullptr ? symbols[index] : std::format("0x{:X}", reinterpret_cast<std::uintptr_t>(frames[index])));
|
|
}
|
|
if (symbols != nullptr) std::free(symbols);
|
|
return text;
|
|
#else
|
|
(void) skip;
|
|
(void) depth;
|
|
return {};
|
|
#endif
|
|
}
|
|
#endif
|
|
|
|
/**
|
|
* The process-wide logger.
|
|
*
|
|
* `enabled` is an atomic read so hot paths can guard expensive message
|
|
* construction; everything else takes the mutex.
|
|
*/
|
|
class logger {
|
|
public:
|
|
[[nodiscard]] static auto instance() -> logger & {
|
|
static logger shared;
|
|
return shared;
|
|
}
|
|
|
|
auto configure(const options &config) -> void {
|
|
const auto lock = std::scoped_lock{mutex_};
|
|
options_ = config;
|
|
minimum_.store(static_cast<std::uint8_t>(config.minimum), std::memory_order_relaxed);
|
|
}
|
|
|
|
[[nodiscard]] auto configuration() const -> options {
|
|
const auto lock = std::scoped_lock{mutex_};
|
|
return options_;
|
|
}
|
|
|
|
[[nodiscard]] auto enabled(const level severity) const -> bool {
|
|
return static_cast<std::uint8_t>(severity) >= minimum_.load(std::memory_order_relaxed);
|
|
}
|
|
|
|
auto add_sink(std::shared_ptr<sink> destination) -> void {
|
|
const auto lock = std::scoped_lock{mutex_};
|
|
sinks_.push_back(std::move(destination));
|
|
}
|
|
|
|
auto set_sinks(std::vector<std::shared_ptr<sink>> destinations) -> void {
|
|
const auto lock = std::scoped_lock{mutex_};
|
|
sinks_ = std::move(destinations);
|
|
}
|
|
|
|
auto dispatch(const record &entry) -> void {
|
|
const auto lock = std::scoped_lock{mutex_};
|
|
for (const auto &destination: sinks_) {
|
|
destination->write(entry);
|
|
}
|
|
}
|
|
|
|
private:
|
|
logger() { sinks_.push_back(std::make_shared<console_sink>()); }
|
|
|
|
mutable std::mutex mutex_;
|
|
options options_{};
|
|
std::atomic<std::uint8_t> minimum_{static_cast<std::uint8_t>(options{}.minimum)};
|
|
std::vector<std::shared_ptr<sink>> sinks_;
|
|
};
|
|
|
|
/** Apply a configuration to the process-wide logger. */
|
|
inline auto configure(const options &config) -> void { logger::instance().configure(config); }
|
|
|
|
/** Current configuration of the process-wide logger. */
|
|
[[nodiscard]] inline auto current_options() -> options { return logger::instance().configuration(); }
|
|
|
|
/** Route records to an additional sink. */
|
|
inline auto add_sink(std::shared_ptr<sink> destination) -> void {
|
|
logger::instance().add_sink(std::move(destination));
|
|
}
|
|
|
|
/**
|
|
* Create a file sink, route records to it, and hand it back.
|
|
*
|
|
* The previous log at `path` is archived on open, so this never appends onto
|
|
* an earlier run.
|
|
*
|
|
* @return The sink, so the caller can inspect the archives it creates.
|
|
*/
|
|
inline auto add_file_sink(std::filesystem::path path, const file_options &options = {})
|
|
-> std::shared_ptr<file_sink> {
|
|
auto destination = std::make_shared<file_sink>(std::move(path), options);
|
|
logger::instance().add_sink(destination);
|
|
return destination;
|
|
}
|
|
|
|
/** Replace every sink. */
|
|
inline auto set_sinks(std::vector<std::shared_ptr<sink>> destinations) -> void {
|
|
logger::instance().set_sinks(std::move(destinations));
|
|
}
|
|
|
|
/** True when a record at this level would be emitted. */
|
|
[[nodiscard]] inline auto enabled(const level severity) -> bool { return logger::instance().enabled(severity); }
|
|
|
|
namespace detail {
|
|
/** Build and dispatch one record. Not for direct use. */
|
|
inline auto emit(const level severity, std::string message, const std::source_location location) -> void {
|
|
auto &target = logger::instance();
|
|
if (!target.enabled(severity)) return;
|
|
|
|
const auto config = target.configuration();
|
|
auto entry = record{
|
|
.severity = severity,
|
|
.message = std::move(message),
|
|
.file = location.file_name(),
|
|
.line = static_cast<std::uint32_t>(location.line()),
|
|
.function = location.function_name(),
|
|
.time = std::chrono::system_clock::now(),
|
|
.thread = std::this_thread::get_id(),
|
|
};
|
|
if (severity >= config.stacktrace_from) {
|
|
entry.stacktrace = capture_stacktrace(config.stacktrace_skip, config.stacktrace_depth);
|
|
}
|
|
target.dispatch(entry);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emit a record.
|
|
*
|
|
* The source location defaults to the call site, so this reports exactly
|
|
* where it was called from.
|
|
*/
|
|
inline auto log(const level severity,
|
|
std::string message,
|
|
const std::source_location location = std::source_location::current()) -> void {
|
|
detail::emit(severity, std::move(message), location);
|
|
}
|
|
|
|
inline auto trace(std::string message, const std::source_location location = std::source_location::current())
|
|
-> void {
|
|
detail::emit(level::trace, std::move(message), location);
|
|
}
|
|
|
|
inline auto debug(std::string message, const std::source_location location = std::source_location::current())
|
|
-> void {
|
|
detail::emit(level::debug, std::move(message), location);
|
|
}
|
|
|
|
inline auto info(std::string message, const std::source_location location = std::source_location::current())
|
|
-> void {
|
|
detail::emit(level::info, std::move(message), location);
|
|
}
|
|
|
|
inline auto warn(std::string message, const std::source_location location = std::source_location::current())
|
|
-> void {
|
|
detail::emit(level::warn, std::move(message), location);
|
|
}
|
|
|
|
/** Emits at `error`, which captures a call stack by default. */
|
|
inline auto error(std::string message, const std::source_location location = std::source_location::current())
|
|
-> void {
|
|
detail::emit(level::error, std::move(message), location);
|
|
}
|
|
|
|
inline auto critical(std::string message, const std::source_location location = std::source_location::current())
|
|
-> void {
|
|
detail::emit(level::critical, std::move(message), location);
|
|
}
|
|
}
|