trigger: a phosphor display keeps the last N acquisitions; and a Force button

Two changes in the trigger pane, landed together.

**Persistence.** Trigger mode keeps the last N published acquisitions and draws them fading toward
the background, N being a panel control (1..8, default 1) with N=1 meaning today's behaviour
exactly, so the knob is the switch. The layers are the accumulate path's own -- one layer per
(signal, age) in the row's free trace slots, named with the accumulate vocabulary so
`layer_base_name()` strips the suffix and `signal_color()` resolves the base signal -- so a layer is
not a signal: the signal list, the colours and the measurements see one row per signal.
`ScopeModel` retains the outgoing snapshot in seven atomic `shared_ptr` slots (no lock, no copy; the
snapshot already exists) and a layer's channel is resolved **by name inside the snapshot**, because
the channel list is renumbered by any structural change since that acquisition was cut.
`RowSet::sync_layers()` re-plans from the rows and the depth alone, so a latch costs no rebuild, and
`event_data_window()` skips layer slots so an older acquisition can never re-pin the axis. The
schedule is 0.5^age -- the acquisition on screen at full brightness and each older one half the
previous, independent of N, so raising the depth adds dimmer acquisitions rather than rescaling the
ones already on screen. Memory is bounded by the ring's own `--buffer-mb` rather than by the depth,
which is the bound that cannot be driven into an OOM, and the panel says when either bound bites.
This also wired up `set_trace_alpha()`, which the accumulate path was setting and nothing was
reading -- so the documented 0.35 translucency of an accumulation was opaque in fact.

**Force.** In the trigger pane beside Arm/Disarm, shown only while trigger mode is on: one
acquisition now, with the configured window. It is a synthetic edge rather than a second publisher
-- the engine opens one capture at the present moment and everything else is the ordinary latch, so
a forced acquisition is cut, pinned, framed, recorded and cached exactly like a latched one, and
the button owns no window and no publish. The pre-trigger gate is deliberately skipped (capture now
is a request for the history that exists, and the snapshot reports its own shorter pre_s), a
capture already filling is refused rather than restarted, and the state afterwards is the mode's:
Normal stays armed, Single disarms. The request is one atomic flag written under the model mutex
with `armed` -- the producer reads `armed` at the top of a packet and the request at the bottom, so
a request written between the two would be dropped -- and consumed at the end of a packet, after
the channel loop and before the pending publish.

**A defect fixed in passing, in the function Force was modelled on:** `on_arm_clicked()` passed
`TriggerConfig::channel` (an index into `channels()`) to `set_signal_enabled()` (an index into
`signals()`). With one signal acquired, arming switched on the stream's clock signal and left the
watched signal untouched -- and since a signal the panel was not showing is a change, it rebuilt
the buffer under the click, dropping the very pre-trigger window the arming, and now the Force, was
about to capture. Measured before: `0.0100 s of history behind it`; after: `2.5299 s`.
`trigger_signal_index()` is now the one resolution, used by both buttons.
This commit is contained in:
Martino Ferrari committed 2026-10-16 16:46:17 +02:00
1 parent fc4700d1c9
commit e14e1ee069
22 files changed
+1652 -26

No files matched your search

+209 -4
View File
@@ -68,8 +68,28 @@ constexpr float kMeasureDashOff = 4.0f;
/// stacked frames read as one tall plot with a line through it.
constexpr plot::f32 kRowGap = 6.0f;
/// The suffix that names a persistence layer, in the accumulate path's own vocabulary --
/// ` (A)`, ` (B)`, ` #4` -- so that `layer_base_name()` takes it back off, 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.
std::string layer_suffix(int age) {
if (age == 1) return " (A)";
if (age == 2) return " (B)";
return " #" + std::to_string(age + 1);
}
} // namespace
float RowSet::persist_alpha(int age) noexcept {
// Half per acquisition, so the seventh is 1/128 -- and halves, not a slope, because that is what
// "exponential decay" means and because it is the one schedule that can be read off a line of
// numbers: 1, 0.5, 0.25, 0.125. It does not depend on the depth, so raising N adds dimmer
// acquisitions instead of restating the ones already on screen.
float a = 1.0f;
for (int i = 0; i < age; ++i) a *= 0.5f;
return a;
}
std::string layer_base_name(const std::string& name) {
const std::size_t a = name.rfind(" (");
if (a != std::string::npos && a + 3 < name.size() && name[a + 3] == ')' &&
@@ -146,6 +166,11 @@ void RowSet::carry_row(Row& dst, const Row& src) {
dst.name = src.name;
dst.channel = src.channel;
dst.color_index = src.color_index;
// A layer travels with the slot that draws it: it *is* part of what that slot holds. The tick's
// `sync_layers()` re-plans them all next frame in any case, which is what heals a row that has
// moved under a plan made for the old one.
dst.layer = src.layer;
dst.slot_alpha = src.slot_alpha;
dst.ruler_y = src.ruler_y;
dst.ruler_b_y = src.ruler_b_y;
@@ -161,6 +186,8 @@ void RowSet::clear_row(Row& r) {
r.name = {};
r.channel = {-1, -1, -1, -1};
r.color_index = {0, 1, 2, 3};
r.layer = {0, 0, 0, 0};
r.slot_alpha = {1.0f, 1.0f, 1.0f, 1.0f};
r.ruler_y = 0.0;
for (SlotSeries& s : r.slots) s.bind(nullptr);
}
@@ -190,10 +217,24 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
{"", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
r.axis_y = r.plot->add_y_axis(
{"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
for (int s = 0; s < kSlotsPerRow; ++s) {
// Traces are drawn in the order they were created, and a `Plot` cannot reorder them: the
// *dimmest* traces are created first and a signal's own trace last, so 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.
std::array<int, kSlotsPerRow> order{};
for (int s = 0; s < kSlotsPerRow; ++s) order[static_cast<std::size_t>(s)] = s;
std::stable_sort(order.begin(), order.end(), [&r](int a, int b) {
return r.slot_alpha[static_cast<std::size_t>(a)] < r.slot_alpha[static_cast<std::size_t>(b)];
});
for (const int s : order) {
const std::size_t i = static_cast<std::size_t>(s);
r.plot->add_trace({&r.slots[i], signal_color(r.signal_name[i], r.color_index[i]), 1.2f,
r.axis_x, r.axis_y, r.name[i]});
plot::Color color = signal_color(r.signal_name[i], r.color_index[i]);
// The slot's own brightness times the figure-wide one the group views set: a persistence
// layer's decay and an accumulation's translucency are the same knob, and `set_trace_alpha()`
// is what reads it here. It was set and never read before, which is why an accumulation's
// traces were opaque.
color.a = r.slot_alpha[i] * trace_alpha_;
r.plot->add_trace({&r.slots[i], color, 1.2f, r.axis_x, r.axis_y, r.name[i]});
}
r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor));
r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor));
@@ -320,9 +361,132 @@ void RowSet::assign(int row_index, int slot, std::string signal_name,
r.channel[s] = -1; // re-resolved by the next bind()
r.name[s] = std::move(display_name);
r.color_index[s] = color_index;
// Whatever the slot held, it is a *signal's* trace now: a drop onto a slot a persistence layer
// was using is the user's arrangement, and the tick's plan moves the layers around it next frame.
r.layer[s] = 0;
r.slot_alpha[s] = 1.0f;
row_dirty_[static_cast<std::size_t>(row_index)] = true;
}
void RowSet::assign_layer(int row_index, int slot, std::string signal_name,
std::string display_name, int color_index, int age, float alpha) {
assign(row_index, slot, std::move(signal_name), std::move(display_name), color_index);
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return;
Row& r = row(row_index);
const std::size_t s = static_cast<std::size_t>(slot);
r.layer[s] = age;
r.slot_alpha[s] = alpha;
row_dirty_[static_cast<std::size_t>(row_index)] = true;
}
void RowSet::clear_layers() {
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (r.layer[si] == 0) continue;
assign(i, s, "", "", r.color_index[si]);
}
}
}
int RowSet::sync_layers(int depth, int* want) {
const int ages = std::clamp(depth, 1, kMaxPersistDepth) - 1;
// What each slot should hold: 0 a signal's own trace (kept as it is), -1 free, >0 a layer age.
std::array<std::array<int, kSlotsPerRow>, kMaxRows> plan{};
std::array<std::array<std::string, kSlotsPerRow>, kMaxRows> names{};
int wanted = 0;
for (int r = 0; r < rows_; ++r) {
Row& row_state = row(r);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
// A layer slot is free for re-planning; a signal's own trace is not.
plan[static_cast<std::size_t>(r)][si] =
row_state.layer[si] > 0 ? -1 : (row_state.signal_name[si].empty() ? -1 : 0);
}
}
// Layer by layer, as the accumulate path fills its own: age 1 for every signal first, then age 2,
// so a row with room for two layers draws the *sharper* one for each of its signals rather than
// four ages of the first one.
for (int age = 1; age <= ages; ++age) {
for (int r = 0; r < rows_; ++r) {
const std::size_t ri = static_cast<std::size_t>(r);
Row& row_state = row(r);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (plan[ri][si] != 0) continue;
++wanted;
int target = -1;
for (int t = 0; t < kSlotsPerRow; ++t) {
if (plan[ri][static_cast<std::size_t>(t)] == -1) {
target = t;
break;
}
}
if (target < 0) continue; // this row is full: it persists as far as it can
const std::size_t ti = static_cast<std::size_t>(target);
plan[ri][ti] = age;
names[ri][ti] = row_state.signal_name[si] + layer_suffix(age);
}
}
}
int placed = 0;
for (int r = 0; r < rows_; ++r) {
const std::size_t ri = static_cast<std::size_t>(r);
Row& row_state = row(r);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
const int now = plan[ri][si];
const int had = row_state.layer[si];
if (now > 0) {
++placed;
if (had == now && row_state.signal_name[si] == names[ri][si]) continue; // already right
assign_layer(r, s, names[ri][si], names[ri][si], row_state.color_index[si], now,
persist_alpha(now));
} else if (had > 0) {
assign(r, s, "", "", row_state.color_index[si]); // nothing wants this slot any more
}
}
}
if (want != nullptr) *want = wanted;
return placed;
}
int RowSet::layer_of(int row_index, int slot) const {
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return 0;
return row(row_index).layer[static_cast<std::size_t>(slot)];
}
float RowSet::slot_alpha_of(int row_index, int slot) const {
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return 1.0f;
return row(row_index).slot_alpha[static_cast<std::size_t>(slot)];
}
int RowSet::layer_slots() const {
int n = 0;
for (int i = 0; i < kMaxRows; ++i) {
const Row& r = row(i);
for (int s = 0; s < kSlotsPerRow; ++s) {
if (r.layer[static_cast<std::size_t>(s)] > 0) ++n;
}
}
return n;
}
std::vector<std::pair<int, float>> RowSet::row_layers(int row_index) const {
std::vector<std::pair<int, float>> out;
if (row_index < 0 || row_index >= kMaxRows) return out;
const Row& r = row(row_index);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (r.layer[si] > 0) out.push_back({r.layer[si], r.slot_alpha[si]});
}
// Oldest first: the order the decay has to read in.
std::sort(out.begin(), out.end(),
[](const auto& a, const auto& b) { return a.first > b.first; });
return out;
}
bool RowSet::slot_bound(int row_index, int slot) const {
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return false;
return !row(row_index).signal_name[static_cast<std::size_t>(slot)].empty();
@@ -348,9 +512,15 @@ void RowSet::bind(ScopeModel& model, DisplayMode mode) {
// Resolve the stable identity every frame: the channel list is rebuilt (and
// renumbered) whenever a signal is enabled, disabled or re-described, and a
// stored index would then point at a different signal's channel.
//
// A persistence layer resolves the name *under its suffix*: `sine (A)` is `sine`, one
// acquisition back, and it is the same signal's trace -- so it is acquired (or not) with its
// signal, drawn in its colour, and counted by nothing that counts signals.
const std::string base_name =
r.layer[si] > 0 ? layer_base_name(r.signal_name[si]) : r.signal_name[si];
int signal_index = -1;
for (std::size_t k = 0; k < signals.size(); ++k) {
if (signals[k].name == r.signal_name[si]) {
if (signals[k].name == base_name) {
signal_index = static_cast<int>(k);
break;
}
@@ -364,6 +534,36 @@ void RowSet::bind(ScopeModel& model, DisplayMode mode) {
}
}
}
if (r.layer[si] > 0) {
// A layer holds no channel: every reader that asks "is this slot a trace of a signal"
// -- the trace counts, the cursors pane's `mix` mode, the measurement marks, the layout
// writer -- tests `channel >= 0`, so a layer is invisible to all of them by construction.
r.channel[si] = -1;
// It draws only while a *snapshot* is on screen, and only if its signal is still acquired
// (switching a signal off takes its traces off every plot, layers included), and it
// resolves its channel **by name inside the snapshot**: the channel list may have been
// rebuilt and renumbered many times since that acquisition was cut, which is exactly why
// the trigger engine names its channel too.
std::shared_ptr<const EventSnapshot> snap;
if (channel >= 0 && (mode == DisplayMode::Held || mode == DisplayMode::Capturing)) {
snap = model.persist_event(static_cast<std::size_t>(r.layer[si]));
}
std::size_t layer_channel = snap != nullptr ? snap->channel_names.size() : 0;
if (snap != nullptr) {
for (std::size_t c = 0; c < snap->channel_names.size(); ++c) {
if (snap->channel_names[c] == base_name) {
layer_channel = c;
break;
}
}
}
if (snap != nullptr && layer_channel < snap->values.size()) {
r.slots[si].bind_event(std::move(snap), layer_channel);
} else {
r.slots[si].bind(nullptr);
}
continue;
}
r.channel[si] = channel;
if (channel < 0) {
// Its signal is present but not acquired: keep the identity (so enabling it
@@ -818,6 +1018,11 @@ bool RowSet::event_data_window(double& lo, double& hi) const {
const Row& r = row(i);
if (!r.plot) continue;
for (int s = 0; s < kSlotsPerRow; ++s) {
// A persistence layer is bound to an event series too, and it is the *past*: letting one
// widen this window would move the pin -- and with it the framing and the numbers a pinned
// measurement reads -- every time an older acquisition happened to be wider than the one on
// screen. What is pinned is the current event's window, and only that.
if (r.layer[static_cast<std::size_t>(s)] > 0) continue;
const EventSeries* ev = r.slots[static_cast<std::size_t>(s)].bound_event();
if (ev == nullptr || ev->size() < 2) continue;
const plot::Range b = ev->x_bounds();