# 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. - **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. - **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::set_signal_enabled()` re-resolves the trigger by name and writes the *new* index back. Keying the reset on the index therefore 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. **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 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()`. - **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. ## A latch is also a file The pane that lists the cache is **on demand**: the toolbar's event-history button and the pane are collapsed until it is asked for, because a column of stale events beside a live plot is what a user who has stopped looking at events does not want. What makes it *available* -- (recording is on) or (an event is on screen) -- is decided in one place, `sync_events_availability()`, so the button and the pane cannot disagree; a disabled cache with nothing being examined takes the button away *and* collapses the pane, because a visible empty column is what "off" must not look like. Trigger mode is deliberately **not** one of the conditions: a stored event being examined is a file, which has no trigger, and tying the pane to the trigger would close the list under the click that opened an event. The recording switch lives in the trigger panel, not in the pane it empties. ## 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.