From c50d77730926635212e19da90ba4d47d7de28ab6 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 6 May 2026 23:21:43 -0600 Subject: [PATCH] Progress modal & persist validation state --- include/dusk/settings.h | 13 ++ res/rml/prelaunch.rcss | 8 + res/rml/window.rcss | 37 ++++ src/dusk/config.cpp | 1 + src/dusk/iso_validate.cpp | 62 ++++-- src/dusk/iso_validate.hpp | 49 +++-- src/dusk/settings.cpp | 2 + src/dusk/ui/modal.cpp | 9 +- src/dusk/ui/modal.hpp | 1 + src/dusk/ui/overlay.cpp | 3 +- src/dusk/ui/prelaunch.cpp | 419 +++++++++++++++++++++++++++++++++----- src/dusk/ui/prelaunch.hpp | 2 +- src/dusk/ui/ui.cpp | 19 +- 13 files changed, 527 insertions(+), 98 deletions(-) diff --git a/include/dusk/settings.h b/include/dusk/settings.h index 766fa506c1..992a52999c 100644 --- a/include/dusk/settings.h +++ b/include/dusk/settings.h @@ -21,6 +21,12 @@ enum class GameLanguage : u8 { Italian = OS_LANGUAGE_ITALIAN, }; +enum class DiscVerificationState : u8 { + Unknown = 0, + Success, + HashMismatch, +}; + namespace config { template <> struct ConfigEnumRange { @@ -33,6 +39,12 @@ struct ConfigEnumRange { static constexpr auto min = GameLanguage::English; static constexpr auto max = GameLanguage::Italian; }; + +template <> +struct ConfigEnumRange { + static constexpr auto min = DiscVerificationState::Unknown; + static constexpr auto max = DiscVerificationState::HashMismatch; +}; } // Persistent user settings @@ -150,6 +162,7 @@ struct UserSettings { struct { ConfigVar isoPath; + ConfigVar isoVerification; ConfigVar graphicsBackend; ConfigVar skipPreLaunchUI; ConfigVar showPipelineCompilation; diff --git a/res/rml/prelaunch.rcss b/res/rml/prelaunch.rcss index 1253e58f8b..ecd9f38df4 100644 --- a/res/rml/prelaunch.rcss +++ b/res/rml/prelaunch.rcss @@ -224,6 +224,10 @@ body.mirrored version-info { color: #FFD6A7; } +#disc-status[status=unknown] { + color: rgba(224, 219, 200, 65%); +} + #disc-status icon { display: none; width: 24dp; @@ -253,6 +257,10 @@ body.mirrored version-info { decorator: text("" center center); } +#disc-status[status=unknown] icon { + decorator: text("" center center); +} + #disc-version { font-size: 20dp; } diff --git a/res/rml/window.rcss b/res/rml/window.rcss index c40bd61227..bc8050a29f 100644 --- a/res/rml/window.rcss +++ b/res/rml/window.rcss @@ -279,6 +279,10 @@ icon.error { decorator: text("" center center); } +icon.verifying { + decorator: text("" center center); +} + icon.celebration { decorator: text("" center center); } @@ -399,6 +403,10 @@ button.achievement-clear { text-align: left; } +window.modal.danger { + border: 2dp #852221; +} + .modal-header { display: flex; flex-direction: row; @@ -424,6 +432,11 @@ button.achievement-clear { flex: 1 1 auto; } +window.modal.danger .modal-title, +window.modal.danger .modal-header icon { + color: #B3261E; +} + .modal-body { display: block; width: 100%; @@ -434,6 +447,30 @@ button.achievement-clear { font-weight: normal; } +.verification-progress { + display: flex; + flex-direction: column; + gap: 10dp; + width: 100%; +} + +.verification-file { + display: block; + font-size: 17dp; + color: #FFFFFF; +} + +progressbar.verification-progress-bar { + height: 8dp; + margin: 2dp 0 0 0; +} + +.verification-detail { + display: block; + font-size: 14dp; + color: rgba(224, 219, 200, 65%); +} + .modal-actions { display: flex; flex-direction: row; diff --git a/src/dusk/config.cpp b/src/dusk/config.cpp index 856cdce4a3..f4af7a2961 100644 --- a/src/dusk/config.cpp +++ b/src/dusk/config.cpp @@ -154,6 +154,7 @@ namespace dusk::config { template class ConfigImpl; template class ConfigImpl; template class ConfigImpl; + template class ConfigImpl; template class ConfigImpl; } diff --git a/src/dusk/iso_validate.cpp b/src/dusk/iso_validate.cpp index a2064102de..6914a0a4ba 100644 --- a/src/dusk/iso_validate.cpp +++ b/src/dusk/iso_validate.cpp @@ -1,8 +1,13 @@ #include "iso_validate.hpp" -#include #include +#include +#include + +#include +#include #include +#include namespace { @@ -62,8 +67,8 @@ struct KnownDisc { 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) + 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)) {} }; @@ -144,22 +149,30 @@ void StreamClose(void* user_data) { } ValidationError verify_disc(NodHandle* disc, VerificationStatus& status) { - const auto hashState = XXH3_createState(); - XXH3_128bits_reset(hashState); + std::unique_ptr hashState( + XXH3_createState(), XXH3_freeState); + if (!hashState) { + return ValidationError::Unknown; + } + XXH3_128bits_reset(hashState.get()); while (true) { + if (status.shouldCancel.load(std::memory_order_relaxed)) { + return ValidationError::Canceled; + } + size_t bytesAvail; const auto buf = nod_buf_read(disc, &bytesAvail); if (!bytesAvail) break; - XXH3_128bits_update(hashState, buf, bytesAvail); + XXH3_128bits_update(hashState.get(), buf, bytesAvail); - status.bytesRead += bytesAvail; + status.bytesRead.fetch_add(bytesAvail, std::memory_order_relaxed); nod_buf_consume(disc, bytesAvail); } - const auto hash = XXH3_128bits_digest(hashState); + const auto hash = XXH3_128bits_digest(hashState.get()); if (!XXH128_isEqual(hash, status.knownDisc->hash)) { return ValidationError::HashMismatch; } @@ -175,7 +188,7 @@ ValidationError validate(const char* path, VerificationStatus& status) { return ValidationError::IOError; } - const NodDiscStream nod_stream { + const NodDiscStream nod_stream{ .user_data = sdlStream, .read_at = StreamReadAt, .stream_len = StreamLength, @@ -187,7 +200,7 @@ ValidationError validate(const char* path, VerificationStatus& status) { return convertNodError(result); } - status.bytesTotal = nod_disc_size(disc.handle); + status.bytesTotal.store(nod_disc_size(disc.handle), std::memory_order_relaxed); NodDiscHeader header{}; result = nod_disc_header(disc.handle, &header); @@ -215,12 +228,12 @@ ValidationError validate(const char* path) { return validate(path, status); } -bool isPal(const char* path) { +ValidationError inspect(const char* path, DiscInfo& info) { NodHandleWrapper disc; const auto sdlStream = SDL_IOFromFile(path, "rb"); if (sdlStream == nullptr) { - return false; + return ValidationError::IOError; } const NodDiscStream nod_stream{ @@ -230,19 +243,30 @@ bool isPal(const char* path) { .close = StreamClose, }; - if (nod_disc_open_stream(&nod_stream, nullptr, &disc.handle) != NOD_RESULT_OK || - disc.handle == nullptr) - { - return false; + auto result = nod_disc_open_stream(&nod_stream, nullptr, &disc.handle); + if (disc.handle == nullptr || result != NOD_RESULT_OK) { + return convertNodError(result); } NodDiscHeader header{}; - if (nod_disc_header(disc.handle, &header) != NOD_RESULT_OK) { - return false; + result = nod_disc_header(disc.handle, &header); + if (result != NOD_RESULT_OK) { + return convertNodError(result); } const auto knownDisc = find_disc(std::string_view(header.game_id, 6)); + if (!knownDisc) { + return ValidationError::WrongGame; + } + info.isPal = knownDisc->region == Region::Europe; + if (!knownDisc->supported) { + return ValidationError::WrongVersion; + } + return ValidationError::Success; +} - return knownDisc && knownDisc->region == Region::Europe; +bool isPal(const char* path) { + DiscInfo info{}; + return inspect(path, info) == ValidationError::Success && info.isPal; } } // namespace dusk::iso diff --git a/src/dusk/iso_validate.hpp b/src/dusk/iso_validate.hpp index 23e5489d71..4bc42709de 100644 --- a/src/dusk/iso_validate.hpp +++ b/src/dusk/iso_validate.hpp @@ -1,31 +1,38 @@ #ifndef DUSK_ISO_VALIDATE_HPP #define DUSK_ISO_VALIDATE_HPP -#include +#include namespace dusk::iso { - struct KnownDisc; +struct KnownDisc; - enum class ValidationError : u8 { - Unknown = 0, - IOError, - InvalidImage, - WrongGame, - WrongVersion, - HashMismatch, - Success - }; +enum class ValidationError : u8 { + Unknown = 0, + IOError, + InvalidImage, + WrongGame, + WrongVersion, + Canceled, + HashMismatch, + Success +}; - struct VerificationStatus { - size_t bytesRead = 0; - size_t bytesTotal = 0; - const KnownDisc* knownDisc = nullptr; - bool shouldCancel = false; - }; +struct VerificationStatus { + std::atomic_size_t bytesRead = 0; + std::atomic_size_t bytesTotal = 0; + const KnownDisc* knownDisc = nullptr; + std::atomic_bool shouldCancel = false; +}; - ValidationError validate(const char* path, VerificationStatus& status); - ValidationError validate(const char* path); - bool isPal(const char* path); -} +struct DiscInfo { + bool isPal = false; +}; + +ValidationError inspect(const char* path, DiscInfo& info); +ValidationError validate(const char* path, VerificationStatus& status); +ValidationError validate(const char* path); +bool isPal(const char* path); + +} // namespace dusk::iso #endif // DUSK_ISO_VALIDATE_HPP diff --git a/src/dusk/settings.cpp b/src/dusk/settings.cpp index 2b84c8bb47..b3e0e13cd3 100644 --- a/src/dusk/settings.cpp +++ b/src/dusk/settings.cpp @@ -109,6 +109,7 @@ UserSettings g_userSettings = { .backend = { .isoPath {"backend.isoPath", ""}, + .isoVerification {"backend.isoVerification", DiscVerificationState::Unknown}, .graphicsBackend {"backend.graphicsBackend", "auto"}, .skipPreLaunchUI {"backend.skipPreLaunchUI", false}, .showPipelineCompilation {"backend.showPipelineCompilation", false}, @@ -206,6 +207,7 @@ void registerSettings() { Register(g_userSettings.game.debugFlyCam); Register(g_userSettings.backend.isoPath); + Register(g_userSettings.backend.isoVerification); Register(g_userSettings.backend.graphicsBackend); Register(g_userSettings.backend.skipPreLaunchUI); Register(g_userSettings.backend.showPipelineCompilation); diff --git a/src/dusk/ui/modal.cpp b/src/dusk/ui/modal.cpp index bb8ed9a473..7120e56538 100644 --- a/src/dusk/ui/modal.cpp +++ b/src/dusk/ui/modal.cpp @@ -2,8 +2,11 @@ namespace dusk::ui { -Modal::Modal(Props props) - : WindowSmall("modal", "modal-dialog"), mProps(std::move(props)) { +Modal::Modal(Props props) : WindowSmall("modal", "modal-dialog"), mProps(std::move(props)) { + if (!mProps.variant.empty()) { + mRoot->SetClass(mProps.variant, true); + } + auto* header = append(mDialog, "div"); header->SetClass("modal-header", true); @@ -14,7 +17,7 @@ Modal::Modal(Props props) if (mProps.isWarning) { auto* icon = append(header, "icon"); icon->SetClass("warning", true); - } else if ( mProps.isError ) { + } else if (mProps.isError) { auto* icon = append(header, "icon"); icon->SetClass("error", true); } diff --git a/src/dusk/ui/modal.hpp b/src/dusk/ui/modal.hpp index 4d3c9e7870..5ffb8b65b4 100644 --- a/src/dusk/ui/modal.hpp +++ b/src/dusk/ui/modal.hpp @@ -18,6 +18,7 @@ public: Rml::String bodyRml; std::vector actions; std::function onDismiss; + Rml::String variant; bool isWarning = false; bool isError = false; }; diff --git a/src/dusk/ui/overlay.cpp b/src/dusk/ui/overlay.cpp index a5c0e036d2..713a2151b1 100644 --- a/src/dusk/ui/overlay.cpp +++ b/src/dusk/ui/overlay.cpp @@ -189,7 +189,8 @@ void Overlay::update() { const bool showControllerWarning = PADGetIndexForPort(PAD_CHAN0) < 0 && PADGetKeyButtonBindings(PAD_CHAN0, nullptr) == nullptr && - dynamic_cast(top_document()) == nullptr; + dynamic_cast(top_document()) == nullptr && + dynamic_cast(top_document()) == nullptr; if (showControllerWarning && mControllerWarning == nullptr) { mControllerWarning = create_controller_warning(mDocument); } else if (showControllerWarning && mControllerWarning != nullptr) { diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index 66e5e2f6f2..bc7c572e08 100644 --- a/src/dusk/ui/prelaunch.cpp +++ b/src/dusk/ui/prelaunch.cpp @@ -11,11 +11,23 @@ #include "version.h" #include +#include #include +#include + +#include +#include +#include +#include +#include +#include +#include #include "m_Do/m_Do_MemCard.h" namespace dusk::ui { +namespace { +aurora::Module PrelaunchLog{"dusk::ui::prelaunch"}; const Rml::String kDocumentSource = R"RML( @@ -54,7 +66,130 @@ constexpr std::array kDiscFileFilters{{ {"All Files", "*"}, }}; -static std::string get_error_msg(iso::ValidationError error) { +struct DiscVerificationResult { + std::string path; + iso::ValidationError validation = iso::ValidationError::Unknown; +}; + +struct DiscVerificationTask { + explicit DiscVerificationTask(std::string discPath) : path(std::move(discPath)) { + worker = std::thread([this] { + try { + validation = iso::validate(path.c_str(), status); + } catch (const std::exception& e) { + PrelaunchLog.error( + "Disc verification failed with exception for '{}': {}", path, e.what()); + validation = iso::ValidationError::Unknown; + } catch (...) { + PrelaunchLog.error( + "Disc verification failed with unknown exception for '{}'", path); + validation = iso::ValidationError::Unknown; + } + done.store(true, std::memory_order_release); + }); + } + + ~DiscVerificationTask() { + status.shouldCancel.store(true, std::memory_order_relaxed); + join(); + } + + void join() { + if (worker.joinable()) { + worker.join(); + } + } + + [[nodiscard]] bool finished() const { return done.load(std::memory_order_acquire); } + + std::string path; + iso::VerificationStatus status; + iso::ValidationError validation = iso::ValidationError::Unknown; + std::atomic_bool done = false; + std::thread worker; +}; + +std::unique_ptr sDiscVerificationTask; +bool sDiscVerificationModalPushed = false; + +bool validation_allows_launch(iso::ValidationError validation) noexcept { + return validation == iso::ValidationError::Success || + validation == iso::ValidationError::HashMismatch || + validation == iso::ValidationError::Canceled; +} + +bool verification_state_allows_launch(iso::ValidationError validation) noexcept { + return validation == iso::ValidationError::Unknown || + validation == iso::ValidationError::Success || + validation == iso::ValidationError::HashMismatch || + validation == iso::ValidationError::Canceled; +} + +iso::ValidationError verification_from_config(DiscVerificationState value) noexcept { + switch (value) { + case DiscVerificationState::Success: + return iso::ValidationError::Success; + case DiscVerificationState::HashMismatch: + return iso::ValidationError::HashMismatch; + default: + return iso::ValidationError::Unknown; + } +} + +DiscVerificationState verification_to_config(iso::ValidationError validation) { + switch (validation) { + case iso::ValidationError::Success: + return DiscVerificationState::Success; + case iso::ValidationError::HashMismatch: + return DiscVerificationState::HashMismatch; + default: + return DiscVerificationState::Unknown; + } +} + +std::string format_bytes(std::size_t bytes) { + constexpr double KiB = 1024.0; + constexpr double MiB = KiB * 1024.0; + constexpr double GiB = MiB * 1024.0; + if (bytes >= static_cast(GiB)) { + return fmt::format("{:.2f} GiB", static_cast(bytes) / GiB); + } + if (bytes >= static_cast(MiB)) { + return fmt::format("{:.0f} MiB", static_cast(bytes) / MiB); + } + if (bytes >= static_cast(KiB)) { + return fmt::format("{:.0f} KiB", static_cast(bytes) / KiB); + } + return fmt::format("{} B", bytes); +} + +void begin_disc_verification(std::string path) noexcept { + if (path.empty()) { + return; + } + if (sDiscVerificationTask != nullptr) { + sDiscVerificationTask->status.shouldCancel.store(true, std::memory_order_relaxed); + sDiscVerificationTask.reset(); + } + sDiscVerificationTask = std::make_unique(std::move(path)); + sDiscVerificationModalPushed = false; +} + +std::optional take_finished_disc_verification() { + if (sDiscVerificationTask == nullptr || !sDiscVerificationTask->finished()) { + return std::nullopt; + } + DiscVerificationResult result{ + .path = sDiscVerificationTask->path, + .validation = sDiscVerificationTask->validation, + }; + sDiscVerificationTask->join(); + sDiscVerificationTask.reset(); + sDiscVerificationModalPushed = false; + return result; +} + +std::string get_error_msg(iso::ValidationError error) { switch (error) { default: return "The selected disc image could not be validated."; @@ -66,40 +201,215 @@ 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::Canceled: + return "Disc verification was canceled. Dusk cannot guarantee the selected disc image " + "is compatible."; case iso::ValidationError::HashMismatch: - return "The selected disc image did not pass hash verification, it may be corrupt or modified."; + 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."; } } +void persist_disc_choice(const std::string& path, iso::ValidationError validation) { + getSettings().backend.isoPath.setValue(path); + getSettings().backend.isoVerification.setValue(verification_to_config(validation)); + config::Save(); +} + +void apply_valid_disc_result(const std::string& path, iso::ValidationError validation) { + auto& state = prelaunch_state(); + iso::DiscInfo info{}; + const bool pal = + iso::inspect(path.c_str(), info) == iso::ValidationError::Success && info.isPal; + + state.selectedDiscPath = path; + state.selectedDiscIsValid = true; + state.selectedDiscIsPal = pal; + state.initialDiscValidationRes = validation; + state.initialDiscIsPal = pal; + persist_disc_choice(path, validation); +} + +void apply_disc_verification_result(const DiscVerificationResult& result) { + auto& state = prelaunch_state(); + + if (result.validation == iso::ValidationError::HashMismatch || + result.validation == iso::ValidationError::Canceled) + { + state.pendingDiscPath = result.path; + state.pendingDiscValidation = result.validation; + state.errorString = escape(get_error_msg(result.validation)); + return; + } + + if (validation_allows_launch(result.validation)) { + apply_valid_disc_result(result.path, result.validation); + + if (result.validation == iso::ValidationError::Success) { + state.errorString.clear(); + state.pendingDiscPath.clear(); + state.pendingDiscValidation = iso::ValidationError::Unknown; + } + return; + } + + state.pendingDiscPath.clear(); + state.pendingDiscValidation = iso::ValidationError::Unknown; + state.errorString = escape(get_error_msg(result.validation)); +} + +class DiscVerificationModal : public WindowSmall { +public: + DiscVerificationModal() : WindowSmall("modal", "modal-dialog") { + auto* header = append(mDialog, "div"); + header->SetClass("modal-header", true); + + auto* title = append(header, "div"); + title->SetClass("modal-title", true); + title->SetInnerRML("Verifying disc image"); + + auto* icon = append(header, "icon"); + icon->SetClass("verifying", true); + + auto* body = append(mDialog, "div"); + body->SetClass("modal-body", true); + + auto* content = append(body, "div"); + content->SetClass("verification-progress", true); + + mFileName = append(content, "div"); + mFileName->SetClass("verification-file", true); + + mProgress = append(content, "progressbar"); + mProgress->SetClass("progress-ongoing", true); + mProgress->SetClass("verification-progress-bar", true); + mProgress->SetAttribute("value", 0.f); + + mDetail = append(content, "div"); + mDetail->SetClass("verification-detail", true); + + auto* actions = append(mDialog, "div"); + actions->SetClass("modal-actions", true); + mCancelButton = std::make_unique