**The blank scope window in trigger mode.** A held event pins the main window to `[-pre, +post]`, and the tick mirrors that pin onto every scope window. It passed `pinned_range().min/max` to `pin_event_window()`, which takes **spans** (`pre, post`): `min` is signed, so `pre` came out negative and the group range became `[+pre, +post]` — an inverted x window. The plotter then had nothing to draw: no trace, no x-axis labels, an empty plot that looked like a broken window. `RowSet::pin_display_range(x0, x1)` states the pin as the range a mirroring view actually has, and the tick uses it; live mode was never affected, which is why the defect only showed under the trigger. Also restores the scope-window `rebuild_if_dirty()` in the tick, which a reproduction experiment had left commented out (`REPRO-TEMP`) and a later commit carried: a window opened empty and filled by a drop kept the figure it was built with. Probe: `[scope-probe] pin_mirror=1 main=[..] scope=[..] x=[..]` pins the main window, reads the scope's pin and plot x range a frame later, and reads `0` for an inverted or mismatched pin. Docs: probe readings and the trigger/pinning invariant (range vs spans).
54 KiB
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. 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", sowindow_s_— what the window control states, capped by--max-window— is the bound. Clamping toretainedalone turned a requested 2 s window into the ~0.02 s the first frames held, permanently: nothing there grows a span back. Loosening it tomax(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_MSreports it aszoom ... 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 isenabled && 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-1while 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 sameenabled(a trigger that is off has no engine to force) and it is stated insync_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 reads0whilegtk_widget_is_visible()already says1— and its sibling's stale allocation (270 px, from when the body was still on screen) reads as a live one, becausegtk_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 acompact_arm_step()of its own; and the control to compare against must be measured withis_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::channelindexeschannels()(the acquired ones) whileScopeModel::set_signal_enabled()indexes the stream'ssignals(), 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_pctis 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.
TriggerEnginelatches once the stamps it is fed have advanced past the post window, andScopeModel::publish_event()cuts the snapshot by binary search over the time ring. There used to be a measured samples-per-second estimate inAcquisitiondoing 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 fromAcquisition::on_data()on every packet out ofmodel_.trigger().TriggerConfig::channelis an index intochannels(), 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 — andScopeModel::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 inrebuild_enabled_locked()alone, so adding or removing a math channel went throughset_math_channels()'s structural path with no restore at all,rebuild_locked()clamped the now-wrong index to-1, andprocess()returns at once on a channel of-1while 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 theBufferStore, so the surviving capture's pre side is short and the snapshot reports its ownpre_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 isUDPSCOPE_PROBE_TRIGRENUM_MS, whoset0_held=1is the assertion. -
EventConditioncarries the trigger's own rejection:noise_bandandhf_fc. Both are in the struct'soperator==, so a change to either is a change to the condition.EventDetector::configure()takesdtand 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.dtisPartition::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()callsRowSet::hold_pinned_window(), which caps the span at[-pinned_pre_, +pinned_post_]'s and clamps the position into it (and statesOff, 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, andupdate_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;[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 whatpinned_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'sset_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 showedgroupandmemberboth 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 intick()for that reason. A paused view pins the same way (pin_pause_window()setspinned_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'sset_x_rangewent 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 neitherevent_heldnorPausednorcapturing) 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 isPaused || capturing || rows->window_pinned(), so a pinned view is always clamped byhold_pinned_window(); and the file's per-frame statement usesset_data_pin()-- the pin's numbers -- unlesspinned_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]'szoomed=is the guard, and it is a reading to take in both modes: live and file must both read1. -
set_data_pin()takes a range;pin_event_window()andpinned_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 isset_data_pin(lo, hi)againstpin_event_window(-lo, hi). Handing the split toset_data_pin()stores the pin as[lo, -lo]-ish, so the next frame'spinned_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 andhold_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]'srepins=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 fromRowSet::event_data_window()(the bound event series' own stamps) while an event is held, from the snapshot's stamps on a pause, andpin_pause_window()/the pause handler set the axis to it as well;[pause-probe]printsaxis=besidefrozen_x=and the axis must lie inside. ACapturingview 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]printsaxis=besidefrozen_x=, and while paused the axis must lie insidefrozen_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 at0.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 andstream_at_x0(), cutsview + offsetout of the ring, and hands the same offset to the snapshot as itsx_offset.stream_at_x0()follows the display: the momentpausedis true with no pause view published yet it answers-display_epoch(), which on aHeldview is 0 -- so the cut searched the ring at the event's edge-relative x, found nothing, and froze an empty picture:ev=0on 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 onpaused(), 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 readsdisplay_mode()before it takesmutex(), while the producer publishes inside that mutex and lowerstrigger_pendingonly at the end of its packet — so a tick can readCapturing, block on the lock throughpublish_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.) Advancingseen_event_seqon such a frame dropped the pin permanently — the axis kept the capture's requested[-pre, +post]over a shorter snapshot andevent_heldstayed false, lettingclamp_to_data()drag the frozen view — so the test isseq != seen_event_seq && display == Heldand 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]'saxis=againstsnap=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::Capturingframes[-(span - post_span_s(span)), +post_span_s(span)]— computed with the producer's ownTriggerConfig::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 whilecapturingas well as whileevent_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 makesstampsand eachvalues[c]equal-length and index-aligned by construction instead of by two truncations that can disagree —EventSeries::gather()indexes both at the samei. -
live_window_locked()is the one live cut, shared bypublish_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 undermutex(). -
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, apre_sshorter than the request when the ring does not reach back that far, and the frame that frames it being the one that consumes itsevent_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 testevent_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 ownpre_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.
- It skips the pre-window gate.
-
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 withexchangeafter the channel loop and before thetrigger_pending()/capture_edge()publish — after the loop because the cutpublish_event()is about to make clamps to every value ring'sproduced(), 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 underScopeModel::mutex()and in the same critical section as thearmedflag the click also sets: the producer holds that lock for a whole packet, reading the panel'sarmedat 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=0rather than relying on NaN's arithmetic properties. -
EventDetectorowns every condition shape and holds no arming state;TriggerEngineowns 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 theagree=1cross-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 testsnow_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 andprocess()uses that forlast_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.
AcquisitionfeedsTriggerEngine::process(value, rel)with the samerelit 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, andholdoff_until_s_is set fromnow_sat the arm. A timeline that stands still therefore reads ashistory=0.0000/1.0000 sfor ever, on every line oftrig engine:, no matter how many samples arrive — and with no latch,display_mode()never leavesWaiting, 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 thetrig engine:line's own pair —nclimbing whilering=[a..a]stands still — and the one-shot[udpscope] warning: the clock stalled ...thaton_data()prints at the first whole frame whose stamps are all behind the clock. See 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
Nacquisitions --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), solayer_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 ruleTriggerEnginealready follows, for the same reason. It leavesRow::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'smixmask, 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, andsine (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_alphaisRowSet::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()mixesslot_alpha * trace_alpha_into the trace's colour, because aPlotfixes a trace's colour atadd_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, andset_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
Plotdraws its traces in the order they were created and cannot reorder them, soinstall()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'sbind()) 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.
- A layer slot holds no channel.
-
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 inHeldandCapturing(a live view has no current event to be the newest of them, aWaitingdisplay is blank by design, and a pause view's stamps are on the live window's clock, not on an edge): inWaitingandLivethe 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 outgoingevent()into the retention before it publishes the new one:ScopeModel::persist_event(age)is the eventagelatches back, one atomicshared_ptrslot per age, read by the tick without a lock exactly asevent()is.snapshot_bytes()is the honest cost of one -- both stamp arrays, every channel'sf32samples and the LOD pyramids built for them, i.e. ~4.5 bytes per sample per channel plus 8 per sample of clock -- andprune_persist_locked()cuts the set to both bounds: the depth, andpersist_budget_bytes(), which is the planned ring bytes (Partition::bytes()), because persistence is a copy of what the rings hold and holding as much again is the honest ceiling for a display of it.kMaxPersistDepthis 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 ofSsignals with 8 slots showsfloor((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 isplaced < want(whatRowSet::sync_layers()returns against what the depth asked for), while a budget shortfall is akeptbelow the depth plus the countpersist_dropped()carries -- and in that case the layer slots exist and draw nothing at all, solayers=/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 = 1retains nothing at all -- no copy, no layer, no rebuild -- andset_persist_depth()prunes the set at once rather than at the next latch, because lowering the depth is a user asking to stop paying, and a trigger that has stopped firing never reaches another publish. A depth change is therefore also how the memory comes back. -
The phosphor density is the trace, and that is a different display from the layers above, not a mode of them. The trigger panel's
Advancedblock offers both, and the words matter: multiple events ispersist_depth(N retained acquisitions, N traces, dimmer with age) while persistence isTriggerConfig::density-- the plotter'sPlot::set_density(), an intensity grid per trace, drawn in that trace's own colour, every acquisition deposited into it, which fades either by a time constant or by a per-acquisition factor. There is no depth on the density and no per-acquisition memory: the image istraces * columns * rows * 4bytes, and both factors are bounded -- the grid is the row's axes rect in device pixels up to the plotter'skDensityCellCap, and there is one grid a trace the row draws. Four traces in a row therefore hold at most 4 MiB between them, whoever dragged the window where (the probe's one-signal-per-row run readscells=1044714, 4 178 856 bytes), so a thousand acquisitions cost what one costs and neither a wide window nor a long run can make the image grow. A cell's value is a number of sweeps: the library deposits a cell once per trace per sweep however many of that trace's own samples crossed it ([density-probe] onreadspeak=1.0000for one sweep; a per-hit deposit read 18) -- dwell isStyle::intensity_grading's question, not this one's. The colour is the trace's own (RowSet::signal_color(), the same source the trace was built with and never a second one), so a saturated cell is that signal's colour at full strength and two signals agreeing in a cell are two quads of two colours rather than a blend. Five things hold it together, each of which is a rule:- It is a trigger-mode display.
apply_density()(the tick) statesHeldandCapturingas the states it is on in;Live,Waiting,Pausedand a file are not, and turning it off drops the grids at once rather than at an acquisition a quiet trigger never sends (the ruleset_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_pendingis set where a snapshot is framed -- besiderecord_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_onsets the same pending flag, so a user who turns persistence on while an event is held sees it instead of waiting for a latch thatsinglemode may never send. - No trace is drawn over it, and the newest acquisition is inside it.
Renderer::draw_traces()returns at once while a density is on: the trace ink is the image, so the acquisition just deposited is a sweep in it and fades with the rest. Drawing the polyline too put a solid, unfading stroke over its own faint ghost -- the reported "persistent image + last acquisition", where the last acquisition never faded in time mode -- and the assertion is a framebuffer one:a_phosphor_density_is_the_trace_in_its_own_colourreads the same pixel before and after one time constant and requires it dimmer (full=224 -> faded=115, the trace's own0xE0green), which a stroke over the density could not do. - The brightness ceiling is the trace's own brightness, and the ramp is linear in sweeps. The
app leaves
DensityConfig::full_scaleat the library's default of one sweep andgammaat 1.0, so the acquisition just deposited reads as the trace it is a picture of and a signal crossing the same place every acquisition settles at the trace's colour (the ceiling clamps), rather than a fraction of a brighter steady state. The old ceiling -- the fade's steady state,1/(1-factor)for the acquisition fade -- is the gain offset a user reported between the latest acquisition and the persistent image, and the "dimmer by design" headroom that made the ramp visible is reversed here. - It is the plot's, so a figure rebuild resets it -- the grids live on each
Plot, andrebuild_all()builds new ones. That is the one state a structural change (a row added, a signal switched off, a trace recoloured) does not carry: it costs the accumulated image and nothing else, and the alternative -- keeping the grids in theRowSet-- 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 asset_invalid_spans()does. - The session carries it.
trigger_density,trigger_density_decay,trigger_density_tauandtrigger_density_factorin 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.
- It is a trigger-mode display.
A latch is also a file
The cache is listed twice, from one EventStore::list(): the dock's compact Events block and the full list on the
events slide-over page (the left pane, its divider and the header toggle are gone, so events no longer take
any of the plot's width). What makes it available -- recording is on, or a replay is on screen, or the
cache already holds something (event_recording || replaying || events_cached) -- is decided in one place,
sync_events_availability(), and it sets the block's sensitivity (dock_events_set_sensitive()) and the page's
(events_box) from the one available, so they cannot disagree. Unavailable means insensitive, never hidden
("disable, do not hide"): the block stays on screen greyed, and turning recording off with the page open leaves it up
and greyed rather than stale and live.
This deliberately differs from the redesign spec, which has the block insensitive "outside trigger mode". Trigger mode is not one of the conditions: a stored event being examined is a file, which has no trigger, and a replay or a group leaves trigger mode, so a block that went grey outside trigger mode would grey itself under the very click that started the replay -- the one-click replay in the dock would disable the dock it was clicked in. Do not "fix" this to match the spec. The recording switch lives in the trigger panel, not in the block it empties.
The two views follow the latch on different cadences: the dock is rebuilt on every latch (record_event()), the
page's list only while the page is open, and open_events_page() refreshes it when it was closed; a caller that
opens the page any other way shows a list that is as stale as the last time it was open.
Opening a file is a detour, so Go live is a restore
A file view overwrites the live view's settings and every overwrite has a reason: the window becomes the event's own span because that is what framing an event means, and the trigger goes off because a file has no edge to wait for (leaving it on kept a frozen capture on screen instead of the file, so the click looked like it did nothing at all). None of those reasons survive the way back. What the user had set is theirs, and "going back to live loses the trigger I spent a minute setting up" is the complaint this answers.
snapshot_live_state()on the way in,restore_live_state()on the way out. The snapshot is taken by both ways into a file view --replay_event()andbuild_event_group()-- before the acquisition is stopped, and is a no-op whilereplaying: 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. enabledandarmedare deliberately not restored from the snapshot. They belong to the toolbar toggle, which owns an atomic mirror ofenabled(set_trigger_enabled()) that a plain field write under the lock would leave stale. The restore writes the rest of theTriggerConfigand 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 inapply_time_window(st.window_s)-- the window the session file holds, not the one the live view had.go_live_again()clearslayout_loaded, so that block fires on the next tick; a restore done insidego_live_again()is overwritten by the saved value a frame later. Being the next statement after it intick()is the fix.rebuild_trigger_signals(), finding achannelof-1after the rebuild renumbered the channels, silently settles on the first signal and re-defaults the level -- which is whytrigger_level_touchedis 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_pendingstays set untilchannels()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. Apending=1that never clears is a reading about the stream (no channels yet), not about the restore -- seeinvariants-session-and-export.mdfor the save that made it stick atchannels=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. pinnedis 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 whatheld=andmode=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,… #3for 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 whykSlotsPerRowis 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#Nback 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 islist=4in[events] compare plotsfor 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#Nback 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
(
samples0in the[events] compareline): 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 --openrun).
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 arecord_pendingflag 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 tworecv()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_npyof 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, sogtk_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. -
A pin is mirrored as a range, never as a pair of numbers that might be spans.
Range(pinned_range():{-pre, +post}) and the pin's spans (pin_event_window(pre, post)) are two different things that both look like a pair, and mixing them inverts the window: a second view that handedpinned_range().mintopin_event_window()pinned to[+pre, +post], and the plot drew nothing — no trace, no axis labels — because the x range was degenerate. Views that mirror a pin usepin_display_range(x0, x1), the range's own form.[scope-probe] pin_mirror=1reads the two windows' pins and the scope's x range, and an inverted pin reads0.