Files
udpscope/CLAUDE.md
T

18 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Build and run

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:

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_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=... (stderr, UDPSCOPE_TRIG_TRACE_MS) — with trigger=on, display must read event(frozen) or nothing, never live, except for the single interval that contains the toggle itself — recognise it by latches=0 and the next probe reading nothing with the same frozen axis=. 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.
  • [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 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.

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_).

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.

In trigger mode RowSet::bind() binds each trace to the snapshot and to nothing else (the last event, or nothing before the first). Never the live ring, not even while the post window is filling — SPECS:37 says the view must not refresh until the next event, and trigger_pending() is informational only, for the status line's wording.

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 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.
  • 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 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.