The toolbar button was the panel's only input: `update_side_visibility()` derived the panel from the button and never the other way, so the button went on saying "shown" in two cases where the panel was off -- while there were no signals to show, and after anything else moved the split view's state. Binding the two together is not the fix. AdwOverlaySplitView writes `show-sidebar` itself, at startup and when it collapses, with no user involved; a binding pushes that into the button, the button's handler reads it as the user's wish, and the sidebar then never comes back when a source connects -- a worse bug than the one reported, and one that a "was this our own write" flag does not catch either, because the toggle's signal may fire after the write returns. So the direction is stated instead of inferred: `show-sidebar` is an output, the button is the only input, and `App::sidebar_wanted` holds the wish between them. The wish is written by the toggle's handler alone (guarded while we set the button ourselves), the no-signals case forces the panel off without touching the wish, and tick() re-asserts both from the wish when the widget disagrees -- so a hide gesture does not stick either. [sidebar-probe] now reads the pair (shown vs button) in both states and against a hide that did not come from the button; shown=1 button=1 with a source, shown=0 button=0 without, and the source reconnecting brings both back is the reading this exists for.
1146 lines
97 KiB
Markdown
1146 lines
97 KiB
Markdown
# Probe readings: what each line asserts
|
||
|
||
The per-line reference for the self-check's output — what every probe and dump line means, the value it must read, and the defect a wrong reading names.
|
||
|
||
Part of [CLAUDE.md](../../CLAUDE.md).
|
||
|
||
Two lines of the self-check output are assertions in disguise:
|
||
|
||
- `layout over N frames: axes.x A..B, ...` — **`viewport.w` must collapse to a single
|
||
value and `axes.x`, `axes.w` and `y-gutter` must *settle*.** `note_y_gutter()` is
|
||
monotone (`plot_host.cpp`), its one-shot widest-label reservation can land a frame or
|
||
two after the first measured frame, and the figure serves its floor out of the label
|
||
strip (`y_width = max(measured, floor - y_title_w)`, `plot.cpp`) so the reservation
|
||
converges over two layouts rather than one. A `y-gutter 88..104` dragging
|
||
`axes.x 88..104`/`axes.w 707..782` with it is that settle, not a twitch — confirm it
|
||
with the `changes` line below. A range that keeps moving is the "twitching axis" bug:
|
||
the plot being re-measured every frame. **A range that keeps *climbing* to 200 is the
|
||
other one**: `note_y_gutter()` is handed the gutter the row was laid out with, which
|
||
already includes the floor imposed last frame, so slack added to it is added again every
|
||
frame and the gutter ratchets to its own 200 px cap — `axes.x 88..200 / y-gutter 88..200
|
||
/ axes.w changes: 17` on an 870 px viewport is what that reads as, and 17 changes looks
|
||
like a settle. The reservation's own padding is the only slack there may be.
|
||
`axes.w` also no longer reaches the viewport's right edge: `apply_gutters()` reserves an
|
||
empty band there (~59 px of 870), so `viewport.w - axes.x - axes.w` is a *constant*, not
|
||
a zero. **`x-desync` must read 0**: it counts frames
|
||
in which any row's x range differed from row 0's by more than a pixel of data, i.e.
|
||
frames the x link group did not reach. `x-ticks` is content, not layout, and
|
||
legitimately varies by one as a live
|
||
window slides over a round number. The companion `axes.w changes: N (last at frame F,
|
||
side panel up=S)` line separates the two readings: `0`, or a handful near the start,
|
||
is a settled layout; a count near the frame count is the twitch. Runs far longer than
|
||
the documented probes have shown one ~320 px step — the side panel's width — that
|
||
reverts; it is intermittent and unexplained, so read `changes` before calling a range
|
||
the twitch bug.
|
||
- `[trig-probe] ... trigger=on ... display=... epoch=...` (stderr,
|
||
`UDPSCOPE_TRIG_TRACE_MS`) — with `trigger=on`, `display` must read `event(frozen)`,
|
||
`live(capturing)` or `nothing`, **never plain `live`**, except for the single interval
|
||
that contains the toggle itself — recognise it by `latches=0` *and* the next probe
|
||
reading `nothing` or `live(capturing)` with a frozen `axis=`. Plain `live` with the
|
||
trigger on is the old "the plot keeps scrolling in trigger mode" defect; the named
|
||
`live(capturing)` is the feature. In that state `epoch=` must equal minus the capture's
|
||
edge and `axis=` must stay at `[-pre..+post]` across consecutive probes while
|
||
`data=`'s upper bound climbs towards `+post` — an axis that moves instead means the
|
||
capture is being treated as a live view (`clamp_to_data()` reaching a pinned window).
|
||
`epoch=` is **minus the newest acquired stamp** in `live` and `nothing` — it is what makes the
|
||
live axis `[-window, 0]`, so there it grows (in magnitude) with the stream and a `0.0000` is
|
||
the defect, not the healthy reading. It is back to `0.0000` only in `event(frozen)`, where the
|
||
snapshot carries its own origin (`EventSnapshot::x_offset`) and the display epoch has nothing
|
||
left to do. Note the probe reads
|
||
`pending=` fresher than `display=` (which is last tick's decision), so a single
|
||
`pending=0 display=live(capturing)` line is one frame of sampling skew, not a defect.
|
||
And `snap=[a..b]` must equal `axis=[a..b]`, with `b` positive by roughly
|
||
the post-trigger share: that is the "post window is empty" defect's observable.
|
||
**`drawn=[a..b]` must equal `data=[a..b]`** in both modes: `data` is `x_at()` (what
|
||
the crosshair reads) and `drawn` is `x0() + gather()` (what the renderer maps), so a
|
||
series that breaks the "gather emits x relative to `x0()`" contract shows up here as
|
||
the two disagreeing by exactly `x0()` — a trace drawn a whole pre-trigger window left
|
||
of its axis, invisible once the view is narrower than the shift, while the crosshair
|
||
keeps reporting correct values.
|
||
- `[trig-probe/idx] n=.. bounds=[..] dx=.. dx_real=.. dx_ratio=R got=[a..b) want=[c..d)`
|
||
(stderr, same probe) — **`got` must contain `want`** (`a <= c` and `b >= d`), and
|
||
`dx_ratio` must stay within about a percent of `1.0`. `got` is the index window
|
||
`Renderer::draw_traces` gathers *today* — `neighbours()`, one sample of slack either side —
|
||
and `want` is the same window from the same call, so read this line for the **rate**:
|
||
`dx_ratio` is the nominal `dx()` against the real last-spacing, and it is the number that
|
||
used to matter. It above 1 (every index scaled up, `a` past the first visible sample, the
|
||
samples in between in the buffer and readable by the crosshair but never drawn) was the
|
||
live view whose older part went blank while its newest part looked fine. That is a *fixed*
|
||
defect: the renderer no longer walks the grid at all, because on a ring-backed series it
|
||
also lost the two end segments and left the left and right margins of a zoomed-in view
|
||
empty. The guard for it is the library's own pixel test
|
||
(`a_sparse_window_between_samples_still_inks_both_margins`), not this line — a probe that
|
||
recomputes the renderer's formula cannot see a renderer that changed it. Also
|
||
read `axis=` in the companion line against the `window control:` line in the dump —
|
||
a live axis narrower than the requested window is the same symptom from the other
|
||
cause (see the `clamp_to_data()` invariant in
|
||
[invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)). Both need a *saturated* ring to
|
||
show up, so run them under `--buffer-mb 1`, which wraps in a few seconds.
|
||
- `trigger: edge at X ... want [-a..b] got [-c..d] n=N` (stderr) — `got` must equal
|
||
`want` and `N` must be exactly the window times the stream's real rate, plus one
|
||
(2 s at 10 kS/s is `n=20001`, not 19999). `got` shorter than `want` on the *left*
|
||
means the edge was accepted before the history reached back a full pre window (the
|
||
gate in `TriggerEngine::process()`); shorter on the *right* means the engine latched
|
||
early or the cut lost the newest samples. An `N` one or two short of the exact count
|
||
is the same class of bug, not rounding: `publish_event()` clamps the slice to every
|
||
value ring's `produced()`, so publishing before each channel of the latching sample
|
||
has been pushed silently drops it.
|
||
- `[disarm-probe] was_capturing=1 display=.. seq=A->B events=C->D snap=[a..b] n=N
|
||
axis=[..]` (stderr, `UDPSCOPE_PROBE_DISARM_MS`) — with `was_capturing=1`, **`display`
|
||
must read `event(frozen)`** and `snap`/`axis` must be `[-pre..e]` where `e` is how far
|
||
the post window had filled, with `n` that span times the stream's real rate plus one.
|
||
`display=nothing` (or a `snap` from the *previous* event) is the "disarming mid-capture
|
||
makes the event completely disappear" defect. **`seq` must advance by exactly one while
|
||
`events` does not move**: no event fired, so the partial publish deliberately leaves
|
||
`trigger_latches()` alone — which is also what keeps the `trigger events: N total, M at
|
||
the disarm` line in the dump meaning what it says.
|
||
- `[trigrenum-probe] window=W event_span=S pre_pct=P post=O holdoff=H level=L edge=E mode=M
|
||
enabled=1 armed=1 ch=C channels=N`, then `capture open: t0=T ch=C -- enabling signal 1 now`,
|
||
`after enable: trigger ch=C' channels=N'`, and `latched t0=T' opened on T t0_held=D` (stderr,
|
||
`UDPSCOPE_PROBE_TRIGRENUM_MS`) — the first line is the geometry the real widgets produced
|
||
(`window=1.0000 event_span=1.0000 pre_pct=10.0 post=0.9000` for the reported setup); it is
|
||
context, not the assertion. The third must show the index **actually moving** (`ch=0` →
|
||
`ch=1`), or the probe tested nothing — the sine has to be a channel that sorts before the
|
||
square. **`t0_held=1` is the assertion**: the capture that opened on `T` latched on `T`, so the
|
||
renumber neither abandoned it nor let a later edge inside the post window become t0. With the
|
||
engine's invalidation keyed on the channel's index instead of its name this read `t0_held=0`,
|
||
with `pending` back to 0 and `holdoff_until` *behind* `now_t` on the engine line — i.e. no
|
||
hold-off was set at all, which is the whole defect. The `[udpscope] trigger: edge at ... got
|
||
[--0.0199..0.9000]` beside it is expected: the rebuild replaces the `BufferStore`, so the
|
||
surviving capture has no pre side left and reports a negative `pre_s`.
|
||
- `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] abs=[..]` (stderr,
|
||
`UDPSCOPE_EXPORT_MS`) — **`time=` must equal `axis=`** and **`rows=` must be the
|
||
span times the stream's real rate, plus one** (2 s at 10 kS/s is 20001). The export
|
||
is the *visible* range at the *stored* rate, so a count far short of that is a table
|
||
cut from the LOD pyramid, and a `time=` narrower than `axis=` is a window searched on
|
||
the wrong clock (the live branch puts the window back on the stream's clock with
|
||
`stream_at_x0()` before searching the ring, and that offset is a whole window while
|
||
streaming — not the 0 it once was).
|
||
**`abs - time` must be a single constant across both ends of a line**, and that constant is
|
||
where the display's origin sits in the stream's own clock: `epoch()` plus the newest stamp in
|
||
`mode=live`, `epoch()` plus the accepted edge in `mode=event`. That is the one assertion on
|
||
the second column, and it replaces the old "`stream=` must be the edge ± the window" reading,
|
||
which only ever held because the display clock *was* the stream clock. A `mode=live` line
|
||
whose `abs - time` differs between the two ends is the two columns computed from different
|
||
epochs — the defect `abs_base` exists as a single `const` to prevent.
|
||
An export taken **mid-capture** reports `mode=live` with `time=[-pre..e]` where `e` is how far
|
||
the post window has filled — the export is what is on screen, and during a capture that is a
|
||
partly filled window. Pin the probe outside a capture when checking the `rows=` count.
|
||
The two `bytes=` lines are the writers: the `.npy` is
|
||
`64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a
|
||
mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing
|
||
either writer.
|
||
- `[window-probe] <action> label=.. entry=.. unit=U window=W event_span=E cap=C decim=M
|
||
stored=R aa=F buffered=B` (stderr, `UDPSCOPE_PROBE_WINDOW_MS`) — **`window` must equal
|
||
`event_span`** on every line (the trigger's event span is the display window; a capture
|
||
cut to a different length than the axis frames is the "post window is empty" defect's
|
||
cousin) and **neither may exceed `cap`**. The `unit ms->s` line is the one that catches
|
||
the feedback loop: changing the unit re-interprets the typed number, so "500" in ms
|
||
becomes 500 **s exactly**, and a `window=499.98` there means the control re-parsed its own
|
||
rounded readout (`8.333 min × 60`) instead of the request — see the `window_sync_unit`
|
||
invariant in [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md). `label` is a reading to three decimals in a natural unit and is
|
||
*expected* to be lossy (500 s reads `8.333 min`); it is the `window=` figure that has to
|
||
be exact.
|
||
For the resampling: **`buffered` must be ≥ `window`** on every line — that is what the
|
||
decimation is for, and a `buffered` short of the window is the old "the control is capped
|
||
to what the ring holds" behaviour coming back. `decim` must be a power of two,
|
||
`stored × decim` must equal the wire rate, and `aa` must be exactly `stored / 2`. Run it
|
||
under `--buffer-mb 1`, which saturates in a few seconds and makes windows past ~6 s
|
||
decimate; at 200 MB nothing inside `--max-window` needs to.
|
||
- **The anti-alias check** is the same probe run against a waveform above the stored
|
||
Nyquist: `--buffer-mb 1 --freq 2000` with the probe ending on a 60 s window gives
|
||
`decim=16`, a 625 S/s stored rate and a 312.5 Hz corner, and the channel dump's
|
||
`ch 0 'sine' ... y=[-0.059, 0.079]` is the assertion — a boxcar of 16 at 10 kS/s passes
|
||
2 kHz at about 6%. **A `y=[-1, 1]` there means the filter is gone** and the 2 kHz sine has
|
||
aliased to a full-amplitude 125 Hz one, which looks entirely plausible on screen. This is
|
||
the only check that distinguishes averaging from striding.
|
||
- `[cursors-probe] gestureless plot move with no gesture: stored A -> B ignored=D` — a
|
||
plot-level `set_cursor_x()` with **no gesture behind it must be ignored** (`ignored=1`, and
|
||
`A == B`): the tick reads a row only while its controller holds a cursor, and this line is the
|
||
guard for the defect where it adopted its own echo and a dragged cursor sprang back. (The
|
||
*adoption* half -- a gesture that does move a cursor, and the shared value reaching every row
|
||
-- is asserted in the `check_host` harness, which can call `sync_cursors(row)` directly; a
|
||
synthetic press here cannot reach the adapter's own event pump.)
|
||
- `[cursors-probe] <action> mode=.. on=D pane=D t0=.. t1=.. dt=.. T=[..] lines=N [..]`
|
||
(stderr, `UDPSCOPE_PROBE_CURSORS_MS`) — walks `vertical` → `horizontal` → `both` →
|
||
`mix`, then moves a marker *through the plot* (`Plot::set_cursor_x(..., Free)`, which is
|
||
what a drag does) and reports again. **The times must survive every mode change**: they
|
||
are the `RowSet`'s one pair, and a mode that parks the markers must not read them back
|
||
(a parked cursor reports a real number, so a read-back would adopt it as a gesture — the
|
||
bug that made `T0` read 0). **`lines`** must be 0 for `vertical`, one per row for
|
||
`horizontal`/`both` (and the time row exists only in the modes that show the vertical
|
||
pair — `T=[]` in `horizontal` is the assertion that it does not), and one per **bound**
|
||
trace for `mix` — a slot with a name *and* a channel the model built, never a name alone
|
||
(see the bound-slot invariant in
|
||
[invariants-plot-and-layout.md](invariants-plot-and-layout.md); `lines=6 marks=3` over three real traces is that
|
||
defect). Each line reports as `<name>=<A> <B> <dY>`, so the names are the reading: a row
|
||
claiming a signal that is not on a plot is the phantom. In `mix` the values must be the
|
||
traces' own (`--` when a marker is past the newest sample, `partial` when only one
|
||
marker is over samples). After the move, `t0` must have changed and every row's marker
|
||
must have followed it (the report's `t0` is the shared value). **`marks`** is the `mix`
|
||
mode's crossing annotations: **0 in every other mode, and one per bound trace for each
|
||
marker that is over samples in `mix`** (so a move that brings the second marker into the
|
||
data doubles it). The `first mark at x=.. y=..` line that follows must agree with the
|
||
pane's *first* value for the same trace (the pane's lines are `chip name Y1 Y2 dY` --
|
||
the chip carries the signal's name, since a chip alone leaves the user to match a colour
|
||
against the plot by eye -- with the values formatted as measurements, two decimals, an SI
|
||
prefix, the channel's unit, so the comparison is against the first number): both are read through the same
|
||
`Plot::crosshair_value()`, and that line is the check that they stay the same number.
|
||
A **ruler** drag is the other direction and the other hazard: the pane's `A`/`B` cells
|
||
for a ruler row must change when the ruler moves (`after ruler drag` in the probe walks
|
||
the first ruler to 80% of its y range), because nothing shares a ruler and the read-back
|
||
in `RowSet::sync_cursors()` is the only thing that can notice. The dump's `cursors in plot
|
||
0:` line is the other half: it reads the markers back out of
|
||
the plot (`cursor(id).x/.visible`), which is what the renderer draws from — the pane's
|
||
numbers and the plot's state agreeing is the difference between "the app placed them" and
|
||
"the app placed them and they are on screen".
|
||
- `[measures-probe]` + the dump's `measure N: 'sig' type thr=..% above=D show=D -> ok|invalid value text=[..]
|
||
[duty=..] [marks=..]` and `measures pane: n=N revealed=D marks_shown=M pane_rows=R pane_w=W
|
||
pane_h=H`
|
||
(stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first
|
||
plottable signal and lets the tick compute them, so what it prints is what a user reads.
|
||
On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.64 and
|
||
`bottom` ≈ -0.64 (the mean of each half of a sine), max/min ≈ ±1, `pulse duration` ≈ 0.5 s
|
||
with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. It needs **`UDPSCOPE_ENABLE_ALL_MS`
|
||
ahead of it** or it prints `no acquired signal to measure` and the dump reads `n=0`.
|
||
**Run it against `--noise 0.1 --freq 1` as well, and the three crossing types must still read
|
||
those figures** (measured: `period=1.000100 frequency=0.999900 pulse duration=0.504100`): the
|
||
hysteresis band is the only thing holding them there, a single level reads a 500 Hz interferer as
|
||
the signal, and the default `--noise 0` mock cannot see it. The band costs `raw`, not accuracy --
|
||
the clean fast mock reads `raw=0.1%` at 1.41 ms a recompute and the noisy one `raw=2.0%` at 1.96
|
||
ms, so read `measure cost:` on **both**. `marks=` lists each mark with its kind, and the **mix is the point**:
|
||
`level:V` alone for average/top/bottom, `level:V,time:T` for min/max (a ruler at the value
|
||
*and* a marker at the first sample that reached it — two level marks at both extremes was
|
||
the bug), `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with
|
||
its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration`
|
||
seconds, and the rest the channel's unit. `marks_shown` counts two per shown measurement
|
||
(a min/max showing both its marks counts two of its own). `measures window:` is the
|
||
timeliness assertion -- the window the numbers were computed over against the one on screen.
|
||
**The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window
|
||
on screen. Both ranges are on the **stream's** clock, which is the space `measure_window` and
|
||
the time marks are stored in -- a live axis is a fixed `[-window, 0]`, so comparing display x
|
||
would read no drift whatever the data did.
|
||
`drift` itself is a **single sample of a sawtooth** and is not an assertion on its
|
||
own: a live view scrolls between recomputes, so it rises to one `kMeasureRefreshUs` of scroll
|
||
and drops to zero at each pass. The figure is `(|Δmin| + |Δmax|) / span`, so a pure scroll
|
||
counts **twice** and the top of the sawtooth is `2 × refresh / window`: ~10 % on a 2 s window
|
||
at the 100 ms cadence, and a few *hundred* percent on a window shorter than the interval (a
|
||
50 ms window reads 254 % for 63.6 ms of scroll, and its ceiling is 400 %). Compute the ceiling
|
||
for the window the run actually used before calling a figure large. A `0.0000%`
|
||
is a dump that landed just after a pass, not a better state. Read it against the recompute count:
|
||
*more* recomputes with a *higher* drift sample is the sawtooth, while a drift that exceeds one
|
||
refresh interval of scroll, or marks outside the window, is the defect. A measurement is
|
||
recomputed when the window moves (2 % of its span) or the held/live state changes, never on a
|
||
wall-clock timer: a timer is what let a marker sit outside the viewport and a dragged one
|
||
survive a frame. **`marks_selectable` must be 0**: the
|
||
measurement marks are created unselectable (`Plot::set_cursor_selectable()`, which the
|
||
library gained for this), read back out of the plot here -- re-placing a marker every frame
|
||
does **not** stop a drag, because the controller holds it for the gesture's life and the
|
||
marker follows the pointer between frames. The library's own controller test
|
||
(`an_unselectable_cursor_is_neither_hovered_nor_grabbed`) is the other half. The probe's last
|
||
step still drags a mark *programmatically* and requires the next frame to put it back
|
||
(`restored=1`), which now checks the placement path rather than the read-only property. The dialog report's `type= thr=
|
||
side= show=` is the per-type behaviour: `period` and `frequency` must show **neither** the
|
||
split nor the side row (they compute from the mid level's rising crossings, and an option
|
||
there is one the user can only get wrong), `average`/`min`/`max` no split either, and only
|
||
`pulse duration` a side. A measurement whose signal is not acquired, or
|
||
a window with too few crossings, must read `invalid`/`--` rather than a made-up number
|
||
(`frequency` on a window holding one edge is the case: `duty` is reported only when a
|
||
period exists to divide by). The probe then **walks the dialog's lifecycle**: open the edit
|
||
dialog on measurement 0, click its `Save`, open it again, click `Delete` — `measures=` must
|
||
be unchanged after the save and one lower after the delete, with the process still alive.
|
||
That is the check for the two things a dialog of this shape gets wrong: two buttons owning
|
||
one context (a double free aborts on edit/delete, and only those paths have two buttons) and
|
||
a `const char*` array built from a `std::vector<std::string>` as it grew (every pointer
|
||
dangling, the first entry the most visibly). The report's `signals=[..]` and `drop_h` are the second and third:
|
||
the list must be the **plotted traces** (real names, deduplicated, in signal-list order —
|
||
*not* every channel, and never a stream's timestamp signal, which is what an acquired-but-
|
||
unplotted list put first and auto-selected), and `drop_h` must be a combo box's height
|
||
(tens of pixels — a scrolled-window wrapper asked it to grow to 160).
|
||
- `[timelabels-probe] <action> relative=D distinct=D window=.. origin=.. note=[..] ticks=[..]
|
||
first_value=.. first_rel=.. range=[..]` + the dump's `time labels: relative=D distinct=D
|
||
origin=.. note=[..] x_origin_set=.. ticks=[..] first_rel=..` (stderr,
|
||
`UDPSCOPE_PROBE_TIMELABELS_MS`) — the x label form across a zoom, driven through
|
||
`apply_time_window()`: **under the figure's x link group the group owns the window, so a
|
||
row-level `set_time_window()` does nothing** (a probe that changes the window must use the
|
||
app's path). **`distinct` must be 1 on every line, and that is the assertion** — it says no two
|
||
major labels on the axis are the same string, which is the one thing the relative form exists to
|
||
buy and the only reading that holds whatever the stream's uptime is. `relative=D` is **expected
|
||
to read 0 on every line now**, and that is a consequence of the newest-relative axis rather than
|
||
a change to the rule: the form is decided by the tick step against the label resolution at the
|
||
axis' **magnitude** (see
|
||
[invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)), and no display mode carries a large magnitude any
|
||
more — live and waiting are `[-window, 0]`, a capture and a held event are edge-relative, a pause
|
||
is freeze-relative. The 50 ms window that used to read absolute at 2 s of uptime (`2.13s, 2.14s,
|
||
2.15s`) and relative at 17 s (`100µs, 10.1ms, 20.1ms`, with the caption) now reads
|
||
`-40ms, -30ms, -20ms, -10ms` at any uptime. The rule and the caption are kept because they are
|
||
the answer for *any* axis magnitude and nothing in them is mode-specific — do not delete them on
|
||
the strength of this reading — but what the probe asserts is `distinct`, not the form. The
|
||
**caption must be non-empty exactly when `relative=1`**, which here means empty throughout. Run it after
|
||
`UDPSCOPE_ARM_TRIGGER_MS` as well, which is the case the span rule got wrong: a triggered 50 ms
|
||
window must read `relative=0 note=[] ticks=[-20ms,-10ms,0,10ms]` — a `t0` caption on an axis
|
||
that is already edge-relative re-anchors the ticks away from the trigger, which is the one
|
||
position the axis exists to show. The probe *races the paint* — it reads the axis on a timer,
|
||
and the ticks it sees are the previous layout's — so its `first_rel` (the first major minus
|
||
the axis' origin) is a fraction of a step; the dump runs after the last paint and is the
|
||
race-free reading. Either way the assertion that matters is the *sum*: `first_value` must
|
||
equal `origin + first_rel` as a *tick label* plus its caption, i.e. the labels and the
|
||
caption must share one anchor. `x_origin_set` is read back out of the plot; it must be 0 in
|
||
the absolute form. Note the last two steps both report `window=0.05`: `RowSet::set_time_window()`
|
||
floors the window there, so the 20 ms step is clamped.
|
||
- `[fit-probe] <action> pinned=D window=.. event_span=.. x=[..] span=.. y=[..]` (stderr,
|
||
`UDPSCOPE_PROBE_FIT_MS`) — the two fits, through the **menu handlers** rather than the bare
|
||
`RowSet` calls, because the notice and the control are the handler's half. A fit is the **undo
|
||
of an x zoom**, so the probe performs one: it moves the y scale off the data (a fixed
|
||
`[-100, 100]`), sets a 2 s window through the control's own path, then zooms row 0 to a quarter
|
||
of it the way a gesture does — on the *member* axis — and fits. Four assertions.
|
||
**`window` and `event_span` must be unchanged on every line** and equal to the typed 2 s: a fit
|
||
is a plot interaction and may not restate the time scale (see
|
||
[invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)), and they are
|
||
the only two readings that can see it try. The `zoomed` line's `span` must be a quarter of the
|
||
window, or the fits have nothing to undo and the lines below mean nothing. **`fit x` must put
|
||
`span` back to the whole window and leave `y` exactly as it was**, and **`fit all` must do the
|
||
same and move `y` to the data's own range** — a `y` that changed on the `fit x` line is the two
|
||
paths aliased. `x`'s *position* advances between lines on a live view and is not an assertion:
|
||
the follow is re-armed on the newest data, which is what the whole window means there.
|
||
Run it **inside a pause** as well (`UDPSCOPE_PROBE_PAUSE_MS=2000 UDPSCOPE_PROBE_FIT_MS=2200`),
|
||
which is the other branch and the one that can silently revert: `pinned=1`, and `fit x` must
|
||
restore the *pin's* full range — the snapshot's own stamps, legitimately narrower than the
|
||
requested window (`span=1.179900` against `window=2.000000` is correct, not a finding) — and
|
||
must still read `pinned=1` afterwards. The pin is restored by writing the **members** as well as
|
||
the group, because `hold_pinned_window()` prefers a member that has moved since `pinned_stated_`
|
||
— that is how it tells a gesture from its own echo — so a fit that wrote the group alone reads
|
||
back as the zoomed range one frame later.
|
||
- `[layout-probe] <action> rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]`
|
||
`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](invariants-gtk-and-chrome.md)), and a `(MISSING)` on one of
|
||
those means the resource path was never registered. That failure is otherwise *silent*: GTK
|
||
substitutes a missing-image placeholder, the button still works, and every other line of the
|
||
dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this
|
||
is the only check that it names something that exists.
|
||
- `app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)` (in the exit dump,
|
||
beside `toolbar icons:`) — the application icon, named and present at the size its own root
|
||
`<svg>` element declares. **Both entries must read `ok` at those sizes.** `ok` is
|
||
`gtk_icon_theme_has_icon()` *and* the resource bytes being found under the registered path, so a
|
||
`MISSING` is a resource path that was never registered or a file that was renamed, and a size
|
||
other than 128 or 16 is a canvas that drifted off the HIG's. **Read it together with
|
||
`toolbar icons:`, and trust that line further for the registration**: the resource-path
|
||
registration is one call in `run_ui()` serving both the window's icon and the toolbar's buttons,
|
||
so a `MISSING` on either line is the same defect — but on a machine where
|
||
`--target desktop-entry` has been run, *this* line's `has_icon` half is also satisfied by the
|
||
**installed** copy under `$XDG_DATA_HOME/icons/hicolor`, so a broken registration still reads
|
||
`ok` here. That was measured: with the resource path deliberately misspelt, `toolbar icons:`
|
||
reported four `MISSING` (nothing installs the action icons) while `app icons:` stayed `ok`
|
||
until the two installed SVGs were moved aside, whereupon it read `MISSING 128x128` /
|
||
`MISSING 16x16`. The line is still worth its place — the *present* half (the resource bytes) and
|
||
the declared sizes are read from the binary and nothing else can satisfy them — but a developer
|
||
machine is the one where it is least sensitive.
|
||
**It deliberately does not claim the icon renders**, because no runtime GTK call can:
|
||
`gtk_icon_theme_lookup_icon()` never returns `NULL`, its paintable's intrinsic size is the size
|
||
it was *asked* for, and a valid icon, a malformed one and a name that does not exist at all all
|
||
snapshot to the same `GSK_TEXTURE_NODE` of the requested bounds — GTK substitutes
|
||
`image-missing`. A malformed SVG is a *build* error instead; see the icon-authoring rules.
|
||
- `[sources] N known: HOST:PORT plots=P signals=S [multicast=G] [interface=I] | ...` (stderr,
|
||
`UDPSCOPE_PROBE_SOURCES=1`) — the startup chooser's discovery, printed and exited **before
|
||
`gtk_init()`**, so this one needs no display and no `xvfb-run`. **Only sources with a named
|
||
signal may appear**: a development machine accumulates one session file per probe run on a
|
||
throwaway port (140 of them when this was written), and the filter in `known_sources()` is
|
||
what keeps the list the sources a user configured. Order is newest first by file mtime.
|
||
- `[sources] picked HOST:PORT multicast=G interface=I` (stderr, `UDPSCOPE_PROBE_PICK_MS`) —
|
||
shows the chooser, then activates row 0 the way a click does and continues into the real
|
||
application, so a full run with **no `--server`** must reach the scope and emit every other
|
||
dump line unchanged. `multicast=`/`interface=` coming back non-empty from a stored row is the
|
||
assertion that a multicast session reconnects to its group, and it needs **no multicast
|
||
networking**: write the session file by hand and read the line. With
|
||
`UDPSCOPE_PROBE_NEWHOST=host:port` it drives the form instead — `[sources] form open,
|
||
connect_sensitive=1` then a `picked` line, with no SIGSEGV, which is as much a crash test of a
|
||
dialog built and destroyed as it is a state check. An empty list must print `picked nothing:
|
||
the list is empty` rather than dereferencing a null row.
|
||
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once
|
||
with a drop and a window change, then again with no interaction: the second run's dump
|
||
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
|
||
first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0
|
||
-> channel ... samples=N` line). Delete the file before a run whose layout you did not
|
||
mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise).
|
||
- `[udpscope] starved: connected (config=1), reconnects=N, no new frames in the last
|
||
second ...` (stderr, once per second while the state holds, plus a status-line notice) —
|
||
"connected but nothing is arriving after a reconnect", which over unicast is the second
|
||
instance taking the source's single session. Check it does **not** fire against a live
|
||
source. The status line carries ` | N reconnects` only when N > 0, which is the same
|
||
counter without the prose.
|
||
**The same stall test has two outcomes, split on `have_config`, and they are different
|
||
diagnoses.** With a signal list in hand, the above. With **no CONFIG at all** it prints
|
||
`[udpscope] connecting: connected (config=0), reconnects=N, no configuration from the
|
||
stream ...` and the notice reads *trying to connect* — nothing was taken away, because
|
||
with no signal list nothing can be enabled and no DATA frame is owed, and the old wording
|
||
blamed a second reader that does not exist. That state is also named *without* waiting for
|
||
the three-sample stall: the glance word is `connecting` and the badge is **yellow** from the
|
||
first frame (see `badge frames:`). The way to see it with no second client is to point the
|
||
app at a UDP port where nothing answers (the client CONNECTs, times out, reconnects), which
|
||
is now the `config=0` branch and no longer exercises the `starved:` one at all — a check of
|
||
the unicast-contention wording needs a real second reader, or a source that configures and
|
||
then goes quiet.
|
||
- **The export's `time`/`abs_seconds` columns must be strictly increasing and evenly
|
||
spaced.** Check the written file, not just the probe: `np.diff(t)` with no `<= 0` and
|
||
`rows / (t[-1] - t[0])` equal to the stored rate (the `ch` line's figure). Duplicate or
|
||
backwards stamps mean the recorded timeline holds two clocks or a collapsed cadence —
|
||
the two ways this has broken: a per-frame choice of time source (now sticky, see the
|
||
README) and a per-sample step derived from the client's `lost` counter (now the
|
||
stream's measured arrival cadence). A `rows=` count far below the span times the rate
|
||
is the other signature, that of a table cut from the LOD pyramid.
|
||
**The step must hold to a few ULP of the column's own magnitude**, and this is the check that
|
||
needs a long run at a high rate: the old form of this assertion was "`set(np.diff(t))` holds
|
||
exactly one value", and that was only true while `time` *was* the stored stamp. Both columns
|
||
are derived now — `time` is `stamp - stream_at_x0()` and `abs_seconds` is that plus a base of a
|
||
wholly different magnitude — so each subtraction costs a rounding and `set()` legitimately
|
||
holds a handful of neighbouring doubles. Measured: four values spanning ~1.5 ULP, 6.8e-16 s.
|
||
The thing that must not come back is the **plateau**: equal stamps, or a diff taking two or
|
||
three values a few *hundred nanoseconds* apart, is the time ring losing precision with uptime
|
||
(see the `f64` time-ring invariant in
|
||
[invariants-data-path.md](invariants-data-path.md)). The figure to compute is the ULP of `t[0]` and of
|
||
`abs_seconds[0]` — the defect that invariant guards is microseconds, nine orders of magnitude
|
||
above the arithmetic noise, so the two readings cannot be confused.
|
||
- `badge frames: lit=L dim=D idle=I connecting=C` — the activity badge's phase per frame. Over
|
||
a run that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it
|
||
blinks" means, and a badge stuck lit is indistinguishable from a working one in a
|
||
screenshot. One or two `idle` frames at the start are the frames before the socket
|
||
connected; a large `idle` is correct only when the run paused or disarmed (a
|
||
`UDPSCOPE_PROBE_DISARM_MS` run should show hundreds). **`connecting` must be 0 on a run
|
||
against a live source** — it is the steady *yellow* phase, "the socket answered but the
|
||
stream has sent no CONFIG", and a non-zero count against a working mock means a signal list
|
||
that arrived late or not at all. It is the *only* non-zero phase on a dead-port run
|
||
(measured: `lit=0 dim=0 idle=90 connecting=446`, the `idle` being the frames before the
|
||
first CONNECT), which is also the check that a source that has sent nothing never gets the
|
||
blinking green. The `mode ...` lines carry the rest of the status line, so `rate` and
|
||
`last event ... ago` are checked there.
|
||
- `frame cost: tick A/B ms over N, draw C/D ms (max at frame F), paint E/G ms over M` and
|
||
`frame path: raw=R lod=L columns=C graded=G frames, segments S/T (mean/max), visible max V samples`
|
||
— the
|
||
drawing cost, split into the frame tick, `PlotArea`'s own draw (timed from the frame
|
||
clock's `before-paint` to the end of the host's `render` handler) and the whole GTK
|
||
paint. **`segments` max must stay in the low thousands and `lod` must dominate `raw`
|
||
on any run holding a large capture**: a max in the *millions* with `lod=0` is the
|
||
empty-pyramid defect (see the `MinMaxPyramid` invariant in
|
||
[invariants-rendering.md](invariants-rendering.md)) — the renderer drawing
|
||
every raw sample of a held event on every frame, which measured 6,000,073 segments and
|
||
100–230 ms a frame at 1 MS/s. `raw` frames are expected and cheap below 64 samples per
|
||
pixel, which is where `level_for()`'s own gate puts the boundary.
|
||
**`columns` must equal the frames that drew a trace at all on any run at a high rate**, and
|
||
it is the reading that says *why* `segments` is small: a dense gathered window is reduced
|
||
onto the pixel grid upstream (one snapped vertical segment per column — see the moiré
|
||
invariant in [invariants-rendering.md](invariants-rendering.md)), so the geometry is bounded by
|
||
`axes.w`, and a `columns=0` beside a large
|
||
`segments` is that reduction gone rather than a narrower window. Measured on
|
||
`--rate 1300000 --batch 2000 --freq 3000` over 20 s: `raw=2 lod=1198 columns=1200 frames,
|
||
segments 2041/2237` over 1201 painted frames, against `columns=0 ... segments
|
||
10960/216093` over 263 before it existed — the same run at 4.6× the frame rate.
|
||
**`graded` must equal `columns`**: this app turns `Style::intensity_grading` on, and grading is
|
||
a way of *drawing* the reduction rather than a third path, so a `graded` below `columns` is the
|
||
style not reaching a frame. **`UDPSCOPE_NO_PHOSPHOR=1` is the A/B** and the only way to read
|
||
either of these two lines against the ungraded path, since a frame mean cannot attribute its own
|
||
cost: measured on `--rate 1300000 --batch 2000`, `columns=1166 graded=1166` with
|
||
`draw 3.24/21.43 ms` and `segments 1340/4157` against `graded=0`, `draw 2.30/25.76 ms` and
|
||
`segments 131/4306` over the same `lod=453` frames. The segment rise *is* the gradient (alpha is
|
||
quantised into steps, one `LineBatch` and one draw call each, because the line pipeline's colour
|
||
is a uniform) and the ~1 ms is its cost; a `raw` that *grows* when the grading is on is the
|
||
level-floor invariant in [invariants-rendering.md](invariants-rendering.md) gone, which measured `raw=1154 lod=0` and 6.36 ms.
|
||
**`draw`'s max must sit
|
||
at a frame near 0** — that is the warm-up (glyph atlas, first vertex-buffer growth); a
|
||
late `F` is a frame whose own geometry was expensive and worth tracing. `tick`'s max is
|
||
the publish stall: `publish_event()` builds the snapshot's pyramids under `mutex()`, so a
|
||
latch at over 1 MS/s costs one ~40 ms tick, which must not produce `gaps`. Both these
|
||
lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything —
|
||
`tools/mock_udpstreamer.py --rate 1300000 --batch 2000` — since at the default 10 kS/s a
|
||
whole window fits in the raw path and the publish is too short to lose a race. (Do not
|
||
raise `--batch` to chase the rate: a batch of 20000 exceeds the datagram limit and the mock
|
||
dies with `OSError: [Errno 90] Message too long`.)
|
||
- `publish cost: N events, A/B ms (mean/worst, blocks the UI); M pauses, C/D ms (mean/worst,
|
||
blocks the producer)` — what *freezing a view* costs, and the line that attributes the stall
|
||
`frame cost:`'s `tick` max can only hint at. The two publishes are counted apart because they
|
||
stall **opposite threads**, and nothing else in the dump distinguishes them:
|
||
`publish_event()` runs on the producer with `mutex()` already held, so its cost lands on the UI
|
||
as a long tick; `publish_pause_view()` runs on the UI thread and takes the lock, so its cost
|
||
lands on the producer and can only surface as `gaps`. Read the event figure against `tick`'s max
|
||
and the pause figure against `gaps`. Both are **wide**: on the fast mock the event publish has
|
||
read **69, 89, 92 and 115 ms** and the pause publish **22 and 86 ms** across runs, so treat the
|
||
line as an order of magnitude and not a figure to compare a change against at the millisecond.
|
||
The event reading matters anyway because it is far above the ~40 ms the `frame cost:` entry above
|
||
quotes — treat that 40 as the old reading and this line as the authority.
|
||
It is also **the number to judge a second live ring against**, which is the one design question
|
||
it exists to answer: freezing a whole `BufferStore` copies nothing and inherits the pyramid a
|
||
`ChannelBuffer` already maintains incrementally, so both `worst` figures would go to **0** — at
|
||
the price of a second budget's worth of memory (README records that one live ring is why
|
||
`--buffer-mb 200` really is 200 MB) and a second per-sample write on the producer. What the line
|
||
says about that trade is that the stall is real but **costs no packets**: an A/B of the same run
|
||
with and without a pause read `gaps=0` both ways, the paused one with an 86 ms publish, so a
|
||
second ring would buy a smoother frame at a latch rather than recovered data. (An earlier run
|
||
reported `gaps=73` with a pause against 0 without, which is what made the stall worth measuring;
|
||
it has not reproduced and did not scale with the publish length — 79 gaps with the pyramid build
|
||
removed against 73 with it — so it was incidental load on one run. Do not quote it as a cost.)
|
||
- `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%, worst
|
||
drawn-vs-quoted D px over R raw paints` plus a
|
||
`[ink] paint N <flags> mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=..
|
||
ink=[..] cover=..% x0=.. x_at=[..] bounds=[..] rel=[..] dx=.. drops=[..]
|
||
back=..` line per anomalous frame (stderr) — **`collapsed` and `staircase` must both be 0**,
|
||
and **`drawn-vs-quoted` must stay far under a pixel** (0.0000 is what a clean run reads).
|
||
This is the only check that can see a frame whose *waveform* vanished: `staged_segments`
|
||
counts geometry including the grid and the instruments, and `lod_traces` counts a trace whose
|
||
points are then collapsed, so both read healthy through the flicker. The probe runs on every
|
||
painted frame that drew a trace and recomputes what the renderer did, through the same calls
|
||
— `neighbours()` for the index window, the 64-spp gate for the level, `gather()`, then the
|
||
increasing-suffix filter — and reports where the ink actually landed against the axis.
|
||
`collapsed` is fewer than two distinct positions left (nothing to draw at all), `staircase`
|
||
is the filter dropping over three quarters of the points, `short` is ink reaching under a
|
||
quarter of the axis (reported only once the data spans the window, since a filling ring is
|
||
*meant* to leave its left side empty). `drawn-vs-quoted` is the other reading, and it is the
|
||
one that names a *defect of position* rather than of presence: at a raw level a gathered pair
|
||
**is** sample `first + k`, so the line differences where a sample is drawn
|
||
(`axis.min + gather[2k]`, the renderer's own reconstruction) against the x a readout quotes
|
||
for it (`x_at()`, exact f64) and reports the worst of them in pixels. That is the
|
||
gather-origin invariant in [invariants-rendering.md](invariants-rendering.md) read from the
|
||
screen's side; it was over a hundred pixels at a
|
||
deep zoom before the origin became the caller's. It is measured on the raw levels only — above
|
||
them a gathered point is a bucket's envelope and has no single sample to be off from — so `R`
|
||
is legitimately below `N`. **`back` ≈ one ring span is the signature** of the torn `x0()` read
|
||
(see the cached-first-stamp invariant); so is an `ink=` range disjoint from `axis=`, or an
|
||
`idx=` covering the whole ring with `cover` far over 100%. There was a `kept_fixed_x0` field
|
||
beside `kept` — the same filter with `x0()` hoisted out of the loop, since the renderer used to
|
||
re-read `x0()` per point and a ring-backed one advances with the producer. The origin is a
|
||
local of the renderer's now, so that failure mode is structurally impossible and the field is
|
||
gone; the `[ink]` walk and the renderer still agree because both add `axis.min`. It is a race,
|
||
so it needs load and a reason for long frames —
|
||
`tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --clock-jitter 0.4` with the
|
||
measurements *and* cursors panes up:
|
||
`UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MEASURES_MS=2000 UDPSCOPE_PROBE_CURSORS_MS=3500
|
||
UDPSCOPE_EXIT_AFTER_MS=35000`, which reported three staircase frames in each of three runs
|
||
while the defect was live and none after. `frame path:`'s own numbers move with it (`segments`
|
||
max 156078 → 19681), since a refused `neighbours()` gathers the whole ring.
|
||
- `traceless paints: N (M after the first bind, last at paint P)` and `worst side/pane min
|
||
width: W px`, plus `[gen] frame F generation A -> B (bound=.. empty=..)` and `[width-probe]
|
||
...` lines (stderr) — three cheap per-frame counters that came out of the flicker hunt and
|
||
each guard a documented invariant from the side the existing lines cannot see. **`M` must be
|
||
only the frames in a state that draws nothing**: a traceless paint is legitimate in
|
||
`DisplayMode::Waiting`, which is why the line carries `mode=` — `UDPSCOPE_EXIT_AFTER_MS` arms
|
||
the trigger before it dumps, so a tail of them at the end of a run is expected. `[gen]` names
|
||
each buffer rebuild as it happens with the bind state around it: a rebuild drops every ring's
|
||
history, so the frames after one legitimately draw an empty plot, and `blank view frames`
|
||
cannot see that (it asks whether the ring holds samples the view has not got, and a rebuilt
|
||
ring holds none either) — so an unexplained `[gen]` burst is a structural change firing when
|
||
nothing structural happened. `worst side/pane min width` is the "layout must not depend on
|
||
content" invariant read from the **demand** side: a `GtkLabel`'s minimum width is its text and
|
||
a minimum wider than the screen is honoured by growing the toplevel, which hands the plot an
|
||
allocation wider than the window; `axes.w` sees that a frame later and cannot name the cause,
|
||
so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the
|
||
per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget
|
||
tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read.
|
||
- `[help] pages=14 parsed=14 shortest='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 `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.
|
||
- `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row,
|
||
then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B`
|
||
and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr,
|
||
`UDPSCOPE_PROBE_MENUS_MS`) — the two popovers the toolbar and the status bar open, measured with
|
||
the side panel hidden. **`rows_bad` must be 0 and `fits` must be 1.** What it asserts is what a
|
||
screenshot can only show an eye: every row's label starts at the *same* x (`label_x=26` for all
|
||
seven — a menu whose checkable rows indent their text and whose others do not is exactly what
|
||
"the text is not left-aligned" looks like), every shortcut ends at the row's own right edge
|
||
(`accel_right == W`), no row is over `kMenuRowMaxPx` (30) tall — Adwaita's button minimum is 34,
|
||
and seven of those is what made the popover read as a stack of dialogs until `.udpscope-menu`
|
||
trimmed it — and the rows arrive in the order its `kExpected` names. `[menu] status` is the ⓘ
|
||
bubble: **`pos` must read `top`**, which is set *after* `gtk_menu_button_set_popover()` because a
|
||
`GtkMenuButton` states a position of its own and the last word wins, and `button_top` must exceed
|
||
the bubble's height, because "below the button" is off the window's bottom edge — the bubble's
|
||
top edge stayed at the button with its body clipped away, which reads as an arrow disconnected
|
||
from its body. Run it **alone**: another probe that opens a window dismisses the popover, and
|
||
every row then reports `h=0`. It turns autohide off for its own measurement — a popover dismisses
|
||
itself when it loses the grab, and `xvfb-run` has no window manager to hand it one — and restores
|
||
it afterwards.
|
||
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
|
||
- `[color] '<name>' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `<span foreground="…">` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit.
|
||
- `[reseq-probe] reseq=N late=N stale=N overflows=N holes=N hole_slots=N backfilled=N
|
||
open_holes=N bands=N bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D` (stderr,
|
||
`UDPSCOPE_PROBE_RESEQ_MS`) —
|
||
the resequencing path's mid-run state; the measured readings and the per-case command are in
|
||
[verification.md](verification.md). Eight things to read carefully:
|
||
|
||
1. **`backfilled` and `hole_slots` are not comparable directly.** `hole_slots` counts
|
||
*time-ring* slots once per hole; `backfilled` counts *value-ring* slots repaired and
|
||
therefore sums over every non-math channel. On a stream with 4 non-math channels they
|
||
stand a factor of 4 apart. `hole_slots=4000 backfilled=15600` in the `late` case is
|
||
healthy, not an error (the shortfall is the one hole whose late packet is still in
|
||
flight); `backfilled` well below `4 × hole_slots` would mean some channels went
|
||
unrepaired.
|
||
|
||
2. **`nan_slots=0` beside a large `holes` is healthy, not a contradiction.** A hole is
|
||
retired from the table only when its slots age out of the ring
|
||
(`ScopeModel::forget_holes_before()`) or a re-plan drops it, *never* on repair. So the
|
||
`late` case reads `holes=40 nan_slots=100`: 39 of the 40 holes were fully repaired and all
|
||
40 entries stay in the table, the 100 remaining placeholders being the one packet still in
|
||
flight. What *would* be a defect is `nan_slots > 0` with `bands_repaired == bands`, which
|
||
would mean a band claimed data was recovered while placeholders remained. The
|
||
discriminator is `bands_repaired`, not `holes`.
|
||
|
||
3. **`bands` is the visible spans, `open_holes` the whole table.** `bands` counts what the
|
||
plot is actually shading in the visible window; `open_holes` counts every live hole entry
|
||
whether or not it is in view. `bands` below `open_holes` is normal — a hole that has
|
||
scrolled out of the visible window is dropped by `build_invalid_spans()` rather than
|
||
pinned to the edge. `bands` *above* `open_holes` would name a stale span left on a plot
|
||
after its hole was retired. Neither is comparable with `holes`, which is cumulative: a
|
||
re-plan clears the table and `kMaxHoles` caps it at 64, so the re-plan case reads
|
||
`holes=315 open_holes=64`.
|
||
|
||
4. **`bands_repaired` is the faint tier.** A hole whose late packets have all arrived is
|
||
still drawn, in `Style::invalid_repaired_fill` rather than `Style::invalid_fill`, so the
|
||
display distinguishes "there is no data here" from "there was none when this window was
|
||
first drawn". The three states are: solid while the hole still lacks packets, faint once
|
||
`Hole::repaired()` is true, absent once the slots age out and `forget_holes_before()`
|
||
drops it. `bands_repaired == bands` with `nan_slots=0` is the fully recovered reading;
|
||
`bands_repaired=0` with a non-zero `backfilled` would mean the repair never reached the
|
||
display.
|
||
|
||
5. **`trigger_in_hole` must be 0, always.** A `1` means the trigger engine was fed a
|
||
reserved span's samples. The engine is specifically not fed them; a NaN comparison is
|
||
false anyway, but the real reason is that feeding them would advance the hold-off clock
|
||
across time nothing was sampled in.
|
||
|
||
6. **The discriminator for whether a reservation sized the hole right is the exit dump's
|
||
per-channel `stored=` line, not `frames=`.** `frames` counts delivered packets and is
|
||
blind to the slots a reservation pushed on their behalf. `stored` reflects what the ring
|
||
actually holds, so a reservation that mismeasured the hole shows up there and nowhere
|
||
else.
|
||
|
||
7. **`stale` is 0 by construction whenever resequencing is enabled**, which is every default
|
||
run. The client increments it only on the `reorder_window_s <= 0` branch, where it names
|
||
the pre-resequencing behaviour: a packet that did not advance the counter was dropped.
|
||
With the window open such a packet is delivered flagged instead and counted in `late`, so
|
||
a reading of 0 here is the feature working and not a dead counter. The only way to see it
|
||
move is to turn resequencing off.
|
||
|
||
8. **`overflows` is how an undersized reorder ring reads.** It counts the times a packet
|
||
needed a slot in the client's 64-slot / 4 MB reorder ring and found none, so the gap was
|
||
committed early to make room — the window those packets got was shorter than
|
||
`reorder_window_s` asked for. Nothing is dropped on that path (the arriving packet is held
|
||
after the commit, or delivered in order with the counters it skipped reported as `lost`),
|
||
which is the point: a dropped packet would leave a hole nothing could ever backfill. It
|
||
reads 0 at the four cases' 10 kS/s and non-zero at the re-plan case's 100 kS/s, where
|
||
`--reorder-every 10 --reorder-delay-ms 50` keeps about 50 packets in flight against 64
|
||
slots. A non-zero reading beside a healthy `backfilled` is a note about the ring's size,
|
||
not a defect.
|
||
|
||
- `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires).
|
||
- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`, `disarmed` — a word added to it must be **no wider than the widest already there**, which is what let `connecting` join at ten characters beside `event held`; the dead-port glance reads `max_glance_chars=51` against a live run's 54), the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the two fields a *state* adds are both elapsed times — trigger mode's `last event … ago` and a pause's `for <elapsed>` — which step units rather than growing digits (`format_elapsed()`). A **paused** glance is not in the dump at all: it is read by `[pause-probe]`'s `glance=` field, since the dump's own line is taken live. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it.
|
||
|
||
## `[condition-probe]`
|
||
|
||
`UDPSCOPE_PROBE_CONDITION_MS`, with the mock's `--glitch-at`/`--glitch-width` (legs 0 and 1) or
|
||
`--quiet-at` (leg 3).
|
||
|
||
Four legs, selected by `UDPSCOPE_PROBE_CONDITION_SHAPE`. Every leg prints one line of the form:
|
||
|
||
```
|
||
[condition-probe] type=T cmp=C width=W t0=T0 measured=M at0=A earlier=E level2=L2 mode=D
|
||
```
|
||
|
||
Measured readings (`--rate 10000 --freq 5`, each on a fresh port). Shape 0's `level2=` and
|
||
`mode=` fields are from the `[search-probe]` run in `.superpowers/sdd/progress.md`; shapes 2 and
|
||
3 were re-measured with those fields present. **Shape 1's `level2=0.500000 mode=2` alone is read
|
||
from the code** rather than from a run (`condition_probe_arm` always sets `level2` to `0.5`, and
|
||
the probe now reports only once the mode is `Held=2`):
|
||
|
||
```
|
||
shape=0 type=1 cmp=0 width=0.010000 t0=1.710000 measured=0.004000 at0=nan earlier=nan level2=0.500000 mode=2
|
||
shape=1 type=1 cmp=1 width=0.010000 t0=2.160100 measured=0.200000 at0=nan earlier=nan level2=0.500000 mode=2
|
||
shape=2 type=2 cmp=0 width=0.010000 t0=1.706700 measured=nan at0=-0.500907 earlier=-0.498185 level2=0.500000 mode=2
|
||
shape=3 type=3 cmp=0 width=0.010000 t0=4.120200 measured=0.300100 at0=nan earlier=nan level2=0.500000 mode=2
|
||
```
|
||
|
||
`measured` is the reading, not a phase. It is the pulse width read back from the held snapshot's
|
||
own stamps, which are edge-relative: the snapshot is the ground truth, not the mock's absolute
|
||
clock.
|
||
|
||
**`t0` is not an assertion.** It is a stream stamp, and the stream clock is rebased by whichever
|
||
rebuild last dropped history, so the same leg reads a different `t0` on every run — leg 2 read
|
||
`1.7103`, `1.6975` and `1.8719` on three consecutive runs, and leg 3 read `2.6552` against a
|
||
`--quiet-at 4.0` stream. Read `measured`, `at0`/`earlier` and `mode`; read `t0` only as
|
||
*something* latched. A leg that is genuinely not working says so in `measured`/`at0`, or does not
|
||
latch at all.
|
||
|
||
Four more things are worth knowing before calling a reading wrong:
|
||
|
||
- **Leg 0**: `measured=0.004` is the injected glitch width (here the search-probe run used
|
||
`--glitch-width 0.004`; the earlier standalone probe run read `0.002` with `--glitch-width 0.002`).
|
||
A `measured` near the signal's own half-period means the width qualifier did not qualify — the
|
||
trigger caught a normal edge instead of the runt.
|
||
- **Leg 1**: `measured=0.200` is an ordinary square half-period; every square pulse satisfies
|
||
`> 0.010`. **Use a 1.0 s window with 50 % pre** so the opening edge of a 0.2 s pulse lies within
|
||
the pre window.
|
||
- **Leg 2**: `at0` must lie **outside** `[-0.5, +0.5]` and `earlier` **inside** it — those are the
|
||
two sample values around the band crossing. The probe prints `nan` for any field a leg cannot
|
||
measure. **This leg uses `sine`**, not `square`: the square is exactly ±1 and has no sample
|
||
inside the `[-0.5, +0.5]` band, so a window condition on the square can never fire. This leg
|
||
uses a **0.2 s window with 10 % pre** — short enough to capture the crossing, because the event
|
||
is the band exit, which lands at x = 0 in the held snapshot.
|
||
- **Leg 3**: `measured=0.300` is the dropout timeout. **This leg also uses `sine`**. A timeout of
|
||
0.050 s against a sine crossing zero every 0.1 s latches on a healthy signal, so use 0.300 s
|
||
or longer; **use a 1.0 s window with 50 % pre** so the last crossing before the outage lies
|
||
within the pre window. It needs `--quiet-at`: with `--quiet-at -1` the leg correctly never
|
||
latches, which is the check that the dropout is not firing on the rebuild's own gap.
|
||
|
||
The channel-selection line `[condition-probe] <name>=<index> of <N> channels` is printed before
|
||
the trigger fires; if the channel is not found this reads `-1` and the probe fails immediately.
|
||
There are two failure lines, and they name different defects:
|
||
|
||
- `no latch after N polls` — the condition never fired: a wrong shape, a missing mock flag, or
|
||
the wrong signal selected.
|
||
- `latched but never held` — the edge was accepted but the post window never filled, so there is
|
||
no snapshot to read. The report deliberately waits for `Held`: a finite `trigger_time()` only
|
||
means the edge was *accepted*, and every field is read from `display_snapshot()`, whose stamps
|
||
span x = 0 only once the event is held. Reporting on the latch alone printed `nan` for legs
|
||
that were working.
|
||
|
||
## `[stats-probe]`
|
||
|
||
`UDPSCOPE_PROBE_STATS_MS`. Needs `UDPSCOPE_ENABLE_ALL_MS` ahead of it and
|
||
`UDPSCOPE_PROBE_MEASURE_MS` to add measurements. Run against the fast mock
|
||
(`--rate 1300000 --batch 2000 --freq 1`) for readable figures.
|
||
|
||
Three phases, one line each. Every line carries `phase=`, `on=` (the toggle), `passes=` (the
|
||
pane's recompute count since start) and one `| n=… mean=… sd=… lo=… hi=…` group per
|
||
measurement. Phase 1 adds `dn=` per group.
|
||
|
||
Measured readings (fast mock, 1 Hz sine, with
|
||
`UDPSCOPE_ENABLE_ALL_MS=800 UDPSCOPE_PROBE_MEASURE_MS=1500 UDPSCOPE_PROBE_STATS_MS=2500`):
|
||
|
||
```
|
||
phase=0 on=1 passes=36 | n=30 mean=-0.0370313 sd=0.0953509 lo=-0.246367 hi=0.105167 | n=30 mean=0.633511 … | n=30 mean=1.99567 … hi=2 | n=30 mean=0.997834 … hi=1
|
||
phase=1 … dn=0 (on all four measurements)
|
||
phase=2 … n=1 mean=-4.84467e-14 | n=1 mean=0.63662 | n=1 mean=2 | n=1 mean=1
|
||
```
|
||
|
||
- `phase=0` — the running count. **`n` must be in the low tens**, not thousands: a count in the
|
||
thousands means the accumulator is fed per sample, not per recompute. `mean=0.633511` (≈ 2/π)
|
||
for the `top` measurement on a unit sine is the check that the threshold types are correct, not
|
||
merely plausible. `sd` must be 0 exactly when `n < 2`, and `lo`/`hi` must bracket `mean`.
|
||
- `phase=1` — after a two-second pause. **`dn` must be 0 or 1**: a pinned window recomputes once
|
||
when it settles and then never again. A `dn` near 120 is the second defect — accumulating per
|
||
frame, which adds a hundred-odd observations of the identical window and collapses `sd` towards
|
||
zero. Note that `n` is **not** reset by the pause or the resume; `passes` continues growing.
|
||
- `phase=2` — after Reset. **`n` must be 0 or 1** (0 if the report ran before the next recompute,
|
||
1 if after); anything larger means Reset cleared nothing.
|
||
|
||
## `[search-probe]`
|
||
|
||
`UDPSCOPE_PROBE_SEARCH_MS`. The handler calls `condition_probe()` directly — the condition is
|
||
set in exactly one place for both the live trigger and the search, so `UDPSCOPE_PROBE_CONDITION_MS`
|
||
must **not** also be set (two arms on the same port race). Run with
|
||
`--rate 2000 --batch 64 --freq 5 --glitch-at 0.25 --glitch-width 0.004` and
|
||
`UDPSCOPE_EXIT_AFTER_MS=12000`.
|
||
|
||
Measured output (port 8419):
|
||
|
||
```
|
||
[condition-probe] type=1 cmp=0 width=0.010000 t0=1.710000 measured=0.004000 at0=nan earlier=nan level2=0.500000 mode=2
|
||
[search-probe] started on channel 0, armed t0=5.710000
|
||
[search-probe] state=2 hits=12 scanned=4.5115 total=4.5115 chunks=2 worst=0.106ms total=0.116ms armed t0=5.710000 vs scanned first=5.710000 err=0 agree=1
|
||
[search-probe] jumped to hit 0 t=1.710000 mode=4 axis=[-0.1000..0.1000] pos=1
|
||
[search-probe] marks placed=1 of 1 in view (12 found) locked=1 mode=4 axis=[-0.1000..0.1000]
|
||
[udpscope] search marks: placed=1 hits=12 locked=1
|
||
```
|
||
|
||
- **`agree=1` is the load-bearing line of the whole feature.** The search and the live trigger run
|
||
the same `EventDetector` over the same stamps, so `agree=0` means one of the two is reading a
|
||
different clock — which is exactly the class of defect a search that merely "returns plausible
|
||
times" hides. Here `err=0`: exact agreement, not within tolerance.
|
||
- `state=2` is `BufferSearch::State::Done` (the enum's ordinal). `state=1` would be `Running`.
|
||
- `mode=4` after the jump is `DisplayMode::Paused` (the enum's ordinal: `Live=0`, `Capturing=1`,
|
||
`Held=2`, `Waiting=3`, `Paused=4`). `mode=2` would be `Held` — the jump having left the held
|
||
event in place instead of publishing its own pause view.
|
||
- **`hits=` is a count, not a constant.** It depends on the retained span and on the arm phase,
|
||
both of which move between runs: the same invocation read `hits=12` at `armed t0=5.710000` and
|
||
`hits=11` at `armed t0=5.742000`. A count in the low tens is the reading; `hits=0` with
|
||
`agree=0` is the defect.
|
||
- **`placed=1 of 1 in view` is the correct answer, not an under-count.** The jump frames a 0.2 s
|
||
window and the glitches are 0.4 s apart (one per square period at 5 Hz / square half-frequency),
|
||
so only one hit can be in view at a time. `1 of 1` against `(12 found)` is the marks agreeing
|
||
with the window, not with the hit list.
|
||
- **`total=4.5115` far short of the run is not a defect.** `condition_probe` enables the channel
|
||
(which starts acquisition) and then calls `apply_time_window()`, and each of those rebuilds the
|
||
buffer and drops history. 4.51 s was the genuinely retained span.
|
||
- `locked=1` says the marks are out of the pointer's reach, like every other readout. The exit
|
||
dump's `search marks: placed=1 hits=12 locked=1` is the same assertion from the dump path; its
|
||
`placed=` counts marks in the **visible window at exit**, which is the live view after the probe
|
||
disarmed the trigger — so `placed=0` there is legitimate and asserts nothing about the search.
|
||
The load-bearing `placed=` is the `[search-probe] marks` line, emitted a frame after the jump,
|
||
which quotes the window the scope has actually moved to.
|
||
|
||
## `[capture-probe]`
|
||
|
||
`UDPSCOPE_PROBE_CAPTURE_MS` toggles the packet capture through the status-bar button's own
|
||
handler, then reads the ring file back off disk and prints one line:
|
||
|
||
```
|
||
[capture-probe] enabled=1 read_ok=1 path=/home/m/.cache/udpscope/127.0.0.1-9102/capture.bin slots=1000 stored=253 resident=253 cursor=253 torn=0 truncated=0 header_ok=1 first_seq=1 last_seq=253 payload=2031337 file=65576064 open_ms=2.9 t0=1791524811.925
|
||
```
|
||
|
||
| field | value it must read | the defect a wrong reading names |
|
||
| --- | --- | --- |
|
||
| `enabled` | `1` after the probe's toggle; `0` when the open failed, with the reason on stderr | a toggle that sets a flag nobody acts on, or an open that failed silently |
|
||
| `read_ok`, `header_ok` | `1` | a file whose header does not round-trip — the format changed without the version bump |
|
||
| `stored` | datagrams since the toggle; equal to the exit dump's datagram count over the same interval | the producer storing more or fewer datagrams than the client handed it — a copy in the capture path, which the design forbids |
|
||
| `resident` | `min(stored, slots)`; equal to `slots` once wrapped | the ring reusing a slot before its `seq` was published — a torn slot that reads as a hole in history |
|
||
| `cursor` | `stored mod slots` | a cursor maintained out of step with `seq`; the file's hint would mislead the `xxd` user it exists for |
|
||
| `torn` | `0` | a slot whose bytes landed but whose `seq` never did *without* a crash — the release-store ordering is wrong |
|
||
| `truncated` | `0` | a datagram longer than `UDPS_MAX_DATAGRAM_BYTES` — the slot sizing disagrees with the client's receive buffer |
|
||
| `first_seq` | `1` on a fresh ring | the ordinal not starting where the file starts — a leftover file being appended to instead of renamed |
|
||
| `last_seq - first_seq` | `slots - 1` on a wrapped ring | ordering lost across the wrap: the newest slot is not the largest `seq` |
|
||
| `payload` | the bytes the wire delivered over the interval | a capture storing headers and not payloads, or truncating silently |
|
||
| `file` | `64 + slots * slot_bytes`, a constant for a given N | the reservation is short — which with `mmap` is the SIGBUS the design forbids |
|
||
| `open_ms` | single-digit ms on a local filesystem | a blocking filesystem between two `recv()` calls — the producer stall the popover's toggle costs |
|
||
|
||
With a **failing** open (the cache path colliding with a plain file, a full disk), the line reads
|
||
`enabled=0 read_ok=0` with every count 0, the toggle returns to off on the next tick, and the
|
||
popover row reads `failed: <strerror>` — one stderr line, no retry storm against a full disk.
|
||
|
||
## `[defaults]` and `[configs]`
|
||
|
||
```
|
||
[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01
|
||
[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25
|
||
[configs] before=default active=default
|
||
[configs] save-as=1 names=probe,default active=probe
|
||
[configs] round trip read=1 comment='written by the configs probe' buffer_mb=8 relative=0 capture=0 plots=1 signals=4 measures=0 math=0
|
||
[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.
|