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
+140 -8
View File
@@ -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: <strerror>` — 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:`
```
+57
View File
@@ -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