gather() staged x as f32 relative to the series' own x0() -- the start of the whole retained window -- so the quantisation was a fraction of the ring rather than of the view: 1.9 us at 20 s of retained data, which at a 10 us view across a 716 px axis is over 100 px between where a sample is drawn and the x a readout quotes for it (crosshair_point() reads x_at() in exact f64). It halved on every zoom out, which is what made it read as "zooming in makes the crosshair select a point beside the data". The origin is now the caller's (plotter c2c8c59): the renderer passes the visible range's minimum, so the error is a fixed 2^-24 of what is on screen -- about 4e-5 px over a 700 px axis -- at any zoom. The parameter sits before out_xy so every call site is a compile error rather than a silent change of behaviour. The renderer no longer reads series.x0() at paint time at all, which removes two of the three symptoms of the torn window_x0_ read by construction; the ink probe's now-vacuous kept_fixed_x0 walk goes with them, replaced by a drawn-vs-quoted pixel figure. neighbours() still tests the visible range against x_at(0), so the cache stays load-bearing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
139 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Build and run
git submodule update --init --recursive # deps/plotter is a submodule
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build
./build/udpscope --server 127.0.0.1 --port 8000
./build/udpscope --server 10.0.0.5 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
The app can be launched from the shell rather than only from a terminal, but only on request:
cmake --build build --target desktop-entry
That writes org.udpscope.app.desktop plus both app icons under $XDG_DATA_HOME (default
~/.local/share), runs desktop-file-validate as a hard failure (when it is on PATH;
absent, it says so and installs anyway), refreshes update-desktop-database and
gtk4-update-icon-cache when they are on PATH, and prints the three paths it wrote — which is
the uninstall instructions, since there is no uninstall target. cmake --build build alone never
touches $XDG_DATA_HOME.
The entry's Exec is the built binary's absolute path in the build tree, so moving or
deleting the build directory breaks it. That is the price of having no install step and it is
deliberate: the entry then always launches exactly the binary that was last built. No
StartupWMClass is needed — GTK takes the WM class from the application ID, and the desktop
file's basename is that same string, which is what associates a running window with the entry.
Options: --server, --port, --multicast, --interface, --buffer-mb (per
buffer, default 200), --max-window, --holdoff, --recv-buffer, --plots (the row
count to start with, 1..RowSet::kMaxRows; the layout editor owns it afterwards).
Run with no --server and udpscope asks which source to open: the sessions it has stored, plus a
form for a new host. Every probe below passes --server, so none of them sees it.
deps/plotter is built from source with only the GTK4 adapter enabled
(PLOT_BUILD_GTK4); its tests and examples are forced off. deps/udpsclient is a
vendored C99 source pair compiled into a static lib.
libadwaita ≥ 1.8 is a system dependency (pkg_check_modules(ADWAITA ... libadwaita-1>=1.8)),
for two widgets: the shortcut overview (AdwShortcutsDialog) and the About dialog
(AdwAboutDialog). GTK4's own GtkShortcutsWindow is deprecated as of 4.18 and
AdwShortcutsDialog is its replacement; GtkAboutDialog still exists but
adw_about_window_new() is deprecated in favour of AdwAboutDialog, which shows the
application icon as the page's own header. Two consequences worth knowing before editing.
adw_init() is called — in run_ui() and, since the chooser runs first and must be styled
the same, in choose_source() in place of its gtk_init() (both are idempotent) — so
the whole app is drawn with libadwaita's stylesheet, not GTK's: a metric change there
reaches every widget, and layout over N frames / axes.w changes is what says whether
the layout still settles. And an AdwDialog is not a GtkWindow: both show_about()
and show_shortcuts() keep an AdwDialog*, cleared on ::closed (there is no
::close-request) and closed with adw_dialog_close(). They share one on_dialog_closed()
handler keyed by a pointer-to-pointer (gpointer slot cast to AdwDialog**), so one
handler clears either pointer without knowing which dialog it is. Presenting either on this
app's plain GtkApplicationWindow is supported and warning-free; it was checked.
deps/plotter is upstream's own repository and this app's figure support was added
there rather than worked around here: Figure::set_min_y_gutter() (a floor under the
common left gutter), and in the GTK4 adapter PlotArea's figure mode —
set_figure(), one input::Controller per row, nearest-row event routing, a
figure-wide crosshair — plus PlotArea::row_at() for naming the row a pointer is over.
The render-quality work went there for the same reason: kTextFS now filters the
glyph atlas (a 2×2 bilinear clamped to the instance's own cell) instead of point-sampling
it, and LineProgram::set_snap() / u_snap rounds an axis-aligned segment's ink onto
whole pixels for the chrome that frames a plot (grid and axis lines), never for anything
drawn at a data position. generate_ticks() also stores the tick at the origin as
exactly 0.0 — the walk starts at ceil(min/stride)*stride and so never lands on zero
when the stride is not a power of two, which labelled it -139a. All three carry
upstream tests — the_atlas_is_filtered_not_point_sampled,
grid_hairlines_land_on_whole_pixels and the_tick_at_the_origin_reads_zero. The minor
grid's look is set there too, not here: grid_minor carries 50% alpha, grid_minor_width is
0.75 (sub-pixel, expressed as coverage at 1x) and grid_minor_dash_on/_off are 3/3 — a
fragment discard, no extra geometry — so the subdivisions read as texture under the labelled
grid instead of as a second set of ticks (the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major).
README records what they cured.
Changing any of this means committing in the submodule and moving its pin, and running the library's own suite as well as the udpscope probes below:
cmake --build deps/plotter/build/debug
xvfb-run -a deps/plotter/build/debug/tests/plot_tests # expect "0 failed"
Verification
There is no unit test suite. The only automated check is the app's own headless
self-check, driven by env vars and run under a display (xvfb-run, llvmpipe) against
a live UDPStreamer source:
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
./build/udpscope --server 127.0.0.1 --port 8000
Two baselines every run is read against. A clean build emits five warnings and no more: two
-Wmissing-field-initializers on MathNode::source, one unused output_built, and
-Woverlength-strings on both generated GResource .c files — the help pages' and the
icons', whose blob crossed C99's 4095-byte string-literal limit when the two app icons joined the
four action ones (a note about a generated file, which GCC itself supports). And an exit-dump run
emits exactly two Gtk-CRITICALs, gtk_widget_get_height and gtk_widget_get_width, from
the dump asking a destroyed widget for its size. A sixth warning, or a third critical, is a
finding.
-
UDPSCOPE_EXIT_AFTER_MS— run the trigger-on → go-live probe, close the window, then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted frames, trace bindings, panel sizes, status line in both states). -
UDPSCOPE_ENABLE_ALL_MS,UDPSCOPE_ARM_TRIGGER_MS,UDPSCOPE_PROBE_DISARM_MS,UDPSCOPE_TRIG_TRACE_MS— schedule individual probes.UDPSCOPE_PROBE_DISARM_MSonly starts its sequence: it switches the trigger on and then polls for a capture to open, so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall. -
UDPSCOPE_PROBE_FIELDS_MS— change the trigger panel's edge and Normal/Single dropdowns. As much a crash test as a state check (see the GTK signal-signature invariant below), so time it inside a capture:[fields-probe] ... capturing=1followed by no SIGSEGV is the assertion. -
UDPSCOPE_PROBE_WINDOW_MS— drive the window control every way a user can: type a number, change the unit under it, step a decade either way. -
UDPSCOPE_PROBE_LAYOUT_MS— drive the layout editor: switch it on, insert above the first row, remove a row, append one at the bottom, switch it off. Its steps are 200 ms apart on purpose — the buttons are placed from the rects the last frame was laid out with, so a synchronous sequence would read every position one action stale. It clicks by emitting::clicked, nevergtk_widget_activate(), which holds aGtkButtonfor its 250 msACTIVATE_TIMEOUTand then emits into a button the rebuild has already destroyed. -
[measure-probe] N measurements on 'X'(stderr,UDPSCOPE_PROBE_MEASURE_MS) — adds one measurement of each statistical type (average, top, peak to peak, amplitude) on the first channel and forces a recompute. It must name a channel, notsignals().front(): the first signal of a stream is its timestamp, which is not acquired, and naming it adds four measurements that all read--and makes a performance run look free (each bails before collecting anything -- that is how the first baseline of the stall below read 0.4 ms). The values are in the exit dump asmeasurements: <type>=<value>; on the fast mock (tools/mock_udpstreamer.py --rate 1300000 --batch 2000) a unit sine must readpeak to peak=2,amplitude=1,average=0andtop=0.636620-- 2/pi, the half-wave mean of a sine, which is the check that the threshold types are not merely plausible.[measure-trace] view=[..] <type>=<value>... marks=N level:V/time:V...is a repeating line (250 ms) from the probe: the pane's numbers beside the current window and the marks actually placed, which is the only way a stale result or a mark that should not be drawn shows up as a number. It must never printmarks=0while measurements withshow_on_plotare on and the data is there -- that was the rulers blinking off at a mode change.measure cost: N recomputes, X ms total, Y ms each, data D ms (P%), covered=C buckets=B raw=R (F%), N ns/covered samplefollows it: read it against a run withUDPSCOPE_PROBE_MEASURE_MSunset, because a frame mean cannot attribute a cost. The last three fields are the assertion, and they are a ratio, not a time:coveredis the samples the window spans,bucketsthe LOD buckets the two walks answered from, andrawthe samples they still had to read.rawmust stay at a fraction of a percent ofcoveredon a clean waveform over a window of millions -- 0.1% is what the pyramid-backed walks give (see the measurement-cost invariant below). Arawnearcoveredmeans the walks went back to reading the window, which at 1 MS/s is 19.7 ms per recompute and a third of the run's frames. It is legitimately nearcoveredfor a window shorter than a bucket (64 samples) or a signal noisy enough to straddle a level everywhere, so read it on the fast mock (tools/mock_udpstreamer.py --rate 1300000 --batch 2000), where a window is ~2 M samples. Note the finalmeasurements:line and[measure-trace]'s last lines may read--:UDPSCOPE_EXIT_AFTER_MSarms the trigger before it dumps, so the dump can land inWaitingwith nothing bound and the axis at its default. The race-free reading is an earlier[measure-trace]line, which carries its ownview=; those lines are also where the level-mark fallback is visible (an invalid pass must still print itslevel:marks). -
[pan-probe] asked=[..] was=[..],[pan-probe] after=[..] held=D asked=[..], then[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L(stderr,UDPSCOPE_PROBE_PAN_MS) —heldmust be 1: a gesture must not move a window that is pinned (trigger mode, or a paused view). The probe writes the panned range the way a gesture does — on the member axis, whichFigure::layout()adopts into the group — and reports the axis the frame after it produced. Time it inside one pinned state: a pan during a capture whose check lands after the capture has ended is re-framed by the mode change, not by the pin, and readsheld=1with the hold removed (that is how the first version of this probe fooled me). The pause probe makes a stable pin, so run them together:UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_PAN_MS=2200puts the pan and its check inside the pause. Withhold_pinned_window()disabled it readsheld=0and the axis sits at the asked range. It then asks for a zoom-in (a window a quarter of what is on screen) and reportsgroup=/member=beside it:allowedmust be 1, with both equal to the ask -- that is the single-trigger-mode case, where the wheel has to keep working, and where reading the wrong one of the two made a working clamp look broken. All three verdicts are read from the tick, one frame after the ask: ag_timeoutwas the first version and it drifted past the paused/resumed boundary under load. The probe then asks for a zoom-out ten times the current span and reports the span the next frame produced:limitedmust be 1, i.e.span <= window. Run it with a ring that holds more than the window (--buffer-mb 64past the window's own seconds) — that is the state the oldmax(retained, window_s_)bound leaked in, and it showed up here asspan=2.502550 window=2.000000 limited=0againstspan=2.000000after the fix. -
[zoom-probe] <sweep>/x<step> axis=[..] span=.. n=N idx=[a..b) spp=.. level=L pts=P kept=K back=.. ink=[..] x_at_first=.. gap_px=.. end_px=.. rect=[..](stderr,UDPSCOPE_PROBE_EVENTZOOM_MS) — arms the trigger in single mode, polls for a held event (a latch happens when the stream says so; a sweep on a timer has nothing to zoom into), then zooms in eight doublings twice:ges/writes the row's own axis the way a gesture does,win/drives the window control (apply_time_window, which unpins and re-arms the live follow before the pin is restated). Each step's reading is taken in the tick, after that frame's pin and clamp — ag_timeoutwould report the ask. It recomputes whatRenderer::draw_tracesis handed, through the same calls:neighbours()foridx, the 64-spp gate forlevel, thengather().gap_pxandend_pxmust stay within a pixel or two at every step — they are how much of the left and right margin no geometry reaches, and agap_pxthat doubles per step is the "zooming into a held event clips it more and more from the left" defect (374 px of a 716 px axis byges/x128), whose cause is a constant offset in data seconds (see theneighbours()invariant below).kept/backare the renderer's own increasing-suffix filter counted rather than applied:keptfar belowptsis a staircase, and a non-zerobackis a non-monotonic gather. Run it with--clock-jitter(tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --clock-jitter 0.4): the ideal mock's stamps sit on a perfect grid, which is exactly what hides this class of bug, and three runs against it reportedgap_px=0.0throughout while the defect was live. The jitter varies each packet's spacing rather than offsetting its start, because the stream's clock has to stay strictly increasing. Thewin/sweep bottoms out atspan=0.05(RowSet::set_time_window()'s floor) and walks to the event's post side, sinceset_time_window()frames the newest span before the pin clamps it — both are expected. -
UDPSCOPE_PROBE_PAUSE_MS— pause acquisition mid-run so layout numbers are measured on a still picture. It now also asserts what a pause is ([pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]on stderr): it presses the button, notAcquisition::set_paused(), because pausing is a display state as well as an acquisition one and the view snapshot and axis pin live in the handler. Across the three linesring_producedmust grow (the producer never stops — it fills the live ring exactly as it does with the trigger off),event_sizemust be non-zero exactly while paused (the plot is bound to the frozen snapshot, not the ring) andfrozen_xmust not move between the pause and the resume; after the resumeevent_sizeis 0 and the window is back on the newest data. Pausing used to stop the producer, which is why the counts grew by nothing and the resume left a hole. -
UDPSCOPE_EXPORT_MS(+ optionalUDPSCOPE_EXPORT_PATH, default/tmp/udpscope-export) — collect the export table and write it both ways, skipping only the file chooser.
Run one self-check at a time. Two concurrent xvfb-run -a instances end up on the
same display and the second gets no GL context — it dumps layout over 0 frames and
valid=-1, which looks like a pipeline failure and is not one. tools/mock_udpstreamer.py
also outlives its --duration, so kill it after each run or the leftovers pile up and
hold ports.
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 44..74draggingaxes.x 67..80/axes.w 546..559with 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.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=must be back to0.0000in every other state. 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 below). 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.[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] stream=[..](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 subtractsdisplay_epoch()before searching the ring). Inmode=event,stream=must be the edge ± the window; inmode=liveit equalstime=. The one exception to both: an export taken mid-capture reportsmode=livewithtime=[-pre..e]whereeis how far the post window has filled, andstream=offset from it by the edge (sostream - timereads the edge exactly) — 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 twobytes=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.[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 below.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=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. 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 below;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 revealed=D marks_shown=M pane_rows=R pane_w=W pane_h=H(stderr,UDPSCOPE_PROBE_MEASURES_MS) — adds one measurement of every type on the first plottable signal and lets the tick compute them, so what it prints is what a user reads. On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0,top≈ 0.64 andbottom≈ -0.64 (the mean of each half of a sine), max/min ≈ ±1,pulse duration≈ 0.5 s withduty≈ 0.5,period≈ 1 s andfrequency≈ 1 Hz.marks=lists each mark with its kind, and the mix is the point:level:Valone for average/top/bottom,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.marks_showncounts two per shown measurement (a min/max showing both its marks counts two of its own).measures window:is the timeliness assertion -- the window the numbers were computed over against the one on screen. The reading that must hold ismarks_in_window=K/K: every time mark inside the window on screen.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. On a 2 s window at the 100 ms cadence the top of that sawtooth isdrift=5%, which is what a dump landing just before a scheduled pass reads -- a0.0000%is a dump that landed just after one, not a better state. Read it against the recompute count: more recomputes with a higher drift sample is the sawtooth, while a drift that exceeds one refresh interval of scroll, or marks outside the window, is the defect. A measurement is recomputed when the window moves (2 % of its span) or the held/live state changes, never on a wall-clock timer: a timer is what let a marker sit outside the viewport and a dragged one survive a frame.marks_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= side= show=is 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. 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=Ddeliberately is not fixed per window: the form is decided by the tick step against the label resolution at the axis' magnitude (see the invariant below), so the same 50 ms window reads 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) — both correct, and a probe pinned to one of them would assert the stream's clock rather than the app. The caption must be non-empty exactly whenrelative=1. 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 the invariant below), 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=[..](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.header: window_button_w=W label_w=L bar_w=B(stderr, once a second) —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.toolbar icons: trigger=NAME(ok) pause=... cursors=... measures=... math=... 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). Four of the names are this app's own (udpscope-trigger-symbolicand friends, aGResourcecompiled into the binary — see the toolbar-icon invariant below), 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.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 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). [udpscope] starved: connected (config=D), 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. Two ways to see it without a second client: point the app at a UDP port where nothing answers (the client CONNECTs, times out, reconnects;config=0), and 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 export's
time/stream_timecolumns 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.set(np.diff(t))must hold exactly one value, and this is the check that needs a long run at a high rate: a plateau of 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 below — the figure to compute is the ULP oft[0], since the defect grows with how long the app has run, not with the export's own length). badge frames: lit=L dim=D idle=I— 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). 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 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 tens of thousands andlodmust dominaterawon any run holding a large capture: a max in the millions withlod=0is the empty-pyramid defect (see theMinMaxPyramidinvariant below) — 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.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 20000— 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.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 below 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] pages=14 parsed=14 shortest='Pause and play' 1434 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok(stderr,UDPSCOPE_PROBE_HELP_MS) —parsedmust equalpagesand no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window looks fine and only this count notices.settings=must be a real path (the same one the About dialog shows).icon=must 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 three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by two different calls, so a mismatched pair crashes or leaks here. One of the three is aGtkWindow(the help window,gtk_window_close()); the About dialog and the shortcut overview areAdwDialogs and close withadw_dialog_close(), cleared on::closed.shortest=is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O, one line per row, then[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=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.[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1(stderr,UDPSCOPE_PROBE_KEYS_MS) — everyX->Ypair must differ andrefusedmust be 1. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while 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).[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, the rate field is%.3g(so10 kS/s, never10.0 kS/s), and the only field trigger mode adds is the elapsed time, which steps units rather than growing digits.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.
The probes are the regression net — when changing UI or plot-host behaviour, extend
the probe/dump in src/ui.cpp rather than adding a separate harness.
Architecture
SPECS.md is the requirement document. README.md documents what was actually built,
including deliberate deviations from the spec and known limitations — read it before
concluding something is a bug.
Threads
Exactly two: a producer thread in Acquisition owns udps_client_t and calls
udps_client_poll(); the GTK main thread owns every widget and GL context. They share
ScopeModel under ScopeModel::mutex(), taken once per DATA frame by the producer and
once per frame by the UI (tick() in src/ui.cpp) around RowSet::bind()/acquire().
Samples themselves cross via plot::data::RingBuffer (single producer, single
consumer) and never through the lock. Cross-thread scalars are atomics on ScopeModel
(trigger_enabled_, trigger_armed_, trigger_latches_, trigger_time_,
event_, generation_). Two of them are a pair: the producer writes capture_edge_
before trigger_pending_ and those two carry release/acquire, so a consumer that sees a
capture open sees which edge it belongs to.
Data path
ScopeModel holds one BufferStore (live()): one shared time-base ring plus one
value ring per channel, sized by plan_partition() to cover --max-window seconds
within the byte budget. The producer always writes it, whatever the trigger is doing —
that is what guarantees a full pre-trigger history on the first latch after arming.
The spec's second buffer (item 1) is the latched EventSnapshot, which is already an
immutable copy of the captured window; see README for why two live rings were dropped.
What a trace binds to is ScopeModel::display_mode(), one of four states, and
RowSet::bind() takes it as an argument rather than recomputing it:
Live— no trigger: the live ring on the stream clock.Capturing— an edge has been accepted (so the pre window was full) and the post window is still filling: the live ring, on the coming event's own clock (display_epoch() == -edge), inside an axis already pinned to[-pre, +post]. The post side fills in view and the marker sits at x = 0 throughout.Held— the frozenEventSnapshotof the last event.Waiting— armed, nothing captured yet: blank.
Disarm during a capture publishes what the capture got to, then disarms: the producer
cuts the window at the newest sample and that partial snapshot becomes the held event, so
the picture on screen at the moment of the Disarm stays on screen. engine.disarm() clears
pending_, which drops the display out of Capturing, and with nothing published the view
fell back to the previous event — or to blank on a first capture. No event fired, so
trigger_latches() is deliberately not bumped; ScopeModel::event_seq() counts published
snapshots instead, and the frame tick keys its event framing on that. Keep the two
distinct: the latch count is what the probes assert "how many events fired" with.
Capturing is a deliberate departure from SPECS:37 ("data visualisation should not
be refreshed till next event"), asked for explicitly; that reading is what the old "never
the live ring mid-capture" rule enforced. What survives of the rule is that Held binds
the snapshot only — a held event is never quietly replaced by live data — and that the
axis does not move during a capture: it is the event's window from the edge onwards,
never a sliding live view.
display_mode() must be read once per frame and passed around (the tick does this,
and hands it to bind()). The producer can open or close a capture between any two
reads, and two reads in one frame put the bind, the epoch, the pin and the status line on
different clocks.
Layers to keep straight:
-
SignalInfo— one signal as the CONFIG described it. The unit of enable/disable. -
ChannelInfo/ChannelBuffer— the unit of acquisition and plotting. AChannelBufferis aplot::data::Seriesover a (value, time) ring pair. -
SlotSeries(src/channel.hpp) — a trace's stable series object, rebound each frame to either aChannelBufferor anEventSeries. -
RowSet(src/plot_host.hpp) — oneplot::Figureof up tokMaxRowsrows (added and removed one at a time by the layout editor), each a borrowedplot::Plot*withkSlotsPerRowfixed trace slots. The figure owns everything the rows have to agree on: the x window (one x link group every row joins), the live follow (the group's — a member's own axis must beXFollow::Off, sinceFigure::link_x()refuses aLivemember), the left gutter and the crosshair. -
ExportTable(src/export.hpp) — the visible window as columns, collected from the snapshot or the live rings and handed to the.npy/.csvwriters. Always the stored samples, never the LOD pyramid's buckets.
src/ui.cpp is the GTK4 window (toolbar, trigger panel, signal panel, plot box) plus
the tick() frame function that binds, updates the trigger state and rewrites the
status line. One plot::gtk4::PlotArea in figure mode draws the whole figure. It is by
far the largest file.
Invariants worth knowing before editing
-
plot::Plotcannot remove or retarget a trace. Hence fixed trace slots created up front and rebinding viaSlotSeries; a name change (legend text) is the one thing that forces a rebuild. Cursors are parked outside the axis range instead of removed. -
A
Figure's row count is fixed at construction, soRowSet::rebuild_all()builds a new figure and the area is handed it (PlotArea::set_figure()); it costs no GL state. That makes one path for "a row's traces changed" and "the row count changed", and it is whyrebuild_all()restates a pinned event window (pinned_pre_/pinned_post_) instead of re-framing on the live edge — a drop during trigger mode would otherwise throw the frozen view onto the stream, where nothing moves it off again until the next latch.PlotArea::create()is handedApp::placeholder_plot, never a row: the area keeps a reference to the plot it was created with, and a rebuild destroys every row. -
A readout quotes a stored sample, never a value between two of them. The hover crosshair and the cursors pane's
mixvalues are one call,Plot::crosshair_value(), and it reports the nearer of the two samples bracketing the x (a tie reads the earlier). Upstream interpolated linearly, and that is wrong twice over here: ingest decimation is a boxcar average overtime_stridesamples, so a number between two stored samples is a second estimate laid over the first that nothing on screen or in an export holds; and past the sample spacing the polyline is a line the renderer drew, so a point on it reports the drawing. The change is indeps/plotter, which keeps the blend asCrosshairValue::Interpolatedfor a host whose x is a quantity it is willing to interpolate — the nearest sample is the default and udpscope never states the other, so aset_crosshair_value_mode()call appearing here is a regression, not a feature. Both the mark and the number come from one call,Plot::crosshair_point(), which returns the quoted sample's own x as well as its y — so every mark this app draws for a readout is snapped to the sample it quotes. The library'sCrosshairRow::markerand the cursors pane'smixcrossing annotations (place_crossings(), which must usecrosshair_point()and notcrosshair_value()) are both placed from it. Two consequences that look like defects and are not: the mark sits beside the polyline at a deep zoom (one y feeding both the number and the dot is what keeps them from disagreeing), and a crossing dot sits a little off its own vertical cursor line — up to half a sample period, invisible at a whole-window view and the width of the panel at a few samples across, which is the only zoom at which anyone is asking which point it is. The line says where the marker is; the dot says which datum is quoted. The hover mark is also deliberately larger than a trace's own sample dots (Style::crosshair_marker_px9 againstmarker_px3), because a snapped mark lands among them and at their size reads as one more sample. The guards are the library'scrosshair_value_is_the_nearest_sample,the_crosshair_marker_snaps_to_the_sample_it_reports,crosshair_interpolated_mode_blends_the_two_samplesandthe_crosshair_mark_is_bigger_than_a_sample_dot, plus[cursors-probe]'sfirst mark at x=.. y=..agreeing with the pane's first value. -
format_measure()is the one formatter for both panes, and its SI prefix does not depend on there being a unit. The cursors pane's values and the measurements pane'stext=go through it, so a change reaches both. The prefix ladder used to be insideif (!unit.empty()), which made two decimals of a small unitless value read0.00— the reading gone, not merely coarse — and that is a whole class of channels, not a corner:measure_unit()returns""for any signal whose CONFIG declares no unit, and a math channel never has one. The regression net could not see it becausetools/mock_udpstreamer.pydeclaredunit="V"for every data signal; it now takes--unit '', and that is the flag to run the measures and cursors probes under when touching this (text=[636.62m],● sine=585.24m). With no unit the prefix is written tight against the number —1.34m, not1.34 m— because the space exists to separate the number from its unit and a spacedmalone reads as metres; with one it stays spaced and the prefix joins the unit (636.62 mV). Below1e-12the ladder runs out, and%.2fwould print0.00again, so that range falls back to%.3g. -
The cursors are read back from the plots only while a gesture is on that row.
Cursor::xis the free pointer position while a drag runs and the snapped value otherwise -- which is what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on every frame therefore adopts its own echo back, and -- because the vertical pair is one value shared by every row -- a row that has not been re-written yet hands the previous shared time straight back. That is the "drag it and it springs back" defect, and the fix isRowSet::sync_cursors(int drag_row): the tick passes the row whose controller reportsgrabbed_cursor() != kInvalid, and every other frame does nothing. The values are echoed back withCursorPlacement::Freefor the same reason -- the controller snaps at the end of the gesture (Controller::end_drag()), and snapping the echo fights the hand that is still moving the cursor. -
The cursors' on/off is one flag on the
RowSet, never a copy per row. It is one piece of state (the toolbar toggle) and the mode is one value; a per-row copy is somethinginsert_row()'sclear_row(), or any rebuild, can clear behind the user's back, which is how a row added while the cursors were already on came up with its instruments parked while every other row showed them. The same goes for the vertical pair'sx: it is one stream time for the figure, so it lives on theRowSettoo, andRowkeeps only what is genuinely per row (the rulers' values, the traces, the colours). -
A trace is a slot with a name and a channel; a name alone is not one.
Row::channel[s]is re-resolved byRowSet::bind()every frame (slots are identified by name, see above), and-1means the model built no channel for that name. Some names can never bind: the stream's time signal is skipped bybuild_channels_locked()however often it is enabled, so a slot holding it has a name for ever and data never. The plot showed nothing for it, which is why it went unnoticed, but the cursors pane'smixmode drew one line per named slot and so gave each phantom a row that could only read--— "the pane shows a lot of empty signals that do not exist", six lines over three real traces. Two halves to the rule. (1) Anything that counts traces countschannel[s] >= 0, not!signal_name[s].empty()— and a per-frame bind means the cursors pane cannot key its rebuild ongeneration()alone, henceApp::cursors_pane_bound, a bit per row/slot (kMaxRows * kSlotsPerRowis 32, so onestd::uint32_t): a trace that appears without a structural change still gets its line. (2)apply_layout()refuses a name that cannot produce a channel (ScopeModel::is_time_signal()), because such a name was sticky:collect_layout()writessignal_nameback verbatim, so one that got into a session file was re-saved every session for ever. The refusal also heals an existing file on the next save. How the names first got in is unexplained — the drop path already refuses a signal with no channel — most likely an older build or a CONFIG whose time signal was not yet classified as one; the fix is the stickiness and the counting, not a guess at the origin. -
A row's contents are state of the
RowSet, not of the figure.insert_row()andremove_row()shift that state withcarry_row()and letrebuild_all()re-deal the figure handles, so inserting above row 1 moves what row 1 plotted down to row 2 rather than renumbering the rows under the user.carry_row()deliberately copies fields instead of assigning a wholeRow: aRowalso holds the figure handles (plot, the axis and cursor ids) and theSlotSeriesobjects the traces point at, and those belong to the row's position. It clears the destination's slot bindings for the same reason — the nextbind()re-resolves them, and without the clear the frame in between draws the moved row's samples under the new row's legend. Both callrebuild_all()themselves, so their callers must re-point the area (PlotArea::set_figure()) directly;relayout_areas()is no help, since it only acts on a dirty row. -
The layout editor's buttons are separate overlay children, never one transparent child covering the area. A
GtkOverlaychild is picked wherever its allocation is, andgtk_widget_set_can_target(FALSE)takes a widget's children out of picking with it, so a full-areaGtkFixedwould swallow every pan, drop and hover the editor is not interested in. Each button is therefore a small widget aligned to a corner or the centre and placed by its margins, and the overlay is only measured from its main child, so moving them cannot reach the plot's layout. They are placed fromFigureLayout::rows— the rects the frame was drawn from — divided byPlotArea::device_scale(), because aRectis framebuffer pixels and a GTK margin is logical ones. Reading the figure's own rects rather than dividing the widget height by the row count is what keeps a×on the row it names when the rows stop being an equal division.sync_layout_editor()runs every frame on purpose:gtk_widget_set_margin_*()returns early on an unchanged value, so a settled layout costs the comparisons and nothing else, and a resize needs no notification. -
Trace slots are identified by signal name, not channel index. Enabling a signal or a new CONFIG renumbers the channel list, so an index would silently re-point a trace at another signal.
-
Any structural change rebuilds the buffer and drops history: a CONFIG, an enable/disable, a calibration change, or a change of display window that moves the decimation factor. Calibration is applied at ingest, which is why it re-partitions.
ScopeModel::set_target_span()is the window's path in, and it re-plans only whentime_strideactually changes, so walking the window inside one decade is free; it asksplan_partition()rather than recomputing the factor, because two expressions for that decision is how the buffer and the view end up disagreeing about the stored rate. It takesmutex()itself — do not call it holding the lock. -
The shared clock is decimated to fit the requested window; channels never are. A window too long to store at the wire rate used to be refused (the control was capped to
Partition::span_s), which made a 600 s view of a 1 MS/s stream unreachable.plan_partition()now pickstime_stride, a power of two, after the growth loop has bought all the ring the budget allows — deciding it earlier over-decimates by whatever factor growth was about to add. Per-channelstride[i]stays 1: a stride ofround(dt_i / dt_fastest)keeps one sample per stride, and when that equals the signal's own period — a 2 kHz sine on a 1 MHz clock, stride 500 — every kept sample sits at the same phase and the channel plots flat. -
Decimation at ingest is an average, never a stride.
Acquisition::on_data()reads and accumulates every sample and pushes the mean of each group oftime_stride(value_acc_/value_acc_n_, andtime_acc_for the clock). That boxcar's first null is at the stored rate, so content that would fold back is attenuated instead of reappearing as a false low frequency — which is the whole reason decimation was once dropped entirely. Skipping the read for unstored samples re-introduces the aliasing; theUDPSCOPE_PROBE_WINDOW_MSrun under--freq 2000is the guard (see below). The stamp pushed is the group's mean, not its last member: that is where the boxcar's group delay puts the value, and stamping at the end of the group shifts every trace half a stored sample late. The accumulators are reset whenevertime_strideor the channel count changes, so a re-plan starts a fresh group rather than finishing one at the wrong length. The LOD min/max pyramid still preserves the extremes of what is stored; a spike narrower than a group is now filtered away rather than aliased, which is the correct trade and is why the status line'sratefield is worth reading. -
The trigger sees the stored (filtered) signal, not the wire signal.
engine.process()is fed the averaged value, inside theifthat pushes it. An edge found on a sample that is not in the buffer would be cut from data that does not contain it. -
The renderer maps the visible range onto indices through the series' own x, never the
dx()grid.deps/plotter'sRenderer::draw_tracesasksSeries::neighbours()for the bracketing samples. A grid walk is wrong for exactly the series this app plots:dx()here is a nominal spacing (the LOD level, a coarse snap) while the real stamps are the time ring, so the window it computes lands inside the visible range and the two end segments — the ones that draw into the left and right margins — are never emitted. That is the "empty margin when zoomed in" defect. The library test isa_sparse_window_between_samples_still_inks_both_margins(a stub whosedx()lies, with the window strictly between two samples). It is the guard, not the probe:[trig-probe/idx]now computesgotfrom the same call the renderer makes, so it cannot catch a renderer that went back to the grid — what it still reports is the drift that made the walk wrong (dx_ratio). -
neighbours()is a binary search over the real stamps in every series here, the snapshot's included.EventSeriesanswered from the(x - x0()) / dx()grid — the walk the invariant above condemns:dx()is the mean spacing of the snapshot's stamps and the stamps are an arrival clock, so grid and clock drift apart by whatever that clock wandered over the capture. The renderer picks its visible index window from this call, so the drift arrived as a constant offset in seconds between the axis and the gathered geometry — and a constant number of seconds is a doubling share of the screen on every zoom step: 8 ms over a 2 s capture is invisible, and seven halvings later it is 374 px of blank left margin on a 716 px axis, with the ink pushed off the right-hand edge by the same amount. That is the "zooming into a held event clips it more and more from the left while the crosshair still reads the right values" report; the crosshair was right because it readsx_at(), a real stamp.ChannelBufferalways searched; the two now agree. A perfectly uniform source cannot show this at all — three probe runs against the ideal mock were clean — so the guard needs--clock-jitter(seeUDPSCOPE_PROBE_EVENTZOOM_MSabove). -
The
Seriesgrid contract does not hold forChannelBuffer. x is a streamed timestamp, sodx()/x0()report a nominal grid (LOD level, coarse snap) whilegather(),x_at(),neighbours()andx_bounds()use the real time ring. Nothing may walk that grid to find which samples a range covers — the renderer did, and it lost the two end segments (see the margin invariant above).gather()emits x relative to an origin because absolute epoch seconds do not survivef32. -
gather()emits x relative to an origin the caller names, in every series here,EventSeriesincluded, and the consumer adds that same origin back inf64. Two separate rules live here and both were learned from a defect. (1) It must be relative at all. An event's stamps are small (edge relative,[-pre, +post]), so emitting them absolute does not overflow anything and does not look like a bug — it just draws the whole capturex0()= one pre-trigger window to the left of the axis. That was the "half the plot is empty / the marker sits at the end of the data / nothing is drawn but the crosshair reads values" defect: the crosshair readsx_at(), which was right all along.[trig-probe]'sdata=vsdrawn=is the guard. (2) The origin is the view's minimum, notx0().x0()is the start of the retained window, so the f32 had to resolve 2^-24 of the whole ring however little of it was on screen: 1.9 µs over 20 s of retained data, which at a view 10 µs wide across a 716 px axis is over a hundred pixels between where a sample is drawn and the xx_at()quotes for it — and it halved on every zoom out, so it read as "zooming in makes the crosshair select a point beside the data".Renderer::draw_tracespassesx_range.min, making the error a fixed 4e-5 of a pixel at any zoom. The parameter sits beforeout_xydeliberately, so a call site that was not thought about is a compile error rather than silent drift. A caller that has no view in mind may still passx0(), and[trig-probe]'sdrawn=/data=does exactly that on purpose: it reads the series' whole span, which is the widest relative value a gather can be asked to stage. The two guards are upstream'sa_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotesand thetrace ink:line'sdrawn-vs-quotedfigure. A probe that recomputes what the renderer gathers must pass the same origin the renderer does ([ink]and[zoom-probe]both pass the axis minimum), or its reconstruction stops being the renderer's and its pixel readings mean nothing. -
Every series bound to a trace must carry a real
MinMaxPyramid, including an immutable one.MinMaxPyramid::level_for()descends to level 0 — raw samples — on a pyramid with no levels, whatevernis, so a series that answerspyramid()with an empty one is re-gathered sample by sample on every frame it is bound. That was the "large data is laggy" defect:EventSerieshad no pyramid, and a held 2 s capture at 1 MS/s staged 6 million segments per frame for as long as it stayed on screen. The pyramid lives onEventSnapshot, not onEventSeries: the snapshot is immutable, so it is built once inpublish_event()inside theO(n)pass that already computes the y bounds, and the same channel on two rows shares it. It costs ≈1 byte per sample per channel.frame path:'ssegmentsis the guard. -
EventSeries::gather()emits a bucket's envelope, not every2^level-th sample. Point decimation hides exactly the spike the capture was triggered on; a scope has to draw a bucket's extremes. It needs no index rebasing, unlike the ring: the snapshot's pyramid is built from sample 0 and nothing is ever dropped from it, sowindow_base()is 0 and a window index is the absolute one. -
The time ring is
f64and the value rings aref32, and the asymmetry is the point. A value's precision is a property of the signal (24 bits is plenty for a sample); a timestamp's is a property of how long the scope has been running, because the stamps are stored relative to an epoch fixed at the first sample ever seen and so grow without bound while the interval they must resolve stays one sample period.f32's ULP is 1.9 µs at 16 s of uptime, 3.8 µs at 32 s, 7.6 µs at 64 s and 61 µs over a 600 s window — so at 1 MS/s two to four consecutive samples shared a stamp after half a minute, eight after a minute, and the LOD envelope over such a plateau drew a zero-width segment, i.e. a vertical spike where the waveform should be. That is "zoom in to ~10 µs and three to five points share a timestamp, and min/max produces artefacts". Do not widen the values to match: they are the memory budget, and nothing about them is magnitude-bound this way. Three consequences:plan_partition()can no longer count slots in its growth loop (a time slot and a value sample are different sizes, so it weighs the budget in bytes —sizeof(f64)for the clock plussizeof(f32) / stride[i]per channel — andbytes_for()is also what bounds the doubling against overflow);live_window_locked()compares its bounds in the ring's own type with no rounding step, the oldf32round-trip having existed only so that a sample sitting exactly on a bound was kept whatever the stream's absolute time was; andEventSnapshot::stampsisf64because a pause view is deliberately not rebased and therefore carries full stream time (an event's own stamps are edge-relative and small — the symmetry is not the reason).RingBuffer<T>stores slots asstd::atomic<T>behind astatic_assert(is_always_lock_free), whichdoublesatisfies on every supported target. The guard is the export:np.diff(t)over a multi-million-row.npymust hold exactly one distinct step. Three steps at 2, 2.5 and 3 ULP of the stamps' magnitude is this defect. -
A dropped time slot is clamped, never answered with a sentinel.
acquire()pins the published window to the value ring's base and both rings keep sliding afterwards — their capacities are equal, so there is no slack — which means the oldest few samples of a saturated window routinely point at time slots the producer has already overwritten.ChannelBuffer::time_of()therefore clamps to the oldest readable slot: a stamp of0would reachx_bounds(), and from theredx()andx0(), which is the renderer's whole index mapping — one unreadable slot inflateddx()by the ratio of elapsed stream time to the retained window and clipped the older part of every live view. The library'sStreamSeriesis immune for a reason worth copying: its x isx0 + i*dx, a function of the absolute index, so an overwritten slot costs a y value, never the geometry. Clamping buys the same bargain here. -
The window's first stamp is cached by
acquire()and never read at paint time. The clamp above makes an unreadable slot survivable; it does not make reading it safe, andabsolute(0)is not an ordinary slot —acquire()pins the window tovalues_->base(), so sample 0 is the ring's oldest, the very slot the producer is about to overwrite.time_of()checks the bounds and then copies, and when the producer laps the slot in between the stamp it returns is one whole ring-span too new. Everything else on aChannelBufferis read at paint time with no lock, andx0()was the origin the renderer reconstructed every trace point from (gather[i] + series.x0()), read both inside its increasing-suffix filter and again atstage_xy()— so one torn read of that one slot produced three pictures, all of them "the waveform is gone while the grid and the axes stay":neighbours()tests againstx_at(0)and refused the whole visible range (the renderer then gathered the entire ring at a coarse level and drew it off the axis —idx=[0..8388608) level=7 cover=320%);x0()changing during the filter loop dropped three quarters of the points; andstage_xy()shifted the finished polyline a ring-span off its axis. The gather origin is the view's minimum now (see thegather()invariant), a local of the renderer's, so the second and third of those are structurally impossible — but the first is not,x_at(0)is still whatneighbours()tests the visible range against, and that is the one that gathered the whole ring. The cache is load-bearing for that reason; do not read the slot at paint time because two of its three symptoms are gone. That is the "with measurements and cursors active the plot flickers as if there were no data, at no constant frequency" defect. It is live only (a snapshot is immutable, sox0()is constant forHeld/Paused), it is a race against packet arrival so it is irregular, and the measurements pane makes it both likelier and larger: the pane holdsmutex()for eachMeasureWindowReader's life, so the released producer bursts several packets at once.acquire()runs on the consumer underScopeModel::mutex()— the same lock the producer's pushes hold — and the window is pinned on the line above, so that is the one point in the object's life where the slot is safe to read;window_x0_is also the right answer rather than merely a stable one (it is the stamp samplewindow_baseactually carries, where a paint-time clamp answered with whatever slot happened to be oldest now).x_at(0)is served from it too, for the same reason, andinvalidate()clears it with the window. The guard istrace ink:/[ink](see Verification). The same torn read still exists for a smalli > 0— the oldest frame or so of samples — but there its observable is a prefix plateau, which is exactly what the renderer's increasing-suffix filter was written to absorb, so it is a different symptom and deliberately not fixed here. -
clamp_to_data()caps the span at the requested window, never at the retained span. It runs every frame and cannot distinguish "the user zoomed out past the data" from "the ring has only just started filling", sowindow_s_— what the window control states, capped by--max-window— is the bound. Clamping toretainedalone turned a requested 2 s window into the ~0.02 s the first frames held, permanently: nothing there grows a span back. Loosening it tomax(retained, window_s_)fixed that and broke the other state instead — while the ring is filling the two are equal, but once it holds more than the window the bound becomes the whole ring, and a wheel zoom-out walked the view into data the window was never set to show.window_s_is right in both: it never shrinks a window a caller asked for, and a partly filled buffer is left empty on its left, which is what a scope that just started should look like.UDPSCOPE_PROBE_PAN_MSreports it aszoom ... span=.. window=.. limited=1. Its pan clamping is likewise gated on the data being wider than the view, or the empty left-hand side drags the live edge off the right-hand edge. -
Layout must not depend on content. The y gutter is reserved at the widest label
TickFormat::Sican produce and only ever grows; the status label is ellipsized so its text cannot drive the paned divider. Both exist to stop per-frame relayout. The same rule is why the activity badge is one glyph in three colours rather than three glyphs, why theratefield collapses to one figure or a range instead of a per-channel list, and whyformat_elapsed()steps to a coarser unit instead of letting the counter grow: each is a status field whose width would otherwise depend on the stream, and the status row is directly above the plot. The reservation goes on the figure (Figure::set_min_y_gutter(), added to the plotter for this), not on the rows:Figure::layout()clears every row'sset_min_y_gutter()to measure what its own labels need, so a floor written onto the rows survives exactly until the next layout. -
Signals start disabled — enabling is what starts acquisition. A trigger on a signal nobody acquires can never fire, so selecting/arming enables it.
-
The trigger's event window is separate from the display window, and
pretrigger_pctis a share of the event span, not of the ring. -
The event window is counted in stream seconds end to end; nothing converts it to a sample count.
TriggerEnginelatches once the stamps it is fed have advanced past the post window, andScopeModel::publish_event()cuts the snapshot by binary search over the time ring. There used to be a measured samples-per-second estimate inAcquisitiondoing that conversion, and it is precisely why the post window came out empty: any stream whose time signal advances in large steps drove it to its 1 S/s floor, the count came out as 1, and the engine latched on the edge sample itself. Do not reintroduce a rate estimate on this path. -
A pinned window is clamped into the event every frame, and a zoom in is allowed. While an event is held, a capture is filling, or the view is paused,
tick()callsRowSet::hold_pinned_window(), which caps the span at[-pinned_pre_, +pinned_post_]'s and clamps the position into it (and statesOff, since a following group would re-derive from a live edge). A zoom in must keep working -- "not outside the event" is not "not readable", and this function's first version re-stated the pin every frame, which made the wheel a no-op in single trigger mode: an acquired event could not be examined at all. The request is read from the row's own axis, not from the group. A member'sset_x_range-- what the controller writes for a gesture -- is not adopted into the group while the follow is off, so a clamp that read the group only ever saw its own previous answer (the probe showedgroupandmemberboth equal to the pin while the ask was gone).pinned_stated_remembers what the clamp last wrote: a member that matches it is our own answer coming back, one that differs is a gesture to honour -- which is also what keeps a fresh pin (pin_event_window(),pin_pause_window(), both of which write the group in the same tick) from being reverted to the member's stale window. Without it nothing bounds a drag or a zoom in those states: the library clamps a following group's offset only, and the live clamp is deliberately skipped here — so a gesture walked the frozen view off its axis, over data the scope never had. The two are one branch intick()for that reason. A paused view pins the same way (pin_pause_window()setspinned_from the frozen range), so the frozen picture is equally immovable; resuming or leaving trigger mode clears it. -
The session is the layout.
LayoutStatepersists what makes a reopened scope look like the one that was closed: the window, the rows and their slot signals, the calibration, the enabled signals, the math channels with their graphs and the measurements pane. Signal identity is the name throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the model built -- an enabled signal is exactly one that produced a channel -- andapply_layout()installs the graphs before assigning the rows, since a row may hold a math channel's name and that name is only a signal once its graph is in. Two format rules, both learned the hard way: write an op or a measure type as its index throughmath_op_at()/measure_type_at(), never as the raw enum value -- those accessors' orders are the dialog's, not the declaration order, so writing one and reading the other silently renames operations. And a top-level key written after a[[table]]belongs to that table in TOML:enabled = [...]emitted after[[calibration]]was parsed into the last calibration record and the enable state was lost without a word.mkdirs()used to create only components with a trailing slash, so~/.config/udpscopewas never made and the first save failed silently -- worth knowing when a layout change "does not stick". -
A math channel is a channel.
ScopeModel::set_math_channels()appends one syntheticSignalInfo(withmath = true) per math channel after the stream's own, so the panel's switch, the plot binding, the measurements and the export all handle a derived channel without knowing it is derived -- and another math channel is a validSourcefor exactly the same reason. The samples come from the producer:Acquisition::on_data()collects each channel's value for the slot and callsScopeModel::push_math_sample()once per stored slot, before the deferred latch (publish_event()clamps its snapshot to every ring'sproduced(), so a derived sample pushed later would be missing from the capture's newest slot). The math therefore runs on the stored (filtered, decimated) samples, which is also whymath_prepare()getsslot_dt() * time_stride: a cutoff the user typed in Hz means the rate the filter will actually run at.src/math_channel.cppis the engine: a graph evaluated as a list walked in order, so a node may only read ones before it and a cycle is impossible by construction rather than by a check (math_channel_valid()states that as a message the dialog shows)./tmp/check_math.cpp-style checks verified the biquads against known responses (LP fc=100 Hz: 50 Hz -> 0.97, 2 kHz -> 0.002; BP fc=1 kHz q=2: 200 Hz -> 0.05, 1 kHz -> 0.95, 4 kHz -> 0.05) -- keep test sines below Nyquist: atdt = 1/10000a "5 kHz" test tone is the Nyquist point itself and says nothing. The editor is the compact graph: nodes as boxes on aGtkDrawingArea, click to select (its parameters in the form beside it), click an output port then an input port to wire, drag to move; no pan/zoom. Its widgets are created once and only shown, hidden and set bymath_dialog_sync(), so no rebuild can invalidate a live callback handle -- the same shape the measurements dialog uses.UDPSCOPE_PROBE_MATH_MSdefines two graphs and opens the editor (a construction crash test as much as a state check), and the dump prints each derived channel's extremes beside the source's. Read it on the fast mock, where the two assertions that matter aregaps=0(the math is on the producer thread, so what it can break is packet capture, never the frame rate) and eachmath '<name>': ... N samples vs M time slotsbeing equal, withmath x2aty=[-2, 2],math lpaty=[-0.998, 0.998]andmath3aty=[-2, 2]. The0 visible (order breaks 0, first at -1), y=[0, 0]tail of that line is not a failure: it isvisible_samples()reading row 0's axis after the window was destroyed, andUDPSCOPE_EXIT_AFTER_MSarms the trigger first, so the dump can land inWaiting. The livech N '<name>' ... y=[..]lines are the race-free reading of the same thing.math cost: S slots, N ns/slot (T timed), X ms total estis the per-slot cost; see the math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure anywhere in 78--101 ns/slot is one reading of the same thing. Four rules here were wrong first and are worth keeping:- A
Sourcemay only name an acquired channel --ScopeModel::channels(), which is what the dialog's dropdown lists, and both places that create a node must take one from there.signals().front()is the stream's timestamp signal: it is first in every CONFIG, it is not acquired, and a graph sourced from it validates and then reads NaN for ever. The dialog's default was fixed first and the pane's+was not, which is what "[ch] -> [gain x2] produces only nan" was. The dropdown also reconciles: a node whose source is not in the list is moved to what the form is showing, rather than the form showing one channel while the node holds another, andApplyrefuses a source that is not acquired (the engine's validator is about the graph -- an output, every input wired above it -- and cannot see the acquired list). - The ingest must skip a derived channel.
Acquisition::on_data()iterates every channel and reads each one from the packet, so a math channel with no signal read zero and pushed it -- whilepush_math_sample()pushed the real value on the same slot. Two pushes per slot into a ring whose time ring advances once: the values alternated between the real one and 0, and once both rings wrapped the value ring had drifted a whole window off the time ring, so the derived trace disappeared.ChannelInfo::mathis the flag (channel.math) and the loopcontinues on it. A derived channel'svalues(c).produced()must equallive().time().produced()-- one push per stored slot -- and the dump prints both asN samples vs M time slotsfor exactly this: the arithmetic on the plot can look plausible long after the pairing is wrong. gtk_window_destroy()does not emitclose-request. The math dialog's pointer is cleared by aclose-requesthandler, so a Cancel that destroyed the window leftapp.math_dialogpointing at a dead widget: every lateropen_math_dialog()took the "already open" branch and presented it -- a GTK critical, and nothing on screen, which is "I cannot edit a math channel again". Both paths close (gtk_window_close()), which emits the signal, lets the handler clear the pointer, and then destroys. The same lesson as the other GTK traps here: a programmatic destroy is not a user close, and only one of them runs the handlers.- Boxes must not overlap on the canvas. The first auto-place staggered nodes by 24 px for a
132 px box, so every box sat on the one before it,
math_node_at()'s topmost-first search always returned the last, and a drag or a click landed on the wrong node -- which reads as the dialog ignoring the pointer. Two columns spaced 160 px apart, rows 58. The probe prints the box the drag began over for exactly this reason. Offering the whole signal list let a graph name something nobody is acquiring (no data for ever) and put the stream's timestamp signal, which has no samples, in it. prepare_math_locked()runs fromrebuild_locked(), so every plan change re-prepares the filters: a CONFIG, an enable/disable, and a window change that moves the decimation (that path isset_target_span()->rebuild_locked()). Coefficients are a function of the stored interval and the filter's state is the old rate's history, so both are rebuilt together. Preparing only on an edit left the cutoff wrong and the state ringing after any window change -- that is what "I tested a low pass and it was a mess" was.- The graph's output defaults to the last node, and a node added to a linear graph becomes it. The output began at index 0 -- the source -- so every operation a user built was computed and then thrown away: a gain of 2 and a low pass both produced a channel equal to its input. The output is drawn as the orange dot and settable explicitly, so an earlier node chosen deliberately is kept; this is the default for a graph that has never been given a choice.
gtk_spin_button_new_with_range()can leave the value at the range's minimum. The offset field began at -1e6, andon_math_param_changed()reads the widgets, sox*2saved asx*2 - 2000000. Every numeric field is seeded with its own identity value; the range-minimum default is never a useful starting point for a gain or a corner frequency.- The form's rows are grouped per op, not per field: gain and offset are one group because
gain + offsetis one op. The offset was a row of its own that nothing ever hid, which read as a property of every node. - A
GtkSpinButtonholds zero decimals by default, so every numeric field sets its digits (4 for gain/offset/clamp, 3 forfc/Q); without that a 12.5 Hz cutoff was stored as 12 and a gain of 0.25 as 0. And the canvas drag reports the total offset from where the gesture began, so adding each update's delta to a value the handler then writes back is a runaway box -- it must record the position atdrag-beginand ignore a drag that did not begin on a box.
- A
-
Every instrument this app computed is out of the pointer's reach. The trigger marker and the measurement marks are cursors created with
Plot::add_cursor()/add_h_ruler(), and a cursor is grabbable by default -- so each takesset_cursor_selectable(id, false). Parking it outside the axis is not enough: the controller holds a marker for the gesture's life and follows the pointer between frames, so a readout parked atkParkedcould still be dragged. The trigger marker says where the scope put the edge and a measurement mark says what a measurement measured; neither is something a user may move. The dump printsreadout instruments locked: N, which must be 1 -- a drag is not something a probe can perform, so the state is what is asserted. -
A pinned view is navigable within its data, and that is the bound -- not the request. Two things had to agree for this, and both were wrong in the same direction: the pause pinned on the window it asked for rather than on the snapshot it got (a cut clamped to the ring is shorter), and the held event pinned on the snapshot's recorded
pre_s/post_s-- the request again -- which a capture shorter than the request exceeds. Either way the shortfall was drawn as an empty margin, and a zoom-in moved the view into it, which is what made it look as if zooming caused it.tick()now re-states the pin fromRowSet::event_data_window()(the bound event series' own stamps) while an event is held, from the snapshot's stamps on a pause, andpin_pause_window()/the pause handler set the axis to it as well;[pause-probe]printsaxis=besidefrozen_x=and the axis must lie inside. ACapturingview is the exception and stays on the requested[-pre, +post]: its post side is empty on purpose, and that is the capture filling in view, not a margin. -
A pause is pinned from its snapshot's own stamps too, not from the window it requested. The pause freezes the visible window, but
publish_pause_view()cuts that window out of the ring and the cut is clamped to what the ring holds -- so a window reaching back past the oldest retained sample produces a shorter snapshot. Pinning the axis to the request left the shortfall as an empty margin on the left, visible before any zoom and made worse by one: zooming in moved the view into the empty part, which is what made it look as if zooming caused it. The pin is the snapshot's own stamps now.[pause-probe]printsaxis=besidefrozen_x=, and while paused the axis must lie insidefrozen_x-- equal at the moment of the pause, a subset afterwards if the probe zoomed in. Before the fix the two disagreed outright:axis=[-1.44..0.56]over data that started at0.0. (The event pin already worked this way; the pause now agrees.) -
The event axis is pinned from the snapshot's own stamps, not from the request. A capture may legitimately be shorter than asked for — the ring need not reach back a full pre-trigger share on the first latch after arming, so
snap=[-0.339..1.000]for a ±1 s request is correct there rather than a defect. -
The frame that frames a snapshot is the one that consumes its
event_seq(). The tick readsdisplay_mode()before it takesmutex(), while the producer publishes inside that mutex and lowerstrigger_pendingonly at the end of its packet — so a tick can readCapturing, block on the lock throughpublish_event(), and reach the framing step with a new sequence number it is not bound to. (The other case is benign and was always handled: a publication and the next edge in one frame, where the capture has framed its own window.) Advancingseen_event_seqon such a frame dropped the pin permanently — the axis kept the capture's requested[-pre, +post]over a shorter snapshot andevent_heldstayed false, lettingclamp_to_data()drag the frozen view — so the test isseq != seen_event_seq && display == Heldand the pin stays owed until a frame is bound to the snapshot. Visible only at high rates, where the publish is long enough to lose the race often;[disarm-probe]'saxis=againstsnap=is the guard, which means running the disarm probe at 10 kS/s alone does not check this. -
A capture in progress is the one place the axis is pinned from the request. There is no snapshot to measure yet, so
DisplayMode::Capturingframes[-(span - post_span_s(span)), +post_span_s(span)]— computed with the producer's ownTriggerConfig::post_span_s(), not a second expression that rounds differently, because the marker at x = 0 has to land where the coming cut will put it. The latch then re-pins from the snapshot, and the two agreeing is why the picture does not jump at the latch. -
clamp_to_data()must be skipped whilecapturingas well as whileevent_held. A capture is live data on a pinned axis — the exact combination the clamp was written for and against: it would follow the live edge and drag the frozen window off the event. -
publish_event()cuts one absolute index range and uses it for the time ring and every value ring. That is what makesstampsand eachvalues[c]equal-length and index-aligned by construction instead of by two truncations that can disagree —EventSeries::gather()indexes both at the samei. -
live_window_locked()is the one live cut, shared bypublish_event()and the export. Both need the same absolute index range over the time ring and every value ring, or an export and a capture of the same window would hold different samples. Call it undermutex(). -
A trace's colour is indexed by signal, not by trace slot.
Row::color_indexstores it andsignal_color_index()(ui.cpp) computes it from the signal list, so a signal dropped on two rows is one colour and the signal panel's chip names a trace unambiguously.plot::Plotfixes a colour atadd_trace(), so changing one needs a figure rebuild — whichassign()already forces. A signal on no plot getsRowSet::unplotted_color()(grey) rather than no chip, so the panel's rows stay the same height. -
The export is collected at click time, not in the file-dialog callback. The live view keeps scrolling while the chooser is up, so a late collection would write a window the user never saw. The writer is picked by the chosen path's extension.
-
The measurements pane is refreshed on a cadence, and its cost is the window's, not the frame's. A measurement is a statistic of the visible window and a live view moves that window every frame, so "recompute when the window moves" is "recompute every frame" at O(visible samples) per measurement. With the pane holding four measurements over a 4.7M-sample window that measured 13.66 ms of tick mean (baseline 0.41 ms) and cost the run a third of its frames. Three things fixed it, and all three matter:
update_measures()recomputes at most everykMeasureRefreshUs(100 ms; 400 ms when a pass costs more thankMeasureHeavyUs), once when the window settles and once per mode change -- and reading that backwards is what made it worse first, 457 recomputes in one run, because a pinned window then recomputed every frame; the window is materialized in bounded chunks (never two whole-window f32 temporaries) and the loop holds it by reference (copying both vectors per measurement was 300 MB of memcpy a frame); andmeasure_stats()is one shared pass carrying both extremes with their first positions, the sum and the count, so average/min/max/peak-to-peak/amplitude need no pass of their own. Adding a type must not add a pass -- that is whatcompute_measure()'sstatsargument is for. The marks' ink is set where they are created (install()inplot_host.cpp): 0.75 px and dashed 4/4 throughPlot::set_cursor_stroke(), which is per cursor because the trigger marker (also a cursor) keeps the solidline_width_pxstroke. A new measure mark must take that stroke -- it is created in the same loop, so it does; a new instrument should not. The marks the pane draws (set_measure_marks()-- full-widthh_rulers and full-height cursors, i.e. the "lines across the plot") are re-placed every frame from the result, so a throttled pass is a stepped ruler: acceptable, because the ruler and the number beside it come from the same pass and agree. What is not acceptable is a ruler that appears and disappears, so an invalid pass falls back to the last valid result for its level marks and only drops its time marks: a level is a property of the signal (a mean, a top, an amplitude) and outlives the window it was measured in, while a time mark is a position inside that window and may not. Both are emitted in one pass after the numbers, never inside the loop that computes them: that loop has threecontinues (an unresolvable signal, a channel with no window) and each one was a way for the rulers to blink off and back. Fixing one and not the others is how that bug survived a round of "fixes".clamp fires: Nin the dump is the other half of this:clamp_to_data()compares with a relative tolerance, because clamping to exactlywindow_s_can leave the group's span an ULP above it and a clamp that fires every frame is a re-frame loop, not a clamp. -
A measurement must not read its window raw. Both passes go through
plot::data::MinMaxPyramid, which the producer already maintains at amortised O(1) -- so the pane inherits the summary of the new samples rather than computing one, which is what the "evaluate only on new samples" ask comes to here. Throttling the cadence (above) bounded how often; this bounds how much, and it is the larger factor: 19.7 ms per recompute to 1.2--1.6 ms, reading 0.1 % of a 2 M-sample window. Pass one (measure_scan_buckets()) adds whole level-0 buckets -- 64 samples each, carrying min, max an exact sum and a finite count -- and locates each extreme to a bucket; one rawmeasure_scan()of that bucket alone then pins it to a sample, two buckets at most whatever the window's size. Pass two (measure_accumulate_bucket()) folds a bucket whole only when it lies strictly on one side of its level (k.lo > levelork.hi < level): then every sample decides the same side, none is at the level, and an entering edge is exactly the bucket's first sample -- which is where the raw walk would have found it, so this is exact and not an approximation. It refuses a bucket that straddles, and a bucket holding a non-finite sample, because the raw walk takes the first finite sample of the new side and an aggregate cannot say which that is; the caller then opens that bucket. Four things this rests on, each of which breaks it silently:- Exactness is the point, so nothing may stride or decimate.
peak to peakmust still read exactly2on a unit sine andtopexactly0.636620(2/pi). A bucket's sum is a sum. - The bucket index space must be the window's.
ChannelBuffer::acquire()callspyramid_.drop_before(values_->base()), so a channel's pyramid is in the value ring's absolute index space -- the same spacelive_window_locked()answers in, since a channel's stride is always 1. The snapshot's pyramid is built from sample 0 and never dropped from, so its space is the snapshot's own.MeasureWindowReader::origin_is what turns one into a window offset, and a mismatch there measures the wrong samples rather than failing. - The raw path stays, and stays exact, for the window's partial edge buckets, for the
samples produced since this frame's
acquire_all()(one frame of production, so bounded), and for the straddling buckets. A window shorter than a bucket degrades to O(window) and must still be right. - Both passes walk the same fetched bucket list (
App::measure_bucket_scratch, reused across recomputes -- a 2 M-sample window is 32 k buckets). Fetching twice is a second walk of the pyramid for nothing.measure cost:'scovered/buckets/rawis the guard, and it needs the fast mock to say anything.
- Exactness is the point, so nothing may stride or decimate.
-
The math channels' cost is the producer's, and the instrument must not be part of it.
push_math_sample()runs once per stored slot -- up to the wire rate -- so what a regression here looks like isgaps, never a frame time. Three things were taken off that path and must not come back: astd::vectorlocal (a heap allocation and free per slot; it is the membermath_values_,assign()d), aSourcenode's channel resolved by name per sample (a linear scan with astd::stringcompare per candidate, per node, per slot, behind a type-erased call --math_prepare()resolves it once intoMathNodeRt::src, and every rebuild re-prepares), and afetch_addon the cost counters. That last one was 20-25 % of the measured figure: alock xaddcosts about what a two-node graph's whole arithmetic does, so counting the slots cost more than the work being counted. The counters are producer-written only, so each is a relaxedstore()of a value already in hand (math_slot_count_and friends). Two things about the figure itself:- It is sampled, one slot in
kMathCostSample, and it is an upper bound. A slot's work is the same order as oneclock_gettime, so the measured slot carries a clock read. Timing a run of consecutive slots to amortise that away is wrong and was tried: the producer gets its slots a packet at a time, so a run long enough to be worth timing straddles a packet boundary now and then and charges the measured slots the wait for the next packet. At 2000 samples a packet a 64-slot run crosses one ~3 % of the time, and a ~1.5 ms gap spread over 64 slots reported 799 ns/slot against the true ~80. One slot is always inside one packet. - 78--101 ns/slot is one reading. Four identical-config runs spread that far, so the per-slot
path is at the noise floor of this harness and a micro-optimisation cannot be shown to work
here. Converting
MathRuntimefrom eight parallelstd::vector<double>to one array ofMathNodeRtwas measured and bought nothing -- at six nodes the whole runtime is L1-resident either way. It was kept for the shape, not for a number; do not expect a cache argument to pay at this size.
- It is sampled, one slot in
-
The status line's per-second fields are measured once a second, not per frame.
App::stored_rate/declared_rateand the label's tooltip are refreshed on the same tick as the channel dump; the status line only formats what is already there. The badge's markup is likewise rewritten on a change of phase (twice a second), never on all sixty frames. -
A
notify::<prop>handler takes three parameters, avalue-changedhandler two.GObject::notifypasses(GObject*, GParamSpec*, gpointer)whileGtkSpinButton::value-changedpasses(GtkSpinButton*, gpointer), so one callback cannot serve both: on thenotify::connection theGParamSpec*lands in theuser_dataslot and the handler dereferences it as itsApp*.G_CALLBACKis a cast, so nothing warns. That is why the trigger panel's spin buttons and its dropdowns have separate thin entry points (on_trigger_field_changed,on_trigger_drop_changed) onto onepublish_trigger_fields(): changing the edge or Normal/Single dropdown used to segfault outright.UDPSCOPE_PROBE_FIELDS_MSis the guard. -
GtkDropDown'snotify::selectedarrives after theset_selected()that caused it returns, so theupdating_panelsflag — set around a programmatic write and cleared when it returns — does not suppress it. Where the write is part of a readout the handler reads back, that is a feedback loop:sync_window_display()writes a value rounded for reading into the window entry and picks the unit for it, and the late notify hadon_window_unit_changed()re-parse that rounded text, turning a 500 s request into 499.98 s. The guard isApp::window_sync_unit— the unit we last synced — and a selection equal to it is ours, since GTK only notifies on a change. Any new control that both writes and reads aGtkDropDownneeds the same shape; the flag alone is not enough. -
The window control's label is width-locked and ellipsized (
kWindowLabelChars), for the same reason as the status label: it sits in the header bar above the plot, so a label that grew from1 sto123.456 mswould widen the bar and relayout everything under it on a mere change of window. That is also why the window is typed into a popover rather than shown in the bar. -
No plot interaction changes the time scale; only the window control does. The time scale is more than the axis:
RowSet::window_s_also feedsScopeModel::set_event_span()(the trigger's pre-trigger share is a share of this window),ScopeModel::set_target_span()(which may re-plan the partition, move the decimation and drop the history), the window control's readout and the session file. A zoom or a pan does not touch it by construction — the controller writes the member axis andclamp_to_data()caps the span atwindow_s_, so nothing has to be written to keep it that way. Neither do the two fits: a fit is the undo of a zoom, soRowSet::fit_x()restores the window that is already set and readswindow_s_without ever writing it. Both fits used to frame the data instead and movewindow_s_alone, which was wrong twice over — a gesture-triggered re-plan of the rings, and an event span left on the old window until the next decade step or unit change applied the fitted value for real (one action, two changes of time scale). -
apply_time_window()is the only place the time scale changes, and the window control is its only caller. It clamps towindow_limit()and movesset_target_span(),set_event_span(),RowSet::set_time_window(), the control and the session save together — the partition before the axis, so the axis is framed over the rings that will exist. A fit must not come here for two reasons: it is not entitled to the scale at all, andset_time_window()unpins and re-arms the live follow, which would throw a held event or a paused view onto a live edge it has nothing bound to. Paths that change the span for their own reasons (an event pin) callsync_window_display()alone, which only restates the control and never moves the plot — callingapply_time_window()there would clamp a legitimately short capture up to the requested window. -
The x labels go relative on the tick step against the label resolution at the axis' magnitude, never on the span.
RowSet::update_time_labels()'s question is only ever "can this step be written at this magnitude":TickFormat::Timecarries three significant figures below a minute, whole seconds throughm:ss/h:mm:ssup to a day, and three figures of days above — so the resolution is a step function of the magnitude, whichtime_label_resolution()states and nothing else may re-derive. The old test wasspan < 1.0and it was wrong in both directions: a 2 s window at twenty minutes of uptime reads20:00on every tick and stayed absolute, and a triggered sub-second window — whose axis is already[-25ms, +25ms]about the edge — went relative and so re-anchored its ticks away from the trigger, with at0caption that had nothing to explain. Three details hold it up. The step is read back from the majors the last layout produced, which is not a feedback loop (a tick's value depends on the range and the axis' pixel width, never on the label form) but is absent before the first layout — and with no ticks the form must be left alone, because a step guessed from the span would be latched by the hysteresis below. The hysteresis is a 1.05 band on leaving the relative form. And entering it needs a ULP of slack (1 - 1e-9): a step differenced from two tick values and a resolution frompow(10, n)differ in their last bits when they are the same number, andstep == resis a readable axis (1.73s,1.74s,1.75s— one in the last digit is exactly enough), so the bare comparison picked a different form from one frame to the next. The guard is[timelabels-probe]'sdistinct=1, which asserts what the form is for rather than which form a given window gets. -
The
t0 = …caption is chrome, never an annotation. It isPlot::set_x_note()(added todeps/plotterfor this), drawn byRenderer::draw_x_note()on the legend's owndraw_readout_panel()backplate in the bottom-left corner of the axes, in pixels. It was anAnnotationfirst, and an annotation's anchor is a data coordinate: the caption therefore crawled across the plot under a pan and drifted under a zoom, which is what "[t0=...s] move slightly around when user zoom/pan" was. Three things follow. The text is the host's, because the caption has to name the anchor precisely enough that label + caption is the exact absolute time andformat_time()would round it to three figures ("t0 = %.6g s"); only the bottom row of the figure carries one, every other row being set to""; and the renderer measures it rather thanPlot::layout()— it reserves no space and nothing can be picked on it, so there is no rect to read back, and a caption explaining the x labels must not be able to move the layout those labels were measured into. The guards are the library'sthe_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move(the footprint is the pixels a frame with the caption differs from one without, and a pan plus a 64× zoom must give the same box to the pixel) and[timelabels-probe]'snote=, which readsPlot::x_note()back. -
fit_x()restores the whole of what the view may show, which is not alwayswindow_s_. Live, that is the newestwindow_s_at the live edge (set_time_window(window_s_), the same path a resume takes). Pinned — a held event, a filling capture or a paused view — it is the pin's full range, which is the snapshot's own stamps and may be legitimately narrower than the requested window. It must write the per-row member axes as well as the link group:hold_pinned_window()prefers a member that differs frompinned_stated_, which is how it tells a real gesture from its own echo, so writing the group alone is reverted to the zoomed range on the next frame.fit_all()is the y fit plus this, shared rather than duplicated so the two cannot drift apart. -
The header bar's buttons are icon-only, so headless probes must reach them through the
Apppointers (trigger_toggle,pause_button, …), not viafind_labelled_button(). The pane switches are toolbar buttons too (cursors_toggle,measures_toggle,math_toggle-- packed withheader_bar_pack_end(), where the first packed is the rightmost, and the bar is an ancestor rather than the parent because packing parents into an internal box), and the cursors pane owns its own Reset button rather than the overflow menu owning one for it. The dump printspanel controls: … ; toolbar, left to right: …to say where they are and in what order. The widget tree's child order is the visual order left to right, so that line reads as the reverse of the pack order -- it is how an arrangement gets checked with no screenshot, and it is what finally settled the pack-order rule above (two comments inbuild_toolbar()disagreed with each other and one of them with reality): it is computed on the first tick, because the dump runs after the window is destroyed and asking GTK about a widget then is four criticals, which is how the first version of that line earned them. What stays in the menu is what acts on the data (Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text. -
Six of this app's icons are its own, and they are a
GResourcecompiled into the binary. No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a measurement ruler, and the stock names those buttons used to carry said something else entirely: a record dot for the trigger and three different magnifiers for cursors, measurements and math.src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math}-symbolic.svgare the four action icons; beside them insrc/icons/scalable/apps/are the two application icons,org.udpscope.app.svg(128px colour artwork) andorg.udpscope.app-symbolic.svg(16px). Theappscontext is where an icon theme keeps an application's own icon and is where the in-process window icon resolves from; it is also the layout thatcmake/install_desktop_entry.cmakemirrors when it copies the same two files into$XDG_DATA_HOME/icons/hicolor/scalable/appsand.../symbolic/apps— those installed copies are what makesIcon=org.udpscope.appin the desktop entry resolve (a desktop shell cannot read a GResource compiled into the binary). All six are bundled bysrc/icons/udpscope.gresource.xml(CMake runsglib-compile-resources) and reached through onegtk_icon_theme_add_resource_path()inrun_ui(), right afteradw_init()— not at the top ofbuild_toolbar(), where it used to be: there are two consumers now (the window's own icon, set before the toolbar is built, and the toolbar's buttons) andrun_ui()is the one place ahead of both.adw_init()callsgtk_init(), which is what makesgdk_display_get_default()valid there. A resource rather than files beside the binary because udpscope is run straight out of the build tree and has no install step. Five authoring rules, each of which fails quietly:- GTK's symbolic recolour only reaches
fillonpath,rectandcircle. It wraps the SVG in a stylesheet for those selectors, so astroke, alineor apolylinekeeps the colour written in the file and stays dark on a dark theme. In a symbolic icon everything is therefore a filledpath— including anything that wants to look stroked, which is drawn as a closed band (two offset curves joined) instead. The rule is scoped to symbolic icons on purpose:org.udpscope.app.svgis 128px colour artwork that is never recoloured, so it strokes its sine freely, which is the sane way to draw a smooth one. Do not "fix" one to match the other. - Whole-pixel coordinates, because these are drawn at 16 px far more often than not: the first draft placed features on half-pixels and the whole icon was a grey smear at 1x.
fill-rule="evenodd"is how the ruler's body is a hole and its ticks still fill -- a tick inside the hole is three crossings deep, which is odd. Nonzero would fill the hole back in.- Overlapping subpaths under the default nonzero rule must be wound the same way or they cancel instead of uniting. All of them run clockwise.
- A malformed SVG is a build error, and that is the only place it can be caught. Every
<file>insrc/icons/udpscope.gresource.xmlcarriespreprocess="xml-stripblanks", which makesglib-compile-resourcespipe it throughxmllint— so--inside an XML comment, which is illegal and which librsvg refuses the whole file for, fails the build with a file, a line and a caret under the offending character (…app-symbolic.svg:5: parser error : Double hyphen within comment). It has to be here because no runtime check can see it: librsvg refuses the file but the icon name still resolves, sogtk_icon_theme_has_icon()returns true and the icon draws nothing.gtk_icon_theme_lookup_icon()cannot tell either (see theapp icons:line under Verification),GMarkupaccepts the double hyphen because it is a lenient subset parser, andgdk_pixbufgoes through glycin here, which sandboxes a subprocess per load and refuses this project's own icons outright. The cost is that thexmllintbinary is a build dependency; it ships with libxml2, which GTK already requires.gtk_icon_theme_has_icon()is checked for every toolbar button and reported by the dump (seetoolbar icons:under Verification): a resource path that was never added does not raise an error, it drawsimage-missing.
- GTK's symbolic recolour only reaches
-
A shortcut table row's
accelis GTK accelerator syntax, and a pointer gesture has none.src/shortcuts.cpp's strings are handed toAdwShortcutsItem, which parses them withgtk_accelerator_parse()— so they are"space","<Control>e","<Control>slash","plus minus"(a space-separated pair is the "either of these" form the label draws as+ / -), never the prose"Ctrl+E"or"+ / -". Neither GTK's syntax nor libadwaita's knows a wheel or a drag:"Wheel"costs aFailed to parsewarning and draws an empty cell. Those six rows therefore carryaccel = ""and put the prose insubtitle, which is the one field that is free text. A row added with a parse error is otherwise silent — the overview still opens and the row is simply blank — so read the probe run's stderr forFailed to parseafter touching either table. -
The chooser runs before
ScopeModelexists, and--serveris what decides.Configcarriesserver_specified, set only by the--serverbranch, andmain()asks only when it is false — so declining costs no buffer and no socket, andScopeModel,Acquisition,RowSetandrun_ui()need no notion of "no source yet". It is--serveralone and not "no arguments":--multicaststill needs a server to fetch CONFIG from. Every documented probe passes--server, which is what makes the feature invisible to the regression net — a probe added without it would reach the chooser and hang.gtk_init()plus a nestedGMainLoop, and thenrun_ui()'sGtkApplicationafterwards in the same process, is a verified combination (gtk_init()is idempotent; running your own loop is the documented GTK4 replacement for the removedgtk_dialog_run()). -
A session file's name cannot say how its source was reached, so the file says it.
<host>_<port>.tomlcarries neither the multicast group nor the interface, so a chooser row could not reconnect to a multicast source at all.LayoutState::multicast/interface_addrare written in the top block, before the first[[table]]— a bare key after a table header is that table's key in TOML, which is exactly howenabledwas parsed into the last[[calibration]]record and lost without a word. A file from an older build has neither key and loads as unicast on the default route. The chooser's list filters to sessions that name at least one signal, because a probe run leaves a file per throwaway port and all of them holdsignals = ["", "", "", ""];known_sources()splits the filename at the last_, sincesanitize()turns anything unsafe in a host into one. -
save_layout_now()refuses to write whilesignals()is empty. A run against a source whose streamer was down receives no CONFIG, so every slot name stays empty and the save would overwrite a configured session withsignals = ["", ...]. The chooser then filters that file out — a source whose streamer was merely down during one run would silently vanish from the only list it can be picked from, for ever. Keeping the old file loses nothing: a run that configured nothing had nothing worth persisting. -
The status bar is two tiers, and the dump reads the detail one.
format_glance()writes the bar andformat_detail()writes both the popover's prose andApp::status_detail, which is whatexit_probe_step3/exit_probe_step2copy intodbg_mode_live/dbg_mode_triggered. Those two used to read the label, and the label no longer carriespackets,gaps,plot 1:or the buffer figures — so agtk_label_get_text()there would silently reduce every documentedmode …:assertion to four fields. The glance line's state field is one word from a fixed set (live,paused,armed,capturing,event held,disarmed) for the layout reason above; a seventh word, or a field whose width tracks the stream, breaks the row the plot is laid out under. -
The ⓘ popover's widgets are built once and only their text is set. It is visible when the tick updates it, so a rebuild is a per-frame relayout of a widget on screen.
update_status_popover()only ever callsgtk_label_set_text(), andpopover_rebuildsin the dump is the assertion. -
The About dialog, the help window and the shortcut overview are opened by
show_about(),show_help()andshow_shortcuts(), and each closes with the matching call. There is exactly oneGtkWindowamong the three — the paged help window. It closes withgtk_window_close(), which emitsclose-requestso the handler that nulls the file-scope pointer runs.gtk_window_destroy()does not emit that signal and must not be used — the math dialog fell into exactly this trap, and the pointer then points at a dead widget until the next attempt to open the same window. The other two — the About dialog (anAdwAboutDialog) and the shortcut overview (anAdwShortcutsDialog) — areAdwDialogs and notGtkWindows: they close withadw_dialog_close()and clear on::closed, there being no::close-requeston anAdwDialog. Both share oneon_dialog_closed()handler keyed by a pointer to the pointer it must null (&g_about,&g_shortcuts), which is what keeps one handler honest for two dialogs. OneGtkWidget*(orAdwDialog*) per window, nulled in its own close handler, is the shape that prevents the double-open defect across all three. -
build_info.cppis generated, never committed.cmake/write_build_info.cmakeruns at configure time (soadd_executablecan name the file) and again on every build throughudpscope_build_info(so the commit and the timestamp belong to the binary that is running).gitabsent leaveskCommitat"unknown"and the build succeeds. The About dialog and the[help]probe both readkVersion,kCommitandkTimestampfrom it;settings=in the probe readsconfig_dir(), which is the path the session file is written to. -
The keyboard zoom's modifiers are not the wheel's.
+/-with Ctrl restricts to x and with Alt to y, while the wheel keeps upstream's Shift = x / Ctrl = y convention. The two cannot agree: Shift is unusable on the keyboard path becausekey_from()folds'+'and'='ontokKeyZoomIn—+is Shift+=on common layouts, so a bare zoom-in already arrives withMod::Shiftset. Ctrl+Alt together zooms neither axis, which needs no special case. The guards aredeps/plotter's ownctrl_plus_zooms_only_xandalt_plus_zooms_only_ytests, plusUDPSCOPE_PROBE_ZOOMKEYS_MS. -
A help page is one
.txt, one<file>line in the GResource manifest, and onekPagesrow. The bodies are Pango markup in a second GResource (--c-name=udpscope_help, so its generated symbols cannot collide with the icons'), validated withpango_parse_markup()at load; a page that fails either lookup or parse renders a one-line stand-in and is excluded fromparsed.UDPSCOPE_PROBE_HELP_MS'sparsedmust equalpages. A page may also carry screenshots (HelpPage::images, a null-terminated list of resources). They areGtkPictures with a height request: one createdcan_shrinkasks for no height at all inside a box, so without the request it is allocated nothing and the page renders its text with a silent gap where its screenshots should be — which is what the first version did. -
find_accel()masks the modifiers before comparing. Lock, Num-Lock and the pointer buttons all arrive inGdkModifierType, so an unmasked compare matches no table row at all once Caps Lock is on. -
Every source file carries an SPDX MIT header; match it in new files.