Files
udpscope/docs/claude/probe-readings.md
T
Martino Ferrari f00d161900 fix: row titles mean their mnemonics instead of drawing the underscore
Fifteen display strings in the two preference forms were written as GTK mnemonics -- `_Directory`,
`_Server`, `Multicast _group`, `_Theme` and the rest -- but the property that makes GTK read them was
never set, so GTK drew the underscore literally: `_Directory` in the row, not a D with a line under it.

Two switches are involved, FALSE by default in each case. `GtkLabel`/`GtkButton` take `use-underline`;
the rows take `AdwPreferencesRow:use-underline`, "whether an embedded underline in the title indicates a
mnemonic". The two buttons in the source form already set it (so their mnemonics worked and showed no
underscore); the thirteen rows did not. All thirteen now do.

The row classes that carry the property are not all related -- AdwEntryRow has its own, while AdwSpinRow,
AdwComboRow and AdwSwitchRow get it through AdwActionRow -- so the probe reads it by name on a GObject
(g_object_class_find_property, then g_object_get) rather than casting to one row type; a cast to a
`GtkWidget*` where the type is only forward-declared does not compile at all.

`_Cancel` and `_Connect` both claimed C in the same form, so Alt+C could only reach one of them: that
marker was unreachable, which is worse than absent because it is in the source to be trusted. `_Connect`
becomes `Conn_ect`, freeing C for Cancel.

[prefs-probe] reads the count (4/4) on the real widgets; check_ui validates the property against the GIR
for every object in every .ui file, inherited properties included.
2026-10-12 15:10:22 +02:00

98 KiB
Raw Blame History

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.

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). Both need a saturated ring to show up, so run them under --buffer-mb 1, which wraps in a few seconds.

  • trigger: edge at X ... want [-a..b] got [-c..d] n=N (stderr) — got must equal want and N must be exactly the window times the stream's real rate, plus one (2 s at 10 kS/s is n=20001, not 19999). got shorter than want on the left means the edge was accepted before the history reached back a full pre window (the gate in TriggerEngine::process()); shorter on the right means the engine latched early or the cut lost the newest samples. An N one or two short of the exact count is the same class of bug, not rounding: publish_event() clamps the slice to every value ring's produced(), so publishing before each channel of the latching sample has been pushed silently drops it.

  • [disarm-probe] was_capturing=1 display=.. seq=A->B events=C->D snap=[a..b] n=N axis=[..] (stderr, UDPSCOPE_PROBE_DISARM_MS) — with was_capturing=1, display must read event(frozen) and snap/axis must be [-pre..e] where e is how far the post window had filled, with n that span times the stream's real rate plus one. display=nothing (or a snap from the previous event) is the "disarming mid-capture makes the event completely disappear" defect. seq must advance by exactly one while events does not move: no event fired, so the partial publish deliberately leaves trigger_latches() alone — which is also what keeps the trigger events: N total, M at the disarm line in the dump meaning what it says.

  • [trigrenum-probe] window=W event_span=S pre_pct=P post=O holdoff=H level=L edge=E mode=M enabled=1 armed=1 ch=C channels=N, then capture open: t0=T ch=C -- enabling signal 1 now, after enable: trigger ch=C' channels=N', and latched t0=T' opened on T t0_held=D (stderr, UDPSCOPE_PROBE_TRIGRENUM_MS) — the first line is the geometry the real widgets produced (window=1.0000 event_span=1.0000 pre_pct=10.0 post=0.9000 for the reported setup); it is context, not the assertion. The third must show the index actually moving (ch=0 → ch=1), or the probe tested nothing — the sine has to be a channel that sorts before the square. t0_held=1 is the assertion: the capture that opened on T latched on T, so the renumber neither abandoned it nor let a later edge inside the post window become t0. With the engine's invalidation keyed on the channel's index instead of its name this read t0_held=0, with pending back to 0 and holdoff_until behind now_t on the engine line — i.e. no hold-off was set at all, which is the whole defect. The [udpscope] trigger: edge at ... got [--0.0199..0.9000] beside it is expected: the rebuild replaces the BufferStore, so the surviving capture has no pre side left and reports a negative pre_s.

  • [export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] abs=[..] (stderr, UDPSCOPE_EXPORT_MS) — time= must equal axis= and rows= must be the span times the stream's real rate, plus one (2 s at 10 kS/s is 20001). The export is the visible range at the stored rate, so a count far short of that is a table cut from the LOD pyramid, and a time= narrower than axis= is a window searched on the wrong clock (the live branch puts the window back on the stream's clock with stream_at_x0() before searching the ring, and that offset is a whole window while streaming — not the 0 it once was). abs - time must be a single constant across both ends of a line, and that constant is where the display's origin sits in the stream's own clock: epoch() plus the newest stamp in mode=live, epoch() plus the accepted edge in mode=event. That is the one assertion on the second column, and it replaces the old "stream= must be the edge ± the window" reading, which only ever held because the display clock was the stream clock. A mode=live line whose abs - time differs between the two ends is the two columns computed from different epochs — the defect abs_base exists as a single const to prevent. An export taken mid-capture reports mode=live with time=[-pre..e] where e is how far the post window has filled — the export is what is on screen, and during a capture that is a partly filled window. Pin the probe outside a capture when checking the rows= count. The two bytes= lines are the writers: the .npy is 64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols), so a size that is not that is a mispacked record. Read it back with pd.DataFrame(np.load(path)) after changing either writer.

  • [window-probe] <action> label=.. entry=.. unit=U window=W event_span=E cap=C decim=M stored=R aa=F buffered=B (stderr, UDPSCOPE_PROBE_WINDOW_MS) — window must equal event_span on every line (the trigger's event span is the display window; a capture cut to a different length than the axis frames is the "post window is empty" defect's cousin) and neither may exceed cap. The unit ms->s line is the one that catches the feedback loop: changing the unit re-interprets the typed number, so "500" in ms becomes 500 s exactly, and a window=499.98 there means the control re-parsed its own rounded readout (8.333 min × 60) instead of the request — see the window_sync_unit invariant in invariants-gtk-and-chrome.md. label is a reading to three decimals in a natural unit and is expected to be lossy (500 s reads 8.333 min); it is the window= figure that has to be exact. For the resampling: buffered must be ≥ window on every line — that is what the decimation is for, and a buffered short of the window is the old "the control is capped to what the ring holds" behaviour coming back. decim must be a power of two, stored × decim must equal the wire rate, and aa must be exactly stored / 2. Run it under --buffer-mb 1, which saturates in a few seconds and makes windows past ~6 s decimate; at 200 MB nothing inside --max-window needs to.

  • The anti-alias check is the same probe run against a waveform above the stored Nyquist: --buffer-mb 1 --freq 2000 with the probe ending on a 60 s window gives decim=16, a 625 S/s stored rate and a 312.5 Hz corner, and the channel dump's ch 0 'sine' ... y=[-0.059, 0.079] is the assertion — a boxcar of 16 at 10 kS/s passes 2 kHz at about 6%. A y=[-1, 1] there means the filter is gone and the 2 kHz sine has aliased to a full-amplitude 125 Hz one, which looks entirely plausible on screen. This is the only check that distinguishes averaging from striding.

  • [cursors-probe] gestureless plot move with no gesture: stored A -> B ignored=D — a plot-level set_cursor_x() with no gesture behind it must be ignored (ignored=1, and A == B): the tick reads a row only while its controller holds a cursor, and this line is the guard for the defect where it adopted its own echo and a dragged cursor sprang back. (The adoption half -- a gesture that does move a cursor, and the shared value reaching every row -- is asserted in the check_host harness, which can call sync_cursors(row) directly; a synthetic press here cannot reach the adapter's own event pump.)

  • [cursors-probe] <action> mode=.. on=D pane=D t0=.. t1=.. dt=.. T=[..] lines=N [..] (stderr, UDPSCOPE_PROBE_CURSORS_MS) — walks vertical → horizontal → both → mix, then moves a marker through the plot (Plot::set_cursor_x(..., Free), which is what a drag does) and reports again. The times must survive every mode change: they are the RowSet's one pair, and a mode that parks the markers must not read them back (a parked cursor reports a real number, so a read-back would adopt it as a gesture — the bug that made T0 read 0). lines must be 0 for vertical, one per row for horizontal/both (and the time row exists only in the modes that show the vertical pair — T=[] in horizontal is the assertion that it does not), and one per bound trace for mix — a slot with a name and a channel the model built, never a name alone (see the bound-slot invariant in invariants-plot-and-layout.md; lines=6 marks=3 over three real traces is that defect). Each line reports as <name>=<A> <B> <dY>, so the names are the reading: a row claiming a signal that is not on a plot is the phantom. In mix the values must be the traces' own (-- when a marker is past the newest sample, partial when only one marker is over samples). After the move, t0 must have changed and every row's marker must have followed it (the report's t0 is the shared value). marks is the mix mode's crossing annotations: 0 in every other mode, and one per bound trace for each marker that is over samples in mix (so a move that brings the second marker into the data doubles it). The first mark at x=.. y=.. line that follows must agree with the pane's first value for the same trace (the pane's lines are chip name Y1 Y2 dY -- the chip carries the signal's name, since a chip alone leaves the user to match a colour against the plot by eye -- with the values formatted as measurements, two decimals, an SI prefix, the channel's unit, so the comparison is against the first number): both are read through the same Plot::crosshair_value(), and that line is the check that they stay the same number. A ruler drag is the other direction and the other hazard: the pane's A/B cells for a ruler row must change when the ruler moves (after ruler drag in the probe walks the first ruler to 80% of its y range), because nothing shares a ruler and the read-back in RowSet::sync_cursors() is the only thing that can notice. The dump's cursors in plot 0: line is the other half: it reads the markers back out of the plot (cursor(id).x/.visible), which is what the renderer draws from — the pane's numbers and the plot's state agreeing is the difference between "the app placed them" and "the app placed them and they are on screen".

  • [measures-probe] + the dump's measure N: 'sig' type thr=..% above=D show=D -> ok|invalid value text=[..] [duty=..] [marks=..] and measures pane: n=N revealed=D marks_shown=M pane_rows=R pane_w=W pane_h=H (stderr, UDPSCOPE_PROBE_MEASURES_MS) — adds one measurement of every type on the first plottable signal and lets the tick compute them, so what it prints is what a user reads. On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, top ≈ 0.64 and bottom ≈ -0.64 (the mean of each half of a sine), max/min ≈ ±1, pulse duration ≈ 0.5 s with duty ≈ 0.5, period ≈ 1 s and frequency ≈ 1 Hz. It needs UDPSCOPE_ENABLE_ALL_MS ahead of it or it prints no acquired signal to measure and the dump reads n=0. Run it against --noise 0.1 --freq 1 as well, and the three crossing types must still read those figures (measured: period=1.000100 frequency=0.999900 pulse duration=0.504100): the hysteresis band is the only thing holding them there, a single level reads a 500 Hz interferer as the signal, and the default --noise 0 mock cannot see it. The band costs raw, not accuracy -- the clean fast mock reads raw=0.1% at 1.41 ms a recompute and the noisy one raw=2.0% at 1.96 ms, so read measure cost: on both. marks= lists each mark with its kind, and the mix is the point: level:V alone for average/top/bottom, level:V,time:T for min/max (a ruler at the value and a marker at the first sample that reached it — two level marks at both extremes was the bug), time:A,time:B for pulse/period/frequency. text= is the pane's own string with its unit, so frequency must read Hz (not the signal's unit), period/pulse duration seconds, and the rest the channel's unit. marks_shown counts two per shown measurement (a min/max showing both its marks counts two of its own). measures window: is the timeliness assertion -- the window the numbers were computed over against the one on screen. The reading that must hold is marks_in_window=K/K: every time mark inside the window on screen. Both ranges are on the stream's clock, which is the space measure_window and the time marks are stored in -- a live axis is a fixed [-window, 0], so comparing display x would read no drift whatever the data did. drift itself is a single sample of a sawtooth and is not an assertion on its own: a live view scrolls between recomputes, so it rises to one kMeasureRefreshUs of scroll and drops to zero at each pass. The figure is (|Δmin| + |Δmax|) / span, so a pure scroll counts twice and the top of the sawtooth is 2 × refresh / window: ~10 % on a 2 s window at the 100 ms cadence, and a few hundred percent on a window shorter than the interval (a 50 ms window reads 254 % for 63.6 ms of scroll, and its ceiling is 400 %). Compute the ceiling for the window the run actually used before calling a figure large. A 0.0000% is a dump that landed just after a pass, not a better state. Read it against the recompute count: more recomputes with a higher drift sample is the sawtooth, while a drift that exceeds one refresh interval of scroll, or marks outside the window, is the defect. A measurement is recomputed when the window moves (2 % of its span) or the held/live state changes, never on a wall-clock timer: a timer is what let a marker sit outside the viewport and a dragged one survive a frame. marks_selectable must be 0: the measurement marks are created unselectable (Plot::set_cursor_selectable(), which the library gained for this), read back out of the plot here -- re-placing a marker every frame does not stop a drag, because the controller holds it for the gesture's life and the marker follows the pointer between frames. The library's own controller test (an_unselectable_cursor_is_neither_hovered_nor_grabbed) is the other half. The probe's last step still drags a mark programmatically and requires the next frame to put it back (restored=1), which now checks the placement path rather than the read-only property. The dialog report's type= thr= side= show= is the per-type behaviour: period and frequency must show neither the split nor the side row (they compute from the mid level's rising crossings, and an option there is one the user can only get wrong), average/min/max no split either, and only pulse duration a side. A measurement whose signal is not acquired, or a window with too few crossings, must read invalid/-- rather than a made-up number (frequency on a window holding one edge is the case: duty is reported only when a period exists to divide by). The probe then walks the dialog's lifecycle: open the edit dialog on measurement 0, click its Save, open it again, click Delete — measures= must be unchanged after the save and one lower after the delete, with the process still alive. That is the check for the two things a dialog of this shape gets wrong: two buttons owning one context (a double free aborts on edit/delete, and only those paths have two buttons) and a const char* array built from a std::vector<std::string> as it grew (every pointer dangling, the first entry the most visibly). The report's signals=[..] and drop_h are the second and third: the list must be the plotted traces (real names, deduplicated, in signal-list order — not every channel, and never a stream's timestamp signal, which is what an acquired-but- unplotted list put first and auto-selected), and drop_h must be a combo box's height (tens of pixels — a scrolled-window wrapper asked it to grow to 160).

  • [timelabels-probe] <action> relative=D distinct=D window=.. origin=.. note=[..] ticks=[..] first_value=.. first_rel=.. range=[..] + the dump's time labels: relative=D distinct=D origin=.. note=[..] x_origin_set=.. ticks=[..] first_rel=.. (stderr, UDPSCOPE_PROBE_TIMELABELS_MS) — the x label form across a zoom, driven through apply_time_window(): under the figure's x link group the group owns the window, so a row-level set_time_window() does nothing (a probe that changes the window must use the app's path). distinct must be 1 on every line, and that is the assertion — it says no two major labels on the axis are the same string, which is the one thing the relative form exists to buy and the only reading that holds whatever the stream's uptime is. relative=D is expected to read 0 on every line now, and that is a consequence of the newest-relative axis rather than a change to the rule: the form is decided by the tick step against the label resolution at the axis' magnitude (see invariants-time-axis-and-window.md), 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), 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) pause=... cursors=... measures=... math=... sidebar=... menu=... (in the exit dump, beside panel controls:) — the icon name every toolbar button carries, with gtk_icon_theme_has_icon()'s verdict. Every entry must read (ok). Four of the names are this app's own (udpscope-trigger-symbolic and friends, a GResource compiled into the binary — see the toolbar-icon invariant in invariants-gtk-and-chrome.md), and a (MISSING) on one of those means the resource path was never registered. That failure is otherwise silent: GTK substitutes a missing-image placeholder, the button still works, and every other line of the dump still passes. Changing an icon name is a one-line change in build_toolbar() and this is the only check that it names something that exists.

  • app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16) (in the exit dump, beside toolbar icons:) — the application icon, named and present at the size its own root <svg> element declares. Both entries must read ok at those sizes. ok is gtk_icon_theme_has_icon() and the resource bytes being found under the registered path, so a MISSING is a resource path that was never registered or a file that was renamed, and a size other than 128 or 16 is a canvas that drifted off the HIG's. Read it together with toolbar icons:, and trust that line further for the registration: the resource-path registration is one call in run_ui() serving both the window's icon and the toolbar's buttons, so a MISSING on either line is the same defect — but on a machine where --target desktop-entry has been run, this line's has_icon half is also satisfied by the installed copy under $XDG_DATA_HOME/icons/hicolor, so a broken registration still reads ok here. That was measured: with the resource path deliberately misspelt, toolbar icons: reported four MISSING (nothing installs the action icons) while app icons: stayed ok until the two installed SVGs were moved aside, whereupon it read MISSING 128x128 / MISSING 16x16. The line is still worth its place — the present half (the resource bytes) and the declared sizes are read from the binary and nothing else can satisfy them — but a developer machine is the one where it is least sensitive. It deliberately does not claim the icon renders, because no runtime GTK call can: gtk_icon_theme_lookup_icon() never returns NULL, its paintable's intrinsic size is the size it was asked for, and a valid icon, a malformed one and a name that does not exist at all all snapshot to the same GSK_TEXTURE_NODE of the requested bounds — GTK substitutes image-missing. A malformed SVG is a build error instead; see the icon-authoring rules.

  • [sources] N known: HOST:PORT plots=P signals=S [multicast=G] [interface=I] | ... (stderr, UDPSCOPE_PROBE_SOURCES=1) — the startup chooser's discovery, printed and exited before gtk_init(), so this one needs no display and no xvfb-run. Only sources with a named signal may appear: a development machine accumulates one session file per probe run on a throwaway port (140 of them when this was written), and the filter in known_sources() is what keeps the list the sources a user configured. Order is newest first by file mtime.

  • [sources] picked HOST:PORT multicast=G interface=I (stderr, UDPSCOPE_PROBE_PICK_MS) — shows the chooser, then activates row 0 the way a click does and continues into the real application, so a full run with no --server must reach the scope and emit every other dump line unchanged. multicast=/interface= coming back non-empty from a stored row is the assertion that a multicast session reconnects to its group, and it needs no multicast networking: write the session file by hand and read the line. With UDPSCOPE_PROBE_NEWHOST=host:port it drives the form instead — [sources] form open, connect_sensitive=1 then a picked line, with no SIGSEGV, which is as much a crash test of a dialog built and destroyed as it is a state check. An empty list must print picked nothing: the list is empty rather than dereferencing a null row.

  • The session layout round trip (~/.config/udpscope/<host>_<port>.toml) — run once with a drop and a window change, then again with no interaction: the second run's dump must read layout loaded from ... and layout: rows=R window_s=W row0=[...] with the first run's values, and the signals it names must come back acquired (a row 0 slot 0 -> channel ... samples=N line). Delete the file before a run whose layout you did not mean to inherit (127.0.0.1_9540.toml and friends accumulate otherwise).

  • [udpscope] starved: connected (config=1), reconnects=N, no new frames in the last second ... (stderr, once per second while the state holds, plus a status-line notice) — "connected but nothing is arriving after a reconnect", which over unicast is the second instance taking the source's single session. Check it does not fire against a live source. The status line carries | N reconnects only when N > 0, which is the same counter without the prose. The same stall test has two outcomes, split on have_config, and they are different diagnoses. With a signal list in hand, the above. With no CONFIG at all it prints [udpscope] connecting: connected (config=0), reconnects=N, no configuration from the stream ... and the notice reads trying to connect — nothing was taken away, because with no signal list nothing can be enabled and no DATA frame is owed, and the old wording blamed a second reader that does not exist. That state is also named without waiting for the three-sample stall: the glance word is connecting and the badge is yellow from the first frame (see badge frames:). The way to see it with no second client is to point the app at a UDP port where nothing answers (the client CONNECTs, times out, reconnects), which is now the config=0 branch and no longer exercises the starved: one at all — a check of the unicast-contention wording needs a real second reader, or a source that configures and then goes quiet.

  • The export's time/abs_seconds columns must be strictly increasing and evenly spaced. Check the written file, not just the probe: np.diff(t) with no <= 0 and rows / (t[-1] - t[0]) equal to the stored rate (the ch line's figure). Duplicate or backwards stamps mean the recorded timeline holds two clocks or a collapsed cadence — the two ways this has broken: a per-frame choice of time source (now sticky, see the README) and a per-sample step derived from the client's lost counter (now the stream's measured arrival cadence). A rows= count far below the span times the rate is the other signature, that of a table cut from the LOD pyramid. The step must hold to a few ULP of the column's own magnitude, and this is the check that needs a long run at a high rate: the old form of this assertion was "set(np.diff(t)) holds exactly one value", and that was only true while time was the stored stamp. Both columns are derived now — time is stamp - stream_at_x0() and abs_seconds is that plus a base of a wholly different magnitude — so each subtraction costs a rounding and set() legitimately holds a handful of neighbouring doubles. Measured: four values spanning ~1.5 ULP, 6.8e-16 s. The thing that must not come back is the plateau: equal stamps, or a diff taking two or three values a few hundred nanoseconds apart, is the time ring losing precision with uptime (see the f64 time-ring invariant in invariants-data-path.md). 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) — 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), 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 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 read from the screen's side; it was over a hundred pixels at a deep zoom before the origin became the caller's. It is measured on the raw levels only — above them a gathered point is a bucket's envelope and has no single sample to be off from — so R is legitimately below N. back ≈ one ring span is the signature of the torn x0() read (see the cached-first-stamp invariant); so is an ink= range disjoint from axis=, or an idx= covering the whole ring with cover far over 100%. There was a kept_fixed_x0 field beside kept — the same filter with x0() hoisted out of the loop, since the renderer used to re-read x0() per point and a ring-backed one advances with the producer. The origin is a local of the renderer's now, so that failure mode is structurally impossible and the field is gone; the [ink] walk and the renderer still agree because both add axis.min. It is a race, so it needs load and a reason for long frames — tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --clock-jitter 0.4 with the measurements and cursors panes up: UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MEASURES_MS=2000 UDPSCOPE_PROBE_CURSORS_MS=3500 UDPSCOPE_EXIT_AFTER_MS=35000, which reported three staircase frames in each of three runs while the defect was live and none after. frame path:'s own numbers move with it (segments max 156078 → 19681), since a refused neighbours() gathers the whole ring.

  • traceless paints: N (M after the first bind, last at paint P) and worst side/pane min width: W px, plus [gen] frame F generation A -> B (bound=.. empty=..) and [width-probe] ... lines (stderr) — three cheap per-frame counters that came out of the flicker hunt and each guard a documented invariant from the side the existing lines cannot see. M must be only the frames in a state that draws nothing: a traceless paint is legitimate in DisplayMode::Waiting, which is why the line carries mode= — UDPSCOPE_EXIT_AFTER_MS arms the trigger before it dumps, so a tail of them at the end of a run is expected. [gen] names each buffer rebuild as it happens with the bind state around it: a rebuild drops every ring's history, so the frames after one legitimately draw an empty plot, and blank view frames cannot see that (it asks whether the ring holds samples the view has not got, and a rebuilt ring holds none either) — so an unexplained [gen] burst is a structural change firing when nothing structural happened. worst side/pane min width is the "layout must not depend on content" invariant read from the demand side: a GtkLabel's minimum width is its text and a minimum wider than the screen is honoured by growing the toplevel, which hands the plot an allocation wider than the window; axes.w sees that a frame later and cannot name the cause, so the panes the tick rewrites are measured directly. The [width-probe] lines are the per-change detail behind the axes.w changes count, and a step over 100 px dumps the widget tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read.

  • [help] pages=14 parsed=14 shortest='Pause and play' 1627 B; shortcuts: app=12 plot=14 tables=26 ui=26 ui_accel=19 ui_icon=7 ui_bare=0; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok (stderr, UDPSCOPE_PROBE_HELP_MS) — parsed must equal pages and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window looks fine and only this count notices. 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 three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by two different calls, so a mismatched pair crashes or leaks here. One of the three is a GtkWindow (the help window, gtk_window_close()); the About dialog and the shortcut overview are AdwDialogs and close with adw_dialog_close(), cleared on ::closed. shortest= is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.

  • [menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O, one line per row, then [menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B and [menu] status pos=top bubble=WxH button_top=T button_right=R fits=F (stderr, UDPSCOPE_PROBE_MENUS_MS) — the two popovers the toolbar and the status bar open, measured with the side panel hidden. rows_bad must be 0 and fits must be 1. What it asserts is what a screenshot can only show an eye: every row's label starts at the same x (label_x=26 for all seven — a menu whose checkable rows indent their text and whose others do not is exactly what "the text is not left-aligned" looks like), every shortcut ends at the row's own right edge (accel_right == W), no row is over kMenuRowMaxPx (30) tall — Adwaita's button minimum is 34, and seven of those is what made the popover read as a stack of dialogs until .udpscope-menu trimmed it — and the rows arrive in the order its kExpected names. [menu] status is the ⓘ bubble: pos must read top, which is set after gtk_menu_button_set_popover() because a GtkMenuButton states a position of its own and the last word wins, and button_top must exceed the bubble's height, because "below the button" is off the window's bottom edge — the bubble's top edge stayed at the button with its body clipped away, which reads as an arrow disconnected from its body. Run it alone: another probe that opens a window dismisses the popover, and every row then reports h=0. It turns autohide off for its own measurement — a popover dismisses itself when it loses the grab, and xvfb-run has no window manager to hand it one — and restores it afterwards.

  • [keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1 (stderr, UDPSCOPE_PROBE_KEYS_MS) — every X->Y pair must differ and refused must be 1. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a GtkEditable has focus makes the window entry unusable. A pair reading 0->0 is an action whose dispatch does not reach the handler a click reaches. Note the format is <key>: <widget-description>: <before>-><after> — the name passed to before_after() includes the widget's description, so t: trigger panel: 0->1 is the correct form (not t: trigger panel 0->1 without the colon before the values).

  • [color] '<name>' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1), then [color] override: trace=#123456 chip_agrees=1, [color] session: entries=1 rgb=#123456 and [color] default: trace=#RRGGBB chip_agrees=1 (stderr, UDPSCOPE_PROBE_COLOR_MS) — all three chip_agrees must be 1, the override line's trace must be #123456, the session line must read entries=1 rgb=#123456, and the default line's trace must be back to the first line's. Run it with UDPSCOPE_ENABLE_ALL_MS ahead of it, or there is no bound trace and the probe prints no bound trace to recolour instead of asserting anything; on the standard mock the first line reads 'sine' at row 0 slot 0: trace=#4FA3FF. Two things make this an assertion rather than a tautology. The colours are read out of plot::Plot::trace().color — what the renderer will stroke, reached through Row::plot after relayout_areas(), since a new colour is a new figure — and out of the chip's own <span foreground="…"> markup, not by asking RowSet::signal_color() twice, which would pass against a chip that never reached the trace. And the override is #123456, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips collect_layout() → save_layout() → load_layout() on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit.

  • [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. 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
[configs] after forget names=default
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] 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] waveforms: 1 recent /tmp/rt.npy(ok) page=1
[sources] picked waveform /tmp/rt.npy
[udpscope] waveform loaded: /tmp/rt.npy signals=4 samples=20001 span=2.000000s rate=10000 time=[-2.000000..0.000000]
field value it must read the defect a wrong reading names
waveforms: one entry per remembered export, with (ok) or (missing), and page=1 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=, samples= the export's cols= and rows= a reader mis-slicing its records, or the columns bound to the wrong channels
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

[drop-probe]

[drop-probe] signals=4 channels=4 rows=4 slots0=[sine|||]
[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

[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] dir=/tmp/k5/cache/udpscope/events/127.0.0.1-21023 n=1 newest: dur=2.000000 pre=1.000000 post=1.000000 edge=1.000100 signals=4 samples=20001 rate=10000 seq=1 thumb_bytes=230454 recorded=1 pane=1
[events] activated row0: replaying=1 pane=1 pane_w=240 tick=0 window=2.000 thumbnail=1 thumb_size=126x42 thumb_visible=1
[events] replayed: channels=4 samples=20001 replaying=1 waveform=1
[events] live again: replaying=0 waveform=0
[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

thumb_size is the built size of the row's picture (96x32 logical, plus the CSS border), so it moves with the font and the display's scale factor; what it must never be is 0x0.

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*160*3) a preview that is empty or missing -- the widget snapshot of a GtkGLArea, which is NULL
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
replaying (last line) 0 a replay with no way back: Live must start a fresh client and thread
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.

[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.

[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.