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