Files
udpscope/CLAUDE.md
T
2026-10-02 11:30:54 +02:00

42 KiB
Raw Blame History

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

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

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.

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; 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
  • 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_MS only 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=1 followed 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, never gtk_widget_activate(), which holds a GtkButton for its 250 ms ACTIVATE_TIMEOUT and then emits into a button the rebuild has already destroyed.
  • UDPSCOPE_PROBE_PAUSE_MS — pause acquisition mid-run so layout numbers are measured on a still picture.
  • UDPSCOPE_EXPORT_MS (+ optional UDPSCOPE_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.w must collapse to a single value and axes.x, axes.w and y-gutter must settle. note_y_gutter() is monotone (plot_host.cpp), its one-shot widest-label reservation can land a frame or two after the first measured frame, and the figure serves its floor out of the label strip (y_width = max(measured, floor - y_title_w), plot.cpp) so the reservation converges over two layouts rather than one. A y-gutter 44..74 dragging axes.x 67..80/axes.w 546..559 with it is that settle, not a twitch — confirm it with the changes line below. A range that keeps moving is the "twitching axis" bug: the plot being re-measured every frame. x-desync must read 0: it counts frames in which any row's x range differed from row 0's by more than a pixel of data, i.e. frames the x link group did not reach. x-ticks is content, not layout, and legitimately varies by one as a live window slides over a round number. The companion axes.w changes: N (last at frame F, side panel up=S) line separates the two readings: 0, or a handful near the start, is a settled layout; a count near the frame count is the twitch. Runs far longer than the documented probes have shown one ~320 px step — the side panel's width — that reverts; it is intermittent and unexplained, so read changes before calling a range the twitch bug.
  • [trig-probe] ... trigger=on ... display=... epoch=... (stderr, UDPSCOPE_TRIG_TRACE_MS) — with trigger=on, display must read event(frozen), live(capturing) or nothing, never plain live, except for the single interval that contains the toggle itself — recognise it by latches=0 and the next probe reading nothing or live(capturing) with a frozen axis=. Plain live with the trigger on is the old "the plot keeps scrolling in trigger mode" defect; the named live(capturing) is the feature. In that state epoch= must equal minus the capture's edge and axis= must stay at [-pre..+post] across consecutive probes while data='s upper bound climbs towards +post — an axis that moves instead means the capture is being treated as a live view (clamp_to_data() reaching a pinned window). epoch= must be back to 0.0000 in every other state. Note the probe reads pending= fresher than display= (which is last tick's decision), so a single pending=0 display=live(capturing) line is one frame of sampling skew, not a defect. And snap=[a..b] must equal axis=[a..b], with b positive by roughly the post-trigger share: that is the "post window is empty" defect's observable. drawn=[a..b] must equal data=[a..b] in both modes: data is x_at() (what the crosshair reads) and drawn is x0() + gather() (what the renderer maps), so a series that breaks the "gather emits x relative to x0()" contract shows up here as the two disagreeing by exactly x0() — a trace drawn a whole pre-trigger window left of its axis, invisible once the view is narrower than the shift, while the crosshair keeps reporting correct values.
  • [trig-probe/idx] n=.. bounds=[..] dx=.. dx_real=.. dx_ratio=R got=[a..b) want=[c..d) (stderr, same probe) — got must contain want (a <= c and b >= d), and dx_ratio must stay within about a percent of 1.0. got is the index window Renderer::draw_traces gathers, recomputed here from its own (x - x0()) / dx() uniform grid formula; want is the same window found by binary search over the real time ring, which is what x_at()/neighbours() — and so the crosshair — use. A dx_ratio above 1 scales every index up, a lands past the first visible sample, and the samples in between sit in the buffer, readable by the crosshair, and are never drawn: the live view's older part goes blank while its newest part looks fine. Also read axis= in the companion line against the window control: line in the dump — a live axis narrower than the requested window is the same symptom from the other cause (see the clamp_to_data() invariant 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) — got must equal want and N must be exactly the window times the stream's real rate, plus one (2 s at 10 kS/s is n=20001, not 19999). got shorter than want on the left means the edge was accepted before the history reached back a full pre window (the gate in TriggerEngine::process()); shorter on the right means the engine latched early or the cut lost the newest samples. An N one or two short of the exact count is the same class of bug, not rounding: publish_event() clamps the slice to every value ring's produced(), so publishing before each channel of the latching sample has been pushed silently drops it.
  • [disarm-probe] was_capturing=1 display=.. seq=A->B events=C->D snap=[a..b] n=N axis=[..] (stderr, UDPSCOPE_PROBE_DISARM_MS) — with was_capturing=1, display must read event(frozen) and snap/axis must be [-pre..e] where e is how far the post window had filled, with n that span times the stream's real rate plus one. display=nothing (or a snap from the previous event) is the "disarming mid-capture makes the event completely disappear" defect. seq must advance by exactly one while events does not move: no event fired, so the partial publish deliberately leaves trigger_latches() alone — which is also what keeps the trigger events: N total, M at the disarm line in the dump meaning what it says.
  • [export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] stream=[..] (stderr, UDPSCOPE_EXPORT_MS) — time= must equal axis= and rows= must be the span times the stream's real rate, plus one (2 s at 10 kS/s is 20001). The export is the visible range at the stored rate, so a count far short of that is a table cut from the LOD pyramid, and a time= narrower than axis= is a window searched on the wrong clock (the live branch subtracts display_epoch() before searching the ring). In mode=event, stream= must be the edge ± the window; in mode=live it equals time=. The one exception to both: an export taken mid-capture reports mode=live with time=[-pre..e] where e is how far the post window has filled, and stream= offset from it by the edge (so stream - time reads 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 the rows= count. The two bytes= lines are the writers: the .npy is 64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols), so a size that is not that is a mispacked record. Read it back with pd.DataFrame(np.load(path)) after changing either writer.
  • [window-probe] <action> label=.. entry=.. unit=U window=W event_span=E cap=C decim=M stored=R aa=F buffered=B (stderr, UDPSCOPE_PROBE_WINDOW_MS) — window must equal event_span on every line (the trigger's event span is the display window; a capture cut to a different length than the axis frames is the "post window is empty" defect's cousin) and neither may exceed cap. The unit ms->s line is the one that catches the feedback loop: changing the unit re-interprets the typed number, so "500" in ms becomes 500 s exactly, and a window=499.98 there means the control re-parsed its own rounded readout (8.333 min × 60) instead of the request — see the window_sync_unit invariant below. label is a reading to three decimals in a natural unit and is expected to be lossy (500 s reads 8.333 min); it is the window= figure that has to be exact. For the resampling: buffered must be ≥ window on every line — that is what the decimation is for, and a buffered short of the window is the old "the control is capped to what the ring holds" behaviour coming back. decim must be a power of two, stored × decim must equal the wire rate, and aa must be exactly stored / 2. Run it under --buffer-mb 1, which saturates in a few seconds and makes windows past ~6 s decimate; at 200 MB nothing inside --max-window needs to.
  • The anti-alias check is the same probe run against a waveform above the stored Nyquist: --buffer-mb 1 --freq 2000 with the probe ending on a 60 s window gives decim=16, a 625 S/s stored rate and a 312.5 Hz corner, and the channel dump's ch 0 'sine' ... y=[-0.059, 0.079] is the assertion — a boxcar of 16 at 10 kS/s passes 2 kHz at about 6%. A y=[-1, 1] there means the filter is gone and the 2 kHz sine has aliased to a full-amplitude 125 Hz one, which looks entirely plausible on screen. This is the only check that distinguishes averaging from striding.
  • [layout-probe] <action> rows=N buttons=B rows_y=[..] close=[..] add=[..] traces=[..] (stderr, UDPSCOPE_PROBE_LAYOUT_MS) — buttons must be 2N+1 while the editor is up (one × per row, one + per boundary) and 0 on the editor off line: the buttons exist only while the editor does. traces= is the real assertion: an insert or a remove moves the rows' contents with them, so add above 0 must read [0:-|1:sine,…] — row 0's signals now on row 1, not renumbered onto the new empty row — and remove 0 must pull them back to 0:. A traces= where the signals stayed on row 0 means carry_row() was skipped and the rows were renumbered under the user. Geometrically, each close= must equal its row's top from rows_y plus the pad, and add= must be centred on the boundaries (so strictly increasing, the first at 0 and the last clamped to the area's bottom). The rows_y spans must tile: a gap other than kRowGap means the figure and the overlay disagree about where a row is, which is how a × ends up on the wrong row. Also read x-desync 0 in the dump — every row the editor adds has to join the one x link group. Run it at --plots 8 as well: the cap and the floor are the two refusals, and because the probe emits ::clicked straight at the widget it reaches the handler even when the button is insensitive, so add above 0 there must leave rows= and traces= untouched rather than growing a ninth row.
  • header: window_button_w=W label_w=L bar_w=B (stderr, once a second) — W must be a small constant and must not track B: the window control is a header-bar child, and an expanding one is measured into the title's centre box, so it stretched from the label to the title (516 px at a 1270 px bar before the fix, 121 px after). A W that grows with the window width is that bug back.
  • The session layout round trip (~/.config/udpscope/<host>_<port>.toml) — run once with a drop and a window change, then again with no interaction: the second run's dump must read layout loaded from ... and layout: rows=R window_s=W row0=[...] with the first run's values, and the signals it names must come back acquired (a row 0 slot 0 -> channel ... samples=N line). Delete the file before a run whose layout you did not mean to inherit (127.0.0.1_9540.toml and friends accumulate otherwise).
  • badge frames: lit=L dim=D idle=I — the activity badge's phase per frame. Over a run that acquired anything both lit and dim must be non-zero: that is what "it blinks" means, and a badge stuck lit is indistinguishable from a working one in a screenshot. One or two idle frames at the start are the frames before the socket connected; a large idle is correct only when the run paused or disarmed (a UDPSCOPE_PROBE_DISARM_MS run should show hundreds). The mode ... lines carry the rest of the status line, so rate and last event ... ago are checked there.
  • frame cost: tick A/B ms over N, draw C/D ms (max at frame F), paint E/G ms over M and frame path: raw=R lod=L frames, segments S/T (mean/max), visible max V samples — the drawing cost, split into the frame tick, PlotArea's own draw (timed from the frame clock's before-paint to the end of the host's render handler) and the whole GTK paint. segments max must stay in the tens of thousands and lod must dominate raw on any run holding a large capture: a max in the millions with lod=0 is the empty-pyramid defect (see the MinMaxPyramid invariant 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. raw frames are expected and cheap below 64 samples per pixel, which is where level_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 late F is a frame whose own geometry was expensive and worth tracing. tick's max is the publish stall: publish_event() builds the snapshot's pyramids under mutex(), so a latch at over 1 MS/s costs one ~40 ms tick, which must not produce gaps. Both these lines and the disarm probe's axis=/snap= need a fast source to say anything — tools/mock_udpstreamer.py --rate 1300000 --batch 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.

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 frozen EventSnapshot of 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. A ChannelBuffer is a plot::data::Series over a (value, time) ring pair.

  • SlotSeries (src/channel.hpp) — a trace's stable series object, rebound each frame to either a ChannelBuffer or an EventSeries.

  • RowSet (src/plot_host.hpp) — one plot::Figure of up to kMaxRows rows (added and removed one at a time by the layout editor), each a borrowed plot::Plot* with kSlotsPerRow fixed 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 be XFollow::Off, since Figure::link_x() refuses a Live member), 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/.csv writers. 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::Plot cannot remove or retarget a trace. Hence fixed trace slots created up front and rebinding via SlotSeries; 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, so RowSet::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 why rebuild_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 handed App::placeholder_plot, never a row: the area keeps a reference to the plot it was created with, and a rebuild destroys every row.
  • A row's contents are state of the RowSet, not of the figure. insert_row() and remove_row() shift that state with carry_row() and let rebuild_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 whole Row: a Row also holds the figure handles (plot, the axis and cursor ids) and the SlotSeries objects the traces point at, and those belong to the row's position. It clears the destination's slot bindings for the same reason — the next bind() re-resolves them, and without the clear the frame in between draws the moved row's samples under the new row's legend. Both call rebuild_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 GtkOverlay child is picked wherever its allocation is, and gtk_widget_set_can_target(FALSE) takes a widget's children out of picking with it, so a full-area GtkFixed would 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 from FigureLayout::rows — the rects the frame was drawn from — divided by PlotArea::device_scale(), because a Rect is 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 when time_stride actually changes, so walking the window inside one decade is free; it asks plan_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 takes mutex() 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 picks time_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-channel stride[i] stays 1: a stride of round(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 of time_stride (value_acc_/value_acc_n_, and time_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; the UDPSCOPE_PROBE_WINDOW_MS run under --freq 2000 is 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 whenever time_stride or 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's rate field is worth reading.
  • The trigger sees the stored (filtered) signal, not the wire signal. engine.process() is fed the averaged value, inside the if that 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 Series grid contract does not hold for ChannelBuffer. x is a streamed timestamp, so dx()/x0() report a nominal grid (LOD level, coarse snap) while gather(), x_at(), neighbours() and x_bounds() use the real time ring. gather() emits x relative to x0() because absolute epoch seconds do not survive f32.
  • gather() emits x relative to x0() in every series here, EventSeries included, and the renderer adds x0() back. 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 capture x0() = 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 reads x_at(), which was right all along. The probe's data= vs drawn= is the guard.
  • 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, whatever n is, so a series that answers pyramid() with an empty one is re-gathered sample by sample on every frame it is bound. That was the "large data is laggy" defect: EventSeries had 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 on EventSnapshot, not on EventSeries: the snapshot is immutable, so it is built once in publish_event() inside the O(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:'s segments is the guard.
  • EventSeries::gather() emits a bucket's envelope, not every 2^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, so window_base() is 0 and a window index is the absolute one.
  • 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 of 0 would reach x_bounds(), and from there dx() and x0(), which is the renderer's whole index mapping — one unreadable slot inflated dx() by the ratio of elapsed stream time to the retained window and clipped the older part of every live view. The library's StreamSeries is immune for a reason worth copying: its x is x0 + 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.
  • clamp_to_data() may not shrink the view below the requested window. It runs every frame and cannot distinguish "the user zoomed out past the data" from "the ring has only just started filling", so it clamps against max(retained, window_s_): a partly filled window is left empty on its left, which is what a scope that just started should look like. Clamping to retained alone turned a requested 2 s window into the ~0.02 s the first frames held, permanently — nothing there grows a span back. 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::Si can 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 the rate field collapses to one figure or a range instead of a per-channel list, and why format_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's set_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_pct is 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. TriggerEngine latches once the stamps it is fed have advanced past the post window, and ScopeModel::publish_event() cuts the snapshot by binary search over the time ring. There used to be a measured samples-per-second estimate in Acquisition doing 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.
  • 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 reads display_mode() before it takes mutex(), while the producer publishes inside that mutex and lowers trigger_pending only at the end of its packet — so a tick can read Capturing, block on the lock through publish_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.) Advancing seen_event_seq on such a frame dropped the pin permanently — the axis kept the capture's requested [-pre, +post] over a shorter snapshot and event_held stayed false, letting clamp_to_data() drag the frozen view — so the test is seq != seen_event_seq && display == Held and 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]'s axis= against snap= 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::Capturing frames [-(span - post_span_s(span)), +post_span_s(span)] — computed with the producer's own TriggerConfig::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 while capturing as well as while event_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 makes stamps and each values[c] equal-length and index-aligned by construction instead of by two truncations that can disagree — EventSeries::gather() indexes both at the same i.
  • live_window_locked() is the one live cut, shared by publish_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 under mutex().
  • A trace's colour is indexed by signal, not by trace slot. Row::color_index stores it and signal_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::Plot fixes a colour at add_trace(), so changing one needs a figure rebuild — which assign() already forces. A signal on no plot gets RowSet::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 status line's per-second fields are measured once a second, not per frame. App::stored_rate/declared_rate and 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, a value-changed handler two. GObject::notify passes (GObject*, GParamSpec*, gpointer) while GtkSpinButton::value-changed passes (GtkSpinButton*, gpointer), so one callback cannot serve both: on the notify:: connection the GParamSpec* lands in the user_data slot and the handler dereferences it as its App*. G_CALLBACK is 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 one publish_trigger_fields(): changing the edge or Normal/Single dropdown used to segfault outright. UDPSCOPE_PROBE_FIELDS_MS is the guard.
  • GtkDropDown's notify::selected arrives after the set_selected() that caused it returns, so the updating_panels flag — 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 had on_window_unit_changed() re-parse that rounded text, turning a 500 s request into 499.98 s. The guard is App::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 a GtkDropDown needs 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 from 1 s to 123.456 ms would 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.
  • apply_time_window() is the only place the display window changes. It clamps to window_limit() and moves RowSet::set_time_window() and ScopeModel::set_event_span() together, because the trigger's pre-trigger share is a share of this window. Paths that change the span for their own reasons (Fit, an event pin) call sync_window_display() instead, which only restates the control and never moves the plot — calling apply_ there would clamp a legitimately short capture up to the requested window.
  • The header bar's buttons are icon-only, so headless probes must reach them through the App pointers (trigger_toggle, pause_button, …), not via find_labelled_button(). The Arm/Disarm button in the trigger panel still has text.
  • Every source file carries an SPDX MIT header; match it in new files.