574 lines
42 KiB
Markdown
574 lines
42 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` (the row
|
||
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards).
|
||
|
||
`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.
|
||
|
||
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`. All three carry
|
||
upstream tests — `the_atlas_is_filtered_not_point_sampled`,
|
||
`grid_hairlines_land_on_whole_pixels` and `the_tick_at_the_origin_reads_zero`; 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
|
||
```
|
||
|
||
- `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.
|
||
- `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 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] 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.
|
||
- `[window-probe] <action> 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.
|
||
- `[layout-probe] <action> rows=N buttons=B 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. **`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.
|
||
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.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).
|
||
- `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.
|
||
- `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 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 tens of 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. **`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 20000` — 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.
|
||
|
||
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 `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.
|
||
|
||
### 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 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 `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.
|
||
- **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.
|
||
- **`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 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.
|
||
- **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 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.
|
||
- **`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.
|
||
- **`apply_time_window()` is the only place the display window changes.** It clamps to
|
||
`window_limit()` and moves `RowSet::set_time_window()` and `ScopeModel::set_event_span()`
|
||
together, because the trigger's pre-trigger share is a share of *this* window. Paths that
|
||
change the span for their own reasons (Fit, an event pin) call `sync_window_display()`
|
||
instead, which only restates the control and never moves the plot — calling `apply_` there
|
||
would clamp a legitimately short capture up to the requested window.
|
||
- **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.
|