SlotMap abstraction & log fix

This commit is contained in:
Luke Street
2026-07-10 17:42:24 -06:00
parent 867ec7fbab
commit 7113584632
7 changed files with 445 additions and 381 deletions
+68 -90
View File
@@ -1,6 +1,7 @@
#include "config.hpp"
#include "registry.hpp"
#include "slot_map.hpp"
#include "aurora/lib/logging.hpp"
#include "dusk/config.hpp"
@@ -15,7 +16,6 @@
#include <memory>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace dusk::mods::svc {
@@ -23,25 +23,22 @@ namespace {
aurora::Module Log("dusk::mods::config");
struct ModConfigVarEntry {
uint64_t handle = 0;
ConfigVarType type = CONFIG_VAR_BOOL;
std::unique_ptr<config::ConfigVarBase> var;
enum class ConfigSlotKind : uint8_t {
Var,
Subscription,
};
struct ModConfigSubscription {
uint64_t handle = 0;
struct ConfigSlot {
ConfigSlotKind kind = ConfigSlotKind::Var;
// Var payload
ConfigVarType type = CONFIG_VAR_BOOL;
std::unique_ptr<config::ConfigVarBase> var;
// Subscription payload
uint64_t varHandle = 0;
config::Subscription coreSubscription = 0;
};
struct ModConfigRecord {
std::vector<ModConfigVarEntry> vars;
std::vector<ModConfigSubscription> subscriptions;
};
std::unordered_map<const LoadedMod*, ModConfigRecord> s_modConfig;
uint64_t s_nextHandle = 1;
SlotMap<ConfigSlot> s_slots;
bool s_dirty = false;
std::chrono::steady_clock::time_point s_lastSave{};
constexpr std::chrono::seconds kSaveDebounce{2};
@@ -130,17 +127,13 @@ bool valid_var_fragment(const char* name) {
}
config::ConfigVarBase* find_var(LoadedMod& mod, const uint64_t handle, uint32_t expectedType) {
const auto recordIt = s_modConfig.find(&mod);
if (recordIt == s_modConfig.end()) {
const auto* entry = s_slots.find_owned(handle, mod);
if (entry == nullptr || entry->value.kind != ConfigSlotKind::Var ||
entry->value.type != expectedType)
{
return nullptr;
}
const auto& vars = recordIt->second.vars;
const auto entry =
std::ranges::find_if(vars, [&](const auto& e) { return e.handle == handle; });
if (entry == vars.end() || entry->type != expectedType) {
return nullptr;
}
return entry->var.get();
return entry->value.var.get();
}
template <typename T>
@@ -154,17 +147,17 @@ ConfigVar<T>* find_typed_var(ModContext* context, ConfigVarHandle handle, uint32
}
void config_remove_mod(LoadedMod& mod) {
const auto it = s_modConfig.find(&mod);
if (it == s_modConfig.end()) {
return;
const auto entries = s_slots.take_all(mod);
for (const auto& entry : entries) {
if (entry.value.kind == ConfigSlotKind::Subscription) {
config::unsubscribe(entry.value.coreSubscription);
}
}
for (const auto& sub : it->second.subscriptions) {
config::unsubscribe(sub.coreSubscription);
for (const auto& entry : entries) {
if (entry.value.kind == ConfigSlotKind::Var) {
config::unregister(*entry.value.var);
}
}
for (const auto& entry : it->second.vars) {
config::unregister(*entry.var);
}
s_modConfig.erase(it);
}
ModResult config_register_var(
@@ -190,16 +183,16 @@ ModResult config_register_var(
std::unique_ptr<config::ConfigVarBase> var;
switch (desc->type) {
case CONFIG_VAR_BOOL:
var = std::make_unique<ConfigVar<bool> >(fullName, desc->default_bool);
var = std::make_unique<ConfigVar<bool>>(fullName, desc->default_bool);
break;
case CONFIG_VAR_INT:
var = std::make_unique<ConfigVar<s64> >(fullName, desc->default_int);
var = std::make_unique<ConfigVar<s64>>(fullName, desc->default_int);
break;
case CONFIG_VAR_FLOAT:
var = std::make_unique<ConfigVar<f64> >(fullName, desc->default_float);
var = std::make_unique<ConfigVar<f64>>(fullName, desc->default_float);
break;
case CONFIG_VAR_STRING:
var = std::make_unique<ConfigVar<std::string> >(
var = std::make_unique<ConfigVar<std::string>>(
fullName, desc->default_string != nullptr ? desc->default_string : "");
break;
default:
@@ -211,13 +204,10 @@ ModResult config_register_var(
// reads the value right after registration anyway.
config::Register(*var);
auto& record = s_modConfig[mod];
auto& entry = record.vars.emplace_back();
entry.handle = s_nextHandle++;
entry.type = desc->type;
entry.var = std::move(var);
const auto handle = s_slots.emplace(
*mod, ConfigSlot{.kind = ConfigSlotKind::Var, .type = desc->type, .var = std::move(var)});
if (outHandle != nullptr) {
*outHandle = entry.handle;
*outHandle = handle;
}
return MOD_OK;
}
@@ -227,29 +217,26 @@ ModResult config_unregister_var(ModContext* context, ConfigVarHandle var) {
if (mod == nullptr || var == 0) {
return MOD_INVALID_ARGUMENT;
}
const auto recordIt = s_modConfig.find(mod);
if (recordIt != s_modConfig.end()) {
auto& record = recordIt->second;
const auto entry =
std::ranges::find_if(record.vars, [&](const auto& e) { return e.handle == var; });
if (entry != record.vars.end()) {
std::erase_if(record.subscriptions, [&](const ModConfigSubscription& sub) {
if (sub.varHandle != var) {
return false;
}
config::unsubscribe(sub.coreSubscription);
return true;
});
// The persisted value is stashed and restored by a future registration of the
// same name.
config::unregister(*entry->var);
record.vars.erase(entry);
return MOD_OK;
}
const auto* entry = s_slots.find_owned(var, *mod);
if (entry == nullptr || entry->value.kind != ConfigSlotKind::Var) {
Log.error("[{}] config unregister failed: unknown handle {}", mod->metadata.id, var);
return MOD_INVALID_ARGUMENT;
}
Log.error("[{}] config unregister failed: unknown handle {}", mod->metadata.id, var);
return MOD_INVALID_ARGUMENT;
// Only the owning mod can (currently) subscribe to a var
std::vector<uint64_t> bound;
s_slots.for_each([&](const uint64_t handle, const auto& e) {
if (e.value.kind == ConfigSlotKind::Subscription && e.value.varHandle == var) {
bound.push_back(handle);
}
});
for (const auto handle : bound) {
config::unsubscribe(s_slots.take(handle)->value.coreSubscription);
}
// The persisted value is stashed and restored by a future registration of the same name.
config::unregister(*s_slots.take(var)->value.var);
return MOD_OK;
}
ModResult config_get_bool(ModContext* context, ConfigVarHandle var, bool* outValue) {
@@ -361,22 +348,13 @@ ModResult config_subscribe(ModContext* context, ConfigVarHandle var, ConfigChang
if (mod == nullptr || var == 0 || callback == nullptr) {
return MOD_INVALID_ARGUMENT;
}
const auto recordIt = s_modConfig.find(mod);
if (recordIt == s_modConfig.end()) {
return MOD_INVALID_ARGUMENT;
}
auto& record = recordIt->second;
const auto entry =
std::ranges::find_if(record.vars, [&](const auto& e) { return e.handle == var; });
if (entry == record.vars.end()) {
const auto* entry = s_slots.find_owned(var, *mod);
if (entry == nullptr || entry->value.kind != ConfigSlotKind::Var) {
return MOD_INVALID_ARGUMENT;
}
auto& sub = record.subscriptions.emplace_back();
sub.handle = s_nextHandle++;
sub.varHandle = var;
sub.coreSubscription = config::subscribe(entry->var->getName(),
[modPtr = mod, callback, userData, varHandle = var, type = entry->type](
const auto coreSubscription = config::subscribe(entry->value.var->getName(),
[modPtr = mod, callback, userData, varHandle = var, type = entry->value.type](
config::ConfigVarBase& varBase, const void* previous) {
const ConfigVarValue previousValue = translate_previous(type, previous);
std::string stringStorage;
@@ -390,8 +368,13 @@ ModResult config_subscribe(ModContext* context, ConfigVarHandle var, ConfigChang
fail_mod(*modPtr, MOD_ERROR, "Unknown exception in config change callback");
}
});
const auto handle = s_slots.emplace(*mod, ConfigSlot{
.kind = ConfigSlotKind::Subscription,
.varHandle = var,
.coreSubscription = coreSubscription,
});
if (outHandle != nullptr) {
*outHandle = sub.handle;
*outHandle = handle;
}
return MOD_OK;
}
@@ -401,19 +384,14 @@ ModResult config_unsubscribe(ModContext* context, ConfigSubscriptionHandle handl
if (mod == nullptr || handle == 0) {
return MOD_INVALID_ARGUMENT;
}
const auto recordIt = s_modConfig.find(mod);
if (recordIt != s_modConfig.end()) {
auto& subscriptions = recordIt->second.subscriptions;
const auto sub =
std::ranges::find_if(subscriptions, [&](const auto& s) { return s.handle == handle; });
if (sub != subscriptions.end()) {
config::unsubscribe(sub->coreSubscription);
subscriptions.erase(sub);
return MOD_OK;
}
const auto* entry = s_slots.find_owned(handle, *mod);
if (entry == nullptr || entry->value.kind != ConfigSlotKind::Subscription) {
Log.error("[{}] config unsubscribe failed: unknown handle {}", mod->metadata.id, handle);
return MOD_INVALID_ARGUMENT;
}
Log.error("[{}] config unsubscribe failed: unknown handle {}", mod->metadata.id, handle);
return MOD_INVALID_ARGUMENT;
config::unsubscribe(entry->value.coreSubscription);
s_slots.erase(handle);
return MOD_OK;
}
constexpr ConfigService s_configService{
+70 -118
View File
@@ -1,4 +1,5 @@
#include "registry.hpp"
#include "slot_map.hpp"
#include "aurora/lib/logging.hpp"
#include "dusk/gfx.hpp"
@@ -35,9 +36,6 @@ enum class GfxStreamBuffer : uint8_t {
struct GfxSlot {
GfxSlotKind kind = GfxSlotKind::DrawType;
uint32_t generation = 1;
bool alive = false;
LoadedMod* owner = nullptr;
ModContext* ownerContext = nullptr;
std::string ownerId;
void* userData = nullptr;
@@ -60,71 +58,37 @@ struct WorkerFailure {
};
std::mutex s_mutex;
std::vector<GfxSlot> s_slots;
std::vector<uint32_t> s_freeSlots;
using GfxSlotMap = svc::SlotMap<GfxSlot>;
GfxSlotMap s_slots;
std::vector<WorkerFailure> s_workerFailures;
bool s_modOffscreenOpen = false;
constexpr uint64_t make_handle(uint32_t index, uint32_t generation) {
return static_cast<uint64_t>(generation) << 32 | index;
GfxSlotMap::Entry* resolve_entry_locked(uint64_t handle, GfxSlotKind kind) {
auto* entry = s_slots.find(handle);
if (entry == nullptr || entry->value.kind != kind) {
return nullptr;
}
return entry;
}
GfxSlot* resolve_slot_locked(uint64_t handle, GfxSlotKind kind) {
const auto index = static_cast<uint32_t>(handle & 0xFFFFFFFFu);
const auto generation = static_cast<uint32_t>(handle >> 32);
if (handle == 0 || index >= s_slots.size()) {
return nullptr;
}
auto& slot = s_slots[index];
if (!slot.alive || slot.generation != generation || slot.kind != kind) {
return nullptr;
}
return &slot;
auto* entry = resolve_entry_locked(handle, kind);
return entry != nullptr ? &entry->value : nullptr;
}
GfxSlot* resolve_owned_slot_locked(LoadedMod& mod, uint64_t handle, GfxSlotKind kind) {
auto* slot = resolve_slot_locked(handle, kind);
if (slot == nullptr || slot->owner != &mod) {
auto* entry = s_slots.find_owned(handle, mod);
if (entry == nullptr || entry->value.kind != kind) {
return nullptr;
}
return slot;
return &entry->value;
}
uint32_t alloc_slot_locked() {
if (!s_freeSlots.empty()) {
const auto index = s_freeSlots.back();
s_freeSlots.pop_back();
return index;
}
const auto index = static_cast<uint32_t>(s_slots.size());
s_slots.emplace_back();
return index;
}
void free_slot_locked(uint32_t index) {
auto& slot = s_slots[index];
slot.alive = false;
++slot.generation;
slot.owner = nullptr;
slot.ownerContext = nullptr;
slot.ownerId.clear();
slot.userData = nullptr;
slot.drawFn = nullptr;
slot.auroraDrawId = aurora::gfx::InvalidDrawType;
slot.stageFn = nullptr;
slot.stage = GFX_STAGE_SCENE_AFTER_TERRAIN;
slot.computeFn = nullptr;
slot.auroraTaskId = aurora::gfx::InvalidEncoderTask;
s_freeSlots.push_back(index);
}
void collect_mod_slots_locked(LoadedMod* owner, std::vector<aurora::gfx::DrawTypeId>& drawIds,
void collect_mod_slots_locked(LoadedMod& owner, std::vector<aurora::gfx::DrawTypeId>& drawIds,
std::vector<aurora::gfx::EncoderTaskId>& taskIds) {
for (uint32_t i = 0; i < s_slots.size(); ++i) {
auto& slot = s_slots[i];
if (!slot.alive || slot.owner != owner) {
continue;
}
auto entries = s_slots.take_all(owner);
for (auto& entry : entries) {
const auto& slot = entry.value;
if (slot.kind == GfxSlotKind::DrawType && slot.auroraDrawId != aurora::gfx::InvalidDrawType)
{
drawIds.push_back(slot.auroraDrawId);
@@ -133,7 +97,6 @@ void collect_mod_slots_locked(LoadedMod* owner, std::vector<aurora::gfx::DrawTyp
{
taskIds.push_back(slot.auroraTaskId);
}
free_slot_locked(i);
}
}
@@ -157,15 +120,16 @@ void draw_trampoline(const aurora::gfx::DrawContext& ctx, const wgpu::RenderPass
std::string ownerId;
{
std::lock_guard lock{s_mutex};
auto* slot = resolve_slot_locked(handle, GfxSlotKind::DrawType);
if (slot == nullptr) {
auto* entry = resolve_entry_locked(handle, GfxSlotKind::DrawType);
if (entry == nullptr) {
return;
}
fn = slot->drawFn;
userData = slot->userData;
modContext = slot->ownerContext;
owner = slot->owner;
ownerId = slot->ownerId;
const auto& slot = entry->value;
fn = slot.drawFn;
userData = slot.userData;
modContext = slot.ownerContext;
owner = entry->owner;
ownerId = slot.ownerId;
}
GfxDrawContext drawContext{
@@ -200,7 +164,7 @@ void draw_trampoline(const aurora::gfx::DrawContext& ctx, const wgpu::RenderPass
.modId = std::move(ownerId),
.message = std::move(failure),
};
collect_mod_slots_locked(owner, record.drawIds, record.taskIds);
collect_mod_slots_locked(*owner, record.drawIds, record.taskIds);
s_workerFailures.push_back(std::move(record));
}
@@ -214,15 +178,16 @@ void compute_trampoline(const aurora::gfx::EncoderTaskContext& ctx, const wgpu::
std::string ownerId;
{
std::lock_guard lock{s_mutex};
auto* slot = resolve_slot_locked(handle, GfxSlotKind::ComputeType);
if (slot == nullptr) {
auto* entry = resolve_entry_locked(handle, GfxSlotKind::ComputeType);
if (entry == nullptr) {
return;
}
fn = slot->computeFn;
userData = slot->userData;
modContext = slot->ownerContext;
owner = slot->owner;
ownerId = slot->ownerId;
const auto& slot = entry->value;
fn = slot.computeFn;
userData = slot.userData;
modContext = slot.ownerContext;
owner = entry->owner;
ownerId = slot.ownerId;
}
GfxComputeContext computeContext{
@@ -251,7 +216,7 @@ void compute_trampoline(const aurora::gfx::EncoderTaskContext& ctx, const wgpu::
.modId = std::move(ownerId),
.message = std::move(failure),
};
collect_mod_slots_locked(owner, record.drawIds, record.taskIds);
collect_mod_slots_locked(*owner, record.drawIds, record.taskIds);
s_workerFailures.push_back(std::move(record));
}
@@ -264,16 +229,13 @@ ModResult gfx_register_draw_type(
uint64_t handle = 0;
{
std::lock_guard lock{s_mutex};
const auto index = alloc_slot_locked();
auto& slot = s_slots[index];
slot.kind = GfxSlotKind::DrawType;
slot.alive = true;
slot.owner = &mod;
slot.ownerContext = mod.context.get();
slot.ownerId = mod.metadata.id;
slot.userData = userData;
slot.drawFn = draw;
handle = make_handle(index, slot.generation);
handle = s_slots.emplace(mod, GfxSlot{
.kind = GfxSlotKind::DrawType,
.ownerContext = mod.context.get(),
.ownerId = mod.metadata.id,
.userData = userData,
.drawFn = draw,
});
}
const auto auroraId = aurora::gfx::register_draw_type(aurora::gfx::DrawTypeDescriptor{
@@ -283,9 +245,7 @@ ModResult gfx_register_draw_type(
});
if (auroraId == aurora::gfx::InvalidDrawType) {
std::lock_guard lock{s_mutex};
if (resolve_owned_slot_locked(mod, handle, GfxSlotKind::DrawType) != nullptr) {
free_slot_locked(static_cast<uint32_t>(handle & 0xFFFFFFFFu));
}
s_slots.erase_owned(handle, mod);
return MOD_ERROR;
}
@@ -308,7 +268,7 @@ ModResult gfx_unregister_draw_type(LoadedMod& mod, uint64_t handle) {
return MOD_INVALID_ARGUMENT;
}
auroraId = slot->auroraDrawId;
free_slot_locked(static_cast<uint32_t>(handle & 0xFFFFFFFFu));
s_slots.erase_owned(handle, mod);
}
aurora::gfx::unregister_draw_type(auroraId);
return MOD_OK;
@@ -359,17 +319,14 @@ ModResult gfx_register_stage_hook(
LoadedMod& mod, GfxStage stage, GfxStageFn callback, void* userData, uint64_t& outHandle) {
outHandle = 0;
std::lock_guard lock{s_mutex};
const auto index = alloc_slot_locked();
auto& slot = s_slots[index];
slot.kind = GfxSlotKind::StageHook;
slot.alive = true;
slot.owner = &mod;
slot.ownerContext = mod.context.get();
slot.ownerId = mod.metadata.id;
slot.userData = userData;
slot.stageFn = callback;
slot.stage = stage;
outHandle = make_handle(index, slot.generation);
outHandle = s_slots.emplace(mod, GfxSlot{
.kind = GfxSlotKind::StageHook,
.ownerContext = mod.context.get(),
.ownerId = mod.metadata.id,
.userData = userData,
.stageFn = callback,
.stage = stage,
});
return MOD_OK;
}
@@ -379,7 +336,7 @@ ModResult gfx_unregister_stage_hook(LoadedMod& mod, uint64_t handle) {
if (slot == nullptr) {
return MOD_INVALID_ARGUMENT;
}
free_slot_locked(static_cast<uint32_t>(handle & 0xFFFFFFFFu));
s_slots.erase_owned(handle, mod);
return MOD_OK;
}
@@ -429,16 +386,13 @@ ModResult gfx_register_compute_type(
uint64_t handle = 0;
{
std::lock_guard lock{s_mutex};
const auto index = alloc_slot_locked();
auto& slot = s_slots[index];
slot.kind = GfxSlotKind::ComputeType;
slot.alive = true;
slot.owner = &mod;
slot.ownerContext = mod.context.get();
slot.ownerId = mod.metadata.id;
slot.userData = userData;
slot.computeFn = callback;
handle = make_handle(index, slot.generation);
handle = s_slots.emplace(mod, GfxSlot{
.kind = GfxSlotKind::ComputeType,
.ownerContext = mod.context.get(),
.ownerId = mod.metadata.id,
.userData = userData,
.computeFn = callback,
});
}
const auto auroraId =
@@ -449,9 +403,7 @@ ModResult gfx_register_compute_type(
});
if (auroraId == aurora::gfx::InvalidEncoderTask) {
std::lock_guard lock{s_mutex};
if (resolve_owned_slot_locked(mod, handle, GfxSlotKind::ComputeType) != nullptr) {
free_slot_locked(static_cast<uint32_t>(handle & 0xFFFFFFFFu));
}
s_slots.erase_owned(handle, mod);
return MOD_ERROR;
}
@@ -474,7 +426,7 @@ ModResult gfx_unregister_compute_type(LoadedMod& mod, uint64_t handle) {
return MOD_INVALID_ARGUMENT;
}
auroraId = slot->auroraTaskId;
free_slot_locked(static_cast<uint32_t>(handle & 0xFFFFFFFFu));
s_slots.erase_owned(handle, mod);
}
aurora::gfx::unregister_encoder_task_type(auroraId);
return MOD_OK;
@@ -510,18 +462,18 @@ void gfx_run_stage(
std::vector<StageEntry> entries;
{
std::lock_guard lock{s_mutex};
for (uint32_t i = 0; i < s_slots.size(); ++i) {
const auto& slot = s_slots[i];
if (slot.alive && slot.kind == GfxSlotKind::StageHook && slot.stage == stage) {
s_slots.for_each([&](uint64_t handle, const auto& slotEntry) {
const auto& slot = slotEntry.value;
if (slot.kind == GfxSlotKind::StageHook && slot.stage == stage) {
entries.push_back(StageEntry{
.handle = make_handle(i, slot.generation),
.handle = handle,
.fn = slot.stageFn,
.userData = slot.userData,
.context = slot.ownerContext,
.owner = slot.owner,
.owner = slotEntry.owner,
});
}
}
});
}
if (entries.empty()) {
return;
@@ -600,7 +552,7 @@ void gfx_remove_mod(LoadedMod& mod) {
std::vector<aurora::gfx::EncoderTaskId> taskIds;
{
std::lock_guard lock{s_mutex};
collect_mod_slots_locked(&mod, drawIds, taskIds);
collect_mod_slots_locked(mod, drawIds, taskIds);
}
if (drawIds.empty() && taskIds.empty()) {
return;
+29 -20
View File
@@ -1,4 +1,5 @@
#include "registry.hpp"
#include "slot_map.hpp"
#include "dusk/mods/loader/loader.hpp"
#include "dusk/mods/manifest.hpp"
@@ -63,14 +64,13 @@ const char* host_mod_dir(ModContext* context) {
}
struct LifecycleWatcher {
uint64_t handle = 0;
LoadedMod* owner = nullptr;
ModLifecycleFn fn = nullptr;
void* userData = nullptr;
uint64_t order = 0;
};
std::vector<LifecycleWatcher> s_watchers;
uint64_t s_nextWatchHandle = 1;
SlotMap<LifecycleWatcher> s_watchers;
uint64_t s_nextWatchOrder = 0;
ModResult host_watch_mod_lifecycle(
ModContext* context, ModLifecycleFn fn, void* userData, uint64_t* outHandle) {
@@ -78,8 +78,8 @@ ModResult host_watch_mod_lifecycle(
if (mod == nullptr || fn == nullptr || outHandle == nullptr) {
return MOD_INVALID_ARGUMENT;
}
const auto handle = s_nextWatchHandle++;
s_watchers.push_back({handle, mod, fn, userData});
const auto handle = s_watchers.emplace(
*mod, LifecycleWatcher{.fn = fn, .userData = userData, .order = s_nextWatchOrder++});
*outHandle = handle;
return MOD_OK;
}
@@ -89,32 +89,41 @@ ModResult host_unwatch_mod_lifecycle(ModContext* context, const uint64_t handle)
if (mod == nullptr) {
return MOD_INVALID_ARGUMENT;
}
const auto erased = std::erase_if(s_watchers,
[&](const LifecycleWatcher& w) { return w.handle == handle && w.owner == mod; });
return erased != 0 ? MOD_OK : MOD_INVALID_ARGUMENT;
return s_watchers.erase_owned(handle, *mod) ? MOD_OK : MOD_INVALID_ARGUMENT;
}
void host_mod_detached(LoadedMod& mod) {
// The subject's own watches go first: a mod is never notified about its own teardown.
std::erase_if(s_watchers, [&](const LifecycleWatcher& w) { return w.owner == &mod; });
s_watchers.erase_all(mod);
// Iterate a snapshot: callbacks may watch/unwatch, and a failing callback erases the
// failing mod's services.
const auto snapshot = s_watchers;
for (const auto& watcher : snapshot) {
const bool alive = std::ranges::any_of(
s_watchers, [&](const LifecycleWatcher& w) { return w.handle == watcher.handle; });
if (!alive) {
// Iterate a snapshot in registration order: callbacks may watch/unwatch, and a failing
// callback erases the failing mod's services.
struct PendingNotify {
uint64_t order;
uint64_t handle;
};
std::vector<PendingNotify> snapshot;
s_watchers.for_each([&](const uint64_t handle, const auto& entry) {
snapshot.push_back({.order = entry.value.order, .handle = handle});
});
std::ranges::sort(snapshot, {}, &PendingNotify::order);
for (const auto& pending : snapshot) {
const auto* entry = s_watchers.find(pending.handle);
if (entry == nullptr) {
continue;
}
// Do not retain pointers into SlotMap across a callback that may mutate it.
auto* owner = entry->owner;
const auto watcher = entry->value;
try {
watcher.fn(watcher.owner->context.get(), mod.context.get(), mod.metadata.id.c_str(),
watcher.fn(owner->context.get(), mod.context.get(), mod.metadata.id.c_str(),
MOD_LIFECYCLE_DETACHED, watcher.userData);
} catch (const std::exception& e) {
fail_mod(*watcher.owner, MOD_ERROR,
fail_mod(*owner, MOD_ERROR,
fmt::format("Exception in mod lifecycle callback: {}", e.what()));
} catch (...) {
fail_mod(*watcher.owner, MOD_ERROR, "Unknown exception in mod lifecycle callback");
fail_mod(*owner, MOD_ERROR, "Unknown exception in mod lifecycle callback");
}
}
}
+39 -40
View File
@@ -1,10 +1,12 @@
#include "registry.hpp"
#include "slot_map.hpp"
#include "aurora/dvd.h"
#include "aurora/lib/logging.hpp"
#include "dusk/mods/loader/loader.hpp"
#include "mods/svc/overlay.h"
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <mutex>
@@ -33,15 +35,15 @@ std::unordered_map<uintptr_t, OverlayFileData> s_overlayFiles;
uintptr_t s_nextOverlayId = 1;
std::mutex s_overlayMutex;
struct RuntimeOverlayEntry {
uint64_t handle = 0;
struct RuntimeOverlaySlot {
std::string discPath;
std::string bundlePath; // bundle-backed if non-empty
std::shared_ptr<const std::vector<u8>> buffer; // buffer-backed otherwise
size_t size = 0;
uint64_t order = 0;
};
std::unordered_map<const LoadedMod*, std::vector<RuntimeOverlayEntry>> s_runtimeOverlays;
uint64_t s_nextRuntimeHandle = 1;
SlotMap<RuntimeOverlaySlot> s_runtimeOverlays;
uint64_t s_nextRuntimeOrder = 0;
bool s_overlaysDirty = false;
// Aurora matches overlay paths against the disc case-insensitively and later entries win, so
@@ -84,20 +86,25 @@ void find_overlay_files(std::vector<AuroraOverlayFile>& files, LoadedMod& mod,
void append_runtime_overlays(std::vector<AuroraOverlayFile>& files, LoadedMod& mod,
std::unordered_map<std::string, const LoadedMod*>& claims) {
const auto it = s_runtimeOverlays.find(&mod);
if (it == s_runtimeOverlays.end()) {
return;
}
for (const auto& entry : it->second) {
const auto id = s_nextOverlayId++;
if (entry.buffer != nullptr) {
s_overlayFiles.emplace(id, OverlayFileData{{}, nullptr, entry.buffer});
} else {
s_overlayFiles.emplace(id, OverlayFileData{entry.bundlePath, mod.bundle, nullptr});
// Aurora resolves duplicate paths later-entry-wins, so emit in registration order (SlotMap
// iteration is index order, and freed indices are reused).
std::vector<const RuntimeOverlaySlot*> slots;
s_runtimeOverlays.for_each([&](uint64_t, const auto& entry) {
if (entry.owner == &mod) {
slots.push_back(&entry.value);
}
claim_overlay_path(claims, entry.discPath, mod);
files.emplace_back(strdup(entry.discPath.c_str()), reinterpret_cast<void*>(id), entry.size);
});
std::ranges::sort(slots, {}, &RuntimeOverlaySlot::order);
for (const auto* slot : slots) {
const auto id = s_nextOverlayId++;
if (slot->buffer != nullptr) {
s_overlayFiles.emplace(id, OverlayFileData{{}, nullptr, slot->buffer});
} else {
s_overlayFiles.emplace(id, OverlayFileData{slot->bundlePath, mod.bundle, nullptr});
}
claim_overlay_path(claims, slot->discPath, mod);
files.emplace_back(strdup(slot->discPath.c_str()), reinterpret_cast<void*>(id), slot->size);
}
}
@@ -201,47 +208,39 @@ void overlay_sync_files() {
uint64_t overlay_add_file(
LoadedMod& mod, std::string discPath, std::string bundlePath, size_t size) {
const auto handle = s_nextRuntimeHandle++;
s_runtimeOverlays[&mod].push_back({
.handle = handle,
.discPath = std::move(discPath),
.bundlePath = std::move(bundlePath),
.size = size,
});
const auto handle = s_runtimeOverlays.emplace(mod, RuntimeOverlaySlot{
.discPath = std::move(discPath),
.bundlePath = std::move(bundlePath),
.size = size,
.order = s_nextRuntimeOrder++,
});
s_overlaysDirty = true;
return handle;
}
uint64_t overlay_add_buffer(LoadedMod& mod, std::string discPath, std::vector<u8> data) {
const auto handle = s_nextRuntimeHandle++;
const auto size = data.size();
s_runtimeOverlays[&mod].push_back({
.handle = handle,
.discPath = std::move(discPath),
.buffer = std::make_shared<const std::vector<u8>>(std::move(data)),
.size = size,
});
const auto handle = s_runtimeOverlays.emplace(mod,
RuntimeOverlaySlot{
.discPath = std::move(discPath),
.buffer = std::make_shared<const std::vector<u8>>(std::move(data)),
.size = size,
.order = s_nextRuntimeOrder++,
});
s_overlaysDirty = true;
return handle;
}
bool overlay_remove(LoadedMod& mod, uint64_t handle) {
const auto it = s_runtimeOverlays.find(&mod);
if (it == s_runtimeOverlays.end()) {
if (!s_runtimeOverlays.erase_owned(handle, mod)) {
return false;
}
if (std::erase_if(it->second, [&](const auto& entry) { return entry.handle == handle; }) == 0) {
return false;
}
if (it->second.empty()) {
s_runtimeOverlays.erase(it);
}
s_overlaysDirty = true;
return true;
}
void overlay_remove_mod(LoadedMod& mod) {
if (s_runtimeOverlays.erase(&mod) != 0) {
if (s_runtimeOverlays.erase_all(mod) != 0) {
s_overlaysDirty = true;
}
}
+188
View File
@@ -0,0 +1,188 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <limits>
#include <optional>
#include <stdexcept>
#include <type_traits>
#include <utility>
#include <vector>
namespace dusk::mods {
struct LoadedMod;
namespace svc {
template <typename T>
class SlotMap {
public:
static_assert(std::is_nothrow_move_constructible_v<T>);
using Handle = uint64_t;
static constexpr Handle InvalidHandle = 0;
struct Entry {
LoadedMod* owner = nullptr;
T value;
};
template <typename... Args>
Handle emplace(LoadedMod& owner, Args&&... args) {
T value{std::forward<Args>(args)...};
const auto index = allocate_index();
auto& slot = m_slots[index];
slot.entry.emplace(Entry{.owner = &owner, .value = std::move(value)});
return make_handle(index, slot.generation);
}
// Returned pointers remain valid only until the next mutating operation.
Entry* find(Handle handle) {
auto* slot = find_slot(handle);
return slot != nullptr ? &*slot->entry : nullptr;
}
const Entry* find(Handle handle) const {
const auto* slot = find_slot(handle);
return slot != nullptr ? &*slot->entry : nullptr;
}
Entry* find_owned(Handle handle, const LoadedMod& owner) {
auto* entry = find(handle);
return entry != nullptr && entry->owner == &owner ? entry : nullptr;
}
const Entry* find_owned(Handle handle, const LoadedMod& owner) const {
const auto* entry = find(handle);
return entry != nullptr && entry->owner == &owner ? entry : nullptr;
}
std::optional<Entry> take(Handle handle) {
const auto index = handle_index(handle);
auto* slot = find_slot(handle);
if (slot == nullptr) {
return std::nullopt;
}
std::optional<Entry> entry{std::move(slot->entry)};
release_slot(index);
return entry;
}
std::optional<Entry> take_owned(Handle handle, const LoadedMod& owner) {
if (find_owned(handle, owner) == nullptr) {
return std::nullopt;
}
return take(handle);
}
std::vector<Entry> take_all(const LoadedMod& owner) {
std::vector<Entry> entries;
for (size_t slotIndex = 0; slotIndex < m_slots.size(); ++slotIndex) {
const auto index = static_cast<uint32_t>(slotIndex);
auto& slot = m_slots[index];
if (!slot.entry.has_value() || slot.entry->owner != &owner) {
continue;
}
entries.push_back(std::move(*slot.entry));
release_slot(index);
}
return entries;
}
bool erase(Handle handle) {
const auto index = handle_index(handle);
if (find_slot(handle) == nullptr) {
return false;
}
release_slot(index);
return true;
}
bool erase_owned(Handle handle, const LoadedMod& owner) {
if (find_owned(handle, owner) == nullptr) {
return false;
}
return erase(handle);
}
size_t erase_all(const LoadedMod& owner) {
return take_all(owner).size();
}
template <typename Fn>
void for_each(Fn&& fn) const {
// The visitor may inspect entries but must not mutate this SlotMap.
for (size_t slotIndex = 0; slotIndex < m_slots.size(); ++slotIndex) {
const auto index = static_cast<uint32_t>(slotIndex);
const auto& slot = m_slots[index];
if (slot.entry.has_value()) {
fn(make_handle(index, slot.generation), *slot.entry);
}
}
}
private:
struct Slot {
uint32_t generation = 1;
std::optional<Entry> entry;
};
static constexpr Handle make_handle(uint32_t index, uint32_t generation) {
return static_cast<Handle>(generation) << 32 | index;
}
static constexpr uint32_t handle_index(Handle handle) {
return static_cast<uint32_t>(handle & std::numeric_limits<uint32_t>::max());
}
static constexpr uint32_t handle_generation(Handle handle) {
return static_cast<uint32_t>(handle >> 32);
}
Slot* find_slot(Handle handle) {
return const_cast<Slot*>(std::as_const(*this).find_slot(handle));
}
const Slot* find_slot(Handle handle) const {
const auto index = handle_index(handle);
if (handle == InvalidHandle || index >= m_slots.size()) {
return nullptr;
}
const auto& slot = m_slots[index];
if (!slot.entry.has_value() || slot.generation != handle_generation(handle)) {
return nullptr;
}
return &slot;
}
uint32_t allocate_index() {
if (!m_freeSlots.empty()) {
const auto index = m_freeSlots.back();
m_freeSlots.pop_back();
return index;
}
if (m_slots.size() > std::numeric_limits<uint32_t>::max()) {
throw std::length_error{"SlotMap handle space exhausted"};
}
const auto index = static_cast<uint32_t>(m_slots.size());
m_slots.emplace_back();
return index;
}
void release_slot(uint32_t index) {
auto& slot = m_slots[index];
slot.entry.reset();
if (slot.generation == std::numeric_limits<uint32_t>::max()) {
return;
}
++slot.generation;
m_freeSlots.push_back(index);
}
std::vector<Slot> m_slots;
std::vector<uint32_t> m_freeSlots;
};
} // namespace svc
} // namespace dusk::mods
+41 -103
View File
@@ -2,6 +2,7 @@
#include "config.hpp"
#include "registry.hpp"
#include "slot_map.hpp"
#include "aurora/lib/logging.hpp"
#include "dusk/mod_loader.hpp"
@@ -33,7 +34,6 @@ namespace {
aurora::Module Log("dusk::mods::ui");
enum class UiSlotKind : u8 {
Free,
Window,
Dialog,
Pane,
@@ -63,17 +63,14 @@ const char* slot_kind_name(UiSlotKind kind) {
case UiSlotKind::MenuTab:
return "menu tab";
default:
return "free";
return "unknown";
}
}
// Generation-checked handle slots. Game thread only: all mutations happen in service calls made
// from mod code, in UI callbacks (ui::update), or in the loader's deactivate paths.
// Game thread only: all mutations happen in service calls made from mod code, in UI callbacks
// (ui::update), or in the loader's deactivate paths.
struct UiSlot {
// Bumped on free, which invalidates every outstanding handle to this slot.
uint32_t generation = 1;
UiSlotKind kind = UiSlotKind::Free;
LoadedMod* owner = nullptr;
UiSlotKind kind = UiSlotKind::Window;
// Pane/Text/Progress/Control: freed automatically when the element is destroyed
Rml::Element* element = nullptr;
// Pane payload
@@ -93,8 +90,7 @@ struct UiSlot {
bool hasElementValue = false;
};
std::vector<UiSlot> s_slots;
std::vector<uint32_t> s_freeSlots;
SlotMap<UiSlot> s_slots;
struct ModUiPanel {
UiPanelBuildFn build = nullptr;
@@ -112,71 +108,26 @@ struct ModMenuTab {
std::unordered_map<const LoadedMod*, std::vector<ModMenuTab>> s_modMenuTabs;
bool s_menuTabsDirty = false;
uint64_t handle_for(uint32_t index) {
return (uint64_t{s_slots[index].generation} << 32) | index;
}
uint32_t slot_index(const UiSlot& slot) {
return static_cast<uint32_t>(&slot - s_slots.data());
}
UiSlot* slot_from_handle(uint64_t handle) {
const auto index = static_cast<uint32_t>(handle & 0xFFFFFFFFu);
const auto generation = static_cast<uint32_t>(handle >> 32);
if (index >= s_slots.size()) {
return nullptr;
}
auto& slot = s_slots[index];
if (slot.kind == UiSlotKind::Free || slot.generation != generation) {
return nullptr;
}
return &slot;
auto* entry = s_slots.find(handle);
return entry != nullptr ? &entry->value : nullptr;
}
// Note: s_slots may reallocate on any later allocation, so callers must not hold the returned
// slot reference across calls that can allocate (e.g. mod build callbacks); re-resolve instead.
UiSlot& alloc_slot(LoadedMod& mod, UiSlotKind kind, uint64_t& outHandle) {
uint32_t index;
if (!s_freeSlots.empty()) {
index = s_freeSlots.back();
s_freeSlots.pop_back();
} else {
index = static_cast<uint32_t>(s_slots.size());
s_slots.emplace_back();
}
auto& slot = s_slots[index];
slot.kind = kind;
slot.owner = &mod;
outHandle = handle_for(index);
return slot;
}
void free_slot(UiSlot& slot) {
slot.generation++;
slot.kind = UiSlotKind::Free;
slot.owner = nullptr;
slot.element = nullptr;
slot.pane = nullptr;
slot.helpPane = nullptr;
slot.document = nullptr;
slot.onClosed = nullptr;
slot.onClosedUserData = nullptr;
slot.styleScope = ui::DocumentScope::None;
slot.styleId.clear();
slot.elementRml.clear();
slot.elementFloat = 0.0f;
slot.hasElementValue = false;
s_freeSlots.push_back(slot_index(slot));
outHandle = s_slots.emplace(mod, UiSlot{.kind = kind});
return s_slots.find(outHandle)->value;
}
UiSlot* resolve(LoadedMod& mod, uint64_t handle, UiSlotKind kind, const char* what) {
auto* slot = slot_from_handle(handle);
if (slot == nullptr || slot->owner != &mod || slot->kind != kind) {
auto* entry = s_slots.find_owned(handle, mod);
if (entry == nullptr || entry->value.kind != kind) {
Log.error("[{}] {}: stale or invalid {} handle {:#x}", mod.metadata.id, what,
slot_kind_name(kind), handle);
return nullptr;
}
return slot;
return &entry->value;
}
// Whether the registration a callback was created under is still live. Callbacks captured by
@@ -184,7 +135,7 @@ UiSlot* resolve(LoadedMod& mod, uint64_t handle, UiSlotKind kind, const char* wh
// once a reload completes, but captured fn pointers still target the unloaded image. Teardown
// frees the slots (ui_remove_mod), which invalidates every callback built under them.
bool slot_live(uint64_t handle) {
return slot_from_handle(handle) != nullptr;
return s_slots.find(handle) != nullptr;
}
bool dialog_open(uint64_t handle) {
@@ -197,30 +148,22 @@ bool dialog_open(uint64_t handle) {
// The generation check makes a late detach of an already-recycled slot a no-op.
class SlotDetachListener final : public Rml::EventListener {
public:
SlotDetachListener(uint32_t index, uint32_t generation)
: m_index{index}, m_generation{generation} {}
explicit SlotDetachListener(uint64_t handle) : m_handle{handle} {}
void ProcessEvent(Rml::Event&) override {}
void OnDetach(Rml::Element*) override {
if (m_index < s_slots.size()) {
auto& slot = s_slots[m_index];
if (slot.kind != UiSlotKind::Free && slot.generation == m_generation) {
free_slot(slot);
}
}
s_slots.erase(m_handle);
delete this;
}
private:
uint32_t m_index;
uint32_t m_generation;
uint64_t m_handle;
};
void track_element(UiSlot& slot, Rml::Element& element) {
void track_element(uint64_t handle, UiSlot& slot, Rml::Element& element) {
slot.element = &element;
element.AddEventListener(
Rml::EventId::Click, new SlotDetachListener(slot_index(slot), slot.generation));
element.AddEventListener(Rml::EventId::Click, new SlotDetachListener{handle});
}
template <typename T, typename Fn>
@@ -269,7 +212,7 @@ uint64_t wrap_pane(LoadedMod& mod, ui::Pane& pane, ui::Pane* helpPane) {
auto& slot = alloc_slot(mod, UiSlotKind::Pane, handle);
slot.pane = &pane;
slot.helpPane = helpPane;
track_element(slot, *pane.root());
track_element(handle, slot, *pane.root());
return handle;
}
@@ -446,14 +389,14 @@ bool wire_config_var_binding(LoadedMod& mod, const UiControlDesc& desc, ui::ModC
}
void on_mod_window_destroyed(uint64_t handle) {
auto* slot = slot_from_handle(handle);
if (slot == nullptr || slot->kind != UiSlotKind::Window) {
const auto* entry = s_slots.find(handle);
if (entry == nullptr || entry->value.kind != UiSlotKind::Window) {
return;
}
LoadedMod* mod = slot->owner;
const UiWindowClosedFn onClosed = slot->onClosed;
void* userData = slot->onClosedUserData;
free_slot(*slot);
auto released = s_slots.take(handle);
auto* mod = released->owner;
const UiWindowClosedFn onClosed = released->value.onClosed;
void* userData = released->value.onClosedUserData;
if (mod != nullptr && onClosed != nullptr) {
guarded_call(*mod, "window on_closed callback", [&] {
onClosed(mod->context.get(), handle, userData);
@@ -464,7 +407,7 @@ void on_mod_window_destroyed(uint64_t handle) {
void on_mod_dialog_destroyed(uint64_t handle) {
auto* slot = slot_from_handle(handle);
if (slot != nullptr && slot->kind == UiSlotKind::Dialog) {
free_slot(*slot);
s_slots.erase(handle);
}
}
@@ -573,7 +516,7 @@ ModResult ui_pane_add_text(LoadedMod& mod, uint64_t pane, const char* text, uint
auto& elemSlot = alloc_slot(mod, UiSlotKind::Text, *outElem);
elemSlot.elementRml = ui::escape(text);
elemSlot.hasElementValue = true;
track_element(elemSlot, *elem);
track_element(*outElem, elemSlot, *elem);
}
return MOD_OK;
}
@@ -588,7 +531,7 @@ ModResult ui_pane_add_rml(LoadedMod& mod, uint64_t pane, const char* rml, uint64
auto& elemSlot = alloc_slot(mod, UiSlotKind::Text, *outElem);
elemSlot.elementRml = rml;
elemSlot.hasElementValue = true;
track_element(elemSlot, *elem);
track_element(*outElem, elemSlot, *elem);
}
return MOD_OK;
}
@@ -604,7 +547,7 @@ ModResult ui_pane_add_progress(LoadedMod& mod, uint64_t pane, float value, uint6
auto& elemSlot = alloc_slot(mod, UiSlotKind::Progress, *outElem);
elemSlot.elementFloat = value;
elemSlot.hasElementValue = true;
track_element(elemSlot, *elem);
track_element(*outElem, elemSlot, *elem);
}
return MOD_OK;
}
@@ -691,7 +634,7 @@ ModResult ui_pane_add_control(
}
if (outElem != nullptr) {
auto& elemSlot = alloc_slot(mod, UiSlotKind::Control, *outElem);
track_element(elemSlot, *control->root());
track_element(*outElem, elemSlot, *control->root());
}
return MOD_OK;
}
@@ -740,13 +683,13 @@ ModResult ui_elem_set_progress(LoadedMod& mod, uint64_t elem, float value) {
}
ModResult ui_elem_set_class(LoadedMod& mod, uint64_t elem, const char* name, bool active) {
auto* slot = slot_from_handle(elem);
if (slot == nullptr || slot->owner != &mod || slot->element == nullptr) {
auto* entry = s_slots.find_owned(elem, mod);
if (entry == nullptr || entry->value.element == nullptr) {
Log.error(
"[{}] elem_set_class: stale or invalid element handle {:#x}", mod.metadata.id, elem);
return MOD_INVALID_ARGUMENT;
}
slot->element->SetClass(name, active);
entry->value.element->SetClass(name, active);
return MOD_OK;
}
@@ -945,7 +888,7 @@ ModResult ui_unregister_menu_tab(LoadedMod& mod, uint64_t handle) {
s_modMenuTabs.erase(it);
}
}
free_slot(*slot);
s_slots.erase_owned(handle, mod);
s_menuTabsDirty = true;
return MOD_OK;
}
@@ -1026,7 +969,7 @@ ModResult ui_register_styles(
slot.styleId = fmt::format("{}:{:x}", mod.metadata.id, handle);
if (!ui::register_scoped_styles(docScope, slot.styleId, rcss)) {
Log.error("[{}] register_styles: failed to parse RCSS", mod.metadata.id);
free_slot(slot);
s_slots.erase(handle);
return MOD_INVALID_ARGUMENT;
}
outHandle = handle;
@@ -1056,8 +999,8 @@ ModResult ui_unregister_styles(LoadedMod& mod, uint64_t handle) {
if (slot == nullptr) {
return MOD_INVALID_ARGUMENT;
}
ui::unregister_scoped_styles(slot->styleScope, slot->styleId);
free_slot(*slot);
auto released = s_slots.take_owned(handle, mod);
ui::unregister_scoped_styles(released->value.styleScope, released->value.styleId);
return MOD_OK;
}
@@ -1066,14 +1009,12 @@ void ui_remove_mod(LoadedMod& mod) {
if (s_modMenuTabs.erase(&mod) != 0) {
s_menuTabsDirty = true;
}
for (auto& slot : s_slots) {
if (slot.kind == UiSlotKind::Free || slot.owner != &mod) {
continue;
}
auto entries = s_slots.take_all(mod);
for (auto& entry : entries) {
auto& slot = entry.value;
switch (slot.kind) {
case UiSlotKind::Window: {
auto* window = static_cast<ui::ModWindow*>(slot.document);
free_slot(slot);
if (window != nullptr) {
window->force_close();
}
@@ -1081,7 +1022,6 @@ void ui_remove_mod(LoadedMod& mod) {
}
case UiSlotKind::Dialog: {
auto* dialog = static_cast<ModDialog*>(slot.document);
free_slot(slot);
if (dialog != nullptr) {
dialog->force_close();
}
@@ -1089,10 +1029,8 @@ void ui_remove_mod(LoadedMod& mod) {
}
case UiSlotKind::Style:
ui::unregister_scoped_styles(slot.styleScope, slot.styleId);
free_slot(slot);
break;
default:
free_slot(slot);
break;
}
}
+10 -10
View File
@@ -73,10 +73,10 @@ std::string format_time(int64_t timeMs) {
}
Rml::Element* append_span(Rml::Element* parent, const char* className, const Rml::String& text) {
auto span = parent->GetOwnerDocument()->CreateElement("span");
auto* span = append(parent, "span");
span->SetClass(className, true);
append_text(span.get(), text);
return parent->AppendChild(std::move(span));
append_text(span, text);
return span;
}
} // namespace
@@ -260,18 +260,18 @@ Rml::Element* LogsWindow::append_log_line(const mods::log::Line& line) {
modId = "?";
}
auto elem = mDocument->CreateElement("div");
auto* elem = append(mLinesElem, "div");
elem->SetClass("log-line", true);
elem->SetClass(level_class(line.level), true);
constexpr const char* kNbsp = "\xc2\xa0";
append_span(elem.get(), "log-time", format_time(line.timeMs));
append_text(elem.get(), kNbsp);
append_span(elem.get(), "log-mod", fmt::format("[{}]", modId));
append_text(elem.get(), kNbsp);
append_span(elem.get(), "log-msg", line.message);
append_span(elem, "log-time", format_time(line.timeMs));
append_text(elem, kNbsp);
append_span(elem, "log-mod", fmt::format("[{}]", modId));
append_text(elem, kNbsp);
append_span(elem, "log-msg", line.message);
return mLinesElem->AppendChild(std::move(elem));
return elem;
}
void LogsWindow::copy_to_clipboard() {