Skip to content

fix(gui): stop baking optimistic zoom guesses into waterfall history - #5142

Open
tropo1234 wants to merge 4 commits into
aethersdr:mainfrom
tropo1234:anan-pr4-waterfall-history-stamp
Open

fix(gui): stop baking optimistic zoom guesses into waterfall history#5142
tropo1234 wants to merge 4 commits into
aethersdr:mainfrom
tropo1234:anan-pr4-waterfall-history-stamp

Conversation

@tropo1234

Copy link
Copy Markdown
Contributor

Summary

Every waterfall history row is permanently stamped with the frequency/
bandwidth it was captured at. But the four zoom gestures (click/drag/
pinch/wheel) write their locally-computed, unconfirmed target straight
into m_centerMhz/m_bandwidthMhz for instant visual feedback, before
the backend has echoed back what it actually took. Any row captured in
that window got a permanently wrong stamp — a lasting scar (wrong signal
at the wrong frequency, or a black gap) that never self-corrects until
it ages out of the 20-minute ring buffer.

Adds m_confirmedCenterMhz/m_confirmedBandwidthMhz, updated only when
setFrequencyRangeInternal() sees a real backend echo, and uses that
pair — not the on-screen guess — everywhere a row's pixels and stamp are
built: updateWaterfallRow(), pushWaterfallRow() (pixel layout and
stamp must agree with each other or the wrong signal shows at the wrong
frequency), pushKiwiSdrWaterfallRow(), and appendHistoryRow()'s own
fallback (now stampFrameForHistoryRow(), unit tested). Gesture handlers
are untouched, so click/drag/pinch/wheel responsiveness is unchanged.

Shared SpectrumWidget code — benefits every backend's waterfall, not
just ANAN's. Found during ANAN zoom bring-up (frequent rate-snapped zoom
requests exercise this race hard), bench-verified there, but not
ANAN-specific in cause or fix. Not part of #4970.

Constitution principle honored

N/A — GUI correctness fix, no persistence/protocol/transmit surface touched.

Test plan

  • Local build passes
  • Behavior verified on a real ANAN-G2 — the newly-exposed edge during
    a zoom's multi-second round trip is now an honest, transient black
    region that fills in and never reappears, instead of a permanent
    scar
  • New unit test testStampFrameForHistoryRow in
    spectrum_preview_logic_test.cpp — pins both the valid-per-row-frame
    case and the fallback-to-confirmed-frame case (zero bandwidth,
    negative bandwidth, non-finite center all treated as "absent")
  • spectrum_preview_logic_test passes, confirmed standalone on this
    exact branch (built fresh against main @ 3e467fa8, not inherited
    from anan-step2-submission)

Checklist

  • Commits are signed
  • N/A — no AppSettings changes
  • Code is clean-room (pre-existing file, standard bug fix)
  • N/A — no meter UI
  • N/A — no user-visible behavior change to document (this restores
    intended behavior; the bug was never documented as a feature)
  • N/A — no security-sensitive change

Every waterfall history row is permanently stamped with the frequency/
bandwidth it was captured at, but the four zoom gestures wrote their
locally-computed, unconfirmed target straight into m_centerMhz/
m_bandwidthMhz for instant visual feedback before the backend echoed
back. Any row captured in that window got a permanently wrong stamp
that never self-corrected until it aged out of the ring buffer.

Add m_confirmedCenterMhz/m_confirmedBandwidthMhz, updated only when
setFrequencyRangeInternal() sees a real backend echo, and use that
pair (not the on-screen guess) everywhere a row's pixels and stamp are
built: updateWaterfallRow(), pushWaterfallRow() (Legacy/2D — pixel
layout and stamp must agree with each other or the wrong signal shows
at the wrong frequency), pushKiwiSdrWaterfallRow(), and
appendHistoryRow()'s own fallback (now stampFrameForHistoryRow(), unit
tested). Gesture handlers are untouched, so click/drag/pinch/wheel
responsiveness is unchanged.

Bench-verified on ANAN-G2: the newly-exposed edge during a zoom's
multi-second round trip is now an honest, transient black region that
fills in and never reappears, instead of a permanent scar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@tropo1234
tropo1234 requested a review from a team as a code owner August 21, 2026 20:20

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue fit

I could not find a linked issue — the body has no fixes/closes #NNNN and a search turns up nothing but this PR itself. So I reviewed against the PR's own stated intent: history rows must not be permanently stamped with an on-screen zoom guess the backend never confirmed. The premise is real and well-grounded — MainWindow_Wiring.cpp:696 and MainWindow_KiwiSdr.cpp:1321 both carry the "a lying frame … bakes black rows into waterfall history (#4142)" precedent the header comment cites, and RadioModel::requestPanCenter()'s -1.0 bandwidth argument does exist (MainWindow_Wiring.cpp:3764). That citation checks out.

The native tile path (updateWaterfallRow) is genuinely fixed and internally coherent — the destination window and the stamp both move to confirmed geometry, the source stays the tile's own real bounds, and the supplemental frame is correctly left alone. That hunk is the good part of this PR.

The FFT-derived path (pushWaterfallRow) is not, and the mechanism feeding m_confirmed* records values the widget itself has classified as untrustworthy. Details in blockers 1 and 2. Per GOVERNANCE.md a bug fix with a clear root cause doesn't need an RFC first, so no process objection — but this is a new piece of view-layer state and it deserves a stated repro.

Scope

File / group What it changes Claimed by title/body? Verdict
src/gui/SpectrumPreviewLogic.h Extracts the existing stamp ternary into stampFrameForHistoryRow() Yes In scope
src/gui/SpectrumWidget.h Adds m_confirmedCenterMhz/m_confirmedBandwidthMhz + doc comment Yes In scope
src/gui/SpectrumWidget.cpp Sets confirmed pair in setFrequencyRangeInternal; switches 4 read sites Yes In scope (correctness issues below, not scope)
tests/spectrum_preview_logic_test.cpp New testStampFrameForHistoryRow(), wired into main() Yes In scope (coverage gap below)

Everything in the diff is explained by the stated fix. No CHANGELOG.md entry (correct — don't add one), no new public/protocol surface, no settings keys, no UI defaults changed, nothing deleted that guarded a named symptom. CodeGuard reported nothing on any of the four changed files; all ten of its findings are in prototypes/, tools/, MidiSettings.cpp, RigctlProtocol.cpp and MainWindow.cpp, none of which this PR touches — nothing to fold in.

Blockers

  1. m_confirmed* is written before the three guards that exist precisely to reject untrustworthy geometrySpectrumWidget.cpp:7484-7485, inline comment below. The assignment is unconditionally first in the function, so the drag-hold return (7498), the settle-pending return (7507-7513) and the stale-echo return (7534-7539) all record their rejected value as "confirmed truth" before returning. The comment sitting directly above the assignment claims the opposite in so many words: "The three OTHER early returns below (drag-hold, settle-pending, stale-echo) do NOT get this treatment." They do. That's not just a stale comment — the settle-pending guard's own text says Flex "can echo older center-only statuses after a combined center+bandwidth command", and those are now the coordinates every subsequent appendHistoryRow() and updateWaterfallRow() uses to lay out and label rows. A fix is to move the pair to the point past all holds (just after m_deferredRangeValid = false; at 7517) — or, if the intent really is "record even when the no-op guard fires", to record before only the equality guard and re-record after the holds.

  2. pushWaterfallRow() now labels the same pixels three different ways, and the bins are not in the confirmed frameSpectrumWidget.cpp:12229 and :12238, inline comments below. In one call: the history row is stamped confirmed (12265 → the new fallback at 5451-5454), the visible row is stamped on-screen (12268 → appendVisibleRow's unchanged fallback at 5372-5374), and the DSS row resolves to preview-base-or-on-screen (12266 → resolvedUntaggedDssFrame, 5233-5250). Before this PR all three agreed on m_centerMhz. Worse, the bins themselves live in the on-screen frame by construction: reprojectSpectrum() (7303-7418) resamples m_bins into the new optimistic frame at gesture time, and updateSpectrum() (8212-8292) stores frameless incoming bins as the current display frame. So during the divergence window a TX-mask / RX-stale fallback row's pixels span the guessed frame while its history stamp says the confirmed frame — remapHistoryRowInto() will shift it, and the row moves horizontally the moment the viewport rebuilds. That is the "wrong signal at the wrong frequency, not just a black gap" outcome this PR's own comment at 8517-8520 warns about, arriving in the one path where the diff doesn't control the pixel layout. Two sub-notes: the inline comment at 12226 ("the pixel layout here must use the same values") is inaccurate — panStartMhz in this function feeds only the TX mask; the bin→column mapping at 12245-12258 is proportional and ignores it entirely. And that TX mask is the concrete casualty: 12236-12240 now tests confirmed-frame frequencies against columns laid out in the on-screen frame, so the blackout covers the wrong columns during any zoom that hasn't landed.

Nits (non-blocking)

  • The test would pass against unfixed main. stampFrameForHistoryRow() is a byte-for-byte extraction of the ternary that was already at SpectrumWidget.cpp:5452-5454; the new test pins the extraction, not the behavior change. Nothing in the suite covers "a zoom guess is not stamped into history" — the entire m_confirmed* mechanism is untested. The three invalid-frame cases are a nice touch and match FrequencyFrame::isValid() (finite, both > 0.0), so keep them, but consider a case that would fail if m_confirmed* were replaced by m_centerMhz again.
  • A fourth notion of "the frame." m_frequencyPreviewBaseCenterMhz/…BandwidthMhz (set by updateFrequencyPreview(), 6218-6254) already captures the pre-gesture frame for exactly this window, and the DSS path already consumes it via resolvedUntaggedDssFrame(). Reusing that would have kept the 2D and DSS paths on one answer instead of two that can disagree (preview base is captured once at gesture start; m_confirmed* tracks every echo, including rejected ones).
  • The default initializers {14.225}/{0.200} duplicate m_centerMhz/m_bandwidthMhz's literals two lines up; if one is ever retuned they'll silently drift apart.

What I tried to break (and didn't)

  • updateWaterfallRow() self-consistency. Walked 8513-8700: destination window (8521, 8527), primary stamp (8572), blanker substitution (waterfallBlankerFrameBundleForOutput, 8611-8616), appendHistoryRow (8674-8681) and appendVisibleRow (8691-8698) all carry outputFrames.primaryFrame explicitly, so the appendHistoryRow fallback never fires here and layout/stamp/visible agree. The supplemental frame stays derived from lowFreqMhz/highFreqMhz as the comment claims. This hunk survives.
  • pushKiwiSdrWaterfallRow()'s changed fallback. Its stamp is used, and the visible line is explicitly remapped from rowCenter/rowBandwidth into m_centerMhz/m_bandwidthMhz (12324-12330), so unlike blocker 2 the two stay consistent. The fallback branch only fires when the Kiwi stream supplies no row frame; the injection path at 1668-1672 passes an explicit one. Clean.
  • FrequencyFrame::isValid() against the new helper. NaN center, zero and negative bandwidth all fall through to confirmed as the test asserts; a confirmed pair that is itself invalid (e.g. bandwidth 0 before the first geometry push) writes a zero stamp, but that's the pre-existing behavior of the ternary this replaced, not a regression.
  • Sibling call sites. Grepped every writer of m_centerMhz/m_bandwidthMhz (2251, 7593, 7643/7648, 9905, 10339, 10473, 10742, 11438, 11618) and every appendHistoryRow() caller. The four zoom handlers do write optimistically exactly as the header comment claims. The pan-drag (10473), edge-pan (10339) and "Center Slice" (9905) writers also advance the display without confirmation and are not covered by the header comment's "four zoom gesture handlers" framing — worth correcting in the comment, though the mechanism handles them the same way.
  • CI. All four checks green on 1145b535. Note that ctest in ci.yml is -R-filtered, so green here means the named subset passed on this merge base, not the suite.

Everything above is reasoned from reading the head checkout at /tmp/aetherclaude/pr-5142 — I'm headless, with no build and no way to drive the GUI, so none of it is reproduced at runtime. Blocker 2 in particular is a code-path argument; if you can show that pushWaterfallRow()'s bins are already in the confirmed frame when it runs, it collapses and I'd like to see that.

Recommendation

Request changes. The native-tile half of this is a real fix and I'd take it as-is. The two blockers are both in the other half: the confirmed pair is recorded from values the widget deliberately rejects (blocker 1, and the comment asserts the opposite of what the code does), and pushWaterfallRow() ends up with its data in one frame and its stamp in another (blocker 2). Concrete next step: move the m_confirmed* assignment past the three holds, and either revert the two pushWaterfallRow() hunks to m_centerMhz/m_bandwidthMhz or convert appendVisibleRow's fallback and the DSS row in that function together so all three consumers agree. A test that fails with m_confirmed* swapped back to m_centerMhz would make the fix stick.

Thanks for this — the problem is real, the header comment is unusually good documentation of intent, and the updateWaterfallRow hunk is exactly right.


🤖 aethersdr-agent · cost: $3.9553 · model: claude-opus-5

Comment thread src/gui/SpectrumWidget.cpp Outdated
Comment on lines +7484 to +7485
m_confirmedCenterMhz = centerMhz;
m_confirmedBandwidthMhz = bandwidthMhz;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker 1. This runs before every return in the function — including the three the comment above says are excluded.

  • drag-hold, line 7498: deferIncomingRange(...); return;
  • settle-pending, 7507-7513: deferIncomingRange(...); return;
  • stale-echo, 7534-7539: bare return;

All three reject the incoming value as not-currently-true, and all three have already written it to m_confirmed* by the time they run. The settle-pending guard's own comment says Flex "can echo older center-only statuses after a combined center+bandwidth command" — those stale centers now become the geometry updateWaterfallRow() lays rows out in and appendHistoryRow() stamps them with, until the next accepted push.

Suggest moving the pair to just after m_deferredRangeValid = false; (7517), which is the point the function itself declares as "past every hold: this value is being applied." If the no-op-guard case at 7488 genuinely needs it too, record there specifically rather than ahead of all four.

Either way the comment needs to stop claiming an exclusion the code doesn't implement.

Comment thread src/gui/SpectrumWidget.cpp Outdated
// so the pixel layout here must use the same values or the row's data
// and its stamp would disagree about what span it covers. See
// m_confirmedCenterMhz's own declaration comment.
const double panStartMhz = m_confirmedCenterMhz - m_confirmedBandwidthMhz / 2.0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker 2. Two problems here.

First, the comment's premise is inverted: panStartMhz in this function feeds only the TX-mask test at 12236-12240. The actual bin→column mapping (12245-12258) is proportional over destWidth and never consults it. So this line does not "lay out the pixel data" at all.

Second, the bins are in the on-screen frame, not the confirmed one. reprojectSpectrum() (7303-7418) resamples m_bins into the new optimistic frame at gesture time, and updateSpectrum() (8212-8292) stores frameless incoming bins as the current display frame — that is the widget's standing convention. Stamping those pixels with the confirmed frame means the row's content and its label disagree during exactly the divergence window this PR is about, so remapHistoryRowInto() shifts it and the row jumps horizontally once the viewport rebuilds.

And within this one call the same scanline now gets three different frames: history = confirmed (12265), visible = on-screen (12268 → appendVisibleRow's untouched fallback at 5372-5374), DSS = preview-base-or-on-screen (12266 → resolvedUntaggedDssFrame, 5233-5250). Before this PR all three said m_centerMhz.

Either revert this path to m_centerMhz/m_bandwidthMhz, or convert the visible and DSS rows here in the same breath so all three agree.

Comment thread src/gui/SpectrumWidget.cpp Outdated
const double freqMhz = panStartMhz
+ (static_cast<double>(x) / static_cast<double>(destWidth))
* m_bandwidthMhz;
* m_confirmedBandwidthMhz;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concrete casualty of blocker 2: freqMhz is now derived from confirmed geometry while the columns it gates are laid out in on-screen geometry (proportional mapping at 12245-12258). During a zoom gesture that hasn't landed, the TX blackout therefore masks the wrong columns — it will be offset or scaled relative to the actual passband. Pre-PR both sides used m_centerMhz/m_bandwidthMhz, so they agreed by construction.

Comment thread src/gui/SpectrumWidget.h
// optimistically" bandwidth argument, citing this exact "bakes black
// rows into waterfall history" failure) -- that fix doesn't reach here,
// where the actual row stamping happens.
double m_confirmedCenterMhz{14.225};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small corrections to the (otherwise excellent) comment above:

  • "the four zoom gesture handlers write their locally-computed target here immediately" — true (2251, 10742, 11438, 11618), but the pan-drag (10473), slice-drag edge-pan (10339) and "Center Slice" context action (9905) also advance m_centerMhz ahead of confirmation. The mechanism covers them; the comment reads as if it doesn't have to.
  • "see setFrequencyRangeInternal()'s own comment for the one place this pair updates" — that comment currently asserts an exclusion the code doesn't implement (see blocker 1), so it shouldn't be cited as the explanation until it's fixed.

Also worth noting: m_frequencyPreviewBaseCenterMhz/…BandwidthMhz (updateFrequencyPreview(), 6218-6254) already holds the pre-gesture frame for this exact window, and resolvedUntaggedDssFrame() already consumes it for untagged DSS rows. Reusing it would avoid a second source of truth that can drift from the first.

return 0;
}

int testStampFrameForHistoryRow()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The invalid-frame cases are good and correctly mirror FrequencyFrame::isValid() (finite + both > 0.0).

But this test would pass unchanged against main: stampFrameForHistoryRow() is a byte-for-byte extraction of the ternary already at SpectrumWidget.cpp:5452-5454 pre-PR. It pins the extraction, not the fix — the actual behavior change (fall back to confirmed rather than on-screen geometry) lives entirely in SpectrumWidget and has no coverage.

Something that fails if m_confirmed* is swapped back to m_centerMhz is what would keep this from regressing. If a widget-level test is impractical headless, a small logic seam — e.g. a free function taking (requested, confirmed, onScreen, isGestureActive) — would at least make the choice testable the way dssUntaggedRowUsesPreviewBase() already is.

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Cisco CodeGuard — static analysis of this PR (10 finding(s))

  • [MEDIUM] CG-PATH-001 — Potential path traversal in prototypes/hl2/hpsdr.py /tmp/aetherclaude/pr-5142/prototypes/hl2/hpsdr.py:5
  • [MEDIUM] CG-PATH-001 — Potential path traversal in prototypes/hl2/spectrum.py /tmp/aetherclaude/pr-5142/prototypes/hl2/spectrum.py:16
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/core/MidiSettings.cpp /tmp/aetherclaude/pr-5142/src/core/MidiSettings.cpp:704
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/core/RigctlProtocol.cpp /tmp/aetherclaude/pr-5142/src/core/RigctlProtocol.cpp:526
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5142/src/gui/MainWindow.cpp:8517
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5142/src/gui/MainWindow.cpp:8629
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5142/src/gui/MainWindow.cpp:8748
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5142/src/gui/MainWindow.cpp:8749
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5142/src/gui/MainWindow.cpp:8750
  • [MEDIUM] CG-NET-001 — Outbound HTTP request to variable URL in tools/aether_mcp.py /tmp/aetherclaude/pr-5142/tools/aether_mcp.py:1334

Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them.


🤖 aethersdr-agent · cost: $4.5176 · model: claude-opus-5

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Antares Detector — candidate vulnerable file(s)

  • src/gui/WaterfallHistoryBuffer.cpp
  • src/gui/SpectrumWidget.cpp

sampleWaterfallHistoryFrame reads from a caller-supplied buffer and returns an unsigned 8-bit frame count; it implements waterfall row offsets, making it the likely location for a memory-safety flaw if untrusted data drives those offsets.

Localized by Cisco Foundation AI Antares-1B running locally in the AetherClaude sandbox, seeded by the Cartographer security map. Advisory only — please verify before acting.


🤖 aethersdr-agent · cost: $4.5176 · model: claude-opus-5

@tropo1234

Copy link
Copy Markdown
Contributor Author

@aethersdr-agent Investigated both blockers.

Frame Mismatch: I don't think this is actually a bug. Traced it through pushWaterfallRow() → appendHistoryRow() → stampFrameForHistoryRow(): the pixel layout at SpectrumWidget.cpp:12229 already uses m_confirmedCenterMhz/m_confirmedBandwidthMhz directly, the call passes no explicit frame to appendHistoryRow(), and that function's defaults (frameCenterMhz = -1.0, frameBandwidthMhz = -1.0) are exactly the "absent" case stampFrameForHistoryRow() falls back to the confirmed pair for — the same case the new unit test covers. Pixel layout and stamp both resolve to the same confirmed values consistently. Happy to be shown a case this misses.

Timing Issue: real finding, but I don't think it's this PR's to fix. The leak is in MainWindow_Wiring.cpp:686-689 — a "local-only snap" path explicitly tagged // aetherd RFC 2.3, which fires PanadapterModel::infoChanged (connected straight to setFrequencyRange) for a value that's deliberately never sent to the radio. Checked docs/architecture/aetherd-iradiobackend-design.md: RFC 2.3 is "split the five mixed models" — a still-in-progress, 5-PR effort to separate operator-requested state from radio-confirmed state across the model layer. Whether a "confirmed, but never touched the radio" value should exist at all is exactly the question that refactor is meant to resolve, and it's explicitly not done yet. Folding a fix for it into a waterfall-history-stamping PR would be scope creep into an already-tracked, larger architectural question — flagging it here instead in case it's useful context for whoever picks up RFC 2.3's remaining PRs.

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue fit

No linked issue; reviewed against its own stated intent. The defect is real and well characterised: waterfall rows carry a permanent frequency/bandwidth stamp, the zoom gestures write their unconfirmed target into m_centerMhz/m_bandwidthMhz for instant feedback, and any row captured in that window is scarred until it ages out of the 20-minute ring. Splitting "what the operator sees" from "what the backend confirmed" is the right fix, and keeping the gesture handlers untouched means responsiveness is unchanged.

Verified empirically on macOS/arm64: the full AetherSDR target builds clean and spectrum_preview_logic_test passes.

No blockers. One finding that limits the fix's reach, and two checks that came out clean.

What I verified rather than assumed

  • The defaults match. m_confirmedCenterMhz{14.225} / m_confirmedBandwidthMhz{0.200} are identical to m_centerMhz / m_bandwidthMhz at SpectrumWidget.h:1338-1339, so there is no window where rows get stamped 0 MHz before the first echo. That was the first thing I went looking for and it is handled.
  • No self-confirmation. SpectrumWidget never calls its own setFrequencyRange() / setFrequencyRangeImmediate() — I grepped the whole file. So none of the eleven direct m_centerMhz = writes can reach the confirming path by accident; the gesture handlers stay optimistic-only, exactly as intended.

Finding — not blocking

Keyboard zoom still bakes a guess, because it confirms before it asks. (inline: SpectrumWidget.cpp:7484) The body scopes the problem to "the four zoom gestures (click/drag/pinch/wheel)", and for those four the fix is complete. There is a fifth, and it takes the newly-confirming path. MainWindow_Shortcuts.cpp:1359-1360:

sw->setFrequencyRange(newCenter, newBw);                          // now marks CONFIRMED
applyPanRangeRequest(s->panId(), newCenter, newBw, "pan-zoom");   // only now asks the backend

newCenter/newBw are computed locally from sw->centerMhz() and the model's min/max limits — an unconfirmed target, by the same definition the PR uses everywhere else — and the ordering is unambiguous: the confirm happens on the line before the request is sent. So a keyboard zoom stamps history with a guess that may be rate-snapped away, which is the defect this PR exists to remove.

The contrast that makes this clear is MainWindow.cpp:9887, which passes pan->centerMhz() / pan->bandwidthMhz() — actual model state, a legitimate confirmation. Two callers of the same entry point, one a backend echo and one an operator guess, and after this PR they are no longer equivalent.

That is the structural point worth naming: this change gives setFrequencyRange() a new meaning ("this geometry is real"), but the boundary between guess and echo actually lives one layer up, at the MainWindow call sites. Nothing in the signature tells a caller which contract they are satisfying, and the keyboard path is already on the wrong side of it.

Nits

  • stampFrameForHistoryRow() being extracted into SpectrumPreviewLogic.h so it can be unit-tested is the right move, and the test is the only reason I could check the fallback behaviour at all. Worth noting the other three call sites (updateWaterfallRow, pushWaterfallRow, pushKiwiSdrWaterfallRow) remain untested — they are the ones where "pixel layout and stamp must agree with each other" matters, and that agreement is currently maintained by hand in three places.
  • The declaration comment for m_confirmedCenterMhz is excellent — it names the failure, the one place the pair updates, and the RadioModel::requestPanCenter() precedent one layer up. Given finding 1, it is worth one more sentence saying that callers of setFrequencyRange() are asserting the geometry is real, since that is now load-bearing and only discoverable by reading this comment.

Verified vs. read

Built and ran: the full app target and spectrum_preview_logic_test. Traced: all eleven m_centerMhz write sites to confirm which are gesture paths and which are inside setFrequencyRangeInternal; every caller of both public setters across src/gui/. Read: the stamp/layout agreement argument in pushWaterfallRow. Not verified: nothing rendered and no ANAN hardware, so the bench result you describe — rate-snapped zoom exercising this race hard — rests on your session. The keyboard-zoom finding is from the call site's own ordering, not from observing a scarred row.

Comment thread src/gui/SpectrumWidget.cpp Outdated
Comment on lines +7480 to +7485
// this function, which is why it's first. The three OTHER early
// returns below (drag-hold, settle-pending, stale-echo) do NOT get
// this treatment -- they legitimately don't represent "this value is
// confirmed truth right now" (see their own comments).
m_confirmedCenterMhz = centerMhz;
m_confirmedBandwidthMhz = bandwidthMhz;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding — this makes setFrequencyRange() mean "confirmed", and one existing caller cannot honour that.

MainWindow_Shortcuts.cpp:1359-1360, the keyboard pan-zoom shortcut:

sw->setFrequencyRange(newCenter, newBw);
applyPanRangeRequest(s->panId(), newCenter, newBw, "pan-zoom");

newCenter comes from sw->centerMhz() clamped against panMinBandwidthMhz/panMaxBandwidthMhz, so it is a locally computed target — and the request to the backend goes out on the next line. After this change, that first call marks the guess as confirmed, and any waterfall row captured before the backend answers is stamped with it. If the backend rate-snaps to something else, that row is scarred exactly the way the four mouse gestures used to scar rows.

Compare MainWindow.cpp:9887, which passes pan->centerMhz() / pan->bandwidthMhz(). That is model state and a genuine confirmation. Same entry point, opposite contract.

I do not think the fix is wrong — it is right for everything it covers, and I verified the gesture handlers never reach here. The issue is that the guess/echo boundary is at the MainWindow layer, not inside SpectrumWidget, and the signature gives a caller no way to know which one they are asserting.

Two ways to close it, both small:

(a) Make the keyboard path optimistic like its mouse siblings — apply the visual change without confirming, and let the backend echo arrive through the normal path, which is what applyPanRangeRequest() already sets in motion.

(b) Make the contract explicit in the signature so it cannot be got wrong by accident:

    // Callers assert this geometry is REAL — the backend has echoed it.
    // For an operator gesture whose target the backend has not confirmed,
    // use setFrequencyRangePreview() instead, which leaves
    // m_confirmedCenterMhz/m_confirmedBandwidthMhz alone.
    void setFrequencyRange(double centerMhz, double bandwidthMhz);

(a) is the smaller diff; (b) is what stops the sixth caller reintroducing this. Either way the body's "the four zoom gestures" should become five, so the next person does not read the keyboard path as already covered.

zoomActivePanadapter() called sw->setFrequencyRange() directly, which
is setFrequencyRangeInternal() -- marking the guess CONFIRMED on
keypress, before the backend had done anything (ten9876, aethersdr#5142
review). Every other pan-range requester (SWR sweep, mouse/wheel/
pinch) only calls applyPanRangeRequest() and lets the confirmed value
update when the echo arrives. Drop the direct call so keyboard zoom
follows the same pattern.
@tropo1234

Copy link
Copy Markdown
Contributor Author

@ten9876 the keyboard-zoom fix is in as 86348e7 — zoomActivePanadapter() was calling sw->setFrequencyRange() directly, which is setFrequencyRangeInternal() under the hood and marks the guess CONFIRMED on keypress before the backend does anything with it. Dropped that call so it now only calls applyPanRangeRequest(), same as every other pan-range path (SWR sweep, mouse/wheel/pinch). Let me know if you want another look.

setFrequencyRangeInternal() wrote m_confirmedCenterMhz/m_confirmedBandwidthMhz
unconditionally at the top of the function, before the drag-hold,
settle-pending, and stale-echo early returns -- despite its own comment
claiming those three were excluded. Move the write to run only for the
no-op case and the real-application path, matching the documented intent
(ten9876, aethersdr#5142 review, "Blocker 1").

pushWaterfallRow()'s TX-mask test computed panStartMhz from the confirmed
frame, but the bins it's masking (m_bins) are kept in the on-screen frame
by reprojectSpectrum()/updateSpectrum() -- the mask could blank the wrong
columns relative to what's actually plotted during a zoom's divergence
window. Revert panStartMhz and the mask's frequency step to on-screen
geometry, which is what the bin data and the mask both need to agree on
(ten9876, aethersdr#5142 review, "Blocker 2", first half).

Not addressed: appendHistoryRow()'s own fallback still stamps this row
with the confirmed frame while its pixel content is on-screen -- that's
this path's original, intentional design (see stampFrameForHistoryRow's
doc comment) and reverting it would undo the PR's point for this code
path. Real tradeoff, no clean single answer; left for a maintainer call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@tropo1234

Copy link
Copy Markdown
Contributor Author

@ten9876 Pushed 7cc9a67 — Blocker 1 fixed as suggested: moved m_confirmedCenterMhz/m_confirmedBandwidthMhz past the drag-hold/settle-pending/stale-echo returns in setFrequencyRangeInternal(), kept it in the no-op-guard branch since that case still needs it.

On the frame-mismatch question in pushWaterfallRow(): panStartMhz and the appendHistoryRow() fallback both resolve to m_confirmedCenterMhz/m_confirmedBandwidthMhz, consistently with each other — but that's a level above the actual question. The bin→column interpolation a few lines below panStartMhz (the binF/binIdx block) never reads panStartMhz or any frame value at all — it's a plain proportional stretch of bins over destWidth. So what matters isn't whether the two labels agree with each other, it's whether either of them agrees with what bins itself actually is.

Traced that at the TX call site: bins is *spectrumBins, and m_bins = *spectrumBins a few lines above it in the same function. reprojectSpectrum() (7303-7418) resamples m_bins to the incoming center/bandwidth every time setFrequencyRangeInternal() runs — to on-screen, not confirmed. So by the time pushWaterfallRow() runs, bins tracks on-screen geometry, and panStartMhz's confirmed label can describe a span the pixel data doesn't actually cover. Reverted panStartMhz (and the mask's frequency step) to on-screen so the TX mask lines up with what it's actually masking.

What I haven't traced, and what would actually settle whether this matters in practice: what the DSP layer feeding spectrumBins computes those bins against in the first place — whether on-screen and confirmed ever diverge widely enough, for long enough, to reach this data before the display catches up. appendHistoryRow()'s own confirmed-frame stamp is untouched — that's intentional per stampFrameForHistoryRow()'s doc comment, and reverting it would reopen the original bug for this fallback path. Between that and the TX-mask fix, this leaves the row's stamp and its pixel content still potentially disagreeing during a zoom's divergence window, same as before — I didn't find a clean way to fix both without either the DSP context above or picking one of two real tradeoffs. @ten9876, worth your read on whether the mask fix alone is enough or whether the deeper stamp-vs-content question needs resolving too.

@jensenpat jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: live IC-7300MK2 testing shows this revision makes horizontal pan dragging materially worse and writes corrupted rows into retained waterfall history.

I tested exact PR head 7cc9a674 merged onto current origin/main (f41dc755) in a fresh macOS RelWithDebInfo build. The focused socket-free tests passed before deployment. On an IC-7300MK2, however, horizontal mouse/trackpad drags produced:

  • wide, solid-black rectangular gaps committed into waterfall history;
  • variegated/checkerboard rows as the drag continued; and
  • the pan and waterfall snapping back to a different center after release.

The live spectrum remained continuous while the waterfall below it broke up, and the connection remained healthy. This is not consistent with packet loss or a harmless transient uncovered edge.

The source path explains the observed shape. IcomCivBackend::onCivFrame() emits each sweep's actual panCenterBandwidthChanged() immediately before spectrumFrameReady(). An Icom center-mode pan drag retunes the VFO, so the exact-span scope bounds move with those sweeps. During the drag, SpectrumWidget::setFrequencyRangeInternal() rejects those changing centers and leaves m_confirmedCenterMhz frozen. The changed updateWaterfallRow() then rasterizes the newly moved Icom sweep against that stale confirmed viewport, zero-filling frequencies outside the overlap, and stamps the primary row as though it covered the whole stale frame. remapHistoryRowInto() prefers that claimed primary-frame coverage, so the correctly framed supplemental row cannot repair the zeros. The black rectangles are therefore retained, not just preview artifacts.

This invalidates the prior conclusion that the native-tile hunk is universally coherent. Its Flex assumption that native tiles extend beyond pan edges does not hold for an Icom cooked scope sweep whose lowFreqMhz/highFreqMhz describe the exact data extent.

Please address before merge:

  1. Keep the producer-supplied frame authoritative for exact-span backend sweeps. If Flex oversized tiles need confirmed-viewport rasterization, distinguish that input contract instead of applying it to every updateWaterfallRow() caller.
  2. Ensure the primary row never claims coverage for pixels that were zero-filled because its source sweep did not cover that frame; otherwise supplemental coverage remains unreachable.
  3. Exercise the post-release Icom convergence path so it settles once on the final radio-accepted center rather than visibly rebasing through an intermediate sweep.
  4. Add a deterministic regression test for: initial confirmed center -> optimistic horizontal drag -> several delayed exact-span sweeps at moving centers -> release/final acknowledgement. Assert no black holes within each sweep's real coverage, correct retained-row stamps, and final view/radio convergence. The current helper test does not exercise updateWaterfallRow() or this temporal sequence.

Required validation should include another IC-7300MK2 drag test plus regression checks for Flex and the other shared-widget producers (ANAN, HL2, Sim, and Kiwi). Only Icom is live-proven broken here, but the rendering code is shared.

Requesting changes because the defect affects a primary display surface and persists in visible history for every affected drag.

@jensenpat
jensenpat marked this pull request as draft September 3, 2026 03:46
…covered

updateWaterfallRow() crops every tile to the confirmed viewport and stamps
the row with that frame. Correct for Flex, whose tiles extend beyond the pan
edges, but an exact-span sweep covers only its own extent: the uncovered
columns are zero-filled while the row still claims the full viewport, and
remapHistoryRowInto() tests the primary frame first, so the supplemental row
can never repair them. On an IC-7300MK2 every horizontal pan drag committed
wide black rectangles to retained history (jensenpat, aethersdr#5142 review).

Add primaryRowFrameForNativeTile(): crop only where the tile really covers
the viewport, otherwise the producer's extent is authoritative. Flex is
unchanged by construction. The rasterisation, the stamp and panStartMhz now
all derive from that one frame, so they cannot drift apart.

Not reproduced locally — Sim takes the same exact-span path but echoes the
requested centre back within the drag's command interval, so its tile tracks
the viewport and the stale claim is never read. Verified by reading and by
unit test, which fails against the previous behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tropo1234

Copy link
Copy Markdown
Contributor Author

Fixed 1 and 2. updateWaterfallRow() no longer crops unconditionally:
primaryRowFrameForNativeTile() (SpectrumPreviewLogic.h) returns the viewport
frame only when the tile actually covers it, otherwise the tile's own extent,
and panStartMhz, the rasterisation step and the stamped primary frame all
derive from that one answer. A tile that covers the viewport takes the
identical path it does today, so Flex is unchanged by construction; a short
tile now spans itself, so nothing it claims is zero-filled and the
supplemental row stays reachable.

Could you re-run the IC-7300MK2 drag? I haven't reproduced it here and I'd
rather not call it fixed on a code reading.

On why it didn't reproduce: Sim takes the same exact-span path, but
SimBackend::setPanCenter() echoes the requested centre back inside the drag's
33 ms command interval, so the tile tracks the viewport and the stale primary
claim sits behind it — every column falls through to the supplemental, and the
bad row is written but never read. Icom's sweeps carry the VFO's actual centre
and lag the retune, which puts the viewport inside the stale claim. Visibility
depends on echo timing, which may be why this survived on the Flex and Sim
paths.

The frame decision is unit-tested in spectrum_preview_logic_test, including
your sequence — viewport frozen while sweeps arrive at moving centres —
asserting the claim never reaches outside its tile. It fails against the
current code.

Not done: 3 is a separate defect in the release path and I've left it alone.
4 — mine pins the frame decision, not the full temporal sequence through
updateWaterfallRow(); that needs a widget-level harness I haven't built.

This branch is behind main, but main doesn't touch any of these functions, so
the reading holds on your merge too. For validation I can cover Sim; I have no
Flex, Icom, HL2 or Kiwi hardware.

@tropo1234
tropo1234 marked this pull request as ready for review September 3, 2026 05:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants