fixed plot flicker, measurement cost and toolbar icons

The flicker ("no data" at no constant frequency, worse with the measurements
and cursors panes up) was a torn read of the window's first stamp: acquire()
pins the window to the ring's oldest slot, and reading it at paint time races
the producer lapping it, returning a stamp one ring span too new. x0() is the
origin every trace point is reconstructed from, so one torn read produced a
refused neighbours(), a filter that dropped three quarters of the points, and
a polyline shifted off its axis. It is cached by acquire() under the lock now.

EventSeries::neighbours() answered from the (x - x0)/dx grid, whose drift from
the real arrival stamps arrives as a constant offset in seconds -- a doubling
share of the screen per zoom step, which is the "zooming into a held event
clips it more from the left" report. Both series binary-search the stamps now.

The measurements pane went through the LOD pyramid rather than reading its
window raw (19.7 ms per recompute to 1.2 ms, 0.1% of a 2 M-sample window) and
recomputes on a cadence; the math engine lost a per-slot heap allocation, a
per-sample source lookup by name and a fetch_add that cost more than the
arithmetic it counted.

The cursors pane's mix mode drew a line per *named* slot, so a slot holding a
signal that can never produce a channel -- the stream's timestamp -- got a row
that could only read "--", and such names were re-saved every session. It
counts bound traces now and apply_layout() refuses them.

Four toolbar icons are drawn for this app and compiled in as a GResource: the
stock ones were a record dot for the trigger and three magnifiers for cursors,
measurements and math.

New probes and dump lines for all of it (trace ink, measure cost, math cost,
event zoom, toolbar icons); README and CLAUDE.md record what they cure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-03 23:36:07 +02:00
1 parent 714d042809
commit a92a6b5bf0
20 files changed
+1945 -243

No files matched your search

+267 -13
View File
@@ -95,8 +95,23 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
placed, which is the only way a stale result or a mark that should not be drawn shows up as a
number. It must never print `marks=0` while measurements with `show_on_plot` are on and the
data is there -- that was the rulers blinking off at a mode change.
`measure cost: N recomputes, X ms total, Y ms each` follows it: **read it against a run with
`UDPSCOPE_PROBE_MEASURE_MS` unset**, because a frame mean cannot attribute a cost.
`measure cost: N recomputes, X ms total, Y ms each, data D ms (P%), covered=C buckets=B
raw=R (F%), N ns/covered sample` follows it: **read it against a run with
`UDPSCOPE_PROBE_MEASURE_MS` unset**, because a frame mean cannot attribute a cost. The last
three fields are the assertion, and they are a *ratio*, not a time: `covered` is the samples
the window spans, `buckets` the LOD buckets the two walks answered from, and `raw` the samples
they still had to read. **`raw` must stay at a fraction of a percent of `covered`** on a clean
waveform over a window of millions -- 0.1% is what the pyramid-backed walks give (see the
measurement-cost invariant below). A `raw` near `covered` means the walks went back to reading
the window, which at 1 MS/s is 19.7 ms per recompute and a third of the run's frames. It is
*legitimately* near `covered` for a window shorter than a bucket (64 samples) or a signal noisy
enough to straddle a level everywhere, so read it on the fast mock
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), where a window is ~2 M samples.
Note the **final** `measurements:` line and `[measure-trace]`'s last lines may read `--`:
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger before it dumps, so the dump can land in `Waiting`
with nothing bound and the axis at its default. The race-free reading is an earlier
`[measure-trace]` line, which carries its own `view=`; those lines are also where the
level-mark fallback is visible (an invalid pass must still print its `level:` marks).
- `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then
`[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L`
@@ -120,6 +135,29 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
that is the state the old `max(retained, window_s_)` bound leaked in, and it showed up here as
`span=2.502550 window=2.000000 limited=0` against `span=2.000000` after the fix.
- `[zoom-probe] <sweep>/x<step> axis=[..] span=.. n=N idx=[a..b) spp=.. level=L pts=P kept=K
back=.. ink=[..] x_at_first=.. gap_px=.. end_px=.. rect=[..]` (stderr,
`UDPSCOPE_PROBE_EVENTZOOM_MS`) — arms the trigger in **single** mode, polls for a **held
event** (a latch happens when the stream says so; a sweep on a timer has nothing to zoom
into), then zooms in eight doublings
twice: `ges/` writes the row's own axis the way a gesture does, `win/` drives the window
control (`apply_time_window`, which unpins and re-arms the live follow before the pin is
restated). Each step's reading is taken in the *tick*, after that frame's pin and clamp — a
`g_timeout` would report the ask. It recomputes what `Renderer::draw_traces` is handed, through
the same calls: `neighbours()` for `idx`, the 64-spp gate for `level`, then `gather()`.
**`gap_px` and `end_px` must stay within a pixel or two at every step** — they are how much of
the left and right margin no geometry reaches, and a `gap_px` that *doubles* per step is the
"zooming into a held event clips it more and more from the left" defect (374 px of a 716 px
axis by `ges/x128`), whose cause is a constant offset in data seconds (see the `neighbours()`
invariant below). `kept`/`back` are the renderer's own increasing-suffix filter counted rather
than applied: `kept` far below `pts` is a staircase, and a non-zero `back` is a non-monotonic
gather. **Run it with `--clock-jitter`** (`tools/mock_udpstreamer.py --rate 1300000 --batch
2000 --clock-jitter 0.4`): the ideal mock's stamps sit on a perfect grid, which is exactly
what hides this class of bug, and three runs against it reported `gap_px=0.0` throughout while
the defect was live. The jitter varies each *packet's* spacing rather than offsetting its
start, because the stream's clock has to stay strictly increasing. The `win/` sweep bottoms
out at `span=0.05` (`RowSet::set_time_window()`'s floor) and walks to the event's post side,
since `set_time_window()` frames the newest span before the pin clamps it — both are expected.
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
measured on a still picture. It now also asserts what a pause *is*
(`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]` on
@@ -275,17 +313,21 @@ Two lines of the self-check output are assertions in disguise:
(a parked cursor reports a real number, so a read-back would adopt it as a gesture — the
bug that made `T0` read 0). **`lines`** must be 0 for `vertical`, one per row for
`horizontal`/`both` (and the time row exists only in the modes that show the vertical
pair — `T=[]` in `horizontal` is the assertion that it does not), and one per bound trace
for `mix`; in `mix` the values must be the
pair — `T=[]` in `horizontal` is the assertion that it does not), and one per **bound**
trace for `mix` — a slot with a name *and* a channel the model built, never a name alone
(see the bound-slot invariant below; `lines=6 marks=3` over three real traces is that
defect). Each line reports as `<name>=<A> <B> <dY>`, so the names are the reading: a row
claiming a signal that is not on a plot is the phantom. In `mix` the values must be the
traces' own (`--` when a marker is past the newest sample, `partial` when only one
marker is over samples). After the move, `t0` must have changed and every row's marker
must have followed it (the report's `t0` is the shared value). **`marks`** is the `mix`
mode's crossing annotations: **0 in every other mode, and one per bound trace for each
marker that is over samples in `mix`** (so a move that brings the second marker into the
data doubles it). The `first mark at x=.. y=..` line that follows must agree with the
pane's *first* value for the same trace (the pane's lines are `chip Y1 Y2 dY`, with
the values formatted as measurements -- two decimals, an SI prefix, the channel's unit --
so the comparison is against the first number): both are read through the same
pane's *first* value for the same trace (the pane's lines are `chip name Y1 Y2 dY` --
the chip carries the signal's name, since a chip alone leaves the user to match a colour
against the plot by eye -- with the values formatted as measurements, two decimals, an SI
prefix, the channel's unit, so the comparison is against the first number): both are read through the same
`Plot::crosshair_value()`, and that line is the check that they stay the same number.
A **ruler** drag is the other direction and the other hazard: the pane's `A`/`B` cells
for a ruler row must change when the ruler moves (`after ruler drag` in the probe walks
@@ -309,11 +351,18 @@ Two lines of the self-check output are assertions in disguise:
its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration`
seconds, and the rest the channel's unit. `marks_shown` counts two per shown measurement
(a min/max showing both its marks counts two of its own). `measures window:` is the
timeliness assertion: **`drift` must stay at (or near) zero** -- the window the numbers
were computed over against the one on screen -- and every *time* mark must lie inside it
(`marks_in_window=K/K`). A measurement is recomputed when the window moves (2 % of its
span) or the held/live state changes, never on a wall-clock timer: a timer is what let a
marker sit outside the viewport and a dragged one survive a frame. **`marks_selectable` must be 0**: the
timeliness assertion -- the window the numbers were computed over against the one on screen.
**The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window
on screen. `drift` itself is a **single sample of a sawtooth** and is not an assertion on its
own: a live view scrolls between recomputes, so it rises to one `kMeasureRefreshUs` of scroll
and drops to zero at each pass. On a 2 s window at the 100 ms cadence the top of that sawtooth
is `drift=5%`, which is what a dump landing just before a scheduled pass reads -- a `0.0000%`
is a dump that landed just after one, not a better state. Read it against the recompute count:
*more* recomputes with a *higher* drift sample is the sawtooth, while a drift that exceeds one
refresh interval of scroll, or marks outside the window, is the defect. A measurement is
recomputed when the window moves (2 % of its span) or the held/live state changes, never on a
wall-clock timer: a timer is what let a marker sit outside the viewport and a dragged one
survive a frame. **`marks_selectable` must be 0**: the
measurement marks are created unselectable (`Plot::set_cursor_selectable()`, which the
library gained for this), read back out of the plot here -- re-placing a marker every frame
does **not** stop a drag, because the controller holds it for the gesture's life and the
@@ -387,6 +436,15 @@ Two lines of the self-check output are assertions in disguise:
and an expanding one is measured into the title's centre box, so it stretched from the
label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that
grows with the window width is that bug back.
- `toolbar icons: trigger=NAME(ok) pause=... cursors=... measures=... math=... sidebar=...
menu=...` (in the exit dump, beside `panel controls:`) — the icon name every toolbar button
carries, with `gtk_icon_theme_has_icon()`'s verdict. **Every entry must read `(ok)`.** Four
of the names are this app's own (`udpscope-trigger-symbolic` and friends, a `GResource`
compiled into the binary — see the toolbar-icon invariant below), and a `(MISSING)` on one of
those means the resource path was never registered. That failure is otherwise *silent*: GTK
substitutes a missing-image placeholder, the button still works, and every other line of the
dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this
is the only check that it names something that exists.
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once
with a drop and a window change, then again with no interaction: the second run's dump
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
@@ -433,6 +491,48 @@ Two lines of the self-check output are assertions in disguise:
lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything —
`tools/mock_udpstreamer.py --rate 1300000 --batch 20000` — since at the default 10 kS/s a
whole window fits in the raw path and the publish is too short to lose a race.
- `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%` plus a
`[ink] paint N <flags> mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=..
ink=[..] cover=..% x0=.. x_at=[..] bounds=[..] rel=[..] dx=.. kept_fixed_x0=.. drops=[..]
back=..` line per anomalous frame (stderr) — **`collapsed` and `staircase` must both be 0**.
This is the only check that can see a frame whose *waveform* vanished: `staged_segments`
counts geometry including the grid and the instruments, and `lod_traces` counts a trace whose
points are then collapsed, so both read healthy through the flicker. The probe runs on every
painted frame that drew a trace and recomputes what the renderer did, through the same calls
— `neighbours()` for the index window, the 64-spp gate for the level, `gather()`, then the
increasing-suffix filter — and reports where the ink actually landed against the axis.
`collapsed` is fewer than two distinct positions left (nothing to draw at all), `staircase`
is the filter dropping over three quarters of the points, `short` is ink reaching under a
quarter of the axis (reported only once the data spans the window, since a filling ring is
*meant* to leave its left side empty). `kept_fixed_x0` is the same filter with `x0()` hoisted
out of the loop: it exceeding `kept` means `x0()` moved *during* the walk. **`back` ≈ one ring
span is the signature** of the torn `x0()` read (see the cached-first-stamp invariant); so is
an `ink=` range disjoint from `axis=`, or an `idx=` covering the whole ring with `cover` far
over 100%. It is a race, so it needs load and a reason for long frames —
`tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --clock-jitter 0.4` with the
measurements *and* cursors panes up:
`UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MEASURES_MS=2000 UDPSCOPE_PROBE_CURSORS_MS=3500
UDPSCOPE_EXIT_AFTER_MS=35000`, which reported three staircase frames in each of three runs
while the defect was live and none after. `frame path:`'s own numbers move with it (`segments`
max 156078 → 19681), since a refused `neighbours()` gathers the whole ring.
- `traceless paints: N (M after the first bind, last at paint P)` and `worst side/pane min
width: W px`, plus `[gen] frame F generation A -> B (bound=.. empty=..)` and `[width-probe]
...` lines (stderr) — three cheap per-frame counters that came out of the flicker hunt and
each guard a documented invariant from the side the existing lines cannot see. **`M` must be
only the frames in a state that draws nothing**: a traceless paint is legitimate in
`DisplayMode::Waiting`, which is why the line carries `mode=` — `UDPSCOPE_EXIT_AFTER_MS` arms
the trigger before it dumps, so a tail of them at the end of a run is expected. `[gen]` names
each buffer rebuild as it happens with the bind state around it: a rebuild drops every ring's
history, so the frames after one legitimately draw an empty plot, and `blank view frames`
cannot see that (it asks whether the ring holds samples the view has not got, and a rebuilt
ring holds none either) — so an unexplained `[gen]` burst is a structural change firing when
nothing structural happened. `worst side/pane min width` is the "layout must not depend on
content" invariant read from the **demand** side: a `GtkLabel`'s minimum width is its text and
a minimum wider than the screen is honoured by growing the toplevel, which hands the plot an
allocation wider than the window; `axes.w` sees that a frame later and cannot name the cause,
so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the
per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget
tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read.
The probes are the regression net — when changing UI or plot-host behaviour, extend
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
@@ -553,6 +653,24 @@ far the largest file.
while every other row showed them. The same goes for the vertical pair's `x`: it is one
stream time for the figure, so it lives on the `RowSet` too, and `Row` keeps only what is
genuinely per row (the rulers' values, the traces, the colours).
- **A trace is a slot with a name *and* a channel; a name alone is not one.** `Row::channel[s]`
is re-resolved by `RowSet::bind()` every frame (slots are identified by name, see above), and
`-1` means the model built no channel for that name. Some names can *never* bind: the stream's
**time signal** is skipped by `build_channels_locked()` however often it is enabled, so a slot
holding it has a name for ever and data never. The plot showed nothing for it, which is why it
went unnoticed, but the cursors pane's `mix` mode drew one line per *named* slot and so gave
each phantom a row that could only read `--` — "the pane shows a lot of empty signals that do
not exist", six lines over three real traces. Two halves to the rule. (1) Anything that counts
traces counts `channel[s] >= 0`, not `!signal_name[s].empty()` — and a per-frame bind means the
cursors pane cannot key its rebuild on `generation()` alone, hence `App::cursors_pane_bound`, a
bit per row/slot (`kMaxRows * kSlotsPerRow` is 32, so one `std::uint32_t`): a trace that
appears without a structural change still gets its line. (2) `apply_layout()` refuses a name
that cannot produce a channel (`ScopeModel::is_time_signal()`), because such a name was
**sticky**: `collect_layout()` writes `signal_name` back verbatim, so one that got into a
session file was re-saved every session for ever. The refusal also heals an existing file on
the next save. How the names first got in is unexplained — the drop path already refuses a
signal with no channel — most likely an older build or a CONFIG whose time signal was not yet
classified as one; the fix is the stickiness and the counting, not a guess at the origin.
- **A row's contents are state of the `RowSet`, not of the figure.** `insert_row()` and
`remove_row()` shift that state with `carry_row()` and let `rebuild_all()` re-deal the
figure handles, so inserting above row 1 moves what row 1 plotted down to row 2 rather
@@ -628,6 +746,20 @@ far the largest file.
computes `got` from the same call the renderer makes, so it cannot catch a renderer that went
back to the grid — what it still reports is the drift that made the walk wrong (`dx_ratio`).
- **`neighbours()` is a binary search over the real stamps in *every* series here, the
snapshot's included.** `EventSeries` answered from the `(x - x0()) / dx()` grid — the walk the
invariant above condemns: `dx()` is the *mean* spacing of the snapshot's stamps and the stamps
are an arrival clock, so grid and clock drift apart by whatever that clock wandered over the
capture. The renderer picks its visible index window from this call, so the drift arrived as a
**constant offset in seconds** between the axis and the gathered geometry — and a constant
number of seconds is a *doubling share of the screen* on every zoom step: 8 ms over a 2 s
capture is invisible, and seven halvings later it is 374 px of blank left margin on a 716 px
axis, with the ink pushed off the right-hand edge by the same amount. That is the "zooming
into a held event clips it more and more from the left while the crosshair still reads the
right values" report; the crosshair was right because it reads `x_at()`, a real stamp.
`ChannelBuffer` always searched; the two now agree. **A perfectly uniform source cannot show
this at all** — three probe runs against the ideal mock were clean — so the guard needs
`--clock-jitter` (see `UDPSCOPE_PROBE_EVENTZOOM_MS` above).
- **The `Series` grid contract does not hold for `ChannelBuffer`.** x is a streamed
timestamp, so `dx()`/`x0()` report a nominal grid (LOD level, coarse snap) while
`gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring.
@@ -668,6 +800,34 @@ far the largest file.
every live view. The library's `StreamSeries` is immune for a reason worth copying:
its x is `x0 + i*dx`, a function of the absolute index, so an overwritten slot costs a
*y* value, never the geometry. Clamping buys the same bargain here.
- **The window's *first* stamp is cached by `acquire()` and never read at paint time.** The
clamp above makes an unreadable slot *survivable*; it does not make reading it *safe*, and
`absolute(0)` is not an ordinary slot — `acquire()` pins the window to `values_->base()`, so
sample 0 is the ring's oldest, the very slot the producer is about to overwrite. `time_of()`
checks the bounds and then copies, and when the producer laps the slot in between the stamp it
returns is **one whole ring-span too new**. Everything else on a `ChannelBuffer` is read at
paint time with no lock, and `x0()` is the origin the renderer reconstructs every trace point
from (`gather[i] + series.x0()`), read both inside its increasing-suffix filter and again at
`stage_xy()` — so one torn read of that one slot produced three pictures, all of them "the
waveform is gone while the grid and the axes stay": `neighbours()` tests against `x_at(0)` and
refused the whole visible range (the renderer then gathered the *entire* ring at a coarse level
and drew it off the axis — `idx=[0..8388608) level=7 cover=320%`); `x0()` changing *during* the
filter loop dropped three quarters of the points; and `stage_xy()` shifted the finished polyline
a ring-span off its axis. That is the "with measurements and cursors active the plot flickers as
if there were no data, at no constant frequency" defect. It is **live only** (a snapshot is
immutable, so `x0()` is constant for `Held`/`Paused`), it is a race against packet arrival so it
is irregular, and the measurements pane makes it both likelier and larger: the pane holds
`mutex()` for each `MeasureWindowReader`'s life, so the released producer bursts several packets
at once. `acquire()` runs on the consumer under `ScopeModel::mutex()` — the same lock the
producer's pushes hold — and the window is pinned on the line above, so that is the one point in
the object's life where the slot is safe to read; `window_x0_` is also the *right* answer rather
than merely a stable one (it is the stamp sample `window_base` actually carries, where a
paint-time clamp answered with whatever slot happened to be oldest *now*). `x_at(0)` is served
from it too, for the same reason, and `invalidate()` clears it with the window. The guard is
`trace ink:` / `[ink]` (see Verification). The same torn read still exists for a small `i > 0`
— the oldest frame or so of samples — but there its observable is a *prefix plateau*, which is
exactly what the renderer's increasing-suffix filter was written to absorb, so it is a different
symptom and deliberately not fixed here.
- **`clamp_to_data()` caps the span at the *requested window*, never at the retained
span.** It runs every frame and cannot distinguish "the user zoomed out past the data"
from "the ring has only just started filling", so `window_s_` — what the window control
@@ -764,7 +924,18 @@ far the largest file.
so no rebuild can invalidate a live callback handle -- the same shape the measurements dialog
uses. `UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test
as much as a state check), and the dump prints each derived channel's extremes beside the
source's. Four rules here were wrong first and are worth keeping:
source's. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the
math is on the producer thread, so what it can break is packet capture, never the frame rate)
and each `math '<name>': ... N samples vs M time slots` being **equal**, with `math x2` at
`y=[-2, 2]`, `math lp` at `y=[-0.998, 0.998]` and `math3` at `y=[-2, 2]`. The `0 visible
(order breaks 0, first at -1), y=[0, 0]` tail of that line is **not** a failure: it is
`visible_samples()` reading row 0's axis after the window was destroyed, and
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger first, so the dump can land in `Waiting`. The live
`ch N '<name>' ... y=[..]` lines are the race-free reading of the same thing.
`math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the
math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure
anywhere in 78--101 ns/slot is one reading of the same thing. Four rules here were wrong first
and are worth keeping:
* **A `Source` may only name an *acquired* channel** -- `ScopeModel::channels()`, which is what the
dialog's dropdown lists, **and both places that create a node must take one from there**.
`signals().front()` is the stream's *timestamp* signal: it is first in every CONFIG, it is not
@@ -931,6 +1102,67 @@ far the largest file.
compares with a relative tolerance, because clamping to exactly `window_s_` can leave the
group's span an ULP above it and a clamp that fires every frame is a re-frame loop, not a
clamp.
- **A measurement must not read its window raw.** Both passes go through
`plot::data::MinMaxPyramid`, which the producer already maintains at amortised O(1) -- so the
pane inherits the summary of the new samples rather than computing one, which is what the
"evaluate only on new samples" ask comes to here. Throttling the cadence (above) bounded *how
often*; this bounds *how much*, and it is the larger factor: 19.7 ms per recompute to 1.2--1.6
ms, reading **0.1 %** of a 2 M-sample window. Pass one (`measure_scan_buckets()`) adds whole
level-0 buckets -- 64 samples each, carrying min, max an **exact** sum and a finite count -- and
locates each extreme to a *bucket*; one raw `measure_scan()` of that bucket alone then pins it
to a sample, two buckets at most whatever the window's size. Pass two
(`measure_accumulate_bucket()`) folds a bucket whole only when it lies **strictly** on one side
of its level (`k.lo > level` or `k.hi < level`): then every sample decides the same side, none
is *at* the level, and an entering edge is exactly the bucket's first sample -- which is where
the raw walk would have found it, so this is exact and not an approximation. It refuses a
bucket that straddles, and a bucket holding a non-finite sample, because the raw walk takes the
first *finite* sample of the new side and an aggregate cannot say which that is; the caller
then opens that bucket. Four things this rests on, each of which breaks it silently:
* **Exactness is the point, so nothing may stride or decimate.** `peak to peak` must still read
exactly `2` on a unit sine and `top` exactly `0.636620` (2/pi). A bucket's sum is a sum.
* **The bucket index space must be the window's.** `ChannelBuffer::acquire()` calls
`pyramid_.drop_before(values_->base())`, so a channel's pyramid is in the value ring's
absolute index space -- the same space `live_window_locked()` answers in, since a channel's
stride is always 1. The snapshot's pyramid is built from sample 0 and never dropped from, so
its space is the snapshot's own. `MeasureWindowReader::origin_` is what turns one into a
window offset, and a mismatch there measures the wrong samples rather than failing.
* **The raw path stays, and stays exact**, for the window's partial edge buckets, for the
samples produced since this frame's `acquire_all()` (one frame of production, so bounded), and
for the straddling buckets. A window shorter than a bucket degrades to O(window) and must
still be right.
* **Both passes walk the same fetched bucket list** (`App::measure_bucket_scratch`, reused
across recomputes -- a 2 M-sample window is 32 k buckets). Fetching twice is a second walk of
the pyramid for nothing.
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
anything.
- **The math channels' cost is the *producer's*, and the instrument must not be part of it.**
`push_math_sample()` runs once per stored slot -- up to the wire rate -- so what a regression
here looks like is `gaps`, never a frame time. Three things were taken off that path and must
not come back: a `std::vector` *local* (a heap allocation and free per slot; it is the member
`math_values_`, `assign()`d), a `Source` node's channel resolved **by name** per sample (a
linear scan with a `std::string` compare per candidate, per node, per slot, behind a
type-erased call -- `math_prepare()` resolves it once into `MathNodeRt::src`, and every rebuild
re-prepares), and a **`fetch_add` on the cost counters**. That last one was 20-25 % of the
measured figure: a `lock xadd` costs about what a two-node graph's whole arithmetic does, so
counting the slots cost more than the work being counted. The counters are producer-written
only, so each is a relaxed `store()` of a value already in hand (`math_slot_count_` and
friends). Two things about the figure itself:
* **It is sampled, one slot in `kMathCostSample`, and it is an upper bound.** A slot's work is
the same order as one `clock_gettime`, so the measured slot carries a clock read. Timing a
*run* of consecutive slots to amortise that away **is wrong and was tried**: the producer gets
its slots a packet at a time, so a run long enough to be worth timing straddles a packet
boundary now and then and charges the measured slots the **wait for the next packet**. At 2000
samples a packet a 64-slot run crosses one ~3 % of the time, and a ~1.5 ms gap spread over 64
slots reported **799 ns/slot against the true ~80**. One slot is always inside one packet.
* **78--101 ns/slot is one reading.** Four identical-config runs spread that far, so the per-slot
path is at the noise floor of this harness and a micro-optimisation cannot be shown to work
here. Converting `MathRuntime` from eight parallel `std::vector<double>` to one array of
`MathNodeRt` was measured and bought **nothing** -- at six nodes the whole runtime is
L1-resident either way. It was kept for the shape, not for a number; do not expect a cache
argument to pay at this size.
- **The status line's per-second fields are measured once a second, not per frame.**
`App::stored_rate`/`declared_rate` and the label's tooltip are refreshed on the same
tick as the channel dump; the status line only formats what is already there. The
@@ -980,4 +1212,26 @@ far the largest file.
runs after the window is destroyed and asking GTK about a widget then is four criticals, which is
how the first version of that line earned them. What stays in the menu is what acts on the *data*
(Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text.
- **Four of the toolbar icons are this app's own, and they are a `GResource` compiled into the
binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a
measurement ruler, and the stock names those buttons used to carry said something else
entirely: a *record dot* for the trigger and three different **magnifiers** for cursors,
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math}-symbolic.svg`
are bundled by `src/icons/udpscope.gresource.xml` (CMake runs `glib-compile-resources`) and
reached through the `gtk_icon_theme_add_resource_path()` at the top of `build_toolbar()`.
A resource rather than files beside the binary because udpscope is run straight out of the
build tree and has no install step. Five authoring rules, each of which fails quietly:
* **GTK's symbolic recolour only reaches `fill` on `path`, `rect` and `circle`.** It wraps the
SVG in a stylesheet for those selectors, so a `stroke`, a `line` or a `polyline` keeps the
colour written in the file and stays dark on a dark theme. Everything is a filled `path`.
* **Whole-pixel coordinates**, because these are drawn at 16 px far more often than not: the
first draft placed features on half-pixels and the whole icon was a grey smear at 1x.
* `fill-rule="evenodd"` is how the ruler's body is a hole *and* its ticks still fill -- a tick
inside the hole is three crossings deep, which is odd. Nonzero would fill the hole back in.
* Overlapping subpaths under the default nonzero rule must be **wound the same way** or they
cancel instead of uniting. All of them run clockwise.
* `--` is illegal inside an XML comment and librsvg refuses the whole file for it.
`gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see
`toolbar icons:` under Verification): a resource path that was never added does not raise an
error, it draws `image-missing`.
- Every source file carries an SPDX MIT header; match it in new files.