diff --git a/AGENTS.md b/AGENTS.md index 8e374bc64..4123f7e14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -467,7 +467,7 @@ document why. **IMPORTANT:** Do NOT use `QSettings` anywhere in AetherSDR. All client-side settings are stored via `AppSettings` (`src/core/AppSettings.h`), which writes an XML file at `~/.config/AetherSDR/AetherSDR.settings`. Key names use -PascalCase (e.g. `LastConnectedRadioSerial`, `DisplayFftAverage`). Boolean +PascalCase (e.g. `LastConnectedRadioSerial`, `DisplayFftFillColor`). Boolean values are stored as `"True"` / `"False"` strings. ```cpp @@ -501,15 +501,26 @@ the radio does NOT save. **Radio-authoritative (do NOT persist):** frequency, mode, filter, step size, AGC, squelch, DSP flags, antennas, TX power, panadapter *count* and per-pan -state (center, bandwidth, min/max dBm, etc.). +state (center, bandwidth, min/max dBm, FFT average/FPS/weighted-average, and +waterfall line duration). **Client-authoritative (persist in AppSettings):** window geometry, layout arrangement (`PanadapterLayout`, applet order/visibility), client-side DSP -(NR2/RN2/NR4/DFNR), UI preferences, display preferences, spot settings. +(NR2/RN2/NR4/DFNR), UI preferences, client-only display appearance +preferences, spot settings. **Why:** When both persist the same setting, they fight on reconnect. The radio's GUIClientID session restore is always more current than our saved state. +**Anti-pattern (recurring — see #4261):** Do not write a radio-echoed status +value into a setter that *also* persists it to `AppSettings`. That makes the +client re-assert stale state on reconnect / profile load and fight the radio — +the exact class of bug behind #2465, #4126, #4081, #4083, and #4261. For a +radio-authoritative field, route status straight to the display (a plain member ++ signal) and never call `AppSettings::setValue()` in its setter. When a display +setter genuinely persists (e.g. waterfall *appearance*: color gain, black +level), that value must be client-only — never a value the radio also echoes. + ### GUI↔Radio Sync (No Feedback Loops) - Model setters emit `commandReady(cmd)` → `RadioModel` sends to radio diff --git a/CMakeLists.txt b/CMakeLists.txt index c150a258b..14c7b616a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2321,6 +2321,11 @@ target_include_directories(model_capabilities_test PRIVATE src) target_link_libraries(model_capabilities_test PRIVATE Qt6::Core) add_test(NAME model_capabilities_test COMMAND model_capabilities_test) +# Adaptive-throttle display-status echo gate (#4261) — header-only pure logic. +add_executable(display_status_gate_test tests/display_status_gate_test.cpp) +target_include_directories(display_status_gate_test PRIVATE src) +add_test(NAME display_status_gate_test COMMAND display_status_gate_test) + # KiwiSDR band-recall re-bind policy (#4158) — header-only, pure logic. add_executable(kiwi_rebind_tracker_test tests/kiwi_rebind_tracker_test.cpp) target_include_directories(kiwi_rebind_tracker_test PRIVATE src) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b8cfe79e5..16988ecbf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,7 +87,11 @@ changes. - **Settings**: Use `AppSettings`, **never** `QSettings`. Keys are PascalCase. Booleans are `"True"` / `"False"` strings. - **Radio-authoritative**: Never persist or override settings the radio manages - (frequency, mode, filter, step size, AGC, antennas, TX power). + (frequency, mode, filter, step size, AGC, squelch, DSP flags, antennas, TX + power, panadapter *count* and per-pan state — including FFT + average/FPS/weighted-average and waterfall line duration). Never write a + radio-echoed status value into a setter that also persists to `AppSettings` + (the recurring #4261 anti-pattern). See `AGENTS.md` for the full list. ### Working in MainWindow diff --git a/docs/automation-bridge.md b/docs/automation-bridge.md index 099b7da2d..d92da1011 100644 --- a/docs/automation-bridge.md +++ b/docs/automation-bridge.md @@ -256,6 +256,7 @@ transmit-gated verbs (refused unless `AETHER_AUTOMATION_ALLOW_TX=1` — see | | [`get flags`](#get) | VFO flag attachment state for slice-to-pan assertions. | | | [`get cwx`](#get-cwx) | CWX keyer state + queue-drain watch (#3949). | | | [`get panstats`](#get-panstats) | Per-panadapter render-cost counters (profiling). | +| | [`get renderstats`](#get-renderstats) | Combined 2D/3D pan, waterfall, DSS, scheduler, and WAVE profiling snapshot. | | | [`get tracedebug`](#get-tracedebug) | Per-panadapter Flex/Kiwi FFT and 3D trace diagnostics. | | | [`get clients`](#get-clients) | Radio client roster, GUI IDs + foreign-pan-write forensics (#3977/#4166). | | | [`get sync`](#get-sync) | Receive-Sync (Auto Assist) state. | @@ -686,6 +687,36 @@ until `lastCommand.pending` is false, require `lastCommand.code == 0`, then poll the raw mode lists after the automatic slice-status resync. The verb is generic; the bridge does not embed or preserve an old registration name. +### `get renderstats` + +Combined rendering-analysis snapshot for before/after automation. It returns +every `panstats` entry, every WAVE/strip `wavestats` entry, the shared pan +scheduler, and non-overlapping headline totals. The totals cover measured +GUI-thread FFT ingest, native/Kiwi waterfall ingest, GPU frame preparation, +software fallback painting, and WAVE painting. DSS timings are reported +separately because they are a subset of FFT/waterfall ingest. + +```json +→ {"cmd":"get","model":"renderstats","selector":"reset"} +← {"ok":true,"model":"renderstats", + "totals":{"panCount":1,"visiblePanCount":1,"waveScopeCount":1, + "fftFramesPerSec":24.9,"gpuFramesPerSec":25.1, + "fftIngestMsPerSec":4.2,"nativeWaterfallUpdateMsPerSec":3.8, + "gpuFrameMsPerSec":2.7,"wavePaintMsPerSec":0.0, + "measuredMainThreadMsPerSec":10.7, + "hiddenWaterfallUpdatesPerSec":0.0, + "hiddenDssHistoryRowsPerSec":0.0, + "waterfallAllocatedBytes":102760448, + "dssAllocatedBytes":37847040}, + "pans":[...],"scopes":[...],"renderScheduler":{...}} +``` + +Use `get renderstats reset`, wait for a fixed observation interval, then read +`get renderstats reset` again. This gives disjoint samples across pan, waterfall, +3DSS, scheduler, and WAVE counters with one command. `measuredMainThreadMsPerSec` +is instrumented GUI-thread work, not whole-process CPU percentage; use it for +causal comparisons while keeping the radio/display configuration fixed. + ### `get panstats` Per-panadapter (SpectrumWidget) frame-cost counters — how much GUI-thread time each pan spends preparing frames, split by pipeline section, for before/after @@ -716,6 +747,10 @@ cost a few integer adds per frame. | `overlayRebuilds*`, `overlayUploadBytesPerSec` | static-overlay QPainter repaints (should be ~0/s when idle) | | `overlayDirtyCauses` | first-cause attribution for each overlay rebuild (`smartMtr`, `detect`, `other`) | | `wfUploadBytesPerSec` | waterfall texture upload volume | +| `nativeWaterfall*` / `kiwiWaterfall*` | source-specific ingest rate and GUI-thread cost; `HiddenUpdates` identifies background Flex/Kiwi work | +| `waterfallVisibleRows*` / `waterfallHistoryRows*` | viewport and retained RGB-history write rates/cost (RGB history is written only for the visible source) | +| `dssLiveRows*` / `dssHistoryRows*` | 96-row live 3D surface work versus deep retained scrollback work; `dssHiddenLiveRowsPerSec` exposes the hidden-Flex live-ring warming (#4081) — hidden sources retain no deep history | +| `waterfallAllocatedBytes` / `dssAllocatedBytes` | current plus cached Flex/Kiwi/profile storage, including hidden-source retained history | | `paintsPerSec` / `paintMsPerSec` | software-QPainter path (non-zero only before QRhi init or in non-GPU builds) | | `renderScheduler` | shared panadapter repaint scheduler counters; `coalescedRequests` and `avgWidgetsPerFlush` show cross-pan request coalescing | diff --git a/src/core/AutomationServer.cpp b/src/core/AutomationServer.cpp index 867756671..c4c668c93 100644 --- a/src/core/AutomationServer.cpp +++ b/src/core/AutomationServer.cpp @@ -2202,6 +2202,7 @@ const QStringList& getModelNames() QStringLiteral("meters"), QStringLiteral("slice"), QStringLiteral("slices"), QStringLiteral("pan"), QStringLiteral("pans"), QStringLiteral("panstats"), + QStringLiteral("renderstats"), QStringLiteral("tracedebug"), QStringLiteral("waveforms"), QStringLiteral("kiwi"), }; @@ -4013,6 +4014,157 @@ QJsonObject AutomationServer::doGet(const QString& model, const QString& selecto {QStringLiteral("model"), model}, {QStringLiteral("waveforms"), data}}; } + if (model == QLatin1String("renderstats")) { + // One profiling snapshot for all panadapter, waterfall, 3DSS, shared + // scheduler, and WAVE-scope work. This deliberately reuses the widget + // snapshots instead of exposing GUI headers through the core bridge. + // `get renderstats reset` returns the interval and atomically starts a + // fresh one across every participating widget. + const bool reset = selector == QLatin1String("reset") + || property == QLatin1String("reset"); + QJsonArray pans; + QJsonArray scopes; + QVariantMap schedulerStats; + bool haveSchedulerStats = false; + QSet seen; + + double fftFramesPerSec = 0.0; + double gpuFramesPerSec = 0.0; + double fftIngestMsPerSec = 0.0; + double gpuFrameMsPerSec = 0.0; + double softwarePaintMsPerSec = 0.0; + double nativeWaterfallUpdatesPerSec = 0.0; + double nativeWaterfallUpdateMsPerSec = 0.0; + double kiwiWaterfallUpdatesPerSec = 0.0; + double kiwiWaterfallUpdateMsPerSec = 0.0; + double hiddenWaterfallUpdatesPerSec = 0.0; + double dssLiveRowsPerSec = 0.0; + double dssLiveMsPerSec = 0.0; + double dssHistoryRowsPerSec = 0.0; + double dssHistoryMsPerSec = 0.0; + double hiddenDssLiveRowsPerSec = 0.0; + double hiddenDssHistoryRowsPerSec = 0.0; + double waterfallAllocatedBytes = 0.0; + double dssAllocatedBytes = 0.0; + int visiblePanCount = 0; + + for (QWidget* w : findWidgetsByClass(QStringLiteral("SpectrumWidget"))) { + if (seen.contains(w)) { + continue; + } + seen.insert(w); + QVariantMap snap; + if (!QMetaObject::invokeMethod(w, "panstatsSnapshot", + Qt::DirectConnection, + Q_RETURN_ARG(QVariantMap, snap), + Q_ARG(bool, reset))) { + continue; + } + pans.append(QJsonObject::fromVariantMap(snap)); + if (snap.value(QStringLiteral("visible")).toBool()) { + ++visiblePanCount; + } + auto number = [&snap](const char* key) { + return snap.value(QString::fromLatin1(key)).toDouble(); + }; + fftFramesPerSec += number("fftFramesPerSec"); + gpuFramesPerSec += number("gpuFramesPerSec"); + fftIngestMsPerSec += number("ingestMsPerSec"); + gpuFrameMsPerSec += number("gpuFrameMsPerSec"); + softwarePaintMsPerSec += number("paintMsPerSec"); + nativeWaterfallUpdatesPerSec += number("nativeWaterfallUpdatesPerSec"); + nativeWaterfallUpdateMsPerSec += number("nativeWaterfallUpdateMsPerSec"); + kiwiWaterfallUpdatesPerSec += number("kiwiWaterfallUpdatesPerSec"); + kiwiWaterfallUpdateMsPerSec += number("kiwiWaterfallUpdateMsPerSec"); + hiddenWaterfallUpdatesPerSec += + number("nativeWaterfallHiddenUpdatesPerSec") + + number("kiwiWaterfallHiddenUpdatesPerSec"); + dssLiveRowsPerSec += number("dssLiveRowsPerSec"); + dssLiveMsPerSec += number("dssLiveMsPerSec"); + dssHistoryRowsPerSec += number("dssHistoryRowsPerSec"); + dssHistoryMsPerSec += number("dssHistoryMsPerSec"); + hiddenDssLiveRowsPerSec += number("dssHiddenLiveRowsPerSec"); + hiddenDssHistoryRowsPerSec += number("dssHiddenHistoryRowsPerSec"); + waterfallAllocatedBytes += number("waterfallAllocatedBytes"); + dssAllocatedBytes += number("dssAllocatedBytes"); + + if (!haveSchedulerStats) { + QVariantMap scheduler; + if (QMetaObject::invokeMethod(w, "renderSchedulerStatsSnapshot", + Qt::DirectConnection, + Q_RETURN_ARG(QVariantMap, scheduler), + Q_ARG(bool, reset))) { + schedulerStats = scheduler; + haveSchedulerStats = + scheduler.value(QStringLiteral("enabled")).toBool(); + } + } + } + + seen.clear(); + double wavePaintMsPerSec = 0.0; + double wavePaintsPerSec = 0.0; + double waveAppendsPerSec = 0.0; + for (QWidget* w : findWidgetsByClass(QStringLiteral("WaveformWidget"))) { + if (seen.contains(w)) { + continue; + } + seen.insert(w); + QVariantMap snap; + if (!QMetaObject::invokeMethod(w, "wavestatsSnapshot", + Qt::DirectConnection, + Q_RETURN_ARG(QVariantMap, snap), + Q_ARG(bool, reset))) { + continue; + } + scopes.append(QJsonObject::fromVariantMap(snap)); + wavePaintMsPerSec += snap.value(QStringLiteral("paintMsPerSec")).toDouble(); + wavePaintsPerSec += snap.value(QStringLiteral("paintsPerSec")).toDouble(); + waveAppendsPerSec += snap.value(QStringLiteral("appendsPerSec")).toDouble(); + } + + const double measuredMainThreadMsPerSec = + fftIngestMsPerSec + nativeWaterfallUpdateMsPerSec + + kiwiWaterfallUpdateMsPerSec + gpuFrameMsPerSec + + softwarePaintMsPerSec + wavePaintMsPerSec; + QJsonObject totals{ + {QStringLiteral("panCount"), pans.size()}, + {QStringLiteral("visiblePanCount"), visiblePanCount}, + {QStringLiteral("waveScopeCount"), scopes.size()}, + {QStringLiteral("fftFramesPerSec"), fftFramesPerSec}, + {QStringLiteral("gpuFramesPerSec"), gpuFramesPerSec}, + {QStringLiteral("fftIngestMsPerSec"), fftIngestMsPerSec}, + {QStringLiteral("gpuFrameMsPerSec"), gpuFrameMsPerSec}, + {QStringLiteral("softwarePaintMsPerSec"), softwarePaintMsPerSec}, + {QStringLiteral("nativeWaterfallUpdatesPerSec"), nativeWaterfallUpdatesPerSec}, + {QStringLiteral("nativeWaterfallUpdateMsPerSec"), nativeWaterfallUpdateMsPerSec}, + {QStringLiteral("kiwiWaterfallUpdatesPerSec"), kiwiWaterfallUpdatesPerSec}, + {QStringLiteral("kiwiWaterfallUpdateMsPerSec"), kiwiWaterfallUpdateMsPerSec}, + {QStringLiteral("hiddenWaterfallUpdatesPerSec"), hiddenWaterfallUpdatesPerSec}, + {QStringLiteral("dssLiveRowsPerSec"), dssLiveRowsPerSec}, + {QStringLiteral("dssLiveMsPerSec"), dssLiveMsPerSec}, + {QStringLiteral("dssHistoryRowsPerSec"), dssHistoryRowsPerSec}, + {QStringLiteral("dssHistoryMsPerSec"), dssHistoryMsPerSec}, + {QStringLiteral("hiddenDssLiveRowsPerSec"), hiddenDssLiveRowsPerSec}, + {QStringLiteral("hiddenDssHistoryRowsPerSec"), hiddenDssHistoryRowsPerSec}, + {QStringLiteral("wavePaintsPerSec"), wavePaintsPerSec}, + {QStringLiteral("wavePaintMsPerSec"), wavePaintMsPerSec}, + {QStringLiteral("waveAppendsPerSec"), waveAppendsPerSec}, + {QStringLiteral("measuredMainThreadMsPerSec"), measuredMainThreadMsPerSec}, + {QStringLiteral("waterfallAllocatedBytes"), waterfallAllocatedBytes}, + {QStringLiteral("dssAllocatedBytes"), dssAllocatedBytes}, + }; + QJsonObject out{{QStringLiteral("ok"), true}, + {QStringLiteral("model"), model}, + {QStringLiteral("pans"), pans}, + {QStringLiteral("scopes"), scopes}, + {QStringLiteral("totals"), totals}}; + if (haveSchedulerStats) { + out[QStringLiteral("renderScheduler")] = + QJsonObject::fromVariantMap(schedulerStats); + } + return out; + } if (model == QLatin1String("panstats")) { // Per-panadapter frame-cost counters from every SpectrumWidget, for // before/after rendering-cost proofs without a profiler attach. @@ -4344,7 +4496,7 @@ QJsonObject AutomationServer::doGet(const QString& model, const QString& selecto data = panSnapshot(p, radio); } else { return err(QStringLiteral("unknown model: ") + model - + QStringLiteral(" (use audio|dsp|sync|radio|transmit|cwx|equalizer|meters|slice|slices|pan|pans|flags|panstats|tracedebug|clients|kiwi|wavestats)")); + + QStringLiteral(" (use audio|dsp|sync|radio|transmit|cwx|equalizer|meters|slice|slices|pan|pans|flags|panstats|renderstats|tracedebug|clients|kiwi|wavestats)")); } if (!property.isEmpty()) { diff --git a/src/gui/DisplayStatusGate.h b/src/gui/DisplayStatusGate.h new file mode 100644 index 000000000..899f69e6b --- /dev/null +++ b/src/gui/DisplayStatusGate.h @@ -0,0 +1,32 @@ +#pragma once + +namespace AetherSDR { + +// Adaptive-throttle echo gate for the radio-authoritative FFT-FPS and +// waterfall-line-duration status (#4261). +// +// When the adaptive throttle is active, RadioModel caps those two values and the +// radio echoes the capped value back as status. That echo must NOT overwrite the +// pre-throttle value the widget holds as its restore target — but a *different* +// reported value is a genuine radio/profile update (e.g. a profile load or a +// second client) and must be applied even while throttled, or it is lost when +// the throttle lifts and the stale restore target is pushed back to the radio. +// +// So: apply a reported value iff it is valid (> 0) and it is not exactly the +// value we are currently capping to. `cappedValue` is the throttle's cap for +// this field (the fps cap, or adaptiveWfMsForCap() for line duration); it is +// only consulted while `throttleActive`. Pure so it is unit-tested directly. +inline bool applyThrottledDisplayReport(bool throttleActive, + int cappedValue, + int reportedValue) +{ + if (reportedValue <= 0) { + return false; + } + if (throttleActive && reportedValue == cappedValue) { + return false; // the adaptive cap's own echo — keep the restore target + } + return true; +} + +} // namespace AetherSDR diff --git a/src/gui/DssRenderer.cpp b/src/gui/DssRenderer.cpp index 81c409d67..54f03b21e 100644 --- a/src/gui/DssRenderer.cpp +++ b/src/gui/DssRenderer.cpp @@ -218,6 +218,30 @@ void DssRenderer::clear() resetHistorySmoothing(); } +quint64 DssRenderer::fixedStorageBytes() const +{ + return sizeof(m_rows) + + sizeof(m_rawPrev1) + sizeof(m_rawPrev2) + + sizeof(m_historyRawPrev1) + sizeof(m_historyRawPrev2); +} + +quint64 DssRenderer::historyStorageBytes() const +{ + return static_cast(m_historyRows.capacity()) * sizeof(qfloat16) + + static_cast(m_historyRowCenterMhz.capacity()) * sizeof(double) + + static_cast(m_historyRowBandwidthMhz.capacity()) * sizeof(double); +} + +quint64 DssRenderer::cacheStorageBytes() const +{ + return m_cache.isNull() ? 0 : static_cast(m_cache.sizeInBytes()); +} + +quint64 DssRenderer::allocatedBytes() const +{ + return fixedStorageBytes() + historyStorageBytes() + cacheStorageBytes(); +} + const std::array& DssRenderer::rowAt(int age) const { diff --git a/src/gui/DssRenderer.h b/src/gui/DssRenderer.h index 4fc395d71..dac0604d6 100644 --- a/src/gui/DssRenderer.h +++ b/src/gui/DssRenderer.h @@ -48,6 +48,10 @@ class DssRenderer void setHistoryCapacityRows(int rows); int historyCapacityRows() const { return m_historyCapacityRows; } int historyRowCount() const { return m_historyRowCount; } + quint64 fixedStorageBytes() const; + quint64 historyStorageBytes() const; + quint64 cacheStorageBytes() const; + quint64 allocatedBytes() const; void appendHistoryRow(const QVector& binsDbm, double centerMhz, double bandwidthMhz, float fallbackDbm); diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index cd21e47ef..8c781487a 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -1763,9 +1763,10 @@ MainWindow::MainWindow(QWidget* parent) if (m_titleBar) m_titleBar->setThrottleFlashColor(active ? fpsCapColor(fpsCap) : QString{}); if (!active) { - // Throttle lifted — push each pan's user-configured fps back to the radio. - // The reconcile timers are suppressed while throttle is active, so they - // won't have done this automatically. + // Throttle lifted — restore the radio values captured in each + // SpectrumWidget before the transient cap status arrived. Live + // fps/line-duration status is deliberately held out of the widgets + // while throttled, so the cap never becomes profile state. if (profileLoadRadioStateWritesHeld()) { qCDebug(lcProtocol) << "MainWindow: adaptive throttle restore suppressed during profile load"; @@ -5301,58 +5302,13 @@ void MainWindow::onConnectionStateChanged(bool connected) audioStopRx(); audioStopTx(); - for (auto it = m_panFpsReconcileConnections.begin(); - it != m_panFpsReconcileConnections.end(); ++it) { - QObject::disconnect(it.value()); - } - m_panFpsReconcileConnections.clear(); - for (auto it = m_wfLineDurationReconcileConnections.begin(); - it != m_wfLineDurationReconcileConnections.end(); ++it) { - QObject::disconnect(it.value()); - } - m_wfLineDurationReconcileConnections.clear(); - for (auto it = m_panFpsReconcile.begin(); - it != m_panFpsReconcile.end(); ++it) { - if (it->timer) { - it->timer->stop(); - it->timer->deleteLater(); + for (auto it = m_panDisplayStatusConnections.cbegin(); + it != m_panDisplayStatusConnections.cend(); ++it) { + for (const QMetaObject::Connection& connection : it.value()) { + QObject::disconnect(connection); } } - m_panFpsReconcile.clear(); - for (auto it = m_wfLineDurationReconcile.begin(); - it != m_wfLineDurationReconcile.end(); ++it) { - if (it->timer) { - it->timer->stop(); - it->timer->deleteLater(); - } - } - m_wfLineDurationReconcile.clear(); - for (auto it = m_panAverageReconcileConnections.begin(); - it != m_panAverageReconcileConnections.end(); ++it) { - QObject::disconnect(it.value()); - } - m_panAverageReconcileConnections.clear(); - for (auto it = m_panWeightedAvgReconcileConnections.begin(); - it != m_panWeightedAvgReconcileConnections.end(); ++it) { - QObject::disconnect(it.value()); - } - m_panWeightedAvgReconcileConnections.clear(); - for (auto it = m_panAverageReconcile.begin(); - it != m_panAverageReconcile.end(); ++it) { - if (it->timer) { - it->timer->stop(); - it->timer->deleteLater(); - } - } - m_panAverageReconcile.clear(); - for (auto it = m_panWeightedAvgReconcile.begin(); - it != m_panWeightedAvgReconcile.end(); ++it) { - if (it->timer) { - it->timer->stop(); - it->timer->deleteLater(); - } - } - m_panWeightedAvgReconcile.clear(); + m_panDisplayStatusConnections.clear(); m_adaptiveThrottleActive = false; m_adaptiveFpsCap = 0; // clear cap alongside throttle flag — see #2829 review @@ -6710,458 +6666,6 @@ void MainWindow::refreshCwDecodeState() m_audio->setCwDecodeTxTapEnabled(txOn); } -void MainWindow::schedulePanFpsReconcile(const QString& panId, int reportedFps) -{ - if (panId.isEmpty() || reportedFps <= 0) - return; - // While adaptive throttle is active the radio fps is intentionally below the - // user's desired value. Don't fight the throttle — MainWindow restores fps - // when adaptiveThrottleChanged(false) fires. - if (m_adaptiveThrottleActive) { - qCDebug(lcProtocol).noquote().nospace() - << "MainWindow: fps reconcile suppressed for pan=" << panId - << " reported=" << reportedFps << " (adaptive throttle active)"; - return; - } - if (profileLoadRadioStateWritesHeld()) { - qCDebug(lcProtocol).noquote().nospace() - << "MainWindow: fps reconcile suppressed for profile load pan=" << panId - << " reported=" << reportedFps; - return; - } - - auto* pan = m_radioModel.panadapter(panId); - if (!pan) - return; - - auto& state = m_panFpsReconcile[panId]; - if (!state.spectrum) { - if (auto* applet = m_panStack->panadapter(panId)) - state.spectrum = applet->spectrumWidget(); - } - - auto* sw = state.spectrum.data(); - if (!sw) - return; - - const int desiredFps = sw->fftFps(); - if (desiredFps <= 0) - return; - if (desiredFps == reportedFps) { - if (state.timer) - state.timer->stop(); - state.lastSentMs = 0; - state.lastSentDesired = -1; - return; - } - - if (!state.timer) { - state.timer = new QTimer(this); - state.timer->setSingleShot(true); - state.timer->setInterval(300); - connect(state.timer, &QTimer::timeout, this, [this, panId]() { - auto it = m_panFpsReconcile.find(panId); - if (it == m_panFpsReconcile.end()) - return; - - auto* pan = m_radioModel.panadapter(panId); - auto* sw = it->spectrum.data(); - if (!sw) { - if (auto* applet = m_panStack->panadapter(panId)) { - sw = applet->spectrumWidget(); - it->spectrum = sw; - } - } - if (!pan || !sw) - return; - if (profileLoadRadioStateWritesHeld()) { - qCDebug(lcProtocol).noquote().nospace() - << "MainWindow: fps timer suppressed for profile load pan=" << panId; - return; - } - - const int reported = pan->fps(); - const int desired = sw->fftFps(); - if (reported <= 0 || desired <= 0 || reported == desired) - return; - - constexpr qint64 kCooldownMs = 5000; - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - if (it->lastSentDesired == desired - && it->lastSentMs > 0 - && now - it->lastSentMs < kCooldownMs) { - return; - } - - qCDebug(lcProtocol).noquote().nospace() - << "MainWindow: reasserting panadapter FPS pan=" << panId - << " reported=" << reported - << " desired=" << desired; - m_radioModel.sendCommand( - QString("display pan set %1 fps=%2").arg(panId).arg(desired)); - it->lastSentMs = now; - it->lastSentDesired = desired; - }); - } - - state.timer->start(); -} - -void MainWindow::schedulePanAverageReconcile(const QString& panId, int reportedAverage) -{ - // FFT averaging is radio-authoritative (#4001): the firmware runs the - // averaging and echoes the level in pan status. After a global-profile / - // band switch the firmware adopts the profile's stored average, but the - // client never re-asserts the user's displayed level. Mirror the fps - // reconcile — reuse the profile-load write-hold + cooldown guards. Unlike - // fps, averaging is NOT adaptively throttled, so there is deliberately NO - // adaptive-throttle guard here. average=0 (off) is a VALID desired value, so - // guard on < 0 (the unknown sentinel), never <= 0. - if (panId.isEmpty() || reportedAverage < 0) - return; - if (profileLoadRadioStateWritesHeld()) { - qCDebug(lcProtocol).noquote().nospace() - << "MainWindow: average reconcile suppressed for profile load pan=" << panId - << " reported=" << reportedAverage; - return; - } - - auto* pan = m_radioModel.panadapter(panId); - if (!pan) - return; - - auto& state = m_panAverageReconcile[panId]; - if (!state.spectrum) { - if (auto* applet = m_panStack->panadapter(panId)) - state.spectrum = applet->spectrumWidget(); - } - - auto* sw = state.spectrum.data(); - if (!sw) - return; - - const int desiredAverage = sw->fftAverage(); - if (desiredAverage < 0) - return; - if (desiredAverage == reportedAverage) { - if (state.timer) - state.timer->stop(); - state.lastSentMs = 0; - state.lastSentDesired = -1; - return; - } - - if (!state.timer) { - state.timer = new QTimer(this); - state.timer->setSingleShot(true); - state.timer->setInterval(300); - connect(state.timer, &QTimer::timeout, this, [this, panId]() { - auto it = m_panAverageReconcile.find(panId); - if (it == m_panAverageReconcile.end()) - return; - - auto* pan = m_radioModel.panadapter(panId); - auto* sw = it->spectrum.data(); - if (!sw) { - if (auto* applet = m_panStack->panadapter(panId)) { - sw = applet->spectrumWidget(); - it->spectrum = sw; - } - } - if (!pan || !sw) - return; - if (profileLoadRadioStateWritesHeld()) { - qCDebug(lcProtocol).noquote().nospace() - << "MainWindow: average timer suppressed for profile load pan=" << panId; - return; - } - - const int reported = pan->average(); - const int desired = sw->fftAverage(); - if (reported < 0 || desired < 0 || reported == desired) - return; - - constexpr qint64 kCooldownMs = 5000; - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - if (it->lastSentDesired == desired - && it->lastSentMs > 0 - && now - it->lastSentMs < kCooldownMs) { - return; - } - - qCDebug(lcProtocol).noquote().nospace() - << "MainWindow: reasserting panadapter average pan=" << panId - << " reported=" << reported - << " desired=" << desired; - m_radioModel.sendCommand( - QString("display pan set %1 average=%2").arg(panId).arg(desired)); - it->lastSentMs = now; - it->lastSentDesired = desired; - }); - } - - state.timer->start(); -} - -void MainWindow::schedulePanWeightedAvgReconcile(const QString& panId, bool reportedWeighted) -{ - // weighted_average has the identical latent gap (#4001): a band switch via - // global profile adopts the profile's stored flag and the client never - // re-asserts the user's checkbox. Mirror the average reconcile; the wire - // field is a bool flag (weighted_average=0/1). No adaptive-throttle guard. - if (panId.isEmpty()) - return; - if (profileLoadRadioStateWritesHeld()) { - qCDebug(lcProtocol).noquote().nospace() - << "MainWindow: weighted_average reconcile suppressed for profile load pan=" << panId - << " reported=" << reportedWeighted; - return; - } - - auto* pan = m_radioModel.panadapter(panId); - if (!pan) - return; - - auto& state = m_panWeightedAvgReconcile[panId]; - if (!state.spectrum) { - if (auto* applet = m_panStack->panadapter(panId)) - state.spectrum = applet->spectrumWidget(); - } - - auto* sw = state.spectrum.data(); - if (!sw) - return; - - const bool desiredWeighted = sw->fftWeightedAvg(); - if (desiredWeighted == reportedWeighted) { - if (state.timer) - state.timer->stop(); - state.lastSentMs = 0; - state.lastSentDesired = -1; - return; - } - - if (!state.timer) { - state.timer = new QTimer(this); - state.timer->setSingleShot(true); - state.timer->setInterval(300); - connect(state.timer, &QTimer::timeout, this, [this, panId]() { - auto it = m_panWeightedAvgReconcile.find(panId); - if (it == m_panWeightedAvgReconcile.end()) - return; - - auto* pan = m_radioModel.panadapter(panId); - auto* sw = it->spectrum.data(); - if (!sw) { - if (auto* applet = m_panStack->panadapter(panId)) { - sw = applet->spectrumWidget(); - it->spectrum = sw; - } - } - if (!pan || !sw) - return; - if (profileLoadRadioStateWritesHeld()) { - qCDebug(lcProtocol).noquote().nospace() - << "MainWindow: weighted_average timer suppressed for profile load pan=" << panId; - return; - } - - const bool reported = pan->weightedAverage(); - const bool desired = sw->fftWeightedAvg(); - if (reported == desired) - return; - - constexpr qint64 kCooldownMs = 5000; - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - const int desiredInt = desired ? 1 : 0; - if (it->lastSentDesired == desiredInt - && it->lastSentMs > 0 - && now - it->lastSentMs < kCooldownMs) { - return; - } - - qCDebug(lcProtocol).noquote().nospace() - << "MainWindow: reasserting panadapter weighted_average pan=" << panId - << " reported=" << reported - << " desired=" << desired; - m_radioModel.sendCommand( - QString("display pan set %1 weighted_average=%2").arg(panId).arg(desiredInt)); - it->lastSentMs = now; - it->lastSentDesired = desiredInt; - }); - } - - state.timer->start(); -} - -void MainWindow::scheduleWaterfallLineDurationReconcile(const QString& panId, int reportedMs) -{ - if (panId.isEmpty() || reportedMs <= 0) - return; - if (m_adaptiveThrottleActive) { - qCDebug(lcProtocol).noquote().nospace() - << "MainWindow: wf line_duration reconcile suppressed for pan=" << panId - << " reported=" << reportedMs << "ms (adaptive throttle active)"; - return; - } - if (profileLoadRadioStateWritesHeld()) { - qCDebug(lcProtocol).noquote().nospace() - << "MainWindow: wf line_duration reconcile suppressed for profile load pan=" << panId - << " reported=" << reportedMs << "ms"; - return; - } - - auto* pan = m_radioModel.panadapter(panId); - if (!pan) - return; - - auto& state = m_wfLineDurationReconcile[panId]; - if (!state.spectrum) { - if (auto* applet = m_panStack->panadapter(panId)) - state.spectrum = applet->spectrumWidget(); - } - - auto* sw = state.spectrum.data(); - if (!sw) - return; - - const int desiredMs = sw->wfLineDuration(); - if (desiredMs <= 0) - return; - if (desiredMs == reportedMs) { - if (state.timer) - state.timer->stop(); - state.lastSentMs = 0; - state.lastSentDesired = -1; - return; - } - - if (!state.timer) { - state.timer = new QTimer(this); - state.timer->setSingleShot(true); - state.timer->setInterval(300); - connect(state.timer, &QTimer::timeout, this, [this, panId]() { - auto it = m_wfLineDurationReconcile.find(panId); - if (it == m_wfLineDurationReconcile.end()) - return; - - auto* pan = m_radioModel.panadapter(panId); - auto* sw = it->spectrum.data(); - if (!sw) { - if (auto* applet = m_panStack->panadapter(panId)) { - sw = applet->spectrumWidget(); - it->spectrum = sw; - } - } - if (!pan || !sw) - return; - if (profileLoadRadioStateWritesHeld()) { - qCDebug(lcProtocol).noquote().nospace() - << "MainWindow: wf line_duration timer suppressed for profile load pan=" << panId; - return; - } - - const QString wfId = pan->waterfallId(); - const int reported = pan->waterfallLineDuration(); - const int desired = sw->wfLineDuration(); - if (wfId.isEmpty() || reported <= 0 || desired <= 0 || reported == desired) - return; - - constexpr qint64 kCooldownMs = 5000; - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - if (it->lastSentDesired == desired - && it->lastSentMs > 0 - && now - it->lastSentMs < kCooldownMs) { - return; - } - - qCDebug(lcProtocol).noquote().nospace() - << "MainWindow: reasserting waterfall rate pan=" << panId - << " waterfall=" << wfId - << " reported_line_duration=" << reported - << " desired_line_duration=" << desired; - m_radioModel.sendCommand( - QString("display panafall set %1 line_duration=%2").arg(wfId).arg(desired)); - it->lastSentMs = now; - it->lastSentDesired = desired; - }); - } - - state.timer->start(); -} - -// Per-pan FPS / waterfall-line-duration reconcilers. Wired from -// wirePanadapter() for fresh pans and from the panadapterReclaimed handler -// for previous-session pans reclaimed on reconnect — the disconnect path -// explicitly tears these connections down, and reclaimed pans never re-emit -// panadapterAdded, so they need this re-wire to keep reconciling. -void MainWindow::wirePanReconcilers(PanadapterApplet* applet, PanadapterModel* pan) -{ - auto* sw = applet->spectrumWidget(); - if (!sw || !pan) - return; - - auto oldFpsConnection = m_panFpsReconcileConnections.take(applet->panId()); - if (oldFpsConnection) - QObject::disconnect(oldFpsConnection); - - auto& fpsState = m_panFpsReconcile[applet->panId()]; - fpsState.spectrum = sw; - m_panFpsReconcileConnections.insert( - applet->panId(), - connect(pan, &PanadapterModel::fpsReported, - this, [this, panId = applet->panId()](int fps) { - schedulePanFpsReconcile(panId, fps); - })); - schedulePanFpsReconcile(applet->panId(), pan->fps()); - - auto oldWfLineDurationConnection = - m_wfLineDurationReconcileConnections.take(applet->panId()); - if (oldWfLineDurationConnection) - QObject::disconnect(oldWfLineDurationConnection); - - auto& wfLineDurationState = m_wfLineDurationReconcile[applet->panId()]; - wfLineDurationState.spectrum = sw; - m_wfLineDurationReconcileConnections.insert( - applet->panId(), - connect(pan, &PanadapterModel::waterfallLineDurationReported, - this, [this, panId = applet->panId()](int ms) { - scheduleWaterfallLineDurationReconcile(panId, ms); - })); - scheduleWaterfallLineDurationReconcile(applet->panId(), - pan->waterfallLineDuration()); - - auto oldAverageConnection = - m_panAverageReconcileConnections.take(applet->panId()); - if (oldAverageConnection) - QObject::disconnect(oldAverageConnection); - - auto& averageState = m_panAverageReconcile[applet->panId()]; - averageState.spectrum = sw; - m_panAverageReconcileConnections.insert( - applet->panId(), - connect(pan, &PanadapterModel::averageReported, - this, [this, panId = applet->panId()](int average) { - schedulePanAverageReconcile(panId, average); - })); - schedulePanAverageReconcile(applet->panId(), pan->average()); - - auto oldWeightedAvgConnection = - m_panWeightedAvgReconcileConnections.take(applet->panId()); - if (oldWeightedAvgConnection) - QObject::disconnect(oldWeightedAvgConnection); - - auto& weightedAvgState = m_panWeightedAvgReconcile[applet->panId()]; - weightedAvgState.spectrum = sw; - m_panWeightedAvgReconcileConnections.insert( - applet->panId(), - connect(pan, &PanadapterModel::weightedAverageReported, - this, [this, panId = applet->panId()](bool weighted) { - schedulePanWeightedAvgReconcile(panId, weighted); - })); - schedulePanWeightedAvgReconcile(applet->panId(), pan->weightedAverage()); -} - // wirePanadapter() / revealFrequencyIfNeeded() / panFollowVfo() / wireVfoWidget() lives in MainWindow_Wiring.cpp (#3351 Phase 1d). void MainWindow::updateNr2Availability() { diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 1b6774cdf..22176dbb2 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -472,11 +472,7 @@ private slots: }; void scheduleKiwiSdrUiSync(int flags); void wirePanadapter(PanadapterApplet* applet); - void wirePanReconcilers(PanadapterApplet* applet, PanadapterModel* pan); - void schedulePanFpsReconcile(const QString& panId, int reportedFps); - void schedulePanAverageReconcile(const QString& panId, int reportedAverage); - void schedulePanWeightedAvgReconcile(const QString& panId, bool reportedWeighted); - void scheduleWaterfallLineDurationReconcile(const QString& panId, int reportedMs); + void wirePanDisplayStatus(PanadapterApplet* applet, PanadapterModel* pan); void reassertUnmutedSliceAudioForPan(const QString& panId); void onMuteAllSlicesToggle(); void showPanadapterInterlockNotification(const QString& message, @@ -1168,46 +1164,14 @@ private slots: QWidget* m_appletPanelFloatWindow{nullptr}; void floatAppletPanel(); void dockAppletPanel(); - bool m_displaySettingsPushed{false}; // one-shot: push saved display settings after pan created + bool m_displaySettingsPushed{false}; // one-shot: push client-rendered settings after pan creation bool m_applyingLayout{false}; // true during layout tear-down/recreate — suppresses panadapterAdded handler - struct PanFpsReconcileState { - QTimer* timer{nullptr}; - QPointer spectrum; - qint64 lastSentMs{0}; - int lastSentDesired{-1}; - }; - QHash m_panFpsReconcile; - QHash m_panFpsReconcileConnections; - // FFT averaging reconcile (#4001) — mirrors the fps reconcile so a global - // profile / band switch that adopts the profile's stored average/weighted - // value gets the user's desired value re-asserted once the write-hold - // releases. Averaging is NOT adaptively throttled, so no throttle guard. - struct PanAverageReconcileState { - QTimer* timer{nullptr}; - QPointer spectrum; - qint64 lastSentMs{0}; - int lastSentDesired{-1}; - }; - QHash m_panAverageReconcile; - QHash m_panAverageReconcileConnections; - struct PanWeightedAvgReconcileState { - QTimer* timer{nullptr}; - QPointer spectrum; - qint64 lastSentMs{0}; - int lastSentDesired{-1}; // 0/1 last-sent weighted_average flag - }; - QHash m_panWeightedAvgReconcile; - QHash m_panWeightedAvgReconcileConnections; - bool m_adaptiveThrottleActive{false}; // fps/wf reconcile suppressed while true + // Live radio status drives the four profile-owned processing controls. + // Keeping the connections per pan lets reconnect/reclaim replace them + // atomically without accumulating duplicate status handlers. + QHash> m_panDisplayStatusConnections; + bool m_adaptiveThrottleActive{false}; // fps/wf status held as restore targets while true int m_adaptiveFpsCap{0}; // current cap (> 0 when throttle active); shown in network label - struct WaterfallLineDurationReconcileState { - QTimer* timer{nullptr}; - QPointer spectrum; - qint64 lastSentMs{0}; - int lastSentDesired{-1}; - }; - QHash m_wfLineDurationReconcile; - QHash m_wfLineDurationReconcileConnections; QTimer* m_layoutRestoreTimer{nullptr}; // debounced layout rearrange after pans added on connect qint64 m_layoutRestoreUntilMs{0}; // User layout choices should suppress startup rearrange, but still allow diff --git a/src/gui/MainWindow_Session.cpp b/src/gui/MainWindow_Session.cpp index f6b1752b9..7bf3333c1 100644 --- a/src/gui/MainWindow_Session.cpp +++ b/src/gui/MainWindow_Session.cpp @@ -1084,7 +1084,9 @@ void MainWindow::wirePanLifecycle() } } }); - // Legacy panadapterInfoChanged — only used for initial display settings push. + // Legacy panadapterInfoChanged — only used for initial client-rendered + // display settings and local WNB/RF-gain restore. Profile-owned FFT + // processing and waterfall timing arrive through PanadapterModel status. // Per-pan frequency/level tracking is done via PanadapterModel signals in panadapterAdded. connect(&m_radioModel, &RadioModel::panadapterInfoChanged, this, [this]() { @@ -1092,17 +1094,10 @@ void MainWindow::wirePanLifecycle() auto* sw = spectrum(); if (!sw) return; // pan not yet available m_displaySettingsPushed = true; - m_radioModel.setPanAverage(sw->fftAverage()); - if (!m_adaptiveThrottleActive) - m_radioModel.setPanFps(sw->fftFps()); - m_radioModel.setPanWeightedAverage(sw->fftWeightedAvg()); m_radioModel.setWaterfallColorGain(sw->wfColorGain()); m_radioModel.setWaterfallBlackLevel(sw->wfBlackLevel()); m_radioModel.setWaterfallAutoBlack(sw->wfAutoBlack()); m_radioModel.setWaterfallAutoBlackSource(sw->wfAutoBlackRadioSide()); - int rate = sw->wfLineDuration(); - if (!m_adaptiveThrottleActive) - m_radioModel.setWaterfallLineDuration(rate); // Restore saved WNB and RF gain auto& s = AppSettings::instance(); bool wnbOn = s.value(sw->settingsKey("DisplayWnbEnabled"), "False").toString() == "True"; @@ -1131,14 +1126,6 @@ void MainWindow::wirePanLifecycle() s.value(sw->settingsKey("DisplaySpectrumRenderMode"), "0").toInt()); sw->setDssGain( s.value(sw->settingsKey("Display3DGain"), "70").toInt()); - // Nudge rate to force waterfall tile re-sync - if (!m_adaptiveThrottleActive) { - QTimer::singleShot(500, this, [this, rate]() { - const int nudgeRate = (rate < 100) ? rate + 1 : rate - 1; - m_radioModel.setWaterfallLineDuration(nudgeRate); - m_radioModel.setWaterfallLineDuration(rate); - }); - } } }); // NOTE: panadapterLevelChanged → spectrum()::setDbmRange has been removed. @@ -1314,8 +1301,8 @@ void MainWindow::wirePanLifecycle() // A reclaimed (previous-session) pan keeps its applet and all the // model→widget wiring from its original panadapterAdded, so the full add // path must not run again (it would duplicate connections). But the - // disconnect path tears down the per-pan FPS / waterfall-line-duration - // reconcilers, so those need re-wiring here. + // disconnect path tears down the four radio-owned display-status handlers, + // so those need re-wiring here. connect(&m_radioModel, &RadioModel::panadapterReclaimed, this, [this](PanadapterModel* pan) { if (m_shuttingDown || !m_panStack || !pan) { @@ -1325,7 +1312,7 @@ void MainWindow::wirePanLifecycle() if (!applet) { return; } - wirePanReconcilers(applet, pan); + wirePanDisplayStatus(applet, pan); for (SliceModel* slice : m_radioModel.slices()) { if (slice && slice->panId() == pan->panId()) { reattachSliceVisualsToPanadapter(slice); @@ -1388,57 +1375,10 @@ void MainWindow::wirePanLifecycle() if (m_shuttingDown || !m_panStack) { return; } - if (auto it = m_panFpsReconcileConnections.find(panId); - it != m_panFpsReconcileConnections.end()) { - QObject::disconnect(it.value()); - m_panFpsReconcileConnections.erase(it); - } - if (auto it = m_wfLineDurationReconcileConnections.find(panId); - it != m_wfLineDurationReconcileConnections.end()) { - QObject::disconnect(it.value()); - m_wfLineDurationReconcileConnections.erase(it); - } - if (auto it = m_panFpsReconcile.find(panId); - it != m_panFpsReconcile.end()) { - if (it->timer) { - it->timer->stop(); - it->timer->deleteLater(); - } - m_panFpsReconcile.erase(it); - } - if (auto it = m_wfLineDurationReconcile.find(panId); - it != m_wfLineDurationReconcile.end()) { - if (it->timer) { - it->timer->stop(); - it->timer->deleteLater(); - } - m_wfLineDurationReconcile.erase(it); - } - if (auto it = m_panAverageReconcileConnections.find(panId); - it != m_panAverageReconcileConnections.end()) { - QObject::disconnect(it.value()); - m_panAverageReconcileConnections.erase(it); - } - if (auto it = m_panWeightedAvgReconcileConnections.find(panId); - it != m_panWeightedAvgReconcileConnections.end()) { - QObject::disconnect(it.value()); - m_panWeightedAvgReconcileConnections.erase(it); - } - if (auto it = m_panAverageReconcile.find(panId); - it != m_panAverageReconcile.end()) { - if (it->timer) { - it->timer->stop(); - it->timer->deleteLater(); - } - m_panAverageReconcile.erase(it); - } - if (auto it = m_panWeightedAvgReconcile.find(panId); - it != m_panWeightedAvgReconcile.end()) { - if (it->timer) { - it->timer->stop(); - it->timer->deleteLater(); - } - m_panWeightedAvgReconcile.erase(it); + const QVector statusConnections = + m_panDisplayStatusConnections.take(panId); + for (const QMetaObject::Connection& connection : statusConnections) { + QObject::disconnect(connection); } // Disconnect all signals from the dying applet's widgets to prevent diff --git a/src/gui/MainWindow_Wiring.cpp b/src/gui/MainWindow_Wiring.cpp index a7ce2f138..4ada50b5d 100644 --- a/src/gui/MainWindow_Wiring.cpp +++ b/src/gui/MainWindow_Wiring.cpp @@ -20,6 +20,7 @@ #include "MainWindow.h" #include "AetherDspWidget.h" +#include "DisplayStatusGate.h" // #4261 adaptive-throttle echo gate #include "Ax25HfPacketDecodeDialog.h" #include "AppletPanel.h" #include "MainWindowHelpers.h" @@ -2110,6 +2111,86 @@ void MainWindow::runProfileLoadRecoveryPass(const QString& profileType, } } +void MainWindow::wirePanDisplayStatus(PanadapterApplet* applet, + PanadapterModel* pan) +{ + if (!applet || !pan) { + return; + } + SpectrumWidget* sw = applet->spectrumWidget(); + if (!sw) { + return; + } + + const QString panId = applet->panId(); + QVector oldConnections = + m_panDisplayStatusConnections.take(panId); + for (const QMetaObject::Connection& connection : oldConnections) { + QObject::disconnect(connection); + } + + QVector connections; + connections.reserve(4); + connections.append(connect( + pan, &PanadapterModel::averageReported, + sw, [sw](int average) { + if (average >= 0) { + sw->setFftAverage(average); + } + })); + connections.append(connect( + pan, &PanadapterModel::weightedAverageReported, + sw, [sw, pan](bool weighted) { + // Guard on "known" like the other three fields, so an unreported + // value doesn't paint a definitive unchecked box (#4261). + if (pan->weightedAverageKnown()) { + sw->setFftWeightedAvg(weighted); + } + })); + connections.append(connect( + pan, &PanadapterModel::fpsReported, + sw, [this, sw](int fps) { + // The adaptive cap is transient client transport state: suppress only + // the cap's own echo so the pre-cap radio value stays the restore + // target, but let a genuine radio/profile update through even while + // throttled (#4261 — otherwise it's lost when the throttle lifts). + if (applyThrottledDisplayReport(m_adaptiveThrottleActive, + m_adaptiveFpsCap, fps)) { + sw->setFftFps(fps); + } + })); + connections.append(connect( + pan, &PanadapterModel::waterfallLineDurationReported, + sw, [this, sw](int lineDurationMs) { + if (applyThrottledDisplayReport( + m_adaptiveThrottleActive, + m_radioModel.adaptiveWfMsForCap(m_adaptiveFpsCap), + lineDurationMs)) { + sw->setWfLineDuration(lineDurationMs); + } + })); + m_panDisplayStatusConnections.insert(panId, connections); + + // Reclaimed pans already hold their latest status and do not necessarily + // emit a new report after reconnect. Seed the view immediately. + if (pan->average() >= 0) { + sw->setFftAverage(pan->average()); + } + if (pan->weightedAverageKnown()) { + sw->setFftWeightedAvg(pan->weightedAverage()); + } + if (applyThrottledDisplayReport(m_adaptiveThrottleActive, + m_adaptiveFpsCap, pan->fps())) { + sw->setFftFps(pan->fps()); + } + if (applyThrottledDisplayReport( + m_adaptiveThrottleActive, + m_radioModel.adaptiveWfMsForCap(m_adaptiveFpsCap), + pan->waterfallLineDuration())) { + sw->setWfLineDuration(pan->waterfallLineDuration()); + } +} + void MainWindow::wirePanadapter(PanadapterApplet* applet) { @@ -2377,7 +2458,7 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet) // correct radio-reported range via the pendingDbm guard. (#3034) sw->setDbmRange(pan->minDbm(), pan->maxDbm()); - wirePanReconcilers(applet, pan); + wirePanDisplayStatus(applet, pan); } syncTxWaterfallSliceToSpectrums(); @@ -3123,19 +3204,15 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet) // Persist all defaults to AppSettings auto& s = AppSettings::instance(); - s.setValue(sw->settingsKey("DisplayFftAverage"), "0"); - s.setValue(sw->settingsKey("DisplayFftFps"), "25"); s.setValue(sw->settingsKey("DisplayFftFillAlpha"), "0.70"); s.setValue(sw->settingsKey("DisplayFftFillColor"), "#00e5ff"); s.setValue(sw->settingsKey("DisplayFftLineWidth"), "2.0"); - s.setValue(sw->settingsKey("DisplayFftWeightedAvg"), "False"); s.setValue(sw->settingsKey("DisplayFftHeatMap"), "True"); s.setValue(sw->settingsKey("DisplayWfColorScheme"), "0"); s.setValue(sw->settingsKey("DisplayWfColorGain"), "50"); s.setValue(sw->settingsKey("DisplayWfBlackLevel"), "15"); s.setValue(sw->settingsKey("DisplayWfAutoBlack"), "True"); s.setValue(sw->settingsKey("DisplayWfAutoBlackRadioSide"), "False"); - s.setValue(sw->settingsKey("DisplayWfLineDuration"), "100"); s.setValue(sw->settingsKey("WaterfallBlankingEnabled"), "False"); s.setValue(sw->settingsKey("WaterfallBlankingThreshold"), "1.15"); s.setValue(sw->settingsKey("WaterfallBlankingMode"), "0"); diff --git a/src/gui/SpectrumOverlayMenu.cpp b/src/gui/SpectrumOverlayMenu.cpp index ba545e6e8..cf3dd375c 100644 --- a/src/gui/SpectrumOverlayMenu.cpp +++ b/src/gui/SpectrumOverlayMenu.cpp @@ -2184,6 +2184,23 @@ void SpectrumOverlayMenu::syncNoiseFloorPosition(int pos) } } +void SpectrumOverlayMenu::syncPanProcessingSettings(int avg, int fps, + bool weightedAvg) +{ + if (!m_avgSlider || !m_fpsSlider || !m_weightedAvgBtn) { + return; + } + + const QSignalBlocker avgBlocker(m_avgSlider); + const QSignalBlocker fpsBlocker(m_fpsSlider); + const QSignalBlocker weightedBlocker(m_weightedAvgBtn); + m_avgSlider->setValue(avg); + m_avgLabel->setText(QString::number(avg)); + m_fpsSlider->setValue(fps); + m_fpsLabel->setText(QString::number(fps)); + m_weightedAvgBtn->setChecked(weightedAvg); +} + void SpectrumOverlayMenu::syncDssFloorDepth(int dB) { if (!m_dssFloorSlider) { diff --git a/src/gui/SpectrumOverlayMenu.h b/src/gui/SpectrumOverlayMenu.h index c4ce85fd8..ae685060f 100644 --- a/src/gui/SpectrumOverlayMenu.h +++ b/src/gui/SpectrumOverlayMenu.h @@ -43,10 +43,10 @@ class SpectrumOverlayMenu : public QWidget { void setKiwiSdrManager(KiwiSdrManager* manager); void setRadioModel(RadioModel* model); - // Sync Display sub-panel controls with saved settings. The Black slider - // displays `black` while autoBlack is off and `autoBlackOffset` while it - // is on; both values are stored internally so toggling the AUTO button - // swaps the slider position without losing either preference. + // Sync the complete Display sub-panel. Radio-owned values are supplied by + // live status; client-rendered values come from AppSettings. The Black + // slider displays `black` while autoBlack is off and `autoBlackOffset` + // while it is on. void syncDisplaySettings(int avg, int fps, int fillPct, bool weightedAvg, const QColor& fillColor, int gain, int black, bool autoBlack, int autoBlackOffset, int rate, @@ -58,6 +58,10 @@ class SpectrumOverlayMenu : public QWidget { int renderMode = 0, int dssFloorDepth = 6, int dssGain = 70); + // Update only the radio-owned pan processing controls from live status. + // Signal blockers keep status echoes from generating commands back to the + // radio. + void syncPanProcessingSettings(int avg, int fps, bool weightedAvg); void syncWfLineDuration(int rate); void syncKiwiWaterfallSettings(int minDbm, int maxDbm, bool autoScale, int rate); diff --git a/src/gui/SpectrumWidget.cpp b/src/gui/SpectrumWidget.cpp index fad87653d..d3e2fdca2 100644 --- a/src/gui/SpectrumWidget.cpp +++ b/src/gui/SpectrumWidget.cpp @@ -887,6 +887,12 @@ QVariantMap SpectrumWidget::panstatsSnapshot(bool reset) ? QStringLiteral("3D") : QStringLiteral("2D"); m[QStringLiteral("renderer")] = rendererDescription(); m[QStringLiteral("sinceMs")] = static_cast(m_panStats.sinceMs()); + m[QStringLiteral("fftAverage")] = m_fftAverage; + m[QStringLiteral("fftFps")] = m_fftFps; + m[QStringLiteral("fftWeightedAverage")] = m_fftWeightedAvg; + m[QStringLiteral("waterfallLineDurationMs")] = m_wfLineDuration; + m[QStringLiteral("waterfallSource")] = m_kiwiSdrWaterfallActive + ? QStringLiteral("kiwi") : QStringLiteral("flex"); m[QStringLiteral("fftFramesPerSec")] = m_panStats.updateSpectrumCalls / secs; m[QStringLiteral("ingestMsPerSec")] = msPerSec(m_panStats.updateSpectrumUs); @@ -905,9 +911,79 @@ QVariantMap SpectrumWidget::panstatsSnapshot(bool reset) static_cast(m_panStats.overlayUploadBytes) / secs; m[QStringLiteral("wfUploadBytesPerSec")] = static_cast(m_panStats.wfUploadBytes) / secs; + m[QStringLiteral("nativeWaterfallUpdatesPerSec")] = + m_panStats.nativeWaterfallCalls / secs; + m[QStringLiteral("nativeWaterfallUpdateMsPerSec")] = + msPerSec(m_panStats.nativeWaterfallUs); + m[QStringLiteral("nativeWaterfallHiddenUpdatesPerSec")] = + m_panStats.nativeWaterfallHiddenCalls / secs; + m[QStringLiteral("kiwiWaterfallUpdatesPerSec")] = + m_panStats.kiwiWaterfallCalls / secs; + m[QStringLiteral("kiwiWaterfallUpdateMsPerSec")] = + msPerSec(m_panStats.kiwiWaterfallUs); + m[QStringLiteral("kiwiWaterfallHiddenUpdatesPerSec")] = + m_panStats.kiwiWaterfallHiddenCalls / secs; + m[QStringLiteral("waterfallVisibleRowsPerSec")] = + m_panStats.waterfallVisibleRows / secs; + m[QStringLiteral("waterfallVisibleRowMsPerSec")] = + msPerSec(m_panStats.waterfallVisibleRowUs); + m[QStringLiteral("waterfallHistoryRowsPerSec")] = + m_panStats.waterfallHistoryRows / secs; + m[QStringLiteral("waterfallHistoryRowMsPerSec")] = + msPerSec(m_panStats.waterfallHistoryRowUs); + m[QStringLiteral("dssLiveRowsPerSec")] = m_panStats.dssLiveRows / secs; + m[QStringLiteral("dssLiveMsPerSec")] = msPerSec(m_panStats.dssLiveUs); + m[QStringLiteral("dssHiddenLiveRowsPerSec")] = + m_panStats.dssHiddenLiveRows / secs; + m[QStringLiteral("dssHistoryRowsPerSec")] = m_panStats.dssHistoryRows / secs; + m[QStringLiteral("dssHistoryMsPerSec")] = msPerSec(m_panStats.dssHistoryUs); m[QStringLiteral("paintsPerSec")] = m_panStats.paintEvents / secs; m[QStringLiteral("paintMsPerSec")] = msPerSec(m_panStats.paintUs); + quint64 cachedWaterfallVisibleBytes = 0; + quint64 cachedWaterfallHistoryBytes = 0; + quint64 dssFixedBytes = m_dss.fixedStorageBytes(); + quint64 dssHistoryBytes = m_dss.historyStorageBytes(); + quint64 dssCacheBytes = m_dss.cacheStorageBytes(); + int dssRendererCount = 1; + auto accountState = [&](const WaterfallStreamState& state) { + cachedWaterfallVisibleBytes += state.waterfall.isNull() + ? 0 : static_cast(state.waterfall.sizeInBytes()); + cachedWaterfallHistoryBytes += state.waterfallHistory.isNull() + ? 0 : static_cast(state.waterfallHistory.sizeInBytes()); + dssFixedBytes += state.dss.fixedStorageBytes(); + dssHistoryBytes += state.dss.historyStorageBytes(); + dssCacheBytes += state.dss.cacheStorageBytes(); + ++dssRendererCount; + }; + accountState(m_nativeWaterfallState); + accountState(m_kiwiWaterfallState); + for (auto it = m_kiwiProfileWaterfallStates.cbegin(); + it != m_kiwiProfileWaterfallStates.cend(); ++it) { + accountState(it.value()); + } + const quint64 currentWaterfallVisibleBytes = m_waterfall.isNull() + ? 0 : static_cast(m_waterfall.sizeInBytes()); + const quint64 currentWaterfallHistoryBytes = m_waterfallHistory.isNull() + ? 0 : static_cast(m_waterfallHistory.sizeInBytes()); + m[QStringLiteral("currentWaterfallVisibleBytes")] = + static_cast(currentWaterfallVisibleBytes); + m[QStringLiteral("currentWaterfallHistoryBytes")] = + static_cast(currentWaterfallHistoryBytes); + m[QStringLiteral("cachedWaterfallVisibleBytes")] = + static_cast(cachedWaterfallVisibleBytes); + m[QStringLiteral("cachedWaterfallHistoryBytes")] = + static_cast(cachedWaterfallHistoryBytes); + m[QStringLiteral("waterfallAllocatedBytes")] = static_cast( + currentWaterfallVisibleBytes + currentWaterfallHistoryBytes + + cachedWaterfallVisibleBytes + cachedWaterfallHistoryBytes); + m[QStringLiteral("dssRendererCount")] = dssRendererCount; + m[QStringLiteral("dssFixedBytes")] = static_cast(dssFixedBytes); + m[QStringLiteral("dssHistoryBytes")] = static_cast(dssHistoryBytes); + m[QStringLiteral("dssCacheBytes")] = static_cast(dssCacheBytes); + m[QStringLiteral("dssAllocatedBytes")] = static_cast( + dssFixedBytes + dssHistoryBytes + dssCacheBytes); + QVariantMap causes; for (auto it = m_panStats.dirtyCauses.cbegin(); it != m_panStats.dirtyCauses.cend(); ++it) @@ -949,6 +1025,16 @@ QVariantMap SpectrumWidget::automationDssSnapshot() const m[QStringLiteral("dssVisibleRows")] = m_dss.rowCount(); m[QStringLiteral("dssHistoryRows")] = m_dss.historyRowCount(); m[QStringLiteral("dssHistoryCapacityRows")] = m_dss.historyCapacityRows(); + m[QStringLiteral("dssFixedBytes")] = + static_cast(m_dss.fixedStorageBytes()); + m[QStringLiteral("dssHistoryBytes")] = + static_cast(m_dss.historyStorageBytes()); + m[QStringLiteral("dssCacheBytes")] = + static_cast(m_dss.cacheStorageBytes()); + m[QStringLiteral("dssAllocatedBytes")] = + static_cast(m_dss.allocatedBytes()); + m[QStringLiteral("waterfallHistoryBytes")] = m_waterfallHistory.isNull() + ? 0 : static_cast(m_waterfallHistory.sizeInBytes()); int peakBin = -1; float peakDbm = -1000.0f; @@ -1665,11 +1751,30 @@ QString SpectrumWidget::settingsKey(const QString& base) const void SpectrumWidget::loadSettings() { auto& s = AppSettings::instance(); + // These four values are stored by the radio (including in profiles). Older + // releases persisted a competing client copy and reasserted it after status + // updates (#2465, #4126). Remove the stale copies once; the member defaults + // are only placeholders until the first PanadapterModel status arrives. + bool removedRadioOwnedDisplaySetting = false; + const QStringList radioOwnedDisplaySettings = { + QStringLiteral("DisplayFftAverage"), + QStringLiteral("DisplayFftFps"), + QStringLiteral("DisplayFftWeightedAvg"), + QStringLiteral("DisplayWfLineDuration"), + }; + for (const QString& base : radioOwnedDisplaySettings) { + const QString key = settingsKey(base); + if (s.contains(key)) { + s.remove(key); + removedRadioOwnedDisplaySetting = true; + } + } + if (removedRadioOwnedDisplaySetting) { + s.save(); + } + m_spectrumFrac = std::clamp(s.value(settingsKey("SpectrumSplitRatio"), "0.40").toFloat(), 0.10f, 0.90f); - m_fftAverage = s.value(settingsKey("DisplayFftAverage"), "0").toInt(); - m_fftFps = s.value(settingsKey("DisplayFftFps"), "25").toInt(); m_fftFillAlpha = s.value(settingsKey("DisplayFftFillAlpha"), "0.70").toFloat(); - m_fftWeightedAvg = s.value(settingsKey("DisplayFftWeightedAvg"), "False").toString() == "True"; const QString fillColorStr = s.value(settingsKey("DisplayFftFillColor"), "#00e5ff").toString(); QColor parsed(fillColorStr); if (parsed.isValid()) @@ -1680,9 +1785,6 @@ void SpectrumWidget::loadSettings() m_wfAutoBlackOffset = s.value(settingsKey("DisplayWfAutoBlackOffset"), "50").toInt(); // Auto-black source defaults to client-side (legacy look); radio-side opt-in. m_wfAutoBlackRadioSide = s.value(settingsKey("DisplayWfAutoBlackRadioSide"), "False").toString() == "True"; - m_wfLineDuration = std::clamp(s.value(settingsKey("DisplayWfLineDuration"), "100").toInt(), - kWaterfallLineDurationMinMs, - kWaterfallLineDurationMaxMs); PerfTelemetry::instance().setWaterfallLineDurationMs(m_wfLineDuration); resetWfTimeScale(); // NB Waterfall Blanker (#277) @@ -1921,7 +2023,7 @@ void SpectrumWidget::applyActiveVfoZOrder() raisePanadapterMessageOverlay(); } -// ── Display control setters (save to AppSettings on each change) ────────────── +// ── Display control setters ────────────────────────────────────────────────── void SpectrumWidget::setBandPlanManager(BandPlanManager* mgr) { m_bandPlanMgr = mgr; @@ -1931,9 +2033,10 @@ void SpectrumWidget::setBandPlanManager(BandPlanManager* mgr) { void SpectrumWidget::setFftAverage(int frames) { m_fftAverage = frames; - auto& s = AppSettings::instance(); - s.setValue(settingsKey("DisplayFftAverage"), QString::number(frames)); - s.save(); + if (m_overlayMenu) { + m_overlayMenu->syncPanProcessingSettings( + m_fftAverage, m_fftFps, m_fftWeightedAvg); + } } QString SpectrumWidget::displaySourceTraceSettingsKey() const @@ -2175,15 +2278,17 @@ void SpectrumWidget::prepareForFftScaleChange() void SpectrumWidget::setFftWeightedAvg(bool on) { m_fftWeightedAvg = on; - auto& s = AppSettings::instance(); - s.setValue(settingsKey("DisplayFftWeightedAvg"), on ? "True" : "False"); - s.save(); + if (m_overlayMenu) { + m_overlayMenu->syncPanProcessingSettings( + m_fftAverage, m_fftFps, m_fftWeightedAvg); + } } void SpectrumWidget::setFftFps(int fps) { m_fftFps = fps; - auto& s = AppSettings::instance(); - s.setValue(settingsKey("DisplayFftFps"), QString::number(fps)); - s.save(); + if (m_overlayMenu) { + m_overlayMenu->syncPanProcessingSettings( + m_fftAverage, m_fftFps, m_fftWeightedAvg); + } } void SpectrumWidget::setFftHeatMap(bool on) { m_fftHeatMap = on; @@ -3122,9 +3227,6 @@ void SpectrumWidget::setWfLineDuration(int ms) { m_wfLineDuration = clamped; PerfTelemetry::instance().setWaterfallLineDurationMs(m_wfLineDuration); - auto& s = AppSettings::instance(); - s.setValue(settingsKey("DisplayWfLineDuration"), QString::number(m_wfLineDuration)); - s.save(); if (m_overlayMenu) { m_overlayMenu->syncWfLineDuration(m_wfLineDuration); } @@ -3507,6 +3609,18 @@ void SpectrumWidget::setSpectrumRenderMode(int mode) { s.setValue(settingsKey("DisplaySpectrumRenderMode"), QString::number(static_cast(m_spectrumRenderMode))); s.save(); + if (m_spectrumRenderMode == SpectrumRenderMode::Mode3D) { + // Allocate retained DSS scrollback only while it can be displayed. + ensureWaterfallHistory(); + } else { + m_dss.setHistoryCapacityRows(0); + m_nativeWaterfallState.dss.setHistoryCapacityRows(0); + m_kiwiWaterfallState.dss.setHistoryCapacityRows(0); + for (auto it = m_kiwiProfileWaterfallStates.begin(); + it != m_kiwiProfileWaterfallStates.end(); ++it) { + it->dss.setHistoryCapacityRows(0); + } + } // Force a full rebuild of the 3DSS surface + its GPU texture, and the // overlay (grid/scales differ between modes). m_dss.invalidate(); @@ -3813,8 +3927,11 @@ void SpectrumWidget::ensureWaterfallHistory() return; } + const bool retainDssHistory = + m_waterfallWriteVisible + && m_spectrumRenderMode == SpectrumRenderMode::Mode3D; if (m_waterfallHistory.size() == desiredSize) { - m_dss.setHistoryCapacityRows(desiredSize.height()); + m_dss.setHistoryCapacityRows(retainDssHistory ? desiredSize.height() : 0); return; } @@ -3840,7 +3957,7 @@ void SpectrumWidget::ensureWaterfallHistory() m_wfLive = true; } m_waterfallHistory = newHistory; - m_dss.setHistoryCapacityRows(desiredSize.height()); + m_dss.setHistoryCapacityRows(retainDssHistory ? desiredSize.height() : 0); } float SpectrumWidget::dssHistoryFallbackDbm() const @@ -3854,14 +3971,18 @@ void SpectrumWidget::appendDssHistoryRow(const QVector& binsDbm, double frameCenterMhz, double frameBandwidthMhz) { + if (!m_waterfallWriteVisible + || m_spectrumRenderMode != SpectrumRenderMode::Mode3D) { + return; + } const double stampCenterMhz = (frameCenterMhz > 0.0 && frameBandwidthMhz > 0.0) ? frameCenterMhz : m_centerMhz; const double stampBandwidthMhz = frameBandwidthMhz > 0.0 ? frameBandwidthMhz : m_bandwidthMhz; - m_dss.appendHistoryRow(binsDbm, stampCenterMhz, stampBandwidthMhz, - dssHistoryFallbackDbm()); + retainDssHistoryRow(m_dss, binsDbm, stampCenterMhz, stampBandwidthMhz, + dssHistoryFallbackDbm()); } void SpectrumWidget::appendDssWaterfallRow(const QVector& binsDbm, @@ -3871,11 +3992,44 @@ void SpectrumWidget::appendDssWaterfallRow(const QVector& binsDbm, { // Keep live DSS and retained scrollback DSS on the same waterfall-row cadence. if (m_wfLive && updateLiveSurface) { - m_dss.pushRow(binsDbm); + pushDssLiveRow(m_dss, binsDbm, !m_waterfallWriteVisible); } appendDssHistoryRow(binsDbm, frameCenterMhz, frameBandwidthMhz); } +void SpectrumWidget::pushDssLiveRow(DssRenderer& dss, + const QVector& binsDbm, + bool hiddenStream) +{ + QElapsedTimer timer; + timer.start(); + dss.pushRow(binsDbm); + m_panStats.dssLiveUs += static_cast(timer.nsecsElapsed() / 1000); + ++m_panStats.dssLiveRows; + if (hiddenStream) { + ++m_panStats.dssHiddenLiveRows; + } +} + +void SpectrumWidget::retainDssHistoryRow(DssRenderer& dss, + const QVector& binsDbm, + double centerMhz, + double bandwidthMhz, + float fallbackDbm) +{ + // Hidden sources release their retained DSS history (capacity 0), so this + // early-returns for them and only the visible source retains scrollback — + // there is deliberately no "hidden history" to count (#4081/#4083). + if (dss.historyCapacityRows() <= 0) { + return; + } + QElapsedTimer timer; + timer.start(); + dss.appendHistoryRow(binsDbm, centerMhz, bandwidthMhz, fallbackDbm); + m_panStats.dssHistoryUs += static_cast(timer.nsecsElapsed() / 1000); + ++m_panStats.dssHistoryRows; +} + void SpectrumWidget::appendLatestDssWaterfallRow(double frameCenterMhz, double frameBandwidthMhz) { @@ -3893,9 +4047,14 @@ void SpectrumWidget::appendVisibleRow(const QRgb* rowData) return; } + QElapsedTimer timer; + timer.start(); m_wfWriteRow = (m_wfWriteRow - 1 + h) % h; auto* row = reinterpret_cast(m_waterfall.bits() + m_wfWriteRow * m_waterfall.bytesPerLine()); std::memcpy(row, rowData, m_waterfall.width() * sizeof(QRgb)); + m_panStats.waterfallVisibleRowUs += + static_cast(timer.nsecsElapsed() / 1000); + ++m_panStats.waterfallVisibleRows; if (PerfTelemetry::instance().enabled()) PerfTelemetry::instance().recordWaterfallVisibleRows(); } @@ -3904,6 +4063,15 @@ void SpectrumWidget::appendHistoryRow(const QRgb* rowData, qint64 timestampMs, double frameCenterMhz, double frameBandwidthMhz) { + // A hidden Flex/Kiwi source keeps only its small viewport and 96-row live + // DSS surface warm. Retained scrollback belongs to the visible source; it + // is rebuilt from new rows after a source switch (#4081, #4083). + if (!m_waterfallWriteVisible) { + return; + } + + QElapsedTimer timer; + timer.start(); ensureWaterfallHistory(); if (m_waterfallHistory.isNull() || rowData == nullptr) { return; @@ -3939,6 +4107,11 @@ void SpectrumWidget::appendHistoryRow(const QRgb* rowData, qint64 timestampMs, if (!m_wfLive) { m_wfHistoryOffsetRows = std::min(m_wfHistoryOffsetRows + 1, maxWaterfallHistoryOffsetRows()); } + m_panStats.waterfallHistoryRowUs += + static_cast(timer.nsecsElapsed() / 1000); + ++m_panStats.waterfallHistoryRows; + // No hidden-history counter: appendHistoryRow early-returns for a hidden + // source above, so RGB history is only ever written for the visible one. } // Copy one history scanline into the viewport, remapping its columns from the @@ -4301,8 +4474,9 @@ void SpectrumWidget::resetCurrentWaterfallRowsForSize( m_waterfallStreamSizeHint = QSize(); } - QSize desiredHistorySize = historySize; - if (desiredHistorySize.isEmpty() && !waterfallSize.isEmpty()) { + QSize desiredHistorySize = m_waterfallWriteVisible ? historySize : QSize{}; + if (m_waterfallWriteVisible + && desiredHistorySize.isEmpty() && !waterfallSize.isEmpty()) { desiredHistorySize = QSize(waterfallSize.width(), waterfallHistoryCapacityRows()); } @@ -4312,7 +4486,10 @@ void SpectrumWidget::resetCurrentWaterfallRowsForSize( m_wfHistoryTimestamps = QVector(desiredHistorySize.height(), 0); m_wfHistoryRowCenterMhz = QVector(desiredHistorySize.height(), 0.0); m_wfHistoryRowBwMhz = QVector(desiredHistorySize.height(), 0.0); - m_dss.setHistoryCapacityRows(desiredHistorySize.height()); + const bool retainDssHistory = + m_spectrumRenderMode == SpectrumRenderMode::Mode3D; + m_dss.setHistoryCapacityRows( + retainDssHistory ? desiredHistorySize.height() : 0); } else { m_waterfallHistory = QImage(); m_waterfallHistoryStreamSizeHint = QSize(); @@ -4410,6 +4587,20 @@ void SpectrumWidget::saveCurrentWaterfallStreamState() state = std::move(updated); } +void SpectrumWidget::discardRetainedHistory(WaterfallStreamState& state) +{ + state.waterfallHistory = QImage{}; + state.historyTimestamps.clear(); + state.historyWriteRow = 0; + state.historyRowCount = 0; + state.historyOffsetRows = 0; + state.historyRowCenterMhz.clear(); + state.historyRowBwMhz.clear(); + state.rowsSinceRateChange = 0; + state.live = true; + state.dss.setHistoryCapacityRows(0); +} + void SpectrumWidget::restoreCurrentWaterfallStreamState() { WaterfallStreamState& state = m_kiwiSdrWaterfallActive @@ -4427,8 +4618,8 @@ void SpectrumWidget::restoreCurrentWaterfallStreamState() && (state.waterfall.isNull() || state.waterfall.size() != currentSize); const bool stateHasWrongHistory = !currentHistorySize.isEmpty() - && (state.waterfallHistory.isNull() - || state.waterfallHistory.size() != currentHistorySize); + && !state.waterfallHistory.isNull() + && state.waterfallHistory.size() != currentHistorySize; if (!state.valid || stateHasWrongWaterfall || stateHasWrongHistory) { const bool restoreKiwiDisplayRange = m_kiwiSdrWaterfallActive && state.kiwiDisplayRangeValid; @@ -4493,11 +4684,15 @@ void SpectrumWidget::restoreCurrentWaterfallStreamState() m_dssMeshHeadUploaded = restored.dssMeshHeadUploaded; m_dssMeshRowGenUploaded = restored.dssMeshRowGenUploaded; #endif + if (m_waterfallWriteVisible) { + ensureWaterfallHistory(); + } } bool SpectrumWidget::beginWaterfallStreamWrite(bool kiwiStream) { const bool visibleStream = m_kiwiSdrWaterfallActive == kiwiStream; + m_waterfallWriteVisible = visibleStream; if (visibleStream) { return true; } @@ -4517,6 +4712,7 @@ void SpectrumWidget::endWaterfallStreamWrite(bool kiwiStream, saveCurrentWaterfallStreamState(); m_kiwiSdrWaterfallActive = !kiwiStream; + m_waterfallWriteVisible = true; restoreCurrentWaterfallStreamState(); } @@ -5822,16 +6018,18 @@ void SpectrumWidget::updateSpectrum(const QVector& binsDbm) } m_bins = *spectrumBins; - // While Kiwi is displayed, keep the *background* Flex surface warm every - // frame (not only in 3D and not only while Flex is visible) so switching - // back from Kiwi shows current Flex 3D history instead of an old surface - // that refills over ~96 frames. Raw bins: the renderer does its own - // spatial/temporal smoothing. When Flex is the active stream this must NOT - // run: updateWaterfallRow() already advances m_dss at waterfall-row cadence - // (paired with the 2D waterfall appendHistoryRow), and feeding here too - // would over-advance the retained DSS scrollback past the waterfall (#4083). if (m_kiwiSdrWaterfallActive) { + // The cached Flex FFT trace (m_bins, updated above) stays current for an + // immediate source switch. Warm the small hidden-Flex DSS live ring too, + // so switching back to Flex shows current 3D history instead of an old + // surface that refills over ~96 frames (#4081). The deep retained DSS + // history is already released for hidden sources (capacity 0), so + // pushDssRowForWaterfallStream() keeps only the small live surface warm + // and retains no scrollback (#4083). Then stop: hidden Flex frames must + // not drive fallback waterfall rows or schedule QRhi redraws for the + // visible Kiwi view. pushDssRowForWaterfallStream(false, m_bins); + return; } if (!m_kiwiSdrWaterfallActive) { @@ -5930,6 +6128,25 @@ void SpectrumWidget::updateWaterfallRow(const QVector& binsIntensity, PerfUpdateScope perfScope(PerfUpdateScope::Kind::Waterfall); // Native waterfall tiles carry intensity values (int16/128.0f, ~96-120 on HF). if (binsIntensity.isEmpty()) return; + ++m_panStats.nativeWaterfallCalls; + if (m_kiwiSdrWaterfallActive) { + ++m_panStats.nativeWaterfallHiddenCalls; + } + struct WaterfallIngestCost { + quint64& acc; + QElapsedTimer timer; + explicit WaterfallIngestCost(quint64& value) : acc(value) { timer.start(); } + ~WaterfallIngestCost() { + acc += static_cast(timer.nsecsElapsed() / 1000); + } + } ingestCost(m_panStats.nativeWaterfallUs); + if (m_kiwiSdrWaterfallActive) { + // The cached Flex viewport remains available for an immediate source + // switch. Do not spend a full state swap, color conversion, viewport + // write, and DSS update on data that cannot be displayed; the first + // fresh Flex tile replaces the snapshot within one radio frame. + return; + } const bool visibleStream = beginWaterfallStreamWrite(false); auto restoreStream = qScopeGuard([&] { endWaterfallStreamWrite(false, visibleStream); @@ -6123,6 +6340,15 @@ void SpectrumWidget::setKiwiSdrWaterfallActive(bool active) dssFloorDepth(), false); saveCurrentWaterfallStreamState(); + // Retained RGB/DSS scrollback follows the visible source. Keep the small + // live viewport/DSS rings in every cached state for an immediate toggle, + // but release all deep histories before choosing the new owner. + discardRetainedHistory(m_nativeWaterfallState); + discardRetainedHistory(m_kiwiWaterfallState); + for (auto it = m_kiwiProfileWaterfallStates.begin(); + it != m_kiwiProfileWaterfallStates.end(); ++it) { + discardRetainedHistory(it.value()); + } m_kiwiSdrWaterfallActive = active; m_lastAutoSquelchLevel = -1; if (!active) { @@ -6205,6 +6431,12 @@ void SpectrumWidget::setKiwiSdrWaterfallProfile(const QString& profileId) if (m_kiwiSdrWaterfallActive) { saveCurrentWaterfallStreamState(); + discardRetainedHistory(m_nativeWaterfallState); + discardRetainedHistory(m_kiwiWaterfallState); + for (auto it = m_kiwiProfileWaterfallStates.begin(); + it != m_kiwiProfileWaterfallStates.end(); ++it) { + discardRetainedHistory(it.value()); + } } m_kiwiSdrWaterfallProfileId = normalized; if (m_kiwiSdrWaterfallActive) { @@ -6374,6 +6606,25 @@ void SpectrumWidget::updateKiwiSdrWaterfallRow(const QVector& binsDbm, return; } + ++m_panStats.kiwiWaterfallCalls; + if (!m_kiwiSdrWaterfallActive) { + ++m_panStats.kiwiWaterfallHiddenCalls; + } + struct KiwiWaterfallIngestCost { + quint64& acc; + QElapsedTimer timer; + explicit KiwiWaterfallIngestCost(quint64& value) : acc(value) { timer.start(); } + ~KiwiWaterfallIngestCost() { + acc += static_cast(timer.nsecsElapsed() / 1000); + } + } ingestCost(m_panStats.kiwiWaterfallUs); + if (!m_kiwiSdrWaterfallActive) { + // Mirror the native path: retain the last Kiwi viewport snapshot while + // hidden and resume from the first fresh row after the operator selects + // Kiwi. Background rows must not consume GUI-thread render budget. + return; + } + const bool visibleStream = beginWaterfallStreamWrite(true); auto restoreStream = qScopeGuard([&] { endWaterfallStreamWrite(true, visibleStream); @@ -8918,7 +9169,7 @@ void SpectrumWidget::pushDssRowForWaterfallStream(bool kiwiStream, ? activeKiwiWaterfallState().live : m_nativeWaterfallState.live; if (live && updateLiveSurface) { - dss.pushRow(binsDbm); + pushDssLiveRow(dss, binsDbm, true); } const double stampCenterMhz = @@ -8931,8 +9182,8 @@ void SpectrumWidget::pushDssRowForWaterfallStream(bool kiwiStream, const float fallbackDbm = kiwiStream ? kKiwiSdrWaterfallMinDbm : m_refLevel - m_dynamicRange; - dss.appendHistoryRow(binsDbm, stampCenterMhz, stampBandwidthMhz, - fallbackDbm); + retainDssHistoryRow(dss, binsDbm, stampCenterMhz, stampBandwidthMhz, + fallbackDbm); } void SpectrumWidget::resetDssUploadState() diff --git a/src/gui/SpectrumWidget.h b/src/gui/SpectrumWidget.h index 8b89b4902..10a96aaa9 100644 --- a/src/gui/SpectrumWidget.h +++ b/src/gui/SpectrumWidget.h @@ -389,7 +389,9 @@ class SpectrumWidget : public SPECTRUM_BASE_CLASS { int bandPlanFontSize() const { return m_bandPlanFontSize; } // ── Display control setters ─────────────────────────────────────────── - // FFT controls (save to AppSettings on each change) + // FFT processing controls are radio-owned. These setters update the local + // view only; MainWindow sends explicit operator changes and live radio + // status updates the same fields without creating a feedback loop. void setFftAverage(int frames); void setFftWeightedAvg(bool on); void setFftFps(int fps); @@ -420,7 +422,8 @@ class SpectrumWidget : public SPECTRUM_BASE_CLASS { return m_draggingPan; } - // Waterfall controls (save to AppSettings on each change) + // Client-rendered waterfall controls persist locally except line_duration, + // which is radio-owned and is updated from live status. void setWfColorGain(int gain); void setWfBlackLevel(int level); void setWfAutoBlack(bool on); @@ -843,6 +846,7 @@ class SpectrumWidget : public SPECTRUM_BASE_CLASS { const QSize& historySize); void saveCurrentWaterfallStreamState(); void restoreCurrentWaterfallStreamState(); + void discardRetainedHistory(WaterfallStreamState& state); WaterfallStreamState& activeKiwiWaterfallState(); const WaterfallStreamState* activeKiwiWaterfallStateConst() const; bool beginWaterfallStreamWrite(bool kiwiStream); @@ -859,6 +863,11 @@ class SpectrumWidget : public SPECTRUM_BASE_CLASS { bool updateLiveSurface = true); void appendLatestDssWaterfallRow(double frameCenterMhz = -1.0, double frameBandwidthMhz = -1.0); + void pushDssLiveRow(DssRenderer& dss, const QVector& binsDbm, + bool hiddenStream); + void retainDssHistoryRow(DssRenderer& dss, const QVector& binsDbm, + double centerMhz, double bandwidthMhz, + float fallbackDbm); float dssHistoryFallbackDbm() const; void appendVisibleRow(const QRgb* rowData); int waterfallHistoryCapacityRows() const; @@ -1003,6 +1012,11 @@ class SpectrumWidget : public SPECTRUM_BASE_CLASS { WaterfallStreamState m_nativeWaterfallState; WaterfallStreamState m_kiwiWaterfallState; QHash m_kiwiProfileWaterfallStates; + // True while the current waterfall state is the operator-visible source. + // Hidden Flex/Kiwi updates temporarily swap their state into the current + // fields; instrumentation and retention policy need to distinguish that + // background work from visible work (#4081). + bool m_waterfallWriteVisible{true}; QString m_kiwiSdrWaterfallProfileId; QVector m_kiwiSdrLastWaterfallBins; double m_kiwiSdrLastWaterfallCenterMhz{0.0}; @@ -1620,6 +1634,21 @@ class SpectrumWidget : public SPECTRUM_BASE_CLASS { quint64 overlayRebuildUs{0}; quint64 overlayUploadBytes{0}; // static+bg texture bytes uploaded quint64 wfUploadBytes{0}; // waterfall texture bytes uploaded + quint64 nativeWaterfallCalls{0}; // native VITA waterfall updates + quint64 nativeWaterfallUs{0}; + quint64 nativeWaterfallHiddenCalls{0}; + quint64 kiwiWaterfallCalls{0}; // Kiwi waterfall updates + quint64 kiwiWaterfallUs{0}; + quint64 kiwiWaterfallHiddenCalls{0}; + quint64 waterfallVisibleRows{0}; + quint64 waterfallVisibleRowUs{0}; + quint64 waterfallHistoryRows{0}; + quint64 waterfallHistoryRowUs{0}; + quint64 dssLiveRows{0}; + quint64 dssLiveUs{0}; + quint64 dssHiddenLiveRows{0}; // hidden-Flex DSS live-ring warming (#4081) + quint64 dssHistoryRows{0}; + quint64 dssHistoryUs{0}; quint64 paintEvents{0}; // software-path paints quint64 paintUs{0}; QHash dirtyCauses; // why the overlay rebuilt diff --git a/src/gui/WaveApplet.cpp b/src/gui/WaveApplet.cpp index 0c7ed8d5f..d3a3db4cf 100644 --- a/src/gui/WaveApplet.cpp +++ b/src/gui/WaveApplet.cpp @@ -28,10 +28,10 @@ constexpr int kMaxZoomPercent = 600; constexpr int kDefaultZoomPercent = 170; constexpr int kMinFps = 5; constexpr int kMaxFps = 60; -// Default 25 fps — a calm, low-overhead cadence matching the 2D/3D panadapter -// default (DisplayFftFps = 25). The slider still reaches 60 for users who want -// a faster scope. Users who previously saved an explicit FPS keep it — the -// default is only applied when the setting key is absent. +// Default 25 fps — a calm, low-overhead cadence matching the usual radio +// panadapter rate. The slider still reaches 60 for users who want a faster +// scope. Users who previously saved an explicit WAVE FPS keep it — the default +// is only applied when the WAVE setting key is absent. constexpr int kDefaultFps = 25; // Discrete window steps for the WaveApplet drawer's "Window" slider. // First three notches give sub-second detail (240 ms, 480 ms, 1 s); the diff --git a/src/models/PanadapterModel.cpp b/src/models/PanadapterModel.cpp index feac6b1d7..b84f78c37 100644 --- a/src/models/PanadapterModel.cpp +++ b/src/models/PanadapterModel.cpp @@ -242,8 +242,10 @@ void PanadapterModel::applyStateExtension(const QVariantMap& fields) } } // Averaging is radio-authoritative: parse the level the firmware echoes back - // and re-emit it so MainWindow can reconcile the user's desired value after a - // global-profile / band switch (#4001). average=0 (off) is a valid value — + // and re-emit it so the display follows the radio's value after a + // global-profile / band switch (#4001; radio authority per #4261 — the UI no + // longer persists or re-asserts a client "desired" value). average=0 (off) + // is a valid value — // the ok-guard rejects only a malformed field, exactly like fps. if (fields.contains(QStringLiteral("average"))) { bool ok = false; @@ -268,6 +270,7 @@ void PanadapterModel::applyStateExtension(const QVariantMap& fields) fields.value(QStringLiteral("weighted_average")).toString().toUInt(&ok); if (ok && v <= 255) { const bool weighted = (v != 0); + m_weightedAverageKnown = true; // radio has now reported it (#4261) if (weighted != m_weightedAverage) { m_weightedAverage = weighted; emit weightedAverageChanged(m_weightedAverage); diff --git a/src/models/PanadapterModel.h b/src/models/PanadapterModel.h index 61d359c12..facf0765c 100644 --- a/src/models/PanadapterModel.h +++ b/src/models/PanadapterModel.h @@ -85,6 +85,10 @@ class PanadapterModel : public QObject { int fps() const { return m_fps; } int average() const { return m_average; } bool weightedAverage() const { return m_weightedAverage; } + // False until the radio first reports weighted_average. Lets the UI avoid + // painting a definitive unchecked box before the real value is known, the + // same way average()/fps() use a -1 unknown sentinel (#4261). + bool weightedAverageKnown() const { return m_weightedAverageKnown; } int waterfallLineDuration() const { return m_waterfallLineDuration; } // Normalized waterfall-line-duration setter driven by the backend (universal // display timing). Feeds PerfTelemetry and always emits @@ -146,8 +150,9 @@ class PanadapterModel : public QObject { void fpsReported(int fps); // Averaging is radio-authoritative (firmware runs it, echoes the level in // pan status). Reported fires every status cycle; Changed only on an actual - // change — mirrors the fps pair so MainWindow can reconcile after a - // global-profile / band switch adopts the profile's stored value (#4001). + // change — mirrors the fps pair so the display follows the radio's value + // after a global-profile / band switch adopts the profile's stored value + // (#4001; radio-owned per #4261 — no client re-assert / persistence). void averageChanged(int average); void averageReported(int average); void weightedAverageChanged(bool weighted); @@ -184,6 +189,7 @@ class PanadapterModel : public QObject { int m_fps{-1}; int m_average{-1}; // -1 = unknown; 0 = off, 1-N = level (#4001) bool m_weightedAverage{false}; + bool m_weightedAverageKnown{false}; // #4261 unknown sentinel int m_waterfallLineDuration{-1}; int m_fftYPixels{-1}; QString m_preamp; diff --git a/src/models/RadioModel.h b/src/models/RadioModel.h index 4e0a3abb6..6ce12c159 100644 --- a/src/models/RadioModel.h +++ b/src/models/RadioModel.h @@ -499,8 +499,9 @@ class RadioModel : public QObject { // Emitted when a previous-session pan model is reclaimed on reconnect // instead of created fresh. The applet/widget wiring from the original // panadapterAdded survives (model and widget both outlive the disconnect), - // but connections MainWindow tears down at disconnect (per-pan FPS and - // waterfall line-duration reconcilers) must be re-established from this. + // but connections MainWindow tears down at disconnect (the per-pan + // radio-status display connections wired by wirePanDisplayStatus(), #4261) + // must be re-established from this. void panadapterReclaimed(PanadapterModel* pan); void panadapterRemoved(const QString& panId); // Emitted when createPanadapter() is blocked because the radio's pan limit is reached. @@ -1084,7 +1085,7 @@ private slots: enum class NetState { Off, Excellent, VeryGood, Good, Fair, Poor }; void applyAdaptiveFrameRate(NetState newState, NetState oldState); static int fpsCapForState(NetState s); // single source of truth; see obs. 1 in PR review - int adaptiveWfMsForCap(int fpsCap) const; + // adaptiveWfMsForCap() moved to the public network-diagnostics section (#4261). void sendAdaptiveCapToPan(const QString& panId, int fpsCap); double networkQualityTargetScore(int pingMs) const; NetState networkStateForScore(double score, NetState currentState) const; @@ -1139,6 +1140,10 @@ private slots: int maxPingRtt() const { return m_maxPingRtt; } bool pendingThrottleLift() const { return m_pendingThrottleLift; } int currentAdaptiveFpsCap() const; // 0 = throttle inactive + // Waterfall line-duration the adaptive throttle caps to for a given fps cap. + // Public so MainWindow can recognize (and suppress) that cap's own status + // echo while distinguishing it from a real radio/profile update (#4261). + int adaptiveWfMsForCap(int fpsCap) const; QString networkQuality() const; int packetLossWindowSeconds() const { return NETWORK_LOSS_WINDOW_SAMPLES; } int packetLossWindowDrops() const { return m_packetLossWindowErrors; } diff --git a/tests/display_status_gate_test.cpp b/tests/display_status_gate_test.cpp new file mode 100644 index 000000000..17e490cf7 --- /dev/null +++ b/tests/display_status_gate_test.cpp @@ -0,0 +1,61 @@ +// Unit tests for applyThrottledDisplayReport() — the adaptive-throttle echo gate +// for radio-authoritative FFT-FPS / waterfall-line-duration status (#4261). +// +// Regression guard for rfoust's blocker #1: a reported setting change WHILE the +// adaptive cap is active must still be applied (so it survives the throttle +// lift), and only the cap's own echo is suppressed. + +#include "gui/DisplayStatusGate.h" + +#include + +using namespace AetherSDR; + +namespace { +int g_failed = 0; +int g_total = 0; +void report(const char* label, bool ok) +{ + ++g_total; + std::printf("%s %s\n", ok ? "[ OK ]" : "[FAIL]", label); + if (!ok) { + ++g_failed; + } +} +} // namespace + +int main() +{ + // ── Not throttled: any valid value applies; invalid (<= 0) never does. ── + report("no throttle: valid value applies", + applyThrottledDisplayReport(false, 0, 25) == true); + report("no throttle: cap arg is ignored when inactive", + applyThrottledDisplayReport(false, 25, 25) == true); + report("value <= 0 never applies (unknown sentinel)", + applyThrottledDisplayReport(false, 0, 0) == false + && applyThrottledDisplayReport(true, 15, -1) == false); + + // ── Throttled: suppress ONLY the cap's own echo. ──────────────────────── + report("throttle active: the cap echo is suppressed", + applyThrottledDisplayReport(true, 15, 15) == false); + + // The regression: a DIFFERENT value reported while capped is a real + // radio/profile update and must be applied so it isn't lost on lift. + report("throttle active: a real update (!= cap) IS applied", + applyThrottledDisplayReport(true, 15, 30) == true); + report("throttle active: pre-cap value reported again IS applied", + applyThrottledDisplayReport(true, 15, 25) == true); + + // Line-duration uses the same gate with adaptiveWfMsForCap() as the cap. + report("line-duration: cap echo suppressed", + applyThrottledDisplayReport(true, 67, 67) == false); + report("line-duration: profile change (!= cap) applied", + applyThrottledDisplayReport(true, 67, 100) == true); + + if (g_failed == 0) { + std::printf("\nAll %d display-status-gate tests passed.\n", g_total); + return 0; + } + std::printf("\n%d of %d display-status-gate tests failed.\n", g_failed, g_total); + return 1; +} diff --git a/tests/dss_renderer_test.cpp b/tests/dss_renderer_test.cpp index 6fde310c4..8aad9743d 100644 --- a/tests/dss_renderer_test.cpp +++ b/tests/dss_renderer_test.cpp @@ -168,6 +168,25 @@ int testMovedFromHistoryCapacityRebuild() return 0; } +int testZeroCapacityReleasesRetainedHistory() +{ + DssRenderer renderer; + renderer.setHistoryCapacityRows(24); + renderer.appendHistoryRow(rowWithPeak(128), 14.0, 1.0, -200.0f); + if (renderer.historyStorageBytes() == 0 || renderer.historyRowCount() != 1) { + return fail("retained DSS history should allocate before release"); + } + + renderer.setHistoryCapacityRows(0); + if (renderer.historyCapacityRows() != 0 + || renderer.historyRowCount() != 0 + || renderer.historyStorageBytes() != 0) { + return fail("zero DSS history capacity must release all retained storage"); + } + + return 0; +} + } // namespace int main() @@ -190,6 +209,9 @@ int main() if (int rc = testMovedFromHistoryCapacityRebuild(); rc != 0) { return rc; } + if (int rc = testZeroCapacityReleasesRetainedHistory(); rc != 0) { + return rc; + } return 0; }