Files
udpscope/docs/claude/invariants-trigger-and-pinning.md
Martino FerrariandClaude Sonnet 4.6 c4b79da057 docs: events in the dock and on its page
The dock's Events block, the events slide-over page, the availability-rule deviation, the probe lines and the removal of the left pane are described; the false remembered-width claim is gone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 07:19:33 +01:00

632 lines
54 KiB
Markdown

# Invariants: the trigger, the capture and a pinned view
The four display modes' framing rules: what the event window is counted in, what pins an axis, and what each pin is measured from.
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
- **`clamp_to_data()` caps the span at the *requested window*, never at the retained
span.** It runs every frame and cannot distinguish "the user zoomed out past the data"
from "the ring has only just started filling", so `window_s_` — what the window control
states, capped by `--max-window` — is the bound. Clamping to `retained` alone turned a
requested 2 s window into the ~0.02 s the first frames held, permanently: nothing there
grows a span back. Loosening it to `max(retained, window_s_)` fixed that and broke the
other state instead — while the ring is filling the two are equal, but once it holds
more than the window the bound becomes the whole ring, and a wheel zoom-out walked the
view into data the window was never set to show. `window_s_` is right in both: it never
shrinks a window a caller asked for, and a partly filled buffer is left empty on its
left, which is what a scope that just started should look like. `UDPSCOPE_PROBE_PAN_MS`
reports it as `zoom ... span=.. window=.. limited=1`.
Its pan clamping is likewise gated on the data being wider than the view, or the empty
left-hand side drags the live edge off the right-hand edge.
- **The compact pane's arm control is built in the heading, and *one* function states both arm
buttons.** Arming is a runtime decision and the locked pane is exactly where it is made, so the
control cannot live in the body — that is the part which goes away. `sync_arm_buttons()` is what
states the label, the sensitivity and the visibility of both buttons, and it is called from the four
places any of its three inputs can change (the click, the toolbar toggle, the curator's re-read,
`apply_pane_lock()`). Visibility is `enabled && locked`: with trigger mode off the line beside it
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
allocation (270 px, from when the *body* was still on screen) reads as a live one, because
`gtk_widget_get_visible()` is the widget's *own* flag and stays true under a hidden parent. The width
only becomes the proof that a control is on the surface **one frame later**, which is why the probe
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. **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
a sample count.** `TriggerEngine` latches once the stamps it is fed have advanced
past the post window, and `ScopeModel::publish_event()` cuts the snapshot by binary
search over the time ring. There used to be a measured samples-per-second estimate
in `Acquisition` doing that conversion, and it is precisely why the post window came
out empty: any stream whose time signal advances in large steps drove it to its
1 S/s floor, the count came out as 1, and the engine latched on the edge sample
itself. **Do not reintroduce a rate estimate on this path.**
- **The engine identifies its channel by *name*, never by index.** `TriggerEngine::configure()`
drops an open capture when the trigger moves to a different channel, and it is re-called from
`Acquisition::on_data()` on **every packet** out of `model_.trigger()`. `TriggerConfig::channel`
is an index into `channels()`, and that index is not stable: every rebuild of the channel list
renumbers it — enabling a signal that sorts before the trigger's own is enough — and
**`ScopeModel::rebuild_channels()` re-resolves the trigger by name and writes the *new* index
back, for every caller.** It lives there and not at the call sites because the function *is* the
renumbering, and a caller that forgot is a trigger that silently stops: the name-based restore
used to be in `rebuild_enabled_locked()` alone, so adding or removing a math channel went through
`set_math_channels()`'s structural path with no restore at all, `rebuild_locked()` clamped the
now-wrong index to `-1`, and `process()` returns at once on a channel of `-1` while the panel went
on naming the channel. That is the second half of the "it sometimes does not trigger, even with
the trigger on a non-math channel" report. Keying the reset on the index made any such rebuild
abandon the capture **and
set no hold-off**, so the next edge inside the post window became t0: that is the "the trigger
re-triggers during a capture and the hold-off does not apply" defect, and it needed no glitch
and no user action beyond enabling a channel. The name changes when the source genuinely
changes, so a deliberate re-point still resets. The rebuild also replaces the `BufferStore`, so
the surviving capture's pre side is short and the snapshot reports its own `pre_s` — which may
be **negative** when the new ring starts after the edge. That is a faithful reading, not a
defect: `[-pre_s, post_s]` still describes the samples the snapshot holds. The guard is
`UDPSCOPE_PROBE_TRIGRENUM_MS`, whose `t0_held=1` is the assertion.
- **`EventCondition` carries the trigger's own rejection: `noise_band` and `hf_fc`.** Both are in the
struct's `operator==`, so a change to either *is* a change to the condition. `EventDetector::configure()`
takes `dt` and early-returns only when the condition **and** the interval are unchanged -- it runs on
*every packet*, so a configure that reset unconditionally would restart the machine before any pulse could
close. `dt` is `Partition::slot_dt()`, the *stored* interval and not the CONFIG's nominal rate, and comes
from exactly three call sites (`acquisition.cpp`, `ui_trigger.cpp`, `ui_probe.cpp`); a zero there leaves
the reject's coefficient at 1, which is a pass-through.
- **A crossing is a change between two *decided* sides, never a change from the undecided one.** The band
makes each sample decide above / below / undecided, and an edge is counted only when the signal has reached
the far threshold of the side it is leaving. Seeding the previous side from `0` -- the undecided code --
fires on a signal that starts *inside* the band and leaves it without ever reaching the level, an event the
trigger is not supposed to see.
**A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
While an event is held, a capture is filling, or the view is paused, `tick()` calls
`RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and
clamps the position into it (and states `Off`, since a following group would re-derive from a
live edge). **A zoom in must keep working** -- "not outside the event" is not "not readable",
and this function's first version *re-stated* the pin every frame, which made the wheel a no-op
in single trigger mode: an acquired event could not be examined at all.
**What a gesture inside the pin may *not* move is a measurement**, and it is not this function
that holds that: `pinned_range()` is the one expression of the pin's extent, and
`update_measures()` measures *it* rather than the viewport while the view is pinned, so a pan
or a zoom on a still frame changes no value and spends no recompute (see
[invariants-measurements.md](invariants-measurements.md); `[measurepin-probe]` is the reading,
`pin=`/`for=` in `[measure-trace]` the field to watch). The pin is therefore load-bearing twice
over, and a change to what `pinned_range()` says changes both the framing and the numbers.
**The request is read from the row's own axis, not from the group.** A member's `set_x_range`
-- what the controller writes for a gesture -- is *not* adopted into the group while the follow
is off, so a clamp that read the group only ever saw its own previous answer (the probe showed
`group` and `member` both equal to the pin while the ask was gone). `pinned_stated_` remembers
what the clamp last wrote: a member that matches it is our own answer coming back, one that
differs is a gesture to honour -- which is also what keeps a *fresh* pin
(`pin_event_window()`, `pin_pause_window()`, both of which write the group in the same tick)
from being reverted to the member's stale window. Without it nothing bounds a drag or
a zoom in those states: the library clamps a *following* group's offset only, and the live
clamp is deliberately skipped here — so a gesture walked the frozen view off its axis, over
data the scope never had. The two are one branch in `tick()` for that reason. A paused view
pins the same way (`pin_pause_window()` sets `pinned_` from the frozen range), so the frozen
picture is equally immovable; resuming or leaving trigger mode clears it.
- **An opened file is a pinned view too, and *how* it is pinned matters as much as that it is.** The
file view states its pin from the ring's own stamps every frame, and it used to do that with
`pin_event_window()` -- which writes the group's range as well as the pin's numbers -- so a box zoom
drew its frame and changed nothing: the controller's `set_x_range` went onto the *member*, the next
frame's re-statement put the group's range back, and the branch it fell into (`clamp_to_data()`,
because a file is neither `event_held` nor `Paused` nor `capturing`) never adopts a member into a
group that is not following. That is the wheel-in-single-trigger defect again, in the one mode nobody
re-tested. Two things hold it now: the branch is `Paused || capturing || rows->window_pinned()`, so a
pinned view is always clamped by `hold_pinned_window()`; and the file's per-frame statement uses
`set_data_pin()` -- the pin's numbers -- unless `pinned_window_is()` says the pin is a *different*
window, which is a re-open with a new extent and is a new event, stated in full. `[boxzoom-probe]`'s
`zoomed=` is the guard, and it is a reading to take in **both** modes: live and file must both read
`1`.
- **`set_data_pin()` takes a *range*; `pin_event_window()` and `pinned_window_is()` take the pre/post
*split*.** The three are used in one breath by the file view's statement and by the held event's, and
the two forms are not interchangeable: for data over `[lo, hi]` it is `set_data_pin(lo, hi)` against
`pin_event_window(-lo, hi)`. Handing the split to `set_data_pin()` stores the pin as `[lo, -lo]`-ish,
so the next frame's `pinned_window_is()` reads false, the pin is stated in full again, and the view
alternates between the file's span and the corrupted pin at the frame rate -- the flicker in which no
interaction survives, because every second frame rewrites the group's range. It is invisible on a file
whose stamps are all positive (the corrupted pin is then *wider* than the data and
`hold_pinned_window()` never clamps) and on one that ends at 0 (the pin inverts, and the clamp returns
early), which is why it reached a user on a recording spanning `[-4..16]` and nothing before it.
`[drop-probe]`'s `repins=` is the reading: **2 for a whole run**, not one per frame.
- **A pinned view is navigable *within its data*, and that is the bound -- not the request.**
Two things had to agree for this, and both were wrong in the same direction:
the **pause** pinned on the window it asked for rather than on the snapshot it got (a cut
clamped to the ring is shorter), and the **held event** pinned on the snapshot's recorded
`pre_s`/`post_s` -- the *request* again -- which a capture shorter than the request exceeds.
Either way the shortfall was drawn as an empty margin, and a zoom-in moved the view *into* it,
which is what made it look as if zooming caused it. `tick()` now re-states the pin from
`RowSet::event_data_window()` (the bound **event series'** own stamps) while an event is held,
from the snapshot's stamps on a pause, and `pin_pause_window()`/the pause handler set the axis
to it as well; `[pause-probe]` prints `axis=` beside `frozen_x=` and the axis must lie inside.
A `Capturing` view is the exception and stays on the *requested* `[-pre, +post]`: its post side
is empty on purpose, and that is the capture filling in view, not a margin.
- **A pause is pinned from its snapshot's own stamps too, not from the window it requested.** The
pause freezes the *visible window*, but `publish_pause_view()` cuts that window out of the ring
and the cut is clamped to what the ring holds -- so a window reaching back past the oldest
retained sample produces a *shorter* snapshot. Pinning the axis to the request left the shortfall
as an **empty margin on the left**, visible before any zoom and made worse by one: zooming in
moved the view *into* the empty part, which is what made it look as if zooming caused it. The pin
is the snapshot's own stamps now. `[pause-probe]` prints `axis=` beside `frozen_x=`, and while
paused **the axis must lie inside `frozen_x`** -- equal at the moment of the pause, a subset
afterwards if the probe zoomed in. Before the fix the two disagreed outright:
`axis=[-1.44..0.56]` over data that started at `0.0`. (The event pin already worked this way; the
pause now agrees.)
- **The pause reads the display offset *before* it sets the pause state, and a pause taken on a
held event depends on that order.** `on_pause_toggled()` reads the row's axis and
`stream_at_x0()`, cuts `view + offset` out of the ring, and hands the same offset to the
snapshot as its `x_offset`. `stream_at_x0()` follows the *display*: the moment `paused` is true
with no pause view published yet it answers `-display_epoch()`, which on a `Held` view is **0**
-- so the cut searched the ring at the event's edge-relative x, found nothing, and froze an
**empty** picture: `ev=0` on the probe's line, every measurement `--` and every ruler gone,
which is a much louder symptom than the empty margin above and reaches a user the same way
(pause while an event is on screen). Read first, it is the offset the axis is actually
displaying, which is what the whole scheme requires. The live case is unchanged -- there the
epoch does not depend on `paused()`, which is why the pause probe, which pauses a *live* view,
never saw it.
- **The event axis is pinned from the snapshot's own stamps, not from the request.** A
capture may legitimately be shorter than asked for — the ring need not reach back a
full pre-trigger share on the first latch after arming, so `snap=[-0.339..1.000]`
for a ±1 s request is correct there rather than a defect.
- **The frame that *frames* a snapshot is the one that consumes its `event_seq()`.** The
tick reads `display_mode()` *before* it takes `mutex()`, while the producer publishes
inside that mutex and lowers `trigger_pending` only at the end of its packet — so a tick
can read `Capturing`, block on the lock through `publish_event()`, and reach the framing
step with a new sequence number it is not bound to. (The other case is benign and was
always handled: a publication and the *next* edge in one frame, where the capture has
framed its own window.) Advancing `seen_event_seq` on such a frame dropped the pin
permanently — the axis kept the capture's requested `[-pre, +post]` over a shorter
snapshot and `event_held` stayed false, letting `clamp_to_data()` drag the frozen view —
so the test is `seq != seen_event_seq && display == Held` and the pin stays owed until a
frame is bound to the snapshot. Visible only at high rates, where the publish is long
enough to lose the race often; `[disarm-probe]`'s `axis=` against `snap=` is the guard,
which means **running the disarm probe at 10 kS/s alone does not check this.**
- **A capture in progress is the one place the axis is pinned from the *request*.** There
is no snapshot to measure yet, so `DisplayMode::Capturing` frames
`[-(span - post_span_s(span)), +post_span_s(span)]` — computed with the producer's own
`TriggerConfig::post_span_s()`, not a second expression that rounds differently, because
the marker at x = 0 has to land where the coming cut will put it. The latch then re-pins
from the snapshot, and the two agreeing is why the picture does not jump at the latch.
- **`clamp_to_data()` must be skipped while `capturing` as well as while `event_held`.**
A capture is live data on a pinned axis — the exact combination the clamp was written
for and against: it would follow the live edge and drag the frozen window off the event.
- **`publish_event()` cuts one absolute index range and uses it for the time ring and
every value ring.** That is what makes `stamps` and each `values[c]` equal-length
and index-aligned by construction instead of by two truncations that can disagree —
`EventSeries::gather()` indexes both at the same `i`.
- **`live_window_locked()` is the one live cut, shared by `publish_event()` and the
export.** Both need the same absolute index range over the time ring and every value
ring, or an export and a capture of the same window would hold different samples. Call
it under `mutex()`.
- **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
asserts `trigger_in_hole=0` rather than relying on NaN's arithmetic properties.
- **`EventDetector` owns every condition shape and holds no arming state; `TriggerEngine` owns
the arming, the hold-off and the post window and holds no condition.** The split is what lets
the buffer search ask the trigger's exact question without owning a trigger, and it is only
true as long as neither half leaks into the other: a detector that knew about the hold-off
would answer a search differently from the live trigger, which is the one thing the
`agree=1` cross-check in `[search-probe]` exists to catch.
- **The detector is fed on *every* sample, including during the hold-off and during a capture.**
A pulse width, a window excursion and a dropout timeout are all *stateful over several
samples*: skipping the feed while the engine is busy loses the opening edge, and the condition
then measures from whatever sample the engine happened to resume on. The hold-off is applied
to the detector's *answer*, not to its input — `process()` feeds first and tests
`now_s < holdoff_until_s_` afterwards.
- **An event's time is the detector's, not the sample's.** A pulse-width event is detected at
the closing edge but could in principle be timed at the opening one, and a dropout is detected
one sample after the timeout expires. `feed()` therefore returns the event's *own* time and
`process()` uses that for `last_trigger_s_`, `pending_start_s_` **and** the pre-window gate —
all three, or the marker, the cut and the gate would disagree with each other by the width of
a pulse.
- **The trigger's whole input is the stored clock, so a clock that stops is a trigger that never
fires — and *that* is what a blank trigger-mode display is.** `Acquisition` feeds
`TriggerEngine::process(value, rel)` with the same `rel` it stamps the ring with, and the engine
has no other clock: the pre-window gate measures the event against the retained history
(`event_t - oldest_s_ >= pre_seconds_`), the post window counts *stream seconds* past the edge,
and `holdoff_until_s_` is set from `now_s` at the arm. A timeline that stands still therefore
reads as `history=0.0000/1.0000 s` for ever, on every line of `trig engine:`, no matter how many
samples arrive — and with no latch, `display_mode()` never leaves `Waiting`, whose bind is
*nothing* by design, so the plot is blank and the other traces are blank with it (the renderer's
bound window is empty too, so the same emptiness shows with the trigger off). Nothing about that
picture says "clock": it is "in trigger mode, with a math channel enabled, the data disappears",
which is how it reached a user. The reading is the `trig engine:` line's own pair — **`n` climbing
while `ring=[a..a]` stands still** — and the one-shot
`[udpscope] warning: the clock stalled ...` that `on_data()` prints at the first whole frame whose
stamps are all behind the clock. See
[invariants-data-path.md](invariants-data-path.md) for the two entries (a plan whose interval
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 planned ring bytes (`Partition::bytes()`), 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.
- **The phosphor density is the *trace*, and that is a different display from the layers above, not a
mode of them.** The trigger panel's `Advanced` block offers both, and the words matter: *multiple
events* is `persist_depth` (N retained acquisitions, N traces, dimmer with age) while *persistence*
is `TriggerConfig::density` -- the plotter's `Plot::set_density()`, an intensity grid
**per trace, drawn in that trace's own colour**, every acquisition deposited into it, which fades
either by a time constant or by a per-acquisition factor. There is **no depth** on the density and
no per-acquisition memory: the image is `traces * columns * rows * 4` bytes, and both factors are
bounded -- the grid is the row's axes rect in device pixels **up to the plotter's
`kDensityCellCap`**, and there is one grid a trace the row draws. Four traces in a row
therefore hold at most 4 MiB between them, whoever dragged the window where (the probe's
one-signal-per-row run reads `cells=1044714`, 4 178 856 bytes), so a thousand acquisitions cost
what one costs and *neither* a wide window nor a long run can make the image grow. A cell's value
is a number of *sweeps*: the library
deposits a cell once per trace per sweep however many of that trace's own samples crossed it
(`[density-probe] on` reads `peak=1.0000` for one sweep; a per-hit deposit read 18) -- dwell is
`Style::intensity_grading`'s question, not this one's. The colour is the trace's own
(`RowSet::signal_color()`, the same source the trace was built with and never a second one), so a
saturated cell is that signal's colour at full strength and two signals agreeing in a cell are two
quads of two colours rather than a blend.
Five things hold it together, each of which is a rule:
* **It is a trigger-mode display.** `apply_density()` (the tick) states `Held` and `Capturing` as
the states it is on in; `Live`, `Waiting`, `Paused` and a file are not, and turning it off drops
the grids **at once** rather than at an acquisition a quiet trigger never sends (the rule
`set_persist_depth()` follows). So it goes away with the trigger, and a re-enable starts from an
empty image rather than a stale one at another scale.
* **One deposit per acquisition, one frame *after* the frame that frames it.** `Plot::accumulate_density()`
deposits what the plot currently holds through the transforms of its **last** layout, because the
frame's own layout is computed inside the paint that follows the tick: `density_pending` is set
where a snapshot is *framed* -- beside `record_pending` -- and consumed by the next tick, so the
deposit is of the new acquisition drawn at the axis that acquisition is pinned to. Doing it in the
same tick was right on the common Held-to-Held transition and wrong on the first latch, whose axis
was still the live ring's.
* **The switch deposits what is already on screen.** `on && !density_was_on` sets the same pending
flag, so a user who turns persistence on while an event is held sees it instead of waiting for a
latch that `single` mode may never send.
* **No trace is drawn over it, and the newest acquisition is inside it.** `Renderer::draw_traces()`
returns at once while a density is on: the trace ink *is* the image, so the acquisition just
deposited is a sweep in it and fades with the rest. Drawing the polyline too put a solid,
unfading stroke over its own faint ghost -- the reported "persistent image + last acquisition",
where the last acquisition never faded in time mode -- and the assertion is a **framebuffer** one:
`a_phosphor_density_is_the_trace_in_its_own_colour` reads the same pixel before and after one
time constant and requires it dimmer (`full=224 -> faded=115`, the trace's own `0xE0` green),
which a stroke over the density could not do.
* **The brightness ceiling is the trace's own brightness, and the ramp is linear in sweeps.** The
app leaves `DensityConfig::full_scale` at the library's default of **one sweep** and `gamma` at
1.0, so the acquisition just deposited reads as the trace it is a picture of and a signal crossing
the same place every acquisition settles *at* the trace's colour (the ceiling clamps), rather than
a fraction of a brighter steady state. The old ceiling -- the fade's steady state, `1/(1-factor)`
for the acquisition fade -- is the gain offset a user reported between the latest acquisition and
the persistent image, and the "dimmer by design" headroom that made the ramp visible is reversed
here.
* **It is the plot's, so a figure rebuild resets it** -- the grids live on each `Plot`, and
`rebuild_all()` builds new ones. That is the one state a structural change (a row added, a signal
switched off, a trace recoloured) does not carry: it costs the accumulated image and nothing else,
and the alternative -- keeping the grids in the `RowSet` -- is what would make it not a plot
feature. The layout is unaffected either way: the density reserves no space and nothing on it can
be picked, exactly as `set_invalid_spans()` does.
* **The session carries it.** `trigger_density`, `trigger_density_decay`, `trigger_density_tau` and
`trigger_density_factor` in the layout file, written whether or not it is on -- a display set up
once and switched off is still the display you come back to. The decay is the *word* the panel's
dropdown shows ("acquisitions"/"time"), for the reason the condition's shapes are text.
## A latch is also a file
The cache is listed twice, from one `EventStore::list()`: the dock's compact **Events block** and the full list on the
`events` slide-over page (the left pane, its divider and the header toggle are gone, so events no longer take
any of the plot's width). What makes it *available* -- recording is on, **or** a replay is on screen, **or** the
cache already holds something (`event_recording || replaying || events_cached`) -- is decided in one place,
`sync_events_availability()`, and it sets the block's sensitivity (`dock_events_set_sensitive()`) and the page's
(`events_box`) from the one `available`, so they cannot disagree. Unavailable means **insensitive, never hidden**
("disable, do not hide"): the block stays on screen greyed, and turning recording off with the page open leaves it up
and greyed rather than stale and live.
**This deliberately differs from the redesign spec**, which has the block insensitive "outside trigger mode". Trigger
mode is *not* one of the conditions: a stored event being examined is a file, which has no trigger, and a replay or a
group *leaves* trigger mode, so a block that went grey outside trigger mode would grey itself under the very click
that started the replay -- the one-click replay in the dock would disable the dock it was clicked in. Do not "fix" this
to match the spec. The recording switch lives in the trigger panel, not in the block it empties.
The two views follow the latch on different cadences: the dock is rebuilt on **every** latch (`record_event()`), the
page's list only while the page is open, and `open_events_page()` refreshes it when it was closed; a caller that
opens the page any other way shows a list that is as stale as the last time it was open.
## Opening a file is a detour, so *Go live* is a restore
A file view overwrites the live view's settings and every overwrite has a reason: the window becomes
the event's own span because that is what *framing* an event means, and the trigger goes off because
a file has no edge to wait for (leaving it on kept a frozen capture on screen *instead* of the file,
so the click looked like it did nothing at all). None of those reasons survive the way back. What
the user had set is theirs, and "going back to live loses the trigger I spent a minute setting up"
is the complaint this answers.
- **`snapshot_live_state()` on the way in, `restore_live_state()` on the way out.** The snapshot is
taken by both ways into a file view -- `replay_event()` and `build_event_group()` -- *before* the
acquisition is stopped, and is a **no-op while `replaying`**: a second event opened from the first,
or a group opened from a replay, would otherwise snapshot a file's settings as "live", which are
the very values the restore exists to undo. The group takes it after its argument checks, because a
group that is refused leaves the live view alone.
- **The trigger channel is stored by *name*.** Same reason `TriggerEngine::configure()` takes one: the
index is a position in a channel list that a file view rebuilds, and a group **renames** its
channels outright (`square (A)`). Putting an index back would select a different signal.
- **`enabled` and `armed` are deliberately not restored from the snapshot.** They belong to the
toolbar toggle, which owns an atomic mirror of `enabled` (`set_trigger_enabled()`) that a plain
field write under the lock would leave stale. The restore writes the rest of the `TriggerConfig`
and then moves the *toggle*, whose handler states both.
- **The restore runs from the tick, not from `go_live_again()`, and *after* the deferred layout
block.** Two writers would otherwise beat it:
- `apply_layout()` ends in `apply_time_window(st.window_s)` -- the window the **session file**
holds, not the one the live view had. `go_live_again()` clears `layout_loaded`, so that block
fires on the next tick; a restore done inside `go_live_again()` is overwritten by the saved value
a frame later. Being the next statement after it in `tick()` is the fix.
- `rebuild_trigger_signals()`, finding a `channel` of `-1` after the rebuild renumbered the
channels, silently settles on the first signal *and* re-defaults the level -- which is why
`trigger_level_touched` is restored with the configuration.
- **It waits for the trigger's channel *name* to resolve again, not for `generation()` to move.** The
restored name has to be resolved against the live channels, and a file view that renamed its channels
(`square (A)`) must be gone first or the name selects the wrong signal. `App::live_restore_pending`
stays set until `channels()` is non-empty **and** a channel carries the snapshot's name (or the
snapshot named none). Gating that on the generation was wrong in the common case: a *single* event
keeps the stream's own channel names, and the restarted client's CONFIG is identical, which the model
treats as **no change on purpose** (`same_stream_signals()`) -- so the generation never moved and the
restore waited for something that could not happen. What shook it loose was toggling the trigger,
which *is* a structural change: "click live, then toggle the trigger a couple of times" was the
symptom. A `pending=1` that never clears is a reading about the *stream* (no channels yet), not about
the restore -- see `invariants-session-and-export.md` for the save that made it stick at
`channels=0`.
- **Only the two controls a file view actually moved are restated** -- the signal dropdown and the
level spin. The rest of the trigger panel was never touched, and writing to a control fires its
handler, so the ones with nothing to put back are left alone. Both writes are inside
`updating_panels`.
- **`pinned` is not a pass/fail reading on the way back.** `apply_time_window()` drops the file's pin
and re-arms the live follow, but if the restored trigger was *on* it can latch again within the
frame or two before the probe reads -- and a held capture is pinned, correctly. That is what
`held=` and `mode=` are beside it for.
## A comparison is two plots of one model
*Compare* and *accumulate* are still one model -- the buffers cannot hold two independent loads -- but a
comparison is **two plots** over it, and which plot draws what is only a matter of which channel *names*
its rows bind. `App::rows` is the primary (`RowSet`, area, gestures); `App::rows_b` is the second, with
its own `RowSet` and its own hidden `PlotArea` in a paned, deliberately without the drop target, the
right-click menu or the frame counter: those act on the primary's rows, and a menu on the second plot
that rearranged the first plot's rows would be a menu lying about its subject.
Rules that cost a defect each:
- **The layout is the user's and is mirrored, never reinvented.** The group build must not call
`set_row_count()`; it reads the rows' slot names and re-names the same slots for each layer
(`… (A)`, `… (B)` for a comparison; a bare name for layer 0 and `… #2`, `… #3` for the extra layers of
an accumulation). An *accumulation* puts its extra layers into free slots -- the row that holds the
signal first, then any row with room -- so the user's traces never move. The default layout is four
signals in one row, which is why `kSlotsPerRow` is 8: with four, "accumulate these two events" had
nowhere to put the second copy and silently did nothing.
- **A layer is not a signal.** A comparison's model holds one channel per (signal, layer), and the
*panel* must not treat the copies as signals of their own: the signal list shows one row per signal
(`layer_base_name()`, the shared rule that takes `(A)`, `(B)` and `#N` back off), `signal_color_index()`
counts *signals* and not channels -- counting channels spread one signal's colour across the palette
-- and both plots of a comparison bind their signal's layers to the *same* colour index, because two
colours would say they were different signals. The reading is `list=4` in `[events] compare plots`
for a two-event comparison of four signals.
- **A slot name carries the last group's suffix, so it must be stripped before it is re-derived**:
after a comparison the rows are named `sine (A)`, and a following accumulation that mapped that name
again bound to no channel at all (`traces0=0`, a plot with nothing in it). `base_name()` takes
`(A)`, `(B)` and `#N` back off.
- **The axes are mirrored from the ranges, once per frame.** The plotter owns its window and a gesture
reaches it through the plot's own setter with nothing announcing it, so the frame compares each row's
signature with the previous frame's: whichever side moved is the side the user touched, and the other
is set to match. The signature is `{span, y.min, y.max}` when the x axis has a **live edge**
(`x_live_edge()`, not merely an armed follow: an armed axis with nothing streaming keeps a *stored*
window, whose edges a pan moves and whose span never changes) and `{x.min, x.max, y.min, y.max}`
otherwise. Comparing the edges under a live edge would read as "both sides moved" on every frame --
the window is recomputed as a distance behind the newest sample -- and a zoom in one plot would be
overwritten by the other's drift. When both moved the first plot is the reference, which is also what
makes the frame after a build converge.
- **`set_y_range()` clears that axis' auto-scale**, by design: a mirrored zoom is a user decision, and
*Fit* re-arms it.
## A group of events is one model, built once
*Compare* and *accumulate* are the same machinery: several event files in **one** model, because the
buffers cannot hold two independent loads -- `load_waveform_into_model` calls `set_signals()`, which
rebuilds them, so a second load takes the first with it. So the group is built by hand: one channel per
(signal, layer) pair, `set_target_span()` to cover the whole group *before* `set_signals()` (or the
oldest samples are written past the ring's end), then all of the events pushed in one pass. A row is
one **signal** and its slots are the events, which is what makes a comparison like for like and an
accumulation a density; the trace alpha (0.35 while accumulating, 1 otherwise) is what turns agreement
into brightness.
Two things every group must do, both of which cost a defect to learn:
- **Stop the acquisition first.** A live producer keeps pushing into the model it was started with --
and it pushes *four* stream signals into the group's *eight* channels, so the group's own samples are
the ones that go missing. The reading that catches it is the samples the busy channel holds
(`samples0` in the `[events] compare` line): it must be exactly the number of stamps pushed, not
that plus whatever the stream added.
- **Free the slots the group does not fill.** A row carries the previous view's binding, and a trace
left in slot 2 of a two-layer row is drawn as if it belonged to the group.
*Compare* shifts the first event so its span *ends* where the second's *begins*, and fills the half a
sample does not belong to with **NaN** -- the plotter excludes non-finite samples from every series and
from every pyramid bucket, so a NaN is a gap and not a repeat, and no line is drawn across the join.
**A replay changes the data and frames it once; it never rearranges the layout.** A row plots a signal
*name*, and an event's export carries the same names the stream does, so loading the event into the
model leaves every row, slot, colour and the row count pointing at the right signal by itself. So
`replay_event()` must **not** rebind the rows (`bind_channels_to_rows()`, which is what a freshly opened
*file* needs) -- that is the user's work, and a click on a list is not a request to rearrange it. The
window is restated **once, on open** (`apply_time_window(span)`, the only path allowed to change the
scale), so the whole event is framed the moment it is opened and the user's own zooms are left alone
afterwards; never per frame. The measurements, the cursors and the readouts then report the stored
event, because they read the model. What a replay *does* change is the model's contents and the trigger:
it leaves trigger mode, without which the display stays `Held` and draws the old snapshot.
**A file view has nothing to acquire.** The signal list's acquire tick is *hidden* -- not disabled --
whenever a file is on screen (`App::waveform_name` non-empty: a replay, a comparison, a `--open`run).
The samples are stored, "acquire this signal" is a stream's question, and a greyed tick invites the user
to look for a reason there is none for. Going live rebuilds the panel, so the ticks come back.
**"The same length" is the same count at the same rate, not the same span to the sample.** Two events
this app recorded itself differ in span by the fraction of a sample the acquisition's window happened to
fill to (0.9999 s of a 1 s pre-window is normal). Demanding one sample interval made a comparison of two
just-recorded events refuse to open; the check allows four intervals or a millisecond, whichever is
larger, and the merged stamp grid carries the difference as a gap.
**A replay must leave trigger mode.** `display_mode()` answers `Held` whenever the model holds a
snapshot, and a held display draws *the snapshot*, not the live channels -- so replaying an event with
trigger mode still armed loaded the file into the model and left the old capture on screen: a click
that looks like it did nothing, with the event sitting in the ring unread. The replay also frames the
view with `apply_time_window(report.span_s)`, so the click shows the whole event rather than the part
of it that fell inside the live window.
The switch that decides recording belongs to the **trigger panel**, not to the pane it empties: it
is a trigger option (it decides whether a latch is written at all), and a pane must not be the only
place to reach the control that stops it.
Every published snapshot is written to the event cache while recording is on: its table (the *export*
format, so `--open` reads it back), a sidecar of what the list shows, and a preview drawn from the
samples. Three rules keep that honest:
- **The record happens on the GTK thread, at the end of the tick that framed the snapshot**, not on
the producer. The snapshot is taken by `shared_ptr` (a latch landing mid-write cannot pull the data
away), and a `record_pending` flag defers the write to the end of the frame so the rows are already
bound to the event when the picture is drawn. Recording from the producer would put file I/O
between two `recv()` calls, which is the one thing the packet capture's design also refuses.
- **Nothing a latch does may be expensive.** The write is a `write_npy` of the event's own window and
a 480x160 raster; a 2 s, four-channel event is ~230 kB of picture and ~600 kB of samples. The pane
is refreshed, not rebuilt, and a failure to write is a line on stderr -- a cache that cannot be
written must not stop the scope.
- **The preview is drawn from the data, never captured from the window.** The plot is a `GtkGLArea`:
its ink is not in the widget's paint tree, so `gtk_snapshot_free_to_node()` on a widget paintable of
it returns NULL and the "screenshot" would be an empty file. Drawing it in the store also keeps that
class GTK-free, which is what makes the cache testable without a window.