20 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 4003084648 feat: a signal's chip is clickable, so a trace's colour is the user's to choose
Clicking the coloured chip in the signal list opens a GtkColorDialog and the
colour it returns overrides that signal's palette entry. Held by name, like
every other identity here, and persisted as a session `[[color]]` table.

RowSet::signal_color() becomes the one resolver: install() builds the trace
through it and all three chip sites read it, because a chip exists to say which
trace a row is about and an override is exactly the state in which two routes to
a colour would disagree. A grey chip names no trace and so is not clickable, and
the way back is `Default colour` in the signal's menu -- a setting with no undo
is a trap.

The gesture sits on the existing label rather than in a GtkButton: padding on
this row is what made the signal list twice as tall once. It claims its sequence
so a click on the chip cannot become the start of a row drag.

UDPSCOPE_PROBE_COLOR_MS reads the result out of plot::Plot::trace() and out of
the chip's own markup, with an override in no palette slot, so neither leg can
pass by asking the resolver what it already said.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-07 08:22:32 +02:00
Martino Ferrari 8bc1fe8b5c fix: a repaired source is reconciled above the validation line, not below it
`math_dialog_sync_form()` wrote the label and then moved a node whose source
names an unacquired channel -- a mutation that changes what
`math_dialog_problem()` reports. With the sync re-entry now guarded, the nested
call that used to repair the label is suppressed, so the line kept describing a
source that had just been fixed and left `Apply` insensitive until the next
mutation. The reconcile moves above the write and the dropdown reuses its list.
2026-10-07 07:17:46 +02:00
Martino Ferrari f238e717b2 fix: a wire is picked by distance and a refusal is spoken, so the editor answers
A review of the whole node editor. The hit test measured the distance to the
24 curve samples rather than to the segments between them, so a wire spanning
several rows was unpickable between two samples -- the probe now places its
boxes 280 units apart and prints the nearest sample's own 8.4 against a 3.0
tolerance, which the point test would have missed. A refused connection said
nothing: the message is now carried in `MathDialog::refusal` and shown once by
the single writer of the label, and a self-wire is refused through the real
drag-end path instead of only through `math_connect()`.

Two defects were found only by the new assertions. `math_dialog_sync()`
re-entered itself -- `gtk_drop_down_set_selected()` emits `notify::selected`
for a programmatic set too, and the handler answers by syncing again, which
consumed the refusal before it could be drawn -- so the body is now guarded
one level deep. And a deleted node left `selected_wire` naming an index that
afterwards belongs to a different wire, so `Delete` cleared an input on the
wrong node.

A node is placed in the first free grid cell rather than at the end of the
list's own cell, so a node added after a delete no longer lands on top of a
survivor; the label ellipsizes with a tooltip rather than wrapping, because a
wrapped line makes the dialog's height depend on the text in it.
2026-10-07 07:09:49 +02:00
Martino Ferrari 300b33159e docs: the node editor's gestures, its new probe assertions and its invariants
Each invariant records what went wrong rather than only the rule: a port hit
that lost its node, a hit test at the identity view that asserts nothing, a
`Delete` that must not fall through to the selected node, and a column pitch
too narrow for a wire's own tread. The warning baseline drops to four.
2026-10-07 02:56:36 +02:00
Martino Ferrari 8918494acb feat: a tidy button lays the graph out by depth, so no two boxes overlap
A single forward pass -- the list is already in evaluation order, so every
input's depth is in hand by the time the reader is reached -- then a fit. The
column pitch is `kNodeW + 48` rather than the add-node grid's 160, which left
28 units for a wire's 40-unit tread and made the bezier double back.
2026-10-07 02:44:50 +02:00
Martino Ferrari 5a69b2147b feat: the op is picked when a node is added, not changed afterwards
\`add node\` made a gain and nothing else, so every other op was two steps. It
is a menu of the twelve now, each carrying its index through
\`math_op_index()\`'s space rather than a cast of the enum -- which is also
what \`math_dialog_sync()\` now uses to drive the op dropdown, since that
order is the dialog's and not the declaration's.
2026-10-07 02:39:26 +02:00
Martino Ferrari bafa391ad8 feat: the editor says why a graph cannot be saved instead of refusing on Apply
One `math_dialog_problem()` drives both the line and Apply's sensitivity, so
they cannot disagree, and the two modal alerts are gone -- there was nothing
in either message a user could not have been told while still building the
graph. Written above `math_dialog_sync()`'s early return, since an
out-of-range selection is exactly when a stale line misleads most.

The source dropdown now lists the acquired channels, which is what Apply
validates against: listing every signal let a user pick a source that could
never be made valid, and put the stream's timestamp signal first.

Deleted `const int output_built = ed->output` from `math_probe()` -- it was
dead code, redundant with the `output_after_add` already printed on the same
line, and its removal is what drops the warning baseline from five to four.
The probe's `bad_text` pointer is copied to `std::string` before the second
`math_dialog_sync()` overwrites the label's internal storage.
2026-10-07 02:28:32 +02:00
Martino Ferrari 5ca762031d feat: a backwards wire re-sorts the graph instead of being refused
The evaluator walks the node list in order, which used to make the order the
user's problem -- an input of an earlier node was not even drawn as
connectable. `math_connect()` runs Kahn's algorithm over the trial edge set,
with ties broken by the current index so a graph already in order is not
permuted, and remaps every index the dialog holds in one block. A self wire
and a cycle are refused with a message and change nothing.
2026-10-07 02:14:10 +02:00
Martino Ferrari 4c726adb54 feat: a wire can be picked and deleted, so a graph can be rewired
\`math_wire_at()\` samples the same cubic the draw lays down -- 24 segments,
well under a pixel of chord error at the scales this canvas allows -- and
\`Delete\` removes the picked one. It deliberately does not fall through to
deleting the selected node: a key whose target depends on an invisible
selection is how a graph gets destroyed by accident.
2026-10-07 02:07:08 +02:00
Martino Ferrari 8dd6b3db12 fix: a drag's release point is the press plus the offset, so a detached wire can be dropped
Record the press in canvas coordinates at drag-begin; use press+total-offset
in both the Wire update and drag-end, replacing the output-port round-trip
that placed the release a column away on a detach. Run the gesture probes at a
non-identity view (scale=2, x=50, y=20) so a missing math_to_graph call causes
failures rather than passing silently. Add a fifth probe: detach and release in
place must reconnect. Update the stale comment that described the deleted
two-click protocol.
2026-10-07 01:59:27 +02:00
Martino FerrariandClaude Opus 4.6 a7702acb63 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>
2026-10-07 01:49:28 +02:00
Martino Ferrari 0b5ace9585 feat: a wire is dragged from port to port, and a wired input is dragged off
One state machine decided at `drag-begin` -- a port starts a wire, a box
moves it, the background pans -- so the two-click protocol and its armed-port
highlight are gone. A drag from an input empties it immediately, which makes
a release over nothing the detach gesture; `Escape` puts it back.
2026-10-07 00:03:42 +02:00
Martino FerrariandClaude Opus 4.6 9f589d5525 fix: the zoom probe calls math_zoom_at() directly, so it measures what it gave
The old assertion called math_canvas_scrolled(nullptr, ...) which resolves the
anchor itself from the event controller — with no controller it fell back to the
canvas centre (or a hardcoded 420x300 size when the widget was not yet allocated).
The probe then measured whether the graph point under (180, 140) was kept. Those
are two different points, so the difference printed was noise.

Extract math_zoom_at(d, px, py, dy) from the handler body: it takes an explicit
canvas anchor and guarantees the graph point under it is still under it after the
zoom. math_canvas_scrolled() resolves the anchor (from the event, or the widget's
own centre with no magic fallback dimensions) and delegates to it. The probe calls
math_zoom_at() with its chosen anchor directly, so dx and dy are an assertion on
the code rather than on the difference between two unrelated points.

Broken-anchor reading: dx=-180.0000 dy=0.0000. Fixed reading: dx=0.0000 dy=0.0000.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 23:52:22 +02:00
Martino FerrariandClaude Opus 4.6 b8d178eec6 feat: the node canvas zooms about the pointer and pans, so a graph can be read
One view transform applied to the cairo context, with `math_to_graph()` as
the inverse that every hit test runs -- a tolerance is a screen distance,
so it is divided by the scale rather than left in graph units. A wheel
zooms about the pointer, a background drag pans (next commit), `f` fits and
Ctrl+0 resets. The drawing area is made focusable, without which a key
controller on it never fires.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 23:47:11 +02:00
Martino FerrariandClaude Opus 4.6 6797b31d2e fix: an armed output highlights every other node's inputs, so the second click is visible
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 23:33:03 +02:00
Martino FerrariandClaude Opus 4.6 84f310ee43 fix: a port hit names its own node, so the first click of a wire lands
\`math_port_at()\` returned a sentinel for an output hit and the caller
recovered the node with a box test -- but the output square straddles the
box's right edge, so every pixel past it disarmed the gesture silently.
That is the whole of "edges cannot be added".

Cancel also closed with \`gtk_window_destroy()\`, which emits no
\`close-request\`, so the dialog pointer was left dangling and no channel
could be edited twice.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 23:28:06 +02:00
Martino FerrariandClaude Opus 4.6 0866b744ad docs: a plan for the node editor, so each defect ships with the probe that saw it
Nine tasks, each ending in a commit: the silent disarm in the port hit test,
the view transform, the drag-based wiring, wire deletion, the topological
re-sort that makes a backwards wire legal, the live validation line, the op
picker, tidy, and the docs with the full verification run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 23:24:15 +02:00
Martino FerrariandClaude Opus 4.6 96ee35da25 docs: a design for a node editor whose wires can be drawn, deleted and navigated
The reported "cannot add edges" has a specific cause: the output port's hit
radius reaches past the box it belongs to, and the press handler re-derives the
owning node with a box test, so clicking the visible dot's outer half sets
pending_out to -1 and silently disarms. Edge deletion has no code path at all.

The design replaces the two-click protocol with a drag gesture, makes the list
order the editor's own invariant via a topological re-sort rather than a rule the
user must obey, adds a view transform with every hit test routed through its
inverse, and trades the modal alert on Apply for a live validation line. It also
records that the Cancel button still calls gtk_window_destroy -- the dangling
dialog pointer CLAUDE.md documents as fixed was only fixed on the Apply path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 22:26:31 +02:00
Martino FerrariandClaude Opus 4.6 f307d27e41 docs: a pause publish costs no packets, and the look is checked with frame path
An A/B of the same fast-mock run with and without a pause reads gaps=0 both
ways, the paused one having published in 86 ms -- so the gaps=73 recorded
yesterday as "a pause costs packet gaps, unexplained" was incidental load on
one run. It never scaled with the publish length and has not reproduced.

Record the publish figures as the range they actually span across runs
(event 69-115 ms, pause 22-86 ms) rather than as single readings, and name
`frame path:` across a pause as the check that the paused trace is drawn like
a live one -- raw=1 lod=555 against raw=0 lod=373, where the defect read
raw=21 lod=118.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 20:45:13 +02:00
Martino FerrariandClaude Opus 4.6 d2c51f2448 fix: a paused view carries its own LOD pyramid, so it is drawn like a held event
publish_pause_view() filled stamps, values and y and left pyramids empty, so
level_for() descended to raw samples and a frozen pause was re-gathered from
every sample it held on every frame. It cost draw time (12.34 -> 18.18 ms mean)
and, because stage_trace_density() normalises each column against its own peak,
it also changed how the trace looked -- which is how it was noticed.

Both publishes are now timed and reported as `publish cost:`, counted apart
because they stall opposite threads: the event publish runs on the producer
under mutex() and blocks the UI, the pause publish runs on the UI thread and
blocks the producer. That is the number a second live ring has to be judged
against.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 20:36:27 +02:00
29 changed files with 6998 additions and 2072 deletions

No files matched your search

+45 -1905
View File
File diff suppressed because it is too large. Load diff
+77 -15
View File
@@ -250,8 +250,9 @@ A sample exactly *at* a threshold is still on neither side, so a signal that mer
does not count every sample as an event. does not count every sample as an event.
Each measurement's row carries **two badges**: its own colour (the same one its marks use on Each measurement's row carries **two badges**: its own colour (the same one its marks use on
the plot) and the signal's chip, in the palette colour every other view names that signal the plot) and the signal's chip, in the colour every other view names that signal with —
with. The dialog's fields and their wording follow the type — the split row is absent for through `RowSet::signal_color()`, so a signal the user re-coloured is re-coloured here too and
not left at the palette entry. The dialog's fields and their wording follow the type — the split row is absent for
`average`, `min` and `max`, the side row only for `pulse duration`, and the "show on plot" `average`, `min` and `max`, the side row only for `pulse duration`, and the "show on plot"
line says what would be drawn. line says what would be drawn.
@@ -459,7 +460,8 @@ ring per channel (N enabled signals -- see the limitation on multi-element signa
below). The spec's second buffer is the latched `EventSnapshot`, which is already below). The spec's second buffer is the latched `EventSnapshot`, which is already
an immutable copy of the captured window (see "Trigger" below); a second live ring an immutable copy of the captured window (see "Trigger" below); a second live ring
bought nothing and cost half the memory budget, so `--buffer-mb 200` really is bought nothing and cost half the memory budget, so `--buffer-mb 200` really is
200 MB. 200 MB. What it *would* buy is priced in the dump's `publish cost:` line — see
"There is one live buffer, not two" under the review findings.
`plan_partition()` sizes the rings to cover `--max-window` seconds and then grows `plan_partition()` sizes the rings to cover `--max-window` seconds and then grows
them with the rest of the budget. When a channel's declared sample rate is lower them with the rest of the budget. When a channel's declared sample rate is lower
than the fastest channel's, it is decimated by the integer ratio than the fastest channel's, it is decimated by the integer ratio
@@ -702,18 +704,25 @@ is what makes branching — the sum of a raw and a filtered channel — expressi
The editor is a compact node graph: each node is a box, its inputs are the squares on its left edge The editor is a compact node graph: each node is a box, its inputs are the squares on its left edge
and its output the square on its right. Click a box to select it and its parameters appear in the and its output the square on its right. Click a box to select it and its parameters appear in the
form beside the canvas; click an output square and then an input square to wire them; drag a box to form beside the canvas; **drag from an output square to an input square to wire them** (the old
move it. The graph is a *list* walked in order, so a node can only read the ones above it — a cycle two-click protocol is gone); drag a wired input square off its port to detach it; click a wire and
is not detected, it is unrepresentable, and an input square is only offered the nodes that qualify. press `Delete` to remove it; drag a box to move it. **Wheel zooms about the pointer, drag the
The node carrying the channel's value is the **last one** of a graph built top to bottom (as the background to pan, `f` to fit, `Ctrl+0` to reset.** *tidy*, a button in the editor dialog beside
orange dot on the canvas says, and settable with *set as output*): it defaulted to the first node — *fit*, lays out all nodes in topological columns. The op is chosen when the node is added — *add
the source — so every operation was computed and discarded, and a gain or a filter produced a node* in the same row of the dialog is a menu button whose popover lists every op — so there is no
channel identical to its input. Numeric fields are seeded with their own identity values too, since opless placeholder to build past. The graph is a *list* walked in order, so a node can
only read the ones above it; a wire drawn right to left re-sorts the list silently, so the graph
stays evaluable without requiring the user to add nodes in order. A cycle is unrepresentable:
`math_connect()` checks the trial edge set with Kahn's algorithm before writing anything. The node
carrying the channel's value is the **last one** of a graph built top to bottom (as the orange dot
on the canvas says, and settable with *set as output*): it defaulted to the first node — the source
— so every operation was computed and discarded, and a gain or a filter produced a channel identical
to its input. Numeric fields are seeded with their own identity values too, since
`gtk_spin_button_new_with_range()` leaves a spin button at its range's minimum: the offset started `gtk_spin_button_new_with_range()` leaves a spin button at its range's minimum: the offset started
at -1e6, which turned `x*2` into `x*2 - 2000000`. at -1e6, which turned `x*2` into `x*2 - 2000000`.
Wiring is checked before the channel is created, and the reason is shown rather than the dialog A live validation line beneath the form says why the graph cannot be saved; Apply is insensitive
closing on the work. while it is non-empty. The two modal alert boxes that used to appear are gone.
A `Source` node lists only the channels the scope is actually **acquiring**: naming a disabled A `Source` node lists only the channels the scope is actually **acquiring**: naming a disabled
signal would read as no data for ever, and the stream's timestamp signal has no samples at all. The signal would read as no data for ever, and the stream's timestamp signal has no samples at all. The
@@ -973,8 +982,38 @@ which hides exactly the spike a capture was triggered on, and since the level wa
it never even ran. On the same repro: **138,078 segments** (43× fewer) and a 66 ms worst it never even ran. On the same repro: **138,078 segments** (43× fewer) and a 66 ms worst
frame, with the steady-state draw around 2 ms. It costs about 1 byte per sample per frame, with the steady-state draw around 2 ms. It costs about 1 byte per sample per
channel (≈25 % on top of the snapshot's `f32` values) and one stall per latch, since the channel (≈25 % on top of the snapshot's `f32` values) and one stall per latch, since the
build runs under `ScopeModel::mutex()` — at 1.3 MS/s that is a ~40 ms tick, once, with no build runs under `ScopeModel::mutex()`. That stall is now measured rather than estimated —
packet gaps. the dump's `publish cost:` line read **115 ms** for one event publish at 1.3 MS/s, against
the ~40 ms this paragraph used to quote — and it produced no packet gaps.
**A paused view carries one too**, and that half was missing until it was found by its
*appearance*. `publish_pause_view()` filled `stamps`, `values` and `y` and left `pyramids`
empty, so a frozen pause was drawn from every raw sample it held, on every frame — the same
defect as above with the same cause, in the other snapshot. `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 going 12.34 → 18.18 ms and its worst frame 30.35 → 86.92 ms. But the reason it was
noticed is that the trace *looked* different: `Renderer::stage_trace_density()` grades each
pixel column against that column's own peak, from a `1/height` dwell deposit per segment, so
a column fed several hundred 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 away. The rule the invariant was written without: **a pause is not a
cheaper case than a capture — it freezes the same rings.**
It is not free, and the trade is one-off against per-frame. Building the pause pyramid took
that publish from 6.15 ms to 22.31 ms, and unlike the event publish this one runs on the
**UI** thread and so blocks the *producer* — against ~5 ms a frame of draw for as long as the
pause lasts, which over a 1.6 s pause at ~60 fps repays thirty times over. The producer
absorbs it: an A/B of the same fast-mock run with and without a pause read `gaps=0` both
ways, with the pause publish at 86 ms in the paused one. (One earlier run reported `gaps=73`
with a pause against 0 without, which is why the stall was worth measuring at all — but it
has not reproduced, it did not scale with the publish length, and an 86 ms publish costing
nothing is the stronger reading. Treat that 73 as incidental load on one run.)
The verification that the *look* is restored is the same A/B read through `frame path:`:
`raw=0 lod=373 columns=373 graded=373` without a pause against `raw=1 lod=555 columns=555
graded=555` with one — the same path, the same 24711-segment worst frame and a segment mean
within 5 % — where before the fix the paused run read `raw=21 lod=118`.
The self-check's two `frame cost:` / `frame path:` lines are the guard: they split a frame The self-check's two `frame cost:` / `frame path:` lines are the guard: they split a frame
into the tick, the plot's own draw and the whole GTK paint, and report the segment counts into the tick, the plot's own draw and the whole GTK paint, and report the segment counts
@@ -1165,6 +1204,20 @@ shape and the column of chips reads as a legend. Because `plot::Plot` fixes a tr
colour at `add_trace()`, the index is stored on the row (`Row::color_index`) and a colour at `add_trace()`, the index is stored on the row (`Row::color_index`) and a
colour change goes through a figure rebuild, which a drop already forces. colour change goes through a figure rebuild, which a drop already forces.
**Clicking the chip of a signal that is on a plot opens a colour chooser** (`GtkColorDialog`,
with the alpha slider off — a trace is stroked opaque, so a transparency setting would do
nothing), and the colour it returns *overrides* the palette entry for that signal. The chip of
a signal on no plot is not clickable: a grey chip names no trace, so there is nothing to
recolour. A chosen colour is held by signal **name** (`RowSet::set_signal_color()`), saved in
the session file's `[[color]]` tables and resolved — for the trace and for every chip — by the
one call `RowSet::signal_color()`, so a chip cannot claim a colour the trace is not drawn in.
The way back is **Default colour** in the signal's `⋯` menu, sensitive only while an override
exists; it is there rather than on the chip because a second gesture on a one-glyph target is
not something anybody would find. The chooser is opened by a `GtkGestureClick` on the existing
label rather than by wrapping it in a `GtkButton`, because a button's padding is exactly what
made this row twice as tall once (below), and the gesture claims its sequence so a click on the
chip cannot become the start of a row drag.
A `GtkSwitch` and two A `GtkSwitch` and two
always-visible buttons made a row about twice as tall (46 px against 26 px now), always-visible buttons made a row about twice as tall (46 px against 26 px now),
which is what turned a four-signal stream into a panel you have to scroll; Adwaita's which is what turned a four-signal stream into a panel you have to scroll; Adwaita's
@@ -1738,7 +1791,16 @@ now — they are listed so they are not mistaken for anything else:
latched `EventSnapshot` — an immutable copy of the captured window, which is latched `EventSnapshot` — an immutable copy of the captured window, which is
exactly what "the buffer the view is frozen on" needs to be. Two live rings would exactly what "the buffer the view is frozen on" needs to be. Two live rings would
also mean acquisition had to follow the trigger state, which is what left the first also mean acquisition had to follow the trigger state, which is what left the first
latch after arming with no pre-trigger history. latch after arming with no pre-trigger history. What a second ring would buy is the
publish: freezing a whole `BufferStore` copies nothing and inherits the pyramid the
producer already maintains, so both figures in the dump's `publish cost:` line would
go to zero. They are worth knowing before pricing that — at 1.3 MS/s an event publish
measured **69–115 ms of blocked UI tick** and a pause publish **22–86 ms of blocked
producer** — but they do not pay for a second budget's worth of memory, a second
per-sample write on the producer, and a single frozen slot where the app today holds
an event, a pause and an export's window at once. The *observable* cost is also
smaller than the stall: an A/B with and without a pause read `gaps=0` both ways, so
what a second ring would buy is a smoother frame at a latch, not recovered packets.
- **The buffer is allocated at the first CONFIG, not at initialisation.** - **The buffer is allocated at the first CONFIG, not at initialisation.**
The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-mb` is the The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-mb` is the
*budget*, and the power-of-two ring rounding means the final *budget*, and the power-of-two ring rounding means the final
+118
View File
@@ -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.
+102
View 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"
```
+112
View File
@@ -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.
+161
View File
@@ -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.
+235
View File
@@ -0,0 +1,235 @@
# 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), **drag from an output port to an input port to wire** (the old
two-click protocol is gone), drag a box to move it; **wheel zooms about the pointer, background
drag pans, `f` fits, `Ctrl+0` resets**.
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.
The probe also asserts the new editor mechanics. Each line carries `(want ...)` and the reading
taken from a real run is the reference:
* `[math-probe] hit: inside node=0 which=-1, past-edge node=0 which=-1 (want 0 -1 0 -1)` — the
output square straddles the box edge, so a hit test that recovers the node by a box test misses
every pixel past it. `past-edge node=-1` is the silent disarm that was "edges cannot be added".
* `[math-probe] cancel: dialog=cleared (want cleared)` — Cancel must `gtk_window_close()`, not
`gtk_window_destroy()`. The probe clicks the **button**, because the defect was in the button's
connection and a direct handler call cannot see it.
* `[math-probe] view: zoom scale=1.210 anchor kept dx=0.0000 dy=0.0000 (want 0.0000 0.0000)` —
a wheel zoom that does not keep the graph point under the pointer reads as the canvas sliding
away from the hand. Both deltas must be `0.0000`.
* `[math-probe] view: pan+zoom hit node=0 which=-1 (want 0 -1)` — hit tests go through
`math_to_graph()`. At the identity view a test that forgot the inverse passes anyway, so this
one is taken after a pan *and* a zoom.
* `[math-probe] view: fit scale=1.591 all_inside=1 (want 1)` and
`[math-probe] view: reset scale=1.000 offset=(0.0,0.0) (want 1.000 (0.0,0.0))`.
* `[math-probe] view: pan mode=3 dview=30.0 dnode=0.0 (want 3 30.0 0.0)` — a background drag
pans the view and moves no node.
* `[math-probe] wire: connect mode=2 in_a=0 (want 2 0)`,
`[math-probe] detach: emptied_at_begin=-1 in_a=-1 (want -1 -1)`,
`[math-probe] detach: escape restores in_a=0 (want 0)`.
* `[math-probe] wire: pick node=1 which=0 -> in_a=-1 off_sample=8.4 tol=3.0 (want 1 0 -1,
off_sample > tol)` — the pick is taken at `t = 0.52`, **between** samples 12 and 13 of the 24 the
curve is walked at, on a wire whose two boxes are several rows apart. `off_sample` is the measured
distance from the pick to the nearest sample point and `tol` the pick radius in the same units:
with `off_sample` the larger, a hit test that measured to the sample *points* rather than to the
*segments* between them would miss. Taken at `t = 0.5` — exactly sample 12 — the line passed for
both implementations and asserted nothing, the same mistake as a hit test taken at the identity
view.
* `[math-probe] wire: backwards ok=1 order=[0:-1,1:0,2:1] output=2 valid=1 why=[] (want 1 ... 1 [])` —
the re-sort. `valid=1` is the assertion; the `order=` string is the reading that explains it.
* `[math-probe] wire: self refused=1 why=[a node cannot read itself]` and
`[math-probe] wire: cycle refused=1 why=[that wire would make a loop]` — and **nothing is
written** on either. Both call `math_connect()` directly, so both assert its out-parameter and
neither says anything about the UI.
* `[math-probe] wire: self through drag-end node=1 problem=[a node cannot read itself] (want the
refusal text, not empty)` — the other half, and the one that matters: the refusal has to reach
the **validation line**. The UI path used to screen the self case out before `math_connect()` and
then drop its `why` on the floor, so the user drew a wire, nothing appeared and nothing was said
while the two lines above passed. No synthetic GTK gesture is reachable headlessly, so the probe
sets `drag_mode`/`wire_from`/`press_cx`/`press_cy` and calls `math_canvas_drag_end()` — which is
still the handler under test. An empty `problem=[]` here is also how the sync **re-entrancy** was
found: setting the op dropdown emits `notify::selected`, whose handler syncs, so the form's own
write re-entered `math_dialog_sync()` and the inner call rewrote the label the outer had just
written. `MathDialog::syncing` guards it.
* `[math-probe] validate: broken problem=[..] label=[..] apply=0; fixed problem=[] apply=1 (want non-empty 0; empty 1)`,
`[math-probe] validate: no-selection label=[..] (want non-empty)` — the label is written
**above** `math_dialog_sync()`'s early return; a stale message when there is no selection to
show is the failure mode.
* `[math-probe] validate: source list=6 acquired=6 signals=7 (want list==acquired)` — the
dropdown lists exactly the acquired channels, not all signals.
* `[math-probe] op pick: nodes 3->4 op_index=7 q_row=1 (want 4 7 1)` and
`[math-probe] op pick: index round trip=1 (want 1)`.
* `[math-probe] add node: after a delete cells=[(24,20) (24,78) (184,78) (184,20)] overlaps=0
(want 0)` — the boxes are put back on the grid, the middle one deleted and one added. The new box
takes cell 1, `(184,20)`, which the deletion freed; placing by `nodes.size()` would have put it on
`(184,78)`, which is occupied, and an overlapped box is an unclickable one. `overlaps` is the
assertion, `cells` the reading that explains it.
* `[math-probe] tidy: xs=[24,24,204,384] overlaps=0 (want 0)` — four nodes in three depth columns
(the delete above left two roots), so two share the first column and the x values repeat. Only
`overlaps=0` is the assertion.
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 while a source that is not acquired is in the graph the **live validation line is
non-empty and Apply is insensitive** (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. The **add-node grid** places in two columns spaced 160 px apart, rows 58,
and `math_add_node()` takes the **first free cell** rather than cell `nodes.size()`: placing by
the count reuses an occupied cell as soon as anything has been deleted — delete the middle of
three and add one and the new box lands exactly on an existing one. The free-cell search uses the
same overlap test the tidy probe asserts with, and falls through to cell `nodes.size()` after 64
cells rather than searching for ever. The **tidy layout** uses a column pitch of `kNodeW + 48` =
180, which is *wider* than that 160 and is what gives a wire's 40-unit horizontal tread its
clearance — at 160 the bezier's control points cross and the wire doubles back on itself. 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.
* **A port hit must name its own node.** The output square is drawn centred on `n.x + kNodeW`,
straddling the box's right edge, with a `±kPort` hit radius against a `±kPort*0.5` drawn
half-width — so recovering the node with `math_node_at()`, which matches only inside the box,
returns `-1` for every pixel past the edge. `math_port_at()` returns the node directly for
that reason.
* **Every hit test goes through `math_to_graph()`, and every tolerance is a screen distance
divided by `view_scale`.** A tolerance left in graph units makes a port unhittable zoomed out
and greedy zoomed in. **A probe that tests a hit at the identity view asserts nothing** — the
check has to be taken after a pan and a zoom.
* **A drag's mode is decided once, at `drag-begin`, and the precedence is port, box, background.**
Asking the box first makes the output square unreachable, which is the same geometry as the
bullet above.
* **A detach empties the input at `drag-begin`.** That is what makes a release over nothing the
detach gesture rather than a second verb, and it is why `Escape` has something to restore.
* **A backwards wire re-sorts the list; it is not refused.** The evaluator walks the nodes in
order, and until now that made the order the *user's* problem — an earlier node's input was not
even drawn as connectable. `math_connect()` runs Kahn's algorithm over the **trial** edge set,
so a cycle is found before anything is written, breaks ties by the current list index so a graph
already in order is not permuted, and remaps **`output`, `selected`, every `in_a`/`in_b`,
`drag_node`, `wire_from`, `wire_detach` and `selected_wire.node`** in one block. A missed index
silently renames an operation or re-points a wire. Its `edges_of` masks each input by the op's
**arity**: a node changed from binary to unary keeps a stale `in_b`, and counting it makes an
indegree that never reaches zero and so reports a loop that is not there.
* **The validation line is written above `math_dialog_sync()`'s early return.** An out-of-range
selection is exactly the state in which a stale message misleads most: the graph can still be
broken and there is no form to read instead.
* **The line is written from `math_dialog_sync()` and from nowhere else, it ellipsizes, and its
tooltip carries the full text.** Three consequences. (1) A *refusal* — "a node cannot read
itself", a cycle — is not a state `math_dialog_problem()` can describe, since it is a thing that
did not happen, so `drag-end` leaves it on `MathDialog::refusal`, which the sync shows in
preference to the graph's own problem and **consumes as it writes**: the message lasts until the
next sync, and every mutation syncs. `Apply`'s sensitivity still follows the graph alone, because
a refused wire wrote nothing. (2) The label **ellipsizes rather than wraps**, and the tooltip is
set beside the text so the two cannot disagree — layout must not depend on content, and this
label sits in a dialog whose canvas is `hexpand`. (3) The sync is **not re-entrant**
(`MathDialog::syncing`): writing the op dropdown emits `notify::selected`, whose handler syncs,
and the inner call rewrote the label the outer one had just written.
* **`math_op_index()`, never a cast.** `math_op_at()`'s order is the dialog's — what a dropdown
lists and what a session file stores — and `static_cast<guint>(op)` worked only because the
two happen to agree today. The session-file rule in this file already says an op is written as
its index through `math_op_at()`; this is the other direction of the same rule.
* **A `Delete` key removes the selected *wire* and never falls through to the selected node.**
A key whose target depends on an invisible selection state is how a graph gets destroyed by
accident; the `delete node` button stays the only way to remove a node.
- **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.
+110
View File
@@ -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`.
+117
View File
@@ -0,0 +1,117 @@
# 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.
- **`RowSet::signal_color()` is the only route to a trace's colour.** The user can override the
palette entry per signal (clicking its chip; `set_signal_color()` / `clear_signal_color()`,
keyed by **name**, because a palette index is positional and a CONFIG renumbers), and that
override is exactly the state in which two routes to a colour disagree. So `install()` builds
the trace through this call and all three chip sites — the signal panel, the measures pane and
the cursors pane — read it too; none of them may index `palette_color()` itself. An override
marks every row holding the signal dirty (`mark_signal_dirty()`, not `assign()`: what the slot
*holds* did not change) and the rebuild happens on the frame tick via `relayout_pending`,
because `relayout_areas()` is the one place that builds a figure and re-points the area at it.
`UDPSCOPE_PROBE_COLOR_MS` reads the result back out of `plot::Plot::trace()` and out of the
chip's own markup, since asking the resolver twice would assert nothing.
@@ -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.
+160
View File
@@ -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,52 @@
# 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 chosen trace colours** (`[[color]]`, `name` + `rgb = "#RRGGBB"` — only the
*overrides*, so a stream's new signals still take the positional palette and a saved session does
not freeze a colour that is meant to follow the signal list), **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()`.
+576
View File
@@ -0,0 +1,576 @@
# 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).
- `[color] '<name>' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `<span foreground="…">` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit.
- `[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.
+180
View File
@@ -0,0 +1,180 @@
# 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 **four** warnings and no more: two
`-Wmissing-field-initializers` on `MathNode::source` 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). The fifth warning that used to appear here was an unused
`output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the
`output_after_add` already printed on the same line — and that deletion is what dropped the baseline
from five to four. Nothing started reading it. And an exit-dump run emits **no** `Gtk-CRITICAL` at
all. A fifth warning, or any critical, is a finding.
An *incremental* build reads **two** of those four: the generated GResource `.c` files are not
recompiled when only `src/` changed, so their `-Woverlength-strings` do not reappear.
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_PROBE_COLOR_MS` — recolour one plotted signal and read the colour back out of the
trace and the chip, round-trip it through the session writer, then clear it. Schedule
`UDPSCOPE_ENABLE_ALL_MS` before it: with nothing bound it has no trace to recolour and asserts
nothing. Its four lines are in
[probe-readings.md](probe-readings.md).
- `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.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,452 @@
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
# The math node editor: wires you can draw and delete, and a canvas you can navigate
The math channel editor works as a *form* and fails as a *graph editor*. Three things were
reported — edges cannot be added, edges cannot be deleted, and the canvas cannot be zoomed or
panned — and all three are true. The first is a defect with a specific cause, the second has no
code path at all, and the third is an absence. Beyond them the editor asks the user to obey a
rule the data structure has (a node may only read nodes above it in the list) rather than
maintaining that rule itself, and it reports an invalid graph in a modal alert at the end
instead of on the canvas as it is being built.
This redesigns the canvas interaction. Nothing below changes `MathNode`, `MathChannel`, the
evaluator in `src/math_channel.cpp`, the session format, or the producer's path.
## Scope
In: the canvas view transform, the port and wire hit tests, the gesture state machine
(node drag / wire drag / background pan / wheel zoom), edge creation and deletion, a
topological re-sort that lets a wire be drawn in either direction, an op picker on the add
button, a live validation line that replaces the modal alert, a tidy/auto-layout pass, the
`Cancel` button's close path, and the `[math-probe]` assertions for all of it.
Out: parameters drawn on the node boxes (explicitly declined — the form beside the canvas is
where a node's values live). No new ops. No change to `math_channel_valid()`'s rules, to the
evaluator, to `MathRuntime`, or to how a derived channel is acquired. No node *library* panel,
no copy/paste, no undo.
## Why edges cannot be added today
`math_canvas_pressed` (`src/ui.cpp:3455`) handles an output-port hit like this:
```cpp
const int port = math_port_at(d, x, y, which);
if (port == -2) {
const int node = math_node_at(d, x, y);
d->pending_out = (node >= 0 && d->pending_out == node) ? -1 : node;
...
}
```
`math_port_at` throws away *which* node's output was hit — it returns the sentinel `-2` — and
the handler recovers the node with `math_node_at()`, which matches only **inside the box**. The
output square is drawn at `x = n.x + kNodeW`, i.e. straddling the right edge: half of it is
outside. Its hit radius is `kPort` (±10) while it is drawn at `±kPort * 0.5` (±5), so the
clickable region extends 10 px past the edge and `math_node_at()` answers `-1` for every pixel
of it beyond the first. `pending_out` is then set to `-1` — a *silent disarm*. Clicking the
visible dot's outer half, which is most of what the eye aims at, arms nothing, and the input
squares never light up. That is the whole of "it is not possible to add edges": the mechanism
exists and the first click of the two-click sequence usually fails.
It is worth fixing the cause and not only the symptom, because the same re-derivation would
break again under the view transform below.
Edge *deletion* needs no diagnosis: no path in the file ever writes `-1` back into `in_a` or
`in_b` except `on_math_node_deleted`, which is deleting the whole node.
## The view transform
`MathDialog` gains three doubles:
```cpp
double view_scale = 1.0; ///< canvas pixels per graph unit
double view_x = 0.0; ///< canvas pixels: where graph (0,0) is drawn
double view_y = 0.0;
```
Graph coordinates are what `MathNode::x`/`y` already are and what the session file stores, so a
view change is never persisted — reopening the editor shows the graph at 1:1, which is the
right default for a window whose size the user did not choose either.
Two conversions, and **every** hit test goes through the second:
```cpp
// canvas <- graph: applied once to the cairo context, never per item.
cairo_translate(cr, d->view_x, d->view_y);
cairo_scale(cr, d->view_scale, d->view_scale);
// graph <- canvas: the inverse, for a pointer position.
void math_to_graph(const MathDialog* d, double cx, double cy, double& gx, double& gy) {
gx = (cx - d->view_x) / d->view_scale;
gy = (cy - d->view_y) / d->view_scale;
}
```
Applying the transform to the context rather than to each item is what keeps the drawing code
unchanged: `math_canvas_draw` keeps every coordinate it has today, and the line widths and font
sizes scale with the graph, which is what a zoom should do to a diagram.
**A hit tolerance is a screen distance, not a graph distance.** A port is aimed at with a
pointer, so its clickable radius must be the same number of *canvas* pixels at every zoom —
which in graph units is `kPort / view_scale`. The same for the wire tolerance below. Getting
this backwards makes the ports unhittable when zoomed out, which is exactly when a user is
looking for them.
**Scale is clamped to `[0.25, 4.0]`.** Below a quarter the op names are unreadable and the
boxes are smaller than the pointer tolerance; above four a single node fills the canvas. The
clamp lives in one place, a `math_set_scale()` helper, because three callers reach it (wheel,
Fit, reset).
### Wheel zoom about the pointer
A `GtkEventControllerScroll` with `GTK_EVENT_CONTROLLER_SCROLL_VERTICAL`. The anchor is the
graph point under the pointer, and it must not move:
```cpp
double gx = 0.0, gy = 0.0;
math_to_graph(d, px, py, gx, gy); // before
d->view_scale = clamp(d->view_scale * (dy < 0 ? 1.1 : 1.0 / 1.1), 0.25, 4.0);
d->view_x = px - gx * d->view_scale; // restore the anchor
d->view_y = py - gy * d->view_scale;
```
The pointer position comes from the controller's own `GtkEventController` current event rather
than from a tracked motion position: a scroll event carries one, and tracking motion to supply
it is a second source of truth that can be stale.
No modifier is involved. The canvas has no other use for the wheel, so a bare wheel zoom is
unambiguous here — unlike the plot, where the wheel's modifiers are already spoken for.
### Pan, Fit, and reset
Dragging the empty background pans: `view_x = pan_x0 + dx`, from a `pan_x0` recorded at
`drag-begin`, for the same total-offset reason the node drag already documents at
`src/ui.cpp:3507`.
A **Fit** button frames every node:
```
bbox over all math_node_rect() (empty graph -> identity view)
scale = clamp(min(w_canvas / (bbox.w + 2*pad), h_canvas / (bbox.h + 2*pad)), 0.25, 4.0)
view_* = centre the scaled bbox in the canvas
```
`Ctrl+0` resets to `scale = 1`, `view_x = view_y = 0`. Both are one call to a shared
`math_fit_view()` / `math_reset_view()` so the button and the accelerator cannot drift apart.
## The port and wire hit tests
`math_port_at` is replaced by one that **names the node it found**:
```cpp
/// The port under a *graph* position. Returns the node index, with `which` set to
/// `kPortOut` (-1) for its output or 0/1 for an input; `-1` when no port is under it.
int math_port_at(const MathDialog* d, double gx, double gy, double tol, int& which);
```
Two changes beyond the signature. It scans **backward**, topmost first, so it agrees with
`math_node_at()` about which of two overlapping nodes owns a shared pixel — a disagreement
between the two searches is how a click lands on one node and a drag on another. And the
caller never re-derives the owner, which is the defect above made structurally impossible.
The hit radius stays *larger* than the drawn square: ±`kPort` canvas pixels around the port's
centre, against a square drawn at ±`kPort * 0.5` — a 20 px target for a 10 px mark. That is
deliberate and, once the owner is no longer re-derived, harmless: a generous radius is the
difference between a diagram that can be wired and one that cannot. The asymmetry is written
down in a comment at the hit test, because it is what the old defect fed on.
A wire is picked by distance to the curve it is drawn as. The curve is the same cubic the
renderer uses (`src/ui.cpp:3401`), so the pick and the ink cannot disagree:
```cpp
/// The wire under a graph position, as (consumer node, which input), or {-1, 0}.
/// The bezier is sampled at 24 points and the nearest segment measured; a curve 40 units of
/// tread wide over a node pitch of 160 is smooth at that sampling, and the tolerance is 6
/// canvas pixels, so a miss by a pixel is not a wrong edge deleted.
MathWireRef math_wire_at(const MathDialog* d, double gx, double gy, double tol);
```
Sampling rather than solving: the exact distance to a cubic is a fifth-degree root-find, and 24
samples of a curve whose control points are 40 units out is well inside a 6 px tolerance. The
sample count is a named constant so a future wider tread can raise it.
**Order of precedence on a press**: port, then wire, then node body, then background. A port
sits on a node's edge and a wire ends on a port, so each later test must only see what the
earlier ones refused.
## The gesture state machine
One `GtkGestureDrag` serves four gestures, decided entirely by what the press landed on. A
`GtkGestureClick` is kept for the selection-only cases, because a click is a drag of zero
length and GTK delivers both; `drag-begin` records the mode and `pressed` only ever *selects*.
```cpp
enum class MathDrag { None, Node, Wire, Pan };
MathDrag drag_mode = MathDrag::None;
int wire_from = -1; ///< Wire: the node whose output the live wire leaves
int wire_detach = -1; ///< Wire: the consumer whose input was detached to start it, or -1
int wire_detach_which = 0;
double wire_x = 0.0; ///< Wire: the pointer, in graph units, for the preview
double wire_y = 0.0;
double pan_x0 = 0.0;
double pan_y0 = 0.0;
MathWireRef selected_wire{-1, 0};
```
`drag-begin`, in precedence order:
- **An output port** → `Wire`, `wire_from = node`, `wire_detach = -1`.
- **A wired input port** → `Wire`, `wire_from = <the node it was wired from>`,
`wire_detach = node`, `wire_detach_which = which`, and the input is cleared *immediately*.
Dragging an existing wire off its input and dropping it elsewhere is one gesture, and
clearing on begin is what makes the preview show the truth: the wire the user is holding.
Dropping it on nothing therefore *detaches*, which is the agreed detach gesture, and needs
no separate case.
- **An unwired input port** → `None` (select the node; there is nothing to drag from an empty
input). Wiring is always output→input, so this is not a connect gesture.
- **A node body** → `Node`, exactly as today.
- **Anything else** → `Pan`.
`drag-update` moves the node, the live wire's endpoint, or the view, by mode.
`drag-end` resolves `Wire`:
- Released on an **input port** of a node that is not `wire_from` → `math_connect()`.
- Released on an **input port of `wire_from` itself** → refused, with "a node cannot read
itself" on the validation line. A self-edge is the one cycle a topological sort cannot
break, and refusing it by name reads better than "the graph has a cycle".
- Released anywhere else → nothing further. If the drag began by detaching, the detachment
stands.
`Escape` during a `Wire` drag cancels and **restores** a detached input: a cancel must undo the
clear-on-begin, or Escape would be a way to delete an edge by accident.
A `GtkEventControllerKey` on the canvas handles `Delete`/`BackSpace` (clear `selected_wire`),
`Escape` (cancel a drag, else clear the wire selection) and `Ctrl+0` (reset the view). It is
added to the canvas and the canvas is given `gtk_widget_set_focusable(TRUE)` plus focus on
press, because a drawing area is not focusable by default and a key controller on an unfocused
widget never fires. **The key controller must not swallow text**: the dialog's name and unit
entries are `GtkEditable`s, and the controller is on the *canvas*, so a key only reaches it
while the canvas has focus — the same reasoning as the app-level accelerators' bubble phase,
reached by a narrower route.
## The edge model
One function owns connection:
```cpp
/// Wire `from`'s output into `to`'s input `which`. Re-sorts the node list when the wire runs
/// backwards. Returns false and sets `why` when the wire would make a cycle.
bool math_connect(MathDialog* d, int from, int to, int which, std::string* why);
```
and deletion is a one-liner at the call site (`in_a`/`in_b` back to `-1`), because there is
nothing to validate in removing an edge: a disconnected input is a state the validation line
already names.
### Backwards wires re-sort the list
`MathChannel::nodes` is a list walked in order, and the evaluator's correctness rests on a node
reading only nodes before it. Today that rule is the *user's* problem: the input ports of nodes
above the armed output are not even drawn as armed, so a wire that would run backwards cannot
be drawn at all. In a two-column auto-placed graph that is a real obstruction — the node you
want to feed is frequently the one that happens to sit earlier in the list.
The rule becomes the editor's invariant instead. `math_connect()` on a backwards wire
(`from > to`) builds the new edge set, runs a topological sort, and commits only if it is
acyclic:
1. Trial edge set = every node's existing inputs, plus the new one.
2. Kahn's algorithm over it. Ties broken by **current list index**, so a sort that changes
nothing leaves the list alone and a sort that must move something moves as little as
possible. (A stable order matters: the node positions on the canvas do not change, so a
gratuitous renumber would silently re-point `output` and `selected` for no reason the user
can see.)
3. On a cycle: nothing is written, `why` is set, and the validation line shows it. The edge is
*not* created — this is the one refusal left, and it is the honest one.
4. On success: permute `nodes`, then remap through the permutation **everything that holds an
index** — `output`, `selected`, every `in_a`/`in_b`, `drag_node`, `selected_wire.node`. A
forgotten one is a silent mis-wire, so they are remapped in a single block with a comment
naming the set.
The session format is untouched: what is written is still a list in evaluation order. A file
written by an older build still loads, because the sort is an editor operation and an
already-ordered list sorts to itself.
`math_canvas_draw` stops gating a wire on `from < i` once this holds — but keeps the gate as a
**defensive skip**, because a `MathChannel` loaded from a file the editor did not write could
hold a forward reference, and drawing it would be drawing an edge the evaluator ignores.
### The armed-port highlight goes away
`pending_out` and the "input ports of later nodes light up" highlight are both deleted. They
exist to serve the two-click protocol and the list-order rule, and neither survives: with a
drag gesture the feedback is the live wire following the pointer, and with the re-sort every
input is a legal target. What replaces the highlight is a hover accent on the port under the
pointer during a `Wire` drag, which says the one thing that still matters — whether releasing
here will connect.
## The live validation line
`on_math_apply` validates in two steps today (`math_channel_valid()`, then an acquired-source
check the validator cannot make) and reports each in a modal `GtkAlertDialog`. A modal alert at
the end of a build is the worst time and place to be told; the graph is right there and can say
so continuously.
One extraction:
```cpp
/// Why the graph as it stands could not be saved, or "" when it could. Both checks the Apply
/// path made: `math_channel_valid()`'s graph rules and the dialog's own "a Source must name an
/// acquired channel", which the validator cannot make because only the dialog sees that list.
std::string math_dialog_problem(const MathDialog* d);
```
`math_dialog_sync()` — already the one place that rewrites the form from the model, and already
called from every mutation — sets the line's text and `gtk_widget_set_sensitive(apply, problem
.empty())`. An insensitive Apply with the reason beside it is a better refusal than a dialog
that must be dismissed.
`on_math_apply` keeps the check and loses the **dialogs**: it calls `math_dialog_problem()` and
returns on a non-empty answer, with no `GtkAlertDialog`. The guard has to stay even though the
button cannot be clicked while it would fire, because the probe calls `on_math_apply()` directly
and must not be able to save a broken graph through it. That `d->apply` pointer is new on
`MathDialog`; the button is built after the canvas today, so `math_dialog_sync()`'s first call
(at `src/ui.cpp:3972`, after the whole tree) already runs late enough to set it.
The line is a `GtkLabel` under the canvas, `error` CSS class when non-empty, and
`gtk_label_set_ellipsize(END)` with a tooltip carrying the full text — **the layout-must-not-
depend-on-content rule applies here as much as to the status bar**: this label sits in a dialog
whose canvas is `hexpand`, and a wrapping error message would otherwise widen the dialog on a
mere change of graph.
## The op picker on add
`add node` always makes a `MathOp::Gain` (`src/ui.cpp:3516`), so adding a low pass is two
actions and a dropdown hunt. It becomes a `GtkMenuButton` labelled `add` whose popover holds a
flat button per op, built once from `math_op_count()` / `math_op_at(i)` — the accessor order,
not the enum order, for the reason `CLAUDE.md` already records about writing an op's index.
Each button carries its op index with `g_object_set_data`, so there is no action-name plumbing
and nothing to parse.
The two behaviours that already work are kept: the new node becomes the output when the graph
`was_linear`, and placement is the existing grid. Placement gains one thing — a node is placed
at the **first free grid cell** rather than at `nodes.size()`, so the slot left by a deletion is
reused instead of the grid growing for ever. The overlap rule stands (`CLAUDE.md`: boxes must
not overlap, or `math_node_at()`'s topmost-first search returns the wrong one).
## Tidy / auto-layout
A `tidy` button lays the graph out left to right by dependency depth:
```
depth(i) = 0 for a Source or an unwired node
= 1 + max(depth of its wired inputs) // well-defined: inputs precede i
x = 24 + depth * (kNodeW + 48)
y = 20 + row_within_depth * (kNodeH + 22)
```
It needs no recursion and no visited set: the list is in evaluation order — the invariant the
re-sort now maintains — so the depth of node `i` depends only on nodes before it and a single
forward pass computes every depth. Then `math_fit_view()`, because a tidied graph that is off
screen is not tidy.
The column pitch is `kNodeW + 48`, which is 180 against the 160 the current grid uses: 160
leaves 28 units for the wire's 40-unit tread, so the curve doubles back on itself between
adjacent columns.
## The `Cancel` button closes rather than destroys
`src/ui.cpp:3962` connects `Cancel` to `gtk_window_destroy`:
```cpp
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window);
```
This is the defect `CLAUDE.md` documents under the math channel invariants — "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" — and it is still live.
The fix for it reached the Apply path (`src/ui.cpp:3731`) and the probe (`src/ui.cpp:6672`) and
never reached the button. It is `gtk_window_close`, one word, and the probe must assert it,
which it does not today: it only ever closes programmatically.
## What `[math-probe]` must assert
Every gesture above is reachable from the probe, because the handlers take the `MathDialog*`
and plain coordinates — which is already how the drag test at `src/ui.cpp:6694` works. The
probe is extended with one line per claim; each is an assertion stated as a `want` beside the
reading, in the style of the existing lines.
- `[math-probe] hit: output at edge+1 -> node N which=-1, at edge+9 -> node N which=-1
(want N N)` — the silent-disarm defect, read at two offsets inside the hit radius and outside
the box. Both must name the node. **This is the one line that would have caught the reported
bug**, and it is the reason the hit test returns the node rather than a sentinel.
- `[math-probe] wire: connect 0->1.a wired=W (want 0)` then a backwards
`[math-probe] wire: backwards connect 2->0.a order=[..] wired=W reordered=D (want 1)` — the
re-sort, with the permuted op order printed so a wrong remap is visible as a wrong list.
- `[math-probe] wire: self refused=D problem=[..] (want 1)` and
`[math-probe] wire: cycle refused=D problem=[..] (want 1)` — the two refusals, with the
cycle built by wiring a two-node pair both ways. `refused=1` **and** the edge absent.
- `[math-probe] wire: pick at (x,y) -> (node,which)=(N,K) delete -> in=-1 (want -1)` — the
pick samples the curve at its midpoint, which is the one place a wrong tolerance or a wrong
curve shows.
- `[math-probe] detach: drag from in 1.a to empty -> in=-1 (want -1)`, and
`[math-probe] detach: escape restores in=F (want F)` — the clear-on-begin and its undo, which
is the half that silently deletes an edge if Escape is forgotten.
- `[math-probe] view: zoom scale=S anchor kept dx=.. dy=.. (want 0.0 0.0)` — the graph point
under the pointer before and after a wheel step, which is the only reading that distinguishes
"zoomed about the pointer" from "zoomed about the origin". Both must be zero to a rounding.
- `[math-probe] view: fit scale=S box=[..] all_inside=D (want 1)`, and
`[math-probe] view: reset scale=S offset=(..) (want 1.000 (0,0))`.
- `[math-probe] view: pan dx=.. dy=.. offset=(..) (want ..)` plus a hit test **after** a pan and
a zoom — `port at the same screen point -> node N` — because a hit test that forgot the
inverse transform passes every test taken at the identity view.
- `[math-probe] tidy: depths=[..] columns=[..] overlaps=D (want 0)` — the overlap count is the
assertion (`CLAUDE.md`'s own rule), and the depths say the layout followed the graph.
- `[math-probe] validate: problem=[..] apply_sensitive=D` before and after wiring the missing
input: `(want non-empty 0)` then `(want empty 1)`.
- `[math-probe] cancel: dialog=cleared` — the `Cancel` button's `::clicked` emitted directly,
then `app.math_dialog` read back. `STILL SET` is the regression.
Two things the probe deliberately cannot assert, and which are therefore stated as design
constraints rather than checks: that the canvas takes **focus** on press (a synthetic press
does not run GTK's focus machinery), and that the live wire *looks* like it follows the pointer
(a draw is not readable from a probe). The second is bounded by the first assertion set — the
endpoint it is drawn from is `wire_x`/`wire_y`, which the drag test writes and can print.
The existing lines are all kept. `drag:` in particular stays as it is: the total-offset rule is
unchanged by any of this, and the gesture that exercises it is still a node-body drag.
## Documentation
Three places, following the project's existing split:
- **`CLAUDE.md`** — the `[math-probe]` bullet under Verification gains the new readings and
their `want`s; the math channel invariants gain the **hit-test-names-its-own-node** rule (a
port hit must not be re-derived with a box test), the **tolerance-is-a-screen-distance** rule,
the **list order is the editor's invariant, maintained by a topological re-sort** rule with
the index-remap set named, and a note that the `Cancel` path is now asserted.
- **`README.md`** — a subsection under the math channels describing the editor as it now
behaves, and the diagnosis of the silent disarm, since README is where "what was actually
built and why" lives.
- **`src/help/11-math.txt`** gains the four canvas gestures (drag a port to wire, drag a wired
input away to detach, click a wire and press Delete, wheel to zoom and drag the background to
pan) and the two buttons. It is an edit to an existing page, so `pages` stays 14 and
`[help] pages=14 parsed=14` must still hold — the markup is Pango and a page that fails to
parse renders a stand-in.
## Risks
- **The re-sort touching an index nothing remapped.** The mitigation is that the remap is one
block, the probe prints the permuted op order, and `selected`/`output` are both read back.
- **The key controller stealing a keystroke from the name entry.** It is on the canvas, which
must have focus; the probe cannot see this, so it is checked by hand once, by typing in the
name field with a wire selected.
- **The wheel controller fighting a future scrolled window.** There is none and this design
removes the reason for one, which is worth stating so a later "wrap the canvas in a
`GtkScrolledWindow`" is recognised as undoing the zoom rather than adding to it.
- **`GtkGestureDrag` and `GtkGestureClick` on one widget both claiming a press.** They already
coexist on this canvas; the change is that `pressed` now only selects and never mutates the
graph, which removes the ordering question rather than answering it.
+3
View File
@@ -22,6 +22,9 @@ the overflow menu opens the editor where the unit and the calibration live.
• A signal's colour follows the <i>signal</i>, not the slot, so the same signal on two rows • A signal's colour follows the <i>signal</i>, not the slot, so the same signal on two rows
is one colour and the side panel's chip identifies it unambiguously. A signal on no row is one colour and the side panel's chip identifies it unambiguously. A signal on no row
gets a grey chip rather than none, so the panel's rows keep their height. gets a grey chip rather than none, so the panel's rows keep their height.
• <b>Click a coloured chip to change that signal's colour.</b> A grey chip names no trace, so
it is not clickable. The colour is saved with the session; <b>Default colour</b> in the
signal's ⋯ menu gives the palette entry back.
<b>Example</b> <b>Example</b>
+19 -9
View File
@@ -5,15 +5,24 @@ nodes, operations, one output — and udpscope then treats the result as an ordi
<b>How to reach it</b> <b>How to reach it</b>
The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel; opening one The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel, and opening
shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside one shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside
the canvas, click an output port then an input port to wire them, drag a box to move it. The the canvas. Inside the editor, <i>add node</i> is a menu button whose popover picks the op, so the
orange dot marks the output node. node arrives as the operation you asked for.
<b>Graph gestures:</b> <b>drag</b> from an output port to an input port to wire them; drag a
wired input port off its connection to detach it; click a wire and press <tt>Delete</tt> to remove
it; drag a box to move it. <b>Wheel</b> zooms about the pointer. <b>Drag the background</b> to
pan. <tt>f</tt> fits all nodes into view; <tt>Ctrl+0</tt> resets the view. <i>tidy</i>, the button
beside <i>fit</i> in the editor, lays the graph out in topological columns and frames it. The orange
dot marks the output node; click <i>set as output</i> to move it.
<b>Worth knowing</b> <b>Worth knowing</b>
• The graph is evaluated as an ordered list, so a node may only read ones <i>above</i> it. A • The graph is evaluated as an ordered list, so a node may only read ones <i>above</i> it. A
cycle is impossible by construction rather than rejected by a check. wire drawn right to left re-sorts the list silently so the graph stays valid. A cycle is
unrepresentable: the connection is refused rather than created, and the line beneath the form
says why — a loop, or a node asked to read itself.
• A derived channel <i>is</i> a channel. It appears in the signal panel, can be plotted, • A derived channel <i>is</i> a channel. It appears in the signal panel, can be plotted,
measured, exported, and used as a source for another math channel. measured, exported, and used as a source for another math channel.
• The math runs on the <b>stored</b> samples — decimated and averaged — so a cutoff you type in • The math runs on the <b>stored</b> samples — decimated and averaged — so a cutoff you type in
@@ -40,10 +49,11 @@ and the last node added becomes the output -- but a graph whose output is left o
valid and simply passes its input through, which is the usual reason a gain or a filter appears to valid and simply passes its input through, which is the usual reason a gain or a filter appears to
have no effect. <i>Set as output</i> moves the dot. have no effect. <i>Set as output</i> moves the dot.
<b>Save refuses an incomplete graph</b>, and says why: an input that is not connected, no output <b>Apply is insensitive while the graph is incomplete.</b> A line beneath the form says why:
chosen, or a <i>channel</i> node naming a signal the scope is not acquiring. Only the channels being an input that is not connected, no output chosen, or a <i>channel</i> node naming a signal the
acquired are offered, because a source that is not acquired reads as no data for ever -- and a graph scope is not acquiring. Only the channels being acquired are offered, because a source that is not
that reads NaN is harder to diagnose than one that would not save. acquired reads as no data for ever — and a graph that reads NaN is harder to diagnose than one that
would not save.
<b>The filters are real filters.</b> A math channel's samples are produced as the stream arrives, <b>The filters are real filters.</b> A math channel's samples are produced as the stream arrives,
once per stored slot, so a low pass carries the history a filter needs. A cutoff typed in hertz is once per stored slot, so a low pass carries the history a filter needs. A cutoff typed in hertz is
+27 -1
View File
@@ -239,13 +239,14 @@ bool load_layout(const std::string& path, LayoutState& out) {
st.window_s = 0.0; st.window_s = 0.0;
st.rows.clear(); st.rows.clear();
st.calibration.clear(); st.calibration.clear();
st.colors.clear();
st.enabled.clear(); st.enabled.clear();
st.math.clear(); st.math.clear();
st.measures.clear(); st.measures.clear();
std::string line; std::string line;
// What the last `[[...]]` header opened, so keys land in the right record. // What the last `[[...]]` header opened, so keys land in the right record.
enum class Section { Top, Plot, Calibration, Math, Measure } section = Section::Top; enum class Section { Top, Plot, Calibration, Color, Math, Measure } section = Section::Top;
int plot_count = 0; int plot_count = 0;
bool plot_section_seen = false; bool plot_section_seen = false;
while (std::getline(in, line)) { while (std::getline(in, line)) {
@@ -259,6 +260,9 @@ bool load_layout(const std::string& path, LayoutState& out) {
} else if (t == "[[calibration]]") { } else if (t == "[[calibration]]") {
section = Section::Calibration; section = Section::Calibration;
st.calibration.emplace_back(); st.calibration.emplace_back();
} else if (t == "[[color]]") {
section = Section::Color;
st.colors.emplace_back();
} else if (t == "[[math]]") { } else if (t == "[[math]]") {
section = Section::Math; section = Section::Math;
st.math.emplace_back(); st.math.emplace_back();
@@ -378,6 +382,23 @@ bool load_layout(const std::string& path, LayoutState& out) {
} catch (...) { } catch (...) {
} }
} }
} else if (section == Section::Color && !st.colors.empty()) {
if (key == "name") {
st.colors.back().name = strip_quote(value);
} else if (key == "rgb") {
// `"#RRGGBB"`, so the file says what it means to a reader hand-editing it -- a bare decimal
// would be the one number in this file nobody could check by eye. Anything unparseable
// leaves the entry at 0, and `apply_layout()` drops an entry with no name anyway.
const std::string hex = strip_quote(value);
const std::size_t at = hex.find('#');
try {
st.colors.back().rgb =
static_cast<std::uint32_t>(std::stoul(
at == std::string::npos ? hex : hex.substr(at + 1), nullptr, 16)) &
0x00FFFFFFu;
} catch (...) {
}
}
} }
} }
if (!plot_section_seen && plot_count > 0) { if (!plot_section_seen && plot_count > 0) {
@@ -422,6 +443,11 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain
<< "\noffset = " << cal.offset << "\n"; << "\noffset = " << cal.offset << "\n";
} }
for (const auto& color : state.colors) {
char rgb[16];
std::snprintf(rgb, sizeof(rgb), "\"#%06X\"", color.rgb & 0x00FFFFFFu);
o << "\n[[color]]\nname = " << toml_escape(color.name) << "\nrgb = " << rgb << "\n";
}
for (const MathChannel& m : state.math) { for (const MathChannel& m : state.math) {
o << "\n[[math]]\nname = " << toml_escape(m.name) << "\nunit = " << toml_escape(m.unit) o << "\n[[math]]\nname = " << toml_escape(m.name) << "\nunit = " << toml_escape(m.unit)
<< "\nenabled = " << (m.enabled ? "true" : "false") << "\noutput = " << m.output << "\nenabled = " << (m.enabled ? "true" : "false") << "\noutput = " << m.output
+9
View File
@@ -32,6 +32,15 @@ struct LayoutState {
}; };
std::vector<Calibration> calibration; std::vector<Calibration> calibration;
/// The trace colours the user chose, `0xRRGGBB`. Only the *overrides*: a signal with no entry
/// takes the positional palette entry, which is what keeps a stream's new signals coloured
/// without a session having to name them. Saved by name like every identity here.
struct Color {
std::string name;
std::uint32_t rgb = 0;
};
std::vector<Color> colors;
/// Signals the session had enabled. Enabling is what starts acquisition, so this is what makes a /// Signals the session had enabled. Enabling is what starts acquisition, so this is what makes a
/// reopened scope look like the one that was closed -- and it is saved by *name*, like every other /// reopened scope look like the one that was closed -- and it is saved by *name*, like every other
/// identity here, because a CONFIG renumbers the channels. /// identity here, because a CONFIG renumbers the channels.
+16 -4
View File
@@ -106,6 +106,13 @@ MathOp math_op_at(int index) {
} }
} }
int math_op_index(MathOp op) {
for (int i = 0; i < math_op_count(); ++i) {
if (math_op_at(i) == op) return i;
}
return 0;
}
int math_op_arity(MathOp op) { int math_op_arity(MathOp op) {
switch (op) { switch (op) {
case MathOp::Source: return 0; case MathOp::Source: return 0;
@@ -215,15 +222,15 @@ double math_step(const MathChannel& channel, MathRuntime& rt, const float* value
return rts[static_cast<std::size_t>(out)].value; return rts[static_cast<std::size_t>(out)].value;
} }
bool math_channel_valid(const MathChannel& channel, std::string* why) { bool math_channel_valid_nodes(const std::vector<MathNode>& nodes, int output, std::string* why) {
const std::size_t n = channel.nodes.size(); const std::size_t n = nodes.size();
const auto fail = [&](const std::string& text) { const auto fail = [&](const std::string& text) {
if (why != nullptr) *why = text; if (why != nullptr) *why = text;
return false; return false;
}; };
if (n == 0) return fail("empty graph"); if (n == 0) return fail("empty graph");
for (std::size_t i = 0; i < n; ++i) { for (std::size_t i = 0; i < n; ++i) {
const MathNode& node = channel.nodes[i]; const MathNode& node = nodes[i];
if (node.op == MathOp::Source && node.source.empty()) { if (node.op == MathOp::Source && node.source.empty()) {
return fail("node " + std::to_string(i + 1) + ": no channel chosen"); return fail("node " + std::to_string(i + 1) + ": no channel chosen");
} }
@@ -238,10 +245,15 @@ bool math_channel_valid(const MathChannel& channel, std::string* why) {
} }
} }
} }
if (channel.output < 0 || static_cast<std::size_t>(channel.output) >= n) { if (output < 0 || static_cast<std::size_t>(output) >= n) {
return fail("no output chosen"); return fail("no output chosen");
} }
if (why != nullptr) why->clear();
return true; return true;
} }
bool math_channel_valid(const MathChannel& channel, std::string* why) {
return math_channel_valid_nodes(channel.nodes, channel.output, why);
}
} // namespace udpscope } // namespace udpscope
+9
View File
@@ -30,6 +30,10 @@ enum class MathOp {
const char* math_op_name(MathOp op); const char* math_op_name(MathOp op);
int math_op_count(); int math_op_count();
MathOp math_op_at(int index); MathOp math_op_at(int index);
/// An op's position in `math_op_at()`'s order. That order is the *dialog's* -- what a dropdown or a
/// menu lists, and what a session file stores -- and it is deliberately not promised to be the
/// enum's declaration order, so a caller that needs a position must ask rather than cast.
int math_op_index(MathOp op);
/// Inputs the op takes: 0 for `Source`, 1 for the unary ones, 2 for the arithmetic. /// Inputs the op takes: 0 for `Source`, 1 for the unary ones, 2 for the arithmetic.
int math_op_arity(MathOp op); int math_op_arity(MathOp op);
/// Which of the per-node parameters an op actually reads, so a dialog can show only those rows -- /// Which of the per-node parameters an op actually reads, so a dialog can show only those rows --
@@ -117,4 +121,9 @@ double math_step(const MathChannel& channel, MathRuntime& rt, const float* value
/// input it needs connected to a node that exists (with `Source` nodes naming something). /// input it needs connected to a node that exists (with `Source` nodes naming something).
bool math_channel_valid(const MathChannel& channel, std::string* why = nullptr); bool math_channel_valid(const MathChannel& channel, std::string* why = nullptr);
/// The same check over a bare node list and output index, for a caller that has a graph but not yet
/// a `MathChannel` -- the editor, and the headless probe that drives it.
bool math_channel_valid_nodes(const std::vector<MathNode>& nodes, int output,
std::string* why = nullptr);
} // namespace udpscope } // namespace udpscope
+32
View File
@@ -516,6 +516,9 @@ void ScopeModel::reset_live_history_locked() {
} }
void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) { void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
// Timed from before the lock: the wait for it is part of what freezing a view costs the thread
// doing it, and this one is the UI's.
const std::uint64_t t0 = now_ns();
auto snap = std::make_shared<EventSnapshot>(); auto snap = std::make_shared<EventSnapshot>();
snap->x_offset = x_offset; snap->x_offset = x_offset;
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
@@ -537,6 +540,7 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
} }
snap->values.resize(channels_.size()); snap->values.resize(channels_.size());
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0}); snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
snap->pyramids.resize(channels_.size());
for (std::size_t c = 0; c < channels_.size(); ++c) { for (std::size_t c = 0; c < channels_.size(); ++c) {
const u64 stride = std::max<u64>(1, part_.stride[c]); const u64 stride = std::max<u64>(1, part_.stride[c]);
auto& vals = snap->values[c]; auto& vals = snap->values[c];
@@ -545,16 +549,42 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
if (take > 0) src.values(c).copy_from(first + n - take, take, vals.data()); if (take > 0) src.values(c).copy_from(first + n - take, take, vals.data());
f32 lo = vals.empty() ? 0.0f : vals.front(); f32 lo = vals.empty() ? 0.0f : vals.front();
f32 hi = lo; f32 hi = lo;
// The LOD index, folded into the bounds pass exactly as `publish_event()` folds it, and for
// the same reason: a snapshot that answers `pyramid()` with an empty one has `level_for()`
// descend to level 0, so the frozen view is re-gathered from every raw sample it holds on
// every frame it is on screen. A pause is not a cheaper case than a capture -- it freezes the
// *same* rings -- and leaving it out was visible twice over. The cost: `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 *look* changed, which is the half a cost figure does
// not show: `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, i.e. the trace changed appearance on pause.
//
// It is not free, and the trade is one-off against per-frame: building it took the pause
// publish from 6.15 ms to 22.31 ms (`publish cost:`), which blocks the *producer* -- against
// ~5 ms a frame of draw for as long as the pause lasts, which at ~60 fps a 1.6 s pause repays
// thirty times over. The producer absorbs the stall: `gaps=0` with and without a pause, the
// paused run having published in 86 ms.
auto& pyr = snap->pyramids[c];
pyr.reserve(take);
for (f32 v : vals) { for (f32 v : vals) {
lo = std::min(lo, v); lo = std::min(lo, v);
hi = std::max(hi, v); hi = std::max(hi, v);
pyr.append(v);
} }
snap->y[c] = plot::Range{lo, hi}; snap->y[c] = plot::Range{lo, hi};
} }
pause_view_.store(std::move(snap), std::memory_order_release); pause_view_.store(std::move(snap), std::memory_order_release);
note_publish(publish_pause_count_, publish_pause_us_total_, publish_pause_us_worst_,
(now_ns() - t0) / 1000u);
} }
void ScopeModel::publish_event(double pre_s, double post_s, double edge) { void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
// The caller is the producer and already holds `mutex()`, so every microsecond counted here is a
// microsecond the UI tick is blocked for -- which is what the documented `tick` max is made of.
const std::uint64_t t0 = now_ns();
auto snap = std::make_shared<EventSnapshot>(); auto snap = std::make_shared<EventSnapshot>();
snap->x_offset = edge; // the stream time x = 0 means, for everything that has to cross back snap->x_offset = edge; // the stream time x = 0 means, for everything that has to cross back
BufferStore& src = live(); BufferStore& src = live();
@@ -605,6 +635,8 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
// Bumped after the snapshot is in place, so a consumer that sees the new sequence // Bumped after the snapshot is in place, so a consumer that sees the new sequence
// number sees the snapshot it counts. // number sees the snapshot it counts.
event_seq_.fetch_add(1, std::memory_order_release); event_seq_.fetch_add(1, std::memory_order_release);
note_publish(publish_event_count_, publish_event_us_total_, publish_event_us_worst_,
(now_ns() - t0) / 1000u);
} }
void BufferStore::set_epoch(f64 epoch) noexcept { void BufferStore::set_epoch(f64 epoch) noexcept {
+47
View File
@@ -224,6 +224,37 @@ class ScopeModel {
std::uint64_t math_cost_ns() const noexcept { std::uint64_t math_cost_ns() const noexcept {
return math_cost_ns_.load(std::memory_order_relaxed); return math_cost_ns_.load(std::memory_order_relaxed);
} }
/// What freezing a view costs, and *which thread it costs it to*. The two publishes are stalls on
/// opposite sides of `mutex()` and so are counted apart: `publish_event()` runs on the **producer**
/// with the lock already held, so its cost lands on the UI as a long tick (the documented `tick`
/// max); `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`. Neither is attributable in a frame mean, which is
/// why they are counted rather than inferred. Each counter is written by one thread and read by the
/// dump, so a relaxed store of a value already in hand is enough.
///
/// This is the number to judge a second live ring against: freezing a whole `BufferStore` would
/// make both figures zero, since a `ChannelBuffer` maintains its pyramid incrementally and there
/// would be nothing to copy -- but it costs a second budget's worth of memory and a second
/// per-sample write on the producer, so it is worth paying only if the worst figures here are
/// large enough to matter.
std::uint64_t publish_event_count() const noexcept {
return publish_event_count_.load(std::memory_order_relaxed);
}
std::uint64_t publish_event_us_total() const noexcept {
return publish_event_us_total_.load(std::memory_order_relaxed);
}
std::uint64_t publish_event_us_worst() const noexcept {
return publish_event_us_worst_.load(std::memory_order_relaxed);
}
std::uint64_t publish_pause_count() const noexcept {
return publish_pause_count_.load(std::memory_order_relaxed);
}
std::uint64_t publish_pause_us_total() const noexcept {
return publish_pause_us_total_.load(std::memory_order_relaxed);
}
std::uint64_t publish_pause_us_worst() const noexcept {
return publish_pause_us_worst_.load(std::memory_order_relaxed);
}
const std::vector<SignalInfo>& signals() const noexcept { return signals_; } const std::vector<SignalInfo>& signals() const noexcept { return signals_; }
const std::vector<ChannelInfo>& channels() const noexcept { return channels_; } const std::vector<ChannelInfo>& channels() const noexcept { return channels_; }
@@ -491,6 +522,22 @@ class ScopeModel {
std::atomic<std::uint64_t> math_slots_{0}; std::atomic<std::uint64_t> math_slots_{0};
std::atomic<std::uint64_t> math_timed_{0}; std::atomic<std::uint64_t> math_timed_{0};
std::atomic<std::uint64_t> math_cost_ns_{0}; std::atomic<std::uint64_t> math_cost_ns_{0};
/// See `publish_event_count()`. Single-writer each: the first three are the producer's, the last
/// three the UI thread's.
std::atomic<std::uint64_t> publish_event_count_{0};
std::atomic<std::uint64_t> publish_event_us_total_{0};
std::atomic<std::uint64_t> publish_event_us_worst_{0};
std::atomic<std::uint64_t> publish_pause_count_{0};
std::atomic<std::uint64_t> publish_pause_us_total_{0};
std::atomic<std::uint64_t> publish_pause_us_worst_{0};
/// Fold one publish's elapsed microseconds into a counter triple. Called by the one thread that
/// owns the triple, so the load/store pairs need no read-modify-write.
static void note_publish(std::atomic<std::uint64_t>& count, std::atomic<std::uint64_t>& total,
std::atomic<std::uint64_t>& worst, std::uint64_t us) noexcept {
count.store(count.load(std::memory_order_relaxed) + 1, std::memory_order_relaxed);
total.store(total.load(std::memory_order_relaxed) + us, std::memory_order_relaxed);
if (us > worst.load(std::memory_order_relaxed)) worst.store(us, std::memory_order_relaxed);
}
std::vector<bool> enabled_; std::vector<bool> enabled_;
Partition part_; Partition part_;
+36 -2
View File
@@ -69,6 +69,40 @@ plot::Color RowSet::palette_color(int index) {
plot::Color RowSet::unplotted_color() { return plot::Color::hex(kUnplottedColor); } plot::Color RowSet::unplotted_color() { return plot::Color::hex(kUnplottedColor); }
plot::Color RowSet::signal_color(const std::string& signal, int palette_index) const {
const auto it = signal_colors_.find(signal);
if (it != signal_colors_.end()) return plot::Color::hex(it->second);
return palette_color(palette_index);
}
void RowSet::mark_signal_dirty(const std::string& signal) {
for (int i = 0; i < kMaxRows; ++i) {
const Row& r = row(i);
for (int s = 0; s < kSlotsPerRow; ++s) {
if (r.signal_name[static_cast<std::size_t>(s)] != signal) continue;
row_dirty_[static_cast<std::size_t>(i)] = true;
break;
}
}
}
void RowSet::set_signal_color(const std::string& signal, plot::u32 rgb) {
if (signal.empty()) return;
// Masked to 24 bits: the palette is `0xRRGGBB` and `Color::hex()` reads it as such, so an alpha
// byte left in the high octet by a caller converting from a `GdkRGBA` would be silently ignored
// by the plot and silently written to the session file.
const plot::u32 want = rgb & 0x00FFFFFFu;
const auto it = signal_colors_.find(signal);
if (it != signal_colors_.end() && it->second == want) return;
signal_colors_[signal] = want;
mark_signal_dirty(signal);
}
void RowSet::clear_signal_color(const std::string& signal) {
if (signal_colors_.erase(signal) == 0) return;
mark_signal_dirty(signal);
}
RowSet::RowSet() { rebuild_all(); } RowSet::RowSet() { rebuild_all(); }
void RowSet::set_row_count(int rows) { void RowSet::set_row_count(int rows) {
@@ -130,8 +164,8 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
{"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si}); {"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
for (int s = 0; s < kSlotsPerRow; ++s) { for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t i = static_cast<std::size_t>(s); const std::size_t i = static_cast<std::size_t>(s);
r.plot->add_trace( r.plot->add_trace({&r.slots[i], signal_color(r.signal_name[i], r.color_index[i]), 1.2f,
{&r.slots[i], palette_color(r.color_index[i]), 1.2f, r.axis_x, r.axis_y, r.name[i]}); r.axis_x, r.axis_y, r.name[i]});
} }
r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor)); r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor));
r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor)); r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor));
+28
View File
@@ -9,6 +9,7 @@
#include <plot/plot/plot.hpp> #include <plot/plot/plot.hpp>
#include <array> #include <array>
#include <map>
#include <memory> #include <memory>
#include <string> #include <string>
@@ -399,7 +400,30 @@ class RowSet {
/// names beside it shifted sideways as signals were placed. /// names beside it shifted sideways as signals were placed.
static plot::Color unplotted_color(); static plot::Color unplotted_color();
/// The colour a signal is drawn in: the one the user chose for it if they chose one, the
/// positional palette entry otherwise.
///
/// **The one resolver**, used by `install()` for the trace and by the panels for their chips.
/// A chip exists to say which trace on screen a panel row is about, so a chip that resolved
/// a colour by a different route than the trace is the single failure the chip cannot
/// survive -- and an override is exactly the state in which two routes would disagree.
plot::Color signal_color(const std::string& signal, int palette_index) const;
/// Gives `signal` the colour `rgb` (0xRRGGBB), by *name*: a palette entry is positional, so an
/// override held by index would move to another signal on the next CONFIG. Marks every row that
/// plots it dirty, because a trace's colour is fixed at `add_trace()` -- a new colour is a new
/// figure, which is the same path `assign()` already takes.
void set_signal_color(const std::string& signal, plot::u32 rgb);
/// Back to the palette entry. A no-op, and no rebuild, when there was no override.
void clear_signal_color(const std::string& signal);
/// Every override, for the session file. Ordered by name, so a saved session's colour
/// tables do not reshuffle between writes.
const std::map<std::string, plot::u32>& signal_colors() const noexcept { return signal_colors_; }
private: private:
/// Marks every row with `signal` in a slot for rebuild. Not `assign()`: nothing about what
/// the slot *holds* changed, only the colour its trace has to be built with.
void mark_signal_dirty(const std::string& signal);
/// Moves one row's *persistent* state -- what it plots, in which colours, where its /// Moves one row's *persistent* state -- what it plots, in which colours, where its
/// cursors are -- onto another row. Deliberately not a `Row` assignment: a `Row` also /// cursors are -- onto another row. Deliberately not a `Row` assignment: a `Row` also
/// carries the figure handles (`plot`, the axis and cursor ids) and the `SlotSeries` /// carries the figure handles (`plot`, the axis and cursor ids) and the `SlotSeries`
@@ -440,6 +464,10 @@ class RowSet {
double right_gutter_px_ = 0.0; double right_gutter_px_ = 0.0;
bool y_gutter_reserved_ = false; bool y_gutter_reserved_ = false;
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{}; std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
/// The colours the user chose, by signal name (see `set_signal_color()`). Sparse on purpose:
/// an absent entry means "the palette decides", which is what keeps a new CONFIG's signals
/// coloured without anything having to seed them.
std::map<std::string, plot::u32> signal_colors_;
CursorMode cursor_mode_ = CursorMode::Vertical; CursorMode cursor_mode_ = CursorMode::Vertical;
bool cursors_on_ = false; bool cursors_on_ = false;
bool relative_x_ = false; bool relative_x_ = false;
+1594 -136
View File
File diff suppressed because it is too large. Load diff