Files
udpscope/docs/claude/probe-readings.md
T
Martino Ferrari 91ca29d181 scope windows: the same instruments as the main window
A scope or detached window drew its traces and nothing else: no cursors, no gate band, no trigger
marker, no measurement marks. They are not per-window state to be recreated -- the cursors are one
pair of times, the gate one band, the trigger one instant -- so the main window's row set stays the
**owner** and every window is written from it.

`sync_scope_instruments()` runs in the main tick after the main's own `sync_cursors()`/
`sync_gate()`: a gesture inside a scope window is read back from *that* window's controllers first
(those two calls only adopt while their own plot holds the grab, which is exactly "the drag
happened here"), adopted by the owner, and then every window -- the dragged one included -- is
written from the owner with the same cursor pair, mode and visibility, gate bounds and visibility,
and trigger marker. `RowSet::set_cursors()` is the new pair setter; `trigger_marker_x()`/`_visible()`
are the getters the mirror reads. The measurement marks travel by signal name
(`RowSet::MeasureMark::signal`, filled by the measures pass), so each window finds the row that
plots the signal and draws its ruler or mark there -- or none, when it does not plot it. The search
marks are times on the window and are placed on every window by the same per-frame path, and the
crosshair-axis-labels preference is restated to open windows when it changes.

Probe: `[scope-probe] instruments windows=N cursors=N gate=N trigger=N marks=N (main trigger
on=..)`, after adding a real gated `Top` measurement on `sine` -- the gate is restated visible each
frame by `update_measures()` and marks come only from `show_on_plot` measurements, so the leg uses
the app's own path. Docs: README, the cursors invariant, and the probe readings.
2026-11-06 12:21:38 +01:00

3117 lines
285 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Probe readings: what each line asserts
The per-line reference for the self-check's output — what every probe and dump line means, the value it must read, and the defect a wrong reading names.
Part of [CLAUDE.md](../../CLAUDE.md).
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 88..104` dragging
`axes.x 88..104`/`axes.w 707..782` 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. **A range that keeps *climbing* to 200 is the
other one**: `note_y_gutter()` is handed the gutter the row was laid out with, which
already includes the floor imposed last frame, so slack added to it is added again every
frame and the gutter ratchets to its own 200 px cap — `axes.x 88..200 / y-gutter 88..200
/ axes.w changes: 17` on an 870 px viewport is what that reads as, and 17 changes looks
like a settle. The reservation's own padding is the only slack there may be.
`axes.w` also no longer reaches the viewport's right edge: `apply_gutters()` reserves an
empty band there (~59 px of 870), so `viewport.w - axes.x - axes.w` is a *constant*, not
a zero. **`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, when it was 320 px — 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=` is **minus the newest acquired stamp** in `live` and `nothing` — it is what makes the
live axis `[-window, 0]`, so there it grows (in magnitude) with the stream and a `0.0000` is
the defect, not the healthy reading. It is back to `0.0000` only in `event(frozen)`, where the
snapshot carries its own origin (`EventSnapshot::x_offset`) and the display epoch has nothing
left to do. 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 in
[invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)). Both need a *saturated* ring to
show up, so run them under `--buffer-samples 65536`, 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.
- `[force-probe] button off: sensitive=0 visible=1 w=X | on: sensitive=1 w=X | arm w=Y |
label_found=1` (stderr, `UDPSCOPE_PROBE_FORCE_MS`) — the Force button's stated state, read on both
sides of the trigger toggle. **`sensitive=0` with the trigger off and `1` with it on** is the whole
rule (`sync_arm_buttons()` states it from the toggle, and a trigger that is off has no engine to
force); `visible=1` in both says it is a *body* control and not a compact-heading one, which is why
the off reading must not be `visible=0`; `w` is its allocation a frame after the state it belongs
to, and `arm w=Y` the button beside it, so a `w=0` next to a non-zero `Y` is the "width read in the
tick the control was stated" trap rather than a collapsed button. `label_found=1` is the button the
window shows under the word **Force** *being* the panel's own widget.
- `[force-probe] leg A|B want=[-a..b] during=[-c..d] snap=[-e..f] n=N axis=[..] latches=P->Q
display=.. matches_request=D pin_is_snapshot=P` (stderr, `UDPSCOPE_PROBE_FORCE_MS`) — one line per
leg, taken after the latch the press produced. **`latches` must move by exactly one and `display`
must read `event(frozen)`**: the probe sets the condition to a level the mock never reaches (9 V
against ±1 V signals), so the engine cannot latch on its own and a forced acquisition is the only
thing that *can* move the count — that is also why this probe must run alone, since any other probe
that arms a reachable condition would add latches of its own. `want` is the pre/post split the
panel was set to at the press, taken from the producer's own `post_span_s()`; `snap` is what the
snapshot holds (`[-pre_s..+post_s]`); `during` is the axis while the forced capture was *filling*,
which is the *requested* window — a capture has no snapshot to frame from yet. The two legs differ
in which of those can agree:
- **Leg A (2 s window at 50 % pre, on a ring that has filled)**: `matches_request=1`, i.e.
`snap ≈ want` to within a stored interval, and `n` the span times the stream's real rate plus
one (`20001` at 10 kS/s for `[-1..+1]`, exactly as the latch line demands).
- **Leg B (100 s window at 99 % pre, which no run this short can reach back to)**: the request is
99 s of history and the ring holds seconds, so `matches_request=0` **on purpose** and
`snap ≈ [-history..+1]`. That is the leg that makes **`pin_is_snapshot=1`** a reading rather than
a coincidence: `axis` must equal `[-snap.pre..+snap.post]` (a few seconds wide) while `during`
still reads the requested `[-99..+1]`. A pin stated from the *request* — the
`hold_pinned_window()`/`pin_event_window()` mistake this probe exists for — reads
`axis=[-99.0000..1.0000]` here, i.e. `pin_is_snapshot=0` with the frozen picture a fraction of
its own axis.
A forced acquisition is an ordinary latch in every other respect, so the `trigger: edge at X ...
want [-a..b] got [-c..d]` line appears beside each leg with the *force's* edge as its `X`, and that
line's own rule (`got == want`, `n` exact) applies to it unchanged. The line
`[udpscope] trigger: forced acquisition, edge at E -- want [-a..b] s, H s of history behind it,
mode=M` is the producer accepting the request: `H` is the ring's retained span at the press, which
is what caps the pre side in leg B, and `mode=single (disarms at the latch)` says the one
acquisition the mode means, so an armed state that reads `0` afterwards is right and not a lost
click. `force ignored (enabled=.. armed=.. ch=.. pending=..)` is the refusal — a trigger that is
off, a channel that is still `-1`, or **a capture already filling**, which is deliberately *not*
restarted (it is the acquisition being asked for); in the `ch=-1` case the click is refused in the
handler instead (`force refused -- no signal is being acquired to trigger on`), because a request
left to the producer would sit behind `on_data()`'s no-streamed-channel early return and fire on
the first signal switched on afterwards.
- `[trigrenum-probe] window=W event_span=S pre_pct=P post=O holdoff=H level=L edge=E mode=M
enabled=1 armed=1 ch=C channels=N`, then `capture open: t0=T ch=C -- enabling signal 1 now`,
`after enable: trigger ch=C' channels=N'`, and `latched t0=T' opened on T t0_held=D` (stderr,
`UDPSCOPE_PROBE_TRIGRENUM_MS`) — the first line is the geometry the real widgets produced
(`window=1.0000 event_span=1.0000 pre_pct=10.0 post=0.9000` for the reported setup); it is
context, not the assertion. The third must show the index **actually moving** (`ch=0` →
`ch=1`), or the probe tested nothing — the sine has to be a channel that sorts before the
square. **`t0_held=1` is the assertion**: the capture that opened on `T` latched on `T`, so the
renumber neither abandoned it nor let a later edge inside the post window become t0. With the
engine's invalidation keyed on the channel's index instead of its name this read `t0_held=0`,
with `pending` back to 0 and `holdoff_until` *behind* `now_t` on the engine line — i.e. no
hold-off was set at all, which is the whole defect. The `[udpscope] trigger: edge at ... got
[--0.0199..0.9000]` beside it is expected: the rebuild replaces the `BufferStore`, so the
surviving capture has no pre side left and reports a negative `pre_s`.
- `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] abs=[..]` (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 puts the window back on the stream's clock with
`stream_at_x0()` before searching the ring, and that offset is a whole window while
streaming — not the 0 it once was).
**`abs - time` must be a single constant across both ends of a line**, and that constant is
where the display's origin sits in the stream's own clock: `epoch()` plus the newest stamp in
`mode=live`, `epoch()` plus the accepted edge in `mode=event`. That is the one assertion on
the second column, and it replaces the old "`stream=` must be the edge ± the window" reading,
which only ever held because the display clock *was* the stream clock. A `mode=live` line
whose `abs - time` differs between the two ends is the two columns computed from different
epochs — the defect `abs_base` exists as a single `const` to prevent.
An export taken **mid-capture** reports `mode=live` with `time=[-pre..e]` where `e` is how far
the post window has filled — 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 three `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.
The `.mf3` line's size is the MDF 3.30 layout the writer spells out, and every term of it is
observable: `272 + comment + rows·record + 28 + channels·274 + 30`, where the ID block is 64,
the HD block 208, the comment is a `TX` block of its text plus 5 bytes, `record` is
`16 + 4·cols` (two `f64` time columns then one `f32` per signal), the data group is 28, each of
`channels = cols + 2` gets a 46-byte conversion block and a 228-byte channel block, and the
channel group is 30. Read the file back with asammdf: `MDF(path).info()` must show one group
whose `channel 0` is `name="time" type=master`, and `get(name, group=0).unit` must be the unit
the next line reports.
`[export-probe] mdf master=time master_unit=s channels=N record=B units=['s' 's' 'V' …]`
is the other half, and it is the assertion on the units: `channels` and `record` are the two
numbers the `.mf3` size above is built from (so `record` must be `16 + 4·cols` and `channels`
`cols + 2`), and `units` must be **the CONFIG's**, in the table's column order, behind the
master's and the relative axis's two `s` — one `'s' 's'` pair plus one entry per exported
column, each exactly the unit the stream sent (an empty one reads `''`). A `V` where the mock
sent `s`, or a missing entry, is the unit not reaching the conversion block, which is the only
thing ScryLab reads a signal's unit from; `write_npy`/`write_csv` must be unchanged either way
(their bytes are pinned by the two lines above).
- `[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 in [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md). `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-samples 65536`, which saturates in a few seconds and makes windows past ~6 s
decimate; at the default 10M nothing inside `--max-window` needs to.
- **The anti-alias check** is the same probe run against a waveform above the stored
Nyquist: `--buffer-samples 65536 --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_sensitive=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. `pane_sensitive` is the cursors *panel body's* sensitivity, not a
revealer's child: with the cursors off the panel stays exactly where it is and its cells blank to `--`,
because a readout whose instruments are off has no sample to quote. **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 in
[invariants-plot-and-layout.md](invariants-plot-and-layout.md); `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 marks_shown=M pane_rows=R pane_w=W
pane_h=H switch_on=X reset_sensitive=Y`
(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.
The dump line carries one more field for the one type that reads *two* shares:
`measure N: 'sig' between thr=50.0% thr2=75.0% above=1 show=1 -> ok ...`, where `thr2` appears
only for `between` (see the band-average probe below).
On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.96 and
`bottom` ≈ -0.96, `amplitude` ≈ 1.92, max/min ≈ ±1, `peak to peak` **exactly 2**,
`pulse duration` ≈ 0.5 s
with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. **`top` is no longer 2/π**, and that
is the point of it: it is the *flat* level of the upper side, read from the density of the
window rather than from a mean over a side, and a sine has no plateau — so the densest band
sits at its peak and the reading is near it, not the 0.636620 a mean over a half would give.
`amplitude` is that pair's delta (`top - bottom`), which is why it reads about 1.92 rather than
the half-swing it used to: a bench scope's `Vamplitude` is `Vtop - Vbase`. The mean over a half
is not gone, though: it is `above 50` on this stream (and `-above 50` is `below 50`), and its
own probe asserts it — see `[bandavg-probe]` below. The measured
figures, and the reason they are what they are, are in
[invariants-measurements.md](invariants-measurements.md) — on the mock's `square` the
separation is the visible one (`top`/`bottom` `0.999983`/`-0.999983`, `amplitude` `1.999966`,
while `peak to peak` keeps the edges' ±1.179 overshoot at `2.358701`). It needs
**`UDPSCOPE_ENABLE_ALL_MS` ahead of it** or it prints `no acquired signal to measure` and the
dump reads `n=0`.
**Run it against `--noise 0.1 --freq 1` as well, and the three crossing types must still read
those figures** (measured: `period=1.000100 frequency=0.999900 pulse duration=0.504100`): the
hysteresis band is the only thing holding them there, a single level reads a 500 Hz interferer as
the signal, and the default `--noise 0` mock cannot see it. The band costs `raw`, not accuracy --
the clean fast mock reads `raw=0.1%` at 1.41 ms a recompute and the noisy one `raw=2.0%` at 1.96
ms, so read `measure cost:` on **both**. `marks=` lists each mark with its kind, and the **mix is the point**:
`level:V` alone for average/top/bottom; **two** level marks for `peak to peak` (the window's
extremes) and for `amplitude` (the two *flat* levels it subtracts -- a pair of rulers, because
the span between them *is* the measurement); `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. `switch_on` and `reset_sensitive` are the panel's *frame* read back: the switch widget against
`measure_stats_on`, which the code drives, and the Reset button that follows it. Either disagreeing with
`[stats-probe] on=` is a `.ui` that lost the widget, or lost the wiring to it; `pane_rows=R` is what
`marks_shown` counts two per shown measurement
(a min/max showing both marks counts two of its own). `measures window:` is the
timeliness assertion -- the window the numbers were computed over against the one that *should*
be on screen.
**The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window
on screen. Both ranges are on the **stream's** clock, which is the space `measure_window` and
the time marks are stored in -- a live axis is a fixed `[-window, 0]`, so comparing display x
would read no drift whatever the data did. `win` is the window as of the last **tick**
(`App::measure_view_window`) and is deliberately not read back here: the dump runs after
`g_application_run` returns, by which point the exit probe has switched the trigger on, and
`stream_at_x0()` then answers for a mode no frame was ever drawn in -- which read as a 45000 %
drift on a run whose every frame was exact. `pin` is the same tick's pin (`App::measure_pin_window`,
`off` when nothing was pinned) and it is the **target** while the view is pinned: a measurement
on a still frame describes the frame, so `drift` there is `results_for` against the *pin*, and a
zoom that leaves the screen showing a slice of that frame is not drift. Read against `win` -- the
old rule -- a zoomed held event would report the intended behaviour as the defect it was once
reported as. The count is still against `win`, the window **on screen**, not against
`measure_window`/`pin`, which contain every mark by construction: read that way it was a tautology
that printed `2/2` while the pane described a window a forty-fold zoom away from the one
displayed. A *time* mark may legitimately sit outside `win` on a pinned view that has been zoomed
into the pin (the mark is a position in the window the result describes, and that window is the
frame), so the strict `K/K` reading belongs to a live view and to a pinned view left at its pin.
On a window **shorter than the refresh interval** the count rides the same sawtooth
`drift` does -- the 50 ms window the zoom sweep leaves behind can lag far enough that *every*
time mark is off screen, so `0/K` there is read beside `drift` and not alone. `0/0` is a pane
whose types carry no time marks at all (`average`, `top`, `amplitude` and `peak to peak` draw
rulers only), and is not a reading about marks.
`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. The figure is `(|Δmin| + |Δmax|) / span`, so a pure scroll
counts **twice** and the top of the sawtooth is `2 × (refresh + one frame) / window` -- the
frame term is not noise under `xvfb`, where a frame costs 30--70 ms: ~10 % on a 2 s window at
the 100 ms cadence with instant frames, and **15.8 %** measured on the documented run, which is
one interval plus one 58 ms frame. On a window shorter than the interval it is a few *hundred*
percent (a 50 ms window reads 254 % for 63.6 ms of scroll, and its ceiling is 400 %). Compute the ceiling
for the window the run actually used before calling a figure large. A `0.0000%`
is a dump that landed just after a pass, 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 **it describes** 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, and it is the window it describes -- the viewport live, the pin while
pinned -- so that a gesture on a still frame spends no recompute at all. **`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=
band= side= show= shares=A/B` 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. `band` is the *second* share's row, which only `between` shows -- it is
the one type whose two edges are both parameters -- and `shares` is what the two spin fields are
*showing*, read back out of the widgets: a share that is stored but not seeded into its field, or
a field that is built but not read back on apply, is then visible in this line rather than only in
the reading it produces. `above`/`below` show the threshold row and no band row, with their own
label (`above % of the range`). 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 distinct=D window=.. origin=.. note=[..] ticks=[..]
first_value=.. first_rel=.. range=[..]` + the dump's `time labels: relative=D distinct=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). **`distinct` must be 1 on every line, and that is the assertion** — it says no two
major labels on the axis are the same string, which is the one thing the relative form exists to
buy and the only reading that holds whatever the stream's uptime is. `relative=D` is **expected
to read 0 on every line now**, and that is a consequence of the newest-relative axis rather than
a change to the rule: the form is decided by the tick step against the label resolution at the
axis' **magnitude** (see
[invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)), and no display mode carries a large magnitude any
more — live and waiting are `[-window, 0]`, a capture and a held event are edge-relative, a pause
is freeze-relative. The 50 ms window that used to read absolute at 2 s of uptime (`2.13s, 2.14s,
2.15s`) and relative at 17 s (`100µs, 10.1ms, 20.1ms`, with the caption) now reads
`-40ms, -30ms, -20ms, -10ms` at any uptime. The rule and the caption are kept because they are
the answer for *any* axis magnitude and nothing in them is mode-specific — do not delete them on
the strength of this reading — but what the probe asserts is `distinct`, not the form. The
**caption must be non-empty exactly when `relative=1`**, which here means empty throughout. Run it after
`UDPSCOPE_ARM_TRIGGER_MS` as well, which is the case the span rule got wrong: a triggered 50 ms
window must read `relative=0 note=[] ticks=[-20ms,-10ms,0,10ms]` — a `t0` caption on an axis
that is already edge-relative re-anchors the ticks away from the trigger, which is the one
position the axis exists to show. 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. Note the last two steps both report `window=0.05`: `RowSet::set_time_window()`
floors the window there, so the 20 ms step is clamped.
- `[fit-probe] <action> pinned=D window=.. event_span=.. x=[..] span=.. y=[..]` (stderr,
`UDPSCOPE_PROBE_FIT_MS`) — the two fits, through the **menu handlers** rather than the bare
`RowSet` calls, because the notice and the control are the handler's half. A fit is the **undo
of an x zoom**, so the probe performs one: it moves the y scale off the data (a fixed
`[-100, 100]`), sets a 2 s window through the control's own path, then zooms row 0 to a quarter
of it the way a gesture does — on the *member* axis — and fits. Four assertions.
**`window` and `event_span` must be unchanged on every line** and equal to the typed 2 s: a fit
is a plot interaction and may not restate the time scale (see
[invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)), and they are
the only two readings that can see it try. The `zoomed` line's `span` must be a quarter of the
window, or the fits have nothing to undo and the lines below mean nothing. **`fit x` must put
`span` back to the whole window and leave `y` exactly as it was**, and **`fit all` must do the
same and move `y` to the data's own range** — a `y` that changed on the `fit x` line is the two
paths aliased. `x`'s *position* advances between lines on a live view and is not an assertion:
the follow is re-armed on the newest data, which is what the whole window means there.
Run it **inside a pause** as well (`UDPSCOPE_PROBE_PAUSE_MS=2000 UDPSCOPE_PROBE_FIT_MS=2200`),
which is the other branch and the one that can silently revert: `pinned=1`, and `fit x` must
restore the *pin's* full range — the snapshot's own stamps, legitimately narrower than the
requested window (`span=1.179900` against `window=2.000000` is correct, not a finding) — and
must still read `pinned=1` afterwards. The pin is restored by writing the **members** as well as
the group, because `hold_pinned_window()` prefers a member that has moved since `pinned_stated_`
— that is how it tells a gesture from its own echo — so a fit that wrote the group alone reads
back as the zoomed range one frame later.
- `[layout-probe] <action> rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] resize=[..] traces=[..] side_w=S cells=[..] colweights=[..]`
`side_w` is the sidebar column's width and **must be its own request (280), never 0**: it read 0 while
the divider sat at the window's right edge, which is a panel that is not there. It was 280 during the
first frames and 0 once the window had settled, so read it from a `placed` line rather than the first.
(stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `3P + 2F + 2`** while the editor is
up, for `P` plots in `F` figure rows: one `×` per plot (`P`), one row `+` and one row resize arrow
per row boundary (`2(F+1)`, the arrows of the figure's two outer edges counted although hidden), and
per row of `n` cells `n + 1` cell `+` buttons and `n - 1` column arrows (`3P` in all, since
`sum(n+1) + sum(n-1) = 2P`, plus the `P` of the `×`). With no split that is **7 for one row and 12 for
two** (`5` and `8` before the split, the old `2N+1` having long stopped counting the arrows), and a
split row adds three per extra cell. **0 on the `editor off` line**: the
buttons exist only while the editor does. `rows=` is the plot count and `cells=[..]` the cells of each
figure row, comma-separated (`[1,1]` for two unsplit rows, `[1,2]` for a split second row);
`colweights=[..]` is each figure row's cell weights, `|` between rows and **empty for a row with none**
(every unsplit row, and a split row nobody dragged), so two unsplit rows read `[|]` and a dragged
`{3,1}` second row `[|3,1]`. An unsplit run must read `cells=[1,..,1]` and a `colweights` of
only separators; anything else is a split the run did not ask for. `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.
- `[udpscope] header: window_button_w=W bar_w=B` (stderr, once a second, **only when
`UDPSCOPE_PROBE_HEADER` is set**) — **`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. The widths are still sampled every second
in every run, because the exit dump's `header:` line reads them; only the line is behind
the variable, since a reading printed on every run is a log flood (the `[notrace]` rule).
An unallocated window reads `0` for both, which is the reading for "the toplevel never
got a size", not for a layout drift.
- `[renderer] option=NAME GSK_RENDERER=V LIBGL_ALWAYS_SOFTWARE=V` (stderr, at startup,
before GTK initialises; only when `UDPSCOPE_PROBE_RENDERER` is set) — what `--renderer`
exported, printed from `main()` so the reading is of the environment a pre-init GTK will
see. `option=(unset)` with both variables `(unset)` is the default and the assertion that
the flag changes nothing unless asked.
- `[renderer] realized=TYPE` (stderr, once, from the first tick that finds a window renderer;
same probe) — the class GTK actually built (`GskCairoRenderer` for `--renderer cairo`),
which is the half of the claim the export alone cannot make. It is null until the window is
realized, so a tick that finds none tries again rather than printing a false reading.
- `toolbar icons: trigger=NAME(ok) events=... pause=... cursors=... 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)`.** Three
of the names are this app's own (`udpscope-trigger-symbolic` and friends, a `GResource`
compiled into the binary — see the toolbar-icon invariant in
[invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)), and a `(MISSING)` on one of
those means the resource path was never registered. That failure is otherwise *silent*: GTK
substitutes a missing-image placeholder, the button still works, and every other line of the
dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this
is the only check that it names something that exists. The line ends with `resize_rows=udpscope-resize-rows-symbolic(ok)
resize_cols=udpscope-resize-cols-symbolic(ok)`: the two layout-editor handle icons, which no toolbar button carries but
which are the same resource and fail the same silent way.
- `dock: shown=1 sensitive=1 w=280 scroller_pages=1` (in the exit dump, beside `panels:`) -- the sidebar's dock
as the last tick found it. There is no tab switcher: the sidebar is one scroller (`dock_scroller`) holding the
**readouts box only** -- a compact, read-only copy of the cursors and of the measurements, each with an Edit
button (`build_readouts_dock()` in `src/ui_dock.cpp`). The signal list, the full cursors table, the measurements
and the trigger are slide-over pages. **`shown=1`
always** (the dock is on screen from the start; `shown=0` only when the user hid it with the Dock button,
which a probe does deliberately); **`sensitive=0` only before the stream has signals** ("disable, do not
hide") -- a run against a port with no sender reads `shown=1 sensitive=0`, a normal run `sensitive=1`;
`w=` is the sidebar's own width (**280**); **`scroller_pages=1`** counts the scrollers directly under the dock
column and says there is one scroller and no stack. These are captured on the tick or at the exit probe:
the dump runs after the window is destroyed. A source with extra panels adds *no* dock section: its panels
are slide-over pages (`source-<id>`), so the line reads the same on the E-1608 and the probe kind.
- `[readouts] pages cursors=1 measure=1 dock_children=3 dock_w=280 pages_sensitive=1 signals=1` (in the exit
dump; `dock_children` counts the direct children of the dock page) -- the two pages and the dock against the
stream. **`cursors=1 measure=1`**: both slide-over pages are registered, always and from the start (not when
the stream has signals); `0` is a page that was never added, and then the dock's Edit buttons and the `m` key
open nothing. **`dock_w=280`** is the sidebar's width, as for `dock:`. **`pages_sensitive` must equal
`signals`**: both pages are insensitive until the stream has signals ("disable, do not hide";
`update_signal_availability()` writes them with the dock), so a run against a port with no sender reads
`pages_sensitive=0 signals=0`, a normal run `pages_sensitive=1 signals=1`. `pages_sensitive=1` with `signals=0`
is a page that can be opened and read with nothing to read; `pages_sensitive=0` with `signals=1` is a
stream whose pages never came back after the signal list arrived. **`dock_children=3`**: the dock
page holds the readouts box, a separator and the Events block (`build_events_dock()`) and nothing else (a fourth
child is a section that crept back into the dock; 1 is a dock without its Events block, 0 a dock that was built
empty).
- `help layer: contextual=on hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} icon=dialog-information-symbolic(ok) hint=12x12; tour=idle steps=6 step=0 auto=1 requested=0` (in the exit
dump, beside `dock:`) — the help affordance layer as the last tick found it. **`hints=` must equal
the number of ids**, and the ids are the surfaces the contextual help is on: eleven of the window
itself, and `math` only while the math editor is open (its heading lives in a builder that is rebuilt
on every opening, so it is not one of the window's own). Four of the eleven are not a pane's *heading*
but the row of a specific control — `events_record` (the "record events" switch in the trigger
panel), `trigger_advanced` (the pre-trigger share row), `trigger_search` (the *header* of the search
block's expander, which is what lets it be on screen while the block is collapsed) and
`signals_config` (the signals pane's hint line, whose box exists so a hint can join it) — which is
the rule the layer follows: a hint is a sibling of the thing it explains and never an overlay on
it. The layer is **off by default**
(`contextual_help` in `settings.toml` is false for a missing file), so a fresh-config run reads
`contextual=off hints=0` and that line above is the state *after* the Help row was switched on;
`contextual=off` beside a non-zero count is the toggle not having removed its buttons — **off is
*gone*, not greyed**. **`icon=` must read `(ok)`**: an "(i)" whose name Adwaita lacks is a
*blank* control that still works, the failure the pane [edit] buttons already paid for once. The name
is `dialog-information-symbolic`, the plain information mark; `help-about-symbolic` is the status
bar's ⓘ and a second meaning for it is what the app's own icons exist to avoid. `hint=WxH` is the
size the **first visible** hint was allocated, measured the way every other rectangle here is
(`gtk_widget_compute_bounds()`), and **12x12 is the reading a "much tinier" mark must keep**: the
class zeroes the outer `menubutton` node and its `button` subnode (a `GtkMenuButton`'s measure
ignores both `min-width` on the widget and a size request), and the glyph is a third node *below*
the subnode — `menubutton > button > box > image`, read off the CSS node dump. The direct-grandchild
form `> button > image` matches **nothing** and silently leaves the glyph at its natural 16 px,
which is the 16x16 an earlier version shipped by mistake; and there is no lower floor to find
(the symbolic glyph renders at any request, measured down to 4 px), so 12 px is a legibility
choice. The default 34x34 is the number to compare against. A hint inside a collapsed expander's
*child* is skipped, so the number is never a hidden widget's 0x0 — but a hint on the expander's
*header* is in the widget tree and reads its real size while the block is collapsed (see the
`collapsed search:` entry below). `tour=` is `running`/`idle` with the plan's `steps=` and the
current `step=`; `auto=1` is the first-run auto-start having fired *in this process* — the flag is
`tour_seen` in `settings.toml`, so a second run against the same config directory reads `auto=0` —
and `requested=1` is the **chooser's** tour having handed over (`Config::tour_pending`), which is
`auto=0` because the request came from the other window rather than from this process's first run.
- `gtk warnings: N` (in the exit dump, beside `help layer:`) — **GTK's own log at warning or above,
counted for the whole run, and it must read `0`.** It is counted through a log *writer* function
because GTK is built with structured logging: its messages go through the writer and never through
`g_log_set_handler()`/`g_log_set_default_handler()`, which is exactly why no probe can read one —
the message is written by the toolkit from inside a call of ours and is stored in no widget. The
first class to need it was `Trying to measure GtkBox … for width of N, but it needs at least M`
(once per frame, from a card measured for a height with a width a margin short of its own minimum;
see the allocation bullet in
[invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). The writer chains to
`g_log_writer_default()`, so every message still reaches stderr exactly as before and the documented
baselines are read the same way.
- `help figures: trigger_head=X,Y,W,H trigger_summary=… trigger_panel=… signal_list=… badge_bar=… dock=… window_button=… status_label=… header_bar=… plot=…` (in the exit
dump, beside `help layer:`) — the **window-coordinate** rectangle of each surface the help's annotated
figures label, `x,y,width,height`, or `-1,-1,0,0` for a surface that is **not on screen**. It is
measured on the tick while the window is alive (nothing in the dump may call `gtk_widget_*` — see
[verification.md](verification.md)) and with `gtk_widget_is_visible()` **and** `gtk_widget_get_mapped()`,
not `get_visible()`: a widget under a hidden parent — `trigger_summary` while the pane is in
*configuration* mode, and **every `trigger_*` surface while the slide-over is closed** (a revealer
unmaps its child without touching its visible flag) — keeps its own flag true and reads `0x0`, and a crop
box drawn from that is a box over the wrong thing. So the three `trigger_*` rectangles read real numbers
only while the `trigger` page is open (the exit probe has it open; `UDPSCOPE_PROBE_HELPUI_MS` opens it for
its figures legs), and `-1,-1,0,0` otherwise. `badge_bar` is the badge row under the plot and `dock` the
right-hand column (these replaced `side_stack`, the old sidebar stack). The figures are produced by cropping a real screenshot, so these
numbers **are** the crop boxes; their values depend on the window's size, which under the documented
`xvfb-run` is the window's 756x894 minimum (the 640x480 screen is smaller than the content), so a run
on a larger screen reads different numbers for the same panels. `trigger_summary` reading
`-1,-1,0,0` is the *configuration* pane rather than a defect: it is the locked pane's content, and
`UDPSCOPE_PROBE_HELPUI_MS` locks the pane for that leg.
- `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 command that reaches the chooser at all is `UDPSCOPE_PROBE_PICK_MS`, and this is worth
saying because three earlier attempts found no chooser and no output:** with no `--server` and no
probe, the chooser runs its own nested `GMainLoop` and *blocks* — there is nothing to time it out
and no dump runs, so a bare `./build/udpscope` (bare or under a pty) sits there silently. The
working form, against the mock:
```sh
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \
./build/udpscope
```
(with `XDG_CONFIG_HOME` pointing at a directory holding at least one saved source, or the pick
finds an empty list and the run prints `picked nothing`). It is the same command the docs give
elsewhere with `--server` removed and `UDPSCOPE_PROBE_PICK_MS` added.
- `[sources] chooser ui: … hints=[N {ids}] hint=WxH` (stderr, `UDPSCOPE_PROBE_PICK_MS`) — the
chooser's frame *and* its help layer in one line. `hints=[2 {chooser_sources chooser_waveform}]`
is the layer **on** (`contextual_help` in the file the run reads) and `hints=[0 {}] hint=0x0` is
it **off**, which is the same "off is gone" rule the window's line reads, in the window that runs
before `App` exists. The two ids are the surfaces this dialog has a row for — the heading above
the saved-source list and the waveforms page's action row; the form's `AdwEntryRow`s are *not*
hosts (a row is not a box and its editor is internal, so a hint cannot join it as a sibling) and
the connection fields are explained by their group's `description` instead. `hint=WxH` is the
same 12x12 the window's line reads: the button is `ui_impl::context_help_button()`, the one
`udpscope-hint` affordance both windows draw.
- `[sources] tour: first run -- auto_started=1 in the chooser` then
`[sources] chooser tour: visible=1 steps=5 step=0` (stderr) — **a first run's tour starts in the
chooser**, on the chooser's own widgets, from the same `settings.tour_seen` the window's
auto-start reads and with the same 1.5 s delay. `steps=5` is the chooser's plan (the source list,
the two tabs, New host, the help menu and the preferences). Driven by the walk itself:
- `UDPSCOPE_PROBE_CHOOSER_TOUR=1` emits `::clicked` on the help menu's **Guided tour** row, which
now starts *this* walk rather than only requesting the window's: `[sources] chooser help: tour
started in the chooser`, then `[sources] chooser tour: visible=1 steps=5 step=0`, then
`[sources] chooser tour: finished 5 stops -- the window continues it (tour_pending=1)` and
`[sources] chooser tour: after finish visible=0 tour_pending=1`. **`tour_pending=1` and
`visible=0` is the handoff assertion** — the walk is over here and the request rides on the one
`Config` value `main()` hands to both windows. The walk is driven in one tick (it is the handoff
being asserted, not the geometry), so the pages it selects are not readable here; the timed
`_MS` form below walks a stop per tick and reads them.
- `UDPSCOPE_PROBE_CHOOSER_TOUR_MS=<ms>` walks the tour a stop per **tick** (250 ms), the cadence
the window's own probe uses and for its reason: a page a stop selects is allocated on the frame
after the switch. It starts the walk itself when one is not already running, so it does not
depend on the config directory being a first run. Every tick prints the page the stack shows and
the page the stop *stands on*:
`[sources] chooser tour: step=N/5 page=<shown> stop_page=<declared> target_mapped=D`. **The
"Two kinds of input" stop stands on `waveforms`**: `step=2/5 page=waveforms stop_page=waveforms`
is the switch. Then it steps **Back** off that stop and reads the page again —
`[sources] chooser tour: back off the waveforms stop: step=1/5 page=sources` — which is the other
direction, and the previous stop stands on `sources`. It then walks to the end:
`[sources] chooser tour: finished 5 stops -- the window continues it (tour_pending=1)` and
`after finish visible=0 page=sources tour_pending=1`. Paired with `UDPSCOPE_PROBE_PICK_MS` after
the walk, the run shows the other half: the application's `help layer:` reads `tour=running …
auto=0 requested=1`, the window's tour having been started by `tour_start_requested()` rather
than by the first-run timer.
- **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=[...] ... cells=[..] colweights=[..]` 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). A **persistence
layer** would appear in that line as a suffixed name (`sine (A)`, `sine #4`) *while trigger mode
is on* — it is not the user's arrangement and is never saved (`collect_layout()` writes a layer
slot as empty) — and it is absent from the dump, which is always taken after the exit probe's
go-live has cleared the layers. `[persist-probe]` is therefore the only run-in-progress reading of
them.
- `[udpscope] starved: connected (config=1), 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. 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 same stall test has two outcomes, split on `have_config`, and they are different
diagnoses.** With a signal list in hand, the above. With **no CONFIG at all** it prints
`[udpscope] connecting: connected (config=0), reconnects=N, no configuration from the
stream ...` and the notice reads *trying to connect* — nothing was taken away, because
with no signal list nothing can be enabled and no DATA frame is owed, and the old wording
blamed a second reader that does not exist. That state is also named *without* waiting for
the three-sample stall: the glance word is `connecting` and the badge is **yellow** from the
first frame (see `badge frames:`). The way to see it with no second client is to point the
app at a UDP port where nothing answers (the client CONNECTs, times out, reconnects), which
is now the `config=0` branch and no longer exercises the `starved:` one at all — a check of
the unicast-contention wording needs a real second reader, or a source that configures and
then goes quiet.
- **The export's `time`/`abs_seconds` 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.
**The step must hold to a few ULP of the column's own magnitude**, and this is the check that
needs a long run at a high rate: the old form of this assertion was "`set(np.diff(t))` holds
exactly one value", and that was only true while `time` *was* the stored stamp. Both columns
are derived now — `time` is `stamp - stream_at_x0()` and `abs_seconds` is that plus a base of a
wholly different magnitude — so each subtraction costs a rounding and `set()` legitimately
holds a handful of neighbouring doubles. Measured: four values spanning ~1.5 ULP, 6.8e-16 s.
The thing that must not come back is the **plateau**: equal stamps, or a diff taking two or
three values a few *hundred nanoseconds* apart, is the time ring losing precision with uptime
(see the `f64` time-ring invariant in
[invariants-data-path.md](invariants-data-path.md)). The figure to compute is the ULP of `t[0]` and of
`abs_seconds[0]` — the defect that invariant guards is microseconds, nine orders of magnitude
above the arithmetic noise, so the two readings cannot be confused.
- `badge frames: lit=L dim=D idle=I connecting=C` — 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). **`connecting` must be 0 on a run
against a live source** — it is the steady *yellow* phase, "the socket answered but the
stream has sent no CONFIG", and a non-zero count against a working mock means a signal list
that arrived late or not at all. It is the *only* non-zero phase on a dead-port run
(measured: `lit=0 dim=0 idle=90 connecting=446`, the `idle` being the frames before the
first CONNECT), which is also the check that a source that has sent nothing never gets the
blinking green. 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 columns=C graded=G 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 low 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 in
[invariants-rendering.md](invariants-rendering.md)) — 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.
**`columns` must equal the frames that drew a trace at all on any run at a high rate**, and
it is the reading that says *why* `segments` is small: a dense gathered window is reduced
onto the pixel grid upstream (one snapped vertical segment per column — see the moiré
invariant in [invariants-rendering.md](invariants-rendering.md)), so the geometry is bounded by
`axes.w`, and a `columns=0` beside a large
`segments` is that reduction gone rather than a narrower window. Measured on
`--rate 1300000 --batch 2000 --freq 3000` over 20 s: `raw=2 lod=1198 columns=1200 frames,
segments 2041/2237` over 1201 painted frames, against `columns=0 ... segments
10960/216093` over 263 before it existed — the same run at 4.6× the frame rate.
**`graded` must equal `columns`**: this app turns `Style::intensity_grading` on, and grading is
a way of *drawing* the reduction rather than a third path, so a `graded` below `columns` is the
style not reaching a frame. **`UDPSCOPE_NO_PHOSPHOR=1` is the A/B** and the only way to read
either of these two lines against the ungraded path, since a frame mean cannot attribute its own
cost: measured on `--rate 1300000 --batch 2000`, `columns=1166 graded=1166` with
`draw 3.24/21.43 ms` and `segments 1340/4157` against `graded=0`, `draw 2.30/25.76 ms` and
`segments 131/4306` over the same `lod=453` frames. The segment rise *is* the gradient (alpha is
quantised into steps, one `LineBatch` and one draw call each, because the line pipeline's colour
is a uniform) and the ~1 ms is its cost; a `raw` that *grows* when the grading is on is the
level-floor invariant in [invariants-rendering.md](invariants-rendering.md) gone, which measured `raw=1154 lod=0` and 6.36 ms.
**`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 2000` — 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. (Do not
raise `--batch` to chase the rate: a batch of 20000 exceeds the datagram limit and the mock
dies with `OSError: [Errno 90] Message too long`.)
- `publish cost: N events, A/B ms (mean/worst, blocks the UI); M pauses, C/D ms (mean/worst,
blocks the producer)` — what *freezing a view* costs, and the line that attributes the stall
`frame cost:`'s `tick` max can only hint at. The two publishes are counted apart because they
stall **opposite threads**, and nothing else in the dump distinguishes them:
`publish_event()` runs on the producer with `mutex()` already held, so its cost lands on the UI
as a long tick; `publish_pause_view()` runs on the UI thread and takes the lock, so its cost
lands on the producer and can only surface as `gaps`. Read the event figure against `tick`'s max
and the pause figure against `gaps`. Both are **wide**: on the fast mock the event publish has
read **69, 89, 92 and 115 ms** and the pause publish **22 and 86 ms** across runs, so treat the
line as an order of magnitude and not a figure to compare a change against at the millisecond.
The event reading matters anyway because it is far above the ~40 ms the `frame cost:` entry above
quotes — treat that 40 as the old reading and this line as the authority.
It is also **the number to judge a second live ring against**, which is the one design question
it exists to answer: freezing a whole `BufferStore` copies nothing and inherits the pyramid a
`ChannelBuffer` already maintains incrementally, so both `worst` figures would go to **0** — at
the price of a second budget's worth of memory (README records that one live ring is why
the planned bytes are the whole memory cost) and a second per-sample write on the producer. What the line
says about that trade is that the stall is real but **costs no packets**: an A/B of the same run
with and without a pause read `gaps=0` both ways, the paused one with an 86 ms publish, so a
second ring would buy a smoother frame at a latch rather than recovered data. (An earlier run
reported `gaps=73` with a pause against 0 without, which is what made the stall worth measuring;
it has not reproduced and did not scale with the publish length — 79 gaps with the pyramid build
removed against 73 with it — so it was incidental load on one run. Do not quote it as a cost.)
- `[levels-probe] N measurement(s) over K channel(s), shares='...' -> W window(s), L level
walk(s)` — then the exit dump's `measure cost:` and `frame cost:` beside it. This is how a
pane's cost is read, and the three fields are the three factors: **`W` is the pane's price
for the window**, one fetch and one scan per *measured channel*; **`L` is its price for the
pane's shares**, one accumulator per distinct one, walked over the window's buckets; and the
window's samples are the multiplier on both, so a sweep at one scale says nothing about
another. Measured at the user's scale (15 s window, `--buffer-samples 100M` -> `stride=1`, 11.8 M
samples in the window = 184 k buckets, four signals, mock at ~0.5 MS/s per signal, llvmpipe),
with `UDPSCOPE_PROBE_LEVELS_CHANNELS` set:
* `50`, 1 channel -> `1 window, 1 level`, `measure cost: 226 recomputes, 6.58 ms each`, `raw=0.1%`,
`tick 1.99 ms` against an empty pane's **1.34**;
* `5,10,15,20`, 1 channel -> `4 levels`, **10.28 ms** each, `raw=0.4%`, `tick 2.38 ms`;
* the sixteen shares `5..80`, 1 channel -> `16 levels`, **24.55 ms** each, `raw=1.2%`, `tick 3.86 ms`;
* `5,10,15,20` over **4 channels** -> 4 windows and 4 levels, **35.69 ms** each, `raw=2.6%`,
`tick 4.58 ms`, and still `gaps=0 packets=30580` — the pane is a *tick* cost and not an
ingest one, which is the thing to say before blaming the producer.
`measure cost:`'s `each` is the pass and `covered`/`recomputes` is the mean window it ran
over (11.8 M samples here), so `each` is only comparable between runs at the same window and
the same pane; `raw` is the *signal's* share (the mock's `noise` channel reads `4.6%` against
its `sine`'s `0.1%` at the same window, because a level inside a bucket's own range has to be
walked sample by sample). A pass that reads **tens of ms** on a 15 s window with a handful of
measurements is the curve in
[invariants-measurements.md](invariants-measurements.md) and not a defect, and the same pane
at a 2 s window reads the 1.2--1.6 ms the pane's budget was fixed at. The guard for the walk
itself is the exit dump's `frame cost:`: sixteen shares on one channel read `24.55 ms` a pass
and `3.86 ms` of tick mean before `measure_accumulate_bucket()` was made `inline`, and
**17.25 ms** a pass after it, with every value in `measurements:` unchanged.
- `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%, worst
drawn-vs-quoted D px over R raw paints` plus a
`[ink] paint N <flags> mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=..
ink=[..] cover=..% x0=.. x_at=[..] bounds=[..] rel=[..] dx=.. drops=[..]
back=..` line per anomalous frame (stderr) — **`collapsed` and `staircase` must both be 0**,
and **`drawn-vs-quoted` must stay far under a pixel** (0.0000 is what a clean run reads).
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). `drawn-vs-quoted` is the other reading, and it is the
one that names a *defect of position* rather than of presence: at a raw level a gathered pair
**is** sample `first + k`, so the line differences where a sample is drawn
(`axis.min + gather[2k]`, the renderer's own reconstruction) against the x a readout quotes
for it (`x_at()`, exact f64) and reports the worst of them in pixels. That is the
gather-origin invariant in [invariants-rendering.md](invariants-rendering.md) read from the
screen's side; it was over a hundred pixels at a
deep zoom before the origin became the caller's. It is measured on the raw levels only — above
them a gathered point is a bucket's envelope and has no single sample to be off from — so `R`
is legitimately below `N`. **`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%. There was a `kept_fixed_x0` field
beside `kept` — the same filter with `x0()` hoisted out of the loop, since the renderer used to
re-read `x0()` per point and a ring-backed one advances with the producer. The origin is a
local of the renderer's now, so that failure mode is structurally impossible and the field is
gone; the `[ink]` walk and the renderer still agree because both add `axis.min`. 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] topics=14 index=1 figures=3 dir=/…/help/C/org.udpscope.app; shortcuts: app=14 plot=14 tables=28 ui=28 ui_accel=21 ui_icon=7 ui_bare=0; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — the manual is a Mallard set on disk now, not a GResource in the binary, so this is a **file-system reading**: `topics` is the `.page` files other than `index.page`, `index` must be 1, and `figures` counts the screenshots under `figures/`. **`topics=0 index=0` means the manual is missing** from `dir` (the source directory the build was configured from, `UDPSCOPE_HELP_DIR`) — the finding the old `parsed != pages` used to name. The manual itself opens in Yelp, another process, which the probe now launches and reports (the `[help] launch` line below). The `shortcuts:` numbers are the shortcut overview four ways.
`app` and `plot` are the rows in the C tables -- what the application actually *handles* -- and `tables`
is their sum. `ui`, `ui_accel`, `ui_icon` and `ui_bare` are what `src/ui/shortcuts.ui` *declares*, read
out of the resource with a markup parse rather than off the built widgets, because a tree walk counts
every row twice: `GtkShortcutsWindow` reaches each one from more than one place in its own tree.
**`ui` must equal `tables`** -- two lists of one thing, and a row in one but not the other is a gesture
that works and is not documented, or is documented and does nothing -- and **`ui_bare` must be zero**: a
row with no accelerator and no icon is a cell reading "No Shortcut". `ui_accel + ui_icon == ui`, so those
two also catch a row whose accelerator went missing. `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 the two dialogs built, presented and closed in one run as it is a state check: they are closed by **two different calls**, so a mismatched pair crashes or leaks here. One is a `GtkWindow` (the shortcut overview, `gtk_window_close()`/`::close-request`); the About dialog is an `AdwDialog` and closes with `adw_dialog_close()`, cleared on `::closed`. The manual is *not* one of them — it opens in Yelp, a separate process, so there is no window of ours to read back or close; the line below is the reading of that path instead.
- `[help] launch attempted=1 done=1 ok=1 error='' installed=/home/…/.local/share/help/C/org.udpscope.app` (stderr, `UDPSCOPE_PROBE_HELP_MS`, a moment after the line above) — the **launch of the manual**, the one part of the help path no other reading reaches: a successful launch leaves no window of ours, so nothing else can say whether `show_help()` did anything at all. `attempted=` is set at the call (`0` would be a probe that never got as far as calling it); `done=` says the asynchronous callback ran, and **`done=0` is the reading for a launch that never called back**; `ok=1` is the `help:` URI handed to a viewer, `ok=0` with the launcher's `error=` text is a launch that failed. **`ok` is not "a window appeared"**: Yelp is another process, and under a headless X server it cannot map one — the launch is *this* process's half (the URI reached a registered handler), which is exactly what the desktop does when a user presses F1, and what an absent handler or an uninstalled viewer breaks. `installed=` is `help_installed_path()`'s answer — the `$XDG_DATA_HOME`/`$XDG_DATA_DIRS` directory the document was found in, or `(not found)` — and it is what a failing `ok=0` should be read beside, because the launch error alone cannot tell a missing document from a missing handler. On a machine with the set installed by `cmake --install build --prefix ~/.local` and `yelp` present, the line reads `ok=1 … installed=~/.local/share/help/C/org.udpscope.app`. A **failure** leaves the launcher's `GtkAlertDialog` up on purpose — that is what a user gets, and the probe must not report a failed path as a silence.
- `[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.
- `[helpui] auto_started=1 visible=1 steps=6 step=0 (first run)` (stderr, `UDPSCOPE_PROBE_HELPUI_MS`) —
the help affordance layer end to end, in ten steps 300-400 ms apart (every reading is an
*allocation*, and a step that read its own predecessor's geometry would read a rectangle from before
the frame that placed it). The line above is the **first-run auto-start**, read before the probe
touches anything: `auto_started=1 visible=1` is the tour that started itself, and the parenthetical is
`settings.tour_seen` as the process found it — `tour_seen already set` with `auto=0` is the *second*
run against the same config directory, which is the assertion that the flag persisted. Then:
- `[helpui] contextual on: hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} hint=12x12` —
the twelve "(i)" affordances, by id, and the size the first visible one was allocated. Eleven are
the window's own surfaces (four of them a specific control's row rather than a heading — see the
`help layer:` entry) and the twelfth is `math`, which is present only while the math editor
window is open; it reads 11 with the editor shut. **On a fresh config this reads `hints=0 ids={}`
and `hint=0x0`**, because the layer is off by default; the example is the state after the row below
was switched on, and a fresh run's `hints=0` beside a `settings.toml` with no `contextual_help`
key is the default being read rather than assumed. **`hint=12x12` is the "much tinier" assertion**
— the default menu-button mark is 34x34 (see `help layer:`), and anything near that is the class
not reaching the glyph's own node.
- `[helpui] main view: rows=7 help_row=1` and one `[helpui] entry N '<label>' accel='<key>' order=O`
per row of the **Help view** — `entry 0 'Back'` with no accelerator and then the four the submenu
promises: `entry 1 'Keyboard shortcuts' accel='Ctrl+/'`, `entry 2 'Documentation' accel='F1'`,
`entry 3 'Guided tour' accel='Ctrl+T'`, `entry 4 'Enable contextual help' accel=''`. **`order=1` on
every row** is the assertion; the view is entered by emitting `::clicked` on the menu's own `Help`
row, so the row's handler is exercised and not just the stack.
- `[helpui] help view: rows=5 expected=5 rows_bad=0 back=1 toggle=1 entries='…'` — the summary, with
`toggle=` being the contextual-help row's check, which *is* `settings.contextual_help`. It reads `0`
on a fresh config (the default) and `1` on one where the setting is on; the probe's next step drives
it both ways, which is why the example here is the `1` side.
- `[helpui] toggle: off -> hints=0 ids={}; on -> hints=12 ids={…}; setting=on` — the toggle's effect
read both ways through the row itself. **`hints=0` with `off` is the assertion**: off means the
affordances are *gone*, not greyed, so anything else is a row that hid its buttons instead of
removing them. The count comes back to the same twelve, which is the other half — a toggle that
removed and could not restore is a one-way switch.
- `[helpui] tour started from the probe: visible=1 steps=6 step=0` — the tour started the way
`Ctrl+T` starts it. `steps=6` is the plan: the table's six stops (plot, badges, trigger, dock,
signals, status) whose surface is on screen or can be opened into view. A stop that names a
slide-over page is always kept (the `on_page` hook opens it whatever the dock is doing), a target
that is mapped is kept, and a stop in a *collapsed dock* is dropped (`gtk_widget_get_mapped()`; see
the surface bullets in [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)).
- `[helpui] step=1/6 visible=1 slideover=none spot=[x,y WxH] raw=[…] target=[…] contains=1 inside=I card=[…] clear=C`
— **in window coordinates**, the frame a screenshot is cropped in, with the slide-over's open page
(`none`, or a page id) beside it; see `[toursurface]` below. `raw=` is the target's own rectangle,
unclipped; `target=` is the part of it that is *on screen*, which the probe computes **itself** by
intersecting `raw` with the bounds of every scrolled window above the target (not through
`tour_target_bounds()`, which the spotlight is drawn from -- reading the spotlight against its own helper
would only ever agree with itself). A form taller than the panel's scroller on a short window reads
`raw` taller than `target`, and is spotlit down to the scroller's edge and no further; the trigger stop at
the documented window reads `raw=[279,143 343x474] target=[279,143 343x253]`.
**`contains` must be 1** on every step: the spotlight covers the visible part (the part grown three
pixels), or is empty when none of the target is visible (a target scrolled out of its scroller gets no
hole at all, and not the hole of the frame before). **`inside` must not be 0**: it is the spotlight
staying within the nearest scrolled window above the target (grown by the same three pixels); `-1` is a
target with no scroller above it (the plot, the badge bar, the dock, the status line), `1` the trigger
form and the signal list. `inside=0` is a spotlight that spills out of its scroller over whatever is
under it. `clear` is the card missing the target, and it is 1 wherever the window has room for the
card beside or above/below the hole — at the documented run's window (see `help figures:` below)
the plot's step is *the whole content*, because at 756 px the split view overlays the dock
rather than putting it beside the plot, so `clear=0` there is the geometry and not a placement
bug. The badges step reads `clear=1`.
- `[helpui] after Next: step=2/6 visible=1`, `[helpui] after Back: step=1/6 visible=1`,
`[helpui] after Skip: visible=0 dims_hidden=1 spot_hidden=1 card_hidden=1 controllers=12 (was 12)
click_gestures=2` — the three controls. **`controllers=` equal to `(was N)` is the "no lingering
input grab" reading**: a tour of ordinary overlay widgets takes no grab at all, and a count that
grew would be one somebody added.
- `[helpui] layer: …` — `capture_help_state()`'s line at the end of the run, the same text the exit
dump prints as `help layer:`.
- `[helpui] figures locked: …` and `[helpui] figures config: …` — `help figures:` read in both of the
trigger pane's modes, by clicking its [edit] button. This exists because one of the help's figures
is the *locked* pane's summary grid (`trigger_summary`), which in configuration mode is not on
screen at all and reads `-1,-1,0,0`; the two lines give the geometry agent both states' numbers
rather than leaving them to find the mode. The probe opens the slide-over's `trigger` page for these
legs (and for the collapsed-search leg after them) and closes it at the end: with the panel shut
every `trigger_*` reads `-1,-1,0,0`. `trigger_panel`'s height differs between them (532 px in
configuration mode against 72 locked — the body is replaced by the summary), which is the check
that the mode really moved.
- `[helpui] collapsed search: expanded=0 mapped=1 mapped_header=1 hint=12x12` (the last step, in
configuration mode, after the figures line) — the search block's "(i)" read on the expander's
**header** with the block **collapsed**. **`expanded=0 mapped=1` is the assertion**: a hint on the
expander's own *child* is not in the widget-parent chain while the block is collapsed and would
read `mapped=0` (and `0x0`), which is exactly why the header box exists (`search_head` in
`src/ui/trigger-panel.ui`) and why the hint joins it as its last child. `mapped_header=` is the
header box beside it, and `hint=WxH` is the same 12x12 the layer's own line reads.
- `[toursurface] …` (stderr, `UDPSCOPE_PROBE_TOUR_SURFACE_MS`) -- a stop that opens a surface it is not
on, entered from the state it is not in. The probe opens the `trigger` slide-over page, starts the
window's tour from there and walks the whole table a stop per tick (400 ms, past the panel's 120 ms
slide); the `on_page` hook is what opens the page for the trigger stop and closes the panel for every
stop that names none. The lines:
- `[toursurface] before: slideover=none signal_list_mapped=0 running=1` -- the state a first run starts
in (panel closed, dock on screen; the signal list is a slide-over page, so unmapped); the auto-started walk is dismissed.
- `[toursurface] on trigger: slideover=trigger trigger_body_mapped=1 side_box_mapped=1` -- a frame after
the page was opened: the dock is still on screen under the panel (they are not alternatives).
- `[toursurface] tour: visible=1 steps=6 step=0 trigger_stop=1 signals_stop=1` -- the plan: **six
stops, the trigger stop and the signals stop both in it**.
- `[toursurface] step=N/6 title='…' slideover=<page> target_mapped=D` -- one per stop, in order: *The
plot*, *The badges*, *The trigger*, *The dock*, *The signals*, *The status line*. **Step 1 reads
`slideover=none` although the probe started with the panel open** (a stop with no page closes it),
**step 3 reads `slideover=trigger`, step 4 reads `slideover=none` again, and step 5 (the signals)
reads `slideover=signals`**; every `target_mapped` is 1.
- `[toursurface] stop=N/6 visible=1 slideover=… spot=[…] raw=[…] target=[…] contains=1 inside=I card=[…] clear=C` -- the
`tour_geometry_line()` the helpui probe uses, one per stop, read a frame after the stop was entered
(the spotlight is withheld until the panel has finished sliding in, `page_settled`): **`contains=1` on
every stop is the assertion** that the spotlight is on the target, and `inside` must not be 0 (both as
in the `[helpui]` entry above, where `raw`, `target` and `inside` are explained). `clear=0` is read as geometry, not
as a placement bug, on the plot stop (the target is the whole content) and on the trigger stop at the
documented 756 px window (the panel is 360 px of it, so the card has no side wide enough and goes flush
left over the plot, overlapping the panel's edge by a few pixels), and on the `signals` stop for the same
reason (the dock beside it is 280 px and the card has no side wide enough).
- `[toursurface] finished: running=0 slideover=none` -- the last Next ended the walk and **the panel is
closed with it**.
- `[toursurface] restarted at trigger: running=1 slideover=trigger` then
`[toursurface] skipped: running=0 slideover=none` -- the walk parked on the trigger stop and dismissed
(the Skip/Esc route): **`slideover=none` after the dismissal** is the tour not leaving a panel behind.
- `[toursurface] collapsed: side_box_mapped=0 steps=5 trigger_stop=1 signals_stop=1 side_stop=0` -- the
filter with the dock put away (`sidebar_wanted` off; `update_side_visibility()` re-asserts it, so this
is how a probe puts the dock away for good): the dock's own stop is dropped, **and the trigger and
signals stops are kept** because the slide-over does not depend on the dock -- five stops remain (plot,
badges, trigger, signals, status).
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: page=1 dock_still=1 closed=1 | g: page=1 dock_still=1 closed=1 | … | esc: opened=1 handled=1 closed=1 idle_handled=0 | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** (`m` and `g` are not pairs: each opens a slide-over page, `measure` and `signals`, and a second press closes it), **`refused` must be 1**, and the `esc:` reading must be `opened=1 handled=1 closed=1 idle_handled=0`.
The `m`/`g` readings are `page=P dock_still=D closed=C`: **all three must be 1 for both keys** -- `P` the page the key opens (`measure` for `m`, `signals` for `g`) is the slide-over's open page after the first press, `D` the dock scroller's vertical adjustment did not move (the dock holds only the readouts, so a key must not scroll it), and `C` a second press closes the page. This is what says `m` opens the measurements' page rather than scrolling the dock to a section (the old `m: dock 0->N (cursors_y=… at_cursors=1)` reading is gone with the dock sections), and that neither key toggles a pane. `m` does *not* turn the measurements on or off: the header Cursors toggle only enables cursors, and `m` only opens the page. `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. **The `esc:` reading goes through `on_app_key_pressed()` with `GDK_KEY_Escape`**, the handler the window's key controller calls, and not through `slideover_escape()`: the probe opens the `trigger` slide-over page (`opened=1`), sends the key (`handled=1`: it was consumed, and `closed=1`: the page is gone), then sends it again with nothing open (`idle_handled=0`: Escape is left alone when there is nothing for it to close, so a dialog or popover under it still gets it). `handled=0` or `closed=0` is the slide-over's Escape never reaching the panel from the window's key path -- a handler that returns before the slide-over test, or a controller in the wrong phase -- which the `[slideover] esc_closes=1` reading (a direct call to `slideover_escape()`) cannot see. 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).
- `[color] '<name>' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `<span foreground="…">` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit.
- `[reseq-probe] reseq=N late=N stale=N overflows=N holes=N hole_slots=N backfilled=N
gaps_refused=N open_holes=N bands=N bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D`
(stderr,
`UDPSCOPE_PROBE_RESEQ_MS`) —
the resequencing path's mid-run state; the measured readings and the per-case command are in
[verification.md](verification.md). Nine things to read carefully:
1. **`backfilled` and `hole_slots` are not comparable directly.** `hole_slots` counts
*time-ring* slots once per hole; `backfilled` counts *value-ring* slots repaired and
therefore sums over every non-math channel. On a stream with 4 non-math channels they
stand a factor of 4 apart. `hole_slots=4000 backfilled=15600` in the `late` case is
healthy, not an error (the shortfall is the one hole whose late packet is still in
flight); `backfilled` well below `4 × hole_slots` would mean some channels went
unrepaired.
2. **`nan_slots=0` beside a large `holes` is healthy, not a contradiction.** A hole is
retired from the table only when its slots age out of the ring
(`ScopeModel::forget_holes_before()`) or a re-plan drops it, *never* on repair. So the
`late` case reads `holes=40 nan_slots=100`: 39 of the 40 holes were fully repaired and all
40 entries stay in the table, the 100 remaining placeholders being the one packet still in
flight. What *would* be a defect is `nan_slots > 0` with `bands_repaired == bands`, which
would mean a band claimed data was recovered while placeholders remained. The
discriminator is `bands_repaired`, not `holes`.
3. **`bands` is the visible spans, `open_holes` the whole table.** `bands` counts what the
plot is actually shading in the visible window; `open_holes` counts every live hole entry
whether or not it is in view. `bands` below `open_holes` is normal — a hole that has
scrolled out of the visible window is dropped by `build_invalid_spans()` rather than
pinned to the edge. `bands` *above* `open_holes` would name a stale span left on a plot
after its hole was retired. Neither is comparable with `holes`, which is cumulative: a
re-plan clears the table and `kMaxHoles` caps it at 64, so the re-plan case reads
`holes=315 open_holes=64`.
4. **`bands_repaired` is the faint tier.** A hole whose late packets have all arrived is
still drawn, in `Style::invalid_repaired_fill` rather than `Style::invalid_fill`, so the
display distinguishes "there is no data here" from "there was none when this window was
first drawn". The three states are: solid while the hole still lacks packets, faint once
`Hole::repaired()` is true, absent once the slots age out and `forget_holes_before()`
drops it. `bands_repaired == bands` with `nan_slots=0` is the fully recovered reading;
`bands_repaired=0` with a non-zero `backfilled` would mean the repair never reached the
display.
5. **`trigger_in_hole` must be 0, always.** A `1` means the trigger engine was fed a
reserved span's samples. The engine is specifically not fed them; a NaN comparison is
false anyway, but the real reason is that feeding them would advance the hold-off clock
across time nothing was sampled in.
6. **The discriminator for whether a reservation sized the hole right is the exit dump's
per-channel `stored=` line, not `frames=`.** `frames` counts delivered packets and is
blind to the slots a reservation pushed on their behalf. `stored` reflects what the ring
actually holds, so a reservation that mismeasured the hole shows up there and nowhere
else.
7. **`stale` is 0 by construction whenever resequencing is enabled**, which is every default
run. The client increments it only on the `reorder_window_s <= 0` branch, where it names
the pre-resequencing behaviour: a packet that did not advance the counter was dropped.
With the window open such a packet is delivered flagged instead and counted in `late`, so
a reading of 0 here is the feature working and not a dead counter. The only way to see it
move is to turn resequencing off.
8. **`overflows` is how an undersized reorder ring reads.** It counts the times a packet
needed a slot in the client's 64-slot / 4 MB reorder ring and found none, so the gap was
committed early to make room — the window those packets got was shorter than
`reorder_window_s` asked for. Nothing is dropped on that path (the arriving packet is held
after the commit, or delivered in order with the counters it skipped reported as `lost`),
which is the point: a dropped packet would leave a hole nothing could ever backfill. It
reads 0 at the four cases' 10 kS/s and non-zero at the re-plan case's 100 kS/s, where
`--reorder-every 10 --reorder-delay-ms 50` keeps about 50 packets in flight against 64
slots. A non-zero reading beside a healthy `backfilled` is a note about the ring's size,
not a defect.
9. **`gaps_refused` counts reservations the wire contradicted, and it is the field that reads
a counter which is not counting packets.** A gap is booked only if it passes two tests
(`Acquisition::gap_plausible()`): the *arrival clock* has to corroborate the elapsed time
(`missing` packets would have taken `missing` frame intervals; anything under half of that
is refused) and the *ring* has to hold the hole (`missing * batch_` samples must not lap
`time_capacity`). The shape this exists for is a device whose counter advances by samples,
or by frames of an inner layer: every datagram reports a forward jump while the wire
delivers on time, so *every* frame reserves -- and, with `relative_time`, every frame
books NaN slots, which is a band over the whole window with the samples washed out and no
packet ever missing to backfill it. `tools/mock_udpstreamer.py --counter-step N` produces
exactly that on an intact link. The reading's two sides, both measured with
`--relative-time` and no session: a *fixed* run reads
`holes=1 hole_slots=900 gaps_refused=419 open_holes=0 bands=0 nan_slots=0 scanned=21700`
with the data channels full and a once-per-connection stderr warning naming the numbers,
while the unfixed one read `holes=420 hole_slots=378000 open_holes=64 bands=64
bands_repaired=0 nan_slots=198000 scanned=220000` -- `nan_slots` at 90% of `scanned`, which
is the whole-window band. The refusal is **not relative-mode-only**: the same command without
`--relative-time` reads `holes=1 gaps_refused=419 nan_slots=0 scanned=21800`, because a
counter that lies is not a mode. On the honest shapes
(`--drop-every`, `--drop-burst 200`, `--reorder-delay-ms`) this field must stay **0**:
a non-zero `gaps_refused` there means the arrival test is refusing real loss, and the cost
is a gap with no shading (never lost data).
- `[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 (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`, `disarmed` — a word added to it must be **no wider than the widest already there**, which is what let `connecting` join at ten characters beside `event held`; the dead-port glance reads `max_glance_chars=51` against a live run's 54), the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the two fields a *state* adds are both elapsed times — trigger mode's `last event … ago` and a pause's `for <elapsed>` — which step units rather than growing digits (`format_elapsed()`). A **paused** glance is not in the dump at all: it is read by `[pause-probe]`'s `glance=` field, since the dump's own line is taken live. `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.
## `[condition-probe]`
`UDPSCOPE_PROBE_CONDITION_MS`, with the mock's `--glitch-at`/`--glitch-width` (legs 0 and 1) or
`--quiet-at` (leg 3).
Four legs, selected by `UDPSCOPE_PROBE_CONDITION_SHAPE`. Every leg prints one line of the form:
```
[condition-probe] type=T cmp=C width=W t0=T0 measured=M at0=A earlier=E level2=L2 mode=D
```
Measured readings (`--rate 10000 --freq 5`, each on a fresh port). Shape 0's `level2=` and
`mode=` fields are from the `[search-probe]` run in `.superpowers/sdd/progress.md`; shapes 2 and
3 were re-measured with those fields present. **Shape 1's `level2=0.500000 mode=2` alone is read
from the code** rather than from a run (`condition_probe_arm` always sets `level2` to `0.5`, and
the probe now reports only once the mode is `Held=2`):
```
shape=0 type=1 cmp=0 width=0.010000 t0=1.710000 measured=0.004000 at0=nan earlier=nan level2=0.500000 mode=2
shape=1 type=1 cmp=1 width=0.010000 t0=2.160100 measured=0.200000 at0=nan earlier=nan level2=0.500000 mode=2
shape=2 type=2 cmp=0 width=0.010000 t0=1.706700 measured=nan at0=-0.500907 earlier=-0.498185 level2=0.500000 mode=2
shape=3 type=3 cmp=0 width=0.010000 t0=4.120200 measured=0.300100 at0=nan earlier=nan level2=0.500000 mode=2
```
`measured` is the reading, not a phase. It is the pulse width read back from the held snapshot's
own stamps, which are edge-relative: the snapshot is the ground truth, not the mock's absolute
clock.
**`t0` is not an assertion.** It is a stream stamp, and the stream clock is rebased by whichever
rebuild last dropped history, so the same leg reads a different `t0` on every run — leg 2 read
`1.7103`, `1.6975` and `1.8719` on three consecutive runs, and leg 3 read `2.6552` against a
`--quiet-at 4.0` stream. Read `measured`, `at0`/`earlier` and `mode`; read `t0` only as
*something* latched. A leg that is genuinely not working says so in `measured`/`at0`, or does not
latch at all.
Four more things are worth knowing before calling a reading wrong:
- **Leg 0**: `measured=0.004` is the injected glitch width (here the search-probe run used
`--glitch-width 0.004`; the earlier standalone probe run read `0.002` with `--glitch-width 0.002`).
A `measured` near the signal's own half-period means the width qualifier did not qualify — the
trigger caught a normal edge instead of the runt.
- **Leg 1**: `measured=0.200` is an ordinary square half-period; every square pulse satisfies
`> 0.010`. **Use a 1.0 s window with 50 % pre** so the opening edge of a 0.2 s pulse lies within
the pre window.
- **Leg 2**: `at0` must lie **outside** `[-0.5, +0.5]` and `earlier` **inside** it — those are the
two sample values around the band crossing. The probe prints `nan` for any field a leg cannot
measure. **This leg uses `sine`**, not `square`: the square is exactly ±1 and has no sample
inside the `[-0.5, +0.5]` band, so a window condition on the square can never fire. This leg
uses a **0.2 s window with 10 % pre** — short enough to capture the crossing, because the event
is the band exit, which lands at x = 0 in the held snapshot.
- **Leg 3**: `measured=0.300` is the dropout timeout. **This leg also uses `sine`**. A timeout of
0.050 s against a sine crossing zero every 0.1 s latches on a healthy signal, so use 0.300 s
or longer; **use a 1.0 s window with 50 % pre** so the last crossing before the outage lies
within the pre window. It needs `--quiet-at`: with `--quiet-at -1` the leg correctly never
latches, which is the check that the dropout is not firing on the rebuild's own gap.
The channel-selection line `[condition-probe] <name>=<index> of <N> channels` is printed before
the trigger fires; if the channel is not found this reads `-1` and the probe fails immediately.
There are two failure lines, and they name different defects:
- `no latch after N polls` — the condition never fired: a wrong shape, a missing mock flag, or
the wrong signal selected.
- `latched but never held` — the edge was accepted but the post window never filled, so there is
no snapshot to read. The report deliberately waits for `Held`: a finite `trigger_time()` only
means the edge was *accepted*, and every field is read from `display_snapshot()`, whose stamps
span x = 0 only once the event is held. Reporting on the latch alone printed `nan` for legs
that were working.
## `[stats-probe]`
`UDPSCOPE_PROBE_STATS_MS`. Needs `UDPSCOPE_ENABLE_ALL_MS` ahead of it and
`UDPSCOPE_PROBE_MEASURE_MS` to add measurements. Run against the fast mock
(`--rate 1300000 --batch 2000 --freq 1`) for readable figures.
Three phases, one line each. Every line carries `phase=`, `on=` (the toggle), `passes=` (the
pane's recompute count since start) and one `| n=… mean=… sd=… lo=… hi=…` group per
measurement. Phase 1 adds `dn=` per group.
Measured readings (fast mock, 1 Hz sine, with
`UDPSCOPE_ENABLE_ALL_MS=800 UDPSCOPE_PROBE_MEASURE_MS=1500 UDPSCOPE_PROBE_STATS_MS=2500`, the
four measurements the measure probe adds -- average, top, peak to peak, amplitude):
```
phase=0 on=1 passes=43 | n=28 mean=0.0826556 sd=0.085427 lo=-0.0314722 hi=0.208469 | n=28 mean=0.957905 sd=0.00142833 lo=0.951967 hi=0.962348 | n=28 mean=2 sd=0 lo=2 hi=2 | n=28 mean=1.91652 sd=0.00300419 lo=1.90993 hi=1.92432
phase=1 … dn=1 (on all four measurements)
phase=2 … n=0 mean=0 sd=0 lo=0 hi=0 (all four)
```
- `phase=0` — the running count. **`n` must be in the low tens**, not thousands: a count in the
thousands means the accumulator is fed per sample, not per recompute. `mean=2 sd=0 lo=2 hi=2` for
the `peak to peak` measurement on a unit sine is the assertion on the shared pass: the extremes of
a sine are exactly ±1 whatever the window does, so anything else is the scan, not the reading.
The two flat-level types are the other half and their figure is the **`sd`**, not the mean: `top`
reads `mean=0.957905 sd=0.00142833` and `amplitude` `mean=1.91652 sd=0.0030` over windows that
scroll under them, and a `top` that wanders by tenths is a level that follows the window's
alignment instead of a *value* band (see the flat-level invariant in
[invariants-measurements.md](invariants-measurements.md); the mean itself is window-dependent on a
sine, which has no plateau -- the densest band sits at its peak and moves with how much of the
peak a window holds). `sd` must be 0 exactly when `n < 2`, and `lo`/`hi` must bracket `mean`.
- `phase=1` — after a two-second pause. **`dn` must be 0 or 1**: a pinned window recomputes once
when it settles and then never again. A `dn` near 120 is the second defect — accumulating per
frame, which adds a hundred-odd observations of the identical window and collapses `sd` towards
zero. Note that `n` is **not** reset by the pause or the resume; `passes` continues growing.
- `phase=2` — after Reset. **`n` must be 0 or 1** (0 if the report ran before the next recompute,
1 if after); anything larger means Reset cleared nothing.
## `[bandavg-probe]`
`UDPSCOPE_PROBE_BANDAVG_MS`. Needs `UDPSCOPE_ENABLE_ALL_MS` ahead of it, and it must **not** be
scheduled with `UDPSCOPE_PROBE_MEASURE_MS` or `UDPSCOPE_PROBE_MEASURES_MS`: all three add
measurements to the same pane, and the later one would report the earlier one's rows as its own.
Run it against the fast mock (`--rate 1300000 --batch 1000 --freq 1`) **at a 2 s window** — the
figures below are shares of the range, and a window that holds a fifth of a cycle is a different
question, not a worse answer. Its own sequence is about 12 s, so `UDPSCOPE_EXIT_AFTER_MS=17000`.
It adds seven measurements, one per shape whose band mean can be computed by hand, and every one of
them is a *mean over a side or a band* rather than a level. `--square-harmonics 30` and
`--square-harmonics 0` are two runs — the second is the ideal step, where the band holds nothing:
| measurement | shape | reading | why that shape |
|---|---|---|---|
| `above 50` | 1 Hz unit sine | `0.636620` | 2/pi, the figure the old `top` documented |
| `below 50` | 1 Hz unit sine | `-0.636620` | its other side: exactly `-above 50` |
| `between 50/75` | 1 Hz unit sine | `0.255873` | `3(2-sqrt 3)/pi`, and neither 0 (the window's mean) nor 0.5 (the band's edge) |
| `above 50` | `square`, 30 harmonics / `0` | `0.986496` / `1.000000` | the reading that commit recorded as the *old* `top`; the ideal step has no ring to put inside it |
| `between 50/75` | `square`, 30 harmonics / `0` | `0.302744` / `--` | the two edges' transitions the band holds; **every** sample is exactly +/-1 on the ideal step, so it holds nothing |
| `between 50/75` | `ramp` | `0.250000` | uniform density: a band's mean *is* that band's centre |
| `above 100` | `ramp` | `--` | a share at the top of the range has no sample above it |
Four line kinds, and the reading is the third:
```
[bandavg-probe] waiting stored=2108000 of 2600000 slots for a 2.000 s window
[bandavg-probe] readings window channel=0 lo=-1.000000 hi=1.000000 samples=2600000 levels=2
[bandavg-probe] readings above 'sine' shares=50% -> ok 0.636620262 text=[636.62 mV]
[bandavg-probe] edit opened band_row=1 shares=50.0/75.0 (as stored)
[bandavg-probe] after delete measures=6 -> 5 (want one fewer)
```
- `waiting` — the probe reads nothing until the ring holds a **full window**: a share of a *partial*
window is a different number (and one that keeps moving while the ring fills), and the mock
delivers about 0.5 MS/s of the 1.3 MS/s it declares, so a ring the probe has just re-enabled needs
seconds of wall clock before a 2 s window is there. The wait is read off the store
(`stored_slots()` against `window / dt`), not guessed from a stopwatch. **A report taken before it
finishes is the one reading this probe can produce that is not the share's** — the settled lines
are the ones to quote.
- `window` — the window each reading was taken over, per channel: `lo`/`hi` are that window's own
extremes (so `lo`/`hi` of the split are visible here), `samples` is what it holds, and `levels`
is how many level accumulators the pane registered for that channel. **`between` on the sine makes
`levels=2` where `top`/`between` alone would make one**: a band is two entries of the level list,
and a second measurement of the same band does not add a third.
- the value line — the pane's own numbers, nine significant digits and then the row's formatted text
(`text=`), with the shares it was given. `-> invalid` with `text=[--]` is the nothing-to-measure
reading (the last two rows of the table). The value carries the measurement's **unit**; the three
new types are signal-valued like `top`/`average`, so a sine reads `mV` and a quantity with no unit
reads the bare number.
- `edit opened` — the dialog's second-share row for a `between`: **`band_row` must be 1** (only
`between` offers it) and `shares` must be the pair that was *stored*, read back out of the two
spin fields. The probe then sets them to `50/90` and saves, and the next report is the proof the
field is wired: the sine's `between` reads `0.431361697` (`0.8 / (2 asin 0.8)`), where neither of
its sides would.
- `after delete` — the cycle's last leg through the same dialog: **one fewer** measurement, process
alive. The two shares are the thing a delete could drop on the floor.
- The `[measure-trace]` line the probe starts carries the same five readings every 250 ms, which is
where the *sixth* decimal's spread is visible (see the band-average invariant in
[invariants-measurements.md](invariants-measurements.md)): `above 50` on the sine reads
`0.636620` on almost every line and `0.636621` on a few, `above 50` on the square `0.986496` on
most and `0.986495` on the rest. **A line's `--` on a *lagging* trace is the trace's, not the
reading's** — the pane is refreshed on a cadence, so quote an earlier line beside its own `view=`.
## `[measurepin-probe]`
`UDPSCOPE_PROBE_MEASUREPIN_MS`. The reading for **what window a measurement describes**: on a still
frame the pane is about the *frame*, so a pan or a zoom changes no number, and on a live view the
numbers still follow the window. It installs `measure_probe`'s own four types on the first *channel*
(`average`, `top`, `peak to peak`, `amplitude`), arms the trigger in **single** mode, and runs three
legs — each one reading the pane before and after a **gesture** written on the row's own axis, which
is what a drag, a wheel or a box zoom writes. Nothing here takes the window-control path: that one
*calls off* the pin (`set_time_window()` clears `pinned_`), so measuring it would test a different
question.
Run it against the fast mock, and mind the datagram limit: `--batch 2000` is 80 kB of payload for the
mock's five signals and dies with `OSError: [Errno 90] Message too long`, so the command here is
`--rate 1300000 --batch 1500 --freq 1`. Its sequence is about 12 s: it waits for a latch, then
~1.5 s per leg, so `UDPSCOPE_EXIT_AFTER_MS=20000` covers it with room.
```
[measurepin-probe] 4 measurements on 'sine', trigger single
[measurepin-probe] wait mode=waiting pinned=0 at_pin=0 for=[-1.419616..0.580384] pin=[0.000000..1.000000] passes=14
[measurepin-probe] wait mode=capturing pinned=1 at_pin=0 for=[-1.419616..0.580384] pin=[0.000000..1.000000] passes=14
[measurepin-probe] wait mode=capturing pinned=1 at_pin=1 for=[0.881155..2.881155] pin=[0.881155..2.881155] passes=15
[measurepin-probe] wait mode=held pinned=1 at_pin=1 for=[0.881155..2.881155] pin=[0.881155..2.881155] passes=15
[measurepin-probe] held/before pinned=1 mode=held view=[-1.000000..1.000000] pin=[-1.000000..1.000000] for=[0.881155..2.881155] passes=16 ev=2600001 evx=[-1.000000..1.000000] average=0.000000 top=0.957975 peak to peak=2.000000 amplitude=1.915950
[measurepin-probe] held/after pinned=1 mode=held view=[-0.250000..0.250000] pin=[-1.000000..1.000000] for=[0.881155..2.881155] passes=16 ev=2600001 evx=[-1.000000..1.000000] average=0.000000 top=0.957975 peak to peak=2.000000 amplitude=1.915950 same=4/4 for_same=1 pass_delta=0 span_before=2.000000 span_after=2.000000 follows=0
[measurepin-probe] paused/before pinned=1 mode=paused view=[-0.250000..0.250000] pin=[-0.250000..0.250000] for=[1.631155..2.131155] passes=17 ev=650001 evx=[-0.250000..0.250000] average=0.000003 top=0.957944 peak to peak=2.000000 amplitude=1.915906
[measurepin-probe] paused/after pinned=1 mode=paused view=[-0.062500..0.062500] pin=[-0.250000..0.250000] for=[1.631155..2.131155] passes=17 ev=650001 evx=[-0.250000..0.250000] average=0.000003 top=0.957944 peak to peak=2.000000 amplitude=1.915906 same=4/4 for_same=1 pass_delta=0 span_before=0.500000 span_after=0.500000 follows=0
[measurepin-probe] live/before pinned=0 mode=live view=[-2.000000..0.000000] pin=[off] for=[1.666922..3.666922] passes=22 ev=0 evx=[0.000000..0.000000] average=-0.000000 top=0.957975 peak to peak=2.000000 amplitude=1.915948
[measurepin-probe] live/after pinned=0 mode=live view=[-1.062500..-0.937500] pin=[off] for=[2.746345..2.871345] passes=24 ev=0 evx=[0.000000..0.000000] average=-0.427665 top=-0.083224 peak to peak=0.687725 amplitude=0.644726 same=0/4 for_same=0 pass_delta=2 span_before=2.000000 span_after=0.125000 follows=1
```
- `view`/`pin`/`for`/`passes`/`ev`/`evx` are the state: the axis, the pin's own extent, the window
the printed values were computed over, the pane's pass count, and the **bound event series'** own
sample count and extent — the frame the numbers are about. `ev=0` while a view is pinned is an
*empty* frozen frame (every value `--`), which is the reading that named the pause ordering defect
(`on_pause_toggled()` read its display offset after setting the pause state).
- `same=K/4` — the values that are **bit-identical** before and after the gesture. On a pinned view
it must be `4/4` with `for_same=1` and `pass_delta=0`: the frame's samples did not change, so
neither may a number, and the pane may not even spend a recompute on the gesture. `for_same` is
the app's own 2 %-of-span criterion (the rule that decides a recompute is not owed), not equality:
the pin is re-derived from the snapshot's stamps every tick and the pane keeps the window its last
pass used, so the two differ in the last digits without anything being owed.
- `follows` — the live leg's assertion, and the case the still-frame rule must *not* swallow:
`for_same=0`, a positive `pass_delta` and the results window's span narrowed from the axis'
2.000000 to 0.125000, with `average`, `top` and `peak to peak` all moving. `follows=1` with `same=4/4` on a
pinned leg, or `follows=1` while pinned at all, is the defect in one direction or the other.
- The `before`/`after` pair is taken only once the pinned frame has **settled** (a `wait` line per
*change* of the state being waited on until then, then nothing): the frames that bind a snapshot or freeze a pause re-state the
pin from the request and then from the snapshot's own stamps, and a gesture made in one of those
frames is reverted by that re-statement — the `[zoom-probe]`'s own gesture sweep loses its first
two steps to it, which is visible in its `ges/x1`, `ges/x2` lines. A `gave up waiting after N
polls` line means no latch (or no pin) was ever reached, and is a reading about the stream.
## `[gate-probe]`
`UDPSCOPE_PROBE_GATE_MS`. The reading for **what the per-measurement gate does**: the gated
measurement reads the shared band, the ungated one the whole window, and the band is
*relative* — fixed on screen while the data flows under it, live or held. Measured output
(port 8140, `--rate 100000 --batch 100 --freq 1`):
```
[gate-probe] on: gate=[-0.300000..-0.100000] view=[-2.000000..0.000000] s0=2.022990 for=[0.022990..2.022990] band=1 placed=1 plain=1 split=1 gwin=1 ungated=-1.36e-17 V gated=355.31 mV
[gate-probe] still: gate=[-0.300000..-0.100000] s0=[2.022990->2.852990] still=1 s0_moved=1
```
- `gate=` is the band, in **display** x; `view=` the axis; `s0=` the stream time of the
display's x = 0 (`stream_at_x0()`); `for=` the pane's own window (the ungated one), in
stream seconds. The probe measures only once the ring is older than the window (`s0 >= 2`
here): the `plain=` assertion is exact zero because a whole-seconds window of the 1 Hz sine
holds whole periods, and a young ring would clip it to a fraction of a cycle.
- `band=1` — one gated measurement was enough to show the band, because the band is shared by
every gated measurement and shown exactly while one exists. `placed=1` — the row's plot
region holds exactly the bounds the `RowSet` does: the drawing and the state are one pair of
numbers, not two.
- `plain=1` — the ungated average is zero (`-1.36e-17 V`): it read the whole window.
`split=1` — the gated average differs (`355.31 mV`, wherever in its period the stream
happened to be): it read the band. The pair is the per-measurement split on one signal.
- `gwin=1` — the pass booked `App::measure_gate_window` as the band moved onto the stream's
clock, within 5 % of the band (the probe's `s0` read and the pass's can straddle one
production interval). That is the phase-free statement of *which* slice the gated walk
read; the slice's mean value itself depends on where in its period the stream is, which is
why the assertion is on the window and not on the value.
- The second line is the **relative** assertion, one second later: `still=1` — the band
exactly where it was put — and `s0_moved=1` — the stream's clock moved under it.
`still=0` is a gesture echo adopted outside a drag; `s0_moved=0` on a live stream means the
display epoch stopped following, which is the unbound-trace state the probe's assignment
exists to prevent.
- The exit dump carries the session leg: `gate: on=1 bounds=[-0.300000..-0.100000]`, and a
second run on the same config with the probe unset restores both measurements with their
gate flags (`measurements: 2 1.sine average=-0.000005 1.sine average=-0.338823` on one
restore run -- numbered with the signal's active-channel number, the same one the pane's
rows carry) and the same band.
## `[crosshair-probe]`
`UDPSCOPE_PROBE_CROSSHAIR_MS`. The reading for **the crosshair's axis badges**: a real pointer
hover — synthesised through the GTK adapter's own controller, a plain move with no button —
must place the crosshair, and with it the two badges the plot writes in the label strips: the
y value in the y strip, the x time in the x strip, each inside its strip and outside the axes.
Measured output (viewport pixels; the absolute numbers are the headless window's own):
```
[crosshair-probe] active=1 y=[74x47@31,588] x=[59x47@749,1529] y_ok=1 x_ok=1 y_centre=1
[crosshair-probe] off: plot_flag=0
```
- `active=1` is the hover having reached the plot (`Crosshair::active`) — the adapter's
viewport conversion is what this probe exercises; the pixels themselves are the plotter
suite's (`crosshair_ink_stays_inside_the_axes`, which asserts the badges' rects against the
frame and pins the off leg too).
- `y_ok=1 x_ok=1` are the two placements: the y badge right-aligned inside the y label strip
(its right edge on the axis line, where the y ticks end), the x badge inside the x label
strip below the axes. Zero-sized badges, or one outside its strip, is the defect.
- `y_centre=1` is the y badge **centred on its line**: its middle row is the pointer's screen
y (the probe injected one). `0` is the top-aligned badge — the line running along the
badge's top edge, reading as if the number belonged to the tick below it — which is half a
badge off, well beyond the one pixel of slack.
- The second line flips the flag the preference drives (`set_crosshair_axis_labels(false)`)
and reads the plot's own flag back: `plot_flag=0` — a preference that reaches the file but
not the plots is the failure this leg names.
- The pixel-level assertions live in the plotter's suite; this probe is the adapter's half.
The preference itself is `[prefs-probe]`'s `xhair=` field, which reads the settings file
back from disk after the dialog toggles it.
## `[maximize-probe]`
`UDPSCOPE_PROBE_MAXIMIZE_MS`. The one-click **full-screen row** toggle, exercised through the
`RowSet` call the button's handler makes. **Do not run it with `UDPSCOPE_ENABLE_ALL_MS`**: the
enable-all probe's second leg clears the front plot two seconds in, and this probe's `before`
read then names an empty row (`row0=`) -- a probe race, not a defect. The full-screen recipe:
`RowSet` call the button's handler makes (a probe cannot click an overlay button the frame
places), arranged as two single-cell rows with one signal each. The two reads after it are the
**corner reveal**: no pointer over the area, then a pointer position in the first row's corner
(set directly -- the probe owns the state the motion controller fills). Measured output:
```
[maximize-probe] single_buttons=0 detach_single=0 before: rows=2 frows=2 row0=sine row1=square buttons=2 detach=2; max: rows=1 frows=1 flag=1 row0=sine; back: rows=2 row0=sine row1=square flag=0
[maximize-probe] corner: hidden_when_off=1 shown_when_near=1 other_row=0 detach_near=1 detach_other=0
```
- `single_buttons=` is the toggle count *before* the second row is added, while the layout is
the default one figure: **0** — one figure has nothing to enlarge *to*, so it carries no
button (and `buttons=` is what the same count reads once the second row exists).
`detach_single=` is the same for the **detach** buttons: **0** — detaching the only figure
would leave the main window with nothing to draw — and `detach=2` once the second row exists
(and **0** again on the `max:` line, where a maximized layout has hidden its rows).
- the corner leg's `detach_near=1 detach_other=0` read the detach buttons with the same pointer
the maximize toggle reads: both come out together at the corner, the other row's stay hidden.
The pointer sits on the detach button's own centre — its zone is the strictest of the pair.
- `before:` is the arrangement as set: two plots, two figure rows, the signals on their rows,
and `buttons=` one overlay toggle per figure row (the toggles are on screen whether or not
the layout editor is up).
- `max:` is the toggle: one plot, one figure row, `flag=1`, and the *maximized row's own*
signal (`row0=sine`) — the row that was asked for, not whatever sat first.
- `back:` is the toggle again: the arrangement, the plot count and both rows' signals exactly
as before, `flag=0`. A signal that changed here is the state save/restore (`RowState` in
`plot_host.*`) losing a field; the restore is a view change, never an edit.
- `corner:` is the proximity reveal: `hidden_when_off=1` (no pointer in the area, so nothing
shows), `shown_when_near=1` (a pointer in the first row's top-right corner brings *that*
row's toggle out), and `other_row=0` -- the second row's toggle stays hidden, because the
zone is per row. A `shown_when_near=0` names a dead reveal path (the motion hook or the zone);
`other_row=1` a zone that leaked across rows.
## `[scope-probe]`
`UDPSCOPE_PROBE_SCOPE_MS`. The **second window**: `open_scope_window()` twice — once copying
the main window's figure 0, once empty — and read back a few frames later (the main tick is the
scope's bind/acquire/repaint pump). It can run beside `UDPSCOPE_ENABLE_ALL_MS`, but that probe's
second leg clears the front plot through the signal page, so the main's slot 0 is empty by then
and `copied` names the **first non-empty** copied slot instead (`'square'` in that order, `'sine'`
alone). `copied=''` with every slot empty is a copy that moved nothing. Measured output:
```
[scope-probe] loaded closed=2 scopes=0
[scope-probe] count=2 copied='sine' bound=1 empty_ok=1 windows=1
[scope-probe] drop=1 slot1='square' page_groups=2 empty_drop=1 empty_slot0='square'
[scope-probe] detach=1 scopes=3 frows=1 attach=1 scopes_back=2 frows_back=2 slot='ramp'
[scope-probe] gl copy=ok(67f) empty=ok(39f) empty_bound=1
[scope-probe] gl detached ok=1 frames=42 rows=1 slot0='sine'
[scope-probe] rclick=1
[scope-probe] pin_mirror=1 main=[-1.000..1.000] scope=[-1.000..1.000] x=[-1.000..1.000]
```
- `count=2` is both windows having been created and retained; `windows=1` each being a visible
top-level window.
- `copied='sine'` is the copy's first non-empty slot holding the main window's figure-0 signal
(`'square'` when the enable-all probe ran first and cleared slot 0 first), and
`bound=1` the tick having resolved it to a live channel — the scope is bound to the same
model, not a snapshot taken at creation.
- `empty_ok=1` is the `-1` call producing an empty row, which is the other entry point the
overflow menu uses.
- `loaded closed=N scopes=0` is the **session remembering windows without opening any**: the
entries a previous run saved are in `App::closed_windows`, and no scope window exists at start.
`scopes` above zero is a launch that opened windows on its own.
- `reopen scopes=… remembered=… left=0` is **close → remember → the menu entry**: destroying a
window puts its arrangement into `closed_windows` (by name -- `name=1` checks it), clicking the
*Reopen closed windows* item ("clicked", the item's own handler) opens every remembered one, and
the list is empty after (`left=0`). A `remembered` that did not grow is a destroy handler that
forgot to record; `left` non-zero is an entry the menu did not take.
- `instruments windows=N cursors=N gate=N trigger=N marks=N` is the **one owner, every window**:
given a cursor pair, a gate band and the trigger marker on the main window, every scope window
reports the same (each count is how many windows matched). `marks` is the per-window measurement
mark count agreeing with what that window plots -- a window that plots the measured signal has
its ruler, one that does not has none. All four counts equal to `windows` is the reading;
`trigger` is compared against the **main** (the marker is up only while the trigger is), and the
parenthesis reports the main's state so a zero is distinguishable from an unequal one. The leg
adds a real gated `Top` measurement on `sine`, because the gate is restated visible every frame
by `update_measures()` and `marks` only come from `show_on_plot` measurements.
- The second line is the **window as a drop target and a *Plot on* group**: `drop=1` is
`on_scope_drop_channel()` — the handler the window's own target wires — putting `square` in
the copied window's second slot (`slot1=`), the rebuild and the area's re-point then left to
the window's next frame; `page_groups=2` is the signal page of an unplotted signal carrying
**one *Plot on* group per window** (`udpscope-scope-plot-on`), so a signal can be plotted
into a scope window without dragging it there. `empty_drop=1 empty_slot0='square'` is the
same drop onto the **empty** window — the user's own path, "open a scope window and drop a
signal into it" — landing in its row set. `drop=0` names a refused drop (a full plot or an
unacquirable signal); `page_groups` less than the window count is a page rebuilt before the
windows were read or a group that lost its window.
- `pin_mirror=1` is a scope window **mirroring a pin as a range**: the probe pins the main window
and reads the scope's pin and its plot's x range a frame later. Both must agree and the x range
must be ordered (`x=[min..max]`, `min < max`). A `0` with an inverted scope pin (`[+pre, +post]`)
is the defect this exists for: `pin_event_window()` takes **spans**, and passing it a range's
signed `min` turned the window inside out, so a scope in Held or Paused mode drew no trace and no
x labels at all.
- `layout buttons=3 rows=1->2` is the window's **own layout editor**: the toggle builds
`2 × rows + 1` buttons (a `×` per row, a `+` per boundary, Done is not counted) for one figure,
and emitting the first boundary `+` adds a row. A wrong count is a button set built for the main
window's rows; `rows` unchanged is a handler writing the wrong row set.
- `rclick=1` is the scope window's **own right-click menu** being wired (one click gesture with
button 3 on its GL area, beside the drop target): the menu's actions act on that window's row
set.
- The fourth line is the windows' **GL account** (`gl copy=`/`gl empty=`): `ok(Nf)` is a context
that staged N frames, `err:` carries the adapter's failure message. A scope whose context
failed draws *nothing* — no axes, no traces, exactly the "blank plot widget" report — so
`err:` there is a driver/context finding, not a layout one, and `empty_bound=1` (the dropped
signal's channel, read a frame after the drop) with `ok(...)` together mean the window is
drawing; a blank window under those two readings is a compositor problem, not the app's.
## `[search-probe]`
`UDPSCOPE_PROBE_SEARCH_MS`. The handler calls `condition_probe()` directly — the condition is
set in exactly one place for both the live trigger and the search, so `UDPSCOPE_PROBE_CONDITION_MS`
must **not** also be set (two arms on the same port race). Run with
`--rate 2000 --batch 64 --freq 5 --glitch-at 0.25 --glitch-width 0.004` and
`UDPSCOPE_EXIT_AFTER_MS=12000`.
**That recipe needs no `--square-harmonics`**: the mock's `square` rings by default (see
[build-and-run.md](build-and-run.md)), and the odd-harmonic series passes through zero at the ideal edge's
own crossings, so the glitch's inverted 0.004 s pulse, the hit count, `agree` and the pulse-width leg's
`measured=0.200000` all read what they read on the step. Only a *level* the ripple reaches and the step does
not — the extremes, `y = ±1.179` — would need `--square-harmonics 0`, and no probe here asserts one.
Measured output (port 8419):
```
[condition-probe] type=1 cmp=0 width=0.010000 t0=1.710000 measured=0.004000 at0=nan earlier=nan level2=0.500000 mode=2
[search-probe] started on channel 0, armed t0=5.710000
[search-probe] state=2 hits=12 scanned=4.5115 total=4.5115 chunks=2 worst=0.106ms total=0.116ms armed t0=5.710000 vs scanned first=5.710000 err=0 agree=1
[search-probe] jumped to hit 0 t=1.710000 mode=4 axis=[-0.1000..0.1000] pos=1
[search-probe] marks placed=1 of 1 in view (12 found) locked=1 mode=4 axis=[-0.1000..0.1000]
[udpscope] search marks: placed=1 hits=12 locked=1
```
- **`agree=1` is the load-bearing line of the whole feature.** The search and the live trigger run
the same `EventDetector` over the same stamps, so `agree=0` means one of the two is reading a
different clock — which is exactly the class of defect a search that merely "returns plausible
times" hides. Here `err=0`: exact agreement, not within tolerance.
- `state=2` is `BufferSearch::State::Done` (the enum's ordinal). `state=1` would be `Running`.
- `mode=4` after the jump is `DisplayMode::Paused` (the enum's ordinal: `Live=0`, `Capturing=1`,
`Held=2`, `Waiting=3`, `Paused=4`). `mode=2` would be `Held` — the jump having left the held
event in place instead of publishing its own pause view.
- **`hits=` is a count, not a constant.** It depends on the retained span and on the arm phase,
both of which move between runs: the same invocation read `hits=12` at `armed t0=5.710000` and
`hits=11` at `armed t0=5.742000`. A count in the low tens is the reading; `hits=0` with
`agree=0` is the defect.
- **`placed=1 of 1 in view` is the correct answer, not an under-count.** The jump frames a 0.2 s
window and the glitches are 0.4 s apart (one per square period at 5 Hz / square half-frequency),
so only one hit can be in view at a time. `1 of 1` against `(12 found)` is the marks agreeing
with the window, not with the hit list.
- **`total=4.5115` far short of the run is not a defect.** `condition_probe` enables the channel
(which starts acquisition) and then calls `apply_time_window()`, and each of those rebuilds the
buffer and drops history. 4.51 s was the genuinely retained span.
- `locked=1` says the marks are out of the pointer's reach, like every other readout. The exit
dump's `search marks: placed=1 hits=12 locked=1` is the same assertion from the dump path; its
`placed=` counts marks in the **visible window at exit**, which is the live view after the probe
disarmed the trigger — so `placed=0` there is legitimate and asserts nothing about the search.
The load-bearing `placed=` is the `[search-probe] marks` line, emitted a frame after the jump,
which quotes the window the scope has actually moved to.
## `[capture-probe]`
`UDPSCOPE_PROBE_CAPTURE_MS` toggles the packet capture through the status-bar button's own
handler, then reads the ring file back off disk and prints one line:
```
[capture-probe] enabled=1 read_ok=1 path=/home/m/.cache/udpscope/127.0.0.1-9102/capture.bin slots=1000 stored=253 resident=253 cursor=253 torn=0 truncated=0 header_ok=1 first_seq=1 last_seq=253 payload=2031337 file=65576064 open_ms=2.9 t0=1791524811.925
```
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `enabled` | `1` after the probe's toggle; `0` when the open failed, with the reason on stderr | a toggle that sets a flag nobody acts on, or an open that failed silently |
| `read_ok`, `header_ok` | `1` | a file whose header does not round-trip — the format changed without the version bump |
| `stored` | datagrams since the toggle; equal to the exit dump's datagram count over the same interval | the producer storing more or fewer datagrams than the client handed it — a copy in the capture path, which the design forbids |
| `resident` | `min(stored, slots)`; equal to `slots` once wrapped | the ring reusing a slot before its `seq` was published — a torn slot that reads as a hole in history |
| `cursor` | `stored mod slots` | a cursor maintained out of step with `seq`; the file's hint would mislead the `xxd` user it exists for |
| `torn` | `0` | a slot whose bytes landed but whose `seq` never did *without* a crash — the release-store ordering is wrong |
| `truncated` | `0` | a datagram longer than `UDPS_MAX_DATAGRAM_BYTES` — the slot sizing disagrees with the client's receive buffer |
| `first_seq` | `1` on a fresh ring | the ordinal not starting where the file starts — a leftover file being appended to instead of renamed |
| `last_seq - first_seq` | `slots - 1` on a wrapped ring | ordering lost across the wrap: the newest slot is not the largest `seq` |
| `payload` | the bytes the wire delivered over the interval | a capture storing headers and not payloads, or truncating silently |
| `file` | `64 + slots * slot_bytes`, a constant for a given N | the reservation is short — which with `mmap` is the SIGBUS the design forbids |
| `open_ms` | single-digit ms on a local filesystem | a blocking filesystem between two `recv()` calls — the producer stall the popover's toggle costs |
With a **failing** open (the cache path colliding with a plain file, a full disk), the line reads
`enabled=0 read_ok=0` with every count 0, the toggle returns to off on the next tick, and the
popover row reads `failed: <strerror>` — one stderr line, no retry storm against a full disk.
## `[defaults]` and `[configs]`
```
[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01 0 0 1
[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25
[configs] before=default active=default store=default
[configs] save-as=1 names=probe,default active=probe store=probe
[configs] round trip read=1 comment='written by the configs probe' buffer_samples=10000000 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1
[configs] after forget names=default active=default store=default
```
`noise=` and `hf=` are the first measurement's band and corner as they came back from the file. The probe
sets them to **7.5 and 250** before saving, so a key that never reached the file reads back as the defaults
(10 and 0) instead -- which is the only way a round trip can tell a written key from an absent one. They read
`-1` when the saved configuration holds no measurement, which is what the run above does.
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `[defaults] calibrate:` | `1 0 model=1 0` | an unseeded spin button: `gtk_spin_button_new_with_range()` leaves the value at the range minimum, so the offset opened at -1e12 and Apply wrote it |
| `[defaults] calibrated:` | `2.5 -1.25 model=2.5 -1.25` — the dialog and the model agreeing after a calibration was set | a dialog that opens at the identity while the trace is drawn through a gain: pressing Apply "confirms" the calibration away. The two halves are printed so the assertion is that they *match*, not that either is a particular number |
| `[defaults] trigger:` | `0.001 0.002 0 0.001 0 50 0.01 0 0 1` | the same, per trigger field — the ten spins in tree order: width, width2, level2, timeout, level, pre-trigger, hold-off, noise band, HF reject, persistence; a level of `-1e12` is a number nobody chose, and the trailing `1` is the persistence depth, whose default *is* the feature off |
| `[configs] names` | gains `probe`, and loses it after `forget` | a save that writes somewhere the listing does not read, or a delete that leaves the file |
| `[configs] active` | `probe` after the save-as | the `active` file not written, so the next start opens the wrong configuration |
| `[configs] round trip` | `read=1`, the note verbatim, `buffer_samples` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with |
| `[configs] store` | the **event cache's configuration key**, following the active one: `default` → `probe` → `default` | a cache still keyed on the source alone (the key never moves), or a switch that renames the session but leaves the events list showing the configuration it left: `store=default` on the `save-as` line, or `store=probe` after `forget`, names it |
## `[rowmenu]`
```
[rowmenu] main 6 'Remove signal' h=18 label_x=26 accel_right=-1/248 ok=1
[rowmenu] main rows=7 bad=0 labels=[Y axis: auto scale,Y axis: fixed range...,Fit X axis,Fit all,Open this plot in a new window,Edit the layout...,Remove signal]
[rowmenu] remove 0 'Back' h=18 label_x=52 accel_right=-1/118 ok=1
[rowmenu] remove rows=1 bad=0 labels=[Back]
[rowmenu] summary main_rows=7 main_bad=0 remove_rows=1 remove_bad=0 after_back_rows=7 after_back_bad=0 second_rows=1 second_bad=0 remove_row_in_main=1 remove_row_gone=1 can_open_again=1
[rowmenu] remove-clicked '' trace_freed=1
```
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `h` | 18 for every row of both views | the plot menu reverting to Adwaita's 34 px buttons — the row style not applied (`popover.udpscope-menu`) |
| `label_x` | 26 for every row, 52 for **Back** | labels not sharing one column; the Back row's 52 is its leading icon, which every menu indents for |
| `accel_right`/`W` | equal when the row has a shortcut (`Fit all` → `F`), `-1` when it has none | the shortcut not flush right, or shown where the action has none |
| `main_rows` | 6 for a one-figure layout, **7** with `Full screen this figure` (a layout of two or more figures), plus 1 with `Restore the layout` while one is maximized | the full-screen entry missing where it would act (it is deliberately absent for a single figure: there is nothing to enlarge to), or the layout-editor entry missing: the plot menu is the third route into the editor beside `l` and the overflow menu |
| `remove_rows` | 1 per occupied trace slot, plus `All traces on this plot` when there is more than one, plus the **Back** row | a remove view built from the wrong row, or missing its way out |
| `remove_row_gone`, `remove_row_in_main` | 1 | a submenu that floats beside the menu instead of replacing it |
| `can_open_again` | 1 | the second click not reopening: the `GtkMenuButton`-owned submenu that collapsed and left its row dead |
| `bad` | 0 for every view | any row outside the compact style. Each view is measured a step after the click that swapped it in — in the same call the new child is unallocated (`h=0`), which reads like a style defect and is not one |
| `trace_freed` | 1 | a remove entry that dismisses the menu without taking the trace off the row |
## `[sources]`
```
[sources] form: name='probe-host' note='made by the new-host probe' buffer=32 relative=1 connect_sensitive=1
[sources] configuration created: 9.9.9.9:7000/probe-host note='made by the new-host probe' buffer=32 relative=1
[sources] picked 9.9.9.9:7000 multicast= interface= buffer=32 relative=1 capture=0 config=probe-host note='made by the new-host probe'
[sources] edit form: name='probe-host' note='made by the new-host probe' buffer=32 relative=1 button='Save'
[sources] configuration saved: 9.9.9.9:7000/bench note='edited by the probe' buffer=16 relative=0
[sources] 1 known: 9.9.9.9:7000/bench plots=0 signals=0 buffer=16 relative=0 capture=0 note='edited by the probe' |
```
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `form:` | the name, note, buffer and clock the form was given | a form field that is not wired: the four things a host could not be given before |
| `connect_sensitive` | 1 once the server is typed | the button's validation not following the entry |
| `configuration created`/`saved` | the identity the form held, with the settings | a save that writes somewhere other than what the form shows |
| `edit form:` … `button='Save'` | the **existing** configuration's values, pre-filled | the edit path not reading the file — an edit form that opens empty edits nothing |
| `known:` after a rename | one row, under the new name | a rename that leaves the old file, so the chooser lists the same session twice |
| `known:` for a settings-only configuration | a row, though `signals=0` | the listing filter treating "no signals yet" as "nobody configured this", which hides every host the form set up |
## `[sources] loading`
```
[sources] picked 127.0.0.1:21077 multicast= interface= buffer=16 relative=0 capture=0 config=default note=''
[sources] loading: title='Connecting to 127.0.0.1:21077' detail='planning the 16 MB sample buffer'
[udpscope] CONFIG: 5 signals, publish=accumulate
[sources] loading: closed
```
The loading window covering the gap from the chooser to the first frame of the real window. It is
printed by any run that reaches the chooser, so `UDPSCOPE_PROBE_PICK_MS` reads the host leg and
`UDPSCOPE_PROBE_WAVE=<path>` the file one (`title='Opening rt.npy' detail='reading the recording'`).
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `loading:` after `picked` | the address or basename just picked, and the buffer size | the window raised from the wrong place: before the chooser's answer it cannot name the wait, after the model it covers nothing |
| its position in the log | **before** the `CONFIG:` or `waveform loaded:` lines | a window shown after the work it is meant to cover — the ten seconds of empty screen, unchanged |
| `loading: closed` | **after** `CONFIG:`, i.e. once the real window is presented | a hide that runs earlier, which puts the screen back to empty for the frames the main window takes to map |
| no `loading:` line at all | correct for a `--server` or `--open` run | only the path that *asked* gets one; a line here means every probe just changed path |
`loading ui: loading.ui is missing an id this code reads` is the file-and-code mismatch the chooser's
own check names, and like it the run continues: a loading window that cannot be built must not stop
the app from opening.
## `[sources] prefs`
```
[sources] prefs: opened=1 rows=1
[sources] prefs: dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark cfg_relative=1
```
`UDPSCOPE_PROBE_PICK_MS` + `UDPSCOPE_PROBE_CHOOSER_PREFS=1` opens the preferences from the *chooser*
rather than from the main window's menu, fills the form through the same `prefs_probe_fill_and_apply()`
the app's probe uses, and reports both the file and `cfg_relative` -- what the run about to start will
actually use.
- The second line must echo the probe's input, exactly as `[prefs-probe]` does.
- `cfg_relative=1` is the chooser's half of the contract: relative time is the one preference that can
still change *this* run, because `main()` plans the buffers after this dialog returns.
- The run leaves the preferences window **open** on purpose: the pick that follows destroys the chooser
with the window still up. A `Gtk-CRITICAL` or a crash here is the transient-window lifetime being
wrong, not a cosmetic failure.
- A `[sources] prefs: could not write <path>` line, with the second line reading the *defaults*
(`dir= keep=32 rel=0 theme=auto`), is the signature of the config directory not having been created:
the chooser is the first thing that writes preferences on a fresh install, so it is where a
`mkdir()` that does not make its parents shows up. This reading is why that is now caught here rather
than reported by a user whose settings quietly did not stick.
- The line after it, `[sources] picked ... relative=1`, is the pair that matters: the preference stated
in the dialog has to survive the row click that follows, which overwrites the rest of the config from
the source's stored session.
## `[sources] settings from configuration`
```
[sources] settings from configuration 'bench': buffer=64 relative=1 capture=500
```
Printed by `apply_stored_settings()` when a source is named on the command line: the stored
configuration's startup settings, filled in where the command line was silent. A `samples=` here
that disagrees with `--buffer-samples` is a flag being overruled by the file; a *missing* line, with a
configuration that carries settings, is the defect this exists for — a saved host opened with
`--server` ran on the defaults, so an edit to its buffer or clock never took effect.
## `[sources] waveforms` and the waveform load
```
[sources] chooser ui: page='sources' title='Sources' icon=yes page='waveforms' title='Waveforms' icon=yes visible='sources' switcher_wired=1 buttons=[prefs=1 new_host=1 open=1 cancel=1] help=[button=1 docs=1 tour=1 toggle=1 on=0]
[sources] chooser help: tour requested
[sources] chooser help: tour_pending=1
[sources] chooser help: toggle=1 setting=1 file=/tmp/…/udpscope/settings.toml
[sources] waveforms: 1 recent /tmp/rt.npy(ok) page=waveforms
[sources] picked waveform /tmp/rt.npy
[udpscope] waveform loaded: /tmp/rt.npy signals=4 samples=20001 rows=20001 decim=1 span=2.000000s rate=10000 time=[-2.000000..0.000000]
[udpscope] waveform loaded: /tmp/long.npy signals=4 samples=31250 rows=1000000 decim=32 span=19.999980s rate=50000 time=[-4.000000..15.999980]
```
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `chooser ui:` | both pages with a `title` and an `icon=yes`, `switcher_wired=1`, all four buttons `1`, and `help=[button=1 docs=1 tour=1 toggle=1 on=0]` | the chooser's `.ui` losing an id: the code names the missing one and refuses the window, and this line is the frame that *did* build -- a page with `icon=NO` is one the switcher cannot draw, and a `help=` field at `0` is a help row (or the button that opens it) the dialog no longer carries |
| `waveforms:` | one entry per remembered export, with `(ok)` or `(missing)`, and `page=waveforms` once the page is shown | the Waveforms page not built, or a stale path offered as if it were openable |
| `picked waveform` | the selected path | a pick that carries no file: `--open` is what the mode reads |
| `signals=`, `rows=` | the export's `cols=` and `rows=` | a reader mis-slicing its records, or the columns bound to the wrong channels |
| `samples=` × `decim=` | `rows`, to within the last partial group | the loader not filtering the file into the plan it built: every row pushed into a ring that cannot hold them leaves the file as its own tail (`stored=` far above `published=` in the channel dump, and a 20 s recording on screen as its last 4 s) |
| `decim=` | 1 whenever the recording fits the buffer at its own rate; a power of two above it | a decimation that moved without the span moving — the second line above is the reading to compare against: `samples` fell 32-fold and `span=` did **not** |
| `span=`, `rate=`, `time=[…]` | the exported window and rate, `[-window, 0]` | the plan not covering the whole file (the tail arrives as the whole picture) or the display epoch not pinned to the file's last stamp |
| `frames=0 packets=0` beside `channels=N` | 0 — no acquisition | a socket opened for a file, which would also make the starvation notice live |
The chooser's **help menu** — `Documentation`, `Guided tour` and `Enable contextual help`, the three
affordances the window's Help view carries, moved in front of a user who has not started yet — has two
probe legs, both of which then fall through to the normal first-row pick so the run they affect is the
run they report:
- `UDPSCOPE_PROBE_CHOOSER_TOUR=1` emits `::clicked` on `Guided tour`, and `[sources] chooser help:
tour_pending=1` reads the request back out of the `Config` the run will be handed. **`tour_pending=1`
is the assertion**: the tour is drawn on a window that does not exist yet, so the entry can only work
by setting that field, and the reading it *started* is the exit dump's `help layer:` — `tour=running
… auto=0 requested=1`, `requested=1` being this path (see `help layer:` above).
- `UDPSCOPE_PROBE_CHOOSER_HELP=1` sets the `Enable contextual help` row active and reads the settings
file back: `toggle=1 setting=1 file=<settings.toml>`. **`setting=1` beside `toggle=1`** is the pair
that matters — the row *is* the setting, and the app is built from the file and not from the dialog,
so the file is where it has to have landed.
## `[drop-probe]`
```
[drop-probe] signals=4 channels=4 rows=4 slots0=[sine|||] view=[-3.999690..15.999670] repins=2
[drop-probe] dropped signal 0 -> 1 slots=[sine|sine||]
[drop-probe] dropped signal 1 -> 1 slots=[sine|sine|square|]
```
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `rows` | the column count, capped at `RowSet::kMaxRows` — one row per trace | rows sized four signals to one row: the only row fills and *every* drop is refused ("plot 1 already holds four signals"), which reads as a scope that cannot plot anything |
| `dropped … -> 1` | 1, and the row gains that trace | a drop that cannot find a free slot, or a signal with no channel to bind |
| `channels` | one per signal | a file whose columns did not become channels, so nothing can be plotted at all |
| `view` | the file's own span, or whatever the user last zoomed to — never a *sub-range of it the user did not ask for* | a pin that no longer holds the data: `view=[3.999690..15.999670]` against a file spanning `[-4..16]` is the pin written as a pre/post split where a range belongs |
| `repins` | **2**, for the whole run, however long | the file view stating its pin per frame instead of refreshing its numbers. Each statement writes the group's range, so the axis alternates between the file's span and the pin's and no gesture survives: `repins=116` at three seconds is one per frame, and it reads on screen as a flicker in which nothing can be clicked |
## `[events]`
```
[events] live, recording off: available=0 dock_sensitive=0 page_sensitive=0 page=none
[events] recording on, no trigger: available=1 dock_sensitive=1 page_sensitive=1 page=none
[events] after trigger on: available=1 dock_sensitive=1 page_sensitive=1 page=none
[events] after events page opened: available=1 dock_sensitive=1 page_sensitive=1 page=events
[events] open alone: page=events page_box=347
[events] preview redrawn: signals=3 stored=480x132 row_drawn=1
[events] dir=/home/u/.cache/udpscope/events/127.0.0.1-21023/default n=3 newest: stem=20261013-184140-0003 dur=2.000000 pre=1.600000 post=0.400000 edge=5.660100 signals=3 samples=20001 rate=10000 seq=3 thumb_bytes=190134 recorded=1 page=1 plot_w_stable=1 plot_w=756/756
[events] activated row0: replaying=1 page=events page_box=347 tick=0 window=2.000 axis=[-1.600,0.400] marker=0.000 thumbnail=1 thumb_size=138x46 thumb_visible=1 rows_kept=1 selected=2/2
[events] dock events: rows=3 pane_rows=3 same_titles=1 sensitive=1 dock_w=268 on_screen=1 marked=0 members=0 max_ok=1 expected_marked=1 at=after pane replay
[events] dock replay: clicked=1 replaying=1 waveform_changed=1
[events] dock events: rows=3 pane_rows=3 same_titles=1 sensitive=1 dock_w=268 on_screen=1 marked=1 members=0 max_ok=1 expected_marked=1 at=after dock click
[events] replayed: channels=3 samples=20001 replaying=1 waveform=1
[events] live again: replaying=0 waveform=0
[events] live restored (after replay): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=10/5
[events] recording off, trigger off: available=1 dock_sensitive=1 page_sensitive=1 page=events
[events] recording off, trigger on: available=1 dock_sensitive=1 page_sensitive=1 page=events
[events] compare buttons: cached=2 selected=2 compare=1 accumulate=1
[events] dock events: rows=2 pane_rows=2 same_titles=1 sensitive=1 dock_w=268 on_screen=0 marked=-1 members=0 max_ok=1 expected_marked=-1 at=two events latched
[events] compare: kind=1 rows=1 channels=8 samples0=32964 traces0=4 traces1=-1 window=2.000 recording=1 page=events
[events] dock events: rows=2 pane_rows=2 same_titles=1 sensitive=1 dock_w=268 on_screen=0 marked=-1 members=2 max_ok=1 expected_marked=-1 at=comparison up
[events] compare plots: area_b=1 rows_a=1 rows_b=1 list=4 x_a=[-2.000,0.000] x_b=[-2.000,0.000] same_x=1
[events] compare sync: y_a=-3.500 y_b=-3.500 mirrored=1
[events] accumulate: kind=2 rows=1 channels=8 samples0=32964 selected=2 traces0=8 window=2.000
[events] group left: replaying=0 kind=0 waveform=0
[events] live restored (after a group): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=22/16
[events] dock replay: clicked=1 replaying=1 (second click, live before)
[events] dock events: rows=2 pane_rows=2 same_titles=1 sensitive=1 dock_w=268 on_screen=1 marked=1 members=0 max_ok=1 expected_marked=1 at=second dock click
```
`thumb_size` is the built size of the row's picture: `kEventThumbW` wide, and as tall as the *stored*
preview's own aspect makes it at that width, clamped to `[kEventThumbH, kEventThumbMaxH]`. So it moves
with the event's **signal count** (a three-signal event sits on the floor at `138x46`, a six-signal one
reads `138x75`) as well as with the CSS border and the display's scale factor. What it must never be is
`0x0`, and a height pinned to the floor for *every* event says the aspect fit was dropped and tall
previews are being squashed again.
**The E-1608 never latches an event** (its stream carries no trigger edge to record): on `--source e1608` the probe reads
`n=0`, the `recording on` lines read `1 1 1` (recording is on, so the block is available), and the two
`recording off` lines read `available=0 dock_sensitive=0 page_sensitive=0 page=events` -- the page is open and
greyed, the "disabled, not hidden" path -- while `activated row0:` reads `replaying=0 ... marker=-1000000000000.000
thumbnail=0 rows_kept=0` and the `dock events:` / `dock replay:` lines read `rows=0`, `clicked=0 replaying=0`. The
`page=` of the first availability lines is whichever `source-*` page the kind opened at start-up, not `none`.
A UDPS run against a **fresh cache** reads the same `n=0` and `rows=0` on the opening `dir=` / `dock` lines (nothing
has latched when they are printed) and fills in from the group leg on; a UDPS run with a populated cache is the one
that proves `same_titles` and the dock click on real rows.
`[events] preview redrawn:` is the repair path, and the probe forces it: step 1 deletes the newest
`.bmp`, drops the thumbnail cache and refreshes the page's list, so this line is `EventStore::redraw_preview()`
drawing the picture again from the `.npy`. `stored` is the file it wrote, read back out of the BMP
header — `480` wide and `44 * signals` tall — and `row_drawn=1` says the row got a paintable from it.
The probe therefore needs a cache a **previous** run populated: it fires ~4.5 s in, before this run's
first latch.
`[events] live restored (…)` is the way *back*, read once per way into a file view — after a plain
replay and after a group — and four of its fields are a **pair**: the value now, and the value the
live view held before the file view opened over it. The probe captures that expectation itself
(`capture_live_expectation()`, `App::dbg_live_*`) rather than asking the restore path for it: a
reading taken from the mechanism under test asserts nothing. The window is set to 0.5 s just before
the event is opened precisely so the pair can disagree — with the event's own span there would be
nothing to tell "restored" from "untouched".
Both lines are read a *later* step, not in the handler that pressed *Live*: the restore waits for the
restarted client's channel list being usable again — the restored trigger's channel *name* has to
resolve — so it is performed by a tick ~900 ms on. `pending=0` is what says it happened; `gen=a/b` is the
generation now against the one *Go live* read, and it is informational: the restore no longer waits for it
to move, because a restarted client whose CONFIG is identical is deliberately *no change*, so a single
event — which keeps the stream's own channel names — never moved it and the restore waited until the
trigger was toggled. A **stuck `pending=1`** is not a reading about the restore but about the stream
behind it: the channel list came back empty, which is how a session file poisoned by a save taken during a
group produced `pending=1 channels=0` and a live view with nothing on it.
**Run this one against a *configured* live view.** The four paired fields are the probe's own capture of
what the live view held when the file view opened over it, so a bare first run legitimately reads
`window=2.000000 (want 0.000000) trigger=1 (want 0) channel='' (want '')` — the wants are the empty
snapshot and the values are the application's defaults, which is not a defect. The documented line above
is reproduced from a session that has a window and a trigger (`window_s = 0.5`, `trigger_enabled = true`,
`trigger_signal = "square"`); the probe sets its own window to 0.5 s before opening the event, and a
session that then disagrees with it takes the pair apart.
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `n`, `stem`, `seq` | one entry per latch, named by its time and the run's counter | two events in one second overwriting each other, or a cache that is never written |
| `dur` | `pre + post` for the event's window | the snapshot's own window not recorded, so the list disagrees with the picture |
| `signals`, `samples` | the event's channels and rows | a table built from the *rows* instead of the snapshot: only what happened to be plotted |
| `thumb_bytes` | a real 24-bit BMP, `54 + 480 * (44 * signals) * 3` rounded up per row to a multiple of four -- so it *grows with the signal count* (190134 at three signals, 380214 at six) | a preview that is empty or missing -- the widget snapshot of a `GtkGLArea`, which is NULL -- or a picture back on a fixed total height, which gave a six-signal event 26 px a signal and an eight-signal one 20: a smear, not a waveform |
| `recorded` | 1 while the trigger panel's switch is on | the switch not wired, or recording defaulting off (it must be on: the cache is for the event you did not expect) |
| `page` (on the `dir=` line) | 1 once the `events` page is open (the probe opens it with `open_events_page()`) | a page that did not open, or one that opened before its list was refreshed |
| `plot_w_stable`, `plot_w=a/b` | 1, with the plot area's width before and after the first latch equal and both > 0 (756/756 on the documented window) | events squeezing the plot again (the old pane did), or two unrealised `-1` widths read as equal (the probe requires > 0) |
| `available`, `dock_sensitive`, `page_sensitive` (the lines headed `live, recording off`, `recording on, no trigger`, `after trigger on`, `after events page opened`, `recording off, trigger off/on`) | the three equal on every line, because both views follow `sync_events_availability()`: `0 0 0` live with recording off and an empty cache; `1 1 1` once recording is on, **and it stays `1 1 1` in trigger mode and with the page open** (a replay or a group leaves trigger mode, and a block that greyed itself under that click is the trap) | the dock block and the page disagreeing (one of the two not wired to the sync), or the block going `0` under trigger mode. After recording is switched off the lines read `1 1 1` **once the cache holds anything** (the probe's own run does by this point): an event on disk outlives the switch that wrote it. With an *empty* cache they read `0 0 0` -- insensitive, **never hidden** ("disable, do not hide") |
| `page` (on the availability lines) | `none` until the probe opens the page, then `events` | the page not registered (`open_events_page()` returning false), or a page left open by an earlier leg |
| `open alone` | `page=events page_box=` a width near the slide-over's (347 on the documented window) | the page's box measuring 0 (an unrevealed `GtkRevealer` as the content instead of `events_box`), or a page not opened at all |
| `dock events` | `same_titles=1`, `rows` equal `pane_rows` (both capped at 24: `pane_rows` is cut to the dock's `kDockEventsMax`), `sensitive=1`, **`dock_w` below 280** (268: the block fits the fixed dock; more means a row that no longer ellipsizes), `max_ok=1` (dock children <= 24 + the "N more" row). `on_screen` counts filled markers: **1 after a replay, 0 while live or in a group**; `marked` is the index of the first filled row (-1 when none), `expected_marked=1` when the filled row is the replayed record's file (-1 where no replay is named); `members` counts hollow markers: **2 with a comparison up**, 0 otherwise. The `at=` text names the moment | `same_titles=0` is the two lists rebuilt from different data or one of them stale (the dock follows every latch, the page only while open or on Manage -- a latch landing between the page's refresh and the read can show `rows=9 pane_rows=8` once, which is a probe-time race, not a defect, if it does not persist); `on_screen` above 1 is two filled markers (`event_on_screen` not shared with the title); `members=0` in a comparison is the group not marking its members; `max_ok=0` is the cap lost |
| `dock replay` | `clicked=1 replaying=1 waveform_changed=1`; the second line, from live, reads `clicked=1 replaying=1` | the dock row's click not reaching `replay_event()` (`clicked=0` with a non-empty list), or the click rebuilding the dock list under its own handler. With an empty cache (`rows=0`) it reads `clicked=0 replaying=0`, which is not a defect |
| `thumbnail`, `thumb_size`, `thumb_visible` | 1, a non-zero size, and 1 | the picture loaded through `gtk_picture_set_filename()`, which fails silently (the paint-able is there, the allocation is there, nothing is drawn), or a `GtkPicture` given a size request and no cap -- its natural size is its content, so the row's text is squeezed off. `thumb_size` is the reading that catches both. It also catches the cache never being *created*: `event_preview_texture()` answers from the cache or not at all, so a reader that does not create one leaves **every** row blank, which is what `thumbnail=0` meant for as long as it read 0 |
| `preview redrawn` | `stored=480x<44*signals>` and `row_drawn=1` | a preview that cannot be repaired. The `.npy` is the event and the `.bmp` is only a picture of it, so a write that failed, a truncated file or a cache from an older build must be redrawn rather than shown as a blank row -- `row_drawn=0` is a row the user reported as "sometimes missing" |
| `axis`, `marker` (in `activated row0`) | `[-pre, +post]` of the event just opened, `marker=0.000` | an opened event framed on something other than its own pin; a trigger line left **parked** (a large negative) rather than on the edge, which is a file opened without its marker. `page=events` and `page_box` (the page's width, as in `open alone`) say the page is still up and has a width: a replay from the page must not close it |
| `rows_kept`, `selected` (in `activated row0`) | `rows_kept=1` and `selected=N/N` -- the counts either side of the activation equal | the list **rebuilt** by the activation. It is the row *object* that is compared, not its index: a rebuilt list has a row 0 too, and only identity tells a list that was left alone from one that was torn down under the user's scroll position and their comparison selection. `mark_events_on_screen()` must rewrite the titles in place |
| `replaying` (last line) | 0 | a replay with no way back: *Live* must start a fresh client and thread |
| `window`, `trigger`, `channel`, `level` (in `live restored`) | each equal to its `(want …)` | the way back out of a file view discarding what the live view was set to. One wrong half names which writer won: a `window` that reads the **session file's** value is the deferred `apply_layout()` beating the restore; `trigger=0` is the toggle the file view switched off and nobody switched back; an empty or shifted `channel` is an index restored instead of a name, after a rebuild renumbered (or a group renamed) the channels; and a `level` that moved on its own is `rebuild_trigger_signals()` re-defaulting it because `trigger_level_touched` was not restored with the configuration |
| `pending`, `gen` (in `live restored`) | `pending=0`, and the two generations different | the restore still waiting. It is gated on the stream's first CONFIG, so `pending=1` says the channel list the stored trigger name needs never arrived -- with `channels=0` behind it, a session file that enabled nothing |
| `pinned`, `held`, `mode` (in `live restored`) | `pinned=0`, **or** `pinned=1` with `held=1` | a file's pin surviving the way back, which is a live view that cannot be navigated. `apply_time_window()` drops it; the second reading is the legitimate case -- a restored trigger that was *on* can latch again before the probe reads, and a held capture is pinned, correctly |
| `tick` | 1 in live mode, **0** once a file is on screen | the signal list still offering "acquire this signal" for samples that are already stored |
| `window` (in `activated row0`) | the **event's** span -- the probe sets 0.5 s just before the click, so reading the event's span says opening it framed it | a replay that leaves the previous window alone, or one that restates it every frame instead of once |
| `compare buttons` | `selected=2` and both buttons sensitive | the buttons reading something other than the selection, or staying insensitive with a valid pair selected |
| `compare` | `kind=1 rows=1 channels=8 samples0=33045 traces0=4`, `page=events` | `samples0` above the stamps pushed -- a live producer left running, pushing four stream signals into the group's eight channels; `rows=1` because the layout is the user's and must not be re-derived; `traces0=4` because the four slots the layout holds are renamed, not replaced |
| `compare plots` | `area_b=1 rows_a=1 rows_b=1 list=4 same_x=1` | no second plot on screen, a second plot with a different row count, two plots not on one x window, or `list` above the signal count -- the panel listing each event's copy of a signal as a signal of its own |
| `compare sync` | `mirrored=1`, with `y_a` and `y_b` equal to the range the probe set on the first plot | the mirror not running, or running the wrong way: a y (or x span) change in one plot that the other never sees |
| `accumulate` | `kind=2 rows=1 channels=8 samples0=33045 traces0=8 window=2.000` | the second event's layers not landing: `traces0` stays at the four signals the layout holds when the slots were not free, or the row named after a previous group's suffix so nothing binds |
| `group left` | `replaying=0 kind=0 waveform=0` | the group surviving *Live*: a file view with no file behind it |
## `[boxzoom-probe]`
```
[boxzoom-probe] open: swept=200x10 frame=100,0 200x853 axes_h=853 band=1 axis_before=[0.000000..2.000000]
[boxzoom-probe] closed: frame=0x0 gone=1 axis_after=[0.000000..0.553167] zoomed=1
```
`UDPSCOPE_PROBE_BOXZOOM_MS` injects a Ctrl box zoom straight into row 0's controller -- the way a
headless probe has to, since the area's gestures arrive from GTK -- and reads the frame back off the
*plot* one frame later. That is what makes this the adapter's per-frame sync being exercised: the
library measures and draws the rect in tests of its own.
- `band=1` is the reading that matters. The pointer swept `200x10`, and the frame is `200` wide and
`1665` -- the height of the axes -- tall. Ctrl zooms **x only**, so the band across the plot is what
the release will apply, while the sliver the pointer actually swept is only the gesture. Its `x` and
`y` must be the press point's.
- `closed: frame=0x0 gone=1` is the other half of the contract: a finished gesture leaves no frame, so
nothing goes on claiming a selection is open. Both lines together are the whole feature.
- `axis_before`/`axis_after`/`zoomed` are the *settled* axis, read a few ticks after the release so
that whatever pin or clamp the view has has already had its say -- which is the point: a rect can
report the gesture and nothing about whether it did anything. **Run this in both modes and both must
read `zoomed=1`.** A file opened with `--open` reading `zoomed=0` while the live view reads `1` is the
defect this reading exists for, and it was invisible from the frame alone: the file view pinning its
axis from the ring each frame is what swallowed the gesture.
## `panes:` — the locked and configuration modes
```
[udpscope] pane drag: slide-over closed for the drag=1
[udpscope] pane edits: locked=document-edit-symbolic(ok) config=object-select-symbolic(ok)
[udpscope] panes: trigger=locked body_visible=0 summary='signal: square | condition: window, falling | level: 0.3 .. 0.6 | mode: single | noise band: 0.2 | pre-trigger: 20 % | hold-off: 1000 ms'
[udpscope] panes: trigger=config body_visible=1 summary=''
[udpscope] panes: the same line as the probe found it: 'mode: stream (trigger off)'
```
**`pane drag:`** is a drag begun through the *source* of the first signal badge (`drag-begin` emitted on its
`GtkDragSource`, so the handler is the one a real drag reaches) with the `signals` slide-over page open. It
must read `1`: the slide-over covers the plot's right edge, so a drag that started with it open would have part
of the plot that is not a place to drop, and the badge's `drag-begin` closes it. (The page that was open is put
back afterwards.) `0` is a drag that leaves the panel over the plot; `-1` is no badge to drag (no signal enabled,
or more than six, where the badges are replaced by the "N signals" overflow). It replaced the old
`sidebar hidden for the drag=1 restored after it=1` pair, which tested `row_drag_sidebar()` -- the list rows were
the drag handle and the overlaid sidebar had to step aside; both are gone.
**`pane edits:`** is the trigger pane's `[edit]` button in each mode, by name and verdict, and it exists because of
a real defect: the "done" icon was `emblem-ok-symbolic`, which **Adwaita does not have** (breeze does), so
the button that leaves configuration mode was blank — and a blank icon button is invisible in every other
reading, exactly as `toolbar icons:` describes for the toolbar. The probe clicks into configuration mode
and back, so one line carries both names. **Both must read `(ok)`**, and the pair is
`document-edit-symbolic` (the pencil: edit) and `object-select-symbolic` (the check: done).
The trigger pane has two modes. **Locked** is what a returning user gets — the pane shows the one line
in `summary` and offers no control that changes the configuration — and **config** is the full pane, which
is what a *first run* gets, because a session file that loaded is what "we have been here before" means.
The heading's `[edit]` button flips it. (The signals have no lock: the badges and the signal pages are
direct manipulation, and `panes:` no longer carries a `signals=` field.)
- **With trigger mode off the summary is one line**: `mode: stream (trigger off)`. The four lines below
describe a trigger that is not running, so quoting them would describe nothing. The dump carries the line
twice — once as the probe found it and once at the end — because only a run whose *session* has the
trigger off shows the stream form, and a run whose session has it on shows the settings. **The pair is
also the refresh test**: the probe toggles the trigger, so a run whose session has it off must show
`stream (trigger off)` in the line it found and the settings in the live one. Both showing `stream` is a
compact view that did not refresh when the mode changed, which is a defect this pair caught.
- **`trigger panel sensitive …=1` is now a guard, not a state.** The trigger's body is never disabled —
a trigger is configured before it is switched on — so this reads `1` always, and a `0` means the
settings have been made unreachable again. The *cursors'* body is disabled with the cursors, and that
is the pane whose readout has nothing to show.
- **`compact arm with the trigger off: visible=0` / `with the trigger on and the pane locked:
visible=1 sensitive=1 width=51 label=Disarm`** is the compact pane's arm control, in the two states
that define it. The first says it is *not* on the line when there is no engine to arm; the second says
it is, and that it is live (`sensitive=1`) and laid out (`width` > 0 — a width can only be read the
frame *after* it is first shown, see the invariants). `label` must agree with `model->trigger_armed()`
rather than with a fixed word, because a single-shot latch may have disarmed it by the time the probe
looks. `[udpscope] compact arm probe: locked=1 enabled=1 own=1 effective=1 width=51 panel_width=-1
label=Disarm` prints the inputs beside the outcome, and **`panel_width=-1` is the assertion that the
body's own Arm button is not on screen at the same time** — two arm controls for one state is the
defect it exists to catch.
- **`arm sensitive with the trigger off=0`** is the one exception inside that body: `Arm` is a *runtime*
control, so with trigger mode off there is no engine to arm and it is disabled (and labelled `Arm`
again). `1` here is an Arm button that does nothing when clicked.
- **`summary`** is the locked trigger pane's whole content, as `key: value` pairs separated by ` |`. The
pane draws it as a small definition list — `signal`, `condition`, `level`, `mode` on four lines, and
then only the figures that are *not* at their defaults (`noise band`, `HF reject`, `pre-trigger`,
`hold-off`), because a line reading "band 0" is a line that says nothing. One line was tried first and
was too little: the four answers are what a user opens the pane for. `summary=''` with
`body_visible=0` is a locked pane showing nothing at all, which is the defect this reads for.
- **`body_visible=0`** is the settings being off screen, which is what makes the pane a couple of hundred
pixels instead of 580 in `panels:`.
- **The signals list has no lock.** It lists what can be *added* (see `signals panel:`), and the signal
pages always allow acquire, colour, gain/offset, math edit and delete.
## `panel switches:` and the trigger panel's default sensitivity
```
[udpscope] trigger panel sensitive before the probe toggled it: 0; signal list items=1 selected=0; column visible after sidebar toggle off=1; pane switches live while their panes are off=1
[udpscope] panel switches: trigger=1/1 cursors=1/1 drove_toolbar=1 (pane switch / header bar button; `drove_toolbar` is the pane's own switch having moved the button)
```
Each pane's heading carries its own switch, and it is the *same* control as the header bar's button:
both run the same action and both are re-read from the toolbar's state afterwards, so they cannot
disagree. The two lines are the claim, in both directions:
- **`pane switches live while their panes are off=1`** — read before anything turns either on, because
it is the whole point of putting the switches in the headings: a control inside an insensitive parent
cannot be clicked, so a switch that went insensitive *with* its own pane could never turn it back on.
**`0` is that defect**: the switch is drawn, greyed, and useless.
- **`trigger=` and `cursors=` are `pane switch / header bar button`**, and the pairs must agree. The
trigger's is moved from the *toolbar* end (these steps toggle that button to arm the trigger) and the
cursors' from the *pane* end, so one line carries both directions of the sync. **`drove_toolbar=1`**
is the second direction's assertion: the pane's own switch turned the cursors on and the header bar's
button followed.
- `trigger panel sensitive …=0` is the *panel body*, not the panel: the heading row, with its switch,
stays live. `1` here while trigger mode is off is the pane not being disabled at all.
## `dock cursors:` and `dock measures:`
```
[cursors-probe] <what> dock cursors: lines=1 page_lines=1 same_text=1 dock_w=268 side_w=280 fits=1 dock_sensitive=1
[measures-probe] <what> dock measures: lines=13 page_rows=13 same_text=1 dock_w=268 side_w=280 fits=1
```
These ride on the `[cursors-probe]` readings (every state line gets a `dock cursors:` beside it) and on the `[measures-probe]` readings (`added`, `settled`, `final`), and read the dock's compact copy against the page it copies. The dock never formats a value: `update_cursors_pane()` and `update_measures()` hand the same string to the page label and the dock label.
- **`same_text=1` is the assertion**: every dock label (the cursors' who, A, B, delta cells; the measurements' values) reads the same text as the page's label in the same position. `same_text=0` is a second formatter, or a value written to one view only; the two panes must never disagree. The probe compares `gtk_label_get_text()`, so the who cell compares as the text a user reads, markup stripped.
- **`lines` must equal `page_lines` / `page_rows`**: one dock line per page line. A shorter dock is a rebuild that was not mirrored (`dock_rebuild_cursors()` / `dock_rebuild_measures()` run from the pages' rebuilds); `same_text` reads 0 with it, so this names the cause.
- **`fits=1`** (`dock_w <= side_w`, `side_w > 0`): the readouts box fits inside the sidebar scroller. `dock_w` reads **268** inside the 280 px sidebar (the box's own margins and the fixed label widths: who 5 characters, each value 9, a measurement's type 12). **A who cell at 8 characters made the box 304 px and `fits=0`**, which is the defeat of the pin: the fixed-width ellipsis is what makes the dock's layout independent of what a value reads, so a cell given more characters widens the dock for every run, not only for a long name. `side_w=-1` or `dock_w=-1` is a probe that ran before the dock was built.
- **`dock_sensitive=1`** (cursors only): the dock's cursor grid is live whenever the stream has signals; `0` before them ("disable, do not hide"), `0` with signals is the dock never coming back.
- On a session with no cursors line, or with no measurements, `lines=0 page_lines=0 same_text=1` is correct: the dock then shows `no measurements` for the measurements (one non-value label, not counted in `lines`).
## `strip:` (the readouts strip)
```
[cursors-probe] <what> strip: visible=1 expected=1 visible_ok=1 dock_shown=1 cursors=4 measures=0 same_text=1 h=26 sens_same=1 collapsed=1 window=756 groups=100 groups_ok=1 add=1
[measures-probe] <what> strip: visible=1 expected=1 visible_ok=1 dock_shown=1 cursors=0 measures=12 same_text=1 h=26 sens_same=1 collapsed=1 window=756 groups=010 groups_ok=1 add=1
[measures-probe] start strip: visible=0 expected=0 visible_ok=1 dock_shown=1 cursors=0 measures=0 same_text=1 h=0 sens_same=1 collapsed=1 window=756 groups=000 groups_ok=1 add=1
[cursors-probe] dock hidden strip: visible=1 expected=1 visible_ok=1 dock_shown=0 cursors=4 measures=0 same_text=1 h=26 sens_same=1 ...
[cursors-probe] dock restored strip: visible=1 expected=1 visible_ok=1 dock_shown=1 ... collapsed=1 window=756
```
One `strip:` line follows every `dock cursors:` / `dock measures:` line, and each probe ends with a drive that puts the dock away through the wish (`sidebar_wanted = false`), reads the strip 600 ms later (`dock hidden`), restores the wish and reads again (`dock restored`).
- **`visible` equals `expected`, and `expected = (!dock_shown || collapsed) && (cursors on || measures non-empty)`** (`dock_shown` is `show-sidebar`, `collapsed` the split view's, both read in the same frame as `visible`; "cursors on" is the cursors toggle, not the `cursors=` chip count, which is a rebuild's and is 1 even with them off). `visible_ok=1` is that equality; `visible_ok=0` is `sync_readouts_strip()` not run, or run before the reconciliation. A single such line right after the probe's own `update_side_visibility()` or on the very first line (the tick has not set `collapsed` yet) is the sub-frame before the next tick and not a defect.
- **What each leg reads.** `[measures-probe] start` (before the probe adds anything, from a **fresh** config: a stored session brings its measurements back) is the "neither" state: `visible=0 expected=0 groups=000 h=0`. The measures probe's later lines have measurements and the cursors off: `groups=010`. The cursors probe alone has the cursors on and no measurements: `groups=100`; run after the measures probe in the same session it has both: `groups=111`. A line with the strip visible and `groups=000`, or hidden with a group on, is `sync_readouts_strip()` not called from that path.
- **`groups=<cursors><measures><separator>`** is which of `strip_cursor_group`, `strip_measure_group` and `strip_sep` are visible; **`groups_ok=1`** is that they equal (cursors on, a measurement, both). `0` is a group left over from the previous state. **`add=1`** is the strip's `[+]` sitting after the last measurement chip (a button, not the chip box) -- the strip adds a measurement itself rather than only opening the pane; it is structural because a probe that clicked it would open a dialog on every report.
- **`h=0` with `visible=1` on the `cursors-probe ... on` and `measures-probe ... added` lines** is the strip shown in the same frame the probe reads it, before its first allocation; the next line reads 26. It is not a `visible_ok` miss: the toggle handler and `rebuild_measures_pane()` call `sync_readouts_strip()` themselves, so `visible` is already right.
- **`visible=1 expected=1` on every line that has something to read, under `xvfb-run`**, the `dock restored` line and the lines with `dock_shown=1` included: the window is 756 px, below the 900 px `kSidebarRoom`, so the split view is collapsed, the dock is an overlay on the plot, and the strip stays. `visible=0` there is the old rule (hidden whenever the dock is shown) or `collapsed` not being read. The wide case (`collapsed=0`, `dock_shown=1`, `visible=0 expected=0`) is **not reachable from a probe**, because a window with no manager cannot be resized by `gtk_window_set_default_size()`.
- **`h` is 26 with `visible=1`, 0 with `visible=0`.** Any other visible height is the chips setting the strip's height (the button trim in `ui_widgets.cpp` or the `height-request` lost).
- **`same_text=1` is the assertion**: every strip label (the cursors' `who` and delta, the measurements' values) has the dock's text, and the counts of chips match the dock's lines. `0` is a value written to one view only -- a dock cell set without `dock_cursor_cell()` / `dock_measure_value()`, or a rebuild that did not mirror.
- **`cursors` / `measures`** are the chip counts and equal the dock's `lines`.
- **`sens_same=1`**: the strip's cursor chips and the dock's cursor grid have the same sensitivity (`dock_set_cursors_sensitive()`).
- **`collapsed=1 window=756`** on every line under `xvfb-run`; the very first line can read `collapsed=0` (the tick has not set it yet), and then `expected` follows it.
## `[sidebar-probe]`
```
[sidebar-probe] beside window=756 sidebar=280 collapsed=1 shown=1 button=1(sens=1)
[sidebar-probe] regained window=756 sidebar=280 collapsed=1 shown=1 button=1(sens=1)
```
`UDPSCOPE_PROBE_SIDEBAR_MS` reads the sidebar in the two states a window can put it in, and against a hide
that did not come from the toolbar: `beside` first, then the sidebar is forced into the **overlaid** state
(`collapsed`, which is what a window with no room beside the plot produces), then the split view's own
`show-sidebar` is **turned off behind the button's back** — an overlay swipe, or the widget hiding itself —
and `regained` reads 400 ms later.
- **`sidebar` must never be 0.** It was, for every window narrower than the 940 px the divider used to sit
at, and the pane could only be found by dragging the divider back — the report this reading exists for.
It must read its own width (280) in *both* states: `collapsed` changes where the sidebar is, never how
wide it is.
- **`shown` and `button` must agree, always.** They are the panel and the toolbar button, and a button that
says "shown" over a hidden panel is the report this reading exists for. `shown=1 button=1` on `regained`
is also the rule that a hide not asked for through the button does not win.
- **`sens=0` with no source is correct.** The button is insensitive and un-pressed while there is nothing to
put in the sidebar; `button=0` is the button telling the truth about the panel rather than the panel
having forgotten the wish. A source reconnecting must give `shown=1 button=1` again — that is the reading
a `wanted` that got eaten by the no-signals frame shows as `shown=0 button=0(sens=1)`, which is the
regression.
- `collapsed=1` means the window is narrower than `kSidebarRoom` (900), which is the expected reading on a
scaled display, where the logical width is half what the screen looks like.
## `[side-tab-probe]`
```
[side-tab-probe] section=none accums=4 baseline recorded (measure page was_open=1, now closed=1)
[side-tab-probe] section=none rows=4 accums=4 advanced=4 min_n=18
```
`UDPSCOPE_PROBE_SIDE_TAB_MS` (the variable and the tag keep their pre-dock names; the function is `dock_section_probe`) is the one reading for the rule that **the statistics accumulator keeps running while the measurements' page is closed**. The measurements are a slide-over page (`measure`), not a dock section, so the old scroll-the-dock set-up is gone. The probe opens the page, forces one pass so there is a baseline, *closes* the page (its widgets stay in the tree, unmapped) and reads again two seconds later. It re-arms once after 400 ms so the first reading has the layout. **Run it against a config that has seen the first-run tour**, which would close the page it opens (`tour_seen = true`).
- **`was_open=1 closed=1` is the precondition**: the page really was open for the baseline and really is closed for the report, so `advanced=4` says something. `was_open=0` is the page never opening (the tour closed it, or the page is insensitive because the stream has no signals) and `closed=0` is `slideover_close()` not taking effect; either way the run proves nothing. `section=` is the slide-over's open page and **reads `none` on both lines**, because the page is closed.
- **`advanced=4` is the assertion**: every accumulator gained passes while the page was closed, and
`min_n` (the smallest `n` seen) must be non-zero too. **`advanced=0` is the defect**: something gated the
accumulation on the page being open, so the readout would gain a gap in its min/max/avg for exactly as long as
the page was shut. A `Gtk-CRITICAL` here is the other half of the same rule -- a label or an
allocation read on the unmapped page.
- The probe enables a signal **and assigns it to a plot** itself, both deliberately. A signal starts disabled
(enabling is what starts acquisition), and a channel with no trace leaves the live window *still* -- and a
still window is the state `update_measures()` is *supposed* to skip, so a probe that left it that way would
read `advanced=0` for a reason that has nothing to do with the page.
- `rows` is the measurement count and `accums` the accumulator count; the two must agree, because one
accumulator per measurement row is what makes `advanced` a statement about every row.
## `[source-kinds]`
```
[source-kinds] kinds=udps,e1608,flatudp,probe
[source-kinds] form udps: round_trip=1 valid=1
[source-kinds] form e1608: round_trip=1 valid=1
[source-kinds] form flatudp: round_trip=1 valid=1
[source-kinds] form probe: round_trip=1 valid=1
[source-kinds] panel disconnected: panels=1 present=1 sensitive=0
[source-kinds] panel connected: panels=1 present=1 sensitive=1
[source-kinds] capture button: present=1 sensitive=0
[source-kinds] command: before=0 after=1 drained=1
```
`UDPSCOPE_PROBE_SOURCE_KINDS=2000`, run as `--source probe --source-opt rate=500`, is the one reading for the
source-plugin path from the registry to the producer thread. The variable also registers the test-only `probe`
kind, so a run without it never shows that kind. The `form` line appears once per registered kind.
- `kinds=udps,e1608,flatudp,probe`: a missing kind means `register_builtin_sources()` was not called before `parse_args()`,
or the env var gate is wrong.
- `round_trip=0`: a form's `write` and `read` disagree about a key -- a renamed `SourceConfig` key, or an
absent key that was not defaulted. `valid=0` is a default configuration the form refuses. The e1608 form's sample sets address, port, scan_rate, mode, ai1, trigger, dir1, plot0 and gen_rate; a mismatch names one of those. The flatudp sample sets port, bind, multicast, iface, rate, endian and signals.
- `panel disconnected: sensitive=0 present=1`: **disable, don't hide.** `present=0` is a panel that was hidden
or never added; `sensitive=1` is a panel that stays live with no source behind it.
- `panel connected: sensitive=1`: anything else means `apply_source_panels` acted on the wrong widget (it must
act on the kind's own widget, not the scroller around it).
- `capture button: present=1 sensitive=0`: a source with no datagrams leaves the capture UI **disabled, not
hidden.** `sensitive=1` is the button live for a source that has nothing to capture, `present=0` a hidden one.
- `command: before=0 after=1 drained=1`: the command reached the producer thread through `post()`.
`drained=0` is a queue that `poll()` does not drain on some path; `after` greater than `before + 1` is a
command run twice.
The UDPS run has no `source-` slide-over page, because the UDPS kind has no panels.
## `[e1608]`
```
[e1608] panel ao: present=1 sensitive=1 connected=1
[e1608] panel dio: present=1 sensitive=1 connected=1
[e1608] actions: ao0_level=1 do1=1 counter_reset=1
[e1608] stream: connected=1 signals=4 expect=4 names=AI0,AI1,DI0,Counter frames=274 rate=1000.000 ok=1
[e1608] calibration: ai0_mean=0.5000 expect=0.5000 ok=1
[e1608] capture button: present=1 sensitive=0
[e1608] state: connects=1 message=''
[e1608] decode: ok=1
[e1608] scan socket closed by the board
[e1608] reconnect: connects=2 connected=1 ok=1
[e1608] resumed: frames_before=612 frames_after=662 ok=1
[e1608] generator: requested=100.0 achieved=99.7 panel='100 Hz (requested 100 Hz)' ok=1
[e1608] generator stopped: achieved=0.0 panel='off (requested 100 Hz)' ok=1
[e1608] scan: found=1 ip=172.21.121.113 mac=00:80:2f:00:16:08 status='1 board found' fill_addr=1 fill_mac=1 rescan_replaced=1 ok=1
```
`UDPSCOPE_PROBE_E1608=1500` (the first eight lines; `frames=` and the exact mean vary) and
`UDPSCOPE_PROBE_E1608_RECONNECT=8000` (the next two) run as `--source e1608` against `tools/mock_e1608.py`
with the options in `verification.md`; `UDPSCOPE_PROBE_E1608_SCAN=1000` (the `scan` line) is a run of its own. The line between `decode` and `reconnect` is the source's own stderr message
about the drop, not a probe line (its text is the error the source met, once per distinct message). `connected=0` throughout, with
`actions: skipped (not connected)`, is the no-board run; it is a reading and not a failure, and it also prints
the one `[e1608] handshake with ...: Connection refused` line, `stream: ... signals=0 expect=1 ... ok=0`,
`calibration: ai0_mean=nan ... ok=0` and `state: connects=0 message='handshake with ...'`.
- `panel ...: present=1 sensitive=<connected>`: **disable, don't hide.** `present=0` is a panel that was never
added; `sensitive` that differs from `connected` is `apply_source_panels` acting on the wrong widget.
- `actions: ao0_level=1 do1=1 counter_reset=1`: each control was found by its widget name (`e1608-ao0-level`,
`e1608-do1`, `e1608-counter-reset`). A `0` is a renamed widget, or (`do1`) `dir1=o` missing from the run. The
effects are read off the **mock's log**, not here: a DAC write near 2.5 V (within 0.01), `DOUT 0x02`, `COUNTER_RESET`.
A control that was found but whose effect is missing is a command that `post()` did not deliver or `poll()`
did not run.
- `stream: signals=4 expect=4 rate=1000.000 ok=1`: the enabled channels plus the plotted digital lines plus the
counter, at the scan rate. `signals` below `expect` is a signal the source did not declare; `rate` off by a
factor is the pacer computed from the wrong aggregate. `ok` also requires `names=` to be the enabled analog
inputs, then the plotted digital lines, then `Counter`, in that order: swapped channels read `ok=0`.
- `calibration: ai0_mean=0.50xx ok=1`: the mock's AI0 is 0.5 V under a 5 Hz sine of 0.1 V, read through
non-trivial factory coefficients; the mean over two periods lands on 0.5 V only when `decode()` applies them.
About `0.2043` is the **uncalibrated** reading (the calibration read failed, or `calibrate=0`); `nan` is a
run that never got AI0 (not connected, or AI0 not enabled).
- `capture button: present=1 sensitive=0`: a source with no datagrams leaves the capture UI disabled, not hidden.
- `state: connects=1 message=''`: a non-empty message at this point is a refusal that happened (for example
`calibration memory refused; using nominal values`), which the stream then ran through.
- `decode: ok=1`: a fixed raw buffer against hand-computed volts (intercept 100, BIP10V and BIP5V, the code
clamp at both ends). It needs no board; `ok=0` is a change to the calibration arithmetic.
- `reconnect: connects=2 connected=1`, `resumed: ok=1`: the mock dropped both links at about 3 s; the source
reported it, retried after 2 s and the frame count moved on. `connects=1` is a source that never noticed the
drop (neither the scan read nor the status check raised it), `resumed: ok=0` is a reconnect that did not restart
the scan (it also reads 0 when `connects` is still 1, since a frame count that grows on a link that never dropped
proves nothing).
- `generator: requested=100.0 achieved=99.7 ... ok=1`, `generator stopped: ... ok=1`
(`UDPSCOPE_PROBE_E1608_GEN=1500`, a run of its own with `gen_rate=100`): `requested` is `gen_rate`, `achieved`
the source's own measurement (generator ticks that wrote, per second of wall time, over a window of about 1 s),
`panel` the subtitle of the Analog out panel's "Update rate" row. `ok` needs `achieved` within 70% to 110% of
`requested` **and** the panel to quote the requested rate; the mock answers at once, so it reads within a
percent of `requested`. Far below is a `run_timers()` that re-arms with a constant (the 20 ms the setting
replaced reads 50) or a tick that runs late; `panel='off'` while `achieved` is non-zero is a panel that
did not refresh (it ticks only while its page is on screen) or one that reads the wrong readout. This line says
nothing about a real board, whose DAC writes are slower; there the same row is the measurement. The stopped
line must read `achieved=0.0` and a panel that starts with `off`; a non-zero value is a rate that
`update_gen_flags()` did not reset when the last generator stopped.
- `scan: found=1 ... ok=1`: the form's **Scan network** row against the mock. `found` is the result rows after
the first scan; `0` with the second line `did not answer` is an environment with no up, non-loopback
broadcast interface that reaches the mock (or a refused broadcast), **not a pass**. `ip` is the listed address
of the expected board and `mac` the MAC the probe looked for.
- `status='1 board found'`: the row's subtitle at the end of the first scan (`N boards found` with more
than one). `No board found` with a mock running is the probe's sockets not reaching it.
- `fill_addr=0`: a click on a result did not set only the address (the MAC or subnet kept a stale value), or it
told the dialog something other than once. `fill_mac=0`: the same for **Use MAC**.
- `rescan_replaced=0`: the results of the first scan were kept or doubled by the second, so `start_scan` did not
remove them.
## `[flatudp]`
```
[flatudp] codec: ok=1
[flatudp] form: initial_valid=0 good_valid=1 size_ok=1 dup_valid=0 dup_msg=1 oversize_valid=0 add=1 remove=1 wave_valid=1 wave_sub=1 wave_roundtrip=1 mixed_valid=0 mixed_msg=1 length_valid=0 length_msg=1 cap_valid=0 cap_msg=1 cap_wave_valid=1 vec_shown=1 vec_drive=1 vec_reset=1 ok=1
[flatudp] datagram of 22 bytes, expected 21, check the signal list
[flatudp] stream: connected=1 signals=7 names=Trigger,Ia,Cnt,Wave[0],Wave[1],Wave[2],Wave[3] expect=Trigger,Ia,Cnt,Wave[0],Wave[1],Wave[2],Wave[3] frames=368 rate=1000.000 ok=1
[flatudp] channels: count=7 expect=7 ok=1
[flatudp] continuity: Trigger_breaks=0 Ia_breaks=0 Cnt_breaks=0 Wave[0]_breaks=0 Wave[1]-[0]_breaks=0 ok=1
[flatudp] wrong size: count=4 message='datagram of 22 bytes, expected 21, check the signal list' ok=1
[flatudp] capture button: present=1 sensitive=1
[flatudp] state: valid=3683 wrong=4
[flatudp] silence: connected=0 model_connected=0 after_ms=4300 ok=1
[flatudp] resumed: connected=1 frames_before=401 frames_after=410 ok=1
```
and, for the waveform-only list `Wave:int16[4]w`:
```
[flatudp] stream: connected=1 signals=1 names=Wave expect=Wave frames=3737 rate=4000.000 ok=1
[flatudp] channels: count=1 expect=1 ok=1
[flatudp] continuity: wave_breaks=0 ok=1
```
`UDPSCOPE_PROBE_FLATUDP=2500` (the first lines; counts vary) and `UDPSCOPE_PROBE_FLATUDP_GAP=1500` (the last two)
run as `--source flatudp` against `tools/mock_flatudp.py` with the options in `verification.md`. The second line is
the source's own stderr message, printed once per distinct wrong size, not a probe line. With no sender the stream
lines read `connected=0 ... frames=0 ... ok=0`, `continuity` is skipped and the capture button reads
`sensitive=0`: a reading, not a failure. A run without `--bad-every` reads `wrong size: count=0 ... ok=0` for the
same reason.
- `codec: ok=1`: the `signals` text round-trips (including a `w` row and a bracketed count of 1 on a waveform), a
hand-built 24-byte datagram decodes to the hand-computed values in both byte orders (`uint8` 254, `int16` -2,
`float32` 1.5, `uint64[2]` 1 and 4294967298, `int8` -1), so do a `float64`, `uint16`, `int32`, `uint32` and
`int64`; `decode_channels` puts a `channels` vector in `count` columns and a waveform in one; and the validity
rules refuse what they should (a waveform of count 1, a waveform mixed with another row or of another length,
257 channels). It needs no source, so `--source probe` shows it too. `ok=0` is a codec defect: a swapped byte
order, a wrong width or a rule that does not fire.
- `form: ... ok=1`: driven through the widgets, not the settings. `initial_valid=0` -- an empty list is not
connectable (`1` is a form that lets a source with no signals through). `good_valid=1 size_ok=1` -- a
13-byte list is valid and its subtitle says `13 bytes` and `6 channels` (`W:int16[4]` as channels).
`dup_valid=0 dup_msg=1` -- a duplicate name is refused *and the subtitle says why* ("used twice").
`oversize_valid=0` -- two `float64[4096]` rows (65536 bytes) exceed a datagram. `add=1` -- two clicks on
"Add signal" append `S1` and `S2`; `remove=1` -- the first row's "Remove" button takes that row out. A `0`
on `add`/`remove` with a `Gtk-CRITICAL` is a row handled after it was removed. The vector fields:
`wave_valid=1 wave_sub=1 wave_roundtrip=1` -- two waveform rows of one length are valid, the subtitle says
`2 channels`, and `signals` reads back with its `w`; `mixed_valid=0 mixed_msg=1` -- a waveform beside a
scalar is refused and the subtitle names the waveform rule; `length_valid=0 length_msg=1` -- waveforms of
two lengths are refused ("same length"); `cap_valid=0 cap_msg=1 cap_wave_valid=1` -- 300 channels are over
the 256 cap and the subtitle says `256`, while two `[200]w` waveforms (2 channels) are fine; `vec_shown=1`
-- the "Vector as" drop-down (`flatudp-vector`) is shown on a count above 1 and hidden on a scalar row;
`vec_drive=1` -- setting it to Waveform writes `Row::wave` (one waveform beside a channels row, then beside
a scalar, are both still invalid, and the text gains its `w`); `vec_reset=1` -- a row whose count returns
to 1 hides its drop-down and returns to Channels. Like `codec`, it needs no flatudp source.
- `stream: ... ok=1`: the model's signals are the list's channels in order, each at its declared rate: `rate`
for a scalar or a `channels` element (named `Name[k]`), `rate * count` for a waveform. `names` out of order is a
decode that mixed rows; a rate that is not the expected one is a `sampling_rate` that did not reach the model
(the ring and the decimation are then sized for the wrong cadence; a waveform at `rate` instead of `rate * 4`
has a time axis 4 times too long).
- `channels: count=7`: one channel per `SignalInfo`, which is `channel_count(rows)`: a scalar 1, a `channels`
vector `count`, a waveform 1 (its `count` values are consecutive samples of that one channel).
- `continuity: <name>_breaks=0 ...`: the mock's row k carries `(i * (k + 1) + e) % 100`, so consecutive samples
of a channel from row k step by k + 1 modulo 100 (`Trigger` 1, `Ia` 2, `Cnt` 3, `Wave[0]` 4), and `Wave[1]-[0]`
is the same-sample difference of two elements, which must be 1. A break is a lost or reordered datagram, a
batch flushed with the wrong number of samples, an element mapped to the wrong channel, a wrong byte order
or ingest decimation that is not 1:1. A waveform list reads `wave_breaks`: samples step by 1 inside a
datagram and by `2 - count` into the next, at a phase the probe finds from the first such step and then holds;
a non-zero count is a block of the wrong length or one that dropped samples. (Before the `channels` form, a
mixed list showed ~400 breaks here: a scalar next to a vector was zero-padded to the vector's length.)
- `wrong size: ok=1`: wrong-size datagrams were counted and the message names the received and expected size.
The message must also be the only stderr line of its kind: one per distinct size, however many arrive.
- `capture button: present=1 sensitive=1`: the kind reports datagrams, so the capture UI is live (contrast
`[source-kinds]`, where the probe kind leaves it disabled). With no stream it reads `sensitive=0`.
- `silence: connected=0 model_connected=0 after_ms=...`: 2 s without a valid datagram raised `Disconnected`
(`after_ms` is the wait from the stream coming up to the drop; it only reads the gap's start in the app's
clock). `connected=1` is the timeout not firing or wrong-size datagrams refreshing the silence clock (they
must not); `never connected` is a stream that never came up before the probe gave up after 20 s.
- `resumed: ok=1`: `Connected` was raised again and frames resumed. `connected=0` here means the source never
recovered after the sender came back.
## `[prefs-probe]`
```
[prefs-probe] opened=1 rows=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000/default cfg=1 cfg_switch=1
[prefs-probe] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark xhair=0 live=1
[prefs-probe] default restored: file=''
[prefs-probe] browse: cancelled=1 icon_ok=1 field='/tmp/k5/cache/udpscope/events'
```
`UDPSCOPE_PROBE_PREFS_MS` opens the preferences, changes every row, and reads the settings file *back
from disk* after each change -- not from `App::settings`, because a dialog that edits its own copy and
never writes is exactly the failure this has to catch.
`store=` is the directory the cache is **really** using, and the `cfg=`/`cfg_switch=` pair is that
directory's key: `cfg=1` reads `store=` ends `/<source>/<active configuration>/` (the cache is per
source *and* configuration, so two configurations of one source never list each other's events), and
`cfg_switch=1` reads `retarget_event_store()` -- the one helper every configuration switch calls --
moving the key to another configuration and back. A `cfg=0` names a store still keyed on the source
alone; a `cfg_switch=0` a switch that leaves the cache pointing at the configuration it left. **A change applies as it is made**: the rows are
libadwaita's (`AdwEntryRow`, `AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow`), and the `preferences.ui`
dialog has no Apply button for the probe to press.
- `opened=1 rows=1` is the dialog having come up *and* its rows having resolved by id. The file is
checked by the build (`xml-stripblanks`) and by `GtkBuilder` at load; a missing **id** is the one
mistake neither sees, and reading a null row on the first change is what it costs.
- `dir` is the directory the *row* is offering -- the preference if there is one, otherwise the default
written out in full -- and `store` is the directory the cache is really using. **`store` must be `dir`
plus the source's folder name**, which is the reading that says the row shows the path in force rather
than a plausible string.
- The second line must echo the probe's own input: directory `/tmp/udpscope-prefs-events`, keep `7`,
relative time `1`, theme `dark`, and `xhair=0` — the crosshair's axis-badge switch, which the
probe drives off exactly as it drives the rest, and which reaches the running plots at once
(`RowSet::set_crosshair_axis_labels()`). Any field that differs means the row, the write or the
parse dropped it. `live=1` is the theme having reached the running style manager rather than only the file.
- `default restored: file=''` closes the loop from the other side: the probe puts the default path back
into the row, and the file must end up with **no** `event_cache_dir` at all. A path in the file is a
user's choice, and leaving the default is not one -- pinning it there would stop the setting following
the cache directory it is named after.
- `browse: cancelled=1 icon_ok=1 field='...'` is the folder chooser. `cancelled=1` is its cancellable
having been cancelled, which is the *cancel* path a picker invites (a user's own Cancel button arrives
in the same callback with the same null answer); `icon_ok=1` is the icon theme knowing
`folder-open-symbolic`; and the field must still read the directory it read before -- **a cancelled
picker changes nothing**.
```
[prefs-probe] opened=1 apply=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000/default cfg=1 cfg_switch=1
[prefs-probe] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark live=1
[prefs-probe] default restored: file=''
[prefs-probe] browse: toplevels=3 extra=1 icon_ok=1 field='/tmp/k5/cache/udpscope/events'
```
`UDPSCOPE_PROBE_PREFS_MS` opens the preferences dialog, fills its form, clicks **Apply** and reads
the settings file *back from disk* -- not from `App::settings`, because a dialog that edits its own
copy and never writes is exactly the failure this has to catch.
- `opened=1` is the window having come up; `apply=0` would mean its *Apply* button was never stored,
which would make the click below test nothing.
- `dir` is the directory the dialog *offers* -- the preference if there is one, otherwise the default
written out in full (it is deliberately not a description: "the standard location" is not something a
user can act on) -- and `store` is the directory the cache is really using. **`store` must be `dir`
plus the source's folder name**, which is the reading that says the field shows the path in force
rather than a plausible string. `dir` here is the default because the file had no preference yet.
- The second line must echo the probe's own input -- directory `/tmp/udpscope-prefs-events`, keep
`7`, relative time `1`, theme `dark`. Any field that differs means the form, the write
or the parse dropped it.
- `live=1` is the theme having reached the running style manager rather than only the file.
- `browse: toplevels=N extra=1 field='…'` is the folder chooser: opened from its button, then cancelled
from *outside* it. `extra=1` is the new toplevel that is the dialog -- a count, because the dialog is
not named after anything a probe could match on -- and the field must still read the directory it read
before it opened, because **a cancelled picker changes nothing**. This is the async-dialog crash
surface (a callback landing after its dialog went away), which is why the preferences window is closed
a beat later than the cancel and why the callback tolerates every widget being null.
- `icon_ok=1` is the icon theme knowing `folder-open-symbolic`, the same question the toolbar-icons
line asks of the bundled six. It is a reading rather than a convention because the button is an icon
and *nothing else*: a name the theme does not carry draws a missing-image placeholder, and nothing
else in a run would say so.
- `default restored: file=''` closes the loop on the same idea from the other side: the probe types the
default path back into the field and presses *Apply*, and the file must end up with **no**
`event_cache_dir` at all. A path in the file is a user's choice, and typing the default is not one --
pinning it there would stop the setting following the cache directory it is named after.
## `mnemonics=` in the two preference probes
```
[prefs-probe] mnemonics=4/4
[sources] form mnemonics=9/9
```
How many of the dialog's rows have `use-underline` set -- the property that makes a `_` in a row's title a
mnemonic. It is FALSE on `AdwPreferencesRow` by default, which is why these titles were drawn with a
literal underscore; anything below the full count is that bug back. Read by property name rather than
through one row type, for the reason in `invariants-gtk-and-chrome.md`. The form's line appears only when
`UDPSCOPE_PROBE_NEWHOST` drives the form. It reads 9/9 since the chooser gained its Source type row, which has a
mnemonic of its own (a non-UDPS kind's form has fewer rows: the `probe` kind reads 6/6).
## `trigger panel:`
```
[udpscope] trigger panel: boxes=16 controls_inside=15/15 search_widgets=4/4 search_inside=4/4 advanced_widgets=5/5 advanced_inside=5/5
```
The trigger panel's frame, read from the window: **the `.ui`'s own panel** (`trigger_body`), not the box
this app wraps it in — the heading row and the locked summary are the code's arrangement, and reading the
*wrapper* made this line report `boxes=2 … search_inside=0/4` the moment the heading moved out of the file.
That is the failure this line is for, so it is worth knowing it can happen to itself. `boxes` is the row
boxes the `.ui` holds and `controls_inside`
counts the sixteen controls the code built and then appended into those rows -- the last three being the
noise band and the HF reject, whose rows arrived with the noise-rejection work, and the persistence depth
counts the fifteen controls the code built and then appended into the panel's own rows -- the noise band and
the HF reject, the persistence-depth spin and the record switch among them, and the Arm/Force *pair* not,
because that row holds buttons and a `boxes` count reads the rows either way.
`boxes` is **16** since the trigger's display settings moved into the `Advanced` expander: the multiple-event
depth and the four density controls left the panel's own child list, and the expander that holds them is not a
box. Those five are what `advanced_widgets`/`advanced_inside` count, for the same two reasons the search pair
exists -- the ids resolving, and the controls being *in* the block -- and an expander's child is **not** in the
widget-parent chain, so a control there is invisible to `controls_inside` by construction (see
`invariants-gtk-and-chrome.md`). The walk for a block therefore starts at **its own** expander,
`gtk_expander_get_child(GTK_EXPANDER(app.search_exp))`: the first version of this line took "the first
expander in the panel", which stopped naming the search block the moment a second expander was added *before*
it -- and would have read `search_inside=0/4` on a panel that was entirely correct. `[defaults] trigger:`
prints the same panel's spins in tree order, so the two lines together read what the file gave and what the
code put in it.
## `[persist-probe]` and `persistence:`
```
[persist-probe] held depth=4 kept=3 bytes=20998152 budget=209715200 dropped=0 signals=5 channels=4 layers=12 want=12 placed=12 alphas=[0.1250,0.2500,0.5000] ages=[3,2,1] row0=[1:'sine (A)'@0.5000 2:'sine (B)'@0.2500 3:'sine #4'@0.1250] drawn=12
[persist-probe] held axis=[-0.2500..0.2500] pin=[-0.2500..0.2500] snap=[-0.2500..0.2500] n=250001 ev=[-0.2500..0.2500] ev_is_snap=1 pin_is_snap=1
[udpscope] persistence: depth=4 kept=3 bytes=20998152 budget=209715200 dropped=0 layers=0 want=0 placed=0 schedule=[0.1250,0.2500,0.5000] alphas=[]
```
The persistence display: the last `depth` triggered acquisitions drawn behind the current one, each older one
at half the previous one's brightness. Run with one signal per row and a 0.5 s window against the mock at
500 kS/s, which is what the two lines above are.
**`alphas=` is the assertion, oldest first**: `0.5000,0.2500,0.1250` — halving, with `ages=[3,2,1]` matching
one for one. A flat, ascending, or age-gapped list is the decay not being applied or applied backwards (the
rule is in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)). `row0=` is the same
schedule read per slot, with each layer's name — `sine (A)` one acquisition back, `sine (B)` two, `sine #4`
three, the accumulate path's own suffix vocabulary. `layers=` is the layer slots that exist,
`want=` the depth's whole demand across the rows, `placed=` what fitted: **the three agreeing is a display
that shows every age it was asked for**, and `placed < want` is a layout with no free trace slot left (a row
of `S` signals shows `floor((8 - S) / S)` ages of each, so the default four-signal row shows one).
**`kept`/`bytes`/`dropped` are the *retention*, and they bound what those slots can draw.** `kept=3` of the
3 asked at `bytes=20998152` (21 MB: three acquisitions of a 0.5 s window at 500 kS/s over four channels,
~7 MB each) against `budget=` the planned ring bytes (`persist_budget_bytes()`, `Partition::bytes()`). `drawn=` is how many layer slots are
actually *bound* to a retained acquisition, and it is a different number from `placed=` exactly when the
budget bit — which is the run to take next, on the fast mock with a long window:
```
[persist-probe] settled: kept=1 of 7 after 270 polls
[persist-probe] held depth=8 kept=1 bytes=72798888 budget=209715200 dropped=0 signals=5 channels=4 layers=28 want=28 placed=28 alphas=[0.0078,0.0156,0.0312,0.0625,0.1250,0.2500,0.5000] ages=[7,6,5,4,3,2,1] row0=[1:'sine (A)'@0.5000 … 7:'sine #8'@0.0078] drawn=4
[persist-probe] held axis=[-1.0000..1.0000] pin=[-1.0000..1.0000] snap=[-1.0000..1.0000] n=2600000 ev=[-1.0000..1.0000] ev_is_snap=1 pin_is_snap=1
[udpscope] persistence: depth=8 kept=2 bytes=145598128 budget=209715200 dropped=2 layers=0 want=0 placed=0 schedule=[0.0078,0.0156,0.0312,0.0625,0.1250,0.2500,0.5000] alphas=[]
```
`UDPSCOPE_PROBE_PERSIST_DEPTH=8 UDPSCOPE_PROBE_PERSIST_WINDOW=2` against the mock at 1.3 MS/s: one retained
acquisition is 72.8 MB, so the budget (the planned rings, ~201 MB for four channels at the default 10M) keeps two and drops two — `kept` below the depth, `dropped`
non-zero, and **`drawn=4` against `placed=28`**: the layer slots exist and the oldest seven ages per row draw
nothing at all. That is the memory bound as a reading, and `settled:` is the wait saying so (the retention
can never reach the depth, so the probe reports what stopped growing instead of waiting out its cap — its
`kept` can therefore be mid-fill, which is why the *dump's* line below is the settled one). The
in-doubling of `alphas=` there is the same rule at full depth: 1/128 at the eighth, which is why the depth
stops at 8. Do not read this run's `frame cost:` as persistence's: a 2 s window at 1.3 MS/s costs ~90 ms a
latch in `publish cost:` on its own, with or without persistence.
**`ev_is_snap` and `pin_is_snap` must be 1**, and that is the rule about the past: the pin the held event's
axis is re-stated from (`event_data_window()`) is the *snapshot's* own window, so the older acquisitions —
which are bound event series on the same rows — moved neither the axis nor the framing; both runs above read
`axis = pin = snap = [-0.2500..0.2500]` with `n=250001`. They are read on a frame that is *bound* (the probe
waits for one), because a latch landing mid-frame leaves the mode `Held` while the rows still hold the live
ring's bindings, and a reading taken there says nothing about either.
## `[density-probe]` and `density:`
```
[density-probe] arming on 'square', window 0.5 s, display off
[density-probe] off enabled=0 fade=acquisitions tau=1.000 factor=0.500 rows=0 traces=0 cells=0 ink=0.0 peak=0.0000 ratio=1.000000
[density-probe] on enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 traces=4 cells=1044714 ink=99403.0 peak=1.0000 ratio=1.000000
[density-probe] step-acquisitions enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 traces=4 cells=1044714 ink=149104.5 peak=1.5000 ratio=1.500000
[density-probe] time-before enabled=1 fade=time tau=1.000 factor=0.500 rows=4 traces=4 cells=1044714 ink=121738.0 peak=1.2247 ratio=1.000000
[density-probe] step-time enabled=1 fade=time tau=1.000 factor=0.500 rows=4 traces=4 cells=1044714 ink=44784.9 peak=0.4505 ratio=0.367879
[density-probe] frame cost: tick before=3.75 ms (145 frames) after=3.59 ms (24 frames) draw before=3.93 ms (125) after=3.23 ms (6)
[udpscope] density: frames=918 rows=4 traces=4 cells=1044714 grid_bytes=4178856 ink=279073.5 peak=2.2 upload_bytes=1044714
```
The **phosphor density** (`Plot::set_density()`, the trigger pane's `persistence` switch): the axes
rect in device pixels **up to `kDensityCellCap`** (262 144 cells, 1 MiB of counts) as an intensity
image **per trace**, in that trace's own signal colour, every acquisition accumulated into it (the
newest included), drawn *in place of* the traces and fading by a count or by the clock. Past the cap
the plotter scales the rect down uniformly and magnifies the image back over it with a linear
filter, so the number this probe reports is bounded whatever the window is. The numbers are the
plotter's own -- `DensityGrid::ink()`/`peak()` summed and maxed over the traces the display is on,
which is the same source the library's tests read -- and the probe drives the panel's **own
widgets** for the switch and the fade, so what it measures is this app's wiring.
- **`off` is the display without it**: `rows=0 traces=0`, so there is no grid at all and both numbers
are zero. That is also the run the frame cost is read against, and it is a plain run -- the density
is off by default and nothing draws it.
- **`on` is one sweep**: `traces=4` -- one grid per signal, four rows of one signal each -- and
`peak=1.0000` exactly, with `ink=99403` the cells that sweep covered. A cell takes **1.0 once per
trace per sweep** however many of the trace's own samples crossed it, which is what makes a sweep's
peak *one* rather than the eighteen a per-hit deposit read on the same run -- and what makes the
sweeps behind it visible at all.
- **`step-acquisitions`** deposits one more sweep: `peak=1.0000 -> 1.5000`, which is `1 + 0.5 x 1` -- the
panel's factor 0.5 applied to what was there, then the new sweep's own pass on top. The ratio is
**not** a constant to expect -- the acquisition on screen can move between the two reads, so what it says
is "the old image halved and a pass landed on it"; the exact relation is the *library's* case
(`a_density_halves_per_acquisition_and_keeps_the_newest_brightest`), where the same frame is deposited
twice.
- **`step-time` is `1/e` by construction**: one step of `dt = tau` through the plot's own `decay_density()`,
so `ratio=0.367879` is `exp(-1)`. The leg before it (`time-before`) reads the same image under the time
fade (a peak between 1 and 2, `1.2247` here), and the pair is the reading that says the *panel's* constant
reaches the grid.
- **`frame cost:` is the A/B inside one run**: the accumulators as of the tick the display was switched on
against the frames after it, so both means are the same stream, the same layout and the same mock. The
"before" side is a mean that **includes the run's startup frames** (the first paints are the expensive
ones), so it is a high-water mark and not a steady-state mean: at four rows of ~261 000 cells each (the
cap) the pair above reads `tick 3.75 -> 3.59 ms`, i.e. the switch is *inside* the startup noise, and a
quiet machine reading the same line before the cap read `tick 0.62 -> 9.53 ms` at 2.2 M cells. **The
reading that prices a display at a given window is the library's**, taken headless and against a fixed
geometry, in the plotter's `docs/data-and-performance.md`: a 3040x1700 framebuffer with four rows of one
trace and a 2 s window, where the grid pass alone was **12.9 ms of tick and 12.8 ms of draw a frame and
4.9 MB uploaded** before the cap and **3.9 / 5.6 ms and 1.0 MB** after it, over the same frames.
- **`cells`/`grid_bytes` are the per-signal memory figure**: at most **262 144 cells and 1 048 576 bytes
*per signal***, and the four signals here read 1 044 714 cells / 4 178 856 bytes because a 1349x424 device
axes rect is already past the cap. It is the cap and the trace count, and neither the trigger nor the
window beyond the cap has anything to say about it -- the app's default layout, four signals in one row,
is four grids for that one row and 4 MiB whoever dragged the window where.
- **The dump's `density:` line** is captured on the last tick the display was **on**, not read at exit: the
exit probe switches the trigger off before it dumps, which takes the display with it, so a dump that asked
the plots would say `rows=0` about a run whose tail had the image up. `upload_bytes` is the **most** any
one frame uploaded -- one byte a cell **per trace**, over the grids that moved -- and not the last frame's,
for the same reason.
- **A frame the display is drawing is not a `[notrace]` frame.** `Renderer::draw_traces()` returns while a
density is on, so every frame of a persistence display stages no trace; the paint probe skips its
traceless accounting for those (they are the display working, not "a frame asked to draw something it had
nothing for") and the `[notrace]` line -- one per frame, and so behind `UDPSCOPE_PROBE_NOTRACE` -- is not
printed for them. A density run's `traceless paints:` therefore no longer counts them: it read `653 (637
after the first bind)` for a run whose density was up six hundred frames.
**Read `peak` as the display's ceiling**: with the default `full_scale` of 1.0 a cell at 1.0 is full ink
(the trace's own colour), so a steady display under the acquisition fade sits between 1 and 2, and a `peak`
well above that (the per-hit deposit read 189 after two sweeps) is the defect this probe's `on` leg now
catches.
**And `N = 1` is the display without the feature**, which is the same command at
`UDPSCOPE_PROBE_PERSIST_DEPTH=1`:
```
[persist-probe] held depth=1 kept=0 bytes=0 budget=209715200 dropped=0 signals=5 channels=4 layers=0 want=0 placed=0 alphas=[] ages=[] row0=[] drawn=0
[persist-probe] held axis=[-0.2500..0.2500] pin=[-0.2500..0.2500] snap=[-0.2500..0.2500] n=250001 ev=[-0.2500..0.2500] ev_is_snap=1 pin_is_snap=1
```
Nothing retained, nothing planned, nothing bound, no bytes — and the framing line identical field for field,
which is what "the same display as before the feature existed" means as a reading. **That run is also the
baseline for the exit dump's `frame cost:` line**, because the tick mean is a mean over the whole run and the
depth is raised at `UDPSCOPE_PROBE_PERSIST_MS` (2.5 s of 16 s), so the difference between the pair is a
*lower bound* on what the layers cost. At the scale above (0.5 s window, 500 kS/s, four rows, one signal
each):
| run | tick mean | draw mean | paint mean | frames |
|---|---|---|---|---|
| `..._PERSIST_DEPTH=1` | `0.82 ms` | `11.10 ms` | `15.96 ms` | 936 |
| `..._PERSIST_DEPTH=4` | `1.18 ms` | `28.92 ms` | `34.60 ms` | 470 |
Thirteen extra traces across the four rows (four signals plus nine layers, the twelfth layer's signal having
no free slot in its row) for **+17.8 ms of draw a frame**, i.e. ~2.6× the ink of the same plot without
persistence; `tick` moves with it by a few tenths, mostly because there are half as many frames to average.
`[udpscope] persistence:` is the same state at the end of the run, and the one line the *standard* self-check
prints without a probe: `depth`, the retention (`kept`/`bytes`/`budget`/`dropped`), the plan
(`layers`/`want`/`placed`, 0 here because the exit probe's go-live has already cleared the layers —
`layers=0` in that line is correct, not a defect), `schedule=` the alphas the depth asks for oldest first
(the rule; the probe's `alphas=` is what the rows were built with), and `alphas=` for row 0.
## `signals panel:`
```
[udpscope] signals panel: labels=3 list=1 rows=5 signals=4 math=0 divider=1 drag_sources=0 addable=4 addable_ok=1 empty_label=0
[udpscope] signals panel: labels=3 list=1 rows=2 signals=1 math=0 divider=1 drag_sources=0 addable=1 addable_ok=1 empty_label=0
[udpscope] signals panel: labels=3 list=1 rows=1 signals=0 math=0 divider=1 drag_sources=0 addable=0 addable_ok=1 empty_label=1
```
The signals panel's frame read from the window, and in the same line the *shape* of the one list. The page
is "Add a signal": it proposes only what can be added, so the list holds the streamed signals that are not
acquired (`signals`), the math channels that are not computed (`math`), and the `MATH` divider with its `+`
(`divider`, 1 when `rows == signals + 1 + math`, 0 otherwise). An acquired signal has a badge in the bar and
is not listed -- **unless the badge bar has overflowed** (more than 6 enabled signals, the "N signals" badge),
when the acquired ones are listed too, since the bar no longer shows them. `addable` is the same count
worked out from the model (unacquired listed signals, or all of them when overflowed) and `addable_ok=1` is
`signals + math == addable`; `0` is a list filter that drifted from the rule. `empty_label=1` is the dim
"every signal is acquired" label shown, which must happen exactly when `addable=0` and the stream declares
signals (the divider row stays, so a math channel can still be created). **`drag_sources` must be
`0`**: dragging a signal onto a plot is the signal *badge's* job now (see `[sigbadges]`, which counts the
badges' own sources), and the list's rows only open the signal's page; a non-zero reading is a second, competing
drag handle on the list. `labels=2` is a `.ui` that lost
one of the three labels (heading, hint, the empty-state label) — counted over the panel's subtree *above* the list, because the rows' own name
and unit labels are the code's — `list=0` one that lost the list -- both of which otherwise show as a panel quietly
missing a piece -- and **`divider=0` is the merged list that lost its `+`**, the one control that can create
a math channel. The first line above is a run with no math channels at all: the divider is there anyway,
because a `+` that only appeared once a channel existed could never create the first one. `panels: …
signals=WxH row_h=26` is the other half: the list's own geometry and the height a row really takes, which
is what the compact-row CSS rule is for.
## `events pane:`
```
[udpscope] events pane: compare=1 accumulate=1 list=1 nested=1
```
The `events` page's frame read from the window (the page's content is `events_box` of `events-pane.ui`; there is
no revealer, divider or header toggle any more, so none is read): the two group actions, the list, plus `nested`
— the list being *inside* `events_box`, which is the one thing a lost id cannot show (a list parented elsewhere
reads 1 everywhere else). **All four must read 1.** `[events]` is the other half, and it drives the page end
to end: recording off, recording on, the trigger, the page opened with `open_events_page()`, a row activation
and the replay, then Compare and Accumulate — so the file's buttons are read by being used.
## `[windowpop]`
```
[windowpop] opened=1 visible=1 click_gestures=2
[windowpop] after a press elsewhere: active=0 (want 0)
[windowpop] after a press on the button: active=1 (want 1)
```
`UDPSCOPE_PROBE_WINDOWPOP_MS`: the time-window popover and how it closes. Autohide is turned *off* for the
probe, exactly as the menu probe does it -- a popover dismisses itself when GTK holds its *grab*, and a bare
`xvfb-run` has no window manager to hand one out -- so what is measured is the state a user meets when the
grab goes missing, where the press has to close the bubble anyway. `click_gestures` reads that the controller
is *connected* as well as that its handler works; the last press is on the button itself, which must be left
to its own toggle (`want 1` rather than `0`).
## `[drop-probe]` and what a refused drop says
```
[drop-probe] full row refused the drop: 0 (want 0)
[drop-probe] refused feedback: notice='plot 1 holds four signals already: drop it on another plot, or add a row with the layout editor (L)' warning=1
[drop-probe] the status line, composed: warning=1 status='plot 1 holds four signals already: ... | live · 9.97 kS/s · 2 s window'
```
`UDPSCOPE_PROBE_DROP_MS`. The first line is the refusal itself (a full row takes no more signals); the
second is that it *said so* -- the message and the stock `.warning` class the status line carries while a
notice is fresh; the third is the composed line the user reads, which is why the label is only checked here
and not in the second: the status line folds the notice into itself on its own cadence, so a beat after the
refusal it may still hold the previous text.
## `[enable-all-probe]` and switching a signal off
```
[enable-all-probe] 4 channel(s) enabled and assigned to plots, notice='connecting to 127.0.0.1:21023 -- planning the buffer: 8.39 M samples per signal'
[enable-all-probe] switched 'sine' off: traces_left=0 (want 0) notice=''sine' is off: 1 trace left the plots'
```
The first line's `notice` is the **startup** notice, and it is read here because this probe is the
earliest thing that reads the status bar. It is set as the window is presented -- before the first
CONFIG can take the model's mutex, so before anything can block -- and it names the host and the
buffer being planned, because that is the window's only answer to "why is nothing happening yet".
An empty `notice` here is the window that used to come up silent while a large buffer was allocated:
at `--buffer-samples 100M` that is the dialog disappearing and then over a second of a frozen, blank
window. (The buffer is allocated on the producer thread under `ScopeModel::mutex()`, which the frame
tick also takes, so the freeze is real and cannot be papered over in the UI -- see the
`[udpscope] buffer allocated:` line below.)
`UDPSCOPE_ENABLE_ALL_MS` enables every signal and assigns the acquired channels across the rows, then
switches one off **through its own acquire tick in the signal list** -- the tick is a box inside the row, which
is the shape `signal_tick_visible()` walks -- and reads the plots back. `traces_left=0` is the rule: a disabled
signal leaves every plot rather than staying as a slot that draws nothing, and the notice is what says so.
The switch step runs late, so that a math probe armed *behind* this one has installed its graphs by then; the
two halves after it are the other rule. `math_off` is the derived channels that read this signal -- they cannot
be computed from a signal nobody acquires, so they go off with it -- and `math_on` is the ones that do not,
which must be untouched:
```
[enable-all-probe] switched 'sine' off: traces_left=0 (want 0) math_off=[math x2,math3] math_on=[math lp] notice=''sine' is off: 1 trace left the plots; math off: math x2 and 1 more'
```
## `trig engine:` — the producer's per-second engine line
```
[udpscope] trig engine: ch=1 armed=1 pending=0 elapsed=0.0000/0.1000 s accum=0 history=0.0000/0.4000 s pre_pct=80 v=1 now_t=2.520 holdoff_until=2.520 ring=[2.5200..2.5200] n=567000
```
Printed by the producer once a second while a trigger is configured, so a run with no trigger at all shows
none. `elapsed/post_want` is the capture's progress in stream seconds, `accum` how many samples that window
holds, `history/pre` how much pre-trigger history the engine has, and `now_t` the stamp of the last sample
the **engine** was fed (`engine.last_t()`), not the stream's time.
**`ring=[oldest..newest] n=` is the store's own retained span and sample count, and it is the pair that says
which of two failures a stalled display is.** `n` counts every stored sample, so it climbs at the stream's
rate whenever data arrives at all:
- **`ring` standing still while `n` climbs** is the *clock*: samples are stored but every one carries the
same stamp, so the axis cannot advance and the trigger's `history` never fills. The stamps are pinned at
the file view's last value because a restarted client's stream clock sits behind it: the advance test
rejects the new packets, four rejections abandon the declared time signal, and the arrival-clock fallback
derives its epoch from the surviving mark. `Acquisition::start()` now resets the store and re-bases that
mark, so `ring` must **move** within a stream round-trip of *Go live*. Measured after going live
from an event view: `ring=[2.5200..2.5200] n=567000 → n=1131000 → n=1689000`, one second apart, with
`now_t` and `holdoff_until` both pinned at `2.520` — the file view's last stamp. The producer's clock was
frozen, not lagging, and the display stayed blank until something re-planned the store.
**It has a second entry, and one of its own readings names it**: under `--relative-time` a re-plan that
*shortened* the plan's interval put every later stamp behind the clock the run had already reached, and
the frame loop's clamp held it there (see the relative-clock invariant in
[invariants-data-path.md](invariants-data-path.md)). The line below is printed once per connection the
first time that happens, so a run that reads it has its whole diagnosis on one line — and the *absence*
of it beside a `ring` that advances is the reading that a re-plan no longer moves the clock:
```
[udpscope] warning: the clock stalled at 0.000384 s: 500 samples stamped [0.000000..0.000384] s are all
behind it, so the timeline stands still while frames keep arriving -- nothing can be drawn and no trigger
can latch. The plan's interval is 7.69230769e-07 s now; ring n=500
```
`history=0.0000/<pre>` on every `trig engine:` line beside a `n` that climbs is the same state seen from
the engine's side, and it is what makes a *trigger-mode* display blank: with no latch the display never
leaves `Waiting`, whose bind is nothing by design. Measured on the mock at 1.3 MS/s with a session that
defines a LowPass channel, in single mode: before the fix `ring=[5.4990..5.4990]` for the whole run,
`latches=0`, `display=nothing`, and the dump read `traceless paints: 1519 (1519 after the first bind)`;
after it, `ring` runs `[0.0058..0.2446] → [0.0058..1.9969]`, `history` reaches the pre-window,
`latches=1` and the frozen event reads `n=2599999 data=[-1.0000..1.0000]`. The same run with the math
channel disabled never showed it at all — which is the control that names the entry, not the filter.
- **`ring` advancing while `now_t` does not** is the *feed*: the store is moving and the engine is not being
handed the samples, which is a trigger-path question and not a clock one.
Neither can be read off the engine's own fields, which is why the store's pair is here.
## `[udpscope] buffer:` and `[udpscope] planner:`
```
[udpscope] buffer: requested=10000000 used=8388608 bytes=167772160 limit=8589934592 limited=0
[udpscope] planner: unclipped got=67108864 pow2=1 limited=0 | clipped(1GiB) got=33554432 bytes=671088640 fits=1 pow2=1 limited=1 | clipped_rel got=33554432 bytes=939524096 fits=1 | too_small_throws=1
```
Printed by the exit dump (`src/ui.cpp`), next to the per-channel `produced` lines. The first is the live
plan: `requested` is `Config::buffer_samples`, `used` is `Partition::time_capacity` (the request rounded
down to a power of two, then halved under the memory limit), `bytes` is `Partition::bytes()` (the rings'
real size -- 8 388 608 ticks x (8 + 3 x 4) = 167 772 160 for the mock's three channels) and `limit` is
`memory_limit_bytes()`. **`used` must be a power of two no larger than `requested`'s floor, `bytes` must be
<= `limit`, and `limited=1` only when `used` is below that floor.** A run with `--buffer-samples 100M
--memory-limit-mb 256` reads `used=8388608 limited=1` and prints the `buffer limited to ...` stderr line;
`used=0 bytes=0` means no plan was made yet (no channel enabled -- add `UDPSCOPE_ENABLE_ALL_MS`).
The second line is not about the run: it feeds `plan_partition()` three channels and a 100 M request on
fixed inputs, so a change to the rounding, the halving or the byte formula reads here even when the run's
own plan is not clipped. `unclipped` (16 GiB limit) must give the pow2 floor of 100 M, **67108864**, with
`pow2=1 limited=0`. `clipped(1GiB)` must give a smaller power of two whose `bytes` are <= 1 GiB
(**33554432**, 671 088 640 bytes: 67 108 864 would need 1 342 177 280), `fits=1 pow2=1 limited=1`.
`clipped_rel` is the same in relative time, where the clock ring is doubled, so a tick costs 8 more bytes:
the same 33 554 432 ticks but 939 524 096 bytes, still `fits=1`. `too_small_throws=1` is a 1000-byte limit
refused (it cannot hold 128 ticks). A `fits=0` is the halving loop stopping early; `limited=0` on the clipped
case is the clip not being reported to the UI; `too_small_throws=0` is a planner that allocates past the limit.
The Preferences *Memory limit* row is not probed: change it by hand and read the new `buffer allocated:` line
and the status popover.
## `[udpscope] buffer allocated:`
```
[udpscope] buffer allocated: 4 channels, 839 s span, 74 ms
```
Not a probe — it is printed by `ScopeModel::rebuild_locked()` in every build, because this is the one
operation in the application whose duration a user can see with no instrument: the store's rings are
`std::vector<std::atomic<T>>` and so **value-initialised**, which touches the whole planned ring storage
(roughly a second per GB, mostly first-touch page faults), and it runs on the producer thread holding
`ScopeModel::mutex()` — which the frame tick also takes. So the line's `ms` is a frozen window, not
bookkeeping.
**It is the count that is the assertion, not the duration.** One line per *real* change of plan. A
rebuild whose plan describes the storage already allocated clears the store instead
(`Partition::same_storage_as()` → `BufferStore::reset()`) and prints nothing, so a startup or a mode
change that reads more than one or two of these is a re-plan that should have been coalesced. Startup
asked for **fifteen**; it now asks for two or three. A run at `--buffer-samples 100M` measured those
coalesced allocations at 1148 ms each and a max frame tick of 1186 ms — the same number, which is the
evidence that the freeze *is* this call and not something downstream of it.
The companion reading is the startup `notice` on the `[enable-all-probe]` line above: the allocation
cannot be made free, so the window must at least say what it is doing while it happens.
## `[math-probe] graph edit:`
```
[math-probe] graph edit: stored_slots 800 -> 900 (want the second at least the first, and non-zero)
```
`UDPSCOPE_PROBE_MATH_MS` applies an edit to one graph through the editor. The plan is a partition over the
channels and an edit does not move it, so the store survives: the count *grows* (the stream keeps filling
while the edit runs) and never falls to 0, which is what a rebuild would do. Every channel then still reads
`N samples vs M time slots` with `N == M`, so no channel was left behind either.
## `[mathwrap-probe]`
```
[mathwrap-probe] installed 'math wrap' on 'sine': channel=1
[mathwrap-probe] wrapped: base=864 slots=66400 (want base > 0)
[mathwrap-probe] after edit: produced=72400 slots=72400 (want equal) y=[-0.5000,0.5000] (want +-0.5, was +-2.0000)
```
`UDPSCOPE_PROBE_MATHWRAP_MS`, and it must be run with a **small `--buffer-samples`** and given time to
saturate it: `--buffer-samples 65536` against `tools/mock_udpstreamer.py --rate 20000 --batch 200`, with
`UDPSCOPE_PROBE_MATHWRAP_MS=1500 UDPSCOPE_EXIT_AFTER_MS=25000`, reads the lines above. It installs one
`Gain` graph of 2 over the mock's unit sine, polls (100 ms) until the ring has **wrapped**, and only then
edits the gain to 0.5 — the same channel list with one node changed, which is the in-place recompute and
not a replan.
`base > 0` on the second line is the precondition and not a result: at `base == 0` both readings below are
identities, so **`base=0` means the run tested nothing** — it was too short, or the buffer too large. This
is also why `math_probe` cannot stand in for this one: every edit it makes follows a structural change,
which replans the buffer and restarts every ring at absolute 0.
The third line is one reading per half of `recompute_math_locked()`. `produced` against `slots` is the
derived ring's absolute count: short by exactly `base` is the renumbering `clear_to()` exists to prevent,
and `live_window_locked()` clamps against *every* channel's `produced()`, so that one lagging channel is
`n = 0` for the live view, every pause view and every trigger capture — the reported freeze. `y` is the
**pyramid's**, so it describes the published window rather than the samples, and the edit *shrinks* the
gain for that reading to exist: a pyramid left unpublished still describes the replaced ±2 samples, a
window holding both quotes the larger, and a *growing* gain would therefore read correct either way.
`y=[-2,2]` here is `republish_from()` missing.
## `[noise-probe]`
```
[noise-probe] setup channels=1
[noise-probe] trigger band: bare=49/2 band=7/2 ratio=7.00
[noise-probe] trigger hf: hf=7/2 band+hf=6/2 band=7
[noise-probe] trigger delay: clean=3 filtered=4 delay=0.002206 want=0.002206 ratio=1.000
```
`UDPSCOPE_PROBE_NOISE_MS`, and it needs the mock's **spiked** sine -- `--spike-every 0.005 --spike-amp 0.5
--spike-width 2` -- where every other probe uses the plain mock. It runs as **four stages**, and the chain is the point:
1. At `UDPSCOPE_PROBE_NOISE_MS` the setup installs the de-noise graph through `apply_math_channels()`.
That is a structural rebuild that *drops the buffer's history*, so the five seconds to the next stage are
what let the ring refill before anything scans it.
2. Five seconds later the legs run. They enable the signals they measure *themselves*, because a probe must
not depend on another probe's side effects (`enable_all_probe` ends by switching one off again, which is
how `sine` went missing from the first run of this one), and wait 2.5 s for the ring to refill after that
structural change. The legs then each search the whole retained buffer through `BufferSearch`, one after
another **without yielding**, so all six see the same buffer -- and the five measurements come next, over
the same retained history.
3. Two and a half seconds later the de-noise channel is **enabled**, by name, through the same switch the
panel drives. The setup installs it *disabled* and this stage turns it on, because an acquired channel is
a share of the ring: a fifth one shortens *every* channel's retained span, and every figure above scales
with that span -- the six leg counts, and `pkpk bare`, which is a peak-to-peak *of the retained window*.
The readings above are the older feature's proof, so the new channel stays out of the acquisition plan
until they have all run.
4. Two and a half seconds after that, the de-noise reading. A math channel starts producing when it is
applied *and* acquired, so the channel's first sample is the first one after stage 3, and a peak-to-peak
needs a window holding a trough *and* a crest: twelve cycles of the 5 Hz source.
With `UDPSCOPE_PROBE_NOISE_MS=4000` the legs therefore land at about 11.5 s and the last line at 16.5 s,
which is why the documented run needs `UDPSCOPE_EXIT_AFTER_MS=18000`. Four seconds and not three: the
enable-all probe's own last act, a click on the front channel's tick at `ENABLE_ALL_MS + 2000`, rebuilds
nothing itself but *is* rebuilt under when a graph is installed at its instant -- see `verification.md`.
- **`setup channels=1`** -- one math channel was installed, and it is printed by the *first* stage, before
any leg: it is the line that says the graph exists at all. A count above 1 means a session file from an
earlier run was loaded on top of it (`$XDG_CONFIG_HOME/udpscope`); those channels load disabled, so they
do not change the partition, and a clean run of the documented command prints `1`.
- **`trigger band:`** -- `bare` is the bare comparator on the spiked sine (every impulse that lands near zero
carries it across, so the count is dominated by spikes), `band` the same with a 0.8 band. **`ratio` must be
at or above 5**, and `band` about the number of sine cycles retained. Neither count is a *fixed* number:
both scale with the retained buffer's length (7 at a 2.5 s buffer, ~30 at 6 s), so the ratio and the
agreement between the legs are the assertions. A `ratio` near 1 means the band is not being applied --
`decide()` is not reached from `feed_edge`.
- **`trigger hf:`** -- `hf` (no band, 50 Hz reject) and `band+hf` (both) must be **within 2 of `band`**: the
reject removes the impulses on its own. `hf` near `bare` means the pole never left `a == 1` (check
`configure()` recomputes it, and that `hf_fc` reached the condition); `band+hf` near 0 means the reject is
attenuating the sine itself, which would be the Nyquist clamp clamping 50 Hz.
- **`trigger delay:`** -- the reject's group delay, measured on **`square`**, which carries no impulses: a
one-pole turns a -1 -> +1 step into a zero crossing at exactly `tau*ln2`, so `delay` must read
`want = 0.002206 s` and **`ratio` between 0.7 and 1.4**. A `ratio` near 0 is a pass-through; a large one
means `a` is far too small, i.e. `dt` never arrived at the detector. **This is the leg the mock's
`--square-harmonics 0` is for**: `want` is a *step* response, and the square rings by default, which moves
the crossing to `delay=0.002770 ratio=1.255` — inside the band, but no longer the figure beside it, and the
same on every run, so it is the shape and not the run. See the recipe in
[verification.md](verification.md). The band/hf legs need no such flag: they count the crossings of `sine`,
which the ripple does not touch at all — their counts read a few percent lower at this recipe's 1.3 MS/s
only because the extra harmonics cost the *producer*, which
[verification.md](verification.md) notes.
- `state` is `2` (Done) or `4` (Capped) on every leg. `3` (Truncated) on more than one leg means the pump loop
is being overtaken -- nothing to fix in the feature, but say so.
The same probe then puts five measurements on the same spiked `sine` as **two windows** -- three legs at
`hf=0` and two at `hf=50` -- and reads them back:
```
[noise-probe] measure pkpk bare noise=10.0 hf=0.0 valid=1 value=2.987692
[noise-probe] measure pkpk hf noise=10.0 hf=50.0 valid=1 value=1.990075
[noise-probe] measure period noband noise=0.0 hf=0.0 valid=1 value=0.021290
[noise-probe] measure period band noise=25.0 hf=0.0 valid=1 value=0.200000
[noise-probe] measure period hf noise=0.0 hf=50.0 valid=1 value=0.200000
[noise-probe] measure windows=2
[noise-probe] denoise raw=2.987696/1 denoised=2.000000/1 ratio=0.669 channels=1
```
- **`pkpk bare`** -- about **3.0**: the sine's 2 plus an impulse either way. The impulses dominate the
window's extremes, so this is the *stimulus*.
- **`pkpk hf`** -- about **1.99** (`2/sqrt(1 + (5/50)^2)`): the *waveform*. This is the reading that says the
filter reached **pass one** and not only pass two; a value near 3 means it did not -- check `measure_scan`
is handed `w.scan_filter`, and that `filtered` disabled the buckets, since a bucket's aggregate is raw.
- **`period noband`** -- about **0.021**, far below the true 0.2. **This one is supposed to read wrong**: it is
the control the other two are compared against, the defect the band exists to fix. "Fixing" it removes the
check.
- **`period band`** -- exactly **0.200000**, the true period, from the band alone. Near `period noband` means
`noise_pct` is not reaching `measure_set_level`. The leg asks for **25 %** and not the 10 % default: the band
is a share of the *window's* peak-to-peak, which with the impulses in it is about 3, so 10 % is a half-band
of 0.3 -- less than the 0.5 an impulse moves the signal by, which is exactly what
`MeasureOptions::noise_pct`'s own rationale says (10 % holds to a 0.3 interferer).
- **`period hf`** -- exactly **0.200000** with the band off: the reject alone. Near `period noband` means the
pole coefficient stayed at 1 -- check `measure_filter_set` was given a non-zero `dt`.
- **`denoise`** -- the feature under test. `raw` is the spiked sine's peak-to-peak over the visible window and
`denoised` the same reading from the **`math denoise`** channel -- a `Source` of `sine` into `DeNoise` with
`med_n=5` and `fc=0`, i.e. the median alone. **`denoised` must read exactly `2.000000`**: that is the clean
sine's own peak-to-peak, so *not one impulse survived*, which is the whole claim the node makes (the
impulses are 2 samples wide and the median is 5, shorter than half a window). `denoised` near `raw` means the
median is passing them: check `med_n` arrived as 5 and not 0, since a `med_n <= 0` node returns NaN and a
peak-to-peak over holes would read `--` instead. **`denoised=0.000000` with `valid=0` means the channel never
produced** -- it was never enabled, its source `sine` was never re-enabled, or `apply_math_channels()` was
never called. `ratio` is `denoised / raw` and follows `raw`, which is whatever the visible window's extremes
happen to hold: 0.669 here, and up to about 0.8 in a run whose window misses the spike at one extreme. The
assertion is on `denoised` and on `raw` being above it; the ratio is the reading, not the test.
`channels=` is the math channel *count* (`a->math.size()`), not whether the channel is acquired.
- **`measure windows=2`** -- the five legs ask for corners `0, 50, 0, 0, 50` on one channel, which is two
distinct windows. **`windows=1` means the corner is not part of the key**: the five collapsed onto one walk
and two of them read a signal they did not ask for.
## `[dock-session]`
```
[dock-session] hidden: wish=0 save_scheduled=1 collected=0 saved=1 loaded=1 read=0 applied=1 split_follows=1 ok=1
[dock-session] shown: wish=1 save_scheduled=1 collected=1 saved=1 loaded=1 read=1 applied=1 split_follows=1 ok=1
[dock-session] old file (side_tab only): loaded=1 dock_shown=1 default=1 ok=1
```
`UDPSCOPE_PROBE_DOCK_SESSION_MS` reads the one thing the dock persists, the Dock button's state, as `dock = true|false` in the
session file (it replaced `side_tab`). Each of the first two lines drives the toggle, then follows the wish through: `save_scheduled=1` is
`on_sidebar_toggled` having scheduled a save (a user's wish that was not saved is lost at the next start), `collected` is
`collect_layout()`'s `dock_shown`, `saved`/`loaded` the file round trip on a temp path, `read` the loaded value, `applied`
the wish after `apply_layout()`, and `split_follows=1` the split view following it. **`ok=1` on all three lines is the assertion.** Each leg first cancels any save timer still pending
(`schedule_layout_save()` is an idempotent 400 ms debounce, so a leg would otherwise inherit the previous one's), which is what makes `save_scheduled` a reading
of *that* leg's toggle; with no source port (a recording opened from a file) nothing can be scheduled and the leg expects `save_scheduled=0`. The third line
loads a file as the previous version wrote it (`side_tab = "measure"`) into a `LayoutState` the probe has set to `dock_shown=false`, the opposite of the default: it must load with no error and
leave `dock_shown` at the default (`true`), so `ok=1` requires `dock_shown == default` -- the parser ignores unknown keys, and this is what says the old key still does not break a session. A load that
left the caller's `false` alone would read `dock_shown=0 default=1 ok=0`. The probe writes only to a temp path.
## `[signalpage]`
```
[signalpage] name=sine open=1 pages=1 enable_visible=1 plot_buttons=1 plot_on_row0=1 plot_on_row0_disabled=1 removed=1 btn_row0=1 title='1. sine' title_ok=1 no_dup=1 row_opens=1 pruned=1 other_page_kept=1 rows=1
```
`UDPSCOPE_PROBE_SIGNAL_PAGES_MS` builds and drives the page of the first streamed signal. `open=1` (the slide-over shows `signal:<name>`), `pages=1`
(one page in `App::signal_pages`), `enable_visible=1` (the acquire tick is shown in live mode; `0` in a file view), `plot_buttons=` equal to
`rows=` (one "Plot on" button per figure row), `plot_on_row0=1` (`assign_signal_to_row()` returned true and the signal is plotted, from a state where the probe first took it off every plot), `plot_on_row0_disabled=1`
(after the page refilled with the signal on plot 1, that plot's "Plot on" button is insensitive, "already on this plot"; `0` is a button that would put the signal in a second slot of
the same plot), `removed=1`
(the page's "Remove from plots" took it off), `btn_row0=1` (the "Plot on 1" button, the user's path, put it back),
`title=` the slide-over **header's** title while the page is open, `title_ok=1` when it is `<channel number>. <name>` -- the
same numbering the badge carries (`measure_channel_number`), so a signal page says which acquired channel it is -- and
`no_dup=1` when the page **body** carries neither a "Back to signals" button (the header's Close is the way out) nor a
second copy of the name (a `title-3` label); `0` is either duplicate back, `row_opens=1` (the probe switches the signal off so it is addable, activates its list row and the page opens; the signal is then switched back on), and `pruned=1`: a math channel's page was opened, its **Delete** button clicked, and
the slide-over closed and the page was dropped by `signal_pages_sync()`. `pruned=-1` means no math channel could be added; `0` is a stale page. `other_page_kept=1`: with the `trigger` page open, a signal page that is not on show was pruned (a math channel added, its page built, the
channel removed) and the slide-over was still on `trigger`, both by `slideover_page()` and by the stack's visible child; `0` is `signal_pages_sync()` parking the stack on the
signals list while the title and the badge marker still name `trigger`; `-1` when there is no `trigger` page or no math channel could be added.
A second line, `[signalpage] calibration identity=1/0 set=2.5/-1.25 pencils=3 pencil_edits_one=1 unit_edit=1 unit='V'`, reads the page's details grid (unit, gain, offset, then the CONFIG's read-only details) rows
(`udpscope-gain` / `udpscope-offset` labels, one string rule: `format_calibration()`, up to six decimals with trailing zeros dropped): `identity` is what an
uncalibrated signal shows, `set` what the page shows after `set_calibration(2.5, -1.25)` and `signal_pages_sync()` (the Apply path), `pencils=3` the unit, gain and offset edit
buttons, and `pencil_edits_one=1` that the gain pencil opens a dialog titled "Gain" with exactly one spin button and that Apply changed the gain only
(offset stayed -1.25) and refilled the label. `unit_edit=1`: the unit pencil opens a dialog titled "Unit" with one entry, "kV" reached the model's signal and the page's `udpscope-unit` label, and an empty entry restored the declared unit (`unit=` prints it; it must be the mock's own). A rename bumps `generation()`, so the panels re-read the list and the measurement statistics restart. `-1` is no pencil / no dialog; `0` is a dialog that edits both or a label that did not follow the model.
## `[sigbadges]`
```
[sigbadges] enabled=4 badges=4 overflow=0 overflow_text=- plus_visible=1 dot_plotted=4 bar_h=36 bar_h_fixed=1 bar_min_w=124 opens=1 plus_opens=1
```
`UDPSCOPE_PROBE_SIGNAL_BADGES_MS` reads the signal badges. `badges` must equal `enabled` (the enabled, listed signals) while `enabled` is at most 6
(`kMaxSignalBadges`); beyond that `badges=0 overflow=1 overflow_text=<N> signals plus_visible=0` (the "N signals" badge replaces the badges and the "+"). `dot_plotted` is the number of
badges whose signal is on a plot (its dot is then the trace colour, not the unplotted grey). `bar_h` is the bar's allocated height and `bar_h_fixed=1` that neither it nor its natural
height moved while one signal was switched off and on again (`rebuild_signal_panels()` each time), and that the badge count followed; `0` is a bar whose height depends on its content.
`bar_min_w` is only a record: the scroller must keep it the same however many signals there are (it reads 124 with the UDPS mock's 3 (the Source badge being hidden there) and with the overflow forced on, and 244 against the E-1608 mock with one signal and with nine -- the overflow, `enabled=9 badges=0 overflow=1 overflow_text=9 signals plus_visible=0 bar_h=36 bar_h_fixed=1`; a value that grows with the signal count is a scroller that has stopped constraining the bar). `opens=1`: clicking the
first signal badge opens `signal:<name>` with the badge marked open and a second click closes it (`-1` when overflowed, there being no signal badges); `plus_opens=1` the same for the "+"
(the overflow badge when overflowed) and the `signals` page.
```
[sigbadges] drag_sources=4 drop_targets=4 prepare_ok=1 reorder_before=sine,square,ramp,noise reorder_after=noise,sine,square,ramp reorder_ok=1 payload_drop=1 list_drag=0
```
The second line is the drag handles. `drag_sources` and `drop_targets` count the `GtkDragSource` / `GtkDropTarget` controllers over all the
signal badges (each badge carries one of each, so both must equal `badges`; beyond six signals the badges are gone and both read 0). `prepare_ok=1`: the first badge's
`prepare` returns a provider whose string is its signal index (the payload the plot area's drop target reads; `0` is a drag that carries nothing or the wrong
signal, `-1` no badge). `reorder_before` / `reorder_after` are the badge names in widget order around a call to the badges' own `drop` handler with the last badge dropped on
the first; `reorder_ok=1` means the last badge is now first, `badge_order` matches the widgets, and the original order was restored afterwards (so later probes see it;
`-1` with fewer than two badges). `payload_drop=1`: the signal is taken off the plots and `assign_signal_to_row(signal, 0)`, the call the plot's drop makes, leaves it plotted
(`-1` no badge). `list_drag` is the number of drag sources on the signal list's first row, and **must be 0**. **A real pointer drag cannot be simulated headless**: the
gesture's threshold, the slide-over closing under it and the pointer-driven drop are untested here; the line reads the controllers, the payload, the reorder and the assignment.
## `[badges]`
```
[badges] table_bad=0 trigger_state=off bar_h=36 bar_h_after=36 fixed=1 open_marked=1 page_open=1 closed_unmarked=1
[badges] source name=UDPStreamer pages=0 visible=0 switch_visible=0 can_target=0 second_page=- second_ok=-1 open_marked=-1 closed_unmarked=1 panel_min_w=0
```
`UDPSCOPE_PROBE_BADGES_MS` reads the badge bar under the plot. **Line 1** is the Trigger badge: `table_bad=0` (the rows of the 16-input
`trigger_badge_state()` table the probe gets wrong -- anything else is a state word that misreports the engine), `fixed=1` (the bar's height is
the same, and at least 36 px, before and after the `trigger` page was opened and closed; `0` means the bar's height depends on content and the plot above it
moves), `open_marked=1` / `page_open=1` / `closed_unmarked=1` (the `udpscope-badge-open` class follows the slide-over's page, both ways; `0` on
any is a badge that does not say its panel is open, or one that stays marked after it closed).
**Line 2** is the Source badge, which is **hidden when the source has no pages** (UDPS, Flat UDP, a file), and every field has a value it must read:
- `name=` the source kind's name, as the badge shows it. The badge carries no link state (the status bar says it); there is no `state=` field.
- `pages=` the number of slide-over pages the source kind contributes (`source-<id>`): `0` for UDPS, `2` for the E-1608 (`source-ao`, `source-dio`).
`can_target=0` with `pages=0` is the UDPS badge being untargetable, which goes with it being hidden; `can_target=1` with `pages>0` is the button being live.
`can_target=1` with `pages=0` is a dead button that opens nothing.
- `visible=` the badge being on screen: **`0` with `pages=0`** (hidden, by design: nothing to open), `1` with `pages>0`. `visible=1` with `pages=0` is a dead pill that was supposed to be hidden; `visible=0` with pages is a source panel nothing can open.
- `switch_visible=` the segmented switch being on screen after the first source page was opened: `1` with two or more pages, `0` with one or none.
A switch shown for a single page is a control that cannot change anything; hidden with two, the second panel is unreachable.
- `second_page=` / `second_ok=` what `slideover_page()` read after the switch selected the second page, and whether it is that page. **`second_ok=1` with
two or more pages, `-1` with fewer** (there is no second page to select, so the reading is "not applicable" and not a pass). `second_ok=0` is
the switch changing its own state without the slide-over following it.
- `open_marked=` the Source badge carrying the open mark while its first page is open: `1`; **`-1` with `pages=0`** (nothing to open);
`0` with pages is a badge that does not follow the panel. `closed_unmarked=1` is the mark gone once the panel is closed.
- `panel_min_w=` the widest minimum width of any source panel, measured in the slide-over's page. The page is 360 px less the scroller's own chrome and the
scroller never scrolls horizontally, so a value above that is a panel clipped at its right edge. `0` is no panel.
## `[slideover]`
```
[slideover] open page=probe panel=360x350 at x=270 overlay=630x350 edge_flush=1 full_height=1 outside_hits_plot=1 inside_hits_panel=1 plot_w before=630 open=630 plot_unchanged=1
[slideover] click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1
```
`UDPSCOPE_PROBE_SLIDEOVER_MS` opens a throw-away `probe` page and reads the panel 0.5 s later (add `UDPSCOPE_ENABLE_ALL_MS` so there are traces to hit).
**Line 1:** `panel=360x<overlay height>` (a 360 px panel as tall as the overlay), `edge_flush=1` (its right edge on the overlay's) and `full_height=1`;
`outside_hits_plot=1` / `inside_hits_panel=1` (a pick outside the panel lands in the plot, one inside it in the panel); `plot_unchanged=1` (`before=` equal to
`open=`: the panel is an overlay and does not resize the plot). `outside_hits_plot=0` is a panel allocation wider than 360 or a transparent parent that swallows
the plot's pan, drop and hover; `plot_unchanged=0` is the panel taking room from the plot. **Line 2:** `click_inside_closes=0` with `still_open=1` (a click
inside the panel does not close it), `click_outside_closes=1` (a click on the plot does), `esc_closes=1` (`slideover_escape()` closes it) and `closed=1`.
`esc_closes=1` is the function called directly; the key *through the window's handler* is the `esc:` reading of the `[keys]` line.
## `[split-probe]`
```
[split-probe] start plots=1 frows=1 cells=[1] widths=[756] tile=1 x_equal=1 cursors_aligned=1 weights=[-] traces=[1:sine,square,ramp,noise]
[split-probe] split plots=2 frows=1 cells=[2] widths=[388,365] tile=1 x_equal=1 cursors_aligned=1 weights=[-] traces=[1a:sine|1b:square]
[split-probe] weights 3:1 plots=2 frows=1 cells=[2] widths=[524,229] tile=1 x_equal=1 cursors_aligned=1 weights=[3,1] traces=[1a:sine|1b:square]
[split-probe] caps-calls insert_cell=[1,1,0] refused_cell=1 insert_row=[1,1,0] refused_row=1 plots=8
[split-probe] roundtrip rt=1 expect=r0(h=2.0000)[w=1.0000:sine] r1(h=1.0000)[w=3.0000:square][w=1.0000:ramp] got=r0(h=2.0000)[w=1.0000:sine] r1(h=1.0000)[w=3.0000:square][w=1.0000:ramp]
[split-probe] ruler-labels split=[row 1,row 2] mixed=[row 0,row 2]
[split-probe] shared-y y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1 ranges=[A=-3.000000..7.000000 B=-3.000000..7.000000 | fit A=-1.04..1.04 B=-1.04..1.04 data=-1..1] axes_w=[671.599976,671.599976] auto=1
[split-probe] x-axes row0_labelled=[1,0,1] bottom_labelled=[1,1,1] gap_ok=[1,1,1] gap_px=[3.0/3.0,3.0/3.0,3.0/3.0] col_gap_px=3.0 caption_ok=[1,1,1] last_plot_zoom=1
[split-probe] cell-xhair left=1 right=1 centre_dy=0.0 sibling=0 (the horizontal line spans a split row's shared y, and no other row)
```
`UDPSCOPE_PROBE_SPLIT_MS` (stderr; see [verification.md](verification.md)). 31 steps (0..30), 250 ms apart;
each restructures and the *next* step reports the frame that was laid out with it, so a line's label
names the arrangement the line **reads**, not the call that made it. The state lines share one format:
`plots` (`row_count()`), `frows` (`figure_row_count()`), `cells=[..]` (per figure row), `widths=[..]`
(logical pixels of each cell rect, `|` between rows; the first cell's rect includes the row's y gutter, ~52 px here, and the others have none, so equal weights read `[388,365]` -- equal *plot* widths, not equal rects; the 15 px by which this moved from `[403,350]` is the plotter giving the row's right gutter to the last cell only and spacing adjacent axes by one `col_gap`, both of which widen the plots), `weights=[..]` (the stored cell weights, `-` for a row with
none), `traces=[label:signals|..]` with the labels `plot_label()` gives (`1`, `1a`, `2b`). Every state line
carries the same three assertions:
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `tile` | **1** on every state line | a row whose cells do not fill its rect edge to edge, are not at its y and height, or are not one `col_gap` apart, or a `FigureLayout` with `cells.size() != plots` / `rows.size() != frows`: the plotter's placement or a host that did not rebuild the figure |
| `x_equal` | **1** | a plot whose x range differs from plot 0's: a cell that is not in the x link group (a rebuild that linked rows and forgot cells) |
| `cursors_aligned` | **1** | a plot whose two vertical cursors sit elsewhere than plot 0's: a per-cell cursor time instead of the one shared time; the probe turns the cursors on and resets them first so the comparison is of real positions |
The other lines, in order:
| line | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `start` / `split` | `cells=[1]` then `[2]` with `widths=[388,365]` (equal plot widths; the first rect is 52 px wider by the y gutter it alone carries) and the traces on `1a`/`1b`; a second cell starts **empty**, the probe assigns it | a split that moved or dropped the first cell's traces, or unequal default plot widths (`[376,376]` is the pre-sharing reading: a second cell that still has its own gutter) |
| `weights 3:1` | `widths=[524,229]`, **not** 3:1, and `tile=1` | not a defect: the row's y gutter and the plotter's 64 px floor per plot are taken off first and the weights share the remainder, so the shares read compressed; a result of exactly 3:1 would be the floor missing |
| `row+cell` | `cells=[2,2]`, `weights=[3,1\|-]` | row 0's cell weights lost or moved when a row and a cell were inserted below it (they belong to the figure row) |
| `caps-calls` | `insert_cell=[1,1,0] refused_cell=1 insert_row=[1,1,0] refused_row=1 plots=8` | a cap that is not reached or not held: row 0 starts with two cells and takes two more, refusing the third call at 4; two rows then fit before the plot total reaches 8 and the third is refused; `plots` must stop at **8** |
| `caps` | `plots=8 frows=4 cells=[4,2,1,1]`, `tile=1` | a ninth plot, or a fifth cell in a row |
| `cell-xhair` | `left=1 right=1 centre_dy=0.0 sibling=0` | the crosshair's horizontal line, placed through `Figure::set_crosshair()` on the **second** cell of a two-cell row: it must also be drawn in the first cell (`left=1`) at the same screen row (`centre_dy`) — the cells share one y, so the line is one value — and must not appear in the figure's other (unsplit) row (`sibling=0`). A `left=0` names a row whose cells each keep their own line; a `sibling=1` a y spread across rows that do not share one |
| `remove` | `plots=7 cells=[3,2,1,1]` | `remove_cell()` on a multi-cell row that removed the row, or that dropped a neighbour's traces |
| `roundtrip-io` | `wrote=1 read=1 plots_in_file=3` | a save that was refused, or a file that lost a `[[plot]]` table |
| `roundtrip` (state line) | `cells=[1,2] widths=[756\|524,229] weights=[-\|3,1]` and the three traces on `1`, `2a`, `2b` | the arrangement did not survive `collect_layout()` -> file -> `apply_layout()` |
| `roundtrip rt=` | **`rt=1`** with `got=` equal to `expect=` (row weights `h=`, cell weights `w=` and each cell's trace, read back at the file's `%.4f`) | **`rt=0`** names the difference: a weight written per plot index instead of per figure row, widths applied before the arrangement (which resets them), or a trace that moved to another cell |
| `legacy-io` | `read=1 plots_in_file=3 row_unset=1` | the stripped file still carrying a `row`, which would make the next line test nothing |
| `legacy` | `cells=[1,1,1]`, `frows=3`, `widths=[756\|756\|756]`, `traces=[1:sine\|2:square\|3:ramp]` | a session from before the split that did not open as one cell per row, or lost its signals |
| `invalid-rows` | `ok=1 frows/plots=[5/5 3/3 3/3 3/3]` | one of four unusable `row` sequences (five cells in a row; a skipped row; a row that goes back; a first row that is not 0) that was refused, or kept as a split instead of loading one cell per row |
| `editor-on` -> `cell-split` | `cells=[1]` then `cell-split-ck cells=2` and `cell-split` with `widths=[388,365]`, the new `1b` empty | the editor's cell `+` not reaching `insert_cell()`, or the figure not re-pointed after it |
| `cell-assigned` | `1a:sine\|1b:square` | an assignment into the new cell that landed on another plot |
| `col-drag` | `ok=1 left_gt_right=1 follows_80=1 relative=1 sum_80=1 right_floor=1 left_floor=1 sum_ext=1 both_above_min=1 handle_comp=1 tile=1`, then `d80=[335.8,335.8]->[415.8,255.8]`, `ext=[575.6,96.0\|96.0,575.6]` and `gutter0=52.2` (all logical **plot** widths, each plot's `layout().axes.w`, measured after a `layout()` on the new weights; 671.6 px pair) | `ok` is the AND of the checks. The compared widths are plot widths and not cell rects, because only the first cell carries the y gutter (`gutter0=` is its rect width minus its plot width, printed so a reader can tell the two apart): a drag that equates the rect widths moves the divider more than the pointer (the gutter's share is treated as weight) and reaches the floor short of 96 plot px, which reads `follows_80=0 relative=0 left_floor=0 right_floor=0 both_above_min=0`. The widths are `axes.w` and not `rect.w - y_axis_width - y_title_width`: the latter also counts the right gutter and the gap between axes, so once the plotter spaced adjacent axes by one `col_gap` and gave the right gutter to the last cell only, the old measure read `ok=0 d80=[335.8,365.0]->[397.9,302.9]` (the pair came out 700.8 instead of 671.6 and the divider followed ~62 for +80). `gutter0=` itself should read the y gutter (about 52 px; 0 means the first cell lost its gutter, a value near the cell width means the plot area collapsed). `follows_80=0`: the drag of +80 did not put the left cell within 1 px of `start + 80`; `relative=0`: three updates (+30, +60, +80) from one begin did not land at +80, i.e. the drag read the live weights instead of the begin snapshot `layout_col_drag_start`; `sum_80=0` / `sum_ext=0`: the pair's total changed instead of being redistributed; `right_floor=0` / `left_floor=0`: after +10000 / -10000 the shrinking cell does not read `kMinCellPx` (96) within 1 px; `both_above_min=0`: a cell below 96 px; `tile=1` is `split_probe_tile()`, the same check the state lines print. `left_gt_right` is `d80.l > d80.r` (after +80 from equal halves the left is the wider). `handle_comp=1`: the pure `layout_col_total_offset(offset, handle_now, handle_begin)` adds the handle's displacement back (`(40,140,100)` and `(80,100,100)` read 80, `(-25,70,100)` reads -55); `0` is a gesture that would leave the divider lagging behind the pointer (it converges on half the travel). The probe goes through `layout_col_drag_begin()` + `layout_col_apply()`, the pair the gesture calls; **a real pointer drag is not exercised headlessly**, so the handle's position read in `on_layout_col_update` is covered only by this helper |
| `pre-insert0` (state line) | after the extreme drags `widths=[148,605]`, `weights=[0.117734,1.88227]` | the last extreme drag was -10000: the left *plot* sits on the 96 px floor (the rect reads 148 = 96 plus the 52 px y gutter) and the right takes the rest of the pair. The weights follow from the same 96 px floor over the 671.6 px pair: `S * (96 - 64) / (671.6 - 128)` with `S = 2` (it read `0.111732` while the pair was 700.8 px). A left rect reading 146 (`widths=[146,607]`) is a 93.3 px plot floor plus the 52.2 px gutter: the drag was measuring cell rects (the gutter's share treated as weight) and not plot widths, so the left plot stopped short of the 96 px floor |
| `insert0-move` | `move_ok=1 before=[sine,square] after=[,sine,square]` | an insert at the left edge that renumbered the cells instead of moving their traces right with them |
| `remove-mid` | `rm_ok=1 after=[,square]` | removing a middle cell that dropped or shifted the wrong neighbour |
| `row-weights` | `wt_ok=1 before=[3,1,1] after=[1,1]` | the row weights not following an insert or removal of a *figure row* (the vector had the plot-count length once and drifted off the rows) |
| `sensitivity` | `cell_plus_sensitive=0 row_plus_sensitive=0 plots=8` | `-1` is a button the probe could not find; `1` is a `+` left clickable at the 4-cell cap (cell) or at 8 plots (row) |
| `two-split-rows` | `cells=[2,2]`, labels `1a 1b 2a 2b`, all empty | `set_cells_per_row()` leaving the old plots' contents or labels behind |
| `drop-rowat` | `ok=1 rowat=[1a=0,1b=1,2a=2,2b=3]` | `PlotArea::row_at(x, y)` choosing by y alone, which would put every cell of a row on its first plot: a drop or a right click on `1b` landing in `1a` |
| `plot-on-buttons` | `[1a:row 1, cell 1 of 2\|1b:row 1, cell 2 of 2\|2a:row 2, cell 1 of 2\|2b:row 2, cell 2 of 2]` | a signals-page button labelled with the flat index, or without the cell tooltip; a one-cell row keeps its plain label and the tooltip "draw this signal on this plot" |
| `assign-1b` / `drop-2b` | `assigned=1 notice='plotting 'sine' on plot 1b'`; `dropped=1 notice='plotting 'square' on plot 2b'` | a notice that names the plot by number alone |
| `after-assign` (state line) | `traces=[1a:-\|1b:sine\|2a:-\|2b:square]` | the signal went to a neighbouring cell |
| `ruler-labels` | `split=[row 1,row 2]` and, with cells `{1,2}`, `mixed=[row 0,row 2]` | the cursors pane's ruler lines (a label read through the pane's own widgets): **one line per figure row**, because the cells of a split row share their y axis and so their rulers. A split row is `row <figure row, from 1>` and sits on its first cell; an unsplit row keeps `row <plot index>` (**0-based**, as it always was -- `row 0` is the first plot, not a defect). One line per cell (`plot 1a,plot 1b,...`) is the pre-sharing reading |
| `shared-y` | `y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1`, `ranges=[A=-3..7 B=-3..7 \| fit A=B]`, `axes_w` equal to within a pixel (both `671.6`; `642.4` was the reading before the plotter gave the right gutter to the last cell only), `auto=1` | a row of two cells with a trace each. `y_shared`: a `set_y_range(-3, 7)` on cell A is B's after one frame. `y_gutter`: only cell A has a y gutter (B's `y_axis_width` and `y_label_area.w` are 0) and the two plots are equally wide. `y_fit`: `fit_all()` leaves both cells on one range that covers both traces (not the manual one). `ruler_mirror`: a ruler written on A and read back by `sync_cursors(0)` is B's too. A 0 in the first two is `set_share_row_y(true)` not reaching the figure; in `y_fit`, no frame between `fit_all()` and the read (the probe restructures to pump one) or a group that did not follow the flag |
| `x-axes` | `row0_labelled=[1,0,1] bottom_labelled=[1,1,1] gap_ok=[1,1,1] caption_ok=[1,1,1] last_plot_zoom=1`, `gap_px` equal on both sides of each `/` and equal to `col_gap_px` | three arrangements, read in order: `{2}/{1}`, `{2}/{2}` and `{2}/{2}` with cell weights 1:3 over 1:1, every plot on channel 0 and the view pinned to an absolute span (`pin_pause_window(100.0, 100.05)`), because the live axis is `[-window, 0]` and never relative. `row0_labelled`: `x_strip_has_labels(x_strip_mode())` of row 0's plots, a row draws labels only if its columns differ from the row below's, so `[1,0,1]` (differs, equal columns, differs by weight); `bottom_labelled`: the bottom row always labels. `gap_ok` / `gap_px`: `axes[k+1].x - axes[k].right()` against `col_gap` within 0.5 px, in the logical px the line prints; a different gap is the plotter's inter-axes spacing changed or a cell that still carries its own right gutter. `caption_ok`: a plot has a non-empty `x_note` (the `t0 = ...` caption) **exactly when** its strip draws labels (`-1` means the labels were not relative, so the caption could not be checked: the pin did not take); `0` is `update_time_labels()` putting the caption on the bottom row only, so a labelled row above reads no `t0`. `last_plot_zoom`: after a narrower x range is written onto the last plot's member (a gesture on a labelled strip that is not row 0) every plot adopts it, `0` is `hold_pinned_window()` looking at row 0 only and reverting the gesture. The step restores the window with `set_time_window()` afterwards |
The run ends with the usual `gtk warnings: 0` and no `Gtk-CRITICAL`. `x_equal=1` and `cursors_aligned=1` on every state line are
the only readings of the shared time window across cells.