Files
udpscope/src/ui_probe.cpp
T
Martino FerrariandClaude Opus 4.6 16e6d7fa75 ui: sidebar tab selector uses text labels instead of icons
The two drawn tab glyphs were judged not understandable enough; the
switcher goes to the wide policy with titles only, and the glyphs,
their gresource entries and the icon-naming code are removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 09:49:55 +01:00

7180 lines
350 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The front end's self-check: the probes that drive the window from the environment variables in
// `docs/claude/verification.md`, and the dump that prints what they found. They are the app's only
// automated check, and they are code of their own -- a third of what used to be `ui.cpp` -- so they
// live in a translation unit of their own.
#include "source/probe_source.hpp"
#include "source/e1608/e1608_source.hpp"
#include "source/flatudp/flatudp_signals.hpp"
#include "source/flatudp/flatudp_source.hpp"
#include "source/registry.hpp"
#include "ui_app.hpp"
#include <sys/stat.h>
#include <cmath>
#include <cstdio>
#include <cstring>
namespace udpscope {
namespace ui_impl {
int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->dbg_paint_start_us != 0) {
const double ms = double(g_get_monotonic_time() - app->dbg_paint_start_us) / 1000.0;
app->dbg_draw_ms_sum += ms;
if (ms > app->dbg_draw_ms_max) {
app->dbg_draw_ms_max = ms;
app->dbg_draw_max_frame = app->dbg_paint_n;
}
}
if (app->area != nullptr) {
if (const plot::Renderer* r = app->area->renderer()) {
const plot::FrameStats& s = r->stats();
app->dbg_seg_sum += s.staged_segments;
app->dbg_seg_max = std::max(app->dbg_seg_max, s.staged_segments);
app->dbg_visible_max = std::max(app->dbg_visible_max, s.visible_samples);
app->dbg_seg_last = s.staged_segments;
if (s.raw_traces > 0) ++app->dbg_raw_frames;
if (s.lod_traces > 0) ++app->dbg_lod_frames;
if (s.column_traces > 0) ++app->dbg_column_frames;
if (s.graded_traces > 0) ++app->dbg_graded_frames;
if (s.raw_traces > 0 || s.lod_traces > 0) check_frame_ink(*app);
if (s.raw_traces == 0 && s.lod_traces == 0) {
// **A frame the density is drawing is not a traceless one.** `Renderer::draw_traces()`
// returns while a plot's phosphor density is on -- the trace ink *is* the image -- so every
// frame of a persistence display stages no trace and would be counted here, which inflates
// the dump's reading and (with `UDPSCOPE_PROBE_NOTRACE` set below) prints a line per frame.
// That is the display working, not "what the frame was asked to draw" with nothing to draw
// it from. Read off the plots, because the frame's own stats can only say no trace was
// drawn and not why.
bool density_on = false;
for (int i = 0; i < app->rows->row_count() && !density_on; ++i) {
const plot::Plot* p = app->rows->row(i).plot;
density_on = p != nullptr && p->density().enabled;
}
if (!density_on) {
++app->dbg_notrace_frames;
app->dbg_notrace_last = app->dbg_paint_n;
if (app->dbg_ever_bound) {
++app->dbg_notrace_after_bound;
// What the frame was asked to draw, beside what there was to draw it from: an axis
// that left the data and a trace that had nothing bound are the same picture and
// different bugs, so both are named here rather than inferred from the count.
//
// **A per-paint line, so it is behind a variable** (`UDPSCOPE_PROBE_NOTRACE`), unlike
// the counters beside it: a state that draws nothing lasts as long as it lasts -- a
// `Waiting` tail, a bound row with no data -- and the line would be one per frame the
// whole time. The count in the dump is the reading; this is the detail behind it.
static const bool notrace_print = g_getenv("UDPSCOPE_PROBE_NOTRACE") != nullptr;
if (notrace_print && app->dbg_notrace_after_bound <= 40) {
const RowSet::Row& r = app->rows->row(0);
const plot::Range ax = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{};
std::fprintf(stderr, "[notrace] paint %d mode=%s axis=[%.6f..%.6f] segments=%u",
app->dbg_paint_n, display_mode_name(app->dbg_display), ax.min, ax.max,
s.staged_segments);
for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) {
if (r.channel[static_cast<std::size_t>(sl)] < 0) continue;
const plot::Range b = r.slots[static_cast<std::size_t>(sl)].x_bounds();
std::fprintf(stderr, " slot%d n=%zu x=[%.6f..%.6f]", sl,
r.slots[static_cast<std::size_t>(sl)].size(), b.min, b.max);
}
std::fprintf(stderr, "\n");
}
}
}
}
}
}
return FALSE; // same reason the adapter does: never stop the emission
}
gboolean exit_probe_step3(gpointer user) {
App* a = static_cast<App*>(user);
a->dbg_mode_live = a->status_detail;
GtkWidget* window = a->window;
a->window = nullptr; // stop the tick before the widgets go
a->dbg_side_w = gtk_widget_get_width(a->side_box);
a->dbg_side_h = gtk_widget_get_height(a->side_box);
a->dbg_signal_w = gtk_widget_get_width(a->signal_panel);
a->dbg_signal_h = gtk_widget_get_height(a->signal_panel);
a->dbg_trigger_w = gtk_widget_get_width(a->trigger_panel);
a->dbg_trigger_h = gtk_widget_get_height(a->trigger_panel);
// The switcher's size as allocated: read here and not on the first tick, where the window has not
// been allocated yet and every widget still measures 0x0.
if (a->side_switcher != nullptr) {
a->dbg_switcher_w = gtk_widget_get_width(a->side_switcher);
a->dbg_switcher_h = gtk_widget_get_height(a->side_switcher);
}
// How many rows of the signal list carry a drag source: dragging a signal onto a plot is how a plot is
// filled, so it has to survive both pane modes -- the locked list drops the tick, the menu and the `+`,
// and this is the reading that says it did not drop the drag with them. One row is the `MATH` divider,
// which is not draggable: the count must be `rows - 1`.
if (a->signal_list != nullptr) {
int sources = 0;
for (GtkWidget* r = gtk_widget_get_first_child(a->signal_list); r != nullptr;
r = gtk_widget_get_next_sibling(r)) {
GListModel* controllers = gtk_widget_observe_controllers(r);
const guint n = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0;
for (guint i = 0; i < n; ++i) {
GObject* c = G_OBJECT(g_list_model_get_item(controllers, i));
if (GTK_IS_DRAG_SOURCE(c)) ++sources;
g_object_unref(c);
}
}
a->dbg_signal_drag_sources = sources;
}
// A drag out of an *overlaid* sidebar: the sidebar has to step aside or the drop has nowhere to land.
// The state is forced the way the sidebar probe forces it -- collapsed is what a window with no room
// beside the plot produces -- and the drag is begun through the *source*, so the handler is the one a
// real drag reaches.
if (a->side_view != nullptr && a->signal_list != nullptr) {
AdwOverlaySplitView* split = ADW_OVERLAY_SPLIT_VIEW(a->side_view);
GtkWidget* row = gtk_widget_get_first_child(a->signal_list);
adw_overlay_split_view_set_collapsed(split, TRUE);
adw_overlay_split_view_set_show_sidebar(split, TRUE);
if (row != nullptr) {
GListModel* controllers = gtk_widget_observe_controllers(row);
const guint n = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0;
for (guint i = 0; i < n; ++i) {
GObject* c = G_OBJECT(g_list_model_get_item(controllers, i));
if (GTK_IS_DRAG_SOURCE(c)) {
g_signal_emit_by_name(c, "drag-begin", nullptr);
a->dbg_drag_hid_sidebar =
adw_overlay_split_view_get_show_sidebar(split) == FALSE ? 1 : 0;
row_drag_sidebar(*a, false);
a->dbg_drag_restored_sidebar =
adw_overlay_split_view_get_show_sidebar(split) != FALSE ? 1 : 0;
}
g_object_unref(c);
}
}
// Left as the window's own width decides, so the readings taken before this are the ones that stand.
adw_overlay_split_view_set_collapsed(split, FALSE);
adw_overlay_split_view_set_show_sidebar(split, TRUE);
}
// The panes' own switches, and *both* directions of the sync: the header bar's trigger button is
// flipped here and the pane's switch has to follow, then the cursors' switch is driven from the
// pane and the button is what has to follow. Both are read back after the fact, so the two pairs in
// the dump are the claim: they agree, having been moved from opposite ends.
if (a->trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
}
a->dbg_panel_switch_trigger =
a->trigger_switch != nullptr && gtk_switch_get_active(GTK_SWITCH(a->trigger_switch)) ? 1 : 0;
a->dbg_toolbar_switch_trigger =
a->trigger_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->trigger_toggle))
? 1 : 0;
if (a->cursors_switch != nullptr) {
gtk_switch_set_active(GTK_SWITCH(a->cursors_switch), TRUE);
a->dbg_panel_switch_cursors = gtk_switch_get_active(GTK_SWITCH(a->cursors_switch)) ? 1 : 0;
a->dbg_toolbar_switch_cursors =
a->cursors_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->cursors_toggle))
? 1 : 0;
a->dbg_panel_drove_toolbar = a->dbg_toolbar_switch_cursors;
}
// The height of one signal row, which is the thing "the channel toggle is too tall"
// was about. The panel's own height cannot stand in for it: it also covers the
// heading, the hint and the box margins.
if (GtkWidget* first = gtk_widget_get_first_child(a->signal_list)) {
a->dbg_signal_row_h = gtk_widget_get_height(first);
}
if (a->area != nullptr) a->dbg_frames_painted = static_cast<int>(a->area->frames_painted());
a->dbg_reset_sensitive = a->cursors_reset != nullptr && gtk_widget_get_sensitive(a->cursors_reset) ? 1 : 0;
// A page, not a revealer: a stack's hidden page measures 0x0 the way an unrevealed revealer
// did, so the reading asks about *state* -- is this page the current one -- and never about size.
const char* page = a->side_stack != nullptr
? adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(a->side_stack))
: nullptr;
const int on_signals = page != nullptr && std::strcmp(page, "signals") == 0 ? 1 : 0;
a->dbg_signal_page_current =
on_signals && gtk_widget_get_first_child(a->signal_list) != nullptr ? 1 : 0;
a->dbg_trigger_sensitive = on_signals && gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0;
// `gtk_widget_get_visible` on the sidebar's GtkBox is always 1: AdwOverlaySplitView hides its
// sidebar child via `gtk_widget_set_child_visible()`, not `gtk_widget_set_visible()`. The useful
// reading is whether the split view is currently showing the sidebar panel.
const int sidebar_shown = a->side_view != nullptr &&
adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(a->side_view)) ? 1 : 0;
a->dbg_side_column_after_hide = sidebar_shown;
a->dbg_switcher_visible =
a->side_switcher != nullptr && gtk_widget_get_visible(a->side_switcher) ? 1 : 0;
if (a->trigger_signal_drop != nullptr) {
GListModel* model = gtk_drop_down_get_model(GTK_DROP_DOWN(a->trigger_signal_drop));
a->dbg_trigger_items = model != nullptr ? static_cast<int>(g_list_model_get_n_items(model)) : 0;
a->dbg_trigger_selected = static_cast<int>(
gtk_drop_down_get_selected(GTK_DROP_DOWN(a->trigger_signal_drop)));
}
// `dbg_window_text` is kept by `sync_window_display()`, the only place that knows the span the
// window control is showing: there is no label to read it back from any more.
if (window != nullptr) gtk_window_destroy(GTK_WINDOW(window));
return G_SOURCE_REMOVE;
}
/// The frame after the pane was locked: read the compact arm control's *allocation*, then leave the
/// configuration mode and trigger mode as step 2 used to, and hand the chain back its 400 ms. The button
/// is created hidden and this is the first tick it is shown, so its width in the same tick would always
/// read 0 -- a number that says "not yet laid out" rather than "not on the surface".
gboolean compact_arm_step(gpointer user) {
App* a = static_cast<App*>(user);
GtkWidget* b = a->trigger_summary_arm;
a->dbg_summary_arm_width = b != nullptr ? gtk_widget_get_width(b) : -1;
std::fprintf(stderr,
"[udpscope] compact arm probe: locked=%d enabled=%d own=%d effective=%d "
"width=%d panel_width=%d label=%s\n",
a->trigger_locked ? 1 : 0,
a->trigger_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->trigger_toggle)) != FALSE
? 1 : 0,
b != nullptr && gtk_widget_get_visible(b) ? 1 : 0,
b != nullptr && gtk_widget_is_visible(b) ? 1 : 0, a->dbg_summary_arm_width,
// The body's own Arm button, read only if it is itself on screen: with the pane locked it
// is not, and its stale allocation would read as if it were. -1 is "not the one on screen".
// `is_visible`, not `get_visible`: the flag is the widget's own and stays true while its
// *body* is hidden, which reported the body button's stale allocation as a live one.
a->arm_button != nullptr && gtk_widget_is_visible(a->arm_button)
? gtk_widget_get_width(a->arm_button)
: -1,
a->dbg_summary_arm_label.c_str());
std::fflush(stderr);
if (a->trigger_edit != nullptr) {
g_signal_emit_by_name(a->trigger_edit, "clicked"); // and back to the view only mode
}
if (a->trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), FALSE);
}
// The tick keeps running for these 400 ms, so by step 3 the status line reads
// the state the app is actually in after leaving trigger mode.
g_timeout_add(400, exit_probe_step3, a);
return G_SOURCE_REMOVE;
}
gboolean exit_probe_step2(gpointer user) {
App* a = static_cast<App*>(user);
a->dbg_mode_triggered = a->status_detail;
if (a->rows->row(0).plot) {
a->dbg_marker_x = a->rows->row(0).plot->cursor(a->rows->row(0).trigger_marker).x;
a->dbg_marker_max = a->rows->row(0).plot->x_range(0).max;
a->dbg_trigger_time = a->model->trigger_time().load();
a->dbg_trigger_time_valid = a->model->trigger_time_valid().load() ? 1 : 0;
}
// The combination the compact control exists for: the trigger *on*, the pane *locked*, so the body's
// own Arm button has gone with the body. Locking is the [edit] button's click, because that is what a
// user does, and the two properties read are the two the user sees. The label is checked against the
// engine's armed state rather than against a word: a single-shot latch can have disarmed it by now.
if (a->trigger_edit != nullptr) {
g_signal_emit_by_name(a->trigger_edit, "clicked"); // into configuration mode
GtkWidget* b = a->trigger_summary_arm;
a->dbg_summary_arm_visible = b != nullptr && gtk_widget_get_visible(b) ? 1 : 0;
a->dbg_summary_arm_sensitive = b != nullptr && gtk_widget_get_sensitive(b) ? 1 : 0;
const char* text = b != nullptr && GTK_IS_BUTTON(b) ? gtk_button_get_label(GTK_BUTTON(b)) : nullptr;
a->dbg_summary_arm_label = text != nullptr ? text : "-";
}
// The pane stays locked for one more frame: the allocation the next step reads is only meaningful once
// a frame has run with the control shown. Leaving trigger mode is that step's job now.
g_timeout_add(400, compact_arm_step, a);
return G_SOURCE_REMOVE;
}
gboolean trig_trace_probe(gpointer user) {
App* a = static_cast<App*>(user);
// Before the first CONFIG the store does not exist yet: live() would hand back a
// null BufferStore.
if (a->window == nullptr || a->model->channel_count() == 0) return G_SOURCE_CONTINUE;
std::lock_guard<std::mutex> lock(a->model->mutex());
ScopeModel& m = *a->model;
// What the first bound slot shows, read off the slot itself: the binding is the
// thing under test, so it must not be inferred from the trigger state.
const char* display = "nothing";
const SlotSeries* shown = nullptr;
for (int i = 0; i < a->rows->row_count(); ++i) {
RowSet::Row& r = a->rows->row(i);
bool found = false;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (r.channel[si] < 0) continue;
// A live binding in trigger mode is only legitimate as a capture in progress, so
// the two are named apart: `live` with `trigger=on` is the defect, `live(capturing)`
// is the feature.
if (r.slots[si].bound_event() != nullptr) display = "event(frozen)";
else if (r.slots[si].bound() != nullptr) display = a->capturing ? "live(capturing)" : "live";
shown = &r.slots[si];
found = true;
break;
}
if (found) break;
}
// Where that slot's samples are *drawn*, reconstructed the way the renderer does it:
// `Series::gather()` emits x relative to an origin **the caller names**, and the
// consumer adds that same origin back before mapping to the screen. So
// `origin + gathered` must equal `x_at()`, and `drawn=` must equal `data=` to the
// f32's precision. The origin named here is `x0()`, deliberately -- the renderer
// passes the visible range's minimum, but this line reads the series' *whole* span,
// which is the widest relative value a gather can be asked to stage and so the worst
// case for the f32.
//
// This is the one defect in this area the picture alone shows and no other probe
// line does: a series that emits absolute x instead makes the consumer add the origin
// twice, so the trace lands a whole origin away from the axis it was measured
// against -- off screen entirely once the view is narrower than that shift, while
// the crosshair (which reads `x_at()`) goes on reporting correct values.
double drawn_lo = 0.0, drawn_hi = 0.0, data_lo = 0.0, data_hi = 0.0;
if (shown != nullptr && shown->size() > 0) {
const u64 n = shown->size();
std::vector<f32> xy;
shown->gather(0u, 0u, 1u, shown->x0(), xy);
const std::size_t head = xy.size();
shown->gather(0u, n - 1, n, shown->x0(), xy);
if (head >= 2 && xy.size() >= head + 2) {
drawn_lo = shown->x0() + double(xy[0]);
drawn_hi = shown->x0() + double(xy[head]);
data_lo = shown->x_at(0);
data_hi = shown->x_at(n - 1);
}
}
double snap_lo = 0.0;
double snap_hi = 0.0;
std::size_t snap_n = 0;
if (auto snap = m.event()) {
snap_lo = -snap->pre_s;
snap_hi = snap->post_s;
snap_n = snap->stamps.size();
}
plot::Range axis{0.0, 0.0};
if (a->rows->row_count() > 0 && a->rows->row(0).plot != nullptr) {
const RowSet::Row& r0 = a->rows->row(0);
axis = r0.plot->x_range(r0.axis_x);
}
// The renderer's own index window, recomputed exactly as `Renderer::draw_traces`
// does it: it maps the visible x range onto sample indices with
// `(x - x0()) / dx()` -- a *uniform grid* formula -- and gathers only
// `[first, last)`. `ChannelBuffer`'s x is a streamed timestamp, so that formula is
// only ever as good as `dx()` describing the real local spacing. `want=` is the
// same window found by binary search over the real time ring, which is what
// `x_at()`/`neighbours()` -- and therefore the crosshair -- use.
//
// **`got=` must contain `want=`.** When `dx()` comes out larger than the real
// spacing every index is overestimated, `first` lands past the first visible
// sample, and the samples between the two are in the buffer, readable by the
// crosshair, and never drawn: the view's older part goes blank while its newest
// part looks perfectly fine. `dx_ratio` is the cause in one number -- it is 1.0
// when the grid assumption holds, and it inflates whenever a stamp inside the
// window is wrong (a sample whose time slot the ring already dropped reads 0).
long long got_lo = -1, got_hi = -1, want_lo = -1, want_hi = -1;
double dx_nominal = 0.0, dx_real = 0.0, bound_lo = 0.0, bound_hi = 0.0;
if (shown != nullptr && shown->size() > 0) {
const u64 n = shown->size();
const f64 sx0 = shown->x0();
dx_nominal = shown->dx();
dx_real = n >= 2 ? shown->x_at(n - 1) - shown->x_at(n - 2) : 0.0;
bound_lo = shown->x_bounds().min;
bound_hi = shown->x_bounds().max;
u64 i0 = 0, i1 = 0;
(void)sx0;
if (dx_nominal > 0.0) {
// What the renderer draws, through the series' own x: it used to walk the
// `(x - x0()) / dx()` grid, and on a ring-backed series that landed the window inside
// the visible range and dropped the two segments that draw into the left and right
// margins. `neighbours()`, one sample of slack either side, is the whole of it now --
// the same call the crosshair reads through. The *guard* against the grid walk coming
// back is the library's own pixel test
// (`a_sparse_window_between_samples_still_inks_both_margins`), because a probe that
// recomputes the renderer's formula cannot see one that no longer matches it; what this
// line still says is the rate: `dx_ratio` is how far the nominal grid sits from the real
// stamps, which is what made the walk wrong in the first place.
// A window edge outside the series keeps the renderer's own default on that side: 0 on
// the left (draw from the oldest sample) and n on the right (draw to the newest), which
// is why a view reaching past the data leaves the margin beyond it genuinely empty.
got_lo = shown->neighbours(axis.min, i0, i1) ? (i0 > 0 ? (long long)i0 - 1 : 0) : 0;
got_hi = shown->neighbours(axis.max, i0, i1)
? std::min<long long>((long long)i1 + 1, (long long)n)
: (long long)n;
}
want_lo = shown->neighbours(axis.min, i0, i1) ? (long long)i0
: (axis.min < shown->x_at(0) ? 0 : (long long)n);
want_hi = shown->neighbours(axis.max, i0, i1) ? (long long)i1 + 1
: (axis.max < shown->x_at(0) ? 0 : (long long)n);
}
std::fprintf(stderr,
"[trig-probe/idx] n=%llu bounds=[%.4f..%.4f] dx=%.6g dx_real=%.6g "
"dx_ratio=%.3f got=[%lld..%lld) want=[%lld..%lld)\n",
static_cast<unsigned long long>(shown != nullptr ? shown->size() : 0), bound_lo,
bound_hi, dx_nominal, dx_real,
dx_real > 0.0 ? dx_nominal / dx_real : 0.0, got_lo, got_hi, want_lo, want_hi);
std::fprintf(stderr,
"[trig-probe] trigger=%s pending=%d display=%s epoch=%.4f latches=%llu armed=%d "
"live_pub=%llu snap=[%.4f..%.4f] n=%zu axis=[%.4f..%.4f] "
"data=[%.4f..%.4f] drawn=[%.4f..%.4f]\n",
m.trigger_enabled() ? "on" : "off", int(m.trigger_pending()), display,
m.display_epoch(),
static_cast<unsigned long long>(m.trigger_latches().load()),
int(m.trigger_armed().load()),
static_cast<unsigned long long>(m.live().values(0).produced()), snap_lo, snap_hi,
snap_n, double(axis.min), double(axis.max), data_lo, data_hi, drawn_lo, drawn_hi);
std::fflush(stderr);
return G_SOURCE_CONTINUE;
}
gboolean eventzoom_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->trigger_mode_drop != nullptr)
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_mode_drop), 1);
if (a->trigger_toggle != nullptr)
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
g_timeout_add(50, eventzoom_wait, a);
return G_SOURCE_REMOVE;
}
gboolean math_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// The channel the graphs are built from is enabled *here*, for the reason the noise and measurement
// probes give: `enable_all_probe` finishes by switching one signal off, and with `sine` gone the graphs
// land on `square`, which turns the documented "2 math channels on 'sine' and 'noise'" into a different
// line for reasons that have nothing to do with the math.
{
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == "sine") a->model->set_signal_enabled(i, true);
}
}
std::string name;
std::string late_name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
if (a->model->channels().empty()) return G_SOURCE_REMOVE;
name = a->model->channels().front().name;
// The *last* acquired channel, and the probe needs it rather than merely liking it.
// Filling the editor's form calls `gtk_drop_down_set_selected()` on the source
// dropdown, and GObject does not emit `notify` when a property is set to the value it
// already holds -- so a channel sourcing `channels().front()` picks index 0 on a
// freshly built dropdown that is already at 0, the signal never fires, and the whole
// `notify::selected` path goes unexercised. That is exactly how a handler of the wrong
// shape on that signal survived every run of this probe while crashing the app on the
// first editor a user opened.
late_name = a->model->channels().back().name;
}
std::vector<MathChannel> channels;
MathChannel gain;
gain.name = "math x2";
gain.enabled = true;
gain.nodes = {MathNode{MathOp::Source, name}, MathNode{MathOp::Gain}};
gain.nodes[1].in_a = 0;
gain.nodes[1].gain = 2.0;
gain.output = 1;
channels.push_back(gain);
MathChannel lp;
lp.name = "math lp";
lp.enabled = true;
lp.nodes = {MathNode{MathOp::Source, late_name}, MathNode{MathOp::LowPass}};
lp.nodes[1].in_a = 0;
lp.nodes[1].fc = 20.0;
lp.output = 1;
channels.push_back(lp);
// A third channel so the editor's new form row is built and synced on a baseline run. Assigned field
// by field: a partial brace-initialiser would add a fifth build warning.
MathChannel dn;
dn.name = "math denoise";
dn.enabled = true;
MathNode dn_src;
dn_src.op = MathOp::Source;
dn_src.source = name;
MathNode dn_op;
dn_op.op = MathOp::DeNoise;
dn_op.in_a = 0;
dn_op.med_n = 5;
dn_op.fc = 500.0;
dn.nodes = {dn_src, dn_op};
dn.output = 1;
channels.push_back(dn);
// Through the app's list, which is the pane's source of truth: a probe that wrote the model
// directly left the two disagreeing, and the next pane edit then silently dropped its channels.
a->math = std::move(channels);
apply_math_channels(*a);
std::fprintf(stderr, "[math-probe] 3 math channels on '%s' and '%s'\n", name.c_str(),
late_name.c_str());
// And open the editor on the first one: as much a construction crash test as a state check,
// since a modal window is presented and its canvas, form and gestures are all built here.
// The pane's own `+`, which is the path the user takes: it must source an *acquired* channel.
// From `signals().front()` it sourced the stream's timestamp signal -- no samples, so the graph
// validated and then read NaN for ever, which is exactly the reported "[ch] -> [gain] is all nan".
add_math_channel(*a);
if (!a->math.empty()) {
const std::string src = a->math.back().nodes.empty() ? std::string("(none)")
: a->math.back().nodes[0].source;
std::fprintf(stderr, "[math-probe] pane add: '%s' sources '%s'\n", a->math.back().name.c_str(),
src.c_str());
}
open_math_dialog(*a, 0);
std::fprintf(stderr, "[math-probe] editor opened\n");
// Opened on the channel sourcing the *last* acquired signal, which is the one open that moves
// the source dropdown off index 0 and so emits `notify::selected` -- see `late_name` above for
// why nothing else here does. The reading is the source the handler wrote back into the node:
// it must be `late_name` and not the dropdown's first entry, because a handler that reads the
// wrong argument writes through a `GParamSpec*` and the Gtk-CRITICAL baseline is what catches
// it when the dereference happens not to fault.
{
if (a->math_dialog != nullptr) gtk_window_close(GTK_WINDOW(a->math_dialog));
open_math_dialog(*a, 1);
auto* ed = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
const char* read = "NO DIALOG";
if (ed != nullptr && !ed->nodes.empty()) read = ed->nodes[0].source.c_str();
std::fprintf(stderr, "[math-probe] source drop: node reads '%s' (want '%s')\n", read,
late_name.c_str());
// The user's own gesture: click the gain node, then the source node. The source node must
// still name `late_name` -- clicking a node must never rename it, and the form's own writes
// reloading the dropdown is exactly how it used to.
if (ed != nullptr && ed->nodes.size() >= 2) {
const auto click_node = [&](std::size_t i) {
const MathNode& n = ed->nodes[i];
math_canvas_pressed(nullptr, 1, (n.x + 10.0) * ed->view_scale + ed->view_x,
(n.y + 10.0) * ed->view_scale + ed->view_y, ed);
};
click_node(1);
click_node(0);
const char* after = ed->nodes.empty() ? "NO NODES" : ed->nodes[0].source.c_str();
std::fprintf(stderr, "[math-probe] click source: node reads '%s' (want '%s')\n", after,
late_name.c_str());
}
}
// The user's own flow, through the app's handlers: the pane's `+` makes a bare source graph, the
// editor adds a gain, the gain is set, Apply saves. The *output* must be the node that was just
// added -- it defaulted to node 0, the source, so every operation was computed and discarded.
{
const int index = static_cast<int>(a->math.size()) - 1;
// Closed first: `open_math_dialog()` on a dialog that is already up just presents it, so this
// block used to edit the *previous* channel and its numbers described the wrong graph.
if (a->math_dialog != nullptr) gtk_window_close(GTK_WINDOW(a->math_dialog));
open_math_dialog(*a, index);
auto* ed = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (ed != nullptr) {
on_math_node_added(nullptr, ed);
const int output_after_add = ed->output;
// Through the *widgets*, which is what the user touches -- and the read-back below is what
// `on_math_param_changed()` will take them from.
if (ed->gain != nullptr) gtk_spin_button_set_value(GTK_SPIN_BUTTON(ed->gain), 2.0);
if (ed->offset != nullptr) gtk_spin_button_set_value(GTK_SPIN_BUTTON(ed->offset), 0.0);
on_math_param_changed(nullptr, ed);
// The frame, structurally: the canvas being *in* the dialog and given an allocation. A `.ui`
// mistake this has already paid for once -- `math_body` nested under the window instead of the
// outer box -- leaves the canvas visible but parentless: it never maps, stays 0x0 and draws
// nothing, which a user reads as "the editor opens and the canvas is blank". The values below
// cannot see that, and the reading is delayed because an allocation only exists after a layout.
g_timeout_add(500, +[](gpointer u) -> gboolean {
App* app = static_cast<App*>(u);
if (app->math_dialog == nullptr) {
std::fprintf(stderr, "[math-probe] editor frame: (dialog closed before the reading)\n");
return G_SOURCE_REMOVE;
}
MathDialog* m = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(app->math_dialog), "udpscope-math-dialog"));
if (m == nullptr) return G_SOURCE_REMOVE;
const auto in_dialog = [app](GtkWidget* w) {
for (GtkWidget* p = w; p != nullptr; p = gtk_widget_get_parent(p)) {
if (p == app->math_dialog) return 1;
}
return 0;
};
std::fprintf(stderr,
"[math-probe] editor frame: canvas=%dx%d mapped=%d in_dialog=%d "
"name_in_dialog=%d\n",
m->canvas != nullptr ? gtk_widget_get_width(m->canvas) : -1,
m->canvas != nullptr ? gtk_widget_get_height(m->canvas) : -1,
m->canvas != nullptr ? gtk_widget_get_mapped(m->canvas) : -1,
in_dialog(m->canvas), in_dialog(m->name));
return G_SOURCE_REMOVE;
}, a);
const std::size_t built = ed->nodes.size();
const double gain_read = ed->nodes.empty() ? 0.0 : ed->nodes.back().gain;
const double offset_read = ed->nodes.empty() ? 0.0 : ed->nodes.back().offset;
// The identity row, which the file cannot hold values for: the entries were filled from the channel
// being edited, so reading them back is what shows the row is the same one the code writes.
const std::string name_read =
ed->name != nullptr ? gtk_editable_get_text(GTK_EDITABLE(ed->name)) : "-";
const std::string unit_read =
ed->unit != nullptr ? gtk_editable_get_text(GTK_EDITABLE(ed->unit)) : "-";
// The edit must not cost the buffer. The plan is a partition over the channels and an edit does not
// move it, so every channel keeps its history and only the edited graph -- and whatever reads it -- is
// recomputed. Before this, `set_math_channels()` rebuilt the store and every channel started again.
const std::uint64_t slots_before = a->model->stored_slots();
on_math_apply(nullptr, ed); // frees the dialog: nothing below may touch `ed`
const std::uint64_t slots_after = a->model->stored_slots();
std::fprintf(stderr, "[math-probe] graph edit: stored_slots %llu -> %llu (want the second at least the first, and non-zero)\n",
static_cast<unsigned long long>(slots_before),
static_cast<unsigned long long>(slots_after));
const std::size_t saved = a->math.empty()
? 0u
: a->math[static_cast<std::size_t>(index)].nodes.size();
const int output_saved =
a->math.empty() ? -1 : a->math[static_cast<std::size_t>(index)].output;
std::fprintf(stderr,
"[math-probe] dialog build: nodes=%zu, output after add=%d (want %zu); "
"reading back gain=%.2f offset=%.2f; name='%s' unit='%s'; "
"saved nodes=%zu output=%d\n",
built, output_after_add, built - 1, gain_read, offset_read, name_read.c_str(),
unit_read.c_str(), saved, output_saved);
}
}
// Closed and reopened: `gtk_window_destroy()` does not emit `close-request`, so a dialog closed
// that way left `app.math_dialog` pointing at a dead widget and every later open was a `present`
// on it -- "I cannot edit a math channel again". A close must clear the pointer, and the reopen
// must build a *new* dialog with its own data.
if (a->math_dialog != nullptr) gtk_window_close(GTK_WINDOW(a->math_dialog));
std::fprintf(stderr, "[math-probe] after close: dialog=%s\n",
a->math_dialog == nullptr ? "cleared" : "STILL SET (the pointer did not clear)");
open_math_dialog(*a, 0);
if (a->math_dialog != nullptr) {
auto* again = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
std::fprintf(stderr, "[math-probe] reopened: dialog=%s, nodes=%s\n",
again != nullptr ? "ok" : "NO DATA",
again != nullptr ? std::to_string(again->nodes.size()).c_str() : "-");
} else {
std::fprintf(stderr, "[math-probe] reopened: FAILED (no dialog)\n");
}
if (a->math_dialog != nullptr) {
auto* h = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (h != nullptr && !h->nodes.empty()) {
// The output square straddles the box's right edge: it is drawn centred on
// `n.x + kNodeW` with a 5 px half-width and hit with a 10 px radius. A hit test that
// recovers the node by a box test therefore misses every pixel past the edge, which
// is a *silent disarm* of the first click of a wiring gesture.
double ox = 0.0;
double oy = 0.0;
math_output_pos(h->nodes[0], ox, oy);
int w_in = 0;
const int n_in = math_port_at(h, ox - 2.0, oy, kPort, w_in);
int w_out = 0;
const int n_out = math_port_at(h, ox + 4.0, oy, kPort, w_out);
std::fprintf(stderr,
"[math-probe] hit: inside node=%d which=%d, past-edge node=%d which=%d "
"(want 0 %d 0 %d)\n",
n_in, w_in, n_out, w_out, kPortOut, kPortOut);
}
// Cancel must clear the pointer exactly as the close above does: it is the *button* that is
// clicked here, not the window, because the defect was in what the button was connected to.
if (a->math_dialog != nullptr) {
GtkWidget* cancel_button = GTK_WIDGET(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-cancel"));
if (cancel_button != nullptr) {
g_signal_emit_by_name(cancel_button, "clicked");
std::fprintf(stderr, "[math-probe] cancel: dialog=%s (want cleared)\n",
a->math_dialog == nullptr ? "cleared" : "STILL SET");
open_math_dialog(*a, 0);
} else {
std::fprintf(stderr, "[math-probe] cancel: NO BUTTON (the data key is not set)\n");
}
}
}
// The editor's two reported failures, driven directly. A drag reports the *total* offset from
// where the gesture began, so replaying a real sequence of them must land the box at start+last,
// not at the sum of the deltas; and a numeric field must hold a fraction.
if (a->math_dialog != nullptr) {
auto* d = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (d != nullptr && !d->nodes.empty()) {
const double x0 = d->nodes[0].x;
const double y0 = d->nodes[0].y;
math_canvas_drag_begin(nullptr, x0 + kNodeW * 0.5, y0 + kNodeH * 0.5, d);
std::fprintf(stderr,
"[math-probe] drag-begin at (%.0f,%.0f) over node %d, selected %d, nodes %zu\n",
x0 + kNodeW * 0.5, y0 + kNodeH * 0.5, d->drag_node, d->selected, d->nodes.size());
math_canvas_drag(nullptr, 20.0, 10.0, d);
math_canvas_drag(nullptr, 40.0, 20.0, d);
math_canvas_drag(nullptr, 60.0, 30.0, d);
std::fprintf(stderr, "[math-probe] drag: (%.0f,%.0f) -> (%.0f,%.0f), want (%.0f,%.0f)\n", x0,
y0, d->nodes[0].x, d->nodes[0].y, x0 + 60.0, y0 + 30.0);
}
if (d != nullptr && !d->nodes.empty()) {
// Only *acquired* channels may be a source: a disabled signal would read as no data for ever,
// and the stream's timestamp signal has no samples at all.
const std::vector<std::string> acquired = math_source_names(*a);
std::size_t listed = 0;
std::string first;
if (d->source_drop != nullptr) {
GListModel* model = gtk_drop_down_get_model(GTK_DROP_DOWN(d->source_drop));
listed = model != nullptr ? g_list_model_get_n_items(model) : 0;
if (listed > 0) {
GObject* item = G_OBJECT(g_list_model_get_item(model, 0));
first = gtk_string_object_get_string(GTK_STRING_OBJECT(item));
g_object_unref(item);
}
}
std::fprintf(stderr,
"[math-probe] sources: %zu acquired, dropdown lists %zu, first='%s'\n",
acquired.size(), listed, first.c_str());
// The gain+offset group belongs to one op and must be hidden for the others.
d->selected = 0;
const MathOp was = d->nodes[0].op;
d->nodes[0].op = MathOp::LowPass;
math_dialog_sync(d);
const int for_filter = gtk_widget_get_visible(d->rows[1]) ? 1 : 0;
d->nodes[0].op = MathOp::Gain;
math_dialog_sync(d);
const int for_gain = gtk_widget_get_visible(d->rows[1]) ? 1 : 0;
d->nodes[0].op = was;
math_dialog_sync(d);
std::fprintf(stderr, "[math-probe] rows: gain+offset visible for a filter=%d, for gain=%d "
"(want 0, 1)\n", for_filter, for_gain);
}
if (d != nullptr) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->fc), 12.5);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->gain), 0.25);
std::fprintf(stderr, "[math-probe] values: fc=%.4f gain=%.4f, want 12.5000 0.2500\n",
gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->fc)),
gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->gain)));
}
}
if (a->math_dialog != nullptr) {
auto* v = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (v != nullptr && !v->nodes.empty()) {
// A wheel zoom must keep the graph point under the pointer under the pointer: that is the
// one thing a zoom can get wrong without looking broken, and it reads as the canvas
// sliding away from the hand.
const double px = 180.0;
const double py = 140.0;
double gx0 = 0.0;
double gy0 = 0.0;
math_to_graph(v, px, py, gx0, gy0);
math_zoom_at(v, px, py, -1.0);
math_zoom_at(v, px, py, -1.0);
double gx1 = 0.0;
double gy1 = 0.0;
math_to_graph(v, px, py, gx1, gy1);
std::fprintf(stderr,
"[math-probe] view: zoom scale=%.3f anchor kept dx=%.4f dy=%.4f "
"(want 0.0000 0.0000)\n",
v->view_scale, gx1 - gx0, gy1 - gy0);
// And the hit tests must go through the inverse. At the identity view a test that forgot
// it passes anyway, so the port is probed *after* a zoom and a pan -- which is the only
// arrangement that can fail.
v->view_x += 37.0;
v->view_y -= 19.0;
double ox = 0.0;
double oy = 0.0;
math_output_pos(v->nodes[0], ox, oy);
const double cx = ox * v->view_scale + v->view_x;
const double cy = oy * v->view_scale + v->view_y;
int which = 0;
const int node = math_port_at_canvas(v, cx + 3.0, cy, which);
std::fprintf(stderr,
"[math-probe] view: pan+zoom hit node=%d which=%d (want 0 %d)\n", node,
which, kPortOut);
math_view_fit(v);
int inside = 1;
int w_px = gtk_widget_get_width(v->canvas);
int h_px = gtk_widget_get_height(v->canvas);
if (w_px <= 0) w_px = 420;
if (h_px <= 0) h_px = 300;
for (const MathNode& n : v->nodes) {
const double x0 = n.x * v->view_scale + v->view_x;
const double y0 = n.y * v->view_scale + v->view_y;
const double x1 = (n.x + kNodeW) * v->view_scale + v->view_x;
const double y1 = (n.y + kNodeH) * v->view_scale + v->view_y;
if (x0 < -0.5 || y0 < -0.5 || x1 > w_px + 0.5 || y1 > h_px + 0.5) inside = 0;
}
std::fprintf(stderr, "[math-probe] view: fit scale=%.3f all_inside=%d (want 1)\n",
v->view_scale, inside);
math_view_reset(v);
std::fprintf(stderr,
"[math-probe] view: reset scale=%.3f offset=(%.1f,%.1f) "
"(want 1.000 (0.0,0.0))\n",
v->view_scale, v->view_x, v->view_y);
}
}
if (a->math_dialog != nullptr) {
auto* g = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
// The editor's two graphs are source -> gain, so node 1 has one input and node 0 an output.
if (g != nullptr && g->nodes.size() >= 2) {
// Set a non-identity view (scale=2, x=50, y=20) before the gesture probes so that a
// transform bug -- e.g. passing canvas coords straight to math_port_at() without
// math_to_graph() -- would produce wrong hit results. At the identity view canvas coords equal
// graph coords, making such a bug invisible. The trailing math_view_reset() below tidies up.
math_set_scale(g, 2.0);
g->view_x = 50.0;
g->view_y = 20.0;
const auto canvas_of = [&](double gx, double gy, double& cx, double& cy) {
cx = gx * g->view_scale + g->view_x;
cy = gy * g->view_scale + g->view_y;
};
double ox = 0.0;
double oy = 0.0;
double ix = 0.0;
double iy = 0.0;
math_output_pos(g->nodes[0], ox, oy);
math_input_pos(g->nodes[1], 0, ix, iy);
double ocx = 0.0;
double ocy = 0.0;
double icx = 0.0;
double icy = 0.0;
canvas_of(ox, oy, ocx, ocy);
canvas_of(ix, iy, icx, icy);
// Connect: press the output, release over the input.
g->nodes[1].in_a = -1;
math_canvas_drag_begin(nullptr, ocx, ocy, g);
const int mode_wire = static_cast<int>(g->drag_mode);
math_canvas_drag(nullptr, icx - ocx, icy - ocy, g);
math_canvas_drag_end(nullptr, icx - ocx, icy - ocy, g);
std::fprintf(stderr,
"[math-probe] wire: connect mode=%d in_a=%d (want %d 0)\n", mode_wire,
g->nodes[1].in_a, static_cast<int>(MathDrag::Wire));
// Detach: a drag that starts on a *wired* input empties it at once, so a release over
// nothing is the detach. Dropping it back is what the connect above already covers.
math_canvas_drag_begin(nullptr, icx, icy, g);
const int emptied = g->nodes[1].in_a;
math_canvas_drag(nullptr, 0.0, 90.0, g);
math_canvas_drag_end(nullptr, 0.0, 90.0, g);
std::fprintf(stderr,
"[math-probe] detach: emptied_at_begin=%d in_a=%d (want -1 -1)\n", emptied,
g->nodes[1].in_a);
// And Escape during the same gesture must put it back.
g->nodes[1].in_a = 0;
math_canvas_drag_begin(nullptr, icx, icy, g);
math_canvas_drag(nullptr, 0.0, 90.0, g);
math_canvas_key(nullptr, GDK_KEY_Escape, 0, static_cast<GdkModifierType>(0), g);
std::fprintf(stderr, "[math-probe] detach: escape restores in_a=%d (want 0)\n",
g->nodes[1].in_a);
// Detach and release in place: a wire detached from an input and released at the same canvas
// position must reconnect. With Finding 1 unfixed the release point is computed from the wrong
// origin (the source node's output port rather than the press point), so this reads in_a=-1.
g->nodes[1].in_a = 0;
math_canvas_drag_begin(nullptr, icx, icy, g);
math_canvas_drag(nullptr, 0.0, 0.0, g);
math_canvas_drag_end(nullptr, 0.0, 0.0, g);
std::fprintf(stderr, "[math-probe] detach: release in place reconnects in_a=%d (want 0)\n",
g->nodes[1].in_a);
// A drag that begins on the background pans and moves nothing.
const double was_x = g->view_x;
const double node_x = g->nodes[0].x;
math_canvas_drag_begin(nullptr, 5.0, 5.0, g);
const int mode_pan = static_cast<int>(g->drag_mode);
math_canvas_drag(nullptr, 30.0, 0.0, g);
math_canvas_drag_end(nullptr, 30.0, 0.0, g);
std::fprintf(stderr,
"[math-probe] view: pan mode=%d dview=%.1f dnode=%.1f (want %d 30.0 0.0)\n",
mode_pan, g->view_x - was_x, g->nodes[0].x - node_x,
static_cast<int>(MathDrag::Pan));
math_view_reset(g);
}
}
if (a->math_dialog != nullptr) {
auto* p = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (p != nullptr && p->nodes.size() >= 2) {
math_set_scale(p, 2.0);
p->view_x = 50.0;
p->view_y = 20.0;
p->nodes[1].in_a = 0;
// The two boxes are put several rows apart on purpose: that is the geometry a tidied graph
// has, and it is the only one in which the pick can fail. Where consecutive curve samples are
// close together, a hit test that measures to the sample *points* and one that measures to the
// *segments* between them give the same answer, so a probe taken on a short wire asserts
// nothing about either.
p->nodes[0].x = 24.0;
p->nodes[0].y = 20.0;
p->nodes[1].x = 260.0;
p->nodes[1].y = 300.0;
double ax = 0.0;
double ay = 0.0;
double bx = 0.0;
double by = 0.0;
math_output_pos(p->nodes[0], ax, ay);
math_input_pos(p->nodes[1], 0, bx, by);
// **Between two samples, not at one.** `math_wire_at()` samples the cubic at `s / 24`, so
// `t = 0.5` is exactly sample 12 and a point-distance test passes there by construction --
// the same mistake as a hit test taken at the identity view. `t = 0.52` falls between samples
// 12 and 13, and `off_sample` below is the measured distance from the pick to the nearest of
// the 25 sample points, in graph units, beside the tolerance the pick is allowed: it reads
// off_sample=8.4 against tol=3.0, so a point test misses and only a segment test can hit.
const auto curve_at = [&](double t, double& px, double& py) {
const double u = 1.0 - t;
px = u * u * u * ax + 3 * u * u * t * (ax + 40.0) + 3 * u * t * t * (bx - 40.0) +
t * t * t * bx;
py = u * u * u * ay + 3 * u * u * t * ay + 3 * u * t * t * by + t * t * t * by;
};
const double t = 0.52;
double mx = 0.0;
double my = 0.0;
curve_at(t, mx, my);
double off_sample = 1e300;
for (int s = 0; s <= 24; ++s) {
double sx = 0.0;
double sy = 0.0;
curve_at(static_cast<double>(s) / 24.0, sx, sy);
off_sample = std::min(off_sample, std::max(std::fabs(mx - sx), std::fabs(my - sy)));
}
const MathWireRef hit = math_wire_at_canvas(p, mx * p->view_scale + p->view_x,
my * p->view_scale + p->view_y);
p->selected_wire = hit;
math_canvas_key(nullptr, GDK_KEY_Delete, 0, static_cast<GdkModifierType>(0), p);
std::fprintf(stderr,
"[math-probe] wire: pick node=%d which=%d -> in_a=%d off_sample=%.1f tol=%.1f "
"(want 1 0 -1, off_sample > tol)\n",
hit.node, hit.which, p->nodes[1].in_a, off_sample,
kWirePick / p->view_scale);
p->nodes[1].in_a = 0;
p->selected_wire = MathWireRef{-1, 0};
}
}
if (a->math_dialog != nullptr) {
auto* c = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (c != nullptr && c->nodes.size() >= 2) {
// A wire drawn right-to-left is legal: the editor re-sorts so that the reader still sits
// after what it reads. Node 0 is the source and node 1 the gain, so wiring the gain into
// the source is impossible (a source has no inputs) -- a second gain reading the first,
// placed *before* it, is the case.
c->nodes.clear();
MathNode src;
src.op = MathOp::Source;
src.source = name;
// The reader is placed *before* what it reads, which is the case the evaluator cannot run
// and the editor has to repair. Index 1 will be wired to read index 2, and index 2 reads
// the source.
MathNode reader;
reader.op = MathOp::Gain;
MathNode feeder;
feeder.op = MathOp::Gain;
c->nodes = {src, reader, feeder};
c->nodes[2].in_a = 0;
// The output is the reader, so the remap is visible in `output` as well as in the wiring.
c->output = 1;
c->selected = 1;
std::string why;
const bool ok = math_connect(c, 2, 1, 0, &why);
// After the sort node 1 must still be the one that reads 0, and the reader of it must sit
// after it. The op is the same for both, so the *wiring* is the reading.
std::string order;
for (std::size_t i = 0; i < c->nodes.size(); ++i) {
order += (i != 0 ? "," : "");
order += std::to_string(i) + ":" + std::to_string(c->nodes[i].in_a);
}
std::string valid_why;
const bool valid = math_channel_valid_nodes(c->nodes, c->output, &valid_why);
std::fprintf(stderr,
"[math-probe] wire: backwards ok=%d order=[%s] output=%d valid=%d why=[%s] "
"(want 1 ... 1 [])\n",
ok ? 1 : 0, order.c_str(), c->output, valid ? 1 : 0, valid_why.c_str());
std::string self_why;
const bool self_ok = math_connect(c, 1, 1, 0, &self_why);
std::fprintf(stderr, "[math-probe] wire: self refused=%d why=[%s] (want 1)\n",
self_ok ? 0 : 1, self_why.c_str());
// The same refusal again, through the **handler**. The line above asserts only
// `math_connect()`'s out-parameter: the UI path used to screen the self case out before the
// call and then drop `why` on the floor, so a user drew the wire, nothing appeared and nothing
// was said -- which is the whole of "the editor is broken" -- while the direct call passed.
// This half asserts what the *validation line* reads after `drag-end`, which is the only place
// that defect is visible. A synthetic GTK gesture is not reachable headlessly, so the drag's
// state is set up by hand and the real handler called: `press_cx`/`press_cy` plus a zero offset
// is a release exactly on the input port of `wire_from` itself.
for (std::size_t i = 0; i < c->nodes.size(); ++i) {
// Spread out, so the release point belongs to one port and not to three stacked at (0,0).
c->nodes[i].x = 24.0 + 200.0 * static_cast<double>(i);
c->nodes[i].y = 20.0;
}
math_set_scale(c, 1.0);
c->view_x = 0.0;
c->view_y = 0.0;
int self_node = -1;
for (std::size_t i = 0; i < c->nodes.size() && self_node < 0; ++i) {
if (math_op_arity(c->nodes[i].op) >= 1) self_node = static_cast<int>(i);
}
if (self_node >= 0) {
double sx = 0.0;
double sy = 0.0;
math_input_pos(c->nodes[static_cast<std::size_t>(self_node)], 0, sx, sy);
c->drag_mode = MathDrag::Wire;
c->wire_from = self_node;
c->wire_detach = -1;
c->press_cx = sx;
c->press_cy = sy;
math_canvas_drag_end(nullptr, 0.0, 0.0, c);
const char* line = gtk_label_get_text(GTK_LABEL(c->problem));
std::fprintf(stderr,
"[math-probe] wire: self through drag-end node=%d problem=[%s] "
"(want the refusal text, not empty)\n",
self_node, line != nullptr ? line : "(null)");
}
// A cycle: the node that already feeds the other is asked to read it back.
const int reader_idx = c->nodes[1].in_a == 0 ? 2 : 1;
const int feeder_idx = reader_idx == 2 ? 1 : 2;
std::string cyc_why;
const bool cyc_ok = math_connect(c, reader_idx, feeder_idx, 0, &cyc_why);
std::fprintf(stderr, "[math-probe] wire: cycle refused=%d why=[%s] (want 1)\n",
cyc_ok ? 0 : 1, cyc_why.c_str());
}
}
if (a->math_dialog != nullptr) {
auto* v2 = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (v2 != nullptr && v2->nodes.size() >= 2) {
// Broken: an unwired input. The line must say so and Apply must be insensitive -- the old
// behaviour was a modal alert *after* the press, which is a refusal rather than guidance.
v2->nodes[1].in_a = -1;
math_dialog_sync(v2);
const std::string bad = math_dialog_problem(v2);
const int bad_sensitive = gtk_widget_get_sensitive(v2->apply) ? 1 : 0;
// Copied before the next sync call overwrites the label's internal string.
const std::string bad_text_copy =
gtk_label_get_text(GTK_LABEL(v2->problem)) != nullptr
? std::string(gtk_label_get_text(GTK_LABEL(v2->problem)))
: std::string("(null)");
v2->nodes[1].in_a = 0;
math_dialog_sync(v2);
const std::string good = math_dialog_problem(v2);
const int good_sensitive = gtk_widget_get_sensitive(v2->apply) ? 1 : 0;
std::fprintf(stderr,
"[math-probe] validate: broken problem=[%s] label=[%s] apply=%d; "
"fixed problem=[%s] apply=%d (want non-empty 0; empty 1)\n",
bad.c_str(), bad_text_copy.c_str(), bad_sensitive,
good.c_str(), good_sensitive);
// The selection out of range is the case `math_dialog_sync()` returns early on, so the
// line must be written above that return or it goes stale exactly when it matters.
const int was_sel = v2->selected;
v2->selected = 999;
v2->nodes[1].in_a = -1;
math_dialog_sync(v2);
const char* stale = gtk_label_get_text(GTK_LABEL(v2->problem));
std::fprintf(stderr, "[math-probe] validate: no-selection label=[%s] (want non-empty)\n",
stale != nullptr ? stale : "(null)");
v2->selected = was_sel;
v2->nodes[1].in_a = 0;
math_dialog_sync(v2);
// And the source dropdown must list the *acquired* channels, which is what Apply checks
// against: listing every signal let a user pick one that could never be made valid.
const std::vector<std::string> acquired = math_source_names(*a);
std::size_t signals_n = 0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
signals_n = a->model->signals().size();
}
v2->selected = 0;
v2->nodes[0].op = MathOp::Source;
math_dialog_sync(v2);
GListModel* lm = gtk_drop_down_get_model(GTK_DROP_DOWN(v2->source_drop));
const guint listed = lm != nullptr ? g_list_model_get_n_items(lm) : 0u;
std::fprintf(stderr,
"[math-probe] validate: source list=%u acquired=%zu signals=%zu "
"(want list==acquired)\n",
listed, acquired.size(), signals_n);
v2->selected = was_sel;
}
}
if (a->math_dialog != nullptr) {
auto* o = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (o != nullptr) {
const std::size_t before = o->nodes.size();
math_add_node(o, MathOp::BandPass);
const int op_added = o->nodes.empty() ? -1 : math_op_index(o->nodes.back().op);
// The op picker must make the node the user asked for, and the `rows` the form shows must
// follow it: a band pass is the one op that shows the Q row, so it is the case worth the
// check.
const int q_row = gtk_widget_get_visible(o->rows[3]) ? 1 : 0;
std::fprintf(stderr,
"[math-probe] op pick: nodes %zu->%zu op_index=%d q_row=%d "
"(want %zu %d 1)\n",
before, o->nodes.size(), op_added, q_row, before + 1,
math_op_index(MathOp::BandPass));
// And the identity the whole picker rests on: a round trip through both accessors.
int round = 1;
for (int i = 0; i < math_op_count(); ++i) {
if (math_op_index(math_op_at(i)) != i) round = 0;
}
std::fprintf(stderr, "[math-probe] op pick: index round trip=%d (want 1)\n", round);
// And placement: the first *free* grid cell, not cell `nodes.size()`. The boxes are put back
// on the grid, the middle one deleted and one added -- under the old placement the new box
// landed on the cell the deleted node's *successor* had moved out of the count for, i.e. on an
// occupied one, and `math_node_at()` searches topmost-first, so the covered box becomes
// unclickable. `cells=` is where the boxes ended up; `overlaps` is the assertion.
if (o->nodes.size() >= 3) {
for (std::size_t i = 0; i < o->nodes.size(); ++i) {
o->nodes[i].x = 24.0 + 160.0 * static_cast<double>(i % 2u);
o->nodes[i].y = 20.0 + 58.0 * static_cast<double>(i / 2u);
}
o->selected = 1;
on_math_node_deleted(nullptr, o);
math_add_node(o, MathOp::Gain);
int overlaps = 0;
std::string cells;
for (std::size_t i = 0; i < o->nodes.size(); ++i) {
const MathNode& p2 = o->nodes[i];
cells += (i != 0 ? " " : "");
cells += "(" + std::to_string(static_cast<int>(p2.x)) + "," +
std::to_string(static_cast<int>(p2.y)) + ")";
for (std::size_t j = i + 1; j < o->nodes.size(); ++j) {
const MathNode& q2 = o->nodes[j];
if (p2.x < q2.x + kNodeW && q2.x < p2.x + kNodeW && p2.y < q2.y + kNodeH &&
q2.y < p2.y + kNodeH) {
++overlaps;
}
}
}
std::fprintf(stderr,
"[math-probe] add node: after a delete cells=[%s] overlaps=%d (want 0)\n",
cells.c_str(), overlaps);
}
}
}
if (a->math_dialog != nullptr) {
auto* ty = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
if (ty != nullptr && ty->nodes.size() >= 2) {
// Pile every node on one spot, so a layout that did nothing would be visible.
for (MathNode& n : ty->nodes) {
n.x = 10.0;
n.y = 10.0;
}
math_tidy(ty);
// No two boxes may overlap -- `math_node_at()` searches topmost-first, so an overlap means
// a click or a drag lands on the wrong node, which reads as the dialog ignoring the pointer.
int overlaps = 0;
std::string xs;
for (std::size_t i = 0; i < ty->nodes.size(); ++i) {
xs += (i != 0 ? "," : "") + std::to_string(static_cast<int>(ty->nodes[i].x));
for (std::size_t j = i + 1; j < ty->nodes.size(); ++j) {
const MathNode& p = ty->nodes[i];
const MathNode& q = ty->nodes[j];
if (p.x < q.x + kNodeW && q.x < p.x + kNodeW && p.y < q.y + kNodeH &&
q.y < p.y + kNodeH) {
++overlaps;
}
}
}
std::fprintf(stderr, "[math-probe] tidy: xs=[%s] overlaps=%d (want 0)\n", xs.c_str(),
overlaps);
}
}
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
// The one state `math_probe` structurally cannot reach: an edit of a graph whose ring has already
// *wrapped*. Every edit that probe makes follows a structural change, which replans the buffer and
// restarts every ring at absolute 0 -- and at `base == 0` the recompute's two renumbering bugs are
// both identities. Run it with a small `--buffer-mb` and give it time to saturate; `base=0` in its
// second line means the run was too short or the buffer too large to have read anything.
int g_mathwrap_index = -1;
double g_mathwrap_y_before = 0.0;
int g_mathwrap_polls = 0;
gboolean mathwrap_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || g_mathwrap_index < 0) return G_SOURCE_REMOVE;
u64 produced = 0;
u64 slots = 0;
plot::Range y{0.0, 0.0};
{
std::lock_guard<std::mutex> lock(a->model->mutex());
const std::size_t idx = static_cast<std::size_t>(g_mathwrap_index);
if (idx >= a->model->channel_count()) return G_SOURCE_REMOVE;
produced = a->model->live().values(idx).produced();
slots = a->model->live().time().produced();
y = a->model->live().channel(idx).y_bounds();
}
// Two readings, one per half of the recompute. `produced` against `slots` is the ring's absolute
// count: short by `base` is a channel the live window clamps everything else down to, which is the
// reported freeze. `y` is the *pyramid's*, so it reads the published window rather than the samples,
// and the edit **shrinks** the gain (2 -> 0.5) for that reading to exist at all: a pyramid that was
// not republished still describes the replaced +-2 samples, and a window holding both would quote
// the larger -- so a growing gain reads correct either way.
std::fprintf(stderr,
"[mathwrap-probe] after edit: produced=%llu slots=%llu (want equal) y=[%.4f,%.4f] "
"(want +-0.5, was +-%.4f)\n",
static_cast<unsigned long long>(produced), static_cast<unsigned long long>(slots),
y.min, y.max, g_mathwrap_y_before);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean mathwrap_probe_wait(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->math.empty()) return G_SOURCE_REMOVE;
u64 base = 0;
u64 slots = 0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
base = a->model->live().time().base();
slots = a->model->live().time().produced();
const std::size_t idx = static_cast<std::size_t>(g_mathwrap_index);
if (g_mathwrap_index >= 0 && idx < a->model->channel_count()) {
g_mathwrap_y_before = a->model->live().channel(idx).y_bounds().max;
}
}
// Polled rather than timed: the wrap is a function of the rate and of `--buffer-mb`, and a fixed
// delay that fell short would print a passing line having tested nothing.
if (base == 0 && ++g_mathwrap_polls < 200) return G_SOURCE_CONTINUE;
std::fprintf(stderr, "[mathwrap-probe] wrapped: base=%llu slots=%llu (want base > 0)\n",
static_cast<unsigned long long>(base), static_cast<unsigned long long>(slots));
std::fflush(stderr);
// The edit itself: the *same* channel list with one node's gain changed, which is the in-place
// path -- `set_math_channels()` replans only when the names or the enables moved.
for (MathNode& n : a->math.front().nodes) {
if (n.op == MathOp::Gain) n.gain = 0.5;
}
apply_math_channels(*a);
// After a tick, because the pyramid half of the reading is published by one.
g_timeout_add(300, mathwrap_probe_report, a);
return G_SOURCE_REMOVE;
}
gboolean mathwrap_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// Its own signal and its own graph: a probe must not depend on another's side effects, and this one
// needs a source whose extremes are known (a unit sine doubled reads +-2) for the pyramid reading.
{
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == "sine") a->model->set_signal_enabled(i, true);
}
}
std::string name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
if (a->model->channels().empty()) return G_SOURCE_REMOVE;
name = a->model->channels().front().name;
}
MathChannel gain;
gain.name = "math wrap";
gain.enabled = true;
// Field by field: a partial brace-initialiser would add a fourth build warning, and the baseline
// is exactly three.
MathNode src;
src.op = MathOp::Source;
src.source = name;
MathNode op;
op.op = MathOp::Gain;
op.in_a = 0;
op.gain = 2.0;
gain.nodes = {src, op};
gain.output = 1;
a->math.assign(1, gain);
apply_math_channels(*a);
{
std::lock_guard<std::mutex> lock(a->model->mutex());
for (std::size_t c = 0; c < a->model->channels().size(); ++c) {
if (a->model->channels()[c].name == gain.name) g_mathwrap_index = static_cast<int>(c);
}
}
std::fprintf(stderr, "[mathwrap-probe] installed 'math wrap' on '%s': channel=%d\n", name.c_str(),
g_mathwrap_index);
std::fflush(stderr);
if (g_mathwrap_index < 0) return G_SOURCE_REMOVE;
g_timeout_add(100, mathwrap_probe_wait, a);
return G_SOURCE_REMOVE;
}
gboolean measure_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// The signal this probe measures is enabled *here*, for the same reason the noise probe enables its own:
// a probe must not depend on another probe's side effects. `enable_all_probe` ends by switching a signal
// off (it reads what a disabled signal does to the plots), and with `sine` gone the readings below were
// taken on `square` -- where `top` is its plateau's 1.0 rather than the sine's flat level, and the numbers
// the docs quote are the sine's.
{
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == "sine") a->model->set_signal_enabled(i, true);
}
}
std::string name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
// A *channel*, not `signals().front()`: the first signal of a stream is its timestamp, which
// is not acquired and has no samples to measure. That mistake made the probe add four
// measurements that all read `--` -- and, because a measurement with no channel bails before
// collecting anything, it also made the first baseline look free.
if (a->model->channels().empty()) return G_SOURCE_REMOVE;
name = a->model->channels().front().name;
}
for (const MeasureType t : {MeasureType::Average, MeasureType::Top, MeasureType::PeakToPeak,
MeasureType::Amplitude}) {
MeasureOptions options;
// The marks are what a wrong window shows up in, so the probe asks for them.
options.show_on_plot = measure_can_show_on_plot(t);
a->measures.push_back({name, t, options});
}
rebuild_measures_pane(*a);
update_measures(*a, /*force=*/true, /*accumulate=*/true);
std::fprintf(stderr, "[measure-probe] %zu measurements on '%s'\n", a->measures.size(),
name.c_str());
std::fflush(stderr);
g_timeout_add(250, measure_trace, a);
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_LEVELS_MS`: puts a pane of **N** measurements on screen -- one `above` per
/// share in `UDPSCOPE_PROBE_LEVELS` (comma separated, default `50`) -- spread in turn over the
/// first `UDPSCOPE_PROBE_LEVELS_CHANNELS` channels (default 1), and says how many *windows* and
/// *levels* the pass then had to walk.
///
/// The pane's cost has three factors and a count alone names none of them: the window's samples
/// (one fetch and one scan per **measured channel**, whatever the pane holds), the number of
/// *distinct levels* in it (measurements that share a share share the walk), and the rows that
/// are only labels over the two. `50,50,50,50` is four rows and one walk; `5,10,15,20` is four
/// rows and four; and the same four shares on four signals is four windows *and* four walks.
/// This probe is the only way to vary the three apart, which is what a report of "it is slow
/// with many measurements" needs before it is a number.
gboolean levels_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::vector<std::string> names;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
for (const ChannelInfo& c : a->model->channels()) names.push_back(c.name);
}
if (names.empty()) return G_SOURCE_REMOVE;
const char* chan_env = g_getenv("UDPSCOPE_PROBE_LEVELS_CHANNELS");
const long want_channels = chan_env != nullptr ? std::atol(chan_env) : 1;
const std::size_t channels =
want_channels > 0 ? std::min<std::size_t>(static_cast<std::size_t>(want_channels),
names.size())
: 1u;
const char* list = g_getenv("UDPSCOPE_PROBE_LEVELS");
const std::string shares = list != nullptr && list[0] != '\0' ? list : "50";
std::size_t rows = 0;
for (std::size_t at = 0; at <= shares.size();) {
const std::size_t comma = shares.find(',', at);
const std::string field =
shares.substr(at, comma == std::string::npos ? std::string::npos : comma - at);
at = comma == std::string::npos ? shares.size() + 1u : comma + 1u;
char* end = nullptr;
const double pct = std::strtod(field.c_str(), &end);
if (end == field.c_str() || !(pct >= 0.0)) continue;
Measure m;
m.signal = names[rows % channels];
m.type = MeasureType::Above;
m.options.threshold_pct = pct;
a->measures.push_back(std::move(m));
++rows;
}
rebuild_measures_pane(*a);
update_measures(*a, /*force=*/true, /*accumulate=*/true);
// What that pass walked: the level accumulators the pane deduplicated to, and the windows they
// hang off. Read without the model lock -- `measure_samples` is the *tick*'s own state, not the
// producer's.
std::size_t levels = 0;
std::size_t windows = 0;
for (const App::MeasureWindow& w : a->measure_samples) {
++windows;
levels += w.pct.size();
}
std::fprintf(stderr,
"[levels-probe] %zu measurement(s) over %zu channel(s), shares='%s' -> %zu "
"window(s), %zu level walk(s)\n",
rows, channels, shares.c_str(), windows, levels);
std::fflush(stderr);
g_timeout_add(250, measure_trace, a);
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_MEASUREPIN_MS`: on a **still frame** the pane describes the frame and not the
/// viewport, so a pan or a zoom changes no number -- and on a live view the numbers still follow
/// their window.
///
/// Three legs, each one reading the pane's own results before and after a **gesture**: the range
/// is written on the row's own axis, which is what a drag, a wheel or a box zoom writes and what
/// `Figure::layout()` adopts. Nothing here takes the window-control path, which *unpins* on the
/// way and would be testing something else.
///
/// * `held`: arms the trigger in single mode, polls for a latched event, reads, zooms to a
/// quarter of the event, reads again. **`same=4/4`, `for_same=1` and `pass_delta=0` are the
/// assertion**: the frame's samples did not change, so no number may, and the pane is not
/// even allowed to spend a recompute on the gesture.
/// * `paused`: presses the pause button and does the same, with `for=` at the *snapshot's* own
/// extent.
/// * `live`: resumes and zooms to a sixteenth of the window. The results must move with it --
/// `follows=1`, `for=`'s span narrowed to the axis' -- and the values move with them, which
/// is the case the still-frame rule must not swallow.
struct MeasurePinProbe {
App* app = nullptr;
/// Which leg is running: 0 held, 1 paused, 2 live. It only names the `before` line, since each
/// step is scheduled from the last.
int stage = 0;
std::vector<double> before;
std::vector<bool> before_valid;
plot::Range for_before{};
double span_before = 0.0;
/// `App::measure_passes` is an `int`, and the delta below is a difference of two of them.
int passes_before = 0;
/// The settle guard's own state: the pass count the last poll saw, and how many polls in a row
/// have been quiet.
int passes_seen = -1;
int settled = 0;
int polls = 0;
/// The last state the wait line was printed for: mode, pinned and at-pin in one number.
int last_state = -1;
};
/// Whether two windows are the same window **by the app's own criterion** -- the 2 %-of-span
/// rule `update_measures()` uses to decide that a recompute is not owed. Equality is not that
/// criterion and would never hold: the pin is recomputed from the snapshot's stamps every tick
/// while the pane keeps the window its last pass used, and the two differ in the last decimals
/// without a recompute being owed for it.
bool measurepin_same_window(const plot::Range& a, const plot::Range& b) {
const double span = std::max(1.0e-12, std::max(a.span(), b.span()));
return std::fabs(a.min - b.min) + std::fabs(a.max - b.max) <= 0.02 * span;
}
/// Whether the pane's results describe the pin.
bool measurepin_at_pin(App& a) {
return a.measure_pin_valid && measurepin_same_window(a.measure_window, a.measure_pin_window);
}
/// The pane's values, its window and its pass count, as the pane itself holds them.
void measurepin_read(App& a, std::vector<double>& values, std::vector<bool>& valid,
plot::Range& window, int& passes) {
values.clear();
valid.clear();
for (const MeasureResult& r : a.measure_results) {
values.push_back(r.primary);
valid.push_back(r.valid);
}
window = a.measure_window;
passes = a.measure_passes;
}
/// One leg's line: the state, the window, every value the pane is showing, and -- the frame the
/// measurement is *about* -- the bound event series' own sample count and extent. A pinned view
/// with no event bound, or one bound to an empty snapshot, is the defect that reads as `--` in
/// every column, and nothing else in the line would say which of the two windows it was.
void measurepin_line(App& a, const char* leg, const std::vector<double>& values,
const std::vector<bool>& valid) {
const RowSet::Row& r0 = a.rows->row(0);
const plot::Range view =
r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{0.0, 0.0};
// The pin only means something while it is what frames the plot: a *stale* pin (one that a
// previous mode left behind) printed as a window would read as the answer.
const bool pinned = a.rows->window_pinned();
const plot::Range pin = a.rows->pinned_range();
const EventSeries* ev = r0.slots[0].bound_event();
const plot::Range ev_x = ev != nullptr ? ev->x_bounds() : plot::Range{0.0, 0.0};
std::fprintf(stderr,
"[measurepin-probe] %-13s pinned=%d mode=%-7s view=[%.6f..%.6f] pin=[%s] "
"for=[%.6f..%.6f] passes=%d ev=%zu evx=[%.6f..%.6f]",
leg, pinned ? 1 : 0, display_mode_name(a.model->display_mode()),
static_cast<double>(view.min), static_cast<double>(view.max),
pinned ? (std::to_string(pin.min) + ".." + std::to_string(pin.max)).c_str() : "off",
a.measure_window.min, a.measure_window.max, a.measure_passes,
ev != nullptr ? ev->size() : 0, ev_x.min, ev_x.max);
for (std::size_t i = 0; i < a.measures.size() && i < values.size(); ++i) {
std::fprintf(stderr, " %s=", measure_type_name(a.measures[i].type));
if (valid[i]) {
std::fprintf(stderr, "%.6f", values[i]);
} else {
std::fprintf(stderr, "--");
}
}
}
/// A gesture zoom: `keep` of the span on screen now, about its centre, written where the pointer
/// puts it. The next frame's `hold_pinned_window()` adopts it -- a zoom *in* is allowed, which is
/// what makes this the case that used to move the numbers.
void measurepin_zoom(App& a, double keep) {
RowSet::Row& r0 = a.rows->row(0);
if (r0.plot == nullptr) return;
const plot::Range now = r0.plot->x_range(r0.axis_x);
const double centre = 0.5 * (now.min + now.max);
const double span = now.span() * keep;
r0.plot->set_x_range(r0.axis_x, plot::Range{centre - 0.5 * span, centre + 0.5 * span});
}
/// Reads the leg's second half and compares it with the first. `same` counts the values that are
/// bit-identical, which is what "the measurement did not change" means for a statistic over the
/// same samples; `for_same` says the window is the same, and `pass_delta` that no recompute was
/// spent.
void measurepin_compare(MeasurePinProbe& p, const char* leg) {
App& a = *p.app;
std::vector<double> after;
std::vector<bool> after_valid;
plot::Range window{};
int passes = 0;
measurepin_read(a, after, after_valid, window, passes);
int same = 0;
for (std::size_t i = 0; i < after.size() && i < p.before.size(); ++i) {
if (after_valid[i] && p.before_valid[i] && after[i] == p.before[i]) ++same;
}
measurepin_line(a, leg, after, after_valid);
const bool for_same = measurepin_same_window(window, p.for_before);
const double swin_span = window.span();
std::fprintf(stderr,
" same=%d/%zu for_same=%d pass_delta=%d"
" span_before=%.6f span_after=%.6f follows=%d\n",
same, after.size(), for_same ? 1 : 0, passes - p.passes_before,
p.span_before, swin_span,
// Live, the results *are* the viewport: the pass that follows a gesture must be
// over the narrowed window, which is where "the numbers follow the window" is a
// reading rather than a claim. Pinned, `for` must not have moved at all.
(for_same == false && swin_span > 0.0 && swin_span <= 0.75 * p.span_before) ? 1 : 0);
std::fflush(stderr);
}
/// Starts a leg: what the pane says now, and the gesture that must not move it.
void measurepin_begin(MeasurePinProbe& p, double keep) {
App& a = *p.app;
p.before_valid.clear();
measurepin_read(a, p.before, p.before_valid, p.for_before, p.passes_before);
p.span_before = p.for_before.span();
measurepin_line(a, p.stage == 0 ? "held/before" : (p.stage == 1 ? "paused/before" : "live/before"),
p.before, p.before_valid);
std::fprintf(stderr, "\n");
std::fflush(stderr);
measurepin_zoom(a, keep);
}
/// The wait budget every leg shares: every step is scheduled from the last, so a step that is
/// never reached has to *say* so -- a probe that quietly waits until the window closes reads as a
/// passing run with no lines, which is the worst of both.
bool measurepin_polls(MeasurePinProbe& p, const char* what, App& a) {
if (++p.polls <= 600) return true;
std::fprintf(stderr, "[measurepin-probe] gave up waiting for %s after %d polls (mode=%s pinned=%d)\n",
what, p.polls, display_mode_name(a.model->display_mode()),
a.rows->window_pinned() ? 1 : 0);
std::fflush(stderr);
return false;
}
/// The pause leg: presses the button, waits for the frozen frame to settle, then reads and zooms
/// it like the held leg.
gboolean measurepin_pause_wait(gpointer user);
/// The live leg: resumes, waits for the follow to re-frame, then reads and zooms.
gboolean measurepin_live_wait(gpointer user);
/// Whether a **pinned** pane has settled on the pin: the results describe it, and no pass has
/// landed since the last poll. Both halves are needed, and the second is what makes the reading
/// below about the gesture: a pass landing between the two reads moves a value for a reason that
/// is not the zoom.
bool measurepin_pinned_settled(MeasurePinProbe& p) {
App& a = *p.app;
if (!measurepin_at_pin(a) || a.measure_passes != p.passes_seen) {
p.passes_seen = a.measure_passes;
p.settled = 0;
return false;
}
// Three polls of quiet: one is the pass, the next is the frame it landed in being re-clamped.
return ++p.settled >= 3;
}
gboolean measurepin_pause_wait(gpointer user) {
MeasurePinProbe& p = *static_cast<MeasurePinProbe*>(user);
App& a = *p.app;
if (a.window == nullptr || !measurepin_polls(p, "the pause to settle", a)) {
delete &p;
return G_SOURCE_REMOVE;
}
if (a.model->display_mode() != DisplayMode::Paused ||
!measurepin_pinned_settled(p)) {
return G_SOURCE_CONTINUE;
}
p.stage = 1;
measurepin_begin(p, 0.25);
g_timeout_add(400, +[](gpointer user) {
MeasurePinProbe& probe = *static_cast<MeasurePinProbe*>(user);
measurepin_compare(probe, "paused/after");
// The live leg: the display has to *leave* the event, and a resumed view does not -- a
// `Held` model holds its snapshot with the trigger on, so resuming only un-pins it. Switching
// the trigger off is the user's own way back to the stream, and it is what makes the last leg
// a live view rather than the same event under another name.
App& a = *probe.app;
if (a.pause_button != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a.pause_button), FALSE);
}
if (a.trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a.trigger_toggle), FALSE);
}
probe.settled = 0;
probe.polls = 0;
g_timeout_add(50, measurepin_live_wait, &probe);
return G_SOURCE_REMOVE;
}, &p);
return G_SOURCE_REMOVE;
}
gboolean measurepin_live_wait(gpointer user) {
MeasurePinProbe& p = *static_cast<MeasurePinProbe*>(user);
App& a = *p.app;
if (a.window == nullptr || !measurepin_polls(p, "the stream to come back", a)) {
delete &p;
return G_SOURCE_REMOVE;
}
// A resumed view is live again, and there "settled" is not a state: the window scrolls and a
// pass lands every `kMeasureRefreshUs`. What is waited for here is the *resume* itself -- the
// follow re-armed and the pin gone -- and then a few frames for the results to be over the new
// window rather than the frozen one.
const bool live = a.model->display_mode() == DisplayMode::Live && !a.rows->window_pinned();
if (!live || ++p.settled < 8) return G_SOURCE_CONTINUE;
p.stage = 2;
measurepin_begin(p, 0.0625);
g_timeout_add(400, +[](gpointer user) {
MeasurePinProbe& probe = *static_cast<MeasurePinProbe*>(user);
measurepin_compare(probe, "live/after");
delete &probe;
return G_SOURCE_REMOVE;
}, &p);
return G_SOURCE_REMOVE;
}
/// Polls for the held event the first leg needs: a latch happens when the stream says so, and a
/// sweep on a timer has nothing to zoom into. Then for the *settled* pin, which is the state the
/// gesture is read against.
gboolean measurepin_held_wait(gpointer user) {
MeasurePinProbe& p = *static_cast<MeasurePinProbe*>(user);
App& a = *p.app;
if (a.window == nullptr) {
delete &p;
return G_SOURCE_REMOVE;
}
if (!measurepin_polls(p, "a latched event to settle on its pin", a)) {
// A wait that never ends is a *reading* about the state, not a hung probe: say what it was
// waiting for rather than printing nothing at all.
delete &p;
return G_SOURCE_REMOVE;
}
// One line per *change* of the state being waited on, which is a handful in a normal run and the
// whole reading in a stuck one: `mode=held at_pin=0` for ever is a pane whose results never land
// on the pin, and it is invisible in the leg lines because there are none.
const int state = static_cast<int>(a.model->display_mode()) * 4 +
(a.rows->window_pinned() ? 2 : 0) + (measurepin_at_pin(a) ? 1 : 0);
if (state != p.last_state) {
p.last_state = state;
std::fprintf(stderr,
"[measurepin-probe] wait mode=%s pinned=%d at_pin=%d for=[%.6f..%.6f] "
"pin=[%.6f..%.6f] passes=%d\n",
display_mode_name(a.model->display_mode()), a.rows->window_pinned() ? 1 : 0,
measurepin_at_pin(a) ? 1 : 0, a.measure_window.min, a.measure_window.max,
a.measure_pin_window.min, a.measure_pin_window.max, a.measure_passes);
std::fflush(stderr);
}
if (a.model->display_mode() != DisplayMode::Held || !measurepin_pinned_settled(p)) {
if (a.model->display_mode() != DisplayMode::Held) p.settled = 0;
return G_SOURCE_CONTINUE;
}
p.stage = 0;
measurepin_begin(p, 0.25);
g_timeout_add(400, +[](gpointer user) {
MeasurePinProbe& probe = *static_cast<MeasurePinProbe*>(user);
measurepin_compare(probe, "held/after");
App& a = *probe.app;
if (a.pause_button != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a.pause_button), TRUE);
}
probe.settled = 0;
probe.polls = 0;
g_timeout_add(50, measurepin_pause_wait, &probe);
return G_SOURCE_REMOVE;
}, &p);
return G_SOURCE_REMOVE;
}
gboolean measurepin_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// The same four types `measure_probe` adds, on a *channel* -- the first signal of a stream is
// its clock, which is not acquired, so naming it would measure nothing at all and every
// comparison below would be a comparison of two `--`.
{
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == "sine") a->model->set_signal_enabled(i, true);
}
}
std::string name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
if (a->model->channels().empty()) return G_SOURCE_REMOVE;
name = a->model->channels().front().name;
}
for (const MeasureType t : {MeasureType::Average, MeasureType::Top, MeasureType::PeakToPeak,
MeasureType::Amplitude}) {
MeasureOptions options;
options.show_on_plot = measure_can_show_on_plot(t);
a->measures.push_back({name, t, options});
}
rebuild_measures_pane(*a);
// Single trigger mode, so one event stays on screen for the whole gesture.
if (a->trigger_mode_drop != nullptr) {
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_mode_drop), 1);
}
if (a->trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
}
std::fprintf(stderr, "[measurepin-probe] %zu measurements on '%s', trigger single\n",
a->measures.size(), name.c_str());
std::fflush(stderr);
auto* probe = new MeasurePinProbe;
probe->app = a;
g_timeout_add(50, measurepin_held_wait, probe);
return G_SOURCE_REMOVE;
}
gboolean stats_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::fprintf(stderr, "[stats-probe] phase=%d on=%d passes=%" G_GUINT64_FORMAT,
g_stats_phase, int(a->measure_stats_on),
static_cast<std::uint64_t>(a->measure_passes));
for (std::size_t i = 0; i < a->measure_stats.size(); ++i) {
const MeasureStatsAccum& s = a->measure_stats[i];
std::fprintf(stderr, " | n=%" G_GUINT64_FORMAT " mean=%.6g sd=%.6g lo=%.6g hi=%.6g", s.n,
s.mean, s.sd(), s.lo, s.hi);
if (g_stats_phase == 1 && i < g_stats_n_before.size()) {
std::fprintf(stderr, " dn=%" G_GUINT64_FORMAT, s.n - g_stats_n_before[i]);
}
}
std::fprintf(stderr, "\n");
std::fflush(stderr);
if (g_stats_phase == 0) {
// Hold a pause and come back: `dn` on the next line is the assertion.
g_stats_n_before.clear();
for (const MeasureStatsAccum& s : a->measure_stats) g_stats_n_before.push_back(s.n);
g_stats_phase = 1;
if (a->pause_button != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->pause_button), TRUE);
}
g_timeout_add(2000, stats_probe_report, a);
return G_SOURCE_REMOVE;
}
if (g_stats_phase == 1) {
// Resume -- a resume must not reset, so phase 2's `n` starts from phase 1's -- then reset
// explicitly, which must.
if (a->pause_button != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->pause_button), FALSE);
}
g_stats_phase = 2;
reset_measure_stats(*a);
update_measures(*a, /*force=*/true, /*accumulate=*/true);
g_timeout_add(50, stats_probe_report, a);
}
return G_SOURCE_REMOVE;
}
gboolean stats_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->measures_stats_check != nullptr) {
gtk_check_button_set_active(GTK_CHECK_BUTTON(a->measures_stats_check), TRUE);
}
g_stats_phase = 0;
// Long enough for several throttled recomputes (one per 100 ms while the window moves).
g_timeout_add(2000, stats_probe_report, a);
return G_SOURCE_REMOVE;
}
/// Phase two of the tab-switch probe: the hidden page's accumulator must have advanced. A statistics
/// readout is *history*, so min/max/avg would gain a gap for exactly as long as the trigger was on
/// screen -- which is the one defect the design's "what runs on a hidden page" rule is there to
/// prevent, and the reason no frame-rate code may be gated on which tab is current.
gboolean side_tab_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::uint64_t advanced = 0;
std::uint64_t min_n = 0;
bool first = true;
for (std::size_t i = 0; i < a->measure_stats.size(); ++i) {
const std::uint64_t n = a->measure_stats[i].n;
if (i < g_side_tab_n_before.size() && n > g_side_tab_n_before[i]) ++advanced;
if (first || n < min_n) {
min_n = n;
first = false;
}
}
std::fprintf(stderr,
"[side-tab-probe] page=%s rows=%zu accums=%zu advanced=%" G_GUINT64_FORMAT
" min_n=%" G_GUINT64_FORMAT "\n",
a->side_tab.c_str(), a->measures.size(), a->measure_stats.size(), advanced, min_n);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// Phase one: four measurements on the first channel, statistics on, then switch *away* from the
/// Measure tab and come back in two seconds to read what ran while it was not on screen.
gboolean side_tab_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// One signal on, and on a plot, the way `enable_all_probe` does it and a restored session arrives.
// Both halves matter here: a signal starts disabled (enabling is what starts acquisition), and a
// channel with no trace leaves the live window *still* -- and a still window is exactly the state
// the tick is supposed to skip, so a probe that left it that way would be testing nothing. This
// probe is about the case where the window does move while the page is somewhere else.
std::size_t pick = a->model->signals().size();
{
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not acquired
pick = i;
break;
}
}
if (pick >= a->model->signals().size()) return G_SOURCE_REMOVE;
a->model->set_signals_enabled({pick}, true);
std::string name;
{
const std::vector<ChannelInfo>& channels = a->model->channels();
if (channels.empty()) return G_SOURCE_REMOVE;
name = channels.front().name;
a->rows->assign(0, 0, channels.front().name, channels.front().name,
signal_color_index(a->model->signals(), channels.front().signal));
}
a->relayout_pending = true;
if (a->measures.empty()) {
for (const MeasureType t : {MeasureType::Average, MeasureType::Top, MeasureType::PeakToPeak,
MeasureType::Amplitude}) {
MeasureOptions options;
options.show_on_plot = measure_can_show_on_plot(t);
a->measures.push_back({name, t, options});
}
rebuild_measures_pane(*a);
}
if (a->measures_stats_check != nullptr) {
gtk_check_button_set_active(GTK_CHECK_BUTTON(a->measures_stats_check), TRUE);
}
select_side_tab(*a, "measure");
update_measures(*a, /*force=*/true, /*accumulate=*/true);
g_side_tab_n_before.clear();
for (const MeasureStatsAccum& s : a->measure_stats) g_side_tab_n_before.push_back(s.n);
// Away from the page the measurements are on: its widgets are now unrealized and measure 0x0, and
// the tick keeps calling `update_measures()` regardless.
select_side_tab(*a, "signals");
std::fprintf(stderr, "[side-tab-probe] page=%s accums=%zu baseline recorded\n",
a->side_tab.c_str(), g_side_tab_n_before.size());
std::fflush(stderr);
// Long enough for several throttled recomputes (one per 100 ms while the window moves).
g_timeout_add(2000, side_tab_probe_report, a);
return G_SOURCE_REMOVE;
}
namespace {
std::uint64_t g_source_kinds_before = 0;
}
/// Phase two: the command the Poke button posted must have been run by `poll()` on the producer.
gboolean source_kinds_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const ProbeSource* src = a->acq != nullptr ? dynamic_cast<const ProbeSource*>(a->acq->source())
: nullptr;
const std::uint64_t after = src != nullptr ? src->commands_applied() : 0;
std::fprintf(stderr, "[source-kinds] command: before=%" G_GUINT64_FORMAT " after=%" G_GUINT64_FORMAT
" drained=%d\n",
g_source_kinds_before, after, after == g_source_kinds_before + 1 ? 1 : 0);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// What the registry offers and what a source kind brings: the kinds, each config form's
/// round trip, the panel page (insensitive while disconnected, still present), and a posted command.
gboolean source_kinds_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::string ids;
for (const SourceKind* k : kinds()) ids += (ids.empty() ? "" : ",") + k->id();
std::fprintf(stderr, "[source-kinds] kinds=%s\n", ids.c_str());
// Each kind's form must hand back what it was given.
const auto sample = [](const std::string& id) {
SourceConfig c;
if (id == "udps") {
c.set("server", "10.0.0.5");
c.set("port", "9001");
c.set("multicast", "239.1.2.3");
c.set("interface", "10.0.0.9");
} else if (id == "probe") {
c.set("rate", "750");
} else if (id == "e1608") {
c.set("address", "10.0.0.5");
c.set("port", "54300");
c.set("scan_rate", "2000");
c.set("mode", "diff");
c.set("ai1", "BIP5V");
c.set("trigger", "rising");
c.set("dir1", "o");
c.set("plot0", "1");
} else if (id == "flatudp") {
c.set("port", "44600");
c.set("bind", "10.0.0.8");
c.set("multicast", "239.1.2.3");
c.set("iface", "10.0.0.9");
c.set("rate", "2500");
c.set("endian", "big");
c.set("signals", "Trigger:uint8,Ia:float32,Wave:int16[4]");
}
return c;
};
for (const SourceKind* k : kinds()) {
ConfigForm f = k->make_config_form();
const SourceConfig in = sample(k->id());
f.write(in);
const SourceConfig out = f.read();
bool same = !in.empty();
for (const auto& kv : in.items()) same = same && out.get(kv.first) == kv.second;
std::fprintf(stderr, "[source-kinds] form %s: round_trip=%d valid=%d\n", k->id().c_str(),
same ? 1 : 0, f.valid() ? 1 : 0);
// The rows were never parented; sink and release them so the form leaves nothing behind.
for (GtkWidget* row : f.rows) {
g_object_ref_sink(row);
g_object_unref(row);
}
}
// The panel page: built, insensitive while disconnected, present either way.
const SourcePanelPage* page = a->source_panels.empty() ? nullptr : &a->source_panels.front();
const auto report = [&](const char* state) {
const bool present =
page != nullptr && a->side_stack != nullptr &&
adw_view_stack_get_child_by_name(ADW_VIEW_STACK(a->side_stack), page->name.c_str()) !=
nullptr;
std::fprintf(stderr, "[source-kinds] panel %s: panels=%zu present=%d sensitive=%d\n", state,
a->source_panels.size(), present ? 1 : 0,
page != nullptr && gtk_widget_get_sensitive(page->content) ? 1 : 0);
};
apply_source_panels(*a, false);
report("disconnected");
apply_source_panels(*a, true);
report("connected");
// The probe kind has no datagrams, so the packet-capture button stays disabled, and is still there.
std::fprintf(stderr, "[source-kinds] capture button: present=%d sensitive=%d\n",
a->capture_button != nullptr ? 1 : 0,
a->capture_button != nullptr && gtk_widget_get_sensitive(a->capture_button) ? 1 : 0);
// Click the panel's button: it posts a command, and a later `poll()` runs it.
ProbeSource* src = a->acq != nullptr ? dynamic_cast<ProbeSource*>(a->acq->source()) : nullptr;
g_source_kinds_before = src != nullptr ? src->commands_applied() : 0;
if (page != nullptr && src != nullptr) {
if (GtkWidget* poke = gtk_widget_get_last_child(page->content)) {
g_signal_emit_by_name(poke, "clicked");
}
}
std::fflush(stderr);
g_timeout_add(500, source_kinds_probe_report, a);
return G_SOURCE_REMOVE;
}
namespace {
/// The first widget named `name` at or under `root` (the panels name their controls).
GtkWidget* find_named(GtkWidget* root, const char* name) {
if (root == nullptr) return nullptr;
if (std::strcmp(gtk_widget_get_name(root), name) == 0) return root;
for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
if (GtkWidget* f = find_named(c, name)) return f;
}
return nullptr;
}
GtkWidget* source_panel_content(App* a, const char* page) {
for (const SourcePanelPage& p : a->source_panels) {
if (p.name == page) return p.content;
}
return nullptr;
}
e1608::E1608Source* e1608_source(App* a) {
return a->acq != nullptr ? dynamic_cast<e1608::E1608Source*>(a->acq->source()) : nullptr;
}
std::uint64_t g_e1608_frames_before = 0;
/// A fixed raw buffer decoded against volts worked out by hand: single-ended, ch0 on BIP10V with
/// intercept 100 (slope 1), ch1 on BIP5V uncalibrated. `(code - 32768) * fs / 32768`, the code
/// clamped to 0..65535.
bool e1608_decode_check() {
using namespace e1608;
Calibration cal;
cal.se[0] = {1.0, 100.0};
const std::vector<QueueEntry> q = {{0, Range::Bip10V}, {1, Range::Bip5V}};
const std::uint16_t raw[] = {32668, 49152, 49052, 16384, 65535, 0}; // 3 scans x 2 channels
std::vector<std::vector<double>> out;
decode_scans(raw, 3, q, cal, out);
const double want0[] = {0.0, 5.0, 32767.0 * 10.0 / 32768.0};
const double want1[] = {2.5, -2.5, -5.0};
if (out.size() != 2 || out[0].size() != 3 || out[1].size() != 3) return false;
for (int i = 0; i < 3; ++i) {
if (std::fabs(out[0][static_cast<std::size_t>(i)] - want0[i]) > 1e-9) return false;
if (std::fabs(out[1][static_cast<std::size_t>(i)] - want1[i]) > 1e-9) return false;
}
return true;
}
/// The `signals` codec and the datagram decode against values worked out by hand. The row set
/// mixes every width and a vector: A:uint8, B:int16, C:float32, D:uint64[2], E:int8, 24 bytes.
/// A = 254, B = -2, C = 1.5, D = {1, 4294967298}, E = -1.
bool flatudp_codec_check() {
using namespace flatudp;
std::vector<Row> rows;
if (!parse(" A:uint8, B:int16,C : float32 ,D:uint64[2],E:int8", rows).empty()) return false;
const std::vector<Row> want = {{"A", Type::U8, 1}, {"B", Type::I16, 1}, {"C", Type::F32, 1},
{"D", Type::U64, 2}, {"E", Type::I8, 1}};
if (rows != want) return false;
if (format(rows) != "A:uint8,B:int16,C:float32,D:uint64[2],E:int8") return false;
if (datagram_size(rows) != 24 || all_scalar(rows) || !problem(rows).empty()) return false;
// The enum value is the type code the model keeps.
if (static_cast<int>(Type::F64) != 9 || static_cast<int>(Type::U64) != 6) return false;
const std::uint8_t le[24] = {0xFE, // A
0xFE, 0xFF, // B
0x00, 0x00, 0xC0, 0x3F, // C
0x01, 0, 0, 0, 0, 0, 0, 0, // D[0]
0x02, 0, 0, 0, 0x01, 0, 0, 0, // D[1]
0xFF}; // E
const std::uint8_t be[24] = {0xFE,
0xFF, 0xFE,
0x3F, 0xC0, 0x00, 0x00,
0, 0, 0, 0, 0, 0, 0, 0x01,
0, 0, 0, 0x01, 0, 0, 0, 0x02,
0xFF};
const double expect[5][2] = {{254, 0}, {-2, 0}, {1.5, 0}, {1, 4294967298.0}, {-1, 0}};
const std::size_t counts[5] = {1, 1, 1, 2, 1};
for (int pass = 0; pass < 2; ++pass) {
std::vector<std::vector<double>> cols;
decode_datagram(rows, pass == 0 ? le : be, pass == 1, cols);
if (cols.size() != 5) return false;
for (std::size_t s = 0; s < 5; ++s) {
if (cols[s].size() != counts[s]) return false;
for (std::size_t k = 0; k < counts[s]; ++k)
if (cols[s][k] != expect[s][k]) return false;
}
}
// Parse failures name the signal; a bad list never half-parses.
std::vector<Row> bad;
if (parse("A:uint8,B", bad).find("signal 2") == std::string::npos || !bad.empty()) return false;
if (parse("A:uint7", bad).find("uint7") == std::string::npos) return false;
if (parse("A:uint8[x]", bad).empty() || parse("A:uint8[3", bad).empty()) return false;
if (!parse("", bad).empty() || !bad.empty() || problem(bad).empty()) return false;
// The validity rules.
const auto prob = [](std::vector<Row> r) { return problem(r); };
// One call per statement: GCC warns (-Wdangling-pointer) on two list temporaries joined by `||`.
const auto valid = [](std::vector<Row> r) { return problem(r).empty(); };
if (prob({{"A", Type::U8, 1}, {"A", Type::U8, 1}}).find("twice") == std::string::npos) return false;
if (valid({{"", Type::U8, 1}})) return false;
if (valid({{"a,b", Type::U8, 1}})) return false;
if (valid({{"A", Type::U8, 0}})) return false;
if (valid({{"A", Type::U8, 4097}})) return false;
// 4096 is the count limit; as `channels` it would be 4096 channels, so it is a waveform here.
if (!valid({{"A", Type::U8, 4096, true}})) return false;
if (valid({{"A", Type::U8, 4096, false}})) return false;
// 2 x 32768 = 65536 bytes is over the datagram limit; 32768 + 16384 is not (waveforms of one length).
if (prob({{"A", Type::F64, 4096, true}, {"B", Type::F64, 4096, true}}).find("65536") == std::string::npos)
return false;
if (!valid({{"A", Type::F64, 4096, true}, {"B", Type::F32, 4096, true}})) return false;
// The `w` suffix: a waveform row. It round-trips, `format` writes it only for a waveform, and a
// count of 1 is still bracketed when the row is one.
const std::vector<Row> mixed = {{"S", Type::U8, 1, false}, {"V", Type::F32, 3, false},
{"W", Type::I16, 16, true}, {"X", Type::U8, 1, true}};
const std::string text = format(mixed);
if (text != "S:uint8,V:float32[3],W:int16[16]w,X:uint8[1]w") return false;
std::vector<Row> back;
if (!parse(text, back).empty() || back != mixed) return false;
if (!parse("W : int16 [ 4 ] w", back).empty() || back.size() != 1 || !back[0].wave ||
back[0].count != 4 || format(back) != "W:int16[4]w")
return false;
if (parse("W:int16[4]x", back).empty() || !back.empty()) return false;
if (parse("W:int16w", back).empty()) return false;
if (parse("W:int16[4", back).empty()) return false;
if (!any_wave(mixed) || any_wave(want)) return false;
// The waveform rules: count above 1; all rows waveforms, of one length.
if (valid({{"W", Type::I16, 1, true}})) return false;
if (prob({{"W", Type::I16, 1, true}}).find("'W'") == std::string::npos) return false;
if (!valid({{"W", Type::I16, 4, true}})) return false;
if (!valid({{"W", Type::I16, 4, true}, {"X", Type::F32, 4, true}})) return false;
if (valid({{"W", Type::I16, 4, true}, {"X", Type::F32, 8, true}})) return false;
if (valid({{"W", Type::I16, 4, true}, {"S", Type::F32, 1, false}})) return false;
if (valid({{"S", Type::F32, 1, false}, {"W", Type::I16, 4, true}})) return false;
if (valid({{"W", Type::I16, 4, true}, {"V", Type::F32, 4, false}})) return false;
if (prob({{"W", Type::I16, 4, true}, {"S", Type::F32, 1, false}}).find("'S'") == std::string::npos)
return false;
if (prob({{"W", Type::I16, 4, true}, {"X", Type::F32, 8, true}}).find("'X'") == std::string::npos)
return false;
// The channel total: scalar 1, vector `count`, waveform 1; at most kMaxChannels.
if (channel_count({{"S", Type::U8, 1, false}, {"V", Type::U8, 5, false}, {"W", Type::U8, 5, true}}) != 7)
return false;
if (!valid({{"V", Type::U8, 256, false}})) return false;
if (valid({{"V", Type::U8, 257, false}})) return false;
if (valid({{"V", Type::U8, 256, false}, {"S", Type::U8, 1, false}})) return false;
if (prob({{"V", Type::U8, 257, false}}).find("256") == std::string::npos) return false;
// Channel decode: a `channels` vector feeds `count` columns of one value, a waveform one column of
// `count`. U16 = 0x1234, I32 = -2, then V[3] of U8 = 7 8 9, then a waveform of two I16 = 1 -1.
const std::vector<Row> cr = {{"A", Type::U16, 1, false}, {"B", Type::I32, 1, false},
{"V", Type::U8, 3, false}, {"W", Type::I16, 2, true}};
const std::uint8_t cd[] = {0x34, 0x12, 0xFE, 0xFF, 0xFF, 0xFF, 7, 8, 9, 0x01, 0x00, 0xFF, 0xFF};
if (datagram_size(cr) != sizeof cd || channel_count(cr) != 6) return false;
std::vector<std::vector<double>> cc;
decode_channels(cr, cd, false, cc);
const std::vector<std::vector<double>> cwant = {{4660}, {-2}, {7}, {8}, {9}, {1, -1}};
if (cc != cwant) return false;
// The wide and unsigned types, little then big endian: a float64, uint16, int32, uint32 and int64.
// F64 -2.5, U16 65535, I32 -123456, U32 4000000000, I64 -5000000000.
const std::vector<Row> tr = {{"F", Type::F64}, {"U", Type::U16}, {"I", Type::I32},
{"L", Type::U32}, {"Q", Type::I64}};
const std::uint8_t tle[] = {0, 0, 0, 0, 0, 0, 0x04, 0xC0, // -2.5 as 0xC004000000000000
0xFF, 0xFF, // 65535
0xC0, 0x1D, 0xFE, 0xFF, // -123456 = 0xFFFE1DC0
0x00, 0x28, 0x6B, 0xEE, // 4000000000 = 0xEE6B2800
0x00, 0x0E, 0xFA, 0xD5, 0xFE, 0xFF, 0xFF, 0xFF}; // -5e9 = 0xFFFFFFFED5FA0E00
if (datagram_size(tr) != sizeof tle) return false;
std::uint8_t tbe[sizeof tle];
{
std::size_t off = 0;
for (const Row& r : tr) {
const std::size_t w = type_size(r.type);
for (std::size_t b = 0; b < w; ++b) tbe[off + b] = tle[off + w - 1 - b];
off += w;
}
}
const double twant[5] = {-2.5, 65535.0, -123456.0, 4000000000.0, -5000000000.0};
for (int pass = 0; pass < 2; ++pass) {
std::vector<std::vector<double>> cols;
decode_datagram(tr, pass == 0 ? tle : tbe, pass == 1, cols);
if (cols.size() != 5) return false;
for (std::size_t k = 0; k < 5; ++k)
if (cols[k].size() != 1 || cols[k][0] != twant[k]) return false;
}
return true;
}
flatudp::FlatUdpSource* flatudp_source(App* a) {
return a->acq != nullptr ? dynamic_cast<flatudp::FlatUdpSource*>(a->acq->source()) : nullptr;
}
/// The mock's signal k carries (i * (k + 1) + element) % 100 in packet i, so two consecutive
/// samples of a scalar signal differ by k + 1 modulo 100. Returns the number of steps that do not,
/// over a window clear of the head the producer is writing; -1 when it cannot be read.
long flatudp_breaks(App* a, std::size_t channel, long step) {
if (channel >= a->model->channels().size()) return -1;
const ChannelBuffer& ch = a->model->live().channel(channel);
const u64 n = ch.size();
const u64 want = 400;
if (n <= want + 50) return -1;
std::vector<f32> v(static_cast<std::size_t>(want));
ch.raw(n - want - 50, n - 50, v.data());
long breaks = 0;
for (std::size_t i = 1; i < v.size(); ++i) {
const long d = std::lround(double(v[i])) - std::lround(double(v[i - 1]));
if (((d % 100) + 100) % 100 != step) ++breaks;
}
return breaks;
}
/// Every widget named `name` at or under `root`, in tree order (a signal row's widgets repeat).
void find_all_named(GtkWidget* root, const char* name, std::vector<GtkWidget*>& out) {
if (root == nullptr) return;
if (std::strcmp(gtk_widget_get_name(root), name) == 0) out.push_back(root);
for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
find_all_named(c, name, out);
}
}
/// The connect form's validity rules and its signal-row editor, driven through the widgets: the
/// empty form is not connectable; a good list is, and its subtitle names the channel total and the
/// implied size; a duplicate name and an over-size list are not; "Add signal" appends `S1`, `S2`; a
/// row's "Remove" button takes that row out. Then the vector rows: a waveform list is valid and
/// round-trips with its `w`; a waveform beside a scalar (or of another length) is not, and the
/// subtitle says so; the 256-channel cap counts a waveform as one; the "Vector as" drop-down is
/// shown for a count above 1 only, writes `Row::wave`, and returns to Channels when the count does.
void flatudp_form_probe() {
SourceKind* k = find_kind("flatudp");
if (k == nullptr) {
std::fprintf(stderr, "[flatudp] form: no flatudp kind\n");
return;
}
ConfigForm f = k->make_config_form();
const bool initial = f.valid();
const auto with = [&](const char* signals) {
SourceConfig c;
c.set("rate", "1000");
c.set("signals", signals);
f.write(c);
return f.valid();
};
const auto subtitle = [&]() {
const char* t = adw_expander_row_get_subtitle(ADW_EXPANDER_ROW(f.rows[2]));
return std::string(t != nullptr ? t : "");
};
const auto has = [](const std::string& text, const char* part) {
return text.find(part) != std::string::npos;
};
const bool good = with("A:uint8,B:float32,W:int16[4]");
const std::string good_sub = subtitle();
const bool size_ok = has(good_sub, "13 bytes") && has(good_sub, "6 channels");
const bool dup = with("A:uint8,A:float32");
const bool dup_ok = has(subtitle(), "twice");
const bool big = with("A:float64[4096],B:float64[4096]");
with("A:uint8,B:float32");
GtkWidget* add = find_named(f.rows[2], "flatudp-add");
if (add != nullptr) {
g_signal_emit_by_name(add, "clicked");
g_signal_emit_by_name(add, "clicked");
}
const bool added = f.read().get("signals") == "A:uint8,B:float32,S1:float32,S2:float32";
GtkWidget* rm = find_named(f.rows[2], "flatudp-remove"); // the first row's
if (rm != nullptr) g_signal_emit_by_name(rm, "clicked");
const bool removed = f.read().get("signals") == "B:float32,S1:float32,S2:float32";
// Waveform rows.
const bool wave_valid = with("W1:int16[4]w,W2:float32[4]w");
const bool wave_sub = has(subtitle(), "2 channels");
const bool wave_rt = f.read().get("signals") == "W1:int16[4]w,W2:float32[4]w";
const bool mixed = with("A:uint8,W:int16[4]w");
const bool mixed_msg = has(subtitle(), "waveform");
const bool length = with("W1:int16[4]w,W2:int16[8]w");
const bool length_msg = has(subtitle(), "same length");
const bool cap = with("A:float32[200],B:float32[100]");
const bool cap_msg = has(subtitle(), "256");
const bool cap_wave = with("A:float32[200]w,B:float32[200]w");
// The "Vector as" drop-down, driven.
with("A:uint8,V1:int16[4],V2:int16[4]");
std::vector<GtkWidget*> vec;
find_all_named(f.rows[2], "flatudp-vector", vec);
const bool vec_found = vec.size() == 3;
const bool vec_shown = vec_found && !gtk_widget_get_visible(vec[0]) &&
gtk_widget_get_visible(vec[1]) && gtk_widget_get_visible(vec[2]);
bool vec_drive = false;
bool vec_reset = false;
if (vec_found) {
gtk_drop_down_set_selected(GTK_DROP_DOWN(vec[1]), 1);
const bool half_valid = f.valid(); // one waveform beside a scalar and a channels row
const std::string half = f.read().get("signals");
gtk_drop_down_set_selected(GTK_DROP_DOWN(vec[2]), 1);
const bool scalar_left = f.valid(); // the scalar A still breaks the all-waveform rule
vec_drive = !half_valid && half == "A:uint8,V1:int16[4]w,V2:int16[4]" && !scalar_left &&
f.read().get("signals") == "A:uint8,V1:int16[4]w,V2:int16[4]w";
// Bring V1 back to one element: its drop-down hides and returns to Channels.
std::vector<GtkWidget*> counts;
find_all_named(f.rows[2], "flatudp-count", counts);
if (counts.size() == 3) gtk_spin_button_set_value(GTK_SPIN_BUTTON(counts[1]), 1);
vec_reset = counts.size() == 3 && !gtk_widget_get_visible(vec[1]) &&
gtk_drop_down_get_selected(GTK_DROP_DOWN(vec[1])) == 0 &&
f.read().get("signals") == "A:uint8,V1:int16,V2:int16[4]w";
}
const bool ok = !initial && good && size_ok && !dup && dup_ok && !big && add != nullptr && added &&
rm != nullptr && removed && wave_valid && wave_sub && wave_rt && !mixed &&
mixed_msg && !length && length_msg && !cap && cap_msg && cap_wave && vec_shown &&
vec_drive && vec_reset;
std::fprintf(stderr,
"[flatudp] form: initial_valid=%d good_valid=%d size_ok=%d dup_valid=%d dup_msg=%d "
"oversize_valid=%d add=%d remove=%d wave_valid=%d wave_sub=%d wave_roundtrip=%d "
"mixed_valid=%d mixed_msg=%d length_valid=%d length_msg=%d cap_valid=%d cap_msg=%d "
"cap_wave_valid=%d vec_shown=%d vec_drive=%d vec_reset=%d ok=%d\n",
initial ? 1 : 0, good ? 1 : 0, size_ok ? 1 : 0, dup ? 1 : 0, dup_ok ? 1 : 0,
big ? 1 : 0, added ? 1 : 0, removed ? 1 : 0, wave_valid ? 1 : 0, wave_sub ? 1 : 0,
wave_rt ? 1 : 0, mixed ? 1 : 0, mixed_msg ? 1 : 0, length ? 1 : 0, length_msg ? 1 : 0,
cap ? 1 : 0, cap_msg ? 1 : 0, cap_wave ? 1 : 0, vec_shown ? 1 : 0, vec_drive ? 1 : 0,
vec_reset ? 1 : 0, ok ? 1 : 0);
// The rows were never parented; sink and release them so the form leaves nothing behind.
for (GtkWidget* row : f.rows) {
g_object_ref_sink(row);
g_object_unref(row);
}
}
} // namespace
/// Phase 2, 1 s after the controls were driven: the stream, the calibration, the capture button,
/// the source's state and the decode.
gboolean e1608_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
e1608::E1608Source* src = e1608_source(a);
if (src == nullptr) return G_SOURCE_REMOVE;
const e1608::E1608Settings& s = src->settings();
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
const std::vector<SignalInfo>& sigs = a->model->signals();
std::size_t plotted = 0;
for (bool p : s.plot) plotted += p ? 1 : 0;
const std::size_t expect =
static_cast<std::size_t>(s.active_channels()) + plotted + (s.plot_counter ? 1 : 0);
std::string names;
for (const SignalInfo& si : sigs) names += (names.empty() ? "" : ",") + si.name;
const double rate = sigs.empty() ? 0.0 : sigs.front().sampling_rate;
const std::uint64_t frames = a->model->stats().frames.load(std::memory_order_relaxed);
// The signals' order is part of the contract: the enabled analog inputs, then the plotted DI
// lines, then the counter. Swapped channels must not read ok.
std::string expect_names;
const auto add_name = [&](const std::string& n) { expect_names += (expect_names.empty() ? "" : ",") + n; };
for (int i = 0; i < 8; ++i) {
if (s.ai_on[i]) add_name("AI" + std::to_string(i));
}
for (int i = 0; i < 8; ++i) {
if (s.plot[i]) add_name("DI" + std::to_string(i));
}
if (s.plot_counter) add_name("Counter");
const bool stream_ok = up && sigs.size() == expect && names == expect_names &&
std::fabs(rate - s.scan_rate) < 1e-6 && frames > 0;
std::fprintf(stderr,
"[e1608] stream: connected=%d signals=%zu expect=%zu names=%s frames=%" G_GUINT64_FORMAT
" rate=%.3f ok=%d\n",
up ? 1 : 0, sigs.size(), expect, names.c_str(), frames, rate, stream_ok ? 1 : 0);
// The mock's AI0 is 0.5 V DC under a 5 Hz sine: two periods average to the DC.
double mean = std::nan("");
if (up && a->model->channel_count() > 0 && !a->model->channels().empty() &&
a->model->channels().front().name == "AI0") {
const ChannelBuffer& ch = a->model->live().channel(0);
const u64 n = ch.size();
const u64 want = static_cast<u64>(std::lround(2.0 * s.scan_rate / 5.0));
if (want > 0 && n > want + 50) {
std::vector<f32> v(static_cast<std::size_t>(want));
ch.raw(n - want - 50, n - 50, v.data()); // stay clear of the head the producer is writing
double sum = 0.0;
for (f32 x : v) sum += double(x);
mean = sum / double(v.size());
}
}
std::fprintf(stderr, "[e1608] calibration: ai0_mean=%.4f expect=0.5000 ok=%d\n", mean,
std::fabs(mean - 0.5) < 0.02 ? 1 : 0);
std::fprintf(stderr, "[e1608] capture button: present=%d sensitive=%d\n",
a->capture_button != nullptr ? 1 : 0,
a->capture_button != nullptr && gtk_widget_get_sensitive(a->capture_button) ? 1 : 0);
std::fprintf(stderr, "[e1608] state: connects=%u message='%s'\n", src->connects(),
src->last_message().c_str());
std::fprintf(stderr, "[e1608] decode: ok=%d\n", e1608_decode_check() ? 1 : 0);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// Phase 1: the panels, then the three controls (a DAC level, a digital output, the counter reset).
gboolean e1608_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
e1608::E1608Source* src = e1608_source(a);
if (src == nullptr) {
std::fprintf(stderr, "[e1608] no e1608 source (run with --source e1608)\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
for (const char* id : {"ao", "dio"}) {
const std::string page = std::string("source-") + id;
GtkWidget* content = source_panel_content(a, page.c_str());
const bool present =
content != nullptr && a->side_stack != nullptr &&
adw_view_stack_get_child_by_name(ADW_VIEW_STACK(a->side_stack), page.c_str()) != nullptr;
std::fprintf(stderr, "[e1608] panel %s: present=%d sensitive=%d connected=%d\n", id,
present ? 1 : 0, content != nullptr && gtk_widget_get_sensitive(content) ? 1 : 0,
up ? 1 : 0);
}
if (up) {
// All the stream's signals, in one structural change; the plots do not matter to these readings.
std::vector<std::size_t> all(a->model->signals().size());
for (std::size_t i = 0; i < all.size(); ++i) all[i] = i;
if (!all.empty()) a->model->set_signals_enabled(all, true);
GtkWidget* ao = source_panel_content(a, "source-ao");
GtkWidget* dio = source_panel_content(a, "source-dio");
GtkWidget* level = find_named(ao, "e1608-ao0-level");
GtkWidget* out1 = find_named(dio, "e1608-do1");
GtkWidget* reset = find_named(dio, "e1608-counter-reset");
// 2.5 V must differ from the configured initial level, or the spin row notifies nothing.
if (level != nullptr) adw_spin_row_set_value(ADW_SPIN_ROW(level), 2.5);
if (out1 != nullptr) adw_switch_row_set_active(ADW_SWITCH_ROW(out1), TRUE);
if (reset != nullptr) g_signal_emit_by_name(reset, "clicked");
std::fprintf(stderr, "[e1608] actions: ao0_level=%d do1=%d counter_reset=%d\n",
level != nullptr ? 1 : 0, out1 != nullptr ? 1 : 0, reset != nullptr ? 1 : 0);
} else {
std::fprintf(stderr, "[e1608] actions: skipped (not connected)\n");
}
std::fflush(stderr);
g_timeout_add(1000, e1608_probe_report, a);
return G_SOURCE_REMOVE;
}
/// After the mock dropped the link: has the source reconnected, and does the stream move again?
gboolean e1608_probe_resumed(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const std::uint64_t after = a->model->stats().frames.load(std::memory_order_relaxed);
// Frames alone also grow on a link that never dropped: the reconnect must have happened too.
e1608::E1608Source* src = e1608_source(a);
const bool reconnected = src != nullptr && src->connects() >= 2;
std::fprintf(stderr, "[e1608] resumed: frames_before=%" G_GUINT64_FORMAT " frames_after=%" G_GUINT64_FORMAT
" ok=%d\n",
g_e1608_frames_before, after, reconnected && after > g_e1608_frames_before ? 1 : 0);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// After the mock dropped the link: the source's connect count, then (500 ms later) whether the stream moves.
gboolean e1608_probe_reconnect(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
e1608::E1608Source* src = e1608_source(a);
if (src == nullptr) return G_SOURCE_REMOVE;
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
g_e1608_frames_before = a->model->stats().frames.load(std::memory_order_relaxed);
std::fprintf(stderr, "[e1608] reconnect: connects=%u connected=%d ok=%d\n", src->connects(),
up ? 1 : 0, src->connects() >= 2 && up ? 1 : 0);
std::fflush(stderr);
g_timeout_add(500, e1608_probe_resumed, a);
return G_SOURCE_REMOVE;
}
/// The mock's channels, as the model must list them: a scalar `Name`, the elements of a `channels`
/// vector `Name[k]`, a waveform `Name` at `rate * count`.
struct FlatudpExpect {
std::vector<std::string> names;
std::vector<double> rates;
};
static FlatudpExpect flatudp_expect(const flatudp::FlatUdpSettings& s) {
FlatudpExpect e;
for (const flatudp::Row& r : s.rows) {
if (r.wave) {
e.names.push_back(r.name);
e.rates.push_back(s.rate * double(r.count));
} else if (r.count == 1) {
e.names.push_back(r.name);
e.rates.push_back(s.rate);
} else {
for (std::uint32_t k = 0; k < r.count; ++k) {
e.names.push_back(r.name + "[" + std::to_string(k) + "]");
e.rates.push_back(s.rate);
}
}
}
return e;
}
/// Breaks in a waveform channel as the mock emits it: datagram i carries i, i+1 .. i+count-1, so
/// consecutive samples differ by 1 and the step into the next datagram by 2 - count (mod 100).
/// The block phase is read from the first such step, then held for the whole window.
static long flatudp_wave_breaks(App* a, std::size_t channel, long count) {
if (channel >= a->model->channels().size()) return -1;
const ChannelBuffer& ch = a->model->live().channel(channel);
const u64 n = ch.size();
const u64 want = 400;
if (n <= want + 50) return -1;
std::vector<f32> v(static_cast<std::size_t>(want));
ch.raw(n - want - 50, n - 50, v.data());
const auto step = [&](std::size_t i) {
const long d = std::lround(double(v[i])) - std::lround(double(v[i - 1]));
return ((d % 100) + 100) % 100;
};
const long jump = (((2 - count) % 100) + 100) % 100;
long phase = -1;
for (std::size_t i = 1; i < v.size() && phase < 0; ++i) {
if (step(i) == jump) phase = static_cast<long>(i % std::size_t(count));
}
if (phase < 0) return static_cast<long>(v.size());
long breaks = 0;
for (std::size_t i = 1; i < v.size(); ++i) {
const long want_step = long(i % std::size_t(count)) == phase ? jump : 1;
if (step(i) != want_step) ++breaks;
}
return breaks;
}
/// Samples of two channels at the same index, `b` minus `a`, modulo 100: the mock's element e of a
/// vector row adds e to the row's value, so element k+1 minus element k is 1 at every sample.
static long flatudp_offset_breaks(App* app, std::size_t a, std::size_t b, long offset) {
if (a >= app->model->channels().size() || b >= app->model->channels().size()) return -1;
const ChannelBuffer& ca = app->model->live().channel(a);
const ChannelBuffer& cb = app->model->live().channel(b);
const u64 n = std::min(ca.size(), cb.size());
const u64 want = 400;
if (n <= want + 50) return -1;
std::vector<f32> va(static_cast<std::size_t>(want)), vb(va.size());
ca.raw(n - want - 50, n - 50, va.data());
cb.raw(n - want - 50, n - 50, vb.data());
long breaks = 0;
for (std::size_t i = 0; i < va.size(); ++i) {
const long d = std::lround(double(vb[i])) - std::lround(double(va[i]));
if (((d % 100) + 100) % 100 != offset) ++breaks;
}
return breaks;
}
/// The silence probe is a small state machine on a 100 ms tick, so it needs no knowledge of when the
/// sender's gap falls (the mock's clock starts seconds before the app's): wait until the stream is
/// up, then for the 2 s silence to take the source down, then for it to come back.
struct FlatudpGapState {
int phase = 0; // 0 waiting for the stream, 1 for the silence, 2 for the return
int ticks = 0;
std::uint64_t frames_before = 0;
};
static FlatudpGapState g_flatudp_gap;
gboolean flatudp_probe_resumed(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
flatudp::FlatUdpSource* src = flatudp_source(a);
const std::uint64_t after = a->model->stats().frames.load(std::memory_order_relaxed);
const bool up = src != nullptr && src->connected() &&
a->model->stats().connected.load(std::memory_order_relaxed);
if (!(up && after > g_flatudp_gap.frames_before) && ++g_flatudp_gap.ticks < 200) return G_SOURCE_CONTINUE;
std::fprintf(stderr, "[flatudp] resumed: connected=%d frames_before=%" G_GUINT64_FORMAT
" frames_after=%" G_GUINT64_FORMAT " ok=%d\n",
up ? 1 : 0, g_flatudp_gap.frames_before, after,
up && after > g_flatudp_gap.frames_before ? 1 : 0);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// Polls the silence: the source must go down within 20 s of the stream coming up.
static gboolean flatudp_probe_gap_poll(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
flatudp::FlatUdpSource* src = flatudp_source(a);
if (src == nullptr) return G_SOURCE_REMOVE;
FlatudpGapState& g = g_flatudp_gap;
const bool src_up = src->connected();
const bool model_up = a->model->stats().connected.load(std::memory_order_relaxed);
++g.ticks;
if (g.phase == 0) {
if (src_up && model_up) {
g.phase = 1;
g.ticks = 0;
} else if (g.ticks >= 200) {
std::fprintf(stderr, "[flatudp] silence: never connected ok=0\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
}
if (!src_up || g.ticks >= 200) {
// The model learns of it through the sink a moment after the source drops: read it a tick later.
if (!src_up && model_up && g.ticks < 200) return G_SOURCE_CONTINUE;
g.frames_before = a->model->stats().frames.load(std::memory_order_relaxed);
std::fprintf(stderr, "[flatudp] silence: connected=%d model_connected=%d after_ms=%d ok=%d\n",
src_up ? 1 : 0, model_up ? 1 : 0, g.ticks * 100, !src_up && !model_up ? 1 : 0);
std::fflush(stderr);
g.ticks = 0;
g.phase = 2;
g_timeout_add(100, flatudp_probe_resumed, a);
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
}
/// Starts the silence probe (see `FlatudpGapState`).
gboolean flatudp_probe_gap(gpointer user) {
g_flatudp_gap = FlatudpGapState{};
g_timeout_add(100, flatudp_probe_gap_poll, user);
return G_SOURCE_REMOVE;
}
/// Phase 2, 1 s after the signals were enabled: the stream, its channels, the value continuity, the
/// wrong-size accounting and the capture button.
gboolean flatudp_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
flatudp::FlatUdpSource* src = flatudp_source(a);
if (src == nullptr) return G_SOURCE_REMOVE;
const flatudp::FlatUdpSettings& s = src->settings();
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
const std::vector<SignalInfo>& sigs = a->model->signals();
const FlatudpExpect ex = flatudp_expect(s);
std::string names, expect_names;
bool rates_ok = sigs.size() == ex.rates.size();
for (std::size_t i = 0; i < sigs.size(); ++i) {
names += (names.empty() ? "" : ",") + sigs[i].name;
if (i < ex.rates.size() && std::fabs(sigs[i].sampling_rate - ex.rates[i]) > 1e-6) rates_ok = false;
}
for (const std::string& n : ex.names) expect_names += (expect_names.empty() ? "" : ",") + n;
const double rate = sigs.empty() ? 0.0 : sigs.front().sampling_rate;
const std::uint64_t frames = a->model->stats().frames.load(std::memory_order_relaxed);
const bool stream_ok = up && names == expect_names && rates_ok && frames > 0;
std::fprintf(stderr,
"[flatudp] stream: connected=%d signals=%zu names=%s expect=%s frames=%" G_GUINT64_FORMAT
" rate=%.3f ok=%d\n",
up ? 1 : 0, sigs.size(), names.c_str(), expect_names.c_str(), frames, rate,
stream_ok ? 1 : 0);
const std::size_t channels = a->model->channels().size();
const std::size_t expect_channels = static_cast<std::size_t>(flatudp::channel_count(s.rows));
std::fprintf(stderr, "[flatudp] channels: count=%zu expect=%zu ok=%d\n", channels, expect_channels,
channels == expect_channels ? 1 : 0);
// The mock's row k, element e, carries (i * (k + 1) + e) % 100 in datagram i, so consecutive
// samples of a channel from row k differ by k + 1 (mod 100), and element e + 1 of a `channels`
// vector reads one more than element e at the same sample. A waveform channel steps by 1 inside
// a datagram and by 2 - count between two.
if (up && !s.rows.empty() && flatudp::any_wave(s.rows)) {
const long wb = flatudp_wave_breaks(a, 0, static_cast<long>(s.rows[0].count));
std::fprintf(stderr, "[flatudp] continuity: wave_breaks=%ld ok=%d\n", wb, wb == 0 ? 1 : 0);
} else if (up && !s.rows.empty()) {
std::string out;
bool ok = true;
std::size_t base = 0;
bool elem_done = false;
for (std::size_t k = 0; k < s.rows.size(); ++k) {
const flatudp::Row& r = s.rows[k];
const long b = flatudp_breaks(a, base, static_cast<long>(k) + 1);
out += " " + r.name + (r.count > 1 ? "[0]" : "") + "_breaks=" + std::to_string(b);
ok = ok && b == 0;
if (r.count > 1 && !elem_done) {
elem_done = true;
const long ob = flatudp_offset_breaks(a, base, base + 1, 1);
out += " " + r.name + "[1]-[0]_breaks=" + std::to_string(ob);
ok = ok && ob == 0;
}
base += r.count;
}
std::fprintf(stderr, "[flatudp] continuity:%s ok=%d\n", out.c_str(), ok ? 1 : 0);
} else {
std::fprintf(stderr, "[flatudp] continuity: skipped (not connected)\n");
}
const std::uint64_t wrong = src->wrong_size_datagrams();
const std::string msg = src->last_message();
std::fprintf(stderr, "[flatudp] wrong size: count=%" G_GUINT64_FORMAT " message='%s' ok=%d\n", wrong,
msg.c_str(), wrong >= 1 && msg.find("expected") != std::string::npos ? 1 : 0);
std::fprintf(stderr, "[flatudp] capture button: present=%d sensitive=%d\n",
a->capture_button != nullptr ? 1 : 0,
a->capture_button != nullptr && gtk_widget_get_sensitive(a->capture_button) ? 1 : 0);
std::fprintf(stderr, "[flatudp] state: valid=%" G_GUINT64_FORMAT " wrong=%" G_GUINT64_FORMAT "\n",
src->valid_datagrams(), wrong);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// Phase 1: the codec check (it needs no flatudp source), then every signal enabled in one structural
/// change, and the report a second later.
gboolean flatudp_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::fprintf(stderr, "[flatudp] codec: ok=%d\n", flatudp_codec_check() ? 1 : 0);
flatudp_form_probe();
if (flatudp_source(a) == nullptr) {
std::fprintf(stderr, "[flatudp] no flatudp source (run with --source flatudp)\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
std::vector<std::size_t> all(a->model->signals().size());
for (std::size_t i = 0; i < all.size(); ++i) all[i] = i;
if (!all.empty()) a->model->set_signals_enabled(all, true);
std::fflush(stderr);
g_timeout_add(1000, flatudp_probe_report, a);
return G_SOURCE_REMOVE;
}
gboolean pan_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->rows->row_count() <= 0) return G_SOURCE_REMOVE;
RowSet::Row& r0 = a->rows->row(0);
if (r0.plot == nullptr) return G_SOURCE_REMOVE;
const plot::Range before = r0.plot->x_range(r0.axis_x);
const double span = before.span();
if (!(span > 0.0)) return G_SOURCE_REMOVE;
// Where the data is not: ten windows to the left, the same order of magnitude a drag moves.
const plot::Range panned{before.min - 10.0 * span, before.max - 10.0 * span};
r0.plot->set_x_range(r0.axis_x, panned);
std::fprintf(stderr, "[pan-probe] asked=[%.6f..%.6f] was=[%.6f..%.6f]\n", panned.min,
panned.max, before.min, before.max);
std::fflush(stderr);
a->dbg_pan_asked_lo = panned.min;
a->dbg_pan_asked_hi = panned.max;
a->dbg_gesture_verdict = 1;
return G_SOURCE_REMOVE;
}
gboolean arm_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// The toggle itself, not a label search: the header bar's buttons carry icons now,
// so there is no text to find them by.
if (a->trigger_toggle != nullptr)
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
std::fprintf(stderr, "[arm-probe] trigger armed\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean trigrenum_probe_step3(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->model->trigger_latches().load(std::memory_order_relaxed) == g_trigrenum_latches)
return G_SOURCE_CONTINUE;
const double latched = a->model->trigger_time().load(std::memory_order_relaxed);
std::fprintf(stderr, "[trigrenum-probe] latched t0=%.4f opened on %.4f t0_held=%d\n", latched,
g_trigrenum_edge, int(std::fabs(latched - g_trigrenum_edge) < 1.0e-6));
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean trigrenum_probe_step2(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (!a->model->trigger_pending()) return G_SOURCE_CONTINUE;
g_trigrenum_edge = a->model->capture_edge();
g_trigrenum_latches = a->model->trigger_latches().load(std::memory_order_relaxed);
int ch = -1;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
ch = a->model->trigger().channel;
}
std::fprintf(stderr, "[trigrenum-probe] capture open: t0=%.4f ch=%d -- enabling signal 1 now\n",
g_trigrenum_edge, ch);
std::fflush(stderr);
a->model->set_signal_enabled(1, true);
{
std::lock_guard<std::mutex> lock(a->model->mutex());
ch = a->model->trigger().channel;
}
std::fprintf(stderr, "[trigrenum-probe] after enable: trigger ch=%d channels=%zu\n", ch,
a->model->channel_count());
std::fflush(stderr);
g_timeout_add(25, trigrenum_probe_step3, a);
return G_SOURCE_REMOVE;
}
gboolean trigrenum_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->window_entry == nullptr) return G_SOURCE_REMOVE;
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->window_unit_drop), 2);
gtk_editable_set_text(GTK_EDITABLE(a->window_entry), "1");
on_window_entry_activate(GTK_ENTRY(a->window_entry), a);
// Signal 2 is the mock's square; signal 1 is its sine. Only the square to begin with.
a->model->set_signal_enabled(1, false);
a->model->set_signal_enabled(2, true);
a->model->set_signal_enabled(3, false);
if (a->model->channel_count() > 0) {
const ChannelInfo& c0 = a->model->channels()[0];
a->rows->assign(0, 0, c0.name, c0.name,
signal_color_index(a->model->signals(), c0.signal));
a->relayout_pending = true;
}
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_pre_spin), 10.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin), 0.0);
publish_trigger_fields(a);
if (a->trigger_toggle != nullptr)
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
{
std::lock_guard<std::mutex> lock(a->model->mutex());
const TriggerConfig& tc = a->model->trigger();
std::fprintf(stderr,
"[trigrenum-probe] window=%.4f event_span=%.4f pre_pct=%.1f post=%.4f "
"holdoff=%.4f level=%.4g edge=%d mode=%d enabled=%d armed=%d ch=%d "
"channels=%zu\n",
a->rows->time_window(), a->model->event_span(), tc.pretrigger_pct,
tc.post_span_s(a->model->event_span()), tc.holdoff_s, tc.condition.level,
int(tc.condition.edge),
int(tc.mode), int(tc.enabled), int(tc.armed), tc.channel,
a->model->channel_count());
}
std::fflush(stderr);
g_timeout_add(25, trigrenum_probe_step2, a);
return G_SOURCE_REMOVE;
}
gboolean fields_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
GtkDropDown* mode = GTK_DROP_DOWN(a->trigger_mode_drop);
GtkDropDown* edge = GTK_DROP_DOWN(a->trigger_edge_drop);
gtk_drop_down_set_selected(mode, gtk_drop_down_get_selected(mode) == 0 ? 1 : 0);
gtk_drop_down_set_selected(edge, (gtk_drop_down_get_selected(edge) + 1) % 3);
std::lock_guard<std::mutex> lock(a->model->mutex());
std::fprintf(stderr, "[fields-probe] mode=%u edge=%u published mode=%d edge=%d capturing=%d\n",
gtk_drop_down_get_selected(mode), gtk_drop_down_get_selected(edge),
int(a->model->trigger().mode), int(a->model->trigger().condition.edge),
int(a->model->trigger_pending()));
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
static const char* condition_probe_channel(int shape) {
return (shape == 2 || shape == 3) ? "sine" : "square";
}
gboolean condition_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const double t0 = a->model->trigger_time();
// A finite `t0` only says the edge was *accepted*: the mode is `Capturing` until the post
// window fills. Every field below is read from `display_snapshot()`, whose stamps span x = 0
// only once the event is `Held`, so reporting on the latch alone races the capture and
// prints `nan` for a leg that is working. Both waits share the one poll budget.
if (!std::isfinite(t0) || t0 <= 0.0 || a->model->display_mode() != DisplayMode::Held) {
if (++g_condition.waited < 200) return G_SOURCE_CONTINUE;
const bool latched = std::isfinite(t0) && t0 > 0.0;
std::fprintf(stderr, "[condition-probe] %s after %d polls\n",
latched ? "latched but never held" : "no latch", g_condition.waited);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
const double kNan = std::numeric_limits<double>::quiet_NaN();
const double level = a->model->trigger().condition.level;
const double level2 = a->model->trigger().condition.level2;
const std::size_t trig_ch = static_cast<std::size_t>(a->model->trigger().channel);
// Shape 2 reports two sample values rather than a measured width.
double at0 = kNan;
double earlier = kNan;
double measured = kNan;
if (std::shared_ptr<const EventSnapshot> snap = a->model->display_snapshot()) {
if (snap != nullptr && trig_ch < snap->values.size()) {
const std::vector<f64>& st = snap->stamps;
const std::vector<f32>& vals = snap->values[trig_ch];
if (vals.size() == st.size() && !st.empty()) {
// Index of the last stamp <= 0 (the latching event).
const auto it = std::upper_bound(st.begin(), st.end(), 0.0);
const std::size_t end = static_cast<std::size_t>(it - st.begin());
if (g_condition.shape == 2) {
// Window leg: x=0 is the moment the signal left the band (falling / out-of-band edge).
// `at0` is the sample at x=0: must be outside [lo, hi], i.e. |at0| >= 0.5.
// `earlier` is the sample immediately before x=0: must be inside, i.e. |earlier| < 0.5.
// Using the adjacent sample makes the assertion exact and independent of snapshot size.
if (end >= 1) at0 = static_cast<double>(vals[end - 1]);
if (end >= 2) earlier = static_cast<double>(vals[end - 2]);
} else {
// Pulse legs: scan backwards for the most recent upward crossing
// (prev < level, value >= level) and negate the stamp to get a duration.
// Dropout leg: accept a crossing in either direction because the last sine crossing
// before the quiet period may be downward, and an upward-only scan would miss it.
for (std::size_t i = end; i >= 2; --i) {
const double prev = static_cast<double>(vals[i - 2]);
const double val = static_cast<double>(vals[i - 1]);
const bool crossed = (g_condition.shape == 3)
? ((prev < level) != (val < level)) // either direction
: (prev < level && val >= level); // upward only
if (crossed) {
measured = -st[i - 1];
break;
}
}
}
}
}
}
std::fprintf(stderr,
"[condition-probe] type=%d cmp=%d width=%.6f t0=%.6f measured=%.6f "
"at0=%.6f earlier=%.6f level2=%.6f mode=%d\n",
int(a->model->trigger().condition.type), int(a->model->trigger().condition.cmp),
a->model->trigger().condition.width_s, t0, measured, at0, earlier, level2,
int(a->model->display_mode()));
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean condition_probe_arm(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// Legs 0 and 2 need only a short snapshot (the event is one sample or one glitch-width back).
// Legs 1 and 3 need a 1.0 s window with 50% pre so the opening edge of a 0.2 s pulse and
// the last sine crossing before a 0.3 s timeout are both captured.
const bool wide = (g_condition.shape == 1 || g_condition.shape == 3);
const double win_s = wide ? 1.0 : 0.2;
const double pre_pct = wide ? 50.0 : 10.0;
apply_time_window(*a, win_s);
const std::vector<ChannelInfo>& channels = a->model->channels();
int sel = -1;
const bool dropout = g_condition.shape == 3;
const char* want_name = condition_probe_channel(g_condition.shape);
for (std::size_t i = 0; i < channels.size(); ++i) {
if (channels[i].name == want_name) sel = static_cast<int>(i);
}
std::fprintf(stderr, "[condition-probe] %s=%d of %zu channels\n", want_name, sel,
channels.size());
if (sel >= 0) {
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_signal_drop),
static_cast<guint>(sel));
}
// Shape 2 is `window`, which the dropdown numbers 2; the two pulse-width legs share type 1
// and differ only in the comparison.
const guint type = g_condition.shape == 2 ? 2u : (dropout ? 3u : 1u);
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_type_drop), type);
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_cmp_drop),
g_condition.shape == 1 ? 1u : 0u); // `>` for leg 1, `<` otherwise
// Window leg uses `falling` (index 1), meaning out-of-band, which makes the latch deterministic:
// x=0 is always a band exit, so at0 is outside and the previous sample is inside.
// Dropout has no direction (index 2, `both`). Pulse-width legs use `rising` (index 0).
const guint edge_idx = (g_condition.shape == 2) ? 1u : (dropout ? 2u : 0u);
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_edge_drop), edge_idx);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_width_spin), 0.010);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_timeout_spin), g_condition.timeout);
// The band for the window leg: straddling zero and well inside the sine's +/-1, so the sine
// crosses into it and out of it twice a period. Harmless for the other legs, which ignore
// `level2`.
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level2_spin), 0.5);
// Zero for every leg but the window, whose band is `[-0.5, +0.5]`.
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin),
g_condition.shape == 2 ? -0.5 : 0.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_pre_spin), pre_pct);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_holdoff_spin), 0.0);
publish_trigger_fields(a);
if (a->trigger_toggle != nullptr)
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
g_condition.waited = 0;
g_timeout_add(25, condition_probe_report, a);
return G_SOURCE_REMOVE;
}
gboolean condition_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (const char* s = g_getenv("UDPSCOPE_PROBE_CONDITION_SHAPE")) {
g_condition.shape = std::atoi(s);
}
// `square` is not acquired until it is enabled, and the trigger can only name a channel
// that is: enabling is what starts acquisition.
const char* want = condition_probe_channel(g_condition.shape);
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == want) a->model->set_signal_enabled(i, true);
}
// One beat for the channel list to be rebuilt and the ring to refill behind the glitch.
g_timeout_add(1500, condition_probe_arm, a);
return G_SOURCE_REMOVE;
}
gboolean search_probe_marks(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const plot::Range view = a->rows->view_range();
std::size_t in_view = 0;
const double s0 = a->model->stream_at_x0();
for (const double h : a->search.hits()) {
const double x = h - s0;
if (x >= view.min && x <= view.max) ++in_view;
}
std::fprintf(stderr,
"[search-probe] marks placed=%d of %zu in view (%zu found) locked=%d mode=%d "
"axis=[%.4f..%.4f]\n",
a->rows->search_marks_placed(), in_view, a->search.hits().size(),
a->rows->readouts_locked() ? 1 : 0, int(a->model->display_mode()), view.min,
view.max);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean search_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const BufferSearch& s = a->search;
if (s.state() == BufferSearch::State::Running) return G_SOURCE_CONTINUE;
double nearest = 0.0;
double err = 0.0;
bool have = false;
for (const double h : s.hits()) {
const double d = std::fabs(h - g_search_t0);
if (!have || d < std::fabs(err)) {
have = true;
nearest = h;
err = h - g_search_t0;
}
}
// One stored sample period is the tolerance: the trigger saw the stored (filtered) stream
// and so does the scan, so they must agree to the sample, not merely to the neighbourhood.
const double tol = a->model->partition().stored_rate() > 0.0
? 1.0 / a->model->partition().stored_rate()
: 1.0e-3;
std::fprintf(stderr,
"[search-probe] state=%d hits=%zu scanned=%.4f total=%.4f chunks=%" G_GUINT64_FORMAT
" worst=%.3fms total=%.3fms armed t0=%.6f vs scanned first=%.6f err=%.6g agree=%d\n",
int(s.state()), s.hits().size(), s.scanned_s(), s.total_s(), s.chunks(),
s.worst_chunk_ms(), s.total_ms(), g_search_t0, have ? nearest : 0.0, err,
int(have && std::fabs(err) <= tol));
if (!s.hits().empty()) {
a->search.select(0);
search_jump(*a, a->search.hits()[0]);
update_search_panel(*a);
const plot::Range view = a->rows->view_range();
std::fprintf(stderr,
"[search-probe] jumped to hit 0 t=%.6f mode=%d axis=[%.4f..%.4f] pos=%d\n",
a->search.hits()[0], int(a->model->display_mode()), view.min, view.max,
a->search.cursor() + 1);
g_timeout_add(250, search_probe_marks, a);
}
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean search_probe_run(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
g_search_t0 = a->model->trigger_time();
EventCondition c;
int channel = -1;
double dt = 0.0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
c = a->model->trigger().condition;
channel = a->model->trigger().channel;
dt = a->model->partition().slot_dt();
}
a->search.start(c, channel, dt);
std::fprintf(stderr, "[search-probe] started on channel %d, armed t0=%.6f\n", channel,
g_search_t0);
std::fflush(stderr);
g_timeout_add(100, search_probe_report, a);
return G_SOURCE_REMOVE;
}
gboolean search_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// Reuse the condition probe's arming entirely: the point is that one condition gives two
// evaluators the same answer, so the condition must be set in exactly one place.
condition_probe(a);
g_timeout_add(6000, search_probe_run, a);
return G_SOURCE_REMOVE;
}
gboolean window_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->window_entry == nullptr) return G_SOURCE_REMOVE;
GtkEditable* entry = GTK_EDITABLE(a->window_entry);
GtkDropDown* unit = GTK_DROP_DOWN(a->window_unit_drop);
auto report = [a](const char* what) {
double span = 0.0;
u64 stride = 0;
double stored = 0.0;
double buffered = 0.0;
double aa = 0.0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
span = a->model->event_span();
const Partition& part = a->model->partition();
stride = part.time_stride;
stored = part.stored_rate();
buffered = part.span_s;
aa = part.antialias_hz();
}
std::fprintf(stderr, "[window-probe] %-14s label='%s' entry='%s' unit=%u window=%.6fs "
"event_span=%.6fs cap=%.3fs decim=%llu stored=%.4gS/s aa=%.4gHz "
"buffered=%.4gs\n",
what, a->dbg_window_text.c_str(),
gtk_editable_get_text(GTK_EDITABLE(a->window_entry)),
gtk_drop_down_get_selected(GTK_DROP_DOWN(a->window_unit_drop)),
a->rows->time_window(), span, a->window_limit_s,
static_cast<unsigned long long>(stride), stored, aa, buffered);
std::fflush(stderr);
};
report("initial");
// 500 ms typed: the entry's own path, and a span no ladder of round numbers had.
gtk_drop_down_set_selected(unit, 1);
gtk_editable_set_text(entry, "500");
on_window_entry_activate(GTK_ENTRY(a->window_entry), a);
report("type 500ms");
// The unit re-interprets the number rather than converting it, so this is 500 s -- past the
// cap, and therefore the clamp's own test.
gtk_drop_down_set_selected(unit, 2);
report("unit ms->s");
gtk_editable_set_text(entry, "2");
on_window_entry_activate(GTK_ENTRY(a->window_entry), a);
report("type 2s");
GtkWidget* up = gtk_button_new_with_label("x10");
g_object_set_data(G_OBJECT(up), "udpscope-window-up", up);
on_window_step(GTK_BUTTON(up), a);
report("x10");
GtkWidget* down = gtk_button_new_with_label("/10");
on_window_step(GTK_BUTTON(down), a);
report("/10");
// Both were created only to carry the direction flag; they were never parented, so they
// still hold the floating reference GTK4 created them with.
g_object_ref_sink(up);
g_object_unref(up);
g_object_ref_sink(down);
g_object_unref(down);
return G_SOURCE_REMOVE;
}
void layout_probe_report(App& a, const char* what) {
const int rows = a.rows->row_count();
const double scale = (a.area != nullptr && a.area->device_scale() > 0.0f)
? a.area->device_scale() : 1.0;
std::string geom;
const plot::FigureLayout& fl = a.rows->figure().layout();
for (std::size_t i = 0; i < fl.rows.size(); ++i) {
char buf[48];
std::snprintf(buf, sizeof(buf), "%s%.0f..%.0f", i == 0 ? "" : ",",
double(fl.rows[i].y) / scale,
(double(fl.rows[i].y) + double(fl.rows[i].h)) / scale);
geom += buf;
}
std::string close_tops;
std::string add_tops;
for (int i = 0; i < rows; ++i) {
char buf[24];
if (GtkWidget* b = layout_button(a, false, i)) {
std::snprintf(buf, sizeof(buf), "%s%d", i == 0 ? "" : ",", gtk_widget_get_margin_top(b));
close_tops += buf;
}
}
for (int i = 0; i <= rows; ++i) {
char buf[24];
if (GtkWidget* b = layout_button(a, true, i)) {
std::snprintf(buf, sizeof(buf), "%s%d", i == 0 ? "" : ",", gtk_widget_get_margin_top(b));
add_tops += buf;
}
}
std::string traces;
for (int r = 0; r < rows; ++r) {
traces += (r == 0 ? "" : "|");
traces += std::to_string(r) + ":";
const RowSet::Row& row = a.rows->row(r);
bool any = false;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::string& n = row.signal_name[static_cast<std::size_t>(s)];
if (n.empty()) continue;
if (any) traces += ",";
traces += n;
any = true;
}
if (!any) traces += "-";
}
// Where the resize arrows are and whether they are on screen: a boundary handle placed at the wrong
// index is invisible in a button count, and the one thing this control must get right is *which*
// border it sits on.
std::string resize_tops;
for (GtkWidget* w : a.layout_buttons) {
if (g_object_get_data(G_OBJECT(w), "udpscope-layout-resize") == nullptr) continue;
if (gtk_widget_get_visible(w)) {
const int top = gtk_widget_get_margin_top(w);
resize_tops += (resize_tops.empty() ? "" : ",") + std::to_string(row_key(w)) + ":" +
std::to_string(top);
} else {
resize_tops += std::string(resize_tops.empty() ? "" : ",") + "(" + std::to_string(row_key(w)) +
":-)";
}
}
if (resize_tops.empty()) resize_tops = "-";
// Where the Done button ended up, in the overlay's own coordinates: the assertion is
// that it hugs the area's top-right corner, so a layout change that pushed it off the
// corner shows here as numbers rather than as a screenshot someone has to look at.
std::string done_box = "-";
if (a.layout_done_button != nullptr && a.area_overlay != nullptr) {
graphene_rect_t r{};
if (gtk_widget_compute_bounds(a.layout_done_button, a.area_overlay, &r)) {
char buf[64];
std::snprintf(buf, sizeof(buf), "%.0f,%.0f,%.0fx%.0f", static_cast<double>(r.origin.x),
static_cast<double>(r.origin.y), static_cast<double>(r.size.width),
static_cast<double>(r.size.height));
done_box = buf;
}
}
std::fprintf(stderr, "[layout-probe] %-12s rows=%d buttons=%zu done=%d done_box=[%s] "
"rows_y=[%s] close=[%s] add=[%s] resize=[%s] traces=[%s] side_w=%d\n",
what, rows, a.layout_buttons.size(), a.layout_done_button != nullptr ? 1 : 0,
done_box.c_str(), geom.c_str(), close_tops.c_str(), add_tops.c_str(),
resize_tops.c_str(),
traces.c_str(),
// The sidebar, which is the pane a divider position can silently take away: `side_w`
// must be its own request (320) rather than 0, and `divider` the position the window's
// width less the sidebar's column left it at.
a.side_box != nullptr ? gtk_widget_get_width(a.side_box) : -1);
std::fflush(stderr);
}
/// The preferences dialog, driven the way a user drives it: opened, filled in, *Apply* clicked, then
/// the file read back from disk. Reading it back rather than checking `App::settings` is the point --
/// a dialog that edits its copy and never writes is the failure this has to catch.
/// `UDPSCOPE_PROBE_BOXZOOM_MS`: what a Ctrl box zoom puts on the plot, and what it does to the axis.
///
/// The gesture is injected straight into the row's controller -- the way a headless probe has to, since
/// the area's gestures arrive from GTK -- and both halves are read back a few ticks later. The *axis* is
/// the half a rect cannot report: it is the *settled* one that matters, after the pin or the data clamp
/// has had its say, and it is the half that silently does nothing in a view whose axis is pinned.
///
/// Three named steps rather than nested lambdas: `g_timeout_add` takes a plain function pointer, so a
/// lambda can carry no state, and the reading needs what the axis was before the drag.
struct BoxZoomProbe {
App* app = nullptr;
plot::Range before{0.0, 0.0};
};
plot::Range boxzoom_axis(App& app) {
const RowSet::Row& r = app.rows->row(0);
return r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{0.0, 0.0};
}
gboolean boxzoom_probe_done(gpointer user) {
BoxZoomProbe& probe = *static_cast<BoxZoomProbe*>(user);
App& app = *probe.app;
const plot::Rect frame = app.rows->figure().plot(0u).selection_rect();
const plot::Range now = boxzoom_axis(app);
std::fprintf(stderr,
"[boxzoom-probe] closed: frame=%.0fx%.0f gone=%d axis_after=[%.6f..%.6f] zoomed=%d\n",
static_cast<double>(frame.w), static_cast<double>(frame.h),
(frame.w == 0.0f && frame.h == 0.0f) ? 1 : 0, static_cast<double>(now.min),
static_cast<double>(now.max),
(now.span() < probe.before.span() * 0.999) ? 1 : 0);
return G_SOURCE_REMOVE;
}
gboolean boxzoom_probe_release(gpointer user) {
App& app = *static_cast<BoxZoomProbe*>(user)->app;
plot::input::Controller* c = app.area->row_controller(0);
if (c != nullptr) {
const plot::u32 ctrl = static_cast<plot::u32>(plot::input::Mod::Ctrl);
c->on_pointer({300.0f, 160.0f, 0.0f, 0.0f, plot::input::Button::Left, ctrl, false, false, true});
}
g_timeout_add_full(G_PRIORITY_DEFAULT, 80, boxzoom_probe_done, user,
+[](gpointer p) { delete static_cast<BoxZoomProbe*>(p); });
return G_SOURCE_REMOVE;
}
gboolean boxzoom_probe_open(gpointer user) {
BoxZoomProbe& probe = *static_cast<BoxZoomProbe*>(user);
App& app = *probe.app;
plot::Plot& plot = app.rows->figure().plot(0u);
const plot::Rect frame = plot.selection_rect();
const plot::Rect axes = plot.layout().axes;
// The frame is the band the *release* will apply: the width the pointer swept, and the height of the
// axes rather than the ten pixels the pointer also moved -- Ctrl zooms x only.
const bool band = std::fabs(frame.w - 200.0f) < 1.0f && std::fabs(frame.h - axes.h) < 1.0f;
std::fprintf(stderr,
"[boxzoom-probe] open: swept=200x10 frame=%.0f,%.0f %.0fx%.0f axes_h=%.0f band=%d "
"axis_before=[%.6f..%.6f]\n",
static_cast<double>(frame.x), static_cast<double>(frame.y),
static_cast<double>(frame.w), static_cast<double>(frame.h),
static_cast<double>(axes.h), band ? 1 : 0, static_cast<double>(probe.before.min),
static_cast<double>(probe.before.max));
return boxzoom_probe_release(user);
}
gboolean boxzoom_probe(gpointer user) {
App& a = *static_cast<App*>(user);
if (a.area == nullptr || a.rows == nullptr) return G_SOURCE_REMOVE;
plot::input::Controller* c = a.area->row_controller(0);
if (c == nullptr) return G_SOURCE_REMOVE;
auto* probe = new BoxZoomProbe{&a, boxzoom_axis(a)};
const plot::u32 ctrl = static_cast<plot::u32>(plot::input::Mod::Ctrl);
c->on_pointer({100.0f, 150.0f, 0.0f, 0.0f, plot::input::Button::Left, ctrl, true, true, false});
c->on_pointer({300.0f, 160.0f, 200.0f, 10.0f, plot::input::Button::Left, ctrl, true, false, false});
// No destroy here: the *last* step owns the struct, because the middle step schedules it. A destroy
// on every step would free it when the first one returns `G_SOURCE_REMOVE` -- and the step it just
// armed would then read freed memory, which is a segfault in a self-check rather than a failure.
g_timeout_add(80, boxzoom_probe_open, probe);
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_SIDEBAR_MS`: the sidebar's width and whether the toolbar button agrees with it, in
/// both of the split view's states and against a hide that did not come from the button.
///
/// The small-window case is the one a divider position got wrong: with the sidebar's width derived from
/// the window, a window narrower than that number gave it nothing at all. The split view takes its size
/// from its own properties instead, and when there is no room beside the plot it *overlays* it
/// (`collapsed`) rather than shrinking to nothing. Both states are read here, so "the sidebar is never
/// zero" is a number rather than a claim.
gboolean sidebar_probe(gpointer user) {
App& a = *static_cast<App*>(user);
if (a.side_view == nullptr) return G_SOURCE_REMOVE;
// `shown` is the panel's own state and `button` the toolbar's; they must agree, or the button is
// telling the user something the window does not do.
const auto report = [](App& app, const char* what) {
const int side = app.side_box != nullptr ? gtk_widget_get_width(app.side_box) : -1;
const int win = app.window != nullptr ? gtk_widget_get_width(app.window) : -1;
std::fprintf(stderr,
"[sidebar-probe] %-10s window=%d sidebar=%d collapsed=%d shown=%d button=%d(sens=%d)\n",
what, win, side,
adw_overlay_split_view_get_collapsed(ADW_OVERLAY_SPLIT_VIEW(app.side_view)) ? 1 : 0,
adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app.side_view)) ? 1 : 0,
app.sidebar_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.sidebar_toggle)) ? 1 : 0,
app.sidebar_toggle != nullptr && gtk_widget_get_sensitive(app.sidebar_toggle) ? 1 : 0);
};
report(a, "beside");
// The state a window with no room produces: the sidebar goes over the plot, and its width is still its
// own. If this ever reads 0, the sidebar is being given a share of a width that is not there.
adw_overlay_split_view_set_collapsed(ADW_OVERLAY_SPLIT_VIEW(a.side_view), TRUE);
// A hide that did not come from the button -- an overlay swipe, or the split view's own collapsing --
// must not win: the panel is where the button says it is. `shown` and `button` still reading 1 here is
// the point, and reading 0 is the regression (a button that no longer reflects the panel, or a panel
// that slips out from under it).
adw_overlay_split_view_set_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(a.side_view), FALSE);
g_timeout_add(400, +[](gpointer u) -> gboolean {
App& app = *static_cast<App*>(u);
const int side = app.side_box != nullptr ? gtk_widget_get_width(app.side_box) : -1;
std::fprintf(stderr,
"[sidebar-probe] %-10s window=%d sidebar=%d collapsed=%d shown=%d button=%d(sens=%d)\n",
"regained", app.window != nullptr ? gtk_widget_get_width(app.window) : -1, side,
adw_overlay_split_view_get_collapsed(ADW_OVERLAY_SPLIT_VIEW(app.side_view)) ? 1 : 0,
adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app.side_view)) ? 1 : 0,
app.sidebar_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.sidebar_toggle)) ? 1 : 0,
app.sidebar_toggle != nullptr && gtk_widget_get_sensitive(app.sidebar_toggle) ? 1 : 0);
adw_overlay_split_view_set_collapsed(ADW_OVERLAY_SPLIT_VIEW(app.side_view), FALSE);
// Back the way a person would: pressing the button is what records the wish, and the panel follows it.
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.sidebar_toggle), TRUE);
return G_SOURCE_REMOVE;
}, &a);
return G_SOURCE_REMOVE;
}
gboolean prefs_probe(gpointer user) {
App& a = *static_cast<App*>(user);
open_preferences(a);
// `dir` is the directory the dialog is offering and `store` is the one the cache is really using:
// `store` must be `dir` plus the source's folder name, which is what says the row shows the path in
// force rather than a plausible-looking string. `rows=1` is the dialog's rows having resolved from
// the `.ui` -- the one mistake neither the build nor `GtkBuilder` sees is a missing id.
const char* shown =
a.prefs.dir_row != nullptr ? gtk_editable_get_text(GTK_EDITABLE(a.prefs.dir_row)) : "-";
// 4/4: every row's title carries a mnemonic (`_Directory`, `Keep _events`, ...), so a row that does not
// ask for `use-underline` draws the underscore instead of underlining the letter.
std::fprintf(stderr, "[prefs-probe] mnemonics=%d/4\n",
(row_underlines(G_OBJECT(a.prefs.dir_row)) + row_underlines(G_OBJECT(a.prefs.keep_row)) +
row_underlines(G_OBJECT(a.prefs.relative_row)) +
row_underlines(G_OBJECT(a.prefs.theme_row))));
std::fprintf(stderr, "[prefs-probe] opened=%d rows=%d dir=%s store=%s\n",
a.prefs.dialog != nullptr ? 1 : 0, a.prefs.dir_row != nullptr ? 1 : 0,
a.prefs.dir_row != nullptr ? shown : "-",
a.event_store != nullptr ? a.event_store->dir().c_str() : "-");
g_timeout_add(400, +[](gpointer u) -> gboolean {
App& app = *static_cast<App*>(u);
prefs_probe_change(app.prefs);
const Settings back = load_settings();
std::fprintf(stderr, "[prefs-probe] dir=%s keep=%d rel=%d theme=%s live=%d\n",
back.event_cache_dir.c_str(), back.event_cache_max, back.relative_time ? 1 : 0,
back.theme.c_str(),
app.settings.theme == "dark" ? 1 : 0);
// And typing the default back must not *pin* it: the file keeps "unset", so the default still
// follows whatever cache directory the next run has. An empty value here is the whole point --
// a path written into the file is a user's choice, and this one was not a choice.
// Setting the row *is* applying it: there is no Apply button to press.
gtk_editable_set_text(GTK_EDITABLE(app.prefs.dir_row), default_events_dir().c_str());
std::fprintf(stderr, "[prefs-probe] default restored: file='%s'\n",
load_settings().event_cache_dir.c_str());
// The folder chooser, opened from its button and then *cancelled from outside*: a callback that
// lands after its dialog's window is gone is what an async picker invites, and cancelling must
// change nothing at all. The preferences window is closed a beat later, so the cancel callback
// arrives while the form is still intact -- and the null checks in it are what makes the other
// order safe too.
if (app.prefs.browse != nullptr) {
g_signal_emit_by_name(app.prefs.browse, "clicked");
g_timeout_add(600, +[](gpointer u) -> gboolean {
App& app = *static_cast<App*>(u);
// Cancel through the chooser's own cancellable: a user's Cancel button lands in the same
// callback with the same null answer, and it is the *cancel* path a picker invites -- a
// callback after the thing it belongs to is gone.
if (app.prefs.browse_cancel != nullptr) g_cancellable_cancel(app.prefs.browse_cancel);
std::fprintf(stderr, "[prefs-probe] browse: cancelled=%d icon_ok=%d field='%s'\n",
app.prefs.browse_cancel != nullptr ? 1 : 0,
app.prefs.browse != nullptr &&
gtk_icon_theme_has_icon(
gtk_icon_theme_get_for_display(gtk_widget_get_display(app.prefs.browse)),
"folder-open-symbolic")
? 1
: 0,
app.prefs.dir_row != nullptr
? gtk_editable_get_text(GTK_EDITABLE(app.prefs.dir_row))
: "-");
g_timeout_add(600, +[](gpointer u2) -> gboolean {
prefs_close(static_cast<App*>(u2)->prefs);
return G_SOURCE_REMOVE;
}, &app);
return G_SOURCE_REMOVE;
}, &app);
return G_SOURCE_REMOVE;
}
prefs_close(app.prefs);
return G_SOURCE_REMOVE;
}, &a);
return G_SOURCE_REMOVE;
}
void layout_probe_next(App& a, int step, guint delay_ms) {
a.dbg_layout_step = step;
g_timeout_add(delay_ms, layout_probe_step, &a);
}
gboolean layout_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_layout_step) {
case 0:
// Through the menu's toggle, so the probe exercises the handler the user reaches.
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->layout_toggle), true);
layout_probe_report(*a, "editor on");
layout_probe_next(*a, 1, 200);
break;
case 1:
layout_probe_report(*a, "placed");
layout_click(layout_button(*a, true, 0));
layout_probe_next(*a, 2, 200);
break;
case 2:
// What row 0 held must now be on row 1: an insert moves rows, it does not renumber.
layout_probe_report(*a, "add above 0");
layout_click(layout_button(*a, false, 0));
layout_probe_next(*a, 3, 200);
break;
case 3:
layout_probe_report(*a, "remove 0");
layout_click(layout_button(*a, true, a->rows->row_count()));
layout_probe_next(*a, 4, 200);
break;
case 4:
layout_probe_report(*a, "add at end");
// Leave through the editor's own Done button, which is the way out a user has in
// front of them; the toolbar toggle is the same state and stays exercised by the
// `on` step above.
layout_probe_report(*a, "before done");
layout_click(a->layout_done_button);
layout_probe_next(*a, 5, 200);
break;
default:
// Nothing left on the overlay: the editor's buttons exist only while it is up.
layout_probe_report(*a, "editor off");
break;
}
return G_SOURCE_REMOVE;
}
gboolean enable_all_probe(gpointer user) {
App* a = static_cast<App*>(user);
// In one call, the way a restored session does it: enabling them one at a time re-plans
// the buffer per signal, so a probe that did that would not be measuring the path a real
// start takes (and `[gen]` would read a burst that the application no longer produces).
{
std::vector<std::size_t> all(a->model->signals().size());
for (std::size_t i = 0; i < all.size(); ++i) all[i] = i;
a->model->set_signals_enabled(all, true);
}
int row = 0;
int slot = 0;
for (std::size_t c = 0; c < a->model->channel_count(); ++c) {
if (row >= a->rows->row_count()) break;
const ChannelInfo& ch = a->model->channels()[c];
a->rows->assign(row, slot, ch.name, ch.name,
signal_color_index(a->model->signals(), ch.signal));
if (++slot >= RowSet::kSlotsPerRow) {
slot = 0;
++row;
}
}
a->relayout_pending = true;
// The startup notice comes with the reading, because it is the window's only answer to "why is
// nothing happening yet": it is set as the window is presented, before the first CONFIG can take
// the model's mutex, and it names the buffer being planned. An empty `notice` here is the window
// that used to come up silent while a large buffer was allocated.
std::fprintf(stderr, "[enable-all-probe] %zu channel(s) enabled and assigned to plots, notice='%s'\n",
a->model->channel_count(), a->notice.c_str());
std::fflush(stderr);
// Then switch one off through its own switch in the signal list: a disabled signal must leave the plots,
// not sit there drawing nothing in a slot a live one could take. Late enough that a math probe armed behind
// this one has had its graphs installed -- the dependants are the point of the second line.
g_timeout_add(2000, +[](gpointer u) -> gboolean {
App* app = static_cast<App*>(u);
if (app->model->channels().empty() || app->signal_list == nullptr) return G_SOURCE_REMOVE;
const std::size_t signal = app->model->channels().front().signal;
const std::string name = app->model->channels().front().name;
GtkWidget* tick = nullptr;
for (GtkWidget* row_widget = gtk_widget_get_first_child(app->signal_list);
row_widget != nullptr && tick == nullptr;
row_widget = gtk_widget_get_next_sibling(row_widget)) {
// The tick sits a box inside the row, which is the shape `signal_tick_visible()` walks too.
for (GtkWidget* box = gtk_widget_get_first_child(row_widget); box != nullptr && tick == nullptr;
box = gtk_widget_get_next_sibling(box)) {
for (GtkWidget* w = gtk_widget_get_first_child(box); w != nullptr;
w = gtk_widget_get_next_sibling(w)) {
if (!GTK_IS_CHECK_BUTTON(w)) continue;
if (GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(w), "udpscope-signal")) != signal) continue;
tick = w;
break;
}
}
}
if (tick == nullptr) {
std::fprintf(stderr, "[enable-all-probe] no acquire tick found for signal %zu\n", signal);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gtk_check_button_set_active(GTK_CHECK_BUTTON(tick), FALSE);
int left = 0;
for (int r = 0; r < app->rows->row_count(); ++r) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (app->rows->row(r).signal_name[static_cast<std::size_t>(s)] == name) ++left;
}
}
// Which *math* channels the disabled signal took with it: a derived channel cannot be computed from a
// signal nobody acquires, so the ones that read it follow it off -- and their traces go too.
std::string off_list;
for (const MathChannel& m : app->math) {
if (m.enabled) continue;
if (!off_list.empty()) off_list += ",";
off_list += m.name;
}
// Both halves: the channels that read it went off, and the ones that do not are still on.
std::string on_list;
for (const MathChannel& m : app->math) {
if (!m.enabled) continue;
if (!on_list.empty()) on_list += ",";
on_list += m.name;
}
std::fprintf(stderr,
"[enable-all-probe] switched '%s' off: traces_left=%d (want 0) math_off=[%s] "
"math_on=[%s] notice='%s'\n",
name.c_str(), left, off_list.c_str(), on_list.c_str(), app->notice.c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}, a);
return G_SOURCE_REMOVE;
}
gboolean disarm_probe_step2(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// The Arm/Disarm button is in the trigger panel, not the title bar: search the
// whole window, and say so when it is not there rather than claiming a click.
GtkWidget* button = find_labelled_button(a->window, "Disarm");
if (button == nullptr) button = find_labelled_button(a->window, "Arm");
std::fprintf(stderr, "[disarm-probe] button %s\n", button ? "found, clicking" : "NOT FOUND");
if (button != nullptr) g_signal_emit_by_name(button, "clicked");
a->dbg_latches_at_disarm = a->model->trigger_latches().load();
std::fprintf(stderr, "[disarm-probe] %llu events so far, armed now=%d\n",
(unsigned long long)a->dbg_latches_at_disarm, int(a->model->trigger().armed));
a->dbg_capturing_at_disarm = a->model->trigger_pending();
a->dbg_seq_at_disarm = a->model->event_seq();
g_timeout_add(600, disarm_probe_step3, a);
return G_SOURCE_REMOVE;
}
gboolean disarm_probe_step3(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const DisplayMode d = a->model->display_mode();
auto snap = a->model->event();
RowSet::Row& r = a->rows->row(0);
const plot::Range axis = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{0.0, 0.0};
std::fprintf(stderr,
"[disarm-probe] was_capturing=%d display=%s seq=%llu->%llu events=%llu->%llu "
"snap=[%.4f..%.4f] n=%llu axis=[%.4f..%.4f]\n",
int(a->dbg_capturing_at_disarm),
d == DisplayMode::Held ? "event(frozen)"
: d == DisplayMode::Live ? "live"
: d == DisplayMode::Capturing ? "live(capturing)"
: "nothing",
(unsigned long long)a->dbg_seq_at_disarm,
(unsigned long long)a->model->event_seq(),
(unsigned long long)a->dbg_latches_at_disarm,
(unsigned long long)a->model->trigger_latches().load(),
snap ? -snap->pre_s : 0.0, snap ? snap->post_s : 0.0,
(unsigned long long)(snap ? snap->stamps.size() : 0), double(axis.min),
double(axis.max));
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean disarm_probe_wait(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
++a->dbg_disarm_polls;
if (a->model->trigger_pending()) ++a->dbg_disarm_in_capture;
// 300 ms into the capture: post-trigger data worth keeping, well short of the latch.
// The 10 s cap is for a stream that never triggers -- the probe still has to report.
if (a->dbg_disarm_in_capture >= 6 || a->dbg_disarm_polls > 200) {
if (a->dbg_disarm_in_capture == 0) {
std::fprintf(stderr, "[disarm-probe] no capture opened in 10 s; disarming anyway\n");
std::fflush(stderr);
}
disarm_probe_step2(a);
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
}
gboolean disarm_probe_step1(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->trigger_toggle != nullptr)
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
g_timeout_add(50, disarm_probe_wait, a);
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_FORCE_MS`: **Force** takes one acquisition at the present moment, with the
/// pre/post window the panel is set to, without waiting for the condition to hold.
///
/// The two legs are the two things the button has to get right, and they need different geometry:
///
/// - **Leg A** presses it where the ring *can* satisfy the request -- a 2 s window at 50 % pre asks
/// for 1 s of history before the edge and 1 s after it, and the probe has let the ring fill past
/// that -- so the whole ordinary path is asserted: a latch count that moves by exactly one,
/// `display=event(frozen)`, and a snapshot spanning what was asked for.
/// - **Leg B** presses it where the ring *cannot*: a 100 s window at 99 % pre asks for 99 s of
/// history, and no run this short has it. That is the only state in which "pinned to the
/// snapshot, not to the request" is a reading rather than a coincidence, and the axis must come
/// out at the snapshot's own `[-pre_got..+post_got]`, a few seconds wide, not at the requested
/// hundred.
///
/// The condition is set to a level the mock never reaches (9 V against +/-1 V signals), so the
/// engine cannot latch on its own and every latch the probe sees is the force's -- which is also
/// what makes the request *unmet* rather than merely untested: without the button, nothing would be
/// captured at all. The `during=` reading is the axis while the forced capture was filling: the
/// *requested* window, which is what a capture is framed on before the latch re-pins it.
///
/// Run it on its own (the trigger must start off, which is the state whose button reading is half
/// the point) against the plain mock, with enough time for both legs: `UDPSCOPE_PROBE_FORCE_MS=3000
/// UDPSCOPE_EXIT_AFTER_MS=14000`.
void force_probe_report(App& a, const char* leg) {
const DisplayMode d = a.model->display_mode();
auto snap = a.model->event();
const RowSet::Row& r = a.rows->row(0);
const plot::Range axis = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{0.0, 0.0};
// `pre_s`/`post_s` are the snapshot's own split as *magnitudes* (`[-pre_s..+post_s]` is the window
// it holds), which is the same pair the frame tick states the pin from -- so the axis must equal
// `[-pre_s, +post_s]`.
const double got_pre = snap ? snap->pre_s : 0.0;
const double got_post = snap ? snap->post_s : 0.0;
const bool pin_is_snapshot = snap != nullptr &&
std::abs(double(axis.min) + got_pre) < 1.0e-3 &&
std::abs(double(axis.max) - got_post) < 1.0e-3;
// "What was asked for" is satisfied to within a stored interval (the cut's ends are samples), and
// leg B *must* fail it: the request is what the ring could not reach back to.
const bool matches_request = std::abs(got_pre - a.dbg_force_want_pre) < 0.01 &&
std::abs(got_post - a.dbg_force_want_post) < 0.01;
std::fprintf(stderr,
"[force-probe] leg %s want=[-%.4f..%.4f] during=[%.4f..%.4f] snap=[-%.4f..%.4f] "
"n=%llu axis=[%.4f..%.4f] latches=%llu->%llu display=%s matches_request=%d "
"pin_is_snapshot=%d\n",
leg, a.dbg_force_want_pre, a.dbg_force_want_post, a.dbg_force_capture_lo,
a.dbg_force_capture_hi, got_pre, got_post,
static_cast<unsigned long long>(snap ? snap->stamps.size() : 0), double(axis.min),
double(axis.max),
static_cast<unsigned long long>(a.dbg_force_latches_before),
static_cast<unsigned long long>(a.model->trigger_latches().load()),
d == DisplayMode::Held ? "event(frozen)"
: d == DisplayMode::Live ? "live"
: d == DisplayMode::Capturing ? "live(capturing)"
: "nothing",
int(matches_request), int(pin_is_snapshot));
std::fflush(stderr);
}
gboolean force_probe_poll(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
++a->dbg_force_polls;
if (a->model->trigger_pending() && !a->dbg_force_capture_seen) {
// The sweep is filling: the axis is the *requested* window now (a capture is framed on what was
// asked for, there being no snapshot to measure yet), and this is the one moment it is readable.
a->dbg_force_capture_seen = 1;
const RowSet::Row& r = a->rows->row(0);
const plot::Range ax = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{0.0, 0.0};
a->dbg_force_capture_lo = ax.min;
a->dbg_force_capture_hi = ax.max;
}
const std::uint64_t latches = a->model->trigger_latches().load();
if (latches <= a->dbg_force_latches_before && a->dbg_force_polls < 200) return G_SOURCE_CONTINUE;
if (latches <= a->dbg_force_latches_before) {
std::fprintf(stderr, "[force-probe] no latch within 10 s of the force (leg %d)\n",
a->dbg_force_leg);
std::fflush(stderr);
}
force_probe_report(*a, a->dbg_force_leg == 1 ? "A" : "B");
a->dbg_force_polls = 0;
if (a->dbg_force_leg == 1) {
// Leg B's geometry: a window the ring cannot cover back to the edge, so the cut is clamped and
// the pin has to come from the snapshot rather than from the ask. 99 % is the panel's ceiling
// (a trigger with no post side at all is not a capture), which leaves 1 s of post window -- the
// leg's own latch delay, and the reason the press is a step of its own.
a->dbg_force_leg = 2;
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_pre_spin), 99.0);
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->window_unit_drop), 2);
gtk_editable_set_text(GTK_EDITABLE(a->window_entry), "100");
on_window_entry_activate(GTK_ENTRY(a->window_entry), a);
g_timeout_add(400, force_probe_press, a);
}
return G_SOURCE_REMOVE;
}
gboolean force_probe_press(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
a->dbg_force_latches_before = a->model->trigger_latches().load();
a->dbg_force_polls = 0;
a->dbg_force_capture_seen = 0;
a->dbg_force_capture_lo = 0.0;
a->dbg_force_capture_hi = 0.0;
{
// The request the engine will cut by, taken from the producer's own expression rather than
// recomputed here, so the reading compares the snapshot against what was actually asked for.
std::lock_guard<std::mutex> lock(a->model->mutex());
const double span = std::max(1.0e-3, a->model->event_span());
a->dbg_force_want_post = a->model->trigger().post_span_s(span);
a->dbg_force_want_pre = span - a->dbg_force_want_post;
}
// The button's own click, as every other probe drives a control. `label_found` was checked in
// step 2: this is the widget the window would press.
if (a->force_button != nullptr) g_signal_emit_by_name(a->force_button, "clicked");
g_timeout_add(50, force_probe_poll, a);
return G_SOURCE_REMOVE;
}
gboolean force_probe_step2(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// The trigger-off state, one frame after step 1 forced it: the *sensitivity* is the rule
// `sync_arm_buttons()` states (a trigger that is off has no engine to force), the *width* is the
// allocation the layout gave the button, and a width read in the tick it was last stated is 0 --
// the trap the compact arm control fell into. `label_found` is the same reading at the user's
// level: the button the window shows under the word "Force" is this one.
a->dbg_force_off_sensitive =
a->force_button != nullptr && gtk_widget_get_sensitive(a->force_button) ? 1 : 0;
a->dbg_force_off_visible =
a->force_button != nullptr && gtk_widget_is_visible(a->force_button) ? 1 : 0;
a->dbg_force_off_width = a->force_button != nullptr ? gtk_widget_get_width(a->force_button) : -1;
a->dbg_force_arm_width = a->arm_button != nullptr ? gtk_widget_get_width(a->arm_button) : -1;
a->dbg_force_label_found =
a->force_button != nullptr && find_labelled_button(a->window, "Force") == a->force_button ? 1
: 0;
// The geometry both legs start from, set through the real widgets: a level no mock signal
// reaches, so nothing latches on its own (every latch the probe sees is then the force's, which
// is also what makes the request *unmet* rather than merely untested), and a 2 s window at 50 %
// pre, which leg A's ring can fill. The window write may re-plan the buffer and clear the
// history, which is why the press is 2.5 s later rather than in this tick.
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin), 9.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_pre_spin), 50.0);
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->window_unit_drop), 2);
gtk_editable_set_text(GTK_EDITABLE(a->window_entry), "2");
on_window_entry_activate(GTK_ENTRY(a->window_entry), a);
// A signal has to be *acquired* for the trigger to have a channel at all (the panel lists the
// acquired ones, and on a fresh source none is switched on): the mock's `sine`, which with the
// level above is a trigger nothing crosses. Set after the level, which is what marks it
// hand-edited -- a rebuild re-defaults an untouched level to the middle of the declared range.
a->model->set_signal_enabled(1, true);
if (a->trigger_toggle != nullptr)
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
a->dbg_force_on_sensitive =
a->force_button != nullptr && gtk_widget_get_sensitive(a->force_button) ? 1 : 0;
a->dbg_force_on_width = a->force_button != nullptr ? gtk_widget_get_width(a->force_button) : -1;
std::fprintf(stderr,
"[force-probe] button off: sensitive=%d visible=%d w=%d | on: sensitive=%d w=%d | "
"arm w=%d | label_found=%d\n",
a->dbg_force_off_sensitive, a->dbg_force_off_visible, a->dbg_force_off_width,
a->dbg_force_on_sensitive, a->dbg_force_on_width, a->dbg_force_arm_width,
a->dbg_force_label_found);
std::fflush(stderr);
a->dbg_force_leg = 1;
g_timeout_add(2500, force_probe_press, a);
return G_SOURCE_REMOVE;
}
gboolean force_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->trigger_toggle == nullptr) return G_SOURCE_REMOVE;
// Trigger mode *off* first, driven through the toggle as a user would: the state whose reading
// step 2 takes, and a state this probe states rather than assumes -- a session file remembers the
// trigger, so a second run against the same source would otherwise start armed and never read the
// off case at all.
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), FALSE);
g_timeout_add(300, force_probe_step2, a);
return G_SOURCE_REMOVE;
}
void pause_probe_report(App& a, const char* what) {
double bounds_lo = 0.0;
double bounds_hi = 0.0;
std::size_t event_size = 0;
std::size_t ring_produced = 0;
{
std::lock_guard<std::mutex> lock(a.model->mutex());
if (!a.model->channels().empty()) ring_produced = a.model->live().values(0).produced();
}
if (const EventSeries* ev = a.rows->row(0).slots[0].bound_event()) {
event_size = ev->size();
const plot::Range b = ev->x_bounds();
bounds_lo = b.min;
bounds_hi = b.max;
}
// The axis beside the data, because those two disagreeing is the whole failure: the frozen view
// is the *snapshot*, and a pin taken from the requested window instead of from the snapshot left
// the difference as an empty margin. `frozen_x` must equal `axis` while paused.
plot::Range axis{0.0, 0.0};
if (a.rows->row_count() > 0 && a.rows->row(0).plot != nullptr) {
const RowSet::Row& r0 = a.rows->row(0);
axis = r0.plot->x_range(r0.axis_x);
}
// The bar's own string, because "how long have I been paused" is a *status* field and the
// elapsed figure is the one part of it that no other line can show: the exit dump's
// `status bar:` is read live and the two `mode ...` lines are the live and triggered states.
// `skips` is the render gate (see the end of `tick()`), cumulative over the session: it must be
// 0 on the `live` report and have grown by the `paused` one, which is the gate being a number
// rather than a claim. A live view that skips frames is the gate's condition inverted; a paused
// view that skips none is the gate not engaging at all.
std::fprintf(stderr,
"[pause-probe] %-9s paused=%d ring_produced=%zu event_size=%zu "
"frozen_x=[%.6f..%.6f] axis=[%.6f..%.6f] skips=%d glance=[%s]\n",
what, a.model->paused() ? 1 : 0, ring_produced, event_size, bounds_lo, bounds_hi,
double(axis.min), double(axis.max), a.dbg_render_skips,
a.dbg_glance_last.c_str());
std::fflush(stderr);
}
gboolean pause_probe(gpointer user) {
App* a = static_cast<App*>(user);
// Through the button, so the *view* snapshot and the axis pin run: pausing is a display
// state, not only an acquisition one.
pause_probe_report(*a, "live");
if (a->pause_button != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->pause_button), TRUE);
} else {
a->acq->set_paused(true);
a->model->set_paused(true);
}
a->dbg_axes_x_min = 1 << 20;
a->dbg_axes_x_max = -(1 << 20);
a->dbg_axes_w_min = 1 << 20;
a->dbg_axes_w_max = -(1 << 20);
a->dbg_axes_w_prev = -1;
a->dbg_axes_w_changes = 0;
a->dbg_axes_w_change_frame = -1;
a->dbg_axes_w_change_side = -1;
a->dbg_vp_w_min = 1 << 20;
a->dbg_vp_w_max = -(1 << 20);
// Reported again a second later (the probe is re-armed below) and once after the resume: the
// ring count must have grown while the frozen window did not move.
static bool reported_paused = false;
if (!reported_paused) {
reported_paused = true;
g_timeout_add(1200, +[](gpointer user) {
App* app = static_cast<App*>(user);
pause_probe_report(*app, "paused");
return G_SOURCE_REMOVE;
}, a);
g_timeout_add(1600, +[](gpointer user) {
App* app = static_cast<App*>(user);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->pause_button), FALSE);
return G_SOURCE_REMOVE;
}, a);
g_timeout_add(2200, +[](gpointer user) {
App* app = static_cast<App*>(user);
pause_probe_report(*app, "resumed");
return G_SOURCE_REMOVE;
}, a);
}
a->dbg_xtick_min = 1 << 20;
a->dbg_xtick_max = -(1 << 20);
a->dbg_gutter_min = 1 << 20;
a->dbg_gutter_max = -(1 << 20);
a->dbg_axes_frames = 0;
a->dbg_x_desync = 0;
return G_SOURCE_REMOVE;
}
gboolean export_probe(gpointer user) {
App* a = static_cast<App*>(user);
const char* env = g_getenv("UDPSCOPE_EXPORT_PATH");
const std::string prefix = env != nullptr ? env : "/tmp/udpscope-export";
const RowSet::Row& r0 = a->rows->row(0);
const plot::Range axis = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{0.0, 0.0};
const ExportTable table = collect_export(*a->model, *a->rows);
std::fprintf(stderr,
"[export-probe] mode=%s axis=[%.6f..%.6f] rows=%zu cols=%zu time=[%.6f..%.6f] "
"abs=[%.6f..%.6f]\n",
a->model->trigger_enabled() && a->model->event() != nullptr ? "event" : "live",
axis.min, axis.max, table.rows(), table.columns.size(),
table.rows() != 0 ? table.time.front() : 0.0,
table.rows() != 0 ? table.time.back() : 0.0,
table.rows() != 0 ? table.abs_seconds.front() : 0.0,
table.rows() != 0 ? table.abs_seconds.back() : 0.0);
for (const ExportTable::Column& col : table.columns) {
std::fprintf(stderr, "[export-probe] column '%s' n=%zu\n", col.name.c_str(), col.values.size());
}
for (const char* ext : {".npy", ".csv", ".mf3"}) {
const std::string path = prefix + ext;
std::string error;
const bool ok = write_export(path, table, error);
long bytes = -1;
if (ok) {
if (std::FILE* f = std::fopen(path.c_str(), "rb"); f != nullptr) {
std::fseek(f, 0, SEEK_END);
bytes = std::ftell(f);
std::fclose(f);
}
}
std::fprintf(stderr, "[export-probe] %s %s bytes=%ld%s%s\n", path.c_str(),
ok ? "written" : "FAILED", bytes, ok ? "" : " error=", ok ? "" : error.c_str());
}
// What the `.mf3` was handed, in the shape the file stores it: the master and the relative
// axis are channels too, and `record` is the bytes of one interleaved record. So the size the
// line above printed is `272 + comment + rows*record + 28 + channels*274 + 30`, and the units
// are the ones ScryLab reads each signal's unit from -- the CONFIG's, per column.
std::string units;
for (const char* u : {"s", "s"}) units += std::string(units.empty() ? "" : " ") + "'" + u + "'";
for (const ExportTable::Column& col : table.columns) {
units += std::string(units.empty() ? "" : " ") + "'" + col.unit + "'";
}
std::fprintf(stderr,
"[export-probe] mdf master=time master_unit=s channels=%zu record=%zu units=[%s]\n",
table.columns.size() + 2, 16 + 4 * table.columns.size(), units.c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean exit_probe_step1(gpointer user) {
App* a = static_cast<App*>(user);
// Record the panel states before the toggles are exercised.
a->dbg_trigger_sensitive_default = gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0;
// The two [edit] buttons, in each mode: the icon is the only thing that says which mode the pane is
// in, and an icon the theme does not have draws nothing at all.
const auto pane_icon = [](GtkWidget* b) {
const char* n = b != nullptr && GTK_IS_BUTTON(b) ? gtk_button_get_icon_name(GTK_BUTTON(b)) : nullptr;
return std::string(n != nullptr ? n : "-");
};
a->dbg_pane_edit_icon = pane_icon(a->trigger_edit);
if (a->trigger_edit != nullptr) {
g_signal_emit_by_name(a->trigger_edit, "clicked"); // into configuration mode
a->dbg_pane_view_icon = pane_icon(a->trigger_edit);
g_signal_emit_by_name(a->trigger_edit, "clicked"); // and back to the view
}
a->dbg_trigger_locked = a->trigger_locked ? 1 : 0;
a->dbg_signals_locked = a->signals_locked ? 1 : 0;
a->dbg_trigger_body_visible =
a->trigger_body != nullptr && gtk_widget_get_visible(a->trigger_body) ? 1 : 0;
// The summary's text comes from the sync that wrote it: the container is a grid of key/value rows,
// and asking it for a label's text was itself a critical.
a->dbg_trigger_summary_early = a->dbg_trigger_summary; // as found, before this probe touches it
a->dbg_arm_sensitive = a->arm_button != nullptr && gtk_widget_get_sensitive(a->arm_button) ? 1 : 0;
// The compact line's arm control must be *absent* here: the trigger is off, the line beside it reads
// "stream (trigger off)", and a trigger that is off has no engine to arm. Read before the toggle, which
// is where that absence is the assertion -- and it is the pair to the reading in step 2.
a->dbg_summary_arm_off =
a->trigger_summary_arm != nullptr && gtk_widget_get_visible(a->trigger_summary_arm) ? 1 : 0;
a->dbg_panel_switch_live =
a->trigger_switch != nullptr && gtk_widget_get_sensitive(a->trigger_switch) &&
a->cursors_switch != nullptr && gtk_widget_get_sensitive(a->cursors_switch)
? 1 : 0;
if (a->trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
}
g_timeout_add(1700, exit_probe_step2, a);
return G_SOURCE_REMOVE;
}
void cursors_probe_report(App& a, const char* what) {
static const char* const kModes[] = {"vertical", "horizontal", "both", "mix"};
const int mode = static_cast<int>(a.rows->cursor_mode());
// The table as a user reads it: the time row first, then one row per line, each cell's
// own text -- which is the assertion (the app's formatting, not the probe's).
std::string t_text;
std::string lines;
int n = 0;
// The row's own name too, not just its three cells: a `mix` line names the trace it reads,
// and a line that should not exist at all is only recognisable by *which* trace it claims.
const auto cells = [](const App::CursorLine& line) {
std::string who = line.label != nullptr ? gtk_label_get_text(GTK_LABEL(line.label)) : "";
return who + "=" + gtk_label_get_text(GTK_LABEL(line.a)) + " " +
gtk_label_get_text(GTK_LABEL(line.b)) + " " +
gtk_label_get_text(GTK_LABEL(line.d));
};
for (const App::CursorLine& line : a.cursors_lines) {
if (line.a == nullptr) continue;
if (line.slot == -2) {
t_text = cells(line);
continue;
}
if (n != 0) lines += " | ";
lines += cells(line);
++n;
}
// The `mix` crossing markers: how many are on screen and where the first is. They are
// what the mode promises on the plot itself, so they are what the probe asserts.
double mx = 0.0;
double my = 0.0;
const bool have_mark = a.rows->crossing_at(0, 0, false, mx, my);
std::fprintf(stderr,
"[cursors-probe] %-14s mode=%-10s on=%d pane_sensitive=%d t0=%.6f t1=%.6f dt=%.6f "
"T=[%s] lines=%d marks=%d [%s]\n",
what, kModes[mode], a.rows->cursors_visible() ? 1 : 0,
a.cursors_body != nullptr && gtk_widget_get_sensitive(a.cursors_body) ? 1 : 0,
a.rows->cursor_a_x(), a.rows->cursor_b_x(),
a.rows->cursor_b_x() - a.rows->cursor_a_x(), t_text.c_str(), n,
a.rows->crossings_visible(), lines.c_str());
if (have_mark) {
std::fprintf(stderr, "[cursors-probe] %-14s first mark at x=%.6f y=%.6g\n", what, mx, my);
}
std::fflush(stderr);
}
void fit_probe_report(App& a, const char* what) {
const RowSet::Row& r0 = a.rows->row(0);
const plot::Range x = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{};
const plot::Range y = r0.plot != nullptr ? r0.plot->y_range(r0.axis_y) : plot::Range{};
// `window` and `event_span` are the assertion that a fit did *not* restate the time scale:
// both are written only by `apply_time_window()`, so they must hold the typed value on every
// line, with `span` the reading that moves -- down on the zoom, back up on the fit.
std::fprintf(stderr,
"[fit-probe] %-10s pinned=%d window=%.6f event_span=%.6f x=[%.6f..%.6f] span=%.6f "
"y=[%.6f..%.6f]\n",
what, a.rows->window_pinned() ? 1 : 0, a.rows->time_window(), a.model->event_span(),
x.min, x.max, x.span(), y.min, y.max);
std::fflush(stderr);
}
gboolean timelabels_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const char* what = "at 2 s";
switch (a->dbg_timelabels_step) {
case 0:
apply_time_window(*a, 2.0);
break;
case 1:
what = "at 500 ms";
apply_time_window(*a, 0.5);
break;
case 2:
what = "at 50 ms";
apply_time_window(*a, 0.05);
break;
default:
// Ends *in* a sub-second window: the dump runs after the last paint, so it is the
// race-free place to read whether the zero tick really sits on the left edge.
what = "final 20 ms";
apply_time_window(*a, 0.02);
break;
}
++a->dbg_timelabels_step;
if (a->dbg_timelabels_step <= 4) {
g_timeout_add(400, timelabels_probe_step, a);
}
// Report the *previous* step's state: the labels are laid out on the frame after the window
// changes, so reading them in the same call would report the old form.
const RowSet::Row& r0 = a->rows->row(0);
const RowSet::Row& last = a->rows->row(a->rows->row_count() - 1);
std::string ticks;
std::string note;
// Whether *every* major label on the axis is a different string. That, and not a span
// threshold, is what the relative form exists to buy: two adjacent ticks reading `12.3s` are
// not labels. It is therefore the one assertion that holds whatever the stream's uptime is --
// which decides where the switch actually falls, since the label resolution is a function of
// the axis' magnitude.
bool distinct = true;
if (last.plot != nullptr) {
int n = 0;
std::vector<std::string> seen;
for (const plot::Tick& t : last.plot->x_axis(last.axis_x).ticks) {
if (!t.major) continue;
if (std::find(seen.begin(), seen.end(), t.label) != seen.end()) distinct = false;
seen.emplace_back(t.label);
if (n++ < 4) {
if (n != 1) ticks += ",";
ticks += t.label;
}
}
note = last.plot->x_note();
}
double first_value = 0.0;
plot::Range shown{};
if (last.plot != nullptr) {
for (const plot::Tick& t : last.plot->x_axis(last.axis_x).ticks) {
if (t.major) {
first_value = t.value;
break;
}
}
shown = last.plot->x_range(last.axis_x);
}
std::fprintf(stderr,
"[timelabels-probe] %-12s relative=%d distinct=%d window=%.6g origin=%.6f "
"note=[%s] ticks=[%s] first_value=%.6f first_rel=%.6f range=[%.6f..%.6f]\n",
what, a->rows->time_labels_relative() ? 1 : 0, distinct ? 1 : 0,
a->rows->time_window(),
a->rows->time_labels_origin(), note.c_str(), ticks.c_str(), first_value,
first_value - (last.plot != nullptr ? last.plot->x_axis_origin(last.axis_x) : 0.0),
shown.min, shown.max);
std::fflush(stderr);
(void)r0;
return G_SOURCE_REMOVE;
}
void fit_probe_zoom_in(App& a) {
RowSet::Row& r0 = a.rows->row(0);
if (r0.plot == nullptr) return;
const plot::Range x = r0.plot->x_range(r0.axis_x);
const double mid = x.min + 0.5 * x.span();
const double half = 0.125 * x.span();
r0.plot->set_x_range(r0.axis_x, {mid - half, mid + half});
}
gboolean fit_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_fit_step) {
case 0:
// Move the y scale off the data's range first, so "fit x leaves y alone" is a statement
// with something to be wrong about -- and set the window through the control's own path,
// since that is the only thing allowed to, and its value is what both fits must preserve.
a->rows->set_y_range(0, -100.0, 100.0);
// Not while pinned: `apply_time_window()` unpins, and the pinned branch of the fit is the
// other half of what this probe is for. Timed inside a pause (`UDPSCOPE_PROBE_PAUSE_MS`)
// it takes the window the pause froze and reports `pinned=1`.
if (!a->rows->window_pinned()) apply_time_window(*a, 2.0);
fit_probe_report(*a, "start");
g_timeout_add(400, fit_probe_step, a);
a->dbg_fit_step = 1;
break;
case 1:
fit_probe_zoom_in(*a);
g_timeout_add(400, fit_probe_step, a);
a->dbg_fit_step = 2;
break;
case 2:
// A quarter of the window: what the fits have to undo, and the reading that proves the
// `span=` on the fit lines is the fit's work rather than a window nothing ever left.
fit_probe_report(*a, "zoomed");
// The handlers themselves, not `RowSet::fit_x()` alone: the window the fit must *not*
// move is the app's, and the notice and the control are the handler's half.
on_fit_x_clicked(nullptr, a);
g_timeout_add(400, fit_probe_step, a);
a->dbg_fit_step = 3;
break;
case 3:
fit_probe_report(*a, "fit x");
fit_probe_zoom_in(*a);
on_fit_clicked(nullptr, a);
g_timeout_add(400, fit_probe_step, a);
a->dbg_fit_step = 4;
break;
default:
fit_probe_report(*a, "fit all");
break;
}
return G_SOURCE_REMOVE;
}
void cursors_probe_next(App& a, int step, guint delay_ms) {
a.dbg_cursors_step = step;
g_timeout_add(delay_ms, cursors_probe_step, &a);
}
gboolean cursors_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_cursors_step) {
case 0:
// Through the toggle the toolbar offers, which is what also reveals the pane.
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->cursors_toggle), true);
cursors_probe_report(*a, "on");
cursors_probe_next(*a, 1, 300);
break;
case 1:
a->rows->set_cursor_mode(RowSet::CursorMode::Horizontal);
cursors_probe_next(*a, 2, 300);
break;
case 2:
cursors_probe_report(*a, "horizontal");
a->rows->set_cursor_mode(RowSet::CursorMode::Both);
cursors_probe_next(*a, 3, 300);
break;
case 3:
cursors_probe_report(*a, "both");
a->rows->set_cursor_mode(RowSet::CursorMode::Mix);
cursors_probe_next(*a, 4, 300);
break;
case 4:
cursors_probe_report(*a, "mix");
cursors_probe_next(*a, 5, 300);
break;
case 5: {
// A plot-level move with *no gesture behind it*: the sync reads a row only while its
// controller holds a cursor, so this must be ignored. That is the invariant the
// "drag it and it springs back" defect broke -- the tick adopted a row's own echo, and a
// row that had not been re-written yet adopted the stale shared value straight back.
RowSet::Row& r0 = a->rows->row(0);
a->dbg_gestureless_before = a->rows->cursor_a_x();
if (r0.plot != nullptr) {
const plot::Range xr = r0.plot->x_range(r0.axis_x);
r0.plot->set_cursor_x(r0.cursor_a, xr.min + 0.20 * xr.span(), plot::CursorPlacement::Free);
}
cursors_probe_next(*a, 6, 300);
break;
}
case 6: {
const double after = a->rows->cursor_a_x();
std::fprintf(stderr,
"[cursors-probe] %-14s plot move with no gesture: stored %.6f -> %.6f "
"ignored=%d\n",
"gestureless", a->dbg_gestureless_before, after,
std::fabs(after - a->dbg_gestureless_before) < 1e-12 ? 1 : 0);
std::fflush(stderr);
cursors_probe_report(*a, "after drag");
// (The adoption half -- a gesture that *does* move a cursor -- is the library's own
// controller test plus the app's rule that the tick passes the row whose controller holds
// a cursor; a programmatic press here cannot reach the adapter's event pump, so this probe
// asserts the invariant that is reachable from here.)
cursors_probe_next(*a, 7, 300);
break;
}
case 7: {
cursors_probe_report(*a, "both");
// A ruler is dragged through its own plot, and the pane's Y cells have to follow it.
// Nothing shares a ruler, so this is the read-back path alone.
RowSet::Row& r0 = a->rows->row(0);
if (r0.plot != nullptr) {
const plot::Range yr = r0.plot->y_range(r0.axis_y);
r0.plot->set_cursor_y(r0.ruler, yr.min + 0.80 * yr.span(), plot::CursorPlacement::Free);
}
cursors_probe_next(*a, 8, 300);
break;
}
default:
cursors_probe_report(*a, "after ruler drag");
break;
}
return G_SOURCE_REMOVE;
}
void measures_probe_next(App& a, int step, guint delay_ms) {
a.dbg_measure_step = step;
g_timeout_add(delay_ms, measures_probe_step, &a);
}
gboolean measures_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_measure_step) {
case 0: {
std::string signal;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
if (!a->model->channels().empty()) signal = a->model->channels().front().name;
}
if (signal.empty()) {
std::fprintf(stderr, "[measures-probe] no acquired signal to measure\n");
return G_SOURCE_REMOVE;
}
for (int i = 0; i < measure_type_count(); ++i) {
Measure m;
m.signal = signal;
m.type = measure_type_at(i);
m.options.show_on_plot = measure_can_show_on_plot(m.type);
a->measures.push_back(std::move(m));
}
select_side_tab(*a, "measure");
rebuild_measures_pane(*a);
std::fprintf(stderr, "[measures-probe] added %zu measurements on '%s'\n", a->measures.size(),
signal.c_str());
// This is the only probe set that asks for every type, and so the only one that places a
// *time* mark: `pulse duration`, `period` and `frequency` mark the crossings they were read
// from, and those are the marks `marks_in_window` counts. Without the trace the run reports
// the count with nothing to attribute it to -- which is how a window that lagged the screen
// read `0/6` with no way to tell a stale recompute from a mark placed correctly.
g_timeout_add(250, measure_trace, a);
measures_probe_next(*a, 1, 400);
break;
}
case 1:
open_measure_dialog(*a, 0); // the edit path: this and the delete are what aborted
measures_probe_next(*a, 2, 400);
break;
case 2:
// Reported a frame after opening, so the dropdown has an allocation: its height is
// what says the combo is a combo and not a 160 px box.
measure_dialog_report(*a, "edit opened");
measures_probe_next(*a, 3, 200);
break;
case 3:
measure_dialog_click(*a, "Save");
measures_probe_next(*a, 4, 400);
break;
case 4:
measure_dialog_report(*a, "after save");
open_measure_dialog(*a, 0);
measures_probe_next(*a, 5, 200);
break;
case 5:
measure_dialog_click(*a, "Delete");
measures_probe_next(*a, 6, 400);
break;
case 6:
// A gesture on a measurement's own cursor, placed through the plot exactly as the
// controller would: the next frame has to put it back, which is what "read-only" means
// here (the library has no flag the hit test would respect).
// The marks as *placed* throughout: the drag writes a plot cursor and the check below reads
// one back, so both sides have to be in the plot's own coordinates -- and a time mark is
// stored on the stream's clock (see `place_measure_marks()`).
if (!a->measure_marks_display.empty()) {
RowSet::Row& r0 = a->rows->row(0);
if (r0.plot != nullptr) {
const RowSet::MeasureMark& mk = a->measure_marks_display.front();
if (mk.level) {
r0.plot->set_cursor_y(r0.measure_h[0], mk.value + 3.0, plot::CursorPlacement::Free);
} else {
r0.plot->set_cursor_x(r0.measure_v[0], mk.value + 3.0, plot::CursorPlacement::Free);
}
}
}
measures_probe_next(*a, 7, 300);
break;
default: {
double restored = -1.0;
double want = 0.0;
// Both read *now*: a time mark rolls leftwards with its sample on a live axis, so the value
// placed on the most recent frame is the only one the plot's cursor can be expected to hold
// -- comparing against the value the drag was launched from would read 300 ms of scroll as a
// failure to restore.
if (!a->measure_marks_display.empty()) {
const RowSet::Row& r0 = a->rows->row(0);
const RowSet::MeasureMark& mk = a->measure_marks_display.front();
want = mk.value;
if (r0.plot != nullptr) {
restored = mk.level ? r0.plot->cursor(r0.measure_h[0]).y
: r0.plot->cursor(r0.measure_v[0]).x;
}
}
std::fprintf(stderr,
"[measures-probe] %-14s marks=%zu first=%.6f plot_now=%.6f restored=%d\n",
"after drag", a->measure_marks_display.size(), want, restored,
std::fabs(restored - want) < 1.0e-9 ? 1 : 0);
std::fflush(stderr);
measure_dialog_report(*a, "final");
break;
}
}
return G_SOURCE_REMOVE;
}
/// The band-average readings, on the shapes whose band means can be computed by hand.
///
/// `above`, `below` and `between` are plain means of the samples on one side of a share -- what
/// `top`/`bottom` reported before a flat level replaced them -- and a mean over a side is a
/// *different number* from the level the signal dwells at, so each reading is asserted where the
/// shape predicts it. Seven measurements are added on the mock's three scalar signals at once,
/// because the trio's point is that a *pair* of levels answers a band no single split can:
/// * the 1 Hz unit sine: `above 50` = 2/pi = 0.636620 and `below 50` = -2/pi, the figures the old
/// `top`/`bottom` documented (and *not* `top`/`bottom`'s 0.957988, the level a sine dwells at
/// near its peak), and `between 50 75` = 3(2-sqrt 3)/pi = 0.255873 -- the mean of the samples in
/// the band (0, 0.5], which is neither 0 (the whole window's mean) nor 0.5 (the band's edge);
/// * the mock's band-limited `square` (30 harmonics, its default): `above 50` = 0.986496, the
/// reading the commit that made `top` a flat level recorded as the *old* `top` -- the edges'
/// ring is inside this mean, and the plateau the signal dwells at is not;
/// * the mock's `ramp`, whose density is uniform: every band's mean is that band's own centre, so
/// `above 50` = 0.5 and `between 50 75` = 0.25 of the range -- a pair of figures no other
/// measurement type on this shape reports (`average` is 0 there, `top`/`bottom` its edges).
/// The dialog leg then edits the sine's band through the real widgets -- the second share is the
/// one field only `between` offers -- and re-reads the value, so a share that is built but not
/// seeded or not read back shows up as a reading that did not move.
gboolean bandavg_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_bandavg_step) {
case 0: {
// The three shapes are enabled *here* rather than assumed: `enable_all_probe` switches a
// signal off two seconds after it runs, and a probe that read another probe's leftovers
// would measure whatever was left. `set_signals_enabled()` is one structural change and a
// no-op when the state already holds, so the usual case costs nothing and the ring keeps
// its history; when it does rebuild, the report below is far enough out for a full window.
std::vector<std::size_t> wanted;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t s = 0; s < signals.size(); ++s) {
if (signals[s].name == "sine" || signals[s].name == "square" ||
signals[s].name == "ramp") {
wanted.push_back(s);
}
}
}
a->model->set_signals_enabled(wanted, true);
struct Leg {
const char* signal;
MeasureType type;
double lo;
double hi;
};
const Leg legs[] = {
{"sine", MeasureType::Above, 50.0, 0.0},
{"sine", MeasureType::Below, 50.0, 0.0},
{"sine", MeasureType::Between, 50.0, 75.0},
{"square", MeasureType::Above, 50.0, 0.0},
// The same band on the square: on the band-limited one it holds the two edges' transitions
// -- a real interior whose mean is ~0, because a transition is odd about its own midpoint --
// and on the ideal `--square-harmonics 0` step it holds *nothing at all*, because every
// sample is exactly +/-1. That pair is the empty-interior rule from both sides.
{"square", MeasureType::Between, 50.0, 75.0},
{"ramp", MeasureType::Between, 50.0, 75.0},
// A share at the top of the range has no sample above it, so this one must read what any
// other measurement with nothing to measure reads -- `invalid`/`--`, never 0. The empty
// *interior* is the same rule from the other side, and the ideal square below is where it
// shows: its samples are all exactly +/-1, so the band (0, 0.5] holds none of them.
{"ramp", MeasureType::Above, 100.0, 0.0},
};
for (const Leg& leg : legs) {
Measure m;
m.signal = leg.signal;
m.type = leg.type;
m.options.threshold_pct = leg.lo;
if (leg.hi > 0.0) m.options.threshold_pct2 = leg.hi;
// The marks are part of the reading for these types too: one ruler at the mean.
m.options.show_on_plot = measure_can_show_on_plot(m.type);
a->measures.push_back(std::move(m));
}
select_side_tab(*a, "measure");
rebuild_measures_pane(*a);
g_timeout_add(250, measure_trace, a);
bandavg_probe_next(*a, 1, 1500);
break;
}
case 1: {
// A *full* window before any reading. The window is what the pane last described -- two seconds
// on the documented run -- and the mock delivers about 0.5 MS/s of the 1.3 MS/s it declares, so
// a ring the probe has just re-enabled needs seconds of wall clock to hold a whole one. A short
// ring reads a *partial* window, which is a different number and keeps moving while it fills,
// and the ring is capacity-bound as well, so the wait is read off the store rather than guessed:
// `stored_slots` is how much history there is and `window / dt` is what the pane is measuring.
double slot_dt = 0.0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
slot_dt = a->model->partition().slot_dt();
}
const double window_s = a->measure_window_valid
? a->measure_window.max - a->measure_window.min
: 0.0;
const std::uint64_t want =
slot_dt > 0.0 && window_s > 0.0 ? static_cast<std::uint64_t>(window_s / slot_dt) : 0;
const std::uint64_t have = a->model->stored_slots();
if (have < want) {
std::fprintf(stderr, "[bandavg-probe] %-15s stored=%llu of %llu slots for a %.3f s window\n",
"waiting", static_cast<unsigned long long>(have),
static_cast<unsigned long long>(want), window_s);
std::fflush(stderr);
bandavg_probe_next(*a, 1, 500);
break;
}
bandavg_report(*a, "readings");
bandavg_probe_next(*a, 2, 400);
break;
}
case 2: {
// The `between` on the sine, through the ordinary edit path: its dialog is the one that
// offers the second share, and this is where the field is read back out of the widgets.
const int at = bandavg_index(*a, MeasureType::Between, "sine");
if (at < 0) {
std::fprintf(stderr, "[bandavg-probe] no sine/between measurement to edit\n");
std::fflush(stderr);
break;
}
open_measure_dialog(*a, at);
bandavg_probe_next(*a, 3, 400);
break;
}
case 3: {
bandavg_report(*a, "edit opened");
auto* d = static_cast<MeasureDialog*>(
g_object_get_data(G_OBJECT(a->measure_dialog), "udpscope-measure-ctx"));
if (d != nullptr) {
// The two fields as the *stored* parameters came back into them, and the band row's
// visibility: a share that is stored but not seeded, or offered for a type that does not
// read it, is a field the user cannot edit back to what the pane is showing.
std::fprintf(stderr,
"[bandavg-probe] %-15s band_row=%d shares=%.1f/%.1f (as stored)\n",
"edit opened", gtk_widget_get_visible(d->band_row) ? 1 : 0,
gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->threshold)),
gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->band)));
// From the mid split to 90 %: a band whose mean neither of its sides reports (the readings
// above 50 and below 90 are 0.636620 and 0.431362), so the value after the save can only
// come from the pair the two fields now hold.
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->threshold), 50.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->band), 90.0);
}
measure_dialog_click(*a, "Save");
bandavg_probe_next(*a, 4, 500);
break;
}
case 4:
bandavg_report(*a, "after band edit");
bandavg_probe_next(*a, 5, 300);
break;
case 5: {
// The delete half of the cycle, through the dialog's own button: a measurement that carries
// two shares is shown to survive add, edit and delete like the ones that carry none.
const int at = bandavg_index(*a, MeasureType::Between, "sine");
if (at >= 0) open_measure_dialog(*a, at);
bandavg_probe_next(*a, 6, 400);
break;
}
default: {
const std::size_t before = a->measures.size();
// `measure_dialog_click()` falls through to the emit when it cannot find its button, so the
// dialog being there is this side's to check, not the helper's.
if (a->measure_dialog != nullptr) measure_dialog_click(*a, "Delete");
std::fprintf(stderr, "[bandavg-probe] %-15s measures=%zu -> %zu (want one fewer)\n",
"after delete", before, a->measures.size());
std::fflush(stderr);
break;
}
}
return G_SOURCE_REMOVE;
}
void bandavg_probe_next(App& a, int step, guint delay_ms) {
a.dbg_bandavg_step = step;
g_timeout_add(delay_ms, bandavg_probe_step, &a);
}
/// The index of one of the probe's measurements, by the pair that names it: a `between` on the sine
/// and a `between` on the ramp are two measurements of one type.
int bandavg_index(App& a, MeasureType type, const char* signal) {
for (std::size_t i = 0; i < a.measures.size(); ++i) {
if (a.measures[i].type == type && a.measures[i].signal == signal) return static_cast<int>(i);
}
return -1;
}
/// Reads the pane out: one line per measurement the probe added, with the shares it was given and
/// the two readings a user could compare (the value and the row's own formatted text). The numbers
/// come from a *forced* recompute over the window on screen now, so they are what the pane says at
/// this instant rather than whatever the last moving frame left behind.
void bandavg_report(App& a, const char* what) {
update_measures(a, /*force=*/true, /*accumulate=*/false);
// The window each reading was taken over, beside it: a mean over a share of a *partial* window is
// a different number from the same share of a full one, and nothing in the value says which it
// was. `samples` against the ring's span is what a short ring looks like from here.
for (const App::MeasureWindow& w : a.measure_samples) {
std::fprintf(stderr,
"[bandavg-probe] %-15s window channel=%d lo=%.6f hi=%.6f samples=%zu levels=%zu\n",
what, w.channel, w.stats.lo, w.stats.hi, w.stats.count, w.level.size());
}
for (std::size_t i = 0; i < a.measures.size(); ++i) {
const Measure& m = a.measures[i];
const MeasureResult& r = i < a.measure_results.size() ? a.measure_results[i] : MeasureResult{};
char shares[64];
if (measure_uses_band(m.type)) {
std::snprintf(shares, sizeof(shares), "%.0f/%.0f%%", m.options.threshold_pct,
m.options.threshold_pct2);
} else {
std::snprintf(shares, sizeof(shares), "%.0f%%", m.options.threshold_pct);
}
std::fprintf(stderr, "[bandavg-probe] %-15s %-7s '%s' shares=%-9s -> %-8s %.9g text=[%s]\n",
what, measure_type_name(m.type), m.signal.c_str(), shares,
r.valid ? "ok" : "invalid", r.valid ? r.primary : 0.0,
i < a.measure_text.size() ? a.measure_text[i].c_str() : "");
}
std::fflush(stderr);
}
gboolean zoomkeys_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->rows->row_count() == 0 || a->rows->row(0).plot == nullptr) return G_SOURCE_REMOVE;
plot::Plot& p = *a->rows->row(0).plot;
const plot::u32 ax = a->rows->row(0).axis_x;
const plot::u32 ay = a->rows->row(0).axis_y;
plot::input::Controller* c =
a->area != nullptr ? a->area->row_controller(static_cast<plot::u32>(0)) : nullptr;
if (c == nullptr) {
std::fprintf(stderr, "[zoomkeys] no controller for row 0\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
const plot::Range x0 = p.x_range(ax);
const plot::Range y0 = p.y_range(ay);
c->on_key({plot::input::kKeyZoomIn,
static_cast<plot::u32>(plot::input::Mod::Ctrl), true});
const plot::Range x1 = p.x_range(ax);
const plot::Range y1 = p.y_range(ay);
const int y_held = std::fabs(y1.span() - y0.span()) < 1e-9 ? 1 : 0;
c->on_key({plot::input::kKeyZoomIn,
static_cast<plot::u32>(plot::input::Mod::Alt), true});
const plot::Range x2 = p.x_range(ax);
const plot::Range y2 = p.y_range(ay);
const int x_held = std::fabs(x2.span() - x1.span()) < 1e-9 ? 1 : 0;
std::fprintf(stderr,
"[zoomkeys] ctrl+plus x=[%.4f..%.4f]->[%.4f..%.4f] y_held=%d | "
"alt+plus y=[%.4f..%.4f]->[%.4f..%.4f] x_held=%d\n",
x0.min, x0.max, x1.min, x1.max, y_held, y1.min, y1.max, y2.min, y2.max,
x_held);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
void menus_probe_next(App& a, int step, guint delay_ms) {
a.dbg_menus_step = step;
g_timeout_add(delay_ms, menus_probe_step, &a);
}
/// `UDPSCOPE_PROBE_WINDOWPOP_MS`: the time-window popover, closed by a press elsewhere in the window.
///
/// Autohide is turned off for the probe, exactly as the menu probe does it: a popover dismisses itself
/// when GTK holds its *grab*, and a bare `xvfb-run` has no window manager to hand one out -- so with
/// autohide on the bubble would not stay up to be tested. Autohide off is the state a user meets when
/// the grab goes missing, which is the report this guards: the press has to close it anyway. The second
/// half presses the button itself, which must *not* close it (its own click toggles).
gboolean windowpop_probe_step(gpointer user) {
static int step = 0;
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->window_button == nullptr) return G_SOURCE_REMOVE;
const auto active = [a] {
return gtk_menu_button_get_active(GTK_MENU_BUTTON(a->window_button)) ? 1 : 0;
};
if (step == 0) {
GtkPopover* pop = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->window_button));
if (pop != nullptr) gtk_popover_set_autohide(pop, FALSE);
gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button));
step = 1;
g_timeout_add(300, windowpop_probe_step, a);
return G_SOURCE_REMOVE;
}
// How many click gestures the window carries, so that "the handler works" and "the handler is
// connected" are read as two facts rather than one.
int clicks = 0;
{
GListModel* controllers = gtk_widget_observe_controllers(a->window);
const guint n = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0;
for (guint i = 0; i < n; ++i) {
GObject* item = G_OBJECT(g_list_model_get_item(controllers, i));
if (item != nullptr) {
if (GTK_IS_GESTURE_CLICK(item)) ++clicks;
g_object_unref(item);
}
}
if (controllers != nullptr) g_object_unref(controllers);
}
std::fprintf(stderr, "[windowpop] opened=%d visible=%d click_gestures=%d\n", active(),
gtk_widget_is_visible(GTK_WIDGET(gtk_menu_button_get_popover(
GTK_MENU_BUTTON(a->window_button))))
? 1
: 0,
clicks);
on_window_popover_press(nullptr, 0, 20.0, 200.0, a);
std::fprintf(stderr, "[windowpop] after a press elsewhere: active=%d (want 0)\n", active());
gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button));
graphene_rect_t r = GRAPHENE_RECT_INIT(0, 0, 0, 0);
if (gtk_widget_compute_bounds(a->window_button, a->window, &r)) {
on_window_popover_press(nullptr, 0, r.origin.x + r.size.width / 2.0,
r.origin.y + r.size.height / 2.0, a);
}
std::fprintf(stderr, "[windowpop] after a press on the button: active=%d (want 1)\n", active());
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->window_button));
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean menus_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->dbg_menus_step == 0) {
// Hiding the panel is the case that was reported: the info button then sits at the
// window's right edge, and a bubble placed below it has nowhere to go.
run_app_action(*a, AppAction::SidePanel);
// Autohide off *for the probe only*: a popover dismisses itself when it loses the grab, and
// under a bare `xvfb-run` there is no window manager to hand it one, so the rows would be
// measured before they were ever mapped. The app's own popovers keep autohide on.
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
if (popover != nullptr) gtk_popover_set_autohide(popover, FALSE);
gtk_menu_button_popup(GTK_MENU_BUTTON(a->menu_button));
menus_probe_next(*a, 1, 300);
return G_SOURCE_REMOVE;
}
if (a->dbg_menus_step == 1) {
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
GtkWidget* box = popover != nullptr ? gtk_popover_get_child(popover) : nullptr;
// The popover's child is the stack the menu's two views are pages of; the rows are the visible
// page's. The Help view is measured by the help probe, not here.
if (box != nullptr && GTK_IS_STACK(box)) box = gtk_stack_get_visible_child(GTK_STACK(box));
static const char* const kExpected[] = {"Fit to data", "Layout editor",
"Export visible range...", "Configurations...",
"Help", "Preferences\u2026", "About udpscope"};
const int expected = static_cast<int>(sizeof(kExpected) / sizeof(kExpected[0]));
int rows = 0;
int bad = 0;
int first_label_x = 0;
for (GtkWidget* row = box != nullptr ? gtk_widget_get_first_child(box) : nullptr;
row != nullptr; row = gtk_widget_get_next_sibling(row)) {
if (!GTK_IS_BUTTON(row)) continue;
GtkWidget* label = nullptr;
GtkWidget* accel = nullptr;
menu_row_labels(gtk_widget_get_first_child(row), &label, &accel);
const int row_w = gtk_widget_get_width(row);
const int row_h = gtk_widget_get_height(row);
const graphene_point_t origin = GRAPHENE_POINT_INIT(0.0f, 0.0f);
graphene_point_t at = GRAPHENE_POINT_INIT(0.0f, 0.0f);
const int label_x = label != nullptr && gtk_widget_compute_point(label, row, &origin, &at)
? static_cast<int>(at.x) : -1;
const int accel_right =
accel != nullptr && gtk_widget_compute_point(accel, row, &origin, &at)
? static_cast<int>(at.x) + gtk_widget_get_width(accel) : -1;
const char* text = label != nullptr ? gtk_label_get_text(GTK_LABEL(label)) : "";
const char* key = accel != nullptr ? gtk_label_get_text(GTK_LABEL(accel)) : "";
const bool named = rows < expected && std::strcmp(text, kExpected[rows]) == 0;
// Every label starts at the same x -- that is what "left-aligned" means for a menu whose
// rows differ in whether they carry a check.
if (rows == 0) first_label_x = label_x;
const bool left = label_x >= 0 && label_x <= 40 && label_x == first_label_x;
const bool right = accel == nullptr || (accel_right > 0 && row_w - accel_right <= 12);
const bool compact = row_h > 0 && row_h <= kMenuRowMaxPx;
const bool ok = named && left && right && compact;
if (!ok) ++bad;
std::fprintf(stderr,
"[menu] %d '%s' accel='%s' h=%d label_x=%d accel_right=%d/%d order=%d "
"ok=%d\n", rows, text, key, row_h, label_x, accel_right, row_w,
named ? 1 : 0, ok ? 1 : 0);
++rows;
}
std::fprintf(stderr,
"[menu] rows=%d expected=%d order=%d rows_bad=%d popover_visible=%d "
"mapped=%d button_mapped=%d\n", rows, expected, rows == expected ? 1 : 0, bad,
popover != nullptr ? gtk_widget_get_visible(GTK_WIDGET(popover)) : -1,
popover != nullptr ? gtk_widget_get_mapped(GTK_WIDGET(popover)) : -1,
a->menu_button != nullptr ? gtk_widget_get_mapped(a->menu_button) : -1);
if (popover != nullptr) gtk_popover_set_autohide(popover, TRUE);
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->menu_button));
if (a->status_info_button != nullptr) {
GtkPopover* info = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->status_info_button));
if (info != nullptr) gtk_popover_set_autohide(info, FALSE);
gtk_menu_button_popup(GTK_MENU_BUTTON(a->status_info_button));
}
menus_probe_next(*a, 2, 300);
return G_SOURCE_REMOVE;
}
// The info bubble: its own size against the room above the button it points at. GTK slides a
// popover sideways to fit, so only the vertical placement has to hold -- and it is the one
// that was wrong: below the button is off the window's bottom edge.
GtkWidget* bubble = a->status_popover;
const int bubble_w = bubble != nullptr ? gtk_widget_get_width(bubble) : -1;
const int bubble_h = bubble != nullptr ? gtk_widget_get_height(bubble) : -1;
graphene_rect_t bounds = GRAPHENE_RECT_INIT(0, 0, 0, 0);
const bool have_button = a->status_info_button != nullptr &&
gtk_widget_compute_bounds(a->status_info_button, a->window, &bounds);
const int button_top = have_button ? static_cast<int>(bounds.origin.y) : -1;
const int button_right =
have_button ? static_cast<int>(bounds.origin.x + bounds.size.width) : -1;
const int pos = bubble != nullptr
? static_cast<int>(gtk_popover_get_position(GTK_POPOVER(bubble))) : -1;
const int fits = (pos == GTK_POS_TOP && bubble_h > 0 && bubble_w > 0 &&
button_top >= bubble_h + 14) ? 1 : 0;
std::fprintf(stderr, "[menu] status pos=%s bubble=%dx%d button_top=%d button_right=%d "
"fits=%d\n", pos == GTK_POS_TOP ? "top" : "other", bubble_w, bubble_h,
button_top, button_right, fits);
if (a->status_info_button != nullptr) {
GtkPopover* info = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->status_info_button));
if (info != nullptr) gtk_popover_set_autohide(info, TRUE);
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->status_info_button));
}
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean color_probe(gpointer user) {
App* a = static_cast<App*>(user);
// Any slot with a *bound* trace: an unbound one has no colour on screen to check.
int probe_row = -1;
int probe_slot = -1;
std::string name;
for (int r = 0; r < a->rows->row_count() && probe_row < 0; ++r) {
const RowSet::Row& row = a->rows->row(r);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (row.signal_name[si].empty() || row.channel[si] < 0) continue;
probe_row = r;
probe_slot = s;
name = row.signal_name[si];
break;
}
}
if (probe_row < 0) {
std::fprintf(stderr, "[color] no bound trace to recolour\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
// The chip as the signal list actually writes it, and the trace as the renderer will read it.
const auto chip_of = [&]() {
std::lock_guard<std::mutex> lock(a->model->mutex());
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == name) return signal_chip_markup(*a, signals, i);
}
return std::string();
};
const auto trace_of = [&]() {
const plot::Plot* p = a->rows->row(probe_row).plot;
if (p == nullptr) return std::string("#??????");
return color_hex(p->trace(static_cast<plot::u32>(probe_slot)).color);
};
// A chip carries its colour inside `<span foreground="...">`, so "the chip says X" is the
// substring test and not a recomputation of what it ought to say.
const auto chip_says = [](const std::string& markup, const std::string& hex) {
return markup.find("\"" + hex + "\"") != std::string::npos ? 1 : 0;
};
const std::string before = trace_of();
std::fprintf(stderr, "[color] '%s' at row %d slot %d: trace=%s chip_agrees=%d (want 1)\n",
name.c_str(), probe_row, probe_slot, before.c_str(),
chip_says(chip_of(), before));
constexpr plot::u32 kPick = 0x123456u;
a->rows->set_signal_color(name, kPick);
// The same path the frame tick takes: a trace's colour is fixed at `add_trace()`, so this is a
// new figure, and reading the old `Row::plot` after it would read the figure that was replaced.
relayout_areas(*a);
const std::string after = trace_of();
std::fprintf(stderr, "[color] override: trace=%s chip_agrees=%d (want #123456 1)\n",
after.c_str(), chip_says(chip_of(), after));
// The session file, which is where a colour has to survive to be worth choosing. Through the real
// writer and reader on a temp path, not the live session's -- a probe must not leave a colour in
// the file a later run would inherit.
const std::string tmp = std::string(g_get_tmp_dir()) + "/udpscope-color-probe.toml";
LayoutState saved = collect_layout(*a);
int round_trip = -1;
std::size_t entries = 0;
if (save_layout(tmp, saved)) {
LayoutState back;
if (load_layout(tmp, back)) {
entries = back.colors.size();
for (const auto& c : back.colors) {
if (c.name == name) round_trip = static_cast<int>(c.rgb);
}
}
}
std::remove(tmp.c_str());
std::fprintf(stderr, "[color] session: entries=%zu rgb=#%06X (want 1 #123456)\n", entries,
round_trip < 0 ? 0u : static_cast<unsigned>(round_trip));
a->rows->clear_signal_color(name);
relayout_areas(*a);
const std::string restored = trace_of();
std::fprintf(stderr, "[color] default: trace=%s chip_agrees=%d (want %s 1)\n", restored.c_str(),
chip_says(chip_of(), restored), before.c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean reseq_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
ScopeModel& m = *a->model;
std::uint64_t nan_slots = 0;
std::uint64_t scanned = 0;
std::size_t open_holes = 0;
int trigger_in_hole = 0;
{
// The hole table and the live store are the producer's, so both are read under the one mutex
// -- the same lock the frame's bind takes (`src/ui.cpp:6263`).
std::lock_guard<std::mutex> lock(m.mutex());
// The first non-math channel's retained window. One channel and not all of them: every
// non-math channel is reserved and repaired by the same loop over the same ticks, so a second
// channel would restate the first's answer at the cost of another pass over the ring.
for (std::size_t i = 0; i < m.channel_count(); ++i) {
if (m.channels()[i].math) continue;
const plot::data::RingBuffer<f32>& v = m.live().values(i);
// `operator[]` is window-relative: index 0 is the slot at `base()`.
scanned = v.produced() - v.base();
for (u64 k = 0; k < scanned; ++k) {
if (!std::isfinite(v[k])) ++nan_slots;
}
break;
}
open_holes = m.holes().size();
// A reserved span's samples are never fed to the trigger engine, so a latch stamped inside one
// would mean they had been. `Hole::x_begin`/`x_end` and `trigger_time()` are both in stream
// seconds, so they compare directly with no epoch in between.
if (m.trigger_time_valid().load()) {
const f64 t = m.trigger_time().load();
for (const Hole& h : m.holes()) {
if (t >= h.x_begin && t <= h.x_end) {
trigger_in_hole = 1;
break;
}
}
}
}
// The shading as the plot actually holds it, read off row 0 after the frame published it. Every
// row carries the same spans, so one row is the whole answer. Split by state because the two
// mean different things: `bands` is what the window is reporting and `bands_repaired` how much
// of that report is historical, so `bands == bands_repaired` with `nan_slots=0` is a healthy
// reading and not the contradiction it would be if the band had one state.
std::size_t bands = 0;
std::size_t bands_repaired = 0;
if (a->rows->row_count() > 0 && a->rows->row(0).plot != nullptr) {
for (const plot::InvalidSpan& span : a->rows->row(0).plot->invalid_spans()) {
++bands;
if (span.repaired) ++bands_repaired;
}
}
std::fprintf(stderr,
"[reseq-probe] reseq=%llu late=%llu stale=%llu overflows=%llu holes=%llu "
"hole_slots=%llu "
"backfilled=%llu gaps_refused=%llu open_holes=%zu bands=%zu bands_repaired=%zu "
"nan_slots=%llu "
"scanned=%llu trigger_in_hole=%d\n",
static_cast<unsigned long long>(m.stats().resequenced.load()),
static_cast<unsigned long long>(m.stats().late.load()),
static_cast<unsigned long long>(m.stats().stale.load()),
static_cast<unsigned long long>(m.stats().overflows.load()),
static_cast<unsigned long long>(m.stats().holes.load()),
static_cast<unsigned long long>(m.stats().hole_slots.load()),
static_cast<unsigned long long>(m.stats().backfilled.load()),
static_cast<unsigned long long>(m.stats().gaps_refused.load()), open_holes, bands,
bands_repaired, static_cast<unsigned long long>(nan_slots),
static_cast<unsigned long long>(scanned), trigger_in_hole);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
void capture_probe_next(App& a, int step, guint delay_ms) {
a.dbg_capture_step = step;
g_timeout_add(delay_ms, capture_probe_step, &a);
}
gboolean capture_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->dbg_capture_step == 0) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->capture_button), TRUE);
capture_probe_next(*a, 1, 2500);
return G_SOURCE_REMOVE;
}
const PacketCapture& cap = a->acq->capture();
CaptureReadStats st;
const bool read_ok = read_capture(cap.path(), &st);
// `stored` is the datagrams-since-open counter, which is what must equal the exit
// dump's count and, in the wrap case, must exceed the slot count; `resident` is what
// the file itself holds, which is the slot count once the ring has wrapped.
std::fprintf(stderr,
"[capture-probe] enabled=%d read_ok=%d path=%s slots=%u stored=%llu "
"resident=%llu cursor=%u torn=%llu truncated=%llu header_ok=%d first_seq=%llu "
"last_seq=%llu payload=%llu file=%llu open_ms=%.1f t0=%.3f\n",
cap.enabled() ? 1 : 0, read_ok ? 1 : 0, cap.path().c_str(), st.slot_count,
static_cast<unsigned long long>(cap.stored()),
static_cast<unsigned long long>(st.stored), st.cursor,
static_cast<unsigned long long>(st.torn),
static_cast<unsigned long long>(st.truncated), st.header_ok ? 1 : 0,
static_cast<unsigned long long>(st.first_seq),
static_cast<unsigned long long>(st.last_seq),
static_cast<unsigned long long>(st.payload),
static_cast<unsigned long long>(st.file_bytes), cap.open_ms(), st.t0_realtime);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean configs_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->source_port == 0) return G_SOURCE_REMOVE;
const auto joined = [&]() {
std::string out;
for (const std::string& n : configuration_names(a->source_host, a->source_port)) {
out += (out.empty() ? "" : ",") + n;
}
return out.empty() ? std::string("-") : out;
};
std::fprintf(stderr, "[configs] before=%s active=%s\n", joined().c_str(),
active_configuration(a->source_host, a->source_port).c_str());
// A non-default band and corner before the save, so the round trip below proves the two keys are
// *written*: left at their defaults, a key that never made it to the file would read back the same.
const bool has_measure = !a->measures.empty();
if (has_measure) {
a->measures.front().options.noise_pct = 7.5;
a->measures.front().options.hf_fc = 250.0;
}
const bool saved = save_configuration_as(*a, "probe", "written by the configs probe");
std::fprintf(stderr, "[configs] save-as=%d names=%s active=%s\n", saved ? 1 : 0,
joined().c_str(), active_configuration(a->source_host, a->source_port).c_str());
// The round trip: what a reopened configuration must carry, note and startup settings included.
LayoutState st;
const std::string path = configuration_path(a->source_host, a->source_port, "probe");
const bool read = load_layout(path, st);
int signals = 0;
for (const LayoutState::Row& r : st.rows) {
for (const std::string& sig : r.signals) {
if (!sig.empty()) ++signals;
}
}
const double noise_back = st.measures.empty() ? -1.0 : st.measures.front().options.noise_pct;
const double hf_back = st.measures.empty() ? -1.0 : st.measures.front().options.hf_fc;
std::fprintf(stderr,
"[configs] round trip read=%d comment='%s' buffer_mb=%.6g relative=%d "
"capture=%u plots=%zu signals=%d measures=%zu math=%zu noise=%.6g hf=%.6g\n",
read ? 1 : 0, st.comment.c_str(), st.buffer_mb, st.relative_time ? 1 : 0,
st.capture_packets, st.rows.size(), signals, st.measures.size(), st.math.size(),
noise_back, hf_back);
forget_configuration(a->source_host, a->source_port, "probe");
std::fprintf(stderr, "[configs] after forget names=%s\n", joined().c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
void rowmenu_probe_next(App& a, int step, guint delay_ms) {
a.dbg_rowmenu_step = step;
g_timeout_add(delay_ms, rowmenu_probe_step, &a);
}
gboolean rowmenu_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->rows->row_count() == 0) return G_SOURCE_REMOVE;
if (a->dbg_rowmenu_step == 0) {
// A trace first, or the remove view has nothing to list and the reading would not notice it
// missing: the point of the probe is the *populated* menu.
if (!a->model->channels().empty()) {
const ChannelInfo& ch = a->model->channels().front();
a->rows->assign(0, 0, ch.name, ch.name,
signal_color_index(a->model->signals(), ch.signal));
a->relayout_pending = true;
}
a->row_menu_popover = open_row_menu(*a, 0, 40.0, 20.0, a->area_widget, /*autohide=*/false);
rowmenu_probe_next(*a, 1, 300);
return G_SOURCE_REMOVE;
}
GtkWidget* popover = a->row_menu_popover;
GtkWidget* box = popover != nullptr ? gtk_popover_get_child(GTK_POPOVER(popover)) : nullptr;
if (a->dbg_rowmenu_step == 1) {
row_menu_measure(box, "main", &g_rowmenu_probe.main_rows, &g_rowmenu_probe.main_bad);
g_rowmenu_probe.remove_row_in_main = row_menu_find(box, "Remove signal") != nullptr ? 1 : 0;
if (GtkWidget* show = row_menu_find(box, "Remove signal")) {
g_signal_emit_by_name(show, "clicked");
}
rowmenu_probe_next(*a, 2, 250);
return G_SOURCE_REMOVE;
}
if (a->dbg_rowmenu_step == 2) {
row_menu_measure(box, "remove", &g_rowmenu_probe.remove_rows, &g_rowmenu_probe.remove_bad);
g_rowmenu_probe.remove_row_gone = row_menu_find(box, "Remove signal") == nullptr ? 1 : 0;
if (GtkWidget* back = row_menu_find(box, "Back")) g_signal_emit_by_name(back, "clicked");
rowmenu_probe_next(*a, 3, 250);
return G_SOURCE_REMOVE;
}
if (a->dbg_rowmenu_step == 3) {
row_menu_measure(box, "after-back", &g_rowmenu_probe.back_rows, &g_rowmenu_probe.back_bad);
if (GtkWidget* again = row_menu_find(box, "Remove signal")) {
g_signal_emit_by_name(again, "clicked");
}
rowmenu_probe_next(*a, 4, 250);
return G_SOURCE_REMOVE;
}
row_menu_measure(box, "second-open", &g_rowmenu_probe.second_rows,
&g_rowmenu_probe.second_bad);
g_rowmenu_probe.can_open_again = row_menu_find(box, "Back") != nullptr ? 1 : 0;
std::fprintf(stderr,
"[rowmenu] summary main_rows=%d main_bad=%d remove_rows=%d remove_bad=%d "
"after_back_rows=%d after_back_bad=%d second_rows=%d second_bad=%d "
"remove_row_in_main=%d remove_row_gone=%d can_open_again=%d\n",
g_rowmenu_probe.main_rows, g_rowmenu_probe.main_bad, g_rowmenu_probe.remove_rows,
g_rowmenu_probe.remove_bad, g_rowmenu_probe.back_rows, g_rowmenu_probe.back_bad,
g_rowmenu_probe.second_rows, g_rowmenu_probe.second_bad,
g_rowmenu_probe.remove_row_in_main, g_rowmenu_probe.remove_row_gone,
g_rowmenu_probe.can_open_again);
// And the removal itself: an entry of the remove view must free that trace of the row. The
// popover is left to the entry's own popdown (the app's path -- a click from *inside* the
// popover), which destroys it through the `closed` handler, so the handle is dropped first.
{
const std::string trace = a->rows->row(0).name[0];
GtkWidget* entry = row_menu_find(box, trace.c_str());
a->row_menu_popover = nullptr;
if (entry != nullptr) g_signal_emit_by_name(entry, "clicked");
const bool freed = a->rows->row(0).channel[0] < 0;
std::fprintf(stderr, "[rowmenu] remove-clicked '%s' trace_freed=%d\n", trace.c_str(),
freed ? 1 : 0);
}
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// The live view's own settings, taken just before a file view is opened over them, so that every
/// way back out can be read against what was there. The trigger is read under the lock and its
/// channel by *name*: a file view rebuilds the channel list, so an index means nothing on the way
/// back. The probe keeps its own copy rather than asking the restore path for one -- a reading taken
/// from the mechanism under test asserts nothing.
void capture_live_expectation(App& a) {
a.dbg_live_window = a.rows->time_window();
a.dbg_live_trigger_on =
a.trigger_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a.trigger_toggle)) != FALSE;
std::lock_guard<std::mutex> lock(a.model->mutex());
const TriggerConfig& tc = a.model->trigger();
a.dbg_live_level = tc.condition.level;
a.dbg_live_trigger_channel = tc.channel >= 0 && tc.channel < int(a.model->channels().size())
? a.model->channels()[std::size_t(tc.channel)].name
: std::string();
}
/// What came back when a file view was left: every setting the live view had before it opened, read
/// against the expectation the probe captured itself. Four of the five readings are a *pair* on
/// purpose -- a file view overwrites the window, the trigger mode, the trigger's channel and its
/// level, so a line where the two halves of a pair disagree names the one that was not put back.
/// `pinned` is the fifth and has no pair: a file pins the axis to the event's span, and going live
/// must leave the axis following again, so the only passing reading is 0.
void report_live_restored(App& a, const char* what) {
std::string channel;
double level = 0.0;
bool enabled = false;
bool armed = false;
{
std::lock_guard<std::mutex> lock(a.model->mutex());
const TriggerConfig& tc = a.model->trigger();
enabled = tc.enabled;
armed = tc.armed;
level = tc.condition.level;
if (tc.channel >= 0 && tc.channel < int(a.model->channels().size())) {
channel = a.model->channels()[std::size_t(tc.channel)].name;
}
}
std::fprintf(stderr,
"[events] live restored (%s): window=%.6f (want %.6f) trigger=%d (want %d) "
"channel='%s' (want '%s') level=%.6g (want %.6g) enabled=%d armed=%d pinned=%d "
"held=%d mode=%d pending=%d gen=%u/%u\n",
what, a.rows->time_window(), a.dbg_live_window,
a.trigger_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a.trigger_toggle))
? 1
: 0,
a.dbg_live_trigger_on ? 1 : 0, channel.c_str(), a.dbg_live_trigger_channel.c_str(),
level, a.dbg_live_level, enabled ? 1 : 0, armed ? 1 : 0,
a.rows->window_pinned() ? 1 : 0, a.event_held ? 1 : 0,
static_cast<int>(a.model->display_mode()), a.live_restore_pending ? 1 : 0,
a.model->generation(), a.live_restore_generation);
std::fflush(stderr);
}
void events_probe_next(App& a, int step, guint delay_ms) {
a.dbg_events_step = step;
g_timeout_add(delay_ms, events_probe_step, &a);
}
gboolean events_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->dbg_events_step == 0) {
// Before anything: streaming, no trigger, recording **off** (the default) -- so no button, no
// pane, and nothing written to disk that the user did not ask to have written.
report_events_availability(*a, "live, recording off");
// The three clicks a user makes: recording on in the trigger panel, trigger mode on, then the
// history button (which is only there once the first two have happened -- that is the item's rule,
// and this is where it is asserted).
gtk_switch_set_active(GTK_SWITCH(a->events_record_switch), TRUE);
// An 80% pre-trigger share, so the event that latches is *not* symmetric: the window a click
// opens must be the event's own span, and with a symmetric event the live window would look
// identical and say nothing.
if (a->trigger_pre_spin != nullptr) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_pre_spin), 80.0);
}
report_events_availability(*a, "recording on, no trigger");
if (a->trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
}
report_events_availability(*a, "after trigger on");
if (a->events_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->events_toggle), TRUE);
}
// Read straight after the click, before anything else can call the availability rule: this is the
// reading that says the *button* opens the pane, and it is the one a later replay used to mask.
report_events_availability(*a, "after history button");
// ... and again a few frames later, still with no replay in the way: this is the *click alone*
// path a user takes, which a later replay's own availability call used to mask.
events_probe_next(*a, 5, 500);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 5) {
GtkWidget* box = a->events_revealer != nullptr
? gtk_revealer_get_child(GTK_REVEALER(a->events_revealer))
: nullptr;
std::fprintf(stderr, "[events] click alone: pane=%d pane_w=%d pane_box=%d\n",
a->events_revealer != nullptr && gtk_widget_get_visible(a->events_revealer) ? 1 : 0,
a->events_divider != nullptr ? gtk_paned_get_position(GTK_PANED(a->events_divider))
: -1,
box != nullptr ? gtk_widget_get_width(box) : -1);
events_probe_next(*a, 1, 2500);
return G_SOURCE_REMOVE;
}
if (a->event_store == nullptr) {
std::fprintf(stderr, "[events] no store (no source)\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 1) {
// The preview's repair, measured before anything else reads the pane: the newest event's picture
// is deleted and the pane refreshed, so the row that comes back proves the pane draws it again
// from the samples rather than showing a blank. The stored geometry is read back too, because the
// defect this fixes was a readable *file* that was too short to read -- `strip_h * signals` says
// the picture grew with the signal count, a fixed 160 says it did not.
const std::vector<EventRecord> before = a->event_store->list();
if (!before.empty()) {
const std::string bmp = a->event_store->dir() + "/" + before.front().stem + ".bmp";
::remove(bmp.c_str());
// The decoded picture has to go with the file: the cache answers by path and would hand back
// the texture of a file that is no longer there, so the refresh would never reach the repair.
if (EventThumbCache* cache = event_thumb_cache(*a)) {
for (auto& entry : *cache) {
if (entry.second != nullptr) g_object_unref(entry.second);
}
cache->clear();
}
refresh_events_pane(*a);
int w = -1;
int h = -1;
if (std::FILE* f = std::fopen(bmp.c_str(), "rb"); f != nullptr) {
unsigned char head[26] = {};
if (std::fread(head, 1, sizeof(head), f) == sizeof(head)) {
w = int(head[18]) | int(head[19]) << 8 | int(head[20]) << 16;
h = int(head[22]) | int(head[23]) << 8 | int(head[24]) << 16;
}
std::fclose(f);
}
const bool drawn = a->events_thumb != nullptr &&
gtk_picture_get_paintable(GTK_PICTURE(a->events_thumb)) != nullptr;
std::fprintf(stderr, "[events] preview redrawn: signals=%zu stored=%dx%d row_drawn=%d\n",
before.front().signals, w, h, drawn ? 1 : 0);
std::fflush(stderr);
}
const std::vector<EventRecord> records = a->event_store->list();
std::fprintf(stderr, "[events] dir=%s n=%zu", a->event_store->dir().c_str(), records.size());
if (!records.empty()) {
const EventRecord& r = records.front();
long thumb_bytes = -1;
if (!r.thumbnail.empty()) {
struct stat sb {};
thumb_bytes = ::stat(r.thumbnail.c_str(), &sb) == 0 ? long(sb.st_size) : -2;
}
std::fprintf(stderr,
" newest: stem=%s dur=%.6f pre=%.6f post=%.6f edge=%.6f signals=%zu samples=%zu "
"rate=%.6g seq=%llu thumb_bytes=%ld recorded=%d pane=%d",
r.stem.c_str(), r.duration_s, r.pre_s, r.post_s, r.edge, r.signals, r.samples,
r.rate_hz, static_cast<unsigned long long>(r.seq), thumb_bytes,
a->event_recording ? 1 : 0,
gtk_revealer_get_reveal_child(GTK_REVEALER(a->events_revealer)) ? 1 : 0);
}
std::fprintf(stderr, "\n");
std::fflush(stderr);
if (!records.empty()) {
// Through the *row*, not `replay_event()` directly: that is the gesture -- it loads the
// thumbnail beside the description and then replays -- so the probe drives what a click does.
// A window that is *not* the event's span, and set *before* the click, so the reading after it
// says whether opening the event framed it: with the same window there would be nothing to tell
// "framed" from "untouched".
apply_time_window(*a, 0.5);
capture_live_expectation(*a);
GtkListBoxRow* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(a->events_list), 0);
// Selected *and* remembered, because what the activation must not do is rebuild the list:
// a selection the user made for a comparison, and the row object itself, are what a
// rebuild silently discarded. Two rows, so the reading after the click can say the
// selection survived rather than merely that something is selected.
if (row != nullptr) gtk_list_box_select_row(GTK_LIST_BOX(a->events_list), row);
if (GtkListBoxRow* second = gtk_list_box_get_row_at_index(GTK_LIST_BOX(a->events_list), 1)) {
gtk_list_box_select_row(GTK_LIST_BOX(a->events_list), second);
}
a->dbg_events_row0 = row;
GList* chosen = gtk_list_box_get_selected_rows(GTK_LIST_BOX(a->events_list));
a->dbg_events_selected = int(g_list_length(chosen));
g_list_free(chosen);
if (row != nullptr) g_signal_emit_by_name(a->events_list, "row-activated", row);
}
// One layout pass before the picture is measured: a widget created a moment ago has no
// allocation yet, so reading its size here would read 0x0 for a pane that is drawing fine.
events_probe_next(*a, 15, 350);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 15) {
// The *allocation*, not only the paint-able: a `GtkPicture` holds a paint-able the moment a file
// is set, so a non-null one says nothing about whether anything is on screen.
const bool picture = a->events_thumb != nullptr &&
gtk_picture_get_paintable(GTK_PICTURE(a->events_thumb)) != nullptr;
// The row *object*, not its index: a rebuilt list has a row 0 too, so only identity tells a
// list that was left alone from one that was torn down and refilled under the user's scroll.
const bool rows_kept =
a->dbg_events_row0 != nullptr &&
gtk_list_box_get_row_at_index(GTK_LIST_BOX(a->events_list), 0) == a->dbg_events_row0;
GList* still = gtk_list_box_get_selected_rows(GTK_LIST_BOX(a->events_list));
const int selected_now = int(g_list_length(still));
g_list_free(still);
std::fprintf(stderr,
"[events] activated row0: replaying=%d pane=%d pane_w=%d pane_box=%d tick=%d "
"window=%.3f axis=[%.3f,%.3f] marker=%.3f "
"thumbnail=%d thumb_size=%dx%d thumb_visible=%d rows_kept=%d selected=%d/%d\n",
a->replaying ? 1 : 0,
a->events_revealer != nullptr && gtk_widget_get_visible(a->events_revealer) ? 1 : 0,
a->events_divider != nullptr ? gtk_paned_get_position(GTK_PANED(a->events_divider))
: -1,
// The pane's *rendered* width: the position is what we asked for, this is what the
// pane actually got -- a divider at 240 with a zero-width pane is still no pane.
a->events_revealer != nullptr && gtk_revealer_get_child(GTK_REVEALER(a->events_revealer)) != nullptr
? gtk_widget_get_width(gtk_revealer_get_child(GTK_REVEALER(a->events_revealer)))
: -1,
signal_tick_visible(*a), a->rows->time_window(),
a->rows->row(0).plot != nullptr
? a->rows->row(0).plot->x_range(a->rows->row(0).axis_x).min
: 0.0,
a->rows->row(0).plot != nullptr
? a->rows->row(0).plot->x_range(a->rows->row(0).axis_x).max
: 0.0,
// The trigger line's own cursor: 0 is the trigger instant and a large negative is the
// park. A file that opens without its trigger line reads the parked value here.
a->rows->row(0).plot != nullptr
? a->rows->row(0).plot->cursor(a->rows->row(0).trigger_marker).x
: 0.0,
picture ? 1 : 0,
a->events_thumb != nullptr ? gtk_widget_get_width(a->events_thumb) : -1,
a->events_thumb != nullptr ? gtk_widget_get_height(a->events_thumb) : -1,
a->events_thumb != nullptr && gtk_widget_get_mapped(a->events_thumb) ? 1 : 0,
rows_kept ? 1 : 0, selected_now, a->dbg_events_selected);
std::fflush(stderr);
events_probe_next(*a, 2, 1200);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 2) {
// After a replay the model holds the *event's* samples and every channel of it, from the loader
// `--open` uses -- which is the whole assertion: the cache is readable by the instrument.
const std::size_t channels = a->model->channels().size();
const std::size_t samples =
channels > 0 ? a->model->live().values(0).produced() : 0;
std::fprintf(stderr, "[events] replayed: channels=%zu samples=%zu replaying=%d waveform=%d\n",
channels, samples, a->replaying ? 1 : 0, a->waveform_name.empty() ? 0 : 1);
std::fflush(stderr);
go_live_again(*a);
events_probe_next(*a, 3, 900);
return G_SOURCE_REMOVE;
}
std::fprintf(stderr, "[events] live again: replaying=%d waveform=%d\n", a->replaying ? 1 : 0,
a->waveform_name.empty() ? 0 : 1);
report_live_restored(*a, "after replay");
// The pane's *other* precondition, through the real switch, in both trigger states: disabling
// recording has to take the button and the pane with it, and arming the trigger afterwards must not
// bring back a pane over an empty cache.
gtk_switch_set_active(GTK_SWITCH(a->events_record_switch), FALSE);
report_events_availability(*a, "recording off, trigger off");
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
report_events_availability(*a, "recording off, trigger on");
// The group layouts need a *second* event, and a scope back on the stream to collect one: recording
// on and the trigger armed again, then step 4 waits for the latch rather than assuming it.
gtk_switch_set_active(GTK_SWITCH(a->events_record_switch), TRUE);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
events_group_probe_next(*a, 4, 2500);
return G_SOURCE_REMOVE;
}
gboolean events_group_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const std::size_t cached = a->event_store != nullptr ? a->event_store->list().size() : 0;
if (a->dbg_events_step == 4) {
// A bounded wait for the second latch: a source that never latches must report the wait, not hang.
if (cached < 2 && a->dbg_events_wait < 12) {
++a->dbg_events_wait;
events_group_probe_next(*a, 4, 700);
return G_SOURCE_REMOVE;
}
a->dbg_events_wait = 0;
// Open the pane first: its list is only rebuilt while the pane is *up*, so a selection made against
// a collapsed pane selects rows that are not there.
if (a->events_toggle != nullptr &&
!gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->events_toggle))) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->events_toggle), TRUE);
}
// The selection *is* the input, so select the two newest rows the way a user does and press the
// button: `on_group_clicked()` reads the list, not a parameter.
GtkListBox* list = GTK_LIST_BOX(a->events_list);
for (int i = 0; i < 2; ++i) {
if (GtkListBoxRow* row = gtk_list_box_get_row_at_index(list, i)) {
gtk_list_box_select_row(list, row);
}
}
std::fprintf(stderr,
"[events] compare buttons: cached=%zu selected=%zu compare=%d accumulate=%d\n",
cached, selected_events(*a).size(),
a->events_compare != nullptr && gtk_widget_get_sensitive(a->events_compare) ? 1 : 0,
a->events_accumulate != nullptr && gtk_widget_get_sensitive(a->events_accumulate) ? 1
: 0);
// The live settings this leg will be read against, taken *here* rather than inherited from the
// replay leg: a group is a second, independent way into a file view, and its way back has to put
// back what was on screen when the button was pressed.
capture_live_expectation(*a);
if (a->events_compare != nullptr) g_signal_emit_by_name(a->events_compare, "clicked");
// Read one step later: a rebuilt row's traces are bound by the next *frame*, so a reading taken in
// the same handler as the click reads the frame before the bind.
events_group_probe_next(*a, 45, 700);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 45) {
std::lock_guard<std::mutex> lock(a->model->mutex());
std::fprintf(stderr,
"[events] compare: kind=%d rows=%d channels=%zu samples0=%llu traces0=%d traces1=%d "
"window=%.3f recording=%d pane=%d\n",
static_cast<int>(a->group_kind), a->rows->row_count(),
a->model->channels().size(),
static_cast<unsigned long long>(a->model->live().values(0).produced()),
group_visible_traces(*a, 0), group_visible_traces(*a, 1), a->rows->time_window(),
a->event_recording ? 1 : 0,
a->events_revealer != nullptr && gtk_widget_get_visible(a->events_revealer) ? 1 : 0);
events_group_probe_next(*a, 46, 400);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 46) {
// The second plot: on screen, the same number of rows as the first, and both on one x window -- then
// a nudge to the *first* plot's row 0, read back from the second a couple of frames later. That last
// reading is the feature: a zoom in one row reaches the same row of the other plot.
const int rows_a = a->rows != nullptr ? a->rows->row_count() : -1;
const int rows_b = a->rows_b != nullptr ? a->rows_b->row_count() : -1;
const bool area_b = a->area_b_widget != nullptr && gtk_widget_get_visible(a->area_b_widget);
std::fprintf(stderr, "[events] compare plots: area_b=%d rows_a=%d rows_b=%d", area_b ? 1 : 0,
rows_a, rows_b);
if (area_b && rows_a > 0 && rows_b == rows_a && a->rows->row(0).plot != nullptr &&
a->rows_b->row(0).plot != nullptr) {
int entries = 0;
for (GtkWidget* w = gtk_widget_get_first_child(a->signal_list); w != nullptr;
w = gtk_widget_get_next_sibling(w)) {
++entries;
}
const plot::Range ax = a->rows->row(0).plot->x_range(a->rows->row(0).axis_x);
const plot::Range bx = a->rows_b->row(0).plot->x_range(a->rows_b->row(0).axis_x);
std::fprintf(stderr, " list=%d x_a=[%.3f,%.3f] x_b=[%.3f,%.3f] same_x=%d", entries, ax.min,
ax.max, bx.min, bx.max, (ax.min == bx.min && ax.max == bx.max) ? 1 : 0);
// Which *channel* each plot's row 0 draws: the comparison is only a comparison if the second
// plot's slots resolved to the other event's channels (and not to the first event's).
std::fprintf(stderr, " channels_a=[");
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
std::fprintf(stderr, "%d%s", a->rows->channel_of(0, s), s + 1 < RowSet::kSlotsPerRow ? "," : "");
}
std::fprintf(stderr, "] channels_b=[");
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
std::fprintf(stderr, "%d%s", a->rows_b->channel_of(0, s), s + 1 < RowSet::kSlotsPerRow ? "," : "");
}
std::fprintf(stderr, "]");
// A y range nobody would arrive at by accident, set on the first plot only.
a->rows->row(0).plot->set_y_range(a->rows->row(0).axis_y, plot::Range{-3.5, 3.5});
}
std::fprintf(stderr, "\n");
std::fflush(stderr);
events_group_probe_next(*a, 47, 400);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 47) {
// The mirror, a couple of frames after the nudge above: the y range set on the *first* plot has to
// be the second's too. Read before the accumulation, which rebuilds the model and the rows.
double y_b = 0.0;
double y_a = 0.0;
if (a->rows_b != nullptr && a->rows_b->row(0).plot != nullptr) {
y_b = a->rows_b->row(0).plot->y_range(a->rows_b->row(0).axis_y).min;
}
if (a->rows != nullptr && a->rows->row(0).plot != nullptr) {
y_a = a->rows->row(0).plot->y_range(a->rows->row(0).axis_y).min;
}
std::fprintf(stderr, "[events] compare sync: y_a=%.3f y_b=%.3f mirrored=%d\n", y_a, y_b,
(y_a == y_b && y_b == -3.5) ? 1 : 0);
events_group_probe_next(*a, 57, 400);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 57) {
// The drop, in a *file* view: the signal is not on any plot yet, so `on_drop_channel()` takes the
// path a drag takes -- and what it must not do is rebuild the model, which in this mode has no
// producer to refill it.
const std::size_t before = a->model->channels().size();
std::uint64_t samples_before = 0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
if (before > 0) samples_before = a->model->live().values(0).produced();
}
GValue value = G_VALUE_INIT;
g_value_init(&value, G_TYPE_STRING);
g_value_set_string(&value, "1"); // the second signal: on no row in the default layout
const gboolean ok = on_drop_channel(nullptr, &value, 40.0, 20.0, a);
g_value_unset(&value);
std::uint64_t samples_after = 0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
if (before > 0) samples_after = a->model->live().values(0).produced();
}
std::fprintf(stderr,
"[events] drop in a file view: ok=%d channels=%zu slot0=['%s'] slot1=['%s'] "
"samples=%llu->%llu\n",
ok ? 1 : 0, a->model->channels().size(),
a->rows->row(0).signal_name[0].c_str(), a->rows->row(0).signal_name[1].c_str(),
static_cast<unsigned long long>(samples_before),
static_cast<unsigned long long>(samples_after));
// One step later, after the figure has been rebuilt and the area re-pointed: what the row now
// *draws* -- the slot's channel and the traces the plot reports as visible.
events_group_probe_next(*a, 58, 600);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 58) {
std::fprintf(stderr, "[events] drop result: slot1_channel=%d visible_traces=%d\n",
a->rows->channel_of(0, 1), group_visible_traces(*a, 0));
events_group_probe_next(*a, 5, 400);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 59) {
// A comparison *after* an accumulation: the accumulation left layers in slots a comparison does not
// use, and those used to stay -- extra legend entries, in colours belonging to nothing.
if (a->events_compare != nullptr) g_signal_emit_by_name(a->events_compare, "clicked");
events_group_probe_next(*a, 60, 700);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 60) {
const RowSet::Row& r0 = a->rows->row(0);
std::fprintf(stderr, "[events] compare after accumulate: kind=%d traces0=%d legend0=%zu slots=[",
static_cast<int>(a->group_kind), group_visible_traces(*a, 0),
r0.plot != nullptr ? r0.plot->layout().legend.rows.size() : 0UL);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
std::fprintf(stderr, "%s'%s'", s ? "," : "",
a->rows->row(0).signal_name[static_cast<std::size_t>(s)].c_str());
}
std::fprintf(stderr, "]\n");
std::fflush(stderr);
go_live_again(*a);
std::fprintf(stderr, "[events] group left: replaying=%d kind=%d waveform=%d\n", a->replaying ? 1 : 0,
static_cast<int>(a->group_kind), a->waveform_name.empty() ? 0 : 1);
std::fflush(stderr);
// The restore is not this handler's: it waits for the restarted client's first CONFIG, so it is a
// *later tick* that performs it. Reading here would read the frame before the stream came back.
events_group_probe_next(*a, 61, 900);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 61) {
report_live_restored(*a, "after a group");
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 5) {
if (a->events_accumulate != nullptr) g_signal_emit_by_name(a->events_accumulate, "clicked");
events_group_probe_next(*a, 55, 700);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 55) {
{
std::lock_guard<std::mutex> lock(a->model->mutex());
std::fprintf(stderr,
"[events] accumulate: kind=%d rows=%d channels=%zu samples0=%llu selected=%zu "
"traces0=%d window=%.3f\n",
static_cast<int>(a->group_kind), a->rows->row_count(),
a->model->channels().size(),
static_cast<unsigned long long>(a->model->live().values(0).produced()),
selected_events(*a).size(), group_visible_traces(*a, 0), a->rows->time_window());
}
events_group_probe_next(*a, 59, 400);
return G_SOURCE_REMOVE;
}
return G_SOURCE_REMOVE;
}
void events_group_probe_next(App& a, int step, guint delay_ms) {
a.dbg_events_step = step;
g_timeout_add(delay_ms, events_group_probe_step, &a);
}
namespace {
struct NoiseLeg {
std::size_t hits = 0;
double first = std::numeric_limits<double>::quiet_NaN();
int state = 0;
};
/// One rising-edge search of the whole retained buffer at level 0.
///
/// Synchronous on purpose. Every leg must see the *same* buffer, and yielding to the main loop between legs
/// would let the producer append between them, so the counts would no longer be comparable. The bound is a
/// safety net: a chunk is `BufferSearch::kChunk` samples, so 20000 pumps cover 160 M of them, far more than
/// the ring holds.
NoiseLeg noise_leg(App* a, int channel, double dt, double band, double hf) {
EventCondition c;
c.type = TriggerType::Edge;
c.edge = TriggerEdge::Rising;
c.level = 0.0;
c.noise_band = band;
c.hf_fc = hf;
a->search.start(c, channel, dt);
for (int i = 0; i < 20000 && a->search.pump(*a->model); ++i) {
}
NoiseLeg r;
r.hits = a->search.hits().size();
if (r.hits > 0) r.first = a->search.hits().front();
r.state = static_cast<int>(a->search.state());
return r;
}
/// The difference of two first-hit stamps, folded into +/- half a period, so a pair whose first hits landed
/// on different cycles still reads as a delay.
double noise_fold(double d, double period) {
const double half = 0.5 * period;
d = std::fmod(d, period);
if (d > half) d -= period;
if (d < -half) d += period;
return d;
}
int noise_channel(App* a, const char* name) {
const std::vector<ChannelInfo>& channels = a->model->channels();
for (std::size_t i = 0; i < channels.size(); ++i) {
if (channels[i].name == name) return static_cast<int>(i);
}
return -1;
}
} // namespace
// Stage one of three. `apply_math_channels()` is a structural rebuild that drops the buffer's
// history, and every reading in `noise_probe` is taken over retained history -- so the rebuild
// happens here, five seconds before the first leg runs, and the buffer refills in between. The
// de-noise channel also needs that time: a math channel starts producing when it is applied.
//
// The channel is installed **disabled**, and `noise_probe_math` enables it two and a half seconds
// before its own reading. An acquired channel is a share of the ring, so a fifth one shortens every
// channel's retained span -- and that moves every figure above it: the six search legs' counts scale
// with the retained buffer, and `pkpk bare` is a peak-to-peak *of that window*. Those readings are
// the older feature's proof, so the new channel stays out of the acquisition plan until they have
// all run, and only the de-noise comparison is taken with it in.
//
// Fields assigned rather than brace-initialised: a partial brace-initialiser on `MathNode` is
// what the two `-Wmissing-field-initializers` in this file already are, and the build baseline
// is exactly four warnings.
gboolean noise_probe_setup(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
MathChannel dn;
dn.name = "math denoise";
dn.enabled = false; // enabled by `noise_probe_math`, so it costs no ring while the legs run
MathNode src_node;
src_node.op = MathOp::Source;
src_node.source = "sine";
MathNode dn_node;
dn_node.op = MathOp::DeNoise;
dn_node.in_a = 0;
dn_node.med_n = 5;
// The median alone: a 2-sample impulse is shorter than half a 5-sample window, so it is rejected
// outright and the pole has nothing left to do. A corner here would attenuate the 5 Hz sine as well
// and make the peak-to-peak reading a function of two things.
dn_node.fc = 0.0;
dn.nodes = {src_node, dn_node};
dn.output = 1;
a->math.push_back(dn);
apply_math_channels(*a);
std::fprintf(stderr, "[noise-probe] setup channels=%zu\n", a->math.size());
std::fflush(stderr);
// 3300 + 4700 = 8000 ms, where Tasks 4 and 8 measured the legs.
g_timeout_add(5000, noise_probe, a);
return G_SOURCE_REMOVE;
}
gboolean noise_probe(gpointer user) {
static int phase = 0;
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (phase == 0) {
// The signals this probe measures are enabled *here*. Not because it is tidier, but because it must not
// depend on another probe's side effects: `enable_all_probe` ends by switching one signal off again (it
// reads what a disabled signal does to the plots), and a run that arms both would take `sine` away.
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == "sine" || signals[i].name == "square") {
a->model->set_signal_enabled(i, true);
}
}
phase = 1;
// The enable is a structural change: it replaces the store, so the ring has to fill again before there
// is anything to search.
g_timeout_add(2500, noise_probe, a);
return G_SOURCE_REMOVE;
}
const int sine = noise_channel(a, "sine");
const int square = noise_channel(a, "square");
double dt = 0.0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
dt = a->model->partition().slot_dt();
}
if (sine < 0 || square < 0 || !(dt > 0.0)) {
// Name the channels that *are* there: an index alone says a probe found nothing, not what it saw.
std::string have;
for (const ChannelInfo& c : a->model->channels()) have += c.name + " ";
std::fprintf(stderr, "[noise-probe] no stream: sine=%d square=%d dt=%g channels=[%s]\n", sine, square,
dt, have.c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
// A: the bare comparator on a spiked sine. Every impulse landing where the sine is within the impulse's own
// amplitude of zero fires a crossing.
const NoiseLeg a_leg = noise_leg(a, sine, dt, 0.0, 0.0);
// B: a 0.8 band. A +/-0.5 impulse cannot carry the signal across a 1.6-wide band, so only the sine's own
// cycles are left.
const NoiseLeg b_leg = noise_leg(a, sine, dt, 0.8, 0.0);
// C: no band, a 50 Hz reject. A 2-sample 0.5 impulse moves a pole with a = 1 - exp(-2*pi*50*dt) by about
// 5e-4, which cannot reach the level.
const NoiseLeg c_leg = noise_leg(a, sine, dt, 0.0, 50.0);
// D: both. The reject attenuates a 5 Hz sine by only 1/sqrt(1 + (5/50)^2), so its peaks still clear the band
// and the count is unchanged.
const NoiseLeg d_leg = noise_leg(a, sine, dt, 0.8, 50.0);
// E/F: the reject's group delay, on `square`, which carries no impulses. A one-pole turns a -1 -> +1 step
// into -1 + 2*(1 - exp(-t/tau)), which crosses zero at tau*ln2 -- exact, and the same on every edge.
const NoiseLeg e_leg = noise_leg(a, square, dt, 0.0, 0.0);
const NoiseLeg f_leg = noise_leg(a, square, dt, 0.0, 50.0);
const double period = 0.2; // the mock's default --freq is 5 Hz.
const double delay = noise_fold(f_leg.first - e_leg.first, period);
const double want = std::log(2.0) / (2.0 * 3.14159265358979323846 * 50.0);
std::fprintf(stderr, "[noise-probe] trigger band: bare=%zu/%d band=%zu/%d ratio=%.2f\n", a_leg.hits,
a_leg.state, b_leg.hits, b_leg.state,
b_leg.hits > 0 ? static_cast<double>(a_leg.hits) / static_cast<double>(b_leg.hits) : 0.0);
std::fprintf(stderr, "[noise-probe] trigger hf: hf=%zu/%d band+hf=%zu/%d band=%zu\n", c_leg.hits,
c_leg.state, d_leg.hits, d_leg.state, b_leg.hits);
std::fprintf(stderr,
"[noise-probe] trigger delay: clean=%zu filtered=%zu delay=%.6f want=%.6f ratio=%.3f\n",
e_leg.hits, f_leg.hits, delay, want, want > 0.0 ? delay / want : 0.0);
std::fflush(stderr);
// Five measurements on the spiked sine, as *two* windows: three legs at `hf=0` and two at `hf=50`. The
// pane keys its walks by (channel, corner), so this is also what checks that a second corner does not
// reuse the first one's walk.
std::string name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
if (a->model->channels().empty()) return G_SOURCE_REMOVE;
name = a->model->channels().front().name; // `sine`; signal 0 is the timestamp
}
struct Leg {
const char* label;
MeasureType type;
double noise;
double hf;
};
// The impulses dominate the window's extremes, so `peak to peak` reads the *stimulus* (about 3)
// unfiltered and the *waveform* (about 2) filtered -- the one reading that says the filter reached pass
// one and not only pass two.
const Leg legs[] = {
{"pkpk bare", MeasureType::PeakToPeak, 10.0, 0.0},
{"pkpk hf", MeasureType::PeakToPeak, 10.0, 50.0},
{"period noband", MeasureType::Period, 0.0, 0.0},
// 25 % and not the 10 % default: the band is a share of the *window's* peak-to-peak, which with the
// impulses in it is about 3, so 10 % is a half-band of 0.3 -- less than the 0.5 an impulse moves the
// signal by, and the `MeasureOptions::noise_pct` comment says as much ("10 % holds to an interferer
// amplitude of 0.3"). The default is what `period noband` and `period hf` are about; this leg asks for
// a band that clears the stimulus, which is what makes the reading legible.
{"period band", MeasureType::Period, 25.0, 0.0},
{"period hf", MeasureType::Period, 0.0, 50.0},
};
const std::size_t base = a->measures.size();
for (const Leg& leg : legs) {
MeasureOptions options;
options.noise_pct = leg.noise;
options.hf_fc = leg.hf;
a->measures.push_back({name, leg.type, options});
}
rebuild_measures_pane(*a);
update_measures(*a, /*force=*/true, /*accumulate=*/true);
for (std::size_t i = 0; i < std::size(legs); ++i) {
const std::size_t at = base + i;
// A *value*, not a reference: the conditional's other arm is a temporary.
const MeasureResult r = at < a->measure_results.size() ? a->measure_results[at] : MeasureResult{};
std::fprintf(stderr, "[noise-probe] measure %-14s noise=%.1f hf=%.1f valid=%d value=%.6f\n",
legs[i].label, legs[i].noise, legs[i].hf, int(r.valid), r.primary);
}
std::fprintf(stderr, "[noise-probe] measure windows=%zu\n", a->measure_samples.size());
std::fflush(stderr);
a->search.clear();
update_search_panel(*a);
g_timeout_add(2500, noise_probe_math, a);
return G_SOURCE_REMOVE;
}
gboolean noise_probe_math(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
{
// Enabled here for the reason `noise_probe_setup` gives: until this moment the channel must not
// be a share of the ring. Enabled *by name*, through the same switch the panel drives, so the
// model and the panel agree.
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == "math denoise") a->model->set_signal_enabled(i, true);
}
}
// A fill before the reading: the channel's first sample is the first one after this enable, and a
// peak-to-peak needs a window with a sine trough *and* a crest in it. Two and a half seconds is
// twelve cycles of the 5 Hz source.
g_timeout_add(2500, noise_probe_denoise, a);
return G_SOURCE_REMOVE;
}
gboolean noise_probe_denoise(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const std::size_t base = a->measures.size();
// Both unfiltered and at no band: the only difference between the two readings is the node.
MeasureOptions options;
a->measures.push_back({std::string("sine"), MeasureType::PeakToPeak, options});
a->measures.push_back({std::string("math denoise"), MeasureType::PeakToPeak, options});
rebuild_measures_pane(*a);
update_measures(*a, /*force=*/true, /*accumulate=*/true);
const MeasureResult raw =
base < a->measure_results.size() ? a->measure_results[base] : MeasureResult{};
const MeasureResult den =
base + 1 < a->measure_results.size() ? a->measure_results[base + 1] : MeasureResult{};
std::fprintf(stderr,
"[noise-probe] denoise raw=%.6f/%d denoised=%.6f/%d ratio=%.3f channels=%zu\n",
raw.primary, int(raw.valid), den.primary, int(den.valid),
raw.primary > 0.0 ? den.primary / raw.primary : 0.0, a->math.size());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean drop_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->model->signals().empty()) return G_SOURCE_REMOVE;
// `repins` beside the view: a file's extent is fixed, so the pin is stated once and only its
// numbers are refreshed afterwards. A count that tracks the frame number is the pin being written
// every frame, which flickers the view and swallows every gesture.
const plot::Range view = a->rows->view_range();
std::fprintf(stderr,
"[drop-probe] signals=%zu channels=%zu rows=%d slots0=[%s|%s|%s|%s] "
"view=[%.6f..%.6f] repins=%d\n",
a->model->signals().size(), a->model->channels().size(), a->rows->row_count(),
a->rows->row(0).name[0].c_str(), a->rows->row(0).name[1].c_str(),
a->rows->row(0).name[2].c_str(), a->rows->row(0).name[3].c_str(), view.min, view.max,
a->dbg_file_repins);
for (int signal = 0; signal < 2 && static_cast<std::size_t>(signal) < a->model->signals().size();
++signal) {
GValue value = G_VALUE_INIT;
g_value_init(&value, G_TYPE_STRING);
g_value_set_string(&value, std::to_string(signal).c_str());
const gboolean ok = on_drop_channel(nullptr, &value, 40.0, 20.0, a);
g_value_unset(&value);
std::fprintf(stderr, "[drop-probe] dropped signal %d -> %d slots=[%s|%s|%s|%s]\n", signal,
ok ? 1 : 0, a->rows->row(0).name[0].c_str(), a->rows->row(0).name[1].c_str(),
a->rows->row(0).name[2].c_str(), a->rows->row(0).name[3].c_str());
}
// Fill the row first, so that the drop below is the *refusal*: a drop can otherwise land in a slot
// whose signal is named but not acquired, which is a success rather than the case under test.
{
const std::vector<ChannelInfo>& channels = a->model->channels();
for (int slot = 0; slot < RowSet::kSlotsPerRow && !channels.empty(); ++slot) {
if (a->rows->slot_bound(0, slot)) continue;
const ChannelInfo& ch = channels[static_cast<std::size_t>(slot) % channels.size()];
a->rows->assign(0, slot, ch.name, ch.name, 0);
}
}
// The refusal's *feedback*, a tick later: the status line composes the notice into itself on the
// next tick, so this is where both the text and the class that makes it visible can be read.
g_timeout_add(500, +[](gpointer u) -> gboolean {
App* app = static_cast<App*>(u);
// And the drop that the full row has to refuse, fired here so its feedback is what the reading below
// sees rather than the last success's.
GValue refused_value = G_VALUE_INIT;
g_value_init(&refused_value, G_TYPE_STRING);
g_value_set_string(&refused_value, "1");
const gboolean refused = on_drop_channel(nullptr, &refused_value, 40.0, 20.0, app);
g_value_unset(&refused_value);
std::fprintf(stderr, "[drop-probe] full row refused the drop: %d (want 0)\n", refused ? 1 : 0);
// The message and its class, which are set the moment the drop is refused. The *label* is read in
// the later line instead: the status line composes the notice into itself on its own cadence, so
// here it may still hold the previous one.
std::fprintf(stderr, "[drop-probe] refused feedback: notice='%s' warning=%d\n",
app->notice.c_str(),
gtk_widget_has_css_class(app->status_label, "warning") ? 1 : 0);
std::fflush(stderr);
// Again a second and a half later: if the label is still the previous notice then the status line
// never recomposes it, which would be the whole of the "no clear feedback" report.
g_timeout_add(1500, +[](gpointer u) -> gboolean {
App* app = static_cast<App*>(u);
std::fprintf(stderr,
"[drop-probe] the status line, composed: warning=%d status='%s'\n",
gtk_widget_has_css_class(app->status_label, "warning") ? 1 : 0,
gtk_label_get_text(GTK_LABEL(app->status_label)));
std::fflush(stderr);
return G_SOURCE_REMOVE;
}, app);
return G_SOURCE_REMOVE;
}, a);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean defaults_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->model->signals().empty()) return G_SOURCE_REMOVE;
open_calibration(*a, GTK_WINDOW(a->window), 0);
std::vector<double> cal;
{
// The dialog is a plain window with a title; find it by that title, because
// `open_calibration()` keeps no handle -- the probe is not going to add one just for itself.
GListModel* windows = gtk_window_get_toplevels();
const guint n = g_list_model_get_n_items(windows);
for (guint i = 0; i < n; ++i) {
GtkWindow* w = GTK_WINDOW(g_list_model_get_item(windows, i));
const char* title = gtk_window_get_title(w);
if (title != nullptr && std::string(title) == "Calibrate") {
collect_spins(gtk_window_get_child(w), &cal);
gtk_window_close(w);
}
g_object_unref(w);
}
}
std::vector<double> trig;
collect_spins(a->trigger_panel, &trig);
const std::pair<double, double> first = a->model->calibration(0);
std::fprintf(stderr, "[defaults] calibrate:");
for (double v : cal) std::fprintf(stderr, " %.6g", v);
std::fprintf(stderr, " model=%.6g %.6g | trigger:", first.first, first.second);
for (double v : trig) std::fprintf(stderr, " %.6g", v);
std::fprintf(stderr, "\n");
// A signal that *is* calibrated: the dialog must open at the stored values, which is the whole
// point of reading them. Set through the model's own setter -- the dialog's Apply calls it -- and
// read back through the dialog, so what is asserted is that the two agree.
a->model->set_calibration(0, 2.5, -1.25);
cal.clear();
open_calibration(*a, GTK_WINDOW(a->window), 0);
{
GListModel* windows = gtk_window_get_toplevels();
const guint n = g_list_model_get_n_items(windows);
for (guint i = 0; i < n; ++i) {
GtkWindow* w = GTK_WINDOW(g_list_model_get_item(windows, i));
const char* title = gtk_window_get_title(w);
if (title != nullptr && std::string(title) == "Calibrate") {
collect_spins(gtk_window_get_child(w), &cal);
gtk_window_close(w);
}
g_object_unref(w);
}
}
const std::pair<double, double> stored = a->model->calibration(0);
std::fprintf(stderr, "[defaults] calibrated:");
for (double v : cal) std::fprintf(stderr, " %.6g", v);
std::fprintf(stderr, " model=%.6g %.6g\n", stored.first, stored.second);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean keys_probe(gpointer user) {
App* a = static_cast<App*>(user);
std::string line = "[keys]";
const auto before_after = [&](const char* name, GtkWidget* button, AppAction action) {
const int before = button != nullptr
? (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button)) ? 1 : 0)
: -1;
run_app_action(*a, action);
const int after = button != nullptr
? (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button)) ? 1 : 0)
: -1;
char buf[96];
std::snprintf(buf, sizeof(buf), " %s: %d->%d |", name, before, after);
line += buf;
};
// Read the lock-protected field, not the atomic mirror: on_arm_clicked() writes
// trigger().armed directly, while trigger_armed() (the atomic) is updated by the
// acquisition thread on the next packet -- reading it immediately would race.
const bool armed_before = a->model->trigger().armed;
run_app_action(*a, AppAction::TriggerArm);
char armed[64];
std::snprintf(armed, sizeof(armed), " a: armed %d->%d |", armed_before ? 1 : 0,
a->model->trigger().armed ? 1 : 0);
line += armed;
const bool paused_before = a->acq->paused();
run_app_action(*a, AppAction::PauseToggle);
char paused[64];
std::snprintf(paused, sizeof(paused), " space: paused %d->%d |", paused_before ? 1 : 0,
a->acq->paused() ? 1 : 0);
line += paused;
run_app_action(*a, AppAction::PauseToggle); // leave the run live for the other probes
before_after("t: trigger panel", a->trigger_toggle, AppAction::TriggerPanel);
before_after("c: cursors", a->cursors_toggle, AppAction::CursorsPane);
{
const std::string before_tab = a->side_tab;
run_app_action(*a, AppAction::SideTabMeasure);
const std::string after_tab = a->side_tab;
char buf[96];
std::snprintf(buf, sizeof(buf), " m: tab %s->%s |", before_tab.c_str(), after_tab.c_str());
line += buf;
}
{
const std::string before_tab = a->side_tab;
run_app_action(*a, AppAction::SideTabSignals);
const std::string after_tab = a->side_tab;
char buf[96];
std::snprintf(buf, sizeof(buf), " g: tab %s->%s |", before_tab.c_str(), after_tab.c_str());
line += buf;
}
before_after("l: layout", a->layout_toggle, AppAction::LayoutEditor);
before_after("s: side panel", a->sidebar_toggle, AppAction::SidePanel);
// An accel must be refused while text is being typed. Focus the window entry, send 'm'
// through the real handler, and require the side tab not to change.
int refused = -1;
if (a->window_entry != nullptr) {
gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button));
gtk_widget_grab_focus(a->window_entry);
const std::string before_tab = a->side_tab;
const gboolean handled = on_app_key_pressed(nullptr, GDK_KEY_m, 0,
static_cast<GdkModifierType>(0), a);
const std::string after_tab = a->side_tab;
refused = (handled == FALSE && before_tab == after_tab) ? 1 : 0;
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->window_button));
}
char tail[64];
std::snprintf(tail, sizeof(tail), " editable focused: 'm' refused=%d\n", refused);
line += tail;
std::fprintf(stderr, "%s", line.c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// What the shortcut overview's UI file *declares*, read out of the resource rather than off the
/// built widgets: `GtkShortcutsWindow` reaches each row from more than one place in its own tree, so a
/// walk counts every row twice (52 for 26), and what a reader counts on the screen is the declaration.
///
/// The two numbers that matter are `with_accel`, the rows drawn as keycaps, and `with_icon`, the ones
/// drawn as an icon. `bare` is a row with *neither*, which is what a cell reading "No Shortcut" is --
/// the reason this overview is a GTK window built from a UI file rather than libadwaita's dialog, whose
/// items have no icon property at all. It must be zero.
struct UiShortcuts {
int rows = -1;
int with_accel = 0;
int with_icon = 0;
int bare = 0;
// Per-row state, and the depth that tells a row's own `</object>` from the one closing the
// `GThemedIcon` nested inside its icon property.
int depth = 0;
int row_depth = -1;
bool row_accel = false;
bool row_icon = false;
bool in_property = false;
std::string property;
};
const char* attribute(const gchar** names, const gchar** values, const char* want) {
for (int i = 0; names[i] != nullptr; ++i) {
if (g_strcmp0(names[i], want) == 0) return values[i];
}
return nullptr;
}
void ui_row_start(GMarkupParseContext*, const gchar* name, const gchar** names, const gchar** values,
gpointer user, GError**) {
UiShortcuts& out = *static_cast<UiShortcuts*>(user);
if (g_strcmp0(name, "object") == 0) {
++out.depth;
if (g_strcmp0(attribute(names, values, "class"), "GtkShortcutsShortcut") == 0) {
out.row_depth = out.depth;
out.row_accel = false;
out.row_icon = false;
}
return;
}
if (out.row_depth < 0 || g_strcmp0(name, "property") != 0) return;
const char* prop = attribute(names, values, "name");
out.property = prop != nullptr ? prop : "";
// Only an accelerator is carried as text; an icon's value is a nested `GThemedIcon` object, so its
// *declaration* is the whole of what there is to see and it is marked here rather than in the text
// callback that would never fire for it.
out.in_property = out.property == "accelerator";
if (out.property == "icon") out.row_icon = true;
}
/// The text callbacks carry the property's *value*; an empty `accelerator` is not a keycap, so only
/// non-empty text counts.
void ui_row_text(GMarkupParseContext*, const gchar* text, gsize len, gpointer user, GError**) {
(void)text; // the length is what the callback is asked for; the bytes are not
UiShortcuts& out = *static_cast<UiShortcuts*>(user);
if (!out.in_property || len == 0) return;
if (out.property == "accelerator") out.row_accel = true;
}
void ui_row_end(GMarkupParseContext*, const gchar* name, gpointer user, GError**) {
UiShortcuts& out = *static_cast<UiShortcuts*>(user);
if (g_strcmp0(name, "property") == 0) {
out.in_property = false;
return;
}
if (g_strcmp0(name, "object") != 0) return;
if (out.depth == out.row_depth) {
out.row_depth = -1;
++out.rows;
if (out.row_accel) {
++out.with_accel;
} else if (out.row_icon) {
++out.with_icon;
} else {
++out.bare;
}
}
--out.depth;
}
UiShortcuts ui_shortcut_rows(const char* path) {
UiShortcuts out;
GError* error = nullptr;
GBytes* bytes = g_resources_lookup_data(path, G_RESOURCE_LOOKUP_FLAGS_NONE, &error);
if (bytes == nullptr) {
if (error != nullptr) g_error_free(error);
return out;
}
gsize len = 0;
const char* data = static_cast<const char*>(g_bytes_get_data(bytes, &len));
GMarkupParser parser{&ui_row_start, &ui_row_end, &ui_row_text, nullptr, nullptr};
if (GMarkupParseContext* ctx = g_markup_parse_context_new(&parser, G_MARKUP_DEFAULT_FLAGS, &out,
nullptr);
ctx != nullptr) {
out.rows = 0;
if (!g_markup_parse_context_parse(ctx, data, static_cast<gssize>(len), &error) && error != nullptr) {
g_error_free(error);
out.rows = 0; // a file this probe cannot parse is not a file with no rows
}
g_markup_parse_context_free(ctx);
}
g_bytes_unref(bytes);
return out;
}
/// Prints the manual launch's outcome, which arrives in a callback (the launch is asynchronous and
/// the viewer is another process) -- so the line is written from a timer a moment later rather than
/// from `help_probe`. `done=0` is a launch that never called back, which is a reading of its own.
gboolean help_launch_report(gpointer user) {
App* a = static_cast<App*>(user);
const std::string installed = help_installed_path();
std::fprintf(stderr, "[help] launch attempted=%d done=%d ok=%d error='%s' installed=%s\n",
a->dbg_help_launch_attempted ? 1 : 0, a->dbg_help_launch_done ? 1 : 0,
a->dbg_help_launch_ok ? 1 : 0, a->dbg_help_launch_error.c_str(),
installed.empty() ? "(not found)" : installed.c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean help_probe(gpointer user) {
App* a = static_cast<App*>(user);
const HelpReport report = help_self_check();
const std::string session =
layout_path(a->model->config().session_host(), a->model->config().session_port());
// The manual is *launched* here and read no further: it opens in another process (Yelp, through
// the `help:` URI) and neither it nor this box can draw a window for it, so there is nothing to
// read back or close. `help_self_check()` above is the assertion on the *set* being on disk where
// the build says it is; the launch is the assertion on the *path*, and its outcome is reported by
// `help_launch_report()` once the launch's callback has run. The failure alert the callback shows
// is deliberately left up -- it is what a user sees, and the probe must not report a failure as a
// silence. The two AdwDialogs below are still built, read and closed for real.
show_about(GTK_WINDOW(a->window), session);
show_shortcuts(GTK_WINDOW(a->window));
// Read while it is open: the icon is the point of the dialog carrying one, and
// `close_help_windows()` is right below.
const std::string about_icon = about_icon_name();
close_help_windows();
// The manual's path: on a successful launch no window is left behind, so this cannot be observed
// any other way; `show_help()`'s reporter is the only hook that runs on that path.
a->dbg_help_launch_attempted = true;
a->dbg_help_launch_done = false;
a->dbg_help_launch_ok = false;
a->dbg_help_launch_error.clear();
show_help(GTK_WINDOW(a->window), [a](bool ok, const std::string& error) {
a->dbg_help_launch_done = true;
a->dbg_help_launch_ok = ok;
a->dbg_help_launch_error = error;
});
int accel_count = 0;
int gesture_count = 0;
(void)app_accels(&accel_count);
(void)plot_gestures(&gesture_count);
// The tables are what the application handles, the UI file is what it shows. They are compared here
// because they are two lists of the same thing: a row in one and not the other is a gesture that
// works and is not documented, or is documented and does nothing.
const UiShortcuts ui = ui_shortcut_rows("/org/udpscope/ui/shortcuts.ui");
std::fprintf(stderr,
"[help] topics=%d index=%d figures=%d dir=%s; shortcuts: app=%d plot=%d tables=%d "
"ui=%d ui_accel=%d ui_icon=%d ui_bare=%d; "
"about version=%s commit=%s built=%s settings=%s icon=%s; closed ok\n",
report.topics, report.index ? 1 : 0, report.figures, UDPSCOPE_HELP_DIR,
accel_count, gesture_count, accel_count + gesture_count,
ui.rows, ui.with_accel, ui.with_icon, ui.bare, build_info::kVersion,
build_info::kCommit, build_info::kTimestamp, config_dir().c_str(),
about_icon.c_str());
std::fflush(stderr);
// The launch's outcome needs its callback to have run; a moment is enough for a local
// `g_app_info_launch_default_for_uri()`, and `done=0` in the line is how a launch that never
// called back reads.
g_timeout_add(600, help_launch_report, a);
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_HELPUI_MS`: the help affordance layer, end to end -- the first-run auto-start, the
/// four entries of the Help submenu, the contextual-help toggle and what it does to the "?"
/// affordances, and the tour started, stepped and dismissed.
///
/// The steps are 300 ms apart because everything it measures is an *allocation*: a widget's is 0 in
/// the tick it is first shown (see the allocation bullets in
/// `docs/claude/invariants-trigger-and-pinning.md`), so a step that read its own predecessor's
/// geometry would read a rectangle from before the frame that placed it.
///
/// The tour is started *from here* rather than from the window, so the probe path is exercised with
/// whatever history the run has; the auto-start is read first, before this touches anything.
void helpui_probe_next(App& a, int step, guint delay_ms) {
a.dbg_helpui_step = step;
g_timeout_add(delay_ms, helpui_probe_step, &a);
}
/// The window's event controllers, and how many of them are clicks. A tour that grabs input and does
/// not give it back shows up here as a count that grew.
void helpui_count_controllers(App* a, int* total, int* clicks) {
*total = 0;
*clicks = 0;
if (a->window == nullptr) return;
GListModel* controllers = gtk_widget_observe_controllers(a->window);
const guint n = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0;
for (guint i = 0; i < n; ++i) {
GObject* item = G_OBJECT(g_list_model_get_item(controllers, i));
if (item != nullptr) {
++*total;
if (GTK_IS_GESTURE_CLICK(item)) ++*clicks;
g_object_unref(item);
}
}
if (controllers != nullptr) g_object_unref(controllers);
}
/// The view the menu is showing: the stack's visible child, which is where the rows are.
GtkWidget* helpui_menu_view(App* a) {
if (a->menu_stack == nullptr) return nullptr;
return gtk_stack_get_visible_child(GTK_STACK(a->menu_stack));
}
/// One line: the spotlight, the widget it spotlights, and the card, all in **window coordinates** --
/// the frame a screenshot is cropped in -- with the window's own record of the sidebar page beside
/// them, so a stop that had to change the page to find its target is readable as such. `contains` is
/// the spotlight covering its target (it is the target's own rectangle, grown three pixels) and
/// `clear` is the card missing the target. `probe` is the tag it is printed under, so a window with
/// more than one tour probe can tell its lines apart.
void tour_geometry_line(App& a, const char* probe, const char* what) {
graphene_rect_t spot = GRAPHENE_RECT_INIT(0, 0, 0, 0);
graphene_rect_t target = GRAPHENE_RECT_INIT(0, 0, 0, 0);
graphene_rect_t card = GRAPHENE_RECT_INIT(0, 0, 0, 0);
GtkWidget* t = tour_target(a);
const bool have_spot =
a.tour.spot != nullptr && gtk_widget_compute_bounds(a.tour.spot, a.window, &spot);
const bool have_target = t != nullptr && gtk_widget_get_mapped(t) &&
gtk_widget_compute_bounds(t, a.window, &target);
const bool have_card =
a.tour.card != nullptr && gtk_widget_compute_bounds(a.tour.card, a.window, &card);
const auto contains = [](const graphene_rect_t& outer, const graphene_rect_t& inner) {
return outer.origin.x <= inner.origin.x + 0.5 && outer.origin.y <= inner.origin.y + 0.5 &&
outer.origin.x + outer.size.width >= inner.origin.x + inner.size.width - 0.5 &&
outer.origin.y + outer.size.height >= inner.origin.y + inner.size.height - 0.5;
};
const auto overlaps = [](const graphene_rect_t& p, const graphene_rect_t& q) {
return p.origin.x < q.origin.x + q.size.width && q.origin.x < p.origin.x + p.size.width &&
p.origin.y < q.origin.y + q.size.height && q.origin.y < p.origin.y + p.size.height;
};
std::fprintf(stderr,
"[%s] %s=%d/%d visible=%d side_tab=%s spot=[%.0f,%.0f %.0fx%.0f] "
"target=[%.0f,%.0f %.0fx%.0f] contains=%d card=[%.0f,%.0f %.0fx%.0f] clear=%d\n",
probe, what, tour_step_index(a) + 1, tour_step_count(a), tour_active(a) ? 1 : 0,
a.side_tab.c_str(), spot.origin.x, spot.origin.y, spot.size.width, spot.size.height,
target.origin.x, target.origin.y, target.size.width, target.size.height,
have_spot && have_target && contains(spot, target) ? 1 : 0, card.origin.x,
card.origin.y, card.size.width, card.size.height,
have_card && have_target && !overlaps(card, target) ? 1 : 0);
}
gboolean helpui_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const int step = a->dbg_helpui_step;
if (step == 0) {
// The run's own history decides the auto-start (`settings.tour_seen`), and this reads it before
// anything else touches the tour: a throwaway config directory is a first run, and a second run
// against the same one is not.
std::fprintf(stderr,
"[helpui] auto_started=%d visible=%d steps=%d step=%d (%s)\n",
a->tour_auto_started ? 1 : 0, tour_active(*a) ? 1 : 0, tour_step_count(*a),
tour_step_index(*a),
a->settings.tour_seen ? "tour_seen already set" : "first run");
if (tour_active(*a)) tour_dismiss(*a);
std::string ids;
const int hints = context_help_count(*a, &ids);
int hint_w = 0;
int hint_h = 0;
context_help_size(*a, &hint_w, &hint_h);
std::fprintf(stderr, "[helpui] contextual on: hints=%d ids={%s} hint=%dx%d\n", hints,
ids.c_str(), hint_w, hint_h);
int total = 0;
int clicks = 0;
helpui_count_controllers(a, &total, &clicks);
a->dbg_helpui_controllers = total;
// Autohide off *for the probe only*, the same as the menu probe: a popover dismisses itself when
// it loses the pointer grab, and under a bare `xvfb-run` there is no window manager to hand it
// one, so the rows would be measured before they were ever mapped.
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
if (popover != nullptr) gtk_popover_set_autohide(popover, FALSE);
gtk_menu_button_popup(GTK_MENU_BUTTON(a->menu_button));
helpui_probe_next(*a, 1, 300);
return G_SOURCE_REMOVE;
}
if (step == 1) {
// The menu's own view first: the door into the Help view has to be on it.
GtkWidget* box = helpui_menu_view(a);
int rows = 0;
int help_row = 0;
for (GtkWidget* row = box != nullptr ? gtk_widget_get_first_child(box) : nullptr;
row != nullptr; row = gtk_widget_get_next_sibling(row)) {
if (!GTK_IS_BUTTON(row)) continue;
GtkWidget* label = nullptr;
GtkWidget* accel = nullptr;
menu_row_labels(gtk_widget_get_first_child(row), &label, &accel);
if (label != nullptr && std::strcmp(gtk_label_get_text(GTK_LABEL(label)), "Help") == 0) {
help_row = 1;
}
++rows;
}
std::fprintf(stderr, "[helpui] main view: rows=%d help_row=%d\n", rows, help_row);
// Into the Help view *through the row itself*: the handler is the thing that can break, so the
// probe goes the way a user goes.
if (a->menu_help_row != nullptr) g_signal_emit_by_name(a->menu_help_row, "clicked");
helpui_probe_next(*a, 2, 300);
return G_SOURCE_REMOVE;
}
if (step == 2) {
static const char* const kExpected[] = {"Back", "Keyboard shortcuts", "Documentation",
"Guided tour", "Enable contextual help"};
constexpr int kExpectedCount = static_cast<int>(sizeof(kExpected) / sizeof(kExpected[0]));
GtkWidget* box = helpui_menu_view(a);
int rows = 0;
int bad = 0;
for (GtkWidget* row = box != nullptr ? gtk_widget_get_first_child(box) : nullptr;
row != nullptr; row = gtk_widget_get_next_sibling(row)) {
if (!GTK_IS_BUTTON(row)) continue;
GtkWidget* label = nullptr;
GtkWidget* accel = nullptr;
menu_row_labels(gtk_widget_get_first_child(row), &label, &accel);
const char* text = label != nullptr ? gtk_label_get_text(GTK_LABEL(label)) : "";
const char* key = accel != nullptr ? gtk_label_get_text(GTK_LABEL(accel)) : "";
const bool named = rows < kExpectedCount && std::strcmp(text, kExpected[rows]) == 0;
if (!named) ++bad;
std::fprintf(stderr, "[helpui] entry %d '%s' accel='%s' order=%d\n", rows, text, key,
named ? 1 : 0);
++rows;
}
const bool on = a->contextual_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->contextual_toggle)) != FALSE;
std::fprintf(stderr, "[helpui] help view: rows=%d expected=%d rows_bad=%d back=%d toggle=%d "
"entries='%s|%s|%s'\n",
rows, kExpectedCount, bad, rows > 0 ? 1 : 0, on ? 1 : 0,
a->menu_shortcuts_row != nullptr ? "Keyboard shortcuts" : "?",
a->menu_docs_row != nullptr ? "Documentation" : "?",
a->menu_tour_row != nullptr ? "Guided tour" : "?");
// The toggle's *effect*: off means the affordances are gone, not greyed. Both directions are
// driven through the row, because the row is what writes the setting.
if (a->contextual_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->contextual_toggle), FALSE);
}
std::string off_ids;
const int off_hints = context_help_count(*a, &off_ids);
if (a->contextual_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->contextual_toggle), TRUE);
}
std::string on_ids;
const int on_hints = context_help_count(*a, &on_ids);
std::fprintf(stderr, "[helpui] toggle: off -> hints=%d ids={%s}; on -> hints=%d ids={%s}; "
"setting=%s\n",
off_hints, off_ids.c_str(), on_hints, on_ids.c_str(),
a->settings.contextual_help ? "on" : "off");
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
if (popover != nullptr) gtk_popover_set_autohide(popover, TRUE);
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->menu_button));
helpui_probe_next(*a, 3, 300);
return G_SOURCE_REMOVE;
}
if (step == 3) {
start_tour(*a);
std::fprintf(stderr, "[helpui] tour started from the probe: visible=%d steps=%d step=%d\n",
tour_active(*a) ? 1 : 0, tour_step_count(*a), tour_step_index(*a));
helpui_probe_next(*a, 4, 400);
return G_SOURCE_REMOVE;
}
if (step == 4 || step == 5) {
// The spotlight against the widget it spotlights, and the card against the same widget: the
// spotlight must *contain* the target, and the card must not overlap it when the window has room
// for a card beside or above/below it. All of it is an allocation, so it is read one frame after
// the step it describes: `update_tour()` places this frame from the allocation the last one left.
tour_geometry_line(*a, "helpui", "step");
if (step == 4) {
// Next, which is what a user presses to walk the tour.
tour_step(*a, 1);
std::fprintf(stderr, "[helpui] after Next: step=%d/%d visible=%d\n", tour_step_index(*a) + 1,
tour_step_count(*a), tour_active(*a) ? 1 : 0);
helpui_probe_next(*a, 5, 400);
return G_SOURCE_REMOVE;
}
// Back, then Skip: the two ways out that are not the last step.
tour_step(*a, -1);
std::fprintf(stderr, "[helpui] after Back: step=%d/%d visible=%d\n", tour_step_index(*a) + 1,
tour_step_count(*a), tour_active(*a) ? 1 : 0);
tour_dismiss(*a);
// Dismissed, and nothing left behind. `controllers=` against the count taken before the tour
// opened is the reading for "no lingering input grab": this overlay is ordinary widgets and
// takes no grab at all, so a count that grew would be a grab somebody added.
int total = 0;
int clicks = 0;
helpui_count_controllers(a, &total, &clicks);
const auto hidden = [](GtkWidget* w) { return w == nullptr || !gtk_widget_is_visible(w); };
std::fprintf(stderr,
"[helpui] after Skip: visible=%d dims_hidden=%d spot_hidden=%d card_hidden=%d "
"controllers=%d (was %d) click_gestures=%d\n",
tour_active(*a) ? 1 : 0,
hidden(a->tour.dim[0]) && hidden(a->tour.dim[1]) && hidden(a->tour.dim[2]) &&
hidden(a->tour.dim[3])
? 1
: 0,
hidden(a->tour.spot) ? 1 : 0, hidden(a->tour.card) ? 1 : 0, total,
a->dbg_helpui_controllers, clicks);
capture_help_state(*a);
std::fprintf(stderr, "[helpui] layer: %s\n", a->dbg_context_help.c_str());
helpui_probe_next(*a, 6, 300);
return G_SOURCE_REMOVE;
}
if (step == 6 || step == 8) {
// The trigger pane's summary grid is on screen only in *locked* mode, and one of the help's
// figures is that content: the probe reads `help figures:` in both states, so the geometry agent
// has the locked pane's numbers without having to guess how to get there.
if (a->trigger_edit != nullptr) g_signal_emit_by_name(a->trigger_edit, "clicked");
helpui_probe_next(*a, step == 6 ? 7 : 9, 400);
return G_SOURCE_REMOVE;
}
// Step 7 (locked) and step 9 (back in configuration mode): one capture each, so each line is the
// state it names.
capture_help_state(*a);
std::fprintf(stderr, "[helpui] figures %s: %s\n", step == 7 ? "locked" : "config",
a->dbg_help_figures.c_str());
if (step == 7) {
helpui_probe_next(*a, 8, 400);
return G_SOURCE_REMOVE;
}
// The search block's "(i)" rides the expander's *header* box, which is in the widget tree and on
// screen while the block is collapsed -- unlike the expander's child, which is out of the
// widget-parent chain until it is expanded (see the invariant). Read here, in config mode with the
// pane and the sidebar up and the block still collapsed: `mapped=1` and a real size *on a collapsed
// expander* is the whole point, and a hint hung on the child would read 0x0 here.
if (a->trigger_search != nullptr) {
GtkWidget* hint = gtk_widget_get_last_child(a->trigger_search);
int sw = 0;
int sh = 0;
context_help_size_in(hint, &sw, &sh);
std::fprintf(stderr,
"[helpui] collapsed search: expanded=%d mapped=%d mapped_header=%d hint=%dx%d\n",
a->search_exp != nullptr && gtk_expander_get_expanded(GTK_EXPANDER(a->search_exp))
? 1
: 0,
hint != nullptr && gtk_widget_get_mapped(hint) ? 1 : 0,
gtk_widget_get_mapped(a->trigger_search) ? 1 : 0, sw, sh);
}
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
// --- the tour's surface switch: a stop that stands on another stack page -------
/// Whether the walk's plan holds `target`. `steps=` alone cannot say a stop was *filtered out* rather
/// than merely reordered, so the two cases the filter tells apart are read against the plan itself.
bool tour_plan_holds(const App& a, GtkWidget* target) {
for (const TourStop& stop : a.tour.plan) {
if (stop.target == target) return true;
}
return false;
}
gboolean tour_surface_probe_step(gpointer user);
void tour_surface_probe_next(App& a, guint delay_ms) {
g_timeout_add(delay_ms, tour_surface_probe_step, &a);
}
/// `UDPSCOPE_PROBE_TOUR_SURFACE_MS`: the stop that stands on a page it is not on. It puts the sidebar
/// on **Measure**, starts the window's tour, and walks it a stop per tick -- a tick apart on purpose,
/// because a stack's new page is allocated on the frame *after* the switch, so a reading taken in the
/// switch's own tick would be of the page the walk has just left (and `update_tour()` places the hole
/// from the frame that *has* been laid out). Every step prints the sidebar's page and whether the
/// stop's target is mapped, and the stop whose target is the signal list prints the same geometry
/// `[helpui]` does: **`side_tab=signals` with `target=signal_list` and `contains=1`** is the reading
/// that the stop was not merely kept but *made* visible. It then puts the sidebar away --
/// `sidebar_wanted` is the wish the tick re-asserts -- and starts the tour again: a *collapsed* pane
/// leaves the stack unmapped, so the stops that need it are dropped and `steps=` falls. That pair is
/// the filter's two cases, read rather than assumed.
gboolean tour_surface_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const auto mapped = [](GtkWidget* w) { return w != nullptr && gtk_widget_get_mapped(w) ? 1 : 0; };
switch (a->dbg_tour_surface_step) {
case 0: {
// Where the run starts: a fresh config's sidebar is on Signals, so this is a *first* run's
// state. The auto-start's walk (if one began) would only confuse the reading; it goes first.
std::fprintf(stderr, "[toursurface] before: side_tab=%s signal_list_mapped=%d running=%d\n",
a->side_tab.c_str(), mapped(a->signal_list), tour_active(*a) ? 1 : 0);
tour_dismiss(*a);
select_side_tab(*a, "measure");
a->dbg_tour_surface_step = 1;
tour_surface_probe_next(*a, 500);
return G_SOURCE_REMOVE;
}
case 1: {
// A frame after the switch, so the Measure page has been laid out and the list really is off
// screen. The tour is started *here*, from this state.
std::fprintf(stderr,
"[toursurface] on measure: side_tab=%s signal_list_mapped=%d side_stack_mapped=%d\n",
a->side_tab.c_str(), mapped(a->signal_list), mapped(a->side_stack));
start_tour(*a);
std::fprintf(stderr, "[toursurface] tour: visible=%d steps=%d step=%d signals_stop=%d\n",
tour_active(*a) ? 1 : 0, tour_step_count(*a), tour_step_index(*a),
tour_plan_holds(*a, a->signal_list) ? 1 : 0);
a->dbg_tour_surface_step = 2;
tour_surface_probe_next(*a, 400);
return G_SOURCE_REMOVE;
}
case 2: {
if (!tour_active(*a)) {
std::fprintf(stderr, "[toursurface] walk ended before the signals stop\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
GtkWidget* target = tour_target(*a);
const bool is_list = target != nullptr && target == a->signal_list;
std::fprintf(stderr, "[toursurface] step=%d/%d side_tab=%s target_mapped=%d%s\n",
tour_step_index(*a) + 1, tour_step_count(*a), a->side_tab.c_str(), mapped(target),
is_list ? " target=signal_list" : "");
if (is_list) {
// The frame after the switch: the page has been laid out and `update_tour()` has placed the
// hole from it, so this is the reading that the spotlight is on the list.
tour_geometry_line(*a, "toursurface", "signals stop");
tour_dismiss(*a);
a->dbg_tour_surface_step = 3;
tour_surface_probe_next(*a, 400);
return G_SOURCE_REMOVE;
}
tour_step(*a, 1);
tour_surface_probe_next(*a, 400);
return G_SOURCE_REMOVE;
}
case 3: {
// The other half of the filter. `sidebar_wanted` is the user's wish that the tick re-asserts
// (`update_side_visibility`), so setting it here is how a probe puts the sidebar away for good;
// the delay leaves the split view time to animate the column shut and unmap it.
a->sidebar_wanted = false;
a->dbg_tour_surface_step = 4;
tour_surface_probe_next(*a, 700);
return G_SOURCE_REMOVE;
}
default: {
start_tour(*a);
std::fprintf(stderr,
"[toursurface] collapsed: side_stack_mapped=%d steps=%d signals_stop=%d "
"side_stop=%d\n",
mapped(a->side_stack), tour_step_count(*a),
tour_plan_holds(*a, a->signal_list) ? 1 : 0,
tour_plan_holds(*a, a->side_box) ? 1 : 0);
tour_dismiss(*a);
a->sidebar_wanted = true;
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
}
return G_SOURCE_REMOVE;
}
// --- persistence (UDPSCOPE_PROBE_PERSIST_MS) ---------------------------------
//
// The phosphor display: the last N acquisitions drawn behind the current one, dimmer with age, with
// `N = 1` -- the default -- drawn exactly like the display without the feature. One leg, and the
// depth comes from the environment, because the *reading* is a pair of runs: `..._PERSIST_DEPTH=4`
// must show the layers at the right brightnesses and ages with the framing still the current event's,
// and the same command at `=1` must show none of them, the same framing, and nothing retained -- and
// the second of those is also the baseline the exit dump's `frame cost:` line is read against.
/// The depth the first leg asks for: `UDPSCOPE_PROBE_PERSIST_DEPTH`, default 4, clamped to the
/// panel's own range. **Set it to 1 and the same run is the unpersisted display**, which is the
/// baseline the exit dump's `frame cost:` line has to be read against.
int persist_probe_depth() {
const char* env = g_getenv("UDPSCOPE_PROBE_PERSIST_DEPTH");
const int n = env != nullptr ? std::atoi(env) : 4;
return std::clamp(n, 1, kMaxPersistDepth);
}
/// The event window the probe arms with: `UDPSCOPE_PROBE_PERSIST_WINDOW` seconds, default 0.5. A
/// *long* window on a fast stream is how the **memory** bound is reached -- one retained acquisition
/// is a whole window copied out of the ring -- so the run that has to show `dropped` and a `kept`
/// below the depth is `=2` against the fast mock.
double persist_probe_window() {
const char* env = g_getenv("UDPSCOPE_PROBE_PERSIST_WINDOW");
const double s = env != nullptr ? std::atof(env) : 0.5;
return s > 0.0 ? s : 0.5;
}
/// One leg's reading: what the model retained, what the rows drew, and whether the framing is still
/// the event on screen and nothing else.
void persist_probe_report(App& a, const char* what) {
int depth = 1;
int snap_n = 0;
double snap_lo = 0.0;
double snap_hi = 0.0;
std::size_t kept = 0;
std::size_t bytes = 0;
std::size_t budget = 0;
std::size_t signals = 0;
std::size_t channels = 0;
unsigned long long dropped = 0;
{
std::lock_guard<std::mutex> lock(a.model->mutex());
depth = a.model->persist_depth_locked();
kept = a.model->persist_count();
bytes = a.model->persist_bytes();
budget = a.model->persist_budget_bytes();
dropped = static_cast<unsigned long long>(a.model->persist_dropped());
signals = a.model->signals().size();
channels = a.model->channels().size();
if (const std::shared_ptr<const EventSnapshot> snap = a.model->event()) {
snap_lo = -snap->pre_s;
snap_hi = snap->post_s;
snap_n = static_cast<int>(snap->stamps.size());
}
}
// The decay schedule in use, oldest first: the reading's point is that the numbers descend by half
// and that the *oldest* is the dimmest.
const std::vector<std::pair<int, float>> layers = a.rows->row_layers(0);
std::fprintf(stderr,
"[persist-probe] %s depth=%d kept=%zu bytes=%zu budget=%zu dropped=%llu signals=%zu "
"channels=%zu layers=%d want=%d placed=%d alphas=[",
what, depth, kept, bytes, budget, dropped, signals, channels, a.rows->layer_slots(),
a.persist_want, a.persist_placed);
for (std::size_t i = 0; i < layers.size(); ++i) {
std::fprintf(stderr, "%s%.4f", i != 0 ? "," : "", static_cast<double>(layers[i].second));
}
std::fprintf(stderr, "] ages=[");
for (std::size_t i = 0; i < layers.size(); ++i) {
std::fprintf(stderr, "%s%d", i != 0 ? "," : "", layers[i].first);
}
std::fprintf(stderr, "] row0=[");
const RowSet::Row& r0 = a.rows->row(0);
bool first = true;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (r0.layer[si] == 0) continue;
std::fprintf(stderr, "%s%d:'%s'@%.4f", first ? "" : " ", s, r0.signal_name[si].c_str(),
static_cast<double>(r0.slot_alpha[si]));
first = false;
}
// How many layer slots are actually *bound* to a retained acquisition. It is not the same number
// as `layers=`/`placed=`: a slot can be planned for an age the retention budget refused, and it
// then draws nothing at all -- so the two pairs together say which of the two bounds bit.
int drawn = 0;
for (int r = 0; r < a.rows->row_count(); ++r) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const RowSet::Row& row_state = a.rows->row(r);
const std::size_t si = static_cast<std::size_t>(s);
if (row_state.layer[si] == 0) continue;
if (row_state.slots[si].bound_event() != nullptr) ++drawn;
}
}
std::fprintf(stderr, "] drawn=%d\n", drawn);
// The framing. `ev` is what a held event's pin is re-stated from (`event_data_window()`), and it
// must be the *snapshot's* own window: layers are bound event series on the same rows, so a union
// that had grown with them reads here as `ev_is_snap=0`.
const plot::Range axis = a.rows->view_range();
const plot::Range pin = a.rows->pinned_range();
double ev_lo = 0.0;
double ev_hi = 0.0;
const bool have_ev = a.rows->event_data_window(ev_lo, ev_hi);
const bool ev_is_snap =
have_ev && std::abs(ev_lo - snap_lo) < 1.0e-6 && std::abs(ev_hi - snap_hi) < 1.0e-6;
const bool pin_is_snap = a.rows->window_pinned() && std::abs(pin.min - snap_lo) < 1.0e-6 &&
std::abs(pin.max - snap_hi) < 1.0e-6;
std::fprintf(stderr,
"[persist-probe] %s axis=[%.4f..%.4f] pin=[%.4f..%.4f] snap=[%.4f..%.4f] n=%d "
"ev=[%.4f..%.4f] ev_is_snap=%d pin_is_snap=%d\n",
what, double(axis.min), double(axis.max), double(pin.min), double(pin.max), snap_lo,
snap_hi, snap_n, ev_lo, ev_hi, ev_is_snap ? 1 : 0, pin_is_snap ? 1 : 0);
std::fflush(stderr);
}
/// Sets the panel's own depth control and publishes it: the *widget* is the path a user takes, so the
/// probe exercises the handler that reads it and not the model setter.
void persist_probe_set_depth(App& a, int depth) {
if (a.trigger_persist_spin != nullptr) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a.trigger_persist_spin), double(depth));
}
publish_trigger_fields(&a);
}
/// Step 1: enable the signals **itself** -- one call, the way a restored session does it, and
/// deliberately not `UDPSCOPE_ENABLE_ALL_MS`: that probe ends by switching one signal off two seconds
/// later, so a run that relied on it would see its first row's signal leave the plots (and its base
/// slot go with it) in the middle of the measurement. Then one signal per row -- the arrangement the
/// app itself gives an opened file, and the one with room for every age (a row of four signals has one
/// free slot per signal, a row of one has seven) -- the depth through the panel's own spin, and the
/// trigger armed on the mock's `square`, which crosses its level several times a second so that several
/// acquisitions land in a couple of seconds.
gboolean persist_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
{
const std::vector<SignalInfo>& sigs = a->model->signals();
std::vector<std::size_t> all(sigs.size());
for (std::size_t i = 0; i < all.size(); ++i) all[i] = i;
if (!all.empty()) a->model->set_signals_enabled(all, true);
}
const std::vector<ChannelInfo>& channels = a->model->channels();
const std::vector<SignalInfo>& signals = a->model->signals();
const int rows = std::min<int>(RowSet::kMaxRows, static_cast<int>(channels.size()));
if (rows > 0) {
a->rows->set_row_count(rows);
for (int r = 0; r < rows; ++r) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) a->rows->assign(r, s, "", "", 0);
a->rows->assign(r, 0, channels[static_cast<std::size_t>(r)].name,
channels[static_cast<std::size_t>(r)].name,
signal_color_index(signals, channels[static_cast<std::size_t>(r)].signal));
}
a->relayout_pending = true;
}
a->dbg_persist_depth = persist_probe_depth();
persist_probe_set_depth(*a, a->dbg_persist_depth);
int sel = -1;
for (std::size_t i = 0; i < channels.size(); ++i) {
if (channels[i].name == "square") sel = static_cast<int>(i);
}
if (sel >= 0) {
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_signal_drop), static_cast<guint>(sel));
}
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_type_drop), 0);
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_edge_drop), 0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin), 0.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_holdoff_spin), 0.0);
const double window_s = persist_probe_window();
apply_time_window(*a, window_s);
publish_trigger_fields(a);
std::fprintf(stderr, "[persist-probe] %d row(s), one signal each, depth=%d, window=%.3f s, arming on '%s'\n",
rows, a->dbg_persist_depth, window_s,
sel >= 0 ? channels[static_cast<std::size_t>(sel)].name.c_str() : "(none)");
std::fflush(stderr);
if (a->trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
}
a->dbg_persist_polls = 0;
g_timeout_add(25, persist_probe_wait, a);
return G_SOURCE_REMOVE;
}
/// Waits for the retention to be *full*: an event on screen and as many older ones kept as the depth
/// asks for. The trigger's cadence is the stream's, so a fixed delay would report whatever happened
/// to have latched; the 15 s cap is for a stream that never latches, and the probe still reports.
///
/// A full set is not always *reachable*: the retention budget can keep fewer acquisitions than the
/// depth asks for, and then the condition above can never come true -- which is the state the memory
/// bound is read in, so waiting for it would report nothing at all. The wait therefore also ends when
/// the set has **settled** (nothing new retained for two seconds) with something in it: that is the
/// budget speaking, and the report's `kept`/`dropped`/`drawn` are what say so.
///
/// The depth is **left where the first leg put it**: the run's own tail is then the display at that
/// depth, which is what the exit dump's `frame cost:` line is a mean over -- and the run to read it
/// against is the same command with `UDPSCOPE_PROBE_PERSIST_DEPTH=1`, whose two legs are both the
/// display without the feature. Reading it in one run that turns the depth off partway would have
/// meant comparing two means over different displays.
gboolean persist_probe_wait(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
++a->dbg_persist_polls;
const bool held = a->model->display_mode() == DisplayMode::Held;
const std::size_t kept = a->model->persist_count();
const std::size_t want = static_cast<std::size_t>(std::max(0, a->dbg_persist_depth - 1));
if (kept != a->dbg_persist_last_kept) {
a->dbg_persist_last_kept = kept;
a->dbg_persist_stable_polls = 0;
} else if (++a->dbg_persist_stable_polls > 160) { // 4 s at 25 ms a poll
a->dbg_persist_settled = true;
}
const bool full = kept >= want;
// ... and the rows must already be *bound* to it: the report reads what the frame bound, and the
// mode can flip to `Held` between two ticks (a latch lands mid-frame), so a reading taken on the
// tick boundary can describe a display whose bindings are still the live ring's. `Held`, bound and
// full -- the state every reading below is about -- is what the wait is for.
double lo = 0.0;
double hi = 0.0;
const bool bound = a->rows->event_data_window(lo, hi);
if ((held && bound && (full || (a->dbg_persist_settled && kept > 0))) ||
a->dbg_persist_polls > 600) {
if (!full) {
std::fprintf(stderr, "[persist-probe] settled: kept=%zu of %zu after %d polls\n", kept, want,
a->dbg_persist_polls);
}
persist_probe_report(*a, "held");
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
}
// --- phosphor density (UDPSCOPE_PROBE_DENSITY_MS) -----------------------------
//
// The density is the *plotter's* image, so every number below is read off the plots -- the same
// `DensityGrid::ink()`/`peak()` the library's own tests read -- summed and maxed over the rows the
// display is on. The probe drives the panel's own widgets for the switch and the fade (the way a
// click publishes them) and calls the plot's own `accumulate_density()`/`decay_density()` for the
// two decay steps, so a step's ratio is arithmetic rather than a race against the frame clock: both
// reads and the step happen inside one `g_timeout` callback, with no tick in between.
//
// The legs, in order:
// * **off** -- the switch as a session that never turned it on leaves it, which is also the run
// the exit dump's `frame cost:` line is read against (a plain run, no probe: the density is off
// by default and nothing draws it);
// * **on** -- one deposit, the acquisition already on screen (switching the display on under an
// event deposits it at once rather than waiting for a latch `single` mode may never send);
// * **step, acquisitions** -- one more accumulation: every cell keeps the factor of what it held
// and the trace on screen adds its own pass. With the same frame in both reads the relation is
// exact (`ink' = f*ink + ink`, the second `ink` being one pass), which is what the *library's*
// own case asserts; **in the app the acquisition may move between the two reads**, so what the
// line's ratio says is "the old image halved and one more pass landed on top" rather than a
// number to expect -- and `ink` growing while the ratio stays above `f` is that reading. 0.5 is
// the panel's default factor;
// * **step, time** -- `exp(-dt/tau)` off the ink, with `dt = tau` stated outright, so the ratio
// the line prints must be `1/e`.
//
// It is left **on**, with the fade it last set, so the tail of the run is the display with the
// density: that is what makes the exit dump's `frame cost:` a mean over frames that uploaded an
// image, to be compared with the same command without the probe.
//
// `UDPSCOPE_PROBE_DENSITY_TAU` states the time constant the time leg uses (default 1.0 s, clamped to
// the panel's own range): `dt = tau` is one step whatever it is, so the ratio does not depend on it
// -- it is there so a run can fade faster than a second and still be read.
/// The time constant the probe's time leg sets, from `UDPSCOPE_PROBE_DENSITY_TAU` (default 1.0 s).
double density_probe_tau() {
const char* env = g_getenv("UDPSCOPE_PROBE_DENSITY_TAU");
const double s = env != nullptr ? std::atof(env) : 1.0;
return s > 0.0 ? std::min(s, 3600.0) : 1.0;
}
/// What the density holds over every row it is on: the rows, the traces (one grid each), the cells,
/// and the two numbers the plot reports. A row with the display off contributes nothing -- it has no
/// grid at all.
struct DensityReading {
int rows = 0;
int traces = 0;
unsigned long long cells = 0;
double ink = 0.0;
double peak = 0.0;
};
DensityReading density_read(const App& a) {
DensityReading r;
for (int i = 0; i < a.rows->row_count(); ++i) {
const RowSet::Row& row = a.rows->row(i);
if (row.plot == nullptr || !row.plot->density().enabled) continue;
++r.rows;
// **One grid per trace**, each in its trace's own colour, so the row's cells/ink are the sum
// over its traces and the peak the brightest of them.
for (plot::u32 t = 0; t < row.plot->trace_count(); ++t) {
const plot::DensityGrid& g = row.plot->density_grid(t);
if (g.cell_count() == 0u) continue; // an unbound slot holds no grid
++r.traces;
r.cells += static_cast<unsigned long long>(g.cell_count());
r.ink += g.ink();
r.peak = std::max(r.peak, static_cast<double>(g.peak()));
}
}
return r;
}
/// One decay step of each kind, on every row the display is on. The acquisitions step is the
/// plotter's own accumulation (its per-acquisition fade happens inside it, before the deposit), and
/// the time step is `decay_density(tau)` -- the same call the frame tick makes with a frame's `dt`.
double density_step(App& a, bool by_time) {
// The ink summed over every trace's grid (one grid per trace): the same total `density_read()`
// reports, so a step's ratio is off one reading.
const auto ink = [&]() {
double total = 0.0;
for (int i = 0; i < a.rows->row_count(); ++i) {
const RowSet::Row& row = a.rows->row(i);
if (row.plot == nullptr || !row.plot->density().enabled) continue;
for (plot::u32 t = 0; t < row.plot->trace_count(); ++t) total += row.plot->density_grid(t).ink();
}
return total;
};
const double before = ink();
for (int i = 0; i < a.rows->row_count(); ++i) {
plot::Plot* p = a.rows->row(i).plot;
if (p == nullptr || !p->density().enabled) continue;
if (by_time) {
p->decay_density(a.dbg_density_tau);
} else {
p->accumulate_density();
}
}
const double after = ink();
return before > 0.0 ? after / before : 0.0;
}
/// States the display through the panel's own widgets and publishes it: the *widget* is the path a
/// user takes, so what is measured is the app's wiring rather than a second way in.
void density_probe_set(App& a, bool on, bool by_time) {
if (a.trigger_density_switch != nullptr) {
gtk_switch_set_active(GTK_SWITCH(a.trigger_density_switch), on);
}
if (a.trigger_density_decay_drop != nullptr) {
gtk_drop_down_set_selected(GTK_DROP_DOWN(a.trigger_density_decay_drop), by_time ? 1u : 0u);
}
publish_trigger_fields(&a);
}
/// One leg's line: the numbers, the fade in force, and the ratio a decay step produced (1.0 where no
/// step was taken).
void density_probe_report(App& a, const char* what, double ratio) {
const DensityReading r = density_read(a);
// The configuration is read from the **model**, not off the widgets: the switch and the dropdown
// are what the probe writes, so reading them back would only say what it just set. The model is
// what the tick reads, so `fade=`/`tau=` beside `rows=` is the wiring's own answer -- and a
// spread between the two is the defect the line exists for (a switch that published nothing, a
// tau the plot never saw).
TriggerConfig tc;
{
std::lock_guard<std::mutex> lock(a.model->mutex());
tc = a.model->trigger();
}
std::fprintf(stderr,
"[density-probe] %s enabled=%d fade=%s tau=%.3f factor=%.3f rows=%d traces=%d "
"cells=%llu ink=%.1f peak=%.4f ratio=%.6f\n",
what, tc.density ? 1 : 0,
tc.density_decay == PersistDecay::Time ? "time" : "acquisitions", tc.density_tau_s,
tc.density_factor, r.rows, r.traces, r.cells, r.ink, r.peak, ratio);
std::fflush(stderr);
}
/// Step 1: the display **off** through the panel's switch (a session file remembers the trigger, so
/// the off state is stated rather than assumed), then the trigger armed on the mock's `square` the
/// way the persistence probe arms its own -- several crossings a second, so an acquisition lands
/// within a couple of seconds.
gboolean density_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
density_probe_set(*a, /*on=*/false, /*by_time=*/false);
{
const std::vector<SignalInfo>& sigs = a->model->signals();
std::vector<std::size_t> all(sigs.size());
for (std::size_t i = 0; i < all.size(); ++i) all[i] = i;
if (!all.empty()) a->model->set_signals_enabled(all, true);
}
// One signal per row, the arrangement the app gives an opened file and the persistence probe
// uses -- and, for this probe, the one that puts an image in *every* row (a reading that summed
// over rows would be one row's if only one row plotted anything).
const std::vector<ChannelInfo>& setup_channels = a->model->channels();
const std::vector<SignalInfo>& setup_signals = a->model->signals();
const int setup_rows =
std::min<int>(RowSet::kMaxRows, static_cast<int>(setup_channels.size()));
if (setup_rows > 0) {
a->rows->set_row_count(setup_rows);
for (int r = 0; r < setup_rows; ++r) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) a->rows->assign(r, s, "", "", 0);
const std::size_t ci = static_cast<std::size_t>(r);
a->rows->assign(r, 0, setup_channels[ci].name, setup_channels[ci].name,
signal_color_index(setup_signals, setup_channels[ci].signal));
}
a->relayout_pending = true;
}
// The time constant the time leg will state, through the panel's own spin -- so the model's tau,
// the plot's tau and the step's `dt` are one number rather than three that must agree.
a->dbg_density_tau = density_probe_tau();
if (a->trigger_density_tau_spin != nullptr) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_density_tau_spin), a->dbg_density_tau);
}
if (a->trigger_density_factor_spin != nullptr) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_density_factor_spin), 0.5);
}
int sel = -1;
const std::vector<ChannelInfo>& channels = a->model->channels();
for (std::size_t i = 0; i < channels.size(); ++i) {
if (channels[i].name == "square") sel = static_cast<int>(i);
}
if (sel >= 0) {
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_signal_drop), static_cast<guint>(sel));
}
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_type_drop), 0);
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_edge_drop), 0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin), 0.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_holdoff_spin), 0.0);
apply_time_window(*a, 0.5);
publish_trigger_fields(a);
std::fprintf(stderr, "[density-probe] arming on '%s', window 0.5 s, display off\n",
sel >= 0 ? channels[static_cast<std::size_t>(sel)].name.c_str() : "(none)");
std::fflush(stderr);
if (a->trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
}
a->dbg_density_stage = 1;
a->dbg_density_polls = 0;
g_timeout_add(200, density_probe_step, a);
return G_SOURCE_REMOVE;
}
/// The sequence. Each step is a separate `g_timeout`, so whole frames -- ticks, binds, paints --
/// happen between two of them: a reading taken in the same callback that set a widget would describe
/// the frame *before* the change, which is the trap the compact-arm probe records.
gboolean density_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
++a->dbg_density_polls;
const bool held = a->model->display_mode() == DisplayMode::Held;
double lo = 0.0;
double hi = 0.0;
switch (a->dbg_density_stage) {
case 1:
// Wait for an acquisition to be on screen. The trigger's cadence is the stream's, so a fixed
// delay would report whatever happened to have latched; the cap reports anyway (and says so
// through `rows=0`).
if (!held || !a->rows->event_data_window(lo, hi)) {
if (a->dbg_density_polls > 100) {
density_probe_report(*a, "off", 1.0);
std::fprintf(stderr, "[density-probe] no event appeared; stopping\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
}
density_probe_report(*a, "off", 1.0);
// The cost *before*: the accumulators as of this tick, so the "after" mean is over the frames
// that follow and the two are read off one run -- same stream, same layout, same mock. Two
// runs would differ in everything but the density, which is exactly what a frame-cost A/B
// cannot afford.
a->dbg_density_tick_sum0 = a->dbg_tick_ms_sum;
a->dbg_density_tick_n0 = a->dbg_tick_n;
a->dbg_density_draw_sum0 = a->dbg_draw_ms_sum;
a->dbg_density_draw_n0 = a->dbg_paint_n;
density_probe_set(*a, /*on=*/true, /*by_time=*/false);
a->dbg_density_stage = 2;
return G_SOURCE_CONTINUE;
case 2: {
// The frame that switched the display on deposited the acquisition already on screen; the
// numbers are the one-pass ones.
density_probe_report(*a, "on", 1.0);
const double ratio = density_step(*a, /*by_time=*/false);
density_probe_report(*a, "step-acquisitions", ratio);
density_probe_set(*a, /*on=*/true, /*by_time=*/true);
a->dbg_density_stage = 3;
return G_SOURCE_CONTINUE;
}
case 3: {
density_probe_report(*a, "time-before", 1.0);
// One step of `dt = tau`, which is 1/e by the plot's own rule -- and the one reading that says
// the time fade is wired at all: the panel's constant reaches the grid through the tick.
const double ratio = density_step(*a, /*by_time=*/true);
density_probe_report(*a, "step-time", ratio);
// The cost *after*: the same two means over the frames since the switch, which is the
// difference this display costs -- the per-frame upload and deposit against the frame without
// them. `upload_bytes` in the exit dump beside it is the one frame's share of it.
const int tick_n = a->dbg_tick_n - a->dbg_density_tick_n0;
const int draw_n = a->dbg_paint_n - a->dbg_density_draw_n0;
std::fprintf(stderr,
"[density-probe] frame cost: tick before=%.2f ms (%d frames) after=%.2f ms "
"(%d frames) draw before=%.2f ms (%d) after=%.2f ms (%d)\n",
a->dbg_density_tick_n0 > 0 ? (a->dbg_density_tick_sum0 / a->dbg_density_tick_n0)
: 0.0,
a->dbg_density_tick_n0,
tick_n > 0 ? (a->dbg_tick_ms_sum - a->dbg_density_tick_sum0) / tick_n : 0.0,
tick_n,
a->dbg_density_draw_n0 > 0 ? (a->dbg_density_draw_sum0 / a->dbg_density_draw_n0)
: 0.0,
a->dbg_density_draw_n0,
draw_n > 0 ? (a->dbg_draw_ms_sum - a->dbg_density_draw_sum0) / draw_n : 0.0,
draw_n);
std::fprintf(stderr, "[density-probe] done: the display is left on, fading by time\n");
std::fflush(stderr);
a->dbg_density_stage = 0;
return G_SOURCE_REMOVE;
}
default:
return G_SOURCE_REMOVE;
}
}
} // namespace ui_impl
} // namespace udpscope