From 8f54841c2c78ac4ebb4556a7d3d113c496a8874f Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 10 Oct 2026 22:52:12 +0200 Subject: [PATCH] working on todo.md features --- CMakeLists.txt | 13 + README.md | 234 +- deps/udpsclient/udps_client.c | 19 +- deps/udpsclient/udps_client.h | 20 + docs/USER-GUIDE.md | 6 + docs/claude/build-and-run.md | 14 +- docs/claude/invariants-data-path.md | 78 + docs/claude/invariants-gtk-and-chrome.md | 57 +- docs/claude/invariants-math-channels.md | 16 + docs/claude/invariants-session-and-export.md | 34 + docs/claude/invariants-trigger-and-pinning.md | 131 + docs/claude/probe-readings.md | 180 +- docs/claude/verification.md | 153 + .../2026-10-09-udp-packet-capture-design.md | 328 + src/acquisition.cpp | 100 +- src/acquisition.hpp | 31 + src/capture.cpp | 238 + src/capture.hpp | 124 + src/config.cpp | 12 + src/config.hpp | 14 + src/event_store.cpp | 329 + src/event_store.hpp | 82 + src/export.cpp | 15 + src/export.hpp | 6 + src/help/02-source.txt | 15 +- src/help/03-signals.txt | 3 + src/help/04-layout.txt | 4 + src/help/08-trigger.txt | 21 + src/help/13-sessions.txt | 56 +- .../actions/udpscope-events-symbolic.svg | 17 + src/icons/udpscope.gresource.xml | 1 + src/layout_store.cpp | 267 +- src/layout_store.hpp | 60 +- src/main.cpp | 47 +- src/math_channel.cpp | 7 + src/model.cpp | 60 + src/model.hpp | 13 + src/plot_host.cpp | 22 +- src/plot_host.hpp | 21 +- src/source_dialog.cpp | 442 +- src/source_dialog.hpp | 10 + src/ui.cpp | 7207 ++--------------- src/ui_app.hpp | 1302 +++ src/ui_cursors.cpp | 148 + src/ui_events.cpp | 814 ++ src/ui_layout.cpp | 435 + src/ui_math.cpp | 1063 +++ src/ui_measure.cpp | 598 ++ src/ui_probe.cpp | 3113 +++++++ src/ui_signals.cpp | 431 + src/ui_status.cpp | 187 + src/ui_trigger.cpp | 432 + src/ui_widgets.cpp | 166 + src/waveform.cpp | 353 + src/waveform.hpp | 84 + todo.md | 55 +- tools/build_info.cpp | 11 + tools/capture_to_pcap.py | 135 + .../CMakeDirectoryInformation.cmake | 16 + .../CMakeFiles/plot_core.dir/DependInfo.cmake | 47 + .../CMakeFiles/plot_core.dir/build.make | 498 ++ .../plot_core.dir/cmake_clean.cmake | 59 + .../plot_core.dir/cmake_clean_target.cmake | 3 + .../plot_core.dir/compiler_depend.make | 2 + .../plot_core.dir/compiler_depend.ts | 2 + .../CMakeFiles/plot_core.dir/depend.make | 2 + .../CMakeFiles/plot_core.dir/flags.make | 10 + .../plotter/CMakeFiles/plot_core.dir/link.txt | 2 + .../CMakeFiles/plot_core.dir/progress.make | 27 + tools/deps/plotter/CMakeFiles/progress.marks | 1 + tools/deps/plotter/Makefile | 832 ++ .../CMakeDirectoryInformation.cmake | 16 + .../CMakeFiles/plot_gtk4.dir/DependInfo.cmake | 23 + .../gtk4/CMakeFiles/plot_gtk4.dir/build.make | 114 + .../plot_gtk4.dir/cmake_clean.cmake | 11 + .../plot_gtk4.dir/cmake_clean_target.cmake | 3 + .../plot_gtk4.dir/compiler_depend.make | 2 + .../plot_gtk4.dir/compiler_depend.ts | 2 + .../gtk4/CMakeFiles/plot_gtk4.dir/depend.make | 2 + .../gtk4/CMakeFiles/plot_gtk4.dir/flags.make | 10 + .../gtk4/CMakeFiles/plot_gtk4.dir/link.txt | 2 + .../CMakeFiles/plot_gtk4.dir/progress.make | 3 + .../adapters/gtk4/CMakeFiles/progress.marks | 1 + tools/deps/plotter/adapters/gtk4/Makefile | 184 + .../plotter/adapters/gtk4/cmake_install.cmake | 50 + tools/deps/plotter/cmake_install.cmake | 60 + tools/deps/plotter/include/plot/version.hpp | 28 + .../CMakeDirectoryInformation.cmake | 16 + .../plot_font_ttf.dir/DependInfo.cmake | 23 + .../CMakeFiles/plot_font_ttf.dir/build.make | 116 + .../plot_font_ttf.dir/cmake_clean.cmake | 11 + .../cmake_clean_target.cmake | 3 + .../plot_font_ttf.dir/compiler_depend.make | 2 + .../plot_font_ttf.dir/compiler_depend.ts | 2 + .../CMakeFiles/plot_font_ttf.dir/depend.make | 2 + .../CMakeFiles/plot_font_ttf.dir/flags.make | 10 + .../CMakeFiles/plot_font_ttf.dir/link.txt | 2 + .../plot_font_ttf.dir/progress.make | 3 + .../plot_stb_truetype.dir/DependInfo.cmake | 23 + .../plot_stb_truetype.dir/build.make | 104 + .../plot_stb_truetype.dir/cmake_clean.cmake | 9 + .../compiler_depend.make | 2 + .../plot_stb_truetype.dir/compiler_depend.ts | 2 + .../plot_stb_truetype.dir/depend.make | 2 + .../plot_stb_truetype.dir/flags.make | 10 + .../plot_stb_truetype.dir/progress.make | 2 + .../src/text/CMakeFiles/progress.marks | 1 + tools/deps/plotter/src/text/Makefile | 226 + .../deps/plotter/src/text/cmake_install.cmake | 50 + tools/mock_udpstreamer.py | 41 + 110 files changed, 15932 insertions(+), 6576 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-09-udp-packet-capture-design.md create mode 100644 src/capture.cpp create mode 100644 src/capture.hpp create mode 100644 src/event_store.cpp create mode 100644 src/event_store.hpp create mode 100644 src/icons/scalable/actions/udpscope-events-symbolic.svg create mode 100644 src/ui_app.hpp create mode 100644 src/ui_cursors.cpp create mode 100644 src/ui_events.cpp create mode 100644 src/ui_layout.cpp create mode 100644 src/ui_math.cpp create mode 100644 src/ui_measure.cpp create mode 100644 src/ui_probe.cpp create mode 100644 src/ui_signals.cpp create mode 100644 src/ui_status.cpp create mode 100644 src/ui_trigger.cpp create mode 100644 src/ui_widgets.cpp create mode 100644 src/waveform.cpp create mode 100644 src/waveform.hpp create mode 100644 tools/build_info.cpp create mode 100755 tools/capture_to_pcap.py create mode 100644 tools/deps/plotter/CMakeFiles/CMakeDirectoryInformation.cmake create mode 100644 tools/deps/plotter/CMakeFiles/plot_core.dir/DependInfo.cmake create mode 100644 tools/deps/plotter/CMakeFiles/plot_core.dir/build.make create mode 100644 tools/deps/plotter/CMakeFiles/plot_core.dir/cmake_clean.cmake create mode 100644 tools/deps/plotter/CMakeFiles/plot_core.dir/cmake_clean_target.cmake create mode 100644 tools/deps/plotter/CMakeFiles/plot_core.dir/compiler_depend.make create mode 100644 tools/deps/plotter/CMakeFiles/plot_core.dir/compiler_depend.ts create mode 100644 tools/deps/plotter/CMakeFiles/plot_core.dir/depend.make create mode 100644 tools/deps/plotter/CMakeFiles/plot_core.dir/flags.make create mode 100644 tools/deps/plotter/CMakeFiles/plot_core.dir/link.txt create mode 100644 tools/deps/plotter/CMakeFiles/plot_core.dir/progress.make create mode 100644 tools/deps/plotter/CMakeFiles/progress.marks create mode 100644 tools/deps/plotter/Makefile create mode 100644 tools/deps/plotter/adapters/gtk4/CMakeFiles/CMakeDirectoryInformation.cmake create mode 100644 tools/deps/plotter/adapters/gtk4/CMakeFiles/plot_gtk4.dir/DependInfo.cmake create mode 100644 tools/deps/plotter/adapters/gtk4/CMakeFiles/plot_gtk4.dir/build.make create mode 100644 tools/deps/plotter/adapters/gtk4/CMakeFiles/plot_gtk4.dir/cmake_clean.cmake create mode 100644 tools/deps/plotter/adapters/gtk4/CMakeFiles/plot_gtk4.dir/cmake_clean_target.cmake create mode 100644 tools/deps/plotter/adapters/gtk4/CMakeFiles/plot_gtk4.dir/compiler_depend.make create mode 100644 tools/deps/plotter/adapters/gtk4/CMakeFiles/plot_gtk4.dir/compiler_depend.ts create mode 100644 tools/deps/plotter/adapters/gtk4/CMakeFiles/plot_gtk4.dir/depend.make create mode 100644 tools/deps/plotter/adapters/gtk4/CMakeFiles/plot_gtk4.dir/flags.make create mode 100644 tools/deps/plotter/adapters/gtk4/CMakeFiles/plot_gtk4.dir/link.txt create mode 100644 tools/deps/plotter/adapters/gtk4/CMakeFiles/plot_gtk4.dir/progress.make create mode 100644 tools/deps/plotter/adapters/gtk4/CMakeFiles/progress.marks create mode 100644 tools/deps/plotter/adapters/gtk4/Makefile create mode 100644 tools/deps/plotter/adapters/gtk4/cmake_install.cmake create mode 100644 tools/deps/plotter/cmake_install.cmake create mode 100644 tools/deps/plotter/include/plot/version.hpp create mode 100644 tools/deps/plotter/src/text/CMakeFiles/CMakeDirectoryInformation.cmake create mode 100644 tools/deps/plotter/src/text/CMakeFiles/plot_font_ttf.dir/DependInfo.cmake create mode 100644 tools/deps/plotter/src/text/CMakeFiles/plot_font_ttf.dir/build.make create mode 100644 tools/deps/plotter/src/text/CMakeFiles/plot_font_ttf.dir/cmake_clean.cmake create mode 100644 tools/deps/plotter/src/text/CMakeFiles/plot_font_ttf.dir/cmake_clean_target.cmake create mode 100644 tools/deps/plotter/src/text/CMakeFiles/plot_font_ttf.dir/compiler_depend.make create mode 100644 tools/deps/plotter/src/text/CMakeFiles/plot_font_ttf.dir/compiler_depend.ts create mode 100644 tools/deps/plotter/src/text/CMakeFiles/plot_font_ttf.dir/depend.make create mode 100644 tools/deps/plotter/src/text/CMakeFiles/plot_font_ttf.dir/flags.make create mode 100644 tools/deps/plotter/src/text/CMakeFiles/plot_font_ttf.dir/link.txt create mode 100644 tools/deps/plotter/src/text/CMakeFiles/plot_font_ttf.dir/progress.make create mode 100644 tools/deps/plotter/src/text/CMakeFiles/plot_stb_truetype.dir/DependInfo.cmake create mode 100644 tools/deps/plotter/src/text/CMakeFiles/plot_stb_truetype.dir/build.make create mode 100644 tools/deps/plotter/src/text/CMakeFiles/plot_stb_truetype.dir/cmake_clean.cmake create mode 100644 tools/deps/plotter/src/text/CMakeFiles/plot_stb_truetype.dir/compiler_depend.make create mode 100644 tools/deps/plotter/src/text/CMakeFiles/plot_stb_truetype.dir/compiler_depend.ts create mode 100644 tools/deps/plotter/src/text/CMakeFiles/plot_stb_truetype.dir/depend.make create mode 100644 tools/deps/plotter/src/text/CMakeFiles/plot_stb_truetype.dir/flags.make create mode 100644 tools/deps/plotter/src/text/CMakeFiles/plot_stb_truetype.dir/progress.make create mode 100644 tools/deps/plotter/src/text/CMakeFiles/progress.marks create mode 100644 tools/deps/plotter/src/text/Makefile create mode 100644 tools/deps/plotter/src/text/cmake_install.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 6c8dcf8..6bd6830 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -107,6 +107,9 @@ add_executable(udpscope src/model.cpp src/condition.cpp src/trigger.cpp + src/capture.cpp + src/waveform.cpp + src/event_store.cpp src/acquisition.cpp src/plot_host.cpp src/export.cpp @@ -119,6 +122,16 @@ add_executable(udpscope src/shortcuts.cpp src/help_window.cpp src/ui.cpp + src/ui_probe.cpp + src/ui_widgets.cpp + src/ui_events.cpp + src/ui_status.cpp + src/ui_measure.cpp + src/ui_cursors.cpp + src/ui_math.cpp + src/ui_layout.cpp + src/ui_trigger.cpp + src/ui_signals.cpp ) target_include_directories(udpscope PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) diff --git a/README.md b/README.md index f1aa36a..023409f 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (size of the sample buffer, default 200), `--max-window` (largest time window the toolbar offers, default 600), `--holdoff` (default trigger hold-off), `--recv-buffer`, `--plots` (the number of stacked plots to start with, 1..8; rows are -added and removed from the **Layout editor** afterwards), `--relative-time` (see below). +added and removed from the **Layout editor** afterwards), `--relative-time` (see below), +`--capture-packets N` (see "Packet capture" below). Every CONFIG is dumped to stderr once -- publish mode and, per signal, its name, element count, rate, time mode and time-signal index -- because a signal that @@ -407,11 +408,201 @@ floored to a **power of two** (`src/model.cpp:83`, `:110`), so a third more weig third of the span; it costs a whole halving. The measured figures above are the correct ones; a per-byte computation gives the wrong answer for this reason. +## Packet capture + +**`--capture-packets N`** stores the last N raw datagrams on disk for forensic +analysis — the bytes as `recv()` returned them, before the client validates the +magic, reassembles fragments or resequences counters. It is a debugging aid for +*wire* behaviours (a counter that skips, a datagram that arrives twice, a fragment +that never completes, a CONFIG whose header does not parse), not a feature of the +instrument: it is off unless the flag is given, and it adds nothing to the status +bar's glance tier. + +The ring lives at `~/.cache/udpscope/-/capture.bin`, the previous +capture is kept one deep as `capture-prev.bin`, and it can be toggled at any time +from the status bar's detail popover (the **Capture packets** button, insensitive +until connected). At the default 1000 slots the file is **~62.6 MiB**, reserved up +front, which is why the size is stated in the tooltip and in the popover row. + +Three things it deliberately is not, and the reasons are in +`docs/superpowers/specs/2026-09-29`'s sibling design note: + +- **It cannot cost a packet.** The capture is an `mmap` and a `memcpy` on the + producer thread between two `recv()` calls; there is no queue, no lock and no + second copy. The one exception is the toggle itself: `open()` — a rename, a + create, a `posix_fallocate` and an `mmap` — runs on the producer thread when you + click, and measured ~1–3 ms here. The popover row shows the measured `open` cost + in the probe line; a reading far above that is a filesystem finding. +- **It is not interpreted.** `tools/capture_to_pcap.py` reads the file back and + writes a classic pcap, so wireshark does the interpreting: + + ```sh + tools/capture_to_pcap.py ~/.cache/udpscope/127.0.0.1-8000/capture.bin out.pcap + # 1000 slots, 16 stored, 0 torn, 0 truncated, 0 empty -> 16 packets, 1.0 MiB, ... + ``` + + The counts it prints are a diagnostic in their own right: `torn` above zero means + the capture died mid-copy (a crash is exactly what the on-disk ring exists to + survive), and `truncated` means a datagram longer than the client's receive + buffer, which should be impossible. +- **It does not see the multicast CONFIG channel.** In multicast mode CONFIG + arrives over TCP, and the capture is of the UDP socket only. A malformed + *multicast* CONFIG is out of reach. + +## Captured events + +Trigger mode keeps the last **32 triggered acquisitions** in a cache, so an event you did not have +time to look at is still there: + +``` +~/.cache/udpscope/192.168.1.40-8000/events/ + 20261009-123250-0001.npy # the event, in the export format + 20261009-123250-0001.txt # when, how long, how many signals, the rate, the edge + 20261009-123250-0001.bmp # a picture of it, drawn from the samples +``` + +The **left pane** is opened on demand from the toolbar's event-history button, is collapsed by +default, and its **divider can be dragged**: it starts at 240 pixels and remembers the width you last +chose, and while it is closed it takes no room at all. Both the button and the pane exist while **recording is on**, or while an event is on +screen: with the cache disabled and nothing being examined there is nothing to show, and a visible +empty column beside the plot is what *off* must not look like. Recording is switched in the **trigger +panel**, with the trigger's other options rather than in the pane whose contents it decides, and it is +**off by default** -- writing every latch to disk is the user's decision about their disk. + +Each row carries **its own picture** beside the numbers -- time, duration, signal count, rate -- in a +fixed 126x42 cell (the preview's own 3:1), so the picture is the same size in every row whatever the +pane's width. Clicking a row **replays** it: the same loader `--open` uses, on the event's file. **The layout does +not change** -- the rows, which signal sits in which slot, the colours, the row count, the time window, +the cursors and the measurements all stay exactly where they are, and now read the stored event instead +of the stream; that is what "show me that event" asks for, and rebinding the rows to the file would +rearrange the user's work under a click. What changes is the data, and the trigger is left, because a +file has no edge to wait for. Cursors, measurements, math channels and another export all work on a +stored event. **Live** takes you back to the stream. + +Two buttons under the pane's header read the **selection**: + +- *Compare* puts the two selected events in **two plots side by side**, each with **the layout you + already have** -- the same rows, the same signals in the same slots, the same colours -- one showing + one event and one the other. Their axes are **synchronised**: zoom or pan a row in either plot and the + same row of the other follows. +- *Accumulate* overlays up to four selected events in the **same traces**, translucent, so where they + agree the trace is brighter. The events' copies take the free slots of the row that already holds + their signal (a row holds eight traces), so your own traces never move. + +Both need events with the same signals and the same length, which is what makes a shared axis honest; +the build checks it and says so in the status bar when it does not hold. + +A group is a file view like a replay: the acquisition is stopped (a live producer would push into a +model whose channel count no longer matches the stream), the trigger is left, and *Live* returns. + +A replay leaves trigger mode, because a file has no edge to wait for -- an armed trigger keeps a +*frozen capture* on screen (`display_mode()` stays `Held` while the model holds a snapshot), and a +held display draws that snapshot rather than the file that was just loaded. The events pane stays +open through it: it is available while a stored event is on screen. + +Three things worth knowing: + +- **The samples are the export format**, so an event is readable by everything that reads an + export: `--open`, the chooser's Waveforms page, and `numpy`/`pandas`. +- **The picture is drawn from the data**, one strip per signal with min/max per pixel column and the + trigger edge as a brighter line -- not captured from the window: the plot is a `GtkGLArea`, and a + GL area's ink is not in the widget's paint tree, so a snapshot of it is empty. A data-drawn + preview is also the honest one: it shows the samples, not the zoom the window happened to be at. +- **The picture is decoded synchronously** (`gdk_texture_new_from_filename()` into + `gtk_picture_set_paintable()`), not through `gtk_picture_set_filename()`: that path uses the image + loader's sandboxed, asynchronous decoder, which here fails silently -- the picture keeps a + paint-able and an allocation, so a probe reports success while the pane shows an empty rectangle. + It is then **scaled once to the row's size** and cached by file: a `GtkPicture`'s natural size is + its content, so the stored 480-pixel preview given straight to a row claims the row's whole width + and squeezes the text out of it -- a size request on the picture is a floor, not a cap. +- **A replay stops the acquisition** (a live producer would overwrite the loaded samples within a + frame), so the status bar names the file until *Live* starts a fresh client and thread. The + replayed samples age out of the ring as the stream refills it. +- The switch in the pane's header stops the recording; the cache is only useful if it was already + on when the event you wanted happened, which is why it defaults to on. + +## Opening an exported waveform + +An export can be opened again — to look at a run you no longer have the source of, and to analyse it +with everything the scope offers: + +```sh +udpscope --open ~/udpscope-20261009-113200.npy +``` + +or start udpscope with no `--server` and use the chooser's **Waveforms** page, which lists the files +opened before (newest first, with the ones that have since been deleted marked *missing* rather than +left to fail on the click) and has an **Open…** button for a new one. + +A waveform is a *source that never arrives*, and the design follows from that: there is no socket, no +acquisition thread, and no starvation notice — the status bar's rate is the file's own stored rate and +its Stream row names the file. Everything else is the same machinery a live run uses: one signal and +channel per column, **one row per column** (so a four-signal file opens as four stacked traces, with +three free slots on every row to drag more onto), the samples pushed into the same ring, the display +epoch pinned to the file's last stamp so the axis reads `[-window, 0]` exactly as the export did — which means cursors, measurements, +math channels, the trigger's search and another export all work on it unchanged. The signal names come +from the file: a `.npy` written by `write_npy()` is a structured array whose *dtype* names its columns +(`time`, `abs_seconds`, then one field per signal), so there is no sidecar to lose. + +Only `.npy` is read: it is the format the item asked for and the one that carries names. A `.csv` +export is for other tools. + +## Named configurations + +What you arranged is remembered per data source, and a source can hold **several named +configurations** — different experiments on the same streamer want different plots, different +measurements, and sometimes a different buffer. Each is one file under the source's own +directory: + +``` +~/.config/udpscope/192.168.1.40_8000/ + active # the configuration this source is on, one line + bench.toml # a configuration, plus its note + emi-test.toml +``` + +A configuration is the whole session: the window, the rows and their signals, the calibration, +the trace colours, which signals were enabled, the math channels with their graphs, the +measurements — **and the settings that can only be decided at startup**, which is why they live +here rather than only on the command line: + +```toml +comment = "5 kHz sine, trigger on Ch1, 8 MB is plenty" +buffer_mb = 8 +relative_time = false +capture_packets = 0 +``` + +The **buffer size** is the one that matters most: two sources on one machine rarely want the same +ring, and the value is applied before the model exists, which is the only moment it can be — by +whichever of the chooser's rows opened the session, **and by `apply_stored_settings()` when the +source was named on the command line**. A saved host's settings are read either way: opening +`udpscope --server 10.0.0.5 --port 8000` gets that source's stored buffer size, clock and capture +ring, and a flag the user actually typed wins over the file (`--buffer-mb`, `--relative-time`, +`--capture-packets`) because it is the more recent statement of intent. Switching configurations *inside* a running +app applies everything that can change live (layout, plots, math, measurements, enables) and says +so in the status line about the rest (buffer, clock, packet capture): they take effect on the next +start. + +The **chooser** (start with no `--server`) lists one row per configuration, with its note and its +settings, so two configurations of one source are told apart before they are opened. Its **New +host…** form takes the address *and* those settings — the configuration's name and note, the buffer +size, and relative time — so a host can be set up before it has ever streamed; the same form opens +from a row's pencil button to **edit** an existing configuration, pre-filled from its file, and +editing the name or the address is a *move* (the old file is deleted, so the list never shows the +same session twice). Connector settings are stored even for a configuration with no signals yet, +which is why those rows exist at all. The app's +menu has **Configurations…**: the same list for the running source, with *Save this session* — +a name and a note — and a delete button per row. Opening one saves the current session first, so +switching is never the gesture that loses what it switches from. + +A session written before configurations existed is imported on first use as the configuration +named `default`: copied into the directory, remembered as active, and the old file renamed to +`_.toml.imported` rather than deleted, so the import can be undone by hand. + ## The session layout file -What you arranged is remembered per data source, in -`~/.config/udpscope/_.toml` (`$XDG_CONFIG_HOME` is honoured; the -host's unsafe characters are replaced with `_`). The file holds the display +A configuration's file holds the display window, the row layout and which signal each of a row's slots plots, and the per-signal gain and offset: @@ -861,6 +1052,15 @@ another math channel can be its source. The operations are `channel` (a source), stored rate), `sum`, `difference`, `product` and `ratio`; the arithmetic ops take two inputs, which is what makes branching — the sum of a raw and a filtered channel — expressible at all. +**A gap is a gap in a filtered trace, never the end of it.** A hole the resequencer reserved is +NaN in the source, and the filters deliberately do not integrate it: the hole shows as a hole in +the filtered trace, the filter's history stays the last real samples it saw, and the trace picks +up again after it. (Integrating it poisoned the biquad's state for ever — every later sample +NaN — which is what a dropped packet used to do to every low pass, systematically in relative +time, where a counter gap reserves NaN slots by construction.) The exit dump's +`math '': finite=N/M` line is where that reads: `finite` must be about `M` minus the hole +share, and `finite=0` beside a non-zero `hole_slots` is the defect back. + The editor is a compact node graph: each node is a box, its inputs are the squares on its left edge and its output the square on its right. Click a box to select it and its parameters appear in the form beside the canvas; **drag from an output square to an input square to wire them** (the old @@ -1389,10 +1589,17 @@ signal if needed, binds it to that row's first free trace slot, and rebuilds the figure so the legend name is right (there is one widget, so the row a drop lands on is `PlotArea::row_at()`). The status line reports the outcome of a drop (`plotting 'sine' on plot 1`, `plot 1 already holds four signals`), so an action -that changes nothing still says why. Each plot's y axis has a right-click menu -with auto scale, a fixed range, **Fit X axis**, **Fit all**, and one -**Remove '' from plot N** entry per occupied trace slot (a plot with nothing on -it says so instead). The two fits are the same pair the overflow menu and the `f` key +that changes nothing still says why. Each plot has a right-click menu in the same +compact row style as the app's own menu — the label at one left column, a shortcut flush right, +18 px rows — holding **Y axis: auto scale**, **Y axis: fixed range...**, **Fit X axis**, **Fit +all** (the `f` key's action, so it shows `F`), and **Remove signal**, which swaps the menu to a +second view — one entry per trace on that plot, plus **All traces on this plot** — with a **Back** +row to return (a plot with nothing on it says so instead). The removals are a view of their own +rather than a flat list because as a flat list they were the same long sentence repeated once per +trace, which is most of the menu spent on one verb; and they *replace* the menu instead of floating +beside it as a submenu, because a second popover is a second thing to dismiss and a `GtkMenuButton` +that owns one toggles itself out of sync as soon as anything else closes it — its row stopped +opening at all on the second click. The two fits are the same pair the overflow menu and the `f` key offer, split by what they touch: **Fit X axis** puts the x view back on the whole time window and leaves every y scale exactly as the user set it, **Fit all** does that and auto-scales the y axes too. Neither changes the time window itself — see below. @@ -1985,7 +2192,7 @@ now — they are listed so they are not mistaken for anything else: progress. What the spec was protecting still holds — a *held* event is bound to its snapshot alone, and the axis is the event's own window from the edge onwards rather than a sliding live view. -- **A plot holds at most four signals** (`RowSet::kSlotsPerRow`), because a `Plot` +- **A plot holds at most eight signals** (`RowSet::kSlotsPerRow`), because a `Plot` cannot give up a trace and the slots are therefore created up front. - **One toggle drives both cursor orientations.** The vertical markers and the horizontal ruler exist separately but share one show/hide and one reset. @@ -2036,6 +2243,15 @@ stall test. Calling that one "connected, but no data is arriving" blamed a secon that does not exist; the usual cause is a streamer that is not running, or one serving a different port. +**A link loss does not cost the acquisition.** When the stream comes back and its CONFIG +names the same signals as before — the common case, because whatever knocked the link out +did not reconfigure the producer — the reconnect keeps the buffer, its history, the +trigger's state and any held event, and the outage appears as the gap in the data that it +was. Only a CONFIG that actually *changes* the stream re-plans the buffer, which is the +one case where dropping history is honest. The mock can reproduce the loss itself: +`tools/mock_udpstreamer.py --blackout-at 5 --blackout 3.5` goes silent past the client's +silence timeout and then resumes, which is the EMI case end to end. + ## Limitations - **Multi-element (vector/matrix) signals are acquired with a zero-order hold.** diff --git a/deps/udpsclient/udps_client.c b/deps/udpsclient/udps_client.c index 5f8ceef..e6825a2 100644 --- a/deps/udpsclient/udps_client.c +++ b/deps/udpsclient/udps_client.c @@ -48,7 +48,7 @@ #define UDPS_FRAG_MASK_BYTES (UDPS_MAX_FRAGMENTS / 8) /** Largest datagram we can receive, header included. */ -#define UDPS_RX_BUF_BYTES (65535 + 17) +#define UDPS_RX_BUF_BYTES UDPS_MAX_DATAGRAM_BYTES /** An incomplete reassembly older than this is abandoned. */ #define UDPS_SLOT_STALE_S 2.0 @@ -209,6 +209,9 @@ struct udps_client { udps_stats_t stats; char err[256]; + udps_datagram_cb datagram_cb; /**< NULL until set; fires inside poll(). */ + void *datagram_user; + udps_config_cb on_config; udps_data_cb on_data; udps_event_cb on_event; @@ -1245,6 +1248,15 @@ static void copy_str(char *dst, size_t cap, const char *src) { dst[cap - 1u] = '\0'; } +void udps_client_set_datagram_cb(udps_client_t *client, + udps_datagram_cb cb, void *user) { + if (client == NULL) { + return; + } + client->datagram_cb = cb; + client->datagram_user = user; +} + udps_client_t *udps_client_create(const udps_client_config_t *cfg) { udps_client_t *c; @@ -1468,6 +1480,11 @@ int udps_client_poll(udps_client_t *c, int timeout_ms) { } drained++; c->last_data = now_mono(); + /* The raw datagram, before anything reads out of rxbuf: this is the + * only place a malformed or duplicate datagram is still visible. */ + if (c->datagram_cb != NULL) { + c->datagram_cb(c->rxbuf, (size_t)n, now_wall(), c->datagram_user); + } if ((size_t)n >= UDPS_HEADER_SIZE) { handle_packet(c, c->rxbuf, (size_t)n); } diff --git a/deps/udpsclient/udps_client.h b/deps/udpsclient/udps_client.h index bcd861e..49f56ee 100644 --- a/deps/udpsclient/udps_client.h +++ b/deps/udpsclient/udps_client.h @@ -237,6 +237,20 @@ typedef void (*udps_data_cb)(const udps_frame_t *frame, void *user); /** Called on connection state changes and on recoverable errors. */ typedef void (*udps_event_cb)(udps_event_t event, const char *detail, void *user); +/** + * Largest datagram recv() can deliver on the client's socket, and therefore the + * most a raw-datagram observer ever sees in one call. + */ +#define UDPS_MAX_DATAGRAM_BYTES (65535 + 17) + +/** Called for every datagram as recv() returned it, before the magic check, + * reassembly or resequencing: the bytes a capture has to store, and the only + * place malformed or duplicate datagrams are still visible. Runs on the polling + * thread, inside udps_client_poll(), like every other callback. `bytes` is + * valid until the callback returns. */ +typedef void (*udps_datagram_cb)(const uint8_t *bytes, size_t len, + double recv_time, void *user); + /** * @brief Transport configuration. * @@ -286,6 +300,12 @@ void udps_client_set_callbacks(udps_client_t *client, udps_event_cb on_event, void *user); +/** Installs the raw-datagram observer. Separate from + * udps_client_set_callbacks(), whose signature has call sites and which a + * capture hook is no reason to change. May be NULL. */ +void udps_client_set_datagram_cb(udps_client_t *client, + udps_datagram_cb cb, void *user); + /** * @brief Drives the client: connects if needed, then waits for and processes * packets for at most @p timeout_ms milliseconds. diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 389bb15..81451dd 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -65,6 +65,12 @@ window change moves the trigger with it. - **Ctrl+drag** frames x to a box, **Alt+drag** y. - **Fit to data** (menu) auto-scales both axes and frames the whole buffered range. +Each plot has its own **right-click menu**, in the same compact row style as the app's menu: +**Y axis: auto scale**, **Y axis: fixed range...**, **Fit X axis**, **Fit all** (`f`), and +**Remove signal** — which swaps the menu to one entry per trace on that plot plus *All traces on +this plot*, with a **Back** row to return. That is how a trace comes off a row without going through +the layout editor. + The view is bounded by the **time window**, not by the buffer: zooming out stops at the span you set, and panning stops where the data starts. When the buffer holds *less* than the window — right after a signal is enabled — the window is left empty on its left, which is what a scope that has just started diff --git a/docs/claude/build-and-run.md b/docs/claude/build-and-run.md index a689e96..c69eac7 100644 --- a/docs/claude/build-and-run.md +++ b/docs/claude/build-and-run.md @@ -36,14 +36,24 @@ file's basename is that same string, which is what associates a running window w Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row -count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards). +count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards), +`--capture-packets N` (the raw-datagram ring of README's "Packet capture"; 1..100000, +the value is required, and the popover button uses 1000 when the flag was absent). +The flag is **not** persisted in the session file: persistence of the enable state +belongs with `todo.md` item 3, alongside the relative-time toggle. Run with no `--server` and udpscope asks which source to open: the sessions it has stored, plus a form for a new host. Every probe below passes `--server`, so none of them sees it. `deps/plotter` is built from source with only the GTK4 adapter enabled (`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a -vendored C99 source pair compiled into a static lib. +vendored C99 source pair compiled into a static lib. `deps/udpsclient` gained one +additive entry point for the packet capture — `udps_client_set_datagram_cb()`, firing +inside `udps_client_poll()` immediately after the UDP `recv()` returns, before the +magic check or reassembly — plus the public `UDPS_MAX_DATAGRAM_BYTES` the capture +sizes its slots from. A separate setter, not a fourth parameter on +`udps_client_set_callbacks()`: that signature has call sites, and a capture hook is no +reason to change them. **libadwaita ≥ 1.8 is a system dependency** (`pkg_check_modules(ADWAITA ... libadwaita-1>=1.8)`), for two widgets: the shortcut overview (`AdwShortcutsDialog`) and the About dialog diff --git a/docs/claude/invariants-data-path.md b/docs/claude/invariants-data-path.md index e76436c..23401c9 100644 --- a/docs/claude/invariants-data-path.md +++ b/docs/claude/invariants-data-path.md @@ -174,3 +174,81 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear — the oldest frame or so of samples — but there its observable is a *prefix plateau*, which is exactly what the renderer's increasing-suffix filter was written to absorb, so it is a different symptom and deliberately not fixed here. + +## The packet capture sits at `recv()`, and its file is reserved, not sparse + +Every wire behaviour a forensic capture exists to answer — a counter that skips, a datagram that +arrives twice, a fragment that never completes, a CONFIG whose header does not parse — is +invisible by the time a `udps_frame_t` reaches `Acquisition::on_data`: the client has already +validated, reassembled, resequenced and dropped. The capture therefore hooks +`udps_client_set_datagram_cb()`, which the client fires immediately after the UDP `recv()` +returns and before anything reads out of `rxbuf`, and it stores the bytes without interpreting +them. The capture is of the *UDP socket*: in multicast mode CONFIG arrives over TCP and is out +of reach. + +The ring file is reserved with `posix_fallocate` rather than left sparse because the stores are +`memcpy`s into an `mmap`: a store into a page the filesystem cannot back raises **SIGBUS**, which +kills udpscope outright. An out-of-space condition has to be an error at the moment the user +toggles — reported in the popover row, the button back off — not a crash ten minutes into the +run the capture was switched on for. Within the reserved file, a slot's trailing `seq` word is +published with a release store *after* its bytes are down, so a slot carrying a non-zero `seq` +is complete and no `fsync` is needed for the file to be readable after a crash: the kernel's +dirty pages are the file, and a half-copied slot still reads `seq == 0`. + +## A reconnect with an unchanged CONFIG keeps the buffer + +A disconnect -- EMI makes it a silence timeout, not a polite DISCONNECT -- must not cost the +acquisition. What historically did was the reconnect's CONFIG: `ScopeModel::set_signals()` rebuilt +the store on *every* CONFIG, and a rebuild is what drops history, resets `tick_`, retires the +holes and invalidates a held event. Now the incoming CONFIG is compared against the stream the +model already has -- names, shapes, rates, ranges, time modes, time-signal indices -- and an +**identical CONFIG returns early**: the ring keeps its history, the trigger keeps its state and +its held event, and the outage shows as the gap in the data that it was. Only a CONFIG that +*changes* the stream re-plans. + +The producer's own state is per *connection*, and carrying it across a drop makes recovery read +as failure: `Acquisition::reset_session_state()` (on DISCONNECTED, and again on the next CONFIG) +clears the counter mark, the stream-time anchor and the smoothed arrival interval, so the first +frame of the new session re-anchors instead of reading as late, as a glitch or as an outage. The +arrival clock's *anchor* deliberately stays -- stamps are small seconds the ring's timeline is +already scaled against, and re-anchoring mid-stream would move x. The clock *choice* is re-derived +per connection (`time_source_ = 0` on CONFIG) for the same reason: a session that dropped may +come back with a different answer. + +Relative time's `slot_base_` also survives: it is samples-since-the-stream-began, the user's +chosen origin, and must not jump backwards because the link blipped. + +## A waveform is a source that never arrives + +`--open FILE` (and the chooser's Waveforms page) puts an exported `.npy` into the model instead of a +stream: one signal and channel per column, the samples pushed through `push_loaded_slot()` — the +producer's own order (values, the shared clock, the absolute clock, then the derived channels), under +the model's lock — and the display epoch pinned to the file's last stamp so the axis reads +`[-window, 0]` exactly as the export did. Everything downstream is unchanged, which is the point: +cursors, measurements, math channels and another export work on a file because they work on the +rings, not on the wire. + +Three things follow, and each is a place the live assumptions would otherwise leak: + +- **Nothing is ever starved.** There is no socket and no source, so the starvation notice is skipped + while a waveform is open, and the popover's Stream row names the file rather than reporting a + connection that has not answered. +- **The loader must not hold the model's mutex.** Every setter it needs (`set_signals`, + `set_signal_enabled`, `set_target_span`) takes that same non-recursive lock; the first version + wrapped the whole load in it and deadlocked on the first call. The per-slot push is a model method + for the same reason. +- **The signal names come from the file.** A `.npy` written by `write_npy()` is a structured array + whose dtype names its fields, so an export is self-describing and there is no sidecar to lose. + `.csv` is not read: it carries the names too, but as text, and the item asked for the binary one. +- **A waveform has no source, and the app must not invent one.** The address the command line + *defaults* to (`127.0.0.1:8000`) is not one: leaving it in place made a file-mode run load — and + then *save* — that source's session, which overwrote the arrangement the file was opened with + (every drop then refused, the row full) and could clobber a real session. `App::source_port` is 0 + in file mode, which is what makes `save_layout_now()` refuse and the first-CONFIG layout load skip + — the same two things that make a source-less run safe. +- **One row per column.** Four columns to one row fills the default single row and then refuses + every drop ("plot 1 already holds four signals"), which is a loaded file the user cannot plot + anything *else* on or rearrange. One per row (sized in `main.cpp`, before the UI exists, as + `--plots` does) leaves three free slots each, shows a four-signal file as four stacked traces, and + keeps the rest of the columns draggable. Sizing the rows from inside the activation instead leaves + the panels built for the old count and the app hangs on the first relayout. diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index 67038e0..a95039a 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -42,12 +42,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear runs after the window is destroyed and asking GTK about a widget then is four criticals, which is how the first version of that line earned them. What stays in the menu is what acts on the *data* (Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text. -- **Six of this app's icons are its own, and they are a `GResource` compiled into the +- **Seven of this app's icons are its own, and they are a `GResource` compiled into the binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a measurement ruler, and the stock names those buttons used to carry said something else entirely: a *record dot* for the trigger and three different **magnifiers** for cursors, - measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math}-symbolic.svg` - are the four action icons; beside them in `src/icons/scalable/apps/` are the two + measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math,events}-symbolic.svg` + are the five action icons; beside them in `src/icons/scalable/apps/` are the two **application** icons, `org.udpscope.app.svg` (128px colour artwork) and `org.udpscope.app-symbolic.svg` (16px). The `apps` context is where an icon theme keeps an application's own icon and is where the **in-process** window icon resolves from; it is also @@ -156,6 +156,57 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height at all inside a box, so without the request it is allocated nothing and the page renders its text with a silent gap where its screenshots should be — which is what the first version did. +- **A `GtkPaned` hands its start child at least its minimum unless the position says otherwise.** + Putting the events pane in a paned is what makes its width the user's to drag -- but with the + position left alone, `shrink-start-child` or not, the pane's minimum is reserved as an empty column + while it is *closed*, and the plot pays for a pane nobody opened. The position is the only knob that + says "no room": store the width when it is closed (reading `gtk_paned_get_position()` *before* + zeroing it) and set 0, restoring it when the pane opens. `gtk_paned_set_shrink_start_child(TRUE)` is + still needed, or the minimum would clamp even a zero position. +- **An unrevealed `GtkRevealer` measures 0x0 and is still given the leftover width.** `reveal_child + = FALSE` makes both the revealer's minimum and its natural size zero -- and its box then hands it + everything the expanding sibling beside it did not claim, so a *collapsed* pane reserved a + 310-pixel empty column between the window edge and a plot drawn 630 pixels wide in a 1270-pixel + window. Every vertical revealer in the side panel hides this, because there the leftover goes down + instead of across. `gtk_widget_set_visible()` is what a layout honours, so anything that must not + take room has to be hidden and not merely unrevealed; the revealer keeps the slide while the pane + *is* open. The allocated width is how this was found -- the revealer's own measure said 0. +- **A list box outlives the children of the pane it sits in, and the teardown order is not yours to + choose.** Tearing the window down finalizes the events pane's buttons, then the list box, and the + list's own `selected-rows-changed` then reaches a handler holding the freed pointers: four + `Gtk-CRITICAL`s from `gtk_widget_set_sensitive` and a read of memory nobody owns. `close-request` is + *not* the hook -- `UDPSCOPE_EXIT_AFTER_MS` ends the self-check with `gtk_window_destroy()`, which does + not emit it. The hook that does work is `App::window`: every teardown path clears it *before* the + widgets go (the exit probe says so in a comment: "stop the tick before the widgets go"), so a handler + that can fire during teardown checks that first, rather than asking GTK whether a pointer is still a + widget. +- **A `GtkPicture`'s natural size *is* its content, so a size request on it is a floor, not a cap.** + A 480x160 texture in a row of a list claims the row's whole width and squeezes the text beside it + out of the row -- the box gives a non-expanding child its natural size and grows it further when + the expanding child has nothing left to take. Give the picture a **frame** whose size request equals + the **scaled texture's** size (natural == minimum, which is what stops the box growing it), and + scale the texture once at decode: it is also what keeps a per-row cache at 12 KB per picture rather + than 300. +- **A pointer to a widget inside a list survives the list being rebuilt.** `events_thumb` named a row's + picture across `refresh_events_pane()`, which removes every row first: the next reader of that + pointer got a `Gtk-CRITICAL` from `gtk_picture_get_paintable` on freed memory (the four criticals + read as "the pane is empty"). A rebuild has to null what it is about to destroy, not only rebuild it. +- **`gtk_picture_set_filename()` can render nothing, silently.** It hands the file to the image + loader's sandboxed, asynchronous decoder, which on this machine fails without a message: the + picture keeps a *paint-able* and takes an allocation, so a probe that checks + `gtk_picture_get_paintable()` reads success while the pane shows an empty rectangle (the event + pane's preview did exactly that). `gdk_texture_new_from_filename()` decodes the same file on this + thread and `gtk_picture_set_paintable()` hands over the result -- and a NULL texture at least says + so. A picture that must be on screen is also a picture with a **height request**: `can_shrink` + alone asks for no height at all in a box. +- **Unparenting a popover inside its own `closed` emission is a use-after-free.** Dismissals go + through `gtk_popover_popdown()`, which emits `closed`; a handler that unparents the popover there + drops the reference the parent held, the widget dies, and GTK's own popdown path then runs on freed + memory — `gtk_popover_get_autohide: assertion 'GTK_IS_POPOVER (popover)' failed`, on every menu + entry that dismisses its popover, which is every entry there is. `on_popover_closed()` therefore + defers the unparent by one idle and holds a reference across it + (`g_idle_add_full(..., g_object_ref(popover), g_object_unref)`), so the popover outlives the + emission that dismissed it. - **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once Caps Lock is on. diff --git a/docs/claude/invariants-math-channels.md b/docs/claude/invariants-math-channels.md index 11064e5..5acc470 100644 --- a/docs/claude/invariants-math-channels.md +++ b/docs/claude/invariants-math-channels.md @@ -233,3 +233,19 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear `MathNodeRt` was measured and bought **nothing** -- at six nodes the whole runtime is L1-resident either way. It was kept for the shape, not for a number; do not expect a cache argument to pay at this size. + +## A gap is a gap in a filter's output, never the end of its state + +A reserved hole is NaN in the source channel, and a biquad that integrates it poisons `z1`/`z2` +for ever: every later sample comes out NaN, so a filtered trace died at the first drop and never +came back. Relative time made it systematic — a counter gap reserves NaN slots by construction, +where a stream-time gap is often absorbed by the stamps — but EMI caused it in both modes, and +the reading was unambiguous: the memoryless channels held 95% finite samples while every +`LowPass`/`HighPass`/`BandPass` channel held `finite=0`. + +`biquad_step()` therefore returns NaN **without touching the state words** when its input is not +finite. The hole stays visible in the filtered trace (the gap is the truth about the data), the +filter's history remains the last *real* samples it saw, and the trace resumes after the hole +with at most the transient a re-start costs. The exit dump's `math '': finite=N/M` line is +the assertion: `finite` must be about `M` minus the hole share, and `finite=0` beside non-zero +`hole_slots` is this defect returned. diff --git a/docs/claude/invariants-session-and-export.md b/docs/claude/invariants-session-and-export.md index 88d1a6c..8a12c53 100644 --- a/docs/claude/invariants-session-and-export.md +++ b/docs/claude/invariants-session-and-export.md @@ -52,3 +52,37 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear out — a source whose streamer was merely down during one run would silently vanish from the only list it can be picked from, for ever. Keeping the old file loses nothing: a run that configured nothing had nothing worth persisting. + +## A configuration is a named session, and a source may hold several + +One source, several arrangements: the same streamer serves different experiments, and retyping a +layout to look at the second one is how a session gets lost. Storage is therefore a **directory +per source** — `~/.config/udpscope/_/` — holding one `.toml` per configuration +plus an `active` file naming the one the source is on. `configuration_path()` is the only +expression of the scheme, and the name is `sanitize()`d exactly as the host is, so a name typed +with a slash cannot escape the directory. + +A configuration carries the whole session *and* the settings that can only be decided before the +first socket: `buffer_mb`, `relative_time`, `capture_packets`. The startup chooser applies them +(`apply_source()`), which works because `choose_source()` runs before `ScopeModel` is constructed +— the one moment a buffer size can be applied at all. Switching configurations inside a running +app applies what can change live and says in the status line that the rest takes effect on the +next start; pretending otherwise would be a configuration that half-applies. + +A configuration's *startup* settings reach the model from two directions, and both are needed: +`apply_source()` for a source picked in the chooser, and `apply_stored_settings()` for a source +named with `--server`, which never reaches the chooser at all. Without the second, a saved host's +buffer size, clock and capture ring were silently ignored — the layout still applied, because the +app loads that separately once a CONFIG names the signals — so an edit to any of the three read as +one that never took effect. **A flag the user typed wins over the file** (`--buffer-mb`, +`--relative-time`, `--capture-packets`): the file fills in what the command line did not say, and +`Config::buffer_mb_specified` is what tells "not asked" from "asked for the default". + +The `active` file is written on every save and by the chooser's pick, so "which configuration was +I on" survives a restart. A session written before configurations existed is **imported once** as +the configuration named `default`, and the old file is renamed to `_.toml.imported` +rather than deleted: an import that cannot be undone by hand is a migration nobody can back out +of. + +Nothing is lost by switching: the dialog saves the current session *before* loading the picked +one, which is the only ordering in which a switch cannot lose what it switches from. diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md index f74f703..51732a9 100644 --- a/docs/claude/invariants-trigger-and-pinning.md +++ b/docs/claude/invariants-trigger-and-pinning.md @@ -144,3 +144,134 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear `process()` uses that for `last_trigger_s_`, `pending_start_s_` **and** the pre-window gate — all three, or the marker, the cut and the gate would disagree with each other by the width of a pulse. + +## A latch is also a file + +The pane that lists the cache is **on demand**: the toolbar's event-history button and the pane are +collapsed until it is asked for, because a column of stale events beside a live plot is what a user +who has stopped looking at events does not want. What makes it *available* -- (recording is on) or +(an event is on screen) -- is decided in one place, `sync_events_availability()`, so the button and +the pane cannot disagree; a disabled cache with nothing being examined takes the button away *and* +collapses the pane, because a visible empty column is what "off" must not look like. Trigger mode is +deliberately **not** one of the conditions: a stored event being examined is a file, which has no +trigger, and tying the pane to the trigger would close the list under the click that opened an event. +The recording switch lives in the trigger panel, not in the pane it empties. + +## A comparison is two plots of one model + +*Compare* and *accumulate* are still one model -- the buffers cannot hold two independent loads -- but a +comparison is **two plots** over it, and which plot draws what is only a matter of which channel *names* +its rows bind. `App::rows` is the primary (`RowSet`, area, gestures); `App::rows_b` is the second, with +its own `RowSet` and its own hidden `PlotArea` in a paned, deliberately without the drop target, the +right-click menu or the frame counter: those act on the primary's rows, and a menu on the second plot +that rearranged the first plot's rows would be a menu lying about its subject. + +Rules that cost a defect each: + +- **The layout is the user's and is mirrored, never reinvented.** The group build must not call + `set_row_count()`; it reads the rows' slot names and re-names the same slots for each layer + (`… (A)`, `… (B)` for a comparison; a bare name for layer 0 and `… #2`, `… #3` for the extra layers of + an accumulation). An *accumulation* puts its extra layers into free slots -- the row that holds the + signal first, then any row with room -- so the user's traces never move. The default layout is four + signals in one row, which is why `kSlotsPerRow` is 8: with four, "accumulate these two events" had + nowhere to put the second copy and silently did nothing. +- **A layer is not a signal.** A comparison's model holds one channel per (signal, layer), and the + *panel* must not treat the copies as signals of their own: the signal list shows one row per signal + (`layer_base_name()`, the shared rule that takes `(A)`, `(B)` and `#N` back off), `signal_color_index()` + counts *signals* and not channels -- counting channels spread one signal's colour across the palette + -- and both plots of a comparison bind their signal's layers to the *same* colour index, because two + colours would say they were different signals. The reading is `list=4` in `[events] compare plots` + for a two-event comparison of four signals. +- **A slot name carries the last group's suffix, so it must be stripped before it is re-derived**: + after a comparison the rows are named `sine (A)`, and a following accumulation that mapped that name + again bound to no channel at all (`traces0=0`, a plot with nothing in it). `base_name()` takes + `(A)`, `(B)` and `#N` back off. +- **The axes are mirrored from the ranges, once per frame.** The plotter owns its window and a gesture + reaches it through the plot's own setter with nothing announcing it, so the frame compares each row's + signature with the previous frame's: whichever side moved is the side the user touched, and the other + is set to match. The signature is `{span, y.min, y.max}` when the x axis has a **live edge** + (`x_live_edge()`, not merely an armed follow: an armed axis with nothing streaming keeps a *stored* + window, whose edges a pan moves and whose span never changes) and `{x.min, x.max, y.min, y.max}` + otherwise. Comparing the edges under a live edge would read as "both sides moved" on every frame -- + the window is recomputed as a distance behind the newest sample -- and a zoom in one plot would be + overwritten by the other's drift. When both moved the first plot is the reference, which is also what + makes the frame after a build converge. +- **`set_y_range()` clears that axis' auto-scale**, by design: a mirrored zoom is a user decision, and + *Fit* re-arms it. + +## A group of events is one model, built once + +*Compare* and *accumulate* are the same machinery: several event files in **one** model, because the +buffers cannot hold two independent loads -- `load_waveform_into_model` calls `set_signals()`, which +rebuilds them, so a second load takes the first with it. So the group is built by hand: one channel per +(signal, layer) pair, `set_target_span()` to cover the whole group *before* `set_signals()` (or the +oldest samples are written past the ring's end), then all of the events pushed in one pass. A row is +one **signal** and its slots are the events, which is what makes a comparison like for like and an +accumulation a density; the trace alpha (0.35 while accumulating, 1 otherwise) is what turns agreement +into brightness. + +Two things every group must do, both of which cost a defect to learn: + +- **Stop the acquisition first.** A live producer keeps pushing into the model it was started with -- + and it pushes *four* stream signals into the group's *eight* channels, so the group's own samples are + the ones that go missing. The reading that catches it is the samples the busy channel holds + (`samples0` in the `[events] compare` line): it must be exactly the number of stamps pushed, not + that plus whatever the stream added. +- **Free the slots the group does not fill.** A row carries the previous view's binding, and a trace + left in slot 2 of a two-layer row is drawn as if it belonged to the group. + +*Compare* shifts the first event so its span *ends* where the second's *begins*, and fills the half a +sample does not belong to with **NaN** -- the plotter excludes non-finite samples from every series and +from every pyramid bucket, so a NaN is a gap and not a repeat, and no line is drawn across the join. + +**A replay changes the data and frames it once; it never rearranges the layout.** A row plots a signal +*name*, and an event's export carries the same names the stream does, so loading the event into the +model leaves every row, slot, colour and the row count pointing at the right signal by itself. So +`replay_event()` must **not** rebind the rows (`bind_channels_to_rows()`, which is what a freshly opened +*file* needs) -- that is the user's work, and a click on a list is not a request to rearrange it. The +window is restated **once, on open** (`apply_time_window(span)`, the only path allowed to change the +scale), so the whole event is framed the moment it is opened and the user's own zooms are left alone +afterwards; never per frame. The measurements, the cursors and the readouts then report the stored +event, because they read the model. What a replay *does* change is the model's contents and the trigger: +it leaves trigger mode, without which the display stays `Held` and draws the old snapshot. + +**A file view has nothing to acquire.** The signal list's acquire tick is *hidden* -- not disabled -- +whenever a file is on screen (`App::waveform_name` non-empty: a replay, a comparison, a `--open`run). +The samples are stored, "acquire this signal" is a stream's question, and a greyed tick invites the user +to look for a reason there is none for. Going live rebuilds the panel, so the ticks come back. + +**"The same length" is the same count at the same rate, not the same span to the sample.** Two events +this app recorded itself differ in span by the fraction of a sample the acquisition's window happened to +fill to (0.9999 s of a 1 s pre-window is normal). Demanding one sample interval made a comparison of two +just-recorded events refuse to open; the check allows four intervals or a millisecond, whichever is +larger, and the merged stamp grid carries the difference as a gap. + +**A replay must leave trigger mode.** `display_mode()` answers `Held` whenever the model holds a +snapshot, and a held display draws *the snapshot*, not the live channels -- so replaying an event with +trigger mode still armed loaded the file into the model and left the old capture on screen: a click +that looks like it did nothing, with the event sitting in the ring unread. The replay also frames the +view with `apply_time_window(report.span_s)`, so the click shows the whole event rather than the part +of it that fell inside the live window. + +The switch that decides recording belongs to the **trigger panel**, not to the pane it empties: it +is a trigger option (it decides whether a latch is written at all), and a pane must not be the only +place to reach the control that stops it. + + +Every published snapshot is written to the event cache while recording is on: its table (the *export* +format, so `--open` reads it back), a sidecar of what the list shows, and a preview drawn from the +samples. Three rules keep that honest: + +- **The record happens on the GTK thread, at the end of the tick that framed the snapshot**, not on + the producer. The snapshot is taken by `shared_ptr` (a latch landing mid-write cannot pull the data + away), and a `record_pending` flag defers the write to the end of the frame so the rows are already + bound to the event when the picture is drawn. Recording from the producer would put file I/O + between two `recv()` calls, which is the one thing the packet capture's design also refuses. +- **Nothing a latch does may be expensive.** The write is a `write_npy` of the event's own window and + a 480x160 raster; a 2 s, four-channel event is ~230 kB of picture and ~600 kB of samples. The pane + is refreshed, not rebuilt, and a failure to write is a line on stderr -- a cache that cannot be + written must not stop the scope. +- **The preview is drawn from the data, never captured from the window.** The plot is a `GtkGLArea`: + its ink is not in the widget's paint tree, so `gtk_snapshot_free_to_node()` on a widget paintable of + it returns NULL and the "screenshot" would be an empty file. Drawing it in the store also keeps that + class GTK-free, which is what makes the cache testable without a window. diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index c5cd77e..b8b645a 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -334,7 +334,7 @@ Two lines of the self-check output are assertions in disguise: floor are the two refusals, and because the probe emits `::clicked` straight at the widget it reaches the handler even when the button is insensitive, so `add above 0` there must leave `rows=` and `traces=` untouched rather than growing a ninth row. -- `header: window_button_w=W label_w=L bar_w=B` (stderr, once a second) — **`W` must be +- `header: window_button_w=W bar_w=B` (stderr, once a second) — **`W` must be a small constant and must not track `B`**: the window control is a header-bar child, and an expanding one is measured into the title's centre box, so it stretched from the label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that @@ -801,3 +801,181 @@ Measured output (port 8419): disarmed the trigger — so `placed=0` there is legitimate and asserts nothing about the search. The load-bearing `placed=` is the `[search-probe] marks` line, emitted a frame after the jump, which quotes the window the scope has actually moved to. + +## `[capture-probe]` + +`UDPSCOPE_PROBE_CAPTURE_MS` toggles the packet capture through the status-bar button's own +handler, then reads the ring file back off disk and prints one line: + +``` +[capture-probe] enabled=1 read_ok=1 path=/home/m/.cache/udpscope/127.0.0.1-9102/capture.bin slots=1000 stored=253 resident=253 cursor=253 torn=0 truncated=0 header_ok=1 first_seq=1 last_seq=253 payload=2031337 file=65576064 open_ms=2.9 t0=1791524811.925 +``` + +| field | value it must read | the defect a wrong reading names | +| --- | --- | --- | +| `enabled` | `1` after the probe's toggle; `0` when the open failed, with the reason on stderr | a toggle that sets a flag nobody acts on, or an open that failed silently | +| `read_ok`, `header_ok` | `1` | a file whose header does not round-trip — the format changed without the version bump | +| `stored` | datagrams since the toggle; equal to the exit dump's datagram count over the same interval | the producer storing more or fewer datagrams than the client handed it — a copy in the capture path, which the design forbids | +| `resident` | `min(stored, slots)`; equal to `slots` once wrapped | the ring reusing a slot before its `seq` was published — a torn slot that reads as a hole in history | +| `cursor` | `stored mod slots` | a cursor maintained out of step with `seq`; the file's hint would mislead the `xxd` user it exists for | +| `torn` | `0` | a slot whose bytes landed but whose `seq` never did *without* a crash — the release-store ordering is wrong | +| `truncated` | `0` | a datagram longer than `UDPS_MAX_DATAGRAM_BYTES` — the slot sizing disagrees with the client's receive buffer | +| `first_seq` | `1` on a fresh ring | the ordinal not starting where the file starts — a leftover file being appended to instead of renamed | +| `last_seq - first_seq` | `slots - 1` on a wrapped ring | ordering lost across the wrap: the newest slot is not the largest `seq` | +| `payload` | the bytes the wire delivered over the interval | a capture storing headers and not payloads, or truncating silently | +| `file` | `64 + slots * slot_bytes`, a constant for a given N | the reservation is short — which with `mmap` is the SIGBUS the design forbids | +| `open_ms` | single-digit ms on a local filesystem | a blocking filesystem between two `recv()` calls — the producer stall the popover's toggle costs | + +With a **failing** open (the cache path colliding with a plain file, a full disk), the line reads +`enabled=0 read_ok=0` with every count 0, the toggle returns to off on the next tick, and the +popover row reads `failed: ` — one stderr line, no retry storm against a full disk. + +## `[defaults]` and `[configs]` + +``` +[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01 +[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25 +[configs] before=default active=default +[configs] save-as=1 names=probe,default active=probe +[configs] round trip read=1 comment='written by the configs probe' buffer_mb=8 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 +[configs] after forget names=default +``` + +| field | value it must read | the defect a wrong reading names | +| --- | --- | --- | +| `[defaults] calibrate:` | `1 0 model=1 0` | an unseeded spin button: `gtk_spin_button_new_with_range()` leaves the value at the range minimum, so the offset opened at -1e12 and Apply wrote it | +| `[defaults] calibrated:` | `2.5 -1.25 model=2.5 -1.25` — the dialog and the model agreeing after a calibration was set | a dialog that opens at the identity while the trace is drawn through a gain: pressing Apply "confirms" the calibration away. The two halves are printed so the assertion is that they *match*, not that either is a particular number | +| `[defaults] trigger:` | `0.001 0.002 0 0.001 0 50 0.01` | the same, per trigger field; a level of `-1e12` is a number nobody chose | +| `[configs] names` | gains `probe`, and loses it after `forget` | a save that writes somewhere the listing does not read, or a delete that leaves the file | +| `[configs] active` | `probe` after the save-as | the `active` file not written, so the next start opens the wrong configuration | +| `[configs] round trip` | `read=1`, the note verbatim, `buffer_mb` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with | + +## `[rowmenu]` + +``` +[rowmenu] main 4 'Remove signal' h=18 label_x=26 accel_right=-1/174 ok=1 +[rowmenu] main rows=5 bad=0 labels=[Y axis: auto scale,Y axis: fixed range...,Fit X axis,Fit all,Remove signal] +[rowmenu] remove 0 'Back' h=18 label_x=52 accel_right=-1/178 ok=1 +[rowmenu] remove rows=6 bad=0 labels=[Back,sine,square,ramp,noise,All traces on this plot] +[rowmenu] summary main_rows=5 main_bad=0 remove_rows=6 remove_bad=0 after_back_rows=5 after_back_bad=0 second_rows=6 second_bad=0 remove_row_in_main=1 remove_row_gone=1 can_open_again=1 +[rowmenu] remove-clicked 'sine' trace_freed=1 +``` + +| field | value it must read | the defect a wrong reading names | +| --- | --- | --- | +| `h` | 18 for every row of both views | the plot menu reverting to Adwaita's 34 px buttons — the row style not applied (`popover.udpscope-menu`) | +| `label_x` | 26 for every row, 52 for **Back** | labels not sharing one column; the Back row's 52 is its leading icon, which every menu indents for | +| `accel_right`/`W` | equal when the row has a shortcut (`Fit all` → `F`), `-1` when it has none | the shortcut not flush right, or shown where the action has none | +| `remove_rows` | 1 per occupied trace slot, plus `All traces on this plot` when there is more than one, plus the **Back** row | a remove view built from the wrong row, or missing its way out | +| `remove_row_gone`, `remove_row_in_main` | 1 | a submenu that floats beside the menu instead of replacing it | +| `can_open_again` | 1 | the second click not reopening: the `GtkMenuButton`-owned submenu that collapsed and left its row dead | +| `bad` | 0 for every view | any row outside the compact style. Each view is measured a step after the click that swapped it in — in the same call the new child is unallocated (`h=0`), which reads like a style defect and is not one | +| `trace_freed` | 1 | a remove entry that dismisses the menu without taking the trace off the row | + +## `[sources]` + +``` +[sources] form: name='probe-host' note='made by the new-host probe' buffer=32 relative=1 connect_sensitive=1 +[sources] configuration created: 9.9.9.9:7000/probe-host note='made by the new-host probe' buffer=32 relative=1 +[sources] picked 9.9.9.9:7000 multicast= interface= buffer=32 relative=1 capture=0 config=probe-host note='made by the new-host probe' +[sources] edit form: name='probe-host' note='made by the new-host probe' buffer=32 relative=1 button='Save' +[sources] configuration saved: 9.9.9.9:7000/bench note='edited by the probe' buffer=16 relative=0 +[sources] 1 known: 9.9.9.9:7000/bench plots=0 signals=0 buffer=16 relative=0 capture=0 note='edited by the probe' | +``` + +| field | value it must read | the defect a wrong reading names | +| --- | --- | --- | +| `form:` | the name, note, buffer and clock the form was given | a form field that is not wired: the four things a host could not be given before | +| `connect_sensitive` | 1 once the server is typed | the button's validation not following the entry | +| `configuration created`/`saved` | the identity the form held, with the settings | a save that writes somewhere other than what the form shows | +| `edit form:` … `button='Save'` | the **existing** configuration's values, pre-filled | the edit path not reading the file — an edit form that opens empty edits nothing | +| `known:` after a rename | one row, under the new name | a rename that leaves the old file, so the chooser lists the same session twice | +| `known:` for a settings-only configuration | a row, though `signals=0` | the listing filter treating "no signals yet" as "nobody configured this", which hides every host the form set up | + +## `[sources] settings from configuration` + +``` +[sources] settings from configuration 'bench': buffer=64 relative=1 capture=500 +``` + +Printed by `apply_stored_settings()` when a source is named on the command line: the stored +configuration's startup settings, filled in where the command line was silent. A `buffer=` here +that disagrees with `--buffer-mb` is a flag being overruled by the file; a *missing* line, with a +configuration that carries settings, is the defect this exists for — a saved host opened with +`--server` ran on the defaults, so an edit to its buffer or clock never took effect. + +## `[sources] waveforms` and the waveform load + +``` +[sources] waveforms: 1 recent /tmp/rt.npy(ok) page=1 +[sources] picked waveform /tmp/rt.npy +[udpscope] waveform loaded: /tmp/rt.npy signals=4 samples=20001 span=2.000000s rate=10000 time=[-2.000000..0.000000] +``` + +| field | value it must read | the defect a wrong reading names | +| --- | --- | --- | +| `waveforms:` | one entry per remembered export, with `(ok)` or `(missing)`, and `page=1` | the Waveforms page not built, or a stale path offered as if it were openable | +| `picked waveform` | the selected path | a pick that carries no file: `--open` is what the mode reads | +| `signals=`, `samples=` | the export's `cols=` and `rows=` | a reader mis-slicing its records, or the columns bound to the wrong channels | +| `span=`, `rate=`, `time=[…]` | the exported window and rate, `[-window, 0]` | the plan not covering the whole file (the tail arrives as the whole picture) or the display epoch not pinned to the file's last stamp | +| `frames=0 packets=0` beside `channels=N` | 0 — no acquisition | a socket opened for a file, which would also make the starvation notice live | + +## `[drop-probe]` + +``` +[drop-probe] signals=4 channels=4 rows=4 slots0=[sine|||] +[drop-probe] dropped signal 0 -> 1 slots=[sine|sine||] +[drop-probe] dropped signal 1 -> 1 slots=[sine|sine|square|] +``` + +| field | value it must read | the defect a wrong reading names | +| --- | --- | --- | +| `rows` | the column count, capped at `RowSet::kMaxRows` — one row per trace | rows sized four signals to one row: the only row fills and *every* drop is refused ("plot 1 already holds four signals"), which reads as a scope that cannot plot anything | +| `dropped … -> 1` | 1, and the row gains that trace | a drop that cannot find a free slot, or a signal with no channel to bind | +| `channels` | one per signal | a file whose columns did not become channels, so nothing can be plotted at all | + +## `[events]` + +``` +[events] live, recording off: button_visible=0 pane=0 pane_w=0 tick=1 recording=0 +[events] recording on, no trigger: button_visible=1 pane=0 pane_w=0 tick=1 recording=1 +[events] after trigger on: button_visible=1 pane=0 pane_w=0 tick=1 recording=1 +[events] dir=/tmp/k5/cache/udpscope/127.0.0.1-21023/events n=1 newest: dur=2.000000 pre=1.000000 post=1.000000 edge=1.000100 signals=4 samples=20001 rate=10000 seq=1 thumb_bytes=230454 recorded=1 pane=1 +[events] activated row0: replaying=1 pane=1 pane_w=240 tick=0 window=2.000 thumbnail=1 thumb_size=126x42 thumb_visible=1 +[events] replayed: channels=4 samples=20001 replaying=1 waveform=1 +[events] live again: replaying=0 waveform=0 +[events] recording off, trigger off: button_visible=0 pane=0 pane_w=0 tick=1 recording=0 +[events] recording off, trigger on: button_visible=0 pane=0 pane_w=0 tick=1 recording=0 +[events] compare buttons: cached=2 selected=2 compare=1 accumulate=1 +[events] compare: kind=1 rows=1 channels=8 samples0=32964 traces0=4 traces1=-1 window=2.000 recording=1 pane=1 +[events] compare plots: area_b=1 rows_a=1 rows_b=1 list=4 x_a=[-2.000,0.000] x_b=[-2.000,0.000] same_x=1 +[events] compare sync: y_a=-3.500 y_b=-3.500 mirrored=1 +[events] accumulate: kind=2 rows=1 channels=8 samples0=32964 selected=2 traces0=8 window=2.000 +[events] group left: replaying=0 kind=0 waveform=0 +``` + +`thumb_size` is the built size of the row's picture (96x32 logical, plus the CSS border), so it moves with +the font and the display's scale factor; what it must never be is `0x0`. + +| field | value it must read | the defect a wrong reading names | +| --- | --- | --- | +| `n`, `stem`, `seq` | one entry per latch, named by its time and the run's counter | two events in one second overwriting each other, or a cache that is never written | +| `dur` | `pre + post` for the event's window | the snapshot's own window not recorded, so the list disagrees with the picture | +| `signals`, `samples` | the event's channels and rows | a table built from the *rows* instead of the snapshot: only what happened to be plotted | +| `thumb_bytes` | a real 24-bit BMP (`54 + 480*160*3`) | a preview that is empty or missing -- the widget snapshot of a `GtkGLArea`, which is NULL | +| `recorded` | 1 while the trigger panel's switch is on | the switch not wired, or recording defaulting off (it must be on: the cache is for the event you did not expect) | +| `pane` | 1 once the history button has been clicked | the button's handler not revealing the pane | +| `live` | `button_visible=0 pane=0** | a pane or a button on screen with no trigger mode: a column of nothing beside a live plot | +| `after trigger on` | `button_visible=1 pane=0` | the pane revealed by trigger mode alone (it must be on demand from the toolbar button), or the button missing in trigger mode | +| `recording off, trigger off` and `recording off, trigger on` | `button_visible=0 pane=0 recording=0` in both | the pane surviving its own switch being turned off -- the empty column the item exists to prevent -- or the button coming back with trigger mode while the cache is disabled | +| `thumbnail`, `thumb_size`, `thumb_visible` | 1, a non-zero size, and 1 | the picture loaded through `gtk_picture_set_filename()`, which fails silently (the paint-able is there, the allocation is there, nothing is drawn), or a `GtkPicture` given a size request and no cap -- its natural size is its content, so the row's text is squeezed off. `thumb_size` is the reading that catches both | +| `replaying` (last line) | 0 | a replay with no way back: *Live* must start a fresh client and thread | +| `pane_w` | the divider's position: 0 while the pane is closed, its remembered width (240 by default) while it is open | the pane reserving a column while closed (a paned hands its start child its minimum unless the position says otherwise), or a width that is not remembered | +| `tick` | 1 in live mode, **0** once a file is on screen | the signal list still offering "acquire this signal" for samples that are already stored | +| `window` (in `activated row0`) | the **event's** span -- the probe sets 0.5 s just before the click, so reading the event's span says opening it framed it | a replay that leaves the previous window alone, or one that restates it every frame instead of once | +| `compare buttons` | `selected=2` and both buttons sensitive | the buttons reading something other than the selection, or staying insensitive with a valid pair selected | +| `compare` | `kind=1 rows=1 channels=8 samples0=33045 traces0=4`, `pane=1` | `samples0` above the stamps pushed -- a live producer left running, pushing four stream signals into the group's eight channels; `rows=1` because the layout is the user's and must not be re-derived; `traces0=4` because the four slots the layout holds are renamed, not replaced | +| `compare plots` | `area_b=1 rows_a=1 rows_b=1 list=4 same_x=1` | no second plot on screen, a second plot with a different row count, two plots not on one x window, or `list` above the signal count -- the panel listing each event's copy of a signal as a signal of its own | +| `compare sync` | `mirrored=1`, with `y_a` and `y_b` equal to the range the probe set on the first plot | the mirror not running, or running the wrong way: a y (or x span) change in one plot that the other never sees | +| `accumulate` | `kind=2 rows=1 channels=8 samples0=33045 traces0=8 window=2.000` | the second event's layers not landing: `traces0` stays at the four signals the layout holds when the slots were not free, or the row named after a previous group's suffix so nothing binds | +| `group left` | `replaying=0 kind=0 waveform=0` | the group surviving *Live*: a file view with no file behind it | diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 44995bf..e667bfe 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -220,6 +220,159 @@ as distinct from the fault injection below, which exercises ingest: | **re-plan** | `--rate 100000` with the `mixed` flags, and `UDPSCOPE_PROBE_WINDOW_MS=4000 UDPSCOPE_PROBE_RESEQ_MS=6000 UDPSCOPE_EXIT_AFTER_MS=9000` | four `buffer rebuilt` lines before the reading, then `bands=64 bands_repaired=16 nan_slots=7800`: **a band is still only marked repaired when its own slots hold values**. A `bands_repaired == bands` beside a non-zero `nan_slots` here is the hole table and the producer's `tick_` surviving a re-plan that reset `produced()` — a band marked settled over samples that are all still NaN. The non-zero `overflows` is this case too: 100 kS/s with a 50 ms reorder delay keeps about 50 packets in flight against 64 slots | The re-plan case needs `--rate 100000`, not the other four's 10 000: at 10 kS/s the ring + +- `UDPSCOPE_PROBE_CAPTURE_MS` — toggles the packet capture **through the same handler the + status-bar button uses**, waits, then reads the file back off disk and prints one line: + `enabled= path= slots= stored= resident= cursor= torn= truncated= header_ok= first_seq= + last_seq= payload= file= open_ms= t0=`. `stored` is the datagrams-since-open counter and must + equal the dump's datagram count from the toggle's instant; `resident` is what the file holds, + which is the slot count once the ring has wrapped; `file` is the reserved size + `64 + slot_count * slot_bytes` and is a constant for a given N, so a mismatch against the file + on disk is a reading. Two cases, **run each as its own process**: + + 1. **enable at runtime** — the mock at `--rate 20000 --batch 200`, udpscope with + `UDPSCOPE_ENABLE_ALL_MS=700 UDPSCOPE_PROBE_CAPTURE_MS=1500 UDPSCOPE_EXIT_AFTER_MS=9000` + and no capture flag: `enabled=1 header_ok=1 torn=0 truncated=0 first_seq=1`, `stored` equal + to the datagrams since the toggle, `open_ms` a few ms. + 2. **wrap** — the same with `--capture-packets=16` and + `UDPSCOPE_PROBE_CAPTURE_MS=7000 UDPSCOPE_EXIT_AFTER_MS=10000`: `stored` (870 in the + reference run) exceeds `slot_count`, `resident=16`, `last_seq - first_seq == 15`, + `torn=0`, and `cursor` agrees with `seq` (counter mod slot count). A wrap that loses + ordering, or a `cursor` that disagrees, shows here and nowhere else. + + Then the converter over the wrap case's file: `tools/capture_to_pcap.py out.pcap` + must exit 0, print a record count equal to `slot_count` with `torn` 0, and the pcap's first + and last record timestamps must fall inside the run's wall-clock interval — both checked by a + `struct.unpack` of the pcap headers, nothing outside the standard library. + +- **A gap must not kill a filtered trace.** `--relative-time` with the mock dropping + (`--drop-every 20`) and `UDPSCOPE_PROBE_MATH_MS=2500 UDPSCOPE_ENABLE_ALL_MS=600 + UDPSCOPE_EXIT_AFTER_MS=9000`: every derived channel's dump line + `math '': finite=N/4000` must read about 3800 — the tail minus the reserved holes — + and **`finite=0` beside a non-zero `hole_slots` is the defect** (a biquad that integrated the + hole's NaN, permanently). The filters' `sd` must stay near the no-drop value; run the same + command without `--drop-every` as the control, where every `finite` reads 4000. +- `UDPSCOPE_PROBE_CONFIGS_MS` — saves the running session as a configuration named `probe` with a + note, through `save_configuration_as()` (the same entry point the dialog's button uses), then + reads it back off disk. Reading: `save-as=1`; `names=` gains `probe`; `active=probe`; and the + round trip must carry the note **and** the startup settings — + `comment='written by the configs probe' buffer_mb=8 relative=0 capture=0`. A `buffer_mb=0` there + is the per-source buffer size not being written, which is the one setting a user notices only + after a capture has been sized wrong. Run it with a throwaway `XDG_CONFIG_HOME`; the probe + deletes its own configuration at the end (`after forget names=…`). +- `UDPSCOPE_PROBE_ROWMENU_MS` — gives row 0 a trace, opens the plot's context menu with + `open_row_menu()` (the one entry point `on_right_click()` wraps) and walks its two views one step + at a time (`main` → click **Remove signal** → `remove` → click **Back** → `after-back` → click + **Remove signal** again → `second-open` → click a trace), measuring each view's rows: + `[rowmenu] N '