# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Build and run ```sh 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`. 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: ```sh 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: ```sh 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. - `[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**, not `signals().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 as `measurements: =`; on the fast mock (`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`) a unit sine must read `peak to peak=2`, `amplitude=1`, `average=0` and **`top=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=[..] =... 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 print `marks=0` while measurements with `show_on_plot` are 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` follows it: **read it against a run with `UDPSCOPE_PROBE_MEASURE_MS` unset**, because a frame mean cannot attribute a cost. - `[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`) — **`held` must 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, which `Figure::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 reads `held=1` with 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=2200` puts the pan and its check inside the pause. With `hold_pinned_window()` disabled it reads `held=0` and the axis sits at the asked range. It then asks for a **zoom-in** (a window a quarter of what is on screen) and reports `group=`/`member=` beside it: **`allowed` must 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: a `g_timeout` was 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: **`limited` must be 1**, i.e. `span <= window`. Run it with a ring that holds *more* than the window (`--buffer-mb 64` past the window's own seconds) — that is the state the old `max(retained, window_s_)` bound leaked in, and it showed up here as `span=2.502550 window=2.000000 limited=0` against `span=2.000000` after the fix. - `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] paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]` on stderr): it presses the **button**, not `Acquisition::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 lines **`ring_produced` must grow** (the producer never stops — it fills the live ring exactly as it does with the trigger off), **`event_size` must be non-zero exactly while paused** (the plot is bound to the frozen snapshot, not the ring) and **`frozen_x` must not move between the pause and the resume**; after the resume `event_size` is 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` (+ 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 *today* — `neighbours()`, one sample of slack either side — and `want` is the same window from the same call, so read this line for the **rate**: `dx_ratio` is the nominal `dx()` against the real last-spacing, and it is the number that used to matter. It above 1 (every index scaled up, `a` past the first visible sample, the samples in between in the buffer and readable by the crosshair but never drawn) was the live view whose older part went blank while its newest part looked fine. That is a *fixed* defect: the renderer no longer walks the grid at all, because on a ring-backed series it also lost the two end segments and left the left and right margins of a zoomed-in view empty. The guard for it is the library's own pixel test (`a_sparse_window_between_samples_still_inks_both_margins`), not this line — a probe that recomputes the renderer's formula cannot see a renderer that changed it. Also read `axis=` in the companion line against the `window control:` line in the dump — a live axis narrower than the requested window is the same symptom from the other cause (see the `clamp_to_data()` invariant 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] 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. - `[cursors-probe] gestureless plot move with no gesture: stored A -> B ignored=D` — a plot-level `set_cursor_x()` with **no gesture behind it must be ignored** (`ignored=1`, and `A == B`): the tick reads a row only while its controller holds a cursor, and this line is the guard for the defect where it adopted its own echo and a dragged cursor sprang back. (The *adoption* half -- a gesture that does move a cursor, and the shared value reaching every row -- is asserted in the `check_host` harness, which can call `sync_cursors(row)` directly; a synthetic press here cannot reach the adapter's own event pump.) - `[cursors-probe] mode=.. on=D pane=D t0=.. t1=.. dt=.. T=[..] lines=N [..]` (stderr, `UDPSCOPE_PROBE_CURSORS_MS`) — walks `vertical` → `horizontal` → `both` → `mix`, then moves a marker *through the plot* (`Plot::set_cursor_x(..., Free)`, which is what a drag does) and reports again. **The times must survive every mode change**: they are the `RowSet`'s one pair, and a mode that parks the markers must not read them back (a parked cursor reports a real number, so a read-back would adopt it as a gesture — the bug that made `T0` read 0). **`lines`** must be 0 for `vertical`, one per row for `horizontal`/`both` (and the time row exists only in the modes that show the vertical pair — `T=[]` in `horizontal` is the assertion that it does not), and one per bound trace for `mix`; in `mix` the values must be the traces' own (`--` when a marker is past the newest sample, `partial` when only one marker is over samples). After the move, `t0` must have changed and every row's marker must have followed it (the report's `t0` is the shared value). **`marks`** is the `mix` mode's crossing annotations: **0 in every other mode, and one per bound trace for each marker that is over samples in `mix`** (so a move that brings the second marker into the data doubles it). The `first mark at x=.. y=..` line that follows must agree with the pane's *first* value for the same trace (the pane's lines are `chip Y1 Y2 dY`, with the values formatted as measurements -- two decimals, an SI prefix, the channel's unit -- so the comparison is against the first number): both are read through the same `Plot::crosshair_value()`, and that line is the check that they stay the same number. A **ruler** drag is the other direction and the other hazard: the pane's `A`/`B` cells for a ruler row must change when the ruler moves (`after ruler drag` in the probe walks the first ruler to 80% of its y range), because nothing shares a ruler and the read-back in `RowSet::sync_cursors()` is the only thing that can notice. The dump's `cursors in plot 0:` line is the other half: it reads the markers back out of the plot (`cursor(id).x/.visible`), which is what the renderer draws from — the pane's numbers and the plot's state agreeing is the difference between "the app placed them" and "the app placed them and they are on screen". - `[measures-probe]` + the dump's `measure N: 'sig' type thr=..% above=D show=D -> ok|invalid value text=[..] [duty=..] [marks=..]` and `measures pane: n=N revealed=D marks_shown=M pane_rows=R pane_w=W pane_h=H` (stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first plottable signal and lets the tick compute them, so what it prints is what a user reads. On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.64 and `bottom` ≈ -0.64 (the mean of each half of a sine), max/min ≈ ±1, `pulse duration` ≈ 0.5 s with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. `marks=` lists each mark with its kind, and the **mix is the point**: `level:V` alone for average/top/bottom, `level:V,time:T` for min/max (a ruler at the value *and* a marker at the first sample that reached it — two level marks at both extremes was the bug), `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration` seconds, and the rest the channel's unit. `marks_shown` counts two per shown measurement (a min/max showing both its marks counts two of its own). `measures window:` is the timeliness assertion: **`drift` must stay at (or near) zero** -- the window the numbers were computed over against the one on screen -- and every *time* mark must lie inside it (`marks_in_window=K/K`). A measurement is recomputed when the window moves (2 % of its span) or the held/live state changes, never on a wall-clock timer: a timer is what let a marker sit outside the viewport and a dragged one survive a frame. **`marks_selectable` must be 0**: the measurement marks are created unselectable (`Plot::set_cursor_selectable()`, which the library gained for this), read back out of the plot here -- re-placing a marker every frame does **not** stop a drag, because the controller holds it for the gesture's life and the marker follows the pointer between frames. The library's own controller test (`an_unselectable_cursor_is_neither_hovered_nor_grabbed`) is the other half. The probe's last step still drags a mark *programmatically* and requires the next frame to put it back (`restored=1`), which now checks the placement path rather than the read-only property. The dialog report's `type= thr= side= show=` is the per-type behaviour: `period` and `frequency` must show **neither** the split nor the side row (they compute from the mid level's rising crossings, and an option there is one the user can only get wrong), `average`/`min`/`max` no split either, and only `pulse duration` a side. A measurement whose signal is not acquired, or a window with too few crossings, must read `invalid`/`--` rather than a made-up number (`frequency` on a window holding one edge is the case: `duty` is reported only when a period exists to divide by). The probe then **walks the dialog's lifecycle**: open the edit dialog on measurement 0, click its `Save`, open it again, click `Delete` — `measures=` must be unchanged after the save and one lower after the delete, with the process still alive. That is the check for the two things a dialog of this shape gets wrong: two buttons owning one context (a double free aborts on edit/delete, and only those paths have two buttons) and a `const char*` array built from a `std::vector` as it grew (every pointer dangling, the first entry the most visibly). The report's `signals=[..]` and `drop_h` are the second and third: the list must be the **plotted traces** (real names, deduplicated, in signal-list order — *not* every channel, and never a stream's timestamp signal, which is what an acquired-but- unplotted list put first and auto-selected), and `drop_h` must be a combo box's height (tens of pixels — a scrolled-window wrapper asked it to grow to 160). - `[timelabels-probe] relative=D window=.. origin=.. note=[..] ticks=[..] first_value=.. first_rel=.. range=[..]` + the dump's `time labels: relative=D origin=.. note=[..] x_origin_set=.. ticks=[..] first_rel=..` (stderr, `UDPSCOPE_PROBE_TIMELABELS_MS`) — the x label form across a zoom, driven through `apply_time_window()`: **under the figure's x link group the group owns the window, so a row-level `set_time_window()` does nothing** (a probe that changes the window must use the app's path). Absolute at 2 s and at 500 ms, `relative=1` below a second, and the caption must be non-empty exactly then. The probe *races the paint* — it reads the axis on a timer, and the ticks it sees are the previous layout's — so its `first_rel` (the first major minus the axis' origin) is a fraction of a step; the dump runs after the last paint and is the race-free reading. Either way the assertion that matters is the *sum*: `first_value` must equal `origin + first_rel` as a *tick label* plus its caption, i.e. the labels and the caption must share one anchor. `x_origin_set` is read back out of the plot; it must be 0 in the absolute form. - `[fit-probe] window=.. x=[..] span=.. y=[..]` (stderr, `UDPSCOPE_PROBE_FIT_MS`) — the two fits, through the same `RowSet` calls the right-click menu wires. It first puts the y scale and the window where the data is *not* (a fixed `[-100, 100]`, a 50 ms window), so the assertions have something to be wrong about: **`fit x` must widen the window to the data's span and leave `y` exactly as it was**, and **`fit all` must then move `y` to the data's own range**. A `y` that changed on the `fit x` line is the two paths aliased. - `[layout-probe] rows=N buttons=B done=D done_box=[x,y,WxH] 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. `done` follows the same rule for the editor's own exit button (1 up, 0 off), and its `done_box` on the `placed` line must sit in the area's top-right corner: `x + W` within a few pixels of the area width and `y` within a few of 0 (the on-switch line reports a box from before the first frame, so read the `placed` one). The last probe step leaves the editor by **clicking that button** — the toolbar toggle is still exercised by the first step — so `buttons=0 done=0` with the rows and traces untouched is the assertion that the way out works. **`traces=` is the real assertion**: an insert or a remove moves the rows' *contents* with them, so `add above 0` must read `[0:-|1:sine,…]` — row 0's signals now on row 1, not renumbered onto the new empty row — and `remove 0` must pull them back to `0:`. A `traces=` where the signals stayed on row 0 means `carry_row()` was skipped and the rows were renumbered under the user. Geometrically, each `close=` must equal its row's top from `rows_y` plus the pad, and `add=` must be centred on the boundaries (so strictly increasing, the first at 0 and the last clamped to the area's bottom). The `rows_y` spans must tile: a gap other than `kRowGap` means the figure and the overlay disagree about where a row is, which is how a `×` ends up on the wrong row. Also read `x-desync 0` in the dump — every row the editor adds has to join the one x link group. Run it at `--plots 8` as well: the cap and the floor are the two refusals, and because the probe emits `::clicked` straight at the widget it reaches the handler even when the button is insensitive, so `add above 0` there must leave `rows=` and `traces=` untouched rather than growing a ninth row. - `header: window_button_w=W 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/_.toml`) — run once with a drop and a window change, then again with no interaction: the second run's dump must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0 -> channel ... samples=N` line). Delete the file before a run whose layout you did not mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise). - `[udpscope] starved: connected (config=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 reconnects` only when N > 0, which is the same counter without the prose. - **The export's `time`/`stream_time` columns must be strictly increasing and evenly spaced.** Check the written file, not just the probe: `np.diff(t)` with no `<= 0` and `rows / (t[-1] - t[0])` equal to the stored rate (the `ch` line's figure). Duplicate or backwards stamps mean the recorded timeline holds two clocks or a collapsed cadence — the two ways this has broken: a per-frame choice of time source (now sticky, see the README) and a per-sample step derived from the client's `lost` counter (now the stream's measured arrival cadence). A `rows=` count far below the span times the rate is the other signature, that of a table cut from the LOD pyramid. - `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. - **The cursors are read back from the plots only while a gesture is on that row.** `Cursor::x` is 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 is `RowSet::sync_cursors(int drag_row)`: the tick passes the row whose controller reports `grabbed_cursor() != kInvalid`, and every other frame does nothing. The values are echoed back with `CursorPlacement::Free` for 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 something `insert_row()`'s `clear_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's `x`: it is one stream time for the figure, so it lives on the `RowSet` too, and `Row` keeps only what is genuinely per row (the rulers' values, the traces, the colours). - **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 renderer maps the visible range onto indices through the series' own x, never the `dx()` grid.** `deps/plotter`'s `Renderer::draw_traces` asks `Series::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 is `a_sparse_window_between_samples_still_inks_both_margins` (a stub whose `dx()` lies, with the window strictly between two samples). It is the guard, not the probe: `[trig-probe/idx]` now computes `got` from 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`). - **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. 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 `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()` 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", so `window_s_` — what the window control states, capped by `--max-window` — is the bound. 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. Loosening it to `max(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_MS` reports it as `zoom ... 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::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.** - **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()` calls `RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and clamps the position into it (and states `Off`, 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's `set_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 showed `group` and `member` both 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 in `tick()` for that reason. A paused view pins the same way (`pin_pause_window()` sets `pinned_` from the frozen range), so the frozen picture is equally immovable; resuming or leaving trigger mode clears it. - **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 takes `set_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 at `kParked` could 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 prints `readout 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 from `RowSet::event_data_window()` (the bound **event series'** own stamps) while an event is held, from the snapshot's stamps on a pause, and `pin_pause_window()`/the pause handler set the axis to it as well; `[pause-probe]` prints `axis=` beside `frozen_x=` and the axis must lie inside. A `Capturing` view 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]` prints `axis=` beside `frozen_x=`, and while paused **the axis must lie inside `frozen_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 at `0.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 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 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 every `kMeasureRefreshUs` (100 ms; 400 ms when a pass costs more than `kMeasureHeavyUs`), 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); and `measure_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 what `compute_measure()`'s `stats` argument is for. The marks' *ink* is set where they are created (`install()` in `plot_host.cpp`): 0.75 px and dashed 4/4 through `Plot::set_cursor_stroke()`, which is per cursor because the trigger marker (also a cursor) keeps the solid `line_width_px` stroke. 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-width `h_ruler`s 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 three `continue`s (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: N` in the dump is the other half of this: `clamp_to_data()` compares with a relative tolerance, because clamping to exactly `window_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. - **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::` 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.