mirror of
https://github.com/TwilitRealm/dusklight
synced 2026-07-09 04:30:49 -04:00
Mods manager UI & logs viewer
This commit is contained in:
+3
-3
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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("" 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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%);
|
||||
}
|
||||
|
||||
@@ -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,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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -385,9 +385,9 @@ ModResult config_subscribe(ModContext* context, ConfigVarHandle var, ConfigChang
|
||||
callback(modPtr->context.get(), varHandle, ¤tValue, &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,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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"( <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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user