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.
This commit is contained in:
Martino Ferrari committed 2026-10-16 16:46:17 +02:00
1 parent fc4700d1c9
commit e14e1ee069
22 files changed
+1652 -26

No files matched your search

+135 -1
View File
@@ -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