The trigger pane gains the display the user asked for and the library now provides, and the
persistence it already had is renamed to what it is.
**Persistence is one image; the last-N-traces display is "multiple events".** Both live in a new
`Advanced` expander in the trigger panel, together with the settings that are set up once and left
alone. `persistence` (a switch) turns on the plotter's `Plot::set_density()` for every row:
every triggered acquisition is accumulated into a screen-sized intensity grid drawn behind the
traces, brightness being how many acquisitions covered a cell -- no depth to choose, no memory
that grows with the acquisitions (2.2 MB over four 573x412 rows in the probe's run). `fades by`
picks the fade the user asked for: **acquisitions** (each keeps a factor of what was before it,
0.5 by default -- the phosphor afterglow) or **time** (a time constant in stream seconds, so the
display fades while you watch whatever the trigger rate).
- **Trigger mode only, as asked.** `apply_density()` (the tick) states the display on for `Held`
and `Capturing` and off for `Live`, `Waiting`, `Paused` and a file, and turning it off drops the
grid at once rather than at an acquisition a quiet trigger never sends. It is unpickable and
reserves no layout space, exactly as the invalid-span bands are: a density can never be in the
way of a cursor or a drop, and it cannot move the framing.
- **One deposit per acquisition, one frame after the frame that frames it.** `Plot::accumulate_density()`
deposits what the plot holds through the transforms of its *last* layout -- the frame's own is
computed inside the paint that follows the tick -- so `density_pending` is set beside
`record_pending` and consumed by the next tick. In the same tick it was right on a Held-to-Held
transition and wrong on the first latch, whose axis was still the live ring's. Switching the
display on under an acquisition already on screen deposits it too, so the switch does not appear
to do nothing until the next latch (`single` mode may never send one).
- **The session carries it**: `trigger_density`, `trigger_density_decay` ("acquisitions"/"time",
the words the panel shows), `trigger_density_tau` and `trigger_density_factor`, written whether
or not it is on -- and `trigger_persistence` still means the multiple-event depth, so an older
file is unchanged.
- **The library changed twice under this**: the deposit now counts a cell **once per trace per
sweep** (counting the hits put one dense sweep at the ceiling of the brightness scale and hid the
sweeps behind it -- the probe's `on` leg read `peak=18` for a single sweep and reads `peak=1`
now), and a frame's upload is every row's rather than the last row's. Both are in the plotter's
own commit.
`UDPSCOPE_PROBE_DENSITY_MS` reads it all back off the plots -- the plotter's own `ink()`/`peak()` --
with the panel's own widgets driving the switch and the fade, in four legs: **off** (`rows=0
ink=0.0 peak=0.0000`), **on** (`rows=4`, `peak=1.0000`), **step-acquisitions** (`peak=1.0000 ->
1.5000`, the factor then the new sweep) and **step-time** (`ratio=0.367879`, `1/e` for `dt = tau`).
It also reads the frame cost as an A/B **inside one run** (the accumulators at the switch against
the frames after it): `tick 0.74 -> 9.99 ms`, `draw 2.09 -> 20.91 ms` at four rows and 2.2 M cells
on llvmpipe.
Acceptance: clean build 0 errors and exactly the three documented warnings (12352-byte GResource
blob, two `MathNode::source`), `test-ui` 13/13, `test-help` green, a plain run with **0
Gtk-CRITICAL / Gtk-WARNING** and `gtk warnings: 0`, and the panel's own frame reading
`boxes=16 controls_inside=15/15 advanced_widgets=5/5 advanced_inside=5/5` (the block's controls
counted from the expander's child, which is not in the widget-parent chain -- and the search
block's walk now names *its* expander instead of "the first one", which the second expander would
have stolen).
Docs: the trigger/pinning invariants carry the rule and its five consequences, `probe-readings.md`
the four legs and the dump line, `verification.md` the probe's sequence and assertions, and the
manual's trigger page explains both displays to a user.
599 lines
51 KiB
Markdown
599 lines
51 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 ring's own `--buffer-mb`, because persistence is a copy
|
|
*of* what the rings hold and holding as much again is the honest ceiling for a display of it.
|
|
`kMaxPersistDepth` is 8 for the different reason the panel needs one: the layer there is already
|
|
invisible (0.8 %) and seven is all the free slots a one-signal row has. The depth is not the only
|
|
thing that limits what is *drawn* -- a row of `S` signals with 8 slots shows
|
|
`floor((8 - S) / S)` ages of each, so the default four-signal row shows one older acquisition
|
|
and a row per signal shows all seven. The two shortfalls are **separate readings**, and a caller
|
|
that conflated them would report a shrunken display as complete: a *slot* shortfall is
|
|
`placed < want` (what `RowSet::sync_layers()` returns against what the depth asked for), while a
|
|
*budget* shortfall is a `kept` below the depth plus the count `persist_dropped()` carries -- and in
|
|
that case the layer slots **exist and draw nothing at all**, so `layers=`/`placed=` alone say
|
|
nothing is wrong. The panel's notice therefore names both numbers -- `D of W layers fit, K of A
|
|
older acquisitions retained` -- because a user who asked for eight layers and sees two has no other
|
|
way to tell "the display is deliberately shallower" from "the feature is broken".
|
|
- **Depth 1 is not a mode, it is the *absence* of one.** `persist_depth = 1` retains nothing at
|
|
all -- no copy, no layer, no rebuild -- and `set_persist_depth()` prunes the set **at once**
|
|
rather than at the next latch, because lowering the depth is a user asking to stop paying, and a
|
|
trigger that has stopped firing never reaches another publish. A depth change is therefore also
|
|
how the memory comes back.
|
|
|
|
- **The phosphor density is one *image*, 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()`, a screen-sized intensity grid
|
|
every acquisition is deposited into and 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 `columns * rows * 4` bytes, one cell per device pixel of the row's axes (2.2 MB over the
|
|
four rows of the probe's run), so a thousand acquisitions cost what one costs. 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.
|
|
Four 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 grid **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.
|
|
* **It is the plot's, so a figure rebuild resets it** -- the grid lives 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 grid 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 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.
|