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

+72 -1
View File
@@ -3514,11 +3514,27 @@ gboolean tick(gpointer user) {
app->capturing = display == DisplayMode::Capturing;
if (new_capture) app->capture_edge = cap_edge;
// The persistence plan this frame: what the depth asked for and what the rows had room to draw.
// Declared outside the lock block because the notice below is a widget write, and the tick's
// widget work happens outside the model's lock.
const bool live_display = display == DisplayMode::Live || !app->waveform_name.empty();
int persist_depth = 1;
int persist_want = 0;
{
// Serialise against the acquisition thread's CONFIG handling and against the
// UI's own structural changes: the bind takes shared handles into the live
// store, and acquire() reads it.
std::lock_guard<std::mutex> lock(model.mutex());
// Persistence first, so the bind below is what *resolves* the layers: the last N acquisitions
// are planned into the rows' free slots, and a depth of 1 -- or the trigger off, or a *file* on
// screen, whose data is not a stream of acquisitions at all -- frees every layer slot, which is
// the display without the feature. The plan is a function of the rows and the depth and not of
// the data, so a latch costs no rebuild and the names and ages on screen stay put.
persist_depth = live_display ? 1 : model.persist_depth_locked();
const int placed = app->rows->sync_layers(persist_depth, &persist_want);
app->persist_want = persist_want;
app->persist_placed = placed;
app->rows->bind(model, display);
app->rows->acquire(model);
if (app->comparing && app->rows_b != nullptr) {
@@ -3564,6 +3580,28 @@ gboolean tick(gpointer user) {
// acquisition thread mutates, so it belongs under this lock and not after it.
app->rows->set_invalid_spans(model, display);
}
// A depth the room or the retention budget could not show in full says so, once per *plan* rather
// than once per frame: 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". Both bounds are named,
// because they are different numbers and only one of them is the user's own: a *slot* shortfall is
// `placed < want` (the rows had no free trace slot), and a *budget* shortfall is fewer retained
// acquisitions than the depth asks for, in which case the slots exist and draw nothing at all.
const int want_ages = persist_depth > 1 ? persist_depth - 1 : 0;
const int kept_ages = static_cast<int>(std::min<std::size_t>(
model.persist_count(), static_cast<std::size_t>(std::max(0, want_ages))));
if (persist_depth > 1 &&
(app->persist_placed < persist_want || kept_ages < want_ages) &&
(app->persist_noted_depth != persist_depth || app->persist_noted_want != persist_want ||
app->persist_noted_placed != app->persist_placed)) {
app->persist_noted_depth = persist_depth;
app->persist_noted_want = persist_want;
app->persist_noted_placed = app->persist_placed;
set_notice(*app,
"persistence: " + std::to_string(app->persist_placed) + " of " +
std::to_string(persist_want) + " layers fit, " + std::to_string(kept_ages) +
" of " + std::to_string(want_ages) + " older acquisitions retained",
5);
}
// Outside the lock: it reads and writes the two plots' own windows, not the model.
sync_compare_axes(*app);
sync_events_divider(*app);
@@ -5777,6 +5815,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_DISARM_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), disarm_probe_step1, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_FORCE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), force_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_ARM_TRIGGER_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), arm_probe, &app);
}
@@ -5909,6 +5950,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_RESEQ_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), reseq_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_PERSIST_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), persist_probe, &app);
}
// Our own options were consumed by parse_args(); GTK would reject them, so only
// the program name is handed to g_application_run().
@@ -6340,7 +6384,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
(app.search_run_button != nullptr) + (app.search_cancel_button != nullptr) +
(app.search_prev_button != nullptr) + (app.search_next_button != nullptr);
std::printf(
"[udpscope] trigger panel: boxes=%d controls_inside=%d/15 search_widgets=%d/4 "
"[udpscope] trigger panel: boxes=%d controls_inside=%d/16 search_widgets=%d/4 "
"search_inside=%d/4\n",
boxes,
inside(app.trigger_signal_drop, app.trigger_body) +
@@ -6357,6 +6401,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
inside(app.trigger_holdoff_spin, app.trigger_body) +
inside(app.trigger_band_spin, app.trigger_body) +
inside(app.trigger_hf_spin, app.trigger_body) +
inside(app.trigger_persist_spin, app.trigger_body) +
inside(app.events_record_switch, app.trigger_body),
search_present,
inside(app.search_run_button, search_root) + inside(app.search_cancel_button, search_root) +
@@ -6600,6 +6645,32 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
static_cast<unsigned long long>(buf != nullptr ? buf->size() : 0));
}
}
{
// Persistence: what is retained, what the rows had room to draw, and the decay schedule in
// use (oldest first, so the half-per-acquisition fall-off reads straight off the line). The
// layer slots are named in the `layout:` line above, as `sine (A)`, and are deliberately
// absent from the per-slot channel lines below it: a layer is not a channel.
std::lock_guard<std::mutex> lock(model.mutex());
const int depth = model.persist_depth_locked();
std::printf("[udpscope] persistence: depth=%d kept=%zu bytes=%zu budget=%zu dropped=%llu "
"layers=%d want=%d placed=%d schedule=[",
depth, model.persist_count(), model.persist_bytes(),
model.persist_budget_bytes(),
static_cast<unsigned long long>(model.persist_dropped()), rows.layer_slots(),
app.persist_want, app.persist_placed);
// The decay schedule the depth asks for, oldest first: half per acquisition, so this line
// reads the rule and the probe's `alphas=` reads what the rows were actually built with.
for (int age = std::min(depth - 1, kMaxPersistDepth - 1); age >= 1; --age) {
std::printf("%s%.4f", age != depth - 1 ? "," : "",
static_cast<double>(RowSet::persist_alpha(age)));
}
std::printf("] alphas=[");
const std::vector<std::pair<int, float>> layers = rows.row_layers(0);
for (std::size_t i = 0; i < layers.size(); ++i) {
std::printf("%s%.4f", i != 0 ? "," : "", static_cast<double>(layers[i].second));
}
std::printf("]\n");
}
}
return result;
}