// 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 #include #include #include namespace udpscope { namespace ui_impl { int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) { App* app = static_cast(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(sl)] < 0) continue; const plot::Range b = r.slots[static_cast(sl)].x_bounds(); std::fprintf(stderr, " slot%d n=%zu x=[%.6f..%.6f]", sl, r.slots[static_cast(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(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(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(g_list_model_get_n_items(model)) : 0; a->dbg_trigger_selected = static_cast( 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(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(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(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 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(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 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)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(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(m.trigger_latches().load()), int(m.trigger_armed().load()), static_cast(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(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(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& 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 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 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( 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(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( 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(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( 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(slots_before), static_cast(slots_after)); const std::size_t saved = a->math.empty() ? 0u : a->math[static_cast(index)].nodes.size(); const int output_saved = a->math.empty() ? -1 : a->math[static_cast(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( 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( 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( 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 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( 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( 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(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(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(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(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(MathDrag::Pan)); math_view_reset(g); } } if (a->math_dialog != nullptr) { auto* p = static_cast( 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(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(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( 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(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(i); } if (self_node >= 0) { double sx = 0.0; double sy = 0.0; math_input_pos(c->nodes[static_cast(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( 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 acquired = math_source_names(*a); std::size_t signals_n = 0; { std::lock_guard 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( 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(i % 2u); o->nodes[i].y = 20.0 + 58.0 * static_cast(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(p2.x)) + "," + std::to_string(static_cast(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( 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(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(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 lock(a->model->mutex()); const std::size_t idx = static_cast(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(produced), static_cast(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(user); if (a->window == nullptr || a->math.empty()) return G_SOURCE_REMOVE; u64 base = 0; u64 slots = 0; { std::lock_guard lock(a->model->mutex()); base = a->model->live().time().base(); slots = a->model->live().time().produced(); const std::size_t idx = static_cast(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(base), static_cast(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(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& 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 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 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(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(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& 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 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(user); if (a->window == nullptr) return G_SOURCE_REMOVE; std::vector names; { std::lock_guard 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(static_cast(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 before; std::vector 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& values, std::vector& 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& values, const std::vector& 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(view.min), static_cast(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 after; std::vector 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(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(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(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(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(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(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(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(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& 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 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(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(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(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(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(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& 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& 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(user); if (a->window == nullptr) return G_SOURCE_REMOVE; const ProbeSource* src = a->acq != nullptr ? dynamic_cast(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(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(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(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 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> 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(i)] - want0[i]) > 1e-9) return false; if (std::fabs(out[1][static_cast(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 rows; if (!parse(" A:uint8, B:int16,C : float32 ,D:uint64[2],E:int8", rows).empty()) return false; const std::vector 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(Type::F64) != 9 || static_cast(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> 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 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 r) { return problem(r); }; // One call per statement: GCC warns (-Wdangling-pointer) on two list temporaries joined by `||`. const auto valid = [](std::vector 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 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 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 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> cc; decode_channels(cr, cd, false, cc); const std::vector> 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 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> 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(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 v(static_cast(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& 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 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 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(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& 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(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(std::lround(2.0 * s.scan_rate / 5.0)); if (want > 0 && n > want + 50) { std::vector v(static_cast(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(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 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(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(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 names; std::vector 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 v(static_cast(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(i % std::size_t(count)); } if (phase < 0) return static_cast(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 va(static_cast(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(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(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(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& 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(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(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(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(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 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(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(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(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(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 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 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(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 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(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 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(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::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(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 snap = a->model->display_snapshot()) { if (snap != nullptr && trig_ch < snap->values.size()) { const std::vector& st = snap->stamps; const std::vector& 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(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(vals[end - 1]); if (end >= 2) earlier = static_cast(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(vals[i - 2]); const double val = static_cast(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(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& 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(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(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(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& 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(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(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(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 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(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(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 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(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(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(r.origin.x), static_cast(r.origin.y), static_cast(r.size.width), static_cast(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(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(frame.w), static_cast(frame.h), (frame.w == 0.0f && frame.h == 0.0f) ? 1 : 0, static_cast(now.min), static_cast(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(user)->app; plot::input::Controller* c = app.area->row_controller(0); if (c != nullptr) { const plot::u32 ctrl = static_cast(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(p); }); return G_SOURCE_REMOVE; } gboolean boxzoom_probe_open(gpointer user) { BoxZoomProbe& probe = *static_cast(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(frame.x), static_cast(frame.y), static_cast(frame.w), static_cast(frame.h), static_cast(axes.h), band ? 1 : 0, static_cast(probe.before.min), static_cast(probe.before.max)); return boxzoom_probe_release(user); } gboolean boxzoom_probe(gpointer user) { App& a = *static_cast(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::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(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(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(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(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(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(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(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(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 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(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(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(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(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(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(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(snap ? snap->stamps.size() : 0), double(axis.min), double(axis.max), static_cast(a.dbg_force_latches_before), static_cast(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(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(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 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(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(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 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(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(user); pause_probe_report(*app, "paused"); return G_SOURCE_REMOVE; }, a); g_timeout_add(1600, +[](gpointer user) { App* app = static_cast(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(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(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(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(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(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 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(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(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(user); if (a->window == nullptr) return G_SOURCE_REMOVE; switch (a->dbg_measure_step) { case 0: { std::string signal; { std::lock_guard 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(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 wanted; { std::lock_guard lock(a->model->mutex()); const std::vector& 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 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(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(have), static_cast(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( 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(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(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(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::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::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(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(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(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(at.x) : -1; const int accel_right = accel != nullptr && gtk_widget_compute_point(accel, row, &origin, &at) ? static_cast(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(bounds.origin.y) : -1; const int button_right = have_button ? static_cast(bounds.origin.x + bounds.size.width) : -1; const int pos = bubble != nullptr ? static_cast(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(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(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 lock(a->model->mutex()); const std::vector& 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(probe_slot)).color); }; // A chip carries its colour inside ``, 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(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(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(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 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& 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(m.stats().resequenced.load()), static_cast(m.stats().late.load()), static_cast(m.stats().stale.load()), static_cast(m.stats().overflows.load()), static_cast(m.stats().holes.load()), static_cast(m.stats().hole_slots.load()), static_cast(m.stats().backfilled.load()), static_cast(m.stats().gaps_refused.load()), open_holes, bands, bands_repaired, static_cast(nan_slots), static_cast(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(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(cap.stored()), static_cast(st.stored), st.cursor, static_cast(st.torn), static_cast(st.truncated), st.header_ok ? 1 : 0, static_cast(st.first_seq), static_cast(st.last_seq), static_cast(st.payload), static_cast(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(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(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 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 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(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(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 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 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(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(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 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(a->group_kind), a->rows->row_count(), a->model->channels().size(), static_cast(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 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 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(samples_before), static_cast(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(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(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(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 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(a->group_kind), a->rows->row_count(), a->model->channels().size(), static_cast(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::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(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& channels = a->model->channels(); for (std::size_t i = 0; i < channels.size(); ++i) { if (channels[i].name == name) return static_cast(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(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(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& 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 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(a_leg.hits) / static_cast(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 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(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& 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(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(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(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& 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(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(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(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(user); if (a->window == nullptr || a->model->signals().empty()) return G_SOURCE_REMOVE; open_calibration(*a, GTK_WINDOW(a->window), 0); std::vector 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 trig; collect_spins(a->trigger_panel, &trig); const std::pair 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 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(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(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 `` 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(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(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(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(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(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(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(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(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(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(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 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(a.model->persist_dropped()); signals = a.model->signals().size(); channels = a.model->channels().size(); if (const std::shared_ptr snap = a.model->event()) { snap_lo = -snap->pre_s; snap_hi = snap->post_s; snap_n = static_cast(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> 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(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(s); if (r0.layer[si] == 0) continue; std::fprintf(stderr, "%s%d:'%s'@%.4f", first ? "" : " ", s, r0.signal_name[si].c_str(), static_cast(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(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(user); if (a->window == nullptr) return G_SOURCE_REMOVE; { const std::vector& sigs = a->model->signals(); std::vector 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& channels = a->model->channels(); const std::vector& signals = a->model->signals(); const int rows = std::min(RowSet::kMaxRows, static_cast(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(r)].name, channels[static_cast(r)].name, signal_color_index(signals, channels[static_cast(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(i); } if (sel >= 0) { gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_signal_drop), static_cast(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(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(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::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(g.cell_count()); r.ink += g.ink(); r.peak = std::max(r.peak, static_cast(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 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(user); if (a->window == nullptr) return G_SOURCE_REMOVE; density_probe_set(*a, /*on=*/false, /*by_time=*/false); { const std::vector& sigs = a->model->signals(); std::vector 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& setup_channels = a->model->channels(); const std::vector& setup_signals = a->model->signals(); const int setup_rows = std::min(RowSet::kMaxRows, static_cast(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(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& channels = a->model->channels(); for (std::size_t i = 0; i < channels.size(); ++i) { if (channels[i].name == "square") sel = static_cast(i); } if (sel >= 0) { gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_signal_drop), static_cast(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(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(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