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>
632 lines
54 KiB
Markdown
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.
|