From fc4700d1c995cd42dc9e242b55c4c8af82907240 Mon Sep 17 00:00:00 2001
From: Martino Ferrari
Date: Fri, 16 Oct 2026 08:00:54 +0200
Subject: [PATCH 001/127] perf: a measurement pane is priced by its window, and
its fold is inline
A pane is recomputed over the whole visible window on a cadence, and at a 15 s window
(11.8 M samples, 184 k level-0 level buckets, `--buffer-mb 4000` so the stride stays 1)
that is priced per *measured channel* and per *distinct share*. Measured with the new
`[levels-probe]` sweep, per recompute: one window with one level 6.58 ms, four shares
10.28 ms, sixteen shares 24.55 ms, and four measurements one share each over four
channels **35.69 ms** -- against a tick mean of 1.34 ms with an empty pane and 4.58 ms
with that one. That is the reported "very slow with multiple measurements": the pass
runs inside `tick()`, so it is a *frame* cost, and not an ingest one -- `gaps` and
`packets` stay clean even though each `MeasureWindowReader` holds the model mutex for
its whole life, because a socket buffer absorbs a few tens of milliseconds of producer.
The largest single term inside a pass was a call. `measure_accumulate_bucket()` folds
one bucket into one level, and it is the innermost loop of a walk whose iteration count
is the window's buckets *times* the pane's levels; it lived out of line in
`measure.cpp`, so every one of those iterations was a cross-translation-unit call.
Moving it, and the crossing state it updates, into `measure.hpp` as `inline` takes
sixteen shares on one channel from 24.55 to **17.25 ms** a recompute -- with every value
in the dump's `measurements:` unchanged. Two levers remain and both are measured: the
fetch's three out-of-line pyramid accessors plus a 48-byte copy per bucket (3.3 ms of an
11.8 M-sample window's 11.6 ms pass) needs a combined accessor upstream, and `raw` is
the *signal's* share (4.6 % on the mock's noisy channel against 0.1 % on its sine),
because a level inside a bucket's own `[lo, hi]` has to be walked sample by sample.
Bisected with the pane populated (the earlier bisect had it empty, which cannot see
this): the parent of the recent range and HEAD read **8.29** and **7.92 ms** a
recompute for the same four-type pane at the same window, and neither of the two
commits between them touches `ui_measure.cpp` or `measure.cpp` at all. So the cost is
the scale and not a regression, and the fix makes a pass cheaper rather than removing
one -- the plot's own per-frame cost was checked the same way (four commits, equal
within 3 %, and unchanged by the window, the ring size and the visible sample count).
`UDPSCOPE_PROBE_LEVELS_MS` with `UDPSCOPE_PROBE_LEVELS` and
`UDPSCOPE_PROBE_LEVELS_CHANNELS` is the sweep; `docs/claude/invariants-measurements.md`
records the curve and the inline rule, `verification.md` and `probe-readings.md` the
probe and what its numbers must read, and the README the pane's cost at this scale.
---
README.md | 29 ++++++++++++
docs/claude/invariants-measurements.md | 41 ++++++++++++++++
docs/claude/probe-readings.md | 27 +++++++++++
docs/claude/verification.md | 16 +++++++
src/measure.cpp | 61 ------------------------
src/measure.hpp | 60 ++++++++++++++++++++++++
src/ui.cpp | 3 ++
src/ui_app.hpp | 1 +
src/ui_probe.cpp | 65 ++++++++++++++++++++++++++
9 files changed, 242 insertions(+), 61 deletions(-)
diff --git a/README.md b/README.md
index 1e3465d..2007139 100644
--- a/README.md
+++ b/README.md
@@ -1638,6 +1638,35 @@ Measured on the same stream and window: tick mean **1.39 ms** with the pane hold
measurements against **0.73 ms** without (595 frames against 610), the pass itself 11 ms every
400 ms — and the 82 ms worst tick is the *control's* too, i.e. the publish stall, not this.
+**...and the same pane costs what the *window* costs, which a big time scale buys.** The three
+fixes above bound *how often* and *how much per sample*; what is left is the arithmetic of the
+scale: a pass is priced per measured **channel** (one fetch of the window's level-0 buckets, one
+statistics scan) and per **distinct share** in the pane (one level accumulator, walked over those
+buckets), and the window's own sample count multiplies both. Measured with the new
+`[levels-probe]` sweep on the mock at a **15 s** window and `--buffer-mb 4000` (so `time_stride`
+stays 1 and the window holds 11.8 M samples, 184 k buckets), four signals, one row, on llvmpipe
+— `measure cost:`'s `each` per recompute, and the tick's mean:
+
+| pane | `each` | `raw` | tick mean |
+|---|---|---|---|
+| empty | — | — | 1.34 ms |
+| 1 measurement, 1 level, 1 channel | 6.58 ms | 0.1 % | 1.99 ms |
+| 4 measurements, 4 shares, 1 channel | 10.28 ms | 0.4 % | 2.38 ms |
+| 16 measurements, 16 shares, 1 channel | 24.55 ms | 1.2 % | 3.86 ms |
+| 4 measurements, 1 share each, **4 channels** | 35.69 ms | 2.6 % | 4.58 ms |
+
+So a machine's ten measured signals at a 15 s window is tens of milliseconds a pass, several
+times a second, on the **tick** — the pane is a *frame* cost and not an ingest one (`gaps` and
+`packets` stay clean even though each reader holds the model mutex, which is what "the plot is
+slow with measurements on" actually is). One of those terms was a pure call-overhead defect:
+pass two folds each bucket into each level through `measure_accumulate_bucket()`, the innermost
+loop of a walk whose iteration count is the window's buckets *times* the pane's levels, and as an
+out-of-line function in its own translation unit it cost **30 % of the pass** — sixteen shares on
+one channel read `24.55 ms` a recompute with every value in `measurements:` unchanged at
+**17.25 ms** once it was moved into `measure.hpp` as `inline`. The fetch's three out-of-line
+pyramid accessors per bucket (3.3 ms of an 11.8 M-sample window's 11.6 ms pass) are the next
+lever, and `raw` is the last one and belongs to the signal rather than to the pane.
+
**"Show on plot" draws full-width rulers, and they must not blink.** A level measurement draws an
`h_ruler` across the whole plot and a time one a full-height cursor, so a pane holding four
measurements can put six lines over the data — that is the feature, and the lines move with the
diff --git a/docs/claude/invariants-measurements.md b/docs/claude/invariants-measurements.md
index 6dc1947..3e39764 100644
--- a/docs/claude/invariants-measurements.md
+++ b/docs/claude/invariants-measurements.md
@@ -118,6 +118,47 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
the pyramid for nothing.
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
anything.
+- **A pass costs the window's buckets times the pane's *measured channels* and its *distinct
+ levels*, and the window's own size multiplies all of it.** The cadence above bounds *how
+ often*; this is *how much*, and it is the term a big time scale buys. Measured on the mock at
+ the user's scale -- a **15 s** window, `--buffer-mb 4000` (so `time_stride` stays 1 and the
+ window is 11.8 M samples, 184 k level-0 buckets), four signals, one row -- with
+ `[levels-probe]` (see [verification.md](verification.md)), `measure cost:` reading
+ `recomputes`/`each`/`covered`/`buckets`/`raw`:
+
+ | pane | `each` | `raw` | tick mean |
+ |---|---|---|---|
+ | empty | -- | -- | 1.34 ms |
+ | 1 measurement, 1 level | 6.58 ms | 0.1 % | 1.99 ms |
+ | 4 measurements, 4 shares on **one** channel | 10.28 ms | 0.4 % | 2.38 ms |
+ | 16 measurements, 16 shares on one channel | 24.55 ms | 1.2 % | 3.86 ms |
+ | 4 measurements, 1 share each on **four** channels | 35.69 ms | 2.6 % | 4.58 ms |
+
+ So a pane is priced per measured **channel** (one `MeasureWindowReader`, one fetch, one scan)
+ and per **distinct share** (one accumulator), and the window's samples are the multiplier on
+ both: the same pane over a 2 M-sample window is the 1.2--1.6 ms a recompute this pane's
+ budget was fixed at, and a machine's ten noisy channels at a 15 s window is **tens of ms a
+ pass**, several times a second. That lands on the **tick** -- `update_measures()` runs inside
+ it -- so `frame cost:`'s tick mean is where a pane's cost shows (1.34 -> 4.58 ms above), and
+ it is *not* an ingest problem: `gaps=`/`packets=` stay exactly clean even though each
+ `MeasureWindowReader` holds `model->mutex()` for its whole life, because a socket buffer
+ absorbs a few tens of ms of producer. Read the tick mean and `each` before blaming the
+ producer.
+ Inside a pass the terms are, instrumented per window at that scale: the **fetch** 3.3 ms
+ (three out-of-line `MinMaxPyramid` accessors and a 48-byte `MeasureBucket` per bucket),
+ `measure_scan_buckets()` 0.8 ms, and **pass two** 7.5 ms -- dominated by the per-(bucket,
+ level) fold. That fold is why `measure_accumulate_bucket()` is `inline` in `measure.hpp`: it
+ is the innermost loop of a walk whose iteration count is the window's buckets *times* the
+ pane's levels, and as an out-of-line call in its own translation unit it cost **30 % of the
+ pass** (sixteen shares on one channel: `24.55 -> 17.25 ms` a recompute). A caller adding a
+ per-bucket type keeps it in the header, and a caller adding a bulk one may not let it grow a
+ call per bucket either.
+ The third term is `raw`, and it is the **signal's**: a level that falls inside a bucket's
+ `[lo, hi]` has to be walked sample by sample (the fold cannot split a bucket), so a signal
+ whose local range covers the level everywhere -- white noise, a noisy machine channel --
+ degrades the pass to O(window) *per level*: measured `raw=4.6 %` on the mock's `noise` channel
+ against `0.1 %` on its `sine` at the same window. It is the field to read before blaming the
+ pane for a slow run, and no cheaper walk fixes it.
- **`Top`/`Bottom`/`Amplitude` read a *flat* level, and the density that finds it is the buckets',
not the samples'.** A band-limited edge overshoots and rings -- the mock's `square` at 30
harmonics reaches ±1.179 where its plateau is ±1 -- so a measurement that reads the extremes, or
diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md
index 79fef9b..e5998f2 100644
--- a/docs/claude/probe-readings.md
+++ b/docs/claude/probe-readings.md
@@ -714,6 +714,33 @@ Two lines of the self-check output are assertions in disguise:
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.)
+- `[levels-probe] N measurement(s) over K channel(s), shares='...' -> W window(s), L level
+ walk(s)` — then the exit dump's `measure cost:` and `frame cost:` beside it. This is how a
+ pane's cost is read, and the three fields are the three factors: **`W` is the pane's price
+ for the window**, one fetch and one scan per *measured channel*; **`L` is its price for the
+ pane's shares**, one accumulator per distinct one, walked over the window's buckets; and the
+ window's samples are the multiplier on both, so a sweep at one scale says nothing about
+ another. Measured at the user's scale (15 s window, `--buffer-mb 4000` -> `stride=1`, 11.8 M
+ samples in the window = 184 k buckets, four signals, mock at ~0.5 MS/s per signal, llvmpipe),
+ with `UDPSCOPE_PROBE_LEVELS_CHANNELS` set:
+ * `50`, 1 channel -> `1 window, 1 level`, `measure cost: 226 recomputes, 6.58 ms each`, `raw=0.1%`,
+ `tick 1.99 ms` against an empty pane's **1.34**;
+ * `5,10,15,20`, 1 channel -> `4 levels`, **10.28 ms** each, `raw=0.4%`, `tick 2.38 ms`;
+ * the sixteen shares `5..80`, 1 channel -> `16 levels`, **24.55 ms** each, `raw=1.2%`, `tick 3.86 ms`;
+ * `5,10,15,20` over **4 channels** -> 4 windows and 4 levels, **35.69 ms** each, `raw=2.6%`,
+ `tick 4.58 ms`, and still `gaps=0 packets=30580` — the pane is a *tick* cost and not an
+ ingest one, which is the thing to say before blaming the producer.
+ `measure cost:`'s `each` is the pass and `covered`/`recomputes` is the mean window it ran
+ over (11.8 M samples here), so `each` is only comparable between runs at the same window and
+ the same pane; `raw` is the *signal's* share (the mock's `noise` channel reads `4.6%` against
+ its `sine`'s `0.1%` at the same window, because a level inside a bucket's own range has to be
+ walked sample by sample). A pass that reads **tens of ms** on a 15 s window with a handful of
+ measurements is the curve in
+ [invariants-measurements.md](invariants-measurements.md) and not a defect, and the same pane
+ at a 2 s window reads the 1.2--1.6 ms the pane's budget was fixed at. The guard for the walk
+ itself is the exit dump's `frame cost:`: sixteen shares on one channel read `24.55 ms` a pass
+ and `3.86 ms` of tick mean before `measure_accumulate_bucket()` was made `inline`, and
+ **17.25 ms** a pass after it, with every value in `measurements:` unchanged.
- `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 mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=..
diff --git a/docs/claude/verification.md b/docs/claude/verification.md
index c179a81..1823012 100644
--- a/docs/claude/verification.md
+++ b/docs/claude/verification.md
@@ -76,6 +76,22 @@ precedent, not an exception.
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.
+- `[levels-probe] N measurement(s) over K channel(s), shares='...' -> W window(s), L level
+ walk(s)` (stderr, `UDPSCOPE_PROBE_LEVELS_MS`) — **prices a measurement pane**, which is what a
+ report of "it is slow with many measurements" has to become before it is a number. It adds one
+ `above` per share in `UDPSCOPE_PROBE_LEVELS` (comma separated, default `50`), spread in turn
+ over the first `UDPSCOPE_PROBE_LEVELS_CHANNELS` channels (default 1), and reports the two
+ things a count does not name: how many **windows** (one `MeasureWindowReader`, one fetch, one
+ scan per measured channel) and how many **level walks** (one accumulator per *distinct* share)
+ the pass then had to take. `50,50,50,50` on one channel is four pane rows and **one** walk;
+ `5,10,15,20` is four rows and **four**; the same four shares over four channels is four
+ windows *and* four walks. Read the `measure cost:` line it leaves in the exit dump and the
+ sweep in [invariants-measurements.md](invariants-measurements.md) — and read it against a run
+ with `UDPSCOPE_PROBE_LEVELS_MS` unset, because the pane's cost is a *difference* between two
+ tick means. **Run it at the scale being asked about**: the cost is the window's, so a 2 s
+ window prices a 15 s one at nothing (four measurements over four channels read `35.69 ms` a
+ recompute and a 4.58 ms tick mean at a 15 s window against `6.58 ms`/1.34 ms for one window at
+ the same window).
- `[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
diff --git a/src/measure.cpp b/src/measure.cpp
index 9ef7f4e..0d9bfae 100644
--- a/src/measure.cpp
+++ b/src/measure.cpp
@@ -14,20 +14,6 @@ namespace {
/// in. Shared by the raw walk and the bucket walk so the two cannot disagree about what an edge
/// records -- an interval's width is the distance from the edge that entered it to the edge that
/// left it, which is the pairing the pulse duration averages.
-void enter_side(MeasureLevel& acc, std::size_t e, double t) {
- const std::size_t other = 1 - e;
- if (acc.enters[e] == 0) acc.first_enter[e] = t;
- acc.last_enter[e] = t;
- ++acc.enters[e];
- if (acc.open[other]) {
- acc.width_sum[other] += t - acc.open_at[other];
- ++acc.widths[other];
- acc.open[other] = false;
- }
- acc.open_at[e] = t;
- acc.open[e] = true;
-}
-
// The hysteresis band and its rationale moved to `MeasureOptions::noise_pct`: it is a per-measurement
// setting now, and a second copy of the number here would be a second thing to keep in step.
constexpr double kPi = 3.14159265358979323846;
@@ -240,51 +226,6 @@ void measure_accumulate(const MeasureChunk& chunk,
}
}
-bool measure_accumulate_bucket(const MeasureBucket& k,
- const std::function& time_of,
- MeasureLevel& acc) {
- if (k.count == 0) return true;
- // A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes
- // the first *finite* sample of the new side, and an aggregate cannot say which that is.
- if (k.count != k.span) return false;
- // The *sums* need only the bare level: strictly on one side of it, so every sample decides the
- // same bin and none is *at* it, which is what makes the fold exact rather than an approximation.
- const bool above = k.lo > acc.level;
- const bool below = k.hi < acc.level;
- if (!above && !below) return false;
- const std::size_t e = above ? 1u : 0u;
- // The band is deliberately *not* folded into the test above. Tightening the whole condition to
- // `level +/- h` refuses every bucket lying inside the band -- a tenth of the amplitude either side
- // of the mid level is where a waveform moves fastest, so those are exactly the buckets a crossing
- // passes through -- which hands the raw walk a share of the window rather than a bucket or two per
- // crossing, and breaks the "`raw` stays at a fraction of a percent of `covered`" invariant.
- // Instead: a bucket on one side of the level can always be *binned*, and only a *side change*
- // whose position is inside the bucket is refused.
- const double top = acc.level + acc.hysteresis;
- const double bot = acc.level - acc.hysteresis;
- // Does the bucket reach past the band at all? One wholly inside it decides no side, exactly as a
- // raw sample inside it decides none, so the walk state is left alone.
- const bool reaches = above ? (k.hi > top) : (k.lo < bot);
- const bool change = reaches && acc.have_side && e != (acc.side ? 1u : 0u);
- // A bucket that *enters* a side while straddling its own threshold holds the crossing somewhere
- // inside itself, and only the raw walk can say where. One wholly past the threshold holds it at
- // the bucket's first sample, exactly, which is where the raw walk would have found it.
- const bool fully_outside = above ? (k.lo > top) : (k.hi < bot);
- // Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`,
- // which adds the same samples again. Sums added above the refusal double-counted every opened
- // bucket -- which read as `top` 0.614304 on a unit sine against the 2/pi a mean over a half then
- // gave, and which is now the sums `Top`/`Bottom` fall back to on a window with no histogram, so
- // a wrong one is a wrong answer waiting for that window.
- if (change && !fully_outside) return false;
- acc.sum[e] += k.sum;
- acc.count[e] += k.count;
- if (change) enter_side(acc, e, time_of(k.offset));
- if (reaches) {
- acc.side = e != 0;
- acc.have_side = true;
- }
- return true;
-}
MeasureFlatLevels measure_flat_levels(const MeasureStats& b, double pct) {
MeasureFlatLevels out;
@@ -375,7 +316,6 @@ int measure_level_pcts(MeasureType type, const MeasureOptions& options, double p
}
}
-
const char* measure_type_name(MeasureType type) {
switch (type) {
case MeasureType::Average: return "average";
@@ -598,5 +538,4 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* const l
return out;
}
-
} // namespace udpscope
diff --git a/src/measure.hpp b/src/measure.hpp
index ec72944..d31642a 100644
--- a/src/measure.hpp
+++ b/src/measure.hpp
@@ -349,6 +349,66 @@ bool measure_accumulate_bucket(const MeasureBucket& bucket,
const std::function& time_of,
MeasureLevel& level);
+inline void enter_side(MeasureLevel& acc, std::size_t e, double t) {
+ const std::size_t other = 1 - e;
+ if (acc.enters[e] == 0) acc.first_enter[e] = t;
+ acc.last_enter[e] = t;
+ ++acc.enters[e];
+ if (acc.open[other]) {
+ acc.width_sum[other] += t - acc.open_at[other];
+ ++acc.widths[other];
+ acc.open[other] = false;
+ }
+ acc.open_at[e] = t;
+ acc.open[e] = true;
+}
+
+inline bool measure_accumulate_bucket(const MeasureBucket& k,
+ const std::function& time_of,
+ MeasureLevel& acc) {
+ if (k.count == 0) return true;
+ // A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes
+ // the first *finite* sample of the new side, and an aggregate cannot say which that is.
+ if (k.count != k.span) return false;
+ // The *sums* need only the bare level: strictly on one side of it, so every sample decides the
+ // same bin and none is *at* it, which is what makes the fold exact rather than an approximation.
+ const bool above = k.lo > acc.level;
+ const bool below = k.hi < acc.level;
+ if (!above && !below) return false;
+ const std::size_t e = above ? 1u : 0u;
+ // The band is deliberately *not* folded into the test above. Tightening the whole condition to
+ // `level +/- h` refuses every bucket lying inside the band -- a tenth of the amplitude either side
+ // of the mid level is where a waveform moves fastest, so those are exactly the buckets a crossing
+ // passes through -- which hands the raw walk a share of the window rather than a bucket or two per
+ // crossing, and breaks the "`raw` stays at a fraction of a percent of `covered`" invariant.
+ // Instead: a bucket on one side of the level can always be *binned*, and only a *side change*
+ // whose position is inside the bucket is refused.
+ const double top = acc.level + acc.hysteresis;
+ const double bot = acc.level - acc.hysteresis;
+ // Does the bucket reach past the band at all? One wholly inside it decides no side, exactly as a
+ // raw sample inside it decides none, so the walk state is left alone.
+ const bool reaches = above ? (k.hi > top) : (k.lo < bot);
+ const bool change = reaches && acc.have_side && e != (acc.side ? 1u : 0u);
+ // A bucket that *enters* a side while straddling its own threshold holds the crossing somewhere
+ // inside itself, and only the raw walk can say where. One wholly past the threshold holds it at
+ // the bucket's first sample, exactly, which is where the raw walk would have found it.
+ const bool fully_outside = above ? (k.lo > top) : (k.hi < bot);
+ // Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`,
+ // which adds the same samples again. Sums added above the refusal double-counted every opened
+ // bucket -- which read as `top` 0.614304 on a unit sine against the 2/pi a mean over a half then
+ // gave, and which is now the sums `Top`/`Bottom` fall back to on a window with no histogram, so
+ // a wrong one is a wrong answer waiting for that window.
+ if (change && !fully_outside) return false;
+ acc.sum[e] += k.sum;
+ acc.count[e] += k.count;
+ if (change) enter_side(acc, e, time_of(k.offset));
+ if (reaches) {
+ acc.side = e != 0;
+ acc.have_side = true;
+ }
+ return true;
+}
+
/// The levels a type reads, as shares of the window's own min..max, **lower first**, or 0 when it
/// reads none (the pure statistics, answered from `MeasureStats` alone). `pct` must hold two: a
/// level type fills `pct[0]` and answers 1, `Between` fills both and answers 2.
diff --git a/src/ui.cpp b/src/ui.cpp
index c20847c..1e27c1b 100644
--- a/src/ui.cpp
+++ b/src/ui.cpp
@@ -5831,6 +5831,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURE_MS")) {
g_timeout_add(static_cast(std::atoi(env)), measure_probe, &app);
}
+ if (const char* env = g_getenv("UDPSCOPE_PROBE_LEVELS_MS")) {
+ g_timeout_add(static_cast(std::atoi(env)), levels_probe, &app);
+ }
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASUREPIN_MS")) {
g_timeout_add(static_cast(std::atoi(env)), measurepin_probe, &app);
}
diff --git a/src/ui_app.hpp b/src/ui_app.hpp
index 55c898e..3cff1b9 100644
--- a/src/ui_app.hpp
+++ b/src/ui_app.hpp
@@ -1730,6 +1730,7 @@ gboolean mathwrap_probe(gpointer user);
gboolean mathwrap_probe_wait(gpointer user);
gboolean mathwrap_probe_report(gpointer user);
gboolean measure_probe(gpointer user);
+gboolean levels_probe(gpointer user);
gboolean measurepin_probe(gpointer user);
gboolean measurepin_held_wait(gpointer user);
gboolean measurepin_pause_wait(gpointer user);
diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp
index a2e6a41..276321d 100644
--- a/src/ui_probe.cpp
+++ b/src/ui_probe.cpp
@@ -1326,6 +1326,71 @@ gboolean measure_probe(gpointer user) {
return G_SOURCE_REMOVE;
}
+/// `UDPSCOPE_PROBE_LEVELS_MS`: puts a pane of **N** measurements on screen -- one `above` per
+/// share in `UDPSCOPE_PROBE_LEVELS` (comma separated, default `50`) -- spread in turn over the
+/// first `UDPSCOPE_PROBE_LEVELS_CHANNELS` channels (default 1), and says how many *windows* and
+/// *levels* the pass then had to walk.
+///
+/// The pane's cost has three factors and a count alone names none of them: the window's samples
+/// (one fetch and one scan per **measured channel**, whatever the pane holds), the number of
+/// *distinct levels* in it (measurements that share a share share the walk), and the rows that
+/// are only labels over the two. `50,50,50,50` is four rows and one walk; `5,10,15,20` is four
+/// rows and four; and the same four shares on four signals is four windows *and* four walks.
+/// This probe is the only way to vary the three apart, which is what a report of "it is slow
+/// with many measurements" needs before it is a number.
+gboolean levels_probe(gpointer user) {
+ App* a = static_cast(user);
+ if (a->window == nullptr) return G_SOURCE_REMOVE;
+ std::vector names;
+ {
+ std::lock_guard lock(a->model->mutex());
+ for (const ChannelInfo& c : a->model->channels()) names.push_back(c.name);
+ }
+ if (names.empty()) return G_SOURCE_REMOVE;
+ const char* chan_env = g_getenv("UDPSCOPE_PROBE_LEVELS_CHANNELS");
+ const long want_channels = chan_env != nullptr ? std::atol(chan_env) : 1;
+ const std::size_t channels =
+ want_channels > 0 ? std::min(static_cast(want_channels),
+ names.size())
+ : 1u;
+ const char* list = g_getenv("UDPSCOPE_PROBE_LEVELS");
+ const std::string shares = list != nullptr && list[0] != '\0' ? list : "50";
+ std::size_t rows = 0;
+ for (std::size_t at = 0; at <= shares.size();) {
+ const std::size_t comma = shares.find(',', at);
+ const std::string field =
+ shares.substr(at, comma == std::string::npos ? std::string::npos : comma - at);
+ at = comma == std::string::npos ? shares.size() + 1u : comma + 1u;
+ char* end = nullptr;
+ const double pct = std::strtod(field.c_str(), &end);
+ if (end == field.c_str() || !(pct >= 0.0)) continue;
+ Measure m;
+ m.signal = names[rows % channels];
+ m.type = MeasureType::Above;
+ m.options.threshold_pct = pct;
+ a->measures.push_back(std::move(m));
+ ++rows;
+ }
+ rebuild_measures_pane(*a);
+ update_measures(*a, /*force=*/true, /*accumulate=*/true);
+ // What that pass walked: the level accumulators the pane deduplicated to, and the windows they
+ // hang off. Read without the model lock -- `measure_samples` is the *tick*'s own state, not the
+ // producer's.
+ std::size_t levels = 0;
+ std::size_t windows = 0;
+ for (const App::MeasureWindow& w : a->measure_samples) {
+ ++windows;
+ levels += w.pct.size();
+ }
+ std::fprintf(stderr,
+ "[levels-probe] %zu measurement(s) over %zu channel(s), shares='%s' -> %zu "
+ "window(s), %zu level walk(s)\n",
+ rows, channels, shares.c_str(), windows, levels);
+ std::fflush(stderr);
+ g_timeout_add(250, measure_trace, a);
+ return G_SOURCE_REMOVE;
+}
+
/// `UDPSCOPE_PROBE_MEASUREPIN_MS`: on a **still frame** the pane describes the frame and not the
/// viewport, so a pan or a zoom changes no number -- and on a live view the numbers still follow
/// their window.
--
2.54.0
From e14e1ee069c4818f0f9d906988d07d7847ba32df Mon Sep 17 00:00:00 2001
From: Martino Ferrari
Date: Fri, 16 Oct 2026 16:44:15 +0200
Subject: [PATCH 002/127] trigger: a phosphor display keeps the last N
acquisitions; and a Force button
Two changes in the trigger pane, landed together.
**Persistence.** Trigger mode keeps the last N published acquisitions and draws them fading toward
the background, N being a panel control (1..8, default 1) with N=1 meaning today's behaviour
exactly, so the knob is the switch. The layers are the accumulate path's own -- one layer per
(signal, age) in the row's free trace slots, named with the accumulate vocabulary so
`layer_base_name()` strips the suffix and `signal_color()` resolves the base signal -- so a layer is
not a signal: the signal list, the colours and the measurements see one row per signal.
`ScopeModel` retains the outgoing snapshot in seven atomic `shared_ptr` slots (no lock, no copy; the
snapshot already exists) and a layer's channel is resolved **by name inside the snapshot**, because
the channel list is renumbered by any structural change since that acquisition was cut.
`RowSet::sync_layers()` re-plans from the rows and the depth alone, so a latch costs no rebuild, and
`event_data_window()` skips layer slots so an older acquisition can never re-pin the axis. The
schedule is 0.5^age -- the acquisition on screen at full brightness and each older one half the
previous, independent of N, so raising the depth adds dimmer acquisitions rather than rescaling the
ones already on screen. Memory is bounded by the ring's own `--buffer-mb` rather than by the depth,
which is the bound that cannot be driven into an OOM, and the panel says when either bound bites.
This also wired up `set_trace_alpha()`, which the accumulate path was setting and nothing was
reading -- so the documented 0.35 translucency of an accumulation was opaque in fact.
**Force.** In the trigger pane beside Arm/Disarm, shown only while trigger mode is on: one
acquisition now, with the configured window. It is a synthetic edge rather than a second publisher
-- the engine opens one capture at the present moment and everything else is the ordinary latch, so
a forced acquisition is cut, pinned, framed, recorded and cached exactly like a latched one, and
the button owns no window and no publish. The pre-trigger gate is deliberately skipped (capture now
is a request for the history that exists, and the snapshot reports its own shorter pre_s), a
capture already filling is refused rather than restarted, and the state afterwards is the mode's:
Normal stays armed, Single disarms. The request is one atomic flag written under the model mutex
with `armed` -- the producer reads `armed` at the top of a packet and the request at the bottom, so
a request written between the two would be dropped -- and consumed at the end of a packet, after
the channel loop and before the pending publish.
**A defect fixed in passing, in the function Force was modelled on:** `on_arm_clicked()` passed
`TriggerConfig::channel` (an index into `channels()`) to `set_signal_enabled()` (an index into
`signals()`). With one signal acquired, arming switched on the stream's clock signal and left the
watched signal untouched -- and since a signal the panel was not showing is a change, it rebuilt
the buffer under the click, dropping the very pre-trigger window the arming, and now the Force, was
about to capture. Measured before: `0.0100 s of history behind it`; after: `2.5299 s`.
`trigger_signal_index()` is now the one resolution, used by both buttons.
---
README.md | 48 ++
docs/claude/invariants-trigger-and-pinning.md | 136 +++++-
docs/claude/probe-readings.md | 148 ++++++-
docs/claude/verification.md | 57 +++
help/C/org.udpscope.app/trigger.page | 52 ++-
src/acquisition.cpp | 32 ++
src/acquisition.hpp | 17 +
src/event_series.hpp | 23 +
src/layout_store.cpp | 5 +
src/layout_store.hpp | 4 +
src/model.cpp | 62 +++
src/model.hpp | 64 +++
src/plot_host.cpp | 213 ++++++++-
src/plot_host.hpp | 46 ++
src/trigger.cpp | 14 +
src/trigger.hpp | 30 ++
src/ui.cpp | 73 +++-
src/ui/trigger-panel.ui | 39 +-
src/ui_app.hpp | 58 +++
src/ui_layout.cpp | 13 +-
src/ui_probe.cpp | 410 ++++++++++++++++++
src/ui_trigger.cpp | 134 +++++-
22 files changed, 1652 insertions(+), 26 deletions(-)
diff --git a/README.md b/README.md
index 2007139..f466146 100644
--- a/README.md
+++ b/README.md
@@ -1214,6 +1214,30 @@ back to the *previous* event, or to blank if this was the first. No event fired,
latch counter does not move; a separate `ScopeModel::event_seq()` counts published
snapshots, and that is what the frame tick keys its framing on.
+**Force takes one acquisition now.** The trigger panel's **Force** button, beside
+Arm/Disarm, is the scope's "capture this instant": for the condition that is not coming,
+it opens a capture at the present moment with the configured pre/post window rather than
+waiting for an edge. It is deliberately *not* a second publisher — `TriggerEngine::force()`
+only puts the engine into the pending state an edge leaves it in, with the newest stored
+stamp as the event's own time, and the ordinary latch does everything else: the cut, the
+publish, the latch count, the marker at x = 0 and the framing. A forced acquisition is
+therefore framed, pinned and recorded exactly like a latched one, down to the axis being
+pinned from the snapshot's own stamps rather than from what was asked for (a request the
+ring cannot reach back to — a 100 s window at 99 % pre — comes out seconds wide, which is
+what the probe's second leg reads).
+
+Two choices are worth naming. The engine's pre-window gate is **skipped**: the history
+behind "now" is whatever the ring holds, and refusing to capture it because the ring is
+young would refuse the one thing the button is for. And a capture already filling is **not**
+restarted — it is the acquisition being asked for, and restarting it would throw away the
+post window it has collected; the click is ignored (and said so on stderr) in that state
+only. What the trigger *is* afterwards is still the mode's decision, made where it always
+was: Normal leaves it armed for the next edge, Single disarms, because one acquisition is
+what the mode means whether an edge produced it or the button. The button lives in the body
+of the pane rather than in the locked pane's heading beside Arm/Disarm: the heading's one
+control is the runtime *state* a locked pane exists to change, while a force is an action
+whose subject — the condition, the level, the pre/post split and the mode — is in the body.
+
The frame that **frames** a snapshot is the one that consumes its sequence number, not
merely the first frame to see it. The tick reads `display_mode()` before it takes the
model's mutex, while the producer publishes the snapshot inside that mutex and only lowers
@@ -1233,6 +1257,30 @@ snapshot's own stamps. That re-basing machinery already existed (it was written
frozen view and then never needed, since snapshots carry edge-relative stamps); this is
what finally uses it.
+**Persistence: the last N acquisitions, dimmer with age.** Trigger mode can keep the last
+`N` acquisitions on screen at once, each older one drawn at half the brightness of the one before
+it (1, 0.5, 0.25, 0.125, …), which is what a phosphor display does with successive sweeps: they
+are aligned on their own trigger edge, so the shape of the event repeats and whatever varies
+between acquisitions — jitter, ringing, an occasional glitch — reads as a faint ghost behind the
+current trace. Persistence in the trigger panel is the knob; `1`, the default, is
+exactly the display without it, and the range stops at 8, where the eighth layer would be 0.8 %
+ink that nothing can see. The older acquisitions are drawn *behind* the current one and are never
+pinned: the axis, the framing and every measurement still describe the event on screen.
+`N = 1` retains nothing at all — no copy, no layer — and lowering the depth frees the retained
+snapshots there and then, because a trigger that has stopped firing never reaches another publish.
+
+**And it says what it costs.** A retained acquisition is a whole window copied out of the ring, so
+the retention is bounded by two things: the depth, and **`--buffer-mb`** — persistence may hold at
+most as much again as the buffer does, which is the bound a long window on a fast stream reaches
+long before the eighth layer. The other bound is the plot's own: a layer takes a *free trace slot
+in the row that plots its signal*, layer by layer, so a row of four signals — the layout the app
+starts with — has room for one older acquisition of each, while a row per signal has room for all
+seven. A depth that the room or the budget cuts short is counted and said in a notice, rather than
+quietly drawing fewer layers than were asked for. The layers are *not* signals: they hold no
+channel, so the signal list, the colour chips, the calibration table, the layout file and every
+measurement see one signal where the plot draws several traces of it — the same rule the event
+*pane*'s accumulate view follows.
+
**The whole trigger path measures the window in stream seconds, and never in
samples.** `TriggerEngine` is fed each sample's timestamp and latches once that clock
has advanced past the post window; `publish_event()` then finds the slice covering
diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md
index 8a22ec4..885236c 100644
--- a/docs/claude/invariants-trigger-and-pinning.md
+++ b/docs/claude/invariants-trigger-and-pinning.md
@@ -27,6 +27,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
already says "stream (trigger off)" and there is no engine to arm, and with the pane open the body's
button is the one on screen — **two arm buttons at once would be two controls for one state**, which
is why the probe reads the body's allocation as `-1` while the compact one is up.
+ **The same function states the Force button beside the arm one, and the Force button is in the body
+ only.** Its sensitivity is the same `enabled` (a trigger that is off has no engine to force) and it
+ is stated in `sync_arm_buttons()` for the same reason the pair above is stated there — one place, so
+ nothing can drift — but it is *not* armed-gated: in Single mode the previous acquisition leaves the
+ engine disarmed, which is exactly when a user reaches for Force again, so a button that greyed out
+ with the engine's armed state would be unavailable for the one thing it is for. It stays in the body
+ because the heading's single control is the runtime *state* a locked pane exists to change, while a
+ force is an action whose subject — the condition, the level, the pre/post split and the mode that
+ decides whether one acquisition is all of it — is in the body; a second Force in the heading would
+ be a second control for one action whose settings are off screen.
- **A widget's allocation is not readable in the tick it is first shown.** The compact arm control is
created hidden and shown for the first time inside a signal handler, so `gtk_widget_get_width()` in
that same tick reads `0` while `gtk_widget_is_visible()` already says `1` — and its sibling's stale
@@ -36,7 +46,15 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
has a `compact_arm_step()` of its own; and the control to compare against must be measured with
`is_visible()` or not at all.
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
- signal nobody acquires can never fire, so selecting/arming enables it.
+ signal nobody acquires can never fire, so selecting/arming enables it. **And the signal is resolved
+ *through* the channel, never taken as one.** `TriggerConfig::channel` indexes `channels()` (the
+ acquired ones) while `ScopeModel::set_signal_enabled()` indexes the stream's `signals()`, and the
+ two are not the same index: with one signal acquired, channel 0 *is* that signal while signal 0 is
+ the stream's own clock. Passing one for the other switched on the time base, left the signal the
+ trigger watches exactly as it was — and, because a signal the panel was not showing *is* a change,
+ rebuilt the buffer under the click and dropped its history, which is precisely the pre-trigger
+ window an arming, and now a force, was about to capture. `trigger_signal_index()` is the one
+ resolution and both buttons go through 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
@@ -201,6 +219,42 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
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()`.
+- **Force is a synthetic edge, and the latch is still the only publisher.** The trigger panel's
+ Force button opens one capture at the *present* moment through `TriggerEngine::force()`: the
+ newest stored stamp becomes the event's own time and the engine enters the same pending state an
+ edge leaves it in, so the ordinary latch cuts, publishes, counts and frames the acquisition. A
+ forced acquisition therefore obeys every rule a latched one does — the axis pinned from the
+ snapshot's own stamps, a `pre_s` shorter than the request when the ring does not reach back that
+ far, and the frame that frames it being the one that consumes its `event_seq()` — and the button
+ owns no window, no publish and no framing of its own: **the event it publishes is cut by the same
+ code as a latched one.** Two things it deliberately does *not* do, both of them choices:
+ - **It skips the pre-window gate.** `force()` does not test `event_t - oldest_s_ >= pre_seconds_`:
+ "capture now" is a request for the history that *exists*, and refusing it because the ring is
+ young would refuse the one thing the button is for. The snapshot reports its own `pre_s`, and the
+ pin follows it — which is also how the pin gets *read*: with a request the ring cannot satisfy (a
+ 100 s window at 99 % pre) the axis must come out a few seconds wide, where a pin stated from the
+ request would come out a hundred.
+ - **It does not restart a capture already filling.** That capture *is* the acquisition being asked
+ for: the display is already showing it fill (`live(capturing)`), and restarting it would throw
+ away the post window it has collected and move the marker to the press. The click is refused, and
+ named on stderr, rather than silently reinterpreted.
+ What the trigger *is* afterwards is still the mode's decision, and it is made where it always was —
+ in the latch: Normal leaves the engine armed with the latch's hold-off, Single disarms it. One
+ acquisition, whether an edge produced it or the button.
+- **The Force request is written under the model lock and consumed at the end of a packet.** It is
+ one atomic flag on `Acquisition` (`request_force()`), read with `exchange` after the channel loop
+ and before the `trigger_pending()`/`capture_edge()` publish — after the loop because the cut
+ `publish_event()` is about to make clamps to every value ring's `produced()`, so the newest sample
+ of every channel has to be in by then, and before the publish so the capture the click opened is
+ reported by the same packet that opened it. The *write* belongs under `ScopeModel::mutex()` and in
+ the same critical section as the `armed` flag the click also sets: the producer holds that lock for
+ a whole packet, reading the panel's `armed` at the top of one and the request at the bottom, so a
+ request written in between would be read by a packet whose engine was still disarmed and then
+ dropped — and dropped is right, because a momentary request must not be replayed later on a trigger
+ the user has stopped watching. With **no channel resolved** (no signal acquired) the click is
+ refused in the handler and not left to the producer: `on_data()` returns at once for a model with
+ no *streamed* channel, so a request parked behind that return would fire on the first signal
+ switched on afterwards.
- **The trigger engine is not fed a reserved hole's samples.** A NaN comparison is false anyway,
so the engine would not latch; but the real reason the samples are withheld is the other one:
feeding them would advance the hold-off clock across time nothing was sampled in. The probe
@@ -242,6 +296,86 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
moved under the stream; a frame accepted into a model with no streamed channel) and for why the
relative clock is accumulated in seconds.
+- **Persistence is the older acquisitions drawn *over* the current one, and "a layer is not a
+ signal" is what keeps them from becoming one.** Trigger mode may keep the last `N`
+ acquisitions -- `TriggerConfig::persist_depth`, the panel's *persistence* spin, 1 = the event
+ on screen alone -- and draw the older ones dimmer, which is the whole feature. The machinery is
+ the layer machinery the accumulate path already has: one **layer** per (signal, age), in the
+ same trace slot the accumulate path puts its own extra events in, named with the same suffix
+ vocabulary (` (A)`, ` (B)`, ` #4`), so `layer_base_name()` gives the base signal back and the
+ signal list, the colour resolver, the measurements and the layout writer all keep seeing **one**
+ signal where the plot draws several traces of it. Four consequences, each of which is a rule:
+ * **A layer slot holds no channel.** `RowSet::bind()` resolves a layer's *base* signal (it must
+ still be acquired -- switching a signal off takes its traces off every plot, layers included)
+ and then finds the channel **by name inside the snapshot** (`EventSnapshot::channel_names`),
+ because the channel list is renumbered by any structural change since that acquisition was
+ cut -- the rule `TriggerEngine` already follows, for the same reason. It leaves
+ `Row::channel[]` at `-1`, which is what makes every reader that asks "is this slot a trace of
+ a signal" skip it: the trace counts, the cursors pane's `mix` mask, the measurement marks, the
+ dump's per-slot lines, `collect_layout()`. A persistence layer is therefore invisible to the
+ signal list, the calibration table and the measurements by construction and not by a check;
+ the *measurements* go on describing the current frame because they resolve a channel from the
+ signal name the user configured, and `sine (A)` is not a signal anybody can configure.
+ * **Its brightness is a property of the slot, and it is applied where the trace is created.**
+ `Row::slot_alpha` is `RowSet::persist_alpha(age)` -- **half per acquisition**, so the schedule
+ is 1, 0.5, 0.25, 0.125, ... and the seventh layer is 0.8 %, i.e. gone; it does *not* depend on
+ the depth, so raising N adds dimmer acquisitions instead of restating the ones already on
+ screen. `install()` mixes `slot_alpha * trace_alpha_` into the trace's colour, because a
+ `Plot` fixes a trace's colour **at `add_trace()`** and a brightness nobody can set later is a
+ brightness that has to be a rebuild. Two things follow: a per-layer alpha is impossible
+ without a per-slot one, and **`set_trace_alpha()` is finally read** -- it was set by the
+ accumulate path and read by nothing, so an accumulation's "translucent, so agreement is
+ brightness" was opaque in fact while the docs claimed otherwise.
+ * **The dimmest trace is created first.** A `Plot` draws its traces in the order they were
+ created and cannot reorder them, so `install()` creates them sorted by brightness and the
+ *newest* acquisition is the ink on top. A layer drawn over the event would take its colour out
+ of the event's own trace wherever the two agree -- the opposite of what a persistence display
+ shows.
+ * **Every slot the plan does not fill is freed, and the plan does not depend on the data.**
+ `RowSet::sync_layers()` (called from the tick, before the frame's `bind()`) re-plans one layer
+ per (signal, age) into the row's free slots, *layer by layer* -- age 1 for every signal first,
+ then age 2, as the accumulate path does -- so a row that runs out of room draws the sharper
+ layer for each of its signals and stops. The plan is a function of the rows, the depth and
+ nothing else, so a **latch costs no rebuild**: the slot names and ages stay put and only the
+ snapshot behind them changes.
+- **An older acquisition is *drawn*, and it may not move anything.** The current event is the one
+ the axis is pinned to, and persistence must not disturb that, so `RowSet::event_data_window()`
+ -- the union the pin is re-stated from every frame while an event is held -- **skips layer
+ slots**: a wider older acquisition would otherwise re-pin the axis, and with it the window a
+ pinned measurement reads, which is a *measurement* moving because a display option was turned
+ up. The layers are bound only in `Held` and `Capturing` (a live view has no current event to be
+ the newest of them, a `Waiting` display is blank by design, and a pause view's stamps are on the
+ live window's clock, not on an edge): in `Waiting` and `Live` the layer slots are planned but
+ bound to nothing, and a trace with no series is not drawn and not listed in the legend. The one
+ thing they do share with the signal is the **row's y axis**, so the y auto-fit sees them -- a
+ y axis that ignored the traces it draws would clip the excursion the display exists to show.
+- **A retained acquisition is a whole window copy, so the depth is capped by bytes and by what a
+ row can draw.** `publish_event()` moves the outgoing `event()` into the retention before it
+ publishes the new one: `ScopeModel::persist_event(age)` is the event `age` latches back, one
+ atomic `shared_ptr` slot per age, read by the tick without a lock exactly as `event()` is.
+ `snapshot_bytes()` is the honest cost of one -- both stamp arrays, every channel's `f32` samples
+ and the LOD pyramids built for them, i.e. ~4.5 bytes per sample per channel plus 8 per sample of
+ clock -- and `prune_persist_locked()` cuts the set to **both** bounds: the depth, and
+ `persist_budget_bytes()`, which is the ring's own `--buffer-mb`, because persistence is a copy
+ *of* what the rings hold and holding as much again is the honest ceiling for a display of it.
+ `kMaxPersistDepth` is 8 for the different reason the panel needs one: the layer there is already
+ invisible (0.8 %) and seven is all the free slots a one-signal row has. The depth is not the only
+ thing that limits what is *drawn* -- a row of `S` signals with 8 slots shows
+ `floor((8 - S) / S)` ages of each, so the default four-signal row shows one older acquisition
+ and a row per signal shows all seven. The two shortfalls are **separate readings**, and a caller
+ that conflated them would report a shrunken display as complete: a *slot* shortfall is
+ `placed < want` (what `RowSet::sync_layers()` returns against what the depth asked for), while a
+ *budget* shortfall is a `kept` below the depth plus the count `persist_dropped()` carries -- and in
+ that case the layer slots **exist and draw nothing at all**, so `layers=`/`placed=` alone say
+ nothing is wrong. The panel's notice therefore names both numbers -- `D of W layers fit, K of A
+ older acquisitions retained` -- because a user who asked for eight layers and sees two has no other
+ way to tell "the display is deliberately shallower" from "the feature is broken".
+- **Depth 1 is not a mode, it is the *absence* of one.** `persist_depth = 1` retains nothing at
+ all -- no copy, no layer, no rebuild -- and `set_persist_depth()` prunes the set **at once**
+ rather than at the next latch, because lowering the depth is a user asking to stop paying, and a
+ trigger that has stopped firing never reaches another publish. A depth change is therefore also
+ how the memory comes back.
+
## A latch is also a file
The pane that lists the cache is **on demand**: the toolbar's event-history button and the pane are
diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md
index e5998f2..1992e1a 100644
--- a/docs/claude/probe-readings.md
+++ b/docs/claude/probe-readings.md
@@ -94,6 +94,50 @@ Two lines of the self-check output are assertions in disguise:
`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.
+- `[force-probe] button off: sensitive=0 visible=1 w=X | on: sensitive=1 w=X | arm w=Y |
+ label_found=1` (stderr, `UDPSCOPE_PROBE_FORCE_MS`) — the Force button's stated state, read on both
+ sides of the trigger toggle. **`sensitive=0` with the trigger off and `1` with it on** is the whole
+ rule (`sync_arm_buttons()` states it from the toggle, and a trigger that is off has no engine to
+ force); `visible=1` in both says it is a *body* control and not a compact-heading one, which is why
+ the off reading must not be `visible=0`; `w` is its allocation a frame after the state it belongs
+ to, and `arm w=Y` the button beside it, so a `w=0` next to a non-zero `Y` is the "width read in the
+ tick the control was stated" trap rather than a collapsed button. `label_found=1` is the button the
+ window shows under the word **Force** *being* the panel's own widget.
+- `[force-probe] leg A|B want=[-a..b] during=[-c..d] snap=[-e..f] n=N axis=[..] latches=P->Q
+ display=.. matches_request=D pin_is_snapshot=P` (stderr, `UDPSCOPE_PROBE_FORCE_MS`) — one line per
+ leg, taken after the latch the press produced. **`latches` must move by exactly one and `display`
+ must read `event(frozen)`**: the probe sets the condition to a level the mock never reaches (9 V
+ against ±1 V signals), so the engine cannot latch on its own and a forced acquisition is the only
+ thing that *can* move the count — that is also why this probe must run alone, since any other probe
+ that arms a reachable condition would add latches of its own. `want` is the pre/post split the
+ panel was set to at the press, taken from the producer's own `post_span_s()`; `snap` is what the
+ snapshot holds (`[-pre_s..+post_s]`); `during` is the axis while the forced capture was *filling*,
+ which is the *requested* window — a capture has no snapshot to frame from yet. The two legs differ
+ in which of those can agree:
+ - **Leg A (2 s window at 50 % pre, on a ring that has filled)**: `matches_request=1`, i.e.
+ `snap ≈ want` to within a stored interval, and `n` the span times the stream's real rate plus
+ one (`20001` at 10 kS/s for `[-1..+1]`, exactly as the latch line demands).
+ - **Leg B (100 s window at 99 % pre, which no run this short can reach back to)**: the request is
+ 99 s of history and the ring holds seconds, so `matches_request=0` **on purpose** and
+ `snap ≈ [-history..+1]`. That is the leg that makes **`pin_is_snapshot=1`** a reading rather than
+ a coincidence: `axis` must equal `[-snap.pre..+snap.post]` (a few seconds wide) while `during`
+ still reads the requested `[-99..+1]`. A pin stated from the *request* — the
+ `hold_pinned_window()`/`pin_event_window()` mistake this probe exists for — reads
+ `axis=[-99.0000..1.0000]` here, i.e. `pin_is_snapshot=0` with the frozen picture a fraction of
+ its own axis.
+ A forced acquisition is an ordinary latch in every other respect, so the `trigger: edge at X ...
+ want [-a..b] got [-c..d]` line appears beside each leg with the *force's* edge as its `X`, and that
+ line's own rule (`got == want`, `n` exact) applies to it unchanged. The line
+ `[udpscope] trigger: forced acquisition, edge at E -- want [-a..b] s, H s of history behind it,
+ mode=M` is the producer accepting the request: `H` is the ring's retained span at the press, which
+ is what caps the pre side in leg B, and `mode=single (disarms at the latch)` says the one
+ acquisition the mode means, so an armed state that reads `0` afterwards is right and not a lost
+ click. `force ignored (enabled=.. armed=.. ch=.. pending=..)` is the refusal — a trigger that is
+ off, a channel that is still `-1`, or **a capture already filling**, which is deliberately *not*
+ restarted (it is the acquisition being asked for); in the `ch=-1` case the click is refused in the
+ handler instead (`force refused -- no signal is being acquired to trigger on`), because a request
+ left to the producer would sit behind `on_data()`'s no-streamed-channel early return and fire on
+ the first signal switched on afterwards.
- `[trigrenum-probe] window=W event_span=S pre_pct=P post=O holdoff=H level=L edge=E mode=M
enabled=1 armed=1 ch=C channels=N`, then `capture open: t0=T ch=C -- enabling signal 1 now`,
`after enable: trigger ch=C' channels=N'`, and `latched t0=T' opened on T t0_held=D` (stderr,
@@ -598,7 +642,12 @@ Two lines of the self-check output are assertions in disguise:
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).
+ mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise). A **persistence
+ layer** would appear in that line as a suffixed name (`sine (A)`, `sine #4`) *while trigger mode
+ is on* — it is not the user's arrangement and is never saved (`collect_layout()` writes a layer
+ slot as empty) — and it is absent from the dump, which is always taken after the exit probe's
+ go-live has cleared the layers. `[persist-probe]` is therefore the only run-in-progress reading of
+ them.
- `[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
@@ -1323,7 +1372,7 @@ popover row reads `failed: ` — one stderr line, no retry storm again
## `[defaults]` and `[configs]`
```
-[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01
+[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01 0 0 1
[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25
[configs] before=default active=default
[configs] save-as=1 names=probe,default active=probe
@@ -1339,7 +1388,7 @@ sets them to **7.5 and 250** before saving, so a key that never reached the file
| --- | --- | --- |
| `[defaults] calibrate:` | `1 0 model=1 0` | an unseeded spin button: `gtk_spin_button_new_with_range()` leaves the value at the range minimum, so the offset opened at -1e12 and Apply wrote it |
| `[defaults] calibrated:` | `2.5 -1.25 model=2.5 -1.25` — the dialog and the model agreeing after a calibration was set | a dialog that opens at the identity while the trace is drawn through a gain: pressing Apply "confirms" the calibration away. The two halves are printed so the assertion is that they *match*, not that either is a particular number |
-| `[defaults] trigger:` | `0.001 0.002 0 0.001 0 50 0.01` | the same, per trigger field; a level of `-1e12` is a number nobody chose |
+| `[defaults] trigger:` | `0.001 0.002 0 0.001 0 50 0.01 0 0 1` | the same, per trigger field — the ten spins in tree order: width, width2, level2, timeout, level, pre-trigger, hold-off, noise band, HF reject, persistence; a level of `-1e12` is a number nobody chose, and the trailing `1` is the persistence depth, whose default *is* the feature off |
| `[configs] names` | gains `probe`, and loses it after `forget` | a save that writes somewhere the listing does not read, or a delete that leaves the file |
| `[configs] active` | `probe` after the save-as | the `active` file not written, so the next start opens the wrong configuration |
| `[configs] round trip` | `read=1`, the note verbatim, `buffer_mb` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with |
@@ -1850,7 +1899,7 @@ through one row type, for the reason in `invariants-gtk-and-chrome.md`. The form
## `trigger panel:`
```
-[udpscope] trigger panel: boxes=15 controls_inside=15/15 search_widgets=4/4 search_inside=4/4
+[udpscope] trigger panel: boxes=17 controls_inside=16/16 search_widgets=4/4 search_inside=4/4
```
The trigger panel's frame, read from the window: **the `.ui`'s own panel** (`trigger_body`), not the box
@@ -1858,18 +1907,101 @@ this app wraps it in — the heading row and the locked summary are the code's a
*wrapper* made this line report `boxes=2 … search_inside=0/4` the moment the heading moved out of the file.
That is the failure this line is for, so it is worth knowing it can happen to itself. `boxes` is the row
boxes the `.ui` holds, `controls_inside`
-counts the fifteen controls the code built and then appended into those rows -- the last two being the
-noise band and the HF reject, whose rows arrived with the noise-rejection work, `search_widgets` is the four
+counts the sixteen controls the code built and then appended into those rows -- the last three being the
+noise band and the HF reject, whose rows arrived with the noise-rejection work, and the persistence depth
+whose row arrived with the persistence display (the Arm/Force pair is a row of its own too, so `boxes` counts
+it while `controls_inside` does not: the pair is a *button* row, not a value) -- `search_widgets` is the four
search buttons having resolved by id, and `search_inside` is those four being inside the block the expander
opens onto. A control built and never placed -- an id that drifted between the file and the code -- reads as
-`controls_inside` below 15 while every other line still looks right, which is the panel that has a field
+`controls_inside` below 16 while every other line still looks right, which is the panel that has a field
missing.
The search walk starts at `gtk_expander_get_child()`, not at the panel: an expander's child is not in the
widget-parent chain at all, so walking up from a search button reaches no ancestor of the panel (see
-`invariants-gtk-and-chrome.md`). `[defaults] trigger:` prints the same panel's seven spins in tree order, so
+`invariants-gtk-and-chrome.md`). `[defaults] trigger:` prints the same panel's ten spins in tree order, so
the two lines together read what the file gave and what the code put in it.
+## `[persist-probe]` and `persistence:`
+
+```
+[persist-probe] held depth=4 kept=3 bytes=20998152 budget=209715200 dropped=0 signals=5 channels=4 layers=12 want=12 placed=12 alphas=[0.1250,0.2500,0.5000] ages=[3,2,1] row0=[1:'sine (A)'@0.5000 2:'sine (B)'@0.2500 3:'sine #4'@0.1250] drawn=12
+[persist-probe] held axis=[-0.2500..0.2500] pin=[-0.2500..0.2500] snap=[-0.2500..0.2500] n=250001 ev=[-0.2500..0.2500] ev_is_snap=1 pin_is_snap=1
+[udpscope] persistence: depth=4 kept=3 bytes=20998152 budget=209715200 dropped=0 layers=0 want=0 placed=0 schedule=[0.1250,0.2500,0.5000] alphas=[]
+```
+
+The persistence display: the last `depth` triggered acquisitions drawn behind the current one, each older one
+at half the previous one's brightness. Run with one signal per row and a 0.5 s window against the mock at
+500 kS/s, which is what the two lines above are.
+
+**`alphas=` is the assertion, oldest first**: `0.5000,0.2500,0.1250` — halving, with `ages=[3,2,1]` matching
+one for one. A flat, ascending, or age-gapped list is the decay not being applied or applied backwards (the
+rule is in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)). `row0=` is the same
+schedule read per slot, with each layer's name — `sine (A)` one acquisition back, `sine (B)` two, `sine #4`
+three, the accumulate path's own suffix vocabulary. `layers=` is the layer slots that exist,
+`want=` the depth's whole demand across the rows, `placed=` what fitted: **the three agreeing is a display
+that shows every age it was asked for**, and `placed < want` is a layout with no free trace slot left (a row
+of `S` signals shows `floor((8 - S) / S)` ages of each, so the default four-signal row shows one).
+
+**`kept`/`bytes`/`dropped` are the *retention*, and they bound what those slots can draw.** `kept=3` of the
+3 asked at `bytes=20998152` (21 MB: three acquisitions of a 0.5 s window at 500 kS/s over four channels,
+~7 MB each) against `budget=209715200`, the ring's own `--buffer-mb`. `drawn=` is how many layer slots are
+actually *bound* to a retained acquisition, and it is a different number from `placed=` exactly when the
+budget bit — which is the run to take next, on the fast mock with a long window:
+
+```
+[persist-probe] settled: kept=1 of 7 after 270 polls
+[persist-probe] held depth=8 kept=1 bytes=72798888 budget=209715200 dropped=0 signals=5 channels=4 layers=28 want=28 placed=28 alphas=[0.0078,0.0156,0.0312,0.0625,0.1250,0.2500,0.5000] ages=[7,6,5,4,3,2,1] row0=[1:'sine (A)'@0.5000 … 7:'sine #8'@0.0078] drawn=4
+[persist-probe] held axis=[-1.0000..1.0000] pin=[-1.0000..1.0000] snap=[-1.0000..1.0000] n=2600000 ev=[-1.0000..1.0000] ev_is_snap=1 pin_is_snap=1
+[udpscope] persistence: depth=8 kept=2 bytes=145598128 budget=209715200 dropped=2 layers=0 want=0 placed=0 schedule=[0.0078,0.0156,0.0312,0.0625,0.1250,0.2500,0.5000] alphas=[]
+```
+
+`UDPSCOPE_PROBE_PERSIST_DEPTH=8 UDPSCOPE_PROBE_PERSIST_WINDOW=2` against the mock at 1.3 MS/s: one retained
+acquisition is 72.8 MB, so the 200 MB budget keeps two and drops two — `kept` below the depth, `dropped`
+non-zero, and **`drawn=4` against `placed=28`**: the layer slots exist and the oldest seven ages per row draw
+nothing at all. That is the memory bound as a reading, and `settled:` is the wait saying so (the retention
+can never reach the depth, so the probe reports what stopped growing instead of waiting out its cap — its
+`kept` can therefore be mid-fill, which is why the *dump's* line below is the settled one). The
+in-doubling of `alphas=` there is the same rule at full depth: 1/128 at the eighth, which is why the depth
+stops at 8. Do not read this run's `frame cost:` as persistence's: a 2 s window at 1.3 MS/s costs ~90 ms a
+latch in `publish cost:` on its own, with or without persistence.
+
+**`ev_is_snap` and `pin_is_snap` must be 1**, and that is the rule about the past: the pin the held event's
+axis is re-stated from (`event_data_window()`) is the *snapshot's* own window, so the older acquisitions —
+which are bound event series on the same rows — moved neither the axis nor the framing; both runs above read
+`axis = pin = snap = [-0.2500..0.2500]` with `n=250001`. They are read on a frame that is *bound* (the probe
+waits for one), because a latch landing mid-frame leaves the mode `Held` while the rows still hold the live
+ring's bindings, and a reading taken there says nothing about either.
+
+**And `N = 1` is the display without the feature**, which is the same command at
+`UDPSCOPE_PROBE_PERSIST_DEPTH=1`:
+
+```
+[persist-probe] held depth=1 kept=0 bytes=0 budget=209715200 dropped=0 signals=5 channels=4 layers=0 want=0 placed=0 alphas=[] ages=[] row0=[] drawn=0
+[persist-probe] held axis=[-0.2500..0.2500] pin=[-0.2500..0.2500] snap=[-0.2500..0.2500] n=250001 ev=[-0.2500..0.2500] ev_is_snap=1 pin_is_snap=1
+```
+
+Nothing retained, nothing planned, nothing bound, no bytes — and the framing line identical field for field,
+which is what "the same display as before the feature existed" means as a reading. **That run is also the
+baseline for the exit dump's `frame cost:` line**, because the tick mean is a mean over the whole run and the
+depth is raised at `UDPSCOPE_PROBE_PERSIST_MS` (2.5 s of 16 s), so the difference between the pair is a
+*lower bound* on what the layers cost. At the scale above (0.5 s window, 500 kS/s, four rows, one signal
+each):
+
+| run | tick mean | draw mean | paint mean | frames |
+|---|---|---|---|---|
+| `..._PERSIST_DEPTH=1` | `0.82 ms` | `11.10 ms` | `15.96 ms` | 936 |
+| `..._PERSIST_DEPTH=4` | `1.18 ms` | `28.92 ms` | `34.60 ms` | 470 |
+
+Thirteen extra traces across the four rows (four signals plus nine layers, the twelfth layer's signal having
+no free slot in its row) for **+17.8 ms of draw a frame**, i.e. ~2.6× the ink of the same plot without
+persistence; `tick` moves with it by a few tenths, mostly because there are half as many frames to average.
+
+`[udpscope] persistence:` is the same state at the end of the run, and the one line the *standard* self-check
+prints without a probe: `depth`, the retention (`kept`/`bytes`/`budget`/`dropped`), the plan
+(`layers`/`want`/`placed`, 0 here because the exit probe's go-live has already cleared the layers —
+`layers=0` in that line is correct, not a defect), `schedule=` the alphas the depth asks for oldest first
+(the rule; the probe's `alphas=` is what the rows were built with), and `alphas=` for row 0.
+
## `signals panel:`
```
diff --git a/docs/claude/verification.md b/docs/claude/verification.md
index 1823012..5c10705 100644
--- a/docs/claude/verification.md
+++ b/docs/claude/verification.md
@@ -57,6 +57,63 @@ precedent, not an exception.
`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_FORCE_MS` — the trigger panel's **Force** button: one acquisition at the present
+ moment, with the pre/post window the panel is set to, without waiting for the condition. It is a
+ chain, not an instant — `MS` is when it *starts*: it forces trigger mode **off** through the toggle
+ (a session file remembers the trigger, so the off state is stated rather than assumed) and reads the
+ button's stated state 300 ms later; it then sets a level no mock signal reaches (9 V), a 2 s window
+ at 50 % pre, acquires the mock's `sine` and turns the trigger on; it presses Force 2.5 s after that
+ (the window write re-plans the buffer and clears the history, so the ring needs the time to fill
+ past the 1 s leg A asks for); it presses a second time on a 100 s window at 99 % pre — a request no
+ run this short can satisfy — 400 ms after the first latch. **Run it alone, against the plain mock**,
+ with time for both legs:
+
+ ```sh
+ python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 &
+ sleep 1.5
+ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 UDPSCOPE_PROBE_FORCE_MS=3000 \
+ ./build/udpscope --server 127.0.0.1 --port 8114 2>&1 | \
+ grep -E 'force-probe|trigger:'
+ ```
+
+ Being alone is load-bearing twice: the level is chosen so the engine *cannot* latch on its own, so
+ a latch count that moves is the force's and nothing else's — any other probe that arms a reachable
+ condition adds latches of its own — and `UDPSCOPE_ENABLE_ALL_MS` before it would acquire four
+ signals, which changes the level's default range and the plan. Two lines per pass are in
+ [probe-readings.md](probe-readings.md); **`latches=P->Q` with `Q == P + 1`, `display=event(frozen)`,
+ `matches_request=1` in leg A, `pin_is_snapshot=1` in both** are the assertions, and leg B's
+ `matches_request=0` is *expected* — a ring that cannot reach back 99 s is the only state where
+ "pinned to the snapshot, not to the request" is a reading rather than a coincidence.
+- `UDPSCOPE_PROBE_PERSIST_MS` (+ `UDPSCOPE_PROBE_PERSIST_DEPTH`, default 4, 1..8) — the **persistence
+ display**: trigger mode keeps the last N acquisitions on screen, each older one drawn at half the
+ previous one's brightness. It enables the signals itself (one call, as a restored session does) and
+ arranges one signal per row — the arrangement the app gives an opened file, and the one with room
+ for every age — sets the depth **through the panel's own spin**, arms the trigger on the mock's
+ `square` and polls until the retention is full: an event on screen and as many older ones kept as
+ the depth asks for. The depth is *left* where it put it, so the run's own tail is the display at
+ that depth:
+
+ ```sh
+ python3 tools/mock_udpstreamer.py --port 9541 --rate 500000 --batch 1000 &
+ PY=$!; sleep 0.5
+ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=16000 \
+ UDPSCOPE_PROBE_PERSIST_MS=2500 UDPSCOPE_PROBE_PERSIST_DEPTH=4 \
+ ./build/udpscope --server 127.0.0.1 --port 9541 2>&1 | grep -E 'persist-probe|persistence:|layout:'
+ kill $PY
+ ```
+
+ **Not with `UDPSCOPE_ENABLE_ALL_MS`**: that probe ends by switching a signal off two seconds later,
+ which takes the row that plotted it — and its layers with it — out of the middle of the measurement.
+
+ **Read it as a pair of runs**: the same command at `..._PERSIST_DEPTH=1` is the display *without*
+ the feature, so its `kept=0`, `layers=0`, `bytes=0` and its `layout:` line holding only the signals
+ is the "N = 1 is exactly the old behaviour" reading, and it is also the run the exit dump's
+ `frame cost:` line is compared against — the tick/draw means are means over the whole run, and the
+ depth is raised at `MS`, so the difference is a *lower bound* on what the layers cost. **`ev_is_snap`
+ and `pin_is_snap` must be 1 in both runs**: the older acquisitions are drawn and never pinned, so
+ the axis, the framing and the measurements still describe the current event. `alphas=` is the decay
+ schedule in use, oldest first — halves, and `1/2^age` at that. Both lines and the two exit-dump
+ lines are in [probe-readings.md](probe-readings.md).
- `UDPSCOPE_PROBE_TRIGRENUM_MS` — the trigger's capture must survive a **renumbering of its own
channel**. It sets the reported geometry through the real widgets (1 s window, 10 % pre share,
hold-off from `--holdoff`) on the mock's *square* alone, polls for a capture to open, and then
diff --git a/help/C/org.udpscope.app/trigger.page b/help/C/org.udpscope.app/trigger.page
index 1bcf3cd..c50903d 100644
--- a/help/C/org.udpscope.app/trigger.page
+++ b/help/C/org.udpscope.app/trigger.page
@@ -5,7 +5,7 @@
- The trigger's four shapes, arming and hold-off, the event cache with its replay,
+ The trigger's four shapes, arming, forcing an acquisition and hold-off, the event cache with its replay,
compare and accumulate, and searching the buffer for a condition.Martino Ferrari
@@ -35,6 +35,17 @@
offered only while trigger mode is on, because a trigger that is off has no engine to
arm.
+
+
Force takes one acquisition now, with the event window above,
+ without waiting for the condition to hold — for when the event you are waiting for is not
+ coming and you want to see what the signal is doing this instant. The capture runs out its
+ post-trigger share and then freezes, exactly like a latch: the marker sits at the instant
+ you pressed. In Normal mode the trigger goes back to waiting afterwards; in
+ Single mode it disarms, because one acquisition is the whole of what that mode
+ means either way. It needs a signal to be acquired — the trigger cannot watch one that is
+ switched off — and pressing it while a capture is already filling leaves that capture
+ alone, since it is the acquisition you asked for.
+
Search the buffer is at the foot of the same panel.
@@ -82,6 +93,40 @@
offers a pulse width to a window trigger.
+
+ Persistence: keeping the last few acquisitions
+
Persistence decides how many of the most recent triggered acquisitions stay on
+ screen at once. It is 1 by default, which is the event alone — the display you get
+ when you ask for no persistence — and it can be raised to 8. Each older acquisition is drawn
+ at half the brightness of the one before it (1, 0.5, 0.25, 0.125, …), and they are all aligned
+ on their own trigger edge, so the pre-trigger part of the event lines up from acquisition to
+ acquisition and whatever changes between them — jitter, ringing, the occasional glitch — shows
+ as a faint ghost behind the current trace. That is what a phosphor display does with
+ successive sweeps.
+
+
The older acquisitions are drawn behind the current one and nothing else changes
+ because of them: the axis still frames the event on screen, the trigger marker still marks
+ its edge, and the measurements still describe the current frame. Lowering
+ Persistence back to 1 remembers nothing and frees the retained acquisitions
+ immediately.
+
+
Two things bound how many are drawn, and the panel tells you when one of them bites.
+
+
Room in the plot. Each older acquisition is an extra trace in the row that
+ plots its signal, so it needs a free trace slot there. A row holding four signals — the
+ layout the app starts with — has room for one older acquisition of each of them; a row per
+ signal has room for all seven.
+
Memory. A retained acquisition is a whole window copied out of the ring,
+ so persistence may hold at most as much again as the buffer (--buffer-mb) does.
+ On a long window at a high sample rate that bound is reached before the eighth layer.
+
+
+
If a depth cannot be shown in full, a notice says how many layers fit, and the panel's
+ tooltip carries the same rule. Persistence is a display of what has already been captured: it
+ changes neither what is acquired nor what is measured, and it does nothing while the trigger
+ is off.
+
+
The event cache, replay and compare
Every triggered acquisition is kept. Latches are written to the event cache
@@ -178,7 +223,10 @@
by live data.
The first edge after arming is only accepted once the history reaches back a
full pre-trigger window, so a capture may legitimately be shorter than you asked for.
- The axis is pinned to what the capture actually got.
+ The axis is pinned to what the capture actually got. Force is the one
+ acquisition that skips that gate — it captures the history that exists, because "now"
+ is the whole of what you asked for — so on a ring that has just started filling its
+ left side is short even though the axis still shows only what it got.
Disarming during a capture publishes what the capture got to, so the
picture on screen at that moment stays on screen. No event fired, so the event count
does not move.
diff --git a/src/acquisition.cpp b/src/acquisition.cpp
index 51c21a1..a00dcb8 100644
--- a/src/acquisition.cpp
+++ b/src/acquisition.cpp
@@ -1051,6 +1051,38 @@ void Acquisition::on_data(const udps_frame_t* frame) {
}
last_stamp_ = last_stamp_used;
+ // A Force pressed since the last packet: one acquisition, at the present moment, without waiting
+ // for the condition to hold. Read *here* -- the end of the packet, past the channel loop -- for
+ // the reason the latch itself is deferred out of that loop: `last_stamp_` is the newest stored
+ // sample by now, so the capture opens on the moment the user pressed, and every channel has
+ // pushed that sample, so the cut the latch goes on to make cannot clamp it away
+ // (`publish_event()` clamps to each value ring's `produced()`).
+ //
+ // It opens the capture and nothing else. The latch below -- reached through `engine.process()`
+ // by an edge, through `engine.force()` here -- is what cuts the window, bumps the latch count,
+ // sets the trigger time and applies the mode's re-arm rule, so a forced acquisition is framed,
+ // pinned and recorded exactly like one the condition produced. A capture already in flight is
+ // not cancelled: it *is* the acquisition being asked for, and the engine refuses the request
+ // rather than restarting it.
+ if (force_want_.exchange(false, std::memory_order_acq_rel)) {
+ if (engine.force(last_stamp_)) {
+ std::fprintf(stderr,
+ "[udpscope] trigger: forced acquisition, edge at %.3f s -- want "
+ "[-%.4f..%.4f] s, %.4f s of history behind it, mode=%s\n",
+ last_stamp_, event_span - post_want, post_want, last_stamp_ - oldest_s,
+ tc.mode == TriggerMode::Single ? "single (disarms at the latch)" : "normal");
+ std::fflush(stderr);
+ } else {
+ // Nothing to force. Every cause is a state the user can see from the panel -- a trigger that
+ // is off, a signal that is not acquired, the engine disarmed, a capture already filling --
+ // so it is named on stderr rather than swallowed.
+ std::fprintf(stderr,
+ "[udpscope] trigger: force ignored (enabled=%d armed=%d ch=%d pending=%d)\n",
+ int(tc.enabled), int(engine.armed()), trig_channel, int(engine.pending()));
+ std::fflush(stderr);
+ }
+ }
+
model_.trigger_armed().store(engine.armed(), std::memory_order_relaxed);
// The edge goes out before the flag that makes the UI read it (release/acquire on
// `trigger_pending`), so a tick that sees a capture open sees which edge it is for. A
diff --git a/src/acquisition.hpp b/src/acquisition.hpp
index a008bb6..c2fa94e 100644
--- a/src/acquisition.hpp
+++ b/src/acquisition.hpp
@@ -48,6 +48,20 @@ class Acquisition {
bool capture_want() const noexcept {
return capture_want_.load(std::memory_order_acquire);
}
+
+ /// Asks for one **forced trigger acquisition** -- the trigger panel's Force button. The producer
+ /// consumes the request on its next packet and opens a capture at that packet's newest stamp,
+ /// which the engine's ordinary latch then publishes with the configured pre/post window. The
+ /// request is dropped rather than retried when there is nothing to force: trigger mode off, no
+ /// channel resolved, the engine disarmed, or a capture already filling (a capture in flight *is*
+ /// the acquisition being asked for).
+ ///
+ /// **Write it under `ScopeModel::mutex()`, together with the `armed` flag the click also sets.**
+ /// The producer holds that lock for a whole packet -- it reads the panel's `armed` at the top of
+ /// one and consumes this request at the bottom -- so a request written between the two would be
+ /// read by a packet whose engine was still disarmed, and then dropped.
+ void request_force() noexcept { force_want_.store(true, std::memory_order_release); }
+
/// The capture, for the popover's row and the probe. Counters and errors are read
/// cross-thread through PacketCapture's own published fields.
const PacketCapture& capture() const noexcept { return capture_; }
@@ -110,6 +124,9 @@ class Acquisition {
std::atomic paused_{false};
PacketCapture capture_;
std::atomic capture_want_{false};
+ /// The pending Force request (see `request_force()`): a flag and not a queue, because two clicks
+ /// inside one packet are one acquisition, which is what a momentary button means.
+ std::atomic force_want_{false};
std::uint32_t capture_slots_ = 1000;
/// Written once, by the GTK thread, before `capture_want_` is ever published true;
/// the producer reads it only after the flag, so no lock is needed.
diff --git a/src/event_series.hpp b/src/event_series.hpp
index cf2b707..009514a 100644
--- a/src/event_series.hpp
+++ b/src/event_series.hpp
@@ -7,6 +7,7 @@
#include
#include
+#include
#include
namespace udpscope {
@@ -63,8 +64,30 @@ struct EventSnapshot {
/// already O(n), and the same channel on two rows shares it instead of building and
/// storing it twice.
std::vector pyramids;
+ /// The channel *names* `values` is indexed by, so a snapshot can be re-bound after the channel
+ /// list was rebuilt -- and renumbered -- under it: a persistence layer names its signal and
+ /// resolves that name here, exactly as the trigger engine names its channel and for the same
+ /// reason (an index is a position in a list that any enable, disable or CONFIG renumbers).
+ /// Empty on a snapshot nobody re-binds later (a pause view).
+ std::vector channel_names;
};
+/// Bytes one snapshot holds: both stamp arrays, every channel's samples, the pyramids built for
+/// them and the names above. The number the persistence retention is measured in -- a retained
+/// snapshot is a whole window copied out of the ring, so what bounds it is bytes, not a count.
+inline std::size_t snapshot_bytes(const EventSnapshot& s) {
+ std::size_t bytes = s.stamps.size() * sizeof(f64) + s.abs_stamps.size() * sizeof(f64) +
+ s.y.size() * sizeof(plot::Range) +
+ s.channel_names.size() * sizeof(std::string);
+ for (const std::vector& v : s.values) bytes += v.size() * sizeof(f32);
+ // A pyramid entry is 32 bytes (`MinMaxPyramid::Entry`), one per level-`l` bucket of
+ // `kBucket << l` samples -- the whole index is about one byte per sample.
+ for (const plot::data::MinMaxPyramid& p : s.pyramids) {
+ for (u32 l = 0; l < p.level_count(); ++l) bytes += p.level_size(l) * 32u;
+ }
+ return bytes;
+}
+
/// A plot::data::Series view of one channel of an EventSnapshot. The stamps are
/// near-uniform (the arrival clock), so dx is the mean spacing and x_at returns
/// the exact stamp; gather decimates by 2^level like the ring series does.
diff --git a/src/layout_store.cpp b/src/layout_store.cpp
index 7a77fcf..ef8d9be 100644
--- a/src/layout_store.cpp
+++ b/src/layout_store.cpp
@@ -516,6 +516,10 @@ bool load_layout(const std::string& path, LayoutState& out) {
num(t.noise_band);
} else if (k == "hf") {
num(t.hf_fc);
+ } else if (k == "persistence") {
+ double n = 0.0;
+ num(n);
+ t.persistence = static_cast(n);
}
};
if (section == Section::Top) {
@@ -718,6 +722,7 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "trigger_holdoff = " << state.trigger.holdoff_s << "\n";
o << "trigger_band = " << state.trigger.noise_band << "\n";
o << "trigger_hf = " << state.trigger.hf_fc << "\n";
+ o << "trigger_persistence = " << state.trigger.persistence << "\n";
if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\n";
if (state.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\n";
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
diff --git a/src/layout_store.hpp b/src/layout_store.hpp
index a89f7d5..37809c8 100644
--- a/src/layout_store.hpp
+++ b/src/layout_store.hpp
@@ -97,6 +97,10 @@ struct LayoutState {
double holdoff_s = 0.010;
double noise_band = 0.0;
double hf_fc = 0.0;
+ /// How many of the most recent acquisitions the trigger mode keeps on screen (1 = the event
+ /// alone). Part of the trigger's own setup, like the window it captures and the shape it waits
+ /// for: it is what the display does with the acquisitions, and there is no second switch.
+ int persistence = 1;
/// Whether the file said anything about the level. A session that did keeps it; one that did not
/// leaves the panel's own "follow the channel's range" default alone.
bool level_given = false;
diff --git a/src/model.cpp b/src/model.cpp
index 60a987b..79fe9f1 100644
--- a/src/model.cpp
+++ b/src/model.cpp
@@ -891,9 +891,11 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
}
snap->values.resize(channels_.size());
+ snap->channel_names.resize(channels_.size());
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) {
+ snap->channel_names[c] = channels_[c].name;
auto& vals = snap->values[c];
vals.resize(static_cast(n));
if (n > 0) src.values(c).copy_from(first, n, vals.data());
@@ -918,6 +920,24 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
// capture at the first sample past `post_s` rather than exactly on it.
snap->pre_s = snap->stamps.empty() ? 0.0 : -static_cast(snap->stamps.front());
snap->post_s = snap->stamps.empty() ? 0.0 : static_cast(snap->stamps.back());
+ // The persistence retention: what was on screen until now becomes the *newest of the older*
+ // acquisitions, and the set is cut back to the depth the panel asks for and to the retention
+ // budget. Nothing is retained at depth 1, so the default costs no memory and no copy at all.
+ if (const int depth = persist_depth_locked(); depth > 1) {
+ std::shared_ptr prev = event_.load(std::memory_order_relaxed);
+ if (prev != nullptr) {
+ // Only the slots the depth wants are shifted: the ones past it are the prune's business, and
+ // moving an acquisition into a slot nothing can ever read is work for a snapshot about to be
+ // dropped.
+ const std::size_t top = std::min(static_cast(depth - 1), persist_.size());
+ for (std::size_t i = top; i-- > 1;) {
+ persist_[i].store(persist_[i - 1].load(std::memory_order_relaxed),
+ std::memory_order_relaxed);
+ }
+ persist_[0].store(std::move(prev), std::memory_order_relaxed);
+ }
+ }
+ prune_persist_locked(static_cast(persist_depth_locked() - 1));
event_.store(std::move(snap), std::memory_order_release);
// Bumped after the snapshot is in place, so a consumer that sees the new sequence
// number sees the snapshot it counts.
@@ -963,6 +983,48 @@ void ScopeModel::set_trigger_enabled(bool on) {
trigger_enabled_.store(on, std::memory_order_relaxed);
}
+void ScopeModel::prune_persist_locked(std::size_t keep) {
+ const std::size_t limit = std::min(keep, persist_.size());
+ const std::size_t budget = persist_budget_bytes();
+ std::size_t bytes = 0;
+ std::size_t kept = 0;
+ std::uint64_t dropped = 0;
+ bool full = false;
+ for (std::size_t i = 0; i < persist_.size(); ++i) {
+ const std::shared_ptr snap = persist_[i].load(std::memory_order_relaxed);
+ if (snap == nullptr) continue;
+ if (i < limit) {
+ // Either bound can cut the set: the depth the panel asks for, and the budget, which a long
+ // window on a fast stream reaches long before the seventh layer.
+ const std::size_t b = snapshot_bytes(*snap);
+ if (!full && bytes + b <= budget) {
+ bytes += b;
+ ++kept;
+ continue;
+ }
+ full = true; // does not fit, and neither does anything older
+ // Only what the *budget* refused is counted: a slot past the depth is not a drop, it is the
+ // depth -- and counting it would make the reading say the memory was the reason when the
+ // panel's own number was.
+ ++dropped;
+ }
+ persist_[i].store(nullptr, std::memory_order_relaxed);
+ }
+ persist_count_.store(kept, std::memory_order_relaxed);
+ persist_bytes_.store(bytes, std::memory_order_relaxed);
+ persist_dropped_.store(persist_dropped_.load(std::memory_order_relaxed) + dropped,
+ std::memory_order_relaxed);
+}
+
+void ScopeModel::set_persist_depth(int depth) {
+ const int n = std::clamp(depth, 1, kMaxPersistDepth);
+ std::lock_guard lock(mutex_);
+ trigger_.persist_depth = n;
+ // Pruned here and not only at the next latch: lowering the depth is the user asking to stop
+ // paying for the layers, and a trigger that has stopped firing never reaches another publish.
+ prune_persist_locked(static_cast(n - 1));
+}
+
void ScopeModel::set_epoch_if_unset(f64 e) noexcept {
if (epoch_ == 0.0) epoch_ = e;
}
diff --git a/src/model.hpp b/src/model.hpp
index e155fe7..338caef 100644
--- a/src/model.hpp
+++ b/src/model.hpp
@@ -10,6 +10,8 @@
#include
+#include
+#include
#include
#include
#include
@@ -511,6 +513,55 @@ class ScopeModel {
/// while the latch count keeps answering "how many events fired".
std::uint64_t event_seq() const noexcept { return event_seq_.load(std::memory_order_acquire); }
+ // --- persistence ----------------------------------------------------------
+ /// The acquisitions retained for the persistence display, newest first: `persist_event(1)` is
+ /// the event one latch back, `(2)` the one before that. Null for an age nobody kept.
+ ///
+ /// Read **without a lock**, like `event()`: the producer publishes these in `publish_event()`
+ /// with the same atomic-`shared_ptr` discipline, and a snapshot is immutable and shared, so a
+ /// reader holds it alive for as long as it draws it. What the *ring* would have given is not
+ /// needed afterwards -- a retained snapshot is a whole window copied out of it.
+ std::shared_ptr persist_event(std::size_t age) const noexcept {
+ if (age < 1 || age > persist_.size()) return nullptr;
+ return persist_[age - 1].load(std::memory_order_acquire);
+ }
+ /// How many acquisitions are retained right now: what the depth asks for, or fewer -- never more
+ /// -- when the retention budget cut the set short. A reading, so it is counted as the slots that
+ /// hold a snapshot rather than derived from the depth.
+ std::size_t persist_count() const noexcept {
+ return persist_count_.load(std::memory_order_relaxed);
+ }
+ /// What those snapshots hold in bytes, and how many acquisitions the *budget* -- as against the
+ /// depth -- has dropped. Both are producer-written and read as readings.
+ std::size_t persist_bytes() const noexcept {
+ return persist_bytes_.load(std::memory_order_relaxed);
+ }
+ std::uint64_t persist_dropped() const noexcept {
+ return persist_dropped_.load(std::memory_order_relaxed);
+ }
+ /// The bytes persistence may retain, which is the ring's own byte budget (`--buffer-mb`): the
+ /// snapshots are copies *of* what the rings hold, so holding as much again as the buffer does is
+ /// the honest ceiling for a display of it -- and it is the bound that cannot be driven into an
+ /// OOM by a long window on a fast stream, whatever depth the panel asks for.
+ std::size_t persist_budget_bytes() const noexcept {
+ const int mb = cfg_.buffer_mb > 0 ? cfg_.buffer_mb : 0;
+ return static_cast(mb) * 1024u * 1024u;
+ }
+
+ /// Sets how many acquisitions the persistence display keeps, clamped to `kMaxPersistDepth`, and
+ /// prunes the retained set to it **at once**: lowering the depth is a user asking to stop paying
+ /// for the layers, and the next latch -- when the retention would otherwise catch up -- may never
+ /// come on a trigger that has stopped firing.
+ ///
+ /// Takes `mutex()` itself; do not call it holding the lock.
+ void set_persist_depth(int depth);
+
+ /// How many acquisitions the persistence display keeps: the depth, clamped to the cap. Reading
+ /// `trigger()` needs the lock, which every caller of this already holds.
+ int persist_depth_locked() const noexcept {
+ return std::clamp(trigger_.persist_depth, 1, kMaxPersistDepth);
+ }
+
/// Publishes the live buffer's new samples to its `ChannelBuffer`s.
void acquire_all();
@@ -742,6 +793,19 @@ class ScopeModel {
std::atomic capture_edge_{0.0};
std::atomic> event_; ///< the event on screen
std::atomic> pause_view_; ///< the view frozen at pause
+ /// The retention behind `persist_event()`: one slot per *previous* acquisition, newest first, so
+ /// slot 0 is the event one latch back. `kMaxPersistDepth - 1` of them, because the acquisition on
+ /// screen is `event_` itself and is not retained a second time. Written by the producer in
+ /// `publish_event()` and by `set_persist_depth()`, both under `mutex()`; read without it.
+ std::array>, kMaxPersistDepth - 1> persist_{};
+ /// The readings `persist_count()`/`persist_bytes()`/`persist_dropped()` answer with, maintained
+ /// where the slots are written.
+ std::atomic persist_count_{0};
+ std::atomic persist_bytes_{0};
+ std::atomic persist_dropped_{0};
+ /// Drops the retained snapshots past `keep` (0 empties the set) and recounts the readings.
+ /// Caller holds `mutex()`.
+ void prune_persist_locked(std::size_t keep);
std::atomic paused_{false};
/// Per-signal-name calibration (see `set_calibration`).
std::unordered_map> calibration_;
diff --git a/src/plot_host.cpp b/src/plot_host.cpp
index efef5e5..770a70b 100644
--- a/src/plot_host.cpp
+++ b/src/plot_host.cpp
@@ -68,8 +68,28 @@ constexpr float kMeasureDashOff = 4.0f;
/// stacked frames read as one tall plot with a line through it.
constexpr plot::f32 kRowGap = 6.0f;
+/// The suffix that names a persistence layer, in the accumulate path's own vocabulary --
+/// ` (A)`, ` (B)`, ` #4` -- so that `layer_base_name()` takes it back off, and the signal list, the
+/// colour resolver, the measurements and the layout writer all keep seeing one signal where the
+/// plot draws several traces of it.
+std::string layer_suffix(int age) {
+ if (age == 1) return " (A)";
+ if (age == 2) return " (B)";
+ return " #" + std::to_string(age + 1);
+}
+
} // namespace
+float RowSet::persist_alpha(int age) noexcept {
+ // Half per acquisition, so the seventh is 1/128 -- and halves, not a slope, because that is what
+ // "exponential decay" means and because it is the one schedule that can be read off a line of
+ // numbers: 1, 0.5, 0.25, 0.125. It does not depend on the depth, so raising N adds dimmer
+ // acquisitions instead of restating the ones already on screen.
+ float a = 1.0f;
+ for (int i = 0; i < age; ++i) a *= 0.5f;
+ return a;
+}
+
std::string layer_base_name(const std::string& name) {
const std::size_t a = name.rfind(" (");
if (a != std::string::npos && a + 3 < name.size() && name[a + 3] == ')' &&
@@ -146,6 +166,11 @@ void RowSet::carry_row(Row& dst, const Row& src) {
dst.name = src.name;
dst.channel = src.channel;
dst.color_index = src.color_index;
+ // A layer travels with the slot that draws it: it *is* part of what that slot holds. The tick's
+ // `sync_layers()` re-plans them all next frame in any case, which is what heals a row that has
+ // moved under a plan made for the old one.
+ dst.layer = src.layer;
+ dst.slot_alpha = src.slot_alpha;
dst.ruler_y = src.ruler_y;
dst.ruler_b_y = src.ruler_b_y;
@@ -161,6 +186,8 @@ void RowSet::clear_row(Row& r) {
r.name = {};
r.channel = {-1, -1, -1, -1};
r.color_index = {0, 1, 2, 3};
+ r.layer = {0, 0, 0, 0};
+ r.slot_alpha = {1.0f, 1.0f, 1.0f, 1.0f};
r.ruler_y = 0.0;
for (SlotSeries& s : r.slots) s.bind(nullptr);
}
@@ -190,10 +217,24 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
{"", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
r.axis_y = r.plot->add_y_axis(
{"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
- for (int s = 0; s < kSlotsPerRow; ++s) {
+ // Traces are drawn in the order they were created, and a `Plot` cannot reorder them: the
+ // *dimmest* traces are created first and a signal's own trace last, so the newest acquisition is
+ // the ink on top. A layer drawn over the event would take its colour out of the event's own trace
+ // wherever the two agree -- the opposite of what a persistence display shows.
+ std::array order{};
+ for (int s = 0; s < kSlotsPerRow; ++s) order[static_cast(s)] = s;
+ std::stable_sort(order.begin(), order.end(), [&r](int a, int b) {
+ return r.slot_alpha[static_cast(a)] < r.slot_alpha[static_cast(b)];
+ });
+ for (const int s : order) {
const std::size_t i = static_cast(s);
- r.plot->add_trace({&r.slots[i], signal_color(r.signal_name[i], r.color_index[i]), 1.2f,
- r.axis_x, r.axis_y, r.name[i]});
+ plot::Color color = signal_color(r.signal_name[i], r.color_index[i]);
+ // The slot's own brightness times the figure-wide one the group views set: a persistence
+ // layer's decay and an accumulation's translucency are the same knob, and `set_trace_alpha()`
+ // is what reads it here. It was set and never read before, which is why an accumulation's
+ // traces were opaque.
+ color.a = r.slot_alpha[i] * trace_alpha_;
+ r.plot->add_trace({&r.slots[i], color, 1.2f, r.axis_x, r.axis_y, r.name[i]});
}
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));
@@ -320,9 +361,132 @@ void RowSet::assign(int row_index, int slot, std::string signal_name,
r.channel[s] = -1; // re-resolved by the next bind()
r.name[s] = std::move(display_name);
r.color_index[s] = color_index;
+ // Whatever the slot held, it is a *signal's* trace now: a drop onto a slot a persistence layer
+ // was using is the user's arrangement, and the tick's plan moves the layers around it next frame.
+ r.layer[s] = 0;
+ r.slot_alpha[s] = 1.0f;
row_dirty_[static_cast(row_index)] = true;
}
+void RowSet::assign_layer(int row_index, int slot, std::string signal_name,
+ std::string display_name, int color_index, int age, float alpha) {
+ assign(row_index, slot, std::move(signal_name), std::move(display_name), color_index);
+ if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return;
+ Row& r = row(row_index);
+ const std::size_t s = static_cast(slot);
+ r.layer[s] = age;
+ r.slot_alpha[s] = alpha;
+ row_dirty_[static_cast(row_index)] = true;
+}
+
+void RowSet::clear_layers() {
+ for (int i = 0; i < kMaxRows; ++i) {
+ Row& r = row(i);
+ for (int s = 0; s < kSlotsPerRow; ++s) {
+ const std::size_t si = static_cast(s);
+ if (r.layer[si] == 0) continue;
+ assign(i, s, "", "", r.color_index[si]);
+ }
+ }
+}
+
+int RowSet::sync_layers(int depth, int* want) {
+ const int ages = std::clamp(depth, 1, kMaxPersistDepth) - 1;
+ // What each slot should hold: 0 a signal's own trace (kept as it is), -1 free, >0 a layer age.
+ std::array, kMaxRows> plan{};
+ std::array, kMaxRows> names{};
+ int wanted = 0;
+ for (int r = 0; r < rows_; ++r) {
+ Row& row_state = row(r);
+ for (int s = 0; s < kSlotsPerRow; ++s) {
+ const std::size_t si = static_cast(s);
+ // A layer slot is free for re-planning; a signal's own trace is not.
+ plan[static_cast(r)][si] =
+ row_state.layer[si] > 0 ? -1 : (row_state.signal_name[si].empty() ? -1 : 0);
+ }
+ }
+ // Layer by layer, as the accumulate path fills its own: age 1 for every signal first, then age 2,
+ // so a row with room for two layers draws the *sharper* one for each of its signals rather than
+ // four ages of the first one.
+ for (int age = 1; age <= ages; ++age) {
+ for (int r = 0; r < rows_; ++r) {
+ const std::size_t ri = static_cast(r);
+ Row& row_state = row(r);
+ for (int s = 0; s < kSlotsPerRow; ++s) {
+ const std::size_t si = static_cast(s);
+ if (plan[ri][si] != 0) continue;
+ ++wanted;
+ int target = -1;
+ for (int t = 0; t < kSlotsPerRow; ++t) {
+ if (plan[ri][static_cast(t)] == -1) {
+ target = t;
+ break;
+ }
+ }
+ if (target < 0) continue; // this row is full: it persists as far as it can
+ const std::size_t ti = static_cast(target);
+ plan[ri][ti] = age;
+ names[ri][ti] = row_state.signal_name[si] + layer_suffix(age);
+ }
+ }
+ }
+ int placed = 0;
+ for (int r = 0; r < rows_; ++r) {
+ const std::size_t ri = static_cast(r);
+ Row& row_state = row(r);
+ for (int s = 0; s < kSlotsPerRow; ++s) {
+ const std::size_t si = static_cast(s);
+ const int now = plan[ri][si];
+ const int had = row_state.layer[si];
+ if (now > 0) {
+ ++placed;
+ if (had == now && row_state.signal_name[si] == names[ri][si]) continue; // already right
+ assign_layer(r, s, names[ri][si], names[ri][si], row_state.color_index[si], now,
+ persist_alpha(now));
+ } else if (had > 0) {
+ assign(r, s, "", "", row_state.color_index[si]); // nothing wants this slot any more
+ }
+ }
+ }
+ if (want != nullptr) *want = wanted;
+ return placed;
+}
+
+int RowSet::layer_of(int row_index, int slot) const {
+ if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return 0;
+ return row(row_index).layer[static_cast(slot)];
+}
+
+float RowSet::slot_alpha_of(int row_index, int slot) const {
+ if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return 1.0f;
+ return row(row_index).slot_alpha[static_cast(slot)];
+}
+
+int RowSet::layer_slots() const {
+ int n = 0;
+ for (int i = 0; i < kMaxRows; ++i) {
+ const Row& r = row(i);
+ for (int s = 0; s < kSlotsPerRow; ++s) {
+ if (r.layer[static_cast(s)] > 0) ++n;
+ }
+ }
+ return n;
+}
+
+std::vector> RowSet::row_layers(int row_index) const {
+ std::vector> out;
+ if (row_index < 0 || row_index >= kMaxRows) return out;
+ const Row& r = row(row_index);
+ for (int s = 0; s < kSlotsPerRow; ++s) {
+ const std::size_t si = static_cast(s);
+ if (r.layer[si] > 0) out.push_back({r.layer[si], r.slot_alpha[si]});
+ }
+ // Oldest first: the order the decay has to read in.
+ std::sort(out.begin(), out.end(),
+ [](const auto& a, const auto& b) { return a.first > b.first; });
+ return out;
+}
+
bool RowSet::slot_bound(int row_index, int slot) const {
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return false;
return !row(row_index).signal_name[static_cast(slot)].empty();
@@ -348,9 +512,15 @@ void RowSet::bind(ScopeModel& model, DisplayMode mode) {
// Resolve the stable identity every frame: the channel list is rebuilt (and
// renumbered) whenever a signal is enabled, disabled or re-described, and a
// stored index would then point at a different signal's channel.
+ //
+ // A persistence layer resolves the name *under its suffix*: `sine (A)` is `sine`, one
+ // acquisition back, and it is the same signal's trace -- so it is acquired (or not) with its
+ // signal, drawn in its colour, and counted by nothing that counts signals.
+ const std::string base_name =
+ r.layer[si] > 0 ? layer_base_name(r.signal_name[si]) : r.signal_name[si];
int signal_index = -1;
for (std::size_t k = 0; k < signals.size(); ++k) {
- if (signals[k].name == r.signal_name[si]) {
+ if (signals[k].name == base_name) {
signal_index = static_cast(k);
break;
}
@@ -364,6 +534,36 @@ void RowSet::bind(ScopeModel& model, DisplayMode mode) {
}
}
}
+ if (r.layer[si] > 0) {
+ // A layer holds no channel: every reader that asks "is this slot a trace of a signal"
+ // -- the trace counts, the cursors pane's `mix` mode, the measurement marks, the layout
+ // writer -- tests `channel >= 0`, so a layer is invisible to all of them by construction.
+ r.channel[si] = -1;
+ // It draws only while a *snapshot* is on screen, and only if its signal is still acquired
+ // (switching a signal off takes its traces off every plot, layers included), and it
+ // resolves its channel **by name inside the snapshot**: the channel list may have been
+ // rebuilt and renumbered many times since that acquisition was cut, which is exactly why
+ // the trigger engine names its channel too.
+ std::shared_ptr snap;
+ if (channel >= 0 && (mode == DisplayMode::Held || mode == DisplayMode::Capturing)) {
+ snap = model.persist_event(static_cast(r.layer[si]));
+ }
+ std::size_t layer_channel = snap != nullptr ? snap->channel_names.size() : 0;
+ if (snap != nullptr) {
+ for (std::size_t c = 0; c < snap->channel_names.size(); ++c) {
+ if (snap->channel_names[c] == base_name) {
+ layer_channel = c;
+ break;
+ }
+ }
+ }
+ if (snap != nullptr && layer_channel < snap->values.size()) {
+ r.slots[si].bind_event(std::move(snap), layer_channel);
+ } else {
+ r.slots[si].bind(nullptr);
+ }
+ continue;
+ }
r.channel[si] = channel;
if (channel < 0) {
// Its signal is present but not acquired: keep the identity (so enabling it
@@ -818,6 +1018,11 @@ bool RowSet::event_data_window(double& lo, double& hi) const {
const Row& r = row(i);
if (!r.plot) continue;
for (int s = 0; s < kSlotsPerRow; ++s) {
+ // A persistence layer is bound to an event series too, and it is the *past*: letting one
+ // widen this window would move the pin -- and with it the framing and the numbers a pinned
+ // measurement reads -- every time an older acquisition happened to be wider than the one on
+ // screen. What is pinned is the current event's window, and only that.
+ if (r.layer[static_cast(s)] > 0) continue;
const EventSeries* ev = r.slots[static_cast(s)].bound_event();
if (ev == nullptr || ev->size() < 2) continue;
const plot::Range b = ev->x_bounds();
diff --git a/src/plot_host.hpp b/src/plot_host.hpp
index e4b99f5..e809cf0 100644
--- a/src/plot_host.hpp
+++ b/src/plot_host.hpp
@@ -112,6 +112,16 @@ class RowSet {
/// `add_trace()`, which is why this is stored rather than resolved per frame --
/// changing it needs a rebuild, and `assign()` already forces one.
std::array color_index = {0, 1, 2, 3};
+ /// The *persistence layer* this slot draws: 0 for a signal's own trace (whatever the frame's
+ /// display mode binds it to), `a` > 0 for the acquisition `a` latches back, drawn at
+ /// `slot_alpha`. A layer slot holds a suffixed name (`sine (A)`) and no channel at all -- it is
+ /// not a signal, and `channel[]` stays -1 for it (see `assign_layer()`).
+ std::array layer = {0, 0, 0, 0};
+ /// How bright the slot's trace is built: 1 for a signal's own trace, `persist_alpha(layer)` for
+ /// a persistence layer, multiplied into `trace_alpha_`. It lives on the slot because a `Plot`
+ /// fixes a trace's colour -- alpha included -- at `add_trace()`, so a per-layer brightness is a
+ /// property of the trace's *creation*, which is what a rebuild is for.
+ std::array slot_alpha = {1.0f, 1.0f, 1.0f, 1.0f};
plot::u32 axis_x = 0;
plot::u32 axis_y = 0;
plot::u32 cursor_a = 0;
@@ -200,6 +210,42 @@ class RowSet {
/// The channel index a row/slot holds, or -1.
int channel_of(int row, int slot) const;
+ // --- persistence layers ---------------------------------------------------
+ /// The brightness a persistence layer is drawn at, `a` acquisitions back: the acquisition on
+ /// screen is the full-bright one and each older one is **half** the previous, so the schedule is
+ /// exponential in age -- 1, 0.5, 0.25, 0.125, ... and by the seventh (0.008, `kMaxPersistDepth`)
+ /// the layer is gone. Halving is per acquisition and not per *depth*, so changing N re-cuts how
+ /// many are kept without rescaling the ones already on screen.
+ static float persist_alpha(int age) noexcept;
+
+ /// Binds a row's slot to a persistence *layer*: the acquisition `age` latches back, at `alpha`
+ /// (see `Row::layer`). The name carries the layer's suffix (`sine (A)`, `sine #4`), which is what
+ /// keeps a layer out of the signal list and out of the measurements -- it is not a signal -- while
+ /// `signal_color()` still resolves it to the base signal's own colour.
+ void assign_layer(int row, int slot, std::string signal_name, std::string display_name,
+ int color_index, int age, float alpha);
+ /// Frees every slot bound to a layer. What turning persistence off -- or the trigger -- is.
+ void clear_layers();
+ /// Lays the persistence layers out over the rows' free slots, one per (signal, age): the signal's
+ /// own trace stays where it is and its layers take the free slots of the *same* row, layer by
+ /// layer (age 1 for every signal first, then age 2, as the accumulate path does), so a row that
+ /// runs out of room draws as many layers as it has room for and no more. Every slot the plan does
+ /// not fill is freed, which is what makes the display follow a layout change, a depth change and a
+ /// signal's removal without any caller having to reset anything.
+ ///
+ /// Returns the number of layers placed; `want` is how many the depth asked for, so a caller can
+ /// say when a layout could not show them all.
+ int sync_layers(int depth, int* want);
+ /// The layer a slot is drawing, 0 for a signal's own trace.
+ int layer_of(int row, int slot) const;
+ /// The slot's own brightness (1 unless it is a layer).
+ float slot_alpha_of(int row, int slot) const;
+ /// How many slots are layers, over every row: the reading that says the layers exist.
+ int layer_slots() const;
+ /// The layers of one row, oldest first, as `(age, alpha)` pairs -- the ordering a reader has to
+ /// see to know the decay is the right way round.
+ std::vector> row_layers(int row) const;
+
/// Rebinds every slot to what `mode` says it should show this frame. Called every
/// frame, because the channel list is renumbered by any structural change.
///
diff --git a/src/trigger.cpp b/src/trigger.cpp
index ca7f8cd..90163f8 100644
--- a/src/trigger.cpp
+++ b/src/trigger.cpp
@@ -39,6 +39,20 @@ void TriggerEngine::arm(double now_s) noexcept {
detector_.reset();
}
+bool TriggerEngine::force(double now_s) noexcept {
+ if (!cfg_.enabled || !armed_ || cfg_.channel < 0 || pending_) return false;
+ // Straight into the pending state `process()` maintains, with the present moment as the event's
+ // own time: the post window is counted from here by the same accumulation an edge's capture uses,
+ // and the latch that ends it is the same latch. `last_trigger_s_` is the edge, so the marker, the
+ // cut's centre and the snapshot's `x_offset` all land on the instant of the press.
+ last_t_s_ = now_s;
+ last_trigger_s_ = now_s;
+ pending_ = true;
+ pending_start_s_ = now_s;
+ post_accum_count_ = 0;
+ return true;
+}
+
bool TriggerEngine::process(double value, double now_s) noexcept {
if (!cfg_.enabled || !armed_ || cfg_.channel < 0) return false;
diff --git a/src/trigger.hpp b/src/trigger.hpp
index 63cbafc..04fe2f0 100644
--- a/src/trigger.hpp
+++ b/src/trigger.hpp
@@ -30,12 +30,25 @@ struct TriggerConfig {
double pretrigger_pct = 50.0; ///< share of the buffer shown before the event.
double holdoff_s = 0.010; ///< default from the spec.
TriggerMode mode = TriggerMode::Normal;
+ /// How many of the most recent acquisitions the plot keeps on screen, the older ones drawn
+ /// dimmer (see `RowSet::persist_alpha()`): 1 -- the default -- keeps nothing but the event on
+ /// screen, which is the display without persistence, and there is deliberately no second switch
+ /// for it.
+ int persist_depth = 1;
/// Seconds of post-trigger data to collect before latching the event, given
/// the buffer's span.
double post_span_s(double buffer_span_s) const;
};
+/// The deepest persistence the trigger panel offers. Seven retained acquisitions is what fits: the
+/// layer in that position is drawn at half the seventh power (0.8 %, i.e. already gone, see
+/// `RowSet::persist_alpha()`), and seven is also the number of free slots a row has beside a single
+/// signal, so a deeper request could not be drawn on the simplest layout there is. It is **not**
+/// the bound on what persistence may cost in memory -- `ScopeModel::persist_budget_bytes()` is, and
+/// a window of millions of samples reaches it long before the seventh layer.
+inline constexpr int kMaxPersistDepth = 8;
+
/// The producer-side trigger state machine. Fed one sample at a time in stream
/// order; it says when the event buffer should be latched.
class TriggerEngine {
@@ -75,6 +88,23 @@ class TriggerEngine {
void disarm() noexcept { armed_ = false; pending_ = false; }
void arm(double now_s) noexcept;
+ /// Opens a capture **now**, as if the condition had just fired: `now_s` becomes the event's own
+ /// time, so the engine collects the post window from the samples that follow it and the ordinary
+ /// latch publishes `[-pre, +post]` around that instant. This is the Force button -- the one
+ /// acquisition a scope takes when the edge is not coming -- and it is deliberately not a
+ /// publish of its own: the capture, the cut, the marker, the count and the mode's re-arm rule are
+ /// the edge path's, which is what keeps a forced acquisition framed and pinned exactly like a
+ /// latched one.
+ ///
+ /// The pre-trigger gate is the one thing *not* applied: the history behind the present moment is
+ /// whatever the ring holds, and a caller who asks to capture now is asking for that. A snapshot
+ /// shorter than the request reports its own `pre_s`, and the view is pinned to it.
+ ///
+ /// Returns false when there is no engine to force -- trigger mode off, no channel resolved, not
+ /// armed -- or when a capture is already filling: a capture in flight *is* the acquisition being
+ /// asked for, and restarting it would discard the post window it has already collected.
+ bool force(double now_s) noexcept;
+
/// Feeds one sample of the trigger channel. `now_s` is the sample's timestamp
/// in stream seconds. Returns true when the event has just been latched.
bool process(double value, double now_s) noexcept;
diff --git a/src/ui.cpp b/src/ui.cpp
index 1e27c1b..73288fb 100644
--- a/src/ui.cpp
+++ b/src/ui.cpp
@@ -3514,11 +3514,27 @@ gboolean tick(gpointer user) {
app->capturing = display == DisplayMode::Capturing;
if (new_capture) app->capture_edge = cap_edge;
+ // The persistence plan this frame: what the depth asked for and what the rows had room to draw.
+ // Declared outside the lock block because the notice below is a widget write, and the tick's
+ // widget work happens outside the model's lock.
+ const bool live_display = display == DisplayMode::Live || !app->waveform_name.empty();
+ int persist_depth = 1;
+ int persist_want = 0;
+
{
// Serialise against the acquisition thread's CONFIG handling and against the
// UI's own structural changes: the bind takes shared handles into the live
// store, and acquire() reads it.
std::lock_guard lock(model.mutex());
+ // Persistence first, so the bind below is what *resolves* the layers: the last N acquisitions
+ // are planned into the rows' free slots, and a depth of 1 -- or the trigger off, or a *file* on
+ // screen, whose data is not a stream of acquisitions at all -- frees every layer slot, which is
+ // the display without the feature. The plan is a function of the rows and the depth and not of
+ // the data, so a latch costs no rebuild and the names and ages on screen stay put.
+ persist_depth = live_display ? 1 : model.persist_depth_locked();
+ const int placed = app->rows->sync_layers(persist_depth, &persist_want);
+ app->persist_want = persist_want;
+ app->persist_placed = placed;
app->rows->bind(model, display);
app->rows->acquire(model);
if (app->comparing && app->rows_b != nullptr) {
@@ -3564,6 +3580,28 @@ gboolean tick(gpointer user) {
// acquisition thread mutates, so it belongs under this lock and not after it.
app->rows->set_invalid_spans(model, display);
}
+ // A depth the room or the retention budget could not show in full says so, once per *plan* rather
+ // than once per frame: a user who asked for eight layers and sees two has no other way to tell
+ // "the display is deliberately shallower" from "the feature is broken". Both bounds are named,
+ // because they are different numbers and only one of them is the user's own: a *slot* shortfall is
+ // `placed < want` (the rows had no free trace slot), and a *budget* shortfall is fewer retained
+ // acquisitions than the depth asks for, in which case the slots exist and draw nothing at all.
+ const int want_ages = persist_depth > 1 ? persist_depth - 1 : 0;
+ const int kept_ages = static_cast(std::min(
+ model.persist_count(), static_cast(std::max(0, want_ages))));
+ if (persist_depth > 1 &&
+ (app->persist_placed < persist_want || kept_ages < want_ages) &&
+ (app->persist_noted_depth != persist_depth || app->persist_noted_want != persist_want ||
+ app->persist_noted_placed != app->persist_placed)) {
+ app->persist_noted_depth = persist_depth;
+ app->persist_noted_want = persist_want;
+ app->persist_noted_placed = app->persist_placed;
+ set_notice(*app,
+ "persistence: " + std::to_string(app->persist_placed) + " of " +
+ std::to_string(persist_want) + " layers fit, " + std::to_string(kept_ages) +
+ " of " + std::to_string(want_ages) + " older acquisitions retained",
+ 5);
+ }
// Outside the lock: it reads and writes the two plots' own windows, not the model.
sync_compare_axes(*app);
sync_events_divider(*app);
@@ -5777,6 +5815,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_DISARM_MS")) {
g_timeout_add(static_cast(std::atoi(env)), disarm_probe_step1, &app);
}
+ if (const char* env = g_getenv("UDPSCOPE_PROBE_FORCE_MS")) {
+ g_timeout_add(static_cast(std::atoi(env)), force_probe, &app);
+ }
if (const char* env = g_getenv("UDPSCOPE_ARM_TRIGGER_MS")) {
g_timeout_add(static_cast(std::atoi(env)), arm_probe, &app);
}
@@ -5909,6 +5950,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_RESEQ_MS")) {
g_timeout_add(static_cast(std::atoi(env)), reseq_probe, &app);
}
+ if (const char* env = g_getenv("UDPSCOPE_PROBE_PERSIST_MS")) {
+ g_timeout_add(static_cast(std::atoi(env)), persist_probe, &app);
+ }
// Our own options were consumed by parse_args(); GTK would reject them, so only
// the program name is handed to g_application_run().
@@ -6340,7 +6384,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
(app.search_run_button != nullptr) + (app.search_cancel_button != nullptr) +
(app.search_prev_button != nullptr) + (app.search_next_button != nullptr);
std::printf(
- "[udpscope] trigger panel: boxes=%d controls_inside=%d/15 search_widgets=%d/4 "
+ "[udpscope] trigger panel: boxes=%d controls_inside=%d/16 search_widgets=%d/4 "
"search_inside=%d/4\n",
boxes,
inside(app.trigger_signal_drop, app.trigger_body) +
@@ -6357,6 +6401,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
inside(app.trigger_holdoff_spin, app.trigger_body) +
inside(app.trigger_band_spin, app.trigger_body) +
inside(app.trigger_hf_spin, app.trigger_body) +
+ inside(app.trigger_persist_spin, app.trigger_body) +
inside(app.events_record_switch, app.trigger_body),
search_present,
inside(app.search_run_button, search_root) + inside(app.search_cancel_button, search_root) +
@@ -6600,6 +6645,32 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
static_cast(buf != nullptr ? buf->size() : 0));
}
}
+ {
+ // Persistence: what is retained, what the rows had room to draw, and the decay schedule in
+ // use (oldest first, so the half-per-acquisition fall-off reads straight off the line). The
+ // layer slots are named in the `layout:` line above, as `sine (A)`, and are deliberately
+ // absent from the per-slot channel lines below it: a layer is not a channel.
+ std::lock_guard lock(model.mutex());
+ const int depth = model.persist_depth_locked();
+ std::printf("[udpscope] persistence: depth=%d kept=%zu bytes=%zu budget=%zu dropped=%llu "
+ "layers=%d want=%d placed=%d schedule=[",
+ depth, model.persist_count(), model.persist_bytes(),
+ model.persist_budget_bytes(),
+ static_cast(model.persist_dropped()), rows.layer_slots(),
+ app.persist_want, app.persist_placed);
+ // The decay schedule the depth asks for, oldest first: half per acquisition, so this line
+ // reads the rule and the probe's `alphas=` reads what the rows were actually built with.
+ for (int age = std::min(depth - 1, kMaxPersistDepth - 1); age >= 1; --age) {
+ std::printf("%s%.4f", age != depth - 1 ? "," : "",
+ static_cast(RowSet::persist_alpha(age)));
+ }
+ std::printf("] alphas=[");
+ const std::vector> layers = rows.row_layers(0);
+ for (std::size_t i = 0; i < layers.size(); ++i) {
+ std::printf("%s%.4f", i != 0 ? "," : "", static_cast(layers[i].second));
+ }
+ std::printf("]\n");
+ }
}
return result;
}
diff --git a/src/ui/trigger-panel.ui b/src/ui/trigger-panel.ui
index 6e814fb..da9c566 100644
--- a/src/ui/trigger-panel.ui
+++ b/src/ui/trigger-panel.ui
@@ -2,10 +2,11 @@
+
diff --git a/src/ui_app.hpp b/src/ui_app.hpp
index 022d36a..d71678e 100644
--- a/src/ui_app.hpp
+++ b/src/ui_app.hpp
@@ -256,6 +256,66 @@ struct App {
std::size_t dbg_persist_last_kept = 0;
int dbg_persist_stable_polls = 0;
bool dbg_persist_settled = false;
+ /// The phosphor density's controls -- the trigger pane's *Advanced* block: the switch, the
+ /// dropdown that chooses what the image fades by, and the two parameter spins, only one of which
+ /// is on screen at a time (`sync_trigger_rows()` states that, as it does for the condition rows).
+ /// The rows and the expander are held too, so the probe can read what the `.ui` holds and the dump
+ /// can walk into the block -- an expander's child is *not* in the widget-parent chain.
+ GtkWidget* trigger_density_switch = nullptr;
+ GtkWidget* trigger_density_decay_drop = nullptr;
+ GtkWidget* trigger_density_tau_spin = nullptr;
+ GtkWidget* trigger_density_factor_spin = nullptr;
+ GtkWidget* trigger_density_row = nullptr;
+ GtkWidget* trigger_density_decay_row = nullptr;
+ GtkWidget* trigger_density_tau_row = nullptr;
+ GtkWidget* trigger_density_factor_row = nullptr;
+ GtkWidget* trigger_advanced = nullptr;
+ /// A new acquisition has reached the screen and has not been deposited into the density grid yet.
+ /// Set where a snapshot is *framed* -- the held-event branch of the tick -- and when the display
+ /// is switched on under an acquisition already on screen, and consumed by the next tick's
+ /// `apply_density()`: the deposit needs the plot laid out for the acquisition it deposits, and
+ /// that layout is the frame's, one frame after the bind.
+ bool density_pending = false;
+ /// The previous tick's clock (`g_get_monotonic_time()`), for the density's per-frame fade; 0
+ /// before the first frame, which is what makes the first frame's `dt` zero rather than since-boot.
+ gint64 density_last_us = 0;
+ /// Whether the density was on the last time the tick looked, so switching it on under an
+ /// acquisition already on screen deposits that one at once instead of waiting for a latch that
+ /// `single` mode may never send.
+ bool density_was_on = false;
+ /// What the last tick left on screen, for the dump: the rows the density was on, the total ink
+ /// and the peak over them, and the bytes the last *frame* uploaded. Captured on the tick because
+ /// the dump runs after the window is gone.
+ /// The **last tick the density was on**, captured there and not read back from the plots at
+ /// exit: the dump runs after the window is gone and after the exit probe has switched the trigger
+ /// off, which turns the display off with it -- a dump that asked the plots would say `rows=0`
+ /// about a run whose whole tail had the image up.
+ int dbg_density_rows = 0;
+ unsigned long long dbg_density_cells = 0;
+ unsigned long long dbg_density_grid_bytes = 0;
+ double dbg_density_ink = 0.0;
+ double dbg_density_peak = 0.0;
+ /// The most any one frame **uploaded** of the grid: one byte a cell, so this is the resolution
+ /// read back as the frame's own cost. A maximum rather than the last value, because the exit
+ /// probe changes the display before the dump runs and the last painted frame is then not a frame
+ /// of this display.
+ long long dbg_density_upload_bytes = 0;
+ /// How many ticks it was on for: 0 is "no image was ever drawn", which is what separates a real
+ /// reading from a default.
+ int dbg_density_frames = 0;
+ /// The frame-cost accumulators as of the tick the probe switched the display on, so the cost can
+ /// be read **before and after** it inside one run -- the same stream, the same layout, the same
+ /// mock. Two runs would differ in everything but the density.
+ double dbg_density_tick_sum0 = 0.0;
+ int dbg_density_tick_n0 = 0;
+ double dbg_density_draw_sum0 = 0.0;
+ int dbg_density_draw_n0 = 0;
+ /// The density probe's own state: the time constant its second half uses, and how many polls it
+ /// has spent waiting for an event to be on screen.
+ double dbg_density_tau = 1.0;
+ int dbg_density_polls = 0;
+ /// The density probe's step, so its sequence is a state machine rather than nested timers.
+ int dbg_density_stage = 0;
GtkWidget* signal_list = nullptr;
GtkWidget* pause_button = nullptr;
/// When the pause began, on the **monotonic wall clock**, or 0 while running. The elapsed time
@@ -1817,6 +1877,26 @@ void persist_probe_report(App& a, const char* what);
void persist_probe_set_depth(App& a, int depth);
gboolean persist_probe_wait(gpointer user);
gboolean persist_probe(gpointer user);
+
+/// The phosphor density's one step for a frame: state every row's `plot::Plot` with the
+/// configuration the trigger holds, advance its fade by the frame's own `dt`, and deposit the
+/// acquisition on screen if one has arrived since the last step. `display` and `live_display` are
+/// passed in rather than re-read, so the step, the bind and the status line all describe one frame.
+///
+/// The density is on while an acquisition is on screen -- `Held`, and `Capturing` as it fills --
+/// and off on a live view, a paused one, or a file: the display the user asked for is a *trigger*
+/// display. Off is not "paused": switching it off (and leaving trigger mode, and opening a file)
+/// drops the image at once, so a fresh presentation starts empty rather than showing a stale image
+/// at another scale.
+void apply_density(App& a, DisplayMode display, bool live_display);
+
+/// The density probe (`UDPSCOPE_PROBE_DENSITY_MS`): the display's own numbers read back off the
+/// plots -- total ink and peak with it off, on, and after one decay step of each kind.
+void density_probe_report(App& a, const char* what);
+/// States the switch and/or the fade through the panel's own widgets, the way a user would.
+void density_probe_set(App& a, bool on, bool by_time);
+gboolean density_probe(gpointer user);
+gboolean density_probe_step(gpointer user);
void force_probe_report(App& a, const char* leg);
gboolean force_probe_press(gpointer user);
gboolean force_probe_poll(gpointer user);
diff --git a/src/ui_layout.cpp b/src/ui_layout.cpp
index 1a31e70..d6c059f 100644
--- a/src/ui_layout.cpp
+++ b/src/ui_layout.cpp
@@ -88,6 +88,10 @@ LayoutState collect_layout(App& app) {
st.trigger.noise_band = tc.condition.noise_band;
st.trigger.hf_fc = tc.condition.hf_fc;
st.trigger.persistence = tc.persist_depth;
+ st.trigger.density = tc.density;
+ st.trigger.density_decay = tc.density_decay == PersistDecay::Time ? "time" : "acquisitions";
+ st.trigger.density_tau_s = tc.density_tau_s;
+ st.trigger.density_factor = tc.density_factor;
st.trigger.level_given = true; // written means given, so a load keeps it
}
// The connection is the session's too: the file's name carries only host and port.
@@ -341,6 +345,14 @@ void apply_trigger_layout(App& app, const LayoutState::Trigger& t) {
tc.condition.hf_fc = t.hf_fc;
tc.pretrigger_pct = t.pretrigger_pct;
tc.holdoff_s = t.holdoff_s;
+ // The phosphor density: a plain field write, unlike the multiple-event depth below -- there is
+ // nothing to prune, because the image is not retained data. Its own clamp is the plotter's
+ // (`full_scale`/`gamma` are the plot's), and the two numbers a file may carry anywhere are
+ // already inside the floats the plot takes.
+ tc.density = t.density;
+ tc.density_decay = t.density_decay == "time" ? PersistDecay::Time : PersistDecay::Acquisitions;
+ tc.density_tau_s = t.density_tau_s;
+ tc.density_factor = t.density_factor;
}
// The persistence depth is not a field write like the rest: it *prunes* the retained
// acquisitions to what the file asks for, so it goes through the one setter (which takes the lock
diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp
index d8cf6c8..e2274b7 100644
--- a/src/ui_probe.cpp
+++ b/src/ui_probe.cpp
@@ -6061,5 +6061,278 @@ gboolean persist_probe_wait(gpointer user) {
return G_SOURCE_CONTINUE;
}
+
+// --- phosphor density (UDPSCOPE_PROBE_DENSITY_MS) -----------------------------
+//
+// The density is the *plotter's* image, so every number below is read off the plots -- the same
+// `DensityGrid::ink()`/`peak()` the library's own tests read -- summed and maxed over the rows the
+// display is on. The probe drives the panel's own widgets for the switch and the fade (the way a
+// click publishes them) and calls the plot's own `accumulate_density()`/`decay_density()` for the
+// two decay steps, so a step's ratio is arithmetic rather than a race against the frame clock: both
+// reads and the step happen inside one `g_timeout` callback, with no tick in between.
+//
+// The legs, in order:
+// * **off** -- the switch as a session that never turned it on leaves it, which is also the run
+// the exit dump's `frame cost:` line is read against (a plain run, no probe: the density is off
+// by default and nothing draws it);
+// * **on** -- one deposit, the acquisition already on screen (switching the display on under an
+// event deposits it at once rather than waiting for a latch `single` mode may never send);
+// * **step, acquisitions** -- one more accumulation: every cell keeps the factor of what it held
+// and the trace on screen adds its own pass. With the same frame in both reads the relation is
+// exact (`ink' = f*ink + ink`, the second `ink` being one pass), which is what the *library's*
+// own case asserts; **in the app the acquisition may move between the two reads**, so what the
+// line's ratio says is "the old image halved and one more pass landed on top" rather than a
+// number to expect -- and `ink` growing while the ratio stays above `f` is that reading. 0.5 is
+// the panel's default factor;
+// * **step, time** -- `exp(-dt/tau)` off the ink, with `dt = tau` stated outright, so the ratio
+// the line prints must be `1/e`.
+//
+// It is left **on**, with the fade it last set, so the tail of the run is the display with the
+// density: that is what makes the exit dump's `frame cost:` a mean over frames that uploaded an
+// image, to be compared with the same command without the probe.
+//
+// `UDPSCOPE_PROBE_DENSITY_TAU` states the time constant the time leg uses (default 1.0 s, clamped to
+// the panel's own range): `dt = tau` is one step whatever it is, so the ratio does not depend on it
+// -- it is there so a run can fade faster than a second and still be read.
+
+/// The time constant the probe's time leg sets, from `UDPSCOPE_PROBE_DENSITY_TAU` (default 1.0 s).
+double density_probe_tau() {
+ const char* env = g_getenv("UDPSCOPE_PROBE_DENSITY_TAU");
+ const double s = env != nullptr ? std::atof(env) : 1.0;
+ return s > 0.0 ? std::min(s, 3600.0) : 1.0;
+}
+
+/// What the density holds over every row it is on: the rows, the cells, and the two numbers the
+/// plot reports. A row with the display off contributes nothing -- it has no grid at all.
+struct DensityReading {
+ int rows = 0;
+ unsigned long long cells = 0;
+ double ink = 0.0;
+ double peak = 0.0;
+};
+
+DensityReading density_read(const App& a) {
+ DensityReading r;
+ for (int i = 0; i < a.rows->row_count(); ++i) {
+ const RowSet::Row& row = a.rows->row(i);
+ if (row.plot == nullptr || !row.plot->density().enabled) continue;
+ const plot::DensityGrid& g = row.plot->density_grid();
+ ++r.rows;
+ r.cells += static_cast(g.cell_count());
+ r.ink += g.ink();
+ r.peak = std::max(r.peak, static_cast(g.peak()));
+ }
+ return r;
+}
+
+/// One decay step of each kind, on every row the display is on. The acquisitions step is the
+/// plotter's own accumulation (its per-acquisition fade happens inside it, before the deposit), and
+/// the time step is `decay_density(tau)` -- the same call the frame tick makes with a frame's `dt`.
+double density_step(App& a, bool by_time) {
+ double before = 0.0;
+ for (int i = 0; i < a.rows->row_count(); ++i) {
+ const RowSet::Row& row = a.rows->row(i);
+ if (row.plot == nullptr || !row.plot->density().enabled) continue;
+ before += row.plot->density_grid().ink();
+ }
+ for (int i = 0; i < a.rows->row_count(); ++i) {
+ plot::Plot* p = a.rows->row(i).plot;
+ if (p == nullptr || !p->density().enabled) continue;
+ if (by_time) {
+ p->decay_density(a.dbg_density_tau);
+ } else {
+ p->accumulate_density();
+ }
+ }
+ double after = 0.0;
+ for (int i = 0; i < a.rows->row_count(); ++i) {
+ const RowSet::Row& row = a.rows->row(i);
+ if (row.plot == nullptr || !row.plot->density().enabled) continue;
+ after += row.plot->density_grid().ink();
+ }
+ return before > 0.0 ? after / before : 0.0;
+}
+
+/// States the display through the panel's own widgets and publishes it: the *widget* is the path a
+/// user takes, so what is measured is the app's wiring rather than a second way in.
+void density_probe_set(App& a, bool on, bool by_time) {
+ if (a.trigger_density_switch != nullptr) {
+ gtk_switch_set_active(GTK_SWITCH(a.trigger_density_switch), on);
+ }
+ if (a.trigger_density_decay_drop != nullptr) {
+ gtk_drop_down_set_selected(GTK_DROP_DOWN(a.trigger_density_decay_drop), by_time ? 1u : 0u);
+ }
+ publish_trigger_fields(&a);
+}
+
+/// One leg's line: the numbers, the fade in force, and the ratio a decay step produced (1.0 where no
+/// step was taken).
+void density_probe_report(App& a, const char* what, double ratio) {
+ const DensityReading r = density_read(a);
+ // The configuration is read from the **model**, not off the widgets: the switch and the dropdown
+ // are what the probe writes, so reading them back would only say what it just set. The model is
+ // what the tick reads, so `fade=`/`tau=` beside `rows=` is the wiring's own answer -- and a
+ // spread between the two is the defect the line exists for (a switch that published nothing, a
+ // tau the plot never saw).
+ TriggerConfig tc;
+ {
+ std::lock_guard lock(a.model->mutex());
+ tc = a.model->trigger();
+ }
+ std::fprintf(stderr,
+ "[density-probe] %s enabled=%d fade=%s tau=%.3f factor=%.3f rows=%d cells=%llu "
+ "ink=%.1f peak=%.4f ratio=%.6f\n",
+ what, tc.density ? 1 : 0,
+ tc.density_decay == PersistDecay::Time ? "time" : "acquisitions", tc.density_tau_s,
+ tc.density_factor, r.rows, r.cells, r.ink, r.peak, ratio);
+ std::fflush(stderr);
+}
+
+/// Step 1: the display **off** through the panel's switch (a session file remembers the trigger, so
+/// the off state is stated rather than assumed), then the trigger armed on the mock's `square` the
+/// way the persistence probe arms its own -- several crossings a second, so an acquisition lands
+/// within a couple of seconds.
+gboolean density_probe(gpointer user) {
+ App* a = static_cast(user);
+ if (a->window == nullptr) return G_SOURCE_REMOVE;
+ density_probe_set(*a, /*on=*/false, /*by_time=*/false);
+ {
+ const std::vector& sigs = a->model->signals();
+ std::vector all(sigs.size());
+ for (std::size_t i = 0; i < all.size(); ++i) all[i] = i;
+ if (!all.empty()) a->model->set_signals_enabled(all, true);
+ }
+ // One signal per row, the arrangement the app gives an opened file and the persistence probe
+ // uses -- and, for this probe, the one that puts an image in *every* row (a reading that summed
+ // over rows would be one row's if only one row plotted anything).
+ const std::vector& setup_channels = a->model->channels();
+ const std::vector& setup_signals = a->model->signals();
+ const int setup_rows =
+ std::min(RowSet::kMaxRows, static_cast(setup_channels.size()));
+ if (setup_rows > 0) {
+ a->rows->set_row_count(setup_rows);
+ for (int r = 0; r < setup_rows; ++r) {
+ for (int s = 0; s < RowSet::kSlotsPerRow; ++s) a->rows->assign(r, s, "", "", 0);
+ const std::size_t ci = static_cast(r);
+ a->rows->assign(r, 0, setup_channels[ci].name, setup_channels[ci].name,
+ signal_color_index(setup_signals, setup_channels[ci].signal));
+ }
+ a->relayout_pending = true;
+ }
+ // The time constant the time leg will state, through the panel's own spin -- so the model's tau,
+ // the plot's tau and the step's `dt` are one number rather than three that must agree.
+ a->dbg_density_tau = density_probe_tau();
+ if (a->trigger_density_tau_spin != nullptr) {
+ gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_density_tau_spin), a->dbg_density_tau);
+ }
+ if (a->trigger_density_factor_spin != nullptr) {
+ gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_density_factor_spin), 0.5);
+ }
+ int sel = -1;
+ const std::vector& channels = a->model->channels();
+ for (std::size_t i = 0; i < channels.size(); ++i) {
+ if (channels[i].name == "square") sel = static_cast(i);
+ }
+ if (sel >= 0) {
+ gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_signal_drop), static_cast(sel));
+ }
+ gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_type_drop), 0);
+ gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_edge_drop), 0);
+ gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin), 0.0);
+ gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_holdoff_spin), 0.0);
+ apply_time_window(*a, 0.5);
+ publish_trigger_fields(a);
+ std::fprintf(stderr, "[density-probe] arming on '%s', window 0.5 s, display off\n",
+ sel >= 0 ? channels[static_cast(sel)].name.c_str() : "(none)");
+ std::fflush(stderr);
+ if (a->trigger_toggle != nullptr) {
+ gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
+ }
+ a->dbg_density_stage = 1;
+ a->dbg_density_polls = 0;
+ g_timeout_add(200, density_probe_step, a);
+ return G_SOURCE_REMOVE;
+}
+
+/// The sequence. Each step is a separate `g_timeout`, so whole frames -- ticks, binds, paints --
+/// happen between two of them: a reading taken in the same callback that set a widget would describe
+/// the frame *before* the change, which is the trap the compact-arm probe records.
+gboolean density_probe_step(gpointer user) {
+ App* a = static_cast(user);
+ if (a->window == nullptr) return G_SOURCE_REMOVE;
+ ++a->dbg_density_polls;
+ const bool held = a->model->display_mode() == DisplayMode::Held;
+ double lo = 0.0;
+ double hi = 0.0;
+ switch (a->dbg_density_stage) {
+ case 1:
+ // Wait for an acquisition to be on screen. The trigger's cadence is the stream's, so a fixed
+ // delay would report whatever happened to have latched; the cap reports anyway (and says so
+ // through `rows=0`).
+ if (!held || !a->rows->event_data_window(lo, hi)) {
+ if (a->dbg_density_polls > 100) {
+ density_probe_report(*a, "off", 1.0);
+ std::fprintf(stderr, "[density-probe] no event appeared; stopping\n");
+ std::fflush(stderr);
+ return G_SOURCE_REMOVE;
+ }
+ return G_SOURCE_CONTINUE;
+ }
+ density_probe_report(*a, "off", 1.0);
+ // The cost *before*: the accumulators as of this tick, so the "after" mean is over the frames
+ // that follow and the two are read off one run -- same stream, same layout, same mock. Two
+ // runs would differ in everything but the density, which is exactly what a frame-cost A/B
+ // cannot afford.
+ a->dbg_density_tick_sum0 = a->dbg_tick_ms_sum;
+ a->dbg_density_tick_n0 = a->dbg_tick_n;
+ a->dbg_density_draw_sum0 = a->dbg_draw_ms_sum;
+ a->dbg_density_draw_n0 = a->dbg_paint_n;
+ density_probe_set(*a, /*on=*/true, /*by_time=*/false);
+ a->dbg_density_stage = 2;
+ return G_SOURCE_CONTINUE;
+ case 2: {
+ // The frame that switched the display on deposited the acquisition already on screen; the
+ // numbers are the one-pass ones.
+ density_probe_report(*a, "on", 1.0);
+ const double ratio = density_step(*a, /*by_time=*/false);
+ density_probe_report(*a, "step-acquisitions", ratio);
+ density_probe_set(*a, /*on=*/true, /*by_time=*/true);
+ a->dbg_density_stage = 3;
+ return G_SOURCE_CONTINUE;
+ }
+ case 3: {
+ density_probe_report(*a, "time-before", 1.0);
+ // One step of `dt = tau`, which is 1/e by the plot's own rule -- and the one reading that says
+ // the time fade is wired at all: the panel's constant reaches the grid through the tick.
+ const double ratio = density_step(*a, /*by_time=*/true);
+ density_probe_report(*a, "step-time", ratio);
+ // The cost *after*: the same two means over the frames since the switch, which is the
+ // difference this display costs -- the per-frame upload and deposit against the frame without
+ // them. `upload_bytes` in the exit dump beside it is the one frame's share of it.
+ const int tick_n = a->dbg_tick_n - a->dbg_density_tick_n0;
+ const int draw_n = a->dbg_paint_n - a->dbg_density_draw_n0;
+ std::fprintf(stderr,
+ "[density-probe] frame cost: tick before=%.2f ms (%d frames) after=%.2f ms "
+ "(%d frames) draw before=%.2f ms (%d) after=%.2f ms (%d)\n",
+ a->dbg_density_tick_n0 > 0 ? (a->dbg_density_tick_sum0 / a->dbg_density_tick_n0)
+ : 0.0,
+ a->dbg_density_tick_n0,
+ tick_n > 0 ? (a->dbg_tick_ms_sum - a->dbg_density_tick_sum0) / tick_n : 0.0,
+ tick_n,
+ a->dbg_density_draw_n0 > 0 ? (a->dbg_density_draw_sum0 / a->dbg_density_draw_n0)
+ : 0.0,
+ a->dbg_density_draw_n0,
+ draw_n > 0 ? (a->dbg_draw_ms_sum - a->dbg_density_draw_sum0) / draw_n : 0.0,
+ draw_n);
+ std::fprintf(stderr, "[density-probe] done: the display is left on, fading by time\n");
+ std::fflush(stderr);
+ a->dbg_density_stage = 0;
+ return G_SOURCE_REMOVE;
+ }
+ default:
+ return G_SOURCE_REMOVE;
+ }
+}
+
} // namespace ui_impl
} // namespace udpscope
diff --git a/src/ui_trigger.cpp b/src/ui_trigger.cpp
index d03495f..6064ccd 100644
--- a/src/ui_trigger.cpp
+++ b/src/ui_trigger.cpp
@@ -248,6 +248,20 @@ void write_trigger_widgets(App& app) {
static_cast(
std::clamp(tc.persist_depth, 1, kMaxPersistDepth)));
}
+ if (app.trigger_density_switch != nullptr) {
+ gtk_switch_set_active(GTK_SWITCH(app.trigger_density_switch), tc.density);
+ }
+ if (app.trigger_density_decay_drop != nullptr) {
+ gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_density_decay_drop),
+ tc.density_decay == PersistDecay::Time ? 1u : 0u);
+ }
+ if (app.trigger_density_tau_spin != nullptr) {
+ gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_density_tau_spin), tc.density_tau_s);
+ }
+ if (app.trigger_density_factor_spin != nullptr) {
+ gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_density_factor_spin),
+ tc.density_factor);
+ }
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_type_drop),
dropdown_index_of(app.trigger_type_drop,
trigger_type_text(tc.condition.type)));
@@ -393,6 +407,23 @@ void publish_trigger_fields(App* app) {
const double hf = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_hf_spin));
const int persist =
static_cast(std::lround(gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_persist_spin))));
+ // The density's four controls. Guarded on null, unlike the ones above: they are built by this
+ // panel and a `.ui` that lost one leaves the field at its default rather than crashing the
+ // publish -- and the switch's own state is what a session file and the tick then agree on.
+ const bool density = app->trigger_density_switch != nullptr &&
+ gtk_switch_get_active(GTK_SWITCH(app->trigger_density_switch)) != FALSE;
+ const guint density_decay =
+ app->trigger_density_decay_drop != nullptr
+ ? gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_density_decay_drop))
+ : 0;
+ const double density_tau =
+ app->trigger_density_tau_spin != nullptr
+ ? gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_density_tau_spin))
+ : 1.0;
+ const double density_factor =
+ app->trigger_density_factor_spin != nullptr
+ ? gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_density_factor_spin))
+ : 0.5;
{
std::lock_guard lock(app->model->mutex());
TriggerConfig& tc = app->model->trigger();
@@ -411,6 +442,10 @@ void publish_trigger_fields(App* app) {
tc.condition.timeout_s = timeout;
tc.condition.noise_band = band;
tc.condition.hf_fc = hf;
+ tc.density = density;
+ tc.density_decay = density_decay == 1u ? PersistDecay::Time : PersistDecay::Acquisitions;
+ tc.density_tau_s = density_tau;
+ tc.density_factor = density_factor;
}
// The persistence depth is applied through the one setter rather than written into the config
// here: it is clamped to the cap *and* it prunes the retained acquisitions at once, so lowering
@@ -460,6 +495,37 @@ void sync_trigger_rows(App& app) {
gtk_spin_button_set_value(l2, other < level ? level - step : level + step);
}
}
+ // The density's own rows follow the *display*, not the condition: the two fades take different
+ // numbers, so only the one in use is on screen -- a greyed spin for the other is a field the user
+ // cannot tell from a broken one -- and the whole choice is hidden while the display is off, since
+ // a decay kind for an image that is not being drawn has no subject. The switch itself stays: it is
+ // the one control that turns the display on.
+ const bool density_on = app.trigger_density_switch != nullptr &&
+ gtk_switch_get_active(GTK_SWITCH(app.trigger_density_switch)) != FALSE;
+ const bool density_by_time =
+ app.trigger_density_decay_drop != nullptr &&
+ gtk_drop_down_get_selected(GTK_DROP_DOWN(app.trigger_density_decay_drop)) == 1u;
+ show(app.trigger_density_decay_row, density_on);
+ show(app.trigger_density_tau_row, density_on && density_by_time);
+ show(app.trigger_density_factor_row, density_on && !density_by_time);
+}
+
+/// The **density switch**. It publishes the field and nothing else: whether the image is on is
+/// read every frame from the model, so there is no widget state to keep in step, and the tick
+/// notices the transition itself -- which is what deposits the acquisition already on screen
+/// instead of waiting for the next latch (in `single` mode there may never be one).
+void on_density_toggled(GObject* o, GParamSpec*, gpointer user) {
+ (void)o;
+ publish_trigger_fields(static_cast(user));
+}
+
+/// The **fade** dropdown. Publishes, then re-states the block: which of the two parameter rows is
+/// on screen is this dropdown's business and `sync_trigger_rows()` is the one place that reads it.
+void on_density_decay_changed(GObject* o, GParamSpec*, gpointer user) {
+ (void)o;
+ App* app = static_cast(user);
+ publish_trigger_fields(app);
+ sync_trigger_rows(*app);
}
void on_trigger_field_changed(GtkWidget*, gpointer user) {
@@ -684,12 +750,65 @@ GtkWidget* build_trigger_panel(App& app) {
static_cast(persist_seed));
gtk_widget_set_tooltip_text(
app.trigger_persist_spin,
- "how many of the most recent triggered acquisitions stay on screen, each older one at half "
- "the previous one's brightness; 1 is the event alone. A layer needs a free trace slot in the "
- "row that plots its signal, and the retained acquisitions are also bounded by the buffer's "
- "own size");
+ "multiple events: how many of the most recent triggered acquisitions stay on screen as "
+ "separate traces, each older one at half the previous one's brightness; 1 is the event "
+ "alone. A layer needs a free trace slot in the row that plots its signal, and the retained "
+ "acquisitions are also bounded by the buffer's own size");
app.trigger_persist_row = row_for(ui, "persistence_row", app.trigger_persist_spin);
+ // The phosphor *density*: one screen-sized image every acquisition is accumulated into, drawn
+ // behind the traces and fading by a time constant or by a per-acquisition factor -- the other
+ // display of the same acquisitions, and nothing like the multiple-event one above (N traces
+ // against one image, and no count to choose). It applies in trigger mode only, which the body
+ // already is: the whole block goes insensitive with the trigger off, the panel's own rule.
+ app.trigger_density_switch = gtk_switch_new();
+ gtk_widget_set_valign(app.trigger_density_switch, GTK_ALIGN_CENTER);
+ gtk_widget_set_halign(app.trigger_density_switch, GTK_ALIGN_END);
+ {
+ std::lock_guard lock(app.model->mutex());
+ gtk_switch_set_active(GTK_SWITCH(app.trigger_density_switch),
+ app.model->trigger().density);
+ }
+ gtk_widget_set_tooltip_text(
+ app.trigger_density_switch,
+ "accumulate every triggered acquisition into one screen-sized image, drawn behind the "
+ "traces: brightness is how often a trace passed, so an image of a thousand acquisitions "
+ "costs what one costs. Trigger mode only, and cleared whenever the display leaves it");
+ g_signal_connect(app.trigger_density_switch, "notify::active",
+ G_CALLBACK(on_density_toggled), &app);
+ // Not `row_for()`: it expands its child, and an expanded switch is a wide pill that reads as a
+ // slider rather than as a switch -- the record row's rule, and the label carries the `hexpand`.
+ if (GtkWidget* density_row = GTK_WIDGET(gtk_builder_get_object(ui, "density_row"))) {
+ gtk_box_append(GTK_BOX(density_row), app.trigger_density_switch);
+ app.trigger_density_row = density_row;
+ }
+
+ const char* density_decays[] = {"acquisitions", "time", nullptr};
+ app.trigger_density_decay_drop = gtk_drop_down_new_from_strings(density_decays);
+ gtk_widget_set_tooltip_text(
+ app.trigger_density_decay_drop,
+ "'acquisitions': every acquisition multiplies the image by the factor below, so the N-th "
+ "acquisition back is the factor to the N -- it scales with the trigger rate. 'time': the "
+ "image fades by exp(-dt / the time constant) as the display runs, whatever the rate");
+ app.trigger_density_decay_row =
+ row_for(ui, "density_decay_row", app.trigger_density_decay_drop);
+ g_signal_connect(app.trigger_density_decay_drop, "notify::selected",
+ G_CALLBACK(on_density_decay_changed), &app);
+
+ app.trigger_density_tau_spin = spin(0.01, 3600.0, 0.1, 3, 1.0);
+ gtk_widget_set_tooltip_text(app.trigger_density_tau_spin,
+ "seconds for the image to fall to 1/e of its ink: the trace is still "
+ "visible a second afterwards, faintly, which is what a phosphor does");
+ app.trigger_density_tau_row = row_for(ui, "density_tau_row", app.trigger_density_tau_spin);
+
+ app.trigger_density_factor_spin = spin(0.0, 1.0, 0.05, 2, 0.5);
+ gtk_widget_set_tooltip_text(app.trigger_density_factor_spin,
+ "what each acquisition keeps of the ones before it: 0.5 is the "
+ "schedule a phosphor's afterglow has -- 1, 0.5, 0.25, ... -- and 0 "
+ "shows only the newest acquisition");
+ app.trigger_density_factor_row =
+ row_for(ui, "density_factor_row", app.trigger_density_factor_spin);
+
app.arm_button = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_arm"));
if (app.arm_button != nullptr) {
g_signal_connect(app.arm_button, "clicked", G_CALLBACK(on_arm_clicked), &app);
@@ -708,9 +827,14 @@ GtkWidget* build_trigger_panel(App& app) {
for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin,
app.trigger_width_spin, app.trigger_width2_spin,
app.trigger_level2_spin, app.trigger_timeout_spin, app.trigger_band_spin,
- app.trigger_hf_spin, app.trigger_persist_spin}) {
+ app.trigger_hf_spin, app.trigger_persist_spin,
+ app.trigger_density_tau_spin, app.trigger_density_factor_spin}) {
g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app);
}
+ // The advanced block itself, so the probe can read it back and the dump can walk into it: an
+ // expander's child is *not* in the widget-parent chain (see docs/claude/invariants-gtk-and-chrome.md),
+ // so "is this control in the panel" has to start from the child the expander returns.
+ app.trigger_advanced = GTK_WIDGET(gtk_builder_get_object(ui, "advanced_exp"));
g_signal_connect(app.trigger_edge_drop, "notify::selected",
G_CALLBACK(on_trigger_drop_changed), &app);
g_signal_connect(app.trigger_mode_drop, "notify::selected",
--
2.54.0
From 5e321995978a93ad6927ba51eced410a1e11ad85 Mon Sep 17 00:00:00 2001
From: Martino Ferrari
Date: Fri, 16 Oct 2026 17:38:06 +0200
Subject: [PATCH 004/127] fix: the density's ceiling comes from the fade, so
the ramp is visible
The library's `full_scale` now defaults to the default fade's steady state and `gamma` to 1.0 (its
own commit); this side states the ceiling for the fade *it* configured, because that is what the
ceiling means: the ink the display settles at, so the newest acquisition is a fraction of full ink
and white is reached only where acquisitions agree.
- **acquisitions**: `1/(1 - factor)` -- the geometric sum, 2.0 at the panel's 0.5, and 1.0 at 0
(only the newest sweep, at full ink).
- **time**: the library's default of two sweeps. A time fade has no fixed steady state -- it
settles where the trigger's rate and the constant meet, which is the *point* of it -- so a fast
trigger reads brighter than a slow one the way a phosphor's does.
- `gamma` is left at the library's 1.0: a cell is a count of sweeps, so half as many must read as
half the ink. With the ceiling at one sweep and gamma at 0.45 (the library's first defaults) one
sweep was already full white and every display looked the same -- which is exactly what the user
reported seeing, and why the assertion for it is a framebuffer one in the library's case
(`empty=21 full=83 quarter=54`) rather than anything `ink`/`peak` can say.
The probe's own figures are unchanged in kind (they are the grid's): `on` `peak=1.0000`,
`step-acquisitions` `peak=1.5000`, `step-time` `ratio=0.367879`, `off` all zeros -- re-read on this
build, with the frame cost `tick 0.53 -> 7.49 ms`, `draw 1.89 -> 16.78 ms` and `gtk warnings: 0` in
the same run. The trigger/pinning invariants carry the ceiling rule and why the eye found it.
---
deps/plotter | 2 +-
docs/claude/invariants-trigger-and-pinning.md | 11 +++++++++++
src/ui.cpp | 12 ++++++++++++
3 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/deps/plotter b/deps/plotter
index 6a6915e..7382052 160000
--- a/deps/plotter
+++ b/deps/plotter
@@ -1 +1 @@
-Subproject commit 6a6915e64e9a9bd5e7b8c93cfa600334bf192b10
+Subproject commit 738205293eca6e77d6876dbd0ad2df1a42f26791
diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md
index 4723572..1c8d7bb 100644
--- a/docs/claude/invariants-trigger-and-pinning.md
+++ b/docs/claude/invariants-trigger-and-pinning.md
@@ -403,6 +403,17 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
* **The switch deposits what is already on screen.** `on && !density_was_on` sets the same pending
flag, so a user who turns persistence on while an event is held sees it instead of waiting for a
latch that `single` mode may never send.
+ * **The brightness ceiling is the fade's steady state, and the ramp is linear in sweeps.** The app
+ states `DensityConfig::full_scale` from the fade it configured -- `1/(1 - factor)` for the
+ acquisition fade, the library's default two sweeps for the time one, which has no fixed steady
+ state (it settles where the rate and the constant meet) -- and leaves `gamma` at 1.0. A ceiling
+ at *one* sweep is the defect a user reported as "the colour fading is not applied, it looks
+ white": a single sweep was already full ink, everything above it clamped to the same pixel, and
+ a display of one sweep and a display of five were the same white. It is also the one half that
+ the *grid's* own numbers cannot see -- `ink`/`peak` were right while the picture was flat -- so
+ the assertion is a **framebuffer** one: `a_phosphor_density_is_drawn_under_the_traces` measures a
+ cell at a quarter of the scale against one at the ceiling and against the background
+ (`empty=21 full=83 quarter=54`), which is the reading the eye asked for.
* **It is the plot's, so a figure rebuild resets it** -- the grid lives on each `Plot`, and
`rebuild_all()` builds new ones. That is the one state a structural change (a row added, a signal
switched off, a trace recoloured) does not carry: it costs the accumulated image and nothing else,
diff --git a/src/ui.cpp b/src/ui.cpp
index bc77641..0f85ce4 100644
--- a/src/ui.cpp
+++ b/src/ui.cpp
@@ -3396,6 +3396,18 @@ void apply_density(App& app, DisplayMode display, bool live_display) {
: plot::DensityDecay::PerAcquisition;
cfg.time_constant_s = static_cast(tc.density_tau_s);
cfg.per_acquisition = static_cast(tc.density_factor);
+ // The brightness ceiling: the ink this fade settles at, so the newest acquisition is a
+ // *fraction* of full ink and the display reaches white only where the acquisitions agree. For a
+ // per-acquisition fade that is the geometric sum `1/(1-f)` exactly. A time fade has no fixed
+ // one -- it settles where the trigger's rate and the constant meet, which is the point of it --
+ // so it keeps the library's default of two sweeps, and a fast trigger then reads brighter than
+ // a slow one the way a phosphor's does. The gamma is left at the library's 1.0: the cell is a
+ // count of sweeps, so half as many sweeps must read as half the ink. (With the ceiling at one
+ // sweep -- the library's first default -- one sweep was already full white and the fade was
+ // invisible, which is exactly what a user reported seeing.)
+ cfg.full_scale = tc.density_decay == PersistDecay::Time
+ ? 2.0f
+ : static_cast(1.0 / std::max(1.0 - tc.density_factor, 0.05));
// The plot drops what it held when this turns the display off, so the step below only ever
// speaks about an image that is on screen.
p->set_density(cfg);
--
2.54.0
From da826f2cb57e5015c162112175fd5b87fc0e0690 Mon Sep 17 00:00:00 2001
From: Martino Ferrari
Date: Fri, 16 Oct 2026 20:21:57 +0200
Subject: [PATCH 005/127] fix: persistence is the trace's own phosphor, not a
density behind a stroke
The plotter now deposits every acquisition -- the newest included -- into one grid per trace and
draws no solid trace over it, each grid in that trace's colour and with a one-sweep ceiling (its own
commit, **1b8b55d**). This side needed almost no change of its own, because the colour was already
the trace's and the trace's colour is `RowSet::signal_color()` -- the invariants' one route -- but the
figures it reads back are now per trace:
- **The ceiling is left at the library's default of one sweep**, no longer derived from the fade.
Deriving it from the steady state is the gain offset the user reported between the latest
acquisition and the persistent image; a signal crossing the same place every acquisition now settles
at the trace's colour, at full strength, and the newest sweep reads as the trace it is a picture of.
- **`cells`/`grid_bytes` are the sum over the row's traces** -- one grid each, a trace with no series
holding none -- and both the probe and the exit dump carry `traces=` beside `rows=`. That is what
makes the per-signal memory figure read: **556 537 cells and 2.2 MB per signal**, at 8904600 bytes
over the four one-signal rows of the probe's run.
The probes, re-read on this build (the `verification.md` command, `--rate 500000 --batch 1000`):
[density-probe] off enabled=0 ... rows=0 traces=0 cells=0 ink=0.0 peak=0.0000
[density-probe] on enabled=1 ... rows=4 traces=4 cells=2226150 ink=226786.0 peak=1.0000
[density-probe] step-acquisitions ... peak=1.5000
[density-probe] time-before ... peak=2.1359
[density-probe] step-time ... ratio=0.367879
[density-probe] frame cost: tick 0.62 -> 6.96 ms (184/23 frames), draw 2.02 -> 6.16 ms (177/18)
[udpscope] density: frames=748 rows=4 traces=4 cells=2226150 grid_bytes=8904600 ink=489400.2 peak=2.2 upload_bytes=2226150
`on` `peak=1.0000`, `step-acquisitions` `peak=1.5000` and `step-time` `ratio=0.367879` are the legs
that were expected, unchanged; the *draw* mean is now **below** the run before the split (2.02 -> 6.16
against 2.09 -> 20.91), because the solid traces are no longer drawn over the image.
Docs updated with it: the trigger manual page (`persistence` is the trace, one image per signal, the
newest included), the README's persistence section, `invariants-trigger-and-pinning.md` (five rules,
the ceiling and the no-stroke rule among them), `verification.md` and `probe-readings.md` (the lines
above), the trigger panel's comment and tooltips, and the plotter submodule.
`cmake --build build --clean-first -j4`: **0 errors, the documented three warnings** (two
`MathNode::source`, one `-Woverlength-strings` at 12352); `make -C build test-ui` **13 ok**;
`make -C build test-help` **green** (15 pages); the self-check **0 `Gtk-WARNING`/`Gtk-CRITICAL`** and
`gtk warnings: 0`, two `buffer allocated:` lines.
---
README.md | 26 +++++---
deps/plotter | 2 +-
docs/claude/invariants-trigger-and-pinning.md | 56 ++++++++++-------
docs/claude/probe-readings.md | 62 +++++++++++--------
docs/claude/verification.md | 10 +--
help/C/org.udpscope.app/trigger.page | 19 +++---
src/ui.cpp | 58 +++++++++--------
src/ui/trigger-panel.ui | 8 +--
src/ui_app.hpp | 1 +
src/ui_probe.cpp | 51 ++++++++-------
src/ui_trigger.cpp | 18 +++---
11 files changed, 181 insertions(+), 130 deletions(-)
diff --git a/README.md b/README.md
index fea3c6e..418ceff 100644
--- a/README.md
+++ b/README.md
@@ -1269,14 +1269,21 @@ pinned: the axis, the framing and every measurement still describe the event on
`N = 1` retains nothing at all — no copy, no layer — and lowering the depth frees the retained
snapshots there and then, because a trigger that has stopped firing never reaches another publish.
-**Persistence: one image, every acquisition.** The trigger panel's `Advanced` block carries the
-*other* display of the same acquisitions, and it is not a count at all: `persistence` turns on a
-**phosphor density** — the plotter's screen-sized intensity image (`Plot::set_density()`, one `f32`
-cell per device pixel of the axes rect) into which every triggered acquisition is deposited and
-which is drawn behind the traces. A cell's brightness is how many acquisitions passed through it,
-so the sweep on screen is the brightest, the older ones add on top **with no depth to choose and no
-memory that grows with them** — the image is 2.2 MB over four 573x412 rows in the probe's run, and
-a thousand acquisitions cost the same. `fades by` picks how it decays: `acquisitions` (each
+**Persistence: one image per signal, every acquisition.** The trigger panel's `Advanced` block
+carries the *other* display of the same acquisitions, and it is not a count at all: `persistence`
+turns on a **phosphor density** — the plotter's screen-sized intensity image (`Plot::set_density()`,
+one `f32` cell per device pixel of the axes rect **per trace, in that trace's own signal colour**)
+into which every triggered acquisition is deposited, the newest included, and which is drawn *in
+place of* the traces. The current acquisition is therefore a sweep *inside* the image and fades with
+the rest; no solid polyline is drawn over it, which is what a report of "the persistent image plus
+the last acquisition, and the last one never fades in time mode" turned out to be. A cell's
+brightness is how many acquisitions passed through it — one sweep's ink is the trace's own colour,
+and where several agree it clamps there rather than going white — so the display brightens where the
+signal is steady and dims where it only passed, **with no depth to choose and no memory that grows
+with the acquisitions**: the cost is the screen's times the traces on it (2.2 MB over the probe's
+four one-signal rows; the default four signals in one 800x480 row are 6 MB) and a thousand
+acquisitions cost the same. Each signal is a separate image, so two signals agreeing in a cell read
+as two colours and not one blended grey. `fades by` picks how it decays: `acquisitions` (each
acquisition keeps the factor of what was before it, 0.5 by default — the phosphor afterglow
schedule) or `time` (a time constant in stream seconds, so the display fades while you watch
whatever the trigger rate). It applies in **trigger mode only** — while a captured acquisition is
@@ -1285,7 +1292,8 @@ is an image *of* a stream of acquisitions. Like the invalid-span bands it reserv
and nothing on it can be picked. The `[density-probe]` run that measured it is in
`docs/claude/probe-readings.md`; what it costs is in the same place (`[density-probe] frame cost:`:
tick 0.74 → 9.99 ms and draw 2.09 → 20.91 ms over the same run, at four rows and 2.2 M cells a
-frame, on llvmpipe).
+frame, on llvmpipe — measured before the per-trace split, when the one image was still re-filled
+per row).
**And it says what it costs.** A retained acquisition is a whole window copied out of the ring, so
the retention is bounded by two things: the depth, and **`--buffer-mb`** — persistence may hold at
diff --git a/deps/plotter b/deps/plotter
index 7382052..1b8b55d 160000
--- a/deps/plotter
+++ b/deps/plotter
@@ -1 +1 @@
-Subproject commit 738205293eca6e77d6876dbd0ad2df1a42f26791
+Subproject commit 1b8b55d3ee37d13a9d8533784a343ac1755b4153
diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md
index 1c8d7bb..e96c784 100644
--- a/docs/claude/invariants-trigger-and-pinning.md
+++ b/docs/claude/invariants-trigger-and-pinning.md
@@ -376,21 +376,26 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
trigger that has stopped firing never reaches another publish. A depth change is therefore also
how the memory comes back.
-- **The phosphor density is one *image*, and that is a different display from the layers above, not a
+- **The phosphor density is the *trace*, and that is a different display from the layers above, not a
mode of them.** The trigger panel's `Advanced` block offers both, and the words matter: *multiple
events* is `persist_depth` (N retained acquisitions, N traces, dimmer with age) while *persistence*
is `TriggerConfig::density` -- the plotter's `Plot::set_density()`, a screen-sized intensity grid
- every acquisition is deposited into and which fades either by a time constant or by a
- per-acquisition factor. There is **no depth** on the density and no per-acquisition memory: the
- image is `columns * rows * 4` bytes, one cell per device pixel of the row's axes (2.2 MB over the
- four rows of the probe's run), so a thousand acquisitions cost what one costs. A cell's value is a
- number of *sweeps*: the library deposits a cell once per trace per sweep however many of that
- trace's own samples crossed it (`[density-probe] on` reads `peak=1.0000` for one sweep; a per-hit
- deposit read 18) -- dwell is `Style::intensity_grading`'s question, not this one's.
- Four things hold it together, each of which is a rule:
+ **per trace, drawn in that trace's own colour**, every acquisition deposited into it, which fades
+ either by a time constant or by a per-acquisition factor. There is **no depth** on the density and
+ no per-acquisition memory: the image is `traces * columns * rows * 4` bytes, one cell per device
+ pixel of the row's axes for every trace the row draws (the app's default four signals in one row
+ are four grids -- 6 MB at 800x480; the probe's one-signal-per-row run is 2.2 MB over four rows), so
+ a thousand acquisitions cost what one costs. A cell's value is a number of *sweeps*: the library
+ deposits a cell once per trace per sweep however many of that trace's own samples crossed it
+ (`[density-probe] on` reads `peak=1.0000` for one sweep; a per-hit deposit read 18) -- dwell is
+ `Style::intensity_grading`'s question, not this one's. The colour is the trace's own
+ (`RowSet::signal_color()`, the same source the trace was built with and never a second one), so a
+ saturated cell is that signal's colour at full strength and two signals agreeing in a cell are two
+ quads of two colours rather than a blend.
+ Five things hold it together, each of which is a rule:
* **It is a trigger-mode display.** `apply_density()` (the tick) states `Held` and `Capturing` as
the states it is on in; `Live`, `Waiting`, `Paused` and a file are not, and turning it off drops
- the grid **at once** rather than at an acquisition a quiet trigger never sends (the rule
+ the grids **at once** rather than at an acquisition a quiet trigger never sends (the rule
`set_persist_depth()` follows). So it goes away with the trigger, and a re-enable starts from an
empty image rather than a stale one at another scale.
* **One deposit per acquisition, one frame *after* the frame that frames it.** `Plot::accumulate_density()`
@@ -403,21 +408,26 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
* **The switch deposits what is already on screen.** `on && !density_was_on` sets the same pending
flag, so a user who turns persistence on while an event is held sees it instead of waiting for a
latch that `single` mode may never send.
- * **The brightness ceiling is the fade's steady state, and the ramp is linear in sweeps.** The app
- states `DensityConfig::full_scale` from the fade it configured -- `1/(1 - factor)` for the
- acquisition fade, the library's default two sweeps for the time one, which has no fixed steady
- state (it settles where the rate and the constant meet) -- and leaves `gamma` at 1.0. A ceiling
- at *one* sweep is the defect a user reported as "the colour fading is not applied, it looks
- white": a single sweep was already full ink, everything above it clamped to the same pixel, and
- a display of one sweep and a display of five were the same white. It is also the one half that
- the *grid's* own numbers cannot see -- `ink`/`peak` were right while the picture was flat -- so
- the assertion is a **framebuffer** one: `a_phosphor_density_is_drawn_under_the_traces` measures a
- cell at a quarter of the scale against one at the ceiling and against the background
- (`empty=21 full=83 quarter=54`), which is the reading the eye asked for.
- * **It is the plot's, so a figure rebuild resets it** -- the grid lives on each `Plot`, and
+ * **No trace is drawn over it, and the newest acquisition is inside it.** `Renderer::draw_traces()`
+ returns at once while a density is on: the trace ink *is* the image, so the acquisition just
+ deposited is a sweep in it and fades with the rest. Drawing the polyline too put a solid,
+ unfading stroke over its own faint ghost -- the reported "persistent image + last acquisition",
+ where the last acquisition never faded in time mode -- and the assertion is a **framebuffer** one:
+ `a_phosphor_density_is_the_trace_in_its_own_colour` reads the same pixel before and after one
+ time constant and requires it dimmer (`full=224 -> faded=115`, the trace's own `0xE0` green),
+ which a stroke over the density could not do.
+ * **The brightness ceiling is the trace's own brightness, and the ramp is linear in sweeps.** The
+ app leaves `DensityConfig::full_scale` at the library's default of **one sweep** and `gamma` at
+ 1.0, so the acquisition just deposited reads as the trace it is a picture of and a signal crossing
+ the same place every acquisition settles *at* the trace's colour (the ceiling clamps), rather than
+ a fraction of a brighter steady state. The old ceiling -- the fade's steady state, `1/(1-factor)`
+ for the acquisition fade -- is the gain offset a user reported between the latest acquisition and
+ the persistent image, and the "dimmer by design" headroom that made the ramp visible is reversed
+ here.
+ * **It is the plot's, so a figure rebuild resets it** -- the grids live on each `Plot`, and
`rebuild_all()` builds new ones. That is the one state a structural change (a row added, a signal
switched off, a trace recoloured) does not carry: it costs the accumulated image and nothing else,
- and the alternative -- keeping the grid in the `RowSet` -- is what would make it not a plot
+ and the alternative -- keeping the grids in the `RowSet` -- is what would make it not a plot
feature. The layout is unaffected either way: the density reserves no space and nothing on it can
be picked, exactly as `set_invalid_spans()` does.
* **The session carries it.** `trigger_density`, `trigger_density_decay`, `trigger_density_tau` and
diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md
index 1733be8..1c0dc45 100644
--- a/docs/claude/probe-readings.md
+++ b/docs/claude/probe-readings.md
@@ -1980,53 +1980,61 @@ ring's bindings, and a reading taken there says nothing about either.
```
[density-probe] arming on 'square', window 0.5 s, display off
-[density-probe] off enabled=0 fade=acquisitions tau=1.000 factor=0.500 rows=0 cells=0 ink=0.0 peak=0.0000 ratio=1.000000
-[density-probe] on enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 cells=2226150 ink=226596.0 peak=1.0000 ratio=1.000000
-[density-probe] step-acquisitions enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 cells=2226150 ink=262722.0 peak=1.5000 ratio=1.159429
-[density-probe] time-before enabled=1 fade=time tau=1.000 factor=0.500 rows=4 cells=2226150 ink=213362.2 peak=1.2182 ratio=1.000000
-[density-probe] step-time enabled=1 fade=time tau=1.000 factor=0.500 rows=4 cells=2226150 ink=78491.6 peak=0.4481 ratio=0.367879
-[density-probe] frame cost: tick before=0.74 ms (163 frames) after=9.99 ms (12 frames) draw before=2.09 ms (157) after=20.91 ms (7)
-[udpscope] density: frames=504 rows=4 cells=2226150 grid_bytes=8904600 ink=632845.4 peak=2.8 upload_bytes=1081350
+[density-probe] off enabled=0 fade=acquisitions tau=1.000 factor=0.500 rows=0 traces=0 cells=0 ink=0.0 peak=0.0000 ratio=1.000000
+[density-probe] on enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 traces=4 cells=2226150 ink=226786.0 peak=1.0000 ratio=1.000000
+[density-probe] step-acquisitions enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 traces=4 cells=2226150 ink=313971.0 peak=1.5000 ratio=1.384437
+[density-probe] time-before enabled=1 fade=time tau=1.000 factor=0.500 rows=4 traces=4 cells=2226150 ink=458610.1 peak=2.1359 ratio=1.000000
+[density-probe] step-time enabled=1 fade=time tau=1.000 factor=0.500 rows=4 traces=4 cells=2226150 ink=168713.2 peak=0.7858 ratio=0.367879
+[density-probe] frame cost: tick before=0.62 ms (184 frames) after=6.96 ms (23 frames) draw before=2.02 ms (177) after=6.16 ms (18)
+[udpscope] density: frames=748 rows=4 traces=4 cells=2226150 grid_bytes=8904600 ink=489400.2 peak=2.2 upload_bytes=2226150
```
The **phosphor density** (`Plot::set_density()`, the trigger pane's `persistence` switch): one
-screen-sized intensity image per row, every acquisition accumulated into it, drawn behind the traces and
-fading by a count or by the clock. The numbers are the plotter's own -- `DensityGrid::ink()`/`peak()` summed
-and maxed over the rows the display is on, which is the same source the library's tests read -- and the
+screen-sized intensity image **per trace**, in that trace's own signal colour, every acquisition
+accumulated into it (the newest included), drawn *in place of* the traces and fading by a count or by
+the clock. The numbers are the plotter's own -- `DensityGrid::ink()`/`peak()` summed and maxed over the
+traces the display is on, which is the same source the library's tests read -- and the
probe drives the panel's **own widgets** for the switch and the fade, so what it measures is this app's
wiring.
-- **`off` is the display without it**: `rows=0`, so there is no grid at all and both numbers are zero.
- That is also the run the frame cost is read against, and it is a plain run -- the density is off by
- default and nothing draws it.
-- **`on` is one sweep**: `peak=1.0000` exactly, and `ink=226596` is the cells that sweep covered over
- four rows. A cell takes **1.0 once per trace per sweep** however many of the trace's own samples crossed
- it, which is what makes a sweep's peak *one* rather than the eighteen a per-hit deposit read on the same
- run -- and what makes the sweeps behind it visible at all.
+- **`off` is the display without it**: `rows=0 traces=0`, so there is no grid at all and both numbers
+ are zero. That is also the run the frame cost is read against, and it is a plain run -- the density
+ is off by default and nothing draws it.
+- **`on` is one sweep**: `traces=4` -- one grid per signal, four rows of one signal each -- and
+ `peak=1.0000` exactly, with `ink=226786` the cells that sweep covered. A cell takes **1.0 once per
+ trace per sweep** however many of the trace's own samples crossed it, which is what makes a sweep's
+ peak *one* rather than the eighteen a per-hit deposit read on the same run -- and what makes the
+ sweeps behind it visible at all.
- **`step-acquisitions`** deposits one more sweep: `peak=1.0000 -> 1.5000`, which is `1 + 0.5 x 1` -- the
- panel's factor 0.5 applied to what was there, then the new sweep's own pass on top. `ink` rises from
- `226596` to `262722` for the same reason: the old image halved and one sweep landed on it. The ratio is
+ panel's factor 0.5 applied to what was there, then the new sweep's own pass on top. The ratio is
**not** a constant to expect -- the acquisition on screen can move between the two reads, so what it says
is "the old image halved and a pass landed on it"; the exact relation is the *library's* case
(`a_density_halves_per_acquisition_and_keeps_the_newest_brightest`), where the same frame is deposited
twice.
- **`step-time` is `1/e` by construction**: one step of `dt = tau` through the plot's own `decay_density()`,
so `ratio=0.367879` is `exp(-1)`. The leg before it (`time-before`) reads the same image under the time
- fade, and the pair is the reading that says the *panel's* constant reaches the grid.
+ fade (`peak=2.1359`), and the pair is the reading that says the *panel's* constant reaches the grid.
- **`frame cost:` is the A/B inside one run**: the accumulators as of the tick the display was switched on
against the frames after it, so both means are the same stream, the same layout and the same mock. At four
- rows of 573x412 cells it reads **tick 0.74 -> 9.99 ms** and **draw 2.09 -> 20.91 ms**: the per-frame
+ rows of 573x412 cells it reads **tick 0.62 -> 6.96 ms** and **draw 2.02 -> 6.16 ms**: the per-frame
conversion, upload and deposit. It is llvmpipe, and the arithmetic is the grid's -- 2.2 M cells, one byte
- each, every frame the time fade moves it.
+ each, every frame the time fade moves it. The *draw* mean is now below the run before the per-trace
+ split (2.02 -> 6.16 against 2.09 -> 20.91): the solid traces are no longer drawn over the image, so the
+ frame draws four quads where it used to draw four quads *and* every trace.
+- **`cells`/`grid_bytes` are the per-signal memory figure**: 556 537 cells and 2.2 MB *per signal*, times
+ the four signals here (2 226 150 cells, 8 904 600 bytes). It is the axes rect's device pixels, so it is
+ the screen and the trace count and nothing the trigger has done -- and the app's default layout, four
+ signals in one row, is four grids for that one row.
- **The dump's `density:` line** is captured on the last tick the display was **on**, not read at exit: the
exit probe switches the trigger off before it dumps, which takes the display with it, so a dump that asked
the plots would say `rows=0` about a run whose tail had the image up. `upload_bytes` is the **most** any
- one frame uploaded -- one byte a cell, over the rows that moved -- and not the last frame's, for the same
- reason.
+ one frame uploaded -- one byte a cell **per trace**, over the grids that moved -- and not the last frame's,
+ for the same reason.
-**Read `peak` as the display's ceiling**: with the default `full_scale` of 1.0 a cell at 1.0 is full ink, so
-a steady display under the acquisition fade sits between 1 and 2, and a `peak` well above that (the
-per-hit deposit read 189 after two sweeps) is the defect this probe's `on` leg now catches.
+**Read `peak` as the display's ceiling**: with the default `full_scale` of 1.0 a cell at 1.0 is full ink
+(the trace's own colour), so a steady display under the acquisition fade sits between 1 and 2, and a `peak`
+well above that (the per-hit deposit read 189 after two sweeps) is the defect this probe's `on` leg now
+catches.
**And `N = 1` is the display without the feature**, which is the same command at
`UDPSCOPE_PROBE_PERSIST_DEPTH=1`:
diff --git a/docs/claude/verification.md b/docs/claude/verification.md
index b7d4ec4..823b23b 100644
--- a/docs/claude/verification.md
+++ b/docs/claude/verification.md
@@ -115,8 +115,9 @@ precedent, not an exception.
schedule in use, oldest first — halves, and `1/2^age` at that. Both lines and the two exit-dump
lines are in [probe-readings.md](probe-readings.md).
- `UDPSCOPE_PROBE_DENSITY_MS` (+ `UDPSCOPE_PROBE_DENSITY_TAU`, default 1.0 s) — the **phosphor
- density**: one screen-sized intensity image every triggered acquisition is accumulated into, drawn
- behind the traces, fading by a per-acquisition factor or by a time constant. It states the display
+ density**: one screen-sized intensity image **per trace**, in that trace's own colour, every
+ triggered acquisition accumulated into it (the newest included), drawn *in place of* the traces,
+ fading by a per-acquisition factor or by a time constant. It states the display
**off** through the panel's own switch first, so the `off` leg is the run without it, then arms the
trigger on the mock's `square` with one signal per row — the arrangement the persistence probe uses
— and only then switches it on, so the acquisition already on screen is the first image. Four legs,
@@ -131,8 +132,9 @@ precedent, not an exception.
kill $PY
```
- **The assertions, in order:** `off` reads `rows=0 cells=0 ink=0.0 peak=0.0000` (there is no grid at
- all); `on` reads `rows=4` — every row has its own image — and **`peak=1.0000`**, one sweep's ink
+ **The assertions, in order:** `off` reads `rows=0 traces=0 cells=0 ink=0.0 peak=0.0000` (there is no
+ grid at all); `on` reads `rows=4` — every row has its own image, one per trace in its own colour,
+ so `traces=4` with one signal a row — and **`peak=1.0000`**, one sweep's ink
being one per cell it covers; `step-acquisitions` reads **`peak=1.5000`**, which is the panel's
0.5 applied to the old image plus the new sweep's own pass; and `step-time` reads
**`ratio=0.367879`**, one step of `dt = tau` through the plot's own `decay_density()`. A `peak` in
diff --git a/help/C/org.udpscope.app/trigger.page b/help/C/org.udpscope.app/trigger.page
index 5f85d2b..7d2cd1a 100644
--- a/help/C/org.udpscope.app/trigger.page
+++ b/help/C/org.udpscope.app/trigger.page
@@ -123,16 +123,19 @@
Persistence: how often the trace has been here
persistence — the switch in the trigger panel's Advanced block — keeps
- one image of the last acquisitions instead of their traces. Every acquisition is
- accumulated into it, so a cell's brightness means how many of them passed through it: the
- sweep on screen is the brightest, the ones behind it add on top, and where several agree the
- ink is brighter still. It is off by default.
+ an image of the last acquisitions instead of their traces. Every acquisition is
+ accumulated into it, the one on screen included, so a cell's brightness means how many of them
+ passed through it: the sweep on screen is the brightest, the ones behind it add on top, and
+ where several agree the ink is brighter still. There is no stroke drawn over the image — the
+ acquisition on screen is its newest ink — so it fades with the rest in both modes. Each
+ signal is kept as its own image in that signal's own colour, so where two signals cross the same
+ place you see both, not one blended line. It is off by default.
Two things make it different from multiple events beside it, and the panel offers
- both because they answer different questions. It never runs out of room: the image is
- the size of the plot on screen, so a display of a thousand acquisitions costs what a display of
- one costs, and there is no count to choose. And it fades, in one of two ways —
- fades by picks them:
+ both because they answer different questions. It never runs out of room: each signal's
+ image is the size of the plot on screen, so a display of a thousand acquisitions costs what a
+ display of one costs — one image per signal, whatever the plot's trace slots — and there is no
+ count to choose. And it fades, in one of two ways — fades by picks them:
acquisitions: every acquisition keeps
per acquisition of what was there before it (0.5, the default, is the schedule a
diff --git a/src/ui.cpp b/src/ui.cpp
index 0f85ce4..efd928e 100644
--- a/src/ui.cpp
+++ b/src/ui.cpp
@@ -3384,6 +3384,7 @@ void apply_density(App& app, DisplayMode display, bool live_display) {
app.density_was_on = on;
int rows_on = 0;
+ int traces_on = 0;
unsigned long long cells = 0;
double ink = 0.0;
double peak = 0.0;
@@ -3396,18 +3397,14 @@ void apply_density(App& app, DisplayMode display, bool live_display) {
: plot::DensityDecay::PerAcquisition;
cfg.time_constant_s = static_cast(tc.density_tau_s);
cfg.per_acquisition = static_cast(tc.density_factor);
- // The brightness ceiling: the ink this fade settles at, so the newest acquisition is a
- // *fraction* of full ink and the display reaches white only where the acquisitions agree. For a
- // per-acquisition fade that is the geometric sum `1/(1-f)` exactly. A time fade has no fixed
- // one -- it settles where the trigger's rate and the constant meet, which is the point of it --
- // so it keeps the library's default of two sweeps, and a fast trigger then reads brighter than
- // a slow one the way a phosphor's does. The gamma is left at the library's 1.0: the cell is a
- // count of sweeps, so half as many sweeps must read as half the ink. (With the ceiling at one
- // sweep -- the library's first default -- one sweep was already full white and the fade was
- // invisible, which is exactly what a user reported seeing.)
- cfg.full_scale = tc.density_decay == PersistDecay::Time
- ? 2.0f
- : static_cast(1.0 / std::max(1.0 - tc.density_factor, 0.05));
+ // The brightness ceiling is left at the library's default of **one sweep** -- the trace's own
+ // brightness -- so the acquisition just deposited reads as the trace it is a picture of and a
+ // signal crossing the same place every acquisition settles at the trace's colour rather than
+ // brighter. The fade's steady state is deliberately *not* the ceiling: it put a gain offset
+ // between the newest sweep and the image behind it (the newest at a fraction of the settled
+ // brightness), which is what a user reported as a gain offset between the latest acquisition
+ // and the persistent image, and the "dimmer by design" headroom that made the ramp visible is
+ // the wrong call at this brightness scale -- reversed here.
// The plot drops what it held when this turns the display off, so the step below only ever
// speaks about an image that is on screen.
p->set_density(cfg);
@@ -3415,16 +3412,26 @@ void apply_density(App& app, DisplayMode display, bool live_display) {
++rows_on;
// Time first, then the deposit: under `PerAcquisition` the decay is the accumulate path's own,
// so this is a no-op there; under `TimeConstant` it is this frame's fade and the deposit lands
- // on top of it at full ink.
+ // on top of it at full ink. Either way the *newest* acquisition is inside the image, so it
+ // fades with the rest -- the renderer draws no solid trace over the density.
p->decay_density(dt);
if (deposit) p->accumulate_density();
- cells += static_cast(p->density_grid().cell_count());
- ink += p->density_grid().ink();
- peak = std::max(peak, static_cast(p->density_grid().peak()));
+ // One grid per trace, each in its own signal's colour, so the row's figures are the **sum over
+ // its traces** and `cells` scales with the signal count as well as the row count. `peak` is the
+ // brightest of them, which is the reading that says whether any one trace is saturating.
+ for (plot::u32 t = 0; t < p->trace_count(); ++t) {
+ const plot::DensityGrid& g = p->density_grid(t);
+ if (g.cell_count() == 0u) continue; // an unbound slot holds no grid
+ ++traces_on;
+ cells += static_cast(g.cell_count());
+ ink += g.ink();
+ peak = std::max(peak, static_cast(g.peak()));
+ }
}
app.density_pending = false;
if (rows_on == 0) return; // nothing was on this frame: the dump's figures stay the last real ones
app.dbg_density_rows = rows_on;
+ app.dbg_density_traces = traces_on;
app.dbg_density_cells = cells;
app.dbg_density_grid_bytes = cells * sizeof(plot::f32);
app.dbg_density_ink = ink;
@@ -6804,15 +6811,16 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
{
// The phosphor density, as of the last tick it was **on** (the exit probe switches the
// trigger off before this runs, so the plots hold nothing by now -- see `App::dbg_density_*`).
- // `frames` is how many ticks drew an image, `cells`/`grid_bytes` its resolution and memory
- // over the rows it was on, `ink`/`peak` the numbers the plot's own readers report, and
- // `upload_bytes` the most any one frame uploaded -- one byte a cell, on the frames whose grid
- // moved; a frame whose grid did not move uploads nothing at all.
- std::printf("[udpscope] density: frames=%d rows=%d cells=%llu grid_bytes=%llu ink=%.1f "
- "peak=%.1f upload_bytes=%lld\n",
- app.dbg_density_frames, app.dbg_density_rows, app.dbg_density_cells,
- app.dbg_density_grid_bytes, app.dbg_density_ink, app.dbg_density_peak,
- app.dbg_density_upload_bytes);
+ // `frames` is how many ticks drew an image; `rows`/`traces` how many plots and how many of
+ // their traces held a grid (one per trace, in the trace's own colour); `cells`/`grid_bytes`
+ // its resolution and memory over them, `ink`/`peak` the numbers the plot's own readers
+ // report, and `upload_bytes` the most any one frame uploaded -- one byte a cell, on the frames
+ // whose grids moved; a frame whose grids did not move uploads nothing at all.
+ std::printf("[udpscope] density: frames=%d rows=%d traces=%d cells=%llu grid_bytes=%llu "
+ "ink=%.1f peak=%.1f upload_bytes=%lld\n",
+ app.dbg_density_frames, app.dbg_density_rows, app.dbg_density_traces,
+ app.dbg_density_cells, app.dbg_density_grid_bytes, app.dbg_density_ink,
+ app.dbg_density_peak, app.dbg_density_upload_bytes);
}
}
return result;
diff --git a/src/ui/trigger-panel.ui b/src/ui/trigger-panel.ui
index 22ec6a9..a850a16 100644
--- a/src/ui/trigger-panel.ui
+++ b/src/ui/trigger-panel.ui
@@ -217,11 +217,11 @@
Both displays live here and they are two different things: *multiple events* keeps the
last N acquisitions as N traces, dimmer with age, while *persistence* accumulates every
- acquisition into one screen-sized image (the plotter's `Plot::set_density()`). The
- first is a count and the second is a decay, which is why neither is a mode of the
- other. -->
+ acquisition into an image (one screen-sized grid per trace, in that trace's own colour,
+ the plotter's `Plot::set_density()`), drawn in place of the traces. The first is a
+ count and the second is a decay, which is why neither is a mode of the other. -->
- what the trigger mode does with the acquisitions it takes: the last few kept as dimmer traces, and a persistence image every acquisition is accumulated into
+ what the trigger mode does with the acquisitions it takes: the last few kept as dimmer traces, and a persistence image (one per signal, in its own colour) every acquisition is accumulated into
diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp
index 76e2b8b..20cdc1c 100644
--- a/src/ui_probe.cpp
+++ b/src/ui_probe.cpp
@@ -40,26 +40,46 @@ int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) {
if (s.graded_traces > 0) ++app->dbg_graded_frames;
if (s.raw_traces > 0 || s.lod_traces > 0) check_frame_ink(*app);
if (s.raw_traces == 0 && s.lod_traces == 0) {
- ++app->dbg_notrace_frames;
- app->dbg_notrace_last = app->dbg_paint_n;
- if (app->dbg_ever_bound) {
- ++app->dbg_notrace_after_bound;
- // What the frame was asked to draw, beside what there was to draw it from: an axis
- // that left the data and a trace that had nothing bound are the same picture and
- // different bugs, so both are named here rather than inferred from the count.
- if (app->dbg_notrace_after_bound <= 40) {
- const RowSet::Row& r = app->rows->row(0);
- const plot::Range ax = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{};
- std::fprintf(stderr, "[notrace] paint %d mode=%s axis=[%.6f..%.6f] segments=%u",
- app->dbg_paint_n, display_mode_name(app->dbg_display), ax.min, ax.max,
- s.staged_segments);
- for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) {
- if (r.channel[static_cast(sl)] < 0) continue;
- const plot::Range b = r.slots[static_cast(sl)].x_bounds();
- std::fprintf(stderr, " slot%d n=%zu x=[%.6f..%.6f]", sl,
- r.slots[static_cast(sl)].size(), b.min, b.max);
+ // **A frame the density is drawing is not a traceless one.** `Renderer::draw_traces()`
+ // returns while a plot's phosphor density is on -- the trace ink *is* the image -- so every
+ // frame of a persistence display stages no trace and would be counted here, which inflates
+ // the dump's reading and (with `UDPSCOPE_PROBE_NOTRACE` set below) prints a line per frame.
+ // That is the display working, not "what the frame was asked to draw" with nothing to draw
+ // it from. Read off the plots, because the frame's own stats can only say no trace was
+ // drawn and not why.
+ bool density_on = false;
+ for (int i = 0; i < app->rows->row_count() && !density_on; ++i) {
+ const plot::Plot* p = app->rows->row(i).plot;
+ density_on = p != nullptr && p->density().enabled;
+ }
+ if (!density_on) {
+ ++app->dbg_notrace_frames;
+ app->dbg_notrace_last = app->dbg_paint_n;
+ if (app->dbg_ever_bound) {
+ ++app->dbg_notrace_after_bound;
+ // What the frame was asked to draw, beside what there was to draw it from: an axis
+ // that left the data and a trace that had nothing bound are the same picture and
+ // different bugs, so both are named here rather than inferred from the count.
+ //
+ // **A per-paint line, so it is behind a variable** (`UDPSCOPE_PROBE_NOTRACE`), unlike
+ // the counters beside it: a state that draws nothing lasts as long as it lasts -- a
+ // `Waiting` tail, a bound row with no data -- and the line would be one per frame the
+ // whole time. The count in the dump is the reading; this is the detail behind it.
+ static const bool notrace_print = g_getenv("UDPSCOPE_PROBE_NOTRACE") != nullptr;
+ if (notrace_print && app->dbg_notrace_after_bound <= 40) {
+ const RowSet::Row& r = app->rows->row(0);
+ const plot::Range ax = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{};
+ std::fprintf(stderr, "[notrace] paint %d mode=%s axis=[%.6f..%.6f] segments=%u",
+ app->dbg_paint_n, display_mode_name(app->dbg_display), ax.min, ax.max,
+ s.staged_segments);
+ for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) {
+ if (r.channel[static_cast(sl)] < 0) continue;
+ const plot::Range b = r.slots[static_cast(sl)].x_bounds();
+ std::fprintf(stderr, " slot%d n=%zu x=[%.6f..%.6f]", sl,
+ r.slots[static_cast(sl)].size(), b.min, b.max);
+ }
+ std::fprintf(stderr, "\n");
}
- std::fprintf(stderr, "\n");
}
}
}
diff --git a/src/ui_trigger.cpp b/src/ui_trigger.cpp
index 0d7c8a6..67217de 100644
--- a/src/ui_trigger.cpp
+++ b/src/ui_trigger.cpp
@@ -756,7 +756,7 @@ GtkWidget* build_trigger_panel(App& app) {
"acquisitions are also bounded by the buffer's own size");
app.trigger_persist_row = row_for(ui, "persistence_row", app.trigger_persist_spin);
- // The phosphor *density*: one screen-sized image per trace, in that trace's own colour, every
+ // The phosphor *density*: one bounded image per trace, in that trace's own colour, every
// acquisition accumulated into it, drawn *in place of* the traces and fading by a time constant
// or by a per-acquisition factor -- the other display of the same acquisitions, and nothing like
// the multiple-event one above (an image per signal against N retained traces, and no count to
@@ -772,7 +772,7 @@ GtkWidget* build_trigger_panel(App& app) {
}
gtk_widget_set_tooltip_text(
app.trigger_density_switch,
- "accumulate every triggered acquisition into a screen-sized image per signal, in that "
+ "accumulate every triggered acquisition into an image per signal, in that "
"signal's own colour, drawn in place of the traces: brightness is how often a trace passed, "
"so an image of a thousand acquisitions costs what one costs. Trigger mode only, and cleared "
"whenever the display leaves it");
--
2.54.0
From fcf268d249e5e97008f321fd6afc724defbf75b2 Mon Sep 17 00:00:00 2001
From: Martino Ferrari
Date: Wed, 28 Oct 2026 16:03:05 +0100
Subject: [PATCH 007/127] Add --renderer for displays with no usable GPU; gate
the header reading
GTK4 defaults to Vulkan, and an X11-forwarded display has no device to make a
swapchain from; the plot is a GtkGLArea, so it then asks for a GL context that
is not there either. --renderer cairo is the software preset for that display:
it exports GSK_RENDERER=cairo and LIBGL_ALWAYS_SOFTWARE=1 in main() before
gtk_init()/adw_init(), because both are read when the renderer and the context
are created. --renderer gl is the faster choice when indirect GL works, and
--renderer vulkan makes the default explicit. Nothing is exported, and GTK's
own choice stands, when the option is absent.
The once-a-second header line is now behind UDPSCOPE_PROBE_HEADER, following
the [notrace] rule: its widths are still sampled for the exit dump, but the
line is detail behind that reading rather than output every run prints.
UDPSCOPE_PROBE_RENDERER prints the exported pair before GTK starts and the
GskRenderer GTK actually built.
README, the manual's overview and docs/claude record the two symptoms and the
cure.
---
README.md | 28 +++++++++++++++++++++++++-
docs/claude/build-and-run.md | 6 ++++++
docs/claude/probe-readings.md | 18 +++++++++++++++--
docs/claude/verification.md | 11 ++++++++++
help/C/org.udpscope.app/overview.page | 15 +++++++++++++-
src/config.cpp | 15 ++++++++++++++
src/config.hpp | 9 +++++++++
src/main.cpp | 27 +++++++++++++++++++++++++
src/ui.cpp | 29 ++++++++++++++++++++++++---
9 files changed, 151 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 50c2e46..25a2a25 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,10 @@ Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb`
window the toolbar offers, default 600), `--holdoff` (default trigger hold-off),
`--recv-buffer`, `--plots` (the number of stacked plots to start with, 1..8; rows are
added and removed from the **Layout editor** afterwards), `--relative-time` (see below),
-`--capture-packets N` (see "Packet capture" below).
+`--capture-packets N` (see "Packet capture" below), `--renderer NAME` (GTK's renderer:
+`cairo`, `gl` or `vulkan`; `cairo` is the software preset that exports both
+`GSK_RENDERER` and `LIBGL_ALWAYS_SOFTWARE` before GTK initialises — see the SSH
+limitation below).
**What a large `--buffer-mb` costs at startup, and what the window says while it happens.** The
buffer's rings are value-initialised, so allocating one touches every byte of `--buffer-mb` --- about
@@ -2922,6 +2925,29 @@ silence timeout and then resumes, which is the EMI case end to end.
inner popover takes the grab, the menu closes under the click, and the bubble is
left parented to a row in a hidden popover. Export is described in the
Documentation instead.
+- **Over SSH with X11 forwarding the window may never paint or allocate, and the cure is
+ `--renderer cairo`.** There are two walls, in order.
+ GTK4 defaults to Vulkan and a forwarded display has no usable Vulkan device, so the
+ log shows `vkCreateSwapchainKHR(): Initialization of an object could not be completed
+ ... (VK_ERROR_INITIALIZATION_FAILED)` and nothing is drawn. `GSK_RENDERER` alone gets
+ past that, but the plot is a `GtkGLArea` and asks GDK for a GL context of its own
+ whatever GSK draws with; over forwarding there is no hardware one, and the window then
+ stays unallocated — `header: window_button_w=0` with a zero `bar_w`, and no
+ `[width-probe]` line (it prints only once the plot has a viewport wider than 100 px) —
+ while the stream side is fine. Absence of `buffer allocated:` is *not* part of that
+ reading: signals arrive disabled, so a start with nothing enabled plans no buffer and
+ prints no line even on a healthy display, and the plot stays empty until a signal's
+ switch is turned on.
+ `--renderer cairo` is the preset for exactly this display: it exports
+ `GSK_RENDERER=cairo` **and** `LIBGL_ALWAYS_SOFTWARE=1` (llvmpipe answers the GL area),
+ the same pair `tools/make-help-shots.sh` uses. `--renderer gl` is the faster
+ alternative when indirect GL works. The variables are exported in `main()` **before**
+ `gtk_init()`/`adw_init()`, which is the whole trap: they are read when the renderer and
+ the GL context are created, so setting them later is a silent no-op. Without the option
+ nothing is set and GTK's own choice stands. No `GDK_BACKEND` companion exists on
+ purpose: an X11-forwarded session carries `DISPLAY` and no `WAYLAND_DISPLAY`, so GDK
+ already selects X11; forcing it would override a working local Wayland session for no
+ gain (if both are set and Wayland is broken, export `GDK_BACKEND=x11` yourself).
- **The tour card can cover its own target, and in the documented headless run it
does.** The card is placed under, over or beside the hole whenever one of those has
room; when the target covers the whole content -- which is what the plot does at the
diff --git a/docs/claude/build-and-run.md b/docs/claude/build-and-run.md
index f52482d..5e23f4a 100644
--- a/docs/claude/build-and-run.md
+++ b/docs/claude/build-and-run.md
@@ -103,6 +103,12 @@ count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwar
the value is required, and the popover button uses 1000 when the flag was absent).
The flag is **not** persisted in the session file: persistence of the enable state
belongs with `todo.md` item 3, alongside the relative-time toggle.
+`--renderer cairo|gl|vulkan` exports `GSK_RENDERER` **at the top of `main()`** —
+before `choose_source()`'s `adw_init()` and `run_ui()`'s, because GSK reads the variable when
+it creates its renderer and setting it later is a silent no-op. `cairo` is the software preset
+and also exports `LIBGL_ALWAYS_SOFTWARE=1`: the plot is a `GtkGLArea`, so a display with no
+usable GPU fails twice, and this is the pair `tools/make-help-shots.sh` already sets. Nothing
+is exported when the option is absent, so GTK's own choice stands.
`tools/mock_udpstreamer.py` has waveform-injection options used by the probes:
diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md
index 777032d..0f20a67 100644
--- a/docs/claude/probe-readings.md
+++ b/docs/claude/probe-readings.md
@@ -450,11 +450,25 @@ Two lines of the self-check output are assertions in disguise:
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 bar_w=B` (stderr, once a second) — **`W` must be
+- `[udpscope] header: window_button_w=W bar_w=B` (stderr, once a second, **only when
+ `UDPSCOPE_PROBE_HEADER` is set**) — **`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.
+ grows with the window width is that bug back. The widths are still sampled every second
+ in every run, because the exit dump's `header:` line reads them; only the line is behind
+ the variable, since a reading printed on every run is a log flood (the `[notrace]` rule).
+ An unallocated window reads `0` for both, which is the reading for "the toplevel never
+ got a size", not for a layout drift.
+- `[renderer] option=NAME GSK_RENDERER=V LIBGL_ALWAYS_SOFTWARE=V` (stderr, at startup,
+ before GTK initialises; only when `UDPSCOPE_PROBE_RENDERER` is set) — what `--renderer`
+ exported, printed from `main()` so the reading is of the environment a pre-init GTK will
+ see. `option=(unset)` with both variables `(unset)` is the default and the assertion that
+ the flag changes nothing unless asked.
+- `[renderer] realized=TYPE` (stderr, once, from the first tick that finds a window renderer;
+ same probe) — the class GTK actually built (`GskCairoRenderer` for `--renderer cairo`),
+ which is the half of the claim the export alone cannot make. It is null until the window is
+ realized, so a tick that finds none tries again rather than printing a false reading.
- `toolbar icons: trigger=NAME(ok) events=... pause=... cursors=... 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)`.** Three
diff --git a/docs/claude/verification.md b/docs/claude/verification.md
index e91556e..87525f4 100644
--- a/docs/claude/verification.md
+++ b/docs/claude/verification.md
@@ -155,6 +155,17 @@ precedent, not an exception.
(the frame's ink is the image) and would otherwise be counted as well. The counter itself is in
every build; see [probe-readings.md](probe-readings.md).
+- `UDPSCOPE_PROBE_HEADER` (no value; **not** a scheduled probe) — the once-a-second
+ `[udpscope] header: window_button_w=… bar_w=…` line. The widths are sampled either way (the
+ exit dump reads them); the line is behind this variable because it is detail behind that
+ reading, not a line every run should print. An unallocated window reads `0` for both.
+- `UDPSCOPE_PROBE_RENDERER` (no value; **not** a scheduled probe) — the renderer pair
+ `--renderer` should have exported, printed from `main()` before GTK initialises
+ (`[renderer] option=… GSK_RENDERER=… LIBGL_ALWAYS_SOFTWARE=…`), then the renderer GTK
+ built (`[renderer] realized=…`) once the window has one. Run it with `--renderer cairo` to
+ check the option reaches the environment before `gtk_init()`/`adw_init()`; run it without
+ the option to check the default is untouched.
+
- `UDPSCOPE_PROBE_TRIGRENUM_MS` — the trigger's capture must survive a **renumbering of its own
channel**. It sets the reported geometry through the real widgets (1 s window, 10 % pre share,
hold-off from `--holdoff`) on the mock's *square* alone, polls for a capture to open, and then
diff --git a/help/C/org.udpscope.app/overview.page b/help/C/org.udpscope.app/overview.page
index 6ea5685..43c2d4d 100644
--- a/help/C/org.udpscope.app/overview.page
+++ b/help/C/org.udpscope.app/overview.page
@@ -55,7 +55,20 @@
The signals arrive disabled. Nothing is acquired until you turn one on in the
signal list, and enabling is what starts acquisition — so a freshly opened scope with an
- idle side panel is working correctly, not failing to connect.
+ idle side panel is working correctly, not failing to connect. For the same reason a start
+ that has enabled nothing prints no buffer allocated: line on stderr.
+
+
+
Over SSH with X11 forwarding the window may never appear, and the cure is
+ udpscope --renderer cairo. Two symptoms tell the two halves apart: a
+ vkCreateSwapchainKHR() ... VK_ERROR_INITIALIZATION_FAILED warning is
+ GTK's default Vulkan renderer failing to make a swapchain and is what the option's
+ GSK_RENDERER=cairo answers; a window whose allocation stays zero
+ (window_button_w=0) means the plot's GL area has no context either, and
+ that is what its LIBGL_ALWAYS_SOFTWARE=1 answers. Both variables are
+ exported before GTK initialises, because they are read when the renderer and the GL
+ context are created. --renderer gl is faster when indirect GL works.
+
See Choosing a source for the chooser and the multicast
diff --git a/src/config.cpp b/src/config.cpp
index 66c1a72..1b71402 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -62,6 +62,12 @@ void print_usage(std::FILE* out, const char* argv0) {
" time is then kept for export only\n"
" --open FILE open an exported waveform (.npy) instead of a live source:\n"
" signals from its columns, no socket, no acquisition\n"
+ " --renderer NAME render with cairo (software), gl or vulkan. cairo also\n"
+ " exports LIBGL_ALWAYS_SOFTWARE=1, because the plot is a\n"
+ " GtkGLArea and still needs a GL context. Over SSH/X11\n"
+ " forwarding use cairo: Vulkan cannot make a swapchain there\n"
+ " and the GL area has no hardware context, so the window\n"
+ " never paints or allocates\n"
" -h, --help this text\n",
argv0);
}
@@ -113,6 +119,15 @@ Config parse_args(int argc, char** argv) {
c.capture_packets = static_cast(n);
} else if (a == "--open" || a.rfind("--open=", 0) == 0) {
c.open_waveform = value_of(argc, argv, i, "--open");
+ } else if (a == "--renderer" || a.rfind("--renderer=", 0) == 0) {
+ const std::string v = value_of(argc, argv, i, "--renderer");
+ // Only the three a source or a forwarded display actually chooses between. Anything
+ // GTK does not know would fall back with a warning and leave the user reading a
+ // renderer failure that is really a typo, so it is refused here instead.
+ if (v != "cairo" && v != "gl" && v != "vulkan") {
+ fail("--renderer must be cairo, gl or vulkan: " + v);
+ }
+ c.renderer = v;
} else if (a == "--relative-time") {
c.relative_time = true;
c.relative_time_specified = true;
diff --git a/src/config.hpp b/src/config.hpp
index 640ae16..3304834 100644
--- a/src/config.hpp
+++ b/src/config.hpp
@@ -60,6 +60,15 @@ struct Config {
/// and the chooser's Waveforms page sets this rather than the connection fields.
std::string open_waveform;
+ /// `--renderer NAME`: the GSK renderer to force, by exporting `GSK_RENDERER` **before any GTK
+ /// call**. Empty leaves GTK's own choice alone. `cairo` is the software preset over SSH/X11
+ /// forwarding: it also exports `LIBGL_ALWAYS_SOFTWARE=1`, because the plot's `GtkGLArea` asks
+ /// for a GL context whatever GSK draws with. `gl` is the faster alternative when indirect GL
+ /// works. The variables are read when the renderer/context is created, so setting them anywhere
+ /// after `gtk_init()`/`adw_init()` is a no-op -- which is why this is a `Config` field applied
+ /// at the top of `main()` and not a preference.
+ std::string renderer;
+
/// The source chooser's "Guided tour" entry. The tour is drawn on the application's own window,
/// which does not exist while the chooser runs, so the request rides on the one value `main()`
/// owns and hands to both the chooser and the front end (`open_waveform` and the preferences'
diff --git a/src/main.cpp b/src/main.cpp
index 9672625..b4e439c 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -10,6 +10,8 @@
#include "source_dialog.hpp"
#include "ui.hpp"
+#include
+
#include
#include
@@ -26,6 +28,31 @@ int main(int argc, char** argv) {
return 2;
}
+ // `--renderer` is exported here and nowhere else, because GSK reads `GSK_RENDERER` when it
+ // *creates* its renderer: inside the first `adw_init()`/`gtk_init()`, which `choose_source()`
+ // below may call. Setting it after that point is a silent no-op, so the one place it can be
+ // honoured is before anything touches GTK. Overwrite: a flag on the command line is a
+ // statement about this run, and the environment is only a default it may override.
+ //
+ // `cairo` is the whole software preset and not just GSK's half: the plot is a `GtkGLArea`,
+ // which asks GDK for a GL context of its own whatever GSK draws with. Over X11 forwarding
+ // that request is what leaves the window unallocated (no `buffer allocated:` line, zero
+ // width) once the Vulkan swapchain failure is out of the way, so `LIBGL_ALWAYS_SOFTWARE`
+ // makes Mesa answer it with llvmpipe. This is the pair `tools/make-help-shots.sh` sets.
+ if (!cfg.renderer.empty()) g_setenv("GSK_RENDERER", cfg.renderer.c_str(), TRUE);
+ if (cfg.renderer == "cairo") g_setenv("LIBGL_ALWAYS_SOFTWARE", "1", TRUE);
+ // The startup half of the renderer probe, printed before GTK exists: the request and the
+ // variables this process actually exported. The window's `[renderer] realized=` line is the
+ // other half and says which renderer GTK built.
+ if (g_getenv("UDPSCOPE_PROBE_RENDERER") != nullptr) {
+ const char* gsk = g_getenv("GSK_RENDERER");
+ const char* gl = g_getenv("LIBGL_ALWAYS_SOFTWARE");
+ std::fprintf(stderr, "[renderer] option=%s GSK_RENDERER=%s LIBGL_ALWAYS_SOFTWARE=%s\n",
+ cfg.renderer.empty() ? "(unset)" : cfg.renderer.c_str(),
+ gsk != nullptr ? gsk : "(unset)", gl != nullptr ? gl : "(unset)");
+ std::fflush(stderr);
+ }
+
// No `--server` means the user has not said which source to open, so ask. Before the model:
// declining must cost no buffer and no socket, and nothing below has to cope with "no source".
// `--open` is the other answer to that question, and the chooser knows it: its Waveforms page
diff --git a/src/ui.cpp b/src/ui.cpp
index efd928e..19d4f8e 100644
--- a/src/ui.cpp
+++ b/src/ui.cpp
@@ -3465,6 +3465,22 @@ gboolean tick(gpointer user) {
ScopeModel& model = *app->model;
const gint64 tick_start_us = g_get_monotonic_time();
+ // The renderer GTK actually built, printed once and only under the probe: `--renderer` exports
+ // `GSK_RENDERER` before any GTK call, and this is the other half of that claim -- the class GTK
+ // chose, not the name it was asked for. The renderer is null until the window is realized, so a
+ // tick that finds none simply tries again.
+ {
+ static const bool renderer_probe = g_getenv("UDPSCOPE_PROBE_RENDERER") != nullptr;
+ static bool renderer_printed = false;
+ if (renderer_probe && !renderer_printed) {
+ if (GskRenderer* r = gtk_native_get_renderer(GTK_NATIVE(app->window))) {
+ std::fprintf(stderr, "[renderer] realized=%s\n", G_OBJECT_TYPE_NAME(r));
+ std::fflush(stderr);
+ renderer_printed = true;
+ }
+ }
+ }
+
// The paint probe's clock, claimed on the first tick that finds one: a widget has no
// frame clock until it is realized, and the area is realized on its first draw.
if (app->dbg_clock == nullptr && app->area_widget != nullptr) {
@@ -3500,14 +3516,21 @@ gboolean tick(gpointer user) {
// the title's centre box, i.e. window_button_w tracked the window width).
if (app->window_button != nullptr) {
static gint64 next_header_log = 0;
+ // The widths stay sampled on this one-second cadence whatever the environment, because the
+ // exit dump's `header:` line reads them; only the *line* is behind a variable. A reading
+ // printed on every run is a log flood and not a reading -- the same rule the `[notrace]`
+ // per-paint line follows.
+ static const bool header_print = g_getenv("UDPSCOPE_PROBE_HEADER") != nullptr;
const gint64 now = g_get_monotonic_time();
if (now >= next_header_log) {
next_header_log = now + G_TIME_SPAN_SECOND;
app->dbg_win_btn_w = gtk_widget_get_width(app->window_button);
app->dbg_bar_w = gtk_widget_get_width(app->window);
- std::fprintf(stderr, "[udpscope] header: window_button_w=%d bar_w=%d\n",
- app->dbg_win_btn_w, app->dbg_bar_w);
- std::fflush(stderr);
+ if (header_print) {
+ std::fprintf(stderr, "[udpscope] header: window_button_w=%d bar_w=%d\n",
+ app->dbg_win_btn_w, app->dbg_bar_w);
+ std::fflush(stderr);
+ }
}
}
// The help layer's own readings and the bounds the help's annotated figures crop, on the same
--
2.54.0
From f3f8f67a62eccb7942dacb7c6cb32afa76a3c8af Mon Sep 17 00:00:00 2001
From: Martino Ferrari
Date: Wed, 28 Oct 2026 16:16:32 +0100
Subject: [PATCH 008/127] tools: a remote display script, Xvfb plus x11vnc over
an SSH tunnel
Over SSH with X11 forwarding the app does not appear: no usable Vulkan device means GTK cannot
create a swapchain, and `--renderer cairo` fixes that half only -- the plot is a GtkGLArea and asks
for its own GL context, so indirect GLX is the second wall. A virtual X server on the machine that
runs the app sidesteps both, because the GL context then lives next door to the samples.
`tools/udpscope-remote.sh` starts Xvfb on a free display, runs the app there with
`--renderer cairo`, and serves the screen through x11vnc bound to localhost only -- the SSH tunnel
is the authentication, so `-nopw` is deliberate and `-localhost` is what keeps a mistake from being
a remotely reachable screen. It prints the tunnel and viewer commands rather than assuming a
client, mentions the websockify/noVNC route for a browser, and takes the X and VNC servers with it
when the app exits. `--no-vnc` leaves just the virtual display, for another viewer; the preflight
names the install command instead of a bare "command not found".
Verified: the preflight refuses with exit 1 and the install line when x11vnc is absent; with
`--no-vnc` the app runs against the virtual display and reads `buffer allocated: 4 channels` with
no Vulkan warning and no critical, and no Xvfb is left behind when the timeout ends it. That run
also exercises what the earlier log showed by omission: nothing is enabled, so a working app
plots nothing.
The README's Limitations gains the bullet a person actually needs -- the exact warning string, that
both halves need the software preset, the script itself, and that a window which appears but stays
empty usually means no signal is enabled.
---
README.md | 10 +++
tools/udpscope-remote.sh | 135 +++++++++++++++++++++++++++++++++++++++
2 files changed, 145 insertions(+)
create mode 100755 tools/udpscope-remote.sh
diff --git a/README.md b/README.md
index 25a2a25..64bbd6b 100644
--- a/README.md
+++ b/README.md
@@ -2893,6 +2893,16 @@ silence timeout and then resumes, which is the EMI case end to end.
## Limitations
+- **A remote display needs the software renderer, and the plot is empty until a signal is
+ enabled.** Over SSH with X11 forwarding there is no usable Vulkan device: GTK cannot create a
+ swapchain (`vkCreateSwapchainKHR … VK_ERROR_INITIALIZATION_FAILED`) and the window never appears
+ even though the stream connects and its CONFIG parses. `--renderer cairo` selects the software
+ preset for **both** halves — GTK's renderer and the plot's own GL context, which is a
+ `GtkGLArea` and asks for one regardless of what GSK draws with — and
+ `tools/udpscope-remote.sh` runs the app against a virtual X server with a VNC view carried over
+ an SSH tunnel, avoiding X11 forwarding altogether. If a window does appear but stays empty, check
+ that a **signal is enabled**: signals start disabled, so a session with none switched on plots
+ nothing and prints no `buffer allocated:` line, by design rather than as a fault.
- **Multi-element (vector/matrix) signals are acquired with a zero-order hold.**
`udps_client` decodes exactly one *sample* of `num_rows x num_cols` values per
DATA packet for such a signal, regardless of the publish mode, so it cannot be
diff --git a/tools/udpscope-remote.sh b/tools/udpscope-remote.sh
new file mode 100755
index 0000000..a9857c5
--- /dev/null
+++ b/tools/udpscope-remote.sh
@@ -0,0 +1,135 @@
+#!/bin/sh
+# SPDX-License-Identifier: MIT
+# SPDX-FileCopyrightText: 2026 Martino Ferrari
+#
+# Run udpscope on a headless machine and look at it over an SSH tunnel.
+#
+# Why this exists: over SSH with X11 forwarding there is no usable Vulkan device, so GTK cannot
+# create a swapchain and the window never appears; `--renderer cairo` fixes GTK's half, but the
+# plot is a GtkGLArea and indirect GLX is the second wall. A virtual X server on the machine that
+# actually runs the app sidesteps both -- the GL context lives next door to the samples -- and VNC
+# carries the picture back through a tunnel. `ssh -X` is not needed and not used.
+#
+# The picture is rendered by Xvfb's software path, so the app must be told to use its software
+# preset too (`--renderer cairo`), otherwise GTK reaches for Vulkan inside Xvfb and warns exactly
+# as it does over a forwarded display.
+#
+# x11vnc binds to localhost only and runs without a password *by design*: the SSH tunnel is the
+# authentication. Never replace that with `-listen 0.0.0.0`, and never open the VNC port on the
+# host's firewall. `-localhost` exists here so that a mistake is not a remotely reachable screen.
+#
+# Usage:
+# tools/udpscope-remote.sh [--size WxH] [--port N] [--app PATH] [--no-vnc] [-- udpscope args...]
+#
+# --size WxH virtual screen (default 1600x1000)
+# --port N VNC port on localhost (default 5900)
+# --app PATH the binary to run (default build/udpscope)
+# --no-vnc start the X server and the app only; for another viewer, or to test the server
+# -- everything after this is passed to udpscope unchanged
+#
+# What it prints when it is up:
+# * the ssh command to run on *your* machine, which forwards the VNC port,
+# * the viewer command (any VNC client), and
+# * optionally how to get a browser instead (websockify + noVNC, if installed).
+#
+# Exits when udpscope exits, or on Ctrl-C, taking the X server and the VNC server with it.
+set -eu
+
+here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
+root=$(dirname -- "$here")
+
+size=1600x1000
+port=5900
+app=${UDPSCOPE_REMOTE_APP:-$root/build/udpscope}
+vnc=1
+app_args=
+
+while [ $# -gt 0 ]; do
+ case $1 in
+ --size) size=$2; shift 2 ;;
+ --port) port=$2; shift 2 ;;
+ --app) app=$2; shift 2 ;;
+ --no-vnc) vnc=0; shift ;;
+ --help|-h) sed -n '7,37p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
+ --) shift; app_args=$*; break ;;
+ *) echo "udpscope-remote: unknown option $1 (use -- to pass options to udpscope)" >&2; exit 2 ;;
+ esac
+done
+
+# Preflight, with the fix in the message rather than a bare "command not found".
+need() {
+ command -v "$1" >/dev/null 2>&1 || {
+ echo "udpscope-remote: $1 is required. $2" >&2
+ exit 1
+ }
+}
+need Xvfb "Install Xvfb (Arch: pacman -S xorg-server-xvfb; Debian: apt install xvfb)."
+if [ "$vnc" = 1 ]; then
+ need x11vnc "Install x11vnc (Arch: pacman -S x11vnc; Debian: apt install x11vnc), or pass --no-vnc."
+fi
+[ -x "$app" ] || { echo "udpscope-remote: $app is not executable; build it first or pass --app" >&2; exit 1; }
+
+# A free display: probe the ones a desktop is unlikely to be using and take the first that answers
+# no. `xdpyinfo` failing is the test -- a stale lock file does not matter, only a live server does.
+display=
+n=90
+while [ "$n" -le 120 ]; do
+ if ! xdpyinfo -display ":$n" >/dev/null 2>&1; then display=":$n"; break; fi
+ n=$((n + 1))
+done
+[ -n "$display" ] || { echo "udpscope-remote: no free display in :90..:120" >&2; exit 1; }
+
+xvfb_pid=
+app_pid=
+vnc_pid=
+cleanup() {
+ [ -n "${vnc_pid:-}" ] && kill "$vnc_pid" 2>/dev/null || true
+ [ -n "${app_pid:-}" ] && kill "$app_pid" 2>/dev/null || true
+ [ -n "${xvfb_pid:-}" ] && kill "$xvfb_pid" 2>/dev/null || true
+ wait 2>/dev/null || true
+}
+trap cleanup EXIT INT TERM
+
+# `-nolisten tcp` too: the virtual server is for this machine's sockets only.
+Xvfb "$display" -screen 0 "${size}x24" -nolisten tcp >/dev/null 2>&1 &
+xvfb_pid=$!
+for _ in 1 2 3 4 5 6 7 8 9 10; do
+ xdpyinfo -display "$display" >/dev/null 2>&1 && break
+ sleep 0.2
+done
+xdpyinfo -display "$display" >/dev/null 2>&1 || { echo "udpscope-remote: Xvfb did not come up on $display" >&2; exit 1; }
+
+export DISPLAY="$display"
+# The software preset, as one option: GTK's renderer *and* the GL-area path (see --renderer's help).
+[ "$vnc" = 1 ] && echo "udpscope-remote: screen $display ${size}x24, vnc on localhost:$port"
+
+# shellcheck disable=SC2086
+"$app" --renderer cairo $app_args &
+app_pid=$!
+
+if [ "$vnc" = 1 ]; then
+ x11vnc -display "$display" -localhost -nopw -forever -shared -quiet -rfbport "$port" &
+ vnc_pid=$!
+ sleep 1
+ cat <
+
+then, in another terminal on your machine:
+
+ vncviewer localhost:$port # or any VNC client
+
+A browser instead of a VNC client, if websockify and noVNC are installed here:
+
+ websockify 6080 localhost:$port &
+ # ...and open http://localhost:6080/vnc.html through a second tunnel:
+ # ssh -N -L 6080:localhost:6080 $(id -un)@
+
+Ctrl-C here, or quitting udpscope, closes the app and both servers.
+EOF
+fi
+
+# The app owns the run: when it exits, so does everything else (the trap).
+wait "$app_pid" || true
--
2.54.0
From 1a3393229aca775fb96181cdf8f634896adb0602 Mon Sep 17 00:00:00 2001
From: Martino Ferrari
Date: Sat, 31 Oct 2026 19:25:21 +0100
Subject: [PATCH 009/127] docs: design for the data-source plugin framework
(sub-project 1 of 4)
Co-Authored-By: Claude Opus 4.6
---
.../specs/2026-10-31-source-plugins-design.md | 116 ++++++++++++++++++
1 file changed, 116 insertions(+)
create mode 100644 docs/superpowers/specs/2026-10-31-source-plugins-design.md
diff --git a/docs/superpowers/specs/2026-10-31-source-plugins-design.md b/docs/superpowers/specs/2026-10-31-source-plugins-design.md
new file mode 100644
index 0000000..802cebb
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-31-source-plugins-design.md
@@ -0,0 +1,116 @@
+# Source plugins: framework and UDPS port (sub-project 1 of 4)
+
+Date: 2026-10-31
+Branch: `feature/source-plugins`
+
+## Context
+
+udpscope reads exactly one kind of source, a UDPStreamer stream through `deps/udpsclient`.
+The goal is to accept several kinds of data source as plugins, each with its own config
+dialog and, where the hardware offers it, its own UI (signal generator, digital I/O,
+multiple timebases).
+
+The work is split into four sub-projects, each with its own spec, plan and implementation:
+
+1. **Plugin framework + UDPS client ported onto it** (this document).
+2. Flat UDP plugin (standard MARTe2 UDPWriter stream; the wire format must be supplied or
+ located before that spec is written -- none was found in `~/workspace` or `~/Projects`).
+3. MCC E-1608 plugin. The protocol code in `MCC_E1608_DataSource` is bound to MARTe2
+ (`BasicTCPSocket`, `StreamString`, `SingleThreadService`); it is ported to plain C++, not
+ linked against MARTe2. AI streaming, DIO and DAC/generator panels.
+4. ABScope plugin. Needs a multi-timebase data path and a wire format: the ABScope design
+ only sketches the UDP header and `streamerd` is unwritten.
+
+Sub-project 1 changes no UDPS behaviour. It is a refactor plus the hosting machinery the
+later plugins need.
+
+## Decisions
+
+| Question | Decision |
+| --- | --- |
+| What "plugin" means | Compiled-in registry; the interface is kept clean enough for a `dlopen` loader to be added later (not built now). |
+| Threading | `Acquisition` keeps the single producer thread and calls `source->poll()`. UI actions reach the source through a thread-safe command queue drained inside `poll()`. CLAUDE.md's two-thread rule holds. (A per-source I/O thread was considered and deferred.) |
+| Source UI | Widget factory: a source returns `GtkWidget*` for its config form and each capability panel. |
+| Timebases in the frame | The neutral frame is single-timebase. Sub-project 4 extends interface and data path together; this may break the interface once, accepted. |
+| CLI / sessions | `--server/--port/...` keep meaning UDPS; `--source NAME` and `--source-opt k=v` added. Sessions keyed by kind + identity; legacy `host_port.toml` read as `udps`. |
+
+## Design
+
+### 1. The source interface (new `src/source/`)
+
+- **`SourceConfig`**: ordered string key/value map with typed getters. It is the stored form
+ (a TOML table), the `--source-opt` form, and what a config widget reads and writes.
+- **`Frame`**: source-neutral, mirrors `udps_frame_t`: `counter`, `lost`, `late`,
+ `recv_time`, `publish_mode`, `num_samples`, `signals[]` (the existing `SignalInfo`) and
+ `values[]` (doubles). The hole, backfill and time-base logic needs `counter/lost/late`; a
+ source with no counter supplies a running counter and `lost = 0`. `Frame::value(sig, k,
+ col)` replaces `udps_frame_value`.
+- **`DataSource`**: the per-connection object.
+ - `open()`, `poll(timeout_ms)`, `close()`.
+ - `stats()` returns the counters now in `StreamStats`; `connected()` reports link state.
+ - `post(Command)`: thread-safe queue, drained inside `poll()`.
+ - `set_datagram_tap(fn)`: optional; lets UDP-based sources feed the raw-packet capture.
+ - Output goes to a `SourceSink` (`on_config`, `on_data`, `on_event`) called inside
+ `poll()`, as `udps_client` does today.
+- **`SourceKind`**: factory and UI description of one plugin type: `id`, `display_name`,
+ `identity(cfg)`, `make(cfg)`, `make_config_form(cfg)` (a `GtkWidget*` plus a `read()`
+ closure) and `make_panels(source)` (each `{id, title, GtkWidget*}`).
+- **`SourceRegistry`**: filled by an explicit `register_builtin_sources()` call. No
+ static-init self-registration (linkers can drop it). A future `dlopen` loader would only
+ call `register_kind()`.
+
+### 2. Acquisition and the UDPS plugin
+
+- `Acquisition` takes a `std::unique_ptr` instead of building a `udps_client_t`
+ and implements `SourceSink`; the four trampolines go away.
+- `on_config` / `on_data` change only in the type they read. Time base, holes, backfill,
+ decimation, trigger and math code are untouched.
+- `run()` calls `source_->poll(50)` and copies `source_->stats()` into
+ `ScopeModel::stats()`. `PacketCapture` stays in `Acquisition`, fed through the datagram
+ tap. Sources without datagrams (E-1608) leave the capture UI disabled, not hidden.
+- `src/source/udps_source.cpp` wraps `deps/udpsclient` unchanged: it maps `udps_signal_t`
+ to `SignalInfo` and `udps_frame_t` to `Frame`, and carries the existing knobs (server,
+ port, multicast, interface, recv buffer). Identity is `host:port`.
+- The `udps_*` types are used only in `acquisition.cpp` today, so no other file changes for
+ this step beyond the `Frame` type.
+
+### 3. UI hosting, config and persistence
+
+- **Connect dialog** (`source_dialog.cpp`): gains a "Source type" dropdown; choosing a kind
+ swaps in that kind's `make_config_form()`. The UDPS form (server, port, multicast,
+ interface) moves into the UDPS plugin. The dialog keeps the known-sources list, buffer
+ size, relative time and capture ring size.
+- **Capability panels**: after the source is created the app calls `make_panels()` and adds
+ each panel as a page of a "Source" stack in the side area. A panel is insensitive when
+ disconnected (disabled, not hidden; the tab switcher owns visibility). A source with no
+ panels adds no tab; UDPS has none. Panels call `source->post(Command)`, never the source
+ directly, so cross-thread safety lives in one place. A helper kit for common panels (DIO
+ bank, generator form) lives in `src/source/widgets/` but is only added when a plugin uses
+ it, so it arrives with sub-project 3.
+- **Config / CLI**: `Config` gains `source_kind` (default `udps`) and a `SourceConfig`.
+ `--server/--port/--multicast/--interface` populate the UDPS entries. `--source NAME` and
+ repeatable `--source-opt k=v` work for any kind. `--help` lists the registered kinds.
+- **Sessions**: file `_.toml` with the source's config in a `[source]`
+ table. Legacy `host_port.toml` is read as `udps`; nothing is migrated destructively.
+- **Mocks**: `tools/mock_udpstreamer.py` remains the UDPS mock. Each later plugin ships its
+ own mock under `tools/` so the headless self-check keeps working.
+
+## Verification
+
+- This is a pure refactor for UDPS: the existing headless self-check must read exactly as on
+ `main` -- exactly three build warnings, no `Gtk-CRITICAL` (see `docs/claude/verification.md`).
+- New probe `UDPSCOPE_PROBE_SOURCES`, extending the probe/dump in `src/ui.cpp` rather than
+ adding a harness. It checks that the registry lists its kinds, that a config form
+ round-trips its `SourceConfig`, and -- with a stub test-only kind carrying one panel --
+ that the panel's tab is built, insensitive while disconnected, and that a posted command is
+ drained by `poll()`.
+- Document the new probe lines in `docs/claude/probe-readings.md`; update `architecture.md`
+ (a `DataSource` replaces the client in the thread description), `build-and-run.md`
+ (new CLI options) and `README.md`.
+
+## Out of scope
+
+- Runtime `.so` loading, and a per-source I/O thread.
+- Multi-timebase data path (sub-project 4).
+- The flat UDP, E-1608 and ABScope plugins (sub-projects 2-4).
+- The DIO / generator helper widgets (arrive with sub-project 3).
--
2.54.0
From 5bc14b1163f6c39de65d492c04e5f4e060203f99 Mon Sep 17 00:00:00 2001
From: Martino Ferrari
Date: Sat, 31 Oct 2026 19:52:21 +0100
Subject: [PATCH 010/127] docs: implementation plan for the source-plugin
framework
---
.../plans/2026-10-31-source-plugins.md | 2277 +++++++++++++++++
1 file changed, 2277 insertions(+)
create mode 100644 docs/superpowers/plans/2026-10-31-source-plugins.md
diff --git a/docs/superpowers/plans/2026-10-31-source-plugins.md b/docs/superpowers/plans/2026-10-31-source-plugins.md
new file mode 100644
index 0000000..6d28036
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-31-source-plugins.md
@@ -0,0 +1,2277 @@
+# Source Plugins: Framework and UDPS Port Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let udpscope read from pluggable data sources behind one `DataSource` interface, with the existing UDPStreamer client ported onto it and no change in UDPS behaviour.
+
+**Architecture:** A compiled-in `SourceRegistry` of `SourceKind` factories. `Acquisition` owns one `DataSource` and still runs the single producer thread, calling `source->poll(50)`; the GTK thread reaches the source only through `post(Command)`, a mutex-guarded queue drained inside `poll()`. A source-neutral `Frame` replaces `udps_frame_t`; each kind supplies a config form (`ConfigForm`) for the chooser and optional capability panels that become pages of the existing `side_stack`.
+
+**Tech Stack:** C++17, GTK4 + libadwaita, CMake, `deps/udpsclient` (wrapped, unchanged), `deps/plotter`. Python mock `tools/mock_udpstreamer.py`.
+
+**Spec:** `docs/superpowers/specs/2026-10-31-source-plugins-design.md` (Task 0 amends it with the implementation details settled here).
+
+## Global Constraints
+
+- There is **no unit test suite.** The only automated check is the headless self-check under `xvfb-run` against `tools/mock_udpstreamer.py`; extend the probes in `src/ui_probe.cpp` rather than adding a harness. Read `docs/claude/verification.md` and `docs/claude/probe-readings.md` before running one.
+- A clean full build has **exactly three warnings**; an incremental build shows one. The run must show `gtk warnings: 0` and no `Gtk-CRITICAL`.
+- Every new source file starts with `// SPDX-License-Identifier: MIT` and `// SPDX-FileCopyrightText: 2026 Martino Ferrari`.
+- Exactly two threads: the producer (owns the source) and the GTK main thread. UI code never touches a `DataSource` directly except `post()`.
+- UDPS behaviour must not change: `--server/--port/--multicast/--interface` keep meaning UDPS; legacy `host_port.toml` session files are read as `udps`; nothing is migrated destructively.
+- Disable, don't hide: a panel for a disconnected source is insensitive, not hidden.
+- GTK traps (see `docs/claude/invariants-gtk-and-chrome.md`): `notify::` handlers take `(GObject*, GParamSpec*, gpointer)`; probes click with `g_signal_emit_by_name(w, "clicked")`.
+- Verification runs are short and serial; kill the mock afterwards; never point a run at the real `~/.config` (use `XDG_CONFIG_HOME=$(mktemp -d)`).
+- Mock: `python3 tools/mock_udpstreamer.py --port 8000 &`. Self-check: `xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 ./build/udpscope --server 127.0.0.1 --port 8000` (dump on stdout, probes on stderr).
+- Work happens on branch `feature/source-plugins`. Commit from the repo root.
+
+## File Structure
+
+New (all under `src/source/` unless noted):
+
+| File | Responsibility |
+| --- | --- |
+| `source_config.hpp/.cpp` | `SourceConfig`: ordered string key/value map with typed getters. |
+| `frame.hpp` | Source-neutral `Frame` and `SignalValues` (header-only). |
+| `data_source.hpp/.cpp` | `Command`, `SourceStats`, `SourceEvent`, `SourceSink`, `DatagramTap`, `DataSource` (command queue). |
+| `source_kind.hpp` | `ConfigForm`, `SourcePanel`, `SourceKind` (includes GTK). |
+| `registry.hpp/.cpp` | Registry functions, `register_builtin_sources()`, session-key helpers. |
+| `udps_source.hpp/.cpp` | `UdpsSource`: wraps `deps/udpsclient`. |
+| `udps_kind.cpp` | `UdpsKind`: id, identity, config form. |
+| `probe_source.hpp/.cpp` | Test-only `probe` kind with one panel (registered only when the probe env var is set). |
+| `src/ui_source.cpp` | Builds and syncs capability panels in the side stack. |
+
+Modified: `src/model.hpp`, `src/model.cpp`, `src/acquisition.hpp/.cpp`, `src/config.hpp/.cpp`, `src/main.cpp`, `src/layout_store.hpp/.cpp`, `src/source_dialog.cpp`, `src/ui/source-form.ui`, `src/ui.cpp`, `src/ui_app.hpp`, `src/ui_layout.cpp`, `src/ui_probe.cpp`, `CMakeLists.txt`, and the docs.
+
+---
+
+## Task 0: Baseline and spec amendment
+
+**Files:**
+- Modify: `docs/superpowers/specs/2026-10-31-source-plugins-design.md`
+- Create (outside repo): `/tmp/udps-baseline.out`, `/tmp/udps-baseline.err`
+
+**Interfaces:** Produces the baseline files every later UDPS check is compared against.
+
+- [ ] **Step 1: Build main's behaviour (the current branch has no code changes yet)**
+
+Run: `cmake -S . -B build -DCMAKE_BUILD_TYPE=Release >/dev/null && cmake --build build -j 2>&1 | grep -c "warning:"`
+Expected: a number (3 on a clean build, 1 if incremental). Note it.
+
+- [ ] **Step 2: Capture the baseline self-check**
+
+```bash
+python3 tools/mock_udpstreamer.py --port 8000 &
+MOCK=$!
+sleep 1
+xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
+ ./build/udpscope --server 127.0.0.1 --port 8000 >/tmp/udps-baseline.out 2>/tmp/udps-baseline.err
+kill $MOCK
+grep -c "Gtk-CRITICAL" /tmp/udps-baseline.err; grep "gtk warnings" /tmp/udps-baseline.err
+```
+Expected: `0`, then `gtk warnings: 0`.
+
+- [ ] **Step 2b: Capture the chooser baseline**
+
+The chooser's form probes (see `docs/claude/probe-readings.md`, "The chooser's form") are the reference for Task 5. Same fresh config dir for all three, mock running:
+
+```bash
+python3 tools/mock_udpstreamer.py --port 8000 &
+MOCK=$!
+sleep 1
+CFGB=$(mktemp -d)
+echo "$CFGB" >/tmp/chooser-cfg-dir
+xvfb-run -a env XDG_CONFIG_HOME=$CFGB UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \
+ UDPSCOPE_PROBE_NEWHOST=127.0.0.1:8000 ./build/udpscope 2>/tmp/chooser-baseline-new.err >/dev/null
+xvfb-run -a env XDG_CONFIG_HOME=$CFGB UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \
+ UDPSCOPE_PROBE_EDITHOST=127.0.0.1:8000 ./build/udpscope 2>/tmp/chooser-baseline-edit.err >/dev/null
+xvfb-run -a env XDG_CONFIG_HOME=$CFGB UDPSCOPE_PROBE_SOURCES=1 ./build/udpscope 2>/tmp/chooser-baseline-list.err >/dev/null
+kill $MOCK
+grep -h "^\[sources\]" /tmp/chooser-baseline-new.err /tmp/chooser-baseline-edit.err /tmp/chooser-baseline-list.err
+```
+Expected: `form mnemonics=8/8`, `form: name='probe-host' ... connect_sensitive=1`, `configuration created: 127.0.0.1:8000/probe-host`, `edit form: ... button='Save'`, `configuration saved`, and one `known:` row. These are the lines Task 5 must reproduce (apart from `8/8` becoming `9/9`, because the new Source type row has a mnemonic too).
+
+- [ ] **Step 3: Amend the spec**
+
+Edit the spec's Decisions/Design text to say:
+- the new probe is `UDPSCOPE_PROBE_SOURCE_KINDS` (the name `UDPSCOPE_PROBE_SOURCES` is the chooser discovery probe), its value is a delay in ms, and its presence also registers the stub `probe` kind;
+- `Command` is `std::function`;
+- session keys are top-level TOML keys (`source_kind`, `source_opt_`), written only for non-UDPS kinds, not a `[source]` table; non-UDPS session files are named by a pseudo-host `-` with port 1;
+- `Acquisition` creates its source lazily in `start()`;
+- `Frame` holds pointers into the source's buffers, valid only during `on_data`;
+- `make_config_form()` takes no argument (the chooser calls `form.write(cfg)` to fill it); `ConfigForm` is a list of `AdwPreferencesRow` widgets plus `write`/`read`/`valid`/`on_change` closures (so the chooser can place them in its own group);
+- panels become pages of the existing `side_stack`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docs/superpowers/specs/2026-10-31-source-plugins-design.md
+git commit -m "docs: settle source-plugin implementation details in the spec"
+```
+
+---
+
+## Task 1: Core types, registry, neutral constants
+
+Compile-only: nothing uses the new code yet, so behaviour cannot change.
+
+**Files:**
+- Create: `src/source/source_config.hpp`, `src/source/source_config.cpp`, `src/source/frame.hpp`, `src/source/data_source.hpp`, `src/source/data_source.cpp`, `src/source/source_kind.hpp`, `src/source/registry.hpp`, `src/source/registry.cpp`
+- Modify: `src/model.hpp` (constants), `src/model.cpp:649`, `CMakeLists.txt`
+
+**Interfaces:**
+- Produces (all in `namespace udpscope`):
+ - `SourceConfig` with `using Items = std::vector>;`, `explicit SourceConfig(Items)`, `void set(const std::string&, const std::string&)`, `bool has(const std::string&) const`, `std::string get(const std::string&, const std::string& def = {}) const`, `long get_int(key, long def) const`, `double get_double(key, double def) const`, `bool get_bool(key, bool def) const`, `bool parse_assignment(const std::string& kv)`, `const Items& items() const`, `bool empty() const`, `static bool valid_key(const std::string&)`.
+ - `Frame`, `SignalValues`, `Command`, `SourceStats`, `SourceEvent`, `SourceSink`, `DatagramTap`, `DataSource`, `ConfigForm`, `SourcePanel`, `SourceKind`.
+ - Registry: `void register_kind(std::unique_ptr)`, `SourceKind* find_kind(const std::string&)`, `const std::vector& kinds()`, `void register_builtin_sources()`, `std::string session_host(const std::string& kind, const SourceConfig&)`, `std::uint16_t session_port(const std::string& kind, const SourceConfig&)`.
+ - `model.hpp`: `constexpr std::uint8_t kTimePacket = 0, kTimeFullArray = 1, kTimeFirstSample = 2, kTimeLastSample = 3;`
+
+- [ ] **Step 1: `source_config.hpp`**
+
+```cpp
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Martino Ferrari
+
+#pragma once
+
+#include
+#include
+#include
+
+namespace udpscope {
+
+/// A source's settings: an ordered string key/value map. It is the stored form (top-level
+/// `source_opt_` entries of a session), the `--source-opt k=v` form, and what a config form
+/// reads and writes. Order is insertion order, so a stored session stays diff-friendly.
+class SourceConfig {
+ public:
+ using Items = std::vector>;
+
+ SourceConfig() = default;
+ explicit SourceConfig(Items items) : items_(std::move(items)) {}
+
+ /// Replaces the value in place if the key exists, else appends.
+ void set(const std::string& key, const std::string& value);
+ bool has(const std::string& key) const;
+ std::string get(const std::string& key, const std::string& def = {}) const;
+ long get_int(const std::string& key, long def) const;
+ double get_double(const std::string& key, double def) const;
+ /// 1/true/yes/on and 0/false/no/off (case-insensitive); anything else gives `def`.
+ bool get_bool(const std::string& key, bool def) const;
+
+ /// Parses `k=v`; false (and no change) if there is no `=` or the key is not `valid_key`.
+ bool parse_assignment(const std::string& kv);
+
+ const Items& items() const noexcept { return items_; }
+ bool empty() const noexcept { return items_.empty(); }
+
+ /// `[A-Za-z0-9_]+` -- the characters that survive as a TOML bare key suffix.
+ static bool valid_key(const std::string& key);
+
+ private:
+ Items items_;
+};
+
+} // namespace udpscope
+```
+
+- [ ] **Step 2: `source_config.cpp`**
+
+```cpp
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Martino Ferrari
+
+#include "source/source_config.hpp"
+
+#include
+#include
+#include
+
+namespace udpscope {
+
+void SourceConfig::set(const std::string& key, const std::string& value) {
+ for (auto& kv : items_) {
+ if (kv.first == key) {
+ kv.second = value;
+ return;
+ }
+ }
+ items_.emplace_back(key, value);
+}
+
+bool SourceConfig::has(const std::string& key) const {
+ return std::any_of(items_.begin(), items_.end(),
+ [&](const auto& kv) { return kv.first == key; });
+}
+
+std::string SourceConfig::get(const std::string& key, const std::string& def) const {
+ for (const auto& kv : items_) {
+ if (kv.first == key) return kv.second;
+ }
+ return def;
+}
+
+long SourceConfig::get_int(const std::string& key, long def) const {
+ if (!has(key)) return def;
+ const std::string v = get(key);
+ char* end = nullptr;
+ const long n = std::strtol(v.c_str(), &end, 10);
+ return (end == v.c_str() || *end != '\0') ? def : n;
+}
+
+double SourceConfig::get_double(const std::string& key, double def) const {
+ if (!has(key)) return def;
+ const std::string v = get(key);
+ char* end = nullptr;
+ const double d = std::strtod(v.c_str(), &end);
+ return (end == v.c_str() || *end != '\0') ? def : d;
+}
+
+bool SourceConfig::get_bool(const std::string& key, bool def) const {
+ if (!has(key)) return def;
+ std::string v = get(key);
+ std::transform(v.begin(), v.end(), v.begin(),
+ [](unsigned char c) { return static_cast(std::tolower(c)); });
+ if (v == "1" || v == "true" || v == "yes" || v == "on") return true;
+ if (v == "0" || v == "false" || v == "no" || v == "off") return false;
+ return def;
+}
+
+bool SourceConfig::valid_key(const std::string& key) {
+ return !key.empty() && std::all_of(key.begin(), key.end(), [](unsigned char c) {
+ return std::isalnum(c) || c == '_';
+ });
+}
+
+bool SourceConfig::parse_assignment(const std::string& kv) {
+ const auto eq = kv.find('=');
+ if (eq == std::string::npos) return false;
+ const std::string key = kv.substr(0, eq);
+ if (!valid_key(key)) return false;
+ set(key, kv.substr(eq + 1));
+ return true;
+}
+
+} // namespace udpscope
+```
+
+- [ ] **Step 3: `frame.hpp`**
+
+```cpp
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Martino Ferrari
+
+#pragma once
+
+#include "model.hpp"
+
+#include
+
+namespace udpscope {
+
+/// One signal's values in a frame. `values` points into the source's own buffer and is valid
+/// only while the `on_data` call that received the frame is running.
+struct SignalValues {
+ const double* values = nullptr;
+ std::uint32_t count = 0;
+};
+
+/// A data frame in source-neutral form; it mirrors `udps_frame_t`. A source with no packet
+/// counter supplies a running one and `lost = 0`: the hole, backfill and time-base logic only
+/// needs `counter`, `lost` and `late`.
+struct Frame {
+ std::uint32_t counter = 0;
+ std::uint32_t lost = 0;
+ bool late = false;
+ double recv_time = 0.0;
+ std::uint8_t publish_mode = 0;
+ std::uint32_t num_samples = 0;
+ std::uint32_t num_signals = 0;
+ const SignalInfo* signals = nullptr;
+ const SignalValues* values = nullptr;
+
+ /// Element `elem` of signal `sig` at sample `sample`. Mirrors `udps_frame_value`: a scalar
+ /// signal carried once per sample is indexed by `sample`, a vector/matrix signal by `elem`.
+ /// Out of range reads 0.0.
+ double value(std::uint32_t sig, std::uint32_t sample, std::uint32_t elem) const noexcept {
+ if (sig >= num_signals) return 0.0;
+ const SignalValues& v = values[sig];
+ const std::uint32_t idx =
+ (v.count == num_samples && signals[sig].elements() == 1u) ? sample : elem;
+ return idx < v.count ? v.values[idx] : 0.0;
+ }
+};
+
+} // namespace udpscope
+```
+
+(`Frame::value` is `udps_frame_value` from `deps/udpsclient/udps_client.c:346` with the pointer replaced by `this`; keep the two identical.)
+
+- [ ] **Step 4: `data_source.hpp`**
+
+```cpp
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Martino Ferrari
+
+#pragma once
+
+#include "source/frame.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace udpscope {
+
+/// Work for the producer thread. UI code posts these; `poll()` drains and runs them, so a
+/// source's state is only ever touched by the thread that owns it.
+using Command = std::function;
+
+/// The counters `StreamStats` shows, in source-neutral form. Producer thread only.
+struct SourceStats {
+ std::uint64_t packets = 0;
+ std::uint64_t frames = 0;
+ std::uint64_t gaps = 0;
+ std::uint64_t late = 0;
+ std::uint64_t resequenced = 0;
+ std::uint64_t stale = 0;
+ std::uint64_t overflows = 0;
+ std::uint64_t reconnects = 0;
+ bool connected = false;
+};
+
+enum class SourceEvent { Connected, Disconnected, Error };
+
+/// Where a source delivers, from inside `poll()`.
+class SourceSink {
+ public:
+ virtual ~SourceSink() = default;
+ /// The signal set (re)defined. `publish_mode` is the stream's own publish mode.
+ virtual void on_config(const std::vector& signals, std::uint8_t publish_mode) = 0;
+ virtual void on_data(const Frame& frame) = 0;
+ virtual void on_event(SourceEvent event, const char* message) = 0;
+};
+
+/// Receives each raw datagram of a UDP-based source: bytes, length, receive time.
+using DatagramTap = std::function;
+
+/// The per-connection object. `Acquisition` owns one and drives it from the producer thread.
+class DataSource {
+ public:
+ virtual ~DataSource() = default;
+
+ /// Allocates and connects. Throws `std::runtime_error` with a message fit for the user.
+ /// Does not deliver: CONFIG and `Connected` arrive from `poll()`.
+ virtual void open(SourceSink& sink) = 0;
+ /// Runs queued commands, then waits up to `timeout_ms` for work and delivers it.
+ virtual void poll(int timeout_ms) = 0;
+ virtual void close() = 0;
+ /// Producer thread only.
+ virtual SourceStats stats() const = 0;
+ virtual bool connected() const = 0;
+ /// Optional: UDP-based sources feed the raw-packet capture through this.
+ virtual void set_datagram_tap(DatagramTap) {}
+
+ /// Thread-safe. The command runs inside a later `poll()`.
+ void post(Command cmd);
+
+ protected:
+ /// For `poll()` implementations: runs and clears the queue.
+ void drain_commands();
+
+ private:
+ std::mutex cmd_mu_;
+ std::deque cmds_;
+};
+
+} // namespace udpscope
+```
+
+- [ ] **Step 5: `data_source.cpp`**
+
+```cpp
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Martino Ferrari
+
+#include "source/data_source.hpp"
+
+#include
+
+namespace udpscope {
+
+void DataSource::post(Command cmd) {
+ std::lock_guard lk(cmd_mu_);
+ cmds_.push_back(std::move(cmd));
+}
+
+void DataSource::drain_commands() {
+ std::deque local;
+ {
+ std::lock_guard lk(cmd_mu_);
+ local.swap(cmds_);
+ }
+ for (auto& c : local) c();
+}
+
+} // namespace udpscope
+```
+
+- [ ] **Step 6: `source_kind.hpp`**
+
+```cpp
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Martino Ferrari
+
+#pragma once
+
+#include "source/data_source.hpp"
+#include "source/source_config.hpp"
+
+#include
+
+#include
+#include
+#include
+#include
+
+namespace udpscope {
+
+/// A kind's configuration UI, as pieces the chooser places in its own group. The widgets are
+/// floating `AdwPreferencesRow`s; the closures capture them, so a form must not outlive its rows
+/// (the chooser drops the form when it removes the rows).
+struct ConfigForm {
+ std::vector rows;
+ /// Shown under the group's title; empty for none.
+ std::string description;
+ /// The widget that takes focus when the form is shown; may be null.
+ GtkWidget* focus = nullptr;
+ /// Puts `cfg` into the widgets (absent keys leave the kind's defaults).
+ std::function write;
+ /// The widgets as a `SourceConfig`.
+ std::function read;
+ /// Whether the form is complete enough to connect.
+ std::function valid;
+ /// Calls `fn` whenever any widget changes.
+ std::function)> on_change;
+};
+
+/// A capability panel (signal generator, digital I/O, ...) for the side area.
+struct SourcePanel {
+ std::string id; ///< stack page name
+ std::string title;
+ std::string icon; ///< icon name for the tab, may be empty
+ GtkWidget* widget = nullptr;
+};
+
+/// One plugin type: its identity, factory and UI.
+class SourceKind {
+ public:
+ virtual ~SourceKind() = default;
+ virtual std::string id() const = 0; ///< short, `[a-z0-9]+`; the CLI and session name
+ virtual std::string display_name() const = 0;
+ /// A stable, human-readable name for one configured source (`host:port`, a serial number).
+ virtual std::string identity(const SourceConfig& cfg) const = 0;
+ virtual std::unique_ptr make(const SourceConfig& cfg) const = 0;
+ virtual ConfigForm make_config_form() const = 0;
+ /// Panels for a live source. The widgets must reach the source only through `post()`.
+ virtual std::vector make_panels(DataSource& source) const {
+ (void)source;
+ return {};
+ }
+ /// Whether the source reports raw datagrams (enables the packet-capture UI).
+ virtual bool has_datagrams() const { return false; }
+};
+
+} // namespace udpscope
+```
+
+- [ ] **Step 7: `registry.hpp` and `registry.cpp`**
+
+```cpp
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Martino Ferrari
+
+#pragma once
+
+#include "source/source_kind.hpp"
+
+#include
+#include
+#include
+#include
+
+namespace udpscope {
+
+/// Takes ownership. A second kind with the same id is ignored.
+void register_kind(std::unique_ptr kind);
+SourceKind* find_kind(const std::string& id);
+/// In registration order (the chooser's dropdown order).
+const std::vector& kinds();
+/// Registers the built-in kinds; idempotent. No static-init self-registration, which a linker
+/// may drop. A later `dlopen` loader would only call `register_kind()`.
+void register_builtin_sources();
+
+/// The built-in kinds, each defined in its own file (`udps_kind.cpp`, `probe_source.cpp`).
+std::unique_ptr make_udps_kind();
+std::unique_ptr make_probe_kind();
+
+/// The (host, port) pair that names a source's session file. UDPS keeps its legacy
+/// `server`/`port`; any other kind is `"-"` (characters outside `[A-Za-z0-9._-]`
+/// become `_`) on port 1 -- not 0, which the session-file parser rejects and waveform mode uses.
+std::string session_host(const std::string& kind_id, const SourceConfig& cfg);
+std::uint16_t session_port(const std::string& kind_id, const SourceConfig& cfg);
+
+} // namespace udpscope
+```
+
+```cpp
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Martino Ferrari
+
+#include "source/registry.hpp"
+
+#include
+#include
+
+namespace udpscope {
+namespace {
+
+std::vector>& owned() {
+ static std::vector> v;
+ return v;
+}
+std::vector& raw() {
+ static std::vector v;
+ return v;
+}
+
+std::string sanitize(const std::string& s) {
+ std::string out = s;
+ for (char& c : out) {
+ const bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
+ c == '.' || c == '_' || c == '-';
+ if (!ok) c = '_';
+ }
+ return out;
+}
+
+} // namespace
+
+void register_kind(std::unique_ptr kind) {
+ if (!kind || find_kind(kind->id())) return;
+ raw().push_back(kind.get());
+ owned().push_back(std::move(kind));
+}
+
+SourceKind* find_kind(const std::string& id) {
+ for (SourceKind* k : raw()) {
+ if (k->id() == id) return k;
+ }
+ return nullptr;
+}
+
+const std::vector& kinds() { return raw(); }
+
+void register_builtin_sources() {
+ static bool done = false;
+ if (done) return;
+ done = true;
+ register_kind(make_udps_kind());
+ // The stub kind exists for the headless source-kinds probe only; a normal run never sees it.
+ if (std::getenv("UDPSCOPE_PROBE_SOURCE_KINDS") != nullptr) register_kind(make_probe_kind());
+}
+
+std::string session_host(const std::string& kind_id, const SourceConfig& cfg) {
+ if (kind_id == "udps") return cfg.get("server", "127.0.0.1");
+ SourceKind* k = find_kind(kind_id);
+ return sanitize(kind_id + "-" + (k ? k->identity(cfg) : std::string("unknown")));
+}
+
+std::uint16_t session_port(const std::string& kind_id, const SourceConfig& cfg) {
+ if (kind_id == "udps") {
+ const long p = cfg.get_int("port", 8000);
+ return static_cast(p > 0 && p <= 65535 ? p : 8000);
+ }
+ return 1;
+}
+
+} // namespace udpscope
+```
+
+`make_udps_kind()` (Task 2) and `make_probe_kind()` (Task 6) are declared in `registry.hpp` but not defined yet, so in this task `register_builtin_sources()` must not call them: write its body as only `static bool done = false; if (done) return; done = true;` followed by the two comment lines `// Task 2: register_kind(make_udps_kind());` and `// Task 6: register make_probe_kind() when UDPSCOPE_PROBE_SOURCE_KINDS is set`. Task 2 Step 6 and Task 6 Step 3 replace them with the real calls.
+
+- [ ] **Step 8: Neutral time-mode constants**
+
+In `src/model.hpp`, inside `namespace udpscope`, above `struct SignalInfo`:
+
+```cpp
+/// `SignalInfo::time_mode` values. They equal the wire protocol's `UDPS_TIME_*`
+/// (`udps_source.cpp` static_asserts it), but the model must not name a source's own header.
+constexpr std::uint8_t kTimePacket = 0;
+constexpr std::uint8_t kTimeFullArray = 1;
+constexpr std::uint8_t kTimeFirstSample = 2;
+constexpr std::uint8_t kTimeLastSample = 3;
+```
+
+In `src/model.cpp:649` replace `UDPS_TIME_FULL_ARRAY` with `kTimeFullArray`. Check line 6 of `model.cpp` (`#include ` or similar): remove it only if nothing else in `model.cpp` needs a `udps_*` name (`grep -n "udps_\|UDPS_" src/model.cpp`).
+
+- [ ] **Step 9: CMake**
+
+In `CMakeLists.txt`, add after `src/acquisition.cpp`:
+
+```
+ src/source/source_config.cpp
+ src/source/data_source.cpp
+ src/source/registry.cpp
+```
+
+- [ ] **Step 10: Build**
+
+Run: `cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c`
+Expected: no errors; warning count equal to Step 1 of Task 0.
+
+- [ ] **Step 11: Commit**
+
+```bash
+git add src/source src/model.hpp src/model.cpp CMakeLists.txt
+git commit -m "source: DataSource, Frame, SourceConfig and the registry (unused yet)"
+```
+
+---
+
+## Task 2: The UDPS source and kind
+
+Wraps `deps/udpsclient` unchanged. Compiled and registered, but `Acquisition` does not use it until Task 3, so the running program is still unchanged.
+
+**Files:**
+- Create: `src/source/udps_source.hpp`, `src/source/udps_source.cpp`, `src/source/udps_kind.cpp`
+- Modify: `src/source/registry.cpp`, `CMakeLists.txt`
+
+**Interfaces:**
+- Consumes: Task 1's `DataSource`, `SourceSink`, `Frame`, `SourceKind`, `ConfigForm`, `SourceConfig`, `kTime*`.
+- Produces: `class UdpsSource final : public DataSource` constructed from a `SourceConfig` with keys `server` (default `127.0.0.1`), `port` (8000), `multicast`, `interface`, `recv_buffer` (8388608); `std::unique_ptr make_udps_kind()` with id `"udps"`, display name `"UDPStreamer"`, identity `":"`, `has_datagrams() == true`.
+
+- [ ] **Step 1: `udps_source.hpp`**
+
+```cpp
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Martino Ferrari
+
+#pragma once
+
+#include "source/data_source.hpp"
+#include "source/source_config.hpp"
+
+#include
+
+#include
+#include
+
+namespace udpscope {
+
+/// The UDPStreamer client behind the `DataSource` interface. A thin adapter: it maps
+/// `udps_signal_t`/`udps_frame_t` to `SignalInfo`/`Frame` and forwards the datagram observer;
+/// all protocol work stays in `deps/udpsclient`.
+class UdpsSource final : public DataSource {
+ public:
+ explicit UdpsSource(const SourceConfig& cfg);
+ ~UdpsSource() override;
+
+ void open(SourceSink& sink) override;
+ void poll(int timeout_ms) override;
+ void close() override;
+ SourceStats stats() const override;
+ bool connected() const override;
+ void set_datagram_tap(DatagramTap tap) override { tap_ = std::move(tap); }
+
+ private:
+ static void config_cb(const udps_signal_t* signals, std::uint32_t count,
+ std::uint8_t publish_mode, void* user);
+ static void data_cb(const udps_frame_t* frame, void* user);
+ static void event_cb(udps_event_t event, const char* detail, void* user);
+ static void datagram_cb(const std::uint8_t* bytes, std::size_t len, double recv_time,
+ void* user);
+
+ std::string server_;
+ std::uint16_t port_ = 8000;
+ std::string multicast_;
+ std::string interface_;
+ int recv_buffer_ = 8 * 1024 * 1024;
+
+ udps_client_t* client_ = nullptr;
+ SourceSink* sink_ = nullptr;
+ DatagramTap tap_;
+ std::vector signals_;
+ std::vector values_;
+};
+
+} // namespace udpscope
+```
+
+- [ ] **Step 2: `udps_source.cpp`**
+
+```cpp
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Martino Ferrari
+
+#include "source/udps_source.hpp"
+
+#include
+
+namespace udpscope {
+namespace {
+
+static_assert(kTimePacket == UDPS_TIME_PACKET && kTimeFullArray == UDPS_TIME_FULL_ARRAY &&
+ kTimeFirstSample == UDPS_TIME_FIRST_SAMPLE &&
+ kTimeLastSample == UDPS_TIME_LAST_SAMPLE,
+ "model.hpp's kTime* must equal the wire protocol's UDPS_TIME_*");
+
+SignalInfo to_info(const udps_signal_t& s) {
+ SignalInfo info;
+ info.name = s.name;
+ info.unit = s.unit;
+ info.type_code = s.type_code;
+ info.quant_type = s.quant_type;
+ info.rows = s.num_rows;
+ info.cols = s.num_cols;
+ info.range_min = s.range_min;
+ info.range_max = s.range_max;
+ info.time_mode = s.time_mode;
+ info.sampling_rate = s.sampling_rate;
+ info.time_signal_idx = s.time_signal_idx;
+ return info;
+}
+
+} // namespace
+
+UdpsSource::UdpsSource(const SourceConfig& cfg)
+ : server_(cfg.get("server", "127.0.0.1")),
+ port_(static_cast(cfg.get_int("port", 8000))),
+ multicast_(cfg.get("multicast")),
+ interface_(cfg.get("interface")),
+ recv_buffer_(static_cast(cfg.get_int("recv_buffer", 8 * 1024 * 1024))) {}
+
+UdpsSource::~UdpsSource() { close(); }
+
+void UdpsSource::open(SourceSink& sink) {
+ close();
+ sink_ = &sink;
+ udps_client_config_t c;
+ udps_client_config_init(&c);
+ c.server_addr = server_.c_str();
+ c.server_port = port_;
+ c.multicast_group = multicast_.empty() ? nullptr : multicast_.c_str();
+ c.interface_addr = interface_.empty() ? nullptr : interface_.c_str();
+ c.recv_buffer_bytes = static_cast(recv_buffer_);
+ c.silence_timeout_s = 2.0;
+ c.reconnect_delay_s = 0.5;
+ c.keepalive_interval_s = 1.0;
+
+ client_ = udps_client_create(&c);
+ if (client_ == nullptr) {
+ throw std::runtime_error("could not create the UDPS client for " + server_ + ":" +
+ std::to_string(port_));
+ }
+ udps_client_set_callbacks(client_, &UdpsSource::config_cb, &UdpsSource::data_cb,
+ &UdpsSource::event_cb, this);
+ udps_client_set_datagram_cb(client_, &UdpsSource::datagram_cb, this);
+}
+
+void UdpsSource::poll(int timeout_ms) {
+ drain_commands();
+ if (client_ != nullptr) udps_client_poll(client_, timeout_ms);
+}
+
+void UdpsSource::close() {
+ if (client_ != nullptr) {
+ udps_client_destroy(client_);
+ client_ = nullptr;
+ }
+}
+
+SourceStats UdpsSource::stats() const {
+ SourceStats out;
+ if (client_ == nullptr) return out;
+ udps_stats_t s;
+ udps_client_stats(client_, &s);
+ out.packets = s.packets_received;
+ out.frames = s.frames_delivered;
+ out.gaps = s.counter_gaps;
+ out.late = s.late_delivered;
+ out.resequenced = s.late_resequenced;
+ out.stale = s.stale_packets;
+ out.overflows = s.reorder_overflows;
+ out.reconnects = s.reconnects;
+ out.connected = udps_client_is_connected(client_) != 0;
+ return out;
+}
+
+bool UdpsSource::connected() const {
+ return client_ != nullptr && udps_client_is_connected(client_) != 0;
+}
+
+void UdpsSource::config_cb(const udps_signal_t* signals, std::uint32_t count,
+ std::uint8_t publish_mode, void* user) {
+ auto* self = static_cast(user);
+ self->signals_.clear();
+ self->signals_.reserve(count);
+ for (std::uint32_t i = 0; i < count; ++i) self->signals_.push_back(to_info(signals[i]));
+ self->sink_->on_config(self->signals_, publish_mode);
+}
+
+void UdpsSource::data_cb(const udps_frame_t* f, void* user) {
+ auto* self = static_cast(user);
+ // The frame's signals are the CONFIG's; the cache is rebuilt only if a frame ever disagrees.
+ if (self->signals_.size() != f->num_signals) {
+ self->signals_.clear();
+ for (std::uint32_t i = 0; i < f->num_signals; ++i) {
+ self->signals_.push_back(to_info(f->signals[i]));
+ }
+ }
+ self->values_.resize(f->num_signals);
+ for (std::uint32_t i = 0; i < f->num_signals; ++i) {
+ self->values_[i].values = f->values[i].values;
+ self->values_[i].count = f->values[i].count;
+ }
+ Frame fr;
+ fr.counter = f->counter;
+ fr.lost = f->lost;
+ fr.late = f->late != 0;
+ fr.recv_time = f->recv_time;
+ fr.publish_mode = f->publish_mode;
+ fr.num_samples = f->num_samples;
+ fr.num_signals = f->num_signals;
+ fr.signals = self->signals_.data();
+ fr.values = self->values_.data();
+ self->sink_->on_data(fr);
+}
+
+void UdpsSource::event_cb(udps_event_t event, const char* detail, void* user) {
+ auto* self = static_cast(user);
+ SourceEvent e = SourceEvent::Error;
+ if (event == UDPS_EVENT_CONNECTED) e = SourceEvent::Connected;
+ else if (event == UDPS_EVENT_DISCONNECTED) e = SourceEvent::Disconnected;
+ self->sink_->on_event(e, detail);
+}
+
+void UdpsSource::datagram_cb(const std::uint8_t* bytes, std::size_t len, double recv_time,
+ void* user) {
+ auto* self = static_cast(user);
+ if (self->tap_) self->tap_(bytes, len, recv_time);
+}
+
+} // namespace udpscope
+```
+
+- [ ] **Step 3: `udps_kind.cpp`, the kind and its form**
+
+The four rows keep the titles, mnemonics and defaults of `src/ui/source-form.ui` (Server, Port 1..65535 default 8000, Multicast group, Interface) so the chooser reads the same.
+
+```cpp
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Martino Ferrari
+
+#include "source/registry.hpp"
+#include "source/udps_source.hpp"
+
+#include
+
+#include
+
+namespace udpscope {
+namespace {
+
+using ChangeCb = std::shared_ptr>;
+
+GtkWidget* entry_row(const char* title) {
+ GtkWidget* r = adw_entry_row_new();
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
+ adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
+ return r;
+}
+
+void on_edited(GtkEditable*, gpointer user) {
+ const auto& cb = *static_cast(user);
+ if (cb && *cb) (*cb)();
+}
+
+void hook(GtkWidget* entry, const ChangeCb& cb) {
+ g_signal_connect_data(entry, "changed", G_CALLBACK(on_edited), new ChangeCb(cb),
+ +[](gpointer d, GClosure*) { delete static_cast(d); },
+ static_cast(0));
+}
+
+std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); }
+
+class UdpsKind final : public SourceKind {
+ public:
+ std::string id() const override { return "udps"; }
+ std::string display_name() const override { return "UDPStreamer"; }
+ bool has_datagrams() const override { return true; }
+
+ std::string identity(const SourceConfig& cfg) const override {
+ return cfg.get("server", "127.0.0.1") + ":" + std::to_string(cfg.get_int("port", 8000));
+ }
+
+ std::unique_ptr make(const SourceConfig& cfg) const override {
+ return std::make_unique(cfg);
+ }
+
+ ConfigForm make_config_form() const override {
+ GtkWidget* server = entry_row("_Server");
+ GtkWidget* port = GTK_WIDGET(adw_spin_row_new_with_range(1, 65535, 1));
+ adw_preferences_row_set_title(ADW_PREFERENCES_ROW(port), "_Port");
+ adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(port), TRUE);
+ adw_spin_row_set_value(ADW_SPIN_ROW(port), 8000);
+ GtkWidget* group = entry_row("Multicast _group");
+ GtkWidget* iface = entry_row("_Interface");
+
+ ChangeCb cb = std::make_shared>();
+ hook(server, cb);
+ hook(group, cb);
+ hook(iface, cb);
+
+ ConfigForm f;
+ f.rows = {server, port, group, iface};
+ f.description = "Leave the multicast group and the interface empty for unicast on the default route";
+ f.focus = server;
+ f.write = [=](const SourceConfig& c) {
+ gtk_editable_set_text(GTK_EDITABLE(server), c.get("server", "127.0.0.1").c_str());
+ adw_spin_row_set_value(ADW_SPIN_ROW(port), static_cast(c.get_int("port", 8000)));
+ gtk_editable_set_text(GTK_EDITABLE(group), c.get("multicast").c_str());
+ gtk_editable_set_text(GTK_EDITABLE(iface), c.get("interface").c_str());
+ };
+ f.read = [=]() {
+ SourceConfig c;
+ c.set("server", text_of(server));
+ c.set("port", std::to_string(static_cast(adw_spin_row_get_value(ADW_SPIN_ROW(port)))));
+ if (!text_of(group).empty()) c.set("multicast", text_of(group));
+ if (!text_of(iface).empty()) c.set("interface", text_of(iface));
+ return c;
+ };
+ f.valid = [=]() { return !text_of(server).empty(); };
+ f.on_change = [cb](std::function fn) { *cb = std::move(fn); };
+ return f;
+ }
+};
+
+} // namespace
+
+std::unique_ptr make_udps_kind() { return std::make_unique(); }
+
+} // namespace udpscope
+```
+
+- [ ] **Step 4: Register it**
+
+In `src/source/registry.cpp` replace the `// Task 2:` comment with `register_kind(make_udps_kind());`.
+
+- [ ] **Step 5: CMake**
+
+Add after the Task 1 entries:
+
+```
+ src/source/udps_source.cpp
+ src/source/udps_kind.cpp
+```
+
+- [ ] **Step 6: Build**
+
+Run: `cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c`
+Expected: no errors; same warning count as Task 0.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/source CMakeLists.txt
+git commit -m "source: the UDPS client behind DataSource, with its config form"
+```
+
+---
+
+## Task 3: Acquisition, Config and CLI on the registry
+
+The switch-over. After this task the program reads UDPS through `DataSource` and the self-check must read exactly like the Task 0 baseline.
+
+**Files:**
+- Modify: `src/acquisition.hpp`, `src/acquisition.cpp`, `src/config.hpp`, `src/config.cpp`, `src/main.cpp`, `src/ui.cpp` (lines ~2497, ~5398, ~5917), `src/ui_probe.cpp` (~5414), `src/source_dialog.cpp` (lines ~35-38, ~1132-1135, ~1199)
+
+**Interfaces:**
+- Consumes: Task 1 and 2 (`find_kind`, `session_host`, `session_port`, `Frame`, `SourceSink`, `kTime*`).
+- Produces:
+ - `Config::source_kind` (`"udps"`), `Config::source_specified`, `Config::source_opts` (`SourceConfig`), `bool Config::has_source() const noexcept`, `SourceConfig Config::source_config() const`, `std::string Config::session_host() const`, `std::uint16_t Config::session_port() const`.
+ - `Acquisition::source()` returning `DataSource*` (null before `start()`).
+ - CLI: `--source NAME`, repeatable `--source-opt k=v`.
+
+- [ ] **Step 1: `config.hpp`**
+
+Add `#include "source/source_config.hpp"` after ``. After `server_specified` add:
+
+```cpp
+ /// `--source NAME`: which registered kind opens. `udps` is the default, and `--server/--port/
+ /// --multicast/--interface` keep meaning UDPS.
+ std::string source_kind = "udps";
+ /// Whether `--source` was given (the other half of "did the user choose a source").
+ bool source_specified = false;
+ /// Repeatable `--source-opt k=v`: the kind's own settings. For `udps` they overlay the fields above.
+ SourceConfig source_opts;
+
+ /// A source was named on the command line (`--server` or `--source`), so the chooser is skipped.
+ bool has_source() const noexcept { return server_specified || source_specified; }
+ /// The settings the kind's `make()` receives: for UDPS the four connection fields plus the receive
+ /// buffer, then `source_opts` on top.
+ SourceConfig source_config() const;
+ /// The (host, port) that name this source's session file; see `session_host()` in the registry.
+ std::string session_host() const;
+ std::uint16_t session_port() const;
+```
+
+- [ ] **Step 2: `config.cpp`**
+
+Add `#include "source/registry.hpp"` after the `plot_host.hpp` include. Add before `print_usage`:
+
+```cpp
+SourceConfig Config::source_config() const {
+ SourceConfig out;
+ if (source_kind == "udps") {
+ out.set("server", server);
+ out.set("port", std::to_string(port));
+ if (!multicast.empty()) out.set("multicast", multicast);
+ if (!interface_addr.empty()) out.set("interface", interface_addr);
+ out.set("recv_buffer", std::to_string(recv_buffer_bytes));
+ }
+ for (const auto& kv : source_opts.items()) out.set(kv.first, kv.second);
+ return out;
+}
+
+std::string Config::session_host() const { return udpscope::session_host(source_kind, source_config()); }
+std::uint16_t Config::session_port() const { return udpscope::session_port(source_kind, source_config()); }
+```
+
+In `print_usage`, after the `--interface` line add (and extend the usage line with `[--source NAME]`):
+
+```cpp
+ std::string names;
+ for (const SourceKind* k : kinds()) names += (names.empty() ? "" : ", ") + k->id();
+```
+placed before the `fprintf`, and inside the format string after `--interface`:
+
+```
+ " --source NAME source kind (%s; default udps). --server/--port/\n"
+ " --multicast/--interface configure udps\n"
+ " --source-opt K=V a setting for the source kind; repeatable\n"
+```
+with `names.c_str()` inserted as the second `fprintf` argument (after `argv0`; the first `%s` is `argv0`, so keep argument order matching the format).
+
+In `parse_args`, before `--buffer-mb`:
+
+```cpp
+ } else if (a == "--source" || a.rfind("--source=", 0) == 0) {
+ const std::string v = value_of(argc, argv, i, "--source");
+ if (find_kind(v) == nullptr) {
+ std::string names;
+ for (const SourceKind* k : kinds()) names += (names.empty() ? "" : ", ") + k->id();
+ fail("unknown source '" + v + "' (available: " + names + ")");
+ }
+ c.source_kind = v;
+ c.source_specified = true;
+ } else if (a == "--source-opt" || a.rfind("--source-opt=", 0) == 0) {
+ const std::string v = value_of(argc, argv, i, "--source-opt");
+ if (!c.source_opts.parse_assignment(v)) fail("--source-opt must be key=value: " + v);
+```
+
+(`--source=` does not match `--source-opt=`: the prefix includes `=`.)
+
+- [ ] **Step 3: `main.cpp`**
+
+Add `#include "source/registry.hpp"`. Immediately before `cfg = udpscope::parse_args(argc, argv);` (line ~21) add `udpscope::register_builtin_sources();`. Replace `cfg.server_specified` with `cfg.has_source()` at lines ~60 and ~66.
+
+- [ ] **Step 4: `acquisition.hpp`**
+
+- Replace `#include ` with `#include "source/data_source.hpp"` and add `#include `.
+- Change `class Acquisition {` to `class Acquisition : private SourceSink {`.
+- Doc of the class: "Owns the data source and the producer thread...".
+- Add to the public section:
+
+```cpp
+ /// The source this acquisition drives, for the UI's capability panels: it may only call
+ /// `post()` on it. Null until `start()`.
+ DataSource* source() noexcept { return source_.get(); }
+```
+- Replace the three `on_*` declarations and the four trampoline declarations (lines ~71-80) with:
+
+```cpp
+ void on_config(const std::vector& signals, std::uint8_t publish_mode) override;
+ void on_data(const Frame& frame) override;
+ void on_event(SourceEvent event, const char* detail) override;
+```
+- `build_stamps(const Frame& frame, int time_signal, bool counter_gap_refused)` and `backfill_from(const Frame& frame, ...)`.
+- Replace `udps_client* client_ = nullptr;` with `std::unique_ptr source_;`.
+- In the doc comment of `build_stamps`, `frame->num_samples` becomes `frame.num_samples`.
+
+- [ ] **Step 5: `acquisition.cpp`, the mechanical part**
+
+Run, from the repo root:
+
+```bash
+sed -i \
+ -e 's/const udps_frame_t\* frame/const Frame\& frame/g' \
+ -e 's/frame->/frame./g' \
+ -e 's/udps_frame_value(frame, /frame.value(/g' \
+ -e 's/udps_signal_num_elements(&\([^()]*\))/\1.elements()/g' \
+ -e 's/const udps_signal_t&/const SignalInfo\&/g' \
+ -e 's/UDPS_TIME_PACKET/kTimePacket/g' \
+ -e 's/UDPS_TIME_FULL_ARRAY/kTimeFullArray/g' \
+ -e 's/UDPS_TIME_FIRST_SAMPLE/kTimeFirstSample/g' \
+ -e 's/UDPS_TIME_LAST_SAMPLE/kTimeLastSample/g' \
+ src/acquisition.cpp
+grep -no "frame\.[a-z_]*" src/acquisition.cpp | cut -d: -f2 | sort | uniq -c
+grep -n "num_rows\|num_cols\|num_dimensions\|hrt" src/acquisition.cpp
+```
+Expected: every `frame.` is one of `counter lost late recv_time publish_mode num_samples num_signals signals values value`; the second grep prints nothing. If either differs, fix by hand (`Frame` has no `hrt`; `SignalInfo` names the dimensions `rows`/`cols`).
+
+- [ ] **Step 6: `acquisition.cpp`, the structural part**
+
+1. Delete `to_info` (lines ~32-46), `#include ` (line 8), the four trampolines at the end of the file (`// Static trampolines ...` through `event_trampoline`), and `datagram_trampoline` (lines ~174-177).
+2. Add includes `"source/registry.hpp"`.
+3. `start()` becomes:
+
+```cpp
+void Acquisition::start() {
+ // A started -- or *restarted* -- source is a new stream. Going live from a file view is the restart that
+ // matters: the store still holds the event's samples, and the new stream's clock can sit behind their
+ // stamps, so the guard below has to re-base rather than clamp. `ring=[2.5200..2.5200]` with `n` climbing
+ // was the symptom -- every packet pinned flat at the file view's last stamp.
+ model_.reset_stream();
+ last_stamp_ = -std::numeric_limits::infinity();
+ if (!source_) {
+ SourceKind* kind = find_kind(cfg_.source_kind);
+ if (kind == nullptr) throw std::runtime_error("unknown source kind: " + cfg_.source_kind);
+ source_ = kind->make(cfg_.source_config());
+ }
+ source_->set_datagram_tap([this](const std::uint8_t* bytes, std::size_t len, double recv_time) {
+ on_datagram(bytes, len, recv_time);
+ });
+ source_->open(*this);
+ // `--capture-packets`: armed before the first datagram, so the first CONNECT and
+ // its CONFIG -- the hardest strange behaviours to reproduce -- are in the ring.
+ // The open itself still happens on the producer thread, at the first datagram.
+ if (cfg_.capture_packets > 0) {
+ set_capture_want(true, cfg_.capture_packets);
+ }
+
+ started_ = std::chrono::steady_clock::now();
+ running_.store(true, std::memory_order_release);
+ thread_ = std::thread([this] { run(); });
+}
+
+void Acquisition::stop() {
+ if (running_.exchange(false, std::memory_order_acq_rel) && thread_.joinable()) thread_.join();
+ if (source_) source_->close();
+}
+
+void Acquisition::run() {
+ while (running_.load(std::memory_order_acquire)) {
+ source_->poll(50);
+ const SourceStats s = source_->stats();
+ StreamStats& out = model_.stats();
+ out.packets.store(s.packets, std::memory_order_relaxed);
+ out.frames.store(s.frames, std::memory_order_relaxed);
+ out.gaps.store(s.gaps, std::memory_order_relaxed);
+ out.resequenced.store(s.resequenced, std::memory_order_relaxed);
+ out.late.store(s.late, std::memory_order_relaxed);
+ out.stale.store(s.stale, std::memory_order_relaxed);
+ out.overflows.store(s.overflows, std::memory_order_relaxed);
+ out.reconnects.store(s.reconnects, std::memory_order_relaxed);
+ out.connected.store(s.connected, std::memory_order_relaxed);
+ }
+}
+```
+4. `set_capture_want`: replace `cfg_.server`/`cfg_.port` with `cfg_.session_host()`/`cfg_.session_port()` for the directory name, and guard the peer address: `if (cfg_.source_kind == "udps" && ::inet_pton(AF_INET, cfg_.server.c_str(), &a) == 1) { ... } capture_.set_peer(addr, cfg_.source_kind == "udps" ? cfg_.port : 0);`.
+5. `on_config` signature: `void Acquisition::on_config(const std::vector& info, std::uint8_t publish_mode)`. Delete the `info` vector construction and use `info.size()` for `count` in the log line (`%u` with `static_cast(info.size())`). The mode log chain becomes `kTimePacket`... already done by sed.
+6. `on_event`:
+
+```cpp
+void Acquisition::on_event(SourceEvent event, const char* detail) {
+ (void)detail;
+ if (event == SourceEvent::Connected) {
+ model_.stats().connected.store(true, std::memory_order_relaxed);
+ } else if (event == SourceEvent::Disconnected) {
+```
+leaving the body of the old `else if` untouched.
+
+- [ ] **Step 7: Every other reader of `cfg.server`/`cfg.port`**
+
+Run: `grep -n "config()\.server\|config()\.port\|cfg\.server\|cfg\.port" src/*.cpp src/*.hpp` and change each that names a *session file or capture directory* to `cfg.session_host()` / `cfg.session_port()`:
+- `src/ui.cpp:~2497` `layout_path(app->model->config().server, ...port)` -> `layout_path(config().session_host(), config().session_port())`;
+- `src/ui.cpp:~5917-5918` `app.source_host`/`source_port` -> `cfg.session_host()`/`cfg.session_port()` (keep the `open_waveform.empty()` guards);
+- `src/ui_probe.cpp:~5414` likewise;
+- `src/source_dialog.cpp:~1132-1135` (`apply_stored_settings`) -> `cfg.session_host()`/`cfg.session_port()`.
+Leave `ui.cpp:~5398` (a log line), `ui.cpp:5919-5920` (`source_multicast`/`source_interface`) and `source_dialog.cpp:~35-38,1199` (UDPS fields filled from `KnownSource`) alone: they stay correct for UDPS and Task 5 reworks them for other kinds.
+
+- [ ] **Step 8: CMake and build**
+
+No new files. Run: `cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c`
+Expected: no errors, warning count as in Task 0 (a full rebuild of `acquisition.cpp` and others is incremental, so 1 or 3). Fix any leftover `udps_`/`->` compile errors by hand.
+
+- [ ] **Step 9: Self-check against the baseline**
+
+```bash
+python3 tools/mock_udpstreamer.py --port 8000 &
+MOCK=$!
+sleep 1
+xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
+ ./build/udpscope --server 127.0.0.1 --port 8000 >/tmp/udps-new.out 2>/tmp/udps-new.err
+kill $MOCK
+grep -c "Gtk-CRITICAL" /tmp/udps-new.err; grep "gtk warnings" /tmp/udps-new.err
+diff <(sed 's/[0-9.]\+/N/g' /tmp/udps-baseline.out) <(sed 's/[0-9.]\+/N/g' /tmp/udps-new.out) | head -30
+diff <(sed 's/[0-9.]\+/N/g' /tmp/udps-baseline.err) <(sed 's/[0-9.]\+/N/g' /tmp/udps-new.err) | head -30
+```
+Expected: `0`, `gtk warnings: 0`, and both diffs empty or limited to timing noise (a line present in one and not the other by a count that varies with timing); anything structural (a missing probe line, a changed message) is a regression to fix before continuing. Also run `./build/udpscope --help | grep -A3 -- --source` (expect the new options with `udps`) and `./build/udpscope --source nope` (expect `unknown source 'nope' (available: udps)`, exit 2).
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add -A src
+git commit -m "acquisition: drive a DataSource from the registry; --source and --source-opt"
+```
+
+---
+
+## Task 4: The `probe` kind and per-kind session persistence
+
+Adds the test-only `probe` kind (a synthetic sine, no panel yet) so a non-UDPS source can run end to end, and makes sessions remember which kind they belong to.
+
+**Files:**
+- Create: `src/source/probe_source.hpp`, `src/source/probe_source.cpp`
+- Modify: `src/source/registry.cpp`, `src/layout_store.hpp`, `src/layout_store.cpp`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_layout.cpp`, `CMakeLists.txt`
+
+**Interfaces:**
+- Consumes: Tasks 1-3.
+- Produces:
+ - `class ProbeSource final : public DataSource` with `explicit ProbeSource(const SourceConfig&)`, `void note_applied()`, `std::uint64_t commands_applied() const`; option `rate` (Hz, default 1000).
+ - Kind id `"probe"`, display name `"Probe (test)"`, identity `"rate"`.
+ - `LayoutState::source_kind` (`std::string`, empty = UDPS) and `LayoutState::source_opts` (`SourceConfig`); `KnownSource::kind` (default `"udps"`) and `KnownSource::opts`.
+ - `App::source_kind`, `App::source_opts`, `App::source_title`.
+ - Session file keys: top-level `source_kind = "probe"` and `source_opt_ = ""`, written only for kinds other than `udps`.
+
+- [ ] **Step 1: `probe_source.hpp`**
+
+```cpp
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Martino Ferrari
+
+#pragma once
+
+#include "source/data_source.hpp"
+#include "source/source_config.hpp"
+
+#include
+#include
+#include
+
+namespace udpscope {
+
+/// A synthetic source for the headless self-check: one 1 V sine signal, `kBatch` samples per
+/// frame at `rate` Hz, no socket. It exists to prove the framework end to end (open, CONFIG,
+/// data, commands, panels) and is registered only when `UDPSCOPE_PROBE_SOURCE_KINDS` is set.
+class ProbeSource final : public DataSource {
+ public:
+ static constexpr std::uint32_t kBatch = 10;
+
+ explicit ProbeSource(const SourceConfig& cfg);
+
+ void open(SourceSink& sink) override;
+ void poll(int timeout_ms) override;
+ void close() override;
+ SourceStats stats() const override;
+ bool connected() const override { return open_.load(std::memory_order_relaxed); }
+
+ /// What a panel command does, so the probe can see that `post()` reached the producer thread.
+ void note_applied() noexcept { applied_.fetch_add(1, std::memory_order_relaxed); }
+ std::uint64_t commands_applied() const noexcept {
+ return applied_.load(std::memory_order_relaxed);
+ }
+
+ private:
+ double rate_hz_;
+ SourceSink* sink_ = nullptr;
+ std::atomic open_{false};
+ bool configured_ = false;
+ std::uint32_t counter_ = 0;
+ std::uint64_t frames_ = 0;
+ double phase_ = 0.0;
+ std::vector signals_;
+ std::vector batch_;
+ SignalValues values_;
+ std::atomic applied_{0};
+};
+
+} // namespace udpscope
+```
+
+- [ ] **Step 2: `probe_source.cpp`**
+
+The kind's `make_panels` is added in Task 6; the form lives here.
+
+```cpp
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Martino Ferrari
+
+#include "source/probe_source.hpp"
+
+#include "source/registry.hpp"
+
+#include
+
+#include
+#include
+#include
+#include
+
+namespace udpscope {
+
+ProbeSource::ProbeSource(const SourceConfig& cfg)
+ : rate_hz_(std::max(1.0, cfg.get_double("rate", 1000.0))), batch_(kBatch, 0.0) {
+ SignalInfo s;
+ s.name = "probe";
+ s.unit = "V";
+ s.type_code = 9; // float64
+ s.rows = 1;
+ s.cols = 1;
+ s.range_min = -1.0;
+ s.range_max = 1.0;
+ s.time_mode = kTimePacket;
+ s.sampling_rate = rate_hz_;
+ signals_.push_back(s);
+}
+
+void ProbeSource::open(SourceSink& sink) {
+ sink_ = &sink;
+ configured_ = false;
+ open_.store(true, std::memory_order_relaxed);
+}
+
+void ProbeSource::close() { open_.store(false, std::memory_order_relaxed); }
+
+SourceStats ProbeSource::stats() const {
+ SourceStats s;
+ s.packets = frames_;
+ s.frames = frames_;
+ s.connected = open_.load(std::memory_order_relaxed);
+ return s;
+}
+
+void ProbeSource::poll(int timeout_ms) {
+ drain_commands();
+ if (!open_.load(std::memory_order_relaxed) || sink_ == nullptr) return;
+ if (!configured_) {
+ configured_ = true;
+ sink_->on_config(signals_, /*publish_mode=*/1);
+ sink_->on_event(SourceEvent::Connected, "");
+ }
+ // One batch takes kBatch / rate seconds to "arrive".
+ const int period_ms = std::max(1, static_cast(1000.0 * kBatch / rate_hz_));
+ std::this_thread::sleep_for(std::chrono::milliseconds(std::min(period_ms, timeout_ms)));
+ const double step = 2.0 * M_PI * 5.0 / rate_hz_; // a 5 Hz sine
+ for (std::uint32_t k = 0; k < kBatch; ++k) {
+ batch_[k] = std::sin(phase_);
+ phase_ += step;
+ }
+ values_.values = batch_.data();
+ values_.count = kBatch;
+ Frame f;
+ f.counter = counter_++;
+ f.lost = 0;
+ f.late = false;
+ f.recv_time = std::chrono::duration(std::chrono::system_clock::now().time_since_epoch())
+ .count();
+ f.publish_mode = 1;
+ f.num_samples = kBatch;
+ f.num_signals = 1;
+ f.signals = signals_.data();
+ f.values = &values_;
+ ++frames_;
+ sink_->on_data(f);
+}
+
+namespace {
+
+void on_rate_changed(GObject*, GParamSpec*, gpointer user) {
+ const auto& cb = *static_cast