The contextual help was on by default, so a first run met a window with a "?" beside every surface it had not asked to have explained; `contextual_help` now defaults to false and the layer is asked for from the Help menu, from the chooser's new help menu, or with Ctrl+H. The source chooser runs before `App` exists, which makes it the window a user who does not know how to start is looking at, so it now carries the same three help affordances the window's Help view does: Documentation (the manual is another process and needs nothing of the app), Enable contextual help (a row whose check *is* the setting, written to the file the app reads a moment later), and Guided tour. The tour is drawn on the app's own window, so the chooser can only record the wish: it sets `Config::tour_pending` -- the value `main()` owns and hands to both `choose_source()` and `run_ui()` -- and `on_activate()` starts it once the window is up and laid out, through its own callback so the readings can tell a request from the first-run auto-start. The rows are the window's own `menu_button()`/`menu_toggle()`; `install_css()` therefore runs from `choose_source()` (it is idempotent now) so the popover gets the `.udpscope-menu` rules before it is shown. Opening the manual could also fail with a message that blamed the handler whatever was missing. `help_installed_path()` now resolves `help:<id>` the way the specification does and the failure alert says which of the two it is: with the set installed it names the viewer, without it it names the identifier, the expected installed path and the install command. `show_help()` gained an optional reporter, which `UDPSCOPE_PROBE_HELP_MS` uses for a new reading -- `[help] launch attempted= done= ok= error='' installed=` -- since a successful launch leaves no window of ours to observe. Tested: clean --clean-first build, three warnings as documented; test-ui 13/13 and test-help pass; no Gtk-CRITICAL in the help, helpui and chooser runs.
1778 lines
152 KiB
Markdown
1778 lines
152 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 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-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_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.
|
||
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. `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
|
||
proves the readout rows landed in the frame's `measures_list` rather than nowhere. `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=[..]`
|
||
`side_w` is the sidebar column's width and **must be its own request (320), 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 320 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 `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 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) 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.
|
||
- `switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36` (in the exit dump, beside
|
||
`panels:`) — the two
|
||
sidebar tabs as the switcher actually draws them. **`pills=2 labels=0`** is the claim: two pills are on
|
||
screen and **no text is drawn** (`labels` counts *mapped labels with text*, because `narrow` keeps an
|
||
empty label in the pill's layout and a plain mapped count would read 2 either way). `names=2` is the
|
||
other half — both pills carry a tooltip and an accessible name set by `name_side_tabs()`, so an
|
||
icon-only pill is not an anonymous button — and `pills=0` means nothing is mapped, which makes the
|
||
rest of the line meaningless rather than a pass. `policy=narrow` is read rather than assumed. `icon=`
|
||
is the box the pills' `GtkImage` is *rendered* in, which proves the 32px override stuck: the tab
|
||
glyphs are 32x16 documents, and GDK paints an icon into a square box whatever the document's aspect,
|
||
so the artwork is drawn 32x16 at 1:1 inside that 32x32 box — letterboxed, not stretched, which is what
|
||
keeps its 2px bars crisp. It reads `0x0` on the first tick, where nothing has been allocated yet, and
|
||
is therefore read by the exit probe with the rest of the sidebar's geometry. `size=` is the bar's own
|
||
size, and it is the answer to "what did the wider glyphs cost": `36` is what it was with 16px icons,
|
||
because the pill's fixed height takes a 32px box without growing — the glyph gets twice the width for
|
||
no vertical room at all.
|
||
- `page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)` (in the exit
|
||
dump, beside `toolbar icons:`) — each tab's own glyph and `gtk_icon_theme_has_icon()`'s verdict, read
|
||
on the first tick for the same reason the toolbar's are. **Both must read `(ok)`**: the page titles are
|
||
empty on purpose, so an icon that does not resolve leaves a *blank* pill, and a blank pill looks
|
||
exactly like a page with nothing in it.
|
||
- `help layer: contextual=on hints=8 ids={events plot status cursors measures trigger signals window} icon=dialog-question-symbolic(ok); tour=idle steps=5 step=0 auto=1 requested=0` (in the exit
|
||
dump, beside `switcher:`) — 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: the eight 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). 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)`**: a `?"` whose name Adwaita lacks is a
|
||
*blank* control that still works, the failure the pane [edit] buttons already paid for once. `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** "Guided tour"
|
||
(`Config::tour_pending`) having been carried into this run instead, which is `auto=0` because it is not
|
||
the auto-start.
|
||
- `help figures: trigger_head=X,Y,W,H trigger_summary=… trigger_panel=… signal_list=… side_switcher=… side_stack=… 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()`, not `get_visible()`: a widget
|
||
under a hidden parent — `trigger_summary` while the pane is in *configuration* mode, the Measure page
|
||
while the Signals tab is showing — keeps its own flag true and reads `0x0`, and a crop box drawn from
|
||
that is a box over the wrong thing. 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 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] 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=5 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=8 ids={events plot status cursors measures trigger signals window}` —
|
||
the eight "?" affordances, by id. The layer's ninth surface, `math`, is not here: its host exists
|
||
only while the math editor window is open. **On a fresh config this reads `hints=0 ids={}`**, 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.
|
||
- `[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=8 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 eight, 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=5 step=0` — the tour started the way
|
||
`Ctrl+T` starts it. `steps=5` is the plan: the table's five steps whose target was on screen
|
||
(`gtk_widget_is_visible()`, so a collapsed pane is not walked over).
|
||
- `[helpui] step=1/5 visible=1 spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C` — **in window
|
||
coordinates**, the frame a screenshot is cropped in. **`contains` must be 1** on every step: the
|
||
spotlight is the target's own rectangle grown three pixels. `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 side panel rather than putting it beside the plot, so
|
||
`clear=0` there is the geometry and not a placement bug. The side panel's step reads `clear=1`.
|
||
- `[helpui] after Next: step=2/5 visible=1`, `[helpui] after Back: step=1/5 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. `trigger_panel`'s height differs between them (508 px in
|
||
configuration mode against 72 locked — the body is replaced by the summary), which is the check
|
||
that the mode really moved.
|
||
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: tab signals->measure | g: tab measure->signals | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**.
|
||
The `m`/`g` pairs are the *tab name* before and after, which is what says those two keys select a tab rather than toggling a pane; `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 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
|
||
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). Eight 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.
|
||
|
||
- `[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.
|
||
|
||
## `[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
|
||
[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25
|
||
[configs] before=default active=default
|
||
[configs] save-as=1 names=probe,default active=probe
|
||
[configs] round trip read=1 comment='written by the configs probe' buffer_mb=8 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1
|
||
[configs] after forget names=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` | the same, per trigger field; a level of `-1e12` is a number nobody chose |
|
||
| `[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_mb` 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 |
|
||
|
||
## `[rowmenu]`
|
||
|
||
```
|
||
[rowmenu] main 4 'Remove signal' h=18 label_x=26 accel_right=-1/174 ok=1
|
||
[rowmenu] main rows=5 bad=0 labels=[Y axis: auto scale,Y axis: fixed range...,Fit X axis,Fit all,Remove signal]
|
||
[rowmenu] remove 0 'Back' h=18 label_x=52 accel_right=-1/178 ok=1
|
||
[rowmenu] remove rows=6 bad=0 labels=[Back,sine,square,ramp,noise,All traces on this plot]
|
||
[rowmenu] summary main_rows=5 main_bad=0 remove_rows=6 remove_bad=0 after_back_rows=5 after_back_bad=0 second_rows=6 second_bad=0 remove_row_in_main=1 remove_row_gone=1 can_open_again=1
|
||
[rowmenu] remove-clicked 'sine' 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 |
|
||
| `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 `buffer=` here
|
||
that disagrees with `--buffer-mb` 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: button_visible=0 pane=0 pane_w=0 tick=1 recording=0
|
||
[events] recording on, no trigger: button_visible=1 pane=0 pane_w=0 tick=1 recording=1
|
||
[events] after trigger on: button_visible=1 pane=0 pane_w=0 tick=1 recording=1
|
||
[events] preview redrawn: signals=3 stored=480x132 row_drawn=1
|
||
[events] dir=/home/u/.cache/udpscope/events/127.0.0.1-21023 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 pane=1
|
||
[events] activated row0: replaying=1 pane=1 pane_w=240 pane_box=228 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] 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: button_visible=1 pane=1 pane_w=240 tick=1 recording=0
|
||
[events] recording off, trigger on: button_visible=1 pane=1 pane_w=240 tick=1 recording=0
|
||
[events] compare buttons: cached=2 selected=2 compare=1 accumulate=1
|
||
[events] compare: kind=1 rows=1 channels=8 samples0=32964 traces0=4 traces1=-1 window=2.000 recording=1 pane=1
|
||
[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
|
||
```
|
||
|
||
`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.
|
||
|
||
`[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 pane, 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) |
|
||
| `pane` | 1 once the history button has been clicked | the button's handler not revealing the pane |
|
||
| `live` | `button_visible=0 pane=0** | a pane or a button on screen with no trigger mode: a column of nothing beside a live plot |
|
||
| `after trigger on` | `button_visible=1 pane=0` | the pane revealed by trigger mode alone (it must be on demand from the toolbar button), or the button missing in trigger mode |
|
||
| `recording off, trigger off` and `recording off, trigger on` | `button_visible=1 pane=1 recording=0` in both **once the cache holds anything** (the probe's own run does by this point) | the button going away with the switch when events are already written -- an event on disk outlives the switch that wrote it -- or `pane_w` not being 240 with the pane open. With an *empty* cache the same two lines read `button_visible=0 pane=0`, which is the empty column the item exists to prevent |
|
||
| `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`, `pane_box` (in `activated row0`) | `[-pre, +post]` of the event just opened, `marker=0.000`, and a `pane_box` close to `pane_w` | 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; or a pane whose *rendered* width is 0 while the divider says 240 -- the position is what was asked for, `pane_box` is what the pane actually got, and a divider at 240 with a zero-width pane is still no pane |
|
||
| `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 |
|
||
| `pane_w` | the divider's position: 0 while the pane is closed, its remembered width (240 by default) while it is open | the pane reserving a column while closed (a paned hands its start child its minimum unless the position says otherwise), or a width that is not remembered |
|
||
| `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`, `pane=1` | `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: sidebar hidden for the drag=1 restored after it=1
|
||
[udpscope] pane edits: locked=document-edit-symbolic(ok) config=object-select-symbolic(ok)
|
||
[udpscope] panes: trigger=locked signals=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 signals=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* with the split view forced into its **overlaid**
|
||
state, which is what a window with no room beside the plot produces. Both must read `1`: with the sidebar
|
||
covering the plot there is no drop target under the pointer, so a drag out of the signal list is
|
||
*impossible* rather than merely awkward, and `row_drag_sidebar()` steps the sidebar aside for the length of
|
||
the drag and puts it back after. `hidden=0` is an overlaid drag that can never land anywhere.
|
||
|
||
**`pane edits:`** is the two `[edit]` buttons 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).
|
||
|
||
Both panes have two modes. **Locked** is what a returning user gets — the trigger pane shows the one line
|
||
in `summary`, the signal list shows only the signals that are on, and neither offers a 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. Each heading's `[edit]` button flips
|
||
its pane.
|
||
|
||
- **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 locked signal list drops the rows of the signals that are off**, so `rows` counts what is on:
|
||
`signals panel: … rows=3 signals=2 divider=1` is two on signals and the `MATH` divider, not four
|
||
signals. Its `+` goes with them — a `+` on a locked list opens the editor the lock exists to keep shut.
|
||
|
||
## `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.
|
||
|
||
## `[sidebar-probe]`
|
||
|
||
```
|
||
[sidebar-probe] beside window=756 sidebar=320 collapsed=1 shown=1 button=1(sens=1)
|
||
[sidebar-probe] regained window=756 sidebar=320 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 (320) 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] page=signals accums=4 baseline recorded
|
||
[side-tab-probe] page=signals rows=4 accums=4 advanced=4 min_n=18
|
||
```
|
||
|
||
`UDPSCOPE_PROBE_SIDE_TAB_MS` is the one reading for the rule that **the statistics accumulator keeps running
|
||
on the hidden page**. It switches *to* the Measure tab, forces one pass so there is a baseline, then switches
|
||
away to Signals and reads again two seconds later -- with the measurements' page not current, so its widgets
|
||
are unrealized and measure 0x0.
|
||
|
||
- **`advanced=4` is the assertion**: every accumulator gained passes while its page was off screen, and
|
||
`min_n` (the smallest `n` seen) must be non-zero too. **`advanced=0` is the defect**: something gated the
|
||
accumulation on the visible tab, so the readout would gain a gap in its min/max/avg for exactly as long as
|
||
the trigger was on screen. A `Gtk-CRITICAL` here is the other half of the same rule -- a label or an
|
||
allocation read on the unrealized 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 tab.
|
||
- `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.
|
||
|
||
## `[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
|
||
[prefs-probe] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark 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. **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`. 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
|
||
[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=8/8
|
||
```
|
||
|
||
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.
|
||
|
||
## `trigger panel:`
|
||
|
||
```
|
||
[udpscope] trigger panel: boxes=15 controls_inside=15/15 search_widgets=4/4 search_inside=4/4
|
||
```
|
||
|
||
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, `controls_inside`
|
||
counts the fifteen controls the code built and then appended into those rows -- the last two being the
|
||
noise band and the HF reject, whose rows arrived with the noise-rejection work, `search_widgets` is the four
|
||
search buttons having resolved by id, and `search_inside` is those four being inside the block the expander
|
||
opens onto. A control built and never placed -- an id that drifted between the file and the code -- reads as
|
||
`controls_inside` below 15 while every other line still looks right, which is the panel that has a field
|
||
missing.
|
||
|
||
The search walk starts at `gtk_expander_get_child()`, not at the panel: an expander's child is not in the
|
||
widget-parent chain at all, so walking up from a search button reaches no ancestor of the panel (see
|
||
`invariants-gtk-and-chrome.md`). `[defaults] trigger:` prints the same panel's seven spins in tree order, so
|
||
the two lines together read what the file gave and what the code put in it.
|
||
|
||
## `signals panel:`
|
||
|
||
```
|
||
[udpscope] signals panel: labels=2 list=1 rows=5 signals=4 math=0 divider=1
|
||
[udpscope] signals panel: labels=2 list=1 rows=9 signals=4 math=4 divider=1
|
||
```
|
||
|
||
The signals panel's frame read from the window, and in the same line the *shape* of the one channel list:
|
||
the heading and the hint the `.ui` gives it, the list the rows go in, how many rows that list holds
|
||
(`rows`), how many of them are streamed signals (`signals`), how many math channels the app has (`math`),
|
||
and `divider`, which is 1 when `rows == signals + 1 + math` and 0 otherwise. **`drag_sources` must be
|
||
`rows - 1`** — one per signal row, the `MATH` divider not being draggable. Dragging a signal onto a plot is
|
||
how a plot is filled, so it has to survive both pane modes: the locked list drops the tick, the menu and the
|
||
`+`, and this is the reading that says the drag did not go with them. (`0` is a list that has become a thing
|
||
you can read but not use.) `labels=1` is a `.ui` that lost
|
||
one of the two labels — counted over the panel's subtree *above* the list, because the rows' own name
|
||
and unit labels are the code's and the heading sits in a heading row beside the pane's `[edit]` button — `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 revealer=1 nested=1
|
||
```
|
||
|
||
The events pane's frame read from the window: the two group actions, the list, and the revealer the `.ui`
|
||
builds, plus `nested` — the list being *inside* that revealer, which is the one thing a lost id cannot show
|
||
(a list parented elsewhere reads 1 everywhere else). `[events]` is the other half, and it drives the pane end
|
||
to end: recording off, recording on, the trigger, the history button that reveals it, a row click 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 200 MB buffer'
|
||
[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-mb 4000` 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.
|
||
- **`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 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 `--buffer-mb`
|
||
(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-mb 4000` 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.
|
||
|
||
## `[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.
|
||
- `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.
|