Files
udpscope/CLAUDE.md
T

372 lines
25 KiB
Markdown

# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build and run
```sh
git submodule update --init --recursive # deps/plotter is a submodule
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build
./build/udpscope --server 127.0.0.1 --port 8000
./build/udpscope --server 10.0.0.5 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
```
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (1..4).
`deps/plotter` is built from source with only the GTK4 adapter enabled
(`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a
vendored C99 source pair compiled into a static lib.
`deps/plotter` is upstream's own repository and this app's figure support was added
there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the
common left gutter), and in the GTK4 adapter `PlotArea`'s **figure mode** —
`set_figure()`, one `input::Controller` per row, nearest-row event routing, a
figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over.
Changing those means committing in the submodule and moving its pin.
## Verification
There is no unit test suite. The only automated check is the app's own headless
self-check, driven by env vars and run under a display (`xvfb-run`, llvmpipe) against
a live UDPStreamer source:
```sh
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
./build/udpscope --server 127.0.0.1 --port 8000
```
- `UDPSCOPE_EXIT_AFTER_MS` — run the trigger-on → go-live probe, close the window,
then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted
frames, trace bindings, panel sizes, status line in both states).
- `UDPSCOPE_ENABLE_ALL_MS`, `UDPSCOPE_ARM_TRIGGER_MS`, `UDPSCOPE_PROBE_DISARM_MS`,
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only
*starts* its sequence: it switches the trigger on and then polls for a capture to open,
so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall.
- `UDPSCOPE_PROBE_FIELDS_MS` — change the trigger panel's edge and Normal/Single
dropdowns. As much a crash test as a state check (see the GTK signal-signature invariant
below), so time it inside a capture: `[fields-probe] ... capturing=1` followed by no
SIGSEGV *is* the assertion.
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
measured on a still picture.
- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default
`/tmp/udpscope-export`) — collect the export table and write it both ways, skipping
only the file chooser.
**Run one self-check at a time.** Two concurrent `xvfb-run -a` instances end up on the
same display and the second gets no GL context — it dumps `layout over 0 frames` and
`valid=-1`, which looks like a pipeline failure and is not one. `tools/mock_udpstreamer.py`
also outlives its `--duration`, so kill it after each run or the leftovers pile up and
hold ports.
Two lines of the self-check output are assertions in disguise:
- `layout over N frames: axes.x A..B, ...` — **`viewport.w` must collapse to a single
value and `axes.x`, `axes.w` and `y-gutter` must *settle*.** `note_y_gutter()` is
monotone (`plot_host.cpp`), its one-shot widest-label reservation can land a frame or
two after the first measured frame, and the figure serves its floor out of the label
strip (`y_width = max(measured, floor - y_title_w)`, `plot.cpp`) so the reservation
converges over two layouts rather than one. A `y-gutter 44..74` dragging
`axes.x 67..80`/`axes.w 546..559` with it is that settle, not a twitch — confirm it
with the `changes` line below. A range that keeps moving is the "twitching axis" bug:
the plot being re-measured every frame. **`x-desync` must read 0**: it counts frames
in which any row's x range differed from row 0's by more than a pixel of data, i.e.
frames the x link group did not reach. `x-ticks` is content, not layout, and
legitimately varies by one as a live
window slides over a round number. The companion `axes.w changes: N (last at frame F,
side panel up=S)` line separates the two readings: `0`, or a handful near the start,
is a settled layout; a count near the frame count is the twitch. Runs far longer than
the documented probes have shown one ~320 px step — the side panel's width — that
reverts; it is intermittent and unexplained, so read `changes` before calling a range
the twitch bug.
- `[trig-probe] ... trigger=on ... display=... epoch=...` (stderr,
`UDPSCOPE_TRIG_TRACE_MS`) — with `trigger=on`, `display` must read `event(frozen)`,
`live(capturing)` or `nothing`, **never plain `live`**, except for the single interval
that contains the toggle itself — recognise it by `latches=0` *and* the next probe
reading `nothing` or `live(capturing)` with a frozen `axis=`. Plain `live` with the
trigger on is the old "the plot keeps scrolling in trigger mode" defect; the named
`live(capturing)` is the feature. In that state `epoch=` must equal minus the capture's
edge and `axis=` must stay at `[-pre..+post]` across consecutive probes while
`data=`'s upper bound climbs towards `+post` — an axis that moves instead means the
capture is being treated as a live view (`clamp_to_data()` reaching a pinned window).
`epoch=` must be back to `0.0000` in every other state. Note the probe reads
`pending=` fresher than `display=` (which is last tick's decision), so a single
`pending=0 display=live(capturing)` line is one frame of sampling skew, not a defect.
And `snap=[a..b]` must equal `axis=[a..b]`, with `b` positive by roughly
the post-trigger share: that is the "post window is empty" defect's observable.
**`drawn=[a..b]` must equal `data=[a..b]`** in both modes: `data` is `x_at()` (what
the crosshair reads) and `drawn` is `x0() + gather()` (what the renderer maps), so a
series that breaks the "gather emits x relative to `x0()`" contract shows up here as
the two disagreeing by exactly `x0()` — a trace drawn a whole pre-trigger window left
of its axis, invisible once the view is narrower than the shift, while the crosshair
keeps reporting correct values.
- `[trig-probe/idx] n=.. bounds=[..] dx=.. dx_real=.. dx_ratio=R got=[a..b) want=[c..d)`
(stderr, same probe) — **`got` must contain `want`** (`a <= c` and `b >= d`), and
`dx_ratio` must stay within about a percent of `1.0`. `got` is the index window
`Renderer::draw_traces` gathers, recomputed here from its own `(x - x0()) / dx()`
*uniform grid* formula; `want` is the same window found by binary search over the real
time ring, which is what `x_at()`/`neighbours()` — and so the crosshair — use. A
`dx_ratio` above 1 scales every index up, `a` lands past the first visible sample, and
the samples in between sit in the buffer, readable by the crosshair, and are never
drawn: the live view's older part goes blank while its newest part looks fine. Also
read `axis=` in the companion line against the `window dropdown:` line in the dump —
a live axis narrower than the requested window is the same symptom from the other
cause (see the `clamp_to_data()` invariant below). Both need a *saturated* ring to
show up, so run them under `--buffer-mb 1`, which wraps in a few seconds.
- `trigger: edge at X ... want [-a..b] got [-c..d] n=N` (stderr) — `got` must equal
`want` and `N` must be exactly the window times the stream's real rate, plus one
(2 s at 10 kS/s is `n=20001`, not 19999). `got` shorter than `want` on the *left*
means the edge was accepted before the history reached back a full pre window (the
gate in `TriggerEngine::process()`); shorter on the *right* means the engine latched
early or the cut lost the newest samples. An `N` one or two short of the exact count
is the same class of bug, not rounding: `publish_event()` clamps the slice to every
value ring's `produced()`, so publishing before each channel of the latching sample
has been pushed silently drops it.
- `[disarm-probe] was_capturing=1 display=.. seq=A->B events=C->D snap=[a..b] n=N
axis=[..]` (stderr, `UDPSCOPE_PROBE_DISARM_MS`) — with `was_capturing=1`, **`display`
must read `event(frozen)`** and `snap`/`axis` must be `[-pre..e]` where `e` is how far
the post window had filled, with `n` that span times the stream's real rate plus one.
`display=nothing` (or a `snap` from the *previous* event) is the "disarming mid-capture
makes the event completely disappear" defect. **`seq` must advance by exactly one while
`events` does not move**: no event fired, so the partial publish deliberately leaves
`trigger_latches()` alone — which is also what keeps the `trigger events: N total, M at
the disarm` line in the dump meaning what it says.
- `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] stream=[..]` (stderr,
`UDPSCOPE_EXPORT_MS`) — **`time=` must equal `axis=`** and **`rows=` must be the
span times the stream's real rate, plus one** (2 s at 10 kS/s is 20001). The export
is the *visible* range at the *stored* rate, so a count far short of that is a table
cut from the LOD pyramid, and a `time=` narrower than `axis=` is a window searched on
the wrong clock (the live branch subtracts `display_epoch()` before searching the
ring). In `mode=event`, `stream=` must be the edge ± the window; in `mode=live` it
equals `time=`.
The one exception to both: an export taken **mid-capture** reports `mode=live` with
`time=[-pre..e]` where `e` is how far the post window has filled, and `stream=` offset
from it by the edge (so `stream - time` reads the edge exactly) — the export is what is
on screen, and during a capture that is a partly filled window. Pin the probe outside a
capture when checking the `rows=` count.
The two `bytes=` lines are the writers: the `.npy` is
`64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a
mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing
either writer.
- `badge frames: lit=L dim=D idle=I` — the activity badge's phase per frame. Over a run
that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it
blinks" means, and a badge stuck lit is indistinguishable from a working one in a
screenshot. One or two `idle` frames at the start are the frames before the socket
connected; a large `idle` is correct only when the run paused or disarmed (a
`UDPSCOPE_PROBE_DISARM_MS` run should show hundreds). The `mode ...` lines carry the
rest of the status line, so `rate` and `last event ... ago` are checked there.
The probes are the regression net — when changing UI or plot-host behaviour, extend
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
## Architecture
`SPECS.md` is the requirement document. `README.md` documents what was actually built,
including deliberate deviations from the spec and known limitations — read it before
concluding something is a bug.
### Threads
Exactly two: a producer thread in `Acquisition` owns `udps_client_t` and calls
`udps_client_poll()`; the GTK main thread owns every widget and GL context. They share
`ScopeModel` under `ScopeModel::mutex()`, taken once per DATA frame by the producer and
once per frame by the UI (`tick()` in `src/ui.cpp`) around `RowSet::bind()`/`acquire()`.
Samples themselves cross via `plot::data::RingBuffer` (single producer, single
consumer) and never through the lock. Cross-thread scalars are atomics on `ScopeModel`
(`trigger_enabled_`, `trigger_armed_`, `trigger_latches_`, `trigger_time_`,
`event_`, `generation_`). Two of them are a pair: the producer writes `capture_edge_`
before `trigger_pending_` and those two carry release/acquire, so a consumer that sees a
capture open sees which edge it belongs to.
### Data path
`ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one
value ring per channel, sized by `plan_partition()` to cover `--max-window` seconds
within the byte budget. The producer always writes it, whatever the trigger is doing —
that is what guarantees a full pre-trigger history on the *first* latch after arming.
The spec's second buffer (item 1) is the latched `EventSnapshot`, which is already an
immutable copy of the captured window; see README for why two live rings were dropped.
What a trace binds to is `ScopeModel::display_mode()`, one of four states, and
`RowSet::bind()` takes it as an argument rather than recomputing it:
- `Live` — no trigger: the live ring on the stream clock.
- `Capturing` — an edge has been accepted (so the pre window was full) and the post
window is still filling: the live ring, on the coming event's own clock
(`display_epoch() == -edge`), inside an axis already pinned to `[-pre, +post]`. The
post side fills in view and the marker sits at x = 0 throughout.
- `Held` — the frozen `EventSnapshot` of the last event.
- `Waiting` — armed, nothing captured yet: blank.
**Disarm during a capture publishes what the capture got to**, then disarms: the producer
cuts the window at the newest sample and that partial snapshot becomes the held event, so
the picture on screen at the moment of the Disarm stays on screen. `engine.disarm()` clears
`pending_`, which drops the display out of `Capturing`, and with nothing published the view
fell back to the *previous* event — or to blank on a first capture. No event fired, so
`trigger_latches()` is deliberately not bumped; `ScopeModel::event_seq()` counts published
snapshots instead, and the frame tick keys its event framing on *that*. Keep the two
distinct: the latch count is what the probes assert "how many events fired" with.
`Capturing` is a **deliberate departure from SPECS:37** ("data visualisation should not
be refreshed till next event"), asked for explicitly; that reading is what the old "never
the live ring mid-capture" rule enforced. What survives of the rule is that `Held` binds
the snapshot *only* — a held event is never quietly replaced by live data — and that the
*axis* does not move during a capture: it is the event's window from the edge onwards,
never a sliding live view.
`display_mode()` must be read **once per frame** and passed around (the tick does this,
and hands it to `bind()`). The producer can open or close a capture between any two
reads, and two reads in one frame put the bind, the epoch, the pin and the status line on
different clocks.
Layers to keep straight:
- `SignalInfo` — one signal as the CONFIG described it. The unit of enable/disable.
- `ChannelInfo` / `ChannelBuffer` — the unit of acquisition and plotting. A
`ChannelBuffer` is a `plot::data::Series` over a (value, time) ring pair.
- `SlotSeries` (`src/channel.hpp`) — a trace's stable series object, rebound each frame
to either a `ChannelBuffer` or an `EventSeries`.
- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to 4 rows, each a
borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots. The figure owns
everything the rows have to agree on: the x window (one x link group every row joins),
the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since
`Figure::link_x()` refuses a `Live` member), the left gutter and the crosshair.
- `ExportTable` (`src/export.hpp`) — the visible window as columns, collected from the
snapshot or the live rings and handed to the `.npy`/`.csv` writers. Always the stored
samples, never the LOD pyramid's buckets.
`src/ui.cpp` is the GTK4 window (toolbar, trigger panel, signal panel, plot box) plus
the `tick()` frame function that binds, updates the trigger state and rewrites the
status line. One `plot::gtk4::PlotArea` in figure mode draws the whole figure. It is by
far the largest file.
### Invariants worth knowing before editing
- **`plot::Plot` cannot remove or retarget a trace.** Hence fixed trace slots created
up front and rebinding via `SlotSeries`; a *name* change (legend text) is the one
thing that forces a rebuild. Cursors are parked outside the axis range instead of
removed.
- **A `Figure`'s row count is fixed at construction**, so `RowSet::rebuild_all()` builds
a *new* figure and the area is handed it (`PlotArea::set_figure()`); it costs no GL
state. That makes one path for "a row's traces changed" and "the row count changed",
and it is why `rebuild_all()` restates a pinned event window (`pinned_pre_`/
`pinned_post_`) instead of re-framing on the live edge — a drop during trigger mode
would otherwise throw the frozen view onto the stream, where nothing moves it off
again until the next latch. `PlotArea::create()` is handed `App::placeholder_plot`,
never a row: the area keeps a reference to the plot it was created with, and a rebuild
destroys every row.
- **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, or a calibration change. Calibration is applied at ingest, which is
why it re-partitions.
- **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.
`gather()` emits x relative to `x0()` because absolute epoch seconds do not survive
`f32`.
- **`gather()` emits x relative to `x0()` in *every* series here**, `EventSeries`
included, and the renderer adds `x0()` back. An event's stamps are small (edge
relative, `[-pre, +post]`), so emitting them absolute does not overflow anything and
does not look like a bug — it just draws the whole capture `x0()` = one pre-trigger
window to the left of the axis. That was the "half the plot is empty / the marker sits
at the end of the data / nothing is drawn but the crosshair reads values" defect: the
crosshair reads `x_at()`, which was right all along. The probe's `data=` vs `drawn=`
is the guard.
- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins
the published window to the value ring's base and both rings keep sliding afterwards —
their capacities are equal, so there is no slack — which means the oldest few samples
of a saturated window routinely point at time slots the producer has already
overwritten. `ChannelBuffer::time_of()` therefore clamps to the oldest *readable*
slot: a stamp of `0` would reach `x_bounds()`, and from there `dx()` and `x0()`, which
is the renderer's whole index mapping — one unreadable slot inflated `dx()` by the
ratio of elapsed stream time to the retained window and clipped the older part of
every live view. The library's `StreamSeries` is immune for a reason worth copying:
its x is `x0 + i*dx`, a function of the absolute index, so an overwritten slot costs a
*y* value, never the geometry. Clamping buys the same bargain here.
- **`clamp_to_data()` may not shrink the view below the requested window.** It runs
every frame and cannot distinguish "the user zoomed out past the data" from "the ring
has only just started filling", so it clamps against `max(retained, window_s_)`: a
partly filled window is left empty on its left, which is what a scope that just
started should look like. 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.
Its pan clamping is likewise gated on the data being wider than the view, or the empty
left-hand side drags the live edge off the right-hand edge.
- **Layout must not depend on content.** The y gutter is reserved at the widest label
`TickFormat::Si` can produce and only ever grows; the status label is ellipsized so
its text cannot drive the paned divider. Both exist to stop per-frame relayout.
The same rule is why the activity badge is one glyph in three colours rather than
three glyphs, why the `rate` field collapses to one figure or a range instead of a
per-channel list, and why `format_elapsed()` steps to a coarser unit instead of
letting the counter grow: each is a status field whose *width* would otherwise depend
on the stream, and the status row is directly above the plot.
The reservation goes on the **figure** (`Figure::set_min_y_gutter()`, added to the
plotter for this), not on the rows: `Figure::layout()` clears every row's
`set_min_y_gutter()` to measure what its own labels need, so a floor written onto the
rows survives exactly until the next layout.
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
signal nobody acquires can never fire, so selecting/arming enables it.
- **The trigger's event window is separate from the display window**, and
`pretrigger_pct` is a share of the *event* span, not of the ring.
- **The event window is counted in stream seconds end to end; nothing converts it to
a sample count.** `TriggerEngine` latches once the stamps it is fed have advanced
past the post window, and `ScopeModel::publish_event()` cuts the snapshot by binary
search over the time ring. There used to be a measured samples-per-second estimate
in `Acquisition` doing that conversion, and it is precisely why the post window came
out empty: any stream whose time signal advances in large steps drove it to its
1 S/s floor, the count came out as 1, and the engine latched on the edge sample
itself. **Do not reintroduce a rate estimate on this path.**
- **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.
- **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 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::<prop>` 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.
- **The header bar's buttons are icon-only**, so headless probes must reach them
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
`find_labelled_button()`. The Arm/Disarm button in the trigger panel still has text.
- Every source file carries an SPDX MIT header; match it in new files.