Files
udpscope/README.md
T

3066 lines
225 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`](https://git.ferrarinas.it/martino/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), `--relative-time` (see below),
`--source NAME` and the repeatable `--source-opt k=v` (the source kind and its settings; see "Data sources" below),
`--capture-packets N` (see "Packet capture" below), `--renderer NAME` (GTK's renderer:
`cairo`, `gl` or `vulkan`; `cairo` is the software preset that exports both
`GSK_RENDERER` and `LIBGL_ALWAYS_SOFTWARE` before GTK initialises — see the SSH
limitation below).
**What a large `--buffer-mb` costs at startup, and what the window says while it happens.** The
buffer's rings are value-initialised, so allocating one touches every byte of `--buffer-mb` --- about
a second per gigabyte, mostly first-touch page faults --- and it happens on the producer thread
holding the lock the frame tick also takes. It is therefore a real freeze and not a slow paint. Two
things follow. First, a rebuild whose plan describes the storage already allocated **clears** the
store instead of allocating a new one, which is what most structural changes are: startup used to ask
for fifteen allocations and now asks for two or three (3.36 s → 1.18 s at `--buffer-mb 1000`), and
every enable of a signal is applied in one batch rather than re-planning per signal. Second, the
window is **presented before** the buffer is planned and carries a notice naming what it is waiting
for --- `connecting to 127.0.0.1:8000 — planning the 4000 MB buffer` --- because the remaining second
or so at a multi-gigabyte buffer is unavoidable, and a blank window that says nothing is the part of
it that reads as a hang. Each allocation also names itself on stderr
(`[udpscope] buffer allocated: 4 channels, 839 s span, 74 ms`), so a run that prints a column of them
is a re-plan that should have been coalesced.
**And a loading window covers the gap the status line cannot.** That notice only exists once there is
a window to put it in, and a run started from the **source chooser** has none: the chooser destroys
its own window the moment a row is clicked, and what follows --- the first allocation, the wait for
the stream's CONFIG or the read of a recording, and then the whole UI being built --- runs with no
main loop at all. The screen was simply empty for it, which at a multi-gigabyte buffer is the ten
seconds that read as "the dialog disappeared and nothing happened". A small window now stands in that
gap, raised before the buffer is planned and taken down only once the real window has been presented.
It names the wait in words --- `Connecting to 127.0.0.1:8000` over `planning the 4000 MB sample
buffer`, or `Opening rt.npy` over `reading the recording` --- because nothing can animate while the
work runs: its spinner is frozen for the duration, a progress bar would have nothing honest to show,
and it carries no close button for an application that could not yet service one. A source named with
`--server` or a file named with `--open` never sees it: those runs never had a window to lose, and the
notice after the present is the right place for them.
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=<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 the About and shortcut dialogs 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 / .mf3 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 data source (the UDPStreamer client is the built-in
one) and calls `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.
## Data sources
What udpscope shows is fed by a *source kind*; the connection chooser's **Source type** row picks one and
swaps in that kind's own connection form. The built-in kind is **UDPS**, the UDPStreamer client this
document describes everywhere else, and it is the default: `--server`, `--port`, `--multicast` and
`--interface` populate its entries, and the same thing is spelled `--source udps --source-opt server=...`.
`--help` lists the registered kinds. A second kind, `probe` (a synthetic sine with one panel), is registered
only when `UDPSCOPE_PROBE_SOURCE_KINDS` is set, and exists only for the self-check.
The second built-in kind is the **MCC E-1608** Ethernet DAQ (8 single-ended or 4 differential 16-bit inputs, two
analog outputs, eight digital lines and a 32-bit counter). Its connection form takes an address, or a MAC address
to look up (optionally with the subnet to sweep). A **Scan network** row at the top of the form probes every
network interface of the host for boards (about a second and a half), lists each one by address and MAC, and a
click on a result fills the address, or **Use MAC** fills the MAC and leaves the address empty. Results are not
saved: only the filled field is. A MAC with no subnet is looked up by a broadcast sent on every up interface: a
single limited broadcast leaves through the default route only, and on 2026-11-02 it missed a real board on
`net0` while the default route was `wlan0`. The form also groups the acquisition settings (scan rate, external clock,
trigger, restart after an overrun, factory calibration, the digital poll rate, the generator update rate), a range per analog input, and the
direction and initial value of each digital line. The analog inputs are the plotted channels; each digital input
and the counter is an extra channel only if you ask for it. Two side-stack tabs belong to it: **Analog out**
(per DAC: a static level, or a sine, square, triangle or ramp from 0.1 to 10 Hz) and **Digital I/O** (a live LED per
input bit, a switch per output bit, the counter with a reset); they are insensitive while no board is connected.
The waveform generator writes the DACs at the **generator update rate** (`gen_rate`, 1 to 200 Hz, default 50), which
is separate from the input poll rate (`poll_rate`, which only reads the digital inputs and the counter). Each update
is a synchronous command to the board, so a board that answers slowly achieves less than was asked: the **Analog out**
tab ends with an **Update rate** row that shows the rate the generator actually achieved (measured over about a
second) next to the requested one, or `off` when no generator runs. The waveform frequency itself is still limited
to 0.1 to 10 Hz.
Acquisition is hardware-paced only (the scan rate or an external clock), the channels times the rate must stay at
or below 250 kS/s, and the form refuses more. With the command-line options, a run that names no `aiN` key gets
`ai0=BIP10V`; naming any `aiN` key drops that default, so `ai1=BIP5V` alone is channel 1 alone.
**This kind has been run against a real board once, on 2026-11-02, with one channel.** The board was at
169.254.88.75 on `net0`, `ai0=BIP10V` single-ended with the input grounded, `scan_rate=1000`, for 7 s: the connection
and handshake succeeded, the source configured one channel and streamed it at the declared 1000 S/s (about 1250
samples in the first 1.25 s, no gaps, no overflows, no `[e1608]` errors), and the readings sat within a few LSB of
0 V (about 0.9 to 1.8 mV on a +-10 V range). Nothing else has been run on hardware; everything else is verified only
against `tools/mock_e1608.py`, a protocol mock written from two independent implementations of the wire protocol.
The following rest on a single reference or the two disagree, and are the first thing to check with more of the board
exercised (the mock's header lists the same items):
the counter reset command (0x31, from one reference only; not run on the board); the pacer value (the source sends the *aggregate*
rate, `round(80e6 / (scan_rate * channels) - 1)`, as one reference does; the other sends the per-channel
frequency; the two differ only with two or more channels, so the one-channel run cannot settle it); the scan-start trigger option bits (none 0, rising 4, falling 8, high 12, low 16, one reference only);
whether the handshake may answer status 2 ("code ignored"; one reference accepts it, the other refuses it, and the
source accepts it); the first frame id; and the maximum frame size. Two further things
are worth a look on a board even though the mock's header does not flag them: the calibration memory layout and
its coefficient type, and the status word's bits, in particular whether the "scan running" bit stays set while the
board waits for a trigger (with auto-restart on, the source restarts a scan that reads as stopped only when no
trigger is configured, so as not to reset the wait for the edge; this is unverified on hardware). The DIFF input
numbering (channel + 8) and the filler byte after scan start are agreed by both references. The scan overrun count
is approximate: the board does not say how many scans it dropped, so the frame after a restart reports one lost.
Errors (no device, a rejected connection code, a timeout, the scan socket closing) are written to stderr as
`[e1608] ...` once per distinct message (an overrun is logged each time it happens); the window shows only that
the source is disconnected (the status popover reads `waiting`, and the panels go insensitive), not why.
The third built-in kind is **Flat UDP**, a receiver for the MARTe2 `UDPSender` datasource: one datagram per
cycle, the signals concatenated in declaration order with no header, no padding and no timestamp, in the
sender's byte order. The datagram describes nothing, so **the signal list in the connection form is the
whole contract and is your responsibility**: one row per signal (a name, a type from `uint8` to `float64`,
and an element count), in the sender's order, including the `Trigger` byte an IndependentThread `UDPSender`
puts first. A row with a count above 1 is a vector, and a "Vector as" drop-down on that row says how to
show it: as **Channels** (the default), where `Wave:float32[16]` becomes 16 scalar channels `Wave[0]` ..
`Wave[15]` of one sample per cycle each, or as a **Waveform**, one channel that receives a block of 16
consecutive samples per cycle (its declared rate is the cycle rate times the count). The waveform is written
`Wave:float32[16]w` in the `signals` text, and the same text is what a session file and `--source-opt
signals=` carry. A list with a waveform row may hold **only waveform rows, all of the same count** (a
scalar or a channels row beside one, or two lengths, would need zero padding or held values, so the form
refuses it and says so), and a list may make **at most 256 channels** (a scalar is one, a channels vector is
`count`, a waveform is one). The form's subtitle shows the channel total and the size the list implies
("6 channels, 13 bytes per datagram"), or the first rule the list breaks; a datagram of any other size is
dropped, counted as stale, and reported once per distinct size on stderr as `[flatudp] ...`. The form also
takes the port (44488, the sender's own default), an optional bind address, an optional multicast group with
the interface to join it on, the byte order, and the sender's **cycle rate in Hz, which is required**: it
sizes the ring and the ingest decimation and spaces the samples within a frame. Time is stamped by the
existing pipeline from arrival and that declared rate (and `--relative-time` gives a counted clock); there is
no timestamp in the stream to prefer. **Loss is never reported** -- with no counter in the datagram, a missed
cycle is indistinguishable from a slow one -- and neither is a changed sender: a list that does not match
reads as "everything dropped". Source-address filtering, detecting the list from the traffic and importing a
MARTe2 `.cfg` are not provided. A list with no waveform row is delivered in frames of about 10 ms
(`round(rate / 100)` cycles, at most 1024; a partial frame is flushed after 100 ms); a list of waveforms
delivers one frame per datagram. The source is connected on the first valid datagram and disconnected after
2 s without one. It is verified against `tools/mock_flatudp.py`, not against a MARTe2 application. Multicast
was verified on a loopback group (`239.255.42.99` joined on `127.0.0.1`, although this machine's `lo` does not
carry the `MULTICAST` flag); a real network, its switches and IGMP were not exercised.
A session file written before kinds existed has no `source_kind`, and is read as UDPS; a UDPS file is
written exactly as before. A kind's capability panels (for instance a source's own controls) appear as
pages of the side stack, and are insensitive rather than hidden while no source is connected.
Planned, as its own sub-project: ABScope. It has more than one
timebase, so it will change the frame and the data path together rather than fit the present single-timebase
frame.
## 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.
**An axis with nothing bound to it is still `[-window, 0]`.** "No samples yet" is the state every
structural change leaves behind — opening an event or a waveform re-plans the rings, which drops
their history, and the axis is then framed before the first sample of the new view lands. Framing it
at `[0, window]` there (which is what it did) offsets the view by a whole window under the x link
group's follow rule, so the two or three frames after every open drew the time base one screen to the
left of where the following frames drew it: a trace that jumps sideways and comes back, on every open.
The empty axis is now drawn in the same place as the full one.
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] <type> <value> [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 **flat** level: the densest band of the window on that side of the split | split % (which side) | a ruler at the level |
| `max` / `min` | the extremes | — | a ruler at the value **and** a marker where it happened |
| `peak to peak` | the swing, `max - min` — the extremes, ripple and overshoot included | — | a ruler at each extreme |
| `amplitude` | the two flat levels' delta, `top - bottom` | — | a ruler at each flat level |
| `above` / `below` | the mean of the samples **above** / **below** a share — a mean *over a side*, not a level (see below) | split % | a ruler at the mean |
| `between` | the mean of the samples **between** two shares | from %, to % | a ruler at the mean |
| `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 |
**`top`/`bottom`/`amplitude` read a *flat* level, and that is a different question from the
extremes.** A band-limited edge does not step, it overshoots and rings: the mock's `square` at 30
harmonics reaches **±1.179** where its plateau is ±1, so anything that reads the window's extremes
reports the ringing — `amplitude` used to read `1.179350` there and `top` `0.986496`, both of them
a property of the edges rather than of the waveform. A flat level is a *density* question instead:
the plateau holds most of the samples and the ringing a few percent. Pass one folds a 64-bin
histogram of the whole buckets' midpoints while it has them in hand, and the level is, either side
of the split, the densest bin that still holds half the densest one's buckets — **the outermost of
those**, which is what makes a waveform with no plateau at all (a sine, a ramp) read its *peak*
rather than an arbitrary mid-range bin — with the exact mean of the samples of the buckets within
**5 % of the window's peak-to-peak** of it. Five per cent is deliberately under the ~9 %-of-swing
Gibbs overshoot, so the ringing falls outside the band and a plateau's own ripple stays inside.
`amplitude` is then that pair's delta, which is a bench scope's `Vamplitude` (`Vtop - Vbase`) and
**not** the half-swing it used to be. Measured on the mock, harmonics `0` → `30`: `top`
`1.000000` → `0.999983`, `bottom` `-1.000000` → `-0.999983`, `amplitude` `2.000000` → `1.999966`,
while `peak to peak` goes `2.000000` → `2.358701`. On the probe's 1 Hz ±1 sine `top` reads
`0.957988` — it used to be `0.636620`, the half-wave mean, and a sine's flat level is near its
peak instead — with `sd 0.0014` across recomputes, and `amplitude` `1.915983`; `peak to peak` is
still exactly `2`. The histogram is the *same* fetched bucket list read a second time and never a
sample, so `measure cost:`'s `raw` does not move; a pane with none of the three types does not pay
for it at all. Where the pyramid cannot summarize the window — an `hf`-filtered walk, or one
shorter than a bucket — there is no density estimate, and the types fall back to the old
behaviour: the mean of each side of the split, and `max - min` for `amplitude`.
**`above`, `below` and `between` are those side means, exposed.** The flat-level change kept the
split's own two means as its *fallback* for a window the pyramid cannot summarize; these three
types are that fallback, named, so the two different questions about one split can be asked side by
side. On a unit sine `above 50` reads `0.636620` — 2/π, exactly what `top` reported before it became
a level — where `top` reads `0.957988`; on the mock's band-limited square `above 50` reads
`0.986496`, where the plateau is 1 and `top` is `0.999983`, because the mean *over a side* carries
the edges' ring and the level the signal dwells at does not. `between` is new arithmetic in one
sense only: it is the samples above the lower edge **and not** above the upper one, so it is a
*difference of two levels* — no wider split can answer a band, because a band's two edges are two
thresholds — and it rides the same bucket walk (a bucket wholly inside the band folds into both
accumulators whole), which is why `measure cost:`'s `raw` is unchanged by it. The readings, on the
fast mock at a 2 s window, `[bandavg-probe]`: sine `above 50` `0.636620`, `below 50` `-0.636620`,
`between 50 75` `0.255873` (3(2−√3)/π — neither the window's mean nor the band's edge); rippled
square `above 50` `0.986496` and `between 50 75` `0.302744` (the two edges' transitions);
`ramp` `between 50 75` `0.250000` and `above 50` `0.500000`, because a uniform density makes every
band's mean that band's **own centre**; ideal square (`--square-harmonics 0`) `above 50` `1.000000`
and `between 50 75` `--`, because every sample is exactly ±1 and the band holds none.
Three things to know before reading one of those numbers:
- **The pair is ordered on read, not refused.** "between 80 % and 20 %" is the band between 20 % and
80 %: the two shares name two *edges* and the band does not depend on which was typed first, so a
reversed pair is normalized rather than turned into a silent `--`.
- **Nothing to measure reads `--`**, like every other type: a share at `100 %` has no sample above
it (the level is the window's own maximum) and a band whose interior holds none — the ideal
square above — is `invalid`/`--`, never `0`.
- **The sixth decimal follows the window's boundary.** The live window is closed at both ends, so
its oldest and newest sample share a phase, and one extra sample out of ~2.6 M moves the mean by
about one unit in the sixth decimal: over one run `above 50` on the sine printed `0.636620` on
most `[measure-trace]` lines and `0.636621` on a few, `above 50` on the square `0.986496` on most
and `0.986495` on the rest. The old `top`/`bottom` spread exactly the same way — it is a property
of a mean over a finite window, not of these types.
`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 leaves the hysteresis band below no room
to reject noise with, 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. For
`top`/`bottom` it says **which side** the level is read from, so any split between the two
plateaus reads the same number — it is no longer a mean *over* the side, which is the reading a
ringing edge used to drag — and for `above`/`below` it is the level that mean is taken about.
Two more options ride on it, both at their old behaviour by default:
| option | what it is | unit | off |
|---|---|---|---|
| `noise` | the hysteresis band's half-width, as the next section describes | % of the window's peak-to-peak | `0` disables the band; the default `10` is the band the app has always counted with |
| `hf` | a one-pole low-pass over the window, before *any* statistic | Hz corner | `0` is off |
The band is clamped to half the room between the split and the nearer extreme, so a 50 % band cannot invert
the thresholds. `hf` is the **one** place where a readout does not quote a stored sample: with `hf > 0` the
extremes, the mean and the levels come from a filtered copy of the window, so a ruler can sit at a level the
drawn trace -- which is raw -- never reaches. That is what filtering *is*; it must not be "fixed" by snapping
the value back to the nearest sample, and with `hf = 0` the old rule holds exactly.
Both cost: a filtered window disables the two bucket fast paths, because a bucket is an aggregate of *raw*
samples and cannot be mixed into a filtered walk. A filtered window is still walked once and shared by every
measurement that asks for it, so a pane of five measurements at two corners costs two walks, not five.
### Why a crossing is counted with hysteresis
A crossing is not read off a single level. It is read off a **band**: two thresholds a tenth of
the window's peak-to-peak either side of the split, and an edge is counted only once the signal
has passed the far one. Inside the band a sample decides nothing. That is a Schmitt trigger, and
it is the difference between counting the waveform's cycles and counting its noise.
Counting on one level was the first implementation and it fails on anything real, badly and
silently. The mid level is exactly where a waveform moves *fastest* — on a sine the slope there
is 2πf and the signal only clears a disturbance of amplitude *a* after a/2πf seconds — so any
interferer faster than the fundamental recrosses the level several times per true crossing, and
each recrossing is counted. Measured on a 5 Hz sine with a 500 Hz tone at a tenth of the
amplitude (`tools/mock_udpstreamer.py --noise 0.1`): 9 true crossings read as **79**, giving
`period=0.025622` and `frequency=39.029` for a true 0.2 s and 5 Hz. The number is not merely
noisy, it is a different order. With the band the same window reads `period=0.200188`. The band
holds to an interferer at **0.3** — a third of the waveform — where it reads `period=0.200688`,
and to white noise at σ=0.05; at 5 % of peak-to-peak it already fails at 0.2.
Three things about it are deliberate, and each was wrong first.
- **The band moves the edge decision only.** The side the `sum`/`count` split uses stays the bare
level, and nothing that band touches is the flat level: a band there would change what a crossing
counts as an edge for no gain to anything, and the split's two means are now only the fallback
`top`/`bottom` take on a window with no bucket histogram.
- **The band is clamped to stay inside the data**, to half the room between the level and the
nearer extreme. `pulse duration` lets the user put the split near an extreme (a 3.3 V logic
signal with ringing wants it nearer the top), and a fixed tenth of the span there puts the upper
threshold *above every sample in the window*: the walk never decides the upper side and the
measurement reads nothing at all, which is strictly worse than reading something noisy. Measured
on such a signal at a 90 % split: `enters=0` with a fixed band, `enters=4 period=0.200000` with
the clamp.
- **The LOD fast path tests the band and the level separately.** Tightening the bucket-fold
condition to `level ± h` would refuse every bucket lying inside the band — and those are
precisely the buckets a crossing passes through, so the raw walk would get a *share of the
window* instead of a bucket or two per crossing. A bucket on one side of the **level** can always
be binned; only a side *change* whose position is inside the bucket is refused. The clean fast
mock still reads `raw=119412 (0.1%)` of a 215 M-sample total at 1.41 ms a recompute; with the
0.1 interferer it is `raw=2.0%` at 1.96 ms, which is the noise's own cost and not the band's.
A sample exactly *at* a threshold is still on neither side, so a signal that merely touches one
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 colour every other view names that signal with —
through `RowSet::signal_color()`, so a signal the user re-coloured is re-coloured here too and
not left at the palette entry. 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`, the second share ("to % of the
range") only for `between`, which is the one type whose two edges are both parameters, and the
words on the split row name its side ("above % of the range" for `above`) — and the "show on plot"
line says what would be drawn.
The values are **window statistics over the window the pane is about, at the stored rate**: the
same cut the export makes, so a measurement describes data rather than the ring behind it — and
the marks it draws belong to that window.
**Which** window, when the view is pinned, is the frame and not the viewport. A held event or a
paused stream is a *still frame*: the data behind it cannot change, so a pan or a zoom there is
only looking closer, and the pane goes on describing the **pin's own extent**
(`RowSet::pinned_range()` — the same statement the framing itself is clamped into, so "what this
view is a view of" has one expression). Zoom into a latched event and the numbers do not move:
that is what a still picture means, and it is the reported *"when measuring a still frame, moving
the zoom makes measurements such as average or top change"*. A live view is the other case and is
unchanged: there the window genuinely moves and the statistic follows it.
They are recomputed when that **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.
"The window moved" is asked in **every** mode, but it is asked about the window the pane
*describes*. Suppressing it while a view is held left the pane describing the whole captured
event while you zoomed into a slice of it; dropping the exemption made the opposite mistake — the
numbers followed the zoom, so a still frame's `average` moved whenever the wheel did. On a pinned
view neither can happen: the pin is what the pane is about, so a gesture moves no number and
spends no recompute, while a fresh latch, a re-stated pin or a new snapshot still recomputes it.
The one path that is *not* the pin is the window control, which unpins as it re-frames
(`apply_time_window()`), so for that frame the view really is live-framed and measuring the
viewport is right. `UDPSCOPE_PROBE_MEASUREPIN_MS` is the guard: a gesture on a held event and on a
pause must leave every value bit-identical (`same=4/4`, `pass_delta=0`), and on a live view the
results must still narrow with the axis (`follows=1`).
**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 crossing types — `pulse duration`, `period`, `frequency` — the band averages,
and, as a fallback
for a window with no histogram, `top`/`bottom`)
folds a bucket's sums whenever it lies *strictly* on one side of its level — then every sample
in it falls in the same bin and none is *at* the level, so the fold is exact. It then asks
separately whether the bucket *enters* a side of the hysteresis band: one wholly past a threshold
holds that crossing at its first sample, exactly, which is where the raw walk would have found
it. Only a bucket that straddles the level, or one that enters a side somewhere inside itself, is
opened — on a clean waveform a bucket or two 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 opened buckets. Nothing is decimated or strided: `peak to peak` still reads
exactly `2` on a unit sine, and the shared pass that carries it is untouched — `top` has no
*exact* reading to protect any more, because it is a flat level read from a density rather than
the half-wave mean it used to be (that mean is now `above 50`, which reads `0.636620` on a unit
sine where `top` reads `0.957988`, near the peak, because a sine has no plateau). A window shorter
than a bucket, or a signal
noisy enough to cross a threshold everywhere, degrades back towards 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 — and `raw` is unchanged by the flat
levels, which read the *same* bucket list a second time and never a sample, and by the band
averages for the same reason (`between` is a **second level in the same walk**: with the
measurements probe adding one of every type at a 2 s window, `raw` went from `113966` of
`240719031` covered — `0.0%` — with ten types to `134978` of `255372033` — `0.1%` — with
thirteen).
### 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<double>` 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.
### What the ingest filter and a still frame cost
Two costs that are paid once per *wire sample* and once per *tick* respectively, and so are the
largest two the application has that are not proportional to anything the user asked for.
**The ingest filter's group test.** Decimation at ingest is an average, never a stride, so the
filter reads every sample by design — and the test for "is this sample the last of its group"
was a modulo: `tick % period == period - 1`, once per channel per sample, plus once more for the
shared clock. `Partition::time_stride` is chosen at *runtime* (`plan_partition()` doubles it from
1 until the window fits), so that modulo is not a compile-time strength reduction but a real
64-bit `div` — and the code's own comment claimed the opposite. Both factors are powers of two
(every per-channel `stride[i]` is 1 by design), so the period minus one is a **mask** and the test
is `(tick & mask) == mask`. The masks are built where the accumulators are sized, keyed on the
partition, so a re-plan cannot leave them addressing the previous partition's groups; the
reserved-hole path got the same treatment, because a hole's samples go through the same
accumulators and the two phases have to agree sample for sample. The shared clock's test is now
computed once per sample and reused by the time ring, the channel loop and the math push — it was
three tests of the same thing.
**A frozen frame.** The tick queued a GL render unconditionally, sixty times a second, for the
whole life of the process. In `Held` and `Paused` that is the same picture again: the rows are
bound to an immutable snapshot on a pinned axis and the producer is feeding the live ring, not the
series on screen. On llvmpipe, or on a battery, re-drawing a frozen event at 60 Hz is the entire
cost of the application. The gate is now two parts, and the second is what makes the first safe to
keep coarse: a *signature* of what a frozen view can be re-framed by — a new snapshot, a pan or
zoom (the group's range, where both the gestures and the pin write), a row added or removed —
which renders the moment it changes, and a **floor** that renders anyway every four ticks (~15 Hz),
so anything the signature does not name is late rather than lost. A gate that can only be opened by
a list of state is a gate that goes wrong the first time the list is one item short. A change opens
the gate for three frames rather than one, because the figure lays itself out per *render* and its
left gutter is a monotone floor that takes two passes to converge. The layout editor is excluded
outright: its buttons are placed from the rects the last frame was laid out with, so there a
skipped frame is a stale *widget* and not just a stale picture. Interaction needs none of this —
`PlotArea` queues its own render from the pointer, scroll and drag handlers, so a crosshair and a
cursor drag stay immediate whatever the gate decides. `[pause-probe]`'s `skips=` field reports it,
cumulative: 0 on the live line, non-zero on the paused one.
### Statistics
**Statistics** under the measurements list adds a second line to every measurement: `n`, the
mean, the standard deviation, and the range. The observations are the pane's own **recomputes**
— one per refresh, not one per sample — so `n` is a count of *windows measured*, which is what
makes "the mean frequency over the last four hundred windows" the claim it reads as. The pane
refreshes at most ten times a second while the window scrolls and exactly once when it settles,
so a settled window contributes one observation and then stops.
The accumulator is Welford's, not a sum of squares: a measurement whose values sit at 1e9 with
a spread of 1e-3 is an ordinary case here (an absolute stream time is one), and the naive form
loses that spread entirely.
**Reset** clears every accumulator. So does editing, adding or deleting a measurement, a CONFIG
that renames the signals, and a change of the display window — each of those changes *what is
being measured*, and a mean across the change describes nothing. A pause, a resume, a trigger
latch, a pan and a zoom do **not** reset: on a live view they change which samples are in view,
which is exactly what a statistic over many windows is meant to average — and on a pinned one they
change nothing at all, values included (above), so the history has nothing to see either way. The
toggle itself does not reset either, so turning the line off to read the plot and back on again
keeps the history.
## Resequencing and relative timing
**A shaded band marks samples the stream says exist and have not arrived.** When a packet is
received that reveals a gap in the counter, a run of NaN samples is reserved for the missing
packets; the span is shaded in the plot and the counters in the status bar show it. The band
carries three states:
- **Solid** while the hole still lacks packets.
- **Faint** once the late packets arrive and repair the values — the data is now correct, but
the record remains that this region arrived out of order.
- **Absent** once that part of the stream scrolls out of the ring's buffer.
Two things are **not** repaired when a late packet arrives: **math channels** and **the trigger**.
A backfilled slot's value is corrected in the ring and in the pyramid, so the raw trace and the
readouts are right. The math engine and the trigger already ran over the placeholder, however, and
are not replayed: a math trace keeps its gap across a repaired hole, and if the trigger latched
adjacent to one, that latch was on the data the engine actually saw.
**Known limitations.** The fade is all-or-nothing at the hole level — a hole with one packet still
missing shades solidly, even if all but one slot are already repaired. And the two tints are not
yet configurable; the preferences dialog in `todo.md` is where that would land. Do not use a
command-line flag for either; none exists.
**The counter is a claim, and the wire is checked against it.** A gap is only booked when the
arrival times corroborate it: `lost` packets would have taken that many packet intervals to
arrive, and a claim of less than half that is refused, as is a hole bigger than the ring could
hold. The status bar's `gaps` counts what the *stream* says either way; the refused ones are
counted separately and said once on stderr. That matters for one kind of source: a device whose
counter counts something other than packets — samples, or frames of an inner layer — reports a
forward jump on every datagram. Believing it reserved a NaN run per packet, which on an intact
link reads as one missing-data band over the whole window with the samples washed out and nothing
lost to backfill it, while the status bar says `live`. The samples are now kept and the gap shows
as the x discontinuity it is.
**`--relative-time` makes the x axis follow the sample count and the declared rate**, rather than
the stream's own clock. The packet counter says exactly how many samples each gap spans, so a
reserved hole is a precise arithmetic range. In stream mode the same hole is estimated from the
declared cadence, which is the existing glitch-bridge mechanism reused.
**The relative clock is a time, not a position times a rate.** The buffer plan is rebuilt whenever
the channel list changes — a CONFIG, an enable, a session's math channels — and the interval that
plan declares is the **1 kHz fallback** until a *streamed* channel declares a rate. A clock
recomputed as `position × interval` therefore jumped whenever the plan moved, and a plan that
*shortened* the interval put every later stamp behind the one already written, where the frame
loop's monotonic clamp held it for the rest of the run: `ring=[a..a]` standing still while `n`
climbed, `history` never reaching the pre-window, no latch, and a blank plot in every mode. The
clock is now accumulated in seconds — a plan change is a slope change — and a frame is only
stamped into a model that has a **streamed** channel, so the fallback interval can never become a
run's origin. The stall it used to cause is named in one line, once per connection:
`[udpscope] warning: the clock stalled at ... s: N samples stamped [...] are all behind it`.
The history cost of relative mode is measured rather than computed:
| mode | retained span |
| --- | --- |
| stream time (default) | **83.886 s** |
| `--relative-time` | **41.943 s** |
Conditions: the mock at 100 kS/s, 4 channels at stride 1, the default `--buffer-mb 200`.
Relative mode retains **exactly half** the history — not the ~22% the original spec claimed, and
not the 75% a per-byte calculation suggests. The reason is the floor: relative mode keeps a second
f64 ring for absolute time beside the stream clock's, so a tick's cost goes from
`8 + 4×4 = 24` bytes to `16 + 4×4 = 32` bytes — a third more. But `Partition::time_capacity` is
floored to a **power of two** (`src/model.cpp:83`, `:110`), so a third more weight does not cost a
third of the span; it costs a whole halving. The measured figures above are the correct ones; a
per-byte computation gives the wrong answer for this reason.
## Packet capture
**`--capture-packets N`** stores the last N raw datagrams on disk for forensic
analysis — the bytes as `recv()` returned them, before the client validates the
magic, reassembles fragments or resequences counters. It is a debugging aid for
*wire* behaviours (a counter that skips, a datagram that arrives twice, a fragment
that never completes, a CONFIG whose header does not parse), not a feature of the
instrument: it is off unless the flag is given, and it adds nothing to the status
bar's glance tier.
The ring lives at `~/.cache/udpscope/<host>-<port>/capture.bin`, the previous
capture is kept one deep as `capture-prev.bin`, and it can be toggled at any time
from the status bar's detail popover (the **Capture packets** button, insensitive
until connected). At the default 1000 slots the file is **~62.6 MiB**, reserved up
front, which is why the size is stated in the tooltip and in the popover row.
Three things it deliberately is not, and the reasons are in
`docs/superpowers/specs/2026-09-29`'s sibling design note:
- **It cannot cost a packet.** The capture is an `mmap` and a `memcpy` on the
producer thread between two `recv()` calls; there is no queue, no lock and no
second copy. The one exception is the toggle itself: `open()` — a rename, a
create, a `posix_fallocate` and an `mmap` — runs on the producer thread when you
click, and measured ~1–3 ms here. The popover row shows the measured `open` cost
in the probe line; a reading far above that is a filesystem finding.
- **It is not interpreted.** `tools/capture_to_pcap.py` reads the file back and
writes a classic pcap, so wireshark does the interpreting:
```sh
tools/capture_to_pcap.py ~/.cache/udpscope/127.0.0.1-8000/capture.bin out.pcap
# 1000 slots, 16 stored, 0 torn, 0 truncated, 0 empty -> 16 packets, 1.0 MiB, ...
```
The counts it prints are a diagnostic in their own right: `torn` above zero means
the capture died mid-copy (a crash is exactly what the on-disk ring exists to
survive), and `truncated` means a datagram longer than the client's receive
buffer, which should be impossible.
- **It does not see the multicast CONFIG channel.** In multicast mode CONFIG
arrives over TCP, and the capture is of the UDP socket only. A malformed
*multicast* CONFIG is out of reach.
## Captured events
Trigger mode keeps the last **32 triggered acquisitions** in a cache, so an event you did not have
time to look at is still there:
```
~/.cache/udpscope/events/192.168.1.40-8000/
20261009-123250-0001.npy # the event, in the export format
20261009-123250-0001.txt # when, how long, how many signals, the rate, the edge
20261009-123250-0001.bmp # a picture of it, drawn from the samples
```
The **left pane** is opened on demand from the toolbar's event-history button, is collapsed by
default, and its **divider can be dragged**: it starts at 240 pixels and remembers the width you last
chose, and while it is closed it takes no room at all. Both the button and the pane exist while **recording is on**, or while an event is on
screen: with the cache disabled and nothing being examined there is nothing to show, and a visible
empty column beside the plot is what *off* must not look like. Recording is switched in the **trigger
panel**, with the trigger's other options rather than in the pane whose contents it decides, and it is
**off by default** -- writing every latch to disk is the user's decision about their disk.
Each row carries **its own picture** beside the numbers -- time, duration, signal count, rate -- 138
pixels wide whatever the pane's width, and as tall as that event's own proportions make it: the
preview is one 44-pixel strip per signal, so a one-signal event is a short row and a six-signal one a
tall one (capped, so that a row stays a row). **Hovering it shows the whole preview at full size**,
through a tooltip -- the small picture says *which* event this is, the zoom says what is in it.
Clicking a row **replays** it: the same loader `--open` uses, on the event's file. The click **leaves
the list alone** -- your scroll position and the pair you had selected for a comparison both survive
it, because marking which event is on screen rewrites the rows' titles in place instead of rebuilding
the list. **The layout does
not change** -- the rows, which signal sits in which slot, the colours, the row count, the time window,
the cursors and the measurements all stay exactly where they are, and now read the stored event instead
of the stream; that is what "show me that event" asks for, and rebinding the rows to the file would
rearrange the user's work under a click. What changes is the data, and the trigger is left, because a
file has no edge to wait for. Cursors, measurements, math channels and another export all work on a
stored event. **Live** takes you back to the stream.
Two buttons under the pane's header read the **selection**:
- *Compare* puts the two selected events in **two plots side by side**, each with **the layout you
already have** -- the same rows, the same signals in the same slots, the same colours -- one showing
one event and one the other. Their axes are **synchronised**: zoom or pan a row in either plot and the
same row of the other follows.
- *Accumulate* overlays up to four selected events in the **same traces**, translucent, so where they
agree the trace is brighter. The events' copies take the free slots of the row that already holds
their signal (a row holds eight traces), so your own traces never move.
Both need events with the same signals and the same length, which is what makes a shared axis honest;
the build checks it and says so in the status bar when it does not hold.
A group is a file view like a replay: the acquisition is stopped (a live producer would push into a
model whose channel count no longer matches the stream), the trigger is left, and *Live* returns.
A replay leaves trigger mode, because a file has no edge to wait for -- an armed trigger keeps a
*frozen capture* on screen (`display_mode()` stays `Held` while the model holds a snapshot), and a
held display draws that snapshot rather than the file that was just loaded. The events pane stays
open through it: it is available while a stored event is on screen.
**Going back to *Live* puts the live view back as it was.** Opening a file changes two things that
are not the user's choice -- the time window becomes the event's own span, because that is what
framing an event means, and trigger mode goes off for the reason above -- and neither reason survives
the way back. So the settings the live view had are taken on the way in and restored on the way out:
the time window, trigger mode, the trigger's channel, level, condition, pre-trigger share and
hold-off. The trigger's channel is remembered by **name**, since a comparison renames its channels
(`square (A)`) and the way back has to find the stream's own. The restore waits for the first CONFIG
of the restarted client, which is a frame or two, and is the last word on the window -- it was
previously the *saved* session's window that came back, which is to say, somebody else's.
A related repair: **the session file is no longer written while a stored event is on screen.** A
comparison rebinds every plot slot to its own renamed channels, and the automatic save wrote those
names into the session -- so the next *Live* re-applied a layout that named no live signal, and the
scope came back with an empty plot. Nothing is lost by skipping: a file view configured nothing about
the stream. The explicit *Save as* is unaffected; that one is an answer to a question the user asked.
Three things worth knowing:
- **The samples are the export format**, so an event is readable by everything that reads an
export: `--open`, the chooser's Waveforms page, and `numpy`/`pandas`.
- **The picture is drawn from the data**, one strip per signal with min/max per pixel column and the
trigger edge as a brighter line -- not captured from the window: the plot is a `GtkGLArea`, and a
GL area's ink is not in the widget's paint tree, so a snapshot of it is empty. A data-drawn
preview is also the honest one: it shows the samples, not the zoom the window happened to be at.
It is a plain 24-bit BMP, written bottom-up with **blue first** — both are BMP's own order, and
writing RGB there is what made every external viewer show the traces in the wrong colour.
- **The picture is decoded synchronously** (`gdk_texture_new_from_filename()` into
`gtk_picture_set_paintable()`), not through `gtk_picture_set_filename()`: that path uses the image
loader's sandboxed, asynchronous decoder, which here fails silently -- the picture keeps a
paint-able and an allocation, so a probe reports success while the pane shows an empty rectangle.
It is then **scaled once to the row's size** and cached by file: a `GtkPicture`'s natural size is
its content, so the stored 480-pixel preview given straight to a row claims the row's whole width
and squeezes the text out of it -- a size request on the picture is a floor, not a cap.
- **A picture that is missing or will not decode is drawn again from the samples.** The `.npy` is the
event; the `.bmp` is only a picture of it, so a write that failed, a truncated file or a cache from
an older build is repaired rather than shown as a blank row. Once per row: a second failure is a
real one.
- **A replay stops the acquisition** (a live producer would overwrite the loaded samples within a
frame), so the status bar names the file until *Live* starts a fresh client and thread. The
replayed samples age out of the ring as the stream refills it.
- **Opening an event, a comparison or going back to *Live* is one re-plan of the buffer**, not one
per channel. Each of those switches rebuilds the signal set, and a rebuild that moves the plan
allocates --- and so touches --- the whole of `--buffer-mb`; enabling a comparison's eight channels
one at a time was eight of those, which is most of what made the switch feel slow. They are now
enabled in a single call, and a rebuild whose plan is unchanged clears the buffer instead of
allocating a new one.
- The switch in the pane's header stops the recording; the cache is only useful if it was already
on when the event you wanted happened, which is why it defaults to on.
## Opening an exported waveform
An export can be opened again — to look at a run you no longer have the source of, and to analyse it
with everything the scope offers:
```sh
udpscope --open ~/udpscope-20261009-113200.npy
```
or start udpscope with no `--server` and use the chooser's **Waveforms** page, which lists the files
opened before (newest first, with the ones that have since been deleted marked *missing* rather than
left to fail on the click) and has an **Open…** button for a new one.
A waveform is a *source that never arrives*, and the design follows from that: there is no socket, no
acquisition thread, and no starvation notice — the status bar's rate is the file's own stored rate and
its Stream row names the file. Everything else is the same machinery a live run uses: one signal and
channel per column, **one row per column** (so a four-signal file opens as four stacked traces, with
three free slots on every row to drag more onto), the samples pushed into the same ring, the display
epoch pinned to the file's last stamp so the axis reads `[-window, 0]` exactly as the export did — which means cursors, measurements,
math channels, the trigger's search and another export all work on it unchanged. The signal names come
from the file: a `.npy` written by `write_npy()` is a structured array whose *dtype* names its columns
(`time`, `abs_seconds`, then one field per signal), so there is no sidecar to lose.
Only `.npy` is read: it is the format the item asked for and the one that carries names. A `.csv`
export is for other tools.
**A recording longer than the buffer can hold at its own rate is resampled, never cut short.** The
file is pushed into the same ring a stream fills, and that ring is sized by `--buffer-mb`: 20 s of ten
channels at 500 kS/s is 480 MB of rings, which a 200 MB buffer cannot hold. The loader therefore reads
the decimation the plan chose and pushes one stored sample per group of file rows, carrying the
group's **mean** — the same anti-aliased filter the live ingest applies, so a long event opens as the
whole recording at a lower sample rate instead of as a fraction of itself. It used to push every row
regardless, which overran the ring and left the file as **its own tail**: a 20 s event opened showing
only its last 4 s, with a correct-looking 20 s header line above it. The load line now names both
numbers, `samples=31250 rows=1000000 decim=32`, so what is on screen is never in doubt; give the run a
larger `--buffer-mb` (the source's configuration carries one) to see such a file sample for sample.
## Named configurations
What you arranged is remembered per data source, and a source can hold **several named
configurations** — different experiments on the same streamer want different plots, different
measurements, and sometimes a different buffer. Each is one file under the source's own
directory:
```
~/.config/udpscope/192.168.1.40_8000/
active # the configuration this source is on, one line
bench.toml # a configuration, plus its note
emi-test.toml
```
A configuration is the whole session: the window, the rows and their signals, the calibration,
the trace colours, which signals were enabled, the math channels with their graphs, the
measurements — **and the settings that can only be decided at startup**, which is why they live
here rather than only on the command line:
```toml
comment = "5 kHz sine, trigger on Ch1, 8 MB is plenty"
buffer_mb = 8
relative_time = false
capture_packets = 0
```
The **buffer size** is the one that matters most: two sources on one machine rarely want the same
ring, and the value is applied before the model exists, which is the only moment it can be — by
whichever of the chooser's rows opened the session, **and by `apply_stored_settings()` when the
source was named on the command line**. A saved host's settings are read either way: opening
`udpscope --server 10.0.0.5 --port 8000` gets that source's stored buffer size, clock and capture
ring, and a flag the user actually typed wins over the file (`--buffer-mb`, `--relative-time`,
`--capture-packets`) because it is the more recent statement of intent. Switching configurations *inside* a running
app applies everything that can change live (layout, plots, math, measurements, enables) and says
so in the status line about the rest (buffer, clock, packet capture): they take effect on the next
start.
The **chooser** (start with no `--server`) lists one row per configuration, with its note and its
settings, so two configurations of one source are told apart before they are opened. Its **New
host…** form takes the address *and* those settings — the configuration's name and note, the buffer
size, and relative time — so a host can be set up before it has ever streamed; the same form opens
from a row's pencil button to **edit** an existing configuration, pre-filled from its file, and
editing the name or the address is a *move* (the old file is deleted, so the list never shows the
same session twice). Connector settings are stored even for a configuration with no signals yet,
which is why those rows exist at all. The app's
menu has **Configurations…**: the same list for the running source, with *Save this session* —
a name and a note — and a delete button per row. Opening one saves the current session first, so
switching is never the gesture that loses what it switches from. Beside **Preferences…** the chooser
also carries a **help menu** with the window's three help entries — the manual, the contextual-help
toggle and the guided tour — because the chooser is shown to exactly the user who has not started yet
and does not know how to; the tour is started by the app once its window is up (see
[the help section](#the-help-submenu-the-contextual-help-and-the-guided-tour)).
A session written before configurations existed is imported on first use as the configuration
named `default`: copied into the directory, remembered as active, and the old file renamed to
`<host>_<port>.toml.imported` rather than deleted, so the import can be undone by hand.
## Preferences
`~/.config/udpscope/settings.toml`, written by **Preferences…** -- in the overflow menu or the source
chooser -- and read at start-up. It holds the event cache's directory, how many events to keep (`0`
keeps every one), whether relative time is on, and the interface's theme. Whether anything is cached at
all is the trigger pane's own switch, and there is deliberately no second one here; the directory is
the one place every source gets a folder inside, `<cache>/udpscope/events` by default. The cache
directory and relative time are *start-up* settings -- the buffers are planned and the cache directory
is opened before any source is known -- and the dialog says so; the theme applies as soon as it is
chosen. A change
applies as it is made — there is no Apply button, and a **Reset to defaults** row at the end of the
dialog puts everything back — and the rows are the ones every GNOME application shows: a title, a
subtitle under it where the setting needs explaining, and the control on the right. A key that is
absent means its default, so the file only mentions what changed, and a missing file is a first run
rather than an error. The directory field shows the directory in use: the default
is filled in rather than described, and typing it back leaves the setting unset, so it goes on
following the cache directory instead of pinning one path for good. It can be typed, or picked with the folder
button beside it, which writes what it found into the same field -- the field stays the one value, and
there is nothing to keep in step.
The same dialog is reachable from the source chooser, the one window that exists before a run is
planned. From the app the cache directory and relative time take effect on the next start; from the
chooser they reach the run about to start. A preference applied there also beats a source's stored
configuration, exactly as `--relative-time` does: it is the more recent statement of intent, and it
works in both directions, so turning relative time off in the dialog is not undone by the row you click
afterwards.
## The session layout file
A configuration's 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
measure_stats = false
[[plot]]
signals = ["Ch1", "", "", ""]
[[calibration]]
name = "Ch1"
gain = 2
offset = 0.5
```
It also stores `measure_stats` (the measurements statistics toggle, a boolean, absent means
`false`). 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 `<file>` 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 shortcut overview's -- `adw_dialog_close()` and `::closed`, rather than
`gtk_window_close()` and `::close-request`. That is the same trap the shortcut overview already
had; each keeps a close handler that nulls its own pointer -- the About dialog on `::closed`, the
overview on `::close-request` -- so neither can be presented again after it is gone. 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.
## The Help submenu, the contextual help and the guided tour
**The hamburger menu's help trio became a `Help` submenu**, which is where every GNOME menu keeps
these four: the **Keyboard shortcuts** overview and the **Documentation** window (the two dialogs
the program already had, reached through `show_shortcuts()`/`show_help()` exactly as their keys
reach them), the **Guided tour**, and a stateful **Enable contextual help** row.
The submenu *replaces* the menu's rows rather than floating beside them -- the same shape, and the
same reason, as the plot's right-click menu: a submenu torn out of the menu it belongs to is a
second thing to dismiss, and the `GtkMenuButton` that owned one toggled itself out of sync the
moment anything else closed its popover. It is a `GtkStack` whose visible page is the view, and not
a rebuild of the box: the main view holds `app.layout_toggle`, which the frame tick reads
(`sync_layout_editor()`) and the `L` accel moves, so rebuilding the rows would leave that pointer on
a destroyed widget, and *swapping the popover's child* would leave the view that is not showing
without an owner. A page swap has neither problem.
**The accelerators are `Ctrl+/` and `F1` for the two dialogs -- they already were -- plus `Ctrl+T`
for the tour and `Ctrl+H` for the contextual help.** Both new keys are rows in
`src/shortcuts.cpp` *and* in `src/ui/shortcuts.ui`, because the `[help]` probe checks the two
against each other (`ui` must equal `tables`).
**The contextual help is one table and a "?" per surface.** `src/ui_context_help.cpp` carries the
rows -- the plot, the trigger, the cursors, the measurements, the math channels, the signals, the
event history, the time window, the status line, and the math editor's identity row -- and each row
names the *heading box* its button joins, so adding a surface is a row and a widget pointer. The
button is a sibling of the heading's title, not an overlay on its corner, for the reason the
toolbar's icons are not overlaid either: an overlay child sits *on* whatever is under it, and the
one surface whose corner is free (the plot) is the one that uses one.
**Off means gone, not greyed, and off is the default.** The toggle removes the buttons rather than
disabling them, and which buttons exist is decided by a data key rather than a list of pointers, so
the math editor -- whose heading is created afresh every time it opens -- cannot leave a stale
pointer behind. The setting is `contextual_help` in `settings.toml`, written by
`Settings`/`load_settings()`/`save_settings()`: the same file, the same three functions and the same
`~/.config/udpscope/settings.toml` the Preferences dialog writes. **A fresh install has no such key
and the default is `false`**, so a first run shows the window as anyone would expect it and the layer
is asked for -- from the Help menu, from the source chooser's own help menu, or with `Ctrl+H`. The
dialog's own rows start from `load_settings()`, so a save from there carries the two help keys
through untouched; the Help menu's row is the only place in the *window* that states `contextual_help`
(which is why the row's check *is* the setting), and the chooser's row states it too, before any
window exists.
**The source chooser carries the same three affordances, because it is shown to exactly the user who
has not started yet.** Its header bar has a help `GtkMenuButton` beside *Preferences*, whose rows are
the window's own `menu_button()`/`menu_toggle()` -- the same shape, and the same `.udpscope-menu`
stylesheet, whose rules `choose_source()` installs before building them since the app installs them
later. *Documentation* opens the manual directly (it is another process and needs nothing of the app),
*Enable contextual help* writes the settings file the app reads a moment later, and *Guided tour*
starts **the chooser's own walk** -- the surfaces a first run meets first are this dialog's own --
whose last step sets **`Config::tour_pending`**, the value `main()` owns and hands to both the chooser
and the front end (`open_waveform` crosses the same way). Half a second and a half after the window is
up, the same delay the first-run auto-start uses, the pending request starts the window's tour; it sets
`tour_requested` rather than `tour_auto_started`, so the readings say which of the two it was. The
chooser's rows show **no accelerators** -- the window prints `F1`/`Ctrl+T` because the window binds
them, and this dialog binds none.
**The guided tour is five steps on a `GtkOverlay`** inserted above the window's content: the plot,
the side panel, the trigger, the signals and the status line. There is no popover anywhere in it and
therefore no pointer grab to lose: what makes the tour modal is four dim rectangles -- above, below
and either side of the target -- that swallow the clicks that land on them. A stop declares the
**page it stands on** (the trigger and the signals steps live on the sidebar's *Signals* page, named
in `TourStop::page`), and the tour selects that page as the step is entered -- on the frame *after*
the switch, since a stack allocates a new page's widgets a frame late -- so the spotlight lands on a
widget the user had left behind on the other tab instead of on nothing. `gtk_widget_get_mapped()`
decides which steps exist at all: a stop on the *wrong page* is kept, because selecting its page
reveals it, while one whose *pane is collapsed* is dropped, because the stack is unmapped with it
(a hidden page keeps its own *visible* flag true -- the widget's own flag, which is all
`is_visible()` walks -- so the old test walked over surfaces that were not on screen). The chooser's
tour works the same way over its own two pages: its tabs step shows the *Waveforms* page and
stepping off it -- in either direction -- brings the *Sources* page back.
Everything is placed from `gtk_widget_compute_bounds()`, re-read on **every frame**, because a
widget's allocation is 0 on the frame it is first shown and a window resize moves it under a
one-shot measurement. The hole is kept from the last frame that could measure the target for the
same reason: a frame can arrive between two layout passes, and a hole that emptied there would make
the spotlight and the card jump. The card goes under the target, over it, or in the column beside it
-- whichever has room -- and only takes the bottom corner when the target *is* the whole content,
which is what happens to the plot step in the documented headless run (a 640x480 Xvfb screen gives
the window its 756x894 minimum, and at that width the split view overlays the side panel instead of
putting it beside the plot). The probe prints the two rectangles (`spot=`, `card=`) rather than
asserting a placement, so that case reads as the geometry it is.
**A first run gets the tour, once.** The decision is `settings.tour_seen`, and it is a key of its
own rather than "the settings file did not exist": a user who had already changed a theme, or the
cache size, has a settings file and has still never seen the tour. It is written when the tour
**starts**, not when it finishes, because a user who skips it in the first second has still seen it
and a second launch that offered the whole tour again would be a nuisance. The auto-start is
scheduled 1.5 s after the window is built -- the window has to be up and laid out for there to be
anything to point at -- and the menu (or `Ctrl+T`) starts the same tour at any time.
Dismissing it is `Skip`, `Finish` on the last step, or `Escape`; all three hide every child of the
overlay. The probe asserts what "left nothing behind" means as a *reading*: four dim rectangles,
the outline and the card all off screen, and **the window carrying exactly the event controllers it
had before the tour opened**.
`UDPSCOPE_PROBE_HELPUI_MS` drives all of it: the first-run auto-start, the menu's seven rows and the
submenu's five, the toggle and the count of "?" affordances on either side of it, and the tour
started, stepped forwards and back, and dismissed. Its readings are in
[probe-readings.md](docs/claude/probe-readings.md).
**The dump also carries the geometry the help's annotated figures crop**, `help figures:`, with each
of ten surfaces' `x,y,width,height` in **window coordinates** -- the frame a screenshot is cropped
in -- or `-1,-1,0,0` when the surface is not on screen. It is measured on the tick while the window
is alive, and it exists because a crop box drawn round a region of a screenshot has to be measured
rather than eyeballed: the panels are where `gtk_widget_compute_bounds()` says they are, and a
plausible guess is not.
## 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. What it *would* buy is priced in the dump's `publish cost:` line — see
"There is one live buffer, not two" under the review findings.
`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.
**One bad packet is bridged; only a run of them changes the clock.** A real device's
clock glitches, and a packet whose stamps jump far ahead of the previous packet's last
is a fault rather than a gap — so a jump of more than **256 packet spans** is rejected
alongside the three checks that were always there (a stamp that is not finite, a packet
with no span at all, a packet that does not advance past the previous one). A rejected
packet is **bridged**: its stamps are replaced by a continuation of the accepted timeline
at the declared cadence, so the next good packet is weighed against an un-poisoned
anchor and is accepted normally. Only **four consecutive** rejected packets abandon the
declared time signal for the arrival clock, because that is what a signal which is not a
time base at all looks like. The declared unit (`ns`, `us`, `ms`) is resolved *before*
these checks rather than after, since they weigh the stamps against a cadence in seconds.
Believing a glitch instead is what produced *"a big spike in one channel and the
acquisition seems to reset"*. The stamps pass through a monotonic clamp, so a glitched
value poisoned the mark and pinned every later packet flat at it — the stream froze at one
x and never recovered — and the glitch also condemned the *next*, perfectly good packet
(now below the poisoned anchor, so it failed the advance test), which threw the whole
stream onto the arrival clock. `tools/mock_udpstreamer.py --time-glitch-at <s>
--time-glitch-by <s>` injects the fault; with a 10 s hold-off the two latches either side
of it are 11.2 s apart, where before the fix they were 1.2 s apart.
**Switching to the arrival clock does not drop history.** `arrival_epoch_` is derived from
the monotonic mark, so the arrival clock *continues* from it and the two timelines share a
zero by construction — the recorded stamps are therefore still on one timeline and are
kept. They used to be wiped on the reasoning that the old stamps belonged to a dead clock;
they do not, and that wipe is what made a single glitching packet look like *"the live
stream restarts from 0 with no change from the user"*. The same continuity is why
`TriggerEngine` needs no rebase across the switch: it holds four absolute stamps
(hold-off, pending edge, last trigger, last sample) and they stay valid. Anything that
makes the clock genuinely *discontinuous* would break that and would have to reset them.
**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.
**Force takes one acquisition now.** The trigger panel's **Force** button, beside
Arm/Disarm, is the scope's "capture this instant": for the condition that is not coming,
it opens a capture at the present moment with the configured pre/post window rather than
waiting for an edge. It is deliberately *not* a second publisher — `TriggerEngine::force()`
only puts the engine into the pending state an edge leaves it in, with the newest stored
stamp as the event's own time, and the ordinary latch does everything else: the cut, the
publish, the latch count, the marker at x = 0 and the framing. A forced acquisition is
therefore framed, pinned and recorded exactly like a latched one, down to the axis being
pinned from the snapshot's own stamps rather than from what was asked for (a request the
ring cannot reach back to — a 100 s window at 99 % pre — comes out seconds wide, which is
what the probe's second leg reads).
Two choices are worth naming. The engine's pre-window gate is **skipped**: the history
behind "now" is whatever the ring holds, and refusing to capture it because the ring is
young would refuse the one thing the button is for. And a capture already filling is **not**
restarted — it is the acquisition being asked for, and restarting it would throw away the
post window it has collected; the click is ignored (and said so on stderr) in that state
only. What the trigger *is* afterwards is still the mode's decision, made where it always
was: Normal leaves it armed for the next edge, Single disarms, because one acquisition is
what the mode means whether an edge produced it or the button. The button lives in the body
of the pane rather than in the locked pane's heading beside Arm/Disarm: the heading's one
control is the runtime *state* a locked pane exists to change, while a force is an action
whose subject — the condition, the level, the pre/post split and the mode — is in the body.
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.
**Multiple events: the last N acquisitions, dimmer with age.** Trigger mode can keep the last
`N` acquisitions on screen at once, each older one drawn at half the brightness of the one before
it (1, 0.5, 0.25, 0.125, …), which is what a phosphor display does with successive sweeps: they
are aligned on their own trigger edge, so the shape of the event repeats and whatever varies
between acquisitions — jitter, ringing, an occasional glitch — reads as a faint ghost behind the
current trace. <gui>Persistence</gui> in the trigger panel is the knob; `1`, the default, is
exactly the display without it, and the range stops at 8, where the eighth layer would be 0.8 %
ink that nothing can see. The older acquisitions are drawn *behind* the current one and are never
pinned: the axis, the framing and every measurement still describe the event on screen.
`N = 1` retains nothing at all — no copy, no layer — and lowering the depth frees the retained
snapshots there and then, because a trigger that has stopped firing never reaches another publish.
**Persistence: one image per signal, every acquisition.** The trigger panel's `Advanced` block
carries the *other* display of the same acquisitions, and it is not a count at all: `persistence`
turns on a **phosphor density** — the plotter's intensity image (`Plot::set_density()`, the axes rect
in device pixels **per trace, in that trace's own signal colour**, bounded by the plotter's
`kDensityCellCap`: past **262 144 cells** — a mebibyte of counts — it scales the rect down uniformly
and magnifies the image back over it with a linear filter)
into which every triggered acquisition is deposited, the newest included, and which is drawn *in
place of* the traces. The current acquisition is therefore a sweep *inside* the image and fades with
the rest; no solid polyline is drawn over it, which is what a report of "the persistent image plus
the last acquisition, and the last one never fades in time mode" turned out to be. A cell's
brightness is how many acquisitions passed through it — one sweep's ink is the trace's own colour,
and where several agree it clamps there rather than going white — so the display brightens where the
signal is steady and dims where it only passed, **with no depth to choose and no memory that grows
with the acquisitions**: the cost is bounded, and the bound is the grid's — at most a mebibyte of
counts a signal, so the app's four signals hold 4 MiB between them however wide the window, and the
probe's four one-signal rows read `cells=1044714` (4 178 856 bytes, 1 044 714 of them uploaded on a
moved frame) — and a thousand acquisitions cost the same. Each signal is a separate image, so two
signals agreeing in a cell read as two colours and not one blended grey. `fades by` picks how it
decays: `acquisitions` (each acquisition keeps the factor of what was before it, 0.5 by default —
the phosphor afterglow schedule) or `time` (a time constant in stream seconds, so the display fades
while you watch whatever the trigger rate). It applies in **trigger mode only** — while a captured
acquisition is on screen — and it goes away with the trigger, on a pause and on an opened file,
because the image is an image *of* a stream of acquisitions. Like the invalid-span bands it reserves
no layout space and nothing on it can be picked. The `[density-probe]` run that measured it is in
`docs/claude/probe-readings.md`; what it costs is there and in the plotter's
`docs/data-and-performance.md`, where the density's own pass is priced headless at a fixed geometry —
the app's in-run A/B of it is a mean against a mean and the "before" side includes the run's own
startup frames. A 3040x1700 framebuffer with four rows of one trace and a 2 s window (the frozen
configuration: at 2x device scale that is a 1520x850 window) read **4.5 M cells a trace, 12.9 ms of
tick and 12.8 ms of draw a frame and 4.9 MB uploaded** on llvmpipe, and reads **1.0 M cells, 3.9 /
5.6 ms and 1.0 MB** at the cap.
**And it says what it costs.** A retained acquisition is a whole window copied out of the ring, so
the retention is bounded by two things: the depth, and **`--buffer-mb`** — persistence may hold at
most as much again as the buffer does, which is the bound a long window on a fast stream reaches
long before the eighth layer. The other bound is the plot's own: a layer takes a *free trace slot
in the row that plots its signal*, layer by layer, so a row of four signals — the layout the app
starts with — has room for one older acquisition of each, while a row per signal has room for all
seven. A depth that the room or the budget cuts short is counted and said in a notice, rather than
quietly drawing fewer layers than were asked for. The layers are *not* signals: they hold no
channel, so the signal list, the colour chips, the calibration table, the layout file and every
measurement see one signal where the plot draws several traces of it — the same rule the event
*pane*'s accumulate view follows.
**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 engine identifies its trigger channel by name, never by index**, and that is what
makes the hold-off hold. `TriggerEngine::configure()` drops an open capture when the
trigger moves to a different channel, and it is re-called on *every* packet. The config's
channel field is an index into the channel list, and that index is not stable: every
rebuild of the list renumbers it — enabling a signal that sorts before the trigger's own
is enough — and the model then re-resolves the trigger by name and writes the new index
back. Keying the reset on the index therefore made any such rebuild abandon the capture
**and set no hold-off**, so the next edge inside the post window became t0: with a 1 s
window, a 10 % pre share and a 10 s hold-off, an edge 0.6 s into a capture stole it. That
needed no glitch and no user action beyond enabling a channel. A deliberate re-point still
resets, because the name changes with the source. Since the rebuild also replaces the
buffer, the surviving capture's pre side is short and the snapshot reports its own `pre_s`
— which may be **negative** when the new ring starts after the edge. That is a faithful
reading rather than a defect: `[-pre_s, post_s]` still describes the samples the snapshot
holds. `UDPSCOPE_PROBE_TRIGRENUM_MS` drives the whole sequence through the real widgets
and prints `t0_held=1` as the assertion.
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.
### Qualified trigger conditions
The level-and-edge trigger is one of four shapes the **Shape** dropdown offers, and all four
are the *same* trigger: the arming, the hold-off, the pre/post event window and the single/auto
modes are unchanged, and only the question "did an event happen at this sample" differs.
- **edge** — a crossing of the level in the chosen direction. The original behaviour, bit for
bit.
- **pulse width** — a crossing *and* its return, qualified by how long the excursion lasted:
`<`, `>`, `within` or `outside` a pair of widths. The event is timed at the **closing** edge,
because that is the first sample at which the width is known. `<` a width shorter than the
signal's own pulses is the glitch trigger: nothing but a runt satisfies it.
- **window** — the signal entering or leaving a band between two levels. A band is two levels,
so the panel refuses a zero-width one: it reduces the condition to an exact float-equality
test against the level, which an ordinary signal never satisfies and a signal sitting *at*
the level satisfies on every transition — a ±1 square against a band of `[1, 1]` fired 19
times in a standalone check. Neither answer is a band, which is why the second spin is
clamped an increment away rather than the detector special-cased.
- **dropout** — the *absence* of a crossing for longer than a timeout. One event per outage:
the condition re-arms only when a crossing returns.
**Noise rejection on the trigger's own input.** A *band* and a *reject* are offered beside the level.
`band` is a share of the channel's own span either side of the level -- a second, undecided threshold, so a
sample inside it decides nothing -- and `reject` is a one-pole low-pass corner in Hz, one state per
configured condition, recomputed when the corner or the sample interval changes. **Off is `band = 0` and
`reject = 0`**, which is bit for bit the trigger that was there before them.
They apply to **edge** and **window** and *not* to **pulse width** or **dropout**. That is a deliberate
deviation from the design spec, which asked for every condition type: a pulse's width is measured from a
crossing to its return, so a band there would change the *measured width* rather than reject noise (the
documented pulse-width readings move with it), and a dropout is a measured *absence*, which a band cannot
describe -- there is no level to compare.
The band and the reject are verified through `BufferSearch` hit counts rather than through
`[condition-probe]` latch times, also deliberately: `probe-readings.md` states that a `t0` is **not** an
assertion, being a stream stamp rebased by whichever rebuild last dropped history -- it read `1.7103`,
`1.6975` and `1.8719` on three consecutive runs of the same leg. The counts, and what each must read, are in
`probe-readings.md`.
A shape's own rows are the only ones shown; the rest are hidden, so the panel never offers a
width to a window trigger. The condition **is** persisted in the session file, together with trigger
mode itself: the settings are written even while the trigger is off, because a trigger set up once and
switched off is still the trigger you come back to. The signal it names is stored by *name* — every
rebuild renumbers the channels — and the shapes by the words the panel's dropdowns show, so a
reordered enumeration cannot reinterpret a file.
### Searching the buffer
**Search the buffer**, at the foot of the trigger panel, runs the trigger's *own* condition over
the whole retained ring and lists every place it holds. There is no second condition editor: a
second place to spell the same question would be a second place to get it wrong.
The scan runs on the GTK thread in a few milliseconds of work per frame, taking the model's
mutex for one chunk at a time, so neither the frame rate nor the producer stalls on it. Up to
10 000 hits are kept; past that the scan stops and says so. If the stream overtakes the oldest
samples mid-scan the search skips forward to what is still there and reports `the stream
overtook the oldest samples` rather than returning a hit at a time that no longer exists.
The arrows step through the hits. A step **pauses** the view on the hit, centred, exactly as the
Pause button does — so the frozen window is pinned and navigable within its own data, and
resuming returns to the live view. Up to 16 of the hits **in the visible window** are marked on
every row at once, nearest the centre first and the selected one always among them in its own
colour; when there are more in view than there are marks the panel says `16 of 38 marks shown —
zoom in to see the rest`, which is a true statement about the picture rather than a silent
truncation.
**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/<host>_<port>.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 `<host>_<port>.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 — nor while a **stored event is on screen**, for
the same reason stated the other way round: a file view configured nothing about the stream, and a
comparison's renamed slots written into the session came back as an empty plot.
**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), `de-noise` (a running median followed by a one-pole), `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.
`de-noise` is **last in the dropdown on purpose**: a node's op is stored in the session as its index
through `math_op_at()`, so an op inserted among the others would reinterpret every node in every file written
before it.
**`de-noise` is one node, not a median the user chains into a low pass.** Its window is `med_n` samples, odd
and 3..9 (the dialog offers only those, and the engine rounds to it anyway, so a hand-edited file loads), and
an impulse shorter than half the window cannot move the median at all; the one-pole at `fc` Hz then takes the
hash the median leaves behind, and `fc = 0` is the median alone. The order is the design: a pole *before* a
median feeds the impulse into the filter's state for its whole settling time, which is the thing the node
exists to avoid. It costs a per-sample insertion sort of at most nine doubles into a local, on the producer
thread where every other op runs -- `math cost:` reads **103 ns/slot** with a de-noise channel in the probe's
three graphs.
**A gap is a gap in a filtered trace, never the end of it.** A hole the resequencer reserved is
NaN in the source, and the filters deliberately do not integrate it: the hole shows as a hole in
the filtered trace, the filter's history stays the last real samples it saw, and the trace picks
up again after it. (Integrating it poisoned the biquad's state for ever — every later sample
NaN — which is what a dropped packet used to do to every low pass, systematically in relative
time, where a counter gap reserves NaN slots by construction.) The exit dump's
`math '<name>': finite=N/M` line is where that reads: `finite` must be about `M` minus the hole
share, and `finite=0` beside a non-zero `hole_slots` is the defect back.
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; **drag from an output square to an input square to wire them** (the old
two-click protocol is gone); drag a wired input square off its port to detach it; click a wire and
press `Delete` to remove it; drag a box to move it. **Wheel zooms about the pointer, drag the
background to pan, `f` to fit, `Ctrl+0` to reset.** *tidy*, a button in the editor dialog beside
*fit*, lays out all nodes in topological columns. The op is chosen when the node is added — *add
node* in the same row of the dialog is a menu button whose popover lists every op — so there is no
opless placeholder to build past. The graph is a *list* walked in order, so a node can
only read the ones above it; a wire drawn right to left re-sorts the list silently, so the graph
stays evaluable without requiring the user to add nodes in order. A cycle is unrepresentable:
`math_connect()` checks the trial edge set with Kahn's algorithm before writing anything. 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`.
A live validation line beneath the form says why the graph cannot be saved; Apply is insensitive
while it is non-empty. The two modal alert boxes that used to appear are gone.
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.
**The pause reads its display offset *before* it sets the pause state**, and pausing while an event
is held is the case that depends on the order. `stream_at_x0()` follows the *display*: the moment
`paused` is true with no pause view published yet it answers `-display_epoch()`, which on a held
event is `0` — so the cut searched the ring at the event's edge-relative x, found no samples, and
froze an **empty** picture: no trace, every measurement `--`, every ruler gone. Read first, the
offset is the one the axis is actually displaying, which is what hands the snapshot stamps back on
the coordinates the axis is already showing. The live case is unaffected (its epoch does not depend
on `paused()`), which is why the pause probe, which pauses a live view, never caught it;
`[measurepin-probe]`'s `paused/before` leg pauses a *held* event and prints the bound frame's own
sample count (`ev=650001` for a quarter-of-the-event zoom), which is `ev=0` when this regresses.
**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.
**...and the same pane costs what the *window* costs, which a big time scale buys.** The three
fixes above bound *how often* and *how much per sample*; what is left is the arithmetic of the
scale: a pass is priced per measured **channel** (one fetch of the window's level-0 buckets, one
statistics scan) and per **distinct share** in the pane (one level accumulator, walked over those
buckets), and the window's own sample count multiplies both. Measured with the new
`[levels-probe]` sweep on the mock at a **15 s** window and `--buffer-mb 4000` (so `time_stride`
stays 1 and the window holds 11.8 M samples, 184 k buckets), four signals, one row, on llvmpipe
— `measure cost:`'s `each` per recompute, and the tick's mean:
| pane | `each` | `raw` | tick mean |
|---|---|---|---|
| empty | — | — | 1.34 ms |
| 1 measurement, 1 level, 1 channel | 6.58 ms | 0.1 % | 1.99 ms |
| 4 measurements, 4 shares, 1 channel | 10.28 ms | 0.4 % | 2.38 ms |
| 16 measurements, 16 shares, 1 channel | 24.55 ms | 1.2 % | 3.86 ms |
| 4 measurements, 1 share each, **4 channels** | 35.69 ms | 2.6 % | 4.58 ms |
So a machine's ten measured signals at a 15 s window is tens of milliseconds a pass, several
times a second, on the **tick** — the pane is a *frame* cost and not an ingest one (`gaps` and
`packets` stay clean even though each reader holds the model mutex, which is what "the plot is
slow with measurements on" actually is). One of those terms was a pure call-overhead defect:
pass two folds each bucket into each level through `measure_accumulate_bucket()`, the innermost
loop of a walk whose iteration count is the window's buckets *times* the pane's levels, and as an
out-of-line function in its own translation unit it cost **30 % of the pass** — sixteen shares on
one channel read `24.55 ms` a recompute with every value in `measurements:` unchanged at
**17.25 ms** once it was moved into `measure.hpp` as `inline`. The fetch's three out-of-line
pyramid accessors per bucket (3.3 ms of an 11.8 M-sample window's 11.6 ms pass) are the next
lever, and `raw` is the last one and belongs to the signal rather than to the pane.
**"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** were the two types added then: `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 came out of the
shared pass, so they were free, and both read exactly what a unit sine should: `amplitude=1`,
`peak to peak=2`. **`amplitude` no longer means that.** It is `top - bottom`, the delta between the
two *flat* levels — a bench scope's `Vamplitude` — and it is the extremes that stay with
`peak to peak`, which is why it reads `2.000000` on the rippled square where the swing is
`2.358701`: a ringing edge's overshoot belongs to the swing and not to the amplitude (see
*Measurements*).
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<T>` stores its slots as `std::atomic<T>` behind a
`static_assert(is_always_lock_free)`, so this was a question worth asking rather than
assuming: `std::atomic<double>` 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()`. That stall is now measured rather than estimated —
the dump's `publish cost:` line read **115 ms** for one event publish at 1.3 MS/s, against
the ~40 ms this paragraph used to quote — and it produced no packet gaps.
**A paused view carries one too**, and that half was missing until it was found by its
*appearance*. `publish_pause_view()` filled `stamps`, `values` and `y` and left `pyramids`
empty, so a frozen pause was drawn from every raw sample it held, on every frame — the same
defect as above with the same cause, in the other snapshot. `frame path:` read
`raw=21 lod=118` over a 1.6 s pause against `raw=1 lod=170` for the same run live, with the
draw mean going 12.34 → 18.18 ms and its worst frame 30.35 → 86.92 ms. But the reason it was
noticed is that the trace *looked* different: `Renderer::stage_trace_density()` grades each
pixel column against that column's own peak, from a `1/height` dwell deposit per segment, so
a column fed several hundred raw inter-sample segments accumulates a far spikier
distribution — and a far larger peak to normalise against — than one fed the one or two
bucket envelopes the pyramid gives. The paused trace read as a hard bright core with its
dwell tail dimmed away. The rule the invariant was written without: **a pause is not a
cheaper case than a capture — it freezes the same rings.**
It is not free, and the trade is one-off against per-frame. Building the pause pyramid took
that publish from 6.15 ms to 22.31 ms, and unlike the event publish this one runs on the
**UI** thread and so blocks the *producer* — against ~5 ms a frame of draw for as long as the
pause lasts, which over a 1.6 s pause at ~60 fps repays thirty times over. The producer
absorbs it: an A/B of the same fast-mock run with and without a pause read `gaps=0` both
ways, with the pause publish at 86 ms in the paused one. (One earlier run reported `gaps=73`
with a pause against 0 without, which is why the stall was worth measuring at all — but it
has not reproduced, it did not scale with the publish length, and an 86 ms publish costing
nothing is the stronger reading. Treat that 73 as incidental load on one run.)
The verification that the *look* is restored is the same A/B read through `frame path:`:
`raw=0 lod=373 columns=373 graded=373` without a pause against `raw=1 lod=555 columns=555
graded=555` with one — the same path, the same 24711-segment worst frame and a segment mean
within 5 % — where before the fix the paused run read `raw=21 lod=118`.
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.
**A dense trace is drawn one pixel column at a time, which is what cured the moiré.** At a
high sample rate, a high signal frequency or on a noisy signal the plots showed strong,
*crawling* banding — and three things compounded to produce it, none of which is a bug on its
own. `MinMaxPyramid::level_for()` sizes a bucket at one to two pixel columns; `Envelope`
emits two points per bucket, so each bucket contributes a near-vertical min→max hairline
*and* a near-vertical connector to the next, about 3.6 near-vertical hairlines per column per
trace; and every one of them lands at a *fractional* x, because a trace is deliberately not
snapped. With src-alpha compositing a column's brightness is then a non-linear function of
the sub-pixel phases overlapping it, the phase pattern repeats at the beat between the bucket
pitch and the pixel pitch, and a pan sweeps the beat. It needs a *thin ribbon* to be visible:
a window whose buckets each span several periods paints a saturated block and hides it
entirely, which is why it showed up on fast and noisy signals and not on a slow sine at a
whole-window view.
`deps/plotter`'s `Renderer::draw_traces` now reduces such a window onto the pixel grid: one
exactly vertical segment per integer column, from the lowest to the highest screen y any
gathered point put in it, flushed in framebuffer pixels with the snap on. The whole trace
then has **one** sub-pixel phase, so there is nothing left to beat. See the upstream
`docs/data-and-performance.md` for the four rules it keeps (a hole stays a hole, disjoint
neighbours are joined so a fast edge keeps its join, a span under the stroke is drawn *as*
the stroke, and the gate is the gathered density rather than the LOD level) and for why this
is the one carve-out in `u_snap`'s rule that chrome may be snapped and data may not.
It is also a large speed-up, because the geometry becomes O(axes.w) instead of O(buckets).
Measured on the fast mock (`--rate 1300000 --batch 2000 --freq 3000`, a 20 s run): `frame
path: raw=1 lod=262 columns=0 frames, segments 10960/216093` and **263 painted frames**
before, `raw=2 lod=1198 columns=1200 frames, segments 2041/2237` and **1201 painted frames**
after — the same run at 4.6× the frame rate, with `trace ink:` reading `0 collapsed, 0
staircase, 0 short, worst drawn-vs-quoted 0.0000 px` either way. `frame path:`'s
`columns=` field is the reading that separates "the segment count collapsed because the
reduction ran" from "the window got narrower".
**Those columns are then graded by dwell, which is the rest of the answer.** A reduced column
is honest about *where* the signal went and silent about *how long it was there*: its min and
max are the same whether its samples fill the span evenly or pile at its two ends, so a ramp
and a pulse are one flat band. `Style::intensity_grading` — off in the library, which udpscope
turns on at the first tick, since the renderer does not exist until the area is realized —
draws each column graded instead: bright where the signal sits, faint where it only passes
through, the way a digital-phosphor scope reads. The distribution cannot be recovered from the
band, so the gather is asked at a level finer than the pixel grid and the dwell read from those
sub-buckets; the measure is dwell *per row*, each segment depositing one unit spread over the
rows it spans, and normalisation is against the column's own peak. See the upstream
`docs/data-and-performance.md` for the five things that carry it and for the two that were
wrong first (counting rows at +1 each inverts the pattern, grading a sine's fast middle as
brightly as its rails; a frame-wide scale needs a 33 MB density image *and* fades the trace out
exactly where it moves fastest).
It costs about a millisecond a frame and the A/B is the only way to read that, because a frame
mean cannot attribute its own cost: **`UDPSCOPE_NO_PHOSPHOR=1` puts the style back to the
library's default**, and the same probe run on the fast mock (`--rate 1300000 --batch 2000`)
reads `draw 3.24/21.43 ms` with the grading against `draw 2.30/25.76 ms` without, over the same
`lod=453` frames. **`frame path:`'s `graded=` must equal `columns=`** — grading is a way of
drawing the reduction and not a third path, so the two counts agreeing is what says the look
reaches every reduced frame (`columns=1166 graded=1166` in that run, `graded=0` under the env
var). The segment count rises with it, from a mean of 131 to 1340, which is the gradient's
tiers: alpha is quantised into steps because the line pipeline's colour is a uniform and a
per-segment alpha is inexpressible, and each step is its own draw call.
The one performance trap here is worth stating because only the A/B found it. Asking for a
finer level drops straight to **raw samples** once a sub-column falls under one pyramid bucket
(64 samples), which at the run's 172 samples a pixel it did: `raw=1154 lod=0` and 6.4 ms a
frame, i.e. grading undoing the very reduction it draws. The finer ask is floored at level 1
whenever the flat path would have used the pyramid, so what degrades is the *look* — fewer
sub-columns than asked for — and never the path.
**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 **manual** (`F1`, the *Documentation* menu row) opens in the desktop help viewer — Yelp,
through the freedesktop `help:` URI — rather than in a window of udpscope's own: it is a Mallard
set installed under `<datadir>/help/C/org.udpscope.app/`, not a scroll view compiled into the
binary. Its overview page shows the main window and a held capture, and the trigger and math pages
carry their own figures, all produced by `tools/make-help-shots.sh` and embedded as `<figure>`s. 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.
**Clicking the chip of a signal that is on a plot opens a colour chooser** (`GtkColorDialog`,
with the alpha slider off — a trace is stroked opaque, so a transparency setting would do
nothing), and the colour it returns *overrides* the palette entry for that signal. The chip of
a signal on no plot is not clickable: a grey chip names no trace, so there is nothing to
recolour. A chosen colour is held by signal **name** (`RowSet::set_signal_color()`), saved in
the session file's `[[color]]` tables and resolved — for the trace and for every chip — by the
one call `RowSet::signal_color()`, so a chip cannot claim a colour the trace is not drawn in.
The way back is **Default colour** in the signal's `⋯` menu, sensitive only while an override
exists; it is there rather than on the chip because a second gesture on a one-glyph target is
not something anybody would find. The chooser is opened by a `GtkGestureClick` on the existing
label rather than by wrapping it in a `GtkButton`, because a button's padding is exactly what
made this row twice as tall once (below), and the gesture claims its sequence so a click on the
chip cannot become the start of a row drag.
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 has a right-click menu in the same
compact row style as the app's own menu — the label at one left column, a shortcut flush right,
18 px rows — holding **Y axis: auto scale**, **Y axis: fixed range...**, **Fit X axis**, **Fit
all** (the `f` key's action, so it shows `F`), and **Remove signal**, which swaps the menu to a
second view — one entry per trace on that plot, plus **All traces on this plot** — with a **Back**
row to return (a plot with nothing on it says so instead). The removals are a view of their own
rather than a flat list because as a flat list they were the same long sentence repeated once per
trace, which is most of the menu spent on one verb; and they *replace* the menu instead of floating
beside it as a submenu, because a second popover is a second thing to dismiss and a `GtkMenuButton`
that owns one toggles itself out of sync as soon as anything else closes it — its row stopped
opening at all on the second click. 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=<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).
Three formats, picked by the extension the save dialog ends up with:
| file | what it is |
|---|---|
| `.npy` | one NumPy structured array, `time`/`abs_seconds` as `<f8` and every signal as `<f4` — the width the ring stores, so the file invents no precision and is about half an all-`f8` one. `pd.DataFrame(np.load(path))` is the whole reader. |
| `.csv` | the same table as text, `%.17g` for the times and `%.9g` for the values: the shortest widths that round-trip each type exactly. |
| `.mf3`, `.mdf` | **MDF 3.30**, one channel group with every channel record-interleaved, written by hand — see *The MDF export* below. |
Parquet (the original ask) would have meant linking Arrow — tens of megabytes of
dependency for a feature whose output a three-line reader already handles.
**The MDF export** is the format for handing a window to another tool, and specifically to
[ScryLab](https://docs.scrylab.de/docs/data-sources/), whose data-source contract is three
rules this file is shaped by: *every data channel becomes a signal* — the channel name is the
signal name — *the master channel of each channel group is the x axis*, and *the unit is taken
from the channel metadata*, which in MDF 3 means the channel's conversion block (`CCBLOCK`) and
nowhere else. A second occurrence of a name is dropped by that reader, so the channel names are
de-duplicated against each other and the master's.
Two things follow from that, and both differ from what the `.npy`/`.csv` table holds:
- **The master is `abs_seconds`, named `time`**, not the axis the plot showed. The relative
column is 0 at a different sample in each display mode, so a master off it would put a live
export and a held event's export on two different timelines — the one thing that column
exists to avoid. The relative axis goes out as an ordinary channel, **`time_relative`**,
right after the master, and both carry the unit `s`. The signal columns follow in the same
order the `.npy`/`.csv` file writes them.
- **The unit travels with a column**, taken from the stream's CONFIG (or a math channel's own),
and is written into the conversion block. The `.npy`/`.csv` writers are unchanged: their
readers get the names and the numbers, and their bytes are what they always were.
There is **no MDF writer library here and no new dependency**: the blocks are spelled out in
`src/export.cpp` against the field list the `asammdf` 8.8.27 reader walks the format with, and
`docs/claude/invariants-session-and-export.md` records the layout, the version decision (MDF
3.30, not MF4 — that is HDF5) and what was verified against a known-good reference file.
The table is collected when the menu item is clicked, not when the dialog is
answered: a live view keeps scrolling while the file chooser is up. The live cut goes
through `ScopeModel::live_window_locked()`, the same index range `publish_event()`
uses, so an export and a capture of the same window hold the same samples.
The export follows the display, not the trigger switch: it reads the snapshot only while
one is **held**, and the live ring otherwise — including mid-capture, where the rows are
on the live ring too. So exporting during a capture writes the partly filled window that
is on screen (`time` from `-pre` up to wherever the post side has reached), not the
previous event.
**The About dialog** (overflow menu) reports the running binary's version, the git commit it was
built from, the build timestamp, the settings directory and this session's layout file. Version,
commit and timestamp come from `src/build_info.cpp`, generated by CMake at configure time and
regenerated on every build — so the commit and the timestamp always belong to the binary that is
running, and `git` absent leaves the commit as `"unknown"` without breaking the build.
**The shortcut overview** (overflow menu, or Ctrl+/) lists all keyboard and mouse controls
in two groups — application actions and plot controls — and is an `AdwShortcutsDialog` rather
than GTK4's deprecated `GtkShortcutsWindow`. libadwaita ≥ 1.8 is therefore a hard dependency;
`CLAUDE.md` records the one consequence worth knowing before editing (the stylesheet it installs
reaches every widget). The full key table:
| key | action |
|---|---|
| `a` | arm / disarm the trigger |
| `Space` | pause / resume |
| `t` | show / hide the trigger panel |
| `c` | show / hide cursors |
| `m` | show / hide measurements |
| `g` | show / hide math channels |
| `l` | show / hide the layout editor |
| `s` | show / hide the side panel |
| `f` | fit to data (the plot's own key, not an application accelerator) |
| `Ctrl+E` | export visible range |
| `F1` | open the manual (`help:org.udpscope.app`) in the desktop help viewer |
| `Ctrl+/` | open the shortcut overview |
| `Ctrl+Q` | quit |
| `+` / `-` | zoom in / out (both axes) |
| `Ctrl+` / `Ctrl-` | zoom in / out (x axis only) |
| `Alt+` / `Alt-` | zoom in / out (y axis only) |
The wheel uses Shift = x / Ctrl = y, while the keyboard uses Ctrl = x / Alt = y. The two
cannot agree: `+` *is* Shift+`=` on common layouts, so a bare zoom-in already arrives with
`Mod::Shift` set, and using Shift for axis restriction would make x-only zoom unreachable from
the keyboard. This is a **deliberate deviation from the wheel's convention**, documented in the
shortcut overview itself.
The six "Plot mouse" rows (wheel, drag, right-click) carry no accelerator key — GTK's shortcut
syntax has no member for pointer gestures — and put the prose in the `subtitle` field instead,
which `AdwShortcutsItem` renders as free text.
**The manual** (the *Documentation* overflow-menu row, or `F1`) is a Mallard help set opened in
Yelp through the `help:` URI, *not* a scroll view compiled into the binary. `help/C/org.udpscope.app/`
holds `index.page`, one page per subject, and the annotated screenshots the pages embed; the document
identifier `org.udpscope.app` is the desktop file's own basename, so it is the `.desktop` name, the
GApplication id and the install directory at once. The set is installed by an `install()` rule to
`share/help/C/org.udpscope.app/` — the path the freedesktop Help System Specification's `help:` URI
resolves — and `show_help()` hands `help:org.udpscope.app` to whatever the desktop registered for the
scheme (Yelp on GNOME), reporting a launch failure with an alert rather than failing silently. That
alert says **which** thing is missing, because the two have different fixes: `help_installed_path()`
looks for the document under `$XDG_DATA_HOME/help/<lang>/org.udpscope.app/` then
`$XDG_DATA_DIRS/help/<lang>/org.udpscope.app/`, so with the set installed the alert names the missing
*viewer* (`yelp`), and without it the alert names the identifier, the expected installed path and the
install command — `cmake --build build --target install-help-user`, the no-root form below, which
writes the set under `$XDG_DATA_HOME` (default `~/.local/share/help/C/org.udpscope.app/`) and prints
the path it wrote. Two opt-in targets install the manual *alone*, without a
whole `cmake --install`: `install-help-user` writes that `$XDG_DATA_HOME` (default `~/.local/share`)
copy and needs no root, while
`cmake --build build --target install-help` writes to the configured `CMAKE_INSTALL_PREFIX` for a
packager or a system-wide user to wrap in `sudo`; both print where they wrote. A build tree has
neither the installed copy nor a
`help:` handler, and **either is enough to make `F1` do nothing**; that is the failure this names.
`cmake --build build --target test-help` checks the set the way `test-ui` checks the `.ui` files:
`yelp-check`'s offline subcommands (links, media, orphans, ids) and a `yelp-build html` render;
`yelp-check validate` is left out because it fetches the Mallard RelaxNG schema over the network.
`tools/make-help-shots.sh` regenerates the figures by driving the app headlessly against the mock
(GTK4 has to be forced to the Cairo renderer or X11 reads the surface back blank).
`UDPSCOPE_PROBE_HELP_MS` asserts the set is *present in the tree* — its
`[help] topics=… index=… figures=…` reading — rather than parsing pages, since they are files now
and not resources, and it **launches the manual too**: `[help] launch attempted=1 done=1 ok=1
error='' installed=/home/…/.local/share/help/C/org.udpscope.app` is the reading, `ok=1` meaning the
`help:` URI reached a registered handler and not that a window appeared (Yelp is another process and
cannot map one headlessly). Mallard is translatable with `itstool`, but only the `C` set is authored
here.
**The status bar is two tiers.** The bar itself shows one glance line: the activity badge, the
display state (one word from a fixed set — `connecting`, `live`, `paused`, `armed`, `capturing`,
`event held`, `disarmed`, none wider than ten characters), the stored rate and the window. Everything else —
packet and frame counts, gaps, buffer size and span, per-plot trace counts — is behind the ⓘ
button at the bar's right-hand end, opening a popover. The two moved there because a bar that
carried all of them was too wide to read while watching a signal, and because the bar sits
directly above the plot: a field whose width tracked the stream would relayout the plot on every
update. The rate field uses `%.3g` (`10 kS/s`, never `10.0 kS/s`), and the elapsed-time field
steps to a coarser unit rather than growing digits.
The exit dump still prints the full detail string (`mode with trigger on:` / `mode after
go-live:`) because those strings come from `format_detail()` — the same path the popover
reads — not from the label, which no longer carries those fields.
## Review findings not (yet) implemented
These are known deviations, found by a spec/code review, that are deliberate for
now — they are listed so they are not mistaken for anything else:
- **There is one live buffer, not two.** The spec's second buffer is served by the
latched `EventSnapshot` — an immutable copy of the captured window, which is
exactly what "the buffer the view is frozen on" needs to be. Two live rings would
also mean acquisition had to follow the trigger state, which is what left the first
latch after arming with no pre-trigger history. What a second ring would buy is the
publish: freezing a whole `BufferStore` copies nothing and inherits the pyramid the
producer already maintains, so both figures in the dump's `publish cost:` line would
go to zero. They are worth knowing before pricing that — at 1.3 MS/s an event publish
measured **69–115 ms of blocked UI tick** and a pause publish **22–86 ms of blocked
producer** — but they do not pay for a second budget's worth of memory, a second
per-sample write on the producer, and a single frozen slot where the app today holds
an event, a pause and an export's window at once. The *observable* cost is also
smaller than the stall: an A/B with and without a pause read `gaps=0` both ways, so
what a second ring would buy is a smoother frame at a latch, not recovered packets.
- **The buffer is allocated at the first CONFIG, not at initialisation.**
The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-mb` is the
*budget*, and the power-of-two ring rounding means the final
allocation lands in `(budget/2, budget]` rather than exactly on it. Every CONFIG,
enable/disable and calibration re-partitions (and drops the history) because the
buffer layout *is* the channel list.
- **Channels are never decimated against each other.** The spec's
`window * rate > 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.
- **The sidebar is two tabs, and a math channel is a row of the signal list.** `SPECS.md`
names the panes as separate collapsible sections — trigger, cursors, measurements, math,
signal list — in one scrolling column; with four of them open in a 320 px sidebar every
section was short of room, and finding one meant scrolling past the others. The built
sidebar is a **Measure** tab (the cursors readout and the measurements) and a **Signals**
tab (the trigger and the channels), **each a text tab** (the sidebar was icon-only for a while; glyphs for "cursors and ruler" and
"trigger and channel list" were not understandable enough, so the words won). A source kind's own tabs
(the E-1608's **Analog out** and **Digital I/O**) sit beside them as text too. Both panes also have a **locked** mode, which is what a returning user gets: the
trigger pane shows one line saying what it is set to, the signal list shows only the signals that are
on, and neither offers a control that changes the configuration — each heading has an `[edit]` button
to switch to the full pane, and back. Dragging a signal onto a plot still works while locked — filling
plots is what the list is for, not configuration — and a locked row can be dragged from anywhere on it,
the colour chip included. In a window too narrow to show both, where the sidebar is *overlaid on* the
plot, the sidebar steps aside for the length of the drag so there is something under the pointer to drop
onto. A **first run** (no session file for that source) comes up
unlocked, because configuring the host is what a first run is for. Each pane's heading also carries its **own switch**, the same control as the
header bar's button — clicking either moves both — because a pane that is *disabled* while its
feature is off is a pane whose controls you would otherwise have to leave to turn it back on. The
switcher's policy is `wide` and each page has a title and no icon (the switcher's own placeholder icon is hidden). With the math
list folded into the signal list under a `MATH` divider whose `+` creates a channel — a derived channel already *is* a channel in
the model, so the two lists were the same list drawn twice. A tab is one-of-*N*, so no
toggle hides a pane any more: cursors-off **disables** its readout and leaves it in place, and
trigger-off leaves the trigger panel fully editable — a trigger is configured *before* it is
switched on, and the locked view says `stream (trigger off)` instead of the settings — and the measurements' and math panes' toolbar toggles are gone with the panes.
- **A plot holds at most eight 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.** 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. The switch keeps the recorded history,
because the arrival clock is anchored to continue from the monotonic mark and the two
timelines therefore share a zero (see "Switching to the arrival clock does not drop
history" above); it used to empty the rings, which is a worse reading of the same
concern. 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 <group> [--interface <local ip>]` 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.
**A link loss does not cost the acquisition.** When the stream comes back and its CONFIG
names the same signals as before — the common case, because whatever knocked the link out
did not reconfigure the producer — the reconnect keeps the buffer, its history, the
trigger's state and any held event, and the outage appears as the gap in the data that it
was. Only a CONFIG that actually *changes* the stream re-plans the buffer, which is the
one case where dropping history is honest. The mock can reproduce the loss itself:
`tools/mock_udpstreamer.py --blackout-at 5 --blackout 3.5` goes silent past the client's
silence timeout and then resumes, which is the EMI case end to end.
## Limitations
- **A remote display needs the software renderer, and the plot is empty until a signal is
enabled.** Over SSH with X11 forwarding there is no usable Vulkan device: GTK cannot create a
swapchain (`vkCreateSwapchainKHR … VK_ERROR_INITIALIZATION_FAILED`) and the window never appears
even though the stream connects and its CONFIG parses. `--renderer cairo` selects the software
preset for **both** halves — GTK's renderer and the plot's own GL context, which is a
`GtkGLArea` and asks for one regardless of what GSK draws with — and
`tools/udpscope-remote.sh` runs the app against a virtual X server with a VNC view carried over
an SSH tunnel, avoiding X11 forwarding altogether. If a window does appear but stays empty, check
that a **signal is enabled**: signals start disabled, so a session with none switched on plots
nothing and prints no `buffer allocated:` line, by design rather than as a fault.
- **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.
- **The contextual help has no "?" on the export row.** Nine surfaces carry one;
export is a *menu action* and has no surface of its own to carry anything, and the
obvious alternative -- a "?" on the row inside the menu's popover, opening a
popover of its own -- is the nested popover the invariants already warn about: the
inner popover takes the grab, the menu closes under the click, and the bubble is
left parented to a row in a hidden popover. Export is described in the
Documentation instead.
- **Over SSH with X11 forwarding the window may never paint or allocate, and the cure is
`--renderer cairo`.** There are two walls, in order.
GTK4 defaults to Vulkan and a forwarded display has no usable Vulkan device, so the
log shows `vkCreateSwapchainKHR(): Initialization of an object could not be completed
... (VK_ERROR_INITIALIZATION_FAILED)` and nothing is drawn. `GSK_RENDERER` alone gets
past that, but the plot is a `GtkGLArea` and asks GDK for a GL context of its own
whatever GSK draws with; over forwarding there is no hardware one, and the window then
stays unallocated — `header: window_button_w=0` with a zero `bar_w`, and no
`[width-probe]` line (it prints only once the plot has a viewport wider than 100 px) —
while the stream side is fine. Absence of `buffer allocated:` is *not* part of that
reading: signals arrive disabled, so a start with nothing enabled plans no buffer and
prints no line even on a healthy display, and the plot stays empty until a signal's
switch is turned on.
`--renderer cairo` is the preset for exactly this display: it exports
`GSK_RENDERER=cairo` **and** `LIBGL_ALWAYS_SOFTWARE=1` (llvmpipe answers the GL area),
the same pair `tools/make-help-shots.sh` uses. `--renderer gl` is the faster
alternative when indirect GL works. The variables are exported in `main()` **before**
`gtk_init()`/`adw_init()`, which is the whole trap: they are read when the renderer and
the GL context are created, so setting them later is a silent no-op. Without the option
nothing is set and GTK's own choice stands. No `GDK_BACKEND` companion exists on
purpose: an X11-forwarded session carries `DISPLAY` and no `WAYLAND_DISPLAY`, so GDK
already selects X11; forcing it would override a working local Wayland session for no
gain (if both are set and Wayland is broken, export `GDK_BACKEND=x11` yourself).
- **The tour card can cover its own target, and in the documented headless run it
does.** The card is placed under, over or beside the hole whenever one of those has
room; when the target covers the whole content -- which is what the plot does at the
window's 756 px minimum, because the split view overlays the side panel rather than
putting it beside the plot -- no placement can avoid it, and the card takes the
bottom corner. The probe prints both rectangles instead of asserting a placement,
so the case reads as a measurement.
**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.