Initial commit
This commit is contained in:
commit
8059b90de7
29 files changed
+7893
No files matched your search
@@ -0,0 +1,245 @@
|
||||
# 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_PAUSE_MS` — pause acquisition mid-run so layout numbers are
|
||||
measured on a still picture.
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
`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`.
|
||||
- **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.
|
||||
Reference in new issue
Block a user