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 @@