Three defects, all of them a window describing data that is not there. A math edit refilled the derived ring with `clear()`, which renumbers it from 0, leaving `produced()` short by the inputs' retained base. Since `live_window_locked()` clamps against every channel's `produced()`, that one lagging channel answered `n = 0` for the live view, every pause view and every trigger capture until a whole ring of fresh data had gone by -- the reported freeze, invisible until the ring first wraps, hence its intermittence. It now restarts with `clear_to(base)`, and `ChannelBuffer::republish_from(base)` puts the published window back: `acquire()` is incremental, so a rewrite in place published nothing and the pyramid went on describing the samples the edit replaced. The trigger's channel is an index, re-resolved by name after a renumbering -- but only in `rebuild_enabled_locked()`, so a math add or delete went through `set_math_channels()` with no restore, the index was clamped to -1, and `process()` returned at once while the panel still named the channel. The restore moves into `rebuild_channels()`, which *is* the renumbering, so no caller can forget it. And `update_measures()` suppressed its "the window moved" test while an event was held -- the one mode where a gesture is the only thing that can move it. Zooming a latched event left the pane describing the whole captured event. A pinned window needs no exemption: it lands on the range it already measured and fails the 2 % test by itself. The checks that missed all three: `UDPSCOPE_PROBE_MATHWRAP_MS` edits a graph whose ring has already wrapped, which `math_probe` structurally cannot reach; `[measure-trace]` now prints the mode, the window on screen and the window the numbers describe; and the dump's `marks_in_window` counts against the window on screen instead of against the one the marks were computed over, where it was a tautology. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2804 lines
203 KiB
Markdown
2804 lines
203 KiB
Markdown
# 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),
|
||
`--capture-packets N` (see "Packet capture" 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 `udps_client_t` and calls
|
||
`udps_client_poll()`; the GTK main thread owns every widget and the GL contexts.
|
||
They share `ScopeModel` under one mutex, which the producer takes once per DATA
|
||
frame and the GUI thread takes once per frame around binding the traces.
|
||
Samples cross the boundary through `plot::data::RingBuffer` (single producer,
|
||
single consumer), never through the lock.
|
||
|
||
## The x axis is relative to now
|
||
|
||
**A live axis is fixed at `[-window, 0]`, with the newest sample at 0.** That is what a bench
|
||
scope in roll mode shows and what the window control's figure means: a 2 s window reads `-2s` to
|
||
`0` and keeps reading that for as long as the stream runs, with the trace flowing right to left
|
||
under it. The axis used to carry the stream's own clock, so the labels counted the app's uptime
|
||
upwards (`20:00`, `20:01`, …) and the one number a user actually wants — how long ago a feature
|
||
happened — had to be worked out by subtraction.
|
||
|
||
Every mode now expresses x as *seconds from the thing the view is about*, and the rule is one
|
||
line: `display_x = stream_t - ScopeModel::stream_at_x0()`, where `stream_at_x0()` is the stream
|
||
time of display x = 0.
|
||
|
||
| display mode | x = 0 is | `stream_at_x0()` |
|
||
| --- | --- | --- |
|
||
| `Live`, `Waiting` | the newest acquired sample | the newest stamp |
|
||
| `Capturing` | the accepted edge | the edge |
|
||
| `Held` | the event's edge | the snapshot's `x_offset` |
|
||
| `Paused` | the instant of the freeze | the snapshot's `x_offset` |
|
||
|
||
The live answer is written by the frame tick **after** `bind()` and `acquire()`, because it has to
|
||
be minus the newest sample the *traces* can report: asking the model before they are bound puts
|
||
the newest data a frame's production to the right of the axis, which at 1 MS/s over a 50 ms window
|
||
is a third of the screen. It converges in exactly one frame and cannot oscillate — `live_edge()`
|
||
reports in the epoch currently in force, so `new = old - live_edge()` is `-raw_newest` whatever the
|
||
old value was.
|
||
|
||
A snapshot carries its own offset (`EventSnapshot::x_offset`) rather than leaning on the model's
|
||
epoch, because its coordinates outlive the epoch that produced them. One consequence is a reversal
|
||
of an earlier deliberate choice: **a pause snapshot is now rebased** like an event's. Leaving its
|
||
stamps on the stream's clock would have thrown the frozen picture a whole uptime to the right of an
|
||
axis that is now newest-relative. `x_offset` keeps the stream clock recoverable, which is what the
|
||
export's second column reads — and it fixed a latent bug on the way, the export's snapshot branch
|
||
having added `trigger_time()` to a *paused* view's stamps: an unrelated edge, or none at all before
|
||
the first latch.
|
||
|
||
**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 exactly the visible range, at the stored rate**: the
|
||
same cut the export makes, so a measurement describes the data on screen rather than the ring
|
||
behind it — and the marks it draws belong to that window, so they stay in the viewport.
|
||
|
||
They are recomputed when the **window moves**, not on a timer: a timer decouples the two, and
|
||
with a scrolling window the stored marks then describe a range the plot has already left (the
|
||
min/max marker ends up outside the viewport, and a dragged marker survives until the next
|
||
tick). The trigger is 2 % of the span, so the numbers track the window within a few pixels
|
||
while the cost stays bounded by the window's motion rather than by the frame rate; one pass
|
||
copies the visible samples of each *channel* once, however many measurements read it.
|
||
|
||
"The window moved" is asked in **every** mode, including a held trigger event — which is the
|
||
one mode where a zoom or a pan is the only thing that can move it. Held views were once
|
||
exempted, on the grounds that a pinned window must not recompute every frame, and the result
|
||
was a pane that kept describing the whole captured event while you zoomed into a slice of it.
|
||
A pinned window needs no exemption: it lands on exactly the range it already measured and
|
||
fails the 2 % test by itself.
|
||
|
||
**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: they change which samples are in view, which is
|
||
exactly what a statistic over many windows is meant to average. 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.
|
||
|
||
The frame that **frames** a snapshot is the one that consumes its sequence number, not
|
||
merely the first frame to see it. The tick reads `display_mode()` before it takes the
|
||
model's mutex, while the producer publishes the snapshot inside that mutex and only lowers
|
||
`trigger_pending` at the end of its packet — so a tick can read `Capturing`, wait out the
|
||
publish on the lock, and arrive at the framing step bound to a capture that no longer
|
||
exists. Consuming the number there dropped the pin for good: the axis kept the capture's
|
||
*requested* `[-pre, +post]` while the snapshot was shorter, and the "an event is held" flag
|
||
stayed false, so `clamp_to_data()` was free to drag the frozen view off it. The window was
|
||
visibly wrong only at high rates, where the publish is long enough to lose that race often
|
||
(`axis=[-1..1]` against `snap=[-1..0.27]` in the disarm probe at 1.3 MS/s, correct at
|
||
10 kS/s). Leaving the number unconsumed costs nothing: the pin is owed until a frame is
|
||
bound to the snapshot, and by then it is still the newest one.
|
||
|
||
Mid-capture the live ring is read on the *event's* clock: `display_epoch()` is set to
|
||
minus the edge, so `x = 0` is the edge for the live channels exactly as it is in a
|
||
snapshot's own stamps. That re-basing machinery already existed (it was written for the
|
||
frozen view and then never needed, since snapshots carry edge-relative stamps); this is
|
||
what finally uses it.
|
||
|
||
**The whole trigger path measures the window in stream seconds, and never in
|
||
samples.** `TriggerEngine` is fed each sample's timestamp and latches once that clock
|
||
has advanced past the post window; `publish_event()` then finds the slice covering
|
||
`[edge - pre, edge + post]` by binary search over the time ring. The two are the same
|
||
clock the x axis reads, so a capture covers the span that was asked for by
|
||
construction.
|
||
|
||
It did not used to. The engine took a post-trigger window in *samples*, converted
|
||
from seconds by a measured estimate of the trigger channel's cadence, and that
|
||
estimate is what made captures wrong — most visibly as the reported defect *"only the
|
||
pre window is filled, the post window is completely empty"*. Any stream whose time
|
||
signal advances in large steps (one sample per stream second, say) drove the estimate
|
||
to its 1 S/s floor; the sample count then came out as 1, which the engine read as "no
|
||
post window requested" and latched on the edge sample itself. The event held a single
|
||
sample, its span was zero, and the UI fell back to pinning a round `[-1, +1]` axis
|
||
that nothing filled past x = 0 — an axis that looked right, which is why the defect
|
||
read as a drawing bug rather than a capture bug. Removing the conversion removes the
|
||
whole failure class: there is no longer a rate estimate to be wrong.
|
||
|
||
An edge is refused until the buffer reaches back a full pre-trigger window behind it,
|
||
so a capture cannot open on a half-filled pre window. The gate compares the oldest
|
||
buffered stamp against the edge rather than timing how long the trigger has been
|
||
armed: the ring fills whether or not the trigger is on, so the history is normally
|
||
already there and waiting would delay the first event for nothing. It only bites just
|
||
after a rebuild — a CONFIG, enabling the signal, or a calibration change all drop the
|
||
history — which is exactly when the first event used to latch on an almost empty pre
|
||
window and draw half a plot.
|
||
|
||
**The 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.
|
||
|
||
**Zooming into a held event clipped it further and further from the left**, and the cause was the
|
||
one the library's own margin fix is about, in the half of the code the fix could not reach.
|
||
`EventSeries::neighbours()` — the call the renderer maps the visible range onto sample indices with
|
||
— answered from the nominal `(x - x0()) / dx()` grid, while `ChannelBuffer::neighbours()` next to
|
||
it binary-searches the real time ring. `dx()` on a snapshot is the *mean* spacing of its stamps and
|
||
the stamps are an arrival clock, so the grid and the clock drift apart by however much the clock
|
||
wandered over the capture; the renderer was handed an index window offset from the axis by that
|
||
drift. Because the offset is a constant number of *seconds*, its share of the screen doubles with
|
||
every zoom step: a 2 s capture off by 8 ms is invisible, and seven halvings later it is 374 px of
|
||
blank on a 716 px axis, with the trace's ink pushed off the right-hand edge by the same amount.
|
||
The crosshair reads `x_at()` — a real stamp — so it went on reporting the right value over the
|
||
blank part, which is exactly how the report described it. `neighbours()` is a binary search over
|
||
`stamps` now, like every other series here. A perfectly uniform source hides the defect
|
||
completely, so the mock can jitter its clock (`--clock-jitter`, applied to each packet's own
|
||
spacing so the stream stays monotonic) and `[zoom-probe]`'s `gap_px` is the guard.
|
||
|
||
**A pinned window is not a navigation surface.** In trigger mode the x window is the event —
|
||
`[-pre, +post]` while a capture fills, the snapshot's own span once it is held — and a gesture
|
||
must not move it. Nothing was pulling a drag back: `clamp_to_data()` is the live-view half of
|
||
the rule above and is deliberately skipped while a capture or an event is displayed, and the
|
||
library's own pan bound lives in the *following* path (a `Live` group's offset is clamped
|
||
against what its members retain), so a group with the follow off had no bound at all. A drag or
|
||
a wheel zoom therefore walked the frozen capture off its axis, leaving an empty plot over data
|
||
the scope never had. The pin is now re-stated every frame while it is pinned, in the same
|
||
branch that decides `clamp_to_data()` is not to run, which makes a pan or a zoom in trigger mode
|
||
(and on a paused view, whose frozen window is held the same way) a no-op. Leaving trigger mode,
|
||
or resuming, is what makes the view navigable again. `UDPSCOPE_PROBE_PAN_MS` is the guard: it
|
||
writes the range a gesture writes and reports `held=1` when the axis ignored it.
|
||
|
||
**Four measurements cost a third of the run's frames.** A measurement is a statistic of the
|
||
visible window, so it was recomputed whenever that window moved — which in a live view is every
|
||
frame, at O(visible samples) *per measurement*. Adding a `top` measurement to a 1.3 MS/s stream
|
||
whose window held 4.7M samples took the frame tick's mean from **0.41 ms to 13.66 ms** and the
|
||
run from 631 frames to 407. Three separate costs, found by instrumenting the pane rather than by
|
||
reading the frame mean (`measure cost: N recomputes, X ms total, Y ms each` in the dump, next to
|
||
the values):
|
||
|
||
1. *The pane was recomputed per frame.* It is a readout, so it now follows the rule the status
|
||
line's own fields already had: at most every 100 ms while the window scrolls, one pass the
|
||
moment it settles, one per mode change — and the interval follows the *work*, stretching to
|
||
400 ms when a pass costs more than 5 ms, so what stays bounded is the duty cycle rather than
|
||
the period. (Getting the "settled" case backwards is what made it *worse* first: 457
|
||
recomputes in one run, because a *pinned* window then recomputed on every frame.)
|
||
2. *Both window vectors were copied per measurement.* Four measurements over a 4.7M-sample
|
||
window is 300 MB of memcpy per frame — memory-bandwidth bound. The loop holds the cached
|
||
window by reference now, and it is materialized in bounded chunks instead of through two
|
||
whole-window `float` temporaries.
|
||
3. *Every type rescanned the window.* Average, min, max and the two new types are all derived
|
||
from one shared pass — both extremes with their first positions, the sum and the count — so
|
||
several measurements on one signal share a single scan and adding a type adds no pass.
|
||
|
||
Measured on the same stream and window: tick mean **1.39 ms** with the pane holding four
|
||
measurements against **0.73 ms** without (595 frames against 610), the pass itself 11 ms every
|
||
400 ms — and the 82 ms worst tick is the *control's* too, i.e. the publish stall, not this.
|
||
|
||
**"Show on plot" draws full-width rulers, and they must not blink.** A level measurement draws an
|
||
`h_ruler` across the whole plot and a time one a full-height cursor, so a pane holding four
|
||
measurements can put six lines over the data — that is the feature, and the lines move with the
|
||
window. Because the pane is now refreshed on a cadence, the marks were re-placed from a result
|
||
that could be a pass old; worse, a pass landing on an empty window (a rebuild, a capture
|
||
boundary, a mode change) emitted no marks at all, so the rulers vanished and returned a frame
|
||
later. The fallback is by *kind*: an invalid pass keeps the last valid pass' **level** marks — a
|
||
level is a property of the signal and outlives the window it was measured in — and drops its
|
||
**time** marks, which are positions inside the window that result described. `marks=0` with
|
||
measurements on and data arriving is now the assertion (`[measure-trace]` in a probe run), and it
|
||
never occurs.
|
||
|
||
Their stroke is the app's own setting, not the library default: **0.75 px and dashed 4/4**
|
||
(`set_cursor_stroke()`), so a measurement's mark reads as an annotation over the data rather than
|
||
as another instrument framing the plot. The trigger marker keeps the solid `line_width_px` stroke
|
||
on purpose — it marks an event the scope acted on, which is a different claim from a measured
|
||
level. The library's own test is `a_cursor_stroke_can_be_thinner_and_dashed`.
|
||
|
||
Two related guards came out of the same hunt: `clamp_to_data()` compares against the window with
|
||
a relative tolerance (clamping to *exactly* `window_s_` can leave the group's span an ULP over,
|
||
and a clamp that fires every frame is a re-frame loop — the dump counts it as `clamp fires:`), and
|
||
the sample-window walk it does is unchanged.
|
||
|
||
**Amplitude and peak to peak** 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 pill with its own glyph and no text** — the
|
||
cursors and the ruler for Measure, a sine crossing its trigger level and the channel list
|
||
for Signals (the same picture the toolbar's trigger button carries), drawn as a
|
||
32x16 pair so both motifs are legible at a glance — with the words kept as each pill's tooltip and
|
||
accessible name. 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
|
||
design spec had the switcher showing *titles* and neither page an
|
||
`icon-name`; `narrow` alone does not do it (libadwaita still draws a label beside the icon,
|
||
measured), so the pages carry an empty title and the pills are named in code. 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
|
||
|
||
- **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.
|
||
- **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.
|