Files
udpscope/CLAUDE.md
T
Martino FerrariandClaude Sonnet 4.6 02c53bb7a0 docs: fix five accuracy findings in the libadwaita and icon documentation
- CLAUDE.md: the libadwaita dependency paragraph now names both AdwDialog
  widgets (AdwShortcutsDialog and AdwAboutDialog), explains the shared
  on_dialog_closed() handler keyed by pointer-to-pointer, and covers
  both show_about() and show_shortcuts() in the AdwDialog sentence.
- CLAUDE.md: the icon-authoring invariant's explanation of how Icon= in
  the desktop entry resolves now names the installed copies under
  $XDG_DATA_HOME/icons/hicolor/scalable/apps and .../symbolic/apps,
  not the GResource (a desktop shell cannot read a compiled-in resource).
- CLAUDE.md: [help] probe example byte count corrected from 1381 to 1379
  (measured: wc -c src/help/01-overview.txt = 1379).
- README.md + udpscope.gresource.xml: "file, line and column" corrected
  to "file and line number (plus a caret under the offending character)"
  to match xmllint's actual output; also removes -- from the XML comment
  where it was illegal.
- README.md: desktop-file-validate described as a hard failure "when the
  tool is on PATH", matching cmake/install_desktop_entry.cmake lines 44-54.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-04 21:13:22 +02:00

1440 lines
120 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build and run
```sh
git submodule update --init --recursive # deps/plotter is a submodule
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build
./build/udpscope --server 127.0.0.1 --port 8000
./build/udpscope --server 10.0.0.5 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
```
The app can be launched from the shell rather than only from a terminal, but only on request:
```sh
cmake --build build --target desktop-entry
```
That writes `org.udpscope.app.desktop` plus both app icons under `$XDG_DATA_HOME` (default
`~/.local/share`), runs `desktop-file-validate` as a **hard failure** (when it is on `PATH`;
absent, it says so and installs anyway), refreshes `update-desktop-database` and
`gtk4-update-icon-cache` when they are on `PATH`, and prints the three paths it wrote — which is
the uninstall instructions, since there is no uninstall target. `cmake --build build` alone never
touches `$XDG_DATA_HOME`.
The entry's `Exec` is the built binary's **absolute path in the build tree**, so **moving or
deleting the build directory breaks it.** That is the price of having no install step and it is
deliberate: the entry then always launches exactly the binary that was last built. No
`StartupWMClass` is needed — GTK takes the WM class from the application ID, and the desktop
file's basename is that same string, which is what associates a running window with the entry.
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards).
Run with no `--server` and udpscope asks which source to open: the sessions it has stored, plus a
form for a new host. Every probe below passes `--server`, so none of them sees it.
`deps/plotter` is built from source with only the GTK4 adapter enabled
(`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a
vendored C99 source pair compiled into a static lib.
**libadwaita ≥ 1.8 is a system dependency** (`pkg_check_modules(ADWAITA ... libadwaita-1>=1.8)`),
for two widgets: the shortcut overview (`AdwShortcutsDialog`) and the About dialog
(`AdwAboutDialog`). GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and
`AdwShortcutsDialog` is its replacement; `GtkAboutDialog` still exists but
`adw_about_window_new()` is deprecated in favour of `AdwAboutDialog`, which shows the
application icon as the page's own header. Two consequences worth knowing before editing.
`adw_init()` is called — in `run_ui()` and, since the chooser runs first and must be styled
the same, in `choose_source()` in place of its `gtk_init()` (both are idempotent) — so
**the whole app is drawn with libadwaita's stylesheet**, not GTK's: a metric change there
reaches every widget, and `layout over N frames` / `axes.w changes` is what says whether
the layout still settles. And an `AdwDialog` is **not** a `GtkWindow`: both `show_about()`
and `show_shortcuts()` keep an `AdwDialog*`, cleared on `::closed` (there is no
`::close-request`) and closed with `adw_dialog_close()`. They share one `on_dialog_closed()`
handler keyed by a pointer-to-pointer (`gpointer slot` cast to `AdwDialog**`), so one
handler clears either pointer without knowing which dialog it is. Presenting either on this
app's plain `GtkApplicationWindow` is supported and warning-free; it was checked.
`deps/plotter` is upstream's own repository and this app's figure support was added
there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the
common left gutter), and in the GTK4 adapter `PlotArea`'s **figure mode** —
`set_figure()`, one `input::Controller` per row, nearest-row event routing, a
figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over.
The render-quality work went there for the same reason: `kTextFS` now **filters** the
glyph atlas (a 2×2 bilinear clamped to the instance's own cell) instead of point-sampling
it, and `LineProgram::set_snap()` / `u_snap` rounds an axis-aligned segment's ink onto
whole pixels for the chrome that frames a plot (grid and axis lines), never for anything
drawn at a data position. `generate_ticks()` also stores the tick at the origin as
exactly `0.0` — the walk starts at `ceil(min/stride)*stride` and so never lands on zero
when the stride is not a power of two, which labelled it `-139a`. All three carry
upstream tests — `the_atlas_is_filtered_not_point_sampled`,
`grid_hairlines_land_on_whole_pixels` and `the_tick_at_the_origin_reads_zero`. The minor
grid's look is set there too, not here: `grid_minor` carries 50% alpha, `grid_minor_width` is
0.75 (sub-pixel, expressed as coverage at 1x) and `grid_minor_dash_on`/`_off` are 3/3 — a
fragment discard, no extra geometry — so the subdivisions read as texture under the labelled
grid instead of as a second set of ticks (`the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major`).
README records what they cured.
Changing any of this means committing in the submodule and moving its pin, and running
the library's own suite as well as the udpscope probes below:
```sh
cmake --build deps/plotter/build/debug
xvfb-run -a deps/plotter/build/debug/tests/plot_tests # expect "0 failed"
```
## Verification
There is no unit test suite. The only automated check is the app's own headless
self-check, driven by env vars and run under a display (`xvfb-run`, llvmpipe) against
a live UDPStreamer source:
```sh
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
./build/udpscope --server 127.0.0.1 --port 8000
```
Two baselines every run is read against. A clean build emits **five** warnings and no more: two
`-Wmissing-field-initializers` on `MathNode::source`, one unused `output_built`, and
`-Woverlength-strings` on **both** generated GResource `.c` files — the help pages' and the
icons', whose blob crossed C99's 4095-byte string-literal limit when the two app icons joined the
four action ones (a note about a generated file, which GCC itself supports). And an exit-dump run
emits exactly **two** `Gtk-CRITICAL`s, `gtk_widget_get_height` and `gtk_widget_get_width`, from
the dump asking a destroyed widget for its size. A sixth warning, or a third critical, is a
finding.
- `UDPSCOPE_EXIT_AFTER_MS` — run the trigger-on → go-live probe, close the window,
then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted
frames, trace bindings, panel sizes, status line in both states).
- `UDPSCOPE_ENABLE_ALL_MS`, `UDPSCOPE_ARM_TRIGGER_MS`, `UDPSCOPE_PROBE_DISARM_MS`,
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only
*starts* its sequence: it switches the trigger on and then polls for a capture to open,
so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall.
- `UDPSCOPE_PROBE_FIELDS_MS` — change the trigger panel's edge and Normal/Single
dropdowns. As much a crash test as a state check (see the GTK signal-signature invariant
below), so time it inside a capture: `[fields-probe] ... capturing=1` followed by no
SIGSEGV *is* the assertion.
- `UDPSCOPE_PROBE_WINDOW_MS` — drive the window control every way a user can: type a
number, change the unit under it, step a decade either way.
- `UDPSCOPE_PROBE_LAYOUT_MS` — drive the layout editor: switch it on, insert above the
first row, remove a row, append one at the bottom, switch it off. Its steps are 200 ms
apart on purpose — the buttons are placed from the rects the *last frame* was laid out
with, so a synchronous sequence would read every position one action stale. It clicks by
emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its
250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed.
- `[measure-probe] N measurements on 'X'` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one
measurement of each statistical type (average, top, peak to peak, amplitude) on the first
*channel* and forces a recompute. It must name a **channel**, not `signals().front()`: the
first signal of a stream is its timestamp, which is not acquired, and naming it adds four
measurements that all read `--` *and* makes a performance run look free (each bails before
collecting anything -- that is how the first baseline of the stall below read 0.4 ms).
The values are in the exit dump as `measurements: <type>=<value>`; on the fast mock
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`) a unit sine must read
`peak to peak=2`, `amplitude=1`, `average=0` and **`top=0.636620`** -- 2/pi, the half-wave
mean of a sine, which is the check that the threshold types are not merely plausible.
`[measure-trace] view=[..] <type>=<value>... marks=N level:V/time:V...` is a repeating line
(250 ms) from the probe: the pane's numbers beside the *current* window and the marks actually
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, 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`
(stderr, `UDPSCOPE_PROBE_PAN_MS`) — **`held` must be 1**: a gesture must not move a window
that is pinned (trigger mode, or a paused view). The probe writes the panned range the way a
gesture does — on the *member* axis, which `Figure::layout()` adopts into the group — and
reports the axis the frame after it produced. Time it *inside* one pinned state: a pan during
a capture whose check lands after the capture has ended is re-framed by the mode change, not
by the pin, and reads `held=1` with the hold removed (that is how the first version of this
probe fooled me). The pause probe makes a stable pin, so run them together:
`UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_PAN_MS=2200` puts the pan and its check inside
the pause. With `hold_pinned_window()` disabled it reads `held=0` and the axis sits at the
asked range. It then asks for a **zoom-in** (a window a quarter of what is on screen) and
reports `group=`/`member=` beside it: **`allowed` must be 1, with both equal to the ask** --
that is the single-trigger-mode case, where the wheel has to keep working, and where reading
the wrong one of the two made a working clamp look broken. All three verdicts are read from the
*tick*, one frame after the ask: a `g_timeout` was the first version and it drifted past the
paused/resumed boundary under load. The probe then asks for a **zoom-out** ten times the current span and reports
the span the next frame produced: **`limited` must be 1**, i.e. `span <= window`. Run it with
a ring that holds *more* than the window (`--buffer-mb 64` past the window's own seconds) —
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
stderr):
it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display
state as well as an acquisition one and the view snapshot and axis pin live in the
handler. Across the three lines **`ring_produced` must grow** (the producer never
stops — it fills the live ring exactly as it does with the trigger off), **`event_size`
must be non-zero exactly while paused** (the plot is bound to the frozen snapshot, not
the ring) and **`frozen_x` must not move between the pause and the resume**; after the
resume `event_size` is 0 and the window is back on the newest data. Pausing used to stop
the producer, which is why the counts grew by nothing and the resume left a hole.
- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default
`/tmp/udpscope-export`) — collect the export table and write it both ways, skipping
only the file chooser.
**Run one self-check at a time.** Two concurrent `xvfb-run -a` instances end up on the
same display and the second gets no GL context — it dumps `layout over 0 frames` and
`valid=-1`, which looks like a pipeline failure and is not one. `tools/mock_udpstreamer.py`
also outlives its `--duration`, so kill it after each run or the leftovers pile up and
hold ports.
Two lines of the self-check output are assertions in disguise:
- `layout over N frames: axes.x A..B, ...` — **`viewport.w` must collapse to a single
value and `axes.x`, `axes.w` and `y-gutter` must *settle*.** `note_y_gutter()` is
monotone (`plot_host.cpp`), its one-shot widest-label reservation can land a frame or
two after the first measured frame, and the figure serves its floor out of the label
strip (`y_width = max(measured, floor - y_title_w)`, `plot.cpp`) so the reservation
converges over two layouts rather than one. A `y-gutter 44..74` dragging
`axes.x 67..80`/`axes.w 546..559` with it is that settle, not a twitch — confirm it
with the `changes` line below. A range that keeps moving is the "twitching axis" bug:
the plot being re-measured every frame. **`x-desync` must read 0**: it counts frames
in which any row's x range differed from row 0's by more than a pixel of data, i.e.
frames the x link group did not reach. `x-ticks` is content, not layout, and
legitimately varies by one as a live
window slides over a round number. The companion `axes.w changes: N (last at frame F,
side panel up=S)` line separates the two readings: `0`, or a handful near the start,
is a settled layout; a count near the frame count is the twitch. Runs far longer than
the documented probes have shown one ~320 px step — the side panel's width — that
reverts; it is intermittent and unexplained, so read `changes` before calling a range
the twitch bug.
- `[trig-probe] ... trigger=on ... display=... epoch=...` (stderr,
`UDPSCOPE_TRIG_TRACE_MS`) — with `trigger=on`, `display` must read `event(frozen)`,
`live(capturing)` or `nothing`, **never plain `live`**, except for the single interval
that contains the toggle itself — recognise it by `latches=0` *and* the next probe
reading `nothing` or `live(capturing)` with a frozen `axis=`. Plain `live` with the
trigger on is the old "the plot keeps scrolling in trigger mode" defect; the named
`live(capturing)` is the feature. In that state `epoch=` must equal minus the capture's
edge and `axis=` must stay at `[-pre..+post]` across consecutive probes while
`data=`'s upper bound climbs towards `+post` — an axis that moves instead means the
capture is being treated as a live view (`clamp_to_data()` reaching a pinned window).
`epoch=` must be back to `0.0000` in every other state. Note the probe reads
`pending=` fresher than `display=` (which is last tick's decision), so a single
`pending=0 display=live(capturing)` line is one frame of sampling skew, not a defect.
And `snap=[a..b]` must equal `axis=[a..b]`, with `b` positive by roughly
the post-trigger share: that is the "post window is empty" defect's observable.
**`drawn=[a..b]` must equal `data=[a..b]`** in both modes: `data` is `x_at()` (what
the crosshair reads) and `drawn` is `x0() + gather()` (what the renderer maps), so a
series that breaks the "gather emits x relative to `x0()`" contract shows up here as
the two disagreeing by exactly `x0()` — a trace drawn a whole pre-trigger window left
of its axis, invisible once the view is narrower than the shift, while the crosshair
keeps reporting correct values.
- `[trig-probe/idx] n=.. bounds=[..] dx=.. dx_real=.. dx_ratio=R got=[a..b) want=[c..d)`
(stderr, same probe) — **`got` must contain `want`** (`a <= c` and `b >= d`), and
`dx_ratio` must stay within about a percent of `1.0`. `got` is the index window
`Renderer::draw_traces` gathers *today* — `neighbours()`, one sample of slack either side —
and `want` is the same window from the same call, so read this line for the **rate**:
`dx_ratio` is the nominal `dx()` against the real last-spacing, and it is the number that
used to matter. It above 1 (every index scaled up, `a` past the first visible sample, the
samples in between in the buffer and readable by the crosshair but never drawn) was the
live view whose older part went blank while its newest part looked fine. That is a *fixed*
defect: the renderer no longer walks the grid at all, because on a ring-backed series it
also lost the two end segments and left the left and right margins of a zoomed-in view
empty. The guard for it is the library's own pixel test
(`a_sparse_window_between_samples_still_inks_both_margins`), not this line — a probe that
recomputes the renderer's formula cannot see a renderer that changed it. Also
read `axis=` in the companion line against the `window control:` line in the dump —
a live axis narrower than the requested window is the same symptom from the other
cause (see the `clamp_to_data()` invariant below). Both need a *saturated* ring to
show up, so run them under `--buffer-mb 1`, which wraps in a few seconds.
- `trigger: edge at X ... want [-a..b] got [-c..d] n=N` (stderr) — `got` must equal
`want` and `N` must be exactly the window times the stream's real rate, plus one
(2 s at 10 kS/s is `n=20001`, not 19999). `got` shorter than `want` on the *left*
means the edge was accepted before the history reached back a full pre window (the
gate in `TriggerEngine::process()`); shorter on the *right* means the engine latched
early or the cut lost the newest samples. An `N` one or two short of the exact count
is the same class of bug, not rounding: `publish_event()` clamps the slice to every
value ring's `produced()`, so publishing before each channel of the latching sample
has been pushed silently drops it.
- `[disarm-probe] was_capturing=1 display=.. seq=A->B events=C->D snap=[a..b] n=N
axis=[..]` (stderr, `UDPSCOPE_PROBE_DISARM_MS`) — with `was_capturing=1`, **`display`
must read `event(frozen)`** and `snap`/`axis` must be `[-pre..e]` where `e` is how far
the post window had filled, with `n` that span times the stream's real rate plus one.
`display=nothing` (or a `snap` from the *previous* event) is the "disarming mid-capture
makes the event completely disappear" defect. **`seq` must advance by exactly one while
`events` does not move**: no event fired, so the partial publish deliberately leaves
`trigger_latches()` alone — which is also what keeps the `trigger events: N total, M at
the disarm` line in the dump meaning what it says.
- `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] stream=[..]` (stderr,
`UDPSCOPE_EXPORT_MS`) — **`time=` must equal `axis=`** and **`rows=` must be the
span times the stream's real rate, plus one** (2 s at 10 kS/s is 20001). The export
is the *visible* range at the *stored* rate, so a count far short of that is a table
cut from the LOD pyramid, and a `time=` narrower than `axis=` is a window searched on
the wrong clock (the live branch subtracts `display_epoch()` before searching the
ring). In `mode=event`, `stream=` must be the edge ± the window; in `mode=live` it
equals `time=`.
The one exception to both: an export taken **mid-capture** reports `mode=live` with
`time=[-pre..e]` where `e` is how far the post window has filled, and `stream=` offset
from it by the edge (so `stream - time` reads the edge exactly) — the export is what is
on screen, and during a capture that is a partly filled window. Pin the probe outside a
capture when checking the `rows=` count.
The two `bytes=` lines are the writers: the `.npy` is
`64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a
mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing
either writer.
- `[window-probe] <action> label=.. entry=.. unit=U window=W event_span=E cap=C decim=M
stored=R aa=F buffered=B` (stderr, `UDPSCOPE_PROBE_WINDOW_MS`) — **`window` must equal
`event_span`** on every line (the trigger's event span is the display window; a capture
cut to a different length than the axis frames is the "post window is empty" defect's
cousin) and **neither may exceed `cap`**. The `unit ms->s` line is the one that catches
the feedback loop: changing the unit re-interprets the typed number, so "500" in ms
becomes 500 **s exactly**, and a `window=499.98` there means the control re-parsed its own
rounded readout (`8.333 min × 60`) instead of the request — see the `window_sync_unit`
invariant below. `label` is a reading to three decimals in a natural unit and is
*expected* to be lossy (500 s reads `8.333 min`); it is the `window=` figure that has to
be exact.
For the resampling: **`buffered` must be ≥ `window`** on every line — that is what the
decimation is for, and a `buffered` short of the window is the old "the control is capped
to what the ring holds" behaviour coming back. `decim` must be a power of two,
`stored × decim` must equal the wire rate, and `aa` must be exactly `stored / 2`. Run it
under `--buffer-mb 1`, which saturates in a few seconds and makes windows past ~6 s
decimate; at 200 MB nothing inside `--max-window` needs to.
- **The anti-alias check** is the same probe run against a waveform above the stored
Nyquist: `--buffer-mb 1 --freq 2000` with the probe ending on a 60 s window gives
`decim=16`, a 625 S/s stored rate and a 312.5 Hz corner, and the channel dump's
`ch 0 'sine' ... y=[-0.059, 0.079]` is the assertion — a boxcar of 16 at 10 kS/s passes
2 kHz at about 6%. **A `y=[-1, 1]` there means the filter is gone** and the 2 kHz sine has
aliased to a full-amplitude 125 Hz one, which looks entirely plausible on screen. This is
the only check that distinguishes averaging from striding.
- `[cursors-probe] gestureless plot move with no gesture: stored A -> B ignored=D` — a
plot-level `set_cursor_x()` with **no gesture behind it must be ignored** (`ignored=1`, and
`A == B`): the tick reads a row only while its controller holds a cursor, and this line is the
guard for the defect where it adopted its own echo and a dragged cursor sprang back. (The
*adoption* half -- a gesture that does move a cursor, and the shared value reaching every row
-- is asserted in the `check_host` harness, which can call `sync_cursors(row)` directly; a
synthetic press here cannot reach the adapter's own event pump.)
- `[cursors-probe] <action> mode=.. on=D pane=D t0=.. t1=.. dt=.. T=[..] lines=N [..]`
(stderr, `UDPSCOPE_PROBE_CURSORS_MS`) — walks `vertical` → `horizontal` → `both` →
`mix`, then moves a marker *through the plot* (`Plot::set_cursor_x(..., Free)`, which is
what a drag does) and reports again. **The times must survive every mode change**: they
are the `RowSet`'s one pair, and a mode that parks the markers must not read them back
(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` — 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 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
the first ruler to 80% of its y range), because nothing shares a ruler and the read-back
in `RowSet::sync_cursors()` is the only thing that can notice. The dump's `cursors in plot
0:` line is the other half: it reads the markers back out of
the plot (`cursor(id).x/.visible`), which is what the renderer draws from — the pane's
numbers and the plot's state agreeing is the difference between "the app placed them" and
"the app placed them and they are on screen".
- `[measures-probe]` + the dump's `measure N: 'sig' type thr=..% above=D show=D -> ok|invalid value text=[..]
[duty=..] [marks=..]` and `measures pane: n=N revealed=D marks_shown=M pane_rows=R pane_w=W
pane_h=H`
(stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first
plottable signal and lets the tick compute them, so what it prints is what a user reads.
On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.64 and
`bottom` ≈ -0.64 (the mean of each half of a sine), max/min ≈ ±1, `pulse duration` ≈ 0.5 s
with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. `marks=` lists each mark with its kind, and the **mix is the point**:
`level:V` alone for average/top/bottom, `level:V,time:T` for min/max (a ruler at the value
*and* a marker at the first sample that reached it — two level marks at both extremes was
the bug), `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with
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 -- 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
marker follows the pointer between frames. The library's own controller test
(`an_unselectable_cursor_is_neither_hovered_nor_grabbed`) is the other half. The probe's last
step still drags a mark *programmatically* and requires the next frame to put it back
(`restored=1`), which now checks the placement path rather than the read-only property. The dialog report's `type= thr=
side= show=` is the per-type behaviour: `period` and `frequency` must show **neither** the
split nor the side row (they compute from the mid level's rising crossings, and an option
there is one the user can only get wrong), `average`/`min`/`max` no split either, and only
`pulse duration` a side. A measurement whose signal is not acquired, or
a window with too few crossings, must read `invalid`/`--` rather than a made-up number
(`frequency` on a window holding one edge is the case: `duty` is reported only when a
period exists to divide by). The probe then **walks the dialog's lifecycle**: open the edit
dialog on measurement 0, click its `Save`, open it again, click `Delete` — `measures=` must
be unchanged after the save and one lower after the delete, with the process still alive.
That is the check for the two things a dialog of this shape gets wrong: two buttons owning
one context (a double free aborts on edit/delete, and only those paths have two buttons) and
a `const char*` array built from a `std::vector<std::string>` as it grew (every pointer
dangling, the first entry the most visibly). The report's `signals=[..]` and `drop_h` are the second and third:
the list must be the **plotted traces** (real names, deduplicated, in signal-list order —
*not* every channel, and never a stream's timestamp signal, which is what an acquired-but-
unplotted list put first and auto-selected), and `drop_h` must be a combo box's height
(tens of pixels — a scrolled-window wrapper asked it to grow to 160).
- `[timelabels-probe] <action> relative=D window=.. origin=.. note=[..] ticks=[..]
first_value=.. first_rel=.. range=[..]` + the dump's `time labels: relative=D origin=..
note=[..] x_origin_set=.. ticks=[..] first_rel=..` (stderr,
`UDPSCOPE_PROBE_TIMELABELS_MS`) — the x label form across a zoom, driven through
`apply_time_window()`: **under the figure's x link group the group owns the window, so a
row-level `set_time_window()` does nothing** (a probe that changes the window must use the
app's path). Absolute at 2 s and at 500 ms, `relative=1` below a second, and the caption
must be non-empty exactly then. The probe *races the paint* — it reads the axis on a timer,
and the ticks it sees are the previous layout's — so its `first_rel` (the first major minus
the axis' origin) is a fraction of a step; the dump runs after the last paint and is the
race-free reading. Either way the assertion that matters is the *sum*: `first_value` must
equal `origin + first_rel` as a *tick label* plus its caption, i.e. the labels and the
caption must share one anchor. `x_origin_set` is read back out of the plot; it must be 0 in
the absolute form.
- `[fit-probe] <action> window=.. x=[..] span=.. y=[..]` (stderr, `UDPSCOPE_PROBE_FIT_MS`) —
the two fits, through the same `RowSet` calls the right-click menu wires. It first puts
the y scale and the window where the data is *not* (a fixed `[-100, 100]`, a 50 ms
window), so the assertions have something to be wrong about: **`fit x` must widen the
window to the data's span and leave `y` exactly as it was**, and **`fit all` must then
move `y` to the data's own range**. A `y` that changed on the `fit x` line is the two
paths aliased.
- `[layout-probe] <action> rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]`
(stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is
up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the
buttons exist only while the editor does. `done` follows the same rule for the editor's
own exit button (1 up, 0 off), and its `done_box` on the `placed` line must sit in the
area's top-right corner: `x + W` within a few pixels of the area width and `y` within a
few of 0 (the on-switch line reports a box from before the first frame, so read the
`placed` one). The last probe step leaves the editor by **clicking that button** — the
toolbar toggle is still exercised by the first step — so `buttons=0 done=0` with the
rows and traces untouched is the assertion that the way out works. **`traces=` is the real assertion**: an insert
or a remove moves the rows' *contents* with them, so `add above 0` must read
`[0:-|1:sine,…]` — row 0's signals now on row 1, not renumbered onto the new empty row —
and `remove 0` must pull them back to `0:`. A `traces=` where the signals stayed on row 0
means `carry_row()` was skipped and the rows were renumbered under the user.
Geometrically, each `close=` must equal its row's top from `rows_y` plus the pad, and
`add=` must be centred on the boundaries (so strictly increasing, the first at 0 and the
last clamped to the area's bottom). The `rows_y` spans must tile: a gap other than
`kRowGap` means the figure and the overlay disagree about where a row is, which is how a
`×` ends up on the wrong row. Also read `x-desync 0` in the dump — every row the editor
adds has to join the one x link group. Run it at `--plots 8` as well: the cap and the
floor are the two refusals, and because the probe emits `::clicked` straight at the
widget it reaches the handler even when the button is insensitive, so `add above 0` there
must leave `rows=` and `traces=` untouched rather than growing a ninth row.
- `header: window_button_w=W label_w=L bar_w=B` (stderr, once a second) — **`W` must be
a small constant and must not track `B`**: the window control is a header-bar child,
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.
- `app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)` (in the exit dump,
beside `toolbar icons:`) — the application icon, named and present at the size its own root
`<svg>` element declares. **Both entries must read `ok` at those sizes.** `ok` is
`gtk_icon_theme_has_icon()` *and* the resource bytes being found under the registered path, so a
`MISSING` is a resource path that was never registered or a file that was renamed, and a size
other than 128 or 16 is a canvas that drifted off the HIG's. **Read it together with
`toolbar icons:`, and trust that line further for the registration**: the resource-path
registration is one call in `run_ui()` serving both the window's icon and the toolbar's buttons,
so a `MISSING` on either line is the same defect — but on a machine where
`--target desktop-entry` has been run, *this* line's `has_icon` half is also satisfied by the
**installed** copy under `$XDG_DATA_HOME/icons/hicolor`, so a broken registration still reads
`ok` here. That was measured: with the resource path deliberately misspelt, `toolbar icons:`
reported four `MISSING` (nothing installs the action icons) while `app icons:` stayed `ok`
until the two installed SVGs were moved aside, whereupon it read `MISSING 128x128` /
`MISSING 16x16`. The line is still worth its place — the *present* half (the resource bytes) and
the declared sizes are read from the binary and nothing else can satisfy them — but a developer
machine is the one where it is least sensitive.
**It deliberately does not claim the icon renders**, because no runtime GTK call can:
`gtk_icon_theme_lookup_icon()` never returns `NULL`, its paintable's intrinsic size is the size
it was *asked* for, and a valid icon, a malformed one and a name that does not exist at all all
snapshot to the same `GSK_TEXTURE_NODE` of the requested bounds — GTK substitutes
`image-missing`. A malformed SVG is a *build* error instead; see the icon-authoring rules.
- `[sources] N known: HOST:PORT plots=P signals=S [multicast=G] [interface=I] | ...` (stderr,
`UDPSCOPE_PROBE_SOURCES=1`) — the startup chooser's discovery, printed and exited **before
`gtk_init()`**, so this one needs no display and no `xvfb-run`. **Only sources with a named
signal may appear**: a development machine accumulates one session file per probe run on a
throwaway port (140 of them when this was written), and the filter in `known_sources()` is
what keeps the list the sources a user configured. Order is newest first by file mtime.
- `[sources] picked HOST:PORT multicast=G interface=I` (stderr, `UDPSCOPE_PROBE_PICK_MS`) —
shows the chooser, then activates row 0 the way a click does and continues into the real
application, so a full run with **no `--server`** must reach the scope and emit every other
dump line unchanged. `multicast=`/`interface=` coming back non-empty from a stored row is the
assertion that a multicast session reconnects to its group, and it needs **no multicast
networking**: write the session file by hand and read the line. With
`UDPSCOPE_PROBE_NEWHOST=host:port` it drives the form instead — `[sources] form open,
connect_sensitive=1` then a `picked` line, with no SIGSEGV, which is as much a crash test of a
dialog built and destroyed as it is a state check. An empty list must print `picked nothing:
the list is empty` rather than dereferencing a null row.
- **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
first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0
-> channel ... samples=N` line). Delete the file before a run whose layout you did not
mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise).
- `[udpscope] starved: connected (config=D), reconnects=N, no new frames in the last
second ...` (stderr, once per second while the state holds, plus a status-line notice) —
"connected but nothing is arriving after a reconnect", which over unicast is the second
instance taking the source's single session. Two ways to see it without a second client:
point the app at a UDP port where nothing answers (the client CONNECTs, times out,
reconnects; `config=0`), and check it does **not** fire against a live source. The status
line carries ` | N reconnects` only when N > 0, which is the same counter without the
prose.
- **The export's `time`/`stream_time` columns must be strictly increasing and evenly
spaced.** Check the written file, not just the probe: `np.diff(t)` with no `<= 0` and
`rows / (t[-1] - t[0])` equal to the stored rate (the `ch` line's figure). Duplicate or
backwards stamps mean the recorded timeline holds two clocks or a collapsed cadence —
the two ways this has broken: a per-frame choice of time source (now sticky, see the
README) and a per-sample step derived from the client's `lost` counter (now the
stream's measured arrival cadence). A `rows=` count far below the span times the rate
is the other signature, that of a table cut from the LOD pyramid.
- `badge frames: lit=L dim=D idle=I` — the activity badge's phase per frame. Over a run
that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it
blinks" means, and a badge stuck lit is indistinguishable from a working one in a
screenshot. One or two `idle` frames at the start are the frames before the socket
connected; a large `idle` is correct only when the run paused or disarmed (a
`UDPSCOPE_PROBE_DISARM_MS` run should show hundreds). The `mode ...` lines carry the
rest of the status line, so `rate` and `last event ... ago` are checked there.
- `frame cost: tick A/B ms over N, draw C/D ms (max at frame F), paint E/G ms over M` and
`frame path: raw=R lod=L frames, segments S/T (mean/max), visible max V samples` — the
drawing cost, split into the frame tick, `PlotArea`'s own draw (timed from the frame
clock's `before-paint` to the end of the host's `render` handler) and the whole GTK
paint. **`segments` max must stay in the tens of thousands and `lod` must dominate `raw`
on any run holding a large capture**: a max in the *millions* with `lod=0` is the
empty-pyramid defect (see the `MinMaxPyramid` invariant below) — the renderer drawing
every raw sample of a held event on every frame, which measured 6,000,073 segments and
100–230 ms a frame at 1 MS/s. `raw` frames are expected and cheap below 64 samples per
pixel, which is where `level_for()`'s own gate puts the boundary. **`draw`'s max must sit
at a frame near 0** — that is the warm-up (glyph atlas, first vertex-buffer growth); a
late `F` is a frame whose own geometry was expensive and worth tracing. `tick`'s max is
the publish stall: `publish_event()` builds the snapshot's pyramids under `mutex()`, so a
latch at over 1 MS/s costs one ~40 ms tick, which must not produce `gaps`. Both these
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.
- `[help] pages=14 parsed=14 shortest='Overview' 1379 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
- `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires).
- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set, the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the only field trigger mode adds is the elapsed time, which steps units rather than growing digits. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it.
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.
## Architecture
`SPECS.md` is the requirement document. `README.md` documents what was actually built,
including deliberate deviations from the spec and known limitations — read it before
concluding something is a bug.
### Threads
Exactly two: a producer thread in `Acquisition` owns `udps_client_t` and calls
`udps_client_poll()`; the GTK main thread owns every widget and GL context. They share
`ScopeModel` under `ScopeModel::mutex()`, taken once per DATA frame by the producer and
once per frame by the UI (`tick()` in `src/ui.cpp`) around `RowSet::bind()`/`acquire()`.
Samples themselves cross via `plot::data::RingBuffer` (single producer, single
consumer) and never through the lock. Cross-thread scalars are atomics on `ScopeModel`
(`trigger_enabled_`, `trigger_armed_`, `trigger_latches_`, `trigger_time_`,
`event_`, `generation_`). Two of them are a pair: the producer writes `capture_edge_`
before `trigger_pending_` and those two carry release/acquire, so a consumer that sees a
capture open sees which edge it belongs to.
### Data path
`ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one
value ring per channel, sized by `plan_partition()` to cover `--max-window` seconds
within the byte budget. The producer always writes it, whatever the trigger is doing —
that is what guarantees a full pre-trigger history on the *first* latch after arming.
The spec's second buffer (item 1) is the latched `EventSnapshot`, which is already an
immutable copy of the captured window; see README for why two live rings were dropped.
What a trace binds to is `ScopeModel::display_mode()`, one of four states, and
`RowSet::bind()` takes it as an argument rather than recomputing it:
- `Live` — no trigger: the live ring on the stream clock.
- `Capturing` — an edge has been accepted (so the pre window was full) and the post
window is still filling: the live ring, on the coming event's own clock
(`display_epoch() == -edge`), inside an axis already pinned to `[-pre, +post]`. The
post side fills in view and the marker sits at x = 0 throughout.
- `Held` — the frozen `EventSnapshot` of the last event.
- `Waiting` — armed, nothing captured yet: blank.
**Disarm during a capture publishes what the capture got to**, then disarms: the producer
cuts the window at the newest sample and that partial snapshot becomes the held event, so
the picture on screen at the moment of the Disarm stays on screen. `engine.disarm()` clears
`pending_`, which drops the display out of `Capturing`, and with nothing published the view
fell back to the *previous* event — or to blank on a first capture. No event fired, so
`trigger_latches()` is deliberately not bumped; `ScopeModel::event_seq()` counts published
snapshots instead, and the frame tick keys its event framing on *that*. Keep the two
distinct: the latch count is what the probes assert "how many events fired" with.
`Capturing` is a **deliberate departure from SPECS:37** ("data visualisation should not
be refreshed till next event"), asked for explicitly; that reading is what the old "never
the live ring mid-capture" rule enforced. What survives of the rule is that `Held` binds
the snapshot *only* — a held event is never quietly replaced by live data — and that the
*axis* does not move during a capture: it is the event's window from the edge onwards,
never a sliding live view.
`display_mode()` must be read **once per frame** and passed around (the tick does this,
and hands it to `bind()`). The producer can open or close a capture between any two
reads, and two reads in one frame put the bind, the epoch, the pin and the status line on
different clocks.
Layers to keep straight:
- `SignalInfo` — one signal as the CONFIG described it. The unit of enable/disable.
- `ChannelInfo` / `ChannelBuffer` — the unit of acquisition and plotting. A
`ChannelBuffer` is a `plot::data::Series` over a (value, time) ring pair.
- `SlotSeries` (`src/channel.hpp`) — a trace's stable series object, rebound each frame
to either a `ChannelBuffer` or an `EventSeries`.
- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to `kMaxRows` rows (added
and removed one at a time by the layout editor), each a
borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots. The figure owns
everything the rows have to agree on: the x window (one x link group every row joins),
the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since
`Figure::link_x()` refuses a `Live` member), the left gutter and the crosshair.
- `ExportTable` (`src/export.hpp`) — the visible window as columns, collected from the
snapshot or the live rings and handed to the `.npy`/`.csv` writers. Always the stored
samples, never the LOD pyramid's buckets.
`src/ui.cpp` is the GTK4 window (toolbar, trigger panel, signal panel, plot box) plus
the `tick()` frame function that binds, updates the trigger state and rewrites the
status line. One `plot::gtk4::PlotArea` in figure mode draws the whole figure. It is by
far the largest file.
### Invariants worth knowing before editing
- **`plot::Plot` cannot remove or retarget a trace.** Hence fixed trace slots created
up front and rebinding via `SlotSeries`; a *name* change (legend text) is the one
thing that forces a rebuild. Cursors are parked outside the axis range instead of
removed.
- **A `Figure`'s row count is fixed at construction**, so `RowSet::rebuild_all()` builds
a *new* figure and the area is handed it (`PlotArea::set_figure()`); it costs no GL
state. That makes one path for "a row's traces changed" and "the row count changed",
and it is why `rebuild_all()` restates a pinned event window (`pinned_pre_`/
`pinned_post_`) instead of re-framing on the live edge — a drop during trigger mode
would otherwise throw the frozen view onto the stream, where nothing moves it off
again until the next latch. `PlotArea::create()` is handed `App::placeholder_plot`,
never a row: the area keeps a reference to the plot it was created with, and a rebuild
destroys every row.
- **The cursors are read back from the plots only while a gesture is on that row.** `Cursor::x`
is the *free* pointer position while a drag runs and the snapped value otherwise -- which is
what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on
every frame therefore adopts its own echo back, and -- because the vertical pair is one value
shared by every row -- a row that has not been re-written yet hands the *previous* shared
time straight back. That is the "drag it and it springs back" defect, and the fix is
`RowSet::sync_cursors(int drag_row)`: the tick passes the row whose controller reports
`grabbed_cursor() != kInvalid`, and every other frame does nothing. The values are echoed
back with `CursorPlacement::Free` for the same reason -- the controller snaps at the end of
the gesture (`Controller::end_drag()`), and snapping the echo fights the hand that is still
moving the cursor.
- **The cursors' on/off is one flag on the `RowSet`, never a copy per row.** It is one
piece of state (the toolbar toggle) and the mode is one value; a per-row copy is something
`insert_row()`'s `clear_row()`, or any rebuild, can clear behind the user's back, which is
how a row added while the cursors were already on came up with its instruments parked
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
than renumbering the rows under the user. `carry_row()` deliberately copies fields
instead of assigning a whole `Row`: a `Row` also holds the figure handles (`plot`, the
axis and cursor ids) and the `SlotSeries` objects the traces point at, and those belong
to the row's *position*. It clears the destination's slot bindings for the same reason —
the next `bind()` re-resolves them, and without the clear the frame in between draws the
moved row's samples under the new row's legend.
Both call `rebuild_all()` themselves, so their callers must re-point the area
(`PlotArea::set_figure()`) directly; `relayout_areas()` is no help, since it only acts on
a *dirty* row.
- **The layout editor's buttons are *separate* overlay children, never one transparent
child covering the area.** A `GtkOverlay` child is picked wherever its allocation is, and
`gtk_widget_set_can_target(FALSE)` takes a widget's children out of picking with it, so a
full-area `GtkFixed` would swallow every pan, drop and hover the editor is not interested
in. Each button is therefore a small widget aligned to a corner or the centre and placed
by its **margins**, and the overlay is only measured from its main child, so moving them
cannot reach the plot's layout.
They are placed from `FigureLayout::rows` — the rects the frame was *drawn* from —
divided by `PlotArea::device_scale()`, because a `Rect` is framebuffer pixels and a GTK
margin is logical ones. Reading the figure's own rects rather than dividing the widget
height by the row count is what keeps a `×` on the row it names when the rows stop being
an equal division. `sync_layout_editor()` runs every frame on purpose:
`gtk_widget_set_margin_*()` returns early on an unchanged value, so a settled layout costs
the comparisons and nothing else, and a resize needs no notification.
- **Trace slots are identified by signal name, not channel index.** Enabling a signal
or a new CONFIG renumbers the channel list, so an index would silently re-point a
trace at another signal.
- **Any structural change rebuilds the buffer and drops history**: a CONFIG, an
enable/disable, a calibration change, or a **change of display window that moves the
decimation factor**. Calibration is applied at ingest, which is why it re-partitions.
`ScopeModel::set_target_span()` is the window's path in, and it re-plans *only* when
`time_stride` actually changes, so walking the window inside one decade is free; it asks
`plan_partition()` rather than recomputing the factor, because two expressions for that
decision is how the buffer and the view end up disagreeing about the stored rate. It
takes `mutex()` itself — do not call it holding the lock.
- **The shared clock is decimated to fit the requested window; channels never are.** A
window too long to store at the wire rate used to be refused (the control was capped to
`Partition::span_s`), which made a 600 s view of a 1 MS/s stream unreachable.
`plan_partition()` now picks `time_stride`, a power of two, **after** the growth loop has
bought all the ring the budget allows — deciding it earlier over-decimates by whatever
factor growth was about to add. Per-channel `stride[i]` stays 1: a stride of
`round(dt_i / dt_fastest)` keeps one sample per stride, and when that equals the signal's
own period — a 2 kHz sine on a 1 MHz clock, stride 500 — every kept sample sits at the
same phase and the channel plots flat.
- **Decimation at ingest is an average, never a stride.** `Acquisition::on_data()` reads
and accumulates *every* sample and pushes the mean of each group of `time_stride`
(`value_acc_`/`value_acc_n_`, and `time_acc_` for the clock). That boxcar's first null is
at the stored rate, so content that would fold back is attenuated instead of reappearing
as a false low frequency — which is the whole reason decimation was once dropped
entirely. Skipping the read for unstored samples re-introduces the aliasing; the
`UDPSCOPE_PROBE_WINDOW_MS` run under `--freq 2000` is the guard (see below).
The stamp pushed is the group's **mean**, not its last member: that is where the boxcar's
group delay puts the value, and stamping at the end of the group shifts every trace half
a stored sample late. The accumulators are reset whenever `time_stride` or the channel
count changes, so a re-plan starts a fresh group rather than finishing one at the wrong
length. The LOD min/max pyramid still preserves the extremes *of what is stored*; a spike
narrower than a group is now filtered away rather than aliased, which is the correct
trade and is why the status line's `rate` field is worth reading.
- **The trigger sees the stored (filtered) signal, not the wire signal.** `engine.process()`
is fed the averaged value, inside the `if` that pushes it. An edge found on a sample that
is not in the buffer would be cut from data that does not contain it.
- **The renderer maps the visible range onto indices through the series' own x, never the
`dx()` grid.** `deps/plotter`'s `Renderer::draw_traces` asks `Series::neighbours()` for the
bracketing samples. A grid walk is wrong for exactly the series this app plots: `dx()` here is
a nominal spacing (the LOD level, a coarse snap) while the real stamps are the time ring, so
the window it computes lands inside the visible range and the two end segments — the ones that
draw into the left and right margins — are never emitted. That is the "empty margin when
zoomed in" defect. The library test is
`a_sparse_window_between_samples_still_inks_both_margins` (a stub whose `dx()` lies, with the
window strictly between two samples). It is the guard, not the probe: `[trig-probe/idx]` now
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.
Nothing may walk that grid to find *which* samples a range covers — the renderer did,
and it lost the two end segments (see the margin invariant above). `gather()` emits x
relative to `x0()` because absolute epoch seconds do not survive `f32`.
- **`gather()` emits x relative to `x0()` in *every* series here**, `EventSeries`
included, and the renderer adds `x0()` back. An event's stamps are small (edge
relative, `[-pre, +post]`), so emitting them absolute does not overflow anything and
does not look like a bug — it just draws the whole capture `x0()` = one pre-trigger
window to the left of the axis. That was the "half the plot is empty / the marker sits
at the end of the data / nothing is drawn but the crosshair reads values" defect: the
crosshair reads `x_at()`, which was right all along. The probe's `data=` vs `drawn=`
is the guard.
- **Every series bound to a trace must carry a real `MinMaxPyramid`**, including an
immutable one. `MinMaxPyramid::level_for()` descends to level 0 — raw samples — on a
pyramid with no levels, whatever `n` is, so a series that answers `pyramid()` with an
empty one is re-gathered sample by sample on every frame it is bound. That was the
"large data is laggy" defect: `EventSeries` had no pyramid, and a held 2 s capture at
1 MS/s staged 6 million segments per frame for as long as it stayed on screen. The
pyramid lives on `EventSnapshot`, not on `EventSeries`: the snapshot is immutable, so it
is built once in `publish_event()` inside the `O(n)` pass that already computes the y
bounds, and the same channel on two rows shares it. It costs ≈1 byte per sample per
channel. `frame path:`'s `segments` is the guard.
- **`EventSeries::gather()` emits a bucket's envelope, not every `2^level`-th sample.**
Point decimation hides exactly the spike the capture was triggered on; a scope has to
draw a bucket's extremes. It needs no index rebasing, unlike the ring: the snapshot's
pyramid is built from sample 0 and nothing is ever dropped from it, so `window_base()` is
0 and a window index *is* the absolute one.
- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins
the published window to the value ring's base and both rings keep sliding afterwards —
their capacities are equal, so there is no slack — which means the oldest few samples
of a saturated window routinely point at time slots the producer has already
overwritten. `ChannelBuffer::time_of()` therefore clamps to the oldest *readable*
slot: a stamp of `0` would reach `x_bounds()`, and from there `dx()` and `x0()`, which
is the renderer's whole index mapping — one unreadable slot inflated `dx()` by the
ratio of elapsed stream time to the retained window and clipped the older part of
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
states, capped by `--max-window` — is the bound. Clamping to `retained` alone turned a
requested 2 s window into the ~0.02 s the first frames held, permanently: nothing there
grows a span back. Loosening it to `max(retained, window_s_)` fixed that and broke the
other state instead — while the ring is filling the two are equal, but once it holds
more than the window the bound becomes the whole ring, and a wheel zoom-out walked the
view into data the window was never set to show. `window_s_` is right in both: it never
shrinks a window a caller asked for, and a partly filled buffer is left empty on its
left, which is what a scope that just started should look like. `UDPSCOPE_PROBE_PAN_MS`
reports it as `zoom ... span=.. window=.. limited=1`.
Its pan clamping is likewise gated on the data being wider than the view, or the empty
left-hand side drags the live edge off the right-hand edge.
- **Layout must not depend on content.** The y gutter is reserved at the widest label
`TickFormat::Si` can produce and only ever grows; the status label is ellipsized so
its text cannot drive the paned divider. Both exist to stop per-frame relayout.
The same rule is why the activity badge is one glyph in three colours rather than
three glyphs, why the `rate` field collapses to one figure or a range instead of a
per-channel list, and why `format_elapsed()` steps to a coarser unit instead of
letting the counter grow: each is a status field whose *width* would otherwise depend
on the stream, and the status row is directly above the plot.
The reservation goes on the **figure** (`Figure::set_min_y_gutter()`, added to the
plotter for this), not on the rows: `Figure::layout()` clears every row's
`set_min_y_gutter()` to measure what its own labels need, so a floor written onto the
rows survives exactly until the next layout.
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
signal nobody acquires can never fire, so selecting/arming enables it.
- **The trigger's event window is separate from the display window**, and
`pretrigger_pct` is a share of the *event* span, not of the ring.
- **The event window is counted in stream seconds end to end; nothing converts it to
a sample count.** `TriggerEngine` latches once the stamps it is fed have advanced
past the post window, and `ScopeModel::publish_event()` cuts the snapshot by binary
search over the time ring. There used to be a measured samples-per-second estimate
in `Acquisition` doing that conversion, and it is precisely why the post window came
out empty: any stream whose time signal advances in large steps drove it to its
1 S/s floor, the count came out as 1, and the engine latched on the edge sample
itself. **Do not reintroduce a rate estimate on this path.**
- **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
While an event is held, a capture is filling, or the view is paused, `tick()` calls
`RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and
clamps the position into it (and states `Off`, since a following group would re-derive from a
live edge). **A zoom in must keep working** -- "not outside the event" is not "not readable",
and this function's first version *re-stated* the pin every frame, which made the wheel a no-op
in single trigger mode: an acquired event could not be examined at all.
**The request is read from the row's own axis, not from the group.** A member's `set_x_range`
-- what the controller writes for a gesture -- is *not* adopted into the group while the follow
is off, so a clamp that read the group only ever saw its own previous answer (the probe showed
`group` and `member` both equal to the pin while the ask was gone). `pinned_stated_` remembers
what the clamp last wrote: a member that matches it is our own answer coming back, one that
differs is a gesture to honour -- which is also what keeps a *fresh* pin
(`pin_event_window()`, `pin_pause_window()`, both of which write the group in the same tick)
from being reverted to the member's stale window. Without it nothing bounds a drag or
a zoom in those states: the library clamps a *following* group's offset only, and the live
clamp is deliberately skipped here — so a gesture walked the frozen view off its axis, over
data the scope never had. The two are one branch in `tick()` for that reason. A paused view
pins the same way (`pin_pause_window()` sets `pinned_` from the frozen range), so the frozen
picture is equally immovable; resuming or leaving trigger mode clears it.
- **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the
one that was closed: the window, the rows and their slot signals, the calibration, **the enabled
signals**, **the math channels with their graphs** and **the measurements pane**. Signal identity
is the *name* throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the
model built -- an enabled signal is exactly one that produced a channel -- and `apply_layout()`
installs the graphs **before** assigning the rows, since a row may hold a math channel's name and
that name is only a signal once its graph is in. Two format rules, both learned the hard way:
write an op or a measure *type* as its index through `math_op_at()`/`measure_type_at()`, never as
the raw enum value -- those accessors' orders are the dialog's, not the declaration order, so
writing one and reading the other silently renames operations. And a top-level key written after
a `[[table]]` belongs to *that table* in TOML: `enabled = [...]` emitted after `[[calibration]]`
was parsed into the last calibration record and the enable state was lost without a word.
`mkdirs()` used to create only components with a trailing slash, so `~/.config/udpscope` was never
made and the *first* save failed silently -- worth knowing when a layout change "does not stick".
- **A math channel *is* a channel.** `ScopeModel::set_math_channels()` appends one *synthetic*
`SignalInfo` (with `math = true`) per math channel after the stream's own, so the panel's switch,
the plot binding, the measurements and the export all handle a derived channel without knowing it
is derived -- and another math channel is a valid `Source` for exactly the same reason. The
*samples* come from the producer: `Acquisition::on_data()` collects each channel's value for the
slot and calls `ScopeModel::push_math_sample()` **once per stored slot**, before the deferred
latch (`publish_event()` clamps its snapshot to every ring's `produced()`, so a derived sample
pushed later would be missing from the capture's newest slot). The math therefore runs on the
*stored* (filtered, decimated) samples, which is also why `math_prepare()` gets `slot_dt() *
time_stride`: a cutoff the user typed in Hz means the rate the filter will actually run at.
`src/math_channel.cpp` is the engine: a graph evaluated as a **list walked in order**, so a node
may only read ones *before* it and a cycle is impossible by construction rather than by a check
(`math_channel_valid()` states that as a message the dialog shows). `/tmp/check_math.cpp`-style
checks verified the biquads against known responses (LP fc=100 Hz: 50 Hz -> 0.97, 2 kHz -> 0.002;
BP fc=1 kHz q=2: 200 Hz -> 0.05, 1 kHz -> 0.95, 4 kHz -> 0.05) -- **keep test sines below
Nyquist**: at `dt = 1/10000` a "5 kHz" test tone is the Nyquist point itself and says nothing.
The editor is the *compact graph*: nodes as boxes on a `GtkDrawingArea`, click to select (its
parameters in the form beside it), click an output port then an input port to wire, drag to move;
no pan/zoom. Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`,
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. 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
acquired, and a graph sourced from it validates and then reads NaN for ever. The dialog's
default was fixed first and the pane's `+` was not, which is what "[ch] -> [gain x2] produces
only nan" was. The dropdown also *reconciles*: a node whose source is not in the list is moved
to what the form is showing, rather than the form showing one channel while the node holds
another, and `Apply` refuses a source that is not acquired (the engine's validator is about the
graph -- an output, every input wired above it -- and cannot see the acquired list).
* **The ingest must skip a derived channel.** `Acquisition::on_data()` iterates every channel and
reads each one from the packet, so a math channel with no signal read *zero* and pushed it --
while `push_math_sample()` pushed the real value on the same slot. Two pushes per slot into a
ring whose time ring advances once: the values alternated between the real one and 0, and once
both rings wrapped the value ring had drifted a whole window off the time ring, so the derived
trace disappeared. `ChannelInfo::math` is the flag (`channel.math`) and the loop `continue`s on
it. **A derived channel's `values(c).produced()` must equal `live().time().produced()`** -- one
push per stored slot -- and the dump prints both as `N samples vs M time slots` for exactly
this: the arithmetic on the plot can look plausible long after the pairing is wrong.
* **`gtk_window_destroy()` does not emit `close-request`.** The math dialog's pointer is cleared
by a `close-request` handler, so a Cancel that *destroyed* the window left `app.math_dialog`
pointing at a dead widget: every later `open_math_dialog()` took the "already open" branch and
presented it -- a GTK critical, and nothing on screen, which is "I cannot edit a math channel
again". Both paths close (`gtk_window_close()`), which emits the signal, lets the handler clear
the pointer, and then destroys. The same lesson as the other GTK traps here: a programmatic
destroy is not a user close, and only one of them runs the handlers.
* **Boxes must not overlap on the canvas.** The first auto-place staggered nodes by 24 px for a
132 px box, so every box sat on the one before it, `math_node_at()`'s topmost-first search always
returned the last, and a drag or a click landed on the wrong node -- which reads as the dialog
ignoring the pointer. Two columns spaced 160 px apart, rows 58. The probe prints the box the
drag *began over* for exactly this reason. Offering the whole signal list let a graph name something nobody is
acquiring (no data for ever) and put the stream's timestamp signal, which has no samples, in it.
* **`prepare_math_locked()` runs from `rebuild_locked()`, so *every* plan change re-prepares the
filters**: a CONFIG, an enable/disable, and a window change that moves the decimation (that path
is `set_target_span()` -> `rebuild_locked()`). Coefficients are a function of the *stored*
interval and the filter's state is the old rate's history, so both are rebuilt together.
Preparing only on an edit left the cutoff wrong and the state ringing after any window change --
that is what "I tested a low pass and it was a mess" was.
* **The graph's output defaults to the *last* node, and a node added to a linear graph becomes
it.** The output began at index 0 -- the *source* -- so every operation a user built was
computed and then thrown away: a gain of 2 and a low pass both produced a channel equal to its
input. The output is drawn as the orange dot and settable explicitly, so an earlier node chosen
deliberately is kept; this is the default for a graph that has never been given a choice.
* **`gtk_spin_button_new_with_range()` can leave the value at the range's *minimum*.** The offset
field began at -1e6, and `on_math_param_changed()` reads the widgets, so `x*2` saved as
`x*2 - 2000000`. Every numeric field is seeded with its own identity value; the range-minimum
default is never a useful starting point for a gain or a corner frequency.
* **The form's rows are grouped per *op*, not per field**: gain and offset are one group because
`gain + offset` is one op. The offset was a row of its own that nothing ever hid, which read as
a property of every node.
* **A `GtkSpinButton` holds zero decimals by default**, so every numeric field sets its digits
(4 for gain/offset/clamp, 3 for `fc`/`Q`); without that a 12.5 Hz cutoff was stored as 12 and a
gain of 0.25 as 0. And the canvas drag reports the *total* offset from where the gesture began,
so adding each update's delta to a value the handler then writes back is a runaway box -- it
must record the position at `drag-begin` and ignore a drag that did not begin on a box.
- **Every instrument this app *computed* is out of the pointer's reach.** The trigger marker and
the measurement marks are cursors created with `Plot::add_cursor()`/`add_h_ruler()`, and a cursor
is grabbable by default -- so each takes `set_cursor_selectable(id, false)`. **Parking it outside
the axis is not enough**: the controller holds a marker for the gesture's life and follows the
pointer between frames, so a readout parked at `kParked` could still be dragged. The trigger
marker says where the scope put the edge and a measurement mark says what a measurement measured;
neither is something a user may move. The dump prints `readout instruments locked: N`, which must
be 1 -- a drag is not something a probe can perform, so the state is what is asserted.
- **A pinned view is navigable *within its data*, and that is the bound -- not the request.**
Two things had to agree for this, and both were wrong in the same direction:
the **pause** pinned on the window it asked for rather than on the snapshot it got (a cut
clamped to the ring is shorter), and the **held event** pinned on the snapshot's recorded
`pre_s`/`post_s` -- the *request* again -- which a capture shorter than the request exceeds.
Either way the shortfall was drawn as an empty margin, and a zoom-in moved the view *into* it,
which is what made it look as if zooming caused it. `tick()` now re-states the pin from
`RowSet::event_data_window()` (the bound **event series'** own stamps) while an event is held,
from the snapshot's stamps on a pause, and `pin_pause_window()`/the pause handler set the axis
to it as well; `[pause-probe]` prints `axis=` beside `frozen_x=` and the axis must lie inside.
A `Capturing` view is the exception and stays on the *requested* `[-pre, +post]`: its post side
is empty on purpose, and that is the capture filling in view, not a margin.
- **A pause is pinned from its snapshot's own stamps too, not from the window it requested.** The
pause freezes the *visible window*, but `publish_pause_view()` cuts that window out of the ring
and the cut is clamped to what the ring holds -- so a window reaching back past the oldest
retained sample produces a *shorter* snapshot. Pinning the axis to the request left the shortfall
as an **empty margin on the left**, visible before any zoom and made worse by one: zooming in
moved the view *into* the empty part, which is what made it look as if zooming caused it. The pin
is the snapshot's own stamps now. `[pause-probe]` prints `axis=` beside `frozen_x=`, and while
paused **the axis must lie inside `frozen_x`** -- equal at the moment of the pause, a subset
afterwards if the probe zoomed in. Before the fix the two disagreed outright:
`axis=[-1.44..0.56]` over data that started at `0.0`. (The event pin already worked this way; the
pause now agrees.)
- **The event axis is pinned from the snapshot's own stamps, not from the request.** A
capture may legitimately be shorter than asked for — the ring need not reach back a
full pre-trigger share on the first latch after arming, so `snap=[-0.339..1.000]`
for a ±1 s request is correct there rather than a defect.
- **The frame that *frames* a snapshot is the one that consumes its `event_seq()`.** The
tick reads `display_mode()` *before* it takes `mutex()`, while the producer publishes
inside that mutex and lowers `trigger_pending` only at the end of its packet — so a tick
can read `Capturing`, block on the lock through `publish_event()`, and reach the framing
step with a new sequence number it is not bound to. (The other case is benign and was
always handled: a publication and the *next* edge in one frame, where the capture has
framed its own window.) Advancing `seen_event_seq` on such a frame dropped the pin
permanently — the axis kept the capture's requested `[-pre, +post]` over a shorter
snapshot and `event_held` stayed false, letting `clamp_to_data()` drag the frozen view —
so the test is `seq != seen_event_seq && display == Held` and the pin stays owed until a
frame is bound to the snapshot. Visible only at high rates, where the publish is long
enough to lose the race often; `[disarm-probe]`'s `axis=` against `snap=` is the guard,
which means **running the disarm probe at 10 kS/s alone does not check this.**
- **A capture in progress is the one place the axis is pinned from the *request*.** There
is no snapshot to measure yet, so `DisplayMode::Capturing` frames
`[-(span - post_span_s(span)), +post_span_s(span)]` — computed with the producer's own
`TriggerConfig::post_span_s()`, not a second expression that rounds differently, because
the marker at x = 0 has to land where the coming cut will put it. The latch then re-pins
from the snapshot, and the two agreeing is why the picture does not jump at the latch.
- **`clamp_to_data()` must be skipped while `capturing` as well as while `event_held`.**
A capture is live data on a pinned axis — the exact combination the clamp was written
for and against: it would follow the live edge and drag the frozen window off the event.
- **`publish_event()` cuts one absolute index range and uses it for the time ring and
every value ring.** That is what makes `stamps` and each `values[c]` equal-length
and index-aligned by construction instead of by two truncations that can disagree —
`EventSeries::gather()` indexes both at the same `i`.
- **`live_window_locked()` is the one live cut, shared by `publish_event()` and the
export.** Both need the same absolute index range over the time ring and every value
ring, or an export and a capture of the same window would hold different samples. Call
it under `mutex()`.
- **A trace's colour is indexed by *signal*, not by trace slot.** `Row::color_index`
stores it and `signal_color_index()` (ui.cpp) computes it from the signal list, so a
signal dropped on two rows is one colour and the signal panel's chip names a trace
unambiguously. `plot::Plot` fixes a colour at `add_trace()`, so changing one needs a
figure rebuild — which `assign()` already forces. A signal on no plot gets
`RowSet::unplotted_color()` (grey) rather than no chip, so the panel's rows stay the
same height.
- **The export is collected at click time, not in the file-dialog callback.** The live
view keeps scrolling while the chooser is up, so a late collection would write a
window the user never saw. The writer is picked by the chosen path's extension.
- **The measurements pane is refreshed on a cadence, and its cost is the *window's*, not the
frame's.** A measurement is a statistic of the visible window and a live view moves that window
every frame, so "recompute when the window moves" is "recompute every frame" at O(visible
samples) *per measurement*. With the pane holding four measurements over a 4.7M-sample window
that measured **13.66 ms of tick mean** (baseline 0.41 ms) and cost the run a third of its
frames. Three things fixed it, and all three matter: `update_measures()` recomputes at most
every `kMeasureRefreshUs` (100 ms; 400 ms when a pass costs more than `kMeasureHeavyUs`), once
when the window settles and once per mode change -- and reading *that* backwards is what made
it worse first, 457 recomputes in one run, because a pinned window then recomputed every
frame; the window is materialized in bounded chunks (never two whole-window f32 temporaries)
and the loop holds it **by reference** (copying both vectors per measurement was 300 MB of
memcpy a frame); and `measure_stats()` is one shared pass carrying both extremes with their
first positions, the sum and the count, so average/min/max/peak-to-peak/amplitude need no
pass of their own. **Adding a type must not add a pass** -- that is what `compute_measure()`'s
`stats` argument is for.
The marks' *ink* is set where they are created (`install()` in `plot_host.cpp`): 0.75 px and
dashed 4/4 through `Plot::set_cursor_stroke()`, which is per cursor because the trigger marker
(also a cursor) keeps the solid `line_width_px` stroke. A new measure mark must take that
stroke -- it is created in the same loop, so it does; a new *instrument* should not.
The **marks** the pane draws (`set_measure_marks()` -- full-width `h_ruler`s and full-height
cursors, i.e. the "lines across the plot") are re-placed every frame from the result, so a
throttled pass is a *stepped* ruler: acceptable, because the ruler and the number beside it
come from the same pass and agree. What is *not* acceptable is a ruler that appears and
disappears, so an invalid pass falls back to the last valid result for its **level** marks and
only drops its **time** marks: a level is a property of the signal (a mean, a top, an
amplitude) and outlives the window it was measured in, while a time mark is a position *inside*
that window and may not. Both are emitted in **one pass after the numbers, never inside the
loop that computes them**: that loop has three `continue`s (an unresolvable signal, a channel
with no window) and each one was a way for the rulers to blink off and back. Fixing one and not
the others is how that bug survived a round of "fixes". `clamp fires: N` in the dump is the other half of this: `clamp_to_data()`
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
badge's markup is likewise rewritten on a change of phase (twice a second), never on
all sixty frames.
- **A `notify::<prop>` handler takes three parameters, a `value-changed` handler two.**
`GObject::notify` passes `(GObject*, GParamSpec*, gpointer)` while
`GtkSpinButton::value-changed` passes `(GtkSpinButton*, gpointer)`, so one callback cannot
serve both: on the `notify::` connection the `GParamSpec*` lands in the `user_data` slot
and the handler dereferences it as its `App*`. `G_CALLBACK` is a cast, so nothing warns.
That is why the trigger panel's spin buttons and its dropdowns have separate thin entry
points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one
`publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault
outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard.
- **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it
returns**, so the `updating_panels` flag — set around a programmatic write and cleared when
it returns — does *not* suppress it. Where the write is part of a readout the handler reads
back, that is a feedback loop: `sync_window_display()` writes a value rounded for reading
into the window entry and picks the unit for it, and the late notify had
`on_window_unit_changed()` re-parse that rounded text, turning a 500 s request into
499.98 s. The guard is `App::window_sync_unit` — the unit we last synced — and a selection
equal to it is ours, since GTK only notifies on a change. Any new control that both writes
and reads a `GtkDropDown` needs the same shape; the flag alone is not enough.
- **The window control's label is width-locked and ellipsized** (`kWindowLabelChars`), for
the same reason as the status label: it sits in the header bar above the plot, so a label
that grew from `1 s` to `123.456 ms` would widen the bar and relayout everything under it
on a mere change of window. That is also why the window is typed into a popover rather
than shown in the bar.
- **`apply_time_window()` is the only place the display window changes.** It clamps to
`window_limit()` and moves `RowSet::set_time_window()` and `ScopeModel::set_event_span()`
together, because the trigger's pre-trigger share is a share of *this* window. Paths that
change the span for their own reasons (Fit, an event pin) call `sync_window_display()`
instead, which only restates the control and never moves the plot — calling `apply_` there
would clamp a legitimately short capture up to the requested window.
- **The header bar's buttons are icon-only**, so headless probes must reach them
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
`find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`,
`measures_toggle`, `math_toggle` -- packed with `header_bar_pack_end()`, where the **first** packed
is the rightmost, and the bar is an *ancestor* rather than the parent because packing parents into
an internal box), and the cursors pane owns its own
**Reset** button rather than the overflow menu owning one for it. The dump prints
`panel controls: … ; toolbar, left to right: …` to say where they are and in what order. The widget
tree's child order *is* the visual order left to right, so that line reads as the *reverse* of the
pack order -- it is how an arrangement gets checked with no screenshot, and it is what finally
settled the pack-order rule above (two comments in `build_toolbar()` disagreed with each other and
one of them with reality): it is computed on the first *tick*, because the dump
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.
- **Six of this app's icons are its 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 the four action icons; beside them in `src/icons/scalable/apps/` are the two
**application** icons, `org.udpscope.app.svg` (128px colour artwork) and
`org.udpscope.app-symbolic.svg` (16px). The `apps` context is where an icon theme keeps an
application's own icon and is where the **in-process** window icon resolves from; it is also
the layout that `cmake/install_desktop_entry.cmake` mirrors when it copies the same two files
into `$XDG_DATA_HOME/icons/hicolor/scalable/apps` and `.../symbolic/apps` — those installed
copies are what makes `Icon=org.udpscope.app` in the desktop entry resolve (a desktop shell
cannot read a GResource compiled into the binary).
All six are bundled by `src/icons/udpscope.gresource.xml` (CMake runs
`glib-compile-resources`) and reached through **one**
`gtk_icon_theme_add_resource_path()` in `run_ui()`, right after `adw_init()` — not at the top
of `build_toolbar()`, where it used to be: there are two consumers now (the window's own icon,
set before the toolbar is built, and the toolbar's buttons) and `run_ui()` is the one place
ahead of both. `adw_init()` calls `gtk_init()`, which is what makes
`gdk_display_get_default()` valid there.
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. **In a symbolic icon everything is
therefore a filled `path`** — including anything that wants to look stroked, which is drawn as
a closed band (two offset curves joined) instead. The rule is scoped to symbolic icons on
purpose: `org.udpscope.app.svg` is 128px colour artwork that is never recoloured, so it strokes
its sine freely, which is the sane way to draw a smooth one. Do not "fix" one to match the
other.
* **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.
* **A malformed SVG is a build error, and that is the only place it can be caught.** Every
`<file>` in `src/icons/udpscope.gresource.xml` carries `preprocess="xml-stripblanks"`, which
makes `glib-compile-resources` pipe it through `xmllint` — so `--` inside an XML comment, which
is illegal and which librsvg refuses the whole file for, fails the build with a file, a line
and a caret under the offending character (`…app-symbolic.svg:5: parser error : Double hyphen
within comment`). It has to be here because **no runtime check can see it**: librsvg refuses
the file but the icon *name* still resolves, so `gtk_icon_theme_has_icon()` returns true and
the icon draws nothing. `gtk_icon_theme_lookup_icon()` cannot tell either (see the `app icons:` line under
Verification), `GMarkup` accepts the double hyphen because it is a lenient subset parser, and
`gdk_pixbuf` goes through glycin here, which sandboxes a subprocess per load and refuses this
project's own icons outright. The cost is that the `xmllint` binary is a build dependency; it
ships with libxml2, which GTK already requires.
`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`.
- **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has
none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them
with `gtk_accelerator_parse()` — so they are `"space"`, `"<Control>e"`, `"<Control>slash"`,
`"plus minus"` (a space-separated pair is the "either of these" form the label draws as
`+ / -`), never the prose `"Ctrl+E"` or `"+ / -"`. Neither GTK's syntax nor libadwaita's
knows a **wheel or a drag**: `"Wheel"` costs a `Failed to parse` warning and draws an empty
cell. Those six rows therefore carry `accel = ""` and put the prose in `subtitle`, which is
the one field that is free text. A row added with a parse error is otherwise silent — the
overview still opens and the row is simply blank — so read the probe run's stderr for
`Failed to parse` after touching either table.
- **The chooser runs before `ScopeModel` exists, and `--server` is what decides.** `Config` carries
`server_specified`, set only by the `--server` branch, and `main()` asks only when it is false —
so declining costs no buffer and no socket, and `ScopeModel`, `Acquisition`, `RowSet` and
`run_ui()` need no notion of "no source yet". It is `--server` alone and not "no arguments":
`--multicast` still needs a server to fetch CONFIG from. **Every documented probe passes
`--server`**, which is what makes the feature invisible to the regression net — a probe added
without it would reach the chooser and hang. `gtk_init()` plus a nested `GMainLoop`, and then
`run_ui()`'s `GtkApplication` afterwards in the same process, is a verified combination
(`gtk_init()` is idempotent; running your own loop is the documented GTK4 replacement for the
removed `gtk_dialog_run()`).
- **A session file's name cannot say how its source was reached, so the file says it.**
`<host>_<port>.toml` carries neither the multicast group nor the interface, so a chooser row
could not reconnect to a multicast source at all. `LayoutState::multicast`/`interface_addr` are
written in the **top block, before the first `[[table]]`** — a bare key after a table header is
that table's key in TOML, which is exactly how `enabled` was parsed into the last
`[[calibration]]` record and lost without a word. A file from an older build has neither key and
loads as unicast on the default route. The chooser's list filters to sessions that **name at
least one signal**, because a probe run leaves a file per throwaway port and all of them hold
`signals = ["", "", "", ""]`; `known_sources()` splits the filename at the **last** `_`, since
`sanitize()` turns anything unsafe in a host into one.
- **`save_layout_now()` refuses to write while `signals()` is empty.** A run against a source
whose streamer was down receives no CONFIG, so every slot name stays empty and the save would
overwrite a configured session with `signals = ["", ...]`. The chooser then filters that file
out — a source whose streamer was merely down during one run would silently vanish from the
only list it can be picked from, for ever. Keeping the old file loses nothing: a run that
configured nothing had nothing worth persisting.
- **The status bar is two tiers, and the dump reads the detail one.** `format_glance()` writes
the bar and `format_detail()` writes both the popover's prose and `App::status_detail`, which
is what `exit_probe_step3`/`exit_probe_step2` copy into `dbg_mode_live`/`dbg_mode_triggered`.
Those two used to read the *label*, and the label no longer carries `packets`, `gaps`,
`plot 1:` or the buffer figures — so a `gtk_label_get_text()` there would silently reduce
every documented `mode …:` assertion to four fields. The glance line's state field is **one
word from a fixed set** (`live`, `paused`, `armed`, `capturing`, `event held`, `disarmed`) for
the layout reason above; a seventh word, or a field whose width tracks the stream, breaks the
row the plot is laid out under.
- **The ⓘ popover's widgets are built once and only their text is set.** It is visible when the
tick updates it, so a rebuild is a per-frame relayout of a widget on screen.
`update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in
the dump is the assertion.
- **The About dialog, the help window and the shortcut overview are opened by `show_about()`,
`show_help()` and `show_shortcuts()`, and each closes with the matching call.** There is
exactly **one `GtkWindow`** among the three — the paged help window. It closes with
`gtk_window_close()`, which emits `close-request` so the handler that nulls the file-scope
pointer runs. `gtk_window_destroy()` does **not** emit that signal and must not be used — the
math dialog fell into exactly this trap, and the pointer then points at a dead widget until the
next attempt to open the same window. The other two — the About dialog (an `AdwAboutDialog`)
and the shortcut overview (an `AdwShortcutsDialog`) — are **`AdwDialog`s and not `GtkWindow`s**:
they close with `adw_dialog_close()` and clear on `::closed`, there being no `::close-request`
on an `AdwDialog`. Both share one `on_dialog_closed()` handler keyed by a **pointer to the
pointer** it must null (`&g_about`, `&g_shortcuts`), which is what keeps one handler honest for
two dialogs. One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its own close handler, is
the shape that prevents the double-open defect across all three.
- **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at
configure time (so `add_executable` can name the file) and again on every build through
`udpscope_build_info` (so the commit and the timestamp belong to the binary that is running).
`git` absent leaves `kCommit` at `"unknown"` and the build succeeds. The About dialog and the
`[help]` probe both read `kVersion`, `kCommit` and `kTimestamp` from it; `settings=` in the
probe reads `config_dir()`, which is the path the session file is written to.
- **The keyboard zoom's modifiers are not the wheel's.** `+`/`-` with **Ctrl** restricts to x
and with **Alt** to y, while the wheel keeps upstream's Shift = x / Ctrl = y convention. The
two cannot agree: Shift is unusable on the keyboard path because `key_from()` folds `'+'` and
`'='` onto `kKeyZoomIn` — `+` *is* Shift+`=` on common layouts, so a bare zoom-in already
arrives with `Mod::Shift` set. Ctrl+Alt together zooms neither axis, which needs no special
case. The guards are `deps/plotter`'s own `ctrl_plus_zooms_only_x` and `alt_plus_zooms_only_y`
tests, plus `UDPSCOPE_PROBE_ZOOMKEYS_MS`.
- **A help page is one `.txt`, one `<file>` line in the GResource manifest, and one `kPages`
row.** The bodies are Pango markup in a second GResource (`--c-name=udpscope_help`, so its
generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at
load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from
`parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`.
- **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons
all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once
Caps Lock is on.
- Every source file carries an SPDX MIT header; match it in new files.