From 402a3053dc5051fb3429b9c6ad3a0809fc6dfea6 Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 3 Oct 2026 19:51:02 -0500 Subject: [PATCH] fix(app): advance logic timers by wall-clock time Logic timers advanced by io.DeltaTime, which update() clamps to 0.25 s so animations don't jump after a pause. That matched real time only while the main loop rendered continuously: with frames seconds apart, a timer fed the clamped delta runs up to 8x slow. Affected: the refresh scheduler, the seed-migration poll, the chat fast scan, the encryption check, the loading and shutdown timers, the lock-screen countdowns, the Settings debug gate and the mining benchmark. - App::logicDeltaTime(): steady-clock seconds since the previous update(), capped at 6 s so a sleep/resume fires each timer once instead of replaying the gap. Visual interpolation keeps the clamped io.DeltaTime. - The refresh timers tick before the RPC results are drained, so a hold that a callback arms (the connect-retry backoff) starts fresh instead of being charged the gap before it existed. - The shutdown timer is measured from shutdown_start_time_: beginShutdown runs from SDL event handling, so an accumulated delta would count the idle gap before the close request. - The lock-screen error and lockout countdowns subtract only the time since they were armed: a failed unlock arms them from an RPC result, which can land on the frame that ends an idle gap. Co-Authored-By: Claude Opus 5.5 --- src/app.cpp | 33 ++++++++++++++++++++++++--------- src/app.h | 16 ++++++++++++++++ src/app_security.cpp | 11 +++++++++-- src/ui/pages/settings_page.cpp | 2 +- src/ui/windows/mining_tab.cpp | 2 +- 5 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index 0bd0c9a..38246b3 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -746,10 +746,19 @@ void App::update() { PERF_SCOPE("Update.Total"); ImGuiIO& io = ImGui::GetIO(); - // Clamp the frame delta: after a long pause (window minimized, or the machine slept) NewFrame reports - // a huge DeltaTime that would fire every refresh/animation timer at once. Every timer reads - // io.DeltaTime, so one clamp here bounds them all (also caps the real-clock delta fed while minimized). + // Clamp the frame delta for visual interpolation: after a long pause (window minimized, an idle + // stretch, or the machine slept) NewFrame reports a large DeltaTime that would make animations jump. if (io.DeltaTime > 0.25f) io.DeltaTime = 0.25f; + // Logic timers use real elapsed time instead (see logicDeltaTime()), capped so a sleep/resume fires + // each timer at most once rather than replaying the whole gap. + { + const auto now = std::chrono::steady_clock::now(); + logic_dt_ = (last_update_time_ == std::chrono::steady_clock::time_point{}) + ? 0.0f + : std::min(std::chrono::duration(now - last_update_time_).count(), kMaxLogicDeltaSeconds); + if (logic_dt_ < 0.0f) logic_dt_ = 0.0f; + last_update_time_ = now; + } // Full UI screenshot sweep: demo state is injected once and must stay frozen. Skip every live // op (refresh/connect/pumps) so a real daemon can't clobber it — on Windows a running node's @@ -768,6 +777,11 @@ void App::update() last_interaction_ = std::chrono::steady_clock::now(); } + // Advance the refresh timers by the real time since the last update BEFORE the RPC results are + // applied, so a hold one of those callbacks arms now (e.g. the connect-retry backoff) starts fresh + // instead of being charged the idle gap that elapsed before it existed. + network_refresh_.tick(logic_dt_); + // Drain completed RPC results back onto the main thread if (worker_) { worker_->drainResults(); @@ -901,7 +915,7 @@ void App::update() pumpWalletRestore(); // While confirming the sweep, poll the tx confirmations + legacy balance every ~5s. if (show_seed_migration_ && seed_migration_step_ == SeedMigrationStep::Confirming) { - seed_migration_poll_timer_ -= ImGui::GetIO().DeltaTime; + seed_migration_poll_timer_ -= logic_dt_; if (seed_migration_poll_timer_ <= 0.0f) { seed_migration_poll_timer_ = 5.0f; pollSweepStatus(); @@ -923,7 +937,6 @@ void App::update() } using RefreshTimer = services::NetworkRefreshService::Timer; - network_refresh_.tick(io.DeltaTime); // Wipe a secret (seed/private key) from the clipboard once its auto-clear delay elapses. pumpSecretClipboardClear(); @@ -1368,7 +1381,7 @@ void App::update() // (slower, 10–15s) Transactions cadence — so incoming messages surface in a few seconds. The // normal harvest only re-scans on a new block. Self-gated (no-op without a chat identity / // connection / on lite); the in-flight guard keeps overlapping RPCs from stacking. - chat_fast_scan_accum_ += ImGui::GetIO().DeltaTime; + chat_fast_scan_accum_ += logic_dt_; const float chatPoll = settings_ ? settings_->getChatPollRateSec() : 2.5f; // user-configurable if (chat_fast_scan_accum_ >= chatPoll) { chat_fast_scan_accum_ = 0.0f; @@ -1993,7 +2006,7 @@ void App::render() } else if (pageNeedsWalletData && (!daemonReady || (state_.connected && !state_.encryption_state_known))) { // Track how long we've been waiting for encryption state if (state_.connected && !state_.encryption_state_known) { - encryption_check_timer_ += ImGui::GetIO().DeltaTime; + encryption_check_timer_ += logic_dt_; if (encryption_check_timer_ >= 15.0f) { DEBUG_LOGF("[App] Encryption state check timed out after 15s — assuming unencrypted\n"); state_.encryption_state_known = true; @@ -6424,7 +6437,9 @@ void App::renderShutdownScreen() float v = ui::schema::UI().drawElement("components.shutdown", key).size; return v >= 0 ? v : fb; }; - shutdown_timer_ += ImGui::GetIO().DeltaTime; + // Elapsed since beginShutdown, from the wall clock: beginShutdown runs from SDL event handling, so an + // accumulated delta would count the idle gap before the close request as shutdown time. + shutdown_timer_ = std::chrono::duration(std::chrono::steady_clock::now() - shutdown_start_time_).count(); const std::string curShut = shutdown_status_.get(); // one consistent snapshot per frame (M-05) // After a few seconds, offer to close the wallet and leave the node running: stopping can take as long @@ -6746,7 +6761,7 @@ void App::renderLoadingOverlay(float contentH) return v >= 0 ? v : fb; }; - loading_timer_ += ImGui::GetIO().DeltaTime; + loading_timer_ += logic_dt_; ImDrawList* dl = ImGui::GetWindowDrawList(); ImVec2 wp = ImGui::GetWindowPos(); diff --git a/src/app.h b/src/app.h index a83c52a..f56ace8 100644 --- a/src/app.h +++ b/src/app.h @@ -114,6 +114,17 @@ public: */ void update(); + /** + * @brief Wall-clock seconds since the previous update(), capped at kMaxLogicDeltaSeconds. + * + * Logic timers (refresh scheduler, poll intervals, countdowns) advance by this. Visual + * interpolation keeps io.DeltaTime, which update() clamps to 0.25 s so animations don't jump. The + * main loop idles between frames (one frame per 2 s wake when nothing is happening), so a timer + * fed the clamped delta would run up to 8x slow. + */ + float logicDeltaTime() const { return logic_dt_; } + static constexpr float kMaxLogicDeltaSeconds = 6.0f; + /** * @brief Pre-frame tasks that must run BEFORE ImGui::NewFrame(). * @@ -1521,6 +1532,7 @@ private: float lock_error_timer_ = 0.0f; int lock_attempts_ = 0; float lock_lockout_timer_ = 0.0f; + std::chrono::steady_clock::time_point lock_timer_armed_at_{}; // when lock_error/lockout timers were last armed bool lock_unlock_in_progress_ = false; // Encrypt wallet dialog state @@ -1562,6 +1574,10 @@ private: // Auto-lock on idle std::chrono::steady_clock::time_point last_interaction_ = std::chrono::steady_clock::now(); + // Wall-clock delta for logic timers (see logicDeltaTime()). + std::chrono::steady_clock::time_point last_update_time_{}; + float logic_dt_ = 0.0f; + // Mine-when-idle: auto-start/stop mining based on system idle state bool idle_mining_active_ = false; // true when mining was auto-started by idle detection bool idle_scaled_to_idle_ = false; // true when threads have been scaled up for idle diff --git a/src/app_security.cpp b/src/app_security.cpp index 819e231..5fb2320 100644 --- a/src/app_security.cpp +++ b/src/app_security.cpp @@ -472,6 +472,7 @@ void App::applyUnlockFailure(const std::string& errorMessage) { lock_attempts_++; lock_error_msg_ = errorMessage; lock_error_timer_ = 3.0f; + lock_timer_armed_at_ = std::chrono::steady_clock::now(); memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_)); const float baseDelay = ui::schema::UI().drawElement("security", "lockout-base-delay").sizeOr(2.0f); const int maxAttempts = (int)ui::schema::UI().drawElement("security", "max-attempts-before-lockout").sizeOr(5.0f); @@ -963,9 +964,14 @@ void App::renderLockScreen() { cy += ts.y + 20.0f * dp; } + // Count down only the time since the timers were armed: a failed unlock arms them from an RPC result, + // which can land on a frame that ends an idle gap the countdown would otherwise lose at once. + const float lockDt = std::min(logic_dt_, std::chrono::duration( + std::chrono::steady_clock::now() - lock_timer_armed_at_).count()); + // Lockout timer if (lock_lockout_timer_ > 0.0f) { - lock_lockout_timer_ -= ImGui::GetIO().DeltaTime; + lock_lockout_timer_ -= lockDt; if (lock_lockout_timer_ < 0) lock_lockout_timer_ = 0; char msg[128]; @@ -1072,7 +1078,7 @@ void App::renderLockScreen() { // Error message if (!lock_error_msg_.empty() && lock_error_timer_ > 0) { - lock_error_timer_ -= ImGui::GetIO().DeltaTime; + lock_error_timer_ -= lockDt; ImVec2 es = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, lock_error_msg_.c_str()); dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cardX + (cardW - es.x) * 0.5f, cy), ui::material::Error(), @@ -1151,6 +1157,7 @@ void App::renderLockScreen() { lock_error_msg_ = TR("pin_not_set"); lock_use_pin_ = false; lock_error_timer_ = 3.0f; + lock_timer_armed_at_ = std::chrono::steady_clock::now(); } else { applyUnlockFailure(TR("incorrect_pin")); } diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index 6a53da9..8d0074e 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -2865,7 +2865,7 @@ void RenderSettingsPage(App* app) { if (s_settingsState.debug_gate_open) { auto& st = s_settingsState; if (st.debug_gate_err_timer > 0.0f) { - st.debug_gate_err_timer -= ImGui::GetIO().DeltaTime; + st.debug_gate_err_timer -= app->logicDeltaTime(); if (st.debug_gate_err_timer <= 0.0f) st.debug_gate_err.clear(); } const bool needAuth = app->debugGateRequiresAuth(); diff --git a/src/ui/windows/mining_tab.cpp b/src/ui/windows/mining_tab.cpp index b6368f8..4b3f80b 100644 --- a/src/ui/windows/mining_tab.cpp +++ b/src/ui/windows/mining_tab.cpp @@ -249,7 +249,7 @@ static void RenderMiningTabContent(App* app) // ================================================================ if (s_benchmark.active()) { auto benchmarkUpdate = AdvanceThreadBenchmark( - s_benchmark, ImGui::GetIO().DeltaTime, state.pool_mining.hashrate_10s); + s_benchmark, app->logicDeltaTime(), state.pool_mining.hashrate_10s); if (benchmarkUpdate.stopPoolMining) { app->stopPoolMining(); }