Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
217 KiB
Probe readings: what each line asserts
The per-line reference for the self-check's output — what every probe and dump line means, the value it must read, and the defect a wrong reading names.
Part of CLAUDE.md.
Two lines of the self-check output are assertions in disguise:
-
layout over N frames: axes.x A..B, ...—viewport.wmust collapse to a single value andaxes.x,axes.wandy-guttermust 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. Ay-gutter 88..104draggingaxes.x 88..104/axes.w 707..782with it is that settle, not a twitch — confirm it with thechangesline 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: 17on 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.walso no longer reaches the viewport's right edge:apply_gutters()reserves an empty band there (~59 px of 870), soviewport.w - axes.x - axes.wis a constant, not a zero.x-desyncmust 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-ticksis content, not layout, and legitimately varies by one as a live window slides over a round number. The companionaxes.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 readchangesbefore calling a range the twitch bug. -
[trig-probe] ... trigger=on ... display=... epoch=...(stderr,UDPSCOPE_TRIG_TRACE_MS) — withtrigger=on,displaymust readevent(frozen),live(capturing)ornothing, never plainlive, except for the single interval that contains the toggle itself — recognise it bylatches=0and the next probe readingnothingorlive(capturing)with a frozenaxis=. Plainlivewith the trigger on is the old "the plot keeps scrolling in trigger mode" defect; the namedlive(capturing)is the feature. In that stateepoch=must equal minus the capture's edge andaxis=must stay at[-pre..+post]across consecutive probes whiledata='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 inliveandnothing— it is what makes the live axis[-window, 0], so there it grows (in magnitude) with the stream and a0.0000is the defect, not the healthy reading. It is back to0.0000only inevent(frozen), where the snapshot carries its own origin (EventSnapshot::x_offset) and the display epoch has nothing left to do. Note the probe readspending=fresher thandisplay=(which is last tick's decision), so a singlepending=0 display=live(capturing)line is one frame of sampling skew, not a defect. Andsnap=[a..b]must equalaxis=[a..b], withbpositive by roughly the post-trigger share: that is the "post window is empty" defect's observable.drawn=[a..b]must equaldata=[a..b]in both modes:dataisx_at()(what the crosshair reads) anddrawnisx0() + gather()(what the renderer maps), so a series that breaks the "gather emits x relative tox0()" contract shows up here as the two disagreeing by exactlyx0()— 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) —gotmust containwant(a <= candb >= d), anddx_ratiomust stay within about a percent of1.0.gotis the index windowRenderer::draw_tracesgathers today —neighbours(), one sample of slack either side — andwantis the same window from the same call, so read this line for the rate:dx_ratiois the nominaldx()against the real last-spacing, and it is the number that used to matter. It above 1 (every index scaled up,apast 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 readaxis=in the companion line against thewindow control:line in the dump — a live axis narrower than the requested window is the same symptom from the other cause (see theclamp_to_data()invariant in invariants-trigger-and-pinning.md). Both need a saturated ring to show up, so run them under--buffer-mb 1, which wraps in a few seconds. -
trigger: edge at X ... want [-a..b] got [-c..d] n=N(stderr) —gotmust equalwantandNmust be exactly the window times the stream's real rate, plus one (2 s at 10 kS/s isn=20001, not 19999).gotshorter thanwanton the left means the edge was accepted before the history reached back a full pre window (the gate inTriggerEngine::process()); shorter on the right means the engine latched early or the cut lost the newest samples. AnNone or two short of the exact count is the same class of bug, not rounding:publish_event()clamps the slice to every value ring'sproduced(), 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) — withwas_capturing=1,displaymust readevent(frozen)andsnap/axismust be[-pre..e]whereeis how far the post window had filled, withnthat span times the stream's real rate plus one.display=nothing(or asnapfrom the previous event) is the "disarming mid-capture makes the event completely disappear" defect.seqmust advance by exactly one whileeventsdoes not move: no event fired, so the partial publish deliberately leavestrigger_latches()alone — which is also what keeps thetrigger events: N total, M at the disarmline in the dump meaning what it says. -
[force-probe] button off: sensitive=0 visible=1 w=X | on: sensitive=1 w=X | arm w=Y | label_found=1(stderr,UDPSCOPE_PROBE_FORCE_MS) — the Force button's stated state, read on both sides of the trigger toggle.sensitive=0with the trigger off and1with it on is the whole rule (sync_arm_buttons()states it from the toggle, and a trigger that is off has no engine to force);visible=1in both says it is a body control and not a compact-heading one, which is why the off reading must not bevisible=0;wis its allocation a frame after the state it belongs to, andarm w=Ythe button beside it, so aw=0next to a non-zeroYis the "width read in the tick the control was stated" trap rather than a collapsed button.label_found=1is the button the window shows under the word Force being the panel's own widget. -
[force-probe] leg A|B want=[-a..b] during=[-c..d] snap=[-e..f] n=N axis=[..] latches=P->Q display=.. matches_request=D pin_is_snapshot=P(stderr,UDPSCOPE_PROBE_FORCE_MS) — one line per leg, taken after the latch the press produced.latchesmust move by exactly one anddisplaymust readevent(frozen): the probe sets the condition to a level the mock never reaches (9 V against ±1 V signals), so the engine cannot latch on its own and a forced acquisition is the only thing that can move the count — that is also why this probe must run alone, since any other probe that arms a reachable condition would add latches of its own.wantis the pre/post split the panel was set to at the press, taken from the producer's ownpost_span_s();snapis what the snapshot holds ([-pre_s..+post_s]);duringis the axis while the forced capture was filling, which is the requested window — a capture has no snapshot to frame from yet. The two legs differ in which of those can agree:- Leg A (2 s window at 50 % pre, on a ring that has filled):
matches_request=1, i.e.snap ≈ wantto within a stored interval, andnthe span times the stream's real rate plus one (20001at 10 kS/s for[-1..+1], exactly as the latch line demands). - Leg B (100 s window at 99 % pre, which no run this short can reach back to): the request is
99 s of history and the ring holds seconds, so
matches_request=0on purpose andsnap ≈ [-history..+1]. That is the leg that makespin_is_snapshot=1a reading rather than a coincidence:axismust equal[-snap.pre..+snap.post](a few seconds wide) whileduringstill reads the requested[-99..+1]. A pin stated from the request — thehold_pinned_window()/pin_event_window()mistake this probe exists for — readsaxis=[-99.0000..1.0000]here, i.e.pin_is_snapshot=0with the frozen picture a fraction of its own axis. A forced acquisition is an ordinary latch in every other respect, so thetrigger: edge at X ... want [-a..b] got [-c..d]line appears beside each leg with the force's edge as itsX, and that line's own rule (got == want,nexact) applies to it unchanged. The line[udpscope] trigger: forced acquisition, edge at E -- want [-a..b] s, H s of history behind it, mode=Mis the producer accepting the request:His the ring's retained span at the press, which is what caps the pre side in leg B, andmode=single (disarms at the latch)says the one acquisition the mode means, so an armed state that reads0afterwards is right and not a lost click.force ignored (enabled=.. armed=.. ch=.. pending=..)is the refusal — a trigger that is off, a channel that is still-1, or a capture already filling, which is deliberately not restarted (it is the acquisition being asked for); in thech=-1case the click is refused in the handler instead (force refused -- no signal is being acquired to trigger on), because a request left to the producer would sit behindon_data()'s no-streamed-channel early return and fire on the first signal switched on afterwards.
- Leg A (2 s window at 50 % pre, on a ring that has filled):
-
[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, thencapture open: t0=T ch=C -- enabling signal 1 now,after enable: trigger ch=C' channels=N', andlatched 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.9000for 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=1is the assertion: the capture that opened onTlatched onT, 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 readt0_held=0, withpendingback to 0 andholdoff_untilbehindnow_ton 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 theBufferStore, so the surviving capture has no pre side left and reports a negativepre_s. -
[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] abs=[..](stderr,UDPSCOPE_EXPORT_MS) —time=must equalaxis=androws=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 atime=narrower thanaxis=is a window searched on the wrong clock (the live branch puts the window back on the stream's clock withstream_at_x0()before searching the ring, and that offset is a whole window while streaming — not the 0 it once was).abs - timemust 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 inmode=live,epoch()plus the accepted edge inmode=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. Amode=liveline whoseabs - timediffers between the two ends is the two columns computed from different epochs — the defectabs_baseexists as a singleconstto prevent. An export taken mid-capture reportsmode=livewithtime=[-pre..e]whereeis 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 therows=count. The threebytes=lines are the writers: the.npyis64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols), so a size that is not that is a mispacked record. Read it back withpd.DataFrame(np.load(path))after changing either writer. The.mf3line's size is the MDF 3.30 layout the writer spells out, and every term of it is observable:272 + comment + rows·record + 28 + channels·274 + 30, where the ID block is 64, the HD block 208, the comment is aTXblock of its text plus 5 bytes,recordis16 + 4·cols(twof64time columns then onef32per signal), the data group is 28, each ofchannels = cols + 2gets a 46-byte conversion block and a 228-byte channel block, and the channel group is 30. Read the file back with asammdf:MDF(path).info()must show one group whosechannel 0isname="time" type=master, andget(name, group=0).unitmust be the unit the next line reports.[export-probe] mdf master=time master_unit=s channels=N record=B units=['s' 's' 'V' …]is the other half, and it is the assertion on the units:channelsandrecordare the two numbers the.mf3size above is built from (sorecordmust be16 + 4·colsandchannelscols + 2), andunitsmust be the CONFIG's, in the table's column order, behind the master's and the relative axis's twos— one's' 's'pair plus one entry per exported column, each exactly the unit the stream sent (an empty one reads''). AVwhere the mock sents, or a missing entry, is the unit not reaching the conversion block, which is the only thing ScryLab reads a signal's unit from;write_npy/write_csvmust be unchanged either way (their bytes are pinned by the two lines above). -
[window-probe] <action> label=.. entry=.. unit=U window=W event_span=E cap=C decim=M stored=R aa=F buffered=B(stderr,UDPSCOPE_PROBE_WINDOW_MS) —windowmust equalevent_spanon 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 exceedcap. Theunit ms->sline 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 awindow=499.98there means the control re-parsed its own rounded readout (8.333 min × 60) instead of the request — see thewindow_sync_unitinvariant in invariants-gtk-and-chrome.md.labelis a reading to three decimals in a natural unit and is expected to be lossy (500 s reads8.333 min); it is thewindow=figure that has to be exact. For the resampling:bufferedmust be ≥windowon every line — that is what the decimation is for, and abufferedshort of the window is the old "the control is capped to what the ring holds" behaviour coming back.decimmust be a power of two,stored × decimmust equal the wire rate, andaamust be exactlystored / 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-windowneeds to. -
The anti-alias check is the same probe run against a waveform above the stored Nyquist:
--buffer-mb 1 --freq 2000with the probe ending on a 60 s window givesdecim=16, a 625 S/s stored rate and a 312.5 Hz corner, and the channel dump'sch 0 'sine' ... y=[-0.059, 0.079]is the assertion — a boxcar of 16 at 10 kS/s passes 2 kHz at about 6%. Ay=[-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-levelset_cursor_x()with no gesture behind it must be ignored (ignored=1, andA == 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 thecheck_hostharness, which can callsync_cursors(row)directly; a synthetic press here cannot reach the adapter's own event pump.) -
[cursors-probe] <action> mode=.. on=D pane_sensitive=D t0=.. t1=.. dt=.. T=[..] lines=N [..](stderr,UDPSCOPE_PROBE_CURSORS_MS) — walksvertical→horizontal→both→mix, then moves a marker through the plot (Plot::set_cursor_x(..., Free), which is what a drag does) and reports again.pane_sensitiveis the cursors panel body's sensitivity, not a revealer's child: with the cursors off the panel stays exactly where it is and its cells blank to--, because a readout whose instruments are off has no sample to quote. The times must survive every mode change: they are theRowSet'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 madeT0read 0).linesmust be 0 forvertical, one per row forhorizontal/both(and the time row exists only in the modes that show the vertical pair —T=[]inhorizontalis the assertion that it does not), and one per bound trace formix— a slot with a name and a channel the model built, never a name alone (see the bound-slot invariant in invariants-plot-and-layout.md;lines=6 marks=3over 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. Inmixthe values must be the traces' own (--when a marker is past the newest sample,partialwhen only one marker is over samples). After the move,t0must have changed and every row's marker must have followed it (the report'st0is the shared value).marksis themixmode's crossing annotations: 0 in every other mode, and one per bound trace for each marker that is over samples inmix(so a move that brings the second marker into the data doubles it). Thefirst mark at x=.. y=..line that follows must agree with the pane's first value for the same trace (the pane's lines arechip 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 samePlot::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'sA/Bcells for a ruler row must change when the ruler moves (after ruler dragin the probe walks the first ruler to 80% of its y range), because nothing shares a ruler and the read-back inRowSet::sync_cursors()is the only thing that can notice. The dump'scursors 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'smeasure N: 'sig' type thr=..% above=D show=D -> ok|invalid value text=[..] [duty=..] [marks=..]andmeasures pane: n=N marks_shown=M pane_rows=R pane_w=W pane_h=H switch_on=X reset_sensitive=Y(stderr,UDPSCOPE_PROBE_MEASURES_MS) — adds one measurement of every type on the first plottable signal and lets the tick compute them, so what it prints is what a user reads. The dump line carries one more field for the one type that reads two shares:measure N: 'sig' between thr=50.0% thr2=75.0% above=1 show=1 -> ok ..., wherethr2appears only forbetween(see the band-average probe below). On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0,top≈ 0.96 andbottom≈ -0.96,amplitude≈ 1.92, max/min ≈ ±1,peak to peakexactly 2,pulse duration≈ 0.5 s withduty≈ 0.5,period≈ 1 s andfrequency≈ 1 Hz.topis no longer 2/π, and that is the point of it: it is the flat level of the upper side, read from the density of the window rather than from a mean over a side, and a sine has no plateau — so the densest band sits at its peak and the reading is near it, not the 0.636620 a mean over a half would give.amplitudeis that pair's delta (top - bottom), which is why it reads about 1.92 rather than the half-swing it used to: a bench scope'sVamplitudeisVtop - Vbase. The mean over a half is not gone, though: it isabove 50on this stream (and-above 50isbelow 50), and its own probe asserts it — see[bandavg-probe]below. The measured figures, and the reason they are what they are, are in invariants-measurements.md — on the mock'ssquarethe separation is the visible one (top/bottom0.999983/-0.999983,amplitude1.999966, whilepeak to peakkeeps the edges' ±1.179 overshoot at2.358701). It needsUDPSCOPE_ENABLE_ALL_MSahead of it or it printsno acquired signal to measureand the dump readsn=0. Run it against--noise 0.1 --freq 1as 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 0mock cannot see it. The band costsraw, not accuracy -- the clean fast mock readsraw=0.1%at 1.41 ms a recompute and the noisy oneraw=2.0%at 1.96 ms, so readmeasure cost:on both.marks=lists each mark with its kind, and the mix is the point:level:Valone for average/top/bottom; two level marks forpeak to peak(the window's extremes) and foramplitude(the two flat levels it subtracts -- a pair of rulers, because the span between them is the measurement);level:V,time:Tfor 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:Bfor pulse/period/frequency.text=is the pane's own string with its unit, sofrequencymust readHz(not the signal's unit),period/pulse durationseconds, and the rest the channel's unit.switch_onandreset_sensitiveare the panel's frame read back: the switch widget againstmeasure_stats_on, which the code drives, and the Reset button that follows it. Either disagreeing with[stats-probe] on=is a.uithat lost the widget, or lost the wiring to it;pane_rows=Ris whatmarks_showncounts two per shown measurement (a min/max showing both marks counts two of its own).measures window:is the timeliness assertion -- the window the numbers were computed over against the one that should be on screen. The reading that must hold ismarks_in_window=K/K: every time mark inside the window on screen. Both ranges are on the stream's clock, which is the spacemeasure_windowand 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.winis the window as of the last tick (App::measure_view_window) and is deliberately not read back here: the dump runs from the destroy handler, by which point the exit probe has switched the trigger on, andstream_at_x0()then answers for a mode no frame was ever drawn in -- which read as a 45000 % drift on a run whose every frame was exact.pinis the same tick's pin (App::measure_pin_window,offwhen nothing was pinned) and it is the target while the view is pinned: a measurement on a still frame describes the frame, sodriftthere isresults_foragainst the pin, and a zoom that leaves the screen showing a slice of that frame is not drift. Read againstwin-- the old rule -- a zoomed held event would report the intended behaviour as the defect it was once reported as. The count is still againstwin, the window on screen, not againstmeasure_window/pin, which contain every mark by construction: read that way it was a tautology that printed2/2while the pane described a window a forty-fold zoom away from the one displayed. A time mark may legitimately sit outsidewinon a pinned view that has been zoomed into the pin (the mark is a position in the window the result describes, and that window is the frame), so the strictK/Kreading belongs to a live view and to a pinned view left at its pin. On a window shorter than the refresh interval the count rides the same sawtoothdriftdoes -- the 50 ms window the zoom sweep leaves behind can lag far enough that every time mark is off screen, so0/Kthere is read besidedriftand not alone.0/0is a pane whose types carry no time marks at all (average,top,amplitudeandpeak to peakdraw rulers only), and is not a reading about marks.driftitself 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 onekMeasureRefreshUsof 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 is2 × (refresh + one frame) / window-- the frame term is not noise underxvfb, where a frame costs 30--70 ms: ~10 % on a 2 s window at the 100 ms cadence with instant frames, and 15.8 % measured on the documented run, which is one interval plus one 58 ms frame. On a window shorter than the interval it is a few hundred percent (a 50 ms window reads 254 % for 63.6 ms of scroll, and its ceiling is 400 %). Compute the ceiling for the window the run actually used before calling a figure large. A0.0000%is a dump that landed just after a pass, not a better state. Read it against the recompute count: more recomputes with a higher drift sample is the sawtooth, while a drift that exceeds one refresh interval of scroll, or marks outside the window, is the defect. A measurement is recomputed when the window it describes moves (2 % of its span) or the held/live state changes, never on a wall-clock timer: a timer is what let a marker sit outside the viewport and a dragged one survive a frame, and it is the window it describes -- the viewport live, the pin while pinned -- so that a gesture on a still frame spends no recompute at all.marks_selectablemust 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'stype= thr= band= side= show= shares=A/Bis the per-type behaviour:periodandfrequencymust 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/maxno split either, and onlypulse durationa side.bandis the second share's row, which onlybetweenshows -- it is the one type whose two edges are both parameters -- andsharesis what the two spin fields are showing, read back out of the widgets: a share that is stored but not seeded into its field, or a field that is built but not read back on apply, is then visible in this line rather than only in the reading it produces.above/belowshow the threshold row and no band row, with their own label (above % of the range). A measurement whose signal is not acquired, or a window with too few crossings, must readinvalid/--rather than a made-up number (frequencyon a window holding one edge is the case:dutyis 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 itsSave, open it again, clickDelete—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 aconst char*array built from astd::vector<std::string>as it grew (every pointer dangling, the first entry the most visibly). The report'ssignals=[..]anddrop_hare 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), anddrop_hmust 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'stime 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 throughapply_time_window(): under the figure's x link group the group owns the window, so a row-levelset_time_window()does nothing (a probe that changes the window must use the app's path).distinctmust 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=Dis expected to read 0 on every line now, and that is a consequence of the newest-relative axis rather than a change to the rule: the form is decided by the tick step against the label resolution at the axis' magnitude (see invariants-time-axis-and-window.md), and no display mode carries a large magnitude any more — live and waiting are[-window, 0], a capture and a held event are edge-relative, a pause is freeze-relative. The 50 ms window that used to read absolute at 2 s of uptime (2.13s, 2.14s, 2.15s) and relative at 17 s (100µs, 10.1ms, 20.1ms, with the caption) now reads-40ms, -30ms, -20ms, -10msat 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 isdistinct, not the form. The caption must be non-empty exactly whenrelative=1, which here means empty throughout. Run it afterUDPSCOPE_ARM_TRIGGER_MSas well, which is the case the span rule got wrong: a triggered 50 ms window must readrelative=0 note=[] ticks=[-20ms,-10ms,0,10ms]— at0caption 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 itsfirst_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_valuemust equalorigin + first_relas a tick label plus its caption, i.e. the labels and the caption must share one anchor.x_origin_setis read back out of the plot; it must be 0 in the absolute form. Note the last two steps both reportwindow=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 bareRowSetcalls, 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.windowandevent_spanmust be unchanged on every line and equal to the typed 2 s: a fit is a plot interaction and may not restate the time scale (see invariants-time-axis-and-window.md), and they are the only two readings that can see it try. Thezoomedline'sspanmust be a quarter of the window, or the fits have nothing to undo and the lines below mean nothing.fit xmust putspanback to the whole window and leaveyexactly as it was, andfit allmust do the same and moveyto the data's own range — aythat changed on thefit xline 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, andfit xmust restore the pin's full range — the snapshot's own stamps, legitimately narrower than the requested window (span=1.179900againstwindow=2.000000is correct, not a finding) — and must still readpinned=1afterwards. The pin is restored by writing the members as well as the group, becausehold_pinned_window()prefers a member that has moved sincepinned_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_wis 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 aplacedline rather than the first. (stderr,UDPSCOPE_PROBE_LAYOUT_MS) —buttonsmust be2N+1while the editor is up (one×per row, one+per boundary) and 0 on theeditor offline: the buttons exist only while the editor does.donefollows the same rule for the editor's own exit button (1 up, 0 off), and itsdone_boxon theplacedline must sit in the area's top-right corner:x + Wwithin a few pixels of the area width andywithin a few of 0 (the on-switch line reports a box from before the first frame, so read theplacedone). The last probe step leaves the editor by clicking that button — the toolbar toggle is still exercised by the first step — sobuttons=0 done=0with 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, soadd above 0must read[0:-|1:sine,…]— row 0's signals now on row 1, not renumbered onto the new empty row — andremove 0must pull them back to0:. Atraces=where the signals stayed on row 0 meanscarry_row()was skipped and the rows were renumbered under the user. Geometrically, eachclose=must equal its row's top fromrows_yplus the pad, andadd=must be centred on the boundaries (so strictly increasing, the first at 0 and the last clamped to the area's bottom). Therows_yspans must tile: a gap other thankRowGapmeans the figure and the overlay disagree about where a row is, which is how a×ends up on the wrong row. Also readx-desync 0in the dump — every row the editor adds has to join the one x link group. Run it at--plots 8as well: the cap and the floor are the two refusals, and because the probe emits::clickedstraight at the widget it reaches the handler even when the button is insensitive, soadd above 0there must leaverows=andtraces=untouched rather than growing a ninth row. -
[udpscope] header: window_button_w=W bar_w=B(stderr, once a second, only whenUDPSCOPE_PROBE_HEADERis set) —Wmust be a small constant and must not trackB: 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). AWthat grows with the window width is that bug back. The widths are still sampled every second in every run, because the exit dump'sheader:line reads them; only the line is behind the variable, since a reading printed on every run is a log flood (the[notrace]rule). An unallocated window reads0for both, which is the reading for "the toplevel never got a size", not for a layout drift. -
[renderer] option=NAME GSK_RENDERER=V LIBGL_ALWAYS_SOFTWARE=V(stderr, at startup, before GTK initialises; only whenUDPSCOPE_PROBE_RENDERERis set) — what--rendererexported, printed frommain()so the reading is of the environment a pre-init GTK will see.option=(unset)with both variables(unset)is the default and the assertion that the flag changes nothing unless asked. -
[renderer] realized=TYPE(stderr, once, from the first tick that finds a window renderer; same probe) — the class GTK actually built (GskCairoRendererfor--renderer cairo), which is the half of the claim the export alone cannot make. It is null until the window is realized, so a tick that finds none tries again rather than printing a false reading. -
toolbar icons: trigger=NAME(ok) events=... pause=... cursors=... sidebar=... menu=...(in the exit dump, besidepanel controls:) — the icon name every toolbar button carries, withgtk_icon_theme_has_icon()'s verdict. Every entry must read(ok). Three of the names are this app's own (udpscope-trigger-symbolicand friends, aGResourcecompiled into the binary — see the toolbar-icon invariant in invariants-gtk-and-chrome.md), and a(MISSING)on one of those means the resource path was never registered. That failure is otherwise silent: GTK substitutes a missing-image placeholder, the button still works, and every other line of the dump still passes. Changing an icon name is a one-line change inbuild_toolbar()and this is the only check that it names something that exists. -
switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36(in the exit dump, besidepanels:) — the two sidebar tabs as the switcher actually draws them.pills=2 labels=0is the claim: two pills are on screen and no text is drawn (labelscounts mapped labels with text, becausenarrowkeeps an empty label in the pill's layout and a plain mapped count would read 2 either way).names=2is the other half — both pills carry a tooltip and an accessible name set byname_side_tabs(), so an icon-only pill is not an anonymous button — andpills=0means nothing is mapped, which makes the rest of the line meaningless rather than a pass.policy=narrowis read rather than assumed.icon=is the box the pills'GtkImageis rendered in, which proves the 32px override stuck: the tab glyphs are 32x16 documents, and GDK paints an icon into a square box whatever the document's aspect, so the artwork is drawn 32x16 at 1:1 inside that 32x32 box — letterboxed, not stretched, which is what keeps its 2px bars crisp. It reads0x0on the first tick, where nothing has been allocated yet, and is therefore read by the exit probe with the rest of the sidebar's geometry.size=is the bar's own size, and it is the answer to "what did the wider glyphs cost":36is what it was with 16px icons, because the pill's fixed height takes a 32px box without growing — the glyph gets twice the width for no vertical room at all. -
page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)(in the exit dump, besidetoolbar icons:) — each tab's own glyph andgtk_icon_theme_has_icon()'s verdict, read on the first tick for the same reason the toolbar's are. Both must read(ok): the page titles are empty on purpose, so an icon that does not resolve leaves a blank pill, and a blank pill looks exactly like a page with nothing in it. -
help layer: contextual=on hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} icon=dialog-information-symbolic(ok) hint=12x12; tour=idle steps=5 step=0 auto=1 requested=0(in the exit dump, besideswitcher:) — the help affordance layer as the last tick found it.hints=must equal the number of ids, and the ids are the surfaces the contextual help is on: eleven of the window itself, andmathonly while the math editor is open (its heading lives in a builder that is rebuilt on every opening, so it is not one of the window's own). Four of the eleven are not a pane's heading but the row of a specific control —events_record(the "record events" switch in the trigger panel),trigger_advanced(the pre-trigger share row),trigger_search(the header of the search block's expander, which is what lets it be on screen while the block is collapsed) andsignals_config(the signals pane's hint line, whose box exists so a hint can join it) — which is the rule the layer follows: a hint is a sibling of the thing it explains and never an overlay on it. The layer is off by default (contextual_helpinsettings.tomlis false for a missing file), so a fresh-config run readscontextual=off hints=0and that line above is the state after the Help row was switched on;contextual=offbeside a non-zero count is the toggle not having removed its buttons — off is gone, not greyed.icon=must read(ok): an "(i)" whose name Adwaita lacks is a blank control that still works, the failure the pane [edit] buttons already paid for once. The name isdialog-information-symbolic, the plain information mark;help-about-symbolicis the status bar's ⓘ and a second meaning for it is what the app's own icons exist to avoid.hint=WxHis the size the first visible hint was allocated, measured the way every other rectangle here is (gtk_widget_compute_bounds()), and 12x12 is the reading a "much tinier" mark must keep: the class zeroes the outermenubuttonnode and itsbuttonsubnode (aGtkMenuButton's measure ignores bothmin-widthon the widget and a size request), and the glyph is a third node below the subnode —menubutton > button > box > image, read off the CSS node dump. The direct-grandchild form> button > imagematches nothing and silently leaves the glyph at its natural 16 px, which is the 16x16 an earlier version shipped by mistake; and there is no lower floor to find (the symbolic glyph renders at any request, measured down to 4 px), so 12 px is a legibility choice. The default 34x34 is the number to compare against. A hint inside a collapsed expander's child is skipped, so the number is never a hidden widget's 0x0 — but a hint on the expander's header is in the widget tree and reads its real size while the block is collapsed (see thecollapsed search:entry below).tour=isrunning/idlewith the plan'ssteps=and the currentstep=;auto=1is the first-run auto-start having fired in this process — the flag istour_seeninsettings.toml, so a second run against the same config directory readsauto=0— andrequested=1is the chooser's tour having handed over (Config::tour_pending), which isauto=0because the request came from the other window rather than from this process's first run. -
gtk warnings: N(in the exit dump, besidehelp layer:) — GTK's own log at warning or above, counted for the whole run, and it must read0. It is counted through a log writer function because GTK is built with structured logging: its messages go through the writer and never throughg_log_set_handler()/g_log_set_default_handler(), which is exactly why no probe can read one — the message is written by the toolkit from inside a call of ours and is stored in no widget. The first class to need it wasTrying to measure GtkBox … for width of N, but it needs at least M(once per frame, from a card measured for a height with a width a margin short of its own minimum; see the allocation bullet in invariants-gtk-and-chrome.md). The writer chains tog_log_writer_default(), so every message still reaches stderr exactly as before and the documented baselines are read the same way. -
help figures: trigger_head=X,Y,W,H trigger_summary=… trigger_panel=… signal_list=… side_switcher=… side_stack=… window_button=… status_label=… header_bar=… plot=…(in the exit dump, besidehelp layer:) — the window-coordinate rectangle of each surface the help's annotated figures label,x,y,width,height, or-1,-1,0,0for a surface that is not on screen. It is measured on the tick while the window is alive (nothing in the dump may callgtk_widget_*— see verification.md) and withgtk_widget_is_visible(), notget_visible(): a widget under a hidden parent —trigger_summarywhile the pane is in configuration mode, the Measure page while the Signals tab is showing — keeps its own flag true and reads0x0, and a crop box drawn from that is a box over the wrong thing. The figures are produced by cropping a real screenshot, so these numbers are the crop boxes; their values depend on the window's size, which under the documentedxvfb-runis the window's 756x894 minimum (the 640x480 screen is smaller than the content), so a run on a larger screen reads different numbers for the same panels.trigger_summaryreading-1,-1,0,0is the configuration pane rather than a defect: it is the locked pane's content, andUDPSCOPE_PROBE_HELPUI_MSlocks the pane for that leg. -
app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)(in the exit dump, besidetoolbar icons:) — the application icon, named and present at the size its own root<svg>element declares. Both entries must readokat those sizes.okisgtk_icon_theme_has_icon()and the resource bytes being found under the registered path, so aMISSINGis 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 withtoolbar icons:, and trust that line further for the registration: the resource-path registration is one call inrun_ui()serving both the window's icon and the toolbar's buttons, so aMISSINGon either line is the same defect — but on a machine where--target desktop-entryhas been run, this line'shas_iconhalf is also satisfied by the installed copy under$XDG_DATA_HOME/icons/hicolor, so a broken registration still readsokhere. That was measured: with the resource path deliberately misspelt,toolbar icons:reported fourMISSING(nothing installs the action icons) whileapp icons:stayedokuntil the two installed SVGs were moved aside, whereupon it readMISSING 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 returnsNULL, 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 sameGSK_TEXTURE_NODEof the requested bounds — GTK substitutesimage-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 beforegtk_init(), so this one needs no display and noxvfb-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 inknown_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--servermust 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. WithUDPSCOPE_PROBE_NEWHOST=host:portit drives the form instead —[sources] form open, connect_sensitive=1then apickedline, 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 printpicked nothing: the list is emptyrather than dereferencing a null row.The command that reaches the chooser at all is
UDPSCOPE_PROBE_PICK_MS, and this is worth saying because three earlier attempts found no chooser and no output: with no--serverand no probe, the chooser runs its own nestedGMainLoopand blocks — there is nothing to time it out and no dump runs, so a bare./build/udpscope(bare or under a pty) sits there silently. The working form, against the mock:xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \ ./build/udpscope(with
XDG_CONFIG_HOMEpointing at a directory holding at least one saved source, or the pick finds an empty list and the run printspicked nothing). It is the same command the docs give elsewhere with--serverremoved andUDPSCOPE_PROBE_PICK_MSadded. -
[sources] chooser ui: … hints=[N {ids}] hint=WxH(stderr,UDPSCOPE_PROBE_PICK_MS) — the chooser's frame and its help layer in one line.hints=[2 {chooser_sources chooser_waveform}]is the layer on (contextual_helpin the file the run reads) andhints=[0 {}] hint=0x0is it off, which is the same "off is gone" rule the window's line reads, in the window that runs beforeAppexists. The two ids are the surfaces this dialog has a row for — the heading above the saved-source list and the waveforms page's action row; the form'sAdwEntryRows are not hosts (a row is not a box and its editor is internal, so a hint cannot join it as a sibling) and the connection fields are explained by their group'sdescriptioninstead.hint=WxHis the same 12x12 the window's line reads: the button isui_impl::context_help_button(), the oneudpscope-hintaffordance both windows draw. -
[sources] tour: first run -- auto_started=1 in the chooserthen[sources] chooser tour: visible=1 steps=5 step=0(stderr) — a first run's tour starts in the chooser, on the chooser's own widgets, from the samesettings.tour_seenthe window's auto-start reads and with the same 1.5 s delay.steps=5is the chooser's plan (the source list, the two tabs, New host, the help menu and the preferences). Driven by the walk itself:UDPSCOPE_PROBE_CHOOSER_TOUR=1emits::clickedon the help menu's Guided tour row, which now starts this walk rather than only requesting the window's:[sources] chooser help: tour started in the chooser, then[sources] chooser tour: visible=1 steps=5 step=0, then[sources] chooser tour: finished 5 stops -- the window continues it (tour_pending=1)and[sources] chooser tour: after finish visible=0 tour_pending=1.tour_pending=1andvisible=0is the handoff assertion — the walk is over here and the request rides on the oneConfigvaluemain()hands to both windows. The walk is driven in one tick (it is the handoff being asserted, not the geometry), so the pages it selects are not readable here; the timed_MSform below walks a stop per tick and reads them.UDPSCOPE_PROBE_CHOOSER_TOUR_MS=<ms>walks the tour a stop per tick (250 ms), the cadence the window's own probe uses and for its reason: a page a stop selects is allocated on the frame after the switch. It starts the walk itself when one is not already running, so it does not depend on the config directory being a first run. Every tick prints the page the stack shows and the page the stop stands on:[sources] chooser tour: step=N/5 page=<shown> stop_page=<declared> target_mapped=D. The "Two kinds of input" stop stands onwaveforms:step=2/5 page=waveforms stop_page=waveformsis the switch. Then it steps Back off that stop and reads the page again —[sources] chooser tour: back off the waveforms stop: step=1/5 page=sources— which is the other direction, and the previous stop stands onsources. It then walks to the end:[sources] chooser tour: finished 5 stops -- the window continues it (tour_pending=1)andafter finish visible=0 page=sources tour_pending=1. Paired withUDPSCOPE_PROBE_PICK_MSafter the walk, the run shows the other half: the application'shelp layer:readstour=running … auto=0 requested=1, the window's tour having been started bytour_start_requested()rather than by the first-run timer.
-
The session layout round trip (
~/.config/udpscope/<host>_<port>.toml) — run once with a drop and a window change, then again with no interaction: the second run's dump must readlayout loaded from ...andlayout: rows=R window_s=W row0=[...]with the first run's values, and the signals it names must come back acquired (arow 0 slot 0 -> channel ... samples=Nline). Delete the file before a run whose layout you did not mean to inherit (127.0.0.1_9540.tomland friends accumulate otherwise). A persistence layer would appear in that line as a suffixed name (sine (A),sine #4) while trigger mode is on — it is not the user's arrangement and is never saved (collect_layout()writes a layer slot as empty) — and it is absent from the dump, which is always taken after the exit probe's go-live has cleared the layers.[persist-probe]is therefore the only run-in-progress reading of them. -
[udpscope] starved: connected (config=1), reconnects=N, no new frames in the last second ...(stderr, once per second while the state holds, plus a status-line notice) — "connected but nothing is arriving after a reconnect", which over unicast is the second instance taking the source's single session. Check it does not fire against a live source. The status line carries| N reconnectsonly when N > 0, which is the same counter without the prose. The same stall test has two outcomes, split onhave_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 isconnectingand the badge is yellow from the first frame (seebadge 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 theconfig=0branch and no longer exercises thestarved: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_secondscolumns must be strictly increasing and evenly spaced. Check the written file, not just the probe:np.diff(t)with no<= 0androws / (t[-1] - t[0])equal to the stored rate (thechline'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'slostcounter (now the stream's measured arrival cadence). Arows=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 whiletimewas the stored stamp. Both columns are derived now —timeisstamp - stream_at_x0()andabs_secondsis that plus a base of a wholly different magnitude — so each subtraction costs a rounding andset()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 thef64time-ring invariant in invariants-data-path.md). The figure to compute is the ULP oft[0]and ofabs_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 bothlitanddimmust 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 twoidleframes at the start are the frames before the socket connected; a largeidleis correct only when the run paused or disarmed (aUDPSCOPE_PROBE_DISARM_MSrun should show hundreds).connectingmust 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, theidlebeing the frames before the first CONNECT), which is also the check that a source that has sent nothing never gets the blinking green. Themode ...lines carry the rest of the status line, sorateandlast event ... agoare checked there. -
frame cost: tick A/B ms over N, draw C/D ms (max at frame F), paint E/G ms over Mandframe 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'sbefore-paintto the end of the host'srenderhandler) and the whole GTK paint.segmentsmax must stay in the low thousands andlodmust dominaterawon any run holding a large capture: a max in the millions withlod=0is the empty-pyramid defect (see theMinMaxPyramidinvariant in invariants-rendering.md) — the renderer drawing every raw sample of a held event on every frame, which measured 6,000,073 segments and 100–230 ms a frame at 1 MS/s.rawframes are expected and cheap below 64 samples per pixel, which is wherelevel_for()'s own gate puts the boundary.columnsmust equal the frames that drew a trace at all on any run at a high rate, and it is the reading that says whysegmentsis small: a dense gathered window is reduced onto the pixel grid upstream (one snapped vertical segment per column — see the moiré invariant in invariants-rendering.md), so the geometry is bounded byaxes.w, and acolumns=0beside a largesegmentsis that reduction gone rather than a narrower window. Measured on--rate 1300000 --batch 2000 --freq 3000over 20 s:raw=2 lod=1198 columns=1200 frames, segments 2041/2237over 1201 painted frames, againstcolumns=0 ... segments 10960/216093over 263 before it existed — the same run at 4.6× the frame rate.gradedmust equalcolumns: this app turnsStyle::intensity_gradingon, and grading is a way of drawing the reduction rather than a third path, so agradedbelowcolumnsis the style not reaching a frame.UDPSCOPE_NO_PHOSPHOR=1is 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=1166withdraw 3.24/21.43 msandsegments 1340/4157againstgraded=0,draw 2.30/25.76 msandsegments 131/4306over the samelod=453frames. The segment rise is the gradient (alpha is quantised into steps, oneLineBatchand one draw call each, because the line pipeline's colour is a uniform) and the ~1 ms is its cost; arawthat grows when the grading is on is the level-floor invariant in invariants-rendering.md gone, which measuredraw=1154 lod=0and 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 lateFis a frame whose own geometry was expensive and worth tracing.tick's max is the publish stall:publish_event()builds the snapshot's pyramids undermutex(), so a latch at over 1 MS/s costs one ~40 ms tick, which must not producegaps. Both these lines and the disarm probe'saxis=/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--batchto chase the rate: a batch of 20000 exceeds the datagram limit and the mock dies withOSError: [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 stallframe cost:'stickmax 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 withmutex()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 asgaps. Read the event figure againsttick's max and the pause figure againstgaps. 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 theframe 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 wholeBufferStorecopies nothing and inherits the pyramid aChannelBufferalready maintains incrementally, so bothworstfigures 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 200really 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 readgaps=0both 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 reportedgaps=73with a pause against 0 without, which is what made the stall worth measuring; it has not reproduced and did not scale with the publish length — 79 gaps with the pyramid build removed against 73 with it — so it was incidental load on one run. Do not quote it as a cost.) -
[levels-probe] N measurement(s) over K channel(s), shares='...' -> W window(s), L level walk(s)— then the exit dump'smeasure cost:andframe cost:beside it. This is how a pane's cost is read, and the three fields are the three factors:Wis the pane's price for the window, one fetch and one scan per measured channel;Lis its price for the pane's shares, one accumulator per distinct one, walked over the window's buckets; and the window's samples are the multiplier on both, so a sweep at one scale says nothing about another. Measured at the user's scale (15 s window,--buffer-mb 4000->stride=1, 11.8 M samples in the window = 184 k buckets, four signals, mock at ~0.5 MS/s per signal, llvmpipe), withUDPSCOPE_PROBE_LEVELS_CHANNELSset:50, 1 channel ->1 window, 1 level,measure cost: 226 recomputes, 6.58 ms each,raw=0.1%,tick 1.99 msagainst an empty pane's 1.34;5,10,15,20, 1 channel ->4 levels, 10.28 ms each,raw=0.4%,tick 2.38 ms;- the sixteen shares
5..80, 1 channel ->16 levels, 24.55 ms each,raw=1.2%,tick 3.86 ms; 5,10,15,20over 4 channels -> 4 windows and 4 levels, 35.69 ms each,raw=2.6%,tick 4.58 ms, and stillgaps=0 packets=30580— the pane is a tick cost and not an ingest one, which is the thing to say before blaming the producer.measure cost:'seachis the pass andcovered/recomputesis the mean window it ran over (11.8 M samples here), soeachis only comparable between runs at the same window and the same pane;rawis the signal's share (the mock'snoisechannel reads4.6%against itssine's0.1%at the same window, because a level inside a bucket's own range has to be walked sample by sample). A pass that reads tens of ms on a 15 s window with a handful of measurements is the curve in invariants-measurements.md and not a defect, and the same pane at a 2 s window reads the 1.2--1.6 ms the pane's budget was fixed at. The guard for the walk itself is the exit dump'sframe cost:: sixteen shares on one channel read24.55 msa pass and3.86 msof tick mean beforemeasure_accumulate_bucket()was madeinline, and 17.25 ms a pass after it, with every value inmeasurements:unchanged.
-
trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%, worst drawn-vs-quoted D px over R raw paintsplus 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) —collapsedandstaircasemust both be 0, anddrawn-vs-quotedmust 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_segmentscounts geometry including the grid and the instruments, andlod_tracescounts 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.collapsedis fewer than two distinct positions left (nothing to draw at all),staircaseis the filter dropping over three quarters of the points,shortis 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-quotedis 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 samplefirst + k, so the line differences where a sample is drawn (axis.min + gather[2k], the renderer's own reconstruction) against the x a readout quotes for it (x_at(), exact f64) and reports the worst of them in pixels. That is the gather-origin invariant in invariants-rendering.md read from the screen's side; it was over a hundred pixels at a deep zoom before the origin became the caller's. It is measured on the raw levels only — above them a gathered point is a bucket's envelope and has no single sample to be off from — soRis legitimately belowN.back≈ one ring span is the signature of the tornx0()read (see the cached-first-stamp invariant); so is anink=range disjoint fromaxis=, or anidx=covering the whole ring withcoverfar over 100%. There was akept_fixed_x0field besidekept— the same filter withx0()hoisted out of the loop, since the renderer used to re-readx0()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 addaxis.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.4with 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 (segmentsmax 156078 → 19681), since a refusedneighbours()gathers the whole ring. -
traceless paints: N (M after the first bind, last at paint P)andworst 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.Mmust be only the frames in a state that draws nothing: a traceless paint is legitimate inDisplayMode::Waiting, which is why the line carriesmode=—UDPSCOPE_EXIT_AFTER_MSarms 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, andblank view framescannot 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 widthis the "layout must not depend on content" invariant read from the demand side: aGtkLabel'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.wsees 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 theaxes.w changescount, and a step over 100 px dumps the widget tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read. -
[help] topics=14 index=1 figures=3 dir=/…/help/C/org.udpscope.app; shortcuts: app=14 plot=14 tables=28 ui=28 ui_accel=21 ui_icon=7 ui_bare=0; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok(stderr,UDPSCOPE_PROBE_HELP_MS) — the manual is a Mallard set on disk now, not a GResource in the binary, so this is a file-system reading:topicsis the.pagefiles other thanindex.page,indexmust be 1, andfigurescounts the screenshots underfigures/.topics=0 index=0means the manual is missing fromdir(the source directory the build was configured from,UDPSCOPE_HELP_DIR) — the finding the oldparsed != pagesused to name. The manual itself opens in Yelp, another process, which the probe now launches and reports (the[help] launchline below). Theshortcuts:numbers are the shortcut overview four ways.appandplotare the rows in the C tables -- what the application actually handles -- andtablesis their sum.ui,ui_accel,ui_iconandui_bareare whatsrc/ui/shortcuts.uideclares, read out of the resource with a markup parse rather than off the built widgets, because a tree walk counts every row twice:GtkShortcutsWindowreaches each one from more than one place in its own tree.uimust equaltables-- 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 -- andui_baremust 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 readorg.udpscope.app— it is read back throughadw_about_dialog_get_application_icon()while the dialog is open, so the dialog is asserted to carry the icon rather than merely to open (aset_application_icon()lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder).closed okwith noGtk-CRITICALis as much a crash test of the two dialogs built, presented and closed in one run as it is a state check: they are closed by two different calls, so a mismatched pair crashes or leaks here. One is aGtkWindow(the shortcut overview,gtk_window_close()/::close-request); the About dialog is anAdwDialogand closes withadw_dialog_close(), cleared on::closed. The manual is not one of them — it opens in Yelp, a separate process, so there is no window of ours to read back or close; the line below is the reading of that path instead. -
[help] launch attempted=1 done=1 ok=1 error='' installed=/home/…/.local/share/help/C/org.udpscope.app(stderr,UDPSCOPE_PROBE_HELP_MS, a moment after the line above) — the launch of the manual, the one part of the help path no other reading reaches: a successful launch leaves no window of ours, so nothing else can say whethershow_help()did anything at all.attempted=is set at the call (0would be a probe that never got as far as calling it);done=says the asynchronous callback ran, anddone=0is the reading for a launch that never called back;ok=1is thehelp:URI handed to a viewer,ok=0with the launcher'serror=text is a launch that failed.okis not "a window appeared": Yelp is another process, and under a headless X server it cannot map one — the launch is this process's half (the URI reached a registered handler), which is exactly what the desktop does when a user presses F1, and what an absent handler or an uninstalled viewer breaks.installed=ishelp_installed_path()'s answer — the$XDG_DATA_HOME/$XDG_DATA_DIRSdirectory the document was found in, or(not found)— and it is what a failingok=0should be read beside, because the launch error alone cannot tell a missing document from a missing handler. On a machine with the set installed bycmake --install build --prefix ~/.localandyelppresent, the line readsok=1 … installed=~/.local/share/help/C/org.udpscope.app. A failure leaves the launcher'sGtkAlertDialogup on purpose — that is what a user gets, and the probe must not report a failed path as a silence. -
[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O, one line per row, then[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=Band[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_badmust be 0 andfitsmust 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=26for 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 overkMenuRowMaxPx(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-menutrimmed it — and the rows arrive in the order itskExpectednames.[menu] statusis the ⓘ bubble:posmust readtop, which is set aftergtk_menu_button_set_popover()because aGtkMenuButtonstates a position of its own and the last word wins, andbutton_topmust 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 reportsh=0. It turns autohide off for its own measurement — a popover dismisses itself when it loses the grab, andxvfb-runhas no window manager to hand it one — and restores it afterwards. -
[helpui] auto_started=1 visible=1 steps=5 step=0 (first run)(stderr,UDPSCOPE_PROBE_HELPUI_MS) — the help affordance layer end to end, in ten steps 300-400 ms apart (every reading is an allocation, and a step that read its own predecessor's geometry would read a rectangle from before the frame that placed it). The line above is the first-run auto-start, read before the probe touches anything:auto_started=1 visible=1is the tour that started itself, and the parenthetical issettings.tour_seenas the process found it —tour_seen already setwithauto=0is the second run against the same config directory, which is the assertion that the flag persisted. Then:[helpui] contextual on: hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} hint=12x12— the twelve "(i)" affordances, by id, and the size the first visible one was allocated. Eleven are the window's own surfaces (four of them a specific control's row rather than a heading — see thehelp layer:entry) and the twelfth ismath, which is present only while the math editor window is open; it reads 11 with the editor shut. On a fresh config this readshints=0 ids={}andhint=0x0, because the layer is off by default; the example is the state after the row below was switched on, and a fresh run'shints=0beside asettings.tomlwith nocontextual_helpkey is the default being read rather than assumed.hint=12x12is the "much tinier" assertion — the default menu-button mark is 34x34 (seehelp layer:), and anything near that is the class not reaching the glyph's own node.[helpui] main view: rows=7 help_row=1and one[helpui] entry N '<label>' accel='<key>' order=Oper row of the Help view —entry 0 'Back'with no accelerator and then the four the submenu promises:entry 1 'Keyboard shortcuts' accel='Ctrl+/',entry 2 'Documentation' accel='F1',entry 3 'Guided tour' accel='Ctrl+T',entry 4 'Enable contextual help' accel=''.order=1on every row is the assertion; the view is entered by emitting::clickedon the menu's ownHelprow, so the row's handler is exercised and not just the stack.[helpui] help view: rows=5 expected=5 rows_bad=0 back=1 toggle=1 entries='…'— the summary, withtoggle=being the contextual-help row's check, which issettings.contextual_help. It reads0on a fresh config (the default) and1on one where the setting is on; the probe's next step drives it both ways, which is why the example here is the1side.[helpui] toggle: off -> hints=0 ids={}; on -> hints=12 ids={…}; setting=on— the toggle's effect read both ways through the row itself.hints=0withoffis the assertion: off means the affordances are gone, not greyed, so anything else is a row that hid its buttons instead of removing them. The count comes back to the same twelve, which is the other half — a toggle that removed and could not restore is a one-way switch.[helpui] tour started from the probe: visible=1 steps=5 step=0— the tour started the wayCtrl+Tstarts it.steps=5is the plan: the table's five steps whose surface is on screen or selectable into it (gtk_widget_get_mapped()-- a wrong page is kept, because selecting its page reveals it, while a collapsed pane is still dropped; see the surface bullets in invariants-gtk-and-chrome.md).[helpui] step=1/5 visible=1 side_tab=signals spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C— in window coordinates, the frame a screenshot is cropped in, with the sidebar's page beside it (a stop the walk had to change the page for is readable as such; see[toursurface]below).containsmust be 1 on every step: the spotlight is the target's own rectangle grown three pixels.clearis the card missing the target, and it is 1 wherever the window has room for the card beside or above/below the hole — at the documented run's window (seehelp figures:below) the plot's step is the whole content, because at 756 px the split view overlays the side panel rather than putting it beside the plot, soclear=0there is the geometry and not a placement bug. The side panel's step readsclear=1.[helpui] after Next: step=2/5 visible=1,[helpui] after Back: step=1/5 visible=1,[helpui] after Skip: visible=0 dims_hidden=1 spot_hidden=1 card_hidden=1 controllers=12 (was 12) click_gestures=2— the three controls.controllers=equal to(was N)is the "no lingering input grab" reading: a tour of ordinary overlay widgets takes no grab at all, and a count that grew would be one somebody added.[helpui] layer: …—capture_help_state()'s line at the end of the run, the same text the exit dump prints ashelp layer:.[helpui] figures locked: …and[helpui] figures config: …—help figures:read in both of the trigger pane's modes, by clicking its [edit] button. This exists because one of the help's figures is the locked pane's summary grid (trigger_summary), which in configuration mode is not on screen at all and reads-1,-1,0,0; the two lines give the geometry agent both states' numbers rather than leaving them to find the mode.trigger_panel's height differs between them (508 px in configuration mode against 72 locked — the body is replaced by the summary), which is the check that the mode really moved.[helpui] collapsed search: expanded=0 mapped=1 mapped_header=1 hint=12x12(the last step, in configuration mode, after the figures line) — the search block's "(i)" read on the expander's header with the block collapsed.expanded=0 mapped=1is the assertion: a hint on the expander's own child is not in the widget-parent chain while the block is collapsed and would readmapped=0(and0x0), which is exactly why the header box exists (search_headinsrc/ui/trigger-panel.ui) and why the hint joins it as its last child.mapped_header=is the header box beside it, andhint=WxHis the same 12x12 the layer's own line reads.
-
[toursurface] …(stderr,UDPSCOPE_PROBE_TOUR_SURFACE_MS) — a stop that stands on a page it is not on, entered from the page it is not on. It puts the sidebar on Measure, starts the window's tour, and walks it a stop per tick (400 ms), because a stack allocates the new page's widgets on the frame after the switch — so each reading is of the page the step left standing, not of the one the switch has just left behind. The lines:[toursurface] before: side_tab=signals signal_list_mapped=1 running=1— the state a first run starts in (a fresh config's sidebar is on Signals), then the auto-started walk is dismissed.[toursurface] on measure: side_tab=measure signal_list_mapped=0 side_stack_mapped=1— a frame after the switch:signal_list_mapped=0besideside_stack_mapped=1is the whole premise, the list is genuinely off screen while the stack that holds it is on screen.[toursurface] tour: visible=1 steps=5 step=0 signals_stop=1—signals_stop=1is the filter's reading: a stop whose target is not mapped is still in the plan because it names a page the stack can select.steps=5is the full table.[toursurface] step=N/5 side_tab=<page> target_mapped=D[ target=signal_list]— one per stop. The page moves frommeasuretosignalsat thetriggerstop (the first stop whose target is on the Signals page) and stays there; thesignalsstop readstarget_mapped=1.[toursurface] signals stop=4/5 visible=1 side_tab=signals spot=[…] target=[…] contains=1 card=[…] clear=1— thetour_geometry_line()the helpui probe uses, read on the frame after the switch:side_tab=signalswithcontains=1is the assertion that the spotlight and the card are on the list, not on the page the walk came from.[toursurface] collapsed: side_stack_mapped=0 steps=2 signals_stop=0 side_stop=0— the other half of the filter, read: withsidebar_wantedoff (update_side_visibility()re-asserts it, so this is how a probe puts the sidebar away for good) the split view unmaps the column, the stack with it, and the two stops that need the sidebar are dropped while the page-less ones stay. This is what says a wrong page and a collapsed pane are not the same case.
-
[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: tab signals->measure | g: tab measure->signals | … | editable focused: 'm' refused=1(stderr,UDPSCOPE_PROBE_KEYS_MS) — everyX->Ypair must differ andrefusedmust be 1. Them/gpairs are the tab name before and after, which is what says those two keys select a tab rather than toggling a pane;t/cread the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while aGtkEditablehas focus makes the window entry unusable. A pair reading0->0is an action whose dispatch does not reach the handler a click reaches. Note the format is<key>: <widget-description>: <before>-><after>— the name passed tobefore_after()includes the widget's description, sot: trigger panel: 0->1is the correct form (nott: trigger panel 0->1without 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=#123456and[color] default: trace=#RRGGBB chip_agrees=1(stderr,UDPSCOPE_PROBE_COLOR_MS) — all threechip_agreesmust be 1, the override line'stracemust be#123456, the session line must readentries=1 rgb=#123456, and the default line'stracemust be back to the first line's. Run it withUDPSCOPE_ENABLE_ALL_MSahead of it, or there is no bound trace and the probe printsno bound trace to recolourinstead 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 ofplot::Plot::trace().color— what the renderer will stroke, reached throughRow::plotafterrelayout_areas(), since a new colour is a new figure — and out of the chip's own<span foreground="…">markup, not by askingRowSet::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-tripscollect_layout()→save_layout()→load_layout()on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit. -
[reseq-probe] reseq=N late=N stale=N overflows=N holes=N hole_slots=N backfilled=N gaps_refused=N open_holes=N bands=N bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D(stderr,UDPSCOPE_PROBE_RESEQ_MS) — the resequencing path's mid-run state; the measured readings and the per-case command are in verification.md. Nine things to read carefully:-
backfilledandhole_slotsare not comparable directly.hole_slotscounts time-ring slots once per hole;backfilledcounts 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=15600in thelatecase is healthy, not an error (the shortfall is the one hole whose late packet is still in flight);backfilledwell below4 × hole_slotswould mean some channels went unrepaired. -
nan_slots=0beside a largeholesis 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 thelatecase readsholes=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 isnan_slots > 0withbands_repaired == bands, which would mean a band claimed data was recovered while placeholders remained. The discriminator isbands_repaired, notholes. -
bandsis the visible spans,open_holesthe whole table.bandscounts what the plot is actually shading in the visible window;open_holescounts every live hole entry whether or not it is in view.bandsbelowopen_holesis normal — a hole that has scrolled out of the visible window is dropped bybuild_invalid_spans()rather than pinned to the edge.bandsaboveopen_holeswould name a stale span left on a plot after its hole was retired. Neither is comparable withholes, which is cumulative: a re-plan clears the table andkMaxHolescaps it at 64, so the re-plan case readsholes=315 open_holes=64. -
bands_repairedis the faint tier. A hole whose late packets have all arrived is still drawn, inStyle::invalid_repaired_fillrather thanStyle::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 onceHole::repaired()is true, absent once the slots age out andforget_holes_before()drops it.bands_repaired == bandswithnan_slots=0is the fully recovered reading;bands_repaired=0with a non-zerobackfilledwould mean the repair never reached the display. -
trigger_in_holemust be 0, always. A1means 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. -
The discriminator for whether a reservation sized the hole right is the exit dump's per-channel
stored=line, notframes=.framescounts delivered packets and is blind to the slots a reservation pushed on their behalf.storedreflects what the ring actually holds, so a reservation that mismeasured the hole shows up there and nowhere else. -
staleis 0 by construction whenever resequencing is enabled, which is every default run. The client increments it only on thereorder_window_s <= 0branch, 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 inlate, 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. -
overflowsis 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 thanreorder_window_sasked 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 aslost), 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 50keeps about 50 packets in flight against 64 slots. A non-zero reading beside a healthybackfilledis a note about the ring's size, not a defect. -
gaps_refusedcounts reservations the wire contradicted, and it is the field that reads a counter which is not counting packets. A gap is booked only if it passes two tests (Acquisition::gap_plausible()): the arrival clock has to corroborate the elapsed time (missingpackets would have takenmissingframe intervals; anything under half of that is refused) and the ring has to hold the hole (missing * batch_samples must not laptime_capacity). The shape this exists for is a device whose counter advances by samples, or by frames of an inner layer: every datagram reports a forward jump while the wire delivers on time, so every frame reserves -- and, withrelative_time, every frame books NaN slots, which is a band over the whole window with the samples washed out and no packet ever missing to backfill it.tools/mock_udpstreamer.py --counter-step Nproduces exactly that on an intact link. The reading's two sides, both measured with--relative-timeand no session: a fixed run readsholes=1 hole_slots=900 gaps_refused=419 open_holes=0 bands=0 nan_slots=0 scanned=21700with the data channels full and a once-per-connection stderr warning naming the numbers, while the unfixed one readholes=420 hole_slots=378000 open_holes=64 bands=64 bands_repaired=0 nan_slots=198000 scanned=220000--nan_slotsat 90% ofscanned, which is the whole-window band. The refusal is not relative-mode-only: the same command without--relative-timereadsholes=1 gaps_refused=419 nan_slots=0 scanned=21800, because a counter that lies is not a mode. On the honest shapes (--drop-every,--drop-burst 200,--reorder-delay-ms) this field must stay 0: a non-zerogaps_refusedthere means the arrival test is refusing real loss, and the cost is a gap with no shading (never lost data).
-
-
[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1(stderr,UDPSCOPE_PROBE_ZOOMKEYS_MS) — both_heldmust 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=0is 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_charsmust stay small (under about 60) andpopover_rebuildsmust 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 letconnectingjoin at ten characters besideevent held; the dead-port glance readsmax_glance_chars=51against a live run's 54), the rate field is%.3g(so10 kS/s, never10.0 kS/s), and the two fields a state adds are both elapsed times — trigger mode'slast event … agoand a pause'sfor <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]'sglance=field, since the dump's own line is taken live.detail_fieldsis the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters:%.3gdrops the trailing zero that a%.4gor a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and14-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.004is the injected glitch width (here the search-probe run used--glitch-width 0.004; the earlier standalone probe run read0.002with--glitch-width 0.002). Ameasurednear 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.200is 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:
at0must lie outside[-0.5, +0.5]andearlierinside it — those are the two sample values around the band crossing. The probe printsnanfor any field a leg cannot measure. This leg usessine, notsquare: 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.300is the dropout timeout. This leg also usessine. 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 -1the 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 forHeld: a finitetrigger_time()only means the edge was accepted, and every field is read fromdisplay_snapshot(), whose stamps span x = 0 only once the event is held. Reporting on the latch alone printednanfor legs that were working.
[stats-probe]
UDPSCOPE_PROBE_STATS_MS. Needs UDPSCOPE_ENABLE_ALL_MS ahead of it and
UDPSCOPE_PROBE_MEASURE_MS to add measurements. Run against the fast mock
(--rate 1300000 --batch 2000 --freq 1) for readable figures.
Three phases, one line each. Every line carries phase=, on= (the toggle), passes= (the
pane's recompute count since start) and one | n=… mean=… sd=… lo=… hi=… group per
measurement. Phase 1 adds dn= per group.
Measured readings (fast mock, 1 Hz sine, with
UDPSCOPE_ENABLE_ALL_MS=800 UDPSCOPE_PROBE_MEASURE_MS=1500 UDPSCOPE_PROBE_STATS_MS=2500, the
four measurements the measure probe adds -- average, top, peak to peak, amplitude):
phase=0 on=1 passes=43 | n=28 mean=0.0826556 sd=0.085427 lo=-0.0314722 hi=0.208469 | n=28 mean=0.957905 sd=0.00142833 lo=0.951967 hi=0.962348 | n=28 mean=2 sd=0 lo=2 hi=2 | n=28 mean=1.91652 sd=0.00300419 lo=1.90993 hi=1.92432
phase=1 … dn=1 (on all four measurements)
phase=2 … n=0 mean=0 sd=0 lo=0 hi=0 (all four)
phase=0— the running count.nmust be in the low tens, not thousands: a count in the thousands means the accumulator is fed per sample, not per recompute.mean=2 sd=0 lo=2 hi=2for thepeak to peakmeasurement on a unit sine is the assertion on the shared pass: the extremes of a sine are exactly ±1 whatever the window does, so anything else is the scan, not the reading. The two flat-level types are the other half and their figure is thesd, not the mean:topreadsmean=0.957905 sd=0.00142833andamplitudemean=1.91652 sd=0.0030over windows that scroll under them, and atopthat wanders by tenths is a level that follows the window's alignment instead of a value band (see the flat-level invariant in invariants-measurements.md; the mean itself is window-dependent on a sine, which has no plateau -- the densest band sits at its peak and moves with how much of the peak a window holds).sdmust be 0 exactly whenn < 2, andlo/himust bracketmean.phase=1— after a two-second pause.dnmust be 0 or 1: a pinned window recomputes once when it settles and then never again. Adnnear 120 is the second defect — accumulating per frame, which adds a hundred-odd observations of the identical window and collapsessdtowards zero. Note thatnis not reset by the pause or the resume;passescontinues growing.phase=2— after Reset.nmust be 0 or 1 (0 if the report ran before the next recompute, 1 if after); anything larger means Reset cleared nothing.
[bandavg-probe]
UDPSCOPE_PROBE_BANDAVG_MS. Needs UDPSCOPE_ENABLE_ALL_MS ahead of it, and it must not be
scheduled with UDPSCOPE_PROBE_MEASURE_MS or UDPSCOPE_PROBE_MEASURES_MS: all three add
measurements to the same pane, and the later one would report the earlier one's rows as its own.
Run it against the fast mock (--rate 1300000 --batch 1000 --freq 1) at a 2 s window — the
figures below are shares of the range, and a window that holds a fifth of a cycle is a different
question, not a worse answer. Its own sequence is about 12 s, so UDPSCOPE_EXIT_AFTER_MS=17000.
It adds seven measurements, one per shape whose band mean can be computed by hand, and every one of
them is a mean over a side or a band rather than a level. --square-harmonics 30 and
--square-harmonics 0 are two runs — the second is the ideal step, where the band holds nothing:
| measurement | shape | reading | why that shape |
|---|---|---|---|
above 50 |
1 Hz unit sine | 0.636620 |
2/pi, the figure the old top documented |
below 50 |
1 Hz unit sine | -0.636620 |
its other side: exactly -above 50 |
between 50/75 |
1 Hz unit sine | 0.255873 |
3(2-sqrt 3)/pi, and neither 0 (the window's mean) nor 0.5 (the band's edge) |
above 50 |
square, 30 harmonics / 0 |
0.986496 / 1.000000 |
the reading that commit recorded as the old top; the ideal step has no ring to put inside it |
between 50/75 |
square, 30 harmonics / 0 |
0.302744 / -- |
the two edges' transitions the band holds; every sample is exactly +/-1 on the ideal step, so it holds nothing |
between 50/75 |
ramp |
0.250000 |
uniform density: a band's mean is that band's centre |
above 100 |
ramp |
-- |
a share at the top of the range has no sample above it |
Four line kinds, and the reading is the third:
[bandavg-probe] waiting stored=2108000 of 2600000 slots for a 2.000 s window
[bandavg-probe] readings window channel=0 lo=-1.000000 hi=1.000000 samples=2600000 levels=2
[bandavg-probe] readings above 'sine' shares=50% -> ok 0.636620262 text=[636.62 mV]
[bandavg-probe] edit opened band_row=1 shares=50.0/75.0 (as stored)
[bandavg-probe] after delete measures=6 -> 5 (want one fewer)
waiting— the probe reads nothing until the ring holds a full window: a share of a partial window is a different number (and one that keeps moving while the ring fills), and the mock delivers about 0.5 MS/s of the 1.3 MS/s it declares, so a ring the probe has just re-enabled needs seconds of wall clock before a 2 s window is there. The wait is read off the store (stored_slots()againstwindow / dt), not guessed from a stopwatch. A report taken before it finishes is the one reading this probe can produce that is not the share's — the settled lines are the ones to quote.window— the window each reading was taken over, per channel:lo/hiare that window's own extremes (solo/hiof the split are visible here),samplesis what it holds, andlevelsis how many level accumulators the pane registered for that channel.betweenon the sine makeslevels=2wheretop/betweenalone would make one: a band is two entries of the level list, and a second measurement of the same band does not add a third.- the value line — the pane's own numbers, nine significant digits and then the row's formatted text
(
text=), with the shares it was given.-> invalidwithtext=[--]is the nothing-to-measure reading (the last two rows of the table). The value carries the measurement's unit; the three new types are signal-valued liketop/average, so a sine readsmVand a quantity with no unit reads the bare number. edit opened— the dialog's second-share row for abetween:band_rowmust be 1 (onlybetweenoffers it) andsharesmust be the pair that was stored, read back out of the two spin fields. The probe then sets them to50/90and saves, and the next report is the proof the field is wired: the sine'sbetweenreads0.431361697(0.8 / (2 asin 0.8)), where neither of its sides would.after delete— the cycle's last leg through the same dialog: one fewer measurement, process alive. The two shares are the thing a delete could drop on the floor.- The
[measure-trace]line the probe starts carries the same five readings every 250 ms, which is where the sixth decimal's spread is visible (see the band-average invariant in invariants-measurements.md):above 50on the sine reads0.636620on almost every line and0.636621on a few,above 50on the square0.986496on most and0.986495on the rest. A line's--on a lagging trace is the trace's, not the reading's — the pane is refreshed on a cadence, so quote an earlier line beside its ownview=.
[measurepin-probe]
UDPSCOPE_PROBE_MEASUREPIN_MS. The reading for what window a measurement describes: on a still
frame the pane is about the frame, so a pan or a zoom changes no number, and on a live view the
numbers still follow the window. It installs measure_probe's own four types on the first channel
(average, top, peak to peak, amplitude), arms the trigger in single mode, and runs three
legs — each one reading the pane before and after a gesture written on the row's own axis, which
is what a drag, a wheel or a box zoom writes. Nothing here takes the window-control path: that one
calls off the pin (set_time_window() clears pinned_), so measuring it would test a different
question.
Run it against the fast mock, and mind the datagram limit: --batch 2000 is 80 kB of payload for the
mock's five signals and dies with OSError: [Errno 90] Message too long, so the command here is
--rate 1300000 --batch 1500 --freq 1. Its sequence is about 12 s: it waits for a latch, then
~1.5 s per leg, so UDPSCOPE_EXIT_AFTER_MS=20000 covers it with room.
[measurepin-probe] 4 measurements on 'sine', trigger single
[measurepin-probe] wait mode=waiting pinned=0 at_pin=0 for=[-1.419616..0.580384] pin=[0.000000..1.000000] passes=14
[measurepin-probe] wait mode=capturing pinned=1 at_pin=0 for=[-1.419616..0.580384] pin=[0.000000..1.000000] passes=14
[measurepin-probe] wait mode=capturing pinned=1 at_pin=1 for=[0.881155..2.881155] pin=[0.881155..2.881155] passes=15
[measurepin-probe] wait mode=held pinned=1 at_pin=1 for=[0.881155..2.881155] pin=[0.881155..2.881155] passes=15
[measurepin-probe] held/before pinned=1 mode=held view=[-1.000000..1.000000] pin=[-1.000000..1.000000] for=[0.881155..2.881155] passes=16 ev=2600001 evx=[-1.000000..1.000000] average=0.000000 top=0.957975 peak to peak=2.000000 amplitude=1.915950
[measurepin-probe] held/after pinned=1 mode=held view=[-0.250000..0.250000] pin=[-1.000000..1.000000] for=[0.881155..2.881155] passes=16 ev=2600001 evx=[-1.000000..1.000000] average=0.000000 top=0.957975 peak to peak=2.000000 amplitude=1.915950 same=4/4 for_same=1 pass_delta=0 span_before=2.000000 span_after=2.000000 follows=0
[measurepin-probe] paused/before pinned=1 mode=paused view=[-0.250000..0.250000] pin=[-0.250000..0.250000] for=[1.631155..2.131155] passes=17 ev=650001 evx=[-0.250000..0.250000] average=0.000003 top=0.957944 peak to peak=2.000000 amplitude=1.915906
[measurepin-probe] paused/after pinned=1 mode=paused view=[-0.062500..0.062500] pin=[-0.250000..0.250000] for=[1.631155..2.131155] passes=17 ev=650001 evx=[-0.250000..0.250000] average=0.000003 top=0.957944 peak to peak=2.000000 amplitude=1.915906 same=4/4 for_same=1 pass_delta=0 span_before=0.500000 span_after=0.500000 follows=0
[measurepin-probe] live/before pinned=0 mode=live view=[-2.000000..0.000000] pin=[off] for=[1.666922..3.666922] passes=22 ev=0 evx=[0.000000..0.000000] average=-0.000000 top=0.957975 peak to peak=2.000000 amplitude=1.915948
[measurepin-probe] live/after pinned=0 mode=live view=[-1.062500..-0.937500] pin=[off] for=[2.746345..2.871345] passes=24 ev=0 evx=[0.000000..0.000000] average=-0.427665 top=-0.083224 peak to peak=0.687725 amplitude=0.644726 same=0/4 for_same=0 pass_delta=2 span_before=2.000000 span_after=0.125000 follows=1
view/pin/for/passes/ev/evxare the state: the axis, the pin's own extent, the window the printed values were computed over, the pane's pass count, and the bound event series' own sample count and extent — the frame the numbers are about.ev=0while a view is pinned is an empty frozen frame (every value--), which is the reading that named the pause ordering defect (on_pause_toggled()read its display offset after setting the pause state).same=K/4— the values that are bit-identical before and after the gesture. On a pinned view it must be4/4withfor_same=1andpass_delta=0: the frame's samples did not change, so neither may a number, and the pane may not even spend a recompute on the gesture.for_sameis the app's own 2 %-of-span criterion (the rule that decides a recompute is not owed), not equality: the pin is re-derived from the snapshot's stamps every tick and the pane keeps the window its last pass used, so the two differ in the last digits without anything being owed.follows— the live leg's assertion, and the case the still-frame rule must not swallow:for_same=0, a positivepass_deltaand the results window's span narrowed from the axis' 2.000000 to 0.125000, withaverage,topandpeak to peakall moving.follows=1withsame=4/4on a pinned leg, orfollows=1while pinned at all, is the defect in one direction or the other.- The
before/afterpair is taken only once the pinned frame has settled (awaitline per change of the state being waited on until then, then nothing): the frames that bind a snapshot or freeze a pause re-state the pin from the request and then from the snapshot's own stamps, and a gesture made in one of those frames is reverted by that re-statement — the[zoom-probe]'s own gesture sweep loses its first two steps to it, which is visible in itsges/x1,ges/x2lines. Agave up waiting after N pollsline means no latch (or no pin) was ever reached, and is a reading about the stream.
[search-probe]
UDPSCOPE_PROBE_SEARCH_MS. The handler calls condition_probe() directly — the condition is
set in exactly one place for both the live trigger and the search, so UDPSCOPE_PROBE_CONDITION_MS
must not also be set (two arms on the same port race). Run with
--rate 2000 --batch 64 --freq 5 --glitch-at 0.25 --glitch-width 0.004 and
UDPSCOPE_EXIT_AFTER_MS=12000.
That recipe needs no --square-harmonics: the mock's square rings by default (see
build-and-run.md), and the odd-harmonic series passes through zero at the ideal edge's
own crossings, so the glitch's inverted 0.004 s pulse, the hit count, agree and the pulse-width leg's
measured=0.200000 all read what they read on the step. Only a level the ripple reaches and the step does
not — the extremes, y = ±1.179 — would need --square-harmonics 0, and no probe here asserts one.
Measured output (port 8419):
[condition-probe] type=1 cmp=0 width=0.010000 t0=1.710000 measured=0.004000 at0=nan earlier=nan level2=0.500000 mode=2
[search-probe] started on channel 0, armed t0=5.710000
[search-probe] state=2 hits=12 scanned=4.5115 total=4.5115 chunks=2 worst=0.106ms total=0.116ms armed t0=5.710000 vs scanned first=5.710000 err=0 agree=1
[search-probe] jumped to hit 0 t=1.710000 mode=4 axis=[-0.1000..0.1000] pos=1
[search-probe] marks placed=1 of 1 in view (12 found) locked=1 mode=4 axis=[-0.1000..0.1000]
[udpscope] search marks: placed=1 hits=12 locked=1
agree=1is the load-bearing line of the whole feature. The search and the live trigger run the sameEventDetectorover the same stamps, soagree=0means one of the two is reading a different clock — which is exactly the class of defect a search that merely "returns plausible times" hides. Hereerr=0: exact agreement, not within tolerance.state=2isBufferSearch::State::Done(the enum's ordinal).state=1would beRunning.mode=4after the jump isDisplayMode::Paused(the enum's ordinal:Live=0,Capturing=1,Held=2,Waiting=3,Paused=4).mode=2would beHeld— 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 readhits=12atarmed t0=5.710000andhits=11atarmed t0=5.742000. A count in the low tens is the reading;hits=0withagree=0is the defect.placed=1 of 1 in viewis 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 1against(12 found)is the marks agreeing with the window, not with the hit list.total=4.5115far short of the run is not a defect.condition_probeenables the channel (which starts acquisition) and then callsapply_time_window(), and each of those rebuilds the buffer and drops history. 4.51 s was the genuinely retained span.locked=1says the marks are out of the pointer's reach, like every other readout. The exit dump'ssearch marks: placed=1 hits=12 locked=1is the same assertion from the dump path; itsplaced=counts marks in the visible window at exit, which is the live view after the probe disarmed the trigger — soplaced=0there is legitimate and asserts nothing about the search. The load-bearingplaced=is the[search-probe] marksline, emitted a frame after the jump, which quotes the window the scope has actually moved to.
[capture-probe]
UDPSCOPE_PROBE_CAPTURE_MS toggles the packet capture through the status-bar button's own
handler, then reads the ring file back off disk and prints one line:
[capture-probe] enabled=1 read_ok=1 path=/home/m/.cache/udpscope/127.0.0.1-9102/capture.bin slots=1000 stored=253 resident=253 cursor=253 torn=0 truncated=0 header_ok=1 first_seq=1 last_seq=253 payload=2031337 file=65576064 open_ms=2.9 t0=1791524811.925
| field | value it must read | the defect a wrong reading names |
|---|---|---|
enabled |
1 after the probe's toggle; 0 when the open failed, with the reason on stderr |
a toggle that sets a flag nobody acts on, or an open that failed silently |
read_ok, header_ok |
1 |
a file whose header does not round-trip — the format changed without the version bump |
stored |
datagrams since the toggle; equal to the exit dump's datagram count over the same interval | the producer storing more or fewer datagrams than the client handed it — a copy in the capture path, which the design forbids |
resident |
min(stored, slots); equal to slots once wrapped |
the ring reusing a slot before its seq was published — a torn slot that reads as a hole in history |
cursor |
stored mod slots |
a cursor maintained out of step with seq; the file's hint would mislead the xxd user it exists for |
torn |
0 |
a slot whose bytes landed but whose seq never did without a crash — the release-store ordering is wrong |
truncated |
0 |
a datagram longer than UDPS_MAX_DATAGRAM_BYTES — the slot sizing disagrees with the client's receive buffer |
first_seq |
1 on a fresh ring |
the ordinal not starting where the file starts — a leftover file being appended to instead of renamed |
last_seq - first_seq |
slots - 1 on a wrapped ring |
ordering lost across the wrap: the newest slot is not the largest seq |
payload |
the bytes the wire delivered over the interval | a capture storing headers and not payloads, or truncating silently |
file |
64 + slots * slot_bytes, a constant for a given N |
the reservation is short — which with mmap is the SIGBUS the design forbids |
open_ms |
single-digit ms on a local filesystem | a blocking filesystem between two recv() calls — the producer stall the popover's toggle costs |
With a failing open (the cache path colliding with a plain file, a full disk), the line reads
enabled=0 read_ok=0 with every count 0, the toggle returns to off on the next tick, and the
popover row reads failed: <strerror> — one stderr line, no retry storm against a full disk.
[defaults] and [configs]
[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01 0 0 1
[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25
[configs] before=default active=default
[configs] save-as=1 names=probe,default active=probe
[configs] round trip read=1 comment='written by the configs probe' buffer_mb=8 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1
[configs] after forget names=default
noise= and hf= are the first measurement's band and corner as they came back from the file. The probe
sets them to 7.5 and 250 before saving, so a key that never reached the file reads back as the defaults
(10 and 0) instead -- which is the only way a round trip can tell a written key from an absent one. They read
-1 when the saved configuration holds no measurement, which is what the run above does.
| field | value it must read | the defect a wrong reading names |
|---|---|---|
[defaults] calibrate: |
1 0 model=1 0 |
an unseeded spin button: gtk_spin_button_new_with_range() leaves the value at the range minimum, so the offset opened at -1e12 and Apply wrote it |
[defaults] calibrated: |
2.5 -1.25 model=2.5 -1.25 — the dialog and the model agreeing after a calibration was set |
a dialog that opens at the identity while the trace is drawn through a gain: pressing Apply "confirms" the calibration away. The two halves are printed so the assertion is that they match, not that either is a particular number |
[defaults] trigger: |
0.001 0.002 0 0.001 0 50 0.01 0 0 1 |
the same, per trigger field — the ten spins in tree order: width, width2, level2, timeout, level, pre-trigger, hold-off, noise band, HF reject, persistence; a level of -1e12 is a number nobody chose, and the trailing 1 is the persistence depth, whose default is the feature off |
[configs] names |
gains probe, and loses it after forget |
a save that writes somewhere the listing does not read, or a delete that leaves the file |
[configs] active |
probe after the save-as |
the active file not written, so the next start opens the wrong configuration |
[configs] round trip |
read=1, the note verbatim, buffer_mb non-zero |
the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with |
[rowmenu]
[rowmenu] main 4 'Remove signal' h=18 label_x=26 accel_right=-1/174 ok=1
[rowmenu] main rows=5 bad=0 labels=[Y axis: auto scale,Y axis: fixed range...,Fit X axis,Fit all,Remove signal]
[rowmenu] remove 0 'Back' h=18 label_x=52 accel_right=-1/178 ok=1
[rowmenu] remove rows=6 bad=0 labels=[Back,sine,square,ramp,noise,All traces on this plot]
[rowmenu] summary main_rows=5 main_bad=0 remove_rows=6 remove_bad=0 after_back_rows=5 after_back_bad=0 second_rows=6 second_bad=0 remove_row_in_main=1 remove_row_gone=1 can_open_again=1
[rowmenu] remove-clicked 'sine' trace_freed=1
| field | value it must read | the defect a wrong reading names |
|---|---|---|
h |
18 for every row of both views | the plot menu reverting to Adwaita's 34 px buttons — the row style not applied (popover.udpscope-menu) |
label_x |
26 for every row, 52 for Back | labels not sharing one column; the Back row's 52 is its leading icon, which every menu indents for |
accel_right/W |
equal when the row has a shortcut (Fit all → F), -1 when it has none |
the shortcut not flush right, or shown where the action has none |
remove_rows |
1 per occupied trace slot, plus All traces on this plot when there is more than one, plus the Back row |
a remove view built from the wrong row, or missing its way out |
remove_row_gone, remove_row_in_main |
1 | a submenu that floats beside the menu instead of replacing it |
can_open_again |
1 | the second click not reopening: the GtkMenuButton-owned submenu that collapsed and left its row dead |
bad |
0 for every view | any row outside the compact style. Each view is measured a step after the click that swapped it in — in the same call the new child is unallocated (h=0), which reads like a style defect and is not one |
trace_freed |
1 | a remove entry that dismisses the menu without taking the trace off the row |
[sources]
[sources] form: name='probe-host' note='made by the new-host probe' buffer=32 relative=1 connect_sensitive=1
[sources] configuration created: 9.9.9.9:7000/probe-host note='made by the new-host probe' buffer=32 relative=1
[sources] picked 9.9.9.9:7000 multicast= interface= buffer=32 relative=1 capture=0 config=probe-host note='made by the new-host probe'
[sources] edit form: name='probe-host' note='made by the new-host probe' buffer=32 relative=1 button='Save'
[sources] configuration saved: 9.9.9.9:7000/bench note='edited by the probe' buffer=16 relative=0
[sources] 1 known: 9.9.9.9:7000/bench plots=0 signals=0 buffer=16 relative=0 capture=0 note='edited by the probe' |
| field | value it must read | the defect a wrong reading names |
|---|---|---|
form: |
the name, note, buffer and clock the form was given | a form field that is not wired: the four things a host could not be given before |
connect_sensitive |
1 once the server is typed | the button's validation not following the entry |
configuration created/saved |
the identity the form held, with the settings | a save that writes somewhere other than what the form shows |
edit form: … button='Save' |
the existing configuration's values, pre-filled | the edit path not reading the file — an edit form that opens empty edits nothing |
known: after a rename |
one row, under the new name | a rename that leaves the old file, so the chooser lists the same session twice |
known: for a settings-only configuration |
a row, though signals=0 |
the listing filter treating "no signals yet" as "nobody configured this", which hides every host the form set up |
[sources] loading
[sources] picked 127.0.0.1:21077 multicast= interface= buffer=16 relative=0 capture=0 config=default note=''
[sources] loading: title='Connecting to 127.0.0.1:21077' detail='planning the 16 MB sample buffer'
[udpscope] CONFIG: 5 signals, publish=accumulate
[sources] loading: closed
The loading window covering the gap from the chooser to the first frame of the real window. It is
printed by any run that reaches the chooser, so UDPSCOPE_PROBE_PICK_MS reads the host leg and
UDPSCOPE_PROBE_WAVE=<path> the file one (title='Opening rt.npy' detail='reading the recording').
| field | value it must read | the defect a wrong reading names |
|---|---|---|
loading: after picked |
the address or basename just picked, and the buffer size | the window raised from the wrong place: before the chooser's answer it cannot name the wait, after the model it covers nothing |
| its position in the log | before the CONFIG: or waveform loaded: lines |
a window shown after the work it is meant to cover — the ten seconds of empty screen, unchanged |
loading: closed |
after CONFIG:, i.e. once the real window is presented |
a hide that runs earlier, which puts the screen back to empty for the frames the main window takes to map |
no loading: line at all |
correct for a --server or --open run |
only the path that asked gets one; a line here means every probe just changed path |
loading ui: loading.ui is missing an id this code reads is the file-and-code mismatch the chooser's
own check names, and like it the run continues: a loading window that cannot be built must not stop
the app from opening.
[sources] prefs
[sources] prefs: opened=1 rows=1
[sources] prefs: dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark cfg_relative=1
UDPSCOPE_PROBE_PICK_MS + UDPSCOPE_PROBE_CHOOSER_PREFS=1 opens the preferences from the chooser
rather than from the main window's menu, fills the form through the same prefs_probe_fill_and_apply()
the app's probe uses, and reports both the file and cfg_relative -- what the run about to start will
actually use.
- The second line must echo the probe's input, exactly as
[prefs-probe]does. cfg_relative=1is the chooser's half of the contract: relative time is the one preference that can still change this run, becausemain()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-CRITICALor 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 amkdir()that does not make its parents shows up. This reading is why that is now caught here rather than reported by a user whose settings quietly did not stick. - The line after it,
[sources] picked ... relative=1, is the pair that matters: the preference stated in the dialog has to survive the row click that follows, which overwrites the rest of the config from the source's stored session.
[sources] settings from configuration
[sources] settings from configuration 'bench': buffer=64 relative=1 capture=500
Printed by apply_stored_settings() when a source is named on the command line: the stored
configuration's startup settings, filled in where the command line was silent. A buffer= here
that disagrees with --buffer-mb is a flag being overruled by the file; a missing line, with a
configuration that carries settings, is the defect this exists for — a saved host opened with
--server ran on the defaults, so an edit to its buffer or clock never took effect.
[sources] waveforms and the waveform load
[sources] chooser ui: page='sources' title='Sources' icon=yes page='waveforms' title='Waveforms' icon=yes visible='sources' switcher_wired=1 buttons=[prefs=1 new_host=1 open=1 cancel=1] help=[button=1 docs=1 tour=1 toggle=1 on=0]
[sources] chooser help: tour requested
[sources] chooser help: tour_pending=1
[sources] chooser help: toggle=1 setting=1 file=/tmp/…/udpscope/settings.toml
[sources] waveforms: 1 recent /tmp/rt.npy(ok) page=waveforms
[sources] picked waveform /tmp/rt.npy
[udpscope] waveform loaded: /tmp/rt.npy signals=4 samples=20001 rows=20001 decim=1 span=2.000000s rate=10000 time=[-2.000000..0.000000]
[udpscope] waveform loaded: /tmp/long.npy signals=4 samples=31250 rows=1000000 decim=32 span=19.999980s rate=50000 time=[-4.000000..15.999980]
| field | value it must read | the defect a wrong reading names |
|---|---|---|
chooser ui: |
both pages with a title and an icon=yes, switcher_wired=1, all four buttons 1, and help=[button=1 docs=1 tour=1 toggle=1 on=0] |
the chooser's .ui losing an id: the code names the missing one and refuses the window, and this line is the frame that did build -- a page with icon=NO is one the switcher cannot draw, and a help= field at 0 is a help row (or the button that opens it) the dialog no longer carries |
waveforms: |
one entry per remembered export, with (ok) or (missing), and page=waveforms once the page is shown |
the Waveforms page not built, or a stale path offered as if it were openable |
picked waveform |
the selected path | a pick that carries no file: --open is what the mode reads |
signals=, rows= |
the export's cols= and rows= |
a reader mis-slicing its records, or the columns bound to the wrong channels |
samples= × decim= |
rows, to within the last partial group |
the loader not filtering the file into the plan it built: every row pushed into a ring that cannot hold them leaves the file as its own tail (stored= far above published= in the channel dump, and a 20 s recording on screen as its last 4 s) |
decim= |
1 whenever the recording fits the buffer at its own rate; a power of two above it | a decimation that moved without the span moving — the second line above is the reading to compare against: samples fell 32-fold and span= did not |
span=, rate=, time=[…] |
the exported window and rate, [-window, 0] |
the plan not covering the whole file (the tail arrives as the whole picture) or the display epoch not pinned to the file's last stamp |
frames=0 packets=0 beside channels=N |
0 — no acquisition | a socket opened for a file, which would also make the starvation notice live |
The chooser's help menu — Documentation, Guided tour and Enable contextual help, the three
affordances the window's Help view carries, moved in front of a user who has not started yet — has two
probe legs, both of which then fall through to the normal first-row pick so the run they affect is the
run they report:
UDPSCOPE_PROBE_CHOOSER_TOUR=1emits::clickedonGuided tour, and[sources] chooser help: tour_pending=1reads the request back out of theConfigthe run will be handed.tour_pending=1is the assertion: the tour is drawn on a window that does not exist yet, so the entry can only work by setting that field, and the reading it started is the exit dump'shelp layer:—tour=running … auto=0 requested=1,requested=1being this path (seehelp layer:above).UDPSCOPE_PROBE_CHOOSER_HELP=1sets theEnable contextual helprow active and reads the settings file back:toggle=1 setting=1 file=<settings.toml>.setting=1besidetoggle=1is the pair that matters — the row is the setting, and the app is built from the file and not from the dialog, so the file is where it has to have landed.
[drop-probe]
[drop-probe] signals=4 channels=4 rows=4 slots0=[sine|||] view=[-3.999690..15.999670] repins=2
[drop-probe] dropped signal 0 -> 1 slots=[sine|sine||]
[drop-probe] dropped signal 1 -> 1 slots=[sine|sine|square|]
| field | value it must read | the defect a wrong reading names |
|---|---|---|
rows |
the column count, capped at RowSet::kMaxRows — one row per trace |
rows sized four signals to one row: the only row fills and every drop is refused ("plot 1 already holds four signals"), which reads as a scope that cannot plot anything |
dropped … -> 1 |
1, and the row gains that trace | a drop that cannot find a free slot, or a signal with no channel to bind |
channels |
one per signal | a file whose columns did not become channels, so nothing can be plotted at all |
view |
the file's own span, or whatever the user last zoomed to — never a sub-range of it the user did not ask for | a pin that no longer holds the data: view=[3.999690..15.999670] against a file spanning [-4..16] is the pin written as a pre/post split where a range belongs |
repins |
2, for the whole run, however long | the file view stating its pin per frame instead of refreshing its numbers. Each statement writes the group's range, so the axis alternates between the file's span and the pin's and no gesture survives: repins=116 at three seconds is one per frame, and it reads on screen as a flicker in which nothing can be clicked |
[events]
[events] live, recording off: button_visible=0 pane=0 pane_w=0 tick=1 recording=0
[events] recording on, no trigger: button_visible=1 pane=0 pane_w=0 tick=1 recording=1
[events] after trigger on: button_visible=1 pane=0 pane_w=0 tick=1 recording=1
[events] preview redrawn: signals=3 stored=480x132 row_drawn=1
[events] dir=/home/u/.cache/udpscope/events/127.0.0.1-21023 n=3 newest: stem=20261013-184140-0003 dur=2.000000 pre=1.600000 post=0.400000 edge=5.660100 signals=3 samples=20001 rate=10000 seq=3 thumb_bytes=190134 recorded=1 pane=1
[events] activated row0: replaying=1 pane=1 pane_w=240 pane_box=228 tick=0 window=2.000 axis=[-1.600,0.400] marker=0.000 thumbnail=1 thumb_size=138x46 thumb_visible=1 rows_kept=1 selected=2/2
[events] replayed: channels=3 samples=20001 replaying=1 waveform=1
[events] live again: replaying=0 waveform=0
[events] live restored (after replay): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=10/5
[events] recording off, trigger off: button_visible=1 pane=1 pane_w=240 tick=1 recording=0
[events] recording off, trigger on: button_visible=1 pane=1 pane_w=240 tick=1 recording=0
[events] compare buttons: cached=2 selected=2 compare=1 accumulate=1
[events] compare: kind=1 rows=1 channels=8 samples0=32964 traces0=4 traces1=-1 window=2.000 recording=1 pane=1
[events] compare plots: area_b=1 rows_a=1 rows_b=1 list=4 x_a=[-2.000,0.000] x_b=[-2.000,0.000] same_x=1
[events] compare sync: y_a=-3.500 y_b=-3.500 mirrored=1
[events] accumulate: kind=2 rows=1 channels=8 samples0=32964 selected=2 traces0=8 window=2.000
[events] group left: replaying=0 kind=0 waveform=0
[events] live restored (after a group): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=22/16
thumb_size is the built size of the row's picture: kEventThumbW wide, and as tall as the stored
preview's own aspect makes it at that width, clamped to [kEventThumbH, kEventThumbMaxH]. So it moves
with the event's signal count (a three-signal event sits on the floor at 138x46, a six-signal one
reads 138x75) as well as with the CSS border and the display's scale factor. What it must never be is
0x0, and a height pinned to the floor for every event says the aspect fit was dropped and tall
previews are being squashed again.
[events] preview redrawn: is the repair path, and the probe forces it: step 1 deletes the newest
.bmp, drops the thumbnail cache and refreshes the pane, so this line is EventStore::redraw_preview()
drawing the picture again from the .npy. stored is the file it wrote, read back out of the BMP
header — 480 wide and 44 * signals tall — and row_drawn=1 says the row got a paintable from it.
The probe therefore needs a cache a previous run populated: it fires ~4.5 s in, before this run's
first latch.
[events] live restored (…) is the way back, read once per way into a file view — after a plain
replay and after a group — and four of its fields are a pair: the value now, and the value the
live view held before the file view opened over it. The probe captures that expectation itself
(capture_live_expectation(), App::dbg_live_*) rather than asking the restore path for it: a
reading taken from the mechanism under test asserts nothing. The window is set to 0.5 s just before
the event is opened precisely so the pair can disagree — with the event's own span there would be
nothing to tell "restored" from "untouched".
Both lines are read a later step, not in the handler that pressed Live: the restore waits for the
restarted client's channel list being usable again — the restored trigger's channel name has to
resolve — so it is performed by a tick ~900 ms on. pending=0 is what says it happened; gen=a/b is the
generation now against the one Go live read, and it is informational: the restore no longer waits for it
to move, because a restarted client whose CONFIG is identical is deliberately no change, so a single
event — which keeps the stream's own channel names — never moved it and the restore waited until the
trigger was toggled. A stuck pending=1 is not a reading about the restore but about the stream
behind it: the channel list came back empty, which is how a session file poisoned by a save taken during a
group produced pending=1 channels=0 and a live view with nothing on it.
Run this one against a configured live view. The four paired fields are the probe's own capture of
what the live view held when the file view opened over it, so a bare first run legitimately reads
window=2.000000 (want 0.000000) trigger=1 (want 0) channel='' (want '') — the wants are the empty
snapshot and the values are the application's defaults, which is not a defect. The documented line above
is reproduced from a session that has a window and a trigger (window_s = 0.5, trigger_enabled = true,
trigger_signal = "square"); the probe sets its own window to 0.5 s before opening the event, and a
session that then disagrees with it takes the pair apart.
| field | value it must read | the defect a wrong reading names |
|---|---|---|
n, stem, seq |
one entry per latch, named by its time and the run's counter | two events in one second overwriting each other, or a cache that is never written |
dur |
pre + post for the event's window |
the snapshot's own window not recorded, so the list disagrees with the picture |
signals, samples |
the event's channels and rows | a table built from the rows instead of the snapshot: only what happened to be plotted |
thumb_bytes |
a real 24-bit BMP, 54 + 480 * (44 * signals) * 3 rounded up per row to a multiple of four -- so it grows with the signal count (190134 at three signals, 380214 at six) |
a preview that is empty or missing -- the widget snapshot of a GtkGLArea, which is NULL -- or a picture back on a fixed total height, which gave a six-signal event 26 px a signal and an eight-signal one 20: a smear, not a waveform |
recorded |
1 while the trigger panel's switch is on | the switch not wired, or recording defaulting off (it must be on: the cache is for the event you did not expect) |
pane |
1 once the history button has been clicked | the button's handler not revealing the pane |
live |
`button_visible=0 pane=0** | a pane or a button on screen with no trigger mode: a column of nothing beside a live plot |
after trigger on |
button_visible=1 pane=0 |
the pane revealed by trigger mode alone (it must be on demand from the toolbar button), or the button missing in trigger mode |
recording off, trigger off and recording off, trigger on |
button_visible=1 pane=1 recording=0 in both once the cache holds anything (the probe's own run does by this point) |
the button going away with the switch when events are already written -- an event on disk outlives the switch that wrote it -- or pane_w not being 240 with the pane open. With an empty cache the same two lines read button_visible=0 pane=0, which is the empty column the item exists to prevent |
thumbnail, thumb_size, thumb_visible |
1, a non-zero size, and 1 | the picture loaded through gtk_picture_set_filename(), which fails silently (the paint-able is there, the allocation is there, nothing is drawn), or a GtkPicture given a size request and no cap -- its natural size is its content, so the row's text is squeezed off. thumb_size is the reading that catches both. It also catches the cache never being created: event_preview_texture() answers from the cache or not at all, so a reader that does not create one leaves every row blank, which is what thumbnail=0 meant for as long as it read 0 |
preview redrawn |
stored=480x<44*signals> and row_drawn=1 |
a preview that cannot be repaired. The .npy is the event and the .bmp is only a picture of it, so a write that failed, a truncated file or a cache from an older build must be redrawn rather than shown as a blank row -- row_drawn=0 is a row the user reported as "sometimes missing" |
axis, marker, pane_box (in activated row0) |
[-pre, +post] of the event just opened, marker=0.000, and a pane_box close to pane_w |
an opened event framed on something other than its own pin; a trigger line left parked (a large negative) rather than on the edge, which is a file opened without its marker; or a pane whose rendered width is 0 while the divider says 240 -- the position is what was asked for, pane_box is what the pane actually got, and a divider at 240 with a zero-width pane is still no pane |
rows_kept, selected (in activated row0) |
rows_kept=1 and selected=N/N -- the counts either side of the activation equal |
the list rebuilt by the activation. It is the row object that is compared, not its index: a rebuilt list has a row 0 too, and only identity tells a list that was left alone from one that was torn down under the user's scroll position and their comparison selection. mark_events_on_screen() must rewrite the titles in place |
replaying (last line) |
0 | a replay with no way back: Live must start a fresh client and thread |
window, trigger, channel, level (in live restored) |
each equal to its (want …) |
the way back out of a file view discarding what the live view was set to. One wrong half names which writer won: a window that reads the session file's value is the deferred apply_layout() beating the restore; trigger=0 is the toggle the file view switched off and nobody switched back; an empty or shifted channel is an index restored instead of a name, after a rebuild renumbered (or a group renamed) the channels; and a level that moved on its own is rebuild_trigger_signals() re-defaulting it because trigger_level_touched was not restored with the configuration |
pending, gen (in live restored) |
pending=0, and the two generations different |
the restore still waiting. It is gated on the stream's first CONFIG, so pending=1 says the channel list the stored trigger name needs never arrived -- with channels=0 behind it, a session file that enabled nothing |
pinned, held, mode (in live restored) |
pinned=0, or pinned=1 with held=1 |
a file's pin surviving the way back, which is a live view that cannot be navigated. apply_time_window() drops it; the second reading is the legitimate case -- a restored trigger that was on can latch again before the probe reads, and a held capture is pinned, correctly |
pane_w |
the divider's position: 0 while the pane is closed, its remembered width (240 by default) while it is open | the pane reserving a column while closed (a paned hands its start child its minimum unless the position says otherwise), or a width that is not remembered |
tick |
1 in live mode, 0 once a file is on screen | the signal list still offering "acquire this signal" for samples that are already stored |
window (in activated row0) |
the event's span -- the probe sets 0.5 s just before the click, so reading the event's span says opening it framed it | a replay that leaves the previous window alone, or one that restates it every frame instead of once |
compare buttons |
selected=2 and both buttons sensitive |
the buttons reading something other than the selection, or staying insensitive with a valid pair selected |
compare |
kind=1 rows=1 channels=8 samples0=33045 traces0=4, pane=1 |
samples0 above the stamps pushed -- a live producer left running, pushing four stream signals into the group's eight channels; rows=1 because the layout is the user's and must not be re-derived; traces0=4 because the four slots the layout holds are renamed, not replaced |
compare plots |
area_b=1 rows_a=1 rows_b=1 list=4 same_x=1 |
no second plot on screen, a second plot with a different row count, two plots not on one x window, or list above the signal count -- the panel listing each event's copy of a signal as a signal of its own |
compare sync |
mirrored=1, with y_a and y_b equal to the range the probe set on the first plot |
the mirror not running, or running the wrong way: a y (or x span) change in one plot that the other never sees |
accumulate |
kind=2 rows=1 channels=8 samples0=33045 traces0=8 window=2.000 |
the second event's layers not landing: traces0 stays at the four signals the layout holds when the slots were not free, or the row named after a previous group's suffix so nothing binds |
group left |
replaying=0 kind=0 waveform=0 |
the group surviving Live: a file view with no file behind it |
[boxzoom-probe]
[boxzoom-probe] open: swept=200x10 frame=100,0 200x853 axes_h=853 band=1 axis_before=[0.000000..2.000000]
[boxzoom-probe] closed: frame=0x0 gone=1 axis_after=[0.000000..0.553167] zoomed=1
UDPSCOPE_PROBE_BOXZOOM_MS injects a Ctrl box zoom straight into row 0's controller -- the way a
headless probe has to, since the area's gestures arrive from GTK -- and reads the frame back off the
plot one frame later. That is what makes this the adapter's per-frame sync being exercised: the
library measures and draws the rect in tests of its own.
band=1is the reading that matters. The pointer swept200x10, and the frame is200wide and1665-- 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. Itsxandymust be the press point's.closed: frame=0x0 gone=1is 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/zoomedare 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 readzoomed=1. A file opened with--openreadingzoomed=0while the live view reads1is the defect this reading exists for, and it was invisible from the frame alone: the file view pinning its axis from the ring each frame is what swallowed the gesture.
panes: — the locked and configuration modes
[udpscope] pane drag: sidebar hidden for the drag=1 restored after it=1
[udpscope] pane edits: locked=document-edit-symbolic(ok) config=object-select-symbolic(ok)
[udpscope] panes: trigger=locked signals=locked body_visible=0 summary='signal: square | condition: window, falling | level: 0.3 .. 0.6 | mode: single | noise band: 0.2 | pre-trigger: 20 % | hold-off: 1000 ms'
[udpscope] panes: trigger=config signals=config body_visible=1 summary=''
[udpscope] panes: the same line as the probe found it: 'mode: stream (trigger off)'
pane drag: is a drag begun through the source with the split view forced into its overlaid
state, which is what a window with no room beside the plot produces. Both must read 1: with the sidebar
covering the plot there is no drop target under the pointer, so a drag out of the signal list is
impossible rather than merely awkward, and row_drag_sidebar() steps the sidebar aside for the length of
the drag and puts it back after. hidden=0 is an overlaid drag that can never land anywhere.
pane edits: is the two [edit] buttons in each mode, by name and verdict, and it exists because of
a real defect: the "done" icon was emblem-ok-symbolic, which Adwaita does not have (breeze does), so
the button that leaves configuration mode was blank — and a blank icon button is invisible in every other
reading, exactly as toolbar icons: describes for the toolbar. The probe clicks into configuration mode
and back, so one line carries both names. Both must read (ok), and the pair is
document-edit-symbolic (the pencil: edit) and object-select-symbolic (the check: done).
Both panes have two modes. Locked is what a returning user gets — the trigger pane shows the one line
in summary, the signal list shows only the signals that are on, and neither offers a control that
changes the configuration — and config is the full pane, which is what a first run gets, because a
session file that loaded is what "we have been here before" means. Each heading's [edit] button flips
its pane.
- With trigger mode off the summary is one line:
mode: stream (trigger off). The four lines below describe a trigger that is not running, so quoting them would describe nothing. The dump carries the line twice — once as the probe found it and once at the end — because only a run whose session has the trigger off shows the stream form, and a run whose session has it on shows the settings. The pair is also the refresh test: the probe toggles the trigger, so a run whose session has it off must showstream (trigger off)in the line it found and the settings in the live one. Both showingstreamis a compact view that did not refresh when the mode changed, which is a defect this pair caught. trigger panel sensitive …=1is now a guard, not a state. The trigger's body is never disabled — a trigger is configured before it is switched on — so this reads1always, and a0means the settings have been made unreachable again. The cursors' body is disabled with the cursors, and that is the pane whose readout has nothing to show.compact arm with the trigger off: visible=0/with the trigger on and the pane locked: visible=1 sensitive=1 width=51 label=Disarmis the compact pane's arm control, in the two states that define it. The first says it is not on the line when there is no engine to arm; the second says it is, and that it is live (sensitive=1) and laid out (width> 0 — a width can only be read the frame after it is first shown, see the invariants).labelmust agree withmodel->trigger_armed()rather than with a fixed word, because a single-shot latch may have disarmed it by the time the probe looks.[udpscope] compact arm probe: locked=1 enabled=1 own=1 effective=1 width=51 panel_width=-1 label=Disarmprints the inputs beside the outcome, andpanel_width=-1is the assertion that the body's own Arm button is not on screen at the same time — two arm controls for one state is the defect it exists to catch.arm sensitive with the trigger off=0is the one exception inside that body:Armis a runtime control, so with trigger mode off there is no engine to arm and it is disabled (and labelledArmagain).1here is an Arm button that does nothing when clicked.summaryis the locked trigger pane's whole content, askey: valuepairs separated by|. The pane draws it as a small definition list —signal,condition,level,modeon four lines, and then only the figures that are not at their defaults (noise band,HF reject,pre-trigger,hold-off), because a line reading "band 0" is a line that says nothing. One line was tried first and was too little: the four answers are what a user opens the pane for.summary=''withbody_visible=0is a locked pane showing nothing at all, which is the defect this reads for.body_visible=0is the settings being off screen, which is what makes the pane a couple of hundred pixels instead of 580 inpanels:.- The locked signal list drops the rows of the signals that are off, so
rowscounts what is on:signals panel: … rows=3 signals=2 divider=1is two on signals and theMATHdivider, not four signals. Its+goes with them — a+on a locked list opens the editor the lock exists to keep shut.
panel switches: and the trigger panel's default sensitivity
[udpscope] trigger panel sensitive before the probe toggled it: 0; signal list items=1 selected=0; column visible after sidebar toggle off=1; pane switches live while their panes are off=1
[udpscope] panel switches: trigger=1/1 cursors=1/1 drove_toolbar=1 (pane switch / header bar button; `drove_toolbar` is the pane's own switch having moved the button)
Each pane's heading carries its own switch, and it is the same control as the header bar's button: both run the same action and both are re-read from the toolbar's state afterwards, so they cannot disagree. The two lines are the claim, in both directions:
pane switches live while their panes are off=1— read before anything turns either on, because it is the whole point of putting the switches in the headings: a control inside an insensitive parent cannot be clicked, so a switch that went insensitive with its own pane could never turn it back on.0is that defect: the switch is drawn, greyed, and useless.trigger=andcursors=arepane switch / header bar button, and the pairs must agree. The trigger's is moved from the toolbar end (these steps toggle that button to arm the trigger) and the cursors' from the pane end, so one line carries both directions of the sync.drove_toolbar=1is the second direction's assertion: the pane's own switch turned the cursors on and the header bar's button followed.trigger panel sensitive …=0is the panel body, not the panel: the heading row, with its switch, stays live.1here while trigger mode is off is the pane not being disabled at all.
[sidebar-probe]
[sidebar-probe] beside window=756 sidebar=320 collapsed=1 shown=1 button=1(sens=1)
[sidebar-probe] regained window=756 sidebar=320 collapsed=1 shown=1 button=1(sens=1)
UDPSCOPE_PROBE_SIDEBAR_MS reads the sidebar in the two states a window can put it in, and against a hide
that did not come from the toolbar: beside first, then the sidebar is forced into the overlaid state
(collapsed, which is what a window with no room beside the plot produces), then the split view's own
show-sidebar is turned off behind the button's back — an overlay swipe, or the widget hiding itself —
and regained reads 400 ms later.
sidebarmust 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:collapsedchanges where the sidebar is, never how wide it is.shownandbuttonmust 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=1onregainedis also the rule that a hide not asked for through the button does not win.sens=0with no source is correct. The button is insensitive and un-pressed while there is nothing to put in the sidebar;button=0is the button telling the truth about the panel rather than the panel having forgotten the wish. A source reconnecting must giveshown=1 button=1again — that is the reading awantedthat got eaten by the no-signals frame shows asshown=0 button=0(sens=1), which is the regression.collapsed=1means the window is narrower thankSidebarRoom(900), which is the expected reading on a scaled display, where the logical width is half what the screen looks like.
[side-tab-probe]
[side-tab-probe] page=signals accums=4 baseline recorded
[side-tab-probe] page=signals rows=4 accums=4 advanced=4 min_n=18
UDPSCOPE_PROBE_SIDE_TAB_MS is the one reading for the rule that the statistics accumulator keeps running
on the hidden page. It switches to the Measure tab, forces one pass so there is a baseline, then switches
away to Signals and reads again two seconds later -- with the measurements' page not current, so its widgets
are unrealized and measure 0x0.
advanced=4is the assertion: every accumulator gained passes while its page was off screen, andmin_n(the smallestnseen) must be non-zero too.advanced=0is the defect: something gated the accumulation on the visible tab, so the readout would gain a gap in its min/max/avg for exactly as long as the trigger was on screen. AGtk-CRITICALhere is the other half of the same rule -- a label or an allocation read on the unrealized page.- The probe enables a signal and assigns it to a plot itself, both deliberately. A signal starts disabled
(enabling is what starts acquisition), and a channel with no trace leaves the live window still -- and a
still window is the state
update_measures()is supposed to skip, so a probe that left it that way would readadvanced=0for a reason that has nothing to do with the tab. rowsis the measurement count andaccumsthe accumulator count; the two must agree, because one accumulator per measurement row is what makesadvanceda statement about every row.
[source-kinds]
[source-kinds] kinds=udps,e1608,flatudp,probe
[source-kinds] form udps: round_trip=1 valid=1
[source-kinds] form e1608: round_trip=1 valid=1
[source-kinds] form flatudp: round_trip=1 valid=1
[source-kinds] form probe: round_trip=1 valid=1
[source-kinds] panel disconnected: panels=1 present=1 sensitive=0
[source-kinds] panel connected: panels=1 present=1 sensitive=1
[source-kinds] capture button: present=1 sensitive=0
[source-kinds] command: before=0 after=1 drained=1
UDPSCOPE_PROBE_SOURCE_KINDS=2000, run as --source probe --source-opt rate=500, is the one reading for the
source-plugin path from the registry to the producer thread. The variable also registers the test-only probe
kind, so a run without it never shows that kind. The form line appears once per registered kind.
kinds=udps,e1608,flatudp,probe: a missing kind meansregister_builtin_sources()was not called beforeparse_args(), or the env var gate is wrong.round_trip=0: a form'swriteandreaddisagree about a key -- a renamedSourceConfigkey, or an absent key that was not defaulted.valid=0is a default configuration the form refuses. The e1608 form's sample sets address, port, scan_rate, mode, ai1, trigger, dir1 and plot0; a mismatch names one of those. The flatudp sample sets port, bind, multicast, iface, rate, endian and signals.panel disconnected: sensitive=0 present=1: disable, don't hide.present=0is a panel that was hidden or never added;sensitive=1is a panel that stays live with no source behind it.panel connected: sensitive=1: anything else meansapply_source_panelsacted on the wrong widget (it must act on the kind's own widget, not the scroller around it).capture button: present=1 sensitive=0: a source with no datagrams leaves the capture UI disabled, not hidden.sensitive=1is the button live for a source that has nothing to capture,present=0a hidden one.command: before=0 after=1 drained=1: the command reached the producer thread throughpost().drained=0is a queue thatpoll()does not drain on some path;aftergreater thanbefore + 1is a command run twice.
The UDPS run has no source- page in side_stack, because the UDPS kind has no panels.
[e1608]
[e1608] panel ao: present=1 sensitive=1 connected=1
[e1608] panel dio: present=1 sensitive=1 connected=1
[e1608] actions: ao0_level=1 do1=1 counter_reset=1
[e1608] stream: connected=1 signals=4 expect=4 names=AI0,AI1,DI0,Counter frames=274 rate=1000.000 ok=1
[e1608] calibration: ai0_mean=0.5000 expect=0.5000 ok=1
[e1608] capture button: present=1 sensitive=0
[e1608] state: connects=1 message=''
[e1608] decode: ok=1
[e1608] scan socket closed by the board
[e1608] reconnect: connects=2 connected=1 ok=1
[e1608] resumed: frames_before=612 frames_after=662 ok=1
UDPSCOPE_PROBE_E1608=1500 (the first eight lines; frames= and the exact mean vary) and
UDPSCOPE_PROBE_E1608_RECONNECT=8000 (the last two) run as --source e1608 against tools/mock_e1608.py
with the options in verification.md. The line between decode and reconnect is the source's own stderr message
about the drop, not a probe line (its text is the error the source met, once per distinct message). connected=0 throughout, with
actions: skipped (not connected), is the no-board run; it is a reading and not a failure, and it also prints
the one [e1608] handshake with ...: Connection refused line, stream: ... signals=0 expect=1 ... ok=0,
calibration: ai0_mean=nan ... ok=0 and state: connects=0 message='handshake with ...'.
panel ...: present=1 sensitive=<connected>: disable, don't hide.present=0is a panel that was never added;sensitivethat differs fromconnectedisapply_source_panelsacting on the wrong widget.actions: ao0_level=1 do1=1 counter_reset=1: each control was found by its widget name (e1608-ao0-level,e1608-do1,e1608-counter-reset). A0is a renamed widget, or (do1)dir1=omissing from the run. The effects are read off the mock's log, not here: a DAC write near 2.5 V (within 0.01),DOUT 0x02,COUNTER_RESET. A control that was found but whose effect is missing is a command thatpost()did not deliver orpoll()did not run.stream: signals=4 expect=4 rate=1000.000 ok=1: the enabled channels plus the plotted digital lines plus the counter, at the scan rate.signalsbelowexpectis a signal the source did not declare;rateoff by a factor is the pacer computed from the wrong aggregate.okalso requiresnames=to be the enabled analog inputs, then the plotted digital lines, thenCounter, in that order: swapped channels readok=0.calibration: ai0_mean=0.50xx ok=1: the mock's AI0 is 0.5 V under a 5 Hz sine of 0.1 V, read through non-trivial factory coefficients; the mean over two periods lands on 0.5 V only whendecode()applies them. About0.2043is the uncalibrated reading (the calibration read failed, orcalibrate=0);nanis a run that never got AI0 (not connected, or AI0 not enabled).capture button: present=1 sensitive=0: a source with no datagrams leaves the capture UI disabled, not hidden.state: connects=1 message='': a non-empty message at this point is a refusal that happened (for examplecalibration memory refused; using nominal values), which the stream then ran through.decode: ok=1: a fixed raw buffer against hand-computed volts (intercept 100, BIP10V and BIP5V, the code clamp at both ends). It needs no board;ok=0is a change to the calibration arithmetic.reconnect: connects=2 connected=1,resumed: ok=1: the mock dropped both links at about 3 s; the source reported it, retried after 2 s and the frame count moved on.connects=1is a source that never noticed the drop (neither the scan read nor the status check raised it),resumed: ok=0is a reconnect that did not restart the scan (it also reads 0 whenconnectsis still 1, since a frame count that grows on a link that never dropped proves nothing).
[flatudp]
[flatudp] codec: ok=1
[flatudp] datagram of 22 bytes, expected 21, check the signal list
[flatudp] stream: connected=1 signals=7 names=Trigger,Ia,Cnt,Wave[0],Wave[1],Wave[2],Wave[3] expect=Trigger,Ia,Cnt,Wave[0],Wave[1],Wave[2],Wave[3] frames=368 rate=1000.000 ok=1
[flatudp] channels: count=7 expect=7 ok=1
[flatudp] continuity: Trigger_breaks=0 Ia_breaks=0 Cnt_breaks=0 Wave[0]_breaks=0 Wave[1]-[0]_breaks=0 ok=1
[flatudp] wrong size: count=4 message='datagram of 22 bytes, expected 21, check the signal list' ok=1
[flatudp] capture button: present=1 sensitive=1
[flatudp] state: valid=3683 wrong=4
[flatudp] silence: connected=0 model_connected=0 after_ms=4300 ok=1
[flatudp] resumed: connected=1 frames_before=401 frames_after=410 ok=1
and, for the waveform-only list Wave:int16[4]w:
[flatudp] stream: connected=1 signals=1 names=Wave expect=Wave frames=3737 rate=4000.000 ok=1
[flatudp] channels: count=1 expect=1 ok=1
[flatudp] continuity: wave_breaks=0 ok=1
UDPSCOPE_PROBE_FLATUDP=2500 (the first lines; counts vary) and UDPSCOPE_PROBE_FLATUDP_GAP=1500 (the last two)
run as --source flatudp against tools/mock_flatudp.py with the options in verification.md. The second line is
the source's own stderr message, printed once per distinct wrong size, not a probe line. With no sender the stream
lines read connected=0 ... frames=0 ... ok=0, continuity is skipped and the capture button reads
sensitive=0: a reading, not a failure. A run without --bad-every reads wrong size: count=0 ... ok=0 for the
same reason.
codec: ok=1: thesignalstext round-trips (including awrow and a bracketed count of 1 on a waveform), a hand-built 24-byte datagram decodes to the hand-computed values in both byte orders (uint8254,int16-2,float321.5,uint64[2]1 and 4294967298,int8-1), so do afloat64,uint16,int32,uint32andint64;decode_channelsputs achannelsvector incountcolumns and a waveform in one; and the validity rules refuse what they should (a waveform of count 1, a waveform mixed with another row or of another length, 257 channels). It needs no source, so--source probeshows it too.ok=0is a codec defect: a swapped byte order, a wrong width or a rule that does not fire.stream: ... ok=1: the model's signals are the list's channels in order, each at its declared rate:ratefor a scalar or achannelselement (namedName[k]),rate * countfor a waveform.namesout of order is a decode that mixed rows; a rate that is not the expected one is asampling_ratethat did not reach the model (the ring and the decimation are then sized for the wrong cadence; a waveform atrateinstead ofrate * 4has a time axis 4 times too long).channels: count=7: one channel perSignalInfo, which ischannel_count(rows): a scalar 1, achannelsvectorcount, a waveform 1 (itscountvalues are consecutive samples of that one channel).continuity: <name>_breaks=0 ...: the mock's row k carries(i * (k + 1) + e) % 100, so consecutive samples of a channel from row k step by k + 1 modulo 100 (Trigger1,Ia2,Cnt3,Wave[0]4), andWave[1]-[0]is the same-sample difference of two elements, which must be 1. A break is a lost or reordered datagram, a batch flushed with the wrong number of samples, an element mapped to the wrong channel, a wrong byte order or ingest decimation that is not 1:1. A waveform list readswave_breaks: samples step by 1 inside a datagram and by2 - countinto the next, at a phase the probe finds from the first such step and then holds; a non-zero count is a block of the wrong length or one that dropped samples. (Before thechannelsform, a mixed list showed ~400 breaks here: a scalar next to a vector was zero-padded to the vector's length.)wrong size: ok=1: wrong-size datagrams were counted and the message names the received and expected size. The message must also be the only stderr line of its kind: one per distinct size, however many arrive.capture button: present=1 sensitive=1: the kind reports datagrams, so the capture UI is live (contrast[source-kinds], where the probe kind leaves it disabled). With no stream it readssensitive=0.silence: connected=0 model_connected=0 after_ms=...: 2 s without a valid datagram raisedDisconnected(after_msis the wait from the stream coming up to the drop; it only reads the gap's start in the app's clock).connected=1is the timeout not firing or wrong-size datagrams refreshing the silence clock (they must not);never connectedis a stream that never came up before the probe gave up after 20 s.resumed: ok=1:Connectedwas raised again and frames resumed.connected=0here means the source never recovered after the sender came back.
[prefs-probe]
[prefs-probe] opened=1 rows=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000
[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=1is the dialog having come up and its rows having resolved by id. The file is checked by the build (xml-stripblanks) and byGtkBuilderat load; a missing id is the one mistake neither sees, and reading a null row on the first change is what it costs.diris the directory the row is offering -- the preference if there is one, otherwise the default written out in full -- andstoreis the directory the cache is really using.storemust bedirplus 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, keep7, relative time1, themedark. Any field that differs means the row, the write or the parse dropped it.live=1is 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 noevent_cache_dirat 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=1is 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=1is the icon theme knowingfolder-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=1is the window having come up;apply=0would mean its Apply button was never stored, which would make the click below test nothing.diris 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) -- andstoreis the directory the cache is really using.storemust bedirplus the source's folder name, which is the reading that says the field shows the path in force rather than a plausible string.dirhere 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, keep7, relative time1, themedark. Any field that differs means the form, the write or the parse dropped it. live=1is 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=1is 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=1is the icon theme knowingfolder-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 noevent_cache_dirat all. A path in the file is a user's choice, and typing the default is not one -- pinning it there would stop the setting following the cache directory it is named after.
mnemonics= in the two preference probes
[prefs-probe] mnemonics=4/4
[sources] form mnemonics=9/9
How many of the dialog's rows have use-underline set -- the property that makes a _ in a row's title a
mnemonic. It is FALSE on AdwPreferencesRow by default, which is why these titles were drawn with a
literal underscore; anything below the full count is that bug back. Read by property name rather than
through one row type, for the reason in invariants-gtk-and-chrome.md. The form's line appears only when
UDPSCOPE_PROBE_NEWHOST drives the form. It reads 9/9 since the chooser gained its Source type row, which has a
mnemonic of its own (a non-UDPS kind's form has fewer rows: the probe kind reads 6/6).
trigger panel:
[udpscope] trigger panel: boxes=16 controls_inside=15/15 search_widgets=4/4 search_inside=4/4 advanced_widgets=5/5 advanced_inside=5/5
The trigger panel's frame, read from the window: the .ui's own panel (trigger_body), not the box
this app wraps it in — the heading row and the locked summary are the code's arrangement, and reading the
wrapper made this line report boxes=2 … search_inside=0/4 the moment the heading moved out of the file.
That is the failure this line is for, so it is worth knowing it can happen to itself. boxes is the row
boxes the .ui holds and controls_inside
counts the sixteen controls the code built and then appended into those rows -- the last three being the
noise band and the HF reject, whose rows arrived with the noise-rejection work, and the persistence depth
counts the fifteen controls the code built and then appended into the panel's own rows -- the noise band and
the HF reject, the persistence-depth spin and the record switch among them, and the Arm/Force pair not,
because that row holds buttons and a boxes count reads the rows either way.
boxes is 16 since the trigger's display settings moved into the Advanced expander: the multiple-event
depth and the four density controls left the panel's own child list, and the expander that holds them is not a
box. Those five are what advanced_widgets/advanced_inside count, for the same two reasons the search pair
exists -- the ids resolving, and the controls being in the block -- and an expander's child is not in the
widget-parent chain, so a control there is invisible to controls_inside by construction (see
invariants-gtk-and-chrome.md). The walk for a block therefore starts at its own expander,
gtk_expander_get_child(GTK_EXPANDER(app.search_exp)): the first version of this line took "the first
expander in the panel", which stopped naming the search block the moment a second expander was added before
it -- and would have read search_inside=0/4 on a panel that was entirely correct. [defaults] trigger:
prints the same panel's spins in tree order, so the two lines together read what the file gave and what the
code put in it.
[persist-probe] and persistence:
[persist-probe] held depth=4 kept=3 bytes=20998152 budget=209715200 dropped=0 signals=5 channels=4 layers=12 want=12 placed=12 alphas=[0.1250,0.2500,0.5000] ages=[3,2,1] row0=[1:'sine (A)'@0.5000 2:'sine (B)'@0.2500 3:'sine #4'@0.1250] drawn=12
[persist-probe] held axis=[-0.2500..0.2500] pin=[-0.2500..0.2500] snap=[-0.2500..0.2500] n=250001 ev=[-0.2500..0.2500] ev_is_snap=1 pin_is_snap=1
[udpscope] persistence: depth=4 kept=3 bytes=20998152 budget=209715200 dropped=0 layers=0 want=0 placed=0 schedule=[0.1250,0.2500,0.5000] alphas=[]
The persistence display: the last depth triggered acquisitions drawn behind the current one, each older one
at half the previous one's brightness. Run with one signal per row and a 0.5 s window against the mock at
500 kS/s, which is what the two lines above are.
alphas= is the assertion, oldest first: 0.5000,0.2500,0.1250 — halving, with ages=[3,2,1] matching
one for one. A flat, ascending, or age-gapped list is the decay not being applied or applied backwards (the
rule is in invariants-trigger-and-pinning.md). row0= is the same
schedule read per slot, with each layer's name — sine (A) one acquisition back, sine (B) two, sine #4
three, the accumulate path's own suffix vocabulary. layers= is the layer slots that exist,
want= the depth's whole demand across the rows, placed= what fitted: the three agreeing is a display
that shows every age it was asked for, and placed < want is a layout with no free trace slot left (a row
of S signals shows floor((8 - S) / S) ages of each, so the default four-signal row shows one).
kept/bytes/dropped are the retention, and they bound what those slots can draw. kept=3 of the
3 asked at bytes=20998152 (21 MB: three acquisitions of a 0.5 s window at 500 kS/s over four channels,
~7 MB each) against budget=209715200, the ring's own --buffer-mb. drawn= is how many layer slots are
actually bound to a retained acquisition, and it is a different number from placed= exactly when the
budget bit — which is the run to take next, on the fast mock with a long window:
[persist-probe] settled: kept=1 of 7 after 270 polls
[persist-probe] held depth=8 kept=1 bytes=72798888 budget=209715200 dropped=0 signals=5 channels=4 layers=28 want=28 placed=28 alphas=[0.0078,0.0156,0.0312,0.0625,0.1250,0.2500,0.5000] ages=[7,6,5,4,3,2,1] row0=[1:'sine (A)'@0.5000 … 7:'sine #8'@0.0078] drawn=4
[persist-probe] held axis=[-1.0000..1.0000] pin=[-1.0000..1.0000] snap=[-1.0000..1.0000] n=2600000 ev=[-1.0000..1.0000] ev_is_snap=1 pin_is_snap=1
[udpscope] persistence: depth=8 kept=2 bytes=145598128 budget=209715200 dropped=2 layers=0 want=0 placed=0 schedule=[0.0078,0.0156,0.0312,0.0625,0.1250,0.2500,0.5000] alphas=[]
UDPSCOPE_PROBE_PERSIST_DEPTH=8 UDPSCOPE_PROBE_PERSIST_WINDOW=2 against the mock at 1.3 MS/s: one retained
acquisition is 72.8 MB, so the 200 MB budget keeps two and drops two — kept below the depth, dropped
non-zero, and drawn=4 against placed=28: the layer slots exist and the oldest seven ages per row draw
nothing at all. That is the memory bound as a reading, and settled: is the wait saying so (the retention
can never reach the depth, so the probe reports what stopped growing instead of waiting out its cap — its
kept can therefore be mid-fill, which is why the dump's line below is the settled one). The
in-doubling of alphas= there is the same rule at full depth: 1/128 at the eighth, which is why the depth
stops at 8. Do not read this run's frame cost: as persistence's: a 2 s window at 1.3 MS/s costs ~90 ms a
latch in publish cost: on its own, with or without persistence.
ev_is_snap and pin_is_snap must be 1, and that is the rule about the past: the pin the held event's
axis is re-stated from (event_data_window()) is the snapshot's own window, so the older acquisitions —
which are bound event series on the same rows — moved neither the axis nor the framing; both runs above read
axis = pin = snap = [-0.2500..0.2500] with n=250001. They are read on a frame that is bound (the probe
waits for one), because a latch landing mid-frame leaves the mode Held while the rows still hold the live
ring's bindings, and a reading taken there says nothing about either.
[density-probe] and density:
[density-probe] arming on 'square', window 0.5 s, display off
[density-probe] off enabled=0 fade=acquisitions tau=1.000 factor=0.500 rows=0 traces=0 cells=0 ink=0.0 peak=0.0000 ratio=1.000000
[density-probe] on enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 traces=4 cells=1044714 ink=99403.0 peak=1.0000 ratio=1.000000
[density-probe] step-acquisitions enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 traces=4 cells=1044714 ink=149104.5 peak=1.5000 ratio=1.500000
[density-probe] time-before enabled=1 fade=time tau=1.000 factor=0.500 rows=4 traces=4 cells=1044714 ink=121738.0 peak=1.2247 ratio=1.000000
[density-probe] step-time enabled=1 fade=time tau=1.000 factor=0.500 rows=4 traces=4 cells=1044714 ink=44784.9 peak=0.4505 ratio=0.367879
[density-probe] frame cost: tick before=3.75 ms (145 frames) after=3.59 ms (24 frames) draw before=3.93 ms (125) after=3.23 ms (6)
[udpscope] density: frames=918 rows=4 traces=4 cells=1044714 grid_bytes=4178856 ink=279073.5 peak=2.2 upload_bytes=1044714
The phosphor density (Plot::set_density(), the trigger pane's persistence switch): the axes
rect in device pixels up to kDensityCellCap (262 144 cells, 1 MiB of counts) as an intensity
image per trace, in that trace's own signal colour, every acquisition accumulated into it (the
newest included), drawn in place of the traces and fading by a count or by the clock. Past the cap
the plotter scales the rect down uniformly and magnifies the image back over it with a linear
filter, so the number this probe reports is bounded whatever the window is. The numbers are the
plotter's own -- DensityGrid::ink()/peak() summed and maxed over the traces the display is on,
which is the same source the library's tests read -- and the probe drives the panel's own
widgets for the switch and the fade, so what it measures is this app's wiring.
offis the display without it:rows=0 traces=0, so there is no grid at all and both numbers are zero. That is also the run the frame cost is read against, and it is a plain run -- the density is off by default and nothing draws it.onis one sweep:traces=4-- one grid per signal, four rows of one signal each -- andpeak=1.0000exactly, withink=99403the cells that sweep covered. A cell takes 1.0 once per trace per sweep however many of the trace's own samples crossed it, which is what makes a sweep's peak one rather than the eighteen a per-hit deposit read on the same run -- and what makes the sweeps behind it visible at all.step-acquisitionsdeposits one more sweep:peak=1.0000 -> 1.5000, which is1 + 0.5 x 1-- the panel's factor 0.5 applied to what was there, then the new sweep's own pass on top. The ratio is not a constant to expect -- the acquisition on screen can move between the two reads, so what it says is "the old image halved and a pass landed on it"; the exact relation is the library's case (a_density_halves_per_acquisition_and_keeps_the_newest_brightest), where the same frame is deposited twice.step-timeis1/eby construction: one step ofdt = tauthrough the plot's owndecay_density(), soratio=0.367879isexp(-1). The leg before it (time-before) reads the same image under the time fade (a peak between 1 and 2,1.2247here), and the pair is the reading that says the panel's constant reaches the grid.frame cost:is the A/B inside one run: the accumulators as of the tick the display was switched on against the frames after it, so both means are the same stream, the same layout and the same mock. The "before" side is a mean that includes the run's startup frames (the first paints are the expensive ones), so it is a high-water mark and not a steady-state mean: at four rows of ~261 000 cells each (the cap) the pair above readstick 3.75 -> 3.59 ms, i.e. the switch is inside the startup noise, and a quiet machine reading the same line before the cap readtick 0.62 -> 9.53 msat 2.2 M cells. The reading that prices a display at a given window is the library's, taken headless and against a fixed geometry, in the plotter'sdocs/data-and-performance.md: a 3040x1700 framebuffer with four rows of one trace and a 2 s window, where the grid pass alone was 12.9 ms of tick and 12.8 ms of draw a frame and 4.9 MB uploaded before the cap and 3.9 / 5.6 ms and 1.0 MB after it, over the same frames.cells/grid_bytesare the per-signal memory figure: at most 262 144 cells and 1 048 576 bytes per signal, and the four signals here read 1 044 714 cells / 4 178 856 bytes because a 1349x424 device axes rect is already past the cap. It is the cap and the trace count, and neither the trigger nor the window beyond the cap has anything to say about it -- the app's default layout, four signals in one row, is four grids for that one row and 4 MiB whoever dragged the window where.- The dump's
density:line is captured on the last tick the display was on, not read at exit: the exit probe switches the trigger off before it dumps, which takes the display with it, so a dump that asked the plots would sayrows=0about a run whose tail had the image up.upload_bytesis the most any one frame uploaded -- one byte a cell per trace, over the grids that moved -- and not the last frame's, for the same reason. - A frame the display is drawing is not a
[notrace]frame.Renderer::draw_traces()returns while a density is on, so every frame of a persistence display stages no trace; the paint probe skips its traceless accounting for those (they are the display working, not "a frame asked to draw something it had nothing for") and the[notrace]line -- one per frame, and so behindUDPSCOPE_PROBE_NOTRACE-- is not printed for them. A density run'straceless paints:therefore no longer counts them: it read653 (637 after the first bind)for a run whose density was up six hundred frames.
Read peak as the display's ceiling: with the default full_scale of 1.0 a cell at 1.0 is full ink
(the trace's own colour), so a steady display under the acquisition fade sits between 1 and 2, and a peak
well above that (the per-hit deposit read 189 after two sweeps) is the defect this probe's on leg now
catches.
And N = 1 is the display without the feature, which is the same command at
UDPSCOPE_PROBE_PERSIST_DEPTH=1:
[persist-probe] held depth=1 kept=0 bytes=0 budget=209715200 dropped=0 signals=5 channels=4 layers=0 want=0 placed=0 alphas=[] ages=[] row0=[] drawn=0
[persist-probe] held axis=[-0.2500..0.2500] pin=[-0.2500..0.2500] snap=[-0.2500..0.2500] n=250001 ev=[-0.2500..0.2500] ev_is_snap=1 pin_is_snap=1
Nothing retained, nothing planned, nothing bound, no bytes — and the framing line identical field for field,
which is what "the same display as before the feature existed" means as a reading. That run is also the
baseline for the exit dump's frame cost: line, because the tick mean is a mean over the whole run and the
depth is raised at UDPSCOPE_PROBE_PERSIST_MS (2.5 s of 16 s), so the difference between the pair is a
lower bound on what the layers cost. At the scale above (0.5 s window, 500 kS/s, four rows, one signal
each):
| run | tick mean | draw mean | paint mean | frames |
|---|---|---|---|---|
..._PERSIST_DEPTH=1 |
0.82 ms |
11.10 ms |
15.96 ms |
936 |
..._PERSIST_DEPTH=4 |
1.18 ms |
28.92 ms |
34.60 ms |
470 |
Thirteen extra traces across the four rows (four signals plus nine layers, the twelfth layer's signal having
no free slot in its row) for +17.8 ms of draw a frame, i.e. ~2.6× the ink of the same plot without
persistence; tick moves with it by a few tenths, mostly because there are half as many frames to average.
[udpscope] persistence: is the same state at the end of the run, and the one line the standard self-check
prints without a probe: depth, the retention (kept/bytes/budget/dropped), the plan
(layers/want/placed, 0 here because the exit probe's go-live has already cleared the layers —
layers=0 in that line is correct, not a defect), schedule= the alphas the depth asks for oldest first
(the rule; the probe's alphas= is what the rows were built with), and alphas= for row 0.
signals panel:
[udpscope] signals panel: labels=2 list=1 rows=5 signals=4 math=0 divider=1
[udpscope] signals panel: labels=2 list=1 rows=9 signals=4 math=4 divider=1
The signals panel's frame read from the window, and in the same line the shape of the one channel list:
the heading and the hint the .ui gives it, the list the rows go in, how many rows that list holds
(rows), how many of them are streamed signals (signals), how many math channels the app has (math),
and divider, which is 1 when rows == signals + 1 + math and 0 otherwise. drag_sources must be
rows - 1 — one per signal row, the MATH divider not being draggable. Dragging a signal onto a plot is
how a plot is filled, so it has to survive both pane modes: the locked list drops the tick, the menu and the
+, and this is the reading that says the drag did not go with them. (0 is a list that has become a thing
you can read but not use.) labels=1 is a .ui that lost
one of the two labels — counted over the panel's subtree above the list, because the rows' own name
and unit labels are the code's and the heading sits in a heading row beside the pane's [edit] button — list=0 one that lost the list -- both of which otherwise show as a panel quietly
missing a piece -- and divider=0 is the merged list that lost its +, the one control that can create
a math channel. The first line above is a run with no math channels at all: the divider is there anyway,
because a + that only appeared once a channel existed could never create the first one. panels: … signals=WxH row_h=26 is the other half: the list's own geometry and the height a row really takes, which
is what the compact-row CSS rule is for.
events pane:
[udpscope] events pane: compare=1 accumulate=1 list=1 revealer=1 nested=1
The events pane's frame read from the window: the two group actions, the list, and the revealer the .ui
builds, plus nested — the list being inside that revealer, which is the one thing a lost id cannot show
(a list parented elsewhere reads 1 everywhere else). [events] is the other half, and it drives the pane end
to end: recording off, recording on, the trigger, the history button that reveals it, a row click and the
replay, then Compare and Accumulate — so the file's buttons are read by being used.
[windowpop]
[windowpop] opened=1 visible=1 click_gestures=2
[windowpop] after a press elsewhere: active=0 (want 0)
[windowpop] after a press on the button: active=1 (want 1)
UDPSCOPE_PROBE_WINDOWPOP_MS: the time-window popover and how it closes. Autohide is turned off for the
probe, exactly as the menu probe does it -- a popover dismisses itself when GTK holds its grab, and a bare
xvfb-run has no window manager to hand one out -- so what is measured is the state a user meets when the
grab goes missing, where the press has to close the bubble anyway. click_gestures reads that the controller
is connected as well as that its handler works; the last press is on the button itself, which must be left
to its own toggle (want 1 rather than 0).
[drop-probe] and what a refused drop says
[drop-probe] full row refused the drop: 0 (want 0)
[drop-probe] refused feedback: notice='plot 1 holds four signals already: drop it on another plot, or add a row with the layout editor (L)' warning=1
[drop-probe] the status line, composed: warning=1 status='plot 1 holds four signals already: ... | live · 9.97 kS/s · 2 s window'
UDPSCOPE_PROBE_DROP_MS. The first line is the refusal itself (a full row takes no more signals); the
second is that it said so -- the message and the stock .warning class the status line carries while a
notice is fresh; the third is the composed line the user reads, which is why the label is only checked here
and not in the second: the status line folds the notice into itself on its own cadence, so a beat after the
refusal it may still hold the previous text.
[enable-all-probe] and switching a signal off
[enable-all-probe] 4 channel(s) enabled and assigned to plots, notice='connecting to 127.0.0.1:21023 -- planning the 200 MB buffer'
[enable-all-probe] switched 'sine' off: traces_left=0 (want 0) notice=''sine' is off: 1 trace left the plots'
The first line's notice is the startup notice, and it is read here because this probe is the
earliest thing that reads the status bar. It is set as the window is presented -- before the first
CONFIG can take the model's mutex, so before anything can block -- and it names the host and the
buffer being planned, because that is the window's only answer to "why is nothing happening yet".
An empty notice here is the window that used to come up silent while a large buffer was allocated:
at --buffer-mb 4000 that is the dialog disappearing and then over a second of a frozen, blank
window. (The buffer is allocated on the producer thread under ScopeModel::mutex(), which the frame
tick also takes, so the freeze is real and cannot be papered over in the UI -- see the
[udpscope] buffer allocated: line below.)
UDPSCOPE_ENABLE_ALL_MS enables every signal and assigns the acquired channels across the rows, then
switches one off through its own acquire tick in the signal list -- the tick is a box inside the row, which
is the shape signal_tick_visible() walks -- and reads the plots back. traces_left=0 is the rule: a disabled
signal leaves every plot rather than staying as a slot that draws nothing, and the notice is what says so.
The switch step runs late, so that a math probe armed behind this one has installed its graphs by then; the
two halves after it are the other rule. math_off is the derived channels that read this signal -- they cannot
be computed from a signal nobody acquires, so they go off with it -- and math_on is the ones that do not,
which must be untouched:
[enable-all-probe] switched 'sine' off: traces_left=0 (want 0) math_off=[math x2,math3] math_on=[math lp] notice=''sine' is off: 1 trace left the plots; math off: math x2 and 1 more'
trig engine: — the producer's per-second engine line
[udpscope] trig engine: ch=1 armed=1 pending=0 elapsed=0.0000/0.1000 s accum=0 history=0.0000/0.4000 s pre_pct=80 v=1 now_t=2.520 holdoff_until=2.520 ring=[2.5200..2.5200] n=567000
Printed by the producer once a second while a trigger is configured, so a run with no trigger at all shows
none. elapsed/post_want is the capture's progress in stream seconds, accum how many samples that window
holds, history/pre how much pre-trigger history the engine has, and now_t the stamp of the last sample
the engine was fed (engine.last_t()), not the stream's time.
ring=[oldest..newest] n= is the store's own retained span and sample count, and it is the pair that says
which of two failures a stalled display is. n counts every stored sample, so it climbs at the stream's
rate whenever data arrives at all:
-
ringstanding still whilenclimbs is the clock: samples are stored but every one carries the same stamp, so the axis cannot advance and the trigger'shistorynever fills. The stamps are pinned at the file view's last value because a restarted client's stream clock sits behind it: the advance test rejects the new packets, four rejections abandon the declared time signal, and the arrival-clock fallback derives its epoch from the surviving mark.Acquisition::start()now resets the store and re-bases that mark, soringmust move within a stream round-trip of Go live. Measured after going live from an event view:ring=[2.5200..2.5200] n=567000 → n=1131000 → n=1689000, one second apart, withnow_tandholdoff_untilboth pinned at2.520— the file view's last stamp. The producer's clock was frozen, not lagging, and the display stayed blank until something re-planned the store. It has a second entry, and one of its own readings names it: under--relative-timea re-plan that shortened the plan's interval put every later stamp behind the clock the run had already reached, and the frame loop's clamp held it there (see the relative-clock invariant in invariants-data-path.md). The line below is printed once per connection the first time that happens, so a run that reads it has its whole diagnosis on one line — and the absence of it beside aringthat advances is the reading that a re-plan no longer moves the clock:[udpscope] warning: the clock stalled at 0.000384 s: 500 samples stamped [0.000000..0.000384] s are all behind it, so the timeline stands still while frames keep arriving -- nothing can be drawn and no trigger can latch. The plan's interval is 7.69230769e-07 s now; ring n=500history=0.0000/<pre>on everytrig engine:line beside anthat climbs is the same state seen from the engine's side, and it is what makes a trigger-mode display blank: with no latch the display never leavesWaiting, whose bind is nothing by design. Measured on the mock at 1.3 MS/s with a session that defines a LowPass channel, in single mode: before the fixring=[5.4990..5.4990]for the whole run,latches=0,display=nothing, and the dump readtraceless paints: 1519 (1519 after the first bind); after it,ringruns[0.0058..0.2446] → [0.0058..1.9969],historyreaches the pre-window,latches=1and the frozen event readsn=2599999 data=[-1.0000..1.0000]. The same run with the math channel disabled never showed it at all — which is the control that names the entry, not the filter. -
ringadvancing whilenow_tdoes not is the feed: the store is moving and the engine is not being handed the samples, which is a trigger-path question and not a clock one.
Neither can be read off the engine's own fields, which is why the store's pair is here.
[udpscope] buffer allocated:
[udpscope] buffer allocated: 4 channels, 839 s span, 74 ms
Not a probe — it is printed by ScopeModel::rebuild_locked() in every build, because this is the one
operation in the application whose duration a user can see with no instrument: the store's rings are
std::vector<std::atomic<T>> and so value-initialised, which touches the whole --buffer-mb
(roughly a second per GB, mostly first-touch page faults), and it runs on the producer thread holding
ScopeModel::mutex() — which the frame tick also takes. So the line's ms is a frozen window, not
bookkeeping.
It is the count that is the assertion, not the duration. One line per real change of plan. A
rebuild whose plan describes the storage already allocated clears the store instead
(Partition::same_storage_as() → BufferStore::reset()) and prints nothing, so a startup or a mode
change that reads more than one or two of these is a re-plan that should have been coalesced. Startup
asked for fifteen; it now asks for two or three. A run at --buffer-mb 4000 measured those
coalesced allocations at 1148 ms each and a max frame tick of 1186 ms — the same number, which is the
evidence that the freeze is this call and not something downstream of it.
The companion reading is the startup notice on the [enable-all-probe] line above: the allocation
cannot be made free, so the window must at least say what it is doing while it happens.
[math-probe] graph edit:
[math-probe] graph edit: stored_slots 800 -> 900 (want the second at least the first, and non-zero)
UDPSCOPE_PROBE_MATH_MS applies an edit to one graph through the editor. The plan is a partition over the
channels and an edit does not move it, so the store survives: the count grows (the stream keeps filling
while the edit runs) and never falls to 0, which is what a rebuild would do. Every channel then still reads
N samples vs M time slots with N == M, so no channel was left behind either.
[mathwrap-probe]
[mathwrap-probe] installed 'math wrap' on 'sine': channel=1
[mathwrap-probe] wrapped: base=864 slots=66400 (want base > 0)
[mathwrap-probe] after edit: produced=72400 slots=72400 (want equal) y=[-0.5000,0.5000] (want +-0.5, was +-2.0000)
UDPSCOPE_PROBE_MATHWRAP_MS, and it must be run with a small --buffer-mb and given time to
saturate it: --buffer-mb 1 against tools/mock_udpstreamer.py --rate 20000 --batch 200, with
UDPSCOPE_PROBE_MATHWRAP_MS=1500 UDPSCOPE_EXIT_AFTER_MS=25000, reads the lines above. It installs one
Gain graph of 2 over the mock's unit sine, polls (100 ms) until the ring has wrapped, and only then
edits the gain to 0.5 — the same channel list with one node changed, which is the in-place recompute and
not a replan.
base > 0 on the second line is the precondition and not a result: at base == 0 both readings below are
identities, so base=0 means the run tested nothing — it was too short, or the buffer too large. This
is also why math_probe cannot stand in for this one: every edit it makes follows a structural change,
which replans the buffer and restarts every ring at absolute 0.
The third line is one reading per half of recompute_math_locked(). produced against slots is the
derived ring's absolute count: short by exactly base is the renumbering clear_to() exists to prevent,
and live_window_locked() clamps against every channel's produced(), so that one lagging channel is
n = 0 for the live view, every pause view and every trigger capture — the reported freeze. y is the
pyramid's, so it describes the published window rather than the samples, and the edit shrinks the
gain for that reading to exist: a pyramid left unpublished still describes the replaced ±2 samples, a
window holding both quotes the larger, and a growing gain would therefore read correct either way.
y=[-2,2] here is republish_from() missing.
[noise-probe]
[noise-probe] setup channels=1
[noise-probe] trigger band: bare=49/2 band=7/2 ratio=7.00
[noise-probe] trigger hf: hf=7/2 band+hf=6/2 band=7
[noise-probe] trigger delay: clean=3 filtered=4 delay=0.002206 want=0.002206 ratio=1.000
UDPSCOPE_PROBE_NOISE_MS, and it needs the mock's spiked sine -- --spike-every 0.005 --spike-amp 0.5 --spike-width 2 -- where every other probe uses the plain mock. It runs as four stages, and the chain is the point:
- At
UDPSCOPE_PROBE_NOISE_MSthe setup installs the de-noise graph throughapply_math_channels(). That is a structural rebuild that drops the buffer's history, so the five seconds to the next stage are what let the ring refill before anything scans it. - Five seconds later the legs run. They enable the signals they measure themselves, because a probe must
not depend on another probe's side effects (
enable_all_probeends by switching one off again, which is howsinewent missing from the first run of this one), and wait 2.5 s for the ring to refill after that structural change. The legs then each search the whole retained buffer throughBufferSearch, one after another without yielding, so all six see the same buffer -- and the five measurements come next, over the same retained history. - Two and a half seconds later the de-noise channel is enabled, by name, through the same switch the
panel drives. The setup installs it disabled and this stage turns it on, because an acquired channel is
a share of the ring: a fifth one shortens every channel's retained span, and every figure above scales
with that span -- the six leg counts, and
pkpk bare, which is a peak-to-peak of the retained window. The readings above are the older feature's proof, so the new channel stays out of the acquisition plan until they have all run. - Two and a half seconds after that, the de-noise reading. A math channel starts producing when it is applied and acquired, so the channel's first sample is the first one after stage 3, and a peak-to-peak needs a window holding a trough and a crest: twelve cycles of the 5 Hz source.
With UDPSCOPE_PROBE_NOISE_MS=4000 the legs therefore land at about 11.5 s and the last line at 16.5 s,
which is why the documented run needs UDPSCOPE_EXIT_AFTER_MS=18000. Four seconds and not three: the
enable-all probe's own last act, a click on the front channel's tick at ENABLE_ALL_MS + 2000, rebuilds
nothing itself but is rebuilt under when a graph is installed at its instant -- see verification.md.
setup channels=1-- one math channel was installed, and it is printed by the first stage, before any leg: it is the line that says the graph exists at all. A count above 1 means a session file from an earlier run was loaded on top of it ($XDG_CONFIG_HOME/udpscope); those channels load disabled, so they do not change the partition, and a clean run of the documented command prints1.trigger band:--bareis the bare comparator on the spiked sine (every impulse that lands near zero carries it across, so the count is dominated by spikes),bandthe same with a 0.8 band.ratiomust be at or above 5, andbandabout the number of sine cycles retained. Neither count is a fixed number: both scale with the retained buffer's length (7 at a 2.5 s buffer, ~30 at 6 s), so the ratio and the agreement between the legs are the assertions. Arationear 1 means the band is not being applied --decide()is not reached fromfeed_edge.trigger hf:--hf(no band, 50 Hz reject) andband+hf(both) must be within 2 ofband: the reject removes the impulses on its own.hfnearbaremeans the pole never lefta == 1(checkconfigure()recomputes it, and thathf_fcreached the condition);band+hfnear 0 means the reject is attenuating the sine itself, which would be the Nyquist clamp clamping 50 Hz.trigger delay:-- the reject's group delay, measured onsquare, which carries no impulses: a one-pole turns a -1 -> +1 step into a zero crossing at exactlytau*ln2, sodelaymust readwant = 0.002206 sandratiobetween 0.7 and 1.4. Arationear 0 is a pass-through; a large one meansais far too small, i.e.dtnever arrived at the detector. This is the leg the mock's--square-harmonics 0is for:wantis a step response, and the square rings by default, which moves the crossing todelay=0.002770 ratio=1.255— inside the band, but no longer the figure beside it, and the same on every run, so it is the shape and not the run. See the recipe in verification.md. The band/hf legs need no such flag: they count the crossings ofsine, which the ripple does not touch at all — their counts read a few percent lower at this recipe's 1.3 MS/s only because the extra harmonics cost the producer, which verification.md notes.stateis2(Done) or4(Capped) on every leg.3(Truncated) on more than one leg means the pump loop is being overtaken -- nothing to fix in the feature, but say so.
The same probe then puts five measurements on the same spiked sine as two windows -- three legs at
hf=0 and two at hf=50 -- and reads them back:
[noise-probe] measure pkpk bare noise=10.0 hf=0.0 valid=1 value=2.987692
[noise-probe] measure pkpk hf noise=10.0 hf=50.0 valid=1 value=1.990075
[noise-probe] measure period noband noise=0.0 hf=0.0 valid=1 value=0.021290
[noise-probe] measure period band noise=25.0 hf=0.0 valid=1 value=0.200000
[noise-probe] measure period hf noise=0.0 hf=50.0 valid=1 value=0.200000
[noise-probe] measure windows=2
[noise-probe] denoise raw=2.987696/1 denoised=2.000000/1 ratio=0.669 channels=1
pkpk bare-- about 3.0: the sine's 2 plus an impulse either way. The impulses dominate the window's extremes, so this is the stimulus.pkpk hf-- about 1.99 (2/sqrt(1 + (5/50)^2)): the waveform. This is the reading that says the filter reached pass one and not only pass two; a value near 3 means it did not -- checkmeasure_scanis handedw.scan_filter, and thatfiltereddisabled the buckets, since a bucket's aggregate is raw.period noband-- about 0.021, far below the true 0.2. This one is supposed to read wrong: it is the control the other two are compared against, the defect the band exists to fix. "Fixing" it removes the check.period band-- exactly 0.200000, the true period, from the band alone. Nearperiod nobandmeansnoise_pctis not reachingmeasure_set_level. The leg asks for 25 % and not the 10 % default: the band is a share of the window's peak-to-peak, which with the impulses in it is about 3, so 10 % is a half-band of 0.3 -- less than the 0.5 an impulse moves the signal by, which is exactly whatMeasureOptions::noise_pct's own rationale says (10 % holds to a 0.3 interferer).period hf-- exactly 0.200000 with the band off: the reject alone. Nearperiod nobandmeans the pole coefficient stayed at 1 -- checkmeasure_filter_setwas given a non-zerodt.denoise-- the feature under test.rawis the spiked sine's peak-to-peak over the visible window anddenoisedthe same reading from themath denoisechannel -- aSourceofsineintoDeNoisewithmed_n=5andfc=0, i.e. the median alone.denoisedmust read exactly2.000000: that is the clean sine's own peak-to-peak, so not one impulse survived, which is the whole claim the node makes (the impulses are 2 samples wide and the median is 5, shorter than half a window).denoisednearrawmeans the median is passing them: checkmed_narrived as 5 and not 0, since amed_n <= 0node returns NaN and a peak-to-peak over holes would read--instead.denoised=0.000000withvalid=0means the channel never produced -- it was never enabled, its sourcesinewas never re-enabled, orapply_math_channels()was never called.ratioisdenoised / rawand followsraw, which is whatever the visible window's extremes happen to hold: 0.669 here, and up to about 0.8 in a run whose window misses the spike at one extreme. The assertion is ondenoisedand onrawbeing above it; the ratio is the reading, not the test.channels=is the math channel count (a->math.size()), not whether the channel is acquired.measure windows=2-- the five legs ask for corners0, 50, 0, 0, 50on one channel, which is two distinct windows.windows=1means the corner is not part of the key: the five collapsed onto one walk and two of them read a signal they did not ask for.