# udpscope A GTK4 oscilloscope-style viewer for a UDPStreamer source: it receives one stream with [`deps/udpsclient`](deps/udpsclient), buffers it in two identical large circular buffers, and draws it with [`deps/plotter`](deps/plotter). `SPECS.md` is the requirement document; this file describes what was built. ## Build and run ```sh cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo cmake --build build ./build/udpscope --server 127.0.0.1 --port 8000 # unicast ./build/udpscope --server 10.0.0.5 --port 8000 \ --multicast 239.1.2.3 --interface 10.0.0.9 # multicast ``` **System dependency: libadwaita ≥ 1.8** (`pkg_check_modules(ADWAITA ... libadwaita-1>=1.8)`). It is used for exactly one widget — the shortcut overview is an `AdwShortcutsDialog` — because GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and `AdwShortcutsDialog` is its replacement. Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (size of the sample buffer, default 200), `--max-window` (largest time window the toolbar offers, default 600), `--holdoff` (default trigger hold-off), `--recv-buffer`, `--plots` (the number of stacked plots to start with, 1..8; rows are added and removed from the **Layout editor** afterwards). Every CONFIG is dumped to stderr once -- publish mode and, per signal, its name, element count, rate, time mode and time-signal index -- because a signal that refuses to plot is almost always a signal the stream described in an unexpected way. The status line carries the same story live: `plot 1: 2 trace(s), 1400 samples`, and `time x3` when the shared clock is decimated. Headless self-check (used by the smoke tests): `UDPSCOPE_EXIT_AFTER_MS=` switches trigger mode on, waits for an event, then leaves trigger mode the way the "Go live" button does, closes the window, and prints on stdout the status line in both states, the frame and per-channel sample counts, each plot's painted-frame count, which channel each trace slot holds, and the allocated size and reveal state of both side panels. Every size it prints is read from a value the frame tick captured while the window was alive, not from the widget: the dump runs from the destroy handler, where the widget pointer is already freed memory. It used to ask two of them directly, which is where the long-standing "an exit-dump run emits exactly two `Gtk-CRITICAL`s" baseline came from — and a critical was only the lucky reading. `UDPSCOPE_PROBE_HELP_MS` builds and destroys three windows of its own before the dump, so by then the memory had been reused and the same two calls **crashed in the middle of the dump**, which looks like a truncated log rather than like a segfault in a debug line. The dump now makes no `gtk_widget_*` call at all and a clean run carries no critical. ## Layout ``` src/config.* command line src/model.* the sample buffer, the partition plan, the signal/channel lists src/channel.* ChannelBuffer (a ring pair as a plot::data::Series) and SlotSeries src/acquisition.* the UDPStreamer client, the producer thread, frame decoding src/trigger.* the trigger configuration and the producer-side state machine src/plot_host.* the one plot::Figure, its rows' traces and cursors, the x link group src/export.* the visible range as a table, and the .npy / .csv writers src/layout_store.* the per-source session layout file (TOML), written and read back src/event_series.hpp an immutable captured event as a plot::data::Series src/ui.* the GTK4 window: toolbar, trigger panel, signal panel, plot box ``` Threading: one producer thread owns the `udps_client_t` and calls `udps_client_poll()`; the GTK main thread owns every widget and the GL contexts. They share `ScopeModel` under one mutex, which the producer takes once per DATA frame and the GUI thread takes once per frame around binding the traces. Samples cross the boundary through `plot::data::RingBuffer` (single producer, single consumer), never through the lock. ## The x axis is relative to now **A live axis is fixed at `[-window, 0]`, with the newest sample at 0.** That is what a bench scope in roll mode shows and what the window control's figure means: a 2 s window reads `-2s` to `0` and keeps reading that for as long as the stream runs, with the trace flowing right to left under it. The axis used to carry the stream's own clock, so the labels counted the app's uptime upwards (`20:00`, `20:01`, …) and the one number a user actually wants — how long ago a feature happened — had to be worked out by subtraction. Every mode now expresses x as *seconds from the thing the view is about*, and the rule is one line: `display_x = stream_t - ScopeModel::stream_at_x0()`, where `stream_at_x0()` is the stream time of display x = 0. | display mode | x = 0 is | `stream_at_x0()` | | --- | --- | --- | | `Live`, `Waiting` | the newest acquired sample | the newest stamp | | `Capturing` | the accepted edge | the edge | | `Held` | the event's edge | the snapshot's `x_offset` | | `Paused` | the instant of the freeze | the snapshot's `x_offset` | The live answer is written by the frame tick **after** `bind()` and `acquire()`, because it has to be minus the newest sample the *traces* can report: asking the model before they are bound puts the newest data a frame's production to the right of the axis, which at 1 MS/s over a 50 ms window is a third of the screen. It converges in exactly one frame and cannot oscillate — `live_edge()` reports in the epoch currently in force, so `new = old - live_edge()` is `-raw_newest` whatever the old value was. A snapshot carries its own offset (`EventSnapshot::x_offset`) rather than leaning on the model's epoch, because its coordinates outlive the epoch that produced them. One consequence is a reversal of an earlier deliberate choice: **a pause snapshot is now rebased** like an event's. Leaving its stamps on the stream's clock would have thrown the frozen picture a whole uptime to the right of an axis that is now newest-relative. `x_offset` keeps the stream clock recoverable, which is what the export's second column reads — and it fixed a latent bug on the way, the export's snapshot branch having added `trigger_time()` to a *paused* view's stamps: an unrelated edge, or none at all before the first latch. Two things move in **opposite** directions under a rolling axis, and the asymmetry is deliberate. A **cursor** stays on screen: placed at -1.5 s it keeps meaning "1.5 s ago" and the data flows under it, which is a bench scope's roll behaviour and is free here, the cursors already being stored in display coordinates. A **measurement mark** is glued to the sample it measured, because a mark is a *result* rather than an instrument — so the marks are stored in stream seconds and reconverted every frame (`place_measure_marks()`), which is what makes them drift left with their own data between recomputes. `App::measure_window` is on the stream's clock for a related reason: "has the window moved" is the question that triggers a recompute, and a fixed `[-window, 0]` makes it invisible in display coordinates, so the pane would have computed once and never again. **Pause freezes the picture, not the acquisition.** The producer keeps writing the live ring while paused — the same thing it does when the trigger is off — and the plot binds a *snapshot* of the window that was on screen at the pause, with the axes pinned there. That is the same immutable-series path a held trigger event uses (`DisplayMode::Paused` sits beside `Held`). Resuming drops the snapshot and re-frames on the newest data, so the seconds that passed while paused are *there*, not a hole — which is what pausing used to leave, because it stopped the producer outright. The status bar says how long it has been frozen (`paused · 10 kS/s · 2 s window · for 12 s`), in **wall** seconds: the producer never stopped, so the stream's own clock says nothing about how long the user has been looking at a still picture. It is written through `format_elapsed()`, which steps to a coarser unit instead of growing digits, because the status row sits directly above the plot and a field whose width tracks the wait would relayout it. ## Measurements **The x ticks read relative when, and only when, they could not be written absolutely.** A `TickFormat::Time` label carries three significant digits, so the test is the tick **step** against the label's own **resolution at the axis' magnitude**: `12.345671s` and `12.345672s` are the same string, and there the ticks are re-anchored on a grid at the window's left edge — so they read `0`, `5ms`, `10ms` — with a caption naming that anchor, `t0 = 12.3456 s`. Adding the two is the tick's absolute time. The labels scale through the prefixed units the SI formatter uses — `500ms`, `500us`, `5us`, `5ns` — so a microsecond window reads in microseconds instead of `0.005ms`. The rule used to be the *span* alone (relative under a second, with a band to 1.05 s), and a span says nothing about whether a label can be written: it was wrong in **both** directions. A 2 s window at twenty minutes of uptime is labelled `20:00` on every tick — `m:ss` carries whole seconds — and was left absolute and unreadable. And a **triggered** sub-second window, whose axis is already `[-25ms, +25ms]` about the edge, reads `-20ms`, `-10ms`, `0`, `10ms` perfectly well, yet was re-anchored anyway: the one position a triggered axis exists to show was displaced by a `t0` caption that had nothing to explain. The step is read back from the majors the last layout produced (a function of the range and the axis width, not of the label form, so no feedback loop), the magnitude is the larger of the two endpoints, and the comparison carries a 1.05 band leaving the relative form plus a ULP of slack entering it — a step *equal* to the resolution is readable (`1.73s`, `1.74s`, `1.75s`: one in the last digit is exactly enough), so a decision taken on the last bits of two differently computed doubles picked a different form from one frame to the next. The caption is **chrome** — `Plot::set_x_note()`, added to the plotter for it, drawn on the legend's own backplate in the bottom-left corner of the axes — rather than the axis title or an annotation. Not the title, because its text changes with the window and a title of a changing width would relayout the plot every frame; not an annotation, because an annotation's anchor is a *data* coordinate, so the caption crawled across the plot under a pan and drifted under a zoom, which is what it was reported as. The library test is `the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move`: the footprint is measured as the pixels a frame with the caption differs from the same frame without one, and a pan to another decade plus a 64× zoom must produce the same box to the pixel. Everything a user reads off the screen is in the *display* coordinate the labels name — the cursors' `T0`/`T1`/`dT`, a measurement's time marks, the crosshair — so live they are negative ages and in trigger mode they are offsets from the edge. Only the **export** carries the stream's own clock as well, in a second column (`abs_seconds`); see *Export*. The anchor is taken by the *axis* on every layout (`Axis::origin_follows_range`), not written by the app: the window's edge is derived from the newest sample during the layout, so a value the app wrote a frame earlier is a frame of scroll stale — at 1 MS/s more than a millisecond window shows. One consequence worth knowing: the zero tick can sit up to one tick step to the *left* of the window's edge in the frame it was drawn, because the grid is anchored to the edge the ticks were generated for; the caption always names the anchor the labels were built with, so the addition stays exact. **Measurements** (overflow menu) reveals a pane with a `[+]` and one row per measurement: `[badge] [edit]`. `[+]` opens a dialog with the signal, the type, the options that type actually uses, and "show on plot"; `edit` opens the same dialog on that measurement, with a `Delete` beside `Save`. The signal list is the signals the **plots are showing**, in signal-list order: a measurement reads the window a plot displays and its marks go on the row that plots the signal, so the candidates are the traces on screen. That is also what keeps everything else out of it — a signal nobody acquires, and a stream's own timestamp channel, which is acquired but is not something to measure. A measurement whose signal is no longer plotted keeps it in the list while it is being edited, so saving does not silently re-point it at whatever the dropdown happened to be showing. The badge is the measurement's own colour, and the marks it draws on the plot use it, so a readout and its ink are recognisably the same measurement. | type | what it is | options | shown on the plot as | |---|---|---|---| | `average` | the mean of the visible samples | — | a ruler at the mean | | `top` / `bottom` | the high / low level: the mean of the samples above / below the split | split % | a ruler at the level | | `max` / `min` | the extremes | — | a ruler at the value **and** a marker where it happened | | `pulse duration` | the mean width of the pulse-side runs | split %, which side is the pulse | markers at the pulse's two edges | | `period` / `frequency` | the spacing of the threshold crossings / its reciprocal, in Hz | — | markers at two edges of a cycle | `period` and `frequency` deliberately take **no options**: the engine counts *rising* crossings of the *mid* level, one edge per cycle, and a split or a side to choose from could only make that count wrong — a split near either extreme counts noise as crossings, and counting both edges halves the period. The split is a share of the **window's own** min..max, not volts: a measurement is about this signal in this window, and a level in volts would have to be typed for every signal. A crossing has to be *strict* — a sample exactly at the level is on neither side — so a signal that merely touches the level does not count every sample as an event. Each measurement's row carries **two badges**: its own colour (the same one its marks use on the plot) and the signal's chip, in the palette colour every other view names that signal with. The dialog's fields and their wording follow the type — the split row is absent for `average`, `min` and `max`, the side row only for `pulse duration`, and the "show on plot" line says what would be drawn. The values are **window statistics over exactly the visible range, at the stored rate**: the same cut the export makes, so a measurement describes the data on screen rather than the ring behind it — and the marks it draws belong to that window, so they stay in the viewport. They are recomputed when the **window moves**, not on a timer: a timer decouples the two, and with a scrolling window the stored marks then describe a range the plot has already left (the min/max marker ends up outside the viewport, and a dragged marker survives until the next tick). The trigger is 2 % of the span, so the numbers track the window within a few pixels while the cost stays bounded by the window's motion rather than by the frame rate; one pass copies the visible samples of each *channel* once, however many measurements read it. **Show on plot** marks the result on the row that plots the signal: a ruler at the level (`top`/`bottom`), both rulers at the extremes (`min`/`max`), the pulse's or the cycle's two edge times as vertical markers. The marks are **readouts, not instruments**: a fixed pool of cursors per row, created **unselectable** (`Plot::set_cursor_selectable()`, added for this), so the controller's hit test never offers the pointer one — it can neither hover nor drag a measurement's ruler. They are also re-placed every frame, because a mark is only correct for the window its measurement was computed over. Re-placing alone was not enough: a drag is held for the gesture's life, so the marker followed the pointer *between* frames and the readout looked draggable. ### What a measurement costs A measurement is a statistic of the **visible window**, and at a megasample a second a window is millions of samples — so the obvious implementation is two O(window) walks per measurement per recompute, and that is what it was. With four measurements over a 2.2 M-sample window it measured **19.7 ms per recompute** and a 1.8 ms frame tick mean: a third of the frames lost, on a pane that is only showing four numbers. It is now **1.2–1.6 ms**, and it reads **0.1 % of the window**. The samples are not what it walks: `plot::data::MinMaxPyramid` already holds the window as 64-sample buckets, each carrying its own min, max, exact sum and finite count, and the producer folds every sample into it as it arrives at amortised O(1). That pyramid *is* "evaluate only on the new samples" — it is maintained by the ingest, so the pane inherits a summary it never paid to build. Both walks go through it: - **Pass one** (the extremes, the mean) adds whole buckets: 1/64 of the reads, and *exact*, because a bucket's sum is a sum and not an estimate. It locates each extreme to a **bucket**, and one raw scan of that bucket alone then pins it to a sample — two buckets at most, whatever the window's size. The two stamps the scan needed are read at the end rather than at every chunk that moved an extreme, since a stamp is a ring read and an extreme moves many times. - **Pass two** (the threshold types: `top`, `bottom`, `pulse duration`, `period`, `frequency`) folds a bucket whole whenever it lies *strictly* on one side of its level — then every sample in it decides the same side, none is *at* the level, and an entering edge is exactly the bucket's first sample, which is where the raw walk would have found it. Only the buckets that **straddle** a level are opened, which on a clean waveform is one per crossing. The raw path is still there and still exact, for the window's partial edge buckets, for the samples produced since this frame's `acquire_all()` (bounded by one frame of production), and for those straddling buckets. Nothing is decimated or strided: `peak to peak` still reads exactly `2` on a unit sine and `top` exactly `0.636620` — 2/π, the half-wave mean, which is the check that a threshold type is not merely plausible. A window shorter than a bucket, or a signal noisy enough to straddle everywhere, degrades back to the O(window) cost and nothing else changes. The dump's `measure cost:` line reports `covered`, `buckets` and `raw` for exactly this: `raw` near `covered` means the pyramid bought nothing. ### What the math channels cost The derived channels run on the **producer** thread, once per stored slot, so they never appear in a frame time at all — what they can do instead is drop packets. Three things were removed from that per-slot path: a heap allocation and free (the slot's value scratch is a member now, `assign()`d), a per-sample type-erased lookup of each `Source` node's channel *by name* (a linear scan with a `std::string` compare per candidate, per node, per sample — resolved once in `math_prepare()` into `MathNodeRt::src` instead), and a locked read-modify-write that belonged to the cost counter itself. That last one is worth naming: one `lock xadd` costs about what a two-node graph's whole arithmetic does, so counting the slots cost more than the work being counted. The counters are written by the producer and only by the producer, so each is a relaxed *store* of a value already in hand. The figure is around 80–100 ns per slot for three two-node graphs at ~1.1 MS/s — roughly a tenth of one core, with `gaps=0`. It is sampled one slot in a thousand, and it is an **upper bound**: a slot's work is the same order as one `clock_gettime`, so the measured slot carries a clock read. Timing a *run* of consecutive slots to amortise that away was tried and is wrong — the producer gets its slots a packet at a time, so a run long enough to be worth timing straddles a packet boundary now and then and charges the measured slots the wait for the next packet. At 2000 samples a packet that reported 799 ns/slot against the true ~80. Below that the per-slot path is at the noise floor of what can be measured here: four identical-config runs spread over 78–101 ns/slot, which is wider than any remaining micro-optimisation. Converting `MathRuntime` from eight parallel `std::vector` to one array of per-node structs was measured and bought **nothing** — at six nodes the whole runtime is L1-resident either way. It was kept because a node's state belonging on one cache line is the right shape for a graph that grows, not because it showed up. ## The session layout file What you arranged is remembered per data source, in `~/.config/udpscope/_.toml` (`$XDG_CONFIG_HOME` is honoured; the host's unsafe characters are replaced with `_`). The file holds the display window, the row layout and which signal each of a row's slots plots, and the per-signal gain and offset: ```toml window_s = 2 plots = 1 [[plot]] signals = ["Ch1", "", "", ""] [[calibration]] name = "Ch1" gain = 2 offset = 0.5 ``` It is written (400 ms after the last change, and flushed on close) whenever the window, the layout, a drop or a calibration changes, and applied once, when the first CONFIG names the signals -- signal *names* are the identity, because a CONFIG renumbers and an enable/disable rebuilds the channel list. Signals the file plots are enabled on load, since a restored plot nobody acquires would come back blank. It is plain TOML and safe to edit; a malformed file loads whatever parsed rather than refusing to start. Writes go to a sibling temp file and are renamed over the target, so a crash mid-write leaves the old layout. The same reason the window control is a fixed-width label in the header bar (see the DPI/relayout notes in `ui.cpp`) is why the bar's `window` control does not expand: a header-bar child that expands is measured into the title's centre box, so the control stretched to the title and every window resize moved it. ## The icon, the About dialog and the desktop entry **The icon is the instrument face**: a dark panel carrying one bold green trace. That is the clearest metaphor available for this program -- it is what the thing being imitated looks like -- and it is the kind of physical object the GNOME HIG's top-and-front depth language was written for, so the guidelines apply as written rather than by analogy. Its silhouette is also what makes it survive being scaled down, which is why two alternatives were dropped: a bare waveform (no silhouette at all, so nothing is left of it in a dock) and a colour version of the trigger glyph the toolbar already carries (a *control*, not the application, and it would mean two icons that have to stay in step). It is built to the HIG's own numbers. A 128px canvas the artwork does not fill (it spans x 12..116, y 22..106), every feature on the 2px grid, and **one** depth treatment: a 4px front profile along the bottom, clipped to the chassis silhouette so it does not protrude past the rounded corners. The rest is flat colour -- no gradients, no shadows -- because every surface here is straight, and the palette is GNOME's (Dark 3 chassis, Dark 2 profile, Dark 4 screen, Green 1 trace). **Two things were deliberately left out.** There is no graticule: the grid lines a real scope face carries cannot survive 32px, and the trace carries the metaphor without them. And the trace is one cycle -- a single crest, a single trough -- because at 32px the screen is about 22x16 px and anything busier reads as a smudge. The two forms are authored differently on purpose. The colour icon **strokes** its sine, which is the sane way to draw a smooth curve; the 16px symbolic one cannot, because GTK's symbolic recolour wraps the file in a stylesheet that reaches `fill` on `path`, `rect` and `circle` only, so a stroked line keeps the colour written in the file and stays dark on a dark theme. Its wave is therefore a closed 2px band -- a sine along the top edge and the same sine 2px lower along the bottom. Do not "fix" one to match the other. Both are compiled into the binary as a `GResource` under `scalable/apps/`, for the same reason the four action icons are: udpscope is run straight out of its build tree and has no install step. The one consequence worth knowing is that a **malformed** SVG cannot be caught at runtime -- the icon *name* still resolves, so GTK reports the icon as present and draws nothing -- which is why every `` in the manifest carries `preprocess="xml-stripblanks"` and the build fails with a file and line number (plus a caret under the offending character) instead. **The About dialog is an `AdwAboutDialog`** rather than a `GtkAboutDialog`, and that migration is what the app has an icon *for*: the dialog shows it in its own header, which `GtkAboutDialog` would too but only after being handed one, and nothing else in the program had asked for a 128px rendering. Two further gains came with it. The debug information is a **Troubleshooting page with a copy button**, where `system_information` on the old dialog was inert text a user reporting a problem had to retype. And the string it shows is a `std::string`, which removed a `char[1024]` that truncated a long settings path in silence -- the one failure mode of a fixed buffer that no one notices until the path matters. What it cost: an `AdwAboutDialog` is an `AdwDialog` and **not** a `GtkWindow`, so its close path differs from the help window sitting beside it -- `adw_dialog_close()` and `::closed`, rather than `gtk_window_close()` and `::close-request`. That is the same trap the shortcut overview already had, and the two now share one `::closed` handler keyed by the pointer it must null. What was deliberately left out: credits (one author, already named), release notes (there are no releases) and an issue-tracker URL (the website link reaches the repository, which carries the tracker). **The desktop entry is opt-in.** `cmake --build build` never touches `$XDG_DATA_HOME`; asking for it does: ```sh cmake --build build --target desktop-entry ``` That writes `org.udpscope.app.desktop` and both app icons under `$XDG_DATA_HOME` (default `~/.local/share`), validates the entry with `desktop-file-validate` as a hard failure when the tool is on `PATH`, refreshes `update-desktop-database` and `gtk4-update-icon-cache` if they are on `PATH`, and prints the three paths it wrote. Those three lines **are** the uninstall instructions: there is no uninstall target, because three `rm`s the build already told you about are less to maintain than a target that has to guess what a previous version installed. The caveat is the entry's `Exec`, which is the built binary's **absolute path in the build tree**: moving or deleting `build/` breaks the launcher. That is the price of having no install step, and it is the right trade here -- the entry then always launches exactly the binary that was last built, which for a program under development is what you want. No `StartupWMClass` is needed: GTK takes the WM class from the application ID, and the desktop file's basename is that same string, which is what associates a running window with the entry. ## How the spec maps onto the code **One buffer of `N+1` slices, plus frozen event snapshots.** `ScopeModel` allocates one `BufferStore` per CONFIG: one shared time-base ring plus one value ring per channel (N enabled signals -- see the limitation on multi-element signals below). The spec's second buffer is the latched `EventSnapshot`, which is already an immutable copy of the captured window (see "Trigger" below); a second live ring bought nothing and cost half the memory budget, so `--buffer-mb 200` really is 200 MB. `plan_partition()` sizes the rings to cover `--max-window` seconds and then grows them with the rest of the budget. When a channel's declared sample rate is lower than the fastest channel's, it is decimated by the integer ratio (`Partition::stride`); when the requested window does not fit the budget, the shared clock is resampled too (`Partition::time_stride`, an averaging decimation — see "A window longer than the buffer is resampled"). Because the split is by *time span* rather than by bytes, every channel and the time base retain the same history -- which is what makes one shared time slice meaningful. With no decimation the two rules coincide and every slice holds the same number of samples (not the same number of bytes: the shared clock is `f64` and the values are `f32`, so the growth loop weighs the budget in bytes -- see "The time ring is `f64`" below). The ring is always filling, whatever the trigger is doing. The pre-trigger history has to already be there when an edge arrives; while acquisition followed the trigger state, the first latch after arming opened on however little had accumulated since. **Circular, time from the streamed data.** Each ring overwrites its oldest sample. Timestamps come from the stream's time signal when the CONFIG names one (`UDPS_TIME_FULL_ARRAY` / `FIRST_SAMPLE` / `LAST_SAMPLE`), and otherwise from an arrival-anchored estimate that accounts for `udps_frame_t::lost` by dividing the elapsed wall time over the cycles the gap covers. `recv_time` is used only as the fallback when the stream provides no usable time signal (it is then the sample's x, via the arrival estimate); it is never used when the stream's own time signal carries a span. **Trigger: what freezes is the view, not acquisition.** `TriggerEngine` runs the edge/hold-off/normal-single state machine on the selected channel, and once the requested post-trigger share of the *event span* (the event-window control, not the whole ring) has been collected, `ScopeModel::publish_event()` copies that window out of the live ring into an immutable `EventSnapshot` and publishes it atomically. Pre-trigger history is what the ring already holds, so the percentage is a share of the event window and not of the buffer. **A capture fills in view** (`DisplayMode::Capturing`). The moment an edge is accepted — which, by the gate below, means the pre-trigger history is already complete — the axis is pinned to the whole event window `[-pre, +post]` with the edge at x = 0, and the trace follows the live ring inside it until the snapshot lands. So the pre side is there immediately, the post side sweeps in from x = 0 to the right, and at the latch the snapshot replaces the live binding on an axis that does not move: the only visible change is that the picture stops. This is a **deliberate deviation from the spec**, requested explicitly. SPECS:37 says the visualisation "should not be refreshed till next event", and an earlier iteration of this app read that strictly — a trace was bound to the snapshot and to nothing else, never the live ring, not even while the post window was filling. What that reading cost was the whole of every capture: for as long as the post side took to fill, the plot showed the *previous* event with no sign that anything was happening. The literal rule is gone; what it was protecting is not. A **held** event is still bound to the snapshot alone and is never quietly overwritten by live data, and the axis is still the event's own window throughout — a capture is live data on a frozen frame, not a live view. The four display states are `Live` (no trigger), `Capturing`, `Held` (the frozen snapshot) and `Waiting` (armed, nothing captured yet: blank). `ScopeModel::display_mode()` answers which, and the frame tick reads it once and hands it to the bind, the display epoch, the axis pin and the status line — the producer can open or close a capture between any two reads, and the four disagreeing about which clock they are on is how a trace ends up drawn a window away from its axis. **Disarm during a capture keeps what the capture got to.** It cuts the window at the newest sample, publishes that as the held event and freezes it — the axis tightens to the part that filled (`[-pre, +0.3 s]` for a 1 s post window disarmed 300 ms in) with the edge still at x = 0. It used to lose the capture outright: clearing the engine's pending flag dropped the display out of `Capturing` with nothing published behind it, so the view fell back to the *previous* event, or to blank if this was the first. No event fired, so the latch counter does not move; a separate `ScopeModel::event_seq()` counts published snapshots, and that is what the frame tick keys its framing on. The frame that **frames** a snapshot is the one that consumes its sequence number, not merely the first frame to see it. The tick reads `display_mode()` before it takes the model's mutex, while the producer publishes the snapshot inside that mutex and only lowers `trigger_pending` at the end of its packet — so a tick can read `Capturing`, wait out the publish on the lock, and arrive at the framing step bound to a capture that no longer exists. Consuming the number there dropped the pin for good: the axis kept the capture's *requested* `[-pre, +post]` while the snapshot was shorter, and the "an event is held" flag stayed false, so `clamp_to_data()` was free to drag the frozen view off it. The window was visibly wrong only at high rates, where the publish is long enough to lose that race often (`axis=[-1..1]` against `snap=[-1..0.27]` in the disarm probe at 1.3 MS/s, correct at 10 kS/s). Leaving the number unconsumed costs nothing: the pin is owed until a frame is bound to the snapshot, and by then it is still the newest one. Mid-capture the live ring is read on the *event's* clock: `display_epoch()` is set to minus the edge, so `x = 0` is the edge for the live channels exactly as it is in a snapshot's own stamps. That re-basing machinery already existed (it was written for the frozen view and then never needed, since snapshots carry edge-relative stamps); this is what finally uses it. **The whole trigger path measures the window in stream seconds, and never in samples.** `TriggerEngine` is fed each sample's timestamp and latches once that clock has advanced past the post window; `publish_event()` then finds the slice covering `[edge - pre, edge + post]` by binary search over the time ring. The two are the same clock the x axis reads, so a capture covers the span that was asked for by construction. It did not used to. The engine took a post-trigger window in *samples*, converted from seconds by a measured estimate of the trigger channel's cadence, and that estimate is what made captures wrong — most visibly as the reported defect *"only the pre window is filled, the post window is completely empty"*. Any stream whose time signal advances in large steps (one sample per stream second, say) drove the estimate to its 1 S/s floor; the sample count then came out as 1, which the engine read as "no post window requested" and latched on the edge sample itself. The event held a single sample, its span was zero, and the UI fell back to pinning a round `[-1, +1]` axis that nothing filled past x = 0 — an axis that looked right, which is why the defect read as a drawing bug rather than a capture bug. Removing the conversion removes the whole failure class: there is no longer a rate estimate to be wrong. An edge is refused until the buffer reaches back a full pre-trigger window behind it, so a capture cannot open on a half-filled pre window. The gate compares the oldest buffered stamp against the edge rather than timing how long the trigger has been armed: the ring fills whether or not the trigger is on, so the history is normally already there and waiting would delay the first event for nothing. It only bites just after a rebuild — a CONFIG, enabling the signal, or a calibration change all drop the history — which is exactly when the first event used to latch on an almost empty pre window and draw half a plot. The event is published on the sample that latched it, but only after every channel of that sample has reached its ring: the cut is clamped to the shortest value ring, so publishing from the middle of the channel loop dropped the newest sample — the very one the post window had been waiting for. That cost one sample of twenty thousand on a 10 kS/s stream, and the whole post side on a one-sample-per-second one. The axis is still pinned to what the snapshot actually captured, read off its own stamps rather than off the request: the two can differ by a sample at each end, and pinning the request would claim coverage the data does not have. The cut's bounds are compared in the ring's own type, which is now the `f64` they arrive in — see "The time ring is `f64`" below. While the ring was `f32` the bounds had to be *rounded to it* before comparing, or a sample sitting exactly on a bound fell to how its stamp rounded, which cost a sample at 10 kS/s and a third of the capture at 1 S/s. That rule is gone with its cause. **Where the capture is drawn was a separate bug from what it contained**, and it outlived the fixes above. The plotting library's `gather()` emits x *relative to* `x0()` and the renderer adds `x0()` back; the event series emitted absolute stamps, so the renderer added `x0()` twice and drew the whole capture one pre-trigger window to the left of the axis it had just been pinned to. Nothing about the numbers looked wrong — the snapshot held the right samples over the right span, and the crosshair (which reads `x_at()`, never `gather()`) reported correct values — but with a 50/50 split the right half of the plot was empty, zooming out showed the trace ending at the x = 0 marker, and zooming in past the shift showed nothing at all. The self-check now prints `data=` and `drawn=` side by side for the bound trace so the contract is checked rather than assumed. **The live view had two clipping bugs of its own**, both wearing the same face as that one: the older part of the trace blank, the newest part fine, and the crosshair still reading values in the blank part. Neither is a data bug — the samples were always there. The first is the requested time window being quietly redefined. `clamp_to_data()` stops a zoom-out from running off the end of the ring, but it runs every frame and cannot tell that apart from a ring that has only just started filling: the first frames after a signal is enabled hold a couple of hundred samples, a 2 s window was clamped to the ~0.02 s they covered, and nothing there ever grows a span back. The view then stayed pinned to the newest 20 ms of a 6.5 s buffer for the rest of the session. It now clamps against the **requested window** itself — not the retained span, which was the second attempt and the second bug: while the ring is still filling the two are the same, but once it holds more than the window the bound became the whole ring, and a zoom-out walked the view past the window into data the window was never set to show. A partly filled window simply has an empty left-hand side. **The panel switches are on the toolbar, and the panes own their own controls.** Cursors, Measurements and Math channels are icon toggles in the header bar rather than rows in the overflow menu, and **Reset cursors** sits in the cursors pane beside the mode it resets (it acts on that pane's instruments and is only visible while they exist). The rows read, left to right: **window control, trigger, pause** — the trigger and the pause are read against the span on screen, so they sit with the control that sets it — then, on the right, **cursors, measurements, math, sidebar** and **the menu last**. With `HeaderBar::pack_end()` the **first** packed is the rightmost, so the list is written right to left; that took a run of the dump to settle (two comments in the builder disagreed), and the line it prints (`panel controls: … ; toolbar, left to right: …`) is how a layout claim gets checked without a screenshot. The menu keeps what acts on the *data* (Fit, Export) and the two editors that are not panes (Layout, Signal list). The dump reports the arrangement as `panel controls: cursors=1 measurements=1 math=1 in the toolbar, reset=1 in the cursors pane`, computed on the first frame — a widget's parent is the only thing that says it, and the dump runs after the window is gone. **Four of those icons are drawn for this app**, because no icon theme has a glyph for what they name, and the stock ones they used to carry said something else entirely: the trigger was a **record dot**, and cursors, measurements and math were three different **magnifiers**. In their place: a rising edge crossing a dashed trigger level; a pair of flat-capped markers on dashed shafts; a ruler with alternating long and short ticks; and a sigma. They live in `src/icons/scalable/actions/` and are compiled into the binary as a `GResource` rather than installed beside it — udpscope is run straight out of the build tree and has no install step, so a search path would have to be found at runtime, and the failure mode of not finding it is a silent placeholder. Hence the dump's `toolbar icons:` line, which names every button's icon and whether the theme resolved it. Drawing them was mostly learning what a 16 px symbolic icon will not forgive. Everything sits on **whole-pixel** boundaries: the first draft placed features on half-pixels and the whole set was a grey smear at 1x, legible only when magnified. Every shape is a filled `path` — GTK's symbolic recolour reaches `fill` on `path`, `rect` and `circle` and nothing else, so a stroked line keeps the file's own colour and stays dark on a dark theme. The ruler's body is a hole punched with `fill-rule="evenodd"`, which also lets its ticks fill (three crossings deep is odd); the sigma's four subpaths are wound the same direction instead, so the default nonzero rule unions them rather than cancelling the overlaps. Two readings had to be designed away: the trigger's level at a 2-on/2-off dash rhythm read as scattered dots, fixed at 2-on/1-off with one wide gap where the edge rises through, and the cursors first read as **audio faders** — a downward triangular cap then read as a funnel, and what works is a flat cap over a *dashed* shaft. **The session is saved with the layout**, per data source, in `~/.config/udpscope/_.toml`: the display window, the rows and which signal each slot holds, the per-signal calibration, **which signals were enabled**, **the math channels with their graphs** and **the measurements pane**. Identity is the signal *name* everywhere, because a CONFIG renumbers the channels; the graphs are installed before the rows are assigned, since a row can hold a math channel's name and that name only exists as a signal once its graph is in. So a scope reopened on the same source comes back as the one that was closed — including a signal that was enabled only to feed a math channel. **Launched with no `--server`, udpscope asks which source to open.** It used to connect to `127.0.0.1:8000` because that is the default in `Config` — fine for one streamer, useless for several, and there was no way to see which sources the app already knew about. The chooser lists one row per stored session: the server and port it was named after, how many plots and signals it held, and its multicast group when it had one, newest first. `New host…` is a four-field form — server, port, multicast group, interface — and nothing more: `--buffer-mb` and the rest are tuning rather than "which source", and a form that asks for them is a form you have to read before using the app. Cancel exits before anything is allocated, because declining to choose a source is not a failure. It runs **before the model exists**, which is the whole reason it is cheap: no buffer, no socket and no window until a source is known, and `ScopeModel`, `Acquisition` and `run_ui()` are untouched by the feature. Every headless probe passes `--server`, so none of them reaches it. Two things the list had to get right. A session file is named `_.toml` and that name cannot say whether the source was multicast or which interface it joined on, so the file now records both — in the top block, before the first table, because a bare key written after a `[[table]]` header belongs to that table in TOML, which is how the `enabled` key was silently lost once already. And the list is **filtered to sessions that name a signal**: a development machine accumulates one file per probe run on a throwaway port — 140 of them here, every one with `signals = ["", "", "", ""]` — so an unfiltered chooser opens as a wall of ports nobody chose. A row's `×` forgets a source by deleting its session file, with no confirmation: the gesture is the confirmation, and what it destroys is one layout the app rewrites on the next clean exit. A session is **not written** when no CONFIG arrived during the run — otherwise a source whose streamer was down during one launch would silently vanish from the chooser list because its file was flattened to empty signal names, which the filter refuses. **Math channels.** A math channel is derived from the others by a graph of operations and is then an *ordinary channel*: the model synthesises a signal entry for it, so an enabled one is acquired, plotted, measured, exported and listed in the signal panel exactly like a streamed signal — and another math channel can be its source. The operations are `channel` (a source), `gain + offset`, `abs`, `sqrt`, `clamp`, `low pass`, `high pass`, `band pass` (biquads, cutoffs in Hz against the stored rate), `sum`, `difference`, `product` and `ratio`; the arithmetic ops take two inputs, which is what makes branching — the sum of a raw and a filtered channel — expressible at all. The editor is a compact node graph: each node is a box, its inputs are the squares on its left edge and its output the square on its right. Click a box to select it and its parameters appear in the form beside the canvas; click an output square and then an input square to wire them; drag a box to move it. The graph is a *list* walked in order, so a node can only read the ones above it — a cycle is not detected, it is unrepresentable, and an input square is only offered the nodes that qualify. The node carrying the channel's value is the **last one** of a graph built top to bottom (as the orange dot on the canvas says, and settable with *set as output*): it defaulted to the first node — the source — so every operation was computed and discarded, and a gain or a filter produced a channel identical to its input. Numeric fields are seeded with their own identity values too, since `gtk_spin_button_new_with_range()` leaves a spin button at its range's minimum: the offset started at -1e6, which turned `x*2` into `x*2 - 2000000`. Wiring is checked before the channel is created, and the reason is shown rather than the dialog closing on the work. A `Source` node lists only the channels the scope is actually **acquiring**: naming a disabled signal would read as no data for ever, and the stream's timestamp signal has no samples at all. The filters are re-prepared from the *stored* sample interval on **every** plan change — a CONFIG, an enable/disable, and a window change that moves the decimation — because a biquad's coefficients are a function of that interval and its state belongs to the rate it was running at; preparing them only when the graph is edited left a wrong cutoff and a ringing transient after any window change. The samples are produced at **ingest**, once per stored slot, from the values the other channels just pushed — and a derived channel is *skipped* by the loop that reads the stream, since it has no packet signal to read. That mattered more than it looks: reading it gave zero, and pushing that alongside the real value advanced its sample ring twice per time slot, so the trace alternated between its value and 0 while the buffer filled and then slid a whole window off the time ring once both wrapped and vanished. One push per slot is the invariant, and the probe's dump prints it as `N samples vs M time slots` — a ratio the plot cannot show you. That is what makes the filters real: an IIR has memory, so a low pass computed from a window would be a different (and wrong) answer every time the window moved, while one fed the stream sample by sample is the filter the user asked for. It also means the pane's numbers and the plot agree by construction — both come from the same stored samples. **Closing the editor clears it.** The dialog's pointer is cleared by a `close-request` handler, and `gtk_window_destroy()` — which is what Cancel and Save used to call — does **not** emit that signal. The pointer therefore stayed set on a destroyed window, and every later Edit took the "already open" branch and presented the corpse: a GTK critical and nothing on screen. Both buttons call `gtk_window_close()`, which emits the signal, clears the pointer, and destroys. **The trigger marker is a readout, not a handle.** It is a cursor, and a cursor is grabbable by default — so parking it outside the axis, which is how it is hidden when no event is on screen, did not stop a *drag*: the controller holds a marker for the gesture's life and follows the pointer between frames. It takes `set_cursor_selectable(false)` like the measurement marks, and the dump reports `readout instruments locked: 1`. **A pinned view is navigable within its data, and in live view too.** Three places could leave the window showing data the scope never had, and all three are now bounded by *the data* rather than by the window that was requested: the **pause** pinned on the window it asked for (the snapshot is a cut clamped to the ring, so it can be shorter), the **held event** pinned on the snapshot's recorded `pre_s`/`post_s`, which is again the request — a capture shorter than it exceeds it — and a **following group**, whose scroll offset was bounded by the retained span alone. That last one is the live case: with the loose bound, scrolling back put the window's *right* edge on the oldest sample, which leaves the whole span to its left over nothing — an empty margin exactly as wide as the view. The group's bound has two cases because it has to: while the members retain at least a window's worth, the whole window is kept on the data; when the window is *wider* than what is retained (a ring still filling, a trimmed one) no placement avoids a margin, and the least-empty one — right edge on the newest sample — is kept, which is also the behaviour every existing test expects. A `Capturing` view is deliberately not clamped: its post side is empty until the capture completes, which is the feature. **The pause pins on its snapshot, not on the window it asked for.** The pause freezes the visible window, but that window is *cut* 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. The axis was pinned to the request, which left the shortfall as an **empty left margin** (a run showed `frozen_x=[0.000000..0.561537]` against an axis of `[-1.44..0.56]`), and zooming in moved the view deeper into the empty part, which is what made it look like zooming *caused* it. The pin is the snapshot's own stamps now, so the frozen picture fills the frame and a zoom stays on its data; the event pin already worked this way, and the pause agrees with it. `[pause-probe]` prints `axis=` beside `frozen_x=`, and while paused the axis must lie *inside* `frozen_x` — equal at the pause, a subset after a zoom-in. **Zooming into a held event clipped it further and further from the left**, and the cause was the one the library's own margin fix is about, in the half of the code the fix could not reach. `EventSeries::neighbours()` — the call the renderer maps the visible range onto sample indices with — answered from the nominal `(x - x0()) / dx()` grid, while `ChannelBuffer::neighbours()` next to it binary-searches the real time ring. `dx()` on a snapshot is the *mean* spacing of its stamps and the stamps are an arrival clock, so the grid and the clock drift apart by however much the clock wandered over the capture; the renderer was handed an index window offset from the axis by that drift. Because the offset is a constant number of *seconds*, its share of the screen doubles with every zoom step: a 2 s capture off by 8 ms is invisible, and seven halvings later it is 374 px of blank on a 716 px axis, with the trace's ink pushed off the right-hand edge by the same amount. The crosshair reads `x_at()` — a real stamp — so it went on reporting the right value over the blank part, which is exactly how the report described it. `neighbours()` is a binary search over `stamps` now, like every other series here. A perfectly uniform source hides the defect completely, so the mock can jitter its clock (`--clock-jitter`, applied to each packet's own spacing so the stream stays monotonic) and `[zoom-probe]`'s `gap_px` is the guard. **A pinned window is not a navigation surface.** In trigger mode the x window is the event — `[-pre, +post]` while a capture fills, the snapshot's own span once it is held — and a gesture must not move it. Nothing was pulling a drag back: `clamp_to_data()` is the live-view half of the rule above and is deliberately skipped while a capture or an event is displayed, and the library's own pan bound lives in the *following* path (a `Live` group's offset is clamped against what its members retain), so a group with the follow off had no bound at all. A drag or a wheel zoom therefore walked the frozen capture off its axis, leaving an empty plot over data the scope never had. The pin is now re-stated every frame while it is pinned, in the same branch that decides `clamp_to_data()` is not to run, which makes a pan or a zoom in trigger mode (and on a paused view, whose frozen window is held the same way) a no-op. Leaving trigger mode, or resuming, is what makes the view navigable again. `UDPSCOPE_PROBE_PAN_MS` is the guard: it writes the range a gesture writes and reports `held=1` when the axis ignored it. **Four measurements cost a third of the run's frames.** A measurement is a statistic of the visible window, so it was recomputed whenever that window moved — which in a live view is every frame, at O(visible samples) *per measurement*. Adding a `top` measurement to a 1.3 MS/s stream whose window held 4.7M samples took the frame tick's mean from **0.41 ms to 13.66 ms** and the run from 631 frames to 407. Three separate costs, found by instrumenting the pane rather than by reading the frame mean (`measure cost: N recomputes, X ms total, Y ms each` in the dump, next to the values): 1. *The pane was recomputed per frame.* It is a readout, so it now follows the rule the status line's own fields already had: at most every 100 ms while the window scrolls, one pass the moment it settles, one per mode change — and the interval follows the *work*, stretching to 400 ms when a pass costs more than 5 ms, so what stays bounded is the duty cycle rather than the period. (Getting the "settled" case backwards is what made it *worse* first: 457 recomputes in one run, because a *pinned* window then recomputed on every frame.) 2. *Both window vectors were copied per measurement.* Four measurements over a 4.7M-sample window is 300 MB of memcpy per frame — memory-bandwidth bound. The loop holds the cached window by reference now, and it is materialized in bounded chunks instead of through two whole-window `float` temporaries. 3. *Every type rescanned the window.* Average, min, max and the two new types are all derived from one shared pass — both extremes with their first positions, the sum and the count — so several measurements on one signal share a single scan and adding a type adds no pass. Measured on the same stream and window: tick mean **1.39 ms** with the pane holding four measurements against **0.73 ms** without (595 frames against 610), the pass itself 11 ms every 400 ms — and the 82 ms worst tick is the *control's* too, i.e. the publish stall, not this. **"Show on plot" draws full-width rulers, and they must not blink.** A level measurement draws an `h_ruler` across the whole plot and a time one a full-height cursor, so a pane holding four measurements can put six lines over the data — that is the feature, and the lines move with the window. Because the pane is now refreshed on a cadence, the marks were re-placed from a result that could be a pass old; worse, a pass landing on an empty window (a rebuild, a capture boundary, a mode change) emitted no marks at all, so the rulers vanished and returned a frame later. The fallback is by *kind*: an invalid pass keeps the last valid pass' **level** marks — a level is a property of the signal and outlives the window it was measured in — and drops its **time** marks, which are positions inside the window that result described. `marks=0` with measurements on and data arriving is now the assertion (`[measure-trace]` in a probe run), and it never occurs. Their stroke is the app's own setting, not the library default: **0.75 px and dashed 4/4** (`set_cursor_stroke()`), so a measurement's mark reads as an annotation over the data rather than as another instrument framing the plot. The trigger marker keeps the solid `line_width_px` stroke on purpose — it marks an event the scope acted on, which is a different claim from a measured level. The library's own test is `a_cursor_stroke_can_be_thinner_and_dashed`. Two related guards came out of the same hunt: `clamp_to_data()` compares against the window with a relative tolerance (clamping to *exactly* `window_s_` can leave the group's span an ULP over, and a clamp that fires every frame is a re-frame loop — the dump counts it as `clamp fires:`), and the sample-window walk it does is unchanged. **Amplitude and peak to peak** are the two new types: `max - min`, and half of it. Deliberately `(max - min) / 2` rather than `max(|value|)` — the latter is a distance from zero, which for a signal riding on an offset is a different and less useful number. Both come out of the shared pass, so they are free, and both read exactly what a unit sine should: `amplitude=1`, `peak to peak=2`. The second is subtler and needs a saturated ring. A `ChannelBuffer` sample is a (value, stamp) pair split across two rings of equal capacity, and the published window is pinned by `acquire()` while both rings keep sliding under it — so the oldest samples of the window point at time slots the producer has since overwritten. Those used to read back as `0`, a perfectly plausible coordinate, which landed in `x_bounds()` and from there in `dx()` and `x0()` — the window's extent, which the axis, the measurements and the LOD level are all read from. (The renderer itself no longer walks that grid to find its sample window; see the margins note below.) It converts the visible x range into indices with `(x - x0()) / dx()`, so *one* unreadable slot inflated `dx()` by the ratio of elapsed stream time to the retained window, scaled every index up with it, and the gather began well inside the visible range. The stamp is now clamped to the oldest readable slot, which costs at most a short plateau of equal stamps at the very left edge — the same bargain the library's own `StreamSeries` documents, where an overwritten slot costs a y value and never the geometry. The self-check prints `dx_ratio` and the renderer's index window beside the binary-searched one, so both are checked rather than assumed. **The clamp made that slot survivable; it did not make reading it safe**, and the difference is the "with the measurements and cursors panes up the plot flickers as if there were no data, at no constant frequency" report. Sample 0 of the window is not an ordinary slot: `acquire()` pins the window to the value ring's `base()`, so it is the ring's *oldest* — the one the producer is about to overwrite. `time_of()` checks the bounds and then copies, and everything on a `ChannelBuffer` except `acquire()` is read at **paint time with no lock**, so when the producer lapped that slot in between, the stamp came back one whole ring span too new. From there it is `x0()`, which is the origin the renderer reconstructs *every* trace point from (`gather[i] + series.x0()`), read both inside its increasing-suffix filter and again when it stages the geometry — so one torn read of one slot produced three different pictures, all of them "the waveform is gone, the grid and the axes are still there": * `neighbours()` tests the asked-for x against `x_at(0)`, so a bad first stamp made it refuse the whole visible range. The renderer then gathered the **entire ring** at a coarse level and drew it off the axis — the probe caught `idx=[0..8388608) level=7 cover=320%`. * `x0()` changing *during* the filter loop made it drop three quarters of the points (`kept=750` against `kept_fixed_x0=1865` on the same frame). * Staging shifted the finished polyline a ring span sideways: `ink=[-6.04..0.38]` against `axis=[4.80..6.80]`. The stamp is now read **once, by `acquire()`** — which runs on the consumer under `ScopeModel::mutex()`, the same lock the producer's pushes hold, on the line after the window is pinned, which is the one point in the object's life where that slot cannot move — and cached as `window_x0_`; `x0()` and `x_at(0)` are served from it. It is also the *correct* answer rather than merely a stable one: it is the stamp sample `window_base` actually carries, where the paint-time clamp answered with whatever slot happened to be oldest at the moment of asking. The defect was live-only (a snapshot is immutable, so its `x0()` never moves, which is why a held event never flickered), and the measurements pane made it both likelier and larger because the pane holds `mutex()` for each window reader's lifetime and the released producer then bursts through several packets at once — hence the report's pairing with the panes, which turned out to be a symptom of frame length rather than of the panes themselves. The user's own first guess, that it might be an anti-aliasing artefact of a thin line, is ruled out by the line shader: a degenerate segment still gets quad area on purpose, and `half_w = width/2 + aa` with the fragment ramp keeps a 1 px line above about half coverage at the worst alignment. Anti-aliasing can dim a trace; it cannot erase one. Neither `staged_segments` nor `lod_traces` can see this — the first counts the grid and the instruments too, the second counts a trace whose points are collapsed a moment later — so the guard is a new per-paint instrument. `check_frame_ink()` recomputes what the renderer was handed, through the same calls, and reports where the ink landed against the axis: the dump's `trace ink: N paints checked, C collapsed, S staircase, H short` must read `0` for the first two, and each anomalous frame prints an `[ink]` line whose `back` ≈ one ring span is this defect's signature. The same torn read still exists for a small `i > 0` — the oldest frame or so of samples — but there the observable is a prefix plateau of equal stamps, which is exactly what the renderer's increasing-suffix filter exists to absorb, so it is left alone rather than fixed speculatively. **The time ring is `f64`; the value rings stay `f32`.** It was `f32` like everything else, with the stamps stored relative to an epoch fixed at the first sample ever seen — so the number being stored grew for as long as the app ran while the interval it had to resolve stayed one sample period. `f32` carries 24 bits of mantissa, which is a *relative* precision, so the ULP at 16 s of uptime is 1.9 µs, at 32 s 3.8 µs, at 64 s 7.6 µs and over a 600 s window 61 µs. At 1 MS/s the sample period is 1 µs, so after half a minute two to four consecutive samples rounded onto the same stamp, after a minute eight, and so on without bound. That is the "zoom in to about 10 µs and there are three to five points with the same timestamp" report, and the export showed it unambiguously: `np.diff(t)` over two million rows held **three** distinct steps, 0.953 µs, 1.192 µs and 1.431 µs — exactly 2, 2.5 and 3 ULP of the magnitude the stamps had reached, a stair instead of a clock. The min/max artefacts are the same cause read through the LOD pyramid: a bucket of samples sharing one x is a zero-width segment, so an envelope drew as a vertical spike at a plateau's edge rather than as the waveform. Nothing was wrong with the arithmetic, so no amount of care at a call site could have fixed it; the type was. **A value's precision is a property of the signal and a timestamp's is a property of how long the scope has been running** — 24 bits is plenty for a sample and runs out on a clock. Widening only the clock keeps the cost where it belongs: the rings are the memory budget, and the values are the overwhelming majority of them. The one consequence worth knowing is that `plan_partition()`'s growth loop can no longer count slots, because a time slot and a value sample are now different sizes — it weighs the budget in **bytes** (`sizeof(f64)` for the shared clock plus `sizeof(f32) / stride[i]` per channel) and the power-of-two rounding absorbs the rest: a three-channel stream at `--buffer-mb 200` retains the same 8.39 s span it did before. `EventSnapshot::stamps` is `f64` for the same reason rather than for symmetry — an *event's* stamps are rebased onto its edge and so are small, but a *pause* view is deliberately not rebased and carries full stream time, which quantised at exactly the rate the live ring did. `RingBuffer` stores its slots as `std::atomic` behind a `static_assert(is_always_lock_free)`, so this was a question worth asking rather than assuming: `std::atomic` is lock-free on every target this app builds for, and the assertion is a compile-time guard rather than a silent fallback. **A held event carries its own LOD pyramid**, and the lack of one is what "the plot gets laggy with large data" was. The renderer picks a level per trace per frame with `MinMaxPyramid::level_for()`, which descends to level 0 — *raw samples* — on a pyramid with no levels, whatever the sample count says. `ChannelBuffer` maintains a real pyramid incrementally, so the live view was never affected; `EventSeries` returned a permanently empty one, so every frame a capture was held re-gathered every sample it held. A 2 s capture at 1 MS/s on three traces staged **6,000,073 line segments per frame** and took 100–230 ms to draw — and it stayed that way for as long as the event was on screen, which in trigger mode is most of the time. The pyramid belongs to `EventSnapshot`, not to the series: the snapshot is immutable and shared, so building it in `publish_event()` costs one `append()` per sample inside the `O(n)` copy-and-bounds pass that was already there, and the same channel on two rows shares one pyramid instead of building and storing it twice. `EventSeries::gather()` then draws envelopes per bucket with raw samples at the two partial edges, the same shape `ChannelBuffer::gather()` uses — the old implementation took every `2^level`-th sample, which hides exactly the spike a capture was triggered on, and since the level was always 0 it never even ran. On the same repro: **138,078 segments** (43× fewer) and a 66 ms worst frame, with the steady-state draw around 2 ms. It costs about 1 byte per sample per channel (≈25 % on top of the snapshot's `f32` values) and one stall per latch, since the build runs under `ScopeModel::mutex()` — at 1.3 MS/s that is a ~40 ms tick, once, with no packet gaps. The self-check's two `frame cost:` / `frame path:` lines are the guard: they split a frame into the tick, the plot's own draw and the whole GTK paint, and report the segment counts and whether the LOD path was taken at all. A segment count in the millions is this defect returning. **The panels and the toolbar.** The header bar carries only what you reach for *while watching a signal*: the time window, pause/resume, the trigger toggle, the side-panel toggle and an overflow menu. Those four buttons are icons with tooltips; nine text buttons was a wall of words to read past every time you wanted the one you needed. The overflow menu holds **Fit to data**, the **Layout editor**, the **Signal list** toggle, **Export visible range...**, **Keyboard shortcuts**, **Help** and **About udpscope**, in that order — the shape a GNOME menu has, and drawn as one: every row's label starts at the same left edge (and the rows that can be checked keep that column whether or not they are checked), a shortcut sits at the row's right edge, a row is ~18 px tall rather than Adwaita's 34, and the groups are separated by rules. The ⓘ on the status bar opens its detail **above** the button: below is off the window's bottom edge, and a bubble whose body is clipped away while its top edge stays at the button is what "the arrow is detached from the body" looks like. The **help window** (`F1`) carries the same screenshots as `docs/`: the overview page shows the main window and a held capture above its text, and the trigger and math pages carry theirs. A page's images are `GtkPicture`s **with a height request** — a shrinkable one asks for no height at all inside a box, so without it the page renders its text with a silent gap where the screenshots should be. The side column and the two panel toggles stay hidden until a CONFIG arrives — there is nothing to configure before the stream describes itself — and the first CONFIG reveals the signal panel (the trigger panel stays behind its own toggle). The trigger panel selects the **signal** (every signal the CONFIG described, not only the acquired ones, so the list is never empty while a stream is known), the edge, level, pre-trigger percentage, hold-off and mode, and arms the trigger. **The event window is its own control**, separate from the display window: it is how much of the stream one captured event covers, and the pre-trigger percentage is a share of *that*. Tying it to the display window was a mistake — with the 600 s default a latch took 300 s (half of it) to fill, so the view sat frozen for minutes and the trigger looked dead. With the default 50 ms event window a latch completes 25 ms after the edge. When an event latches and the plot is zoomed out wider than the event window, the display zooms to the event so the captured waveform fills the plot; the window control follows. **Hold-off is the quiet time after an event has been captured** — measured from the end of the capture, not from the edge, because the capture itself already blocks re-arming. Choosing a signal, switching trigger mode on, or arming the trigger all enable that signal's acquisition — a trigger on a signal nobody acquires could never fire. **Trigger mode is the switch between streaming and triggering**, and the status line always names the state it is in: | status line | meaning | |---|---| | `[live]` | trigger mode off: the view follows the stream continuously | | `[trigger: armed, waiting for an event]` | watching for an edge; the view shows the previous state | | `[trigger: CAPTURING (post-trigger window filling)]` | an edge was accepted; the post side is filling in view | | `[trigger: EVENT HELD …]` | an event was latched; the view is frozen on it | | `[trigger: disarmed (view frozen)]` | armed and waiting are not the same thing — nothing will arrive until it is armed again | While an event is on screen — being captured or held — a **red vertical line marks the trigger instant** on every row, at x = 0. The latch completes a post-window later, but the line marks the edge, so the pre/post split can be read straight off the plot. It is not one of the user's cursors: it has its own colour, ignores the cursor show/hide toggle, and is parked (invisible) when there is no event. **The status line.** It starts with an activity badge and then the mode, the connection, the measured rate, the packet/frame/gap counters, what plot 1 holds and the buffer's size and span. Three of those are worth calling out: - **The badge blinks green at 1 Hz while the pipeline is actually running** — the stream is connected, a CONFIG has arrived, acquisition is not paused, and in trigger mode the trigger is armed. Anything else leaves it steady, with the reason on its tooltip (`paused`, `trigger disarmed`, `waiting for the stream`). *Blinking* is the signal, not the colour: a steady badge is a stopped pipeline, and that distinction survives a screenshot. It is one glyph whose colour is all that changes, so blinking it cannot change its width — a badge that grew and shrank twice a second would move the label beside it and re-lay out the plot with it. There are two steady colours, and the difference is how far the handshake got. **Grey** is a socket with nothing behind it, or a pipeline the user stopped. **Yellow** is a source that answered CONNECT but has sent **no CONFIG**: there is no signal list, so nothing can be enabled and no DATA frame is owed. The glance line reads `connecting` in both, because neither has the signal list every other state word claims; the colour is what separates "no socket yet" from "a socket and no stream behind it". A blinking green over a source that has sent nothing would claim a working pipeline, which is the one reading the badge exists to prevent. - **`rate 9.99 kS/s` is the rate the buffer actually stored over the last second**, not the rate the CONFIG declared. The two agree unless the requested window is too long to hold at the wire rate, in which case the ingest resamples (see "A window longer than the buffer is resampled" below) and the stored rate is the readout of how much resolution the window costs; otherwise a gap between them is the datasource's real wire rate. One figure when every channel agrees within a percent, `lo..hi` when they do not; the per-channel breakdown, stored against declared, is on the line's tooltip. A per-channel list in the line itself would make its width depend on the stream. - **`last event 12 s ago`** (trigger mode only) ages the picture on screen, in seconds, then minutes, then hours. A frozen view looks the same whether the last event was a second or an hour ago, so without it there is no way to tell a working trigger from a dead one. Measured on the wall clock, not the stream's: it is a question about how long *you* have been waiting. The clock starts at the **edge**, not at the latch — that is the instant the user saw, and it is also when the capture appeared on screen — so a long capture reads as an event that is already ageing rather than one that has not happened. Before the first event it reads `no event yet`. Switching the header bar's **trigger** button off returns to `[live]` and resumes drawing the stream; the trigger panel carries the same action as a **Go live (trigger off)** button, so the way out is inside the panel that froze the view. Switching trigger mode on arms it immediately (arming is what "wait for an event" means), and in single-shot mode the trigger disarms itself after a latch, which the status line reports. The signal panel is one line per signal: a colour chip naming the trace it is drawn as, a checkbox that acquires it, its name, its unit, and a `⋯` menu holding the information popup and the gain/offset calibration popup. **The chip's colour belongs to the signal, not to the slot it landed in.** The palette is indexed by position in the signal list (`signal_color_index()`), so a signal dropped on two rows is the same colour in both and the chip names a trace unambiguously; indexing by trace slot made the same signal blue on row 1 and orange on row 2, and then the chip named neither. A signal that is on no plot — acquired or not — gets a **grey** chip rather than no chip, so every row of the panel is the same shape and the column of chips reads as a legend. Because `plot::Plot` fixes a trace's colour at `add_trace()`, the index is stored on the row (`Row::color_index`) and a colour change goes through a figure rebuild, which a drop already forces. A `GtkSwitch` and two always-visible buttons made a row about twice as tall (46 px against 26 px now), which is what turned a four-signal stream into a panel you have to scroll; Adwaita's button and list-row minimums, not the widgets themselves, set that height, so the row carries a `.udpscope-compact` style class that relaxes them. **Signals start disabled**, because enabling one is what starts acquiring it. Enable a signal with its checkbox, or drag it onto a plot: the drop enables the signal if needed, binds it to that row's first free trace slot, and rebuilds the figure so the legend name is right (there is one widget, so the row a drop lands on is `PlotArea::row_at()`). The status line reports the outcome of a drop (`plotting 'sine' on plot 1`, `plot 1 already holds four signals`), so an action that changes nothing still says why. Each plot's y axis has a right-click menu with auto scale, a fixed range, **Fit X axis**, **Fit all**, and one **Remove '' from plot N** entry per occupied trace slot (a plot with nothing on it says so instead). The two fits are the same pair the overflow menu and the `f` key offer, split by what they touch: **Fit X axis** puts the x view back on the whole time window and leaves every y scale exactly as the user set it, **Fit all** does that and auto-scales the y axes too. Neither changes the time window itself — see below. **The plot does not twitch.** Two independent causes were fixed, both of which showed up as the x axis moving a few pixels every frame — including while paused, which is how the second one was found: 1. *The gutter followed the y tick labels.* `TickFormat::Si` changes their width as the auto-fitted range moves (`1.5` → `15` → `150` → `1.5k`), and `set_min_y_gutter()` is only a floor — the library still takes the larger of measurement and floor. `RowSet` therefore reserves the width of the widest label the formatter can produce, measured once with the plot's own monospaced font, and hands it to the *figure* (`Figure::set_min_y_gutter()`, added for this): `Figure::layout()` clears every row's own floor each frame to measure what its labels need, so a floor written onto the rows would survive exactly until the next layout. The figure seeds its common gutter — the maximum over the rows, which is also what aligns their axes — with that reservation. The reservation is the widest label `format_si()` writes for an axis with no unit (a sign, three significant figures and a one-glyph prefix), not the wider `%.3g` fallback it used to cover: that form needs a value past 1e24, so it cost a third of the gutter for a label no real signal produces — and since the floor is a monotone maximum over the *measured* need, one that did appear would widen the gutter for good anyway. 2. *The status line drove the pane width.* A `GtkLabel`'s minimum width is its text, and the status line is rewritten every frame; as its counters and messages changed width they moved the paned divider, which resized the plot, which re-laid out the axes and their ticks. The label is now ellipsized, so its minimum is “…”; the plot keeps the width it was given. 3. *The gutter was a ratchet.* `note_y_gutter()` is handed the gutter the row was **laid out with** — already the maximum of its labels and the floor imposed on the frame before — and it added six pixels of slack to it. Those six pixels came back next frame and were added again, so the left margin climbed a decade of pixels a second until it hit the 200 px cap and stopped: 200 px of an 870 px viewport, with `axes.w changes: 17` beside it, which reads as a settle and is a runaway that ran out of room. The reservation's own padding is the slack, once; nothing is added to a measurement that is partly our own answer. **The two sides are balanced.** The axes rect used to reach the viewport's right edge, because that is all the library's layout had ever reserved — so the rightmost x tick label, *centred* on its tick, had half of itself outside the plot (a live axis is `[-window, 0]`, so there is always a tick exactly on that edge) and the newest sample's ink landed on the frame. `Plot::set_right_gutter()` and `Figure::set_right_gutter()` were added upstream for this: an empty band, served from the same half-viewport allowance as the label and title strips, set once on every row because the rows' axes have to end on a common column as well as start on one. `RowSet` asks for half the widest time label plus the label padding, which is exactly what the last label needs to be drawn whole. With the two fixes above the figure now sits in a 104 px left gutter holding the y labels and a 59 px empty band on the right, where it was 200 px and nothing. The self-check reports all of it. `viewport.w` must collapse to a single value, and `axes.x`, `axes.w` and `y-gutter` must settle: the one-shot label reservation lands a few frames after the first measured frame, and because the figure serves its floor out of the label strip the reservation converges over two layouts, so their ranges show that settle and nothing after it. The companion `axes.w changes: N (last at frame F)` line is what separates a settle from the twitch — a handful of changes near the start is settled, a count near the frame count is the bug. `x-ticks` is content, and varies by one as a live window slides over a round number: ``` layout over 583 frames: axes.x 88..104, axes.w 707..782, viewport.w 870..870, y-gutter 88..104, x-ticks 20..41, x-desync 0 axes.w changes: 1 (last at frame 1, side panel up=1) ``` Set `UDPSCOPE_PROBE_PAUSE_MS=` to pause the acquisition part-way through a headless run, so the numbers are measured on a still picture. The app also logs, once per second, one line per acquired channel with the number of samples stored and the rate that implies, next to the rate the CONFIG declared: ``` [udpscope] ch 1 'ch2_2kHz' stored=8500 (+4603 kS/s, declared 0 S/s) stride=1 decim=1 y=[-0.98, 0.98] ``` `stride` is the per-channel decimation (always 1) and `decim` the shared clock's (`Partition::time_stride`, 1 unless the requested window forced a resample). With `decim=1`, a stored rate far below the declared one is the datasource's own wire rate, not something the viewer did; with `decim=N` the stored rate is the wire rate over `N`. **The labels and the hairlines were fixed in the plotter, not worked around here.** Two defects made the plot look rough, and both were in `deps/plotter`'s shaders, so the fix is a submodule commit and a pin move (see CLAUDE.md): 1. *Glyphs lost whole strokes.* `kTextFS` **point**-sampled the atlas — `GL_NEAREST` plus `floor(v_uv * u_tex_size)` — and a point sample at a non-integer scale does not blend texels, it **deletes** them. The library's own default is a 12 px style over the 13 px embedded bake, a scale of 0.9231, which dropped one of every 8 columns and one of every 17 rows of every glyph; `500m` under a y axis rendered with broken zeros, and which stroke went missing depended on where the unsnapped pen fell. `kTextFS` now does a 2×2 bilinear fetch **clamped to the instance's own cell**, so the no-bleed guarantee the `GL_NEAREST` sampler mode protects still holds — the atlas is a tight strip of cells with no padding, and a filter that knew nothing of cell bounds would pick up a glyph's neighbour. At scale 1 with a whole pen the weights are exactly zero and the result is the point sample again. Filtered glyphs peak at 0.844 coverage rather than 1.0, which is the honest cost of not deleting strokes; total ink rose 5.7 %. 2. *A 1 px grid line lit two columns at half coverage.* The grid and axis lines are staged in **data** space and mapped in the vertex shader, so their centres landed on arbitrary fractions: x = 100.0 smears across two pixel columns, x = 100.5 lights one crisply, a frame mixed both, and panning made each shimmer between the two. `LineProgram::set_snap()` / `u_snap` rounds an axis-aligned segment's ink *leading edge* onto a pixel boundary — integer+0.5 for odd widths, integer for even ones, from one expression. It is on for the chrome that *frames* a plot (the grid, the axis lines) and **off** for everything drawn at a data position — traces, cursors, region edges, the crosshair — because the rounding moves a line by up to half a pixel, and a cursor that reports a value may not be moved to look tidy. Diagonals are untouched either way. Measured on one scanline of 620 px: 22 distinct intensities before, 10 after, with the labelled grid landing exactly on its own `grid_major` colour instead of a spread of partial blends. (That measurement predates the minor grid's own styling below: the *minor* class now blends on purpose, so it no longer lands on its exact colour — the majors still do.) A third, found the same way, is in the tick generator rather than the shaders: 3. *The tick that meant zero was labelled `-139a`.* A y axis over `[-1, 1]` read `-1 -500m -139a 500m 1`. `generate_ticks()` walks from `ceil(min / stride) * stride`, which is not representable whenever the stride is not a power of two — 0.1 and 0.2 are the everyday ones — so a range straddling the origin arrives there a few ULP off, and `format_si` dutifully reaches six prefixes down the SI table for it. Every tick is an integer multiple of the stride, so the smallest a *non-zero* tick can be is the stride itself while the residue is a few ULP of the range: anything inside a millionth of the stride is the origin and nothing else. It is now stored as exactly `0.0`, not merely labelled `0`, because `Axis::refresh()` re-formats a time axis from `tick.value` — where the same residue read `3.19e-13ms`. A range whose ticks are *legitimately* that small keeps them. 4. *One second was labelled `1e+3ms`.* `format_time()` picked its rung by comparing the raw value against each unit's scale and then wrote the mantissa with `%.3g` — so a tick a hair under a rung's top (a 1 s tick computed as 0.9999999 s, which is what differencing a live axis gives) stayed on the rung below and its mantissa *rounded up to 1000*, which `%.3g` writes in exponential form. The fix is to pick the rung by the **rounded** mantissa (`t < u.scale * 0.9995`), which is exactly the `>= 999.5` spill guard `format_si()` in the same file already had; the whole-second branch takes the same slack. The test is `format_time_never_lets_a_field_reach_its_radix`, extended with the boundary of every rung from seconds down to nanoseconds and its negative. **The minor grid is texture, the labelled grid is structure.** The subdivisions used to be drawn exactly like the labelled lines — same width, same opacity — so a plot dense enough to need them read as two competing sets of ticks. They are now three things the style states explicitly: **thinner** (`grid_minor_width = 0.75`, a sub-pixel logical width, which at 1x is expressed as reduced coverage and on a HiDPI display as a literally narrower line), **dashed** (`grid_minor_dash_on`/`_off = 3/3`, a fragment-shader discard, so the vertices are unchanged), and **blended into the background** (`grid_minor` carries 50% alpha, and the line shader applies it). The labelled grid is also brighter than it was (`grid_major` `0x2A3038` -> `0x39414C`), still well under the axis line's `0x8A94A6`, so the three-way hierarchy the eye reads is minor < major < axis. Together the defaults leave a minor line an order of magnitude less ink than a major one, which is what gives the eye one grid to read instead of two. Set `grid_minor_dash_off = 0` for the old solid subdivision, and `grid_minor_width`/`grid_minor` for the rest. The library test is `the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major`. A fourth lives in the renderer rather than the atlas: the visible x range is mapped onto sample indices through the **series' own x**, not the `(x - x0()) / dx()` grid. `dx()` is documented as a *nominal* spacing and on a ring-backed series — whose x is a streamed timestamp — it is only the mean of the retained window, so the grid walk landed the draw window inside the visible range and dropped the two segments that enter and leave the frame. The symptom is the one zooming in shows first: the trace stops short of the left and right margins and those edges go empty. `Series::neighbours()` answers where the bracketing samples are, over the real x, and a uniform series answers the same question through the same call — so it is one path, and it costs what the crosshair's own lookup costs. Each carries a test upstream (`the_atlas_is_filtered_not_point_sampled`, `grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero`, `format_time_never_lets_a_field_reach_its_radix`, `a_sparse_window_between_samples_still_inks_both_margins`), and each was verified to fail with its own fix reverted. **Cursors start hidden** and are switched on from the overflow menu; **Reset cursors** switches them on as well, because a reset nobody can see reads as a dead button. The two panels collapse vertically (`SLIDE_DOWN`), so hiding one gives its space back instead of leaving an invisible gap in the column. With the cursors on, a **readout panel** appears in the side column, and a dropdown chooses what the instruments are: | mode | instruments | the pane reads | |---|---|---| | `vertical` | two x markers, one pair for the whole figure | `T0`, `T1`, `dT` | | `horizontal` | two y rulers on **each** row | `Y1`, `Y2`, `dY` per row | | `both` | both of the above | the times, then a line per row | | `mix` | the x pair, plus where each marker **crosses each trace** | the times, then `Y1`, `Y2`, `dY` per **bound** trace | The vertical pair is one stream time shared by every row, so the markers line up down the column and dragging one on any row moves them all — the app reads the position back out of the plot **while a gesture is on that row**, because a drag writes the *plot* and that is the only place it is visible. Outside a gesture the plot is not read at all: `cursor.x` is the snapped value then, which is what the app wrote itself a frame earlier, and adopting that back is what made a dragged cursor spring to where it had been. A ruler is per row: each row has its own y axis, so two rows' rulers are two different values, and the pane names the row. `mix` needs no rulers: it reports the value the trace actually has at each marker, read through the library's `Plot::crosshair_value()` — **the nearer of the two bracketing samples**, never a number between them — so it is the answer to "which datum of this signal is at these two times" rather than "drag a ruler until it crosses the trace". A readout is `--` when the trace has nothing at that time and `partial` when only one of the two markers is over samples. The value used to be a linear interpolation between the brackets, which is upstream's old convention and was wrong for this app in two ways at once. The samples here are **already averaged** — ingest decimation is a boxcar over `time_stride` samples (see "Decimation at ingest is an average") — so a number computed *between* two of them is a second, undeclared estimate laid over the first, and nothing on screen or in an export holds it. And at a zoom past the sample spacing it is visibly not the datum the pointer is over: the polyline between two samples is a straight line the renderer drew, and quoting a point on it reports the drawing rather than the data. A tie reads the earlier sample. The fix is upstream (`deps/plotter`, `crosshair_span()`'s `t` is a selector rather than a blend factor), so the library's own hover crosshair changed with the pane — which is the point, since both are the same lookup and a user comparing them must not see two numbers. Upstream keeps the blend as `CrosshairValue::Interpolated` for hosts whose x is a quantity they are willing to interpolate; the nearest sample is the default and udpscope never states the other. **The crossing dot is on the sample, not on the marker's column.** The dot is placed through `Plot::crosshair_point()` — the same lookup as the value, returning the quoted sample's own x as well as its y — so the mark the eye follows and the number in the pane are the same datum. The two x's differ by up to half a sample period: nothing at a whole-window view, and the whole width of the panel once a few samples span the axes, which is exactly the state in which a reader is asking "which point is that?". It therefore leaves the dot a little off its own vertical line by design — the line says where the *marker* is and the dot says which *datum* is being quoted, and when the marker is between two samples those are two places. The library's hover crosshair snaps the same way and draws its mark `Style::crosshair_marker_px` across (9 logical px against a trace's own 3 px sample dots), since a snapped mark lands among those dots and at their size would read as one more sample instead of as the answer to the question the hover asks. **A zoomed-in trace is drawn on the x its readout quotes, which needed the gather's origin to become the caller's.** Separately from the half-sample offset above — which is a property of *which datum* is quoted — there was an offset in *where the polyline was drawn*, and it grew with the zoom. `plot::data::Series::gather()` stages x as `f32`, which cannot hold an absolute epoch time, so it emits a value relative to an origin the consumer adds back in `f64`. That origin was fixed at `x0()`, the start of the **retained window**: the f32 then had to resolve 2^-24 of the whole ring however little of it was on screen — 1.9 µs over 20 s of retained data — while a readout reads `x_at()`, exact `f64`. At a view 10 µs wide across a 716 px axis (14 ns a pixel) that is over a hundred pixels between the drawn sample and the x reported for it, and it halves with every zoom *out*, which is why it read as "zooming in makes the crosshair pick the wrong point". The origin is now an argument, and `Renderer::draw_traces` passes the **visible range's minimum** — so the quantisation is 2^-24 of the span on screen, a fixed 4e-5 of a pixel, at every zoom. The fix is upstream (`deps/plotter`), where the parameter was deliberately placed *before* the output buffer so that every one of the library's and this app's call sites was a compile error rather than a silent change of behaviour. One simplification fell out of it: `stage_xy()` took a gather origin and a draw origin, those are now always the same value, and the linear case hands the gathered buffer straight to the line batch with no per-point copy. Two guards: the library's `a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes`, which measures the error in pixels both ways (under 0.05 px with the view's minimum, over 10 px with `x0()`), and this app's `trace ink:` line, which now reports `worst drawn-vs-quoted P px` over the raw-level paints of a whole run. The readouts are a **table**: a header naming the columns, then one row per instrument — `| who | A | B | delta |`. The first row is the two times and their difference, and the rows below it are the rulers (one per row, in `horizontal`/`both`) or the traces (in `mix`). The time row is there only where the time cursors are: in `horizontal` the pair is parked, and a row of times nothing on the plot can move would be a readout of nothing. Columns size to their widest cell, so a column of numbers is a column; the two cursors are `A` and `B` and their difference is `delta`, whichever of the three the mode puts in them. Every readout is a **measurement**, not a raw number: at most two decimals, scaled by an SI prefix so the digits sit in `1..999`, and the unit the stream declared appended — `10.52 mV`, `-9.8 V`, `dT -398.67 ms`, `1.06 pV`. Two decimals is the resolution the instruments can claim (a marker lands on a sample, and the sample is an `f32`), and a trailing zero is dropped, so `1.50 V` reads `1.5 V`. The prefix is applied **whether or not the stream declared a unit**, and that is not a detail: a signal whose CONFIG declares none is ordinary, a *math* channel never has one, and two decimals of an unprefixed small value is `0.00` — the reading was simply gone for an entire class of channels, which went unnoticed because the mock declares `V` for everything (it now takes `--unit ''`, which is the only way a probe can see this). With no unit the prefix is written *tight* against the number — `585.24m`, not `585.24 m` — because the space is there to separate the number from its unit, and a spaced `m` with nothing after it reads as metres. Below `1e-12`, where no prefix can help, the value falls back to three significant digits in whatever form fits rather than to `0.00`. A row's rulers use its signals' unit when they agree on one and print bare digits when they do not: a ruler is on the row's y axis, and two units on one axis make "the" unit a guess. A trace's line carries the signal's **colour chip and its name** — `● sine 616.49 mV -24.57 mV -641.06 mV` — and the values are bare, because the pane is a readout beside a plot and `Y1:` labels there are noise. The chip began as the *whole* identifier, on the argument that it names a trace exactly as the signal panel does; it does not, once there are several traces, because matching a colour against the plot by eye is a guess, and a line with nothing but a chip is also unreadable in a bug report. The name is appended to the chip's markup (escaped — a signal name is the stream's text, not ours) and the cell is ellipsized with the full name on a tooltip, so a long name cannot widen the pane. **`mix` draws a line per *bound* trace, not per named slot.** A slot holds a signal **name** and the channel behind it is re-resolved every frame, so a name with no channel is not a trace — and some names can never get one: the stream's time signal is never acquired, so a slot holding it has a name for ever and data never. The plot drew nothing for such a slot, which is how it stayed invisible, but the pane counted names and gave each one a row that could only read `--`: a user with three traces saw six lines, three of them blank. They were also **sticky**, because a session file is written from the slot names verbatim, so one that got in was saved again every session. Both halves are fixed — the pane follows the bind (which is also why its rebuild watches a bit per slot rather than only the model's generation: a trace can appear with no structural change behind it), and loading a layout now refuses a name that cannot produce a channel, which heals an existing file on its next save. Where those names originally came from is unexplained: dropping a signal that has no channel is already refused, so the likely source is an older build or a datasource whose time signal was not yet recognised as one. `mix` also *draws* the crossings: a mark on each trace where each marker meets it, in the marker's own colour, placed with the same call the pane reads (so the mark and the number cannot disagree), and shown only where the trace actually has samples at that time. The marks are annotations the plot holds from construction and that are moved, never added: `Plot` appends annotations only, so a host that re-added them would grow the plot every frame — which is why the library gained `Plot::set_annotation()` for this. Nothing marks the ruler modes: a ruler *is* the mark. **The time window is typed, not picked off a ladder.** The header bar's `window` button reads the current span and opens onto `÷10` · a number · a unit (µs, ms, s, min) · `×10`. A ladder of round numbers could only ever offer the spans someone had thought of, and the useful range runs from microseconds — a fast datasource's few hundred samples — to minutes; the two steppers cover "an order of magnitude wider" without typing at all. The request is clamped to `--max-window` and nothing else — a window too long to store at the wire rate is resampled rather than refused (next section) — and the control then restates the window that was *taken*, so a clamped request reads back honestly. Changing the unit **re-interprets** the number rather than converting it: `2` switched from ms to s means 2 s. Converting would make the selector a no-op, since the span it names would not change. The button's label is a reading to three decimals in whichever unit keeps the number above 1, so a 500 s window reads `8.333 min` while the window itself stays exactly 500 s — the label is deliberately lossy and width-locked, because it sits in the header bar directly above the plot and a label that grew with its text would relayout everything under it on a mere change of window. `Fit`, which frames whatever the ring holds and so rarely lands on a round number, restates the control without going back through the clamp. **A window longer than the buffer is resampled, not refused.** `window × rate × sample size` can exceed the byte budget long before `--max-window` does, and the control used to be capped to whatever one buffer held — so on a fast datasource most of the typed range was unreachable. Instead, `plan_partition()` now picks a power-of-two `time_stride` for the shared clock: the growth loop first buys as long a ring as the budget allows, and only then is the clock decimated by the smallest factor that makes the requested span fit. Deciding it earlier would over-decimate by whatever the growth loop was about to add. What makes that safe is that the decimation is a **low-pass filter, not a stride**. `Acquisition::on_data()` keeps one running sum per channel (and one for the clock) and stores the *average* of each group of `time_stride` base samples; the stamp stored is the group's mean, which is the boxcar's own group-delay centre. A boxcar of length *M* has its first null exactly at the new stored rate and is about −3 dB at 0.886× the new Nyquist, so content that would fold back is attenuated instead of reappearing as a false low frequency. It is O(1) per sample and its only state is the accumulator, which is cleared whenever a re-plan changes the group length — a group cannot be finished at the wrong length. The trigger therefore sees the stored, filtered signal, which is the same signal the user is looking at. This reverses the earlier "the app does no decimation at all" decision, and keeps its reason: a naive stride is what made a 2 kHz sine on a 1 MHz clock plot as a full-amplitude 125 Hz sine — a plausible-looking waveform that was never in the data. The headless check is the same aliasing case with the filter in place: at `--buffer-mb 1 --freq 2000` with a 60 s window the clock decimates by 16 to a 625 S/s stored rate (312.5 Hz corner) and the channel dump reads `y=[-0.059, 0.079]`, roughly the boxcar's predicted 6%. A `y=[-1, 1]` there would mean the filter had been lost. Changing the decimation factor is a **structural change**, like a CONFIG or an enable: the rings are re-planned and history is dropped. The status line says so (`window 1 min: resampled to 625 S/s, history cleared`) and the same transition is logged with both rates and the new anti-alias corner. A window change that leaves `time_stride` alone rebuilds nothing. Per-channel `stride` is still always 1 (see the limitations below); only the shared clock is decimated. **Fit to data** (overflow menu, the `f` key, or **Fit all** in a plot's right-click menu) auto-scales every row's y axis to the data it holds and puts the x view back on the whole time window, propagated to every row. **Fit X axis** is that x half alone, for the common case where a zoom should be undone but the levels were placed by hand. The status line acknowledges either (`x back to the 2 s window`). **Nothing you do on the plot changes the time window** — only the window control does. A fit is the *undo of a zoom*: a 2 s window zoomed into reads 2 s again. That matters because the window is not just the axis. It is also the trigger's event span, how the samples are stored (a window long enough to move the decimation re-plans the rings and **drops the history**), the control's readout and what the session file records — so a wheel notch or a Fit that redefined it would quietly throw the buffer away. A zoom and a pan are view-only by construction; the fits read the window and never write it. Which 2 s depends on the state, and the fit restores the whole of whatever the view is allowed to show: live, the newest window at the live edge; and for a held event or a paused view, the pin's full range — the snapshot's own span, which is legitimately *narrower* than the requested window when the ring had not filled it. A fit of a frozen view keeps it frozen rather than throwing it back onto the stream. **Shared x axis.** The rows are the rows of **one `plot::Figure`**, drawn by one `plot::gtk4::PlotArea` in figure mode, and they all join a single x link group. A gesture changes the row it landed on; `Figure::layout()` adopts that change into the group and pushes it to the others, so panning or zooming any row moves them all with nothing to propagate by hand. The live follow is the *group's* (`set_x_link_follow`), not each row's — `Figure::link_x()` refuses a member whose own axis still follows, because a following axis would reinterpret the pushed range as its own span and offset behind its own edge, which is per-row drift by construction. The figure also owns both gutters and the crosshair, so a vertical feature lands in the same pixel column in every row. The self-check's layout line carries `x-desync N`, the number of frames in which any row's x range differed from row 0's by more than a pixel of data. **It must read 0.** `Figure`'s row count is fixed at construction, so there is one structural path: a changed row count, or a changed legend name (the one thing a `Plot` cannot express), builds a new figure and the area is handed it. That costs no GL state — the widget keeps its context. A displayed event survives the rebuild because the pinned `[-pre, +post]` window is restated rather than re-derived from the live edge. **Rows are edited in place, not chosen from a count.** The header bar used to carry a `plots` dropdown of 1 to 4, and a count is the wrong handle: it says how many rows you want and leaves which row your traces land on to the renumbering. **Layout editor** (overflow menu) instead puts the controls on the rows — a `×` at the top-left of each row removes it, a `+` on each boundary inserts one there — so the row an action names is the row you are pointing at. `--plots` is now only the count to start with, and `RowSet::kMaxRows` (8) is a ceiling rather than a menu: past about that a row is chrome and no plot. The editor also carries its own way out: a **`Done`** button in the plot area's top-right corner while it is up, because the toggle that opened it lives in the overflow menu — a long way from the plot being edited. It turns that toggle off, so there is still exactly one piece of state, and the buttons (its own included) go with it. An insert or a remove **moves the rows' contents with them**: what row 1 plotted, in the colours it plotted it in and with its cursors where they were, is on row 2 after inserting above row 1. Only that state moves; the figure's `Plot`s are rebuilt and re-dealt, which is the mechanism a row-count change already used. There is **one `+` per boundary, not two per row**. The request was for a `+` at the top centre and another at the bottom centre of every row, but the rows tile the figure, so a row's bottom edge and the next row's top edge are the same line: "add below row 1" and "add above row 2" are the same insertion, and two buttons there would sit one on top of the other. The `rows + 1` boundary buttons — above the first row and below the last included — reach every position the two-per-row reading offers. The buttons are `GtkOverlay` children placed by their margins over the figure's own row rects (`FigureLayout::rows`, divided by the area's device scale), deliberately **not** one transparent widget covering the plot: an overlay child is picked wherever its allocation is, and a full-area child would swallow every pan, drop and hover the editor has no interest in. Reading the figure's rects rather than dividing the widget height by the row count is what keeps a `×` on the row it names if the rows ever stop being an equal division. **Export visible range…** (overflow menu) writes every signal bound to a visible row, over the shared x window, at the **stored** sample rate — never the LOD pyramid's buckets, which are the envelope the renderer drew rather than the acquisition. A signal on several rows is one column; the columns come out in channel order, so the file reads in the same order as the signal panel. The table has **two time columns**, which is the whole answer to "relative or absolute": - **`time`** is the axis exactly as it was displayed — negative ages ending at 0 live, offsets from the edge in trigger mode — so a row's `time` is the x the user read a value at. - **`abs_seconds`** is the stream's own clock for the same sample: `epoch() + stream_at_x0() + time[i]`, one expression correct in all four display modes (see *The x axis is relative to now*). Two exports taken in different modes therefore line up on this column, and `abs - time` is a constant per file naming where the display's origin sat in the stream. Both are `f64` seconds and not the wire's integer, because the wire's integer never exists in this process: `udps_frame_t::values` is a `const double*`, so the time signal has already been widened by the client before udpscope sees it. One caveat follows from that and from `time` being a *derived* coordinate: `abs_seconds` is a sum of two numbers of very different magnitudes, so consecutive differences wobble by a few ULP of the stream's uptime (femtoseconds at any plausible uptime — note that the `f32` defect this used to guard against was *microseconds*, four orders of magnitude up). Two formats, picked by the extension the save dialog ends up with: | file | what it is | |---|---| | `.npy` | one NumPy structured array, `time`/`abs_seconds` as ` buffer` rule was first implemented as `stride = round(rate_fastest / rate_channel)`, which for a 2 kHz sine on a 1 MHz clock is exactly one sample per period: every kept sample sat at the same phase and the channel plotted as a flat line. Channels are stored at the shared clock's rate and the LOD min/max pyramid preserves the extremes at draw time. The budget rule is honoured by resampling the *shared clock* (`Partition::time_stride`, the status line's `time xN`) with an anti-alias average in front of it — see "A window longer than the buffer is resampled" above. The filter is the difference: it is a stride that aliases, not a rate reduction. - **Pre-trigger % is a share of the *event* window**, not of the retained history — see the trigger section above for why (the spec's sentence predates the event window being a separate control). - **A capture is drawn as it fills**, against SPECS:37's "should not be refreshed till next event", which the app used to honour literally. Requested explicitly: a capture whose post window takes any appreciable time showed the previous event and no sign of progress. What the spec was protecting still holds — a *held* event is bound to its snapshot alone, and the axis is the event's own window from the edge onwards rather than a sliding live view. - **A plot holds at most four signals** (`RowSet::kSlotsPerRow`), because a `Plot` cannot give up a trace and the slots are therefore created up front. - **One toggle drives both cursor orientations.** The vertical markers and the horizontal ruler exist separately but share one show/hide and one reset. - **The time reference is the first *enabled* channel's signal.** A CONFIG whose time signal belongs to a signal that is disabled contributes no stream time, and the app falls back to arrival-based timing (with a warning on stderr). - **The choice of time source is sticky, and a change of source empties the rings.** It is decided once (and re-decided on a CONFIG) and then kept: choosing per frame let a stream whose declared time signal fails one check and passes the next alternate between two timelines, so one ring held stamps from both and the x axis, the crosshair and an export read nonsense — a plateau of one timestamp, then a jump to another epoch. On the switch the recorded history is dropped rather than left half on each clock. The stamps themselves are spaced by the stream's *measured* arrival cadence whenever the declared rate disagrees with it, because a CONFIG's rate is a claim the stream may not keep (one that declared 1 MS/s and delivered a hundred samples every fifth of a second made every frame span microseconds while the frames were 100 ms apart). **Every launch is its own instance.** The GTK application is created `G_APPLICATION_NON_UNIQUE`: a scope is the kind of program a user runs twice side by side — one instance per source, or two views of the same one — and without the flag `GApplication`'s D-Bus single-instance rule turned every later launch into an activation on the first, so the new process exited at once and only one instance ever lived. **One source, one client, over unicast.** A UDPStreamer over unicast keeps a single session: a second `udpscope`'s CONNECT displaces the first, the displaced client sees its `silence_timeout_s` elapse, reconnects and displaces the other, and the two take turns being served — which reads as "the second UI gets no data" (and both windows look alike because they share the saved layout). Nothing on the client side can give both of them the stream: the wire has one destination. Watching one source from several UIs is what `--multicast [--interface ]` is for — every instance joins the group and the kernel hands each of them a copy. When the app sees the fight (a reconnect with no frames delivered in the same second — the client's own retry counter is also shown in the status line as `N reconnects` once it is nonzero) it says so in the status line rather than leaving an empty plot to be puzzled over, and prints a `starved:` line to stderr while the state holds. That notice is **split on whether a CONFIG ever arrived**, because the two states are different diagnoses and only one of them is about another reader. With a signal list in hand, a stream that stops is the fight above: something took the session away, and the notice says so (`starved:` on stderr). With **no CONFIG at all** nothing was ever taken — there is no signal list, so nothing can be enabled and no DATA frame is owed — and the app says `trying to connect` instead (`connecting:` on stderr), with the glance line reading `connecting` and the badge yellow from the first frame rather than after the three-second stall test. Calling that one "connected, but no data is arriving" blamed a second reader that does not exist; the usual cause is a streamer that is not running, or one serving a different port. ## Limitations - **Multi-element (vector/matrix) signals are acquired with a zero-order hold.** `udps_client` decodes exactly one *sample* of `num_rows x num_cols` values per DATA packet for such a signal, regardless of the publish mode, so it cannot be decimated on its declared rate. Each element becomes one channel sampled on the shared clock's own cadence with the packet's value held until the next packet — exactly faithful in `strict` mode, and the only reading of the wire that does not invent samples in `accumulate` mode. The info popup and the CONFIG dump on stderr report the element counts. - **The event window can be shorter than the buffer.** The trigger latches a window of the event-span selection; the ring holds far more, so with trigger mode off the rest is scrollable history. - **Calibration is applied at ingest**, so changing gain or offset rebuilds the buffers (and drops the history). Applying it at read time would need the LOD pyramid to be gain-aware. - **The horizontal rulers are per row**, since a y value means nothing without the y axis it is on; the vertical pair is shared, so the same two times are on every row. Show/hide *parks* every instrument outside the axis range rather than removing it, because `Plot` has no `remove_cursor` — which is why switching mode re-places rather than rebuilds, and why the frame tick reads the markers back: a parked cursor's snapped position is a real number, so nothing may be inferred from it. The trigger marker is separate: its own cursor, independent of the cursor toggle. - **Verified headless only.** Acquisition, partitioning, the trigger state machine and its integration with the buffers, and rendering under Xvfb (llvmpipe) were exercised; the interactive paths (drag and drop, the dialogs, the right-click menu) were not driven by an automated test. **A debug-build assertion in the library's suite.** `deps/plotter`'s debug build prints `TextBatch::add_cell`'s `glyph_is_cell` assertion once while running its tests — from a forked child of the text/atlas cases (`a_backend_without_an_atlas_uploads_nothing` is the one that precedes it), not from a failing test: the suite still reports `355 cases, 0 failed`, and this is pre-existing rather than something the cursors work introduced. The new ruler test asserts the *strokes* (`figure_rows_draw_their_own_horizontal_rulers`) with the harness's no-atlas backend, so it neither needs nor trips the text path.