**The clock.** Trigger mode showed a blank plot and never latched when the session defined a math
channel, because such a session gives the model a plan *before* the stream's own signals are in it:
one derived channel, declared rate 0, so `plan_partition()`'s 1 kHz fallback is the interval those
early frames are stamped at -- 1300x too fast for a 1.3 MS/s stream -- and they have nowhere to go,
since the value loop skips derived channels. Relative mode then stamped a *position* times the plan's
*current* interval, so when the CONFIG's channels landed and the plan took the real rate (769 ns) the
same positions produced times 1300x *smaller* than what was already in the ring, and the monotonic
clamp held the clock at the old value for the rest of the run: one repeated stamp, `history=0`, no
latch, and a window the renderer cannot draw. The same family as the restarted-client poisoning
fixed in 620473a, through a different door. The filter's own output was finite and correct
throughout, and disabling the math channel only appeared to cure it because that is a structural
change which rebuilds the store -- the third time that channel has looked guilty while being a
bystander.
Relative mode now accumulates *seconds* (`rel_base_s_ += n * dt`), so a re-plan is a slope change and
the clock cannot jump backwards by construction; a frame is only stamped into a model that has a
*streamed* channel; and the epoch is adopted once per connection, with "unset" a state rather than
the value zero. A one-shot warning names the family if it returns: `the clock stalled at ...: N
samples stamped [a..b] are all behind it`.
**The dialog.** The Configurations window's Close button and the window-manager close both did
nothing: `close-request` was connected to a bare `void` lambda, and that signal is `gboolean` -- TRUE
means "handled" and suppresses the default handler that destroys the window, while `G_CALLBACK` is a
cast, so GTK read whatever the compiler left in the return register. A standalone GTK program of that
shape reproduces it: `void` handler -> `window_alive=1` after `gtk_window_close()`, `gboolean FALSE`
-> 0. The same audit found `gtk_window_present(GTK_WINDOW(...))` on an `AdwDialog`, a type assertion
and a no-op; it is `adw_dialog_present()` now. The menu row stays: it is the only way to switch or
save a configuration without restarting, and what made it look useless was that it could not close.
Verified: trigger mode with that session's graph reads `ring=[0.0058..0.2446] -> [0.0058..1.9969]`,
`history 0.237 -> 2.0 s`, `edge at 1.120 s, latched at 2.120 s`, `display=event(frozen) latches=1`,
`traceless paints 266` against 1519 before the fix. Baselines hold: 0 errors, the documented three
warnings, test-ui 13 ok, test-help green, `gtk warnings: 0`.
401 lines
33 KiB
Markdown
401 lines
33 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.
|
|
- **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.
|
|
- **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.
|
|
|
|
## 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.
|