From 4e23472ed53cddce5e2d22d92cbe601e9ff5fd9a Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 6 May 2026 11:14:12 -0600 Subject: [PATCH 01/40] UI: Controller connect/disconnect toasts --- extern/aurora | 2 +- res/rml/overlay.rcss | 31 ++++++++++- src/dusk/ui/input.cpp | 2 +- src/dusk/ui/input.hpp | 5 +- src/dusk/ui/overlay.cpp | 19 +++++-- src/dusk/ui/ui.cpp | 118 ++++++++++++++++++++++++++++++++++++++-- src/dusk/ui/ui.hpp | 3 + 7 files changed, 166 insertions(+), 14 deletions(-) diff --git a/extern/aurora b/extern/aurora index 4cd8d2f009..b78bbf3f58 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 4cd8d2f009f6e38ac96cdf1ea249d0c155af9fcb +Subproject commit b78bbf3f585a6ccce81366f9b0bc1681e366ae15 diff --git a/res/rml/overlay.rcss b/res/rml/overlay.rcss index e9c4644476..2cf8c8a8de 100644 --- a/res/rml/overlay.rcss +++ b/res/rml/overlay.rcss @@ -65,13 +65,35 @@ toast heading { color: #92875B; } -toast message { +toast heading > span { + flex: 1 0 auto; +} + +toast heading > row { + flex: 1 0 auto; display: flex; align-items: center; + gap: 4dp; +} + +toast message { + display: flex; + flex-flow: column; + align-items: start; justify-content: start; gap: 8dp; } +toast message row { + display: flex; + align-items: start; + justify-content: start; +} + +toast message row.muted { + opacity: 0.5; +} + toast progress { height: 4dp; position: absolute; @@ -113,6 +135,13 @@ icon.trophy { decorator: text("" center center); } +icon.controller { + width: 24dp; + height: 24dp; + font-size: 24dp; + decorator: text("" center center); +} + logo { position: absolute; width: 100dp; diff --git a/src/dusk/ui/input.cpp b/src/dusk/ui/input.cpp index d3722f6efa..cdaa2360c0 100644 --- a/src/dusk/ui/input.cpp +++ b/src/dusk/ui/input.cpp @@ -12,7 +12,7 @@ #include #include -namespace dusk::ui { +namespace dusk::ui::input { namespace { constexpr double kGamepadRepeatInitialDelay = 0.32; diff --git a/src/dusk/ui/input.hpp b/src/dusk/ui/input.hpp index b83c9ab3d2..a2ad859530 100644 --- a/src/dusk/ui/input.hpp +++ b/src/dusk/ui/input.hpp @@ -1,7 +1,10 @@ #pragma once -namespace dusk::ui { +union SDL_Event; +namespace dusk::ui::input { + +void handle_event(const SDL_Event& event) noexcept; void update_input() noexcept; void reset_input_state() noexcept; void sync_input_block() noexcept; diff --git a/src/dusk/ui/overlay.cpp b/src/dusk/ui/overlay.cpp index 109ea7e0a3..c26ff51481 100644 --- a/src/dusk/ui/overlay.cpp +++ b/src/dusk/ui/overlay.cpp @@ -38,18 +38,29 @@ Rml::Element* create_toast(Rml::Element* parent, const Toast& toast) { } { auto* heading = append(elem, "heading"); - auto* span = append(heading, "span"); - span->SetInnerRML(toast.title); + if (toast.title.starts_with("<")) { + heading->SetInnerRML(toast.title); + } else { + auto* span = append(heading, "span"); + span->SetInnerRML(toast.title); + } if (toast.type == "achievement") { auto* icon = append(heading, "icon"); icon->SetClass("trophy", true); mDoAud_seStartMenu(kSoundAchievementUnlock); + } else if (toast.type == "controller") { + auto* icon = append(heading, "icon"); + icon->SetClass("controller", true); } } { auto* message = append(elem, "message"); - auto* span = append(message, "span"); - span->SetInnerRML(toast.content); + if (toast.content.starts_with("<")) { + message->SetInnerRML(toast.content); + } else { + auto* span = append(message, "span"); + span->SetInnerRML(toast.content); + } } { auto* progress = append(elem, "progress"); diff --git a/src/dusk/ui/ui.cpp b/src/dusk/ui/ui.cpp index 367dd69086..7fb8f483d4 100644 --- a/src/dusk/ui/ui.cpp +++ b/src/dusk/ui/ui.cpp @@ -2,7 +2,9 @@ #include #include +#include #include +#include #include #include @@ -26,6 +28,12 @@ std::vector > sDocumentStack; std::vector > sPassiveDocuments; std::deque sToasts; +// Sometimes gamepads can connect and disconnect quickly, especially during +// connection negotiation. In this case, we'll receive an _ADDED event for a +// disconnected gamepad. Storing IDs here lets use only show disconnected +// notifications for gamepads that we sent a connected notification for. +absl::flat_hash_set sConnectedGamepads; + } // namespace bool initialize() noexcept { @@ -51,11 +59,109 @@ bool initialize() noexcept { void shutdown() noexcept { sDocumentStack.clear(); sPassiveDocuments.clear(); - reset_input_state(); - release_input_block(); + sConnectedGamepads.clear(); + input::reset_input_state(); + input::release_input_block(); sInitialized = false; } +const char* battery_icon(SDL_PowerState state, int level) noexcept { + if (state == SDL_POWERSTATE_UNKNOWN || state == SDL_POWERSTATE_NO_BATTERY) { + return "e1a6"; // Battery Unknown + } + if (state == SDL_POWERSTATE_ERROR) { + return "f7ea"; // Battery Error + } + if (state == SDL_POWERSTATE_CHARGED || level == 100) { + return "e1a4"; // Battery Full + } + if (state == SDL_POWERSTATE_CHARGING) { + if (level >= 90) + return "f0a7"; // Battery Charging 90 + if (level >= 80) + return "f0a6"; // Battery Charging 80 + if (level >= 60) + return "f0a5"; // Battery Charging 60 + if (level >= 50) + return "f0a4"; // Battery Charging 50 + if (level >= 30) + return "f0a3"; // Battery Charging 30 + if (level >= 20) + return "f0a2"; // Battery Charging 20 + return "e1a3"; // Battery Charging Full (we use it as empty) + } + if (level >= 85) + return "ebd2"; // Battery 6 Bar + if (level >= 70) + return "ebd4"; // Battery 5 Bar + if (level >= 55) + return "ebe2"; // Battery 4 Bar + if (level >= 40) + return "ebdd"; // Battery 3 Bar + if (level >= 25) + return "ebe0"; // Battery 2 Bar + if (level >= 10) + return "ebd9"; // Battery 1 Bar + return "e19c"; // Battery Alert +} + +const char* connection_state_icon(SDL_JoystickConnectionState state) noexcept { + switch (state) { + case SDL_JOYSTICK_CONNECTION_WIRELESS: + return "e1a7"; + case SDL_JOYSTICK_CONNECTION_WIRED: + return "e1e0"; + default: + return nullptr; + } +} + +void handle_event(const SDL_Event& event) noexcept { + if (event.type == SDL_EVENT_GAMEPAD_ADDED) { + auto* gamepad = SDL_GetGamepadFromID(event.gdevice.which); + if (SDL_GamepadConnected(gamepad)) { + const char* name = SDL_GetGamepadName(gamepad); + Rml::String content = fmt::format("{}", name ? name : "[Unknown]"); + Rml::String title = "Controller connected"; + if (const char* icon = connection_state_icon(SDL_GetGamepadConnectionState(gamepad))) { + title = fmt::format( + "{} &#x{};", title, + icon); + } + int batteryLevel = -1; + const auto powerState = SDL_GetGamepadPowerInfo(gamepad, &batteryLevel); + if (powerState != SDL_POWERSTATE_UNKNOWN) { + content = fmt::format( + "{}&#x{};", + content, battery_icon(powerState, batteryLevel)); + if (batteryLevel > -1) { + content = fmt::format("{} {}%", content, batteryLevel); + } + content += ""; + } + push_toast({ + .type = "controller", + .title = title, + .content = content, + .duration = std::chrono::seconds(4), + }); + sConnectedGamepads.insert(event.gdevice.which); + } + } else if (event.type == SDL_EVENT_GAMEPAD_REMOVED && + sConnectedGamepads.contains(event.gdevice.which)) + { + const char* name = SDL_GetGamepadNameForID(event.gdevice.which); + push_toast({ + .type = "controller", + .title = "Controller disconnected", + .content = name ? name : "[Unknown]", + .duration = std::chrono::seconds(4), + }); + sConnectedGamepads.erase(event.gdevice.which); + } + input::handle_event(event); +} + Document& push_document(std::unique_ptr doc, bool show, bool passive) noexcept { Document& ret = *doc; if (passive) { @@ -66,7 +172,7 @@ Document& push_document(std::unique_ptr doc, bool show, bool passive) if (show) { ret.show(); } - sync_input_block(); + input::sync_input_block(); return ret; } @@ -74,7 +180,7 @@ void show_top_document() noexcept { if (auto* doc = top_document()) { doc->show(); } - sync_input_block(); + input::sync_input_block(); } bool any_document_visible() noexcept { @@ -99,7 +205,7 @@ Document* top_document() noexcept { } void update() noexcept { - update_input(); + input::update_input(); for (const auto& doc : sDocumentStack) { doc->update(); } @@ -131,7 +237,7 @@ void update() noexcept { } } - sync_input_block(); + input::sync_input_block(); } std::filesystem::path resource_path(const std::filesystem::path& filename) noexcept { diff --git a/src/dusk/ui/ui.hpp b/src/dusk/ui/ui.hpp index 7f59114ca1..7d63030b2f 100644 --- a/src/dusk/ui/ui.hpp +++ b/src/dusk/ui/ui.hpp @@ -85,4 +85,7 @@ Insets safe_area_insets(Rml::Context* context) noexcept; void push_toast(Toast toast) noexcept; std::deque& get_toasts() noexcept; +const char* battery_icon(SDL_PowerState state, int level) noexcept; +const char* connection_state_icon(SDL_JoystickConnectionState state) noexcept; + } // namespace dusk::ui From c21bce0093b5134ba3b58df45c212e77be9fe72f Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 6 May 2026 11:49:24 -0600 Subject: [PATCH 02/40] UI: Adjust battery icon thresholds --- src/dusk/ui/ui.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/dusk/ui/ui.cpp b/src/dusk/ui/ui.cpp index 7fb8f483d4..7b0f792c2b 100644 --- a/src/dusk/ui/ui.cpp +++ b/src/dusk/ui/ui.cpp @@ -90,17 +90,17 @@ const char* battery_icon(SDL_PowerState state, int level) noexcept { return "f0a2"; // Battery Charging 20 return "e1a3"; // Battery Charging Full (we use it as empty) } - if (level >= 85) + if (level >= 90) return "ebd2"; // Battery 6 Bar - if (level >= 70) + if (level >= 80) return "ebd4"; // Battery 5 Bar - if (level >= 55) + if (level >= 60) return "ebe2"; // Battery 4 Bar - if (level >= 40) + if (level >= 50) return "ebdd"; // Battery 3 Bar - if (level >= 25) + if (level >= 30) return "ebe0"; // Battery 2 Bar - if (level >= 10) + if (level >= 20) return "ebd9"; // Battery 1 Bar return "e19c"; // Battery Alert } From 7f0955f0222898b0392b7b67c1f93e70d5fa60f2 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 6 May 2026 16:39:28 -0600 Subject: [PATCH 03/40] Add ImGui screen for Aurora Null backend Resolves #628 --- extern/aurora | 2 +- res/rml/overlay.rcss | 4 -- src/dusk/imgui/ImGuiConsole.cpp | 64 +++++++++++++++++++++- src/dusk/imgui/ImGuiConsole.hpp | 25 ++++++++- src/dusk/imgui/ImGuiEngine.cpp | 2 +- src/dusk/imgui/ImGuiMenuTools.cpp | 18 ------ src/dusk/ui/overlay.cpp | 91 ++++++++++++++++--------------- src/dusk/ui/ui.cpp | 8 +++ src/m_Do/m_Do_main.cpp | 15 +++++ 9 files changed, 160 insertions(+), 69 deletions(-) diff --git a/extern/aurora b/extern/aurora index b78bbf3f58..552be91d68 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit b78bbf3f585a6ccce81366f9b0bc1681e366ae15 +Subproject commit 552be91d68d57a911dac5ba2b718b8fd61ac7e37 diff --git a/res/rml/overlay.rcss b/res/rml/overlay.rcss index 2cf8c8a8de..cf90037f57 100644 --- a/res/rml/overlay.rcss +++ b/res/rml/overlay.rcss @@ -79,15 +79,11 @@ toast heading > row { toast message { display: flex; flex-flow: column; - align-items: start; - justify-content: start; gap: 8dp; } toast message row { display: flex; - align-items: start; - justify-content: start; } toast message row.muted { diff --git a/src/dusk/imgui/ImGuiConsole.cpp b/src/dusk/imgui/ImGuiConsole.cpp index fa5f849dbe..a18d257690 100644 --- a/src/dusk/imgui/ImGuiConsole.cpp +++ b/src/dusk/imgui/ImGuiConsole.cpp @@ -10,7 +10,7 @@ #include "fmt/format.h" #include "ImGuiConsole.hpp" -#include "dusk/ui/ui.hpp" +#include "ImGuiEngine.hpp" #include "JSystem/JUtility/JUTGamePad.h" #include "SDL3/SDL_mouse.h" #include "dusk/audio/DuskAudioSystem.h" @@ -20,6 +20,7 @@ #include "dusk/livesplit.h" #include "dusk/main.h" #include "dusk/settings.h" +#include "dusk/ui/ui.hpp" #include "f_pc/f_pc_manager.h" #include "f_pc/f_pc_name.h" #include "m_Do/m_Do_controller_pad.h" @@ -302,6 +303,67 @@ namespace dusk { UpdateDragScroll(); + // Show message when Aurora backend is Null + if (aurora_get_backend() == BACKEND_NULL) { + auto& io = ImGui::GetIO(); + ImGui::SetNextWindowSize(ImVec2(io.DisplaySize.x, io.DisplaySize.y)); + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowBgAlpha(0.65f); + ImGui::Begin("Pre Launch Window", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoBringToFrontOnFocus); + ImGui::NewLine(); + if (ImGuiEngine::duskLogo) { + const auto& windowSize = ImGui::GetWindowSize(); + ImGui::NewLine(); + float iconSize = 150.f; + float width = iconSize * 2.5f; + ImGui::SameLine(windowSize.x / 2 - width + (width / 2)); + ImGui::Image(ImGuiEngine::duskLogo, ImVec2{width, iconSize}); + } else { + ImGui::PushFont(ImGuiEngine::fontExtraLarge); + ImGuiTextCenter("Dusk"); + ImGui::PopFont(); + } + ImGui::PushFont(ImGuiEngine::fontLarge); + ImGuiTextCenter("Failed to initialize any graphics backend"); + const auto& style = ImGui::GetStyle(); + const auto retrySize = ImGui::CalcTextSize("Retry (Auto backend)"); + const auto quitSize = ImGui::CalcTextSize("Quit"); + float buttonsWidth = quitSize.x + style.FramePadding.x * 2.0f; + if constexpr (SupportsProcessRestart) { + buttonsWidth += retrySize.x + style.FramePadding.x * 2.0f + style.ItemSpacing.x; + } +#if DUSK_CAN_OPEN_DATA_FOLDER + const auto openSize = ImGui::CalcTextSize("Open Data Folder"); + buttonsWidth += openSize.x + style.FramePadding.x * 2.0f + style.ItemSpacing.x; +#endif + ImGui::NewLine(); + ImGui::SetCursorPosX( + ImMax(style.WindowPadding.x, (ImGui::GetWindowSize().x - buttonsWidth) * 0.5f)); + if constexpr (SupportsProcessRestart) { + if (ImGui::Button("Retry (Auto backend)")) { + getSettings().backend.graphicsBackend.setValue("auto"); + config::Save(); + RestartRequested = true; + IsRunning = false; + } + ImGui::SameLine(); + } +#if DUSK_CAN_OPEN_DATA_FOLDER + if (ImGui::Button("Open Data Folder")) { + OpenDataFolder(); + } + ImGui::SameLine(); +#endif + if (ImGui::Button("Quit")) { + IsRunning = false; + } + ImGui::PopFont(); + ImGui::End(); + } + m_menuGame.windowControllerConfig(); m_menuGame.windowInputViewer(); m_menuGame.drawSpeedrunTimerOverlay(); diff --git a/src/dusk/imgui/ImGuiConsole.hpp b/src/dusk/imgui/ImGuiConsole.hpp index 1aee9df373..1b6964c2cc 100644 --- a/src/dusk/imgui/ImGuiConsole.hpp +++ b/src/dusk/imgui/ImGuiConsole.hpp @@ -2,13 +2,16 @@ #define DUSK_IMGUI_HPP #include +#include #include #include +#include #include #include "ImGuiMenuGame.hpp" #include "ImGuiMenuTools.hpp" +#include "dusk/main.h" #include "imgui.h" union SDL_Event; @@ -23,7 +26,7 @@ public: void PreDraw(); void PostDraw(); - static bool CheckMenuViewToggle(ImGuiKey key, bool& active); + static bool CheckMenuViewToggle(ImGuiKey key, bool& active); void AddToast(std::string_view message, float duration = 3.f); private: @@ -72,4 +75,24 @@ float ImGuiScale(); void DuskDebugPad(); +#if defined(_WIN32) || \ + (defined(__APPLE__) && !TARGET_OS_IOS && !TARGET_OS_TV && !TARGET_OS_MACCATALYST) || \ + (defined(__linux__) && !defined(__ANDROID__)) +#define DUSK_CAN_OPEN_DATA_FOLDER 1 + +namespace fs = std::filesystem; + +static void OpenDataFolder() { + const std::string path = fs::absolute(dusk::ConfigPath).generic_string(); +#if defined(_WIN32) + const std::string url = std::string("file:///") + path; +#else + const std::string url = std::string("file://") + path; +#endif + (void)SDL_OpenURL(url.c_str()); +} +#else +#define DUSK_CAN_OPEN_DATA_FOLDER 0 +#endif + #endif // DUSK_IMGUI_HPP diff --git a/src/dusk/imgui/ImGuiEngine.cpp b/src/dusk/imgui/ImGuiEngine.cpp index 4b6a7fb531..3e742de853 100644 --- a/src/dusk/imgui/ImGuiEngine.cpp +++ b/src/dusk/imgui/ImGuiEngine.cpp @@ -219,7 +219,7 @@ void ImGuiEngine_AddTextures() { ImGuiEngine::orgIcon = AddTexture("org-icon.png"); } if (ImGuiEngine::duskLogo == 0) { - ImGuiEngine::duskLogo = AddTexture("logo.png"); + ImGuiEngine::duskLogo = AddTexture("logo-mascot.png"); } } } // namespace dusk diff --git a/src/dusk/imgui/ImGuiMenuTools.cpp b/src/dusk/imgui/ImGuiMenuTools.cpp index d3c062e0f0..2326ba4fa4 100644 --- a/src/dusk/imgui/ImGuiMenuTools.cpp +++ b/src/dusk/imgui/ImGuiMenuTools.cpp @@ -23,24 +23,6 @@ #include #endif -#if defined(_WIN32) || (defined(__APPLE__) && !TARGET_OS_IOS && !TARGET_OS_MACCATALYST) || (defined(__linux__) && !defined(__ANDROID__)) -#define DUSK_CAN_OPEN_DATA_FOLDER 1 - -namespace fs = std::filesystem; - -static void OpenDataFolder() { - const std::string path = fs::absolute(dusk::ConfigPath).generic_string(); -#if defined(_WIN32) - const std::string url = std::string("file:///") + path; -#else - const std::string url = std::string("file://") + path; -#endif - (void)SDL_OpenURL(url.c_str()); -} -#else -#define DUSK_CAN_OPEN_DATA_FOLDER 0 -#endif - namespace aurora::gx { extern bool enableLodBias; } diff --git a/src/dusk/ui/overlay.cpp b/src/dusk/ui/overlay.cpp index c26ff51481..6e401934ce 100644 --- a/src/dusk/ui/overlay.cpp +++ b/src/dusk/ui/overlay.cpp @@ -21,53 +21,58 @@ const Rml::String kDocumentSource = R"RML( )RML"; +constexpr std::array, 3> kAutoSaveLayers{{ + {"inner", "res/org-icon-inner.png"}, + {"outer", "res/org-icon-outer.png"}, + {"center", "res/org-icon-center.png"}, +}}; + Rml::Element* create_toast(Rml::Element* parent, const Toast& toast) { if (toast.type == "autosave") { - Rml::Factory::InstanceElementText(parent, R"RML( - - - - - -)RML"); - return parent->GetFirstChild(); - } else { - auto* elem = append(parent, "toast"); - if (!toast.type.empty()) { - elem->SetClass(toast.type, true); + auto* logo = append(parent, "logo"); + for (const auto [cls, src] : kAutoSaveLayers) { + auto* img = append(logo, "img"); + img->SetClass(cls, true); + img->SetAttribute("src", src); } - { - auto* heading = append(elem, "heading"); - if (toast.title.starts_with("<")) { - heading->SetInnerRML(toast.title); - } else { - auto* span = append(heading, "span"); - span->SetInnerRML(toast.title); - } - if (toast.type == "achievement") { - auto* icon = append(heading, "icon"); - icon->SetClass("trophy", true); - mDoAud_seStartMenu(kSoundAchievementUnlock); - } else if (toast.type == "controller") { - auto* icon = append(heading, "icon"); - icon->SetClass("controller", true); - } - } - { - auto* message = append(elem, "message"); - if (toast.content.starts_with("<")) { - message->SetInnerRML(toast.content); - } else { - auto* span = append(message, "span"); - span->SetInnerRML(toast.content); - } - } - { - auto* progress = append(elem, "progress"); - progress->SetAttribute("value", 1.f); - } - return elem; + return logo; } + + auto* elem = append(parent, "toast"); + if (!toast.type.empty()) { + elem->SetClass(toast.type, true); + } + { + auto* heading = append(elem, "heading"); + if (toast.title.starts_with("<")) { + heading->SetInnerRML(toast.title); + } else { + auto* span = append(heading, "span"); + span->SetInnerRML(toast.title); + } + if (toast.type == "achievement") { + auto* icon = append(heading, "icon"); + icon->SetClass("trophy", true); + mDoAud_seStartMenu(kSoundAchievementUnlock); + } else if (toast.type == "controller") { + auto* icon = append(heading, "icon"); + icon->SetClass("controller", true); + } + } + { + auto* message = append(elem, "message"); + if (toast.content.starts_with("<")) { + message->SetInnerRML(toast.content); + } else { + auto* span = append(message, "span"); + span->SetInnerRML(toast.content); + } + } + { + auto* progress = append(elem, "progress"); + progress->SetAttribute("value", 1.f); + } + return elem; } } // namespace diff --git a/src/dusk/ui/ui.cpp b/src/dusk/ui/ui.cpp index 7b0f792c2b..d664a0ce6a 100644 --- a/src/dusk/ui/ui.cpp +++ b/src/dusk/ui/ui.cpp @@ -117,6 +117,10 @@ const char* connection_state_icon(SDL_JoystickConnectionState state) noexcept { } void handle_event(const SDL_Event& event) noexcept { + if (!aurora::rmlui::is_initialized()) { + return; + } + if (event.type == SDL_EVENT_GAMEPAD_ADDED) { auto* gamepad = SDL_GetGamepadFromID(event.gdevice.which); if (SDL_GamepadConnected(gamepad)) { @@ -205,6 +209,10 @@ Document* top_document() noexcept { } void update() noexcept { + if (!aurora::rmlui::is_initialized()) { + return; + } + input::update_input(); for (const auto& doc : sDocumentStack) { doc->update(); diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index b171b9733d..f7b6cd8865 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -600,6 +600,21 @@ int game_main(int argc, char* argv[]) { dusk::audio::SetEnableReverb(dusk::getSettings().audio.enableReverb); dusk::audio::EnableHrtf = dusk::getSettings().audio.enableHrtf; + // Run ImGui UI loop if Aurora couldn't initialize a backend + if (auroraInfo.backend == BACKEND_NULL) { + launchUILoop(); + dusk::ShutdownCrashReporting(); + dusk::ShutdownFileLogging(); + fflush(stdout); + fflush(stderr); +#ifdef DUSK_DISCORD + dusk::discord::shutdown(); +#endif + dusk::ui::shutdown(); + aurora_shutdown(); + return 0; + } + dusk::ui::initialize(); dusk::ui::push_document(std::make_unique(), true, true); dusk::ui::push_document(std::make_unique(), false); From 3240885bfd115b2086401356f5d16e7c52cee94a Mon Sep 17 00:00:00 2001 From: doop <56421834+dooplecks@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:12:14 +0000 Subject: [PATCH 04/40] Preserved doop work * Disc verification * Add platform and region info to known discs map * Use array over map * Use std::to_array --- src/dusk/iso_validate.cpp | 149 ++++++++++++++++++++++++++++++-------- src/dusk/iso_validate.hpp | 16 +++- 2 files changed, 131 insertions(+), 34 deletions(-) diff --git a/src/dusk/iso_validate.cpp b/src/dusk/iso_validate.cpp index a4e64c5b70..0816bba562 100644 --- a/src/dusk/iso_validate.cpp +++ b/src/dusk/iso_validate.cpp @@ -1,48 +1,94 @@ #include "iso_validate.hpp" #include -#include #include "SDL3/SDL_iostream.h" +namespace { + +constexpr uint8_t hex_nibble_to_u8(char c) { + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + throw std::invalid_argument("invalid hex character"); +} + +constexpr uint64_t parse_u64_hex(std::string_view s) { + if (s.size() != 16) + throw std::invalid_argument("expected 16 hex chars for uint64"); + + uint64_t value = 0; + for (char c : s) { + value = (value << 4) | hex_nibble_to_u8(c); + } + return value; +} + +constexpr XXH128_hash_t parse_xxh128(std::string_view hex) { + if (hex.size() != 32) + throw std::invalid_argument("expected 32 hex chars for XXH128"); + + return XXH128_hash_t{ + .low64 = parse_u64_hex(hex.substr(16, 16)), + .high64 = parse_u64_hex(hex.substr(0, 16)), + }; +} + +} // namespace + namespace dusk::iso { -constexpr const char* TP_GAME_IDS[] = { - "GZ2E01", // GCN USA - "GZ2P01", // GCN PAL - "GZ2J01", // GCN JPN - "RZDE01", // Wii USA - "RZDP01", // Wii PAL - "RZDJ01", // Wii JPN - "RZDK01", // Wii KOR +enum class Platform : u8 { + GameCube, + Wii, }; -constexpr const char* PAL_GAME_IDS[] = { - "GZ2P01", // GCN PAL - "RZDP01", // Wii PAL +enum class Region : u8 { + NorthAmerica, + Europe, + Japan, + Korea, }; -constexpr const char* SUPPORTED_TP_GAME_IDS[] = { - "GZ2E01", // GCN USA - "GZ2P01", // GCN PAL +struct KnownDisc { + std::string_view id; + Platform platform; + Region region; + bool supported = false; + XXH128_hash_t hash{}; + + constexpr KnownDisc(std::string_view id, Platform platform, Region region) + : id(id), platform(platform), region(region) {} + constexpr KnownDisc(std::string_view id, Platform platform, Region region, + const std::string_view hash) + : id(id), platform(platform), region(region), supported(true), hash(parse_xxh128(hash)) {} }; -template -constexpr bool matches(const char (&id)[6], const char* const (&valid)[N]) { - for (auto elem : valid) { - if (strncmp(id, elem, 6) == 0) { - return true; - } +constexpr auto KNOWN_DISCS = std::to_array({ + {"GZ2E01", Platform::GameCube, Region::NorthAmerica, "14e886f08e548a000afde98a3195e788"}, + {"GZ2J01", Platform::GameCube, Region::Japan}, + {"GZ2P01", Platform::GameCube, Region::Europe, "9ef597588b0035ca9e91b333fa9a8a7e"}, + {"RZDE01", Platform::Wii, Region::NorthAmerica}, + {"RZDJ01", Platform::Wii, Region::Japan}, + {"RZDK01", Platform::Wii, Region::Korea}, + {"RZDP01", Platform::Wii, Region::Europe}, +}); + +constexpr const KnownDisc* find_disc(std::string_view id) { + for (const auto& disc : KNOWN_DISCS) { + if (disc.id == id) + return &disc; } - - return false; + return nullptr; } struct NodHandleWrapper { NodHandle* handle; - NodHandleWrapper() : handle(nullptr) { - } + NodHandleWrapper() : handle(nullptr) {} ~NodHandleWrapper() { if (handle != nullptr) { @@ -97,7 +143,35 @@ void StreamClose(void* user_data) { SDL_CloseIO(io); } -ValidationError validate(const char* path) { +ValidationError verify_disc(NodHandle* disc, VerificationStatus& status) { + const auto hashState = XXH3_createState(); + XXH3_128bits_reset(hashState); + + while (!status.shouldCancel) { + size_t bytesAvail; + const auto buf = nod_buf_read(disc, &bytesAvail); + if (!bytesAvail) + break; + + XXH3_128bits_update(hashState, buf, bytesAvail); + + status.bytesRead += bytesAvail; + nod_buf_consume(disc, bytesAvail); + } + + if (status.shouldCancel) { + return ValidationError::Cancelled; + } + + const auto hash = XXH3_128bits_digest(hashState); + if (!XXH128_isEqual(hash, status.knownDisc->hash)) { + return ValidationError::DiscHashMismatch; + } + + return ValidationError::Success; +} + +ValidationError validate(const char* path, VerificationStatus& status) { NodHandleWrapper disc; const auto sdlStream = SDL_IOFromFile(path, "rb"); @@ -117,22 +191,29 @@ ValidationError validate(const char* path) { return convertNodError(result); } + status.bytesTotal = nod_disc_size(disc.handle); + NodDiscHeader header{}; result = nod_disc_header(disc.handle, &header); if (result != NOD_RESULT_OK) { return convertNodError(result); } - if (!matches(header.game_id, TP_GAME_IDS)) { + const auto knownDisc = find_disc(std::string_view(header.game_id, 6)); + + if (!knownDisc) { return ValidationError::WrongGame; } - if (!matches(header.game_id, SUPPORTED_TP_GAME_IDS)) { + status.knownDisc = knownDisc; + + if (!knownDisc->supported) { return ValidationError::WrongVersion; } - return ValidationError::Success; + return verify_disc(disc.handle, status); } + bool isPal(const char* path) { NodHandleWrapper disc; @@ -148,7 +229,9 @@ bool isPal(const char* path) { .close = StreamClose, }; - if (nod_disc_open_stream(&nod_stream, nullptr, &disc.handle) != NOD_RESULT_OK || disc.handle == nullptr) { + if (nod_disc_open_stream(&nod_stream, nullptr, &disc.handle) != NOD_RESULT_OK || + disc.handle == nullptr) + { return false; } @@ -157,6 +240,8 @@ bool isPal(const char* path) { return false; } - return matches(header.game_id, PAL_GAME_IDS); + const auto knownDisc = find_disc(std::string_view(header.game_id, 6)); + + return knownDisc && knownDisc->region == Region::Europe; } -} // namespace dusk::iso \ No newline at end of file +} // namespace dusk::iso diff --git a/src/dusk/iso_validate.hpp b/src/dusk/iso_validate.hpp index d961f052cd..65ae53c8fe 100644 --- a/src/dusk/iso_validate.hpp +++ b/src/dusk/iso_validate.hpp @@ -1,18 +1,30 @@ #ifndef DUSK_ISO_VALIDATE_HPP #define DUSK_ISO_VALIDATE_HPP +#include + namespace dusk::iso { + struct KnownDisc; + enum class ValidationError : u8 { Success = 0, IOError, InvalidImage, WrongGame, WrongVersion, - ExecutableMismatch, + Cancelled, + DiscHashMismatch, Unknown }; - ValidationError validate(const char* path); + struct VerificationStatus { + size_t bytesRead = 0; + size_t bytesTotal = 0; + const KnownDisc* knownDisc = nullptr; + bool shouldCancel = false; + }; + + ValidationError validate(const char* path, VerificationStatus& status); bool isPal(const char* path); } From 72c20f4dd06f11161ab98a0b0aacc51afe44c880 Mon Sep 17 00:00:00 2001 From: Irastris Date: Wed, 6 May 2026 00:12:58 -0400 Subject: [PATCH 05/40] Add stylesheet rules --- res/rml/prelaunch.rcss | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/res/rml/prelaunch.rcss b/res/rml/prelaunch.rcss index 1c217e466c..1253e58f8b 100644 --- a/res/rml/prelaunch.rcss +++ b/res/rml/prelaunch.rcss @@ -134,8 +134,14 @@ hero img { decorator: horizontal-gradient(#00000000 #00000000); } +#menu-list button:disabled { + opacity: 0.75; + cursor: default; + decorator: horizontal-gradient(#00000000 #00000000); +} + #menu-list button.anim-done { - transition: decorator color 0.1s linear-in-out; + transition: decorator color opacity 0.1s linear-in-out; } #menu-list button:hover, @@ -210,8 +216,16 @@ body.mirrored version-info { color: #FFC9C9; } +#disc-status[status=verifying] { + color: #FFFFFF; +} + +#disc-status[status=mismatch] { + color: #FFD6A7; +} + #disc-status icon { - display: block; + display: none; width: 24dp; height: 24dp; font-family: "Material Symbols Rounded"; @@ -219,6 +233,10 @@ body.mirrored version-info { font-size: 24dp; } +#disc-status[status] icon { + display: block; +} + #disc-status[status=good] icon { decorator: text("" center center); } @@ -227,6 +245,14 @@ body.mirrored version-info { decorator: text("" center center); } +#disc-status[status=verifying] icon { + decorator: text("" center center); +} + +#disc-status[status=mismatch] icon { + decorator: text("" center center); +} + #disc-version { font-size: 20dp; } From 4404fce369f5a45fd9b89127ff0e4f31c9401ec3 Mon Sep 17 00:00:00 2001 From: Irastris Date: Wed, 6 May 2026 16:08:48 -0400 Subject: [PATCH 06/40] Do hash-based verification of disc images --- src/dusk/iso_validate.cpp | 17 +++++++++-------- src/dusk/iso_validate.hpp | 8 ++++---- src/dusk/ui/prelaunch.cpp | 13 ++++++------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/dusk/iso_validate.cpp b/src/dusk/iso_validate.cpp index 0816bba562..a2064102de 100644 --- a/src/dusk/iso_validate.cpp +++ b/src/dusk/iso_validate.cpp @@ -1,8 +1,8 @@ #include "iso_validate.hpp" #include - -#include "SDL3/SDL_iostream.h" +#include +#include namespace { @@ -147,7 +147,7 @@ ValidationError verify_disc(NodHandle* disc, VerificationStatus& status) { const auto hashState = XXH3_createState(); XXH3_128bits_reset(hashState); - while (!status.shouldCancel) { + while (true) { size_t bytesAvail; const auto buf = nod_buf_read(disc, &bytesAvail); if (!bytesAvail) @@ -159,13 +159,9 @@ ValidationError verify_disc(NodHandle* disc, VerificationStatus& status) { nod_buf_consume(disc, bytesAvail); } - if (status.shouldCancel) { - return ValidationError::Cancelled; - } - const auto hash = XXH3_128bits_digest(hashState); if (!XXH128_isEqual(hash, status.knownDisc->hash)) { - return ValidationError::DiscHashMismatch; + return ValidationError::HashMismatch; } return ValidationError::Success; @@ -214,6 +210,11 @@ ValidationError validate(const char* path, VerificationStatus& status) { return verify_disc(disc.handle, status); } +ValidationError validate(const char* path) { + VerificationStatus status{}; + return validate(path, status); +} + bool isPal(const char* path) { NodHandleWrapper disc; diff --git a/src/dusk/iso_validate.hpp b/src/dusk/iso_validate.hpp index 65ae53c8fe..23e5489d71 100644 --- a/src/dusk/iso_validate.hpp +++ b/src/dusk/iso_validate.hpp @@ -7,14 +7,13 @@ namespace dusk::iso { struct KnownDisc; enum class ValidationError : u8 { - Success = 0, + Unknown = 0, IOError, InvalidImage, WrongGame, WrongVersion, - Cancelled, - DiscHashMismatch, - Unknown + HashMismatch, + Success }; struct VerificationStatus { @@ -25,6 +24,7 @@ namespace dusk::iso { }; ValidationError validate(const char* path, VerificationStatus& status); + ValidationError validate(const char* path); bool isPal(const char* path); } diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index f4cf78c9d3..b091239bc2 100644 --- a/src/dusk/ui/prelaunch.cpp +++ b/src/dusk/ui/prelaunch.cpp @@ -56,6 +56,8 @@ constexpr std::array kDiscFileFilters{{ static std::string get_error_msg(iso::ValidationError error) { switch (error) { + default: + return "The selected disc image could not be validated."; case iso::ValidationError::IOError: return "Unable to read the selected file."; case iso::ValidationError::InvalidImage: @@ -64,22 +66,19 @@ static std::string get_error_msg(iso::ValidationError error) { return "The selected game is not supported by Dusk."; case iso::ValidationError::WrongVersion: return "Dusk currently supports GameCube USA and PAL disc images only."; + case iso::ValidationError::HashMismatch: + return "The selected disc image did not pass hash verification, it may be corrupt or modified."; case iso::ValidationError::Success: return "The selected disc image is valid."; - default: - return "The selected disc image could not be validated."; } } void file_dialog_callback(void*, const char* path, const char* error) { - auto& state = prelaunch_state(); - if (error != nullptr) { - return; - } - if (path == nullptr) { + if (path == nullptr || error != nullptr) { return; } + auto& state = prelaunch_state(); const auto validation = iso::validate(path); if (validation != iso::ValidationError::Success) { state.errorString = escape(get_error_msg(validation)); From 47593d0eb43a342bf1131156b50231b1b895316c Mon Sep 17 00:00:00 2001 From: Irastris Date: Wed, 6 May 2026 16:17:18 -0400 Subject: [PATCH 07/40] Allow hash-mismatched discs to be loaded with user confirmation --- src/dusk/ui/prelaunch.cpp | 150 +++++++++++++++++++++++++++++--------- src/dusk/ui/prelaunch.hpp | 9 ++- src/dusk/ui/settings.cpp | 23 +----- src/m_Do/m_Do_main.cpp | 7 +- 4 files changed, 126 insertions(+), 63 deletions(-) diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index b091239bc2..02fabde70f 100644 --- a/src/dusk/ui/prelaunch.cpp +++ b/src/dusk/ui/prelaunch.cpp @@ -80,16 +80,22 @@ void file_dialog_callback(void*, const char* path, const char* error) { auto& state = prelaunch_state(); const auto validation = iso::validate(path); - if (validation != iso::ValidationError::Success) { - state.errorString = escape(get_error_msg(validation)); + if (validation == iso::ValidationError::Success) { + state.selectedDiscPath = path; + state.errorString.clear(); + state.pendingDiscPath.clear(); + state.userAcceptedDiscPath.clear(); + getSettings().backend.isoPath.setValue(state.selectedDiscPath); + config::Save(); + refresh_state(); return; } - - state.selectedDiscPath = path; - state.errorString.clear(); - getSettings().backend.isoPath.setValue(state.selectedDiscPath); - config::Save(); - refresh_state(); + if (validation == iso::ValidationError::HashMismatch) { + state.pendingDiscPath = path; + } else { + state.pendingDiscPath.clear(); + } + state.errorString = escape(get_error_msg(validation)); } PrelaunchState sPrelaunchState; @@ -100,13 +106,86 @@ PrelaunchState& prelaunch_state() noexcept { void refresh_state() noexcept { auto& state = prelaunch_state(); - const auto validation = iso::validate(state.selectedDiscPath.c_str()); - if (state.selectedDiscPath.empty() || validation != iso::ValidationError::Success) { + if (state.selectedDiscPath.empty()) { state.selectedDiscIsValid = false; return; } - state.selectedDiscIsValid = true; - state.selectedDiscIsPal = iso::isPal(state.selectedDiscPath.c_str()); + if (!state.userAcceptedDiscPath.empty() && + state.selectedDiscPath == state.userAcceptedDiscPath) { + state.selectedDiscIsValid = true; + state.selectedDiscIsPal = iso::isPal(state.selectedDiscPath.c_str()); + return; + } + + const auto validation = iso::validate(state.selectedDiscPath.c_str()); + // Allow HashMismatch, so that the user is not prompted to accept the warning on every boot. + if (validation >= iso::ValidationError::HashMismatch) { + state.selectedDiscIsValid = true; + state.selectedDiscIsPal = iso::isPal(state.selectedDiscPath.c_str()); + state.userAcceptedDiscPath.clear(); + return; + } + state.selectedDiscIsValid = false; +} + +void try_push_verification_modal(Document& host) { + auto& state = prelaunch_state(); + if (state.errorString.empty()) { + return; + } + + auto dismiss = [](Modal& modal) { + auto& state = prelaunch_state(); + state.errorString.clear(); + state.pendingDiscPath.clear(); + modal.pop(); + }; + + if (!state.pendingDiscPath.empty()) { + const Rml::String bodyRml = state.errorString + "

You may proceed at your own risk."; + auto acceptHashMismatch = [](Modal& modal) { + auto& st = prelaunch_state(); + std::string path = std::move(st.pendingDiscPath); + st.pendingDiscPath.clear(); + st.errorString.clear(); + st.selectedDiscPath = path; + st.userAcceptedDiscPath = path; + getSettings().backend.isoPath.setValue(path); + config::Save(); + refresh_state(); + modal.pop(); + }; + host.push(std::make_unique(Modal::Props{ + .title = "Disc verification", + .bodyRml = bodyRml, + .actions = + { + ModalAction{ + .label = "Cancel", + .onPressed = dismiss, + }, + ModalAction{ + .label = "Continue anyway", + .onPressed = acceptHashMismatch, + }, + }, + .onDismiss = dismiss, + })); + return; + } + + host.push(std::make_unique(Modal::Props{ + .title = "Disc verification", + .bodyRml = state.errorString, + .actions = + { + ModalAction{ + .label = "OK", + .onPressed = dismiss, + }, + }, + .onDismiss = dismiss, + })); } void ensure_initialized() noexcept { @@ -117,7 +196,9 @@ void ensure_initialized() noexcept { state.selectedDiscPath = getSettings().backend.isoPath; state.initialDiscPath = state.selectedDiscPath; - if (iso::validate(state.initialDiscPath.c_str()) == iso::ValidationError::Success) { + const auto& res = iso::validate(state.initialDiscPath.c_str()); + if (res >= iso::ValidationError::HashMismatch) { + state.initialDiscValidationRes = res; state.initialDiscIsPal = iso::isPal(state.initialDiscPath.c_str()); } state.initialLanguage = getSettings().game.language; @@ -287,28 +368,14 @@ void Prelaunch::update() { ensure_initialized(); try_apply_mirrored_layout(mDocument); - auto& state = prelaunch_state(); - if (!state.errorString.empty() && top_document() == this) { - auto dismiss = [](Modal& modal) { - prelaunch_state().errorString.clear(); - modal.pop(); - }; - push(std::make_unique(Modal::Props{ - .title = "Invalid disc image", - .bodyRml = state.errorString, - .actions = - { - ModalAction{ - .label = "OK", - .onPressed = dismiss, - }, - }, - .onDismiss = dismiss, - })); + if (top_document() == this) { + try_push_verification_modal(*this); } - const bool hasValidPath = prelaunch_state().selectedDiscIsValid; - mDocument->SetClass("disc-ready", hasValidPath); + const auto& state = prelaunch_state(); + + const bool hasValidPath = state.selectedDiscIsValid; + mDocument->SetClass("disc-ready", IsGameLaunched); if (hasValidPath) { if (getSettings().backend.skipPreLaunchUI) { hide(true); @@ -327,17 +394,28 @@ void Prelaunch::update() { const auto discStatusLabel = mDiscStatus->GetElementById("disc-status-label"); + const bool discHashMismatchAccepted = + hasValidPath && !state.userAcceptedDiscPath.empty() && + state.selectedDiscPath == state.userAcceptedDiscPath; + if (mDiscStatus != nullptr && discStatusLabel != nullptr) { - if (hasValidPath) { + if (state.initialDiscValidationRes == iso::ValidationError::Success) { mDiscStatus->SetAttribute("status", "good"); discStatusLabel->SetInnerRML("Disc ready."); + } else if (state.initialDiscValidationRes == iso::ValidationError::HashMismatch) { + mDiscStatus->SetAttribute("status", "mismatch"); + discStatusLabel->SetInnerRML("Disc hash mismatch."); + } else { + mDiscStatus->RemoveAttribute("status"); + discStatusLabel->SetInnerRML("No disc image found."); } } if (mDiscDetail != nullptr) { if (hasValidPath) { mDiscDetail->SetProperty(Rml::PropertyId::Display, Rml::Style::Display::Block); - mDiscDetail->SetInnerRML( - prelaunch_state().initialDiscIsPal ? "GameCube • EUR" : "GameCube • USA"); + Rml::String innerRML = "GameCube • "; + innerRML += state.initialDiscIsPal ? "EUR" : "USA"; + mDiscDetail->SetInnerRML(innerRML); } else { mDiscDetail->SetProperty(Rml::PropertyId::Display, Rml::Style::Display::None); } diff --git a/src/dusk/ui/prelaunch.hpp b/src/dusk/ui/prelaunch.hpp index e4d542cc2d..73033f9816 100644 --- a/src/dusk/ui/prelaunch.hpp +++ b/src/dusk/ui/prelaunch.hpp @@ -1,5 +1,6 @@ #pragma once +#include "dusk/iso_validate.hpp" #include "button.hpp" #include "document.hpp" @@ -39,12 +40,15 @@ struct PrelaunchState { std::string selectedDiscPath; bool selectedDiscIsValid = false; bool selectedDiscIsPal = false; - std::string errorString; - bool initialDiscIsPal = false; std::string initialDiscPath; + iso::ValidationError initialDiscValidationRes = iso::ValidationError::Unknown; + bool initialDiscIsPal = false; GameLanguage initialLanguage = GameLanguage::English; std::string initialGraphicsBackend; int initialCardFileType = 0; + std::string errorString; + std::string pendingDiscPath; + std::string userAcceptedDiscPath; }; PrelaunchState& prelaunch_state() noexcept; @@ -52,5 +56,6 @@ void ensure_initialized() noexcept; void refresh_state() noexcept; void open_iso_picker() noexcept; bool is_restart_pending() noexcept; +void try_push_verification_modal(Document& host); } // namespace dusk::ui diff --git a/src/dusk/ui/settings.cpp b/src/dusk/ui/settings.cpp index 7979b22116..ddb6a4cd31 100644 --- a/src/dusk/ui/settings.cpp +++ b/src/dusk/ui/settings.cpp @@ -17,8 +17,6 @@ #include -#include "modal.hpp" - namespace dusk::ui { namespace { @@ -869,27 +867,8 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { } void SettingsWindow::update() { - // Show disc validation error message if present if (mPrelaunch && top_document() == this) { - auto& state = prelaunch_state(); - if (!state.errorString.empty()) { - auto dismissInvalidDisc = [](Modal& modal) { - prelaunch_state().errorString.clear(); - modal.pop(); - }; - push_document(std::make_unique(Modal::Props{ - .title = "Invalid disc image", - .bodyRml = state.errorString, - .actions = - { - ModalAction{ - .label = "OK", - .onPressed = dismissInvalidDisc, - }, - }, - .onDismiss = dismissInvalidDisc, - })); - } + try_push_verification_modal(*this); } Window::update(); diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index f7b6cd8865..e996589b59 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -621,7 +621,7 @@ int game_main(int argc, char* argv[]) { // Invalidate a bad saved isoPath so that Dusk can't get blocked from starting up const std::string p = dusk::getSettings().backend.isoPath; - if (!p.empty() && dusk::iso::validate(p.c_str()) != dusk::iso::ValidationError::Success) { + if (!p.empty() && dusk::iso::validate(p.c_str()) < dusk::iso::ValidationError::HashMismatch) { dusk::getSettings().backend.isoPath.setValue(""); } @@ -629,7 +629,7 @@ int game_main(int argc, char* argv[]) { bool dvd_opened = false; if (parsed_arg_options.count("dvd")) { dvd_path = parsed_arg_options["dvd"].as(); - if (dusk::iso::validate(dvd_path.c_str()) == dusk::iso::ValidationError::Success) { + if (dusk::iso::validate(dvd_path.c_str()) >= dusk::iso::ValidationError::HashMismatch) { DuskLog.info("Loading DVD image from command line: {}", dvd_path); dvd_opened = aurora_dvd_open(dvd_path.c_str()); if (!dvd_opened) { @@ -668,7 +668,8 @@ int game_main(int argc, char* argv[]) { if (dvd_path.empty()) { DuskLog.fatal("No DVD image specified, unable to boot!"); } - if (dusk::iso::validate(dvd_path.c_str()) != dusk::iso::ValidationError::Success) { + if (!dusk::IsGameLaunched && + dusk::iso::validate(dvd_path.c_str()) < dusk::iso::ValidationError::HashMismatch) { DuskLog.fatal("DVD image failed verification: {}", dvd_path); } DuskLog.info("Loading DVD image: {}", dvd_path); From b5f98f69dbda5bf72476092435587492a1c66df7 Mon Sep 17 00:00:00 2001 From: Irastris Date: Wed, 6 May 2026 19:12:20 -0400 Subject: [PATCH 08/40] Redesign Modal --- res/rml/window.rcss | 65 ++++++++++++++++++++++++++++++--------- src/dusk/ui/modal.cpp | 17 ++++++++-- src/dusk/ui/modal.hpp | 2 ++ src/dusk/ui/prelaunch.cpp | 6 ++-- 4 files changed, 70 insertions(+), 20 deletions(-) diff --git a/res/rml/window.rcss b/res/rml/window.rcss index 24f616e500..55ea0294eb 100644 --- a/res/rml/window.rcss +++ b/res/rml/window.rcss @@ -44,7 +44,7 @@ window.preset { } window.modal { - max-width: 816dp; + max-width: 640dp; } window[open] { @@ -116,12 +116,6 @@ window content pane > spacer { pointer-events: none; } -window modal { - padding: 32dp; - gap: 20dp; - flex: 0 1 auto; -} - scrollbarvertical { width: 8dp; margin: 4dp 4dp 4dp 0; @@ -209,9 +203,8 @@ button:not(:disabled):active { } button.modal-btn { - font-size: 20dp; - padding: 16dp 10dp; flex: 1 1 0; + text-align: center; } select-button { @@ -274,6 +267,8 @@ select-button input { } icon { + width: 1em; + height: 1em; font-family: "Material Symbols Rounded"; font-weight: normal; display: inline-block; @@ -281,10 +276,11 @@ icon { } icon.warning { - width: 1em; - height: 1em; decorator: text("" center center); - color: #ffcc00; +} + +icon.error { + decorator: text("" center center); } .achievement-row { @@ -422,19 +418,58 @@ button.preset-btn { .modal-dialog { display: flex; - flex-flow: column; - padding: 16dp; + flex-direction: column; + align-items: flex-start; + padding: 24dp; gap: 20dp; flex: 0 1 auto; + width: 100%; + text-align: left; +} + +.modal-header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + width: 100%; + flex: 0 0 auto; + gap: 16dp; +} + +.modal-header icon { + font-size: 24dp; + color: #92875B; +} + +.modal-title { + display: block; + font-family: "Fira Sans Condensed"; + font-weight: bold; + text-transform: uppercase; + font-size: 18dp; + color: #92875B; + flex: 1 1 auto; +} + +.modal-body { + display: block; + width: 100%; + flex: 0 1 auto; min-width: 0; + font-size: 20dp; + color: #FFFFFF; + font-weight: normal; } .modal-actions { display: flex; flex-direction: row; flex-wrap: nowrap; + justify-content: stretch; align-items: stretch; gap: 12dp; - padding-top: 12dp; width: 100%; + flex: 0 0 auto; + padding-top: 4dp; } diff --git a/src/dusk/ui/modal.cpp b/src/dusk/ui/modal.cpp index cd46f68528..bb8ed9a473 100644 --- a/src/dusk/ui/modal.cpp +++ b/src/dusk/ui/modal.cpp @@ -4,12 +4,23 @@ namespace dusk::ui { Modal::Modal(Props props) : WindowSmall("modal", "modal-dialog"), mProps(std::move(props)) { - auto* title = append(mDialog, "div"); - title->SetClass("preset-title", true); + auto* header = append(mDialog, "div"); + header->SetClass("modal-header", true); + + auto* title = append(header, "div"); + title->SetClass("modal-title", true); title->SetInnerRML(mProps.title); + if (mProps.isWarning) { + auto* icon = append(header, "icon"); + icon->SetClass("warning", true); + } else if ( mProps.isError ) { + auto* icon = append(header, "icon"); + icon->SetClass("error", true); + } + auto* body = append(mDialog, "div"); - body->SetClass("preset-intro", true); + body->SetClass("modal-body", true); body->SetInnerRML(mProps.bodyRml); auto* actions = append(mDialog, "div"); diff --git a/src/dusk/ui/modal.hpp b/src/dusk/ui/modal.hpp index 585966b98d..4d3c9e7870 100644 --- a/src/dusk/ui/modal.hpp +++ b/src/dusk/ui/modal.hpp @@ -18,6 +18,8 @@ public: Rml::String bodyRml; std::vector actions; std::function onDismiss; + bool isWarning = false; + bool isError = false; }; explicit Modal(Props props); diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index 02fabde70f..7ef7e5e66c 100644 --- a/src/dusk/ui/prelaunch.cpp +++ b/src/dusk/ui/prelaunch.cpp @@ -156,7 +156,7 @@ void try_push_verification_modal(Document& host) { modal.pop(); }; host.push(std::make_unique(Modal::Props{ - .title = "Disc verification", + .title = "Disc verification warning", .bodyRml = bodyRml, .actions = { @@ -170,12 +170,13 @@ void try_push_verification_modal(Document& host) { }, }, .onDismiss = dismiss, + .isWarning = true, })); return; } host.push(std::make_unique(Modal::Props{ - .title = "Disc verification", + .title = "Disc verification error", .bodyRml = state.errorString, .actions = { @@ -185,6 +186,7 @@ void try_push_verification_modal(Document& host) { }, }, .onDismiss = dismiss, + .isError = true, })); } From 18eb0692f099bf235d0bc717ce8b21fc0e1d9ead Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 6 May 2026 17:34:08 -0600 Subject: [PATCH 09/40] UI: "No controller" & menu notifications Resolves #629 Resolves #678 --- res/rml/overlay.rcss | 50 +++++++++++- src/dusk/imgui/ImGuiConsole.cpp | 4 - src/dusk/ui/overlay.cpp | 139 ++++++++++++++++++++++++++++++-- src/dusk/ui/overlay.hpp | 3 + src/dusk/ui/prelaunch.cpp | 3 +- src/dusk/ui/ui.cpp | 11 +++ src/dusk/ui/ui.hpp | 2 + 7 files changed, 200 insertions(+), 12 deletions(-) diff --git a/res/rml/overlay.rcss b/res/rml/overlay.rcss index cf90037f57..0f9e4ffd4e 100644 --- a/res/rml/overlay.rcss +++ b/res/rml/overlay.rcss @@ -110,6 +110,47 @@ toast.achievement heading { color: #C2A42D; } +toast.controller-warning { + top: auto; + right: auto; + bottom: 40dp; + left: 50%; + width: 440dp; + max-width: 90%; + transform: translateX(-50%) scale(0.9); +} + +toast.controller-warning[open] { + transform: translateX(-50%) scale(1); +} + +toast.controller-warning heading { + color: #C2A42D; +} + +toast.menu-notification { + top: 40dp; + right: auto; + bottom: auto; + left: 50%; + max-width: 90%; + transform: translateX(-50%) scale(0.9); +} + +toast.menu-notification[open] { + transform: translateX(-50%) scale(1); +} + +toast.menu-notification message { + align-items: center; + text-align: center; +} + +toast.menu-notification message row { + align-items: center; + gap: 6dp; +} + icon { font-family: "Material Symbols Rounded"; font-weight: normal; @@ -138,6 +179,13 @@ icon.controller { decorator: text("" center center); } +icon.warning { + width: 24dp; + height: 24dp; + font-size: 24dp; + decorator: text("" center center); +} + logo { position: absolute; width: 100dp; @@ -186,4 +234,4 @@ logo img.outer { to { transform: rotate(360deg); } -} \ No newline at end of file +} diff --git a/src/dusk/imgui/ImGuiConsole.cpp b/src/dusk/imgui/ImGuiConsole.cpp index a18d257690..86e4312b00 100644 --- a/src/dusk/imgui/ImGuiConsole.cpp +++ b/src/dusk/imgui/ImGuiConsole.cpp @@ -291,10 +291,6 @@ namespace dusk { ImGui::PopStyleColor(); if (dusk::IsGameLaunched && !m_isLaunchInitialized) { - AddToast(ImGui::GetIO().MouseSource == ImGuiMouseSource_TouchScreen ? - "3-finger tap to toggle menu"s : - "Press F1 to toggle menu"s, - 4.f); m_isLaunchInitialized = true; if (getSettings().game.liveSplitEnabled) { dusk::speedrun::connectLiveSplit(); diff --git a/src/dusk/ui/overlay.cpp b/src/dusk/ui/overlay.cpp index 6e401934ce..c11ad51b78 100644 --- a/src/dusk/ui/overlay.cpp +++ b/src/dusk/ui/overlay.cpp @@ -1,11 +1,13 @@ #include "overlay.hpp" #include "aurora/lib/logging.hpp" -#include "magic_enum.hpp" - -#include - #include "dusk/achievements.h" +#include "magic_enum.hpp" +#include "window.hpp" + +#include +#include +#include namespace dusk::ui { namespace { @@ -27,6 +29,8 @@ constexpr std::array, 3> kAutoSaveLayers{{ {"center", "res/org-icon-center.png"}, }}; +constexpr auto kMenuNotificationDuration = std::chrono::milliseconds(2500); + Rml::Element* create_toast(Rml::Element* parent, const Toast& toast) { if (toast.type == "autosave") { auto* logo = append(parent, "logo"); @@ -75,6 +79,78 @@ Rml::Element* create_toast(Rml::Element* parent, const Toast& toast) { return elem; } +Rml::Element* create_controller_warning(Rml::Element* parent) { + auto* elem = append(parent, "toast"); + elem->SetClass("controller-warning", true); + + auto* heading = append(elem, "heading"); + auto* title = append(heading, "span"); + title->SetInnerRML("No controller assigned"); + auto* icon = append(heading, "icon"); + icon->SetClass("warning", true); + + auto* message = append(elem, "message"); + auto* content = append(message, "span"); + content->SetInnerRML("Configure controller port 1 in Settings."); + + return elem; +} + +SDL_Gamepad* gamepad_for_port(u32 port) noexcept { + const s32 index = PADGetIndexForPort(port); + if (index < 0) { + return nullptr; + } + return PADGetSDLGamepadForIndex(static_cast(index)); +} + +Rml::String back_button_name() { + if (auto* gamepad = gamepad_for_port(PAD_CHAN0)) { + switch (SDL_GetGamepadType(gamepad)) { + case SDL_GAMEPAD_TYPE_PS3: + return "Select"; + case SDL_GAMEPAD_TYPE_PS4: + return "Share"; + case SDL_GAMEPAD_TYPE_PS5: + return "Create"; + case SDL_GAMEPAD_TYPE_XBOX360: + return "Back"; + case SDL_GAMEPAD_TYPE_XBOXONE: + return "View"; + case SDL_GAMEPAD_TYPE_GAMECUBE: + return "R + Start"; + default: + break; + } + } + return "Back"; +} + +Rml::Element* create_menu_notification(Rml::Element* parent) { + auto* elem = append(parent, "toast"); + elem->SetClass("menu-notification", true); + + auto* message = append(elem, "message"); + auto* row = append(message, "row"); + append(row, "span")->SetInnerRML("Press F1 or"); + auto* icon = append(row, "icon"); + icon->SetClass("controller", true); + append(row, "span")->SetInnerRML(escape(back_button_name())); + append(row, "span")->SetInnerRML("to open menu"); + + return elem; +} + +void remove_element(Rml::Element*& elem) noexcept { + if (elem == nullptr) { + return; + } + if (auto* parent = elem->GetParentNode()) { + parent->RemoveChild(elem); + } + elem = nullptr; +} + } // namespace Overlay::Overlay() : Document(kDocumentSource) { @@ -86,6 +162,15 @@ Overlay::Overlay() : Document(kDocumentSource) { { mCurrentToast->SetPseudoClass("done", true); } + } else if (mControllerWarning != nullptr && + event.GetTargetElement() == mControllerWarning && + !mControllerWarning->HasAttribute("open")) + { + mControllerWarning->SetPseudoClass("done", true); + } else if (mMenuNotification != nullptr && event.GetTargetElement() == mMenuNotification && + !mMenuNotification->HasAttribute("open")) + { + mMenuNotification->SetPseudoClass("done", true); } }); } @@ -98,6 +183,49 @@ void Overlay::show() { void Overlay::update() { Document::update(); + if (mDocument == nullptr) { + return; + } + + const bool showControllerWarning = + PADGetIndexForPort(PAD_CHAN0) < 0 && dynamic_cast(top_document()) == nullptr; + if (showControllerWarning && mControllerWarning == nullptr) { + mControllerWarning = create_controller_warning(mDocument); + } else if (showControllerWarning && mControllerWarning != nullptr) { + mControllerWarning->SetAttribute("open", ""); + mControllerWarning->SetPseudoClass("opened", true); + mControllerWarning->SetPseudoClass("done", false); + } else if (!showControllerWarning && mControllerWarning != nullptr) { + if (mControllerWarning->IsPseudoClassSet("done") || + !mControllerWarning->IsPseudoClassSet("opened")) + { + remove_element(mControllerWarning); + } else { + mControllerWarning->RemoveAttribute("open"); + } + } + + if (mMenuNotification != nullptr) { + if (clock::now() >= mMenuNotificationStartTime + kMenuNotificationDuration) { + if (mMenuNotification->IsPseudoClassSet("done") || + !mMenuNotification->IsPseudoClassSet("opened")) + { + remove_element(mMenuNotification); + } else { + mMenuNotification->RemoveAttribute("open"); + } + } else { + mMenuNotification->SetAttribute("open", ""); + mMenuNotification->SetPseudoClass("opened", true); + mMenuNotification->SetPseudoClass("done", false); + } + } + if (consume_menu_notification_request()) { + if (mMenuNotification == nullptr) { + mMenuNotification = create_menu_notification(mDocument); + } + mMenuNotificationStartTime = clock::now(); + } auto& toasts = get_toasts(); if (mCurrentToast == nullptr) { @@ -123,8 +251,7 @@ void Overlay::update() { // Fallback for large gaps in time where we never actually opened it !mCurrentToast->IsPseudoClassSet("opened")) { - mCurrentToast->GetParentNode()->RemoveChild(mCurrentToast); - mCurrentToast = nullptr; + remove_element(mCurrentToast); toasts.pop_front(); } else { mCurrentToast->RemoveAttribute("open"); diff --git a/src/dusk/ui/overlay.hpp b/src/dusk/ui/overlay.hpp index 71e2e72470..6a29ca26e4 100644 --- a/src/dusk/ui/overlay.hpp +++ b/src/dusk/ui/overlay.hpp @@ -17,7 +17,10 @@ protected: bool handle_nav_command(Rml::Event& event, NavCommand cmd) override; Rml::Element* mCurrentToast = nullptr; + Rml::Element* mControllerWarning = nullptr; + Rml::Element* mMenuNotification = nullptr; clock::time_point mCurrentToastStartTime; + clock::time_point mMenuNotificationStartTime; }; } // namespace dusk::ui diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index f4cf78c9d3..6fe9075891 100644 --- a/src/dusk/ui/prelaunch.cpp +++ b/src/dusk/ui/prelaunch.cpp @@ -178,6 +178,7 @@ Prelaunch::Prelaunch() : Document(kDocumentSource), mRoot(mDocument->GetElementB } mDoAud_seStartMenu(kSoundPlay); + show_menu_notification(); if (getSettings().audio.menuSounds) { JAISoundHandle* handle = g_mEnvSeMgr.field_0x144.getHandle(); @@ -199,7 +200,7 @@ Prelaunch::Prelaunch() : Document(kDocumentSource), mRoot(mDocument->GetElementB }); apply_intro_animation(mMenuButtons.back()->root(), "delay-1"); - mMenuButtons.push_back(std::make_unique