diff --git a/CLAUDE.md b/CLAUDE.md index a6c2350..41e5188 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,1966 +2,62 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Build and run +## What this project is -```sh -git submodule update --init --recursive # deps/plotter is a submodule -cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -cmake --build build +udpscope is a **GTK4 oscilloscope-style viewer for a UDPStreamer source**. It receives one +UDP stream with `deps/udpsclient`, buffers it in a large ring (default 200 MB), and draws it +with `deps/plotter` — a figure of up to eight stacked rows sharing one x window. On top of +the live view it provides the things a bench scope provides: a trigger with a pre/post event +window, a pause that freezes the visible window, cursors, statistical measurements, +user-defined math channels built in a node editor, and an export of the visible window to +`.npy`/`.csv`. -./build/udpscope --server 127.0.0.1 --port 8000 -./build/udpscope --server 10.0.0.5 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9 -``` +Two documents bound the work and are **not** interchangeable with this one: -The app can be launched from the shell rather than only from a terminal, but only on request: +- **`SPECS.md`** is the requirement document — what was asked for. +- **`README.md`** documents what was actually *built*, including deliberate deviations from + the spec and known limitations. **Read it before concluding something is a bug.** -```sh -cmake --build build --target desktop-entry -``` +Three things shape almost every change here, and the linked files expand on each: -That writes `org.udpscope.app.desktop` plus both app icons under `$XDG_DATA_HOME` (default -`~/.local/share`), runs `desktop-file-validate` as a **hard failure** (when it is on `PATH`; -absent, it says so and installs anyway), refreshes `update-desktop-database` and -`gtk4-update-icon-cache` when they are on `PATH`, and prints the three paths it wrote — which is -the uninstall instructions, since there is no uninstall target. `cmake --build build` alone never -touches `$XDG_DATA_HOME`. +- **There are exactly two threads** — a producer that owns the UDP client and the GTK main + thread that owns every widget and the GL context — and they share `ScopeModel` under one + mutex. Samples cross through lock-free rings, never through the lock. +- **There is no unit test suite.** The only automated check is the application's own headless + self-check, driven by environment variables under `xvfb-run` against a mock source. Extend + the probe/dump in `src/ui.cpp` rather than adding a separate harness. +- **Every source file carries an SPDX MIT header**; match it in new files. -The entry's `Exec` is the built binary's **absolute path in the build tree**, so **moving or -deleting the build directory breaks it.** That is the price of having no install step and it is -deliberate: the entry then always launches exactly the binary that was last built. 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. +## Table of contents -Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per -buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row -count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards). +The detail lives in `docs/claude/`. Read the files that touch the area you are editing +*before* you edit it — most of their contents are rules that cost a defect to learn. -Run with no `--server` and udpscope asks which source to open: the sessions it has stored, plus a -form for a new host. Every probe below passes `--server`, so none of them sees it. +### Working in the repository -`deps/plotter` is built from source with only the GTK4 adapter enabled -(`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a -vendored C99 source pair compiled into a static lib. +| File | What it covers | +| --- | --- | +| [build-and-run.md](docs/claude/build-and-run.md) | Configuring and building, the desktop-entry target, the command-line options, and the two vendored dependencies (`deps/plotter`, `deps/udpsclient`) including what was changed upstream and why. | +| [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly five build warnings; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. | +| [probe-readings.md](docs/claude/probe-readings.md) | The per-line reference for the self-check's output: what every probe and dump line means, the value it must read, and the defect a wrong reading names. | -**libadwaita ≥ 1.8 is a system dependency** (`pkg_check_modules(ADWAITA ... libadwaita-1>=1.8)`), -for two widgets: the shortcut overview (`AdwShortcutsDialog`) and the About dialog -(`AdwAboutDialog`). GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and -`AdwShortcutsDialog` is its replacement; `GtkAboutDialog` still exists but -`adw_about_window_new()` is deprecated in favour of `AdwAboutDialog`, which shows the -application icon as the page's own header. Two consequences worth knowing before editing. -`adw_init()` is called — in `run_ui()` and, since the chooser runs first and must be styled -the same, in `choose_source()` in place of its `gtk_init()` (both are idempotent) — so -**the whole app is drawn with libadwaita's stylesheet**, not GTK's: a metric change there -reaches every widget, and `layout over N frames` / `axes.w changes` is what says whether -the layout still settles. And an `AdwDialog` is **not** a `GtkWindow`: both `show_about()` -and `show_shortcuts()` keep an `AdwDialog*`, cleared on `::closed` (there is no -`::close-request`) and closed with `adw_dialog_close()`. They share one `on_dialog_closed()` -handler keyed by a pointer-to-pointer (`gpointer slot` cast to `AdwDialog**`), so one -handler clears either pointer without knowing which dialog it is. Presenting either on this -app's plain `GtkApplicationWindow` is supported and warning-free; it was checked. +### How the application is put together -`deps/plotter` is upstream's own repository and this app's figure support was added -there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the -common left gutter), `Figure::set_right_gutter()` / `Plot::set_right_gutter()` (an empty -band on the *other* side, which the layout had no concept of — `axes` reached the -viewport's right edge, so the rightmost x tick label was centred on a line it had half of -itself outside of; the guard is `figure_rows_share_one_right_gutter`), and in the GTK4 -adapter `PlotArea`'s **figure mode** — -`set_figure()`, one `input::Controller` per row, nearest-row event routing, a -figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over. - -The render-quality work went there for the same reason: `kTextFS` now **filters** the -glyph atlas (a 2×2 bilinear clamped to the instance's own cell) instead of point-sampling -it, and `LineProgram::set_snap()` / `u_snap` rounds an axis-aligned segment's ink onto -whole pixels for the chrome that frames a plot (grid and axis lines), never for anything -drawn at a data position. `generate_ticks()` also stores the tick at the origin as -exactly `0.0` — the walk starts at `ceil(min/stride)*stride` and so never lands on zero -when the stride is not a power of two, which labelled it `-139a`. `format_time()` picks its -unit rung by the **rounded** mantissa (`t < u.scale * 0.9995`, and the same slack on the -whole-second branch) — the `>= 999.5` spill guard `format_si()` in the same file always had — -because a tick a hair under a rung's top rounds to 1000 at three significant figures and -`%.3g` writes that `1e+03`, which is how a one-second tick read `1e+3ms`. All four carry -upstream tests — `the_atlas_is_filtered_not_point_sampled`, -`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero` and -`format_time_never_lets_a_field_reach_its_radix`. The minor -grid's look is set there too, not here: `grid_minor` carries 50% alpha, `grid_minor_width` is -0.75 (sub-pixel, expressed as coverage at 1x) and `grid_minor_dash_on`/`_off` are 3/3 — a -fragment discard, no extra geometry — so the subdivisions read as texture under the labelled -grid instead of as a second set of ticks (`the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major`). -README records what they cured. - -Changing any of this means committing in the submodule and moving its pin, and running -the library's own suite as well as the udpscope probes below: - -```sh -cmake --build deps/plotter/build/debug -xvfb-run -a deps/plotter/build/debug/tests/plot_tests # expect "0 failed" -``` - -## Verification - -There is no unit test suite. The only automated check is the app's own headless -self-check, driven by env vars and run under a display (`xvfb-run`, llvmpipe) against -a live UDPStreamer source: - -```sh -xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ - ./build/udpscope --server 127.0.0.1 --port 8000 -``` - -Two baselines every run is read against. A clean build emits **five** warnings and no more: two -`-Wmissing-field-initializers` on `MathNode::source`, one unused `output_built`, and -`-Woverlength-strings` on **both** generated GResource `.c` files — the help pages' and the -icons', whose blob crossed C99's 4095-byte string-literal limit when the two app icons joined the -four action ones (a note about a generated file, which GCC itself supports). And an exit-dump run -emits **no** `Gtk-CRITICAL` at all. A sixth warning, or any critical, is a finding. - -That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`, -from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump -runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only -the lucky reading of it. A run that had built and destroyed other windows first — -`UDPSCOPE_PROBE_HELP_MS`, which opens three — has had that memory reused, and the same two calls -then **SIGSEGV in the middle of the dump**, which reads as a truncated dump rather than as a -crash in a debug line. Every size the dump prints is therefore captured on the *tick*, while the -window is alive (`App::dbg_*`), and **no `gtk_widget_*` call may appear in the dump** — the -`panel controls:` line was already written that way for the same reason, and that comment is the -precedent, not an exception. - -- `UDPSCOPE_EXIT_AFTER_MS` — run the trigger-on → go-live probe, close the window, - then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted - frames, trace bindings, panel sizes, status line in both states). -- `UDPSCOPE_ENABLE_ALL_MS`, `UDPSCOPE_ARM_TRIGGER_MS`, `UDPSCOPE_PROBE_DISARM_MS`, - `UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only - *starts* its sequence: it switches the trigger on and then polls for a capture to open, - so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall. -- `UDPSCOPE_PROBE_FIELDS_MS` — change the trigger panel's edge and Normal/Single - dropdowns. As much a crash test as a state check (see the GTK signal-signature invariant - below), so time it inside a capture: `[fields-probe] ... capturing=1` followed by no - SIGSEGV *is* the assertion. -- `UDPSCOPE_PROBE_WINDOW_MS` — drive the window control every way a user can: type a - number, change the unit under it, step a decade either way. -- `UDPSCOPE_PROBE_LAYOUT_MS` — drive the layout editor: switch it on, insert above the - first row, remove a row, append one at the bottom, switch it off. Its steps are 200 ms - apart on purpose — the buttons are placed from the rects the *last frame* was laid out - with, so a synchronous sequence would read every position one action stale. It clicks by - emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its - 250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed. -- `[measure-probe] N measurements on 'X'` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one - measurement of each statistical type (average, top, peak to peak, amplitude) on the first - *channel* and forces a recompute. It must name a **channel**, not `signals().front()`: the - first signal of a stream is its timestamp, which is not acquired, and naming it adds four - measurements that all read `--` *and* makes a performance run look free (each bails before - collecting anything -- that is how the first baseline of the stall below read 0.4 ms). - The values are in the exit dump as `measurements: =`; on the fast mock - (`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`) a unit sine must read - `peak to peak=2`, `amplitude=1`, `average=0` and **`top=0.636620`** -- 2/pi, the half-wave - mean of a sine, which is the check that the threshold types are not merely plausible. - `[measure-trace] view=[..] =... marks=N level:V/time:V...` is a repeating line - (250 ms) from the probe: the pane's numbers beside the *current* window and the marks actually - placed, which is the only way a stale result or a mark that should not be drawn shows up as a - number. It must never print `marks=0` while measurements with `show_on_plot` are on and the - data is there -- that was the rulers blinking off at a mode change. - `measure cost: N recomputes, X ms total, Y ms each, data D ms (P%), covered=C buckets=B - raw=R (F%), N ns/covered sample` follows it: **read it against a run with - `UDPSCOPE_PROBE_MEASURE_MS` unset**, because a frame mean cannot attribute a cost. The last - three fields are the assertion, and they are a *ratio*, not a time: `covered` is the samples - the window spans, `buckets` the LOD buckets the two walks answered from, and `raw` the samples - they still had to read. **`raw` must stay at a fraction of a percent of `covered`** on a clean - waveform over a window of millions -- 0.1% is what the pyramid-backed walks give (see the - measurement-cost invariant below). A `raw` near `covered` means the walks went back to reading - the window, which at 1 MS/s is 19.7 ms per recompute and a third of the run's frames. It is - *legitimately* higher for a window shorter than a bucket (64 samples) or a signal noisy - enough to cross a threshold everywhere, so read it on the fast mock - (`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), where a window is ~2 M samples. - `--noise 0.1` there reads **2.0%** at 1.96 ms a recompute against the clean 0.1% at 1.41 ms, and - that is the interferer's own cost rather than a regression -- the figure to compare a change - against is the **clean** one. - Note the **final** `measurements:` line and `[measure-trace]`'s last lines may read `--`: - `UDPSCOPE_EXIT_AFTER_MS` arms the trigger before it dumps, so the dump can land in `Waiting` - with nothing bound and the axis at its default. The race-free reading is an earlier - `[measure-trace]` line, which carries its own `view=`; those lines are also where the - level-mark fallback is visible (an invalid pass must still print its `level:` marks). - -- `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then - `[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L` - (stderr, `UDPSCOPE_PROBE_PAN_MS`) — **`held` must be 1**: a gesture must not move a window - that is pinned (trigger mode, or a paused view). The probe writes the panned range the way a - gesture does — on the *member* axis, which `Figure::layout()` adopts into the group — and - reports the axis the frame after it produced. Time it *inside* one pinned state: a pan during - a capture whose check lands after the capture has ended is re-framed by the mode change, not - by the pin, and reads `held=1` with the hold removed (that is how the first version of this - probe fooled me). The pause probe makes a stable pin, so run them together: - `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_PAN_MS=2200` puts the pan and its check inside - the pause. With `hold_pinned_window()` disabled it reads `held=0` and the axis sits at the - asked range. It then asks for a **zoom-in** (a window a quarter of what is on screen) and - reports `group=`/`member=` beside it: **`allowed` must be 1, with both equal to the ask** -- - that is the single-trigger-mode case, where the wheel has to keep working, and where reading - the wrong one of the two made a working clamp look broken. All three verdicts are read from the - *tick*, one frame after the ask: a `g_timeout` was the first version and it drifted past the - paused/resumed boundary under load. The probe then asks for a **zoom-out** ten times the current span and reports - the span the next frame produced: **`limited` must be 1**, i.e. `span <= window`. Run it with - a ring that holds *more* than the window (`--buffer-mb 64` past the window's own seconds) — - that is the state the old `max(retained, window_s_)` bound leaked in, and it showed up here as - `span=2.502550 window=2.000000 limited=0` against `span=2.000000` after the fix. - -- `[zoom-probe] /x axis=[..] span=.. n=N idx=[a..b) spp=.. level=L pts=P kept=K - back=.. ink=[..] x_at_first=.. gap_px=.. end_px=.. rect=[..]` (stderr, - `UDPSCOPE_PROBE_EVENTZOOM_MS`) — arms the trigger in **single** mode, polls for a **held - event** (a latch happens when the stream says so; a sweep on a timer has nothing to zoom - into), then zooms in eight doublings - twice: `ges/` writes the row's own axis the way a gesture does, `win/` drives the window - control (`apply_time_window`, which unpins and re-arms the live follow before the pin is - restated). Each step's reading is taken in the *tick*, after that frame's pin and clamp — a - `g_timeout` would report the ask. It recomputes what `Renderer::draw_traces` is handed, through - the same calls: `neighbours()` for `idx`, the 64-spp gate for `level`, then `gather()`. - **`gap_px` and `end_px` must stay within a pixel or two at every step** — they are how much of - the left and right margin no geometry reaches, and a `gap_px` that *doubles* per step is the - "zooming into a held event clips it more and more from the left" defect (374 px of a 716 px - axis by `ges/x128`), whose cause is a constant offset in data seconds (see the `neighbours()` - invariant below). `kept`/`back` are the renderer's own increasing-suffix filter counted rather - than applied: `kept` far below `pts` is a staircase, and a non-zero `back` is a non-monotonic - gather. **Run it with `--clock-jitter`** (`tools/mock_udpstreamer.py --rate 1300000 --batch - 2000 --clock-jitter 0.4`): the ideal mock's stamps sit on a perfect grid, which is exactly - what hides this class of bug, and three runs against it reported `gap_px=0.0` throughout while - the defect was live. The jitter varies each *packet's* spacing rather than offsetting its - start, because the stream's clock has to stay strictly increasing. The `win/` sweep bottoms - out at `span=0.05` (`RowSet::set_time_window()`'s floor) and walks to the event's post side, - since `set_time_window()` frames the newest span before the pin clamps it — both are expected. -- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are - measured on a still picture. It now also asserts what a pause *is* - (`[pause-probe] paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..] - glance=[..]` on stderr): - it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display - state as well as an acquisition one and the view snapshot and axis pin live in the - handler. Across the three lines **`ring_produced` must grow** (the producer never - stops — it fills the live ring exactly as it does with the trigger off), **`event_size` - must be non-zero exactly while paused** (the plot is bound to the frozen snapshot, not - the ring) and **`frozen_x` must not move between the pause and the resume**; after the - resume `event_size` is 0 and the window is back on the newest data. Pausing used to stop - the producer, which is why the counts grew by nothing and the resume left a hole. - `frozen_x` is the *rebased* snapshot — the pause view's own stamps are relative to the freeze - instant now, so the range reads `[-window..0]`-ish rather than stream seconds, and a - `frozen_x` in the thousands is the rebase gone. - **`glance=` is the assertion for the paused status field**, and this is the only line that can - make it: the exit dump's `status bar:` is read live and its two `mode …` lines are the live and - triggered states. While paused it must read `paused · · · for `, with - the elapsed figure **growing between the two paused lines** and **absent on the resume line**. - It is **wall** time (`g_get_monotonic_time()`), not stream time, because the producer never - stops while paused and "how long have I been looking at this" is a question about the user, not - about the stream; it is written through `format_elapsed()` so the field steps to a coarser unit - instead of growing digits (see the layout rule). -- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default - `/tmp/udpscope-export`) — collect the export table and write it both ways, skipping - only the file chooser. - -**Run one self-check at a time.** Two concurrent `xvfb-run -a` instances end up on the -same display and the second gets no GL context — it dumps `layout over 0 frames` and -`valid=-1`, which looks like a pipeline failure and is not one. `tools/mock_udpstreamer.py` -also outlives its `--duration`, so kill it after each run or the leftovers pile up and -hold ports. - -Two lines of the self-check output are assertions in disguise: - -- `layout over N frames: axes.x A..B, ...` — **`viewport.w` must collapse to a single - value and `axes.x`, `axes.w` and `y-gutter` must *settle*.** `note_y_gutter()` is - monotone (`plot_host.cpp`), its one-shot widest-label reservation can land a frame or - two after the first measured frame, and the figure serves its floor out of the label - strip (`y_width = max(measured, floor - y_title_w)`, `plot.cpp`) so the reservation - converges over two layouts rather than one. A `y-gutter 88..104` dragging - `axes.x 88..104`/`axes.w 707..782` with it is that settle, not a twitch — confirm it - with the `changes` line below. A range that keeps moving is the "twitching axis" bug: - the plot being re-measured every frame. **A range that keeps *climbing* to 200 is the - other one**: `note_y_gutter()` is handed the gutter the row was laid out with, which - already includes the floor imposed last frame, so slack added to it is added again every - frame and the gutter ratchets to its own 200 px cap — `axes.x 88..200 / y-gutter 88..200 - / axes.w changes: 17` on an 870 px viewport is what that reads as, and 17 changes looks - like a settle. The reservation's own padding is the only slack there may be. - `axes.w` also no longer reaches the viewport's right edge: `apply_gutters()` reserves an - empty band there (~59 px of 870), so `viewport.w - axes.x - axes.w` is a *constant*, not - a zero. **`x-desync` must read 0**: it counts frames - in which any row's x range differed from row 0's by more than a pixel of data, i.e. - frames the x link group did not reach. `x-ticks` is content, not layout, and - legitimately varies by one as a live - window slides over a round number. The companion `axes.w changes: N (last at frame F, - side panel up=S)` line separates the two readings: `0`, or a handful near the start, - is a settled layout; a count near the frame count is the twitch. Runs far longer than - the documented probes have shown one ~320 px step — the side panel's width — that - reverts; it is intermittent and unexplained, so read `changes` before calling a range - the twitch bug. -- `[trig-probe] ... trigger=on ... display=... epoch=...` (stderr, - `UDPSCOPE_TRIG_TRACE_MS`) — with `trigger=on`, `display` must read `event(frozen)`, - `live(capturing)` or `nothing`, **never plain `live`**, except for the single interval - that contains the toggle itself — recognise it by `latches=0` *and* the next probe - reading `nothing` or `live(capturing)` with a frozen `axis=`. Plain `live` with the - trigger on is the old "the plot keeps scrolling in trigger mode" defect; the named - `live(capturing)` is the feature. In that state `epoch=` must equal minus the capture's - edge and `axis=` must stay at `[-pre..+post]` across consecutive probes while - `data=`'s upper bound climbs towards `+post` — an axis that moves instead means the - capture is being treated as a live view (`clamp_to_data()` reaching a pinned window). - `epoch=` is **minus the newest acquired stamp** in `live` and `nothing` — it is what makes the - live axis `[-window, 0]`, so there it grows (in magnitude) with the stream and a `0.0000` is - the defect, not the healthy reading. It is back to `0.0000` only in `event(frozen)`, where the - snapshot carries its own origin (`EventSnapshot::x_offset`) and the display epoch has nothing - left to do. Note the probe reads - `pending=` fresher than `display=` (which is last tick's decision), so a single - `pending=0 display=live(capturing)` line is one frame of sampling skew, not a defect. - And `snap=[a..b]` must equal `axis=[a..b]`, with `b` positive by roughly - the post-trigger share: that is the "post window is empty" defect's observable. - **`drawn=[a..b]` must equal `data=[a..b]`** in both modes: `data` is `x_at()` (what - the crosshair reads) and `drawn` is `x0() + gather()` (what the renderer maps), so a - series that breaks the "gather emits x relative to `x0()`" contract shows up here as - the two disagreeing by exactly `x0()` — a trace drawn a whole pre-trigger window left - of its axis, invisible once the view is narrower than the shift, while the crosshair - keeps reporting correct values. -- `[trig-probe/idx] n=.. bounds=[..] dx=.. dx_real=.. dx_ratio=R got=[a..b) want=[c..d)` - (stderr, same probe) — **`got` must contain `want`** (`a <= c` and `b >= d`), and - `dx_ratio` must stay within about a percent of `1.0`. `got` is the index window - `Renderer::draw_traces` gathers *today* — `neighbours()`, one sample of slack either side — - and `want` is the same window from the same call, so read this line for the **rate**: - `dx_ratio` is the nominal `dx()` against the real last-spacing, and it is the number that - used to matter. It above 1 (every index scaled up, `a` past the first visible sample, the - samples in between in the buffer and readable by the crosshair but never drawn) was the - live view whose older part went blank while its newest part looked fine. That is a *fixed* - defect: the renderer no longer walks the grid at all, because on a ring-backed series it - also lost the two end segments and left the left and right margins of a zoomed-in view - empty. The guard for it is the library's own pixel test - (`a_sparse_window_between_samples_still_inks_both_margins`), not this line — a probe that - recomputes the renderer's formula cannot see a renderer that changed it. Also - read `axis=` in the companion line against the `window control:` line in the dump — - a live axis narrower than the requested window is the same symptom from the other - cause (see the `clamp_to_data()` invariant below). Both need a *saturated* ring to - show up, so run them under `--buffer-mb 1`, which wraps in a few seconds. -- `trigger: edge at X ... want [-a..b] got [-c..d] n=N` (stderr) — `got` must equal - `want` and `N` must be exactly the window times the stream's real rate, plus one - (2 s at 10 kS/s is `n=20001`, not 19999). `got` shorter than `want` on the *left* - means the edge was accepted before the history reached back a full pre window (the - gate in `TriggerEngine::process()`); shorter on the *right* means the engine latched - early or the cut lost the newest samples. An `N` one or two short of the exact count - is the same class of bug, not rounding: `publish_event()` clamps the slice to every - value ring's `produced()`, so publishing before each channel of the latching sample - has been pushed silently drops it. -- `[disarm-probe] was_capturing=1 display=.. seq=A->B events=C->D snap=[a..b] n=N - axis=[..]` (stderr, `UDPSCOPE_PROBE_DISARM_MS`) — with `was_capturing=1`, **`display` - must read `event(frozen)`** and `snap`/`axis` must be `[-pre..e]` where `e` is how far - the post window had filled, with `n` that span times the stream's real rate plus one. - `display=nothing` (or a `snap` from the *previous* event) is the "disarming mid-capture - makes the event completely disappear" defect. **`seq` must advance by exactly one while - `events` does not move**: no event fired, so the partial publish deliberately leaves - `trigger_latches()` alone — which is also what keeps the `trigger events: N total, M at - the disarm` line in the dump meaning what it says. -- `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] abs=[..]` (stderr, - `UDPSCOPE_EXPORT_MS`) — **`time=` must equal `axis=`** and **`rows=` must be the - span times the stream's real rate, plus one** (2 s at 10 kS/s is 20001). The export - is the *visible* range at the *stored* rate, so a count far short of that is a table - cut from the LOD pyramid, and a `time=` narrower than `axis=` is a window searched on - the wrong clock (the live branch puts the window back on the stream's clock with - `stream_at_x0()` before searching the ring, and that offset is a whole window while - streaming — not the 0 it once was). - **`abs - time` must be a single constant across both ends of a line**, and that constant is - where the display's origin sits in the stream's own clock: `epoch()` plus the newest stamp in - `mode=live`, `epoch()` plus the accepted edge in `mode=event`. That is the one assertion on - the second column, and it replaces the old "`stream=` must be the edge ± the window" reading, - which only ever held because the display clock *was* the stream clock. A `mode=live` line - whose `abs - time` differs between the two ends is the two columns computed from different - epochs — the defect `abs_base` exists as a single `const` to prevent. - An export taken **mid-capture** reports `mode=live` with `time=[-pre..e]` where `e` is how far - the post window has filled — the export is what is on screen, and during a capture that is a - partly filled window. Pin the probe outside a capture when checking the `rows=` count. - The two `bytes=` lines are the writers: the `.npy` is - `64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a - mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing - either writer. -- `[window-probe] label=.. entry=.. unit=U window=W event_span=E cap=C decim=M - stored=R aa=F buffered=B` (stderr, `UDPSCOPE_PROBE_WINDOW_MS`) — **`window` must equal - `event_span`** on every line (the trigger's event span is the display window; a capture - cut to a different length than the axis frames is the "post window is empty" defect's - cousin) and **neither may exceed `cap`**. The `unit ms->s` line is the one that catches - the feedback loop: changing the unit re-interprets the typed number, so "500" in ms - becomes 500 **s exactly**, and a `window=499.98` there means the control re-parsed its own - rounded readout (`8.333 min × 60`) instead of the request — see the `window_sync_unit` - invariant below. `label` is a reading to three decimals in a natural unit and is - *expected* to be lossy (500 s reads `8.333 min`); it is the `window=` figure that has to - be exact. - For the resampling: **`buffered` must be ≥ `window`** on every line — that is what the - decimation is for, and a `buffered` short of the window is the old "the control is capped - to what the ring holds" behaviour coming back. `decim` must be a power of two, - `stored × decim` must equal the wire rate, and `aa` must be exactly `stored / 2`. Run it - under `--buffer-mb 1`, which saturates in a few seconds and makes windows past ~6 s - decimate; at 200 MB nothing inside `--max-window` needs to. -- **The anti-alias check** is the same probe run against a waveform above the stored - Nyquist: `--buffer-mb 1 --freq 2000` with the probe ending on a 60 s window gives - `decim=16`, a 625 S/s stored rate and a 312.5 Hz corner, and the channel dump's - `ch 0 'sine' ... y=[-0.059, 0.079]` is the assertion — a boxcar of 16 at 10 kS/s passes - 2 kHz at about 6%. **A `y=[-1, 1]` there means the filter is gone** and the 2 kHz sine has - aliased to a full-amplitude 125 Hz one, which looks entirely plausible on screen. This is - the only check that distinguishes averaging from striding. -- `[cursors-probe] gestureless plot move with no gesture: stored A -> B ignored=D` — a - plot-level `set_cursor_x()` with **no gesture behind it must be ignored** (`ignored=1`, and - `A == B`): the tick reads a row only while its controller holds a cursor, and this line is the - guard for the defect where it adopted its own echo and a dragged cursor sprang back. (The - *adoption* half -- a gesture that does move a cursor, and the shared value reaching every row - -- is asserted in the `check_host` harness, which can call `sync_cursors(row)` directly; a - synthetic press here cannot reach the adapter's own event pump.) -- `[cursors-probe] mode=.. on=D pane=D t0=.. t1=.. dt=.. T=[..] lines=N [..]` - (stderr, `UDPSCOPE_PROBE_CURSORS_MS`) — walks `vertical` → `horizontal` → `both` → - `mix`, then moves a marker *through the plot* (`Plot::set_cursor_x(..., Free)`, which is - what a drag does) and reports again. **The times must survive every mode change**: they - are the `RowSet`'s one pair, and a mode that parks the markers must not read them back - (a parked cursor reports a real number, so a read-back would adopt it as a gesture — the - bug that made `T0` read 0). **`lines`** must be 0 for `vertical`, one per row for - `horizontal`/`both` (and the time row exists only in the modes that show the vertical - pair — `T=[]` in `horizontal` is the assertion that it does not), and one per **bound** - trace for `mix` — a slot with a name *and* a channel the model built, never a name alone - (see the bound-slot invariant below; `lines=6 marks=3` over three real traces is that - defect). Each line reports as `= `, so the names are the reading: a row - claiming a signal that is not on a plot is the phantom. In `mix` the values must be the - traces' own (`--` when a marker is past the newest sample, `partial` when only one - marker is over samples). After the move, `t0` must have changed and every row's marker - must have followed it (the report's `t0` is the shared value). **`marks`** is the `mix` - mode's crossing annotations: **0 in every other mode, and one per bound trace for each - marker that is over samples in `mix`** (so a move that brings the second marker into the - data doubles it). The `first mark at x=.. y=..` line that follows must agree with the - pane's *first* value for the same trace (the pane's lines are `chip name Y1 Y2 dY` -- - the chip carries the signal's name, since a chip alone leaves the user to match a colour - against the plot by eye -- with the values formatted as measurements, two decimals, an SI - prefix, the channel's unit, so the comparison is against the first number): both are read through the same - `Plot::crosshair_value()`, and that line is the check that they stay the same number. - A **ruler** drag is the other direction and the other hazard: the pane's `A`/`B` cells - for a ruler row must change when the ruler moves (`after ruler drag` in the probe walks - the first ruler to 80% of its y range), because nothing shares a ruler and the read-back - in `RowSet::sync_cursors()` is the only thing that can notice. The dump's `cursors in plot - 0:` line is the other half: it reads the markers back out of - the plot (`cursor(id).x/.visible`), which is what the renderer draws from — the pane's - numbers and the plot's state agreeing is the difference between "the app placed them" and - "the app placed them and they are on screen". -- `[measures-probe]` + the dump's `measure N: 'sig' type thr=..% above=D show=D -> ok|invalid value text=[..] - [duty=..] [marks=..]` and `measures pane: n=N revealed=D marks_shown=M pane_rows=R pane_w=W - pane_h=H` - (stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first - plottable signal and lets the tick compute them, so what it prints is what a user reads. - On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.64 and - `bottom` ≈ -0.64 (the mean of each half of a sine), max/min ≈ ±1, `pulse duration` ≈ 0.5 s - with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. It needs **`UDPSCOPE_ENABLE_ALL_MS` - ahead of it** or it prints `no acquired signal to measure` and the dump reads `n=0`. - **Run it against `--noise 0.1 --freq 1` as well, and the three crossing types must still read - those figures** (measured: `period=1.000100 frequency=0.999900 pulse duration=0.504100`): the - hysteresis band is the only thing holding them there, a single level reads a 500 Hz interferer as - the signal, and the default `--noise 0` mock cannot see it. The band costs `raw`, not accuracy -- - the clean fast mock reads `raw=0.1%` at 1.41 ms a recompute and the noisy one `raw=2.0%` at 1.96 - ms, so read `measure cost:` on **both**. `marks=` lists each mark with its kind, and the **mix is the point**: - `level:V` alone for average/top/bottom, `level:V,time:T` for min/max (a ruler at the value - *and* a marker at the first sample that reached it — two level marks at both extremes was - the bug), `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with - its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration` - seconds, and the rest the channel's unit. `marks_shown` counts two per shown measurement - (a min/max showing both its marks counts two of its own). `measures window:` is the - timeliness assertion -- the window the numbers were computed over against the one on screen. - **The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window - on screen. Both ranges are on the **stream's** clock, which is the space `measure_window` and - the time marks are stored in -- a live axis is a fixed `[-window, 0]`, so comparing display x - would read no drift whatever the data did. - `drift` itself is a **single sample of a sawtooth** and is not an assertion on its - own: a live view scrolls between recomputes, so it rises to one `kMeasureRefreshUs` of scroll - and drops to zero at each pass. The figure is `(|Δmin| + |Δmax|) / span`, so a pure scroll - counts **twice** and the top of the sawtooth is `2 × refresh / window`: ~10 % on a 2 s window - at the 100 ms cadence, and a few *hundred* percent on a window shorter than the interval (a - 50 ms window reads 254 % for 63.6 ms of scroll, and its ceiling is 400 %). Compute the ceiling - for the window the run actually used before calling a figure large. A `0.0000%` - is a dump that landed just after a pass, not a better state. Read it against the recompute count: - *more* recomputes with a *higher* drift sample is the sawtooth, while a drift that exceeds one - refresh interval of scroll, or marks outside the window, is the defect. A measurement is - recomputed when the window moves (2 % of its span) or the held/live state changes, never on a - wall-clock timer: a timer is what let a marker sit outside the viewport and a dragged one - survive a frame. **`marks_selectable` must be 0**: the - measurement marks are created unselectable (`Plot::set_cursor_selectable()`, which the - library gained for this), read back out of the plot here -- re-placing a marker every frame - does **not** stop a drag, because the controller holds it for the gesture's life and the - marker follows the pointer between frames. The library's own controller test - (`an_unselectable_cursor_is_neither_hovered_nor_grabbed`) is the other half. The probe's last - step still drags a mark *programmatically* and requires the next frame to put it back - (`restored=1`), which now checks the placement path rather than the read-only property. The dialog report's `type= thr= - side= show=` is the per-type behaviour: `period` and `frequency` must show **neither** the - split nor the side row (they compute from the mid level's rising crossings, and an option - there is one the user can only get wrong), `average`/`min`/`max` no split either, and only - `pulse duration` a side. A measurement whose signal is not acquired, or - a window with too few crossings, must read `invalid`/`--` rather than a made-up number - (`frequency` on a window holding one edge is the case: `duty` is reported only when a - period exists to divide by). The probe then **walks the dialog's lifecycle**: open the edit - dialog on measurement 0, click its `Save`, open it again, click `Delete` — `measures=` must - be unchanged after the save and one lower after the delete, with the process still alive. - That is the check for the two things a dialog of this shape gets wrong: two buttons owning - one context (a double free aborts on edit/delete, and only those paths have two buttons) and - a `const char*` array built from a `std::vector` as it grew (every pointer - dangling, the first entry the most visibly). The report's `signals=[..]` and `drop_h` are the second and third: - the list must be the **plotted traces** (real names, deduplicated, in signal-list order — - *not* every channel, and never a stream's timestamp signal, which is what an acquired-but- - unplotted list put first and auto-selected), and `drop_h` must be a combo box's height - (tens of pixels — a scrolled-window wrapper asked it to grow to 160). -- `[timelabels-probe] relative=D distinct=D window=.. origin=.. note=[..] ticks=[..] - first_value=.. first_rel=.. range=[..]` + the dump's `time labels: relative=D distinct=D - origin=.. note=[..] x_origin_set=.. ticks=[..] first_rel=..` (stderr, - `UDPSCOPE_PROBE_TIMELABELS_MS`) — the x label form across a zoom, driven through - `apply_time_window()`: **under the figure's x link group the group owns the window, so a - row-level `set_time_window()` does nothing** (a probe that changes the window must use the - app's path). **`distinct` must be 1 on every line, and that is the assertion** — it says no two - major labels on the axis are the same string, which is the one thing the relative form exists to - buy and the only reading that holds whatever the stream's uptime is. `relative=D` is **expected - to read 0 on every line now**, and that is a consequence of the newest-relative axis rather than - a change to the rule: the form is decided by the tick step against the label resolution at the - axis' **magnitude** (see the invariant below), and no display mode carries a large magnitude any - more — live and waiting are `[-window, 0]`, a capture and a held event are edge-relative, a pause - is freeze-relative. The 50 ms window that used to read absolute at 2 s of uptime (`2.13s, 2.14s, - 2.15s`) and relative at 17 s (`100µs, 10.1ms, 20.1ms`, with the caption) now reads - `-40ms, -30ms, -20ms, -10ms` at any uptime. The rule and the caption are kept because they are - the answer for *any* axis magnitude and nothing in them is mode-specific — do not delete them on - the strength of this reading — but what the probe asserts is `distinct`, not the form. The - **caption must be non-empty exactly when `relative=1`**, which here means empty throughout. Run it after - `UDPSCOPE_ARM_TRIGGER_MS` as well, which is the case the span rule got wrong: a triggered 50 ms - window must read `relative=0 note=[] ticks=[-20ms,-10ms,0,10ms]` — a `t0` caption on an axis - that is already edge-relative re-anchors the ticks away from the trigger, which is the one - position the axis exists to show. The probe *races the paint* — it reads the axis on a timer, - and the ticks it sees are the previous layout's — so its `first_rel` (the first major minus - the axis' origin) is a fraction of a step; the dump runs after the last paint and is the - race-free reading. Either way the assertion that matters is the *sum*: `first_value` must - equal `origin + first_rel` as a *tick label* plus its caption, i.e. the labels and the - caption must share one anchor. `x_origin_set` is read back out of the plot; it must be 0 in - the absolute form. Note the last two steps both report `window=0.05`: `RowSet::set_time_window()` - floors the window there, so the 20 ms step is clamped. -- `[fit-probe] pinned=D window=.. event_span=.. x=[..] span=.. y=[..]` (stderr, - `UDPSCOPE_PROBE_FIT_MS`) — the two fits, through the **menu handlers** rather than the bare - `RowSet` calls, because the notice and the control are the handler's half. A fit is the **undo - of an x zoom**, so the probe performs one: it moves the y scale off the data (a fixed - `[-100, 100]`), sets a 2 s window through the control's own path, then zooms row 0 to a quarter - of it the way a gesture does — on the *member* axis — and fits. Four assertions. - **`window` and `event_span` must be unchanged on every line** and equal to the typed 2 s: a fit - is a plot interaction and may not restate the time scale (see the invariant below), and they are - the only two readings that can see it try. The `zoomed` line's `span` must be a quarter of the - window, or the fits have nothing to undo and the lines below mean nothing. **`fit x` must put - `span` back to the whole window and leave `y` exactly as it was**, and **`fit all` must do the - same and move `y` to the data's own range** — a `y` that changed on the `fit x` line is the two - paths aliased. `x`'s *position* advances between lines on a live view and is not an assertion: - the follow is re-armed on the newest data, which is what the whole window means there. - Run it **inside a pause** as well (`UDPSCOPE_PROBE_PAUSE_MS=2000 UDPSCOPE_PROBE_FIT_MS=2200`), - which is the other branch and the one that can silently revert: `pinned=1`, and `fit x` must - restore the *pin's* full range — the snapshot's own stamps, legitimately narrower than the - requested window (`span=1.179900` against `window=2.000000` is correct, not a finding) — and - must still read `pinned=1` afterwards. The pin is restored by writing the **members** as well as - the group, because `hold_pinned_window()` prefers a member that has moved since `pinned_stated_` - — that is how it tells a gesture from its own echo — so a fit that wrote the group alone reads - back as the zoomed range one frame later. -- `[layout-probe] rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]` - (stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is - up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the - buttons exist only while the editor does. `done` follows the same rule for the editor's - own exit button (1 up, 0 off), and its `done_box` on the `placed` line must sit in the - area's top-right corner: `x + W` within a few pixels of the area width and `y` within a - few of 0 (the on-switch line reports a box from before the first frame, so read the - `placed` one). The last probe step leaves the editor by **clicking that button** — the - toolbar toggle is still exercised by the first step — so `buttons=0 done=0` with the - rows and traces untouched is the assertion that the way out works. **`traces=` is the real assertion**: an insert - or a remove moves the rows' *contents* with them, so `add above 0` must read - `[0:-|1:sine,…]` — row 0's signals now on row 1, not renumbered onto the new empty row — - and `remove 0` must pull them back to `0:`. A `traces=` where the signals stayed on row 0 - means `carry_row()` was skipped and the rows were renumbered under the user. - Geometrically, each `close=` must equal its row's top from `rows_y` plus the pad, and - `add=` must be centred on the boundaries (so strictly increasing, the first at 0 and the - last clamped to the area's bottom). The `rows_y` spans must tile: a gap other than - `kRowGap` means the figure and the overlay disagree about where a row is, which is how a - `×` ends up on the wrong row. Also read `x-desync 0` in the dump — every row the editor - adds has to join the one x link group. Run it at `--plots 8` as well: the cap and the - floor are the two refusals, and because the probe emits `::clicked` straight at the - widget it reaches the handler even when the button is insensitive, so `add above 0` there - must leave `rows=` and `traces=` untouched rather than growing a ninth row. -- `header: window_button_w=W label_w=L bar_w=B` (stderr, once a second) — **`W` must be - a small constant and must not track `B`**: the window control is a header-bar child, - and an expanding one is measured into the title's centre box, so it stretched from the - label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that - grows with the window width is that bug back. -- `toolbar icons: trigger=NAME(ok) pause=... cursors=... measures=... math=... sidebar=... - menu=...` (in the exit dump, beside `panel controls:`) — the icon name every toolbar button - carries, with `gtk_icon_theme_has_icon()`'s verdict. **Every entry must read `(ok)`.** Four - of the names are this app's own (`udpscope-trigger-symbolic` and friends, a `GResource` - compiled into the binary — see the toolbar-icon invariant below), and a `(MISSING)` on one of - those means the resource path was never registered. That failure is otherwise *silent*: GTK - substitutes a missing-image placeholder, the button still works, and every other line of the - dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this - is the only check that it names something that exists. -- `app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)` (in the exit dump, - beside `toolbar icons:`) — the application icon, named and present at the size its own root - `` element declares. **Both entries must read `ok` at those sizes.** `ok` is - `gtk_icon_theme_has_icon()` *and* the resource bytes being found under the registered path, so a - `MISSING` is a resource path that was never registered or a file that was renamed, and a size - other than 128 or 16 is a canvas that drifted off the HIG's. **Read it together with - `toolbar icons:`, and trust that line further for the registration**: the resource-path - registration is one call in `run_ui()` serving both the window's icon and the toolbar's buttons, - so a `MISSING` on either line is the same defect — but on a machine where - `--target desktop-entry` has been run, *this* line's `has_icon` half is also satisfied by the - **installed** copy under `$XDG_DATA_HOME/icons/hicolor`, so a broken registration still reads - `ok` here. That was measured: with the resource path deliberately misspelt, `toolbar icons:` - reported four `MISSING` (nothing installs the action icons) while `app icons:` stayed `ok` - until the two installed SVGs were moved aside, whereupon it read `MISSING 128x128` / - `MISSING 16x16`. The line is still worth its place — the *present* half (the resource bytes) and - the declared sizes are read from the binary and nothing else can satisfy them — but a developer - machine is the one where it is least sensitive. - **It deliberately does not claim the icon renders**, because no runtime GTK call can: - `gtk_icon_theme_lookup_icon()` never returns `NULL`, its paintable's intrinsic size is the size - it was *asked* for, and a valid icon, a malformed one and a name that does not exist at all all - snapshot to the same `GSK_TEXTURE_NODE` of the requested bounds — GTK substitutes - `image-missing`. A malformed SVG is a *build* error instead; see the icon-authoring rules. -- `[sources] N known: HOST:PORT plots=P signals=S [multicast=G] [interface=I] | ...` (stderr, - `UDPSCOPE_PROBE_SOURCES=1`) — the startup chooser's discovery, printed and exited **before - `gtk_init()`**, so this one needs no display and no `xvfb-run`. **Only sources with a named - signal may appear**: a development machine accumulates one session file per probe run on a - throwaway port (140 of them when this was written), and the filter in `known_sources()` is - what keeps the list the sources a user configured. Order is newest first by file mtime. -- `[sources] picked HOST:PORT multicast=G interface=I` (stderr, `UDPSCOPE_PROBE_PICK_MS`) — - shows the chooser, then activates row 0 the way a click does and continues into the real - application, so a full run with **no `--server`** must reach the scope and emit every other - dump line unchanged. `multicast=`/`interface=` coming back non-empty from a stored row is the - assertion that a multicast session reconnects to its group, and it needs **no multicast - networking**: write the session file by hand and read the line. With - `UDPSCOPE_PROBE_NEWHOST=host:port` it drives the form instead — `[sources] form open, - connect_sensitive=1` then a `picked` line, with no SIGSEGV, which is as much a crash test of a - dialog built and destroyed as it is a state check. An empty list must print `picked nothing: - the list is empty` rather than dereferencing a null row. -- **The session layout round trip** (`~/.config/udpscope/_.toml`) — run once - with a drop and a window change, then again with no interaction: the second run's dump - must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the - first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0 - -> channel ... samples=N` line). Delete the file before a run whose layout you did not - mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise). -- `[udpscope] starved: connected (config=1), reconnects=N, no new frames in the last - second ...` (stderr, once per second while the state holds, plus a status-line notice) — - "connected but nothing is arriving after a reconnect", which over unicast is the second - instance taking the source's single session. Check it does **not** fire against a live - source. The status line carries ` | N reconnects` only when N > 0, which is the same - counter without the prose. - **The same stall test has two outcomes, split on `have_config`, and they are different - diagnoses.** With a signal list in hand, the above. With **no CONFIG at all** it prints - `[udpscope] connecting: connected (config=0), reconnects=N, no configuration from the - stream ...` and the notice reads *trying to connect* — nothing was taken away, because - with no signal list nothing can be enabled and no DATA frame is owed, and the old wording - blamed a second reader that does not exist. That state is also named *without* waiting for - the three-sample stall: the glance word is `connecting` and the badge is **yellow** from the - first frame (see `badge frames:`). The way to see it with no second client is to point the - app at a UDP port where nothing answers (the client CONNECTs, times out, reconnects), which - is now the `config=0` branch and no longer exercises the `starved:` one at all — a check of - the unicast-contention wording needs a real second reader, or a source that configures and - then goes quiet. -- **The export's `time`/`abs_seconds` columns must be strictly increasing and evenly - spaced.** Check the written file, not just the probe: `np.diff(t)` with no `<= 0` and - `rows / (t[-1] - t[0])` equal to the stored rate (the `ch` line's figure). Duplicate or - backwards stamps mean the recorded timeline holds two clocks or a collapsed cadence — - the two ways this has broken: a per-frame choice of time source (now sticky, see the - README) and a per-sample step derived from the client's `lost` counter (now the - stream's measured arrival cadence). A `rows=` count far below the span times the rate - is the other signature, that of a table cut from the LOD pyramid. - **The step must hold to a few ULP of the column's own magnitude**, and this is the check that - needs a long run at a high rate: the old form of this assertion was "`set(np.diff(t))` holds - exactly one value", and that was only true while `time` *was* the stored stamp. Both columns - are derived now — `time` is `stamp - stream_at_x0()` and `abs_seconds` is that plus a base of a - wholly different magnitude — so each subtraction costs a rounding and `set()` legitimately - holds a handful of neighbouring doubles. Measured: four values spanning ~1.5 ULP, 6.8e-16 s. - The thing that must not come back is the **plateau**: equal stamps, or a diff taking two or - three values a few *hundred nanoseconds* apart, is the time ring losing precision with uptime - (see the `f64` time-ring invariant below). The figure to compute is the ULP of `t[0]` and of - `abs_seconds[0]` — the defect that invariant guards is microseconds, nine orders of magnitude - above the arithmetic noise, so the two readings cannot be confused. -- `badge frames: lit=L dim=D idle=I connecting=C` — the activity badge's phase per frame. Over - a run that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it - blinks" means, and a badge stuck lit is indistinguishable from a working one in a - screenshot. One or two `idle` frames at the start are the frames before the socket - connected; a large `idle` is correct only when the run paused or disarmed (a - `UDPSCOPE_PROBE_DISARM_MS` run should show hundreds). **`connecting` must be 0 on a run - against a live source** — it is the steady *yellow* phase, "the socket answered but the - stream has sent no CONFIG", and a non-zero count against a working mock means a signal list - that arrived late or not at all. It is the *only* non-zero phase on a dead-port run - (measured: `lit=0 dim=0 idle=90 connecting=446`, the `idle` being the frames before the - first CONNECT), which is also the check that a source that has sent nothing never gets the - blinking green. The `mode ...` lines carry the rest of the status line, so `rate` and - `last event ... ago` are checked there. -- `frame cost: tick A/B ms over N, draw C/D ms (max at frame F), paint E/G ms over M` and - `frame path: raw=R lod=L columns=C graded=G frames, segments S/T (mean/max), visible max V samples` - — the - drawing cost, split into the frame tick, `PlotArea`'s own draw (timed from the frame - clock's `before-paint` to the end of the host's `render` handler) and the whole GTK - paint. **`segments` max must stay in the low thousands and `lod` must dominate `raw` - on any run holding a large capture**: a max in the *millions* with `lod=0` is the - empty-pyramid defect (see the `MinMaxPyramid` invariant below) — the renderer drawing - every raw sample of a held event on every frame, which measured 6,000,073 segments and - 100–230 ms a frame at 1 MS/s. `raw` frames are expected and cheap below 64 samples per - pixel, which is where `level_for()`'s own gate puts the boundary. - **`columns` must equal the frames that drew a trace at all on any run at a high rate**, and - it is the reading that says *why* `segments` is small: a dense gathered window is reduced - onto the pixel grid upstream (one snapped vertical segment per column — see the moiré - invariant below), so the geometry is bounded by `axes.w`, and a `columns=0` beside a large - `segments` is that reduction gone rather than a narrower window. Measured on - `--rate 1300000 --batch 2000 --freq 3000` over 20 s: `raw=2 lod=1198 columns=1200 frames, - segments 2041/2237` over 1201 painted frames, against `columns=0 ... segments - 10960/216093` over 263 before it existed — the same run at 4.6× the frame rate. - **`graded` must equal `columns`**: this app turns `Style::intensity_grading` on, and grading is - a way of *drawing* the reduction rather than a third path, so a `graded` below `columns` is the - style not reaching a frame. **`UDPSCOPE_NO_PHOSPHOR=1` is the A/B** and the only way to read - either of these two lines against the ungraded path, since a frame mean cannot attribute its own - cost: measured on `--rate 1300000 --batch 2000`, `columns=1166 graded=1166` with - `draw 3.24/21.43 ms` and `segments 1340/4157` against `graded=0`, `draw 2.30/25.76 ms` and - `segments 131/4306` over the same `lod=453` frames. The segment rise *is* the gradient (alpha is - quantised into steps, one `LineBatch` and one draw call each, because the line pipeline's colour - is a uniform) and the ~1 ms is its cost; a `raw` that *grows* when the grading is on is the - level-floor invariant below gone, which measured `raw=1154 lod=0` and 6.36 ms. - **`draw`'s max must sit - at a frame near 0** — that is the warm-up (glyph atlas, first vertex-buffer growth); a - late `F` is a frame whose own geometry was expensive and worth tracing. `tick`'s max is - the publish stall: `publish_event()` builds the snapshot's pyramids under `mutex()`, so a - latch at over 1 MS/s costs one ~40 ms tick, which must not produce `gaps`. Both these - lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything — - `tools/mock_udpstreamer.py --rate 1300000 --batch 2000` — since at the default 10 kS/s a - whole window fits in the raw path and the publish is too short to lose a race. (Do not - raise `--batch` to chase the rate: a batch of 20000 exceeds the datagram limit and the mock - dies with `OSError: [Errno 90] Message too long`.) -- `publish cost: N events, A/B ms (mean/worst, blocks the UI); M pauses, C/D ms (mean/worst, - blocks the producer)` — what *freezing a view* costs, and the line that attributes the stall - `frame cost:`'s `tick` max can only hint at. The two publishes are counted apart because they - stall **opposite threads**, and nothing else in the dump distinguishes them: - `publish_event()` runs on the producer with `mutex()` already held, so its cost lands on the UI - as a long tick; `publish_pause_view()` runs on the UI thread and takes the lock, so its cost - lands on the producer and can only surface as `gaps`. Read the event figure against `tick`'s max - and the pause figure against `gaps`. Both are **wide**: on the fast mock the event publish has - read **69, 89, 92 and 115 ms** and the pause publish **22 and 86 ms** across runs, so treat the - line as an order of magnitude and not a figure to compare a change against at the millisecond. - The event reading matters anyway because it is far above the ~40 ms the `frame cost:` entry above - quotes — treat that 40 as the old reading and this line as the authority. - It is also **the number to judge a second live ring against**, which is the one design question - it exists to answer: freezing a whole `BufferStore` copies nothing and inherits the pyramid a - `ChannelBuffer` already maintains incrementally, so both `worst` figures would go to **0** — at - the price of a second budget's worth of memory (README records that one live ring is why - `--buffer-mb 200` really is 200 MB) and a second per-sample write on the producer. What the line - says about that trade is that the stall is real but **costs no packets**: an A/B of the same run - with and without a pause read `gaps=0` both ways, the paused one with an 86 ms publish, so a - second ring would buy a smoother frame at a latch rather than recovered data. (An earlier run - reported `gaps=73` with a pause against 0 without, which is what made the stall worth measuring; - it has not reproduced and did not scale with the publish length — 79 gaps with the pyramid build - removed against 73 with it — so it was incidental load on one run. Do not quote it as a cost.) -- `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%, worst - drawn-vs-quoted D px over R raw paints` plus a - `[ink] paint N mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=.. - ink=[..] cover=..% x0=.. x_at=[..] bounds=[..] rel=[..] dx=.. drops=[..] - back=..` line per anomalous frame (stderr) — **`collapsed` and `staircase` must both be 0**, - and **`drawn-vs-quoted` must stay far under a pixel** (0.0000 is what a clean run reads). - This is the only check that can see a frame whose *waveform* vanished: `staged_segments` - counts geometry including the grid and the instruments, and `lod_traces` counts a trace whose - points are then collapsed, so both read healthy through the flicker. The probe runs on every - painted frame that drew a trace and recomputes what the renderer did, through the same calls - — `neighbours()` for the index window, the 64-spp gate for the level, `gather()`, then the - increasing-suffix filter — and reports where the ink actually landed against the axis. - `collapsed` is fewer than two distinct positions left (nothing to draw at all), `staircase` - is the filter dropping over three quarters of the points, `short` is ink reaching under a - quarter of the axis (reported only once the data spans the window, since a filling ring is - *meant* to leave its left side empty). `drawn-vs-quoted` is the other reading, and it is the - one that names a *defect of position* rather than of presence: at a raw level a gathered pair - **is** sample `first + k`, so the line differences where a sample is drawn - (`axis.min + gather[2k]`, the renderer's own reconstruction) against the x a readout quotes - for it (`x_at()`, exact f64) and reports the worst of them in pixels. That is the - gather-origin invariant below read from the screen's side; it was over a hundred pixels at a - deep zoom before the origin became the caller's. It is measured on the raw levels only — above - them a gathered point is a bucket's envelope and has no single sample to be off from — so `R` - is legitimately below `N`. **`back` ≈ one ring span is the signature** of the torn `x0()` read - (see the cached-first-stamp invariant); so is an `ink=` range disjoint from `axis=`, or an - `idx=` covering the whole ring with `cover` far over 100%. There was a `kept_fixed_x0` field - beside `kept` — the same filter with `x0()` hoisted out of the loop, since the renderer used to - re-read `x0()` per point and a ring-backed one advances with the producer. The origin is a - local of the renderer's now, so that failure mode is structurally impossible and the field is - gone; the `[ink]` walk and the renderer still agree because both add `axis.min`. It is a race, - so it needs load and a reason for long frames — - `tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --clock-jitter 0.4` with the - measurements *and* cursors panes up: - `UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MEASURES_MS=2000 UDPSCOPE_PROBE_CURSORS_MS=3500 - UDPSCOPE_EXIT_AFTER_MS=35000`, which reported three staircase frames in each of three runs - while the defect was live and none after. `frame path:`'s own numbers move with it (`segments` - max 156078 → 19681), since a refused `neighbours()` gathers the whole ring. -- `traceless paints: N (M after the first bind, last at paint P)` and `worst side/pane min - width: W px`, plus `[gen] frame F generation A -> B (bound=.. empty=..)` and `[width-probe] - ...` lines (stderr) — three cheap per-frame counters that came out of the flicker hunt and - each guard a documented invariant from the side the existing lines cannot see. **`M` must be - only the frames in a state that draws nothing**: a traceless paint is legitimate in - `DisplayMode::Waiting`, which is why the line carries `mode=` — `UDPSCOPE_EXIT_AFTER_MS` arms - the trigger before it dumps, so a tail of them at the end of a run is expected. `[gen]` names - each buffer rebuild as it happens with the bind state around it: a rebuild drops every ring's - history, so the frames after one legitimately draw an empty plot, and `blank view frames` - cannot see that (it asks whether the ring holds samples the view has not got, and a rebuilt - ring holds none either) — so an unexplained `[gen]` burst is a structural change firing when - nothing structural happened. `worst side/pane min width` is the "layout must not depend on - content" invariant read from the **demand** side: a `GtkLabel`'s minimum width is its text and - a minimum wider than the screen is honoured by growing the toplevel, which hands the plot an - allocation wider than the window; `axes.w` sees that a frame later and cannot name the cause, - so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the - per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget - tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read. -- `[help] pages=14 parsed=14 shortest='Choosing a source' 1563 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape. -- `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row, - then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B` - and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr, - `UDPSCOPE_PROBE_MENUS_MS`) — the two popovers the toolbar and the status bar open, measured with - the side panel hidden. **`rows_bad` must be 0 and `fits` must be 1.** What it asserts is what a - screenshot can only show an eye: every row's label starts at the *same* x (`label_x=26` for all - seven — a menu whose checkable rows indent their text and whose others do not is exactly what - "the text is not left-aligned" looks like), every shortcut ends at the row's own right edge - (`accel_right == W`), no row is over `kMenuRowMaxPx` (30) tall — Adwaita's button minimum is 34, - and seven of those is what made the popover read as a stack of dialogs until `.udpscope-menu` - trimmed it — and the rows arrive in the order its `kExpected` names. `[menu] status` is the ⓘ - bubble: **`pos` must read `top`**, which is set *after* `gtk_menu_button_set_popover()` because a - `GtkMenuButton` states a position of its own and the last word wins, and `button_top` must exceed - the bubble's height, because "below the button" is off the window's bottom edge — the bubble's - top edge stayed at the button with its body clipped away, which reads as an arrow disconnected - from its body. Run it **alone**: another probe that opens a window dismisses the popover, and - every row then reports `h=0`. It turns autohide off for its own measurement — a popover dismisses - itself when it loses the grab, and `xvfb-run` has no window manager to hand it one — and restores - it afterwards. -- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `: : ->` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values). -- `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires). -- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`, `disarmed` — a word added to it must be **no wider than the widest already there**, which is what let `connecting` join at ten characters beside `event held`; the dead-port glance reads `max_glance_chars=51` against a live run's 54), the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the two fields a *state* adds are both elapsed times — trigger mode's `last event … ago` and a pause's `for ` — which step units rather than growing digits (`format_elapsed()`). A **paused** glance is not in the dump at all: it is read by `[pause-probe]`'s `glance=` field, since the dump's own line is taken live. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it. - -The probes are the regression net — when changing UI or plot-host behaviour, extend -the probe/dump in `src/ui.cpp` rather than adding a separate harness. - -## Architecture - -`SPECS.md` is the requirement document. `README.md` documents what was actually built, -including deliberate deviations from the spec and known limitations — read it before -concluding something is a bug. - -### Threads - -Exactly two: a producer thread in `Acquisition` owns `udps_client_t` and calls -`udps_client_poll()`; the GTK main thread owns every widget and GL context. They share -`ScopeModel` under `ScopeModel::mutex()`, taken once per DATA frame by the producer and -once per frame by the UI (`tick()` in `src/ui.cpp`) around `RowSet::bind()`/`acquire()`. -Samples themselves cross via `plot::data::RingBuffer` (single producer, single -consumer) and never through the lock. Cross-thread scalars are atomics on `ScopeModel` -(`trigger_enabled_`, `trigger_armed_`, `trigger_latches_`, `trigger_time_`, -`event_`, `generation_`). Two of them are a pair: the producer writes `capture_edge_` -before `trigger_pending_` and those two carry release/acquire, so a consumer that sees a -capture open sees which edge it belongs to. - -### Data path - -`ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one -value ring per channel, sized by `plan_partition()` to cover `--max-window` seconds -within the byte budget. The producer always writes it, whatever the trigger is doing — -that is what guarantees a full pre-trigger history on the *first* latch after arming. -The spec's second buffer (item 1) is the latched `EventSnapshot`, which is already an -immutable copy of the captured window; see README for why two live rings were dropped. - -What a trace binds to is `ScopeModel::display_mode()`, one of four states, and -`RowSet::bind()` takes it as an argument rather than recomputing it: - -- `Live` — no trigger: the live ring, on the clock of the **newest sample** - (`display_epoch() == -newest`), so the axis is a fixed `[-window, 0]` and x reads as an age. -- `Capturing` — an edge has been accepted (so the pre window was full) and the post - window is still filling: the live ring, on the coming event's own clock - (`display_epoch() == -edge`), inside an axis already pinned to `[-pre, +post]`. The - post side fills in view and the marker sits at x = 0 throughout. -- `Held` — the frozen `EventSnapshot` of the last event, whose stamps are already edge-relative, - so the display epoch is 0. -- `Waiting` — armed, nothing captured yet: blank, with the live epoch (there is nothing bound, - but the axis is still a live one). - -**Disarm during a capture publishes what the capture got to**, then disarms: the producer -cuts the window at the newest sample and that partial snapshot becomes the held event, so -the picture on screen at the moment of the Disarm stays on screen. `engine.disarm()` clears -`pending_`, which drops the display out of `Capturing`, and with nothing published the view -fell back to the *previous* event — or to blank on a first capture. No event fired, so -`trigger_latches()` is deliberately not bumped; `ScopeModel::event_seq()` counts published -snapshots instead, and the frame tick keys its event framing on *that*. Keep the two -distinct: the latch count is what the probes assert "how many events fired" with. - -`Capturing` is a **deliberate departure from SPECS:37** ("data visualisation should not -be refreshed till next event"), asked for explicitly; that reading is what the old "never -the live ring mid-capture" rule enforced. What survives of the rule is that `Held` binds -the snapshot *only* — a held event is never quietly replaced by live data — and that the -*axis* does not move during a capture: it is the event's window from the edge onwards, -never a sliding live view. - -`display_mode()` must be read **once per frame** and passed around (the tick does this, -and hands it to `bind()`). The producer can open or close a capture between any two -reads, and two reads in one frame put the bind, the epoch, the pin and the status line on -different clocks. - -**The display clock is not the stream clock, and `ScopeModel::stream_at_x0()` is the only -conversion.** `display_x = stream_t - stream_at_x0()` in **every** mode, and the offset lives in -one of two places: a snapshot carries its own (`EventSnapshot::x_offset`, frozen with the -picture) and a live view carries it as minus `display_epoch()`. Anything crossing between the -two clocks goes through that one accessor rather than picking the branch itself — the export's -`abs_seconds` column, the pause handler putting a display window back on the ring to cut it, the -measurement marks — because there are four modes and picking the branch by hand gets one of them -wrong. It reads atomics only, so it is safe to call inside an existing `mutex()` guard. -Three things about the live epoch specifically: -- **It is written after `bind()` and `acquire_all()`, not before.** It must be minus the newest - sample the *traces* can report, and asking before the acquire is one frame of production - stale — at 1 MS/s over a 50 ms window that is a third of the screen to the right of the axis. -- **There is no feedback loop**, even though the newest stamp is read through the epoch that is - about to change: `RowSet::live_edge()` reports in the epoch currently in force, so - `display_epoch() - live_edge()` is minus the *raw* stamp and the fixed point is reached in one - frame rather than converged on. -- **`Waiting` takes the live epoch too.** Nothing is bound, but the axis is still a live one, and - an epoch of 0 there would make the axis jump on the frame a capture closes. - -**A measurement mark is glued to its sample; a cursor stays on screen.** The two instruments move -in opposite directions under a rolling axis and that is deliberate — a bench scope's cursors sit -where the user put them while the waveform rolls under, and a measurement's mark names the sample -it measured. The cursors need no code: their x *is* a display coordinate. The marks are stored in -**stream** seconds and reconverted every frame by `place_measure_marks()`, since a mark left in -display coordinates would crawl forward at one window per window. `App::measure_window` is -stream-clocked for the same reason, and that one is load-bearing: the recompute trigger is "the -window moved by 2 % of its span", and in display coordinates a live window is a fixed -`[-window, 0]` — it never moves, so the measurements would never be recomputed again. - -Layers to keep straight: - -- `SignalInfo` — one signal as the CONFIG described it. The unit of enable/disable. -- `ChannelInfo` / `ChannelBuffer` — the unit of acquisition and plotting. A - `ChannelBuffer` is a `plot::data::Series` over a (value, time) ring pair. -- `SlotSeries` (`src/channel.hpp`) — a trace's stable series object, rebound each frame - to either a `ChannelBuffer` or an `EventSeries`. -- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to `kMaxRows` rows (added - and removed one at a time by the layout editor), each a - borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots. The figure owns - everything the rows have to agree on: the x window (one x link group every row joins), - the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since - `Figure::link_x()` refuses a `Live` member), the left gutter and the crosshair. - -- `ExportTable` (`src/export.hpp`) — the visible window as columns, collected from the - snapshot or the live rings and handed to the `.npy`/`.csv` writers. Always the stored - samples, never the LOD pyramid's buckets. - -`src/ui.cpp` is the GTK4 window (toolbar, trigger panel, signal panel, plot box) plus -the `tick()` frame function that binds, updates the trigger state and rewrites the -status line. One `plot::gtk4::PlotArea` in figure mode draws the whole figure. It is by -far the largest file. +| File | What it covers | +| --- | --- | +| [architecture.md](docs/claude/architecture.md) | The two threads and what they share; the one `BufferStore` and the four display modes (`Live`, `Capturing`, `Held`, `Waiting`); the display clock and `stream_at_x0()`; the layers to keep straight (`SignalInfo` → `ChannelBuffer` → `SlotSeries` → `RowSet`). | ### Invariants worth knowing before editing -- **`plot::Plot` cannot remove or retarget a trace.** Hence fixed trace slots created - up front and rebinding via `SlotSeries`; a *name* change (legend text) is the one - thing that forces a rebuild. Cursors are parked outside the axis range instead of - removed. -- **A `Figure`'s row count is fixed at construction**, so `RowSet::rebuild_all()` builds - a *new* figure and the area is handed it (`PlotArea::set_figure()`); it costs no GL - state. That makes one path for "a row's traces changed" and "the row count changed", - and it is why `rebuild_all()` restates a pinned event window (`pinned_pre_`/ - `pinned_post_`) instead of re-framing on the live edge — a drop during trigger mode - would otherwise throw the frozen view onto the stream, where nothing moves it off - again until the next latch. `PlotArea::create()` is handed `App::placeholder_plot`, - never a row: the area keeps a reference to the plot it was created with, and a rebuild - destroys every row. -- **A readout quotes a stored sample, never a value between two of them.** The hover - crosshair and the cursors pane's `mix` values are one call, `Plot::crosshair_value()`, and it - reports the **nearer** of the two samples bracketing the x (a tie reads the earlier). Upstream - interpolated linearly, and that is wrong twice over here: ingest decimation is a *boxcar - average* over `time_stride` samples, so a number between two stored samples is a second - estimate laid over the first that nothing on screen or in an export holds; and past the sample - spacing the polyline is a line the renderer drew, so a point on it reports the drawing. The - change is in `deps/plotter`, which keeps the blend as `CrosshairValue::Interpolated` for a host - whose x is a quantity it is willing to interpolate — the nearest sample is the **default** and - **udpscope never states the other**, so a `set_crosshair_value_mode()` call appearing here is a - regression, not a feature. - **Both the mark and the number come from one call, `Plot::crosshair_point()`**, which returns - the quoted sample's own **x** as well as its y — so every mark this app draws for a readout is - *snapped to the sample it quotes*. The library's `CrosshairRow::marker` and the cursors pane's - `mix` crossing annotations (`place_crossings()`, which must use `crosshair_point()` and not - `crosshair_value()`) are both placed from it. Two consequences that look like defects and are - not: the mark **sits beside the polyline at a deep zoom** (one y feeding both the number and the - dot is what keeps them from disagreeing), and a crossing dot **sits a little off its own - vertical cursor line** — up to half a sample period, invisible at a whole-window view and the - width of the panel at a few samples across, which is the only zoom at which anyone is asking - which point it is. The line says where the *marker* is; the dot says which *datum* is quoted. - The hover mark is also deliberately *larger* than a trace's own sample dots - (`Style::crosshair_marker_px` 9 against `marker_px` 3), because a snapped mark lands among them - and at their size reads as one more sample. The guards are the library's - `crosshair_value_is_the_nearest_sample`, `the_crosshair_marker_snaps_to_the_sample_it_reports`, - `crosshair_interpolated_mode_blends_the_two_samples` and - `the_crosshair_mark_is_bigger_than_a_sample_dot`, plus `[cursors-probe]`'s `first mark at x=.. - y=..` agreeing with the pane's first value. -- **`format_measure()` is the one formatter for both panes, and its SI prefix does not depend on - there being a unit.** The cursors pane's values and the measurements pane's `text=` go through - it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which - made two decimals of a small unitless value read `0.00` — the reading gone, not merely coarse — - and that is a whole class of channels, not a corner: `measure_unit()` returns `""` for any signal - whose CONFIG declares no unit, and a **math channel never has one**. The regression net could not - see it because `tools/mock_udpstreamer.py` declared `unit="V"` for every data signal; it now takes - **`--unit ''`**, and that is the flag to run the measures and cursors probes under when touching - this (`text=[636.62m]`, `● sine=585.24m`). With no unit the prefix is written **tight** against - the number — `1.34m`, not `1.34 m` — because the space exists to separate the number from its - unit and a spaced `m` alone reads as metres; with one it stays spaced and the prefix joins the - unit (`636.62 mV`). Below `1e-12` the ladder runs out, and `%.2f` would print `0.00` again, so - that range falls back to `%.3g`. -- **The cursors are read back from the plots only while a gesture is on that row.** `Cursor::x` - is the *free* pointer position while a drag runs and the snapped value otherwise -- which is - what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on - every frame therefore adopts its own echo back, and -- because the vertical pair is one value - shared by every row -- a row that has not been re-written yet hands the *previous* shared - time straight back. That is the "drag it and it springs back" defect, and the fix is - `RowSet::sync_cursors(int drag_row)`: the tick passes the row whose controller reports - `grabbed_cursor() != kInvalid`, and every other frame does nothing. The values are echoed - back with `CursorPlacement::Free` for the same reason -- the controller snaps at the end of - the gesture (`Controller::end_drag()`), and snapping the echo fights the hand that is still - moving the cursor. -- **The cursors' on/off is one flag on the `RowSet`, never a copy per row.** It is one - piece of state (the toolbar toggle) and the mode is one value; a per-row copy is something - `insert_row()`'s `clear_row()`, or any rebuild, can clear behind the user's back, which is - how a row added while the cursors were already on came up with its instruments parked - while every other row showed them. The same goes for the vertical pair's `x`: it is one - stream time for the figure, so it lives on the `RowSet` too, and `Row` keeps only what is - genuinely per row (the rulers' values, the traces, the colours). -- **A trace is a slot with a name *and* a channel; a name alone is not one.** `Row::channel[s]` - is re-resolved by `RowSet::bind()` every frame (slots are identified by name, see above), and - `-1` means the model built no channel for that name. Some names can *never* bind: the stream's - **time signal** is skipped by `build_channels_locked()` however often it is enabled, so a slot - holding it has a name for ever and data never. The plot showed nothing for it, which is why it - went unnoticed, but the cursors pane's `mix` mode drew one line per *named* slot and so gave - each phantom a row that could only read `--` — "the pane shows a lot of empty signals that do - not exist", six lines over three real traces. Two halves to the rule. (1) Anything that counts - traces counts `channel[s] >= 0`, not `!signal_name[s].empty()` — and a per-frame bind means the - cursors pane cannot key its rebuild on `generation()` alone, hence `App::cursors_pane_bound`, a - bit per row/slot (`kMaxRows * kSlotsPerRow` is 32, so one `std::uint32_t`): a trace that - appears without a structural change still gets its line. (2) `apply_layout()` refuses a name - that cannot produce a channel (`ScopeModel::is_time_signal()`), because such a name was - **sticky**: `collect_layout()` writes `signal_name` back verbatim, so one that got into a - session file was re-saved every session for ever. The refusal also heals an existing file on - the next save. How the names first got in is unexplained — the drop path already refuses a - signal with no channel — most likely an older build or a CONFIG whose time signal was not yet - classified as one; the fix is the stickiness and the counting, not a guess at the origin. -- **A row's contents are state of the `RowSet`, not of the figure.** `insert_row()` and - `remove_row()` shift that state with `carry_row()` and let `rebuild_all()` re-deal the - figure handles, so inserting above row 1 moves what row 1 plotted down to row 2 rather - than renumbering the rows under the user. `carry_row()` deliberately copies fields - instead of assigning a whole `Row`: a `Row` also holds the figure handles (`plot`, the - axis and cursor ids) and the `SlotSeries` objects the traces point at, and those belong - to the row's *position*. It clears the destination's slot bindings for the same reason — - the next `bind()` re-resolves them, and without the clear the frame in between draws the - moved row's samples under the new row's legend. - Both call `rebuild_all()` themselves, so their callers must re-point the area - (`PlotArea::set_figure()`) directly; `relayout_areas()` is no help, since it only acts on - a *dirty* row. -- **The layout editor's buttons are *separate* overlay children, never one transparent - child covering the area.** A `GtkOverlay` child is picked wherever its allocation is, and - `gtk_widget_set_can_target(FALSE)` takes a widget's children out of picking with it, so a - full-area `GtkFixed` would swallow every pan, drop and hover the editor is not interested - in. Each button is therefore a small widget aligned to a corner or the centre and placed - by its **margins**, and the overlay is only measured from its main child, so moving them - cannot reach the plot's layout. - They are placed from `FigureLayout::rows` — the rects the frame was *drawn* from — - divided by `PlotArea::device_scale()`, because a `Rect` is framebuffer pixels and a GTK - margin is logical ones. Reading the figure's own rects rather than dividing the widget - height by the row count is what keeps a `×` on the row it names when the rows stop being - an equal division. `sync_layout_editor()` runs every frame on purpose: - `gtk_widget_set_margin_*()` returns early on an unchanged value, so a settled layout costs - the comparisons and nothing else, and a resize needs no notification. -- **Trace slots are identified by signal name, not channel index.** Enabling a signal - or a new CONFIG renumbers the channel list, so an index would silently re-point a - trace at another signal. -- **Any structural change rebuilds the buffer and drops history**: a CONFIG, an - enable/disable, a calibration change, or a **change of display window that moves the - decimation factor**. Calibration is applied at ingest, which is why it re-partitions. - `ScopeModel::set_target_span()` is the window's path in, and it re-plans *only* when - `time_stride` actually changes, so walking the window inside one decade is free; it asks - `plan_partition()` rather than recomputing the factor, because two expressions for that - decision is how the buffer and the view end up disagreeing about the stored rate. It - takes `mutex()` itself — do not call it holding the lock. -- **The shared clock is decimated to fit the requested window; channels never are.** A - window too long to store at the wire rate used to be refused (the control was capped to - `Partition::span_s`), which made a 600 s view of a 1 MS/s stream unreachable. - `plan_partition()` now picks `time_stride`, a power of two, **after** the growth loop has - bought all the ring the budget allows — deciding it earlier over-decimates by whatever - factor growth was about to add. Per-channel `stride[i]` stays 1: a stride of - `round(dt_i / dt_fastest)` keeps one sample per stride, and when that equals the signal's - own period — a 2 kHz sine on a 1 MHz clock, stride 500 — every kept sample sits at the - same phase and the channel plots flat. -- **Decimation at ingest is an average, never a stride.** `Acquisition::on_data()` reads - and accumulates *every* sample and pushes the mean of each group of `time_stride` - (`value_acc_`/`value_acc_n_`, and `time_acc_` for the clock). That boxcar's first null is - at the stored rate, so content that would fold back is attenuated instead of reappearing - as a false low frequency — which is the whole reason decimation was once dropped - entirely. Skipping the read for unstored samples re-introduces the aliasing; the - `UDPSCOPE_PROBE_WINDOW_MS` run under `--freq 2000` is the guard (see below). - The stamp pushed is the group's **mean**, not its last member: that is where the boxcar's - group delay puts the value, and stamping at the end of the group shifts every trace half - a stored sample late. The accumulators are reset whenever `time_stride` or the channel - count changes, so a re-plan starts a fresh group rather than finishing one at the wrong - length. The LOD min/max pyramid still preserves the extremes *of what is stored*; a spike - narrower than a group is now filtered away rather than aliased, which is the correct - trade and is why the status line's `rate` field is worth reading. -- **The trigger sees the stored (filtered) signal, not the wire signal.** `engine.process()` - is fed the averaged value, inside the `if` that pushes it. An edge found on a sample that - is not in the buffer would be cut from data that does not contain it. -- **The renderer maps the visible range onto indices through the series' own x, never the - `dx()` grid.** `deps/plotter`'s `Renderer::draw_traces` asks `Series::neighbours()` for the - bracketing samples. A grid walk is wrong for exactly the series this app plots: `dx()` here is - a nominal spacing (the LOD level, a coarse snap) while the real stamps are the time ring, so - the window it computes lands inside the visible range and the two end segments — the ones that - draw into the left and right margins — are never emitted. That is the "empty margin when - zoomed in" defect. The library test is - `a_sparse_window_between_samples_still_inks_both_margins` (a stub whose `dx()` lies, with the - window strictly between two samples). It is the guard, not the probe: `[trig-probe/idx]` now - computes `got` from the same call the renderer makes, so it cannot catch a renderer that went - back to the grid — what it still reports is the drift that made the walk wrong (`dx_ratio`). - -- **`neighbours()` is a binary search over the real stamps in *every* series here, the - snapshot's included.** `EventSeries` answered from the `(x - x0()) / dx()` grid — the walk the - invariant above condemns: `dx()` is the *mean* spacing of the snapshot's stamps and the stamps - are an arrival clock, so grid and clock drift apart by whatever that clock wandered over the - capture. The renderer picks its visible index window from this call, so the drift arrived as a - **constant offset in seconds** between the axis and the gathered geometry — and a constant - number of seconds is a *doubling share of the screen* on every zoom step: 8 ms over a 2 s - capture is invisible, and seven halvings later it is 374 px of blank left margin on a 716 px - axis, with the ink pushed off the right-hand edge by the same amount. That is the "zooming - into a held event clips it more and more from the left while the crosshair still reads the - right values" report; the crosshair was right because it reads `x_at()`, a real stamp. - `ChannelBuffer` always searched; the two now agree. **A perfectly uniform source cannot show - this at all** — three probe runs against the ideal mock were clean — so the guard needs - `--clock-jitter` (see `UDPSCOPE_PROBE_EVENTZOOM_MS` above). -- **The `Series` grid contract does not hold for `ChannelBuffer`.** x is a streamed - timestamp, so `dx()`/`x0()` report a nominal grid (LOD level, coarse snap) while - `gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring. - Nothing may walk that grid to find *which* samples a range covers — the renderer did, - and it lost the two end segments (see the margin invariant above). `gather()` emits x - relative to an origin because absolute epoch seconds do not survive `f32`. -- **`gather()` emits x relative to an origin *the caller names*, in every series here**, - `EventSeries` included, and the consumer adds that same origin back in `f64`. Two separate - rules live here and both were learned from a defect. - (1) **It must be relative at all.** An event's stamps are small (edge relative, - `[-pre, +post]`), so emitting them absolute does not overflow anything and does not look - like a bug — it just draws the whole capture `x0()` = one pre-trigger window to the left of - the axis. That was the "half the plot is empty / the marker sits at the end of the data / - nothing is drawn but the crosshair reads values" defect: the crosshair reads `x_at()`, which - was right all along. `[trig-probe]`'s `data=` vs `drawn=` is the guard. - (2) **The origin is the *view's* minimum, not `x0()`.** `x0()` is the start of the - **retained window**, so the f32 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, which at a view 10 µs wide across a 716 px - axis is over a hundred pixels between where a sample is drawn and the x `x_at()` quotes for - it — and it halved on every zoom out, so it read as "zooming in makes the crosshair select a - point beside the data". `Renderer::draw_traces` passes `x_range.min`, making the error a fixed - 4e-5 of a pixel at any zoom. The parameter sits **before** `out_xy` deliberately, so a call - site that was not thought about is a compile error rather than silent drift. A caller that has - no view in mind may still pass `x0()`, and `[trig-probe]`'s `drawn=`/`data=` does exactly that - on purpose: it reads the series' *whole* span, which is the widest relative value a gather can - be asked to stage. The two guards are upstream's - `a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes` and the - `trace ink:` line's `drawn-vs-quoted` figure. - **A probe that recomputes what the renderer gathers must pass the same origin the renderer - does** (`[ink]` and `[zoom-probe]` both pass the axis minimum), or its reconstruction stops - being the renderer's and its pixel readings mean nothing. -- **Every series bound to a trace must carry a real `MinMaxPyramid`**, including an - immutable one. `MinMaxPyramid::level_for()` descends to level 0 — raw samples — on a - pyramid with no levels, whatever `n` is, so a series that answers `pyramid()` with an - empty one is re-gathered sample by sample on every frame it is bound. That was the - "large data is laggy" defect: `EventSeries` had no pyramid, and a held 2 s capture at - 1 MS/s staged 6 million segments per frame for as long as it stayed on screen. The - pyramid lives on `EventSnapshot`, not on `EventSeries`: the snapshot is immutable, so it - is built once in `publish_event()` inside the `O(n)` pass that already computes the y - bounds, and the same channel on two rows shares it. It costs ≈1 byte per sample per - channel. `frame path:`'s `segments` is the guard. - **"Every" includes the pause**, and that is the half this invariant was written without and - which then went wrong for real: `publish_pause_view()` filled `stamps`, `values` and `y` and - left `pyramids` empty, so a *paused* view was re-gathered raw on every frame. Both halves of - the cost are readable, and the second is the one a performance line cannot show. - `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 12.34 → 18.18 ms — and the **trace changed appearance**, which is what - gets reported. `stage_trace_density()` grades a column against *its own peak* from a `1/height` - deposit per segment, so a column fed ~660 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 out. **A pause is not a cheaper case than a capture — it freezes the same rings**, so - anything `publish_event()` does to a snapshot, `publish_pause_view()` owes too. Building it is - not free (see `publish cost:` below): it took the pause publish from 6.15 ms to 22.31 ms, a - one-off producer stall against ~5 ms a frame of draw for the pause's whole length, which at - ~60 fps repays about thirty times over on a 1.6 s pause — and the producer absorbs it without - losing packets (`gaps=0` with and without a pause, the paused run at an 86 ms publish). - **The check that the look is restored is `frame path:` across the pause, not the cost line**: - a paused run must read the same path as a live one — measured `raw=1 lod=555 columns=555 - graded=555` against `raw=0 lod=373 columns=373 graded=373`, same 24711-segment worst frame, - segment mean within 5 % — where the defect read `raw=21 lod=118`. A `raw` in the tens over a - pause is this returning, and it is the appearance as much as the cost. -- **`EventSeries::gather()` emits a bucket's envelope, not every `2^level`-th sample.** - Point decimation hides exactly the spike the capture was triggered on; a scope has to - draw a bucket's extremes. It needs no index rebasing, unlike the ring: the snapshot's - pyramid is built from sample 0 and nothing is ever dropped from it, so `window_base()` is - 0 and a window index *is* the absolute one. -- **A trace denser than the axis is drawn one pixel column at a time, and that is what keeps - the plots free of moiré.** The envelope staged as a polyline banded visibly and *crawled* - under a pan on exactly the signals this app is pointed at — a high rate, a high frequency or - noise — and three things compound to produce it, none a bug alone: `level_for()` sizes a - bucket at one to two pixel columns, `Envelope` emits two points per bucket so each - contributes a near-vertical min→max hairline *and* a near-vertical connector (≈3.6 per - column per trace), and every one lands at a **fractional** x because a trace is deliberately - not snapped. Under src-alpha compositing a column's brightness is then a non-linear function - of the sub-pixel phases overlapping it, the pattern repeats at the beat between the bucket - pitch and the pixel pitch, and a pan sweeps the beat. `deps/plotter`'s - `Renderer::stage_trace_columns()` emits one snapped vertical segment per integer column - instead, so the trace has **one** phase and there is nothing left to beat. Four things to - know before touching it, all upstream in `docs/data-and-performance.md`: the gate is the - gathered **density** (≥2 points per column) and `Envelope`, never the LOD level — the raw - path at 1.5–64 spp beats the same way, and the density term is also what keeps the reduction - off a *sparse* window whose trace is the two segments entering and leaving the frame; a hole - stays a hole; disjoint neighbours are joined so a fast edge keeps its join; and it is the one - carve-out in `u_snap`'s "never anything drawn at a data position" rule, because a column *is* - a pixel column. - **It needs a thin ribbon to be visible at all** — a window whose buckets span several periods - paints a saturated block and hides the beat — so a screenshot of a slow sine at a whole-window - view says nothing either way. The guards are upstream's - `a_dense_trace_is_drawn_as_whole_pixel_columns`, which counts the **distinct coverage levels** - in the ink (4 against the polyline's 126; a count of *partially covered* pixels separates - nothing, 577 against 652, because a 1.5 px stroke's fringe always spills into the next - column), and `frame path:`'s `columns=` field here. `trace ink:` deliberately cannot see this - path: it recomputes the **gathered** points, which the reduction is downstream of, so its - `drawn-vs-quoted` reads 0.0000 either way and remains the guard for the gather origin alone. -- **A reduced column is graded by dwell, and the grading may not undo the reduction.** The - column above is honest about *where* the signal went and silent about *how long it was there* - — a column's min and max are identical whether its samples fill the span or pile at its two - ends, so a ramp and a pulse are one flat band. `Style::intensity_grading` grades it instead - (bright where the signal sits, faint where it only passes through, the digital-phosphor look), - and **udpscope turns it on; the library's default is off**, so other hosts are not restyled by - an upgrade. The switch is a one-shot in `tick()` guarded by `App::style_stated`, not a line in - the window build: `PlotArea::renderer()` is null until the area is realized, so there is no - earlier place to state a style. Three things to know here, the rest upstream in - `docs/data-and-performance.md` (the `1/height` dwell weighting, the one-column accumulator, the - per-column-peak normalisation, the quantised alpha steps): - * **The finer gather level is floored at 1 whenever the flat path would have used the - pyramid.** Grading asks for `samples_per_pixel / intensity_sub_columns`, and - `MinMaxPyramid::level_for()` answers **0 — raw samples** — as soon as that falls under one - bucket (`kBucket` is 64). Unfloored, every moderately dense frame became an O(visible) - gather: at 1.3 MS/s over a 2 s window, 172 samples a pixel, `raw=1154 lod=0` against the flat - path's `raw=710 lod=453`, and 2.30 → 6.36 ms of draw mean. The floor degrades the *look* - (fewer sub-columns than asked for, down to what the coarsest buckets give — still two or - three per column at 64 samples a bucket) and never the path. It is also why - `intensity_sub_columns` past about 8 buys nothing: the bucket size is the limit. - * **`graded=` must equal `columns=`** in `frame path:`, and `UDPSCOPE_NO_PHOSPHOR=1` is the A/B - that makes either line readable against the ungraded path (see Verification). - * **The steps are draw calls, not per-segment alpha.** `LineInstance` is four floats of - geometry and the colour is a **uniform** (only `TextInstance` carries `a_colour`), so the - gradient is `intensity_steps` `LineBatch`es flushed at different alphas. That is why the - segment mean rises from 131 to 1340 on the same frames — the rise is the tiers, not extra - geometry per column, and the bound is still `axes.w` (measured 14.5 runs a column in the - library's own guard, 8303 segments over a 572 px axis). - The guard is upstream's `a_dense_trace_can_be_graded_by_dwell`, which draws one frame both ways - and profiles the band's outer fifths against its middle: flat reads **191/191**, graded - **191/131**. That 0.69 is a *tier* ratio, not the dwell ratio — `intensity_gamma` (0.45) and - `intensity_floor` (0.3) compress it deliberately, the floor so that a fast riser, one - sub-bucket deep everywhere, is never dimmed out of existence. -- **The time ring is `f64` and the value rings are `f32`, and the asymmetry is the point.** - A value's precision is a property of the signal (24 bits is plenty for a sample); a - timestamp's is a property of *how long the scope has been running*, because the stamps are - stored relative to an epoch fixed at the first sample ever seen and so grow without bound - while the interval they must resolve stays one sample period. `f32`'s ULP is 1.9 µs at 16 s - of uptime, 3.8 µs at 32 s, 7.6 µs at 64 s and 61 µs over a 600 s window — so at 1 MS/s two - to four consecutive samples shared a stamp after half a minute, eight after a minute, and - the LOD envelope over such a plateau drew a zero-width segment, i.e. a vertical spike where - the waveform should be. That is "zoom in to ~10 µs and three to five points share a - timestamp, and min/max produces artefacts". **Do not widen the values to match**: they are - the memory budget, and nothing about them is magnitude-bound this way. Three consequences: - `plan_partition()` can no longer count slots in its growth loop (a time slot and a value - sample are different sizes, so it weighs the budget in **bytes** — `sizeof(f64)` for the - clock plus `sizeof(f32) / stride[i]` per channel — and `bytes_for()` is also what bounds - the doubling against overflow); `live_window_locked()` compares its bounds in the ring's own - type with **no rounding step**, the old `f32` round-trip having existed only so that a - sample sitting exactly on a bound was kept whatever the stream's absolute time was; and - `EventSnapshot::stamps` is `f64` because it is cut from this ring and is therefore in the same - units, whatever a given snapshot's own origin is (both kinds are rebased now — an event's - stamps are edge-relative, a pause's freeze-relative — so neither is magnitude-bound the way the - ring is, and the width is for the cut rather than for the coordinate). `RingBuffer` stores - slots as `std::atomic` behind a `static_assert(is_always_lock_free)`, which `double` - satisfies on every supported target. The guard is the **export**: over a multi-million-row - `.npy`, `np.diff(t)` must hold one step to within a few ULP of the column's magnitude (see - the export guard above for why it is no longer exactly one value). Three steps at 2, 2.5 and - 3 ULP of *the stream's uptime* — microseconds, not femtoseconds — is this defect. -- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins - the published window to the value ring's base and both rings keep sliding afterwards — - their capacities are equal, so there is no slack — which means the oldest few samples - of a saturated window routinely point at time slots the producer has already - overwritten. `ChannelBuffer::time_of()` therefore clamps to the oldest *readable* - slot: a stamp of `0` would reach `x_bounds()`, and from there `dx()` and `x0()`, which - is the renderer's whole index mapping — one unreadable slot inflated `dx()` by the - ratio of elapsed stream time to the retained window and clipped the older part of - every live view. The library's `StreamSeries` is immune for a reason worth copying: - its x is `x0 + i*dx`, a function of the absolute index, so an overwritten slot costs a - *y* value, never the geometry. Clamping buys the same bargain here. -- **The window's *first* stamp is cached by `acquire()` and never read at paint time.** The - clamp above makes an unreadable slot *survivable*; it does not make reading it *safe*, and - `absolute(0)` is not an ordinary slot — `acquire()` pins the window to `values_->base()`, so - sample 0 is the ring's oldest, the very slot the producer is about to overwrite. `time_of()` - checks the bounds and then copies, and when the producer laps the slot in between the stamp it - returns is **one whole ring-span too new**. Everything else on a `ChannelBuffer` is read at - paint time with no lock, and `x0()` *was* the origin the renderer reconstructed every trace - point from (`gather[i] + series.x0()`), read both inside its increasing-suffix filter and again - at `stage_xy()` — so one torn read of that one slot produced three pictures, all of them "the - waveform is gone while the grid and the axes stay": `neighbours()` tests against `x_at(0)` and - refused the whole visible range (the renderer then gathered the *entire* ring at a coarse level - and drew it off the axis — `idx=[0..8388608) level=7 cover=320%`); `x0()` changing *during* the - filter loop dropped three quarters of the points; and `stage_xy()` shifted the finished polyline - a ring-span off its axis. The gather origin is the *view's* minimum now (see the `gather()` - invariant), a local of the renderer's, so the second and third of those are structurally - impossible — but **the first is not**, `x_at(0)` is still what `neighbours()` tests the visible - range against, and that is the one that gathered the whole ring. The cache is load-bearing for - that reason; do not read the slot at paint time because two of its three symptoms are gone. - That is the "with measurements and cursors active the plot flickers as - if there were no data, at no constant frequency" defect. It is **live only** (a snapshot is - immutable, so `x0()` is constant for `Held`/`Paused`), it is a race against packet arrival so it - is irregular, and the measurements pane makes it both likelier and larger: the pane holds - `mutex()` for each `MeasureWindowReader`'s life, so the released producer bursts several packets - at once. `acquire()` runs on the consumer under `ScopeModel::mutex()` — the same lock the - producer's pushes hold — and the window is pinned on the line above, so that is the one point in - the object's life where the slot is safe to read; `window_x0_` is also the *right* answer rather - than merely a stable one (it is the stamp sample `window_base` actually carries, where a - paint-time clamp answered with whatever slot happened to be oldest *now*). `x_at(0)` is served - from it too, for the same reason, and `invalidate()` clears it with the window. The guard is - `trace ink:` / `[ink]` (see Verification). The same torn read still exists for a small `i > 0` - — the oldest frame or so of samples — but there its observable is a *prefix plateau*, which is - exactly what the renderer's increasing-suffix filter was written to absorb, so it is a different - symptom and deliberately not fixed here. -- **`clamp_to_data()` caps the span at the *requested window*, never at the retained - span.** It runs every frame and cannot distinguish "the user zoomed out past the data" - from "the ring has only just started filling", so `window_s_` — what the window control - states, capped by `--max-window` — is the bound. Clamping to `retained` alone turned a - requested 2 s window into the ~0.02 s the first frames held, permanently: nothing there - grows a span back. Loosening it to `max(retained, window_s_)` fixed that and broke the - other state instead — while the ring is filling the two are equal, but once it holds - more than the window the bound becomes the whole ring, and a wheel zoom-out walked the - view into data the window was never set to show. `window_s_` is right in both: it never - shrinks a window a caller asked for, and a partly filled buffer is left empty on its - left, which is what a scope that just started should look like. `UDPSCOPE_PROBE_PAN_MS` - reports it as `zoom ... span=.. window=.. limited=1`. - Its pan clamping is likewise gated on the data being wider than the view, or the empty - left-hand side drags the live edge off the right-hand edge. -- **Layout must not depend on content.** The y gutter is reserved at the widest label - `TickFormat::Si` can produce *for an axis with no unit* — a sign, three significant - figures and a one-glyph prefix, measured and not counted in characters — and only ever - grows; the status label is ellipsized so - its text cannot drive the paned divider. Both exist to stop per-frame relayout. - The reservation deliberately does **not** cover `format_si()`'s `%.3g` fallback - (`-1.23e-45`), which it used to: that form needs a value past 1e24, so it bought a third - of the gutter for a label no stream produces, and the floor being a monotone maximum over - the *measured* need means one that did appear widens the gutter for good anyway. Erring - wide is not free here — it is a third of the plot's width. - And **nothing may add slack to the gutter it is told**: `note_y_gutter()`'s argument is - `layout().y_axis_width`, which is `max(measured, floor)` — partly our own answer coming - back — so a constant added to it compounds. See the ratchet reading under Verification. - The same rule is why the activity badge is one glyph in three colours rather than - three glyphs, why the `rate` field collapses to one figure or a range instead of a - per-channel list, and why `format_elapsed()` steps to a coarser unit instead of - letting the counter grow: each is a status field whose *width* would otherwise depend - on the stream, and the status row is directly above the plot. - The reservation goes on the **figure** (`Figure::set_min_y_gutter()`, added to the - plotter for this), not on the rows: `Figure::layout()` clears every row's - `set_min_y_gutter()` to measure what its own labels need, so a floor written onto the - rows survives exactly until the next layout. -- **The right-hand band is a want, and the left gutter is a measurement.** The two sides of - the plot are not symmetric in kind: the left holds the y labels, so it is measured and - floored; the right holds nothing, so there is nothing to measure and `RowSet` simply asks - for half the widest time label plus the label padding (`right_gutter_px_`, reserved in the - same one-shot block). Half a label is the figure that matters: an x tick label is - *centred* on its tick and a live axis is `[-window, 0]`, so there is always a tick exactly - on the axes' right edge, and with `axes` flush to the viewport half of that label was - drawn outside the plot. Both go on the **figure** and for the same reason — the rows have - to end on one column as well as start on one — which is why `apply_gutters()` applies the - pair and `rebuild_all()` calls it: a new `Figure` carries neither. -- **Signals start disabled** — enabling is what starts acquisition. A trigger on a - signal nobody acquires can never fire, so selecting/arming enables it. -- **The trigger's event window is separate from the display window**, and - `pretrigger_pct` is a share of the *event* span, not of the ring. -- **The event window is counted in stream seconds end to end; nothing converts it to - a sample count.** `TriggerEngine` latches once the stamps it is fed have advanced - past the post window, and `ScopeModel::publish_event()` cuts the snapshot by binary - search over the time ring. There used to be a measured samples-per-second estimate - in `Acquisition` doing that conversion, and it is precisely why the post window came - out empty: any stream whose time signal advances in large steps drove it to its - 1 S/s floor, the count came out as 1, and the engine latched on the edge sample - itself. **Do not reintroduce a rate estimate on this path.** -- **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.** - While an event is held, a capture is filling, or the view is paused, `tick()` calls - `RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and - clamps the position into it (and states `Off`, since a following group would re-derive from a - live edge). **A zoom in must keep working** -- "not outside the event" is not "not readable", - and this function's first version *re-stated* the pin every frame, which made the wheel a no-op - in single trigger mode: an acquired event could not be examined at all. - **The request is read from the row's own axis, not from the group.** A member's `set_x_range` - -- what the controller writes for a gesture -- is *not* adopted into the group while the follow - is off, so a clamp that read the group only ever saw its own previous answer (the probe showed - `group` and `member` both equal to the pin while the ask was gone). `pinned_stated_` remembers - what the clamp last wrote: a member that matches it is our own answer coming back, one that - differs is a gesture to honour -- which is also what keeps a *fresh* pin - (`pin_event_window()`, `pin_pause_window()`, both of which write the group in the same tick) - from being reverted to the member's stale window. Without it nothing bounds a drag or - a zoom in those states: the library clamps a *following* group's offset only, and the live - clamp is deliberately skipped here — so a gesture walked the frozen view off its axis, over - data the scope never had. The two are one branch in `tick()` for that reason. A paused view - pins the same way (`pin_pause_window()` sets `pinned_` from the frozen range), so the frozen - picture is equally immovable; resuming or leaving trigger mode clears it. - -- **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the - one that was closed: the window, the rows and their slot signals, the calibration, **the enabled - signals**, **the math channels with their graphs** and **the measurements pane**. Signal identity - is the *name* throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the - model built -- an enabled signal is exactly one that produced a channel -- and `apply_layout()` - installs the graphs **before** assigning the rows, since a row may hold a math channel's name and - that name is only a signal once its graph is in. Two format rules, both learned the hard way: - write an op or a measure *type* as its index through `math_op_at()`/`measure_type_at()`, never as - the raw enum value -- those accessors' orders are the dialog's, not the declaration order, so - writing one and reading the other silently renames operations. And a top-level key written after - a `[[table]]` belongs to *that table* in TOML: `enabled = [...]` emitted after `[[calibration]]` - was parsed into the last calibration record and the enable state was lost without a word. - `mkdirs()` used to create only components with a trailing slash, so `~/.config/udpscope` was never - made and the *first* save failed silently -- worth knowing when a layout change "does not stick". - -- **A math channel *is* a channel.** `ScopeModel::set_math_channels()` appends one *synthetic* - `SignalInfo` (with `math = true`) per math channel after the stream's own, so the panel's switch, - the plot binding, the measurements and the export all handle a derived channel without knowing it - is derived -- and another math channel is a valid `Source` for exactly the same reason. The - *samples* come from the producer: `Acquisition::on_data()` collects each channel's value for the - slot and calls `ScopeModel::push_math_sample()` **once per stored slot**, before the deferred - latch (`publish_event()` clamps its snapshot to every ring's `produced()`, so a derived sample - pushed later would be missing from the capture's newest slot). The math therefore runs on the - *stored* (filtered, decimated) samples, which is also why `math_prepare()` gets `slot_dt() * - time_stride`: a cutoff the user typed in Hz means the rate the filter will actually run at. - `src/math_channel.cpp` is the engine: a graph evaluated as a **list walked in order**, so a node - may only read ones *before* it and a cycle is impossible by construction rather than by a check - (`math_channel_valid()` states that as a message the dialog shows). `/tmp/check_math.cpp`-style - checks verified the biquads against known responses (LP fc=100 Hz: 50 Hz -> 0.97, 2 kHz -> 0.002; - BP fc=1 kHz q=2: 200 Hz -> 0.05, 1 kHz -> 0.95, 4 kHz -> 0.05) -- **keep test sines below - Nyquist**: at `dt = 1/10000` a "5 kHz" test tone is the Nyquist point itself and says nothing. - The editor is the *compact graph*: nodes as boxes on a `GtkDrawingArea`, click to select (its - parameters in the form beside it), click an output port then an input port to wire, drag to move; - no pan/zoom. Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`, - so no rebuild can invalidate a live callback handle -- the same shape the measurements dialog - uses. `UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test - as much as a state check), and the dump prints each derived channel's extremes beside the - source's. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the - math is on the producer thread, so what it can break is packet capture, never the frame rate) - and each `math '': ... N samples vs M time slots` being **equal**, with `math x2` at - `y=[-2, 2]`, `math lp` at `y=[-0.998, 0.998]` and `math3` at `y=[-2, 2]`. The `0 visible - (order breaks 0, first at -1), y=[0, 0]` tail of that line is **not** a failure: it is - `visible_samples()` reading row 0's axis after the window was destroyed, and - `UDPSCOPE_EXIT_AFTER_MS` arms the trigger first, so the dump can land in `Waiting`. The live - `ch N '' ... y=[..]` lines are the race-free reading of the same thing. - `math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the - math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure - anywhere in 78--101 ns/slot is one reading of the same thing. Four rules here were wrong first - and are worth keeping: - * **A `Source` may only name an *acquired* channel** -- `ScopeModel::channels()`, which is what the - dialog's dropdown lists, **and both places that create a node must take one from there**. - `signals().front()` is the stream's *timestamp* signal: it is first in every CONFIG, it is not - acquired, and a graph sourced from it validates and then reads NaN for ever. The dialog's - default was fixed first and the pane's `+` was not, which is what "[ch] -> [gain x2] produces - only nan" was. The dropdown also *reconciles*: a node whose source is not in the list is moved - to what the form is showing, rather than the form showing one channel while the node holds - another, and `Apply` refuses a source that is not acquired (the engine's validator is about the - graph -- an output, every input wired above it -- and cannot see the acquired list). - * **The ingest must skip a derived channel.** `Acquisition::on_data()` iterates every channel and - reads each one from the packet, so a math channel with no signal read *zero* and pushed it -- - while `push_math_sample()` pushed the real value on the same slot. Two pushes per slot into a - ring whose time ring advances once: the values alternated between the real one and 0, and once - both rings wrapped the value ring had drifted a whole window off the time ring, so the derived - trace disappeared. `ChannelInfo::math` is the flag (`channel.math`) and the loop `continue`s on - it. **A derived channel's `values(c).produced()` must equal `live().time().produced()`** -- one - push per stored slot -- and the dump prints both as `N samples vs M time slots` for exactly - this: the arithmetic on the plot can look plausible long after the pairing is wrong. - * **`gtk_window_destroy()` does not emit `close-request`.** The math dialog's pointer is cleared - by a `close-request` handler, so a Cancel that *destroyed* the window left `app.math_dialog` - pointing at a dead widget: every later `open_math_dialog()` took the "already open" branch and - presented it -- a GTK critical, and nothing on screen, which is "I cannot edit a math channel - again". Both paths close (`gtk_window_close()`), which emits the signal, lets the handler clear - the pointer, and then destroys. The same lesson as the other GTK traps here: a programmatic - destroy is not a user close, and only one of them runs the handlers. - * **Boxes must not overlap on the canvas.** The first auto-place staggered nodes by 24 px for a - 132 px box, so every box sat on the one before it, `math_node_at()`'s topmost-first search always - returned the last, and a drag or a click landed on the wrong node -- which reads as the dialog - ignoring the pointer. Two columns spaced 160 px apart, rows 58. The probe prints the box the - drag *began over* for exactly this reason. Offering the whole signal list let a graph name something nobody is - acquiring (no data for ever) and put the stream's timestamp signal, which has no samples, in it. - * **`prepare_math_locked()` runs from `rebuild_locked()`, so *every* plan change re-prepares the - filters**: a CONFIG, an enable/disable, and a window change that moves the decimation (that path - is `set_target_span()` -> `rebuild_locked()`). Coefficients are a function of the *stored* - interval and the filter's state is the old rate's history, so both are rebuilt together. - Preparing only on an edit left the cutoff wrong and the state ringing after any window change -- - that is what "I tested a low pass and it was a mess" was. - * **The graph's output defaults to the *last* node, and a node added to a linear graph becomes - it.** The output began at index 0 -- the *source* -- so every operation a user built was - computed and then thrown away: a gain of 2 and a low pass both produced a channel equal to its - input. The output is drawn as the orange dot and settable explicitly, so an earlier node chosen - deliberately is kept; this is the default for a graph that has never been given a choice. - * **`gtk_spin_button_new_with_range()` can leave the value at the range's *minimum*.** The offset - field began at -1e6, and `on_math_param_changed()` reads the widgets, so `x*2` saved as - `x*2 - 2000000`. Every numeric field is seeded with its own identity value; the range-minimum - default is never a useful starting point for a gain or a corner frequency. - * **The form's rows are grouped per *op*, not per field**: gain and offset are one group because - `gain + offset` is one op. The offset was a row of its own that nothing ever hid, which read as - a property of every node. - * **A `GtkSpinButton` holds zero decimals by default**, so every numeric field sets its digits - (4 for gain/offset/clamp, 3 for `fc`/`Q`); without that a 12.5 Hz cutoff was stored as 12 and a - gain of 0.25 as 0. And the canvas drag reports the *total* offset from where the gesture began, - so adding each update's delta to a value the handler then writes back is a runaway box -- it - must record the position at `drag-begin` and ignore a drag that did not begin on a box. - -- **Every instrument this app *computed* is out of the pointer's reach.** The trigger marker and - the measurement marks are cursors created with `Plot::add_cursor()`/`add_h_ruler()`, and a cursor - is grabbable by default -- so each takes `set_cursor_selectable(id, false)`. **Parking it outside - the axis is not enough**: the controller holds a marker for the gesture's life and follows the - pointer between frames, so a readout parked at `kParked` could still be dragged. The trigger - marker says where the scope put the edge and a measurement mark says what a measurement measured; - neither is something a user may move. The dump prints `readout instruments locked: N`, which must - be 1 -- a drag is not something a probe can perform, so the state is what is asserted. - -- **A pinned view is navigable *within its data*, and that is the bound -- not the request.** - Two things had to agree for this, and both were wrong in the same direction: - the **pause** pinned on the window it asked for rather than on the snapshot it got (a cut - clamped to the ring is shorter), and the **held event** pinned on the snapshot's recorded - `pre_s`/`post_s` -- the *request* again -- which a capture shorter than the request exceeds. - Either way the shortfall was drawn as an empty margin, and a zoom-in moved the view *into* it, - which is what made it look as if zooming caused it. `tick()` now re-states the pin from - `RowSet::event_data_window()` (the bound **event series'** own stamps) while an event is held, - from the snapshot's stamps on a pause, and `pin_pause_window()`/the pause handler set the axis - to it as well; `[pause-probe]` prints `axis=` beside `frozen_x=` and the axis must lie inside. - A `Capturing` view is the exception and stays on the *requested* `[-pre, +post]`: its post side - is empty on purpose, and that is the capture filling in view, not a margin. - -- **A pause is pinned from its snapshot's own stamps too, not from the window it requested.** The - pause freezes the *visible window*, but `publish_pause_view()` cuts that window out of the ring - and the cut is clamped to what the ring holds -- so a window reaching back past the oldest - retained sample produces a *shorter* snapshot. Pinning the axis to the request left the shortfall - as an **empty margin on the left**, visible before any zoom and made worse by one: zooming in - moved the view *into* the empty part, which is what made it look as if zooming caused it. The pin - is the snapshot's own stamps now. `[pause-probe]` prints `axis=` beside `frozen_x=`, and while - paused **the axis must lie inside `frozen_x`** -- equal at the moment of the pause, a subset - afterwards if the probe zoomed in. Before the fix the two disagreed outright: - `axis=[-1.44..0.56]` over data that started at `0.0`. (The event pin already worked this way; the - pause now agrees.) - -- **The event axis is pinned from the snapshot's own stamps, not from the request.** A - capture may legitimately be shorter than asked for — the ring need not reach back a - full pre-trigger share on the first latch after arming, so `snap=[-0.339..1.000]` - for a ±1 s request is correct there rather than a defect. -- **The frame that *frames* a snapshot is the one that consumes its `event_seq()`.** The - tick reads `display_mode()` *before* it takes `mutex()`, while the producer publishes - inside that mutex and lowers `trigger_pending` only at the end of its packet — so a tick - can read `Capturing`, block on the lock through `publish_event()`, and reach the framing - step with a new sequence number it is not bound to. (The other case is benign and was - always handled: a publication and the *next* edge in one frame, where the capture has - framed its own window.) Advancing `seen_event_seq` on such a frame dropped the pin - permanently — the axis kept the capture's requested `[-pre, +post]` over a shorter - snapshot and `event_held` stayed false, letting `clamp_to_data()` drag the frozen view — - so the test is `seq != seen_event_seq && display == Held` and the pin stays owed until a - frame is bound to the snapshot. Visible only at high rates, where the publish is long - enough to lose the race often; `[disarm-probe]`'s `axis=` against `snap=` is the guard, - which means **running the disarm probe at 10 kS/s alone does not check this.** -- **A capture in progress is the one place the axis is pinned from the *request*.** There - is no snapshot to measure yet, so `DisplayMode::Capturing` frames - `[-(span - post_span_s(span)), +post_span_s(span)]` — computed with the producer's own - `TriggerConfig::post_span_s()`, not a second expression that rounds differently, because - the marker at x = 0 has to land where the coming cut will put it. The latch then re-pins - from the snapshot, and the two agreeing is why the picture does not jump at the latch. -- **`clamp_to_data()` must be skipped while `capturing` as well as while `event_held`.** - A capture is live data on a pinned axis — the exact combination the clamp was written - for and against: it would follow the live edge and drag the frozen window off the event. -- **`publish_event()` cuts one absolute index range and uses it for the time ring and - every value ring.** That is what makes `stamps` and each `values[c]` equal-length - and index-aligned by construction instead of by two truncations that can disagree — - `EventSeries::gather()` indexes both at the same `i`. -- **`live_window_locked()` is the one live cut, shared by `publish_event()` and the - export.** Both need the same absolute index range over the time ring and every value - ring, or an export and a capture of the same window would hold different samples. Call - it under `mutex()`. -- **A trace's colour is indexed by *signal*, not by trace slot.** `Row::color_index` - stores it and `signal_color_index()` (ui.cpp) computes it from the signal list, so a - signal dropped on two rows is one colour and the signal panel's chip names a trace - unambiguously. `plot::Plot` fixes a colour at `add_trace()`, so changing one needs a - figure rebuild — which `assign()` already forces. A signal on no plot gets - `RowSet::unplotted_color()` (grey) rather than no chip, so the panel's rows stay the - same height. -- **The export is collected at click time, not in the file-dialog callback.** The live - view keeps scrolling while the chooser is up, so a late collection would write a - window the user never saw. The writer is picked by the chosen path's extension. -- **The measurements pane is refreshed on a cadence, and its cost is the *window's*, not the - frame's.** A measurement is a statistic of the visible window and a live view moves that window - every frame, so "recompute when the window moves" is "recompute every frame" at O(visible - samples) *per measurement*. With the pane holding four measurements over a 4.7M-sample window - that measured **13.66 ms of tick mean** (baseline 0.41 ms) and cost the run a third of its - frames. Three things fixed it, and all three matter: `update_measures()` recomputes at most - every `kMeasureRefreshUs` (100 ms; 400 ms when a pass costs more than `kMeasureHeavyUs`), once - when the window settles and once per mode change -- and reading *that* backwards is what made - it worse first, 457 recomputes in one run, because a pinned window then recomputed every - frame; the window is materialized in bounded chunks (never two whole-window f32 temporaries) - and the loop holds it **by reference** (copying both vectors per measurement was 300 MB of - memcpy a frame); and `measure_stats()` is one shared pass carrying both extremes with their - first positions, the sum and the count, so average/min/max/peak-to-peak/amplitude need no - pass of their own. **Adding a type must not add a pass** -- that is what `compute_measure()`'s - `stats` argument is for. - The marks' *ink* is set where they are created (`install()` in `plot_host.cpp`): 0.75 px and - dashed 4/4 through `Plot::set_cursor_stroke()`, which is per cursor because the trigger marker - (also a cursor) keeps the solid `line_width_px` stroke. A new measure mark must take that - stroke -- it is created in the same loop, so it does; a new *instrument* should not. - The **marks** the pane draws (`set_measure_marks()` -- full-width `h_ruler`s and full-height - cursors, i.e. the "lines across the plot") are re-placed every frame from the result, so a - throttled pass is a *stepped* ruler: acceptable, because the ruler and the number beside it - come from the same pass and agree. What is *not* acceptable is a ruler that appears and - disappears, so an invalid pass falls back to the last valid result for its **level** marks and - only drops its **time** marks: a level is a property of the signal (a mean, a top, an - amplitude) and outlives the window it was measured in, while a time mark is a position *inside* - that window and may not. Both are emitted in **one pass after the numbers, never inside the - loop that computes them**: that loop has three `continue`s (an unresolvable signal, a channel - with no window) and each one was a way for the rulers to blink off and back. Fixing one and not - the others is how that bug survived a round of "fixes". `clamp fires: N` in the dump is the other half of this: `clamp_to_data()` - compares with a relative tolerance, because clamping to exactly `window_s_` can leave the - group's span an ULP above it and a clamp that fires every frame is a re-frame loop, not a - clamp. - -- **A measurement must not read its window raw.** Both passes go through - `plot::data::MinMaxPyramid`, which the producer already maintains at amortised O(1) -- so the - pane inherits the summary of the new samples rather than computing one, which is what the - "evaluate only on new samples" ask comes to here. Throttling the cadence (above) bounded *how - often*; this bounds *how much*, and it is the larger factor: 19.7 ms per recompute to 1.2--1.6 - ms, reading **0.1 %** of a 2 M-sample window. Pass one (`measure_scan_buckets()`) adds whole - level-0 buckets -- 64 samples each, carrying min, max an **exact** sum and a finite count -- and - locates each extreme to a *bucket*; one raw `measure_scan()` of that bucket alone then pins it - to a sample, two buckets at most whatever the window's size. Pass two - (`measure_accumulate_bucket()`) folds a bucket's **sums** only when it lies **strictly** on one - side of its level (`k.lo > level` or `k.hi < level`): then every sample falls in the same bin and - none is *at* the level, so the fold is exact and not an approximation. It asks **separately** - whether the bucket enters a side of the hysteresis band (see the invariant below), because a - single test against `level ± h` refuses exactly the buckets a crossing passes through. A bucket - wholly past a threshold holds that crossing at its first sample, exactly -- which is where the raw - walk would have found it. It refuses a bucket that straddles the level, one that enters a side - somewhere inside itself, and one holding a non-finite sample (the raw walk takes the first - *finite* sample of the new side and an aggregate cannot say which that is); the caller then opens - that bucket. **Nothing may be folded before a refusal** -- a refused bucket goes to - `measure_accumulate()`, which adds the same samples again, and sums added above the `return false` - double-counted every opened bucket, reading `top=0.614304` on a unit sine against the 2/pi it must - be. Four things this rests on, each of which breaks it silently: - * **Exactness is the point, so nothing may stride or decimate.** `peak to peak` must still read - exactly `2` on a unit sine and `top` exactly `0.636620` (2/pi). A bucket's sum is a sum. - * **The bucket index space must be the window's.** `ChannelBuffer::acquire()` calls - `pyramid_.drop_before(values_->base())`, so a channel's pyramid is in the value ring's - absolute index space -- the same space `live_window_locked()` answers in, since a channel's - stride is always 1. The snapshot's pyramid is built from sample 0 and never dropped from, so - its space is the snapshot's own. `MeasureWindowReader::origin_` is what turns one into a - window offset, and a mismatch there measures the wrong samples rather than failing. - * **The raw path stays, and stays exact**, for the window's partial edge buckets, for the - samples produced since this frame's `acquire_all()` (one frame of production, so bounded), and - for the straddling buckets. A window shorter than a bucket degrades to O(window) and must - still be right. - * **Both passes walk the same fetched bucket list** (`App::measure_bucket_scratch`, reused - across recomputes -- a 2 M-sample window is 32 k buckets). Fetching twice is a second walk of - the pyramid for nothing. - `measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say - anything. - -- **A crossing is counted with hysteresis, and the band moves the *edge decision* only.** - `MeasureLevel::hysteresis` is a Schmitt trigger about `level`: an edge is counted once the signal - has passed `level + h` going up or `level - h` going down, and a sample inside the band decides - nothing. Without it `period`, `frequency` and `pulse duration` are unusable on anything real -- - the mid level is exactly where a waveform moves *fastest*, so an interferer faster than the - fundamental recrosses a single level several times per true crossing. 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 counted as **79**, `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz, against - `period=0.200188` with the band. Four rules, each of which was wrong first: - * **`measure_set_level()` is the only place the band is sized**, and `src/ui.cpp` calls it rather - than assigning `.level` itself: two expressions for the width is how the raw walk and the bucket - path end up disagreeing about where a crossing was. `h = min(0.10 * (hi - lo), 0.5 * min(hi - - level, level - lo))`. The 0.10 is measured -- 5 % reads a 0.1 interferer and fails at 0.2 - (`period=0.075`), 10 % holds to 0.3 and to white noise at sigma=0.05. - * **The clamp is load-bearing.** `PulseDuration` lets the user put the split near an extreme, and - a fixed tenth of the span there puts the upper threshold *above every sample*: the walk never - decides that side and the measurement reads nothing at all, which is worse than reading - something noisy. On a ringing 3.3 V logic signal at a 90 % split: `enters=0` with a fixed band, - `enters=4 period=0.200000` with the clamp. - * **The `sum`/`count` split stays at the bare `level`.** That split *is* `Top`/`Bottom` -- the mean - above and below the threshold -- so a band there changes a documented exact reading. `top` on a - unit sine must still be `0.636620`. - * **`h = 0` must reduce exactly to the single-level walk**, including the `v == level` skip, so the - band is a parameter of the engine rather than a mode of it. - The cost is the noise's, not the band's: the clean fast mock reads `raw=119412 (0.1%)` of 215 M at - 1.41 ms a recompute and with the 0.1 interferer `raw=2.0%` at 1.96 ms. A - `/tmp/check_measure.cpp`-style check is how the walk itself was verified (`g++ -std=c++20 -O2 -I - src ... src/measure.cpp`): over a clean sine, sines with 0.1 and 0.3 interferers, 10 %/50 % duty - squares and a 90 % threshold, the **bucket walk must equal the raw walk exactly** -- same - `enters`, same `count[]`, identical `first_enter`/`last_enter` to 0.0 -- which is the assertion - that the fast path is a fast path and not an approximation. The app's reading is - `[measures-probe]` against `--noise 0.1`. - -- **The math channels' cost is the *producer's*, and the instrument must not be part of it.** - `push_math_sample()` runs once per stored slot -- up to the wire rate -- so what a regression - here looks like is `gaps`, never a frame time. Three things were taken off that path and must - not come back: a `std::vector` *local* (a heap allocation and free per slot; it is the member - `math_values_`, `assign()`d), a `Source` node's channel resolved **by name** per sample (a - linear scan with a `std::string` compare per candidate, per node, per slot, behind a - type-erased call -- `math_prepare()` resolves it once into `MathNodeRt::src`, and every rebuild - re-prepares), and a **`fetch_add` on the cost counters**. That last one was 20-25 % of the - measured figure: a `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 producer-written - only, so each is a relaxed `store()` of a value already in hand (`math_slot_count_` and - friends). Two things about the figure itself: - * **It is sampled, one slot in `kMathCostSample`, 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 **is wrong and was tried**: 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 a 64-slot run crosses one ~3 % of the time, and a ~1.5 ms gap spread over 64 - slots reported **799 ns/slot against the true ~80**. One slot is always inside one packet. - * **78--101 ns/slot is one reading.** Four identical-config runs spread that far, so the per-slot - path is at the noise floor of this harness and a micro-optimisation cannot be shown to work - here. Converting `MathRuntime` from eight parallel `std::vector` to one array of - `MathNodeRt` was measured and bought **nothing** -- at six nodes the whole runtime is - L1-resident either way. It was kept for the shape, not for a number; do not expect a cache - argument to pay at this size. - -- **The status line's per-second fields are measured once a second, not per frame.** - `App::stored_rate`/`declared_rate` and the label's tooltip are refreshed on the same - tick as the channel dump; the status line only formats what is already there. The - badge's markup is likewise rewritten on a change of phase (twice a second), never on - all sixty frames. -- **A `notify::` handler takes three parameters, a `value-changed` handler two.** - `GObject::notify` passes `(GObject*, GParamSpec*, gpointer)` while - `GtkSpinButton::value-changed` passes `(GtkSpinButton*, gpointer)`, so one callback cannot - serve both: on the `notify::` connection the `GParamSpec*` lands in the `user_data` slot - and the handler dereferences it as its `App*`. `G_CALLBACK` is a cast, so nothing warns. - That is why the trigger panel's spin buttons and its dropdowns have separate thin entry - points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one - `publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault - outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard. -- **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it - returns**, so the `updating_panels` flag — set around a programmatic write and cleared when - it returns — does *not* suppress it. Where the write is part of a readout the handler reads - back, that is a feedback loop: `sync_window_display()` writes a value rounded for reading - into the window entry and picks the unit for it, and the late notify had - `on_window_unit_changed()` re-parse that rounded text, turning a 500 s request into - 499.98 s. The guard is `App::window_sync_unit` — the unit we last synced — and a selection - equal to it is ours, since GTK only notifies on a change. Any new control that both writes - and reads a `GtkDropDown` needs the same shape; the flag alone is not enough. -- **The window control's label is width-locked and ellipsized** (`kWindowLabelChars`), for - the same reason as the status label: it sits in the header bar above the plot, so a label - that grew from `1 s` to `123.456 ms` would widen the bar and relayout everything under it - on a mere change of window. That is also why the window is typed into a popover rather - than shown in the bar. -- **No plot interaction changes the time scale; only the window control does.** The *time scale* - is more than the axis: `RowSet::window_s_` also feeds `ScopeModel::set_event_span()` (the - trigger's pre-trigger share is a share of *this* window), `ScopeModel::set_target_span()` - (which may re-plan the partition, move the decimation and **drop the history**), the window - control's readout and the session file. A **zoom or a pan** does not touch it by construction — - the controller writes the *member* axis and `clamp_to_data()` caps the span at `window_s_`, so - nothing has to be written to keep it that way. **Neither do the two fits**: a fit is the - *undo of a zoom*, so `RowSet::fit_x()` restores the window that is already set and reads - `window_s_` without ever writing it. Both fits used to frame the *data* instead and move - `window_s_` alone, which was wrong twice over — a gesture-triggered re-plan of the rings, and - an event span left on the *old* window until the next decade step or unit change applied the - fitted value for real (one action, two changes of time scale). -- **`apply_time_window()` is the only place the time scale changes**, and the window control is - its only caller. It clamps to `window_limit()` and moves `set_target_span()`, - `set_event_span()`, `RowSet::set_time_window()`, the control and the session save together — - the partition **before** the axis, so the axis is framed over the rings that will exist. A fit - must not come here for two reasons: it is not entitled to the scale at all, and - `set_time_window()` unpins and re-arms the live follow, which would throw a held event or a - paused view onto a live edge it has nothing bound to. Paths that change the span for their own - reasons (an event pin) call `sync_window_display()` alone, which only restates the control and - never moves the plot — calling `apply_time_window()` there would clamp a legitimately short - capture up to the requested window. -- **The x labels go relative on the tick *step against the label resolution at the axis' - magnitude*, never on the span.** `RowSet::update_time_labels()`'s question is only ever "can - this step be written at this magnitude": `TickFormat::Time` carries three significant figures - below a minute, **whole seconds** through `m:ss`/`h:mm:ss` up to a day, and three figures of - days above — so the resolution is a step function of the magnitude, which `time_label_resolution()` - states and nothing else may re-derive. The old test was `span < 1.0` and it was wrong in both - directions: a 2 s window at twenty minutes of uptime reads `20:00` on every tick and stayed - absolute, and a **triggered** sub-second window — whose axis is already `[-25ms, +25ms]` about - the edge — went relative and so re-anchored its ticks *away from the trigger*, with a `t0` - caption that had nothing to explain. Three details hold it up. The step is read back from the - majors the **last layout** produced, which is not a feedback loop (a tick's value depends on the - range and the axis' pixel width, never on the label form) but *is* absent before the first - layout — and with no ticks the form must be **left alone**, because a step guessed from the span - would be latched by the hysteresis below. The hysteresis is a 1.05 band on *leaving* the relative - form. And *entering* it needs a ULP of slack (`1 - 1e-9`): a step differenced from two tick - values and a resolution from `pow(10, n)` differ in their last bits when they are the same - number, and `step == res` is a readable axis (`1.73s`, `1.74s`, `1.75s` — one in the last digit - is exactly enough), so the bare comparison picked a different form from one frame to the next. - The guard is `[timelabels-probe]`'s `distinct=1`, which asserts what the form is *for* rather - than which form a given window gets. -- **The `t0 = …` caption is chrome, never an annotation.** It is `Plot::set_x_note()` (added to - `deps/plotter` for this), drawn by `Renderer::draw_x_note()` on the legend's own - `draw_readout_panel()` backplate in the bottom-left corner of the axes, in **pixels**. It was an - `Annotation` first, and an annotation's anchor is a *data* coordinate: the caption therefore - crawled across the plot under a pan and drifted under a zoom, which is what "[t0=...s] move - slightly around when user zoom/pan" was. Three things follow. The **text is the host's**, because - the caption has to name the anchor precisely enough that label + caption is the exact absolute - time and `format_time()` would round it to three figures (`"t0 = %.6g s"`); only the **bottom - row** of the figure carries one, every other row being set to `""`; and the renderer measures it - rather than `Plot::layout()` — it reserves no space and nothing can be picked on it, so there is - no rect to read back, and a caption explaining the x labels must not be able to move the layout - those labels were measured into. The guards are the library's - `the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move` (the footprint is the pixels a frame - with the caption differs from one without, and a pan plus a 64× zoom must give the same box to - the pixel) and `[timelabels-probe]`'s `note=`, which reads `Plot::x_note()` back. -- **`fit_x()` restores the *whole of what the view may show*, which is not always `window_s_`.** - Live, that is the newest `window_s_` at the live edge (`set_time_window(window_s_)`, the same - path a resume takes). Pinned — a held event, a filling capture or a paused view — it is the - **pin's** full range, which is the snapshot's own stamps and may be legitimately *narrower* - than the requested window. It must write the per-row **member** axes as well as the link group: - `hold_pinned_window()` prefers a member that differs from `pinned_stated_`, which is how it - tells a real gesture from its own echo, so writing the group alone is reverted to the zoomed - range on the next frame. `fit_all()` is the y fit plus this, shared rather than duplicated so - the two cannot drift apart. -- **The header bar's buttons are icon-only**, so headless probes must reach them - through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via - `find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`, - `measures_toggle`, `math_toggle` -- packed with `header_bar_pack_end()`, where the **first** packed - is the rightmost, and the bar is an *ancestor* rather than the parent because packing parents into - an internal box), and the cursors pane owns its own - **Reset** button rather than the overflow menu owning one for it. The dump prints - `panel controls: … ; toolbar, left to right: …` to say where they are and in what order. The widget - tree's child order *is* the visual order left to right, so that line reads as the *reverse* of the - pack order -- it is how an arrangement gets checked with no screenshot, and it is what finally - settled the pack-order rule above (two comments in `build_toolbar()` disagreed with each other and - one of them with reality): it is computed on the first *tick*, because the dump - runs after the window is destroyed and asking GTK about a widget then is four criticals, which is - how the first version of that line earned them. What stays in the menu is what acts on the *data* - (Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text. -- **Six of this app's icons are its own, and they are a `GResource` compiled into the - binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a - measurement ruler, and the stock names those buttons used to carry said something else - entirely: a *record dot* for the trigger and three different **magnifiers** for cursors, - measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math}-symbolic.svg` - are the four action icons; beside them in `src/icons/scalable/apps/` are the two - **application** icons, `org.udpscope.app.svg` (128px colour artwork) and - `org.udpscope.app-symbolic.svg` (16px). The `apps` context is where an icon theme keeps an - application's own icon and is where the **in-process** window icon resolves from; it is also - the layout that `cmake/install_desktop_entry.cmake` mirrors when it copies the same two files - into `$XDG_DATA_HOME/icons/hicolor/scalable/apps` and `.../symbolic/apps` — those installed - copies are what makes `Icon=org.udpscope.app` in the desktop entry resolve (a desktop shell - cannot read a GResource compiled into the binary). - All six are bundled by `src/icons/udpscope.gresource.xml` (CMake runs - `glib-compile-resources`) and reached through **one** - `gtk_icon_theme_add_resource_path()` in `run_ui()`, right after `adw_init()` — not at the top - of `build_toolbar()`, where it used to be: there are two consumers now (the window's own icon, - set before the toolbar is built, and the toolbar's buttons) and `run_ui()` is the one place - ahead of both. `adw_init()` calls `gtk_init()`, which is what makes - `gdk_display_get_default()` valid there. - A resource rather than files beside the binary because udpscope is run straight out of the - build tree and has no install step. Five authoring rules, each of which fails quietly: - * **GTK's symbolic recolour only reaches `fill` on `path`, `rect` and `circle`.** It wraps the - SVG in a stylesheet for those selectors, so a `stroke`, a `line` or a `polyline` keeps the - colour written in the file and stays dark on a dark theme. **In a symbolic icon everything is - therefore a filled `path`** — including anything that wants to look stroked, which is drawn as - a closed band (two offset curves joined) instead. The rule is scoped to symbolic icons on - purpose: `org.udpscope.app.svg` is 128px colour artwork that is never recoloured, so it strokes - its sine freely, which is the sane way to draw a smooth one. Do not "fix" one to match the - other. - * **Whole-pixel coordinates**, because these are drawn at 16 px far more often than not: the - first draft placed features on half-pixels and the whole icon was a grey smear at 1x. - * `fill-rule="evenodd"` is how the ruler's body is a hole *and* its ticks still fill -- a tick - inside the hole is three crossings deep, which is odd. Nonzero would fill the hole back in. - * Overlapping subpaths under the default nonzero rule must be **wound the same way** or they - cancel instead of uniting. All of them run clockwise. - * **A malformed SVG is a build error, and that is the only place it can be caught.** Every - `` in `src/icons/udpscope.gresource.xml` carries `preprocess="xml-stripblanks"`, which - makes `glib-compile-resources` pipe it through `xmllint` — so `--` inside an XML comment, which - is illegal and which librsvg refuses the whole file for, fails the build with a file, a line - and a caret under the offending character (`…app-symbolic.svg:5: parser error : Double hyphen - within comment`). It has to be here because **no runtime check can see it**: librsvg refuses - the file but the icon *name* still resolves, so `gtk_icon_theme_has_icon()` returns true and - the icon draws nothing. `gtk_icon_theme_lookup_icon()` cannot tell either (see the `app icons:` line under - Verification), `GMarkup` accepts the double hyphen because it is a lenient subset parser, and - `gdk_pixbuf` goes through glycin here, which sandboxes a subprocess per load and refuses this - project's own icons outright. The cost is that the `xmllint` binary is a build dependency; it - ships with libxml2, which GTK already requires. - `gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see - `toolbar icons:` under Verification): a resource path that was never added does not raise an - error, it draws `image-missing`. -- **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has - none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them - with `gtk_accelerator_parse()` — so they are `"space"`, `"e"`, `"slash"`, - `"plus minus"` (a space-separated pair is the "either of these" form the label draws as - `+ / -`), never the prose `"Ctrl+E"` or `"+ / -"`. Neither GTK's syntax nor libadwaita's - knows a **wheel or a drag**: `"Wheel"` costs a `Failed to parse` warning and draws an empty - cell. Those six rows therefore carry `accel = ""` and put the prose in `subtitle`, which is - the one field that is free text. A row added with a parse error is otherwise silent — the - overview still opens and the row is simply blank — so read the probe run's stderr for - `Failed to parse` after touching either table. -- **The chooser runs before `ScopeModel` exists, and `--server` is what decides.** `Config` carries - `server_specified`, set only by the `--server` branch, and `main()` asks only when it is false — - so declining costs no buffer and no socket, and `ScopeModel`, `Acquisition`, `RowSet` and - `run_ui()` need no notion of "no source yet". It is `--server` alone and not "no arguments": - `--multicast` still needs a server to fetch CONFIG from. **Every documented probe passes - `--server`**, which is what makes the feature invisible to the regression net — a probe added - without it would reach the chooser and hang. `gtk_init()` plus a nested `GMainLoop`, and then - `run_ui()`'s `GtkApplication` afterwards in the same process, is a verified combination - (`gtk_init()` is idempotent; running your own loop is the documented GTK4 replacement for the - removed `gtk_dialog_run()`). -- **A session file's name cannot say how its source was reached, so the file says it.** - `_.toml` carries neither the multicast group nor the interface, so a chooser row - could not reconnect to a multicast source at all. `LayoutState::multicast`/`interface_addr` are - written in the **top block, before the first `[[table]]`** — a bare key after a table header is - that table's key in TOML, which is exactly how `enabled` was parsed into the last - `[[calibration]]` record and lost without a word. A file from an older build has neither key and - loads as unicast on the default route. The chooser's list filters to sessions that **name at - least one signal**, because a probe run leaves a file per throwaway port and all of them hold - `signals = ["", "", "", ""]`; `known_sources()` splits the filename at the **last** `_`, since - `sanitize()` turns anything unsafe in a host into one. -- **`save_layout_now()` refuses to write while `signals()` is empty.** A run against a source - whose streamer was down receives no CONFIG, so every slot name stays empty and the save would - overwrite a configured session with `signals = ["", ...]`. The chooser then filters that file - out — a source whose streamer was merely down during one run would silently vanish from the - only list it can be picked from, for ever. Keeping the old file loses nothing: a run that - configured nothing had nothing worth persisting. -- **The status bar is two tiers, and the dump reads the detail one.** `format_glance()` writes - the bar and `format_detail()` writes both the popover's prose and `App::status_detail`, which - is what `exit_probe_step3`/`exit_probe_step2` copy into `dbg_mode_live`/`dbg_mode_triggered`. - Those two used to read the *label*, and the label no longer carries `packets`, `gaps`, - `plot 1:` or the buffer figures — so a `gtk_label_get_text()` there would silently reduce - every documented `mode …:` assertion to four fields. The glance line's state field is **one - word from a fixed set** (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`, - `disarmed`) for the layout reason above; a word **wider than the widest already there**, or a - field whose width tracks the stream, breaks the row the plot is laid out under. The set may - grow — `connecting` was added for the no-CONFIG state — but only on that condition, and the - check is the dump's `max_glance_chars`. `connecting` is derived from `have_config` alone and - sits **ahead of every other branch**, including the display mode's: with no signal list, every - other word claims something that does not exist yet. The `mode` prose branches the same way for - the same reason, which is what keeps the two tiers from disagreeing. -- **The ⓘ popover's widgets are built once and only their text is set.** It is visible when the - tick updates it, so a rebuild is a per-frame relayout of a widget on screen. - `update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in - the dump is the assertion. -- **The About dialog, the help window and the shortcut overview are opened by `show_about()`, - `show_help()` and `show_shortcuts()`, and each closes with the matching call.** There is - exactly **one `GtkWindow`** among the three — the paged help window. It closes with - `gtk_window_close()`, which emits `close-request` so the handler that nulls the file-scope - pointer runs. `gtk_window_destroy()` does **not** emit that signal and must not be used — the - math dialog fell into exactly this trap, and the pointer then points at a dead widget until the - next attempt to open the same window. The other two — the About dialog (an `AdwAboutDialog`) - and the shortcut overview (an `AdwShortcutsDialog`) — are **`AdwDialog`s and not `GtkWindow`s**: - they close with `adw_dialog_close()` and clear on `::closed`, there being no `::close-request` - on an `AdwDialog`. Both share one `on_dialog_closed()` handler keyed by a **pointer to the - pointer** it must null (`&g_about`, `&g_shortcuts`), which is what keeps one handler honest for - two dialogs. One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its own close handler, is - the shape that prevents the double-open defect across all three. -- **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at - configure time (so `add_executable` can name the file) and again on every build through - `udpscope_build_info` (so the commit and the timestamp belong to the binary that is running). - `git` absent leaves `kCommit` at `"unknown"` and the build succeeds. The About dialog and the - `[help]` probe both read `kVersion`, `kCommit` and `kTimestamp` from it; `settings=` in the - probe reads `config_dir()`, which is the path the session file is written to. -- **The keyboard zoom's modifiers are not the wheel's.** `+`/`-` with **Ctrl** restricts to x - and with **Alt** to y, while the wheel keeps upstream's Shift = x / Ctrl = y convention. The - two cannot agree: Shift is unusable on the keyboard path because `key_from()` folds `'+'` and - `'='` onto `kKeyZoomIn` — `+` *is* Shift+`=` on common layouts, so a bare zoom-in already - arrives with `Mod::Shift` set. Ctrl+Alt together zooms neither axis, which needs no special - case. The guards are `deps/plotter`'s own `ctrl_plus_zooms_only_x` and `alt_plus_zooms_only_y` - tests, plus `UDPSCOPE_PROBE_ZOOMKEYS_MS`. -- **A help page is one `.txt`, one `` line in the GResource manifest, and one `kPages` - row.** The bodies are Pango markup in a second GResource (`--c-name=udpscope_help`, so its - generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at - load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from - `parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`. - A page may also carry screenshots (`HelpPage::images`, a null-terminated list of resources). - They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height - at all inside a box, so without the request it is allocated nothing and the page renders its text - with a silent gap where its screenshots should be — which is what the first version did. -- **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons - all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once - Caps Lock is on. -- Every source file carries an SPDX MIT header; match it in new files. +| File | What it covers | +| --- | --- | +| [invariants-plot-and-layout.md](docs/claude/invariants-plot-and-layout.md) | What `plot::Figure`/`plot::Plot` can and cannot be asked to do, what a row's contents belong to, the layout editor's overlay children, and the rule that **layout must not depend on content**. | +| [invariants-readouts-and-cursors.md](docs/claude/invariants-readouts-and-cursors.md) | A readout quotes a stored sample and never a value between two; how the cursors are read back from the plots; the one formatter both panes share. | +| [invariants-data-path.md](docs/claude/invariants-data-path.md) | What rebuilds the buffer and drops history, why decimation at ingest is an **average** and never a stride, and the two rules the `f64` time ring and its cached first stamp impose. | +| [invariants-rendering.md](docs/claude/invariants-rendering.md) | How the renderer maps a visible range onto samples (never the `dx()` grid), what `gather()` emits and relative to which origin, why every series needs a real `MinMaxPyramid`, and why a dense trace is drawn one pixel column at a time. | +| [invariants-trigger-and-pinning.md](docs/claude/invariants-trigger-and-pinning.md) | The event window counted in stream seconds end to end, what pins an axis in each mode, and what each pin is measured *from* (the snapshot's own stamps, not the request). | +| [invariants-time-axis-and-window.md](docs/claude/invariants-time-axis-and-window.md) | **No plot interaction changes the time scale**; `apply_time_window()` is the only path that does; how the x labels pick their form; what a fit restores. | +| [invariants-measurements.md](docs/claude/invariants-measurements.md) | Why the pane is refreshed on a cadence, why neither pass may read its window raw, and how a crossing is counted with hysteresis. | +| [invariants-math-channels.md](docs/claude/invariants-math-channels.md) | A math channel *is* a channel; the engine runs on the producer thread, so a regression there costs packets and not frames; the node editor's own rules. | +| [invariants-session-and-export.md](docs/claude/invariants-session-and-export.md) | What a session file persists and in what order, when a save is refused, and when an export is collected. | +| [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the six bundled icons and their authoring rules, and the help pages. | diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md new file mode 100644 index 0000000..c97252b --- /dev/null +++ b/docs/claude/architecture.md @@ -0,0 +1,118 @@ +# Architecture: threads, the data path and the layers + +The two threads and what they share, the four display modes, the display clock, and the layers to keep straight. + +Part of [CLAUDE.md](../../CLAUDE.md). + +`SPECS.md` is the requirement document. `README.md` documents what was actually built, +including deliberate deviations from the spec and known limitations — read it before +concluding something is a bug. + +### Threads + +Exactly two: a producer thread in `Acquisition` owns `udps_client_t` and calls +`udps_client_poll()`; the GTK main thread owns every widget and GL context. They share +`ScopeModel` under `ScopeModel::mutex()`, taken once per DATA frame by the producer and +once per frame by the UI (`tick()` in `src/ui.cpp`) around `RowSet::bind()`/`acquire()`. +Samples themselves cross via `plot::data::RingBuffer` (single producer, single +consumer) and never through the lock. Cross-thread scalars are atomics on `ScopeModel` +(`trigger_enabled_`, `trigger_armed_`, `trigger_latches_`, `trigger_time_`, +`event_`, `generation_`). Two of them are a pair: the producer writes `capture_edge_` +before `trigger_pending_` and those two carry release/acquire, so a consumer that sees a +capture open sees which edge it belongs to. + +### Data path + +`ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one +value ring per channel, sized by `plan_partition()` to cover `--max-window` seconds +within the byte budget. The producer always writes it, whatever the trigger is doing — +that is what guarantees a full pre-trigger history on the *first* latch after arming. +The spec's second buffer (item 1) is the latched `EventSnapshot`, which is already an +immutable copy of the captured window; see README for why two live rings were dropped. + +What a trace binds to is `ScopeModel::display_mode()`, one of four states, and +`RowSet::bind()` takes it as an argument rather than recomputing it: + +- `Live` — no trigger: the live ring, on the clock of the **newest sample** + (`display_epoch() == -newest`), so the axis is a fixed `[-window, 0]` and x reads as an age. +- `Capturing` — an edge has been accepted (so the pre window was full) and the post + window is still filling: the live ring, on the coming event's own clock + (`display_epoch() == -edge`), inside an axis already pinned to `[-pre, +post]`. The + post side fills in view and the marker sits at x = 0 throughout. +- `Held` — the frozen `EventSnapshot` of the last event, whose stamps are already edge-relative, + so the display epoch is 0. +- `Waiting` — armed, nothing captured yet: blank, with the live epoch (there is nothing bound, + but the axis is still a live one). + +**Disarm during a capture publishes what the capture got to**, then disarms: the producer +cuts the window at the newest sample and that partial snapshot becomes the held event, so +the picture on screen at the moment of the Disarm stays on screen. `engine.disarm()` clears +`pending_`, which drops the display out of `Capturing`, and with nothing published the view +fell back to the *previous* event — or to blank on a first capture. No event fired, so +`trigger_latches()` is deliberately not bumped; `ScopeModel::event_seq()` counts published +snapshots instead, and the frame tick keys its event framing on *that*. Keep the two +distinct: the latch count is what the probes assert "how many events fired" with. + +`Capturing` is a **deliberate departure from SPECS:37** ("data visualisation should not +be refreshed till next event"), asked for explicitly; that reading is what the old "never +the live ring mid-capture" rule enforced. What survives of the rule is that `Held` binds +the snapshot *only* — a held event is never quietly replaced by live data — and that the +*axis* does not move during a capture: it is the event's window from the edge onwards, +never a sliding live view. + +`display_mode()` must be read **once per frame** and passed around (the tick does this, +and hands it to `bind()`). The producer can open or close a capture between any two +reads, and two reads in one frame put the bind, the epoch, the pin and the status line on +different clocks. + +**The display clock is not the stream clock, and `ScopeModel::stream_at_x0()` is the only +conversion.** `display_x = stream_t - stream_at_x0()` in **every** mode, and the offset lives in +one of two places: a snapshot carries its own (`EventSnapshot::x_offset`, frozen with the +picture) and a live view carries it as minus `display_epoch()`. Anything crossing between the +two clocks goes through that one accessor rather than picking the branch itself — the export's +`abs_seconds` column, the pause handler putting a display window back on the ring to cut it, the +measurement marks — because there are four modes and picking the branch by hand gets one of them +wrong. It reads atomics only, so it is safe to call inside an existing `mutex()` guard. +Three things about the live epoch specifically: +- **It is written after `bind()` and `acquire_all()`, not before.** It must be minus the newest + sample the *traces* can report, and asking before the acquire is one frame of production + stale — at 1 MS/s over a 50 ms window that is a third of the screen to the right of the axis. +- **There is no feedback loop**, even though the newest stamp is read through the epoch that is + about to change: `RowSet::live_edge()` reports in the epoch currently in force, so + `display_epoch() - live_edge()` is minus the *raw* stamp and the fixed point is reached in one + frame rather than converged on. +- **`Waiting` takes the live epoch too.** Nothing is bound, but the axis is still a live one, and + an epoch of 0 there would make the axis jump on the frame a capture closes. + +**A measurement mark is glued to its sample; a cursor stays on screen.** The two instruments move +in opposite directions under a rolling axis and that is deliberate — a bench scope's cursors sit +where the user put them while the waveform rolls under, and a measurement's mark names the sample +it measured. The cursors need no code: their x *is* a display coordinate. The marks are stored in +**stream** seconds and reconverted every frame by `place_measure_marks()`, since a mark left in +display coordinates would crawl forward at one window per window. `App::measure_window` is +stream-clocked for the same reason, and that one is load-bearing: the recompute trigger is "the +window moved by 2 % of its span", and in display coordinates a live window is a fixed +`[-window, 0]` — it never moves, so the measurements would never be recomputed again. + +Layers to keep straight: + +- `SignalInfo` — one signal as the CONFIG described it. The unit of enable/disable. +- `ChannelInfo` / `ChannelBuffer` — the unit of acquisition and plotting. A + `ChannelBuffer` is a `plot::data::Series` over a (value, time) ring pair. +- `SlotSeries` (`src/channel.hpp`) — a trace's stable series object, rebound each frame + to either a `ChannelBuffer` or an `EventSeries`. +- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to `kMaxRows` rows (added + and removed one at a time by the layout editor), each a + borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots. The figure owns + everything the rows have to agree on: the x window (one x link group every row joins), + the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since + `Figure::link_x()` refuses a `Live` member), the left gutter and the crosshair. + +- `ExportTable` (`src/export.hpp`) — the visible window as columns, collected from the + snapshot or the live rings and handed to the `.npy`/`.csv` writers. Always the stored + samples, never the LOD pyramid's buckets. + +`src/ui.cpp` is the GTK4 window (toolbar, trigger panel, signal panel, plot box) plus +the `tick()` frame function that binds, updates the trigger state and rewrites the +status line. One `plot::gtk4::PlotArea` in figure mode draws the whole figure. It is by +far the largest file. diff --git a/docs/claude/build-and-run.md b/docs/claude/build-and-run.md new file mode 100644 index 0000000..4f6c9c5 --- /dev/null +++ b/docs/claude/build-and-run.md @@ -0,0 +1,102 @@ +# Build and run + +Configuring, building and launching udpscope, the desktop entry target, the command-line options and the two vendored dependencies. + +Part of [CLAUDE.md](../../CLAUDE.md). + +## Build and run + +```sh +git submodule update --init --recursive # deps/plotter is a submodule +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo +cmake --build build + +./build/udpscope --server 127.0.0.1 --port 8000 +./build/udpscope --server 10.0.0.5 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9 +``` + +The app can be launched from the shell rather than only from a terminal, but only on request: + +```sh +cmake --build build --target desktop-entry +``` + +That writes `org.udpscope.app.desktop` plus both app icons under `$XDG_DATA_HOME` (default +`~/.local/share`), runs `desktop-file-validate` as a **hard failure** (when it is on `PATH`; +absent, it says so and installs anyway), refreshes `update-desktop-database` and +`gtk4-update-icon-cache` when they are on `PATH`, and prints the three paths it wrote — which is +the uninstall instructions, since there is no uninstall target. `cmake --build build` alone never +touches `$XDG_DATA_HOME`. + +The entry's `Exec` is the built binary's **absolute path in the build tree**, so **moving or +deleting the build directory breaks it.** That is the price of having no install step and it is +deliberate: the entry then always launches exactly the binary that was last built. 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. + +Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per +buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row +count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards). + +Run with no `--server` and udpscope asks which source to open: the sessions it has stored, plus a +form for a new host. Every probe below passes `--server`, so none of them sees it. + +`deps/plotter` is built from source with only the GTK4 adapter enabled +(`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a +vendored C99 source pair compiled into a static lib. + +**libadwaita ≥ 1.8 is a system dependency** (`pkg_check_modules(ADWAITA ... libadwaita-1>=1.8)`), +for two widgets: the shortcut overview (`AdwShortcutsDialog`) and the About dialog +(`AdwAboutDialog`). GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and +`AdwShortcutsDialog` is its replacement; `GtkAboutDialog` still exists but +`adw_about_window_new()` is deprecated in favour of `AdwAboutDialog`, which shows the +application icon as the page's own header. Two consequences worth knowing before editing. +`adw_init()` is called — in `run_ui()` and, since the chooser runs first and must be styled +the same, in `choose_source()` in place of its `gtk_init()` (both are idempotent) — so +**the whole app is drawn with libadwaita's stylesheet**, not GTK's: a metric change there +reaches every widget, and `layout over N frames` / `axes.w changes` is what says whether +the layout still settles. And an `AdwDialog` is **not** a `GtkWindow`: both `show_about()` +and `show_shortcuts()` keep an `AdwDialog*`, cleared on `::closed` (there is no +`::close-request`) and closed with `adw_dialog_close()`. They share one `on_dialog_closed()` +handler keyed by a pointer-to-pointer (`gpointer slot` cast to `AdwDialog**`), so one +handler clears either pointer without knowing which dialog it is. Presenting either on this +app's plain `GtkApplicationWindow` is supported and warning-free; it was checked. + +`deps/plotter` is upstream's own repository and this app's figure support was added +there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the +common left gutter), `Figure::set_right_gutter()` / `Plot::set_right_gutter()` (an empty +band on the *other* side, which the layout had no concept of — `axes` reached the +viewport's right edge, so the rightmost x tick label was centred on a line it had half of +itself outside of; the guard is `figure_rows_share_one_right_gutter`), and in the GTK4 +adapter `PlotArea`'s **figure mode** — +`set_figure()`, one `input::Controller` per row, nearest-row event routing, a +figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over. + +The render-quality work went there for the same reason: `kTextFS` now **filters** the +glyph atlas (a 2×2 bilinear clamped to the instance's own cell) instead of point-sampling +it, and `LineProgram::set_snap()` / `u_snap` rounds an axis-aligned segment's ink onto +whole pixels for the chrome that frames a plot (grid and axis lines), never for anything +drawn at a data position. `generate_ticks()` also stores the tick at the origin as +exactly `0.0` — the walk starts at `ceil(min/stride)*stride` and so never lands on zero +when the stride is not a power of two, which labelled it `-139a`. `format_time()` picks its +unit rung by the **rounded** mantissa (`t < u.scale * 0.9995`, and the same slack on the +whole-second branch) — the `>= 999.5` spill guard `format_si()` in the same file always had — +because a tick a hair under a rung's top rounds to 1000 at three significant figures and +`%.3g` writes that `1e+03`, which is how a one-second tick read `1e+3ms`. All four carry +upstream tests — `the_atlas_is_filtered_not_point_sampled`, +`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero` and +`format_time_never_lets_a_field_reach_its_radix`. The minor +grid's look is set there too, not here: `grid_minor` carries 50% alpha, `grid_minor_width` is +0.75 (sub-pixel, expressed as coverage at 1x) and `grid_minor_dash_on`/`_off` are 3/3 — a +fragment discard, no extra geometry — so the subdivisions read as texture under the labelled +grid instead of as a second set of ticks (`the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major`). +README records what they cured. + +Changing any of this means committing in the submodule and moving its pin, and running +the library's own suite as well as the udpscope probes (see +[verification.md](verification.md)): + +```sh +cmake --build deps/plotter/build/debug +xvfb-run -a deps/plotter/build/debug/tests/plot_tests # expect "0 failed" +``` diff --git a/docs/claude/invariants-data-path.md b/docs/claude/invariants-data-path.md new file mode 100644 index 0000000..f6bcfe4 --- /dev/null +++ b/docs/claude/invariants-data-path.md @@ -0,0 +1,112 @@ +# Invariants: the data path — buffers, decimation and the rings + +What rebuilds a buffer, why decimation is an average, and the two rules the time ring's precision and its first stamp impose. + +Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it. + +- **Any structural change rebuilds the buffer and drops history**: a CONFIG, an + enable/disable, a calibration change, or a **change of display window that moves the + decimation factor**. Calibration is applied at ingest, which is why it re-partitions. + `ScopeModel::set_target_span()` is the window's path in, and it re-plans *only* when + `time_stride` actually changes, so walking the window inside one decade is free; it asks + `plan_partition()` rather than recomputing the factor, because two expressions for that + decision is how the buffer and the view end up disagreeing about the stored rate. It + takes `mutex()` itself — do not call it holding the lock. +- **The shared clock is decimated to fit the requested window; channels never are.** A + window too long to store at the wire rate used to be refused (the control was capped to + `Partition::span_s`), which made a 600 s view of a 1 MS/s stream unreachable. + `plan_partition()` now picks `time_stride`, a power of two, **after** the growth loop has + bought all the ring the budget allows — deciding it earlier over-decimates by whatever + factor growth was about to add. Per-channel `stride[i]` stays 1: a stride of + `round(dt_i / dt_fastest)` keeps one sample per stride, and when that equals the signal's + own period — a 2 kHz sine on a 1 MHz clock, stride 500 — every kept sample sits at the + same phase and the channel plots flat. +- **Decimation at ingest is an average, never a stride.** `Acquisition::on_data()` reads + and accumulates *every* sample and pushes the mean of each group of `time_stride` + (`value_acc_`/`value_acc_n_`, and `time_acc_` for the clock). That boxcar's first null is + at the stored rate, so content that would fold back is attenuated instead of reappearing + as a false low frequency — which is the whole reason decimation was once dropped + entirely. Skipping the read for unstored samples re-introduces the aliasing; the + `UDPSCOPE_PROBE_WINDOW_MS` run under `--freq 2000` is the guard (the anti-alias check in + [probe-readings.md](probe-readings.md)). + The stamp pushed is the group's **mean**, not its last member: that is where the boxcar's + group delay puts the value, and stamping at the end of the group shifts every trace half + a stored sample late. The accumulators are reset whenever `time_stride` or the channel + count changes, so a re-plan starts a fresh group rather than finishing one at the wrong + length. The LOD min/max pyramid still preserves the extremes *of what is stored*; a spike + narrower than a group is now filtered away rather than aliased, which is the correct + trade and is why the status line's `rate` field is worth reading. +- **The trigger sees the stored (filtered) signal, not the wire signal.** `engine.process()` + is fed the averaged value, inside the `if` that pushes it. An edge found on a sample that + is not in the buffer would be cut from data that does not contain it. +- **The time ring is `f64` and the value rings are `f32`, and the asymmetry is the point.** + A value's precision is a property of the signal (24 bits is plenty for a sample); a + timestamp's is a property of *how long the scope has been running*, because the stamps are + stored relative to an epoch fixed at the first sample ever seen and so grow without bound + while the interval they must resolve stays one sample period. `f32`'s ULP is 1.9 µs at 16 s + of uptime, 3.8 µs at 32 s, 7.6 µs at 64 s and 61 µs over a 600 s window — so at 1 MS/s two + to four consecutive samples shared a stamp after half a minute, eight after a minute, and + the LOD envelope over such a plateau drew a zero-width segment, i.e. a vertical spike where + the waveform should be. That is "zoom in to ~10 µs and three to five points share a + timestamp, and min/max produces artefacts". **Do not widen the values to match**: they are + the memory budget, and nothing about them is magnitude-bound this way. Three consequences: + `plan_partition()` can no longer count slots in its growth loop (a time slot and a value + sample are different sizes, so it weighs the budget in **bytes** — `sizeof(f64)` for the + clock plus `sizeof(f32) / stride[i]` per channel — and `bytes_for()` is also what bounds + the doubling against overflow); `live_window_locked()` compares its bounds in the ring's own + type with **no rounding step**, the old `f32` round-trip having existed only so that a + sample sitting exactly on a bound was kept whatever the stream's absolute time was; and + `EventSnapshot::stamps` is `f64` because it is cut from this ring and is therefore in the same + units, whatever a given snapshot's own origin is (both kinds are rebased now — an event's + stamps are edge-relative, a pause's freeze-relative — so neither is magnitude-bound the way the + ring is, and the width is for the cut rather than for the coordinate). `RingBuffer` stores + slots as `std::atomic` behind a `static_assert(is_always_lock_free)`, which `double` + satisfies on every supported target. The guard is the **export**: over a multi-million-row + `.npy`, `np.diff(t)` must hold one step to within a few ULP of the column's magnitude (see + the export guard in [probe-readings.md](probe-readings.md) for why it is no longer exactly one + value). Three steps at 2, 2.5 and + 3 ULP of *the stream's uptime* — microseconds, not femtoseconds — is this defect. +- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins + the published window to the value ring's base and both rings keep sliding afterwards — + their capacities are equal, so there is no slack — which means the oldest few samples + of a saturated window routinely point at time slots the producer has already + overwritten. `ChannelBuffer::time_of()` therefore clamps to the oldest *readable* + slot: a stamp of `0` would reach `x_bounds()`, and from there `dx()` and `x0()`, which + is the renderer's whole index mapping — one unreadable slot inflated `dx()` by the + ratio of elapsed stream time to the retained window and clipped the older part of + every live view. The library's `StreamSeries` is immune for a reason worth copying: + its x is `x0 + i*dx`, a function of the absolute index, so an overwritten slot costs a + *y* value, never the geometry. Clamping buys the same bargain here. +- **The window's *first* stamp is cached by `acquire()` and never read at paint time.** The + clamp above makes an unreadable slot *survivable*; it does not make reading it *safe*, and + `absolute(0)` is not an ordinary slot — `acquire()` pins the window to `values_->base()`, so + sample 0 is the ring's oldest, the very slot the producer is about to overwrite. `time_of()` + checks the bounds and then copies, and when the producer laps the slot in between the stamp it + returns is **one whole ring-span too new**. Everything else on a `ChannelBuffer` is read at + paint time with no lock, and `x0()` *was* the origin the renderer reconstructed every trace + point from (`gather[i] + series.x0()`), read both inside its increasing-suffix filter and again + at `stage_xy()` — so one torn read of that one slot produced three pictures, all of them "the + waveform is gone while the grid and the axes stay": `neighbours()` tests against `x_at(0)` and + refused the whole visible range (the renderer then gathered the *entire* ring at a coarse level + and drew it off the axis — `idx=[0..8388608) level=7 cover=320%`); `x0()` changing *during* the + filter loop dropped three quarters of the points; and `stage_xy()` shifted the finished polyline + a ring-span off its axis. The gather origin is the *view's* minimum now (see the `gather()` + invariant), a local of the renderer's, so the second and third of those are structurally + impossible — but **the first is not**, `x_at(0)` is still what `neighbours()` tests the visible + range against, and that is the one that gathered the whole ring. The cache is load-bearing for + that reason; do not read the slot at paint time because two of its three symptoms are gone. + That is the "with measurements and cursors active the plot flickers as + if there were no data, at no constant frequency" defect. It is **live only** (a snapshot is + immutable, so `x0()` is constant for `Held`/`Paused`), it is a race against packet arrival so it + is irregular, and the measurements pane makes it both likelier and larger: the pane holds + `mutex()` for each `MeasureWindowReader`'s life, so the released producer bursts several packets + at once. `acquire()` runs on the consumer under `ScopeModel::mutex()` — the same lock the + producer's pushes hold — and the window is pinned on the line above, so that is the one point in + the object's life where the slot is safe to read; `window_x0_` is also the *right* answer rather + than merely a stable one (it is the stamp sample `window_base` actually carries, where a + paint-time clamp answered with whatever slot happened to be oldest *now*). `x_at(0)` is served + from it too, for the same reason, and `invalidate()` clears it with the window. The guard is + `trace ink:` / `[ink]` (see [probe-readings.md](probe-readings.md)). The same torn read still exists for a small `i > 0` + — the oldest frame or so of samples — but there its observable is a *prefix plateau*, which is + exactly what the renderer's increasing-suffix filter was written to absorb, so it is a different + symptom and deliberately not fixed here. diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md new file mode 100644 index 0000000..67038e0 --- /dev/null +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -0,0 +1,161 @@ +# Invariants: GTK traps, the status bar, the icons and the help + +The GTK signal-signature and dialog-lifetime traps this app has already fallen into, plus the status bar's two tiers, the bundled icons and the help pages. + +Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it. + +- **The status line's per-second fields are measured once a second, not per frame.** + `App::stored_rate`/`declared_rate` and the label's tooltip are refreshed on the same + tick as the channel dump; the status line only formats what is already there. The + badge's markup is likewise rewritten on a change of phase (twice a second), never on + all sixty frames. +- **A `notify::` handler takes three parameters, a `value-changed` handler two.** + `GObject::notify` passes `(GObject*, GParamSpec*, gpointer)` while + `GtkSpinButton::value-changed` passes `(GtkSpinButton*, gpointer)`, so one callback cannot + serve both: on the `notify::` connection the `GParamSpec*` lands in the `user_data` slot + and the handler dereferences it as its `App*`. `G_CALLBACK` is a cast, so nothing warns. + That is why the trigger panel's spin buttons and its dropdowns have separate thin entry + points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one + `publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault + outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard. +- **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it + returns**, so the `updating_panels` flag — set around a programmatic write and cleared when + it returns — does *not* suppress it. Where the write is part of a readout the handler reads + back, that is a feedback loop: `sync_window_display()` writes a value rounded for reading + into the window entry and picks the unit for it, and the late notify had + `on_window_unit_changed()` re-parse that rounded text, turning a 500 s request into + 499.98 s. The guard is `App::window_sync_unit` — the unit we last synced — and a selection + equal to it is ours, since GTK only notifies on a change. Any new control that both writes + and reads a `GtkDropDown` needs the same shape; the flag alone is not enough. +- **The header bar's buttons are icon-only**, so headless probes must reach them + through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via + `find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`, + `measures_toggle`, `math_toggle` -- packed with `header_bar_pack_end()`, where the **first** packed + is the rightmost, and the bar is an *ancestor* rather than the parent because packing parents into + an internal box), and the cursors pane owns its own + **Reset** button rather than the overflow menu owning one for it. The dump prints + `panel controls: … ; toolbar, left to right: …` to say where they are and in what order. The widget + tree's child order *is* the visual order left to right, so that line reads as the *reverse* of the + pack order -- it is how an arrangement gets checked with no screenshot, and it is what finally + settled the pack-order rule above (two comments in `build_toolbar()` disagreed with each other and + one of them with reality): it is computed on the first *tick*, because the dump + runs after the window is destroyed and asking GTK about a widget then is four criticals, which is + how the first version of that line earned them. What stays in the menu is what acts on the *data* + (Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text. +- **Six of this app's icons are its own, and they are a `GResource` compiled into the + binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a + measurement ruler, and the stock names those buttons used to carry said something else + entirely: a *record dot* for the trigger and three different **magnifiers** for cursors, + measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math}-symbolic.svg` + are the four action icons; beside them in `src/icons/scalable/apps/` are the two + **application** icons, `org.udpscope.app.svg` (128px colour artwork) and + `org.udpscope.app-symbolic.svg` (16px). The `apps` context is where an icon theme keeps an + application's own icon and is where the **in-process** window icon resolves from; it is also + the layout that `cmake/install_desktop_entry.cmake` mirrors when it copies the same two files + into `$XDG_DATA_HOME/icons/hicolor/scalable/apps` and `.../symbolic/apps` — those installed + copies are what makes `Icon=org.udpscope.app` in the desktop entry resolve (a desktop shell + cannot read a GResource compiled into the binary). + All six are bundled by `src/icons/udpscope.gresource.xml` (CMake runs + `glib-compile-resources`) and reached through **one** + `gtk_icon_theme_add_resource_path()` in `run_ui()`, right after `adw_init()` — not at the top + of `build_toolbar()`, where it used to be: there are two consumers now (the window's own icon, + set before the toolbar is built, and the toolbar's buttons) and `run_ui()` is the one place + ahead of both. `adw_init()` calls `gtk_init()`, which is what makes + `gdk_display_get_default()` valid there. + A resource rather than files beside the binary because udpscope is run straight out of the + build tree and has no install step. Five authoring rules, each of which fails quietly: + * **GTK's symbolic recolour only reaches `fill` on `path`, `rect` and `circle`.** It wraps the + SVG in a stylesheet for those selectors, so a `stroke`, a `line` or a `polyline` keeps the + colour written in the file and stays dark on a dark theme. **In a symbolic icon everything is + therefore a filled `path`** — including anything that wants to look stroked, which is drawn as + a closed band (two offset curves joined) instead. The rule is scoped to symbolic icons on + purpose: `org.udpscope.app.svg` is 128px colour artwork that is never recoloured, so it strokes + its sine freely, which is the sane way to draw a smooth one. Do not "fix" one to match the + other. + * **Whole-pixel coordinates**, because these are drawn at 16 px far more often than not: the + first draft placed features on half-pixels and the whole icon was a grey smear at 1x. + * `fill-rule="evenodd"` is how the ruler's body is a hole *and* its ticks still fill -- a tick + inside the hole is three crossings deep, which is odd. Nonzero would fill the hole back in. + * Overlapping subpaths under the default nonzero rule must be **wound the same way** or they + cancel instead of uniting. All of them run clockwise. + * **A malformed SVG is a build error, and that is the only place it can be caught.** Every + `` in `src/icons/udpscope.gresource.xml` carries `preprocess="xml-stripblanks"`, which + makes `glib-compile-resources` pipe it through `xmllint` — so `--` inside an XML comment, which + is illegal and which librsvg refuses the whole file for, fails the build with a file, a line + and a caret under the offending character (`…app-symbolic.svg:5: parser error : Double hyphen + within comment`). It has to be here because **no runtime check can see it**: librsvg refuses + the file but the icon *name* still resolves, so `gtk_icon_theme_has_icon()` returns true and + the icon draws nothing. `gtk_icon_theme_lookup_icon()` cannot tell either (see the `app icons:` line under + Verification), `GMarkup` accepts the double hyphen because it is a lenient subset parser, and + `gdk_pixbuf` goes through glycin here, which sandboxes a subprocess per load and refuses this + project's own icons outright. The cost is that the `xmllint` binary is a build dependency; it + ships with libxml2, which GTK already requires. + `gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see + `toolbar icons:` in [probe-readings.md](probe-readings.md)): a resource path that was never added does not raise an + error, it draws `image-missing`. +- **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has + none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them + with `gtk_accelerator_parse()` — so they are `"space"`, `"e"`, `"slash"`, + `"plus minus"` (a space-separated pair is the "either of these" form the label draws as + `+ / -`), never the prose `"Ctrl+E"` or `"+ / -"`. Neither GTK's syntax nor libadwaita's + knows a **wheel or a drag**: `"Wheel"` costs a `Failed to parse` warning and draws an empty + cell. Those six rows therefore carry `accel = ""` and put the prose in `subtitle`, which is + the one field that is free text. A row added with a parse error is otherwise silent — the + overview still opens and the row is simply blank — so read the probe run's stderr for + `Failed to parse` after touching either table. +- **The status bar is two tiers, and the dump reads the detail one.** `format_glance()` writes + the bar and `format_detail()` writes both the popover's prose and `App::status_detail`, which + is what `exit_probe_step3`/`exit_probe_step2` copy into `dbg_mode_live`/`dbg_mode_triggered`. + Those two used to read the *label*, and the label no longer carries `packets`, `gaps`, + `plot 1:` or the buffer figures — so a `gtk_label_get_text()` there would silently reduce + every documented `mode …:` assertion to four fields. The glance line's state field is **one + word from a fixed set** (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`, + `disarmed`) for the layout reason above; a word **wider than the widest already there**, or a + field whose width tracks the stream, breaks the row the plot is laid out under. The set may + grow — `connecting` was added for the no-CONFIG state — but only on that condition, and the + check is the dump's `max_glance_chars`. `connecting` is derived from `have_config` alone and + sits **ahead of every other branch**, including the display mode's: with no signal list, every + other word claims something that does not exist yet. The `mode` prose branches the same way for + the same reason, which is what keeps the two tiers from disagreeing. +- **The ⓘ popover's widgets are built once and only their text is set.** It is visible when the + tick updates it, so a rebuild is a per-frame relayout of a widget on screen. + `update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in + the dump is the assertion. +- **The About dialog, the help window and the shortcut overview are opened by `show_about()`, + `show_help()` and `show_shortcuts()`, and each closes with the matching call.** There is + exactly **one `GtkWindow`** among the three — the paged help window. It closes with + `gtk_window_close()`, which emits `close-request` so the handler that nulls the file-scope + pointer runs. `gtk_window_destroy()` does **not** emit that signal and must not be used — the + math dialog fell into exactly this trap, and the pointer then points at a dead widget until the + next attempt to open the same window. The other two — the About dialog (an `AdwAboutDialog`) + and the shortcut overview (an `AdwShortcutsDialog`) — are **`AdwDialog`s and not `GtkWindow`s**: + they close with `adw_dialog_close()` and clear on `::closed`, there being no `::close-request` + on an `AdwDialog`. Both share one `on_dialog_closed()` handler keyed by a **pointer to the + pointer** it must null (`&g_about`, `&g_shortcuts`), which is what keeps one handler honest for + two dialogs. One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its own close handler, is + the shape that prevents the double-open defect across all three. +- **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at + configure time (so `add_executable` can name the file) and again on every build through + `udpscope_build_info` (so the commit and the timestamp belong to the binary that is running). + `git` absent leaves `kCommit` at `"unknown"` and the build succeeds. The About dialog and the + `[help]` probe both read `kVersion`, `kCommit` and `kTimestamp` from it; `settings=` in the + probe reads `config_dir()`, which is the path the session file is written to. +- **The keyboard zoom's modifiers are not the wheel's.** `+`/`-` with **Ctrl** restricts to x + and with **Alt** to y, while the wheel keeps upstream's Shift = x / Ctrl = y convention. The + two cannot agree: Shift is unusable on the keyboard path because `key_from()` folds `'+'` and + `'='` onto `kKeyZoomIn` — `+` *is* Shift+`=` on common layouts, so a bare zoom-in already + arrives with `Mod::Shift` set. Ctrl+Alt together zooms neither axis, which needs no special + case. The guards are `deps/plotter`'s own `ctrl_plus_zooms_only_x` and `alt_plus_zooms_only_y` + tests, plus `UDPSCOPE_PROBE_ZOOMKEYS_MS`. +- **A help page is one `.txt`, one `` line in the GResource manifest, and one `kPages` + row.** The bodies are Pango markup in a second GResource (`--c-name=udpscope_help`, so its + generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at + load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from + `parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`. + A page may also carry screenshots (`HelpPage::images`, a null-terminated list of resources). + They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height + at all inside a box, so without the request it is allocated nothing and the page renders its text + with a silent gap where its screenshots should be — which is what the first version did. +- **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons + all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once + Caps Lock is on. diff --git a/docs/claude/invariants-math-channels.md b/docs/claude/invariants-math-channels.md new file mode 100644 index 0000000..7af2b62 --- /dev/null +++ b/docs/claude/invariants-math-channels.md @@ -0,0 +1,119 @@ +# Invariants: the math channels and the node editor + +A derived channel is an ordinary channel; the engine runs on the producer thread, so its cost is measured in packets. + +Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it. + +- **A math channel *is* a channel.** `ScopeModel::set_math_channels()` appends one *synthetic* + `SignalInfo` (with `math = true`) per math channel after the stream's own, so the panel's switch, + the plot binding, the measurements and the export all handle a derived channel without knowing it + is derived -- and another math channel is a valid `Source` for exactly the same reason. The + *samples* come from the producer: `Acquisition::on_data()` collects each channel's value for the + slot and calls `ScopeModel::push_math_sample()` **once per stored slot**, before the deferred + latch (`publish_event()` clamps its snapshot to every ring's `produced()`, so a derived sample + pushed later would be missing from the capture's newest slot). The math therefore runs on the + *stored* (filtered, decimated) samples, which is also why `math_prepare()` gets `slot_dt() * + time_stride`: a cutoff the user typed in Hz means the rate the filter will actually run at. + `src/math_channel.cpp` is the engine: a graph evaluated as a **list walked in order**, so a node + may only read ones *before* it and a cycle is impossible by construction rather than by a check + (`math_channel_valid()` states that as a message the dialog shows). `/tmp/check_math.cpp`-style + checks verified the biquads against known responses (LP fc=100 Hz: 50 Hz -> 0.97, 2 kHz -> 0.002; + BP fc=1 kHz q=2: 200 Hz -> 0.05, 1 kHz -> 0.95, 4 kHz -> 0.05) -- **keep test sines below + Nyquist**: at `dt = 1/10000` a "5 kHz" test tone is the Nyquist point itself and says nothing. + The editor is the *compact graph*: nodes as boxes on a `GtkDrawingArea`, click to select (its + parameters in the form beside it), click an output port then an input port to wire, drag to move; + no pan/zoom. Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`, + so no rebuild can invalidate a live callback handle -- the same shape the measurements dialog + uses. `UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test + as much as a state check), and the dump prints each derived channel's extremes beside the + source's. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the + math is on the producer thread, so what it can break is packet capture, never the frame rate) + and each `math '': ... N samples vs M time slots` being **equal**, with `math x2` at + `y=[-2, 2]`, `math lp` at `y=[-0.998, 0.998]` and `math3` at `y=[-2, 2]`. The `0 visible + (order breaks 0, first at -1), y=[0, 0]` tail of that line is **not** a failure: it is + `visible_samples()` reading row 0's axis after the window was destroyed, and + `UDPSCOPE_EXIT_AFTER_MS` arms the trigger first, so the dump can land in `Waiting`. The live + `ch N '' ... y=[..]` lines are the race-free reading of the same thing. + `math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the + math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure + anywhere in 78--101 ns/slot is one reading of the same thing. Four rules here were wrong first + and are worth keeping: + * **A `Source` may only name an *acquired* channel** -- `ScopeModel::channels()`, which is what the + dialog's dropdown lists, **and both places that create a node must take one from there**. + `signals().front()` is the stream's *timestamp* signal: it is first in every CONFIG, it is not + acquired, and a graph sourced from it validates and then reads NaN for ever. The dialog's + default was fixed first and the pane's `+` was not, which is what "[ch] -> [gain x2] produces + only nan" was. The dropdown also *reconciles*: a node whose source is not in the list is moved + to what the form is showing, rather than the form showing one channel while the node holds + another, and `Apply` refuses a source that is not acquired (the engine's validator is about the + graph -- an output, every input wired above it -- and cannot see the acquired list). + * **The ingest must skip a derived channel.** `Acquisition::on_data()` iterates every channel and + reads each one from the packet, so a math channel with no signal read *zero* and pushed it -- + while `push_math_sample()` pushed the real value on the same slot. Two pushes per slot into a + ring whose time ring advances once: the values alternated between the real one and 0, and once + both rings wrapped the value ring had drifted a whole window off the time ring, so the derived + trace disappeared. `ChannelInfo::math` is the flag (`channel.math`) and the loop `continue`s on + it. **A derived channel's `values(c).produced()` must equal `live().time().produced()`** -- one + push per stored slot -- and the dump prints both as `N samples vs M time slots` for exactly + this: the arithmetic on the plot can look plausible long after the pairing is wrong. + * **`gtk_window_destroy()` does not emit `close-request`.** The math dialog's pointer is cleared + by a `close-request` handler, so a Cancel that *destroyed* the window left `app.math_dialog` + pointing at a dead widget: every later `open_math_dialog()` took the "already open" branch and + presented it -- a GTK critical, and nothing on screen, which is "I cannot edit a math channel + again". Both paths close (`gtk_window_close()`), which emits the signal, lets the handler clear + the pointer, and then destroys. The same lesson as the other GTK traps here: a programmatic + destroy is not a user close, and only one of them runs the handlers. + * **Boxes must not overlap on the canvas.** The first auto-place staggered nodes by 24 px for a + 132 px box, so every box sat on the one before it, `math_node_at()`'s topmost-first search always + returned the last, and a drag or a click landed on the wrong node -- which reads as the dialog + ignoring the pointer. Two columns spaced 160 px apart, rows 58. The probe prints the box the + drag *began over* for exactly this reason. Offering the whole signal list let a graph name something nobody is + acquiring (no data for ever) and put the stream's timestamp signal, which has no samples, in it. + * **`prepare_math_locked()` runs from `rebuild_locked()`, so *every* plan change re-prepares the + filters**: a CONFIG, an enable/disable, and a window change that moves the decimation (that path + is `set_target_span()` -> `rebuild_locked()`). Coefficients are a function of the *stored* + interval and the filter's state is the old rate's history, so both are rebuilt together. + Preparing only on an edit left the cutoff wrong and the state ringing after any window change -- + that is what "I tested a low pass and it was a mess" was. + * **The graph's output defaults to the *last* node, and a node added to a linear graph becomes + it.** The output began at index 0 -- the *source* -- so every operation a user built was + computed and then thrown away: a gain of 2 and a low pass both produced a channel equal to its + input. The output is drawn as the orange dot and settable explicitly, so an earlier node chosen + deliberately is kept; this is the default for a graph that has never been given a choice. + * **`gtk_spin_button_new_with_range()` can leave the value at the range's *minimum*.** The offset + field began at -1e6, and `on_math_param_changed()` reads the widgets, so `x*2` saved as + `x*2 - 2000000`. Every numeric field is seeded with its own identity value; the range-minimum + default is never a useful starting point for a gain or a corner frequency. + * **The form's rows are grouped per *op*, not per field**: gain and offset are one group because + `gain + offset` is one op. The offset was a row of its own that nothing ever hid, which read as + a property of every node. + * **A `GtkSpinButton` holds zero decimals by default**, so every numeric field sets its digits + (4 for gain/offset/clamp, 3 for `fc`/`Q`); without that a 12.5 Hz cutoff was stored as 12 and a + gain of 0.25 as 0. And the canvas drag reports the *total* offset from where the gesture began, + so adding each update's delta to a value the handler then writes back is a runaway box -- it + must record the position at `drag-begin` and ignore a drag that did not begin on a box. +- **The math channels' cost is the *producer's*, and the instrument must not be part of it.** + `push_math_sample()` runs once per stored slot -- up to the wire rate -- so what a regression + here looks like is `gaps`, never a frame time. Three things were taken off that path and must + not come back: a `std::vector` *local* (a heap allocation and free per slot; it is the member + `math_values_`, `assign()`d), a `Source` node's channel resolved **by name** per sample (a + linear scan with a `std::string` compare per candidate, per node, per slot, behind a + type-erased call -- `math_prepare()` resolves it once into `MathNodeRt::src`, and every rebuild + re-prepares), and a **`fetch_add` on the cost counters**. That last one was 20-25 % of the + measured figure: a `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 producer-written + only, so each is a relaxed `store()` of a value already in hand (`math_slot_count_` and + friends). Two things about the figure itself: + * **It is sampled, one slot in `kMathCostSample`, 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 **is wrong and was tried**: 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 a 64-slot run crosses one ~3 % of the time, and a ~1.5 ms gap spread over 64 + slots reported **799 ns/slot against the true ~80**. One slot is always inside one packet. + * **78--101 ns/slot is one reading.** Four identical-config runs spread that far, so the per-slot + path is at the noise floor of this harness and a micro-optimisation cannot be shown to work + here. Converting `MathRuntime` from eight parallel `std::vector` to one array of + `MathNodeRt` was measured and bought **nothing** -- at six nodes the whole runtime is + L1-resident either way. It was kept for the shape, not for a number; do not expect a cache + argument to pay at this size. diff --git a/docs/claude/invariants-measurements.md b/docs/claude/invariants-measurements.md new file mode 100644 index 0000000..68f9404 --- /dev/null +++ b/docs/claude/invariants-measurements.md @@ -0,0 +1,110 @@ +# Invariants: the measurements pane + +Why the pane is refreshed on a cadence, why neither pass may read its window raw, and how a crossing is counted. + +Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it. + +- **The measurements pane is refreshed on a cadence, and its cost is the *window's*, not the + frame's.** A measurement is a statistic of the visible window and a live view moves that window + every frame, so "recompute when the window moves" is "recompute every frame" at O(visible + samples) *per measurement*. With the pane holding four measurements over a 4.7M-sample window + that measured **13.66 ms of tick mean** (baseline 0.41 ms) and cost the run a third of its + frames. Three things fixed it, and all three matter: `update_measures()` recomputes at most + every `kMeasureRefreshUs` (100 ms; 400 ms when a pass costs more than `kMeasureHeavyUs`), once + when the window settles and once per mode change -- and reading *that* backwards is what made + it worse first, 457 recomputes in one run, because a pinned window then recomputed every + frame; the window is materialized in bounded chunks (never two whole-window f32 temporaries) + and the loop holds it **by reference** (copying both vectors per measurement was 300 MB of + memcpy a frame); and `measure_stats()` is one shared pass carrying both extremes with their + first positions, the sum and the count, so average/min/max/peak-to-peak/amplitude need no + pass of their own. **Adding a type must not add a pass** -- that is what `compute_measure()`'s + `stats` argument is for. + The marks' *ink* is set where they are created (`install()` in `plot_host.cpp`): 0.75 px and + dashed 4/4 through `Plot::set_cursor_stroke()`, which is per cursor because the trigger marker + (also a cursor) keeps the solid `line_width_px` stroke. A new measure mark must take that + stroke -- it is created in the same loop, so it does; a new *instrument* should not. + The **marks** the pane draws (`set_measure_marks()` -- full-width `h_ruler`s and full-height + cursors, i.e. the "lines across the plot") are re-placed every frame from the result, so a + throttled pass is a *stepped* ruler: acceptable, because the ruler and the number beside it + come from the same pass and agree. What is *not* acceptable is a ruler that appears and + disappears, so an invalid pass falls back to the last valid result for its **level** marks and + only drops its **time** marks: a level is a property of the signal (a mean, a top, an + amplitude) and outlives the window it was measured in, while a time mark is a position *inside* + that window and may not. Both are emitted in **one pass after the numbers, never inside the + loop that computes them**: that loop has three `continue`s (an unresolvable signal, a channel + with no window) and each one was a way for the rulers to blink off and back. Fixing one and not + the others is how that bug survived a round of "fixes". `clamp fires: N` in the dump is the other half of this: `clamp_to_data()` + compares with a relative tolerance, because clamping to exactly `window_s_` can leave the + group's span an ULP above it and a clamp that fires every frame is a re-frame loop, not a + clamp. +- **A measurement must not read its window raw.** Both passes go through + `plot::data::MinMaxPyramid`, which the producer already maintains at amortised O(1) -- so the + pane inherits the summary of the new samples rather than computing one, which is what the + "evaluate only on new samples" ask comes to here. Throttling the cadence (above) bounded *how + often*; this bounds *how much*, and it is the larger factor: 19.7 ms per recompute to 1.2--1.6 + ms, reading **0.1 %** of a 2 M-sample window. Pass one (`measure_scan_buckets()`) adds whole + level-0 buckets -- 64 samples each, carrying min, max an **exact** sum and a finite count -- and + locates each extreme to a *bucket*; one raw `measure_scan()` of that bucket alone then pins it + to a sample, two buckets at most whatever the window's size. Pass two + (`measure_accumulate_bucket()`) folds a bucket's **sums** only when it lies **strictly** on one + side of its level (`k.lo > level` or `k.hi < level`): then every sample falls in the same bin and + none is *at* the level, so the fold is exact and not an approximation. It asks **separately** + whether the bucket enters a side of the hysteresis band (see the invariant below), because a + single test against `level ± h` refuses exactly the buckets a crossing passes through. A bucket + wholly past a threshold holds that crossing at its first sample, exactly -- which is where the raw + walk would have found it. It refuses a bucket that straddles the level, one that enters a side + somewhere inside itself, and one holding a non-finite sample (the raw walk takes the first + *finite* sample of the new side and an aggregate cannot say which that is); the caller then opens + that bucket. **Nothing may be folded before a refusal** -- a refused bucket goes to + `measure_accumulate()`, which adds the same samples again, and sums added above the `return false` + double-counted every opened bucket, reading `top=0.614304` on a unit sine against the 2/pi it must + be. Four things this rests on, each of which breaks it silently: + * **Exactness is the point, so nothing may stride or decimate.** `peak to peak` must still read + exactly `2` on a unit sine and `top` exactly `0.636620` (2/pi). A bucket's sum is a sum. + * **The bucket index space must be the window's.** `ChannelBuffer::acquire()` calls + `pyramid_.drop_before(values_->base())`, so a channel's pyramid is in the value ring's + absolute index space -- the same space `live_window_locked()` answers in, since a channel's + stride is always 1. The snapshot's pyramid is built from sample 0 and never dropped from, so + its space is the snapshot's own. `MeasureWindowReader::origin_` is what turns one into a + window offset, and a mismatch there measures the wrong samples rather than failing. + * **The raw path stays, and stays exact**, for the window's partial edge buckets, for the + samples produced since this frame's `acquire_all()` (one frame of production, so bounded), and + for the straddling buckets. A window shorter than a bucket degrades to O(window) and must + still be right. + * **Both passes walk the same fetched bucket list** (`App::measure_bucket_scratch`, reused + across recomputes -- a 2 M-sample window is 32 k buckets). Fetching twice is a second walk of + the pyramid for nothing. + `measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say + anything. +- **A crossing is counted with hysteresis, and the band moves the *edge decision* only.** + `MeasureLevel::hysteresis` is a Schmitt trigger about `level`: an edge is counted once the signal + has passed `level + h` going up or `level - h` going down, and a sample inside the band decides + nothing. Without it `period`, `frequency` and `pulse duration` are unusable on anything real -- + the mid level is exactly where a waveform moves *fastest*, so an interferer faster than the + fundamental recrosses a single level several times per true crossing. 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 counted as **79**, `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz, against + `period=0.200188` with the band. Four rules, each of which was wrong first: + * **`measure_set_level()` is the only place the band is sized**, and `src/ui.cpp` calls it rather + than assigning `.level` itself: two expressions for the width is how the raw walk and the bucket + path end up disagreeing about where a crossing was. `h = min(0.10 * (hi - lo), 0.5 * min(hi - + level, level - lo))`. The 0.10 is measured -- 5 % reads a 0.1 interferer and fails at 0.2 + (`period=0.075`), 10 % holds to 0.3 and to white noise at sigma=0.05. + * **The clamp is load-bearing.** `PulseDuration` lets the user put the split near an extreme, and + a fixed tenth of the span there puts the upper threshold *above every sample*: the walk never + decides that side and the measurement reads nothing at all, which is worse than reading + something noisy. On a ringing 3.3 V logic signal at a 90 % split: `enters=0` with a fixed band, + `enters=4 period=0.200000` with the clamp. + * **The `sum`/`count` split stays at the bare `level`.** That split *is* `Top`/`Bottom` -- the mean + above and below the threshold -- so a band there changes a documented exact reading. `top` on a + unit sine must still be `0.636620`. + * **`h = 0` must reduce exactly to the single-level walk**, including the `v == level` skip, so the + band is a parameter of the engine rather than a mode of it. + The cost is the noise's, not the band's: the clean fast mock reads `raw=119412 (0.1%)` of 215 M at + 1.41 ms a recompute and with the 0.1 interferer `raw=2.0%` at 1.96 ms. A + `/tmp/check_measure.cpp`-style check is how the walk itself was verified (`g++ -std=c++20 -O2 -I + src ... src/measure.cpp`): over a clean sine, sines with 0.1 and 0.3 interferers, 10 %/50 % duty + squares and a 90 % threshold, the **bucket walk must equal the raw walk exactly** -- same + `enters`, same `count[]`, identical `first_enter`/`last_enter` to 0.0 -- which is the assertion + that the fast path is a fast path and not an approximation. The app's reading is + `[measures-probe]` against `--noise 0.1`. diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md new file mode 100644 index 0000000..30ee674 --- /dev/null +++ b/docs/claude/invariants-plot-and-layout.md @@ -0,0 +1,106 @@ +# Invariants: the plot, the figure and the layout + +How `plot::Figure`/`plot::Plot` may and may not be driven, what a row's state belongs to, the layout editor's overlay, and the rule that layout must not depend on content. + +Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it. + +- **`plot::Plot` cannot remove or retarget a trace.** Hence fixed trace slots created + up front and rebinding via `SlotSeries`; a *name* change (legend text) is the one + thing that forces a rebuild. Cursors are parked outside the axis range instead of + removed. +- **A `Figure`'s row count is fixed at construction**, so `RowSet::rebuild_all()` builds + a *new* figure and the area is handed it (`PlotArea::set_figure()`); it costs no GL + state. That makes one path for "a row's traces changed" and "the row count changed", + and it is why `rebuild_all()` restates a pinned event window (`pinned_pre_`/ + `pinned_post_`) instead of re-framing on the live edge — a drop during trigger mode + would otherwise throw the frozen view onto the stream, where nothing moves it off + again until the next latch. `PlotArea::create()` is handed `App::placeholder_plot`, + never a row: the area keeps a reference to the plot it was created with, and a rebuild + destroys every row. +- **A trace is a slot with a name *and* a channel; a name alone is not one.** `Row::channel[s]` + is re-resolved by `RowSet::bind()` every frame (slots are identified by name, see above), and + `-1` means the model built no channel for that name. Some names can *never* bind: the stream's + **time signal** is skipped by `build_channels_locked()` however often it is enabled, so a slot + holding it has a name for ever and data never. The plot showed nothing for it, which is why it + went unnoticed, but the cursors pane's `mix` mode drew one line per *named* slot and so gave + each phantom a row that could only read `--` — "the pane shows a lot of empty signals that do + not exist", six lines over three real traces. Two halves to the rule. (1) Anything that counts + traces counts `channel[s] >= 0`, not `!signal_name[s].empty()` — and a per-frame bind means the + cursors pane cannot key its rebuild on `generation()` alone, hence `App::cursors_pane_bound`, a + bit per row/slot (`kMaxRows * kSlotsPerRow` is 32, so one `std::uint32_t`): a trace that + appears without a structural change still gets its line. (2) `apply_layout()` refuses a name + that cannot produce a channel (`ScopeModel::is_time_signal()`), because such a name was + **sticky**: `collect_layout()` writes `signal_name` back verbatim, so one that got into a + session file was re-saved every session for ever. The refusal also heals an existing file on + the next save. How the names first got in is unexplained — the drop path already refuses a + signal with no channel — most likely an older build or a CONFIG whose time signal was not yet + classified as one; the fix is the stickiness and the counting, not a guess at the origin. +- **A row's contents are state of the `RowSet`, not of the figure.** `insert_row()` and + `remove_row()` shift that state with `carry_row()` and let `rebuild_all()` re-deal the + figure handles, so inserting above row 1 moves what row 1 plotted down to row 2 rather + than renumbering the rows under the user. `carry_row()` deliberately copies fields + instead of assigning a whole `Row`: a `Row` also holds the figure handles (`plot`, the + axis and cursor ids) and the `SlotSeries` objects the traces point at, and those belong + to the row's *position*. It clears the destination's slot bindings for the same reason — + the next `bind()` re-resolves them, and without the clear the frame in between draws the + moved row's samples under the new row's legend. + Both call `rebuild_all()` themselves, so their callers must re-point the area + (`PlotArea::set_figure()`) directly; `relayout_areas()` is no help, since it only acts on + a *dirty* row. +- **The layout editor's buttons are *separate* overlay children, never one transparent + child covering the area.** A `GtkOverlay` child is picked wherever its allocation is, and + `gtk_widget_set_can_target(FALSE)` takes a widget's children out of picking with it, so a + full-area `GtkFixed` would swallow every pan, drop and hover the editor is not interested + in. Each button is therefore a small widget aligned to a corner or the centre and placed + by its **margins**, and the overlay is only measured from its main child, so moving them + cannot reach the plot's layout. + They are placed from `FigureLayout::rows` — the rects the frame was *drawn* from — + divided by `PlotArea::device_scale()`, because a `Rect` is framebuffer pixels and a GTK + margin is logical ones. Reading the figure's own rects rather than dividing the widget + height by the row count is what keeps a `×` on the row it names when the rows stop being + an equal division. `sync_layout_editor()` runs every frame on purpose: + `gtk_widget_set_margin_*()` returns early on an unchanged value, so a settled layout costs + the comparisons and nothing else, and a resize needs no notification. +- **Trace slots are identified by signal name, not channel index.** Enabling a signal + or a new CONFIG renumbers the channel list, so an index would silently re-point a + trace at another signal. +- **Layout must not depend on content.** The y gutter is reserved at the widest label + `TickFormat::Si` can produce *for an axis with no unit* — a sign, three significant + figures and a one-glyph prefix, measured and not counted in characters — and only ever + grows; the status label is ellipsized so + its text cannot drive the paned divider. Both exist to stop per-frame relayout. + The reservation deliberately does **not** cover `format_si()`'s `%.3g` fallback + (`-1.23e-45`), which it used to: that form needs a value past 1e24, so it bought a third + of the gutter for a label no stream produces, and the floor being a monotone maximum over + the *measured* need means one that did appear widens the gutter for good anyway. Erring + wide is not free here — it is a third of the plot's width. + And **nothing may add slack to the gutter it is told**: `note_y_gutter()`'s argument is + `layout().y_axis_width`, which is `max(measured, floor)` — partly our own answer coming + back — so a constant added to it compounds. See the ratchet reading under `layout over N frames` in + [probe-readings.md](probe-readings.md). + The same rule is why the activity badge is one glyph in three colours rather than + three glyphs, why the `rate` field collapses to one figure or a range instead of a + per-channel list, and why `format_elapsed()` steps to a coarser unit instead of + letting the counter grow: each is a status field whose *width* would otherwise depend + on the stream, and the status row is directly above the plot. + The reservation goes on the **figure** (`Figure::set_min_y_gutter()`, added to the + plotter for this), not on the rows: `Figure::layout()` clears every row's + `set_min_y_gutter()` to measure what its own labels need, so a floor written onto the + rows survives exactly until the next layout. +- **The right-hand band is a want, and the left gutter is a measurement.** The two sides of + the plot are not symmetric in kind: the left holds the y labels, so it is measured and + floored; the right holds nothing, so there is nothing to measure and `RowSet` simply asks + for half the widest time label plus the label padding (`right_gutter_px_`, reserved in the + same one-shot block). Half a label is the figure that matters: an x tick label is + *centred* on its tick and a live axis is `[-window, 0]`, so there is always a tick exactly + on the axes' right edge, and with `axes` flush to the viewport half of that label was + drawn outside the plot. Both go on the **figure** and for the same reason — the rows have + to end on one column as well as start on one — which is why `apply_gutters()` applies the + pair and `rebuild_all()` calls it: a new `Figure` carries neither. +- **A trace's colour is indexed by *signal*, not by trace slot.** `Row::color_index` + stores it and `signal_color_index()` (ui.cpp) computes it from the signal list, so a + signal dropped on two rows is one colour and the signal panel's chip names a trace + unambiguously. `plot::Plot` fixes a colour at `add_trace()`, so changing one needs a + figure rebuild — which `assign()` already forces. A signal on no plot gets + `RowSet::unplotted_color()` (grey) rather than no chip, so the panel's rows stay the + same height. diff --git a/docs/claude/invariants-readouts-and-cursors.md b/docs/claude/invariants-readouts-and-cursors.md new file mode 100644 index 0000000..a6e9717 --- /dev/null +++ b/docs/claude/invariants-readouts-and-cursors.md @@ -0,0 +1,73 @@ +# Invariants: readouts, cursors and the value formatter + +What a readout is allowed to quote, how the cursors are read back, and the one formatter both panes share. + +Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it. + +- **A readout quotes a stored sample, never a value between two of them.** The hover + crosshair and the cursors pane's `mix` values are one call, `Plot::crosshair_value()`, and it + reports the **nearer** of the two samples bracketing the x (a tie reads the earlier). Upstream + interpolated linearly, and that is wrong twice over here: ingest decimation is a *boxcar + average* over `time_stride` samples, so a number between two stored samples is a second + estimate laid over the first that nothing on screen or in an export holds; and past the sample + spacing the polyline is a line the renderer drew, so a point on it reports the drawing. The + change is in `deps/plotter`, which keeps the blend as `CrosshairValue::Interpolated` for a host + whose x is a quantity it is willing to interpolate — the nearest sample is the **default** and + **udpscope never states the other**, so a `set_crosshair_value_mode()` call appearing here is a + regression, not a feature. + **Both the mark and the number come from one call, `Plot::crosshair_point()`**, which returns + the quoted sample's own **x** as well as its y — so every mark this app draws for a readout is + *snapped to the sample it quotes*. The library's `CrosshairRow::marker` and the cursors pane's + `mix` crossing annotations (`place_crossings()`, which must use `crosshair_point()` and not + `crosshair_value()`) are both placed from it. Two consequences that look like defects and are + not: the mark **sits beside the polyline at a deep zoom** (one y feeding both the number and the + dot is what keeps them from disagreeing), and a crossing dot **sits a little off its own + vertical cursor line** — up to half a sample period, invisible at a whole-window view and the + width of the panel at a few samples across, which is the only zoom at which anyone is asking + which point it is. The line says where the *marker* is; the dot says which *datum* is quoted. + The hover mark is also deliberately *larger* than a trace's own sample dots + (`Style::crosshair_marker_px` 9 against `marker_px` 3), because a snapped mark lands among them + and at their size reads as one more sample. The guards are the library's + `crosshair_value_is_the_nearest_sample`, `the_crosshair_marker_snaps_to_the_sample_it_reports`, + `crosshair_interpolated_mode_blends_the_two_samples` and + `the_crosshair_mark_is_bigger_than_a_sample_dot`, plus `[cursors-probe]`'s `first mark at x=.. + y=..` agreeing with the pane's first value. +- **`format_measure()` is the one formatter for both panes, and its SI prefix does not depend on + there being a unit.** The cursors pane's values and the measurements pane's `text=` go through + it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which + made two decimals of a small unitless value read `0.00` — the reading gone, not merely coarse — + and that is a whole class of channels, not a corner: `measure_unit()` returns `""` for any signal + whose CONFIG declares no unit, and a **math channel never has one**. The regression net could not + see it because `tools/mock_udpstreamer.py` declared `unit="V"` for every data signal; it now takes + **`--unit ''`**, and that is the flag to run the measures and cursors probes under when touching + this (`text=[636.62m]`, `● sine=585.24m`). With no unit the prefix is written **tight** against + the number — `1.34m`, not `1.34 m` — because the space exists to separate the number from its + unit and a spaced `m` alone reads as metres; with one it stays spaced and the prefix joins the + unit (`636.62 mV`). Below `1e-12` the ladder runs out, and `%.2f` would print `0.00` again, so + that range falls back to `%.3g`. +- **The cursors are read back from the plots only while a gesture is on that row.** `Cursor::x` + is the *free* pointer position while a drag runs and the snapped value otherwise -- which is + what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on + every frame therefore adopts its own echo back, and -- because the vertical pair is one value + shared by every row -- a row that has not been re-written yet hands the *previous* shared + time straight back. That is the "drag it and it springs back" defect, and the fix is + `RowSet::sync_cursors(int drag_row)`: the tick passes the row whose controller reports + `grabbed_cursor() != kInvalid`, and every other frame does nothing. The values are echoed + back with `CursorPlacement::Free` for the same reason -- the controller snaps at the end of + the gesture (`Controller::end_drag()`), and snapping the echo fights the hand that is still + moving the cursor. +- **The cursors' on/off is one flag on the `RowSet`, never a copy per row.** It is one + piece of state (the toolbar toggle) and the mode is one value; a per-row copy is something + `insert_row()`'s `clear_row()`, or any rebuild, can clear behind the user's back, which is + how a row added while the cursors were already on came up with its instruments parked + while every other row showed them. The same goes for the vertical pair's `x`: it is one + stream time for the figure, so it lives on the `RowSet` too, and `Row` keeps only what is + genuinely per row (the rulers' values, the traces, the colours). +- **Every instrument this app *computed* is out of the pointer's reach.** The trigger marker and + the measurement marks are cursors created with `Plot::add_cursor()`/`add_h_ruler()`, and a cursor + is grabbable by default -- so each takes `set_cursor_selectable(id, false)`. **Parking it outside + the axis is not enough**: the controller holds a marker for the gesture's life and follows the + pointer between frames, so a readout parked at `kParked` could still be dragged. The trigger + marker says where the scope put the edge and a measurement mark says what a measurement measured; + neither is something a user may move. The dump prints `readout instruments locked: N`, which must + be 1 -- a drag is not something a probe can perform, so the state is what is asserted. diff --git a/docs/claude/invariants-rendering.md b/docs/claude/invariants-rendering.md new file mode 100644 index 0000000..2c0eeb8 --- /dev/null +++ b/docs/claude/invariants-rendering.md @@ -0,0 +1,160 @@ +# Invariants: rendering — index mapping, gather, LOD and grading + +How the renderer maps a visible range onto samples, what `gather()` emits and relative to what, and why a dense trace is drawn one pixel column at a time. + +Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it. + +- **The renderer maps the visible range onto indices through the series' own x, never the + `dx()` grid.** `deps/plotter`'s `Renderer::draw_traces` asks `Series::neighbours()` for the + bracketing samples. A grid walk is wrong for exactly the series this app plots: `dx()` here is + a nominal spacing (the LOD level, a coarse snap) while the real stamps are the time ring, so + the window it computes lands inside the visible range and the two end segments — the ones that + draw into the left and right margins — are never emitted. That is the "empty margin when + zoomed in" defect. The library test is + `a_sparse_window_between_samples_still_inks_both_margins` (a stub whose `dx()` lies, with the + window strictly between two samples). It is the guard, not the probe: `[trig-probe/idx]` now + computes `got` from the same call the renderer makes, so it cannot catch a renderer that went + back to the grid — what it still reports is the drift that made the walk wrong (`dx_ratio`). +- **`neighbours()` is a binary search over the real stamps in *every* series here, the + snapshot's included.** `EventSeries` answered from the `(x - x0()) / dx()` grid — the walk the + invariant above condemns: `dx()` is the *mean* spacing of the snapshot's stamps and the stamps + are an arrival clock, so grid and clock drift apart by whatever that clock wandered over the + capture. The renderer picks its visible index window from this call, so the drift arrived as a + **constant offset in seconds** between the axis and the gathered geometry — and a constant + number of seconds is a *doubling share of the screen* on every zoom step: 8 ms over a 2 s + capture is invisible, and seven halvings later it is 374 px of blank left margin on a 716 px + axis, with the ink pushed off the right-hand edge by the same amount. That is the "zooming + into a held event clips it more and more from the left while the crosshair still reads the + right values" report; the crosshair was right because it reads `x_at()`, a real stamp. + `ChannelBuffer` always searched; the two now agree. **A perfectly uniform source cannot show + this at all** — three probe runs against the ideal mock were clean — so the guard needs + `--clock-jitter` (see `UDPSCOPE_PROBE_EVENTZOOM_MS` above). +- **The `Series` grid contract does not hold for `ChannelBuffer`.** x is a streamed + timestamp, so `dx()`/`x0()` report a nominal grid (LOD level, coarse snap) while + `gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring. + Nothing may walk that grid to find *which* samples a range covers — the renderer did, + and it lost the two end segments (see the margin invariant above). `gather()` emits x + relative to an origin because absolute epoch seconds do not survive `f32`. +- **`gather()` emits x relative to an origin *the caller names*, in every series here**, + `EventSeries` included, and the consumer adds that same origin back in `f64`. Two separate + rules live here and both were learned from a defect. + (1) **It must be relative at all.** An event's stamps are small (edge relative, + `[-pre, +post]`), so emitting them absolute does not overflow anything and does not look + like a bug — it just draws the whole capture `x0()` = one pre-trigger window to the left of + the axis. That was the "half the plot is empty / the marker sits at the end of the data / + nothing is drawn but the crosshair reads values" defect: the crosshair reads `x_at()`, which + was right all along. `[trig-probe]`'s `data=` vs `drawn=` is the guard. + (2) **The origin is the *view's* minimum, not `x0()`.** `x0()` is the start of the + **retained window**, so the f32 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, which at a view 10 µs wide across a 716 px + axis is over a hundred pixels between where a sample is drawn and the x `x_at()` quotes for + it — and it halved on every zoom out, so it read as "zooming in makes the crosshair select a + point beside the data". `Renderer::draw_traces` passes `x_range.min`, making the error a fixed + 4e-5 of a pixel at any zoom. The parameter sits **before** `out_xy` deliberately, so a call + site that was not thought about is a compile error rather than silent drift. A caller that has + no view in mind may still pass `x0()`, and `[trig-probe]`'s `drawn=`/`data=` does exactly that + on purpose: it reads the series' *whole* span, which is the widest relative value a gather can + be asked to stage. The two guards are upstream's + `a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes` and the + `trace ink:` line's `drawn-vs-quoted` figure. + **A probe that recomputes what the renderer gathers must pass the same origin the renderer + does** (`[ink]` and `[zoom-probe]` both pass the axis minimum), or its reconstruction stops + being the renderer's and its pixel readings mean nothing. +- **Every series bound to a trace must carry a real `MinMaxPyramid`**, including an + immutable one. `MinMaxPyramid::level_for()` descends to level 0 — raw samples — on a + pyramid with no levels, whatever `n` is, so a series that answers `pyramid()` with an + empty one is re-gathered sample by sample on every frame it is bound. That was the + "large data is laggy" defect: `EventSeries` had no pyramid, and a held 2 s capture at + 1 MS/s staged 6 million segments per frame for as long as it stayed on screen. The + pyramid lives on `EventSnapshot`, not on `EventSeries`: the snapshot is immutable, so it + is built once in `publish_event()` inside the `O(n)` pass that already computes the y + bounds, and the same channel on two rows shares it. It costs ≈1 byte per sample per + channel. `frame path:`'s `segments` is the guard. + **"Every" includes the pause**, and that is the half this invariant was written without and + which then went wrong for real: `publish_pause_view()` filled `stamps`, `values` and `y` and + left `pyramids` empty, so a *paused* view was re-gathered raw on every frame. Both halves of + the cost are readable, and the second is the one a performance line cannot show. + `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 12.34 → 18.18 ms — and the **trace changed appearance**, which is what + gets reported. `stage_trace_density()` grades a column against *its own peak* from a `1/height` + deposit per segment, so a column fed ~660 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 out. **A pause is not a cheaper case than a capture — it freezes the same rings**, so + anything `publish_event()` does to a snapshot, `publish_pause_view()` owes too. Building it is + not free (see `publish cost:` below): it took the pause publish from 6.15 ms to 22.31 ms, a + one-off producer stall against ~5 ms a frame of draw for the pause's whole length, which at + ~60 fps repays about thirty times over on a 1.6 s pause — and the producer absorbs it without + losing packets (`gaps=0` with and without a pause, the paused run at an 86 ms publish). + **The check that the look is restored is `frame path:` across the pause, not the cost line**: + a paused run must read the same path as a live one — measured `raw=1 lod=555 columns=555 + graded=555` against `raw=0 lod=373 columns=373 graded=373`, same 24711-segment worst frame, + segment mean within 5 % — where the defect read `raw=21 lod=118`. A `raw` in the tens over a + pause is this returning, and it is the appearance as much as the cost. +- **`EventSeries::gather()` emits a bucket's envelope, not every `2^level`-th sample.** + Point decimation hides exactly the spike the capture was triggered on; a scope has to + draw a bucket's extremes. It needs no index rebasing, unlike the ring: the snapshot's + pyramid is built from sample 0 and nothing is ever dropped from it, so `window_base()` is + 0 and a window index *is* the absolute one. +- **A trace denser than the axis is drawn one pixel column at a time, and that is what keeps + the plots free of moiré.** The envelope staged as a polyline banded visibly and *crawled* + under a pan on exactly the signals this app is pointed at — a high rate, a high frequency or + noise — and three things compound to produce it, none a bug alone: `level_for()` sizes a + bucket at one to two pixel columns, `Envelope` emits two points per bucket so each + contributes a near-vertical min→max hairline *and* a near-vertical connector (≈3.6 per + column per trace), and every one lands at a **fractional** x because a trace is deliberately + not snapped. Under src-alpha compositing a column's brightness is then a non-linear function + of the sub-pixel phases overlapping it, the pattern repeats at the beat between the bucket + pitch and the pixel pitch, and a pan sweeps the beat. `deps/plotter`'s + `Renderer::stage_trace_columns()` emits one snapped vertical segment per integer column + instead, so the trace has **one** phase and there is nothing left to beat. Four things to + know before touching it, all upstream in `docs/data-and-performance.md`: the gate is the + gathered **density** (≥2 points per column) and `Envelope`, never the LOD level — the raw + path at 1.5–64 spp beats the same way, and the density term is also what keeps the reduction + off a *sparse* window whose trace is the two segments entering and leaving the frame; a hole + stays a hole; disjoint neighbours are joined so a fast edge keeps its join; and it is the one + carve-out in `u_snap`'s "never anything drawn at a data position" rule, because a column *is* + a pixel column. + **It needs a thin ribbon to be visible at all** — a window whose buckets span several periods + paints a saturated block and hides the beat — so a screenshot of a slow sine at a whole-window + view says nothing either way. The guards are upstream's + `a_dense_trace_is_drawn_as_whole_pixel_columns`, which counts the **distinct coverage levels** + in the ink (4 against the polyline's 126; a count of *partially covered* pixels separates + nothing, 577 against 652, because a 1.5 px stroke's fringe always spills into the next + column), and `frame path:`'s `columns=` field here. `trace ink:` deliberately cannot see this + path: it recomputes the **gathered** points, which the reduction is downstream of, so its + `drawn-vs-quoted` reads 0.0000 either way and remains the guard for the gather origin alone. +- **A reduced column is graded by dwell, and the grading may not undo the reduction.** The + column above is honest about *where* the signal went and silent about *how long it was there* + — a column's min and max are identical whether its samples fill the span or pile at its two + ends, so a ramp and a pulse are one flat band. `Style::intensity_grading` grades it instead + (bright where the signal sits, faint where it only passes through, the digital-phosphor look), + and **udpscope turns it on; the library's default is off**, so other hosts are not restyled by + an upgrade. The switch is a one-shot in `tick()` guarded by `App::style_stated`, not a line in + the window build: `PlotArea::renderer()` is null until the area is realized, so there is no + earlier place to state a style. Three things to know here, the rest upstream in + `docs/data-and-performance.md` (the `1/height` dwell weighting, the one-column accumulator, the + per-column-peak normalisation, the quantised alpha steps): + * **The finer gather level is floored at 1 whenever the flat path would have used the + pyramid.** Grading asks for `samples_per_pixel / intensity_sub_columns`, and + `MinMaxPyramid::level_for()` answers **0 — raw samples** — as soon as that falls under one + bucket (`kBucket` is 64). Unfloored, every moderately dense frame became an O(visible) + gather: at 1.3 MS/s over a 2 s window, 172 samples a pixel, `raw=1154 lod=0` against the flat + path's `raw=710 lod=453`, and 2.30 → 6.36 ms of draw mean. The floor degrades the *look* + (fewer sub-columns than asked for, down to what the coarsest buckets give — still two or + three per column at 64 samples a bucket) and never the path. It is also why + `intensity_sub_columns` past about 8 buys nothing: the bucket size is the limit. + * **`graded=` must equal `columns=`** in `frame path:`, and `UDPSCOPE_NO_PHOSPHOR=1` is the A/B + that makes either line readable against the ungraded path (see + [probe-readings.md](probe-readings.md)). + * **The steps are draw calls, not per-segment alpha.** `LineInstance` is four floats of + geometry and the colour is a **uniform** (only `TextInstance` carries `a_colour`), so the + gradient is `intensity_steps` `LineBatch`es flushed at different alphas. That is why the + segment mean rises from 131 to 1340 on the same frames — the rise is the tiers, not extra + geometry per column, and the bound is still `axes.w` (measured 14.5 runs a column in the + library's own guard, 8303 segments over a 572 px axis). + The guard is upstream's `a_dense_trace_can_be_graded_by_dwell`, which draws one frame both ways + and profiles the band's outer fifths against its middle: flat reads **191/191**, graded + **191/131**. That 0.69 is a *tier* ratio, not the dwell ratio — `intensity_gamma` (0.45) and + `intensity_floor` (0.3) compress it deliberately, the floor so that a fast riser, one + sub-bucket deep everywhere, is never dimmed out of existence. diff --git a/docs/claude/invariants-session-and-export.md b/docs/claude/invariants-session-and-export.md new file mode 100644 index 0000000..285cbf8 --- /dev/null +++ b/docs/claude/invariants-session-and-export.md @@ -0,0 +1,49 @@ +# Invariants: the session file, the export and the source chooser + +What a session persists and in what order, when a save is refused, and when an export is collected. + +Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it. + +- **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the + one that was closed: the window, the rows and their slot signals, the calibration, **the enabled + signals**, **the math channels with their graphs** and **the measurements pane**. Signal identity + is the *name* throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the + model built -- an enabled signal is exactly one that produced a channel -- and `apply_layout()` + installs the graphs **before** assigning the rows, since a row may hold a math channel's name and + that name is only a signal once its graph is in. Two format rules, both learned the hard way: + write an op or a measure *type* as its index through `math_op_at()`/`measure_type_at()`, never as + the raw enum value -- those accessors' orders are the dialog's, not the declaration order, so + writing one and reading the other silently renames operations. And a top-level key written after + a `[[table]]` belongs to *that table* in TOML: `enabled = [...]` emitted after `[[calibration]]` + was parsed into the last calibration record and the enable state was lost without a word. + `mkdirs()` used to create only components with a trailing slash, so `~/.config/udpscope` was never + made and the *first* save failed silently -- worth knowing when a layout change "does not stick". +- **The export is collected at click time, not in the file-dialog callback.** The live + view keeps scrolling while the chooser is up, so a late collection would write a + window the user never saw. The writer is picked by the chosen path's extension. +- **The chooser runs before `ScopeModel` exists, and `--server` is what decides.** `Config` carries + `server_specified`, set only by the `--server` branch, and `main()` asks only when it is false — + so declining costs no buffer and no socket, and `ScopeModel`, `Acquisition`, `RowSet` and + `run_ui()` need no notion of "no source yet". It is `--server` alone and not "no arguments": + `--multicast` still needs a server to fetch CONFIG from. **Every documented probe passes + `--server`**, which is what makes the feature invisible to the regression net — a probe added + without it would reach the chooser and hang. `gtk_init()` plus a nested `GMainLoop`, and then + `run_ui()`'s `GtkApplication` afterwards in the same process, is a verified combination + (`gtk_init()` is idempotent; running your own loop is the documented GTK4 replacement for the + removed `gtk_dialog_run()`). +- **A session file's name cannot say how its source was reached, so the file says it.** + `_.toml` carries neither the multicast group nor the interface, so a chooser row + could not reconnect to a multicast source at all. `LayoutState::multicast`/`interface_addr` are + written in the **top block, before the first `[[table]]`** — a bare key after a table header is + that table's key in TOML, which is exactly how `enabled` was parsed into the last + `[[calibration]]` record and lost without a word. A file from an older build has neither key and + loads as unicast on the default route. The chooser's list filters to sessions that **name at + least one signal**, because a probe run leaves a file per throwaway port and all of them hold + `signals = ["", "", "", ""]`; `known_sources()` splits the filename at the **last** `_`, since + `sanitize()` turns anything unsafe in a host into one. +- **`save_layout_now()` refuses to write while `signals()` is empty.** A run against a source + whose streamer was down receives no CONFIG, so every slot name stays empty and the save would + overwrite a configured session with `signals = ["", ...]`. The chooser then filters that file + out — a source whose streamer was merely down during one run would silently vanish from the + only list it can be picked from, for ever. Keeping the old file loses nothing: a run that + configured nothing had nothing worth persisting. diff --git a/docs/claude/invariants-time-axis-and-window.md b/docs/claude/invariants-time-axis-and-window.md new file mode 100644 index 0000000..d378895 --- /dev/null +++ b/docs/claude/invariants-time-axis-and-window.md @@ -0,0 +1,77 @@ +# Invariants: the time axis and the window control + +What may change the time scale (one function, one caller), how the x labels pick their form, and what a fit restores. + +Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it. + +- **The window control's label is width-locked and ellipsized** (`kWindowLabelChars`), for + the same reason as the status label: it sits in the header bar above the plot, so a label + that grew from `1 s` to `123.456 ms` would widen the bar and relayout everything under it + on a mere change of window. That is also why the window is typed into a popover rather + than shown in the bar. +- **No plot interaction changes the time scale; only the window control does.** The *time scale* + is more than the axis: `RowSet::window_s_` also feeds `ScopeModel::set_event_span()` (the + trigger's pre-trigger share is a share of *this* window), `ScopeModel::set_target_span()` + (which may re-plan the partition, move the decimation and **drop the history**), the window + control's readout and the session file. A **zoom or a pan** does not touch it by construction — + the controller writes the *member* axis and `clamp_to_data()` caps the span at `window_s_`, so + nothing has to be written to keep it that way. **Neither do the two fits**: a fit is the + *undo of a zoom*, so `RowSet::fit_x()` restores the window that is already set and reads + `window_s_` without ever writing it. Both fits used to frame the *data* instead and move + `window_s_` alone, which was wrong twice over — a gesture-triggered re-plan of the rings, and + an event span left on the *old* window until the next decade step or unit change applied the + fitted value for real (one action, two changes of time scale). +- **`apply_time_window()` is the only place the time scale changes**, and the window control is + its only caller. It clamps to `window_limit()` and moves `set_target_span()`, + `set_event_span()`, `RowSet::set_time_window()`, the control and the session save together — + the partition **before** the axis, so the axis is framed over the rings that will exist. A fit + must not come here for two reasons: it is not entitled to the scale at all, and + `set_time_window()` unpins and re-arms the live follow, which would throw a held event or a + paused view onto a live edge it has nothing bound to. Paths that change the span for their own + reasons (an event pin) call `sync_window_display()` alone, which only restates the control and + never moves the plot — calling `apply_time_window()` there would clamp a legitimately short + capture up to the requested window. +- **The x labels go relative on the tick *step against the label resolution at the axis' + magnitude*, never on the span.** `RowSet::update_time_labels()`'s question is only ever "can + this step be written at this magnitude": `TickFormat::Time` carries three significant figures + below a minute, **whole seconds** through `m:ss`/`h:mm:ss` up to a day, and three figures of + days above — so the resolution is a step function of the magnitude, which `time_label_resolution()` + states and nothing else may re-derive. The old test was `span < 1.0` and it was wrong in both + directions: a 2 s window at twenty minutes of uptime reads `20:00` on every tick and stayed + absolute, and a **triggered** sub-second window — whose axis is already `[-25ms, +25ms]` about + the edge — went relative and so re-anchored its ticks *away from the trigger*, with a `t0` + caption that had nothing to explain. Three details hold it up. The step is read back from the + majors the **last layout** produced, which is not a feedback loop (a tick's value depends on the + range and the axis' pixel width, never on the label form) but *is* absent before the first + layout — and with no ticks the form must be **left alone**, because a step guessed from the span + would be latched by the hysteresis below. The hysteresis is a 1.05 band on *leaving* the relative + form. And *entering* it needs a ULP of slack (`1 - 1e-9`): a step differenced from two tick + values and a resolution from `pow(10, n)` differ in their last bits when they are the same + number, and `step == res` is a readable axis (`1.73s`, `1.74s`, `1.75s` — one in the last digit + is exactly enough), so the bare comparison picked a different form from one frame to the next. + The guard is `[timelabels-probe]`'s `distinct=1`, which asserts what the form is *for* rather + than which form a given window gets. +- **The `t0 = …` caption is chrome, never an annotation.** It is `Plot::set_x_note()` (added to + `deps/plotter` for this), drawn by `Renderer::draw_x_note()` on the legend's own + `draw_readout_panel()` backplate in the bottom-left corner of the axes, in **pixels**. It was an + `Annotation` first, and an annotation's anchor is a *data* coordinate: the caption therefore + crawled across the plot under a pan and drifted under a zoom, which is what "[t0=...s] move + slightly around when user zoom/pan" was. Three things follow. The **text is the host's**, because + the caption has to name the anchor precisely enough that label + caption is the exact absolute + time and `format_time()` would round it to three figures (`"t0 = %.6g s"`); only the **bottom + row** of the figure carries one, every other row being set to `""`; and the renderer measures it + rather than `Plot::layout()` — it reserves no space and nothing can be picked on it, so there is + no rect to read back, and a caption explaining the x labels must not be able to move the layout + those labels were measured into. The guards are the library's + `the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move` (the footprint is the pixels a frame + with the caption differs from one without, and a pan plus a 64× zoom must give the same box to + the pixel) and `[timelabels-probe]`'s `note=`, which reads `Plot::x_note()` back. +- **`fit_x()` restores the *whole of what the view may show*, which is not always `window_s_`.** + Live, that is the newest `window_s_` at the live edge (`set_time_window(window_s_)`, the same + path a resume takes). Pinned — a held event, a filling capture or a paused view — it is the + **pin's** full range, which is the snapshot's own stamps and may be legitimately *narrower* + than the requested window. It must write the per-row **member** axes as well as the link group: + `hold_pinned_window()` prefers a member that differs from `pinned_stated_`, which is how it + tells a real gesture from its own echo, so writing the group alone is reverted to the zoomed + range on the next frame. `fit_all()` is the y fit plus this, shared rather than duplicated so + the two cannot drift apart. diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md new file mode 100644 index 0000000..6c6dfb0 --- /dev/null +++ b/docs/claude/invariants-trigger-and-pinning.md @@ -0,0 +1,109 @@ +# Invariants: the trigger, the capture and a pinned view + +The four display modes' framing rules: what the event window is counted in, what pins an axis, and what each pin is measured from. + +Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it. + +- **`clamp_to_data()` caps the span at the *requested window*, never at the retained + span.** It runs every frame and cannot distinguish "the user zoomed out past the data" + from "the ring has only just started filling", so `window_s_` — what the window control + states, capped by `--max-window` — is the bound. Clamping to `retained` alone turned a + requested 2 s window into the ~0.02 s the first frames held, permanently: nothing there + grows a span back. Loosening it to `max(retained, window_s_)` fixed that and broke the + other state instead — while the ring is filling the two are equal, but once it holds + more than the window the bound becomes the whole ring, and a wheel zoom-out walked the + view into data the window was never set to show. `window_s_` is right in both: it never + shrinks a window a caller asked for, and a partly filled buffer is left empty on its + left, which is what a scope that just started should look like. `UDPSCOPE_PROBE_PAN_MS` + reports it as `zoom ... span=.. window=.. limited=1`. + Its pan clamping is likewise gated on the data being wider than the view, or the empty + left-hand side drags the live edge off the right-hand edge. +- **Signals start disabled** — enabling is what starts acquisition. A trigger on a + signal nobody acquires can never fire, so selecting/arming enables it. +- **The trigger's event window is separate from the display window**, and + `pretrigger_pct` is a share of the *event* span, not of the ring. +- **The event window is counted in stream seconds end to end; nothing converts it to + a sample count.** `TriggerEngine` latches once the stamps it is fed have advanced + past the post window, and `ScopeModel::publish_event()` cuts the snapshot by binary + search over the time ring. There used to be a measured samples-per-second estimate + in `Acquisition` doing that conversion, and it is precisely why the post window came + out empty: any stream whose time signal advances in large steps drove it to its + 1 S/s floor, the count came out as 1, and the engine latched on the edge sample + itself. **Do not reintroduce a rate estimate on this path.** +- **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.** + While an event is held, a capture is filling, or the view is paused, `tick()` calls + `RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and + clamps the position into it (and states `Off`, since a following group would re-derive from a + live edge). **A zoom in must keep working** -- "not outside the event" is not "not readable", + and this function's first version *re-stated* the pin every frame, which made the wheel a no-op + in single trigger mode: an acquired event could not be examined at all. + **The request is read from the row's own axis, not from the group.** A member's `set_x_range` + -- what the controller writes for a gesture -- is *not* adopted into the group while the follow + is off, so a clamp that read the group only ever saw its own previous answer (the probe showed + `group` and `member` both equal to the pin while the ask was gone). `pinned_stated_` remembers + what the clamp last wrote: a member that matches it is our own answer coming back, one that + differs is a gesture to honour -- which is also what keeps a *fresh* pin + (`pin_event_window()`, `pin_pause_window()`, both of which write the group in the same tick) + from being reverted to the member's stale window. Without it nothing bounds a drag or + a zoom in those states: the library clamps a *following* group's offset only, and the live + clamp is deliberately skipped here — so a gesture walked the frozen view off its axis, over + data the scope never had. The two are one branch in `tick()` for that reason. A paused view + pins the same way (`pin_pause_window()` sets `pinned_` from the frozen range), so the frozen + picture is equally immovable; resuming or leaving trigger mode clears it. +- **A pinned view is navigable *within its data*, and that is the bound -- not the request.** + Two things had to agree for this, and both were wrong in the same direction: + the **pause** pinned on the window it asked for rather than on the snapshot it got (a cut + clamped to the ring is shorter), and the **held event** pinned on the snapshot's recorded + `pre_s`/`post_s` -- the *request* again -- which a capture shorter than the request exceeds. + Either way the shortfall was drawn as an empty margin, and a zoom-in moved the view *into* it, + which is what made it look as if zooming caused it. `tick()` now re-states the pin from + `RowSet::event_data_window()` (the bound **event series'** own stamps) while an event is held, + from the snapshot's stamps on a pause, and `pin_pause_window()`/the pause handler set the axis + to it as well; `[pause-probe]` prints `axis=` beside `frozen_x=` and the axis must lie inside. + A `Capturing` view is the exception and stays on the *requested* `[-pre, +post]`: its post side + is empty on purpose, and that is the capture filling in view, not a margin. +- **A pause is pinned from its snapshot's own stamps too, not from the window it requested.** The + pause freezes the *visible window*, but `publish_pause_view()` cuts that window out of the ring + and the cut is clamped to what the ring holds -- so a window reaching back past the oldest + retained sample produces a *shorter* snapshot. Pinning the axis to the request left the shortfall + as an **empty margin on the left**, visible before any zoom and made worse by one: zooming in + moved the view *into* the empty part, which is what made it look as if zooming caused it. The pin + is the snapshot's own stamps now. `[pause-probe]` prints `axis=` beside `frozen_x=`, and while + paused **the axis must lie inside `frozen_x`** -- equal at the moment of the pause, a subset + afterwards if the probe zoomed in. Before the fix the two disagreed outright: + `axis=[-1.44..0.56]` over data that started at `0.0`. (The event pin already worked this way; the + pause now agrees.) +- **The event axis is pinned from the snapshot's own stamps, not from the request.** A + capture may legitimately be shorter than asked for — the ring need not reach back a + full pre-trigger share on the first latch after arming, so `snap=[-0.339..1.000]` + for a ±1 s request is correct there rather than a defect. +- **The frame that *frames* a snapshot is the one that consumes its `event_seq()`.** The + tick reads `display_mode()` *before* it takes `mutex()`, while the producer publishes + inside that mutex and lowers `trigger_pending` only at the end of its packet — so a tick + can read `Capturing`, block on the lock through `publish_event()`, and reach the framing + step with a new sequence number it is not bound to. (The other case is benign and was + always handled: a publication and the *next* edge in one frame, where the capture has + framed its own window.) Advancing `seen_event_seq` on such a frame dropped the pin + permanently — the axis kept the capture's requested `[-pre, +post]` over a shorter + snapshot and `event_held` stayed false, letting `clamp_to_data()` drag the frozen view — + so the test is `seq != seen_event_seq && display == Held` and the pin stays owed until a + frame is bound to the snapshot. Visible only at high rates, where the publish is long + enough to lose the race often; `[disarm-probe]`'s `axis=` against `snap=` is the guard, + which means **running the disarm probe at 10 kS/s alone does not check this.** +- **A capture in progress is the one place the axis is pinned from the *request*.** There + is no snapshot to measure yet, so `DisplayMode::Capturing` frames + `[-(span - post_span_s(span)), +post_span_s(span)]` — computed with the producer's own + `TriggerConfig::post_span_s()`, not a second expression that rounds differently, because + the marker at x = 0 has to land where the coming cut will put it. The latch then re-pins + from the snapshot, and the two agreeing is why the picture does not jump at the latch. +- **`clamp_to_data()` must be skipped while `capturing` as well as while `event_held`.** + A capture is live data on a pinned axis — the exact combination the clamp was written + for and against: it would follow the live edge and drag the frozen window off the event. +- **`publish_event()` cuts one absolute index range and uses it for the time ring and + every value ring.** That is what makes `stamps` and each `values[c]` equal-length + and index-aligned by construction instead of by two truncations that can disagree — + `EventSeries::gather()` indexes both at the same `i`. +- **`live_window_locked()` is the one live cut, shared by `publish_event()` and the + export.** Both need the same absolute index range over the time ring and every value + ring, or an export and a capture of the same window would hold different samples. Call + it under `mutex()`. diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md new file mode 100644 index 0000000..4e2a1bc --- /dev/null +++ b/docs/claude/probe-readings.md @@ -0,0 +1,575 @@ +# Probe readings: what each line asserts + +The per-line reference for the self-check's output — what every probe and dump line means, the value it must read, and the defect a wrong reading names. + +Part of [CLAUDE.md](../../CLAUDE.md). + +Two lines of the self-check output are assertions in disguise: + +- `layout over N frames: axes.x A..B, ...` — **`viewport.w` must collapse to a single + value and `axes.x`, `axes.w` and `y-gutter` must *settle*.** `note_y_gutter()` is + monotone (`plot_host.cpp`), its one-shot widest-label reservation can land a frame or + two after the first measured frame, and the figure serves its floor out of the label + strip (`y_width = max(measured, floor - y_title_w)`, `plot.cpp`) so the reservation + converges over two layouts rather than one. A `y-gutter 88..104` dragging + `axes.x 88..104`/`axes.w 707..782` with it is that settle, not a twitch — confirm it + with the `changes` line below. A range that keeps moving is the "twitching axis" bug: + the plot being re-measured every frame. **A range that keeps *climbing* to 200 is the + other one**: `note_y_gutter()` is handed the gutter the row was laid out with, which + already includes the floor imposed last frame, so slack added to it is added again every + frame and the gutter ratchets to its own 200 px cap — `axes.x 88..200 / y-gutter 88..200 + / axes.w changes: 17` on an 870 px viewport is what that reads as, and 17 changes looks + like a settle. The reservation's own padding is the only slack there may be. + `axes.w` also no longer reaches the viewport's right edge: `apply_gutters()` reserves an + empty band there (~59 px of 870), so `viewport.w - axes.x - axes.w` is a *constant*, not + a zero. **`x-desync` must read 0**: it counts frames + in which any row's x range differed from row 0's by more than a pixel of data, i.e. + frames the x link group did not reach. `x-ticks` is content, not layout, and + legitimately varies by one as a live + window slides over a round number. The companion `axes.w changes: N (last at frame F, + side panel up=S)` line separates the two readings: `0`, or a handful near the start, + is a settled layout; a count near the frame count is the twitch. Runs far longer than + the documented probes have shown one ~320 px step — the side panel's width — that + reverts; it is intermittent and unexplained, so read `changes` before calling a range + the twitch bug. +- `[trig-probe] ... trigger=on ... display=... epoch=...` (stderr, + `UDPSCOPE_TRIG_TRACE_MS`) — with `trigger=on`, `display` must read `event(frozen)`, + `live(capturing)` or `nothing`, **never plain `live`**, except for the single interval + that contains the toggle itself — recognise it by `latches=0` *and* the next probe + reading `nothing` or `live(capturing)` with a frozen `axis=`. Plain `live` with the + trigger on is the old "the plot keeps scrolling in trigger mode" defect; the named + `live(capturing)` is the feature. In that state `epoch=` must equal minus the capture's + edge and `axis=` must stay at `[-pre..+post]` across consecutive probes while + `data=`'s upper bound climbs towards `+post` — an axis that moves instead means the + capture is being treated as a live view (`clamp_to_data()` reaching a pinned window). + `epoch=` is **minus the newest acquired stamp** in `live` and `nothing` — it is what makes the + live axis `[-window, 0]`, so there it grows (in magnitude) with the stream and a `0.0000` is + the defect, not the healthy reading. It is back to `0.0000` only in `event(frozen)`, where the + snapshot carries its own origin (`EventSnapshot::x_offset`) and the display epoch has nothing + left to do. Note the probe reads + `pending=` fresher than `display=` (which is last tick's decision), so a single + `pending=0 display=live(capturing)` line is one frame of sampling skew, not a defect. + And `snap=[a..b]` must equal `axis=[a..b]`, with `b` positive by roughly + the post-trigger share: that is the "post window is empty" defect's observable. + **`drawn=[a..b]` must equal `data=[a..b]`** in both modes: `data` is `x_at()` (what + the crosshair reads) and `drawn` is `x0() + gather()` (what the renderer maps), so a + series that breaks the "gather emits x relative to `x0()`" contract shows up here as + the two disagreeing by exactly `x0()` — a trace drawn a whole pre-trigger window left + of its axis, invisible once the view is narrower than the shift, while the crosshair + keeps reporting correct values. +- `[trig-probe/idx] n=.. bounds=[..] dx=.. dx_real=.. dx_ratio=R got=[a..b) want=[c..d)` + (stderr, same probe) — **`got` must contain `want`** (`a <= c` and `b >= d`), and + `dx_ratio` must stay within about a percent of `1.0`. `got` is the index window + `Renderer::draw_traces` gathers *today* — `neighbours()`, one sample of slack either side — + and `want` is the same window from the same call, so read this line for the **rate**: + `dx_ratio` is the nominal `dx()` against the real last-spacing, and it is the number that + used to matter. It above 1 (every index scaled up, `a` past the first visible sample, the + samples in between in the buffer and readable by the crosshair but never drawn) was the + live view whose older part went blank while its newest part looked fine. That is a *fixed* + defect: the renderer no longer walks the grid at all, because on a ring-backed series it + also lost the two end segments and left the left and right margins of a zoomed-in view + empty. The guard for it is the library's own pixel test + (`a_sparse_window_between_samples_still_inks_both_margins`), not this line — a probe that + recomputes the renderer's formula cannot see a renderer that changed it. Also + read `axis=` in the companion line against the `window control:` line in the dump — + a live axis narrower than the requested window is the same symptom from the other + cause (see the `clamp_to_data()` invariant in + [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)). Both need a *saturated* ring to + show up, so run them under `--buffer-mb 1`, which wraps in a few seconds. +- `trigger: edge at X ... want [-a..b] got [-c..d] n=N` (stderr) — `got` must equal + `want` and `N` must be exactly the window times the stream's real rate, plus one + (2 s at 10 kS/s is `n=20001`, not 19999). `got` shorter than `want` on the *left* + means the edge was accepted before the history reached back a full pre window (the + gate in `TriggerEngine::process()`); shorter on the *right* means the engine latched + early or the cut lost the newest samples. An `N` one or two short of the exact count + is the same class of bug, not rounding: `publish_event()` clamps the slice to every + value ring's `produced()`, so publishing before each channel of the latching sample + has been pushed silently drops it. +- `[disarm-probe] was_capturing=1 display=.. seq=A->B events=C->D snap=[a..b] n=N + axis=[..]` (stderr, `UDPSCOPE_PROBE_DISARM_MS`) — with `was_capturing=1`, **`display` + must read `event(frozen)`** and `snap`/`axis` must be `[-pre..e]` where `e` is how far + the post window had filled, with `n` that span times the stream's real rate plus one. + `display=nothing` (or a `snap` from the *previous* event) is the "disarming mid-capture + makes the event completely disappear" defect. **`seq` must advance by exactly one while + `events` does not move**: no event fired, so the partial publish deliberately leaves + `trigger_latches()` alone — which is also what keeps the `trigger events: N total, M at + the disarm` line in the dump meaning what it says. +- `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] abs=[..]` (stderr, + `UDPSCOPE_EXPORT_MS`) — **`time=` must equal `axis=`** and **`rows=` must be the + span times the stream's real rate, plus one** (2 s at 10 kS/s is 20001). The export + is the *visible* range at the *stored* rate, so a count far short of that is a table + cut from the LOD pyramid, and a `time=` narrower than `axis=` is a window searched on + the wrong clock (the live branch puts the window back on the stream's clock with + `stream_at_x0()` before searching the ring, and that offset is a whole window while + streaming — not the 0 it once was). + **`abs - time` must be a single constant across both ends of a line**, and that constant is + where the display's origin sits in the stream's own clock: `epoch()` plus the newest stamp in + `mode=live`, `epoch()` plus the accepted edge in `mode=event`. That is the one assertion on + the second column, and it replaces the old "`stream=` must be the edge ± the window" reading, + which only ever held because the display clock *was* the stream clock. A `mode=live` line + whose `abs - time` differs between the two ends is the two columns computed from different + epochs — the defect `abs_base` exists as a single `const` to prevent. + An export taken **mid-capture** reports `mode=live` with `time=[-pre..e]` where `e` is how far + the post window has filled — the export is what is on screen, and during a capture that is a + partly filled window. Pin the probe outside a capture when checking the `rows=` count. + The two `bytes=` lines are the writers: the `.npy` is + `64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a + mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing + either writer. +- `[window-probe] label=.. entry=.. unit=U window=W event_span=E cap=C decim=M + stored=R aa=F buffered=B` (stderr, `UDPSCOPE_PROBE_WINDOW_MS`) — **`window` must equal + `event_span`** on every line (the trigger's event span is the display window; a capture + cut to a different length than the axis frames is the "post window is empty" defect's + cousin) and **neither may exceed `cap`**. The `unit ms->s` line is the one that catches + the feedback loop: changing the unit re-interprets the typed number, so "500" in ms + becomes 500 **s exactly**, and a `window=499.98` there means the control re-parsed its own + rounded readout (`8.333 min × 60`) instead of the request — see the `window_sync_unit` + invariant in [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md). `label` is a reading to three decimals in a natural unit and is + *expected* to be lossy (500 s reads `8.333 min`); it is the `window=` figure that has to + be exact. + For the resampling: **`buffered` must be ≥ `window`** on every line — that is what the + decimation is for, and a `buffered` short of the window is the old "the control is capped + to what the ring holds" behaviour coming back. `decim` must be a power of two, + `stored × decim` must equal the wire rate, and `aa` must be exactly `stored / 2`. Run it + under `--buffer-mb 1`, which saturates in a few seconds and makes windows past ~6 s + decimate; at 200 MB nothing inside `--max-window` needs to. +- **The anti-alias check** is the same probe run against a waveform above the stored + Nyquist: `--buffer-mb 1 --freq 2000` with the probe ending on a 60 s window gives + `decim=16`, a 625 S/s stored rate and a 312.5 Hz corner, and the channel dump's + `ch 0 'sine' ... y=[-0.059, 0.079]` is the assertion — a boxcar of 16 at 10 kS/s passes + 2 kHz at about 6%. **A `y=[-1, 1]` there means the filter is gone** and the 2 kHz sine has + aliased to a full-amplitude 125 Hz one, which looks entirely plausible on screen. This is + the only check that distinguishes averaging from striding. +- `[cursors-probe] gestureless plot move with no gesture: stored A -> B ignored=D` — a + plot-level `set_cursor_x()` with **no gesture behind it must be ignored** (`ignored=1`, and + `A == B`): the tick reads a row only while its controller holds a cursor, and this line is the + guard for the defect where it adopted its own echo and a dragged cursor sprang back. (The + *adoption* half -- a gesture that does move a cursor, and the shared value reaching every row + -- is asserted in the `check_host` harness, which can call `sync_cursors(row)` directly; a + synthetic press here cannot reach the adapter's own event pump.) +- `[cursors-probe] mode=.. on=D pane=D t0=.. t1=.. dt=.. T=[..] lines=N [..]` + (stderr, `UDPSCOPE_PROBE_CURSORS_MS`) — walks `vertical` → `horizontal` → `both` → + `mix`, then moves a marker *through the plot* (`Plot::set_cursor_x(..., Free)`, which is + what a drag does) and reports again. **The times must survive every mode change**: they + are the `RowSet`'s one pair, and a mode that parks the markers must not read them back + (a parked cursor reports a real number, so a read-back would adopt it as a gesture — the + bug that made `T0` read 0). **`lines`** must be 0 for `vertical`, one per row for + `horizontal`/`both` (and the time row exists only in the modes that show the vertical + pair — `T=[]` in `horizontal` is the assertion that it does not), and one per **bound** + trace for `mix` — a slot with a name *and* a channel the model built, never a name alone + (see the bound-slot invariant in + [invariants-plot-and-layout.md](invariants-plot-and-layout.md); `lines=6 marks=3` over three real traces is that + defect). Each line reports as `= `, so the names are the reading: a row + claiming a signal that is not on a plot is the phantom. In `mix` the values must be the + traces' own (`--` when a marker is past the newest sample, `partial` when only one + marker is over samples). After the move, `t0` must have changed and every row's marker + must have followed it (the report's `t0` is the shared value). **`marks`** is the `mix` + mode's crossing annotations: **0 in every other mode, and one per bound trace for each + marker that is over samples in `mix`** (so a move that brings the second marker into the + data doubles it). The `first mark at x=.. y=..` line that follows must agree with the + pane's *first* value for the same trace (the pane's lines are `chip name Y1 Y2 dY` -- + the chip carries the signal's name, since a chip alone leaves the user to match a colour + against the plot by eye -- with the values formatted as measurements, two decimals, an SI + prefix, the channel's unit, so the comparison is against the first number): both are read through the same + `Plot::crosshair_value()`, and that line is the check that they stay the same number. + A **ruler** drag is the other direction and the other hazard: the pane's `A`/`B` cells + for a ruler row must change when the ruler moves (`after ruler drag` in the probe walks + the first ruler to 80% of its y range), because nothing shares a ruler and the read-back + in `RowSet::sync_cursors()` is the only thing that can notice. The dump's `cursors in plot + 0:` line is the other half: it reads the markers back out of + the plot (`cursor(id).x/.visible`), which is what the renderer draws from — the pane's + numbers and the plot's state agreeing is the difference between "the app placed them" and + "the app placed them and they are on screen". +- `[measures-probe]` + the dump's `measure N: 'sig' type thr=..% above=D show=D -> ok|invalid value text=[..] + [duty=..] [marks=..]` and `measures pane: n=N revealed=D marks_shown=M pane_rows=R pane_w=W + pane_h=H` + (stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first + plottable signal and lets the tick compute them, so what it prints is what a user reads. + On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.64 and + `bottom` ≈ -0.64 (the mean of each half of a sine), max/min ≈ ±1, `pulse duration` ≈ 0.5 s + with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. It needs **`UDPSCOPE_ENABLE_ALL_MS` + ahead of it** or it prints `no acquired signal to measure` and the dump reads `n=0`. + **Run it against `--noise 0.1 --freq 1` as well, and the three crossing types must still read + those figures** (measured: `period=1.000100 frequency=0.999900 pulse duration=0.504100`): the + hysteresis band is the only thing holding them there, a single level reads a 500 Hz interferer as + the signal, and the default `--noise 0` mock cannot see it. The band costs `raw`, not accuracy -- + the clean fast mock reads `raw=0.1%` at 1.41 ms a recompute and the noisy one `raw=2.0%` at 1.96 + ms, so read `measure cost:` on **both**. `marks=` lists each mark with its kind, and the **mix is the point**: + `level:V` alone for average/top/bottom, `level:V,time:T` for min/max (a ruler at the value + *and* a marker at the first sample that reached it — two level marks at both extremes was + the bug), `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with + its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration` + seconds, and the rest the channel's unit. `marks_shown` counts two per shown measurement + (a min/max showing both its marks counts two of its own). `measures window:` is the + timeliness assertion -- the window the numbers were computed over against the one on screen. + **The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window + on screen. Both ranges are on the **stream's** clock, which is the space `measure_window` and + the time marks are stored in -- a live axis is a fixed `[-window, 0]`, so comparing display x + would read no drift whatever the data did. + `drift` itself is a **single sample of a sawtooth** and is not an assertion on its + own: a live view scrolls between recomputes, so it rises to one `kMeasureRefreshUs` of scroll + and drops to zero at each pass. The figure is `(|Δmin| + |Δmax|) / span`, so a pure scroll + counts **twice** and the top of the sawtooth is `2 × refresh / window`: ~10 % on a 2 s window + at the 100 ms cadence, and a few *hundred* percent on a window shorter than the interval (a + 50 ms window reads 254 % for 63.6 ms of scroll, and its ceiling is 400 %). Compute the ceiling + for the window the run actually used before calling a figure large. A `0.0000%` + is a dump that landed just after a pass, not a better state. Read it against the recompute count: + *more* recomputes with a *higher* drift sample is the sawtooth, while a drift that exceeds one + refresh interval of scroll, or marks outside the window, is the defect. A measurement is + recomputed when the window moves (2 % of its span) or the held/live state changes, never on a + wall-clock timer: a timer is what let a marker sit outside the viewport and a dragged one + survive a frame. **`marks_selectable` must be 0**: the + measurement marks are created unselectable (`Plot::set_cursor_selectable()`, which the + library gained for this), read back out of the plot here -- re-placing a marker every frame + does **not** stop a drag, because the controller holds it for the gesture's life and the + marker follows the pointer between frames. The library's own controller test + (`an_unselectable_cursor_is_neither_hovered_nor_grabbed`) is the other half. The probe's last + step still drags a mark *programmatically* and requires the next frame to put it back + (`restored=1`), which now checks the placement path rather than the read-only property. The dialog report's `type= thr= + side= show=` is the per-type behaviour: `period` and `frequency` must show **neither** the + split nor the side row (they compute from the mid level's rising crossings, and an option + there is one the user can only get wrong), `average`/`min`/`max` no split either, and only + `pulse duration` a side. A measurement whose signal is not acquired, or + a window with too few crossings, must read `invalid`/`--` rather than a made-up number + (`frequency` on a window holding one edge is the case: `duty` is reported only when a + period exists to divide by). The probe then **walks the dialog's lifecycle**: open the edit + dialog on measurement 0, click its `Save`, open it again, click `Delete` — `measures=` must + be unchanged after the save and one lower after the delete, with the process still alive. + That is the check for the two things a dialog of this shape gets wrong: two buttons owning + one context (a double free aborts on edit/delete, and only those paths have two buttons) and + a `const char*` array built from a `std::vector` as it grew (every pointer + dangling, the first entry the most visibly). The report's `signals=[..]` and `drop_h` are the second and third: + the list must be the **plotted traces** (real names, deduplicated, in signal-list order — + *not* every channel, and never a stream's timestamp signal, which is what an acquired-but- + unplotted list put first and auto-selected), and `drop_h` must be a combo box's height + (tens of pixels — a scrolled-window wrapper asked it to grow to 160). +- `[timelabels-probe] relative=D distinct=D window=.. origin=.. note=[..] ticks=[..] + first_value=.. first_rel=.. range=[..]` + the dump's `time labels: relative=D distinct=D + origin=.. note=[..] x_origin_set=.. ticks=[..] first_rel=..` (stderr, + `UDPSCOPE_PROBE_TIMELABELS_MS`) — the x label form across a zoom, driven through + `apply_time_window()`: **under the figure's x link group the group owns the window, so a + row-level `set_time_window()` does nothing** (a probe that changes the window must use the + app's path). **`distinct` must be 1 on every line, and that is the assertion** — it says no two + major labels on the axis are the same string, which is the one thing the relative form exists to + buy and the only reading that holds whatever the stream's uptime is. `relative=D` is **expected + to read 0 on every line now**, and that is a consequence of the newest-relative axis rather than + a change to the rule: the form is decided by the tick step against the label resolution at the + axis' **magnitude** (see + [invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)), and no display mode carries a large magnitude any + more — live and waiting are `[-window, 0]`, a capture and a held event are edge-relative, a pause + is freeze-relative. The 50 ms window that used to read absolute at 2 s of uptime (`2.13s, 2.14s, + 2.15s`) and relative at 17 s (`100µs, 10.1ms, 20.1ms`, with the caption) now reads + `-40ms, -30ms, -20ms, -10ms` at any uptime. The rule and the caption are kept because they are + the answer for *any* axis magnitude and nothing in them is mode-specific — do not delete them on + the strength of this reading — but what the probe asserts is `distinct`, not the form. The + **caption must be non-empty exactly when `relative=1`**, which here means empty throughout. Run it after + `UDPSCOPE_ARM_TRIGGER_MS` as well, which is the case the span rule got wrong: a triggered 50 ms + window must read `relative=0 note=[] ticks=[-20ms,-10ms,0,10ms]` — a `t0` caption on an axis + that is already edge-relative re-anchors the ticks away from the trigger, which is the one + position the axis exists to show. The probe *races the paint* — it reads the axis on a timer, + and the ticks it sees are the previous layout's — so its `first_rel` (the first major minus + the axis' origin) is a fraction of a step; the dump runs after the last paint and is the + race-free reading. Either way the assertion that matters is the *sum*: `first_value` must + equal `origin + first_rel` as a *tick label* plus its caption, i.e. the labels and the + caption must share one anchor. `x_origin_set` is read back out of the plot; it must be 0 in + the absolute form. Note the last two steps both report `window=0.05`: `RowSet::set_time_window()` + floors the window there, so the 20 ms step is clamped. +- `[fit-probe] pinned=D window=.. event_span=.. x=[..] span=.. y=[..]` (stderr, + `UDPSCOPE_PROBE_FIT_MS`) — the two fits, through the **menu handlers** rather than the bare + `RowSet` calls, because the notice and the control are the handler's half. A fit is the **undo + of an x zoom**, so the probe performs one: it moves the y scale off the data (a fixed + `[-100, 100]`), sets a 2 s window through the control's own path, then zooms row 0 to a quarter + of it the way a gesture does — on the *member* axis — and fits. Four assertions. + **`window` and `event_span` must be unchanged on every line** and equal to the typed 2 s: a fit + is a plot interaction and may not restate the time scale (see + [invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)), and they are + the only two readings that can see it try. The `zoomed` line's `span` must be a quarter of the + window, or the fits have nothing to undo and the lines below mean nothing. **`fit x` must put + `span` back to the whole window and leave `y` exactly as it was**, and **`fit all` must do the + same and move `y` to the data's own range** — a `y` that changed on the `fit x` line is the two + paths aliased. `x`'s *position* advances between lines on a live view and is not an assertion: + the follow is re-armed on the newest data, which is what the whole window means there. + Run it **inside a pause** as well (`UDPSCOPE_PROBE_PAUSE_MS=2000 UDPSCOPE_PROBE_FIT_MS=2200`), + which is the other branch and the one that can silently revert: `pinned=1`, and `fit x` must + restore the *pin's* full range — the snapshot's own stamps, legitimately narrower than the + requested window (`span=1.179900` against `window=2.000000` is correct, not a finding) — and + must still read `pinned=1` afterwards. The pin is restored by writing the **members** as well as + the group, because `hold_pinned_window()` prefers a member that has moved since `pinned_stated_` + — that is how it tells a gesture from its own echo — so a fit that wrote the group alone reads + back as the zoomed range one frame later. +- `[layout-probe] rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]` + (stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is + up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the + buttons exist only while the editor does. `done` follows the same rule for the editor's + own exit button (1 up, 0 off), and its `done_box` on the `placed` line must sit in the + area's top-right corner: `x + W` within a few pixels of the area width and `y` within a + few of 0 (the on-switch line reports a box from before the first frame, so read the + `placed` one). The last probe step leaves the editor by **clicking that button** — the + toolbar toggle is still exercised by the first step — so `buttons=0 done=0` with the + rows and traces untouched is the assertion that the way out works. **`traces=` is the real assertion**: an insert + or a remove moves the rows' *contents* with them, so `add above 0` must read + `[0:-|1:sine,…]` — row 0's signals now on row 1, not renumbered onto the new empty row — + and `remove 0` must pull them back to `0:`. A `traces=` where the signals stayed on row 0 + means `carry_row()` was skipped and the rows were renumbered under the user. + Geometrically, each `close=` must equal its row's top from `rows_y` plus the pad, and + `add=` must be centred on the boundaries (so strictly increasing, the first at 0 and the + last clamped to the area's bottom). The `rows_y` spans must tile: a gap other than + `kRowGap` means the figure and the overlay disagree about where a row is, which is how a + `×` ends up on the wrong row. Also read `x-desync 0` in the dump — every row the editor + adds has to join the one x link group. Run it at `--plots 8` as well: the cap and the + floor are the two refusals, and because the probe emits `::clicked` straight at the + widget it reaches the handler even when the button is insensitive, so `add above 0` there + must leave `rows=` and `traces=` untouched rather than growing a ninth row. +- `header: window_button_w=W label_w=L bar_w=B` (stderr, once a second) — **`W` must be + a small constant and must not track `B`**: the window control is a header-bar child, + and an expanding one is measured into the title's centre box, so it stretched from the + label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that + grows with the window width is that bug back. +- `toolbar icons: trigger=NAME(ok) pause=... cursors=... measures=... math=... sidebar=... + menu=...` (in the exit dump, beside `panel controls:`) — the icon name every toolbar button + carries, with `gtk_icon_theme_has_icon()`'s verdict. **Every entry must read `(ok)`.** Four + of the names are this app's own (`udpscope-trigger-symbolic` and friends, a `GResource` + compiled into the binary — see the toolbar-icon invariant in + [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)), and a `(MISSING)` on one of + those means the resource path was never registered. That failure is otherwise *silent*: GTK + substitutes a missing-image placeholder, the button still works, and every other line of the + dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this + is the only check that it names something that exists. +- `app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)` (in the exit dump, + beside `toolbar icons:`) — the application icon, named and present at the size its own root + `` element declares. **Both entries must read `ok` at those sizes.** `ok` is + `gtk_icon_theme_has_icon()` *and* the resource bytes being found under the registered path, so a + `MISSING` is a resource path that was never registered or a file that was renamed, and a size + other than 128 or 16 is a canvas that drifted off the HIG's. **Read it together with + `toolbar icons:`, and trust that line further for the registration**: the resource-path + registration is one call in `run_ui()` serving both the window's icon and the toolbar's buttons, + so a `MISSING` on either line is the same defect — but on a machine where + `--target desktop-entry` has been run, *this* line's `has_icon` half is also satisfied by the + **installed** copy under `$XDG_DATA_HOME/icons/hicolor`, so a broken registration still reads + `ok` here. That was measured: with the resource path deliberately misspelt, `toolbar icons:` + reported four `MISSING` (nothing installs the action icons) while `app icons:` stayed `ok` + until the two installed SVGs were moved aside, whereupon it read `MISSING 128x128` / + `MISSING 16x16`. The line is still worth its place — the *present* half (the resource bytes) and + the declared sizes are read from the binary and nothing else can satisfy them — but a developer + machine is the one where it is least sensitive. + **It deliberately does not claim the icon renders**, because no runtime GTK call can: + `gtk_icon_theme_lookup_icon()` never returns `NULL`, its paintable's intrinsic size is the size + it was *asked* for, and a valid icon, a malformed one and a name that does not exist at all all + snapshot to the same `GSK_TEXTURE_NODE` of the requested bounds — GTK substitutes + `image-missing`. A malformed SVG is a *build* error instead; see the icon-authoring rules. +- `[sources] N known: HOST:PORT plots=P signals=S [multicast=G] [interface=I] | ...` (stderr, + `UDPSCOPE_PROBE_SOURCES=1`) — the startup chooser's discovery, printed and exited **before + `gtk_init()`**, so this one needs no display and no `xvfb-run`. **Only sources with a named + signal may appear**: a development machine accumulates one session file per probe run on a + throwaway port (140 of them when this was written), and the filter in `known_sources()` is + what keeps the list the sources a user configured. Order is newest first by file mtime. +- `[sources] picked HOST:PORT multicast=G interface=I` (stderr, `UDPSCOPE_PROBE_PICK_MS`) — + shows the chooser, then activates row 0 the way a click does and continues into the real + application, so a full run with **no `--server`** must reach the scope and emit every other + dump line unchanged. `multicast=`/`interface=` coming back non-empty from a stored row is the + assertion that a multicast session reconnects to its group, and it needs **no multicast + networking**: write the session file by hand and read the line. With + `UDPSCOPE_PROBE_NEWHOST=host:port` it drives the form instead — `[sources] form open, + connect_sensitive=1` then a `picked` line, with no SIGSEGV, which is as much a crash test of a + dialog built and destroyed as it is a state check. An empty list must print `picked nothing: + the list is empty` rather than dereferencing a null row. +- **The session layout round trip** (`~/.config/udpscope/_.toml`) — run once + with a drop and a window change, then again with no interaction: the second run's dump + must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the + first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0 + -> channel ... samples=N` line). Delete the file before a run whose layout you did not + mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise). +- `[udpscope] starved: connected (config=1), reconnects=N, no new frames in the last + second ...` (stderr, once per second while the state holds, plus a status-line notice) — + "connected but nothing is arriving after a reconnect", which over unicast is the second + instance taking the source's single session. Check it does **not** fire against a live + source. The status line carries ` | N reconnects` only when N > 0, which is the same + counter without the prose. + **The same stall test has two outcomes, split on `have_config`, and they are different + diagnoses.** With a signal list in hand, the above. With **no CONFIG at all** it prints + `[udpscope] connecting: connected (config=0), reconnects=N, no configuration from the + stream ...` and the notice reads *trying to connect* — nothing was taken away, because + with no signal list nothing can be enabled and no DATA frame is owed, and the old wording + blamed a second reader that does not exist. That state is also named *without* waiting for + the three-sample stall: the glance word is `connecting` and the badge is **yellow** from the + first frame (see `badge frames:`). The way to see it with no second client is to point the + app at a UDP port where nothing answers (the client CONNECTs, times out, reconnects), which + is now the `config=0` branch and no longer exercises the `starved:` one at all — a check of + the unicast-contention wording needs a real second reader, or a source that configures and + then goes quiet. +- **The export's `time`/`abs_seconds` columns must be strictly increasing and evenly + spaced.** Check the written file, not just the probe: `np.diff(t)` with no `<= 0` and + `rows / (t[-1] - t[0])` equal to the stored rate (the `ch` line's figure). Duplicate or + backwards stamps mean the recorded timeline holds two clocks or a collapsed cadence — + the two ways this has broken: a per-frame choice of time source (now sticky, see the + README) and a per-sample step derived from the client's `lost` counter (now the + stream's measured arrival cadence). A `rows=` count far below the span times the rate + is the other signature, that of a table cut from the LOD pyramid. + **The step must hold to a few ULP of the column's own magnitude**, and this is the check that + needs a long run at a high rate: the old form of this assertion was "`set(np.diff(t))` holds + exactly one value", and that was only true while `time` *was* the stored stamp. Both columns + are derived now — `time` is `stamp - stream_at_x0()` and `abs_seconds` is that plus a base of a + wholly different magnitude — so each subtraction costs a rounding and `set()` legitimately + holds a handful of neighbouring doubles. Measured: four values spanning ~1.5 ULP, 6.8e-16 s. + The thing that must not come back is the **plateau**: equal stamps, or a diff taking two or + three values a few *hundred nanoseconds* apart, is the time ring losing precision with uptime + (see the `f64` time-ring invariant in + [invariants-data-path.md](invariants-data-path.md)). The figure to compute is the ULP of `t[0]` and of + `abs_seconds[0]` — the defect that invariant guards is microseconds, nine orders of magnitude + above the arithmetic noise, so the two readings cannot be confused. +- `badge frames: lit=L dim=D idle=I connecting=C` — the activity badge's phase per frame. Over + a run that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it + blinks" means, and a badge stuck lit is indistinguishable from a working one in a + screenshot. One or two `idle` frames at the start are the frames before the socket + connected; a large `idle` is correct only when the run paused or disarmed (a + `UDPSCOPE_PROBE_DISARM_MS` run should show hundreds). **`connecting` must be 0 on a run + against a live source** — it is the steady *yellow* phase, "the socket answered but the + stream has sent no CONFIG", and a non-zero count against a working mock means a signal list + that arrived late or not at all. It is the *only* non-zero phase on a dead-port run + (measured: `lit=0 dim=0 idle=90 connecting=446`, the `idle` being the frames before the + first CONNECT), which is also the check that a source that has sent nothing never gets the + blinking green. The `mode ...` lines carry the rest of the status line, so `rate` and + `last event ... ago` are checked there. +- `frame cost: tick A/B ms over N, draw C/D ms (max at frame F), paint E/G ms over M` and + `frame path: raw=R lod=L columns=C graded=G frames, segments S/T (mean/max), visible max V samples` + — the + drawing cost, split into the frame tick, `PlotArea`'s own draw (timed from the frame + clock's `before-paint` to the end of the host's `render` handler) and the whole GTK + paint. **`segments` max must stay in the low thousands and `lod` must dominate `raw` + on any run holding a large capture**: a max in the *millions* with `lod=0` is the + empty-pyramid defect (see the `MinMaxPyramid` invariant in + [invariants-rendering.md](invariants-rendering.md)) — the renderer drawing + every raw sample of a held event on every frame, which measured 6,000,073 segments and + 100–230 ms a frame at 1 MS/s. `raw` frames are expected and cheap below 64 samples per + pixel, which is where `level_for()`'s own gate puts the boundary. + **`columns` must equal the frames that drew a trace at all on any run at a high rate**, and + it is the reading that says *why* `segments` is small: a dense gathered window is reduced + onto the pixel grid upstream (one snapped vertical segment per column — see the moiré + invariant in [invariants-rendering.md](invariants-rendering.md)), so the geometry is bounded by + `axes.w`, and a `columns=0` beside a large + `segments` is that reduction gone rather than a narrower window. Measured on + `--rate 1300000 --batch 2000 --freq 3000` over 20 s: `raw=2 lod=1198 columns=1200 frames, + segments 2041/2237` over 1201 painted frames, against `columns=0 ... segments + 10960/216093` over 263 before it existed — the same run at 4.6× the frame rate. + **`graded` must equal `columns`**: this app turns `Style::intensity_grading` on, and grading is + a way of *drawing* the reduction rather than a third path, so a `graded` below `columns` is the + style not reaching a frame. **`UDPSCOPE_NO_PHOSPHOR=1` is the A/B** and the only way to read + either of these two lines against the ungraded path, since a frame mean cannot attribute its own + cost: measured on `--rate 1300000 --batch 2000`, `columns=1166 graded=1166` with + `draw 3.24/21.43 ms` and `segments 1340/4157` against `graded=0`, `draw 2.30/25.76 ms` and + `segments 131/4306` over the same `lod=453` frames. The segment rise *is* the gradient (alpha is + quantised into steps, one `LineBatch` and one draw call each, because the line pipeline's colour + is a uniform) and the ~1 ms is its cost; a `raw` that *grows* when the grading is on is the + level-floor invariant in [invariants-rendering.md](invariants-rendering.md) gone, which measured `raw=1154 lod=0` and 6.36 ms. + **`draw`'s max must sit + at a frame near 0** — that is the warm-up (glyph atlas, first vertex-buffer growth); a + late `F` is a frame whose own geometry was expensive and worth tracing. `tick`'s max is + the publish stall: `publish_event()` builds the snapshot's pyramids under `mutex()`, so a + latch at over 1 MS/s costs one ~40 ms tick, which must not produce `gaps`. Both these + lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything — + `tools/mock_udpstreamer.py --rate 1300000 --batch 2000` — since at the default 10 kS/s a + whole window fits in the raw path and the publish is too short to lose a race. (Do not + raise `--batch` to chase the rate: a batch of 20000 exceeds the datagram limit and the mock + dies with `OSError: [Errno 90] Message too long`.) +- `publish cost: N events, A/B ms (mean/worst, blocks the UI); M pauses, C/D ms (mean/worst, + blocks the producer)` — what *freezing a view* costs, and the line that attributes the stall + `frame cost:`'s `tick` max can only hint at. The two publishes are counted apart because they + stall **opposite threads**, and nothing else in the dump distinguishes them: + `publish_event()` runs on the producer with `mutex()` already held, so its cost lands on the UI + as a long tick; `publish_pause_view()` runs on the UI thread and takes the lock, so its cost + lands on the producer and can only surface as `gaps`. Read the event figure against `tick`'s max + and the pause figure against `gaps`. Both are **wide**: on the fast mock the event publish has + read **69, 89, 92 and 115 ms** and the pause publish **22 and 86 ms** across runs, so treat the + line as an order of magnitude and not a figure to compare a change against at the millisecond. + The event reading matters anyway because it is far above the ~40 ms the `frame cost:` entry above + quotes — treat that 40 as the old reading and this line as the authority. + It is also **the number to judge a second live ring against**, which is the one design question + it exists to answer: freezing a whole `BufferStore` copies nothing and inherits the pyramid a + `ChannelBuffer` already maintains incrementally, so both `worst` figures would go to **0** — at + the price of a second budget's worth of memory (README records that one live ring is why + `--buffer-mb 200` really is 200 MB) and a second per-sample write on the producer. What the line + says about that trade is that the stall is real but **costs no packets**: an A/B of the same run + with and without a pause read `gaps=0` both ways, the paused one with an 86 ms publish, so a + second ring would buy a smoother frame at a latch rather than recovered data. (An earlier run + reported `gaps=73` with a pause against 0 without, which is what made the stall worth measuring; + it has not reproduced and did not scale with the publish length — 79 gaps with the pyramid build + removed against 73 with it — so it was incidental load on one run. Do not quote it as a cost.) +- `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%, worst + drawn-vs-quoted D px over R raw paints` plus a + `[ink] paint N mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=.. + ink=[..] cover=..% x0=.. x_at=[..] bounds=[..] rel=[..] dx=.. drops=[..] + back=..` line per anomalous frame (stderr) — **`collapsed` and `staircase` must both be 0**, + and **`drawn-vs-quoted` must stay far under a pixel** (0.0000 is what a clean run reads). + This is the only check that can see a frame whose *waveform* vanished: `staged_segments` + counts geometry including the grid and the instruments, and `lod_traces` counts a trace whose + points are then collapsed, so both read healthy through the flicker. The probe runs on every + painted frame that drew a trace and recomputes what the renderer did, through the same calls + — `neighbours()` for the index window, the 64-spp gate for the level, `gather()`, then the + increasing-suffix filter — and reports where the ink actually landed against the axis. + `collapsed` is fewer than two distinct positions left (nothing to draw at all), `staircase` + is the filter dropping over three quarters of the points, `short` is ink reaching under a + quarter of the axis (reported only once the data spans the window, since a filling ring is + *meant* to leave its left side empty). `drawn-vs-quoted` is the other reading, and it is the + one that names a *defect of position* rather than of presence: at a raw level a gathered pair + **is** sample `first + k`, so the line differences where a sample is drawn + (`axis.min + gather[2k]`, the renderer's own reconstruction) against the x a readout quotes + for it (`x_at()`, exact f64) and reports the worst of them in pixels. That is the + gather-origin invariant in [invariants-rendering.md](invariants-rendering.md) read from the + screen's side; it was over a hundred pixels at a + deep zoom before the origin became the caller's. It is measured on the raw levels only — above + them a gathered point is a bucket's envelope and has no single sample to be off from — so `R` + is legitimately below `N`. **`back` ≈ one ring span is the signature** of the torn `x0()` read + (see the cached-first-stamp invariant); so is an `ink=` range disjoint from `axis=`, or an + `idx=` covering the whole ring with `cover` far over 100%. There was a `kept_fixed_x0` field + beside `kept` — the same filter with `x0()` hoisted out of the loop, since the renderer used to + re-read `x0()` per point and a ring-backed one advances with the producer. The origin is a + local of the renderer's now, so that failure mode is structurally impossible and the field is + gone; the `[ink]` walk and the renderer still agree because both add `axis.min`. It is a race, + so it needs load and a reason for long frames — + `tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --clock-jitter 0.4` with the + measurements *and* cursors panes up: + `UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MEASURES_MS=2000 UDPSCOPE_PROBE_CURSORS_MS=3500 + UDPSCOPE_EXIT_AFTER_MS=35000`, which reported three staircase frames in each of three runs + while the defect was live and none after. `frame path:`'s own numbers move with it (`segments` + max 156078 → 19681), since a refused `neighbours()` gathers the whole ring. +- `traceless paints: N (M after the first bind, last at paint P)` and `worst side/pane min + width: W px`, plus `[gen] frame F generation A -> B (bound=.. empty=..)` and `[width-probe] + ...` lines (stderr) — three cheap per-frame counters that came out of the flicker hunt and + each guard a documented invariant from the side the existing lines cannot see. **`M` must be + only the frames in a state that draws nothing**: a traceless paint is legitimate in + `DisplayMode::Waiting`, which is why the line carries `mode=` — `UDPSCOPE_EXIT_AFTER_MS` arms + the trigger before it dumps, so a tail of them at the end of a run is expected. `[gen]` names + each buffer rebuild as it happens with the bind state around it: a rebuild drops every ring's + history, so the frames after one legitimately draw an empty plot, and `blank view frames` + cannot see that (it asks whether the ring holds samples the view has not got, and a rebuilt + ring holds none either) — so an unexplained `[gen]` burst is a structural change firing when + nothing structural happened. `worst side/pane min width` is the "layout must not depend on + content" invariant read from the **demand** side: a `GtkLabel`'s minimum width is its text and + a minimum wider than the screen is honoured by growing the toplevel, which hands the plot an + allocation wider than the window; `axes.w` sees that a frame later and cannot name the cause, + so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the + per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget + tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read. +- `[help] pages=14 parsed=14 shortest='Choosing a source' 1563 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape. +- `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row, + then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B` + and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr, + `UDPSCOPE_PROBE_MENUS_MS`) — the two popovers the toolbar and the status bar open, measured with + the side panel hidden. **`rows_bad` must be 0 and `fits` must be 1.** What it asserts is what a + screenshot can only show an eye: every row's label starts at the *same* x (`label_x=26` for all + seven — a menu whose checkable rows indent their text and whose others do not is exactly what + "the text is not left-aligned" looks like), every shortcut ends at the row's own right edge + (`accel_right == W`), no row is over `kMenuRowMaxPx` (30) tall — Adwaita's button minimum is 34, + and seven of those is what made the popover read as a stack of dialogs until `.udpscope-menu` + trimmed it — and the rows arrive in the order its `kExpected` names. `[menu] status` is the ⓘ + bubble: **`pos` must read `top`**, which is set *after* `gtk_menu_button_set_popover()` because a + `GtkMenuButton` states a position of its own and the last word wins, and `button_top` must exceed + the bubble's height, because "below the button" is off the window's bottom edge — the bubble's + top edge stayed at the button with its body clipped away, which reads as an arrow disconnected + from its body. Run it **alone**: another probe that opens a window dismisses the popover, and + every row then reports `h=0`. It turns autohide off for its own measurement — a popover dismisses + itself when it loses the grab, and `xvfb-run` has no window manager to hand it one — and restores + it afterwards. +- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `: : ->` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values). +- `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires). +- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`, `disarmed` — a word added to it must be **no wider than the widest already there**, which is what let `connecting` join at ten characters beside `event held`; the dead-port glance reads `max_glance_chars=51` against a live run's 54), the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the two fields a *state* adds are both elapsed times — trigger mode's `last event … ago` and a pause's `for ` — which step units rather than growing digits (`format_elapsed()`). A **paused** glance is not in the dump at all: it is read by `[pause-probe]`'s `glance=` field, since the dump's own line is taken live. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it. diff --git a/docs/claude/verification.md b/docs/claude/verification.md new file mode 100644 index 0000000..2687e58 --- /dev/null +++ b/docs/claude/verification.md @@ -0,0 +1,169 @@ +# Verification + +How to run the headless self-check, the two baselines every run is read against, and the environment variables that schedule each probe. + +Part of [CLAUDE.md](../../CLAUDE.md). + +There is no unit test suite. The only automated check is the app's own headless +self-check, driven by env vars and run under a display (`xvfb-run`, llvmpipe) against +a live UDPStreamer source: + +```sh +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8000 +``` + +Two baselines every run is read against. A clean build emits **five** warnings and no more: two +`-Wmissing-field-initializers` on `MathNode::source`, one unused `output_built`, and +`-Woverlength-strings` on **both** generated GResource `.c` files — the help pages' and the +icons', whose blob crossed C99's 4095-byte string-literal limit when the two app icons joined the +four action ones (a note about a generated file, which GCC itself supports). And an exit-dump run +emits **no** `Gtk-CRITICAL` at all. A sixth warning, or any critical, is a finding. + +That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`, +from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump +runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only +the lucky reading of it. A run that had built and destroyed other windows first — +`UDPSCOPE_PROBE_HELP_MS`, which opens three — has had that memory reused, and the same two calls +then **SIGSEGV in the middle of the dump**, which reads as a truncated dump rather than as a +crash in a debug line. Every size the dump prints is therefore captured on the *tick*, while the +window is alive (`App::dbg_*`), and **no `gtk_widget_*` call may appear in the dump** — the +`panel controls:` line was already written that way for the same reason, and that comment is the +precedent, not an exception. + +- `UDPSCOPE_EXIT_AFTER_MS` — run the trigger-on → go-live probe, close the window, + then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted + frames, trace bindings, panel sizes, status line in both states). +- `UDPSCOPE_ENABLE_ALL_MS`, `UDPSCOPE_ARM_TRIGGER_MS`, `UDPSCOPE_PROBE_DISARM_MS`, + `UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only + *starts* its sequence: it switches the trigger on and then polls for a capture to open, + so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall. +- `UDPSCOPE_PROBE_FIELDS_MS` — change the trigger panel's edge and Normal/Single + dropdowns. As much a crash test as a state check (see the GTK signal-signature invariant + below), so time it inside a capture: `[fields-probe] ... capturing=1` followed by no + SIGSEGV *is* the assertion. +- `UDPSCOPE_PROBE_WINDOW_MS` — drive the window control every way a user can: type a + number, change the unit under it, step a decade either way. +- `UDPSCOPE_PROBE_LAYOUT_MS` — drive the layout editor: switch it on, insert above the + first row, remove a row, append one at the bottom, switch it off. Its steps are 200 ms + apart on purpose — the buttons are placed from the rects the *last frame* was laid out + with, so a synchronous sequence would read every position one action stale. It clicks by + emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its + 250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed. +- `[measure-probe] N measurements on 'X'` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one + measurement of each statistical type (average, top, peak to peak, amplitude) on the first + *channel* and forces a recompute. It must name a **channel**, not `signals().front()`: the + first signal of a stream is its timestamp, which is not acquired, and naming it adds four + measurements that all read `--` *and* makes a performance run look free (each bails before + collecting anything -- that is how the first baseline of the stall below read 0.4 ms). + The values are in the exit dump as `measurements: =`; on the fast mock + (`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`) a unit sine must read + `peak to peak=2`, `amplitude=1`, `average=0` and **`top=0.636620`** -- 2/pi, the half-wave + mean of a sine, which is the check that the threshold types are not merely plausible. + `[measure-trace] view=[..] =... marks=N level:V/time:V...` is a repeating line + (250 ms) from the probe: the pane's numbers beside the *current* window and the marks actually + placed, which is the only way a stale result or a mark that should not be drawn shows up as a + number. It must never print `marks=0` while measurements with `show_on_plot` are on and the + data is there -- that was the rulers blinking off at a mode change. + `measure cost: N recomputes, X ms total, Y ms each, data D ms (P%), covered=C buckets=B + raw=R (F%), N ns/covered sample` follows it: **read it against a run with + `UDPSCOPE_PROBE_MEASURE_MS` unset**, because a frame mean cannot attribute a cost. The last + three fields are the assertion, and they are a *ratio*, not a time: `covered` is the samples + the window spans, `buckets` the LOD buckets the two walks answered from, and `raw` the samples + they still had to read. **`raw` must stay at a fraction of a percent of `covered`** on a clean + waveform over a window of millions -- 0.1% is what the pyramid-backed walks give (see the + measurement-cost invariant in + [invariants-measurements.md](invariants-measurements.md)). A `raw` near `covered` means the walks went back to reading + the window, which at 1 MS/s is 19.7 ms per recompute and a third of the run's frames. It is + *legitimately* higher for a window shorter than a bucket (64 samples) or a signal noisy + enough to cross a threshold everywhere, so read it on the fast mock + (`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), where a window is ~2 M samples. + `--noise 0.1` there reads **2.0%** at 1.96 ms a recompute against the clean 0.1% at 1.41 ms, and + that is the interferer's own cost rather than a regression -- the figure to compare a change + against is the **clean** one. + Note the **final** `measurements:` line and `[measure-trace]`'s last lines may read `--`: + `UDPSCOPE_EXIT_AFTER_MS` arms the trigger before it dumps, so the dump can land in `Waiting` + with nothing bound and the axis at its default. The race-free reading is an earlier + `[measure-trace]` line, which carries its own `view=`; those lines are also where the + level-mark fallback is visible (an invalid pass must still print its `level:` marks). + +- `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then + `[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L` + (stderr, `UDPSCOPE_PROBE_PAN_MS`) — **`held` must be 1**: a gesture must not move a window + that is pinned (trigger mode, or a paused view). The probe writes the panned range the way a + gesture does — on the *member* axis, which `Figure::layout()` adopts into the group — and + reports the axis the frame after it produced. Time it *inside* one pinned state: a pan during + a capture whose check lands after the capture has ended is re-framed by the mode change, not + by the pin, and reads `held=1` with the hold removed (that is how the first version of this + probe fooled me). The pause probe makes a stable pin, so run them together: + `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_PAN_MS=2200` puts the pan and its check inside + the pause. With `hold_pinned_window()` disabled it reads `held=0` and the axis sits at the + asked range. It then asks for a **zoom-in** (a window a quarter of what is on screen) and + reports `group=`/`member=` beside it: **`allowed` must be 1, with both equal to the ask** -- + that is the single-trigger-mode case, where the wheel has to keep working, and where reading + the wrong one of the two made a working clamp look broken. All three verdicts are read from the + *tick*, one frame after the ask: a `g_timeout` was the first version and it drifted past the + paused/resumed boundary under load. The probe then asks for a **zoom-out** ten times the current span and reports + the span the next frame produced: **`limited` must be 1**, i.e. `span <= window`. Run it with + a ring that holds *more* than the window (`--buffer-mb 64` past the window's own seconds) — + that is the state the old `max(retained, window_s_)` bound leaked in, and it showed up here as + `span=2.502550 window=2.000000 limited=0` against `span=2.000000` after the fix. + +- `[zoom-probe] /x axis=[..] span=.. n=N idx=[a..b) spp=.. level=L pts=P kept=K + back=.. ink=[..] x_at_first=.. gap_px=.. end_px=.. rect=[..]` (stderr, + `UDPSCOPE_PROBE_EVENTZOOM_MS`) — arms the trigger in **single** mode, polls for a **held + event** (a latch happens when the stream says so; a sweep on a timer has nothing to zoom + into), then zooms in eight doublings + twice: `ges/` writes the row's own axis the way a gesture does, `win/` drives the window + control (`apply_time_window`, which unpins and re-arms the live follow before the pin is + restated). Each step's reading is taken in the *tick*, after that frame's pin and clamp — a + `g_timeout` would report the ask. It recomputes what `Renderer::draw_traces` is handed, through + the same calls: `neighbours()` for `idx`, the 64-spp gate for `level`, then `gather()`. + **`gap_px` and `end_px` must stay within a pixel or two at every step** — they are how much of + the left and right margin no geometry reaches, and a `gap_px` that *doubles* per step is the + "zooming into a held event clips it more and more from the left" defect (374 px of a 716 px + axis by `ges/x128`), whose cause is a constant offset in data seconds (see the `neighbours()` + invariant in [invariants-rendering.md](invariants-rendering.md)). `kept`/`back` are the renderer's own increasing-suffix filter counted rather + than applied: `kept` far below `pts` is a staircase, and a non-zero `back` is a non-monotonic + gather. **Run it with `--clock-jitter`** (`tools/mock_udpstreamer.py --rate 1300000 --batch + 2000 --clock-jitter 0.4`): the ideal mock's stamps sit on a perfect grid, which is exactly + what hides this class of bug, and three runs against it reported `gap_px=0.0` throughout while + the defect was live. The jitter varies each *packet's* spacing rather than offsetting its + start, because the stream's clock has to stay strictly increasing. The `win/` sweep bottoms + out at `span=0.05` (`RowSet::set_time_window()`'s floor) and walks to the event's post side, + since `set_time_window()` frames the newest span before the pin clamps it — both are expected. +- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are + measured on a still picture. It now also asserts what a pause *is* + (`[pause-probe] paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..] + glance=[..]` on stderr): + it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display + state as well as an acquisition one and the view snapshot and axis pin live in the + handler. Across the three lines **`ring_produced` must grow** (the producer never + stops — it fills the live ring exactly as it does with the trigger off), **`event_size` + must be non-zero exactly while paused** (the plot is bound to the frozen snapshot, not + the ring) and **`frozen_x` must not move between the pause and the resume**; after the + resume `event_size` is 0 and the window is back on the newest data. Pausing used to stop + the producer, which is why the counts grew by nothing and the resume left a hole. + `frozen_x` is the *rebased* snapshot — the pause view's own stamps are relative to the freeze + instant now, so the range reads `[-window..0]`-ish rather than stream seconds, and a + `frozen_x` in the thousands is the rebase gone. + **`glance=` is the assertion for the paused status field**, and this is the only line that can + make it: the exit dump's `status bar:` is read live and its two `mode …` lines are the live and + triggered states. While paused it must read `paused · · · for `, with + the elapsed figure **growing between the two paused lines** and **absent on the resume line**. + It is **wall** time (`g_get_monotonic_time()`), not stream time, because the producer never + stops while paused and "how long have I been looking at this" is a question about the user, not + about the stream; it is written through `format_elapsed()` so the field steps to a coarser unit + instead of growing digits (see the layout rule). +- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default + `/tmp/udpscope-export`) — collect the export table and write it both ways, skipping + only the file chooser. + +**Run one self-check at a time.** Two concurrent `xvfb-run -a` instances end up on the +same display and the second gets no GL context — it dumps `layout over 0 frames` and +`valid=-1`, which looks like a pipeline failure and is not one. `tools/mock_udpstreamer.py` +also outlives its `--duration`, so kill it after each run or the leftovers pile up and +hold ports. + +The probes are the regression net — when changing UI or plot-host behaviour, extend +the probe/dump in `src/ui.cpp` rather than adding a separate harness.