Mods manager UI & logs viewer

This commit is contained in:
Luke Street
2026-07-08 17:51:13 -06:00
parent 36635f8c62
commit bc11bf3563
35 changed files with 1709 additions and 176 deletions
+3 -3
View File
@@ -131,9 +131,9 @@ A service is a struct of C function pointers with a version header. You declare
loader resolves it before your mod initializes:
```cpp
IMPORT_SERVICE(LogService, svc_log); // required, any minor version
IMPORT_SERVICE_VERSION(LogService, svc_log, 2); // required, minor version >= 2
IMPORT_OPTIONAL_SERVICE(SomeService, svc_maybe); // may be null
IMPORT_SERVICE(LogService, svc_log); // required, any minor version
IMPORT_SERVICE_VERSION(LogService, svc_log, 2); // required, minor version >= 2
IMPORT_OPTIONAL_SERVICE(SomeService, svc_maybe); // may be null
```
Each service is individually versioned, and there may be multiple major versions of a service provided at once,
+10 -2
View File
@@ -1496,6 +1496,10 @@ set(DUSK_FILES
src/dusk/ui/input.hpp
src/dusk/ui/icon_provider.cpp
src/dusk/ui/icon_provider.hpp
src/dusk/ui/logs_window.cpp
src/dusk/ui/logs_window.hpp
src/dusk/ui/mod_texture_provider.cpp
src/dusk/ui/mod_texture_provider.hpp
src/dusk/ui/modal.cpp
src/dusk/ui/modal.hpp
src/dusk/ui/nav_types.hpp
@@ -1507,6 +1511,8 @@ set(DUSK_FILES
src/dusk/ui/pane.hpp
src/dusk/ui/menu_bar.cpp
src/dusk/ui/menu_bar.hpp
src/dusk/ui/mods_window.cpp
src/dusk/ui/mods_window.hpp
src/dusk/ui/prelaunch.cpp
src/dusk/ui/prelaunch.hpp
src/dusk/ui/preset.cpp
@@ -1541,6 +1547,10 @@ set(DUSK_FILES
src/dusk/OSReport.cpp
src/dusk/OSThread.cpp
src/dusk/OSMutex.cpp
src/dusk/mods/log_buffer.cpp
src/dusk/mods/log_buffer.hpp
src/dusk/mods/manifest.cpp
src/dusk/mods/manifest.hpp
src/dusk/mods/loader/bundle_disk.cpp
src/dusk/mods/loader/bundle_zip.cpp
src/dusk/mods/loader/context.cpp
@@ -1550,8 +1560,6 @@ set(DUSK_FILES
src/dusk/mods/loader/loader.hpp
src/dusk/mods/loader/native_module.cpp
src/dusk/mods/loader/native_module.hpp
src/dusk/mods/loader/manifest.cpp
src/dusk/mods/loader/manifest.hpp
src/dusk/mods/svc/config.cpp
src/dusk/mods/svc/config.hpp
src/dusk/mods/svc/game.cpp
+4 -4
View File
@@ -176,7 +176,7 @@ public:
void request_enable(std::string_view id);
void request_disable(std::string_view id);
void request_reload(std::string_view id);
void notify_mod_failure(LoadedMod& mod);
void notify_mod_failure(LoadedMod& mod, bool firstFailure);
[[nodiscard]] auto mods() const {
return m_mods | std::views::transform([](const auto& m) -> LoadedMod& { return *m; });
@@ -204,6 +204,7 @@ private:
std::vector<ModSearchDir> m_searchDirs;
std::filesystem::path m_cacheDir;
std::vector<Request> m_pendingRequests;
std::vector<std::string> m_pendingFailures;
std::vector<RetiredNative> m_retiredNatives;
bool m_initialized = false;
bool m_startupComplete = false;
@@ -224,12 +225,11 @@ private:
bool resolve_service_imports(LoadedMod& mod);
[[nodiscard]] std::string describe_missing_import(
const char* serviceId, uint16_t majorVersion, uint16_t minMinorVersion) const;
void clear_services();
void fail_mod(LoadedMod& mod, ModResult code, std::string_view message);
LoadedMod* find_mod(std::string_view id);
LoadedMod* find_mod(std::string_view id) const;
void drain_retired_natives();
void apply_pending_requests();
void flush_toasts();
void on_enabled_changed(LoadedMod& mod);
// Deactivates `target` (if needed) and its transitive dependents, optionally re-reads the
// bundle from disk, then reactivates whatever the current cvar/provider state allows.
+97
View File
@@ -0,0 +1,97 @@
window.logs content {
flex-flow: column;
}
window.logs .log-toolbar {
display: flex;
flex-flow: row;
flex: 0 0 64dp;
height: 64dp;
align-items: center;
gap: 8dp;
padding-right: 72dp;
background-color: rgba(217, 217, 217, 10%);
border-bottom: 2dp #92875B;
font-family: "Fira Sans Condensed";
font-weight: bold;
font-size: 18dp;
}
window.logs > close {
top: 8dp;
right: 8dp;
}
window.logs .log-title {
align-self: stretch;
flex: 0 0 auto;
padding: 0 24dp;
line-height: 64dp;
text-transform: uppercase;
border-bottom: 4dp #C2A42D;
font-effect: glow(0dp 4dp 0dp 4dp black);
}
window.logs .log-title-mod {
flex: 0 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
font-family: "Fira Sans";
font-weight: normal;
font-size: 15dp;
color: rgba(224, 219, 200, 55%);
}
.log-toolbar-spacer {
flex: 1 1 0;
}
.log-toolbar button {
flex: 0 0 auto;
font-family: "Fira Sans";
font-weight: normal;
font-size: 15dp;
padding: 5dp 12dp;
}
window.logs content pane.log-view {
flex: 1 1 0;
padding: 12dp 16dp;
padding-bottom: 0dp;
gap: 0dp;
}
.log-lines {
display: block;
}
.log-line {
display: block;
font-family: "Noto Mono";
font-size: 13dp;
line-height: 1.5;
word-break: break-word;
white-space: pre-wrap;
}
.log-line .log-time {
color: rgba(224, 219, 200, 45%);
}
.log-line .log-mod {
color: rgba(194, 164, 45, 80%);
}
.log-line.lvl-trace,
.log-line.lvl-debug {
opacity: 0.55;
}
.log-line.lvl-warn .log-msg {
color: #ffa826;
}
.log-line.lvl-error .log-msg {
color: #cc4444;
}
+218
View File
@@ -0,0 +1,218 @@
window.mods content pane.mod-list {
flex: 0 0 360dp;
padding: 16dp;
padding-bottom: 0dp;
gap: 4dp;
}
@media (max-height: 640dp) {
window.mods content pane.mod-list {
flex: 1 1 0;
}
}
window.mods content pane.mod-detail {
gap: 12dp;
}
.mod-info-row {
display: flex;
align-items: center;
gap: 12dp;
padding: 4dp 0;
}
.mod-info-label {
font-family: "Fira Sans Condensed";
font-weight: bold;
opacity: 0.55;
flex: 0 0 auto;
}
.mod-info-value {
flex: 1 1 0;
}
.mod-path {
font-size: 14dp;
word-break: break-all;
opacity: 0.7;
}
mod-entry {
display: flex;
flex-flow: row;
gap: 12dp;
padding: 10dp;
border-radius: 10dp;
decorator: vertical-gradient(#c2a42d00 #c2a42d00);
transition: decorator 0.1s linear-in-out;
cursor: pointer;
focus: auto;
}
mod-entry.current {
box-shadow: rgba(146, 135, 91, 40%) 0 0 0 1dp;
}
mod-entry:hover,
mod-entry:focus-visible {
decorator: vertical-gradient(#c2a42d00 #c2a42d26);
}
mod-entry:selected {
decorator: vertical-gradient(#c2a42d10 #c2a42d40);
}
mod-entry .mod-icon {
flex: 0 0 auto;
width: 56dp;
height: 56dp;
border-radius: 8dp;
}
mod-entry icon.mod-icon {
font-size: 36dp;
background-color: rgba(17, 16, 10, 20%);
color: rgba(224, 219, 200, 45%);
decorator: text("&#xe87b;" center center);
}
mod-entry .mod-entry-info {
display: flex;
flex-flow: column;
flex: 1 1 0;
min-width: 0;
gap: 2dp;
}
mod-entry .mod-entry-name {
display: flex;
flex-flow: row;
align-items: baseline;
gap: 6dp;
}
mod-entry .mod-entry-name-text {
flex: 0 1 auto;
min-width: 0;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
}
mod-entry .mod-entry-version {
flex: 0 0 auto;
font-size: 13dp;
color: rgba(224, 219, 200, 50%);
}
mod-entry .mod-entry-status.active {
color: #44cc55;
}
mod-entry .mod-entry-status.failed {
color: #cc4444;
}
mod-entry .mod-entry-desc {
font-size: 14dp;
line-height: 1.3;
color: rgba(224, 219, 200, 65%);
max-height: 2.6em;
overflow: hidden;
}
mod-entry .mod-entry-sub {
font-size: 13dp;
color: rgba(224, 219, 200, 50%);
}
mod-entry.inactive .mod-icon {
filter: grayscale(1);
}
mod-entry.inactive .mod-entry-info {
opacity: 0.5;
}
mod-header {
position: relative;
flex: 0 0 auto;
}
mod-header.has-banner {
height: 180dp;
margin: -24dp -24dp 0dp -24dp;
}
mod-header .mod-actions {
position: absolute;
top: 24dp;
left: 24dp;
display: flex;
flex-flow: row;
gap: 8dp;
}
mod-header .mod-actions button {
font-size: 16dp;
padding: 6dp 14dp;
background-color: rgba(21, 22, 16, 80%);
box-shadow: rgba(146, 135, 91, 60%) 0 0 0 1dp;
}
mod-header.no-banner {
display: flex;
flex-flow: row;
align-items: center;
}
mod-header.no-banner .mod-actions {
position: static;
}
window.mods .mod-title {
display: block;
font-size: 28dp;
font-weight: bold;
}
window.mods .mod-title .mod-title-version {
font-weight: normal;
font-size: 16dp;
color: rgba(224, 219, 200, 55%);
}
window.mods .mod-author {
display: block;
font-size: 15dp;
color: rgba(224, 219, 200, 55%);
}
window.mods .mod-restart-note {
font-size: 15dp;
color: #ffa826;
opacity: 0.85;
}
window.mods .mod-description {
line-height: 1.5;
}
.status-badge {
font-size: 14dp;
opacity: 0.7;
}
.status-badge.active,
.mod-info-label.active {
color: #44cc55;
opacity: 1;
}
.status-badge.failed,
.mod-info-label.failed {
color: #cc4444;
opacity: 1;
}
+8
View File
@@ -159,6 +159,14 @@ toast.achievement heading {
color: #C2A42D;
}
toast.warning {
border: 1dp #C2A42D;
}
toast.warning heading {
color: #C2A42D;
}
toast.controller-warning {
top: auto;
right: auto;
+4
View File
@@ -362,6 +362,10 @@ body.animate-in .intro-item {
transition: opacity transform 0.3s 0.6s cubic-in-out;
}
.delay-6 {
transition: opacity transform 0.3s 0.7s cubic-in-out;
}
/* Mobile layout */
@media (max-height: 640dp) {
.gradient {
+12 -3
View File
@@ -50,7 +50,8 @@ tab-bar[closable] tab-end-spacer {
pointer-events: none;
}
tab-bar[closable] close {
tab-bar[closable] close,
window > close {
display: block;
position: fixed;
top: 8dp;
@@ -70,12 +71,20 @@ tab-bar[closable] close {
}
tab-bar[closable] close:hover,
tab-bar[closable] close:focus-visible {
tab-bar[closable] close:focus-visible,
window > close:hover,
window > close:focus-visible {
color: #fff;
background-color: rgba(194, 164, 45, 24%);
}
tab-bar[closable] close:active {
window > close {
top: 16dp;
right: 16dp;
}
tab-bar[closable] close:active,
window > close:active {
color: #fff;
background-color: rgba(194, 164, 45, 40%);
}
+5 -16
View File
@@ -1,11 +1,7 @@
#include "loader.hpp"
#include "dusk/logging.h"
#include "dusk/mods/log_buffer.hpp"
#include "dusk/mods/svc/registry.hpp"
#include "dusk/ui/ui.hpp"
#include "fmt/format.h"
#include <chrono>
namespace dusk::mods {
@@ -55,16 +51,9 @@ void fail_mod(LoadedMod& mod, ModResult code, std::string_view message) {
// callback), and full teardown happens via deactivate_mod at a safe point.
svc::remove_services_for_provider(mod);
mod.servicesRegistered = false;
ModLoader::instance().notify_mod_failure(mod);
DuskLog.error("[{}] disabled: {} ({})", mod.metadata.id, message, static_cast<int>(code));
if (firstFailure) {
ui::push_toast({
.type = "warning",
.title = "Mod Disabled",
.content = ui::escape(fmt::format("{}: {}", mod.metadata.name, message)),
.duration = std::chrono::seconds(5),
});
}
ModLoader::instance().notify_mod_failure(mod, firstFailure);
log::write(
mod.metadata.id, LOG_LEVEL_ERROR, "failed: {} ({})", message, static_cast<int>(code));
}
} // namespace dusk::mods::loader
} // namespace dusk::mods
+5 -8
View File
@@ -5,13 +5,11 @@
#include "dusk/logging.h"
#include "loader.hpp"
#include "native_module.hpp"
static aurora::Module Log("dusk::modLoader");
#include "native_module.hpp" // IWYU pragma: keep
namespace dusk::mods::loader {
namespace {
aurora::Module Log{"dusk::mods::loader"};
struct Edge {
size_t provider;
@@ -31,7 +29,7 @@ std::vector<Edge> collect_edges(const std::vector<std::unique_ptr<LoadedMod>>& m
for (size_t i = 0; i < mods.size(); ++i) {
const auto matches = [&](const ModManifestInfo::Export& serviceExport) {
return serviceExport.major == serviceImport.major &&
serviceExport.id == serviceImport.id;
serviceExport.id == serviceImport.id;
};
if (std::ranges::any_of(mods[i]->manifestInfo.exports, matches)) {
return i;
@@ -165,7 +163,7 @@ void sort_mods(std::vector<std::unique_ptr<LoadedMod>>& mods) {
}
for (const size_t index : cycleMods) {
fail_mod(*mods[index], MOD_CONFLICT,
"required service import cycle between mods: " + names);
"Required service import cycle between mods: " + names);
place(index);
}
continue;
@@ -174,8 +172,7 @@ void sort_mods(std::vector<std::unique_ptr<LoadedMod>>& mods) {
// Only optional imports left in the cycle: drop one edge and retry. The
// import still resolves, but without any initialization-order guarantee.
const auto optionalEdge = std::ranges::find_if(edges, [&](const Edge& edge) {
return edge.alive && !edge.required && !placed[edge.provider] &&
!placed[edge.importer];
return edge.alive && !edge.required && !placed[edge.provider] && !placed[edge.importer];
});
if (optionalEdge == edges.end()) {
// Unreachable: a stall with no required cycle implies an optional edge.
+105 -58
View File
@@ -3,24 +3,26 @@
#include "dusk/mod_loader.hpp"
#include <algorithm>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <string>
#include <string_view>
#include <utility>
#include "../manifest.hpp"
#include "depgraph.hpp"
#include "dusk/config.hpp"
#include "dusk/io.hpp"
#include "dusk/mods/log_buffer.hpp"
#include "dusk/mods/svc/config.hpp"
#include "dusk/mods/svc/registry.hpp"
#include "manifest.hpp"
#include "dusk/ui/mods_window.hpp"
#include "dusk/ui/ui.hpp"
#include "miniz.h"
#include "native_module.hpp"
#include "nlohmann/json.hpp"
static aurora::Module Log("dusk::modLoader");
using namespace std::string_literals;
using namespace std::string_view_literals;
@@ -71,6 +73,7 @@ static constexpr std::string_view k_nativeLibName = ""sv;
namespace dusk::mods {
namespace {
aurora::Module Log{"dusk::mods::loader"};
ModLoader g_modLoader;
std::unique_ptr<ModBundle> load_bundle(const std::filesystem::path& modPath, bool fromDir) {
@@ -163,9 +166,11 @@ static std::string resolve_image_path(ModBundle& bundle, const std::string& modI
std::string_view key, const std::string& manifestPath, const std::string& defaultPath) {
if (!manifestPath.empty()) {
if (!is_safe_resource_path(manifestPath)) {
Log.warn("{}: invalid {} path '{}' in mod.json", modId, key, manifestPath);
log::write(
modId, LOG_LEVEL_WARN, "invalid {} path '{}' in mod.json", key, manifestPath);
} else if (!bundle_has_file(bundle, manifestPath)) {
Log.warn("{}: {} path '{}' not found in bundle", modId, key, manifestPath);
log::write(
modId, LOG_LEVEL_WARN, "{} path '{}' not found in bundle", key, manifestPath);
} else {
return manifestPath;
}
@@ -217,18 +222,18 @@ static ModMetadata load_metadata(const std::filesystem::path& modPath, ModBundle
static bool validate_manifest(const ModManifest* manifest, LoadedMod& mod) {
if (manifest == nullptr) {
Log.error("{} returned a null mod manifest", mod.metadata.id);
log::write(mod.metadata.id, LOG_LEVEL_ERROR, "returned a null mod manifest");
mod.nativeStatus = NativeModStatus::MissingExport;
return false;
}
if (manifest->struct_size != sizeof(ModManifest)) {
Log.error("{} manifest has invalid size {} (expected {})", mod.metadata.id,
log::write(mod.metadata.id, LOG_LEVEL_ERROR, "manifest has invalid size {} (expected {})",
manifest->struct_size, sizeof(ModManifest));
mod.nativeStatus = NativeModStatus::ApiVersionMismatch;
return false;
}
if (manifest->abi_version != MOD_ABI_VERSION) {
Log.error("{} expects ABI v{} but engine is v{}, skipping", mod.metadata.id,
log::write(mod.metadata.id, LOG_LEVEL_ERROR, "expects ABI v{} but engine is v{}, skipping",
manifest->abi_version, MOD_ABI_VERSION);
mod.nativeStatus = NativeModStatus::ApiVersionMismatch;
return false;
@@ -236,7 +241,7 @@ static bool validate_manifest(const ModManifest* manifest, LoadedMod& mod) {
if ((manifest->import_count > 0 && manifest->imports == nullptr) ||
(manifest->export_count > 0 && manifest->exports == nullptr))
{
Log.error("{} manifest has invalid import/export arrays", mod.metadata.id);
log::write(mod.metadata.id, LOG_LEVEL_ERROR, "manifest has invalid import/export arrays");
mod.nativeStatus = NativeModStatus::MissingExport;
return false;
}
@@ -245,7 +250,7 @@ static bool validate_manifest(const ModManifest* manifest, LoadedMod& mod) {
static bool validate_context_symbol(ModContext** contextSymbol, LoadedMod& mod) {
if (contextSymbol == nullptr) {
Log.error("{} missing required mod_ctx export", mod.metadata.id);
log::write(mod.metadata.id, LOG_LEVEL_ERROR, "missing required mod_ctx export");
mod.nativeStatus = NativeModStatus::MissingExport;
return false;
}
@@ -289,8 +294,8 @@ std::filesystem::path ModLoader::external_native_lib_path(const LoadedMod& mod)
if (libDir.empty()) {
return {};
}
fs::path path = libDir / fs::path(mod.metadata.id + io::fs_path_to_string(
fs::path(k_nativeLibName).extension()));
fs::path path = libDir / fs::path(mod.metadata.id +
io::fs_path_to_string(fs::path(k_nativeLibName).extension()));
std::error_code ec;
if (!fs::is_regular_file(path, ec)) {
return {};
@@ -300,7 +305,7 @@ std::filesystem::path ModLoader::external_native_lib_path(const LoadedMod& mod)
void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) {
if (!EnableCodeMods) {
Log.error("Code mods are not available in this build");
log::write(mod.metadata.id, LOG_LEVEL_ERROR, "Code mods are not available in this build");
mod.nativeStatus = NativeModStatus::BuildDisabled;
return;
}
@@ -318,15 +323,15 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) {
} else if (auto external = external_native_lib_path(mod); !external.empty()) {
libPath = std::move(external);
} else {
Log.error("no native library named {} found in {}; skipping", k_nativeLibName,
mod.metadata.id);
log::write(mod.metadata.id, LOG_LEVEL_ERROR,
"no native library named {} found; skipping", k_nativeLibName);
mod.nativeStatus = NativeModStatus::ModMissingPlatform;
return;
}
} else {
if (dllEntry.empty()) {
Log.error("no native library named {} found in {}; skipping", k_nativeLibName,
mod.metadata.id);
log::write(mod.metadata.id, LOG_LEVEL_ERROR,
"no native library named {} found; skipping", k_nativeLibName);
mod.nativeStatus = NativeModStatus::ModMissingPlatform;
return;
}
@@ -342,14 +347,15 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) {
try {
dllData = mod.bundle->readFile(dllEntry);
} catch (const std::exception& e) {
Log.error("failed to extract {} from {}", dllEntry, mod.metadata.id);
log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to extract {}", dllEntry);
return;
}
{
std::ofstream out(dllCachePath, std::ios::binary | std::ios::out);
if (!out) {
Log.error("failed to write {}", io::fs_path_to_string(dllCachePath));
log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to write {}",
io::fs_path_to_string(dllCachePath));
return;
}
@@ -364,7 +370,8 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) {
try {
nativeMod->handle = std::make_unique<loader::NativeModule>(libPath);
} catch (const std::runtime_error& e) {
Log.error("failed to open {}: {}", io::fs_path_to_string(libPath), e.what());
log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to open {}: {}",
io::fs_path_to_string(libPath), e.what());
return;
}
@@ -377,7 +384,8 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) {
if (!getManifest || !nativeMod->contextSymbol || !nativeMod->fn_initialize ||
!nativeMod->fn_update || !nativeMod->fn_shutdown)
{
Log.error("{} missing required mod API exports; skipping",
log::write(mod.metadata.id, LOG_LEVEL_ERROR,
"{} missing required mod API exports; skipping",
io::fs_path_to_string(libPath.filename()));
mod.nativeStatus = NativeModStatus::MissingExport;
return;
@@ -486,9 +494,9 @@ static void warn_unpublished_deferred_exports(const LoadedMod& mod) {
const auto* record =
svc::find_service_record(serviceExport.service_id, serviceExport.major_version);
if (record != nullptr && record->service == nullptr) {
Log.warn("'{}' declared deferred service '{}@{}' but never published it during "
"initialization",
mod.metadata.id, serviceExport.service_id, serviceExport.major_version);
log::write(mod.metadata.id, LOG_LEVEL_WARN,
"declared deferred service '{}@{}' but never published it during initialization",
serviceExport.service_id, serviceExport.major_version);
}
}
}
@@ -516,10 +524,10 @@ void ModLoader::try_load_mod(
if (const auto* existing = find_mod(metadata.id)) {
if (existing->searchDirIndex < searchDirIndex) {
Log.info("'{}' ({}) shadowed by higher-priority copy {}", metadata.id,
log::write(metadata.id, LOG_LEVEL_INFO, "{} shadowed by higher-priority copy {}",
io::fs_path_to_string(modPath.filename()), existing->modPath);
} else {
Log.error("mod with id '{}' already exists, not loading {}", metadata.id,
log::write(metadata.id, LOG_LEVEL_ERROR, "duplicate mod id, not loading {}",
io::fs_path_to_string(modPath.filename()));
}
return;
@@ -537,7 +545,7 @@ void ModLoader::try_load_mod(
mod.context = std::make_unique<ModContext>();
mod.context->mod = &mod;
mod.cvarIsEnabled =
std::make_unique<ConfigVar<bool> >(mod_enabled_cvar_name(mod.metadata.id), true);
std::make_unique<ConfigVar<bool>>(mod_enabled_cvar_name(mod.metadata.id), true);
const auto [dllEntry, anyLibs] = locate_dll_in_bundle(*mod.bundle);
if (anyLibs || (mod.inPlace && !external_native_lib_path(mod).empty())) {
@@ -545,19 +553,18 @@ void ModLoader::try_load_mod(
load_native(mod, dllEntry);
if (mod.nativeStatus != NativeModStatus::Loaded) {
Log.error("Native mod '{}' failed to load, disabling", mod.metadata.id);
mod.active = false;
mod.loadFailed = true;
mod.failureReason = native_status_message(mod.nativeStatus);
fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus));
} else {
mod.manifestInfo = build_manifest_info(*mod.native->manifest);
}
}
Log.info("found '{}' ('{}') v{} by {} ({})", mod.metadata.name, mod.metadata.id,
log::write(mod.metadata.id, LOG_LEVEL_INFO, "found '{}' v{} by {} ({})", mod.metadata.name,
mod.metadata.version, mod.metadata.author, io::fs_path_to_string(modPath.filename()));
}
bool ModLoader::activate_mod(LoadedMod& mod) {
log::write(mod.metadata.id, LOG_LEVEL_INFO, "activating mod");
mod.active = true;
// Asset-only mods have no lifecycle beyond their overlay files.
@@ -568,33 +575,33 @@ bool ModLoader::activate_mod(LoadedMod& mod) {
if (!mod.servicesRegistered) {
if (!register_static_service_exports(mod)) {
Log.error("'{}' failed to register service exports", mod.metadata.id);
log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to register service exports");
return false;
}
mod.servicesRegistered = true;
}
if (!resolve_service_imports(mod)) {
Log.error("'{}' failed to resolve service imports", mod.metadata.id);
log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to resolve service imports");
return false;
}
*mod.native->contextSymbol = mod.context.get();
Log.debug("Initializing '{}'", mod.metadata.id);
log::write(mod.metadata.id, LOG_LEVEL_TRACE, "calling mod_initialize");
try {
ModError error = MOD_ERROR_INIT;
const auto result = mod.native->fn_initialize(&error);
if (result == MOD_OK && !mod.loadFailed) {
mod.initialized = true;
Log.info("'{}' initialized", mod.metadata.id);
log::write(mod.metadata.id, LOG_LEVEL_TRACE, "mod_initialize succeeded");
} else {
fail_mod(mod, result, lifecycle_error_message("mod_initialize", result, error));
}
} catch (const std::exception& e) {
fail_mod(mod, MOD_ERROR, fmt::format("exception in mod_initialize: {}", e.what()));
fail_mod(mod, MOD_ERROR, fmt::format("Exception in mod_initialize: {}", e.what()));
} catch (...) {
fail_mod(mod, MOD_ERROR, "unknown exception in mod_initialize");
fail_mod(mod, MOD_ERROR, "Unknown exception in mod_initialize");
}
warn_unpublished_deferred_exports(mod);
@@ -611,11 +618,14 @@ bool ModLoader::activate_mod(LoadedMod& mod) {
void ModLoader::deactivate_mod(LoadedMod& mod) {
if (mod.initialized && mod.native && mod.native->fn_shutdown) {
log::write(mod.metadata.id, LOG_LEVEL_TRACE, "calling mod_shutdown");
try {
ModError error = MOD_ERROR_INIT;
const auto result = mod.native->fn_shutdown(&error);
if (result != MOD_OK) {
Log.error("mod_shutdown failed for '{}': {}", mod.metadata.id,
if (result == MOD_OK) {
log::write(mod.metadata.id, LOG_LEVEL_TRACE, "mod_shutdown succeeded");
} else {
log::write(mod.metadata.id, LOG_LEVEL_ERROR, "mod_shutdown failed: {}",
lifecycle_error_message("mod_shutdown", result, error));
}
} catch (...) {
@@ -719,7 +729,7 @@ void ModLoader::init() {
if (register_static_service_exports(mod)) {
mod.servicesRegistered = true;
} else {
Log.error("'{}' failed to register service exports", mod.metadata.id);
log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to register service exports");
}
}
@@ -730,7 +740,7 @@ void ModLoader::init() {
// Config-disabled mods keep their dependency edges but must not resolve until enabled.
for (auto& mod : mods()) {
if (!mod.cvarIsEnabled->getValue()) {
Log.info("Mod '{}' is disabled by config", mod.metadata.id);
log::write(mod.metadata.id, LOG_LEVEL_INFO, "disabled by config");
mod.active = false;
if (mod.servicesRegistered) {
svc::remove_services_for_provider(mod);
@@ -753,7 +763,7 @@ void ModLoader::init() {
m_startupComplete = true;
}
LoadedMod* ModLoader::find_mod(std::string_view id) {
LoadedMod* ModLoader::find_mod(std::string_view id) const {
for (auto& mod : mods()) {
if (mod.metadata.id == id) {
return &mod;
@@ -778,23 +788,55 @@ void ModLoader::request_reload(std::string_view id) {
m_pendingRequests.push_back({std::string{id}, RequestKind::Reload});
}
void ModLoader::notify_mod_failure(LoadedMod& mod) {
// Startup failures are handled inline by activate_mod; the queue only exists for failures
// raised while mod code may be on the stack (mod_update, hook callbacks, UI callbacks).
void ModLoader::notify_mod_failure(LoadedMod& mod, bool firstFailure) {
if (firstFailure) {
m_pendingFailures.push_back(mod.metadata.name);
}
// Startup failures are handled inline by activate_mod
if (!m_startupComplete) {
return;
}
m_pendingRequests.push_back({mod.metadata.id, RequestKind::Disable});
}
void ModLoader::flush_toasts() {
if (m_pendingFailures.empty()) {
return;
}
const auto names = std::exchange(m_pendingFailures, {});
// Skip displaying toasts if the mods window is currently open
if (const auto* window = dynamic_cast<const ui::ModsWindow*>(ui::top_document())) {
if (window->visible()) {
return;
}
}
ui::Toast toast{.type = "warning", .duration = std::chrono::seconds{5}};
if (names.size() == 1) {
toast.title = "Mod failed";
toast.content =
fmt::format("<div><b>{}</b> failed and was disabled.</div><div>Check Mods for "
"more information.</div>",
ui::escape(names.front()));
} else {
toast.title = "Mods failed";
toast.content = fmt::format("<div><b>{} mods</b> failed and were disabled.</div><div>Check "
"Mods for more information.</div>",
names.size());
}
ui::push_toast(std::move(toast));
}
static bool requiredDepsActive(const LoadedMod& mod) {
return std::ranges::all_of(mod.dependencies,
[](const ModDependencyEdge& edge) { return !edge.required || edge.mod->active; });
}
std::vector<LoadedMod*> ModLoader::collect_lifecycle_set(LoadedMod& target) {
std::vector<LoadedMod*> included{&target};
std::vector<LoadedMod*> pending{&target};
std::vector included{&target};
std::vector pending{&target};
while (!pending.empty()) {
auto* current = pending.back();
pending.pop_back();
@@ -843,7 +885,7 @@ bool ModLoader::ensure_native_loaded(LoadedMod& mod) {
bool ModLoader::reload_bundle(LoadedMod& mod) {
namespace fs = std::filesystem;
Log.info("reloading '{}' from {}", mod.metadata.id, mod.modPath);
log::write(mod.metadata.id, LOG_LEVEL_INFO, "reloading from {}", mod.modPath);
std::shared_ptr<ModBundle> newBundle;
ModMetadata newMetadata;
@@ -852,13 +894,13 @@ bool ModLoader::reload_bundle(LoadedMod& mod) {
newBundle = load_bundle(mod.modPath, fs::is_directory(mod.modPath, ec));
newMetadata = load_metadata(mod.modPath, *newBundle);
} catch (const std::exception& e) {
fail_mod(mod, MOD_ERROR, fmt::format("reload failed: {}", e.what()));
fail_mod(mod, MOD_ERROR, fmt::format("Reload failed: {}", e.what()));
return false;
}
if (newMetadata.id != mod.metadata.id) {
fail_mod(mod, MOD_CONFLICT,
fmt::format("mod id changed on reload ('{}'); restart the game", newMetadata.id));
fmt::format("Mod ID changed on reload ('{}'); restart required", newMetadata.id));
return false;
}
@@ -885,8 +927,8 @@ bool ModLoader::reload_bundle(LoadedMod& mod) {
if (newInfo != mod.manifestInfo) {
// The reload changes the mod's imports/exports; rebuild the dependency graph so edges,
// init/tick/shutdown order and cascade sets reflect the new manifest.
Log.info("'{}' changed its service imports/exports; rebuilding mod dependency graph",
mod.metadata.id);
log::write(mod.metadata.id, LOG_LEVEL_INFO,
"changed its service imports/exports; rebuilding mod dependency graph");
mod.manifestInfo = std::move(newInfo);
loader::sort_mods(m_mods);
}
@@ -906,7 +948,7 @@ void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) {
continue;
}
const bool wasActive = mod->active;
Log.info("deactivating '{}'", mod->metadata.id);
log::write(mod->metadata.id, LOG_LEVEL_INFO, "deactivating mod");
deactivate_mod(*mod);
if (mod != &target && wasActive) {
// Provisional; cleared below if the mod comes straight back up.
@@ -953,7 +995,8 @@ void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) {
}
if (!requiredDepsActive(*mod)) {
mod->suspendedByProvider = true;
Log.info("'{}' suspended: a required provider is disabled", mod->metadata.id);
log::write(
mod->metadata.id, LOG_LEVEL_INFO, "suspended: a required provider is disabled");
continue;
}
mod->suspendedByProvider = false;
@@ -972,6 +1015,10 @@ void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) {
void ModLoader::on_enabled_changed(LoadedMod& mod) {
svc::config_mark_dirty();
if (mod.loadFailed) {
if (!mod.cvarIsEnabled->getValue()) {
mod.loadFailed = false;
mod.failureReason.clear();
}
return;
}
if (mod.suspendedByProvider) {
@@ -1013,7 +1060,7 @@ void ModLoader::apply_pending_requests() {
continue;
}
if (request.kind == RequestKind::Reload && mod->inPlace) {
Log.warn("'{}' is a built-in mod and can't be reloaded", mod->metadata.id);
log::write(mod->metadata.id, LOG_LEVEL_WARN, "is a built-in mod and can't be reloaded");
continue;
}
if (request.kind == RequestKind::Enable && mod->enabledApplied) {
@@ -1046,13 +1093,14 @@ void ModLoader::tick() {
fail_mod(mod, result, lifecycle_error_message("mod_update", result, error));
}
} catch (const std::exception& e) {
fail_mod(mod, MOD_ERROR, fmt::format("exception in mod_update: {}", e.what()));
fail_mod(mod, MOD_ERROR, fmt::format("Exception in mod_update: {}", e.what()));
} catch (...) {
fail_mod(mod, MOD_ERROR, "unknown exception in mod_update");
fail_mod(mod, MOD_ERROR, "Unknown exception in mod_update");
}
}
svc::modules_frame_end();
flush_toasts();
}
void ModLoader::shutdown() {
@@ -1069,7 +1117,6 @@ void ModLoader::shutdown() {
m_mods.clear();
drain_retired_natives();
svc::modules_shutdown();
clear_services();
Log.info("all mods unloaded");
}
+103
View File
@@ -0,0 +1,103 @@
#include "log_buffer.hpp"
#include <chrono>
#include <mutex>
#include "dusk/logging.h"
namespace dusk::mods::log {
namespace {
struct BufferState {
std::mutex mutex;
std::vector<Line> ring;
size_t head = 0;
size_t count = 0;
uint64_t nextSeq = 0;
std::vector<std::string> modIds;
uint16_t intern(std::string_view modId) {
for (size_t i = 0; i < modIds.size(); ++i) {
if (modIds[i] == modId) {
return static_cast<uint16_t>(i);
}
}
modIds.emplace_back(modId);
return static_cast<uint16_t>(modIds.size() - 1);
}
};
BufferState g_buffer;
} // namespace
void emit(Source source, const std::string& modId, LogLevel level, const std::string& message) {
const auto timeMs = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
{
std::lock_guard lock{g_buffer.mutex};
if (g_buffer.ring.empty()) {
g_buffer.ring.resize(k_capacity);
}
auto& slot = g_buffer.ring[(g_buffer.head + g_buffer.count) % k_capacity];
if (g_buffer.count == k_capacity) {
g_buffer.head = (g_buffer.head + 1) % k_capacity;
} else {
++g_buffer.count;
}
slot.seq = g_buffer.nextSeq++;
slot.timeMs = timeMs;
slot.level = level;
slot.modIndex = g_buffer.intern(modId);
slot.source = source;
// assign() reuses the slot's capacity once the ring has wrapped
slot.message.assign(message);
}
AuroraLogLevel auroraLevel = LOG_INFO;
switch (level) {
case LOG_LEVEL_TRACE:
case LOG_LEVEL_DEBUG:
auroraLevel = LOG_DEBUG;
break;
case LOG_LEVEL_INFO:
auroraLevel = LOG_INFO;
break;
case LOG_LEVEL_WARN:
auroraLevel = LOG_WARNING;
break;
case LOG_LEVEL_ERROR:
auroraLevel = LOG_ERROR;
break;
}
if (aurora::g_config.logLevel <= auroraLevel) {
aurora::log_internal(auroraLevel, modId.c_str(), message.c_str(),
static_cast<unsigned int>(message.length()));
}
}
Range copy_since(uint64_t sinceSeq, std::vector<Line>& out) {
std::lock_guard lock{g_buffer.mutex};
const Range range{
.firstSeq = g_buffer.nextSeq - g_buffer.count,
.nextSeq = g_buffer.nextSeq,
};
for (uint64_t seq = std::max(sinceSeq, range.firstSeq); seq < range.nextSeq; ++seq) {
out.push_back(g_buffer.ring[(g_buffer.head + (seq - range.firstSeq)) % k_capacity]);
}
return range;
}
std::vector<std::string> ids() {
std::lock_guard lock{g_buffer.mutex};
return g_buffer.modIds;
}
void clear() {
std::lock_guard lock{g_buffer.mutex};
g_buffer.head = 0;
g_buffer.count = 0;
}
} // namespace dusk::mods::log
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
#include <fmt/format.h>
#include "mods/svc/log.h"
namespace dusk::mods::log {
constexpr size_t k_capacity = 2048;
enum class Source : uint8_t {
Loader,
Mod,
};
struct Line {
uint64_t seq = 0; // monotonic, never reset (survives clear)
int64_t timeMs = 0;
LogLevel level = LOG_LEVEL_INFO;
uint16_t modIndex = 0; // index into ids()
Source source = Source::Loader;
std::string message;
};
struct Range {
uint64_t firstSeq = 0; // oldest retained entry
uint64_t nextSeq = 0; // one past the newest entry
};
// Appends to the mod log buffer and emits through aurora logging with the mod ID as the
// module tag. Console/file output honors the configured log level; the buffer captures
// every level. Thread-safe; mods may log from worker threads.
void emit(Source source, const std::string& modId, LogLevel level, const std::string& message);
// Appends entries with seq >= sinceSeq to `out` and returns the retained range,
// so callers diffing by seq can detect both new entries and truncation.
Range copy_since(uint64_t sinceSeq, std::vector<Line>& out);
// Append-only intern table of mod ids; indices are stable for the session.
std::vector<std::string> ids();
void clear();
// Formatting helper for loader messages.
template <typename... T>
void write(const std::string& modId, LogLevel level, fmt::format_string<T...> format, T&&... args) {
emit(Source::Loader, modId, level, fmt::format(format, std::forward<T>(args)...));
}
} // namespace dusk::mods::log
+2 -2
View File
@@ -385,9 +385,9 @@ ModResult config_subscribe(ModContext* context, ConfigVarHandle var, ConfigChang
callback(modPtr->context.get(), varHandle, &currentValue, &previousValue, userData);
} catch (const std::exception& e) {
fail_mod(*modPtr, MOD_ERROR,
fmt::format("exception in config change callback: {}", e.what()));
fmt::format("Exception in config change callback: {}", e.what()));
} catch (...) {
fail_mod(*modPtr, MOD_ERROR, "unknown exception in config change callback");
fail_mod(*modPtr, MOD_ERROR, "Unknown exception in config change callback");
}
});
if (outHandle != nullptr) {
+1 -1
View File
@@ -1,7 +1,7 @@
#include "registry.hpp"
#include "dusk/mods/loader/loader.hpp"
#include "dusk/mods/loader/manifest.hpp"
#include "dusk/mods/manifest.hpp"
#include "mods/svc/hook.h"
#if DUSK_CODE_MODS
+4 -4
View File
@@ -1,7 +1,7 @@
#include "registry.hpp"
#include "dusk/mods/loader/loader.hpp"
#include "dusk/mods/loader/manifest.hpp"
#include "dusk/mods/manifest.hpp"
#include "fmt/format.h"
#include <algorithm>
@@ -38,7 +38,7 @@ ModResult host_publish_service(
void host_fail(ModContext* context, const ModResult code, const char* message) {
auto* mod = mod_from_context(context);
if (mod != nullptr) {
fail_mod(*mod, code, message != nullptr ? message : "mod reported failure");
fail_mod(*mod, code, message != nullptr ? message : "Mod reported an unknown failure");
}
}
@@ -112,9 +112,9 @@ void host_mod_detached(LoadedMod& mod) {
MOD_LIFECYCLE_DETACHED, watcher.userData);
} catch (const std::exception& e) {
fail_mod(*watcher.owner, MOD_ERROR,
fmt::format("exception in mod lifecycle callback: {}", e.what()));
fmt::format("Exception in mod lifecycle callback: {}", e.what()));
} catch (...) {
fail_mod(*watcher.owner, MOD_ERROR, "unknown exception in mod lifecycle callback");
fail_mod(*watcher.owner, MOD_ERROR, "Unknown exception in mod lifecycle callback");
}
}
}
+2 -16
View File
@@ -1,28 +1,14 @@
#include "registry.hpp"
#include "dusk/logging.h"
#include "dusk/mods/loader/loader.hpp"
#include "dusk/mods/log_buffer.hpp"
namespace dusk::mods::svc {
namespace {
void log_write(ModContext* context, const LogLevel level, const char* message) {
const char* text = message != nullptr ? message : "";
switch (level) {
case LOG_LEVEL_TRACE:
case LOG_LEVEL_DEBUG:
DuskLog.debug("[{}] {}", mod_id_from_context(context), text);
break;
case LOG_LEVEL_INFO:
DuskLog.info("[{}] {}", mod_id_from_context(context), text);
break;
case LOG_LEVEL_WARN:
DuskLog.warn("[{}] {}", mod_id_from_context(context), text);
break;
case LOG_LEVEL_ERROR:
DuskLog.error("[{}] {}", mod_id_from_context(context), text);
break;
}
log::emit(log::Source::Mod, mod_id_from_context(context), level, text);
}
void log_trace(ModContext* context, const char* message) {
+16 -22
View File
@@ -1,5 +1,6 @@
#include "registry.hpp"
#include "dusk/app_info.hpp"
#include "dusk/logging.h"
#include "dusk/mods/loader/loader.hpp"
@@ -22,7 +23,7 @@ std::string service_key(std::string_view id, const uint16_t majorVersion) {
}
const char* mod_id(const LoadedMod* mod) {
return mod != nullptr ? mod->metadata.id.c_str() : "dusklight";
return mod != nullptr ? mod->metadata.id.c_str() : AppName;
}
bool validate_service_header(const ServiceHeader* header, const char* serviceId,
@@ -45,6 +46,11 @@ bool validate_service_header(const ServiceHeader* header, const char* serviceId,
return true;
}
void clear_services() {
s_services.clear();
s_modules.clear();
}
} // namespace
bool valid_service_id(const char* serviceId) {
@@ -131,11 +137,6 @@ const ServiceRecord* find_service_record(const char* serviceId, const uint16_t m
return it != s_services.end() ? &it->second : nullptr;
}
void clear_services() {
s_services.clear();
s_modules.clear();
}
ModResult register_module(const ServiceModule& module) {
const auto result = register_service(
module.id, module.majorVersion, module.minorVersion, module.service, nullptr, false);
@@ -187,6 +188,7 @@ void modules_shutdown() {
module->shutdown();
}
}
clear_services();
}
} // namespace dusk::mods::svc
@@ -222,13 +224,13 @@ bool ModLoader::register_static_service_exports(LoadedMod& mod) {
if (serviceExport.struct_size != sizeof(ServiceExport) ||
!svc::valid_service_id(serviceExport.service_id))
{
fail_mod(mod, MOD_INVALID_ARGUMENT, "invalid service export descriptor");
fail_mod(mod, MOD_INVALID_ARGUMENT, "Invalid service export descriptor");
return false;
}
const bool deferred = (serviceExport.flags & SERVICE_EXPORT_DEFERRED) != 0;
if (!deferred && serviceExport.service == nullptr) {
fail_mod(mod, MOD_INVALID_ARGUMENT, "static service export has null service pointer");
fail_mod(mod, MOD_INVALID_ARGUMENT, "Static service export has null service pointer");
return false;
}
@@ -236,7 +238,7 @@ bool ModLoader::register_static_service_exports(LoadedMod& mod) {
svc::register_service(serviceExport.service_id, serviceExport.major_version,
serviceExport.minor_version, serviceExport.service, &mod, deferred);
if (result != MOD_OK) {
fail_mod(mod, result, "service export registration failed");
fail_mod(mod, result, "Service export registration failed");
return false;
}
}
@@ -248,10 +250,10 @@ std::string ModLoader::describe_missing_import(
const char* serviceId, const uint16_t majorVersion, const uint16_t minMinorVersion) const {
if (const auto* record = svc::find_service_record(serviceId, majorVersion)) {
if (record->service == nullptr) {
return fmt::format("required service {}@{} was never published by provider '{}'",
return fmt::format("Required service {}@{} was never published by provider '{}'",
serviceId, majorVersion, svc::mod_id(record->provider));
}
return fmt::format("required service {}@{} only provides minor version {} (need >= {})",
return fmt::format("Required service {}@{} only provides minor version {} (need >= {})",
serviceId, majorVersion, record->minorVersion, minMinorVersion);
}
@@ -270,14 +272,14 @@ std::string ModLoader::describe_missing_import(
std::string_view{serviceExport.service_id} == serviceId &&
serviceExport.major_version == majorVersion)
{
return fmt::format("required service {}@{} unavailable: provider '{}' {}",
return fmt::format("Required service {}@{} unavailable: provider '{}' {}",
serviceId, majorVersion, other.metadata.id,
other.loadFailed ? "failed to load" : "is disabled");
}
}
}
return fmt::format("required service unavailable: {}@{}", serviceId, majorVersion);
return fmt::format("Required service unavailable: {}@{}", serviceId, majorVersion);
}
bool ModLoader::resolve_service_imports(LoadedMod& mod) {
@@ -291,7 +293,7 @@ bool ModLoader::resolve_service_imports(LoadedMod& mod) {
if (serviceImport.struct_size != sizeof(ServiceImport) ||
!svc::valid_service_id(serviceImport.service_id) || serviceImport.slot == nullptr)
{
fail_mod(mod, MOD_INVALID_ARGUMENT, "invalid service import descriptor");
fail_mod(mod, MOD_INVALID_ARGUMENT, "Invalid service import descriptor");
return false;
}
@@ -315,12 +317,4 @@ bool ModLoader::resolve_service_imports(LoadedMod& mod) {
return true;
}
void ModLoader::clear_services() {
svc::clear_services();
}
void ModLoader::fail_mod(LoadedMod& mod, const ModResult code, std::string_view message) {
mods::fail_mod(mod, code, message);
}
} // namespace dusk::mods
-1
View File
@@ -53,7 +53,6 @@ const ServiceRecord* find_service(
const char* serviceId, uint16_t majorVersion, uint16_t minMinorVersion);
// Unlike find_service, also returns deferred records that have not been published yet.
const ServiceRecord* find_service_record(const char* serviceId, uint16_t majorVersion);
void clear_services();
ModResult register_module(const ServiceModule& module);
void modules_mod_detached(LoadedMod& mod);
+301
View File
@@ -0,0 +1,301 @@
#include "logs_window.hpp"
#include <array>
#include <ctime>
#include <SDL3/SDL_timer.h>
#include <fmt/format.h>
#include "pane.hpp"
namespace dusk::ui {
namespace {
const char* level_name(LogLevel level) {
switch (level) {
case LOG_LEVEL_TRACE:
return "Trace";
case LOG_LEVEL_DEBUG:
return "Debug";
case LOG_LEVEL_INFO:
return "Info";
case LOG_LEVEL_WARN:
return "Warn";
case LOG_LEVEL_ERROR:
return "Error";
}
return "?";
}
const char* level_logger_name(LogLevel level) {
switch (level) {
case LOG_LEVEL_TRACE:
return "TRACE";
case LOG_LEVEL_DEBUG:
return "DEBUG";
case LOG_LEVEL_INFO:
return "INFO";
case LOG_LEVEL_WARN:
return "WARNING";
case LOG_LEVEL_ERROR:
return "ERROR";
}
return "?";
}
const char* level_class(LogLevel level) {
switch (level) {
case LOG_LEVEL_TRACE:
return "lvl-trace";
case LOG_LEVEL_DEBUG:
return "lvl-debug";
case LOG_LEVEL_INFO:
return "lvl-info";
case LOG_LEVEL_WARN:
return "lvl-warn";
case LOG_LEVEL_ERROR:
return "lvl-error";
}
return "lvl-info";
}
std::string format_time(int64_t timeMs) {
const auto seconds = static_cast<std::time_t>(timeMs / 1000);
std::tm localTime{};
#if _WIN32
localtime_s(&localTime, &seconds);
#else
localtime_r(&seconds, &localTime);
#endif
std::array<char, 16> buffer{};
std::strftime(buffer.data(), buffer.size(), "%H:%M:%S", &localTime);
return fmt::format("{}.{:03}", buffer.data(), timeMs % 1000);
}
void append_text(Rml::ElementDocument* doc, Rml::Element* parent, const Rml::String& text) {
parent->AppendChild(doc->CreateTextNode(text));
}
Rml::Element* append_span(Rml::ElementDocument* doc, Rml::Element* parent, const char* className,
const Rml::String& text) {
auto span = doc->CreateElement("span");
span->SetClass(className, true);
append_text(doc, span.get(), text);
return parent->AppendChild(std::move(span));
}
} // namespace
LogsWindow::LogsWindow(std::string modFilter)
: Window{Props{.tabBar = false, .styleSheets = {"res/rml/logs.rcss"}}},
mModFilter{std::move(modFilter)} {
mRoot->SetClass("logs", true);
set_content([this](Rml::Element* content) { build_content(content); });
}
void LogsWindow::build_content(Rml::Element* content) {
auto* toolbar = append(content, "div");
toolbar->SetClass("log-toolbar", true);
auto* title = append(toolbar, "div");
title->SetClass("log-title", true);
title->SetInnerRML("Logs");
auto* modLabel = append(toolbar, "div");
modLabel->SetClass("log-title-mod", true);
modLabel->SetInnerRML(mModFilter.empty() ? "All mods" : fmt::format("{}", escape(mModFilter)));
append(toolbar, "div")->SetClass("log-toolbar-spacer", true);
for (const LogLevel level :
{LOG_LEVEL_TRACE, LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_WARN, LOG_LEVEL_ERROR})
{
add_child<ControlledButton>(toolbar,
ControlledButton::Props{
.text = level_name(level),
.isSelected = [this, level] { return mMinLevel <= level; },
})
.on_pressed([this, level] {
mMinLevel = level;
rebuild_lines();
});
}
append(toolbar, "div")->SetClass("log-toolbar-spacer", true);
add_child<Button>(toolbar, "Copy").on_pressed([this] { copy_to_clipboard(); });
add_child<Button>(toolbar, "Clear").on_pressed([this] {
mods::log::clear();
rebuild_lines();
});
auto& pane = add_child<Pane>(content, Pane::Type::Uncontrolled);
pane.root()->SetClass("log-view", true);
mScrollElem = pane.root();
mLinesElem = append(pane.root(), "div");
mLinesElem->SetClass("log-lines", true);
pane.finalize();
listen(mScrollElem, Rml::EventId::Scroll, [this](Rml::Event&) {
const float bottom = mScrollElem->GetScrollHeight() - mScrollElem->GetClientHeight();
mStickToBottom = mScrollElem->GetScrollTop() >= bottom - 4.0f;
});
rebuild_lines();
}
void LogsWindow::update() {
Window::update();
if (mLinesElem == nullptr) {
return;
}
const Uint64 perfFreq = SDL_GetPerformanceFrequency();
const Uint64 now = SDL_GetPerformanceCounter();
// Limit refreshes to ~8 per second
const bool refresh =
perfFreq == 0 || mLastRefresh == 0 ||
static_cast<double>(now - mLastRefresh) >= 0.125 * static_cast<double>(perfFreq);
if (refresh) {
mLastRefresh = now;
refresh_lines();
}
// Applied every frame: layout of freshly appended lines is deferred, so a
// single post-append scroll would land short of the real bottom.
if (mStickToBottom && mScrollElem != nullptr) {
mScrollElem->SetScrollTop(mScrollElem->GetScrollHeight() - mScrollElem->GetClientHeight());
}
update_visible_window();
}
// Mark items fully outside the scroll view as `visibility: hidden;`.
// They retain their layout, but stops RmlUi from trying to render them.
void LogsWindow::update_visible_window() {
const float viewTop = mScrollElem->GetAbsoluteOffset(Rml::BoxArea::Border).y;
const float viewHeight = mScrollElem->GetClientHeight();
const int count = mLinesElem->GetNumChildren();
for (int i = 0; i < count && i < static_cast<int>(mLines.size()); ++i) {
auto* elem = mLinesElem->GetChild(i);
const float top = elem->GetAbsoluteOffset(Rml::BoxArea::Border).y - viewTop;
const bool shown = top + elem->GetOffsetHeight() >= -viewHeight && top <= viewHeight * 2.0f;
if (shown != mLines[i].shown) {
mLines[i].shown = shown;
if (shown) {
elem->RemoveProperty("visibility");
} else {
elem->SetProperty("visibility", "hidden");
}
}
}
}
void LogsWindow::refresh_lines() {
mScratch.clear();
const auto [firstSeq, nextSeq] = mods::log::copy_since(mNextSeq, mScratch);
mNextSeq = nextSeq;
// Drop displayed lines that fell out of the buffer (ring wrap or clear)
while (!mLines.empty() && mLines.front().seq < firstSeq) {
if (auto* first = mLinesElem->GetFirstChild()) {
mLinesElem->RemoveChild(first);
}
mLines.pop_front();
}
if (mScratch.empty()) {
return;
}
for (const auto& line : mScratch) {
if (line.modIndex >= mModIds.size()) {
mModIds = mods::log::ids();
break;
}
}
for (const auto& line : mScratch) {
if (!line_visible(line)) {
continue;
}
append_log_line(line);
mLines.push_back({.seq = line.seq});
}
}
void LogsWindow::rebuild_lines() {
if (mLinesElem == nullptr) {
return;
}
mModIds = mods::log::ids();
mScratch.clear();
const auto [_, nextSeq] = mods::log::copy_since(0, mScratch);
mNextSeq = nextSeq;
mLines.clear();
while (auto* child = mLinesElem->GetFirstChild()) {
mLinesElem->RemoveChild(child);
}
for (const auto& line : mScratch) {
if (!line_visible(line)) {
continue;
}
append_log_line(line);
mLines.push_back({.seq = line.seq});
}
mStickToBottom = true;
}
bool LogsWindow::line_visible(const mods::log::Line& line) const {
if (line.level < mMinLevel) {
return false;
}
if (mModFilter.empty()) {
return true;
}
return line.modIndex < mModIds.size() && mModIds[line.modIndex] == mModFilter;
}
Rml::Element* LogsWindow::append_log_line(const mods::log::Line& line) {
std::string_view modId;
if (line.source == mods::log::Source::Loader) {
modId = "loader";
} else if (line.modIndex < mModIds.size()) {
modId = std::string_view{mModIds[line.modIndex]};
} else {
modId = "?";
}
auto elem = mDocument->CreateElement("div");
elem->SetClass("log-line", true);
elem->SetClass(level_class(line.level), true);
constexpr const char* kNbsp = "\xc2\xa0";
append_span(mDocument, elem.get(), "log-time", format_time(line.timeMs));
append_text(mDocument, elem.get(), kNbsp);
append_span(mDocument, elem.get(), "log-mod", fmt::format("[{}]", modId));
append_text(mDocument, elem.get(), kNbsp);
append_span(mDocument, elem.get(), "log-msg", line.message);
return mLinesElem->AppendChild(std::move(elem));
}
void LogsWindow::copy_to_clipboard() {
mModIds = mods::log::ids();
std::vector<mods::log::Line> lines;
mods::log::copy_since(0, lines);
std::string text;
for (const auto& line : lines) {
if (!line_visible(line)) {
continue;
}
const std::string_view modId =
line.modIndex < mModIds.size() ? std::string_view{mModIds[line.modIndex]} : "?";
text += fmt::format("{} [{}] [{}] {}\n", format_time(line.timeMs),
level_logger_name(line.level), modId, line.message);
}
Rml::GetSystemInterface()->SetClipboardText(text);
push_toast({.content = "Copied to clipboard", .duration = std::chrono::seconds(2)});
}
} // namespace dusk::ui
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include "window.hpp"
#include <deque>
#include <string>
#include <vector>
#include "dusk/mods/log_buffer.hpp"
namespace dusk::ui {
class LogsWindow : public Window {
public:
explicit LogsWindow(std::string modFilter = {});
void update() override;
private:
struct DisplayLine {
uint64_t seq = 0;
bool shown = true;
};
void build_content(Rml::Element* content);
void rebuild_lines();
void refresh_lines();
void update_visible_window();
bool line_visible(const mods::log::Line& line) const;
Rml::Element* append_log_line(const mods::log::Line& line);
void copy_to_clipboard();
std::string mModFilter;
LogLevel mMinLevel = LOG_LEVEL_DEBUG;
std::vector<std::string> mModIds;
uint64_t mNextSeq = 0;
std::vector<mods::log::Line> mScratch;
std::deque<DisplayLine> mLines;
Rml::Element* mLinesElem = nullptr;
Rml::Element* mScrollElem = nullptr;
bool mStickToBottom = true;
Uint64 mLastRefresh = 0;
};
} // namespace dusk::ui
+16 -2
View File
@@ -7,15 +7,16 @@
#include "achievements.hpp"
#include "aurora/rmlui.hpp"
#include "dusk/speedrun.h"
#include "dusk/livesplit.h"
#include "dusk/main.h"
#include "dusk/settings.h"
#include "dusk/speedrun.h"
#include "editor.hpp"
#include "f_pc/f_pc_manager.h"
#include "f_pc/f_pc_name.h"
#include "imgui.h"
#include "modal.hpp"
#include "mods_window.hpp"
#include "settings.hpp"
#include "ui.hpp"
#include "warp.hpp"
@@ -58,7 +59,7 @@ MenuBar::MenuBar() : Document(kDocumentSource), mRoot(mDocument->GetElementById(
}
mTabBar->add_tab("Achievements", [this] { push(std::make_unique<AchievementsWindow>()); });
mTabBar->add_tab("Mods", [this] { push(std::make_unique<ModsWindow>()); });
mTabBar->add_tab("Reset", [this] {
mTabBar->set_active_tab(-1);
@@ -229,4 +230,17 @@ bool MenuBar::focus() {
return mTabBar->focus();
}
void MenuBar::rebuild() {
for (auto& doc : get_document_stack()) {
if (auto* menuBar = dynamic_cast<MenuBar*>(doc.get())) {
const bool wasVisible = menuBar->visible();
doc = std::make_unique<MenuBar>();
if (wasVisible) {
doc->show();
}
break;
}
}
}
} // namespace dusk::ui
+2
View File
@@ -21,6 +21,8 @@ public:
bool focus() override;
bool visible() const override;
static void rebuild();
protected:
bool handle_nav_command(Rml::Event& event, NavCommand cmd) override;
+210
View File
@@ -0,0 +1,210 @@
#include "mod_texture_provider.hpp"
#include "dusk/mod_loader.hpp"
#include <fmt/format.h>
namespace dusk::ui {
std::string mod_image_source(const mods::LoadedMod& mod, std::string_view bundlePath) {
return fmt::format("mod://{}/{}?rev={}", mod.metadata.id, bundlePath, mod.cacheGeneration);
}
} // namespace dusk::ui
#ifdef AURORA_ENABLE_RMLUI
#include <SDL3/SDL_iostream.h>
#include <SDL3/SDL_surface.h>
#include <aurora/lib/logging.hpp>
#include <aurora/rmlui.hpp>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <memory>
#include <optional>
#include <span>
#include <stdexcept>
#include <unordered_map>
#include <vector>
#include "dusk/mods/loader/loader.hpp"
namespace dusk::ui {
namespace {
aurora::Module Log{"dusk::ui::modTexture"};
constexpr std::string_view kScheme = "mod";
constexpr std::string_view kSourcePrefix = "mod://";
constexpr size_t kMaxCachedImages = 64;
constexpr size_t kMaxImageFileSize = 16 * 1024 * 1024;
constexpr uint32_t kMaxImageDimension = 4096;
struct CachedImage {
std::vector<uint8_t> pixels;
uint32_t width = 0;
uint32_t height = 0;
};
std::unordered_map<std::string, CachedImage>& image_cache() {
static auto* cache = new std::unordered_map<std::string, CachedImage>();
return *cache;
}
std::string_view strip_query(std::string_view path) noexcept {
const auto queryPos = path.find_first_of("?#");
if (queryPos != std::string_view::npos) {
path = path.substr(0, queryPos);
}
return path;
}
std::optional<CachedImage> decode_png(std::span<const uint8_t> data, std::string_view source) {
SDL_IOStream* stream = SDL_IOFromConstMem(data.data(), data.size());
if (stream == nullptr) {
Log.warn("Failed to open image stream for '{}': {}", source, SDL_GetError());
return std::nullopt;
}
SDL_Surface* loadedSurface = SDL_LoadPNG_IO(stream, true);
if (loadedSurface == nullptr) {
Log.warn("Failed to decode image '{}': {}", source, SDL_GetError());
return std::nullopt;
}
SDL_Surface* rgbaSurface = SDL_ConvertSurface(loadedSurface, SDL_PIXELFORMAT_RGBA32);
SDL_DestroySurface(loadedSurface);
if (rgbaSurface == nullptr) {
Log.warn("Failed to convert image '{}': {}", source, SDL_GetError());
return std::nullopt;
}
const auto width = static_cast<uint32_t>(rgbaSurface->w);
const auto height = static_cast<uint32_t>(rgbaSurface->h);
if (width == 0 || height == 0 || width > kMaxImageDimension || height > kMaxImageDimension) {
Log.warn("Image '{}' has unsupported dimensions {}x{}", source, width, height);
SDL_DestroySurface(rgbaSurface);
return std::nullopt;
}
const size_t rowSize = static_cast<size_t>(width) * 4;
CachedImage image{
.pixels = std::vector<uint8_t>(rowSize * height),
.width = width,
.height = height,
};
for (uint32_t row = 0; row < height; ++row) {
const auto* src = static_cast<const uint8_t*>(rgbaSurface->pixels) +
static_cast<size_t>(row) * static_cast<size_t>(rgbaSurface->pitch);
auto* dst = image.pixels.data() + static_cast<size_t>(row) * rowSize;
std::memcpy(dst, src, rowSize);
// Convert to premultiplied alpha for correct compositing.
for (size_t col = 0; col < rowSize; col += 4) {
const uint8_t alpha = dst[col + 3];
for (size_t channel = 0; channel < 3; ++channel) {
dst[col + channel] = static_cast<uint8_t>(
(static_cast<uint32_t>(dst[col + channel]) * static_cast<uint32_t>(alpha)) /
255);
}
}
}
SDL_DestroySurface(rgbaSurface);
return image;
}
std::optional<CachedImage> load_mod_image(std::string_view idAndPath, std::string_view source) {
const auto slash = idAndPath.find('/');
if (slash == std::string_view::npos || slash == 0 || slash + 1 >= idAndPath.size()) {
Log.warn("Malformed mod image source '{}'", source);
return std::nullopt;
}
const std::string modId{idAndPath.substr(0, slash)};
const std::string path{idAndPath.substr(slash + 1)};
if (!mods::is_safe_resource_path(path)) {
Log.warn("Unsafe path in mod image source '{}'", source);
return std::nullopt;
}
std::shared_ptr<mods::ModBundle> bundle;
for (const auto& mod : mods::ModLoader::instance().mods()) {
if (mod.metadata.id == modId) {
bundle = mod.bundle;
break;
}
}
if (bundle == nullptr) {
Log.warn("Unknown mod in image source '{}'", source);
return std::nullopt;
}
std::vector<u8> data;
try {
if (bundle->getFileSize(path) > kMaxImageFileSize) {
Log.warn("Image '{}' exceeds the {} MiB limit", source, kMaxImageFileSize >> 20);
return std::nullopt;
}
data = bundle->readFile(path);
} catch (const std::runtime_error& e) {
Log.warn("Failed to read image '{}': {}", source, e.what());
return std::nullopt;
}
return decode_png(std::span{data.data(), data.size()}, source);
}
std::optional<aurora::rmlui::RuntimeTexture> mod_texture_provider(std::string_view source) {
if (!source.starts_with(kSourcePrefix)) {
return std::nullopt;
}
auto& cache = image_cache();
const std::string key{source};
auto it = cache.find(key);
if (it == cache.end()) {
auto image = load_mod_image(strip_query(source.substr(kSourcePrefix.size())), source);
if (!image) {
return std::nullopt;
}
if (cache.size() >= kMaxCachedImages) {
cache.erase(cache.begin());
}
it = cache.emplace(key, std::move(*image)).first;
}
const auto& image = it->second;
return aurora::rmlui::RuntimeTexture{
.width = image.width,
.height = image.height,
.rgba8 =
std::span{reinterpret_cast<const std::byte*>(image.pixels.data()), image.pixels.size()},
.premultipliedAlpha = true,
};
}
} // namespace
void register_mod_texture_provider() noexcept {
aurora::rmlui::register_texture_provider(std::string{kScheme}, mod_texture_provider);
}
void unregister_mod_texture_provider() noexcept {
aurora::rmlui::unregister_texture_provider(kScheme);
image_cache().clear();
}
} // namespace dusk::ui
#else
namespace dusk::ui {
void register_mod_texture_provider() noexcept {}
void unregister_mod_texture_provider() noexcept {}
} // namespace dusk::ui
#endif
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <string>
#include <string_view>
namespace dusk::mods {
struct LoadedMod;
} // namespace dusk::mods
namespace dusk::ui {
// Serves PNG images out of mod bundles to RmlUi via the mod:// texture provider scheme.
// Sources embed the mod's cacheGeneration so reloads bust RmlUi's texture cache.
std::string mod_image_source(const mods::LoadedMod& mod, std::string_view bundlePath);
void register_mod_texture_provider() noexcept;
void unregister_mod_texture_provider() noexcept;
} // namespace dusk::ui
+325
View File
@@ -0,0 +1,325 @@
#include "mods_window.hpp"
#include "dusk/mod_loader.hpp"
#include "fmt/format.h"
#include "logs_window.hpp"
#include "mod_texture_provider.hpp"
#include "pane.hpp"
#include "Z2AudioLib/Z2SeMgr.h"
#include "m_Do/m_Do_audio.h"
#include <memory>
#include <string>
#include <string_view>
namespace dusk::ui {
namespace {
struct ModStatus {
const char* badgeClass = "";
const char* text = "";
};
bool mod_enabled(const mods::LoadedMod& mod) {
return mod.cvarIsEnabled != nullptr && mod.cvarIsEnabled->getValue();
}
ModStatus mod_status(const mods::LoadedMod& mod) {
if (mod.loadFailed) {
return {"failed", "Failed"};
}
if (mod.active) {
return {"active", "Active"};
}
if (mod.suspendedByProvider) {
return {"suspended", "Suspended"};
}
return {"", "Disabled"};
}
// Truncates to at most maxBytes without splitting a UTF-8 sequence.
std::string snippet(std::string_view text, size_t maxBytes) {
if (text.size() <= maxBytes) {
return std::string{text};
}
size_t end = maxBytes;
while (end > 0 && (static_cast<unsigned char>(text[end]) & 0xC0) == 0x80) {
--end;
}
return std::string{text.substr(0, end)} + "...";
}
class ModListEntry : public FluentComponent<ModListEntry> {
public:
ModListEntry(Rml::Element* parent, const mods::LoadedMod& mod)
: FluentComponent{append(parent, "mod-entry")} {
Rml::String iconRml;
if (!mod.metadata.iconPath.empty()) {
iconRml = fmt::format(R"(<img class="mod-icon" src="{}"/>)",
mod_image_source(mod, mod.metadata.iconPath));
} else {
iconRml = R"(<icon class="mod-icon placeholder"/>)";
}
const auto status = mod_status(mod);
mRoot->SetInnerRML(fmt::format(
R"({})"
R"(<div class="mod-entry-info">)"
R"(<div class="mod-entry-name"><span class="mod-entry-name-text">{}</span>)"
R"(<span class="mod-entry-version">v{}</span></div>)"
R"(<div class="mod-entry-sub">{} - <span class="mod-entry-status {}">{}</span></div>)"
R"(<div class="mod-entry-desc">{}</div>)"
R"(</div>)",
iconRml, escape(mod.metadata.name), escape(mod.metadata.version),
escape(mod.metadata.author), status.badgeClass, status.text,
escape(snippet(mod.metadata.description, 90))));
mRoot->SetClass("inactive", !mod.active);
mRoot->SetClass("failed", mod.loadFailed);
on_nav_command([this](Rml::Event&, NavCommand cmd) {
if (cmd == NavCommand::Confirm) {
mRoot->DispatchEvent(Rml::EventId::Submit, {});
return true;
}
return false;
});
}
};
class ModDetailHeader : public FluentComponent<ModDetailHeader> {
public:
ModDetailHeader(
Rml::Element* parent, const mods::LoadedMod& mod, std::function<void()> onShowLogs)
: FluentComponent{append(parent, "mod-header")} {
const bool hasBanner = !mod.metadata.bannerPath.empty();
mRoot->SetClass(hasBanner ? "has-banner" : "no-banner", true);
if (hasBanner) {
mRoot->SetProperty("decorator", fmt::format(R"(image("{}" cover center top))",
mod_image_source(mod, mod.metadata.bannerPath)));
}
auto* actions = append(mRoot, "div");
actions->SetClass("mod-actions", true);
const std::string modId = mod.metadata.id;
if (mod_enabled(mod)) {
if (!mod.inPlace) {
make_button(actions, "Reload").on_pressed([modId] {
mods::ModLoader::instance().request_reload(modId);
});
}
make_button(actions, "Disable").on_pressed([modId] {
mods::ModLoader::instance().request_disable(modId);
});
} else {
make_button(actions, "Enable").on_pressed([modId] {
mods::ModLoader::instance().request_enable(modId);
});
}
make_button(actions, "Logs").on_pressed(std::move(onShowLogs));
listen(Rml::EventId::Keydown, [this](Rml::Event& event) {
const auto cmd = map_nav_event(event);
if (cmd != NavCommand::Left && cmd != NavCommand::Right) {
return;
}
int index = -1;
for (int i = 0; i < static_cast<int>(mButtons.size()); ++i) {
if (mButtons[i]->contains(event.GetTargetElement())) {
index = i;
break;
}
}
if (index == -1) {
return;
}
const int next = index + (cmd == NavCommand::Right ? 1 : -1);
if (next >= 0 && next < static_cast<int>(mButtons.size()) && mButtons[next]->focus()) {
mDoAud_seStartMenu(kSoundItemFocus);
event.StopPropagation();
}
});
}
bool focus() override {
for (auto* button : mButtons) {
if (button->focus()) {
return true;
}
}
return false;
}
private:
Button& make_button(Rml::Element* parent, Rml::String text) {
auto button = std::make_unique<Button>(parent, std::move(text));
Button& ref = *button;
mChildren.emplace_back(std::move(button));
mButtons.push_back(&ref);
return ref;
}
std::vector<Button*> mButtons;
};
} // namespace
ModsWindow::ModsWindow() : Window{Props{.tabBar = false, .styleSheets = {"res/rml/mods.rcss"}}} {
mRoot->SetClass("mods", true);
for (auto& trackedMod : mods::ModLoader::instance().mods()) {
mSnapshot.push_back({
.mod = &trackedMod,
.active = trackedMod.active,
.loadFailed = trackedMod.loadFailed,
.enabled = mod_enabled(trackedMod),
.suspended = trackedMod.suspendedByProvider,
.cacheGeneration = trackedMod.cacheGeneration,
});
}
set_content([this](Rml::Element* content) { build_content(content); });
}
void ModsWindow::build_content(Rml::Element* content) {
mEntries.clear();
mEntryMods.clear();
auto& listPane = add_child<Pane>(content, Pane::Type::Controlled);
listPane.root()->SetClass("mod-list", true);
auto& detailPane = add_child<Pane>(content, Pane::Type::Uncontrolled);
detailPane.root()->SetClass("mod-detail", true);
if (mods::ModLoader::instance().mods().empty()) {
listPane.add_text("No mods installed.");
listPane.finalize();
detailPane.finalize();
return;
}
for (auto& trackedMod : mods::ModLoader::instance().mods()) {
auto& entry = listPane.add_child<ModListEntry>(trackedMod);
mEntries.push_back(&entry);
mEntryMods.push_back(&trackedMod);
listPane.register_control(entry, detailPane, [this, tracked = &trackedMod](Pane& pane) {
mSelectedMod = tracked;
pane.clear();
build_detail(pane, *tracked);
mark_current_entry();
});
}
if (mSelectedMod == nullptr) {
mSelectedMod = mEntryMods.front();
}
build_detail(detailPane, *mSelectedMod);
mark_current_entry();
listPane.finalize();
}
void ModsWindow::build_detail(Pane& pane, mods::LoadedMod& mod) {
pane.add_child<ModDetailHeader>(
mod, [this, id = mod.metadata.id] { push(std::make_unique<LogsWindow>(id)); });
Rml::String statusBadge;
if (mod.loadFailed || mod.suspendedByProvider) {
const auto status = mod_status(mod);
statusBadge = fmt::format(
R"(&nbsp;<span class="status-badge {}">{}</span>)", status.badgeClass, status.text);
}
pane.add_rml(fmt::format(R"(<div class="mod-title">{} )"
R"(<span class="mod-title-version">v{}</span>{}</div>)"
R"(<div class="mod-author">by {}</div>)",
escape(mod.metadata.name), escape(mod.metadata.version), statusBadge,
escape(mod.metadata.author)));
if (mod.loadFailed && !mod.failureReason.empty()) {
pane.add_rml(fmt::format(R"(<div class="mod-info-row">)"
R"(<span class="mod-info-label failed">Reason</span>)"
R"(<span class="mod-info-value">{}</span>)"
R"(</div>)",
escape(mod.failureReason)));
} else if (mod.suspendedByProvider) {
std::string providers;
for (const auto& edge : mod.dependencies) {
if (edge.required && edge.mod != nullptr && !edge.mod->active) {
if (!providers.empty()) {
providers += ", ";
}
providers += edge.mod->metadata.name;
}
}
pane.add_rml(fmt::format(R"(<div class="mod-info-row">)"
R"(<span class="mod-info-label">Waiting on</span>)"
R"(<span class="mod-info-value">{}</span>)"
R"(</div>)",
escape(providers)));
}
std::string activeDependents;
for (const auto& edge : mod.dependents) {
if (edge.mod != nullptr && edge.mod->active) {
if (!activeDependents.empty()) {
activeDependents += ", ";
}
activeDependents += edge.mod->metadata.name;
}
}
if (mod.active && !activeDependents.empty()) {
pane.add_rml(fmt::format(R"(<div class="mod-restart-note">{}</div>)",
escape(fmt::format("Disabling or reloading also restarts: {}", activeDependents))));
}
if (!mod.metadata.description.empty()) {
pane.add_text(mod.metadata.description)->SetClass("mod-description", true);
}
pane.finalize();
}
void ModsWindow::mark_current_entry() {
for (size_t i = 0; i < mEntries.size(); ++i) {
mEntries[i]->root()->SetClass("current", mEntryMods[i] == mSelectedMod);
}
}
void ModsWindow::update() {
bool dirty = false;
for (auto& snapshot : mSnapshot) {
const auto& mod = *snapshot.mod;
if (mod.active != snapshot.active || mod.loadFailed != snapshot.loadFailed ||
mod_enabled(mod) != snapshot.enabled || mod.suspendedByProvider != snapshot.suspended ||
mod.cacheGeneration != snapshot.cacheGeneration)
{
snapshot.active = mod.active;
snapshot.loadFailed = mod.loadFailed;
snapshot.enabled = mod_enabled(mod);
snapshot.suspended = mod.suspendedByProvider;
snapshot.cacheGeneration = mod.cacheGeneration;
dirty = true;
}
}
if (dirty) {
auto* focused = mDocument != nullptr ? mDocument->GetFocusLeafNode() : nullptr;
bool hadContentFocus = false;
for (auto* node = focused; node != nullptr; node = node->GetParentNode()) {
if (node == mContentRoot) {
hadContentFocus = true;
break;
}
}
rebuild_content();
if (hadContentFocus) {
for (size_t i = 0; i < mEntryMods.size(); ++i) {
if (mEntryMods[i] == mSelectedMod) {
mEntries[i]->focus();
break;
}
}
}
}
Window::update();
}
} // namespace dusk::ui
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include "window.hpp"
#include <vector>
#include "dusk/mod_loader.hpp"
namespace dusk::ui {
class Pane;
class ModsWindow : public Window {
public:
ModsWindow();
void update() override;
private:
struct ModSnapshot {
mods::LoadedMod* mod = nullptr;
bool active = false;
bool loadFailed = false;
bool enabled = false;
bool suspended = false;
u32 cacheGeneration = 0;
};
void build_content(Rml::Element* content);
void build_detail(Pane& pane, mods::LoadedMod& mod);
void mark_current_entry();
std::vector<ModSnapshot> mSnapshot;
std::vector<Component*> mEntries;
std::vector<mods::LoadedMod*> mEntryMods;
mods::LoadedMod* mSelectedMod = nullptr;
};
} // namespace dusk::ui
+3
View File
@@ -89,6 +89,9 @@ Rml::Element* create_toast(Rml::Element* parent, const Toast& toast) {
} else if (toast.type == "controller") {
auto* icon = append(heading, "icon");
icon->SetClass("controller", true);
} else if (toast.type == "warning") {
auto* icon = append(heading, "icon");
icon->SetClass("warning", true);
}
}
{
+11 -3
View File
@@ -8,6 +8,7 @@
#include "dusk/settings.h"
#include "dusk/update_check.hpp"
#include "modal.hpp"
#include "mods_window.hpp"
#include "preset.hpp"
#include "settings.hpp"
#include "version.h"
@@ -49,14 +50,14 @@ const Rml::String kDocumentSource = R"RML(
</hero>
<div id="menu-list" />
</menu>
<disc-info class="intro-item delay-4">
<disc-info class="intro-item delay-5">
<div id="disc-status">
<icon />
<span id="disc-status-label" />
</div>
<span id="disc-version" class="detail" />
</disc-info>
<version-info class="intro-item delay-5">
<version-info class="intro-item delay-6">
<div class="version">Version <span id="version-text"></span></div>
<div id="update-status" class="update">
<span id="update-message"></span>
@@ -726,9 +727,16 @@ Prelaunch::Prelaunch() : Document(kDocumentSource), mRoot(mDocument->GetElementB
});
apply_intro_animation(mMenuButtons.back()->root(), "delay-2");
mMenuButtons.push_back(std::make_unique<Button>(menuList, "Mods"));
mMenuButtons.back()->on_pressed([this] {
mRestartSuppressed = false;
push(std::make_unique<ModsWindow>());
});
apply_intro_animation(mMenuButtons.back()->root(), "delay-3");
mMenuButtons.push_back(std::make_unique<Button>(menuList, "Quit"));
mMenuButtons.back()->on_pressed([] { IsRunning = false; });
apply_intro_animation(mMenuButtons.back()->root(), "delay-3");
apply_intro_animation(mMenuButtons.back()->root(), "delay-4");
}
mDiscStatus = mDocument->GetElementById("disc-status");
+2 -15
View File
@@ -1329,12 +1329,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
speedrun::disconnectLiveSplit();
}
}
for (auto& doc : get_document_stack()) {
if (dynamic_cast<MenuBar*>(doc.get())) {
doc = std::make_unique<MenuBar>();
break;
}
}
MenuBar::rebuild();
},
});
config_bool_select(leftPane, rightPane, getSettings().game.liveSplitEnabled,
@@ -1568,15 +1563,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
.helpText = "Show advanced settings and debugging tools with "
"Shift+F1.<br/><br/><icon class=\"warning\"/> WARNING: Debugging tools "
"can easily break your game. Do not use on a regular save!",
.onChange =
[](bool) {
for (auto& doc : get_document_stack()) {
if (dynamic_cast<MenuBar*>(doc.get())) {
doc = std::make_unique<MenuBar>();
break;
}
}
},
.onChange = [](bool) { MenuBar::rebuild(); },
.isDisabled = [] { return getSettings().game.speedrunMode.getValue(); },
});
config_bool_select(leftPane, rightPane, getSettings().game.showInputViewer,
+4 -1
View File
@@ -17,8 +17,9 @@
#include "aurora/lib/window.hpp"
#include "dusk/config.hpp"
#include "dusk/io.hpp"
#include "input.hpp"
#include "icon_provider.hpp"
#include "input.hpp"
#include "mod_texture_provider.hpp"
#include "prelaunch.hpp"
#include "window.hpp"
@@ -62,11 +63,13 @@ bool initialize() noexcept {
load_font("NotoMono-Regular.ttf");
register_icon_texture_provider();
register_mod_texture_provider();
sInitialized = true;
return true;
}
void shutdown() noexcept {
unregister_mod_texture_provider();
unregister_icon_texture_provider();
sDocumentStack.clear();
sPassiveDocuments.clear();
+68 -14
View File
@@ -2,6 +2,7 @@
#include "aurora/lib/window.hpp"
#include "aurora/rmlui.hpp"
#include "fmt/format.h"
#include "magic_enum.hpp"
#include "pane.hpp"
#include "ui.hpp"
@@ -24,17 +25,24 @@ float base_body_padding(Rml::Context* context) noexcept {
return 64.0f * dpRatio;
}
const Rml::String kDocumentSource = R"RML(
Rml::String window_document_source(const std::vector<Rml::String>& styleSheets) {
Rml::String links;
for (const auto& sheet : styleSheets) {
links += fmt::format(" <link type=\"text/rcss\" href=\"{}\" />\n", sheet);
}
return fmt::format(R"RML(
<rml>
<head>
<link type="text/rcss" href="res/rml/tabbing.rcss" />
<link type="text/rcss" href="res/rml/window.rcss" />
</head>
{}</head>
<body>
<window id="window"></window>
</body>
</rml>
)RML";
)RML",
links);
}
const Rml::String kDocumentSourceSmall = R"RML(
<rml>
@@ -51,12 +59,25 @@ const Rml::String kDocumentSourceSmall = R"RML(
} // namespace
Window::Window() : Document(kDocumentSource), mRoot(mDocument->GetElementById("window")) {
mTabBar = std::make_unique<TabBar>(mRoot, TabBar::Props{
.onClose = [this] { request_close(); },
.selectedTabIndex = 0,
.autoSelect = true,
});
Window::Window(Props props)
: Document(window_document_source(props.styleSheets)),
mRoot(mDocument->GetElementById("window")) {
if (props.tabBar) {
mTabBar = std::make_unique<TabBar>(mRoot, TabBar::Props{
.onClose = [this] { request_close(); },
.selectedTabIndex = 0,
.autoSelect = true,
});
} else {
mCloseButton = std::make_unique<Button>(mRoot, Button::Props{}, "close");
mCloseButton->on_nav_command([this](Rml::Event&, NavCommand cmd) {
if (cmd == NavCommand::Confirm) {
request_close();
return true;
}
return false;
});
}
auto elem = mDocument->CreateElement("content");
elem->SetAttribute("id", "content");
@@ -152,7 +173,7 @@ void Window::update_safe_area() noexcept {
}
bool Window::set_active_tab(int index) {
return mTabBar->set_active_tab(index);
return mTabBar && mTabBar->set_active_tab(index);
}
void Window::request_close() {
@@ -167,10 +188,17 @@ bool Window::consume_close_request() {
}
void Window::refresh_active_tab() {
mTabBar->refresh_active_tab();
if (mTabBar) {
mTabBar->refresh_active_tab();
} else {
rebuild_content();
}
}
void Window::add_tab(const Rml::String& title, TabBuilder builder) {
if (!mTabBar) {
return;
}
mTabBar->add_tab(title, [this, builder = std::move(builder)] {
clear_content();
if (builder) {
@@ -179,6 +207,18 @@ void Window::add_tab(const Rml::String& title, TabBuilder builder) {
});
}
void Window::set_content(TabBuilder builder) {
mContentBuilder = std::move(builder);
rebuild_content();
}
void Window::rebuild_content() {
clear_content();
if (mContentBuilder) {
mContentBuilder(mContentRoot);
}
}
void Window::clear_content() noexcept {
mContentComponents.clear();
while (mContentRoot->GetNumChildren() != 0) {
@@ -187,7 +227,15 @@ void Window::clear_content() noexcept {
}
bool Window::focus() {
return mTabBar->focus();
if (mTabBar) {
return mTabBar->focus();
}
for (const auto& component : mContentComponents) {
if (component->focus()) {
return true;
}
}
return mCloseButton && mCloseButton->focus();
}
bool Window::visible() const {
@@ -211,7 +259,7 @@ bool Window::handle_nav_command(Rml::Event& event, NavCommand cmd) {
request_close();
return true;
}
if (mTabBar->handle_nav_command(event, cmd)) {
if (mTabBar && mTabBar->handle_nav_command(event, cmd)) {
return true;
}
return mSuppressNavFallback ? false : Document::handle_nav_command(event, cmd);
@@ -219,6 +267,9 @@ bool Window::handle_nav_command(Rml::Event& event, NavCommand cmd) {
bool Window::handle_content_nav(Rml::Event& event, NavCommand cmd) noexcept {
if (cmd == NavCommand::Up) {
if (!mTabBar) {
return true;
}
if (focus()) {
mDoAud_seStartMenu(kSoundItemFocus);
return true;
@@ -246,6 +297,9 @@ bool Window::handle_content_nav(Rml::Event& event, NavCommand cmd) noexcept {
return true;
}
}
if (!mTabBar) {
return false;
}
return focus();
} else if (cmd == NavCommand::Left || cmd == NavCommand::Right) {
int currentComponent = -1;
@@ -305,4 +359,4 @@ bool WindowSmall::visible() const {
return mRoot->HasAttribute("open");
}
} // namespace dusk::ui
} // namespace dusk::ui
+12 -1
View File
@@ -17,8 +17,13 @@ public:
std::unique_ptr<Button> button;
TabBuilder builder;
};
struct Props {
bool tabBar = true;
std::vector<Rml::String> styleSheets;
};
Window();
Window() : Window(Props{}) {}
explicit Window(Props props);
Window(const Window&) = delete;
Window& operator=(const Window&) = delete;
@@ -34,6 +39,9 @@ protected:
void request_close();
virtual bool consume_close_request();
void add_tab(const Rml::String& title, TabBuilder builder);
// Tab-bar-less counterpart of add_tab: stores the builder and runs it immediately.
void set_content(TabBuilder builder);
void rebuild_content();
void refresh_active_tab();
void update_safe_area() noexcept;
void clear_content() noexcept;
@@ -52,6 +60,9 @@ protected:
Rml::Element* mRoot;
Rml::Element* mContentRoot;
std::unique_ptr<TabBar> mTabBar;
// Only set for tab-bar-less windows.
std::unique_ptr<Button> mCloseButton;
TabBuilder mContentBuilder;
std::vector<std::unique_ptr<Component> > mContentComponents;
Insets mBodyPadding;
bool mInitialOpen = true;