Commit Graph
100 Commits
Author SHA1 Message Date
Martino Ferrari 8073e7b3fd feat: the main window's skeleton is a UI file
src/ui/main-window.ui holds the window, its two panes, the plot column with the status line under it,
and the sidebar's scroller. What stays in code is what depends on the stream: the events pane, the two
plot areas, the sidebar's panels and the status popover, each put into a slot by id.

The header bar is deliberately not in the file. It is the window's own titlebar, every button on it is
wired to an action, and an AdwApplicationWindow refuses a titlebar outright -- a fatal Adwaita-ERROR
rather than a warning that leaves the window merely ugly -- so the window is a GtkApplicationWindow and
the header bar stays where it was.

gtk_paned_set_position() is the other thing that had to stay in code, and the reason is order rather
than shape: it clamps against its children *now*, so stating it before the sidebar's panels exist moved
the plot column by ~180 px.

The layout, events and box-zoom probes read what they read before this change, and the header reads
window_button_w=130 bar_w=756 as it always has -- checked by building the previous commit and running
the same probe against it, because the layout numbers in the notes turned out to be stale from an
earlier session. Five UI files now.
2026-10-11 17:39:04 +02:00
Martino Ferrari 68743a7d7f feat: the host form, and the help window, are UI files
src/ui/source-form.ui is the form a source is added or edited with: an AdwDialog of
AdwPreferencesGroup rows (AdwEntryRow, AdwSpinRow, AdwSwitchRow) with its action in the header bar,
where the label says which mode the form is in -- Connect for a new host, Save for an existing
configuration. The grid of labels and bare entries it replaces had its hints in placeholders, which
vanish as soon as there is a value; they are the groups' descriptions now.

src/ui/help.ui is the help window's chrome: an AdwWindow with a header bar and an
AdwNavigationSplitView whose sidebar is the page list. The rows stay in C++ because the fourteen
pages are data, not template.

Four UI files now, 653 lines of template that used to be widget construction. Two libadwaita traps
cost a run each and are in docs/claude/invariants-gtk-and-chrome.md: an AdwDialog has no content
property (its child is a plain <child>, unlike an AdwToolbarView), and AdwEntryRow has no subtitle
(it is an AdwPreferencesRow, not an AdwActionRow). The unused form_field() helper went with them.

Every probe reading is unchanged -- [sources] form:, [sources] edit form:, [help] -- which is what
refactoring a shape rather than a behaviour should look like.
2026-10-11 17:27:05 +02:00
Martino Ferrari 4c56172005 feat: the preferences are a UI file, applying as they change
src/ui/preferences.ui is an AdwPreferencesDialog whose rows are libadwaita's own -- AdwEntryRow,
AdwSpinRow, AdwSwitchRow, AdwComboRow, AdwButtonRow -- instead of a label and a bare GtkEntry in a
GtkBox. There is no Apply button: a row applies as it changes, which is what those widgets are
designed around, and [prefs-probe] reads the settings file back after every change.

The folder chooser is cancellable now, so a picker still open when the dialog closes cannot write
into a row that is gone; the cancellable is also how the probe takes the cancel path without a
mouse.

Two traps cost a run each and are recorded in docs/claude/invariants-gtk-and-chrome.md: an
AdwDialog is not a GtkWindow (it cannot parent a GtkFileDialog, and in floating mode it adds a
native surface to gtk_window_get_toplevels() that is neither a widget nor a window), and a .ui
that is not listed in the GResource is a Gtk-ERROR at runtime rather than a build failure -- which
is exactly what the probe opening every window is for.
2026-10-11 15:30:10 +02:00
Martino Ferrari 0e256764d5 udpscope: an oscilloscope view for a UDPStreamer source
GTK4 and libadwaita. It receives one UDP stream through the vendored
udpsclient, buffers it in a large ring, and draws it with the vendored
plotter: a trigger with a pre/post event window, a pause that freezes the
window, cursors, measurements, math channels in a node editor, an event cache,
named configurations, preferences, and an export of the visible window to
.npy/.csv. Waveform files can be opened and analysed in place.

The interface is two threads -- a producer that owns the socket and the GTK
main thread that owns every widget and the GL context -- sharing one model
under one mutex, with samples crossing through lock-free rings.

deps/plotter is a submodule pinned to the commit this tree builds against;
deps/udpsclient is vendored in place. docs/claude/ documents the rules that
cost a defect to learn, and SPECS.md is the requirement document.
2026-10-11 15:21:40 +02:00
Martino Ferrari 8f54841c2c working on todo.md features 2026-10-10 22:52:12 +02:00
Martino FerrariandClaude Opus 4.6 210d124814 docs: the five reseq probe cases as this build measures them, plus the overflow counter's reading
Re-measured all four recorded cases and added a fifth for the re-plan path,
which needs 100 kS/s: at 10 kS/s the ring already covers 838 s, so no window
the control can reach moves time_stride and the window probe re-plans nothing.
Also notes that `stale` is 0 by construction while resequencing is on, and that
the reservation cap is counted in packets, so its sample cost scales with the
batch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-09 06:52:31 +02:00
Martino FerrariandClaude Opus 4.6 d2d4b7f204 fix: a hole straddling the counter wrap now matches its late packets
The lookup compared raw uint32 counters with >= and <, so a hole spanning the
2^32 wrap matched nothing and every late packet belonging to it was dropped. A
signed delta, the idiom deps/udpsclient already uses throughout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-09 06:52:24 +02:00
Martino FerrariandClaude Opus 4.6 51d13a09cb fix: the warn-once latches reset on a CONFIG, so a reconnect is diagnosed too
Four latches were process-lifetime -- two of them function-local statics, so
process-wide rather than per-object -- and a condition that warned once stayed
silent about every later occurrence in a long-lived session. A diagnostic that
goes quiet after the first occurrence is the failure mode worth fixing in a
feature whose job is diagnosis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-09 06:52:04 +02:00
Martino FerrariandClaude Opus 4.6 8320988d96 fix: a re-plan drops the holes it invalidated and restarts the tick they are addressed by
rebuild_locked() replaced the store, so produced() restarted at 0 while the
producer's tick_ kept counting: a hole's slot and tick addressing then described
a buffer that was gone, forget_holes_before() could not retire it, and a
backfill's amend fell outside the ring -- repairing nothing while the hole was
still marked repaired, which is the band reporting the opposite of the truth. A
re-plan generation is the signal rather than the stride/channel-count guard,
which a reconnect with an identical CONFIG leaves unchanged. slot_base_ is left
alone: it is relative mode's clock origin and must not walk backwards when the
user retypes the window.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-09 06:51:29 +02:00
Martino FerrariandClaude Opus 4.6 176ef1df9f fix: a full reorder ring keeps the arriving packet and counts the overflow, instead of dropping it silently
A packet that needed a slot and found none was neither held nor delivered: no
counter recorded it and the consumer reserved a hole nothing could ever backfill.
It now commits the gap to free a slot and takes the ordering decision once more
against the sequence that commit left behind, bounded to one retry; past that it
is delivered in order with the counters it skipped as its `lost`, never flagged
late, because a packet ahead of the sequence has no hole to be looked up by. A
duplicate can now replace its slot at a full ring, as hold_packet's own comment
already promised, and the 20 ms default window is a named constant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-09 06:50:43 +02:00
Martino Ferrari cb1bbd2b7b docs: a strikethrough that renders, and the 22% claim attributed to the right document 2026-10-08 23:19:15 +02:00
Martino Ferrari d90903d229 docs: the old reorder signature is history, not the delayed case's reading 2026-10-08 23:17:49 +02:00
Martino FerrariandClaude Sonnet 4.6 eacb3f5efc docs: the reseq probe's readings, and the invariants resequencing changes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-08 23:14:38 +02:00
Martino Ferrari 4549c622fc chore: plotter at 1fc97d8 -- the invalid-span band and its two tints 2026-10-08 23:05:11 +02:00
Martino Ferrari 3859d2ca79 feat: a repaired hole keeps a fainter band, so a report is not a false accusation 2026-10-08 23:00:22 +02:00
Martino Ferrari 4666b807fc test: a probe reads the resequencing path's state, not only its counters 2026-10-08 20:23:38 +02:00
Martino Ferrari ee55ac84b7 feat: a reserved hole is shaded, so a sub-pixel gap is still a report 2026-10-08 20:12:38 +02:00
Martino Ferrari b32987d7e0 docs: the acquire() guard says what it actually guards, now that the drain sits outside it 2026-10-08 19:52:16 +02:00
Martino Ferrari d6afdfef9f fix: a reserved hole is addressed by the counters actually missing, not one past the frame that revealed them 2026-10-08 19:47:07 +02:00
Martino Ferrari 7c60d79b65 feat: a late packet backfills the hole it left, values only 2026-10-08 19:44:24 +02:00
Martino Ferrari fc81e661d0 feat: the status bar and the exit dump report resequencing, holes and stale packets 2026-10-08 18:58:06 +02:00
Martino FerrariandClaude Sonnet 4.6 d38ab3f9c9 fix: the hole reservation now runs after the accumulator re-plan, so value_acc_ is always sized to channels.size() before reserve_hole() writes into it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-08 18:29:57 +02:00
Martino FerrariandClaude Sonnet 4.6 e9d259ac7f fix: a late frame is now dropped before it reaches the timeline, leaving its reserved hole intact
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-08 18:26:26 +02:00
Martino FerrariandClaude Sonnet 4.6 6830b97c02 fix: batch_ is now learned in both stream and relative-time modes so hole reservation fires in stream mode.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-08 18:22:32 +02:00
Martino Ferrari 5915fc8421 fix: a lost packet reserves the samples it owed instead of vanishing from the timeline 2026-10-08 18:18:12 +02:00
Martino Ferrari 85ba835d61 test: the mock can release a withheld packet past the reorder window 2026-10-08 17:49:11 +02:00
Martino FerrariandClaude Sonnet 4.6 45958a441a docs: late_resequenced counts held-then-delivered packets, not gaps avoided
Fix the false trailing clause in the udps_stats_t::late_resequenced comment,
move udps_held_t from the tunable-limits section to internal-state next to
udps_slot_t, and add braces to three braceless if bodies to match house style.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-08 17:44:13 +02:00
Martino Ferrari 6e79a67252 feat: hold a packet that runs ahead of the sequence, and deliver a late one flagged 2026-10-08 17:34:26 +02:00
Martino Ferrari 57e2f6aa8b feat: relative mode keeps the stream's absolute clock for the export 2026-10-08 17:20:47 +02:00
Martino Ferrari d11e7d48c4 feat: relative mode stamps a sample from its position and the declared rate 2026-10-08 17:06:09 +02:00
Martino Ferrari b645859f64 feat: --relative-time selects the counter-derived clock 2026-10-08 17:03:28 +02:00
Martino FerrariandClaude Opus 4.6 2b89f72960 chore: bump deps/plotter to the slope-graded polyline, untrack the sdd report
.superpowers/sdd/ is scratch and gitignored; that one report was tracked
by accident in an earlier phase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 16:56:37 +02:00
Martino FerrariandClaude Opus 4.6 ec8eae0317 docs: todo.md rewritten around what is left
The four original items are done. What replaces them: the invisible
riser, relative timing, named configurations, frame storage for
forensics, opening exported waveforms, the plot context menu, drag
resize, an event cache with replay, and a preferences dialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 16:56:36 +02:00
Martino FerrariandClaude Opus 4.6 82180f45f7 feat: the mock can drop and invert packets, and carries a noisy channel
--drop-every/--drop-burst withhold datagrams the counter and stream clock
have already advanced over, and --reorder-every inverts a pair on the
wire; both are deterministic for the reason --glitch-at is, since a probe
asserts the spacing of what it finds and a random count is not a figure.
A `noise` signal gives the dense-trace and intensity-floor paths a input
that is not piecewise smooth.

verification.md records the measured readings, including the one that is
a finding rather than a baseline: a burst past kGlitchPackets (256) is
read as a producer clock glitch, each bridge advances the anchor by one
packet span against a timeline 300 ahead, and four consecutive failures
then throw the stream onto the arrival clock.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 16:56:36 +02:00
Martino FerrariandClaude Opus 4.6 cd9657663a fix: the math editor's source dropdown no longer hands a GParamSpec to a value handler
notify::selected passes a GParamSpec* ahead of the user data and
GtkSpinButton::value-changed does not, so on_math_param_changed read the
pspec as its MathDialog* and faulted on the first field it touched --
which is to say on opening the math editor and picking a source.

The probe missed it because filling the form sets the dropdown to the
value it already holds, and GObject emits no notify for that: a channel
sourcing channels().front() picks index 0 on a dropdown already at 0.
math_probe now opens on a channel sourcing the *last* acquired signal,
which does move the selection, and reads back the source the handler
wrote into the node.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 16:56:25 +02:00
Martino FerrariandClaude Opus 4.6 219ae91fd8 docs: a spec for the square wave's riser being drawn only sometimes
Two complaints, not one: the glitch is a row dropped before any style
knob is consulted, and the thinness is a choice udpscope made and never
revisited -- it asks deps/plotter for a trace thinner than the library's
own default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 16:56:25 +02:00
Martino FerrariandClaude Opus 4.6 723d1db3d2 docs: implementation plan for resequencing and relative timing
Twelve tasks in five phases, so relative timing ships before any of
the client-side reordering work lands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 16:50:50 +02:00
Martino FerrariandClaude Opus 4.6 d5d1295b26 docs: a design for resequencing late packets into a reserved hole, and for relative timing
The two were asked for together and are one design: the packet counter is what
makes a hole addressable, so relative timing is the coordinate system in which
resequencing is arithmetic rather than inference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 16:38:18 +02:00
Martino FerrariandClaude Opus 4.6 aa8ec1794a fix: a zero-width band fires on equality or not at all, and the text now says which
The earlier correction moved the claim rather than fixing it: four places still
said the float-equality test "never fires on a real-valued signal", and a +/-1
square is a real-valued signal -- a standalone check against a band of [1, 1]
fired 19 times, because `inside` is true at +1 and false at -1, so the signal
changes side on every transition.

All four now state the measured behaviour: an exact float-equality test against
the level, which an ordinary signal never satisfies and a signal sitting at the
level satisfies on every transition. Neither answer is a band, which is the
reason the second spin is clamped an increment away rather than the detector
special-cased -- the same conclusion, now resting on a true premise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 11:39:22 +02:00
Martino FerrariandClaude Opus 4.6 c8f10f59dc fix: a condition leg is reported once it is held, so a working leg cannot read nan
A finite trigger_time() only says the edge was accepted; the mode is Capturing
until the post window fills. Every field the report prints is read from
display_snapshot(), whose stamps span x = 0 only once the event is Held, so
reporting on the latch alone raced the capture: leg 2 printed
`measured=nan at0=nan ... mode=1` on one run and the documented
`at0=-0.500907 earlier=-0.498185 mode=2` on the next. The report now waits for
Held under the same poll budget, and says `latched but never held` when it runs
out -- a different defect from `no latch`, so the line names which.

The readings are corrected against three consecutive leg-2 runs and one leg-3
run: `t0` is a rebased stream stamp and varies every run, so it was never an
assertion; `hits=` likewise tracks the arm phase (12 at one arm, 11 at another).
The mode ordinals were wrong -- `mode=2` is `Held`, not `Capturing`. Shapes 2
and 3 are now measured rather than read from the arm code; shape 1 still is not.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 11:34:30 +02:00
Martino FerrariandClaude Opus 4.6 90d5d0e2d2 fix: --glitch-at without --glitch-width now does something rather than silently nothing
--glitch-width defaulted to 0.0, so --glitch-at alone produced no glitch and no
warning — the same class of silent no-op that cost a debugging cycle earlier on this
branch.  The default is now 0.002 s (2 ms), matching the standalone probe run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 11:19:27 +02:00
Martino FerrariandClaude Opus 4.6 728e7fbda5 docs: help pages, probe reference and session docs updated for the three new features
- 08-trigger.txt: names all four condition shapes and the buffer search; fixes the first
  paragraph and 'How to reach it' to match what the panel actually contains (fix 3).
- 10-measurements.txt: adds a Statistics bullet — one observation per recompute, what
  resets the history, what does not (fix 3).
- probe-readings.md: re-quotes condition-probe lines with level2= and mode= fields;
  corrects leg-2 window geometry (0.2 s / 10 % pre, not 1.0 s / 50 %); moves the
  wide-window reason to legs 1 and 3; fixes the search-probe scheduling note
  (search_probe calls condition_probe directly; do not also set CONDITION_MS) (fix 6).
- verification.md: corrects shape-2 window geometry to match the code (fix 7).
- invariants-session-and-export.md, README.md: document the measure_stats session key —
  position, type, default false (fix 8).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 11:19:19 +02:00
Martino FerrariandClaude Opus 4.6 090b6bb57d fix: a zero-width band fires on equality, not never; dead accessors removed
- The comment in `feed_window()` and three companion sites claimed a zero-width band
  "never fires, because no sample can be on a different side of it than its neighbour".
  That is false: `inside = value >= lo && value <= hi` with `lo == hi == 1.0` is true at
  +1 and false at -1, so a ±1 square fires 19 times in 20 samples.  The correct reason
  the panel refuses one is that it reduces the condition to a float-equality test on a
  real-valued signal, which in practice never fires (fix 4).
- `EventDetector::condition()`, `BufferSearch::channel()` and `BufferSearch::condition_`
  were confirmed unused by grep and deleted (fix 10).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 11:19:05 +02:00
Martino FerrariandClaude Opus 4.6 7aa9cf4664 fix: a rebuild, a spurious observation, and a width limit protect the invariants they were breaking
- A buffer rebuild mid-scan (CONFIG or window change) now calls `search.clear()` beside
  `reset_measure_stats()` at both sites; without it the scan kept stale at_/end_ and could
  report Done over fabricated hits (fix 1).
- `search_marks_label` now carries `PANGO_ELLIPSIZE_END` so the side pane's minimum width
  is bounded; the longest text (~58 chars) was driving the pane width (fix 2).
- `update_measures()` gains a third `accumulate` parameter (no default) so every call
  states its intent; the Statistics toggle now passes `false`, stopping the same window
  being counted twice and deflating sd on repeated toggles (fix 5).
- `search_jump()` records whether `publish_pause_view()` returned an empty snapshot and
  `update_search_panel()` appends "(no longer in the buffer)" to the position label when
  it did (fix 9).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 11:18:51 +02:00
Martino FerrariandClaude Opus 4.6 806e168ec4 fix: the condition tooltip says what the dropdown changes, not that it has entries
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 10:47:38 +02:00
Martino FerrariandClaude Sonnet 4.6 ffc3ec1b4d docs: the three new features are described where each of their readers looks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-08 10:43:39 +02:00
Martino FerrariandClaude Sonnet 4.6 aefac2d796 fix: the condition dropdown's tooltip names all four shapes without listing them
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-08 10:43:36 +02:00
Martino FerrariandClaude Opus 4.6 2ed42fe632 fix: every branch of the marks label counts the marks it can see, so the counts read alike
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 10:34:34 +02:00
Martino Ferrari 4a58887cb1 fix: the search label no longer claims an off-screen selected hit is in view
place_search_marks() places the selected hit unconditionally to keep it marked when stepping
to it, but the label must distinguish this case from hits that are actually visible on screen,
so it does not lie about what is in view. add sel_off_view to track when the selected hit is
off screen, introduce a shown count that excludes it, and emit a third label case when only
the selected hit is off screen.
2026-10-08 10:25:27 +02:00
Martino Ferrari 5bb4bd207d fix: the marks are read a frame after the jump, so a placed count is read from the window it is on
The plan's Task 8 named the search probe as its test but never extended it, and leaned on the
exit dump instead -- which stops the tick from Live, where no hit is in view, so it reads
placed=0 for a correct implementation and a broken one alike.
2026-10-08 10:18:44 +02:00
Martino Ferrari db81ab2700 feat: a search's hits are marked on every row, and the panel says how many of them it is showing 2026-10-08 10:11:50 +02:00
Martino Ferrari 8ee4effb70 fix: a truncated scan stays truncated, so a hit list with a hole is not reported clean 2026-10-08 10:03:46 +02:00
Martino Ferrari 415fceca87 feat: the buffer can be searched for the trigger's own condition, and the hits stepped through 2026-10-08 09:56:47 +02:00
Martino FerrariandClaude Sonnet 4.6 2cee85c52b fix: a statistics line elides its own text, the toggle handler is guarded, and the doc comment names both labels
- The statistics label in measures_row() now carries PANGO_ELLIPSIZE_END,
  matching the kind label's own treatment, so a long reading cannot widen
  the pane beyond its measured minimum.
- The toggled handler on measures_stats_check now returns early when
  updating_panels is set; apply_layout() sets updating_panels around its
  gtk_check_button_set_active() call and then sets the Reset button's
  sensitivity directly, so the loaded state is fully applied without a
  spurious schedule_layout_save().
- The measures_row() header comment now names both labels that
  update_measures() rewrites and explains the two-line layout.
- gtk_widget_set_sensitive on measures_stats_reset is null-checked in the
  toggled handler, matching every neighbouring handler in the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-08 09:44:04 +02:00
Martino Ferrari 4f496b14b7 feat: the measurements pane can show how each reading varies, and remembers that it was asked to 2026-10-08 09:34:06 +02:00
Martino Ferrari 1a8218887c feat: a measurement accumulates how it varies, so one reading can be told from a trend 2026-10-08 09:22:35 +02:00
Martino FerrariandClaude Sonnet 4.6 d79810c7ba fix: dead probe fields are removed and the channel name is chosen once, so arming cannot disagree with enabling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-07 18:25:33 +02:00
Martino Ferrari 601923f1d1 fix: a window leg's band comment names the sine it is now read against
The leg moved from the square to the sine when the band proved untraversable
by a +/-1 square; the comment beside the band still described the square.
2026-10-07 18:18:33 +02:00
Martino FerrariandClaude Sonnet 4.6 a00663510f fix: a dropout probe outlasts the sine's own crossings, so only a real silence latches it
- Leg 1: widen to 1.0 s / 50% pre so the square's opening edge is in the snapshot
- Leg 2: use sine (not square) for the window channel, edge = falling so the band exit
  is deterministic, and read earlier from vals[end-2] rather than half the snapshot
- Leg 3: raise timeout to 0.3 s (> half-period), accept either crossing direction,
  widen to 1.0 s / 50% pre to reach the crossing that sits 0.3 s before x=0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-07 18:13:26 +02:00
Martino FerrariandClaude Opus 4.6 a26770ec32 feat: a level band and a signal that stops can be the trigger's event too
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-07 18:01:07 +02:00
Martino FerrariandClaude Sonnet 4.6 0876ecfe22 fix: condition-probe assertion uses snapshot width, not mock-clock phase
The old `phase=` field compared `trigger_time()` against the mock's absolute
`glitch_at + glitch_width` target.  The app's stream epoch is the first packet
it received, not the mock's t = 0; the two clocks differ by however many
packets the CONNECT handshake cost, making the comparison meaningless.

Replace it with a snapshot-relative measurement: scan backwards from x = 0 in
the held event snapshot for the most recent upward crossing of `condition.level`
(the same `prev < level && val >= level` test the detector uses) and negate the
crossing's stamp to get the measured pulse width.  A `measured` near 0.002 s is
the glitch caught exactly; near 0.200 s means an ordinary edge was latched.

Remove the now-dead `glitch_at`, `glitch_width`, `square_period` fields from
`ConditionProbe`.  No other files changed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-07 17:48:25 +02:00
Martino Ferrari d147678b1c feat: a pulse whose width is wrong can be the trigger's event, so a glitch can be caught 2026-10-07 17:32:11 +02:00
Martino FerrariandClaude Sonnet 4.6 b3fd9ae00f refactor: what counts as an event moves into its own evaluator, so a search can ask the same question the trigger asks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-07 17:15:37 +02:00
Martino Ferrari 178cf54ef6 feat: the mock can inject a narrow pulse and stop a signal, so a width and a dropout condition have something to find 2026-10-07 16:59:29 +02:00
Martino FerrariandClaude Opus 4.6 9aa1d80158 docs: an implementation plan for the three new scope features
Nine tasks with literal code: the mock's injected faults, the shared
EventDetector the trigger and the search both run, the four condition
shapes and their panel rows, the Welford statistics and what resets
them, the chunked buffer search, its marks, and the documentation.

Every probe's expected reading is written as a shape with the measured
figure left to be recorded from a run, never assumed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-07 12:27:30 +02:00
Martino FerrariandClaude Opus 4.6 a5ca841603 docs: the spec names the formatter the code actually has
format_measure_value() does not exist; the shared SI-prefix formatter is
format_measure(double, const std::string&) at src/ui.cpp:1004.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-07 11:21:36 +02:00
Martino FerrariandClaude Opus 4.6 19aad492eb docs: the trigger is not persisted, so the condition is not either
The spec described the new condition fields as appended session keys. LayoutState
carries no trigger field at all -- the trigger is built from --holdoff and the
panel's defaults on every launch -- so there was nothing to append to. Persisting it
is a separate feature nobody asked for, and a session that silently restored an armed
pulse-width condition would surprise rather than help. The statistics toggle stays as
the one session key this design adds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-07 11:11:30 +02:00
Martino FerrariandClaude Opus 4.6 6770c6a648 docs: a zero-width trigger band is refused, not degraded to an edge
The spec claimed a window condition with equal levels degrades to an edge trigger on
that level. It does not: the band test is a change of side, so equal levels make the
signal "inside" only on exact float equality and the condition silently never fires.
Special-casing the detector into an edge trigger would conflate two state machines
that answer different questions -- the divergence the shared evaluator exists to
prevent. The panel refuses the degenerate band instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-07 11:09:52 +02:00
Martino FerrariandClaude Opus 4.6 98be03e00d docs: three scope features are specified around one shared event condition
Qualified trigger conditions (pulse width with glitch as a comparison, window,
dropout), per-measurement statistics with a reset, and a search over the retained
ring. The first and third are the same question asked of live and of recorded data,
so they share one allocation-free EventDetector extracted out of TriggerEngine --
and the probe that proves the two paths agree on t0 is part of the scope.

Reference waveforms, PNG export, FFT and a byte-bounded event history are out, each
for a stated reason.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-07 11:07:00 +02:00
Martino FerrariandClaude Opus 4.6 f179fbbb44 fix: a clock glitch is bridged and a channel is known by name, so a capture keeps its t0
Two defects behind one report ("a trigger during a capture overrides the first,
and the hold-off does not apply"); the second is also what made a spike in one
channel look like the acquisition resetting.

The trigger engine keyed its invalidation on the trigger channel's *index*, which
every rebuild of the channel list renumbers -- enabling a signal that sorts before
it is enough. Any rebuild therefore abandoned an open capture and set no hold-off,
so the next edge inside the post window became t0. It keys on the channel's name
now, which changes only when the source genuinely does.

A packet whose stamps jump more than 256 packet spans ahead is now rejected as a
fault and *bridged* at the declared cadence, instead of poisoning the monotonic
mark (which pinned every later packet flat at the glitched value) and condemning
the next good packet. Only four consecutive failures abandon the declared time
signal, and that fallback no longer wipes the rings: the arrival clock is anchored
to continue from the monotonic mark, so the history is still on one timeline --
the wipe is what made one glitching packet look like the stream restarting from 0.
The declared unit is resolved before those checks, since they weigh the stamps
against a cadence in seconds.

Guards: UDPSCOPE_PROBE_TRIGRENUM_MS prints t0_held=1 through the real widgets,
and the mock's --time-glitch-at/--time-glitch-by injects the clock fault.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-07 10:29:05 +02:00
Martino FerrariandClaude Opus 4.6 4003084648 feat: a signal's chip is clickable, so a trace's colour is the user's to choose
Clicking the coloured chip in the signal list opens a GtkColorDialog and the
colour it returns overrides that signal's palette entry. Held by name, like
every other identity here, and persisted as a session `[[color]]` table.

RowSet::signal_color() becomes the one resolver: install() builds the trace
through it and all three chip sites read it, because a chip exists to say which
trace a row is about and an override is exactly the state in which two routes to
a colour would disagree. A grey chip names no trace and so is not clickable, and
the way back is `Default colour` in the signal's menu -- a setting with no undo
is a trap.

The gesture sits on the existing label rather than in a GtkButton: padding on
this row is what made the signal list twice as tall once. It claims its sequence
so a click on the chip cannot become the start of a row drag.

UDPSCOPE_PROBE_COLOR_MS reads the result out of plot::Plot::trace() and out of
the chip's own markup, with an override in no palette slot, so neither leg can
pass by asking the resolver what it already said.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-07 08:22:32 +02:00
Martino Ferrari 8bc1fe8b5c fix: a repaired source is reconciled above the validation line, not below it
`math_dialog_sync_form()` wrote the label and then moved a node whose source
names an unacquired channel -- a mutation that changes what
`math_dialog_problem()` reports. With the sync re-entry now guarded, the nested
call that used to repair the label is suppressed, so the line kept describing a
source that had just been fixed and left `Apply` insensitive until the next
mutation. The reconcile moves above the write and the dropdown reuses its list.
2026-10-07 07:17:46 +02:00
Martino Ferrari f238e717b2 fix: a wire is picked by distance and a refusal is spoken, so the editor answers
A review of the whole node editor. The hit test measured the distance to the
24 curve samples rather than to the segments between them, so a wire spanning
several rows was unpickable between two samples -- the probe now places its
boxes 280 units apart and prints the nearest sample's own 8.4 against a 3.0
tolerance, which the point test would have missed. A refused connection said
nothing: the message is now carried in `MathDialog::refusal` and shown once by
the single writer of the label, and a self-wire is refused through the real
drag-end path instead of only through `math_connect()`.

Two defects were found only by the new assertions. `math_dialog_sync()`
re-entered itself -- `gtk_drop_down_set_selected()` emits `notify::selected`
for a programmatic set too, and the handler answers by syncing again, which
consumed the refusal before it could be drawn -- so the body is now guarded
one level deep. And a deleted node left `selected_wire` naming an index that
afterwards belongs to a different wire, so `Delete` cleared an input on the
wrong node.

A node is placed in the first free grid cell rather than at the end of the
list's own cell, so a node added after a delete no longer lands on top of a
survivor; the label ellipsizes with a tooltip rather than wrapping, because a
wrapped line makes the dialog's height depend on the text in it.
2026-10-07 07:09:49 +02:00
Martino Ferrari 300b33159e docs: the node editor's gestures, its new probe assertions and its invariants
Each invariant records what went wrong rather than only the rule: a port hit
that lost its node, a hit test at the identity view that asserts nothing, a
`Delete` that must not fall through to the selected node, and a column pitch
too narrow for a wire's own tread. The warning baseline drops to four.
2026-10-07 02:56:36 +02:00
Martino Ferrari 8918494acb feat: a tidy button lays the graph out by depth, so no two boxes overlap
A single forward pass -- the list is already in evaluation order, so every
input's depth is in hand by the time the reader is reached -- then a fit. The
column pitch is `kNodeW + 48` rather than the add-node grid's 160, which left
28 units for a wire's 40-unit tread and made the bezier double back.
2026-10-07 02:44:50 +02:00
Martino Ferrari 5a69b2147b feat: the op is picked when a node is added, not changed afterwards
\`add node\` made a gain and nothing else, so every other op was two steps. It
is a menu of the twelve now, each carrying its index through
\`math_op_index()\`'s space rather than a cast of the enum -- which is also
what \`math_dialog_sync()\` now uses to drive the op dropdown, since that
order is the dialog's and not the declaration's.
2026-10-07 02:39:26 +02:00
Martino Ferrari bafa391ad8 feat: the editor says why a graph cannot be saved instead of refusing on Apply
One `math_dialog_problem()` drives both the line and Apply's sensitivity, so
they cannot disagree, and the two modal alerts are gone -- there was nothing
in either message a user could not have been told while still building the
graph. Written above `math_dialog_sync()`'s early return, since an
out-of-range selection is exactly when a stale line misleads most.

The source dropdown now lists the acquired channels, which is what Apply
validates against: listing every signal let a user pick a source that could
never be made valid, and put the stream's timestamp signal first.

Deleted `const int output_built = ed->output` from `math_probe()` -- it was
dead code, redundant with the `output_after_add` already printed on the same
line, and its removal is what drops the warning baseline from five to four.
The probe's `bad_text` pointer is copied to `std::string` before the second
`math_dialog_sync()` overwrites the label's internal storage.
2026-10-07 02:28:32 +02:00
Martino Ferrari 5ca762031d feat: a backwards wire re-sorts the graph instead of being refused
The evaluator walks the node list in order, which used to make the order the
user's problem -- an input of an earlier node was not even drawn as
connectable. `math_connect()` runs Kahn's algorithm over the trial edge set,
with ties broken by the current index so a graph already in order is not
permuted, and remaps every index the dialog holds in one block. A self wire
and a cycle are refused with a message and change nothing.
2026-10-07 02:14:10 +02:00
Martino Ferrari 4c726adb54 feat: a wire can be picked and deleted, so a graph can be rewired
\`math_wire_at()\` samples the same cubic the draw lays down -- 24 segments,
well under a pixel of chord error at the scales this canvas allows -- and
\`Delete\` removes the picked one. It deliberately does not fall through to
deleting the selected node: a key whose target depends on an invisible
selection is how a graph gets destroyed by accident.
2026-10-07 02:07:08 +02:00
Martino Ferrari 8dd6b3db12 fix: a drag's release point is the press plus the offset, so a detached wire can be dropped
Record the press in canvas coordinates at drag-begin; use press+total-offset
in both the Wire update and drag-end, replacing the output-port round-trip
that placed the release a column away on a detach. Run the gesture probes at a
non-identity view (scale=2, x=50, y=20) so a missing math_to_graph call causes
failures rather than passing silently. Add a fifth probe: detach and release in
place must reconnect. Update the stale comment that described the deleted
two-click protocol.
2026-10-07 01:59:27 +02:00
Martino FerrariandClaude Opus 4.6 a7702acb63 docs: CLAUDE.md is a goal and a table of contents, so a context pays for what it reads
The file was 1967 lines and is auto-loaded into every context, so every
session paid for the whole of it to reach one invariant. It now carries the
project's goal, the three facts that are always relevant, and a table of
contents; the body is fourteen files under docs/claude/ reached by link
rather than by import, so nothing is loaded until it is asked for.

The content is moved verbatim -- the only edits are each file's own header
and the cross-references that became cross-file ("see the invariant below"
is now a link to the file that owns it).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-07 01:49:28 +02:00
Martino Ferrari 0b5ace9585 feat: a wire is dragged from port to port, and a wired input is dragged off
One state machine decided at `drag-begin` -- a port starts a wire, a box
moves it, the background pans -- so the two-click protocol and its armed-port
highlight are gone. A drag from an input empties it immediately, which makes
a release over nothing the detach gesture; `Escape` puts it back.
2026-10-07 00:03:42 +02:00
Martino FerrariandClaude Opus 4.6 9f589d5525 fix: the zoom probe calls math_zoom_at() directly, so it measures what it gave
The old assertion called math_canvas_scrolled(nullptr, ...) which resolves the
anchor itself from the event controller — with no controller it fell back to the
canvas centre (or a hardcoded 420x300 size when the widget was not yet allocated).
The probe then measured whether the graph point under (180, 140) was kept. Those
are two different points, so the difference printed was noise.

Extract math_zoom_at(d, px, py, dy) from the handler body: it takes an explicit
canvas anchor and guarantees the graph point under it is still under it after the
zoom. math_canvas_scrolled() resolves the anchor (from the event, or the widget's
own centre with no magic fallback dimensions) and delegates to it. The probe calls
math_zoom_at() with its chosen anchor directly, so dx and dy are an assertion on
the code rather than on the difference between two unrelated points.

Broken-anchor reading: dx=-180.0000 dy=0.0000. Fixed reading: dx=0.0000 dy=0.0000.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 23:52:22 +02:00
Martino FerrariandClaude Opus 4.6 b8d178eec6 feat: the node canvas zooms about the pointer and pans, so a graph can be read
One view transform applied to the cairo context, with `math_to_graph()` as
the inverse that every hit test runs -- a tolerance is a screen distance,
so it is divided by the scale rather than left in graph units. A wheel
zooms about the pointer, a background drag pans (next commit), `f` fits and
Ctrl+0 resets. The drawing area is made focusable, without which a key
controller on it never fires.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 23:47:11 +02:00
Martino FerrariandClaude Opus 4.6 6797b31d2e fix: an armed output highlights every other node's inputs, so the second click is visible
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 23:33:03 +02:00
Martino FerrariandClaude Opus 4.6 84f310ee43 fix: a port hit names its own node, so the first click of a wire lands
\`math_port_at()\` returned a sentinel for an output hit and the caller
recovered the node with a box test -- but the output square straddles the
box's right edge, so every pixel past it disarmed the gesture silently.
That is the whole of "edges cannot be added".

Cancel also closed with \`gtk_window_destroy()\`, which emits no
\`close-request\`, so the dialog pointer was left dangling and no channel
could be edited twice.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 23:28:06 +02:00
Martino FerrariandClaude Opus 4.6 0866b744ad docs: a plan for the node editor, so each defect ships with the probe that saw it
Nine tasks, each ending in a commit: the silent disarm in the port hit test,
the view transform, the drag-based wiring, wire deletion, the topological
re-sort that makes a backwards wire legal, the live validation line, the op
picker, tidy, and the docs with the full verification run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 23:24:15 +02:00
Martino FerrariandClaude Opus 4.6 96ee35da25 docs: a design for a node editor whose wires can be drawn, deleted and navigated
The reported "cannot add edges" has a specific cause: the output port's hit
radius reaches past the box it belongs to, and the press handler re-derives the
owning node with a box test, so clicking the visible dot's outer half sets
pending_out to -1 and silently disarms. Edge deletion has no code path at all.

The design replaces the two-click protocol with a drag gesture, makes the list
order the editor's own invariant via a topological re-sort rather than a rule the
user must obey, adds a view transform with every hit test routed through its
inverse, and trades the modal alert on Apply for a live validation line. It also
records that the Cancel button still calls gtk_window_destroy -- the dangling
dialog pointer CLAUDE.md documents as fixed was only fixed on the Apply path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 22:26:31 +02:00
Martino FerrariandClaude Opus 4.6 f307d27e41 docs: a pause publish costs no packets, and the look is checked with frame path
An A/B of the same fast-mock run with and without a pause reads gaps=0 both
ways, the paused one having published in 86 ms -- so the gaps=73 recorded
yesterday as "a pause costs packet gaps, unexplained" was incidental load on
one run. It never scaled with the publish length and has not reproduced.

Record the publish figures as the range they actually span across runs
(event 69-115 ms, pause 22-86 ms) rather than as single readings, and name
`frame path:` across a pause as the check that the paused trace is drawn like
a live one -- raw=1 lod=555 against raw=0 lod=373, where the defect read
raw=21 lod=118.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 20:45:13 +02:00
Martino FerrariandClaude Opus 4.6 d2c51f2448 fix: a paused view carries its own LOD pyramid, so it is drawn like a held event
publish_pause_view() filled stamps, values and y and left pyramids empty, so
level_for() descended to raw samples and a frozen pause was re-gathered from
every sample it held on every frame. It cost draw time (12.34 -> 18.18 ms mean)
and, because stage_trace_density() normalises each column against its own peak,
it also changed how the trace looked -- which is how it was noticed.

Both publishes are now timed and reported as `publish cost:`, counted apart
because they stall opposite threads: the event publish runs on the producer
under mutex() and blocks the UI, the pause publish runs on the UI thread and
blocks the producer. That is the number a second live ring has to be judged
against.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 20:36:27 +02:00
Martino FerrariandClaude Opus 4.6 13574bfcef fix: a crossing is counted with hysteresis, so noise is not counted as cycles
period, frequency and pulse duration counted every sign change about a single
level, which is unusable on any real signal: the mid level is exactly where a
waveform moves fastest, so an interferer faster than the fundamental recrosses
it several times per true crossing. A 5 Hz sine with a 500 Hz tone at a tenth of
the amplitude read 79 crossings for 9, period=0.025622 against 0.2.

MeasureLevel gains a hysteresis band sized once in measure_set_level(): a tenth
of the window's peak-to-peak, clamped to half the room to the nearer extreme so
a threshold set near one stays usable. The band moves the edge decision only --
the sum/count split stays at the bare level, since that split is top/bottom and
has a documented exact reading. The bucket fast path tests the band and the
level separately rather than folding them, or it would refuse exactly the
buckets a crossing passes through: clean cost is unchanged at raw=0.1% and
1.41 ms a recompute.

tools/mock_udpstreamer.py gains --noise, defaulting to 0 so the exact probe
assertions still hold.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 10:07:56 +02:00
Martino FerrariandClaude Opus 4.6 b4b26d3e31 feat: a dense trace's columns are graded by dwell, so density reads as brightness
Stage 1 cured the moire by collapsing a dense gathered window onto one vertical
segment per pixel column; this is the information that collapse throws away. A
column's min and max are the same whether its samples are spread evenly or piled
at its two ends, so the flat parts of a square wave and the edges between them
drew as one ink. Style::intensity_grading, which the library leaves off and this
app turns on at the first tick (the renderer does not exist until the area is
realized), grades each column by the time the signal spent at each row -- the
digital-phosphor look.

frame path: gains a graded= field, which must equal columns=: grading is a way of
drawing the reduction, not a third path. UDPSCOPE_NO_PHOSPHOR=1 puts the style
back to the library's default and is the A/B, since a frame mean cannot attribute
its own cost -- measured on the fast mock, draw 3.24 ms graded against 2.30 ms,
with segments 1340/4157 against 131/4306 over the same lod=453 frames. That A/B
is also what found the level-floor regression now fixed upstream.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 03:02:48 +02:00
Martino FerrariandClaude Opus 4.6 5f5b87ca60 fix: a dense trace is drawn one pixel column at a time, so it cannot moiré
At a high rate, a high frequency or on noise the plots banded visibly and the bands
crawled under a pan. Three things compound to produce it, none a bug alone: a
pyramid bucket spans one to two pixel columns, Envelope emits two points per bucket
so each contributes a near-vertical hairline and a near-vertical connector, and
every one lands at a fractional x because a trace is deliberately not snapped --
so a column's brightness beats against the pixel grid, and a pan sweeps the beat.

The cure is upstream (deps/plotter, pin moved): draw_traces reduces such a window
onto the pixel grid, one snapped vertical segment per column from that column's
lowest to its highest value. One sub-pixel phase for the whole trace, so there is
nothing left to beat, and the geometry is bounded by axes.w rather than by the
buckets.

frame path: gains a columns= field, which is what separates "the segment count
collapsed because the reduction ran" from "the window got narrower". On the fast
mock at 3 kHz over 20 s: columns=1200, segments 2041/2237 over 1201 painted frames,
against columns=0, segments 10960/216093 over 263 before -- the same run at 4.6x
the frame rate, with trace ink: clean either way.

Also corrects the documented mock invocation: --batch 20000 exceeds the datagram
limit and the mock dies with Errno 90.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 02:02:52 +02:00
Martino FerrariandClaude Opus 4.6 62df049f53 fix: the plot's two side margins are balanced, and the dump outlives its widgets
With the axis titles gone the left gutter was 200 px of an 870 px viewport and the right
side had no margin at all, so the newest tick's label was drawn half outside the plot and
the newest sample's ink landed on the frame.

Two causes on the left. The reservation covered `format_si()`'s `%.3g` fallback
(`-1.23e-45`), which no signal on an `Si` axis produces -- the widest real label for a
unitless axis is `-1.23m` -- and `note_y_gutter()` added 6 px of slack to a figure that is
already the maximum of the labels and last frame's floor, making the gutter a ratchet that
climbed to its 200 px cap in ~19 frames. It settles at 104 px now, with `axes.w changes: 1`
where it read 17.

The right side did not exist: a plot's `axes` rect reached the viewport's right edge by
construction. `deps/plotter` gains `Plot::set_right_gutter()` and `Figure::set_right_gutter()`
-- a want rather than a measurement, since nothing is drawn there, imposed figure-wide so
the rows end on one column as they already start on one. udpscope asks for half the widest
time label plus the label padding (59 px), x labels being centred on their tick.

Separately: the exit dump asked two destroyed widgets for their size, which is where the
"exactly two Gtk-CRITICALs" baseline came from. The pointer is freed memory by then, so a
run that had destroyed other windows first (UDPSCOPE_PROBE_HELP_MS opens three) segfaulted
in the middle of the dump instead. Both sizes are captured on the tick; a clean run now
carries no critical.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 22:34:10 +02:00
Martino Ferrari 811c6d80f0 fix: a source that has sent no CONFIG reads connecting, not connected
A stream that answered CONNECT but has sent no CONFIG has no signal list, so
nothing can be enabled and no DATA frame is owed. Calling that "connected, but
no data is arriving: another reader may hold the source" blamed a second reader
that does not exist, and the badge blinked green over it -- a working pipeline
is exactly what it did not have.

The glance word is `connecting` (ten characters, no wider than `event held`,
so the fixed-set layout rule still holds) and the badge takes a fourth, steady
yellow phase, both from the first frame rather than after the three-second
stall test. The stall notice itself now splits on `have_config`: the unicast
contention wording where a signal list exists, "trying to connect" where it
does not, with `[udpscope] connecting:` on stderr beside the old `starved:`.
2026-10-05 22:08:13 +02:00
Martino FerrariandClaude Opus 4.6 b313cac46e feat: the x axis is relative to now, and an export carries both clocks
Three asked-for changes, one commit because they are one coordinate system.

The live axis is a fixed [-window, 0] with the newest sample at 0, so x reads
as an age and the labels stand still while the trace rolls under them. That
makes the display clock distinct from the stream clock for the first time, so
`ScopeModel::stream_at_x0()` becomes the single conversion between them
(`display_x = stream_t - stream_at_x0()` in all four modes) and a snapshot
carries its own origin in `EventSnapshot::x_offset`. The pause view is now
rebased like an event's, reversing an earlier deliberate choice: a frozen
absolute-stamped picture would sit a whole uptime right of a newest-relative
axis. That also fixed a latent bug, the export adding `trigger_time()` to a
*paused* view's stamps.

The status bar says how long you have been paused -- wall time, since the
producer never stops, through `format_elapsed()` so the field cannot widen.

The export writes two time columns: `time`, the axis as displayed, and
`abs_seconds`, the stream's own clock for the same sample. f64 seconds and
not the wire's integer, because `udps_frame_t::values` is a `const double*`
and the integer never exists in this process.

Cursors and measurement marks move in opposite directions under the rolling
axis, deliberately: a cursor is a position on screen and stays put, a mark
names a sample and travels with it. `App::measure_window` is stream-clocked
for a sharper reason -- a display-coordinate live window never moves, so the
"the window moved" recompute would never fire again.

Moves the deps/plotter pin to 58473db, which stops a time label's mantissa
reaching its own radix: a one-second tick differenced off a live axis read
`1e+3ms`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 21:49:12 +02:00
Martino FerrariandClaude Opus 4.6 2f4530c128 fix: a zoomed trace is drawn on the x its readout quotes
gather() staged x as f32 relative to the series' own x0() -- the start of
the whole retained window -- so the quantisation was a fraction of the
ring rather than of the view: 1.9 us at 20 s of retained data, which at a
10 us view across a 716 px axis is over 100 px between where a sample is
drawn and the x a readout quotes for it (crosshair_point() reads x_at()
in exact f64). It halved on every zoom out, which is what made it read as
"zooming in makes the crosshair select a point beside the data".

The origin is now the caller's (plotter c2c8c59): the renderer passes the
visible range's minimum, so the error is a fixed 2^-24 of what is on
screen -- about 4e-5 px over a 700 px axis -- at any zoom. The parameter
sits before out_xy so every call site is a compile error rather than a
silent change of behaviour.

The renderer no longer reads series.x0() at paint time at all, which
removes two of the three symptoms of the torn window_x0_ read by
construction; the ink probe's now-vacuous kept_fixed_x0 walk goes with
them, replaced by a drawn-vs-quoted pixel figure. neighbours() still
tests the visible range against x_at(0), so the cache stays load-bearing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 18:49:54 +02:00
Martino FerrariandClaude Opus 4.6 39f30d913f fix: the t0 caption is chrome anchored to the axis, not an annotation in the data
The caption that names the relative x labels' anchor was an `Annotation`, whose anchor
is a data coordinate -- so it crawled across the plot under a pan and drifted under a
zoom, which is how it was reported. It is now `Plot::set_x_note()`: the bottom row's
caption, drawn by the renderer in the axes' bottom-left corner in pixels, on the
legend's own backplate.

The text stays the host's, because only the host knows that label + caption has to be
the exact absolute time (`%.6g`, where `format_time()` would round to three figures).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 18:21:28 +02:00
Martino FerrariandClaude Opus 4.6 e2985aa159 fix: the x labels go relative on what a label can write, not on the span
A `t0 = ...` caption appeared on every sub-second window, including a
triggered one whose axis is already relative to the edge -- so the ticks
were re-anchored away from the one position a triggered axis exists to
show, and the caption had nothing to explain.

The form is now decided by the tick step against `TickFormat::Time`'s own
resolution at the axis' magnitude (three significant figures below a
minute, whole seconds through `m:ss` to a day, three figures of days
above). The span said nothing about that and was wrong both ways: a 2 s
window at twenty minutes of uptime reads `20:00` on every tick and stayed
absolute.

The step is read back from the last layout's majors (a tick value depends
on the range and the axis width, not on the label form); with no ticks yet
the form is left alone rather than guessed, since the hysteresis would
latch a guess. Entering the relative form carries a ULP of slack, because
`step == res` is readable and a bare comparison of two differently
computed doubles flipped the form between frames.

`[timelabels-probe]` and the dump gain `distinct=`, which asserts that no
two major labels read the same -- the thing the relative form is for, and
the only reading that holds whatever the stream's uptime is.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 18:01:41 +02:00
Martino FerrariandClaude Opus 4.6 b505706d92 fix: a readout keeps its metric prefix when the stream declares no unit
The SI prefix ladder in format_measure() was gated on a non-empty unit, so two
decimals of a small unitless value printed `0.00` -- the reading gone, not merely
coarse. That is a whole class of channels rather than a corner: a CONFIG need not
declare a unit, and a math channel never has one. Below 1e-12 the same loss
happened even with a unit, so that range falls back to three significant digits.

With no unit the prefix goes tight against the number (`585.24m`), since the space
exists to separate the number from its unit and a spaced `m` alone reads as metres.

The mock declared `unit="V"` for every data signal, which is why no probe could see
this; it now takes `--unit ''`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 17:28:51 +02:00