1463 lines
122 KiB
Markdown
1463 lines
122 KiB
Markdown
# 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='Pause and play' 1434 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.
|
||
- `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row,
|
||
then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B`
|
||
and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr,
|
||
`UDPSCOPE_PROBE_MENUS_MS`) — the two popovers the toolbar and the status bar open, measured with
|
||
the side panel hidden. **`rows_bad` must be 0 and `fits` must be 1.** What it asserts is what a
|
||
screenshot can only show an eye: every row's label starts at the *same* x (`label_x=26` for all
|
||
seven — a menu whose checkable rows indent their text and whose others do not is exactly what
|
||
"the text is not left-aligned" looks like), every shortcut ends at the row's own right edge
|
||
(`accel_right == W`), no row is over `kMenuRowMaxPx` (30) tall — Adwaita's button minimum is 34,
|
||
and seven of those is what made the popover read as a stack of dialogs until `.udpscope-menu`
|
||
trimmed it — and the rows arrive in the order its `kExpected` names. `[menu] status` is the ⓘ
|
||
bubble: **`pos` must read `top`**, which is set *after* `gtk_menu_button_set_popover()` because a
|
||
`GtkMenuButton` states a position of its own and the last word wins, and `button_top` must exceed
|
||
the bubble's height, because "below the button" is off the window's bottom edge — the bubble's
|
||
top edge stayed at the button with its body clipped away, which reads as an arrow disconnected
|
||
from its body. Run it **alone**: another probe that opens a window dismisses the popover, and
|
||
every row then reports `h=0`. It turns autohide off for its own measurement — a popover dismisses
|
||
itself when it loses the grab, and `xvfb-run` has no window manager to hand it one — and restores
|
||
it afterwards.
|
||
- `[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`.
|
||
A page may also carry screenshots (`HelpPage::images`, a null-terminated list of resources).
|
||
They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height
|
||
at all inside a box, so without the request it is allocated nothing and the page renders its text
|
||
with a silent gap where its screenshots should be — which is what the first version did.
|
||
- **`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.
|