A finite trigger_time() only says the edge was accepted; the mode is Capturing until the post window fills. Every field the report prints is read from display_snapshot(), whose stamps span x = 0 only once the event is Held, so reporting on the latch alone raced the capture: leg 2 printed `measured=nan at0=nan ... mode=1` on one run and the documented `at0=-0.500907 earlier=-0.498185 mode=2` on the next. The report now waits for Held under the same poll budget, and says `latched but never held` when it runs out -- a different defect from `no latch`, so the line names which. The readings are corrected against three consecutive leg-2 runs and one leg-3 run: `t0` is a rebased stream stamp and varies every run, so it was never an assertion; `hits=` likewise tracks the arm phase (12 at one arm, 11 at another). The mode ordinals were wrong -- `mode=2` is `Held`, not `Capturing`. Shapes 2 and 3 are now measured rather than read from the arm code; shape 1 still is not. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
734 lines
65 KiB
Markdown
734 lines
65 KiB
Markdown
# 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 — 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-mb 1`, which wraps in a few seconds.
|
||
- `trigger: edge at X ... want [-a..b] got [-c..d] n=N` (stderr) — `got` must equal
|
||
`want` and `N` must be exactly the window times the stream's real rate, plus one
|
||
(2 s at 10 kS/s is `n=20001`, not 19999). `got` shorter than `want` on the *left*
|
||
means the edge was accepted before the history reached back a full pre window (the
|
||
gate in `TriggerEngine::process()`); shorter on the *right* means the engine latched
|
||
early or the cut lost the newest samples. An `N` one or two short of the exact count
|
||
is the same class of bug, not rounding: `publish_event()` clamps the slice to every
|
||
value ring's `produced()`, so publishing before each channel of the latching sample
|
||
has been pushed silently drops it.
|
||
- `[disarm-probe] was_capturing=1 display=.. seq=A->B events=C->D snap=[a..b] n=N
|
||
axis=[..]` (stderr, `UDPSCOPE_PROBE_DISARM_MS`) — with `was_capturing=1`, **`display`
|
||
must read `event(frozen)`** and `snap`/`axis` must be `[-pre..e]` where `e` is how far
|
||
the post window had filled, with `n` that span times the stream's real rate plus one.
|
||
`display=nothing` (or a `snap` from the *previous* event) is the "disarming mid-capture
|
||
makes the event completely disappear" defect. **`seq` must advance by exactly one while
|
||
`events` does not move**: no event fired, so the partial publish deliberately leaves
|
||
`trigger_latches()` alone — which is also what keeps the `trigger events: N total, M at
|
||
the disarm` line in the dump meaning what it says.
|
||
- `[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 two `bytes=` lines are the writers: the `.npy` is
|
||
`64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a
|
||
mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing
|
||
either writer.
|
||
- `[window-probe] <action> label=.. entry=.. unit=U window=W event_span=E cap=C decim=M
|
||
stored=R aa=F buffered=B` (stderr, `UDPSCOPE_PROBE_WINDOW_MS`) — **`window` must equal
|
||
`event_span`** on every line (the trigger's event span is the display window; a capture
|
||
cut to a different length than the axis frames is the "post window is empty" defect's
|
||
cousin) and **neither may exceed `cap`**. The `unit ms->s` line is the one that catches
|
||
the feedback loop: changing the unit re-interprets the typed number, so "500" in ms
|
||
becomes 500 **s exactly**, and a `window=499.98` there means the control re-parsed its own
|
||
rounded readout (`8.333 min × 60`) instead of the request — see the `window_sync_unit`
|
||
invariant 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-mb 1`, which saturates in a few seconds and makes windows past ~6 s
|
||
decimate; at 200 MB nothing inside `--max-window` needs to.
|
||
- **The anti-alias check** is the same probe run against a waveform above the stored
|
||
Nyquist: `--buffer-mb 1 --freq 2000` with the probe ending on a 60 s window gives
|
||
`decim=16`, a 625 S/s stored rate and a 312.5 Hz corner, and the channel dump's
|
||
`ch 0 'sine' ... y=[-0.059, 0.079]` is the assertion — a boxcar of 16 at 10 kS/s passes
|
||
2 kHz at about 6%. **A `y=[-1, 1]` there means the filter is gone** and the 2 kHz sine has
|
||
aliased to a full-amplitude 125 Hz one, which looks entirely plausible on screen. This is
|
||
the only check that distinguishes averaging from striding.
|
||
- `[cursors-probe] gestureless plot move with no gesture: stored A -> B ignored=D` — a
|
||
plot-level `set_cursor_x()` with **no gesture behind it must be ignored** (`ignored=1`, and
|
||
`A == B`): the tick reads a row only while its controller holds a cursor, and this line is the
|
||
guard for the defect where it adopted its own echo and a dragged cursor sprang back. (The
|
||
*adoption* half -- a gesture that does move a cursor, and the shared value reaching every row
|
||
-- is asserted in the `check_host` harness, which can call `sync_cursors(row)` directly; a
|
||
synthetic press here cannot reach the adapter's own event pump.)
|
||
- `[cursors-probe] <action> mode=.. on=D pane=D t0=.. t1=.. dt=.. T=[..] lines=N [..]`
|
||
(stderr, `UDPSCOPE_PROBE_CURSORS_MS`) — walks `vertical` → `horizontal` → `both` →
|
||
`mix`, then moves a marker *through the plot* (`Plot::set_cursor_x(..., Free)`, which is
|
||
what a drag does) and reports again. **The times must survive every mode change**: they
|
||
are the `RowSet`'s one pair, and a mode that parks the markers must not read them back
|
||
(a parked cursor reports a real number, so a read-back would adopt it as a gesture — the
|
||
bug that made `T0` read 0). **`lines`** must be 0 for `vertical`, one per row for
|
||
`horizontal`/`both` (and the time row exists only in the modes that show the vertical
|
||
pair — `T=[]` in `horizontal` is the assertion that it does not), and one per **bound**
|
||
trace for `mix` — a slot with a name *and* a channel the model built, never a name alone
|
||
(see the bound-slot invariant 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 revealed=D marks_shown=M pane_rows=R pane_w=W
|
||
pane_h=H`
|
||
(stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first
|
||
plottable signal and lets the tick compute them, so what it prints is what a user reads.
|
||
On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.64 and
|
||
`bottom` ≈ -0.64 (the mean of each half of a sine), max/min ≈ ±1, `pulse duration` ≈ 0.5 s
|
||
with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. 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, `level:V,time:T` for min/max (a ruler at the value
|
||
*and* a marker at the first sample that reached it — two level marks at both extremes was
|
||
the bug), `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with
|
||
its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration`
|
||
seconds, and the rest the channel's unit. `marks_shown` counts two per shown measurement
|
||
(a min/max showing both its marks counts two of its own). `measures window:` is the
|
||
timeliness assertion -- the window the numbers were computed over against the one on screen.
|
||
**The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window
|
||
on screen. 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.
|
||
`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 / window`: ~10 % on a 2 s window
|
||
at the 100 ms cadence, and a few *hundred* percent on a window shorter than the interval (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 moves (2 % of its span) or the held/live state changes, never on a
|
||
wall-clock timer: a timer is what let a marker sit outside the viewport and a dragged one
|
||
survive a frame. **`marks_selectable` must be 0**: the
|
||
measurement marks are created unselectable (`Plot::set_cursor_selectable()`, which the
|
||
library gained for this), read back out of the plot here -- re-placing a marker every frame
|
||
does **not** stop a drag, because the controller holds it for the gesture's life and the
|
||
marker follows the pointer between frames. The library's own controller test
|
||
(`an_unselectable_cursor_is_neither_hovered_nor_grabbed`) is the other half. The probe's last
|
||
step still drags a mark *programmatically* and requires the next frame to put it back
|
||
(`restored=1`), which now checks the placement path rather than the read-only property. The dialog report's `type= thr=
|
||
side= show=` is the per-type behaviour: `period` and `frequency` must show **neither** the
|
||
split nor the side row (they compute from the mid level's rising crossings, and an option
|
||
there is one the user can only get wrong), `average`/`min`/`max` no split either, and only
|
||
`pulse duration` a side. A measurement whose signal is not acquired, or
|
||
a window with too few crossings, must read `invalid`/`--` rather than a made-up number
|
||
(`frequency` on a window holding one edge is the case: `duty` is reported only when a
|
||
period exists to divide by). The probe then **walks the dialog's lifecycle**: open the edit
|
||
dialog on measurement 0, click its `Save`, open it again, click `Delete` — `measures=` must
|
||
be unchanged after the save and one lower after the delete, with the process still alive.
|
||
That is the check for the two things a dialog of this shape gets wrong: two buttons owning
|
||
one context (a double free aborts on edit/delete, and only those paths have two buttons) and
|
||
a `const char*` array built from a `std::vector<std::string>` as it grew (every pointer
|
||
dangling, the first entry the most visibly). The report's `signals=[..]` and `drop_h` are the second and third:
|
||
the list must be the **plotted traces** (real names, deduplicated, in signal-list order —
|
||
*not* every channel, and never a stream's timestamp signal, which is what an acquired-but-
|
||
unplotted list put first and auto-selected), and `drop_h` must be a combo box's height
|
||
(tens of pixels — a scrolled-window wrapper asked it to grow to 160).
|
||
- `[timelabels-probe] <action> relative=D 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=[..] traces=[..]`
|
||
(stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is
|
||
up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the
|
||
buttons exist only while the editor does. `done` follows the same rule for the editor's
|
||
own exit button (1 up, 0 off), and its `done_box` on the `placed` line must sit in the
|
||
area's top-right corner: `x + W` within a few pixels of the area width and `y` within a
|
||
few of 0 (the on-switch line reports a box from before the first frame, so read the
|
||
`placed` one). The last probe step leaves the editor by **clicking that button** — the
|
||
toolbar toggle is still exercised by the first step — so `buttons=0 done=0` with the
|
||
rows and traces untouched is the assertion that the way out works. **`traces=` is the real assertion**: an insert
|
||
or a remove moves the rows' *contents* with them, so `add above 0` must read
|
||
`[0:-|1:sine,…]` — row 0's signals now on row 1, not renumbered onto the new empty row —
|
||
and `remove 0` must pull them back to `0:`. A `traces=` where the signals stayed on row 0
|
||
means `carry_row()` was skipped and the rows were renumbered under the user.
|
||
Geometrically, each `close=` must equal its row's top from `rows_y` plus the pad, and
|
||
`add=` must be centred on the boundaries (so strictly increasing, the first at 0 and the
|
||
last clamped to the area's bottom). The `rows_y` spans must tile: a gap other than
|
||
`kRowGap` means the figure and the overlay disagree about where a row is, which is how a
|
||
`×` ends up on the wrong row. Also read `x-desync 0` in the dump — every row the editor
|
||
adds has to join the one x link group. Run it at `--plots 8` as well: the cap and the
|
||
floor are the two refusals, and because the probe emits `::clicked` straight at the
|
||
widget it reaches the handler even when the button is insensitive, so `add above 0` there
|
||
must leave `rows=` and `traces=` untouched rather than growing a ninth row.
|
||
- `header: window_button_w=W label_w=L bar_w=B` (stderr, once a second) — **`W` must be
|
||
a small constant and must not track `B`**: the window control is a header-bar child,
|
||
and an expanding one is measured into the title's centre box, so it stretched from the
|
||
label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that
|
||
grows with the window width is that bug back.
|
||
- `toolbar icons: trigger=NAME(ok) pause=... cursors=... measures=... math=... sidebar=...
|
||
menu=...` (in the exit dump, beside `panel controls:`) — the icon name every toolbar button
|
||
carries, with `gtk_icon_theme_has_icon()`'s verdict. **Every entry must read `(ok)`.** Four
|
||
of the names are this app's own (`udpscope-trigger-symbolic` and friends, a `GResource`
|
||
compiled into the binary — see the toolbar-icon invariant 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.
|
||
- `app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)` (in the exit dump,
|
||
beside `toolbar icons:`) — the application icon, named and present at the size its own root
|
||
`<svg>` element declares. **Both entries must read `ok` at those sizes.** `ok` is
|
||
`gtk_icon_theme_has_icon()` *and* the resource bytes being found under the registered path, so a
|
||
`MISSING` is a resource path that was never registered or a file that was renamed, and a size
|
||
other than 128 or 16 is a canvas that drifted off the HIG's. **Read it together with
|
||
`toolbar icons:`, and trust that line further for the registration**: the resource-path
|
||
registration is one call in `run_ui()` serving both the window's icon and the toolbar's buttons,
|
||
so a `MISSING` on either line is the same defect — but on a machine where
|
||
`--target desktop-entry` has been run, *this* line's `has_icon` half is also satisfied by the
|
||
**installed** copy under `$XDG_DATA_HOME/icons/hicolor`, so a broken registration still reads
|
||
`ok` here. That was measured: with the resource path deliberately misspelt, `toolbar icons:`
|
||
reported four `MISSING` (nothing installs the action icons) while `app icons:` stayed `ok`
|
||
until the two installed SVGs were moved aside, whereupon it read `MISSING 128x128` /
|
||
`MISSING 16x16`. The line is still worth its place — the *present* half (the resource bytes) and
|
||
the declared sizes are read from the binary and nothing else can satisfy them — but a developer
|
||
machine is the one where it is least sensitive.
|
||
**It deliberately does not claim the icon renders**, because no runtime GTK call can:
|
||
`gtk_icon_theme_lookup_icon()` never returns `NULL`, its paintable's intrinsic size is the size
|
||
it was *asked* for, and a valid icon, a malformed one and a name that does not exist at all all
|
||
snapshot to the same `GSK_TEXTURE_NODE` of the requested bounds — GTK substitutes
|
||
`image-missing`. A malformed SVG is a *build* error instead; see the icon-authoring rules.
|
||
- `[sources] N known: HOST:PORT plots=P signals=S [multicast=G] [interface=I] | ...` (stderr,
|
||
`UDPSCOPE_PROBE_SOURCES=1`) — the startup chooser's discovery, printed and exited **before
|
||
`gtk_init()`**, so this one needs no display and no `xvfb-run`. **Only sources with a named
|
||
signal may appear**: a development machine accumulates one session file per probe run on a
|
||
throwaway port (140 of them when this was written), and the filter in `known_sources()` is
|
||
what keeps the list the sources a user configured. Order is newest first by file mtime.
|
||
- `[sources] picked HOST:PORT multicast=G interface=I` (stderr, `UDPSCOPE_PROBE_PICK_MS`) —
|
||
shows the chooser, then activates row 0 the way a click does and continues into the real
|
||
application, so a full run with **no `--server`** must reach the scope and emit every other
|
||
dump line unchanged. `multicast=`/`interface=` coming back non-empty from a stored row is the
|
||
assertion that a multicast session reconnects to its group, and it needs **no multicast
|
||
networking**: write the session file by hand and read the line. With
|
||
`UDPSCOPE_PROBE_NEWHOST=host:port` it drives the form instead — `[sources] form open,
|
||
connect_sensitive=1` then a `picked` line, with no SIGSEGV, which is as much a crash test of a
|
||
dialog built and destroyed as it is a state check. An empty list must print `picked nothing:
|
||
the list is empty` rather than dereferencing a null row.
|
||
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once
|
||
with a drop and a window change, then again with no interaction: the second run's dump
|
||
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
|
||
first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0
|
||
-> channel ... samples=N` line). Delete the file before a run whose layout you did not
|
||
mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise).
|
||
- `[udpscope] starved: connected (config=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
|
||
`--buffer-mb 200` really is 200 MB) 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.)
|
||
- `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] pages=14 parsed=14 shortest='Choosing a source' 1563 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.
|
||
- `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row,
|
||
then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B`
|
||
and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr,
|
||
`UDPSCOPE_PROBE_MENUS_MS`) — the two popovers the toolbar and the status bar open, measured with
|
||
the side panel hidden. **`rows_bad` must be 0 and `fits` must be 1.** What it asserts is what a
|
||
screenshot can only show an eye: every row's label starts at the *same* x (`label_x=26` for all
|
||
seven — a menu whose checkable rows indent their text and whose others do not is exactly what
|
||
"the text is not left-aligned" looks like), every shortcut ends at the row's own right edge
|
||
(`accel_right == W`), no row is over `kMenuRowMaxPx` (30) tall — Adwaita's button minimum is 34,
|
||
and seven of those is what made the popover read as a stack of dialogs until `.udpscope-menu`
|
||
trimmed it — and the rows arrive in the order its `kExpected` names. `[menu] status` is the ⓘ
|
||
bubble: **`pos` must read `top`**, which is set *after* `gtk_menu_button_set_popover()` because a
|
||
`GtkMenuButton` states a position of its own and the last word wins, and `button_top` must exceed
|
||
the bubble's height, because "below the button" is off the window's bottom edge — the bubble's
|
||
top edge stayed at the button with its body clipped away, which reads as an arrow disconnected
|
||
from its body. Run it **alone**: another probe that opens a window dismisses the popover, and
|
||
every row then reports `h=0`. It turns autohide off for its own measurement — a popover dismisses
|
||
itself when it loses the grab, and `xvfb-run` has no window manager to hand it one — and restores
|
||
it afterwards.
|
||
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
|
||
- `[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.
|
||
- `[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`):
|
||
|
||
```
|
||
phase=0 on=1 passes=36 | n=30 mean=-0.0370313 sd=0.0953509 lo=-0.246367 hi=0.105167 | n=30 mean=0.633511 … | n=30 mean=1.99567 … hi=2 | n=30 mean=0.997834 … hi=1
|
||
phase=1 … dn=0 (on all four measurements)
|
||
phase=2 … n=1 mean=-4.84467e-14 | n=1 mean=0.63662 | n=1 mean=2 | n=1 mean=1
|
||
```
|
||
|
||
- `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=0.633511` (≈ 2/π)
|
||
for the `top` measurement on a unit sine is the check that the threshold types are correct, not
|
||
merely plausible. `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.
|
||
|
||
## `[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`.
|
||
|
||
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.
|